diff --git a/internal/modules/closings/repositories/closing.repository.go b/internal/modules/closings/repositories/closing.repository.go index 27cd15af..b6d90a63 100644 --- a/internal/modules/closings/repositories/closing.repository.go +++ b/internal/modules/closings/repositories/closing.repository.go @@ -25,8 +25,8 @@ type ClosingRepository interface { SumMarketingWeightAndQtyByProjectFlockKandangIDsAndFlagNames(ctx context.Context, projectFlockKandangIDs []uint, flagNames []string) (float64, float64, float64, error) SumRecordingEggQtyByProjectFlockKandangIDsAndFlagNames(ctx context.Context, projectFlockKandangIDs []uint, flagNames []string) (float64, error) GetExpeditionHPP(ctx context.Context, projectFlockID uint, projectFlockKandangID *uint) ([]ExpeditionHPPRow, error) - FetchSapronakIncoming(ctx context.Context, kandangID uint, start, end *time.Time) ([]SapronakIncomingRow, error) - FetchSapronakIncomingDetails(ctx context.Context, kandangID uint, start, end *time.Time) (map[uint][]SapronakDetailRow, error) + FetchSapronakIncoming(ctx context.Context, projectFlockKandangID uint, kandangID uint, start, end *time.Time) ([]SapronakIncomingRow, error) + FetchSapronakIncomingDetails(ctx context.Context, projectFlockKandangID uint, kandangID uint, start, end *time.Time) (map[uint][]SapronakDetailRow, error) FetchSapronakUsage(ctx context.Context, pfkID uint, start, end *time.Time) ([]SapronakUsageRow, error) FetchSapronakUsageDetails(ctx context.Context, pfkID uint, start, end *time.Time) (map[uint][]SapronakDetailRow, error) FetchSapronakChickinUsage(ctx context.Context, pfkID uint, start, end *time.Time) ([]SapronakUsageRow, error) @@ -90,6 +90,23 @@ type SapronakQueryParams struct { EndDate *time.Time } +func sapronakIncomingPurchaseQueryParts(params SapronakQueryParams) (string, []any) { + if len(params.ProjectFlockKandangIDs) > 0 { + return sapronakIncomingPurchasesScopedSQL(), []any{ + fifo.UsableKeyRecordingStock.String(), + fifo.UsableKeyProjectChickin.String(), + fifo.StockableKeyPurchaseItems.String(), + entity.StockAllocationStatusActive, + entity.StockAllocationPurposeConsume, + params.ProjectFlockKandangIDs, + params.ProjectFlockKandangIDs, + params.WarehouseIDs, + } + } + + return sapronakIncomingPurchasesSQL, []any{params.WarehouseIDs} +} + func (r *ClosingRepositoryImpl) GetSapronak(ctx context.Context, params SapronakQueryParams) ([]SapronakRow, int64, error) { db := r.DB().WithContext(ctx) @@ -103,8 +120,10 @@ func (r *ClosingRepositoryImpl) GetSapronak(ctx context.Context, params Sapronak if len(params.WarehouseIDs) == 0 { return []SapronakRow{}, 0, nil } - unionParts = append(unionParts, sapronakIncomingPurchasesSQL, sapronakIncomingTransfersSQL, sapronakIncomingAdjustmentsSQL) - args = append(args, params.WarehouseIDs, params.WarehouseIDs, params.WarehouseIDs) + purchasesSQL, purchaseArgs := sapronakIncomingPurchaseQueryParts(params) + unionParts = append(unionParts, purchasesSQL, sapronakIncomingTransfersSQL, sapronakIncomingAdjustmentsSQL) + args = append(args, purchaseArgs...) + args = append(args, params.WarehouseIDs, params.WarehouseIDs) case validation.SapronakTypeOutgoing: if len(params.WarehouseIDs) > 0 { unionParts = append(unionParts, sapronakOutgoingTransfersSQL, sapronakOutgoingAdjustmentsSQL) @@ -193,8 +212,10 @@ func (r *ClosingRepositoryImpl) GetSapronakSummary(ctx context.Context, params S if len(params.WarehouseIDs) == 0 { return []SapronakSummaryRow{}, nil } - unionParts = append(unionParts, sapronakIncomingPurchasesSQL, sapronakIncomingTransfersSQL, sapronakIncomingAdjustmentsSQL) - args = append(args, params.WarehouseIDs, params.WarehouseIDs, params.WarehouseIDs) + purchasesSQL, purchaseArgs := sapronakIncomingPurchaseQueryParts(params) + unionParts = append(unionParts, purchasesSQL, sapronakIncomingTransfersSQL, sapronakIncomingAdjustmentsSQL) + args = append(args, purchaseArgs...) + args = append(args, params.WarehouseIDs, params.WarehouseIDs) case validation.SapronakTypeOutgoing: if len(params.WarehouseIDs) > 0 { unionParts = append(unionParts, sapronakOutgoingTransfersSQL, sapronakOutgoingAdjustmentsSQL) @@ -855,6 +876,140 @@ func sapronakFlags(flags ...utils.FlagType) []string { return out } +func sapronakLegacyFlagByProductCategoryCase(categoryCodeExpr string) string { + return fmt.Sprintf( + `CASE + WHEN UPPER(%s) = 'DOC' THEN '%s' + WHEN UPPER(%s) = 'PLT' THEN '%s' + WHEN UPPER(%s) IN ('RAW', 'PST', 'STR', 'FSR') THEN '%s' + WHEN UPPER(%s) IN ('OBT', 'VTM', 'KMA') THEN '%s' + ELSE NULL + END`, + categoryCodeExpr, utils.FlagDOC, + categoryCodeExpr, utils.FlagPullet, + categoryCodeExpr, utils.FlagPakan, + categoryCodeExpr, utils.FlagOVK, + ) +} + +func sapronakIncomingPurchasesScopedSQL() string { + return ` +WITH scoped_farm_allocations AS ( + SELECT + sa.stockable_id AS purchase_item_id, + COALESCE(SUM(sa.qty), 0) AS allocated_qty + FROM stock_allocations sa + LEFT JOIN recording_stocks rs ON rs.id = sa.usable_id AND sa.usable_type = ? + LEFT JOIN recordings rec ON rec.id = rs.recording_id AND rec.deleted_at IS NULL + LEFT JOIN project_chickins pc ON pc.id = sa.usable_id AND sa.usable_type = ? + WHERE sa.stockable_type = ? + AND sa.status = ? + AND sa.allocation_purpose = ? + AND COALESCE(rec.project_flock_kandangs_id, pc.project_flock_kandang_id) IN ? + GROUP BY sa.stockable_id +) +SELECT + CAST(pi.id AS BIGINT) AS id, + COALESCE(pi.received_date, '1970-01-01') AS sort_date, + COALESCE(TO_CHAR(pi.received_date, 'DD-Mon-YYYY'), '') AS date_text, + COALESCE(p.po_number, '') AS reference_number, + 'Pembelian' AS transaction_type, + prod.name AS product_name, + COALESCE(( + SELECT string_agg( + f.name, + ' ' ORDER BY + CASE + WHEN UPPER(f.name) IN ('DOC', 'PAKAN', 'OVK', 'PULLET') THEN 0 + ELSE 1 + END, + f.name + ) + FROM flags f + WHERE f.flagable_type = 'products' AND f.flagable_id = prod.id + ), '') AS product_category, + COALESCE(( + SELECT string_agg( + f.name, + ' ' ORDER BY + CASE + WHEN UPPER(f.name) IN ('DOC', 'PAKAN', 'OVK', 'PULLET') THEN 0 + ELSE 1 + END, + f.name + ) + FROM flags f + WHERE f.flagable_type = 'products' AND f.flagable_id = prod.id + ), '') AS product_sub_category, + '-' AS source_warehouse, + 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 +JOIN purchases p ON p.id = pi.purchase_id +JOIN products prod ON prod.id = pi.product_id +JOIN uoms u ON u.id = prod.uom_id +JOIN warehouses w ON w.id = pi.warehouse_id +WHERE w.kandang_id IS NOT NULL + AND ( + pi.project_flock_kandang_id IN ? + OR (pi.project_flock_kandang_id IS NULL AND pi.warehouse_id IN ?) + ) +UNION ALL +SELECT + CAST(pi.id AS BIGINT) AS id, + COALESCE(pi.received_date, '1970-01-01') AS sort_date, + COALESCE(TO_CHAR(pi.received_date, 'DD-Mon-YYYY'), '') AS date_text, + COALESCE(p.po_number, '') AS reference_number, + 'Pembelian' AS transaction_type, + prod.name AS product_name, + COALESCE(( + SELECT string_agg( + f.name, + ' ' ORDER BY + CASE + WHEN UPPER(f.name) IN ('DOC', 'PAKAN', 'OVK', 'PULLET') THEN 0 + ELSE 1 + END, + f.name + ) + FROM flags f + WHERE f.flagable_type = 'products' AND f.flagable_id = prod.id + ), '') AS product_category, + COALESCE(( + SELECT string_agg( + f.name, + ' ' ORDER BY + CASE + WHEN UPPER(f.name) IN ('DOC', 'PAKAN', 'OVK', 'PULLET') THEN 0 + ELSE 1 + END, + f.name + ) + FROM flags f + WHERE f.flagable_type = 'products' AND f.flagable_id = prod.id + ), '') AS product_sub_category, + '-' AS source_warehouse, + w.name AS destination_warehouse, + '' AS destination, + sfa.allocated_qty AS quantity, + u.id AS unit_id, + u.name AS unit, + COALESCE(p.notes, '') AS notes +FROM purchase_items pi +JOIN purchases p ON p.id = pi.purchase_id +JOIN products prod ON prod.id = pi.product_id +JOIN uoms u ON u.id = prod.uom_id +JOIN warehouses w ON w.id = pi.warehouse_id +JOIN scoped_farm_allocations sfa ON sfa.purchase_item_id = pi.id +WHERE w.kandang_id IS NULL + AND COALESCE(sfa.allocated_qty, 0) > 0 +` +} + var ( sapronakFlagsAll = sapronakFlags(utils.FlagDOC, utils.FlagPakan, utils.FlagOVK, utils.FlagPullet) sapronakFlagsUsage = sapronakFlags(utils.FlagPakan, utils.FlagOVK) @@ -862,18 +1017,44 @@ var ( ) func (r *ClosingRepositoryImpl) joinSapronakProductFlag(db *gorm.DB, productAlias string) *gorm.DB { - subquery := r.DB(). + actualFlags := r.DB(). Table("flags"). - Select("DISTINCT ON (flagable_id) flagable_id, name"). + Select(` + flagable_id, + MIN(CASE + WHEN UPPER(name) = 'DOC' THEN 1 + WHEN UPPER(name) = 'PULLET' THEN 2 + WHEN UPPER(name) = 'PAKAN' THEN 3 + WHEN UPPER(name) = 'OVK' THEN 4 + ELSE 5 + END) AS priority + `). Where("flagable_type = ?", entity.FlagableTypeProduct). - Where("name IN ?", sapronakFlagsAll). - Order(fmt.Sprintf( - "flagable_id, CASE WHEN name = '%s' THEN 1 WHEN name = '%s' THEN 2 WHEN name = '%s' THEN 3 WHEN name = '%s' THEN 4 ELSE 5 END", + Where("UPPER(name) IN ?", sapronakFlagsAll). + Group("flagable_id") + + legacyFlagExpr := sapronakLegacyFlagByProductCategoryCase("pc.code") + subquery := r.DB(). + Table("products AS sapronak_products"). + Select(fmt.Sprintf(` + sapronak_products.id AS flagable_id, + CASE + WHEN actual_flags.priority = 1 THEN '%s' + WHEN actual_flags.priority = 2 THEN '%s' + WHEN actual_flags.priority = 3 THEN '%s' + WHEN actual_flags.priority = 4 THEN '%s' + ELSE %s + END AS name + `, utils.FlagDOC, utils.FlagPullet, utils.FlagPakan, utils.FlagOVK, - )) + legacyFlagExpr, + )). + Joins("LEFT JOIN (?) AS actual_flags ON actual_flags.flagable_id = sapronak_products.id", actualFlags). + Joins("LEFT JOIN product_categories pc ON pc.id = sapronak_products.product_category_id"). + Where("actual_flags.priority IS NOT NULL OR " + legacyFlagExpr + " IS NOT NULL") return db.Joins("JOIN (?) f ON f.flagable_id = "+productAlias+".id", subquery) } @@ -1132,22 +1313,111 @@ func (r *ClosingRepositoryImpl) FetchSapronakUsageAllocatedDetails(ctx context.C return scanAndGroupDetails(query) } -func (r *ClosingRepositoryImpl) incomingPurchaseBase(ctx context.Context, kandangID uint, start, end *time.Time) *gorm.DB { +func (r *ClosingRepositoryImpl) incomingPurchaseBase(ctx context.Context, projectFlockKandangID uint, kandangID uint, start, end *time.Time) *gorm.DB { db := r.withCtx(ctx). Table("purchase_items AS pi"). Joins("JOIN purchases po ON po.id = pi.purchase_id AND po.deleted_at IS NULL"). Joins("JOIN products p ON p.id = pi.product_id"). Joins("JOIN warehouses w ON w.id = pi.warehouse_id"). - Where("w.kandang_id = ?", kandangID). + Where("f.name IN ?", sapronakFlagsAll). + Where("pi.received_date IS NOT NULL") + if projectFlockKandangID > 0 { + db = db.Where( + "w.kandang_id = ? AND (pi.project_flock_kandang_id = ? OR pi.project_flock_kandang_id IS NULL)", + kandangID, + projectFlockKandangID, + ) + } else { + db = db.Where("w.kandang_id = ?", kandangID) + } + db = applyDateRange(db, "pi.received_date", start, end) + return r.joinSapronakProductFlag(db, "p") +} + +func (r *ClosingRepositoryImpl) incomingFarmPurchaseAllocationBase(ctx context.Context, projectFlockKandangID uint, start, end *time.Time) *gorm.DB { + db := r.withCtx(ctx). + Table("stock_allocations AS sa"). + Joins("JOIN purchase_items pi ON pi.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyPurchaseItems.String()). + Joins("JOIN purchases po ON po.id = pi.purchase_id AND po.deleted_at IS NULL"). + Joins("JOIN products p ON p.id = pi.product_id"). + Joins("JOIN warehouses w ON w.id = pi.warehouse_id"). + Joins("LEFT JOIN recording_stocks rs ON rs.id = sa.usable_id AND sa.usable_type = ?", fifo.UsableKeyRecordingStock.String()). + Joins("LEFT JOIN recordings rec ON rec.id = rs.recording_id AND rec.deleted_at IS NULL"). + Joins("LEFT JOIN project_chickins pc ON pc.id = sa.usable_id AND sa.usable_type = ?", fifo.UsableKeyProjectChickin.String()). + Where("sa.status = ?", entity.StockAllocationStatusActive). + Where("sa.allocation_purpose = ?", entity.StockAllocationPurposeConsume). + Where("w.kandang_id IS NULL"). + Where("COALESCE(rec.project_flock_kandangs_id, pc.project_flock_kandang_id) = ?", projectFlockKandangID). Where("f.name IN ?", sapronakFlagsAll). Where("pi.received_date IS NOT NULL") db = applyDateRange(db, "pi.received_date", start, end) return r.joinSapronakProductFlag(db, "p") } -func (r *ClosingRepositoryImpl) FetchSapronakIncoming(ctx context.Context, kandangID uint, start, end *time.Time) ([]SapronakIncomingRow, error) { +func mergeSapronakIncomingRows(primary []SapronakIncomingRow, extra []SapronakIncomingRow) []SapronakIncomingRow { + if len(extra) == 0 { + return primary + } + + type key struct { + productID uint + flag string + } + + merged := make(map[key]*SapronakIncomingRow, len(primary)+len(extra)) + order := make([]key, 0, len(primary)+len(extra)) + + add := func(rows []SapronakIncomingRow) { + for _, row := range rows { + k := key{productID: row.ProductID, flag: row.Flag} + if existing, ok := merged[k]; ok { + existing.Qty += row.Qty + existing.Value += row.Value + if existing.ProductName == "" { + existing.ProductName = row.ProductName + } + if existing.DefaultPrice == 0 { + existing.DefaultPrice = row.DefaultPrice + } + continue + } + + copyRow := row + merged[k] = ©Row + order = append(order, k) + } + } + + add(primary) + add(extra) + + result := make([]SapronakIncomingRow, 0, len(order)) + for _, k := range order { + result = append(result, *merged[k]) + } + return result +} + +func mergeSapronakDetailMaps(primary map[uint][]SapronakDetailRow, extra map[uint][]SapronakDetailRow) map[uint][]SapronakDetailRow { + if len(primary) == 0 && len(extra) == 0 { + return map[uint][]SapronakDetailRow{} + } + if len(extra) == 0 { + return primary + } + if len(primary) == 0 { + return extra + } + + for productID, rows := range extra { + primary[productID] = append(primary[productID], rows...) + } + return primary +} + +func (r *ClosingRepositoryImpl) FetchSapronakIncoming(ctx context.Context, projectFlockKandangID uint, kandangID uint, start, end *time.Time) ([]SapronakIncomingRow, error) { rows := make([]SapronakIncomingRow, 0) - db := r.incomingPurchaseBase(ctx, kandangID, start, end).Select(` + db := r.incomingPurchaseBase(ctx, projectFlockKandangID, kandangID, start, end).Select(` pi.product_id AS product_id, p.name AS product_name, f.name AS flag, @@ -1158,22 +1428,68 @@ func (r *ClosingRepositoryImpl) FetchSapronakIncoming(ctx context.Context, kanda if err := db.Group("pi.product_id, p.name, f.name, p.product_price").Scan(&rows).Error; err != nil { return nil, err } - return rows, nil + + if projectFlockKandangID == 0 { + return rows, nil + } + + farmRows := make([]SapronakIncomingRow, 0) + farmDB := r.incomingFarmPurchaseAllocationBase(ctx, projectFlockKandangID, start, end).Select(` + pi.product_id AS product_id, + p.name AS product_name, + f.name AS flag, + COALESCE(SUM(sa.qty), 0) AS qty, + COALESCE(SUM(sa.qty * pi.price), 0) AS value, + COALESCE(p.product_price, 0) AS default_price + `) + if err := farmDB.Group("pi.product_id, p.name, f.name, p.product_price").Scan(&farmRows).Error; err != nil { + return nil, err + } + + return mergeSapronakIncomingRows(rows, farmRows), nil } -func (r *ClosingRepositoryImpl) FetchSapronakIncomingDetails(ctx context.Context, kandangID uint, start, end *time.Time) (map[uint][]SapronakDetailRow, error) { - return scanAndGroupDetails( - r.incomingPurchaseBase(ctx, kandangID, start, end).Select(` +func (r *ClosingRepositoryImpl) FetchSapronakIncomingDetails(ctx context.Context, projectFlockKandangID uint, kandangID uint, start, end *time.Time) (map[uint][]SapronakDetailRow, error) { + rows, err := scanAndGroupDetails( + r.incomingPurchaseBase(ctx, projectFlockKandangID, kandangID, start, end).Select(` pi.product_id AS product_id, p.name AS product_name, f.name AS flag, pi.received_date AS date, COALESCE(po.po_number, '') AS reference, COALESCE(pi.total_qty,0) AS qty_in, + 0 AS qty_out, + COALESCE(pi.price,0) AS price + `), + ) + if err != nil { + return nil, err + } + + if projectFlockKandangID == 0 { + return rows, nil + } + + farmRows, err := scanAndGroupDetails( + r.incomingFarmPurchaseAllocationBase(ctx, projectFlockKandangID, start, end).Select(` + pi.product_id AS product_id, + p.name AS product_name, + f.name AS flag, + pi.received_date AS date, + COALESCE(po.po_number, '') AS reference, + COALESCE(SUM(sa.qty),0) AS qty_in, 0 AS qty_out, COALESCE(pi.price,0) AS price + `).Group(` + pi.id, pi.product_id, p.name, f.name, + pi.received_date, po.po_number, pi.price `), ) + if err != nil { + return nil, err + } + + return mergeSapronakDetailMaps(rows, farmRows), nil } type stockLogSapronakRow struct { diff --git a/internal/modules/closings/repositories/closing.repository_test.go b/internal/modules/closings/repositories/closing.repository_test.go new file mode 100644 index 00000000..362dbef2 --- /dev/null +++ b/internal/modules/closings/repositories/closing.repository_test.go @@ -0,0 +1,208 @@ +package repository + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/glebarez/sqlite" + "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo" + "gorm.io/gorm" +) + +func TestSapronakIncomingPurchaseQueryPartsUsesAttributedPurchasesWhenProjectFlockKandangIDsProvided(t *testing.T) { + sql, args := sapronakIncomingPurchaseQueryParts(SapronakQueryParams{ + WarehouseIDs: []uint{46}, + ProjectFlockKandangIDs: []uint{101}, + }) + + if sql != sapronakIncomingPurchasesScopedSQL() { + t.Fatalf("expected scoped purchase SQL, got %q", sql) + } + if len(args) != 8 { + t.Fatalf("expected 8 argument groups, got %d", len(args)) + } +} + +func TestFetchSapronakIncomingIncludesAttributedFarmPurchasesAndHistoricalWarehouseFallback(t *testing.T) { + db := setupClosingRepositoryTestDB(t) + repo := NewClosingRepository(db) + ctx := context.Background() + + receivedAt := time.Date(2026, 4, 1, 4, 0, 0, 0, time.UTC) + statements := []string{ + `INSERT INTO warehouses (id, kandang_id) VALUES (1, NULL), (2, 59), (3, 88)`, + `INSERT INTO product_categories (id, code) VALUES (1, 'OBT'), (2, 'RAW')`, + `INSERT INTO products (id, name, product_category_id, product_price) VALUES + (10, 'MEFISTO @1 LITER', 1, 261700), + (20, 'PAKAN GROWING CRUMBLE MALINDO', 2, 15000)`, + `INSERT INTO flags (id, flagable_id, flagable_type, name) VALUES + (1, 10, 'products', 'OVK'), + (2, 10, 'products', 'OBAT')`, + `INSERT INTO purchases (id, po_number, deleted_at) VALUES (1, 'PO-LTI-0005', NULL)`, + `INSERT INTO recordings (id, project_flock_kandangs_id, deleted_at) VALUES (11, 101, NULL), (12, 999, NULL)`, + `INSERT INTO recording_stocks (id, recording_id, product_warehouse_id, usage_qty) VALUES (21, 11, 501, 150), (22, 12, 502, 10)`, + `INSERT INTO purchase_items (id, purchase_id, product_id, warehouse_id, project_flock_kandang_id, total_qty, price, received_date) VALUES + (1, 1, 10, 1, NULL, 100, 261700, '` + receivedAt.Format(time.RFC3339) + `'), + (2, 1, 20, 1, NULL, 50, 15000, '` + receivedAt.Format(time.RFC3339) + `'), + (3, 1, 20, 2, NULL, 25, 12000, '` + receivedAt.Format(time.RFC3339) + `'), + (4, 1, 10, 3, 999, 10, 261700, '` + receivedAt.Format(time.RFC3339) + `'), + (5, 1, 20, 1, NULL, 40, 15000, '` + receivedAt.Format(time.RFC3339) + `')`, + fmt.Sprintf(`INSERT INTO stock_allocations (id, product_warehouse_id, stockable_type, stockable_id, usable_type, usable_id, qty, allocation_purpose, status) VALUES + (1, 701, '%s', 1, '%s', 21, 100, 'CONSUME', 'ACTIVE'), + (2, 702, '%s', 2, '%s', 21, 50, 'CONSUME', 'ACTIVE'), + (3, 703, '%s', 5, '%s', 22, 40, 'CONSUME', 'ACTIVE')`, + fifo.StockableKeyPurchaseItems.String(), + fifo.UsableKeyRecordingStock.String(), + fifo.StockableKeyPurchaseItems.String(), + fifo.UsableKeyRecordingStock.String(), + fifo.StockableKeyPurchaseItems.String(), + fifo.UsableKeyRecordingStock.String(), + ), + } + for _, stmt := range statements { + if err := db.Exec(stmt).Error; err != nil { + t.Fatalf("failed seeding schema: %v", err) + } + } + + rows, err := repo.FetchSapronakIncoming(ctx, 101, 59, nil, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(rows) != 2 { + t.Fatalf("expected 2 sapronak rows, got %d", len(rows)) + } + + byProduct := make(map[uint]SapronakIncomingRow, len(rows)) + for _, row := range rows { + byProduct[row.ProductID] = row + } + + if got := byProduct[10]; got.ProductID == 0 || got.Flag != "OVK" || got.Qty != 100 { + t.Fatalf("expected OVK farm purchase qty 100 for product 10, got %+v", got) + } + + if got := byProduct[20]; got.ProductID == 0 || got.Flag != "PAKAN" || got.Qty != 75 { + t.Fatalf("expected PAKAN total qty 75 including farm allocated qty 50 and kandang receipt qty 25, got %+v", got) + } +} + +func setupClosingRepositoryTestDB(t *testing.T) *gorm.DB { + t.Helper() + + db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=private"), &gorm.Config{}) + if err != nil { + t.Fatalf("failed opening sqlite db: %v", err) + } + + statements := []string{ + `CREATE TABLE warehouses ( + id INTEGER PRIMARY KEY, + kandang_id INTEGER NULL + )`, + `CREATE TABLE product_categories ( + id INTEGER PRIMARY KEY, + code TEXT NOT NULL + )`, + `CREATE TABLE uoms ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL + )`, + `CREATE TABLE products ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + product_category_id INTEGER NULL, + uom_id INTEGER NULL, + product_price NUMERIC(15,3) NOT NULL DEFAULT 0 + )`, + `CREATE TABLE flags ( + id INTEGER PRIMARY KEY, + flagable_id INTEGER NOT NULL, + flagable_type TEXT NOT NULL, + name TEXT NOT NULL + )`, + `CREATE TABLE purchases ( + id INTEGER PRIMARY KEY, + po_number TEXT NULL, + notes TEXT NULL, + deleted_at TIMESTAMP NULL + )`, + `CREATE TABLE purchase_items ( + id INTEGER PRIMARY KEY, + purchase_id INTEGER NOT NULL, + product_id INTEGER NOT NULL, + warehouse_id INTEGER NOT NULL, + project_flock_kandang_id INTEGER NULL, + total_qty NUMERIC(15,3) NOT NULL DEFAULT 0, + price NUMERIC(15,3) NOT NULL DEFAULT 0, + received_date TIMESTAMP NULL + )`, + `CREATE TABLE recordings ( + id INTEGER PRIMARY KEY, + project_flock_kandangs_id INTEGER NOT NULL, + deleted_at TIMESTAMP NULL + )`, + `CREATE TABLE recording_stocks ( + id INTEGER PRIMARY KEY, + recording_id INTEGER NOT NULL, + product_warehouse_id INTEGER NOT NULL, + usage_qty NUMERIC(15,3) NOT NULL DEFAULT 0 + )`, + `CREATE TABLE project_chickins ( + id INTEGER PRIMARY KEY, + project_flock_kandang_id INTEGER NOT NULL + )`, + `CREATE TABLE stock_allocations ( + id INTEGER PRIMARY KEY, + product_warehouse_id INTEGER NOT NULL, + stockable_type TEXT NOT NULL, + stockable_id INTEGER NOT NULL, + usable_type TEXT NOT NULL, + usable_id INTEGER NOT NULL, + qty NUMERIC(15,3) NOT NULL DEFAULT 0, + allocation_purpose TEXT NOT NULL, + status TEXT NOT NULL + )`, + `CREATE TABLE product_warehouses ( + id INTEGER PRIMARY KEY, + product_id INTEGER NOT NULL, + warehouse_id INTEGER NOT NULL, + project_flock_kandang_id INTEGER NULL + )`, + `CREATE TABLE stock_transfers ( + id INTEGER PRIMARY KEY, + from_warehouse_id INTEGER NULL, + to_warehouse_id INTEGER NULL, + transfer_date TIMESTAMP NULL, + movement_number TEXT NULL, + reason TEXT NULL + )`, + `CREATE TABLE stock_transfer_details ( + id INTEGER PRIMARY KEY, + stock_transfer_id INTEGER NOT NULL, + product_id INTEGER NOT NULL, + dest_product_warehouse_id INTEGER NULL, + source_product_warehouse_id INTEGER NULL, + total_qty NUMERIC(15,3) NOT NULL DEFAULT 0, + usage_qty NUMERIC(15,3) NOT NULL DEFAULT 0 + )`, + `CREATE TABLE adjustment_stocks ( + id INTEGER PRIMARY KEY, + product_warehouse_id INTEGER NOT NULL, + total_qty NUMERIC(15,3) NOT NULL DEFAULT 0, + usage_qty NUMERIC(15,3) NOT NULL DEFAULT 0, + adj_number TEXT NULL, + created_at TIMESTAMP NULL + )`, + } + + for _, stmt := range statements { + if err := db.Exec(stmt).Error; err != nil { + t.Fatalf("failed preparing schema: %v", err) + } + } + + return db +} diff --git a/internal/modules/closings/services/closing.service.go b/internal/modules/closings/services/closing.service.go index cd8ea5ac..360a39f9 100644 --- a/internal/modules/closings/services/closing.service.go +++ b/internal/modules/closings/services/closing.service.go @@ -383,7 +383,7 @@ func (s closingService) GetClosingSapronak(c *fiber.Ctx, projectFlockID uint, pa var projectFlockKandangIDs []uint if params.KandangID != nil && *params.KandangID > 0 { projectFlockKandangIDs = []uint{*params.KandangID} - } else if params.Type == validation.SapronakTypeOutgoing { + } else { 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) @@ -474,7 +474,7 @@ func (s closingService) GetClosingSapronakSummary(c *fiber.Ctx, projectFlockID u var projectFlockKandangIDs []uint if params.KandangID != nil && *params.KandangID > 0 { projectFlockKandangIDs = []uint{*params.KandangID} - } else if params.Type == validation.SapronakTypeOutgoing { + } else { 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) @@ -1156,7 +1156,7 @@ func (s closingService) GetClosingDataProduksi(c *fiber.Ctx, projectFlockID uint chickenDepletion = 0 } -chickenPerformance := calculatePerformanceMetrics(chickenAverageWeight, chickenSalesWeight, feedUsed, population, chickenDepletion, age) + chickenPerformance := calculatePerformanceMetrics(chickenAverageWeight, chickenSalesWeight, feedUsed, population, chickenDepletion, age) if fcrActFromRecording != nil { chickenPerformance.FcrAct = *fcrActFromRecording } diff --git a/internal/modules/closings/services/sapronak.service.go b/internal/modules/closings/services/sapronak.service.go index 460b139a..f548820a 100644 --- a/internal/modules/closings/services/sapronak.service.go +++ b/internal/modules/closings/services/sapronak.service.go @@ -382,11 +382,11 @@ func buildSapronakDetails( func (s sapronakService) buildSapronakItems(ctx context.Context, pfk entity.ProjectFlockKandang, flagFilter string) ([]dto.SapronakItemDTO, []dto.SapronakGroupDTO, float64, float64, error) { // Filter by project flock period (start = first chickin or pfk created_at, end = closed_at if any). startDate, endDate := sapronakPeriodRange(pfk) - incomingRows, err := s.Repository.FetchSapronakIncoming(ctx, pfk.KandangId, startDate, endDate) + incomingRows, err := s.Repository.FetchSapronakIncoming(ctx, pfk.Id, pfk.KandangId, startDate, endDate) if err != nil { return nil, nil, 0, 0, err } - incomingDetailsRows, err := s.Repository.FetchSapronakIncomingDetails(ctx, pfk.KandangId, startDate, endDate) + incomingDetailsRows, err := s.Repository.FetchSapronakIncomingDetails(ctx, pfk.Id, pfk.KandangId, startDate, endDate) if err != nil { return nil, nil, 0, 0, err }