From 3422fceec759cf140ac7eef58b2a166daea122fa Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Sun, 11 Jan 2026 20:10:19 +0700 Subject: [PATCH 01/76] feat(BE-ExpenseApproval): add unit vice president approval step and permissions --- internal/middleware/permissions.go | 25 +++++++------- .../controllers/expense.controller.go | 2 ++ internal/modules/expenses/route.go | 3 ++ .../expenses/services/expense.service.go | 17 +++++++--- .../services/transfer_expense_bridge.go | 34 +++++++------------ .../purchases/services/expense_bridge.go | 3 ++ internal/utils/constant.go | 24 +++++++------ 7 files changed, 60 insertions(+), 48 deletions(-) diff --git a/internal/middleware/permissions.go b/internal/middleware/permissions.go index e9148927..5820db27 100644 --- a/internal/middleware/permissions.go +++ b/internal/middleware/permissions.go @@ -19,18 +19,19 @@ const ( ) const ( - P_ExpenseGetAll = "lti.expense.list" - P_ExpenseCreateOne = "lti.expense.create" - P_ExpenseUpdateOne = "lti.expense.update" - P_ExpenseGetOne = "lti.expense.detail" - P_ExpenseDeleteOne = "lti.expense.delete" - P_ExpenseApprovalManager = "lti.expense.approve.manager" - P_ExpenseApprovalFinance = "lti.expense.approve.finance" - P_ExpenseCreateRealizations = "lti.expense.create.realization" - P_ExpenseUpdateRealizations = "lti.expense.update.realization" - P_ExpenseCompleteExpense = "lti.expense.complete.expense" - P_ExpenseDocument = "lti.expense.document" - P_ExpenseDocumentRealizations = "lti.expense.document.realization" + P_ExpenseGetAll = "lti.expense.list" + P_ExpenseCreateOne = "lti.expense.create" + P_ExpenseUpdateOne = "lti.expense.update" + P_ExpenseGetOne = "lti.expense.detail" + P_ExpenseDeleteOne = "lti.expense.delete" + P_ExpenseApprovalManager = "lti.expense.approve.manager" + P_ExpenseApprovalFinance = "lti.expense.approve.finance" + P_ExpenseApprovalUnitVicePresident = "lti.expense.approve.unit_vice_president" + P_ExpenseCreateRealizations = "lti.expense.create.realization" + P_ExpenseUpdateRealizations = "lti.expense.update.realization" + P_ExpenseCompleteExpense = "lti.expense.complete.expense" + P_ExpenseDocument = "lti.expense.document" + P_ExpenseDocumentRealizations = "lti.expense.document.realization" ) const ( P_AdjustmentGetAll = "lti.inventory.list" diff --git a/internal/modules/expenses/controllers/expense.controller.go b/internal/modules/expenses/controllers/expense.controller.go index 666642ca..125aeb0c 100644 --- a/internal/modules/expenses/controllers/expense.controller.go +++ b/internal/modules/expenses/controllers/expense.controller.go @@ -233,6 +233,8 @@ func (u *ExpenseController) Approval(c *fiber.Ctx) error { approvalType = "manager" } else if strings.Contains(path, "/approvals/finance") { approvalType = "finance" + } else if strings.Contains(path, "/approvals/unit-vice-president") { + approvalType = "unit-vice-president" } else { return fiber.NewError(fiber.StatusBadRequest, "Invalid approval path") } diff --git a/internal/modules/expenses/route.go b/internal/modules/expenses/route.go index 9c22bde3..cfb4dd23 100644 --- a/internal/modules/expenses/route.go +++ b/internal/modules/expenses/route.go @@ -27,8 +27,11 @@ func ExpenseRoutes(v1 fiber.Router, u user.UserService, s expense.ExpenseService route.Get("/:id", m.RequirePermissions(m.P_ExpenseGetOne), ctrl.GetOne) route.Patch("/:id", m.RequirePermissions(m.P_ExpenseUpdateOne), ctrl.UpdateOne) route.Delete("/:id", m.RequirePermissions(m.P_ExpenseDeleteOne), ctrl.DeleteOne) + route.Post("/approvals/manager", m.RequirePermissions(m.P_ExpenseApprovalManager), ctrl.Approval) route.Post("/approvals/finance", m.RequirePermissions(m.P_ExpenseApprovalFinance), ctrl.Approval) + route.Post("/approvals/unit-vice-president", m.RequirePermissions(m.P_ExpenseApprovalUnitVicePresident), ctrl.Approval) + route.Post("/:id/realizations", m.RequirePermissions(m.P_ExpenseCreateRealizations), ctrl.CreateRealization) route.Patch("/:id/realizations", m.RequirePermissions(m.P_ExpenseUpdateRealizations), ctrl.UpdateRealization) route.Post("/:id/complete", m.RequirePermissions(m.P_ExpenseCompleteExpense), ctrl.CompleteExpense) diff --git a/internal/modules/expenses/services/expense.service.go b/internal/modules/expenses/services/expense.service.go index 3e50da26..8afbac28 100644 --- a/internal/modules/expenses/services/expense.service.go +++ b/internal/modules/expenses/services/expense.service.go @@ -1055,15 +1055,24 @@ func (s *expenseService) Approval(c *fiber.Ctx, req *validation.ApprovalRequest, if latestApproval.StepNumber != uint16(utils.ExpenseStepPengajuan) { currentStepName := utils.ExpenseApprovalSteps[approvalutils.ApprovalStep(latestApproval.StepNumber)] return fiber.NewError(fiber.StatusBadRequest, - fmt.Sprintf("Cannot process at Manager step. Latest approval is at %s step. Expected previous step: Pengajuan", currentStepName)) + fmt.Sprintf("Cannot process at Head Area step. Latest approval is at %s step. Expected previous step: Pengajuan", currentStepName)) } - } else if approvalType == "finance" { + } else if approvalType == "unit-vice-president" { - stepNumber = utils.ExpenseStepFinance + stepNumber = utils.ExpenseStepUnitVicePresident if latestApproval.StepNumber != uint16(utils.ExpenseStepManager) { currentStepName := utils.ExpenseApprovalSteps[approvalutils.ApprovalStep(latestApproval.StepNumber)] return fiber.NewError(fiber.StatusBadRequest, - fmt.Sprintf("Cannot process at Finance step. Latest approval is at %s step. Expected previous step: Manager", currentStepName)) + fmt.Sprintf("Cannot process at Unit Vice President step. Latest approval is at %s step. Expected previous step: Head Area", currentStepName)) + } + + } else if approvalType == "finance" { + + stepNumber = utils.ExpenseStepFinance + if latestApproval.StepNumber != uint16(utils.ExpenseStepUnitVicePresident) { + currentStepName := utils.ExpenseApprovalSteps[approvalutils.ApprovalStep(latestApproval.StepNumber)] + return fiber.NewError(fiber.StatusBadRequest, + fmt.Sprintf("Cannot process at Finance step. Latest approval is at %s step. Expected previous step: Unit Vice President", currentStepName)) } } else { return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Invalid approval type: %v", approvalType)) diff --git a/internal/modules/inventory/transfers/services/transfer_expense_bridge.go b/internal/modules/inventory/transfers/services/transfer_expense_bridge.go index 90350c18..d4322be6 100644 --- a/internal/modules/inventory/transfers/services/transfer_expense_bridge.go +++ b/internal/modules/inventory/transfers/services/transfer_expense_bridge.go @@ -39,12 +39,12 @@ type TransferExpenseReceivingPayload struct { } type groupedTransferItem struct { - detail *entity.StockTransferDetail - payload TransferExpenseReceivingPayload - projectFK *uint - kandangID *uint - totalPrice float64 - shippingCostTotal float64 + detail *entity.StockTransferDetail + payload TransferExpenseReceivingPayload + projectFK *uint + kandangID *uint + totalPrice float64 + shippingCostTotal float64 } func groupingKey(supplierID uint, date time.Time, warehouseID uint) string { @@ -84,7 +84,6 @@ func (b *transferExpenseBridge) OnItemsDeleted(ctx context.Context, _ uint64, it expenseIDs := make(map[uint64]struct{}) expenseNonstockIDs := make([]uint64, 0) - for _, item := range items { if item.ExpenseNonstockId != nil && *item.ExpenseNonstockId != 0 { expenseNonstockIDs = append(expenseNonstockIDs, *item.ExpenseNonstockId) @@ -92,7 +91,7 @@ func (b *transferExpenseBridge) OnItemsDeleted(ctx context.Context, _ uint64, it } if len(expenseNonstockIDs) > 0 { - + for _, nsID := range expenseNonstockIDs { var expenseID uint64 if err := tx.Model(&entity.ExpenseNonstock{}). @@ -106,13 +105,11 @@ func (b *transferExpenseBridge) OnItemsDeleted(ctx context.Context, _ uint64, it } } - if err := tx.Delete(&entity.ExpenseNonstock{}, expenseNonstockIDs).Error; err != nil { return err } } - approvalRepoTx := commonRepo.NewApprovalRepository(tx) for expenseID := range expenseIDs { var count int64 @@ -122,7 +119,6 @@ func (b *transferExpenseBridge) OnItemsDeleted(ctx context.Context, _ uint64, it return err } - if count == 0 { if err := approvalRepoTx.DeleteByTarget(ctx, utils.ApprovalWorkflowExpense.String(), uint(expenseID)); err != nil { return err @@ -220,7 +216,6 @@ func (b *transferExpenseBridge) createExpenseViaService( for _, gi := range items { note := fmt.Sprintf("stock_transfer_detail:%d", gi.detail.Id) - price := gi.shippingCostTotal if gi.payload.TransportPerItem != nil { price = *gi.payload.TransportPerItem * gi.payload.DeliveredQty @@ -228,7 +223,7 @@ func (b *transferExpenseBridge) createExpenseViaService( costItems = append(costItems, expenseValidation.CostItem{ NonstockID: expeditionNonstockID, - Quantity: 1, + Quantity: 1, Price: price, Notes: note, }) @@ -251,7 +246,6 @@ func (b *transferExpenseBridge) createExpenseViaService( return nil, err } - action := entity.ApprovalActionApproved actorID := uint(transfer.CreatedBy) if actorID == 0 { @@ -261,6 +255,9 @@ func (b *transferExpenseBridge) createExpenseViaService( if _, err := approvalSvc.CreateApproval(ctx, utils.ApprovalWorkflowExpense, uint(detail.Id), utils.ExpenseStepManager, &action, actorID, nil); err != nil { return nil, err } + if _, err := approvalSvc.CreateApproval(ctx, utils.ApprovalWorkflowExpense, uint(detail.Id), utils.ExpenseStepUnitVicePresident, &action, actorID, nil); err != nil { + return nil, err + } if _, err := approvalSvc.CreateApproval(ctx, utils.ApprovalWorkflowExpense, uint(detail.Id), utils.ExpenseStepFinance, &action, actorID, nil); err != nil { return nil, err } @@ -328,7 +325,6 @@ func (b *transferExpenseBridge) OnItemsDelivered(c *fiber.Ctx, transferID uint64 ctx := c.Context() - transfer, err := b.transferRepo.GetByID(ctx, uint(transferID), func(db *gorm.DB) *gorm.DB { return db. Preload("Details"). @@ -348,11 +344,10 @@ func (b *transferExpenseBridge) OnItemsDelivered(c *fiber.Ctx, transferID uint64 for i := range transfer.Details { detailMap[transfer.Details[i].Id] = &transfer.Details[i] - for _, deliveryItem := range transfer.Details[i].DeliveryItems { if deliveryItem.StockTransferDelivery != nil { shippingCostMap[transfer.Details[i].Id] = deliveryItem.StockTransferDelivery.ShippingCostTotal - break + break } } } @@ -395,17 +390,14 @@ func (b *transferExpenseBridge) OnItemsDelivered(c *fiber.Ctx, transferID uint64 } } - shippingCostTotal := shippingCostMap[detail.Id] - totalPrice := shippingCostTotal if payload.TransportPerItem != nil { - + totalPrice = *payload.TransportPerItem * payload.DeliveredQty } - warehouseID := uint(payload.WarehouseID) if warehouseID == 0 && transfer.ToWarehouse != nil { warehouseID = uint(transfer.ToWarehouse.Id) diff --git a/internal/modules/purchases/services/expense_bridge.go b/internal/modules/purchases/services/expense_bridge.go index 23b95c58..094b99c1 100644 --- a/internal/modules/purchases/services/expense_bridge.go +++ b/internal/modules/purchases/services/expense_bridge.go @@ -621,6 +621,9 @@ func (b *expenseBridge) createExpenseViaService( if _, err := approvalSvc.CreateApproval(ctx, utils.ApprovalWorkflowExpense, uint(detail.Id), utils.ExpenseStepManager, &action, actorID, nil); err != nil { return nil, err } + if _, err := approvalSvc.CreateApproval(ctx, utils.ApprovalWorkflowExpense, uint(detail.Id), utils.ExpenseStepUnitVicePresident, &action, actorID, nil); err != nil { + return nil, err + } if _, err := approvalSvc.CreateApproval(ctx, utils.ApprovalWorkflowExpense, uint(detail.Id), utils.ExpenseStepFinance, &action, actorID, nil); err != nil { return nil, err } diff --git a/internal/utils/constant.go b/internal/utils/constant.go index 6ec50447..44c79e35 100644 --- a/internal/utils/constant.go +++ b/internal/utils/constant.go @@ -354,20 +354,22 @@ var MarketingApprovalSteps = map[approvalutils.ApprovalStep]string{ // ------------------------------------------------------------------- const ( - ApprovalWorkflowExpense approvalutils.ApprovalWorkflowKey = approvalutils.ApprovalWorkflowKey("EXPENSES") - ExpenseStepPengajuan approvalutils.ApprovalStep = 1 - ExpenseStepManager approvalutils.ApprovalStep = 2 - ExpenseStepFinance approvalutils.ApprovalStep = 3 - ExpenseStepRealisasi approvalutils.ApprovalStep = 4 - ExpenseStepSelesai approvalutils.ApprovalStep = 5 + ApprovalWorkflowExpense approvalutils.ApprovalWorkflowKey = approvalutils.ApprovalWorkflowKey("EXPENSES") + ExpenseStepPengajuan approvalutils.ApprovalStep = 1 + ExpenseStepManager approvalutils.ApprovalStep = 2 + ExpenseStepUnitVicePresident approvalutils.ApprovalStep = 3 + ExpenseStepFinance approvalutils.ApprovalStep = 4 + ExpenseStepRealisasi approvalutils.ApprovalStep = 5 + ExpenseStepSelesai approvalutils.ApprovalStep = 6 ) var ExpenseApprovalSteps = map[approvalutils.ApprovalStep]string{ - ExpenseStepPengajuan: "Pengajuan", - ExpenseStepManager: "Approval Manager", - ExpenseStepFinance: "Approval Finance", - ExpenseStepRealisasi: "Realisasi", - ExpenseStepSelesai: "Selesai", + ExpenseStepPengajuan: "Pengajuan", + ExpenseStepManager: "Approval Head Area", + ExpenseStepUnitVicePresident: "Approval Business Unit Vice President", + ExpenseStepFinance: "Approval Finance", + ExpenseStepRealisasi: "Realisasi", + ExpenseStepSelesai: "Selesai", } // ------------------------------------------------------------------- From 9515848d8ff86b438eb685c29a6c39d4e8141ca4 Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Mon, 12 Jan 2026 10:11:31 +0700 Subject: [PATCH 02/76] feat(BE): update approval flow to use head area instead of manager --- internal/middleware/permissions.go | 2 +- internal/modules/expenses/controllers/expense.controller.go | 4 ++-- internal/modules/expenses/route.go | 2 +- internal/modules/expenses/services/expense.service.go | 6 +++--- .../inventory/transfers/services/transfer_expense_bridge.go | 2 +- internal/modules/purchases/services/expense_bridge.go | 2 +- internal/utils/constant.go | 4 ++-- 7 files changed, 11 insertions(+), 11 deletions(-) diff --git a/internal/middleware/permissions.go b/internal/middleware/permissions.go index 5820db27..6e4fe6db 100644 --- a/internal/middleware/permissions.go +++ b/internal/middleware/permissions.go @@ -24,7 +24,7 @@ const ( P_ExpenseUpdateOne = "lti.expense.update" P_ExpenseGetOne = "lti.expense.detail" P_ExpenseDeleteOne = "lti.expense.delete" - P_ExpenseApprovalManager = "lti.expense.approve.manager" + P_ExpenseApprovalHeadArea = "lti.expense.approve.head_area" P_ExpenseApprovalFinance = "lti.expense.approve.finance" P_ExpenseApprovalUnitVicePresident = "lti.expense.approve.unit_vice_president" P_ExpenseCreateRealizations = "lti.expense.create.realization" diff --git a/internal/modules/expenses/controllers/expense.controller.go b/internal/modules/expenses/controllers/expense.controller.go index 125aeb0c..49c8f356 100644 --- a/internal/modules/expenses/controllers/expense.controller.go +++ b/internal/modules/expenses/controllers/expense.controller.go @@ -229,8 +229,8 @@ func (u *ExpenseController) Approval(c *fiber.Ctx) error { path := c.Path() approvalType := "" - if strings.Contains(path, "/approvals/manager") { - approvalType = "manager" + if strings.Contains(path, "/approvals/head-area") { + approvalType = "head-area" } else if strings.Contains(path, "/approvals/finance") { approvalType = "finance" } else if strings.Contains(path, "/approvals/unit-vice-president") { diff --git a/internal/modules/expenses/route.go b/internal/modules/expenses/route.go index cfb4dd23..6ddceb14 100644 --- a/internal/modules/expenses/route.go +++ b/internal/modules/expenses/route.go @@ -28,7 +28,7 @@ func ExpenseRoutes(v1 fiber.Router, u user.UserService, s expense.ExpenseService route.Patch("/:id", m.RequirePermissions(m.P_ExpenseUpdateOne), ctrl.UpdateOne) route.Delete("/:id", m.RequirePermissions(m.P_ExpenseDeleteOne), ctrl.DeleteOne) - route.Post("/approvals/manager", m.RequirePermissions(m.P_ExpenseApprovalManager), ctrl.Approval) + route.Post("/approvals/head-area", m.RequirePermissions(m.P_ExpenseApprovalHeadArea), ctrl.Approval) route.Post("/approvals/finance", m.RequirePermissions(m.P_ExpenseApprovalFinance), ctrl.Approval) route.Post("/approvals/unit-vice-president", m.RequirePermissions(m.P_ExpenseApprovalUnitVicePresident), ctrl.Approval) diff --git a/internal/modules/expenses/services/expense.service.go b/internal/modules/expenses/services/expense.service.go index 8afbac28..9a994bc9 100644 --- a/internal/modules/expenses/services/expense.service.go +++ b/internal/modules/expenses/services/expense.service.go @@ -1049,9 +1049,9 @@ func (s *expenseService) Approval(c *fiber.Ctx, req *validation.ApprovalRequest, } var stepNumber approvalutils.ApprovalStep - if approvalType == "manager" { + if approvalType == "head-area" { - stepNumber = utils.ExpenseStepManager + stepNumber = utils.ExpenseStepHeadArea if latestApproval.StepNumber != uint16(utils.ExpenseStepPengajuan) { currentStepName := utils.ExpenseApprovalSteps[approvalutils.ApprovalStep(latestApproval.StepNumber)] return fiber.NewError(fiber.StatusBadRequest, @@ -1060,7 +1060,7 @@ func (s *expenseService) Approval(c *fiber.Ctx, req *validation.ApprovalRequest, } else if approvalType == "unit-vice-president" { stepNumber = utils.ExpenseStepUnitVicePresident - if latestApproval.StepNumber != uint16(utils.ExpenseStepManager) { + if latestApproval.StepNumber != uint16(utils.ExpenseStepHeadArea) { currentStepName := utils.ExpenseApprovalSteps[approvalutils.ApprovalStep(latestApproval.StepNumber)] return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Cannot process at Unit Vice President step. Latest approval is at %s step. Expected previous step: Head Area", currentStepName)) diff --git a/internal/modules/inventory/transfers/services/transfer_expense_bridge.go b/internal/modules/inventory/transfers/services/transfer_expense_bridge.go index d4322be6..c4f28354 100644 --- a/internal/modules/inventory/transfers/services/transfer_expense_bridge.go +++ b/internal/modules/inventory/transfers/services/transfer_expense_bridge.go @@ -252,7 +252,7 @@ func (b *transferExpenseBridge) createExpenseViaService( actorID = 1 } approvalSvc := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(b.db)) - if _, err := approvalSvc.CreateApproval(ctx, utils.ApprovalWorkflowExpense, uint(detail.Id), utils.ExpenseStepManager, &action, actorID, nil); err != nil { + if _, err := approvalSvc.CreateApproval(ctx, utils.ApprovalWorkflowExpense, uint(detail.Id), utils.ExpenseStepHeadArea, &action, actorID, nil); err != nil { return nil, err } if _, err := approvalSvc.CreateApproval(ctx, utils.ApprovalWorkflowExpense, uint(detail.Id), utils.ExpenseStepUnitVicePresident, &action, actorID, nil); err != nil { diff --git a/internal/modules/purchases/services/expense_bridge.go b/internal/modules/purchases/services/expense_bridge.go index 094b99c1..7e5cbd91 100644 --- a/internal/modules/purchases/services/expense_bridge.go +++ b/internal/modules/purchases/services/expense_bridge.go @@ -618,7 +618,7 @@ func (b *expenseBridge) createExpenseViaService( actorID = 1 } approvalSvc := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(b.db)) - if _, err := approvalSvc.CreateApproval(ctx, utils.ApprovalWorkflowExpense, uint(detail.Id), utils.ExpenseStepManager, &action, actorID, nil); err != nil { + if _, err := approvalSvc.CreateApproval(ctx, utils.ApprovalWorkflowExpense, uint(detail.Id), utils.ExpenseStepHeadArea, &action, actorID, nil); err != nil { return nil, err } if _, err := approvalSvc.CreateApproval(ctx, utils.ApprovalWorkflowExpense, uint(detail.Id), utils.ExpenseStepUnitVicePresident, &action, actorID, nil); err != nil { diff --git a/internal/utils/constant.go b/internal/utils/constant.go index 44c79e35..ba0f51f1 100644 --- a/internal/utils/constant.go +++ b/internal/utils/constant.go @@ -356,7 +356,7 @@ var MarketingApprovalSteps = map[approvalutils.ApprovalStep]string{ const ( ApprovalWorkflowExpense approvalutils.ApprovalWorkflowKey = approvalutils.ApprovalWorkflowKey("EXPENSES") ExpenseStepPengajuan approvalutils.ApprovalStep = 1 - ExpenseStepManager approvalutils.ApprovalStep = 2 + ExpenseStepHeadArea approvalutils.ApprovalStep = 2 ExpenseStepUnitVicePresident approvalutils.ApprovalStep = 3 ExpenseStepFinance approvalutils.ApprovalStep = 4 ExpenseStepRealisasi approvalutils.ApprovalStep = 5 @@ -365,7 +365,7 @@ const ( var ExpenseApprovalSteps = map[approvalutils.ApprovalStep]string{ ExpenseStepPengajuan: "Pengajuan", - ExpenseStepManager: "Approval Head Area", + ExpenseStepHeadArea: "Approval Head Area", ExpenseStepUnitVicePresident: "Approval Business Unit Vice President", ExpenseStepFinance: "Approval Finance", ExpenseStepRealisasi: "Realisasi", From 15be8dcbeab9fc877c528fb91a82a23c3ce078a5 Mon Sep 17 00:00:00 2001 From: MacBook Air M1 Date: Mon, 12 Jan 2026 11:09:32 +0700 Subject: [PATCH 03/76] fix route daily checklist --- internal/modules/daily-checklists/route.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/modules/daily-checklists/route.go b/internal/modules/daily-checklists/route.go index 0f6657c0..9e576a05 100644 --- a/internal/modules/daily-checklists/route.go +++ b/internal/modules/daily-checklists/route.go @@ -1,7 +1,7 @@ package dailyChecklists import ( - // m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" controller "gitlab.com/mbugroup/lti-api.git/internal/modules/daily-checklists/controllers" dailyChecklist "gitlab.com/mbugroup/lti-api.git/internal/modules/daily-checklists/services" user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" @@ -13,7 +13,7 @@ func DailyChecklistRoutes(v1 fiber.Router, u user.UserService, s dailyChecklist. ctrl := controller.NewDailyChecklistController(s) route := v1.Group("/daily-checklists") - // route.Use(m.Auth(u)) + route.Use(m.Auth(u)) route.Get("/", ctrl.GetAll) route.Get("/report", ctrl.GetReport) @@ -22,7 +22,7 @@ func DailyChecklistRoutes(v1 fiber.Router, u user.UserService, s dailyChecklist. route.Get("/report", ctrl.GetReport) - // create daily checklist + // upsert daily checklist route.Post("/", ctrl.CreateOne) // get detail data daily checklist by id From b47f26d448c7081e9a3f26070e8d9bcb14ef6f46 Mon Sep 17 00:00:00 2001 From: MacBook Air M1 Date: Mon, 12 Jan 2026 11:30:57 +0700 Subject: [PATCH 04/76] adjust max limit location and kandang --- .../modules/master/kandangs/validations/kandang.validation.go | 2 +- .../modules/master/locations/validations/location.validation.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/modules/master/kandangs/validations/kandang.validation.go b/internal/modules/master/kandangs/validations/kandang.validation.go index f4adc55e..63f03d12 100644 --- a/internal/modules/master/kandangs/validations/kandang.validation.go +++ b/internal/modules/master/kandangs/validations/kandang.validation.go @@ -20,7 +20,7 @@ type Update struct { type Query struct { Page int `query:"page" validate:"omitempty,number,min=1"` - Limit int `query:"limit" validate:"omitempty,number,min=1,max=100"` + Limit int `query:"limit" validate:"omitempty,number,min=1,max=500"` Search string `query:"search" validate:"omitempty,max=50"` LocationId int `query:"location_id" validate:"omitempty,number,gt=0"` PicId int `query:"pic_id" validate:"omitempty,number,gt=0"` diff --git a/internal/modules/master/locations/validations/location.validation.go b/internal/modules/master/locations/validations/location.validation.go index 61ab4125..a2ac6175 100644 --- a/internal/modules/master/locations/validations/location.validation.go +++ b/internal/modules/master/locations/validations/location.validation.go @@ -14,7 +14,7 @@ type Update struct { type Query struct { Page int `query:"page" validate:"omitempty,number,min=1"` - Limit int `query:"limit" validate:"omitempty,number,min=1,max=100"` + Limit int `query:"limit" validate:"omitempty,number,min=1,max=500"` Search string `query:"search" validate:"omitempty,max=50"` AreaId int `query:"area_id" validate:"omitempty,number,gt=0"` } From 80b2cafd2fda0557a49ebe5aa17fe3f37a654736 Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Mon, 12 Jan 2026 13:29:24 +0700 Subject: [PATCH 05/76] FIX[BE] ; fixing chikin replenish. and other logic --- .../modules/production/chickins/module.go | 3 + .../chickins/services/chickin.service.go | 288 +++++------------- 2 files changed, 76 insertions(+), 215 deletions(-) diff --git a/internal/modules/production/chickins/module.go b/internal/modules/production/chickins/module.go index 143ebad2..09514f0d 100644 --- a/internal/modules/production/chickins/module.go +++ b/internal/modules/production/chickins/module.go @@ -14,6 +14,7 @@ import ( rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" rKandang "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/repositories" + rProduct "gitlab.com/mbugroup/lti-api.git/internal/modules/master/products/repositories" rWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/repositories" rChickin "gitlab.com/mbugroup/lti-api.git/internal/modules/production/chickins/repositories" sChickin "gitlab.com/mbugroup/lti-api.git/internal/modules/production/chickins/services" @@ -38,6 +39,7 @@ func (ChickinModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate * projectflockpopulationrepo := rProjectFlock.NewProjectFlockPopulationRepository(db) projectFlockRepo := rProjectFlock.NewProjectflockRepository(db) productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db) + productRepo := rProduct.NewProductRepository(db) stockAllocationRepo := commonRepo.NewStockAllocationRepository(db) fifoService := commonSvc.NewFifoService(db, stockAllocationRepo, productWarehouseRepo, utils.Log) userRepo := rUser.NewUserRepository(db) @@ -88,6 +90,7 @@ func (ChickinModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate * kandangRepo, warehouseRepo, productWarehouseRepo, + productRepo, projectFlockRepo, projectflockkandangrepo, projectflockpopulationrepo, diff --git a/internal/modules/production/chickins/services/chickin.service.go b/internal/modules/production/chickins/services/chickin.service.go index de49bb1e..eabe596c 100644 --- a/internal/modules/production/chickins/services/chickin.service.go +++ b/internal/modules/production/chickins/services/chickin.service.go @@ -12,6 +12,7 @@ import ( m "gitlab.com/mbugroup/lti-api.git/internal/middleware" rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" KandangRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/repositories" + rProduct "gitlab.com/mbugroup/lti-api.git/internal/modules/master/products/repositories" rWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/repositories" repository "gitlab.com/mbugroup/lti-api.git/internal/modules/production/chickins/repositories" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/chickins/validations" @@ -44,6 +45,7 @@ type chickinService struct { KandangRepo KandangRepo.KandangRepository WarehouseRepo rWarehouse.WarehouseRepository ProductWarehouseRepo rProductWarehouse.ProductWarehouseRepository + ProductRepo rProduct.ProductRepository ProjectFlockRepo rProjectFlock.ProjectflockRepository ProjectflockKandangRepo rProjectFlock.ProjectFlockKandangRepository ProjectflockPopulationRepo rProjectFlock.ProjectFlockPopulationRepository @@ -52,7 +54,7 @@ type chickinService struct { StockLogRepo rStockLogs.StockLogRepository } -func NewChickinService(repo repository.ProjectChickinRepository, kandangRepo KandangRepo.KandangRepository, warehouseRepo rWarehouse.WarehouseRepository, productWarehouseRepo rProductWarehouse.ProductWarehouseRepository, projectFlockRepo rProjectFlock.ProjectflockRepository, projectflockkandangRepo rProjectFlock.ProjectFlockKandangRepository, projectflockpopulationRepo rProjectFlock.ProjectFlockPopulationRepository, projectChickinDetailRepo repository.ProjectChickinDetailRepository, validate *validator.Validate, fifoSvc commonSvc.FifoService) ChickinService { +func NewChickinService(repo repository.ProjectChickinRepository, kandangRepo KandangRepo.KandangRepository, warehouseRepo rWarehouse.WarehouseRepository, productWarehouseRepo rProductWarehouse.ProductWarehouseRepository, productRepo rProduct.ProductRepository, projectFlockRepo rProjectFlock.ProjectflockRepository, projectflockkandangRepo rProjectFlock.ProjectFlockKandangRepository, projectflockpopulationRepo rProjectFlock.ProjectFlockPopulationRepository, projectChickinDetailRepo repository.ProjectChickinDetailRepository, validate *validator.Validate, fifoSvc commonSvc.FifoService) ChickinService { return &chickinService{ Log: utils.Log, Validate: validate, @@ -60,6 +62,7 @@ func NewChickinService(repo repository.ProjectChickinRepository, kandangRepo Kan KandangRepo: kandangRepo, WarehouseRepo: warehouseRepo, ProductWarehouseRepo: productWarehouseRepo, + ProductRepo: productRepo, ProjectFlockRepo: projectFlockRepo, ProjectflockKandangRepo: projectflockkandangRepo, ProjectflockPopulationRepo: projectflockpopulationRepo, @@ -99,7 +102,6 @@ func (s chickinService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity return db.Order("created_at DESC").Order("updated_at DESC") }) if err != nil { - s.Log.Errorf("Failed to get chickins: %+v", err) return nil, 0, err } return chickins, total, nil @@ -347,7 +349,6 @@ func (s chickinService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) if errors.Is(err, gorm.ErrRecordNotFound) { return nil, fiber.NewError(fiber.StatusNotFound, "Chickin not found") } - s.Log.Errorf("Failed to update chickin: %+v", err) return nil, err } @@ -380,7 +381,6 @@ func (s chickinService) DeleteOne(c *fiber.Ctx, id uint) error { warehouseDeltas := make(map[uint]float64) warehouseDeltas[chickin.ProductWarehouseId] += currentUsageQty if err := s.adjustProductWarehouseQuantities(c.Context(), s.Repository.DB(), warehouseDeltas); err != nil { - s.Log.Errorf("Failed to adjust product warehouses for deleted chickin %d: %+v", chickin.Id, err) return err } } @@ -449,6 +449,7 @@ func (s chickinService) Approval(c *fiber.Ctx, req *validation.Approve) ([]entit approvalSvc := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(dbTransaction)) chickinRepoTx := repository.NewChickinRepository(dbTransaction) + ProjectFlockPopulationRepotx := s.ProjectflockPopulationRepo.WithTx(dbTransaction) for _, approvableID := range approvableIDs { if _, err := approvalSvc.CreateApproval( @@ -479,39 +480,55 @@ func (s chickinService) Approval(c *fiber.Ctx, req *validation.Approve) ([]entit category := strings.ToUpper(strings.TrimSpace(kandangForApproval.ProjectFlock.Category)) + var targetFlag utils.FlagType if category == string(utils.ProjectFlockCategoryGrowing) { - warehouse, err := s.WarehouseRepo.GetByKandangID(c.Context(), kandangForApproval.KandangId) - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Warehouse for kandang %d not found", kandangForApproval.KandangId)) - } - return fiber.NewError(fiber.StatusInternalServerError, "Failed to get warehouse") - } - - pfkID := approvableID - targetPW, err := s.getOrCreateProductWarehouse(c, warehouse.Id, "PULLET", dbTransaction, actorID, &pfkID) - if err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to get/create PULLET product warehouse") - } - if err := s.convertChickinsToTarget(c, chickins, targetPW, dbTransaction, actorID); err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to convert chickins to target") - } + targetFlag = utils.FlagPullet } else if category == string(utils.ProjectFlockCategoryLaying) { - warehouse, err := s.WarehouseRepo.GetByKandangID(c.Context(), kandangForApproval.KandangId) + targetFlag = utils.FlagLayer + } else { + continue + } + + for _, chickin := range chickins { + populationExists, err := ProjectFlockPopulationRepotx.ExistsByProjectChickinID(c.Context(), chickin.Id) if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Warehouse for kandang %d not found", kandangForApproval.KandangId)) - } - return fiber.NewError(fiber.StatusInternalServerError, "Failed to get warehouse") + return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to check population for chickin %d", chickin.Id)) + } + if populationExists { + continue } - pfkID := approvableID - targetPW, err := s.getOrCreateProductWarehouse(c, warehouse.Id, "LAYER", dbTransaction, actorID, &pfkID) + sourcePW, err := s.ProductWarehouseRepo.GetByID(c.Context(), chickin.ProductWarehouseId, func(db *gorm.DB) *gorm.DB { + return db.Preload("Product.Flags") + }) if err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to get/create LAYER product warehouse") + return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to get product warehouse for chickin %d", chickin.Id)) } - if err := s.convertChickinsToTarget(c, chickins, targetPW, dbTransaction, actorID); err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to convert chickins to target") + + if err := s.autoAddFlagToProduct(c.Context(), dbTransaction, sourcePW.Product.Id, targetFlag); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to auto-add flag to product %d", sourcePW.Product.Id)) + } + + population := &entity.ProjectFlockPopulation{ + ProjectChickinId: chickin.Id, + ProductWarehouseId: sourcePW.Id, + TotalQty: 0, + TotalUsedQty: 0, + Notes: chickin.Notes, + CreatedBy: actorID, + } + if err := ProjectFlockPopulationRepotx.CreateOne(c.Context(), population, nil); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to create population for chickin %d", chickin.Id)) + } + + if err := chickinRepoTx.PatchOne(c.Context(), chickin.Id, map[string]any{ + "pending_usage_qty": 0, + }, nil); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to reset pending usage qty for chickin %d", chickin.Id)) + } + + if err := s.ReplenishChickinStocks(c.Context(), dbTransaction, &chickin, sourcePW, population, actorID); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to replenish stock for chickin %d", chickin.Id)) } } } @@ -534,7 +551,6 @@ func (s chickinService) Approval(c *fiber.Ctx, req *validation.Approve) ([]entit warehouseDeltas := make(map[uint]float64) warehouseDeltas[chickin.ProductWarehouseId] += chickin.UsageQty if err := s.adjustProductWarehouseQuantities(c.Context(), dbTransaction, warehouseDeltas); err != nil { - s.Log.Errorf("Failed to adjust product warehouses for rejected chickin %d: %+v", chickin.Id, err) return err } @@ -568,104 +584,35 @@ func (s chickinService) Approval(c *fiber.Ctx, req *validation.Approve) ([]entit return updated, nil } -func (s *chickinService) getOrCreateProductWarehouse(ctx *fiber.Ctx, warehouseId uint, categoryCode string, dbTransaction *gorm.DB, actorID uint, projectFlockKandangId *uint) (*entity.ProductWarehouse, error) { - - products, err := s.ProductWarehouseRepo.GetByFlagAndWarehouseID(ctx.Context(), categoryCode, warehouseId) - if err == nil && len(products) > 0 { - existingPW := &products[0] - - if existingPW.ProjectFlockKandangId == nil && projectFlockKandangId != nil { - existingPW.ProjectFlockKandangId = projectFlockKandangId - if err := s.ProductWarehouseRepo.WithTx(dbTransaction).UpdateOne(ctx.Context(), existingPW.Id, existingPW, nil); err != nil { - return nil, fmt.Errorf("failed to update %s product warehouse with project_flock_kandang_id: %w", categoryCode, err) - } - } - return existingPW, nil +// autoAddFlagToProduct adds target flag to product if not already present (idempotent) +func (s *chickinService) autoAddFlagToProduct(ctx context.Context, tx *gorm.DB, productID uint, targetFlag utils.FlagType) error { + if s.ProductRepo == nil { + return nil } - product, err := s.ProductWarehouseRepo.GetFirstProductByFlag(ctx.Context(), categoryCode) + currentFlags, err := s.ProductRepo.GetFlags(ctx, productID) if err != nil { - return nil, fmt.Errorf("failed to get %s product: %w", categoryCode, err) - } - if product == nil { - return nil, fmt.Errorf("no %s product found in system", categoryCode) + return fmt.Errorf("failed to get product flags: %w", err) } - newPW := &entity.ProductWarehouse{ - ProductId: product.Id, - WarehouseId: warehouseId, - ProjectFlockKandangId: projectFlockKandangId, - Quantity: 0, + hasTargetFlag := false + currentFlagNames := make([]string, 0, len(currentFlags)) + for _, flag := range currentFlags { + currentFlagNames = append(currentFlagNames, flag.Name) + if flag.Name == string(targetFlag) { + hasTargetFlag = true + } } - if err := s.ProductWarehouseRepo.WithTx(dbTransaction).CreateOne(ctx.Context(), newPW, nil); err != nil { - return nil, fmt.Errorf("failed to create %s product warehouse: %w", categoryCode, err) + if hasTargetFlag { + return nil } - return newPW, nil -} - -func (s *chickinService) convertChickinsToTarget(ctx *fiber.Ctx, chickins []entity.ProjectChickin, targetPW *entity.ProductWarehouse, dbTransaction *gorm.DB, actorID uint) error { - - if targetPW == nil || targetPW.Id == 0 { - return fmt.Errorf("invalid target product warehouse") + newFlags := append(currentFlagNames, string(targetFlag)) + if err := s.ProductRepo.SyncFlags(ctx, tx, productID, newFlags); err != nil { + return fmt.Errorf("failed to sync flags: %w", err) } - ProjectFlockPopulationRepotx := s.ProjectflockPopulationRepo.WithTx(dbTransaction) - chickinRepoTx := s.Repository.WithTx(dbTransaction) - - var totalQuantityAdded float64 - - for _, chickin := range chickins { - - populationExists, err := ProjectFlockPopulationRepotx.ExistsByProjectChickinID(ctx.Context(), chickin.Id) - if err != nil { - return fmt.Errorf("failed to check population existence for chickin %d: %w", chickin.Id, err) - } - - if populationExists { - s.Log.Infof("population already exists for chickin %d, skipping", chickin.Id) - continue - } - - quantityToConvert := chickin.UsageQty - - population := &entity.ProjectFlockPopulation{ - ProjectChickinId: chickin.Id, - ProductWarehouseId: targetPW.Id, - TotalQty: 0, // Will be set by FIFO Replenish - TotalUsedQty: 0, - Notes: chickin.Notes, - CreatedBy: actorID, - } - if err := ProjectFlockPopulationRepotx.CreateOne(ctx.Context(), population, nil); err != nil { - return err - } - - // Reset PendingUsageQty to 0 since population has been created - if err := chickinRepoTx.PatchOne(ctx.Context(), chickin.Id, map[string]any{ - "pending_usage_qty": 0, - }, nil); err != nil { - return fmt.Errorf("failed to reset pending usage qty for chickin %d: %w", chickin.Id, err) - } - - // Replenish stock to target ProductWarehouse based on source flag - // StockableKey is PROJECT_CHICKIN but StockableID refers to Population ID - if err := s.ReplenishChickinStocks(ctx.Context(), dbTransaction, &chickin, targetPW, population, actorID); err != nil { - s.Log.Errorf("Failed to replenish stock for chickin %d: %+v", chickin.Id, err) - return err - } - - totalQuantityAdded += quantityToConvert - } - - // NOTE: ProductWarehouse target sudah ditambah melalui ReplenishChickinStocks - // yang dipanggil di atas untuk setiap chickin berdasarkan flag source: - // - DOC → replenish ke PULLET - // - PULLET → replenish ke LAYER - // - LAYER → tidak perlu replenish (sudah final) - // - DOC+PULLET+LAYER → replenish ke dirinya sendiri - return nil } @@ -674,9 +621,6 @@ func (s *chickinService) ConsumeChickinStocks(ctx context.Context, tx *gorm.DB, return nil } - s.Log.Infof("ConsumeChickinStocks: chickin_id=%d, product_warehouse_id=%d, desired_qty=%.3f", - chickin.Id, chickin.ProductWarehouseId, desiredQty) - result, err := s.FifoSvc.Consume(ctx, commonSvc.StockConsumeRequest{ UsableKey: chickinUsableKey, UsableID: chickin.Id, @@ -686,13 +630,9 @@ func (s *chickinService) ConsumeChickinStocks(ctx context.Context, tx *gorm.DB, Tx: tx, }) if err != nil { - s.Log.Errorf("Failed to consume FIFO stock for chickin %d: %+v", chickin.Id, err) return err } - s.Log.Infof("ConsumeChickinStocks result: usage_qty=%.3f, pending_qty=%.3f, allocated_allocations=%d", - result.UsageQuantity, result.PendingQuantity, len(result.AddedAllocations)) - if err := s.Repository.UpdateUsageFields(ctx, tx, chickin.Id, result.UsageQuantity, result.PendingQuantity); err != nil { return err } @@ -706,10 +646,7 @@ func (s *chickinService) ConsumeChickinStocks(ctx context.Context, tx *gorm.DB, CreatedBy: actorID, Notes: fmt.Sprintf("Chickin #%d", chickin.Id), } - if err := s.StockLogRepo.CreateOne(ctx, decreaseLog, nil); err != nil { - s.Log.Errorf("Failed to create stock log for chickin %d: %+v", chickin.Id, err) - - } + s.StockLogRepo.CreateOne(ctx, decreaseLog, nil) } return nil @@ -720,93 +657,17 @@ func (s *chickinService) ReplenishChickinStocks(ctx context.Context, tx *gorm.DB return nil } - sourcePW, err := s.ProductWarehouseRepo.GetByID(ctx, chickin.ProductWarehouseId, func(db *gorm.DB) *gorm.DB { - return db.Preload("Product.Flags") + _, err := s.FifoSvc.Replenish(ctx, commonSvc.StockReplenishRequest{ + StockableKey: fifo.StockableKeyProjectFlockPopulation, + StockableID: population.Id, + ProductWarehouseID: targetPW.Id, + Quantity: chickin.UsageQty, + Tx: tx, }) if err != nil { - return err } - if sourcePW == nil || sourcePW.Product.Id == 0 { - return fmt.Errorf("source product warehouse or product not found for chickin %d", chickin.Id) - } - sourceFlags := sourcePW.Product.Flags - if len(sourceFlags) == 0 { - s.Log.Warnf("Source product %d has no flags, skipping replenish for chickin %d", sourcePW.Product.Id, chickin.Id) - return nil - } - - hasDoc := false - hasPullet := false - hasLayer := false - for _, flag := range sourceFlags { - flagName := utils.FlagType(flag.Name) - if flagName == utils.FlagDOC { - hasDoc = true - } else if flagName == utils.FlagPullet { - hasPullet = true - } else if flagName == utils.FlagLayer { - hasLayer = true - } - } - - if hasDoc && hasPullet && hasLayer { - s.Log.Infof("Chickin %d has mixed flags (DOC+PULLET+LAYER), replenishing to source PW %d", chickin.Id, sourcePW.Id) - _, err = s.FifoSvc.Replenish(ctx, commonSvc.StockReplenishRequest{ - StockableKey: fifo.StockableKeyProjectFlockPopulation, - StockableID: population.Id, - ProductWarehouseID: sourcePW.Id, - Quantity: chickin.UsageQty, - Tx: tx, - }) - if err != nil { - s.Log.Errorf("Failed to replenish stock to source PW for chickin %d: %+v", chickin.Id, err) - return err - } - return nil - } - - // LAYER only - no replenish needed - if hasLayer && !hasDoc && !hasPullet { - s.Log.Infof("Chickin %d has LAYER flag only, skipping replenish", chickin.Id) - return nil - } - - if hasDoc && !hasPullet && !hasLayer { - s.Log.Infof("Chickin %d has DOC flag, replenishing to PULLET PW %d", chickin.Id, targetPW.Id) - _, err = s.FifoSvc.Replenish(ctx, commonSvc.StockReplenishRequest{ - StockableKey: fifo.StockableKeyProjectFlockPopulation, - StockableID: population.Id, - ProductWarehouseID: targetPW.Id, - Quantity: chickin.UsageQty, - Tx: tx, - }) - if err != nil { - s.Log.Errorf("Failed to replenish stock to PULLET PW for chickin %d: %+v", chickin.Id, err) - return err - } - return nil - } - - if hasPullet && !hasDoc && !hasLayer { - s.Log.Infof("Chickin %d has PULLET flag, replenishing to LAYER PW %d", chickin.Id, targetPW.Id) - _, err = s.FifoSvc.Replenish(ctx, commonSvc.StockReplenishRequest{ - StockableKey: fifo.StockableKeyProjectFlockPopulation, - StockableID: population.Id, - ProductWarehouseID: targetPW.Id, - Quantity: chickin.UsageQty, - Tx: tx, - }) - if err != nil { - s.Log.Errorf("Failed to replenish stock to LAYER PW for chickin %d: %+v", chickin.Id, err) - return err - } - return nil - } - - // Other combinations (e.g., DOC + PULLET without LAYER) - skip for now - s.Log.Warnf("Chickin %d has unsupported flag combination, skipping replenish", chickin.Id) return nil } @@ -825,7 +686,6 @@ func (s *chickinService) ReleaseChickinStocks(ctx context.Context, tx *gorm.DB, UsableID: chickin.Id, Tx: tx, }); err != nil { - s.Log.Errorf("Failed to release FIFO stock for chickin %d: %+v", chickin.Id, err) return err } @@ -842,9 +702,7 @@ func (s *chickinService) ReleaseChickinStocks(ctx context.Context, tx *gorm.DB, CreatedBy: actorID, Notes: fmt.Sprintf("Chickin #%d - Stock released", chickin.Id), } - if err := s.StockLogRepo.CreateOne(ctx, increaseLog, nil); err != nil { - s.Log.Errorf("Failed to create stock log for released chickin %d: %+v", chickin.Id, err) - } + s.StockLogRepo.CreateOne(ctx, increaseLog, nil) } return nil From 8fab5d7d913b5a3751172f1e8eae53c3e63c318b Mon Sep 17 00:00:00 2001 From: "Hafizh A. Y" Date: Mon, 12 Jan 2026 14:54:14 +0700 Subject: [PATCH 06/76] feat(BE): price-product-supplier --- ...016_add_price_to_product_supplier.down.sql | 6 ++ ...73016_add_price_to_product_supplier.up.sql | 6 ++ internal/entities/nonstock_supplier.go | 1 + internal/entities/product_supplier.go | 1 + .../master/nonstocks/dto/nonstock.dto.go | 27 ++++++--- .../repositories/nonstock.repository.go | 33 +++++++---- .../nonstocks/services/nonstock.service.go | 55 +++++++++++++----- .../validations/nonstock.validation.go | 9 ++- .../master/products/dto/product.dto.go | 29 +++++++--- .../repositories/product.repository.go | 33 +++++++---- .../products/services/product.service.go | 58 +++++++++++++------ .../validations/product.validation.go | 9 ++- .../suppliers/dto/supplier_nonstock.dto.go | 2 + .../suppliers/dto/supplier_product.dto.go | 26 +++++---- 14 files changed, 212 insertions(+), 83 deletions(-) create mode 100644 internal/database/migrations/20260112073016_add_price_to_product_supplier.down.sql create mode 100644 internal/database/migrations/20260112073016_add_price_to_product_supplier.up.sql diff --git a/internal/database/migrations/20260112073016_add_price_to_product_supplier.down.sql b/internal/database/migrations/20260112073016_add_price_to_product_supplier.down.sql new file mode 100644 index 00000000..0acbcbe2 --- /dev/null +++ b/internal/database/migrations/20260112073016_add_price_to_product_supplier.down.sql @@ -0,0 +1,6 @@ +-- Rollback: remove price from supplier relations +ALTER TABLE product_suppliers + DROP COLUMN IF EXISTS price; + +ALTER TABLE nonstock_suppliers + DROP COLUMN IF EXISTS price; diff --git a/internal/database/migrations/20260112073016_add_price_to_product_supplier.up.sql b/internal/database/migrations/20260112073016_add_price_to_product_supplier.up.sql new file mode 100644 index 00000000..b96abf64 --- /dev/null +++ b/internal/database/migrations/20260112073016_add_price_to_product_supplier.up.sql @@ -0,0 +1,6 @@ +-- Migration: add price to supplier relations +ALTER TABLE product_suppliers + ADD COLUMN IF NOT EXISTS price NUMERIC(15, 3) NOT NULL DEFAULT 0; + +ALTER TABLE nonstock_suppliers + ADD COLUMN IF NOT EXISTS price NUMERIC(15, 3) NOT NULL DEFAULT 0; diff --git a/internal/entities/nonstock_supplier.go b/internal/entities/nonstock_supplier.go index 2206390c..d666e3c8 100644 --- a/internal/entities/nonstock_supplier.go +++ b/internal/entities/nonstock_supplier.go @@ -5,6 +5,7 @@ import "time" type NonstockSupplier struct { NonstockId uint `gorm:"not null"` SupplierId uint `gorm:"not null"` + Price float64 `gorm:"type:numeric(15,3);not null;default:0"` CreatedAt time.Time `gorm:"autoCreateTime"` Nonstock Nonstock `gorm:"foreignKey:NonstockId;references:Id"` diff --git a/internal/entities/product_supplier.go b/internal/entities/product_supplier.go index d64b1e85..9b9aa67c 100644 --- a/internal/entities/product_supplier.go +++ b/internal/entities/product_supplier.go @@ -5,6 +5,7 @@ import "time" type ProductSupplier struct { ProductId uint `gorm:"not null"` SupplierId uint `gorm:"not null"` + Price float64 `gorm:"type:numeric(15,3);not null;default:0"` CreatedAt time.Time `gorm:"autoCreateTime"` Product Product `gorm:"foreignKey:ProductId;references:Id"` diff --git a/internal/modules/master/nonstocks/dto/nonstock.dto.go b/internal/modules/master/nonstocks/dto/nonstock.dto.go index 9954ee76..fa102b9c 100644 --- a/internal/modules/master/nonstocks/dto/nonstock.dto.go +++ b/internal/modules/master/nonstocks/dto/nonstock.dto.go @@ -4,7 +4,6 @@ import ( "time" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" - supplierDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/suppliers/dto" uomDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/uoms/dto" userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto" ) @@ -23,7 +22,7 @@ type NonstockListDTO struct { Name string `json:"name"` Flags []string `json:"flags"` Uom *uomDTO.UomRelationDTO `json:"uom"` - Suppliers []supplierDTO.SupplierRelationDTO `json:"suppliers"` + Suppliers []NonstockSupplierDTO `json:"suppliers"` CreatedUser *userDTO.UserRelationDTO `json:"created_user"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` @@ -33,6 +32,14 @@ type NonstockDetailDTO struct { NonstockListDTO } +type NonstockSupplierDTO struct { + Id uint `json:"id"` + Name string `json:"name"` + Alias string `json:"alias"` + Category string `json:"category"` + Price float64 `json:"price"` +} + // === Mapper Functions === func ToNonstockRelationDTO(e entity.Nonstock) NonstockRelationDTO { @@ -99,21 +106,27 @@ func ToNonstockDetailDTO(e entity.Nonstock) NonstockDetailDTO { } } -func toNonstockSupplierDTOs(relations []entity.NonstockSupplier) []supplierDTO.SupplierRelationDTO { +func toNonstockSupplierDTOs(relations []entity.NonstockSupplier) []NonstockSupplierDTO { if len(relations) == 0 { - return make([]supplierDTO.SupplierRelationDTO, 0) + return make([]NonstockSupplierDTO, 0) } - result := make([]supplierDTO.SupplierRelationDTO, 0, len(relations)) + result := make([]NonstockSupplierDTO, 0, len(relations)) for _, relation := range relations { if relation.Supplier.Id == 0 { continue } - result = append(result, supplierDTO.ToSupplierRelationDTO(relation.Supplier)) + result = append(result, NonstockSupplierDTO{ + Id: relation.Supplier.Id, + Name: relation.Supplier.Name, + Alias: relation.Supplier.Alias, + Category: relation.Supplier.Category, + Price: relation.Price, + }) } if len(result) == 0 { - return make([]supplierDTO.SupplierRelationDTO, 0) + return make([]NonstockSupplierDTO, 0) } return result diff --git a/internal/modules/master/nonstocks/repositories/nonstock.repository.go b/internal/modules/master/nonstocks/repositories/nonstock.repository.go index aeff162f..16260272 100644 --- a/internal/modules/master/nonstocks/repositories/nonstock.repository.go +++ b/internal/modules/master/nonstocks/repositories/nonstock.repository.go @@ -12,7 +12,7 @@ import ( type NonstockRepository interface { repository.BaseRepository[entity.Nonstock] NameExists(ctx context.Context, name string, excludeID *uint) (bool, error) - SyncSuppliersDiff(ctx context.Context, tx *gorm.DB, nonstockID uint, supplierIDs []uint) error + SyncSuppliersDiff(ctx context.Context, tx *gorm.DB, nonstockID uint, suppliers []entity.NonstockSupplier) error UomExists(ctx context.Context, uomID uint) (bool, error) GetSuppliersByIDs(ctx context.Context, supplierIDs []uint) ([]entity.Supplier, error) SyncFlags(ctx context.Context, tx *gorm.DB, nonstockID uint, flags []string) error @@ -40,13 +40,13 @@ func (r *NonstockRepositoryImpl) IdExists(ctx context.Context, id uint) (bool, e return repository.Exists[entity.Nonstock](ctx, r.DB(), id) } -func (r *NonstockRepositoryImpl) SyncSuppliersDiff(ctx context.Context, tx *gorm.DB, nonstockID uint, supplierIDs []uint) error { +func (r *NonstockRepositoryImpl) SyncSuppliersDiff(ctx context.Context, tx *gorm.DB, nonstockID uint, suppliers []entity.NonstockSupplier) error { db := tx if db == nil { db = r.DB() } - if supplierIDs == nil { + if suppliers == nil { return db.WithContext(ctx). Where("nonstock_id = ?", nonstockID). Delete(&entity.NonstockSupplier{}). @@ -61,18 +61,31 @@ func (r *NonstockRepositoryImpl) SyncSuppliersDiff(ctx context.Context, tx *gorm return err } - existingMap := make(map[uint]struct{}, len(existing)) + existingMap := make(map[uint]entity.NonstockSupplier, len(existing)) for _, rel := range existing { - existingMap[rel.SupplierId] = struct{}{} + existingMap[rel.SupplierId] = rel } - incomingMap := make(map[uint]struct{}, len(supplierIDs)) - for _, id := range supplierIDs { - incomingMap[id] = struct{}{} - if _, exists := existingMap[id]; exists { + incomingMap := make(map[uint]struct{}, len(suppliers)) + for _, rel := range suppliers { + incomingMap[rel.SupplierId] = struct{}{} + if existingRel, exists := existingMap[rel.SupplierId]; exists { + if existingRel.Price != rel.Price { + if err := db.WithContext(ctx). + Model(&entity.NonstockSupplier{}). + Where("nonstock_id = ? AND supplier_id = ?", nonstockID, rel.SupplierId). + Update("price", rel.Price). + Error; err != nil { + return err + } + } continue } - record := entity.NonstockSupplier{NonstockId: nonstockID, SupplierId: id} + record := entity.NonstockSupplier{ + NonstockId: nonstockID, + SupplierId: rel.SupplierId, + Price: rel.Price, + } if err := db.WithContext(ctx).Create(&record).Error; err != nil { return err } diff --git a/internal/modules/master/nonstocks/services/nonstock.service.go b/internal/modules/master/nonstocks/services/nonstock.service.go index ad044b08..e1cc5495 100644 --- a/internal/modules/master/nonstocks/services/nonstock.service.go +++ b/internal/modules/master/nonstocks/services/nonstock.service.go @@ -111,8 +111,25 @@ func (s *nonstockService) CreateOne(c *fiber.Ctx, req *validation.Create) (*enti return nil, err } - supplierIDs := utils.UniqueUintSlice(req.SupplierIDs) - if len(supplierIDs) > 0 { + var ( + supplierLinks []entity.NonstockSupplier + supplierIDs []uint + ) + if len(req.Suppliers) > 0 { + seen := make(map[uint]struct{}, len(req.Suppliers)) + supplierLinks = make([]entity.NonstockSupplier, 0, len(req.Suppliers)) + supplierIDs = make([]uint, 0, len(req.Suppliers)) + for _, supplier := range req.Suppliers { + if _, exists := seen[supplier.SupplierID]; exists { + return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Duplicate supplier_id %d", supplier.SupplierID)) + } + seen[supplier.SupplierID] = struct{}{} + supplierIDs = append(supplierIDs, supplier.SupplierID) + supplierLinks = append(supplierLinks, entity.NonstockSupplier{ + SupplierId: supplier.SupplierID, + Price: supplier.Price, + }) + } supplierList, supplierErr := s.Repository.GetSuppliersByIDs(ctx, supplierIDs) if supplierErr != nil { s.Log.Errorf("Failed to validate suppliers: %+v", supplierErr) @@ -155,7 +172,7 @@ func (s *nonstockService) CreateOne(c *fiber.Ctx, req *validation.Create) (*enti return err } - return s.Repository.SyncSuppliersDiff(ctx, tx, createBody.Id, supplierIDs) + return s.Repository.SyncSuppliersDiff(ctx, tx, createBody.Id, supplierLinks) }) if err != nil { @@ -193,15 +210,27 @@ func (s nonstockService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint updateBody["uom_id"] = *req.UomID } - var supplierIDs []uint + var supplierLinks []entity.NonstockSupplier var supplierUpdate bool - if req.SupplierIDs != nil { + if req.Suppliers != nil { supplierUpdate = true - supplierIDs = utils.UniqueUintSlice(*req.SupplierIDs) - if len(supplierIDs) > 0 { - var supplierList []entity.Supplier - var supplierErr error - supplierList, supplierErr = s.Repository.GetSuppliersByIDs(ctx, supplierIDs) + if len(*req.Suppliers) > 0 { + seen := make(map[uint]struct{}, len(*req.Suppliers)) + supplierLinks = make([]entity.NonstockSupplier, 0, len(*req.Suppliers)) + supplierIDs := make([]uint, 0, len(*req.Suppliers)) + for _, supplier := range *req.Suppliers { + if _, exists := seen[supplier.SupplierID]; exists { + return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Duplicate supplier_id %d", supplier.SupplierID)) + } + seen[supplier.SupplierID] = struct{}{} + supplierIDs = append(supplierIDs, supplier.SupplierID) + supplierLinks = append(supplierLinks, entity.NonstockSupplier{ + SupplierId: supplier.SupplierID, + Price: supplier.Price, + }) + } + + supplierList, supplierErr := s.Repository.GetSuppliersByIDs(ctx, supplierIDs) if supplierErr != nil { s.Log.Errorf("Failed to validate suppliers: %+v", supplierErr) return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to validate suppliers") @@ -253,11 +282,7 @@ func (s nonstockService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint } if supplierUpdate { - var ids []uint - if len(supplierIDs) > 0 { - ids = supplierIDs - } - if err := s.Repository.SyncSuppliersDiff(ctx, tx, id, ids); err != nil { + if err := s.Repository.SyncSuppliersDiff(ctx, tx, id, supplierLinks); err != nil { return err } } diff --git a/internal/modules/master/nonstocks/validations/nonstock.validation.go b/internal/modules/master/nonstocks/validations/nonstock.validation.go index 62a41197..c5491991 100644 --- a/internal/modules/master/nonstocks/validations/nonstock.validation.go +++ b/internal/modules/master/nonstocks/validations/nonstock.validation.go @@ -1,16 +1,21 @@ package validation +type SupplierPrice struct { + SupplierID uint `json:"supplier_id" validate:"required,gt=0"` + Price float64 `json:"price" validate:"required,gte=0"` +} + type Create struct { Name string `json:"name" validate:"required_strict,min=3,max=50"` UomID uint `json:"uom_id" validate:"required,gt=0"` - SupplierIDs []uint `json:"supplier_ids" validate:"dive,gt=0"` + Suppliers []SupplierPrice `json:"suppliers,omitempty" validate:"omitempty,dive"` Flags []string `json:"flags" validate:"dive,max=50"` } type Update struct { Name *string `json:"name,omitempty" validate:"omitempty,min=3,max=50"` UomID *uint `json:"uom_id,omitempty" validate:"omitempty,gt=0"` - SupplierIDs *[]uint `json:"supplier_ids,omitempty" validate:"omitempty,dive,gt=0"` + Suppliers *[]SupplierPrice `json:"suppliers,omitempty" validate:"omitempty,dive"` Flags *[]string `json:"flags,omitempty" validate:"omitempty,dive,max=50"` } diff --git a/internal/modules/master/products/dto/product.dto.go b/internal/modules/master/products/dto/product.dto.go index 59f57034..d115ad23 100644 --- a/internal/modules/master/products/dto/product.dto.go +++ b/internal/modules/master/products/dto/product.dto.go @@ -5,7 +5,6 @@ import ( entity "gitlab.com/mbugroup/lti-api.git/internal/entities" productCategoryDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/product-categories/dto" - supplierDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/suppliers/dto" uomDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/uoms/dto" userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto" ) @@ -20,7 +19,7 @@ type ProductRelationDTO struct { Uom *uomDTO.UomRelationDTO `json:"uom,omitempty"` Flags *[]string `json:"flags,omitempty"` ProductCategory *productCategoryDTO.ProductCategoryRelationDTO `json:"product_category,omitempty"` - Suppliers []supplierDTO.SupplierRelationDTO `json:"suppliers"` + Suppliers []ProductSupplierDTO `json:"suppliers"` } type ProductListDTO struct { @@ -35,7 +34,7 @@ type ProductListDTO struct { Flags []string `json:"flags"` Uom *uomDTO.UomRelationDTO `json:"uom,omitempty"` ProductCategory *productCategoryDTO.ProductCategoryRelationDTO `json:"product_category,omitempty"` - Suppliers []supplierDTO.SupplierRelationDTO `json:"suppliers"` + Suppliers []ProductSupplierDTO `json:"suppliers"` CreatedUser *userDTO.UserRelationDTO `json:"created_user"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` @@ -45,6 +44,14 @@ type ProductDetailDTO struct { ProductListDTO } +type ProductSupplierDTO struct { + Id uint `json:"id"` + Name string `json:"name"` + Alias string `json:"alias"` + Category string `json:"category"` + Price float64 `json:"price"` +} + // === Mapper Functions === func ToProductRelationDTO(e entity.Product) ProductRelationDTO { @@ -134,21 +141,27 @@ func ToProductDetailDTO(e entity.Product) ProductDetailDTO { } } -func toProductSupplierDTOs(relations []entity.ProductSupplier) []supplierDTO.SupplierRelationDTO { +func toProductSupplierDTOs(relations []entity.ProductSupplier) []ProductSupplierDTO { if len(relations) == 0 { - return make([]supplierDTO.SupplierRelationDTO, 0) + return make([]ProductSupplierDTO, 0) } - result := make([]supplierDTO.SupplierRelationDTO, 0, len(relations)) + result := make([]ProductSupplierDTO, 0, len(relations)) for _, relation := range relations { if relation.Supplier.Id == 0 { continue } - result = append(result, supplierDTO.ToSupplierRelationDTO(relation.Supplier)) + result = append(result, ProductSupplierDTO{ + Id: relation.Supplier.Id, + Name: relation.Supplier.Name, + Alias: relation.Supplier.Alias, + Category: relation.Supplier.Category, + Price: relation.Price, + }) } if len(result) == 0 { - return make([]supplierDTO.SupplierRelationDTO, 0) + return make([]ProductSupplierDTO, 0) } return result diff --git a/internal/modules/master/products/repositories/product.repository.go b/internal/modules/master/products/repositories/product.repository.go index 244259d5..ecef0204 100644 --- a/internal/modules/master/products/repositories/product.repository.go +++ b/internal/modules/master/products/repositories/product.repository.go @@ -17,7 +17,7 @@ type ProductRepository interface { CategoryExists(ctx context.Context, categoryID uint) (bool, error) GetSuppliersByIDs(ctx context.Context, supplierIDs []uint) ([]entity.Supplier, error) IsLinkedToSupplier(ctx context.Context, productID, supplierID uint) (bool, error) - SyncSuppliersDiff(ctx context.Context, tx *gorm.DB, productID uint, supplierIDs []uint) error + SyncSuppliersDiff(ctx context.Context, tx *gorm.DB, productID uint, suppliers []entity.ProductSupplier) error SyncFlags(ctx context.Context, tx *gorm.DB, productID uint, flags []string) error DeleteFlags(ctx context.Context, tx *gorm.DB, productID uint) error GetFlags(ctx context.Context, productID uint) ([]entity.Flag, error) @@ -102,13 +102,13 @@ func (r *ProductRepositoryImpl) IsLinkedToSupplier(ctx context.Context, productI return count > 0, nil } -func (r *ProductRepositoryImpl) SyncSuppliersDiff(ctx context.Context, tx *gorm.DB, productID uint, supplierIds []uint) error { +func (r *ProductRepositoryImpl) SyncSuppliersDiff(ctx context.Context, tx *gorm.DB, productID uint, suppliers []entity.ProductSupplier) error { db := tx if db == nil { db = r.DB() } - if supplierIds == nil { + if suppliers == nil { return db.WithContext(ctx). Where("product_id = ?", productID). Delete(&entity.ProductSupplier{}). @@ -123,18 +123,31 @@ func (r *ProductRepositoryImpl) SyncSuppliersDiff(ctx context.Context, tx *gorm. return err } - existingMap := make(map[uint]struct{}, len(existing)) + existingMap := make(map[uint]entity.ProductSupplier, len(existing)) for _, rel := range existing { - existingMap[rel.SupplierId] = struct{}{} + existingMap[rel.SupplierId] = rel } - incomingMap := make(map[uint]struct{}, len(supplierIds)) - for _, id := range supplierIds { - incomingMap[id] = struct{}{} - if _, exists := existingMap[id]; exists { + incomingMap := make(map[uint]struct{}, len(suppliers)) + for _, rel := range suppliers { + incomingMap[rel.SupplierId] = struct{}{} + if existingRel, exists := existingMap[rel.SupplierId]; exists { + if existingRel.Price != rel.Price { + if err := db.WithContext(ctx). + Model(&entity.ProductSupplier{}). + Where("product_id = ? AND supplier_id = ?", productID, rel.SupplierId). + Update("price", rel.Price). + Error; err != nil { + return err + } + } continue } - record := entity.ProductSupplier{ProductId: productID, SupplierId: id} + record := entity.ProductSupplier{ + ProductId: productID, + SupplierId: rel.SupplierId, + Price: rel.Price, + } if err := db.WithContext(ctx).Create(&record).Error; err != nil { return err } diff --git a/internal/modules/master/products/services/product.service.go b/internal/modules/master/products/services/product.service.go index e63b462b..0aaa0952 100644 --- a/internal/modules/master/products/services/product.service.go +++ b/internal/modules/master/products/services/product.service.go @@ -138,9 +138,25 @@ func (s *productService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entit return nil, err } - supplierIDs := utils.UniqueUintSlice(req.SupplierIDs) - var err error - if len(supplierIDs) > 0 { + var ( + supplierLinks []entity.ProductSupplier + supplierIDs []uint + ) + if len(req.Suppliers) > 0 { + seen := make(map[uint]struct{}, len(req.Suppliers)) + supplierLinks = make([]entity.ProductSupplier, 0, len(req.Suppliers)) + supplierIDs = make([]uint, 0, len(req.Suppliers)) + for _, supplier := range req.Suppliers { + if _, exists := seen[supplier.SupplierID]; exists { + return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Duplicate supplier_id %d", supplier.SupplierID)) + } + seen[supplier.SupplierID] = struct{}{} + supplierIDs = append(supplierIDs, supplier.SupplierID) + supplierLinks = append(supplierLinks, entity.ProductSupplier{ + SupplierId: supplier.SupplierID, + Price: supplier.Price, + }) + } suppliers, err := s.Repository.GetSuppliersByIDs(ctx, supplierIDs) if err != nil { s.Log.Errorf("Failed to validate suppliers: %+v", err) @@ -180,7 +196,7 @@ func (s *productService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entit CreatedBy: 1, } - err = s.Repository.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { + err := s.Repository.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { repoTx := s.Repository.WithTx(tx) if err := repoTx.CreateOne(ctx, createBody, nil); err != nil { @@ -191,7 +207,7 @@ func (s *productService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entit return err } - return s.Repository.SyncSuppliersDiff(ctx, tx, createBody.Id, supplierIDs) + return s.Repository.SyncSuppliersDiff(ctx, tx, createBody.Id, supplierLinks) }) if err != nil { @@ -276,15 +292,27 @@ func (s productService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) ctx := c.Context() - var suppliers []entity.Supplier - var supplierIDs []uint + var supplierLinks []entity.ProductSupplier var supplierUpdate bool - if req.SupplierIDs != nil { + if req.Suppliers != nil { supplierUpdate = true - supplierIDs = utils.UniqueUintSlice(*req.SupplierIDs) - if len(supplierIDs) > 0 { - var err error - suppliers, err = s.Repository.GetSuppliersByIDs(ctx, supplierIDs) + if len(*req.Suppliers) > 0 { + seen := make(map[uint]struct{}, len(*req.Suppliers)) + supplierLinks = make([]entity.ProductSupplier, 0, len(*req.Suppliers)) + supplierIDs := make([]uint, 0, len(*req.Suppliers)) + for _, supplier := range *req.Suppliers { + if _, exists := seen[supplier.SupplierID]; exists { + return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Duplicate supplier_id %d", supplier.SupplierID)) + } + seen[supplier.SupplierID] = struct{}{} + supplierIDs = append(supplierIDs, supplier.SupplierID) + supplierLinks = append(supplierLinks, entity.ProductSupplier{ + SupplierId: supplier.SupplierID, + Price: supplier.Price, + }) + } + + suppliers, err := s.Repository.GetSuppliersByIDs(ctx, supplierIDs) if err != nil { s.Log.Errorf("Failed to validate suppliers: %+v", err) return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to validate suppliers") @@ -336,11 +364,7 @@ func (s productService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) } if supplierUpdate { - var ids []uint - if len(supplierIDs) > 0 { - ids = supplierIDs - } - if err := s.Repository.SyncSuppliersDiff(ctx, tx, id, ids); err != nil { + if err := s.Repository.SyncSuppliersDiff(ctx, tx, id, supplierLinks); err != nil { return err } } diff --git a/internal/modules/master/products/validations/product.validation.go b/internal/modules/master/products/validations/product.validation.go index e732d054..77e8e1bf 100644 --- a/internal/modules/master/products/validations/product.validation.go +++ b/internal/modules/master/products/validations/product.validation.go @@ -1,5 +1,10 @@ package validation +type SupplierPrice struct { + SupplierID uint `json:"supplier_id" validate:"required,gt=0"` + Price float64 `json:"price" validate:"required,gte=0"` +} + type Create struct { Name string `json:"name" validate:"required_strict,min=3,max=50"` Brand string `json:"brand" validate:"required_strict,min=2,max=50"` @@ -10,7 +15,7 @@ type Create struct { SellingPrice *float64 `json:"selling_price,omitempty" validate:"omitempty"` Tax *float64 `json:"tax,omitempty" validate:"omitempty"` ExpiryPeriod *int `json:"expiry_period,omitempty" validate:"omitempty,gt=0"` - SupplierIDs []uint `json:"supplier_ids,omitempty" validate:"omitempty,dive,gt=0"` + Suppliers []SupplierPrice `json:"suppliers,omitempty" validate:"omitempty,dive"` Flags []string `json:"flags,omitempty" validate:"omitempty,dive"` } @@ -24,7 +29,7 @@ type Update struct { SellingPrice *float64 `json:"selling_price,omitempty" validate:"omitempty"` Tax *float64 `json:"tax,omitempty" validate:"omitempty"` ExpiryPeriod *int `json:"expiry_period,omitempty" validate:"omitempty,gt=0"` - SupplierIDs *[]uint `json:"supplier_ids,omitempty" validate:"omitempty,dive,gt=0"` + Suppliers *[]SupplierPrice `json:"suppliers,omitempty" validate:"omitempty,dive"` Flags *[]string `json:"flags,omitempty" validate:"omitempty,dive"` } diff --git a/internal/modules/master/suppliers/dto/supplier_nonstock.dto.go b/internal/modules/master/suppliers/dto/supplier_nonstock.dto.go index 828063eb..8c5e0082 100644 --- a/internal/modules/master/suppliers/dto/supplier_nonstock.dto.go +++ b/internal/modules/master/suppliers/dto/supplier_nonstock.dto.go @@ -10,6 +10,7 @@ import ( type SupplierNonstockDTO struct { Id uint `json:"id"` Name string `json:"name"` + Price float64 `json:"price"` Uom *uomDTO.UomRelationDTO `json:"uom,omitempty"` Flags []string `json:"flags"` } @@ -42,6 +43,7 @@ func toSupplierNonstockDTOs(relations []entity.NonstockSupplier) []SupplierNonst result = append(result, SupplierNonstockDTO{ Id: Nonstock.Id, Name: Nonstock.Name, + Price: relation.Price, Uom: uomRef, Flags: flags, }) diff --git a/internal/modules/master/suppliers/dto/supplier_product.dto.go b/internal/modules/master/suppliers/dto/supplier_product.dto.go index 47a6ae0e..a6178aaf 100644 --- a/internal/modules/master/suppliers/dto/supplier_product.dto.go +++ b/internal/modules/master/suppliers/dto/supplier_product.dto.go @@ -8,12 +8,13 @@ import ( // === DTO Structs === type SupplierProductDTO struct { - Id uint `json:"id"` - Name string `json:"name"` - ProductPrice float64 `gorm:"type:numeric(15,3);not null"` - SellingPrice *float64 `gorm:"type:numeric(15,3)"` - Uom *uomDTO.UomRelationDTO `json:"uom,omitempty"` - Flags []string `json:"flags"` + Id uint `json:"id"` + Name string `json:"name"` + ProductPrice float64 `gorm:"type:numeric(15,3);not null"` + SellingPrice *float64 `gorm:"type:numeric(15,3)"` + SupplierPrice float64 `json:"supplier_price"` + Uom *uomDTO.UomRelationDTO `json:"uom,omitempty"` + Flags []string `json:"flags"` } // === Mapper Functions === @@ -42,12 +43,13 @@ func toSupplierProductDTOs(relations []entity.ProductSupplier) []SupplierProduct } result = append(result, SupplierProductDTO{ - Id: product.Id, - Name: product.Name, - ProductPrice: product.ProductPrice, - SellingPrice: product.SellingPrice, - Uom: uomRef, - Flags: flags, + Id: product.Id, + Name: product.Name, + ProductPrice: product.ProductPrice, + SellingPrice: product.SellingPrice, + SupplierPrice: relation.Price, + Uom: uomRef, + Flags: flags, }) } return result From ac5edb36e70606cd974e449d2cafd536d03c3508 Mon Sep 17 00:00:00 2001 From: ragilap Date: Mon, 12 Jan 2026 16:28:03 +0700 Subject: [PATCH 07/76] [FIX/BE] adjustment response --- .../repports/dto/repportDebtSupplier.dto.go | 10 +- .../repositories/debt_supplier.repository.go | 113 ++++++++++- .../repports/services/repport.service.go | 177 ++++++++++++++++-- .../validations/repport.validation.go | 2 +- 4 files changed, 277 insertions(+), 25 deletions(-) diff --git a/internal/modules/repports/dto/repportDebtSupplier.dto.go b/internal/modules/repports/dto/repportDebtSupplier.dto.go index 5dce055f..8699ca60 100644 --- a/internal/modules/repports/dto/repportDebtSupplier.dto.go +++ b/internal/modules/repports/dto/repportDebtSupplier.dto.go @@ -9,8 +9,8 @@ import ( type DebtSupplierRowDTO struct { PrNumber string `json:"pr_number"` PoNumber string `json:"po_number"` - PrDate string `json:"pr_date"` PoDate string `json:"po_date"` + ReceivedDate string `json:"received_date"` Aging int `json:"aging"` Area *areaDTO.AreaRelationDTO `json:"area,omitempty"` Warehouse *warehouseDTO.WarehouseRelationDTO `json:"warehouse,omitempty"` @@ -21,6 +21,7 @@ type DebtSupplierRowDTO struct { DebtPrice float64 `json:"debt_price"` Status string `json:"status"` TravelNumber string `json:"travel_number"` + Balance float64 `json:"balance"` } type DebtSupplierTotalDTO struct { @@ -31,7 +32,8 @@ type DebtSupplierTotalDTO struct { } type DebtSupplierDTO struct { - Supplier *supplierDTO.SupplierRelationDTO `json:"supplier"` - Rows []DebtSupplierRowDTO `json:"rows"` - Total DebtSupplierTotalDTO `json:"total"` + Supplier *supplierDTO.SupplierRelationDTO `json:"supplier"` + InitialBalance float64 `json:"initial_balance"` + Rows []DebtSupplierRowDTO `json:"rows"` + Total DebtSupplierTotalDTO `json:"total"` } diff --git a/internal/modules/repports/repositories/debt_supplier.repository.go b/internal/modules/repports/repositories/debt_supplier.repository.go index 84e9402d..3d415606 100644 --- a/internal/modules/repports/repositories/debt_supplier.repository.go +++ b/internal/modules/repports/repositories/debt_supplier.repository.go @@ -15,7 +15,10 @@ import ( type DebtSupplierRepository interface { GetSuppliersWithPurchases(ctx context.Context, offset, limit int, filters *validation.DebtSupplierQuery) ([]entity.Supplier, int64, error) GetPurchasesBySuppliers(ctx context.Context, supplierIDs []uint, filters *validation.DebtSupplierQuery) ([]entity.Purchase, error) + GetPaymentsBySuppliers(ctx context.Context, supplierIDs []uint, filters *validation.DebtSupplierQuery) ([]entity.Payment, error) GetPaymentTotalsByReferences(ctx context.Context, supplierIDs []uint, references []string) (map[string]float64, error) + GetPurchaseTotalsBeforeDate(ctx context.Context, supplierIDs []uint, filters *validation.DebtSupplierQuery) (map[uint]float64, error) + GetPaymentTotalsBeforeDate(ctx context.Context, supplierIDs []uint, filters *validation.DebtSupplierQuery) (map[uint]float64, error) } type debtSupplierRepositoryImpl struct { @@ -28,10 +31,10 @@ func NewDebtSupplierRepository(db *gorm.DB) DebtSupplierRepository { func resolveDebtSupplierDateColumn(filterBy string) string { switch strings.ToLower(strings.TrimSpace(filterBy)) { + case "receive_date": + return "purchases.receive_date" case "po_date": return "purchases.po_date" - case "pr_date": - return "purchases.created_at" case "do_date", "received_date", "": return "purchase_items.received_date" default: @@ -157,6 +160,39 @@ func (r *debtSupplierRepositoryImpl) GetPurchasesBySuppliers(ctx context.Context return purchases, nil } +func (r *debtSupplierRepositoryImpl) GetPaymentsBySuppliers(ctx context.Context, supplierIDs []uint, filters *validation.DebtSupplierQuery) ([]entity.Payment, error) { + if len(supplierIDs) == 0 { + return []entity.Payment{}, nil + } + + db := r.db.WithContext(ctx). + Model(&entity.Payment{}). + Where("party_type = ?", string(utils.PaymentPartySupplier)). + Where("direction = ?", "OUT"). + Where("party_id IN ?", supplierIDs) + + if strings.TrimSpace(filters.StartDate) != "" { + if dateFrom, err := utils.ParseDateString(filters.StartDate); err == nil { + db = db.Where("DATE(payment_date) >= ?", dateFrom) + } + } + + if strings.TrimSpace(filters.EndDate) != "" { + if dateTo, err := utils.ParseDateString(filters.EndDate); err == nil { + db = db.Where("DATE(payment_date) <= ?", dateTo) + } + } + + var payments []entity.Payment + if err := db. + Order("payment_date ASC, id ASC"). + Find(&payments).Error; err != nil { + return nil, err + } + + return payments, nil +} + func (r *debtSupplierRepositoryImpl) getPurchaseIDs(ctx context.Context, supplierIDs []uint, filters *validation.DebtSupplierQuery) ([]uint, error) { dateColumn := resolveDebtSupplierDateColumn(filters.FilterBy) @@ -219,3 +255,76 @@ func (r *debtSupplierRepositoryImpl) GetPaymentTotalsByReferences(ctx context.Co return result, nil } + +func (r *debtSupplierRepositoryImpl) GetPurchaseTotalsBeforeDate(ctx context.Context, supplierIDs []uint, filters *validation.DebtSupplierQuery) (map[uint]float64, error) { + if len(supplierIDs) == 0 || strings.TrimSpace(filters.StartDate) == "" { + return map[uint]float64{}, nil + } + + dateFrom, err := utils.ParseDateString(filters.StartDate) + if err != nil { + return map[uint]float64{}, nil + } + + dateColumn := resolveDebtSupplierDateColumn(filters.FilterBy) + + type purchaseTotalRow struct { + SupplierID uint `gorm:"column:supplier_id"` + Total float64 `gorm:"column:total"` + } + + rows := make([]purchaseTotalRow, 0) + if err := r.db.WithContext(ctx). + Table("purchases"). + Select("purchases.supplier_id AS supplier_id, SUM(purchase_items.total_price) AS total"). + Joins("JOIN purchase_items ON purchase_items.purchase_id = purchases.id"). + Where("purchases.supplier_id IN ?", supplierIDs). + Where(fmt.Sprintf("DATE(%s) < ?", dateColumn), dateFrom). + Group("purchases.supplier_id"). + Scan(&rows).Error; err != nil { + return nil, err + } + + result := make(map[uint]float64, len(rows)) + for _, row := range rows { + result[row.SupplierID] = row.Total + } + + return result, nil +} + +func (r *debtSupplierRepositoryImpl) GetPaymentTotalsBeforeDate(ctx context.Context, supplierIDs []uint, filters *validation.DebtSupplierQuery) (map[uint]float64, error) { + if len(supplierIDs) == 0 || strings.TrimSpace(filters.StartDate) == "" { + return map[uint]float64{}, nil + } + + dateFrom, err := utils.ParseDateString(filters.StartDate) + if err != nil { + return map[uint]float64{}, nil + } + + type paymentTotalRow struct { + SupplierID uint `gorm:"column:supplier_id"` + Total float64 `gorm:"column:total"` + } + + rows := make([]paymentTotalRow, 0) + if err := r.db.WithContext(ctx). + Model(&entity.Payment{}). + Select("party_id AS supplier_id, SUM(nominal) AS total"). + Where("party_type = ?", string(utils.PaymentPartySupplier)). + Where("direction = ?", "OUT"). + Where("party_id IN ?", supplierIDs). + Where("DATE(payment_date) < ?", dateFrom). + Group("party_id"). + Scan(&rows).Error; err != nil { + return nil, err + } + + result := make(map[uint]float64, len(rows)) + for _, row := range rows { + result[row.SupplierID] = row.Total + } + + return result, nil +} diff --git a/internal/modules/repports/services/repport.service.go b/internal/modules/repports/services/repport.service.go index c7576e5f..5f3cbbad 100644 --- a/internal/modules/repports/services/repport.service.go +++ b/internal/modules/repports/services/repport.service.go @@ -642,8 +642,8 @@ func (s *repportService) GetPurchaseSupplier(c *fiber.Ctx, params *validation.Pu } func (s *repportService) GetDebtSupplier(c *fiber.Ctx, params *validation.DebtSupplierQuery) ([]dto.DebtSupplierDTO, int64, error) { - if params.FilterBy == "" { - params.FilterBy = "do_date" + if params.FilterBy == "" || strings.EqualFold(strings.TrimSpace(params.FilterBy), "do_date") { + params.FilterBy = "received_date" } if err := s.Validate.Struct(params); err != nil { @@ -675,6 +675,11 @@ func (s *repportService) GetDebtSupplier(c *fiber.Ctx, params *validation.DebtSu return nil, 0, err } + payments, err := s.DebtSupplierRepo.GetPaymentsBySuppliers(c.Context(), supplierIDs, params) + if err != nil { + return nil, 0, err + } + purchasesBySupplier := make(map[uint][]entity.Purchase, len(supplierIDs)) references := make([]string, 0) seenRefs := make(map[string]struct{}) @@ -697,6 +702,21 @@ func (s *repportService) GetDebtSupplier(c *fiber.Ctx, params *validation.DebtSu return nil, 0, err } + paymentsBySupplier := make(map[uint][]entity.Payment, len(supplierIDs)) + for _, payment := range payments { + paymentsBySupplier[payment.PartyId] = append(paymentsBySupplier[payment.PartyId], payment) + } + + initialPurchaseTotals, err := s.DebtSupplierRepo.GetPurchaseTotalsBeforeDate(c.Context(), supplierIDs, params) + if err != nil { + return nil, 0, err + } + + initialPaymentTotals, err := s.DebtSupplierRepo.GetPaymentTotalsBeforeDate(c.Context(), supplierIDs, params) + if err != nil { + return nil, 0, err + } + location, err := time.LoadLocation("Asia/Jakarta") if err != nil { return nil, 0, fiber.NewError(fiber.StatusInternalServerError, "failed to load timezone configuration") @@ -710,29 +730,81 @@ func (s *repportService) GetDebtSupplier(c *fiber.Ctx, params *validation.DebtSu continue } + initialBalance := initialPaymentTotals[supplierID] - initialPurchaseTotals[supplierID] + items := purchasesBySupplier[supplierID] - rows := make([]dto.DebtSupplierRowDTO, 0, len(items)) + paymentItems := paymentsBySupplier[supplierID] + rows := make([]dto.DebtSupplierRowDTO, 0, len(items)+len(paymentItems)) total := dto.DebtSupplierTotalDTO{} + type debtSupplierRowItem struct { + Row dto.DebtSupplierRowDTO + SortTime time.Time + Order int + DeltaBalance float64 + CountTotals bool + } + + combinedRows := make([]debtSupplierRowItem, 0, len(items)+len(paymentItems)) for _, purchase := range items { row := buildDebtSupplierRow(purchase, paymentTotals, now, location) - rows = append(rows, row) + sortTime := resolveDebtSupplierSortTime(purchase, params.FilterBy, location) + combinedRows = append(combinedRows, debtSupplierRowItem{ + Row: row, + SortTime: sortTime, + Order: 0, + DeltaBalance: -row.TotalPrice, + CountTotals: true, + }) + } - if row.Aging > total.Aging { - total.Aging = row.Aging + for _, payment := range paymentItems { + row := buildDebtSupplierPaymentRow(payment, location) + sortTime := payment.PaymentDate.In(location) + combinedRows = append(combinedRows, debtSupplierRowItem{ + Row: row, + SortTime: sortTime, + Order: 1, + DeltaBalance: payment.Nominal, + CountTotals: false, + }) + } + + sort.SliceStable(combinedRows, func(i, j int) bool { + if combinedRows[i].SortTime.Equal(combinedRows[j].SortTime) { + return combinedRows[i].Order < combinedRows[j].Order + } + return combinedRows[i].SortTime.Before(combinedRows[j].SortTime) + }) + + balance := initialBalance + for i := range combinedRows { + balance += combinedRows[i].DeltaBalance + combinedRows[i].Row.Balance = balance + + if combinedRows[i].CountTotals { + row := combinedRows[i].Row + if row.Aging > total.Aging { + total.Aging = row.Aging + } + total.TotalPrice += row.TotalPrice + total.PaymentPrice += row.PaymentPrice + total.DebtPrice += row.DebtPrice + } else { + combinedRows[i].Row.DebtPrice = balance } - total.TotalPrice += row.TotalPrice - total.PaymentPrice += row.PaymentPrice - total.DebtPrice += row.DebtPrice } sortDesc := strings.EqualFold(params.SortOrder, "desc") - sort.SliceStable(rows, func(i, j int) bool { - if sortDesc { - return rows[i].PrDate > rows[j].PrDate + if sortDesc { + for i := len(combinedRows) - 1; i >= 0; i-- { + rows = append(rows, combinedRows[i].Row) } - return rows[i].PrDate < rows[j].PrDate - }) + } else { + for i := range combinedRows { + rows = append(rows, combinedRows[i].Row) + } + } var supplierDTORef *supplierDTO.SupplierRelationDTO if supplier.Id != 0 { @@ -741,9 +813,10 @@ func (s *repportService) GetDebtSupplier(c *fiber.Ctx, params *validation.DebtSu } result = append(result, dto.DebtSupplierDTO{ - Supplier: supplierDTORef, - Rows: rows, - Total: total, + Supplier: supplierDTORef, + InitialBalance: initialBalance, + Rows: rows, + Total: total, }) } @@ -769,6 +842,7 @@ func buildDebtSupplierRow(purchase entity.Purchase, paymentTotals map[string]flo totalPrice := 0.0 travelNumber := "-" + receivedDate := "" var area *areaDTO.AreaRelationDTO var warehouse *warehouseDTO.WarehouseRelationDTO @@ -787,8 +861,19 @@ func buildDebtSupplierRow(purchase entity.Purchase, paymentTotals map[string]flo } } + earliestReceived := time.Time{} for _, item := range purchase.Items { totalPrice += item.TotalPrice + if item.ReceivedDate == nil || item.ReceivedDate.IsZero() { + continue + } + received := item.ReceivedDate.In(loc) + if earliestReceived.IsZero() || received.Before(earliestReceived) { + earliestReceived = received + } + } + if !earliestReceived.IsZero() { + receivedDate = earliestReceived.Format("2006-01-02") } } @@ -820,8 +905,8 @@ func buildDebtSupplierRow(purchase entity.Purchase, paymentTotals map[string]flo return dto.DebtSupplierRowDTO{ PrNumber: prNumber, PoNumber: poNumber, - PrDate: prDate.Format("2006-01-02"), PoDate: poDate, + ReceivedDate: receivedDate, Aging: aging, Area: area, Warehouse: warehouse, @@ -835,6 +920,62 @@ func buildDebtSupplierRow(purchase entity.Purchase, paymentTotals map[string]flo } } +func buildDebtSupplierPaymentRow(payment entity.Payment, loc *time.Location) dto.DebtSupplierRowDTO { + referenceNumber := "" + if payment.ReferenceNumber != nil { + referenceNumber = *payment.ReferenceNumber + } + + prNumber := payment.PaymentCode + if strings.TrimSpace(prNumber) == "" { + prNumber = referenceNumber + } + + return dto.DebtSupplierRowDTO{ + PrNumber: prNumber, + PoNumber: referenceNumber, + PoDate: "-", + ReceivedDate: payment.PaymentDate.In(loc).Format("2006-01-02"), + Aging: 0, + Area: nil, + Warehouse: nil, + DueDate: "-", + DueStatus: "-", + TotalPrice: 0, + PaymentPrice: payment.Nominal, + DebtPrice: 0, + Status: "Pembayaran", + TravelNumber: "-", + } +} + +func resolveDebtSupplierSortTime(purchase entity.Purchase, filterBy string, loc *time.Location) time.Time { + switch strings.ToLower(strings.TrimSpace(filterBy)) { + case "po_date": + if purchase.PoDate != nil && !purchase.PoDate.IsZero() { + return purchase.PoDate.In(loc) + } + case "pr_date": + return purchase.CreatedAt.In(loc) + default: + earliest := time.Time{} + for _, item := range purchase.Items { + if item.ReceivedDate == nil || item.ReceivedDate.IsZero() { + continue + } + received := item.ReceivedDate.In(loc) + if earliest.IsZero() || received.Before(earliest) { + earliest = received + } + } + if !earliest.IsZero() { + return earliest + } + } + + return purchase.CreatedAt.In(loc) +} + func (s *repportService) GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangResponseData, *dto.HppPerKandangMetaDTO, error) { params, filters, err := s.parseHppPerKandangQuery(ctx) if err != nil { diff --git a/internal/modules/repports/validations/repport.validation.go b/internal/modules/repports/validations/repport.validation.go index 6c80275f..5b60a31f 100644 --- a/internal/modules/repports/validations/repport.validation.go +++ b/internal/modules/repports/validations/repport.validation.go @@ -49,7 +49,7 @@ type DebtSupplierQuery struct { SupplierIDs []int64 `query:"-" validate:"omitempty,dive,gt=0"` StartDate string `query:"start_date" validate:"omitempty,datetime=2006-01-02"` EndDate string `query:"end_date" validate:"omitempty,datetime=2006-01-02"` - FilterBy string `query:"filter_by" validate:"omitempty,oneof=do_date po_date pr_date"` + FilterBy string `query:"filter_by" validate:"omitempty,oneof=received_date po_date pr_date do_date"` SortOrder string `query:"sort_order" validate:"omitempty,oneof=asc desc"` } From f0b4fe916c918494689c08b8fc0a9f71b71ad5dc Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Mon, 12 Jan 2026 20:00:49 +0700 Subject: [PATCH 08/76] FEAT[BE] ;: inisiate customer payment report route and related DTOs --- internal/middleware/permissions.go | 6 +- .../chickins/services/chickin.service.go | 1 - .../controllers/repport.controller.go | 27 ++++++++ .../dto/repportCustomerPayment.dto.go | 63 +++++++++++++++++++ internal/modules/repports/route.go | 2 +- 5 files changed, 95 insertions(+), 4 deletions(-) create mode 100644 internal/modules/repports/dto/repportCustomerPayment.dto.go diff --git a/internal/middleware/permissions.go b/internal/middleware/permissions.go index 10741bff..9450d228 100644 --- a/internal/middleware/permissions.go +++ b/internal/middleware/permissions.go @@ -1,8 +1,9 @@ package middleware -const( +const ( P_DashboardGetAll = "lti.dashboard.list" ) + // project-flock const ( P_ProjectFlockKandangsClosing = "lti.production.project_flock_kandangs.closing" @@ -50,6 +51,7 @@ const ( P_ReportDebtSupplierGetAll = "lti.repport.debtsupplier.list" P_ReportHppPerKandangGetAll = "lti.repport.gethppperkandang.list" P_ReportProductionResultGetAll = "lti.repport.production_result.list" + P_ReportCustomerPaymentGetAll = "lti.repport.customerpayment.list" ) const ( @@ -150,7 +152,7 @@ const ( P_ProductsCreateOne = "lti.master.products.create" P_ProductsUpdateOne = "lti.master.products.update" P_ProductsDeleteOne = "lti.master.products.delete" - + P_SuppliersGetAll = "lti.master.suppliers.list" P_SuppliersGetOne = "lti.master.suppliers.detail" P_SuppliersCreateOne = "lti.master.suppliers.create" diff --git a/internal/modules/production/chickins/services/chickin.service.go b/internal/modules/production/chickins/services/chickin.service.go index eabe596c..84e98f2d 100644 --- a/internal/modules/production/chickins/services/chickin.service.go +++ b/internal/modules/production/chickins/services/chickin.service.go @@ -584,7 +584,6 @@ func (s chickinService) Approval(c *fiber.Ctx, req *validation.Approve) ([]entit return updated, nil } -// autoAddFlagToProduct adds target flag to product if not already present (idempotent) func (s *chickinService) autoAddFlagToProduct(ctx context.Context, tx *gorm.DB, productID uint, targetFlag utils.FlagType) error { if s.ProductRepo == nil { return nil diff --git a/internal/modules/repports/controllers/repport.controller.go b/internal/modules/repports/controllers/repport.controller.go index 22ff4acf..d89effa3 100644 --- a/internal/modules/repports/controllers/repport.controller.go +++ b/internal/modules/repports/controllers/repport.controller.go @@ -282,6 +282,33 @@ func (c *RepportController) GetProductionResult(ctx *fiber.Ctx) error { }) } +func (c *RepportController) GetCustomerPayment(ctx *fiber.Ctx) error { + page := ctx.QueryInt("page", 1) + limit := ctx.QueryInt("limit", 10) + + if page < 1 || limit < 1 { + return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0") + } + + // TODO: Implement service call + data := []dto.CustomerPaymentReportItem{} + totalResults := int64(0) + + return ctx.Status(fiber.StatusOK). + JSON(response.SuccessWithPaginate[dto.CustomerPaymentReportItem]{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get customer payment report successfully", + Meta: response.Meta{ + Page: page, + Limit: limit, + TotalPages: int64(math.Ceil(float64(totalResults) / float64(limit))), + TotalResults: totalResults, + }, + Data: data, + }) +} + func parseCommaSeparatedInt64s(raw string) ([]int64, error) { raw = strings.TrimSpace(raw) if raw == "" { diff --git a/internal/modules/repports/dto/repportCustomerPayment.dto.go b/internal/modules/repports/dto/repportCustomerPayment.dto.go new file mode 100644 index 00000000..e0938b51 --- /dev/null +++ b/internal/modules/repports/dto/repportCustomerPayment.dto.go @@ -0,0 +1,63 @@ +package dto + +import ( + "time" +) + +// CustomerPaymentReportCustomer represents customer information in the report +type CustomerPaymentReportCustomer struct { + ID uint `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + AccountNumber string `json:"account_number"` + Balance float64 `json:"balance"` + Address string `json:"address"` +} + +// CustomerPaymentReportRow represents each transaction row +type CustomerPaymentReportRow struct { + ID uint `json:"id"` + DoDate time.Time `json:"do_date"` + RealizationDate time.Time `json:"realization_date"` + AgingDay int `json:"aging_day"` + Reference string `json:"reference"` + VehiclePlate []string `json:"vehicle_plate"` + Qty float64 `json:"qty"` + Weight float64 `json:"weight"` + AverageWeight float64 `json:"average_weight"` + Price float64 `json:"price"` + CreditNote float64 `json:"credit_note"` + FinalPrice float64 `json:"final_price"` + PPN float64 `json:"ppn"` + Total float64 `json:"total"` + Payment float64 `json:"payment"` + AccountsReceivable float64 `json:"accounts_receivable"` + Notes string `json:"notes"` + PickupInfo string `json:"pickup_info"` + SalesMarketing string `json:"sales_marketing"` +} + +// CustomerPaymentReportSummary represents summary calculations per customer +type CustomerPaymentReportSummary struct { + TotalQty float64 `json:"total_qty"` + TotalWeight float64 `json:"total_weight"` + TotalInitialAmount float64 `json:"total_initial_amount"` + TotalCreditNote float64 `json:"total_credit_note"` + TotalFinalAmount float64 `json:"total_final_amount"` + TotalPPN float64 `json:"total_ppn"` + TotalGrandAmount float64 `json:"total_grand_amount"` + TotalPayment float64 `json:"total_payment"` + TotalAccountsReceivable float64 `json:"total_accounts_receivable"` +} + +// CustomerPaymentReportItem represents data grouped by customer +type CustomerPaymentReportItem struct { + Customer CustomerPaymentReportCustomer `json:"customer"` + Rows []CustomerPaymentReportRow `json:"rows"` + Summary CustomerPaymentReportSummary `json:"summary"` +} + +// CustomerPaymentReportResponse represents the complete response +type CustomerPaymentReportResponse struct { + Data []CustomerPaymentReportItem `json:"data"` +} diff --git a/internal/modules/repports/route.go b/internal/modules/repports/route.go index 0a0cf8a3..2f5eceec 100644 --- a/internal/modules/repports/route.go +++ b/internal/modules/repports/route.go @@ -21,5 +21,5 @@ func RepportRoutes(v1 fiber.Router, u user.UserService, s repport.RepportService route.Get("/debt-supplier", m.RequirePermissions(m.P_ReportDebtSupplierGetAll), ctrl.GetDebtSupplier) route.Get("/hpp-per-kandang", m.RequirePermissions(m.P_ReportHppPerKandangGetAll), ctrl.GetHppPerKandang) route.Get("/production-result/:idProjectFlockKandang", m.RequirePermissions(m.P_ReportProductionResultGetAll), ctrl.GetProductionResult) - + route.Get("/customer-payment", m.RequirePermissions(m.P_ReportCustomerPaymentGetAll), ctrl.GetCustomerPayment) } From 6a166ceb8601856c6cbd073d0920553b707b3fb8 Mon Sep 17 00:00:00 2001 From: ragilap Date: Tue, 13 Jan 2026 00:15:48 +0700 Subject: [PATCH 09/76] [FIX/BE-US-390] dashboard statistic hpp global and avg seling price not refrence to filter --- .../dashboards/services/dashboard.service.go | 30 ++++++++++++------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/internal/modules/dashboards/services/dashboard.service.go b/internal/modules/dashboards/services/dashboard.service.go index 8fa0a2c9..f929cc7b 100644 --- a/internal/modules/dashboards/services/dashboard.service.go +++ b/internal/modules/dashboards/services/dashboard.service.go @@ -98,12 +98,14 @@ func (s dashboardService) buildPerformanceStatistics(ctx context.Context, params endDate := params.PeriodEnd endExclusive := params.PeriodEndExclusive - hppCurrent, hppLast, err := s.calculateHppGlobal(ctx, filter, startDate, endExclusive, endDate, location) + globalStartDate, globalEndDate, globalEndExclusive := currentPeriodDates(location) + + hppCurrent, hppLast, err := s.calculateHppGlobal(ctx, globalStartDate, globalEndExclusive, globalEndDate, location) if err != nil { return nil, err } - sellingCurrent, sellingLast, err := s.calculateSellingPrice(ctx, filter, endDate, location) + sellingCurrent, sellingLast, err := s.calculateSellingPrice(ctx, globalEndDate, location) if err != nil { return nil, err } @@ -843,12 +845,12 @@ func percentDelta(current, last float64) float64 { return (current - last) / last } -func (s dashboardService) calculateHppGlobal(ctx context.Context, filter *validation.DashboardFilter, startDate, endExclusive, endDate time.Time, location *time.Location) (float64, float64, error) { - totalEggKg, err := s.Repository.SumEggProductionWeightKg(ctx, startDate, endExclusive, filter) +func (s dashboardService) calculateHppGlobal(ctx context.Context, startDate, endExclusive, endDate time.Time, location *time.Location) (float64, float64, error) { + totalEggKg, err := s.Repository.SumEggProductionWeightKg(ctx, startDate, endExclusive, nil) if err != nil { return 0, 0, err } - totalCost, err := s.sumHppCost(ctx, filter, startDate, endExclusive) + totalCost, err := s.sumHppCost(ctx, nil, startDate, endExclusive) if err != nil { return 0, 0, err } @@ -859,11 +861,11 @@ func (s dashboardService) calculateHppGlobal(ctx context.Context, filter *valida } lastMonthStart, lastMonthEndExclusive := monthRange(endDate.AddDate(0, -1, 0), location) - lastEggKg, err := s.Repository.SumEggProductionWeightKg(ctx, lastMonthStart, lastMonthEndExclusive, filter) + lastEggKg, err := s.Repository.SumEggProductionWeightKg(ctx, lastMonthStart, lastMonthEndExclusive, nil) if err != nil { return 0, 0, err } - lastCost, err := s.sumHppCost(ctx, filter, lastMonthStart, lastMonthEndExclusive) + lastCost, err := s.sumHppCost(ctx, nil, lastMonthStart, lastMonthEndExclusive) if err != nil { return 0, 0, err } @@ -876,16 +878,16 @@ func (s dashboardService) calculateHppGlobal(ctx context.Context, filter *valida return hppCurrent, hppLast, nil } -func (s dashboardService) calculateSellingPrice(ctx context.Context, filter *validation.DashboardFilter, endDate time.Time, location *time.Location) (float64, float64, error) { +func (s dashboardService) calculateSellingPrice(ctx context.Context, endDate time.Time, location *time.Location) (float64, float64, error) { startPrevMonth, endPrevMonthExclusive := monthRange(endDate.AddDate(0, -1, 0), location) currentEndExclusive := endDate.AddDate(0, 0, 1) - currentAvg, err := s.avgSellingPrice(ctx, filter, startPrevMonth, currentEndExclusive) + currentAvg, err := s.avgSellingPrice(ctx, nil, startPrevMonth, currentEndExclusive) if err != nil { return 0, 0, err } - lastAvg, err := s.avgSellingPrice(ctx, filter, startPrevMonth, endPrevMonthExclusive) + lastAvg, err := s.avgSellingPrice(ctx, nil, startPrevMonth, endPrevMonthExclusive) if err != nil { return 0, 0, err } @@ -1027,3 +1029,11 @@ func monthRange(t time.Time, location *time.Location) (time.Time, time.Time) { endExclusive := start.AddDate(0, 1, 0) return start, endExclusive } + +func currentPeriodDates(location *time.Location) (time.Time, time.Time, time.Time) { + now := time.Now().In(location) + startDate := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, location) + endDate := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, location) + endExclusive := endDate.AddDate(0, 0, 1) + return startDate, endDate, endExclusive +} From bba2dec8c645a4ed420f621b9ee28e57a80636c7 Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Tue, 13 Jan 2026 09:52:25 +0700 Subject: [PATCH 10/76] FEAT[BE] :update route --- internal/modules/repports/controllers/repport.controller.go | 5 ++++- internal/modules/repports/route.go | 2 +- internal/modules/repports/services/repport.service.go | 6 ++++++ 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/internal/modules/repports/controllers/repport.controller.go b/internal/modules/repports/controllers/repport.controller.go index d89effa3..613c4e00 100644 --- a/internal/modules/repports/controllers/repport.controller.go +++ b/internal/modules/repports/controllers/repport.controller.go @@ -289,8 +289,11 @@ func (c *RepportController) GetCustomerPayment(ctx *fiber.Ctx) error { if page < 1 || limit < 1 { return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0") } + + + + - // TODO: Implement service call data := []dto.CustomerPaymentReportItem{} totalResults := int64(0) diff --git a/internal/modules/repports/route.go b/internal/modules/repports/route.go index 2f5eceec..3f803677 100644 --- a/internal/modules/repports/route.go +++ b/internal/modules/repports/route.go @@ -21,5 +21,5 @@ func RepportRoutes(v1 fiber.Router, u user.UserService, s repport.RepportService route.Get("/debt-supplier", m.RequirePermissions(m.P_ReportDebtSupplierGetAll), ctrl.GetDebtSupplier) route.Get("/hpp-per-kandang", m.RequirePermissions(m.P_ReportHppPerKandangGetAll), ctrl.GetHppPerKandang) route.Get("/production-result/:idProjectFlockKandang", m.RequirePermissions(m.P_ReportProductionResultGetAll), ctrl.GetProductionResult) - route.Get("/customer-payment", m.RequirePermissions(m.P_ReportCustomerPaymentGetAll), ctrl.GetCustomerPayment) + route.Get("/customer-payment", ctrl.GetCustomerPayment) } diff --git a/internal/modules/repports/services/repport.service.go b/internal/modules/repports/services/repport.service.go index c7576e5f..e54d8674 100644 --- a/internal/modules/repports/services/repport.service.go +++ b/internal/modules/repports/services/repport.service.go @@ -40,6 +40,7 @@ type RepportService interface { GetDebtSupplier(ctx *fiber.Ctx, params *validation.DebtSupplierQuery) ([]dto.DebtSupplierDTO, int64, error) GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangResponseData, *dto.HppPerKandangMetaDTO, error) GetProductionResult(ctx *fiber.Ctx, params *validation.ProductionResultQuery) ([]dto.ProductionResultDTO, int64, error) + GetCustomerPayment(ctx *fiber.Ctx) (int, error) } type repportService struct { @@ -1232,6 +1233,11 @@ func (s *repportService) parseHppPerKandangQuery(ctx *fiber.Ctx) (*validation.Hp return params, filters, nil } +func (s *repportService) GetCustomerPayment(c *fiber.Ctx) (int, error) { + + return 0, nil +} + func parseCommaSeparatedInt64s(raw string) ([]int64, error) { raw = strings.TrimSpace(raw) if raw == "" { From 4bd8319e3b2ce563b419a1e562e97ca4d6914174 Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Tue, 13 Jan 2026 10:16:28 +0700 Subject: [PATCH 11/76] FIX[BE] : fixing typografical error on report marketing --- .../repports/dto/repportMarketing.dto.go | 50 ++++++++++++++++++- 1 file changed, 48 insertions(+), 2 deletions(-) diff --git a/internal/modules/repports/dto/repportMarketing.dto.go b/internal/modules/repports/dto/repportMarketing.dto.go index 90c2fe50..3f133674 100644 --- a/internal/modules/repports/dto/repportMarketing.dto.go +++ b/internal/modules/repports/dto/repportMarketing.dto.go @@ -1,12 +1,16 @@ package dto import ( + "encoding/json" "time" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" marketingDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/dto" customerDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/customers/dto" + productCategoryDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/product-categories/dto" productDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/products/dto" + supplierDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/suppliers/dto" + uomDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/uoms/dto" warehouseDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/dto" userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto" "gitlab.com/mbugroup/lti-api.git/internal/utils" @@ -22,7 +26,7 @@ type RepportMarketingItemDTO struct { DoNumber string `json:"do_number"` Sales *userDTO.UserRelationDTO `json:"sales,omitempty"` VehicleNumber string `json:"vehicle_number"` - Product *productDTO.ProductRelationDTO `json:"product,omitempty"` + Product *ProductRelationDTOFixed `json:"product,omitempty"` MarketingType string `json:"marketing_type"` Qty float64 `json:"qty"` AverageWeightKg float64 `json:"average_weight_kg"` @@ -46,6 +50,12 @@ type RepportMarketingResponseDTO struct { Total *Summary `json:"total,omitempty"` } +type ProductRelationDTOFixed struct { + productDTO.ProductRelationDTO + ProductPrice float64 `json:"product_price"` + SellingPrice *float64 `json:"selling_price,omitempty"` +} + func ToRepportMarketingItemDTO(mdp entity.MarketingDeliveryProduct, hppPricePerKg float64, category string) RepportMarketingItemDTO { soDate := time.Time{} agingDays := 0 @@ -106,7 +116,7 @@ func ToRepportMarketingItemDTO(mdp entity.MarketingDeliveryProduct, hppPricePerK if mdp.MarketingProduct.ProductWarehouse.ProductId != 0 { mapped := productDTO.ToProductRelationDTO(mdp.MarketingProduct.ProductWarehouse.Product) - item.Product = &mapped + item.Product = newProductRelationDTOFixedPtr(&mapped) } return item @@ -259,3 +269,39 @@ func ToRepportMarketingResponseDTO(mdps []entity.MarketingDeliveryProduct, hppPr Total: total, } } + +func newProductRelationDTOFixedPtr(original *productDTO.ProductRelationDTO) *ProductRelationDTOFixed { + if original == nil { + return nil + } + fixed := ProductRelationDTOFixed{ + ProductRelationDTO: *original, + ProductPrice: original.ProductPrice, + SellingPrice: original.SellingPrice, + } + return &fixed +} + +func (p ProductRelationDTOFixed) MarshalJSON() ([]byte, error) { + type Alias struct { + Id uint `json:"id"` + Name string `json:"name"` + ProductPrice float64 `json:"product_price"` + SellingPrice *float64 `json:"selling_price"` + Uom *uomDTO.UomRelationDTO `json:"uom,omitempty"` + Flags *[]string `json:"flags,omitempty"` + ProductCategory *productCategoryDTO.ProductCategoryRelationDTO `json:"product_category,omitempty"` + Suppliers []supplierDTO.SupplierRelationDTO `json:"suppliers"` + } + + return json.Marshal(&Alias{ + Id: p.ProductRelationDTO.Id, + Name: p.ProductRelationDTO.Name, + ProductPrice: p.ProductPrice, + SellingPrice: p.SellingPrice, + Uom: p.ProductRelationDTO.Uom, + Flags: p.ProductRelationDTO.Flags, + ProductCategory: p.ProductRelationDTO.ProductCategory, + Suppliers: p.ProductRelationDTO.Suppliers, + }) +} From f7a392be528e53a49c6cb7658bc9c69ab5b22bbb Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Tue, 13 Jan 2026 11:37:23 +0700 Subject: [PATCH 12/76] feat[BE]: add GetPenjualanByProjectFlockKandang endpoint and update related services --- .../controllers/closing.controller.go | 39 +++++++++++++++---- .../closings/dto/closingMarketing.dto.go | 2 +- internal/modules/closings/route.go | 2 + .../closings/services/closing.service.go | 32 ++------------- .../salesorder_delivery_product.repository.go | 38 ++++++++++++++++++ 5 files changed, 77 insertions(+), 36 deletions(-) diff --git a/internal/modules/closings/controllers/closing.controller.go b/internal/modules/closings/controllers/closing.controller.go index 6ab2d398..1a472f03 100644 --- a/internal/modules/closings/controllers/closing.controller.go +++ b/internal/modules/closings/controllers/closing.controller.go @@ -108,12 +108,7 @@ func (u *ClosingController) GetPenjualan(c *fiber.Ctx) error { return fiber.NewError(fiber.StatusBadRequest, "Invalid Project Flock Id") } - projectFlock, err := u.ClosingService.GetProjectFlockByID(c, uint(projectFlockID)) - if err != nil { - return err - } - - result, err := u.ClosingService.GetPenjualan(c, uint(projectFlockID)) + result, err := u.ClosingService.GetPenjualan(c, uint(projectFlockID), nil) if err != nil { return err } @@ -123,7 +118,37 @@ func (u *ClosingController) GetPenjualan(c *fiber.Ctx) error { Code: fiber.StatusOK, Status: "success", Message: "Get closing penjualan successfully", - Data: dto.ToPenjualanRealisasiResponseDTO(projectFlock.Category, uint(projectFlockID), result), + Data: dto.ToPenjualanRealisasiResponseDTO(uint(projectFlockID), result), + }) +} + +func (u *ClosingController) GetPenjualanByProjectFlockKandang(c *fiber.Ctx) error { + projectParam := c.Params("project_flock_id") + kandangParam := c.Params("project_flock_kandang_id") + + projectFlockID, err := strconv.Atoi(projectParam) + if err != nil || projectFlockID <= 0 { + return fiber.NewError(fiber.StatusBadRequest, "Invalid project_flock_id") + } + + pfkID, err := strconv.Atoi(kandangParam) + if err != nil || pfkID <= 0 { + return fiber.NewError(fiber.StatusBadRequest, "Invalid project_flock_kandang_id") + } + + kandangID := uint(pfkID) + + result, err := u.ClosingService.GetPenjualan(c, uint(projectFlockID), &kandangID) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get closing penjualan by project flock kandang successfully", + Data: dto.ToPenjualanRealisasiResponseDTO(uint(projectFlockID), result), }) } diff --git a/internal/modules/closings/dto/closingMarketing.dto.go b/internal/modules/closings/dto/closingMarketing.dto.go index 42d95be2..1a790ad6 100644 --- a/internal/modules/closings/dto/closingMarketing.dto.go +++ b/internal/modules/closings/dto/closingMarketing.dto.go @@ -87,7 +87,7 @@ func ToSalesDTOs(e []entity.MarketingDeliveryProduct) []SalesDTO { return result } -func ToPenjualanRealisasiResponseDTO(projectType string, projectFlockID uint, e []entity.MarketingDeliveryProduct) PenjualanRealisasiResponseDTO { +func ToPenjualanRealisasiResponseDTO(projectFlockID uint, e []entity.MarketingDeliveryProduct) PenjualanRealisasiResponseDTO { return PenjualanRealisasiResponseDTO{ diff --git a/internal/modules/closings/route.go b/internal/modules/closings/route.go index 79c83c22..a9d25758 100644 --- a/internal/modules/closings/route.go +++ b/internal/modules/closings/route.go @@ -23,6 +23,7 @@ func ClosingRoutes(v1 fiber.Router, u user.UserService, s closing.ClosingService route.Get("/", m.RequirePermissions(m.P_ClosingGetAll), ctrl.GetAll) route.Get("/:project_flock_id/penjualan", m.RequirePermissions(m.P_ClosingDetail), ctrl.GetPenjualan) + route.Get("/:project_flock_id/:project_flock_kandang_id/penjualan", m.RequirePermissions(m.P_ClosingDetail), ctrl.GetPenjualanByProjectFlockKandang) route.Get("/:projectFlockId", m.RequirePermissions(m.P_ClosingDetail), ctrl.GetClosingSummary) route.Get("/:project_flock_id/overhead", m.RequirePermissions(m.P_ClosingDetail), ctrl.GetOverhead) route.Get("/:project_flock_id/:project_flock_kandang_id/perhitungan_sapronak", m.RequirePermissions(m.P_ClosingDetail), ctrl.GetSapronakByKandang) @@ -32,4 +33,5 @@ func ClosingRoutes(v1 fiber.Router, u user.UserService, s closing.ClosingService route.Get("/:project_flock_id/:project_flock_kandang_id/expedition-hpp", m.RequirePermissions(m.P_ClosingDetail), ctrl.GetExpeditionHPPByKandang) route.Get("/:projectFlockId/production-data", m.RequirePermissions(m.P_ClosingDetail), ctrl.GetClosingDataProduksi) route.Get("/:projectFlockId/keuangan", m.RequirePermissions(m.P_ClosingDetail), ctrl.GetClosingKeuangan) + } diff --git a/internal/modules/closings/services/closing.service.go b/internal/modules/closings/services/closing.service.go index 245fd24c..6c682b9c 100644 --- a/internal/modules/closings/services/closing.service.go +++ b/internal/modules/closings/services/closing.service.go @@ -32,7 +32,7 @@ import ( type ClosingService interface { GetAll(ctx *fiber.Ctx, params *validation.Query) ([]dto.ClosingListItemDTO, int64, error) GetProjectFlockByID(ctx *fiber.Ctx, id uint) (*entity.ProjectFlock, error) - GetPenjualan(ctx *fiber.Ctx, projectFlockID uint) ([]entity.MarketingDeliveryProduct, error) + GetPenjualan(ctx *fiber.Ctx, projectFlockID uint, projectFlockKandangID *uint) ([]entity.MarketingDeliveryProduct, error) GetClosingSummary(ctx *fiber.Ctx, projectFlockID uint) (*dto.ClosingSummaryDTO, error) GetOverhead(ctx *fiber.Ctx, projectFlockID uint) (*dto.OverheadListDTO, error) GetClosingDataProduksi(ctx *fiber.Ctx, projectFlockID uint) (*dto.ClosingProductionReportDTO, error) @@ -129,24 +129,9 @@ func (s closingService) GetProjectFlockByID(c *fiber.Ctx, id uint) (*entity.Proj return projectFlock, nil } -func (s closingService) GetPenjualan(c *fiber.Ctx, projectFlockID uint) ([]entity.MarketingDeliveryProduct, error) { +func (s closingService) GetPenjualan(c *fiber.Ctx, projectFlockID uint, projectFlockKandangID *uint) ([]entity.MarketingDeliveryProduct, error) { - realisasi, err := s.MarketingDeliveryProductRepo.GetDeliveryProductsByProjectFlockID(c.Context(), projectFlockID, func(db *gorm.DB) *gorm.DB { - return db. - Preload("MarketingProduct"). - Preload("MarketingProduct.ProductWarehouse"). - Preload("MarketingProduct.ProductWarehouse.Product"). - Preload("MarketingProduct.ProductWarehouse.Product.ProductCategory"). - Preload("MarketingProduct.ProductWarehouse.Product.Uom"). - Preload("MarketingProduct.ProductWarehouse.Product.Flags"). - Preload("MarketingProduct.ProductWarehouse.Warehouse"). - Preload("MarketingProduct.ProductWarehouse.ProjectFlockKandang"). - Preload("MarketingProduct.ProductWarehouse.ProjectFlockKandang.Kandang"). - Preload("MarketingProduct.ProductWarehouse.ProjectFlockKandang.Chickins"). - Preload("MarketingProduct.Marketing"). - Preload("MarketingProduct.Marketing.Customer"). - Order("marketing_delivery_products.delivery_date DESC") - }) + realisasi, err := s.MarketingDeliveryProductRepo.GetClosingPenjualan(c.Context(), projectFlockID, projectFlockKandangID) if err != nil { return nil, err } @@ -154,16 +139,7 @@ func (s closingService) GetPenjualan(c *fiber.Ctx, projectFlockID uint) ([]entit return []entity.MarketingDeliveryProduct{}, nil } - filtered := make([]entity.MarketingDeliveryProduct, 0, len(realisasi)) - for _, item := range realisasi { - - if item.UsageQty != 0 || item.TotalWeight != 0 || item.AvgWeight != 0 || - item.UnitPrice != 0 || item.TotalPrice != 0 { - filtered = append(filtered, item) - } - } - - return filtered, nil + return realisasi, nil } func (s closingService) GetClosingSummary(c *fiber.Ctx, projectFlockID uint) (*dto.ClosingSummaryDTO, error) { diff --git a/internal/modules/marketing/repositories/salesorder_delivery_product.repository.go b/internal/modules/marketing/repositories/salesorder_delivery_product.repository.go index 04051009..bb1343a2 100644 --- a/internal/modules/marketing/repositories/salesorder_delivery_product.repository.go +++ b/internal/modules/marketing/repositories/salesorder_delivery_product.repository.go @@ -14,6 +14,7 @@ import ( type MarketingDeliveryProductRepository interface { repository.BaseRepository[entity.MarketingDeliveryProduct] GetDeliveryProductsByProjectFlockID(ctx context.Context, projectFlockID uint, callback func(*gorm.DB) *gorm.DB) ([]entity.MarketingDeliveryProduct, error) + GetClosingPenjualan(ctx context.Context, projectFlockID uint, projectFlockKandangID *uint) ([]entity.MarketingDeliveryProduct, error) GetByMarketingId(ctx context.Context, marketingId uint) ([]entity.MarketingDeliveryProduct, error) GetByMarketingProductID(ctx context.Context, marketingProductID uint) (*entity.MarketingDeliveryProduct, error) GetAllWithFilters(ctx context.Context, offset, limit int, filters *validation.MarketingQuery) ([]entity.MarketingDeliveryProduct, int64, error) @@ -53,6 +54,43 @@ func (r *MarketingDeliveryProductRepositoryImpl) GetDeliveryProductsByProjectFlo return deliveryProducts, nil } +func (r *MarketingDeliveryProductRepositoryImpl) GetClosingPenjualan(ctx context.Context, projectFlockID uint, projectFlockKandangID *uint) ([]entity.MarketingDeliveryProduct, error) { + var deliveryProducts []entity.MarketingDeliveryProduct + + db := r.DB().WithContext(ctx). + Joins("JOIN marketing_products ON marketing_products.id = marketing_delivery_products.marketing_product_id"). + Joins("JOIN product_warehouses ON product_warehouses.id = marketing_products.product_warehouse_id"). + Joins("JOIN project_flock_kandangs ON project_flock_kandangs.id = product_warehouses.project_flock_kandang_id"). + Where("project_flock_kandangs.project_flock_id = ?", projectFlockID). + Where("marketing_delivery_products.delivery_date IS NOT NULL"). + Distinct("marketing_delivery_products.*") + + if projectFlockKandangID != nil { + db = db.Where("product_warehouses.project_flock_kandang_id = ?", *projectFlockKandangID) + } + + db = db. + Preload("MarketingProduct"). + Preload("MarketingProduct.ProductWarehouse"). + Preload("MarketingProduct.ProductWarehouse.Product"). + Preload("MarketingProduct.ProductWarehouse.Product.ProductCategory"). + Preload("MarketingProduct.ProductWarehouse.Product.Uom"). + Preload("MarketingProduct.ProductWarehouse.Product.Flags"). + Preload("MarketingProduct.ProductWarehouse.Warehouse"). + Preload("MarketingProduct.ProductWarehouse.ProjectFlockKandang"). + Preload("MarketingProduct.ProductWarehouse.ProjectFlockKandang.Kandang"). + Preload("MarketingProduct.ProductWarehouse.ProjectFlockKandang.Chickins"). + Preload("MarketingProduct.Marketing"). + Preload("MarketingProduct.Marketing.Customer"). + Order("marketing_delivery_products.delivery_date DESC") + + if err := db.Find(&deliveryProducts).Error; err != nil { + return nil, err + } + + return deliveryProducts, nil +} + func (r *MarketingDeliveryProductRepositoryImpl) GetByMarketingId(ctx context.Context, marketingId uint) ([]entity.MarketingDeliveryProduct, error) { var deliveryProducts []entity.MarketingDeliveryProduct From 3c10866208e2cbf2f1dfca047e39900aba7aaa8b Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Tue, 13 Jan 2026 13:20:06 +0700 Subject: [PATCH 13/76] feat[BE]: add GetOverheadByProjectFlockKandang endpoint and update related services --- .../controllers/closing.controller.go | 51 +++++++++++++++-- .../closings/dto/closingKeuangan.dto.go | 17 +++--- .../closings/dto/closingOverhead.dto.go | 55 ++++++++++++++++-- internal/modules/closings/module.go | 2 +- internal/modules/closings/route.go | 1 + .../closings/services/closing.service.go | 56 +++++++++++++++---- .../expense_realization.repository.go | 40 +++++++++++++ 7 files changed, 190 insertions(+), 32 deletions(-) diff --git a/internal/modules/closings/controllers/closing.controller.go b/internal/modules/closings/controllers/closing.controller.go index 1a472f03..c4ef4585 100644 --- a/internal/modules/closings/controllers/closing.controller.go +++ b/internal/modules/closings/controllers/closing.controller.go @@ -78,6 +78,36 @@ func (u *ClosingController) GetOne(c *fiber.Ctx) error { }) } +func (u *ClosingController) GetOverheadByProjectFlockKandang(c *fiber.Ctx) error { + projectParam := c.Params("project_flock_id") + kandangParam := c.Params("project_flock_kandang_id") + + projectFlockID, err := strconv.Atoi(projectParam) + if err != nil || projectFlockID <= 0 { + return fiber.NewError(fiber.StatusBadRequest, "Invalid project_flock_id") + } + + pfkID, err := strconv.Atoi(kandangParam) + if err != nil || pfkID <= 0 { + return fiber.NewError(fiber.StatusBadRequest, "Invalid project_flock_kandang_id") + } + + kandangID := uint(pfkID) + + result, err := u.ClosingService.GetOverhead(c, uint(projectFlockID), &kandangID) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get overhead by project flock kandang successfully", + Data: result, + }) +} + func (u *ClosingController) GetClosingSummary(c *fiber.Ctx) error { param := c.Params("projectFlockId") @@ -153,14 +183,25 @@ func (u *ClosingController) GetPenjualanByProjectFlockKandang(c *fiber.Ctx) erro } func (u *ClosingController) GetOverhead(c *fiber.Ctx) error { - param := c.Params("project_flock_id") + projectParam := c.Params("project_flock_id") + kandangParam := c.Params("project_flock_kandang_id") // bisa kosong - projectFlockID, err := strconv.Atoi(param) - if err != nil { - return fiber.NewError(fiber.StatusBadRequest, "Invalid Project Flock Id") + projectFlockID, err := strconv.Atoi(projectParam) + if err != nil || projectFlockID <= 0 { + return fiber.NewError(fiber.StatusBadRequest, "Invalid project_flock_id") } - result, err := u.ClosingService.GetOverhead(c, uint(projectFlockID)) + var projectFlockKandangID *uint + if kandangParam != "" { + pfkID, err := strconv.Atoi(kandangParam) + if err != nil || pfkID <= 0 { + return fiber.NewError(fiber.StatusBadRequest, "Invalid project_flock_kandang_id") + } + kandangID := uint(pfkID) + projectFlockKandangID = &kandangID + } + + result, err := u.ClosingService.GetOverhead(c, uint(projectFlockID), projectFlockKandangID) if err != nil { return err } diff --git a/internal/modules/closings/dto/closingKeuangan.dto.go b/internal/modules/closings/dto/closingKeuangan.dto.go index 08bfb5fc..fa99a59d 100644 --- a/internal/modules/closings/dto/closingKeuangan.dto.go +++ b/internal/modules/closings/dto/closingKeuangan.dto.go @@ -79,10 +79,11 @@ type HppGroup struct { } type SummaryHpp struct { - Label string `json:"label"` - Comparison `json:"-"` - EggBudgeting *FinancialMetrics `json:"egg_budgeting,omitempty"` - EggRealization *FinancialMetrics `json:"egg_realization,omitempty"` + Label string `json:"label"` + Budgeting FinancialMetrics `json:"budgeting"` + Realization FinancialMetrics `json:"realization"` + EggBudgeting *FinancialMetrics `json:"egg_budgeting,omitempty"` + EggRealization *FinancialMetrics `json:"egg_realization,omitempty"` } type HppPurchasesSection struct { @@ -246,11 +247,9 @@ func ToSummaryHpp(label string, purchaseItems []entities.PurchaseItem, budgets [ realizationRpPerBird, realizationRpPerKg := calculatePerUnitMetrics(totalRealization, ctx.TotalPopulation, ctx.TotalWeightProduced) summary := SummaryHpp{ - Label: label, - Comparison: ToComparison( - ToFinancialMetrics(budgetRpPerBird, budgetRpPerKg, totalBudget), - ToFinancialMetrics(realizationRpPerBird, realizationRpPerKg, totalRealization), - ), + Label: label, + Budgeting: ToFinancialMetrics(budgetRpPerBird, budgetRpPerKg, totalBudget), + Realization: ToFinancialMetrics(realizationRpPerBird, realizationRpPerKg, totalRealization), } if projectFlockCategory == string(utils.ProjectFlockCategoryLaying) && ctx.TotalEggWeightKg > 0 { diff --git a/internal/modules/closings/dto/closingOverhead.dto.go b/internal/modules/closings/dto/closingOverhead.dto.go index 71975da1..42903794 100644 --- a/internal/modules/closings/dto/closingOverhead.dto.go +++ b/internal/modules/closings/dto/closingOverhead.dto.go @@ -1,6 +1,8 @@ package dto import ( + "encoding/json" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" ) @@ -69,7 +71,7 @@ func ToOverheadDTO(budget *entity.ProjectBudget, realization *entity.ExpenseReal return dto } -func ToOverheadListDTOs(budgets []entity.ProjectBudget, realizations []entity.ExpenseRealization, totalChickinQty, totalActualPopulation float64) OverheadListDTO { +func ToOverheadListDTOs(budgets []entity.ProjectBudget, realizations []entity.ExpenseRealization, totalChickinQty, totalActualPopulation float64, isPerKandang bool, totalKandangCount int) OverheadListDTO { overheadsByNonstockID := make(map[uint]*OverheadDTO) latestDateByNonstockID := make(map[uint]string) @@ -82,9 +84,19 @@ func ToOverheadListDTOs(budgets []entity.ProjectBudget, realizations []entity.Ex itemName, itemUOM := getItemInfo(budgets[i].Nonstock) overheadsByNonstockID[nonstockID].ItemName = itemName overheadsByNonstockID[nonstockID].UOMName = itemUOM - overheadsByNonstockID[nonstockID].BudgetQuantity = budgets[i].Qty - overheadsByNonstockID[nonstockID].BudgetUnitPrice = budgets[i].Price - overheadsByNonstockID[nonstockID].BudgetTotalAmount = calculateTotal(budgets[i].Qty, budgets[i].Price) + + budgetQty := budgets[i].Qty + budgetPrice := budgets[i].Price + budgetTotal := calculateTotal(budgets[i].Qty, budgets[i].Price) + + if isPerKandang && totalKandangCount > 0 { + budgetQty = budgetQty / float64(totalKandangCount) + budgetTotal = budgetTotal / float64(totalKandangCount) + } + + overheadsByNonstockID[nonstockID].BudgetQuantity = budgetQty + overheadsByNonstockID[nonstockID].BudgetUnitPrice = budgetPrice + overheadsByNonstockID[nonstockID].BudgetTotalAmount = budgetTotal } for i := range realizations { @@ -97,8 +109,22 @@ func ToOverheadListDTOs(budgets []entity.ProjectBudget, realizations []entity.Ex overheadsByNonstockID[nonstockID] = &OverheadDTO{} } - overheadsByNonstockID[nonstockID].ActualQuantity += realizations[i].Qty - overheadsByNonstockID[nonstockID].ActualTotalAmount += calculateTotal(realizations[i].Qty, realizations[i].Price) + // Check if this is farm-level expense (multiple project flocks) + qty := realizations[i].Qty + totalAmount := calculateTotal(realizations[i].Qty, realizations[i].Price) + + if realizations[i].ExpenseNonstock.Expense != nil && + realizations[i].ExpenseNonstock.Expense.ProjectFlockId != nil { + projectFlockCount := countProjectFlocksInJSON(*realizations[i].ExpenseNonstock.Expense.ProjectFlockId) + if projectFlockCount > 1 { + // Bagi biaya sesuai jumlah project flock + qty = qty / float64(projectFlockCount) + totalAmount = totalAmount / float64(projectFlockCount) + } + } + + overheadsByNonstockID[nonstockID].ActualQuantity += qty + overheadsByNonstockID[nonstockID].ActualTotalAmount += totalAmount if overheadsByNonstockID[nonstockID].ItemName == "" { itemName, itemUOM := getItemInfo(realizations[i].ExpenseNonstock.Nonstock) @@ -148,6 +174,23 @@ func ToOverheadListDTOs(budgets []entity.ProjectBudget, realizations []entity.Ex // === Helper Functions === +func countProjectFlocksInJSON(projectFlockJSON string) int { + if projectFlockJSON == "" { + return 0 + } + + var projectFlocks []int + if err := json.Unmarshal([]byte(projectFlockJSON), &projectFlocks); err != nil { + return 1 // default to 1 if parsing fails + } + + if len(projectFlocks) == 0 { + return 1 + } + + return len(projectFlocks) +} + func getItemInfo(nonstock *entity.Nonstock) (string, string) { if nonstock != nil && nonstock.Id != 0 { return nonstock.Name, nonstock.Uom.Name diff --git a/internal/modules/closings/module.go b/internal/modules/closings/module.go index c89e6125..658f1bef 100644 --- a/internal/modules/closings/module.go +++ b/internal/modules/closings/module.go @@ -37,7 +37,7 @@ func (ClosingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate * approvalRepo := commonRepo.NewApprovalRepository(db) approvalService := commonSvc.NewApprovalService(approvalRepo) - closingService := sClosing.NewClosingService(closingRepo, projectFlockRepo, marketingRepo, marketingDeliveryProductRepo, approvalService, expenseRealizationRepo, projectBudgetRepo, chickinRepo, purchaseRepo, recordingRepo, validate) + closingService := sClosing.NewClosingService(closingRepo, projectFlockRepo, projectFlockKandangRepo, marketingRepo, marketingDeliveryProductRepo, approvalService, expenseRealizationRepo, projectBudgetRepo, chickinRepo, purchaseRepo, recordingRepo, validate) sapronakService := sClosing.NewSapronakService(closingRepo, projectFlockKandangRepo, validate) userService := sUser.NewUserService(userRepo, validate) diff --git a/internal/modules/closings/route.go b/internal/modules/closings/route.go index a9d25758..1cd4559d 100644 --- a/internal/modules/closings/route.go +++ b/internal/modules/closings/route.go @@ -26,6 +26,7 @@ func ClosingRoutes(v1 fiber.Router, u user.UserService, s closing.ClosingService route.Get("/:project_flock_id/:project_flock_kandang_id/penjualan", m.RequirePermissions(m.P_ClosingDetail), ctrl.GetPenjualanByProjectFlockKandang) route.Get("/:projectFlockId", m.RequirePermissions(m.P_ClosingDetail), ctrl.GetClosingSummary) route.Get("/:project_flock_id/overhead", m.RequirePermissions(m.P_ClosingDetail), ctrl.GetOverhead) + route.Get("/:project_flock_id/:project_flock_kandang_id/overhead", m.RequirePermissions(m.P_ClosingDetail), ctrl.GetOverhead) route.Get("/:project_flock_id/:project_flock_kandang_id/perhitungan_sapronak", m.RequirePermissions(m.P_ClosingDetail), ctrl.GetSapronakByKandang) route.Get("/:project_flock_id/perhitungan_sapronak", m.RequirePermissions(m.P_ClosingDetail), ctrl.GetSapronakByProject) route.Get("/:projectFlockId/sapronak", m.RequirePermissions(m.P_ClosingDetail), ctrl.GetClosingSapronak) diff --git a/internal/modules/closings/services/closing.service.go b/internal/modules/closings/services/closing.service.go index 6c682b9c..5870aa12 100644 --- a/internal/modules/closings/services/closing.service.go +++ b/internal/modules/closings/services/closing.service.go @@ -34,7 +34,7 @@ type ClosingService interface { GetProjectFlockByID(ctx *fiber.Ctx, id uint) (*entity.ProjectFlock, error) GetPenjualan(ctx *fiber.Ctx, projectFlockID uint, projectFlockKandangID *uint) ([]entity.MarketingDeliveryProduct, error) GetClosingSummary(ctx *fiber.Ctx, projectFlockID uint) (*dto.ClosingSummaryDTO, error) - GetOverhead(ctx *fiber.Ctx, projectFlockID uint) (*dto.OverheadListDTO, error) + GetOverhead(ctx *fiber.Ctx, projectFlockID uint, projectFlockKandangID *uint) (*dto.OverheadListDTO, error) GetClosingDataProduksi(ctx *fiber.Ctx, projectFlockID uint) (*dto.ClosingProductionReportDTO, error) GetClosingSapronak(ctx *fiber.Ctx, projectFlockID uint, params *validation.ClosingSapronakQuery) ([]dto.ClosingSapronakItemDTO, int64, error) GetClosingKeuangan(ctx *fiber.Ctx, projectFlockID uint) (*dto.ReportResponse, error) @@ -46,6 +46,7 @@ type closingService struct { Validate *validator.Validate Repository repository.ClosingRepository ProjectFlockRepo projectflockRepository.ProjectflockRepository + ProjectFlockKandangRepo projectflockRepository.ProjectFlockKandangRepository MarketingRepo marketingRepository.MarketingRepository MarketingDeliveryProductRepo marketingDeliveryProductRepository.MarketingDeliveryProductRepository ApprovalSvc commonSvc.ApprovalService @@ -56,12 +57,13 @@ type closingService struct { RecordingRepo recordingRepository.RecordingRepository } -func NewClosingService(repo repository.ClosingRepository, projectFlockRepo projectflockRepository.ProjectflockRepository, marketingRepo marketingRepository.MarketingRepository, marketingDeliveryProductRepo marketingDeliveryProductRepository.MarketingDeliveryProductRepository, approvalSvc commonSvc.ApprovalService, expenseRealizationRepo expenseRealizationRepository.ExpenseRealizationRepository, projectBudgetRepo projectflockRepository.ProjectBudgetRepository, chickinRepo chickinRepository.ProjectChickinRepository, purchaseRepo purchaseRepository.PurchaseRepository, recordingRepo recordingRepository.RecordingRepository, validate *validator.Validate) ClosingService { +func NewClosingService(repo repository.ClosingRepository, projectFlockRepo projectflockRepository.ProjectflockRepository, projectFlockKandangRepo projectflockRepository.ProjectFlockKandangRepository, marketingRepo marketingRepository.MarketingRepository, marketingDeliveryProductRepo marketingDeliveryProductRepository.MarketingDeliveryProductRepository, approvalSvc commonSvc.ApprovalService, expenseRealizationRepo expenseRealizationRepository.ExpenseRealizationRepository, projectBudgetRepo projectflockRepository.ProjectBudgetRepository, chickinRepo chickinRepository.ProjectChickinRepository, purchaseRepo purchaseRepository.PurchaseRepository, recordingRepo recordingRepository.RecordingRepository, validate *validator.Validate) ClosingService { return &closingService{ Log: utils.Log, Validate: validate, Repository: repo, ProjectFlockRepo: projectFlockRepo, + ProjectFlockKandangRepo: projectFlockKandangRepo, MarketingRepo: marketingRepo, MarketingDeliveryProductRepo: marketingDeliveryProductRepo, ApprovalSvc: approvalSvc, @@ -355,35 +357,67 @@ func (s closingService) getApprovalStatuses(ctx context.Context, projectFlockID return statusProject, statusClosing, nil } -func (s closingService) GetOverhead(c *fiber.Ctx, projectFlockID uint) (*dto.OverheadListDTO, error) { +func (s closingService) GetOverhead(c *fiber.Ctx, projectFlockID uint, projectFlockKandangID *uint) (*dto.OverheadListDTO, error) { budgets, err := s.ProjectBudgetRepo.GetByProjectFlockID(c.Context(), projectFlockID) if err != nil { return nil, err } - realizations, err := s.ExpenseRealizationRepo.GetByProjectFlockID(c.Context(), projectFlockID) + realizations, err := s.ExpenseRealizationRepo.GetClosingOverhead(c.Context(), projectFlockID, projectFlockKandangID) if err != nil { return nil, err } + // Count total kandang in project flock (for budget division if per kandang) + projectFlockKandangs, err := s.ProjectFlockKandangRepo.GetByProjectFlockID(c.Context(), projectFlockID) + if err != nil { + return nil, err + } + totalKandangCount := len(projectFlockKandangs) + chickins, err := s.ChickinRepo.GetByProjectFlockID(c.Context(), projectFlockID) if err != nil { return nil, err } var totalChickinQty float64 - for _, chickin := range chickins { - totalChickinQty += chickin.UsageQty - } + var totalDepletion float64 - totalDepletion, err := s.RecordingRepo.GetTotalDepletionByProjectFlockID(c.Context(), projectFlockID) - if err != nil { - s.Log.Warnf("GetTotalDepletionByProjectFlockID error: %v", err) + if projectFlockKandangID != nil { + + for _, chickin := range chickins { + if chickin.ProjectFlockKandangId == *projectFlockKandangID { + totalChickinQty += chickin.UsageQty + } + } + + var depletionResult float64 + err = s.RecordingRepo.DB().WithContext(c.Context()). + Table("recording_depletions"). + Select("COALESCE(SUM(recording_depletions.qty), 0)"). + Joins("JOIN recordings ON recordings.id = recording_depletions.recording_id"). + Where("recordings.project_flock_kandangs_id = ?", *projectFlockKandangID). + Scan(&depletionResult).Error + if err != nil { + s.Log.Warnf("GetTotalDepletionByProjectFlockKandangID error: %v", err) + } else { + totalDepletion = depletionResult + } + } else { + + for _, chickin := range chickins { + totalChickinQty += chickin.UsageQty + } + + totalDepletion, err = s.RecordingRepo.GetTotalDepletionByProjectFlockID(c.Context(), projectFlockID) + if err != nil { + s.Log.Warnf("GetTotalDepletionByProjectFlockID error: %v", err) + } } totalActualPopulation := totalChickinQty - totalDepletion - result := dto.ToOverheadListDTOs(budgets, realizations, totalChickinQty, totalActualPopulation) + result := dto.ToOverheadListDTOs(budgets, realizations, totalChickinQty, totalActualPopulation, projectFlockKandangID != nil, totalKandangCount) return &result, nil } diff --git a/internal/modules/expenses/repositories/expense_realization.repository.go b/internal/modules/expenses/repositories/expense_realization.repository.go index f1387483..2c7db649 100644 --- a/internal/modules/expenses/repositories/expense_realization.repository.go +++ b/internal/modules/expenses/repositories/expense_realization.repository.go @@ -2,6 +2,7 @@ package repository import ( "context" + "fmt" "gitlab.com/mbugroup/lti-api.git/internal/common/repository" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" @@ -15,6 +16,7 @@ type ExpenseRealizationRepository interface { IdExists(ctx context.Context, id uint64) (bool, error) GetByExpenseNonstockID(ctx context.Context, expenseNonstockID uint64) (*entity.ExpenseRealization, error) GetByProjectFlockID(ctx context.Context, projectFlockID uint) ([]entity.ExpenseRealization, error) + GetClosingOverhead(ctx context.Context, projectFlockID uint, projectFlockKandangID *uint) ([]entity.ExpenseRealization, error) GetAllWithFilters(ctx context.Context, offset, limit int, filters *validation.ExpenseQuery) ([]entity.ExpenseRealization, int64, error) } @@ -55,6 +57,44 @@ func (r *ExpenseRealizationRepositoryImpl) GetByProjectFlockID(ctx context.Conte return realizations, err } +func (r *ExpenseRealizationRepositoryImpl) GetClosingOverhead(ctx context.Context, projectFlockID uint, projectFlockKandangID *uint) ([]entity.ExpenseRealization, error) { + var realizations []entity.ExpenseRealization + + db := r.DB().WithContext(ctx). + Preload("ExpenseNonstock"). + Preload("ExpenseNonstock.Nonstock"). + Preload("ExpenseNonstock.Nonstock.Uom"). + Preload("ExpenseNonstock.Nonstock.Flags"). + Preload("ExpenseNonstock.Expense"). + Joins("JOIN expense_nonstocks ON expense_nonstocks.id = expense_realizations.expense_nonstock_id"). + Joins("JOIN expenses ON expenses.id = expense_nonstocks.expense_id"). + Joins("LEFT JOIN project_flock_kandangs ON project_flock_kandangs.id = expense_nonstocks.project_flock_kandang_id"). + Joins("LEFT JOIN kandangs ON kandangs.id = expense_nonstocks.kandang_id"). + Where("expenses.realization_date IS NOT NULL") + + // Build WHERE clause for project flock filtering + if projectFlockKandangID != nil { + // Per kandang: hanya ambil expense yang specific ke kandang tersebut + // SKIP expense level farm (yang punya multiple project flocks di JSON array) + // IMPORTANT: Untuk kandang_id, pastikan kandang tersebut belong to project_flock_kandang ini + db = db.Where(`( + expense_nonstocks.project_flock_kandang_id = ? OR + (expense_nonstocks.kandang_id = (SELECT kandang_id FROM project_flock_kandangs WHERE id = ?) AND + expense_nonstocks.project_flock_kandang_id IS NULL) + )`, *projectFlockKandangID, *projectFlockKandangID) + } else { + // All kandang: include expense kandang-specific DAN expense level farm + db = db.Where(`( + project_flock_kandangs.project_flock_id = ? OR + kandangs.id IN (SELECT kandang_id FROM project_flock_kandangs WHERE project_flock_id = ?) OR + (expenses.project_flock_id IS NOT NULL AND expenses.project_flock_id::jsonb @> ?::jsonb) + )`, projectFlockID, projectFlockID, fmt.Sprintf("[%d]", projectFlockID)) + } + + err := db.Find(&realizations).Error + return realizations, err +} + func (r *ExpenseRealizationRepositoryImpl) GetAllWithFilters(ctx context.Context, offset, limit int, filters *validation.ExpenseQuery) ([]entity.ExpenseRealization, int64, error) { var realizations []entity.ExpenseRealization var total int64 From b088eebac5f9aa8a3256c3c865b47062fe663cac Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Tue, 13 Jan 2026 13:36:08 +0700 Subject: [PATCH 14/76] feat[BE]: enhance GetOverhead functionality with project flock kandang count mapping and update related DTOs --- .../controllers/closing.controller.go | 2 +- .../closings/dto/closingOverhead.dto.go | 57 +++++++++++++------ .../closings/services/closing.service.go | 32 +++++++++-- .../expense_realization.repository.go | 10 +--- 4 files changed, 71 insertions(+), 30 deletions(-) diff --git a/internal/modules/closings/controllers/closing.controller.go b/internal/modules/closings/controllers/closing.controller.go index c4ef4585..a10d6a94 100644 --- a/internal/modules/closings/controllers/closing.controller.go +++ b/internal/modules/closings/controllers/closing.controller.go @@ -184,7 +184,7 @@ func (u *ClosingController) GetPenjualanByProjectFlockKandang(c *fiber.Ctx) erro func (u *ClosingController) GetOverhead(c *fiber.Ctx) error { projectParam := c.Params("project_flock_id") - kandangParam := c.Params("project_flock_kandang_id") // bisa kosong + kandangParam := c.Params("project_flock_kandang_id") projectFlockID, err := strconv.Atoi(projectParam) if err != nil || projectFlockID <= 0 { diff --git a/internal/modules/closings/dto/closingOverhead.dto.go b/internal/modules/closings/dto/closingOverhead.dto.go index 42903794..4730474a 100644 --- a/internal/modules/closings/dto/closingOverhead.dto.go +++ b/internal/modules/closings/dto/closingOverhead.dto.go @@ -71,7 +71,7 @@ func ToOverheadDTO(budget *entity.ProjectBudget, realization *entity.ExpenseReal return dto } -func ToOverheadListDTOs(budgets []entity.ProjectBudget, realizations []entity.ExpenseRealization, totalChickinQty, totalActualPopulation float64, isPerKandang bool, totalKandangCount int) OverheadListDTO { +func ToOverheadListDTOs(budgets []entity.ProjectBudget, realizations []entity.ExpenseRealization, totalChickinQty, totalActualPopulation float64, isPerKandang bool, totalKandangCount int, projectFlockKandangCountMap map[uint]int) OverheadListDTO { overheadsByNonstockID := make(map[uint]*OverheadDTO) latestDateByNonstockID := make(map[uint]string) @@ -89,6 +89,7 @@ func ToOverheadListDTOs(budgets []entity.ProjectBudget, realizations []entity.Ex budgetPrice := budgets[i].Price budgetTotal := calculateTotal(budgets[i].Qty, budgets[i].Price) + // Budget division: per kandang view only if isPerKandang && totalKandangCount > 0 { budgetQty = budgetQty / float64(totalKandangCount) budgetTotal = budgetTotal / float64(totalKandangCount) @@ -109,17 +110,35 @@ func ToOverheadListDTOs(budgets []entity.ProjectBudget, realizations []entity.Ex overheadsByNonstockID[nonstockID] = &OverheadDTO{} } - // Check if this is farm-level expense (multiple project flocks) qty := realizations[i].Qty totalAmount := calculateTotal(realizations[i].Qty, realizations[i].Price) + // Farm-level expense division if realizations[i].ExpenseNonstock.Expense != nil && realizations[i].ExpenseNonstock.Expense.ProjectFlockId != nil { - projectFlockCount := countProjectFlocksInJSON(*realizations[i].ExpenseNonstock.Expense.ProjectFlockId) - if projectFlockCount > 1 { - // Bagi biaya sesuai jumlah project flock - qty = qty / float64(projectFlockCount) - totalAmount = totalAmount / float64(projectFlockCount) + projectFlockIDs := parseProjectFlockIDsFromJSON(*realizations[i].ExpenseNonstock.Expense.ProjectFlockId) + + if len(projectFlockIDs) > 0 { + totalKandangInAllProjects := 0 + for _, pfID := range projectFlockIDs { + if count, exists := projectFlockKandangCountMap[pfID]; exists { + totalKandangInAllProjects += count + } + } + + if totalKandangInAllProjects > 0 { + if isPerKandang { + qty = qty / float64(totalKandangInAllProjects) + totalAmount = totalAmount / float64(totalKandangInAllProjects) + } else { + // Overhead ALL: divide by total kandang then multiply by this project's kandang count + perKandangAmount := totalAmount / float64(totalKandangInAllProjects) + perKandangQty := qty / float64(totalKandangInAllProjects) + + qty = perKandangQty * float64(totalKandangCount) + totalAmount = perKandangAmount * float64(totalKandangCount) + } + } } } @@ -172,22 +191,24 @@ func ToOverheadListDTOs(budgets []entity.ProjectBudget, realizations []entity.Ex } } -// === Helper Functions === +func parseProjectFlockIDsFromJSON(projectFlockJSON string) []uint { + if projectFlockJSON == "" { + return []uint{} + } + + var projectFlocks []uint + if err := json.Unmarshal([]byte(projectFlockJSON), &projectFlocks); err != nil { + return []uint{} + } + + return projectFlocks +} func countProjectFlocksInJSON(projectFlockJSON string) int { - if projectFlockJSON == "" { - return 0 - } - - var projectFlocks []int - if err := json.Unmarshal([]byte(projectFlockJSON), &projectFlocks); err != nil { - return 1 // default to 1 if parsing fails - } - + projectFlocks := parseProjectFlockIDsFromJSON(projectFlockJSON) if len(projectFlocks) == 0 { return 1 } - return len(projectFlocks) } diff --git a/internal/modules/closings/services/closing.service.go b/internal/modules/closings/services/closing.service.go index 5870aa12..f137901d 100644 --- a/internal/modules/closings/services/closing.service.go +++ b/internal/modules/closings/services/closing.service.go @@ -2,6 +2,7 @@ package service import ( "context" + "encoding/json" "errors" "math" "strconv" @@ -368,13 +369,38 @@ func (s closingService) GetOverhead(c *fiber.Ctx, projectFlockID uint, projectFl return nil, err } - // Count total kandang in project flock (for budget division if per kandang) projectFlockKandangs, err := s.ProjectFlockKandangRepo.GetByProjectFlockID(c.Context(), projectFlockID) if err != nil { return nil, err } totalKandangCount := len(projectFlockKandangs) + // Build kandang count map for farm expense division + projectFlockKandangCountMap := make(map[uint]int) + projectFlockKandangCountMap[projectFlockID] = totalKandangCount + + involvedProjectFlocks := make(map[uint]bool) + for _, realization := range realizations { + if realization.ExpenseNonstock != nil && + realization.ExpenseNonstock.Expense != nil && + realization.ExpenseNonstock.Expense.ProjectFlockId != nil { + var projectFlockIDs []uint + if err := json.Unmarshal([]byte(*realization.ExpenseNonstock.Expense.ProjectFlockId), &projectFlockIDs); err == nil { + for _, pfID := range projectFlockIDs { + if pfID != projectFlockID { + involvedProjectFlocks[pfID] = true + } + } + } + } + } + + for pfID := range involvedProjectFlocks { + if pfKandangs, err := s.ProjectFlockKandangRepo.GetByProjectFlockID(c.Context(), pfID); err == nil { + projectFlockKandangCountMap[pfID] = len(pfKandangs) + } + } + chickins, err := s.ChickinRepo.GetByProjectFlockID(c.Context(), projectFlockID) if err != nil { return nil, err @@ -384,7 +410,6 @@ func (s closingService) GetOverhead(c *fiber.Ctx, projectFlockID uint, projectFl var totalDepletion float64 if projectFlockKandangID != nil { - for _, chickin := range chickins { if chickin.ProjectFlockKandangId == *projectFlockKandangID { totalChickinQty += chickin.UsageQty @@ -404,7 +429,6 @@ func (s closingService) GetOverhead(c *fiber.Ctx, projectFlockID uint, projectFl totalDepletion = depletionResult } } else { - for _, chickin := range chickins { totalChickinQty += chickin.UsageQty } @@ -417,7 +441,7 @@ func (s closingService) GetOverhead(c *fiber.Ctx, projectFlockID uint, projectFl totalActualPopulation := totalChickinQty - totalDepletion - result := dto.ToOverheadListDTOs(budgets, realizations, totalChickinQty, totalActualPopulation, projectFlockKandangID != nil, totalKandangCount) + result := dto.ToOverheadListDTOs(budgets, realizations, totalChickinQty, totalActualPopulation, projectFlockKandangID != nil, totalKandangCount, projectFlockKandangCountMap) return &result, nil } diff --git a/internal/modules/expenses/repositories/expense_realization.repository.go b/internal/modules/expenses/repositories/expense_realization.repository.go index 2c7db649..60ec97a7 100644 --- a/internal/modules/expenses/repositories/expense_realization.repository.go +++ b/internal/modules/expenses/repositories/expense_realization.repository.go @@ -72,18 +72,14 @@ func (r *ExpenseRealizationRepositoryImpl) GetClosingOverhead(ctx context.Contex Joins("LEFT JOIN kandangs ON kandangs.id = expense_nonstocks.kandang_id"). Where("expenses.realization_date IS NOT NULL") - // Build WHERE clause for project flock filtering if projectFlockKandangID != nil { - // Per kandang: hanya ambil expense yang specific ke kandang tersebut - // SKIP expense level farm (yang punya multiple project flocks di JSON array) - // IMPORTANT: Untuk kandang_id, pastikan kandang tersebut belong to project_flock_kandang ini db = db.Where(`( expense_nonstocks.project_flock_kandang_id = ? OR (expense_nonstocks.kandang_id = (SELECT kandang_id FROM project_flock_kandangs WHERE id = ?) AND - expense_nonstocks.project_flock_kandang_id IS NULL) - )`, *projectFlockKandangID, *projectFlockKandangID) + expense_nonstocks.project_flock_kandang_id IS NULL) OR + (expenses.project_flock_id IS NOT NULL AND expenses.project_flock_id::jsonb @> ?::jsonb) + )`, *projectFlockKandangID, *projectFlockKandangID, fmt.Sprintf("[%d]", projectFlockID)) } else { - // All kandang: include expense kandang-specific DAN expense level farm db = db.Where(`( project_flock_kandangs.project_flock_id = ? OR kandangs.id IN (SELECT kandang_id FROM project_flock_kandangs WHERE project_flock_id = ?) OR From eaa208f73321d21494f961066bb65f71e2f74f83 Mon Sep 17 00:00:00 2001 From: ragilap Date: Tue, 13 Jan 2026 14:11:53 +0700 Subject: [PATCH 15/76] [FIX/BE-US-281] response recording and add payload record_at only in createOne --- .DS_Store | Bin 6148 -> 8196 bytes .../recordings/dto/recording.dto.go | 419 +++++++++++------- .../repositories/recording.repository.go | 11 +- .../recordings/services/recording.service.go | 63 ++- .../validations/recording.validation.go | 1 + 5 files changed, 342 insertions(+), 152 deletions(-) diff --git a/.DS_Store b/.DS_Store index e39247fdff6549a6304ce8065c332c38da11c1a4..be6f22d7458c6ebeb623d831ffa7f5c4a8127cb7 100644 GIT binary patch delta 715 zcmd5)u}Z^G6uplm=8*<%Q9>52?dD+64-l&8(8+?UAZ-&R_+qq;Lnk|Q%M>0i4qf~J zDH;79!9Nf|)RQ=vWO4PueYw1M?m73|m)Xv&V-pcOxOI1lvXmpTZ@re>Sc%hO?M3fd zNjLe2@~Cs3OjXuSP4$y`1`TOM9=V7sipZxviLMBT0}&1~2E-G>`N|ivjM=3Sb(g3a z(KfP>0=mXN#)(5BzXcES32uZuRhuWPe^gDN3~X7Oy;rmiI{ej^J>8O))@as?r*sY% zqS%S-Xsy&(3fPtb37;;|c0V^8!t}T-ncJO9q3_-4FqDnQhq 0 { - eggs = ToRecordingEggDTOs(e.Eggs) - } + listDTO := toRecordingListDTO(e) return RecordingDetailDTO{ RecordingListDTO: listDTO, - Depletions: ToRecordingDepletionDTOs(e.Depletions), - Stocks: ToRecordingStockDTOs(e.Stocks), - Eggs: eggs, + Warehouse: recordingWarehouseDTO(e), + ProductCategory: recordingProductCategory(e), + Depletions: ToRecordingDepletionDTOs(e.Depletions), + Stocks: ToRecordingStockDTOs(e.Stocks), + Eggs: ToRecordingEggDTOs(e.Eggs), } } @@ -233,11 +160,15 @@ func ToRecordingStockDTOs(stocks []entity.RecordingStock) []RecordingStockDTO { if s.UsageQty != nil { usageAmount = *s.UsageQty } + var pendingQty float64 + if s.PendingQty != nil { + pendingQty = *s.PendingQty + } result[i] = RecordingStockDTO{ ProductWarehouseId: s.ProductWarehouseId, UsageAmount: usageAmount, - PendingQty: s.PendingQty, + PendingQty: pendingQty, ProductWarehouse: mapProductWarehouseDTO(&s.ProductWarehouse), } } @@ -258,6 +189,184 @@ func ToRecordingEggDTOs(eggs []entity.RecordingEgg) []RecordingEggDTO { return result } +func toRecordingListDTO(e entity.Recording) RecordingListDTO { + relation := toRecordingRelationDTO(e) + + var createdUser *userDTO.UserRelationDTO + if e.CreatedUser != nil && e.CreatedUser.Id != 0 { + mapped := userDTO.ToUserRelationDTO(*e.CreatedUser) + createdUser = &mapped + } + + return RecordingListDTO{ + RecordingRelationDTO: relation, + CreatedAt: e.CreatedAt, + UpdatedAt: e.UpdatedAt, + CreatedUser: createdUser, + } +} + +func toRecordingRelationDTO(e entity.Recording) RecordingRelationDTO { + latestApproval := defaultRecordingLatestApproval(e) + if e.LatestApproval != nil { + snapshot := approvalDTO.ToApprovalDTO(*e.LatestApproval) + latestApproval = snapshot + } + + return RecordingRelationDTO{ + Id: e.Id, + ProjectFlock: toRecordingProjectFlockDTO(e), + RecordDatetime: e.RecordDatetime, + Day: intValue(e.Day), + TotalDepletionQty: floatValue(e.TotalDepletionQty), + CumDepletionRate: floatValue(e.CumDepletionRate), + CumIntake: intValue(e.CumIntake), + FcrValue: floatValue(e.FcrValue), + HenDay: floatValue(e.HenDay), + HenHouse: floatValue(e.HenHouse), + FeedIntake: floatValue(e.FeedIntake), + EggMass: floatValue(e.EggMass), + EggWeight: floatValue(e.EggWeight), + Approval: latestApproval, + } +} + +func toRecordingProjectFlockDTO(e entity.Recording) RecordingProjectFlockDTO { + result := RecordingProjectFlockDTO{ + ProjectFlockKandangId: e.ProjectFlockKandangId, + } + + pfk := e.ProjectFlockKandang + if pfk == nil { + return result + } + + if pfk.ProjectFlock.Id != 0 { + result.FlockName = pfk.ProjectFlock.FlockName + if pfk.ProjectFlock.Category != "" { + result.ProjectFlockCategory = strings.ToUpper(pfk.ProjectFlock.Category) + } + } + + result.Period = pfk.Period + + if pfk.ProjectFlock.ProductionStandard.Id != 0 { + result.ProductionStandart = &RecordingProductionStandardDTO{ + Id: pfk.ProjectFlock.ProductionStandard.Id, + Week: recordingWeekValue(e), + Name: pfk.ProjectFlock.ProductionStandard.Name, + HenDayStd: floatValue(e.StandardHenDay), + HenHouseStd: floatValue(e.StandardHenHouse), + FeedIntakeStd: floatValue(e.StandardFeedIntake), + MaxDepletionStd: floatValue(e.StandardMaxDepletion), + EggMassStd: floatValue(e.StandardEggMass), + EggWeightStd: floatValue(e.StandardEggWeight), + } + } + + if pfk.ProjectFlock.Fcr.Id != 0 || e.StandardFcr != nil { + result.Fcr = &RecordingFcrDTO{ + Id: pfk.ProjectFlock.Fcr.Id, + Name: pfk.ProjectFlock.Fcr.Name, + FcrStd: floatValue(e.StandardFcr), + } + } + + result.TotalChickQty = floatValue(e.TotalChickQty) + + return result +} + +func recordingWeekValue(e entity.Recording) int { + day := intValue(e.Day) + if day <= 0 { + return 0 + } + weekBase := 1 + if isLayingRecording(e) { + weekBase = 18 + } + return ((day - 1) / 7) + weekBase +} + +func isLayingRecording(e entity.Recording) bool { + if e.ProjectFlockKandang == nil { + return false + } + return strings.EqualFold(e.ProjectFlockKandang.ProjectFlock.Category, string(utils.ProjectFlockCategoryLaying)) +} + +func recordingProductCategory(e entity.Recording) string { + if e.ProjectFlockKandang == nil { + return "" + } + project := e.ProjectFlockKandang.ProjectFlock + if project.Id == 0 { + return "" + } + if project.ProductionStandard.Id != 0 && project.ProductionStandard.ProjectCategory != "" { + return strings.ToUpper(project.ProductionStandard.ProjectCategory) + } + if project.Category != "" { + return strings.ToUpper(project.Category) + } + return "" +} + +func recordingWarehouseDTO(e entity.Recording) *RecordingWarehouseDTO { + pw := primaryProductWarehouse(e) + if pw == nil || pw.Warehouse.Id == 0 { + return nil + } + return mapWarehouseDTO(&pw.Warehouse) +} + +func primaryProductWarehouse(e entity.Recording) *entity.ProductWarehouse { + if len(e.Stocks) > 0 { + pw := e.Stocks[0].ProductWarehouse + if pw.Id != 0 { + return &pw + } + } + if len(e.Depletions) > 0 { + pw := e.Depletions[0].ProductWarehouse + if pw.Id != 0 { + return &pw + } + } + if len(e.Eggs) > 0 { + pw := e.Eggs[0].ProductWarehouse + if pw.Id != 0 { + return &pw + } + } + return nil +} + +func mapWarehouseDTO(wh *entity.Warehouse) *RecordingWarehouseDTO { + if wh == nil || wh.Id == 0 { + return nil + } + dto := &RecordingWarehouseDTO{ + Id: wh.Id, + Name: wh.Name, + } + if wh.Area.Id != 0 { + dto.Area = &RecordingAreaDTO{ + Id: wh.Area.Id, + Name: wh.Area.Name, + } + } + if wh.Location != nil && wh.Location.Id != 0 { + dto.Location = &RecordingLocationDTO{ + Id: wh.Location.Id, + Name: wh.Location.Name, + Address: wh.Location.Address, + } + } + return dto +} + func mapProductWarehouseDTO(pw *entity.ProductWarehouse) productWarehouseDTO.ProductWarehouseDTO { if pw == nil { return productWarehouseDTO.ProductWarehouseDTO{} @@ -271,6 +380,20 @@ func mapProductWarehouseDTO(pw *entity.ProductWarehouse) productWarehouseDTO.Pro return *mapped } +func floatValue(value *float64) float64 { + if value == nil { + return 0 + } + return *value +} + +func intValue(value *int) int { + if value == nil { + return 0 + } + return *value +} + func defaultRecordingLatestApproval(e entity.Recording) approvalDTO.ApprovalRelationDTO { result := approvalDTO.ApprovalRelationDTO{} diff --git a/internal/modules/production/recordings/repositories/recording.repository.go b/internal/modules/production/recordings/repositories/recording.repository.go index 941d4507..dafd92ce 100644 --- a/internal/modules/production/recordings/repositories/recording.repository.go +++ b/internal/modules/production/recordings/repositories/recording.repository.go @@ -64,19 +64,28 @@ func (r *RecordingRepositoryImpl) WithRelations(db *gorm.DB) *gorm.DB { return db. Preload("CreatedUser"). Preload("ProjectFlockKandang"). + Preload("ProjectFlockKandang.Kandang"). Preload("ProjectFlockKandang.ProjectFlock"). + Preload("ProjectFlockKandang.ProjectFlock.ProductionStandard"). + Preload("ProjectFlockKandang.ProjectFlock.Fcr"). Preload("Depletions"). Preload("Depletions.ProductWarehouse"). Preload("Depletions.ProductWarehouse.Product"). Preload("Depletions.ProductWarehouse.Warehouse"). + Preload("Depletions.ProductWarehouse.Warehouse.Area"). + Preload("Depletions.ProductWarehouse.Warehouse.Location"). Preload("Stocks"). Preload("Stocks.ProductWarehouse"). Preload("Stocks.ProductWarehouse.Product"). Preload("Stocks.ProductWarehouse.Warehouse"). + Preload("Stocks.ProductWarehouse.Warehouse.Area"). + Preload("Stocks.ProductWarehouse.Warehouse.Location"). Preload("Eggs"). Preload("Eggs.ProductWarehouse"). Preload("Eggs.ProductWarehouse.Product"). - Preload("Eggs.ProductWarehouse.Warehouse") + Preload("Eggs.ProductWarehouse.Warehouse"). + Preload("Eggs.ProductWarehouse.Warehouse.Area"). + Preload("Eggs.ProductWarehouse.Warehouse.Location") } func (r *RecordingRepositoryImpl) GetLatestByProjectFlockKandangID(ctx context.Context, projectFlockKandangId uint) (*entity.Recording, error) { diff --git a/internal/modules/production/recordings/services/recording.service.go b/internal/modules/production/recordings/services/recording.service.go index 819552dc..18c00966 100644 --- a/internal/modules/production/recordings/services/recording.service.go +++ b/internal/modules/production/recordings/services/recording.service.go @@ -169,6 +169,14 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent } ctx := c.Context() + recordTime := time.Now().UTC() + if req.RecordDate != nil && strings.TrimSpace(*req.RecordDate) != "" { + parsed, err := time.Parse("2006-01-02", strings.TrimSpace(*req.RecordDate)) + if err != nil { + return nil, fiber.NewError(fiber.StatusBadRequest, "record_date must be in YYYY-MM-DD format") + } + recordTime = parsed.UTC() + } pfk, err := s.ProjectFlockKandangRepo.GetByID(ctx, req.ProjectFlockKandangId) if err != nil { @@ -188,6 +196,9 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent if err := s.ensureChickInExists(ctx, pfk.Id); err != nil { return nil, err } + if err := s.ensureProductionStandardWeekStart(ctx, pfk); err != nil { + return nil, err + } if !isLaying && len(req.Eggs) > 0 { return nil, fiber.NewError(fiber.StatusBadRequest, "Egg details permitted only for laying project flocks") @@ -211,7 +222,6 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent return err } - recordTime := time.Now().UTC() existsToday, err := s.Repository.ExistsOnDate(ctx, req.ProjectFlockKandangId, recordTime) if err != nil { s.Log.Errorf("Failed to verify existing recording on date: %+v", err) @@ -1330,12 +1340,16 @@ func (s *recordingService) attachProductionStandard(ctx context.Context, item *e return nil } - week := ((int(*item.Day) - 1) / 7) + 1 + category := strings.ToUpper(item.ProjectFlockKandang.ProjectFlock.Category) + weekBase := 1 + if category == string(utils.ProjectFlockCategoryLaying) { + weekBase = 18 + } + week := ((int(*item.Day) - 1) / 7) + weekBase if week <= 0 { return nil } - category := strings.ToUpper(item.ProjectFlockKandang.ProjectFlock.Category) db := s.Repository.DB() standardDetailRepo := rProductionStandard.NewProductionStandardDetailRepository(db) growthDetailRepo := rProductionStandard.NewStandardGrowthDetailRepository(db) @@ -1462,3 +1476,46 @@ func (s *recordingService) ensureChickInExists(ctx context.Context, projectFlock return fiber.NewError(fiber.StatusBadRequest, "Chick in project flock belum disetujui sehingga belum dapat membuat recording") } + +func (s *recordingService) ensureProductionStandardWeekStart(ctx context.Context, pfk *entity.ProjectFlockKandang) error { + if pfk == nil || pfk.ProjectFlock.Id == 0 { + return nil + } + + standardID := pfk.ProjectFlock.ProductionStandardId + if standardID == 0 { + return nil + } + + category := strings.ToUpper(pfk.ProjectFlock.Category) + switch category { + case string(utils.ProjectFlockCategoryLaying): + detailRepo := rProductionStandard.NewProductionStandardDetailRepository(s.Repository.DB()) + details, err := detailRepo.GetByProductionStandardID(ctx, standardID) + if err != nil { + return err + } + startWeek := 0 + if len(details) > 0 { + startWeek = details[0].Week + } + if startWeek != 18 { + return fiber.NewError(fiber.StatusBadRequest, "Week tidak sesuai dengan standart kategori project flock") + } + case string(utils.ProjectFlockCategoryGrowing): + growthRepo := rProductionStandard.NewStandardGrowthDetailRepository(s.Repository.DB()) + details, err := growthRepo.GetByProductionStandardID(ctx, standardID) + if err != nil { + return err + } + startWeek := 0 + if len(details) > 0 { + startWeek = details[0].Week + } + if startWeek != 1 { + return fiber.NewError(fiber.StatusBadRequest, "Week tidak sesuai dengan standart kategori project flock") + } + } + + return nil +} diff --git a/internal/modules/production/recordings/validations/recording.validation.go b/internal/modules/production/recordings/validations/recording.validation.go index a1d6aaf7..8b4eab57 100644 --- a/internal/modules/production/recordings/validations/recording.validation.go +++ b/internal/modules/production/recordings/validations/recording.validation.go @@ -21,6 +21,7 @@ type ( type Create struct { ProjectFlockKandangId uint `json:"project_flock_kandang_id" validate:"required,number,min=1"` + RecordDate *string `json:"record_date,omitempty" validate:"omitempty,datetime=2006-01-02"` Stocks []Stock `json:"stocks" validate:"dive"` Depletions []Depletion `json:"depletions" validate:"dive"` Eggs []Egg `json:"eggs" validate:"omitempty,dive"` From ce7ce778fd62bcd72583ef75a3e19ad6658c4149 Mon Sep 17 00:00:00 2001 From: ragilap Date: Tue, 13 Jan 2026 14:39:59 +0700 Subject: [PATCH 16/76] [FIX/BE-US-281] add response validation if weeks not have production standart --- .../services/production-standard.service.go | 81 +++++++++++++++++++ .../modules/production/recordings/module.go | 13 +++ .../recordings/services/recording.service.go | 58 +++---------- 3 files changed, 107 insertions(+), 45 deletions(-) diff --git a/internal/modules/master/production-standards/services/production-standard.service.go b/internal/modules/master/production-standards/services/production-standard.service.go index e1470170..2ea95cf3 100644 --- a/internal/modules/master/production-standards/services/production-standard.service.go +++ b/internal/modules/master/production-standards/services/production-standard.service.go @@ -1,8 +1,10 @@ package service import ( + "context" "errors" "fmt" + "strings" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" m "gitlab.com/mbugroup/lti-api.git/internal/middleware" @@ -22,6 +24,8 @@ type ProductionStandardService interface { CreateOne(ctx *fiber.Ctx, req *validation.Create) (*entity.ProductionStandard, error) UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint) (*entity.ProductionStandard, error) DeleteOne(ctx *fiber.Ctx, id uint) error + EnsureWeekStart(ctx context.Context, standardID uint, category string) error + EnsureWeekAvailable(ctx context.Context, standardID uint, category string, day int) error } type productionStandardService struct { @@ -299,3 +303,80 @@ func (s productionStandardService) DeleteOne(c *fiber.Ctx, id uint) error { } return nil } + +func (s productionStandardService) EnsureWeekStart(ctx context.Context, standardID uint, category string) error { + if standardID == 0 || strings.TrimSpace(category) == "" { + return nil + } + + switch strings.ToUpper(category) { + case string(utils.ProjectFlockCategoryLaying): + details, err := s.ProductionStandardDetailRepo.GetByProductionStandardID(ctx, standardID) + if err != nil { + return err + } + startWeek := 0 + if len(details) > 0 { + startWeek = details[0].Week + } + if startWeek != 18 { + return fiber.NewError(fiber.StatusBadRequest, "Week tidak sesuai dengan standart kategori project flock") + } + case string(utils.ProjectFlockCategoryGrowing): + details, err := s.StandardGrowthDetailRepo.GetByProductionStandardID(ctx, standardID) + if err != nil { + return err + } + startWeek := 0 + if len(details) > 0 { + startWeek = details[0].Week + } + if startWeek != 1 { + return fiber.NewError(fiber.StatusBadRequest, "Week tidak sesuai dengan standart kategori project flock") + } + } + + return nil +} + +func (s productionStandardService) EnsureWeekAvailable(ctx context.Context, standardID uint, category string, day int) error { + if standardID == 0 || day <= 0 { + return nil + } + + upperCategory := strings.ToUpper(category) + weekBase := 1 + if upperCategory == string(utils.ProjectFlockCategoryLaying) { + weekBase = 18 + } + week := ((day - 1) / 7) + weekBase + if week <= 0 { + return nil + } + + if upperCategory == string(utils.ProjectFlockCategoryLaying) { + detail, err := s.ProductionStandardDetailRepo.GetByStandardIDAndWeek(ctx, standardID, week) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Standart production tidak tersedia untuk week %d", week)) + } + return err + } + if detail == nil { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Standart production tidak tersedia untuk week %d", week)) + } + } + + growthDetail, err := s.StandardGrowthDetailRepo.GetByStandardIDAndWeek(ctx, standardID, week) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Standart production tidak tersedia untuk week %d", week)) + } + return err + } + if growthDetail == nil { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Standart production tidak tersedia untuk week %d", week)) + } + + return nil +} diff --git a/internal/modules/production/recordings/module.go b/internal/modules/production/recordings/module.go index a19faa33..91b024ac 100644 --- a/internal/modules/production/recordings/module.go +++ b/internal/modules/production/recordings/module.go @@ -11,6 +11,8 @@ import ( commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" + rProductionStandard "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards/repositories" + sProductionStandard "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards/services" rProjectFlock "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" rRecording "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/repositories" sRecording "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/services" @@ -29,6 +31,16 @@ func (RecordingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate projectFlockPopulationRepo := rProjectFlock.NewProjectFlockPopulationRepository(db) productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db) stockAllocationRepo := commonRepo.NewStockAllocationRepository(db) + productionStandardRepo := rProductionStandard.NewProductionStandardRepository(db) + productionStandardDetailRepo := rProductionStandard.NewProductionStandardDetailRepository(db) + standardGrowthDetailRepo := rProductionStandard.NewStandardGrowthDetailRepository(db) + + productionStandardService := sProductionStandard.NewProductionStandardService( + productionStandardRepo, + productionStandardDetailRepo, + standardGrowthDetailRepo, + validate, + ) fifoService := commonSvc.NewFifoService(db, stockAllocationRepo, productWarehouseRepo, utils.Log) if err := fifoService.RegisterUsable(fifo.UsableConfig{ @@ -63,6 +75,7 @@ func (RecordingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate approvalRepo, approvalService, fifoService, + productionStandardService, validate, ) userService := sUser.NewUserService(userRepo, validate) diff --git a/internal/modules/production/recordings/services/recording.service.go b/internal/modules/production/recordings/services/recording.service.go index 18c00966..ccf360b9 100644 --- a/internal/modules/production/recordings/services/recording.service.go +++ b/internal/modules/production/recordings/services/recording.service.go @@ -10,6 +10,7 @@ import ( m "gitlab.com/mbugroup/lti-api.git/internal/middleware" rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" rProductionStandard "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards/repositories" + sProductionStandard "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards/services" rProjectFlock "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" repository "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/repositories" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/validations" @@ -53,6 +54,7 @@ type recordingService struct { ProjectFlockPopulationRepo rProjectFlock.ProjectFlockPopulationRepository ApprovalRepo commonRepo.ApprovalRepository ApprovalSvc commonSvc.ApprovalService + ProductionStandardSvc sProductionStandard.ProductionStandardService FifoSvc commonSvc.FifoService } @@ -64,6 +66,7 @@ func NewRecordingService( approvalRepo commonRepo.ApprovalRepository, approvalSvc commonSvc.ApprovalService, fifoSvc commonSvc.FifoService, + productionStandardSvc sProductionStandard.ProductionStandardService, validate *validator.Validate, ) RecordingService { return &recordingService{ @@ -75,6 +78,7 @@ func NewRecordingService( ProjectFlockPopulationRepo: projectFlockPopulationRepo, ApprovalRepo: approvalRepo, ApprovalSvc: approvalSvc, + ProductionStandardSvc: productionStandardSvc, FifoSvc: fifoSvc, } } @@ -196,8 +200,10 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent if err := s.ensureChickInExists(ctx, pfk.Id); err != nil { return nil, err } - if err := s.ensureProductionStandardWeekStart(ctx, pfk); err != nil { - return nil, err + if s.ProductionStandardSvc != nil { + if err := s.ProductionStandardSvc.EnsureWeekStart(ctx, pfk.ProjectFlock.ProductionStandardId, category); err != nil { + return nil, err + } } if !isLaying && len(req.Eggs) > 0 { @@ -221,6 +227,11 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent s.Log.Errorf("Failed to determine recording day: %+v", err) return err } + if s.ProductionStandardSvc != nil { + if err := s.ProductionStandardSvc.EnsureWeekAvailable(ctx, pfk.ProjectFlock.ProductionStandardId, category, nextDay); err != nil { + return err + } + } existsToday, err := s.Repository.ExistsOnDate(ctx, req.ProjectFlockKandangId, recordTime) if err != nil { @@ -1476,46 +1487,3 @@ func (s *recordingService) ensureChickInExists(ctx context.Context, projectFlock return fiber.NewError(fiber.StatusBadRequest, "Chick in project flock belum disetujui sehingga belum dapat membuat recording") } - -func (s *recordingService) ensureProductionStandardWeekStart(ctx context.Context, pfk *entity.ProjectFlockKandang) error { - if pfk == nil || pfk.ProjectFlock.Id == 0 { - return nil - } - - standardID := pfk.ProjectFlock.ProductionStandardId - if standardID == 0 { - return nil - } - - category := strings.ToUpper(pfk.ProjectFlock.Category) - switch category { - case string(utils.ProjectFlockCategoryLaying): - detailRepo := rProductionStandard.NewProductionStandardDetailRepository(s.Repository.DB()) - details, err := detailRepo.GetByProductionStandardID(ctx, standardID) - if err != nil { - return err - } - startWeek := 0 - if len(details) > 0 { - startWeek = details[0].Week - } - if startWeek != 18 { - return fiber.NewError(fiber.StatusBadRequest, "Week tidak sesuai dengan standart kategori project flock") - } - case string(utils.ProjectFlockCategoryGrowing): - growthRepo := rProductionStandard.NewStandardGrowthDetailRepository(s.Repository.DB()) - details, err := growthRepo.GetByProductionStandardID(ctx, standardID) - if err != nil { - return err - } - startWeek := 0 - if len(details) > 0 { - startWeek = details[0].Week - } - if startWeek != 1 { - return fiber.NewError(fiber.StatusBadRequest, "Week tidak sesuai dengan standart kategori project flock") - } - } - - return nil -} From 590df26a1f5d7d1de04a1110454c586b3a540c48 Mon Sep 17 00:00:00 2001 From: MacBook Air M1 Date: Tue, 13 Jan 2026 14:43:37 +0700 Subject: [PATCH 17/76] adjust api closing production data --- .../controllers/closing.controller.go | 12 +- internal/modules/closings/dto/closing.dto.go | 33 ++- internal/modules/closings/module.go | 5 +- .../repositories/closing.repository.go | 18 ++ .../closings/services/closing.service.go | 214 +++++++++++++++--- .../repositories/recording.repository.go | 73 ++++++ 6 files changed, 313 insertions(+), 42 deletions(-) diff --git a/internal/modules/closings/controllers/closing.controller.go b/internal/modules/closings/controllers/closing.controller.go index 6ab2d398..8f129521 100644 --- a/internal/modules/closings/controllers/closing.controller.go +++ b/internal/modules/closings/controllers/closing.controller.go @@ -338,7 +338,17 @@ func (u *ClosingController) GetClosingDataProduksi(c *fiber.Ctx) error { return fiber.NewError(fiber.StatusBadRequest, "Invalid projectFlockId") } - result, err := u.ClosingService.GetClosingDataProduksi(c, uint(id)) + var kandangID *uint + if raw := c.Query("kandang_id"); raw != "" { + kandangInt, convErr := strconv.Atoi(raw) + if convErr != nil || kandangInt <= 0 { + return fiber.NewError(fiber.StatusBadRequest, "Invalid kandang_id") + } + kandangUint := uint(kandangInt) + kandangID = &kandangUint + } + + result, err := u.ClosingService.GetClosingDataProduksi(c, uint(id), kandangID) if err != nil { return err } diff --git a/internal/modules/closings/dto/closing.dto.go b/internal/modules/closings/dto/closing.dto.go index ac172c83..05a606ca 100644 --- a/internal/modules/closings/dto/closing.dto.go +++ b/internal/modules/closings/dto/closing.dto.go @@ -65,33 +65,44 @@ type ClosingPurchaseDTO struct { FinalPopulation int `json:"final_population"` FeedIn float64 `json:"feed_in"` FeedUsed float64 `json:"feed_used"` - FeedUsedPerHead float64 `json:"feed_used_per_head"` + // FeedUsedPerHead float64 `json:"feed_used_per_head"` } type ClosingSalesDTO struct { SalesPopulation int `json:"sales_population"` SalesWeight float64 `json:"sales_weight"` - AverageWeight float64 `json:"average_weight"` - AverageSellingPrice float64 `json:"chicken_average_selling_price"` + AverageWeight float64 `json:"avg_weight"` + AverageSellingPrice float64 `json:"avg_selling_price"` } type ClosingEggSalesDTO struct { EggPieces int `json:"egg_pieces"` - EggMassKg float64 `json:"egg_mass_kg"` - AverageEggWeightKg float64 `json:"average_egg_weight_kg"` - AverageSellingPrice float64 `json:"egg_average_selling_price"` + EggMassKg float64 `json:"egg_mass"` + AverageEggWeightKg float64 `json:"avg_egg_weight"` + AverageSellingPrice float64 `json:"avg_selling_price"` } type ClosingPerformanceDTO struct { Depletion float64 `json:"depletion"` Age float64 `json:"age_day"` - MortalityStd float64 `json:"mortality_std"` - MortalityAct float64 `json:"mortality_act"` - DeffMortality float64 `json:"deff_mortality"` + MortalityStd float64 `json:"mor_std"` + MortalityAct float64 `json:"mor_act"` + DeffMortality float64 `json:"mor_diff"` FcrStd float64 `json:"fcr_std"` FcrAct float64 `json:"fcr_act"` - DeffFcr float64 `json:"deff_fcr"` - Awg float64 `json:"awg"` + DeffFcr float64 `json:"fcr_diff"` + AwgAct float64 `json:"awg_act"` + AwgStd float64 `json:"awg_std"` + FeedIntake float64 `json:"feed_intake"` + FeedIntakeStd float64 `json:"feed_intake_std"` + HenDayAct *float64 `json:"hen_day_act,omitempty"` + HendayStd *float64 `json:"hen_day_std,omitempty"` + EggMass *float64 `json:"egg_mass,omitempty"` + EggMassStd *float64 `json:"egg_mass_std,omitempty"` + EggWeight *float64 `json:"egg_weight,omitempty"` + EggWeightStd *float64 `json:"egg_weight_std,omitempty"` + HenHouseAct *float64 `json:"hen_housed_act,omitempty"` + HenHouseStd *float64 `json:"hen_housed_std,omitempty"` } type ClosingSalesGroupDTO struct { diff --git a/internal/modules/closings/module.go b/internal/modules/closings/module.go index c89e6125..87ce815a 100644 --- a/internal/modules/closings/module.go +++ b/internal/modules/closings/module.go @@ -11,6 +11,7 @@ import ( sClosing "gitlab.com/mbugroup/lti-api.git/internal/modules/closings/services" rExpenseRealization "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/repositories" rMarketings "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/repositories" + rProductionStandard "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards/repositories" rChickin "gitlab.com/mbugroup/lti-api.git/internal/modules/production/chickins/repositories" rProjectFlock "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" rRecording "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/repositories" @@ -33,11 +34,13 @@ func (ClosingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate * expenseRealizationRepo := rExpenseRealization.NewExpenseRealizationRepository(db) chickinRepo := rChickin.NewChickinRepository(db) recordingRepo := rRecording.NewRecordingRepository(db) + standardGrowthDetailRepo := rProductionStandard.NewStandardGrowthDetailRepository(db) + productionStandardDetailRepo := rProductionStandard.NewProductionStandardDetailRepository(db) purchaseRepo := rPurchase.NewPurchaseRepository(db) approvalRepo := commonRepo.NewApprovalRepository(db) approvalService := commonSvc.NewApprovalService(approvalRepo) - closingService := sClosing.NewClosingService(closingRepo, projectFlockRepo, marketingRepo, marketingDeliveryProductRepo, approvalService, expenseRealizationRepo, projectBudgetRepo, chickinRepo, purchaseRepo, recordingRepo, validate) + closingService := sClosing.NewClosingService(closingRepo, projectFlockRepo, marketingRepo, marketingDeliveryProductRepo, approvalService, expenseRealizationRepo, projectBudgetRepo, chickinRepo, purchaseRepo, recordingRepo, standardGrowthDetailRepo, productionStandardDetailRepo, validate) sapronakService := sClosing.NewSapronakService(closingRepo, projectFlockKandangRepo, validate) userService := sUser.NewUserService(userRepo, validate) diff --git a/internal/modules/closings/repositories/closing.repository.go b/internal/modules/closings/repositories/closing.repository.go index 9d08d083..2ce3e496 100644 --- a/internal/modules/closings/repositories/closing.repository.go +++ b/internal/modules/closings/repositories/closing.repository.go @@ -18,6 +18,7 @@ type ClosingRepository interface { repository.BaseRepository[entity.ProjectFlock] GetSapronak(ctx context.Context, params SapronakQueryParams) ([]SapronakRow, int64, error) SumFeedPurchaseAndUsedByProjectFlockKandangIDs(ctx context.Context, projectFlockKandangIDs []uint) (float64, float64, error) + SumProjectChickinUsageByProjectFlockKandangIDs(ctx context.Context, projectFlockKandangIDs []uint) (float64, error) SumClaimCullingByProjectFlockKandangIDs(ctx context.Context, projectFlockKandangIDs []uint) (float64, error) SumMarketingWeightAndQtyByProjectFlockKandangIDs(ctx context.Context, projectFlockKandangIDs []uint) (float64, float64, float64, error) SumMarketingWeightAndQtyByProjectFlockKandangIDsAndFlagNames(ctx context.Context, projectFlockKandangIDs []uint, flagNames []string) (float64, float64, float64, error) @@ -166,6 +167,23 @@ func (r *ClosingRepositoryImpl) SumFeedPurchaseAndUsedByProjectFlockKandangIDs(c return purchaseAgg.TotalIn, usageAgg.TotalUsed, nil } +func (r *ClosingRepositoryImpl) SumProjectChickinUsageByProjectFlockKandangIDs(ctx context.Context, projectFlockKandangIDs []uint) (float64, error) { + if len(projectFlockKandangIDs) == 0 { + return 0, nil + } + + var total float64 + if err := r.DB().WithContext(ctx). + Model(&entity.ProjectChickin{}). + Where("project_flock_kandang_id IN ?", projectFlockKandangIDs). + Select("COALESCE(SUM(usage_qty), 0)"). + Scan(&total).Error; err != nil { + return 0, err + } + + return total, nil +} + func (r *ClosingRepositoryImpl) SumClaimCullingByProjectFlockKandangIDs(ctx context.Context, projectFlockKandangIDs []uint) (float64, error) { if len(projectFlockKandangIDs) == 0 { return 0, nil diff --git a/internal/modules/closings/services/closing.service.go b/internal/modules/closings/services/closing.service.go index 245fd24c..8b00475f 100644 --- a/internal/modules/closings/services/closing.service.go +++ b/internal/modules/closings/services/closing.service.go @@ -16,6 +16,7 @@ import ( expenseRealizationRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/repositories" marketingDeliveryProductRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/repositories" marketingRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/repositories" + productionStandardRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards/repositories" chickinRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/production/chickins/repositories" projectflockRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" recordingRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/repositories" @@ -35,7 +36,7 @@ type ClosingService interface { GetPenjualan(ctx *fiber.Ctx, projectFlockID uint) ([]entity.MarketingDeliveryProduct, error) GetClosingSummary(ctx *fiber.Ctx, projectFlockID uint) (*dto.ClosingSummaryDTO, error) GetOverhead(ctx *fiber.Ctx, projectFlockID uint) (*dto.OverheadListDTO, error) - GetClosingDataProduksi(ctx *fiber.Ctx, projectFlockID uint) (*dto.ClosingProductionReportDTO, error) + GetClosingDataProduksi(ctx *fiber.Ctx, projectFlockID uint, kandangID *uint) (*dto.ClosingProductionReportDTO, error) GetClosingSapronak(ctx *fiber.Ctx, projectFlockID uint, params *validation.ClosingSapronakQuery) ([]dto.ClosingSapronakItemDTO, int64, error) GetClosingKeuangan(ctx *fiber.Ctx, projectFlockID uint) (*dto.ReportResponse, error) GetExpeditionHPP(ctx *fiber.Ctx, projectFlockID uint, projectFlockKandangID *uint) (*dto.ExpeditionHPPDTO, error) @@ -54,9 +55,11 @@ type closingService struct { ChickinRepo chickinRepository.ProjectChickinRepository PurchaseRepo purchaseRepository.PurchaseRepository RecordingRepo recordingRepository.RecordingRepository + StandardGrowthDetailRepo productionStandardRepository.StandardGrowthDetailRepository + ProductionStandardDetailRepo productionStandardRepository.ProductionStandardDetailRepository } -func NewClosingService(repo repository.ClosingRepository, projectFlockRepo projectflockRepository.ProjectflockRepository, marketingRepo marketingRepository.MarketingRepository, marketingDeliveryProductRepo marketingDeliveryProductRepository.MarketingDeliveryProductRepository, approvalSvc commonSvc.ApprovalService, expenseRealizationRepo expenseRealizationRepository.ExpenseRealizationRepository, projectBudgetRepo projectflockRepository.ProjectBudgetRepository, chickinRepo chickinRepository.ProjectChickinRepository, purchaseRepo purchaseRepository.PurchaseRepository, recordingRepo recordingRepository.RecordingRepository, validate *validator.Validate) ClosingService { +func NewClosingService(repo repository.ClosingRepository, projectFlockRepo projectflockRepository.ProjectflockRepository, marketingRepo marketingRepository.MarketingRepository, marketingDeliveryProductRepo marketingDeliveryProductRepository.MarketingDeliveryProductRepository, approvalSvc commonSvc.ApprovalService, expenseRealizationRepo expenseRealizationRepository.ExpenseRealizationRepository, projectBudgetRepo projectflockRepository.ProjectBudgetRepository, chickinRepo chickinRepository.ProjectChickinRepository, purchaseRepo purchaseRepository.PurchaseRepository, recordingRepo recordingRepository.RecordingRepository, standardGrowthDetailRepo productionStandardRepository.StandardGrowthDetailRepository, productionStandardDetailRepo productionStandardRepository.ProductionStandardDetailRepository, validate *validator.Validate) ClosingService { return &closingService{ Log: utils.Log, Validate: validate, @@ -70,6 +73,8 @@ func NewClosingService(repo repository.ClosingRepository, projectFlockRepo proje ChickinRepo: chickinRepo, PurchaseRepo: purchaseRepo, RecordingRepo: recordingRepo, + StandardGrowthDetailRepo: standardGrowthDetailRepo, + ProductionStandardDetailRepo: productionStandardDetailRepo, } } @@ -231,7 +236,7 @@ func (s closingService) GetClosingSapronak(c *fiber.Ctx, projectFlockID uint, pa var projectFlockKandangIDs []uint if params.Type == validation.SapronakTypeOutgoing { - projectFlockKandangIDs, err = s.getProjectFlockKandangIDs(c.Context(), projectFlockID) + projectFlockKandangIDs, err = s.getProjectFlockKandangIDs(c.Context(), projectFlockID, nil) if err != nil { s.Log.Errorf("Failed to fetch project flock kandang IDs for project flock %d: %+v", projectFlockID, err) return nil, 0, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch project flock kandang") @@ -311,12 +316,15 @@ func (s closingService) getWarehouseIDsByProjectFlock(ctx context.Context, proje return ids, nil } -func (s closingService) getProjectFlockKandangIDs(ctx context.Context, projectFlockID uint) ([]uint, error) { +func (s closingService) getProjectFlockKandangIDs(ctx context.Context, projectFlockID uint, kandangID *uint) ([]uint, error) { var ids []uint - err := s.Repository.DB().WithContext(ctx). + query := s.Repository.DB().WithContext(ctx). Model(&entity.ProjectFlockKandang{}). - Where("project_flock_id = ?", projectFlockID). - Pluck("id", &ids).Error + Where("project_flock_id = ?", projectFlockID) + if kandangID != nil { + query = query.Where("kandang_id = ?", *kandangID) + } + err := query.Order("id ASC").Pluck("id", &ids).Error if err != nil { return nil, err } @@ -520,12 +528,22 @@ func (s closingService) GetExpeditionHPP(c *fiber.Ctx, projectFlockID uint, proj return result, nil } -func (s closingService) GetClosingDataProduksi(c *fiber.Ctx, projectFlockID uint) (*dto.ClosingProductionReportDTO, error) { +func (s closingService) GetClosingDataProduksi(c *fiber.Ctx, projectFlockID uint, kandangID *uint) (*dto.ClosingProductionReportDTO, error) { if projectFlockID == 0 { return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid project flock id") } - project, err := s.Repository.GetByID(c.Context(), projectFlockID, s.withClosingRelations) + projectFlockKandangIDs, err := s.getProjectFlockKandangIDs(c.Context(), projectFlockID, kandangID) + if err != nil { + s.Log.Errorf("Failed to fetch project flock kandangs for %d: %+v", projectFlockID, err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch project flock kandangs") + } + + if len(projectFlockKandangIDs) == 0 { + return nil, fiber.NewError(fiber.StatusNotFound, "No project flock kandang found") + } + + project, err := s.Repository.GetByID(c.Context(), projectFlockID, s.withRelations) if errors.Is(err, gorm.ErrRecordNotFound) { return nil, fiber.NewError(fiber.StatusNotFound, "Project flock not found") } @@ -534,19 +552,29 @@ func (s closingService) GetClosingDataProduksi(c *fiber.Ctx, projectFlockID uint return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch project flock") } - var population float64 - for _, history := range project.KandangHistory { - for _, chickin := range history.Chickins { - population += chickin.UsageQty - } + population, err := s.Repository.SumProjectChickinUsageByProjectFlockKandangIDs(c.Context(), projectFlockKandangIDs) + if err != nil { + s.Log.Errorf("Failed to sum population for project flock %d: %+v", projectFlockID, err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch population data") } isGrowing := strings.EqualFold(project.Category, string(utils.ProjectFlockCategoryGrowing)) - projectFlockKandangIDs, err := s.getProjectFlockKandangIDs(c.Context(), projectFlockID) + currentWeek, err := s.determineProductionWeek(c.Context(), projectFlockKandangIDs) if err != nil { - s.Log.Errorf("Failed to fetch project flock kandangs for %d: %+v", projectFlockID, err) - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch project flock kandangs") + s.Log.Errorf("Failed to determine production week for project flock %d: %+v", projectFlockID, err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to determine production week") + } + + targetAverages, err := s.RecordingRepo.GetAverageTargetMetricsByProjectFlockKandangID(c.Context(), projectFlockKandangIDs[0], !isGrowing) + if err != nil { + s.Log.Errorf("Failed to calculate target metrics for project flock %d: %+v", projectFlockID, err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch target metrics data") + } + var fcrActFromRecording *float64 + if targetAverages.FcrCount > 0 { + fcrAvg := targetAverages.FcrAvg + fcrActFromRecording = &fcrAvg } feedIn, feedUsed, err := s.Repository.SumFeedPurchaseAndUsedByProjectFlockKandangIDs(c.Context(), projectFlockKandangIDs) @@ -555,6 +583,30 @@ func (s closingService) GetClosingDataProduksi(c *fiber.Ctx, projectFlockID uint return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch feed purchase data") } + averageFeedIntake := targetAverages.FeedIntakeAvg + + feedIntakeStd := 0.0 + if project.ProductionStandardId > 0 && currentWeek > 0 && s.StandardGrowthDetailRepo != nil { + feedIntakeStd, err = s.calculateFeedIntakeStd(c.Context(), project.ProductionStandardId, currentWeek) + if err != nil { + s.Log.Errorf("Failed to compute feed intake std for project flock %d: %+v", projectFlockID, err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch feed intake standard data") + } + } + + var productionStandardDetail *entity.ProductionStandardDetail + if project.ProductionStandardId > 0 && currentWeek > 0 && s.ProductionStandardDetailRepo != nil { + productionStandardDetail, err = s.ProductionStandardDetailRepo.GetByStandardIDAndWeek(c.Context(), project.ProductionStandardId, currentWeek) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + productionStandardDetail = nil + } else { + s.Log.Errorf("Failed to fetch production standard detail for project flock %d: %+v", projectFlockID, err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch production standard detail data") + } + } + } + claimCulling, err := s.Repository.SumClaimCullingByProjectFlockKandangIDs(c.Context(), projectFlockKandangIDs) if err != nil { s.Log.Errorf("Failed to sum claim culling for project flock %d: %+v", projectFlockID, err) @@ -577,10 +629,10 @@ func (s closingService) GetClosingDataProduksi(c *fiber.Ctx, projectFlockID uint return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch sales age data") } - feedUsedPerHead := 0.0 - if population > 0 { - feedUsedPerHead = feedUsed / population - } + // feedUsedPerHead := 0.0 + // if population > 0 { + // feedUsedPerHead = feedUsed / population + // } purchase := dto.ClosingPurchaseDTO{ InitialPopulation: int(population), @@ -588,7 +640,7 @@ func (s closingService) GetClosingDataProduksi(c *fiber.Ctx, projectFlockID uint FinalPopulation: int(finalPopulation), FeedIn: feedIn, FeedUsed: feedUsed, - FeedUsedPerHead: feedUsedPerHead, + // FeedUsedPerHead: feedUsedPerHead, } chickenFlagNames := []string{string(utils.FlagPullet)} @@ -621,6 +673,9 @@ func (s closingService) GetClosingDataProduksi(c *fiber.Ctx, projectFlockID uint } chickenPerformance := calculatePerformanceMetrics(chickenAverageWeight, chickenSalesWeight, feedUsed, population, chickenDepletion, age, standards) + if fcrActFromRecording != nil { + chickenPerformance.FcrAct = *fcrActFromRecording + } var eggSales *dto.ClosingEggSalesDTO var eggPerformance *dto.ClosingPerformanceDTO @@ -668,6 +723,9 @@ func (s closingService) GetClosingDataProduksi(c *fiber.Ctx, projectFlockID uint } eggPerf := calculatePerformanceMetrics(averageEggWeight, eggSalesWeight, feedUsed, harvestEggQty, eggDepletion, age, standards) + if fcrActFromRecording != nil { + eggPerf.FcrAct = *fcrActFromRecording + } eggPerformance = &eggPerf } @@ -684,15 +742,55 @@ func (s closingService) GetClosingDataProduksi(c *fiber.Ctx, projectFlockID uint DeffMortality: chickenPerformance.DeffMortality, } if eggPerformance != nil { - performance.FcrStd = eggPerformance.FcrStd + // performance.FcrStd = eggPerformance.FcrStd performance.FcrAct = eggPerformance.FcrAct - performance.DeffFcr = eggPerformance.DeffFcr - performance.Awg = eggPerformance.Awg + // performance.DeffFcr = eggPerformance.DeffFcr + performance.AwgAct = eggPerformance.AwgAct } else { - performance.FcrStd = chickenPerformance.FcrStd + // performance.FcrStd = chickenPerformance.FcrStd performance.FcrAct = chickenPerformance.FcrAct - performance.DeffFcr = chickenPerformance.DeffFcr - performance.Awg = chickenPerformance.Awg + // performance.DeffFcr = chickenPerformance.DeffFcr + performance.AwgAct = chickenPerformance.AwgAct + } + performance.FeedIntake = averageFeedIntake + performance.FeedIntakeStd = feedIntakeStd + if !isGrowing { + if targetAverages.HenDayCount > 0 { + henDayAct := targetAverages.HenDayAvg + performance.HenDayAct = &henDayAct + } + if targetAverages.HenHouseCount > 0 { + henHouseAct := targetAverages.HenHouseAvg + performance.HenHouseAct = &henHouseAct + } + if targetAverages.EggWeightCount > 0 { + eggWeight := targetAverages.EggWeightAvg + performance.EggWeight = &eggWeight + } + if targetAverages.EggMassCount > 0 { + eggMass := targetAverages.EggMassAvg + performance.EggMass = &eggMass + } + } + performance.DeffFcr = performance.FcrStd - performance.FcrAct + if productionStandardDetail != nil { + if productionStandardDetail.StandardFCR != nil { + performance.FcrStd = *productionStandardDetail.StandardFCR + } + if !isGrowing { + if productionStandardDetail.TargetHenDayProduction != nil { + performance.HendayStd = productionStandardDetail.TargetHenDayProduction + } + if productionStandardDetail.TargetHenHouseProduction != nil { + performance.HenHouseStd = productionStandardDetail.TargetHenHouseProduction + } + if productionStandardDetail.TargetEggWeight != nil { + performance.EggWeightStd = productionStandardDetail.TargetEggWeight + } + if productionStandardDetail.TargetEggMass != nil { + performance.EggMassStd = productionStandardDetail.TargetEggMass + } + } } result := dto.ClosingProductionReportDTO{ @@ -738,6 +836,64 @@ func (s closingService) calculateAverageSalesAge(ctx context.Context, projectFlo return totalAgeWeeks / totalQty, nil } +func (s closingService) determineProductionWeek(ctx context.Context, projectFlockKandangIDs []uint) (int, error) { + if len(projectFlockKandangIDs) == 0 { + return 0, nil + } + + firstKandangID := projectFlockKandangIDs[0] + + var chickin entity.ProjectChickin + if err := s.Repository.DB().WithContext(ctx). + Where("project_flock_kandang_id = ?", firstKandangID). + Order("chick_in_date ASC"). + First(&chickin).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return 0, nil + } + return 0, err + } + + recording, err := s.RecordingRepo.GetLatestByProjectFlockKandangID(ctx, firstKandangID) + if err != nil { + return 0, err + } + if recording == nil { + return 0, nil + } + + if recording.RecordDatetime.Before(chickin.ChickInDate) { + return 0, nil + } + + elapsed := recording.RecordDatetime.Sub(chickin.ChickInDate) + weekFloat := elapsed.Hours() / (24 * 7) + week := int(math.Ceil(weekFloat)) + if week <= 0 { + week = 1 + } + + return week, nil +} + +func (s closingService) calculateFeedIntakeStd(ctx context.Context, productionStandardID uint, week int) (float64, error) { + if productionStandardID == 0 || week <= 0 || s.StandardGrowthDetailRepo == nil { + return 0, nil + } + + detail, err := s.StandardGrowthDetailRepo.GetByStandardIDAndWeek(ctx, productionStandardID, week) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return 0, nil + } + return 0, err + } + if detail == nil || detail.FeedIntake == nil { + return 0, nil + } + return *detail.FeedIntake, nil +} + func calculatePerformanceMetrics(averageWeight, totalWeight, feedUsed, basePopulation, depletion, age float64, standards []entity.FcrStandard) dto.ClosingPerformanceDTO { mortalityStd, fcrStd := closestFcrValues(standards, averageWeight) @@ -768,7 +924,7 @@ func calculatePerformanceMetrics(averageWeight, totalWeight, feedUsed, basePopul FcrStd: fcrStd, FcrAct: fcrAct, DeffFcr: deffFcr, - Awg: awg, + AwgAct: awg, } } diff --git a/internal/modules/production/recordings/repositories/recording.repository.go b/internal/modules/production/recordings/repositories/recording.repository.go index 941d4507..d15b934d 100644 --- a/internal/modules/production/recordings/repositories/recording.repository.go +++ b/internal/modules/production/recordings/repositories/recording.repository.go @@ -48,12 +48,28 @@ type RecordingRepository interface { GetTotalDepletionByProjectFlockID(ctx context.Context, projectFlockID uint) (totalDepletion float64, err error) GetLatestAvgWeightByProjectFlockID(ctx context.Context, projectFlockID uint) (avgWeight float64, err error) GetTotalEggProductionWeightByProjectFlockID(ctx context.Context, projectFlockID uint) (totalWeightKg float64, err error) + GetAverageTargetMetricsByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint, includeTargets bool) (RecordingTargetAverages, error) } type RecordingRepositoryImpl struct { *repository.BaseRepositoryImpl[entity.Recording] } +type RecordingTargetAverages struct { + HenDayAvg float64 + HenDayCount int64 + HenHouseAvg float64 + HenHouseCount int64 + EggWeightAvg float64 + EggWeightCount int64 + EggMassAvg float64 + EggMassCount int64 + FeedIntakeAvg float64 + FeedIntakeCount int64 + FcrAvg float64 + FcrCount int64 +} + func NewRecordingRepository(db *gorm.DB) RecordingRepository { return &RecordingRepositoryImpl{ BaseRepositoryImpl: repository.NewBaseRepository[entity.Recording](db), @@ -433,6 +449,63 @@ func (r *RecordingRepositoryImpl) GetTotalEggProductionWeightByProjectFlockID(ct return result, err } +func (r *RecordingRepositoryImpl) GetAverageTargetMetricsByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint, includeTargets bool) (RecordingTargetAverages, error) { + var row struct { + HenDayTotal float64 + HenHouseTotal float64 + EggWeightTotal float64 + EggMassTotal float64 + FeedIntakeTotal float64 + FcrTotal float64 + TotalCount int64 + } + + selectParts := []string{ + "COALESCE(SUM(feed_intake), 0) AS feed_intake_total", + "COALESCE(SUM(fcr_value), 0) AS fcr_total", + "COUNT(*) AS total_count", + } + if includeTargets { + selectParts = append([]string{ + "COALESCE(SUM(hen_day), 0) AS hen_day_total", + "COALESCE(SUM(hen_house), 0) AS hen_house_total", + "COALESCE(SUM(egg_weight), 0) AS egg_weight_total", + "COALESCE(SUM(egg_mass), 0) AS egg_mass_total", + }, selectParts...) + } + + if err := r.DB().WithContext(ctx). + Table("recordings"). + Select(strings.Join(selectParts, ", ")). + Where("project_flock_kandangs_id = ? AND deleted_at IS NULL", projectFlockKandangID). + Scan(&row).Error; err != nil { + return RecordingTargetAverages{}, err + } + + result := RecordingTargetAverages{ + FeedIntakeCount: row.TotalCount, + FcrCount: row.TotalCount, + } + if includeTargets { + result.HenDayCount = row.TotalCount + result.HenHouseCount = row.TotalCount + result.EggWeightCount = row.TotalCount + result.EggMassCount = row.TotalCount + } + if row.TotalCount > 0 { + if includeTargets { + result.HenDayAvg = row.HenDayTotal / float64(row.TotalCount) + result.HenHouseAvg = row.HenHouseTotal / float64(row.TotalCount) + result.EggWeightAvg = row.EggWeightTotal / float64(row.TotalCount) + result.EggMassAvg = row.EggMassTotal / float64(row.TotalCount) + } + result.FeedIntakeAvg = row.FeedIntakeTotal / float64(row.TotalCount) + result.FcrAvg = row.FcrTotal / float64(row.TotalCount) + } + + return result, nil +} + func nextRecordingDay(days []int) int { if len(days) == 0 { return 1 From 0f4cc6e3791ead16b42cf1fd698bd53e594dff97 Mon Sep 17 00:00:00 2001 From: MacBook Air M1 Date: Tue, 13 Jan 2026 15:32:43 +0700 Subject: [PATCH 18/76] adjust api closing data produksi --- .../closings/services/closing.service.go | 44 ++++++++--------- .../repositories/recording.repository.go | 48 +++++++++++-------- 2 files changed, 49 insertions(+), 43 deletions(-) diff --git a/internal/modules/closings/services/closing.service.go b/internal/modules/closings/services/closing.service.go index 8b00475f..76f3e024 100644 --- a/internal/modules/closings/services/closing.service.go +++ b/internal/modules/closings/services/closing.service.go @@ -586,11 +586,21 @@ func (s closingService) GetClosingDataProduksi(c *fiber.Ctx, projectFlockID uint averageFeedIntake := targetAverages.FeedIntakeAvg feedIntakeStd := 0.0 + var mortalityStdFromGrowth *float64 if project.ProductionStandardId > 0 && currentWeek > 0 && s.StandardGrowthDetailRepo != nil { - feedIntakeStd, err = s.calculateFeedIntakeStd(c.Context(), project.ProductionStandardId, currentWeek) - if err != nil { - s.Log.Errorf("Failed to compute feed intake std for project flock %d: %+v", projectFlockID, err) - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch feed intake standard data") + growthDetail, growthErr := s.StandardGrowthDetailRepo.GetByStandardIDAndWeek(c.Context(), project.ProductionStandardId, currentWeek) + if growthErr != nil { + if !errors.Is(growthErr, gorm.ErrRecordNotFound) { + s.Log.Errorf("Failed to fetch growth detail for project flock %d: %+v", projectFlockID, growthErr) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch growth standard data") + } + } else if growthDetail != nil { + if growthDetail.FeedIntake != nil { + feedIntakeStd = *growthDetail.FeedIntake + } + if growthDetail.MaxDepletion != nil { + mortalityStdFromGrowth = growthDetail.MaxDepletion + } } } @@ -754,6 +764,14 @@ func (s closingService) GetClosingDataProduksi(c *fiber.Ctx, projectFlockID uint } performance.FeedIntake = averageFeedIntake performance.FeedIntakeStd = feedIntakeStd + if targetAverages.CumDepletionRateCount > 0 { + performance.MortalityAct = targetAverages.CumDepletionRateAvg + performance.DeffMortality = performance.MortalityAct - performance.MortalityStd + } + if mortalityStdFromGrowth != nil { + performance.MortalityStd = *mortalityStdFromGrowth + performance.DeffMortality = performance.MortalityAct - performance.MortalityStd + } if !isGrowing { if targetAverages.HenDayCount > 0 { henDayAct := targetAverages.HenDayAvg @@ -876,24 +894,6 @@ func (s closingService) determineProductionWeek(ctx context.Context, projectFloc return week, nil } -func (s closingService) calculateFeedIntakeStd(ctx context.Context, productionStandardID uint, week int) (float64, error) { - if productionStandardID == 0 || week <= 0 || s.StandardGrowthDetailRepo == nil { - return 0, nil - } - - detail, err := s.StandardGrowthDetailRepo.GetByStandardIDAndWeek(ctx, productionStandardID, week) - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return 0, nil - } - return 0, err - } - if detail == nil || detail.FeedIntake == nil { - return 0, nil - } - return *detail.FeedIntake, nil -} - func calculatePerformanceMetrics(averageWeight, totalWeight, feedUsed, basePopulation, depletion, age float64, standards []entity.FcrStandard) dto.ClosingPerformanceDTO { mortalityStd, fcrStd := closestFcrValues(standards, averageWeight) diff --git a/internal/modules/production/recordings/repositories/recording.repository.go b/internal/modules/production/recordings/repositories/recording.repository.go index d15b934d..bf08b78b 100644 --- a/internal/modules/production/recordings/repositories/recording.repository.go +++ b/internal/modules/production/recordings/repositories/recording.repository.go @@ -56,18 +56,20 @@ type RecordingRepositoryImpl struct { } type RecordingTargetAverages struct { - HenDayAvg float64 - HenDayCount int64 - HenHouseAvg float64 - HenHouseCount int64 - EggWeightAvg float64 - EggWeightCount int64 - EggMassAvg float64 - EggMassCount int64 - FeedIntakeAvg float64 - FeedIntakeCount int64 - FcrAvg float64 - FcrCount int64 + HenDayAvg float64 + HenDayCount int64 + HenHouseAvg float64 + HenHouseCount int64 + EggWeightAvg float64 + EggWeightCount int64 + EggMassAvg float64 + EggMassCount int64 + FeedIntakeAvg float64 + FeedIntakeCount int64 + FcrAvg float64 + FcrCount int64 + CumDepletionRateAvg float64 + CumDepletionRateCount int64 } func NewRecordingRepository(db *gorm.DB) RecordingRepository { @@ -451,18 +453,20 @@ func (r *RecordingRepositoryImpl) GetTotalEggProductionWeightByProjectFlockID(ct func (r *RecordingRepositoryImpl) GetAverageTargetMetricsByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint, includeTargets bool) (RecordingTargetAverages, error) { var row struct { - HenDayTotal float64 - HenHouseTotal float64 - EggWeightTotal float64 - EggMassTotal float64 - FeedIntakeTotal float64 - FcrTotal float64 - TotalCount int64 + HenDayTotal float64 + HenHouseTotal float64 + EggWeightTotal float64 + EggMassTotal float64 + FeedIntakeTotal float64 + FcrTotal float64 + CumDepletionRateTotal float64 + TotalCount int64 } selectParts := []string{ "COALESCE(SUM(feed_intake), 0) AS feed_intake_total", "COALESCE(SUM(fcr_value), 0) AS fcr_total", + "COALESCE(SUM(cum_depletion_rate), 0) AS cum_depletion_rate_total", "COUNT(*) AS total_count", } if includeTargets { @@ -483,8 +487,9 @@ func (r *RecordingRepositoryImpl) GetAverageTargetMetricsByProjectFlockKandangID } result := RecordingTargetAverages{ - FeedIntakeCount: row.TotalCount, - FcrCount: row.TotalCount, + FeedIntakeCount: row.TotalCount, + FcrCount: row.TotalCount, + CumDepletionRateCount: row.TotalCount, } if includeTargets { result.HenDayCount = row.TotalCount @@ -501,6 +506,7 @@ func (r *RecordingRepositoryImpl) GetAverageTargetMetricsByProjectFlockKandangID } result.FeedIntakeAvg = row.FeedIntakeTotal / float64(row.TotalCount) result.FcrAvg = row.FcrTotal / float64(row.TotalCount) + result.CumDepletionRateAvg = row.CumDepletionRateTotal } return result, nil From 33e89d65ab0e76234a8c2295fb40dc7168d90f79 Mon Sep 17 00:00:00 2001 From: ragilap Date: Tue, 13 Jan 2026 15:37:54 +0700 Subject: [PATCH 19/76] [FIX/BE-US-281] changes calculate fcr egg --- .../modules/production/recordings/services/recording.service.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/modules/production/recordings/services/recording.service.go b/internal/modules/production/recordings/services/recording.service.go index ccf360b9..88ed4cf7 100644 --- a/internal/modules/production/recordings/services/recording.service.go +++ b/internal/modules/production/recordings/services/recording.service.go @@ -1178,7 +1178,7 @@ func (s *recordingService) computeAndUpdateMetrics(ctx context.Context, tx *gorm var fcrValue float64 if usageInGrams > 0 && totalEggWeightGrams > 0 { - fcrValue = totalEggWeightGrams / usageInGrams + fcrValue = usageInGrams / totalEggWeightGrams updates["fcr_value"] = fcrValue recording.FcrValue = &fcrValue } else { From 5d7b613ffcec659aa9daa5e570b467e6f69cd92c Mon Sep 17 00:00:00 2001 From: ragilap Date: Tue, 13 Jan 2026 15:39:20 +0700 Subject: [PATCH 20/76] [FIX/BE-US-281] changes calculate fcr egg --- .../dashboards/services/dashboard.service.go | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/internal/modules/dashboards/services/dashboard.service.go b/internal/modules/dashboards/services/dashboard.service.go index f929cc7b..b4635b2e 100644 --- a/internal/modules/dashboards/services/dashboard.service.go +++ b/internal/modules/dashboards/services/dashboard.service.go @@ -273,15 +273,15 @@ func (s dashboardService) buildPerformanceCharts(ctx context.Context, params *va weekFeed := weeklyFeedMap[week] actFcr := 0.0 - if weekFeed > 0 { - actFcr = weekEgg / weekFeed + if weekEgg > 0 { + actFcr = weekFeed / weekEgg } cumEgg += weekEgg cumFeed += weekFeed actFcrCum := 0.0 - if cumFeed > 0 { - actFcrCum = cumEgg / cumFeed + if cumEgg > 0 { + actFcrCum = cumFeed / cumEgg } bodyWeightDataset = append(bodyWeightDataset, map[string]interface{}{ @@ -359,10 +359,10 @@ func (s dashboardService) buildPerformanceCharts(ctx context.Context, params *va }, "fcr": { Series: []dto.DashboardChartSeriesDTO{ - {Id: "act_fcr", Label: "Act. FCR", Unit: "%"}, - {Id: "std_fcr", Label: "STD. FCR", Unit: "%"}, - {Id: "act_fcr_cum", Label: "Act. FCR Cummulative", Unit: "%"}, - {Id: "std_fcr_cum", Label: "STD. FCR Cummulative", Unit: "%"}, + {Id: "act_fcr", Label: "Act. FCR", Unit: "kg/kg"}, + {Id: "std_fcr", Label: "STD. FCR", Unit: "kg/kg"}, + {Id: "act_fcr_cum", Label: "Act. FCR Cummulative", Unit: "kg/kg"}, + {Id: "std_fcr_cum", Label: "STD. FCR Cummulative", Unit: "kg/kg"}, }, Dataset: fcrDataset, }, @@ -937,11 +937,11 @@ func (s dashboardService) fcrValue(ctx context.Context, filter *validation.Dashb } feedUsageGrams := feedUsageToGrams(feedRows) - if feedUsageGrams <= 0 { + if eggWeightGrams <= 0 { return 0, nil } - return eggWeightGrams / feedUsageGrams, nil + return feedUsageGrams / eggWeightGrams, nil } func (s dashboardService) mortalityValue(ctx context.Context, filter *validation.DashboardFilter, startDate, endExclusive time.Time) (float64, error) { From 1b5b5bc8476acc9f1e78ca5d7c13da58dc7ba2c6 Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Tue, 13 Jan 2026 16:10:27 +0700 Subject: [PATCH 21/76] feat[BE]: add MarketingType filter to marketing reports and update related validations --- .../salesorder_delivery_product.repository.go | 30 +++++++++++++++++-- .../controllers/repport.controller.go | 1 + .../repports/dto/repportMarketing.dto.go | 20 +++++++++---- .../validations/repport.validation.go | 1 + 4 files changed, 43 insertions(+), 9 deletions(-) diff --git a/internal/modules/marketing/repositories/salesorder_delivery_product.repository.go b/internal/modules/marketing/repositories/salesorder_delivery_product.repository.go index bb1343a2..f14988b1 100644 --- a/internal/modules/marketing/repositories/salesorder_delivery_product.repository.go +++ b/internal/modules/marketing/repositories/salesorder_delivery_product.repository.go @@ -137,13 +137,14 @@ func (r *MarketingDeliveryProductRepositoryImpl) GetAllWithFilters(ctx context.C Preload("ProductWarehouse.ProjectFlockKandang.ProjectFlock") }). Joins("JOIN marketing_products ON marketing_products.id = marketing_delivery_products.marketing_product_id"). - Joins("JOIN marketings ON marketings.id = marketing_products.marketing_id") + Joins("JOIN marketings ON marketings.id = marketing_products.marketing_id"). + Where("marketing_delivery_products.delivery_date IS NOT NULL") - if filters.ProductId > 0 || filters.WarehouseId > 0 || filters.Search != "" { + if filters.ProductId > 0 || filters.WarehouseId > 0 || filters.Search != "" || filters.MarketingType != "" { db = db.Joins("LEFT JOIN product_warehouses ON product_warehouses.id = marketing_products.product_warehouse_id") } - if filters.ProductId > 0 || filters.Search != "" { + if filters.ProductId > 0 || filters.Search != "" || filters.MarketingType != "" { db = db.Joins("LEFT JOIN products ON products.id = product_warehouses.product_id") } @@ -177,6 +178,29 @@ func (r *MarketingDeliveryProductRepositoryImpl) GetAllWithFilters(ctx context.C db = db.Where("product_warehouses.warehouse_id = ?", filters.WarehouseId) } + if filters.MarketingType != "" { + db = db.Joins("LEFT JOIN flags ON flags.flagable_id = products.id AND flags.flagable_type = 'products'"). + Group("marketing_delivery_products.id") + + switch filters.MarketingType { + case "ayam": + db = db.Where("flags.name IN (?)", []string{ + string(utils.FlagDOC), string(utils.FlagPullet), string(utils.FlagLayer), + string(utils.FlagAyamAfkir), string(utils.FlagAyamCulling), string(utils.FlagAyamMati), + }) + case "telur": + db = db.Where("flags.name IN (?)", []string{ + string(utils.FlagTelur), string(utils.FlagTelurUtuh), string(utils.FlagTelurPecah), + string(utils.FlagTelurPutih), string(utils.FlagTelurRetak), + }) + case "trading": + db = db.Where("flags.name IN (?)", []string{ + string(utils.FlagOVK), string(utils.FlagObat), string(utils.FlagVitamin), string(utils.FlagKimia), + string(utils.FlagPakan), string(utils.FlagPreStarter), string(utils.FlagStarter), string(utils.FlagFinisher), + }) + } + } + if filters.FilterBy != "" && (filters.StartDate != "" || filters.EndDate != "") { if filters.FilterBy == "so_date" { if filters.StartDate != "" { diff --git a/internal/modules/repports/controllers/repport.controller.go b/internal/modules/repports/controllers/repport.controller.go index 22ff4acf..1d273af1 100644 --- a/internal/modules/repports/controllers/repport.controller.go +++ b/internal/modules/repports/controllers/repport.controller.go @@ -82,6 +82,7 @@ func (c *RepportController) GetMarketing(ctx *fiber.Ctx) error { ProductId: int64(ctx.QueryInt("product_id", 0)), WarehouseId: int64(ctx.QueryInt("warehouse_id", 0)), SalesPersonId: int64(ctx.QueryInt("sales_person_id", 0)), + MarketingType: ctx.Query("marketing_type", ""), FilterBy: ctx.Query("filter_by", ""), StartDate: ctx.Query("start_date", ""), EndDate: ctx.Query("end_date", ""), diff --git a/internal/modules/repports/dto/repportMarketing.dto.go b/internal/modules/repports/dto/repportMarketing.dto.go index 3f133674..36df7a05 100644 --- a/internal/modules/repports/dto/repportMarketing.dto.go +++ b/internal/modules/repports/dto/repportMarketing.dto.go @@ -149,7 +149,7 @@ func ToRepportMarketingItemDTOsWithHppMap(mdps []entity.MarketingDeliveryProduct } func getMarketingType(mdp entity.MarketingDeliveryProduct) string { - hasAyam, hasTelur := checkProductFlags(mdp.MarketingProduct.ProductWarehouse.Product.Flags) + hasAyam, hasTelur, hasTrading := checkProductFlags(mdp.MarketingProduct.ProductWarehouse.Product.Flags) if hasAyam { return "ayam" @@ -157,12 +157,15 @@ func getMarketingType(mdp entity.MarketingDeliveryProduct) string { if hasTelur { return "telur" } - return "trading" + if hasTrading { + return "trading" + } + return "trading" // default to trading if no flags found } -func checkProductFlags(flags []entity.Flag) (hasAyam, hasTelur bool) { +func checkProductFlags(flags []entity.Flag) (hasAyam, hasTelur, hasTrading bool) { if len(flags) == 0 { - return false, false + return false, false, false } for _, flag := range flags { @@ -177,13 +180,18 @@ func checkProductFlags(flags []entity.Flag) (hasAyam, hasTelur bool) { ft == utils.FlagTelurPutih || ft == utils.FlagTelurRetak { hasTelur = true } + + if ft == utils.FlagOVK || ft == utils.FlagObat || ft == utils.FlagVitamin || ft == utils.FlagKimia || + ft == utils.FlagPakan || ft == utils.FlagPreStarter || ft == utils.FlagStarter || ft == utils.FlagFinisher { + hasTrading = true + } } - return hasAyam, hasTelur + return hasAyam, hasTelur, hasTrading } func isProductEligibleForHpp(mdp entity.MarketingDeliveryProduct, category string) bool { - hasAyam, hasTelur := checkProductFlags(mdp.MarketingProduct.ProductWarehouse.Product.Flags) + hasAyam, hasTelur, _ := checkProductFlags(mdp.MarketingProduct.ProductWarehouse.Product.Flags) if utils.ProjectFlockCategory(category) == utils.ProjectFlockCategoryGrowing { return hasAyam diff --git a/internal/modules/repports/validations/repport.validation.go b/internal/modules/repports/validations/repport.validation.go index 5b60a31f..5dde8f51 100644 --- a/internal/modules/repports/validations/repport.validation.go +++ b/internal/modules/repports/validations/repport.validation.go @@ -23,6 +23,7 @@ type MarketingQuery struct { ProductId int64 `query:"product_id" validate:"omitempty"` WarehouseId int64 `query:"warehouse_id" validate:"omitempty"` SalesPersonId int64 `query:"sales_person_id" validate:"omitempty"` + MarketingType string `query:"marketing_type" validate:"omitempty,oneof=ayam telur trading"` FilterBy string `query:"filter_by" validate:"omitempty,oneof=so_date realization_date"` StartDate string `query:"start_date" validate:"omitempty,datetime=2006-01-02"` EndDate string `query:"end_date" validate:"omitempty,datetime=2006-01-02"` From 7551d11888445d89f02a0aa218a0815f42fbe574 Mon Sep 17 00:00:00 2001 From: MacBook Air M1 Date: Tue, 13 Jan 2026 16:14:11 +0700 Subject: [PATCH 22/76] add filter by kandang id sapronak --- .../modules/closings/controllers/closing.controller.go | 9 +++++++++ internal/modules/closings/services/closing.service.go | 2 +- .../modules/closings/validations/closing.validation.go | 7 ++++--- .../repositories/production_result.repository.go | 1 - 4 files changed, 14 insertions(+), 5 deletions(-) diff --git a/internal/modules/closings/controllers/closing.controller.go b/internal/modules/closings/controllers/closing.controller.go index 8f129521..d837f5d6 100644 --- a/internal/modules/closings/controllers/closing.controller.go +++ b/internal/modules/closings/controllers/closing.controller.go @@ -162,6 +162,14 @@ func (u *ClosingController) GetClosingSapronak(c *fiber.Ctx) error { Page: c.QueryInt("page", 1), Limit: c.QueryInt("limit", 10), } + if raw := c.Query("kandang_id"); raw != "" { + kandangInt, convErr := strconv.Atoi(raw) + if convErr != nil || kandangInt <= 0 { + return fiber.NewError(fiber.StatusBadRequest, "Invalid kandang_id") + } + kandangUint := uint(kandangInt) + query.KandangID = &kandangUint + } if query.Page < 1 || query.Limit < 1 { return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0") @@ -346,6 +354,7 @@ func (u *ClosingController) GetClosingDataProduksi(c *fiber.Ctx) error { } kandangUint := uint(kandangInt) kandangID = &kandangUint + } result, err := u.ClosingService.GetClosingDataProduksi(c, uint(id), kandangID) diff --git a/internal/modules/closings/services/closing.service.go b/internal/modules/closings/services/closing.service.go index 76f3e024..d87568b6 100644 --- a/internal/modules/closings/services/closing.service.go +++ b/internal/modules/closings/services/closing.service.go @@ -236,7 +236,7 @@ func (s closingService) GetClosingSapronak(c *fiber.Ctx, projectFlockID uint, pa var projectFlockKandangIDs []uint if params.Type == validation.SapronakTypeOutgoing { - projectFlockKandangIDs, err = s.getProjectFlockKandangIDs(c.Context(), projectFlockID, nil) + projectFlockKandangIDs, err = s.getProjectFlockKandangIDs(c.Context(), projectFlockID, params.KandangID) if err != nil { s.Log.Errorf("Failed to fetch project flock kandang IDs for project flock %d: %+v", projectFlockID, err) return nil, 0, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch project flock kandang") diff --git a/internal/modules/closings/validations/closing.validation.go b/internal/modules/closings/validations/closing.validation.go index 610e89b8..0c738407 100644 --- a/internal/modules/closings/validations/closing.validation.go +++ b/internal/modules/closings/validations/closing.validation.go @@ -20,7 +20,8 @@ const ( ) type ClosingSapronakQuery struct { - Type string `query:"type" validate:"required,oneof=incoming outgoing"` - Page int `query:"page" validate:"omitempty,number,min=1,gt=0"` - Limit int `query:"limit" validate:"omitempty,number,min=1,max=100,gt=0"` + Type string `query:"type" validate:"required,oneof=incoming outgoing"` + Page int `query:"page" validate:"omitempty,number,min=1,gt=0"` + Limit int `query:"limit" validate:"omitempty,number,min=1,max=100,gt=0"` + KandangID *uint `query:"kandang_id" validate:"omitempty,gt=0"` } diff --git a/internal/modules/repports/repositories/production_result.repository.go b/internal/modules/repports/repositories/production_result.repository.go index f2decedf..19007d0f 100644 --- a/internal/modules/repports/repositories/production_result.repository.go +++ b/internal/modules/repports/repositories/production_result.repository.go @@ -59,7 +59,6 @@ func (r *productionResultRepositoryImpl) GetRecordingsByProjectFlockKandang( dataQuery := r.db.WithContext(ctx). Model(&entity.Recording{}). Where("project_flock_kandangs_id = ?", projectFlockKandangID). - Preload("BodyWeights"). Preload("Eggs", func(db *gorm.DB) *gorm.DB { return db.Select("recording_eggs.*, f.name AS product_flag_name"). Joins("LEFT JOIN product_warehouses pw ON pw.id = recording_eggs.product_warehouse_id"). From 5de81f6315a89febf9602bff6b753af9c24470fa Mon Sep 17 00:00:00 2001 From: Adnan Zahir Date: Tue, 13 Jan 2026 16:18:15 +0700 Subject: [PATCH 23/76] chore: test image versioning --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index da8394f1..5b502da1 100644 --- a/README.md +++ b/README.md @@ -110,4 +110,4 @@ IT Development PT Mitra Berlian Unggas Group ## 📃 License -This project is private. All rights reserved. +> This project is private. All rights reserved. From de6580d11cf6b4b28be589c9d20b45db70f2245b Mon Sep 17 00:00:00 2001 From: Adnan Zahir Date: Tue, 13 Jan 2026 16:46:49 +0700 Subject: [PATCH 24/76] chore: test image versioning 2 --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 5b502da1..da8394f1 100644 --- a/README.md +++ b/README.md @@ -110,4 +110,4 @@ IT Development PT Mitra Berlian Unggas Group ## 📃 License -> This project is private. All rights reserved. +This project is private. All rights reserved. From 5730053e045d7b8d0d5476869a82568374a8c15a Mon Sep 17 00:00:00 2001 From: ragilap Date: Tue, 13 Jan 2026 19:54:47 +0700 Subject: [PATCH 25/76] [FIX/BE-US-390] changes counting debt supplier --- .../repositories/debt_supplier.repository.go | 6 +- .../repports/services/repport.service.go | 97 +++++++------------ .../validations/repport.validation.go | 2 +- 3 files changed, 36 insertions(+), 69 deletions(-) diff --git a/internal/modules/repports/repositories/debt_supplier.repository.go b/internal/modules/repports/repositories/debt_supplier.repository.go index 3d415606..e8f548d6 100644 --- a/internal/modules/repports/repositories/debt_supplier.repository.go +++ b/internal/modules/repports/repositories/debt_supplier.repository.go @@ -31,11 +31,9 @@ func NewDebtSupplierRepository(db *gorm.DB) DebtSupplierRepository { func resolveDebtSupplierDateColumn(filterBy string) string { switch strings.ToLower(strings.TrimSpace(filterBy)) { - case "receive_date": - return "purchases.receive_date" case "po_date": return "purchases.po_date" - case "do_date", "received_date", "": + case "received_date", "": return "purchase_items.received_date" default: return "purchase_items.received_date" @@ -130,7 +128,7 @@ func (r *debtSupplierRepositoryImpl) GetPurchasesBySuppliers(ctx context.Context Preload("Warehouse.Area"). Order("purchase_items.id ASC") - if strings.EqualFold(strings.TrimSpace(filters.FilterBy), "do_date") || strings.EqualFold(strings.TrimSpace(filters.FilterBy), "received_date") || strings.TrimSpace(filters.FilterBy) == "" { + if strings.EqualFold(strings.TrimSpace(filters.FilterBy), "received_date") || strings.TrimSpace(filters.FilterBy) == "" { if filters.StartDate != "" { if dateFrom, err := utils.ParseDateString(filters.StartDate); err == nil { db = db.Where("DATE(purchase_items.received_date) >= ?", dateFrom) diff --git a/internal/modules/repports/services/repport.service.go b/internal/modules/repports/services/repport.service.go index 5f3cbbad..c4883b72 100644 --- a/internal/modules/repports/services/repport.service.go +++ b/internal/modules/repports/services/repport.service.go @@ -642,7 +642,7 @@ func (s *repportService) GetPurchaseSupplier(c *fiber.Ctx, params *validation.Pu } func (s *repportService) GetDebtSupplier(c *fiber.Ctx, params *validation.DebtSupplierQuery) ([]dto.DebtSupplierDTO, int64, error) { - if params.FilterBy == "" || strings.EqualFold(strings.TrimSpace(params.FilterBy), "do_date") { + if params.FilterBy == "" { params.FilterBy = "received_date" } @@ -681,25 +681,8 @@ func (s *repportService) GetDebtSupplier(c *fiber.Ctx, params *validation.DebtSu } purchasesBySupplier := make(map[uint][]entity.Purchase, len(supplierIDs)) - references := make([]string, 0) - seenRefs := make(map[string]struct{}) for _, purchase := range purchases { - supplierID := purchase.SupplierId - purchasesBySupplier[supplierID] = append(purchasesBySupplier[supplierID], purchase) - - reference := purchase.PrNumber - if purchase.PoNumber != nil && strings.TrimSpace(*purchase.PoNumber) != "" { - reference = *purchase.PoNumber - } - if _, exists := seenRefs[reference]; !exists { - seenRefs[reference] = struct{}{} - references = append(references, reference) - } - } - - paymentTotals, err := s.DebtSupplierRepo.GetPaymentTotalsByReferences(c.Context(), supplierIDs, references) - if err != nil { - return nil, 0, err + purchasesBySupplier[purchase.SupplierId] = append(purchasesBySupplier[purchase.SupplierId], purchase) } paymentsBySupplier := make(map[uint][]entity.Payment, len(supplierIDs)) @@ -724,6 +707,14 @@ func (s *repportService) GetDebtSupplier(c *fiber.Ctx, params *validation.DebtSu now := time.Now().In(location) result := make([]dto.DebtSupplierDTO, 0, len(supplierIDs)) + type debtSupplierRowItem struct { + Row dto.DebtSupplierRowDTO + SortTime time.Time + Order int + DeltaBalance float64 + CountTotals bool + } + for _, supplierID := range supplierIDs { supplier, exists := supplierMap[supplierID] if !exists { @@ -731,23 +722,13 @@ func (s *repportService) GetDebtSupplier(c *fiber.Ctx, params *validation.DebtSu } initialBalance := initialPaymentTotals[supplierID] - initialPurchaseTotals[supplierID] - items := purchasesBySupplier[supplierID] paymentItems := paymentsBySupplier[supplierID] - rows := make([]dto.DebtSupplierRowDTO, 0, len(items)+len(paymentItems)) total := dto.DebtSupplierTotalDTO{} - type debtSupplierRowItem struct { - Row dto.DebtSupplierRowDTO - SortTime time.Time - Order int - DeltaBalance float64 - CountTotals bool - } - combinedRows := make([]debtSupplierRowItem, 0, len(items)+len(paymentItems)) for _, purchase := range items { - row := buildDebtSupplierRow(purchase, paymentTotals, now, location) + row := buildDebtSupplierRow(purchase, now, location) sortTime := resolveDebtSupplierSortTime(purchase, params.FilterBy, location) combinedRows = append(combinedRows, debtSupplierRowItem{ Row: row, @@ -780,6 +761,7 @@ func (s *repportService) GetDebtSupplier(c *fiber.Ctx, params *validation.DebtSu balance := initialBalance for i := range combinedRows { balance += combinedRows[i].DeltaBalance + combinedRows[i].Row.DebtPrice = balance combinedRows[i].Row.Balance = balance if combinedRows[i].CountTotals { @@ -788,13 +770,13 @@ func (s *repportService) GetDebtSupplier(c *fiber.Ctx, params *validation.DebtSu total.Aging = row.Aging } total.TotalPrice += row.TotalPrice - total.PaymentPrice += row.PaymentPrice - total.DebtPrice += row.DebtPrice } else { - combinedRows[i].Row.DebtPrice = balance + total.PaymentPrice += combinedRows[i].Row.PaymentPrice } } + total.DebtPrice = balance + rows := make([]dto.DebtSupplierRowDTO, 0, len(combinedRows)) sortDesc := strings.EqualFold(params.SortOrder, "desc") if sortDesc { for i := len(combinedRows) - 1; i >= 0; i-- { @@ -823,18 +805,13 @@ func (s *repportService) GetDebtSupplier(c *fiber.Ctx, params *validation.DebtSu return result, totalSuppliers, nil } -func buildDebtSupplierRow(purchase entity.Purchase, paymentTotals map[string]float64, now time.Time, loc *time.Location) dto.DebtSupplierRowDTO { +func buildDebtSupplierRow(purchase entity.Purchase, now time.Time, loc *time.Location) dto.DebtSupplierRowDTO { prNumber := purchase.PrNumber poNumber := "" if purchase.PoNumber != nil { poNumber = *purchase.PoNumber } - reference := prNumber - if strings.TrimSpace(poNumber) != "" { - reference = poNumber - } - prDate := purchase.CreatedAt.In(loc) startDate := time.Date(prDate.Year(), prDate.Month(), prDate.Day(), 0, 0, 0, 0, loc) endDate := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc) @@ -877,9 +854,6 @@ func buildDebtSupplierRow(purchase entity.Purchase, paymentTotals map[string]flo } } - paymentPrice := paymentTotals[reference] - debtPrice := paymentPrice - totalPrice - dueDate := "" dueStatus := "-" if purchase.DueDate != nil && !purchase.DueDate.IsZero() { @@ -893,10 +867,6 @@ func buildDebtSupplierRow(purchase entity.Purchase, paymentTotals map[string]flo } status := "Belum Lunas" - if debtPrice >= 0 { - status = "Lunas" - } - poDate := "" if purchase.PoDate != nil && !purchase.PoDate.IsZero() { poDate = purchase.PoDate.In(loc).Format("2006-01-02") @@ -913,10 +883,11 @@ func buildDebtSupplierRow(purchase entity.Purchase, paymentTotals map[string]flo DueDate: dueDate, DueStatus: dueStatus, TotalPrice: totalPrice, - PaymentPrice: paymentPrice, - DebtPrice: debtPrice, + PaymentPrice: 0, + DebtPrice: 0, Status: status, TravelNumber: travelNumber, + Balance: 0, } } @@ -946,32 +917,30 @@ func buildDebtSupplierPaymentRow(payment entity.Payment, loc *time.Location) dto DebtPrice: 0, Status: "Pembayaran", TravelNumber: "-", + Balance: 0, } } func resolveDebtSupplierSortTime(purchase entity.Purchase, filterBy string, loc *time.Location) time.Time { - switch strings.ToLower(strings.TrimSpace(filterBy)) { - case "po_date": + if strings.EqualFold(strings.TrimSpace(filterBy), "po_date") { if purchase.PoDate != nil && !purchase.PoDate.IsZero() { return purchase.PoDate.In(loc) } - case "pr_date": - return purchase.CreatedAt.In(loc) - default: - earliest := time.Time{} - for _, item := range purchase.Items { - if item.ReceivedDate == nil || item.ReceivedDate.IsZero() { - continue - } - received := item.ReceivedDate.In(loc) - if earliest.IsZero() || received.Before(earliest) { - earliest = received - } + } + + earliest := time.Time{} + for _, item := range purchase.Items { + if item.ReceivedDate == nil || item.ReceivedDate.IsZero() { + continue } - if !earliest.IsZero() { - return earliest + received := item.ReceivedDate.In(loc) + if earliest.IsZero() || received.Before(earliest) { + earliest = received } } + if !earliest.IsZero() { + return earliest + } return purchase.CreatedAt.In(loc) } diff --git a/internal/modules/repports/validations/repport.validation.go b/internal/modules/repports/validations/repport.validation.go index 5b60a31f..5f166248 100644 --- a/internal/modules/repports/validations/repport.validation.go +++ b/internal/modules/repports/validations/repport.validation.go @@ -49,7 +49,7 @@ type DebtSupplierQuery struct { SupplierIDs []int64 `query:"-" validate:"omitempty,dive,gt=0"` StartDate string `query:"start_date" validate:"omitempty,datetime=2006-01-02"` EndDate string `query:"end_date" validate:"omitempty,datetime=2006-01-02"` - FilterBy string `query:"filter_by" validate:"omitempty,oneof=received_date po_date pr_date do_date"` + FilterBy string `query:"filter_by" validate:"omitempty,oneof=received_date po_date"` SortOrder string `query:"sort_order" validate:"omitempty,oneof=asc desc"` } From 7545e9b37d871c057e97cf5044fc792f40d92c5c Mon Sep 17 00:00:00 2001 From: ragilap Date: Tue, 13 Jan 2026 19:57:47 +0700 Subject: [PATCH 26/76] [FIX/BE-US] add ignore for dockerfile.local --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 4a814ebe..24887418 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,7 @@ Makefile docker-compose.local.yml docker-compose.yaml Dockerfile +Dockerfile.local .gitlab-ci.yml # Go build cache .gocache/ From 7f1d796b650a1a5fbd2fecd9c49b10130012a43d Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Tue, 13 Jan 2026 22:50:58 +0700 Subject: [PATCH 27/76] fix[BE]: correct total price calculation in delivery and sales order services --- internal/modules/marketing/services/deliveryorder.service.go | 4 ++-- internal/modules/marketing/services/salesorder.service.go | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/internal/modules/marketing/services/deliveryorder.service.go b/internal/modules/marketing/services/deliveryorder.service.go index a1f4e1dd..a521e5bc 100644 --- a/internal/modules/marketing/services/deliveryorder.service.go +++ b/internal/modules/marketing/services/deliveryorder.service.go @@ -249,7 +249,7 @@ func (s *deliveryOrdersService) CreateOne(c *fiber.Ctx, req *validation.Delivery // Hitung total_weight dan total_price otomatis totalWeight := requestedProduct.Qty * requestedProduct.AvgWeight - totalPrice := requestedProduct.UnitPrice * requestedProduct.Qty + totalPrice := requestedProduct.UnitPrice * totalWeight deliveryProduct.ProductWarehouseId = foundMarketingProduct.ProductWarehouseId deliveryProduct.UnitPrice = requestedProduct.UnitPrice @@ -363,7 +363,7 @@ func (s deliveryOrdersService) UpdateOne(c *fiber.Ctx, req *validation.DeliveryO // Hitung total_weight dan total_price otomatis totalWeight := requestedProduct.Qty * requestedProduct.AvgWeight - totalPrice := requestedProduct.UnitPrice * requestedProduct.Qty + totalPrice := requestedProduct.UnitPrice * totalWeight deliveryProduct.ProductWarehouseId = foundMarketingProduct.ProductWarehouseId deliveryProduct.UnitPrice = requestedProduct.UnitPrice diff --git a/internal/modules/marketing/services/salesorder.service.go b/internal/modules/marketing/services/salesorder.service.go index d57b323e..e73184dd 100644 --- a/internal/modules/marketing/services/salesorder.service.go +++ b/internal/modules/marketing/services/salesorder.service.go @@ -294,7 +294,7 @@ func (s salesOrdersService) UpdateOne(c *fiber.Ctx, req *validation.Update, id u // Hitung total_weight dan total_price otomatis totalWeight := rp.Qty * rp.AvgWeight - totalPrice := rp.UnitPrice * rp.Qty + totalPrice := rp.UnitPrice * totalWeight updateBody := map[string]any{ "product_warehouse_id": rp.ProductWarehouseId, @@ -594,7 +594,7 @@ func (s *salesOrdersService) createMarketingProductWithDelivery(ctx context.Cont // Hitung total_weight dan total_price otomatis totalWeight := rp.Qty * rp.AvgWeight - totalPrice := rp.UnitPrice * rp.Qty + totalPrice := rp.UnitPrice * totalWeight marketingProduct := &entity.MarketingProduct{ MarketingId: marketingId, From bd9d41e161e573b86382cfc9f0412a1f69bf5352 Mon Sep 17 00:00:00 2001 From: MacBook Air M1 Date: Wed, 14 Jan 2026 11:20:42 +0700 Subject: [PATCH 28/76] fix(BE): add .air.toml --- .air.toml | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 .air.toml diff --git a/.air.toml b/.air.toml new file mode 100644 index 00000000..c463b5b2 --- /dev/null +++ b/.air.toml @@ -0,0 +1,13 @@ +# .air.toml +root = "." +tmp_dir = "tmp" + +[build] +cmd = "go build -buildvcs=false -o ./tmp/main ./cmd/api" +bin = "tmp/main" +full_bin = "APP_ENV=dev ./tmp/main" +include_ext = ["go", "tpl", "tmpl", "html"] +exclude_dir = ["vendor", "tmp"] + +[log] +time = true From 8a639f127c8aaf84546893f1811b445971c870d5 Mon Sep 17 00:00:00 2001 From: MacBook Air M1 Date: Wed, 14 Jan 2026 11:41:47 +0700 Subject: [PATCH 29/76] adjust api informasi umum filter per kandang --- .../controllers/closing.controller.go | 12 +- internal/modules/closings/dto/closing.dto.go | 41 ++++-- .../closings/services/closing.service.go | 127 +++++++++++++++++- 3 files changed, 164 insertions(+), 16 deletions(-) diff --git a/internal/modules/closings/controllers/closing.controller.go b/internal/modules/closings/controllers/closing.controller.go index c78fd15f..3e64f89b 100644 --- a/internal/modules/closings/controllers/closing.controller.go +++ b/internal/modules/closings/controllers/closing.controller.go @@ -116,7 +116,17 @@ func (u *ClosingController) GetClosingSummary(c *fiber.Ctx) error { return fiber.NewError(fiber.StatusBadRequest, "Invalid projectFlockId") } - result, err := u.ClosingService.GetClosingSummary(c, uint(id)) + var kandangID *uint + if raw := c.Query("kandang_id"); raw != "" { + kandangInt, convErr := strconv.Atoi(raw) + if convErr != nil || kandangInt <= 0 { + return fiber.NewError(fiber.StatusBadRequest, "Invalid kandang_id") + } + kandangUint := uint(kandangInt) + kandangID = &kandangUint + } + + result, err := u.ClosingService.GetClosingSummary(c, uint(id), kandangID) if err != nil { return err } diff --git a/internal/modules/closings/dto/closing.dto.go b/internal/modules/closings/dto/closing.dto.go index 05a606ca..470f506d 100644 --- a/internal/modules/closings/dto/closing.dto.go +++ b/internal/modules/closings/dto/closing.dto.go @@ -59,6 +59,21 @@ type ClosingSummaryDTO struct { StatusClosing string `json:"closing_status"` } +type ClosingSummaryKandangDTO struct { + FlockID uint `json:"flock_id"` + Period int `json:"period"` + LocationName string `json:"location_name"` + Population int `json:"population"` + PopulationFormatted string `json:"population_formatted"` + ProjectType string `json:"project_type"` + ClosingDate string `json:"closing_date"` + KandangName string `json:"kandang_name"` + ChickInDate string `json:"chick_in_date"` + PicName string `json:"pic_name"` + ApprovalDate string `json:"approval_date"` + ProjectStatus string `json:"project_status"` +} + type ClosingPurchaseDTO struct { InitialPopulation int `json:"initial_population"` ClaimCulling int `json:"claim_culling"` @@ -83,18 +98,18 @@ type ClosingEggSalesDTO struct { } type ClosingPerformanceDTO struct { - Depletion float64 `json:"depletion"` - Age float64 `json:"age_day"` - MortalityStd float64 `json:"mor_std"` - MortalityAct float64 `json:"mor_act"` - DeffMortality float64 `json:"mor_diff"` - FcrStd float64 `json:"fcr_std"` - FcrAct float64 `json:"fcr_act"` - DeffFcr float64 `json:"fcr_diff"` - AwgAct float64 `json:"awg_act"` - AwgStd float64 `json:"awg_std"` - FeedIntake float64 `json:"feed_intake"` - FeedIntakeStd float64 `json:"feed_intake_std"` + Depletion float64 `json:"depletion"` + Age float64 `json:"age_day"` + MortalityStd float64 `json:"mor_std"` + MortalityAct float64 `json:"mor_act"` + DeffMortality float64 `json:"mor_diff"` + FcrStd float64 `json:"fcr_std"` + FcrAct float64 `json:"fcr_act"` + DeffFcr float64 `json:"fcr_diff"` + AwgAct float64 `json:"awg_act"` + AwgStd float64 `json:"awg_std"` + FeedIntake float64 `json:"feed_intake"` + FeedIntakeStd float64 `json:"feed_intake_std"` HenDayAct *float64 `json:"hen_day_act,omitempty"` HendayStd *float64 `json:"hen_day_std,omitempty"` EggMass *float64 `json:"egg_mass,omitempty"` @@ -175,7 +190,7 @@ func sumPopulation(history []entity.ProjectFlockKandang) float64 { var total float64 for _, h := range history { for _, chickin := range h.Chickins { - total += chickin.UsageQty + chickin.PendingUsageQty + total += chickin.UsageQty } } return total diff --git a/internal/modules/closings/services/closing.service.go b/internal/modules/closings/services/closing.service.go index 0c543d05..38529b0d 100644 --- a/internal/modules/closings/services/closing.service.go +++ b/internal/modules/closings/services/closing.service.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + "fmt" "math" "strconv" "strings" @@ -35,7 +36,7 @@ type ClosingService interface { GetAll(ctx *fiber.Ctx, params *validation.Query) ([]dto.ClosingListItemDTO, int64, error) GetProjectFlockByID(ctx *fiber.Ctx, id uint) (*entity.ProjectFlock, error) GetPenjualan(ctx *fiber.Ctx, projectFlockID uint, projectFlockKandangID *uint) ([]entity.MarketingDeliveryProduct, error) - GetClosingSummary(ctx *fiber.Ctx, projectFlockID uint) (*dto.ClosingSummaryDTO, error) + GetClosingSummary(ctx *fiber.Ctx, projectFlockID uint, kandangID *uint) (any, error) GetClosingDataProduksi(ctx *fiber.Ctx, projectFlockID uint, kandangID *uint) (*dto.ClosingProductionReportDTO, error) GetOverhead(ctx *fiber.Ctx, projectFlockID uint, projectFlockKandangID *uint) (*dto.OverheadListDTO, error) GetClosingSapronak(ctx *fiber.Ctx, projectFlockID uint, params *validation.ClosingSapronakQuery) ([]dto.ClosingSapronakItemDTO, int64, error) @@ -150,11 +151,15 @@ func (s closingService) GetPenjualan(c *fiber.Ctx, projectFlockID uint, projectF return realisasi, nil } -func (s closingService) GetClosingSummary(c *fiber.Ctx, projectFlockID uint) (*dto.ClosingSummaryDTO, error) { +func (s closingService) GetClosingSummary(c *fiber.Ctx, projectFlockID uint, kandangID *uint) (any, error) { if projectFlockID == 0 { return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid project flock id") } + if kandangID != nil { + return s.getClosingSummaryByKandang(c.Context(), projectFlockID, *kandangID) + } + project, err := s.Repository.GetByID(c.Context(), projectFlockID, s.withClosingRelations) if errors.Is(err, gorm.ErrRecordNotFound) { return nil, fiber.NewError(fiber.StatusNotFound, "Project flock not found") @@ -175,6 +180,124 @@ func (s closingService) GetClosingSummary(c *fiber.Ctx, projectFlockID uint) (*d return &summary, nil } +func (s closingService) getClosingSummaryByKandang(ctx context.Context, projectFlockID uint, kandangID uint) (*dto.ClosingSummaryKandangDTO, error) { + if projectFlockID == 0 || kandangID == 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid project flock id or kandang id") + } + + db := s.Repository.DB().WithContext(ctx) + + var kandang entity.ProjectFlockKandang + if err := db. + Preload("Kandang"). + Preload("Kandang.Location"). + Preload("Kandang.Pic"). + Where("project_flock_id = ?", projectFlockID). + Where("kandang_id = ?", kandangID). + First(&kandang).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Project flock kandang not found") + } + s.Log.Errorf("Failed get project flock kandang %d/%d: %+v", projectFlockID, kandangID, err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch project flock kandang") + } + + var project entity.ProjectFlock + if err := db. + Select("id", "category"). + First(&project, projectFlockID).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Project flock not found") + } + s.Log.Errorf("Failed get project flock %d: %+v", projectFlockID, err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch project flock") + } + + var population float64 + if err := db. + Table("project_flock_populations pfp"). + Joins("JOIN project_chickins pc ON pc.id = pfp.project_chickin_id"). + Where("pc.project_flock_kandang_id = ?", kandang.Id). + Select("COALESCE(SUM(pfp.total_qty), 0)"). + Scan(&population).Error; err != nil { + s.Log.Errorf("Failed to sum population for project flock kandang %d: %+v", kandang.Id, err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch population data") + } + + var chickInDate time.Time + if err := db. + Table("project_chickins"). + Where("project_flock_kandang_id = ?", kandang.Id). + Select("MIN(chick_in_date)"). + Scan(&chickInDate).Error; err != nil { + s.Log.Errorf("Failed to fetch chick in date for project flock kandang %d: %+v", kandang.Id, err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch chick in date") + } + + statusProject := "Belum Selesai" + var approvalDate string + if s.ApprovalSvc != nil { + records, _, err := s.ApprovalSvc.List(ctx, utils.ApprovalWorkflowProjectFlockKandang.String(), &kandang.Id, 1, 1000, "") + if err != nil { + s.Log.Errorf("Failed to fetch approvals for project flock kandang %d: %+v", kandang.Id, err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch approval data") + } + + var ( + minStep uint16 + latestActionAt time.Time + ) + + for _, rec := range records { + if minStep == 0 || rec.StepNumber < minStep { + minStep = rec.StepNumber + } + + if latestActionAt.IsZero() || rec.ActionAt.After(latestActionAt) { + latestActionAt = rec.ActionAt + statusProject = rec.StepName + } + } + + if statusProject == "" && minStep > 0 { + if label, ok := approvalutils.ApprovalStepName(utils.ApprovalWorkflowProjectFlockKandang, approvalutils.ApprovalStep(minStep)); ok { + statusProject = label + } + } + + if !latestActionAt.IsZero() { + approvalDate = latestActionAt.Format("2006-01-02") + } + } + + closingDate := "" + if kandang.ClosedAt != nil { + closingDate = kandang.ClosedAt.Format("2006-01-02") + } + + chickInDateStr := "" + if !chickInDate.IsZero() { + chickInDateStr = chickInDate.Format("2006-01-02") + } + + populationInt := int(population) + + return &dto.ClosingSummaryKandangDTO{ + FlockID: projectFlockID, + Period: kandang.Period, + LocationName: kandang.Kandang.Location.Name, + Population: populationInt, + PopulationFormatted: fmt.Sprintf("%d Ekor", populationInt), + ProjectType: project.Category, + ClosingDate: closingDate, + KandangName: kandang.Kandang.Name, + ChickInDate: chickInDateStr, + PicName: kandang.Kandang.Pic.Name, + ApprovalDate: approvalDate, + ProjectStatus: statusProject, + }, nil +} + func (s closingService) GetClosingSapronak(c *fiber.Ctx, projectFlockID uint, params *validation.ClosingSapronakQuery) ([]dto.ClosingSapronakItemDTO, int64, error) { if projectFlockID == 0 { return nil, 0, fiber.NewError(fiber.StatusBadRequest, "Invalid project flock id") From f6e872c0aa2c026a1fee649635f71d72a9f41d54 Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Wed, 14 Jan 2026 11:46:39 +0700 Subject: [PATCH 30/76] feat[BE]: implement customer payment report retrieval with pagination and filtering --- .../controllers/repport.controller.go | 84 ++++--- .../dto/repportCustomerPayment.dto.go | 62 +++--- internal/modules/repports/module.go | 3 +- .../customer_payment.repository.go | 205 ++++++++++++++++++ .../repports/services/repport.service.go | 171 ++++++++++++++- .../validations/repport.validation.go | 8 + 6 files changed, 460 insertions(+), 73 deletions(-) create mode 100644 internal/modules/repports/repositories/customer_payment.repository.go diff --git a/internal/modules/repports/controllers/repport.controller.go b/internal/modules/repports/controllers/repport.controller.go index d9701305..577c1b1b 100644 --- a/internal/modules/repports/controllers/repport.controller.go +++ b/internal/modules/repports/controllers/repport.controller.go @@ -242,6 +242,60 @@ func (c *RepportController) GetHppPerKandang(ctx *fiber.Ctx) error { return ctx.Status(fiber.StatusOK).JSON(resp) } +func (c *RepportController) GetCustomerPayment(ctx *fiber.Ctx) error { + var customerID *uint + if customerIDStr := ctx.Query("customer_id"); customerIDStr != "" { + if id, err := strconv.ParseUint(customerIDStr, 10, 32); err == nil { + cid := uint(id) + customerID = &cid + } + } + + query := &validation.CustomerPaymentQuery{ + Page: ctx.QueryInt("page", 1), + Limit: ctx.QueryInt("limit", 10), + CustomerID: customerID, + StartDate: ctx.Query("start_date", ""), + EndDate: ctx.Query("end_date", ""), + } + + // Validate pagination + if customerID == nil && (query.Page < 1 || query.Limit < 1) { + return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0") + } + + result, totalResults, err := c.RepportService.GetCustomerPayment(ctx, query) + if err != nil { + return err + } + + // If single customer mode, return without pagination + if customerID != nil { + return ctx.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get customer payment report successfully", + Data: result, + }) + } + + // Multiple customers mode with pagination + return ctx.Status(fiber.StatusOK). + JSON(response.SuccessWithPaginate[dto.CustomerPaymentReportItem]{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get customer payment report successfully", + Meta: response.Meta{ + Page: query.Page, + Limit: query.Limit, + TotalPages: int64(math.Ceil(float64(totalResults) / float64(query.Limit))), + TotalResults: totalResults, + }, + Data: result, + }) +} + func (c *RepportController) GetProductionResult(ctx *fiber.Ctx) error { idParam := ctx.Params("idProjectFlockKandang") if idParam == "" { @@ -283,36 +337,6 @@ func (c *RepportController) GetProductionResult(ctx *fiber.Ctx) error { }) } -func (c *RepportController) GetCustomerPayment(ctx *fiber.Ctx) error { - page := ctx.QueryInt("page", 1) - limit := ctx.QueryInt("limit", 10) - - if page < 1 || limit < 1 { - return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0") - } - - - - - - data := []dto.CustomerPaymentReportItem{} - totalResults := int64(0) - - return ctx.Status(fiber.StatusOK). - JSON(response.SuccessWithPaginate[dto.CustomerPaymentReportItem]{ - Code: fiber.StatusOK, - Status: "success", - Message: "Get customer payment report successfully", - Meta: response.Meta{ - Page: page, - Limit: limit, - TotalPages: int64(math.Ceil(float64(totalResults) / float64(limit))), - TotalResults: totalResults, - }, - Data: data, - }) -} - func parseCommaSeparatedInt64s(raw string) ([]int64, error) { raw = strings.TrimSpace(raw) if raw == "" { diff --git a/internal/modules/repports/dto/repportCustomerPayment.dto.go b/internal/modules/repports/dto/repportCustomerPayment.dto.go index e0938b51..2f200379 100644 --- a/internal/modules/repports/dto/repportCustomerPayment.dto.go +++ b/internal/modules/repports/dto/repportCustomerPayment.dto.go @@ -2,42 +2,33 @@ package dto import ( "time" + + customerDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/customers/dto" ) -// CustomerPaymentReportCustomer represents customer information in the report -type CustomerPaymentReportCustomer struct { - ID uint `json:"id"` - Name string `json:"name"` - Type string `json:"type"` - AccountNumber string `json:"account_number"` - Balance float64 `json:"balance"` - Address string `json:"address"` -} - -// CustomerPaymentReportRow represents each transaction row type CustomerPaymentReportRow struct { - ID uint `json:"id"` - DoDate time.Time `json:"do_date"` - RealizationDate time.Time `json:"realization_date"` - AgingDay int `json:"aging_day"` - Reference string `json:"reference"` - VehiclePlate []string `json:"vehicle_plate"` - Qty float64 `json:"qty"` - Weight float64 `json:"weight"` - AverageWeight float64 `json:"average_weight"` - Price float64 `json:"price"` - CreditNote float64 `json:"credit_note"` - FinalPrice float64 `json:"final_price"` - PPN float64 `json:"ppn"` - Total float64 `json:"total"` - Payment float64 `json:"payment"` - AccountsReceivable float64 `json:"accounts_receivable"` - Notes string `json:"notes"` - PickupInfo string `json:"pickup_info"` - SalesMarketing string `json:"sales_marketing"` + TransactionType string `json:"transaction_type"` + TransactionID int64 `json:"transaction_id"` + TransDate time.Time `json:"trans_date"` + DeliveryDate *time.Time `json:"delivery_date"` + Reference string `json:"reference"` + VehicleNumbers string `json:"vehicle_numbers"` + Qty float64 `json:"qty"` + Weight float64 `json:"weight"` + AverageWeight float64 `json:"average_weight"` + Price float64 `json:"price"` + CreditNote float64 `json:"credit_note"` + FinalPrice float64 `json:"final_price"` + PPN float64 `json:"ppn"` + TotalPrice float64 `json:"total_price"` + PaymentAmount float64 `json:"payment_amount"` + AccountsReceivable float64 `json:"accounts_receivable"` + AgingDay int `json:"aging_day"` + Status string `json:"status"` + PickupInfo string `json:"pickup_info"` + SalesPerson string `json:"sales_person"` } -// CustomerPaymentReportSummary represents summary calculations per customer type CustomerPaymentReportSummary struct { TotalQty float64 `json:"total_qty"` TotalWeight float64 `json:"total_weight"` @@ -50,14 +41,13 @@ type CustomerPaymentReportSummary struct { TotalAccountsReceivable float64 `json:"total_accounts_receivable"` } -// CustomerPaymentReportItem represents data grouped by customer type CustomerPaymentReportItem struct { - Customer CustomerPaymentReportCustomer `json:"customer"` - Rows []CustomerPaymentReportRow `json:"rows"` - Summary CustomerPaymentReportSummary `json:"summary"` + Customer customerDTO.CustomerRelationDTO `json:"customer"` + InitialBalance float64 `json:"initial_balance"` + Rows []CustomerPaymentReportRow `json:"rows"` + Summary CustomerPaymentReportSummary `json:"summary"` } -// CustomerPaymentReportResponse represents the complete response type CustomerPaymentReportResponse struct { Data []CustomerPaymentReportItem `json:"data"` } diff --git a/internal/modules/repports/module.go b/internal/modules/repports/module.go index 61f37d4d..b0432316 100644 --- a/internal/modules/repports/module.go +++ b/internal/modules/repports/module.go @@ -34,10 +34,11 @@ func (RepportModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate * debtSupplierRepository := repportRepo.NewDebtSupplierRepository(db) hppPerKandangRepository := repportRepo.NewHppPerKandangRepository(db) productionResultRepository := repportRepo.NewProductionResultRepository(db) + customerPaymentRepository := repportRepo.NewCustomerPaymentRepository(db) userRepository := rUser.NewUserRepository(db) approvalSvc := approvalService.NewApprovalService(approvalRepository) - repportService := sRepport.NewRepportService(validate, expenseRealizationRepository, marketingDeliveryProductRepository, purchaseRepository, chickinRepository, recordingRepository, approvalSvc, purchaseSupplierRepository, debtSupplierRepository, hppPerKandangRepository, productionResultRepository) + repportService := sRepport.NewRepportService(db, validate, expenseRealizationRepository, marketingDeliveryProductRepository, purchaseRepository, chickinRepository, recordingRepository, approvalSvc, purchaseSupplierRepository, debtSupplierRepository, hppPerKandangRepository, productionResultRepository, customerPaymentRepository) userService := sUser.NewUserService(userRepository, validate) RepportRoutes(router, userService, repportService) diff --git a/internal/modules/repports/repositories/customer_payment.repository.go b/internal/modules/repports/repositories/customer_payment.repository.go new file mode 100644 index 00000000..1d4ffd28 --- /dev/null +++ b/internal/modules/repports/repositories/customer_payment.repository.go @@ -0,0 +1,205 @@ +package repositories + +import ( + "context" + "time" + + "gorm.io/gorm" +) + +type CustomerPaymentTransaction struct { + TransactionType string `gorm:"column:transaction_type"` + TransactionID int64 `gorm:"column:transaction_id"` + CustomerID int64 `gorm:"column:customer_id"` + TransDate time.Time `gorm:"column:trans_date"` + DeliveryDate *time.Time `gorm:"column:delivery_date"` + Reference string `gorm:"column:reference"` + VehicleNumbers string `gorm:"column:vehicle_numbers"` + Qty float64 `gorm:"column:qty"` + Weight float64 `gorm:"column:weight"` + AverageWeight float64 `gorm:"column:average_weight"` + Price float64 `gorm:"column:price"` + CreditNote float64 `gorm:"column:credit_note"` + FinalPrice float64 `gorm:"column:final_price"` + PPN float64 `gorm:"column:ppn"` + TotalPrice float64 `gorm:"column:total_price"` + PaymentAmount float64 `gorm:"column:payment_amount"` + PickupInfo string `gorm:"column:pickup_info"` + SalesPerson string `gorm:"column:sales_person"` +} + +type CustomerPaymentRepository interface { + GetCustomerPaymentTransactions(ctx context.Context, customerID *uint) ([]CustomerPaymentTransaction, error) + GetInitialBalanceByCustomer(ctx context.Context, customerID uint) (float64, error) + GetCustomerIDsWithTransactions(ctx context.Context, limit, offset int) ([]uint, int64, error) +} + +type customerPaymentRepositoryImpl struct { + db *gorm.DB +} + +func NewCustomerPaymentRepository(db *gorm.DB) CustomerPaymentRepository { + return &customerPaymentRepositoryImpl{db: db} +} + +func (r *customerPaymentRepositoryImpl) GetCustomerPaymentTransactions(ctx context.Context, customerID *uint) ([]CustomerPaymentTransaction, error) { + // Build SALES subquery + salesQuery := r.db.WithContext(ctx). + Table("marketings m"). + Select(` + 'SALES' AS transaction_type, + m.id::BIGINT AS transaction_id, + c.id::BIGINT AS customer_id, + m.so_date::DATE AS trans_date, + MAX(mdp.delivery_date)::DATE AS delivery_date, + m.so_number AS reference, + COALESCE(STRING_AGG(DISTINCT mdp.vehicle_number, ', ') FILTER (WHERE mdp.vehicle_number IS NOT NULL AND mdp.vehicle_number != ''), '') AS vehicle_numbers, + COALESCE(SUM(COALESCE(mp.qty, 0)), 0)::NUMERIC(15,3) AS qty, + COALESCE(SUM(COALESCE(mdp.total_weight, mp.total_weight, 0)), 0)::NUMERIC(15,3) AS weight, + CASE WHEN COALESCE(SUM(COALESCE(mp.qty, 0)), 0) > 0 + THEN (COALESCE(SUM(COALESCE(mdp.total_weight, mp.total_weight, 0)), 0) / COALESCE(SUM(COALESCE(mp.qty, 0)), 0))::NUMERIC(15,3) + ELSE 0::NUMERIC(15,3) + END AS average_weight, + COALESCE(AVG(COALESCE(mdp.unit_price, mp.unit_price, 0)), 0)::NUMERIC(15,3) AS price, + 0::NUMERIC(15,3) AS credit_note, + COALESCE(SUM(COALESCE(mdp.total_price, mp.total_price)), 0)::NUMERIC(15,3) AS final_price, + 0::NUMERIC(15,3) AS ppn, + COALESCE(SUM(COALESCE(mdp.total_price, mp.total_price)), 0)::NUMERIC(15,3) AS total_price, + 0::NUMERIC(15,3) AS payment_amount, + COALESCE(STRING_AGG(DISTINCT w.name, ', ') FILTER (WHERE w.name IS NOT NULL), '') AS pickup_info, + MAX(u.name) AS sales_person + `). + Joins("INNER JOIN customers c ON c.id = m.customer_id"). + Joins("LEFT JOIN marketing_products mp ON mp.marketing_id = m.id"). + Joins("LEFT JOIN marketing_delivery_products mdp ON mdp.marketing_product_id = mp.id"). + Joins("LEFT JOIN product_warehouses pw ON pw.id = mp.product_warehouse_id"). + Joins("LEFT JOIN warehouses w ON w.id = pw.warehouse_id"). + Joins("LEFT JOIN users u ON u.id = m.sales_person_id"). + Where("m.deleted_at IS NULL"). + Where("c.deleted_at IS NULL") + + if customerID != nil { + salesQuery = salesQuery.Where("c.id = ?", *customerID) + } + + salesQuery = salesQuery.Group("m.id, c.id, m.so_date, m.so_number") + + // Build PAYMENT subquery + paymentQuery := r.db.WithContext(ctx). + Table("payments p"). + Select(` + 'PAYMENT' AS transaction_type, + p.id::BIGINT AS transaction_id, + c.id::BIGINT AS customer_id, + p.payment_date::DATE AS trans_date, + NULL AS delivery_date, + COALESCE(p.reference_number, p.payment_code) AS reference, + '-' AS vehicle_numbers, + 0::NUMERIC(15,3) AS qty, + 0::NUMERIC(15,3) AS weight, + 0::NUMERIC(15,3) AS average_weight, + 0::NUMERIC(15,3) AS price, + 0::NUMERIC(15,3) AS credit_note, + 0::NUMERIC(15,3) AS final_price, + 0::NUMERIC(15,3) AS ppn, + 0::NUMERIC(15,3) AS total_price, + p.nominal::NUMERIC(15,3) AS payment_amount, + '-' AS pickup_info, + '-' AS sales_person + `). + Joins("INNER JOIN customers c ON c.id = p.party_id"). + Where("p.party_type = ?", "CUSTOMER"). + Where("p.direction = ?", "IN"). + Where("p.transaction_type = ?", "PENJUALAN"). + Where("p.deleted_at IS NULL"). + Where("c.deleted_at IS NULL") + + if customerID != nil { + paymentQuery = paymentQuery.Where("c.id = ?", *customerID) + } + + // Combine with UNION ALL and execute + var results []CustomerPaymentTransaction + err := r.db.WithContext(ctx). + Raw("? UNION ALL ? ORDER BY customer_id, trans_date, transaction_type DESC, transaction_id", + salesQuery, + paymentQuery, + ). + Scan(&results). + Error + + if err != nil { + return nil, err + } + + return results, nil +} + +func (r *customerPaymentRepositoryImpl) GetInitialBalanceByCustomer(ctx context.Context, customerID uint) (float64, error) { + var result struct { + Nominal float64 + } + + err := r.db.WithContext(ctx). + Table("payments"). + Select("COALESCE(SUM(nominal), 0) as nominal"). + Where("party_type = ?", "CUSTOMER"). + Where("party_id = ?", customerID). + Where("transaction_type = ?", "SALDO_AWAL"). + Where("deleted_at IS NULL"). + Scan(&result). + Error + + if err != nil { + return 0, err + } + + return result.Nominal, nil +} + +func (r *customerPaymentRepositoryImpl) GetCustomerIDsWithTransactions(ctx context.Context, limit, offset int) ([]uint, int64, error) { + // Subquery to get all distinct customer IDs with transactions + subQuery := r.db.WithContext(ctx). + Table("(" + + "SELECT DISTINCT c.id as customer_id FROM marketings m " + + "INNER JOIN customers c ON c.id = m.customer_id " + + "WHERE m.deleted_at IS NULL AND c.deleted_at IS NULL " + + "UNION " + + "SELECT DISTINCT c.id as customer_id FROM payments p " + + "INNER JOIN customers c ON c.id = p.party_id " + + "WHERE p.party_type = 'CUSTOMER' AND p.direction = 'IN' " + + "AND p.transaction_type = 'PENJUALAN' AND p.deleted_at IS NULL AND c.deleted_at IS NULL" + + ") as customer_ids") + + // Count total customers + var total int64 + if err := subQuery.Count(&total).Error; err != nil { + return nil, 0, err + } + + // Get paginated customer IDs + var customerIDs []uint + err := r.db.WithContext(ctx). + Table("("+ + "SELECT DISTINCT c.id as customer_id FROM marketings m "+ + "INNER JOIN customers c ON c.id = m.customer_id "+ + "WHERE m.deleted_at IS NULL AND c.deleted_at IS NULL "+ + "UNION "+ + "SELECT DISTINCT c.id as customer_id FROM payments p "+ + "INNER JOIN customers c ON c.id = p.party_id "+ + "WHERE p.party_type = 'CUSTOMER' AND p.direction = 'IN' "+ + "AND p.transaction_type = 'PENJUALAN' AND p.deleted_at IS NULL AND c.deleted_at IS NULL"+ + ") as customer_ids"). + Select("customer_id"). + Order("customer_id ASC"). + Limit(limit). + Offset(offset). + Pluck("customer_id", &customerIDs). + Error + + if err != nil { + return nil, 0, err + } + + return customerIDs, total, nil +} diff --git a/internal/modules/repports/services/repport.service.go b/internal/modules/repports/services/repport.service.go index a36d4d21..89acca09 100644 --- a/internal/modules/repports/services/repport.service.go +++ b/internal/modules/repports/services/repport.service.go @@ -19,6 +19,7 @@ import ( expenseRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/repositories" marketingRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/repositories" areaDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/areas/dto" + customerDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/customers/dto" supplierDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/suppliers/dto" warehouseDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/dto" chickinRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/production/chickins/repositories" @@ -40,12 +41,13 @@ type RepportService interface { GetDebtSupplier(ctx *fiber.Ctx, params *validation.DebtSupplierQuery) ([]dto.DebtSupplierDTO, int64, error) GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangResponseData, *dto.HppPerKandangMetaDTO, error) GetProductionResult(ctx *fiber.Ctx, params *validation.ProductionResultQuery) ([]dto.ProductionResultDTO, int64, error) - GetCustomerPayment(ctx *fiber.Ctx) (int, error) + GetCustomerPayment(ctx *fiber.Ctx, params *validation.CustomerPaymentQuery) ([]dto.CustomerPaymentReportItem, int64, error) } type repportService struct { Log *logrus.Logger Validate *validator.Validate + DB *gorm.DB ExpenseRealizationRepo expenseRepo.ExpenseRealizationRepository MarketingDeliveryRepo marketingRepo.MarketingDeliveryProductRepository PurchaseRepo purchaseRepo.PurchaseRepository @@ -56,6 +58,7 @@ type repportService struct { DebtSupplierRepo repportRepo.DebtSupplierRepository HppPerKandangRepo repportRepo.HppPerKandangRepository ProductionResultRepo repportRepo.ProductionResultRepository + CustomerPaymentRepo repportRepo.CustomerPaymentRepository } type HppCostAggregate struct { @@ -68,6 +71,7 @@ type HppCostAggregate struct { } func NewRepportService( + db *gorm.DB, validate *validator.Validate, expenseRealizationRepo expenseRepo.ExpenseRealizationRepository, marketingDeliveryRepo marketingRepo.MarketingDeliveryProductRepository, @@ -79,10 +83,12 @@ func NewRepportService( debtSupplierRepo repportRepo.DebtSupplierRepository, hppPerKandangRepo repportRepo.HppPerKandangRepository, productionResultRepo repportRepo.ProductionResultRepository, + customerPaymentRepo repportRepo.CustomerPaymentRepository, ) RepportService { return &repportService{ Log: utils.Log, Validate: validate, + DB: db, ExpenseRealizationRepo: expenseRealizationRepo, MarketingDeliveryRepo: marketingDeliveryRepo, PurchaseRepo: purchaseRepo, @@ -93,6 +99,7 @@ func NewRepportService( DebtSupplierRepo: debtSupplierRepo, HppPerKandangRepo: hppPerKandangRepo, ProductionResultRepo: productionResultRepo, + CustomerPaymentRepo: customerPaymentRepo, } } @@ -308,6 +315,163 @@ func (s *repportService) GetProductionResult(ctx *fiber.Ctx, params *validation. return weeklyResults, totalWeeks, nil } +func (s *repportService) GetCustomerPayment(ctx *fiber.Ctx, params *validation.CustomerPaymentQuery) ([]dto.CustomerPaymentReportItem, int64, error) { + if err := s.Validate.Struct(params); err != nil { + return nil, 0, err + } + + // Determine customer IDs to process + var customerIDs []uint + var totalCustomers int64 + + if params.CustomerID != nil { + // Single customer mode + customerIDs = []uint{*params.CustomerID} + totalCustomers = 1 + } else { + // Multiple customers mode with pagination + page := params.Page + limit := params.Limit + if page < 1 { + page = 1 + } + if limit < 1 { + limit = 10 + } + + offset := (page - 1) * limit + + var err error + customerIDs, totalCustomers, err = s.CustomerPaymentRepo.GetCustomerIDsWithTransactions(ctx.Context(), limit, offset) + if err != nil { + return nil, 0, err + } + + if len(customerIDs) == 0 { + return []dto.CustomerPaymentReportItem{}, 0, nil + } + } + + // Process each customer + var result []dto.CustomerPaymentReportItem + for _, customerID := range customerIDs { + item, err := s.processCustomerPayment(ctx.Context(), customerID) + if err != nil { + return nil, 0, err + } + result = append(result, item) + } + + return result, totalCustomers, nil +} + +func (s *repportService) processCustomerPayment(ctx context.Context, customerID uint) (dto.CustomerPaymentReportItem, error) { + // Get customer info + customer := entity.Customer{} + if err := s.DB.WithContext(ctx). + Where("id = ?", customerID). + First(&customer).Error; err != nil { + return dto.CustomerPaymentReportItem{}, err + } + + // Get initial balance + initialBalance, err := s.CustomerPaymentRepo.GetInitialBalanceByCustomer(ctx, customerID) + if err != nil { + return dto.CustomerPaymentReportItem{}, err + } + + // Get transactions for this customer + cid := customerID + transactions, err := s.CustomerPaymentRepo.GetCustomerPaymentTransactions(ctx, &cid) + if err != nil { + return dto.CustomerPaymentReportItem{}, err + } + + // Process transactions and calculate running balance + rows := make([]dto.CustomerPaymentReportRow, 0, len(transactions)) + runningBalance := initialBalance + + for _, tx := range transactions { + row := dto.CustomerPaymentReportRow{ + TransactionType: tx.TransactionType, + TransactionID: tx.TransactionID, + TransDate: tx.TransDate, + DeliveryDate: tx.DeliveryDate, + Reference: tx.Reference, + VehicleNumbers: tx.VehicleNumbers, + Qty: tx.Qty, + Weight: tx.Weight, + AverageWeight: tx.AverageWeight, + Price: tx.Price, + CreditNote: tx.CreditNote, + FinalPrice: tx.FinalPrice, + PPN: tx.PPN, + TotalPrice: tx.TotalPrice, + PaymentAmount: tx.PaymentAmount, + PickupInfo: tx.PickupInfo, + SalesPerson: tx.SalesPerson, + } + + // Calculate running balance + if tx.TransactionType == "SALES" { + runningBalance -= tx.TotalPrice + // Status will be calculated later (requires looking ahead) + row.Status = "" + row.AgingDay = 0 // Will be calculated later + } else if tx.TransactionType == "PAYMENT" { + runningBalance += tx.PaymentAmount + row.Status = "" + row.AgingDay = 0 + } + + row.AccountsReceivable = runningBalance + rows = append(rows, row) + } + + // Calculate summary + summary := s.calculateSummary(rows, initialBalance) + + // Build customer DTO + customerDTO := customerDTO.CustomerRelationDTO{ + Id: customer.Id, + Name: customer.Name, + Type: customer.Type, + AccountNumber: customer.AccountNumber, + Balance: customer.Balance, + } + + return dto.CustomerPaymentReportItem{ + Customer: customerDTO, + InitialBalance: initialBalance, + Rows: rows, + Summary: summary, + }, nil +} + +func (s *repportService) calculateSummary(rows []dto.CustomerPaymentReportRow, initialBalance float64) dto.CustomerPaymentReportSummary { + summary := dto.CustomerPaymentReportSummary{} + + for _, row := range rows { + summary.TotalQty += row.Qty + summary.TotalWeight += row.Weight + summary.TotalCreditNote += row.CreditNote + summary.TotalPPN += row.PPN + + if row.TransactionType == "SALES" { + summary.TotalInitialAmount += row.TotalPrice + summary.TotalFinalAmount += row.FinalPrice + summary.TotalGrandAmount += row.TotalPrice + } else if row.TransactionType == "PAYMENT" { + summary.TotalPayment += row.PaymentAmount + } + } + + // Final AR = initial balance + total sales - total payment + summary.TotalAccountsReceivable = initialBalance + summary.TotalGrandAmount - summary.TotalPayment + + return summary +} + func mapRecordingToProductionResultDTO(record entity.Recording) dto.ProductionResultDTO { result := dto.ProductionResultDTO{ CreatedAt: record.CreatedAt, @@ -1374,11 +1538,6 @@ func (s *repportService) parseHppPerKandangQuery(ctx *fiber.Ctx) (*validation.Hp return params, filters, nil } -func (s *repportService) GetCustomerPayment(c *fiber.Ctx) (int, error) { - - return 0, nil -} - func parseCommaSeparatedInt64s(raw string) ([]int64, error) { raw = strings.TrimSpace(raw) if raw == "" { diff --git a/internal/modules/repports/validations/repport.validation.go b/internal/modules/repports/validations/repport.validation.go index 5dde8f51..c79dd90d 100644 --- a/internal/modules/repports/validations/repport.validation.go +++ b/internal/modules/repports/validations/repport.validation.go @@ -71,3 +71,11 @@ type ProductionResultQuery struct { Limit int `query:"limit" validate:"omitempty,min=1,max=100,gt=0"` ProjectFlockKandangID uint `query:"-" validate:"required,gt=0"` } + +type CustomerPaymentQuery struct { + Page int `query:"page" validate:"omitempty,min=1,gt=0"` + Limit int `query:"limit" validate:"omitempty,min=1,max=100,gt=0"` + CustomerID *uint `query:"customer_id" validate:"omitempty,gt=0"` + StartDate string `query:"start_date" validate:"omitempty,datetime=2006-01-02"` + EndDate string `query:"end_date" validate:"omitempty,datetime=2006-01-02"` +} From 3012d260ec00a782fa1dff96d3188a94a816e524 Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Wed, 14 Jan 2026 11:57:56 +0700 Subject: [PATCH 31/76] FIX[BE]: fixing error on report marketing dto --- .../modules/repports/dto/repportMarketing.dto.go | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/internal/modules/repports/dto/repportMarketing.dto.go b/internal/modules/repports/dto/repportMarketing.dto.go index 36df7a05..92ee9a77 100644 --- a/internal/modules/repports/dto/repportMarketing.dto.go +++ b/internal/modules/repports/dto/repportMarketing.dto.go @@ -302,6 +302,16 @@ func (p ProductRelationDTOFixed) MarshalJSON() ([]byte, error) { Suppliers []supplierDTO.SupplierRelationDTO `json:"suppliers"` } + suppliers := make([]supplierDTO.SupplierRelationDTO, len(p.ProductRelationDTO.Suppliers)) + for i, ps := range p.ProductRelationDTO.Suppliers { + suppliers[i] = supplierDTO.SupplierRelationDTO{ + Id: ps.Id, + Name: ps.Name, + Alias: ps.Alias, + Category: ps.Category, + } + } + return json.Marshal(&Alias{ Id: p.ProductRelationDTO.Id, Name: p.ProductRelationDTO.Name, @@ -310,6 +320,6 @@ func (p ProductRelationDTOFixed) MarshalJSON() ([]byte, error) { Uom: p.ProductRelationDTO.Uom, Flags: p.ProductRelationDTO.Flags, ProductCategory: p.ProductRelationDTO.ProductCategory, - Suppliers: p.ProductRelationDTO.Suppliers, + Suppliers: suppliers, }) } From e12c34db133a8deb086d4a147e01bbf0e71b89dd Mon Sep 17 00:00:00 2001 From: kris Date: Wed, 14 Jan 2026 06:18:47 +0000 Subject: [PATCH 32/76] Update .gitlab-ci.yml file --- .gitlab-ci.yml | 245 ++++++++++++++++--------------------------------- 1 file changed, 81 insertions(+), 164 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index b0e3883e..53f28b3e 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,173 +1,90 @@ stages: - - build - - migrate - deploy - - seed -default: - tags: - - self-hosted-stg - -workflow: - rules: - - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "staging"' - when: always - - when: never - -variables: - DOCKER_BUILDKIT: "1" - - IMAGE_TAG: "staging_${CI_COMMIT_SHORT_SHA}" - IMAGE_NAME: "${CI_REGISTRY_IMAGE}:${IMAGE_TAG}" - IMAGE_LATEST: "${CI_REGISTRY_IMAGE}:staging_latest" - - DEPLOY_DIR: "/opt/deploy/stg-lti-api" - COMPOSE_FILE: "docker-compose.yaml" - -# ========================= -# BUILD (AUTO) -# ========================= -build_staging: - stage: build - rules: - - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "staging"' - script: | - set -e - docker info - - echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY" - - echo "✅ Build image: $IMAGE_NAME" - docker build -t "$IMAGE_NAME" -f Dockerfile . - - echo "✅ Push image: $IMAGE_NAME" - docker push "$IMAGE_NAME" - - echo "✅ Tag latest: $IMAGE_LATEST" - docker tag "$IMAGE_NAME" "$IMAGE_LATEST" - docker push "$IMAGE_LATEST" - - -# ========================= -# MIGRATE (AUTO) -# ========================= -migrate_staging: - stage: migrate - rules: - - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "staging"' - needs: - - job: build_staging - artifacts: false - script: | - set -e - echo "✅ Running migrations (staging) ..." - - cd "$DEPLOY_DIR" - test -f "$COMPOSE_FILE" || (echo "❌ $COMPOSE_FILE not found in $DEPLOY_DIR" && exit 1) - test -f .env || (echo "❌ .env not found in $DEPLOY_DIR" && exit 1) - - # ✅ load env dari server - set -a - . ./.env - set +a - - # ✅ validasi - test -n "$DB_HOST" || (echo "❌ DB_HOST empty" && exit 1) - test -n "$DB_PORT" || (echo "❌ DB_PORT empty" && exit 1) - test -n "$DB_USER" || (echo "❌ DB_USER empty" && exit 1) - test -n "$DB_PASSWORD" || (echo "❌ DB_PASSWORD empty" && exit 1) - test -n "$DB_NAME" || (echo "❌ DB_NAME empty" && exit 1) - - export DATABASE_URL="postgres://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}?sslmode=${DB_SSLMODE:-disable}" - echo "✅ DATABASE_URL=$DATABASE_URL" - - # ✅ Pastikan postgres & redis ON (sesuaikan nama service compose kamu!) - echo "✅ Ensuring postgres & redis running ..." - docker compose -f "$COMPOSE_FILE" up -d stg-postgres-lti stg-redis-lti || true - - # ✅ Ambil network key dari compose - COMPOSE_NETWORK_KEY="$(docker compose -f "$COMPOSE_FILE" config | awk '/networks:/ {getline; print $1}' | tr -d ':')" - echo "✅ Compose network key: $COMPOSE_NETWORK_KEY" - - # ✅ Cari network name yang dipakai docker - NETWORK_NAME="$(docker network ls --format '{{.Name}}' | grep "_${COMPOSE_NETWORK_KEY}$" | head -n 1)" - test -n "$NETWORK_NAME" || (echo "❌ Cannot find docker network for compose ($COMPOSE_NETWORK_KEY)" && exit 1) - - echo "✅ Docker network detected: $NETWORK_NAME" - - # ✅ Migrations dari repo (CI workspace) - echo "✅ Checking migrations from repo..." - ls -lah "$CI_PROJECT_DIR/internal/database/migrations" - - echo "✅ Running migrations via migrate/migrate container" - set +e - out=$(docker run --rm \ - --network "$NETWORK_NAME" \ - -v "$CI_PROJECT_DIR/internal/database/migrations:/migrations:ro" \ - migrate/migrate:v4.15.2 \ - -path=/migrations -database "$DATABASE_URL" up 2>&1) - code=$? - set -e - - echo "$out" - - # ✅ Handle no change dengan benar (tidak false-success) - if echo "$out" | grep -qi "no change"; then - echo "✅ No change (already up to date)" - exit 0 - fi - - if [ $code -ne 0 ]; then - echo "❌ Migration failed with exit code $code" - exit $code - fi - - echo "✅ Migration applied successfully" - - -# ========================= -# DEPLOY (AUTO) -# ========================= -deploy_staging: +deploy-dev: stage: deploy - rules: - - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "staging"' - needs: - - job: migrate_staging - artifacts: false - - job: build_staging - artifacts: false - script: | - set -e - docker info - echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY" + image: alpine:3.20 + variables: + DEPLOY_APP: "LTI-MBUGROUP" + # Opsional: kalau pakai submodule, ini bikin clone submodule pakai SSH juga + GIT_SUBMODULE_STRATEGY: recursive + GIT_DEPTH: "1" - cd "$DEPLOY_DIR" - test -f "$COMPOSE_FILE" || (echo "❌ $COMPOSE_FILE not found in $DEPLOY_DIR" && exit 1) - test -f .env || (echo "❌ .env not found in $DEPLOY_DIR" && exit 1) + before_script: + - echo "🧰 Installing dependencies..." + - apk update && apk add --no-cache openssh git curl bash - docker compose -f "$COMPOSE_FILE" pull - docker compose -f "$COMPOSE_FILE" up -d --force-recreate - docker image prune -f + # Setup SSH di runner + - mkdir -p ~/.ssh + - echo "$SSH_PRIVATE_KEY" | tr -d '\r' > ~/.ssh/id_rsa + - chmod 600 ~/.ssh/id_rsa + - eval "$(ssh-agent -s)" + - ssh-add ~/.ssh/id_rsa + # Trust host keys (server + gitlab) biar SSH gak nanya interaktif + - ssh-keyscan -H "$SERVER_IP" >> ~/.ssh/known_hosts + - ssh-keyscan -H gitlab.com >> ~/.ssh/known_hosts -# ========================= -# SEED (MANUAL) -# ========================= -seed_staging: - stage: seed - rules: - - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "staging"' - needs: - - job: deploy_staging - artifacts: false - when: manual - allow_failure: false - script: | - set -e - cd "$DEPLOY_DIR" - test -f "$COMPOSE_FILE" || (echo "❌ $COMPOSE_FILE not found" && exit 1) - test -f .env || (echo "❌ .env not found" && exit 1) + script: + - echo "🚀 Deploying latest code to $SERVER_USER@$SERVER_IP" - docker compose -f "$COMPOSE_FILE" pull seed || true - docker compose -f "$COMPOSE_FILE" run --rm seed \ No newline at end of file + - > + if ssh -o StrictHostKeyChecking=no "$SERVER_USER@$SERVER_IP" " + set -e + + cd /home/devops/docker/deployment/development/lti-api + + # Pastikan remote origin SSH (antisipasi kalau pernah ke-set HTTPS) + git remote set-url origin git@gitlab.com:mbugroup/lti-api.git + + # Pastikan server percaya gitlab.com juga (untuk git fetch via SSH) + mkdir -p ~/.ssh + ssh-keyscan -H gitlab.com >> ~/.ssh/known_hosts + + # Fetch/reset pakai SSH + GIT_SSH_COMMAND='ssh -o StrictHostKeyChecking=no' git fetch origin development + git reset --hard origin/development + + docker compose restart dev-api-lti || docker compose up -d dev-api-lti + "; then + STATUS='success'; + else + STATUS='failed'; + fi; + + RUN_URL="${CI_PROJECT_URL}/-/pipelines/${CI_PIPELINE_ID}"; + + if [ "$STATUS" = "success" ]; then + COLOR=3066993; + TITLE="✅ Deployment API Succeeded"; + DESC="Deployment job on branch \`${CI_COMMIT_REF_NAME}\` completed successfully."; + else + COLOR=15158332; + TITLE="❌ Deployment API Failed Gaes"; + DESC="Deployment job on branch \`${CI_COMMIT_REF_NAME}\` failed."; + fi; + + echo "{ + \"username\": \"CI Bot\", + \"embeds\": [{ + \"title\": \"$TITLE\", + \"description\": \"$DESC\", + \"color\": $COLOR, + \"fields\": [ + {\"name\": \"Repository\", \"value\": \"${CI_PROJECT_PATH}\", \"inline\": true}, + {\"name\": \"Actor\", \"value\": \"${GITLAB_USER_LOGIN}\", \"inline\": true}, + {\"name\": \"Commit\", \"value\": \"${CI_COMMIT_SHA}\", \"inline\": false}, + {\"name\": \"Pipeline\", \"value\": \"[Open run](${RUN_URL})\", \"inline\": false} + ] + }] + }" > payload.json; + + echo "📡 Sending notification to Discord..."; + curl -sS -H "Content-Type: application/json" \ + -d @payload.json "$DISCORD_WEBHOOK_URL"; + + only: + - development + + environment: + name: development \ No newline at end of file From c2a89910fbc832666c32de653bc3056ad1dbd7ca Mon Sep 17 00:00:00 2001 From: Adnan Zahir Date: Wed, 14 Jan 2026 13:46:15 +0700 Subject: [PATCH 33/76] chore: test staging from development --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index da8394f1..5b502da1 100644 --- a/README.md +++ b/README.md @@ -110,4 +110,4 @@ IT Development PT Mitra Berlian Unggas Group ## 📃 License -This project is private. All rights reserved. +> This project is private. All rights reserved. From 894fa0b22a48ea1f5adaebdf2ca0d3c5a2b4f07c Mon Sep 17 00:00:00 2001 From: kris Date: Wed, 14 Jan 2026 06:55:28 +0000 Subject: [PATCH 34/76] Update .gitlab-ci.yml file --- .gitlab-ci.yml | 245 +++++++++++++++++++++++++++++++++---------------- 1 file changed, 164 insertions(+), 81 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 53f28b3e..a46bb3aa 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,90 +1,173 @@ stages: + - build + - migrate - deploy + - seed -deploy-dev: +default: + tags: + - self-hosted-stg + +workflow: + rules: + - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "staging"' + when: always + - when: never + +variables: + DOCKER_BUILDKIT: "1" + + IMAGE_TAG: "staging_${CI_COMMIT_SHORT_SHA}" + IMAGE_NAME: "${CI_REGISTRY_IMAGE}:${IMAGE_TAG}" + IMAGE_LATEST: "${CI_REGISTRY_IMAGE}:staging_latest" + + DEPLOY_DIR: "/opt/deploy/stg-lti-api" + COMPOSE_FILE: "docker-compose.yaml" + +# ========================= +# BUILD (AUTO) +# ========================= +build_staging: + stage: build + rules: + - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "staging"' + script: | + set -e + docker info + + echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY" + + echo "✅ Build image: $IMAGE_NAME" + docker build -t "$IMAGE_NAME" -f Dockerfile . + + echo "✅ Push image: $IMAGE_NAME" + docker push "$IMAGE_NAME" + + echo "✅ Tag latest: $IMAGE_LATEST" + docker tag "$IMAGE_NAME" "$IMAGE_LATEST" + docker push "$IMAGE_LATEST" + + +# ========================= +# MIGRATE (AUTO) +# ========================= +migrate_staging: + stage: migrate + rules: + - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "staging"' + needs: + - job: build_staging + artifacts: false + script: | + set -e + echo "✅ Running migrations (staging) ..." + + cd "$DEPLOY_DIR" + test -f "$COMPOSE_FILE" || (echo "❌ $COMPOSE_FILE not found in $DEPLOY_DIR" && exit 1) + test -f .env || (echo "❌ .env not found in $DEPLOY_DIR" && exit 1) + + # ✅ load env dari server + set -a + . ./.env + set +a + + # ✅ validasi + test -n "$DB_HOST" || (echo "❌ DB_HOST empty" && exit 1) + test -n "$DB_PORT" || (echo "❌ DB_PORT empty" && exit 1) + test -n "$DB_USER" || (echo "❌ DB_USER empty" && exit 1) + test -n "$DB_PASSWORD" || (echo "❌ DB_PASSWORD empty" && exit 1) + test -n "$DB_NAME" || (echo "❌ DB_NAME empty" && exit 1) + + export DATABASE_URL="postgres://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}?sslmode=${DB_SSLMODE:-disable}" + echo "✅ DATABASE_URL=$DATABASE_URL" + + # ✅ Pastikan postgres & redis ON (sesuaikan nama service compose kamu!) + echo "✅ Ensuring postgres & redis running ..." + docker compose -f "$COMPOSE_FILE" up -d stg-postgres-lti stg-redis-lti || true + + # ✅ Ambil network key dari compose + COMPOSE_NETWORK_KEY="$(docker compose -f "$COMPOSE_FILE" config | awk '/networks:/ {getline; print $1}' | tr -d ':')" + echo "✅ Compose network key: $COMPOSE_NETWORK_KEY" + + # ✅ Cari network name yang dipakai docker + NETWORK_NAME="$(docker network ls --format '{{.Name}}' | grep "_${COMPOSE_NETWORK_KEY}$" | head -n 1)" + test -n "$NETWORK_NAME" || (echo "❌ Cannot find docker network for compose ($COMPOSE_NETWORK_KEY)" && exit 1) + + echo "✅ Docker network detected: $NETWORK_NAME" + + # ✅ Migrations dari repo (CI workspace) + echo "✅ Checking migrations from repo..." + ls -lah "$CI_PROJECT_DIR/internal/database/migrations" + + echo "✅ Running migrations via migrate/migrate container" + set +e + out=$(docker run --rm \ + --network "$NETWORK_NAME" \ + -v "$CI_PROJECT_DIR/internal/database/migrations:/migrations:ro" \ + migrate/migrate:v4.15.2 \ + -path=/migrations -database "$DATABASE_URL" up 2>&1) + code=$? + set -e + + echo "$out" + + # ✅ Handle no change dengan benar (tidak false-success) + if echo "$out" | grep -qi "no change"; then + echo "✅ No change (already up to date)" + exit 0 + fi + + if [ $code -ne 0 ]; then + echo "❌ Migration failed with exit code $code" + exit $code + fi + + echo "✅ Migration applied successfully" + + +# ========================= +# DEPLOY (AUTO) +# ========================= +deploy_staging: stage: deploy - image: alpine:3.20 - variables: - DEPLOY_APP: "LTI-MBUGROUP" - # Opsional: kalau pakai submodule, ini bikin clone submodule pakai SSH juga - GIT_SUBMODULE_STRATEGY: recursive - GIT_DEPTH: "1" + rules: + - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "staging"' + needs: + - job: migrate_staging + artifacts: false + - job: build_staging + artifacts: false + script: | + set -e + docker info + echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY" - before_script: - - echo "🧰 Installing dependencies..." - - apk update && apk add --no-cache openssh git curl bash + cd "$DEPLOY_DIR" + test -f "$COMPOSE_FILE" || (echo "❌ $COMPOSE_FILE not found in $DEPLOY_DIR" && exit 1) + test -f .env || (echo "❌ .env not found in $DEPLOY_DIR" && exit 1) - # Setup SSH di runner - - mkdir -p ~/.ssh - - echo "$SSH_PRIVATE_KEY" | tr -d '\r' > ~/.ssh/id_rsa - - chmod 600 ~/.ssh/id_rsa - - eval "$(ssh-agent -s)" - - ssh-add ~/.ssh/id_rsa + docker compose -f "$COMPOSE_FILE" pull + docker compose -f "$COMPOSE_FILE" up -d --force-recreate + docker image prune -f - # Trust host keys (server + gitlab) biar SSH gak nanya interaktif - - ssh-keyscan -H "$SERVER_IP" >> ~/.ssh/known_hosts - - ssh-keyscan -H gitlab.com >> ~/.ssh/known_hosts - script: - - echo "🚀 Deploying latest code to $SERVER_USER@$SERVER_IP" +# ========================= +# SEED (MANUAL) +# ========================= +seed_staging: + stage: seed + rules: + - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "staging"' + needs: + - job: deploy_staging + artifacts: false + when: manual + allow_failure: false + script: | + set -e + cd "$DEPLOY_DIR" + test -f "$COMPOSE_FILE" || (echo "❌ $COMPOSE_FILE not found" && exit 1) + test -f .env || (echo "❌ .env not found" && exit 1) - - > - if ssh -o StrictHostKeyChecking=no "$SERVER_USER@$SERVER_IP" " - set -e - - cd /home/devops/docker/deployment/development/lti-api - - # Pastikan remote origin SSH (antisipasi kalau pernah ke-set HTTPS) - git remote set-url origin git@gitlab.com:mbugroup/lti-api.git - - # Pastikan server percaya gitlab.com juga (untuk git fetch via SSH) - mkdir -p ~/.ssh - ssh-keyscan -H gitlab.com >> ~/.ssh/known_hosts - - # Fetch/reset pakai SSH - GIT_SSH_COMMAND='ssh -o StrictHostKeyChecking=no' git fetch origin development - git reset --hard origin/development - - docker compose restart dev-api-lti || docker compose up -d dev-api-lti - "; then - STATUS='success'; - else - STATUS='failed'; - fi; - - RUN_URL="${CI_PROJECT_URL}/-/pipelines/${CI_PIPELINE_ID}"; - - if [ "$STATUS" = "success" ]; then - COLOR=3066993; - TITLE="✅ Deployment API Succeeded"; - DESC="Deployment job on branch \`${CI_COMMIT_REF_NAME}\` completed successfully."; - else - COLOR=15158332; - TITLE="❌ Deployment API Failed Gaes"; - DESC="Deployment job on branch \`${CI_COMMIT_REF_NAME}\` failed."; - fi; - - echo "{ - \"username\": \"CI Bot\", - \"embeds\": [{ - \"title\": \"$TITLE\", - \"description\": \"$DESC\", - \"color\": $COLOR, - \"fields\": [ - {\"name\": \"Repository\", \"value\": \"${CI_PROJECT_PATH}\", \"inline\": true}, - {\"name\": \"Actor\", \"value\": \"${GITLAB_USER_LOGIN}\", \"inline\": true}, - {\"name\": \"Commit\", \"value\": \"${CI_COMMIT_SHA}\", \"inline\": false}, - {\"name\": \"Pipeline\", \"value\": \"[Open run](${RUN_URL})\", \"inline\": false} - ] - }] - }" > payload.json; - - echo "📡 Sending notification to Discord..."; - curl -sS -H "Content-Type: application/json" \ - -d @payload.json "$DISCORD_WEBHOOK_URL"; - - only: - - development - - environment: - name: development \ No newline at end of file + docker compose -f "$COMPOSE_FILE" pull seed || true + docker compose -f "$COMPOSE_FILE" run --rm seed \ No newline at end of file From 7daa509cd053d334de49766713e5d7652802b35a Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Wed, 14 Jan 2026 14:06:34 +0700 Subject: [PATCH 35/76] feat[BE]: update customer payment report to support multiple customer IDs and nullable aging days --- .../controllers/repport.controller.go | 33 +++--- .../dto/repportCustomerPayment.dto.go | 2 +- .../customer_payment.repository.go | 1 + .../repports/services/repport.service.go | 101 ++++++++++++++---- .../validations/repport.validation.go | 10 +- 5 files changed, 109 insertions(+), 38 deletions(-) diff --git a/internal/modules/repports/controllers/repport.controller.go b/internal/modules/repports/controllers/repport.controller.go index 577c1b1b..f83f0902 100644 --- a/internal/modules/repports/controllers/repport.controller.go +++ b/internal/modules/repports/controllers/repport.controller.go @@ -243,25 +243,30 @@ func (c *RepportController) GetHppPerKandang(ctx *fiber.Ctx) error { } func (c *RepportController) GetCustomerPayment(ctx *fiber.Ctx) error { - var customerID *uint - if customerIDStr := ctx.Query("customer_id"); customerIDStr != "" { - if id, err := strconv.ParseUint(customerIDStr, 10, 32); err == nil { - cid := uint(id) - customerID = &cid + var customerIDs []uint + if customerIDsStr := ctx.Query("customer_ids"); customerIDsStr != "" { + ids := strings.Split(customerIDsStr, ",") + for _, idStr := range ids { + idStr = strings.TrimSpace(idStr) + if idStr != "" { + if id, err := strconv.ParseUint(idStr, 10, 32); err == nil { + customerIDs = append(customerIDs, uint(id)) + } + } } } query := &validation.CustomerPaymentQuery{ - Page: ctx.QueryInt("page", 1), - Limit: ctx.QueryInt("limit", 10), - CustomerID: customerID, - StartDate: ctx.Query("start_date", ""), - EndDate: ctx.Query("end_date", ""), + Page: ctx.QueryInt("page", 1), + Limit: ctx.QueryInt("limit", 10), + CustomerIDs: customerIDs, + StartDate: ctx.Query("start_date", ""), + EndDate: ctx.Query("end_date", ""), } // Validate pagination - if customerID == nil && (query.Page < 1 || query.Limit < 1) { - return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0") + if len(customerIDs) == 0 && (query.Page < 1 || query.Limit < 1) { + return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0 when customer_ids is not provided") } result, totalResults, err := c.RepportService.GetCustomerPayment(ctx, query) @@ -269,8 +274,8 @@ func (c *RepportController) GetCustomerPayment(ctx *fiber.Ctx) error { return err } - // If single customer mode, return without pagination - if customerID != nil { + // If single customer mode (only 1 customer ID), return without pagination + if len(customerIDs) == 1 { return ctx.Status(fiber.StatusOK). JSON(response.Success{ Code: fiber.StatusOK, diff --git a/internal/modules/repports/dto/repportCustomerPayment.dto.go b/internal/modules/repports/dto/repportCustomerPayment.dto.go index 2f200379..439eed42 100644 --- a/internal/modules/repports/dto/repportCustomerPayment.dto.go +++ b/internal/modules/repports/dto/repportCustomerPayment.dto.go @@ -23,7 +23,7 @@ type CustomerPaymentReportRow struct { TotalPrice float64 `json:"total_price"` PaymentAmount float64 `json:"payment_amount"` AccountsReceivable float64 `json:"accounts_receivable"` - AgingDay int `json:"aging_day"` + AgingDay *int `json:"aging_day"` Status string `json:"status"` PickupInfo string `json:"pickup_info"` SalesPerson string `json:"sales_person"` diff --git a/internal/modules/repports/repositories/customer_payment.repository.go b/internal/modules/repports/repositories/customer_payment.repository.go index 1d4ffd28..49e9424c 100644 --- a/internal/modules/repports/repositories/customer_payment.repository.go +++ b/internal/modules/repports/repositories/customer_payment.repository.go @@ -2,6 +2,7 @@ package repositories import ( "context" + "time" "gorm.io/gorm" diff --git a/internal/modules/repports/services/repport.service.go b/internal/modules/repports/services/repport.service.go index 89acca09..c6f18002 100644 --- a/internal/modules/repports/services/repport.service.go +++ b/internal/modules/repports/services/repport.service.go @@ -324,10 +324,14 @@ func (s *repportService) GetCustomerPayment(ctx *fiber.Ctx, params *validation.C var customerIDs []uint var totalCustomers int64 - if params.CustomerID != nil { - // Single customer mode - customerIDs = []uint{*params.CustomerID} - totalCustomers = 1 + if len(params.CustomerIDs) > 0 { + // Specific customer IDs mode (no pagination) + customerIDs = params.CustomerIDs + totalCustomers = int64(len(customerIDs)) + + if len(customerIDs) == 0 { + return []dto.CustomerPaymentReportItem{}, 0, nil + } } else { // Multiple customers mode with pagination page := params.Page @@ -366,7 +370,6 @@ func (s *repportService) GetCustomerPayment(ctx *fiber.Ctx, params *validation.C } func (s *repportService) processCustomerPayment(ctx context.Context, customerID uint) (dto.CustomerPaymentReportItem, error) { - // Get customer info customer := entity.Customer{} if err := s.DB.WithContext(ctx). Where("id = ?", customerID). @@ -374,24 +377,21 @@ func (s *repportService) processCustomerPayment(ctx context.Context, customerID return dto.CustomerPaymentReportItem{}, err } - // Get initial balance initialBalance, err := s.CustomerPaymentRepo.GetInitialBalanceByCustomer(ctx, customerID) if err != nil { return dto.CustomerPaymentReportItem{}, err } - // Get transactions for this customer cid := customerID transactions, err := s.CustomerPaymentRepo.GetCustomerPaymentTransactions(ctx, &cid) if err != nil { return dto.CustomerPaymentReportItem{}, err } - // Process transactions and calculate running balance rows := make([]dto.CustomerPaymentReportRow, 0, len(transactions)) runningBalance := initialBalance - for _, tx := range transactions { + for i, tx := range transactions { row := dto.CustomerPaymentReportRow{ TransactionType: tx.TransactionType, TransactionID: tx.TransactionID, @@ -412,26 +412,48 @@ func (s *repportService) processCustomerPayment(ctx context.Context, customerID SalesPerson: tx.SalesPerson, } - // Calculate running balance + previousBalance := runningBalance + if tx.TransactionType == "SALES" { runningBalance -= tx.TotalPrice - // Status will be calculated later (requires looking ahead) - row.Status = "" - row.AgingDay = 0 // Will be calculated later + status, paymentDate := s.determineSalesStatusAndPaymentDate(transactions, i, previousBalance, runningBalance) + row.Status = status + + if status == "LUNAS" { + if previousBalance >= tx.TotalPrice { + days := 0 + row.AgingDay = &days + } else if paymentDate != nil { + // Aging = payment_date - trans_date (SO date) + days := int(paymentDate.Sub(tx.TransDate).Hours() / 24) + if days < 0 { + days = 0 + } + row.AgingDay = &days + } else { + days := 0 + row.AgingDay = &days + } + } else { + // Aging = current_date - trans_date (SO date) + days := int(time.Since(tx.TransDate).Hours() / 24) + if days < 0 { + days = 0 + } + row.AgingDay = &days + } } else if tx.TransactionType == "PAYMENT" { runningBalance += tx.PaymentAmount row.Status = "" - row.AgingDay = 0 + row.AgingDay = nil } row.AccountsReceivable = runningBalance rows = append(rows, row) } - // Calculate summary summary := s.calculateSummary(rows, initialBalance) - // Build customer DTO customerDTO := customerDTO.CustomerRelationDTO{ Id: customer.Id, Name: customer.Name, @@ -466,12 +488,55 @@ func (s *repportService) calculateSummary(rows []dto.CustomerPaymentReportRow, i } } - // Final AR = initial balance + total sales - total payment - summary.TotalAccountsReceivable = initialBalance + summary.TotalGrandAmount - summary.TotalPayment + // Formula: Total AR = Initial Balance - Total Sales + Total Payment + // - Initial balance: positive (customer deposit) + // - Sales: reduces balance (customer debt) + // - Payment: increases balance (customer pays) + summary.TotalAccountsReceivable = initialBalance - summary.TotalGrandAmount + summary.TotalPayment return summary } +func (s *repportService) determineSalesStatusAndPaymentDate(transactions []repportRepo.CustomerPaymentTransaction, currentIndex int, previousBalance, currentBalance float64) (string, *time.Time) { + currentSales := transactions[currentIndex] + + // Status Logic: + // 1. LUNAS: previousBalance >= salesAmount (paid from deposit) + // 2. LUNAS: future payments make AR >= 0 (eventually paid) + // 3. DIBAYAR SEBAGIAN: has payment but not enough + // 4. BELUM LUNAS: no payment at all + + if previousBalance >= currentSales.TotalPrice { + return "LUNAS", nil + } + + hasPartialPaymentFromBalance := previousBalance > 0 && previousBalance < currentSales.TotalPrice + + futureBalance := currentBalance + hasPayment := false + var paymentDateThatMadeItLunas *time.Time + + for i := currentIndex + 1; i < len(transactions); i++ { + if transactions[i].TransactionType == "PAYMENT" { + futureBalance += transactions[i].PaymentAmount + hasPayment = true + + if futureBalance >= 0 { + paymentDateThatMadeItLunas = &transactions[i].TransDate + return "LUNAS", paymentDateThatMadeItLunas + } + } else if transactions[i].TransactionType == "SALES" { + futureBalance -= transactions[i].TotalPrice + } + } + + if hasPayment || hasPartialPaymentFromBalance { + return "DIBAYAR SEBAGIAN", nil + } + + return "BELUM LUNAS", nil +} + func mapRecordingToProductionResultDTO(record entity.Recording) dto.ProductionResultDTO { result := dto.ProductionResultDTO{ CreatedAt: record.CreatedAt, diff --git a/internal/modules/repports/validations/repport.validation.go b/internal/modules/repports/validations/repport.validation.go index c79dd90d..68bfee90 100644 --- a/internal/modules/repports/validations/repport.validation.go +++ b/internal/modules/repports/validations/repport.validation.go @@ -73,9 +73,9 @@ type ProductionResultQuery struct { } type CustomerPaymentQuery struct { - Page int `query:"page" validate:"omitempty,min=1,gt=0"` - Limit int `query:"limit" validate:"omitempty,min=1,max=100,gt=0"` - CustomerID *uint `query:"customer_id" validate:"omitempty,gt=0"` - StartDate string `query:"start_date" validate:"omitempty,datetime=2006-01-02"` - EndDate string `query:"end_date" validate:"omitempty,datetime=2006-01-02"` + Page int `query:"page" validate:"omitempty,min=1,gt=0"` + Limit int `query:"limit" validate:"omitempty,min=1,max=100,gt=0"` + CustomerIDs []uint `query:"customer_ids" validate:"omitempty,dive,gt=0"` + StartDate string `query:"start_date" validate:"omitempty,datetime=2006-01-02"` + EndDate string `query:"end_date" validate:"omitempty,datetime=2006-01-02"` } From dbf72c72484fff68acca67d0c476641182ed7534 Mon Sep 17 00:00:00 2001 From: kris Date: Wed, 14 Jan 2026 07:06:37 +0000 Subject: [PATCH 36/76] Delete .air.toml --- .air.toml | 13 ------------- 1 file changed, 13 deletions(-) delete mode 100644 .air.toml diff --git a/.air.toml b/.air.toml deleted file mode 100644 index c463b5b2..00000000 --- a/.air.toml +++ /dev/null @@ -1,13 +0,0 @@ -# .air.toml -root = "." -tmp_dir = "tmp" - -[build] -cmd = "go build -buildvcs=false -o ./tmp/main ./cmd/api" -bin = "tmp/main" -full_bin = "APP_ENV=dev ./tmp/main" -include_ext = ["go", "tpl", "tmpl", "html"] -exclude_dir = ["vendor", "tmp"] - -[log] -time = true From 2a884a8d09a069f1841096fea86fe87dd957003a Mon Sep 17 00:00:00 2001 From: kris Date: Wed, 14 Jan 2026 07:35:57 +0000 Subject: [PATCH 37/76] Edit .gitignore (Tom haye) --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 24887418..3522e1f6 100644 --- a/.gitignore +++ b/.gitignore @@ -9,7 +9,7 @@ main bin/ *.exe *.out - +.air.toml Makefile docker-compose.local.yml docker-compose.yaml From 804ff45dbd1bc6e2ccf4c324853834efc702c31c Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Wed, 14 Jan 2026 15:15:29 +0700 Subject: [PATCH 38/76] feat[BE]: enhance customer payment report with vehicle numbers and pickup info, add date filtering --- .../master/customers/dto/customer.dto.go | 1 + .../dto/repportCustomerPayment.dto.go | 110 ++++++++++++++---- .../customer_payment.repository.go | 6 +- .../repports/services/repport.service.go | 102 ++++++++-------- 4 files changed, 141 insertions(+), 78 deletions(-) diff --git a/internal/modules/master/customers/dto/customer.dto.go b/internal/modules/master/customers/dto/customer.dto.go index 444c6768..592f14cd 100644 --- a/internal/modules/master/customers/dto/customer.dto.go +++ b/internal/modules/master/customers/dto/customer.dto.go @@ -52,6 +52,7 @@ func ToCustomerRelationDTO(e entity.Customer) CustomerRelationDTO { Name: e.Name, Type: e.Type, AccountNumber: e.AccountNumber, + Balance: e.Balance, Pic: pic, } } diff --git a/internal/modules/repports/dto/repportCustomerPayment.dto.go b/internal/modules/repports/dto/repportCustomerPayment.dto.go index 439eed42..3f6b7a2d 100644 --- a/internal/modules/repports/dto/repportCustomerPayment.dto.go +++ b/internal/modules/repports/dto/repportCustomerPayment.dto.go @@ -1,32 +1,35 @@ package dto import ( + "strings" "time" customerDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/customers/dto" + repportRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/repports/repositories" ) type CustomerPaymentReportRow struct { - TransactionType string `json:"transaction_type"` - TransactionID int64 `json:"transaction_id"` - TransDate time.Time `json:"trans_date"` - DeliveryDate *time.Time `json:"delivery_date"` - Reference string `json:"reference"` - VehicleNumbers string `json:"vehicle_numbers"` - Qty float64 `json:"qty"` - Weight float64 `json:"weight"` - AverageWeight float64 `json:"average_weight"` - Price float64 `json:"price"` - CreditNote float64 `json:"credit_note"` - FinalPrice float64 `json:"final_price"` - PPN float64 `json:"ppn"` - TotalPrice float64 `json:"total_price"` - PaymentAmount float64 `json:"payment_amount"` - AccountsReceivable float64 `json:"accounts_receivable"` - AgingDay *int `json:"aging_day"` - Status string `json:"status"` - PickupInfo string `json:"pickup_info"` - SalesPerson string `json:"sales_person"` + TransactionType string `json:"transaction_type"` + TransactionID int64 `json:"transaction_id"` + TransDate time.Time `json:"trans_date"` + DeliveryDate *time.Time `json:"delivery_date"` + Reference string `json:"reference"` + + Qty float64 `json:"qty"` + Weight float64 `json:"weight"` + AverageWeight float64 `json:"average_weight"` + Price float64 `json:"price"` + CreditNote float64 `json:"credit_note"` + FinalPrice float64 `json:"final_price"` + PPN float64 `json:"ppn"` + TotalPrice float64 `json:"total_price"` + PaymentAmount float64 `json:"payment_amount"` + AccountsReceivable float64 `json:"accounts_receivable"` + AgingDay *int `json:"aging_day"` + Status string `json:"status"` + VehicleNumbers []string `json:"vehicle_numbers"` + PickupInfo []string `json:"pickup_info"` + SalesPerson string `json:"sales_person"` } type CustomerPaymentReportSummary struct { @@ -51,3 +54,70 @@ type CustomerPaymentReportItem struct { type CustomerPaymentReportResponse struct { Data []CustomerPaymentReportItem `json:"data"` } + +func ToCustomerPaymentReportRow(tx repportRepo.CustomerPaymentTransaction) CustomerPaymentReportRow { + return CustomerPaymentReportRow{ + TransactionType: tx.TransactionType, + TransactionID: tx.TransactionID, + TransDate: tx.TransDate, + DeliveryDate: tx.DeliveryDate, + Reference: tx.Reference, + Qty: tx.Qty, + Weight: tx.Weight, + AverageWeight: tx.AverageWeight, + Price: tx.Price, + CreditNote: tx.CreditNote, + FinalPrice: tx.FinalPrice, + PPN: tx.PPN, + TotalPrice: tx.TotalPrice, + PaymentAmount: tx.PaymentAmount, + VehicleNumbers: parseStringSlice(tx.VehicleNumbers), + PickupInfo: parseStringSlice(tx.PickupInfo), + SalesPerson: tx.SalesPerson, + } +} + +func parseStringSlice(str string) []string { + str = strings.TrimSpace(str) + if str == "" || str == "-" { + return []string{} + } + + parts := strings.Split(str, ",") + result := make([]string, 0, len(parts)) + for _, part := range parts { + part = strings.TrimSpace(part) + if part != "" { + result = append(result, part) + } + } + + return result +} + +func CalculateCustomerPaymentSummary(rows []CustomerPaymentReportRow, initialBalance float64) CustomerPaymentReportSummary { + summary := CustomerPaymentReportSummary{} + + for _, row := range rows { + summary.TotalQty += row.Qty + summary.TotalWeight += row.Weight + summary.TotalCreditNote += row.CreditNote + summary.TotalPPN += row.PPN + + if row.TransactionType == "SALES" { + summary.TotalInitialAmount += row.TotalPrice + summary.TotalFinalAmount += row.FinalPrice + summary.TotalGrandAmount += row.TotalPrice + } else if row.TransactionType == "PAYMENT" { + summary.TotalPayment += row.PaymentAmount + } + } + + // Formula: Total AR = Initial Balance - Total Sales + Total Payment + // - Initial balance: positive (customer deposit) + // - Sales: reduces balance (customer debt) + // - Payment: increases balance (customer pays) + summary.TotalAccountsReceivable = initialBalance - summary.TotalGrandAmount + summary.TotalPayment + + return summary +} diff --git a/internal/modules/repports/repositories/customer_payment.repository.go b/internal/modules/repports/repositories/customer_payment.repository.go index 49e9424c..5a39b127 100644 --- a/internal/modules/repports/repositories/customer_payment.repository.go +++ b/internal/modules/repports/repositories/customer_payment.repository.go @@ -54,7 +54,8 @@ func (r *customerPaymentRepositoryImpl) GetCustomerPaymentTransactions(ctx conte m.so_date::DATE AS trans_date, MAX(mdp.delivery_date)::DATE AS delivery_date, m.so_number AS reference, - COALESCE(STRING_AGG(DISTINCT mdp.vehicle_number, ', ') FILTER (WHERE mdp.vehicle_number IS NOT NULL AND mdp.vehicle_number != ''), '') AS vehicle_numbers, + COALESCE(STRING_AGG(DISTINCT mdp.vehicle_number, ', ') FILTER (WHERE mdp.vehicle_number IS NOT NULL), '') AS vehicle_numbers, + COALESCE(SUM(COALESCE(mp.qty, 0)), 0)::NUMERIC(15,3) AS qty, COALESCE(SUM(COALESCE(mdp.total_weight, mp.total_weight, 0)), 0)::NUMERIC(15,3) AS weight, CASE WHEN COALESCE(SUM(COALESCE(mp.qty, 0)), 0) > 0 @@ -77,7 +78,8 @@ func (r *customerPaymentRepositoryImpl) GetCustomerPaymentTransactions(ctx conte Joins("LEFT JOIN warehouses w ON w.id = pw.warehouse_id"). Joins("LEFT JOIN users u ON u.id = m.sales_person_id"). Where("m.deleted_at IS NULL"). - Where("c.deleted_at IS NULL") + Where("c.deleted_at IS NULL"). + Where("mdp.delivery_date IS NOT NULL") if customerID != nil { salesQuery = salesQuery.Where("c.id = ?", *customerID) diff --git a/internal/modules/repports/services/repport.service.go b/internal/modules/repports/services/repport.service.go index c6f18002..1dba2114 100644 --- a/internal/modules/repports/services/repport.service.go +++ b/internal/modules/repports/services/repport.service.go @@ -359,7 +359,7 @@ func (s *repportService) GetCustomerPayment(ctx *fiber.Ctx, params *validation.C // Process each customer var result []dto.CustomerPaymentReportItem for _, customerID := range customerIDs { - item, err := s.processCustomerPayment(ctx.Context(), customerID) + item, err := s.processCustomerPayment(ctx.Context(), customerID, params) if err != nil { return nil, 0, err } @@ -369,7 +369,7 @@ func (s *repportService) GetCustomerPayment(ctx *fiber.Ctx, params *validation.C return result, totalCustomers, nil } -func (s *repportService) processCustomerPayment(ctx context.Context, customerID uint) (dto.CustomerPaymentReportItem, error) { +func (s *repportService) processCustomerPayment(ctx context.Context, customerID uint, params *validation.CustomerPaymentQuery) (dto.CustomerPaymentReportItem, error) { customer := entity.Customer{} if err := s.DB.WithContext(ctx). Where("id = ?", customerID). @@ -392,28 +392,11 @@ func (s *repportService) processCustomerPayment(ctx context.Context, customerID runningBalance := initialBalance for i, tx := range transactions { - row := dto.CustomerPaymentReportRow{ - TransactionType: tx.TransactionType, - TransactionID: tx.TransactionID, - TransDate: tx.TransDate, - DeliveryDate: tx.DeliveryDate, - Reference: tx.Reference, - VehicleNumbers: tx.VehicleNumbers, - Qty: tx.Qty, - Weight: tx.Weight, - AverageWeight: tx.AverageWeight, - Price: tx.Price, - CreditNote: tx.CreditNote, - FinalPrice: tx.FinalPrice, - PPN: tx.PPN, - TotalPrice: tx.TotalPrice, - PaymentAmount: tx.PaymentAmount, - PickupInfo: tx.PickupInfo, - SalesPerson: tx.SalesPerson, - } previousBalance := runningBalance + row := dto.ToCustomerPaymentReportRow(tx) + if tx.TransactionType == "SALES" { runningBalance -= tx.TotalPrice status, paymentDate := s.determineSalesStatusAndPaymentDate(transactions, i, previousBalance, runningBalance) @@ -452,51 +435,58 @@ func (s *repportService) processCustomerPayment(ctx context.Context, customerID rows = append(rows, row) } - summary := s.calculateSummary(rows, initialBalance) + if params.StartDate != "" || params.EndDate != "" { + filteredRows := make([]dto.CustomerPaymentReportRow, 0, len(rows)) + location, err := time.LoadLocation("Asia/Jakarta") + if err != nil { + return dto.CustomerPaymentReportItem{}, err + } - customerDTO := customerDTO.CustomerRelationDTO{ - Id: customer.Id, - Name: customer.Name, - Type: customer.Type, - AccountNumber: customer.AccountNumber, - Balance: customer.Balance, + var startDate, endDate *time.Time + if params.StartDate != "" { + parsed, err := time.ParseInLocation("2006-01-02", params.StartDate, location) + if err != nil { + return dto.CustomerPaymentReportItem{}, err + } + startDate = &parsed + } + if params.EndDate != "" { + parsed, err := time.ParseInLocation("2006-01-02", params.EndDate, location) + if err != nil { + return dto.CustomerPaymentReportItem{}, err + } + // End date should be inclusive, so set to end of day + endOfDay := time.Date(parsed.Year(), parsed.Month(), parsed.Day(), 23, 59, 59, 999999999, location) + endDate = &endOfDay + } + + for _, row := range rows { + transDate := row.TransDate.In(location) + + // Check if transaction date is within range + if startDate != nil && transDate.Before(*startDate) { + continue + } + if endDate != nil && transDate.After(*endDate) { + continue + } + + filteredRows = append(filteredRows, row) + } + + rows = filteredRows } + summary := dto.CalculateCustomerPaymentSummary(rows, initialBalance) + return dto.CustomerPaymentReportItem{ - Customer: customerDTO, + Customer: customerDTO.ToCustomerRelationDTO(customer), InitialBalance: initialBalance, Rows: rows, Summary: summary, }, nil } -func (s *repportService) calculateSummary(rows []dto.CustomerPaymentReportRow, initialBalance float64) dto.CustomerPaymentReportSummary { - summary := dto.CustomerPaymentReportSummary{} - - for _, row := range rows { - summary.TotalQty += row.Qty - summary.TotalWeight += row.Weight - summary.TotalCreditNote += row.CreditNote - summary.TotalPPN += row.PPN - - if row.TransactionType == "SALES" { - summary.TotalInitialAmount += row.TotalPrice - summary.TotalFinalAmount += row.FinalPrice - summary.TotalGrandAmount += row.TotalPrice - } else if row.TransactionType == "PAYMENT" { - summary.TotalPayment += row.PaymentAmount - } - } - - // Formula: Total AR = Initial Balance - Total Sales + Total Payment - // - Initial balance: positive (customer deposit) - // - Sales: reduces balance (customer debt) - // - Payment: increases balance (customer pays) - summary.TotalAccountsReceivable = initialBalance - summary.TotalGrandAmount + summary.TotalPayment - - return summary -} - func (s *repportService) determineSalesStatusAndPaymentDate(transactions []repportRepo.CustomerPaymentTransaction, currentIndex int, previousBalance, currentBalance float64) (string, *time.Time) { currentSales := transactions[currentIndex] From e0ff6e6d7902b21990d04ef501c337eedd0402a7 Mon Sep 17 00:00:00 2001 From: kris Date: Wed, 14 Jan 2026 08:26:07 +0000 Subject: [PATCH 39/76] Update .gitlab-ci.yml file --- .gitlab-ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index a46bb3aa..80e4f608 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -49,7 +49,7 @@ build_staging: # ========================= -# MIGRATE (AUTO) +# MIGRATE (AUTO) # ========================= migrate_staging: stage: migrate From e0043544208300b401b5044916cc4fb6cd6e6f1a Mon Sep 17 00:00:00 2001 From: giovanni Date: Wed, 14 Jan 2026 16:20:59 +0700 Subject: [PATCH 40/76] adjust api production-result --- internal/modules/repports/module.go | 19 ++- .../production_result.repository.go | 23 ++++ .../repports/services/repport.service.go | 115 +++++++++++++++--- 3 files changed, 141 insertions(+), 16 deletions(-) diff --git a/internal/modules/repports/module.go b/internal/modules/repports/module.go index 61f37d4d..c6495a14 100644 --- a/internal/modules/repports/module.go +++ b/internal/modules/repports/module.go @@ -12,6 +12,7 @@ import ( expenseRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/repositories" marketingRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/repositories" + productionStandardRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards/repositories" chickinRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/production/chickins/repositories" recordingRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/repositories" purchaseRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/repositories" @@ -34,10 +35,26 @@ func (RepportModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate * debtSupplierRepository := repportRepo.NewDebtSupplierRepository(db) hppPerKandangRepository := repportRepo.NewHppPerKandangRepository(db) productionResultRepository := repportRepo.NewProductionResultRepository(db) + standardGrowthDetailRepository := productionStandardRepo.NewStandardGrowthDetailRepository(db) + productionStandardDetailRepository := productionStandardRepo.NewProductionStandardDetailRepository(db) userRepository := rUser.NewUserRepository(db) approvalSvc := approvalService.NewApprovalService(approvalRepository) - repportService := sRepport.NewRepportService(validate, expenseRealizationRepository, marketingDeliveryProductRepository, purchaseRepository, chickinRepository, recordingRepository, approvalSvc, purchaseSupplierRepository, debtSupplierRepository, hppPerKandangRepository, productionResultRepository) + repportService := sRepport.NewRepportService( + validate, + expenseRealizationRepository, + marketingDeliveryProductRepository, + purchaseRepository, + chickinRepository, + recordingRepository, + approvalSvc, + purchaseSupplierRepository, + debtSupplierRepository, + hppPerKandangRepository, + productionResultRepository, + standardGrowthDetailRepository, + productionStandardDetailRepository, + ) userService := sUser.NewUserService(userRepository, validate) RepportRoutes(router, userService, repportService) diff --git a/internal/modules/repports/repositories/production_result.repository.go b/internal/modules/repports/repositories/production_result.repository.go index 19007d0f..a8eccb91 100644 --- a/internal/modules/repports/repositories/production_result.repository.go +++ b/internal/modules/repports/repositories/production_result.repository.go @@ -11,6 +11,7 @@ import ( type ProductionResultRepository interface { GetRecordingsByProjectFlockKandang(ctx context.Context, projectFlockKandangID uint, offset, limit int) ([]entity.Recording, int64, error) + GetProductionStandardIDByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint) (uint, error) } type productionResultRepositoryImpl struct { @@ -76,3 +77,25 @@ func (r *productionResultRepositoryImpl) GetRecordingsByProjectFlockKandang( return recordings, total, nil } + +func (r *productionResultRepositoryImpl) GetProductionStandardIDByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint) (uint, error) { + if projectFlockKandangID == 0 { + return 0, nil + } + + var row struct { + ProductionStandardID uint `gorm:"column:production_standard_id"` + } + + err := r.db.WithContext(ctx). + Table("project_flock_kandangs pfk"). + Select("pf.production_standard_id"). + Joins("JOIN project_flocks pf ON pf.id = pfk.project_flock_id"). + Where("pfk.id = ?", projectFlockKandangID). + Take(&row).Error + if err != nil { + return 0, err + } + + return row.ProductionStandardID, nil +} diff --git a/internal/modules/repports/services/repport.service.go b/internal/modules/repports/services/repport.service.go index c4883b72..2577787a 100644 --- a/internal/modules/repports/services/repport.service.go +++ b/internal/modules/repports/services/repport.service.go @@ -2,6 +2,7 @@ package service import ( "context" + "errors" "fmt" "math" "sort" @@ -21,6 +22,7 @@ import ( areaDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/areas/dto" supplierDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/suppliers/dto" warehouseDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/dto" + productionStandardRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards/repositories" chickinRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/production/chickins/repositories" recordingRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/repositories" purchaseRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/repositories" @@ -55,6 +57,8 @@ type repportService struct { DebtSupplierRepo repportRepo.DebtSupplierRepository HppPerKandangRepo repportRepo.HppPerKandangRepository ProductionResultRepo repportRepo.ProductionResultRepository + StandardGrowthDetailRepo productionStandardRepository.StandardGrowthDetailRepository + ProductionStandardDetailRepo productionStandardRepository.ProductionStandardDetailRepository } type HppCostAggregate struct { @@ -78,6 +82,8 @@ func NewRepportService( debtSupplierRepo repportRepo.DebtSupplierRepository, hppPerKandangRepo repportRepo.HppPerKandangRepository, productionResultRepo repportRepo.ProductionResultRepository, + standardGrowthDetailRepo productionStandardRepository.StandardGrowthDetailRepository, + productionStandardDetailRepo productionStandardRepository.ProductionStandardDetailRepository, ) RepportService { return &repportService{ Log: utils.Log, @@ -92,6 +98,8 @@ func NewRepportService( DebtSupplierRepo: debtSupplierRepo, HppPerKandangRepo: hppPerKandangRepo, ProductionResultRepo: productionResultRepo, + StandardGrowthDetailRepo: standardGrowthDetailRepo, + ProductionStandardDetailRepo: productionStandardDetailRepo, } } @@ -285,6 +293,21 @@ func (s *repportService) GetProductionResult(ctx *fiber.Ctx, params *validation. weeklyResults := summarizeProductionResults(dailyResults, recordsPerWeek) + var productionStandardID uint + if s.ProductionResultRepo != nil { + standardID, err := s.ProductionResultRepo.GetProductionStandardIDByProjectFlockKandangID(ctx.Context(), params.ProjectFlockKandangID) + if err != nil { + if !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, 0, err + } + } else { + productionStandardID = standardID + } + } + + standardDetailCache := make(map[int]*entity.ProductionStandardDetail) + growthDetailCache := make(map[int]*entity.StandardGrowthDetail) + var cumulativeButir int64 var cumulativeKg float64 for i := range weeklyResults { @@ -300,6 +323,66 @@ func (s *repportService) GetProductionResult(ctx *fiber.Ctx, params *validation. cumulativeKg += weeklyResults[i].KgJumlah weeklyResults[i].TotalKg = cumulativeKg + + if productionStandardID == 0 { + continue + } + + week := int(weeklyResults[i].Woa) + if s.ProductionStandardDetailRepo != nil { + detail, ok := standardDetailCache[week] + if !ok { + fetched, fetchErr := s.ProductionStandardDetailRepo.GetByStandardIDAndWeek(ctx.Context(), productionStandardID, week) + if fetchErr != nil { + if !errors.Is(fetchErr, gorm.ErrRecordNotFound) { + return nil, 0, fetchErr + } + } else { + detail = fetched + } + standardDetailCache[week] = detail + } + + if detail != nil { + if detail.TargetHenDayProduction != nil { + weeklyResults[i].HdStd = *detail.TargetHenDayProduction + } + if detail.TargetHenHouseProduction != nil { + weeklyResults[i].HhStd = *detail.TargetHenHouseProduction + } + if detail.TargetEggWeight != nil { + weeklyResults[i].EwStd = *detail.TargetEggWeight + } + if detail.TargetEggMass != nil { + weeklyResults[i].EmStd = *detail.TargetEggMass + } + if detail.StandardFCR != nil { + weeklyResults[i].FcrStd = *detail.StandardFCR + } + } + } + + if s.StandardGrowthDetailRepo != nil { + detail, ok := growthDetailCache[week] + if !ok { + fetched, fetchErr := s.StandardGrowthDetailRepo.GetByStandardIDAndWeek(ctx.Context(), productionStandardID, week) + if fetchErr != nil { + if !errors.Is(fetchErr, gorm.ErrRecordNotFound) { + return nil, 0, fetchErr + } + } else { + detail = fetched + } + growthDetailCache[week] = detail + } + + if detail != nil && detail.FeedIntake != nil { + weeklyResults[i].FiStd = *detail.FeedIntake + } + if detail != nil && detail.TargetMeanBw != nil { + weeklyResults[i].StdBw = *detail.TargetMeanBw + } + } } totalWeeks := int64(math.Ceil(float64(totalRecordings) / float64(recordsPerWeek))) @@ -314,17 +397,17 @@ func mapRecordingToProductionResultDTO(record entity.Recording) dto.ProductionRe StdUniformity: "90% up", DepKum: valueOrZero(record.CumDepletionRate), DepStd: valueOrZero(record.TotalDepletionQty), + Hd: valueOrZero(record.HenDay), + Fi: valueOrZero(record.FeedIntake), Fcr: valueOrZero(record.FcrValue), - Hh: valueOrZero(record.TotalChickQty), + Hh: valueOrZero(record.HenHouse), + Em: valueOrZero(record.EggMass), + Ew: valueOrZero(record.EggWeight), } if record.Day != nil { result.Woa = float64(*record.Day) } - if record.CumIntake != nil { - result.Fi = float64(*record.CumIntake) - } - // avgWeight := calculateAverageBodyWeight(record.BodyWeights) avgWeight := 1.0 if avgWeight > 0 { @@ -351,8 +434,6 @@ func mapRecordingToProductionResultDTO(record entity.Recording) dto.ProductionRe result.PersenPutih = roundFloat((float64(result.ButiranPutih)/total)*100, 2) result.PersenRetak = roundFloat((float64(result.ButiranRetak)/total)*100, 2) result.PersenPecah = roundFloat((float64(result.ButiranPecah)/total)*100, 2) - result.Ew = (eggSummary.TotalKg * 1000) / total - result.Em = eggSummary.TotalKg } return result @@ -464,13 +545,13 @@ func summarizeProductionResults(daily []dto.ProductionResultDTO, groupSize int) if end > len(daily) { end = len(daily) } - result = append(result, aggregateProductionResultGroup(daily[i:end])) + result = append(result, aggregateProductionResultGroup(daily[i:end], groupSize)) } return result } -func aggregateProductionResultGroup(group []dto.ProductionResultDTO) dto.ProductionResultDTO { +func aggregateProductionResultGroup(group []dto.ProductionResultDTO, groupSize int) dto.ProductionResultDTO { count := len(group) if count == 0 { return dto.ProductionResultDTO{} @@ -542,6 +623,10 @@ func aggregateProductionResultGroup(group []dto.ProductionResultDTO) dto.Product if divider == 0 { divider = 1 } + weeklyDivider := float64(groupSize) + if weeklyDivider == 0 { + weeklyDivider = divider + } agg.Bw = sumBw / divider agg.StdBw = sumStdBw / divider @@ -570,17 +655,17 @@ func aggregateProductionResultGroup(group []dto.ProductionResultDTO) dto.Product agg.PersenPecah = roundFloat(sumPersenPecah/percentDivider, 2) } - agg.Hd = sumHd / divider + agg.Hd = roundFloat(sumHd/weeklyDivider, 2) agg.HdStd = sumHdStd / divider - agg.Fi = sumFi / divider + agg.Fi = roundFloat(sumFi/weeklyDivider, 2) agg.FiStd = sumFiStd / divider - agg.Em = sumEm / divider + agg.Em = group[count-1].Em agg.EmStd = sumEmStd / divider - agg.Ew = sumEw / divider + agg.Ew = group[count-1].Ew agg.EwStd = sumEwStd / divider - agg.Fcr = sumFcr / divider + agg.Fcr = roundFloat(sumFcr/weeklyDivider, 2) agg.FcrStd = sumFcrStd / divider - agg.Hh = sumHh / divider + agg.Hh = roundFloat(sumHh/weeklyDivider, 2) agg.HhStd = sumHhStd / divider return agg From cad15bcd786d90d0ef637b18c33ca6738ac75f17 Mon Sep 17 00:00:00 2001 From: kris Date: Wed, 14 Jan 2026 09:46:39 +0000 Subject: [PATCH 41/76] Update .gitlab-ci.yml file --- .gitlab-ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 80e4f608..a46bb3aa 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -49,7 +49,7 @@ build_staging: # ========================= -# MIGRATE (AUTO) +# MIGRATE (AUTO) # ========================= migrate_staging: stage: migrate From aeb5433346edd647972287c528e89c015340a3ec Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Wed, 14 Jan 2026 20:00:44 +0700 Subject: [PATCH 42/76] feat[BE]: refine customer payment report structure by removing unused fields and enhancing query logic for better performance --- .../dto/repportCustomerPayment.dto.go | 15 +--- internal/modules/repports/module.go | 4 +- .../customer_payment.repository.go | 69 ++++++++----------- .../repports/services/repport.service.go | 20 +++--- 4 files changed, 40 insertions(+), 68 deletions(-) diff --git a/internal/modules/repports/dto/repportCustomerPayment.dto.go b/internal/modules/repports/dto/repportCustomerPayment.dto.go index 3f6b7a2d..cdac5029 100644 --- a/internal/modules/repports/dto/repportCustomerPayment.dto.go +++ b/internal/modules/repports/dto/repportCustomerPayment.dto.go @@ -19,9 +19,7 @@ type CustomerPaymentReportRow struct { Weight float64 `json:"weight"` AverageWeight float64 `json:"average_weight"` Price float64 `json:"price"` - CreditNote float64 `json:"credit_note"` FinalPrice float64 `json:"final_price"` - PPN float64 `json:"ppn"` TotalPrice float64 `json:"total_price"` PaymentAmount float64 `json:"payment_amount"` AccountsReceivable float64 `json:"accounts_receivable"` @@ -35,10 +33,7 @@ type CustomerPaymentReportRow struct { type CustomerPaymentReportSummary struct { TotalQty float64 `json:"total_qty"` TotalWeight float64 `json:"total_weight"` - TotalInitialAmount float64 `json:"total_initial_amount"` - TotalCreditNote float64 `json:"total_credit_note"` TotalFinalAmount float64 `json:"total_final_amount"` - TotalPPN float64 `json:"total_ppn"` TotalGrandAmount float64 `json:"total_grand_amount"` TotalPayment float64 `json:"total_payment"` TotalAccountsReceivable float64 `json:"total_accounts_receivable"` @@ -66,9 +61,7 @@ func ToCustomerPaymentReportRow(tx repportRepo.CustomerPaymentTransaction) Custo Weight: tx.Weight, AverageWeight: tx.AverageWeight, Price: tx.Price, - CreditNote: tx.CreditNote, FinalPrice: tx.FinalPrice, - PPN: tx.PPN, TotalPrice: tx.TotalPrice, PaymentAmount: tx.PaymentAmount, VehicleNumbers: parseStringSlice(tx.VehicleNumbers), @@ -101,11 +94,8 @@ func CalculateCustomerPaymentSummary(rows []CustomerPaymentReportRow, initialBal for _, row := range rows { summary.TotalQty += row.Qty summary.TotalWeight += row.Weight - summary.TotalCreditNote += row.CreditNote - summary.TotalPPN += row.PPN if row.TransactionType == "SALES" { - summary.TotalInitialAmount += row.TotalPrice summary.TotalFinalAmount += row.FinalPrice summary.TotalGrandAmount += row.TotalPrice } else if row.TransactionType == "PAYMENT" { @@ -113,10 +103,7 @@ func CalculateCustomerPaymentSummary(rows []CustomerPaymentReportRow, initialBal } } - // Formula: Total AR = Initial Balance - Total Sales + Total Payment - // - Initial balance: positive (customer deposit) - // - Sales: reduces balance (customer debt) - // - Payment: increases balance (customer pays) + // Total AR = Initial Balance - Total Sales + Total Payment summary.TotalAccountsReceivable = initialBalance - summary.TotalGrandAmount + summary.TotalPayment return summary diff --git a/internal/modules/repports/module.go b/internal/modules/repports/module.go index b0432316..d081306c 100644 --- a/internal/modules/repports/module.go +++ b/internal/modules/repports/module.go @@ -12,6 +12,7 @@ import ( expenseRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/repositories" marketingRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/repositories" + customerRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/master/customers/repositories" chickinRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/production/chickins/repositories" recordingRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/repositories" purchaseRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/repositories" @@ -35,10 +36,11 @@ func (RepportModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate * hppPerKandangRepository := repportRepo.NewHppPerKandangRepository(db) productionResultRepository := repportRepo.NewProductionResultRepository(db) customerPaymentRepository := repportRepo.NewCustomerPaymentRepository(db) + customerRepository := customerRepo.NewCustomerRepository(db) userRepository := rUser.NewUserRepository(db) approvalSvc := approvalService.NewApprovalService(approvalRepository) - repportService := sRepport.NewRepportService(db, validate, expenseRealizationRepository, marketingDeliveryProductRepository, purchaseRepository, chickinRepository, recordingRepository, approvalSvc, purchaseSupplierRepository, debtSupplierRepository, hppPerKandangRepository, productionResultRepository, customerPaymentRepository) + repportService := sRepport.NewRepportService(db, validate, expenseRealizationRepository, marketingDeliveryProductRepository, purchaseRepository, chickinRepository, recordingRepository, approvalSvc, purchaseSupplierRepository, debtSupplierRepository, hppPerKandangRepository, productionResultRepository, customerPaymentRepository, customerRepository) userService := sUser.NewUserService(userRepository, validate) RepportRoutes(router, userService, repportService) diff --git a/internal/modules/repports/repositories/customer_payment.repository.go b/internal/modules/repports/repositories/customer_payment.repository.go index 5a39b127..8a5747aa 100644 --- a/internal/modules/repports/repositories/customer_payment.repository.go +++ b/internal/modules/repports/repositories/customer_payment.repository.go @@ -20,9 +20,7 @@ type CustomerPaymentTransaction struct { Weight float64 `gorm:"column:weight"` AverageWeight float64 `gorm:"column:average_weight"` Price float64 `gorm:"column:price"` - CreditNote float64 `gorm:"column:credit_note"` FinalPrice float64 `gorm:"column:final_price"` - PPN float64 `gorm:"column:ppn"` TotalPrice float64 `gorm:"column:total_price"` PaymentAmount float64 `gorm:"column:payment_amount"` PickupInfo string `gorm:"column:pickup_info"` @@ -44,50 +42,41 @@ func NewCustomerPaymentRepository(db *gorm.DB) CustomerPaymentRepository { } func (r *customerPaymentRepositoryImpl) GetCustomerPaymentTransactions(ctx context.Context, customerID *uint) ([]CustomerPaymentTransaction, error) { - // Build SALES subquery salesQuery := r.db.WithContext(ctx). - Table("marketings m"). + Table("marketing_delivery_products mdp"). Select(` 'SALES' AS transaction_type, - m.id::BIGINT AS transaction_id, + mdp.id::BIGINT AS transaction_id, c.id::BIGINT AS customer_id, m.so_date::DATE AS trans_date, - MAX(mdp.delivery_date)::DATE AS delivery_date, - m.so_number AS reference, - COALESCE(STRING_AGG(DISTINCT mdp.vehicle_number, ', ') FILTER (WHERE mdp.vehicle_number IS NOT NULL), '') AS vehicle_numbers, + mdp.delivery_date::DATE AS delivery_date, + m.so_number || '-' || TO_CHAR(mdp.delivery_date, 'YYYYMMDD') || '-' || CAST(pw.warehouse_id AS VARCHAR) AS reference, + COALESCE(mdp.vehicle_number, '') AS vehicle_numbers, - COALESCE(SUM(COALESCE(mp.qty, 0)), 0)::NUMERIC(15,3) AS qty, - COALESCE(SUM(COALESCE(mdp.total_weight, mp.total_weight, 0)), 0)::NUMERIC(15,3) AS weight, - CASE WHEN COALESCE(SUM(COALESCE(mp.qty, 0)), 0) > 0 - THEN (COALESCE(SUM(COALESCE(mdp.total_weight, mp.total_weight, 0)), 0) / COALESCE(SUM(COALESCE(mp.qty, 0)), 0))::NUMERIC(15,3) - ELSE 0::NUMERIC(15,3) - END AS average_weight, - COALESCE(AVG(COALESCE(mdp.unit_price, mp.unit_price, 0)), 0)::NUMERIC(15,3) AS price, - 0::NUMERIC(15,3) AS credit_note, - COALESCE(SUM(COALESCE(mdp.total_price, mp.total_price)), 0)::NUMERIC(15,3) AS final_price, - 0::NUMERIC(15,3) AS ppn, - COALESCE(SUM(COALESCE(mdp.total_price, mp.total_price)), 0)::NUMERIC(15,3) AS total_price, + COALESCE(mdp.usage_qty, 0)::NUMERIC(15,3) AS qty, + COALESCE(mdp.total_weight, 0)::NUMERIC(15,3) AS weight, + COALESCE(mdp.avg_weight, 0)::NUMERIC(15,3) AS average_weight, + COALESCE(mdp.unit_price, 0)::NUMERIC(15,3) AS price, + COALESCE(mdp.total_price, 0)::NUMERIC(15,3) AS final_price, + COALESCE(mdp.total_price, 0)::NUMERIC(15,3) AS total_price, 0::NUMERIC(15,3) AS payment_amount, - COALESCE(STRING_AGG(DISTINCT w.name, ', ') FILTER (WHERE w.name IS NOT NULL), '') AS pickup_info, - MAX(u.name) AS sales_person + w.name AS pickup_info, + u.name AS sales_person `). + Joins("INNER JOIN marketing_products mp ON mp.id = mdp.marketing_product_id"). + Joins("INNER JOIN marketings m ON m.id = mp.marketing_id"). Joins("INNER JOIN customers c ON c.id = m.customer_id"). - Joins("LEFT JOIN marketing_products mp ON mp.marketing_id = m.id"). - Joins("LEFT JOIN marketing_delivery_products mdp ON mdp.marketing_product_id = mp.id"). - Joins("LEFT JOIN product_warehouses pw ON pw.id = mp.product_warehouse_id"). - Joins("LEFT JOIN warehouses w ON w.id = pw.warehouse_id"). - Joins("LEFT JOIN users u ON u.id = m.sales_person_id"). + Joins("INNER JOIN product_warehouses pw ON pw.id = mdp.product_warehouse_id"). + Joins("INNER JOIN warehouses w ON w.id = pw.warehouse_id"). + Joins("INNER JOIN users u ON u.id = m.sales_person_id"). + Where("mdp.delivery_date IS NOT NULL"). Where("m.deleted_at IS NULL"). - Where("c.deleted_at IS NULL"). - Where("mdp.delivery_date IS NOT NULL") + Where("c.deleted_at IS NULL") if customerID != nil { salesQuery = salesQuery.Where("c.id = ?", *customerID) } - salesQuery = salesQuery.Group("m.id, c.id, m.so_date, m.so_number") - - // Build PAYMENT subquery paymentQuery := r.db.WithContext(ctx). Table("payments p"). Select(` @@ -102,9 +91,7 @@ func (r *customerPaymentRepositoryImpl) GetCustomerPaymentTransactions(ctx conte 0::NUMERIC(15,3) AS weight, 0::NUMERIC(15,3) AS average_weight, 0::NUMERIC(15,3) AS price, - 0::NUMERIC(15,3) AS credit_note, 0::NUMERIC(15,3) AS final_price, - 0::NUMERIC(15,3) AS ppn, 0::NUMERIC(15,3) AS total_price, p.nominal::NUMERIC(15,3) AS payment_amount, '-' AS pickup_info, @@ -121,7 +108,6 @@ func (r *customerPaymentRepositoryImpl) GetCustomerPaymentTransactions(ctx conte paymentQuery = paymentQuery.Where("c.id = ?", *customerID) } - // Combine with UNION ALL and execute var results []CustomerPaymentTransaction err := r.db.WithContext(ctx). Raw("? UNION ALL ? ORDER BY customer_id, trans_date, transaction_type DESC, transaction_id", @@ -161,12 +147,13 @@ func (r *customerPaymentRepositoryImpl) GetInitialBalanceByCustomer(ctx context. } func (r *customerPaymentRepositoryImpl) GetCustomerIDsWithTransactions(ctx context.Context, limit, offset int) ([]uint, int64, error) { - // Subquery to get all distinct customer IDs with transactions subQuery := r.db.WithContext(ctx). Table("(" + - "SELECT DISTINCT c.id as customer_id FROM marketings m " + + "SELECT DISTINCT c.id as customer_id FROM marketing_delivery_products mdp " + + "INNER JOIN marketing_products mp ON mp.id = mdp.marketing_product_id " + + "INNER JOIN marketings m ON m.id = mp.marketing_id " + "INNER JOIN customers c ON c.id = m.customer_id " + - "WHERE m.deleted_at IS NULL AND c.deleted_at IS NULL " + + "WHERE mdp.delivery_date IS NOT NULL AND m.deleted_at IS NULL AND c.deleted_at IS NULL " + "UNION " + "SELECT DISTINCT c.id as customer_id FROM payments p " + "INNER JOIN customers c ON c.id = p.party_id " + @@ -174,19 +161,19 @@ func (r *customerPaymentRepositoryImpl) GetCustomerIDsWithTransactions(ctx conte "AND p.transaction_type = 'PENJUALAN' AND p.deleted_at IS NULL AND c.deleted_at IS NULL" + ") as customer_ids") - // Count total customers var total int64 if err := subQuery.Count(&total).Error; err != nil { return nil, 0, err } - // Get paginated customer IDs var customerIDs []uint err := r.db.WithContext(ctx). Table("("+ - "SELECT DISTINCT c.id as customer_id FROM marketings m "+ + "SELECT DISTINCT c.id as customer_id FROM marketing_delivery_products mdp "+ + "INNER JOIN marketing_products mp ON mp.id = mdp.marketing_product_id "+ + "INNER JOIN marketings m ON m.id = mp.marketing_id "+ "INNER JOIN customers c ON c.id = m.customer_id "+ - "WHERE m.deleted_at IS NULL AND c.deleted_at IS NULL "+ + "WHERE mdp.delivery_date IS NOT NULL AND m.deleted_at IS NULL AND c.deleted_at IS NULL "+ "UNION "+ "SELECT DISTINCT c.id as customer_id FROM payments p "+ "INNER JOIN customers c ON c.id = p.party_id "+ diff --git a/internal/modules/repports/services/repport.service.go b/internal/modules/repports/services/repport.service.go index 1dba2114..2c102418 100644 --- a/internal/modules/repports/services/repport.service.go +++ b/internal/modules/repports/services/repport.service.go @@ -20,6 +20,7 @@ import ( marketingRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/repositories" areaDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/areas/dto" customerDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/customers/dto" + customerRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/master/customers/repositories" supplierDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/suppliers/dto" warehouseDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/dto" chickinRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/production/chickins/repositories" @@ -59,6 +60,7 @@ type repportService struct { HppPerKandangRepo repportRepo.HppPerKandangRepository ProductionResultRepo repportRepo.ProductionResultRepository CustomerPaymentRepo repportRepo.CustomerPaymentRepository + CustomerRepo customerRepo.CustomerRepository } type HppCostAggregate struct { @@ -84,6 +86,7 @@ func NewRepportService( hppPerKandangRepo repportRepo.HppPerKandangRepository, productionResultRepo repportRepo.ProductionResultRepository, customerPaymentRepo repportRepo.CustomerPaymentRepository, + customerRepo customerRepo.CustomerRepository, ) RepportService { return &repportService{ Log: utils.Log, @@ -100,6 +103,7 @@ func NewRepportService( HppPerKandangRepo: hppPerKandangRepo, ProductionResultRepo: productionResultRepo, CustomerPaymentRepo: customerPaymentRepo, + CustomerRepo: customerRepo, } } @@ -356,7 +360,6 @@ func (s *repportService) GetCustomerPayment(ctx *fiber.Ctx, params *validation.C } } - // Process each customer var result []dto.CustomerPaymentReportItem for _, customerID := range customerIDs { item, err := s.processCustomerPayment(ctx.Context(), customerID, params) @@ -370,10 +373,9 @@ func (s *repportService) GetCustomerPayment(ctx *fiber.Ctx, params *validation.C } func (s *repportService) processCustomerPayment(ctx context.Context, customerID uint, params *validation.CustomerPaymentQuery) (dto.CustomerPaymentReportItem, error) { - customer := entity.Customer{} - if err := s.DB.WithContext(ctx). - Where("id = ?", customerID). - First(&customer).Error; err != nil { + + customer, err := s.CustomerRepo.GetByID(ctx, customerID, nil) + if err != nil { return dto.CustomerPaymentReportItem{}, err } @@ -407,7 +409,6 @@ func (s *repportService) processCustomerPayment(ctx context.Context, customerID days := 0 row.AgingDay = &days } else if paymentDate != nil { - // Aging = payment_date - trans_date (SO date) days := int(paymentDate.Sub(tx.TransDate).Hours() / 24) if days < 0 { days = 0 @@ -418,7 +419,6 @@ func (s *repportService) processCustomerPayment(ctx context.Context, customerID row.AgingDay = &days } } else { - // Aging = current_date - trans_date (SO date) days := int(time.Since(tx.TransDate).Hours() / 24) if days < 0 { days = 0 @@ -455,22 +455,18 @@ func (s *repportService) processCustomerPayment(ctx context.Context, customerID if err != nil { return dto.CustomerPaymentReportItem{}, err } - // End date should be inclusive, so set to end of day endOfDay := time.Date(parsed.Year(), parsed.Month(), parsed.Day(), 23, 59, 59, 999999999, location) endDate = &endOfDay } for _, row := range rows { transDate := row.TransDate.In(location) - - // Check if transaction date is within range if startDate != nil && transDate.Before(*startDate) { continue } if endDate != nil && transDate.After(*endDate) { continue } - filteredRows = append(filteredRows, row) } @@ -480,7 +476,7 @@ func (s *repportService) processCustomerPayment(ctx context.Context, customerID summary := dto.CalculateCustomerPaymentSummary(rows, initialBalance) return dto.CustomerPaymentReportItem{ - Customer: customerDTO.ToCustomerRelationDTO(customer), + Customer: customerDTO.ToCustomerRelationDTO(*customer), InitialBalance: initialBalance, Rows: rows, Summary: summary, From c6dc94a4e11c5b94fe0c50344191a82fa78afe5c Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Wed, 14 Jan 2026 20:06:41 +0700 Subject: [PATCH 43/76] feat[BE]: add permission requirement for customer payment report route --- internal/modules/repports/route.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/modules/repports/route.go b/internal/modules/repports/route.go index 3f803677..2f5eceec 100644 --- a/internal/modules/repports/route.go +++ b/internal/modules/repports/route.go @@ -21,5 +21,5 @@ func RepportRoutes(v1 fiber.Router, u user.UserService, s repport.RepportService route.Get("/debt-supplier", m.RequirePermissions(m.P_ReportDebtSupplierGetAll), ctrl.GetDebtSupplier) route.Get("/hpp-per-kandang", m.RequirePermissions(m.P_ReportHppPerKandangGetAll), ctrl.GetHppPerKandang) route.Get("/production-result/:idProjectFlockKandang", m.RequirePermissions(m.P_ReportProductionResultGetAll), ctrl.GetProductionResult) - route.Get("/customer-payment", ctrl.GetCustomerPayment) + route.Get("/customer-payment", m.RequirePermissions(m.P_ReportCustomerPaymentGetAll), ctrl.GetCustomerPayment) } From c316a6d7a93cd4619bcf0f7ff961fb05a1de5c22 Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Thu, 15 Jan 2026 10:41:44 +0700 Subject: [PATCH 44/76] feat[BE]: add address field to CustomerRelationDTO and refactor payment report functions for improved clarity and structure --- .../master/customers/dto/customer.dto.go | 2 + .../dto/repportCustomerPayment.dto.go | 42 ++++++++++++------- .../repports/services/repport.service.go | 10 +---- 3 files changed, 30 insertions(+), 24 deletions(-) diff --git a/internal/modules/master/customers/dto/customer.dto.go b/internal/modules/master/customers/dto/customer.dto.go index 592f14cd..eceafa39 100644 --- a/internal/modules/master/customers/dto/customer.dto.go +++ b/internal/modules/master/customers/dto/customer.dto.go @@ -14,6 +14,7 @@ type CustomerRelationDTO struct { Name string `json:"name"` Type string `json:"type"` AccountNumber string `json:"account_number"` + Address string `json:"address,omitempty"` Balance float64 `json:"balance"` Pic *userDTO.UserRelationDTO `json:"pic,omitempty"` } @@ -52,6 +53,7 @@ func ToCustomerRelationDTO(e entity.Customer) CustomerRelationDTO { Name: e.Name, Type: e.Type, AccountNumber: e.AccountNumber, + Address: e.Address, Balance: e.Balance, Pic: pic, } diff --git a/internal/modules/repports/dto/repportCustomerPayment.dto.go b/internal/modules/repports/dto/repportCustomerPayment.dto.go index cdac5029..5a8a69ac 100644 --- a/internal/modules/repports/dto/repportCustomerPayment.dto.go +++ b/internal/modules/repports/dto/repportCustomerPayment.dto.go @@ -4,6 +4,7 @@ import ( "strings" "time" + "gitlab.com/mbugroup/lti-api.git/internal/entities" customerDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/customers/dto" repportRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/repports/repositories" ) @@ -70,25 +71,16 @@ func ToCustomerPaymentReportRow(tx repportRepo.CustomerPaymentTransaction) Custo } } -func parseStringSlice(str string) []string { - str = strings.TrimSpace(str) - if str == "" || str == "-" { - return []string{} +func ToCustomerPaymentReportItem(customer entities.Customer, initialBalance float64, rows []CustomerPaymentReportRow, summary CustomerPaymentReportSummary) CustomerPaymentReportItem { + return CustomerPaymentReportItem{ + Customer: customerDTO.ToCustomerRelationDTO(customer), + InitialBalance: initialBalance, + Rows: rows, + Summary: summary, } - - parts := strings.Split(str, ",") - result := make([]string, 0, len(parts)) - for _, part := range parts { - part = strings.TrimSpace(part) - if part != "" { - result = append(result, part) - } - } - - return result } -func CalculateCustomerPaymentSummary(rows []CustomerPaymentReportRow, initialBalance float64) CustomerPaymentReportSummary { +func ToCustomerPaymentReportSummary(rows []CustomerPaymentReportRow, initialBalance float64) CustomerPaymentReportSummary { summary := CustomerPaymentReportSummary{} for _, row := range rows { @@ -108,3 +100,21 @@ func CalculateCustomerPaymentSummary(rows []CustomerPaymentReportRow, initialBal return summary } + +func parseStringSlice(str string) []string { + str = strings.TrimSpace(str) + if str == "" || str == "-" { + return []string{} + } + + parts := strings.Split(str, ",") + result := make([]string, 0, len(parts)) + for _, part := range parts { + part = strings.TrimSpace(part) + if part != "" { + result = append(result, part) + } + } + + return result +} diff --git a/internal/modules/repports/services/repport.service.go b/internal/modules/repports/services/repport.service.go index 4e2104e8..1b721d64 100644 --- a/internal/modules/repports/services/repport.service.go +++ b/internal/modules/repports/services/repport.service.go @@ -19,7 +19,6 @@ import ( expenseRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/repositories" marketingRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/repositories" areaDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/areas/dto" - customerDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/customers/dto" customerRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/master/customers/repositories" supplierDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/suppliers/dto" warehouseDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/dto" @@ -473,14 +472,9 @@ func (s *repportService) processCustomerPayment(ctx context.Context, customerID rows = filteredRows } - summary := dto.CalculateCustomerPaymentSummary(rows, initialBalance) + summary := dto.ToCustomerPaymentReportSummary(rows, initialBalance) - return dto.CustomerPaymentReportItem{ - Customer: customerDTO.ToCustomerRelationDTO(*customer), - InitialBalance: initialBalance, - Rows: rows, - Summary: summary, - }, nil + return dto.ToCustomerPaymentReportItem(*customer, initialBalance, rows, summary), nil } func (s *repportService) determineSalesStatusAndPaymentDate(transactions []repportRepo.CustomerPaymentTransaction, currentIndex int, previousBalance, currentBalance float64) (string, *time.Time) { From 37c26d58774409c5feac80f757ba799fe2f86508 Mon Sep 17 00:00:00 2001 From: giovanni Date: Thu, 15 Jan 2026 10:45:13 +0700 Subject: [PATCH 45/76] add daily checklist permission --- internal/middleware/permissions.go | 17 +++++++++-- internal/modules/daily-checklists/route.go | 28 +++++++++---------- .../modules/master/config-checklists/route.go | 10 +++---- internal/modules/master/employees/route.go | 10 +++---- .../modules/master/phase-activities/route.go | 10 +++---- internal/modules/master/phasess/route.go | 10 +++---- 6 files changed, 49 insertions(+), 36 deletions(-) diff --git a/internal/middleware/permissions.go b/internal/middleware/permissions.go index 5d5290d3..b5d3c727 100644 --- a/internal/middleware/permissions.go +++ b/internal/middleware/permissions.go @@ -1,8 +1,9 @@ package middleware -const( +const ( P_DashboardGetAll = "lti.dashboard.list" ) + // project-flock const ( P_ProjectFlockKandangsClosing = "lti.production.project_flock_kandangs.closing" @@ -151,7 +152,7 @@ const ( P_ProductsCreateOne = "lti.master.products.create" P_ProductsUpdateOne = "lti.master.products.update" P_ProductsDeleteOne = "lti.master.products.delete" - + P_SuppliersGetAll = "lti.master.suppliers.list" P_SuppliersGetOne = "lti.master.suppliers.detail" P_SuppliersCreateOne = "lti.master.suppliers.create" @@ -238,3 +239,15 @@ const ( P_UserGetAll = "lti.users.list" P_UserGetOne = "lti.users.detail" ) + +// daily-checklist +const ( + P_DailyChecklistDashboardList = "lti.daily_checklist.dashboard.list" + P_DailyChecklistCreateOne = "lti.daily_checklist.create" + P_DailyChecklistGetAll = "lti.daily_checklist.list" + P_DailyChecklistGetOne = "lti.daily_checklist.detail" + P_DailyChecklistReports = "lti.daily_checklist.reports" + P_DailyChecklistEmployee = "lti.daily_checklist.master_data.employee" + P_DailyChecklistActivity = "lti.daily_checklist.master_data.activity" + P_DailyChecklistActivityConfig = "lti.daily_checklist.master_data.configuration" +) diff --git a/internal/modules/daily-checklists/route.go b/internal/modules/daily-checklists/route.go index 9e576a05..0927486a 100644 --- a/internal/modules/daily-checklists/route.go +++ b/internal/modules/daily-checklists/route.go @@ -15,49 +15,49 @@ func DailyChecklistRoutes(v1 fiber.Router, u user.UserService, s dailyChecklist. route := v1.Group("/daily-checklists") route.Use(m.Auth(u)) - route.Get("/", ctrl.GetAll) - route.Get("/report", ctrl.GetReport) + route.Get("/", m.RequirePermissions(m.P_DailyChecklistGetAll), ctrl.GetAll) + route.Get("/report", m.RequirePermissions(m.P_DailyChecklistReports), ctrl.GetReport) - route.Get("/summary", ctrl.GetSummary) + route.Get("/summary", m.RequirePermissions(m.P_DailyChecklistDashboardList), ctrl.GetSummary) - route.Get("/report", ctrl.GetReport) + // route.Get("/report", ctrl.GetReport) // upsert daily checklist - route.Post("/", ctrl.CreateOne) + route.Post("/", m.RequirePermissions(m.P_DailyChecklistCreateOne), ctrl.CreateOne) // get detail data daily checklist by id - route.Get("/relation/:idDailyChecklist", ctrl.GetOne) + route.Get("/relation/:idDailyChecklist", m.RequirePermissions(m.P_DailyChecklistGetOne), ctrl.GetOne) // get phases by daily checklist id - route.Get("/phase/:idDailyChecklist", ctrl.GetPhaseByIdChecklist) + route.Get("/phase/:idDailyChecklist", m.RequirePermissions(m.P_DailyChecklistCreateOne), ctrl.GetPhaseByIdChecklist) // create task /* ketika add phase */ - route.Post("/phase/:idDailyChecklist", ctrl.CreateDailyChecklistPhase) + route.Post("/phase/:idDailyChecklist", m.RequirePermissions(m.P_DailyChecklistCreateOne), ctrl.CreateDailyChecklistPhase) // create assigment /* ketika add ABK */ - route.Post("/assignment/:idDailyChecklist", ctrl.CreateAssignment) + route.Post("/assignment/:idDailyChecklist", m.RequirePermissions(m.P_DailyChecklistCreateOne), ctrl.CreateAssignment) // remove assignment /* ketika remove ABK */ - route.Delete("/:idDailyChecklist/assignments/:idEmployee", ctrl.RemoveAssignment) + route.Delete("/:idDailyChecklist/assignments/:idEmployee", m.RequirePermissions(m.P_DailyChecklistCreateOne), ctrl.RemoveAssignment) //get all tasks - route.Get("/tasks", ctrl.GetAllTasks) + route.Get("/tasks", m.RequirePermissions(m.P_DailyChecklistCreateOne), ctrl.GetAllTasks) // update assignment /* ketika check dan uncheck tugas oleh ABK */ - route.Post("/assignment", ctrl.UpdateAssignment) + route.Post("/assignment", m.RequirePermissions(m.P_DailyChecklistCreateOne), ctrl.UpdateAssignment) - route.Patch("/:idDailyChecklist", ctrl.UpdateOne) - route.Delete("/:idDailyChecklist", ctrl.DeleteOne) + route.Patch("/:idDailyChecklist", m.RequirePermissions(m.P_DailyChecklistCreateOne), ctrl.UpdateOne) + route.Delete("/:idDailyChecklist", m.RequirePermissions(m.P_DailyChecklistCreateOne), ctrl.DeleteOne) } diff --git a/internal/modules/master/config-checklists/route.go b/internal/modules/master/config-checklists/route.go index 1b590067..a7e09500 100644 --- a/internal/modules/master/config-checklists/route.go +++ b/internal/modules/master/config-checklists/route.go @@ -15,9 +15,9 @@ func ConfigChecklistRoutes(v1 fiber.Router, u user.UserService, s configChecklis route := v1.Group("/config-checklists") route.Use(m.Auth(u)) - route.Get("/", ctrl.GetAll) - route.Post("/", ctrl.CreateOne) - route.Get("/:id", ctrl.GetOne) - route.Patch("/:id", ctrl.UpdateOne) - route.Delete("/:id", ctrl.DeleteOne) + route.Get("/", m.RequirePermissions(m.P_DailyChecklistActivityConfig), ctrl.GetAll) + route.Post("/", m.RequirePermissions(m.P_DailyChecklistActivityConfig), ctrl.CreateOne) + route.Get("/:id", m.RequirePermissions(m.P_DailyChecklistActivityConfig), ctrl.GetOne) + route.Patch("/:id", m.RequirePermissions(m.P_DailyChecklistActivityConfig), ctrl.UpdateOne) + route.Delete("/:id", m.RequirePermissions(m.P_DailyChecklistActivityConfig), ctrl.DeleteOne) } diff --git a/internal/modules/master/employees/route.go b/internal/modules/master/employees/route.go index 53974814..08fb4870 100644 --- a/internal/modules/master/employees/route.go +++ b/internal/modules/master/employees/route.go @@ -15,9 +15,9 @@ func EmployeesRoutes(v1 fiber.Router, u user.UserService, s employees.EmployeesS route := v1.Group("/employees") route.Use(m.Auth(u)) - route.Get("/", ctrl.GetAll) - route.Post("/", ctrl.CreateOne) - route.Get("/:id", ctrl.GetOne) - route.Patch("/:id", ctrl.UpdateOne) - route.Delete("/:id", ctrl.DeleteOne) + route.Get("/", m.RequirePermissions(m.P_DailyChecklistEmployee), ctrl.GetAll) + route.Post("/", m.RequirePermissions(m.P_DailyChecklistEmployee), ctrl.CreateOne) + route.Get("/:id", m.RequirePermissions(m.P_DailyChecklistEmployee), ctrl.GetOne) + route.Patch("/:id", m.RequirePermissions(m.P_DailyChecklistEmployee), ctrl.UpdateOne) + route.Delete("/:id", m.RequirePermissions(m.P_DailyChecklistEmployee), ctrl.DeleteOne) } diff --git a/internal/modules/master/phase-activities/route.go b/internal/modules/master/phase-activities/route.go index 6fcef558..723fd7bd 100644 --- a/internal/modules/master/phase-activities/route.go +++ b/internal/modules/master/phase-activities/route.go @@ -15,9 +15,9 @@ func PhaseActivityRoutes(v1 fiber.Router, u user.UserService, s phaseActivity.Ph route := v1.Group("/phase-activities") route.Use(m.Auth(u)) - route.Get("/", ctrl.GetAll) - route.Post("/", ctrl.CreateOne) - route.Get("/:id", ctrl.GetOne) - route.Patch("/:id", ctrl.UpdateOne) - route.Delete("/:id", ctrl.DeleteOne) + route.Get("/", m.RequirePermissions(m.P_DailyChecklistActivity), ctrl.GetAll) + route.Post("/", m.RequirePermissions(m.P_DailyChecklistActivity), ctrl.CreateOne) + route.Get("/:id", m.RequirePermissions(m.P_DailyChecklistActivity), ctrl.GetOne) + route.Patch("/:id", m.RequirePermissions(m.P_DailyChecklistActivity), ctrl.UpdateOne) + route.Delete("/:id", m.RequirePermissions(m.P_DailyChecklistActivity), ctrl.DeleteOne) } diff --git a/internal/modules/master/phasess/route.go b/internal/modules/master/phasess/route.go index b4ca202d..1da6aeeb 100644 --- a/internal/modules/master/phasess/route.go +++ b/internal/modules/master/phasess/route.go @@ -15,9 +15,9 @@ func PhasesRoutes(v1 fiber.Router, u user.UserService, s phases.PhasesService) { route := v1.Group("/phases") route.Use(m.Auth(u)) - route.Get("/", ctrl.GetAll) - route.Post("/", ctrl.CreateOne) - route.Get("/:id", ctrl.GetOne) - route.Patch("/:id", ctrl.UpdateOne) - route.Delete("/:id", ctrl.DeleteOne) + route.Get("/", m.RequirePermissions(m.P_DailyChecklistActivity), ctrl.GetAll) + route.Post("/", m.RequirePermissions(m.P_DailyChecklistActivity), ctrl.CreateOne) + route.Get("/:id", m.RequirePermissions(m.P_DailyChecklistActivity), ctrl.GetOne) + route.Patch("/:id", m.RequirePermissions(m.P_DailyChecklistActivity), ctrl.UpdateOne) + route.Delete("/:id", m.RequirePermissions(m.P_DailyChecklistActivity), ctrl.DeleteOne) } From 8792161c02c437e32a43f28d83b961c224172408 Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Thu, 15 Jan 2026 10:58:00 +0700 Subject: [PATCH 46/76] feat[BE]: rename Price field to UnitPrice in CustomerPaymentReportRow for clarity --- internal/modules/repports/dto/repportCustomerPayment.dto.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/modules/repports/dto/repportCustomerPayment.dto.go b/internal/modules/repports/dto/repportCustomerPayment.dto.go index 5a8a69ac..99862349 100644 --- a/internal/modules/repports/dto/repportCustomerPayment.dto.go +++ b/internal/modules/repports/dto/repportCustomerPayment.dto.go @@ -19,7 +19,7 @@ type CustomerPaymentReportRow struct { Qty float64 `json:"qty"` Weight float64 `json:"weight"` AverageWeight float64 `json:"average_weight"` - Price float64 `json:"price"` + UnitPrice float64 `json:"unit_price"` FinalPrice float64 `json:"final_price"` TotalPrice float64 `json:"total_price"` PaymentAmount float64 `json:"payment_amount"` @@ -61,7 +61,7 @@ func ToCustomerPaymentReportRow(tx repportRepo.CustomerPaymentTransaction) Custo Qty: tx.Qty, Weight: tx.Weight, AverageWeight: tx.AverageWeight, - Price: tx.Price, + UnitPrice: tx.Price, FinalPrice: tx.FinalPrice, TotalPrice: tx.TotalPrice, PaymentAmount: tx.PaymentAmount, From 3f4d6c630a34a9041ecb7271bd68bcc3e13ddec0 Mon Sep 17 00:00:00 2001 From: kris Date: Thu, 15 Jan 2026 06:46:07 +0000 Subject: [PATCH 47/76] Update .gitlab-ci.yml file --- .gitlab-ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index a46bb3aa..41aa41be 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -25,7 +25,7 @@ variables: COMPOSE_FILE: "docker-compose.yaml" # ========================= -# BUILD (AUTO) +# BUILD (AUTO) # ========================= build_staging: stage: build From 7f2401311b3cc458e8b72aaad513b248a337197a Mon Sep 17 00:00:00 2001 From: ragilap Date: Thu, 15 Jan 2026 13:48:00 +0700 Subject: [PATCH 48/76] [FIX/BE-US] add response warehouse and project flock kandang --- ...115062032_add_fifo_recording_eggs.down.sql | 3 + ...60115062032_add_fifo_recording_eggs.up.sql | 7 ++ internal/entities/recording_egg.go | 2 + .../controllers/projectflock.controller.go | 7 ++ .../dto/projectflock_kandang.dto.go | 30 ++++---- .../services/projectflock.service.go | 26 +++++++ .../recordings/dto/recording.dto.go | 4 +- .../modules/production/recordings/module.go | 16 ++++ .../recordings/services/recording.service.go | 74 ++++++++++++++++++- internal/utils/fifo/constants.go | 1 + 10 files changed, 150 insertions(+), 20 deletions(-) create mode 100644 internal/database/migrations/20260115062032_add_fifo_recording_eggs.down.sql create mode 100644 internal/database/migrations/20260115062032_add_fifo_recording_eggs.up.sql diff --git a/internal/database/migrations/20260115062032_add_fifo_recording_eggs.down.sql b/internal/database/migrations/20260115062032_add_fifo_recording_eggs.down.sql new file mode 100644 index 00000000..8fb42a96 --- /dev/null +++ b/internal/database/migrations/20260115062032_add_fifo_recording_eggs.down.sql @@ -0,0 +1,3 @@ +ALTER TABLE recording_eggs + DROP COLUMN IF EXISTS total_used, + DROP COLUMN IF EXISTS total_qty; diff --git a/internal/database/migrations/20260115062032_add_fifo_recording_eggs.up.sql b/internal/database/migrations/20260115062032_add_fifo_recording_eggs.up.sql new file mode 100644 index 00000000..dbadd9e3 --- /dev/null +++ b/internal/database/migrations/20260115062032_add_fifo_recording_eggs.up.sql @@ -0,0 +1,7 @@ +ALTER TABLE recording_eggs + ADD COLUMN total_qty NUMERIC(15, 3) DEFAULT 0 NOT NULL, + ADD COLUMN total_used NUMERIC(15, 3) DEFAULT 0 NOT NULL; + +UPDATE recording_eggs +SET total_qty = qty +WHERE total_qty = 0; diff --git a/internal/entities/recording_egg.go b/internal/entities/recording_egg.go index 68269728..b48c49ca 100644 --- a/internal/entities/recording_egg.go +++ b/internal/entities/recording_egg.go @@ -7,6 +7,8 @@ type RecordingEgg struct { RecordingId uint `gorm:"column:recording_id;not null;index"` ProductWarehouseId uint `gorm:"column:product_warehouse_id;not null"` Qty int `gorm:"column:qty;not null"` + TotalQty float64 `gorm:"column:total_qty"` + TotalUsed float64 `gorm:"column:total_used"` Weight *float64 `gorm:"column:weight"` CreatedBy uint `gorm:"column:created_by"` CreatedAt time.Time `gorm:"autoCreateTime"` diff --git a/internal/modules/production/project_flocks/controllers/projectflock.controller.go b/internal/modules/production/project_flocks/controllers/projectflock.controller.go index 4315b948..c13baab6 100644 --- a/internal/modules/production/project_flocks/controllers/projectflock.controller.go +++ b/internal/modules/production/project_flocks/controllers/projectflock.controller.go @@ -7,6 +7,7 @@ import ( "strconv" "strings" + warehouseDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/dto" "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/dto" service "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/services" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/validations" @@ -281,6 +282,12 @@ func (u *ProjectflockController) LookupProjectFlockKandang(c *fiber.Ctx) error { dtoResult := dto.ToProjectFlockKandangDTO(*result) dtoResult.AvailableQuantity = float64(availableStock) + if warehouse, werr := u.ProjectflockService.GetWarehouseByKandangID(c, result.KandangId); werr != nil { + return werr + } else if warehouse != nil { + mapped := warehouseDTO.ToWarehouseRelationDTO(*warehouse) + dtoResult.Warehouse = &mapped + } if withPopulation { population, err := u.ProjectflockService.GetProjectFlockKandangPopulation(c, result.Id) if err != nil { diff --git a/internal/modules/production/project_flocks/dto/projectflock_kandang.dto.go b/internal/modules/production/project_flocks/dto/projectflock_kandang.dto.go index 8dedaf15..b6bbf852 100644 --- a/internal/modules/production/project_flocks/dto/projectflock_kandang.dto.go +++ b/internal/modules/production/project_flocks/dto/projectflock_kandang.dto.go @@ -7,6 +7,7 @@ import ( kandangDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/dto" locationDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/locations/dto" productionStandardDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards/dto" + warehouseDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/dto" userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto" ) @@ -17,24 +18,25 @@ type KandangWithPivotDTO struct { type ProjectFlockWithPivotDTO struct { ProjectFlockRelationDTO - Area *areaDTO.AreaRelationDTO `json:"area,omitempty"` - Category string `json:"category"` - Fcr *fcrDTO.FcrRelationDTO `json:"fcr,omitempty"` + Area *areaDTO.AreaRelationDTO `json:"area,omitempty"` + Category string `json:"category"` + Fcr *fcrDTO.FcrRelationDTO `json:"fcr,omitempty"` ProductionStandard *productionStandardDTO.ProductionStandardRelationDTO `json:"production_standard,omitempty"` - Location *locationDTO.LocationRelationDTO `json:"location,omitempty"` - Kandangs []KandangWithPivotDTO `json:"kandangs,omitempty"` - CreatedUser *userDTO.UserRelationDTO `json:"created_user,omitempty"` + Location *locationDTO.LocationRelationDTO `json:"location,omitempty"` + Kandangs []KandangWithPivotDTO `json:"kandangs,omitempty"` + CreatedUser *userDTO.UserRelationDTO `json:"created_user,omitempty"` } type ProjectFlockKandangDTO struct { - Id uint `json:"id"` - ProjectFlockKandangId uint `json:"project_flock_kandang_id"` - ProjectFlockId uint `json:"project_flock_id"` - KandangId uint `json:"kandang_id"` - Kandang *kandangDTO.KandangRelationDTO `json:"kandang,omitempty"` - ProjectFlock *ProjectFlockWithPivotDTO `json:"project_flock,omitempty"` - AvailableQuantity float64 `json:"available_quantity"` - Population *float64 `json:"population,omitempty"` + Id uint `json:"id"` + ProjectFlockKandangId uint `json:"project_flock_kandang_id"` + ProjectFlockId uint `json:"project_flock_id"` + KandangId uint `json:"kandang_id"` + Kandang *kandangDTO.KandangRelationDTO `json:"kandang,omitempty"` + Warehouse *warehouseDTO.WarehouseRelationDTO `json:"warehouse,omitempty"` + ProjectFlock *ProjectFlockWithPivotDTO `json:"project_flock,omitempty"` + AvailableQuantity float64 `json:"available_quantity"` + Population *float64 `json:"population,omitempty"` } func ToProjectFlockKandangDTO(e entity.ProjectFlockKandang) ProjectFlockKandangDTO { diff --git a/internal/modules/production/project_flocks/services/projectflock.service.go b/internal/modules/production/project_flocks/services/projectflock.service.go index 3dbe3f4b..05e21894 100644 --- a/internal/modules/production/project_flocks/services/projectflock.service.go +++ b/internal/modules/production/project_flocks/services/projectflock.service.go @@ -38,6 +38,7 @@ type ProjectflockService interface { GetOne(ctx *fiber.Ctx, id uint) (*entity.ProjectFlock, *flockDTO.FlockRelationDTO, error) CreateOne(ctx *fiber.Ctx, req *validation.Create) (*entity.ProjectFlock, error) GetAvailableDocQuantity(ctx *fiber.Ctx, kandangID uint) (float64, error) + GetWarehouseByKandangID(ctx *fiber.Ctx, kandangID uint) (*entity.Warehouse, error) DeleteOne(ctx *fiber.Ctx, id uint) error GetProjectFlockKandangByProjectAndKandang(ctx *fiber.Ctx, projectFlockID uint, kandangID uint) (*entity.ProjectFlockKandang, float64, error) GetProjectFlockKandangPopulation(ctx *fiber.Ctx, projectFlockKandangID uint) (float64, error) @@ -518,6 +519,31 @@ func (s projectflockService) GetAvailableDocQuantity(ctx *fiber.Ctx, kandangID u return total, nil } +func (s projectflockService) GetWarehouseByKandangID(ctx *fiber.Ctx, kandangID uint) (*entity.Warehouse, error) { + if kandangID == 0 || s.WarehouseRepo == nil { + return nil, nil + } + + var warehouse entity.Warehouse + err := s.WarehouseRepo.DB().WithContext(ctx.Context()). + Preload("Area"). + Preload("Location"). + Preload("Kandang"). + Where("kandang_id = ?", kandangID). + Where("deleted_at IS NULL"). + Order("id DESC"). + First(&warehouse).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + if err != nil { + s.Log.Errorf("Failed to fetch warehouse for kandang %d: %+v", kandangID, err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch warehouse") + } + + return &warehouse, nil +} + func (s projectflockService) GetProjectPeriods(c *fiber.Ctx, projectIDs []uint) (map[uint]int, error) { if len(projectIDs) == 0 { return map[uint]int{}, nil diff --git a/internal/modules/production/recordings/dto/recording.dto.go b/internal/modules/production/recordings/dto/recording.dto.go index 4deb9f7e..ebb093ba 100644 --- a/internal/modules/production/recordings/dto/recording.dto.go +++ b/internal/modules/production/recordings/dto/recording.dto.go @@ -82,11 +82,11 @@ type RecordingListDTO struct { CreatedUser *userDTO.UserRelationDTO `json:"created_user,omitempty"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` + Warehouse *RecordingWarehouseDTO `json:"warehouse,omitempty"` } type RecordingDetailDTO struct { RecordingListDTO - Warehouse *RecordingWarehouseDTO `json:"warehouse,omitempty"` ProductCategory string `json:"product_category"` Depletions []RecordingDepletionDTO `json:"depletions"` Stocks []RecordingStockDTO `json:"stocks"` @@ -133,7 +133,6 @@ func ToRecordingDetailDTO(e entity.Recording) RecordingDetailDTO { return RecordingDetailDTO{ RecordingListDTO: listDTO, - Warehouse: recordingWarehouseDTO(e), ProductCategory: recordingProductCategory(e), Depletions: ToRecordingDepletionDTOs(e.Depletions), Stocks: ToRecordingStockDTOs(e.Stocks), @@ -203,6 +202,7 @@ func toRecordingListDTO(e entity.Recording) RecordingListDTO { CreatedAt: e.CreatedAt, UpdatedAt: e.UpdatedAt, CreatedUser: createdUser, + Warehouse: recordingWarehouseDTO(e), } } diff --git a/internal/modules/production/recordings/module.go b/internal/modules/production/recordings/module.go index 91b024ac..11a1e152 100644 --- a/internal/modules/production/recordings/module.go +++ b/internal/modules/production/recordings/module.go @@ -43,6 +43,22 @@ func (RecordingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate ) fifoService := commonSvc.NewFifoService(db, stockAllocationRepo, productWarehouseRepo, utils.Log) + if err := fifoService.RegisterStockable(fifo.StockableConfig{ + Key: fifo.StockableKeyRecordingEgg, + Table: "recording_eggs", + Columns: fifo.StockableColumns{ + ID: "id", + ProductWarehouseID: "product_warehouse_id", + TotalQuantity: "total_qty", + TotalUsedQuantity: "total_used", + CreatedAt: "created_at", + }, + OrderBy: []string{"created_at ASC", "id ASC"}, + }); err != nil { + if !strings.Contains(strings.ToLower(err.Error()), "already registered") { + panic(fmt.Sprintf("failed to register recording egg stockable workflow: %v", err)) + } + } if err := fifoService.RegisterUsable(fifo.UsableConfig{ Key: fifo.UsableKeyRecordingStock, Table: "recording_stocks", diff --git a/internal/modules/production/recordings/services/recording.service.go b/internal/modules/production/recordings/services/recording.service.go index 88ed4cf7..5dabad9f 100644 --- a/internal/modules/production/recordings/services/recording.service.go +++ b/internal/modules/production/recordings/services/recording.service.go @@ -290,8 +290,19 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent s.Log.Errorf("Failed to persist eggs: %+v", err) return err } + if s.FifoSvc != nil { + if err := s.replenishRecordingEggs(ctx, tx, mappedEggs); err != nil { + return err + } + } - if err := s.adjustProductWarehouseQuantities(ctx, tx, buildWarehouseDeltas(nil, mappedDepletions, nil, mappedEggs)); err != nil { + var warehouseDeltas map[uint]float64 + if s.FifoSvc != nil { + warehouseDeltas = buildWarehouseDeltas(nil, mappedDepletions, nil, nil) + } else { + warehouseDeltas = buildWarehouseDeltas(nil, mappedDepletions, nil, mappedEggs) + } + if err := s.adjustProductWarehouseQuantities(ctx, tx, warehouseDeltas); err != nil { s.Log.Errorf("Failed to adjust product warehouses: %+v", err) return err } @@ -438,6 +449,16 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin } if hasEggChanges { + if s.FifoSvc != nil { + if err := ensureRecordingEggsUnused(existingEggs); err != nil { + return err + } + if err := s.adjustProductWarehouseQuantities(ctx, tx, buildWarehouseDeltas(nil, nil, existingEggs, nil)); err != nil { + s.Log.Errorf("Failed to adjust product warehouses for eggs: %+v", err) + return err + } + } + if err := s.Repository.DeleteEggs(tx, recordingEntity.Id); err != nil { s.Log.Errorf("Failed to clear eggs: %+v", err) return err @@ -449,9 +470,15 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin return err } - if err := s.adjustProductWarehouseQuantities(ctx, tx, buildWarehouseDeltas(nil, nil, existingEggs, mappedEggs)); err != nil { - s.Log.Errorf("Failed to adjust product warehouses for eggs: %+v", err) - return err + if s.FifoSvc != nil { + if err := s.replenishRecordingEggs(ctx, tx, mappedEggs); err != nil { + return err + } + } else { + if err := s.adjustProductWarehouseQuantities(ctx, tx, buildWarehouseDeltas(nil, nil, existingEggs, mappedEggs)); err != nil { + s.Log.Errorf("Failed to adjust product warehouses for eggs: %+v", err) + return err + } } } @@ -626,6 +653,11 @@ func (s recordingService) DeleteOne(c *fiber.Ctx, id uint) error { s.Log.Errorf("Failed to list eggs before delete: %+v", err) return err } + if s.FifoSvc != nil { + if err := ensureRecordingEggsUnused(oldEggs); err != nil { + return err + } + } oldStocks, err := s.Repository.ListStocks(tx, id) if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { @@ -802,6 +834,32 @@ func (s *recordingService) adjustProductWarehouseQuantities(ctx context.Context, return s.ProductWarehouseRepo.AdjustQuantities(ctx, deltas, func(*gorm.DB) *gorm.DB { return tx }) } +func (s *recordingService) replenishRecordingEggs(ctx context.Context, tx *gorm.DB, eggs []entity.RecordingEgg) error { + if len(eggs) == 0 || s.FifoSvc == nil { + return nil + } + + for _, egg := range eggs { + if egg.Id == 0 || egg.ProductWarehouseId == 0 || egg.Qty <= 0 { + continue + } + note := fmt.Sprintf("Recording egg #%d", egg.Id) + if _, err := s.FifoSvc.Replenish(ctx, commonSvc.StockReplenishRequest{ + StockableKey: fifo.StockableKeyRecordingEgg, + StockableID: egg.Id, + ProductWarehouseID: egg.ProductWarehouseId, + Quantity: float64(egg.Qty), + Note: ¬e, + Tx: tx, + }); err != nil { + s.Log.Errorf("Failed to replenish FIFO stock for recording egg %d: %+v", egg.Id, err) + return err + } + } + + return nil +} + type desiredStock struct { Usage float64 Pending float64 @@ -922,6 +980,14 @@ type eggTotals struct { Weight float64 } +func ensureRecordingEggsUnused(eggs []entity.RecordingEgg) error { + for _, egg := range eggs { + if egg.TotalUsed > 0 { + return fiber.NewError(fiber.StatusBadRequest, "Recording egg sudah digunakan sehingga tidak dapat diubah") + } + } + return nil +} func stocksMatch(existing []entity.RecordingStock, incoming []validation.Stock) bool { hasPending := false diff --git a/internal/utils/fifo/constants.go b/internal/utils/fifo/constants.go index 03f61f82..076d960d 100644 --- a/internal/utils/fifo/constants.go +++ b/internal/utils/fifo/constants.go @@ -15,4 +15,5 @@ const ( StockableKeyAdjustmentIn StockableKey = "ADJUSTMENT_IN" StockableKeyPurchaseItems StockableKey = "PURCHASE_ITEMS" StockableKeyProjectFlockPopulation StockableKey = "PROJECT_FLOCK_POPULATION" + StockableKeyRecordingEgg StockableKey = "RECORDING_EGG" ) From f1032b44d17a7dc96487f35a7f0f0607b463feb1 Mon Sep 17 00:00:00 2001 From: ragilap Date: Thu, 15 Jan 2026 15:42:57 +0700 Subject: [PATCH 49/76] [FIX/BE-US] adjustment recording --- .../controllers/projectflock.controller.go | 12 +++++++----- .../dto/projectflock_kandang.dto.go | 18 ++++++++++-------- 2 files changed, 17 insertions(+), 13 deletions(-) diff --git a/internal/modules/production/project_flocks/controllers/projectflock.controller.go b/internal/modules/production/project_flocks/controllers/projectflock.controller.go index c13baab6..e82d3af5 100644 --- a/internal/modules/production/project_flocks/controllers/projectflock.controller.go +++ b/internal/modules/production/project_flocks/controllers/projectflock.controller.go @@ -279,9 +279,14 @@ func (u *ProjectflockController) LookupProjectFlockKandang(c *fiber.Ctx) error { if err != nil { return err } + _ = availableStock dtoResult := dto.ToProjectFlockKandangDTO(*result) - dtoResult.AvailableQuantity = float64(availableStock) + if population, err := u.ProjectflockService.GetProjectFlockKandangPopulation(c, result.Id); err != nil { + return err + } else { + dtoResult.AvailableQuantity = population + } if warehouse, werr := u.ProjectflockService.GetWarehouseByKandangID(c, result.KandangId); werr != nil { return werr } else if warehouse != nil { @@ -289,10 +294,7 @@ func (u *ProjectflockController) LookupProjectFlockKandang(c *fiber.Ctx) error { dtoResult.Warehouse = &mapped } if withPopulation { - population, err := u.ProjectflockService.GetProjectFlockKandangPopulation(c, result.Id) - if err != nil { - return err - } + population := dtoResult.AvailableQuantity dtoResult.Population = &population } diff --git a/internal/modules/production/project_flocks/dto/projectflock_kandang.dto.go b/internal/modules/production/project_flocks/dto/projectflock_kandang.dto.go index b6bbf852..c18f3f65 100644 --- a/internal/modules/production/project_flocks/dto/projectflock_kandang.dto.go +++ b/internal/modules/production/project_flocks/dto/projectflock_kandang.dto.go @@ -18,13 +18,14 @@ type KandangWithPivotDTO struct { type ProjectFlockWithPivotDTO struct { ProjectFlockRelationDTO - Area *areaDTO.AreaRelationDTO `json:"area,omitempty"` - Category string `json:"category"` - Fcr *fcrDTO.FcrRelationDTO `json:"fcr,omitempty"` - ProductionStandard *productionStandardDTO.ProductionStandardRelationDTO `json:"production_standard,omitempty"` - Location *locationDTO.LocationRelationDTO `json:"location,omitempty"` - Kandangs []KandangWithPivotDTO `json:"kandangs,omitempty"` - CreatedUser *userDTO.UserRelationDTO `json:"created_user,omitempty"` + Area *areaDTO.AreaRelationDTO `json:"area,omitempty"` + Category string `json:"category"` + Fcr *fcrDTO.FcrRelationDTO `json:"fcr,omitempty"` + ProductionStandard *productionStandardDTO.ProductionStandardRelationDTO `json:"production_standard,omitempty"` + ProductionStandardId uint `json:"production_standard_id"` + Location *locationDTO.LocationRelationDTO `json:"location,omitempty"` + Kandangs []KandangWithPivotDTO `json:"kandangs,omitempty"` + CreatedUser *userDTO.UserRelationDTO `json:"created_user,omitempty"` } type ProjectFlockKandangDTO struct { @@ -55,7 +56,8 @@ func ToProjectFlockKandangDTO(e entity.ProjectFlockKandang) ProjectFlockKandangD Period: e.Period, FlockName: e.ProjectFlock.FlockName, }, - Category: e.ProjectFlock.Category, + Category: e.ProjectFlock.Category, + ProductionStandardId: e.ProjectFlock.ProductionStandardId, } if e.ProjectFlock.Area.Id != 0 { From fe002c9602792ef43a8cc81dd65ad7fd1fa30d52 Mon Sep 17 00:00:00 2001 From: "Hafizh A. Y" Date: Thu, 15 Jan 2026 15:49:15 +0700 Subject: [PATCH 50/76] fix(BE): remove supplier price in master nonstock --- ...ove_price_from_nonstock_suppliers.down.sql | 3 ++ ...emove_price_from_nonstock_suppliers.up.sql | 3 ++ internal/entities/nonstock_supplier.go | 1 - .../master/nonstocks/dto/nonstock.dto.go | 2 - .../repositories/nonstock.repository.go | 16 ++----- .../nonstocks/services/nonstock.service.go | 44 +++++++++---------- .../validations/nonstock.validation.go | 21 ++++----- .../suppliers/dto/supplier_nonstock.dto.go | 2 - 8 files changed, 38 insertions(+), 54 deletions(-) create mode 100644 internal/database/migrations/20260115082849_remove_price_from_nonstock_suppliers.down.sql create mode 100644 internal/database/migrations/20260115082849_remove_price_from_nonstock_suppliers.up.sql diff --git a/internal/database/migrations/20260115082849_remove_price_from_nonstock_suppliers.down.sql b/internal/database/migrations/20260115082849_remove_price_from_nonstock_suppliers.down.sql new file mode 100644 index 00000000..503f592d --- /dev/null +++ b/internal/database/migrations/20260115082849_remove_price_from_nonstock_suppliers.down.sql @@ -0,0 +1,3 @@ +-- Rollback: add price back to nonstock_suppliers +ALTER TABLE nonstock_suppliers + ADD COLUMN IF NOT EXISTS price NUMERIC(15, 3) NOT NULL DEFAULT 0; diff --git a/internal/database/migrations/20260115082849_remove_price_from_nonstock_suppliers.up.sql b/internal/database/migrations/20260115082849_remove_price_from_nonstock_suppliers.up.sql new file mode 100644 index 00000000..07fdd009 --- /dev/null +++ b/internal/database/migrations/20260115082849_remove_price_from_nonstock_suppliers.up.sql @@ -0,0 +1,3 @@ +-- Migration: remove price from nonstock_suppliers +ALTER TABLE nonstock_suppliers + DROP COLUMN IF EXISTS price; diff --git a/internal/entities/nonstock_supplier.go b/internal/entities/nonstock_supplier.go index d666e3c8..2206390c 100644 --- a/internal/entities/nonstock_supplier.go +++ b/internal/entities/nonstock_supplier.go @@ -5,7 +5,6 @@ import "time" type NonstockSupplier struct { NonstockId uint `gorm:"not null"` SupplierId uint `gorm:"not null"` - Price float64 `gorm:"type:numeric(15,3);not null;default:0"` CreatedAt time.Time `gorm:"autoCreateTime"` Nonstock Nonstock `gorm:"foreignKey:NonstockId;references:Id"` diff --git a/internal/modules/master/nonstocks/dto/nonstock.dto.go b/internal/modules/master/nonstocks/dto/nonstock.dto.go index fa102b9c..8182da21 100644 --- a/internal/modules/master/nonstocks/dto/nonstock.dto.go +++ b/internal/modules/master/nonstocks/dto/nonstock.dto.go @@ -37,7 +37,6 @@ type NonstockSupplierDTO struct { Name string `json:"name"` Alias string `json:"alias"` Category string `json:"category"` - Price float64 `json:"price"` } // === Mapper Functions === @@ -121,7 +120,6 @@ func toNonstockSupplierDTOs(relations []entity.NonstockSupplier) []NonstockSuppl Name: relation.Supplier.Name, Alias: relation.Supplier.Alias, Category: relation.Supplier.Category, - Price: relation.Price, }) } diff --git a/internal/modules/master/nonstocks/repositories/nonstock.repository.go b/internal/modules/master/nonstocks/repositories/nonstock.repository.go index 16260272..56ef39b8 100644 --- a/internal/modules/master/nonstocks/repositories/nonstock.repository.go +++ b/internal/modules/master/nonstocks/repositories/nonstock.repository.go @@ -61,30 +61,20 @@ func (r *NonstockRepositoryImpl) SyncSuppliersDiff(ctx context.Context, tx *gorm return err } - existingMap := make(map[uint]entity.NonstockSupplier, len(existing)) + existingMap := make(map[uint]struct{}, len(existing)) for _, rel := range existing { - existingMap[rel.SupplierId] = rel + existingMap[rel.SupplierId] = struct{}{} } incomingMap := make(map[uint]struct{}, len(suppliers)) for _, rel := range suppliers { incomingMap[rel.SupplierId] = struct{}{} - if existingRel, exists := existingMap[rel.SupplierId]; exists { - if existingRel.Price != rel.Price { - if err := db.WithContext(ctx). - Model(&entity.NonstockSupplier{}). - Where("nonstock_id = ? AND supplier_id = ?", nonstockID, rel.SupplierId). - Update("price", rel.Price). - Error; err != nil { - return err - } - } + if _, exists := existingMap[rel.SupplierId]; exists { continue } record := entity.NonstockSupplier{ NonstockId: nonstockID, SupplierId: rel.SupplierId, - Price: rel.Price, } if err := db.WithContext(ctx).Create(&record).Error; err != nil { return err diff --git a/internal/modules/master/nonstocks/services/nonstock.service.go b/internal/modules/master/nonstocks/services/nonstock.service.go index e1cc5495..1b6a409c 100644 --- a/internal/modules/master/nonstocks/services/nonstock.service.go +++ b/internal/modules/master/nonstocks/services/nonstock.service.go @@ -115,19 +115,18 @@ func (s *nonstockService) CreateOne(c *fiber.Ctx, req *validation.Create) (*enti supplierLinks []entity.NonstockSupplier supplierIDs []uint ) - if len(req.Suppliers) > 0 { - seen := make(map[uint]struct{}, len(req.Suppliers)) - supplierLinks = make([]entity.NonstockSupplier, 0, len(req.Suppliers)) - supplierIDs = make([]uint, 0, len(req.Suppliers)) - for _, supplier := range req.Suppliers { - if _, exists := seen[supplier.SupplierID]; exists { - return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Duplicate supplier_id %d", supplier.SupplierID)) + if len(req.SupplierIDs) > 0 { + seen := make(map[uint]struct{}, len(req.SupplierIDs)) + supplierLinks = make([]entity.NonstockSupplier, 0, len(req.SupplierIDs)) + supplierIDs = make([]uint, 0, len(req.SupplierIDs)) + for _, supplierID := range req.SupplierIDs { + if _, exists := seen[supplierID]; exists { + return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Duplicate supplier_id %d", supplierID)) } - seen[supplier.SupplierID] = struct{}{} - supplierIDs = append(supplierIDs, supplier.SupplierID) + seen[supplierID] = struct{}{} + supplierIDs = append(supplierIDs, supplierID) supplierLinks = append(supplierLinks, entity.NonstockSupplier{ - SupplierId: supplier.SupplierID, - Price: supplier.Price, + SupplierId: supplierID, }) } supplierList, supplierErr := s.Repository.GetSuppliersByIDs(ctx, supplierIDs) @@ -212,21 +211,20 @@ func (s nonstockService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint var supplierLinks []entity.NonstockSupplier var supplierUpdate bool - if req.Suppliers != nil { + if req.SupplierIDs != nil { supplierUpdate = true - if len(*req.Suppliers) > 0 { - seen := make(map[uint]struct{}, len(*req.Suppliers)) - supplierLinks = make([]entity.NonstockSupplier, 0, len(*req.Suppliers)) - supplierIDs := make([]uint, 0, len(*req.Suppliers)) - for _, supplier := range *req.Suppliers { - if _, exists := seen[supplier.SupplierID]; exists { - return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Duplicate supplier_id %d", supplier.SupplierID)) + if len(*req.SupplierIDs) > 0 { + seen := make(map[uint]struct{}, len(*req.SupplierIDs)) + supplierLinks = make([]entity.NonstockSupplier, 0, len(*req.SupplierIDs)) + supplierIDs := make([]uint, 0, len(*req.SupplierIDs)) + for _, supplierID := range *req.SupplierIDs { + if _, exists := seen[supplierID]; exists { + return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Duplicate supplier_id %d", supplierID)) } - seen[supplier.SupplierID] = struct{}{} - supplierIDs = append(supplierIDs, supplier.SupplierID) + seen[supplierID] = struct{}{} + supplierIDs = append(supplierIDs, supplierID) supplierLinks = append(supplierLinks, entity.NonstockSupplier{ - SupplierId: supplier.SupplierID, - Price: supplier.Price, + SupplierId: supplierID, }) } diff --git a/internal/modules/master/nonstocks/validations/nonstock.validation.go b/internal/modules/master/nonstocks/validations/nonstock.validation.go index c5491991..6378ac18 100644 --- a/internal/modules/master/nonstocks/validations/nonstock.validation.go +++ b/internal/modules/master/nonstocks/validations/nonstock.validation.go @@ -1,22 +1,17 @@ package validation -type SupplierPrice struct { - SupplierID uint `json:"supplier_id" validate:"required,gt=0"` - Price float64 `json:"price" validate:"required,gte=0"` -} - type Create struct { - Name string `json:"name" validate:"required_strict,min=3,max=50"` - UomID uint `json:"uom_id" validate:"required,gt=0"` - Suppliers []SupplierPrice `json:"suppliers,omitempty" validate:"omitempty,dive"` - Flags []string `json:"flags" validate:"dive,max=50"` + Name string `json:"name" validate:"required_strict,min=3,max=50"` + UomID uint `json:"uom_id" validate:"required,gt=0"` + SupplierIDs []uint `json:"supplier_ids,omitempty" validate:"omitempty,dive,gt=0"` + Flags []string `json:"flags" validate:"dive,max=50"` } type Update struct { - Name *string `json:"name,omitempty" validate:"omitempty,min=3,max=50"` - UomID *uint `json:"uom_id,omitempty" validate:"omitempty,gt=0"` - Suppliers *[]SupplierPrice `json:"suppliers,omitempty" validate:"omitempty,dive"` - Flags *[]string `json:"flags,omitempty" validate:"omitempty,dive,max=50"` + Name *string `json:"name,omitempty" validate:"omitempty,min=3,max=50"` + UomID *uint `json:"uom_id,omitempty" validate:"omitempty,gt=0"` + SupplierIDs *[]uint `json:"supplier_ids,omitempty" validate:"omitempty,dive,gt=0"` + Flags *[]string `json:"flags,omitempty" validate:"omitempty,dive,max=50"` } type Query struct { diff --git a/internal/modules/master/suppliers/dto/supplier_nonstock.dto.go b/internal/modules/master/suppliers/dto/supplier_nonstock.dto.go index 8c5e0082..828063eb 100644 --- a/internal/modules/master/suppliers/dto/supplier_nonstock.dto.go +++ b/internal/modules/master/suppliers/dto/supplier_nonstock.dto.go @@ -10,7 +10,6 @@ import ( type SupplierNonstockDTO struct { Id uint `json:"id"` Name string `json:"name"` - Price float64 `json:"price"` Uom *uomDTO.UomRelationDTO `json:"uom,omitempty"` Flags []string `json:"flags"` } @@ -43,7 +42,6 @@ func toSupplierNonstockDTOs(relations []entity.NonstockSupplier) []SupplierNonst result = append(result, SupplierNonstockDTO{ Id: Nonstock.Id, Name: Nonstock.Name, - Price: relation.Price, Uom: uomRef, Flags: flags, }) From 89293a843e566d852938716790de14dd18994a27 Mon Sep 17 00:00:00 2001 From: giovanni Date: Thu, 15 Jan 2026 16:07:45 +0700 Subject: [PATCH 51/76] adjust api hpp kandang --- .../modules/repports/dto/repportHpp.dto.go | 58 +++---- .../hpp_per_kandang.repository.go | 160 +++++++++++++----- .../repports/services/repport.service.go | 108 +++++++++--- 3 files changed, 227 insertions(+), 99 deletions(-) diff --git a/internal/modules/repports/dto/repportHpp.dto.go b/internal/modules/repports/dto/repportHpp.dto.go index 63c5dce9..f790244c 100644 --- a/internal/modules/repports/dto/repportHpp.dto.go +++ b/internal/modules/repports/dto/repportHpp.dto.go @@ -25,14 +25,12 @@ type HppPerKandangResponseData struct { } type HppPerKandangRowDTO struct { - ID int `json:"id"` - Kandang HppPerKandangRowKandangDTO `json:"kandang"` - WeightRange HppPerKandangWeightRangeDTO `json:"weight_range"` - RemainingChickenBirds int64 `json:"remaining_chicken_birds"` - RemainingChickenWeightKg float64 `json:"remaining_chicken_weight_kg"` - AvgWeightKg float64 `json:"avg_weight_kg"` - EggProductionPieces int64 `json:"egg_production_pieces"` - EggProductionKg float64 `json:"egg_production_kg"` + ID int `json:"id"` + Kandang HppPerKandangRowKandangDTO `json:"kandang"` + WeightRange HppPerKandangWeightRangeDTO `json:"weight_range"` + AvgWeightKg float64 `json:"avg_weight_kg"` + EggProductionPieces int64 `json:"egg_production_pieces"` + EggProductionKg float64 `json:"egg_production_kg"` // FeedCostRp float64 `json:"feed_cost_rp"` // OvkCostRp float64 `json:"ovk_cost_rp"` EggHppRpPerKg float64 `json:"egg_hpp_rp_per_kg"` @@ -80,34 +78,28 @@ type HppPerKandangSummaryDTO struct { } type HppPerKandangSummaryWeightRangeDTO struct { - ID int `json:"id"` - WeightRange HppPerKandangWeightRangeDTO `json:"weight_range"` - Label string `json:"label"` - RemainingChickenBirds int64 `json:"remaining_chicken_birds"` - RemainingChickenWeightKg float64 `json:"remaining_chicken_weight_kg"` - AvgWeightKg float64 `json:"avg_weight_kg"` - EggProductionPieces int64 `json:"egg_production_pieces"` - EggProductionKg float64 `json:"egg_production_kg"` - EggHppRpPerKg float64 `json:"egg_hpp_rp_per_kg"` - EggValueRp int64 `json:"egg_value_rp"` - FeedSuppliers []HppPerKandangSupplierDTO `json:"feed_suppliers"` - DocSuppliers []HppPerKandangSupplierDTO `json:"doc_suppliers"` - AverageDocPriceRp float64 `json:"average_doc_price_rp"` - HppRp float64 `json:"hpp_rp"` - RemainingValueRp int64 `json:"remaining_value_rp"` + ID int `json:"id"` + WeightRange HppPerKandangWeightRangeDTO `json:"weight_range"` + Label string `json:"label"` + AvgWeightKg float64 `json:"avg_weight_kg"` + EggProductionPieces int64 `json:"egg_production_pieces"` + EggProductionKg float64 `json:"egg_production_kg"` + EggHppRpPerKg float64 `json:"egg_hpp_rp_per_kg"` + EggValueRp int64 `json:"egg_value_rp"` + FeedSuppliers []HppPerKandangSupplierDTO `json:"feed_suppliers"` + DocSuppliers []HppPerKandangSupplierDTO `json:"doc_suppliers"` + AverageDocPriceRp float64 `json:"average_doc_price_rp"` + HppRp float64 `json:"hpp_rp"` + RemainingValueRp int64 `json:"remaining_value_rp"` } type HppPerKandangSummaryTotalDTO struct { - TotalRemainingChickenBirds int64 `json:"total_remaining_chicken_birds"` - TotalRemainingChickenWeightKg float64 `json:"total_remaining_chicken_weight_kg"` - AverageWeightKg float64 `json:"average_weight_kg"` - TotalRemainingValueRp int64 `json:"total_remaining_value_rp"` - TotalEggProductionPieces int64 `json:"total_egg_production_pieces"` - TotalEggProductionKg float64 `json:"total_egg_production_kg"` - AverageEggHppRpPerKg float64 `json:"average_egg_hpp_rp_per_kg"` - TotalEggValueRp int64 `json:"total_egg_value_rp"` - TotalHppRp float64 `json:"total_hpp_rp"` - TotalAverageDocPriceRp float64 `json:"total_average_doc_price_rp"` + AverageWeightKg float64 `json:"average_weight_kg"` + TotalEggProductionPieces int64 `json:"total_egg_production_pieces"` + TotalEggProductionKg float64 `json:"total_egg_production_kg"` + AverageEggHppRpPerKg float64 `json:"average_egg_hpp_rp_per_kg"` + TotalEggValueRp int64 `json:"total_egg_value_rp"` + TotalAverageDocPriceRp float64 `json:"total_average_doc_price_rp"` } func NewHppPerKandangFiltersDTO(area, location, kandang, weightMin, weightMax, period, showUnrecorded string) HppPerKandangFiltersDTO { diff --git a/internal/modules/repports/repositories/hpp_per_kandang.repository.go b/internal/modules/repports/repositories/hpp_per_kandang.repository.go index 4bd9aab4..64676ca8 100644 --- a/internal/modules/repports/repositories/hpp_per_kandang.repository.go +++ b/internal/modules/repports/repositories/hpp_per_kandang.repository.go @@ -11,6 +11,7 @@ import ( ) type HppPerKandangRow struct { + ProjectFlockKandangID uint KandangID uint KandangName string KandangStatus string @@ -18,6 +19,7 @@ type HppPerKandangRow struct { LocationName string PicID uint PicName string + RecordingCount int64 RemainingChickenBirds float64 RemainingChickenWeight float64 EggProductionWeightKg float64 @@ -44,7 +46,8 @@ type HppPerKandangSupplierRow struct { type HppPerKandangRepository interface { GetRowsByPeriod(ctx context.Context, start, end time.Time, areaIDs, locationIDs, kandangIDs []int64) ([]HppPerKandangRow, error) - GetFeedOvkDocCostByPeriod(ctx context.Context, start, end time.Time, areaIDs, locationIDs, kandangIDs []int64) ([]HppPerKandangCostRow, []HppPerKandangSupplierRow, error) + GetFeedOvkDocCostByPeriod(ctx context.Context, start, end time.Time, projectFlockKandangIDs []uint) ([]HppPerKandangCostRow, []HppPerKandangSupplierRow, error) + GetEggProductionByProjectFlockKandangIDs(ctx context.Context, start, end time.Time, projectFlockKandangIDs []uint) (map[uint]HppPerKandangRow, error) } type hppPerKandangRepository struct { @@ -58,9 +61,31 @@ func NewHppPerKandangRepository(db *gorm.DB) HppPerKandangRepository { func (r *hppPerKandangRepository) GetRowsByPeriod(ctx context.Context, start, end time.Time, areaIDs, locationIDs, kandangIDs []int64) ([]HppPerKandangRow, error) { var rows []HppPerKandangRow - query := r.db.WithContext(ctx). + latestApproval := r.db.WithContext(ctx). + Table("approvals AS a"). + Select("a.approvable_id, a.action"). + Joins(` + JOIN ( + SELECT approvable_id, MAX(action_at) AS latest_action_at + FROM approvals + WHERE approvable_type = ? + GROUP BY approvable_id + ) AS la ON la.approvable_id = a.approvable_id AND la.latest_action_at = a.action_at`, + string(utils.ApprovalWorkflowRecording), + ) + + validRecordings := r.db.WithContext(ctx). Table("recordings AS r"). + Select("r.id, r.project_flock_kandangs_id, r.total_chick_qty"). + Joins("LEFT JOIN (?) AS la ON la.approvable_id = r.id", latestApproval). + Where("r.record_datetime >= ? AND r.record_datetime < ?", start, end). + Where("r.deleted_at IS NULL"). + Where("(la.action IS NULL OR la.action != ?)", string(entity.ApprovalActionRejected)) + + query := r.db.WithContext(ctx). + Table("project_flocks AS pf"). Select(` + pfk.id AS project_flock_kandang_id, k.id AS kandang_id, k.name AS kandang_name, k.status AS kandang_status, @@ -68,22 +93,21 @@ func (r *hppPerKandangRepository) GetRowsByPeriod(ctx context.Context, start, en loc.name AS location_name, pic.id AS pic_id, pic.name AS pic_name, - COALESCE(MAX(r.total_chick_qty), 0) AS remaining_chicken_birds, - COALESCE(SUM(rbw.total_weight), 0) AS remaining_chicken_weight, - COALESCE(SUM(re.weight), 0) AS egg_production_weight_kg, - COALESCE(SUM(re.qty), 0) AS egg_production_pieces`). - Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = r.project_flock_kandangs_id"). + COALESCE(COUNT(vr.id), 0) AS recording_count, + COALESCE(MAX(vr.total_chick_qty), 0) AS remaining_chicken_birds, + 0 AS remaining_chicken_weight, + 0 AS egg_production_weight_kg, + 0 AS egg_production_pieces`). + Joins("JOIN project_flock_kandangs AS pfk ON pfk.project_flock_id = pf.id"). Joins("JOIN kandangs AS k ON k.id = pfk.kandang_id"). Joins("JOIN locations AS loc ON loc.id = k.location_id"). Joins("JOIN users AS pic ON pic.id = k.pic_id"). - Joins("LEFT JOIN recording_bws AS rbw ON rbw.recording_id = r.id"). - Joins("LEFT JOIN recording_eggs AS re ON re.recording_id = r.id"). - Where("r.record_datetime >= ? AND r.record_datetime < ?", start, end). - Where("r.deleted_at IS NULL") + Joins("LEFT JOIN (?) AS vr ON vr.project_flock_kandangs_id = pfk.id", validRecordings). + Where("pfk.closed_at IS NULL") query = applyLocationFilters(query, areaIDs, locationIDs, kandangIDs) - query = query.Group("k.id, k.name, k.status, loc.id, loc.name, pic.id, pic.name"). + query = query.Group("pfk.id, k.id, k.name, k.status, loc.id, loc.name, pic.id, pic.name"). Order("k.id ASC") if err := query.Scan(&rows).Error; err != nil { @@ -93,41 +117,44 @@ func (r *hppPerKandangRepository) GetRowsByPeriod(ctx context.Context, start, en return rows, nil } -func (r *hppPerKandangRepository) GetFeedOvkDocCostByPeriod(ctx context.Context, start, end time.Time, areaIDs, locationIDs, kandangIDs []int64) ([]HppPerKandangCostRow, []HppPerKandangSupplierRow, error) { +func (r *hppPerKandangRepository) GetFeedOvkDocCostByPeriod(ctx context.Context, start, end time.Time, projectFlockKandangIDs []uint) ([]HppPerKandangCostRow, []HppPerKandangSupplierRow, error) { var rows []HppPerKandangCostRow - recordingPfk := r.db.WithContext(ctx). - Table("recordings AS r"). - Select("DISTINCT pfk.id"). - Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = r.project_flock_kandangs_id"). - Joins("JOIN kandangs AS k ON k.id = pfk.kandang_id"). - Joins("JOIN locations AS loc ON loc.id = k.location_id"). - Where("r.record_datetime >= ? AND r.record_datetime < ?", start, end). - Where("r.deleted_at IS NULL") - recordingPfk = applyLocationFilters(recordingPfk, areaIDs, locationIDs, kandangIDs) - purchaseStockableKey := fifo.StockableKeyPurchaseItems.String() transferStockableKey := fifo.StockableKeyStockTransferIn.String() + latestApproval := r.db.WithContext(ctx). + Table("approvals AS a"). + Select("a.approvable_id, a.action"). + Joins(` + JOIN ( + SELECT approvable_id, MAX(action_at) AS latest_action_at + FROM approvals + WHERE approvable_type = ? + GROUP BY approvable_id + ) AS la ON la.approvable_id = a.approvable_id AND la.latest_action_at = a.action_at`, + string(utils.ApprovalWorkflowRecording), + ) + query := r.db.WithContext(ctx). Table("recordings AS r"). Select(` k.id AS kandang_id, COALESCE(SUM(CASE WHEN f.name = ? THEN COALESCE(sa.qty, 0) * COALESCE(pi.price, 0) - WHEN sa.stockable_type = ? AND tf.name = ? THEN COALESCE(std.quantity, 0) * COALESCE(tpi.price, 0) + WHEN sa.stockable_type = ? AND tf.name = ? THEN COALESCE(std.total_qty, 0) * COALESCE(tpi.price, 0) ELSE 0 END), 0) AS feed_cost, COALESCE(SUM(CASE WHEN f.name = ? THEN COALESCE(sa.qty, 0) * COALESCE(pi.price, 0) - WHEN sa.stockable_type = ? AND tf.name = ? THEN COALESCE(std.quantity, 0) * COALESCE(tpi.price, 0) + WHEN sa.stockable_type = ? AND tf.name = ? THEN COALESCE(std.total_qty, 0) * COALESCE(tpi.price, 0) ELSE 0 END), 0) AS ovk_cost`, utils.FlagPakan, transferStockableKey, utils.FlagPakan, utils.FlagOVK, transferStockableKey, utils.FlagOVK). Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = r.project_flock_kandangs_id"). Joins("JOIN kandangs AS k ON k.id = pfk.kandang_id"). - Joins("JOIN locations AS loc ON loc.id = k.location_id"). + Joins("LEFT JOIN (?) AS la ON la.approvable_id = r.id", latestApproval). Joins("LEFT JOIN recording_stocks AS rs ON rs.recording_id = r.id"). Joins("LEFT JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = rs.id AND sa.status = ?", fifo.UsableKeyRecordingStock.String(), entity.StockAllocationStatusActive). Joins("LEFT JOIN purchase_items AS pi ON pi.id = sa.stockable_id AND sa.stockable_type = ?", purchaseStockableKey). @@ -136,11 +163,10 @@ func (r *hppPerKandangRepository) GetFeedOvkDocCostByPeriod(ctx context.Context, Joins("LEFT JOIN purchase_items AS tpi ON tpi.product_id = std.product_id AND tpi.warehouse_id = st.from_warehouse_id"). Joins("LEFT JOIN flags AS f ON f.flagable_id = pi.product_id AND f.flagable_type = ?", entity.FlagableTypeProduct). Joins("LEFT JOIN flags AS tf ON tf.flagable_id = std.product_id AND tf.flagable_type = ?", entity.FlagableTypeProduct). - Where("r.project_flock_kandangs_id IN (?)", recordingPfk.Session(&gorm.Session{NewDB: true})). - Where("r.record_datetime >= ? AND r.record_datetime < ?", start, end). - Where("r.deleted_at IS NULL") - - query = applyLocationFilters(query, areaIDs, locationIDs, kandangIDs) + Where("r.project_flock_kandangs_id IN ?", projectFlockKandangIDs). + // Where("r.record_datetime >= ? AND r.record_datetime < ?", start, end). + Where("r.deleted_at IS NULL"). + Where("(la.action IS NULL OR la.action != ?)", string(entity.ApprovalActionRejected)) query = query.Group("k.id").Order("k.id ASC") @@ -172,9 +198,8 @@ func (r *hppPerKandangRepository) GetFeedOvkDocCostByPeriod(ctx context.Context, Joins("LEFT JOIN purchase_items AS pi ON pi.product_warehouse_id = pc.product_warehouse_id"). Joins("LEFT JOIN purchases AS pur ON pur.id = pi.purchase_id"). Joins("LEFT JOIN suppliers AS s ON s.id = pur.supplier_id"). - Where("pc.project_flock_kandang_id IN (?)", recordingPfk.Session(&gorm.Session{NewDB: true})). + Where("pc.project_flock_kandang_id IN ?", projectFlockKandangIDs). Group("pfk.kandang_id, s.id, s.name, s.alias") - docQuery = applyLocationFilters(docQuery, areaIDs, locationIDs, kandangIDs) if err := docQuery.Scan(&docRows).Error; err != nil { return nil, nil, err @@ -254,9 +279,9 @@ func (r *hppPerKandangRepository) GetFeedOvkDocCostByPeriod(ctx context.Context, Joins("JOIN project_budgets AS pb ON pb.project_flock_id = pfk.project_flock_id"). Joins("LEFT JOIN (?) AS k_usage ON k_usage.project_flock_kandang_id = pfk.id", pfkUsageSub). Joins("LEFT JOIN (?) AS p_usage ON p_usage.project_flock_id = pfk.project_flock_id", projectUsageSub). - Where("pfk.id IN (?)", recordingPfk.Session(&gorm.Session{NewDB: true})). + Where("pfk.id IN (?)", projectFlockKandangIDs). Group("k.id") - budgetQuery = applyLocationFilters(budgetQuery, areaIDs, locationIDs, kandangIDs) + // budgetQuery = applyLocationFilters(budgetQuery, areaIDs, locationIDs, kandangIDs) if err := budgetQuery.Scan(&budgetRows).Error; err != nil { return nil, nil, err @@ -288,9 +313,9 @@ func (r *hppPerKandangRepository) GetFeedOvkDocCostByPeriod(ctx context.Context, Joins("JOIN locations AS loc ON loc.id = k.location_id"). Joins("JOIN expense_nonstocks AS en ON en.project_flock_kandang_id = pfk.id"). Joins("JOIN expense_realizations AS er ON er.expense_nonstock_id = en.id"). - Where("pfk.id IN (?)", recordingPfk.Session(&gorm.Session{NewDB: true})). + Where("pfk.id IN (?)", projectFlockKandangIDs). Group("k.id") - expenseQuery = applyLocationFilters(expenseQuery, areaIDs, locationIDs, kandangIDs) + // expenseQuery = applyLocationFilters(expenseQuery, areaIDs, locationIDs, kandangIDs) if err := expenseQuery.Scan(&expenseRows).Error; err != nil { return nil, nil, err @@ -323,10 +348,10 @@ func (r *hppPerKandangRepository) GetFeedOvkDocCostByPeriod(ctx context.Context, Joins("LEFT JOIN suppliers AS s ON s.id = pur.supplier_id"). Joins("LEFT JOIN flags AS f ON f.flagable_id = pi.product_id AND f.flagable_type = ?", entity.FlagableTypeProduct). Where("f.name IN ?", []utils.FlagType{utils.FlagPakan, utils.FlagOVK}). - Where("r.project_flock_kandangs_id IN (?)", recordingPfk.Session(&gorm.Session{NewDB: true})). - Where("r.record_datetime >= ? AND r.record_datetime < ?", start, end). + Where("r.project_flock_kandangs_id IN (?)", projectFlockKandangIDs). + // Where("r.record_datetime >= ? AND r.record_datetime < ?", start, end). Where("r.deleted_at IS NULL") - feedQuery = applyLocationFilters(feedQuery, areaIDs, locationIDs, kandangIDs) + // feedQuery = applyLocationFilters(feedQuery, areaIDs, locationIDs, kandangIDs) if err := feedQuery.Scan(&feedSuppliers).Error; err != nil { return nil, nil, err @@ -347,6 +372,61 @@ func (r *hppPerKandangRepository) GetFeedOvkDocCostByPeriod(ctx context.Context, return rows, supplierRows, nil } +func (r *hppPerKandangRepository) GetEggProductionByProjectFlockKandangIDs(ctx context.Context, start, end time.Time, projectFlockKandangIDs []uint) (map[uint]HppPerKandangRow, error) { + if len(projectFlockKandangIDs) == 0 { + return map[uint]HppPerKandangRow{}, nil + } + + latestApproval := r.db.WithContext(ctx). + Table("approvals AS a"). + Select("a.approvable_id, a.action"). + Joins(` + JOIN ( + SELECT approvable_id, MAX(action_at) AS latest_action_at + FROM approvals + WHERE approvable_type = ? + GROUP BY approvable_id + ) AS la ON la.approvable_id = a.approvable_id AND la.latest_action_at = a.action_at`, + string(utils.ApprovalWorkflowRecording), + ) + + type eggRow struct { + ProjectFlockKandangID uint + EggProductionWeightKg float64 + EggProductionPieces float64 + } + + eggRows := make([]eggRow, 0) + query := r.db.WithContext(ctx). + Table("recordings AS r"). + Select(` + r.project_flock_kandangs_id AS project_flock_kandang_id, + COALESCE(SUM(re.weight), 0) AS egg_production_weight_kg, + COALESCE(SUM(re.qty), 0) AS egg_production_pieces`). + Joins("LEFT JOIN (?) AS la ON la.approvable_id = r.id", latestApproval). + Joins("LEFT JOIN recording_eggs AS re ON re.recording_id = r.id"). + Where("r.project_flock_kandangs_id IN ?", projectFlockKandangIDs). + // Where("r.record_datetime >= ? AND r.record_datetime < ?", start, end). + Where("r.deleted_at IS NULL"). + Where("(la.action IS NULL OR la.action != ?)", string(entity.ApprovalActionRejected)). + Group("r.project_flock_kandangs_id") + + if err := query.Scan(&eggRows).Error; err != nil { + return nil, err + } + + result := make(map[uint]HppPerKandangRow, len(eggRows)) + for _, row := range eggRows { + result[row.ProjectFlockKandangID] = HppPerKandangRow{ + ProjectFlockKandangID: row.ProjectFlockKandangID, + EggProductionWeightKg: row.EggProductionWeightKg, + EggProductionPieces: row.EggProductionPieces, + } + } + + return result, nil +} + func applyLocationFilters(query *gorm.DB, areaIDs, locationIDs, kandangIDs []int64) *gorm.DB { if len(areaIDs) > 0 { query = query.Where("loc.area_id IN ?", areaIDs) diff --git a/internal/modules/repports/services/repport.service.go b/internal/modules/repports/services/repport.service.go index c4883b72..9be8d8c4 100644 --- a/internal/modules/repports/services/repport.service.go +++ b/internal/modules/repports/services/repport.service.go @@ -972,10 +972,37 @@ func (s *repportService) GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangRes if err != nil { return nil, nil, err } - costRows, supplierRows, err := s.HppPerKandangRepo.GetFeedOvkDocCostByPeriod(ctx.Context(), startOfDay, endOfDay, params.AreaIDs, params.LocationIDs, params.KandangIDs) - if err != nil { - return nil, nil, err + + validPfkIDs := make([]uint, 0, len(repoRows)) + pfkIndex := make(map[uint]int, len(repoRows)) + for idx := range repoRows { + row := repoRows[idx] + pfkIndex[row.ProjectFlockKandangID] = idx + if row.RecordingCount > 0 { + validPfkIDs = append(validPfkIDs, row.ProjectFlockKandangID) + } } + + costRows := make([]repportRepo.HppPerKandangCostRow, 0) + supplierRows := make([]repportRepo.HppPerKandangSupplierRow, 0) + if len(validPfkIDs) > 0 { + costRows, supplierRows, err = s.HppPerKandangRepo.GetFeedOvkDocCostByPeriod(ctx.Context(), startOfDay, endOfDay, validPfkIDs) + if err != nil { + return nil, nil, err + } + + eggMap, err := s.HppPerKandangRepo.GetEggProductionByProjectFlockKandangIDs(ctx.Context(), startOfDay, endOfDay, validPfkIDs) + if err != nil { + return nil, nil, err + } + for pfkID, egg := range eggMap { + if rowIdx, ok := pfkIndex[pfkID]; ok { + repoRows[rowIdx].EggProductionWeightKg = egg.EggProductionWeightKg + repoRows[rowIdx].EggProductionPieces = egg.EggProductionPieces + } + } + } + costMap := make(map[uint]HppCostAggregate, len(costRows)) for _, row := range costRows { costMap[row.KandangID] = HppCostAggregate{ @@ -1028,9 +1055,13 @@ func (s *repportService) GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangRes Max float64 } type weightRangeAggregate struct { - Summary *dto.HppPerKandangSummaryWeightRangeDTO - EggHppSum float64 - EggHppCount int + Summary *dto.HppPerKandangSummaryWeightRangeDTO + RemainingBirds int64 + RemainingWeightKg float64 + EggHppSum float64 + EggHppCount int + FeedSuppliers map[int64]dto.HppPerKandangSupplierDTO + DocSuppliers map[int64]dto.HppPerKandangSupplierDTO } dataRows := make([]dto.HppPerKandangRowDTO, 0, len(repoRows)) @@ -1049,6 +1080,10 @@ func (s *repportService) GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangRes var totalEggHppCount int for _, row := range repoRows { + if !params.ShowUnrecorded && row.RecordingCount == 0 { + continue + } + birdsFloat := row.RemainingChickenBirds if math.IsNaN(birdsFloat) || math.IsInf(birdsFloat, 0) { birdsFloat = 0 @@ -1067,9 +1102,16 @@ func (s *repportService) GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangRes } avgWeight := 0.0 - if birdsFloat > 0 { - avgWeight = weightFloat / birdsFloat + if eggPiecesFloat > 0 { + avgWeight = eggWeightFloat / eggPiecesFloat } + if params.WeightMin != nil && avgWeight < *params.WeightMin { + continue + } + if params.WeightMax != nil && avgWeight > *params.WeightMax { + continue + } + weightMin := math.Floor(avgWeight*10) / 10 if weightMin < 0 { weightMin = 0 @@ -1116,9 +1158,7 @@ func (s *repportService) GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangRes WeightMin: weightMin, WeightMax: weightMax, }, - RemainingChickenBirds: rowBirds, - RemainingChickenWeightKg: weightFloat, - AvgWeightKg: avgWeight, + AvgWeightKg: avgWeight, // FeedCostRp: costEntry.FeedCost, // OvkCostRp: costEntry.OvkCost, DocSuppliers: docSupplierMap[row.KandangID], @@ -1126,10 +1166,10 @@ func (s *repportService) GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangRes EggProductionPieces: rowEggPieces, EggProductionKg: eggWeightFloat, AverageDocPriceRp: avgDocPrice, - HppRp: hppRp, - EggHppRpPerKg: eggHpp, - RemainingValueRp: rowRemainingValue, - EggValueRp: rowEggValue, + // HppRp: hppRp, + EggHppRpPerKg: eggHpp, + RemainingValueRp: rowRemainingValue, + EggValueRp: rowEggValue, }) totalBirds += rowBirds @@ -1161,13 +1201,25 @@ func (s *repportService) GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangRes }, Label: fmt.Sprintf("%.2f - %.2f", weightMin, weightMax), }, + FeedSuppliers: make(map[int64]dto.HppPerKandangSupplierDTO), + DocSuppliers: make(map[int64]dto.HppPerKandangSupplierDTO), } perRangeMap[rangeKey] = rangeAgg } rangeSummary := rangeAgg.Summary - rangeSummary.RemainingChickenBirds += rowBirds - rangeSummary.RemainingChickenWeightKg += row.RemainingChickenWeight + rangeAgg.RemainingBirds += rowBirds + rangeAgg.RemainingWeightKg += row.RemainingChickenWeight + for _, supplier := range feedSupplierMap[row.KandangID] { + if _, ok := rangeAgg.FeedSuppliers[supplier.ID]; !ok { + rangeAgg.FeedSuppliers[supplier.ID] = supplier + } + } + for _, supplier := range docSupplierMap[row.KandangID] { + if _, ok := rangeAgg.DocSuppliers[supplier.ID]; !ok { + rangeAgg.DocSuppliers[supplier.ID] = supplier + } + } rangeSummary.EggProductionPieces += rowEggPieces rangeSummary.EggProductionKg += eggWeightFloat rangeSummary.RemainingValueRp += rowRemainingValue @@ -1194,22 +1246,27 @@ func (s *repportService) GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangRes agg := perRangeMap[key] entry := agg.Summary entry.ID = idx + 1 - if entry.RemainingChickenBirds > 0 { - entry.AvgWeightKg = entry.RemainingChickenWeightKg / float64(entry.RemainingChickenBirds) + if agg.RemainingBirds > 0 { + entry.AvgWeightKg = agg.RemainingWeightKg / float64(agg.RemainingBirds) } if agg.EggHppCount > 0 { entry.EggHppRpPerKg = agg.EggHppSum / float64(agg.EggHppCount) } + entry.FeedSuppliers = make([]dto.HppPerKandangSupplierDTO, 0, len(agg.FeedSuppliers)) + for _, supplier := range agg.FeedSuppliers { + entry.FeedSuppliers = append(entry.FeedSuppliers, supplier) + } + entry.DocSuppliers = make([]dto.HppPerKandangSupplierDTO, 0, len(agg.DocSuppliers)) + for _, supplier := range agg.DocSuppliers { + entry.DocSuppliers = append(entry.DocSuppliers, supplier) + } perRangeSummary = append(perRangeSummary, *entry) } totalSummary := dto.HppPerKandangSummaryTotalDTO{ - TotalRemainingChickenBirds: totalBirds, - TotalRemainingChickenWeightKg: totalWeight, - TotalEggProductionPieces: totalEggPieces, - TotalEggProductionKg: totalEggKg, - TotalRemainingValueRp: totalRemainingValueRp, - TotalEggValueRp: totalEggValueRp, + TotalEggProductionPieces: totalEggPieces, + TotalEggProductionKg: totalEggKg, + TotalEggValueRp: totalEggValueRp, } if totalBirds > 0 { totalSummary.AverageWeightKg = totalWeight / float64(totalBirds) @@ -1218,7 +1275,6 @@ func (s *repportService) GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangRes totalSummary.AverageEggHppRpPerKg = totalEggHppSum / float64(totalEggHppCount) } if totalHppCount > 0 { - totalSummary.TotalHppRp = totalHppSum / float64(totalHppCount) } if totalDocPriceCount > 0 { totalSummary.TotalAverageDocPriceRp = totalDocPriceSum / float64(totalDocPriceCount) From 62ccc2e5d6f28da75bdb5c61a832022c5e22b4b0 Mon Sep 17 00:00:00 2001 From: giovanni Date: Thu, 15 Jan 2026 16:48:37 +0700 Subject: [PATCH 52/76] adjust avg weight --- .../modules/repports/services/repport.service.go | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/internal/modules/repports/services/repport.service.go b/internal/modules/repports/services/repport.service.go index 9be8d8c4..738bd34d 100644 --- a/internal/modules/repports/services/repport.service.go +++ b/internal/modules/repports/services/repport.service.go @@ -1058,6 +1058,8 @@ func (s *repportService) GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangRes Summary *dto.HppPerKandangSummaryWeightRangeDTO RemainingBirds int64 RemainingWeightKg float64 + AvgWeightSum float64 + AvgWeightCount int64 EggHppSum float64 EggHppCount int FeedSuppliers map[int64]dto.HppPerKandangSupplierDTO @@ -1078,6 +1080,8 @@ func (s *repportService) GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangRes var totalDocPriceCount int var totalEggHppSum float64 var totalEggHppCount int + var totalAvgWeightSum float64 + var totalAvgWeightCount int64 for _, row := range repoRows { if !params.ShowUnrecorded && row.RecordingCount == 0 { @@ -1178,6 +1182,8 @@ func (s *repportService) GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangRes totalEggKg += eggWeightFloat totalRemainingValueRp += rowRemainingValue totalEggValueRp += rowEggValue + totalAvgWeightSum += avgWeight + totalAvgWeightCount++ if weightFloat > 0 { totalHppSum += hppRp totalHppCount++ @@ -1210,6 +1216,8 @@ func (s *repportService) GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangRes rangeSummary := rangeAgg.Summary rangeAgg.RemainingBirds += rowBirds rangeAgg.RemainingWeightKg += row.RemainingChickenWeight + rangeAgg.AvgWeightSum += avgWeight + rangeAgg.AvgWeightCount++ for _, supplier := range feedSupplierMap[row.KandangID] { if _, ok := rangeAgg.FeedSuppliers[supplier.ID]; !ok { rangeAgg.FeedSuppliers[supplier.ID] = supplier @@ -1246,8 +1254,8 @@ func (s *repportService) GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangRes agg := perRangeMap[key] entry := agg.Summary entry.ID = idx + 1 - if agg.RemainingBirds > 0 { - entry.AvgWeightKg = agg.RemainingWeightKg / float64(agg.RemainingBirds) + if agg.AvgWeightCount > 0 { + entry.AvgWeightKg = agg.AvgWeightSum / float64(agg.AvgWeightCount) } if agg.EggHppCount > 0 { entry.EggHppRpPerKg = agg.EggHppSum / float64(agg.EggHppCount) @@ -1269,7 +1277,9 @@ func (s *repportService) GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangRes TotalEggValueRp: totalEggValueRp, } if totalBirds > 0 { - totalSummary.AverageWeightKg = totalWeight / float64(totalBirds) + } + if totalAvgWeightCount > 0 { + totalSummary.AverageWeightKg = totalAvgWeightSum / float64(totalAvgWeightCount) } if totalEggHppCount > 0 { totalSummary.AverageEggHppRpPerKg = totalEggHppSum / float64(totalEggHppCount) From 2ca84ecffee6c8b076491abc391338126dcbe424 Mon Sep 17 00:00:00 2001 From: giovanni Date: Thu, 15 Jan 2026 18:16:23 +0700 Subject: [PATCH 53/76] fixing api closing sapronak and data produksi --- internal/modules/closings/dto/closing.dto.go | 8 ++--- .../repositories/closing.repository.go | 8 ++--- .../closings/services/closing.service.go | 33 +++++++++++-------- 3 files changed, 27 insertions(+), 22 deletions(-) diff --git a/internal/modules/closings/dto/closing.dto.go b/internal/modules/closings/dto/closing.dto.go index 470f506d..1c191d29 100644 --- a/internal/modules/closings/dto/closing.dto.go +++ b/internal/modules/closings/dto/closing.dto.go @@ -111,13 +111,13 @@ type ClosingPerformanceDTO struct { FeedIntake float64 `json:"feed_intake"` FeedIntakeStd float64 `json:"feed_intake_std"` HenDayAct *float64 `json:"hen_day_act,omitempty"` - HendayStd *float64 `json:"hen_day_std,omitempty"` + HendayStd float64 `json:"hen_day_std"` EggMass *float64 `json:"egg_mass,omitempty"` - EggMassStd *float64 `json:"egg_mass_std,omitempty"` + EggMassStd float64 `json:"egg_mass_std"` EggWeight *float64 `json:"egg_weight,omitempty"` - EggWeightStd *float64 `json:"egg_weight_std,omitempty"` + EggWeightStd float64 `json:"egg_weight_std"` HenHouseAct *float64 `json:"hen_housed_act,omitempty"` - HenHouseStd *float64 `json:"hen_housed_std,omitempty"` + HenHouseStd float64 `json:"hen_housed_std"` } type ClosingSalesGroupDTO struct { diff --git a/internal/modules/closings/repositories/closing.repository.go b/internal/modules/closings/repositories/closing.repository.go index 2ce3e496..30d29b14 100644 --- a/internal/modules/closings/repositories/closing.repository.go +++ b/internal/modules/closings/repositories/closing.repository.go @@ -473,8 +473,8 @@ SELECT WHERE f.flagable_type = 'products' AND f.flagable_id = prod.id ), '') AS product_sub_category, COALESCE(fw.name, '') AS source_warehouse, - '' AS destination_warehouse, - COALESCE(tw.name, '') AS destination, + COALESCE(tw.name, '') AS destination_warehouse, + '' AS destination, std.usage_qty AS quantity, u.name AS unit, 'Transfer to other unit' AS notes @@ -522,8 +522,8 @@ SELECT WHERE f.flagable_type = 'products' AND f.flagable_id = prod.id ), '') AS product_sub_category, w.name AS source_warehouse, - '' AS destination_warehouse, - 'RETAIL CUSTOMER' AS destination, + 'RETAIL CUSTOMER' AS destination_warehouse, + '' AS destination, mp.qty AS quantity, u.name AS unit, m.notes AS notes diff --git a/internal/modules/closings/services/closing.service.go b/internal/modules/closings/services/closing.service.go index 38529b0d..6b0b56f6 100644 --- a/internal/modules/closings/services/closing.service.go +++ b/internal/modules/closings/services/closing.service.go @@ -337,8 +337,10 @@ func (s closingService) GetClosingSapronak(c *fiber.Ctx, projectFlockID uint, pa } var projectFlockKandangIDs []uint - if params.Type == validation.SapronakTypeOutgoing { - projectFlockKandangIDs, err = s.getProjectFlockKandangIDs(c.Context(), projectFlockID, params.KandangID) + if params.KandangID != nil && *params.KandangID > 0 { + projectFlockKandangIDs = []uint{*params.KandangID} + } else if params.Type == validation.SapronakTypeOutgoing { + projectFlockKandangIDs, err = s.getProjectFlockKandangIDs(c.Context(), projectFlockID) if err != nil { s.Log.Errorf("Failed to fetch project flock kandang IDs for project flock %d: %+v", projectFlockID, err) return nil, 0, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch project flock kandang") @@ -418,14 +420,11 @@ func (s closingService) getWarehouseIDsByProjectFlock(ctx context.Context, proje return ids, nil } -func (s closingService) getProjectFlockKandangIDs(ctx context.Context, projectFlockID uint, kandangID *uint) ([]uint, error) { +func (s closingService) getProjectFlockKandangIDs(ctx context.Context, projectFlockID uint) ([]uint, error) { var ids []uint query := s.Repository.DB().WithContext(ctx). Model(&entity.ProjectFlockKandang{}). Where("project_flock_id = ?", projectFlockID) - if kandangID != nil { - query = query.Where("kandang_id = ?", *kandangID) - } err := query.Order("id ASC").Pluck("id", &ids).Error if err != nil { return nil, err @@ -690,10 +689,16 @@ func (s closingService) GetClosingDataProduksi(c *fiber.Ctx, projectFlockID uint return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid project flock id") } - projectFlockKandangIDs, err := s.getProjectFlockKandangIDs(c.Context(), projectFlockID, kandangID) - if err != nil { - s.Log.Errorf("Failed to fetch project flock kandangs for %d: %+v", projectFlockID, err) - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch project flock kandangs") + var projectFlockKandangIDs []uint + if kandangID != nil && *kandangID > 0 { + projectFlockKandangIDs = []uint{*kandangID} + } else { + var err error + projectFlockKandangIDs, err = s.getProjectFlockKandangIDs(c.Context(), projectFlockID) + if err != nil { + s.Log.Errorf("Failed to fetch project flock kandangs for %d: %+v", projectFlockID, err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch project flock kandangs") + } } if len(projectFlockKandangIDs) == 0 { @@ -954,16 +959,16 @@ func (s closingService) GetClosingDataProduksi(c *fiber.Ctx, projectFlockID uint } if !isGrowing { if productionStandardDetail.TargetHenDayProduction != nil { - performance.HendayStd = productionStandardDetail.TargetHenDayProduction + performance.HendayStd = *productionStandardDetail.TargetHenDayProduction } if productionStandardDetail.TargetHenHouseProduction != nil { - performance.HenHouseStd = productionStandardDetail.TargetHenHouseProduction + performance.HenHouseStd = *productionStandardDetail.TargetHenHouseProduction } if productionStandardDetail.TargetEggWeight != nil { - performance.EggWeightStd = productionStandardDetail.TargetEggWeight + performance.EggWeightStd = *productionStandardDetail.TargetEggWeight } if productionStandardDetail.TargetEggMass != nil { - performance.EggMassStd = productionStandardDetail.TargetEggMass + performance.EggMassStd = *productionStandardDetail.TargetEggMass } } } From 77af262662aa69723ef6b96b0ff92e58e5edae5b Mon Sep 17 00:00:00 2001 From: ragilap Date: Thu, 15 Jan 2026 18:45:52 +0700 Subject: [PATCH 54/76] [FIX/BE-US] adjustment purchase,closing hpp expedition,supplier filter flags --- .../repositories/closing.repository.go | 58 +++++++-- .../closings/services/sapronak.service.go | 113 ++++++++++-------- .../controllers/supplier.controller.go | 1 + .../suppliers/services/supplier.service.go | 24 +++- .../validations/supplier.validation.go | 3 +- .../purchases/services/expense_bridge.go | 25 +++- .../purchases/services/purchase.service.go | 14 ++- 7 files changed, 169 insertions(+), 69 deletions(-) diff --git a/internal/modules/closings/repositories/closing.repository.go b/internal/modules/closings/repositories/closing.repository.go index 2ce3e496..30dc98f4 100644 --- a/internal/modules/closings/repositories/closing.repository.go +++ b/internal/modules/closings/repositories/closing.repository.go @@ -318,6 +318,7 @@ func (r *ClosingRepositoryImpl) GetExpeditionHPP(ctx context.Context, projectFlo Joins("JOIN suppliers s ON s.id = e.supplier_id"). Where("pfk.project_flock_id = ?", projectFlockID). Where("e.category = ?", "BOP"). + Where("e.realization_date IS NOT NULL"). Where("UPPER(f.name) = ?", strings.ToUpper(string(utils.FlagEkspedisi))) if projectFlockKandangID != nil && *projectFlockKandangID != 0 { @@ -889,17 +890,58 @@ func (r *ClosingRepositoryImpl) FetchSapronakAdjustments(ctx context.Context, ka } func (r *ClosingRepositoryImpl) FetchSapronakTransfers(ctx context.Context, kandangID uint) (map[uint][]SapronakDetailRow, map[uint][]SapronakDetailRow, error) { - rows, err := r.fetchStockLogs(ctx, kandangID, string(utils.StockLogTypeTransfer), true) + incomingQuery := r.withCtx(ctx). + Table("stock_transfer_details AS std"). + Select(` + std.product_id AS product_id, + p.name AS product_name, + f.name AS flag, + st.transfer_date::timestamp AS date, + COALESCE(st.movement_number, '') AS reference, + COALESCE(std.total_qty, 0) AS qty_in, + 0 AS qty_out, + COALESCE(p.product_price, 0) AS price + `). + Joins("JOIN stock_transfers st ON st.id = std.stock_transfer_id"). + Joins("JOIN product_warehouses pw ON pw.id = std.dest_product_warehouse_id"). + Joins("JOIN warehouses w ON w.id = pw.warehouse_id"). + Joins("JOIN products p ON p.id = std.product_id"). + Joins("JOIN flags f ON f.flagable_id = p.id AND f.flagable_type = ?", entity.FlagableTypeProduct). + Where("w.kandang_id = ?", kandangID). + Where("f.name IN ?", sapronakFlagsAll) + incoming, err := scanAndGroupDetails(incomingQuery) if err != nil { return nil, nil, err } - in, out := splitStockLogs(rows, func(row stockLogSapronakRow) string { - if ref := strings.TrimSpace(row.MovementNumber); ref != "" { - return ref - } - return fmt.Sprintf("TRF-%d", row.ID) - }) - return in, out, nil + + outgoingQuery := r.withCtx(ctx). + Table("stock_allocations AS sa"). + Select(` + std.product_id AS product_id, + p.name AS product_name, + f.name AS flag, + st.transfer_date::timestamp AS date, + COALESCE(st.movement_number, '') AS reference, + 0 AS qty_in, + COALESCE(SUM(sa.qty), 0) AS qty_out, + COALESCE(p.product_price, 0) AS price + `). + Joins("JOIN stock_transfer_details std ON std.id = sa.usable_id AND sa.usable_type = ?", fifo.UsableKeyStockTransferOut.String()). + Joins("JOIN stock_transfers st ON st.id = std.stock_transfer_id"). + Joins("JOIN product_warehouses pw ON pw.id = sa.product_warehouse_id"). + Joins("JOIN warehouses w ON w.id = pw.warehouse_id"). + Joins("JOIN products p ON p.id = std.product_id"). + Joins("JOIN flags f ON f.flagable_id = p.id AND f.flagable_type = ?", entity.FlagableTypeProduct). + Where("sa.status = ?", entity.StockAllocationStatusActive). + Where("w.kandang_id = ?", kandangID). + Where("f.name IN ?", sapronakFlagsAll). + Group("std.id, std.product_id, p.name, f.name, st.transfer_date, st.movement_number, p.product_price") + outgoing, err := scanAndGroupDetails(outgoingQuery) + if err != nil { + return nil, nil, err + } + + return incoming, outgoing, nil } type ActualUsageCostRow struct { diff --git a/internal/modules/closings/services/sapronak.service.go b/internal/modules/closings/services/sapronak.service.go index b923db5d..fc354f46 100644 --- a/internal/modules/closings/services/sapronak.service.go +++ b/internal/modules/closings/services/sapronak.service.go @@ -3,7 +3,6 @@ package service import ( "context" "strings" - "time" "github.com/go-playground/validator/v10" "github.com/gofiber/fiber/v2" @@ -112,7 +111,7 @@ func (s sapronakService) computeSapronakReports(ctx context.Context, params *val } // We no longer filter by date for closing sapronak report; pass nil pointers. - items, groups, totalIncoming, totalUsage, err := s.buildSapronakItems(ctx, pfk, nil, nil, params.Flag) + items, groups, totalIncoming, totalUsage, err := s.buildSapronakItems(ctx, pfk, params.Flag) if err != nil { s.Log.Errorf("Failed to build sapronak items for pfk %d: %+v", pfk.Id, err) return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to calculate sapronak report") @@ -126,8 +125,6 @@ func (s sapronakService) computeSapronakReports(ctx context.Context, params *val KandangName: pfk.Kandang.Name, Period: pfk.Period, Status: status, - StartDate: nil, - EndDate: nil, TotalIncomingValue: totalIncoming, TotalUsageValue: totalUsage, Items: items, @@ -318,7 +315,7 @@ func buildSapronakDetails( return result } -func (s sapronakService) buildSapronakItems(ctx context.Context, pfk entity.ProjectFlockKandang, start, end *time.Time, flagFilter string) ([]dto.SapronakItemDTO, []dto.SapronakGroupDTO, float64, float64, error) { +func (s sapronakService) buildSapronakItems(ctx context.Context, pfk entity.ProjectFlockKandang, flagFilter string) ([]dto.SapronakItemDTO, []dto.SapronakGroupDTO, float64, float64, error) { // For sapronak closing report we intentionally ignore date range // and aggregate all historical transactions for the kandang/project. incomingRows, err := s.Repository.FetchSapronakIncoming(ctx, pfk.KandangId) @@ -419,6 +416,22 @@ func (s sapronakService) buildSapronakItems(ctx context.Context, pfk entity.Proj return groupMap[flag] } + resolveFlagName := func(productID uint, details []dto.SapronakDetailDTO) (string, string) { + flag := "" + name := "" + if item, ok := itemMap[productID]; ok { + flag = item.Flag + name = item.ProductName + } + if flag == "" && len(details) > 0 { + flag = details[0].Flag + } + if name == "" && len(details) > 0 { + name = details[0].ProductName + } + return flag, name + } + for _, row := range incoming { if !matchesFlag(row.Flag) { continue @@ -554,19 +567,18 @@ func (s sapronakService) buildSapronakItems(ctx context.Context, pfk entity.Proj } for productID, details := range incomingDetails { - flag := "" - name := "" - if item, ok := itemMap[productID]; ok { - flag = item.Flag - name = item.ProductName - } + flag, name := resolveFlagName(productID, details) if !matchesFlag(flag) { continue } group := ensureGroup(flag) for _, d := range details { - d.Flag = flag - d.ProductName = name + if d.Flag == "" { + d.Flag = flag + } + if d.ProductName == "" { + d.ProductName = name + } group.Items = append(group.Items, d) group.TotalMasuk += d.QtyMasuk group.TotalNilai += d.Nilai @@ -575,19 +587,18 @@ func (s sapronakService) buildSapronakItems(ctx context.Context, pfk entity.Proj } for productID, details := range adjIncoming { - flag := "" - name := "" - if item, ok := itemMap[productID]; ok { - flag = item.Flag - name = item.ProductName - } + flag, name := resolveFlagName(productID, details) if !matchesFlag(flag) { continue } group := ensureGroup(flag) for _, d := range details { - d.Flag = flag - d.ProductName = name + if d.Flag == "" { + d.Flag = flag + } + if d.ProductName == "" { + d.ProductName = name + } group.Items = append(group.Items, d) group.TotalMasuk += d.QtyMasuk group.TotalNilai += d.Nilai @@ -596,19 +607,18 @@ func (s sapronakService) buildSapronakItems(ctx context.Context, pfk entity.Proj } for productID, details := range usageDetails { - flag := "" - name := "" - if item, ok := itemMap[productID]; ok { - flag = item.Flag - name = item.ProductName - } + flag, name := resolveFlagName(productID, details) if !matchesFlag(flag) { continue } group := ensureGroup(flag) for _, d := range details { - d.Flag = flag - d.ProductName = name + if d.Flag == "" { + d.Flag = flag + } + if d.ProductName == "" { + d.ProductName = name + } group.Items = append(group.Items, d) group.TotalKeluar += d.QtyKeluar group.SaldoAkhir -= d.QtyKeluar @@ -616,19 +626,18 @@ func (s sapronakService) buildSapronakItems(ctx context.Context, pfk entity.Proj } for productID, details := range adjOutgoing { - flag := "" - name := "" - if item, ok := itemMap[productID]; ok { - flag = item.Flag - name = item.ProductName - } + flag, name := resolveFlagName(productID, details) if !matchesFlag(flag) { continue } group := ensureGroup(flag) for _, d := range details { - d.Flag = flag - d.ProductName = name + if d.Flag == "" { + d.Flag = flag + } + if d.ProductName == "" { + d.ProductName = name + } group.Items = append(group.Items, d) group.TotalKeluar += d.QtyKeluar group.SaldoAkhir -= d.QtyKeluar @@ -636,19 +645,18 @@ func (s sapronakService) buildSapronakItems(ctx context.Context, pfk entity.Proj } for productID, details := range transIncoming { - flag := "" - name := "" - if item, ok := itemMap[productID]; ok { - flag = item.Flag - name = item.ProductName - } + flag, name := resolveFlagName(productID, details) if !matchesFlag(flag) { continue } group := ensureGroup(flag) for _, d := range details { - d.Flag = flag - d.ProductName = name + if d.Flag == "" { + d.Flag = flag + } + if d.ProductName == "" { + d.ProductName = name + } group.Items = append(group.Items, d) group.TotalMasuk += d.QtyMasuk group.TotalNilai += d.Nilai @@ -657,19 +665,18 @@ func (s sapronakService) buildSapronakItems(ctx context.Context, pfk entity.Proj } for productID, details := range transOutgoing { - flag := "" - name := "" - if item, ok := itemMap[productID]; ok { - flag = item.Flag - name = item.ProductName - } + flag, name := resolveFlagName(productID, details) if !matchesFlag(flag) { continue } group := ensureGroup(flag) for _, d := range details { - d.Flag = flag - d.ProductName = name + if d.Flag == "" { + d.Flag = flag + } + if d.ProductName == "" { + d.ProductName = name + } group.Items = append(group.Items, d) group.TotalKeluar += d.QtyKeluar group.SaldoAkhir -= d.QtyKeluar diff --git a/internal/modules/master/suppliers/controllers/supplier.controller.go b/internal/modules/master/suppliers/controllers/supplier.controller.go index c427316d..a373b680 100644 --- a/internal/modules/master/suppliers/controllers/supplier.controller.go +++ b/internal/modules/master/suppliers/controllers/supplier.controller.go @@ -28,6 +28,7 @@ func (u *SupplierController) GetAll(c *fiber.Ctx) error { Limit: c.QueryInt("limit", 10), Search: c.Query("search", ""), Category: c.Query("category", ""), + Flag: c.Query("flag", ""), } if query.Page < 1 || query.Limit < 1 { diff --git a/internal/modules/master/suppliers/services/supplier.service.go b/internal/modules/master/suppliers/services/supplier.service.go index c331647d..d211ed9d 100644 --- a/internal/modules/master/suppliers/services/supplier.service.go +++ b/internal/modules/master/suppliers/services/supplier.service.go @@ -47,6 +47,10 @@ func (s supplierService) withRelations(db *gorm.DB) *gorm.DB { Preload("NonstockSuppliers.Nonstock.Flags") } +func (s supplierService) withListRelations(db *gorm.DB) *gorm.DB { + return db.Preload("CreatedUser") +} + func (s supplierService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.Supplier, int64, error) { if err := s.Validate.Struct(params); err != nil { return nil, 0, err @@ -63,7 +67,7 @@ func (s supplierService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entit offset := (params.Page - 1) * params.Limit suppliers, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { - db = s.withRelations(db) + db = s.withListRelations(db) if params.Search != "" { return db.Where("name ILIKE ?", "%"+params.Search+"%") } @@ -72,7 +76,23 @@ func (s supplierService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entit db = db.Where("category ILIKE ?", "%"+params.Category+"%") } - return db.Order("created_at DESC").Order("updated_at DESC") + if params.Flag != "" { + flag := strings.ToUpper(params.Flag) + db = db.Where(` + EXISTS ( + SELECT 1 + FROM nonstock_suppliers nsup + JOIN nonstocks n ON n.id = nsup.nonstock_id + JOIN flags f ON f.flagable_id = n.id AND f.flagable_type = ? + WHERE nsup.supplier_id = suppliers.id + AND UPPER(f.name) = ? + )`, + entity.FlagableTypeNonstock, + flag, + ) + } + + return db.Order("suppliers.created_at DESC").Order("suppliers.updated_at DESC") }) if err != nil { diff --git a/internal/modules/master/suppliers/validations/supplier.validation.go b/internal/modules/master/suppliers/validations/supplier.validation.go index ec02cd8e..720e784e 100644 --- a/internal/modules/master/suppliers/validations/supplier.validation.go +++ b/internal/modules/master/suppliers/validations/supplier.validation.go @@ -32,7 +32,8 @@ type Update struct { type Query struct { Page int `query:"page" validate:"omitempty,number,min=1"` - Limit int `query:"limit" validate:"omitempty,number,min=1,max=100"` + Limit int `query:"limit" validate:"omitempty,number,min=1"` + Flag string `query:"flag" validate:"omitempty"` Search string `query:"search" validate:"omitempty,max=50"` Category string `query:"category" validate:"omitempty,max=50"` } diff --git a/internal/modules/purchases/services/expense_bridge.go b/internal/modules/purchases/services/expense_bridge.go index 7e5cbd91..56097a90 100644 --- a/internal/modules/purchases/services/expense_bridge.go +++ b/internal/modules/purchases/services/expense_bridge.go @@ -36,6 +36,7 @@ type ExpenseReceivingPayload struct { TransportPerItem *float64 ReceivedQty float64 ReceivedDate *time.Time + VehicleNumber *string } type groupedItem struct { @@ -182,6 +183,22 @@ func (b *expenseBridge) OnItemsReceived(c *fiber.Ctx, purchaseID uint, updates [ } ctx := c.Context() + filtered := make([]ExpenseReceivingPayload, 0, len(updates)) + for _, upd := range updates { + if upd.SupplierID == 0 { + continue + } + if upd.TransportPerItem == nil || *upd.TransportPerItem <= 0 { + continue + } + if upd.VehicleNumber == nil || strings.TrimSpace(*upd.VehicleNumber) == "" { + continue + } + filtered = append(filtered, upd) + } + if len(filtered) == 0 { + return nil + } // Load current links to decide whether to update in place or recreate. type itemLink struct { @@ -205,9 +222,9 @@ func (b *expenseBridge) OnItemsReceived(c *fiber.Ctx, purchaseID uint, updates [ itemLinks := make(map[uint]itemLink) updatedExpenses := make(map[uint64]struct{}) - if len(updates) > 0 { - ids := make([]uint, 0, len(updates)) - for _, upd := range updates { + if len(filtered) > 0 { + ids := make([]uint, 0, len(filtered)) + for _, upd := range filtered { if upd.PurchaseItemID != 0 { ids = append(ids, upd.PurchaseItemID) } @@ -252,7 +269,7 @@ func (b *expenseBridge) OnItemsReceived(c *fiber.Ctx, purchaseID uint, updates [ groups := make(map[string][]groupedItem) - for _, payload := range updates { + for _, payload := range filtered { if payload.ReceivedDate == nil { return fiber.NewError(fiber.StatusBadRequest, "received_date is required") } diff --git a/internal/modules/purchases/services/purchase.service.go b/internal/modules/purchases/services/purchase.service.go index 35ca2f75..f6337c8a 100644 --- a/internal/modules/purchases/services/purchase.service.go +++ b/internal/modules/purchases/services/purchase.service.go @@ -702,6 +702,7 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation warehouseID uint supplierID uint transportPerItem *float64 + vehicleNumber *string overrideWarehouse bool receivedQty float64 } @@ -756,7 +757,7 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation } visitedItems[payload.PurchaseItemID] = struct{}{} - supplierID := purchase.SupplierId + var supplierID uint if payload.ExpeditionVendorID != nil && *payload.ExpeditionVendorID != 0 { supplierID = *payload.ExpeditionVendorID } @@ -770,6 +771,15 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation transportPerItem = &val } + var vehicleNumber *string + if payload.VehicleNumber != nil && strings.TrimSpace(*payload.VehicleNumber) != "" { + val := strings.TrimSpace(*payload.VehicleNumber) + vehicleNumber = &val + } else if item.VehicleNumber != nil && strings.TrimSpace(*item.VehicleNumber) != "" { + val := strings.TrimSpace(*item.VehicleNumber) + vehicleNumber = &val + } + prepared = append(prepared, preparedReceiving{ item: item, payload: payload, @@ -777,6 +787,7 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation warehouseID: warehouseID, supplierID: supplierID, transportPerItem: transportPerItem, + vehicleNumber: vehicleNumber, overrideWarehouse: overrideWarehouse, receivedQty: receivedQty, }) @@ -964,6 +975,7 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation TransportPerItem: prep.transportPerItem, ReceivedQty: prep.receivedQty, ReceivedDate: &date, + VehicleNumber: prep.vehicleNumber, } receivingPayloads = append(receivingPayloads, payload) } From e770526c1ad6193c7d6d95375e040e3fcc5f849e Mon Sep 17 00:00:00 2001 From: "Hafizh A. Y" Date: Thu, 15 Jan 2026 18:51:32 +0700 Subject: [PATCH 55/76] fix(BE): warehouse provided location --- .../warehouses/services/warehouse.service.go | 65 ++++++++++++++----- 1 file changed, 50 insertions(+), 15 deletions(-) diff --git a/internal/modules/master/warehouses/services/warehouse.service.go b/internal/modules/master/warehouses/services/warehouse.service.go index 7eeaad3d..0b9dfc18 100644 --- a/internal/modules/master/warehouses/services/warehouse.service.go +++ b/internal/modules/master/warehouses/services/warehouse.service.go @@ -110,7 +110,11 @@ func (s *warehouseService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent } typ := strings.ToUpper(req.Type) - if err := validateWarehouseTypeRequirements(typ, &req.AreaId, req.LocationId, req.KandangId); err != nil { + createValidationOpts := WarehouseTypeValidationOptions{ + LocationProvided: req.LocationId != nil, + KandangProvided: req.KandangId != nil, + } + if err := validateWarehouseTypeRequirements(typ, &req.AreaId, &req.LocationId, &req.KandangId, createValidationOpts); err != nil { return nil, err } @@ -208,9 +212,22 @@ func (s warehouseService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin finalKandangId = req.KandangId } - if err := validateWarehouseTypeRequirements(finalType, &finalAreaId, finalLocationId, finalKandangId); err != nil { + originalLocationId := finalLocationId + originalKandangId := finalKandangId + updateValidationOpts := WarehouseTypeValidationOptions{ + AutoClear: true, + LocationProvided: req.LocationId != nil, + KandangProvided: req.KandangId != nil, + } + if err := validateWarehouseTypeRequirements(finalType, &finalAreaId, &finalLocationId, &finalKandangId, updateValidationOpts); err != nil { return nil, err } + if originalLocationId != finalLocationId { + updateBody["location_id"] = nil + } + if originalKandangId != finalKandangId { + updateBody["kandang_id"] = nil + } if len(updateBody) == 0 { return s.GetOne(c, id) @@ -238,47 +255,65 @@ func (s warehouseService) DeleteOne(c *fiber.Ctx, id uint) error { return nil } -func validateWarehouseTypeRequirements(typ string, areaID *uint, locationID *uint, kandangID *uint) error { +type WarehouseTypeValidationOptions struct { + AutoClear bool + LocationProvided bool + KandangProvided bool +} + +func validateWarehouseTypeRequirements(typ string, areaID *uint, locationID **uint, kandangID **uint, opts WarehouseTypeValidationOptions) error { switch utils.WarehouseType(typ) { case utils.WarehouseTypeArea: if areaID == nil || *areaID == 0 { return fiber.NewError(fiber.StatusBadRequest, "area_id is required when type is AREA") } - if locationID != nil { - return fiber.NewError(fiber.StatusBadRequest, "location_id must not be provided when type is AREA") + if locationID != nil && *locationID != nil { + if opts.AutoClear && !opts.LocationProvided { + *locationID = nil + } else { + return fiber.NewError(fiber.StatusBadRequest, "location_id must not be provided when type is AREA") + } } - if kandangID != nil { - return fiber.NewError(fiber.StatusBadRequest, "kandang_id must not be provided when type is AREA") + if kandangID != nil && *kandangID != nil { + if opts.AutoClear && !opts.KandangProvided { + *kandangID = nil + } else { + return fiber.NewError(fiber.StatusBadRequest, "kandang_id must not be provided when type is AREA") + } } return nil case utils.WarehouseTypeLokasi: if areaID == nil || *areaID == 0 { return fiber.NewError(fiber.StatusBadRequest, "area_id is required when type is LOCATION") } - if locationID == nil { + if locationID == nil || *locationID == nil { return fiber.NewError(fiber.StatusBadRequest, "location_id is required when type is LOCATION") } - if *locationID == 0 { + if **locationID == 0 { return fiber.NewError(fiber.StatusBadRequest, "location_id must be greater than 0 when type is LOCATION") } - if kandangID != nil { - return fiber.NewError(fiber.StatusBadRequest, "kandang_id must not be provided when type is LOCATION") + if kandangID != nil && *kandangID != nil { + if opts.AutoClear && !opts.KandangProvided { + *kandangID = nil + } else { + return fiber.NewError(fiber.StatusBadRequest, "kandang_id must not be provided when type is LOCATION") + } } return nil case utils.WarehouseTypeKandang: if areaID == nil || *areaID == 0 { return fiber.NewError(fiber.StatusBadRequest, "area_id is required when type is KANDANG") } - if locationID == nil { + if locationID == nil || *locationID == nil { return fiber.NewError(fiber.StatusBadRequest, "location_id is required when type is KANDANG") } - if *locationID == 0 { + if **locationID == 0 { return fiber.NewError(fiber.StatusBadRequest, "location_id must be greater than 0 when type is KANDANG") } - if kandangID == nil { + if kandangID == nil || *kandangID == nil { return fiber.NewError(fiber.StatusBadRequest, "kandang_id is required when type is KANDANG") } - if *kandangID == 0 { + if **kandangID == 0 { return fiber.NewError(fiber.StatusBadRequest, "kandang_id must be greater than 0 when type is KANDANG") } return nil From 688d3fa75703f34b98b3bfc9411ecf8c5478dee3 Mon Sep 17 00:00:00 2001 From: giovanni Date: Thu, 15 Jan 2026 19:16:58 +0700 Subject: [PATCH 56/76] fix api production result --- .../repports/services/repport.service.go | 140 ++++++++++++++---- 1 file changed, 109 insertions(+), 31 deletions(-) diff --git a/internal/modules/repports/services/repport.service.go b/internal/modules/repports/services/repport.service.go index 83d611d6..2e07e212 100644 --- a/internal/modules/repports/services/repport.service.go +++ b/internal/modules/repports/services/repport.service.go @@ -2,6 +2,7 @@ package service import ( "context" + "encoding/json" "errors" "fmt" "math" @@ -21,9 +22,9 @@ import ( marketingRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/repositories" areaDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/areas/dto" customerRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/master/customers/repositories" + productionStandardRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards/repositories" supplierDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/suppliers/dto" warehouseDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/dto" - productionStandardRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards/repositories" chickinRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/production/chickins/repositories" recordingRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/repositories" purchaseRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/repositories" @@ -47,21 +48,21 @@ type RepportService interface { } type repportService struct { - Log *logrus.Logger - Validate *validator.Validate - DB *gorm.DB - ExpenseRealizationRepo expenseRepo.ExpenseRealizationRepository - MarketingDeliveryRepo marketingRepo.MarketingDeliveryProductRepository - PurchaseRepo purchaseRepo.PurchaseRepository - ChickinRepo chickinRepo.ProjectChickinRepository - RecordingRepo recordingRepo.RecordingRepository - ApprovalSvc approvalService.ApprovalService - PurchaseSupplierRepo repportRepo.PurchaseSupplierRepository - DebtSupplierRepo repportRepo.DebtSupplierRepository - HppPerKandangRepo repportRepo.HppPerKandangRepository - ProductionResultRepo repportRepo.ProductionResultRepository - CustomerPaymentRepo repportRepo.CustomerPaymentRepository - CustomerRepo customerRepo.CustomerRepository + Log *logrus.Logger + Validate *validator.Validate + DB *gorm.DB + ExpenseRealizationRepo expenseRepo.ExpenseRealizationRepository + MarketingDeliveryRepo marketingRepo.MarketingDeliveryProductRepository + PurchaseRepo purchaseRepo.PurchaseRepository + ChickinRepo chickinRepo.ProjectChickinRepository + RecordingRepo recordingRepo.RecordingRepository + ApprovalSvc approvalService.ApprovalService + PurchaseSupplierRepo repportRepo.PurchaseSupplierRepository + DebtSupplierRepo repportRepo.DebtSupplierRepository + HppPerKandangRepo repportRepo.HppPerKandangRepository + ProductionResultRepo repportRepo.ProductionResultRepository + CustomerPaymentRepo repportRepo.CustomerPaymentRepository + CustomerRepo customerRepo.CustomerRepository StandardGrowthDetailRepo productionStandardRepository.StandardGrowthDetailRepository ProductionStandardDetailRepo productionStandardRepository.ProductionStandardDetailRepository } @@ -94,21 +95,21 @@ func NewRepportService( productionStandardDetailRepo productionStandardRepository.ProductionStandardDetailRepository, ) RepportService { return &repportService{ - Log: utils.Log, - Validate: validate, - DB: db, - ExpenseRealizationRepo: expenseRealizationRepo, - MarketingDeliveryRepo: marketingDeliveryRepo, - PurchaseRepo: purchaseRepo, - ChickinRepo: chickinRepo, - RecordingRepo: recordingRepo, - ApprovalSvc: approvalSvc, - PurchaseSupplierRepo: purchaseSupplierRepo, - DebtSupplierRepo: debtSupplierRepo, - HppPerKandangRepo: hppPerKandangRepo, - ProductionResultRepo: productionResultRepo, - CustomerPaymentRepo: customerPaymentRepo, - CustomerRepo: customerRepo, + Log: utils.Log, + Validate: validate, + DB: db, + ExpenseRealizationRepo: expenseRealizationRepo, + MarketingDeliveryRepo: marketingDeliveryRepo, + PurchaseRepo: purchaseRepo, + ChickinRepo: chickinRepo, + RecordingRepo: recordingRepo, + ApprovalSvc: approvalSvc, + PurchaseSupplierRepo: purchaseSupplierRepo, + DebtSupplierRepo: debtSupplierRepo, + HppPerKandangRepo: hppPerKandangRepo, + ProductionResultRepo: productionResultRepo, + CustomerPaymentRepo: customerPaymentRepo, + CustomerRepo: customerRepo, StandardGrowthDetailRepo: standardGrowthDetailRepo, ProductionStandardDetailRepo: productionStandardDetailRepo, } @@ -319,6 +320,15 @@ func (s *repportService) GetProductionResult(ctx *fiber.Ctx, params *validation. standardDetailCache := make(map[int]*entity.ProductionStandardDetail) growthDetailCache := make(map[int]*entity.StandardGrowthDetail) + weeks := make([]int, len(weeklyResults)) + for i := range weeklyResults { + weeks[i] = defaultStartWoa + i + } + uniformityMap, err := s.getUniformityByWeek(ctx.Context(), params.ProjectFlockKandangID, weeks) + if err != nil { + return nil, 0, err + } + var cumulativeButir int64 var cumulativeKg float64 for i := range weeklyResults { @@ -328,6 +338,12 @@ func (s *repportService) GetProductionResult(ctx *fiber.Ctx, params *validation. if weeklyResults[i].StdUniformity == "" { weeklyResults[i].StdUniformity = defaultUniformText } + if uniformity, ok := uniformityMap[defaultStartWoa+i]; ok { + weeklyResults[i].Uniformity = uniformity.Uniformity + if uniformity.AvgWeight != nil { + weeklyResults[i].Bw = *uniformity.AvgWeight + } + } cumulativeButir += weeklyResults[i].ButiranJumlah weeklyResults[i].TotalButir = cumulativeButir @@ -744,6 +760,68 @@ func getEggFlagType(egg entity.RecordingEgg) (utils.FlagType, bool) { return "", false } +type uniformityWeekData struct { + Uniformity float64 + AvgWeight *float64 +} + +type uniformityChartPayload struct { + Statistics *uniformityChartStats `json:"statistics"` +} + +type uniformityChartStats struct { + AverageWeight *float64 `json:"average_weight"` +} + +func (s *repportService) getUniformityByWeek(ctx context.Context, projectFlockKandangID uint, weeks []int) (map[int]uniformityWeekData, error) { + result := make(map[int]uniformityWeekData, len(weeks)) + if projectFlockKandangID == 0 || len(weeks) == 0 { + return result, nil + } + + var rows []entity.ProjectFlockKandangUniformity + if err := s.DB.WithContext(ctx). + Model(&entity.ProjectFlockKandangUniformity{}). + Select("week, uniformity, uniform_date, id"). + Where("project_flock_kandang_id = ?", projectFlockKandangID). + Where("week IN ?", weeks). + Order("uniform_date DESC"). + Order("id DESC"). + Find(&rows).Error; err != nil { + return nil, err + } + + for _, row := range rows { + if _, exists := result[row.Week]; exists { + continue + } + result[row.Week] = uniformityWeekData{ + Uniformity: row.Uniformity, + AvgWeight: extractAverageWeight(row.ChartData, s.Log), + } + } + + return result, nil +} + +func extractAverageWeight(raw json.RawMessage, log *logrus.Logger) *float64 { + if len(raw) == 0 { + return nil + } + + var payload uniformityChartPayload + if err := json.Unmarshal(raw, &payload); err != nil { + if log != nil { + log.WithError(err).Warn("uniformity chart_data decode failed") + } + return nil + } + if payload.Statistics == nil { + return nil + } + return payload.Statistics.AverageWeight +} + func summarizeProductionResults(daily []dto.ProductionResultDTO, groupSize int) []dto.ProductionResultDTO { if groupSize <= 0 || len(daily) == 0 { return daily From 8ad11af9c90894065a16d08973f02d2ebe90d372 Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Fri, 16 Jan 2026 12:27:18 +0700 Subject: [PATCH 57/76] feat: Refactor closing services and add ClosingKeuanganService - Updated ClosingRoutes to include ClosingKeuanganService. - Removed GetClosingKeuangan method from ClosingService interface and its implementation. - Introduced new ClosingKeuanganService with GetClosingKeuangan method to handle financial logic. - Implemented detailed logging and error handling in the new service. - Added GetTotalWeightProducedFromUniformityByProjectFlockID method in RecordingRepository to support weight calculations. - Enhanced the logic for fetching and classifying product usage data by flags. - Built comprehensive DTO responses for HPP and Profit Loss sections. --- .../controllers/closing.controller.go | 14 +- .../closings/dto/closingKeuangan.dto.go | 588 ---------------- .../closings/dto/closingKeuanganNew.dto.go | 161 +++++ internal/modules/closings/module.go | 4 +- .../repositories/closing.repository.go | 127 ---- .../closingKeuangan.repository.go | 316 +++++++++ internal/modules/closings/route.go | 4 +- .../closings/services/closing.service.go | 126 ---- .../services/closingKeuangan.service.go | 655 ++++++++++++++++++ .../repositories/recording.repository.go | 29 + 10 files changed, 1174 insertions(+), 850 deletions(-) delete mode 100644 internal/modules/closings/dto/closingKeuangan.dto.go create mode 100644 internal/modules/closings/dto/closingKeuanganNew.dto.go create mode 100644 internal/modules/closings/repositories/closingKeuangan.repository.go create mode 100644 internal/modules/closings/services/closingKeuangan.service.go diff --git a/internal/modules/closings/controllers/closing.controller.go b/internal/modules/closings/controllers/closing.controller.go index 3e64f89b..9dfae460 100644 --- a/internal/modules/closings/controllers/closing.controller.go +++ b/internal/modules/closings/controllers/closing.controller.go @@ -14,14 +14,16 @@ import ( ) type ClosingController struct { - ClosingService service.ClosingService - SapronakService service.SapronakService + ClosingService service.ClosingService + SapronakService service.SapronakService + ClosingKeuanganService service.ClosingKeuanganService } -func NewClosingController(closingService service.ClosingService, sapronakService service.SapronakService) *ClosingController { +func NewClosingController(closingService service.ClosingService, sapronakService service.SapronakService, closingKeuanganService service.ClosingKeuanganService) *ClosingController { return &ClosingController{ - ClosingService: closingService, - SapronakService: sapronakService, + ClosingService: closingService, + SapronakService: sapronakService, + ClosingKeuanganService: closingKeuanganService, } } @@ -338,7 +340,7 @@ func (u *ClosingController) GetClosingKeuangan(c *fiber.Ctx) error { return fiber.NewError(fiber.StatusBadRequest, "Invalid Project Flock Id") } - result, err := u.ClosingService.GetClosingKeuangan(c, uint(projectFlockID)) + result, err := u.ClosingKeuanganService.GetClosingKeuangan(c, uint(projectFlockID)) if err != nil { return err } diff --git a/internal/modules/closings/dto/closingKeuangan.dto.go b/internal/modules/closings/dto/closingKeuangan.dto.go deleted file mode 100644 index fa99a59d..00000000 --- a/internal/modules/closings/dto/closingKeuangan.dto.go +++ /dev/null @@ -1,588 +0,0 @@ -package dto - -import ( - "slices" - "strings" - - "gitlab.com/mbugroup/lti-api.git/internal/entities" - "gitlab.com/mbugroup/lti-api.git/internal/utils" -) - -// === CONSTANTS === -const ( - HPPGroupPengeluaran = "HPP dan Pengeluaran" - HPPGroupBahanBaku = "HPP dan Bahan Baku" - HPPLabelOverhead = "Pengeluaran Overhead" - HPPLabelEkspedisi = "Beban Ekspedisi" - HPPSummaryLabel = "HPP" - - PLSalesTypeChicken = "Penjualan Ayam Besar" - PLSalesTypeEgg = "Penjualan Telur" - - PLItemTypeSapronak = "Pembelian Sapronak" - PLItemTypeOverhead = "Pengeluaran Overhead" - PLItemTypeEkspedisi = "Beban Ekspedisi" - - PLSummaryLabelGrossProfit = "LABA RUGI BRUTTO" - PLSummaryLabelSubTotal = "SUB TOTAL" - PLSummaryLabelNetProfit = "LABA RUGI NETTO" - - PurchaseLabelPrefix = "Pembelian " -) - -// === CONTEXT STRUCTS === - -type CalculationContext struct { - TotalPopulation float64 - TotalWeightProduced float64 - TotalEggWeightKg float64 - TotalDepletion float64 - TotalWeightSold float64 - ActualPopulation float64 -} - -type ClosingKeuanganInput struct { - ProjectFlockCategory string - PurchaseItems []entities.PurchaseItem - Budgets []entities.ProjectBudget - Realizations []entities.ExpenseRealization - DeliveryProducts []entities.MarketingDeliveryProduct - Chickins []entities.ProjectChickin - TotalWeightProduced float64 - TotalEggWeightKg float64 - TotalDepletion float64 -} - -// === BASE METRICS === - -type FinancialMetrics struct { - RpPerBird float64 `json:"rp_per_bird"` - RpPerKg float64 `json:"rp_per_kg"` - Amount float64 `json:"amount"` -} - -type Comparison struct { - Budgeting FinancialMetrics `json:"budgeting"` - Realization FinancialMetrics `json:"realization"` -} - -// === HPP PURCHASES PACKAGE === - -type HppItem struct { - Type string `json:"type"` - Comparison -} - -type HppGroup struct { - GroupName string `json:"group_name"` - Data []HppItem `json:"data"` -} - -type SummaryHpp struct { - Label string `json:"label"` - Budgeting FinancialMetrics `json:"budgeting"` - Realization FinancialMetrics `json:"realization"` - EggBudgeting *FinancialMetrics `json:"egg_budgeting,omitempty"` - EggRealization *FinancialMetrics `json:"egg_realization,omitempty"` -} - -type HppPurchasesSection struct { - Hpp []HppGroup `json:"hpp"` - SummaryHpp SummaryHpp `json:"summary_hpp"` -} - -// === PROFIT LOSS PACKAGE === - -type PLItem struct { - Type string `json:"type"` - FinancialMetrics -} - -type PLSummaryItem struct { - Label string `json:"label"` - FinancialMetrics -} - -type PLSummaryGroup struct { - GrossProfit PLSummaryItem `json:"gross_profit"` - SubTotal PLSummaryItem `json:"sub_total"` - NetProfit PLSummaryItem `json:"net_profit"` -} - -type ProfitLossData struct { - Penjualan []PLItem `json:"penjualan"` - Pembelian []PLItem `json:"pembelian"` - Overhead PLItem `json:"overhead"` - Ekspedisi PLItem `json:"ekspedisi"` - Summary PLSummaryGroup `json:"summary"` -} - -type ProfitLossSection struct { - Data ProfitLossData `json:"data"` -} - -// === RESPONSE DTO (ROOT) === - -type ReportResponse struct { - HppPurchases HppPurchasesSection `json:"hpp_purchases"` - ProfitLoss ProfitLossSection `json:"profit_loss"` -} - -// === MAPPER FUNCTIONS === - -func ToFinancialMetrics(rpPerBird, rpPerKg, amount float64) FinancialMetrics { - return FinancialMetrics{ - RpPerBird: rpPerBird, - RpPerKg: rpPerKg, - Amount: amount, - } -} - -func ToComparison(budgeting, realization FinancialMetrics) Comparison { - return Comparison{ - Budgeting: budgeting, - Realization: realization, - } -} - -// === HPP PENGELUARAN (from Purchase Items) === - -func getFlagLabel(flagType utils.FlagType) string { - return PurchaseLabelPrefix + string(flagType) -} - -func buildHppItemsByPurchaseFlags(purchaseItems []entities.PurchaseItem, ctx CalculationContext) []HppItem { - flags := []utils.FlagType{ - utils.FlagDOC, utils.FlagPullet, utils.FlagLayer, utils.FlagPakan, - utils.FlagPreStarter, utils.FlagStarter, utils.FlagFinisher, - utils.FlagOVK, utils.FlagObat, utils.FlagVitamin, utils.FlagKimia, - } - - items := []HppItem{} - seenFlags := make(map[utils.FlagType]bool) - - for _, item := range purchaseItems { - if item.Product == nil || len(item.Product.Flags) == 0 { - continue - } - - for _, flag := range item.Product.Flags { - flagType := utils.FlagType(flag.Name) - - if slices.Contains(flags, flagType) && !seenFlags[flagType] { - amount := sumPurchasesByFlag(purchaseItems, flagType) - rpPerBird, rpPerKg := calculatePerUnitMetrics(amount, ctx.TotalPopulation, ctx.TotalWeightProduced) - - items = append(items, HppItem{ - Type: getFlagLabel(flagType), - Comparison: ToComparison( - ToFinancialMetrics(rpPerBird, rpPerKg, amount), - ToFinancialMetrics(rpPerBird, rpPerKg, amount), - ), - }) - seenFlags[flagType] = true - } - } - } - - return items -} - -// === HPP BAHAN BAKU (from ProjectBudget + ExpenseRealization) === - -func createHppOverheadItem(budgetAmount, realizationAmount float64, ctx CalculationContext) HppItem { - budgetRpPerBird, budgetRpPerKg := calculatePerUnitMetrics(budgetAmount, ctx.TotalPopulation, ctx.TotalWeightProduced) - realizationRpPerBird, realizationRpPerKg := calculatePerUnitMetrics(realizationAmount, ctx.TotalPopulation, ctx.TotalWeightProduced) - - return HppItem{ - Type: HPPLabelOverhead, - Comparison: ToComparison( - ToFinancialMetrics(budgetRpPerBird, budgetRpPerKg, budgetAmount), - ToFinancialMetrics(realizationRpPerBird, realizationRpPerKg, realizationAmount), - ), - } -} - -func createHppEkspedisiItem(ekspedisiAmount float64, ctx CalculationContext) HppItem { - ekspedisiRpPerBird, ekspedisiRpPerKg := calculatePerUnitMetrics(ekspedisiAmount, ctx.TotalPopulation, ctx.TotalWeightProduced) - - return HppItem{ - Type: HPPLabelEkspedisi, - Comparison: ToComparison( - ToFinancialMetrics(ekspedisiRpPerBird, ekspedisiRpPerKg, ekspedisiAmount), - ToFinancialMetrics(ekspedisiRpPerBird, ekspedisiRpPerKg, ekspedisiAmount), - ), - } -} - -func ToHppBahanBakuGroup(budgets []entities.ProjectBudget, realizations []entities.ExpenseRealization, ctx CalculationContext) HppGroup { - items := []HppItem{} - - budgetAmount := sumBudgetsByFilter(budgets, func(*entities.ProjectBudget) bool { return true }) - realizationAmount := getOperationalExpenses(realizations) - - if budgetAmount > 0 || realizationAmount > 0 { - items = append(items, createHppOverheadItem(budgetAmount, realizationAmount, ctx)) - } - - ekspedisiAmount := sumRealizationsByFilter(realizations, filterRealizationByNonstockFlag(utils.FlagEkspedisi)) - items = append(items, createHppEkspedisiItem(ekspedisiAmount, ctx)) - - return HppGroup{ - GroupName: HPPGroupBahanBaku, - Data: items, - } -} - -// === HPP SUMMARY === - -func ToSummaryHpp(label string, purchaseItems []entities.PurchaseItem, budgets []entities.ProjectBudget, realizations []entities.ExpenseRealization, projectFlockCategory string, ctx CalculationContext) SummaryHpp { - purchaseTotal := sumPurchaseTotal(purchaseItems) - budgetTotal := sumBudgetsByFilter(budgets, func(*entities.ProjectBudget) bool { return true }) - totalBudget := purchaseTotal + budgetTotal - - totalRealization := sumRealizationsByFilter(realizations, func(*entities.ExpenseRealization) bool { return true }) - - budgetRpPerBird, budgetRpPerKg := calculatePerUnitMetrics(totalBudget, ctx.TotalPopulation, ctx.TotalWeightProduced) - realizationRpPerBird, realizationRpPerKg := calculatePerUnitMetrics(totalRealization, ctx.TotalPopulation, ctx.TotalWeightProduced) - - summary := SummaryHpp{ - Label: label, - Budgeting: ToFinancialMetrics(budgetRpPerBird, budgetRpPerKg, totalBudget), - Realization: ToFinancialMetrics(realizationRpPerBird, realizationRpPerKg, totalRealization), - } - - if projectFlockCategory == string(utils.ProjectFlockCategoryLaying) && ctx.TotalEggWeightKg > 0 { - budgetEggRpPerKg, _ := calculatePerUnitMetrics(totalBudget, 0, ctx.TotalEggWeightKg) - realizationEggRpPerKg, _ := calculatePerUnitMetrics(totalRealization, 0, ctx.TotalEggWeightKg) - - summary.EggBudgeting = &FinancialMetrics{ - RpPerBird: 0, - RpPerKg: budgetEggRpPerKg, - Amount: totalBudget, - } - summary.EggRealization = &FinancialMetrics{ - RpPerBird: 0, - RpPerKg: realizationEggRpPerKg, - Amount: totalRealization, - } - } - - return summary -} - -func ToHppPurchasesSection(purchaseItems []entities.PurchaseItem, budgets []entities.ProjectBudget, realizations []entities.ExpenseRealization, projectFlockCategory string, ctx CalculationContext) HppPurchasesSection { - hppGroups := []HppGroup{ - { - GroupName: HPPGroupPengeluaran, - Data: buildHppItemsByPurchaseFlags(purchaseItems, ctx), - }, - ToHppBahanBakuGroup(budgets, realizations, ctx), - } - - summaryHpp := ToSummaryHpp(HPPSummaryLabel, purchaseItems, budgets, realizations, projectFlockCategory, ctx) - - return HppPurchasesSection{ - Hpp: hppGroups, - SummaryHpp: summaryHpp, - } -} - -// === PROFIT & LOSS === - -func ToPLItem(itemType string, metrics FinancialMetrics) PLItem { - return PLItem{ - Type: itemType, - FinancialMetrics: metrics, - } -} - -func ToPLSummaryItem(label string, metrics FinancialMetrics) PLSummaryItem { - return PLSummaryItem{ - Label: label, - FinancialMetrics: metrics, - } -} - -func createPLItemWithMetrics(itemType string, amount float64, ctx CalculationContext) PLItem { - rpPerBird, rpPerKg := calculatePerUnitMetrics(amount, ctx.ActualPopulation, ctx.TotalWeightProduced) - return ToPLItem(itemType, ToFinancialMetrics(rpPerBird, rpPerKg, amount)) -} - -func sumPLItems(items []PLItem) (totalAmount, totalPerBird float64) { - for _, item := range items { - totalAmount += item.Amount - totalPerBird += item.RpPerBird - } - return -} - -func createPenjualanItem(salesType string, amount float64, ctx CalculationContext) PLItem { - rpPerBird, rpPerKg := calculatePerUnitMetrics(amount, ctx.ActualPopulation, ctx.TotalWeightSold) - return ToPLItem(salesType, ToFinancialMetrics(rpPerBird, rpPerKg, amount)) -} - -func ToPenjualanItems(projectFlockCategory string, deliveryProducts []entities.MarketingDeliveryProduct, ctx CalculationContext) []PLItem { - items := []PLItem{} - - categorized := categorizeDeliveriesBySalesType(deliveryProducts) - - if projectFlockCategory == string(utils.ProjectFlockCategoryLaying) { - ayamAmount := sumDeliveriesByCategory(categorized[PLSalesTypeChicken]) - telurAmount := sumDeliveriesByCategory(categorized[PLSalesTypeEgg]) - - items = append(items, createPenjualanItem(PLSalesTypeChicken, ayamAmount, ctx)) - items = append(items, createPenjualanItem(PLSalesTypeEgg, telurAmount, ctx)) - } else { - ayamAmount := sumDeliveriesByCategory(categorized[PLSalesTypeChicken]) - items = append(items, createPenjualanItem(PLSalesTypeChicken, ayamAmount, ctx)) - } - - return items -} - -func ToPembelianItems(purchases []entities.PurchaseItem, realizations []entities.ExpenseRealization, ctx CalculationContext) []PLItem { - purchaseAmount := sumPurchaseTotal(purchases) - - return []PLItem{ - createPLItemWithMetrics(PLItemTypeSapronak, purchaseAmount, ctx), - } -} - -func ToOverheadItems(realizations []entities.ExpenseRealization, ctx CalculationContext) []PLItem { - realizationAmount := getOperationalExpenses(realizations) - return []PLItem{ - createPLItemWithMetrics(PLItemTypeOverhead, realizationAmount, ctx), - } -} - -func ToEkspedisiItems(realizations []entities.ExpenseRealization, ctx CalculationContext) []PLItem { - amount := sumRealizationsByFilter(realizations, filterRealizationByNonstockFlag(utils.FlagEkspedisi)) - return []PLItem{ - createPLItemWithMetrics(PLItemTypeEkspedisi, amount, ctx), - } -} - -func ToPLSummaryGroup(penjualanItems, pembelianItems, overheadItems, ekspedisiItems []PLItem) PLSummaryGroup { - totalPenjualan, totalPenjualanPerBird := sumPLItems(penjualanItems) - totalPembelian, totalPembelianPerBird := sumPLItems(pembelianItems) - totalOverhead, totalOverheadPerBird := sumPLItems(overheadItems) - totalEkspedisi, totalEkspedisiPerBird := sumPLItems(ekspedisiItems) - - grossProfit := totalPenjualan - totalPembelian - grossProfitPerBird := totalPenjualanPerBird - totalPembelianPerBird - - totalOtherExpenses := totalOverhead + totalEkspedisi - totalOtherExpensesPerBird := totalOverheadPerBird + totalEkspedisiPerBird - - netProfit := grossProfit - totalOtherExpenses - netProfitPerBird := grossProfitPerBird - totalOtherExpensesPerBird - - return PLSummaryGroup{ - GrossProfit: ToPLSummaryItem(PLSummaryLabelGrossProfit, ToFinancialMetrics(grossProfitPerBird, 0, grossProfit)), - SubTotal: ToPLSummaryItem(PLSummaryLabelSubTotal, ToFinancialMetrics(totalOtherExpensesPerBird, 0, totalOtherExpenses)), - NetProfit: ToPLSummaryItem(PLSummaryLabelNetProfit, ToFinancialMetrics(netProfitPerBird, 0, netProfit)), - } -} - -func ToProfitLossData(penjualanItems, pembelianItems, overheadItems, ekspedisiItems []PLItem) ProfitLossData { - summary := ToPLSummaryGroup(penjualanItems, pembelianItems, overheadItems, ekspedisiItems) - - totalOverhead := aggregatePLItems(overheadItems, PLItemTypeOverhead) - totalEkspedisi := aggregatePLItems(ekspedisiItems, PLItemTypeEkspedisi) - - return ProfitLossData{ - Penjualan: penjualanItems, - Pembelian: pembelianItems, - Overhead: totalOverhead, - Ekspedisi: totalEkspedisi, - Summary: summary, - } -} - -func ToProfitLossSection(penjualanItems, pembelianItems, overheadItems, ekspedisiItems []PLItem) ProfitLossSection { - return ProfitLossSection{ - Data: ToProfitLossData(penjualanItems, pembelianItems, overheadItems, ekspedisiItems), - } -} - -func aggregatePLItems(items []PLItem, label string) PLItem { - totalAmount, totalPerBird := sumPLItems(items) - return ToPLItem(label, ToFinancialMetrics(totalPerBird, 0, totalAmount)) -} - -func ToReportResponse(hppPurchases HppPurchasesSection, profitLoss ProfitLossSection) ReportResponse { - return ReportResponse{ - HppPurchases: hppPurchases, - ProfitLoss: profitLoss, - } -} - -func ToClosingKeuanganReport(input ClosingKeuanganInput) ReportResponse { - var totalPopulation float64 - var totalWeightSold float64 - - for _, chickin := range input.Chickins { - totalPopulation += chickin.UsageQty - } - - for _, delivery := range input.DeliveryProducts { - totalWeightSold += delivery.TotalWeight - } - - ctx := CalculationContext{ - TotalPopulation: totalPopulation, - TotalWeightProduced: input.TotalWeightProduced, - TotalEggWeightKg: input.TotalEggWeightKg, - TotalDepletion: input.TotalDepletion, - TotalWeightSold: totalWeightSold, - ActualPopulation: totalPopulation - input.TotalDepletion, - } - - hppSection := ToHppPurchasesSection(input.PurchaseItems, input.Budgets, input.Realizations, input.ProjectFlockCategory, ctx) - penjualanItems := ToPenjualanItems(input.ProjectFlockCategory, input.DeliveryProducts, ctx) - pembelianItems := ToPembelianItems(input.PurchaseItems, input.Realizations, ctx) - overheadItems := ToOverheadItems(input.Realizations, ctx) - ekspedisiItems := ToEkspedisiItems(input.Realizations, ctx) - plSection := ToProfitLossSection(penjualanItems, pembelianItems, overheadItems, ekspedisiItems) - - return ToReportResponse(hppSection, plSection) -} - -// === HELPER FUNCTIONS === - -func calculatePerUnitMetrics(amount, totalPopulation, totalWeightSold float64) (rpPerBird, rpPerKg float64) { - if totalPopulation > 0 { - rpPerBird = amount / totalPopulation - } - if totalWeightSold > 0 { - rpPerKg = amount / totalWeightSold - } - return rpPerBird, rpPerKg -} - -func hasProductFlag(flags []entities.Flag, flagType utils.FlagType) bool { - for _, flag := range flags { - if strings.ToUpper(flag.Name) == string(flagType) { - return true - } - } - return false -} - -func filterByPurchaseFlag(flagType utils.FlagType) func(*entities.PurchaseItem) bool { - return func(item *entities.PurchaseItem) bool { - if item.Product == nil || len(item.Product.Flags) == 0 { - return false - } - return hasProductFlag(item.Product.Flags, flagType) - } -} - -func filterRealizationByNonstockFlag(flagType utils.FlagType) func(*entities.ExpenseRealization) bool { - return func(realization *entities.ExpenseRealization) bool { - if realization.ExpenseNonstock == nil || realization.ExpenseNonstock.Nonstock == nil { - return false - } - return hasProductFlag(realization.ExpenseNonstock.Nonstock.Flags, flagType) - } -} - -func filterRealizationExceptFlag(flagType utils.FlagType) func(*entities.ExpenseRealization) bool { - hasFlag := filterRealizationByNonstockFlag(flagType) - return func(realization *entities.ExpenseRealization) bool { - return !hasFlag(realization) - } -} - -func sumByFilter[T any](items []T, extractor func(*T) float64, filter func(*T) bool) float64 { - amount := 0.0 - for i := range items { - if filter(&items[i]) { - amount += extractor(&items[i]) - } - } - return amount -} - -func sumPurchasesByFilter(purchases []entities.PurchaseItem, filter func(*entities.PurchaseItem) bool) float64 { - return sumByFilter(purchases, func(p *entities.PurchaseItem) float64 { return p.TotalPrice }, filter) -} - -func sumPurchasesByFlag(purchases []entities.PurchaseItem, flagType utils.FlagType) float64 { - return sumPurchasesByFilter(purchases, filterByPurchaseFlag(flagType)) -} - -func sumPurchaseTotal(purchases []entities.PurchaseItem) float64 { - return sumByFilter(purchases, func(p *entities.PurchaseItem) float64 { return p.TotalPrice }, func(*entities.PurchaseItem) bool { return true }) -} - -func sumBudgetsByFilter(budgets []entities.ProjectBudget, filter func(*entities.ProjectBudget) bool) float64 { - return sumByFilter(budgets, func(b *entities.ProjectBudget) float64 { return b.Price * b.Qty }, filter) -} - -func sumRealizationsByFilter(realizations []entities.ExpenseRealization, filter func(*entities.ExpenseRealization) bool) float64 { - return sumByFilter(realizations, func(r *entities.ExpenseRealization) float64 { return r.Price * r.Qty }, filter) -} - -func getOperationalExpenses(realizations []entities.ExpenseRealization) float64 { - return sumRealizationsByFilter(realizations, filterRealizationExceptFlag(utils.FlagEkspedisi)) -} - -func isChickenProductFlag(flagType utils.FlagType) bool { - switch flagType { - case utils.FlagDOC, utils.FlagPullet, utils.FlagLayer, - utils.FlagAyamAfkir, utils.FlagAyamCulling, utils.FlagAyamMati: - return true - } - return false -} - -func isEggProductFlag(flagType utils.FlagType) bool { - switch flagType { - case utils.FlagTelur, utils.FlagTelurUtuh, utils.FlagTelurPecah, - utils.FlagTelurPutih, utils.FlagTelurRetak: - return true - } - return false -} - -func getSalesTypeFromProductFlags(product *entities.Product) string { - if product == nil || len(product.Flags) == 0 { - return PLSalesTypeChicken - } - - for _, flag := range product.Flags { - flagType := utils.FlagType(strings.ToUpper(flag.Name)) - - if isEggProductFlag(flagType) { - return PLSalesTypeEgg - } - if isChickenProductFlag(flagType) { - return PLSalesTypeChicken - } - } - - return PLSalesTypeChicken -} - -func categorizeDeliveriesBySalesType(deliveries []entities.MarketingDeliveryProduct) map[string][]entities.MarketingDeliveryProduct { - categorized := make(map[string][]entities.MarketingDeliveryProduct) - - for _, delivery := range deliveries { - product := delivery.MarketingProduct.ProductWarehouse.Product - salesType := getSalesTypeFromProductFlags(&product) - - categorized[salesType] = append(categorized[salesType], delivery) - } - - return categorized -} - -func sumDeliveriesByCategory(deliveries []entities.MarketingDeliveryProduct) float64 { - amount := 0.0 - for _, delivery := range deliveries { - amount += delivery.TotalPrice - } - return amount -} diff --git a/internal/modules/closings/dto/closingKeuanganNew.dto.go b/internal/modules/closings/dto/closingKeuanganNew.dto.go new file mode 100644 index 00000000..4bef7280 --- /dev/null +++ b/internal/modules/closings/dto/closingKeuanganNew.dto.go @@ -0,0 +1,161 @@ +package dto + +// === NEW CLOSING KEUANGAN DTO === + +// FinancialMetrics represents financial metrics with per unit and total amounts +type FinancialMetrics struct { + RpPerBird float64 `json:"rp_per_bird"` + RpPerKg float64 `json:"rp_per_kg"` + Amount float64 `json:"amount"` +} + +// HPPItem represents an item in HPP section +type HPPItem struct { + ID uint `json:"id"` + Category string `json:"category"` // "purchase" or "overhead" + Code string `json:"code"` // "PAKAN", "OVK", "DOC", "EKSPEDISI" + Label string `json:"label"` + Budgeting FinancialMetrics `json:"budgeting"` + Realization FinancialMetrics `json:"realization"` +} + +// HPPSummary represents summary for HPP section +type HPPSummary struct { + Label string `json:"label"` + Budgeting FinancialMetrics `json:"budgeting"` + Realization FinancialMetrics `json:"realization"` + EggBudgeting *FinancialMetrics `json:"egg_budgeting,omitempty"` + EggRealization *FinancialMetrics `json:"egg_realization,omitempty"` +} + +// HPPSection represents HPP data section +type HPPSection struct { + Items []HPPItem `json:"items"` + Summary HPPSummary `json:"summary"` +} + +// ProfitLossItem represents an item in Profit & Loss section +type ProfitLossItem struct { + Code string `json:"code"` // "SALES", "PURCHASE_DOC", "OVERHEAD", "EKSPEDISI" + Label string `json:"label"` + Type string `json:"type"` // "income", "purchase", "overhead" + RpPerBird float64 `json:"rp_per_bird"` + RpPerKg float64 `json:"rp_per_kg"` + Amount float64 `json:"amount"` +} + +// ProfitLossSummary represents summary for Profit & Loss section +type ProfitLossSummary struct { + GrossProfit FinancialMetrics `json:"gross_profit"` + SubTotal FinancialMetrics `json:"sub_total"` + NetProfit FinancialMetrics `json:"net_profit"` +} + +// ProfitLossSection represents Profit & Loss data section +type ProfitLossSection struct { + Items []ProfitLossItem `json:"items"` + Summary ProfitLossSummary `json:"summary"` +} + +// ClosingKeuanganData represents the main data structure +type ClosingKeuanganData struct { + HPP HPPSection `json:"hpp"` + ProfitLoss ProfitLossSection `json:"profit_loss"` +} + +// ClosingKeuanganResponse represents the full API response +type ClosingKeuanganResponse struct { + Code int `json:"code"` + Status string `json:"status"` + Message string `json:"message"` + Data ClosingKeuanganData `json:"data"` +} + +// === MAPPER FUNCTIONS === + +// ToFinancialMetrics creates FinancialMetrics from values +func ToFinancialMetrics(rpPerBird, rpPerKg, amount float64) FinancialMetrics { + return FinancialMetrics{ + RpPerBird: rpPerBird, + RpPerKg: rpPerKg, + Amount: amount, + } +} + +// ToHPPItem creates HPP item +func ToHPPItem(id uint, category, code, label string, budgeting, realization FinancialMetrics) HPPItem { + return HPPItem{ + ID: id, + Category: category, + Code: code, + Label: label, + Budgeting: budgeting, + Realization: realization, + } +} + +// ToHPPSummary creates HPP summary +func ToHPPSummary(label string, budgeting, realization FinancialMetrics, eggBudgeting, eggRealization *FinancialMetrics) HPPSummary { + return HPPSummary{ + Label: label, + Budgeting: budgeting, + Realization: realization, + EggBudgeting: eggBudgeting, + EggRealization: eggRealization, + } +} + +// ToHPPSection creates HPP section +func ToHPPSection(items []HPPItem, summary HPPSummary) HPPSection { + return HPPSection{ + Items: items, + Summary: summary, + } +} + +// ToProfitLossItem creates Profit & Loss item +func ToProfitLossItem(code, label, itemType string, rpPerBird, rpPerKg, amount float64) ProfitLossItem { + return ProfitLossItem{ + Code: code, + Label: label, + Type: itemType, + RpPerBird: rpPerBird, + RpPerKg: rpPerKg, + Amount: amount, + } +} + +// ToProfitLossSummary creates Profit & Loss summary +func ToProfitLossSummary(grossProfit, subTotal, netProfit FinancialMetrics) ProfitLossSummary { + return ProfitLossSummary{ + GrossProfit: grossProfit, + SubTotal: subTotal, + NetProfit: netProfit, + } +} + +// ToProfitLossSection creates Profit & Loss section +func ToProfitLossSection(items []ProfitLossItem, summary ProfitLossSummary) ProfitLossSection { + return ProfitLossSection{ + Items: items, + Summary: summary, + } +} + +// ToClosingKeuanganData creates complete closing keuangan data +func ToClosingKeuanganData(hpp HPPSection, profitLoss ProfitLossSection) ClosingKeuanganData { + return ClosingKeuanganData{ + HPP: hpp, + ProfitLoss: profitLoss, + } +} + +// ToSuccessClosingKeuanganResponse creates success response +func ToSuccessClosingKeuanganResponse(data ClosingKeuanganData) ClosingKeuanganResponse { + return ClosingKeuanganResponse{ + Code: 200, + Status: "success", + Message: "Get closing keuangan successfully", + Data: data, + } +} diff --git a/internal/modules/closings/module.go b/internal/modules/closings/module.go index a79c9f0b..1079663d 100644 --- a/internal/modules/closings/module.go +++ b/internal/modules/closings/module.go @@ -25,6 +25,7 @@ type ClosingModule struct{} func (ClosingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { closingRepo := rClosing.NewClosingRepository(db) + closingKeuanganRepo := rClosing.NewClosingKeuanganRepository(db) userRepo := rUser.NewUserRepository(db) projectFlockRepo := rProjectFlock.NewProjectflockRepository(db) projectFlockKandangRepo := rProjectFlock.NewProjectFlockKandangRepository(db) @@ -41,8 +42,9 @@ func (ClosingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate * approvalService := commonSvc.NewApprovalService(approvalRepo) closingService := sClosing.NewClosingService(closingRepo, projectFlockRepo, projectFlockKandangRepo, marketingRepo, marketingDeliveryProductRepo, approvalService, expenseRealizationRepo, projectBudgetRepo, chickinRepo, purchaseRepo, recordingRepo, standardGrowthDetailRepo, productionStandardDetailRepo, validate) + closingKeuanganService := sClosing.NewClosingKeuanganService(closingKeuanganRepo, projectFlockRepo, projectFlockKandangRepo, marketingDeliveryProductRepo, expenseRealizationRepo, projectBudgetRepo, chickinRepo, recordingRepo) sapronakService := sClosing.NewSapronakService(closingRepo, projectFlockKandangRepo, validate) userService := sUser.NewUserService(userRepo, validate) - ClosingRoutes(router, userService, closingService, sapronakService) + ClosingRoutes(router, userService, closingService, sapronakService, closingKeuanganService) } diff --git a/internal/modules/closings/repositories/closing.repository.go b/internal/modules/closings/repositories/closing.repository.go index 7ecc86d8..582a1207 100644 --- a/internal/modules/closings/repositories/closing.repository.go +++ b/internal/modules/closings/repositories/closing.repository.go @@ -33,7 +33,6 @@ type ClosingRepository interface { FetchSapronakChickinUsageDetails(ctx context.Context, pfkID uint) (map[uint][]SapronakDetailRow, error) FetchSapronakAdjustments(ctx context.Context, kandangID uint) (map[uint][]SapronakDetailRow, map[uint][]SapronakDetailRow, error) FetchSapronakTransfers(ctx context.Context, kandangID uint) (map[uint][]SapronakDetailRow, map[uint][]SapronakDetailRow, error) - GetActualUsageCostByProjectFlockID(ctx context.Context, projectFlockID uint) ([]ActualUsageCostRow, error) GetProductsWithFlagsByIDs(ctx context.Context, productIDs []uint) ([]entity.Product, error) } @@ -944,132 +943,6 @@ func (r *ClosingRepositoryImpl) FetchSapronakTransfers(ctx context.Context, kand return incoming, outgoing, nil } -type ActualUsageCostRow struct { - ProductID uint `gorm:"column:product_id"` - ProductName string `gorm:"column:product_name"` - FlagName string `gorm:"column:flag_name"` - TotalQty float64 `gorm:"column:total_qty"` - TotalPrice float64 `gorm:"column:total_price"` - AveragePrice float64 `gorm:"column:average_price"` -} - -func (r *ClosingRepositoryImpl) GetActualUsageCostByProjectFlockID(ctx context.Context, projectFlockID uint) ([]ActualUsageCostRow, error) { - if projectFlockID == 0 { - return []ActualUsageCostRow{}, nil - } - - db := r.DB().WithContext(ctx) - - // Get all project flock kandang IDs for this project flock - var pfkIDs []uint - err := db.Table("project_flock_kandangs"). - Where("project_flock_id = ?", projectFlockID). - Pluck("id", &pfkIDs).Error - if err != nil { - return nil, err - } - - if len(pfkIDs) == 0 { - return []ActualUsageCostRow{}, nil - } - - var rows []ActualUsageCostRow - - purchaseStockableKey := fifo.StockableKeyPurchaseItems.String() - transferStockableKey := fifo.StockableKeyStockTransferIn.String() - - recordingQuery := db. - Table("recordings AS r"). - Select(` - pw.product_id AS product_id, - p.name AS product_name, - COALESCE(f.name, tf.name) AS flag_name, - COALESCE(SUM( - CASE - WHEN sa.stockable_type = ? THEN COALESCE(sa.qty, 0) - WHEN sa.stockable_type = ? THEN COALESCE(std.usage_qty, 0) - ELSE 0 - END - ), 0) AS total_qty, - COALESCE(SUM( - CASE - WHEN sa.stockable_type = ? THEN COALESCE(sa.qty, 0) * COALESCE(pi.price, 0) - WHEN sa.stockable_type = ? THEN COALESCE(std.usage_qty, 0) * COALESCE(tpi.price, 0) - ELSE 0 - END - ), 0) AS total_price, - COALESCE(SUM( - CASE - WHEN sa.stockable_type = ? THEN COALESCE(sa.qty, 0) - WHEN sa.stockable_type = ? THEN COALESCE(std.usage_qty, 0) - ELSE 0 - END - ), 0) AS qty_divisor, - COALESCE(SUM( - CASE - WHEN sa.stockable_type = ? THEN COALESCE(sa.qty, 0) * COALESCE(pi.price, 0) - WHEN sa.stockable_type = ? THEN COALESCE(std.usage_qty, 0) * COALESCE(tpi.price, 0) - ELSE 0 - END - ), 0) / NULLIF(COALESCE(SUM( - CASE - WHEN sa.stockable_type = ? THEN COALESCE(sa.qty, 0) - WHEN sa.stockable_type = ? THEN COALESCE(std.usage_qty, 0) - ELSE 0 - END - ), 0), 0) AS average_price`, - purchaseStockableKey, transferStockableKey, - purchaseStockableKey, transferStockableKey, - purchaseStockableKey, transferStockableKey, - purchaseStockableKey, transferStockableKey, - purchaseStockableKey, transferStockableKey). - Joins("JOIN recording_stocks AS rs ON rs.recording_id = r.id"). - Joins("JOIN product_warehouses AS pw ON pw.id = rs.product_warehouse_id"). - Joins("JOIN products AS p ON p.id = pw.product_id"). - Joins("LEFT JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = rs.id AND sa.status = ?", - "recording_stocks", entity.StockAllocationStatusActive). - Joins("LEFT JOIN purchase_items AS pi ON pi.id = sa.stockable_id AND sa.stockable_type = ?", purchaseStockableKey). - Joins("LEFT JOIN stock_transfer_details AS std ON std.id = sa.stockable_id AND sa.stockable_type = ?", transferStockableKey). - Joins("LEFT JOIN stock_transfers AS st ON st.id = std.stock_transfer_id"). - Joins("LEFT JOIN purchase_items AS tpi ON tpi.product_id = std.product_id AND tpi.warehouse_id = st.from_warehouse_id"). - Joins("LEFT JOIN flags AS f ON f.flagable_id = pi.product_id AND f.flagable_type = ?", entity.FlagableTypeProduct). - Joins("LEFT JOIN flags AS tf ON tf.flagable_id = std.product_id AND tf.flagable_type = ?", entity.FlagableTypeProduct). - Where("r.project_flock_kandangs_id IN ?", pfkIDs). - Where("r.deleted_at IS NULL"). - Group("pw.product_id, p.name, COALESCE(f.name, tf.name)") - - if err := recordingQuery.Scan(&rows).Error; err != nil { - return nil, err - } - - chickinQuery := db. - Table("project_chickins AS pc"). - Select(` - pw.product_id AS product_id, - p.name AS product_name, - f.name AS flag_name, - COALESCE(SUM(pc.usage_qty), 0) AS total_qty, - COALESCE(SUM(pc.usage_qty * COALESCE(pi.price, 0)), 0) AS total_price, - COALESCE(AVG(COALESCE(pi.price, 0)), 0) AS average_price - `). - Joins("JOIN product_warehouses AS pw ON pw.id = pc.product_warehouse_id"). - Joins("JOIN products AS p ON p.id = pw.product_id"). - Joins("LEFT JOIN purchase_items AS pi ON pi.product_warehouse_id = pc.product_warehouse_id"). - Joins("LEFT JOIN flags AS f ON f.flagable_id = p.id AND f.flagable_type = ?", entity.FlagableTypeProduct). - Where("pc.project_flock_kandang_id IN ?", pfkIDs). - Where("pc.usage_qty > 0"). - Group("pw.product_id, p.name, f.name") - - var chickinRows []ActualUsageCostRow - if err := chickinQuery.Scan(&chickinRows).Error; err != nil { - return nil, err - } - - rows = append(rows, chickinRows...) - - return rows, nil -} - func (r *ClosingRepositoryImpl) GetProductsWithFlagsByIDs(ctx context.Context, productIDs []uint) ([]entity.Product, error) { if len(productIDs) == 0 { return []entity.Product{}, nil diff --git a/internal/modules/closings/repositories/closingKeuangan.repository.go b/internal/modules/closings/repositories/closingKeuangan.repository.go new file mode 100644 index 00000000..3763f92b --- /dev/null +++ b/internal/modules/closings/repositories/closingKeuangan.repository.go @@ -0,0 +1,316 @@ +package repository + +import ( + "context" + "fmt" + "sort" + "strings" + + "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + "gorm.io/gorm" +) + +// ClosingKeuanganRepository handles database operations for closing keuangan +type ClosingKeuanganRepository interface { + repository.BaseRepository[interface{}] + + // All Product Usage + GetAllProductUsageByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint, flagFilters []string) ([]ProductUsageRow, error) + + // DB returns the underlying GORM DB instance + DB() *gorm.DB +} + +type ClosingKeuanganRepositoryImpl struct { + *repository.BaseRepositoryImpl[interface{}] +} + +func NewClosingKeuanganRepository(db *gorm.DB) ClosingKeuanganRepository { + return &ClosingKeuanganRepositoryImpl{ + BaseRepositoryImpl: repository.NewBaseRepository[interface{}](db), + } +} + +// Result Rows + +type ProductUsageRow struct { + ProductID uint `gorm:"column:product_id"` + ProductName string `gorm:"column:product_name"` + FlagNames string `gorm:"column:flag_names"` + TotalQty float64 `gorm:"column:total_qty"` + Price float64 `gorm:"column:price"` + TotalPengeluaran float64 `gorm:"column:total_pengeluaran"` +} + +// GetAllProductUsageByProjectFlockKandangID gets all product usage for a project flock kandang +// Combines data from all usable types: recordings, chickins, marketing, transfers, adjustments +// flagFilters: optional filter to get only specific flags (e.g., ["PAKAN", "OVK"]), empty means get all +func (r *ClosingKeuanganRepositoryImpl) GetAllProductUsageByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint, flagFilters []string) ([]ProductUsageRow, error) { + if projectFlockKandangID == 0 { + return []ProductUsageRow{}, nil + } + + type SubQueryResult struct { + ProductID uint `gorm:"column:product_id"` + ProductName string `gorm:"column:product_name"` + TotalQty float64 `gorm:"column:total_qty"` + Price float64 `gorm:"column:price"` + } + + type AggregatedResult struct { + ProductID uint `gorm:"column:product_id"` + ProductName string `gorm:"column:product_name"` + TotalQty float64 `gorm:"column:total_qty"` + Price float64 `gorm:"column:price"` + PriceCount int `gorm:"-"` // For calculating average price + } + + type FlagResult struct { + ProductID uint `gorm:"column:product_id"` + FlagNames string `gorm:"column:flag_names"` + } + + var allResults []SubQueryResult + + // Subquery 1: Recordings + var recordingsResults []SubQueryResult + err := r.DB().WithContext(ctx). + Table("recordings r"). + Select("pw.product_id, p.name as product_name, "+ + "COALESCE(SUM(CASE "+ + "WHEN sa.stockable_type = 'PURCHASE_ITEMS' THEN COALESCE(sa.qty, 0) "+ + "WHEN sa.stockable_type = 'STOCK_TRANSFER_IN' THEN COALESCE(std.usage_qty, 0) "+ + "WHEN sa.stockable_type = 'TRANSFERTOLAYING_IN' THEN COALESCE(ltt.total_used, 0) "+ + "WHEN sa.stockable_type = 'ADJUSTMENT_IN' THEN COALESCE(adjs.total_used, 0) "+ + "WHEN sa.stockable_type = 'PROJECT_FLOCK_POPULATION' THEN COALESCE(pfp.total_used_qty, 0) "+ + "ELSE 0 END), 0) as total_qty, "+ + "COALESCE(AVG(CASE WHEN sa.stockable_type = 'PURCHASE_ITEMS' THEN COALESCE(pi.price, 0) END), 0) as price"). + Joins("JOIN recording_stocks rs ON rs.recording_id = r.id"). + Joins("JOIN product_warehouses pw ON pw.id = rs.product_warehouse_id"). + Joins("JOIN products p ON p.id = pw.product_id"). + Joins("LEFT JOIN stock_allocations sa ON sa.usable_type = 'RECORDING_STOCK' AND sa.usable_id = rs.id AND sa.status = 'ACTIVE'"). + Joins("LEFT JOIN purchase_items pi ON pi.id = sa.stockable_id AND sa.stockable_type = 'PURCHASE_ITEMS'"). + Joins("LEFT JOIN stock_transfer_details std ON std.id = sa.stockable_id AND sa.stockable_type = 'STOCK_TRANSFER_IN'"). + Joins("LEFT JOIN laying_transfer_targets ltt ON ltt.id = sa.stockable_id AND sa.stockable_type = 'TRANSFERTOLAYING_IN'"). + Joins("LEFT JOIN adjustment_stocks adjs ON adjs.id = sa.stockable_id AND sa.stockable_type = 'ADJUSTMENT_IN'"). + Joins("LEFT JOIN project_flock_populations pfp ON pfp.id = sa.stockable_id AND sa.stockable_type = 'PROJECT_FLOCK_POPULATION'"). + Where("r.project_flock_kandangs_id = ?", projectFlockKandangID). + Where("r.deleted_at IS NULL"). + Group("pw.product_id, p.name"). + Scan(&recordingsResults).Error + + if err != nil { + return nil, fmt.Errorf("failed to get recordings product usage: %w", err) + } + fmt.Printf("[REPO] Recordings query: %d results for projectFlockKandangID=%d\n", len(recordingsResults), projectFlockKandangID) + allResults = append(allResults, recordingsResults...) + + // Subquery 2: Chickins + var chickinsResults []SubQueryResult + err = r.DB().WithContext(ctx). + Table("project_chickins pc"). + Select("pw.product_id, p.name as product_name, "+ + "COALESCE(SUM(pc.usage_qty), 0) as total_qty, "+ + "COALESCE(AVG(COALESCE(pi.price, 0)), 0) as price"). + Joins("JOIN product_warehouses pw ON pw.id = pc.product_warehouse_id"). + Joins("JOIN products p ON p.id = pw.product_id"). + Joins("LEFT JOIN purchase_items pi ON pi.product_warehouse_id = pw.id"). + Where("pc.project_flock_kandang_id = ?", projectFlockKandangID). + Where("pc.usage_qty > 0"). + Group("pw.product_id, p.name"). + Scan(&chickinsResults).Error + + if err != nil { + return nil, fmt.Errorf("failed to get chickins product usage: %w", err) + } + fmt.Printf("[REPO] Chickins query: %d results for projectFlockKandangID=%d\n", len(chickinsResults), projectFlockKandangID) + allResults = append(allResults, chickinsResults...) + + // Subquery 3: Marketing Delivery + var marketingResults []SubQueryResult + err = r.DB().WithContext(ctx). + Table("marketing_delivery_products mdp"). + Select("pw.product_id, p.name as product_name, "+ + "COALESCE(SUM(mdp.usage_qty), 0) as total_qty, "+ + "COALESCE(AVG(COALESCE(pi.price, 0)), 0) as price"). + Joins("JOIN marketing_products mp ON mp.id = mdp.marketing_product_id"). + Joins("JOIN product_warehouses pw ON pw.id = mp.product_warehouse_id"). + Joins("JOIN products p ON p.id = pw.product_id"). + Joins("LEFT JOIN purchase_items pi ON pi.product_warehouse_id = pw.id"). + Where("pw.project_flock_kandang_id = ?", projectFlockKandangID). + Group("pw.product_id, p.name"). + Scan(&marketingResults).Error + + if err != nil { + return nil, fmt.Errorf("failed to get marketing product usage: %w", err) + } + fmt.Printf("[REPO] Marketing query: %d results for projectFlockKandangID=%d\n", len(marketingResults), projectFlockKandangID) + allResults = append(allResults, marketingResults...) + + // Subquery 4: Laying Transfer Sources + var layingTransferResults []SubQueryResult + err = r.DB().WithContext(ctx). + Table("laying_transfer_sources lts"). + Select("pw.product_id, p.name as product_name, "+ + "COALESCE(SUM(lts.usage_qty), 0) as total_qty, "+ + "COALESCE(AVG(COALESCE(pi.price, 0)), 0) as price"). + Joins("JOIN laying_transfers lt ON lt.id = lts.laying_transfer_id"). + Joins("JOIN product_warehouses pw ON pw.id = lts.product_warehouse_id"). + Joins("JOIN products p ON p.id = pw.product_id"). + Joins("LEFT JOIN purchase_items pi ON pi.product_warehouse_id = pw.id"). + Where("pw.project_flock_kandang_id = ?", projectFlockKandangID). + Group("pw.product_id, p.name"). + Scan(&layingTransferResults).Error + + if err != nil { + return nil, fmt.Errorf("failed to get laying transfer product usage: %w", err) + } + fmt.Printf("[REPO] Laying Transfer query: %d results for projectFlockKandangID=%d\n", len(layingTransferResults), projectFlockKandangID) + allResults = append(allResults, layingTransferResults...) + + // Subquery 5: Stock Transfer Details + var stockTransferResults []SubQueryResult + err = r.DB().WithContext(ctx). + Table("stock_transfer_details std"). + Select("pw.product_id, p.name as product_name, "+ + "COALESCE(SUM(std.usage_qty), 0) as total_qty, "+ + "COALESCE(AVG(COALESCE(pi.price, 0)), 0) as price"). + Joins("JOIN product_warehouses pw ON pw.id = std.source_product_warehouse_id"). + Joins("JOIN products p ON p.id = std.product_id"). + Joins("LEFT JOIN purchase_items pi ON pi.product_warehouse_id = pw.id"). + Where("pw.project_flock_kandang_id = ?", projectFlockKandangID). + Group("pw.product_id, p.name"). + Scan(&stockTransferResults).Error + + if err != nil { + return nil, fmt.Errorf("failed to get stock transfer product usage: %w", err) + } + fmt.Printf("[REPO] Stock Transfer query: %d results for projectFlockKandangID=%d\n", len(stockTransferResults), projectFlockKandangID) + allResults = append(allResults, stockTransferResults...) + + // Subquery 6: Adjustment Stocks + var adjustmentResults []SubQueryResult + err = r.DB().WithContext(ctx). + Table("adjustment_stocks ads"). + Select("pw.product_id, p.name as product_name, "+ + "COALESCE(SUM(ads.usage_qty), 0) as total_qty, "+ + "COALESCE(AVG(COALESCE(pi.price, 0)), 0) as price"). + Joins("JOIN product_warehouses pw ON pw.id = ads.product_warehouse_id"). + Joins("JOIN products p ON p.id = pw.product_id"). + Joins("LEFT JOIN purchase_items pi ON pi.product_warehouse_id = pw.id"). + Where("pw.project_flock_kandang_id = ?", projectFlockKandangID). + Where("ads.usage_qty > 0"). + Group("pw.product_id, p.name"). + Scan(&adjustmentResults).Error + + if err != nil { + return nil, fmt.Errorf("failed to get adjustment product usage: %w", err) + } + fmt.Printf("[REPO] Adjustment query: %d results for projectFlockKandangID=%d\n", len(adjustmentResults), projectFlockKandangID) + allResults = append(allResults, adjustmentResults...) + + fmt.Printf("[REPO] Total raw results before aggregation: %d items\n", len(allResults)) + + // Aggregate results by product_id + aggregatedMap := make(map[uint]*AggregatedResult) + for _, result := range allResults { + key := result.ProductID + if existing, exists := aggregatedMap[key]; exists { + existing.TotalQty += result.TotalQty + existing.Price += result.Price + existing.PriceCount++ + } else { + aggregatedMap[key] = &AggregatedResult{ + ProductID: result.ProductID, + ProductName: result.ProductName, + TotalQty: result.TotalQty, + Price: result.Price, + PriceCount: 1, + } + } + } + + fmt.Printf("[REPO] Aggregated to %d unique products\n", len(aggregatedMap)) + + // Get flags for all products + productIDs := make([]uint, 0, len(aggregatedMap)) + for id := range aggregatedMap { + productIDs = append(productIDs, id) + } + + var flagResults []FlagResult + if len(productIDs) > 0 { + err = r.DB().WithContext(ctx). + Table("products p"). + Select("p.id as product_id, STRING_AGG(DISTINCT f.name, ', ') as flag_names"). + Joins("LEFT JOIN flags f ON f.flagable_type = 'products' AND f.flagable_id = p.id"). + Where("p.id IN ?", productIDs). + Group("p.id"). + Scan(&flagResults).Error + + if err != nil { + return nil, fmt.Errorf("failed to get product flags: %w", err) + } + } + fmt.Printf("[REPO] Fetched flags for %d products\n", len(flagResults)) + + // Build flag map + flagMap := make(map[uint]string) + for _, flag := range flagResults { + flagMap[flag.ProductID] = flag.FlagNames + } + + // Combine results and calculate average price + results := make([]ProductUsageRow, 0, len(aggregatedMap)) + for _, agg := range aggregatedMap { + avgPrice := float64(0) + if agg.PriceCount > 0 { + avgPrice = agg.Price / float64(agg.PriceCount) + } + + flagNames := flagMap[agg.ProductID] + + // Apply flag filters if provided + if len(flagFilters) > 0 { + // Check if any of the flagFilters exist in flagNames + matched := false + for _, filter := range flagFilters { + if containsIgnoreCase(flagNames, filter) { + matched = true + break + } + } + if !matched { + continue // Skip this product if no flag matches + } + } + + results = append(results, ProductUsageRow{ + ProductID: agg.ProductID, + ProductName: agg.ProductName, + FlagNames: flagNames, + TotalQty: agg.TotalQty, + Price: avgPrice, + TotalPengeluaran: agg.TotalQty * avgPrice, + }) + } + + fmt.Printf("[REPO] After filtering with flagFilters=%v: %d results\n", flagFilters, len(results)) + for i, r := range results { + fmt.Printf("[REPO] Result[%d]: ProductID=%d, ProductName=%s, FlagNames=%s, TotalQty=%.2f, Price=%.2f, TotalPengeluaran=%.2f\n", + i, r.ProductID, r.ProductName, r.FlagNames, r.TotalQty, r.Price, r.TotalPengeluaran) + } + + // Sort by product name + sort.Slice(results, func(i, j int) bool { + return results[i].ProductName < results[j].ProductName + }) + + fmt.Printf("[REPO] Final sorted results: %d items\n", len(results)) + return results, nil +} + +// containsIgnoreCase checks if a string contains a substring (case-insensitive) +func containsIgnoreCase(str, substr string) bool { + return strings.Contains(strings.ToUpper(str), strings.ToUpper(substr)) +} diff --git a/internal/modules/closings/route.go b/internal/modules/closings/route.go index 1cd4559d..89578aeb 100644 --- a/internal/modules/closings/route.go +++ b/internal/modules/closings/route.go @@ -9,8 +9,8 @@ import ( "github.com/gofiber/fiber/v2" ) -func ClosingRoutes(v1 fiber.Router, u user.UserService, s closing.ClosingService, sapronakSvc closing.SapronakService) { - ctrl := controller.NewClosingController(s, sapronakSvc) +func ClosingRoutes(v1 fiber.Router, u user.UserService, s closing.ClosingService, sapronakSvc closing.SapronakService, closingKeuanganSvc closing.ClosingKeuanganService) { + ctrl := controller.NewClosingController(s, sapronakSvc, closingKeuanganSvc) route := v1.Group("/closings") route.Use(m.Auth(u)) diff --git a/internal/modules/closings/services/closing.service.go b/internal/modules/closings/services/closing.service.go index 6b0b56f6..8cda7220 100644 --- a/internal/modules/closings/services/closing.service.go +++ b/internal/modules/closings/services/closing.service.go @@ -40,7 +40,6 @@ type ClosingService interface { GetClosingDataProduksi(ctx *fiber.Ctx, projectFlockID uint, kandangID *uint) (*dto.ClosingProductionReportDTO, error) GetOverhead(ctx *fiber.Ctx, projectFlockID uint, projectFlockKandangID *uint) (*dto.OverheadListDTO, error) GetClosingSapronak(ctx *fiber.Ctx, projectFlockID uint, params *validation.ClosingSapronakQuery) ([]dto.ClosingSapronakItemDTO, int64, error) - GetClosingKeuangan(ctx *fiber.Ctx, projectFlockID uint) (*dto.ReportResponse, error) GetExpeditionHPP(ctx *fiber.Ctx, projectFlockID uint, projectFlockKandangID *uint) (*dto.ExpeditionHPPDTO, error) } @@ -576,82 +575,6 @@ func (s closingService) GetOverhead(c *fiber.Ctx, projectFlockID uint, projectFl return &result, nil } -func (s closingService) GetClosingKeuangan(c *fiber.Ctx, projectFlockID uint) (*dto.ReportResponse, error) { - - if err := commonSvc.EnsureRelations(c.Context(), - commonSvc.RelationCheck{Name: "Project Flock", ID: &projectFlockID, Exists: s.ProjectFlockRepo.IdExists}, - ); err != nil { - return nil, err - } - - projectFlock, err := s.ProjectFlockRepo.GetByID(c.Context(), projectFlockID, nil) - if err != nil { - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch project flock") - } - - budgets, err := s.ProjectBudgetRepo.GetByProjectFlockID(c.Context(), projectFlockID) - if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch budgets") - } - - actualUsageRows, err := s.Repository.GetActualUsageCostByProjectFlockID(c.Context(), projectFlockID) - if err != nil { - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch actual usage cost") - } - - purchaseItems := s.convertActualUsageToPurchaseItems(c.Context(), actualUsageRows) - - realizations, err := s.ExpenseRealizationRepo.GetByProjectFlockID(c.Context(), projectFlockID) - if err != nil { - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch realizations") - } - - deliveryProducts, err := s.MarketingDeliveryProductRepo.GetDeliveryProductsByProjectFlockID(c.Context(), projectFlockID, func(db *gorm.DB) *gorm.DB { - return db.Preload("MarketingProduct"). - Preload("MarketingProduct.ProductWarehouse"). - Preload("MarketingProduct.ProductWarehouse.Product") - }) - if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch delivery products") - } - - chickins, err := s.ChickinRepo.GetByProjectFlockID(c.Context(), projectFlockID) - if err != nil { - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch chickins") - } - - totalWeightProduced, _, err := s.RecordingRepo.GetProductionWeightAndQtyByProjectFlockID(c.Context(), projectFlockID) - if err != nil { - s.Log.Warnf("GetProductionWeightAndQtyByProjectFlockID error: %v", err) - } - - totalEggWeightKg, err := s.RecordingRepo.GetTotalEggProductionWeightByProjectFlockID(c.Context(), projectFlockID) - if err != nil { - s.Log.Warnf("GetTotalEggProductionWeightByProjectFlockID error: %v", err) - } - - totalDepletion, err := s.RecordingRepo.GetTotalDepletionByProjectFlockID(c.Context(), projectFlockID) - if err != nil { - s.Log.Warnf("GetTotalDepletionByProjectFlockID error: %v", err) - } - - input := dto.ClosingKeuanganInput{ - ProjectFlockCategory: projectFlock.Category, - PurchaseItems: purchaseItems, - Budgets: budgets, - Realizations: realizations, - DeliveryProducts: deliveryProducts, - Chickins: chickins, - TotalWeightProduced: totalWeightProduced, - TotalEggWeightKg: totalEggWeightKg, - TotalDepletion: totalDepletion, - } - - report := dto.ToClosingKeuanganReport(input) - - return &report, nil -} - func (s closingService) GetExpeditionHPP(c *fiber.Ctx, projectFlockID uint, projectFlockKandangID *uint) (*dto.ExpeditionHPPDTO, error) { if projectFlockID == 0 { return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid project flock id") @@ -1108,52 +1031,3 @@ func closestFcrValues(standards []entity.FcrStandard, averageWeight float64) (fl return closest.Mortality, closest.FcrNumber } -func (s closingService) convertActualUsageToPurchaseItems(ctx context.Context, actualUsageRows []repository.ActualUsageCostRow) []entity.PurchaseItem { - if len(actualUsageRows) == 0 { - return []entity.PurchaseItem{} - } - - // Collect all product IDs - productIDs := make([]uint, len(actualUsageRows)) - for i, row := range actualUsageRows { - productIDs[i] = row.ProductID - } - - // Fetch products with flags from repository - products, err := s.Repository.GetProductsWithFlagsByIDs(ctx, productIDs) - if err != nil { - s.Log.Warnf("Failed to fetch products for actual usage: %v", err) - products = []entity.Product{} - } - - // Create product map - productMap := make(map[uint]*entity.Product) - for i := range products { - productMap[products[i].Id] = &products[i] - } - - // Convert to pseudo purchase items - purchaseItems := make([]entity.PurchaseItem, 0, len(actualUsageRows)) - for _, row := range actualUsageRows { - product := productMap[row.ProductID] - - // Skip if product not found - if product == nil { - s.Log.Warnf("Product ID %d not found for actual usage", row.ProductID) - continue - } - - purchaseItem := entity.PurchaseItem{ - Id: 0, // Pseudo item, no ID - ProductId: row.ProductID, - TotalQty: row.TotalQty, - TotalPrice: row.TotalPrice, - Price: row.AveragePrice, - Product: product, - } - - purchaseItems = append(purchaseItems, purchaseItem) - } - - return purchaseItems -} diff --git a/internal/modules/closings/services/closingKeuangan.service.go b/internal/modules/closings/services/closingKeuangan.service.go new file mode 100644 index 00000000..d041d765 --- /dev/null +++ b/internal/modules/closings/services/closingKeuangan.service.go @@ -0,0 +1,655 @@ +package service + +import ( + "errors" + "strings" + + commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gitlab.com/mbugroup/lti-api.git/internal/modules/closings/dto" + repository "gitlab.com/mbugroup/lti-api.git/internal/modules/closings/repositories" + expenseRealizationRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/repositories" + marketingDeliveryProductRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/repositories" + chickinRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/production/chickins/repositories" + projectflockRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" + recordingRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/repositories" + "gitlab.com/mbugroup/lti-api.git/internal/utils" + "github.com/gofiber/fiber/v2" + "github.com/sirupsen/logrus" + "gorm.io/gorm" +) + +// ClosingKeuanganService handles closing keuangan business logic +type ClosingKeuanganService interface { + GetClosingKeuangan(ctx *fiber.Ctx, projectFlockID uint) (*dto.ClosingKeuanganResponse, error) +} + +type closingKeuanganService struct { + Log *logrus.Logger + ClosingKeuanganRepo repository.ClosingKeuanganRepository + ProjectFlockRepo projectflockRepository.ProjectflockRepository + ProjectFlockKandangRepo projectflockRepository.ProjectFlockKandangRepository + MarketingDeliveryProductRepo marketingDeliveryProductRepository.MarketingDeliveryProductRepository + ExpenseRealizationRepo expenseRealizationRepository.ExpenseRealizationRepository + ProjectBudgetRepo projectflockRepository.ProjectBudgetRepository + ChickinRepo chickinRepository.ProjectChickinRepository + RecordingRepo recordingRepository.RecordingRepository +} + +func NewClosingKeuanganService( + closingKeuanganRepo repository.ClosingKeuanganRepository, + projectFlockRepo projectflockRepository.ProjectflockRepository, + projectFlockKandangRepo projectflockRepository.ProjectFlockKandangRepository, + marketingDeliveryProductRepo marketingDeliveryProductRepository.MarketingDeliveryProductRepository, + expenseRealizationRepo expenseRealizationRepository.ExpenseRealizationRepository, + projectBudgetRepo projectflockRepository.ProjectBudgetRepository, + chickinRepo chickinRepository.ProjectChickinRepository, + recordingRepo recordingRepository.RecordingRepository, +) ClosingKeuanganService { + return &closingKeuanganService{ + Log: utils.Log, + ClosingKeuanganRepo: closingKeuanganRepo, + ProjectFlockRepo: projectFlockRepo, + ProjectFlockKandangRepo: projectFlockKandangRepo, + MarketingDeliveryProductRepo: marketingDeliveryProductRepo, + ExpenseRealizationRepo: expenseRealizationRepo, + ProjectBudgetRepo: projectBudgetRepo, + ChickinRepo: chickinRepo, + RecordingRepo: recordingRepo, + } +} + +func (s closingKeuanganService) GetClosingKeuangan(c *fiber.Ctx, projectFlockID uint) (*dto.ClosingKeuanganResponse, error) { + s.Log.Infof("===== START GetClosingKeuangan for ProjectFlockID: %d =====", projectFlockID) + + if err := commonSvc.EnsureRelations(c.Context(), + commonSvc.RelationCheck{Name: "Project Flock", ID: &projectFlockID, Exists: s.ProjectFlockRepo.IdExists}, + ); err != nil { + return nil, err + } + + projectFlock, err := s.ProjectFlockRepo.GetByID(c.Context(), projectFlockID, nil) + if err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch project flock") + } + s.Log.Infof("ProjectFlock: ID=%d, Name=%s, Category=%s", projectFlock.Id, projectFlock.FlockName, projectFlock.Category) + + budgets, err := s.ProjectBudgetRepo.GetByProjectFlockID(c.Context(), projectFlockID) + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch budgets") + } + + // Preload Nonstock.Flags manually + var budgetIDs []uint + for _, b := range budgets { + budgetIDs = append(budgetIDs, b.Id) + } + if len(budgetIDs) > 0 { + err = s.ProjectBudgetRepo.DB().WithContext(c.Context()). + Preload("Nonstock.Flags"). + Where("id IN ?", budgetIDs). + Find(&budgets).Error + if err != nil { + s.Log.Warnf("Failed to preload Nonstock.Flags: %v", err) + } + } + + s.Log.Infof("Budgets fetched: %d items", len(budgets)) + for i, b := range budgets { + nonstockName := "Unknown" + if b.Nonstock != nil { + nonstockName = b.Nonstock.Name + } + s.Log.Infof(" Budget[%d]: ID=%d, Nonstock=%s, Price=%.2f, Qty=%.2f", i, b.Id, nonstockName, b.Price, b.Qty) + } + + // Get all kandang for this project flock + kandangs, err := s.ProjectFlockKandangRepo.GetByProjectFlockID(c.Context(), projectFlockID) + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch kandangs") + } + s.Log.Infof("Kandangs fetched: %d kandangs", len(kandangs)) + for i, k := range kandangs { + s.Log.Infof(" Kandang[%d]: ID=%d, KandangID=%d, Period=%d", i, k.Id, k.KandangId, k.Period) + } + + // Define flag filters + // PAKAN flags: PAKAN, PRE-STARTER, STARTER, FINISHER (priority 1) + // OVK flags: OVK, OBAT, VITAMIN, KIMIA, EKSPEDISI (priority 2) + // AYAM flags: DOC, PULLET, LAYER (priority 3 - only if no PAKAN and OVK) + pakanFilters := []string{"PAKAN", "PRE-STARTER", "STARTER", "FINISHER"} + ovkFilters := []string{"OVK", "OBAT", "VITAMIN", "KIMIA", "EKSPEDISI"} + ayamFilters := []string{"DOC", "PULLET", "LAYER"} + allFilters := append(pakanFilters, ovkFilters...) + allFilters = append(allFilters, ayamFilters...) + s.Log.Infof("All Filters: %v", allFilters) + + var allProductUsageRows []repository.ProductUsageRow + + // Get ALL product usage once + s.Log.Infof("===== Fetching ALL product usage =====") + for _, kandang := range kandangs { + s.Log.Infof("Fetching ALL for kandang ID=%d", kandang.Id) + rows, err := s.ClosingKeuanganRepo.GetAllProductUsageByProjectFlockKandangID(c.Context(), kandang.Id, allFilters) + if err != nil { + s.Log.Errorf("Failed to get product usage for kandang %d: %v", kandang.Id, err) + } else { + s.Log.Infof("Kandang %d: Got %d rows", kandang.Id, len(rows)) + for i, row := range rows { + s.Log.Infof(" [%d]: ProductID=%d, ProductName=%s, FlagNames=%s, TotalQty=%.2f, Price=%.2f, TotalPengeluaran=%.2f", + i, row.ProductID, row.ProductName, row.FlagNames, row.TotalQty, row.Price, row.TotalPengeluaran) + } + allProductUsageRows = append(allProductUsageRows, rows...) + } + } + + // Classify into categories based on flag priority + var pakanProductUsageRows []repository.ProductUsageRow + var ovkProductUsageRows []repository.ProductUsageRow + var ayamProductUsageRows []repository.ProductUsageRow + + s.Log.Infof("===== Classifying products by flag priority =====") + for _, row := range allProductUsageRows { + // Parse flag names from comma-separated string + flagNames := strings.Split(row.FlagNames, ",") + + hasPakanFlag := false + hasOvkFlag := false + hasAyamFlag := false + + for _, flag := range flagNames { + flag = strings.TrimSpace(flag) + if containsItem(pakanFilters, flag) { + hasPakanFlag = true + } + if containsItem(ovkFilters, flag) { + hasOvkFlag = true + } + if containsItem(ayamFilters, flag) { + hasAyamFlag = true + } + } + + // Priority: PAKAN > OVK > AYAM + category := "" + if hasPakanFlag { + pakanProductUsageRows = append(pakanProductUsageRows, row) + category = "PAKAN" + } else if hasOvkFlag { + ovkProductUsageRows = append(ovkProductUsageRows, row) + category = "OVK" + } else if hasAyamFlag { + ayamProductUsageRows = append(ayamProductUsageRows, row) + category = "AYAM" + } else { + s.Log.Warnf("ProductID=%d (%s) has no recognized flags: %s", row.ProductID, row.ProductName, row.FlagNames) + continue + } + + s.Log.Infof("ProductID=%d (%s) → Category: %s (Flags: %s, Pakan=%v, OVK=%v, Ayam=%v)", + row.ProductID, row.ProductName, category, row.FlagNames, hasPakanFlag, hasOvkFlag, hasAyamFlag) + } + + s.Log.Infof("Total ProductUsageRows collected: %d items", len(allProductUsageRows)) + s.Log.Infof(" - PAKAN: %d items", len(pakanProductUsageRows)) + s.Log.Infof(" - OVK: %d items", len(ovkProductUsageRows)) + s.Log.Infof(" - AYAM: %d items", len(ayamProductUsageRows)) + + // Calculate total price for each category + var totalPakanPrice, totalOvkPrice, totalAyamPrice float64 + for _, row := range pakanProductUsageRows { + totalPakanPrice += row.TotalPengeluaran + } + for _, row := range ovkProductUsageRows { + totalOvkPrice += row.TotalPengeluaran + } + for _, row := range ayamProductUsageRows { + totalAyamPrice += row.TotalPengeluaran + } + s.Log.Infof("===== TOTAL PRICE BY CATEGORY =====") + s.Log.Infof(" - PAKAN Total Price: %.2f", totalPakanPrice) + s.Log.Infof(" - OVK Total Price: %.2f", totalOvkPrice) + s.Log.Infof(" - AYAM Total Price: %.2f", totalAyamPrice) + s.Log.Infof(" - ALL Total Price: %.2f", totalPakanPrice+totalOvkPrice+totalAyamPrice) + + realizations, err := s.ExpenseRealizationRepo.GetByProjectFlockID(c.Context(), projectFlockID) + if err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch realizations") + } + s.Log.Infof("Realizations fetched: %d items", len(realizations)) + for i, r := range realizations { + s.Log.Infof(" Realization[%d]: ID=%d, Price=%.2f, Qty=%.2f", i, r.Id, r.Price, r.Qty) + } + + deliveryProducts, err := s.MarketingDeliveryProductRepo.GetDeliveryProductsByProjectFlockID(c.Context(), projectFlockID, func(db *gorm.DB) *gorm.DB { + return db.Preload("MarketingProduct"). + Preload("MarketingProduct.ProductWarehouse"). + Preload("MarketingProduct.ProductWarehouse.Product") + }) + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch delivery products") + } + s.Log.Infof("DeliveryProducts fetched: %d items", len(deliveryProducts)) + for i, dp := range deliveryProducts { + s.Log.Infof(" DeliveryProduct[%d]: ID=%d, TotalWeight=%.2f, TotalPrice=%.2f", i, dp.Id, dp.TotalWeight, dp.TotalPrice) + } + + chickins, err := s.ChickinRepo.GetByProjectFlockID(c.Context(), projectFlockID) + if err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch chickins") + } + s.Log.Infof("Chickins fetched: %d items", len(chickins)) + for i, ch := range chickins { + s.Log.Infof(" Chickin[%d]: ID=%d, UsageQty=%.2f", i, ch.Id, ch.UsageQty) + } + + // Get total depletion + totalDepletion, err := s.RecordingRepo.GetTotalDepletionByProjectFlockID(c.Context(), projectFlockID) + if err != nil { + s.Log.Warnf("GetTotalDepletionByProjectFlockID error: %v", err) + totalDepletion = 0 + } + s.Log.Infof("TotalDepletion: %.2f birds", totalDepletion) + + totalWeightProduced, _, err := s.RecordingRepo.GetProductionWeightAndQtyByProjectFlockID(c.Context(), projectFlockID) + if err != nil { + s.Log.Warnf("GetProductionWeightAndQtyByProjectFlockID error: %v", err) + } + s.Log.Infof("TotalWeightProduced (from stub): %.2f kg", totalWeightProduced) + + // Try to get actual weight from uniformity data + totalWeightFromUniformity, err := s.RecordingRepo.GetTotalWeightProducedFromUniformityByProjectFlockID(c.Context(), projectFlockID) + if err != nil { + s.Log.Warnf("GetTotalWeightProducedFromUniformityByProjectFlockID error: %v", err) + } else if totalWeightFromUniformity > 0 { + totalWeightProduced = totalWeightFromUniformity + s.Log.Infof("TotalWeightProduced (from uniformity): %.2f kg", totalWeightProduced) + } + + // Fetch egg data only for Laying category + var totalEggWeightKg float64 + if projectFlock.Category == string(utils.ProjectFlockCategoryLaying) { + s.Log.Infof("===== Fetching EGG data (Laying Category) =====") + // TODO: Replace with actual method to get egg weight from RecordingRepo + // totalEggWeightKg, err = s.RecordingRepo.GetEggWeightByProjectFlockID(c.Context(), projectFlockID) + // For now, set to 0 as placeholder + totalEggWeightKg = 0 + s.Log.Infof("TotalEggWeightKg: %.2f kg", totalEggWeightKg) + if err != nil { + s.Log.Warnf("Failed to fetch egg weight: %v", err) + } + } else { + s.Log.Infof("Skipping egg data fetch (Category: %s - not Laying)", projectFlock.Category) + totalEggWeightKg = 0 + } + + // Build new DTO structure + s.Log.Infof("===== BUILDING NEW DTO RESPONSE =====") + + // Calculate totals + var totalPopulation float64 + for _, chickin := range chickins { + totalPopulation += chickin.UsageQty + } + + // Calculate actual population (total population - depletion) + actualPopulation := totalPopulation - totalDepletion + s.Log.Infof("Population - Total: %.2f, Depletion: %.2f, Actual: %.2f", totalPopulation, totalDepletion, actualPopulation) + + // Calculate budget totals by category + calculateBudgetByFlag := func(flags []string) float64 { + var total float64 + for _, budget := range budgets { + if budget.Nonstock != nil { + for _, nonstockFlag := range budget.Nonstock.Flags { + flagName := strings.ToUpper(nonstockFlag.Name) + for _, targetFlag := range flags { + if flagName == strings.ToUpper(targetFlag) { + total += budget.Price * budget.Qty + break + } + } + } + } + } + return total + } + + // Budget per category + budgetPakan := calculateBudgetByFlag([]string{"PAKAN", "PRE-STARTER", "STARTER", "FINISHER"}) + budgetOvk := calculateBudgetByFlag([]string{"OVK", "OBAT", "VITAMIN", "KIMIA", "EKSPEDISI"}) + budgetAyam := calculateBudgetByFlag([]string{"DOC", "PULLET", "LAYER"}) + budgetEkspedisi := calculateBudgetByFlag([]string{"EKSPEDISI"}) + + // Operational budget = total budget - pakan - ovk - ayam - ekspedisi + totalBudgetAmount := 0.0 + for _, budget := range budgets { + totalBudgetAmount += budget.Price * budget.Qty + } + budgetOperational := totalBudgetAmount - budgetPakan - budgetOvk - budgetAyam - budgetEkspedisi + + s.Log.Infof("Budgets by category:") + s.Log.Infof(" PAKAN: %.2f", budgetPakan) + s.Log.Infof(" OVK: %.2f", budgetOvk) + s.Log.Infof(" AYAM: %.2f", budgetAyam) + s.Log.Infof(" OVERHEAD: %.2f", budgetOperational) + s.Log.Infof(" EKSPEDISI: %.2f", budgetEkspedisi) + + // Calculate realization totals + var totalRealizationAmount float64 + var totalEkspedisiRealization float64 + for _, realization := range realizations { + amount := realization.Price * realization.Qty + totalRealizationAmount += amount + + // Check if this is ekspedisi (need to check nonstock flags) + if realization.ExpenseNonstock != nil && realization.ExpenseNonstock.Nonstock != nil { + for _, flag := range realization.ExpenseNonstock.Nonstock.Flags { + if flag.Name == "EKSPEDISI" { + totalEkspedisiRealization += amount + break + } + } + } + } + + totalOperationalRealization := totalRealizationAmount - totalEkspedisiRealization + + // Filter delivery products based on category + var filteredDeliveryProducts []entity.MarketingDeliveryProduct + for _, delivery := range deliveryProducts { + // Get product from delivery + if delivery.MarketingProduct.ProductWarehouse.Product.Id == 0 { + continue + } + + product := delivery.MarketingProduct.ProductWarehouse.Product + isEggProduct := false + isChickenProduct := false + + // Check product flags + for _, flag := range product.Flags { + flagName := strings.ToUpper(flag.Name) + + // Egg product flags + if flagName == "TELUR" || flagName == "TELURUTUH" || flagName == "TELURPECAH" || + flagName == "TELURPUTIH" || flagName == "TELURRETAK" { + isEggProduct = true + } + + // Chicken product flags + if flagName == "AYAMAFKIR" || flagName == "AYAMCULLING" || flagName == "AYAMMATI" { + isChickenProduct = true + } + } + + // Filter based on project flock category + if projectFlock.Category == string(utils.ProjectFlockCategoryLaying) { + // Laying: only egg products + if isEggProduct { + filteredDeliveryProducts = append(filteredDeliveryProducts, delivery) + } + } else { + // Growing/Contract Growing: only chicken products + if isChickenProduct || (!isEggProduct && !isChickenProduct) { + // Include if chicken product or if no specific flags (default to chicken) + filteredDeliveryProducts = append(filteredDeliveryProducts, delivery) + } + } + } + + s.Log.Infof("Filtered DeliveryProducts: %d items (from %d total)", len(filteredDeliveryProducts), len(deliveryProducts)) + + // Calculate total weight sold and sales amount from filtered products + var totalWeightSold float64 + var totalSalesAmount float64 + for _, delivery := range filteredDeliveryProducts { + totalWeightSold += delivery.TotalWeight + totalSalesAmount += delivery.TotalPrice + } + + s.Log.Infof("Calculated totals:") + s.Log.Infof(" Total Population: %.2f", totalPopulation) + s.Log.Infof(" Actual Population: %.2f (after depletion)", actualPopulation) + s.Log.Infof(" Total Weight Produced: %.2f kg (ayam)", totalWeightProduced) + s.Log.Infof(" Total Weight Sold: %.2f kg (filtered by category)", totalWeightSold) + s.Log.Infof(" Total Budget: %.2f", totalBudgetAmount) + s.Log.Infof(" Total Realization: %.2f (Operational: %.2f, Ekspedisi: %.2f)", totalRealizationAmount, totalOperationalRealization, totalEkspedisiRealization) + s.Log.Infof(" Total Sales: %.2f", totalSalesAmount) + + // Calculate metrics - always use kg ayam for rp_per_kg + calculateMetrics := func(amount float64) (rpPerBird, rpPerKg float64) { + if actualPopulation > 0 { + rpPerBird = amount / actualPopulation // Use actual population + } + if totalWeightProduced > 0 { + rpPerKg = amount / totalWeightProduced + } + return + } + + // Calculate metrics for profit loss (use total population for sales) + calculateProfitLossMetrics := func(amount float64) (rpPerBird, rpPerKg float64) { + if totalPopulation > 0 { + rpPerBird = amount / totalPopulation + } + if totalWeightSold > 0 { + rpPerKg = amount / totalWeightSold + } + return + } + + // Build HPP Items + hppItems := []dto.HPPItem{} + + // PAKAN item + pakanBudgetRpPerBird, pakanBudgetRpPerKg := calculateMetrics(budgetPakan) + pakanRealizationRpPerBird, pakanRealizationRpPerKg := calculateMetrics(totalPakanPrice) + hppItems = append(hppItems, dto.ToHPPItem( + 1, + "purchase", + "PAKAN", + "Pakan", + dto.ToFinancialMetrics(pakanBudgetRpPerBird, pakanBudgetRpPerKg, budgetPakan), + dto.ToFinancialMetrics(pakanRealizationRpPerBird, pakanRealizationRpPerKg, totalPakanPrice), + )) + + // OVK item + ovkBudgetRpPerBird, ovkBudgetRpPerKg := calculateMetrics(budgetOvk) + ovkRealizationRpPerBird, ovkRealizationRpPerKg := calculateMetrics(totalOvkPrice) + hppItems = append(hppItems, dto.ToHPPItem( + 2, + "purchase", + "OVK", + "OVK", + dto.ToFinancialMetrics(ovkBudgetRpPerBird, ovkBudgetRpPerKg, budgetOvk), + dto.ToFinancialMetrics(ovkRealizationRpPerBird, ovkRealizationRpPerKg, totalOvkPrice), + )) + + // DOC item + docBudgetRpPerBird, docBudgetRpPerKg := calculateMetrics(budgetAyam) + docRealizationRpPerBird, docRealizationRpPerKg := calculateMetrics(totalAyamPrice) + hppItems = append(hppItems, dto.ToHPPItem( + 3, + "purchase", + "DOC", + "DOC", + dto.ToFinancialMetrics(docBudgetRpPerBird, docBudgetRpPerKg, budgetAyam), + dto.ToFinancialMetrics(docRealizationRpPerBird, docRealizationRpPerKg, totalAyamPrice), + )) + + // OVERHEAD operational item (before EKSPEDISI) + overheadBudgetRpPerBird, overheadBudgetRpPerKg := calculateMetrics(budgetOperational) + overheadRealizationRpPerBird, overheadRealizationRpPerKg := calculateMetrics(totalOperationalRealization) + hppItems = append(hppItems, dto.ToHPPItem( + 4, + "overhead", + "OVERHEAD", + "Pengeluaran Overhead", + dto.ToFinancialMetrics(overheadBudgetRpPerBird, overheadBudgetRpPerKg, budgetOperational), + dto.ToFinancialMetrics(overheadRealizationRpPerBird, overheadRealizationRpPerKg, totalOperationalRealization), + )) + + // EKSPEDISI item (overhead) + ekspedisiBudgetRpPerBird, ekspedisiBudgetRpPerKg := calculateMetrics(budgetEkspedisi) + ekspedisiRealizationRpPerBird, ekspedisiRealizationRpPerKg := calculateMetrics(totalEkspedisiRealization) + hppItems = append(hppItems, dto.ToHPPItem( + 5, + "overhead", + "EKSPEDISI", + "Beban Ekspedisi", + dto.ToFinancialMetrics(ekspedisiBudgetRpPerBird, ekspedisiBudgetRpPerKg, budgetEkspedisi), + dto.ToFinancialMetrics(ekspedisiRealizationRpPerBird, ekspedisiRealizationRpPerKg, totalEkspedisiRealization), + )) + + // HPP Summary + totalBudgetHpp := budgetPakan + budgetOvk + budgetAyam + budgetOperational + budgetEkspedisi + totalRealizationHpp := totalPakanPrice + totalOvkPrice + totalAyamPrice + totalOperationalRealization + totalEkspedisiRealization + + hppBudgetRpPerBird, hppBudgetRpPerKg := calculateMetrics(totalBudgetHpp) + hppRealizationRpPerBird, hppRealizationRpPerKg := calculateMetrics(totalRealizationHpp) + + var eggBudgeting, eggRealization *dto.FinancialMetrics + if projectFlock.Category == string(utils.ProjectFlockCategoryLaying) && totalEggWeightKg > 0 { + eggBudgetRpPerKg := totalBudgetHpp / totalEggWeightKg + eggRealizationRpPerKg := totalRealizationHpp / totalEggWeightKg + eggBudgeting = &dto.FinancialMetrics{ + RpPerBird: 0, + RpPerKg: eggBudgetRpPerKg, + Amount: totalBudgetHpp, + } + eggRealization = &dto.FinancialMetrics{ + RpPerBird: 0, + RpPerKg: eggRealizationRpPerKg, + Amount: totalRealizationHpp, + } + } + + hppSummary := dto.ToHPPSummary( + "HPP", + dto.ToFinancialMetrics(hppBudgetRpPerBird, hppBudgetRpPerKg, totalBudgetHpp), + dto.ToFinancialMetrics(hppRealizationRpPerBird, hppRealizationRpPerKg, totalRealizationHpp), + eggBudgeting, + eggRealization, + ) + + hppSection := dto.ToHPPSection(hppItems, hppSummary) + + // Build Profit Loss Items + plItems := []dto.ProfitLossItem{} + + // SALES item + salesRpPerBird, salesRpPerKg := calculateProfitLossMetrics(totalSalesAmount) + salesLabel := "Penjualan Ayam" + if projectFlock.Category == string(utils.ProjectFlockCategoryLaying) { + salesLabel = "Penjualan Telur" + } + plItems = append(plItems, dto.ToProfitLossItem( + "SALES", + salesLabel, + "income", + salesRpPerBird, + salesRpPerKg, + totalSalesAmount, + )) + + // PURCHASE_DOC item + purchaseDocLabel := "Pembelian DOC" + if projectFlock.Category == string(utils.ProjectFlockCategoryLaying) { + purchaseDocLabel = "Depresiasi" + } + plItems = append(plItems, dto.ToProfitLossItem( + "PURCHASE_DOC", + purchaseDocLabel, + "purchase", + docRealizationRpPerBird, + docRealizationRpPerKg, + totalAyamPrice, + )) + + // PAKAN item + plItems = append(plItems, dto.ToProfitLossItem( + "PAKAN", + "Pakan", + "purchase", + pakanRealizationRpPerBird, + pakanRealizationRpPerKg, + totalPakanPrice, + )) + + // OVK item + plItems = append(plItems, dto.ToProfitLossItem( + "OVK", + "OVK", + "purchase", + ovkRealizationRpPerBird, + ovkRealizationRpPerKg, + totalOvkPrice, + )) + + // OVERHEAD item + overheadRpPerBird, overheadRpPerKg := calculateMetrics(totalOperationalRealization) + plItems = append(plItems, dto.ToProfitLossItem( + "OVERHEAD", + "Overhead", + "overhead", + overheadRpPerBird, + overheadRpPerKg, + totalOperationalRealization, + )) + + // EKSPEDISI item + plItems = append(plItems, dto.ToProfitLossItem( + "EKSPEDISI", + "Ekspedisi", + "overhead", + ekspedisiRealizationRpPerBird, + ekspedisiRealizationRpPerKg, + totalEkspedisiRealization, + )) + + // Profit Loss Summary + // Calculate total cost of goods sold (HPP) - use realization + totalCostOfGoodsSold := totalPakanPrice + totalOvkPrice + totalAyamPrice + totalOperationalRealization + totalEkspedisiRealization + totalCostOfGoodsSoldRpPerBird := pakanRealizationRpPerBird + ovkRealizationRpPerBird + docRealizationRpPerBird + overheadRpPerBird + ekspedisiRealizationRpPerBird + + // Gross Profit = Sales - Cost of Goods Sold + grossProfit := totalSalesAmount - totalCostOfGoodsSold + grossProfitRpPerBird := salesRpPerBird - totalCostOfGoodsSoldRpPerBird + + // Operating Expenses (already included in COGS above, so this shows the breakdown) + totalOperatingExpenses := totalOperationalRealization + totalEkspedisiRealization + totalOperatingExpensesRpPerBird := overheadRpPerBird + ekspedisiRealizationRpPerBird + + // Net Profit = Gross Profit (COGS already deducted) + netProfit := grossProfit + netProfitRpPerBird := grossProfitRpPerBird + + plSummary := dto.ToProfitLossSummary( + dto.ToFinancialMetrics(grossProfitRpPerBird, 0, grossProfit), + dto.ToFinancialMetrics(totalOperatingExpensesRpPerBird, 0, totalOperatingExpenses), + dto.ToFinancialMetrics(netProfitRpPerBird, 0, netProfit), + ) + + profitLossSection := dto.ToProfitLossSection(plItems, plSummary) + + // Build complete response + data := dto.ToClosingKeuanganData(hppSection, profitLossSection) + response := dto.ToSuccessClosingKeuanganResponse(data) + + s.Log.Infof("===== NEW DTO RESPONSE BUILT SUCCESSFULLY =====") + s.Log.Infof("HPP Items: %d, Profit Loss Items: %d", len(hppItems), len(plItems)) + s.Log.Infof("===== END GetClosingKeuangan for ProjectFlockID: %d =====", projectFlockID) + + return &response, nil +} + +// containsItem checks if a string exists in a slice +func containsItem(slice []string, item string) bool { + for _, s := range slice { + if strings.EqualFold(s, item) { + return true + } + } + return false +} diff --git a/internal/modules/production/recordings/repositories/recording.repository.go b/internal/modules/production/recordings/repositories/recording.repository.go index fbb628ff..db43b2fa 100644 --- a/internal/modules/production/recordings/repositories/recording.repository.go +++ b/internal/modules/production/recordings/repositories/recording.repository.go @@ -44,6 +44,7 @@ type RecordingRepository interface { GetEggSummaryByRecording(tx *gorm.DB, recordingID uint) (totalQty float64, totalWeightGrams float64, err error) GetCumulativeEggQtyByProjectFlockKandang(tx *gorm.DB, projectFlockKandangId uint, recordTime time.Time) (float64, error) GetFcrStandardNumber(tx *gorm.DB, fcrId uint, currentWeightKg float64) (float64, bool, error) + GetTotalWeightProducedFromUniformityByProjectFlockID(ctx context.Context, projectFlockID uint) (float64, error) GetProductionWeightAndQtyByProjectFlockID(ctx context.Context, projectFlockID uint) (totalWeight float64, totalQty float64, err error) GetTotalDepletionByProjectFlockID(ctx context.Context, projectFlockID uint) (totalDepletion float64, err error) GetLatestAvgWeightByProjectFlockID(ctx context.Context, projectFlockID uint) (avgWeight float64, err error) @@ -548,3 +549,31 @@ func nextRecordingDay(days []int) int { return len(normalized) + 1 } + +// GetTotalWeightProducedFromUniformityByProjectFlockID calculates total weight produced from uniformity data +// It takes the latest uniformity record per kandang and calculates: SUM(mean_weight * chick_qty_of_weight / 1000) +func (r *RecordingRepositoryImpl) GetTotalWeightProducedFromUniformityByProjectFlockID(ctx context.Context, projectFlockID uint) (float64, error) { + if projectFlockID == 0 { + return 0, nil + } + + var result struct { + TotalWeight float64 + } + + err := r.DB().WithContext(ctx). + Table("project_flock_kandang_uniformity"). + Select("COALESCE(SUM(mean_weight * chick_qty_of_weight / 1000), 0) as total_weight"). + Joins("JOIN ("+ + " SELECT pfku.project_flock_kandang_id, MAX(pfku.id) as latest_id "+ + " FROM project_flock_kandang_uniformity pfku "+ + " JOIN project_flock_kandangs pfk ON pfk.id = pfku.project_flock_kandang_id "+ + " WHERE pfk.project_flock_id = ? "+ + " AND pfku.deleted_at IS NULL "+ + " GROUP BY pfku.project_flock_kandang_id "+ + ") latest ON latest.project_flock_kandang_id = project_flock_kandang_uniformity.project_flock_kandang_id "+ + "AND project_flock_kandang_uniformity.id = latest.latest_id"). + Scan(&result).Error + + return result.TotalWeight, err +} From 31c48ee1da64ef981c6d0a2861515ea25f93a5a9 Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Fri, 16 Jan 2026 20:53:47 +0700 Subject: [PATCH 58/76] feat[BE]: Add GetClosingKeuanganByKandang endpoint and related service methods --- .../controllers/closing.controller.go | 28 ++ .../closings/dto/closingKeuanganNew.dto.go | 24 ++ .../closingKeuangan.repository.go | 49 +++ internal/modules/closings/route.go | 1 + .../services/closingKeuangan.service.go | 317 +++++++++--------- .../repositories/recording.repository.go | 5 +- 6 files changed, 255 insertions(+), 169 deletions(-) diff --git a/internal/modules/closings/controllers/closing.controller.go b/internal/modules/closings/controllers/closing.controller.go index 9dfae460..ed3cfcbc 100644 --- a/internal/modules/closings/controllers/closing.controller.go +++ b/internal/modules/closings/controllers/closing.controller.go @@ -354,6 +354,34 @@ func (u *ClosingController) GetClosingKeuangan(c *fiber.Ctx) error { }) } +func (u *ClosingController) GetClosingKeuanganByKandang(c *fiber.Ctx) error { + projectParam := c.Params("project_flock_id") + kandangParam := c.Params("project_flock_kandang_id") + + projectFlockID, err := strconv.Atoi(projectParam) + if err != nil || projectFlockID <= 0 { + return fiber.NewError(fiber.StatusBadRequest, "Invalid project_flock_id") + } + + pfkID, err := strconv.Atoi(kandangParam) + if err != nil || pfkID <= 0 { + return fiber.NewError(fiber.StatusBadRequest, "Invalid project_flock_kandang_id") + } + + result, err := u.ClosingKeuanganService.GetClosingKeuanganByKandang(c, uint(projectFlockID), uint(pfkID)) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get closing keuangan by kandang successfully", + Data: result, + }) +} + func (u *ClosingController) GetExpeditionHPP(c *fiber.Ctx) error { param := c.Params("project_flock_id") diff --git a/internal/modules/closings/dto/closingKeuanganNew.dto.go b/internal/modules/closings/dto/closingKeuanganNew.dto.go index 4bef7280..6ca19d5c 100644 --- a/internal/modules/closings/dto/closingKeuanganNew.dto.go +++ b/internal/modules/closings/dto/closingKeuanganNew.dto.go @@ -1,5 +1,29 @@ package dto +// === CLOSING KEUANGAN CODES === + +// Closing HPP Codes +type ClosingHPPCode string + +const ( + HPPCodePakan ClosingHPPCode = "PAKAN" + HPPCodeOVK ClosingHPPCode = "OVK" + HPPCodeDOC ClosingHPPCode = "DOC" + HPPCodeDepresiasi ClosingHPPCode = "DEPRESIASI" + HPPCodeOverhead ClosingHPPCode = "OVERHEAD" + HPPCodeEkspedisi ClosingHPPCode = "EKSPEDISI" +) + +// Closing Profit Loss Codes +type ClosingProfitLossCode string + +const ( + PLCodeSales ClosingProfitLossCode = "SALES" + PLCodeSapronak ClosingProfitLossCode = "SAPRONAK" + PLCodeOverhead ClosingProfitLossCode = "OVERHEAD" + PLCodeEkspedisi ClosingProfitLossCode = "EKSPEDISI" +) + // === NEW CLOSING KEUANGAN DTO === // FinancialMetrics represents financial metrics with per unit and total amounts diff --git a/internal/modules/closings/repositories/closingKeuangan.repository.go b/internal/modules/closings/repositories/closingKeuangan.repository.go index 3763f92b..dedea807 100644 --- a/internal/modules/closings/repositories/closingKeuangan.repository.go +++ b/internal/modules/closings/repositories/closingKeuangan.repository.go @@ -17,6 +17,12 @@ type ClosingKeuanganRepository interface { // All Product Usage GetAllProductUsageByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint, flagFilters []string) ([]ProductUsageRow, error) + // Depletion per kandang + GetTotalDepletionByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint) (float64, error) + + // Weight produced from uniformity per kandang + GetTotalWeightProducedFromUniformityByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint) (float64, error) + // DB returns the underlying GORM DB instance DB() *gorm.DB } @@ -310,6 +316,49 @@ func (r *ClosingKeuanganRepositoryImpl) GetAllProductUsageByProjectFlockKandangI return results, nil } +// GetTotalDepletionByProjectFlockKandangID gets total depletion for a specific kandang +func (r *ClosingKeuanganRepositoryImpl) GetTotalDepletionByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint) (float64, error) { + var result float64 + err := r.DB().WithContext(ctx). + Table("recording_depletions"). + Select("COALESCE(SUM(recording_depletions.qty), 0)"). + Joins("JOIN recordings ON recordings.id = recording_depletions.recording_id"). + Joins("JOIN project_flock_kandangs ON project_flock_kandangs.id = recordings.project_flock_kandangs_id"). + Where("project_flock_kandangs.id = ?", projectFlockKandangID). + Scan(&result).Error + return result, err +} + +// GetTotalWeightProducedFromUniformityByProjectFlockKandangID calculates total weight produced from uniformity data for a specific kandang +// Formula: (mean_up / 1.10) * chick_qty_of_weight / 1000 +func (r *ClosingKeuanganRepositoryImpl) GetTotalWeightProducedFromUniformityByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint) (float64, error) { + if projectFlockKandangID == 0 { + return 0, nil + } + + var uniformity struct { + MeanUp float64 + ChickQtyOfWeight float64 + } + + err := r.DB().WithContext(ctx). + Table("project_flock_kandang_uniformity"). + Select("mean_up, chick_qty_of_weight"). + Where("project_flock_kandang_id = ?", projectFlockKandangID). + Order("id DESC"). + Limit(1). + Scan(&uniformity).Error + + if err != nil { + return 0, err + } + + // Calculate weight: (mean_up / 1.10) * chick_qty_of_weight / 1000 + totalWeight := (uniformity.MeanUp / 1.10) * uniformity.ChickQtyOfWeight / 1000 + + return totalWeight, nil +} + // containsIgnoreCase checks if a string contains a substring (case-insensitive) func containsIgnoreCase(str, substr string) bool { return strings.Contains(strings.ToUpper(str), strings.ToUpper(substr)) diff --git a/internal/modules/closings/route.go b/internal/modules/closings/route.go index 89578aeb..f0a6ca2a 100644 --- a/internal/modules/closings/route.go +++ b/internal/modules/closings/route.go @@ -34,5 +34,6 @@ func ClosingRoutes(v1 fiber.Router, u user.UserService, s closing.ClosingService route.Get("/:project_flock_id/:project_flock_kandang_id/expedition-hpp", m.RequirePermissions(m.P_ClosingDetail), ctrl.GetExpeditionHPPByKandang) route.Get("/:projectFlockId/production-data", m.RequirePermissions(m.P_ClosingDetail), ctrl.GetClosingDataProduksi) route.Get("/:projectFlockId/keuangan", m.RequirePermissions(m.P_ClosingDetail), ctrl.GetClosingKeuangan) + route.Get("/:project_flock_id/:project_flock_kandang_id/keuangan", m.RequirePermissions(m.P_ClosingDetail), ctrl.GetClosingKeuanganByKandang) } diff --git a/internal/modules/closings/services/closingKeuangan.service.go b/internal/modules/closings/services/closingKeuangan.service.go index d041d765..ffb7dbf4 100644 --- a/internal/modules/closings/services/closingKeuangan.service.go +++ b/internal/modules/closings/services/closingKeuangan.service.go @@ -21,7 +21,8 @@ import ( // ClosingKeuanganService handles closing keuangan business logic type ClosingKeuanganService interface { - GetClosingKeuangan(ctx *fiber.Ctx, projectFlockID uint) (*dto.ClosingKeuanganResponse, error) + GetClosingKeuangan(ctx *fiber.Ctx, projectFlockID uint) (*dto.ClosingKeuanganData, error) + GetClosingKeuanganByKandang(ctx *fiber.Ctx, projectFlockID uint, projectFlockKandangID uint) (*dto.ClosingKeuanganData, error) } type closingKeuanganService struct { @@ -59,8 +60,7 @@ func NewClosingKeuanganService( } } -func (s closingKeuanganService) GetClosingKeuangan(c *fiber.Ctx, projectFlockID uint) (*dto.ClosingKeuanganResponse, error) { - s.Log.Infof("===== START GetClosingKeuangan for ProjectFlockID: %d =====", projectFlockID) +func (s closingKeuanganService) GetClosingKeuangan(c *fiber.Ctx, projectFlockID uint) (*dto.ClosingKeuanganData, error) { if err := commonSvc.EnsureRelations(c.Context(), commonSvc.RelationCheck{Name: "Project Flock", ID: &projectFlockID, Exists: s.ProjectFlockRepo.IdExists}, @@ -72,7 +72,6 @@ func (s closingKeuanganService) GetClosingKeuangan(c *fiber.Ctx, projectFlockID if err != nil { return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch project flock") } - s.Log.Infof("ProjectFlock: ID=%d, Name=%s, Category=%s", projectFlock.Id, projectFlock.FlockName, projectFlock.Category) budgets, err := s.ProjectBudgetRepo.GetByProjectFlockID(c.Context(), projectFlockID) if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { @@ -89,18 +88,6 @@ func (s closingKeuanganService) GetClosingKeuangan(c *fiber.Ctx, projectFlockID Preload("Nonstock.Flags"). Where("id IN ?", budgetIDs). Find(&budgets).Error - if err != nil { - s.Log.Warnf("Failed to preload Nonstock.Flags: %v", err) - } - } - - s.Log.Infof("Budgets fetched: %d items", len(budgets)) - for i, b := range budgets { - nonstockName := "Unknown" - if b.Nonstock != nil { - nonstockName = b.Nonstock.Name - } - s.Log.Infof(" Budget[%d]: ID=%d, Nonstock=%s, Price=%.2f, Qty=%.2f", i, b.Id, nonstockName, b.Price, b.Qty) } // Get all kandang for this project flock @@ -108,37 +95,68 @@ func (s closingKeuanganService) GetClosingKeuangan(c *fiber.Ctx, projectFlockID if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch kandangs") } - s.Log.Infof("Kandangs fetched: %d kandangs", len(kandangs)) - for i, k := range kandangs { - s.Log.Infof(" Kandang[%d]: ID=%d, KandangID=%d, Period=%d", i, k.Id, k.KandangId, k.Period) + + return s.calculateClosingKeuangan(c, projectFlock, budgets, kandangs, projectFlockID) +} + +func (s closingKeuanganService) GetClosingKeuanganByKandang(c *fiber.Ctx, projectFlockID uint, projectFlockKandangID uint) (*dto.ClosingKeuanganData, error) { + + if err := commonSvc.EnsureRelations(c.Context(), + commonSvc.RelationCheck{Name: "Project Flock", ID: &projectFlockID, Exists: s.ProjectFlockRepo.IdExists}, + ); err != nil { + return nil, err } - // Define flag filters - // PAKAN flags: PAKAN, PRE-STARTER, STARTER, FINISHER (priority 1) - // OVK flags: OVK, OBAT, VITAMIN, KIMIA, EKSPEDISI (priority 2) - // AYAM flags: DOC, PULLET, LAYER (priority 3 - only if no PAKAN and OVK) - pakanFilters := []string{"PAKAN", "PRE-STARTER", "STARTER", "FINISHER"} - ovkFilters := []string{"OVK", "OBAT", "VITAMIN", "KIMIA", "EKSPEDISI"} - ayamFilters := []string{"DOC", "PULLET", "LAYER"} + // Validate and fetch project flock kandang + kandang, err := s.ProjectFlockKandangRepo.GetByID(c.Context(), projectFlockKandangID) + if err != nil { + return nil, fiber.NewError(fiber.StatusNotFound, "Project flock kandang not found") + } + if kandang.ProjectFlockId != projectFlockID { + return nil, fiber.NewError(fiber.StatusBadRequest, "Project flock kandang does not belong to this project flock") + } + + projectFlock, err := s.ProjectFlockRepo.GetByID(c.Context(), projectFlockID, nil) + if err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch project flock") + } + + budgets, err := s.ProjectBudgetRepo.GetByProjectFlockID(c.Context(), projectFlockID) + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch budgets") + } + + // Preload Nonstock.Flags manually + var budgetIDs []uint + for _, b := range budgets { + budgetIDs = append(budgetIDs, b.Id) + } + if len(budgetIDs) > 0 { + err = s.ProjectBudgetRepo.DB().WithContext(c.Context()). + Preload("Nonstock.Flags"). + Where("id IN ?", budgetIDs). + Find(&budgets).Error + } + + kandangs := []entity.ProjectFlockKandang{*kandang} + + return s.calculateClosingKeuangan(c, projectFlock, budgets, kandangs, projectFlockID) +} + +func (s closingKeuanganService) calculateClosingKeuangan(c *fiber.Ctx, projectFlock *entity.ProjectFlock, budgets []entity.ProjectBudget, kandangs []entity.ProjectFlockKandang, scopeID uint) (*dto.ClosingKeuanganData, error) { + // Define flag filters using constants + pakanFilters := []string{string(utils.FlagPakan), string(utils.FlagPreStarter), string(utils.FlagStarter), string(utils.FlagFinisher)} + ovkFilters := []string{string(utils.FlagOVK), string(utils.FlagObat), string(utils.FlagVitamin), string(utils.FlagKimia), string(utils.FlagEkspedisi)} + ayamFilters := []string{string(utils.FlagDOC), string(utils.FlagPullet), string(utils.FlagLayer)} allFilters := append(pakanFilters, ovkFilters...) allFilters = append(allFilters, ayamFilters...) - s.Log.Infof("All Filters: %v", allFilters) var allProductUsageRows []repository.ProductUsageRow - // Get ALL product usage once - s.Log.Infof("===== Fetching ALL product usage =====") + // Get ALL product usage for _, kandang := range kandangs { - s.Log.Infof("Fetching ALL for kandang ID=%d", kandang.Id) rows, err := s.ClosingKeuanganRepo.GetAllProductUsageByProjectFlockKandangID(c.Context(), kandang.Id, allFilters) - if err != nil { - s.Log.Errorf("Failed to get product usage for kandang %d: %v", kandang.Id, err) - } else { - s.Log.Infof("Kandang %d: Got %d rows", kandang.Id, len(rows)) - for i, row := range rows { - s.Log.Infof(" [%d]: ProductID=%d, ProductName=%s, FlagNames=%s, TotalQty=%.2f, Price=%.2f, TotalPengeluaran=%.2f", - i, row.ProductID, row.ProductName, row.FlagNames, row.TotalQty, row.Price, row.TotalPengeluaran) - } + if err == nil { allProductUsageRows = append(allProductUsageRows, rows...) } } @@ -148,7 +166,6 @@ func (s closingKeuanganService) GetClosingKeuangan(c *fiber.Ctx, projectFlockID var ovkProductUsageRows []repository.ProductUsageRow var ayamProductUsageRows []repository.ProductUsageRow - s.Log.Infof("===== Classifying products by flag priority =====") for _, row := range allProductUsageRows { // Parse flag names from comma-separated string flagNames := strings.Split(row.FlagNames, ",") @@ -171,29 +188,17 @@ func (s closingKeuanganService) GetClosingKeuangan(c *fiber.Ctx, projectFlockID } // Priority: PAKAN > OVK > AYAM - category := "" if hasPakanFlag { pakanProductUsageRows = append(pakanProductUsageRows, row) - category = "PAKAN" } else if hasOvkFlag { ovkProductUsageRows = append(ovkProductUsageRows, row) - category = "OVK" } else if hasAyamFlag { ayamProductUsageRows = append(ayamProductUsageRows, row) - category = "AYAM" } else { - s.Log.Warnf("ProductID=%d (%s) has no recognized flags: %s", row.ProductID, row.ProductName, row.FlagNames) continue } - - s.Log.Infof("ProductID=%d (%s) → Category: %s (Flags: %s, Pakan=%v, OVK=%v, Ayam=%v)", - row.ProductID, row.ProductName, category, row.FlagNames, hasPakanFlag, hasOvkFlag, hasAyamFlag) } - s.Log.Infof("Total ProductUsageRows collected: %d items", len(allProductUsageRows)) - s.Log.Infof(" - PAKAN: %d items", len(pakanProductUsageRows)) - s.Log.Infof(" - OVK: %d items", len(ovkProductUsageRows)) - s.Log.Infof(" - AYAM: %d items", len(ayamProductUsageRows)) // Calculate total price for each category var totalPakanPrice, totalOvkPrice, totalAyamPrice float64 @@ -206,85 +211,100 @@ func (s closingKeuanganService) GetClosingKeuangan(c *fiber.Ctx, projectFlockID for _, row := range ayamProductUsageRows { totalAyamPrice += row.TotalPengeluaran } - s.Log.Infof("===== TOTAL PRICE BY CATEGORY =====") - s.Log.Infof(" - PAKAN Total Price: %.2f", totalPakanPrice) - s.Log.Infof(" - OVK Total Price: %.2f", totalOvkPrice) - s.Log.Infof(" - AYAM Total Price: %.2f", totalAyamPrice) - s.Log.Infof(" - ALL Total Price: %.2f", totalPakanPrice+totalOvkPrice+totalAyamPrice) - realizations, err := s.ExpenseRealizationRepo.GetByProjectFlockID(c.Context(), projectFlockID) + // Determine if this is per-kandang or per-project-flock scope + isPerKandang := len(kandangs) == 1 + var projectFlockKandangID *uint + if isPerKandang { + kandangID := kandangs[0].Id + projectFlockKandangID = &kandangID + } + + var err error + + // Fetch realizations + var realizations []entity.ExpenseRealization + if isPerKandang && projectFlockKandangID != nil { + realizations, err = s.ExpenseRealizationRepo.GetClosingOverhead(c.Context(), projectFlock.Id, projectFlockKandangID) + } else { + realizations, err = s.ExpenseRealizationRepo.GetClosingOverhead(c.Context(), projectFlock.Id, nil) + } if err != nil { return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch realizations") } - s.Log.Infof("Realizations fetched: %d items", len(realizations)) - for i, r := range realizations { - s.Log.Infof(" Realization[%d]: ID=%d, Price=%.2f, Qty=%.2f", i, r.Id, r.Price, r.Qty) - } - deliveryProducts, err := s.MarketingDeliveryProductRepo.GetDeliveryProductsByProjectFlockID(c.Context(), projectFlockID, func(db *gorm.DB) *gorm.DB { - return db.Preload("MarketingProduct"). + deliveryProducts, err := s.MarketingDeliveryProductRepo.GetDeliveryProductsByProjectFlockID(c.Context(), projectFlock.Id, func(db *gorm.DB) *gorm.DB { + db = db.Preload("MarketingProduct"). Preload("MarketingProduct.ProductWarehouse"). Preload("MarketingProduct.ProductWarehouse.Product") + return db }) if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch delivery products") } - s.Log.Infof("DeliveryProducts fetched: %d items", len(deliveryProducts)) - for i, dp := range deliveryProducts { - s.Log.Infof(" DeliveryProduct[%d]: ID=%d, TotalWeight=%.2f, TotalPrice=%.2f", i, dp.Id, dp.TotalWeight, dp.TotalPrice) + + // Filter by kandang if scope is per-kandang (manual filtering after fetch) + if isPerKandang && projectFlockKandangID != nil { + filteredProducts := make([]entity.MarketingDeliveryProduct, 0) + for _, dp := range deliveryProducts { + pfKandangID := dp.MarketingProduct.ProductWarehouse.ProjectFlockKandangId + if pfKandangID != nil && *pfKandangID == *projectFlockKandangID { + filteredProducts = append(filteredProducts, dp) + } + } + deliveryProducts = filteredProducts } - chickins, err := s.ChickinRepo.GetByProjectFlockID(c.Context(), projectFlockID) + // Fetch chickins + var chickins []entity.ProjectChickin + if isPerKandang && projectFlockKandangID != nil { + chickins, err = s.ChickinRepo.GetByProjectFlockKandangID(c.Context(), *projectFlockKandangID) + } else { + chickins, err = s.ChickinRepo.GetByProjectFlockID(c.Context(), projectFlock.Id) + } if err != nil { return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch chickins") } - s.Log.Infof("Chickins fetched: %d items", len(chickins)) - for i, ch := range chickins { - s.Log.Infof(" Chickin[%d]: ID=%d, UsageQty=%.2f", i, ch.Id, ch.UsageQty) - } // Get total depletion - totalDepletion, err := s.RecordingRepo.GetTotalDepletionByProjectFlockID(c.Context(), projectFlockID) + var totalDepletion float64 + if isPerKandang && projectFlockKandangID != nil { + totalDepletion, err = s.ClosingKeuanganRepo.GetTotalDepletionByProjectFlockKandangID(c.Context(), *projectFlockKandangID) + } else { + totalDepletion, err = s.RecordingRepo.GetTotalDepletionByProjectFlockID(c.Context(), projectFlock.Id) + } if err != nil { - s.Log.Warnf("GetTotalDepletionByProjectFlockID error: %v", err) totalDepletion = 0 } - s.Log.Infof("TotalDepletion: %.2f birds", totalDepletion) - totalWeightProduced, _, err := s.RecordingRepo.GetProductionWeightAndQtyByProjectFlockID(c.Context(), projectFlockID) + totalWeightProduced, _, err := s.RecordingRepo.GetProductionWeightAndQtyByProjectFlockID(c.Context(), projectFlock.Id) if err != nil { - s.Log.Warnf("GetProductionWeightAndQtyByProjectFlockID error: %v", err) } - s.Log.Infof("TotalWeightProduced (from stub): %.2f kg", totalWeightProduced) // Try to get actual weight from uniformity data - totalWeightFromUniformity, err := s.RecordingRepo.GetTotalWeightProducedFromUniformityByProjectFlockID(c.Context(), projectFlockID) + var totalWeightFromUniformity float64 + if isPerKandang && projectFlockKandangID != nil { + totalWeightFromUniformity, err = s.ClosingKeuanganRepo.GetTotalWeightProducedFromUniformityByProjectFlockKandangID(c.Context(), *projectFlockKandangID) + } else { + totalWeightFromUniformity, err = s.RecordingRepo.GetTotalWeightProducedFromUniformityByProjectFlockID(c.Context(), projectFlock.Id) + } if err != nil { - s.Log.Warnf("GetTotalWeightProducedFromUniformityByProjectFlockID error: %v", err) } else if totalWeightFromUniformity > 0 { totalWeightProduced = totalWeightFromUniformity - s.Log.Infof("TotalWeightProduced (from uniformity): %.2f kg", totalWeightProduced) } // Fetch egg data only for Laying category var totalEggWeightKg float64 if projectFlock.Category == string(utils.ProjectFlockCategoryLaying) { - s.Log.Infof("===== Fetching EGG data (Laying Category) =====") // TODO: Replace with actual method to get egg weight from RecordingRepo - // totalEggWeightKg, err = s.RecordingRepo.GetEggWeightByProjectFlockID(c.Context(), projectFlockID) + // totalEggWeightKg, err = s.RecordingRepo.GetEggWeightByProjectFlockID(c.Context(), projectFlock.Id) // For now, set to 0 as placeholder totalEggWeightKg = 0 - s.Log.Infof("TotalEggWeightKg: %.2f kg", totalEggWeightKg) - if err != nil { - s.Log.Warnf("Failed to fetch egg weight: %v", err) - } } else { - s.Log.Infof("Skipping egg data fetch (Category: %s - not Laying)", projectFlock.Category) totalEggWeightKg = 0 } // Build new DTO structure - s.Log.Infof("===== BUILDING NEW DTO RESPONSE =====") // Calculate totals var totalPopulation float64 @@ -294,7 +314,6 @@ func (s closingKeuanganService) GetClosingKeuangan(c *fiber.Ctx, projectFlockID // Calculate actual population (total population - depletion) actualPopulation := totalPopulation - totalDepletion - s.Log.Infof("Population - Total: %.2f, Depletion: %.2f, Actual: %.2f", totalPopulation, totalDepletion, actualPopulation) // Calculate budget totals by category calculateBudgetByFlag := func(flags []string) float64 { @@ -328,12 +347,6 @@ func (s closingKeuanganService) GetClosingKeuangan(c *fiber.Ctx, projectFlockID } budgetOperational := totalBudgetAmount - budgetPakan - budgetOvk - budgetAyam - budgetEkspedisi - s.Log.Infof("Budgets by category:") - s.Log.Infof(" PAKAN: %.2f", budgetPakan) - s.Log.Infof(" OVK: %.2f", budgetOvk) - s.Log.Infof(" AYAM: %.2f", budgetAyam) - s.Log.Infof(" OVERHEAD: %.2f", budgetOperational) - s.Log.Infof(" EKSPEDISI: %.2f", budgetEkspedisi) // Calculate realization totals var totalRealizationAmount float64 @@ -398,7 +411,6 @@ func (s closingKeuanganService) GetClosingKeuangan(c *fiber.Ctx, projectFlockID } } - s.Log.Infof("Filtered DeliveryProducts: %d items (from %d total)", len(filteredDeliveryProducts), len(deliveryProducts)) // Calculate total weight sold and sales amount from filtered products var totalWeightSold float64 @@ -408,14 +420,6 @@ func (s closingKeuanganService) GetClosingKeuangan(c *fiber.Ctx, projectFlockID totalSalesAmount += delivery.TotalPrice } - s.Log.Infof("Calculated totals:") - s.Log.Infof(" Total Population: %.2f", totalPopulation) - s.Log.Infof(" Actual Population: %.2f (after depletion)", actualPopulation) - s.Log.Infof(" Total Weight Produced: %.2f kg (ayam)", totalWeightProduced) - s.Log.Infof(" Total Weight Sold: %.2f kg (filtered by category)", totalWeightSold) - s.Log.Infof(" Total Budget: %.2f", totalBudgetAmount) - s.Log.Infof(" Total Realization: %.2f (Operational: %.2f, Ekspedisi: %.2f)", totalRealizationAmount, totalOperationalRealization, totalEkspedisiRealization) - s.Log.Infof(" Total Sales: %.2f", totalSalesAmount) // Calculate metrics - always use kg ayam for rp_per_kg calculateMetrics := func(amount float64) (rpPerBird, rpPerKg float64) { @@ -439,7 +443,7 @@ func (s closingKeuanganService) GetClosingKeuangan(c *fiber.Ctx, projectFlockID return } - // Build HPP Items + // Build HPP Items using constants hppItems := []dto.HPPItem{} // PAKAN item @@ -448,8 +452,8 @@ func (s closingKeuanganService) GetClosingKeuangan(c *fiber.Ctx, projectFlockID hppItems = append(hppItems, dto.ToHPPItem( 1, "purchase", - "PAKAN", - "Pakan", + string(dto.HPPCodePakan), + "Pembelian Pakan", dto.ToFinancialMetrics(pakanBudgetRpPerBird, pakanBudgetRpPerKg, budgetPakan), dto.ToFinancialMetrics(pakanRealizationRpPerBird, pakanRealizationRpPerKg, totalPakanPrice), )) @@ -460,43 +464,49 @@ func (s closingKeuanganService) GetClosingKeuangan(c *fiber.Ctx, projectFlockID hppItems = append(hppItems, dto.ToHPPItem( 2, "purchase", - "OVK", - "OVK", + string(dto.HPPCodeOVK), + "Pembelian OVK", dto.ToFinancialMetrics(ovkBudgetRpPerBird, ovkBudgetRpPerKg, budgetOvk), dto.ToFinancialMetrics(ovkRealizationRpPerBird, ovkRealizationRpPerKg, totalOvkPrice), )) - // DOC item + // DOC/DEPRESIASI item + docCode := string(dto.HPPCodeDOC) + docLabel := "Pembelian DOC" + if projectFlock.Category == string(utils.ProjectFlockCategoryLaying) { + docCode = string(dto.HPPCodeDepresiasi) + docLabel = "Depresiasi" + } docBudgetRpPerBird, docBudgetRpPerKg := calculateMetrics(budgetAyam) docRealizationRpPerBird, docRealizationRpPerKg := calculateMetrics(totalAyamPrice) hppItems = append(hppItems, dto.ToHPPItem( 3, "purchase", - "DOC", - "DOC", + docCode, + docLabel, dto.ToFinancialMetrics(docBudgetRpPerBird, docBudgetRpPerKg, budgetAyam), dto.ToFinancialMetrics(docRealizationRpPerBird, docRealizationRpPerKg, totalAyamPrice), )) - // OVERHEAD operational item (before EKSPEDISI) + // OVERHEAD item overheadBudgetRpPerBird, overheadBudgetRpPerKg := calculateMetrics(budgetOperational) overheadRealizationRpPerBird, overheadRealizationRpPerKg := calculateMetrics(totalOperationalRealization) hppItems = append(hppItems, dto.ToHPPItem( 4, "overhead", - "OVERHEAD", + string(dto.HPPCodeOverhead), "Pengeluaran Overhead", dto.ToFinancialMetrics(overheadBudgetRpPerBird, overheadBudgetRpPerKg, budgetOperational), dto.ToFinancialMetrics(overheadRealizationRpPerBird, overheadRealizationRpPerKg, totalOperationalRealization), )) - // EKSPEDISI item (overhead) + // EKSPEDISI item ekspedisiBudgetRpPerBird, ekspedisiBudgetRpPerKg := calculateMetrics(budgetEkspedisi) ekspedisiRealizationRpPerBird, ekspedisiRealizationRpPerKg := calculateMetrics(totalEkspedisiRealization) hppItems = append(hppItems, dto.ToHPPItem( 5, "overhead", - "EKSPEDISI", + string(dto.HPPCodeEkspedisi), "Beban Ekspedisi", dto.ToFinancialMetrics(ekspedisiBudgetRpPerBird, ekspedisiBudgetRpPerKg, budgetEkspedisi), dto.ToFinancialMetrics(ekspedisiRealizationRpPerBird, ekspedisiRealizationRpPerKg, totalEkspedisiRealization), @@ -535,7 +545,7 @@ func (s closingKeuanganService) GetClosingKeuangan(c *fiber.Ctx, projectFlockID hppSection := dto.ToHPPSection(hppItems, hppSummary) - // Build Profit Loss Items + // Build Profit Loss Items using constants plItems := []dto.ProfitLossItem{} // SALES item @@ -545,7 +555,7 @@ func (s closingKeuanganService) GetClosingKeuangan(c *fiber.Ctx, projectFlockID salesLabel = "Penjualan Telur" } plItems = append(plItems, dto.ToProfitLossItem( - "SALES", + string(dto.PLCodeSales), salesLabel, "income", salesRpPerBird, @@ -553,44 +563,24 @@ func (s closingKeuanganService) GetClosingKeuangan(c *fiber.Ctx, projectFlockID totalSalesAmount, )) - // PURCHASE_DOC item - purchaseDocLabel := "Pembelian DOC" - if projectFlock.Category == string(utils.ProjectFlockCategoryLaying) { - purchaseDocLabel = "Depresiasi" - } + // SAPRONAK item - combines DOC/Depresiasi + PAKAN + OVK + totalSapronakAmount := totalAyamPrice + totalPakanPrice + totalOvkPrice + sapronakRpPerBird := docRealizationRpPerBird + pakanRealizationRpPerBird + ovkRealizationRpPerBird + sapronakRpPerKg := docRealizationRpPerKg + pakanRealizationRpPerKg + ovkRealizationRpPerKg + sapronakLabel := "Pengeluaran Sapronak" plItems = append(plItems, dto.ToProfitLossItem( - "PURCHASE_DOC", - purchaseDocLabel, + string(dto.PLCodeSapronak), + sapronakLabel, "purchase", - docRealizationRpPerBird, - docRealizationRpPerKg, - totalAyamPrice, - )) - - // PAKAN item - plItems = append(plItems, dto.ToProfitLossItem( - "PAKAN", - "Pakan", - "purchase", - pakanRealizationRpPerBird, - pakanRealizationRpPerKg, - totalPakanPrice, - )) - - // OVK item - plItems = append(plItems, dto.ToProfitLossItem( - "OVK", - "OVK", - "purchase", - ovkRealizationRpPerBird, - ovkRealizationRpPerKg, - totalOvkPrice, + sapronakRpPerBird, + sapronakRpPerKg, + totalSapronakAmount, )) // OVERHEAD item overheadRpPerBird, overheadRpPerKg := calculateMetrics(totalOperationalRealization) plItems = append(plItems, dto.ToProfitLossItem( - "OVERHEAD", + string(dto.PLCodeOverhead), "Overhead", "overhead", overheadRpPerBird, @@ -600,7 +590,7 @@ func (s closingKeuanganService) GetClosingKeuangan(c *fiber.Ctx, projectFlockID // EKSPEDISI item plItems = append(plItems, dto.ToProfitLossItem( - "EKSPEDISI", + string(dto.PLCodeEkspedisi), "Ekspedisi", "overhead", ekspedisiRealizationRpPerBird, @@ -609,21 +599,21 @@ func (s closingKeuanganService) GetClosingKeuangan(c *fiber.Ctx, projectFlockID )) // Profit Loss Summary - // Calculate total cost of goods sold (HPP) - use realization - totalCostOfGoodsSold := totalPakanPrice + totalOvkPrice + totalAyamPrice + totalOperationalRealization + totalEkspedisiRealization - totalCostOfGoodsSoldRpPerBird := pakanRealizationRpPerBird + ovkRealizationRpPerBird + docRealizationRpPerBird + overheadRpPerBird + ekspedisiRealizationRpPerBird + // Gross Profit = Sales - (DOC + PAKAN + OVK) only + // Gross Profit should NOT include overhead and ekspedisi + costOfGoodsSold := totalAyamPrice + totalPakanPrice + totalOvkPrice + costOfGoodsSoldRpPerBird := sapronakRpPerBird - // Gross Profit = Sales - Cost of Goods Sold - grossProfit := totalSalesAmount - totalCostOfGoodsSold - grossProfitRpPerBird := salesRpPerBird - totalCostOfGoodsSoldRpPerBird + grossProfit := totalSalesAmount - costOfGoodsSold + grossProfitRpPerBird := salesRpPerBird - costOfGoodsSoldRpPerBird - // Operating Expenses (already included in COGS above, so this shows the breakdown) + // Operating Expenses (Overhead + Ekspedisi) totalOperatingExpenses := totalOperationalRealization + totalEkspedisiRealization totalOperatingExpensesRpPerBird := overheadRpPerBird + ekspedisiRealizationRpPerBird - // Net Profit = Gross Profit (COGS already deducted) - netProfit := grossProfit - netProfitRpPerBird := grossProfitRpPerBird + // Net Profit = Gross Profit - Operating Expenses + netProfit := grossProfit - totalOperatingExpenses + netProfitRpPerBird := grossProfitRpPerBird - totalOperatingExpensesRpPerBird plSummary := dto.ToProfitLossSummary( dto.ToFinancialMetrics(grossProfitRpPerBird, 0, grossProfit), @@ -635,13 +625,8 @@ func (s closingKeuanganService) GetClosingKeuangan(c *fiber.Ctx, projectFlockID // Build complete response data := dto.ToClosingKeuanganData(hppSection, profitLossSection) - response := dto.ToSuccessClosingKeuanganResponse(data) - s.Log.Infof("===== NEW DTO RESPONSE BUILT SUCCESSFULLY =====") - s.Log.Infof("HPP Items: %d, Profit Loss Items: %d", len(hppItems), len(plItems)) - s.Log.Infof("===== END GetClosingKeuangan for ProjectFlockID: %d =====", projectFlockID) - - return &response, nil + return &data, nil } // containsItem checks if a string exists in a slice diff --git a/internal/modules/production/recordings/repositories/recording.repository.go b/internal/modules/production/recordings/repositories/recording.repository.go index db43b2fa..703c05f0 100644 --- a/internal/modules/production/recordings/repositories/recording.repository.go +++ b/internal/modules/production/recordings/repositories/recording.repository.go @@ -563,16 +563,15 @@ func (r *RecordingRepositoryImpl) GetTotalWeightProducedFromUniformityByProjectF err := r.DB().WithContext(ctx). Table("project_flock_kandang_uniformity"). - Select("COALESCE(SUM(mean_weight * chick_qty_of_weight / 1000), 0) as total_weight"). + Select("COALESCE(SUM((mean_up / 1.10) * chick_qty_of_weight / 1000), 0) as total_weight"). Joins("JOIN ("+ " SELECT pfku.project_flock_kandang_id, MAX(pfku.id) as latest_id "+ " FROM project_flock_kandang_uniformity pfku "+ " JOIN project_flock_kandangs pfk ON pfk.id = pfku.project_flock_kandang_id "+ " WHERE pfk.project_flock_id = ? "+ - " AND pfku.deleted_at IS NULL "+ " GROUP BY pfku.project_flock_kandang_id "+ ") latest ON latest.project_flock_kandang_id = project_flock_kandang_uniformity.project_flock_kandang_id "+ - "AND project_flock_kandang_uniformity.id = latest.latest_id"). + "AND project_flock_kandang_uniformity.id = latest.latest_id", projectFlockID). Scan(&result).Error return result.TotalWeight, err From 302f0ed87706bd249e14fa5648c569a0cdfd3765 Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Fri, 16 Jan 2026 21:34:49 +0700 Subject: [PATCH 59/76] fix:[BE] Remove unnecessary filters and update profit loss calculation logic in ClosingKeuanganService --- .../closings/services/closingKeuangan.service.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/internal/modules/closings/services/closingKeuangan.service.go b/internal/modules/closings/services/closingKeuangan.service.go index ffb7dbf4..0f3351f7 100644 --- a/internal/modules/closings/services/closingKeuangan.service.go +++ b/internal/modules/closings/services/closingKeuangan.service.go @@ -146,7 +146,7 @@ func (s closingKeuanganService) GetClosingKeuanganByKandang(c *fiber.Ctx, projec func (s closingKeuanganService) calculateClosingKeuangan(c *fiber.Ctx, projectFlock *entity.ProjectFlock, budgets []entity.ProjectBudget, kandangs []entity.ProjectFlockKandang, scopeID uint) (*dto.ClosingKeuanganData, error) { // Define flag filters using constants pakanFilters := []string{string(utils.FlagPakan), string(utils.FlagPreStarter), string(utils.FlagStarter), string(utils.FlagFinisher)} - ovkFilters := []string{string(utils.FlagOVK), string(utils.FlagObat), string(utils.FlagVitamin), string(utils.FlagKimia), string(utils.FlagEkspedisi)} + ovkFilters := []string{string(utils.FlagOVK), string(utils.FlagObat), string(utils.FlagVitamin), string(utils.FlagKimia)} ayamFilters := []string{string(utils.FlagDOC), string(utils.FlagPullet), string(utils.FlagLayer)} allFilters := append(pakanFilters, ovkFilters...) allFilters = append(allFilters, ayamFilters...) @@ -336,7 +336,7 @@ func (s closingKeuanganService) calculateClosingKeuangan(c *fiber.Ctx, projectFl // Budget per category budgetPakan := calculateBudgetByFlag([]string{"PAKAN", "PRE-STARTER", "STARTER", "FINISHER"}) - budgetOvk := calculateBudgetByFlag([]string{"OVK", "OBAT", "VITAMIN", "KIMIA", "EKSPEDISI"}) + budgetOvk := calculateBudgetByFlag([]string{"OVK", "OBAT", "VITAMIN", "KIMIA"}) budgetAyam := calculateBudgetByFlag([]string{"DOC", "PULLET", "LAYER"}) budgetEkspedisi := calculateBudgetByFlag([]string{"EKSPEDISI"}) @@ -432,13 +432,13 @@ func (s closingKeuanganService) calculateClosingKeuangan(c *fiber.Ctx, projectFl return } - // Calculate metrics for profit loss (use total population for sales) + // Calculate metrics for profit loss (use total population and total weight produced) calculateProfitLossMetrics := func(amount float64) (rpPerBird, rpPerKg float64) { if totalPopulation > 0 { rpPerBird = amount / totalPopulation } - if totalWeightSold > 0 { - rpPerKg = amount / totalWeightSold + if totalWeightProduced > 0 { + rpPerKg = amount / totalWeightProduced } return } From ef482dd1b9f347511fc3bb2fb3f3b5d5a1d0e97a Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Fri, 16 Jan 2026 21:37:51 +0700 Subject: [PATCH 60/76] feat[BE]: Add new ClosingKeuangan DTO and related mapper functions --- .../dto/{closingKeuanganNew.dto.go => closingKeuangan.dto.go} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename internal/modules/closings/dto/{closingKeuanganNew.dto.go => closingKeuangan.dto.go} (100%) diff --git a/internal/modules/closings/dto/closingKeuanganNew.dto.go b/internal/modules/closings/dto/closingKeuangan.dto.go similarity index 100% rename from internal/modules/closings/dto/closingKeuanganNew.dto.go rename to internal/modules/closings/dto/closingKeuangan.dto.go From fbeccf4cdc3899f83fe8ae9da3096846d4cd37b7 Mon Sep 17 00:00:00 2001 From: giovanni Date: Sat, 17 Jan 2026 11:01:08 +0700 Subject: [PATCH 61/76] fix query outgoing sapronak --- .../modules/closings/repositories/closing.repository.go | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/internal/modules/closings/repositories/closing.repository.go b/internal/modules/closings/repositories/closing.repository.go index 582a1207..507d7c88 100644 --- a/internal/modules/closings/repositories/closing.repository.go +++ b/internal/modules/closings/repositories/closing.repository.go @@ -534,6 +534,13 @@ JOIN products prod ON prod.id = pw.product_id JOIN uoms u ON u.id = prod.uom_id JOIN warehouses w ON w.id = pw.warehouse_id WHERE pw.project_flock_kandang_id IN ? + AND EXISTS ( + SELECT 1 + FROM flags f + WHERE f.flagable_id = pw.product_id + AND f.flagable_type = 'products' + AND UPPER(f.name) NOT IN ('DOC', 'LAYER', 'PULLET') + ) ` ) From dcfb5e10b478a2b7f11af0c451e848d5ca41dbf0 Mon Sep 17 00:00:00 2001 From: giovanni Date: Sat, 17 Jan 2026 11:34:12 +0700 Subject: [PATCH 62/76] adjust max limit to 1000 --- internal/modules/repports/validations/repport.validation.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/modules/repports/validations/repport.validation.go b/internal/modules/repports/validations/repport.validation.go index bddb5ab1..85df9aa5 100644 --- a/internal/modules/repports/validations/repport.validation.go +++ b/internal/modules/repports/validations/repport.validation.go @@ -56,7 +56,7 @@ type DebtSupplierQuery struct { type HppPerKandangQuery struct { Page int `query:"page" validate:"omitempty,min=1,gt=0"` - Limit int `query:"limit" validate:"omitempty,min=1,max=100,gt=0"` + Limit int `query:"limit" validate:"omitempty,min=1,max=1000,gt=0"` Period string `query:"period" validate:"required"` ShowUnrecorded bool `query:"show_unrecorded"` AreaIDs []int64 `query:"-"` @@ -68,7 +68,7 @@ type HppPerKandangQuery struct { type ProductionResultQuery struct { Page int `query:"page" validate:"omitempty,min=1,gt=0"` - Limit int `query:"limit" validate:"omitempty,min=1,max=100,gt=0"` + Limit int `query:"limit" validate:"omitempty,min=1,max=1000,gt=0"` ProjectFlockKandangID uint `query:"-" validate:"required,gt=0"` } From 533e9aca6f3504d65f57d53ed85bfc7eb37ee8e4 Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Sat, 17 Jan 2026 12:20:40 +0700 Subject: [PATCH 63/76] FIX[BE]: Fixing filter area and location --- .../salesorder_delivery_product.repository.go | 16 ++++++++++++++-- .../repports/controllers/repport.controller.go | 2 ++ .../repports/validations/repport.validation.go | 2 ++ 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/internal/modules/marketing/repositories/salesorder_delivery_product.repository.go b/internal/modules/marketing/repositories/salesorder_delivery_product.repository.go index f14988b1..286206e0 100644 --- a/internal/modules/marketing/repositories/salesorder_delivery_product.repository.go +++ b/internal/modules/marketing/repositories/salesorder_delivery_product.repository.go @@ -140,7 +140,7 @@ func (r *MarketingDeliveryProductRepositoryImpl) GetAllWithFilters(ctx context.C Joins("JOIN marketings ON marketings.id = marketing_products.marketing_id"). Where("marketing_delivery_products.delivery_date IS NOT NULL") - if filters.ProductId > 0 || filters.WarehouseId > 0 || filters.Search != "" || filters.MarketingType != "" { + if filters.ProductId > 0 || filters.WarehouseId > 0 || filters.AreaId > 0 || filters.LocationId > 0 || filters.Search != "" || filters.MarketingType != "" { db = db.Joins("LEFT JOIN product_warehouses ON product_warehouses.id = marketing_products.product_warehouse_id") } @@ -178,6 +178,19 @@ func (r *MarketingDeliveryProductRepositoryImpl) GetAllWithFilters(ctx context.C db = db.Where("product_warehouses.warehouse_id = ?", filters.WarehouseId) } + if filters.AreaId > 0 || filters.LocationId > 0 { + db = db.Joins("LEFT JOIN project_flock_kandangs ON project_flock_kandangs.id = product_warehouses.project_flock_kandang_id"). + Joins("LEFT JOIN project_flocks ON project_flocks.id = project_flock_kandangs.project_flock_id") + + if filters.AreaId > 0 { + db = db.Where("project_flocks.area_id = ?", filters.AreaId) + } + + if filters.LocationId > 0 { + db = db.Where("project_flocks.location_id = ?", filters.LocationId) + } + } + if filters.MarketingType != "" { db = db.Joins("LEFT JOIN flags ON flags.flagable_id = products.id AND flags.flagable_type = 'products'"). Group("marketing_delivery_products.id") @@ -186,7 +199,6 @@ func (r *MarketingDeliveryProductRepositoryImpl) GetAllWithFilters(ctx context.C case "ayam": db = db.Where("flags.name IN (?)", []string{ string(utils.FlagDOC), string(utils.FlagPullet), string(utils.FlagLayer), - string(utils.FlagAyamAfkir), string(utils.FlagAyamCulling), string(utils.FlagAyamMati), }) case "telur": db = db.Where("flags.name IN (?)", []string{ diff --git a/internal/modules/repports/controllers/repport.controller.go b/internal/modules/repports/controllers/repport.controller.go index f83f0902..c1982279 100644 --- a/internal/modules/repports/controllers/repport.controller.go +++ b/internal/modules/repports/controllers/repport.controller.go @@ -82,6 +82,8 @@ func (c *RepportController) GetMarketing(ctx *fiber.Ctx) error { ProductId: int64(ctx.QueryInt("product_id", 0)), WarehouseId: int64(ctx.QueryInt("warehouse_id", 0)), SalesPersonId: int64(ctx.QueryInt("sales_person_id", 0)), + AreaId: int64(ctx.QueryInt("area_id", 0)), + LocationId: int64(ctx.QueryInt("location_id", 0)), MarketingType: ctx.Query("marketing_type", ""), FilterBy: ctx.Query("filter_by", ""), StartDate: ctx.Query("start_date", ""), diff --git a/internal/modules/repports/validations/repport.validation.go b/internal/modules/repports/validations/repport.validation.go index bddb5ab1..1ef5e0fe 100644 --- a/internal/modules/repports/validations/repport.validation.go +++ b/internal/modules/repports/validations/repport.validation.go @@ -23,6 +23,8 @@ type MarketingQuery struct { ProductId int64 `query:"product_id" validate:"omitempty"` WarehouseId int64 `query:"warehouse_id" validate:"omitempty"` SalesPersonId int64 `query:"sales_person_id" validate:"omitempty"` + AreaId int64 `query:"area_id" validate:"omitempty"` + LocationId int64 `query:"location_id" validate:"omitempty"` MarketingType string `query:"marketing_type" validate:"omitempty,oneof=ayam telur trading"` FilterBy string `query:"filter_by" validate:"omitempty,oneof=so_date realization_date"` StartDate string `query:"start_date" validate:"omitempty,datetime=2006-01-02"` From 7d3602d829b30400430ca9020aa4aaf42276bc5d Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Sat, 17 Jan 2026 13:22:01 +0700 Subject: [PATCH 64/76] feat[BE]: Enhance CreateOne method to validate project flock closing status and handle warehouse without kandang_id --- .../transfers/services/transfer.service.go | 27 +++++++++++++------ 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/internal/modules/inventory/transfers/services/transfer.service.go b/internal/modules/inventory/transfers/services/transfer.service.go index 3f12b444..1c085976 100644 --- a/internal/modules/inventory/transfers/services/transfer.service.go +++ b/internal/modules/inventory/transfers/services/transfer.service.go @@ -159,12 +159,15 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques return nil, err } - projectFlockKandang, err := s.ProjectFlockKandangRepo.GetByID(c.Context(), destPfkID) - if err != nil { - return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data project flock") - } - if projectFlockKandang.ClosedAt != nil { - return nil, fiber.NewError(fiber.StatusBadRequest, "Project flock tujuan sudah closing") + // Hanya validasi closing jika ada project flock kandang (warehouse punya kandang_id) + if destPfkID > 0 { + projectFlockKandang, err := s.ProjectFlockKandangRepo.GetByID(c.Context(), destPfkID) + if err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data project flock") + } + if projectFlockKandang.ClosedAt != nil { + return nil, fiber.NewError(fiber.StatusBadRequest, "Project flock tujuan sudah closing") + } } actorID, err := m.ActorIDFromContext(c) @@ -256,11 +259,18 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques if err != nil { return err } + + // Set ProjectFlockKandangId hanya jika ada kandang + var pfkID *uint + if projectFlockKandangID > 0 { + pfkID = &projectFlockKandangID + } + destPW = &entity.ProductWarehouse{ ProductId: uint(product.ProductID), WarehouseId: uint(req.DestinationWarehouseID), Quantity: 0, - ProjectFlockKandangId: &projectFlockKandangID, + ProjectFlockKandangId: pfkID, } if err := productWarehouseRepoTX.CreateOne(c.Context(), destPW, nil); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat product warehouse destination") @@ -477,8 +487,9 @@ func (s *transferService) getActiveProjectFlockKandangID(ctx context.Context, wa return 0, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data gudang") } + // Jika warehouse tidak punya kandang_id, return 0 tanpa error if warehouse.KandangId == nil || *warehouse.KandangId == 0 { - return 0, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Gudang %d belum terhubung ke kandang", warehouseID)) + return 0, nil } projectFlockKandang, err := s.ProjectFlockKandangRepo.GetActiveByKandangID(ctx, uint(*warehouse.KandangId)) From 687d02313bd926500961aaf27f099637190946fd Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Sun, 18 Jan 2026 19:46:09 +0700 Subject: [PATCH 65/76] feat[BE]: Update TransferRelationDTO and service search logic to include warehouse names --- .../inventory/transfers/dto/transfer.dto.go | 77 ++++--------------- .../transfers/services/transfer.service.go | 6 +- 2 files changed, 19 insertions(+), 64 deletions(-) diff --git a/internal/modules/inventory/transfers/dto/transfer.dto.go b/internal/modules/inventory/transfers/dto/transfer.dto.go index 8fa4d158..73b1a66c 100644 --- a/internal/modules/inventory/transfers/dto/transfer.dto.go +++ b/internal/modules/inventory/transfers/dto/transfer.dto.go @@ -4,20 +4,17 @@ import ( "time" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + warehouseDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/dto" userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto" ) type TransferRelationDTO struct { - Id uint64 `json:"id"` - TransferReason string `json:"transfer_reason"` - TransferDate string `json:"transfer_date"` - SourceWarehouse *WarehouseDetailDTO `json:"source_warehouse,omitempty"` - DestinationWarehouse *WarehouseDetailDTO `json:"destination_warehouse,omitempty"` -} - -type WarehouseSimpleDTO struct { - Id uint `json:"id"` - Name string `json:"name"` + Id uint64 `json:"id"` + MovementNumber string `json:"movement_number"` + TransferReason string `json:"transfer_reason"` + TransferDate string `json:"transfer_date"` + SourceWarehouse *warehouseDTO.WarehouseRelationDTO `json:"source_warehouse,omitempty"` + DestinationWarehouse *warehouseDTO.WarehouseRelationDTO `json:"destination_warehouse,omitempty"` } type ProductSimpleDTO struct { @@ -25,16 +22,6 @@ type ProductSimpleDTO struct { Name string `json:"name"` } -type AreaDTO struct { - Id uint `json:"id"` - Name string `json:"name"` -} - -type LocationDTO struct { - Id uint `json:"id"` - Name string `json:"name"` -} - type SupplierSimpleDTO struct { Id uint `json:"id"` Name string `json:"name"` @@ -48,13 +35,6 @@ type DocumentDTO struct { Size float64 `json:"size"` } -type WarehouseDetailDTO struct { - Id uint `json:"id"` - Name string `json:"name"` - Location *LocationDTO `json:"location"` - Area *AreaDTO `json:"area"` -} - type TransferListDTO struct { TransferRelationDTO CreatedUser *userDTO.UserRelationDTO `json:"created_user,omitempty"` @@ -97,16 +77,19 @@ type TransferDeliveryItemDTO struct { } func ToTransferRelationDTO(e entity.StockTransfer) TransferRelationDTO { - var sourceWarehouse *WarehouseDetailDTO + var sourceWarehouse *warehouseDTO.WarehouseRelationDTO if e.FromWarehouse != nil && e.FromWarehouse.Id != 0 { - sourceWarehouse = toWarehouseDetailDTO(e.FromWarehouse) + mapped := warehouseDTO.ToWarehouseRelationDTO(*e.FromWarehouse) + sourceWarehouse = &mapped } - var destinationWarehouse *WarehouseDetailDTO + var destinationWarehouse *warehouseDTO.WarehouseRelationDTO if e.ToWarehouse != nil && e.ToWarehouse.Id != 0 { - destinationWarehouse = toWarehouseDetailDTO(e.ToWarehouse) + mapped := warehouseDTO.ToWarehouseRelationDTO(*e.ToWarehouse) + destinationWarehouse = &mapped } return TransferRelationDTO{ Id: e.Id, + MovementNumber: e.MovementNumber, TransferReason: e.Reason, TransferDate: e.CreatedAt.Format("2006-01-02"), SourceWarehouse: sourceWarehouse, @@ -114,38 +97,6 @@ func ToTransferRelationDTO(e entity.StockTransfer) TransferRelationDTO { } } -func toAreaDTO(a *entity.Area) *AreaDTO { - if a == nil { - return nil - } - return &AreaDTO{ - Id: a.Id, - Name: a.Name, - } -} - -func toLocationDTO(l *entity.Location) *LocationDTO { - if l == nil { - return nil - } - return &LocationDTO{ - Id: l.Id, - Name: l.Name, - } -} - -func toWarehouseDetailDTO(w *entity.Warehouse) *WarehouseDetailDTO { - if w == nil { - return nil - } - return &WarehouseDetailDTO{ - Id: w.Id, - Name: w.Name, - Location: toLocationDTO(w.Location), - Area: toAreaDTO(&w.Area), - } -} - func ToTransferListDTO(e entity.StockTransfer) TransferListDTO { var createdUser *userDTO.UserRelationDTO if e.CreatedUser != nil { diff --git a/internal/modules/inventory/transfers/services/transfer.service.go b/internal/modules/inventory/transfers/services/transfer.service.go index 3f12b444..35ddb4b1 100644 --- a/internal/modules/inventory/transfers/services/transfer.service.go +++ b/internal/modules/inventory/transfers/services/transfer.service.go @@ -99,7 +99,11 @@ func (s transferService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entit transfers, total, err := s.StockTransferRepo.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { db = s.withRelations(db) if params.Search != "" { - db = db.Where("movement_number ILIKE ?", "%"+strings.TrimSpace(params.Search)+"%") + searchTerm := "%" + strings.TrimSpace(params.Search) + "%" + db = db.Joins("LEFT JOIN warehouses AS from_warehouses ON from_warehouses.id = stock_transfers.from_warehouse_id"). + Joins("LEFT JOIN warehouses AS to_warehouses ON to_warehouses.id = stock_transfers.to_warehouse_id"). + Where("movement_number ILIKE ? OR from_warehouses.name ILIKE ? OR to_warehouses.name ILIKE ?", + searchTerm, searchTerm, searchTerm) } return db.Order("created_at DESC").Order("updated_at DESC") }) From bac36b4f00ff68d4111c791f6e7235cd974dd80f Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Sun, 18 Jan 2026 20:36:33 +0700 Subject: [PATCH 66/76] fix[BE]: Update error messages in TransferService to provide clearer context in Indonesian --- .../transfers/services/transfer.service.go | 51 +++++++++---------- 1 file changed, 25 insertions(+), 26 deletions(-) diff --git a/internal/modules/inventory/transfers/services/transfer.service.go b/internal/modules/inventory/transfers/services/transfer.service.go index 35ddb4b1..482916cb 100644 --- a/internal/modules/inventory/transfers/services/transfer.service.go +++ b/internal/modules/inventory/transfers/services/transfer.service.go @@ -122,9 +122,9 @@ func (s transferService) GetOne(c *fiber.Ctx, id uint) (*entity.StockTransfer, e }) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, fiber.NewError(fiber.StatusNotFound, "Transfer not found") + return nil, fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Transfer dengan ID %d tidak ditemukan", id)) } - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to get transfer") + return nil, fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Gagal mengambil data transfer dengan ID %d", id)) } return transferPtr, nil @@ -140,12 +140,12 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques ) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Stok produk %d tidak tersedia di gudang asal", product.ProductID)) + return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Produk dengan ID %d tidak ditemukan di gudang asal (ID: %d)", product.ProductID, req.SourceWarehouseID)) } - return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal cek stok produk di gudang asal") + return nil, fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Gagal mengecek stok produk %d di gudang asal", product.ProductID)) } if sourcePW.Quantity < product.ProductQty { - return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Stok produk %d di gudang asal tidak cukup", product.ProductID)) + return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Stok produk %d di gudang asal tidak mencukupi. Tersedia: %.2f, Diminta: %.2f", product.ProductID, sourcePW.Quantity, product.ProductQty)) } pwIDs = append(pwIDs, sourcePW.Id) } @@ -165,10 +165,10 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques projectFlockKandang, err := s.ProjectFlockKandangRepo.GetByID(c.Context(), destPfkID) if err != nil { - return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data project flock") + return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data project flock untuk gudang tujuan") } if projectFlockKandang.ClosedAt != nil { - return nil, fiber.NewError(fiber.StatusBadRequest, "Project flock tujuan sudah closing") + return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Project flock untuk gudang tujuan sudah ditutup (closing) pada %s", projectFlockKandang.ClosedAt.Format("2006-01-02"))) } actorID, err := m.ActorIDFromContext(c) @@ -196,16 +196,16 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques if errors.Is(err, gorm.ErrRecordNotFound) { return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Supplier dengan ID %d tidak ditemukan", delivery.SupplierID)) } - return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal cek data supplier") + return nil, fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Gagal mengambil data supplier dengan ID %d", delivery.SupplierID)) } if supplier.Category != string(utils.SupplierCategoryBOP) { - return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Supplier dengan ID %d bukan kategori BOP", delivery.SupplierID)) + return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Supplier '%s' (ID: %d) bukan kategori BOP. Kategori saat ini: %s", supplier.Name, delivery.SupplierID, supplier.Category)) } } movementNumber, err := s.StockTransferRepo.GenerateMovementNumber(c.Context()) if err != nil { - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to generate movement number") + return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat nomor movement transfer") } transferDate, _ := utils.ParseDateString(req.TransferDate) @@ -243,16 +243,16 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques ) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { - return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Stok produk %d tidak tersedia di gudang asal", product.ProductID)) + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Produk %d tidak ditemukan di gudang asal (ID: %d)", product.ProductID, req.SourceWarehouseID)) } - return fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data product warehouse source") + return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Gagal mengambil data product warehouse untuk produk %d di gudang asal", product.ProductID)) } destPW, err := productWarehouseRepoTX.GetProductWarehouseByProductAndWarehouseID( c.Context(), uint(product.ProductID), uint(req.DestinationWarehouseID), ) if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { - return fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data product warehouse destination") + return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Gagal mengambil data product warehouse untuk produk %d di gudang tujuan", product.ProductID)) } if errors.Is(err, gorm.ErrRecordNotFound) { ctx := c.Context() @@ -267,7 +267,7 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques ProjectFlockKandangId: &projectFlockKandangID, } if err := productWarehouseRepoTX.CreateOne(c.Context(), destPW, nil); err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat product warehouse destination") + return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Gagal membuat product warehouse untuk produk %d di gudang tujuan", product.ProductID)) } } @@ -313,7 +313,7 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques for _, prod := range item.Products { detail, ok := detailMap[uint64(prod.ProductID)] if !ok { - return fmt.Errorf("produk %d tidak ditemukan di detail", prod.ProductID) + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Produk %d tidak ditemukan dalam daftar transfer untuk delivery #%d", prod.ProductID, i+1)) } deliveryItems = append(deliveryItems, &entity.StockTransferDeliveryItem{ StockTransferDeliveryId: delivery.Id, @@ -376,7 +376,7 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques Tx: tx, }) if err != nil { - return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Stok tidak cukup di gudang asal untuk produk %d: %v", product.ProductID, err)) + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Stok tidak mencukupi untuk produk %d di gudang asal. Error: %v", product.ProductID, err)) } if err := tx.Model(&entity.StockTransferDetail{}). @@ -385,7 +385,7 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques "usage_qty": consumeResult.UsageQuantity, "pending_qty": consumeResult.PendingQuantity, }).Error; err != nil { - return fmt.Errorf("gagal update usage tracking: %w", err) + return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Gagal mengupdate tracking usage untuk produk %d", product.ProductID)) } note := fmt.Sprintf("Transfer #%s", entityTransfer.MovementNumber) @@ -398,7 +398,7 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques Tx: tx, }) if err != nil { - return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Gagal menambah stok di gudang tujuan untuk produk %d: %v", product.ProductID, err)) + return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Gagal menambah stok untuk produk %d di gudang tujuan. Error: %v", product.ProductID, err)) } if err := tx.Model(&entity.StockTransferDetail{}). @@ -406,7 +406,7 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques Updates(map[string]interface{}{ "total_qty": replenishResult.AddedQuantity, }).Error; err != nil { - return fmt.Errorf("gagal update total tracking: %w", err) + return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Gagal mengupdate tracking total untuk produk %d", product.ProductID)) } } @@ -440,7 +440,7 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques }) if err != nil { - return nil, fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to process transfer transaction: %v", err)) + return nil, fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Gagal memproses transfer. Error: %v", err)) } result, err := s.GetOne(c, uint(entityTransfer.Id)) @@ -450,8 +450,7 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques if len(expensePayloads) > 0 { if err := s.notifyExpenseItemsDelivered(c, entityTransfer.Id, expensePayloads); err != nil { - s.Log.Errorf("Failed to sync expense for transfer %d: %+v", entityTransfer.Id, err) - return nil, fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to sync expense: %v", err)) + return nil, fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Gagal sinkronisasi data expense untuk transfer %s. Silakan cek manual di module expense", entityTransfer.MovementNumber)) } } @@ -478,19 +477,19 @@ func (s *transferService) getActiveProjectFlockKandangID(ctx context.Context, wa if errors.Is(err, gorm.ErrRecordNotFound) { return 0, fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Gudang dengan ID %d tidak ditemukan", warehouseID)) } - return 0, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data gudang") + return 0, fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Gagal mengambil data gudang dengan ID %d", warehouseID)) } if warehouse.KandangId == nil || *warehouse.KandangId == 0 { - return 0, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Gudang %d belum terhubung ke kandang", warehouseID)) + return 0, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Gudang '%s' (ID: %d) belum terhubung ke kandang", warehouse.Name, warehouseID)) } projectFlockKandang, err := s.ProjectFlockKandangRepo.GetActiveByKandangID(ctx, uint(*warehouse.KandangId)) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { - return 0, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Kandang %d belum memiliki project flock aktif", *warehouse.KandangId)) + return 0, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Tidak ada project flock aktif untuk kandang %d", *warehouse.KandangId)) } - return 0, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil project flock kandang") + return 0, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data project flock kandang yang aktif") } return uint(projectFlockKandang.Id), nil From af7aabdec8511d61a7e913744773be65e7f20b81 Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Sun, 18 Jan 2026 20:50:52 +0700 Subject: [PATCH 67/76] fix[BE]: Adjust validation for MarketingQuery limit to remove max constraint --- internal/modules/repports/validations/repport.validation.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/modules/repports/validations/repport.validation.go b/internal/modules/repports/validations/repport.validation.go index bddb5ab1..b178202f 100644 --- a/internal/modules/repports/validations/repport.validation.go +++ b/internal/modules/repports/validations/repport.validation.go @@ -17,7 +17,7 @@ type ExpenseQuery struct { type MarketingQuery struct { Page int `query:"page" validate:"omitempty,min=1,gt=0"` - Limit int `query:"limit" validate:"omitempty,min=1,max=100,gt=0"` + Limit int `query:"limit" validate:"omitempty,min=1,gt=0"` Search string `query:"search" validate:"omitempty,max=100"` CustomerId int64 `query:"customer_id" validate:"omitempty"` ProductId int64 `query:"product_id" validate:"omitempty"` From fb193fc61f2aa1d29a4fb653dd86ae80e5ffad04 Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Sun, 18 Jan 2026 21:26:31 +0700 Subject: [PATCH 68/76] fix[BE]: Update GetAllWithFilters to enhance search functionality and join conditions --- .../salesorder_delivery_product.repository.go | 24 ++++++++++++++----- 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/internal/modules/marketing/repositories/salesorder_delivery_product.repository.go b/internal/modules/marketing/repositories/salesorder_delivery_product.repository.go index f14988b1..8c0bad3d 100644 --- a/internal/modules/marketing/repositories/salesorder_delivery_product.repository.go +++ b/internal/modules/marketing/repositories/salesorder_delivery_product.repository.go @@ -141,25 +141,37 @@ func (r *MarketingDeliveryProductRepositoryImpl) GetAllWithFilters(ctx context.C Where("marketing_delivery_products.delivery_date IS NOT NULL") if filters.ProductId > 0 || filters.WarehouseId > 0 || filters.Search != "" || filters.MarketingType != "" { - db = db.Joins("LEFT JOIN product_warehouses ON product_warehouses.id = marketing_products.product_warehouse_id") + db = db.Joins("LEFT JOIN product_warehouses ON product_warehouses.id = marketing_delivery_products.product_warehouse_id") } if filters.ProductId > 0 || filters.Search != "" || filters.MarketingType != "" { db = db.Joins("LEFT JOIN products ON products.id = product_warehouses.product_id") } - if filters.WarehouseId > 0 { + if filters.WarehouseId > 0 || filters.Search != "" { db = db.Joins("LEFT JOIN warehouses ON warehouses.id = product_warehouses.warehouse_id") } if filters.Search != "" { db = db.Joins("LEFT JOIN customers ON customers.id = marketings.customer_id") - } + db = db.Joins("LEFT JOIN users AS sales_users ON sales_users.id = marketings.sales_person_id") - if filters.Search != "" { searchPattern := "%" + filters.Search + "%" - db = db.Where("marketing_delivery_products.vehicle_number ILIKE ? OR marketings.so_number ILIKE ? OR customers.name ILIKE ? OR products.name ILIKE ?", - searchPattern, searchPattern, searchPattern, searchPattern) + db = db.Where(`( + marketing_delivery_products.vehicle_number ILIKE ? OR + customers.name ILIKE ? OR + warehouses.name ILIKE ? OR + products.name ILIKE ? OR + sales_users.name ILIKE ? OR + CONCAT( + marketings.so_number, + '-', + COALESCE(TO_CHAR(marketing_delivery_products.delivery_date, 'YYYYMMDD'), ''), + '-', + COALESCE(product_warehouses.warehouse_id::text, '') + ) ILIKE ? + )`, + searchPattern, searchPattern, searchPattern, searchPattern, searchPattern, searchPattern) } if filters.CustomerId > 0 { From 378d633ea49967af3f447ffb1e61e001b8c82fc8 Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Mon, 19 Jan 2026 09:27:37 +0700 Subject: [PATCH 69/76] feat[BE]: Enhance payment allocation logic to support FIFO consumption for sales transactions --- .../repports/services/repport.service.go | 78 ++++++++++++++++--- 1 file changed, 67 insertions(+), 11 deletions(-) diff --git a/internal/modules/repports/services/repport.service.go b/internal/modules/repports/services/repport.service.go index 2e07e212..48f9e205 100644 --- a/internal/modules/repports/services/repport.service.go +++ b/internal/modules/repports/services/repport.service.go @@ -464,9 +464,13 @@ func (s *repportService) GetCustomerPayment(ctx *fiber.Ctx, params *validation.C if err != nil { return nil, 0, err } - result = append(result, item) + + if len(item.Rows) > 0 { + result = append(result, item) + } } + totalCustomers = int64(len(result)) return result, totalCustomers, nil } @@ -503,14 +507,8 @@ func (s *repportService) processCustomerPayment(ctx context.Context, customerID row.Status = status if status == "LUNAS" { - if previousBalance >= tx.TotalPrice { - days := 0 - row.AgingDay = &days - } else if paymentDate != nil { + if paymentDate != nil { days := int(paymentDate.Sub(tx.TransDate).Hours() / 24) - if days < 0 { - days = 0 - } row.AgingDay = &days } else { days := 0 @@ -518,9 +516,6 @@ func (s *repportService) processCustomerPayment(ctx context.Context, customerID } } else { days := int(time.Since(tx.TransDate).Hours() / 24) - if days < 0 { - days = 0 - } row.AgingDay = &days } } else if tx.TransactionType == "PAYMENT" { @@ -586,6 +581,67 @@ func (s *repportService) determineSalesStatusAndPaymentDate(transactions []reppo // 4. BELUM LUNAS: no payment at all if previousBalance >= currentSales.TotalPrice { + // Cari payment yang digunakan untuk melunasi sales ini dengan FIFO + // Track payment allocations that are consumed by previous sales + type paymentAllocation struct { + date time.Time + amount float64 + consumed float64 + } + allocations := []paymentAllocation{} + runningBalance := 0.0 + + // Process all transactions before current sales to build allocation map + for i := 0; i < currentIndex; i++ { + if transactions[i].TransactionType == "PAYMENT" { + allocations = append(allocations, paymentAllocation{ + date: transactions[i].TransDate, + amount: transactions[i].PaymentAmount, + consumed: 0, + }) + runningBalance += transactions[i].PaymentAmount + } else if transactions[i].TransactionType == "SALES" { + salesAmount := transactions[i].TotalPrice + remainingToConsume := salesAmount + + // Consume from oldest allocations first (FIFO) + for j := range allocations { + if remainingToConsume <= 0 { + break + } + available := allocations[j].amount - allocations[j].consumed + if available > 0 { + consume := available + if consume > remainingToConsume { + consume = remainingToConsume + } + allocations[j].consumed += consume + remainingToConsume -= consume + } + } + runningBalance -= salesAmount + } + } + + // Now find which allocation covers the current sales + amountNeeded := currentSales.TotalPrice + for _, alloc := range allocations { + available := alloc.amount - alloc.consumed + if available > 0 { + if amountNeeded <= available { + // This allocation fully covers the sales + return "LUNAS", &alloc.date + } else { + // This allocation partially covers, continue to next + amountNeeded -= available + } + } + } + + // If we get here, use the oldest allocation + if len(allocations) > 0 { + return "LUNAS", &allocations[0].date + } return "LUNAS", nil } From 8cd9627a511bfcc7f5d935d5cb149da28bd65f04 Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Mon, 19 Jan 2026 14:34:08 +0700 Subject: [PATCH 70/76] feat[BE]: Add requested_qty field to LayingTransferSource and update related logic for transfer operations --- ...ed_qty_to_laying_transfer_sources.down.sql | 4 ++ ...sted_qty_to_laying_transfer_sources.up.sql | 9 +++ internal/entities/laying_transfer_source.go | 1 + internal/modules/inventory/transfers/route.go | 6 +- .../chickins/services/chickin.service.go | 4 +- .../project_flock_population_repository.go | 23 +++++++- .../dto/transfer_laying.dto.go | 12 +++- .../services/transfer_laying.service.go | 58 ++++++++++++++++--- .../validations/transfer_laying.validation.go | 12 ++-- 9 files changed, 109 insertions(+), 20 deletions(-) create mode 100644 internal/database/migrations/20260119055524_add_requested_qty_to_laying_transfer_sources.down.sql create mode 100644 internal/database/migrations/20260119055524_add_requested_qty_to_laying_transfer_sources.up.sql diff --git a/internal/database/migrations/20260119055524_add_requested_qty_to_laying_transfer_sources.down.sql b/internal/database/migrations/20260119055524_add_requested_qty_to_laying_transfer_sources.down.sql new file mode 100644 index 00000000..7dd06499 --- /dev/null +++ b/internal/database/migrations/20260119055524_add_requested_qty_to_laying_transfer_sources.down.sql @@ -0,0 +1,4 @@ +-- Rollback: Remove requested_qty column from laying_transfer_sources table + +ALTER TABLE laying_transfer_sources +DROP COLUMN IF EXISTS requested_qty; diff --git a/internal/database/migrations/20260119055524_add_requested_qty_to_laying_transfer_sources.up.sql b/internal/database/migrations/20260119055524_add_requested_qty_to_laying_transfer_sources.up.sql new file mode 100644 index 00000000..dc28ca74 --- /dev/null +++ b/internal/database/migrations/20260119055524_add_requested_qty_to_laying_transfer_sources.up.sql @@ -0,0 +1,9 @@ +-- Add requested_qty column to laying_transfer_sources table +-- This field stores the quantity requested by user during create/update +-- Separate from UsageQty (FIFO consumed) and PendingUsageQty (FIFO pending) + +ALTER TABLE laying_transfer_sources +ADD COLUMN requested_qty NUMERIC(15,3) DEFAULT 0 NOT NULL; + +-- Add comment for documentation +COMMENT ON COLUMN laying_transfer_sources.requested_qty IS 'Quantity requested by user during create/update'; diff --git a/internal/entities/laying_transfer_source.go b/internal/entities/laying_transfer_source.go index e0b85774..b284746d 100644 --- a/internal/entities/laying_transfer_source.go +++ b/internal/entities/laying_transfer_source.go @@ -11,6 +11,7 @@ type LayingTransferSource struct { LayingTransferId uint `gorm:"index;not null"` SourceProjectFlockKandangId uint `gorm:"not null"` ProductWarehouseId *uint `gorm:""` + RequestedQty float64 `gorm:"type:numeric(15,3);default:0;not null"` // Quantity requested by user UsageQty float64 `gorm:"type:numeric(15,3);default:0;not null"` // FIFO USABLE field PendingUsageQty float64 `gorm:"type:numeric(15,3);default:0;not null"` // FIFO USABLE field Note string `gorm:"type:text"` diff --git a/internal/modules/inventory/transfers/route.go b/internal/modules/inventory/transfers/route.go index d24dbcb4..f754148c 100644 --- a/internal/modules/inventory/transfers/route.go +++ b/internal/modules/inventory/transfers/route.go @@ -15,8 +15,8 @@ func TransferRoutes(v1 fiber.Router, u user.UserService, s transfer.TransferServ route := v1.Group("/transfers") route.Use(m.Auth(u)) - route.Get("/",m.RequirePermissions(m.P_TransferGetAll), ctrl.GetAll) - route.Post("/",m.RequirePermissions(m.P_TransferCreateOne), ctrl.CreateOne) - route.Get("/:id",m.RequirePermissions(m.P_TransferGetOne), ctrl.GetOne) + route.Get("/", m.RequirePermissions(m.P_TransferGetAll), ctrl.GetAll) + route.Post("/", m.RequirePermissions(m.P_TransferCreateOne), ctrl.CreateOne) + route.Get("/:id", m.RequirePermissions(m.P_TransferGetOne), ctrl.GetOne) } diff --git a/internal/modules/production/chickins/services/chickin.service.go b/internal/modules/production/chickins/services/chickin.service.go index 84e98f2d..b39dca78 100644 --- a/internal/modules/production/chickins/services/chickin.service.go +++ b/internal/modules/production/chickins/services/chickin.service.go @@ -200,9 +200,9 @@ func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) ([]enti newChikins = append(newChikins, newChickin) - totalPopulationQty, err := s.ProjectflockPopulationRepo.GetTotalQtyByProjectFlockKandangID(c.Context(), req.ProjectFlockKandangId) + totalPopulationQty, err := s.ProjectflockPopulationRepo.GetTotalQtyByProductWarehouseID(c.Context(), chickinReq.ProductWarehouseId) if err != nil { - return nil, fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to get total population quantity for project_flock_kandang %d", req.ProjectFlockKandangId)) + return nil, fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to get total population quantity for product warehouse %d", chickinReq.ProductWarehouseId)) } availableQty := productWarehouse.Quantity - totalPopulationQty diff --git a/internal/modules/production/project_flocks/repositories/project_flock_population_repository.go b/internal/modules/production/project_flocks/repositories/project_flock_population_repository.go index 022da6a3..36fe8cbc 100644 --- a/internal/modules/production/project_flocks/repositories/project_flock_population_repository.go +++ b/internal/modules/production/project_flocks/repositories/project_flock_population_repository.go @@ -2,6 +2,7 @@ package repository import ( "context" + "math" "gitlab.com/mbugroup/lti-api.git/internal/common/repository" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" @@ -16,6 +17,7 @@ type ProjectFlockPopulationRepository interface { GetTotalQtyByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint) (float64, error) GetTotalQtyByProductWarehouseID(ctx context.Context, productWarehouseID uint) (float64, error) GetAvailableQtyByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint) (float64, error) + GetTotalChickInByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint) (int64, error) CreateOne(ctx context.Context, entity *entity.ProjectFlockPopulation, modifier func(*gorm.DB) *gorm.DB) error PatchOne(ctx context.Context, id uint, updates map[string]any, modifier func(*gorm.DB) *gorm.DB) error @@ -111,7 +113,7 @@ func (r *projectFlockPopulationRepositoryImpl) GetTotalQtyByProductWarehouseID(c err := r.DB().WithContext(ctx). Model(&entity.ProjectFlockPopulation{}). Where("product_warehouse_id = ?", productWarehouseID). - Select("COALESCE(SUM(total_qty), 0)"). + Select("COALESCE(SUM(total_qty - total_used_qty), 0)"). Scan(&total).Error if err != nil { return 0, err @@ -135,3 +137,22 @@ func (r *projectFlockPopulationRepositoryImpl) GetAvailableQtyByProjectFlockKand } return total, nil } + +func (r *projectFlockPopulationRepositoryImpl) GetTotalChickInByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint) (int64, error) { + var total float64 + err := r.DB().WithContext(ctx). + Table("project_flock_populations"). + Select("COALESCE(SUM(project_flock_populations.total_qty - project_flock_populations.total_used_qty), 0) AS total_qty"). + Joins("JOIN project_chickins ON project_chickins.id = project_flock_populations.project_chickin_id"). + Where("project_chickins.project_flock_kandang_id = ?", projectFlockKandangID). + Scan(&total).Error + if err != nil { + return 0, err + } + + if total < 0 { + total = 0 + } + + return int64(math.Round(total)), nil +} diff --git a/internal/modules/production/transfer_layings/dto/transfer_laying.dto.go b/internal/modules/production/transfer_layings/dto/transfer_laying.dto.go index e81d6cc5..dfc5e5d9 100644 --- a/internal/modules/production/transfer_layings/dto/transfer_laying.dto.go +++ b/internal/modules/production/transfer_layings/dto/transfer_laying.dto.go @@ -162,9 +162,19 @@ func ToProductWarehouseSummaryDTO(pw *entity.ProductWarehouse) *ProductWarehouse } func ToLayingTransferSourceDTO(source entity.LayingTransferSource) LayingTransferSourceDTO { + // Tampilkan requested qty sebelum approve, consumed qty setelah approve + var displayQty float64 + if source.UsageQty > 0 { + // Sudah di-approve dan di-consume, tampilkan actual consumed quantity + displayQty = source.UsageQty + } else { + // Belum di-approve, tampilkan requested quantity + displayQty = source.RequestedQty + } + return LayingTransferSourceDTO{ SourceProjectFlockKandang: ToProjectFlockKandangSummaryDTO(source.SourceProjectFlockKandang), - Qty: source.UsageQty, // Ambil dari UsageQty (FIFO consumed quantity) + Qty: displayQty, ProductWarehouse: ToProductWarehouseSummaryDTO(source.ProductWarehouse), Note: source.Note, } diff --git a/internal/modules/production/transfer_layings/services/transfer_laying.service.go b/internal/modules/production/transfer_layings/services/transfer_laying.service.go index 9732ad75..3fe0b0b7 100644 --- a/internal/modules/production/transfer_layings/services/transfer_laying.service.go +++ b/internal/modules/production/transfer_layings/services/transfer_laying.service.go @@ -110,8 +110,32 @@ func (s transferLayingService) GetAll(c *fiber.Ctx, params *validation.Query) ([ offset := (params.Page - 1) * params.Limit transferLayings, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { - db = s.withRelations(db) + // Apply search and filters + if params.Search != "" { + searchPattern := "%" + params.Search + "%" + db = db.Joins("LEFT JOIN project_flocks AS pf_from ON laying_transfers.from_project_flock_id = pf_from.id"). + Joins("LEFT JOIN project_flocks AS pf_to ON laying_transfers.to_project_flock_id = pf_to.id"). + Where("laying_transfers.transfer_number ILIKE ? OR laying_transfers.notes ILIKE ? OR pf_from.flock_name ILIKE ? OR pf_to.flock_name ILIKE ?", + searchPattern, searchPattern, searchPattern, searchPattern) + } + + if params.TransferDate != "" { + db = db.Where("transfer_date::date = ?::date", params.TransferDate) + } + + if params.FlockSource > 0 { + db = db.Where("from_project_flock_id = ?", params.FlockSource) + } + + if params.FlockDestination > 0 { + db = db.Where("to_project_flock_id = ?", params.FlockDestination) + } + db = db.Order("created_at DESC") + + // Apply relations for eager loading + db = s.withRelations(db) + return db }) @@ -216,7 +240,7 @@ func (s *transferLayingService) CreateOne(c *fiber.Ctx, req *validation.Create) for _, sourceDetail := range req.SourceKandangs { if sourceDetail.Quantity <= 0 { - return nil, fiber.NewError(fiber.StatusBadRequest, "Jumlah kandang sumber harus lebih dari 0") + continue } totalSourceQty += sourceDetail.Quantity @@ -247,11 +271,18 @@ func (s *transferLayingService) CreateOne(c *fiber.Ctx, req *validation.Create) for _, targetDetail := range req.TargetKandangs { if targetDetail.Quantity <= 0 { - return nil, fiber.NewError(fiber.StatusBadRequest, "Jumlah kandang tujuan harus lebih dari 0") + continue } totalTargetQty += targetDetail.Quantity } + if totalSourceQty == 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, "Minimal harus ada 1 kandang sumber dengan jumlah lebih dari 0") + } + if totalTargetQty == 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, "Minimal harus ada 1 kandang tujuan dengan jumlah lebih dari 0") + } + if totalSourceQty != totalTargetQty { return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Jumlah total sumber (%.0f) harus sama dengan jumlah total tujuan (%.0f)", totalSourceQty, totalTargetQty)) } @@ -279,11 +310,16 @@ func (s *transferLayingService) CreateOne(c *fiber.Ctx, req *validation.Create) } for _, sourceDetail := range req.SourceKandangs { + if sourceDetail.Quantity == 0 { + continue + } + productWarehouseId := sourceWarehouseMap[sourceDetail.ProjectFlockKandangId] source := entity.LayingTransferSource{ LayingTransferId: createBody.Id, SourceProjectFlockKandangId: sourceDetail.ProjectFlockKandangId, + RequestedQty: sourceDetail.Quantity, // Quantity yang diminta user UsageQty: 0, PendingUsageQty: 0, // Di-set 0, biarkan FIFO Consume yang handle saat Approval ProductWarehouseId: &productWarehouseId, @@ -295,6 +331,9 @@ func (s *transferLayingService) CreateOne(c *fiber.Ctx, req *validation.Create) } for _, targetDetail := range req.TargetKandangs { + if targetDetail.Quantity == 0 { + continue + } targetprojectFlockKandang, err := s.ProjectFlockKandangRepo.GetByID(c.Context(), targetDetail.ProjectFlockKandangId) if err != nil { @@ -463,8 +502,9 @@ func (s *transferLayingService) UpdateOne(c *fiber.Ctx, req *validation.Update, source := entity.LayingTransferSource{ LayingTransferId: id, SourceProjectFlockKandangId: sourceDetail.ProjectFlockKandangId, + RequestedQty: sourceDetail.Quantity, // Quantity yang diminta user UsageQty: 0, - PendingUsageQty: sourceDetail.Quantity, + PendingUsageQty: 0, // Di-set 0, biarkan FIFO Consume yang handle saat Approval ProductWarehouseId: &productWarehouseId, } if err := sourceRepo.CreateOne(c.Context(), &source, nil); err != nil { @@ -700,7 +740,7 @@ func (s transferLayingService) Approval(c *fiber.Ctx, req *validation.Approve) ( return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Target product warehouse tidak ditemukan untuk transfer %d", approvableID)) } - note := fmt.Sprintf("Transfer to Laying #%s - Target Kandang", transfer.TransferNumber) + note := fmt.Sprintf("Transfer to Laying #%s", transfer.TransferNumber) replenishResult, err := s.FifoSvc.Replenish(c.Context(), commonSvc.StockReplenishRequest{ StockableKey: fifo.StockableKeyTransferToLayingIn, StockableID: target.Id, @@ -814,15 +854,15 @@ func (s transferLayingService) GetAvailableQtyPerKandang(ctx *fiber.Ctx, project kandangAvailableQty := make(map[uint]float64) for _, kandang := range kandangs { - - totalQty, err := s.ProjectFlockPopulationRepo.GetTotalQtyByProjectFlockKandangID(ctx.Context(), kandang.Id) + // Gunakan fungsi repository yang sama dengan recording service + totalAvailable, err := s.ProjectFlockPopulationRepo.GetAvailableQtyByProjectFlockKandangID(ctx.Context(), kandang.Id) if err != nil { - s.Log.Warnf("Failed to get total qty for kandang %d: %+v", kandang.Id, err) + s.Log.Warnf("Failed to get available qty for kandang %d: %+v", kandang.Id, err) kandangAvailableQty[kandang.Id] = 0 continue } - kandangAvailableQty[kandang.Id] = totalQty + kandangAvailableQty[kandang.Id] = totalAvailable } return pf, kandangAvailableQty, nil diff --git a/internal/modules/production/transfer_layings/validations/transfer_laying.validation.go b/internal/modules/production/transfer_layings/validations/transfer_laying.validation.go index 45a73e48..06d52316 100644 --- a/internal/modules/production/transfer_layings/validations/transfer_laying.validation.go +++ b/internal/modules/production/transfer_layings/validations/transfer_laying.validation.go @@ -2,12 +2,12 @@ package validation type SourceKandangDetail struct { ProjectFlockKandangId uint `json:"project_flock_kandang_id" validate:"required"` - Quantity float64 `json:"quantity" validate:"required,gt=0"` + Quantity float64 `json:"quantity"` } type TargetKandangDetail struct { ProjectFlockKandangId uint `json:"project_flock_kandang_id" validate:"required"` - Quantity float64 `json:"quantity" validate:"required,gt=0"` + Quantity float64 `json:"quantity"` } type Create struct { @@ -29,8 +29,12 @@ type Update struct { } type Query struct { - Page int `query:"page" validate:"omitempty,number,min=1,gt=0"` - Limit int `query:"limit" validate:"omitempty,number,min=1,max=100,gt=0"` + Page int `query:"page" validate:"omitempty,number,min=1,gt=0"` + Limit int `query:"limit" validate:"omitempty,number,min=1,max=100,gt=0"` + Search string `query:"search" validate:"omitempty"` + TransferDate string `query:"transfer_date" validate:"omitempty"` + FlockSource uint `query:"flock_source" validate:"omitempty,number"` + FlockDestination uint `query:"flock_destination" validate:"omitempty,number"` } type Approve struct { From 768961d7d6baf7d9ba48b1fbc6274fabb384c49c Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Mon, 19 Jan 2026 14:39:43 +0700 Subject: [PATCH 71/76] fix[BE]: Refactor GetAll method to improve query parameter handling and formatting --- .../controllers/transfer_laying.controller.go | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/internal/modules/production/transfer_layings/controllers/transfer_laying.controller.go b/internal/modules/production/transfer_layings/controllers/transfer_laying.controller.go index d2ab6d0a..13c39334 100644 --- a/internal/modules/production/transfer_layings/controllers/transfer_laying.controller.go +++ b/internal/modules/production/transfer_layings/controllers/transfer_laying.controller.go @@ -25,8 +25,12 @@ func NewTransferLayingController(transferLayingService service.TransferLayingSer func (u *TransferLayingController) GetAll(c *fiber.Ctx) error { query := &validation.Query{ - Page: c.QueryInt("page", 1), - Limit: c.QueryInt("limit", 10), + Page: c.QueryInt("page", 1), + Limit: c.QueryInt("limit", 10), + Search: c.Query("search", ""), + TransferDate: c.Query("transfer_date", ""), + FlockSource: uint(c.QueryInt("flock_source", 0)), + FlockDestination: uint(c.QueryInt("flock_destination", 0)), } if query.Page < 1 || query.Limit < 1 { @@ -179,7 +183,6 @@ func (u *TransferLayingController) Approval(c *fiber.Ctx) error { }) } - func (u *TransferLayingController) GetAvailableQtyPerKandang(c *fiber.Ctx) error { projectFlockID, err := strconv.ParseUint(c.Params("project_flock_id"), 10, 32) if err != nil { From 71c62c5e0235a5d05792f0d318036c5f2f2c0c9e Mon Sep 17 00:00:00 2001 From: giovanni Date: Mon, 19 Jan 2026 16:19:47 +0700 Subject: [PATCH 72/76] [FIX][BE]: LSS390 --- .../dto/project_flock_kandang.dto.go | 14 +++ .../modules/repports/dto/repportHpp.dto.go | 6 +- .../hpp_per_kandang.repository.go | 71 +++++++----- .../repports/services/repport.service.go | 103 ++++++++++-------- 4 files changed, 121 insertions(+), 73 deletions(-) diff --git a/internal/modules/production/project-flock-kandangs/dto/project_flock_kandang.dto.go b/internal/modules/production/project-flock-kandangs/dto/project_flock_kandang.dto.go index 452cc7b3..c8faf761 100644 --- a/internal/modules/production/project-flock-kandangs/dto/project_flock_kandang.dto.go +++ b/internal/modules/production/project-flock-kandangs/dto/project_flock_kandang.dto.go @@ -1,6 +1,7 @@ package dto import ( + "strconv" "time" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" @@ -53,6 +54,7 @@ type ProjectFlockKandangListDTO struct { ProjectFlockKandangRelationDTO ProjectFlock *ProjectFlockDTO `json:"project_flock,omitempty"` Kandang *kandangDTO.KandangRelationDTO `json:"kandang,omitempty"` + NameWithPeriod string `json:"name_with_period"` CreatedUser *userDTO.UserRelationDTO `json:"created_user,omitempty"` CreatedAt time.Time `json:"created_at"` Approval *approvalDTO.ApprovalRelationDTO `json:"approval,omitempty"` @@ -104,6 +106,7 @@ func ToProjectFlockKandangDetailDTOWithAvailableQty(e entity.ProjectFlockKandang ProjectFlockKandangRelationDTO: ToProjectFlockKandangRelationDTO(e), ProjectFlock: toProjectFlockDTO(projectFlockSummary), Kandang: toKandangRelation(e.Kandang), + NameWithPeriod: toNameWithPeriod(e.Kandang, e.Period), CreatedAt: e.CreatedAt, CreatedUser: toCreatedUserDTO(e.ProjectFlock), Approval: toApprovalDTOSelector(e, func(x entity.ProjectFlockKandang) *entity.Approval { return x.LatestProjectFlockApproval }), @@ -126,6 +129,16 @@ func toKandangRelation(kandang entity.Kandang) *kandangDTO.KandangRelationDTO { return &mapped } +func toNameWithPeriod(kandang entity.Kandang, period int) string { + if kandang.Name == "" { + return "" + } + if period == 0 { + return kandang.Name + } + return kandang.Name + " Period " + strconv.Itoa(period) +} + func toApprovalDTOSelector( e entity.ProjectFlockKandang, selector func(entity.ProjectFlockKandang) *entity.Approval) *approvalDTO.ApprovalRelationDTO { approval := selector(e) @@ -147,6 +160,7 @@ func ToProjectFlockKandangListDTO(e entity.ProjectFlockKandang) ProjectFlockKand ProjectFlockKandangRelationDTO: ToProjectFlockKandangRelationDTO(e), ProjectFlock: toProjectFlockDTO(projectFlockSummary), Kandang: toKandangRelation(e.Kandang), + NameWithPeriod: toNameWithPeriod(e.Kandang, e.Period), CreatedAt: e.CreatedAt, CreatedUser: toCreatedUserDTO(e.ProjectFlock), Approval: toApprovalDTOSelector(e, func(x entity.ProjectFlockKandang) *entity.Approval { return x.LatestProjectFlockApproval }), diff --git a/internal/modules/repports/dto/repportHpp.dto.go b/internal/modules/repports/dto/repportHpp.dto.go index f790244c..15b6d51b 100644 --- a/internal/modules/repports/dto/repportHpp.dto.go +++ b/internal/modules/repports/dto/repportHpp.dto.go @@ -31,6 +31,8 @@ type HppPerKandangRowDTO struct { AvgWeightKg float64 `json:"avg_weight_kg"` EggProductionPieces int64 `json:"egg_production_pieces"` EggProductionKg float64 `json:"egg_production_kg"` + // EggProductionTotalWeightKg float64 `json:"egg_production_total_weight_kg"` + // EggProductionTotalPieces int64 `json:"egg_production_total_pieces"` // FeedCostRp float64 `json:"feed_cost_rp"` // OvkCostRp float64 `json:"ovk_cost_rp"` EggHppRpPerKg float64 `json:"egg_hpp_rp_per_kg"` @@ -38,8 +40,8 @@ type HppPerKandangRowDTO struct { FeedSuppliers []HppPerKandangSupplierDTO `json:"feed_suppliers"` DocSuppliers []HppPerKandangSupplierDTO `json:"doc_suppliers"` AverageDocPriceRp int64 `json:"average_doc_price_rp"` - HppRp float64 `json:"hpp_rp"` - RemainingValueRp int64 `json:"remaining_value_rp"` + // HppRp float64 `json:"hpp_rp"` + // RemainingValueRp int64 `json:"remaining_value_rp"` } type HppPerKandangRowKandangDTO struct { diff --git a/internal/modules/repports/repositories/hpp_per_kandang.repository.go b/internal/modules/repports/repositories/hpp_per_kandang.repository.go index 64676ca8..37a37d45 100644 --- a/internal/modules/repports/repositories/hpp_per_kandang.repository.go +++ b/internal/modules/repports/repositories/hpp_per_kandang.repository.go @@ -11,19 +11,21 @@ import ( ) type HppPerKandangRow struct { - ProjectFlockKandangID uint - KandangID uint - KandangName string - KandangStatus string - LocationID uint - LocationName string - PicID uint - PicName string - RecordingCount int64 - RemainingChickenBirds float64 - RemainingChickenWeight float64 - EggProductionWeightKg float64 - EggProductionPieces float64 + ProjectFlockKandangID uint + KandangID uint + KandangName string + KandangStatus string + LocationID uint + LocationName string + PicID uint + PicName string + RecordingCount int64 + // RemainingChickenBirds float64 + // RemainingChickenWeight float64 + EggProductionWeightKgRemaining float64 + EggProductionPiecesRemaining float64 + EggProductionTotalWeightKg float64 + EggProductionTotalPieces float64 } type HppPerKandangCostRow struct { @@ -97,13 +99,22 @@ func (r *hppPerKandangRepository) GetRowsByPeriod(ctx context.Context, start, en COALESCE(MAX(vr.total_chick_qty), 0) AS remaining_chicken_birds, 0 AS remaining_chicken_weight, 0 AS egg_production_weight_kg, - 0 AS egg_production_pieces`). + 0 AS egg_production_pieces, + 0 AS egg_production_total_weight_kg, + 0 AS egg_production_total_pieces`). Joins("JOIN project_flock_kandangs AS pfk ON pfk.project_flock_id = pf.id"). + Joins(` + LEFT JOIN ( + SELECT project_flock_kandang_id, MIN(chick_in_date) AS chick_in_date + FROM project_chickins + GROUP BY project_flock_kandang_id + ) AS pc ON pc.project_flock_kandang_id = pfk.id`). Joins("JOIN kandangs AS k ON k.id = pfk.kandang_id"). Joins("JOIN locations AS loc ON loc.id = k.location_id"). Joins("JOIN users AS pic ON pic.id = k.pic_id"). Joins("LEFT JOIN (?) AS vr ON vr.project_flock_kandangs_id = pfk.id", validRecordings). - Where("pfk.closed_at IS NULL") + Where("pf.category = ?", utils.ProjectFlockCategoryLaying). + Where("(pfk.closed_at IS NULL OR ? BETWEEN pc.chick_in_date AND pfk.closed_at)", start) query = applyLocationFilters(query, areaIDs, locationIDs, kandangIDs) @@ -164,7 +175,7 @@ func (r *hppPerKandangRepository) GetFeedOvkDocCostByPeriod(ctx context.Context, Joins("LEFT JOIN flags AS f ON f.flagable_id = pi.product_id AND f.flagable_type = ?", entity.FlagableTypeProduct). Joins("LEFT JOIN flags AS tf ON tf.flagable_id = std.product_id AND tf.flagable_type = ?", entity.FlagableTypeProduct). Where("r.project_flock_kandangs_id IN ?", projectFlockKandangIDs). - // Where("r.record_datetime >= ? AND r.record_datetime < ?", start, end). + Where("r.record_datetime < ?", end). Where("r.deleted_at IS NULL"). Where("(la.action IS NULL OR la.action != ?)", string(entity.ApprovalActionRejected)) @@ -349,7 +360,7 @@ func (r *hppPerKandangRepository) GetFeedOvkDocCostByPeriod(ctx context.Context, Joins("LEFT JOIN flags AS f ON f.flagable_id = pi.product_id AND f.flagable_type = ?", entity.FlagableTypeProduct). Where("f.name IN ?", []utils.FlagType{utils.FlagPakan, utils.FlagOVK}). Where("r.project_flock_kandangs_id IN (?)", projectFlockKandangIDs). - // Where("r.record_datetime >= ? AND r.record_datetime < ?", start, end). + Where("r.record_datetime < ?", end). Where("r.deleted_at IS NULL") // feedQuery = applyLocationFilters(feedQuery, areaIDs, locationIDs, kandangIDs) @@ -391,9 +402,11 @@ func (r *hppPerKandangRepository) GetEggProductionByProjectFlockKandangIDs(ctx c ) type eggRow struct { - ProjectFlockKandangID uint - EggProductionWeightKg float64 - EggProductionPieces float64 + ProjectFlockKandangID uint + EggProductionWeightKgRemaining float64 + EggProductionPiecesRemaining float64 + EggProductionTotalWeightKg float64 + EggProductionTotalPieces float64 } eggRows := make([]eggRow, 0) @@ -401,12 +414,14 @@ func (r *hppPerKandangRepository) GetEggProductionByProjectFlockKandangIDs(ctx c Table("recordings AS r"). Select(` r.project_flock_kandangs_id AS project_flock_kandang_id, - COALESCE(SUM(re.weight), 0) AS egg_production_weight_kg, - COALESCE(SUM(re.qty), 0) AS egg_production_pieces`). + COALESCE(SUM((re.total_qty - re.total_used) * re.weight / 1000), 0) AS egg_production_weight_kg_remaining, + COALESCE(SUM(re.total_qty - re.total_used), 0) AS egg_production_pieces_remaining, + COALESCE(SUM(re.weight / 1000), 0) AS egg_production_total_weight_kg, + COALESCE(SUM(re.total_qty), 0) AS egg_production_total_pieces`). Joins("LEFT JOIN (?) AS la ON la.approvable_id = r.id", latestApproval). Joins("LEFT JOIN recording_eggs AS re ON re.recording_id = r.id"). Where("r.project_flock_kandangs_id IN ?", projectFlockKandangIDs). - // Where("r.record_datetime >= ? AND r.record_datetime < ?", start, end). + Where("r.record_datetime < ?", end). Where("r.deleted_at IS NULL"). Where("(la.action IS NULL OR la.action != ?)", string(entity.ApprovalActionRejected)). Group("r.project_flock_kandangs_id") @@ -418,9 +433,11 @@ func (r *hppPerKandangRepository) GetEggProductionByProjectFlockKandangIDs(ctx c result := make(map[uint]HppPerKandangRow, len(eggRows)) for _, row := range eggRows { result[row.ProjectFlockKandangID] = HppPerKandangRow{ - ProjectFlockKandangID: row.ProjectFlockKandangID, - EggProductionWeightKg: row.EggProductionWeightKg, - EggProductionPieces: row.EggProductionPieces, + ProjectFlockKandangID: row.ProjectFlockKandangID, + EggProductionWeightKgRemaining: row.EggProductionWeightKgRemaining, + EggProductionPiecesRemaining: row.EggProductionPiecesRemaining, + EggProductionTotalWeightKg: row.EggProductionTotalWeightKg, + EggProductionTotalPieces: row.EggProductionTotalPieces, } } @@ -435,7 +452,7 @@ func applyLocationFilters(query *gorm.DB, areaIDs, locationIDs, kandangIDs []int query = query.Where("k.location_id IN ?", locationIDs) } if len(kandangIDs) > 0 { - query = query.Where("k.id IN ?", kandangIDs) + query = query.Where("pfk.id IN ?", kandangIDs) } return query } diff --git a/internal/modules/repports/services/repport.service.go b/internal/modules/repports/services/repport.service.go index 2e07e212..9c0c600f 100644 --- a/internal/modules/repports/services/repport.service.go +++ b/internal/modules/repports/services/repport.service.go @@ -1370,8 +1370,10 @@ func (s *repportService) GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangRes } for pfkID, egg := range eggMap { if rowIdx, ok := pfkIndex[pfkID]; ok { - repoRows[rowIdx].EggProductionWeightKg = egg.EggProductionWeightKg - repoRows[rowIdx].EggProductionPieces = egg.EggProductionPieces + repoRows[rowIdx].EggProductionWeightKgRemaining = egg.EggProductionWeightKgRemaining + repoRows[rowIdx].EggProductionPiecesRemaining = egg.EggProductionPiecesRemaining + repoRows[rowIdx].EggProductionTotalWeightKg = egg.EggProductionTotalWeightKg + repoRows[rowIdx].EggProductionTotalPieces = egg.EggProductionTotalPieces } } } @@ -1442,12 +1444,12 @@ func (s *repportService) GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangRes dataRows := make([]dto.HppPerKandangRowDTO, 0, len(repoRows)) perRangeMap := make(map[weightRangeKey]*weightRangeAggregate) var totalBirds int64 - var totalWeight float64 + // var totalWeight float64 var totalEggPieces int64 var totalEggKg float64 - var totalRemainingValueRp int64 + // var totalRemainingValueRp int64 var totalEggValueRp int64 - var totalHppSum float64 + // var totalHppSum float64 var totalHppCount int var totalDocPriceSum float64 var totalDocPriceCount int @@ -1461,26 +1463,34 @@ func (s *repportService) GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangRes continue } - birdsFloat := row.RemainingChickenBirds - if math.IsNaN(birdsFloat) || math.IsInf(birdsFloat, 0) { - birdsFloat = 0 + // birdsFloat := row.RemainingChickenBirds + // if math.IsNaN(birdsFloat) || math.IsInf(birdsFloat, 0) { + // birdsFloat = 0 + // } + // weightFloat := row.RemainingChickenWeight + // if math.IsNaN(weightFloat) || math.IsInf(weightFloat, 0) { + // weightFloat = 0 + // } + eggPiecesFloatRemaining := row.EggProductionPiecesRemaining + if math.IsNaN(eggPiecesFloatRemaining) || math.IsInf(eggPiecesFloatRemaining, 0) { + eggPiecesFloatRemaining = 0 } - weightFloat := row.RemainingChickenWeight - if math.IsNaN(weightFloat) || math.IsInf(weightFloat, 0) { - weightFloat = 0 + eggTotalPiecesFloat := row.EggProductionTotalPieces + if math.IsNaN(eggTotalPiecesFloat) || math.IsInf(eggTotalPiecesFloat, 0) { + eggTotalPiecesFloat = 0 } - eggPiecesFloat := row.EggProductionPieces - if math.IsNaN(eggPiecesFloat) || math.IsInf(eggPiecesFloat, 0) { - eggPiecesFloat = 0 + eggRemainingWeightFloatRemaining := row.EggProductionWeightKgRemaining + if math.IsNaN(eggRemainingWeightFloatRemaining) || math.IsInf(eggRemainingWeightFloatRemaining, 0) { + eggRemainingWeightFloatRemaining = 0 } - eggWeightFloat := row.EggProductionWeightKg + eggWeightFloat := row.EggProductionTotalWeightKg if math.IsNaN(eggWeightFloat) || math.IsInf(eggWeightFloat, 0) { eggWeightFloat = 0 } avgWeight := 0.0 - if eggPiecesFloat > 0 { - avgWeight = eggWeightFloat / eggPiecesFloat + if eggTotalPiecesFloat > 0 { + avgWeight = eggWeightFloat / eggTotalPiecesFloat } if params.WeightMin != nil && avgWeight < *params.WeightMin { continue @@ -1496,21 +1506,21 @@ func (s *repportService) GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangRes weightMax := weightMin + 0.09 rangeKey := weightRangeKey{Min: weightMin, Max: weightMax} - rowBirds := int64(math.Round(birdsFloat)) + // rowBirds := int64(math.Round(birdsFloat)) costEntry := costMap[row.KandangID] totalCost := costEntry.FeedCost + costEntry.OvkCost + costEntry.DocCost + costEntry.BudgetCost + costEntry.ExpenseCost - hppRp := 0.0 - if weightFloat > 0 { - hppRp = totalCost / weightFloat - } + // hppRp := 0.0 + // if weightFloat > 0 { + // hppRp = totalCost / weightFloat + // } eggHpp := 0.0 if eggWeightFloat > 0 { - eggHpp = totalCost / eggWeightFloat + eggHpp = (totalCost / eggWeightFloat) / 1000 } - rowEggPieces := int64(math.Round(eggPiecesFloat)) - rowEggValue := int64(eggHpp * eggWeightFloat) - rowRemainingValue := int64(hppRp * weightFloat) + rowEggPieces := int64(math.Round(eggPiecesFloatRemaining)) + rowEggValue := int64(eggHpp * eggRemainingWeightFloatRemaining) + // rowRemainingValue := int64(hppRp * weightFloat) avgDocPrice := int64(0) if costEntry.DocQty > 0 { avgDocPrice = int64(math.Round(costEntry.DocCost / costEntry.DocQty)) @@ -1540,27 +1550,29 @@ func (s *repportService) GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangRes // OvkCostRp: costEntry.OvkCost, DocSuppliers: docSupplierMap[row.KandangID], FeedSuppliers: feedSupplierMap[row.KandangID], - EggProductionPieces: rowEggPieces, - EggProductionKg: eggWeightFloat, - AverageDocPriceRp: avgDocPrice, + EggProductionPieces: int64(math.Round(eggPiecesFloatRemaining)), + EggProductionKg: eggRemainingWeightFloatRemaining, + // EggProductionTotalWeightKg: eggWeightFloat, + // EggProductionTotalPieces: int64(math.Round(eggTotalPiecesFloat)), + AverageDocPriceRp: avgDocPrice, // HppRp: hppRp, - EggHppRpPerKg: eggHpp, - RemainingValueRp: rowRemainingValue, - EggValueRp: rowEggValue, + EggHppRpPerKg: eggHpp, + // RemainingValueRp: rowRemainingValue, + EggValueRp: rowEggValue, }) - totalBirds += rowBirds - totalWeight += weightFloat + // totalBirds += rowBirds + // totalWeight += weightFloat totalEggPieces += rowEggPieces - totalEggKg += eggWeightFloat - totalRemainingValueRp += rowRemainingValue + totalEggKg += eggRemainingWeightFloatRemaining + // totalRemainingValueRp += rowRemainingValue totalEggValueRp += rowEggValue totalAvgWeightSum += avgWeight totalAvgWeightCount++ - if weightFloat > 0 { - totalHppSum += hppRp - totalHppCount++ - } + // if weightFloat > 0 { + // totalHppSum += hppRp + // totalHppCount++ + // } if avgDocPrice > 0 { totalDocPriceSum += float64(avgDocPrice) totalDocPriceCount++ @@ -1587,8 +1599,8 @@ func (s *repportService) GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangRes } rangeSummary := rangeAgg.Summary - rangeAgg.RemainingBirds += rowBirds - rangeAgg.RemainingWeightKg += row.RemainingChickenWeight + // rangeAgg.RemainingBirds += rowBirds + // rangeAgg.RemainingWeightKg += row.RemainingChickenWeight rangeAgg.AvgWeightSum += avgWeight rangeAgg.AvgWeightCount++ for _, supplier := range feedSupplierMap[row.KandangID] { @@ -1602,8 +1614,8 @@ func (s *repportService) GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangRes } } rangeSummary.EggProductionPieces += rowEggPieces - rangeSummary.EggProductionKg += eggWeightFloat - rangeSummary.RemainingValueRp += rowRemainingValue + rangeSummary.EggProductionKg += eggRemainingWeightFloatRemaining + // rangeSummary.RemainingValueRp += rowRemainingValue rangeSummary.EggValueRp += rowEggValue if eggWeightFloat > 0 { rangeAgg.EggHppSum += eggHpp @@ -1750,6 +1762,9 @@ func (s *repportService) parseHppPerKandangQuery(ctx *fiber.Ctx) (*validation.Hp if err != nil { return nil, dto.HppPerKandangFiltersDTO{}, fiber.NewError(fiber.StatusBadRequest, err.Error()) } + if weightMin != nil && weightMax != nil && *weightMin > *weightMax { + return nil, dto.HppPerKandangFiltersDTO{}, fiber.NewError(fiber.StatusBadRequest, "weight_min must be less than or equal to weight_max") + } params := &validation.HppPerKandangQuery{ Page: page, From 3052497fc0538c17123c599d32bb6ad2471b07b0 Mon Sep 17 00:00:00 2001 From: giovanni Date: Mon, 19 Jan 2026 17:05:43 +0700 Subject: [PATCH 73/76] adjust grouping by project flock kandang --- .../modules/repports/dto/repportHpp.dto.go | 1 + .../hpp_per_kandang.repository.go | 112 +++++++++--------- .../repports/services/repport.service.go | 29 +++-- 3 files changed, 74 insertions(+), 68 deletions(-) diff --git a/internal/modules/repports/dto/repportHpp.dto.go b/internal/modules/repports/dto/repportHpp.dto.go index 15b6d51b..dc0b81d4 100644 --- a/internal/modules/repports/dto/repportHpp.dto.go +++ b/internal/modules/repports/dto/repportHpp.dto.go @@ -27,6 +27,7 @@ type HppPerKandangResponseData struct { type HppPerKandangRowDTO struct { ID int `json:"id"` Kandang HppPerKandangRowKandangDTO `json:"kandang"` + NameWithPeriode string `json:"name_with_periode"` WeightRange HppPerKandangWeightRangeDTO `json:"weight_range"` AvgWeightKg float64 `json:"avg_weight_kg"` EggProductionPieces int64 `json:"egg_production_pieces"` diff --git a/internal/modules/repports/repositories/hpp_per_kandang.repository.go b/internal/modules/repports/repositories/hpp_per_kandang.repository.go index 37a37d45..1135efbf 100644 --- a/internal/modules/repports/repositories/hpp_per_kandang.repository.go +++ b/internal/modules/repports/repositories/hpp_per_kandang.repository.go @@ -12,6 +12,7 @@ import ( type HppPerKandangRow struct { ProjectFlockKandangID uint + ProjectFlockPeriod int KandangID uint KandangName string KandangStatus string @@ -29,21 +30,21 @@ type HppPerKandangRow struct { } type HppPerKandangCostRow struct { - KandangID uint - FeedCost float64 - OvkCost float64 - DocCost float64 - DocQty float64 - BudgetCost float64 - ExpenseCost float64 + ProjectFlockKandangID uint + FeedCost float64 + OvkCost float64 + DocCost float64 + DocQty float64 + BudgetCost float64 + ExpenseCost float64 } type HppPerKandangSupplierRow struct { - KandangID uint - SupplierID uint - SupplierName string - SupplierAlias string - Category string + ProjectFlockKandangID uint + SupplierID uint + SupplierName string + SupplierAlias string + Category string } type HppPerKandangRepository interface { @@ -88,6 +89,7 @@ func (r *hppPerKandangRepository) GetRowsByPeriod(ctx context.Context, start, en Table("project_flocks AS pf"). Select(` pfk.id AS project_flock_kandang_id, + pfk.period AS project_flock_period, k.id AS kandang_id, k.name AS kandang_name, k.status AS kandang_status, @@ -118,8 +120,8 @@ func (r *hppPerKandangRepository) GetRowsByPeriod(ctx context.Context, start, en query = applyLocationFilters(query, areaIDs, locationIDs, kandangIDs) - query = query.Group("pfk.id, k.id, k.name, k.status, loc.id, loc.name, pic.id, pic.name"). - Order("k.id ASC") + query = query.Group("pfk.id, pfk.period, k.id, k.name, k.status, loc.id, loc.name, pic.id, pic.name"). + Order("pfk.id ASC") if err := query.Scan(&rows).Error; err != nil { return nil, err @@ -150,7 +152,7 @@ func (r *hppPerKandangRepository) GetFeedOvkDocCostByPeriod(ctx context.Context, query := r.db.WithContext(ctx). Table("recordings AS r"). Select(` - k.id AS kandang_id, + pfk.id AS project_flock_kandang_id, COALESCE(SUM(CASE WHEN f.name = ? THEN COALESCE(sa.qty, 0) * COALESCE(pi.price, 0) WHEN sa.stockable_type = ? AND tf.name = ? THEN COALESCE(std.total_qty, 0) * COALESCE(tpi.price, 0) @@ -179,25 +181,25 @@ func (r *hppPerKandangRepository) GetFeedOvkDocCostByPeriod(ctx context.Context, Where("r.deleted_at IS NULL"). Where("(la.action IS NULL OR la.action != ?)", string(entity.ApprovalActionRejected)) - query = query.Group("k.id").Order("k.id ASC") + query = query.Group("pfk.id").Order("pfk.id ASC") if err := query.Scan(&rows).Error; err != nil { return nil, nil, err } docRows := make([]struct { - KandangID uint - DocCost float64 - DocQty float64 - SupplierID *uint - SupplierName *string - SupplierAlias *string + ProjectFlockKandangID uint + DocCost float64 + DocQty float64 + SupplierID *uint + SupplierName *string + SupplierAlias *string }, 0) docQuery := r.db.WithContext(ctx). Table("project_chickins AS pc"). Select(` - pfk.kandang_id AS kandang_id, + pfk.id AS project_flock_kandang_id, COALESCE(SUM(pc.usage_qty * COALESCE(pi.price, 0)), 0) AS doc_cost, COALESCE(SUM(pc.usage_qty), 0) AS doc_qty, s.id AS supplier_id, @@ -210,7 +212,7 @@ func (r *hppPerKandangRepository) GetFeedOvkDocCostByPeriod(ctx context.Context, Joins("LEFT JOIN purchases AS pur ON pur.id = pi.purchase_id"). Joins("LEFT JOIN suppliers AS s ON s.id = pur.supplier_id"). Where("pc.project_flock_kandang_id IN ?", projectFlockKandangIDs). - Group("pfk.kandang_id, s.id, s.name, s.alias") + Group("pfk.id, s.id, s.name, s.alias") if err := docQuery.Scan(&docRows).Error; err != nil { return nil, nil, err @@ -219,28 +221,28 @@ func (r *hppPerKandangRepository) GetFeedOvkDocCostByPeriod(ctx context.Context, costMap := make(map[uint]*HppPerKandangCostRow, len(rows)) for i := range rows { row := rows[i] - costMap[row.KandangID] = &rows[i] + costMap[row.ProjectFlockKandangID] = &rows[i] } docSuppliers := make([]HppPerKandangSupplierRow, 0) docSeen := make(map[uint]map[uint]bool) for _, doc := range docRows { - entry, ok := costMap[doc.KandangID] + entry, ok := costMap[doc.ProjectFlockKandangID] if !ok { rows = append(rows, HppPerKandangCostRow{ - KandangID: doc.KandangID, + ProjectFlockKandangID: doc.ProjectFlockKandangID, }) entry = &rows[len(rows)-1] - costMap[doc.KandangID] = entry + costMap[doc.ProjectFlockKandangID] = entry } entry.DocCost += doc.DocCost entry.DocQty += doc.DocQty if doc.SupplierID != nil { - if docSeen[doc.KandangID] == nil { - docSeen[doc.KandangID] = make(map[uint]bool) + if docSeen[doc.ProjectFlockKandangID] == nil { + docSeen[doc.ProjectFlockKandangID] = make(map[uint]bool) } - if !docSeen[doc.KandangID][*doc.SupplierID] { - docSeen[doc.KandangID][*doc.SupplierID] = true + if !docSeen[doc.ProjectFlockKandangID][*doc.SupplierID] { + docSeen[doc.ProjectFlockKandangID][*doc.SupplierID] = true supplierName := "" if doc.SupplierName != nil { supplierName = *doc.SupplierName @@ -250,19 +252,19 @@ func (r *hppPerKandangRepository) GetFeedOvkDocCostByPeriod(ctx context.Context, supplierAlias = *doc.SupplierAlias } docSuppliers = append(docSuppliers, HppPerKandangSupplierRow{ - KandangID: doc.KandangID, - SupplierID: *doc.SupplierID, - SupplierName: supplierName, - SupplierAlias: supplierAlias, - Category: "DOC", + ProjectFlockKandangID: doc.ProjectFlockKandangID, + SupplierID: *doc.SupplierID, + SupplierName: supplierName, + SupplierAlias: supplierAlias, + Category: "DOC", }) } } } budgetRows := make([]struct { - KandangID uint - BudgetCost float64 + ProjectFlockKandangID uint + BudgetCost float64 }, 0) pfkUsageSub := r.db. @@ -283,7 +285,7 @@ func (r *hppPerKandangRepository) GetFeedOvkDocCostByPeriod(ctx context.Context, budgetQuery := r.db.WithContext(ctx). Table("project_flock_kandangs AS pfk"). Select(` - k.id AS kandang_id, + pfk.id AS project_flock_kandang_id, COALESCE(SUM((pb.qty * pb.price) * COALESCE(k_usage.kandang_usage_qty, 0) / NULLIF(p_usage.project_usage_qty, 0)), 0) AS budget_cost`). Joins("JOIN kandangs AS k ON k.id = pfk.kandang_id"). Joins("JOIN locations AS loc ON loc.id = k.location_id"). @@ -291,7 +293,7 @@ func (r *hppPerKandangRepository) GetFeedOvkDocCostByPeriod(ctx context.Context, Joins("LEFT JOIN (?) AS k_usage ON k_usage.project_flock_kandang_id = pfk.id", pfkUsageSub). Joins("LEFT JOIN (?) AS p_usage ON p_usage.project_flock_id = pfk.project_flock_id", projectUsageSub). Where("pfk.id IN (?)", projectFlockKandangIDs). - Group("k.id") + Group("pfk.id") // budgetQuery = applyLocationFilters(budgetQuery, areaIDs, locationIDs, kandangIDs) if err := budgetQuery.Scan(&budgetRows).Error; err != nil { @@ -299,33 +301,33 @@ func (r *hppPerKandangRepository) GetFeedOvkDocCostByPeriod(ctx context.Context, } for _, budget := range budgetRows { - entry, ok := costMap[budget.KandangID] + entry, ok := costMap[budget.ProjectFlockKandangID] if !ok { rows = append(rows, HppPerKandangCostRow{ - KandangID: budget.KandangID, + ProjectFlockKandangID: budget.ProjectFlockKandangID, }) entry = &rows[len(rows)-1] - costMap[budget.KandangID] = entry + costMap[budget.ProjectFlockKandangID] = entry } entry.BudgetCost += budget.BudgetCost } expenseRows := make([]struct { - KandangID uint - ExpenseCost float64 + ProjectFlockKandangID uint + ExpenseCost float64 }, 0) expenseQuery := r.db.WithContext(ctx). Table("project_flock_kandangs AS pfk"). Select(` - k.id AS kandang_id, + pfk.id AS project_flock_kandang_id, COALESCE(SUM(er.qty * er.price), 0) AS expense_cost`). Joins("JOIN kandangs AS k ON k.id = pfk.kandang_id"). Joins("JOIN locations AS loc ON loc.id = k.location_id"). Joins("JOIN expense_nonstocks AS en ON en.project_flock_kandang_id = pfk.id"). Joins("JOIN expense_realizations AS er ON er.expense_nonstock_id = en.id"). Where("pfk.id IN (?)", projectFlockKandangIDs). - Group("k.id") + Group("pfk.id") // expenseQuery = applyLocationFilters(expenseQuery, areaIDs, locationIDs, kandangIDs) if err := expenseQuery.Scan(&expenseRows).Error; err != nil { @@ -333,13 +335,13 @@ func (r *hppPerKandangRepository) GetFeedOvkDocCostByPeriod(ctx context.Context, } for _, exp := range expenseRows { - entry, ok := costMap[exp.KandangID] + entry, ok := costMap[exp.ProjectFlockKandangID] if !ok { rows = append(rows, HppPerKandangCostRow{ - KandangID: exp.KandangID, + ProjectFlockKandangID: exp.ProjectFlockKandangID, }) entry = &rows[len(rows)-1] - costMap[exp.KandangID] = entry + costMap[exp.ProjectFlockKandangID] = entry } entry.ExpenseCost += exp.ExpenseCost } @@ -348,7 +350,7 @@ func (r *hppPerKandangRepository) GetFeedOvkDocCostByPeriod(ctx context.Context, feedQuery := r.db.WithContext(ctx). Table("recordings AS r"). - Select("DISTINCT k.id AS kandang_id, s.id AS supplier_id, s.name AS supplier_name, s.alias AS supplier_alias"). + Select("DISTINCT pfk.id AS project_flock_kandang_id, s.id AS supplier_id, s.name AS supplier_name, s.alias AS supplier_alias"). Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = r.project_flock_kandangs_id"). Joins("JOIN kandangs AS k ON k.id = pfk.kandang_id"). Joins("JOIN locations AS loc ON loc.id = k.location_id"). @@ -369,11 +371,11 @@ func (r *hppPerKandangRepository) GetFeedOvkDocCostByPeriod(ctx context.Context, } for i := range feedSuppliers { - if _, exists := costMap[feedSuppliers[i].KandangID]; !exists { + if _, exists := costMap[feedSuppliers[i].ProjectFlockKandangID]; !exists { rows = append(rows, HppPerKandangCostRow{ - KandangID: feedSuppliers[i].KandangID, + ProjectFlockKandangID: feedSuppliers[i].ProjectFlockKandangID, }) - costMap[feedSuppliers[i].KandangID] = &rows[len(rows)-1] + costMap[feedSuppliers[i].ProjectFlockKandangID] = &rows[len(rows)-1] } feedSuppliers[i].Category = "FEED" } diff --git a/internal/modules/repports/services/repport.service.go b/internal/modules/repports/services/repport.service.go index 9c0c600f..452d46b6 100644 --- a/internal/modules/repports/services/repport.service.go +++ b/internal/modules/repports/services/repport.service.go @@ -1380,7 +1380,7 @@ func (s *repportService) GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangRes costMap := make(map[uint]HppCostAggregate, len(costRows)) for _, row := range costRows { - costMap[row.KandangID] = HppCostAggregate{ + costMap[row.ProjectFlockKandangID] = HppCostAggregate{ FeedCost: row.FeedCost, OvkCost: row.OvkCost, DocCost: row.DocCost, @@ -1409,15 +1409,15 @@ func (s *repportService) GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangRes category = "DOC" } - if seen[sup.KandangID] == nil { - seen[sup.KandangID] = make(map[uint]bool) + if seen[sup.ProjectFlockKandangID] == nil { + seen[sup.ProjectFlockKandangID] = make(map[uint]bool) } - if seen[sup.KandangID][sup.SupplierID] { + if seen[sup.ProjectFlockKandangID][sup.SupplierID] { continue } - seen[sup.KandangID][sup.SupplierID] = true + seen[sup.ProjectFlockKandangID][sup.SupplierID] = true - targetMap[sup.KandangID] = append(targetMap[sup.KandangID], dto.HppPerKandangSupplierDTO{ + targetMap[sup.ProjectFlockKandangID] = append(targetMap[sup.ProjectFlockKandangID], dto.HppPerKandangSupplierDTO{ ID: int64(sup.SupplierID), Name: sup.SupplierName, Alias: sup.SupplierAlias, @@ -1507,7 +1507,7 @@ func (s *repportService) GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangRes rangeKey := weightRangeKey{Min: weightMin, Max: weightMax} // rowBirds := int64(math.Round(birdsFloat)) - costEntry := costMap[row.KandangID] + costEntry := costMap[row.ProjectFlockKandangID] totalCost := costEntry.FeedCost + costEntry.OvkCost + costEntry.DocCost + costEntry.BudgetCost + costEntry.ExpenseCost // hppRp := 0.0 // if weightFloat > 0 { @@ -1526,8 +1526,10 @@ func (s *repportService) GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangRes avgDocPrice = int64(math.Round(costEntry.DocCost / costEntry.DocQty)) } + nameWithPeriod := fmt.Sprintf("%s Period %d", row.KandangName, row.ProjectFlockPeriod) + dataRows = append(dataRows, dto.HppPerKandangRowDTO{ - ID: int(row.KandangID), + ID: int(row.ProjectFlockKandangID), Kandang: dto.HppPerKandangRowKandangDTO{ ID: int64(row.KandangID), Name: row.KandangName, @@ -1545,11 +1547,12 @@ func (s *repportService) GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangRes WeightMin: weightMin, WeightMax: weightMax, }, - AvgWeightKg: avgWeight, + AvgWeightKg: avgWeight, + NameWithPeriode: nameWithPeriod, // FeedCostRp: costEntry.FeedCost, // OvkCostRp: costEntry.OvkCost, - DocSuppliers: docSupplierMap[row.KandangID], - FeedSuppliers: feedSupplierMap[row.KandangID], + DocSuppliers: docSupplierMap[row.ProjectFlockKandangID], + FeedSuppliers: feedSupplierMap[row.ProjectFlockKandangID], EggProductionPieces: int64(math.Round(eggPiecesFloatRemaining)), EggProductionKg: eggRemainingWeightFloatRemaining, // EggProductionTotalWeightKg: eggWeightFloat, @@ -1603,12 +1606,12 @@ func (s *repportService) GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangRes // rangeAgg.RemainingWeightKg += row.RemainingChickenWeight rangeAgg.AvgWeightSum += avgWeight rangeAgg.AvgWeightCount++ - for _, supplier := range feedSupplierMap[row.KandangID] { + for _, supplier := range feedSupplierMap[row.ProjectFlockKandangID] { if _, ok := rangeAgg.FeedSuppliers[supplier.ID]; !ok { rangeAgg.FeedSuppliers[supplier.ID] = supplier } } - for _, supplier := range docSupplierMap[row.KandangID] { + for _, supplier := range docSupplierMap[row.ProjectFlockKandangID] { if _, ok := rangeAgg.DocSuppliers[supplier.ID]; !ok { rangeAgg.DocSuppliers[supplier.ID] = supplier } From b240478ed5e19504cb5663cec8c29f6ba5b6de7b Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Mon, 19 Jan 2026 17:44:10 +0700 Subject: [PATCH 74/76] feat[BE]: Add notes field to Update validation and update approval logic in expense services --- .../modules/expenses/services/expense.service.go | 16 ++++++++++++++-- .../expenses/validations/expense.validation.go | 1 + .../services/transfer_expense_bridge.go | 15 ++++++++++++--- .../services/transfer_laying.service.go | 1 - .../modules/purchases/services/expense_bridge.go | 15 ++++++++++++--- 5 files changed, 39 insertions(+), 9 deletions(-) diff --git a/internal/modules/expenses/services/expense.service.go b/internal/modules/expenses/services/expense.service.go index 3bf2db55..8b42fbdf 100644 --- a/internal/modules/expenses/services/expense.service.go +++ b/internal/modules/expenses/services/expense.service.go @@ -396,6 +396,10 @@ func (s expenseService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) updateBody["supplier_id"] = *req.SupplierID } + if req.Notes != nil { + updateBody["notes"] = *req.Notes + } + if req.LocationID != nil { locationID := uint(*req.LocationID) updateBody["location_id"] = locationID @@ -568,20 +572,28 @@ func (s expenseService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) if err != nil { return fiber.NewError(fiber.StatusUnauthorized, "Failed to get actor ID from context") } - if *latestApproval.Action != entity.ApprovalActionUpdated { + + if *latestApproval.Action != entity.ApprovalActionUpdated && latestApproval.StepNumber > uint16(utils.ExpenseStepPengajuan) { approvalAction := entity.ApprovalActionUpdated + previousStep := approvalutils.ApprovalStep(latestApproval.StepNumber) - 1 + + if previousStep < utils.ExpenseStepPengajuan { + previousStep = utils.ExpenseStepPengajuan + } + if _, err := approvalSvcTx.CreateApproval( c.Context(), utils.ApprovalWorkflowExpense, id, - utils.ExpenseStepPengajuan, + previousStep, &approvalAction, actorID, nil); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to reset approval step") } + } if s.DocumentSvc != nil && len(req.Documents) > 0 { diff --git a/internal/modules/expenses/validations/expense.validation.go b/internal/modules/expenses/validations/expense.validation.go index 4501b87d..3fb9ccd5 100644 --- a/internal/modules/expenses/validations/expense.validation.go +++ b/internal/modules/expenses/validations/expense.validation.go @@ -31,6 +31,7 @@ type Update struct { Category *string `form:"category" json:"category" validate:"omitempty,oneof=BOP NON-BOP"` SupplierID *uint64 `form:"supplier_id" json:"supplier_id" validate:"omitempty,gt=0"` LocationID *uint64 `form:"location_id" json:"location_id" validate:"omitempty,gt=0"` + Notes *string `form:"notes" json:"notes" validate:"omitempty,max=500"` Documents []*multipart.FileHeader `form:"documents" json:"documents" validate:"omitempty,dive"` ExpenseNonstocks *[]ExpenseNonstock `form:"expense_nonstocks" json:"expense_nonstocks" validate:"omitempty,min=1,dive"` } diff --git a/internal/modules/inventory/transfers/services/transfer_expense_bridge.go b/internal/modules/inventory/transfers/services/transfer_expense_bridge.go index c4f28354..7b69c22a 100644 --- a/internal/modules/inventory/transfers/services/transfer_expense_bridge.go +++ b/internal/modules/inventory/transfers/services/transfer_expense_bridge.go @@ -140,12 +140,21 @@ func (b *transferExpenseBridge) markExpensesUpdated(ctx context.Context, expense if actorID == 0 { actorID = 1 } - svc := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(b.db)) - action := entity.ApprovalActionUpdated + approvalRepo := commonRepo.NewApprovalRepository(b.db) + svc := commonSvc.NewApprovalService(approvalRepo) + action := entity.ApprovalActionCreated + for id := range expenseIDs { - if _, err := svc.CreateApproval(ctx, utils.ApprovalWorkflowExpense, uint(id), utils.ExpenseStepFinance, &action, actorID, nil); err != nil { + latestApproval, err := approvalRepo.LatestByTarget(ctx, string(utils.ApprovalWorkflowExpense), uint(id), nil) + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { return err } + + if latestApproval == nil { + if _, err := svc.CreateApproval(ctx, utils.ApprovalWorkflowExpense, uint(id), utils.ExpenseStepFinance, &action, actorID, nil); err != nil { + return err + } + } } return nil } diff --git a/internal/modules/production/transfer_layings/services/transfer_laying.service.go b/internal/modules/production/transfer_layings/services/transfer_laying.service.go index 3fe0b0b7..e64b9cc2 100644 --- a/internal/modules/production/transfer_layings/services/transfer_laying.service.go +++ b/internal/modules/production/transfer_layings/services/transfer_laying.service.go @@ -133,7 +133,6 @@ func (s transferLayingService) GetAll(c *fiber.Ctx, params *validation.Query) ([ db = db.Order("created_at DESC") - // Apply relations for eager loading db = s.withRelations(db) return db diff --git a/internal/modules/purchases/services/expense_bridge.go b/internal/modules/purchases/services/expense_bridge.go index 56097a90..1210b3a1 100644 --- a/internal/modules/purchases/services/expense_bridge.go +++ b/internal/modules/purchases/services/expense_bridge.go @@ -167,12 +167,21 @@ func (b *expenseBridge) markExpensesUpdated(ctx context.Context, expenseIDs map[ if actorID == 0 { actorID = 1 } - svc := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(b.db)) - action := entity.ApprovalActionUpdated + approvalRepo := commonRepo.NewApprovalRepository(b.db) + svc := commonSvc.NewApprovalService(approvalRepo) + action := entity.ApprovalActionCreated + for id := range expenseIDs { - if _, err := svc.CreateApproval(ctx, utils.ApprovalWorkflowExpense, uint(id), utils.ExpenseStepFinance, &action, actorID, nil); err != nil { + latestApproval, err := approvalRepo.LatestByTarget(ctx, string(utils.ApprovalWorkflowExpense), uint(id), nil) + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { return err } + + if latestApproval == nil { + if _, err := svc.CreateApproval(ctx, utils.ApprovalWorkflowExpense, uint(id), utils.ExpenseStepFinance, &action, actorID, nil); err != nil { + return err + } + } } return nil } From bc771660be77fa128a5cf1af231f589477be5715 Mon Sep 17 00:00:00 2001 From: giovanni Date: Tue, 20 Jan 2026 10:03:57 +0700 Subject: [PATCH 75/76] adjust closing tap sapronak; add api summart total kuantitas per category and uom --- .../controllers/closing.controller.go | 54 +++++++- .../closings/dto/closingSapronak.dto.go | 11 ++ .../repositories/closing.repository.go | 120 +++++++++++++++++- .../closings/services/closing.service.go | 71 ++++++++++- .../validations/closing.validation.go | 1 + .../repositories/stock_transfer.repository.go | 2 +- 6 files changed, 245 insertions(+), 14 deletions(-) diff --git a/internal/modules/closings/controllers/closing.controller.go b/internal/modules/closings/controllers/closing.controller.go index ed3cfcbc..a43687ac 100644 --- a/internal/modules/closings/controllers/closing.controller.go +++ b/internal/modules/closings/controllers/closing.controller.go @@ -236,9 +236,8 @@ func (u *ClosingController) GetClosingSapronak(c *fiber.Ctx) error { } query := &validation.ClosingSapronakQuery{ - Type: strings.ToLower(c.Query("type")), - Page: c.QueryInt("page", 1), - Limit: c.QueryInt("limit", 10), + Type: strings.ToLower(c.Query("type")), + Search: c.Query("search"), } if raw := c.Query("kandang_id"); raw != "" { kandangInt, convErr := strconv.Atoi(raw) @@ -249,10 +248,6 @@ func (u *ClosingController) GetClosingSapronak(c *fiber.Ctx) error { query.KandangID = &kandangUint } - if query.Page < 1 || query.Limit < 1 { - return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0") - } - if query.Type != validation.SapronakTypeIncoming && query.Type != validation.SapronakTypeOutgoing { return fiber.NewError(fiber.StatusBadRequest, "type must be either incoming or outgoing") } @@ -277,6 +272,51 @@ func (u *ClosingController) GetClosingSapronak(c *fiber.Ctx) error { }) } +func (u *ClosingController) GetClosingSapronakSummary(c *fiber.Ctx) error { + param := c.Params("projectFlockId") + + id, err := strconv.Atoi(param) + if err != nil || id <= 0 { + return fiber.NewError(fiber.StatusBadRequest, "Invalid projectFlockId") + } + + query := &validation.ClosingSapronakQuery{ + Type: strings.ToLower(c.Query("type")), + Page: c.QueryInt("page", 1), + Limit: c.QueryInt("limit", 10), + Search: c.Query("search"), + } + if raw := c.Query("kandang_id"); raw != "" { + kandangInt, convErr := strconv.Atoi(raw) + if convErr != nil || kandangInt <= 0 { + return fiber.NewError(fiber.StatusBadRequest, "Invalid kandang_id") + } + kandangUint := uint(kandangInt) + query.KandangID = &kandangUint + } + + if query.Page < 1 || query.Limit < 1 { + return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0") + } + + if query.Type != validation.SapronakTypeIncoming && query.Type != validation.SapronakTypeOutgoing { + return fiber.NewError(fiber.StatusBadRequest, "type must be either incoming or outgoing") + } + + result, err := u.ClosingService.GetClosingSapronakSummary(c, uint(id), query) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Retrieved closing report (sapronak summary) successfully", + Data: result, + }) +} + func (u *ClosingController) GetSapronakByProject(c *fiber.Ctx) error { param := c.Params("project_flock_id") flag := c.Query("flag", "") diff --git a/internal/modules/closings/dto/closingSapronak.dto.go b/internal/modules/closings/dto/closingSapronak.dto.go index 768c727e..6d59294e 100644 --- a/internal/modules/closings/dto/closingSapronak.dto.go +++ b/internal/modules/closings/dto/closingSapronak.dto.go @@ -114,6 +114,17 @@ type ClosingSapronakDTO struct { OutgoingSapronak []ClosingSapronakItemDTO `json:"outgoing_sapronak"` } +type ClosingSapronakSummaryItemDTO struct { + Category string `json:"category"` + TotalQty int64 `json:"total_qty"` + Uom UomSummaryDTO `json:"uom"` +} + +type UomSummaryDTO struct { + ID uint `json:"id"` + Name string `json:"name"` +} + // === Mapper Functions for Aggregated Sapronak Response === func ToSapronakProjectAggregatedFromReports(reports []SapronakReportDTO, flag string) SapronakProjectAggregatedDTO { diff --git a/internal/modules/closings/repositories/closing.repository.go b/internal/modules/closings/repositories/closing.repository.go index 582a1207..7b1fb6cf 100644 --- a/internal/modules/closings/repositories/closing.repository.go +++ b/internal/modules/closings/repositories/closing.repository.go @@ -17,6 +17,7 @@ import ( type ClosingRepository interface { repository.BaseRepository[entity.ProjectFlock] GetSapronak(ctx context.Context, params SapronakQueryParams) ([]SapronakRow, int64, error) + GetSapronakSummary(ctx context.Context, params SapronakQueryParams) ([]SapronakSummaryRow, error) SumFeedPurchaseAndUsedByProjectFlockKandangIDs(ctx context.Context, projectFlockKandangIDs []uint) (float64, float64, error) SumProjectChickinUsageByProjectFlockKandangIDs(ctx context.Context, projectFlockKandangIDs []uint) (float64, error) SumClaimCullingByProjectFlockKandangIDs(ctx context.Context, projectFlockKandangIDs []uint) (float64, error) @@ -59,10 +60,18 @@ type SapronakRow struct { DestinationWarehouse string `gorm:"column:destination_warehouse"` Destination string `gorm:"column:destination"` Quantity float64 `gorm:"column:quantity"` + UnitID uint `gorm:"column:unit_id"` Unit string `gorm:"column:unit"` Notes string `gorm:"column:notes"` } +type SapronakSummaryRow struct { + Category string `gorm:"column:category"` + TotalQty int64 `gorm:"column:total_qty"` + UomID uint `gorm:"column:uom_id"` + UomName string `gorm:"column:uom_name"` +} + type ExpeditionHPPRow struct { SupplierName string `gorm:"column:supplier_name"` TotalAmount float64 `gorm:"column:total_amount"` @@ -74,6 +83,7 @@ type SapronakQueryParams struct { ProjectFlockKandangIDs []uint Limit int Offset int + Search string } func (r *ClosingRepositoryImpl) GetSapronak(ctx context.Context, params SapronakQueryParams) ([]SapronakRow, int64, error) { @@ -109,14 +119,36 @@ func (r *ClosingRepositoryImpl) GetSapronak(ctx context.Context, params Sapronak unionSQL := strings.Join(unionParts, " UNION ALL ") + search := strings.TrimSpace(params.Search) + searchClause := "" + var searchArgs []any + if search != "" { + searchClause = ` + WHERE ( + reference_number ILIKE ? + OR product_name ILIKE ? + OR product_category ILIKE ? + OR source_warehouse ILIKE ? + OR destination_warehouse ILIKE ? + OR CAST(quantity AS TEXT) ILIKE ? + OR unit ILIKE ? + OR notes ILIKE ? + OR transaction_type ILIKE ? + )` + like := "%" + search + "%" + searchArgs = append(searchArgs, like, like, like, like, like, like, like, like, like) + } + var totalResults int64 - countSQL := fmt.Sprintf("SELECT COUNT(*) FROM (%s) AS combined", unionSQL) - if err := db.Raw(countSQL, args...).Scan(&totalResults).Error; err != nil { + countSQL := fmt.Sprintf("SELECT COUNT(*) FROM (%s) AS combined%s", unionSQL, searchClause) + countArgs := append(append([]any{}, args...), searchArgs...) + if err := db.Raw(countSQL, countArgs...).Scan(&totalResults).Error; err != nil { return nil, 0, err } - dataArgs := append(append([]any{}, args...), params.Limit, params.Offset) - dataSQL := fmt.Sprintf("SELECT * FROM (%s) AS combined ORDER BY sort_date ASC, id ASC LIMIT ? OFFSET ?", unionSQL) + dataArgs := append(append([]any{}, args...), searchArgs...) + dataArgs = append(dataArgs, params.Limit, params.Offset) + dataSQL := fmt.Sprintf("SELECT * FROM (%s) AS combined%s ORDER BY sort_date ASC, id ASC LIMIT ? OFFSET ?", unionSQL, searchClause) var rows []SapronakRow if err := db.Raw(dataSQL, dataArgs...).Scan(&rows).Error; err != nil { @@ -126,6 +158,79 @@ func (r *ClosingRepositoryImpl) GetSapronak(ctx context.Context, params Sapronak return rows, totalResults, nil } +func (r *ClosingRepositoryImpl) GetSapronakSummary(ctx context.Context, params SapronakQueryParams) ([]SapronakSummaryRow, error) { + db := r.DB().WithContext(ctx) + + var ( + unionParts []string + args []any + ) + + switch params.Type { + case validation.SapronakTypeIncoming: + if len(params.WarehouseIDs) == 0 { + return []SapronakSummaryRow{}, nil + } + unionParts = append(unionParts, sapronakIncomingPurchasesSQL, sapronakIncomingTransfersSQL) + args = append(args, params.WarehouseIDs, params.WarehouseIDs) + case validation.SapronakTypeOutgoing: + if len(params.WarehouseIDs) > 0 { + unionParts = append(unionParts, sapronakOutgoingTransfersSQL) + args = append(args, params.WarehouseIDs) + } + if len(params.ProjectFlockKandangIDs) > 0 { + unionParts = append(unionParts, sapronakOutgoingMarketingsSQL) + args = append(args, params.ProjectFlockKandangIDs) + } + if len(unionParts) == 0 { + return []SapronakSummaryRow{}, nil + } + default: + return nil, fmt.Errorf("invalid sapronak type: %s", params.Type) + } + + unionSQL := strings.Join(unionParts, " UNION ALL ") + + search := strings.TrimSpace(params.Search) + searchClause := "" + var searchArgs []any + if search != "" { + searchClause = ` + WHERE ( + reference_number ILIKE ? + OR product_name ILIKE ? + OR product_category ILIKE ? + OR source_warehouse ILIKE ? + OR destination_warehouse ILIKE ? + OR CAST(quantity AS TEXT) ILIKE ? + OR unit ILIKE ? + OR notes ILIKE ? + OR transaction_type ILIKE ? + )` + like := "%" + search + "%" + searchArgs = append(searchArgs, like, like, like, like, like, like, like, like, like) + } + + querySQL := fmt.Sprintf(` +SELECT + product_category AS category, + CAST(COALESCE(SUM(quantity), 0) AS BIGINT) AS total_qty, + unit_id AS uom_id, + unit AS uom_name +FROM (%s) AS combined%s +GROUP BY product_category, unit_id, unit +ORDER BY product_category ASC, unit ASC +`, unionSQL, searchClause) + queryArgs := append(append([]any{}, args...), searchArgs...) + + var rows []SapronakSummaryRow + if err := db.Raw(querySQL, queryArgs...).Scan(&rows).Error; err != nil { + return nil, err + } + + return rows, nil +} + func (r *ClosingRepositoryImpl) SumFeedPurchaseAndUsedByProjectFlockKandangIDs(ctx context.Context, projectFlockKandangIDs []uint) (float64, float64, error) { if len(projectFlockKandangIDs) == 0 { return 0, 0, nil @@ -379,6 +484,7 @@ SELECT w.name AS destination_warehouse, '' AS destination, pi.total_qty AS quantity, + u.id AS unit_id, u.name AS unit, COALESCE(p.notes, '') AS notes FROM purchase_items pi @@ -427,6 +533,7 @@ SELECT COALESCE(tw.name, '') AS destination_warehouse, '' AS destination, std.usage_qty AS quantity, + u.id AS unit_id, u.name AS unit, 'Stock Refill' AS notes FROM stock_transfer_details std @@ -476,6 +583,7 @@ SELECT COALESCE(tw.name, '') AS destination_warehouse, '' AS destination, std.usage_qty AS quantity, + u.id AS unit_id, u.name AS unit, 'Transfer to other unit' AS notes FROM stock_transfer_details std @@ -522,13 +630,15 @@ SELECT WHERE f.flagable_type = 'products' AND f.flagable_id = prod.id ), '') AS product_sub_category, w.name AS source_warehouse, - 'RETAIL CUSTOMER' AS destination_warehouse, + COALESCE(c.name, '') AS destination_warehouse, '' AS destination, mp.qty AS quantity, + u.id AS unit_id, u.name AS unit, m.notes AS notes FROM marketing_products mp JOIN marketings m ON m.id = mp.marketing_id +LEFT JOIN customers c ON c.id = m.customer_id JOIN product_warehouses pw ON pw.id = mp.product_warehouse_id JOIN products prod ON prod.id = pw.product_id JOIN uoms u ON u.id = prod.uom_id diff --git a/internal/modules/closings/services/closing.service.go b/internal/modules/closings/services/closing.service.go index 8cda7220..443eec7f 100644 --- a/internal/modules/closings/services/closing.service.go +++ b/internal/modules/closings/services/closing.service.go @@ -40,6 +40,7 @@ type ClosingService interface { GetClosingDataProduksi(ctx *fiber.Ctx, projectFlockID uint, kandangID *uint) (*dto.ClosingProductionReportDTO, error) GetOverhead(ctx *fiber.Ctx, projectFlockID uint, projectFlockKandangID *uint) (*dto.OverheadListDTO, error) GetClosingSapronak(ctx *fiber.Ctx, projectFlockID uint, params *validation.ClosingSapronakQuery) ([]dto.ClosingSapronakItemDTO, int64, error) + GetClosingSapronakSummary(ctx *fiber.Ctx, projectFlockID uint, params *validation.ClosingSapronakQuery) ([]dto.ClosingSapronakSummaryItemDTO, error) GetExpeditionHPP(ctx *fiber.Ctx, projectFlockID uint, projectFlockKandangID *uint) (*dto.ExpeditionHPPDTO, error) } @@ -353,6 +354,7 @@ func (s closingService) GetClosingSapronak(c *fiber.Ctx, projectFlockID uint, pa ProjectFlockKandangIDs: projectFlockKandangIDs, Limit: params.Limit, Offset: offset, + Search: params.Search, }) if err != nil { s.Log.Errorf("Failed to fetch sapronak %s for project flock %d: %+v", params.Type, projectFlockID, err) @@ -387,6 +389,74 @@ func (s closingService) GetClosingSapronak(c *fiber.Ctx, projectFlockID uint, pa return items, totalResults, nil } +func (s closingService) GetClosingSapronakSummary(c *fiber.Ctx, projectFlockID uint, params *validation.ClosingSapronakQuery) ([]dto.ClosingSapronakSummaryItemDTO, error) { + if projectFlockID == 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid project flock id") + } + + if params == nil { + params = &validation.ClosingSapronakQuery{} + } + + if err := s.Validate.Struct(params); err != nil { + return nil, fiber.NewError(fiber.StatusBadRequest, err.Error()) + } + + if params.Type != validation.SapronakTypeIncoming && params.Type != validation.SapronakTypeOutgoing { + return nil, fiber.NewError(fiber.StatusBadRequest, "type must be either incoming or outgoing") + } + + if _, err := s.Repository.GetByID(c.Context(), projectFlockID, nil); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Project flock tidak ditemukan") + } + s.Log.Errorf("Failed get project flock %d for sapronak closing summary: %+v", projectFlockID, err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch project flock") + } + + warehouseIDs, err := s.getWarehouseIDsByProjectFlock(c.Context(), projectFlockID) + if err != nil { + s.Log.Errorf("Failed to fetch warehouses for project flock %d: %+v", projectFlockID, err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch warehouses for project flock") + } + + var projectFlockKandangIDs []uint + if params.KandangID != nil && *params.KandangID > 0 { + projectFlockKandangIDs = []uint{*params.KandangID} + } else if params.Type == validation.SapronakTypeOutgoing { + projectFlockKandangIDs, err = s.getProjectFlockKandangIDs(c.Context(), projectFlockID) + if err != nil { + s.Log.Errorf("Failed to fetch project flock kandang IDs for project flock %d: %+v", projectFlockID, err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch project flock kandang") + } + } + + rows, err := s.Repository.GetSapronakSummary(c.Context(), repository.SapronakQueryParams{ + Type: params.Type, + WarehouseIDs: warehouseIDs, + ProjectFlockKandangIDs: projectFlockKandangIDs, + Search: params.Search, + }) + if err != nil { + s.Log.Errorf("Failed to fetch sapronak %s summary for project flock %d: %+v", params.Type, projectFlockID, err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch sapronak summary data") + } + + items := make([]dto.ClosingSapronakSummaryItemDTO, 0, len(rows)) + for _, row := range rows { + items = append(items, dto.ClosingSapronakSummaryItemDTO{ + Category: row.Category, + TotalQty: row.TotalQty, + Uom: dto.UomSummaryDTO{ + ID: row.UomID, + Name: row.UomName, + }, + }) + } + + return items, nil +} + func (s closingService) getWarehouseIDsByProjectFlock(ctx context.Context, projectFlockID uint) ([]uint, error) { var kandangIDs []uint db := s.Repository.DB().WithContext(ctx) @@ -1030,4 +1100,3 @@ func closestFcrValues(standards []entity.FcrStandard, averageWeight float64) (fl return closest.Mortality, closest.FcrNumber } - diff --git a/internal/modules/closings/validations/closing.validation.go b/internal/modules/closings/validations/closing.validation.go index 0c738407..454bbdfc 100644 --- a/internal/modules/closings/validations/closing.validation.go +++ b/internal/modules/closings/validations/closing.validation.go @@ -24,4 +24,5 @@ type ClosingSapronakQuery struct { Page int `query:"page" validate:"omitempty,number,min=1,gt=0"` Limit int `query:"limit" validate:"omitempty,number,min=1,max=100,gt=0"` KandangID *uint `query:"kandang_id" validate:"omitempty,gt=0"` + Search string `query:"search" validate:"omitempty,max=100"` } diff --git a/internal/modules/inventory/transfers/repositories/stock_transfer.repository.go b/internal/modules/inventory/transfers/repositories/stock_transfer.repository.go index cd314901..9d9d6aeb 100644 --- a/internal/modules/inventory/transfers/repositories/stock_transfer.repository.go +++ b/internal/modules/inventory/transfers/repositories/stock_transfer.repository.go @@ -40,6 +40,6 @@ func (r *StockTransferRepositoryImpl) GenerateMovementNumber(ctx context.Context if err != nil { return "", err } - movementNumber := fmt.Sprintf("ST-%05d", seq) + movementNumber := fmt.Sprintf("PND-LTI-%05d", seq) return movementNumber, nil } From 9fb5395469ae640a0a2ac943522360a45c090291 Mon Sep 17 00:00:00 2001 From: ragilap Date: Tue, 20 Jan 2026 10:13:58 +0700 Subject: [PATCH 76/76] [FIX/BE-US] recording,reporting,closing and uniformity --- ...650_add_fifo_recording_depletions.down.sql | 3 + ...95650_add_fifo_recording_depletions.up.sql | 17 ++ internal/entities/recording_depletion.go | 10 +- .../closings/dto/closingSapronak.dto.go | 46 ++++- .../repositories/closing.repository.go | 99 ++++++++++ .../closings/services/sapronak.service.go | 64 +++++- .../controllers/recording.controller.go | 5 +- .../recordings/dto/recording.dto.go | 95 ++++++--- .../modules/production/recordings/module.go | 22 +++ .../repositories/recording.repository.go | 70 +++++-- .../recordings/services/recording.service.go | 186 ++++++++++++++---- .../validations/recording.validation.go | 28 +-- .../services/uniformity.service.go | 78 +++++++- .../repports/dto/repportPurchase.dto.go | 32 ++- .../repositories/debt_supplier.repository.go | 82 +++++++- .../purchase_supplier.repository.go | 29 ++- .../repports/services/repport.service.go | 72 ++++++- internal/utils/fifo/constants.go | 23 +-- internal/utils/recording/util.recording.go | 5 - 19 files changed, 805 insertions(+), 161 deletions(-) create mode 100644 internal/database/migrations/20260119095650_add_fifo_recording_depletions.down.sql create mode 100644 internal/database/migrations/20260119095650_add_fifo_recording_depletions.up.sql diff --git a/internal/database/migrations/20260119095650_add_fifo_recording_depletions.down.sql b/internal/database/migrations/20260119095650_add_fifo_recording_depletions.down.sql new file mode 100644 index 00000000..4daff87b --- /dev/null +++ b/internal/database/migrations/20260119095650_add_fifo_recording_depletions.down.sql @@ -0,0 +1,3 @@ +ALTER TABLE recording_depletions + DROP COLUMN IF EXISTS pending_qty, + DROP COLUMN IF EXISTS source_product_warehouse_id; diff --git a/internal/database/migrations/20260119095650_add_fifo_recording_depletions.up.sql b/internal/database/migrations/20260119095650_add_fifo_recording_depletions.up.sql new file mode 100644 index 00000000..4e29e129 --- /dev/null +++ b/internal/database/migrations/20260119095650_add_fifo_recording_depletions.up.sql @@ -0,0 +1,17 @@ +ALTER TABLE recording_depletions + ADD COLUMN IF NOT EXISTS pending_qty numeric(15,3) NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS source_product_warehouse_id bigint; + +UPDATE recording_depletions rd +SET source_product_warehouse_id = src.product_warehouse_id +FROM recordings r +JOIN LATERAL ( + SELECT pfp.product_warehouse_id + FROM project_chickins pc + JOIN project_flock_populations pfp ON pfp.project_chickin_id = pc.id + WHERE pc.project_flock_kandang_id = r.project_flock_kandangs_id + ORDER BY pfp.created_at ASC, pfp.id ASC + LIMIT 1 +) AS src ON true +WHERE r.id = rd.recording_id + AND rd.source_product_warehouse_id IS NULL; diff --git a/internal/entities/recording_depletion.go b/internal/entities/recording_depletion.go index 53af300d..8e0c7afe 100644 --- a/internal/entities/recording_depletion.go +++ b/internal/entities/recording_depletion.go @@ -1,10 +1,12 @@ package entities type RecordingDepletion struct { - Id uint `gorm:"primaryKey"` - RecordingId uint `gorm:"column:recording_id;not null;index"` - ProductWarehouseId uint `gorm:"column:product_warehouse_id;not null"` - Qty float64 `gorm:"column:qty;not null"` + Id uint `gorm:"primaryKey"` + RecordingId uint `gorm:"column:recording_id;not null;index"` + ProductWarehouseId uint `gorm:"column:product_warehouse_id;not null"` + SourceProductWarehouseId *uint `gorm:"column:source_product_warehouse_id"` + Qty float64 `gorm:"column:qty;not null"` + PendingQty float64 `gorm:"column:pending_qty"` Recording Recording `gorm:"foreignKey:RecordingId;references:Id"` ProductWarehouse ProductWarehouse `gorm:"foreignKey:ProductWarehouseId;references:Id"` diff --git a/internal/modules/closings/dto/closingSapronak.dto.go b/internal/modules/closings/dto/closingSapronak.dto.go index 768c727e..0067b9d2 100644 --- a/internal/modules/closings/dto/closingSapronak.dto.go +++ b/internal/modules/closings/dto/closingSapronak.dto.go @@ -201,18 +201,48 @@ func ToSapronakProjectAggregatedFromReport(report *SapronakReportDTO, flag strin switch strings.ToLower(item.JenisTransaksi) { case "pembelian", "adjustment masuk", "mutasi masuk": row.QtyIn += item.QtyMasuk - row.TotalAmount += item.Nilai + if row.UnitPrice == 0 { + if item.QtyMasuk > 0 && item.Nilai > 0 { + row.UnitPrice = item.Nilai / item.QtyMasuk + } else if item.Harga > 0 { + row.UnitPrice = item.Harga + } + } + if strings.ToLower(item.JenisTransaksi) == "mutasi masuk" { + ref := strings.ToUpper(strings.TrimSpace(item.NoReferensi)) + if strings.HasPrefix(ref, "TL-") { + row.Notes = "TRANSFER LAYING" + } else if strings.HasPrefix(ref, "ST-") { + row.Notes = "TRANSFER STOCK" + } + } case "pemakaian", "adjustment keluar": + price := row.UnitPrice + if price == 0 { + price = item.Harga + } row.QtyUsed += item.QtyKeluar - case "mutasi keluar": + row.TotalAmount += item.QtyKeluar * price + case "mutasi keluar", "penjualan": + price := row.UnitPrice + if price == 0 { + price = item.Harga + } row.QtyOut += item.QtyKeluar + if strings.ToLower(item.JenisTransaksi) == "mutasi keluar" { + ref := strings.ToUpper(strings.TrimSpace(item.NoReferensi)) + if strings.HasPrefix(ref, "TL-") { + row.Notes = "TRANSFER LAYING" + } else if strings.HasPrefix(ref, "ST-") { + row.Notes = "TRANSFER STOCK" + } + } default: row.QtyIn += item.QtyMasuk row.TotalAmount += item.Nilai - } - - if row.QtyIn > 0 { - row.UnitPrice = row.TotalAmount / row.QtyIn + if row.QtyIn > 0 { + row.UnitPrice = row.TotalAmount / row.QtyIn + } } } @@ -233,8 +263,8 @@ func ToSapronakProjectAggregatedFromReport(report *SapronakReportDTO, flag strin total += r.TotalAmount } avg := 0.0 - if qtyIn > 0 { - avg = total / qtyIn + if qtyUsed > 0 { + avg = total / qtyUsed } cat.Total = SapronakCategoryTotalDTO{ Label: label, diff --git a/internal/modules/closings/repositories/closing.repository.go b/internal/modules/closings/repositories/closing.repository.go index 507d7c88..6d7df1cc 100644 --- a/internal/modules/closings/repositories/closing.repository.go +++ b/internal/modules/closings/repositories/closing.repository.go @@ -33,6 +33,7 @@ type ClosingRepository interface { FetchSapronakChickinUsageDetails(ctx context.Context, pfkID uint) (map[uint][]SapronakDetailRow, error) FetchSapronakAdjustments(ctx context.Context, kandangID uint) (map[uint][]SapronakDetailRow, map[uint][]SapronakDetailRow, error) FetchSapronakTransfers(ctx context.Context, kandangID uint) (map[uint][]SapronakDetailRow, map[uint][]SapronakDetailRow, error) + FetchSapronakSales(ctx context.Context, kandangID uint) (map[uint][]SapronakDetailRow, error) GetProductsWithFlagsByIDs(ctx context.Context, productIDs []uint) ([]entity.Product, error) } @@ -909,17 +910,50 @@ func (r *ClosingRepositoryImpl) FetchSapronakTransfers(ctx context.Context, kand COALESCE(p.product_price, 0) AS price `). Joins("JOIN stock_transfers st ON st.id = std.stock_transfer_id"). + Joins("LEFT JOIN warehouses fw ON fw.id = st.from_warehouse_id"). Joins("JOIN product_warehouses pw ON pw.id = std.dest_product_warehouse_id"). Joins("JOIN warehouses w ON w.id = pw.warehouse_id"). Joins("JOIN products p ON p.id = std.product_id"). Joins("JOIN flags f ON f.flagable_id = p.id AND f.flagable_type = ?", entity.FlagableTypeProduct). Where("w.kandang_id = ?", kandangID). + Where("(fw.kandang_id IS NULL OR fw.kandang_id <> w.kandang_id)"). Where("f.name IN ?", sapronakFlagsAll) incoming, err := scanAndGroupDetails(incomingQuery) if err != nil { return nil, nil, err } + incomingLayingQuery := r.withCtx(ctx). + Table("laying_transfer_targets AS ltt"). + Select(` + pw.product_id AS product_id, + p.name AS product_name, + f.name AS flag, + lt.transfer_date::timestamp AS date, + COALESCE(lt.transfer_number, '') AS reference, + COALESCE(ltt.total_qty, 0) AS qty_in, + 0 AS qty_out, + COALESCE(p.product_price, 0) AS price + `). + Joins("JOIN laying_transfers lt ON lt.id = ltt.laying_transfer_id"). + Joins("LEFT JOIN laying_transfer_sources lts ON lts.laying_transfer_id = lt.id"). + Joins("LEFT JOIN product_warehouses pw_source ON pw_source.id = lts.product_warehouse_id"). + Joins("LEFT JOIN warehouses w_source ON w_source.id = pw_source.warehouse_id"). + Joins("JOIN product_warehouses pw ON pw.id = ltt.product_warehouse_id"). + Joins("JOIN warehouses w ON w.id = pw.warehouse_id"). + Joins("JOIN products p ON p.id = pw.product_id"). + Joins("JOIN flags f ON f.flagable_id = p.id AND f.flagable_type = ?", entity.FlagableTypeProduct). + Where("w.kandang_id = ?", kandangID). + Where("(w_source.kandang_id IS NULL OR w_source.kandang_id <> w.kandang_id)"). + Where("f.name IN ?", sapronakFlagsAll) + incomingLaying, err := scanAndGroupDetails(incomingLayingQuery) + if err != nil { + return nil, nil, err + } + for pid, rows := range incomingLaying { + incoming[pid] = append(incoming[pid], rows...) + } + outgoingQuery := r.withCtx(ctx). Table("stock_allocations AS sa"). Select(` @@ -936,10 +970,13 @@ func (r *ClosingRepositoryImpl) FetchSapronakTransfers(ctx context.Context, kand Joins("JOIN stock_transfers st ON st.id = std.stock_transfer_id"). Joins("JOIN product_warehouses pw ON pw.id = sa.product_warehouse_id"). Joins("JOIN warehouses w ON w.id = pw.warehouse_id"). + Joins("LEFT JOIN product_warehouses pw_dest ON pw_dest.id = std.dest_product_warehouse_id"). + Joins("LEFT JOIN warehouses w_dest ON w_dest.id = pw_dest.warehouse_id"). Joins("JOIN products p ON p.id = std.product_id"). Joins("JOIN flags f ON f.flagable_id = p.id AND f.flagable_type = ?", entity.FlagableTypeProduct). Where("sa.status = ?", entity.StockAllocationStatusActive). Where("w.kandang_id = ?", kandangID). + Where("(w_dest.kandang_id IS NULL OR w_dest.kandang_id <> w.kandang_id)"). Where("f.name IN ?", sapronakFlagsAll). Group("std.id, std.product_id, p.name, f.name, st.transfer_date, st.movement_number, p.product_price") outgoing, err := scanAndGroupDetails(outgoingQuery) @@ -947,9 +984,71 @@ func (r *ClosingRepositoryImpl) FetchSapronakTransfers(ctx context.Context, kand return nil, nil, err } + outgoingLayingQuery := r.withCtx(ctx). + Table("stock_allocations AS sa"). + Select(` + pw.product_id AS product_id, + p.name AS product_name, + f.name AS flag, + lt.transfer_date::timestamp AS date, + COALESCE(lt.transfer_number, '') AS reference, + 0 AS qty_in, + COALESCE(SUM(sa.qty), 0) AS qty_out, + COALESCE(p.product_price, 0) AS price + `). + Joins("JOIN laying_transfer_sources lts ON lts.id = sa.usable_id AND sa.usable_type = ?", fifo.UsableKeyTransferToLayingOut.String()). + Joins("JOIN laying_transfers lt ON lt.id = lts.laying_transfer_id"). + Joins("LEFT JOIN laying_transfer_targets ltt ON ltt.laying_transfer_id = lt.id"). + Joins("LEFT JOIN product_warehouses pw_dest ON pw_dest.id = ltt.product_warehouse_id"). + Joins("LEFT JOIN warehouses w_dest ON w_dest.id = pw_dest.warehouse_id"). + Joins("JOIN product_warehouses pw ON pw.id = sa.product_warehouse_id"). + Joins("JOIN warehouses w ON w.id = pw.warehouse_id"). + Joins("JOIN products p ON p.id = pw.product_id"). + Joins("JOIN flags f ON f.flagable_id = p.id AND f.flagable_type = ?", entity.FlagableTypeProduct). + Where("sa.status = ?", entity.StockAllocationStatusActive). + Where("w.kandang_id = ?", kandangID). + Where("(w_dest.kandang_id IS NULL OR w_dest.kandang_id <> w.kandang_id)"). + Where("f.name IN ?", sapronakFlagsAll). + Group("lts.id, pw.product_id, p.name, f.name, lt.transfer_date, lt.transfer_number, p.product_price") + outgoingLaying, err := scanAndGroupDetails(outgoingLayingQuery) + if err != nil { + return nil, nil, err + } + for pid, rows := range outgoingLaying { + outgoing[pid] = append(outgoing[pid], rows...) + } + return incoming, outgoing, nil } +func (r *ClosingRepositoryImpl) FetchSapronakSales(ctx context.Context, kandangID uint) (map[uint][]SapronakDetailRow, error) { + query := r.withCtx(ctx). + Table("stock_allocations AS sa"). + Select(` + pw.product_id AS product_id, + p.name AS product_name, + f.name AS flag, + COALESCE(mdp.delivery_date, mdp.created_at) AS date, + COALESCE(m.so_number, '') AS reference, + 0 AS qty_in, + COALESCE(SUM(sa.qty), 0) AS qty_out, + COALESCE(mdp.unit_price, mp.unit_price, 0) AS price + `). + Joins("JOIN marketing_delivery_products mdp ON mdp.id = sa.usable_id AND sa.usable_type = ?", fifo.UsableKeyMarketingDelivery.String()). + Joins("JOIN marketing_products mp ON mp.id = mdp.marketing_product_id"). + Joins("JOIN marketings m ON m.id = mp.marketing_id"). + Joins("JOIN product_warehouses pw ON pw.id = sa.product_warehouse_id"). + Joins("JOIN warehouses w ON w.id = pw.warehouse_id"). + Joins("JOIN products p ON p.id = pw.product_id"). + Joins("JOIN flags f ON f.flagable_id = p.id AND f.flagable_type = ?", entity.FlagableTypeProduct). + Where("sa.status = ?", entity.StockAllocationStatusActive). + Where("w.kandang_id = ?", kandangID). + Where("f.name IN ?", sapronakFlagsAll). + Group("mdp.id, pw.product_id, p.name, f.name, mdp.delivery_date, mdp.created_at, m.so_number, mdp.unit_price, mp.unit_price") + + return scanAndGroupDetails(query) +} + func (r *ClosingRepositoryImpl) GetProductsWithFlagsByIDs(ctx context.Context, productIDs []uint) ([]entity.Product, error) { if len(productIDs) == 0 { return []entity.Product{}, nil diff --git a/internal/modules/closings/services/sapronak.service.go b/internal/modules/closings/services/sapronak.service.go index fc354f46..930c1bc5 100644 --- a/internal/modules/closings/services/sapronak.service.go +++ b/internal/modules/closings/services/sapronak.service.go @@ -2,6 +2,7 @@ package service import ( "context" + "fmt" "strings" "github.com/go-playground/validator/v10" @@ -111,7 +112,7 @@ func (s sapronakService) computeSapronakReports(ctx context.Context, params *val } // We no longer filter by date for closing sapronak report; pass nil pointers. - items, groups, totalIncoming, totalUsage, err := s.buildSapronakItems(ctx, pfk, params.Flag) + items, groups, totalIncoming, totalUsage, err := s.buildSapronakItems(ctx, pfk, params.Flag) if err != nil { s.Log.Errorf("Failed to build sapronak items for pfk %d: %+v", pfk.Id, err) return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to calculate sapronak report") @@ -262,6 +263,7 @@ type sapronakDetailMaps struct { AdjOutgoing map[uint][]dto.SapronakDetailDTO TransferIn map[uint][]dto.SapronakDetailDTO TransferOut map[uint][]dto.SapronakDetailDTO + SalesOut map[uint][]dto.SapronakDetailDTO } func buildSapronakDetails( @@ -271,6 +273,7 @@ func buildSapronakDetails( adjOutgoingRows map[uint][]repository.SapronakDetailRow, transferInRows map[uint][]repository.SapronakDetailRow, transferOutRows map[uint][]repository.SapronakDetailRow, + salesOutRows map[uint][]repository.SapronakDetailRow, ) sapronakDetailMaps { result := sapronakDetailMaps{ Incoming: make(map[uint][]dto.SapronakDetailDTO), @@ -279,6 +282,7 @@ func buildSapronakDetails( AdjOutgoing: make(map[uint][]dto.SapronakDetailDTO), TransferIn: make(map[uint][]dto.SapronakDetailDTO), TransferOut: make(map[uint][]dto.SapronakDetailDTO), + SalesOut: make(map[uint][]dto.SapronakDetailDTO), } addRows := func(target map[uint][]dto.SapronakDetailDTO, src map[uint][]repository.SapronakDetailRow, jenis string, masuk bool) { @@ -311,6 +315,7 @@ func buildSapronakDetails( addRows(result.AdjOutgoing, adjOutgoingRows, "Adjustment Keluar", false) addRows(result.TransferIn, transferInRows, "Mutasi Masuk", true) addRows(result.TransferOut, transferOutRows, "Mutasi Keluar", false) + addRows(result.SalesOut, salesOutRows, "Penjualan", false) return result } @@ -350,6 +355,10 @@ func (s sapronakService) buildSapronakItems(ctx context.Context, pfk entity.Proj if err != nil { return nil, nil, 0, 0, err } + salesOutRows, err := s.Repository.FetchSapronakSales(ctx, pfk.KandangId) + if err != nil { + return nil, nil, 0, 0, err + } filterFlag := strings.ToUpper(strings.TrimSpace(flagFilter)) matchesFlag := func(f string) bool { @@ -362,6 +371,34 @@ func (s sapronakService) buildSapronakItems(ctx context.Context, pfk entity.Proj } return candidate == filterFlag } + dedupTransfers := func(src map[uint][]dto.SapronakDetailDTO) map[uint][]dto.SapronakDetailDTO { + result := make(map[uint][]dto.SapronakDetailDTO, len(src)) + seen := make(map[string]struct{}) + for pid, rows := range src { + for _, d := range rows { + dateKey := "" + if d.Tanggal != nil { + dateKey = d.Tanggal.Format("2006-01-02") + } + qtyKey := d.QtyMasuk + if qtyKey == 0 { + qtyKey = d.QtyKeluar + } + + ref := strings.TrimSpace(d.NoReferensi) + key := fmt.Sprintf("%d|%s|%s|%.3f", pid, ref, dateKey, qtyKey) + if ref == "" { + key = fmt.Sprintf("%d|%s|%s|%.3f|%s", pid, ref, dateKey, qtyKey, strings.ToUpper(strings.TrimSpace(d.Flag))) + } + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + result[pid] = append(result[pid], d) + } + } + return result + } // For project flocks with category GROWING, pullet usage from chickin // should not be counted yet. Only when category is LAYING we allow @@ -400,13 +437,17 @@ func (s sapronakService) buildSapronakItems(ctx context.Context, pfk entity.Proj usageDetailsRows[pid] = append(usageDetailsRows[pid], rows...) } - detailMaps := buildSapronakDetails(incomingDetailsRows, usageDetailsRows, adjIncomingRows, adjOutgoingRows, transIncomingRows, transOutgoingRows) + detailMaps := buildSapronakDetails(incomingDetailsRows, usageDetailsRows, adjIncomingRows, adjOutgoingRows, transIncomingRows, transOutgoingRows, salesOutRows) incomingDetails := detailMaps.Incoming usageDetails := detailMaps.Usage adjIncoming := detailMaps.AdjIncoming adjOutgoing := detailMaps.AdjOutgoing transIncoming := detailMaps.TransferIn transOutgoing := detailMaps.TransferOut + salesOutgoing := detailMaps.SalesOut + + transIncoming = dedupTransfers(transIncoming) + transOutgoing = dedupTransfers(transOutgoing) ensureGroup := func(flag string) *dto.SapronakGroupDTO { if g, ok := groupMap[flag]; ok { @@ -683,6 +724,25 @@ func (s sapronakService) buildSapronakItems(ctx context.Context, pfk entity.Proj } } + for productID, details := range salesOutgoing { + flag, name := resolveFlagName(productID, details) + if !matchesFlag(flag) { + continue + } + group := ensureGroup(flag) + for _, d := range details { + if d.Flag == "" { + d.Flag = flag + } + if d.ProductName == "" { + d.ProductName = name + } + group.Items = append(group.Items, d) + group.TotalKeluar += d.QtyKeluar + group.SaldoAkhir -= d.QtyKeluar + } + } + groups := make([]dto.SapronakGroupDTO, 0, len(groupMap)) for _, g := range groupMap { groups = append(groups, *g) diff --git a/internal/modules/production/recordings/controllers/recording.controller.go b/internal/modules/production/recordings/controllers/recording.controller.go index c0f1737b..7edb7b9a 100644 --- a/internal/modules/production/recordings/controllers/recording.controller.go +++ b/internal/modules/production/recordings/controllers/recording.controller.go @@ -26,8 +26,9 @@ func (u *RecordingController) GetAll(c *fiber.Ctx) error { projectFlockID := c.QueryInt("project_flock_kandang_id", 0) query := &validation.Query{ - Page: c.QueryInt("page", 1), - Limit: c.QueryInt("limit", 10), + Page: c.QueryInt("page", 1), + Limit: c.QueryInt("limit", 10), + Search: c.Query("search"), } if projectFlockID > 0 { query.ProjectFlockKandangId = uint(projectFlockID) diff --git a/internal/modules/production/recordings/dto/recording.dto.go b/internal/modules/production/recordings/dto/recording.dto.go index ebb093ba..0fa14e97 100644 --- a/internal/modules/production/recordings/dto/recording.dto.go +++ b/internal/modules/production/recordings/dto/recording.dto.go @@ -15,13 +15,13 @@ import ( // === DTO Structs === type RecordingProjectFlockDTO struct { - ProjectFlockKandangId uint `json:"project_flock_kandang_id"` - FlockName string `json:"flock_name"` - ProjectFlockCategory string `json:"project_flock_category"` - Period int `json:"period"` - ProductionStandart *RecordingProductionStandardDTO `json:"production_standart,omitempty"` - Fcr *RecordingFcrDTO `json:"fcr,omitempty"` - TotalChickQty float64 `json:"total_chick_qty"` + ProjectFlockKandangId uint `json:"project_flock_kandang_id"` + FlockName string `json:"flock_name"` + ProjectFlockCategory string `json:"project_flock_category"` + Period int `json:"period"` + ProductionStandart *RecordingProductionStandardDTO `json:"production_standart,omitempty"` + Fcr *RecordingFcrDTO `json:"fcr,omitempty"` + TotalChickQty float64 `json:"total_chick_qty"` } type RecordingProductionStandardDTO struct { @@ -53,6 +53,13 @@ type RecordingLocationDTO struct { Address string `json:"address"` } +type RecordingKandangDTO struct { + Id uint `json:"id"` + Name string `json:"name"` + Status string `json:"status"` + Capacity float64 `json:"capacity"` +} + type RecordingWarehouseDTO struct { Id uint `json:"id"` Name string `json:"name"` @@ -82,12 +89,14 @@ type RecordingListDTO struct { CreatedUser *userDTO.UserRelationDTO `json:"created_user,omitempty"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` - Warehouse *RecordingWarehouseDTO `json:"warehouse,omitempty"` + Kandang *RecordingKandangDTO `json:"kandang,omitempty"` + Location *RecordingLocationDTO `json:"location,omitempty"` } type RecordingDetailDTO struct { RecordingListDTO - ProductCategory string `json:"product_category"` + ProductCategory string `json:"product_category"` + Warehouse *RecordingWarehouseDTO `json:"warehouse,omitempty"` Depletions []RecordingDepletionDTO `json:"depletions"` Stocks []RecordingStockDTO `json:"stocks"` Eggs []RecordingEggDTO `json:"eggs"` @@ -133,10 +142,11 @@ func ToRecordingDetailDTO(e entity.Recording) RecordingDetailDTO { return RecordingDetailDTO{ RecordingListDTO: listDTO, - ProductCategory: recordingProductCategory(e), - Depletions: ToRecordingDepletionDTOs(e.Depletions), - Stocks: ToRecordingStockDTOs(e.Stocks), - Eggs: ToRecordingEggDTOs(e.Eggs), + ProductCategory: recordingProductCategory(e), + Warehouse: recordingWarehouseDTO(e), + Depletions: ToRecordingDepletionDTOs(e.Depletions), + Stocks: ToRecordingStockDTOs(e.Stocks), + Eggs: ToRecordingEggDTOs(e.Eggs), } } @@ -202,7 +212,8 @@ func toRecordingListDTO(e entity.Recording) RecordingListDTO { CreatedAt: e.CreatedAt, UpdatedAt: e.UpdatedAt, CreatedUser: createdUser, - Warehouse: recordingWarehouseDTO(e), + Kandang: recordingKandangDTO(e), + Location: recordingKandangLocationDTO(e), } } @@ -214,20 +225,20 @@ func toRecordingRelationDTO(e entity.Recording) RecordingRelationDTO { } return RecordingRelationDTO{ - Id: e.Id, - ProjectFlock: toRecordingProjectFlockDTO(e), - RecordDatetime: e.RecordDatetime, - Day: intValue(e.Day), - TotalDepletionQty: floatValue(e.TotalDepletionQty), - CumDepletionRate: floatValue(e.CumDepletionRate), - CumIntake: intValue(e.CumIntake), - FcrValue: floatValue(e.FcrValue), - HenDay: floatValue(e.HenDay), - HenHouse: floatValue(e.HenHouse), - FeedIntake: floatValue(e.FeedIntake), - EggMass: floatValue(e.EggMass), - EggWeight: floatValue(e.EggWeight), - Approval: latestApproval, + Id: e.Id, + ProjectFlock: toRecordingProjectFlockDTO(e), + RecordDatetime: e.RecordDatetime, + Day: intValue(e.Day), + TotalDepletionQty: floatValue(e.TotalDepletionQty), + CumDepletionRate: floatValue(e.CumDepletionRate), + CumIntake: intValue(e.CumIntake), + FcrValue: floatValue(e.FcrValue), + HenDay: floatValue(e.HenDay), + HenHouse: floatValue(e.HenHouse), + FeedIntake: floatValue(e.FeedIntake), + EggMass: floatValue(e.EggMass), + EggWeight: floatValue(e.EggWeight), + Approval: latestApproval, } } @@ -321,6 +332,34 @@ func recordingWarehouseDTO(e entity.Recording) *RecordingWarehouseDTO { return mapWarehouseDTO(&pw.Warehouse) } +func recordingKandangDTO(e entity.Recording) *RecordingKandangDTO { + if e.ProjectFlockKandang == nil || e.ProjectFlockKandang.Kandang.Id == 0 { + return nil + } + kandang := e.ProjectFlockKandang.Kandang + return &RecordingKandangDTO{ + Id: kandang.Id, + Name: kandang.Name, + Status: kandang.Status, + Capacity: kandang.Capacity, + } +} + +func recordingKandangLocationDTO(e entity.Recording) *RecordingLocationDTO { + if e.ProjectFlockKandang == nil || e.ProjectFlockKandang.Kandang.Id == 0 { + return nil + } + location := e.ProjectFlockKandang.Kandang.Location + if location.Id == 0 { + return nil + } + return &RecordingLocationDTO{ + Id: location.Id, + Name: location.Name, + Address: location.Address, + } +} + func primaryProductWarehouse(e entity.Recording) *entity.ProductWarehouse { if len(e.Stocks) > 0 { pw := e.Stocks[0].ProductWarehouse diff --git a/internal/modules/production/recordings/module.go b/internal/modules/production/recordings/module.go index 11a1e152..71981a9e 100644 --- a/internal/modules/production/recordings/module.go +++ b/internal/modules/production/recordings/module.go @@ -74,6 +74,28 @@ func (RecordingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate panic(fmt.Sprintf("failed to register recording usable workflow: %v", err)) } } + if err := fifoService.RegisterUsable(fifo.UsableConfig{ + Key: fifo.UsableKeyRecordingDepletion, + Table: "recording_depletions", + Columns: fifo.UsableColumns{ + ID: "id", + ProductWarehouseID: "source_product_warehouse_id", + UsageQuantity: "qty", + PendingQuantity: "pending_qty", + CreatedAt: "id", + }, + ExcludedStockables: []fifo.StockableKey{ + fifo.StockableKeyTransferToLayingIn, + fifo.StockableKeyStockTransferIn, + fifo.StockableKeyAdjustmentIn, + fifo.StockableKeyPurchaseItems, + fifo.StockableKeyRecordingEgg, + }, + }); err != nil { + if !strings.Contains(strings.ToLower(err.Error()), "already registered") { + panic(fmt.Sprintf("failed to register recording depletion usable workflow: %v", err)) + } + } approvalRepo := commonRepo.NewApprovalRepository(db) approvalService := commonSvc.NewApprovalService(approvalRepo) diff --git a/internal/modules/production/recordings/repositories/recording.repository.go b/internal/modules/production/recordings/repositories/recording.repository.go index 703c05f0..9e783134 100644 --- a/internal/modules/production/recordings/repositories/recording.repository.go +++ b/internal/modules/production/recordings/repositories/recording.repository.go @@ -17,6 +17,7 @@ type RecordingRepository interface { repository.BaseRepository[entity.Recording] WithRelations(db *gorm.DB) *gorm.DB + ApplySearchFilters(db *gorm.DB, rawSearch string) *gorm.DB GetLatestByProjectFlockKandangID(ctx context.Context, projectFlockKandangId uint) (*entity.Recording, error) GenerateNextDay(tx *gorm.DB, projectFlockKandangId uint) (int, error) @@ -24,6 +25,7 @@ type RecordingRepository interface { DeleteStocks(tx *gorm.DB, recordingID uint) error ListStocks(tx *gorm.DB, recordingID uint) ([]entity.RecordingStock, error) UpdateStockUsage(tx *gorm.DB, stockID uint, usageQty, pendingQty float64) error + UpdateDepletionPending(tx *gorm.DB, depletionID uint, pendingQty float64) error CreateDepletions(tx *gorm.DB, depletions []entity.RecordingDepletion) error DeleteDepletions(tx *gorm.DB, recordingID uint) error @@ -84,6 +86,7 @@ func (r *RecordingRepositoryImpl) WithRelations(db *gorm.DB) *gorm.DB { Preload("CreatedUser"). Preload("ProjectFlockKandang"). Preload("ProjectFlockKandang.Kandang"). + Preload("ProjectFlockKandang.Kandang.Location"). Preload("ProjectFlockKandang.ProjectFlock"). Preload("ProjectFlockKandang.ProjectFlock.ProductionStandard"). Preload("ProjectFlockKandang.ProjectFlock.Fcr"). @@ -107,6 +110,42 @@ func (r *RecordingRepositoryImpl) WithRelations(db *gorm.DB) *gorm.DB { Preload("Eggs.ProductWarehouse.Warehouse.Location") } +func (r *RecordingRepositoryImpl) ApplySearchFilters(db *gorm.DB, rawSearch string) *gorm.DB { + normalized := strings.ToLower(strings.TrimSpace(rawSearch)) + if normalized == "" { + return db + } + + likeQuery := "%" + normalized + "%" + subQuery := db.Session(&gorm.Session{NewDB: true}). + Table("recordings"). + Select("recordings.id"). + Joins("LEFT JOIN project_flock_kandangs pfk ON pfk.id = recordings.project_flock_kandangs_id"). + Joins("LEFT JOIN project_flocks pf ON pf.id = pfk.project_flock_id"). + Joins("LEFT JOIN kandangs k ON k.id = pfk.kandang_id"). + Joins("LEFT JOIN locations l ON l.id = k.location_id"). + Joins("LEFT JOIN recording_stocks rs ON rs.recording_id = recordings.id"). + Joins("LEFT JOIN recording_depletions rd ON rd.recording_id = recordings.id"). + Joins("LEFT JOIN recording_eggs re ON re.recording_id = recordings.id"). + Joins("LEFT JOIN product_warehouses pws ON pws.id = rs.product_warehouse_id"). + Joins("LEFT JOIN product_warehouses pwd ON pwd.id = rd.product_warehouse_id"). + Joins("LEFT JOIN product_warehouses pwe ON pwe.id = re.product_warehouse_id"). + Joins("LEFT JOIN warehouses ws ON ws.id = pws.warehouse_id"). + Joins("LEFT JOIN warehouses wd ON wd.id = pwd.warehouse_id"). + Joins("LEFT JOIN warehouses we ON we.id = pwe.warehouse_id"). + Where(` + LOWER(pf.flock_name) LIKE ? + OR LOWER(k.name) LIKE ? + OR LOWER(l.name) LIKE ? + OR LOWER(l.address) LIKE ? + OR LOWER(ws.name) LIKE ? + OR LOWER(wd.name) LIKE ? + OR LOWER(we.name) LIKE ?`, + likeQuery, likeQuery, likeQuery, likeQuery, likeQuery, likeQuery, likeQuery, + ) + return db.Where("recordings.id IN (?)", subQuery) +} + func (r *RecordingRepositoryImpl) GetLatestByProjectFlockKandangID(ctx context.Context, projectFlockKandangId uint) (*entity.Recording, error) { if projectFlockKandangId == 0 { return nil, errors.New("project_flock_kandang_id is required") @@ -167,6 +206,12 @@ func (r *RecordingRepositoryImpl) UpdateStockUsage(tx *gorm.DB, stockID uint, us }).Error } +func (r *RecordingRepositoryImpl) UpdateDepletionPending(tx *gorm.DB, depletionID uint, pendingQty float64) error { + return tx.Model(&entity.RecordingDepletion{}). + Where("id = ?", depletionID). + Update("pending_qty", pendingQty).Error +} + func (r *RecordingRepositoryImpl) CreateDepletions(tx *gorm.DB, depletions []entity.RecordingDepletion) error { if len(depletions) == 0 { return nil @@ -322,38 +367,25 @@ func (r *RecordingRepositoryImpl) GetTotalChickinByProjectFlockKandang(tx *gorm. } func (r *RecordingRepositoryImpl) GetFeedUsageInGrams(tx *gorm.DB, recordingID uint) (float64, error) { - var rows []struct { + var result struct { TotalQty float64 - UomName string } if err := tx. Table("recording_stocks"). - Select("COALESCE(recording_stocks.usage_qty, 0) + COALESCE(recording_stocks.pending_qty, 0) AS total_qty, LOWER(uoms.name) AS uom_name"). + Select("COALESCE(SUM(COALESCE(recording_stocks.usage_qty, 0) + COALESCE(recording_stocks.pending_qty, 0)), 0) AS total_qty"). Joins("JOIN product_warehouses ON product_warehouses.id = recording_stocks.product_warehouse_id"). Joins("JOIN products ON products.id = product_warehouses.product_id"). - Joins("JOIN uoms ON uoms.id = products.uom_id"). Joins("JOIN flags ON flags.flagable_id = products.id AND flags.flagable_type = ? AND UPPER(flags.name) = ?", entity.FlagableTypeProduct, "PAKAN"). Where("recording_stocks.recording_id = ?", recordingID). - Scan(&rows).Error; err != nil { + Scan(&result).Error; err != nil { return 0, err } - var total float64 - for _, row := range rows { - if row.TotalQty <= 0 { - continue - } - switch strings.TrimSpace(row.UomName) { - case "kilogram", "kg", "kilograms", "kilo": - total += row.TotalQty * 1000 - case "gram", "g", "grams": - total += row.TotalQty - default: - total += row.TotalQty - } + if result.TotalQty <= 0 { + return 0, nil } - return total, nil + return result.TotalQty * 1000, nil } func (r *RecordingRepositoryImpl) GetEggSummaryByRecording(tx *gorm.DB, recordingID uint) (totalQty float64, totalWeightGrams float64, err error) { diff --git a/internal/modules/production/recordings/services/recording.service.go b/internal/modules/production/recordings/services/recording.service.go index 5dabad9f..80611109 100644 --- a/internal/modules/production/recordings/services/recording.service.go +++ b/internal/modules/production/recordings/services/recording.service.go @@ -44,6 +44,7 @@ type RecordingFIFOIntegrationService interface { } var recordingStockUsableKey = fifo.UsableKeyRecordingStock +var recordingDepletionUsableKey = fifo.UsableKeyRecordingDepletion type recordingService struct { Log *logrus.Logger @@ -116,7 +117,8 @@ func (s recordingService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti if params.ProjectFlockKandangId != 0 { db = db.Where("project_flock_kandangs_id = ?", params.ProjectFlockKandangId) } - return db.Order("record_datetime DESC").Order("created_at DESC") + db = s.Repository.ApplySearchFilters(db, params.Search) + return db.Order("recordings.record_datetime DESC").Order("recordings.created_at DESC") }) if err != nil { @@ -209,9 +211,6 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent if !isLaying && len(req.Eggs) > 0 { return nil, fiber.NewError(fiber.StatusBadRequest, "Egg details permitted only for laying project flocks") } - if isLaying && len(req.Eggs) == 0 { - return nil, fiber.NewError(fiber.StatusBadRequest, "Egg details are required for laying project flocks") - } if err := s.ensureProductWarehousesExist(c, req.Stocks, req.Depletions, req.Eggs); err != nil { return nil, err @@ -280,10 +279,24 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent } mappedDepletions := recordingutil.MapDepletions(createdRecording.Id, req.Depletions) + if s.FifoSvc != nil && len(mappedDepletions) > 0 { + sourceWarehouseID, err := s.resolvePopulationWarehouseID(ctx, req.ProjectFlockKandangId) + if err != nil { + return err + } + for i := range mappedDepletions { + mappedDepletions[i].SourceProductWarehouseId = &sourceWarehouseID + } + } if err := s.Repository.CreateDepletions(tx, mappedDepletions); err != nil { s.Log.Errorf("Failed to persist depletions: %+v", err) return err } + if s.FifoSvc != nil { + if err := s.consumeRecordingDepletions(ctx, tx, mappedDepletions); err != nil { + return err + } + } mappedEggs := recordingutil.MapEggs(createdRecording.Id, createdRecording.CreatedBy, req.Eggs) if err := s.Repository.CreateEggs(tx, mappedEggs); err != nil { @@ -297,11 +310,7 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent } var warehouseDeltas map[uint]float64 - if s.FifoSvc != nil { - warehouseDeltas = buildWarehouseDeltas(nil, mappedDepletions, nil, nil) - } else { - warehouseDeltas = buildWarehouseDeltas(nil, mappedDepletions, nil, mappedEggs) - } + warehouseDeltas = buildWarehouseDeltas(nil, mappedDepletions, nil, mappedEggs) if err := s.adjustProductWarehouseQuantities(ctx, tx, warehouseDeltas); err != nil { s.Log.Errorf("Failed to adjust product warehouses: %+v", err) return err @@ -407,9 +416,6 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin if !isLaying && len(req.Eggs) > 0 { return fiber.NewError(fiber.StatusBadRequest, "Egg details permitted only for laying project flocks") } - if isLaying && len(req.Eggs) == 0 { - return fiber.NewError(fiber.StatusBadRequest, "Egg details are required for laying project flocks") - } } if hasStockChanges { @@ -431,17 +437,38 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin } if hasDepletionChanges { + if s.FifoSvc != nil { + if err := s.releaseRecordingDepletions(ctx, tx, existingDepletions); err != nil { + return err + } + } + if err := s.Repository.DeleteDepletions(tx, recordingEntity.Id); err != nil { s.Log.Errorf("Failed to clear depletions: %+v", err) return err } mappedDepletions := recordingutil.MapDepletions(recordingEntity.Id, req.Depletions) + if s.FifoSvc != nil && len(mappedDepletions) > 0 { + sourceWarehouseID, err := s.resolvePopulationWarehouseID(ctx, recordingEntity.ProjectFlockKandangId) + if err != nil { + return err + } + for i := range mappedDepletions { + mappedDepletions[i].SourceProductWarehouseId = &sourceWarehouseID + } + } if err := s.Repository.CreateDepletions(tx, mappedDepletions); err != nil { s.Log.Errorf("Failed to update depletions: %+v", err) return err } + if s.FifoSvc != nil { + if err := s.consumeRecordingDepletions(ctx, tx, mappedDepletions); err != nil { + return err + } + } + if err := s.adjustProductWarehouseQuantities(ctx, tx, buildWarehouseDeltas(existingDepletions, mappedDepletions, nil, nil)); err != nil { s.Log.Errorf("Failed to adjust product warehouses for depletions: %+v", err) return err @@ -647,6 +674,11 @@ func (s recordingService) DeleteOne(c *fiber.Ctx, id uint) error { s.Log.Errorf("Failed to list depletions before delete: %+v", err) return err } + if s.FifoSvc != nil { + if err := s.releaseRecordingDepletions(ctx, tx, oldDepletions); err != nil { + return err + } + } oldEggs, err := s.Repository.ListEggs(tx, id) if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { @@ -765,6 +797,46 @@ func (s *recordingService) consumeRecordingStocks(ctx context.Context, tx *gorm. return nil } +func (s *recordingService) consumeRecordingDepletions(ctx context.Context, tx *gorm.DB, depletions []entity.RecordingDepletion) error { + if len(depletions) == 0 || s.FifoSvc == nil { + return nil + } + + for _, depletion := range depletions { + if depletion.Id == 0 { + continue + } + + sourceWarehouseID := uint(0) + if depletion.SourceProductWarehouseId != nil { + sourceWarehouseID = *depletion.SourceProductWarehouseId + } + if sourceWarehouseID == 0 { + return fiber.NewError(fiber.StatusBadRequest, "Source product warehouse tidak ditemukan untuk depletion") + } + + desired := depletion.Qty + depletion.PendingQty + result, err := s.FifoSvc.Consume(ctx, commonSvc.StockConsumeRequest{ + UsableKey: recordingDepletionUsableKey, + UsableID: depletion.Id, + ProductWarehouseID: sourceWarehouseID, + Quantity: desired, + AllowPending: false, + Tx: tx, + }) + if err != nil { + s.Log.Errorf("Failed to consume FIFO stock for recording depletion %d: %+v", depletion.Id, err) + return err + } + + if err := s.Repository.UpdateDepletionPending(tx, depletion.Id, result.PendingQuantity); err != nil { + return err + } + } + + return nil +} + func (s *recordingService) ConsumeRecordingStocks(ctx context.Context, tx *gorm.DB, stocks []entity.RecordingStock) error { return s.consumeRecordingStocks(ctx, tx, stocks) } @@ -796,10 +868,67 @@ func (s *recordingService) releaseRecordingStocks(ctx context.Context, tx *gorm. return nil } +func (s *recordingService) releaseRecordingDepletions(ctx context.Context, tx *gorm.DB, depletions []entity.RecordingDepletion) error { + if len(depletions) == 0 || s.FifoSvc == nil { + return nil + } + + for _, depletion := range depletions { + if depletion.Id == 0 { + continue + } + + sourceWarehouseID := uint(0) + if depletion.SourceProductWarehouseId != nil { + sourceWarehouseID = *depletion.SourceProductWarehouseId + } + if sourceWarehouseID == 0 { + return fiber.NewError(fiber.StatusBadRequest, "Source product warehouse tidak ditemukan untuk depletion") + } + + if err := s.FifoSvc.ReleaseUsage(ctx, commonSvc.StockReleaseRequest{ + UsableKey: recordingDepletionUsableKey, + UsableID: depletion.Id, + Tx: tx, + }); err != nil { + s.Log.Errorf("Failed to release FIFO stock for recording depletion %d: %+v", depletion.Id, err) + return err + } + + if err := s.Repository.UpdateDepletionPending(tx, depletion.Id, 0); err != nil { + return err + } + } + + return nil +} + func (s *recordingService) ReleaseRecordingStocks(ctx context.Context, tx *gorm.DB, stocks []entity.RecordingStock) error { return s.releaseRecordingStocks(ctx, tx, stocks) } +func (s *recordingService) resolvePopulationWarehouseID(ctx context.Context, projectFlockKandangID uint) (uint, error) { + if projectFlockKandangID == 0 { + return 0, fiber.NewError(fiber.StatusBadRequest, "Project flock kandang tidak valid") + } + populations, err := s.ProjectFlockPopulationRepo.GetByProjectFlockKandangID(ctx, projectFlockKandangID) + if err != nil { + s.Log.Errorf("Failed to fetch populations for project_flock_kandang_id=%d: %+v", projectFlockKandangID, err) + return 0, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data populasi") + } + for _, pop := range populations { + if pop.ProductWarehouseId > 0 && pop.TotalQty > 0 { + return pop.ProductWarehouseId, nil + } + } + for _, pop := range populations { + if pop.ProductWarehouseId > 0 { + return pop.ProductWarehouseId, nil + } + } + return 0, fiber.NewError(fiber.StatusBadRequest, "Source product warehouse populasi tidak ditemukan") +} + func buildWarehouseDeltas( oldDepletions, newDepletions []entity.RecordingDepletion, oldEggs, newEggs []entity.RecordingEgg, @@ -941,10 +1070,8 @@ func (s *recordingService) syncRecordingStocks( desired := item.Qty stock.UsageQty = &desired - if item.PendingQty != nil { - pending := *item.PendingQty - stock.PendingQty = &pending - } + zero := 0.0 + stock.PendingQty = &zero stocksToConsume = append(stocksToConsume, stock) } @@ -990,43 +1117,20 @@ func ensureRecordingEggsUnused(eggs []entity.RecordingEgg) error { } func stocksMatch(existing []entity.RecordingStock, incoming []validation.Stock) bool { - hasPending := false - for _, item := range incoming { - if item.PendingQty != nil { - hasPending = true - break - } - } - existingUsage := make(map[uint]float64) - existingTotal := make(map[uint]float64) for _, stock := range existing { var usage float64 - var pending float64 if stock.UsageQty != nil { usage = *stock.UsageQty } - if stock.PendingQty != nil { - pending = *stock.PendingQty - } existingUsage[stock.ProductWarehouseId] += usage - existingTotal[stock.ProductWarehouseId] += usage + pending } incomingUsage := make(map[uint]float64) - incomingTotal := make(map[uint]float64) for _, item := range incoming { - var pending float64 - if item.PendingQty != nil { - pending = *item.PendingQty - } incomingUsage[item.ProductWarehouseId] += item.Qty - incomingTotal[item.ProductWarehouseId] += item.Qty + pending } - if hasPending { - return floatMapsMatch(existingTotal, incomingTotal) - } return floatMapsMatch(existingUsage, incomingUsage) } @@ -1224,7 +1328,7 @@ func (s *recordingService) computeAndUpdateMetrics(ctx context.Context, tx *gorm var eggMass float64 if remainingChick > 0 && totalEggWeightGrams > 0 { - eggMass = (totalEggWeightGrams / remainingChick) * 1000 + eggMass = totalEggWeightGrams / remainingChick updates["egg_mass"] = eggMass recording.EggMass = &eggMass } else { @@ -1234,7 +1338,7 @@ func (s *recordingService) computeAndUpdateMetrics(ctx context.Context, tx *gorm var eggWeight float64 if totalEggQty > 0 && totalEggWeightGrams > 0 { - eggWeight = (totalEggWeightGrams / totalEggQty) * 1000 + eggWeight = totalEggWeightGrams / totalEggQty updates["egg_weight"] = eggWeight recording.EggWeight = &eggWeight } else { diff --git a/internal/modules/production/recordings/validations/recording.validation.go b/internal/modules/production/recordings/validations/recording.validation.go index 8b4eab57..dbbd4f30 100644 --- a/internal/modules/production/recordings/validations/recording.validation.go +++ b/internal/modules/production/recordings/validations/recording.validation.go @@ -2,9 +2,8 @@ package validation type ( Stock struct { - ProductWarehouseId uint `json:"product_warehouse_id" validate:"required,number,min=1"` - Qty float64 `json:"qty" validate:"required,gte=0"` - PendingQty *float64 `json:"pending_qty,omitempty" validate:"omitempty,gte=0"` + ProductWarehouseId uint `json:"product_warehouse_id" validate:"required,number,min=1"` + Qty float64 `json:"qty" validate:"required,gte=0"` } Depletion struct { @@ -20,23 +19,24 @@ type ( ) type Create struct { - ProjectFlockKandangId uint `json:"project_flock_kandang_id" validate:"required,number,min=1"` - RecordDate *string `json:"record_date,omitempty" validate:"omitempty,datetime=2006-01-02"` - Stocks []Stock `json:"stocks" validate:"dive"` - Depletions []Depletion `json:"depletions" validate:"dive"` - Eggs []Egg `json:"eggs" validate:"omitempty,dive"` + ProjectFlockKandangId uint `json:"project_flock_kandang_id" validate:"required,number,min=1"` + RecordDate *string `json:"record_date,omitempty" validate:"omitempty,datetime=2006-01-02"` + Stocks []Stock `json:"stocks" validate:"dive"` + Depletions []Depletion `json:"depletions,omitempty" validate:"omitempty,dive"` + Eggs []Egg `json:"eggs" validate:"omitempty,dive"` } type Update struct { - Stocks []Stock `json:"stocks,omitempty" validate:"omitempty,dive"` - Depletions []Depletion `json:"depletions,omitempty" validate:"omitempty,dive"` - Eggs []Egg `json:"eggs,omitempty" validate:"omitempty,dive"` + Stocks []Stock `json:"stocks,omitempty" validate:"omitempty,dive"` + Depletions []Depletion `json:"depletions,omitempty" validate:"omitempty,dive"` + Eggs []Egg `json:"eggs,omitempty" validate:"omitempty,dive"` } type Query struct { - Page int `query:"page" validate:"omitempty,number,min=1"` - Limit int `query:"limit" validate:"omitempty,number,min=1,max=100"` - ProjectFlockKandangId uint `query:"project_flock_kandang_id" validate:"omitempty,number,min=1"` + Page int `query:"page" validate:"omitempty,number,min=1"` + Limit int `query:"limit" validate:"omitempty,number,min=1,max=100"` + ProjectFlockKandangId uint `query:"project_flock_kandang_id" validate:"omitempty,number,min=1"` + Search string `query:"search" validate:"omitempty,max=50"` } type Approve struct { diff --git a/internal/modules/production/uniformities/services/uniformity.service.go b/internal/modules/production/uniformities/services/uniformity.service.go index 92db84a3..41611ac3 100644 --- a/internal/modules/production/uniformities/services/uniformity.service.go +++ b/internal/modules/production/uniformities/services/uniformity.service.go @@ -345,7 +345,52 @@ func (s *uniformityService) CreateOne(c *fiber.Ctx, req *validation.Create, file ); err != nil { return nil, err } - if err := s.ensureUniqueUniformity(c.Context(), 0, req.ProjectFlockKandangId, req.Week, &uniformDate); err != nil { + + pfk, err := s.ProjectFlockKandangRepo.GetByID(c.Context(), req.ProjectFlockKandangId) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Project Flock Kandang not found") + } + return nil, err + } + category := strings.TrimSpace(pfk.ProjectFlock.Category) + if s.ProductionStandardRepo != nil && pfk.ProjectFlock.ProductionStandardId != 0 { + if standard, err := s.ProductionStandardRepo.GetByID(c.Context(), pfk.ProjectFlock.ProductionStandardId, nil); err == nil { + if strings.TrimSpace(standard.ProjectCategory) != "" { + category = standard.ProjectCategory + } + } + } + weekBase := 1 + if strings.EqualFold(category, string(utils.ProjectFlockCategoryLaying)) { + weekBase = 18 + } + if req.Week < weekBase { + if weekBase == 18 { + return nil, fiber.NewError(fiber.StatusBadRequest, "week must start from 18 for laying projects") + } + return nil, fiber.NewError(fiber.StatusBadRequest, "week must start from 1 for growing projects") + } + + var latestWeek int + if err := s.Repository.DB().WithContext(c.Context()). + Model(&entity.ProjectFlockKandangUniformity{}). + Where("project_flock_kandang_id = ? AND deleted_at IS NULL", req.ProjectFlockKandangId). + Select("COALESCE(MAX(week), 0)"). + Scan(&latestWeek).Error; err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to validate uniformity week sequence") + } + if latestWeek == 0 && req.Week != weekBase { + if weekBase == 18 { + return nil, fiber.NewError(fiber.StatusBadRequest, "week must start from 18 for laying projects") + } + return nil, fiber.NewError(fiber.StatusBadRequest, "week must start from 1 for growing projects") + } + if latestWeek > 0 && req.Week > latestWeek+1 { + return nil, fiber.NewError(fiber.StatusBadRequest, "week must be sequential without skipping") + } + + if err := s.ensureUniqueUniformity(c.Context(), 0, req.ProjectFlockKandangId, req.Week); err != nil { return nil, err } @@ -487,8 +532,35 @@ func (s uniformityService) UpdateOne(c *fiber.Ctx, req *validation.Update, id ui if req.ProjectFlockKandangId != nil { targetPFKID = *req.ProjectFlockKandangId } + if targetPFKID != 0 && targetWeek > 0 { + pfk, err := s.ProjectFlockKandangRepo.GetByID(c.Context(), targetPFKID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Project Flock Kandang not found") + } + return nil, err + } + category := strings.TrimSpace(pfk.ProjectFlock.Category) + if s.ProductionStandardRepo != nil && pfk.ProjectFlock.ProductionStandardId != 0 { + if standard, err := s.ProductionStandardRepo.GetByID(c.Context(), pfk.ProjectFlock.ProductionStandardId, nil); err == nil { + if strings.TrimSpace(standard.ProjectCategory) != "" { + category = standard.ProjectCategory + } + } + } + weekBase := 1 + if strings.EqualFold(category, string(utils.ProjectFlockCategoryLaying)) { + weekBase = 18 + } + if targetWeek < weekBase { + if weekBase == 18 { + return nil, fiber.NewError(fiber.StatusBadRequest, "week must start from 18 for laying projects") + } + return nil, fiber.NewError(fiber.StatusBadRequest, "week must start from 1 for growing projects") + } + } if targetDate != nil { - if err := s.ensureUniqueUniformity(c.Context(), id, targetPFKID, targetWeek, targetDate); err != nil { + if err := s.ensureUniqueUniformity(c.Context(), id, targetPFKID, targetWeek); err != nil { return nil, err } } @@ -604,7 +676,7 @@ func (s uniformityService) UpdateOne(c *fiber.Ctx, req *validation.Update, id ui return s.GetOne(c, id) } -func (s *uniformityService) ensureUniqueUniformity(ctx context.Context, id uint, projectFlockKandangID uint, week int, uniformDate *time.Time) error { +func (s *uniformityService) ensureUniqueUniformity(ctx context.Context, id uint, projectFlockKandangID uint, week int) error { if projectFlockKandangID == 0 || week == 0 { return nil } diff --git a/internal/modules/repports/dto/repportPurchase.dto.go b/internal/modules/repports/dto/repportPurchase.dto.go index 830a076f..f776121b 100644 --- a/internal/modules/repports/dto/repportPurchase.dto.go +++ b/internal/modules/repports/dto/repportPurchase.dto.go @@ -27,12 +27,12 @@ type PurchaseSupplierRowDTO struct { } type PurchaseSupplierSummaryDTO struct { - TotalQty float64 `json:"total_qty"` - TotalPurchaseValue float64 `json:"total_purchase_value"` - TotalTransportValue float64 `json:"total_transport_value"` - TotalAmount float64 `json:"total_amount"` - TotalUnitPrice float64 `json:"total_unit_price"` - TotalTransportUnitPrice float64 `json:"total_transport_unit_price"` + TotalQty float64 `json:"total_qty"` + TotalPurchaseValue float64 `json:"total_purchase_value"` + TotalTransportValue float64 `json:"total_transport_value"` + TotalAmount float64 `json:"total_amount"` + TotalUnitPrice float64 `json:"total_unit_price"` + TotalTransportUnitPrice float64 `json:"total_transport_unit_price"` } type PurchaseSupplierDTO struct { @@ -122,11 +122,6 @@ func ToPurchaseSupplierDTO(supplier entity.Supplier, items []entity.PurchaseItem rows := make([]PurchaseSupplierRowDTO, 0, len(items)) summary := PurchaseSupplierSummaryDTO{} - var unitPriceSum float64 - var unitPriceCount int - var transportUnitPriceSum float64 - var transportUnitPriceCount int - for i := range items { row := ToPurchaseSupplierRowDTO(&items[i]) rows = append(rows, row) @@ -136,19 +131,16 @@ func ToPurchaseSupplierDTO(supplier entity.Supplier, items []entity.PurchaseItem summary.TotalTransportValue += row.TransportValue summary.TotalAmount += row.TotalAmount - unitPriceSum += row.UnitPrice - unitPriceCount++ - - transportUnitPriceSum += row.TransportUnitPrice - transportUnitPriceCount++ } - if unitPriceCount > 0 { - summary.TotalUnitPrice = math.Round(unitPriceSum / float64(unitPriceCount)) + if summary.TotalQty > 0 { + avg := summary.TotalPurchaseValue / summary.TotalQty + summary.TotalUnitPrice = math.Round(avg) } - if transportUnitPriceCount > 0 { - summary.TotalTransportUnitPrice = math.Round(transportUnitPriceSum / float64(transportUnitPriceCount)) + if summary.TotalQty > 0 { + avg := summary.TotalTransportValue / summary.TotalQty + summary.TotalTransportUnitPrice = math.Round(avg) } return PurchaseSupplierDTO{ diff --git a/internal/modules/repports/repositories/debt_supplier.repository.go b/internal/modules/repports/repositories/debt_supplier.repository.go index e8f548d6..977db610 100644 --- a/internal/modules/repports/repositories/debt_supplier.repository.go +++ b/internal/modules/repports/repositories/debt_supplier.repository.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "strings" + "time" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/repports/validations" @@ -17,6 +18,8 @@ type DebtSupplierRepository interface { GetPurchasesBySuppliers(ctx context.Context, supplierIDs []uint, filters *validation.DebtSupplierQuery) ([]entity.Purchase, error) GetPaymentsBySuppliers(ctx context.Context, supplierIDs []uint, filters *validation.DebtSupplierQuery) ([]entity.Payment, error) GetPaymentTotalsByReferences(ctx context.Context, supplierIDs []uint, references []string) (map[string]float64, error) + GetPaymentSummariesByReferences(ctx context.Context, supplierIDs []uint, references []string) (map[string]PaymentReferenceSummary, error) + GetInitialBalanceTotals(ctx context.Context, supplierIDs []uint) (map[uint]float64, error) GetPurchaseTotalsBeforeDate(ctx context.Context, supplierIDs []uint, filters *validation.DebtSupplierQuery) (map[uint]float64, error) GetPaymentTotalsBeforeDate(ctx context.Context, supplierIDs []uint, filters *validation.DebtSupplierQuery) (map[uint]float64, error) } @@ -25,6 +28,11 @@ type debtSupplierRepositoryImpl struct { db *gorm.DB } +type PaymentReferenceSummary struct { + Total float64 + LatestPaymentDate time.Time +} + func NewDebtSupplierRepository(db *gorm.DB) DebtSupplierRepository { return &debtSupplierRepositoryImpl{db: db} } @@ -167,7 +175,8 @@ func (r *debtSupplierRepositoryImpl) GetPaymentsBySuppliers(ctx context.Context, Model(&entity.Payment{}). Where("party_type = ?", string(utils.PaymentPartySupplier)). Where("direction = ?", "OUT"). - Where("party_id IN ?", supplierIDs) + Where("party_id IN ?", supplierIDs). + Where("transaction_type <> ?", string(utils.TransactionTypeSaldoAwal)) if strings.TrimSpace(filters.StartDate) != "" { if dateFrom, err := utils.ParseDateString(filters.StartDate); err == nil { @@ -238,6 +247,7 @@ func (r *debtSupplierRepositoryImpl) GetPaymentTotalsByReferences(ctx context.Co Where("direction = ?", "OUT"). Where("party_id IN ?", supplierIDs). Where("reference_number IN ?", references). + Where("transaction_type <> ?", string(utils.TransactionTypeSaldoAwal)). Group("reference_number"). Scan(&rows).Error; err != nil { return nil, err @@ -254,6 +264,75 @@ func (r *debtSupplierRepositoryImpl) GetPaymentTotalsByReferences(ctx context.Co return result, nil } +func (r *debtSupplierRepositoryImpl) GetPaymentSummariesByReferences(ctx context.Context, supplierIDs []uint, references []string) (map[string]PaymentReferenceSummary, error) { + if len(supplierIDs) == 0 || len(references) == 0 { + return map[string]PaymentReferenceSummary{}, nil + } + + type paymentRow struct { + ReferenceNumber *string `gorm:"column:reference_number"` + Total float64 `gorm:"column:total"` + LatestPaymentDate time.Time `gorm:"column:latest_payment_date"` + } + + rows := make([]paymentRow, 0) + if err := r.db.WithContext(ctx). + Model(&entity.Payment{}). + Select("reference_number, SUM(nominal) AS total, MAX(payment_date) AS latest_payment_date"). + Where("party_type = ?", string(utils.PaymentPartySupplier)). + Where("direction = ?", "OUT"). + Where("party_id IN ?", supplierIDs). + Where("reference_number IN ?", references). + Where("transaction_type <> ?", string(utils.TransactionTypeSaldoAwal)). + Group("reference_number"). + Scan(&rows).Error; err != nil { + return nil, err + } + + result := make(map[string]PaymentReferenceSummary, len(rows)) + for _, row := range rows { + if row.ReferenceNumber == nil || strings.TrimSpace(*row.ReferenceNumber) == "" { + continue + } + result[*row.ReferenceNumber] = PaymentReferenceSummary{ + Total: row.Total, + LatestPaymentDate: row.LatestPaymentDate, + } + } + + return result, nil +} + +func (r *debtSupplierRepositoryImpl) GetInitialBalanceTotals(ctx context.Context, supplierIDs []uint) (map[uint]float64, error) { + if len(supplierIDs) == 0 { + return map[uint]float64{}, nil + } + + type balanceRow struct { + SupplierID uint `gorm:"column:supplier_id"` + Total float64 `gorm:"column:total"` + } + + rows := make([]balanceRow, 0) + if err := r.db.WithContext(ctx). + Model(&entity.Payment{}). + Select("party_id AS supplier_id, SUM(nominal) AS total"). + Where("party_type = ?", string(utils.PaymentPartySupplier)). + Where("party_id IN ?", supplierIDs). + Where("transaction_type = ?", string(utils.TransactionTypeSaldoAwal)). + Group("party_id"). + Scan(&rows).Error; err != nil { + return nil, err + } + + result := make(map[uint]float64, len(rows)) + for _, row := range rows { + result[row.SupplierID] = row.Total + } + + return result, nil +} + func (r *debtSupplierRepositoryImpl) GetPurchaseTotalsBeforeDate(ctx context.Context, supplierIDs []uint, filters *validation.DebtSupplierQuery) (map[uint]float64, error) { if len(supplierIDs) == 0 || strings.TrimSpace(filters.StartDate) == "" { return map[uint]float64{}, nil @@ -313,6 +392,7 @@ func (r *debtSupplierRepositoryImpl) GetPaymentTotalsBeforeDate(ctx context.Cont Where("party_type = ?", string(utils.PaymentPartySupplier)). Where("direction = ?", "OUT"). Where("party_id IN ?", supplierIDs). + Where("transaction_type <> ?", string(utils.TransactionTypeSaldoAwal)). Where("DATE(payment_date) < ?", dateFrom). Group("party_id"). Scan(&rows).Error; err != nil { diff --git a/internal/modules/repports/repositories/purchase_supplier.repository.go b/internal/modules/repports/repositories/purchase_supplier.repository.go index 979623fc..6a07c555 100644 --- a/internal/modules/repports/repositories/purchase_supplier.repository.go +++ b/internal/modules/repports/repositories/purchase_supplier.repository.go @@ -25,6 +25,21 @@ func NewPurchaseSupplierRepository(db *gorm.DB) PurchaseSupplierRepository { return &purchaseSupplierRepositoryImpl{db: db} } +func (r *purchaseSupplierRepositoryImpl) latestPurchaseApproval(ctx context.Context) *gorm.DB { + return r.db.WithContext(ctx). + Table("approvals AS a"). + Select("a.approvable_id, a.step_number, a.action"). + Joins(` + JOIN ( + SELECT approvable_id, MAX(action_at) AS latest_action_at + FROM approvals + WHERE approvable_type = ? + GROUP BY approvable_id + ) AS la ON la.approvable_id = a.approvable_id AND la.latest_action_at = a.action_at`, + string(utils.ApprovalWorkflowPurchase), + ) +} + func (r *purchaseSupplierRepositoryImpl) baseSupplierQuery(ctx context.Context, filters *validation.PurchaseSupplierQuery) *gorm.DB { dateColumn := "purchase_items.received_date" switch strings.ToLower(strings.TrimSpace(filters.FilterBy)) { @@ -34,10 +49,16 @@ func (r *purchaseSupplierRepositoryImpl) baseSupplierQuery(ctx context.Context, dateColumn = "purchase_items.received_date" } + latestApproval := r.latestPurchaseApproval(ctx) + db := r.db.WithContext(ctx). Model(&entity.Supplier{}). Joins("JOIN purchases ON purchases.supplier_id = suppliers.id"). - Joins("JOIN purchase_items ON purchase_items.purchase_id = purchases.id") + Joins("JOIN purchase_items ON purchase_items.purchase_id = purchases.id"). + Joins("JOIN (?) AS la ON la.approvable_id = purchases.id", latestApproval). + Where("la.step_number >= ?", uint16(utils.PurchaseStepReceiving)). + Where("(la.action IS NULL OR la.action != ?)", string(entity.ApprovalActionRejected)). + Where("purchase_items.received_date IS NOT NULL") if filters.SupplierId > 0 { db = db.Where("suppliers.id = ?", filters.SupplierId) @@ -152,7 +173,11 @@ func (r *purchaseSupplierRepositoryImpl) GetItemsBySuppliers(ctx context.Context Preload("ExpenseNonstock.Expense"). Preload("ExpenseNonstock.Expense.Supplier"). Joins("JOIN purchases ON purchases.id = purchase_items.purchase_id"). - Where("purchases.supplier_id IN ?", supplierIDs) + Joins("JOIN (?) AS la ON la.approvable_id = purchases.id", r.latestPurchaseApproval(ctx)). + Where("purchases.supplier_id IN ?", supplierIDs). + Where("la.step_number >= ?", uint16(utils.PurchaseStepReceiving)). + Where("(la.action IS NULL OR la.action != ?)", string(entity.ApprovalActionRejected)). + Where("purchase_items.received_date IS NOT NULL") if filters.ProductId > 0 { db = db.Where("purchase_items.product_id = ?", filters.ProductId) diff --git a/internal/modules/repports/services/repport.service.go b/internal/modules/repports/services/repport.service.go index 41a66731..a0e0f350 100644 --- a/internal/modules/repports/services/repport.service.go +++ b/internal/modules/repports/services/repport.service.go @@ -1129,6 +1129,17 @@ func (s *repportService) GetDebtSupplier(c *fiber.Ctx, params *validation.DebtSu return nil, 0, err } + initialBalanceTotals, err := s.DebtSupplierRepo.GetInitialBalanceTotals(c.Context(), supplierIDs) + if err != nil { + return nil, 0, err + } + + references := collectDebtSupplierReferences(purchases) + paymentSummaries, err := s.DebtSupplierRepo.GetPaymentSummariesByReferences(c.Context(), supplierIDs, references) + if err != nil { + return nil, 0, err + } + location, err := time.LoadLocation("Asia/Jakarta") if err != nil { return nil, 0, fiber.NewError(fiber.StatusInternalServerError, "failed to load timezone configuration") @@ -1150,7 +1161,7 @@ func (s *repportService) GetDebtSupplier(c *fiber.Ctx, params *validation.DebtSu continue } - initialBalance := initialPaymentTotals[supplierID] - initialPurchaseTotals[supplierID] + initialBalance := initialBalanceTotals[supplierID] + (initialPaymentTotals[supplierID] - initialPurchaseTotals[supplierID]) items := purchasesBySupplier[supplierID] paymentItems := paymentsBySupplier[supplierID] total := dto.DebtSupplierTotalDTO{} @@ -1158,6 +1169,16 @@ func (s *repportService) GetDebtSupplier(c *fiber.Ctx, params *validation.DebtSu combinedRows := make([]debtSupplierRowItem, 0, len(items)+len(paymentItems)) for _, purchase := range items { row := buildDebtSupplierRow(purchase, now, location) + if reference := resolveDebtSupplierReference(purchase); reference != "" { + if summary, ok := paymentSummaries[reference]; ok { + if isDebtSupplierPaid(row.TotalPrice, summary.Total) { + row.Status = "Lunas" + if !summary.LatestPaymentDate.IsZero() { + row.Aging = calculateDebtSupplierAging(purchase, summary.LatestPaymentDate, location) + } + } + } + } sortTime := resolveDebtSupplierSortTime(purchase, params.FilterBy, location) combinedRows = append(combinedRows, debtSupplierRowItem{ Row: row, @@ -1374,6 +1395,55 @@ func resolveDebtSupplierSortTime(purchase entity.Purchase, filterBy string, loc return purchase.CreatedAt.In(loc) } +func collectDebtSupplierReferences(purchases []entity.Purchase) []string { + if len(purchases) == 0 { + return nil + } + seen := make(map[string]struct{}, len(purchases)) + result := make([]string, 0, len(purchases)) + for _, purchase := range purchases { + ref := resolveDebtSupplierReference(purchase) + if ref == "" { + continue + } + if _, ok := seen[ref]; ok { + continue + } + seen[ref] = struct{}{} + result = append(result, ref) + } + return result +} + +func resolveDebtSupplierReference(purchase entity.Purchase) string { + if purchase.PoNumber != nil { + if ref := strings.TrimSpace(*purchase.PoNumber); ref != "" { + return ref + } + } + if ref := strings.TrimSpace(purchase.PrNumber); ref != "" { + return ref + } + return "" +} + +func isDebtSupplierPaid(totalPrice, paymentTotal float64) bool { + if totalPrice <= 0 { + return true + } + return paymentTotal >= totalPrice-0.000001 +} + +func calculateDebtSupplierAging(purchase entity.Purchase, endDate time.Time, loc *time.Location) int { + prDate := purchase.CreatedAt.In(loc) + startDate := time.Date(prDate.Year(), prDate.Month(), prDate.Day(), 0, 0, 0, 0, loc) + stopDate := time.Date(endDate.Year(), endDate.Month(), endDate.Day(), 0, 0, 0, 0, loc) + if stopDate.Before(startDate) { + return 0 + } + return int(stopDate.Sub(startDate).Hours() / 24) +} + func (s *repportService) GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangResponseData, *dto.HppPerKandangMetaDTO, error) { params, filters, err := s.parseHppPerKandangQuery(ctx) if err != nil { diff --git a/internal/utils/fifo/constants.go b/internal/utils/fifo/constants.go index 076d960d..840ba8e1 100644 --- a/internal/utils/fifo/constants.go +++ b/internal/utils/fifo/constants.go @@ -2,18 +2,19 @@ package fifo const ( // Usable Keys - UsableKeyRecordingStock UsableKey = "RECORDING_STOCK" - UsableKeyProjectChickin UsableKey = "PROJECT_CHICKIN" - UsableKeyMarketingDelivery UsableKey = "MARKETING_DELIVERY" - UsableKeyTransferToLayingOut UsableKey = "TRANSFERTOLAYING_OUT" - UsableKeyStockTransferOut UsableKey = "STOCK_TRANSFER_OUT" - UsableKeyAdjustmentOut UsableKey = "ADJUSTMENT_OUT" + UsableKeyRecordingStock UsableKey = "RECORDING_STOCK" + UsableKeyRecordingDepletion UsableKey = "RECORDING_DEPLETION" + UsableKeyProjectChickin UsableKey = "PROJECT_CHICKIN" + UsableKeyMarketingDelivery UsableKey = "MARKETING_DELIVERY" + UsableKeyTransferToLayingOut UsableKey = "TRANSFERTOLAYING_OUT" + UsableKeyStockTransferOut UsableKey = "STOCK_TRANSFER_OUT" + UsableKeyAdjustmentOut UsableKey = "ADJUSTMENT_OUT" // Stockable Keys - StockableKeyTransferToLayingIn StockableKey = "TRANSFERTOLAYING_IN" - StockableKeyStockTransferIn StockableKey = "STOCK_TRANSFER_IN" - StockableKeyAdjustmentIn StockableKey = "ADJUSTMENT_IN" - StockableKeyPurchaseItems StockableKey = "PURCHASE_ITEMS" + StockableKeyTransferToLayingIn StockableKey = "TRANSFERTOLAYING_IN" + StockableKeyStockTransferIn StockableKey = "STOCK_TRANSFER_IN" + StockableKeyAdjustmentIn StockableKey = "ADJUSTMENT_IN" + StockableKeyPurchaseItems StockableKey = "PURCHASE_ITEMS" StockableKeyProjectFlockPopulation StockableKey = "PROJECT_FLOCK_POPULATION" - StockableKeyRecordingEgg StockableKey = "RECORDING_EGG" + StockableKeyRecordingEgg StockableKey = "RECORDING_EGG" ) diff --git a/internal/utils/recording/util.recording.go b/internal/utils/recording/util.recording.go index 91c9cc4b..f40818bf 100644 --- a/internal/utils/recording/util.recording.go +++ b/internal/utils/recording/util.recording.go @@ -14,15 +14,10 @@ func MapStocks(recordingID uint, items []validation.Stock) []entity.RecordingSto for _, item := range items { usagePtr := new(float64) *usagePtr = item.Qty - pending := item.PendingQty - if pending == nil { - pending = new(float64) - } result = append(result, entity.RecordingStock{ RecordingId: recordingID, ProductWarehouseId: item.ProductWarehouseId, UsageQty: usagePtr, - PendingQty: pending, }) } return result