From be008371481f1729d85996e4238694fca5b192fe Mon Sep 17 00:00:00 2001 From: Adnan Zahir Date: Mon, 30 Mar 2026 13:40:29 +0700 Subject: [PATCH] codex: initiated changes --- docs/farm_stock_attribution_design.md | 31 ++ ...rketing_delivery_attribution.repository.go | 224 ++++++++++ ...ng_delivery_attribution.repository_test.go | 147 +++++++ ...attribution_fields_for_farm_stock.down.sql | 18 + ...g_attribution_fields_for_farm_stock.up.sql | 61 +++ .../entities/marketing_delivery_product.go | 22 +- internal/entities/recording_depletion.go | 20 +- internal/entities/recording_egg.go | 30 +- .../closings/dto/closingMarketing.dto.go | 25 +- .../repositories/closing.repository.go | 128 ++++-- .../product_warehouse.repository.go | 90 ++-- .../product_warehouse.repository_test.go | 117 ++++++ .../salesorder_delivery_product.repository.go | 362 ++++++++++------ ...sorder_delivery_product.repository_test.go | 42 ++ .../services/deliveryorder.service.go | 389 +++++++++++++++++- .../project_flock_population_repository.go | 56 +++ .../recordings/services/recording.service.go | 78 +++- .../validations/recording.validation.go | 5 +- .../repports/dto/repportMarketing.dto.go | 15 +- .../repports/services/repport.service.go | 107 +++-- internal/utils/recording/util.recording.go | 76 +++- .../utils/recording/util.recording_test.go | 47 +++ 22 files changed, 1762 insertions(+), 328 deletions(-) create mode 100644 docs/farm_stock_attribution_design.md create mode 100644 internal/common/repository/marketing_delivery_attribution.repository.go create mode 100644 internal/common/repository/marketing_delivery_attribution.repository_test.go create mode 100644 internal/database/migrations/20260330110000_add_recording_attribution_fields_for_farm_stock.down.sql create mode 100644 internal/database/migrations/20260330110000_add_recording_attribution_fields_for_farm_stock.up.sql create mode 100644 internal/modules/inventory/product-warehouses/repositories/product_warehouse.repository_test.go create mode 100644 internal/modules/marketing/repositories/salesorder_delivery_product.repository_test.go create mode 100644 internal/utils/recording/util.recording_test.go diff --git a/docs/farm_stock_attribution_design.md b/docs/farm_stock_attribution_design.md new file mode 100644 index 00000000..b1379bfa --- /dev/null +++ b/docs/farm_stock_attribution_design.md @@ -0,0 +1,31 @@ +# Farm Stock Attribution Design Note + +## Goal + +Allow farm-level physical stock to be used directly by kandang-level operations without forcing transfers, while keeping kandang attribution, FIFO-v2 compatibility, traceability, and HPP/COGS intact. + +## Core Model + +- Physical stock stays on the real `product_warehouse_id` that was consumed or received. +- Kandang attribution comes from the transaction or allocation path, not from `product_warehouses.project_flock_kandang_id`. +- Existing kandang-bound warehouses remain valid for historical and current kandang-only flows. +- Shared farm warehouses must stay shareable; application code must stop silently converting them into kandang-owned warehouses. + +## Attribution Rules + +- `recording_stocks`: consumer kandang is the parent `recordings.project_flock_kandangs_id`; physical stock source remains `recording_stocks.product_warehouse_id`. +- `recording_depletions`: source kandang is the recording kandang and is stored explicitly for compatibility; physical source remains `source_product_warehouse_id`, destination stock remains `product_warehouse_id`. +- `recording_eggs`: producer kandang is the recording kandang and is stored explicitly for compatibility; physical stock remains `product_warehouse_id`, which may be a farm warehouse. +- `marketing_delivery_products`: outbound kandang attribution comes from active `stock_allocations` to `PROJECT_FLOCK_POPULATION`, `RECORDING_DEPLETION`, or `RECORDING_EGG`, with product-warehouse kandang ownership only as a fallback for historical/non-FIFO rows. + +## Reporting and HPP + +- Feed and OVK cost attribution should continue to follow recording-level consumption plus FIFO allocations to incoming stock. +- Egg and live-bird sales attribution should be derived from `stock_allocations` back to the originating kandang transactions or populations. +- Queries that filter or group by kandang must use explicit transaction attribution or FIFO allocation provenance, not warehouse ownership, when pooled farm stock is involved. + +## Live-Data Safety + +- Schema changes are additive and nullable. +- Historical rows are backfilled only when attribution is deterministic from existing rows. +- No FIFO-v2 route-rule behavior is changed unless the current code is only resyncing or constraining allocation metadata around already-created FIFO allocations. diff --git a/internal/common/repository/marketing_delivery_attribution.repository.go b/internal/common/repository/marketing_delivery_attribution.repository.go new file mode 100644 index 00000000..fc7dc088 --- /dev/null +++ b/internal/common/repository/marketing_delivery_attribution.repository.go @@ -0,0 +1,224 @@ +package repository + +import ( + "fmt" + + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo" + "gorm.io/gorm" +) + +type MarketingDeliveryAttributionRow struct { + MarketingDeliveryProductID uint `gorm:"column:marketing_delivery_product_id"` + ProjectFlockKandangID uint `gorm:"column:project_flock_kandang_id"` + ProjectFlockID uint `gorm:"column:project_flock_id"` + ProjectFlockCategory string `gorm:"column:project_flock_category"` + AllocatedQty float64 `gorm:"column:allocated_qty"` +} + +func MarketingDeliveryAttributionRowsQuery(db *gorm.DB) *gorm.DB { + sql := ` +WITH mapped AS ( + SELECT + sa.usable_id AS marketing_delivery_product_id, + pc.project_flock_kandang_id AS project_flock_kandang_id, + pfk.project_flock_id AS project_flock_id, + pf.category AS project_flock_category, + SUM(sa.qty) AS allocated_qty + FROM stock_allocations sa + JOIN project_flock_populations pfp + ON pfp.id = sa.stockable_id + AND sa.stockable_type = ? + JOIN project_chickins pc ON pc.id = pfp.project_chickin_id + JOIN project_flock_kandangs pfk ON pfk.id = pc.project_flock_kandang_id + JOIN project_flocks pf ON pf.id = pfk.project_flock_id + WHERE sa.usable_type = ? + AND sa.status = ? + AND sa.allocation_purpose = ? + GROUP BY sa.usable_id, pc.project_flock_kandang_id, pfk.project_flock_id, pf.category + + UNION ALL + + SELECT + sa.usable_id AS marketing_delivery_product_id, + COALESCE(re.project_flock_kandang_id, r.project_flock_kandangs_id) AS project_flock_kandang_id, + pfk.project_flock_id AS project_flock_id, + pf.category AS project_flock_category, + SUM(sa.qty) AS allocated_qty + FROM stock_allocations sa + JOIN recording_eggs re + ON re.id = sa.stockable_id + AND sa.stockable_type = ? + LEFT JOIN recordings r ON r.id = re.recording_id + JOIN project_flock_kandangs pfk ON pfk.id = COALESCE(re.project_flock_kandang_id, r.project_flock_kandangs_id) + JOIN project_flocks pf ON pf.id = pfk.project_flock_id + WHERE sa.usable_type = ? + AND sa.status = ? + AND sa.allocation_purpose = ? + GROUP BY sa.usable_id, COALESCE(re.project_flock_kandang_id, r.project_flock_kandangs_id), pfk.project_flock_id, pf.category + + UNION ALL + + SELECT + sa.usable_id AS marketing_delivery_product_id, + COALESCE(rd.source_project_flock_kandang_id, r.project_flock_kandangs_id) AS project_flock_kandang_id, + pfk.project_flock_id AS project_flock_id, + pf.category AS project_flock_category, + SUM(sa.qty) AS allocated_qty + FROM stock_allocations sa + JOIN recording_depletions rd + ON rd.id = sa.stockable_id + AND sa.stockable_type = ? + LEFT JOIN recordings r ON r.id = rd.recording_id + JOIN project_flock_kandangs pfk ON pfk.id = COALESCE(rd.source_project_flock_kandang_id, r.project_flock_kandangs_id) + JOIN project_flocks pf ON pf.id = pfk.project_flock_id + WHERE sa.usable_type = ? + AND sa.status = ? + AND sa.allocation_purpose = ? + GROUP BY sa.usable_id, COALESCE(rd.source_project_flock_kandang_id, r.project_flock_kandangs_id), pfk.project_flock_id, pf.category + + UNION ALL + + SELECT + sa.usable_id AS marketing_delivery_product_id, + pi.project_flock_kandang_id AS project_flock_kandang_id, + pfk.project_flock_id AS project_flock_id, + pf.category AS project_flock_category, + SUM(sa.qty) AS allocated_qty + FROM stock_allocations sa + JOIN purchase_items pi + ON pi.id = sa.stockable_id + AND sa.stockable_type = ? + JOIN project_flock_kandangs pfk ON pfk.id = pi.project_flock_kandang_id + JOIN project_flocks pf ON pf.id = pfk.project_flock_id + WHERE sa.usable_type = ? + AND sa.status = ? + AND sa.allocation_purpose = ? + AND pi.project_flock_kandang_id IS NOT NULL + GROUP BY sa.usable_id, pi.project_flock_kandang_id, pfk.project_flock_id, pf.category + + UNION ALL + + SELECT + sa.usable_id AS marketing_delivery_product_id, + source_pw.project_flock_kandang_id AS project_flock_kandang_id, + pfk.project_flock_id AS project_flock_id, + pf.category AS project_flock_category, + SUM(sa.qty) AS allocated_qty + FROM stock_allocations sa + JOIN stock_transfer_details std + ON std.id = sa.stockable_id + AND sa.stockable_type = ? + JOIN product_warehouses source_pw ON source_pw.id = std.source_product_warehouse_id + JOIN project_flock_kandangs pfk ON pfk.id = source_pw.project_flock_kandang_id + JOIN project_flocks pf ON pf.id = pfk.project_flock_id + WHERE sa.usable_type = ? + AND sa.status = ? + AND sa.allocation_purpose = ? + AND source_pw.project_flock_kandang_id IS NOT NULL + GROUP BY sa.usable_id, source_pw.project_flock_kandang_id, pfk.project_flock_id, pf.category + + UNION ALL + + SELECT + sa.usable_id AS marketing_delivery_product_id, + ltt.target_project_flock_kandang_id AS project_flock_kandang_id, + pfk.project_flock_id AS project_flock_id, + pf.category AS project_flock_category, + SUM(sa.qty) AS allocated_qty + FROM stock_allocations sa + JOIN laying_transfer_targets ltt + ON ltt.id = sa.stockable_id + AND sa.stockable_type = ? + JOIN project_flock_kandangs pfk ON pfk.id = ltt.target_project_flock_kandang_id + JOIN project_flocks pf ON pf.id = pfk.project_flock_id + WHERE sa.usable_type = ? + AND sa.status = ? + AND sa.allocation_purpose = ? + GROUP BY sa.usable_id, ltt.target_project_flock_kandang_id, pfk.project_flock_id, pf.category +) +SELECT + src.marketing_delivery_product_id, + src.project_flock_kandang_id, + src.project_flock_id, + src.project_flock_category, + SUM(src.allocated_qty) AS allocated_qty +FROM ( + SELECT + mapped.marketing_delivery_product_id, + mapped.project_flock_kandang_id, + mapped.project_flock_id, + mapped.project_flock_category, + mapped.allocated_qty + FROM mapped + + UNION ALL + + SELECT + mdp.id AS marketing_delivery_product_id, + pw.project_flock_kandang_id AS project_flock_kandang_id, + pfk.project_flock_id AS project_flock_id, + pf.category AS project_flock_category, + COALESCE(mdp.usage_qty, 0) AS allocated_qty + FROM marketing_delivery_products mdp + JOIN marketing_products mp ON mp.id = mdp.marketing_product_id + JOIN product_warehouses pw ON pw.id = mp.product_warehouse_id + JOIN project_flock_kandangs pfk ON pfk.id = pw.project_flock_kandang_id + JOIN project_flocks pf ON pf.id = pfk.project_flock_id + LEFT JOIN mapped ON mapped.marketing_delivery_product_id = mdp.id + WHERE mapped.marketing_delivery_product_id IS NULL + AND pw.project_flock_kandang_id IS NOT NULL + AND COALESCE(mdp.usage_qty, 0) > 0 +) src +GROUP BY + src.marketing_delivery_product_id, + src.project_flock_kandang_id, + src.project_flock_id, + src.project_flock_category +` + + return db.Raw( + sql, + fifo.StockableKeyProjectFlockPopulation.String(), + fifo.UsableKeyMarketingDelivery.String(), + entity.StockAllocationStatusActive, + entity.StockAllocationPurposeConsume, + fifo.StockableKeyRecordingEgg.String(), + fifo.UsableKeyMarketingDelivery.String(), + entity.StockAllocationStatusActive, + entity.StockAllocationPurposeConsume, + fifo.StockableKeyRecordingDepletion.String(), + fifo.UsableKeyMarketingDelivery.String(), + entity.StockAllocationStatusActive, + entity.StockAllocationPurposeConsume, + fifo.StockableKeyPurchaseItems.String(), + fifo.UsableKeyMarketingDelivery.String(), + entity.StockAllocationStatusActive, + entity.StockAllocationPurposeConsume, + fifo.StockableKeyStockTransferIn.String(), + fifo.UsableKeyMarketingDelivery.String(), + entity.StockAllocationStatusActive, + entity.StockAllocationPurposeConsume, + fifo.StockableKeyTransferToLayingIn.String(), + fifo.UsableKeyMarketingDelivery.String(), + entity.StockAllocationStatusActive, + entity.StockAllocationPurposeConsume, + ) +} + +func MarketingDeliverySingleAttributionQuery(db *gorm.DB) *gorm.DB { + return db. + Table("(?) AS mda", MarketingDeliveryAttributionRowsQuery(db)). + Select(` + mda.marketing_delivery_product_id, + CASE + WHEN COUNT(DISTINCT mda.project_flock_kandang_id) = 1 THEN MIN(mda.project_flock_kandang_id) + ELSE NULL + END AS attributed_project_flock_kandang_id + `). + Group("mda.marketing_delivery_product_id") +} + +func MarketingDeliveryAttributionFilterSQL(column string) string { + return fmt.Sprintf("EXISTS (SELECT 1 FROM (?) AS mda WHERE mda.marketing_delivery_product_id = %s)", column) +} diff --git a/internal/common/repository/marketing_delivery_attribution.repository_test.go b/internal/common/repository/marketing_delivery_attribution.repository_test.go new file mode 100644 index 00000000..8fe3da75 --- /dev/null +++ b/internal/common/repository/marketing_delivery_attribution.repository_test.go @@ -0,0 +1,147 @@ +package repository + +import ( + "testing" + + "github.com/glebarez/sqlite" + "gorm.io/gorm" +) + +func TestMarketingDeliveryAttributionRowsQueryIncludesMappedAndFallbackRows(t *testing.T) { + db := setupMarketingAttributionTestDB(t) + + statements := []string{ + `INSERT INTO project_flocks (id, category) VALUES (1, 'LAYING')`, + `INSERT INTO project_flock_kandangs (id, project_flock_id) VALUES (101, 1), (102, 1)`, + `INSERT INTO project_chickins (id, project_flock_kandang_id) VALUES (201, 101), (202, 102)`, + `INSERT INTO project_flock_populations (id, project_chickin_id) VALUES (301, 201), (302, 202)`, + `INSERT INTO product_warehouses (id, project_flock_kandang_id) VALUES (401, NULL), (402, 101)`, + `INSERT INTO marketing_products (id, product_warehouse_id) VALUES (501, 401), (502, 402), (503, 401)`, + `INSERT INTO marketing_delivery_products (id, marketing_product_id, usage_qty) VALUES (601, 501, 100), (602, 502, 25), (603, 503, 12)`, + `INSERT INTO recording_eggs (id, recording_id, project_flock_kandang_id) VALUES (701, NULL, 101)`, + `INSERT INTO stock_allocations (id, product_warehouse_id, stockable_type, stockable_id, usable_type, usable_id, qty, status, allocation_purpose) VALUES + (1, 401, 'PROJECT_FLOCK_POPULATION', 301, 'MARKETING_DELIVERY', 601, 60, 'ACTIVE', 'CONSUME'), + (2, 401, 'PROJECT_FLOCK_POPULATION', 302, 'MARKETING_DELIVERY', 601, 40, 'ACTIVE', 'CONSUME'), + (3, 401, 'RECORDING_EGG', 701, 'MARKETING_DELIVERY', 603, 12, 'ACTIVE', 'CONSUME')`, + } + for _, stmt := range statements { + if err := db.Exec(stmt).Error; err != nil { + t.Fatalf("failed seeding fixtures: %v", err) + } + } + + var rows []MarketingDeliveryAttributionRow + if err := db.Table("(?) AS mda", MarketingDeliveryAttributionRowsQuery(db)). + Order("mda.marketing_delivery_product_id ASC, mda.project_flock_kandang_id ASC"). + Scan(&rows).Error; err != nil { + t.Fatalf("failed scanning attribution rows: %v", err) + } + + if len(rows) != 4 { + t.Fatalf("expected 4 attribution rows, got %d", len(rows)) + } + if rows[0].MarketingDeliveryProductID != 601 || rows[0].ProjectFlockKandangID != 101 || rows[0].AllocatedQty != 60 { + t.Fatalf("unexpected first attribution row: %+v", rows[0]) + } + if rows[1].MarketingDeliveryProductID != 601 || rows[1].ProjectFlockKandangID != 102 || rows[1].AllocatedQty != 40 { + t.Fatalf("unexpected second attribution row: %+v", rows[1]) + } + if rows[2].MarketingDeliveryProductID != 602 || rows[2].ProjectFlockKandangID != 101 || rows[2].AllocatedQty != 25 { + t.Fatalf("unexpected fallback attribution row: %+v", rows[2]) + } + if rows[3].MarketingDeliveryProductID != 603 || rows[3].ProjectFlockKandangID != 101 || rows[3].AllocatedQty != 12 { + t.Fatalf("unexpected egg attribution row: %+v", rows[3]) + } +} + +func TestMarketingDeliverySingleAttributionQueryOnlyReturnsSingleSourceRows(t *testing.T) { + db := setupMarketingAttributionTestDB(t) + + statements := []string{ + `INSERT INTO project_flocks (id, category) VALUES (1, 'LAYING')`, + `INSERT INTO project_flock_kandangs (id, project_flock_id) VALUES (101, 1), (102, 1)`, + `INSERT INTO project_chickins (id, project_flock_kandang_id) VALUES (201, 101), (202, 102)`, + `INSERT INTO project_flock_populations (id, project_chickin_id) VALUES (301, 201), (302, 202)`, + `INSERT INTO product_warehouses (id, project_flock_kandang_id) VALUES (401, NULL), (402, 101)`, + `INSERT INTO marketing_products (id, product_warehouse_id) VALUES (501, 401), (502, 402), (503, 401)`, + `INSERT INTO marketing_delivery_products (id, marketing_product_id, usage_qty) VALUES (601, 501, 100), (602, 502, 25), (603, 503, 12)`, + `INSERT INTO recording_eggs (id, recording_id, project_flock_kandang_id) VALUES (701, NULL, 101)`, + `INSERT INTO stock_allocations (id, product_warehouse_id, stockable_type, stockable_id, usable_type, usable_id, qty, status, allocation_purpose) VALUES + (1, 401, 'PROJECT_FLOCK_POPULATION', 301, 'MARKETING_DELIVERY', 601, 60, 'ACTIVE', 'CONSUME'), + (2, 401, 'PROJECT_FLOCK_POPULATION', 302, 'MARKETING_DELIVERY', 601, 40, 'ACTIVE', 'CONSUME'), + (3, 401, 'RECORDING_EGG', 701, 'MARKETING_DELIVERY', 603, 12, 'ACTIVE', 'CONSUME')`, + } + for _, stmt := range statements { + if err := db.Exec(stmt).Error; err != nil { + t.Fatalf("failed seeding fixtures: %v", err) + } + } + + type singleRow struct { + MarketingDeliveryProductID uint `gorm:"column:marketing_delivery_product_id"` + AttributedProjectFlockKandangID *uint `gorm:"column:attributed_project_flock_kandang_id"` + } + + var rows []singleRow + if err := db.Table("(?) AS mda", MarketingDeliverySingleAttributionQuery(db)). + Order("mda.marketing_delivery_product_id ASC"). + Scan(&rows).Error; err != nil { + t.Fatalf("failed scanning single attribution rows: %v", err) + } + + if len(rows) != 3 { + t.Fatalf("expected 3 rows, got %d", len(rows)) + } + if rows[0].MarketingDeliveryProductID != 601 || rows[0].AttributedProjectFlockKandangID != nil { + t.Fatalf("expected pooled delivery 601 to have nil single attribution, got %+v", rows[0]) + } + if rows[1].MarketingDeliveryProductID != 602 || rows[1].AttributedProjectFlockKandangID == nil || *rows[1].AttributedProjectFlockKandangID != 101 { + t.Fatalf("expected fallback delivery 602 to map to kandang 101, got %+v", rows[1]) + } + if rows[2].MarketingDeliveryProductID != 603 || rows[2].AttributedProjectFlockKandangID == nil || *rows[2].AttributedProjectFlockKandangID != 101 { + t.Fatalf("expected egg delivery 603 to map to kandang 101, got %+v", rows[2]) + } +} + +func setupMarketingAttributionTestDB(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 stock_allocations ( + id INTEGER PRIMARY KEY, + product_warehouse_id INTEGER, + stockable_type TEXT, + stockable_id INTEGER, + usable_type TEXT, + usable_id INTEGER, + qty NUMERIC(15,3), + status TEXT, + allocation_purpose TEXT + )`, + `CREATE TABLE project_flock_populations (id INTEGER PRIMARY KEY, project_chickin_id INTEGER)`, + `CREATE TABLE project_chickins (id INTEGER PRIMARY KEY, project_flock_kandang_id INTEGER)`, + `CREATE TABLE project_flock_kandangs (id INTEGER PRIMARY KEY, project_flock_id INTEGER)`, + `CREATE TABLE project_flocks (id INTEGER PRIMARY KEY, category TEXT)`, + `CREATE TABLE marketing_delivery_products (id INTEGER PRIMARY KEY, marketing_product_id INTEGER, usage_qty NUMERIC(15,3))`, + `CREATE TABLE marketing_products (id INTEGER PRIMARY KEY, product_warehouse_id INTEGER)`, + `CREATE TABLE product_warehouses (id INTEGER PRIMARY KEY, project_flock_kandang_id INTEGER NULL)`, + `CREATE TABLE recording_eggs (id INTEGER PRIMARY KEY, recording_id INTEGER, project_flock_kandang_id INTEGER NULL)`, + `CREATE TABLE recordings (id INTEGER PRIMARY KEY, project_flock_kandangs_id INTEGER NULL)`, + `CREATE TABLE recording_depletions (id INTEGER PRIMARY KEY, recording_id INTEGER, source_project_flock_kandang_id INTEGER NULL)`, + `CREATE TABLE purchase_items (id INTEGER PRIMARY KEY, project_flock_kandang_id INTEGER NULL)`, + `CREATE TABLE stock_transfer_details (id INTEGER PRIMARY KEY, source_product_warehouse_id INTEGER NULL)`, + `CREATE TABLE laying_transfer_targets (id INTEGER PRIMARY KEY, target_project_flock_kandang_id INTEGER 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/database/migrations/20260330110000_add_recording_attribution_fields_for_farm_stock.down.sql b/internal/database/migrations/20260330110000_add_recording_attribution_fields_for_farm_stock.down.sql new file mode 100644 index 00000000..9f080f9f --- /dev/null +++ b/internal/database/migrations/20260330110000_add_recording_attribution_fields_for_farm_stock.down.sql @@ -0,0 +1,18 @@ +BEGIN; + +DROP INDEX IF EXISTS idx_recording_depletions_source_project_flock_kandang_id; +DROP INDEX IF EXISTS idx_recording_eggs_project_flock_kandang_id; + +ALTER TABLE recording_depletions + DROP CONSTRAINT IF EXISTS fk_recording_depletions_source_project_flock_kandang_id; + +ALTER TABLE recording_eggs + DROP CONSTRAINT IF EXISTS fk_recording_eggs_project_flock_kandang_id; + +ALTER TABLE recording_depletions + DROP COLUMN IF EXISTS source_project_flock_kandang_id; + +ALTER TABLE recording_eggs + DROP COLUMN IF EXISTS project_flock_kandang_id; + +COMMIT; diff --git a/internal/database/migrations/20260330110000_add_recording_attribution_fields_for_farm_stock.up.sql b/internal/database/migrations/20260330110000_add_recording_attribution_fields_for_farm_stock.up.sql new file mode 100644 index 00000000..2e5ea18f --- /dev/null +++ b/internal/database/migrations/20260330110000_add_recording_attribution_fields_for_farm_stock.up.sql @@ -0,0 +1,61 @@ +BEGIN; + +ALTER TABLE recording_depletions + ADD COLUMN IF NOT EXISTS source_project_flock_kandang_id BIGINT NULL; + +ALTER TABLE recording_eggs + ADD COLUMN IF NOT EXISTS project_flock_kandang_id BIGINT NULL; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'fk_recording_depletions_source_project_flock_kandang_id' + ) THEN + ALTER TABLE recording_depletions + ADD CONSTRAINT fk_recording_depletions_source_project_flock_kandang_id + FOREIGN KEY (source_project_flock_kandang_id) + REFERENCES project_flock_kandangs(id) + ON DELETE SET NULL + ON UPDATE CASCADE; + END IF; +END $$; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'fk_recording_eggs_project_flock_kandang_id' + ) THEN + ALTER TABLE recording_eggs + ADD CONSTRAINT fk_recording_eggs_project_flock_kandang_id + FOREIGN KEY (project_flock_kandang_id) + REFERENCES project_flock_kandangs(id) + ON DELETE SET NULL + ON UPDATE CASCADE; + END IF; +END $$; + +CREATE INDEX IF NOT EXISTS idx_recording_depletions_source_project_flock_kandang_id + ON recording_depletions(source_project_flock_kandang_id); + +CREATE INDEX IF NOT EXISTS idx_recording_eggs_project_flock_kandang_id + ON recording_eggs(project_flock_kandang_id); + +UPDATE recording_depletions rd +SET source_project_flock_kandang_id = r.project_flock_kandangs_id +FROM recordings r +WHERE r.id = rd.recording_id + AND rd.source_project_flock_kandang_id IS NULL + AND r.project_flock_kandangs_id IS NOT NULL; + +UPDATE recording_eggs re +SET project_flock_kandang_id = r.project_flock_kandangs_id +FROM recordings r +WHERE r.id = re.recording_id + AND re.project_flock_kandang_id IS NULL + AND r.project_flock_kandangs_id IS NOT NULL; + +COMMIT; diff --git a/internal/entities/marketing_delivery_product.go b/internal/entities/marketing_delivery_product.go index 7ac3d967..78ca61ab 100644 --- a/internal/entities/marketing_delivery_product.go +++ b/internal/entities/marketing_delivery_product.go @@ -5,20 +5,22 @@ import ( ) type MarketingDeliveryProduct struct { - Id uint `gorm:"primaryKey;autoIncrement"` - MarketingProductId uint `gorm:"uniqueIndex;not null"` - ProductWarehouseId uint `gorm:"not null"` - UnitPrice float64 `gorm:"type:numeric(15,3)"` - TotalWeight float64 `gorm:"type:numeric(15,3)"` - AvgWeight float64 `gorm:"type:numeric(15,3)"` - TotalPrice float64 `gorm:"type:numeric(15,3)"` - DeliveryDate *time.Time `gorm:"type:timestamptz"` - VehicleNumber string `gorm:"type:varchar(50)"` + Id uint `gorm:"primaryKey;autoIncrement"` + MarketingProductId uint `gorm:"uniqueIndex;not null"` + ProductWarehouseId uint `gorm:"not null"` + AttributedProjectFlockKandangId *uint `gorm:"->;column:attributed_project_flock_kandang_id"` + UnitPrice float64 `gorm:"type:numeric(15,3)"` + TotalWeight float64 `gorm:"type:numeric(15,3)"` + AvgWeight float64 `gorm:"type:numeric(15,3)"` + TotalPrice float64 `gorm:"type:numeric(15,3)"` + DeliveryDate *time.Time `gorm:"type:timestamptz"` + VehicleNumber string `gorm:"type:varchar(50)"` // FIFO Fields UsageQty float64 `gorm:"type:numeric(15,3);default:0;not null"` PendingQty float64 `gorm:"type:numeric(15,3);default:0;not null"` CreatedAt *time.Time `gorm:"type:timestamptz;not null"` - MarketingProduct MarketingProduct `gorm:"foreignKey:MarketingProductId;references:Id"` + MarketingProduct MarketingProduct `gorm:"foreignKey:MarketingProductId;references:Id"` + AttributedProjectFlockKandang *ProjectFlockKandang `gorm:"foreignKey:AttributedProjectFlockKandangId;references:Id"` } diff --git a/internal/entities/recording_depletion.go b/internal/entities/recording_depletion.go index ae0b6746..ba3b1d25 100644 --- a/internal/entities/recording_depletion.go +++ b/internal/entities/recording_depletion.go @@ -1,14 +1,16 @@ 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"` - SourceProductWarehouseId *uint `gorm:"column:source_product_warehouse_id"` - Qty float64 `gorm:"column:qty;not null"` - UsageQty float64 `gorm:"column:usage_qty"` - PendingQty float64 `gorm:"column:pending_qty"` + 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"` + SourceProjectFlockKandangId *uint `gorm:"column:source_project_flock_kandang_id"` + Qty float64 `gorm:"column:qty;not null"` + UsageQty float64 `gorm:"column:usage_qty"` + PendingQty float64 `gorm:"column:pending_qty"` - Recording Recording `gorm:"foreignKey:RecordingId;references:Id"` - ProductWarehouse ProductWarehouse `gorm:"foreignKey:ProductWarehouseId;references:Id"` + Recording Recording `gorm:"foreignKey:RecordingId;references:Id"` + ProductWarehouse ProductWarehouse `gorm:"foreignKey:ProductWarehouseId;references:Id"` + SourceProjectFlockKandang *ProjectFlockKandang `gorm:"foreignKey:SourceProjectFlockKandangId;references:Id"` } diff --git a/internal/entities/recording_egg.go b/internal/entities/recording_egg.go index b48c49ca..a2014a17 100644 --- a/internal/entities/recording_egg.go +++ b/internal/entities/recording_egg.go @@ -3,18 +3,20 @@ package entities import "time" type RecordingEgg struct { - Id uint `gorm:"primaryKey"` - 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"` - UpdatedAt time.Time `gorm:"autoUpdateTime"` - ProductWarehouse ProductWarehouse `gorm:"foreignKey:ProductWarehouseId;references:Id"` - ProductFlagName *string `gorm:"->;column:product_flag_name" json:"-"` - CreatedUser *User `gorm:"foreignKey:CreatedBy;references:Id"` - Recording Recording `gorm:"foreignKey:RecordingId;references:Id"` + Id uint `gorm:"primaryKey"` + RecordingId uint `gorm:"column:recording_id;not null;index"` + ProductWarehouseId uint `gorm:"column:product_warehouse_id;not null"` + ProjectFlockKandangId *uint `gorm:"column:project_flock_kandang_id"` + 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"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` + ProductWarehouse ProductWarehouse `gorm:"foreignKey:ProductWarehouseId;references:Id"` + ProjectFlockKandang *ProjectFlockKandang `gorm:"foreignKey:ProjectFlockKandangId;references:Id"` + ProductFlagName *string `gorm:"->;column:product_flag_name" json:"-"` + CreatedUser *User `gorm:"foreignKey:CreatedBy;references:Id"` + Recording Recording `gorm:"foreignKey:RecordingId;references:Id"` } diff --git a/internal/modules/closings/dto/closingMarketing.dto.go b/internal/modules/closings/dto/closingMarketing.dto.go index 421a8d3d..a454d53b 100644 --- a/internal/modules/closings/dto/closingMarketing.dto.go +++ b/internal/modules/closings/dto/closingMarketing.dto.go @@ -44,6 +44,7 @@ type PenjualanRealisasiResponseDTO struct { // === Mapper Functions === func ToSalesDTO(e entity.MarketingDeliveryProduct) SalesDTO { + projectFlockKandang := resolveMarketingDeliveryProjectFlockKandang(e) productFlags := make([]string, len(e.MarketingProduct.ProductWarehouse.Product.Flags)) for i, f := range e.MarketingProduct.ProductWarehouse.Product.Flags { @@ -51,11 +52,11 @@ func ToSalesDTO(e entity.MarketingDeliveryProduct) SalesDTO { } var category string - if e.MarketingProduct.ProductWarehouse.ProjectFlockKandang != nil { - category = e.MarketingProduct.ProductWarehouse.ProjectFlockKandang.ProjectFlock.Category + if projectFlockKandang != nil { + category = projectFlockKandang.ProjectFlock.Category } - ageInDay, ageInWeeks := calculateAgeFromChickin(e.MarketingProduct.ProductWarehouse.ProjectFlockKandang, e.DeliveryDate, productFlags, category) + ageInDay, ageInWeeks := calculateAgeFromChickin(projectFlockKandang, e.DeliveryDate, productFlags, category) var product *productDTO.ProductRelationDTO if e.MarketingProduct.ProductWarehouse.Product.Id != 0 { @@ -70,8 +71,8 @@ func ToSalesDTO(e entity.MarketingDeliveryProduct) SalesDTO { } var kandang *kandangDTO.KandangRelationDTO - if e.MarketingProduct.ProductWarehouse.ProjectFlockKandang != nil && e.MarketingProduct.ProductWarehouse.ProjectFlockKandang.Kandang.Id != 0 { - mapped := kandangDTO.ToKandangRelationDTO(e.MarketingProduct.ProductWarehouse.ProjectFlockKandang.Kandang) + if projectFlockKandang != nil && projectFlockKandang.Kandang.Id != 0 { + mapped := kandangDTO.ToKandangRelationDTO(projectFlockKandang.Kandang) kandang = &mapped } @@ -102,6 +103,7 @@ func ToSalesDTO(e entity.MarketingDeliveryProduct) SalesDTO { } func ToSalesAgeDTO(e entity.MarketingDeliveryProduct) SalesDTO { + projectFlockKandang := resolveMarketingDeliveryProjectFlockKandang(e) productFlags := make([]string, len(e.MarketingProduct.ProductWarehouse.Product.Flags)) for i, f := range e.MarketingProduct.ProductWarehouse.Product.Flags { @@ -109,11 +111,11 @@ func ToSalesAgeDTO(e entity.MarketingDeliveryProduct) SalesDTO { } var category string - if e.MarketingProduct.ProductWarehouse.ProjectFlockKandang != nil { - category = e.MarketingProduct.ProductWarehouse.ProjectFlockKandang.ProjectFlock.Category + if projectFlockKandang != nil { + category = projectFlockKandang.ProjectFlock.Category } - ageInDay, _ := calculateAgeFromChickin(e.MarketingProduct.ProductWarehouse.ProjectFlockKandang, e.DeliveryDate, productFlags, category) + ageInDay, _ := calculateAgeFromChickin(projectFlockKandang, e.DeliveryDate, productFlags, category) return SalesDTO{ Age: ageInDay, @@ -164,6 +166,13 @@ func ToPenjualanRealisasiResponseDTO(e []entity.MarketingDeliveryProduct) Penjua } } +func resolveMarketingDeliveryProjectFlockKandang(e entity.MarketingDeliveryProduct) *entity.ProjectFlockKandang { + if e.AttributedProjectFlockKandang != nil { + return e.AttributedProjectFlockKandang + } + return e.MarketingProduct.ProductWarehouse.ProjectFlockKandang +} + func calculateAgeFromChickin(projectFlockKandang *entity.ProjectFlockKandang, deliveryDate *time.Time, productFlags []string, category string) (int, int) { if projectFlockKandang == nil || deliveryDate == nil || len(projectFlockKandang.Chickins) == 0 { return 0, 0 diff --git a/internal/modules/closings/repositories/closing.repository.go b/internal/modules/closings/repositories/closing.repository.go index 8c6905f8..27cd15af 100644 --- a/internal/modules/closings/repositories/closing.repository.go +++ b/internal/modules/closings/repositories/closing.repository.go @@ -298,10 +298,11 @@ func (r *ClosingRepositoryImpl) SumFeedPurchaseAndUsedByProjectFlockKandangIDs(c err = r.DB().WithContext(ctx). Table("recording_stocks rs"). + Joins("JOIN recordings rec ON rec.id = rs.recording_id"). Joins("JOIN product_warehouses pw ON pw.id = rs.product_warehouse_id"). Joins("JOIN products prod ON prod.id = pw.product_id"). Joins("JOIN flags f ON f.flagable_id = prod.id AND f.flagable_type = ?", "products"). - Where("pw.project_flock_kandang_id IN ?", projectFlockKandangIDs). + Where("rec.project_flock_kandangs_id IN ?", projectFlockKandangIDs). Where("f.name = ?", "PAKAN"). Select("COALESCE(SUM(COALESCE(rs.usage_qty, 0) + COALESCE(rs.pending_qty, 0)), 0) AS total_used"). Scan(&usageAgg).Error @@ -340,10 +341,11 @@ func (r *ClosingRepositoryImpl) SumClaimCullingByProjectFlockKandangIDs(ctx cont err := r.DB().WithContext(ctx). Table("recording_depletions rd"). + Joins("JOIN recordings rec ON rec.id = rd.recording_id"). Joins("JOIN product_warehouses pw ON pw.id = rd.product_warehouse_id"). Joins("JOIN products prod ON prod.id = pw.product_id"). Joins("JOIN flags f ON f.flagable_id = prod.id AND f.flagable_type = ?", "products"). - Where("pw.project_flock_kandang_id IN ?", projectFlockKandangIDs). + Where("COALESCE(rd.source_project_flock_kandang_id, rec.project_flock_kandangs_id) IN ?", projectFlockKandangIDs). Where("f.name = ?", utils.FlagAyamCulling). Select("COALESCE(SUM(rd.qty), 0) AS total_culling"). Scan(&agg).Error @@ -358,52 +360,14 @@ func (r *ClosingRepositoryImpl) SumMarketingWeightAndQtyByProjectFlockKandangIDs if len(projectFlockKandangIDs) == 0 { return 0, 0, 0, nil } - - var agg struct { - TotalWeight float64 `gorm:"column:total_weight"` - TotalQty float64 `gorm:"column:total_qty"` - TotalPrice float64 `gorm:"column:total_price"` - } - - err := r.DB().WithContext(ctx). - Table("marketing_products mp"). - Joins("JOIN product_warehouses pw ON pw.id = mp.product_warehouse_id"). - Where("pw.project_flock_kandang_id IN ?", projectFlockKandangIDs). - Select("COALESCE(SUM(mp.total_weight), 0) AS total_weight, COALESCE(SUM(mp.qty), 0) AS total_qty, COALESCE(SUM(mp.total_price), 0) AS total_price"). - Scan(&agg).Error - if err != nil { - return 0, 0, 0, err - } - - return agg.TotalWeight, agg.TotalQty, agg.TotalPrice, nil + return r.sumMarketingAttributedByProjectFlockKandangIDs(ctx, projectFlockKandangIDs, nil) } func (r *ClosingRepositoryImpl) SumMarketingWeightAndQtyByProjectFlockKandangIDsAndFlagNames(ctx context.Context, projectFlockKandangIDs []uint, flagNames []string) (float64, float64, float64, error) { if len(projectFlockKandangIDs) == 0 || len(flagNames) == 0 { return 0, 0, 0, nil } - - var agg struct { - TotalWeight float64 `gorm:"column:total_weight"` - TotalQty float64 `gorm:"column:total_qty"` - TotalPrice float64 `gorm:"column:total_price"` - } - - err := r.DB().WithContext(ctx). - Table("marketing_products mp"). - Joins("JOIN product_warehouses pw ON pw.id = mp.product_warehouse_id"). - Joins("JOIN products prod ON prod.id = pw.product_id"). - Joins("JOIN flags f ON f.flagable_id = prod.id AND f.flagable_type = ?", "products"). - Joins("JOIN marketing_delivery_products mdp ON mdp.marketing_product_id = mp.id"). - Where("pw.project_flock_kandang_id IN ?", projectFlockKandangIDs). - Where("f.name IN ?", flagNames). - Select("COALESCE(SUM(mdp.total_weight), 0) AS total_weight, COALESCE(SUM(mdp.usage_qty), 0) AS total_qty, COALESCE(SUM(mdp.total_price), 0) AS total_price"). - Scan(&agg).Error - if err != nil { - return 0, 0, 0, err - } - - return agg.TotalWeight, agg.TotalQty, agg.TotalPrice, nil + return r.sumMarketingAttributedByProjectFlockKandangIDs(ctx, projectFlockKandangIDs, flagNames) } func (r *ClosingRepositoryImpl) SumRecordingEggQtyByProjectFlockKandangIDsAndFlagNames(ctx context.Context, projectFlockKandangIDs []uint, flagNames []string) (float64, error) { @@ -417,10 +381,11 @@ func (r *ClosingRepositoryImpl) SumRecordingEggQtyByProjectFlockKandangIDsAndFla err := r.DB().WithContext(ctx). Table("recording_eggs re"). + Joins("JOIN recordings rec ON rec.id = re.recording_id"). Joins("JOIN product_warehouses pw ON pw.id = re.product_warehouse_id"). Joins("JOIN products prod ON prod.id = pw.product_id"). Joins("JOIN flags f ON f.flagable_id = prod.id AND f.flagable_type = ?", "products"). - Where("pw.project_flock_kandang_id IN ?", projectFlockKandangIDs). + Where("COALESCE(re.project_flock_kandang_id, rec.project_flock_kandangs_id) IN ?", projectFlockKandangIDs). Where("f.name IN ?", flagNames). Select("COALESCE(SUM(re.qty), 0) AS total_qty"). Scan(&agg).Error @@ -817,6 +782,52 @@ type SapronakDetailRow struct { func (r *ClosingRepositoryImpl) withCtx(ctx context.Context) *gorm.DB { return r.DB().WithContext(ctx) } +func (r *ClosingRepositoryImpl) sumMarketingAttributedByProjectFlockKandangIDs( + ctx context.Context, + projectFlockKandangIDs []uint, + flagNames []string, +) (float64, float64, float64, error) { + var agg struct { + TotalWeight float64 `gorm:"column:total_weight"` + TotalQty float64 `gorm:"column:total_qty"` + TotalPrice float64 `gorm:"column:total_price"` + } + + query := r.withCtx(ctx). + Table("(?) AS mda", repository.MarketingDeliveryAttributionRowsQuery(r.withCtx(ctx))). + Joins("JOIN marketing_delivery_products mdp ON mdp.id = mda.marketing_delivery_product_id"). + 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 prod ON prod.id = pw.product_id"). + Where("mda.project_flock_kandang_id IN ?", projectFlockKandangIDs). + Where("mdp.delivery_date IS NOT NULL") + + if len(flagNames) > 0 { + query = query. + Joins("JOIN flags f ON f.flagable_id = prod.id AND f.flagable_type = ?", entity.FlagableTypeProduct). + Where("f.name IN ?", flagNames) + } + + err := query. + Select(` + COALESCE(SUM(CASE + WHEN COALESCE(mdp.usage_qty, 0) > 0 THEN mdp.total_weight * (mda.allocated_qty / mdp.usage_qty) + ELSE 0 + END), 0) AS total_weight, + COALESCE(SUM(mda.allocated_qty), 0) AS total_qty, + COALESCE(SUM(CASE + WHEN COALESCE(mdp.usage_qty, 0) > 0 THEN mdp.total_price * (mda.allocated_qty / mdp.usage_qty) + ELSE 0 + END), 0) AS total_price + `). + Scan(&agg).Error + if err != nil { + return 0, 0, 0, err + } + + return agg.TotalWeight, agg.TotalQty, agg.TotalPrice, nil +} + func applyDateRange(db *gorm.DB, column string, start, end *time.Time) *gorm.DB { if start != nil { db = db.Where(column+"::date >= ?", start) @@ -1453,6 +1464,16 @@ func (r *ClosingRepositoryImpl) FetchSapronakTransfers(ctx context.Context, kand } func (r *ClosingRepositoryImpl) FetchSapronakSales(ctx context.Context, projectFlockKandangID uint, start, end *time.Time) (map[uint][]SapronakDetailRow, error) { + attributedProjectFlockKandangExpr := ` + COALESCE( + pc.project_flock_kandang_id, + pi.project_flock_kandang_id, + source_pw.project_flock_kandang_id, + ltt.target_project_flock_kandang_id, + pw.project_flock_kandang_id + ) + ` + query := r.withCtx(ctx). Table("stock_allocations AS sa"). Select(` @@ -1470,9 +1491,15 @@ func (r *ClosingRepositoryImpl) FetchSapronakSales(ctx context.Context, projectF Joins("JOIN marketings m ON m.id = mp.marketing_id"). Joins("JOIN product_warehouses pw ON pw.id = sa.product_warehouse_id"). Joins("JOIN products p ON p.id = pw.product_id"). + Joins("LEFT JOIN purchase_items pi ON pi.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyPurchaseItems.String()). + Joins("LEFT JOIN stock_transfer_details std ON std.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyStockTransferIn.String()). + Joins("LEFT JOIN product_warehouses source_pw ON source_pw.id = std.source_product_warehouse_id"). + Joins("LEFT JOIN laying_transfer_targets ltt ON ltt.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyTransferToLayingIn.String()). + Joins("LEFT JOIN project_flock_populations pfp ON pfp.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyProjectFlockPopulation.String()). + Joins("LEFT JOIN project_chickins pc ON pc.id = pfp.project_chickin_id"). Where("sa.status = ?", entity.StockAllocationStatusActive). Where("sa.allocation_purpose = ?", entity.StockAllocationPurposeConsume). - Where("pw.project_flock_kandang_id = ?", projectFlockKandangID). + Where(attributedProjectFlockKandangExpr+" = ?", projectFlockKandangID). 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") @@ -1548,6 +1575,16 @@ func (r *ClosingRepositoryImpl) FetchSapronakSalesAllocatedDetails(ctx context.C END `, pfpType) + attributedProjectFlockKandangExpr := ` + COALESCE( + pc.project_flock_kandang_id, + pi.project_flock_kandang_id, + source_pw.project_flock_kandang_id, + ltt.target_project_flock_kandang_id, + pw_sales.project_flock_kandang_id + ) + ` + query := r.withCtx(ctx). Table("stock_allocations AS sa"). Select(fmt.Sprintf(` @@ -1600,6 +1637,7 @@ func (r *ClosingRepositoryImpl) FetchSapronakSalesAllocatedDetails(ctx context.C Joins("LEFT JOIN purchases po ON po.id = pi.purchase_id"). Joins("LEFT JOIN stock_transfer_details std ON std.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyStockTransferIn.String()). Joins("LEFT JOIN stock_transfers st ON st.id = std.stock_transfer_id"). + Joins("LEFT JOIN product_warehouses source_pw ON source_pw.id = std.source_product_warehouse_id"). Joins("LEFT JOIN laying_transfer_targets ltt ON ltt.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyTransferToLayingIn.String()). Joins("LEFT JOIN laying_transfers lt ON lt.id = ltt.laying_transfer_id"). Joins("LEFT JOIN product_warehouses pw_ltt ON pw_ltt.id = ltt.product_warehouse_id"). @@ -1619,7 +1657,7 @@ func (r *ClosingRepositoryImpl) FetchSapronakSalesAllocatedDetails(ctx context.C Where("sa.status = ?", entity.StockAllocationStatusActive). Where("sa.allocation_purpose = ?", entity.StockAllocationPurposeConsume). Where("sa.stockable_type <> ?", fifo.StockableKeyProjectFlockPopulation.String()). - Where("pw.project_flock_kandang_id = ?", projectFlockKandangID). + Where(attributedProjectFlockKandangExpr+" = ?", projectFlockKandangID). Where("f.name IN ?", sapronakFlagsAll). Group(` p_resolve.id, p_resolve.name, f.name, diff --git a/internal/modules/inventory/product-warehouses/repositories/product_warehouse.repository.go b/internal/modules/inventory/product-warehouses/repositories/product_warehouse.repository.go index e49fc421..87d114c9 100644 --- a/internal/modules/inventory/product-warehouses/repositories/product_warehouse.repository.go +++ b/internal/modules/inventory/product-warehouses/repositories/product_warehouse.repository.go @@ -84,31 +84,28 @@ func (r *ProductWarehouseRepositoryImpl) ProductWarehouseExistByProductAndWareho } func (r *ProductWarehouseRepositoryImpl) GetProductWarehouseByProductAndWarehouseID(ctx context.Context, productId, warehouseId uint) (*entity.ProductWarehouse, error) { - var productWarehouse entity.ProductWarehouse - - err := r.DB().WithContext(ctx). - Where("product_id = ? AND warehouse_id = ? AND project_flock_kandang_id IS NOT NULL", productId, warehouseId). - Order("id DESC"). - Preload("ProjectFlockKandang"). - First(&productWarehouse).Error - - if err == nil { - - if productWarehouse.ProjectFlockKandang.ClosedAt == nil { - return &productWarehouse, nil - } - - } - - err = r.DB().WithContext(ctx). - Where("product_id = ? AND warehouse_id = ? AND project_flock_kandang_id IS NULL", productId, warehouseId). - First(&productWarehouse).Error - + warehouseIsKandang, err := r.isKandangWarehouse(ctx, warehouseId) if err != nil { return nil, err } - return &productWarehouse, nil + if warehouseIsKandang { + if productWarehouse, err := r.findOpenKandangOwnedWarehouse(ctx, productId, warehouseId); err == nil { + return productWarehouse, nil + } else if !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, err + } + + return r.findSharedWarehouse(ctx, productId, warehouseId) + } + + if productWarehouse, err := r.findSharedWarehouse(ctx, productId, warehouseId); err == nil { + return productWarehouse, nil + } else if !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, err + } + + return r.findOpenKandangOwnedWarehouse(ctx, productId, warehouseId) } func (r *ProductWarehouseRepositoryImpl) FindByProductWarehouseAndPfk(ctx context.Context, productID uint, warehouseID uint, projectFlockKandangID *uint) (*entity.ProductWarehouse, error) { @@ -266,18 +263,8 @@ func (r *ProductWarehouseRepositoryImpl) EnsureProductWarehouse( projectFlockKandangID *uint, createdBy uint, ) (uint, error) { - record, err := r.GetProductWarehouseByProductAndWarehouseID(ctx, productID, warehouseID) + record, err := r.FindByProductWarehouseAndPfk(ctx, productID, warehouseID, projectFlockKandangID) if err == nil { - // Backfill project_flock_kandang_id when it's missing and caller provides one. - if projectFlockKandangID != nil && (record.ProjectFlockKandangId == nil || *record.ProjectFlockKandangId == 0) { - if err := r.DB().WithContext(ctx). - Model(&entity.ProductWarehouse{}). - Where("id = ?", record.Id). - Update("project_flock_kandang_id", *projectFlockKandangID).Error; err != nil { - return 0, err - } - record.ProjectFlockKandangId = projectFlockKandangID - } return record.Id, nil } if !errors.Is(err, gorm.ErrRecordNotFound) { @@ -301,6 +288,45 @@ func (r *ProductWarehouseRepositoryImpl) EnsureProductWarehouse( return entity.Id, nil } +func (r *ProductWarehouseRepositoryImpl) isKandangWarehouse(ctx context.Context, warehouseID uint) (bool, error) { + var kandangID *uint + if err := r.DB().WithContext(ctx). + Table("warehouses"). + Select("kandang_id"). + Where("id = ?", warehouseID). + Scan(&kandangID).Error; err != nil { + return false, err + } + return kandangID != nil && *kandangID != 0, nil +} + +func (r *ProductWarehouseRepositoryImpl) findOpenKandangOwnedWarehouse(ctx context.Context, productID uint, warehouseID uint) (*entity.ProductWarehouse, error) { + var productWarehouse entity.ProductWarehouse + err := r.DB().WithContext(ctx). + Where("product_id = ? AND warehouse_id = ? AND project_flock_kandang_id IS NOT NULL", productID, warehouseID). + Order("id DESC"). + Preload("ProjectFlockKandang"). + First(&productWarehouse).Error + if err != nil { + return nil, err + } + if productWarehouse.ProjectFlockKandang != nil && productWarehouse.ProjectFlockKandang.ClosedAt == nil { + return &productWarehouse, nil + } + return nil, gorm.ErrRecordNotFound +} + +func (r *ProductWarehouseRepositoryImpl) findSharedWarehouse(ctx context.Context, productID uint, warehouseID uint) (*entity.ProductWarehouse, error) { + var productWarehouse entity.ProductWarehouse + if err := r.DB().WithContext(ctx). + Where("product_id = ? AND warehouse_id = ? AND project_flock_kandang_id IS NULL", productID, warehouseID). + Preload("ProjectFlockKandang"). + First(&productWarehouse).Error; err != nil { + return nil, err + } + return &productWarehouse, nil +} + func (r *ProductWarehouseRepositoryImpl) GetByProductWarehouseAndProjectFlockKandang( ctx context.Context, productId uint, diff --git a/internal/modules/inventory/product-warehouses/repositories/product_warehouse.repository_test.go b/internal/modules/inventory/product-warehouses/repositories/product_warehouse.repository_test.go new file mode 100644 index 00000000..a224404b --- /dev/null +++ b/internal/modules/inventory/product-warehouses/repositories/product_warehouse.repository_test.go @@ -0,0 +1,117 @@ +package repository + +import ( + "context" + "testing" + + "github.com/glebarez/sqlite" + "gorm.io/gorm" +) + +func TestGetProductWarehouseByProductAndWarehouseIDPrefersSharedForFarmWarehouse(t *testing.T) { + db := setupProductWarehouseRepoTestDB(t) + repo := NewProductWarehouseRepository(db) + ctx := context.Background() + + insertProductWarehouseTestFixtures(t, db) + + got, err := repo.GetProductWarehouseByProductAndWarehouseID(ctx, 1, 1) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got.Id != 2 { + t.Fatalf("expected shared farm warehouse id 2, got %d", got.Id) + } +} + +func TestGetProductWarehouseByProductAndWarehouseIDPrefersOpenKandangOwnedForKandangWarehouse(t *testing.T) { + db := setupProductWarehouseRepoTestDB(t) + repo := NewProductWarehouseRepository(db) + ctx := context.Background() + + insertProductWarehouseTestFixtures(t, db) + + got, err := repo.GetProductWarehouseByProductAndWarehouseID(ctx, 1, 2) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got.Id != 3 { + t.Fatalf("expected kandang-owned warehouse id 3, got %d", got.Id) + } +} + +func TestEnsureProductWarehouseDoesNotBackfillSharedWarehouse(t *testing.T) { + db := setupProductWarehouseRepoTestDB(t) + repo := NewProductWarehouseRepository(db) + ctx := context.Background() + + insertProductWarehouseTestFixtures(t, db) + + projectFlockKandangID := uint(101) + createdID, err := repo.EnsureProductWarehouse(ctx, 1, 1, &projectFlockKandangID, 9) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if createdID == 2 { + t.Fatalf("expected new kandang-attributed row instead of reusing shared row") + } + + var sharedPfkID *uint + if err := db.WithContext(ctx). + Table("product_warehouses"). + Select("project_flock_kandang_id"). + Where("id = ?", 2). + Scan(&sharedPfkID).Error; err != nil { + t.Fatalf("failed to load shared warehouse row: %v", err) + } + if sharedPfkID != nil { + t.Fatalf("expected shared row attribution to stay nil, got %v", *sharedPfkID) + } +} + +func setupProductWarehouseRepoTestDB(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 project_flock_kandangs (id INTEGER PRIMARY KEY, closed_at TIMESTAMP 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, + qty NUMERIC(15,3) NOT NULL DEFAULT 0 + )`, + } + for _, stmt := range statements { + if err := db.Exec(stmt).Error; err != nil { + t.Fatalf("failed preparing schema: %v", err) + } + } + + return db +} + +func insertProductWarehouseTestFixtures(t *testing.T, db *gorm.DB) { + t.Helper() + + statements := []string{ + `INSERT INTO warehouses (id, kandang_id) VALUES (1, NULL), (2, 7)`, + `INSERT INTO project_flock_kandangs (id, closed_at) VALUES (101, NULL)`, + `INSERT INTO product_warehouses (id, product_id, warehouse_id, project_flock_kandang_id, qty) VALUES + (1, 1, 1, 101, 10), + (2, 1, 1, NULL, 20), + (3, 1, 2, 101, 30), + (4, 1, 2, NULL, 40)`, + } + for _, stmt := range statements { + if err := db.Exec(stmt).Error; err != nil { + t.Fatalf("failed seeding fixtures: %v", err) + } + } +} diff --git a/internal/modules/marketing/repositories/salesorder_delivery_product.repository.go b/internal/modules/marketing/repositories/salesorder_delivery_product.repository.go index 17394b80..f0216570 100644 --- a/internal/modules/marketing/repositories/salesorder_delivery_product.repository.go +++ b/internal/modules/marketing/repositories/salesorder_delivery_product.repository.go @@ -2,9 +2,10 @@ package repository import ( "context" + "sort" "strings" - "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/repports/validations" "gitlab.com/mbugroup/lti-api.git/internal/utils" @@ -12,7 +13,7 @@ import ( ) type MarketingDeliveryProductRepository interface { - repository.BaseRepository[entity.MarketingDeliveryProduct] + commonRepo.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) GetClosingPenjualanByCategory(ctx context.Context, projectFlockID uint, projectFlockKandangID *uint, category string) ([]entity.MarketingDeliveryProduct, error) @@ -23,26 +24,27 @@ type MarketingDeliveryProductRepository interface { GetUsageQty(ctx context.Context, id uint) (float64, error) ResetFifoFields(ctx context.Context, id uint) error GetClosingPenjualanForAgeChickDataProduction(ctx context.Context, projectFlockID uint, projectFlockKandangID *uint) ([]entity.MarketingDeliveryProduct, error) + GetAttributionRowsByDeliveryProductIDs(ctx context.Context, deliveryProductIDs []uint) ([]commonRepo.MarketingDeliveryAttributionRow, error) } type MarketingDeliveryProductRepositoryImpl struct { - *repository.BaseRepositoryImpl[entity.MarketingDeliveryProduct] + *commonRepo.BaseRepositoryImpl[entity.MarketingDeliveryProduct] } func NewMarketingDeliveryProductRepository(db *gorm.DB) MarketingDeliveryProductRepository { return &MarketingDeliveryProductRepositoryImpl{ - BaseRepositoryImpl: repository.NewBaseRepository[entity.MarketingDeliveryProduct](db), + BaseRepositoryImpl: commonRepo.NewBaseRepository[entity.MarketingDeliveryProduct](db), } } func (r *MarketingDeliveryProductRepositoryImpl) GetDeliveryProductsByProjectFlockID(ctx context.Context, projectFlockID uint, callback func(*gorm.DB) *gorm.DB) ([]entity.MarketingDeliveryProduct, error) { var deliveryProducts []entity.MarketingDeliveryProduct + attributionQuery := commonRepo.MarketingDeliveryAttributionRowsQuery(r.DB().WithContext(ctx)) + 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). + Joins("JOIN (?) AS mda ON mda.marketing_delivery_product_id = marketing_delivery_products.id", attributionQuery). + Where("mda.project_flock_id = ?", projectFlockID). Distinct("marketing_delivery_products.*") if callback != nil { @@ -57,139 +59,50 @@ func (r *MarketingDeliveryProductRepositoryImpl) GetDeliveryProductsByProjectFlo } 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 products ON products.id = product_warehouses.product_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 { + attributionRows, err := r.getClosingAttributionRows(ctx, projectFlockID, projectFlockKandangID, nil) + if err != nil { return nil, err } - - return deliveryProducts, nil + return r.fetchClosingDeliveryProducts(ctx, attributionRows, projectFlockKandangID) } func (r *MarketingDeliveryProductRepositoryImpl) GetClosingPenjualanForAgeChickDataProduction(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 products ON products.id = product_warehouses.product_id"). - Joins("JOIN flags ON flags.flagable_id = products.id AND flags.flagable_type = 'products'"). - 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("flags.name IN (?)", []string{ - string(utils.FlagAyamAfkir), - string(utils.FlagAyamCulling), - string(utils.FlagPullet), - string(utils.FlagLayer), - }). - 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.Flags"). - Preload("MarketingProduct.ProductWarehouse.ProjectFlockKandang"). - Preload("MarketingProduct.ProductWarehouse.ProjectFlockKandang.Chickins"). - Order("marketing_delivery_products.delivery_date DESC") - - if err := db.Find(&deliveryProducts).Error; err != nil { + attributionRows, err := r.getClosingAttributionRows(ctx, projectFlockID, projectFlockKandangID, []string{ + string(utils.FlagAyamAfkir), + string(utils.FlagAyamCulling), + string(utils.FlagPullet), + string(utils.FlagLayer), + }) + if err != nil { return nil, err } - - return deliveryProducts, nil + return r.fetchClosingDeliveryProducts(ctx, attributionRows, projectFlockKandangID) } func (r *MarketingDeliveryProductRepositoryImpl) GetClosingPenjualanByCategory(ctx context.Context, projectFlockID uint, projectFlockKandangID *uint, category string) ([]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 products ON products.id = product_warehouses.product_id"). - Joins("JOIN flags ON flags.flagable_id = products.id AND flags.flagable_type = 'products'"). - 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) + flagNames := []string{ + string(utils.FlagDOC), + string(utils.FlagPullet), + string(utils.FlagLayer), + string(utils.FlagAyamAfkir), + string(utils.FlagAyamCulling), + string(utils.FlagAyamMati), } - if category == string(utils.ProjectFlockCategoryLaying) { - db = db.Where("flags.name IN (?)", []string{ + flagNames = []string{ string(utils.FlagTelur), string(utils.FlagTelurUtuh), string(utils.FlagTelurPecah), string(utils.FlagTelurPutih), string(utils.FlagTelurRetak), - }) - } else { - db = db.Where("flags.name IN (?)", []string{ - string(utils.FlagDOC), - string(utils.FlagPullet), - string(utils.FlagLayer), - string(utils.FlagAyamAfkir), - string(utils.FlagAyamCulling), - string(utils.FlagAyamMati), - }) + } } - 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 { + attributionRows, err := r.getClosingAttributionRows(ctx, projectFlockID, projectFlockKandangID, flagNames) + if err != nil { return nil, err } - - return deliveryProducts, nil + return r.fetchClosingDeliveryProducts(ctx, attributionRows, projectFlockKandangID) } func (r *MarketingDeliveryProductRepositoryImpl) GetByMarketingId(ctx context.Context, marketingId uint) ([]entity.MarketingDeliveryProduct, error) { @@ -219,12 +132,199 @@ func (r *MarketingDeliveryProductRepositoryImpl) GetByMarketingProductID(ctx con return &deliveryProduct, nil } +func (r *MarketingDeliveryProductRepositoryImpl) GetAttributionRowsByDeliveryProductIDs(ctx context.Context, deliveryProductIDs []uint) ([]commonRepo.MarketingDeliveryAttributionRow, error) { + if len(deliveryProductIDs) == 0 { + return []commonRepo.MarketingDeliveryAttributionRow{}, nil + } + + var rows []commonRepo.MarketingDeliveryAttributionRow + query := r.DB().WithContext(ctx). + Table("(?) AS mda", commonRepo.MarketingDeliveryAttributionRowsQuery(r.DB().WithContext(ctx))). + Where("mda.marketing_delivery_product_id IN ?", deliveryProductIDs). + Order("mda.marketing_delivery_product_id ASC, mda.project_flock_kandang_id ASC") + + if err := query.Scan(&rows).Error; err != nil { + return nil, err + } + + return rows, nil +} + +func (r *MarketingDeliveryProductRepositoryImpl) getClosingAttributionRows( + ctx context.Context, + projectFlockID uint, + projectFlockKandangID *uint, + flagNames []string, +) ([]commonRepo.MarketingDeliveryAttributionRow, error) { + var rows []commonRepo.MarketingDeliveryAttributionRow + + attributionQuery := commonRepo.MarketingDeliveryAttributionRowsQuery(r.DB().WithContext(ctx)) + query := r.DB().WithContext(ctx). + Table("(?) AS mda", attributionQuery). + Joins("JOIN marketing_delivery_products mdp ON mdp.id = mda.marketing_delivery_product_id"). + 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 prod ON prod.id = pw.product_id"). + Where("mda.project_flock_id = ?", projectFlockID). + Where("mdp.delivery_date IS NOT NULL") + + if projectFlockKandangID != nil { + query = query.Where("mda.project_flock_kandang_id = ?", *projectFlockKandangID) + } + if len(flagNames) > 0 { + query = query. + Joins("JOIN flags f ON f.flagable_id = prod.id AND f.flagable_type = ?", entity.FlagableTypeProduct). + Where("f.name IN ?", flagNames) + } + + query = query. + Select(` + mda.marketing_delivery_product_id, + mda.project_flock_kandang_id, + mda.project_flock_id, + mda.project_flock_category, + SUM(mda.allocated_qty) AS allocated_qty + `). + Group(` + mda.marketing_delivery_product_id, + mda.project_flock_kandang_id, + mda.project_flock_id, + mda.project_flock_category + `). + Order("mda.marketing_delivery_product_id ASC, mda.project_flock_kandang_id ASC") + + if err := query.Scan(&rows).Error; err != nil { + return nil, err + } + + return rows, nil +} + +func (r *MarketingDeliveryProductRepositoryImpl) fetchClosingDeliveryProducts( + ctx context.Context, + attributionRows []commonRepo.MarketingDeliveryAttributionRow, + projectFlockKandangID *uint, +) ([]entity.MarketingDeliveryProduct, error) { + deliveryIDs := orderedDeliveryProductIDs(attributionRows) + if len(deliveryIDs) == 0 { + return []entity.MarketingDeliveryProduct{}, nil + } + + query := r.closingDeliveryProductsQuery(ctx). + Where("marketing_delivery_products.id IN ?", deliveryIDs). + Order("marketing_delivery_products.delivery_date DESC") + + if projectFlockKandangID == nil { + query = query.Joins( + "LEFT JOIN (?) AS mda_single ON mda_single.marketing_delivery_product_id = marketing_delivery_products.id", + commonRepo.MarketingDeliverySingleAttributionQuery(r.DB().WithContext(ctx)), + ).Select("marketing_delivery_products.*, mda_single.attributed_project_flock_kandang_id") + } + + var deliveryProducts []entity.MarketingDeliveryProduct + if err := query.Find(&deliveryProducts).Error; err != nil { + return nil, err + } + + if projectFlockKandangID == nil { + return deliveryProducts, nil + } + + return scaleDeliveryProductsByAttribution(deliveryProducts, attributionRows, *projectFlockKandangID), nil +} + +func (r *MarketingDeliveryProductRepositoryImpl) closingDeliveryProductsQuery(ctx context.Context) *gorm.DB { + return r.DB().WithContext(ctx). + Model(&entity.MarketingDeliveryProduct{}). + 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.ProjectFlock"). + Preload("MarketingProduct.ProductWarehouse.ProjectFlockKandang.Kandang"). + Preload("MarketingProduct.ProductWarehouse.ProjectFlockKandang.Chickins"). + Preload("MarketingProduct.Marketing"). + Preload("MarketingProduct.Marketing.Customer"). + Preload("AttributedProjectFlockKandang"). + Preload("AttributedProjectFlockKandang.ProjectFlock"). + Preload("AttributedProjectFlockKandang.Kandang"). + Preload("AttributedProjectFlockKandang.Chickins") +} + +func orderedDeliveryProductIDs(rows []commonRepo.MarketingDeliveryAttributionRow) []uint { + seen := make(map[uint]struct{}, len(rows)) + ids := make([]uint, 0, len(rows)) + for _, row := range rows { + if row.MarketingDeliveryProductID == 0 { + continue + } + if _, ok := seen[row.MarketingDeliveryProductID]; ok { + continue + } + seen[row.MarketingDeliveryProductID] = struct{}{} + ids = append(ids, row.MarketingDeliveryProductID) + } + sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] }) + return ids +} + +func scaleDeliveryProductsByAttribution( + deliveryProducts []entity.MarketingDeliveryProduct, + rows []commonRepo.MarketingDeliveryAttributionRow, + projectFlockKandangID uint, +) []entity.MarketingDeliveryProduct { + if len(deliveryProducts) == 0 || projectFlockKandangID == 0 { + return deliveryProducts + } + + totalByDelivery := make(map[uint]float64, len(rows)) + selectedByDelivery := make(map[uint]float64, len(rows)) + for _, row := range rows { + totalByDelivery[row.MarketingDeliveryProductID] += row.AllocatedQty + if row.ProjectFlockKandangID == projectFlockKandangID { + selectedByDelivery[row.MarketingDeliveryProductID] += row.AllocatedQty + } + } + + filtered := make([]entity.MarketingDeliveryProduct, 0, len(deliveryProducts)) + for _, delivery := range deliveryProducts { + selectedQty := selectedByDelivery[delivery.Id] + totalQty := totalByDelivery[delivery.Id] + if selectedQty <= 0 { + continue + } + + share := 1.0 + if totalQty > 0 { + share = selectedQty / totalQty + } + + cloned := delivery + cloned.AttributedProjectFlockKandangId = &projectFlockKandangID + cloned.UsageQty = selectedQty + cloned.PendingQty = 0 + cloned.TotalWeight = delivery.TotalWeight * share + cloned.TotalPrice = delivery.TotalPrice * share + filtered = append(filtered, cloned) + } + + return filtered +} + func (r *MarketingDeliveryProductRepositoryImpl) GetAllWithFilters(ctx context.Context, offset, limit int, filters *validation.MarketingQuery) ([]entity.MarketingDeliveryProduct, int64, error) { var deliveryProducts []entity.MarketingDeliveryProduct var total int64 + baseDB := r.DB().WithContext(ctx) + singleAttributionQuery := commonRepo.MarketingDeliverySingleAttributionQuery(baseDB) db := r.DB().WithContext(ctx). Model(&entity.MarketingDeliveryProduct{}). + Select("marketing_delivery_products.*, mda_single.attributed_project_flock_kandang_id"). + Joins("LEFT JOIN (?) AS mda_single ON mda_single.marketing_delivery_product_id = marketing_delivery_products.id", singleAttributionQuery). Preload("MarketingProduct", func(db *gorm.DB) *gorm.DB { return db. Preload("Marketing"). @@ -237,6 +337,9 @@ func (r *MarketingDeliveryProductRepositoryImpl) GetAllWithFilters(ctx context.C Preload("ProductWarehouse.ProjectFlockKandang"). Preload("ProductWarehouse.ProjectFlockKandang.ProjectFlock") }). + Preload("AttributedProjectFlockKandang"). + Preload("AttributedProjectFlockKandang.ProjectFlock"). + Preload("AttributedProjectFlockKandang.Kandang"). Joins("JOIN marketing_products ON marketing_products.id = marketing_delivery_products.marketing_product_id"). Joins("JOIN marketings ON marketings.id = marketing_products.marketing_id"). Where("marketing_delivery_products.delivery_date IS NOT NULL") @@ -292,22 +395,27 @@ func (r *MarketingDeliveryProductRepositoryImpl) GetAllWithFilters(ctx context.C } if filters.AreaId > 0 || filters.LocationId > 0 || filters.AllowedAreaIDs != nil || filters.AllowedLocationIDs != nil { - 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") - + buildAttrFilter := func() *gorm.DB { + return r.DB().WithContext(ctx). + Table("(?) AS mda", commonRepo.MarketingDeliveryAttributionRowsQuery(r.DB().WithContext(ctx))). + Select("1"). + Joins("JOIN project_flock_kandangs pfk ON pfk.id = mda.project_flock_kandang_id"). + Joins("JOIN project_flocks pf ON pf.id = pfk.project_flock_id"). + Where("mda.marketing_delivery_product_id = marketing_delivery_products.id") + } if filters.AreaId > 0 { - db = db.Where("project_flocks.area_id = ?", filters.AreaId) + db = db.Where("EXISTS (?)", buildAttrFilter().Where("pf.area_id = ?", filters.AreaId)) } if filters.LocationId > 0 { - db = db.Where("project_flocks.location_id = ?", filters.LocationId) + db = db.Where("EXISTS (?)", buildAttrFilter().Where("pf.location_id = ?", filters.LocationId)) } if filters.AllowedAreaIDs != nil { if len(filters.AllowedAreaIDs) == 0 { db = db.Where("1 = 0") } else { - db = db.Where("project_flocks.area_id IN ?", filters.AllowedAreaIDs) + db = db.Where("EXISTS (?)", buildAttrFilter().Where("pf.area_id IN ?", filters.AllowedAreaIDs)) } } @@ -315,7 +423,7 @@ func (r *MarketingDeliveryProductRepositoryImpl) GetAllWithFilters(ctx context.C if len(filters.AllowedLocationIDs) == 0 { db = db.Where("1 = 0") } else { - db = db.Where("project_flocks.location_id IN ?", filters.AllowedLocationIDs) + db = db.Where("EXISTS (?)", buildAttrFilter().Where("pf.location_id IN ?", filters.AllowedLocationIDs)) } } } diff --git a/internal/modules/marketing/repositories/salesorder_delivery_product.repository_test.go b/internal/modules/marketing/repositories/salesorder_delivery_product.repository_test.go new file mode 100644 index 00000000..1263c2c2 --- /dev/null +++ b/internal/modules/marketing/repositories/salesorder_delivery_product.repository_test.go @@ -0,0 +1,42 @@ +package repository + +import ( + "testing" + + commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" +) + +func TestScaleDeliveryProductsByAttribution(t *testing.T) { + projectFlockKandangID := uint(101) + + deliveryProducts := []entity.MarketingDeliveryProduct{ + { + Id: 55, + UsageQty: 100, + TotalWeight: 180, + TotalPrice: 3600, + }, + } + attributionRows := []commonRepo.MarketingDeliveryAttributionRow{ + {MarketingDeliveryProductID: 55, ProjectFlockKandangID: 101, AllocatedQty: 60}, + {MarketingDeliveryProductID: 55, ProjectFlockKandangID: 102, AllocatedQty: 40}, + } + + got := scaleDeliveryProductsByAttribution(deliveryProducts, attributionRows, projectFlockKandangID) + if len(got) != 1 { + t.Fatalf("expected 1 scaled delivery, got %d", len(got)) + } + if got[0].UsageQty != 60 { + t.Fatalf("expected usage qty 60, got %.2f", got[0].UsageQty) + } + if got[0].TotalWeight != 108 { + t.Fatalf("expected total weight 108, got %.2f", got[0].TotalWeight) + } + if got[0].TotalPrice != 2160 { + t.Fatalf("expected total price 2160, got %.2f", got[0].TotalPrice) + } + if got[0].AttributedProjectFlockKandangId == nil || *got[0].AttributedProjectFlockKandangId != projectFlockKandangID { + t.Fatalf("expected attributed kandang id %d, got %+v", projectFlockKandangID, got[0].AttributedProjectFlockKandangId) + } +} diff --git a/internal/modules/marketing/services/deliveryorder.service.go b/internal/modules/marketing/services/deliveryorder.service.go index 0680cf0e..2ae8dec9 100644 --- a/internal/modules/marketing/services/deliveryorder.service.go +++ b/internal/modules/marketing/services/deliveryorder.service.go @@ -643,6 +643,11 @@ func (s deliveryOrdersService) releaseDeliveryStock(ctx context.Context, tx *gor return nil } + affectedKandangIDs, err := s.marketingPopulationKandangIDsFromActiveAllocations(ctx, tx, deliveryProduct.Id) + if err != nil { + return err + } + deliveryProduct.UsageQty = 0 deliveryProduct.PendingQty = 0 if err := deliveryProductRepo.UpdateOne(ctx, deliveryProduct.Id, deliveryProduct, nil); err != nil { @@ -670,6 +675,9 @@ func (s deliveryOrdersService) releaseDeliveryStock(ctx context.Context, tx *gor if err := fifoV2.ReleasePopulationConsumptionByUsable(ctx, tx, fifo.UsableKeyMarketingDelivery.String(), deliveryProduct.Id); err != nil { return err } + if err := s.resyncPopulationUsageByKandangIDs(ctx, tx, affectedKandangIDs); err != nil { + return err + } releasedUsage := currentUsage - deliveryProduct.UsageQty if actorID > 0 && releasedUsage > 0 { @@ -725,29 +733,378 @@ func (s deliveryOrdersService) allocatePopulationForMarketingDelivery( return nil } - pw, err := s.ProductWarehouseRepo.WithTx(tx).GetByID(ctx, productWarehouseID, nil) + exactAllocations, err := s.findDirectPopulationAllocationsForMarketing(ctx, tx, deliveryProduct.Id) if err != nil { return err } - if pw.ProjectFlockKandangId == nil || *pw.ProjectFlockKandangId == 0 { + if len(exactAllocations) > 0 { + if err := fifoV2.ReleasePopulationConsumptionByUsable(ctx, tx, fifo.UsableKeyMarketingDelivery.String(), deliveryProduct.Id); err != nil { + return err + } + if err := s.applyDirectPopulationAllocationsForMarketing(ctx, tx, productWarehouseID, deliveryProduct.Id, exactAllocations); err != nil { + return err + } + return s.resyncPopulationUsageByKandangIDs(ctx, tx, marketingAllocationKandangIDs(exactAllocations)) + } + + sourceGroups, err := s.findPopulationSourceGroupsForMarketing(ctx, tx, deliveryProduct.Id, productWarehouseID) + if err != nil { + return err + } + if len(sourceGroups) == 0 { return nil } - - populations, err := s.ProjectFlockPopulationRepo.WithTx(tx).GetByProjectFlockKandangIDAndProductWarehouseID(ctx, *pw.ProjectFlockKandangId, productWarehouseID) - if err != nil { + if err := fifoV2.ReleasePopulationConsumptionByUsable(ctx, tx, fifo.UsableKeyMarketingDelivery.String(), deliveryProduct.Id); err != nil { return err } - if len(populations) == 0 { - return fiber.NewError(fiber.StatusBadRequest, "Populasi tidak ditemukan untuk delivery") + for _, group := range sourceGroups { + populations, err := s.ProjectFlockPopulationRepo.WithTx(tx).GetByProjectFlockKandangIDAndProductWarehouseID( + ctx, + group.ProjectFlockKandangID, + group.ProductWarehouseID, + ) + if err != nil { + return err + } + if len(populations) == 0 { + return fiber.NewError(fiber.StatusBadRequest, "Populasi tidak ditemukan untuk delivery") + } + if err := s.allocatePopulationConsumptionWithoutRelease( + ctx, + tx, + populations, + productWarehouseID, + deliveryProduct.Id, + group.Qty, + ); err != nil { + return err + } + } + return s.resyncPopulationUsageByKandangIDs(ctx, tx, marketingSourceGroupKandangIDs(sourceGroups)) +} + +type marketingPopulationAllocation struct { + ProjectFlockPopulationID uint `gorm:"column:project_flock_population_id"` + ProjectFlockKandangID uint `gorm:"column:project_flock_kandang_id"` + Qty float64 `gorm:"column:qty"` +} + +type marketingPopulationSourceGroup struct { + ProjectFlockKandangID uint `gorm:"column:project_flock_kandang_id"` + ProductWarehouseID uint `gorm:"column:product_warehouse_id"` + Qty float64 `gorm:"column:qty"` +} + +func (s deliveryOrdersService) findDirectPopulationAllocationsForMarketing( + ctx context.Context, + tx *gorm.DB, + deliveryProductID uint, +) ([]marketingPopulationAllocation, error) { + var rows []marketingPopulationAllocation + err := tx.WithContext(ctx). + Table("stock_allocations sa"). + Select(` + pfp.id AS project_flock_population_id, + pc.project_flock_kandang_id AS project_flock_kandang_id, + SUM(sa.qty) AS qty + `). + Joins("JOIN project_flock_populations pfp ON pfp.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyProjectFlockPopulation.String()). + Joins("JOIN project_chickins pc ON pc.id = pfp.project_chickin_id"). + Where("sa.usable_type = ? AND sa.usable_id = ? AND sa.status = ? AND sa.allocation_purpose = ?", + fifo.UsableKeyMarketingDelivery.String(), + deliveryProductID, + entity.StockAllocationStatusActive, + entity.StockAllocationPurposeConsume, + ). + Group("pfp.id, pc.project_flock_kandang_id"). + Order("pfp.id ASC"). + Scan(&rows).Error + return rows, err +} + +func (s deliveryOrdersService) findPopulationSourceGroupsForMarketing( + ctx context.Context, + tx *gorm.DB, + deliveryProductID uint, + productWarehouseID uint, +) ([]marketingPopulationSourceGroup, error) { + groups := make(map[string]marketingPopulationSourceGroup) + + appendGroup := func(projectFlockKandangID uint, sourceProductWarehouseID uint, qty float64) { + if projectFlockKandangID == 0 || sourceProductWarehouseID == 0 || qty <= 0 { + return + } + key := fmt.Sprintf("%d:%d", projectFlockKandangID, sourceProductWarehouseID) + current := groups[key] + current.ProjectFlockKandangID = projectFlockKandangID + current.ProductWarehouseID = sourceProductWarehouseID + current.Qty += qty + groups[key] = current } - return fifoV2.AllocatePopulationConsumption( - ctx, - tx, - populations, - productWarehouseID, - fifo.UsableKeyMarketingDelivery.String(), - deliveryProduct.Id, - deliveryProduct.UsageQty, - ) + var transferRows []marketingPopulationSourceGroup + if err := tx.WithContext(ctx). + Table("stock_allocations sa"). + Select(` + source_pw.project_flock_kandang_id AS project_flock_kandang_id, + std.source_product_warehouse_id AS product_warehouse_id, + SUM(sa.qty) AS qty + `). + Joins("JOIN stock_transfer_details std ON std.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyStockTransferIn.String()). + Joins("JOIN product_warehouses source_pw ON source_pw.id = std.source_product_warehouse_id"). + Where("sa.usable_type = ? AND sa.usable_id = ? AND sa.status = ? AND sa.allocation_purpose = ?", + fifo.UsableKeyMarketingDelivery.String(), + deliveryProductID, + entity.StockAllocationStatusActive, + entity.StockAllocationPurposeConsume, + ). + Where("source_pw.project_flock_kandang_id IS NOT NULL"). + Group("source_pw.project_flock_kandang_id, std.source_product_warehouse_id"). + Scan(&transferRows).Error; err != nil { + return nil, err + } + for _, row := range transferRows { + appendGroup(row.ProjectFlockKandangID, row.ProductWarehouseID, row.Qty) + } + + var purchaseRows []marketingPopulationSourceGroup + if err := tx.WithContext(ctx). + Table("stock_allocations sa"). + Select(` + pi.project_flock_kandang_id AS project_flock_kandang_id, + pi.product_warehouse_id AS product_warehouse_id, + SUM(sa.qty) AS qty + `). + Joins("JOIN purchase_items pi ON pi.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyPurchaseItems.String()). + Where("sa.usable_type = ? AND sa.usable_id = ? AND sa.status = ? AND sa.allocation_purpose = ?", + fifo.UsableKeyMarketingDelivery.String(), + deliveryProductID, + entity.StockAllocationStatusActive, + entity.StockAllocationPurposeConsume, + ). + Where("pi.project_flock_kandang_id IS NOT NULL"). + Where("pi.product_warehouse_id IS NOT NULL"). + Group("pi.project_flock_kandang_id, pi.product_warehouse_id"). + Scan(&purchaseRows).Error; err != nil { + return nil, err + } + for _, row := range purchaseRows { + appendGroup(row.ProjectFlockKandangID, row.ProductWarehouseID, row.Qty) + } + + var layingRows []marketingPopulationSourceGroup + if err := tx.WithContext(ctx). + Table("stock_allocations sa"). + Select(` + ltt.target_project_flock_kandang_id AS project_flock_kandang_id, + ltt.product_warehouse_id AS product_warehouse_id, + SUM(sa.qty) AS qty + `). + Joins("JOIN laying_transfer_targets ltt ON ltt.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyTransferToLayingIn.String()). + Where("sa.usable_type = ? AND sa.usable_id = ? AND sa.status = ? AND sa.allocation_purpose = ?", + fifo.UsableKeyMarketingDelivery.String(), + deliveryProductID, + entity.StockAllocationStatusActive, + entity.StockAllocationPurposeConsume, + ). + Where("ltt.product_warehouse_id IS NOT NULL"). + Group("ltt.target_project_flock_kandang_id, ltt.product_warehouse_id"). + Scan(&layingRows).Error; err != nil { + return nil, err + } + for _, row := range layingRows { + appendGroup(row.ProjectFlockKandangID, row.ProductWarehouseID, row.Qty) + } + + if len(groups) == 0 { + pw, err := s.ProductWarehouseRepo.WithTx(tx).GetByID(ctx, productWarehouseID, nil) + if err != nil { + return nil, err + } + if pw.ProjectFlockKandangId != nil && *pw.ProjectFlockKandangId != 0 { + appendGroup(*pw.ProjectFlockKandangId, productWarehouseID, 0) + } + } + + result := make([]marketingPopulationSourceGroup, 0, len(groups)) + for _, group := range groups { + if group.Qty == 0 { + group.Qty = s.resolveMarketingRequestedUsageQty(ctx, tx, deliveryProductID) + } + if group.Qty > 0 { + result = append(result, group) + } + } + + return result, nil +} + +func (s deliveryOrdersService) applyDirectPopulationAllocationsForMarketing( + ctx context.Context, + tx *gorm.DB, + productWarehouseID uint, + deliveryProductID uint, + allocations []marketingPopulationAllocation, +) error { + stockAllocationRepo := commonRepo.NewStockAllocationRepository(tx) + for _, allocation := range allocations { + if allocation.ProjectFlockPopulationID == 0 || allocation.Qty <= 0 { + continue + } + record := &entity.StockAllocation{ + ProductWarehouseId: productWarehouseID, + StockableType: fifo.StockableKeyProjectFlockPopulation.String(), + StockableId: allocation.ProjectFlockPopulationID, + UsableType: fifo.UsableKeyMarketingDelivery.String(), + UsableId: deliveryProductID, + Qty: allocation.Qty, + Status: entity.StockAllocationStatusActive, + AllocationPurpose: entity.StockAllocationPurposeConsume, + } + if err := stockAllocationRepo.CreateOne(ctx, record, nil); err != nil { + return err + } + if err := tx.WithContext(ctx). + Model(&entity.ProjectFlockPopulation{}). + Where("id = ?", allocation.ProjectFlockPopulationID). + Update("total_used_qty", gorm.Expr("total_used_qty + ?", allocation.Qty)).Error; err != nil { + return err + } + } + return nil +} + +func (s deliveryOrdersService) allocatePopulationConsumptionWithoutRelease( + ctx context.Context, + tx *gorm.DB, + populations []entity.ProjectFlockPopulation, + productWarehouseID uint, + deliveryProductID uint, + consumeQty float64, +) error { + if consumeQty <= 0 { + return nil + } + remaining := consumeQty + stockAllocationRepo := commonRepo.NewStockAllocationRepository(tx) + for _, population := range populations { + available := population.TotalQty - population.TotalUsedQty + if available <= 0 { + continue + } + portion := available + if remaining < portion { + portion = remaining + } + if portion <= 0 { + continue + } + + record := &entity.StockAllocation{ + ProductWarehouseId: productWarehouseID, + StockableType: fifo.StockableKeyProjectFlockPopulation.String(), + StockableId: population.Id, + UsableType: fifo.UsableKeyMarketingDelivery.String(), + UsableId: deliveryProductID, + Qty: portion, + Status: entity.StockAllocationStatusActive, + AllocationPurpose: entity.StockAllocationPurposeConsume, + } + if err := stockAllocationRepo.CreateOne(ctx, record, nil); err != nil { + return err + } + if err := tx.WithContext(ctx). + Model(&entity.ProjectFlockPopulation{}). + Where("id = ?", population.Id). + Update("total_used_qty", gorm.Expr("total_used_qty + ?", portion)).Error; err != nil { + return err + } + + remaining -= portion + if remaining <= 0.000001 { + break + } + } + + if remaining > 0.000001 { + return fiber.NewError(fiber.StatusBadRequest, "Populasi tidak mencukupi") + } + return nil +} + +func (s deliveryOrdersService) marketingPopulationKandangIDsFromActiveAllocations( + ctx context.Context, + tx *gorm.DB, + deliveryProductID uint, +) ([]uint, error) { + var ids []uint + err := tx.WithContext(ctx). + Table("stock_allocations sa"). + Distinct("pc.project_flock_kandang_id"). + Joins("JOIN project_flock_populations pfp ON pfp.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyProjectFlockPopulation.String()). + Joins("JOIN project_chickins pc ON pc.id = pfp.project_chickin_id"). + Where("sa.usable_type = ? AND sa.usable_id = ? AND sa.status = ? AND sa.allocation_purpose = ?", + fifo.UsableKeyMarketingDelivery.String(), + deliveryProductID, + entity.StockAllocationStatusActive, + entity.StockAllocationPurposeConsume, + ). + Pluck("pc.project_flock_kandang_id", &ids).Error + return ids, err +} + +func (s deliveryOrdersService) resyncPopulationUsageByKandangIDs(ctx context.Context, tx *gorm.DB, kandangIDs []uint) error { + for _, kandangID := range uniqueUintIDs(kandangIDs) { + if kandangID == 0 { + continue + } + if err := s.ProjectFlockPopulationRepo.WithTx(tx).ResyncUsageByProjectFlockKandangID(ctx, tx, kandangID); err != nil { + return err + } + } + return nil +} + +func (s deliveryOrdersService) resolveMarketingRequestedUsageQty(ctx context.Context, tx *gorm.DB, deliveryProductID uint) float64 { + var usageQty float64 + if err := tx.WithContext(ctx). + Table("marketing_delivery_products"). + Select("usage_qty"). + Where("id = ?", deliveryProductID). + Scan(&usageQty).Error; err != nil { + return 0 + } + return usageQty +} + +func marketingAllocationKandangIDs(rows []marketingPopulationAllocation) []uint { + ids := make([]uint, 0, len(rows)) + for _, row := range rows { + ids = append(ids, row.ProjectFlockKandangID) + } + return ids +} + +func marketingSourceGroupKandangIDs(rows []marketingPopulationSourceGroup) []uint { + ids := make([]uint, 0, len(rows)) + for _, row := range rows { + ids = append(ids, row.ProjectFlockKandangID) + } + return ids +} + +func uniqueUintIDs(ids []uint) []uint { + seen := make(map[uint]struct{}, len(ids)) + result := make([]uint, 0, len(ids)) + for _, id := range ids { + if id == 0 { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + result = append(result, id) + } + return result } 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 361bf8a3..738dcf4c 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 @@ -18,6 +18,7 @@ type ProjectFlockPopulationRepository interface { GetTotalQtyByProductWarehouseID(ctx context.Context, productWarehouseID uint) (float64, error) GetAvailableQtyByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint) (float64, error) GetTotalChickInByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint) (int64, error) + ResyncUsageByProjectFlockKandangID(ctx context.Context, tx *gorm.DB, projectFlockKandangID uint) 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 @@ -167,3 +168,58 @@ func (r *projectFlockPopulationRepositoryImpl) GetTotalChickInByProjectFlockKand return int64(math.Round(total)), nil } + +func (r *projectFlockPopulationRepositoryImpl) ResyncUsageByProjectFlockKandangID(ctx context.Context, tx *gorm.DB, projectFlockKandangID uint) error { + if projectFlockKandangID == 0 { + return nil + } + + idsSubquery := ` + SELECT pfp.id + FROM project_flock_populations pfp + JOIN project_chickins pc ON pc.id = pfp.project_chickin_id + WHERE pc.project_flock_kandang_id = ? + ` + + updateWithAlloc := ` + UPDATE project_flock_populations p + SET total_used_qty = COALESCE(a.used, 0) + FROM ( + SELECT stockable_id, SUM(qty) AS used + FROM stock_allocations + WHERE stockable_type = 'PROJECT_FLOCK_POPULATION' + AND status = 'ACTIVE' + AND allocation_purpose = 'CONSUME' + GROUP BY stockable_id + ) a + WHERE p.id = a.stockable_id + AND p.id IN (` + idsSubquery + `) + ` + + resetMissing := ` + UPDATE project_flock_populations p + SET total_used_qty = 0 + WHERE p.id IN (` + idsSubquery + `) + AND NOT EXISTS ( + SELECT 1 + FROM stock_allocations sa + WHERE sa.stockable_type = 'PROJECT_FLOCK_POPULATION' + AND sa.status = 'ACTIVE' + AND sa.allocation_purpose = 'CONSUME' + AND sa.stockable_id = p.id + ) + ` + + db := r.DB().WithContext(ctx) + if tx != nil { + db = tx.WithContext(ctx) + } + + if err := db.Exec(updateWithAlloc, projectFlockKandangID).Error; err != nil { + return err + } + if err := db.Exec(resetMissing, projectFlockKandangID).Error; err != nil { + return err + } + return nil +} diff --git a/internal/modules/production/recordings/services/recording.service.go b/internal/modules/production/recordings/services/recording.service.go index 7c4c9341..f5691b57 100644 --- a/internal/modules/production/recordings/services/recording.service.go +++ b/internal/modules/production/recordings/services/recording.service.go @@ -366,6 +366,15 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent if err := s.ensureProductWarehousesByFlags(ctx, depletionIDs, []string{"AYAM-AFKIR", "AYAM-CULLING", "AYAM-MATI"}, "depletion"); err != nil { return nil, err } + depletionSourceIDs := recordingutil.CollectWarehouseIDs(req.Depletions, func(d validation.Depletion) uint { + if d.SourceProductWarehouseId == nil { + return 0 + } + return *d.SourceProductWarehouseId + }) + if err := s.ensureProductWarehousesByFlags(ctx, depletionSourceIDs, []string{"DOC", "PULLET", "LAYER"}, "depletion source"); err != nil { + return nil, err + } eggIDs := recordingutil.CollectWarehouseIDs(req.Eggs, func(e validation.Egg) uint { return e.ProductWarehouseId }) if err := s.ensureProductWarehousesByFlags(ctx, eggIDs, []string{"TELUR-UTUH", "TELUR-PECAH", "TELUR-PUTIH", "TELUR-RETAK", "TELUR"}, "egg"); err != nil { return nil, err @@ -435,11 +444,16 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent if err := s.ensureDepletionWithinPopulation(ctx, tx, req.ProjectFlockKandangId, sumDepletionQty(mappedDepletions), 0); err != nil { return err } - sourceWarehouseID, err := s.resolvePopulationWarehouseID(ctx, req.ProjectFlockKandangId) - if err != nil { - return err - } + sourceProjectFlockKandangID := req.ProjectFlockKandangId for i := range mappedDepletions { + mappedDepletions[i].SourceProjectFlockKandangId = &sourceProjectFlockKandangID + if mappedDepletions[i].SourceProductWarehouseId != nil && *mappedDepletions[i].SourceProductWarehouseId != 0 { + continue + } + sourceWarehouseID, err := s.resolvePopulationWarehouseID(ctx, req.ProjectFlockKandangId) + if err != nil { + return err + } mappedDepletions[i].SourceProductWarehouseId = &sourceWarehouseID } } @@ -460,7 +474,7 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent return err } - mappedEggs := recordingutil.MapEggs(createdRecording.Id, createdRecording.CreatedBy, req.Eggs) + mappedEggs := recordingutil.MapEggs(createdRecording.Id, createdRecording.ProjectFlockKandangId, createdRecording.CreatedBy, req.Eggs) if err := s.Repository.CreateEggs(tx, mappedEggs); err != nil { s.Log.Errorf("Failed to persist eggs: %+v", err) return err @@ -590,13 +604,13 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin s.Log.Errorf("Failed to list existing depletions: %+v", err) return err } - existingTotals := recordingutil.TotalsByWarehouse(existingDepletions, func(dep entity.RecordingDepletion) (uint, float64) { - return dep.ProductWarehouseId, dep.Qty + existingTotals := recordingutil.DepletionTotalsByRoute(existingDepletions, func(dep entity.RecordingDepletion) (uint, *uint, float64) { + return dep.ProductWarehouseId, dep.SourceProductWarehouseId, dep.Qty }) - incomingTotals := recordingutil.TotalsByWarehouse(req.Depletions, func(dep validation.Depletion) (uint, float64) { - return dep.ProductWarehouseId, dep.Qty + incomingTotals := recordingutil.DepletionTotalsByRoute(req.Depletions, func(dep validation.Depletion) (uint, *uint, float64) { + return dep.ProductWarehouseId, dep.SourceProductWarehouseId, dep.Qty }) - match := recordingutil.FloatMapsEqual(existingTotals, incomingTotals) + match := recordingutil.DepletionRouteMapsEqual(existingTotals, incomingTotals) if match { hasDepletionChanges = false } else { @@ -613,6 +627,15 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin if err := s.ensureProductWarehousesByFlags(ctx, depletionIDs, []string{"AYAM-AFKIR", "AYAM-CULLING", "AYAM-MATI"}, "depletion"); err != nil { return err } + depletionSourceIDs := recordingutil.CollectWarehouseIDs(req.Depletions, func(d validation.Depletion) uint { + if d.SourceProductWarehouseId == nil { + return 0 + } + return *d.SourceProductWarehouseId + }) + if err := s.ensureProductWarehousesByFlags(ctx, depletionSourceIDs, []string{"DOC", "PULLET", "LAYER"}, "depletion source"); err != nil { + return err + } if err := s.reflowResetRecordingDepletionsOut(ctx, tx, existingDepletions, note, actorID); err != nil { return err } @@ -630,11 +653,16 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin if err := s.ensureDepletionWithinPopulation(ctx, tx, recordingEntity.ProjectFlockKandangId, sumDepletionQty(mappedDepletions), sumDepletionQty(existingDepletions)); err != nil { return err } - sourceWarehouseID, err := s.resolvePopulationWarehouseID(ctx, recordingEntity.ProjectFlockKandangId) - if err != nil { - return err - } + sourceProjectFlockKandangID := recordingEntity.ProjectFlockKandangId for i := range mappedDepletions { + mappedDepletions[i].SourceProjectFlockKandangId = &sourceProjectFlockKandangID + if mappedDepletions[i].SourceProductWarehouseId != nil && *mappedDepletions[i].SourceProductWarehouseId != 0 { + continue + } + sourceWarehouseID, err := s.resolvePopulationWarehouseID(ctx, recordingEntity.ProjectFlockKandangId) + if err != nil { + return err + } mappedDepletions[i].SourceProductWarehouseId = &sourceWarehouseID } } @@ -700,7 +728,7 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin return err } - mappedEggs := recordingutil.MapEggs(recordingEntity.Id, recordingEntity.CreatedBy, req.Eggs) + mappedEggs := recordingutil.MapEggs(recordingEntity.Id, recordingEntity.ProjectFlockKandangId, recordingEntity.CreatedBy, req.Eggs) if err := s.Repository.CreateEggs(tx, mappedEggs); err != nil { s.Log.Errorf("Failed to update eggs: %+v", err) return err @@ -1476,6 +1504,9 @@ func (s *recordingService) ensureProductWarehousesExist(c *fiber.Ctx, stocks []v if dep.ProductWarehouseId != 0 { idSet[dep.ProductWarehouseId] = struct{}{} } + if dep.SourceProductWarehouseId != nil && *dep.SourceProductWarehouseId != 0 { + idSet[*dep.SourceProductWarehouseId] = struct{}{} + } } for _, egg := range eggs { if egg.ProductWarehouseId != 0 { @@ -2500,12 +2531,17 @@ func (s *recordingService) allocatePopulationForDepletion( } var projectFlockKandangID uint - if err := tx.WithContext(ctx). - Table("recordings"). - Select("project_flock_kandangs_id"). - Where("id = ?", depletion.RecordingId). - Scan(&projectFlockKandangID).Error; err != nil { - return err + if depletion.SourceProjectFlockKandangId != nil { + projectFlockKandangID = *depletion.SourceProjectFlockKandangId + } + if projectFlockKandangID == 0 { + if err := tx.WithContext(ctx). + Table("recordings"). + Select("project_flock_kandangs_id"). + Where("id = ?", depletion.RecordingId). + Scan(&projectFlockKandangID).Error; err != nil { + return err + } } if projectFlockKandangID == 0 { return fiber.NewError(fiber.StatusBadRequest, "Project flock kandang tidak ditemukan untuk depletion") diff --git a/internal/modules/production/recordings/validations/recording.validation.go b/internal/modules/production/recordings/validations/recording.validation.go index 647ea37c..2e6ebbdb 100644 --- a/internal/modules/production/recordings/validations/recording.validation.go +++ b/internal/modules/production/recordings/validations/recording.validation.go @@ -9,8 +9,9 @@ type ( } Depletion struct { - ProductWarehouseId uint `json:"product_warehouse_id" validate:"required,number,min=1"` - Qty float64 `json:"qty" validate:"required,gte=0"` + ProductWarehouseId uint `json:"product_warehouse_id" validate:"required,number,min=1"` + SourceProductWarehouseId *uint `json:"source_product_warehouse_id,omitempty" validate:"omitempty,number,min=1"` + Qty float64 `json:"qty" validate:"required,gte=0"` } Egg struct { diff --git a/internal/modules/repports/dto/repportMarketing.dto.go b/internal/modules/repports/dto/repportMarketing.dto.go index b12fdfeb..30375b88 100644 --- a/internal/modules/repports/dto/repportMarketing.dto.go +++ b/internal/modules/repports/dto/repportMarketing.dto.go @@ -53,17 +53,18 @@ type ProductRelationDTOFixed struct { SellingPrice *float64 `json:"selling_price,omitempty"` } -func ToMarketingReportItems(mdps []entity.MarketingDeliveryProduct, hppMap map[uint]float64, agingMap map[int]int) []RepportMarketingItemDTO { +func ToMarketingReportItems(mdps []entity.MarketingDeliveryProduct, hppByDelivery map[uint]float64, categoryByDelivery map[uint]string, agingMap map[int]int) []RepportMarketingItemDTO { items := make([]RepportMarketingItemDTO, 0, len(mdps)) for _, mdp := range mdps { - hppPerKg := float64(0) - category := "" - if projectFlockKandang := mdp.MarketingProduct.ProductWarehouse.ProjectFlockKandang; projectFlockKandang != nil { - if hpp, exists := hppMap[projectFlockKandang.ProjectFlockId]; exists { - hppPerKg = hpp + hppPerKg := hppByDelivery[mdp.Id] + category := categoryByDelivery[mdp.Id] + if category == "" { + if projectFlockKandang := mdp.AttributedProjectFlockKandang; projectFlockKandang != nil { + category = projectFlockKandang.ProjectFlock.Category + } else if projectFlockKandang := mdp.MarketingProduct.ProductWarehouse.ProjectFlockKandang; projectFlockKandang != nil { + category = projectFlockKandang.ProjectFlock.Category } - category = projectFlockKandang.ProjectFlock.Category } soDate := time.Time{} diff --git a/internal/modules/repports/services/repport.service.go b/internal/modules/repports/services/repport.service.go index 3e002e2c..3468f266 100644 --- a/internal/modules/repports/services/repport.service.go +++ b/internal/modules/repports/services/repport.service.go @@ -18,6 +18,7 @@ import ( validation "gitlab.com/mbugroup/lti-api.git/internal/modules/repports/validations" "gitlab.com/mbugroup/lti-api.git/internal/utils" + commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" approvalService "gitlab.com/mbugroup/lti-api.git/internal/common/service" approvalDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/approvals/dto" expenseRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/repositories" @@ -215,39 +216,95 @@ func (s *repportService) GetMarketing(c *fiber.Ctx, params *validation.Marketing } } - projectFlockIDMap := make(map[uint]bool) - hppMap := make(map[uint]float64) + deliveryIDs := make([]uint, 0, len(deliveryProducts)) + for _, delivery := range deliveryProducts { + deliveryIDs = append(deliveryIDs, delivery.Id) + } - for _, dp := range deliveryProducts { - if projectFlockKandang := dp.MarketingProduct.ProductWarehouse.ProjectFlockKandang; projectFlockKandang != nil { - projectFlockID := projectFlockKandang.ProjectFlockId - if projectFlockID > 0 && !projectFlockIDMap[projectFlockID] { - projectFlockIDMap[projectFlockID] = true + attributionRows, err := s.MarketingDeliveryRepo.GetAttributionRowsByDeliveryProductIDs(c.Context(), deliveryIDs) + if err != nil { + return nil, 0, err + } - category := projectFlockKandang.ProjectFlock.Category - if utils.ProjectFlockCategory(category) == utils.ProjectFlockCategoryLaying { - if s.HppSvc != nil { - hppCost, err := s.HppSvc.CalculateHppCost(projectFlockID, nil) - if err != nil { - hppMap[projectFlockID] = 0.0 - } else if hppCost != nil { - hppMap[projectFlockID] = hppCost.Real.HargaKg - } else { - hppMap[projectFlockID] = 0.0 - } - } else { - hppMap[projectFlockID] = 0.0 - } - } else { + hppByDelivery := buildMarketingHppByDelivery(c.Context(), s.HppSvc, attributionRows) + categoryByDelivery := buildMarketingCategoryByDelivery(deliveryProducts, attributionRows) - hppMap[projectFlockID] = 0.0 + items := dto.ToMarketingReportItems(deliveryProducts, hppByDelivery, categoryByDelivery, agingMap) + return items, total, nil +} + +func buildMarketingHppByDelivery( + ctx context.Context, + hppSvc approvalService.HppService, + attributionRows []commonRepo.MarketingDeliveryAttributionRow, +) map[uint]float64 { + if len(attributionRows) == 0 { + return map[uint]float64{} + } + + hppByKandang := make(map[uint]float64) + weightedByDelivery := make(map[uint]float64) + totalQtyByDelivery := make(map[uint]float64) + + for _, row := range attributionRows { + if row.MarketingDeliveryProductID == 0 || row.ProjectFlockKandangID == 0 || row.AllocatedQty <= 0 { + continue + } + + hppPerKg, exists := hppByKandang[row.ProjectFlockKandangID] + if !exists { + hppPerKg = 0 + if hppSvc != nil && utils.ProjectFlockCategory(row.ProjectFlockCategory) == utils.ProjectFlockCategoryLaying { + if hppCost, err := hppSvc.CalculateHppCost(row.ProjectFlockKandangID, nil); err == nil && hppCost != nil { + hppPerKg = hppCost.Real.HargaKg } } + hppByKandang[row.ProjectFlockKandangID] = hppPerKg + } + + weightedByDelivery[row.MarketingDeliveryProductID] += row.AllocatedQty * hppPerKg + totalQtyByDelivery[row.MarketingDeliveryProductID] += row.AllocatedQty + } + + result := make(map[uint]float64, len(totalQtyByDelivery)) + for deliveryID, totalQty := range totalQtyByDelivery { + if totalQty <= 0 { + continue + } + result[deliveryID] = weightedByDelivery[deliveryID] / totalQty + } + + return result +} + +func buildMarketingCategoryByDelivery( + deliveryProducts []entity.MarketingDeliveryProduct, + attributionRows []commonRepo.MarketingDeliveryAttributionRow, +) map[uint]string { + result := make(map[uint]string, len(deliveryProducts)) + for _, row := range attributionRows { + if row.MarketingDeliveryProductID == 0 || strings.TrimSpace(row.ProjectFlockCategory) == "" { + continue + } + if _, exists := result[row.MarketingDeliveryProductID]; !exists { + result[row.MarketingDeliveryProductID] = row.ProjectFlockCategory } } - items := dto.ToMarketingReportItems(deliveryProducts, hppMap, agingMap) - return items, total, nil + for _, delivery := range deliveryProducts { + if _, exists := result[delivery.Id]; exists { + continue + } + if delivery.AttributedProjectFlockKandang != nil { + result[delivery.Id] = delivery.AttributedProjectFlockKandang.ProjectFlock.Category + continue + } + if delivery.MarketingProduct.ProductWarehouse.ProjectFlockKandang != nil { + result[delivery.Id] = delivery.MarketingProduct.ProductWarehouse.ProjectFlockKandang.ProjectFlock.Category + } + } + + return result } func (s *repportService) GetProductionResult(ctx *fiber.Ctx, params *validation.ProductionResultQuery) ([]dto.ProductionResultDTO, int64, error) { diff --git a/internal/utils/recording/util.recording.go b/internal/utils/recording/util.recording.go index d03ea800..49335c90 100644 --- a/internal/utils/recording/util.recording.go +++ b/internal/utils/recording/util.recording.go @@ -31,44 +31,65 @@ func MapDepletions(recordingID uint, items []validation.Depletion) []entity.Reco return nil } - aggregate := make(map[uint]float64, len(items)) + type depletionKey struct { + ProductWarehouseID uint + SourceProductWarehouseID uint + } + + aggregate := make(map[depletionKey]float64, len(items)) for _, item := range items { if item.ProductWarehouseId == 0 || item.Qty == 0 { continue } - aggregate[item.ProductWarehouseId] += item.Qty + key := depletionKey{ProductWarehouseID: item.ProductWarehouseId} + if item.SourceProductWarehouseId != nil { + key.SourceProductWarehouseID = *item.SourceProductWarehouseId + } + aggregate[key] += item.Qty } if len(aggregate) == 0 { return nil } result := make([]entity.RecordingDepletion, 0, len(aggregate)) - for warehouseID, qty := range aggregate { + for key, qty := range aggregate { if qty == 0 { continue } + var sourceWarehouseID *uint + if key.SourceProductWarehouseID != 0 { + sourceWarehouseID = new(uint) + *sourceWarehouseID = key.SourceProductWarehouseID + } result = append(result, entity.RecordingDepletion{ - RecordingId: recordingID, - ProductWarehouseId: warehouseID, - Qty: qty, + RecordingId: recordingID, + ProductWarehouseId: key.ProductWarehouseID, + SourceProductWarehouseId: sourceWarehouseID, + Qty: qty, }) } return result } -func MapEggs(recordingID uint, createdBy uint, items []validation.Egg) []entity.RecordingEgg { +func MapEggs(recordingID uint, projectFlockKandangID uint, createdBy uint, items []validation.Egg) []entity.RecordingEgg { if len(items) == 0 { return nil } result := make([]entity.RecordingEgg, 0, len(items)) for _, item := range items { + var sourceProjectFlockKandangID *uint + if projectFlockKandangID != 0 { + sourceProjectFlockKandangID = new(uint) + *sourceProjectFlockKandangID = projectFlockKandangID + } result = append(result, entity.RecordingEgg{ - RecordingId: recordingID, - ProductWarehouseId: item.ProductWarehouseId, - Qty: item.Qty, - Weight: item.Weight, - CreatedBy: createdBy, + RecordingId: recordingID, + ProductWarehouseId: item.ProductWarehouseId, + ProjectFlockKandangId: sourceProjectFlockKandangID, + Qty: item.Qty, + Weight: item.Weight, + CreatedBy: createdBy, }) } return result @@ -79,6 +100,11 @@ type EggTotals struct { Weight float64 } +type DepletionRoute struct { + ProductWarehouseId uint + SourceProductWarehouseId uint +} + func StockUsageByWarehouse(items []entity.RecordingStock) map[uint]float64 { return TotalsByWarehouse(items, func(stock entity.RecordingStock) (uint, float64) { var usage float64 @@ -121,6 +147,19 @@ func EggTotalsEqual(a, b map[uint]EggTotals) bool { return true } +func DepletionRouteMapsEqual(a, b map[DepletionRoute]float64) bool { + if len(a) != len(b) { + return false + } + for key, value := range a { + other, ok := b[key] + if !ok || !floatNearlyEqual(value, other) { + return false + } + } + return true +} + func floatNearlyEqual(a, b float64) bool { return a-b <= 0.000001 && b-a <= 0.000001 } @@ -134,6 +173,19 @@ func TotalsByWarehouse[T any](items []T, get func(T) (uint, float64)) map[uint]f return result } +func DepletionTotalsByRoute[T any](items []T, get func(T) (uint, *uint, float64)) map[DepletionRoute]float64 { + result := make(map[DepletionRoute]float64) + for _, item := range items { + productWarehouseID, sourceProductWarehouseID, qty := get(item) + key := DepletionRoute{ProductWarehouseId: productWarehouseID} + if sourceProductWarehouseID != nil { + key.SourceProductWarehouseId = *sourceProductWarehouseID + } + result[key] += qty + } + return result +} + func EggTotalsByWarehouse[T any](items []T, get func(T) (uint, int, *float64)) map[uint]EggTotals { result := make(map[uint]EggTotals) for _, item := range items { diff --git a/internal/utils/recording/util.recording_test.go b/internal/utils/recording/util.recording_test.go new file mode 100644 index 00000000..9ce0ff75 --- /dev/null +++ b/internal/utils/recording/util.recording_test.go @@ -0,0 +1,47 @@ +package recording + +import ( + "testing" + + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/validations" +) + +func TestMapDepletionsKeepsSourceWarehouseRoutes(t *testing.T) { + sourceA := uint(11) + sourceB := uint(12) + + got := MapDepletions(99, []validation.Depletion{ + {ProductWarehouseId: 21, SourceProductWarehouseId: &sourceA, Qty: 3}, + {ProductWarehouseId: 21, SourceProductWarehouseId: &sourceA, Qty: 2}, + {ProductWarehouseId: 21, SourceProductWarehouseId: &sourceB, Qty: 4}, + }) + + if len(got) != 2 { + t.Fatalf("expected 2 depletion routes, got %d", len(got)) + } + + routeQty := DepletionTotalsByRoute(got, func(item entity.RecordingDepletion) (uint, *uint, float64) { + return item.ProductWarehouseId, item.SourceProductWarehouseId, item.Qty + }) + + if routeQty[DepletionRoute{ProductWarehouseId: 21, SourceProductWarehouseId: sourceA}] != 5 { + t.Fatalf("expected source A qty 5, got %.2f", routeQty[DepletionRoute{ProductWarehouseId: 21, SourceProductWarehouseId: sourceA}]) + } + if routeQty[DepletionRoute{ProductWarehouseId: 21, SourceProductWarehouseId: sourceB}] != 4 { + t.Fatalf("expected source B qty 4, got %.2f", routeQty[DepletionRoute{ProductWarehouseId: 21, SourceProductWarehouseId: sourceB}]) + } +} + +func TestMapEggsSetsProjectFlockKandangID(t *testing.T) { + got := MapEggs(77, 44, 9, []validation.Egg{ + {ProductWarehouseId: 88, Qty: 10}, + }) + + if len(got) != 1 { + t.Fatalf("expected 1 egg row, got %d", len(got)) + } + if got[0].ProjectFlockKandangId == nil || *got[0].ProjectFlockKandangId != 44 { + t.Fatalf("expected project flock kandang id 44, got %+v", got[0].ProjectFlockKandangId) + } +}