diff --git a/.gitignore b/.gitignore index 4a814ebe..3522e1f6 100644 --- a/.gitignore +++ b/.gitignore @@ -9,11 +9,12 @@ main bin/ *.exe *.out - +.air.toml Makefile docker-compose.local.yml docker-compose.yaml Dockerfile +Dockerfile.local .gitlab-ci.yml # Go build cache .gocache/ diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index c99f940f..3cca12bf 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -25,7 +25,7 @@ variables: COMPOSE_FILE: "docker-compose.yaml" # ========================= -# BUILD (AUTO) +# BUILD (AUTO) # ========================= build_production: stage: build @@ -87,7 +87,7 @@ migrate_production: # ========================= -# DEPLOY (AUTO) +# DEPLOY (AUTO) # ========================= deploy_production: stage: deploy 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. 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/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/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/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/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/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/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/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/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/middleware/permissions.go b/internal/middleware/permissions.go index 10741bff..05c80d54 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" @@ -22,18 +23,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_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" + 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" @@ -50,6 +52,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 +153,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" @@ -237,3 +240,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/closings/controllers/closing.controller.go b/internal/modules/closings/controllers/closing.controller.go index 6ab2d398..a43687ac 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, } } @@ -78,6 +80,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") @@ -86,7 +118,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 } @@ -108,12 +150,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,19 +160,60 @@ 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), }) } 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") - 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 } @@ -158,13 +236,16 @@ 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 query.Page < 1 || query.Limit < 1 { - return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0") + 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.Type != validation.SapronakTypeIncoming && query.Type != validation.SapronakTypeOutgoing { @@ -191,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", "") @@ -254,7 +380,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 } @@ -268,6 +394,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") @@ -338,7 +492,18 @@ 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..1c191d29 100644 --- a/internal/modules/closings/dto/closing.dto.go +++ b/internal/modules/closings/dto/closing.dto.go @@ -59,39 +59,65 @@ 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"` 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"` - FcrStd float64 `json:"fcr_std"` - FcrAct float64 `json:"fcr_act"` - DeffFcr float64 `json:"deff_fcr"` - Awg float64 `json:"awg"` + 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"` + EggMass *float64 `json:"egg_mass,omitempty"` + EggMassStd float64 `json:"egg_mass_std"` + EggWeight *float64 `json:"egg_weight,omitempty"` + EggWeightStd float64 `json:"egg_weight_std"` + HenHouseAct *float64 `json:"hen_housed_act,omitempty"` + HenHouseStd float64 `json:"hen_housed_std"` } type ClosingSalesGroupDTO struct { @@ -164,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/dto/closingKeuangan.dto.go b/internal/modules/closings/dto/closingKeuangan.dto.go index 08bfb5fc..6ca19d5c 100644 --- a/internal/modules/closings/dto/closingKeuangan.dto.go +++ b/internal/modules/closings/dto/closingKeuangan.dto.go @@ -1,134 +1,103 @@ package dto -import ( - "slices" - "strings" +// === CLOSING KEUANGAN CODES === - "gitlab.com/mbugroup/lti-api.git/internal/entities" - "gitlab.com/mbugroup/lti-api.git/internal/utils" -) +// Closing HPP Codes +type ClosingHPPCode string -// === 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 " + HPPCodePakan ClosingHPPCode = "PAKAN" + HPPCodeOVK ClosingHPPCode = "OVK" + HPPCodeDOC ClosingHPPCode = "DOC" + HPPCodeDepresiasi ClosingHPPCode = "DEPRESIASI" + HPPCodeOverhead ClosingHPPCode = "OVERHEAD" + HPPCodeEkspedisi ClosingHPPCode = "EKSPEDISI" ) -// === CONTEXT STRUCTS === +// Closing Profit Loss Codes +type ClosingProfitLossCode string -type CalculationContext struct { - TotalPopulation float64 - TotalWeightProduced float64 - TotalEggWeightKg float64 - TotalDepletion float64 - TotalWeightSold float64 - ActualPopulation float64 -} +const ( + PLCodeSales ClosingProfitLossCode = "SALES" + PLCodeSapronak ClosingProfitLossCode = "SAPRONAK" + PLCodeOverhead ClosingProfitLossCode = "OVERHEAD" + PLCodeEkspedisi ClosingProfitLossCode = "EKSPEDISI" +) -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 === +// === 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"` } -type Comparison struct { +// 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"` } -// === 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"` - Comparison `json:"-"` +// 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"` } -type HppPurchasesSection struct { - Hpp []HppGroup `json:"hpp"` - SummaryHpp SummaryHpp `json:"summary_hpp"` +// HPPSection represents HPP data section +type HPPSection struct { + Items []HPPItem `json:"items"` + Summary HPPSummary `json:"summary"` } -// === PROFIT LOSS PACKAGE === - -type PLItem struct { - Type string `json:"type"` - FinancialMetrics +// 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"` } -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"` +// 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 { - Data ProfitLossData `json:"data"` + Items []ProfitLossItem `json:"items"` + Summary ProfitLossSummary `json:"summary"` } -// === RESPONSE DTO (ROOT) === +// ClosingKeuanganData represents the main data structure +type ClosingKeuanganData struct { + HPP HPPSection `json:"hpp"` + ProfitLoss ProfitLossSection `json:"profit_loss"` +} -type ReportResponse struct { - HppPurchases HppPurchasesSection `json:"hpp_purchases"` - 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, @@ -137,453 +106,80 @@ func ToFinancialMetrics(rpPerBird, rpPerKg, amount float64) FinancialMetrics { } } -func ToComparison(budgeting, realization FinancialMetrics) Comparison { - return Comparison{ +// 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, } } -// === 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), - ), +// 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, } } -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), - ), +// ToHPPSection creates HPP section +func ToHPPSection(items []HPPItem, summary HPPSummary) HPPSection { + return HPPSection{ + Items: items, + Summary: summary, } } -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, +// 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, } } -// === 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, - Comparison: ToComparison( - ToFinancialMetrics(budgetRpPerBird, budgetRpPerKg, totalBudget), - 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, +// ToProfitLossSummary creates Profit & Loss summary +func ToProfitLossSummary(grossProfit, subTotal, netProfit FinancialMetrics) ProfitLossSummary { + return ProfitLossSummary{ + GrossProfit: grossProfit, + SubTotal: subTotal, + NetProfit: netProfit, } } -// === 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 { +// ToProfitLossSection creates Profit & Loss section +func ToProfitLossSection(items []ProfitLossItem, summary ProfitLossSummary) ProfitLossSection { return ProfitLossSection{ - Data: ToProfitLossData(penjualanItems, pembelianItems, overheadItems, ekspedisiItems), + Items: items, + Summary: summary, } } -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, +// ToClosingKeuanganData creates complete closing keuangan data +func ToClosingKeuanganData(hpp HPPSection, profitLoss ProfitLossSection) ClosingKeuanganData { + return ClosingKeuanganData{ + HPP: hpp, + 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) +// ToSuccessClosingKeuanganResponse creates success response +func ToSuccessClosingKeuanganResponse(data ClosingKeuanganData) ClosingKeuanganResponse { + return ClosingKeuanganResponse{ + Code: 200, + Status: "success", + Message: "Get closing keuangan successfully", + Data: data, } } - -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/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/dto/closingOverhead.dto.go b/internal/modules/closings/dto/closingOverhead.dto.go index 71975da1..4730474a 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, projectFlockKandangCountMap map[uint]int) OverheadListDTO { overheadsByNonstockID := make(map[uint]*OverheadDTO) latestDateByNonstockID := make(map[uint]string) @@ -82,9 +84,20 @@ 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) + + // Budget division: per kandang view only + 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 +110,40 @@ 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) + 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 { + 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) + } + } + } + } + + overheadsByNonstockID[nonstockID].ActualQuantity += qty + overheadsByNonstockID[nonstockID].ActualTotalAmount += totalAmount if overheadsByNonstockID[nonstockID].ItemName == "" { itemName, itemUOM := getItemInfo(realizations[i].ExpenseNonstock.Nonstock) @@ -146,7 +191,26 @@ 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 { + projectFlocks := parseProjectFlockIDsFromJSON(projectFlockJSON) + if len(projectFlocks) == 0 { + return 1 + } + return len(projectFlocks) +} func getItemInfo(nonstock *entity.Nonstock) (string, string) { if nonstock != nil && nonstock.Id != 0 { diff --git a/internal/modules/closings/dto/closingSapronak.dto.go b/internal/modules/closings/dto/closingSapronak.dto.go index 768c727e..ad66d5a7 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 { @@ -201,18 +212,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 +274,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/module.go b/internal/modules/closings/module.go index c89e6125..1079663d 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" @@ -24,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) @@ -33,13 +35,16 @@ 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, 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 9d08d083..120c3e5c 100644 --- a/internal/modules/closings/repositories/closing.repository.go +++ b/internal/modules/closings/repositories/closing.repository.go @@ -17,7 +17,9 @@ 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) SumMarketingWeightAndQtyByProjectFlockKandangIDs(ctx context.Context, projectFlockKandangIDs []uint) (float64, float64, float64, error) SumMarketingWeightAndQtyByProjectFlockKandangIDsAndFlagNames(ctx context.Context, projectFlockKandangIDs []uint, flagNames []string) (float64, float64, float64, error) @@ -32,7 +34,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) - GetActualUsageCostByProjectFlockID(ctx context.Context, projectFlockID uint) ([]ActualUsageCostRow, error) + FetchSapronakSales(ctx context.Context, kandangID uint) (map[uint][]SapronakDetailRow, error) GetProductsWithFlagsByIDs(ctx context.Context, productIDs []uint) ([]entity.Product, error) } @@ -59,10 +61,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 +84,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 +120,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 +159,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 @@ -166,6 +272,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 @@ -300,6 +423,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 { @@ -361,6 +485,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 @@ -409,6 +534,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 @@ -455,9 +581,10 @@ 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.id AS unit_id, u.name AS unit, 'Transfer to other unit' AS notes FROM stock_transfer_details std @@ -504,18 +631,27 @@ 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, + 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 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') + ) ` ) @@ -871,143 +1007,156 @@ 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("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 } - 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 -} -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"). + incomingLayingQuery := r.withCtx(ctx). + Table("laying_transfer_targets AS ltt"). 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 + 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 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 + 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...) } - rows = append(rows, chickinRows...) + 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("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) + if err != nil { + return nil, nil, err + } - return rows, nil + 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) { diff --git a/internal/modules/closings/repositories/closingKeuangan.repository.go b/internal/modules/closings/repositories/closingKeuangan.repository.go new file mode 100644 index 00000000..dedea807 --- /dev/null +++ b/internal/modules/closings/repositories/closingKeuangan.repository.go @@ -0,0 +1,365 @@ +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) + + // 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 +} + +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 +} + +// 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 79c83c22..f0a6ca2a 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)) @@ -23,8 +23,10 @@ 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/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) @@ -32,4 +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/closing.service.go b/internal/modules/closings/services/closing.service.go index 245fd24c..443eec7f 100644 --- a/internal/modules/closings/services/closing.service.go +++ b/internal/modules/closings/services/closing.service.go @@ -2,7 +2,9 @@ package service import ( "context" + "encoding/json" "errors" + "fmt" "math" "strconv" "strings" @@ -16,6 +18,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" @@ -32,12 +35,12 @@ 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) - 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) + GetPenjualan(ctx *fiber.Ctx, projectFlockID uint, projectFlockKandangID *uint) ([]entity.MarketingDeliveryProduct, 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) - GetClosingKeuangan(ctx *fiber.Ctx, projectFlockID uint) (*dto.ReportResponse, error) + GetClosingSapronakSummary(ctx *fiber.Ctx, projectFlockID uint, params *validation.ClosingSapronakQuery) ([]dto.ClosingSapronakSummaryItemDTO, error) GetExpeditionHPP(ctx *fiber.Ctx, projectFlockID uint, projectFlockKandangID *uint) (*dto.ExpeditionHPPDTO, error) } @@ -46,6 +49,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 @@ -54,14 +58,17 @@ 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, 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, standardGrowthDetailRepo productionStandardRepository.StandardGrowthDetailRepository, productionStandardDetailRepo productionStandardRepository.ProductionStandardDetailRepository, validate *validator.Validate) ClosingService { return &closingService{ Log: utils.Log, Validate: validate, Repository: repo, ProjectFlockRepo: projectFlockRepo, + ProjectFlockKandangRepo: projectFlockKandangRepo, MarketingRepo: marketingRepo, MarketingDeliveryProductRepo: marketingDeliveryProductRepo, ApprovalSvc: approvalSvc, @@ -70,6 +77,8 @@ func NewClosingService(repo repository.ClosingRepository, projectFlockRepo proje ChickinRepo: chickinRepo, PurchaseRepo: purchaseRepo, RecordingRepo: recordingRepo, + StandardGrowthDetailRepo: standardGrowthDetailRepo, + ProductionStandardDetailRepo: productionStandardDetailRepo, } } @@ -129,24 +138,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,23 +148,18 @@ 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) { +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") @@ -191,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") @@ -230,7 +337,9 @@ func (s closingService) GetClosingSapronak(c *fiber.Ctx, projectFlockID uint, pa } var projectFlockKandangIDs []uint - if params.Type == validation.SapronakTypeOutgoing { + 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) @@ -245,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) @@ -279,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) @@ -313,10 +491,10 @@ func (s closingService) getWarehouseIDsByProjectFlock(ctx context.Context, proje func (s closingService) getProjectFlockKandangIDs(ctx context.Context, projectFlockID 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) + err := query.Order("id ASC").Pluck("id", &ids).Error if err != nil { return nil, err } @@ -379,115 +557,94 @@ 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 } + 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 } 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, projectFlockKandangCountMap) 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") @@ -520,12 +677,28 @@ 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) + 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 { + 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 +707,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 +738,40 @@ 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 + var mortalityStdFromGrowth *float64 + if project.ProductionStandardId > 0 && currentWeek > 0 && s.StandardGrowthDetailRepo != nil { + 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 + } + } + } + + 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 +794,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 +805,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 +838,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 +888,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 +907,63 @@ 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 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 + 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 +1009,46 @@ 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 calculatePerformanceMetrics(averageWeight, totalWeight, feedUsed, basePopulation, depletion, age float64, standards []entity.FcrStandard) dto.ClosingPerformanceDTO { mortalityStd, fcrStd := closestFcrValues(standards, averageWeight) @@ -768,7 +1079,7 @@ func calculatePerformanceMetrics(averageWeight, totalWeight, feedUsed, basePopul FcrStd: fcrStd, FcrAct: fcrAct, DeffFcr: deffFcr, - Awg: awg, + AwgAct: awg, } } @@ -789,53 +1100,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..0f3351f7 --- /dev/null +++ b/internal/modules/closings/services/closingKeuangan.service.go @@ -0,0 +1,640 @@ +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.ClosingKeuanganData, error) + GetClosingKeuanganByKandang(ctx *fiber.Ctx, projectFlockID uint, projectFlockKandangID uint) (*dto.ClosingKeuanganData, 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.ClosingKeuanganData, 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") + } + + // 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 + } + + // 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") + } + + 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 + } + + // 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)} + ayamFilters := []string{string(utils.FlagDOC), string(utils.FlagPullet), string(utils.FlagLayer)} + allFilters := append(pakanFilters, ovkFilters...) + allFilters = append(allFilters, ayamFilters...) + + var allProductUsageRows []repository.ProductUsageRow + + // Get ALL product usage + for _, kandang := range kandangs { + rows, err := s.ClosingKeuanganRepo.GetAllProductUsageByProjectFlockKandangID(c.Context(), kandang.Id, allFilters) + if err == nil { + allProductUsageRows = append(allProductUsageRows, rows...) + } + } + + // Classify into categories based on flag priority + var pakanProductUsageRows []repository.ProductUsageRow + var ovkProductUsageRows []repository.ProductUsageRow + var ayamProductUsageRows []repository.ProductUsageRow + + 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 + if hasPakanFlag { + pakanProductUsageRows = append(pakanProductUsageRows, row) + } else if hasOvkFlag { + ovkProductUsageRows = append(ovkProductUsageRows, row) + } else if hasAyamFlag { + ayamProductUsageRows = append(ayamProductUsageRows, row) + } else { + continue + } + } + + + // 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 + } + + // 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") + } + + 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") + } + + // 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 + } + + // 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") + } + + // Get total depletion + 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 { + totalDepletion = 0 + } + + totalWeightProduced, _, err := s.RecordingRepo.GetProductionWeightAndQtyByProjectFlockID(c.Context(), projectFlock.Id) + if err != nil { + } + + // Try to get actual weight from uniformity data + 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 { + } else if totalWeightFromUniformity > 0 { + totalWeightProduced = totalWeightFromUniformity + } + + // Fetch egg data only for Laying category + var totalEggWeightKg float64 + if projectFlock.Category == string(utils.ProjectFlockCategoryLaying) { + // TODO: Replace with actual method to get egg weight from RecordingRepo + // totalEggWeightKg, err = s.RecordingRepo.GetEggWeightByProjectFlockID(c.Context(), projectFlock.Id) + // For now, set to 0 as placeholder + totalEggWeightKg = 0 + } else { + totalEggWeightKg = 0 + } + + // Build new DTO structure + + // Calculate totals + var totalPopulation float64 + for _, chickin := range chickins { + totalPopulation += chickin.UsageQty + } + + // Calculate actual population (total population - depletion) + actualPopulation := totalPopulation - totalDepletion + + // 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"}) + 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 + + + // 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) + } + } + } + + + // 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 + } + + + // 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 and total weight produced) + calculateProfitLossMetrics := func(amount float64) (rpPerBird, rpPerKg float64) { + if totalPopulation > 0 { + rpPerBird = amount / totalPopulation + } + if totalWeightProduced > 0 { + rpPerKg = amount / totalWeightProduced + } + return + } + + // Build HPP Items using constants + hppItems := []dto.HPPItem{} + + // PAKAN item + pakanBudgetRpPerBird, pakanBudgetRpPerKg := calculateMetrics(budgetPakan) + pakanRealizationRpPerBird, pakanRealizationRpPerKg := calculateMetrics(totalPakanPrice) + hppItems = append(hppItems, dto.ToHPPItem( + 1, + "purchase", + string(dto.HPPCodePakan), + "Pembelian 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", + string(dto.HPPCodeOVK), + "Pembelian OVK", + dto.ToFinancialMetrics(ovkBudgetRpPerBird, ovkBudgetRpPerKg, budgetOvk), + dto.ToFinancialMetrics(ovkRealizationRpPerBird, ovkRealizationRpPerKg, totalOvkPrice), + )) + + // 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", + docCode, + docLabel, + dto.ToFinancialMetrics(docBudgetRpPerBird, docBudgetRpPerKg, budgetAyam), + dto.ToFinancialMetrics(docRealizationRpPerBird, docRealizationRpPerKg, totalAyamPrice), + )) + + // OVERHEAD item + overheadBudgetRpPerBird, overheadBudgetRpPerKg := calculateMetrics(budgetOperational) + overheadRealizationRpPerBird, overheadRealizationRpPerKg := calculateMetrics(totalOperationalRealization) + hppItems = append(hppItems, dto.ToHPPItem( + 4, + "overhead", + string(dto.HPPCodeOverhead), + "Pengeluaran Overhead", + dto.ToFinancialMetrics(overheadBudgetRpPerBird, overheadBudgetRpPerKg, budgetOperational), + dto.ToFinancialMetrics(overheadRealizationRpPerBird, overheadRealizationRpPerKg, totalOperationalRealization), + )) + + // EKSPEDISI item + ekspedisiBudgetRpPerBird, ekspedisiBudgetRpPerKg := calculateMetrics(budgetEkspedisi) + ekspedisiRealizationRpPerBird, ekspedisiRealizationRpPerKg := calculateMetrics(totalEkspedisiRealization) + hppItems = append(hppItems, dto.ToHPPItem( + 5, + "overhead", + string(dto.HPPCodeEkspedisi), + "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 using constants + 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( + string(dto.PLCodeSales), + salesLabel, + "income", + salesRpPerBird, + salesRpPerKg, + totalSalesAmount, + )) + + // 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( + string(dto.PLCodeSapronak), + sapronakLabel, + "purchase", + sapronakRpPerBird, + sapronakRpPerKg, + totalSapronakAmount, + )) + + // OVERHEAD item + overheadRpPerBird, overheadRpPerKg := calculateMetrics(totalOperationalRealization) + plItems = append(plItems, dto.ToProfitLossItem( + string(dto.PLCodeOverhead), + "Overhead", + "overhead", + overheadRpPerBird, + overheadRpPerKg, + totalOperationalRealization, + )) + + // EKSPEDISI item + plItems = append(plItems, dto.ToProfitLossItem( + string(dto.PLCodeEkspedisi), + "Ekspedisi", + "overhead", + ekspedisiRealizationRpPerBird, + ekspedisiRealizationRpPerKg, + totalEkspedisiRealization, + )) + + // Profit Loss Summary + // Gross Profit = Sales - (DOC + PAKAN + OVK) only + // Gross Profit should NOT include overhead and ekspedisi + costOfGoodsSold := totalAyamPrice + totalPakanPrice + totalOvkPrice + costOfGoodsSoldRpPerBird := sapronakRpPerBird + + grossProfit := totalSalesAmount - costOfGoodsSold + grossProfitRpPerBird := salesRpPerBird - costOfGoodsSoldRpPerBird + + // Operating Expenses (Overhead + Ekspedisi) + totalOperatingExpenses := totalOperationalRealization + totalEkspedisiRealization + totalOperatingExpensesRpPerBird := overheadRpPerBird + ekspedisiRealizationRpPerBird + + // Net Profit = Gross Profit - Operating Expenses + netProfit := grossProfit - totalOperatingExpenses + netProfitRpPerBird := grossProfitRpPerBird - totalOperatingExpensesRpPerBird + + 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) + + return &data, 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/closings/services/sapronak.service.go b/internal/modules/closings/services/sapronak.service.go index b923db5d..930c1bc5 100644 --- a/internal/modules/closings/services/sapronak.service.go +++ b/internal/modules/closings/services/sapronak.service.go @@ -2,8 +2,8 @@ package service import ( "context" + "fmt" "strings" - "time" "github.com/go-playground/validator/v10" "github.com/gofiber/fiber/v2" @@ -112,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, 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 +126,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, @@ -265,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( @@ -274,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), @@ -282,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) { @@ -314,11 +315,12 @@ 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 } -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) @@ -353,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 { @@ -365,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 @@ -403,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 { @@ -419,6 +457,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 +608,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 +628,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 +648,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 +667,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 +686,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 +706,37 @@ 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 + } + } + + 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 diff --git a/internal/modules/closings/validations/closing.validation.go b/internal/modules/closings/validations/closing.validation.go index 610e89b8..454bbdfc 100644 --- a/internal/modules/closings/validations/closing.validation.go +++ b/internal/modules/closings/validations/closing.validation.go @@ -20,7 +20,9 @@ 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"` + Search string `query:"search" validate:"omitempty,max=100"` } diff --git a/internal/modules/daily-checklists/route.go b/internal/modules/daily-checklists/route.go index 0f6657c0..0927486a 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,51 +13,51 @@ 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) + 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) - // create daily checklist - route.Post("/", ctrl.CreateOne) + // upsert daily checklist + 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/dashboards/services/dashboard.service.go b/internal/modules/dashboards/services/dashboard.service.go index 8fa0a2c9..b4635b2e 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 } @@ -271,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{}{ @@ -357,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, }, @@ -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 } @@ -935,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) { @@ -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 +} diff --git a/internal/modules/expenses/controllers/expense.controller.go b/internal/modules/expenses/controllers/expense.controller.go index 666642ca..49c8f356 100644 --- a/internal/modules/expenses/controllers/expense.controller.go +++ b/internal/modules/expenses/controllers/expense.controller.go @@ -229,10 +229,12 @@ 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") { + approvalType = "unit-vice-president" } else { return fiber.NewError(fiber.StatusBadRequest, "Invalid approval path") } diff --git a/internal/modules/expenses/repositories/expense_realization.repository.go b/internal/modules/expenses/repositories/expense_realization.repository.go index f1387483..60ec97a7 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,40 @@ 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") + + if projectFlockKandangID != nil { + 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) OR + (expenses.project_flock_id IS NOT NULL AND expenses.project_flock_id::jsonb @> ?::jsonb) + )`, *projectFlockKandangID, *projectFlockKandangID, fmt.Sprintf("[%d]", projectFlockID)) + } else { + 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 diff --git a/internal/modules/expenses/route.go b/internal/modules/expenses/route.go index 9c22bde3..6ddceb14 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/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) + 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 4e2e218f..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 { @@ -1049,21 +1061,30 @@ 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, - 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 == "unit-vice-president" { + + stepNumber = utils.ExpenseStepUnitVicePresident + 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)) + } + } else if approvalType == "finance" { stepNumber = utils.ExpenseStepFinance - if latestApproval.StepNumber != uint16(utils.ExpenseStepManager) { + 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: Manager", currentStepName)) + 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/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/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/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 } 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/inventory/transfers/services/transfer.service.go b/internal/modules/inventory/transfers/services/transfer.service.go index 3f12b444..86ace0c2 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") }) @@ -118,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 @@ -136,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) } @@ -161,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) @@ -192,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) @@ -239,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() @@ -256,14 +260,21 @@ 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") + return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Gagal membuat product warehouse untuk produk %d di gudang tujuan", product.ProductID)) } } @@ -309,7 +320,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, @@ -372,7 +383,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{}). @@ -381,7 +392,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) @@ -394,7 +405,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{}). @@ -402,7 +413,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)) } } @@ -436,7 +447,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)) @@ -446,8 +457,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)) } } @@ -461,32 +471,26 @@ func (s *transferService) notifyExpenseItemsDelivered(c *fiber.Ctx, transferID u return s.ExpenseBridge.OnItemsDelivered(c, transferID, payloads) } -func (s *transferService) notifyExpenseDetailsDeleted(ctx context.Context, transferID uint64, items []entity.StockTransferDetail) error { - if s.ExpenseBridge == nil || transferID == 0 || len(items) == 0 { - return nil - } - return s.ExpenseBridge.OnItemsDeleted(ctx, transferID, items) -} - func (s *transferService) getActiveProjectFlockKandangID(ctx context.Context, warehouseID uint) (uint, error) { warehouse, err := s.WarehouseRepo.GetByID(ctx, warehouseID, nil) if err != nil { 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)) } + // 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)) 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 diff --git a/internal/modules/inventory/transfers/services/transfer_expense_bridge.go b/internal/modules/inventory/transfers/services/transfer_expense_bridge.go index 90350c18..7b69c22a 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 @@ -144,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 } @@ -220,7 +225,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 +232,7 @@ func (b *transferExpenseBridge) createExpenseViaService( costItems = append(costItems, expenseValidation.CostItem{ NonstockID: expeditionNonstockID, - Quantity: 1, + Quantity: 1, Price: price, Notes: note, }) @@ -251,14 +255,16 @@ func (b *transferExpenseBridge) createExpenseViaService( return nil, err } - action := entity.ApprovalActionApproved actorID := uint(transfer.CreatedBy) if actorID == 0 { 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 { return nil, err } if _, err := approvalSvc.CreateApproval(ctx, utils.ApprovalWorkflowExpense, uint(detail.Id), utils.ExpenseStepFinance, &action, actorID, nil); err != nil { @@ -328,7 +334,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 +353,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 +399,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/marketing/repositories/salesorder_delivery_product.repository.go b/internal/modules/marketing/repositories/salesorder_delivery_product.repository.go index 04051009..e219b041 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 @@ -99,28 +137,41 @@ 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.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") } - 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") } - 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 { @@ -139,6 +190,41 @@ 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") + + switch filters.MarketingType { + case "ayam": + db = db.Where("flags.name IN (?)", []string{ + string(utils.FlagDOC), string(utils.FlagPullet), string(utils.FlagLayer), + }) + 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/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, 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/customers/dto/customer.dto.go b/internal/modules/master/customers/dto/customer.dto.go index 444c6768..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,8 @@ 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/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/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"` } diff --git a/internal/modules/master/nonstocks/dto/nonstock.dto.go b/internal/modules/master/nonstocks/dto/nonstock.dto.go index 9954ee76..8182da21 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,13 @@ type NonstockDetailDTO struct { NonstockListDTO } +type NonstockSupplierDTO struct { + Id uint `json:"id"` + Name string `json:"name"` + Alias string `json:"alias"` + Category string `json:"category"` +} + // === Mapper Functions === func ToNonstockRelationDTO(e entity.Nonstock) NonstockRelationDTO { @@ -99,21 +105,26 @@ 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, + }) } 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..56ef39b8 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{}). @@ -66,13 +66,16 @@ func (r *NonstockRepositoryImpl) SyncSuppliersDiff(ctx context.Context, tx *gorm existingMap[rel.SupplierId] = struct{}{} } - 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 _, exists := existingMap[rel.SupplierId]; exists { continue } - record := entity.NonstockSupplier{NonstockId: nonstockID, SupplierId: id} + record := entity.NonstockSupplier{ + NonstockId: nonstockID, + SupplierId: rel.SupplierId, + } 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..1b6a409c 100644 --- a/internal/modules/master/nonstocks/services/nonstock.service.go +++ b/internal/modules/master/nonstocks/services/nonstock.service.go @@ -111,8 +111,24 @@ 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.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[supplierID] = struct{}{} + supplierIDs = append(supplierIDs, supplierID) + supplierLinks = append(supplierLinks, entity.NonstockSupplier{ + SupplierId: supplierID, + }) + } supplierList, supplierErr := s.Repository.GetSuppliersByIDs(ctx, supplierIDs) if supplierErr != nil { s.Log.Errorf("Failed to validate suppliers: %+v", supplierErr) @@ -155,7 +171,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 +209,26 @@ 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 { 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.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[supplierID] = struct{}{} + supplierIDs = append(supplierIDs, supplierID) + supplierLinks = append(supplierLinks, entity.NonstockSupplier{ + SupplierId: supplierID, + }) + } + + 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 +280,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..6378ac18 100644 --- a/internal/modules/master/nonstocks/validations/nonstock.validation.go +++ b/internal/modules/master/nonstocks/validations/nonstock.validation.go @@ -1,17 +1,17 @@ package validation 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"` - 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"` - SupplierIDs *[]uint `json:"supplier_ids,omitempty" validate:"omitempty,dive,gt=0"` - 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/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) } 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/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/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/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 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/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 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..b39dca78 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 @@ -198,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 @@ -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,34 @@ 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 +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 +620,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 +629,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 +645,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 +656,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 +685,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 +701,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 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/production/project_flocks/controllers/projectflock.controller.go b/internal/modules/production/project_flocks/controllers/projectflock.controller.go index 4315b948..e82d3af5 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" @@ -278,14 +279,22 @@ 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 { + mapped := warehouseDTO.ToWarehouseRelationDTO(*warehouse) + 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 8dedaf15..c18f3f65 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,26 @@ 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 { - 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 { @@ -53,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 { 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/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/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 f5a04821..0fa14e97 100644 --- a/internal/modules/production/recordings/dto/recording.dto.go +++ b/internal/modules/production/recordings/dto/recording.dto.go @@ -14,44 +14,92 @@ import ( // === DTO Structs === -type RecordingRelationDTO struct { - Id uint `json:"id"` +type RecordingProjectFlockDTO struct { ProjectFlockKandangId uint `json:"project_flock_kandang_id"` - RecordDatetime time.Time `json:"record_datetime"` - Day int `json:"day"` + FlockName string `json:"flock_name"` ProjectFlockCategory string `json:"project_flock_category"` - TotalDepletionQty float64 `json:"total_depletion_qty"` - CumDepletionRate float64 `json:"cum_depletion_rate"` - CumIntake int `json:"cum_intake"` - FcrValue float64 `json:"fcr_value"` + Period int `json:"period"` + ProductionStandart *RecordingProductionStandardDTO `json:"production_standart,omitempty"` + Fcr *RecordingFcrDTO `json:"fcr,omitempty"` TotalChickQty float64 `json:"total_chick_qty"` - HenDay float64 `json:"hen_day"` - HenHouse float64 `json:"hen_house"` - FeedIntake float64 `json:"feed_intake"` - EggMass float64 `json:"egg_mass"` - EggWeight float64 `json:"egg_weight"` - StandardHenDay *float64 `json:"hen_day_std,omitempty"` - StandardHenHouse *float64 `json:"hen_house_std,omitempty"` - StandardFeedIntake *float64 `json:"feed_intake_std,omitempty"` - StandardMaxDepletion *float64 `json:"max_depletion_std,omitempty"` - StandardEggMass *float64 `json:"egg_mass_std,omitempty"` - StandardEggWeight *float64 `json:"egg_weight_std,omitempty"` - StandardFcr *float64 `json:"fcr_std,omitempty"` - Approval approvalDTO.ApprovalRelationDTO `json:"approval"` +} + +type RecordingProductionStandardDTO struct { + Id uint `json:"id"` + Week int `json:"week"` + Name string `json:"name"` + HenDayStd float64 `json:"hen_day_std"` + HenHouseStd float64 `json:"hen_house_std"` + FeedIntakeStd float64 `json:"feed_intake_std"` + MaxDepletionStd float64 `json:"max_depletion_std"` + EggMassStd float64 `json:"egg_mass_std"` + EggWeightStd float64 `json:"egg_weight_std"` +} + +type RecordingFcrDTO struct { + Id uint `json:"id"` + Name string `json:"name"` + FcrStd float64 `json:"fcr_std"` +} + +type RecordingAreaDTO struct { + Id uint `json:"id"` + Name string `json:"name"` +} + +type RecordingLocationDTO struct { + Id uint `json:"id"` + Name string `json:"name"` + 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"` + Area *RecordingAreaDTO `json:"area,omitempty"` + Location *RecordingLocationDTO `json:"location,omitempty"` +} + +type RecordingRelationDTO struct { + Id uint `json:"id"` + ProjectFlock RecordingProjectFlockDTO `json:"project_flock"` + RecordDatetime time.Time `json:"record_datetime"` + Day int `json:"day"` + TotalDepletionQty float64 `json:"total_depletion_qty"` + CumDepletionRate float64 `json:"cum_depletion_rate"` + CumIntake int `json:"cum_intake"` + FcrValue float64 `json:"fcr_value"` + HenDay float64 `json:"hen_day"` + HenHouse float64 `json:"hen_house"` + FeedIntake float64 `json:"feed_intake"` + EggMass float64 `json:"egg_mass"` + EggWeight float64 `json:"egg_weight"` + Approval approvalDTO.ApprovalRelationDTO `json:"approval"` } type RecordingListDTO struct { RecordingRelationDTO - CreatedUser *userDTO.UserRelationDTO `json:"created_user"` + CreatedUser *userDTO.UserRelationDTO `json:"created_user,omitempty"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` + Kandang *RecordingKandangDTO `json:"kandang,omitempty"` + Location *RecordingLocationDTO `json:"location,omitempty"` } type RecordingDetailDTO struct { RecordingListDTO - Depletions []RecordingDepletionDTO `json:"depletions"` - Stocks []RecordingStockDTO `json:"stocks"` - Eggs []RecordingEggDTO `json:"eggs"` + ProductCategory string `json:"product_category"` + Warehouse *RecordingWarehouseDTO `json:"warehouse,omitempty"` + Depletions []RecordingDepletionDTO `json:"depletions"` + Stocks []RecordingStockDTO `json:"stocks"` + Eggs []RecordingEggDTO `json:"eggs"` } type RecordingDepletionDTO struct { @@ -63,7 +111,7 @@ type RecordingDepletionDTO struct { type RecordingStockDTO struct { ProductWarehouseId uint `json:"product_warehouse_id"` UsageAmount float64 `json:"usage_amount"` - PendingQty *float64 `json:"pending_qty,omitempty"` + PendingQty float64 `json:"pending_qty"` ProductWarehouse productWarehouseDTO.ProductWarehouseDTO `json:"product_warehouse"` } @@ -75,117 +123,10 @@ type RecordingEggDTO struct { ProductWarehouse productWarehouseDTO.ProductWarehouseDTO `json:"product_warehouse"` } -type RecordingProductWarehouseDTO struct { - Id uint `json:"id"` - ProductId uint `json:"product_id"` - ProductName string `json:"product_name"` - WarehouseId uint `json:"warehouse_id"` - WarehouseName string `json:"warehouse_name"` -} - // === Mapper Functions === -func ToRecordingRelationDTO(e entity.Recording) RecordingRelationDTO { - var ( - projectFlockCategory string - day int - totalDepletionQty float64 - cumDepletionRate float64 - cumIntake int - fcrValue float64 - totalChickQty float64 - henDay float64 - henHouse float64 - feedIntake float64 - eggMass float64 - eggWeight float64 - ) - - if e.Day != nil { - day = *e.Day - } - if e.TotalDepletionQty != nil { - totalDepletionQty = *e.TotalDepletionQty - } - if e.CumDepletionRate != nil { - cumDepletionRate = *e.CumDepletionRate - } - if e.CumIntake != nil { - cumIntake = *e.CumIntake - } - if e.FcrValue != nil { - fcrValue = *e.FcrValue - } - if e.TotalChickQty != nil { - totalChickQty = *e.TotalChickQty - } - if e.HenDay != nil { - henDay = *e.HenDay - } - if e.HenHouse != nil { - henHouse = *e.HenHouse - } - if e.FeedIntake != nil { - feedIntake = *e.FeedIntake - } - if e.EggMass != nil { - eggMass = *e.EggMass - } - if e.EggWeight != nil { - eggWeight = *e.EggWeight - } - - if e.ProjectFlockKandang != nil && e.ProjectFlockKandang.ProjectFlock.Id != 0 { - category := e.ProjectFlockKandang.ProjectFlock.Category - projectFlockCategory = category - } - - latestApproval := defaultRecordingLatestApproval(e) - if e.LatestApproval != nil { - snapshot := approvalDTO.ToApprovalDTO(*e.LatestApproval) - latestApproval = snapshot - } - - return RecordingRelationDTO{ - Id: e.Id, - ProjectFlockKandangId: e.ProjectFlockKandangId, - RecordDatetime: e.RecordDatetime, - Day: day, - ProjectFlockCategory: projectFlockCategory, - TotalDepletionQty: totalDepletionQty, - CumDepletionRate: cumDepletionRate, - CumIntake: cumIntake, - FcrValue: fcrValue, - TotalChickQty: totalChickQty, - HenDay: henDay, - HenHouse: henHouse, - FeedIntake: feedIntake, - EggMass: eggMass, - EggWeight: eggWeight, - StandardHenDay: e.StandardHenDay, - StandardHenHouse: e.StandardHenHouse, - StandardFeedIntake: e.StandardFeedIntake, - StandardMaxDepletion: e.StandardMaxDepletion, - StandardEggMass: e.StandardEggMass, - StandardEggWeight: e.StandardEggWeight, - StandardFcr: e.StandardFcr, - Approval: latestApproval, - } -} - func ToRecordingListDTO(e entity.Recording) RecordingListDTO { - var createdUser *userDTO.UserRelationDTO - if e.CreatedUser != nil && e.CreatedUser.Id != 0 { - mapped := userDTO.ToUserRelationDTO(*e.CreatedUser) - createdUser = &mapped - } - - return RecordingListDTO{ - RecordingRelationDTO: ToRecordingRelationDTO(e), - CreatedAt: e.CreatedAt, - UpdatedAt: e.UpdatedAt, - CreatedUser: createdUser, - } + return toRecordingListDTO(e) } func ToRecordingListDTOs(e []entity.Recording) []RecordingListDTO { @@ -197,20 +138,15 @@ func ToRecordingListDTOs(e []entity.Recording) []RecordingListDTO { } func ToRecordingDetailDTO(e entity.Recording) RecordingDetailDTO { - listDTO := ToRecordingListDTO(e) - - var eggs []RecordingEggDTO - if strings.EqualFold(listDTO.ProjectFlockCategory, string(utils.ProjectFlockCategoryLaying)) { - eggs = ToRecordingEggDTOs(e.Eggs) - } else if len(e.Eggs) > 0 { - eggs = ToRecordingEggDTOs(e.Eggs) - } + listDTO := toRecordingListDTO(e) return RecordingDetailDTO{ RecordingListDTO: listDTO, + ProductCategory: recordingProductCategory(e), + Warehouse: recordingWarehouseDTO(e), Depletions: ToRecordingDepletionDTOs(e.Depletions), Stocks: ToRecordingStockDTOs(e.Stocks), - Eggs: eggs, + Eggs: ToRecordingEggDTOs(e.Eggs), } } @@ -233,11 +169,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 +198,214 @@ 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, + Kandang: recordingKandangDTO(e), + Location: recordingKandangLocationDTO(e), + } +} + +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 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 + 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 +419,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/module.go b/internal/modules/production/recordings/module.go index a19faa33..71981a9e 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,8 +31,34 @@ 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.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", @@ -46,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) @@ -63,6 +113,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/repositories/recording.repository.go b/internal/modules/production/recordings/repositories/recording.repository.go index 941d4507..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 @@ -44,16 +46,35 @@ 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) 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 + CumDepletionRateAvg float64 + CumDepletionRateCount int64 +} + func NewRecordingRepository(db *gorm.DB) RecordingRepository { return &RecordingRepositoryImpl{ BaseRepositoryImpl: repository.NewBaseRepository[entity.Recording](db), @@ -64,19 +85,65 @@ func (r *RecordingRepositoryImpl) WithRelations(db *gorm.DB) *gorm.DB { return db. Preload("CreatedUser"). Preload("ProjectFlockKandang"). + Preload("ProjectFlockKandang.Kandang"). + Preload("ProjectFlockKandang.Kandang.Location"). 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) 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) { @@ -139,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 @@ -294,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) { @@ -433,6 +493,67 @@ 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 + 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 { + 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, + CumDepletionRateCount: 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) + result.CumDepletionRateAvg = row.CumDepletionRateTotal + } + + return result, nil +} + func nextRecordingDay(days []int) int { if len(days) == 0 { return 1 @@ -460,3 +581,30 @@ 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_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 = ? "+ + " 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", projectFlockID). + Scan(&result).Error + + return result.TotalWeight, err +} diff --git a/internal/modules/production/recordings/services/recording.service.go b/internal/modules/production/recordings/services/recording.service.go index 819552dc..80611109 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" @@ -43,6 +44,7 @@ type RecordingFIFOIntegrationService interface { } var recordingStockUsableKey = fifo.UsableKeyRecordingStock +var recordingDepletionUsableKey = fifo.UsableKeyRecordingDepletion type recordingService struct { Log *logrus.Logger @@ -53,6 +55,7 @@ type recordingService struct { ProjectFlockPopulationRepo rProjectFlock.ProjectFlockPopulationRepository ApprovalRepo commonRepo.ApprovalRepository ApprovalSvc commonSvc.ApprovalService + ProductionStandardSvc sProductionStandard.ProductionStandardService FifoSvc commonSvc.FifoService } @@ -64,6 +67,7 @@ func NewRecordingService( approvalRepo commonRepo.ApprovalRepository, approvalSvc commonSvc.ApprovalService, fifoSvc commonSvc.FifoService, + productionStandardSvc sProductionStandard.ProductionStandardService, validate *validator.Validate, ) RecordingService { return &recordingService{ @@ -75,6 +79,7 @@ func NewRecordingService( ProjectFlockPopulationRepo: projectFlockPopulationRepo, ApprovalRepo: approvalRepo, ApprovalSvc: approvalSvc, + ProductionStandardSvc: productionStandardSvc, FifoSvc: fifoSvc, } } @@ -112,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 { @@ -169,6 +175,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,13 +202,15 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent if err := s.ensureChickInExists(ctx, pfk.Id); 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 { 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 @@ -210,8 +226,12 @@ 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 + } + } - 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) @@ -259,18 +279,39 @@ 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 { 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 + 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 } @@ -375,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 { @@ -399,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 @@ -417,6 +476,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 @@ -428,9 +497,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 + } } } @@ -599,12 +674,22 @@ 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) { 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) { @@ -712,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) } @@ -743,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, @@ -781,6 +963,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 @@ -862,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) } @@ -901,45 +1107,30 @@ type eggTotals struct { Weight float64 } - -func stocksMatch(existing []entity.RecordingStock, incoming []validation.Stock) bool { - hasPending := false - for _, item := range incoming { - if item.PendingQty != nil { - hasPending = true - break +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 { 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) } @@ -1137,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 { @@ -1147,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 { @@ -1157,7 +1348,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 { @@ -1330,12 +1521,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) diff --git a/internal/modules/production/recordings/validations/recording.validation.go b/internal/modules/production/recordings/validations/recording.validation.go index a1d6aaf7..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,22 +19,24 @@ type ( ) type Create struct { - ProjectFlockKandangId uint `json:"project_flock_kandang_id" validate:"required,number,min=1"` - 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/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 { 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..e64b9cc2 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,31 @@ 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") + + db = s.withRelations(db) + return db }) @@ -216,7 +239,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 +270,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 +309,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 +330,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 +501,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 +739,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 +853,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 { 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/purchases/services/expense_bridge.go b/internal/modules/purchases/services/expense_bridge.go index 23b95c58..1210b3a1 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 { @@ -166,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 } @@ -182,6 +192,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 +231,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 +278,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") } @@ -618,7 +644,10 @@ 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 { return nil, err } if _, err := approvalSvc.CreateApproval(ctx, utils.ApprovalWorkflowExpense, uint(detail.Id), utils.ExpenseStepFinance, &action, actorID, nil); err != nil { 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) } diff --git a/internal/modules/repports/controllers/repport.controller.go b/internal/modules/repports/controllers/repport.controller.go index 22ff4acf..c1982279 100644 --- a/internal/modules/repports/controllers/repport.controller.go +++ b/internal/modules/repports/controllers/repport.controller.go @@ -82,6 +82,9 @@ 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", ""), EndDate: ctx.Query("end_date", ""), @@ -241,6 +244,65 @@ func (c *RepportController) GetHppPerKandang(ctx *fiber.Ctx) error { return ctx.Status(fiber.StatusOK).JSON(resp) } +func (c *RepportController) GetCustomerPayment(ctx *fiber.Ctx) error { + 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), + CustomerIDs: customerIDs, + StartDate: ctx.Query("start_date", ""), + EndDate: ctx.Query("end_date", ""), + } + + // Validate pagination + 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) + if err != nil { + return err + } + + // 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, + 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 == "" { diff --git a/internal/modules/repports/dto/repportCustomerPayment.dto.go b/internal/modules/repports/dto/repportCustomerPayment.dto.go new file mode 100644 index 00000000..99862349 --- /dev/null +++ b/internal/modules/repports/dto/repportCustomerPayment.dto.go @@ -0,0 +1,120 @@ +package dto + +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" +) + +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"` + + Qty float64 `json:"qty"` + Weight float64 `json:"weight"` + AverageWeight float64 `json:"average_weight"` + UnitPrice float64 `json:"unit_price"` + FinalPrice float64 `json:"final_price"` + 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 { + TotalQty float64 `json:"total_qty"` + TotalWeight float64 `json:"total_weight"` + TotalFinalAmount float64 `json:"total_final_amount"` + TotalGrandAmount float64 `json:"total_grand_amount"` + TotalPayment float64 `json:"total_payment"` + TotalAccountsReceivable float64 `json:"total_accounts_receivable"` +} + +type CustomerPaymentReportItem struct { + Customer customerDTO.CustomerRelationDTO `json:"customer"` + InitialBalance float64 `json:"initial_balance"` + Rows []CustomerPaymentReportRow `json:"rows"` + Summary CustomerPaymentReportSummary `json:"summary"` +} + +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, + UnitPrice: tx.Price, + FinalPrice: tx.FinalPrice, + TotalPrice: tx.TotalPrice, + PaymentAmount: tx.PaymentAmount, + VehicleNumbers: parseStringSlice(tx.VehicleNumbers), + PickupInfo: parseStringSlice(tx.PickupInfo), + SalesPerson: tx.SalesPerson, + } +} + +func ToCustomerPaymentReportItem(customer entities.Customer, initialBalance float64, rows []CustomerPaymentReportRow, summary CustomerPaymentReportSummary) CustomerPaymentReportItem { + return CustomerPaymentReportItem{ + Customer: customerDTO.ToCustomerRelationDTO(customer), + InitialBalance: initialBalance, + Rows: rows, + Summary: summary, + } +} + +func ToCustomerPaymentReportSummary(rows []CustomerPaymentReportRow, initialBalance float64) CustomerPaymentReportSummary { + summary := CustomerPaymentReportSummary{} + + for _, row := range rows { + summary.TotalQty += row.Qty + summary.TotalWeight += row.Weight + + if row.TransactionType == "SALES" { + summary.TotalFinalAmount += row.FinalPrice + summary.TotalGrandAmount += row.TotalPrice + } else if row.TransactionType == "PAYMENT" { + summary.TotalPayment += row.PaymentAmount + } + } + + // Total AR = Initial Balance - Total Sales + Total Payment + summary.TotalAccountsReceivable = initialBalance - summary.TotalGrandAmount + summary.TotalPayment + + 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/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/dto/repportHpp.dto.go b/internal/modules/repports/dto/repportHpp.dto.go index 63c5dce9..dc0b81d4 100644 --- a/internal/modules/repports/dto/repportHpp.dto.go +++ b/internal/modules/repports/dto/repportHpp.dto.go @@ -25,14 +25,15 @@ 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"` + NameWithPeriode string `json:"name_with_periode"` + WeightRange HppPerKandangWeightRangeDTO `json:"weight_range"` + 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"` @@ -40,8 +41,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 { @@ -80,34 +81,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/dto/repportMarketing.dto.go b/internal/modules/repports/dto/repportMarketing.dto.go index 90c2fe50..92ee9a77 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 @@ -139,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" @@ -147,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 { @@ -167,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 @@ -259,3 +277,49 @@ 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"` + } + + 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, + ProductPrice: p.ProductPrice, + SellingPrice: p.SellingPrice, + Uom: p.ProductRelationDTO.Uom, + Flags: p.ProductRelationDTO.Flags, + ProductCategory: p.ProductRelationDTO.ProductCategory, + Suppliers: suppliers, + }) +} 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/module.go b/internal/modules/repports/module.go index 61f37d4d..60345d5b 100644 --- a/internal/modules/repports/module.go +++ b/internal/modules/repports/module.go @@ -12,6 +12,8 @@ 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" + 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 +36,14 @@ 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) + customerRepository := customerRepo.NewCustomerRepository(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(db, validate, expenseRealizationRepository, marketingDeliveryProductRepository, purchaseRepository, chickinRepository, recordingRepository, approvalSvc, purchaseSupplierRepository, debtSupplierRepository, hppPerKandangRepository, productionResultRepository, customerPaymentRepository, customerRepository, standardGrowthDetailRepository, productionStandardDetailRepository) 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..8a5747aa --- /dev/null +++ b/internal/modules/repports/repositories/customer_payment.repository.go @@ -0,0 +1,195 @@ +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"` + FinalPrice float64 `gorm:"column:final_price"` + 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) { + salesQuery := r.db.WithContext(ctx). + Table("marketing_delivery_products mdp"). + Select(` + 'SALES' AS transaction_type, + mdp.id::BIGINT AS transaction_id, + c.id::BIGINT AS customer_id, + m.so_date::DATE AS trans_date, + 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(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, + 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("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") + + if customerID != nil { + salesQuery = salesQuery.Where("c.id = ?", *customerID) + } + + 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 final_price, + 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) + } + + 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 := r.db.WithContext(ctx). + Table("(" + + "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 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 " + + "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") + + var total int64 + if err := subQuery.Count(&total).Error; err != nil { + return nil, 0, err + } + + var customerIDs []uint + err := r.db.WithContext(ctx). + Table("("+ + "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 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 "+ + "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/repositories/debt_supplier.repository.go b/internal/modules/repports/repositories/debt_supplier.repository.go index 84e9402d..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" @@ -15,13 +16,23 @@ 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) + 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) } type debtSupplierRepositoryImpl struct { db *gorm.DB } +type PaymentReferenceSummary struct { + Total float64 + LatestPaymentDate time.Time +} + func NewDebtSupplierRepository(db *gorm.DB) DebtSupplierRepository { return &debtSupplierRepositoryImpl{db: db} } @@ -30,9 +41,7 @@ func resolveDebtSupplierDateColumn(filterBy string) string { switch strings.ToLower(strings.TrimSpace(filterBy)) { case "po_date": return "purchases.po_date" - case "pr_date": - return "purchases.created_at" - case "do_date", "received_date", "": + case "received_date", "": return "purchase_items.received_date" default: return "purchase_items.received_date" @@ -127,7 +136,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) @@ -157,6 +166,40 @@ 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). + Where("transaction_type <> ?", string(utils.TransactionTypeSaldoAwal)) + + 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) @@ -204,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 @@ -219,3 +263,146 @@ 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 + } + + 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("transaction_type <> ?", string(utils.TransactionTypeSaldoAwal)). + 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/repositories/hpp_per_kandang.repository.go b/internal/modules/repports/repositories/hpp_per_kandang.repository.go index 4bd9aab4..1135efbf 100644 --- a/internal/modules/repports/repositories/hpp_per_kandang.repository.go +++ b/internal/modules/repports/repositories/hpp_per_kandang.repository.go @@ -11,40 +11,46 @@ import ( ) type HppPerKandangRow struct { - KandangID uint - KandangName string - KandangStatus string - LocationID uint - LocationName string - PicID uint - PicName string - RemainingChickenBirds float64 - RemainingChickenWeight float64 - EggProductionWeightKg float64 - EggProductionPieces float64 + ProjectFlockKandangID uint + ProjectFlockPeriod int + 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 { - 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 { 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 +64,32 @@ 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, + pfk.period AS project_flock_period, k.id AS kandang_id, k.name AS kandang_name, k.status AS kandang_status, @@ -68,23 +97,31 @@ 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, + 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 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("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) - query = query.Group("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 @@ -93,41 +130,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, + 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.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,31 +176,30 @@ 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") + Where("r.project_flock_kandangs_id IN ?", projectFlockKandangIDs). + Where("r.record_datetime < ?", end). + Where("r.deleted_at IS NULL"). + Where("(la.action IS NULL OR la.action != ?)", string(entity.ApprovalActionRejected)) - query = applyLocationFilters(query, areaIDs, locationIDs, kandangIDs) - - 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, @@ -172,9 +211,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})). - Group("pfk.kandang_id, s.id, s.name, s.alias") - docQuery = applyLocationFilters(docQuery, areaIDs, locationIDs, kandangIDs) + Where("pc.project_flock_kandang_id IN ?", projectFlockKandangIDs). + Group("pfk.id, s.id, s.name, s.alias") if err := docQuery.Scan(&docRows).Error; err != nil { return nil, nil, err @@ -183,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 @@ -214,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. @@ -247,63 +285,63 @@ 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"). 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})). - Group("k.id") - budgetQuery = applyLocationFilters(budgetQuery, areaIDs, locationIDs, kandangIDs) + Where("pfk.id IN (?)", projectFlockKandangIDs). + Group("pfk.id") + // budgetQuery = applyLocationFilters(budgetQuery, areaIDs, locationIDs, kandangIDs) if err := budgetQuery.Scan(&budgetRows).Error; err != nil { return nil, nil, err } 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 (?)", recordingPfk.Session(&gorm.Session{NewDB: true})). - Group("k.id") - expenseQuery = applyLocationFilters(expenseQuery, areaIDs, locationIDs, kandangIDs) + Where("pfk.id IN (?)", projectFlockKandangIDs). + Group("pfk.id") + // expenseQuery = applyLocationFilters(expenseQuery, areaIDs, locationIDs, kandangIDs) if err := expenseQuery.Scan(&expenseRows).Error; err != nil { return nil, nil, err } 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 } @@ -312,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"). @@ -323,21 +361,21 @@ 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 < ?", 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 } 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" } @@ -347,6 +385,67 @@ 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 + EggProductionWeightKgRemaining float64 + EggProductionPiecesRemaining float64 + EggProductionTotalWeightKg float64 + EggProductionTotalPieces 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.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 < ?", 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, + EggProductionWeightKgRemaining: row.EggProductionWeightKgRemaining, + EggProductionPiecesRemaining: row.EggProductionPiecesRemaining, + EggProductionTotalWeightKg: row.EggProductionTotalWeightKg, + EggProductionTotalPieces: row.EggProductionTotalPieces, + } + } + + 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) @@ -355,7 +454,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/repositories/production_result.repository.go b/internal/modules/repports/repositories/production_result.repository.go index f2decedf..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 { @@ -59,7 +60,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"). @@ -77,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/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/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) } diff --git a/internal/modules/repports/services/repport.service.go b/internal/modules/repports/services/repport.service.go index c7576e5f..a0e0f350 100644 --- a/internal/modules/repports/services/repport.service.go +++ b/internal/modules/repports/services/repport.service.go @@ -2,6 +2,8 @@ package service import ( "context" + "encoding/json" + "errors" "fmt" "math" "sort" @@ -19,6 +21,8 @@ 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" + 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" chickinRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/production/chickins/repositories" @@ -40,21 +44,27 @@ 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, params *validation.CustomerPaymentQuery) ([]dto.CustomerPaymentReportItem, int64, error) } type repportService struct { - Log *logrus.Logger - Validate *validator.Validate - 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 + 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 } type HppCostAggregate struct { @@ -67,6 +77,7 @@ type HppCostAggregate struct { } func NewRepportService( + db *gorm.DB, validate *validator.Validate, expenseRealizationRepo expenseRepo.ExpenseRealizationRepository, marketingDeliveryRepo marketingRepo.MarketingDeliveryProductRepository, @@ -78,20 +89,29 @@ func NewRepportService( debtSupplierRepo repportRepo.DebtSupplierRepository, hppPerKandangRepo repportRepo.HppPerKandangRepository, productionResultRepo repportRepo.ProductionResultRepository, + customerPaymentRepo repportRepo.CustomerPaymentRepository, + customerRepo customerRepo.CustomerRepository, + standardGrowthDetailRepo productionStandardRepository.StandardGrowthDetailRepository, + productionStandardDetailRepo productionStandardRepository.ProductionStandardDetailRepository, ) RepportService { return &repportService{ - Log: utils.Log, - Validate: validate, - ExpenseRealizationRepo: expenseRealizationRepo, - MarketingDeliveryRepo: marketingDeliveryRepo, - PurchaseRepo: purchaseRepo, - ChickinRepo: chickinRepo, - RecordingRepo: recordingRepo, - ApprovalSvc: approvalSvc, - PurchaseSupplierRepo: purchaseSupplierRepo, - DebtSupplierRepo: debtSupplierRepo, - HppPerKandangRepo: hppPerKandangRepo, - ProductionResultRepo: productionResultRepo, + 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, } } @@ -285,6 +305,30 @@ 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) + + 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 { @@ -294,12 +338,78 @@ 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 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))) @@ -307,6 +417,261 @@ 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 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 + 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 + } + } + + var result []dto.CustomerPaymentReportItem + for _, customerID := range customerIDs { + item, err := s.processCustomerPayment(ctx.Context(), customerID, params) + if err != nil { + return nil, 0, err + } + + if len(item.Rows) > 0 { + result = append(result, item) + } + } + + totalCustomers = int64(len(result)) + return result, totalCustomers, nil +} + +func (s *repportService) processCustomerPayment(ctx context.Context, customerID uint, params *validation.CustomerPaymentQuery) (dto.CustomerPaymentReportItem, error) { + + customer, err := s.CustomerRepo.GetByID(ctx, customerID, nil) + if err != nil { + return dto.CustomerPaymentReportItem{}, err + } + + initialBalance, err := s.CustomerPaymentRepo.GetInitialBalanceByCustomer(ctx, customerID) + if err != nil { + return dto.CustomerPaymentReportItem{}, err + } + + cid := customerID + transactions, err := s.CustomerPaymentRepo.GetCustomerPaymentTransactions(ctx, &cid) + if err != nil { + return dto.CustomerPaymentReportItem{}, err + } + + rows := make([]dto.CustomerPaymentReportRow, 0, len(transactions)) + runningBalance := initialBalance + + for i, tx := range transactions { + + previousBalance := runningBalance + + row := dto.ToCustomerPaymentReportRow(tx) + + if tx.TransactionType == "SALES" { + runningBalance -= tx.TotalPrice + status, paymentDate := s.determineSalesStatusAndPaymentDate(transactions, i, previousBalance, runningBalance) + row.Status = status + + if status == "LUNAS" { + if paymentDate != nil { + days := int(paymentDate.Sub(tx.TransDate).Hours() / 24) + row.AgingDay = &days + } else { + days := 0 + row.AgingDay = &days + } + } else { + days := int(time.Since(tx.TransDate).Hours() / 24) + row.AgingDay = &days + } + } else if tx.TransactionType == "PAYMENT" { + runningBalance += tx.PaymentAmount + row.Status = "" + row.AgingDay = nil + } + + row.AccountsReceivable = runningBalance + rows = append(rows, row) + } + + 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 + } + + 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 + } + 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) + if startDate != nil && transDate.Before(*startDate) { + continue + } + if endDate != nil && transDate.After(*endDate) { + continue + } + filteredRows = append(filteredRows, row) + } + + rows = filteredRows + } + + summary := dto.ToCustomerPaymentReportSummary(rows, initialBalance) + + return dto.ToCustomerPaymentReportItem(*customer, initialBalance, rows, summary), nil +} + +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 { + // 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 + } + + 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, @@ -314,17 +679,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 +716,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 @@ -453,6 +816,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 @@ -464,13 +889,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 +967,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 +999,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 @@ -643,7 +1072,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 == "" { - params.FilterBy = "do_date" + params.FilterBy = "received_date" } if err := s.Validate.Struct(params); err != nil { @@ -675,24 +1104,38 @@ func (s *repportService) GetDebtSupplier(c *fiber.Ctx, params *validation.DebtSu return nil, 0, err } - 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) - } + payments, err := s.DebtSupplierRepo.GetPaymentsBySuppliers(c.Context(), supplierIDs, params) + if err != nil { + return nil, 0, err } - paymentTotals, err := s.DebtSupplierRepo.GetPaymentTotalsByReferences(c.Context(), supplierIDs, references) + purchasesBySupplier := make(map[uint][]entity.Purchase, len(supplierIDs)) + for _, purchase := range purchases { + purchasesBySupplier[purchase.SupplierId] = append(purchasesBySupplier[purchase.SupplierId], purchase) + } + + 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 + } + + 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 } @@ -704,36 +1147,97 @@ 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 { continue } + initialBalance := initialBalanceTotals[supplierID] + (initialPaymentTotals[supplierID] - initialPurchaseTotals[supplierID]) items := purchasesBySupplier[supplierID] - rows := make([]dto.DebtSupplierRowDTO, 0, len(items)) + paymentItems := paymentsBySupplier[supplierID] total := dto.DebtSupplierTotalDTO{} + combinedRows := make([]debtSupplierRowItem, 0, len(items)+len(paymentItems)) for _, purchase := range items { - row := buildDebtSupplierRow(purchase, paymentTotals, now, location) - rows = append(rows, row) - - if row.Aging > total.Aging { - total.Aging = row.Aging + 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) + } + } + } } - total.TotalPrice += row.TotalPrice - total.PaymentPrice += row.PaymentPrice - total.DebtPrice += row.DebtPrice + sortTime := resolveDebtSupplierSortTime(purchase, params.FilterBy, location) + combinedRows = append(combinedRows, debtSupplierRowItem{ + Row: row, + SortTime: sortTime, + Order: 0, + DeltaBalance: -row.TotalPrice, + CountTotals: true, + }) } - sortDesc := strings.EqualFold(params.SortOrder, "desc") - sort.SliceStable(rows, func(i, j int) bool { - if sortDesc { - return rows[i].PrDate > rows[j].PrDate + 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 rows[i].PrDate < rows[j].PrDate + return combinedRows[i].SortTime.Before(combinedRows[j].SortTime) }) + balance := initialBalance + for i := range combinedRows { + balance += combinedRows[i].DeltaBalance + combinedRows[i].Row.DebtPrice = balance + 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 + } else { + 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-- { + rows = append(rows, combinedRows[i].Row) + } + } else { + for i := range combinedRows { + rows = append(rows, combinedRows[i].Row) + } + } + var supplierDTORef *supplierDTO.SupplierRelationDTO if supplier.Id != 0 { mapped := supplierDTO.ToSupplierRelationDTO(supplier) @@ -741,27 +1245,23 @@ 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, }) } 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) @@ -769,6 +1269,7 @@ func buildDebtSupplierRow(purchase entity.Purchase, paymentTotals map[string]flo totalPrice := 0.0 travelNumber := "-" + receivedDate := "" var area *areaDTO.AreaRelationDTO var warehouse *warehouseDTO.WarehouseRelationDTO @@ -787,14 +1288,22 @@ 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") } } - paymentPrice := paymentTotals[reference] - debtPrice := paymentPrice - totalPrice - dueDate := "" dueStatus := "-" if purchase.DueDate != nil && !purchase.DueDate.IsZero() { @@ -808,10 +1317,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") @@ -820,21 +1325,125 @@ 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, DueDate: dueDate, DueStatus: dueStatus, TotalPrice: totalPrice, - PaymentPrice: paymentPrice, - DebtPrice: debtPrice, + PaymentPrice: 0, + DebtPrice: 0, Status: status, TravelNumber: travelNumber, + Balance: 0, } } +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: "-", + Balance: 0, + } +} + +func resolveDebtSupplierSortTime(purchase entity.Purchase, filterBy string, loc *time.Location) time.Time { + if strings.EqualFold(strings.TrimSpace(filterBy), "po_date") { + if purchase.PoDate != nil && !purchase.PoDate.IsZero() { + return purchase.PoDate.In(loc) + } + } + + 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 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 { @@ -862,13 +1471,42 @@ 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].EggProductionWeightKgRemaining = egg.EggProductionWeightKgRemaining + repoRows[rowIdx].EggProductionPiecesRemaining = egg.EggProductionPiecesRemaining + repoRows[rowIdx].EggProductionTotalWeightKg = egg.EggProductionTotalWeightKg + repoRows[rowIdx].EggProductionTotalPieces = egg.EggProductionTotalPieces + } + } + } + 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, @@ -897,15 +1535,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, @@ -918,48 +1556,75 @@ 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 + AvgWeightSum float64 + AvgWeightCount int64 + EggHppSum float64 + EggHppCount int + FeedSuppliers map[int64]dto.HppPerKandangSupplierDTO + DocSuppliers map[int64]dto.HppPerKandangSupplierDTO } 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 var totalEggHppSum float64 var totalEggHppCount int + var totalAvgWeightSum float64 + var totalAvgWeightCount int64 for _, row := range repoRows { - birdsFloat := row.RemainingChickenBirds - if math.IsNaN(birdsFloat) || math.IsInf(birdsFloat, 0) { - birdsFloat = 0 + if !params.ShowUnrecorded && row.RecordingCount == 0 { + continue } - weightFloat := row.RemainingChickenWeight - if math.IsNaN(weightFloat) || math.IsInf(weightFloat, 0) { - weightFloat = 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 } - eggPiecesFloat := row.EggProductionPieces - if math.IsNaN(eggPiecesFloat) || math.IsInf(eggPiecesFloat, 0) { - eggPiecesFloat = 0 + eggTotalPiecesFloat := row.EggProductionTotalPieces + if math.IsNaN(eggTotalPiecesFloat) || math.IsInf(eggTotalPiecesFloat, 0) { + eggTotalPiecesFloat = 0 } - eggWeightFloat := row.EggProductionWeightKg + eggRemainingWeightFloatRemaining := row.EggProductionWeightKgRemaining + if math.IsNaN(eggRemainingWeightFloatRemaining) || math.IsInf(eggRemainingWeightFloatRemaining, 0) { + eggRemainingWeightFloatRemaining = 0 + } + eggWeightFloat := row.EggProductionTotalWeightKg if math.IsNaN(eggWeightFloat) || math.IsInf(eggWeightFloat, 0) { eggWeightFloat = 0 } avgWeight := 0.0 - if birdsFloat > 0 { - avgWeight = weightFloat / birdsFloat + if eggTotalPiecesFloat > 0 { + avgWeight = eggWeightFloat / eggTotalPiecesFloat } + 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 @@ -967,28 +1632,30 @@ func (s *repportService) GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangRes weightMax := weightMin + 0.09 rangeKey := weightRangeKey{Min: weightMin, Max: weightMax} - rowBirds := int64(math.Round(birdsFloat)) - costEntry := costMap[row.KandangID] + // rowBirds := int64(math.Round(birdsFloat)) + costEntry := costMap[row.ProjectFlockKandangID] 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)) } + 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, @@ -1006,32 +1673,35 @@ func (s *repportService) GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangRes WeightMin: weightMin, WeightMax: weightMax, }, - RemainingChickenBirds: rowBirds, - RemainingChickenWeightKg: weightFloat, - AvgWeightKg: avgWeight, + AvgWeightKg: avgWeight, + NameWithPeriode: nameWithPeriod, // FeedCostRp: costEntry.FeedCost, // OvkCostRp: costEntry.OvkCost, - DocSuppliers: docSupplierMap[row.KandangID], - FeedSuppliers: feedSupplierMap[row.KandangID], - EggProductionPieces: rowEggPieces, - EggProductionKg: eggWeightFloat, - AverageDocPriceRp: avgDocPrice, - HppRp: hppRp, - EggHppRpPerKg: eggHpp, - RemainingValueRp: rowRemainingValue, - EggValueRp: rowEggValue, + DocSuppliers: docSupplierMap[row.ProjectFlockKandangID], + FeedSuppliers: feedSupplierMap[row.ProjectFlockKandangID], + EggProductionPieces: int64(math.Round(eggPiecesFloatRemaining)), + EggProductionKg: eggRemainingWeightFloatRemaining, + // EggProductionTotalWeightKg: eggWeightFloat, + // EggProductionTotalPieces: int64(math.Round(eggTotalPiecesFloat)), + AverageDocPriceRp: avgDocPrice, + // HppRp: hppRp, + 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 - if weightFloat > 0 { - totalHppSum += hppRp - totalHppCount++ - } + totalAvgWeightSum += avgWeight + totalAvgWeightCount++ + // if weightFloat > 0 { + // totalHppSum += hppRp + // totalHppCount++ + // } if avgDocPrice > 0 { totalDocPriceSum += float64(avgDocPrice) totalDocPriceCount++ @@ -1051,16 +1721,30 @@ 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 + rangeAgg.AvgWeightSum += avgWeight + rangeAgg.AvgWeightCount++ + for _, supplier := range feedSupplierMap[row.ProjectFlockKandangID] { + if _, ok := rangeAgg.FeedSuppliers[supplier.ID]; !ok { + rangeAgg.FeedSuppliers[supplier.ID] = supplier + } + } + for _, supplier := range docSupplierMap[row.ProjectFlockKandangID] { + if _, ok := rangeAgg.DocSuppliers[supplier.ID]; !ok { + rangeAgg.DocSuppliers[supplier.ID] = supplier + } + } rangeSummary.EggProductionPieces += rowEggPieces - rangeSummary.EggProductionKg += eggWeightFloat - rangeSummary.RemainingValueRp += rowRemainingValue + rangeSummary.EggProductionKg += eggRemainingWeightFloatRemaining + // rangeSummary.RemainingValueRp += rowRemainingValue rangeSummary.EggValueRp += rowEggValue if eggWeightFloat > 0 { rangeAgg.EggHppSum += eggHpp @@ -1084,31 +1768,37 @@ 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.AvgWeightCount > 0 { + entry.AvgWeightKg = agg.AvgWeightSum / float64(agg.AvgWeightCount) } 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) + } + if totalAvgWeightCount > 0 { + totalSummary.AverageWeightKg = totalAvgWeightSum / float64(totalAvgWeightCount) } if totalEggHppCount > 0 { totalSummary.AverageEggHppRpPerKg = totalEggHppSum / float64(totalEggHppCount) } if totalHppCount > 0 { - totalSummary.TotalHppRp = totalHppSum / float64(totalHppCount) } if totalDocPriceCount > 0 { totalSummary.TotalAverageDocPriceRp = totalDocPriceSum / float64(totalDocPriceCount) @@ -1201,6 +1891,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, diff --git a/internal/modules/repports/validations/repport.validation.go b/internal/modules/repports/validations/repport.validation.go index 6c80275f..e0161b5c 100644 --- a/internal/modules/repports/validations/repport.validation.go +++ b/internal/modules/repports/validations/repport.validation.go @@ -17,12 +17,15 @@ 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"` 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"` EndDate string `query:"end_date" validate:"omitempty,datetime=2006-01-02"` @@ -49,13 +52,13 @@ 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"` SortOrder string `query:"sort_order" validate:"omitempty,oneof=asc desc"` } 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:"-"` @@ -67,6 +70,14 @@ 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"` } + +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"` + 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"` +} diff --git a/internal/utils/constant.go b/internal/utils/constant.go index 35ce3132..7d12f5c6 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 + ExpenseStepHeadArea 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", + ExpenseStepHeadArea: "Approval Head Area", + ExpenseStepUnitVicePresident: "Approval Business Unit Vice President", + ExpenseStepFinance: "Approval Finance", + ExpenseStepRealisasi: "Realisasi", + ExpenseStepSelesai: "Selesai", } // ------------------------------------------------------------------- diff --git a/internal/utils/fifo/constants.go b/internal/utils/fifo/constants.go index 03f61f82..840ba8e1 100644 --- a/internal/utils/fifo/constants.go +++ b/internal/utils/fifo/constants.go @@ -2,17 +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" ) 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