From f6e25be76be783c9ba480e4b2fd028f7ad0c4382 Mon Sep 17 00:00:00 2001 From: "Hafizh A. Y" Date: Tue, 3 Mar 2026 10:36:48 +0700 Subject: [PATCH] fix: chickin include stock allocation, fix calculation hpp --- cmd/delete-adjustments/main.go | 6 +- cmd/reflow-adjustments/main.go | 1 + cmd/reflow-project-flock-kandang/main.go | 186 +++++++++++++- cmd/validate-chickin-trace/main.go | 122 ++++++++++ .../repository/common.hpp.repository.go | 22 +- .../common.stock_allocation.repository.go | 4 +- .../common/service/common.fifo.service.go | 31 +-- .../common/service/fifo_stock_v2/allocate.go | 4 +- .../common/service/fifo_stock_v2/gather.go | 12 +- .../common/service/fifo_stock_v2/service.go | 14 +- .../common/service/fifo_stock_v2/types.go | 2 + ...tion_purpose_to_stock_allocations.down.sql | 13 + ...cation_purpose_to_stock_allocations.up.sql | 33 +++ internal/entities/stock_allocation.go | 6 +- .../repositories/closing.repository.go | 9 +- .../chickins/services/chickin.service.go | 228 +++++++++++++++++- .../repositories/recording.repository.go | 18 +- .../purchases/services/purchase.service.go | 3 +- .../hpp_per_kandang.repository.go | 10 +- 19 files changed, 665 insertions(+), 59 deletions(-) create mode 100644 cmd/validate-chickin-trace/main.go create mode 100644 internal/database/migrations/20260302064432_add_allocation_purpose_to_stock_allocations.down.sql create mode 100644 internal/database/migrations/20260302064432_add_allocation_purpose_to_stock_allocations.up.sql diff --git a/cmd/delete-adjustments/main.go b/cmd/delete-adjustments/main.go index 6555749b..4f01d0a2 100644 --- a/cmd/delete-adjustments/main.go +++ b/cmd/delete-adjustments/main.go @@ -366,6 +366,7 @@ func countActiveUsableAllocations(ctx context.Context, db *gorm.DB, usableType s Table("stock_allocations"). Where("usable_type = ? AND usable_id = ?", usableType, usableID). Where("status = ?", entity.StockAllocationStatusActive). + Where("allocation_purpose = ?", entity.StockAllocationPurposeConsume). Count(&count).Error return count, err } @@ -376,19 +377,20 @@ func countActiveStockableAllocations(ctx context.Context, db *gorm.DB, stockable Table("stock_allocations"). Where("stockable_type = ? AND stockable_id = ?", stockableType, stockableID). Where("status = ?", entity.StockAllocationStatusActive). + Where("allocation_purpose = ?", entity.StockAllocationPurposeConsume). Count(&count).Error return count, err } func hardDeleteUsableAllocations(ctx context.Context, tx *gorm.DB, usableType string, usableID uint) error { return tx.WithContext(ctx). - Exec("DELETE FROM stock_allocations WHERE usable_type = ? AND usable_id = ?", usableType, usableID). + Exec("DELETE FROM stock_allocations WHERE usable_type = ? AND usable_id = ? AND allocation_purpose = ?", usableType, usableID, entity.StockAllocationPurposeConsume). Error } func hardDeleteStockableAllocations(ctx context.Context, tx *gorm.DB, stockableType string, stockableID uint) error { return tx.WithContext(ctx). - Exec("DELETE FROM stock_allocations WHERE stockable_type = ? AND stockable_id = ?", stockableType, stockableID). + Exec("DELETE FROM stock_allocations WHERE stockable_type = ? AND stockable_id = ? AND allocation_purpose = ?", stockableType, stockableID, entity.StockAllocationPurposeConsume). Error } diff --git a/cmd/reflow-adjustments/main.go b/cmd/reflow-adjustments/main.go index 9e2a351b..fe7cd54d 100644 --- a/cmd/reflow-adjustments/main.go +++ b/cmd/reflow-adjustments/main.go @@ -324,6 +324,7 @@ func countActiveAllocations(ctx context.Context, db *gorm.DB, usableType string, Table("stock_allocations"). Where("usable_type = ? AND usable_id = ?", usableType, usableID). Where("status = ?", entity.StockAllocationStatusActive). + Where("allocation_purpose = ?", entity.StockAllocationPurposeConsume). Count(&count).Error if err != nil { return 0, err diff --git a/cmd/reflow-project-flock-kandang/main.go b/cmd/reflow-project-flock-kandang/main.go index 973026a9..45ca5621 100644 --- a/cmd/reflow-project-flock-kandang/main.go +++ b/cmd/reflow-project-flock-kandang/main.go @@ -5,6 +5,7 @@ import ( "flag" "fmt" "log" + "math" "os" "sort" "strings" @@ -14,6 +15,7 @@ import ( "gitlab.com/mbugroup/lti-api.git/internal/config" "gitlab.com/mbugroup/lti-api.git/internal/database" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo" "gorm.io/gorm" ) @@ -181,6 +183,8 @@ func main() { orphanPopulationRows := int64(0) syncedPopulationQtyRows := int64(0) syncedPopulationUsedRows := int64(0) + traceReleasedRows := int64(0) + traceInsertedRows := int64(0) if rowsOrphan, rowsQty, rowsUsed, err := resyncProjectFlockPopulation(ctx, db, projectFlockKandangID); err != nil { fmt.Printf("FAIL population_resync project_flock_kandang_id=%d error=%v\n", projectFlockKandangID, err) failedApply++ @@ -196,9 +200,22 @@ func main() { ) } + if released, inserted, err := resyncChickinTraceByProjectFlockKandang(ctx, db, fifoStockV2Svc, projectFlockKandangID); err != nil { + fmt.Printf("FAIL chickin_trace_resync project_flock_kandang_id=%d error=%v\n", projectFlockKandangID, err) + failedApply++ + } else { + traceReleasedRows = released + traceInsertedRows = inserted + fmt.Printf( + "SYNC chickin_trace released=%d inserted=%d\n", + traceReleasedRows, + traceInsertedRows, + ) + } + fmt.Println() fmt.Printf( - "Summary: planned=%d skipped_pw=%d failed_resolve=%d applied=%d failed_apply=%d population_orphan=%d population_qty_synced=%d population_used_synced=%d\n", + "Summary: planned=%d skipped_pw=%d failed_resolve=%d applied=%d failed_apply=%d population_orphan=%d population_qty_synced=%d population_used_synced=%d trace_released=%d trace_inserted=%d\n", len(targets), skippedPW, failedResolve, @@ -207,6 +224,8 @@ func main() { orphanPopulationRows, syncedPopulationQtyRows, syncedPopulationUsedRows, + traceReleasedRows, + traceInsertedRows, ) if failedResolve > 0 || failedApply > 0 { os.Exit(1) @@ -448,6 +467,7 @@ func resyncProjectFlockPopulation(ctx context.Context, db *gorm.DB, projectFlock FROM stock_allocations sa WHERE sa.stockable_type = 'PROJECT_FLOCK_POPULATION' AND sa.status = 'ACTIVE' + AND sa.allocation_purpose = 'CONSUME' GROUP BY sa.stockable_id ) UPDATE project_flock_populations p @@ -463,3 +483,167 @@ func resyncProjectFlockPopulation(ctx context.Context, db *gorm.DB, projectFlock return orphanResult.RowsAffected, qtyResult.RowsAffected, usedResult.RowsAffected, nil } + +func resyncChickinTraceByProjectFlockKandang( + ctx context.Context, + db *gorm.DB, + fifoStockV2Svc commonSvc.FifoStockV2Service, + projectFlockKandangID uint, +) (int64, int64, error) { + if projectFlockKandangID == 0 { + return 0, 0, nil + } + + var productWarehouseIDs []uint + if err := db.WithContext(ctx). + Table("project_chickins"). + Distinct("product_warehouse_id"). + Where("project_flock_kandang_id = ?", projectFlockKandangID). + Where("deleted_at IS NULL"). + Order("product_warehouse_id ASC"). + Pluck("product_warehouse_id", &productWarehouseIDs).Error; err != nil { + return 0, 0, err + } + if len(productWarehouseIDs) == 0 { + return 0, 0, nil + } + + totalReleased := int64(0) + totalInserted := int64(0) + + for _, productWarehouseID := range productWarehouseIDs { + var releasedRows int64 + var insertedRows int64 + + err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + flagGroups, err := resolveFlagGroupsByProductWarehouse(ctx, tx, productWarehouseID) + if err != nil { + return err + } + if len(flagGroups) == 0 { + return nil + } + flagGroupCode := strings.TrimSpace(flagGroups[0]) + if flagGroupCode == "" { + return nil + } + + released := tx.WithContext(ctx). + Table("stock_allocations"). + Where("product_warehouse_id = ?", productWarehouseID). + Where("usable_type = ?", fifo.UsableKeyProjectChickin.String()). + Where("allocation_purpose = ?", entity.StockAllocationPurposeTraceChickin). + Where("status = ?", entity.StockAllocationStatusActive). + Updates(map[string]any{ + "status": entity.StockAllocationStatusReleased, + "released_at": time.Now(), + "updated_at": time.Now(), + "note": "chickin_trace_reflow_reset", + }) + if released.Error != nil { + return released.Error + } + releasedRows = released.RowsAffected + + type chickinRow struct { + ID uint `gorm:"column:id"` + UsageQty float64 `gorm:"column:usage_qty"` + ChickIn time.Time `gorm:"column:chick_in_date"` + } + chickins := make([]chickinRow, 0) + if err := tx.WithContext(ctx). + Table("project_chickins"). + Select("id, usage_qty, chick_in_date"). + Where("product_warehouse_id = ?", productWarehouseID). + Where("deleted_at IS NULL"). + Where("usage_qty > 0"). + Order("chick_in_date ASC, id ASC"). + Scan(&chickins).Error; err != nil { + return err + } + if len(chickins) == 0 { + return nil + } + + gatherRows, err := fifoStockV2Svc.Gather(ctx, commonSvc.FifoStockV2GatherRequest{ + FlagGroupCode: flagGroupCode, + Lane: "STOCKABLE", + AllocationPurpose: entity.StockAllocationPurposeTraceChickin, + IgnoreSourceUsed: true, + ProductWarehouseID: productWarehouseID, + Limit: 50000, + Tx: tx, + }) + if err != nil { + return err + } + if len(gatherRows) == 0 { + return nil + } + + type lotKey struct { + StockableType string + StockableID uint + } + remainingByLot := make(map[lotKey]float64, len(gatherRows)) + for _, row := range gatherRows { + key := lotKey{StockableType: row.Ref.LegacyTypeKey, StockableID: row.Ref.ID} + remainingByLot[key] = row.AvailableQuantity + } + + now := time.Now() + lotIndex := 0 + for _, chickinRow := range chickins { + remaining := chickinRow.UsageQty + for remaining > 1e-6 && lotIndex < len(gatherRows) { + lot := gatherRows[lotIndex] + key := lotKey{StockableType: lot.Ref.LegacyTypeKey, StockableID: lot.Ref.ID} + available := remainingByLot[key] + if available <= 1e-6 { + lotIndex++ + continue + } + + portion := math.Min(remaining, available) + if portion <= 1e-6 { + lotIndex++ + continue + } + + insert := map[string]any{ + "product_warehouse_id": productWarehouseID, + "stockable_type": lot.Ref.LegacyTypeKey, + "stockable_id": lot.Ref.ID, + "usable_type": fifo.UsableKeyProjectChickin.String(), + "usable_id": chickinRow.ID, + "qty": portion, + "status": entity.StockAllocationStatusActive, + "allocation_purpose": entity.StockAllocationPurposeTraceChickin, + "engine_version": "v2", + "flag_group_code": flagGroupCode, + "function_code": "CHICKIN_TRACE", + "created_at": now, + "updated_at": now, + } + if err := tx.WithContext(ctx).Table("stock_allocations").Create(insert).Error; err != nil { + return err + } + + insertedRows++ + remaining -= portion + remainingByLot[key] = available - portion + } + } + + return nil + }) + if err != nil { + return totalReleased, totalInserted, err + } + + totalReleased += releasedRows + totalInserted += insertedRows + } + + return totalReleased, totalInserted, nil +} diff --git a/cmd/validate-chickin-trace/main.go b/cmd/validate-chickin-trace/main.go new file mode 100644 index 00000000..81d04d63 --- /dev/null +++ b/cmd/validate-chickin-trace/main.go @@ -0,0 +1,122 @@ +package main + +import ( + "context" + "flag" + "fmt" + "log" + "math" + "os" + + "gitlab.com/mbugroup/lti-api.git/internal/config" + "gitlab.com/mbugroup/lti-api.git/internal/database" + "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo" + "gorm.io/gorm" +) + +type mismatchRow struct { + ChickinID uint `gorm:"column:chickin_id"` + ProjectFlockKandang uint `gorm:"column:project_flock_kandang_id"` + ProductWarehouseID uint `gorm:"column:product_warehouse_id"` + UsageQty float64 `gorm:"column:usage_qty"` + TraceQty float64 `gorm:"column:trace_qty"` +} + +func main() { + var projectFlockKandangID uint + flag.UintVar(&projectFlockKandangID, "project-flock-kandang-id", 0, "Optional project flock kandang scope") + flag.Parse() + + ctx := context.Background() + db := database.Connect(config.DBHost, config.DBName) + + rows, err := loadTraceMismatches(ctx, db, projectFlockKandangID) + if err != nil { + log.Fatalf("failed to load trace mismatches: %v", err) + } + + activeConsumeRows, err := countActiveConsumeProjectChickin(ctx, db, projectFlockKandangID) + if err != nil { + log.Fatalf("failed to count active consume rows: %v", err) + } + + fmt.Printf("Scope project_flock_kandang_id=%d\n", projectFlockKandangID) + fmt.Printf("Mismatched chickin trace rows: %d\n", len(rows)) + fmt.Printf("Active PROJECT_CHICKIN consume rows: %d\n", activeConsumeRows) + + if len(rows) > 0 { + for _, row := range rows { + fmt.Printf( + "MISMATCH chickin_id=%d pfk=%d pw=%d usage=%.3f trace=%.3f diff=%.3f\n", + row.ChickinID, + row.ProjectFlockKandang, + row.ProductWarehouseID, + row.UsageQty, + row.TraceQty, + row.TraceQty-row.UsageQty, + ) + } + } + + if len(rows) > 0 || activeConsumeRows > 0 { + os.Exit(1) + } +} + +func loadTraceMismatches(ctx context.Context, db *gorm.DB, projectFlockKandangID uint) ([]mismatchRow, error) { + query := db.WithContext(ctx). + Table("project_chickins pc"). + Select(` + pc.id AS chickin_id, + pc.project_flock_kandang_id, + pc.product_warehouse_id, + COALESCE(pc.usage_qty, 0) AS usage_qty, + COALESCE(SUM(sa.qty), 0) AS trace_qty + `). + Joins(` + LEFT JOIN stock_allocations sa + ON sa.usable_type = ? + AND sa.usable_id = pc.id + AND sa.status = 'ACTIVE' + AND sa.allocation_purpose = 'TRACE_CHICKIN' + `, fifo.UsableKeyProjectChickin.String()). + Where("pc.deleted_at IS NULL"). + Where("COALESCE(pc.usage_qty,0) > 0"). + Group("pc.id, pc.project_flock_kandang_id, pc.product_warehouse_id, pc.usage_qty") + + if projectFlockKandangID > 0 { + query = query.Where("pc.project_flock_kandang_id = ?", projectFlockKandangID) + } + + rows := make([]mismatchRow, 0) + if err := query.Scan(&rows).Error; err != nil { + return nil, err + } + + out := make([]mismatchRow, 0, len(rows)) + for _, row := range rows { + if math.Abs(row.TraceQty-row.UsageQty) > 1e-3 { + out = append(out, row) + } + } + return out, nil +} + +func countActiveConsumeProjectChickin(ctx context.Context, db *gorm.DB, projectFlockKandangID uint) (int64, error) { + q := db.WithContext(ctx). + Table("stock_allocations sa"). + Joins("JOIN project_chickins pc ON pc.id = sa.usable_id"). + Where("sa.usable_type = ?", fifo.UsableKeyProjectChickin.String()). + Where("sa.status = 'ACTIVE'"). + Where("sa.allocation_purpose = 'CONSUME'") + + if projectFlockKandangID > 0 { + q = q.Where("pc.project_flock_kandang_id = ?", projectFlockKandangID) + } + + var count int64 + if err := q.Count(&count).Error; err != nil { + return 0, err + } + return count, nil +} diff --git a/internal/common/repository/common.hpp.repository.go b/internal/common/repository/common.hpp.repository.go index d1dc51f0..260e78de 100644 --- a/internal/common/repository/common.hpp.repository.go +++ b/internal/common/repository/common.hpp.repository.go @@ -51,8 +51,8 @@ func (r *HppRepositoryImpl) GetDocCost(ctx context.Context, projectFlockKandangI var total float64 err := r.db.WithContext(ctx). Table("project_chickins AS pc"). - Select("COALESCE(SUM(pc.usage_qty * COALESCE(pi.price, 0)), 0)"). - Joins("JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = pc.id AND sa.stockable_type = ?", fifo.UsableKeyProjectChickin.String(), fifo.StockableKeyPurchaseItems.String()). + Select("COALESCE(SUM(sa.qty * COALESCE(pi.price, 0)), 0)"). + Joins("JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = pc.id AND sa.stockable_type = ? AND sa.status = ? AND sa.allocation_purpose = ?", fifo.UsableKeyProjectChickin.String(), fifo.StockableKeyPurchaseItems.String(), entity.StockAllocationStatusActive, entity.StockAllocationPurposeTraceChickin). Joins("JOIN purchase_items AS pi ON pi.id = sa.stockable_id"). Where("pc.project_flock_kandang_id IN (?)", projectFlockKandangIDs). Scan(&total).Error @@ -103,11 +103,11 @@ func (r *HppRepositoryImpl) GetFeedUsageCost(ctx context.Context, projectFlockKa var total float64 err := r.db.WithContext(ctx). Table("recordings AS r"). - Select("COALESCE(SUM(rs.usage_qty * COALESCE(pi.price, 0)), 0)"). + Select("COALESCE(SUM(sa.qty * COALESCE(pi.price, 0)), 0)"). Joins("JOIN recording_stocks AS rs ON rs.recording_id = r.id"). Joins("JOIN product_warehouses AS pw ON pw.id = rs.product_warehouse_id"). Joins("JOIN flags AS f ON f.flagable_id = pw.product_id AND f.flagable_type = ?", entity.FlagableTypeProduct). - Joins("JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = rs.id AND sa.stockable_type = ?", fifo.UsableKeyRecordingStock.String(), fifo.StockableKeyPurchaseItems.String()). + Joins("JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = rs.id AND sa.stockable_type = ? AND sa.status = ? AND sa.allocation_purpose = ?", fifo.UsableKeyRecordingStock.String(), fifo.StockableKeyPurchaseItems.String(), entity.StockAllocationStatusActive, entity.StockAllocationPurposeConsume). Joins("JOIN purchase_items AS pi ON pi.id = sa.stockable_id"). Where("r.project_flock_kandangs_id IN (?)", projectFlockKandangIDs). Where("r.record_datetime <= ?", *date). @@ -136,10 +136,10 @@ func (r *HppRepositoryImpl) GetOvkUsageCost(ctx context.Context, projectFlockKan var total float64 err := r.db.WithContext(ctx). Table("recordings AS r"). - Select("COALESCE(SUM(rs.usage_qty * COALESCE(pi.price, 0)), 0)"). + Select("COALESCE(SUM(sa.qty * COALESCE(pi.price, 0)), 0)"). Joins("JOIN recording_stocks AS rs ON rs.recording_id = r.id"). Joins("JOIN product_warehouses AS pw ON pw.id = rs.product_warehouse_id"). - Joins("JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = rs.id AND sa.stockable_type = ?", fifo.UsableKeyRecordingStock.String(), fifo.StockableKeyPurchaseItems.String()). + Joins("JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = rs.id AND sa.stockable_type = ? AND sa.status = ? AND sa.allocation_purpose = ?", fifo.UsableKeyRecordingStock.String(), fifo.StockableKeyPurchaseItems.String(), entity.StockAllocationStatusActive, entity.StockAllocationPurposeConsume). Joins("JOIN purchase_items AS pi ON pi.id = sa.stockable_id"). Where("r.project_flock_kandangs_id IN (?)", projectFlockKandangIDs). Where("r.record_datetime <= ?", *date). @@ -175,15 +175,15 @@ func (r *HppRepositoryImpl) GetPulletCost(ctx context.Context, projectFlockKanda err := r.db.WithContext(ctx). Table("project_chickins AS pc"). Select(` - COALESCE(SUM(pc.usage_qty * CASE + COALESCE(SUM(sa.qty * CASE WHEN sa.stockable_type = ? THEN COALESCE(pi.price, 0) WHEN sa.stockable_type = ? THEN COALESCE(tpi.price, 0) ELSE 0 END), 0)`, stockablePurchase, stockableTransferIn). - Joins("JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = pc.id", usableProjectChickin). + Joins("JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = pc.id AND sa.status = ? AND sa.allocation_purpose = ?", usableProjectChickin, entity.StockAllocationStatusActive, entity.StockAllocationPurposeTraceChickin). Joins("LEFT JOIN purchase_items AS pi ON pi.id = sa.stockable_id AND sa.stockable_type = ?", stockablePurchase). - Joins("LEFT JOIN stock_allocations AS tsa ON tsa.usable_type = ? AND tsa.usable_id = sa.stockable_id AND sa.stockable_type = ? AND tsa.stockable_type = ?", stockableTransferIn, stockableTransferIn, stockablePurchase). + Joins("LEFT JOIN stock_allocations AS tsa ON tsa.usable_type = ? AND tsa.usable_id = sa.stockable_id AND sa.stockable_type = ? AND tsa.stockable_type = ? AND tsa.status = ? AND tsa.allocation_purpose = ?", stockableTransferIn, stockableTransferIn, stockablePurchase, entity.StockAllocationStatusActive, entity.StockAllocationPurposeConsume). Joins("LEFT JOIN purchase_items AS tpi ON tpi.id = tsa.stockable_id"). Where("pc.project_flock_kandang_id = ?", projectFlockKandangId). Scan(&total).Error @@ -245,9 +245,11 @@ func (r *HppRepositoryImpl) GetEggTerjualPiecesAndWeightKgByProjectFlockKandangI `). Joins("JOIN recording_eggs re ON re.recording_id = r.id"). Joins( - "JOIN stock_allocations sa ON sa.stockable_type = ? AND sa.stockable_id = re.id AND sa.usable_type = ?", + "JOIN stock_allocations sa ON sa.stockable_type = ? AND sa.stockable_id = re.id AND sa.usable_type = ? AND sa.status = ? AND sa.allocation_purpose = ?", fifo.StockableKeyRecordingEgg.String(), fifo.UsableKeyMarketingDelivery.String(), + entity.StockAllocationStatusActive, + entity.StockAllocationPurposeConsume, ). Joins("JOIN marketing_delivery_products mdp ON mdp.id = sa.usable_id"). Where("r.project_flock_kandangs_id IN (?)", projectFlockKandangIDs). diff --git a/internal/common/repository/common.stock_allocation.repository.go b/internal/common/repository/common.stock_allocation.repository.go index 466fbe4a..08ca3236 100644 --- a/internal/common/repository/common.stock_allocation.repository.go +++ b/internal/common/repository/common.stock_allocation.repository.go @@ -33,7 +33,7 @@ func (r *StockAllocationRepositoryImpl) FindActiveByUsable( var allocations []entity.StockAllocation q := r.DB().WithContext(ctx). - Where("usable_type = ? AND usable_id = ? AND status = ?", usableType, usableID, entity.StockAllocationStatusActive) + Where("usable_type = ? AND usable_id = ? AND status = ? AND allocation_purpose = ?", usableType, usableID, entity.StockAllocationStatusActive, entity.StockAllocationPurposeConsume) if modifier != nil { q = modifier(q) @@ -70,7 +70,7 @@ func (r *StockAllocationRepositoryImpl) ReleaseByUsable( q := baseDB.WithContext(ctx). Model(&entity.StockAllocation{}). - Where("usable_type = ? AND usable_id = ? AND status = ?", usableType, usableID, entity.StockAllocationStatusActive) + Where("usable_type = ? AND usable_id = ? AND status = ? AND allocation_purpose = ?", usableType, usableID, entity.StockAllocationStatusActive, entity.StockAllocationPurposeConsume) return q.Updates(updates).Error } diff --git a/internal/common/service/common.fifo.service.go b/internal/common/service/common.fifo.service.go index 100c8fcc..dafefbdd 100644 --- a/internal/common/service/common.fifo.service.go +++ b/internal/common/service/common.fifo.service.go @@ -528,6 +528,7 @@ func (s *fifoService) allocateFromStock( UsableType: usableKey.String(), UsableId: usableID, Qty: portion, + AllocationPurpose: entities.StockAllocationPurposeConsume, Status: entities.StockAllocationStatusActive, }) @@ -890,22 +891,22 @@ func (s *fifoService) fetchPendingCandidates(ctx context.Context, tx *gorm.DB, p query = query.Order(order) } - if err := query.Find(&rows).Error; err != nil { - return nil, err - } - for _, row := range rows { - if row.Pending <= 0 { - continue + if err := query.Find(&rows).Error; err != nil { + return nil, err } - candidates = append(candidates, pendingCandidate{ - UsableKey: key, - Config: cfg, - UsableID: row.ID, - Pending: row.Pending, - CreatedAt: time.Unix(0, row.CreatedAt), - }) - } - } else { + for _, row := range rows { + if row.Pending <= 0 { + continue + } + candidates = append(candidates, pendingCandidate{ + UsableKey: key, + Config: cfg, + UsableID: row.ID, + Pending: row.Pending, + CreatedAt: time.Unix(0, row.CreatedAt), + }) + } + } else { var rows []struct { ID uint Pending float64 `gorm:"column:pending_qty"` diff --git a/internal/common/service/fifo_stock_v2/allocate.go b/internal/common/service/fifo_stock_v2/allocate.go index a7bfe3d7..02f1815e 100644 --- a/internal/common/service/fifo_stock_v2/allocate.go +++ b/internal/common/service/fifo_stock_v2/allocate.go @@ -157,6 +157,7 @@ func (s *fifoStockV2Service) allocateInternal(ctx context.Context, tx *gorm.DB, "usable_id": req.Usable.ID, "qty": portion, "status": activeAllocationStatus(), + "allocation_purpose": defaultAllocationPurpose(), "created_at": now, "updated_at": now, "engine_version": "v2", @@ -591,7 +592,7 @@ func (s *fifoStockV2Service) loadActiveAllocations( ) ([]allocationRow, error) { query := tx.Table("stock_allocations"). Select("id, product_warehouse_id, stockable_type, stockable_id, usable_type, usable_id, qty, status, created_at"). - Where("usable_type = ? AND usable_id = ? AND status = ?", usableType, usableID, activeAllocationStatus()) + Where("usable_type = ? AND usable_id = ? AND status = ? AND allocation_purpose = ?", usableType, usableID, activeAllocationStatus(), defaultAllocationPurpose()) if productWarehouseID > 0 { query = query.Where("product_warehouse_id = ?", productWarehouseID) } @@ -690,6 +691,7 @@ func (s *fifoStockV2Service) resolveRollbackFlagGroup(ctx context.Context, tx *g Select("flag_group_code"). Where("usable_type = ? AND usable_id = ?", req.Usable.LegacyTypeKey, req.Usable.ID). Where("engine_version = 'v2'"). + Where("allocation_purpose = ?", defaultAllocationPurpose()). Where("flag_group_code IS NOT NULL AND flag_group_code <> ''"). Order("id DESC"). Limit(1). diff --git a/internal/common/service/fifo_stock_v2/gather.go b/internal/common/service/fifo_stock_v2/gather.go index a1f4c4ae..f3733d31 100644 --- a/internal/common/service/fifo_stock_v2/gather.go +++ b/internal/common/service/fifo_stock_v2/gather.go @@ -48,6 +48,8 @@ func (s *fifoStockV2Service) Gather(ctx context.Context, req GatherRequest) ([]G } func (s *fifoStockV2Service) gatherRows(ctx context.Context, tx *gorm.DB, req GatherRequest) ([]GatherRow, error) { + req.AllocationPurpose = normalizeAllocationPurpose(req.AllocationPurpose) + rules, err := s.loadRouteRules(ctx, tx, req.FlagGroupCode, req.Lane) if err != nil { return nil, err @@ -155,7 +157,7 @@ func (s *fifoStockV2Service) buildGatherSubquery(rule routeRule, trait traitRule whereExtraArgs := make([]any, 0, 1) if req.Lane == LaneStockable { - if rule.UsedQuantityCol != nil && strings.TrimSpace(*rule.UsedQuantityCol) != "" { + if !req.IgnoreSourceUsed && rule.UsedQuantityCol != nil && strings.TrimSpace(*rule.UsedQuantityCol) != "" { usedCol, _ := mustSafeIdentifier(*rule.UsedQuantityCol) usedExpr = fmt.Sprintf("COALESCE(src.%s,0)::numeric", usedCol) } else { @@ -167,13 +169,13 @@ func (s *fifoStockV2Service) buildGatherSubquery(rule routeRule, trait traitRule // We split the args because the WHERE placeholder order appears // after product/flag filter placeholders in the final SQL. usedExpr = fmt.Sprintf( - "(SELECT COALESCE(SUM(sa.qty),0)::numeric FROM stock_allocations sa WHERE sa.stockable_type = ? AND sa.stockable_id = src.%s AND sa.status = '%s')", + "(SELECT COALESCE(SUM(sa.qty),0)::numeric FROM stock_allocations sa WHERE sa.stockable_type = ? AND sa.stockable_id = src.%s AND sa.status = '%s' AND sa.allocation_purpose = ?)", sourceIDCol, activeAllocationStatus(), ) - extraArgs = append(extraArgs, rule.LegacyTypeKey) - extraArgs = append(extraArgs, rule.LegacyTypeKey) - whereExtraArgs = append(whereExtraArgs, rule.LegacyTypeKey) + extraArgs = append(extraArgs, rule.LegacyTypeKey, req.AllocationPurpose) + extraArgs = append(extraArgs, rule.LegacyTypeKey, req.AllocationPurpose) + whereExtraArgs = append(whereExtraArgs, rule.LegacyTypeKey, req.AllocationPurpose) } availableExpr = fmt.Sprintf("(%s - %s)", baseQtyExpr, usedExpr) } else { diff --git a/internal/common/service/fifo_stock_v2/service.go b/internal/common/service/fifo_stock_v2/service.go index 25578d30..0642b31c 100644 --- a/internal/common/service/fifo_stock_v2/service.go +++ b/internal/common/service/fifo_stock_v2/service.go @@ -238,7 +238,7 @@ func nearlyZero(v float64) bool { } func (s *fifoStockV2Service) ensureStockAllocationColumns(tx *gorm.DB) error { - checkCols := []string{"engine_version", "flag_group_code", "function_code", "idempotency_key"} + checkCols := []string{"engine_version", "flag_group_code", "function_code", "idempotency_key", "allocation_purpose"} for _, col := range checkCols { var count int64 err := tx.Raw(` @@ -263,3 +263,15 @@ func activeAllocationStatus() string { func releasedAllocationStatus() string { return entity.StockAllocationStatusReleased } + +func defaultAllocationPurpose() string { + return entity.StockAllocationPurposeConsume +} + +func normalizeAllocationPurpose(purpose string) string { + purpose = strings.TrimSpace(strings.ToUpper(purpose)) + if purpose == "" { + return defaultAllocationPurpose() + } + return purpose +} diff --git a/internal/common/service/fifo_stock_v2/types.go b/internal/common/service/fifo_stock_v2/types.go index 701274c4..abb7cc5b 100644 --- a/internal/common/service/fifo_stock_v2/types.go +++ b/internal/common/service/fifo_stock_v2/types.go @@ -33,6 +33,8 @@ type Ref struct { type GatherRequest struct { FlagGroupCode string Lane Lane + AllocationPurpose string + IgnoreSourceUsed bool ProductWarehouseID uint From *time.Time AsOf *time.Time diff --git a/internal/database/migrations/20260302064432_add_allocation_purpose_to_stock_allocations.down.sql b/internal/database/migrations/20260302064432_add_allocation_purpose_to_stock_allocations.down.sql new file mode 100644 index 00000000..b6eb5a76 --- /dev/null +++ b/internal/database/migrations/20260302064432_add_allocation_purpose_to_stock_allocations.down.sql @@ -0,0 +1,13 @@ +BEGIN; + +DROP INDEX IF EXISTS idx_stock_allocations_purpose_stockable_active; +DROP INDEX IF EXISTS idx_stock_allocations_purpose_usable_active; +DROP INDEX IF EXISTS idx_stock_allocations_purpose_status; + +ALTER TABLE stock_allocations + DROP CONSTRAINT IF EXISTS stock_allocations_allocation_purpose_check; + +ALTER TABLE stock_allocations + DROP COLUMN IF EXISTS allocation_purpose; + +COMMIT; diff --git a/internal/database/migrations/20260302064432_add_allocation_purpose_to_stock_allocations.up.sql b/internal/database/migrations/20260302064432_add_allocation_purpose_to_stock_allocations.up.sql new file mode 100644 index 00000000..3b63e37b --- /dev/null +++ b/internal/database/migrations/20260302064432_add_allocation_purpose_to_stock_allocations.up.sql @@ -0,0 +1,33 @@ +BEGIN; + +ALTER TABLE stock_allocations + ADD COLUMN IF NOT EXISTS allocation_purpose VARCHAR(32); + +UPDATE stock_allocations +SET allocation_purpose = 'CONSUME' +WHERE allocation_purpose IS NULL + OR BTRIM(allocation_purpose) = ''; + +ALTER TABLE stock_allocations + ALTER COLUMN allocation_purpose SET DEFAULT 'CONSUME', + ALTER COLUMN allocation_purpose SET NOT NULL; + +ALTER TABLE stock_allocations + DROP CONSTRAINT IF EXISTS stock_allocations_allocation_purpose_check; + +ALTER TABLE stock_allocations + ADD CONSTRAINT stock_allocations_allocation_purpose_check + CHECK (allocation_purpose IN ('CONSUME', 'TRACE_CHICKIN')); + +CREATE INDEX IF NOT EXISTS idx_stock_allocations_purpose_status + ON stock_allocations (allocation_purpose, status); + +CREATE INDEX IF NOT EXISTS idx_stock_allocations_purpose_usable_active + ON stock_allocations (allocation_purpose, usable_type, usable_id) + WHERE status = 'ACTIVE'; + +CREATE INDEX IF NOT EXISTS idx_stock_allocations_purpose_stockable_active + ON stock_allocations (allocation_purpose, stockable_type, stockable_id) + WHERE status = 'ACTIVE'; + +COMMIT; diff --git a/internal/entities/stock_allocation.go b/internal/entities/stock_allocation.go index 614762a1..c3aa1c28 100644 --- a/internal/entities/stock_allocation.go +++ b/internal/entities/stock_allocation.go @@ -10,6 +10,9 @@ const ( StockAllocationStatusPending = "PENDING" StockAllocationStatusActive = "ACTIVE" StockAllocationStatusReleased = "RELEASED" + + StockAllocationPurposeConsume = "CONSUME" + StockAllocationPurposeTraceChickin = "TRACE_CHICKIN" ) // StockAllocation links a usable record (consumption) with an incoming stock record. @@ -22,7 +25,8 @@ type StockAllocation struct { UsableType string `gorm:"size:100;not null;index:stock_allocations_usage_lookup,priority:1"` UsableId uint `gorm:"not null;index:stock_allocations_usage_lookup,priority:2"` Qty float64 `gorm:"type:numeric(15,3);not null"` - Status string `gorm:"size:20;not null;default:ACTIVE"` + AllocationPurpose string `gorm:"size:32;not null;default:CONSUME;index:stock_allocations_purpose_status,priority:1"` + Status string `gorm:"size:20;not null;default:ACTIVE;index:stock_allocations_purpose_status,priority:2"` Note *string `gorm:"type:text"` CreatedAt time.Time `gorm:"autoCreateTime"` UpdatedAt time.Time `gorm:"autoUpdateTime"` diff --git a/internal/modules/closings/repositories/closing.repository.go b/internal/modules/closings/repositories/closing.repository.go index ecd96b0a..b475dab0 100644 --- a/internal/modules/closings/repositories/closing.repository.go +++ b/internal/modules/closings/repositories/closing.repository.go @@ -1031,6 +1031,7 @@ func (r *ClosingRepositoryImpl) FetchSapronakUsageAllocatedDetails(ctx context.C Joins("LEFT JOIN project_chickins pc ON pc.id = pfp.project_chickin_id"). Joins("LEFT JOIN products p_resolve ON p_resolve.id = COALESCE(pi.product_id, pw_ltt.product_id, pw.product_id)"). Where("sa.status = ?", entity.StockAllocationStatusActive). + Where("sa.allocation_purpose = ?", entity.StockAllocationPurposeConsume). Where("sa.stockable_type <> ?", fifo.StockableKeyProjectFlockPopulation.String()). Where("f.name IN ?", sapronakFlagsAll). Where(` @@ -1236,6 +1237,7 @@ func (r *ClosingRepositoryImpl) FetchSapronakAdjustments(ctx context.Context, ka Joins("JOIN warehouses w ON w.id = pw.warehouse_id"). Joins("JOIN products p ON p.id = pw.product_id"). Where("sa.status = ?", entity.StockAllocationStatusActive). + Where("sa.allocation_purpose = ?", entity.StockAllocationPurposeConsume). Where("w.kandang_id = ?", kandangID). Where("f.name IN ?", sapronakFlagsAll). Where("f.name NOT IN ?", sapronakFlags(utils.FlagDOC, utils.FlagPullet)). @@ -1327,6 +1329,7 @@ func (r *ClosingRepositoryImpl) FetchSapronakTransfers(ctx context.Context, kand Joins("LEFT JOIN warehouses w_dest ON w_dest.id = pw_dest.warehouse_id"). Joins("JOIN products p ON p.id = std.product_id"). Where("sa.status = ?", entity.StockAllocationStatusActive). + Where("sa.allocation_purpose = ?", entity.StockAllocationPurposeConsume). Where("w.kandang_id = ?", kandangID). Where("(w_dest.kandang_id IS NULL OR w_dest.kandang_id <> w.kandang_id)"). Where("f.name IN ?", sapronakFlagsAll). @@ -1358,6 +1361,7 @@ func (r *ClosingRepositoryImpl) FetchSapronakTransfers(ctx context.Context, kand Joins("JOIN warehouses w ON w.id = pw.warehouse_id"). Joins("JOIN products p ON p.id = pw.product_id"). Where("sa.status = ?", entity.StockAllocationStatusActive). + Where("sa.allocation_purpose = ?", entity.StockAllocationPurposeConsume). Where("w.kandang_id = ?", kandangID). Where("(w_dest.kandang_id IS NULL OR w_dest.kandang_id <> w.kandang_id)"). Where("f.name IN ?", sapronakFlagsAll). @@ -1393,6 +1397,7 @@ func (r *ClosingRepositoryImpl) FetchSapronakSales(ctx context.Context, projectF Joins("JOIN product_warehouses pw ON pw.id = sa.product_warehouse_id"). Joins("JOIN products p ON p.id = pw.product_id"). Where("sa.status = ?", entity.StockAllocationStatusActive). + Where("sa.allocation_purpose = ?", entity.StockAllocationPurposeConsume). Where("pw.project_flock_kandang_id = ?", 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") @@ -1419,9 +1424,10 @@ 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 = mp.product_warehouse_id"). Joins("JOIN products p ON p.id = pw.product_id"). - Joins("LEFT JOIN stock_allocations sa ON sa.usable_id = mdp.id AND sa.usable_type = ? AND sa.status = ?", + Joins("LEFT JOIN stock_allocations sa ON sa.usable_id = mdp.id AND sa.usable_type = ? AND sa.status = ? AND sa.allocation_purpose = ?", fifo.UsableKeyMarketingDelivery.String(), entity.StockAllocationStatusActive, + entity.StockAllocationPurposeConsume, ). Where("mdp.usage_qty > 0"). Where("sa.id IS NULL"). @@ -1481,6 +1487,7 @@ func (r *ClosingRepositoryImpl) FetchSapronakSalesAllocatedDetails(ctx context.C Joins("LEFT JOIN laying_transfers lt ON lt.id = ltt.laying_transfer_id"). Joins("LEFT JOIN adjustment_stocks ast ON ast.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyAdjustmentIn.String()). 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("f.name IN ?", sapronakFlagsAll). diff --git a/internal/modules/production/chickins/services/chickin.service.go b/internal/modules/production/chickins/services/chickin.service.go index 3a54f3ba..a198b51a 100644 --- a/internal/modules/production/chickins/services/chickin.service.go +++ b/internal/modules/production/chickins/services/chickin.service.go @@ -4,7 +4,9 @@ import ( "context" "errors" "fmt" + "math" "strings" + "time" commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" @@ -19,6 +21,7 @@ import ( rProjectFlock "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" rStockLogs "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/repositories" "gitlab.com/mbugroup/lti-api.git/internal/utils" + "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo" "github.com/go-playground/validator/v10" "github.com/gofiber/fiber/v2" @@ -350,7 +353,18 @@ func (s chickinService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) return nil, err } - return s.GetOne(c, id) + updated, err := s.GetOne(c, id) + if err != nil { + return nil, err + } + + if updated.UsageQty > 0 { + if err := s.syncChickinTraceForProductWarehouse(c.Context(), nil, updated.ProductWarehouseId); err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to sync chickin stock trace") + } + } + + return updated, nil } func (s chickinService) DeleteOne(c *fiber.Ctx, id uint) error { @@ -368,15 +382,31 @@ func (s chickinService) DeleteOne(c *fiber.Ctx, id uint) error { return err } - if chickin.UsageQty > 0 || chickin.PendingUsageQty > 0 { - if err := s.ReleaseChickinStocks(c.Context(), s.Repository.DB(), chickin, actorID); err != nil { + err = s.Repository.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error { + chickinRepoTx := repository.NewChickinRepository(tx) + + if chickin.UsageQty > 0 || chickin.PendingUsageQty > 0 { + if err := s.ReleaseChickinStocks(c.Context(), tx, chickin, actorID); err != nil { + return err + } + } + + if err := chickinRepoTx.DeleteOne(c.Context(), id); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusNotFound, "Chickin not found") + } return err } - } - if err := s.Repository.DeleteOne(c.Context(), id); err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return fiber.NewError(fiber.StatusNotFound, "Chickin not found") + if err := s.syncChickinTraceForProductWarehouse(c.Context(), tx, chickin.ProductWarehouseId); err != nil { + return err + } + + return nil + }) + if err != nil { + if fiberErr, ok := err.(*fiber.Error); ok { + return fiberErr } return err } @@ -439,6 +469,7 @@ func (s chickinService) Approval(c *fiber.Ctx, req *validation.Approve) ([]entit approvalSvc := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(dbTransaction)) chickinRepoTx := repository.NewChickinRepository(dbTransaction) ProjectFlockPopulationRepotx := s.ProjectflockPopulationRepo.WithTx(dbTransaction) + touchedProductWarehouseIDs := make(map[uint]struct{}) for _, approvableID := range approvableIDs { if _, err := approvalSvc.CreateApproval( @@ -492,6 +523,7 @@ func (s chickinService) Approval(c *fiber.Ctx, req *validation.Approve) ([]entit } chickin.UsageQty = approvedQty chickin.PendingUsageQty = 0 + touchedProductWarehouseIDs[chickin.ProductWarehouseId] = struct{}{} populationExists, err := ProjectFlockPopulationRepotx.ExistsByProjectChickinID(c.Context(), chickin.Id) if err != nil { @@ -555,6 +587,7 @@ func (s chickinService) Approval(c *fiber.Ctx, req *validation.Approve) ([]entit if err := s.ReleaseChickinStocks(c.Context(), dbTransaction, &chickin, actorID); err != nil { return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to release stock for rejected chickin %d: %v", chickin.Id, err)) } + touchedProductWarehouseIDs[chickin.ProductWarehouseId] = struct{}{} if err := chickinRepoTx.DeleteOne(c.Context(), chickin.Id); err != nil { if !errors.Is(err, gorm.ErrRecordNotFound) { @@ -564,6 +597,13 @@ func (s chickinService) Approval(c *fiber.Ctx, req *validation.Approve) ([]entit } } } + + for productWarehouseID := range touchedProductWarehouseIDs { + if err := s.syncChickinTraceForProductWarehouse(c.Context(), dbTransaction, productWarehouseID); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to sync chickin trace for product warehouse %d", productWarehouseID)) + } + } + return nil }) @@ -678,6 +718,180 @@ func (s *chickinService) ReleaseChickinStocks(ctx context.Context, tx *gorm.DB, return nil } +func (s *chickinService) syncChickinTraceForProductWarehouse(ctx context.Context, tx *gorm.DB, productWarehouseID uint) error { + if productWarehouseID == 0 { + return nil + } + if s.FifoStockV2Svc == nil { + return nil + } + + if tx == nil { + return s.Repository.DB().WithContext(ctx).Transaction(func(innerTx *gorm.DB) error { + return s.syncChickinTraceForProductWarehouse(ctx, innerTx, productWarehouseID) + }) + } + + flagGroupCode, err := s.resolveFlagGroupByProductWarehouse(ctx, tx, productWarehouseID) + if err != nil { + return err + } + if strings.TrimSpace(flagGroupCode) == "" { + return nil + } + + now := time.Now() + if err := tx.WithContext(ctx). + Table("stock_allocations"). + Where("product_warehouse_id = ?", productWarehouseID). + Where("usable_type = ?", fifo.UsableKeyProjectChickin.String()). + Where("allocation_purpose = ?", entity.StockAllocationPurposeTraceChickin). + Where("status = ?", entity.StockAllocationStatusActive). + Updates(map[string]any{ + "status": entity.StockAllocationStatusReleased, + "released_at": now, + "updated_at": now, + "note": "chickin_trace_reflow_reset", + }).Error; err != nil { + return err + } + + type chickinTraceRow struct { + ID uint `gorm:"column:id"` + UsageQty float64 `gorm:"column:usage_qty"` + ChickIn time.Time `gorm:"column:chick_in_date"` + } + chickins := make([]chickinTraceRow, 0) + if err := tx.WithContext(ctx). + Table("project_chickins"). + Select("id, usage_qty, chick_in_date"). + Where("product_warehouse_id = ?", productWarehouseID). + Where("deleted_at IS NULL"). + Where("usage_qty > 0"). + Order("chick_in_date ASC, id ASC"). + Scan(&chickins).Error; err != nil { + return err + } + if len(chickins) == 0 { + return nil + } + + gatherRows, err := s.FifoStockV2Svc.Gather(ctx, commonSvc.FifoStockV2GatherRequest{ + FlagGroupCode: flagGroupCode, + Lane: "STOCKABLE", + AllocationPurpose: entity.StockAllocationPurposeTraceChickin, + IgnoreSourceUsed: true, + ProductWarehouseID: productWarehouseID, + Limit: 50000, + Tx: tx, + }) + if err != nil { + return err + } + if len(gatherRows) == 0 { + return nil + } + + type lotKey struct { + StockableType string + StockableID uint + } + remainingByLot := make(map[lotKey]float64, len(gatherRows)) + for _, row := range gatherRows { + key := lotKey{StockableType: row.Ref.LegacyTypeKey, StockableID: row.Ref.ID} + remainingByLot[key] = row.AvailableQuantity + } + + lotIndex := 0 + traceNow := time.Now() + for _, chickin := range chickins { + remaining := chickin.UsageQty + for remaining > 1e-6 && lotIndex < len(gatherRows) { + lot := gatherRows[lotIndex] + key := lotKey{StockableType: lot.Ref.LegacyTypeKey, StockableID: lot.Ref.ID} + available := remainingByLot[key] + if available <= 1e-6 { + lotIndex++ + continue + } + + portion := math.Min(remaining, available) + if portion <= 1e-6 { + lotIndex++ + continue + } + + insert := map[string]any{ + "product_warehouse_id": productWarehouseID, + "stockable_type": lot.Ref.LegacyTypeKey, + "stockable_id": lot.Ref.ID, + "usable_type": fifo.UsableKeyProjectChickin.String(), + "usable_id": chickin.ID, + "qty": portion, + "status": entity.StockAllocationStatusActive, + "allocation_purpose": entity.StockAllocationPurposeTraceChickin, + "engine_version": "v2", + "flag_group_code": flagGroupCode, + "function_code": "CHICKIN_TRACE", + "created_at": traceNow, + "updated_at": traceNow, + } + if err := tx.WithContext(ctx).Table("stock_allocations").Create(insert).Error; err != nil { + return err + } + + remaining -= portion + remainingByLot[key] = available - portion + } + + if remaining > 1e-6 { + s.Log.Warnf( + "chickin trace partial allocation for product_warehouse_id=%d chickin_id=%d: remaining=%.3f", + productWarehouseID, + chickin.ID, + remaining, + ) + } + } + + return nil +} + +func (s *chickinService) resolveFlagGroupByProductWarehouse(ctx context.Context, tx *gorm.DB, productWarehouseID uint) (string, error) { + type row struct { + FlagGroupCode string `gorm:"column:flag_group_code"` + } + + selected := row{} + err := tx.WithContext(ctx). + Table("fifo_stock_v2_route_rules rr"). + Select("rr.flag_group_code"). + Joins("JOIN fifo_stock_v2_flag_groups fg ON fg.code = rr.flag_group_code AND fg.is_active = TRUE"). + Where("rr.is_active = TRUE"). + Where("rr.lane = 'STOCKABLE'"). + Where(` + EXISTS ( + SELECT 1 + FROM product_warehouses pw + JOIN flags f ON f.flagable_id = pw.product_id + JOIN fifo_stock_v2_flag_members fm ON fm.flag_name = f.name AND fm.is_active = TRUE + WHERE pw.id = ? + AND f.flagable_type = ? + AND fm.flag_group_code = rr.flag_group_code + ) + `, productWarehouseID, entity.FlagableTypeProduct). + Order("fg.priority ASC, rr.id ASC"). + Limit(1). + Take(&selected).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return "", nil + } + return "", err + } + return selected.FlagGroupCode, nil +} + func (s chickinService) EnsureChickInExists(ctx context.Context, projectFlockKandangID uint) error { if projectFlockKandangID == 0 { return fiber.NewError(fiber.StatusBadRequest, "Project flock kandang tidak valid") diff --git a/internal/modules/production/recordings/repositories/recording.repository.go b/internal/modules/production/recordings/repositories/recording.repository.go index 9d4791b6..3010eca1 100644 --- a/internal/modules/production/recordings/repositories/recording.repository.go +++ b/internal/modules/production/recordings/repositories/recording.repository.go @@ -894,6 +894,7 @@ func (r *RecordingRepositoryImpl) ResyncProjectFlockPopulationUsage(ctx context. 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 @@ -904,14 +905,15 @@ func (r *RecordingRepositoryImpl) ResyncProjectFlockPopulationUsage(ctx context. 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.stockable_id = p.id - ) - ` + 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 { diff --git a/internal/modules/purchases/services/purchase.service.go b/internal/modules/purchases/services/purchase.service.go index 313c4b7f..48f31bf0 100644 --- a/internal/modules/purchases/services/purchase.service.go +++ b/internal/modules/purchases/services/purchase.service.go @@ -1914,11 +1914,12 @@ func (s *purchaseService) resolveChickinLockedItemIDsByItemID(ctx context.Contex if err := db.WithContext(ctx). Model(&entity.StockAllocation{}). Distinct("stockable_id"). - Where("stockable_type = ? AND stockable_id IN ? AND usable_type = ? AND status IN ?", + Where("stockable_type = ? AND stockable_id IN ? AND usable_type = ? AND status IN ? AND allocation_purpose = ?", fifo.StockableKeyPurchaseItems.String(), itemIDs, fifo.UsableKeyProjectChickin.String(), []string{entity.StockAllocationStatusActive, entity.StockAllocationStatusPending}, + entity.StockAllocationPurposeConsume, ). Pluck("stockable_id", &allocationLockedIDs).Error; err != nil { return nil, err diff --git a/internal/modules/repports/repositories/hpp_per_kandang.repository.go b/internal/modules/repports/repositories/hpp_per_kandang.repository.go index e13d3f17..7655fcdb 100644 --- a/internal/modules/repports/repositories/hpp_per_kandang.repository.go +++ b/internal/modules/repports/repositories/hpp_per_kandang.repository.go @@ -147,15 +147,16 @@ func (r *hppPerKandangRepository) GetFeedOvkDocCostByPeriod(ctx context.Context, Table("project_chickins AS pc"). Select(` pfk.id AS project_flock_kandang_id, - COALESCE(SUM(pc.usage_qty * COALESCE(pi.price, 0)), 0) AS doc_cost, - COALESCE(SUM(pc.usage_qty), 0) AS doc_qty, + COALESCE(SUM(sa.qty * COALESCE(pi.price, 0)), 0) AS doc_cost, + COALESCE(SUM(sa.qty), 0) AS doc_qty, s.id AS supplier_id, s.name AS supplier_name, s.alias AS supplier_alias`). Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = pc.project_flock_kandang_id"). Joins("JOIN kandangs AS k ON k.id = pfk.kandang_id"). Joins("JOIN locations AS loc ON loc.id = k.location_id"). - Joins("LEFT JOIN purchase_items AS pi ON pi.product_warehouse_id = pc.product_warehouse_id"). + Joins("LEFT JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = pc.id AND sa.stockable_type = ? AND sa.status = ? AND sa.allocation_purpose = ?", fifo.UsableKeyProjectChickin.String(), fifo.StockableKeyPurchaseItems.String(), entity.StockAllocationStatusActive, entity.StockAllocationPurposeTraceChickin). + Joins("LEFT JOIN purchase_items AS pi ON pi.id = sa.stockable_id"). Joins("LEFT JOIN purchases AS pur ON pur.id = pi.purchase_id"). Joins("LEFT JOIN suppliers AS s ON s.id = pur.supplier_id"). Where("pc.project_flock_kandang_id IN ?", projectFlockKandangIDs). @@ -221,13 +222,14 @@ func (r *hppPerKandangRepository) GetFeedOvkDocCostByPeriod(ctx context.Context, Table("recordings AS r"). Select(` r.project_flock_kandangs_id AS project_flock_kandang_id, + COALESCE(SUM(sa.qty * COALESCE(pi.price, 0)), 0) AS feed_cost, s.id AS supplier_id, s.name AS supplier_name, s.alias AS supplier_alias`). Joins("JOIN recording_stocks AS rs ON rs.recording_id = r.id"). Joins("JOIN product_warehouses AS pw ON pw.id = rs.product_warehouse_id"). Joins("JOIN flags AS f ON f.flagable_id = pw.product_id AND f.flagable_type = ?", entity.FlagableTypeProduct). - Joins("JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = rs.id AND sa.stockable_type = ?", fifo.UsableKeyRecordingStock.String(), fifo.StockableKeyPurchaseItems.String()). + Joins("JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = rs.id AND sa.stockable_type = ? AND sa.status = ? AND sa.allocation_purpose = ?", fifo.UsableKeyRecordingStock.String(), fifo.StockableKeyPurchaseItems.String(), entity.StockAllocationStatusActive, entity.StockAllocationPurposeConsume). Joins("JOIN purchase_items AS pi ON pi.id = sa.stockable_id"). Joins("LEFT JOIN purchases AS pur ON pur.id = pi.purchase_id"). Joins("LEFT JOIN suppliers AS s ON s.id = pur.supplier_id").