init depresiasi

This commit is contained in:
giovanni
2026-04-17 21:26:56 +07:00
parent a54c6184a2
commit fcde3b0a36
34 changed files with 3588 additions and 46 deletions
@@ -42,6 +42,9 @@ import (
type RepportService interface {
GetExpense(ctx *fiber.Ctx, params *validation.ExpenseQuery) ([]dto.RepportExpenseListDTO, int64, error)
GetExpenseDepreciation(ctx *fiber.Ctx) ([]dto.ExpenseDepreciationRowDTO, *dto.ExpenseDepreciationMetaDTO, error)
GetExpenseDepreciationManualInputs(ctx *fiber.Ctx) ([]dto.ExpenseDepreciationManualInputRowDTO, *dto.ExpenseDepreciationMetaDTO, error)
UpsertExpenseDepreciationManualInput(ctx *fiber.Ctx, req *validation.ExpenseDepreciationManualInputUpsert) (*dto.ExpenseDepreciationManualInputRowDTO, error)
GetMarketing(ctx *fiber.Ctx, params *validation.MarketingQuery) ([]dto.RepportMarketingItemDTO, int64, error)
GetPurchaseSupplier(ctx *fiber.Ctx, params *validation.PurchaseSupplierQuery) ([]dto.PurchaseSupplierDTO, int64, error)
GetDebtSupplier(ctx *fiber.Ctx, params *validation.DebtSupplierQuery) ([]dto.DebtSupplierDTO, int64, error)
@@ -56,12 +59,14 @@ type repportService struct {
Validate *validator.Validate
db *gorm.DB
ExpenseRealizationRepo expenseRepo.ExpenseRealizationRepository
ExpenseDepreciationRepo repportRepo.ExpenseDepreciationRepository
MarketingDeliveryRepo marketingRepo.MarketingDeliveryProductRepository
PurchaseRepo purchaseRepo.PurchaseRepository
ChickinRepo chickinRepo.ProjectChickinRepository
RecordingRepo recordingRepo.RecordingRepository
ApprovalSvc approvalService.ApprovalService
HppSvc approvalService.HppService
HppCostRepo commonRepo.HppCostRepository
PurchaseSupplierRepo repportRepo.PurchaseSupplierRepository
DebtSupplierRepo repportRepo.DebtSupplierRepository
HppPerKandangRepo repportRepo.HppPerKandangRepository
@@ -85,12 +90,14 @@ func NewRepportService(
db *gorm.DB,
validate *validator.Validate,
expenseRealizationRepo expenseRepo.ExpenseRealizationRepository,
expenseDepreciationRepo repportRepo.ExpenseDepreciationRepository,
marketingDeliveryRepo marketingRepo.MarketingDeliveryProductRepository,
purchaseRepo purchaseRepo.PurchaseRepository,
chickinRepo chickinRepo.ProjectChickinRepository,
recordingRepo recordingRepo.RecordingRepository,
approvalSvc approvalService.ApprovalService,
hppSvc approvalService.HppService,
hppCostRepo commonRepo.HppCostRepository,
purchaseSupplierRepo repportRepo.PurchaseSupplierRepository,
debtSupplierRepo repportRepo.DebtSupplierRepository,
hppPerKandangRepo repportRepo.HppPerKandangRepository,
@@ -105,12 +112,14 @@ func NewRepportService(
Validate: validate,
db: db,
ExpenseRealizationRepo: expenseRealizationRepo,
ExpenseDepreciationRepo: expenseDepreciationRepo,
MarketingDeliveryRepo: marketingDeliveryRepo,
PurchaseRepo: purchaseRepo,
ChickinRepo: chickinRepo,
RecordingRepo: recordingRepo,
ApprovalSvc: approvalSvc,
HppSvc: hppSvc,
HppCostRepo: hppCostRepo,
PurchaseSupplierRepo: purchaseSupplierRepo,
DebtSupplierRepo: debtSupplierRepo,
HppPerKandangRepo: hppPerKandangRepo,
@@ -164,6 +173,495 @@ func (s *repportService) GetExpense(c *fiber.Ctx, params *validation.ExpenseQuer
return result, total, nil
}
func (s *repportService) GetExpenseDepreciation(ctx *fiber.Ctx) ([]dto.ExpenseDepreciationRowDTO, *dto.ExpenseDepreciationMetaDTO, error) {
params, filters, err := s.parseExpenseDepreciationQuery(ctx)
if err != nil {
return nil, nil, err
}
if err := s.Validate.Struct(params); err != nil {
return nil, nil, fiber.NewError(fiber.StatusBadRequest, err.Error())
}
if s.ExpenseDepreciationRepo == nil {
return nil, nil, fiber.NewError(fiber.StatusInternalServerError, "expense depreciation repository is not configured")
}
location, err := time.LoadLocation("Asia/Jakarta")
if err != nil {
return nil, nil, fiber.NewError(fiber.StatusInternalServerError, "failed to load timezone configuration")
}
periodDate, err := time.ParseInLocation("2006-01-02", params.Period, location)
if err != nil {
return nil, nil, fiber.NewError(fiber.StatusBadRequest, "period must follow format YYYY-MM-DD")
}
candidateRows, err := s.ExpenseDepreciationRepo.GetCandidateFarms(
ctx.Context(),
periodDate,
params.AreaIDs,
params.LocationIDs,
params.ProjectFlockIDs,
)
if err != nil {
return nil, nil, err
}
limit := params.Limit
if limit <= 0 {
limit = 10
}
if len(candidateRows) == 0 {
meta := &dto.ExpenseDepreciationMetaDTO{
Page: params.Page,
Limit: limit,
TotalPages: 1,
TotalResults: 0,
Filters: filters,
}
return []dto.ExpenseDepreciationRowDTO{}, meta, nil
}
farmIDs := make([]uint, 0, len(candidateRows))
farmNameByID := make(map[uint]string, len(candidateRows))
for _, row := range candidateRows {
farmIDs = append(farmIDs, row.ProjectFlockID)
farmNameByID[row.ProjectFlockID] = row.FarmName
}
snapshots, err := s.ExpenseDepreciationRepo.GetSnapshotsByPeriodAndFarmIDs(ctx.Context(), periodDate, farmIDs)
if err != nil {
return nil, nil, err
}
snapshotByFarmID := make(map[uint]entity.FarmDepreciationSnapshot, len(snapshots))
for _, row := range snapshots {
snapshotByFarmID[row.ProjectFlockId] = row
}
missingFarmIDs := make([]uint, 0)
for _, farmID := range farmIDs {
if _, exists := snapshotByFarmID[farmID]; exists {
continue
}
missingFarmIDs = append(missingFarmIDs, farmID)
}
if len(missingFarmIDs) > 0 {
computedSnapshots, computeErr := s.computeExpenseDepreciationSnapshots(ctx.Context(), periodDate, missingFarmIDs, farmNameByID)
if computeErr != nil {
return nil, nil, computeErr
}
if len(computedSnapshots) > 0 {
if err := s.ExpenseDepreciationRepo.UpsertSnapshots(ctx.Context(), computedSnapshots); err != nil {
return nil, nil, err
}
for _, row := range computedSnapshots {
snapshotByFarmID[row.ProjectFlockId] = row
}
}
}
rows := make([]dto.ExpenseDepreciationRowDTO, 0, len(candidateRows))
for _, candidate := range candidateRows {
snapshot, exists := snapshotByFarmID[candidate.ProjectFlockID]
if !exists {
rows = append(rows, dto.ExpenseDepreciationRowDTO{
ProjectFlockID: int64(candidate.ProjectFlockID),
FarmName: candidate.FarmName,
Period: params.Period,
DepreciationPercentEffective: 0,
DepreciationValue: 0,
PulletCostDayNTotal: 0,
Components: map[string]any{},
})
continue
}
rows = append(rows, dto.ExpenseDepreciationRowDTO{
ProjectFlockID: int64(snapshot.ProjectFlockId),
FarmName: candidate.FarmName,
Period: params.Period,
DepreciationPercentEffective: snapshot.DepreciationPercentEffective,
DepreciationValue: snapshot.DepreciationValue,
PulletCostDayNTotal: snapshot.PulletCostDayNTotal,
Components: parseSnapshotComponents(snapshot.Components),
})
}
totalResults := int64(len(rows))
totalPages := int64(0)
if totalResults > 0 {
totalPages = int64(math.Ceil(float64(totalResults) / float64(limit)))
}
if totalPages == 0 {
totalPages = 1
}
offset := (params.Page - 1) * limit
if offset < 0 {
offset = 0
}
if offset > len(rows) {
offset = len(rows)
}
end := offset + limit
if end > len(rows) {
end = len(rows)
}
meta := &dto.ExpenseDepreciationMetaDTO{
Page: params.Page,
Limit: limit,
TotalPages: totalPages,
TotalResults: totalResults,
Filters: filters,
}
return rows[offset:end], meta, nil
}
func (s *repportService) GetExpenseDepreciationManualInputs(ctx *fiber.Ctx) ([]dto.ExpenseDepreciationManualInputRowDTO, *dto.ExpenseDepreciationMetaDTO, error) {
params, filters, err := s.parseExpenseDepreciationQuery(ctx)
if err != nil {
return nil, nil, err
}
if s.ExpenseDepreciationRepo == nil {
return nil, nil, fiber.NewError(fiber.StatusInternalServerError, "expense depreciation repository is not configured")
}
repoRows, err := s.ExpenseDepreciationRepo.GetLatestManualInputsByFarms(
ctx.Context(),
params.AreaIDs,
params.LocationIDs,
params.ProjectFlockIDs,
)
if err != nil {
return nil, nil, err
}
rows := make([]dto.ExpenseDepreciationManualInputRowDTO, 0, len(repoRows))
for _, row := range repoRows {
rows = append(rows, dto.ExpenseDepreciationManualInputRowDTO{
ID: int64(row.Id),
ProjectFlockID: int64(row.ProjectFlockID),
FarmName: row.FarmName,
TotalCost: row.TotalCost,
Note: row.Note,
})
}
limit := params.Limit
if limit <= 0 {
limit = 10
}
totalResults := int64(len(rows))
totalPages := int64(0)
if totalResults > 0 {
totalPages = int64(math.Ceil(float64(totalResults) / float64(limit)))
}
if totalPages == 0 {
totalPages = 1
}
offset := (params.Page - 1) * limit
if offset < 0 {
offset = 0
}
if offset > len(rows) {
offset = len(rows)
}
end := offset + limit
if end > len(rows) {
end = len(rows)
}
meta := &dto.ExpenseDepreciationMetaDTO{
Page: params.Page,
Limit: limit,
TotalPages: totalPages,
TotalResults: totalResults,
Filters: filters,
}
return rows[offset:end], meta, nil
}
func (s *repportService) UpsertExpenseDepreciationManualInput(ctx *fiber.Ctx, req *validation.ExpenseDepreciationManualInputUpsert) (*dto.ExpenseDepreciationManualInputRowDTO, error) {
if req == nil {
return nil, fiber.NewError(fiber.StatusBadRequest, "request is required")
}
if err := s.Validate.Struct(req); err != nil {
return nil, fiber.NewError(fiber.StatusBadRequest, err.Error())
}
if s.ExpenseDepreciationRepo == nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, "expense depreciation repository is not configured")
}
row := entity.FarmDepreciationManualInput{
ProjectFlockId: req.ProjectFlockID,
TotalCost: req.TotalCost,
Note: req.Note,
}
if err := s.ExpenseDepreciationRepo.UpsertManualInput(ctx.Context(), &row); err != nil {
return nil, err
}
response := &dto.ExpenseDepreciationManualInputRowDTO{
ID: int64(row.Id),
ProjectFlockID: int64(row.ProjectFlockId),
TotalCost: row.TotalCost,
Note: row.Note,
}
listRows, listErr := s.ExpenseDepreciationRepo.GetLatestManualInputsByFarms(
ctx.Context(),
nil,
nil,
[]int64{int64(row.ProjectFlockId)},
)
if listErr == nil {
for _, listRow := range listRows {
if listRow.ProjectFlockID == row.ProjectFlockId {
response.FarmName = listRow.FarmName
break
}
}
}
return response, nil
}
type depreciationKandangComponent struct {
ProjectFlockKandangID uint `json:"project_flock_kandang_id"`
KandangID uint `json:"kandang_id"`
KandangName string `json:"kandang_name"`
TransferID uint `json:"transfer_id"`
TransferDate string `json:"transfer_date"`
SourceProjectFlockID uint `json:"source_project_flock_id"`
HouseType string `json:"house_type"`
DayN int `json:"day_n"`
DepreciationPercent float64 `json:"depreciation_percent"`
TransferQty float64 `json:"transfer_qty"`
PulletCostDayN float64 `json:"pullet_cost_day_n"`
DepreciationValue float64 `json:"depreciation_value"`
}
type depreciationFarmComponents struct {
KandangCount int `json:"kandang_count"`
Kandang []depreciationKandangComponent `json:"kandang"`
}
func (s *repportService) computeExpenseDepreciationSnapshots(
ctx context.Context,
periodDate time.Time,
farmIDs []uint,
farmNameByID map[uint]string,
) ([]entity.FarmDepreciationSnapshot, error) {
if len(farmIDs) == 0 {
return []entity.FarmDepreciationSnapshot{}, nil
}
inputRows, err := s.ExpenseDepreciationRepo.GetLatestTransferInputsByFarms(ctx, periodDate, farmIDs)
if err != nil {
return nil, err
}
groupedByFarm := make(map[uint][]repportRepo.FarmDepreciationLatestTransferRow, len(farmIDs))
houseTypeSet := make(map[string]struct{})
maxDay := 0
for _, row := range inputRows {
groupedByFarm[row.ProjectFlockID] = append(groupedByFarm[row.ProjectFlockID], row)
dayN := depreciationDayNumber(row.TransferDate, periodDate)
if dayN > maxDay {
maxDay = dayN
}
houseType := strings.TrimSpace(strings.ToLower(valueOrEmptyString(row.HouseType)))
if houseType != "" {
houseTypeSet[houseType] = struct{}{}
}
}
houseTypes := make([]string, 0, len(houseTypeSet))
for houseType := range houseTypeSet {
houseTypes = append(houseTypes, houseType)
}
sort.Strings(houseTypes)
percentByHouseType, err := s.ExpenseDepreciationRepo.GetDepreciationPercents(ctx, houseTypes, maxDay)
if err != nil {
return nil, err
}
type sourceCostCacheItem struct {
totalDepCost float64
}
sourceCostCache := make(map[string]sourceCostCacheItem)
sourcePopulationCache := make(map[uint]float64)
result := make([]entity.FarmDepreciationSnapshot, 0, len(farmIDs))
for _, farmID := range farmIDs {
farmRows := groupedByFarm[farmID]
components := depreciationFarmComponents{
KandangCount: len(farmRows),
Kandang: make([]depreciationKandangComponent, 0, len(farmRows)),
}
totalDepreciationValue := 0.0
totalPulletCostDayN := 0.0
for _, row := range farmRows {
dayN := depreciationDayNumber(row.TransferDate, periodDate)
houseType := strings.TrimSpace(strings.ToLower(valueOrEmptyString(row.HouseType)))
transferDateKey := row.TransferDate.Format("2006-01-02")
cacheKey := fmt.Sprintf("%d|%s", row.SourceProjectFlockID, transferDateKey)
cached, exists := sourceCostCache[cacheKey]
if !exists {
endOfDay := row.TransferDate.Add(24 * time.Hour)
sourceDepCost, calcErr := s.HppSvc.GetTotalDepresiasiFlockGrowing(row.SourceProjectFlockID, &endOfDay)
if calcErr != nil {
return nil, calcErr
}
cached = sourceCostCacheItem{totalDepCost: sourceDepCost}
sourceCostCache[cacheKey] = cached
}
sourcePopulation, popExists := sourcePopulationCache[row.SourceProjectFlockID]
if !popExists {
if s.HppCostRepo == nil {
sourcePopulation = 0
} else {
kandangIDs, idsErr := s.HppCostRepo.GetProjectFlockKandangIDs(ctx, row.SourceProjectFlockID)
if idsErr != nil {
return nil, idsErr
}
population, popErr := s.HppCostRepo.GetTotalPopulation(ctx, kandangIDs)
if popErr != nil {
return nil, popErr
}
sourcePopulation = population
}
sourcePopulationCache[row.SourceProjectFlockID] = sourcePopulation
}
initialPulletCost := 0.0
if sourcePopulation > 0 {
initialPulletCost = (cached.totalDepCost * row.TransferQty) / sourcePopulation
}
pulletCostDayN, depreciationValue, depreciationPercent := calculateDepreciationAtDayN(
initialPulletCost,
dayN,
houseType,
percentByHouseType,
)
totalPulletCostDayN += pulletCostDayN
totalDepreciationValue += depreciationValue
components.Kandang = append(components.Kandang, depreciationKandangComponent{
ProjectFlockKandangID: row.ProjectFlockKandangID,
KandangID: row.KandangID,
KandangName: row.KandangName,
TransferID: row.TransferID,
TransferDate: row.TransferDate.Format("2006-01-02"),
SourceProjectFlockID: row.SourceProjectFlockID,
HouseType: houseType,
DayN: dayN,
DepreciationPercent: depreciationPercent,
TransferQty: row.TransferQty,
PulletCostDayN: pulletCostDayN,
DepreciationValue: depreciationValue,
})
}
effectivePercent := 0.0
effectivePercent = calculateEffectiveDepreciationPercent(totalDepreciationValue, totalPulletCostDayN)
componentsJSON, marshalErr := json.Marshal(components)
if marshalErr != nil {
return nil, marshalErr
}
result = append(result, entity.FarmDepreciationSnapshot{
ProjectFlockId: farmID,
PeriodDate: periodDate,
DepreciationPercentEffective: effectivePercent,
DepreciationValue: totalDepreciationValue,
PulletCostDayNTotal: totalPulletCostDayN,
Components: componentsJSON,
})
}
return result, nil
}
func depreciationDayNumber(transferDate time.Time, periodDate time.Time) int {
transfer := time.Date(transferDate.Year(), transferDate.Month(), transferDate.Day(), 0, 0, 0, 0, transferDate.Location())
period := time.Date(periodDate.Year(), periodDate.Month(), periodDate.Day(), 0, 0, 0, 0, periodDate.Location())
if period.Before(transfer) {
return 0
}
return int(period.Sub(transfer).Hours()/24) + 1
}
func calculateDepreciationAtDayN(
initialPulletCost float64,
dayN int,
houseType string,
percentByHouseType map[string]map[int]float64,
) (float64, float64, float64) {
if initialPulletCost <= 0 || dayN <= 0 || houseType == "" {
return 0, 0, 0
}
housePercent, exists := percentByHouseType[houseType]
if !exists {
return 0, 0, 0
}
current := initialPulletCost
pulletCostDayN := 0.0
depreciationValue := 0.0
depreciationPercent := 0.0
for day := 1; day <= dayN; day++ {
pct := housePercent[day]
dep := current * (pct / 100)
if day == dayN {
pulletCostDayN = current
depreciationValue = dep
depreciationPercent = pct
}
current -= dep
if current < 0 {
current = 0
}
}
return pulletCostDayN, depreciationValue, depreciationPercent
}
func calculateEffectiveDepreciationPercent(totalDepreciationValue, totalPulletCostDayN float64) float64 {
if totalPulletCostDayN <= 0 {
return 0
}
return (totalDepreciationValue / totalPulletCostDayN) * 100
}
func parseSnapshotComponents(raw []byte) any {
if len(raw) == 0 {
return map[string]any{}
}
var out any
if err := json.Unmarshal(raw, &out); err != nil {
return map[string]any{}
}
return out
}
func valueOrEmptyString(v *string) string {
if v == nil {
return ""
}
return *v
}
func (s *repportService) GetMarketing(c *fiber.Ctx, params *validation.MarketingQuery) ([]dto.RepportMarketingItemDTO, int64, error) {
if err := s.Validate.Struct(params); err != nil {
return nil, 0, err
@@ -2133,6 +2631,84 @@ func (s *repportService) parseHppPerKandangQuery(ctx *fiber.Ctx) (*validation.Hp
return params, filters, nil
}
func (s *repportService) parseExpenseDepreciationQuery(ctx *fiber.Ctx) (*validation.ExpenseDepreciationQuery, dto.ExpenseDepreciationFiltersDTO, error) {
page := ctx.QueryInt("page", 1)
if page < 1 {
page = 1
}
limit := ctx.QueryInt("limit", 10)
if limit < 1 {
limit = 10
}
rawArea := ctx.Query("area_id", "")
rawLocation := ctx.Query("location_id", "")
rawProjectFlock := ctx.Query("project_flock_id", "")
period := strings.TrimSpace(ctx.Query("period", ""))
areaIDs, err := parseCommaSeparatedInt64s(rawArea)
if err != nil {
return nil, dto.ExpenseDepreciationFiltersDTO{}, fiber.NewError(fiber.StatusBadRequest, err.Error())
}
locationIDs, err := parseCommaSeparatedInt64s(rawLocation)
if err != nil {
return nil, dto.ExpenseDepreciationFiltersDTO{}, fiber.NewError(fiber.StatusBadRequest, err.Error())
}
projectFlockIDs, err := parseCommaSeparatedInt64s(rawProjectFlock)
if err != nil {
return nil, dto.ExpenseDepreciationFiltersDTO{}, fiber.NewError(fiber.StatusBadRequest, err.Error())
}
locationScope, err := m.ResolveLocationScope(ctx, s.ExpenseRealizationRepo.DB())
if err != nil {
return nil, dto.ExpenseDepreciationFiltersDTO{}, err
}
areaScope, err := m.ResolveAreaScope(ctx, s.ExpenseRealizationRepo.DB())
if err != nil {
return nil, dto.ExpenseDepreciationFiltersDTO{}, err
}
if locationScope.Restrict {
allowed := toInt64Slice(locationScope.IDs)
if len(allowed) == 0 {
locationIDs = []int64{-1}
} else if len(locationIDs) > 0 {
locationIDs = intersectInt64(locationIDs, allowed)
} else {
locationIDs = allowed
}
}
if areaScope.Restrict {
allowed := toInt64Slice(areaScope.IDs)
if len(allowed) == 0 {
areaIDs = []int64{-1}
} else if len(areaIDs) > 0 {
areaIDs = intersectInt64(areaIDs, allowed)
} else {
areaIDs = allowed
}
}
params := &validation.ExpenseDepreciationQuery{
Page: page,
Limit: limit,
Period: period,
ProjectFlockIDs: projectFlockIDs,
AreaIDs: areaIDs,
LocationIDs: locationIDs,
}
filters := dto.NewExpenseDepreciationFiltersDTO(
rawArea,
rawLocation,
rawProjectFlock,
period,
)
return params, filters, nil
}
func parseCommaSeparatedInt64s(raw string) ([]int64, error) {
raw = strings.TrimSpace(raw)
if raw == "" {