From bfef1446683d415b2bcc6e82ebab7f7e86cc3e26 Mon Sep 17 00:00:00 2001 From: giovanni Date: Sun, 31 May 2026 16:23:22 +0700 Subject: [PATCH 1/3] add filter warehouse to marketing;add detail export recording egg; adjust format export marketing --- .../controllers/deliveryorder.controller.go | 1 + .../controllers/deliveryorder.export.go | 276 ++++++++++++++---- .../services/deliveryorder.service.go | 10 + .../validations/deliveryorder.validation.go | 1 + .../controllers/recording.controller.go | 66 +++++ .../controllers/recording.export.go | 182 +++++++++--- .../recordings/dto/recording.dto.go | 18 +- .../repositories/recording.repository.go | 17 ++ .../recordings/services/recording.service.go | 5 + 9 files changed, 478 insertions(+), 98 deletions(-) diff --git a/internal/modules/marketing/controllers/deliveryorder.controller.go b/internal/modules/marketing/controllers/deliveryorder.controller.go index e9ccb285..208d8b48 100644 --- a/internal/modules/marketing/controllers/deliveryorder.controller.go +++ b/internal/modules/marketing/controllers/deliveryorder.controller.go @@ -72,6 +72,7 @@ func (u *DeliveryOrdersController) GetAll(c *fiber.Ctx) error { MarketingId: uint(c.QueryInt("marketing_id", 0)), ProjectFlockID: uint(c.QueryInt("project_flock_id", 0)), ProjectFlockKandangID: uint(c.QueryInt("project_flock_kandang_id", 0)), + WarehouseID: uint(c.QueryInt("warehouse_id", 0)), SortBy: sortBy, SortOrder: sortOrder, } diff --git a/internal/modules/marketing/controllers/deliveryorder.export.go b/internal/modules/marketing/controllers/deliveryorder.export.go index 06b3ab28..7dc20a64 100644 --- a/internal/modules/marketing/controllers/deliveryorder.export.go +++ b/internal/modules/marketing/controllers/deliveryorder.export.go @@ -70,23 +70,26 @@ func buildMarketingExportWorkbook(items []dto.MarketingListDTO) ([]byte, error) } func setMarketingExportColumns(file *excelize.File, sheet string) error { + // A–Q = 17 columns + // E = Sales (new), H = Gudang (new), Satuan (old I) removed columnWidths := map[string]float64{ - "A": 16, - "B": 14, - "C": 18, - "D": 20, - "E": 14, - "F": 40, - "G": 10, - "H": 12, - "I": 12, - "J": 12, - "K": 16, - "L": 16, - "M": 18, - "N": 18, - "O": 18, - "P": 24, + "A": 16, // No. Order + "B": 14, // Tanggal + "C": 18, // Status + "D": 20, // Customer + "E": 20, // Sales (new) + "F": 14, // Tipe + "G": 40, // Nama Produk + "H": 20, // Gudang (new) + "I": 10, // Week + "J": 12, // Jumlah + "K": 12, // Qty Peti + "L": 16, // Berat Rata-rata (kg) + "M": 16, // Total Berat (kg) + "N": 18, // Harga Satuan + "O": 18, // Total Harga + "P": 18, // Grand Total + "Q": 24, // Catatan } for col, width := range columnWidths { @@ -104,22 +107,23 @@ func setMarketingExportColumns(file *excelize.File, sheet string) error { func setMarketingExportHeaders(file *excelize.File, sheet string) error { headers := []string{ - "No. Order", // A - "Tanggal", // B - "Status", // C - "Customer", // D - "Tipe", // E - "Nama Produk", // F - "Week", // G - "Jumlah", // H - "Satuan", // I - "Qty Peti", // J - "Berat Rata-rata (kg)", // K - "Total Berat (kg)", // L - "Harga Satuan", // M - "Total Harga", // N - "Grand Total", // O - "Catatan", // P + "No. Order", // A + "Tanggal", // B + "Status", // C + "Customer", // D + "Sales", // E (new) + "Tipe", // F + "Nama Produk", // G + "Gudang", // H (new) + "Week", // I + "Jumlah Butir", // J + "Qty Peti", // K + "Berat Rata-rata (kg)", // L + "Total Berat (kg)", // M + "Harga Satuan", // N + "Total Harga", // O + "Grand Total", // P + "Catatan", // Q } for i, header := range headers { @@ -148,7 +152,7 @@ func setMarketingExportHeaders(file *excelize.File, sheet string) error { return err } - return file.SetCellStyle(sheet, "A1", "P1", headerStyle) + return file.SetCellStyle(sheet, "A1", "Q1", headerStyle) } func setMarketingExportRows(file *excelize.File, sheet string, items []dto.MarketingListDTO) error { @@ -162,17 +166,161 @@ func setMarketingExportRows(file *excelize.File, sheet string, items []dto.Marke soDate := formatMarketingExportDate(item.SoDate) status := formatMarketingExportStatus(item) customer := safeMarketingExportText(item.Customer.Name) - grandTotal := sumMarketingGrandTotal(item.SalesOrder) notes := safeMarketingExportText(item.Notes) + salesPerson := safeMarketingExportText(item.SalesPerson.Name) + + isDeliveryOrder := strings.EqualFold(strings.TrimSpace(status), "delivery order") + + // ── Delivery Order branch ────────────────────────────────────────────── + if isDeliveryOrder { + grandTotal := sumDeliveryGrandTotal(item.DeliveryOrder) + + if len(item.DeliveryOrder) == 0 { + row++ + r := strconv.Itoa(row) + vals := map[string]interface{}{ + "A": soNumber, "B": soDate, "C": status, "D": customer, "E": salesPerson, + "F": "-", "G": "-", "H": "-", "I": "-", "J": "-", "K": "-", + "L": "-", "M": "-", "N": "-", "O": "-", + "P": grandTotal, "Q": notes, + } + for col, val := range vals { + if err := file.SetCellValue(sheet, col+r, val); err != nil { + return err + } + } + continue + } + + // Build lookup map: MarketingProductId → SO product (for Week & MarketingType) + soProductMap := make(map[uint]*dto.DeliveryMarketingProductDTO, len(item.SalesOrder)) + for i := range item.SalesOrder { + soProductMap[item.SalesOrder[i].Id] = &item.SalesOrder[i] + } + + for _, group := range item.DeliveryOrder { + doNumber := safeMarketingExportText(group.DoNumber) + + doDate := "-" + if group.DeliveryDate != nil { + doDate = formatMarketingExportDate(*group.DeliveryDate) + } + + gudang := "-" + if group.Warehouse != nil { + gudang = safeMarketingExportText(group.Warehouse.Name) + } + + if len(group.Deliveries) == 0 { + row++ + r := strconv.Itoa(row) + vals := map[string]interface{}{ + "A": doNumber, "B": doDate, "C": status, "D": customer, "E": salesPerson, + "F": "-", "G": "-", "H": gudang, "I": "-", "J": "-", "K": "-", + "L": "-", "M": "-", "N": "-", "O": "-", + "P": grandTotal, "Q": notes, + } + for col, val := range vals { + if err := file.SetCellValue(sheet, col+r, val); err != nil { + return err + } + } + continue + } + + for _, delivery := range group.Deliveries { + row++ + r := strconv.Itoa(row) + + productName := "-" + if delivery.ProductWarehouse != nil && delivery.ProductWarehouse.Product != nil { + if n := strings.TrimSpace(delivery.ProductWarehouse.Product.Name); n != "" { + productName = n + } + } + + week := "-" + marketingType := "-" + if soProduct, ok := soProductMap[delivery.MarketingProductId]; ok { + if soProduct.Week != nil { + week = strconv.Itoa(*soProduct.Week) + } + marketingType = safeMarketingExportText(soProduct.MarketingType) + } + + if err := file.SetCellValue(sheet, "A"+r, doNumber); err != nil { + return err + } + if err := file.SetCellValue(sheet, "B"+r, doDate); err != nil { + return err + } + if err := file.SetCellValue(sheet, "C"+r, status); err != nil { + return err + } + if err := file.SetCellValue(sheet, "D"+r, customer); err != nil { + return err + } + if err := file.SetCellValue(sheet, "E"+r, salesPerson); err != nil { + return err + } + if err := file.SetCellValue(sheet, "F"+r, marketingType); err != nil { + return err + } + if err := file.SetCellValue(sheet, "G"+r, productName); err != nil { + return err + } + if err := file.SetCellValue(sheet, "H"+r, gudang); err != nil { + return err + } + if err := file.SetCellValue(sheet, "I"+r, week); err != nil { + return err + } + if err := file.SetCellValue(sheet, "J"+r, delivery.Qty); err != nil { + return err + } + if delivery.TotalPeti != nil { + if err := file.SetCellValue(sheet, "K"+r, *delivery.TotalPeti); err != nil { + return err + } + } else { + if err := file.SetCellValue(sheet, "K"+r, "-"); err != nil { + return err + } + } + if err := file.SetCellValue(sheet, "L"+r, delivery.AvgWeight); err != nil { + return err + } + if err := file.SetCellValue(sheet, "M"+r, delivery.TotalWeight); err != nil { + return err + } + if err := file.SetCellValue(sheet, "N"+r, delivery.UnitPrice); err != nil { + return err + } + if err := file.SetCellValue(sheet, "O"+r, delivery.TotalPrice); err != nil { + return err + } + if err := file.SetCellValue(sheet, "P"+r, grandTotal); err != nil { + return err + } + if err := file.SetCellValue(sheet, "Q"+r, notes); err != nil { + return err + } + } + } + continue + } + + // ── Sales Order branch (all other statuses) ─────────────────────────── + grandTotal := sumMarketingGrandTotal(item.SalesOrder) if len(item.SalesOrder) == 0 { row++ r := strconv.Itoa(row) vals := map[string]interface{}{ - "A": soNumber, "B": soDate, "C": status, "D": customer, - "E": "-", "F": "-", "G": "-", "H": "-", "I": "-", "J": "-", - "K": "-", "L": "-", "M": "-", "N": "-", - "O": grandTotal, "P": notes, + "A": soNumber, "B": soDate, "C": status, "D": customer, "E": salesPerson, + "F": "-", "G": "-", "H": "-", "I": "-", "J": "-", "K": "-", + "L": "-", "M": "-", "N": "-", "O": "-", + "P": grandTotal, "Q": notes, } for col, val := range vals { if err := file.SetCellValue(sheet, col+r, val); err != nil { @@ -198,9 +346,9 @@ func setMarketingExportRows(file *excelize.File, sheet string, items []dto.Marke week = strconv.Itoa(*prod.Week) } - satuan := "-" - if prod.ConvertionUnit != nil && strings.TrimSpace(*prod.ConvertionUnit) != "" { - satuan = *prod.ConvertionUnit + gudang := "-" + if prod.ProductWarehouse != nil { + gudang = safeMarketingExportText(prod.ProductWarehouse.Warehouse.Name) } if err := file.SetCellValue(sheet, "A"+r, soNumber); err != nil { @@ -215,46 +363,49 @@ func setMarketingExportRows(file *excelize.File, sheet string, items []dto.Marke if err := file.SetCellValue(sheet, "D"+r, customer); err != nil { return err } - if err := file.SetCellValue(sheet, "E"+r, safeMarketingExportText(prod.MarketingType)); err != nil { + if err := file.SetCellValue(sheet, "E"+r, salesPerson); err != nil { return err } - if err := file.SetCellValue(sheet, "F"+r, productName); err != nil { + if err := file.SetCellValue(sheet, "F"+r, safeMarketingExportText(prod.MarketingType)); err != nil { return err } - if err := file.SetCellValue(sheet, "G"+r, week); err != nil { + if err := file.SetCellValue(sheet, "G"+r, productName); err != nil { return err } - if err := file.SetCellValue(sheet, "H"+r, prod.Qty); err != nil { + if err := file.SetCellValue(sheet, "H"+r, gudang); err != nil { return err } - if err := file.SetCellValue(sheet, "I"+r, satuan); err != nil { + if err := file.SetCellValue(sheet, "I"+r, week); err != nil { + return err + } + if err := file.SetCellValue(sheet, "J"+r, prod.Qty); err != nil { return err } if prod.TotalPeti != nil { - if err := file.SetCellValue(sheet, "J"+r, *prod.TotalPeti); err != nil { + if err := file.SetCellValue(sheet, "K"+r, *prod.TotalPeti); err != nil { return err } } else { - if err := file.SetCellValue(sheet, "J"+r, "-"); err != nil { + if err := file.SetCellValue(sheet, "K"+r, "-"); err != nil { return err } } - if err := file.SetCellValue(sheet, "K"+r, prod.AvgWeight); err != nil { + if err := file.SetCellValue(sheet, "L"+r, prod.AvgWeight); err != nil { return err } - if err := file.SetCellValue(sheet, "L"+r, prod.TotalWeight); err != nil { + if err := file.SetCellValue(sheet, "M"+r, prod.TotalWeight); err != nil { return err } - if err := file.SetCellValue(sheet, "M"+r, prod.UnitPrice); err != nil { + if err := file.SetCellValue(sheet, "N"+r, prod.UnitPrice); err != nil { return err } - if err := file.SetCellValue(sheet, "N"+r, prod.TotalPrice); err != nil { + if err := file.SetCellValue(sheet, "O"+r, prod.TotalPrice); err != nil { return err } - if err := file.SetCellValue(sheet, "O"+r, grandTotal); err != nil { + if err := file.SetCellValue(sheet, "P"+r, grandTotal); err != nil { return err } - if err := file.SetCellValue(sheet, "P"+r, notes); err != nil { + if err := file.SetCellValue(sheet, "Q"+r, notes); err != nil { return err } } @@ -276,7 +427,7 @@ func setMarketingExportRows(file *excelize.File, sheet string, items []dto.Marke if err != nil { return err } - if err := file.SetCellStyle(sheet, "A2", "P"+lastRowStr, dataStyle); err != nil { + if err := file.SetCellStyle(sheet, "A2", "Q"+lastRowStr, dataStyle); err != nil { return err } @@ -287,7 +438,7 @@ func setMarketingExportRows(file *excelize.File, sheet string, items []dto.Marke if err != nil { return err } - if err := file.SetCellStyle(sheet, "K2", "O"+lastRowStr, numberStyle); err != nil { + if err := file.SetCellStyle(sheet, "L2", "P"+lastRowStr, numberStyle); err != nil { return err } @@ -298,7 +449,7 @@ func setMarketingExportRows(file *excelize.File, sheet string, items []dto.Marke if err != nil { return err } - for _, col := range []string{"G", "H", "J"} { + for _, col := range []string{"I", "J", "K"} { if err := file.SetCellStyle(sheet, col+"2", col+lastRowStr, centerStyle); err != nil { return err } @@ -327,16 +478,23 @@ func formatMarketingExportStatus(item dto.MarketingListDTO) string { return safeMarketingExportText(item.LatestApproval.StepName) } - func sumMarketingGrandTotal(items []dto.DeliveryMarketingProductDTO) float64 { total := 0.0 for _, item := range items { total += item.TotalPrice } - return total } +func sumDeliveryGrandTotal(groups []dto.DeliveryGroupDTO) float64 { + total := 0.0 + for _, g := range groups { + for _, d := range g.Deliveries { + total += d.TotalPrice + } + } + return total +} func safeMarketingExportText(value string) string { trimmed := strings.TrimSpace(value) diff --git a/internal/modules/marketing/services/deliveryorder.service.go b/internal/modules/marketing/services/deliveryorder.service.go index 396a06e3..d442f457 100644 --- a/internal/modules/marketing/services/deliveryorder.service.go +++ b/internal/modules/marketing/services/deliveryorder.service.go @@ -287,6 +287,16 @@ func (s deliveryOrdersService) GetAll(c *fiber.Ctx, params *validation.DeliveryO db = db.Where("marketings.customer_id = ?", params.CustomerId) } + if params.WarehouseID != 0 { + db = db.Where(`EXISTS ( + SELECT 1 + FROM marketing_products mp + JOIN product_warehouses pw ON pw.id = mp.product_warehouse_id + WHERE mp.marketing_id = marketings.id + AND pw.warehouse_id = ? + )`, params.WarehouseID) + } + db = s.applyMarketingProjectFlockFilter(c.Context(), db, params.ProjectFlockID, params.ProjectFlockKandangID) db = s.applyMarketingSearchFilter(c.Context(), db, params.Search) diff --git a/internal/modules/marketing/validations/deliveryorder.validation.go b/internal/modules/marketing/validations/deliveryorder.validation.go index 629a5df6..c602de54 100644 --- a/internal/modules/marketing/validations/deliveryorder.validation.go +++ b/internal/modules/marketing/validations/deliveryorder.validation.go @@ -31,6 +31,7 @@ type DeliveryOrderQuery struct { MarketingId uint `query:"marketing_id" validate:"omitempty,gt=0"` ProjectFlockID uint `query:"project_flock_id" validate:"omitempty,gt=0"` ProjectFlockKandangID uint `query:"project_flock_kandang_id" validate:"omitempty,gt=0"` + WarehouseID uint `query:"warehouse_id" validate:"omitempty,gt=0"` SortBy string `query:"sort_by" validate:"omitempty,oneof=so_number so_date status customer grand_total created_at"` SortOrder string `query:"sort_order" validate:"omitempty,oneof=asc desc"` } diff --git a/internal/modules/production/recordings/controllers/recording.controller.go b/internal/modules/production/recordings/controllers/recording.controller.go index 3bf55546..f7e90f80 100644 --- a/internal/modules/production/recordings/controllers/recording.controller.go +++ b/internal/modules/production/recordings/controllers/recording.controller.go @@ -8,10 +8,12 @@ import ( "time" "gitlab.com/mbugroup/lti-api.git/internal/common/exportprogress" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/dto" service "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/services" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/validations" "gitlab.com/mbugroup/lti-api.git/internal/response" + "gitlab.com/mbugroup/lti-api.git/internal/utils" "github.com/gofiber/fiber/v2" ) @@ -75,6 +77,43 @@ func (u *RecordingController) GetAll(c *fiber.Ctx) error { } listDTO := dto.ToRecordingListDTOs(result) + + recordingIDs := make([]uint, 0, len(result)) + for i := range result { + if result[i].Id != 0 { + recordingIDs = append(recordingIDs, result[i].Id) + } + } + if len(recordingIDs) > 0 { + eggs, err := u.RecordingService.GetEggsWithFlagsByRecordingIDs(c.Context(), recordingIDs) + if err != nil { + return err + } + eggByRecording := make(map[uint][]entity.RecordingEgg, len(recordingIDs)) + for _, egg := range eggs { + eggByRecording[egg.RecordingId] = append(eggByRecording[egg.RecordingId], egg) + } + for i := range listDTO { + id := listDTO[i].Id + if eggList, ok := eggByRecording[id]; ok { + breakdown := make(map[string]dto.EggExportBreakdownDTO) + for _, egg := range eggList { + flagName := eggTypeFromProductName(egg.ProductWarehouse.Product.Name) + if flagName == "" { + continue + } + entry := breakdown[flagName] + entry.Qty += egg.Qty + if egg.Weight != nil { + entry.Kg += *egg.Weight + } + breakdown[flagName] = entry + } + listDTO[i].EggExportBreakdown = breakdown + } + } + } + if strings.EqualFold(exportType, "excel") { return exportRecordingListExcel(c, listDTO) } @@ -94,6 +133,33 @@ func (u *RecordingController) GetAll(c *fiber.Ctx) error { }) } +// eggTypeFromProductName maps product name to egg type flag name by keyword matching. +// Falls back to empty string if no keyword matches. +func eggTypeFromProductName(name string) string { + normalized := strings.ToLower(strings.TrimSpace(name)) + if normalized == "" { + return "" + } + // Ordered longest-first to prefer "papacal" over partial match of "pacal", etc. + keywords := []struct { + keyword string + flag string + }{ + {"papacal", string(utils.FlagTelurPapacal)}, + {"jumbo", string(utils.FlagTelurJumbo)}, + {"retak", string(utils.FlagTelurRetak)}, + {"putih", string(utils.FlagTelurPutih)}, + {"pecah", string(utils.FlagTelurPecah)}, + {"utuh", string(utils.FlagTelurUtuh)}, + } + for _, k := range keywords { + if strings.Contains(normalized, k.keyword) { + return k.flag + } + } + return "" +} + func (u *RecordingController) GetOne(c *fiber.Ctx) error { param := c.Params("id") diff --git a/internal/modules/production/recordings/controllers/recording.export.go b/internal/modules/production/recordings/controllers/recording.export.go index fc514fbd..6260f1de 100644 --- a/internal/modules/production/recordings/controllers/recording.export.go +++ b/internal/modules/production/recordings/controllers/recording.export.go @@ -8,6 +8,7 @@ import ( "time" "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/dto" + "gitlab.com/mbugroup/lti-api.git/internal/utils" "github.com/gofiber/fiber/v2" "github.com/xuri/excelize/v2" @@ -79,6 +80,18 @@ func setRecordingExportColumns(file *excelize.File, sheet string) error { "AB": 18, "AC": 24, "AD": 18, + "AE": 12, + "AF": 10, + "AG": 12, + "AH": 10, + "AI": 12, + "AJ": 10, + "AK": 12, + "AL": 10, + "AM": 12, + "AN": 10, + "AO": 12, + "AP": 10, } for col, width := range columnWidths { @@ -208,6 +221,31 @@ func setRecordingExportHeaders(file *excelize.File, sheet string) error { return err } + eggTypes := []struct { + col1, col2, label string + }{ + {"AE", "AF", "Telur Utuh"}, + {"AG", "AH", "Telur Pecah"}, + {"AI", "AJ", "Telur Putih"}, + {"AK", "AL", "Telur Retak"}, + {"AM", "AN", "Telur Papacal"}, + {"AO", "AP", "Telur Jumbo"}, + } + for _, et := range eggTypes { + if err := file.MergeCell(sheet, et.col1+"1", et.col2+"1"); err != nil { + return err + } + if err := file.SetCellValue(sheet, et.col1+"1", et.label); err != nil { + return err + } + if err := file.SetCellValue(sheet, et.col1+"2", "Butir"); err != nil { + return err + } + if err := file.SetCellValue(sheet, et.col2+"2", "Kg"); err != nil { + return err + } + } + headerStyle, err := file.NewStyle(&excelize.Style{ Font: &excelize.Font{ Bold: true, @@ -234,7 +272,7 @@ func setRecordingExportHeaders(file *excelize.File, sheet string) error { return err } - return file.SetCellStyle(sheet, "A1", "AD2", headerStyle) + return file.SetCellStyle(sheet, "A1", "AP2", headerStyle) } func setRecordingExportRows(file *excelize.File, sheet string, items []dto.RecordingListDTO) error { @@ -245,7 +283,8 @@ func setRecordingExportRows(file *excelize.File, sheet string, items []dto.Recor columns := []string{ "A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M", "N", "O", "P", "Q", "R", "S", "T", "U", "V", "W", "X", "Y", "Z", "AA", "AB", - "AC", "AD", + "AC", "AD", "AE", "AF", "AG", "AH", "AI", "AJ", "AK", "AL", "AM", "AN", + "AO", "AP", } currentRow := 3 @@ -293,14 +332,14 @@ func setRecordingExportRows(file *excelize.File, sheet string, items []dto.Recor // Expand recordings into one row per sapronak type sapronakRow struct { name string - input string + input interface{} // float64 for numeric, string "-" for placeholder } sapronaks := make([]sapronakRow, 0) if len(item.FeedUsage) > 0 { for _, fu := range item.FeedUsage { sapronaks = append(sapronaks, sapronakRow{ name: safeExportText(fu.ProductName), - input: formatNumberID(fu.UsageAmount+fu.PendingQty, 2, true), + input: fu.UsageAmount + fu.PendingQty, }) } } else { @@ -311,37 +350,66 @@ func setRecordingExportRows(file *excelize.File, sheet string, items []dto.Recor for sIdx, s := range sapronaks { if sIdx == 0 { + eggQty := func(flagName string) int { + if item.EggExportBreakdown != nil { + if bd, ok := item.EggExportBreakdown[flagName]; ok { + return bd.Qty + } + } + return 0 + } + eggKg := func(flagName string) float64 { + if item.EggExportBreakdown != nil { + if bd, ok := item.EggExportBreakdown[flagName]; ok { + return bd.Kg + } + } + return 0 + } + rowValues := []interface{}{ - i + 1, // A - locationName, // B - safeExportText(item.ProjectFlock.FlockName), // C - kandangName, // D - item.ProjectFlock.Period, // E + i + 1, // A + locationName, // B + safeExportText(item.ProjectFlock.FlockName), // C + kandangName, // D + item.ProjectFlock.Period, // E formatCategoryLabel(item.ProjectFlock.ProjectFlockCategory), // F - formatAgeLabel(item), // G - formatDateIndonesian(item.RecordDatetime), // H - formatNumberID(item.ProjectFlock.TotalChickQty, 0, false), // I - formatNumberID(item.FcrValue, 2, true), // J - formatNumberID(fcrStd, 2, true), // K - formatNumberID(item.FeedIntake, 2, true), // L - formatNumberID(feedIntakeStd, 2, true), // M - formatPercentID(item.CumDepletionRate, 2), // N - formatPercentID(maxDepletionStd, 2), // O - formatNumberID(item.TotalDepletionQty, 2, true), // P - formatNumberID(item.EggMass, 2, true), // Q - formatNumberID(eggMassStd, 2, true), // R - formatNumberID(item.EggWeight, 2, true), // S - formatNumberID(eggWeightStd, 2, true), // T - formatPercentID(item.HenDay, 2), // U - formatPercentID(henDayStd, 2), // V - formatPercentID(item.HenHouse, 2), // W - formatPercentID(henHouseStd, 2), // X - formatApprovalStatus(item), // Y + formatAgeLabel(item), // G + formatDateIndonesian(item.RecordDatetime), // H + item.ProjectFlock.TotalChickQty, // I + item.FcrValue, // J + fcrStd, // K + item.FeedIntake, // L + feedIntakeStd, // M + item.CumDepletionRate, // N + maxDepletionStd, // O + item.TotalDepletionQty, // P + item.EggMass, // Q + eggMassStd, // R + item.EggWeight, // S + eggWeightStd, // T + item.HenDay, // U + henDayStd, // V + item.HenHouse, // W + henHouseStd, // X + formatApprovalStatus(item), // Y safeExportText(pointerString(item.Approval.Notes)), // Z - createdBy, // AA - formatDateIndonesian(item.CreatedAt), // AB - s.name, // AC - s.input, // AD + createdBy, // AA + formatDateIndonesian(item.CreatedAt), // AB + s.name, // AC + s.input, // AD + eggQty(string(utils.FlagTelurUtuh)), // AE + eggKg(string(utils.FlagTelurUtuh)), // AF + eggQty(string(utils.FlagTelurPecah)), // AG + eggKg(string(utils.FlagTelurPecah)), // AH + eggQty(string(utils.FlagTelurPutih)), // AI + eggKg(string(utils.FlagTelurPutih)), // AJ + eggQty(string(utils.FlagTelurRetak)), // AK + eggKg(string(utils.FlagTelurRetak)), // AL + eggQty(string(utils.FlagTelurPapacal)), // AM + eggKg(string(utils.FlagTelurPapacal)), // AN + eggQty(string(utils.FlagTelurJumbo)), // AO + eggKg(string(utils.FlagTelurJumbo)), // AP } for idx, col := range columns { @@ -379,7 +447,7 @@ func setRecordingExportRows(file *excelize.File, sheet string, items []dto.Recor if err != nil { return err } - if err := file.SetCellStyle(sheet, "A3", fmt.Sprintf("AD%d", lastRow), dataCenterStyle); err != nil { + if err := file.SetCellStyle(sheet, "A3", fmt.Sprintf("AP%d", lastRow), dataCenterStyle); err != nil { return err } @@ -445,6 +513,7 @@ func setRecordingExportRows(file *excelize.File, sheet string, items []dto.Recor mergeCols := []string{"A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M", "N", "O", "P", "Q", "R", "S", "T", "U", "V", "W", "X", "Y", "Z", "AA", "AB", + "AE", "AF", "AG", "AH", "AI", "AJ", "AK", "AL", "AM", "AN", "AO", "AP", } for _, rng := range itemRanges { if rng.end > rng.start { @@ -454,6 +523,53 @@ func setRecordingExportRows(file *excelize.File, sheet string, items []dto.Recor } file.SetCellStyle(sheet, fmt.Sprintf("AC%d", rng.end), fmt.Sprintf("AC%d", rng.end), borderBottomLeftStyle) file.SetCellStyle(sheet, fmt.Sprintf("AD%d", rng.end), fmt.Sprintf("AD%d", rng.end), borderBottomCenterStyle) + // Egg columns use center + thick bottom border + for _, col := range []string{"AE", "AF", "AG", "AH", "AI", "AJ", "AK", "AL", "AM", "AN", "AO", "AP"} { + file.SetCellStyle(sheet, fmt.Sprintf("%s%d", col, rng.end), fmt.Sprintf("%s%d", col, rng.end), borderBottomCenterStyle) + } + } + + numFmtInt := "0" + numberIntStyle, err := file.NewStyle(&excelize.Style{ + CustomNumFmt: &numFmtInt, + Alignment: &excelize.Alignment{Horizontal: "center", Vertical: "center", WrapText: true}, + Border: []excelize.Border{ + {Type: "left", Color: "E6E6E6", Style: 1}, + {Type: "top", Color: "E6E6E6", Style: 1}, + {Type: "bottom", Color: "E6E6E6", Style: 1}, + {Type: "right", Color: "E6E6E6", Style: 1}, + }, + }) + if err != nil { + return err + } + numFmtFloat := "0.00" + numberFloatStyle, err := file.NewStyle(&excelize.Style{ + CustomNumFmt: &numFmtFloat, + Alignment: &excelize.Alignment{Horizontal: "center", Vertical: "center", WrapText: true}, + Border: []excelize.Border{ + {Type: "left", Color: "E6E6E6", Style: 1}, + {Type: "top", Color: "E6E6E6", Style: 1}, + {Type: "bottom", Color: "E6E6E6", Style: 1}, + {Type: "right", Color: "E6E6E6", Style: 1}, + }, + }) + if err != nil { + return err + } + + intCols := []string{"E", "I", "AE", "AG", "AI", "AK", "AM", "AO"} + for _, col := range intCols { + if err := file.SetCellStyle(sheet, col+"3", fmt.Sprintf("%s%d", col, lastRow), numberIntStyle); err != nil { + return err + } + } + + floatCols := []string{"J", "K", "L", "M", "N", "O", "P", "Q", "R", "S", "T", "U", "V", "W", "X", "AD", "AF", "AH", "AJ", "AL", "AN", "AP"} + for _, col := range floatCols { + if err := file.SetCellStyle(sheet, col+"3", fmt.Sprintf("%s%d", col, lastRow), numberFloatStyle); err != nil { + return err + } } return nil diff --git a/internal/modules/production/recordings/dto/recording.dto.go b/internal/modules/production/recordings/dto/recording.dto.go index b24bfd68..f8e6cf22 100644 --- a/internal/modules/production/recordings/dto/recording.dto.go +++ b/internal/modules/production/recordings/dto/recording.dto.go @@ -100,14 +100,20 @@ type RecordingFeedUsageDTO struct { PendingQty float64 `json:"pending_qty"` } +type EggExportBreakdownDTO struct { + Qty int `json:"qty"` + Kg float64 `json:"kg"` +} + type RecordingListDTO struct { RecordingRelationDTO - 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"` - FeedUsage []RecordingFeedUsageDTO `json:"feed_usage,omitempty"` + 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"` + FeedUsage []RecordingFeedUsageDTO `json:"feed_usage,omitempty"` + EggExportBreakdown map[string]EggExportBreakdownDTO `json:"egg_breakdown,omitempty"` } type RecordingDetailDTO struct { diff --git a/internal/modules/production/recordings/repositories/recording.repository.go b/internal/modules/production/recordings/repositories/recording.repository.go index ca989359..7a829ead 100644 --- a/internal/modules/production/recordings/repositories/recording.repository.go +++ b/internal/modules/production/recordings/repositories/recording.repository.go @@ -51,6 +51,7 @@ type RecordingRepository interface { UpdateEggTotalQty(tx *gorm.DB, eggID uint, totalQty float64) error UpdateEggWeight(tx *gorm.DB, eggID uint, weight *float64) error GetRecordingEggByID(ctx context.Context, id uint, modifier func(*gorm.DB) *gorm.DB) (*entity.RecordingEgg, error) + GetEggsWithFlagsByRecordingIDs(ctx context.Context, recordingIDs []uint) ([]entity.RecordingEgg, error) ExistsOnDate(ctx context.Context, projectFlockKandangId uint, recordTime time.Time) (bool, error) @@ -581,6 +582,22 @@ func (r *RecordingRepositoryImpl) GetRecordingEggByID( return &egg, nil } +func (r *RecordingRepositoryImpl) GetEggsWithFlagsByRecordingIDs(ctx context.Context, recordingIDs []uint) ([]entity.RecordingEgg, error) { + if len(recordingIDs) == 0 { + return nil, nil + } + + var eggs []entity.RecordingEgg + err := r.DB().WithContext(ctx). + Preload("ProductWarehouse.Product"). + Where("recording_eggs.recording_id IN ?", recordingIDs). + Find(&eggs).Error + if err != nil { + return nil, err + } + return eggs, nil +} + func (r *RecordingRepositoryImpl) ExistsOnDate(ctx context.Context, projectFlockKandangId uint, recordTime time.Time) (bool, error) { if projectFlockKandangId == 0 { return false, nil diff --git a/internal/modules/production/recordings/services/recording.service.go b/internal/modules/production/recordings/services/recording.service.go index f06216eb..a4647980 100644 --- a/internal/modules/production/recordings/services/recording.service.go +++ b/internal/modules/production/recordings/services/recording.service.go @@ -46,6 +46,7 @@ type RecordingService interface { DeleteOne(ctx *fiber.Ctx, id uint) error Approval(ctx *fiber.Ctx, req *validation.Approve) ([]entity.Recording, error) GetProgressRows(ctx *fiber.Ctx, query *exportprogress.Query) ([]exportprogress.Row, error) + GetEggsWithFlagsByRecordingIDs(ctx context.Context, recordingIDs []uint) ([]entity.RecordingEgg, error) } type recordingService struct { @@ -259,6 +260,10 @@ func (s recordingService) GetProgressRows(c *fiber.Ctx, query *exportprogress.Qu return s.Repository.GetProgressRows(c.Context(), query.StartDate, query.EndDate, scope.IDs, scope.Restrict) } +func (s recordingService) GetEggsWithFlagsByRecordingIDs(ctx context.Context, recordingIDs []uint) ([]entity.RecordingEgg, error) { + return s.Repository.GetEggsWithFlagsByRecordingIDs(ctx, recordingIDs) +} + func (s recordingService) GetOne(c *fiber.Ctx, id uint) (*entity.Recording, error) { if err := m.EnsureRecordingAccess(c, s.Repository.DB(), id); err != nil { return nil, err From 90efd0ba5a250e8e5d8db9062f74123b28e95093 Mon Sep 17 00:00:00 2001 From: giovanni Date: Sun, 31 May 2026 16:25:16 +0700 Subject: [PATCH 2/3] add command to fix reconcile fifo; fix fifo stock v2 --- cmd/reconcile-fifo-total-used/main.go | 484 ++++++++++++++++++ .../fifo_stock_v2/population_allocation.go | 11 +- 2 files changed, 494 insertions(+), 1 deletion(-) create mode 100644 cmd/reconcile-fifo-total-used/main.go diff --git a/cmd/reconcile-fifo-total-used/main.go b/cmd/reconcile-fifo-total-used/main.go new file mode 100644 index 00000000..8f825aa1 --- /dev/null +++ b/cmd/reconcile-fifo-total-used/main.go @@ -0,0 +1,484 @@ +// Command reconcile-fifo-total-used memperbaiki "phantom total_used" pada +// stockable lot FIFO v2 (recording_eggs, stock_transfer_details, dst.). +// +// LATAR BELAKANG +// Sebelum fix di population_allocation.go, ReleaseByUsable melepas SEMUA alokasi +// CONSUME sebuah usable (termasuk RECORDING_EGG / STOCK_TRANSFER_IN) tanpa +// men-decrement total_used stockable-nya. Akibatnya total_used "nyangkut" lebih +// besar dari jumlah alokasi ACTIVE yang membackup-nya (phantom) → available +// dihitung 0 padahal stok fisik ada → Delivery Order telur nyangkut di pending. +// +// PERBAIKAN +// Sumber kebenaran konsumsi = stock_allocations status ACTIVE & purpose CONSUME. +// Command ini menyetel ulang total_used setiap lot = SUM(alokasi ACTIVE CONSUME +// untuk lot itu), lalu menjalankan FIFO v2 Reflow per (PW, flag group) sehingga +// pending dialokasi ulang ke stok yang kini available dan product_warehouses.qty +// dihitung ulang. +// +// PENTING: jalankan command ini SETELAH fix kode (population_allocation.go) +// ter-deploy, dan SEBELUM mengaktifkan blok over-sell telur. +// +// Cara pakai: +// +// go run ./cmd/reconcile-fifo-total-used/ -pw=1292 # dry-run 1 PW +// go run ./cmd/reconcile-fifo-total-used/ -pw=1292 -apply # apply 1 PW +// go run ./cmd/reconcile-fifo-total-used/ -pw=1292,1296,1268 -apply +// go run ./cmd/reconcile-fifo-total-used/ -pw=1292 -apply -output=json +package main + +import ( + "context" + "encoding/json" + "flag" + "fmt" + "log" + "os" + "regexp" + "strconv" + "strings" + "time" + + commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" + "gitlab.com/mbugroup/lti-api.git/internal/config" + "gitlab.com/mbugroup/lti-api.git/internal/database" + + "github.com/sirupsen/logrus" + "gorm.io/gorm" + gormlogger "gorm.io/gorm/logger" +) + +const ( + outputTable = "table" + outputJSON = "json" +) + +var identifierRe = regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_]*$`) + +type options struct { + Apply bool + Output string + DBSSLMode string + PWs []uint +} + +// stockableRule menggambarkan satu jenis stockable (mis. RECORDING_EGG) beserta +// tabel & kolom yang dipakai FIFO v2 untuk melacak stok masuk. +type stockableRule struct { + LegacyTypeKey string + SourceTable string + SourceIDColumn string + UsedQuantityCol string + ProductWarehouseCol string + QuantityCol string + ScopeSQL string +} + +type pwResult struct { + ProductWarehouseID uint `json:"product_warehouse_id"` + Product string `json:"product"` + Warehouse string `json:"warehouse"` + FlagGroups []string `json:"flag_groups"` + + QtyBefore float64 `json:"qty_before"` + TotalUsedBefore float64 `json:"total_used_before"` + ActiveConsume float64 `json:"active_consume"` + Phantom float64 `json:"phantom"` + PendingBefore float64 `json:"pending_before"` + + QtyAfter float64 `json:"qty_after,omitempty"` + PendingAfter float64 `json:"pending_after,omitempty"` + + Status string `json:"status"` + Error string `json:"error,omitempty"` +} + +type runSummary struct { + Mode string `json:"mode"` + TargetPWs []uint `json:"target_pws"` + Results []pwResult `json:"results"` + DurationSeconds float64 `json:"duration_seconds"` + OverallStatus string `json:"overall_status"` +} + +func main() { + opts, err := parseFlags() + if err != nil { + log.Fatalf("invalid flags: %v", err) + } + if opts.DBSSLMode != "" { + config.DBSSLMode = opts.DBSSLMode + } + + ctx := context.Background() + db := database.Connect(config.DBHost, config.DBName) + // Quiet the per-query GORM logging; this command emits its own summary and + // the reflow step would otherwise produce a very noisy query log. + db = db.Session(&gorm.Session{Logger: gormlogger.Default.LogMode(gormlogger.Silent)}) + + logger := logrus.New() + logger.SetLevel(logrus.WarnLevel) + svc := commonSvc.NewFifoStockV2Service(db, logger) + + start := time.Now() + + stockableRules, err := loadStockableRules(ctx, db) + if err != nil { + log.Fatalf("failed to load stockable route rules: %v", err) + } + pendingRules, err := loadUsablePendingRules(ctx, db) + if err != nil { + log.Fatalf("failed to load usable route rules: %v", err) + } + + summary := runSummary{ + Mode: modeLabel(opts.Apply), + TargetPWs: opts.PWs, + OverallStatus: "PASS", + } + + for _, pw := range opts.PWs { + res := reconcilePW(ctx, db, svc, pw, stockableRules, pendingRules, opts.Apply) + if res.Status == "FAIL" { + summary.OverallStatus = "FAIL" + } + summary.Results = append(summary.Results, res) + } + + summary.DurationSeconds = time.Since(start).Seconds() + render(opts.Output, summary) + + if !opts.Apply { + fmt.Println("\nDry-run only. Re-run with -apply to reset total_used and reflow the PW(s) above.") + } + if summary.OverallStatus == "FAIL" { + os.Exit(1) + } +} + +// reconcilePW mengukur kondisi PW, lalu (jika -apply) menyetel ulang total_used +// tiap lot dan menjalankan reflow, semuanya dalam satu transaksi. +func reconcilePW( + ctx context.Context, + db *gorm.DB, + svc commonSvc.FifoStockV2Service, + pw uint, + stockableRules []stockableRule, + pendingRules []stockableRule, + apply bool, +) pwResult { + res := pwResult{ProductWarehouseID: pw, Status: "OK"} + + if name, wh, err := loadPWIdentity(ctx, db, pw); err != nil { + res.Status = "FAIL" + res.Error = fmt.Sprintf("load identity: %v", err) + return res + } else { + res.Product, res.Warehouse = name, wh + } + + flagGroups, err := loadFlagGroups(ctx, db, pw) + if err != nil { + res.Status = "FAIL" + res.Error = fmt.Sprintf("load flag groups: %v", err) + return res + } + res.FlagGroups = flagGroups + + res.QtyBefore, _ = loadQty(ctx, db, pw) + res.TotalUsedBefore, _ = sumStockableUsed(ctx, db, pw, stockableRules) + res.ActiveConsume, _ = loadActiveConsume(ctx, db, pw) + res.PendingBefore, _ = sumPending(ctx, db, pw, pendingRules) + res.Phantom = res.TotalUsedBefore - res.ActiveConsume + + if !apply { + return res + } + + err = db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + for _, rule := range stockableRules { + if err := recomputeUsed(ctx, tx, rule, pw); err != nil { + return fmt.Errorf("recompute %s: %w", rule.LegacyTypeKey, err) + } + } + for _, fg := range flagGroups { + if _, err := svc.Reflow(ctx, commonSvc.FifoStockV2ReflowRequest{ + FlagGroupCode: fg, + ProductWarehouseID: pw, + Tx: tx, + }); err != nil { + return fmt.Errorf("reflow flag_group=%s: %w", fg, err) + } + } + return nil + }) + if err != nil { + res.Status = "FAIL" + res.Error = err.Error() + return res + } + + res.QtyAfter, _ = loadQty(ctx, db, pw) + res.PendingAfter, _ = sumPending(ctx, db, pw, pendingRules) + return res +} + +func recomputeUsed(ctx context.Context, tx *gorm.DB, rule stockableRule, pw uint) error { + q := fmt.Sprintf(` +UPDATE %s t +SET %s = COALESCE(( + SELECT SUM(sa.qty) FROM stock_allocations sa + WHERE sa.stockable_type = ? + AND sa.stockable_id = t.%s + AND sa.status = 'ACTIVE' + AND sa.allocation_purpose = 'CONSUME' +), 0) +WHERE t.%s = ?`, rule.SourceTable, rule.UsedQuantityCol, rule.SourceIDColumn, rule.ProductWarehouseCol) + if strings.TrimSpace(rule.ScopeSQL) != "" { + q += " AND (" + rule.ScopeSQL + ")" + } + return tx.WithContext(ctx).Exec(q, rule.LegacyTypeKey, pw).Error +} + +// ---- loaders ---- + +func loadStockableRules(ctx context.Context, db *gorm.DB) ([]stockableRule, error) { + type row struct { + LegacyTypeKey string `gorm:"column:legacy_type_key"` + SourceTable string `gorm:"column:source_table"` + SourceIDColumn string `gorm:"column:source_id_column"` + UsedQuantityCol string `gorm:"column:used_quantity_col"` + ProductWarehouseCol string `gorm:"column:product_warehouse_col"` + QuantityCol string `gorm:"column:quantity_col"` + ScopeSQL string `gorm:"column:scope_sql"` + } + var rows []row + err := db.WithContext(ctx). + Table("fifo_stock_v2_route_rules"). + Select("DISTINCT legacy_type_key, source_table, source_id_column, COALESCE(used_quantity_col,'') AS used_quantity_col, product_warehouse_col, COALESCE(quantity_col,'') AS quantity_col, COALESCE(scope_sql,'') AS scope_sql"). + Where("lane = ? AND is_active = TRUE", "STOCKABLE"). + Where("used_quantity_col IS NOT NULL AND used_quantity_col <> ''"). + Scan(&rows).Error + if err != nil { + return nil, err + } + + out := make([]stockableRule, 0, len(rows)) + seen := map[string]bool{} + for _, r := range rows { + if !validIdentifiers(r.SourceTable, r.SourceIDColumn, r.UsedQuantityCol, r.ProductWarehouseCol) { + return nil, fmt.Errorf("unsafe identifier in route rule %s (table=%s used=%s pw=%s)", r.LegacyTypeKey, r.SourceTable, r.UsedQuantityCol, r.ProductWarehouseCol) + } + key := r.LegacyTypeKey + "|" + r.SourceTable + "|" + r.UsedQuantityCol + "|" + r.ProductWarehouseCol + if seen[key] { + continue + } + seen[key] = true + out = append(out, stockableRule(r)) + } + return out, nil +} + +func loadUsablePendingRules(ctx context.Context, db *gorm.DB) ([]stockableRule, error) { + type row struct { + SourceTable string `gorm:"column:source_table"` + ProductWarehouseCol string `gorm:"column:product_warehouse_col"` + PendingCol string `gorm:"column:pending_quantity_col"` + ScopeSQL string `gorm:"column:scope_sql"` + } + var rows []row + err := db.WithContext(ctx). + Table("fifo_stock_v2_route_rules"). + Select("DISTINCT source_table, product_warehouse_col, pending_quantity_col, COALESCE(scope_sql,'') AS scope_sql"). + Where("lane = ? AND is_active = TRUE", "USABLE"). + Where("pending_quantity_col IS NOT NULL AND pending_quantity_col <> ''"). + Scan(&rows).Error + if err != nil { + return nil, err + } + out := make([]stockableRule, 0, len(rows)) + seen := map[string]bool{} + for _, r := range rows { + if !validIdentifiers(r.SourceTable, r.ProductWarehouseCol, r.PendingCol) { + return nil, fmt.Errorf("unsafe identifier in usable rule (table=%s pw=%s pending=%s)", r.SourceTable, r.ProductWarehouseCol, r.PendingCol) + } + key := r.SourceTable + "|" + r.PendingCol + "|" + r.ProductWarehouseCol + if seen[key] { + continue + } + seen[key] = true + out = append(out, stockableRule{ + SourceTable: r.SourceTable, + ProductWarehouseCol: r.ProductWarehouseCol, + UsedQuantityCol: r.PendingCol, // reuse field as the column to SUM + ScopeSQL: r.ScopeSQL, + }) + } + return out, nil +} + +func loadPWIdentity(ctx context.Context, db *gorm.DB, pw uint) (string, string, error) { + type row struct { + Product string `gorm:"column:product"` + Warehouse string `gorm:"column:warehouse"` + } + var out row + err := db.WithContext(ctx). + Table("product_warehouses pw"). + Select("p.name AS product, w.name AS warehouse"). + Joins("JOIN products p ON p.id = pw.product_id"). + Joins("JOIN warehouses w ON w.id = pw.warehouse_id"). + Where("pw.id = ?", pw). + Take(&out).Error + return out.Product, out.Warehouse, err +} + +func loadFlagGroups(ctx context.Context, db *gorm.DB, pw uint) ([]string, error) { + var groups []string + err := db.WithContext(ctx). + Table("stock_allocations"). + Distinct("flag_group_code"). + Where("product_warehouse_id = ? AND flag_group_code IS NOT NULL AND flag_group_code <> ''", pw). + Order("flag_group_code ASC"). + Scan(&groups).Error + return groups, err +} + +func loadQty(ctx context.Context, db *gorm.DB, pw uint) (float64, error) { + var v float64 + err := db.WithContext(ctx). + Table("product_warehouses"). + Select("COALESCE(qty,0)"). + Where("id = ?", pw). + Scan(&v).Error + return v, err +} + +func loadActiveConsume(ctx context.Context, db *gorm.DB, pw uint) (float64, error) { + var v float64 + err := db.WithContext(ctx). + Table("stock_allocations"). + Select("COALESCE(SUM(qty),0)"). + Where("product_warehouse_id = ? AND status = 'ACTIVE' AND allocation_purpose = 'CONSUME'", pw). + Scan(&v).Error + return v, err +} + +func sumStockableUsed(ctx context.Context, db *gorm.DB, pw uint, rules []stockableRule) (float64, error) { + total := 0.0 + for _, rule := range rules { + v, err := sumColumn(ctx, db, rule.SourceTable, rule.UsedQuantityCol, rule.ProductWarehouseCol, rule.ScopeSQL, pw) + if err != nil { + return total, err + } + total += v + } + return total, nil +} + +func sumPending(ctx context.Context, db *gorm.DB, pw uint, rules []stockableRule) (float64, error) { + total := 0.0 + for _, rule := range rules { + v, err := sumColumn(ctx, db, rule.SourceTable, rule.UsedQuantityCol, rule.ProductWarehouseCol, rule.ScopeSQL, pw) + if err != nil { + return total, err + } + total += v + } + return total, nil +} + +func sumColumn(ctx context.Context, db *gorm.DB, table, col, pwCol, scope string, pw uint) (float64, error) { + q := fmt.Sprintf("SELECT COALESCE(SUM(%s),0) FROM %s WHERE %s = ?", col, table, pwCol) + if strings.TrimSpace(scope) != "" { + q += " AND (" + scope + ")" + } + var v float64 + err := db.WithContext(ctx).Raw(q, pw).Scan(&v).Error + return v, err +} + +// ---- flags / render ---- + +func parseFlags() (*options, error) { + var opts options + var pwsRaw string + flag.BoolVar(&opts.Apply, "apply", false, "Apply the reconciliation (omit for dry-run)") + flag.StringVar(&opts.Output, "output", outputTable, "Output format: table or json") + flag.StringVar(&opts.DBSSLMode, "db-sslmode", "", "Database sslmode override") + flag.StringVar(&pwsRaw, "pw", "", "Comma-separated product_warehouse ids to reconcile (required)") + flag.Parse() + + opts.Output = strings.ToLower(strings.TrimSpace(opts.Output)) + if opts.Output == "" { + opts.Output = outputTable + } + if opts.Output != outputTable && opts.Output != outputJSON { + return nil, fmt.Errorf("unsupported --output=%s", opts.Output) + } + + pwsRaw = strings.TrimSpace(pwsRaw) + if pwsRaw == "" { + return nil, fmt.Errorf("-pw is required (e.g. -pw=1292 or -pw=1292,1296)") + } + for _, part := range strings.Split(pwsRaw, ",") { + part = strings.TrimSpace(part) + if part == "" { + continue + } + id, err := strconv.ParseUint(part, 10, 64) + if err != nil || id == 0 { + return nil, fmt.Errorf("invalid product_warehouse id %q", part) + } + opts.PWs = append(opts.PWs, uint(id)) + } + if len(opts.PWs) == 0 { + return nil, fmt.Errorf("no valid product_warehouse ids parsed from -pw") + } + return &opts, nil +} + +func validIdentifiers(ids ...string) bool { + for _, id := range ids { + if !identifierRe.MatchString(id) { + return false + } + } + return true +} + +func modeLabel(apply bool) string { + if apply { + return "APPLY" + } + return "DRY_RUN" +} + +func render(mode string, summary runSummary) { + if mode == outputJSON { + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + _ = enc.Encode(summary) + return + } + + fmt.Printf("=== Reconcile FIFO total_used ===\n") + fmt.Printf("Mode : %s\n", summary.Mode) + for _, r := range summary.Results { + fmt.Printf("\n--- PW %d (%s @ %s) [%s] ---\n", r.ProductWarehouseID, r.Product, r.Warehouse, r.Status) + if r.Error != "" { + fmt.Printf("ERROR : %s\n", r.Error) + } + fmt.Printf("Flag groups : %s\n", strings.Join(r.FlagGroups, ", ")) + fmt.Printf("qty (before) : %.3f\n", r.QtyBefore) + fmt.Printf("Σ total_used : %.3f\n", r.TotalUsedBefore) + fmt.Printf("Σ active CONSUME: %.3f\n", r.ActiveConsume) + fmt.Printf("PHANTOM : %.3f (total_used yang akan dilepas)\n", r.Phantom) + fmt.Printf("pending (before): %.3f\n", r.PendingBefore) + if summary.Mode == "APPLY" && r.Status == "OK" { + fmt.Printf("qty (after) : %.3f\n", r.QtyAfter) + fmt.Printf("pending (after) : %.3f\n", r.PendingAfter) + } + } + fmt.Printf("\nDuration : %.2fs\n", summary.DurationSeconds) + fmt.Printf("Overall status : %s\n", summary.OverallStatus) +} diff --git a/internal/common/service/fifo_stock_v2/population_allocation.go b/internal/common/service/fifo_stock_v2/population_allocation.go index ce961564..3bc0e1aa 100644 --- a/internal/common/service/fifo_stock_v2/population_allocation.go +++ b/internal/common/service/fifo_stock_v2/population_allocation.go @@ -45,7 +45,16 @@ func ReleasePopulationConsumptionByUsable( } } - return stockAllocationRepo.ReleaseByUsable(ctx, usableType, usableID, nil, nil) + // Only release the PROJECT_FLOCK_POPULATION allocations here. Releasing the + // other CONSUME allocations of this usable (RECORDING_EGG, STOCK_TRANSFER_IN, + // PURCHASE_ITEMS, etc.) would orphan their stockable total_used because this + // path only restores total_used_qty for population lots — leaving the FIFO + // stock counters permanently inflated (phantom stock). Those stock + // allocations are owned by the FIFO Reflow/Rollback path, which decrements + // total_used correctly via adjustStockableUsedQuantity. + return stockAllocationRepo.ReleaseByUsable(ctx, usableType, usableID, nil, func(db *gorm.DB) *gorm.DB { + return db.Where("stockable_type = ?", fifo.StockableKeyProjectFlockPopulation.String()) + }) } func AllocatePopulationConsumption( From 68bddd5c785ba4e93597c82aecbfba87a061d23d Mon Sep 17 00:00:00 2001 From: giovanni Date: Sun, 31 May 2026 16:38:22 +0700 Subject: [PATCH 3/3] adjust response list marketing add grand total so dan do --- internal/modules/marketing/dto/deliveryorder.dto.go | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/internal/modules/marketing/dto/deliveryorder.dto.go b/internal/modules/marketing/dto/deliveryorder.dto.go index 915a183b..e5488a7e 100644 --- a/internal/modules/marketing/dto/deliveryorder.dto.go +++ b/internal/modules/marketing/dto/deliveryorder.dto.go @@ -28,6 +28,8 @@ type MarketingListDTO struct { Customer customerDTO.CustomerRelationDTO `json:"customer"` SalesPerson userDTO.UserRelationDTO `json:"sales_person"` SoDocs string `json:"so_docs"` + GrandTotalSO float64 `json:"grand_total_so"` + GrandTotalDO float64 `json:"grand_total_do"` SalesOrder []DeliveryMarketingProductDTO `json:"sales_order"` DeliveryOrder []DeliveryGroupDTO `json:"delivery_order"` CreatedUser userDTO.UserRelationDTO `json:"created_user"` @@ -198,11 +200,18 @@ func ToMarketingListDTO(marketing *entity.Marketing, deliveryProducts []entity.M salesOrderProducts[i] = ToDeliveryMarketingProductDTO(product, marketing.MarketingType) } } + var grandTotalSO float64 + for _, p := range marketing.Products { + grandTotalSO += p.TotalPrice + } + return MarketingListDTO{ MarketingRelationDTO: ToMarketingRelationDTO(marketing), Customer: customer, SalesPerson: salesPerson, SoDocs: marketing.SoDocs, + GrandTotalSO: grandTotalSO, + GrandTotalDO: marketing.GrandTotal, SalesOrder: salesOrderProducts, DeliveryOrder: extractDeliveryGroupsFromProducts(marketing), CreatedUser: createdUser,