Compare commits

...

36 Commits

Author SHA1 Message Date
Adnan Zahir c4c414aa94 Merge branch 'feat/BE/implement-new-trf' into 'dev/fifo-v2'
Fix transfer to laying delete and fix chikin delete with response recording

See merge request mbugroup/lti-api!366
2026-03-17 11:04:04 +07:00
ragilap c9dee7d1c4 add paired adjustment triger depletion adjustment 2026-03-17 11:02:37 +07:00
ragilap 131949874a changes name response transfer 2026-03-16 11:08:37 +07:00
ragilap d0f3392738 changes name response transfer 2026-03-16 10:41:43 +07:00
ragilap b2e70fa6eb add restrict create/edit/delete depletion 2026-03-14 15:39:09 +07:00
ragilap 5ba10113c3 add restrict create/edit/delete depletion 2026-03-14 15:38:47 +07:00
ragilap 29956528e5 fixing filter pw for transfer, add transfer delete 2026-03-13 11:22:10 +07:00
ragilap 9dcccabc6a fixing filter product warehouse transfer, cannot take from population 2026-03-11 15:10:49 +07:00
ragilap 333cb9e136 Fix logic recording transition 2026-03-10 17:02:45 +07:00
ragilap 3a8cc47fa0 Fix transfer to laying delete and fix chikin delete with response recording 2026-03-09 13:10:06 +07:00
Adnan Zahir c5a27ef3a6 Merge branch 'Feat/BE/implement-new-trf' into 'dev/fifo-v2'
fix: reimplement transfer to laying logics separating effective financial date...

See merge request mbugroup/lti-api!361
2026-03-08 23:32:31 +07:00
ragilap 45cc057dd4 Fix adjusment stock chickin, transfer to laying and chickin 2026-03-08 23:31:04 +07:00
Hafizh A. Y. b8fa79a2ab Merge branch 'fix/migration-fifo-v2' into 'dev/fifo-v2'
fix: migration fifo v2

See merge request mbugroup/lti-api!360
2026-03-08 12:02:11 +00:00
Hafizh A. Y b1c829bbf8 fix: migration fifo v2 2026-03-08 19:01:09 +07:00
ragilap fca96df3d9 Merge branch 'dev/fifo-v2' of https://gitlab.com/mbugroup/lti-api into dev/fifo-v2 2026-03-06 11:46:31 +07:00
Hafizh A. Y. 073b098843 Merge branch 'fix/chickin-master-product' into 'dev/fifo-v2'
fix: master product and chickin

See merge request mbugroup/lti-api!358
2026-03-06 03:26:18 +00:00
Hafizh A. Y 650f8e0fdb fix: master product and chickin 2026-03-06 10:24:43 +07:00
Adnan Zahir 7c6a401ac2 Merge branch 'fix/chickin-master-product' into 'dev/fifo-v2'
feat: refactor module adjusment stock, adjust constant, adjust table migration...

See merge request mbugroup/lti-api!357
2026-03-05 17:33:26 +07:00
Hafizh A. Y 345fe32433 fix: flag master data product and fix chickin approve 2026-03-05 17:18:19 +07:00
Adnan Zahir 7f8013c5ed fix: reimplement transfer to laying logics separating effective financial date and physical transfer date 2026-03-05 12:53:00 +07:00
Adnan Zahir 1b6041073e Merge branch 'feat/BE/restriction_growing_trl' into 'dev/fifo-v2'
Fix config chickin

See merge request mbugroup/lti-api!346
2026-03-04 14:54:27 +07:00
ragilap 1724a5f846 Fix config chickin 2026-03-04 14:39:50 +07:00
Adnan Zahir f082c5c122 Merge branch 'feat/BE/restriction_growing_trl' into 'dev/fifo-v2'
[FEAT/BE] wiring recording,transfer_stock,transfer_laying,marketing for...

See merge request mbugroup/lti-api!345
2026-03-04 14:30:40 +07:00
ragilap d334f46829 [FEAT/BE] wiring recording,transfer_stock,transfer_laying,marketing for consumer chick in project flock population 2026-03-04 12:41:26 +07:00
Hafizh A. Y 80135466df fix: all implemented fifo v2 2026-03-03 16:15:35 +07:00
Hafizh A. Y 9d5f733172 fix: first push need support testing, and implemented fifo v2 to all modules 2026-03-03 16:10:12 +07:00
Adnan Zahir 4bb750fc98 dev: initiate adjustment recording and trf to laying 2026-03-03 15:47:29 +07:00
Hafizh A. Y. d335597bed Merge branch 'fix/implement-fifo-v2' into 'dev/fifo-v2'
[FIX/BE] Implementation Fifo V2 to all module

See merge request mbugroup/lti-api!343
2026-03-03 04:09:27 +00:00
Hafizh A. Y f6e25be76b fix: chickin include stock allocation, fix calculation hpp 2026-03-03 10:36:48 +07:00
Hafizh A. Y d5a1751868 fix: all implemented fifo v2 2026-03-02 12:44:20 +07:00
Hafizh A. Y dd61b66af0 fix: adjusment module depletion, chickin, recording refactor 2026-02-28 21:35:07 +07:00
Hafizh A. Y 944604adad fix: first push need support testing, and implemented fifo v2 to all modules 2026-02-27 19:09:01 +07:00
Hafizh A. Y. 3bc0685b46 Merge branch 'revert-915302c4' into 'dev/fifo-v2'
Revert "Merge branch 'fix/implement-fifo-v2' into 'dev/fifo-v2'"

See merge request mbugroup/lti-api!342
2026-02-27 09:37:50 +00:00
Hafizh A. Y. f6c88b773d Revert "Merge branch 'fix/implement-fifo-v2' into 'dev/fifo-v2'"
This reverts merge request !340
2026-02-27 09:37:03 +00:00
Hafizh A. Y. 915302c445 Merge branch 'fix/implement-fifo-v2' into 'dev/fifo-v2'
feat: refactor module adjusment stock, adjust constant, adjust table migration and create command reflow and delete module adjusment stock

See merge request mbugroup/lti-api!340
2026-02-27 08:30:53 +00:00
Hafizh A. Y e7e065c320 fix: first push implementation fifo v2 to all stockable and useable 2026-02-27 15:21:55 +07:00
115 changed files with 11429 additions and 3082 deletions
+27 -62
View File
@@ -11,12 +11,10 @@ import (
"strings" "strings"
"time" "time"
commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository"
commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service"
"gitlab.com/mbugroup/lti-api.git/internal/config" "gitlab.com/mbugroup/lti-api.git/internal/config"
"gitlab.com/mbugroup/lti-api.git/internal/database" "gitlab.com/mbugroup/lti-api.git/internal/database"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities" entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories"
"gitlab.com/mbugroup/lti-api.git/internal/utils/fifo" "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo"
"gorm.io/gorm" "gorm.io/gorm"
) )
@@ -61,13 +59,7 @@ func main() {
ctx := context.Background() ctx := context.Background()
db := database.Connect(config.DBHost, config.DBName) db := database.Connect(config.DBHost, config.DBName)
productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db)
stockAllocRepo := commonRepo.NewStockAllocationRepository(db)
fifoSvc := commonSvc.NewFifoService(db, stockAllocRepo, productWarehouseRepo, nil)
fifoStockV2Svc := commonSvc.NewFifoStockV2Service(db, nil) fifoStockV2Svc := commonSvc.NewFifoStockV2Service(db, nil)
if err := registerAdjustmentFIFO(fifoSvc); err != nil {
log.Fatalf("failed to register adjustment fifo config: %v", err)
}
adjustments, err := loadAdjustments(ctx, db, ids) adjustments, err := loadAdjustments(ctx, db, ids)
if err != nil { if err != nil {
@@ -134,14 +126,9 @@ func main() {
reflowReq := commonSvc.FifoStockV2ReflowRequest{ reflowReq := commonSvc.FifoStockV2ReflowRequest{
FlagGroupCode: route.FlagGroupCode, FlagGroupCode: route.FlagGroupCode,
ProductWarehouseID: adj.ProductWarehouseID, ProductWarehouseID: adj.ProductWarehouseID,
Usable: commonSvc.FifoStockV2Ref{ AsOf: &adj.CreatedAt,
ID: adj.ID, IdempotencyKey: fmt.Sprintf("delete-adjustment-usable-%d-%d", adj.ID, time.Now().UnixNano()),
LegacyTypeKey: fifo.UsableKeyAdjustmentOut.String(), Tx: tx,
FunctionCode: route.FunctionCode,
},
DesiredQty: 0,
IdempotencyKey: fmt.Sprintf("delete-adjustment-usable-%d-%d", adj.ID, time.Now().UnixNano()),
Tx: tx,
} }
if _, err := fifoStockV2Svc.Reflow(ctx, reflowReq); err != nil { if _, err := fifoStockV2Svc.Reflow(ctx, reflowReq); err != nil {
return fmt.Errorf("reflow usable to zero: %w", err) return fmt.Errorf("reflow usable to zero: %w", err)
@@ -190,7 +177,7 @@ func main() {
} }
fmt.Printf( fmt.Printf(
"PLAN adj=%d lane=STOCKABLE function=%s total=%.3f remove_qty=%.3f action=reverse_stock+delete\n", "PLAN adj=%d lane=STOCKABLE function=%s total=%.3f remove_qty=%.3f action=reflow_to_zero+delete\n",
adj.ID, adj.ID,
route.FunctionCode, route.FunctionCode,
adj.TotalQty, adj.TotalQty,
@@ -203,16 +190,25 @@ func main() {
} }
err = db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { err = db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if removeQty > 0 { if err := tx.WithContext(ctx).
if err := fifoSvc.AdjustStockableQuantity(ctx, commonSvc.StockAdjustRequest{ Table("adjustment_stocks").
StockableKey: fifo.StockableKeyAdjustmentIn, Where("id = ?", adj.ID).
StockableID: adj.ID, Updates(map[string]any{
ProductWarehouseID: adj.ProductWarehouseID, "total_qty": 0,
Quantity: -removeQty, "total_used": 0,
Tx: tx, }).Error; err != nil {
}); err != nil { return fmt.Errorf("set stockable qty to zero: %w", err)
return fmt.Errorf("reverse stockable quantity: %w", err) }
}
reflowReq := commonSvc.FifoStockV2ReflowRequest{
FlagGroupCode: route.FlagGroupCode,
ProductWarehouseID: adj.ProductWarehouseID,
AsOf: &adj.CreatedAt,
IdempotencyKey: fmt.Sprintf("delete-adjustment-stockable-%d-%d", adj.ID, time.Now().UnixNano()),
Tx: tx,
}
if _, err := fifoStockV2Svc.Reflow(ctx, reflowReq); err != nil {
return fmt.Errorf("reflow stockable to zero: %w", err)
} }
if err := hardDeleteStockableAllocations(ctx, tx, fifo.StockableKeyAdjustmentIn.String(), adj.ID); err != nil { if err := hardDeleteStockableAllocations(ctx, tx, fifo.StockableKeyAdjustmentIn.String(), adj.ID); err != nil {
@@ -248,39 +244,6 @@ func main() {
} }
} }
func registerAdjustmentFIFO(fifoSvc commonSvc.FifoService) error {
if err := fifoSvc.RegisterStockable(fifo.StockableConfig{
Key: fifo.StockableKeyAdjustmentIn,
Table: "adjustment_stocks",
Columns: fifo.StockableColumns{
ID: "id",
ProductWarehouseID: "product_warehouse_id",
TotalQuantity: "total_qty",
TotalUsedQuantity: "total_used",
CreatedAt: "created_at",
},
OrderBy: []string{"created_at ASC", "id ASC"},
}); err != nil && !strings.Contains(strings.ToLower(err.Error()), "already registered") {
return err
}
if err := fifoSvc.RegisterUsable(fifo.UsableConfig{
Key: fifo.UsableKeyAdjustmentOut,
Table: "adjustment_stocks",
Columns: fifo.UsableColumns{
ID: "id",
ProductWarehouseID: "product_warehouse_id",
UsageQuantity: "usage_qty",
PendingQuantity: "pending_qty",
CreatedAt: "created_at",
},
OrderBy: []string{"created_at ASC", "id ASC"},
}); err != nil && !strings.Contains(strings.ToLower(err.Error()), "already registered") {
return err
}
return nil
}
func modeLabel(apply bool) string { func modeLabel(apply bool) string {
if apply { if apply {
return "APPLY" return "APPLY"
@@ -403,6 +366,7 @@ func countActiveUsableAllocations(ctx context.Context, db *gorm.DB, usableType s
Table("stock_allocations"). Table("stock_allocations").
Where("usable_type = ? AND usable_id = ?", usableType, usableID). Where("usable_type = ? AND usable_id = ?", usableType, usableID).
Where("status = ?", entity.StockAllocationStatusActive). Where("status = ?", entity.StockAllocationStatusActive).
Where("allocation_purpose = ?", entity.StockAllocationPurposeConsume).
Count(&count).Error Count(&count).Error
return count, err return count, err
} }
@@ -413,19 +377,20 @@ func countActiveStockableAllocations(ctx context.Context, db *gorm.DB, stockable
Table("stock_allocations"). Table("stock_allocations").
Where("stockable_type = ? AND stockable_id = ?", stockableType, stockableID). Where("stockable_type = ? AND stockable_id = ?", stockableType, stockableID).
Where("status = ?", entity.StockAllocationStatusActive). Where("status = ?", entity.StockAllocationStatusActive).
Where("allocation_purpose = ?", entity.StockAllocationPurposeConsume).
Count(&count).Error Count(&count).Error
return count, err return count, err
} }
func hardDeleteUsableAllocations(ctx context.Context, tx *gorm.DB, usableType string, usableID uint) error { func hardDeleteUsableAllocations(ctx context.Context, tx *gorm.DB, usableType string, usableID uint) error {
return tx.WithContext(ctx). 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 Error
} }
func hardDeleteStockableAllocations(ctx context.Context, tx *gorm.DB, stockableType string, stockableID uint) error { func hardDeleteStockableAllocations(ctx context.Context, tx *gorm.DB, stockableType string, stockableID uint) error {
return tx.WithContext(ctx). 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 Error
} }
+3 -13
View File
@@ -121,12 +121,7 @@ func main() {
continue continue
} }
usableType := fifo.UsableKeyAdjustmentOut.String() activeAllocationCount, err := countActiveAllocations(ctx, db, fifo.UsableKeyAdjustmentOut.String(), adj.ID)
if route.SourceTable == "adjustment_stocks" && strings.TrimSpace(route.LegacyTypeKey) != "" {
usableType = strings.TrimSpace(route.LegacyTypeKey)
}
activeAllocationCount, err := countActiveAllocations(ctx, db, usableType, adj.ID)
if err != nil { if err != nil {
fmt.Printf("FAIL adj=%d error=count allocations: %v\n", adj.ID, err) fmt.Printf("FAIL adj=%d error=count allocations: %v\n", adj.ID, err)
failed++ failed++
@@ -142,13 +137,7 @@ func main() {
reflowReq := commonSvc.FifoStockV2ReflowRequest{ reflowReq := commonSvc.FifoStockV2ReflowRequest{
FlagGroupCode: route.FlagGroupCode, FlagGroupCode: route.FlagGroupCode,
ProductWarehouseID: adj.ProductWarehouseID, ProductWarehouseID: adj.ProductWarehouseID,
Usable: commonSvc.FifoStockV2Ref{ IdempotencyKey: fmt.Sprintf("manual-adjustment-reflow-%d-%d", adj.ID, time.Now().UnixNano()),
ID: adj.ID,
LegacyTypeKey: usableType,
FunctionCode: route.FunctionCode,
},
DesiredQty: desiredQty,
IdempotencyKey: fmt.Sprintf("manual-adjustment-reflow-%d-%d", adj.ID, time.Now().UnixNano()),
} }
if asOfCreatedAt { if asOfCreatedAt {
asOf := adj.CreatedAt asOf := adj.CreatedAt
@@ -335,6 +324,7 @@ func countActiveAllocations(ctx context.Context, db *gorm.DB, usableType string,
Table("stock_allocations"). Table("stock_allocations").
Where("usable_type = ? AND usable_id = ?", usableType, usableID). Where("usable_type = ? AND usable_id = ?", usableType, usableID).
Where("status = ?", entity.StockAllocationStatusActive). Where("status = ?", entity.StockAllocationStatusActive).
Where("allocation_purpose = ?", entity.StockAllocationPurposeConsume).
Count(&count).Error Count(&count).Error
if err != nil { if err != nil {
return 0, err return 0, err
+648
View File
@@ -0,0 +1,648 @@
package main
import (
"context"
"flag"
"fmt"
"log"
"math"
"os"
"sort"
"strings"
"time"
commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service"
"gitlab.com/mbugroup/lti-api.git/internal/config"
"gitlab.com/mbugroup/lti-api.git/internal/database"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
"gitlab.com/mbugroup/lti-api.git/internal/utils/fifo"
"gorm.io/gorm"
)
type productWarehouseScopeRow struct {
ProductWarehouseID uint `gorm:"column:product_warehouse_id"`
ProductID uint `gorm:"column:product_id"`
WarehouseID uint `gorm:"column:warehouse_id"`
ProjectFlockKandangID *uint `gorm:"column:project_flock_kandang_id"`
}
type reflowTarget struct {
ProductWarehouseID uint
ProductID uint
WarehouseID uint
ProjectFlockKandangID *uint
FlagGroupCode string
}
func main() {
var (
projectFlockKandangID uint
apply bool
asOfRaw string
includeShared bool
)
flag.UintVar(&projectFlockKandangID, "project-flock-kandang-id", 0, "Project flock kandang ID (required)")
flag.BoolVar(&apply, "apply", false, "Apply reflow. If false, run as dry-run")
flag.StringVar(&asOfRaw, "as-of", "", "Optional AsOf boundary. Format: RFC3339 or YYYY-MM-DD")
flag.BoolVar(&includeShared, "include-shared", true, "Include product warehouses referenced by transactions in this PFK scope (including shared/non-bound product warehouses)")
flag.Parse()
if projectFlockKandangID == 0 {
log.Fatal("--project-flock-kandang-id is required")
}
asOf, err := parseAsOf(asOfRaw)
if err != nil {
log.Fatalf("invalid --as-of: %v", err)
}
ctx := context.Background()
db := database.Connect(config.DBHost, config.DBName)
fifoStockV2Svc := commonSvc.NewFifoStockV2Service(db, nil)
exists, err := projectFlockKandangExists(ctx, db, projectFlockKandangID)
if err != nil {
log.Fatalf("failed to check project flock kandang: %v", err)
}
if !exists {
log.Fatalf("project_flock_kandang_id %d not found", projectFlockKandangID)
}
scopedPWs, err := loadScopedProductWarehouses(ctx, db, projectFlockKandangID, includeShared)
if err != nil {
log.Fatalf("failed to load scoped product warehouses: %v", err)
}
if len(scopedPWs) == 0 {
fmt.Printf("Mode: %s\n", modeLabel(apply))
fmt.Printf("Scope: project_flock_kandang_id=%d\n", projectFlockKandangID)
fmt.Println("No product warehouse found in scope")
return
}
targets := make([]reflowTarget, 0, len(scopedPWs))
skippedPW := 0
failedResolve := 0
for _, pw := range scopedPWs {
flagGroups, err := resolveFlagGroupsByProductWarehouse(ctx, db, pw.ProductWarehouseID)
if err != nil {
fmt.Printf("FAIL pw=%d error=resolve flag groups: %v\n", pw.ProductWarehouseID, err)
failedResolve++
continue
}
if len(flagGroups) == 0 {
fmt.Printf("SKIP pw=%d reason=no active fifo v2 route by product flag\n", pw.ProductWarehouseID)
skippedPW++
continue
}
for _, group := range flagGroups {
targets = append(targets, reflowTarget{
ProductWarehouseID: pw.ProductWarehouseID,
ProductID: pw.ProductID,
WarehouseID: pw.WarehouseID,
ProjectFlockKandangID: pw.ProjectFlockKandangID,
FlagGroupCode: group,
})
}
}
sort.Slice(targets, func(i, j int) bool {
if targets[i].ProductWarehouseID == targets[j].ProductWarehouseID {
return targets[i].FlagGroupCode < targets[j].FlagGroupCode
}
return targets[i].ProductWarehouseID < targets[j].ProductWarehouseID
})
fmt.Printf("Mode: %s\n", modeLabel(apply))
fmt.Printf("Scope: project_flock_kandang_id=%d include_shared=%t\n", projectFlockKandangID, includeShared)
if asOf != nil {
fmt.Printf("AsOf: %s\n", asOf.UTC().Format(time.RFC3339))
} else {
fmt.Println("AsOf: <nil> (full timeline)")
}
fmt.Printf("Product warehouses in scope: %d\n", len(scopedPWs))
fmt.Printf("Planned reflow targets: %d\n\n", len(targets))
for _, target := range targets {
fmt.Printf(
"PLAN pw=%d product=%d warehouse=%d pw_pfk=%s flag_group=%s\n",
target.ProductWarehouseID,
target.ProductID,
target.WarehouseID,
displayOptionalUint(target.ProjectFlockKandangID),
target.FlagGroupCode,
)
}
if !apply {
fmt.Println()
fmt.Printf("Summary: planned=%d skipped_pw=%d failed_resolve=%d applied=0 failed_apply=0\n", len(targets), skippedPW, failedResolve)
if failedResolve > 0 {
os.Exit(1)
}
return
}
successApply := 0
failedApply := 0
for idx, target := range targets {
req := commonSvc.FifoStockV2ReflowRequest{
FlagGroupCode: target.FlagGroupCode,
ProductWarehouseID: target.ProductWarehouseID,
AsOf: asOf,
IdempotencyKey: fmt.Sprintf(
"manual-pfk-reflow-%d-%d-%s-%d-%d",
projectFlockKandangID,
target.ProductWarehouseID,
strings.ToUpper(strings.TrimSpace(target.FlagGroupCode)),
time.Now().UnixNano(),
idx,
),
}
res, err := fifoStockV2Svc.Reflow(ctx, req)
if err != nil {
fmt.Printf("FAIL pw=%d flag_group=%s error=%v\n", target.ProductWarehouseID, target.FlagGroupCode, err)
failedApply++
continue
}
fmt.Printf(
"DONE pw=%d flag_group=%s rollback=%.3f allocate=%.3f pending=%.3f processed_usable=%d\n",
target.ProductWarehouseID,
target.FlagGroupCode,
res.Rollback.ReleasedQty,
res.Allocate.AllocatedQty,
res.Allocate.PendingQty,
res.ProcessedUsables,
)
successApply++
}
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++
} else {
orphanPopulationRows = rowsOrphan
syncedPopulationQtyRows = rowsQty
syncedPopulationUsedRows = rowsUsed
fmt.Printf(
"SYNC project_flock_populations orphan_marked=%d qty_synced=%d used_synced=%d\n",
orphanPopulationRows,
syncedPopulationQtyRows,
syncedPopulationUsedRows,
)
}
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 trace_released=%d trace_inserted=%d\n",
len(targets),
skippedPW,
failedResolve,
successApply,
failedApply,
orphanPopulationRows,
syncedPopulationQtyRows,
syncedPopulationUsedRows,
traceReleasedRows,
traceInsertedRows,
)
if failedResolve > 0 || failedApply > 0 {
os.Exit(1)
}
}
func parseAsOf(raw string) (*time.Time, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil, nil
}
layouts := []string{
time.RFC3339Nano,
time.RFC3339,
"2006-01-02 15:04:05",
"2006-01-02",
}
for _, layout := range layouts {
parsed, err := time.Parse(layout, raw)
if err != nil {
continue
}
if layout == "2006-01-02" {
endOfDay := time.Date(parsed.Year(), parsed.Month(), parsed.Day(), 23, 59, 59, int(time.Second-time.Nanosecond), time.UTC)
return &endOfDay, nil
}
asOf := parsed.UTC()
return &asOf, nil
}
return nil, fmt.Errorf("unsupported format %q", raw)
}
func modeLabel(apply bool) string {
if apply {
return "APPLY"
}
return "DRY-RUN"
}
func displayOptionalUint(v *uint) string {
if v == nil {
return "NULL"
}
return fmt.Sprintf("%d", *v)
}
func projectFlockKandangExists(ctx context.Context, db *gorm.DB, projectFlockKandangID uint) (bool, error) {
var count int64
err := db.WithContext(ctx).
Table("project_flock_kandangs").
Where("id = ?", projectFlockKandangID).
Count(&count).Error
if err != nil {
return false, err
}
return count > 0, nil
}
func loadScopedProductWarehouses(ctx context.Context, db *gorm.DB, projectFlockKandangID uint, includeShared bool) ([]productWarehouseScopeRow, error) {
if !includeShared {
var rows []productWarehouseScopeRow
err := db.WithContext(ctx).
Table("product_warehouses").
Select("id AS product_warehouse_id, product_id, warehouse_id, project_flock_kandang_id").
Where("project_flock_kandang_id = ?", projectFlockKandangID).
Order("id ASC").
Scan(&rows).Error
if err != nil {
return nil, err
}
return rows, nil
}
query := `
WITH scoped_pw AS (
SELECT pw.id AS product_warehouse_id
FROM product_warehouses pw
WHERE pw.project_flock_kandang_id = ?
UNION
SELECT pc.product_warehouse_id
FROM project_chickins pc
WHERE pc.project_flock_kandang_id = ?
AND pc.deleted_at IS NULL
UNION
SELECT rs.product_warehouse_id
FROM recordings r
JOIN recording_stocks rs ON rs.recording_id = r.id
WHERE r.project_flock_kandangs_id = ?
AND r.deleted_at IS NULL
UNION
SELECT rd.product_warehouse_id
FROM recordings r
JOIN recording_depletions rd ON rd.recording_id = r.id
WHERE r.project_flock_kandangs_id = ?
AND r.deleted_at IS NULL
UNION
SELECT rd.source_product_warehouse_id
FROM recordings r
JOIN recording_depletions rd ON rd.recording_id = r.id
WHERE r.project_flock_kandangs_id = ?
AND r.deleted_at IS NULL
AND rd.source_product_warehouse_id IS NOT NULL
UNION
SELECT re.product_warehouse_id
FROM recordings r
JOIN recording_eggs re ON re.recording_id = r.id
WHERE r.project_flock_kandangs_id = ?
AND r.deleted_at IS NULL
UNION
SELECT lts.product_warehouse_id
FROM laying_transfer_sources lts
WHERE lts.source_project_flock_kandang_id = ?
AND lts.deleted_at IS NULL
AND lts.product_warehouse_id IS NOT NULL
UNION
SELECT ltt.product_warehouse_id
FROM laying_transfer_targets ltt
WHERE ltt.target_project_flock_kandang_id = ?
AND ltt.deleted_at IS NULL
AND ltt.product_warehouse_id IS NOT NULL
UNION
SELECT pi.product_warehouse_id
FROM purchase_items pi
WHERE pi.project_flock_kandang_id = ?
AND pi.product_warehouse_id IS NOT NULL
)
SELECT DISTINCT
pw.id AS product_warehouse_id,
pw.product_id,
pw.warehouse_id,
pw.project_flock_kandang_id
FROM scoped_pw s
JOIN product_warehouses pw ON pw.id = s.product_warehouse_id
ORDER BY pw.id ASC
`
var rows []productWarehouseScopeRow
err := db.WithContext(ctx).
Raw(
query,
projectFlockKandangID,
projectFlockKandangID,
projectFlockKandangID,
projectFlockKandangID,
projectFlockKandangID,
projectFlockKandangID,
projectFlockKandangID,
projectFlockKandangID,
projectFlockKandangID,
).
Scan(&rows).Error
if err != nil {
return nil, err
}
return rows, nil
}
func resolveFlagGroupsByProductWarehouse(ctx context.Context, db *gorm.DB, productWarehouseID uint) ([]string, error) {
var groups []string
err := db.WithContext(ctx).
Table("fifo_stock_v2_route_rules rr").
Select("DISTINCT 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(`
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("rr.flag_group_code ASC").
Scan(&groups).Error
if err != nil {
return nil, err
}
return groups, nil
}
func resyncProjectFlockPopulation(ctx context.Context, db *gorm.DB, projectFlockKandangID uint) (int64, int64, int64, error) {
if projectFlockKandangID == 0 {
return 0, 0, 0, nil
}
orphanResult := db.WithContext(ctx).Exec(`
UPDATE project_flock_populations pfp
SET deleted_at = NOW(),
updated_at = NOW()
FROM project_chickins pc
WHERE pfp.project_chickin_id = pc.id
AND pc.project_flock_kandang_id = ?
AND pc.deleted_at IS NOT NULL
AND pfp.deleted_at IS NULL
`, projectFlockKandangID)
if orphanResult.Error != nil {
return 0, 0, 0, orphanResult.Error
}
qtyResult := db.WithContext(ctx).Exec(`
UPDATE project_flock_populations p
SET total_qty = GREATEST(COALESCE(pc.usage_qty, 0), 0),
updated_at = NOW()
FROM project_chickins pc
WHERE p.project_chickin_id = pc.id
AND pc.project_flock_kandang_id = ?
AND pc.deleted_at IS NULL
AND p.deleted_at IS NULL
`, projectFlockKandangID)
if qtyResult.Error != nil {
return 0, 0, 0, qtyResult.Error
}
usedResult := db.WithContext(ctx).Exec(`
WITH scoped AS (
SELECT pfp.id, pfp.total_qty
FROM project_flock_populations pfp
JOIN project_chickins pc ON pc.id = pfp.project_chickin_id
WHERE pc.project_flock_kandang_id = ?
AND pc.deleted_at IS NULL
AND pfp.deleted_at IS NULL
),
alloc AS (
SELECT sa.stockable_id, SUM(sa.qty) AS used_qty
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
SET total_used_qty = LEAST(COALESCE(a.used_qty, 0), GREATEST(s.total_qty, 0)),
updated_at = NOW()
FROM scoped s
LEFT JOIN alloc a ON a.stockable_id = s.id
WHERE p.id = s.id
`, projectFlockKandangID)
if usedResult.Error != nil {
return 0, 0, 0, usedResult.Error
}
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
}
+122
View File
@@ -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
}
@@ -51,8 +51,8 @@ func (r *HppRepositoryImpl) GetDocCost(ctx context.Context, projectFlockKandangI
var total float64 var total float64
err := r.db.WithContext(ctx). err := r.db.WithContext(ctx).
Table("project_chickins AS pc"). Table("project_chickins AS pc").
Select("COALESCE(SUM(pc.usage_qty * COALESCE(pi.price, 0)), 0)"). 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 = ?", fifo.UsableKeyProjectChickin.String(), fifo.StockableKeyPurchaseItems.String()). 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"). Joins("JOIN purchase_items AS pi ON pi.id = sa.stockable_id").
Where("pc.project_flock_kandang_id IN (?)", projectFlockKandangIDs). Where("pc.project_flock_kandang_id IN (?)", projectFlockKandangIDs).
Scan(&total).Error Scan(&total).Error
@@ -103,11 +103,11 @@ func (r *HppRepositoryImpl) GetFeedUsageCost(ctx context.Context, projectFlockKa
var total float64 var total float64
err := r.db.WithContext(ctx). err := r.db.WithContext(ctx).
Table("recordings AS r"). 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 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 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 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("JOIN purchase_items AS pi ON pi.id = sa.stockable_id").
Where("r.project_flock_kandangs_id IN (?)", projectFlockKandangIDs). Where("r.project_flock_kandangs_id IN (?)", projectFlockKandangIDs).
Where("r.record_datetime <= ?", *date). Where("r.record_datetime <= ?", *date).
@@ -136,10 +136,10 @@ func (r *HppRepositoryImpl) GetOvkUsageCost(ctx context.Context, projectFlockKan
var total float64 var total float64
err := r.db.WithContext(ctx). err := r.db.WithContext(ctx).
Table("recordings AS r"). 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 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 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"). Joins("JOIN purchase_items AS pi ON pi.id = sa.stockable_id").
Where("r.project_flock_kandangs_id IN (?)", projectFlockKandangIDs). Where("r.project_flock_kandangs_id IN (?)", projectFlockKandangIDs).
Where("r.record_datetime <= ?", *date). Where("r.record_datetime <= ?", *date).
@@ -175,15 +175,15 @@ func (r *HppRepositoryImpl) GetPulletCost(ctx context.Context, projectFlockKanda
err := r.db.WithContext(ctx). err := r.db.WithContext(ctx).
Table("project_chickins AS pc"). Table("project_chickins AS pc").
Select(` 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(pi.price, 0)
WHEN sa.stockable_type = ? THEN COALESCE(tpi.price, 0) WHEN sa.stockable_type = ? THEN COALESCE(tpi.price, 0)
ELSE 0 ELSE 0
END), 0)`, END), 0)`,
stockablePurchase, stockableTransferIn). 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 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"). Joins("LEFT JOIN purchase_items AS tpi ON tpi.id = tsa.stockable_id").
Where("pc.project_flock_kandang_id = ?", projectFlockKandangId). Where("pc.project_flock_kandang_id = ?", projectFlockKandangId).
Scan(&total).Error Scan(&total).Error
@@ -245,9 +245,11 @@ func (r *HppRepositoryImpl) GetEggTerjualPiecesAndWeightKgByProjectFlockKandangI
`). `).
Joins("JOIN recording_eggs re ON re.recording_id = r.id"). Joins("JOIN recording_eggs re ON re.recording_id = r.id").
Joins( 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.StockableKeyRecordingEgg.String(),
fifo.UsableKeyMarketingDelivery.String(), fifo.UsableKeyMarketingDelivery.String(),
entity.StockAllocationStatusActive,
entity.StockAllocationPurposeConsume,
). ).
Joins("JOIN marketing_delivery_products mdp ON mdp.id = sa.usable_id"). Joins("JOIN marketing_delivery_products mdp ON mdp.id = sa.usable_id").
Where("r.project_flock_kandangs_id IN (?)", projectFlockKandangIDs). Where("r.project_flock_kandangs_id IN (?)", projectFlockKandangIDs).
@@ -297,6 +299,9 @@ func (r *HppRepositoryImpl) GetTransferSourceSummary(ctx context.Context, projec
Table("laying_transfer_targets AS ltt"). Table("laying_transfer_targets AS ltt").
Select("lt.from_project_flock_id AS project_flock_id, COALESCE(SUM(ltt.total_qty), 0) AS total_qty"). Select("lt.from_project_flock_id AS project_flock_id, COALESCE(SUM(ltt.total_qty), 0) AS total_qty").
Joins("JOIN laying_transfers AS lt ON lt.id = ltt.laying_transfer_id"). Joins("JOIN laying_transfers AS lt ON lt.id = ltt.laying_transfer_id").
Where("lt.deleted_at IS NULL").
Where("ltt.deleted_at IS NULL").
Where("lt.executed_at IS NOT NULL").
Where("ltt.target_project_flock_kandang_id = ?", projectFlockKandangId). Where("ltt.target_project_flock_kandang_id = ?", projectFlockKandangId).
Group("lt.from_project_flock_id"). Group("lt.from_project_flock_id").
Scan(&summary).Error Scan(&summary).Error
@@ -33,7 +33,7 @@ func (r *StockAllocationRepositoryImpl) FindActiveByUsable(
var allocations []entity.StockAllocation var allocations []entity.StockAllocation
q := r.DB().WithContext(ctx). 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 { if modifier != nil {
q = modifier(q) q = modifier(q)
@@ -70,7 +70,7 @@ func (r *StockAllocationRepositoryImpl) ReleaseByUsable(
q := baseDB.WithContext(ctx). q := baseDB.WithContext(ctx).
Model(&entity.StockAllocation{}). 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 return q.Updates(updates).Error
} }
+16 -15
View File
@@ -528,6 +528,7 @@ func (s *fifoService) allocateFromStock(
UsableType: usableKey.String(), UsableType: usableKey.String(),
UsableId: usableID, UsableId: usableID,
Qty: portion, Qty: portion,
AllocationPurpose: entities.StockAllocationPurposeConsume,
Status: entities.StockAllocationStatusActive, Status: entities.StockAllocationStatusActive,
}) })
@@ -890,22 +891,22 @@ func (s *fifoService) fetchPendingCandidates(ctx context.Context, tx *gorm.DB, p
query = query.Order(order) query = query.Order(order)
} }
if err := query.Find(&rows).Error; err != nil { if err := query.Find(&rows).Error; err != nil {
return nil, err return nil, err
}
for _, row := range rows {
if row.Pending <= 0 {
continue
} }
candidates = append(candidates, pendingCandidate{ for _, row := range rows {
UsableKey: key, if row.Pending <= 0 {
Config: cfg, continue
UsableID: row.ID, }
Pending: row.Pending, candidates = append(candidates, pendingCandidate{
CreatedAt: time.Unix(0, row.CreatedAt), UsableKey: key,
}) Config: cfg,
} UsableID: row.ID,
} else { Pending: row.Pending,
CreatedAt: time.Unix(0, row.CreatedAt),
})
}
} else {
var rows []struct { var rows []struct {
ID uint ID uint
Pending float64 `gorm:"column:pending_qty"` Pending float64 `gorm:"column:pending_qty"`
@@ -0,0 +1,104 @@
package service
import (
"context"
"errors"
"strings"
"gorm.io/gorm"
)
type FifoPendingPolicyInput struct {
Lane string
FlagGroupCode string
FunctionCode string
LegacyTypeKey string
}
type FifoPendingPolicyResult struct {
AllowPending bool
RuleSource string
Found bool
}
func ResolveFifoPendingPolicy(ctx context.Context, tx *gorm.DB, input FifoPendingPolicyInput) (*FifoPendingPolicyResult, error) {
if tx == nil {
return nil, gorm.ErrInvalidDB
}
lane := strings.ToUpper(strings.TrimSpace(input.Lane))
flagGroupCode := strings.ToUpper(strings.TrimSpace(input.FlagGroupCode))
functionCode := strings.ToUpper(strings.TrimSpace(input.FunctionCode))
legacyTypeKey := strings.ToUpper(strings.TrimSpace(input.LegacyTypeKey))
if lane == "" {
return &FifoPendingPolicyResult{
AllowPending: false,
RuleSource: "SAFE_DEFAULT_BLOCK",
Found: false,
}, nil
}
type overconsumeRuleRow struct {
Allow bool `gorm:"column:allow_overconsume"`
}
var overconsume overconsumeRuleRow
overconsumeErr := tx.WithContext(ctx).
Table("fifo_stock_v2_overconsume_rules").
Select("allow_overconsume").
Where("is_active = TRUE").
Where("lane = ?", lane).
Where("(flag_group_code IS NULL OR flag_group_code = ?)", flagGroupCode).
Where("(function_code IS NULL OR function_code = ?)", functionCode).
Order("CASE WHEN flag_group_code IS NULL THEN 1 ELSE 0 END ASC").
Order("CASE WHEN function_code IS NULL THEN 1 ELSE 0 END ASC").
Order("priority ASC, id ASC").
Limit(1).
Take(&overconsume).Error
if overconsumeErr == nil {
return &FifoPendingPolicyResult{
AllowPending: overconsume.Allow,
RuleSource: "OVERCONSUME_RULE",
Found: true,
}, nil
}
if !errors.Is(overconsumeErr, gorm.ErrRecordNotFound) {
return nil, overconsumeErr
}
type routeRuleRow struct {
AllowPendingDefault bool `gorm:"column:allow_pending_default"`
}
var routeRule routeRuleRow
routeQuery := tx.WithContext(ctx).
Table("fifo_stock_v2_route_rules").
Select("allow_pending_default").
Where("is_active = TRUE").
Where("lane = ?", lane).
Where("flag_group_code = ?", flagGroupCode)
if legacyTypeKey != "" {
routeQuery = routeQuery.Where("legacy_type_key = ?", legacyTypeKey)
}
if functionCode != "" {
routeQuery = routeQuery.Where("function_code = ?", functionCode)
}
routeErr := routeQuery.
Order("id ASC").
Limit(1).
Take(&routeRule).Error
if routeErr == nil {
return &FifoPendingPolicyResult{
AllowPending: routeRule.AllowPendingDefault,
RuleSource: "ROUTE_RULE_DEFAULT",
Found: true,
}, nil
}
if !errors.Is(routeErr, gorm.ErrRecordNotFound) {
return nil, routeErr
}
return &FifoPendingPolicyResult{
AllowPending: false,
RuleSource: "SAFE_DEFAULT_BLOCK",
Found: false,
}, nil
}
+177 -35
View File
@@ -141,6 +141,9 @@ func (s *fifoStockV2Service) allocateInternal(ctx context.Context, tx *gorm.DB,
if remaining <= 0 { if remaining <= 0 {
break break
} }
if shouldSkipStockableForUsable(req, lot.Ref.LegacyTypeKey) {
continue
}
if lot.AvailableQuantity <= 0 { if lot.AvailableQuantity <= 0 {
continue continue
} }
@@ -157,6 +160,7 @@ func (s *fifoStockV2Service) allocateInternal(ctx context.Context, tx *gorm.DB,
"usable_id": req.Usable.ID, "usable_id": req.Usable.ID,
"qty": portion, "qty": portion,
"status": activeAllocationStatus(), "status": activeAllocationStatus(),
"allocation_purpose": defaultAllocationPurpose(),
"created_at": now, "created_at": now,
"updated_at": now, "updated_at": now,
"engine_version": "v2", "engine_version": "v2",
@@ -206,6 +210,23 @@ func (s *fifoStockV2Service) allocateInternal(ctx context.Context, tx *gorm.DB,
return result, nil return result, nil
} }
func shouldSkipStockableForUsable(req AllocateRequest, stockableType string) bool {
usableType := strings.ToUpper(strings.TrimSpace(req.Usable.LegacyTypeKey))
functionCode := strings.ToUpper(strings.TrimSpace(req.Usable.FunctionCode))
stockable := strings.ToUpper(strings.TrimSpace(stockableType))
// CHICKIN_OUT must consume physical stock sources, not population lots,
// otherwise approved chickin can consume its own just-created population.
if (usableType == "PROJECT_CHICKIN" || functionCode == "CHICKIN_OUT") && stockable == "PROJECT_FLOCK_POPULATION" {
return true
}
if (usableType == "STOCK_TRANSFER_OUT" || functionCode == "STOCK_TRANSFER_OUT") && stockable == "PROJECT_FLOCK_POPULATION" {
return true
}
return false
}
func (s *fifoStockV2Service) Rollback(ctx context.Context, req RollbackRequest) (*RollbackResult, error) { func (s *fifoStockV2Service) Rollback(ctx context.Context, req RollbackRequest) (*RollbackResult, error) {
if err := s.validateRollbackRequest(req); err != nil { if err := s.validateRollbackRequest(req); err != nil {
return nil, err return nil, err
@@ -401,12 +422,9 @@ func (s *fifoStockV2Service) rollbackInternal(
} }
func (s *fifoStockV2Service) Reflow(ctx context.Context, req ReflowRequest) (*ReflowResult, error) { func (s *fifoStockV2Service) Reflow(ctx context.Context, req ReflowRequest) (*ReflowResult, error) {
if strings.TrimSpace(req.FlagGroupCode) == "" || req.ProductWarehouseID == 0 || req.Usable.ID == 0 || strings.TrimSpace(req.Usable.LegacyTypeKey) == "" { if strings.TrimSpace(req.FlagGroupCode) == "" || req.ProductWarehouseID == 0 {
return nil, fmt.Errorf("%w: invalid reflow request", ErrInvalidRequest) return nil, fmt.Errorf("%w: invalid reflow request", ErrInvalidRequest)
} }
if req.DesiredQty < 0 {
return nil, fmt.Errorf("%w: desired qty must be >= 0", ErrInvalidRequest)
}
result := &ReflowResult{} result := &ReflowResult{}
err := s.withTransaction(ctx, req.Tx, func(tx *gorm.DB) error { err := s.withTransaction(ctx, req.Tx, func(tx *gorm.DB) error {
@@ -420,11 +438,7 @@ func (s *fifoStockV2Service) Reflow(ctx context.Context, req ReflowRequest) (*Re
hash := requestHash(map[string]any{ hash := requestHash(map[string]any{
"flag_group_code": req.FlagGroupCode, "flag_group_code": req.FlagGroupCode,
"product_warehouse_id": req.ProductWarehouseID, "product_warehouse_id": req.ProductWarehouseID,
"usable_type": req.Usable.LegacyTypeKey,
"usable_id": req.Usable.ID,
"desired_qty": req.DesiredQty,
"as_of": req.AsOf, "as_of": req.AsOf,
"allow_over_consume": req.AllowOverConsume,
}) })
logRow, reused, err := s.beginOperation( logRow, reused, err := s.beginOperation(
tx, tx,
@@ -433,8 +447,8 @@ func (s *fifoStockV2Service) Reflow(ctx context.Context, req ReflowRequest) (*Re
hash, hash,
req.ProductWarehouseID, req.ProductWarehouseID,
req.FlagGroupCode, req.FlagGroupCode,
req.Usable.LegacyTypeKey, "",
req.Usable.ID, 0,
) )
if err != nil { if err != nil {
return err return err
@@ -456,32 +470,73 @@ func (s *fifoStockV2Service) Reflow(ctx context.Context, req ReflowRequest) (*Re
}() }()
} }
rollbackRes, rollbackErr := s.rollbackInternal(ctx, tx, RollbackRequest{ usableRows, gatherErr := s.gatherAllRows(ctx, tx, GatherRequest{
FlagGroupCode: req.FlagGroupCode,
Lane: LaneUsable,
ProductWarehouseID: req.ProductWarehouseID, ProductWarehouseID: req.ProductWarehouseID,
Usable: req.Usable, Limit: s.defaultGatherLimit,
ReleaseQty: nil, })
Reason: "reflow reset", if gatherErr != nil {
}, req.FlagGroupCode) err = gatherErr
if rollbackErr != nil { return gatherErr
err = rollbackErr
return rollbackErr
} }
result.Rollback = *rollbackRes result.ProcessedUsables = len(usableRows)
if req.DesiredQty > 0 { for _, usableRow := range usableRows {
desiredQty := usableRow.Quantity + usableRow.PendingQuantity
rollbackRes, rollbackErr := s.rollbackInternal(ctx, tx, RollbackRequest{
ProductWarehouseID: req.ProductWarehouseID,
Usable: usableRow.Ref,
ReleaseQty: nil,
Reason: "reflow reset",
}, req.FlagGroupCode)
if rollbackErr != nil {
err = rollbackErr
return rollbackErr
}
result.Rollback.ReleasedQty += rollbackRes.ReleasedQty
if len(rollbackRes.Details) > 0 {
result.Rollback.Details = append(result.Rollback.Details, rollbackRes.Details...)
}
if desiredQty <= 0 {
continue
}
allocateRes, allocateErr := s.allocateInternal(ctx, tx, AllocateRequest{ allocateRes, allocateErr := s.allocateInternal(ctx, tx, AllocateRequest{
FlagGroupCode: req.FlagGroupCode, FlagGroupCode: req.FlagGroupCode,
ProductWarehouseID: req.ProductWarehouseID, ProductWarehouseID: req.ProductWarehouseID,
Usable: req.Usable, Usable: usableRow.Ref,
NeedQty: req.DesiredQty, NeedQty: desiredQty,
AllowOverConsume: req.AllowOverConsume, AsOf: nil,
AsOf: req.AsOf,
}) })
if allocateErr != nil { if allocateErr != nil {
err = allocateErr err = allocateErr
return allocateErr return allocateErr
} }
result.Allocate = *allocateRes result.Allocate.AllocatedQty += allocateRes.AllocatedQty
result.Allocate.PendingQty += allocateRes.PendingQty
if len(allocateRes.Details) > 0 {
result.Allocate.Details = append(result.Allocate.Details, allocateRes.Details...)
}
}
expectedQty, calcErr := s.calculateWarehouseAvailableForGroup(ctx, tx, req.ProductWarehouseID, req.FlagGroupCode, nil)
if calcErr != nil {
err = calcErr
return calcErr
}
actualQty, loadErr := s.loadWarehouseQty(ctx, tx, req.ProductWarehouseID)
if loadErr != nil {
err = loadErr
return loadErr
}
drift := expectedQty - actualQty
if math.Abs(drift) >= 1e-6 {
if adjustErr := s.adjustProductWarehouseQty(tx, req.ProductWarehouseID, drift); adjustErr != nil {
err = adjustErr
return adjustErr
}
} }
if finishErr := s.finishOperation(tx, logRow, result); finishErr != nil { if finishErr := s.finishOperation(tx, logRow, result); finishErr != nil {
@@ -496,6 +551,54 @@ func (s *fifoStockV2Service) Reflow(ctx context.Context, req ReflowRequest) (*Re
return result, nil return result, nil
} }
func (s *fifoStockV2Service) gatherAllRows(
ctx context.Context,
tx *gorm.DB,
req GatherRequest,
) ([]GatherRow, error) {
limit := req.Limit
if limit <= 0 {
limit = s.defaultGatherLimit
}
if limit <= 0 {
limit = 1000
}
req.Limit = limit
out := make([]GatherRow, 0, limit)
var cursorSortAt *time.Time
cursorSourceTable := ""
var cursorSourceID uint
for {
req.AfterSortAt = cursorSortAt
req.AfterSourceTable = cursorSourceTable
req.AfterSourceID = cursorSourceID
rows, err := s.gatherRows(ctx, tx, req)
if err != nil {
return nil, err
}
if len(rows) == 0 {
break
}
out = append(out, rows...)
if len(rows) < limit {
break
}
last := rows[len(rows)-1]
lastSortAt := last.SortAt
cursorSortAt = &lastSortAt
cursorSourceTable = last.SourceTable
cursorSourceID = last.SourceID
}
return out, nil
}
func (s *fifoStockV2Service) loadActiveAllocations( func (s *fifoStockV2Service) loadActiveAllocations(
tx *gorm.DB, tx *gorm.DB,
usableType string, usableType string,
@@ -504,7 +607,7 @@ func (s *fifoStockV2Service) loadActiveAllocations(
) ([]allocationRow, error) { ) ([]allocationRow, error) {
query := tx.Table("stock_allocations"). query := tx.Table("stock_allocations").
Select("id, product_warehouse_id, stockable_type, stockable_id, usable_type, usable_id, qty, status, created_at"). 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 { if productWarehouseID > 0 {
query = query.Where("product_warehouse_id = ?", productWarehouseID) query = query.Where("product_warehouse_id = ?", productWarehouseID)
} }
@@ -598,15 +701,17 @@ func (s *fifoStockV2Service) resolveRollbackFlagGroup(ctx context.Context, tx *g
FlagGroupCode string `gorm:"column:flag_group_code"` FlagGroupCode string `gorm:"column:flag_group_code"`
} }
var latest row var latest row
err := tx.WithContext(ctx). latestQuery := tx.WithContext(ctx).
Table("stock_allocations"). Table("stock_allocations").
Select("flag_group_code"). Select("flag_group_code").
Where("usable_type = ? AND usable_id = ?", req.Usable.LegacyTypeKey, req.Usable.ID). Where("usable_type = ? AND usable_id = ?", req.Usable.LegacyTypeKey, req.Usable.ID).
Where("engine_version = 'v2'"). Where("engine_version = 'v2'").
Where("flag_group_code IS NOT NULL AND flag_group_code <> ''"). Where("allocation_purpose = ?", defaultAllocationPurpose()).
Order("id DESC"). Where("flag_group_code IS NOT NULL AND flag_group_code <> ''")
Limit(1). if code := strings.TrimSpace(req.Usable.FunctionCode); code != "" {
Take(&latest).Error latestQuery = latestQuery.Where("function_code = ?", code)
}
err := latestQuery.Order("id DESC").Limit(1).Take(&latest).Error
if err == nil && strings.TrimSpace(latest.FlagGroupCode) != "" { if err == nil && strings.TrimSpace(latest.FlagGroupCode) != "" {
return latest.FlagGroupCode, nil return latest.FlagGroupCode, nil
} }
@@ -614,19 +719,56 @@ func (s *fifoStockV2Service) resolveRollbackFlagGroup(ctx context.Context, tx *g
return "", err return "", err
} }
var rules []routeRule rulesQuery := tx.WithContext(ctx).
err = tx.WithContext(ctx).
Table("fifo_stock_v2_route_rules"). Table("fifo_stock_v2_route_rules").
Where("is_active = TRUE"). Where("is_active = TRUE").
Where("lane = ?", string(LaneUsable)). Where("lane = ?", string(LaneUsable)).
Where("legacy_type_key = ?", req.Usable.LegacyTypeKey). Where("legacy_type_key = ?", req.Usable.LegacyTypeKey)
Find(&rules).Error if code := strings.TrimSpace(req.Usable.FunctionCode); code != "" {
rulesQuery = rulesQuery.Where("function_code = ?", code)
}
var rules []routeRule
err = rulesQuery.Find(&rules).Error
if err != nil { if err != nil {
return "", err return "", err
} }
if len(rules) == 0 { if len(rules) == 0 {
return "", fmt.Errorf("cannot resolve flag group for usable type %s", req.Usable.LegacyTypeKey) return "", fmt.Errorf("cannot resolve flag group for usable type %s", req.Usable.LegacyTypeKey)
} }
if len(rules) > 1 && req.ProductWarehouseID != 0 {
type candidateRow struct {
FlagGroupCode string `gorm:"column:flag_group_code"`
}
var candidates []candidateRow
byProductQuery := tx.WithContext(ctx).
Table("fifo_stock_v2_route_rules rr").
Select("DISTINCT 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 = ?", string(LaneUsable)).
Where("rr.legacy_type_key = ?", req.Usable.LegacyTypeKey).
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 = 'products'
AND fm.flag_group_code = rr.flag_group_code
)
`, req.ProductWarehouseID)
if code := strings.TrimSpace(req.Usable.FunctionCode); code != "" {
byProductQuery = byProductQuery.Where("rr.function_code = ?", code)
}
if err := byProductQuery.Order("rr.flag_group_code ASC").Scan(&candidates).Error; err != nil {
return "", err
}
if len(candidates) == 1 {
return strings.TrimSpace(candidates[0].FlagGroupCode), nil
}
}
if len(rules) > 1 { if len(rules) > 1 {
return "", fmt.Errorf("ambiguous rollback flag group for usable type %s", req.Usable.LegacyTypeKey) return "", fmt.Errorf("ambiguous rollback flag group for usable type %s", req.Usable.LegacyTypeKey)
} }
@@ -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) { 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) rules, err := s.loadRouteRules(ctx, tx, req.FlagGroupCode, req.Lane)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -151,19 +153,29 @@ func (s *fifoStockV2Service) buildGatherSubquery(rule routeRule, trait traitRule
usedExpr := "0::numeric" usedExpr := "0::numeric"
pendingExpr := "0::numeric" pendingExpr := "0::numeric"
availableExpr := baseQtyExpr availableExpr := baseQtyExpr
extraArgs := make([]any, 0, 1) extraArgs := make([]any, 0, 2)
whereExtraArgs := make([]any, 0, 1)
if req.Lane == LaneStockable { 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) usedCol, _ := mustSafeIdentifier(*rule.UsedQuantityCol)
usedExpr = fmt.Sprintf("COALESCE(src.%s,0)::numeric", usedCol) usedExpr = fmt.Sprintf("COALESCE(src.%s,0)::numeric", usedCol)
} else { } else {
// NOTE:
// usedExpr is referenced twice in the generated SELECT:
// 1) as used_quantity
// 2) inside available_quantity = base - usedExpr
// plus once in stockable WHERE clause via availableExpr > 0.
// We split the args because the WHERE placeholder order appears
// after product/flag filter placeholders in the final SQL.
usedExpr = fmt.Sprintf( 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, sourceIDCol,
activeAllocationStatus(), activeAllocationStatus(),
) )
extraArgs = append(extraArgs, 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) availableExpr = fmt.Sprintf("(%s - %s)", baseQtyExpr, usedExpr)
} else { } else {
@@ -179,6 +191,12 @@ func (s *fifoStockV2Service) buildGatherSubquery(rule routeRule, trait traitRule
return "", nil, err return "", nil, err
} }
functionCodeExpr := "?::text"
functionCodeArgs := []any{rule.FunctionCode}
if rule.SourceTable == "adjustment_stocks" {
functionCodeExpr = "COALESCE(NULLIF(src.function_code,''), ?::text)"
}
whereParts := []string{ whereParts := []string{
fmt.Sprintf("src.%s = ?", productWarehouseCol), fmt.Sprintf("src.%s = ?", productWarehouseCol),
fmt.Sprintf(`EXISTS ( fmt.Sprintf(`EXISTS (
@@ -197,6 +215,9 @@ func (s *fifoStockV2Service) buildGatherSubquery(rule routeRule, trait traitRule
if req.AsOf != nil { if req.AsOf != nil {
whereParts = append(whereParts, fmt.Sprintf("%s <= ?", sortExpr)) whereParts = append(whereParts, fmt.Sprintf("%s <= ?", sortExpr))
} }
if req.From != nil {
whereParts = append(whereParts, fmt.Sprintf("%s >= ?", sortExpr))
}
if rule.ScopeSQL != nil && strings.TrimSpace(*rule.ScopeSQL) != "" { if rule.ScopeSQL != nil && strings.TrimSpace(*rule.ScopeSQL) != "" {
whereParts = append(whereParts, fmt.Sprintf("(%s)", normalizeScopeSQL(*rule.ScopeSQL))) whereParts = append(whereParts, fmt.Sprintf("(%s)", normalizeScopeSQL(*rule.ScopeSQL)))
@@ -206,7 +227,7 @@ func (s *fifoStockV2Service) buildGatherSubquery(rule routeRule, trait traitRule
SELECT SELECT
?::text AS source_table, ?::text AS source_table,
?::text AS legacy_type_key, ?::text AS legacy_type_key,
?::text AS function_code, %s AS function_code,
src.%s AS source_id, src.%s AS source_id,
src.%s AS product_warehouse_id, src.%s AS product_warehouse_id,
%s AS sort_at, %s AS sort_at,
@@ -218,24 +239,28 @@ func (s *fifoStockV2Service) buildGatherSubquery(rule routeRule, trait traitRule
FROM %s src FROM %s src
%s %s
WHERE %s WHERE %s
`, sourceIDCol, productWarehouseCol, sortExpr, baseQtyExpr, usedExpr, pendingExpr, availableExpr, sourceTable, joinClause, strings.Join(whereParts, " AND ")) `, functionCodeExpr, sourceIDCol, productWarehouseCol, sortExpr, baseQtyExpr, usedExpr, pendingExpr, availableExpr, sourceTable, joinClause, strings.Join(whereParts, " AND "))
args := []any{ args := []any{
rule.SourceTable, rule.SourceTable,
rule.LegacyTypeKey, rule.LegacyTypeKey,
rule.FunctionCode,
trait.SortPriority,
} }
args = append(args, functionCodeArgs...)
args = append(args, trait.SortPriority)
args = append(args, extraArgs...) args = append(args, extraArgs...)
args = append(args, args = append(args,
req.ProductWarehouseID, req.ProductWarehouseID,
entity.FlagableTypeProduct, entity.FlagableTypeProduct,
req.FlagGroupCode, req.FlagGroupCode,
) )
args = append(args, whereExtraArgs...)
if req.AsOf != nil { if req.AsOf != nil {
args = append(args, *req.AsOf) args = append(args, *req.AsOf)
} }
if req.From != nil {
args = append(args, *req.From)
}
return subquery, args, nil return subquery, args, nil
} }
@@ -0,0 +1,131 @@
package fifo_stock_v2
import (
"context"
"errors"
"math"
"sort"
commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
"gitlab.com/mbugroup/lti-api.git/internal/utils/fifo"
"github.com/gofiber/fiber/v2"
"gorm.io/gorm"
)
func ReleasePopulationConsumptionByUsable(
ctx context.Context,
tx *gorm.DB,
usableType string,
usableID uint,
) error {
if tx == nil {
return errors.New("transaction is required")
}
if usableType == "" || usableID == 0 {
return errors.New("usable type and id are required")
}
stockAllocationRepo := commonRepo.NewStockAllocationRepository(tx)
allocations, err := stockAllocationRepo.FindActiveByUsable(ctx, usableType, usableID, nil)
if err != nil {
return err
}
for _, allocation := range allocations {
if allocation.StockableType != fifo.StockableKeyProjectFlockPopulation.String() || allocation.StockableId == 0 || allocation.Qty <= 0 {
continue
}
if err := tx.WithContext(ctx).
Model(&entity.ProjectFlockPopulation{}).
Where("id = ?", allocation.StockableId).
Update("total_used_qty", gorm.Expr("GREATEST(total_used_qty - ?, 0)", allocation.Qty)).Error; err != nil {
return err
}
}
return stockAllocationRepo.ReleaseByUsable(ctx, usableType, usableID, nil, nil)
}
func AllocatePopulationConsumption(
ctx context.Context,
tx *gorm.DB,
populations []entity.ProjectFlockPopulation,
productWarehouseID uint,
usableType string,
usableID uint,
consumeQty float64,
) error {
if consumeQty <= 0 {
return nil
}
if tx == nil {
return errors.New("transaction is required")
}
if productWarehouseID == 0 {
return fiber.NewError(fiber.StatusBadRequest, "Product warehouse tidak valid")
}
if usableType == "" || usableID == 0 {
return errors.New("usable type and id are required")
}
if len(populations) == 0 {
return fiber.NewError(fiber.StatusBadRequest, "Populasi tidak ditemukan")
}
if err := ReleasePopulationConsumptionByUsable(ctx, tx, usableType, usableID); err != nil {
return err
}
sort.Slice(populations, func(i, j int) bool {
if populations[i].CreatedAt.Equal(populations[j].CreatedAt) {
return populations[i].Id < populations[j].Id
}
return populations[i].CreatedAt.Before(populations[j].CreatedAt)
})
stockAllocationRepo := commonRepo.NewStockAllocationRepository(tx)
remaining := consumeQty
for _, pop := range populations {
available := pop.TotalQty - pop.TotalUsedQty
if available <= 0 {
continue
}
portion := math.Min(available, remaining)
if portion <= 0 {
continue
}
allocation := &entity.StockAllocation{
ProductWarehouseId: productWarehouseID,
StockableType: fifo.StockableKeyProjectFlockPopulation.String(),
StockableId: pop.Id,
UsableType: usableType,
UsableId: usableID,
Qty: portion,
Status: entity.StockAllocationStatusActive,
AllocationPurpose: entity.StockAllocationPurposeConsume,
}
if err := stockAllocationRepo.CreateOne(ctx, allocation, nil); err != nil {
return err
}
if err := tx.WithContext(ctx).
Model(&entity.ProjectFlockPopulation{}).
Where("id = ?", pop.Id).
Update("total_used_qty", gorm.Expr("total_used_qty + ?", portion)).Error; err != nil {
return err
}
remaining -= portion
if remaining <= 1e-6 {
break
}
}
if remaining > 1e-6 {
return fiber.NewError(fiber.StatusBadRequest, "Populasi tidak mencukupi")
}
return nil
}
@@ -238,7 +238,7 @@ func nearlyZero(v float64) bool {
} }
func (s *fifoStockV2Service) ensureStockAllocationColumns(tx *gorm.DB) error { 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 { for _, col := range checkCols {
var count int64 var count int64
err := tx.Raw(` err := tx.Raw(`
@@ -263,3 +263,15 @@ func activeAllocationStatus() string {
func releasedAllocationStatus() string { func releasedAllocationStatus() string {
return entity.StockAllocationStatusReleased 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
}
@@ -33,7 +33,10 @@ type Ref struct {
type GatherRequest struct { type GatherRequest struct {
FlagGroupCode string FlagGroupCode string
Lane Lane Lane Lane
AllocationPurpose string
IgnoreSourceUsed bool
ProductWarehouseID uint ProductWarehouseID uint
From *time.Time
AsOf *time.Time AsOf *time.Time
Limit int Limit int
AfterSortAt *time.Time AfterSortAt *time.Time
@@ -98,17 +101,15 @@ type RollbackResult struct {
type ReflowRequest struct { type ReflowRequest struct {
FlagGroupCode string FlagGroupCode string
ProductWarehouseID uint ProductWarehouseID uint
Usable Ref
DesiredQty float64
AllowOverConsume *bool
IdempotencyKey string
AsOf *time.Time AsOf *time.Time
IdempotencyKey string
Tx *gorm.DB Tx *gorm.DB
} }
type ReflowResult struct { type ReflowResult struct {
Rollback RollbackResult ProcessedUsables int
Allocate AllocateResult Rollback RollbackResult
Allocate AllocateResult
} }
type RecalculateRequest struct { type RecalculateRequest struct {
+64 -54
View File
@@ -22,60 +22,61 @@ type SSOClientConfig struct {
} }
var ( var (
IsProd bool IsProd bool
AppHost string AppHost string
Version string Version string
LogLevel string LogLevel string
AppPort int AppPort int
DBHost string DBHost string
DBUser string DBUser string
DBPassword string DBPassword string
DBName string DBName string
DBPort int DBPort int
DBSSLMode string DBSSLMode string
DBSSLRootCert string DBSSLRootCert string
DBSSLCert string DBSSLCert string
DBSSLKey string DBSSLKey string
JWTSecret string JWTSecret string
JWTAccessExp int JWTAccessExp int
JWTRefreshExp int JWTRefreshExp int
JWTResetPasswordExp int JWTResetPasswordExp int
JWTVerifyEmailExp int JWTVerifyEmailExp int
RedisURL string RedisURL string
CORSAllowOrigins []string CORSAllowOrigins []string
CORSAllowMethods []string CORSAllowMethods []string
CORSAllowHeaders []string CORSAllowHeaders []string
CORSExposeHeaders []string CORSExposeHeaders []string
CORSAllowCredentials bool CORSAllowCredentials bool
CORSMaxAge int CORSMaxAge int
SSOIssuer string SSOIssuer string
SSOJWKSURL string SSOJWKSURL string
SSOAllowedAudiences []string SSOAllowedAudiences []string
SSOAuthorizeURL string SSOAuthorizeURL string
SSOTokenURL string SSOTokenURL string
SSOGetMeURL string SSOGetMeURL string
SSOPortalURL string SSOPortalURL string
SSOClients map[string]SSOClientConfig SSOClients map[string]SSOClientConfig
SSOAccessCookieName string SSOAccessCookieName string
SSORefreshCookieName string SSORefreshCookieName string
SSOCookieDomain string SSOCookieDomain string
SSOCookieSecure bool SSOCookieSecure bool
SSOCookieSameSite string SSOCookieSameSite string
SSOAccessTokenMaxBytes int SSOAccessTokenMaxBytes int
SSOTokenBlacklistPrefix string SSOTokenBlacklistPrefix string
SSOPKCETTL time.Duration SSOPKCETTL time.Duration
SSOUserSyncDrift time.Duration SSOUserSyncDrift time.Duration
SSOUserSyncNonceTTL time.Duration SSOUserSyncNonceTTL time.Duration
SSOUserSyncMaxBodyBytes int SSOUserSyncMaxBodyBytes int
S3Endpoint string S3Endpoint string
S3Region string S3Region string
S3Bucket string S3Bucket string
S3AccessKey string S3AccessKey string
S3SecretKey string S3SecretKey string
S3ForcePathStyle bool S3ForcePathStyle bool
S3PublicBaseURL string S3PublicBaseURL string
S3EnvPrefix string S3EnvPrefix string
S3DocumentKeyPrefix string S3DocumentKeyPrefix string
TransferToLayingGrowingMaxWeek int
) )
func init() { func init() {
@@ -117,6 +118,11 @@ func init() {
// Redis // Redis
RedisURL = viper.GetString("REDIS_URL") RedisURL = viper.GetString("REDIS_URL")
TransferToLayingGrowingMaxWeek = viper.GetInt("TRANSFER_TO_LAYING_GROWING_MAX_WEEK")
if TransferToLayingGrowingMaxWeek <= 0 {
TransferToLayingGrowingMaxWeek = 19
}
// Object storage // Object storage
S3Endpoint = strings.TrimSpace(viper.GetString("S3_ENDPOINT")) S3Endpoint = strings.TrimSpace(viper.GetString("S3_ENDPOINT"))
S3Region = strings.TrimSpace(viper.GetString("S3_REGION")) S3Region = strings.TrimSpace(viper.GetString("S3_REGION"))
@@ -253,6 +259,10 @@ func defaultString(v, def string) string {
return v return v
} }
func LayingWeekStart() int {
return TransferToLayingGrowingMaxWeek
}
func joinPath(parts ...string) string { func joinPath(parts ...string) string {
out := make([]string, 0, len(parts)) out := make([]string, 0, len(parts))
for _, part := range parts { for _, part := range parts {
@@ -0,0 +1,154 @@
BEGIN;
-- Bootstrap FIFO v2 core tables before seed migration (20260218090010).
-- Keep definitions aligned with 20260304033546_create_fifo_stock_v2_core.
CREATE TABLE IF NOT EXISTS fifo_stock_v2_flag_groups (
code VARCHAR(64) PRIMARY KEY,
name VARCHAR(128) NOT NULL,
priority INT NOT NULL DEFAULT 100,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS fifo_stock_v2_flag_members (
flag_name VARCHAR(64) PRIMARY KEY,
flag_group_code VARCHAR(64) NOT NULL REFERENCES fifo_stock_v2_flag_groups(code),
priority INT NOT NULL DEFAULT 100,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS fifo_stock_v2_traits (
id BIGSERIAL PRIMARY KEY,
source_table VARCHAR(64) NOT NULL,
lane VARCHAR(16) NOT NULL CHECK (lane IN ('STOCKABLE', 'USABLE')),
date_table VARCHAR(64) NULL,
date_join_left_col VARCHAR(64) NULL,
date_join_right_col VARCHAR(64) NULL,
date_column VARCHAR(64) NOT NULL,
fallback_date_column VARCHAR(64) NULL,
sort_priority INT NOT NULL DEFAULT 100,
id_column VARCHAR(64) NOT NULL DEFAULT 'id',
is_active BOOLEAN NOT NULL DEFAULT TRUE,
UNIQUE (source_table, lane)
);
CREATE TABLE IF NOT EXISTS fifo_stock_v2_route_rules (
id BIGSERIAL PRIMARY KEY,
flag_group_code VARCHAR(64) NOT NULL REFERENCES fifo_stock_v2_flag_groups(code),
lane VARCHAR(16) NOT NULL CHECK (lane IN ('STOCKABLE', 'USABLE')),
function_code VARCHAR(64) NOT NULL,
source_table VARCHAR(64) NOT NULL,
source_id_column VARCHAR(64) NOT NULL DEFAULT 'id',
product_warehouse_col VARCHAR(64) NOT NULL,
quantity_col VARCHAR(64) NOT NULL,
used_quantity_col VARCHAR(64) NULL,
pending_quantity_col VARCHAR(64) NULL,
scope_sql TEXT NULL,
legacy_type_key VARCHAR(100) NOT NULL,
allow_pending_default BOOLEAN NOT NULL DEFAULT TRUE,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (flag_group_code, lane, function_code, source_table)
);
CREATE TABLE IF NOT EXISTS fifo_stock_v2_overconsume_rules (
id BIGSERIAL PRIMARY KEY,
flag_group_code VARCHAR(64) NULL REFERENCES fifo_stock_v2_flag_groups(code),
function_code VARCHAR(64) NULL,
lane VARCHAR(16) NOT NULL DEFAULT 'USABLE' CHECK (lane IN ('STOCKABLE', 'USABLE')),
allow_overconsume BOOLEAN NOT NULL,
priority INT NOT NULL DEFAULT 100,
reason TEXT NULL,
is_active BOOLEAN NOT NULL DEFAULT TRUE
);
CREATE TABLE IF NOT EXISTS fifo_stock_v2_operation_log (
id BIGSERIAL PRIMARY KEY,
idempotency_key VARCHAR(128) NOT NULL,
operation VARCHAR(16) NOT NULL CHECK (operation IN ('ALLOCATE', 'ROLLBACK', 'REFLOW', 'RECALCULATE')),
product_warehouse_id BIGINT NOT NULL,
flag_group_code VARCHAR(64) NOT NULL,
usable_type VARCHAR(100) NULL,
usable_id BIGINT NULL,
request_hash VARCHAR(64) NOT NULL,
status VARCHAR(16) NOT NULL CHECK (status IN ('RUNNING', 'DONE', 'FAILED')),
result_payload JSONB NULL,
error_text TEXT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
finished_at TIMESTAMPTZ NULL,
UNIQUE (idempotency_key, operation)
);
CREATE TABLE IF NOT EXISTS fifo_stock_v2_reflow_runs (
id BIGSERIAL PRIMARY KEY,
mode VARCHAR(16) NOT NULL CHECK (mode IN ('DRY_RUN', 'APPLY')),
status VARCHAR(16) NOT NULL CHECK (status IN ('RUNNING', 'PAUSED', 'DONE', 'FAILED', 'CANCELLED')),
as_of TIMESTAMPTZ NULL,
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
finished_at TIMESTAMPTZ NULL,
total_shards INT NOT NULL DEFAULT 0,
processed_shards INT NOT NULL DEFAULT 0,
processed_rows BIGINT NOT NULL DEFAULT 0,
mismatch_rows BIGINT NOT NULL DEFAULT 0,
created_by BIGINT NULL,
note TEXT NULL
);
CREATE TABLE IF NOT EXISTS fifo_stock_v2_reflow_checkpoints (
id BIGSERIAL PRIMARY KEY,
run_id BIGINT NOT NULL REFERENCES fifo_stock_v2_reflow_runs(id) ON DELETE CASCADE,
flag_group_code VARCHAR(64) NOT NULL,
product_warehouse_id BIGINT NOT NULL,
last_sort_at TIMESTAMPTZ NULL,
last_source_table VARCHAR(64) NULL,
last_source_id BIGINT NULL,
status VARCHAR(16) NOT NULL CHECK (status IN ('PENDING', 'RUNNING', 'DONE', 'FAILED')) DEFAULT 'PENDING',
retry_count INT NOT NULL DEFAULT 0,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (run_id, flag_group_code, product_warehouse_id)
);
CREATE TABLE IF NOT EXISTS fifo_stock_v2_shadow_allocations (
id BIGSERIAL PRIMARY KEY,
run_id BIGINT NOT NULL REFERENCES fifo_stock_v2_reflow_runs(id) ON DELETE CASCADE,
product_warehouse_id BIGINT NOT NULL,
stockable_type VARCHAR(100) NOT NULL,
stockable_id BIGINT NOT NULL,
usable_type VARCHAR(100) NOT NULL,
usable_id BIGINT NOT NULL,
qty NUMERIC(15,3) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
sort_at TIMESTAMPTZ NULL,
source_table VARCHAR(64) NULL,
source_id BIGINT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_fifo_v2_shadow_run_usable
ON fifo_stock_v2_shadow_allocations(run_id, usable_type, usable_id);
CREATE INDEX IF NOT EXISTS idx_fifo_v2_shadow_run_stockable
ON fifo_stock_v2_shadow_allocations(run_id, stockable_type, stockable_id);
ALTER TABLE stock_allocations
ADD COLUMN IF NOT EXISTS engine_version VARCHAR(8) NOT NULL DEFAULT 'v1',
ADD COLUMN IF NOT EXISTS flag_group_code VARCHAR(64) NULL,
ADD COLUMN IF NOT EXISTS function_code VARCHAR(64) NULL,
ADD COLUMN IF NOT EXISTS reflow_run_id BIGINT NULL,
ADD COLUMN IF NOT EXISTS idempotency_key VARCHAR(128) NULL;
CREATE INDEX IF NOT EXISTS idx_stock_allocations_engine_version
ON stock_allocations(engine_version);
CREATE INDEX IF NOT EXISTS idx_stock_allocations_flag_group
ON stock_allocations(flag_group_code);
CREATE INDEX IF NOT EXISTS idx_stock_allocations_idempotency
ON stock_allocations(idempotency_key);
COMMIT;
@@ -1,36 +1,60 @@
BEGIN; BEGIN;
DELETE FROM fifo_stock_v2_overconsume_rules DO $$
WHERE reason IN ( BEGIN
'fifo_v2_default_allow', IF to_regclass('public.fifo_stock_v2_overconsume_rules') IS NOT NULL THEN
'fifo_v2_exception_ayam_depletion_block', EXECUTE '
'fifo_v2_exception_marketing_block', DELETE FROM fifo_stock_v2_overconsume_rules
'fifo_v2_exception_transfer_block', WHERE reason IN (
'fifo_v2_exception_adjustment_block', ''fifo_v2_default_allow'',
'fifo_v2_exception_transfer_laying_block' ''fifo_v2_exception_ayam_depletion_block'',
); ''fifo_v2_exception_marketing_block'',
''fifo_v2_exception_transfer_block'',
''fifo_v2_exception_adjustment_block'',
''fifo_v2_exception_transfer_laying_block''
)
';
END IF;
DELETE FROM fifo_stock_v2_route_rules IF to_regclass('public.fifo_stock_v2_route_rules') IS NOT NULL THEN
WHERE flag_group_code IN ('AYAM', 'AFKIR_CULLING_MATI', 'PAKAN', 'OVK', 'TELUR', 'TELUR_GRADE'); EXECUTE '
DELETE FROM fifo_stock_v2_route_rules
WHERE flag_group_code IN (''AYAM'', ''AFKIR_CULLING_MATI'', ''PAKAN'', ''OVK'', ''TELUR'', ''TELUR_GRADE'')
';
END IF;
DELETE FROM fifo_stock_v2_traits IF to_regclass('public.fifo_stock_v2_traits') IS NOT NULL THEN
WHERE source_table IN ( EXECUTE '
'purchase_items', DELETE FROM fifo_stock_v2_traits
'stock_transfer_details', WHERE source_table IN (
'laying_transfer_targets', ''purchase_items'',
'laying_transfer_sources', ''stock_transfer_details'',
'adjustment_stocks', ''laying_transfer_targets'',
'recording_stocks', ''laying_transfer_sources'',
'recording_depletions', ''adjustment_stocks'',
'recording_eggs', ''recording_stocks'',
'marketing_delivery_products', ''recording_depletions'',
'project_chickins' ''recording_eggs'',
); ''marketing_delivery_products'',
''project_chickins'',
''project_flock_populations''
)
';
END IF;
DELETE FROM fifo_stock_v2_flag_members IF to_regclass('public.fifo_stock_v2_flag_members') IS NOT NULL THEN
WHERE flag_group_code IN ('AYAM', 'AFKIR_CULLING_MATI', 'PAKAN', 'OVK', 'TELUR', 'TELUR_GRADE'); EXECUTE '
DELETE FROM fifo_stock_v2_flag_members
WHERE flag_group_code IN (''AYAM'', ''AFKIR_CULLING_MATI'', ''PAKAN'', ''OVK'', ''TELUR'', ''TELUR_GRADE'')
';
END IF;
DELETE FROM fifo_stock_v2_flag_groups IF to_regclass('public.fifo_stock_v2_flag_groups') IS NOT NULL THEN
WHERE code IN ('AYAM', 'AFKIR_CULLING_MATI', 'PAKAN', 'OVK', 'TELUR', 'TELUR_GRADE'); EXECUTE '
DELETE FROM fifo_stock_v2_flag_groups
WHERE code IN (''AYAM'', ''AFKIR_CULLING_MATI'', ''PAKAN'', ''OVK'', ''TELUR'', ''TELUR_GRADE'')
';
END IF;
END $$;
COMMIT; COMMIT;
@@ -79,7 +79,8 @@ VALUES
('marketing_delivery_products', 'USABLE', NULL, NULL, NULL, 'delivery_date', 'created_at', 45, 'id'), ('marketing_delivery_products', 'USABLE', NULL, NULL, NULL, 'delivery_date', 'created_at', 45, 'id'),
('project_chickins', 'USABLE', NULL, NULL, NULL, 'chick_in_date', 'created_at', 50, 'id') ('project_chickins', 'USABLE', NULL, NULL, NULL, 'chick_in_date', 'created_at', 50, 'id'),
('project_flock_populations', 'STOCKABLE', 'project_chickins', 'project_chickin_id', 'id', 'chick_in_date', 'created_at', 55, 'id')
ON CONFLICT (source_table, lane) DO UPDATE ON CONFLICT (source_table, lane) DO UPDATE
SET SET
date_table = EXCLUDED.date_table, date_table = EXCLUDED.date_table,
@@ -112,6 +113,7 @@ VALUES
('AYAM', 'STOCKABLE', 'STOCK_TRANSFER_IN', 'stock_transfer_details', 'id', 'dest_product_warehouse_id', 'total_qty', 'total_used', NULL, 'deleted_at IS NULL', 'STOCK_TRANSFER_IN', TRUE, TRUE), ('AYAM', 'STOCKABLE', 'STOCK_TRANSFER_IN', 'stock_transfer_details', 'id', 'dest_product_warehouse_id', 'total_qty', 'total_used', NULL, 'deleted_at IS NULL', 'STOCK_TRANSFER_IN', TRUE, TRUE),
('AYAM', 'STOCKABLE', 'PURCHASE_IN', 'purchase_items', 'id', 'product_warehouse_id', 'total_qty', 'total_used', NULL, NULL, 'PURCHASE_ITEMS', TRUE, TRUE), ('AYAM', 'STOCKABLE', 'PURCHASE_IN', 'purchase_items', 'id', 'product_warehouse_id', 'total_qty', 'total_used', NULL, NULL, 'PURCHASE_ITEMS', TRUE, TRUE),
('AYAM', 'STOCKABLE', 'TRANSFER_TO_LAYING_IN', 'laying_transfer_targets', 'id', 'product_warehouse_id', 'total_qty', 'total_used', NULL, 'deleted_at IS NULL', 'TRANSFERTOLAYING_IN', TRUE, TRUE), ('AYAM', 'STOCKABLE', 'TRANSFER_TO_LAYING_IN', 'laying_transfer_targets', 'id', 'product_warehouse_id', 'total_qty', 'total_used', NULL, 'deleted_at IS NULL', 'TRANSFERTOLAYING_IN', TRUE, TRUE),
('AYAM', 'STOCKABLE', 'POPULATION_IN', 'project_flock_populations', 'id', 'product_warehouse_id', 'total_qty', 'total_used_qty', NULL, NULL, 'PROJECT_FLOCK_POPULATION', TRUE, TRUE),
-- AYAM USABLE -- AYAM USABLE
('AYAM', 'USABLE', 'ADJUSTMENT_OUT', 'adjustment_stocks', 'id', 'product_warehouse_id', 'usage_qty', NULL, 'pending_qty', NULL, 'ADJUSTMENT_OUT', TRUE, TRUE), ('AYAM', 'USABLE', 'ADJUSTMENT_OUT', 'adjustment_stocks', 'id', 'product_warehouse_id', 'usage_qty', NULL, 'pending_qty', NULL, 'ADJUSTMENT_OUT', TRUE, TRUE),
@@ -0,0 +1,20 @@
BEGIN;
-- Restore CHICKIN route if rollback is required.
-- NOTE: released PROJECT_CHICKIN allocations are not restored by this down migration.
DO $$
BEGIN
IF to_regclass('public.fifo_stock_v2_route_rules') IS NOT NULL THEN
EXECUTE '
UPDATE fifo_stock_v2_route_rules
SET is_active = TRUE,
updated_at = NOW()
WHERE flag_group_code = ''AYAM''
AND lane = ''USABLE''
AND function_code = ''CHICKIN_OUT''
AND source_table = ''project_chickins''
';
END IF;
END $$;
COMMIT;
@@ -0,0 +1,151 @@
BEGIN;
-- Disable CHICKIN as FIFO USABLE so chick-in acts as business tagging/conversion,
-- not physical stock consumption.
UPDATE fifo_stock_v2_route_rules
SET is_active = FALSE,
updated_at = NOW()
WHERE flag_group_code = 'AYAM'
AND lane = 'USABLE'
AND function_code = 'CHICKIN_OUT'
AND source_table = 'project_chickins'
AND is_active = TRUE;
-- Release existing active allocations created by PROJECT_CHICKIN
-- and return warehouse qty back.
WITH released AS (
UPDATE stock_allocations
SET status = 'RELEASED',
released_at = COALESCE(released_at, NOW()),
updated_at = NOW(),
note = CASE
WHEN COALESCE(note, '') = '' THEN 'fifo_v2_chickin_conversion_release'
ELSE note || '; fifo_v2_chickin_conversion_release'
END
WHERE usable_type = 'PROJECT_CHICKIN'
AND status = 'ACTIVE'
RETURNING product_warehouse_id, qty
),
pw_delta AS (
SELECT product_warehouse_id, COALESCE(SUM(qty), 0) AS qty_delta
FROM released
GROUP BY product_warehouse_id
)
UPDATE product_warehouses pw
SET qty = COALESCE(pw.qty, 0) + d.qty_delta
FROM pw_delta d
WHERE pw.id = d.product_warehouse_id;
-- Resync stockable total_used columns from remaining ACTIVE allocations.
-- purchase_items (PURCHASE_ITEMS)
UPDATE purchase_items pi
SET total_used = COALESCE(a.used, 0)
FROM (
SELECT stockable_id, SUM(qty) AS used
FROM stock_allocations
WHERE status = 'ACTIVE'
AND stockable_type = 'PURCHASE_ITEMS'
GROUP BY stockable_id
) a
WHERE pi.id = a.stockable_id;
UPDATE purchase_items pi
SET total_used = 0
WHERE NOT EXISTS (
SELECT 1
FROM stock_allocations sa
WHERE sa.status = 'ACTIVE'
AND sa.stockable_type = 'PURCHASE_ITEMS'
AND sa.stockable_id = pi.id
);
-- stock_transfer_details (STOCK_TRANSFER_IN)
UPDATE stock_transfer_details std
SET total_used = COALESCE(a.used, 0)
FROM (
SELECT stockable_id, SUM(qty) AS used
FROM stock_allocations
WHERE status = 'ACTIVE'
AND stockable_type = 'STOCK_TRANSFER_IN'
GROUP BY stockable_id
) a
WHERE std.id = a.stockable_id;
UPDATE stock_transfer_details std
SET total_used = 0
WHERE NOT EXISTS (
SELECT 1
FROM stock_allocations sa
WHERE sa.status = 'ACTIVE'
AND sa.stockable_type = 'STOCK_TRANSFER_IN'
AND sa.stockable_id = std.id
);
-- adjustment_stocks (ADJUSTMENT_IN)
UPDATE adjustment_stocks ast
SET total_used = COALESCE(a.used, 0)
FROM (
SELECT stockable_id, SUM(qty) AS used
FROM stock_allocations
WHERE status = 'ACTIVE'
AND stockable_type = 'ADJUSTMENT_IN'
GROUP BY stockable_id
) a
WHERE ast.id = a.stockable_id;
UPDATE adjustment_stocks ast
SET total_used = 0
WHERE NOT EXISTS (
SELECT 1
FROM stock_allocations sa
WHERE sa.status = 'ACTIVE'
AND sa.stockable_type = 'ADJUSTMENT_IN'
AND sa.stockable_id = ast.id
);
-- laying_transfer_targets (TRANSFERTOLAYING_IN)
UPDATE laying_transfer_targets ltt
SET total_used = COALESCE(a.used, 0)
FROM (
SELECT stockable_id, SUM(qty) AS used
FROM stock_allocations
WHERE status = 'ACTIVE'
AND stockable_type = 'TRANSFERTOLAYING_IN'
GROUP BY stockable_id
) a
WHERE ltt.id = a.stockable_id;
UPDATE laying_transfer_targets ltt
SET total_used = 0
WHERE NOT EXISTS (
SELECT 1
FROM stock_allocations sa
WHERE sa.status = 'ACTIVE'
AND sa.stockable_type = 'TRANSFERTOLAYING_IN'
AND sa.stockable_id = ltt.id
);
-- recording_eggs (RECORDING_EGG)
UPDATE recording_eggs re
SET total_used = COALESCE(a.used, 0)
FROM (
SELECT stockable_id, SUM(qty) AS used
FROM stock_allocations
WHERE status = 'ACTIVE'
AND stockable_type = 'RECORDING_EGG'
GROUP BY stockable_id
) a
WHERE re.id = a.stockable_id;
UPDATE recording_eggs re
SET total_used = 0
WHERE NOT EXISTS (
SELECT 1
FROM stock_allocations sa
WHERE sa.status = 'ACTIVE'
AND sa.stockable_type = 'RECORDING_EGG'
AND sa.stockable_id = re.id
);
COMMIT;
@@ -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;
@@ -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;
@@ -0,0 +1,24 @@
BEGIN;
DROP INDEX IF EXISTS idx_stock_allocations_idempotency;
DROP INDEX IF EXISTS idx_stock_allocations_flag_group;
DROP INDEX IF EXISTS idx_stock_allocations_engine_version;
ALTER TABLE stock_allocations
DROP COLUMN IF EXISTS idempotency_key,
DROP COLUMN IF EXISTS reflow_run_id,
DROP COLUMN IF EXISTS function_code,
DROP COLUMN IF EXISTS flag_group_code,
DROP COLUMN IF EXISTS engine_version;
DROP TABLE IF EXISTS fifo_stock_v2_shadow_allocations;
DROP TABLE IF EXISTS fifo_stock_v2_reflow_checkpoints;
DROP TABLE IF EXISTS fifo_stock_v2_reflow_runs;
DROP TABLE IF EXISTS fifo_stock_v2_operation_log;
DROP TABLE IF EXISTS fifo_stock_v2_overconsume_rules;
DROP TABLE IF EXISTS fifo_stock_v2_route_rules;
DROP TABLE IF EXISTS fifo_stock_v2_traits;
DROP TABLE IF EXISTS fifo_stock_v2_flag_members;
DROP TABLE IF EXISTS fifo_stock_v2_flag_groups;
COMMIT;
@@ -0,0 +1,15 @@
BEGIN;
DROP INDEX IF EXISTS idx_laying_transfers_executed_by;
DROP INDEX IF EXISTS idx_laying_transfers_executed_at;
DROP INDEX IF EXISTS idx_laying_transfers_effective_move_date;
ALTER TABLE laying_transfers
DROP CONSTRAINT IF EXISTS fk_laying_transfers_executed_by;
ALTER TABLE laying_transfers
DROP COLUMN IF EXISTS executed_by,
DROP COLUMN IF EXISTS executed_at,
DROP COLUMN IF EXISTS effective_move_date;
COMMIT;
@@ -0,0 +1,50 @@
BEGIN;
ALTER TABLE laying_transfers
ADD COLUMN IF NOT EXISTS effective_move_date DATE,
ADD COLUMN IF NOT EXISTS executed_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS executed_by BIGINT;
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'users')
AND NOT EXISTS (
SELECT 1
FROM pg_constraint
WHERE conname = 'fk_laying_transfers_executed_by'
) THEN
ALTER TABLE laying_transfers
ADD CONSTRAINT fk_laying_transfers_executed_by
FOREIGN KEY (executed_by)
REFERENCES users(id)
ON DELETE SET NULL
ON UPDATE CASCADE;
END IF;
END $$;
CREATE INDEX IF NOT EXISTS idx_laying_transfers_effective_move_date
ON laying_transfers(effective_move_date);
CREATE INDEX IF NOT EXISTS idx_laying_transfers_executed_at
ON laying_transfers(executed_at);
CREATE INDEX IF NOT EXISTS idx_laying_transfers_executed_by
ON laying_transfers(executed_by);
-- Backfill historical approved transfers. Before deferred execution,
-- approved transfers were executed immediately during approval.
UPDATE laying_transfers lt
SET
effective_move_date = COALESCE(lt.effective_move_date, lt.transfer_date),
executed_at = COALESCE(lt.executed_at, lt.updated_at),
executed_by = COALESCE(lt.executed_by, lt.created_by)
WHERE (
SELECT a.action
FROM approvals a
WHERE a.approvable_type = 'TRANSFER_TO_LAYINGS'
AND a.approvable_id = lt.id
ORDER BY a.id DESC
LIMIT 1
) = 'APPROVED';
COMMIT;
@@ -0,0 +1,9 @@
BEGIN;
DELETE FROM fifo_stock_v2_route_rules
WHERE flag_group_code = 'AYAM'
AND lane = 'USABLE'
AND function_code = 'CHICKIN_OUT'
AND source_table = 'project_chickins';
COMMIT;
@@ -0,0 +1,34 @@
BEGIN;
INSERT INTO fifo_stock_v2_route_rules(
flag_group_code,
lane,
function_code,
source_table,
source_id_column,
product_warehouse_col,
quantity_col,
used_quantity_col,
pending_quantity_col,
scope_sql,
legacy_type_key,
allow_pending_default,
is_active
)
VALUES
('AYAM', 'USABLE', 'CHICKIN_OUT', 'project_chickins', 'id', 'product_warehouse_id', 'usage_qty', NULL, 'pending_usage_qty', 'deleted_at IS NULL', 'PROJECT_CHICKIN', TRUE, TRUE)
ON CONFLICT (flag_group_code, lane, function_code, source_table) DO UPDATE
SET
source_id_column = EXCLUDED.source_id_column,
product_warehouse_col = EXCLUDED.product_warehouse_col,
quantity_col = EXCLUDED.quantity_col,
used_quantity_col = EXCLUDED.used_quantity_col,
pending_quantity_col = EXCLUDED.pending_quantity_col,
scope_sql = EXCLUDED.scope_sql,
legacy_type_key = EXCLUDED.legacy_type_key,
allow_pending_default = EXCLUDED.allow_pending_default,
updated_at = NOW(),
-- Keep existing is_active (do not override disable migration if it was intentional).
is_active = fifo_stock_v2_route_rules.is_active;
COMMIT;
@@ -0,0 +1,8 @@
BEGIN;
DROP INDEX IF EXISTS idx_laying_transfers_economic_cutoff_date;
ALTER TABLE laying_transfers
DROP COLUMN IF EXISTS economic_cutoff_date;
COMMIT;
@@ -0,0 +1,13 @@
BEGIN;
ALTER TABLE laying_transfers
ADD COLUMN IF NOT EXISTS economic_cutoff_date DATE;
CREATE INDEX IF NOT EXISTS idx_laying_transfers_economic_cutoff_date
ON laying_transfers(economic_cutoff_date);
UPDATE laying_transfers
SET economic_cutoff_date = COALESCE(economic_cutoff_date, effective_move_date, transfer_date)
WHERE economic_cutoff_date IS NULL;
COMMIT;
@@ -0,0 +1,7 @@
BEGIN;
DELETE FROM fifo_stock_v2_flag_members
WHERE flag_name = 'AYAM'
AND flag_group_code = 'AYAM';
COMMIT;
@@ -0,0 +1,13 @@
BEGIN;
INSERT INTO fifo_stock_v2_flag_members(flag_name, flag_group_code, priority, is_active, created_at, updated_at)
VALUES
('AYAM', 'AYAM', 5, TRUE, NOW(), NOW())
ON CONFLICT (flag_name) DO UPDATE
SET
flag_group_code = EXCLUDED.flag_group_code,
priority = EXCLUDED.priority,
is_active = TRUE,
updated_at = NOW();
COMMIT;
@@ -0,0 +1,60 @@
BEGIN;
UPDATE fifo_stock_v2_route_rules
SET
is_active = TRUE,
updated_at = NOW()
WHERE flag_group_code = 'AYAM'
AND lane = 'USABLE'
AND function_code = 'TRANSFER_TO_LAYING_OUT'
AND source_table = 'laying_transfer_sources';
UPDATE fifo_stock_v2_route_rules
SET
is_active = FALSE,
updated_at = NOW()
WHERE flag_group_code = 'AYAM'
AND lane = 'USABLE'
AND function_code = 'TRANSFER_TO_LAYING_OUT'
AND source_table = 'laying_transfers';
UPDATE fifo_stock_v2_traits
SET is_active = TRUE
WHERE source_table = 'laying_transfer_sources'
AND lane = 'USABLE';
UPDATE fifo_stock_v2_traits
SET is_active = FALSE
WHERE source_table = 'laying_transfers'
AND lane = 'USABLE';
DROP INDEX IF EXISTS idx_laying_transfers_source_project_flock_kandang_id;
DROP INDEX IF EXISTS idx_laying_transfers_source_product_warehouse_id;
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'fk_laying_transfers_source_project_flock_kandang_id'
) THEN
ALTER TABLE laying_transfers
DROP CONSTRAINT fk_laying_transfers_source_project_flock_kandang_id;
END IF;
IF EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'fk_laying_transfers_source_product_warehouse_id'
) THEN
ALTER TABLE laying_transfers
DROP CONSTRAINT fk_laying_transfers_source_product_warehouse_id;
END IF;
END $$;
ALTER TABLE laying_transfers
DROP COLUMN IF EXISTS source_project_flock_kandang_id,
DROP COLUMN IF EXISTS source_product_warehouse_id,
DROP COLUMN IF EXISTS source_requested_qty,
DROP COLUMN IF EXISTS source_usage_qty,
DROP COLUMN IF EXISTS source_pending_usage_qty;
COMMIT;
@@ -0,0 +1,170 @@
BEGIN;
ALTER TABLE laying_transfers
ADD COLUMN IF NOT EXISTS source_project_flock_kandang_id BIGINT,
ADD COLUMN IF NOT EXISTS source_product_warehouse_id BIGINT,
ADD COLUMN IF NOT EXISTS source_requested_qty NUMERIC(15,3) NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS source_usage_qty NUMERIC(15,3) NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS source_pending_usage_qty NUMERIC(15,3) NOT NULL DEFAULT 0;
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'project_flock_kandangs')
AND NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'fk_laying_transfers_source_project_flock_kandang_id'
) THEN
ALTER TABLE laying_transfers
ADD CONSTRAINT fk_laying_transfers_source_project_flock_kandang_id
FOREIGN KEY (source_project_flock_kandang_id)
REFERENCES project_flock_kandangs(id)
ON DELETE RESTRICT
ON UPDATE CASCADE;
END IF;
IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'product_warehouses')
AND NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'fk_laying_transfers_source_product_warehouse_id'
) THEN
ALTER TABLE laying_transfers
ADD CONSTRAINT fk_laying_transfers_source_product_warehouse_id
FOREIGN KEY (source_product_warehouse_id)
REFERENCES product_warehouses(id)
ON DELETE SET NULL
ON UPDATE CASCADE;
END IF;
END $$;
CREATE INDEX IF NOT EXISTS idx_laying_transfers_source_project_flock_kandang_id
ON laying_transfers(source_project_flock_kandang_id);
CREATE INDEX IF NOT EXISTS idx_laying_transfers_source_product_warehouse_id
ON laying_transfers(source_product_warehouse_id);
WITH single_source AS (
SELECT
lts.laying_transfer_id,
MIN(lts.source_project_flock_kandang_id) AS source_project_flock_kandang_id,
MIN(lts.product_warehouse_id) AS source_product_warehouse_id
FROM laying_transfer_sources lts
WHERE lts.deleted_at IS NULL
GROUP BY lts.laying_transfer_id
HAVING COUNT(*) = 1
)
UPDATE laying_transfers lt
SET
source_project_flock_kandang_id = ss.source_project_flock_kandang_id,
source_product_warehouse_id = ss.source_product_warehouse_id
FROM single_source ss
WHERE lt.id = ss.laying_transfer_id
AND (lt.source_project_flock_kandang_id IS NULL OR lt.source_project_flock_kandang_id = 0);
WITH source_totals AS (
SELECT
laying_transfer_id,
COALESCE(SUM(requested_qty), 0) AS requested_qty,
COALESCE(SUM(usage_qty), 0) AS usage_qty,
COALESCE(SUM(pending_usage_qty), 0) AS pending_qty
FROM laying_transfer_sources
WHERE deleted_at IS NULL
GROUP BY laying_transfer_id
)
UPDATE laying_transfers lt
SET
source_requested_qty = CASE
WHEN lt.source_requested_qty = 0 THEN st.requested_qty
ELSE lt.source_requested_qty
END,
source_usage_qty = CASE
WHEN lt.source_usage_qty = 0 THEN st.usage_qty
ELSE lt.source_usage_qty
END,
source_pending_usage_qty = CASE
WHEN lt.source_pending_usage_qty = 0 THEN st.pending_qty
ELSE lt.source_pending_usage_qty
END
FROM source_totals st
WHERE lt.id = st.laying_transfer_id;
WITH target_totals AS (
SELECT
laying_transfer_id,
COALESCE(SUM(total_qty), 0) AS total_qty
FROM laying_transfer_targets
WHERE deleted_at IS NULL
GROUP BY laying_transfer_id
)
UPDATE laying_transfers lt
SET source_requested_qty = tt.total_qty
FROM target_totals tt
WHERE lt.id = tt.laying_transfer_id
AND (lt.source_requested_qty IS NULL OR lt.source_requested_qty = 0);
INSERT INTO fifo_stock_v2_traits(
source_table,
lane,
date_table,
date_join_left_col,
date_join_right_col,
date_column,
fallback_date_column,
sort_priority,
id_column
)
VALUES
('laying_transfers', 'USABLE', NULL, NULL, NULL, 'transfer_date', NULL, 25, 'id')
ON CONFLICT (source_table, lane) DO UPDATE
SET
date_table = EXCLUDED.date_table,
date_join_left_col = EXCLUDED.date_join_left_col,
date_join_right_col = EXCLUDED.date_join_right_col,
date_column = EXCLUDED.date_column,
fallback_date_column = EXCLUDED.fallback_date_column,
sort_priority = EXCLUDED.sort_priority,
id_column = EXCLUDED.id_column,
is_active = TRUE;
UPDATE fifo_stock_v2_traits
SET is_active = FALSE
WHERE source_table = 'laying_transfer_sources'
AND lane = 'USABLE';
INSERT INTO fifo_stock_v2_route_rules(
flag_group_code,
lane,
function_code,
source_table,
source_id_column,
product_warehouse_col,
quantity_col,
used_quantity_col,
pending_quantity_col,
scope_sql,
legacy_type_key,
allow_pending_default,
is_active
)
VALUES
('AYAM', 'USABLE', 'TRANSFER_TO_LAYING_OUT', 'laying_transfers', 'id', 'source_product_warehouse_id', 'source_usage_qty', NULL, 'source_pending_usage_qty', 'deleted_at IS NULL', 'TRANSFERTOLAYING_OUT', TRUE, TRUE)
ON CONFLICT (flag_group_code, lane, function_code, source_table) DO UPDATE
SET
source_id_column = EXCLUDED.source_id_column,
product_warehouse_col = EXCLUDED.product_warehouse_col,
quantity_col = EXCLUDED.quantity_col,
used_quantity_col = EXCLUDED.used_quantity_col,
pending_quantity_col = EXCLUDED.pending_quantity_col,
scope_sql = EXCLUDED.scope_sql,
legacy_type_key = EXCLUDED.legacy_type_key,
allow_pending_default = EXCLUDED.allow_pending_default,
is_active = TRUE,
updated_at = NOW();
UPDATE fifo_stock_v2_route_rules
SET
is_active = FALSE,
updated_at = NOW()
WHERE flag_group_code = 'AYAM'
AND lane = 'USABLE'
AND function_code = 'TRANSFER_TO_LAYING_OUT'
AND source_table = 'laying_transfer_sources';
COMMIT;
@@ -0,0 +1,18 @@
BEGIN;
UPDATE fifo_stock_v2_route_rules
SET
is_active = FALSE,
updated_at = NOW()
WHERE flag_group_code = 'AYAM'
AND lane = 'STOCKABLE'
AND function_code = 'POPULATION_IN'
AND source_table = 'project_flock_populations'
AND legacy_type_key = 'PROJECT_FLOCK_POPULATION';
UPDATE fifo_stock_v2_traits
SET is_active = FALSE
WHERE source_table = 'project_flock_populations'
AND lane = 'STOCKABLE';
COMMIT;
@@ -0,0 +1,81 @@
BEGIN;
INSERT INTO fifo_stock_v2_traits(
source_table,
lane,
date_table,
date_join_left_col,
date_join_right_col,
date_column,
fallback_date_column,
sort_priority,
id_column,
is_active
)
VALUES
('project_flock_populations', 'STOCKABLE', 'project_chickins', 'project_chickin_id', 'id', 'chick_in_date', 'created_at', 55, 'id', TRUE)
ON CONFLICT (source_table, lane) DO UPDATE
SET
date_table = EXCLUDED.date_table,
date_join_left_col = EXCLUDED.date_join_left_col,
date_join_right_col = EXCLUDED.date_join_right_col,
date_column = EXCLUDED.date_column,
fallback_date_column = EXCLUDED.fallback_date_column,
sort_priority = EXCLUDED.sort_priority,
id_column = EXCLUDED.id_column,
is_active = TRUE;
INSERT INTO fifo_stock_v2_route_rules(
flag_group_code,
lane,
function_code,
source_table,
source_id_column,
product_warehouse_col,
quantity_col,
used_quantity_col,
pending_quantity_col,
scope_sql,
legacy_type_key,
allow_pending_default,
is_active
)
VALUES
('AYAM', 'STOCKABLE', 'POPULATION_IN', 'project_flock_populations', 'id', 'product_warehouse_id', 'total_qty', 'total_used_qty', NULL, NULL, 'PROJECT_FLOCK_POPULATION', TRUE, TRUE)
ON CONFLICT (flag_group_code, lane, function_code, source_table) DO UPDATE
SET
source_id_column = EXCLUDED.source_id_column,
product_warehouse_col = EXCLUDED.product_warehouse_col,
quantity_col = EXCLUDED.quantity_col,
used_quantity_col = EXCLUDED.used_quantity_col,
pending_quantity_col = EXCLUDED.pending_quantity_col,
scope_sql = EXCLUDED.scope_sql,
legacy_type_key = EXCLUDED.legacy_type_key,
allow_pending_default = EXCLUDED.allow_pending_default,
is_active = TRUE,
updated_at = NOW();
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;
UPDATE project_flock_populations p
SET total_used_qty = 0
WHERE 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
);
COMMIT;
@@ -0,0 +1,12 @@
BEGIN;
UPDATE fifo_stock_v2_route_rules
SET
is_active = FALSE,
updated_at = NOW()
WHERE flag_group_code = 'AYAM'
AND lane = 'USABLE'
AND function_code = 'CHICKIN_OUT'
AND source_table = 'project_chickins';
COMMIT;
@@ -0,0 +1,33 @@
BEGIN;
INSERT INTO fifo_stock_v2_route_rules(
flag_group_code,
lane,
function_code,
source_table,
source_id_column,
product_warehouse_col,
quantity_col,
used_quantity_col,
pending_quantity_col,
scope_sql,
legacy_type_key,
allow_pending_default,
is_active
)
VALUES
('AYAM', 'USABLE', 'CHICKIN_OUT', 'project_chickins', 'id', 'product_warehouse_id', 'usage_qty', NULL, 'pending_usage_qty', 'deleted_at IS NULL', 'PROJECT_CHICKIN', TRUE, TRUE)
ON CONFLICT (flag_group_code, lane, function_code, source_table) DO UPDATE
SET
source_id_column = EXCLUDED.source_id_column,
product_warehouse_col = EXCLUDED.product_warehouse_col,
quantity_col = EXCLUDED.quantity_col,
used_quantity_col = EXCLUDED.used_quantity_col,
pending_quantity_col = EXCLUDED.pending_quantity_col,
scope_sql = EXCLUDED.scope_sql,
legacy_type_key = EXCLUDED.legacy_type_key,
allow_pending_default = EXCLUDED.allow_pending_default,
is_active = TRUE,
updated_at = NOW();
COMMIT;
@@ -0,0 +1,118 @@
BEGIN;
-- MARKETING_OUT: if AYAM-only rule exists, convert back to global rule.
UPDATE fifo_stock_v2_overconsume_rules
SET
flag_group_code = NULL,
allow_overconsume = FALSE,
priority = 20,
reason = 'fifo_v2_exception_marketing_block',
is_active = TRUE
WHERE lane = 'USABLE'
AND function_code = 'MARKETING_OUT'
AND flag_group_code = 'AYAM'
AND reason = 'fifo_v2_exception_marketing_block_ayam_only';
-- MARKETING_OUT: if global row already exists, keep it active.
UPDATE fifo_stock_v2_overconsume_rules
SET
allow_overconsume = FALSE,
priority = 20,
is_active = TRUE
WHERE lane = 'USABLE'
AND function_code = 'MARKETING_OUT'
AND flag_group_code IS NULL
AND reason = 'fifo_v2_exception_marketing_block';
-- MARKETING_OUT: insert global rule if still missing.
INSERT INTO fifo_stock_v2_overconsume_rules(
flag_group_code,
function_code,
lane,
allow_overconsume,
priority,
reason,
is_active
)
SELECT NULL, 'MARKETING_OUT', 'USABLE', FALSE, 20, 'fifo_v2_exception_marketing_block', TRUE
WHERE NOT EXISTS (
SELECT 1
FROM fifo_stock_v2_overconsume_rules
WHERE lane = 'USABLE'
AND function_code = 'MARKETING_OUT'
AND flag_group_code IS NULL
AND reason = 'fifo_v2_exception_marketing_block'
);
-- MARKETING_OUT: deactivate AYAM-only duplicates if any remain.
UPDATE fifo_stock_v2_overconsume_rules
SET
is_active = FALSE
WHERE lane = 'USABLE'
AND function_code = 'MARKETING_OUT'
AND flag_group_code = 'AYAM'
AND reason = 'fifo_v2_exception_marketing_block_ayam_only';
-- STOCK_TRANSFER_OUT: if AYAM-only rule exists, convert back to global rule.
UPDATE fifo_stock_v2_overconsume_rules
SET
flag_group_code = NULL,
allow_overconsume = FALSE,
priority = 30,
reason = 'fifo_v2_exception_transfer_block',
is_active = TRUE
WHERE lane = 'USABLE'
AND function_code = 'STOCK_TRANSFER_OUT'
AND flag_group_code = 'AYAM'
AND reason = 'fifo_v2_exception_transfer_block_ayam_only';
-- STOCK_TRANSFER_OUT: if global row already exists, keep it active.
UPDATE fifo_stock_v2_overconsume_rules
SET
allow_overconsume = FALSE,
priority = 30,
is_active = TRUE
WHERE lane = 'USABLE'
AND function_code = 'STOCK_TRANSFER_OUT'
AND flag_group_code IS NULL
AND reason = 'fifo_v2_exception_transfer_block';
-- STOCK_TRANSFER_OUT: insert global rule if still missing.
INSERT INTO fifo_stock_v2_overconsume_rules(
flag_group_code,
function_code,
lane,
allow_overconsume,
priority,
reason,
is_active
)
SELECT NULL, 'STOCK_TRANSFER_OUT', 'USABLE', FALSE, 30, 'fifo_v2_exception_transfer_block', TRUE
WHERE NOT EXISTS (
SELECT 1
FROM fifo_stock_v2_overconsume_rules
WHERE lane = 'USABLE'
AND function_code = 'STOCK_TRANSFER_OUT'
AND flag_group_code IS NULL
AND reason = 'fifo_v2_exception_transfer_block'
);
-- STOCK_TRANSFER_OUT: deactivate AYAM-only duplicates if any remain.
UPDATE fifo_stock_v2_overconsume_rules
SET
is_active = FALSE
WHERE lane = 'USABLE'
AND function_code = 'STOCK_TRANSFER_OUT'
AND flag_group_code = 'AYAM'
AND reason = 'fifo_v2_exception_transfer_block_ayam_only';
-- CHICKIN_OUT: rollback AYAM-only hard-block added by up migration.
UPDATE fifo_stock_v2_overconsume_rules
SET
is_active = FALSE
WHERE lane = 'USABLE'
AND function_code = 'CHICKIN_OUT'
AND flag_group_code = 'AYAM'
AND reason = 'fifo_v2_exception_chickin_block_ayam_only';
COMMIT;
@@ -0,0 +1,139 @@
BEGIN;
-- MARKETING_OUT: if global rule exists, convert to AYAM-specific.
UPDATE fifo_stock_v2_overconsume_rules
SET
flag_group_code = 'AYAM',
allow_overconsume = FALSE,
priority = 20,
reason = 'fifo_v2_exception_marketing_block_ayam_only',
is_active = TRUE
WHERE lane = 'USABLE'
AND function_code = 'MARKETING_OUT'
AND flag_group_code IS NULL
AND reason = 'fifo_v2_exception_marketing_block';
-- MARKETING_OUT: if AYAM-specific row already exists, enforce desired value.
UPDATE fifo_stock_v2_overconsume_rules
SET
allow_overconsume = FALSE,
priority = 20,
is_active = TRUE
WHERE lane = 'USABLE'
AND function_code = 'MARKETING_OUT'
AND flag_group_code = 'AYAM'
AND reason = 'fifo_v2_exception_marketing_block_ayam_only';
-- MARKETING_OUT: insert AYAM-specific if no suitable row exists.
INSERT INTO fifo_stock_v2_overconsume_rules(
flag_group_code,
function_code,
lane,
allow_overconsume,
priority,
reason,
is_active
)
SELECT 'AYAM', 'MARKETING_OUT', 'USABLE', FALSE, 20, 'fifo_v2_exception_marketing_block_ayam_only', TRUE
WHERE NOT EXISTS (
SELECT 1
FROM fifo_stock_v2_overconsume_rules
WHERE lane = 'USABLE'
AND function_code = 'MARKETING_OUT'
AND flag_group_code = 'AYAM'
AND reason = 'fifo_v2_exception_marketing_block_ayam_only'
);
-- MARKETING_OUT: deactivate remaining global rule (if any duplicate row exists).
UPDATE fifo_stock_v2_overconsume_rules
SET
is_active = FALSE
WHERE lane = 'USABLE'
AND function_code = 'MARKETING_OUT'
AND flag_group_code IS NULL
AND reason = 'fifo_v2_exception_marketing_block';
-- STOCK_TRANSFER_OUT: if global rule exists, convert to AYAM-specific.
UPDATE fifo_stock_v2_overconsume_rules
SET
flag_group_code = 'AYAM',
allow_overconsume = FALSE,
priority = 30,
reason = 'fifo_v2_exception_transfer_block_ayam_only',
is_active = TRUE
WHERE lane = 'USABLE'
AND function_code = 'STOCK_TRANSFER_OUT'
AND flag_group_code IS NULL
AND reason = 'fifo_v2_exception_transfer_block';
-- STOCK_TRANSFER_OUT: if AYAM-specific row already exists, enforce desired value.
UPDATE fifo_stock_v2_overconsume_rules
SET
allow_overconsume = FALSE,
priority = 30,
is_active = TRUE
WHERE lane = 'USABLE'
AND function_code = 'STOCK_TRANSFER_OUT'
AND flag_group_code = 'AYAM'
AND reason = 'fifo_v2_exception_transfer_block_ayam_only';
-- STOCK_TRANSFER_OUT: insert AYAM-specific if no suitable row exists.
INSERT INTO fifo_stock_v2_overconsume_rules(
flag_group_code,
function_code,
lane,
allow_overconsume,
priority,
reason,
is_active
)
SELECT 'AYAM', 'STOCK_TRANSFER_OUT', 'USABLE', FALSE, 30, 'fifo_v2_exception_transfer_block_ayam_only', TRUE
WHERE NOT EXISTS (
SELECT 1
FROM fifo_stock_v2_overconsume_rules
WHERE lane = 'USABLE'
AND function_code = 'STOCK_TRANSFER_OUT'
AND flag_group_code = 'AYAM'
AND reason = 'fifo_v2_exception_transfer_block_ayam_only'
);
-- STOCK_TRANSFER_OUT: deactivate remaining global rule (if any duplicate row exists).
UPDATE fifo_stock_v2_overconsume_rules
SET
is_active = FALSE
WHERE lane = 'USABLE'
AND function_code = 'STOCK_TRANSFER_OUT'
AND flag_group_code IS NULL
AND reason = 'fifo_v2_exception_transfer_block';
-- CHICKIN_OUT: enforce AYAM-specific hard-block (cannot pending).
UPDATE fifo_stock_v2_overconsume_rules
SET
allow_overconsume = FALSE,
priority = 25,
is_active = TRUE
WHERE lane = 'USABLE'
AND function_code = 'CHICKIN_OUT'
AND flag_group_code = 'AYAM'
AND reason = 'fifo_v2_exception_chickin_block_ayam_only';
INSERT INTO fifo_stock_v2_overconsume_rules(
flag_group_code,
function_code,
lane,
allow_overconsume,
priority,
reason,
is_active
)
SELECT 'AYAM', 'CHICKIN_OUT', 'USABLE', FALSE, 25, 'fifo_v2_exception_chickin_block_ayam_only', TRUE
WHERE NOT EXISTS (
SELECT 1
FROM fifo_stock_v2_overconsume_rules
WHERE lane = 'USABLE'
AND function_code = 'CHICKIN_OUT'
AND flag_group_code = 'AYAM'
AND reason = 'fifo_v2_exception_chickin_block_ayam_only'
);
COMMIT;
@@ -0,0 +1,14 @@
BEGIN;
ALTER TABLE adjustment_stocks
DROP CONSTRAINT IF EXISTS chk_adjustment_stocks_paired_not_self;
ALTER TABLE adjustment_stocks
DROP CONSTRAINT IF EXISTS fk_adjustment_stocks_paired_adjustment_id;
DROP INDEX IF EXISTS idx_adjustment_stocks_paired_adjustment_id;
ALTER TABLE adjustment_stocks
DROP COLUMN IF EXISTS paired_adjustment_id;
COMMIT;
@@ -0,0 +1,86 @@
BEGIN;
ALTER TABLE adjustment_stocks
ADD COLUMN IF NOT EXISTS paired_adjustment_id BIGINT NULL;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'fk_adjustment_stocks_paired_adjustment_id'
) THEN
ALTER TABLE adjustment_stocks
ADD CONSTRAINT fk_adjustment_stocks_paired_adjustment_id
FOREIGN KEY (paired_adjustment_id)
REFERENCES adjustment_stocks(id)
ON DELETE SET NULL
ON UPDATE CASCADE;
END IF;
END $$;
ALTER TABLE adjustment_stocks
DROP CONSTRAINT IF EXISTS chk_adjustment_stocks_paired_not_self;
ALTER TABLE adjustment_stocks
ADD CONSTRAINT chk_adjustment_stocks_paired_not_self
CHECK (paired_adjustment_id IS NULL OR paired_adjustment_id <> id);
CREATE INDEX IF NOT EXISTS idx_adjustment_stocks_paired_adjustment_id
ON adjustment_stocks(paired_adjustment_id);
-- Backfill pairing untuk depletion-out <-> depletion-in existing records.
WITH candidates AS (
SELECT
src.id AS src_id,
dst.id AS dst_id,
ABS(EXTRACT(EPOCH FROM (dst.created_at - src.created_at))) AS ts_diff,
ABS(dst.id - src.id) AS id_diff,
ROW_NUMBER() OVER (
PARTITION BY src.id
ORDER BY ABS(EXTRACT(EPOCH FROM (dst.created_at - src.created_at))) ASC,
ABS(dst.id - src.id) ASC,
dst.id ASC
) AS rn_src,
ROW_NUMBER() OVER (
PARTITION BY dst.id
ORDER BY ABS(EXTRACT(EPOCH FROM (dst.created_at - src.created_at))) ASC,
ABS(dst.id - src.id) ASC,
src.id ASC
) AS rn_dst
FROM adjustment_stocks src
JOIN adjustment_stocks dst
ON dst.id <> src.id
AND dst.transaction_type = src.transaction_type
AND dst.function_code = 'RECORDING_DEPLETION_IN'
AND src.function_code = 'RECORDING_DEPLETION_OUT'
AND dst.paired_adjustment_id IS NULL
AND src.paired_adjustment_id IS NULL
AND ABS((COALESCE(src.usage_qty, 0) + COALESCE(src.pending_qty, 0)) - COALESCE(dst.total_qty, 0)) < 0.0001
AND COALESCE(src.price, 0) = COALESCE(dst.price, 0)
AND COALESCE(src.grand_total, 0) = COALESCE(dst.grand_total, 0)
AND ABS(EXTRACT(EPOCH FROM (dst.created_at - src.created_at))) <= 120
),
chosen AS (
SELECT src_id, dst_id
FROM candidates
WHERE rn_src = 1
AND rn_dst = 1
)
UPDATE adjustment_stocks src
SET paired_adjustment_id = c.dst_id
FROM chosen c
WHERE src.id = c.src_id
AND src.paired_adjustment_id IS NULL;
WITH chosen AS (
SELECT a.id AS src_id, a.paired_adjustment_id AS dst_id
FROM adjustment_stocks a
WHERE a.function_code = 'RECORDING_DEPLETION_OUT'
AND a.paired_adjustment_id IS NOT NULL
)
UPDATE adjustment_stocks dst
SET paired_adjustment_id = c.src_id
FROM chosen c
WHERE dst.id = c.dst_id
AND dst.paired_adjustment_id IS NULL;
COMMIT;
+2
View File
@@ -5,6 +5,7 @@ import "time"
type AdjustmentStock struct { type AdjustmentStock struct {
Id uint `gorm:"primaryKey"` Id uint `gorm:"primaryKey"`
ProductWarehouseId uint `gorm:"column:product_warehouse_id;not null"` ProductWarehouseId uint `gorm:"column:product_warehouse_id;not null"`
PairedAdjustmentId *uint `gorm:"column:paired_adjustment_id"`
TransactionType string `gorm:"column:transaction_type;type:varchar(100);not null;default:LEGACY"` TransactionType string `gorm:"column:transaction_type;type:varchar(100);not null;default:LEGACY"`
FunctionCode string `gorm:"column:function_code;type:varchar(64)"` FunctionCode string `gorm:"column:function_code;type:varchar(64)"`
TotalQty float64 `gorm:"column:total_qty;default:0"` TotalQty float64 `gorm:"column:total_qty;default:0"`
@@ -18,5 +19,6 @@ type AdjustmentStock struct {
AdjNumber string `gorm:"column:adj_number;uniqueIndex;not null"` AdjNumber string `gorm:"column:adj_number;uniqueIndex;not null"`
ProductWarehouse *ProductWarehouse `gorm:"foreignKey:ProductWarehouseId;references:Id"` ProductWarehouse *ProductWarehouse `gorm:"foreignKey:ProductWarehouseId;references:Id"`
PairedAdjustment *AdjustmentStock `gorm:"foreignKey:PairedAdjustmentId;references:Id"`
StockLog *StockLog `gorm:"polymorphic:Loggable;polymorphicType:LoggableType;polymorphicId:LoggableId;polymorphicValue:ADJUSTMENT"` StockLog *StockLog `gorm:"polymorphic:Loggable;polymorphicType:LoggableType;polymorphicId:LoggableId;polymorphicValue:ADJUSTMENT"`
} }
+28 -16
View File
@@ -7,21 +7,33 @@ import (
) )
type LayingTransfer struct { type LayingTransfer struct {
Id uint `gorm:"primaryKey"` Id uint `gorm:"primaryKey"`
TransferNumber string `gorm:"uniqueIndex;not null"` TransferNumber string `gorm:"uniqueIndex;not null"`
FromProjectFlockId uint `gorm:"not null"` FromProjectFlockId uint `gorm:"not null"`
ToProjectFlockId uint `gorm:"not null"` ToProjectFlockId uint `gorm:"not null"`
TransferDate time.Time `gorm:"type:date;not null"` SourceProjectFlockKandangId *uint `gorm:"index"`
Notes string `gorm:"type:text"` SourceProductWarehouseId *uint `gorm:"index"`
CreatedBy uint `gorm:"not null"` SourceRequestedQty float64 `gorm:"type:numeric(15,3);default:0;not null"`
CreatedAt time.Time `gorm:"autoCreateTime"` SourceUsageQty float64 `gorm:"type:numeric(15,3);default:0;not null"`
UpdatedAt time.Time `gorm:"autoUpdateTime"` SourcePendingUsageQty float64 `gorm:"type:numeric(15,3);default:0;not null"`
DeletedAt gorm.DeletedAt `gorm:"index"` TransferDate time.Time `gorm:"type:date;not null"`
EconomicCutoffDate *time.Time `gorm:"type:date"`
EffectiveMoveDate *time.Time `gorm:"type:date"`
ExecutedAt *time.Time `gorm:"type:timestamptz"`
ExecutedBy *uint `gorm:"index"`
Notes string `gorm:"type:text"`
CreatedBy uint `gorm:"not null"`
CreatedAt time.Time `gorm:"autoCreateTime"`
UpdatedAt time.Time `gorm:"autoUpdateTime"`
DeletedAt gorm.DeletedAt `gorm:"index"`
FromProjectFlock *ProjectFlock `gorm:"foreignKey:FromProjectFlockId;references:Id"` FromProjectFlock *ProjectFlock `gorm:"foreignKey:FromProjectFlockId;references:Id"`
ToProjectFlock *ProjectFlock `gorm:"foreignKey:ToProjectFlockId;references:Id"` ToProjectFlock *ProjectFlock `gorm:"foreignKey:ToProjectFlockId;references:Id"`
CreatedUser *User `gorm:"foreignKey:CreatedBy;references:Id"` SourceProjectFlockKandang *ProjectFlockKandang `gorm:"foreignKey:SourceProjectFlockKandangId;references:Id"`
Sources []LayingTransferSource `gorm:"foreignKey:LayingTransferId;constraint:OnDelete:CASCADE"` SourceProductWarehouse *ProductWarehouse `gorm:"foreignKey:SourceProductWarehouseId;references:Id"`
Targets []LayingTransferTarget `gorm:"foreignKey:LayingTransferId;constraint:OnDelete:CASCADE"` CreatedUser *User `gorm:"foreignKey:CreatedBy;references:Id"`
LatestApproval *Approval `gorm:"-" json:"-"` ExecutedUser *User `gorm:"foreignKey:ExecutedBy;references:Id"`
Sources []LayingTransferSource `gorm:"foreignKey:LayingTransferId;constraint:OnDelete:CASCADE"`
Targets []LayingTransferTarget `gorm:"foreignKey:LayingTransferId;constraint:OnDelete:CASCADE"`
LatestApproval *Approval `gorm:"-" json:"-"`
} }
+6 -5
View File
@@ -1,11 +1,12 @@
package entities package entities
type ProductWarehouse struct { type ProductWarehouse struct {
Id uint `gorm:"primaryKey;column:id"` Id uint `gorm:"primaryKey;column:id"`
ProductId uint `gorm:"column:product_id;not null"` ProductId uint `gorm:"column:product_id;not null"`
WarehouseId uint `gorm:"column:warehouse_id;not null"` WarehouseId uint `gorm:"column:warehouse_id;not null"`
ProjectFlockKandangId *uint `gorm:"column:project_flock_kandang_id"` ProjectFlockKandangId *uint `gorm:"column:project_flock_kandang_id"`
Quantity float64 `gorm:"column:qty;type:numeric(15,3);default:0"` Quantity float64 `gorm:"column:qty;type:numeric(15,3);default:0"`
AvailableQty *float64 `gorm:"-"`
// Relations // Relations
Product Product `gorm:"foreignKey:ProductId;references:Id"` Product Product `gorm:"foreignKey:ProductId;references:Id"`
+4
View File
@@ -43,4 +43,8 @@ type Recording struct {
StandardEggMass *float64 `gorm:"-"` StandardEggMass *float64 `gorm:"-"`
StandardEggWeight *float64 `gorm:"-"` StandardEggWeight *float64 `gorm:"-"`
StandardFcr *float64 `gorm:"-"` StandardFcr *float64 `gorm:"-"`
PopulationCanChange *bool `gorm:"-"`
TransferExecuted *bool `gorm:"-"`
IsTransition *bool `gorm:"-"`
IsLaying *bool `gorm:"-"`
} }
+5 -1
View File
@@ -10,6 +10,9 @@ const (
StockAllocationStatusPending = "PENDING" StockAllocationStatusPending = "PENDING"
StockAllocationStatusActive = "ACTIVE" StockAllocationStatusActive = "ACTIVE"
StockAllocationStatusReleased = "RELEASED" StockAllocationStatusReleased = "RELEASED"
StockAllocationPurposeConsume = "CONSUME"
StockAllocationPurposeTraceChickin = "TRACE_CHICKIN"
) )
// StockAllocation links a usable record (consumption) with an incoming stock record. // 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"` 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"` UsableId uint `gorm:"not null;index:stock_allocations_usage_lookup,priority:2"`
Qty float64 `gorm:"type:numeric(15,3);not null"` 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"` Note *string `gorm:"type:text"`
CreatedAt time.Time `gorm:"autoCreateTime"` CreatedAt time.Time `gorm:"autoCreateTime"`
UpdatedAt time.Time `gorm:"autoUpdateTime"` UpdatedAt time.Time `gorm:"autoUpdateTime"`
+5 -3
View File
@@ -38,9 +38,10 @@ const (
P_ExpenseDocumentRealizations = "lti.expense.document.realization" P_ExpenseDocumentRealizations = "lti.expense.document.realization"
) )
const ( const (
P_AdjustmentGetAll = "lti.inventory.list" P_AdjustmentGetAll = "lti.inventory.list"
P_AdjustmentCreate = "lti.inventory.create" P_AdjustmentCreate = "lti.inventory.create"
P_AdjustmentGetOne = "lti.inventory.detail" P_AdjustmentGetOne = "lti.inventory.detail"
P_AdjustmentDeleteOne = "lti.inventory.delete"
) )
const ( const (
P_ApprovalGetAll = "lti.approval.list" P_ApprovalGetAll = "lti.approval.list"
@@ -70,6 +71,7 @@ const (
P_TransferGetAll = "lti.inventory.transfer.list" P_TransferGetAll = "lti.inventory.transfer.list"
P_TransferGetOne = "lti.inventory.transfer.detail" P_TransferGetOne = "lti.inventory.transfer.detail"
P_TransferCreateOne = "lti.inventory.transfer.create" P_TransferCreateOne = "lti.inventory.transfer.create"
P_TransferDeleteOne = "lti.inventory.transfer.delete"
) )
const ( const (
@@ -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 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)"). 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.status = ?", entity.StockAllocationStatusActive).
Where("sa.allocation_purpose = ?", entity.StockAllocationPurposeConsume).
Where("sa.stockable_type <> ?", fifo.StockableKeyProjectFlockPopulation.String()). Where("sa.stockable_type <> ?", fifo.StockableKeyProjectFlockPopulation.String()).
Where("f.name IN ?", sapronakFlagsAll). Where("f.name IN ?", sapronakFlagsAll).
Where(` 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 warehouses w ON w.id = pw.warehouse_id").
Joins("JOIN products p ON p.id = pw.product_id"). Joins("JOIN products p ON p.id = pw.product_id").
Where("sa.status = ?", entity.StockAllocationStatusActive). Where("sa.status = ?", entity.StockAllocationStatusActive).
Where("sa.allocation_purpose = ?", entity.StockAllocationPurposeConsume).
Where("w.kandang_id = ?", kandangID). Where("w.kandang_id = ?", kandangID).
Where("f.name IN ?", sapronakFlagsAll). Where("f.name IN ?", sapronakFlagsAll).
Where("f.name NOT IN ?", sapronakFlags(utils.FlagDOC, utils.FlagPullet)). Where("f.name NOT IN ?", sapronakFlags(utils.FlagDOC, utils.FlagPullet)).
@@ -1289,8 +1291,7 @@ func (r *ClosingRepositoryImpl) FetchSapronakTransfers(ctx context.Context, kand
COALESCE(p.product_price, 0) AS price COALESCE(p.product_price, 0) AS price
`). `).
Joins("JOIN laying_transfers lt ON lt.id = ltt.laying_transfer_id"). Joins("JOIN laying_transfers lt ON lt.id = ltt.laying_transfer_id").
Joins("LEFT JOIN laying_transfer_sources lts ON lts.laying_transfer_id = lt.id"). Joins("LEFT JOIN product_warehouses pw_source ON pw_source.id = lt.source_product_warehouse_id").
Joins("LEFT JOIN product_warehouses pw_source ON pw_source.id = lts.product_warehouse_id").
Joins("LEFT JOIN warehouses w_source ON w_source.id = pw_source.warehouse_id"). Joins("LEFT JOIN warehouses w_source ON w_source.id = pw_source.warehouse_id").
Joins("JOIN product_warehouses pw ON pw.id = ltt.product_warehouse_id"). Joins("JOIN product_warehouses pw ON pw.id = ltt.product_warehouse_id").
Joins("JOIN warehouses w ON w.id = pw.warehouse_id"). Joins("JOIN warehouses w ON w.id = pw.warehouse_id").
@@ -1327,6 +1328,7 @@ func (r *ClosingRepositoryImpl) FetchSapronakTransfers(ctx context.Context, kand
Joins("LEFT JOIN warehouses w_dest ON w_dest.id = pw_dest.warehouse_id"). Joins("LEFT JOIN warehouses w_dest ON w_dest.id = pw_dest.warehouse_id").
Joins("JOIN products p ON p.id = std.product_id"). Joins("JOIN products p ON p.id = std.product_id").
Where("sa.status = ?", entity.StockAllocationStatusActive). Where("sa.status = ?", entity.StockAllocationStatusActive).
Where("sa.allocation_purpose = ?", entity.StockAllocationPurposeConsume).
Where("w.kandang_id = ?", kandangID). Where("w.kandang_id = ?", kandangID).
Where("(w_dest.kandang_id IS NULL OR w_dest.kandang_id <> w.kandang_id)"). Where("(w_dest.kandang_id IS NULL OR w_dest.kandang_id <> w.kandang_id)").
Where("f.name IN ?", sapronakFlagsAll). Where("f.name IN ?", sapronakFlagsAll).
@@ -1349,8 +1351,7 @@ func (r *ClosingRepositoryImpl) FetchSapronakTransfers(ctx context.Context, kand
COALESCE(SUM(sa.qty), 0) AS qty_out, COALESCE(SUM(sa.qty), 0) AS qty_out,
COALESCE(p.product_price, 0) AS price COALESCE(p.product_price, 0) AS price
`). `).
Joins("JOIN laying_transfer_sources lts ON lts.id = sa.usable_id AND sa.usable_type = ?", fifo.UsableKeyTransferToLayingOut.String()). Joins("JOIN laying_transfers lt ON lt.id = sa.usable_id AND sa.usable_type = ?", fifo.UsableKeyTransferToLayingOut.String()).
Joins("JOIN laying_transfers lt ON lt.id = lts.laying_transfer_id").
Joins("LEFT JOIN laying_transfer_targets ltt ON ltt.laying_transfer_id = lt.id"). Joins("LEFT JOIN laying_transfer_targets ltt ON ltt.laying_transfer_id = lt.id").
Joins("LEFT JOIN product_warehouses pw_dest ON pw_dest.id = ltt.product_warehouse_id"). Joins("LEFT JOIN product_warehouses pw_dest ON pw_dest.id = ltt.product_warehouse_id").
Joins("LEFT JOIN warehouses w_dest ON w_dest.id = pw_dest.warehouse_id"). Joins("LEFT JOIN warehouses w_dest ON w_dest.id = pw_dest.warehouse_id").
@@ -1358,10 +1359,11 @@ func (r *ClosingRepositoryImpl) FetchSapronakTransfers(ctx context.Context, kand
Joins("JOIN warehouses w ON w.id = pw.warehouse_id"). Joins("JOIN warehouses w ON w.id = pw.warehouse_id").
Joins("JOIN products p ON p.id = pw.product_id"). Joins("JOIN products p ON p.id = pw.product_id").
Where("sa.status = ?", entity.StockAllocationStatusActive). Where("sa.status = ?", entity.StockAllocationStatusActive).
Where("sa.allocation_purpose = ?", entity.StockAllocationPurposeConsume).
Where("w.kandang_id = ?", kandangID). Where("w.kandang_id = ?", kandangID).
Where("(w_dest.kandang_id IS NULL OR w_dest.kandang_id <> w.kandang_id)"). Where("(w_dest.kandang_id IS NULL OR w_dest.kandang_id <> w.kandang_id)").
Where("f.name IN ?", sapronakFlagsAll). Where("f.name IN ?", sapronakFlagsAll).
Group("lts.id, pw.product_id, p.name, f.name, lt.transfer_date, lt.transfer_number, p.product_price") Group("lt.id, pw.product_id, p.name, f.name, lt.transfer_date, lt.transfer_number, p.product_price")
outgoingLayingQuery = r.joinSapronakProductFlag(outgoingLayingQuery, "p") outgoingLayingQuery = r.joinSapronakProductFlag(outgoingLayingQuery, "p")
outgoingLaying, err := scanAndGroupDetails(outgoingLayingQuery) outgoingLaying, err := scanAndGroupDetails(outgoingLayingQuery)
if err != nil { if err != nil {
@@ -1393,6 +1395,7 @@ func (r *ClosingRepositoryImpl) FetchSapronakSales(ctx context.Context, projectF
Joins("JOIN product_warehouses pw ON pw.id = sa.product_warehouse_id"). Joins("JOIN product_warehouses pw ON pw.id = sa.product_warehouse_id").
Joins("JOIN products p ON p.id = pw.product_id"). Joins("JOIN products p ON p.id = pw.product_id").
Where("sa.status = ?", entity.StockAllocationStatusActive). Where("sa.status = ?", entity.StockAllocationStatusActive).
Where("sa.allocation_purpose = ?", entity.StockAllocationPurposeConsume).
Where("pw.project_flock_kandang_id = ?", projectFlockKandangID). Where("pw.project_flock_kandang_id = ?", projectFlockKandangID).
Where("f.name IN ?", sapronakFlagsAll). 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") 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 +1422,10 @@ func (r *ClosingRepositoryImpl) FetchSapronakSales(ctx context.Context, projectF
Joins("JOIN marketings m ON m.id = mp.marketing_id"). Joins("JOIN marketings m ON m.id = mp.marketing_id").
Joins("JOIN product_warehouses pw ON pw.id = mp.product_warehouse_id"). Joins("JOIN product_warehouses pw ON pw.id = mp.product_warehouse_id").
Joins("JOIN products p ON p.id = pw.product_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(), fifo.UsableKeyMarketingDelivery.String(),
entity.StockAllocationStatusActive, entity.StockAllocationStatusActive,
entity.StockAllocationPurposeConsume,
). ).
Where("mdp.usage_qty > 0"). Where("mdp.usage_qty > 0").
Where("sa.id IS NULL"). Where("sa.id IS NULL").
@@ -1481,6 +1485,7 @@ func (r *ClosingRepositoryImpl) FetchSapronakSalesAllocatedDetails(ctx context.C
Joins("LEFT JOIN laying_transfers lt ON lt.id = ltt.laying_transfer_id"). 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()). 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.status = ?", entity.StockAllocationStatusActive).
Where("sa.allocation_purpose = ?", entity.StockAllocationPurposeConsume).
Where("sa.stockable_type <> ?", fifo.StockableKeyProjectFlockPopulation.String()). Where("sa.stockable_type <> ?", fifo.StockableKeyProjectFlockPopulation.String()).
Where("pw.project_flock_kandang_id = ?", projectFlockKandangID). Where("pw.project_flock_kandang_id = ?", projectFlockKandangID).
Where("f.name IN ?", sapronakFlagsAll). Where("f.name IN ?", sapronakFlagsAll).
@@ -32,6 +32,44 @@ func (r *ConstantRepositoryImpl) GetConstants() (map[string]interface{}, error)
} }
sort.Strings(flagList) sort.Strings(flagList)
productMainFlags := utils.ProductMainFlags()
productMainFlagValues := make([]string, len(productMainFlags))
for i, flag := range productMainFlags {
productMainFlagValues[i] = string(flag)
}
type productFlagOption struct {
Flag string `json:"flag"`
SubFlags []string `json:"sub_flags"`
AllowWithoutSubFlag bool `json:"allow_without_sub_flag"`
}
productOptions := utils.ProductFlagOptions()
productFlagOptions := make([]productFlagOption, 0, len(productOptions))
for _, option := range productOptions {
subFlags := make([]string, len(option.SubFlags))
for i, subFlag := range option.SubFlags {
subFlags[i] = string(subFlag)
}
productFlagOptions = append(productFlagOptions, productFlagOption{
Flag: string(option.Flag),
SubFlags: subFlags,
AllowWithoutSubFlag: option.AllowWithoutSubFlag,
})
}
productSubFlagToFlagRaw := utils.ProductSubFlagToFlag()
productSubFlagToFlag := make(map[string]string, len(productSubFlagToFlagRaw))
for subFlag, flag := range productSubFlagToFlagRaw {
productSubFlagToFlag[string(subFlag)] = string(flag)
}
legacyAliasesRaw := utils.LegacyFlagTypeAliases()
legacyAliases := make(map[string]string, len(legacyAliasesRaw))
for legacy, canonical := range legacyAliasesRaw {
legacyAliases[string(legacy)] = string(canonical)
}
type approvalStepConstant struct { type approvalStepConstant struct {
StepNumber uint16 `json:"step_number"` StepNumber uint16 `json:"step_number"`
StepName string `json:"step_name"` StepName string `json:"step_name"`
@@ -96,9 +134,15 @@ func (r *ConstantRepositoryImpl) GetConstants() (map[string]interface{}, error)
"BISNIS", "BISNIS",
"INDIVIDUAL", "INDIVIDUAL",
}, },
"adjustment": map[string]interface{}{ "adjustment": map[string]interface{}{
"transaction_subtypes": adjustmentSubtypesByType, "transaction_subtypes": adjustmentSubtypesByType,
}, },
"approval_workflows": approvalWorkflows, "legacy_flag_aliases": legacyAliases,
}, nil "product_flag_mapping": map[string]interface{}{
"flags": productMainFlagValues,
"options": productFlagOptions,
"sub_flag_to_flag": productSubFlagToFlag,
},
"approval_workflows": approvalWorkflows,
}, nil
} }
@@ -103,3 +103,22 @@ func (u *AdjustmentController) GetOne(c *fiber.Ctx) error {
Data: dto.ToAdjustmentDetailDTO(stockLog), Data: dto.ToAdjustmentDetailDTO(stockLog),
}) })
} }
func (u *AdjustmentController) DeleteOne(c *fiber.Ctx) error {
param := c.Params("id")
id, err := strconv.Atoi(param)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, "Invalid Id")
}
if err := u.AdjustmentService.DeleteOne(c, uint(id)); err != nil {
return err
}
return c.Status(fiber.StatusOK).
JSON(response.Common{
Code: fiber.StatusOK,
Status: "success",
Message: "Delete adjustment successfully",
})
}
@@ -5,7 +5,6 @@ import (
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
"gorm.io/gorm" "gorm.io/gorm"
commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository"
commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service"
rAdjustmentStock "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/adjustments/repositories" rAdjustmentStock "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/adjustments/repositories"
sAdjustment "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/adjustments/services" sAdjustment "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/adjustments/services"
@@ -17,7 +16,6 @@ import (
rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories"
sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services"
"gitlab.com/mbugroup/lti-api.git/internal/utils" "gitlab.com/mbugroup/lti-api.git/internal/utils"
"gitlab.com/mbugroup/lti-api.git/internal/utils/fifo"
) )
type AdjustmentModule struct{} type AdjustmentModule struct{}
@@ -28,56 +26,22 @@ func (AdjustmentModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validat
warehouseRepo := rWarehouse.NewWarehouseRepository(db) warehouseRepo := rWarehouse.NewWarehouseRepository(db)
productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db) productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db)
projectFlockKandangRepo := rProjectFlockKandang.NewProjectFlockKandangRepository(db) projectFlockKandangRepo := rProjectFlockKandang.NewProjectFlockKandangRepository(db)
projectFlockPopulationRepo := rProjectFlockKandang.NewProjectFlockPopulationRepository(db)
userRepo := rUser.NewUserRepository(db) userRepo := rUser.NewUserRepository(db)
productRepo := rproduct.NewProductRepository(db) productRepo := rproduct.NewProductRepository(db)
adjustmentStockRepo := rAdjustmentStock.NewAdjustmentStockRepository(db) adjustmentStockRepo := rAdjustmentStock.NewAdjustmentStockRepository(db)
stockAllocRepo := commonRepo.NewStockAllocationRepository(db)
fifoService := commonSvc.NewFifoService(db, stockAllocRepo, productWarehouseRepo, utils.Log)
fifoStockV2Service := commonSvc.NewFifoStockV2Service(db, utils.Log) fifoStockV2Service := commonSvc.NewFifoStockV2Service(db, utils.Log)
err := fifoService.RegisterStockable(fifo.StockableConfig{
Key: fifo.StockableKeyAdjustmentIn,
Table: "adjustment_stocks",
Columns: fifo.StockableColumns{
ID: "id",
ProductWarehouseID: "product_warehouse_id",
TotalQuantity: "total_qty",
TotalUsedQuantity: "total_used",
CreatedAt: "created_at",
},
OrderBy: []string{"created_at ASC", "id ASC"},
})
if err != nil {
panic("Failed to register ADJUSTMENT_IN as Stockable: " + err.Error())
}
err = fifoService.RegisterUsable(fifo.UsableConfig{
Key: fifo.UsableKeyAdjustmentOut,
Table: "adjustment_stocks",
Columns: fifo.UsableColumns{
ID: "id",
ProductWarehouseID: "product_warehouse_id",
UsageQuantity: "usage_qty",
PendingQuantity: "pending_qty",
CreatedAt: "created_at",
},
OrderBy: []string{"created_at ASC", "id ASC"},
})
if err != nil {
panic("Failed to register ADJUSTMENT_OUT as Usable: " + err.Error())
}
adjustmentService := sAdjustment.NewAdjustmentService( adjustmentService := sAdjustment.NewAdjustmentService(
productRepo, productRepo,
stockLogsRepo, stockLogsRepo,
warehouseRepo, warehouseRepo,
productWarehouseRepo, productWarehouseRepo,
adjustmentStockRepo, adjustmentStockRepo,
fifoService,
fifoStockV2Service, fifoStockV2Service,
validate, validate,
projectFlockKandangRepo, projectFlockKandangRepo,
projectFlockPopulationRepo,
) )
userService := sUser.NewUserService(userRepo, validate) userService := sUser.NewUserService(userRepo, validate)
@@ -2,12 +2,12 @@ package repositories
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"strconv" "strconv"
"strings" "strings"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities" entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/clause" "gorm.io/gorm/clause"
) )
@@ -15,9 +15,19 @@ import (
type AdjustmentStockRepository interface { type AdjustmentStockRepository interface {
CreateOne(ctx context.Context, data *entity.AdjustmentStock, modifier func(*gorm.DB) *gorm.DB) error CreateOne(ctx context.Context, data *entity.AdjustmentStock, modifier func(*gorm.DB) *gorm.DB) error
GetByID(ctx context.Context, id uint, modifier func(*gorm.DB) *gorm.DB) (*entity.AdjustmentStock, error) GetByID(ctx context.Context, id uint, modifier func(*gorm.DB) *gorm.DB) (*entity.AdjustmentStock, error)
GetByIDForUpdate(ctx context.Context, id uint) (*entity.AdjustmentStock, error)
FindKandangIDByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint) (uint, error) FindKandangIDByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint) (uint, error)
FindProductIDByProductWarehouseID(ctx context.Context, productWarehouseID uint) (uint, error)
FindRoutesByFunctionCode(ctx context.Context, productID uint, functionCode string) ([]AdjustmentRouteResolution, error) FindRoutesByFunctionCode(ctx context.Context, productID uint, functionCode string) ([]AdjustmentRouteResolution, error)
FindOverconsumeRule(ctx context.Context, lane, flagGroupCode, functionCode string) (*bool, error) LoadDownstreamDependencies(ctx context.Context, stockableType string, stockableIDs []uint) ([]AdjustmentDownstreamDependency, error)
FindAyamSourceProductWarehouse(ctx context.Context, warehouseID uint, projectFlockKandangID uint) (*entity.ProductWarehouse, error)
IsAyamProduct(ctx context.Context, productID uint) (bool, error)
CountActiveConsumeAllocationsByUsable(ctx context.Context, usableType string, usableID uint) (int64, error)
UpdateTotalQty(ctx context.Context, id uint, qty float64) error
UpdatePairedAdjustmentID(ctx context.Context, id uint, pairedID uint) error
DeleteStockLogsByAdjustmentID(ctx context.Context, adjustmentID uint) error
DeleteAdjustmentByID(ctx context.Context, id uint) error
ResyncProjectFlockPopulationUsage(ctx context.Context, projectFlockKandangID uint) error
FindHistory(ctx context.Context, filter AdjustmentHistoryFilter, modifier func(*gorm.DB) *gorm.DB) ([]*entity.AdjustmentStock, int64, error) FindHistory(ctx context.Context, filter AdjustmentHistoryFilter, modifier func(*gorm.DB) *gorm.DB) ([]*entity.AdjustmentStock, int64, error)
WithTx(tx *gorm.DB) AdjustmentStockRepository WithTx(tx *gorm.DB) AdjustmentStockRepository
DB() *gorm.DB DB() *gorm.DB
@@ -44,6 +54,13 @@ type AdjustmentHistoryFilter struct {
Limit int Limit int
} }
type AdjustmentDownstreamDependency struct {
UsableType string `gorm:"column:usable_type"`
UsableID uint64 `gorm:"column:usable_id"`
FunctionCode string `gorm:"column:function_code"`
FlagGroupCode string `gorm:"column:flag_group_code"`
}
type adjustmentStockRepositoryImpl struct { type adjustmentStockRepositoryImpl struct {
db *gorm.DB db *gorm.DB
} }
@@ -73,6 +90,17 @@ func (r *adjustmentStockRepositoryImpl) GetByID(ctx context.Context, id uint, mo
return &record, nil return &record, nil
} }
func (r *adjustmentStockRepositoryImpl) GetByIDForUpdate(ctx context.Context, id uint) (*entity.AdjustmentStock, error) {
var record entity.AdjustmentStock
if err := r.db.WithContext(ctx).
Clauses(clause.Locking{Strength: "UPDATE"}).
Where("id = ?", id).
Take(&record).Error; err != nil {
return nil, err
}
return &record, nil
}
func (r *adjustmentStockRepositoryImpl) FindKandangIDByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint) (uint, error) { func (r *adjustmentStockRepositoryImpl) FindKandangIDByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint) (uint, error) {
type pfkRow struct { type pfkRow struct {
KandangID uint `gorm:"column:kandang_id"` KandangID uint `gorm:"column:kandang_id"`
@@ -91,6 +119,21 @@ func (r *adjustmentStockRepositoryImpl) FindKandangIDByProjectFlockKandangID(ctx
return pfk.KandangID, nil return pfk.KandangID, nil
} }
func (r *adjustmentStockRepositoryImpl) FindProductIDByProductWarehouseID(ctx context.Context, productWarehouseID uint) (uint, error) {
type productRow struct {
ProductID uint `gorm:"column:product_id"`
}
var row productRow
if err := r.db.WithContext(ctx).
Table("product_warehouses").
Select("product_id").
Where("id = ?", productWarehouseID).
Take(&row).Error; err != nil {
return 0, err
}
return row.ProductID, nil
}
func (r *adjustmentStockRepositoryImpl) FindRoutesByFunctionCode( func (r *adjustmentStockRepositoryImpl) FindRoutesByFunctionCode(
ctx context.Context, ctx context.Context,
productID uint, productID uint,
@@ -122,37 +165,183 @@ func (r *adjustmentStockRepositoryImpl) FindRoutesByFunctionCode(
return rows, nil return rows, nil
} }
func (r *adjustmentStockRepositoryImpl) FindOverconsumeRule( func (r *adjustmentStockRepositoryImpl) LoadDownstreamDependencies(
ctx context.Context, ctx context.Context,
lane string, stockableType string,
flagGroupCode string, stockableIDs []uint,
functionCode string, ) ([]AdjustmentDownstreamDependency, error) {
) (*bool, error) { if strings.TrimSpace(stockableType) == "" || len(stockableIDs) == 0 {
type selectedRow struct { return nil, nil
AllowOverconsume bool `gorm:"column:allow_overconsume"`
} }
var selected selectedRow var rows []AdjustmentDownstreamDependency
err := r.db.WithContext(ctx). err := r.db.WithContext(ctx).
Table("fifo_stock_v2_overconsume_rules"). Table("stock_allocations").
Select("allow_overconsume"). Select("usable_type, usable_id, COALESCE(function_code,'') AS function_code, COALESCE(flag_group_code,'') AS flag_group_code").
Where("is_active = TRUE"). Where("stockable_type = ?", strings.ToUpper(strings.TrimSpace(stockableType))).
Where("lane = ?", lane). Where("stockable_id IN ?", stockableIDs).
Where("(flag_group_code IS NULL OR flag_group_code = ?)", flagGroupCode). Where("status = ?", entity.StockAllocationStatusActive).
Where("(function_code IS NULL OR function_code = ?)", functionCode). Where("allocation_purpose = ?", entity.StockAllocationPurposeConsume).
Order("CASE WHEN flag_group_code IS NULL THEN 1 ELSE 0 END ASC"). Where("deleted_at IS NULL").
Order("CASE WHEN function_code IS NULL THEN 1 ELSE 0 END ASC"). Where(
Order("priority ASC, id ASC"). "(usable_type <> ? OR EXISTS (SELECT 1 FROM project_chickins pc WHERE pc.id = stock_allocations.usable_id AND pc.deleted_at IS NULL))",
Limit(1). "PROJECT_CHICKIN",
Take(&selected).Error ).
Group("usable_type, usable_id, function_code, flag_group_code").
Scan(&rows).Error
if err != nil { if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err return nil, err
} }
return &selected.AllowOverconsume, nil return rows, nil
}
func (r *adjustmentStockRepositoryImpl) FindAyamSourceProductWarehouse(
ctx context.Context,
warehouseID uint,
projectFlockKandangID uint,
) (*entity.ProductWarehouse, error) {
var sourcePW entity.ProductWarehouse
err := r.db.WithContext(ctx).
Model(&entity.ProductWarehouse{}).
Where("project_flock_kandang_id = ?", projectFlockKandangID).
Where(`
EXISTS (
SELECT 1
FROM flags f
JOIN fifo_stock_v2_flag_members fm ON fm.flag_name = f.name AND fm.is_active = TRUE
WHERE f.flagable_type = ?
AND f.flagable_id = product_warehouses.product_id
AND fm.flag_group_code = ?
)
`, entity.FlagableTypeProduct, "AYAM").
Order(gorm.Expr("CASE WHEN warehouse_id = ? THEN 0 ELSE 1 END ASC", warehouseID)).
Order("id ASC").
Take(&sourcePW).Error
if err != nil {
return nil, err
}
return &sourcePW, nil
}
func (r *adjustmentStockRepositoryImpl) IsAyamProduct(ctx context.Context, productID uint) (bool, error) {
if productID == 0 {
return false, nil
}
var count int64
if err := r.db.WithContext(ctx).
Table("flags f").
Joins("JOIN fifo_stock_v2_flag_members fm ON fm.flag_name = f.name AND fm.flag_group_code = ? AND fm.is_active = TRUE", "AYAM").
Where("f.flagable_type = ?", entity.FlagableTypeProduct).
Where("f.flagable_id = ?", productID).
Count(&count).Error; err != nil {
return false, err
}
return count > 0, nil
}
func (r *adjustmentStockRepositoryImpl) CountActiveConsumeAllocationsByUsable(
ctx context.Context,
usableType string,
usableID uint,
) (int64, error) {
if strings.TrimSpace(usableType) == "" || usableID == 0 {
return 0, nil
}
var count int64
err := r.db.WithContext(ctx).
Table("stock_allocations").
Where("usable_type = ?", strings.ToUpper(strings.TrimSpace(usableType))).
Where("usable_id = ?", usableID).
Where("status = ?", entity.StockAllocationStatusActive).
Where("allocation_purpose = ?", entity.StockAllocationPurposeConsume).
Where("deleted_at IS NULL").
Count(&count).Error
if err != nil {
return 0, err
}
return count, nil
}
func (r *adjustmentStockRepositoryImpl) UpdateTotalQty(ctx context.Context, id uint, qty float64) error {
return r.db.WithContext(ctx).
Model(&entity.AdjustmentStock{}).
Where("id = ?", id).
Update("total_qty", qty).Error
}
func (r *adjustmentStockRepositoryImpl) UpdatePairedAdjustmentID(ctx context.Context, id uint, pairedID uint) error {
return r.db.WithContext(ctx).
Model(&entity.AdjustmentStock{}).
Where("id = ?", id).
Update("paired_adjustment_id", pairedID).Error
}
func (r *adjustmentStockRepositoryImpl) DeleteStockLogsByAdjustmentID(ctx context.Context, adjustmentID uint) error {
return r.db.WithContext(ctx).
Where("loggable_type = ? AND loggable_id = ?", string(utils.StockLogTypeAdjustment), adjustmentID).
Delete(&entity.StockLog{}).Error
}
func (r *adjustmentStockRepositoryImpl) DeleteAdjustmentByID(ctx context.Context, id uint) error {
return r.db.WithContext(ctx).
Where("id = ?", id).
Delete(&entity.AdjustmentStock{}).Error
}
func (r *adjustmentStockRepositoryImpl) ResyncProjectFlockPopulationUsage(ctx context.Context, 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 err := db.Exec(updateWithAlloc, projectFlockKandangID).Error; err != nil {
return err
}
if err := db.Exec(resetMissing, projectFlockKandangID).Error; err != nil {
return err
}
return nil
} }
func (r *adjustmentStockRepositoryImpl) FindHistory( func (r *adjustmentStockRepositoryImpl) FindHistory(
@@ -15,8 +15,9 @@ func AdjustmentRoutes(v1 fiber.Router, u user.UserService, s adjustment.Adjustme
route := v1.Group("/adjustments") route := v1.Group("/adjustments")
route.Use(m.Auth(u)) route.Use(m.Auth(u))
// Standard CRUD routes following master data pattern // Standard CRUD routes following master data pattern
route.Get("/",m.RequirePermissions(m.P_AdjustmentGetAll), ctrl.AdjustmentHistory) // Get all with pagination and filters route.Get("/", m.RequirePermissions(m.P_AdjustmentGetAll), ctrl.AdjustmentHistory) // Get all with pagination and filters
route.Post("/",m.RequirePermissions(m.P_AdjustmentCreate), ctrl.Adjustment) // Create adjustment route.Post("/", m.RequirePermissions(m.P_AdjustmentCreate), ctrl.Adjustment) // Create adjustment
route.Get("/:id",m.RequirePermissions(m.P_AdjustmentGetOne), ctrl.GetOne) route.Get("/:id", m.RequirePermissions(m.P_AdjustmentGetOne), ctrl.GetOne)
route.Delete("/:id", m.RequirePermissions(m.P_AdjustmentDeleteOne), ctrl.DeleteOne)
} }
@@ -5,12 +5,14 @@ import (
"errors" "errors"
"fmt" "fmt"
"math" "math"
"sort"
"strings" "strings"
"github.com/go-playground/validator/v10" "github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
common "gitlab.com/mbugroup/lti-api.git/internal/common/service" common "gitlab.com/mbugroup/lti-api.git/internal/common/service"
fifoV2 "gitlab.com/mbugroup/lti-api.git/internal/common/service/fifo_stock_v2"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities" entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
m "gitlab.com/mbugroup/lti-api.git/internal/middleware" m "gitlab.com/mbugroup/lti-api.git/internal/middleware"
adjustmentStockRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/adjustments/repositories" adjustmentStockRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/adjustments/repositories"
@@ -28,20 +30,21 @@ import (
type AdjustmentService interface { type AdjustmentService interface {
Adjustment(ctx *fiber.Ctx, req *validation.Create) (*entity.AdjustmentStock, error) Adjustment(ctx *fiber.Ctx, req *validation.Create) (*entity.AdjustmentStock, error)
GetOne(ctx *fiber.Ctx, id uint) (*entity.AdjustmentStock, error) GetOne(ctx *fiber.Ctx, id uint) (*entity.AdjustmentStock, error)
DeleteOne(ctx *fiber.Ctx, id uint) error
AdjustmentHistory(ctx *fiber.Ctx, query *validation.Query) ([]*entity.AdjustmentStock, int64, error) AdjustmentHistory(ctx *fiber.Ctx, query *validation.Query) ([]*entity.AdjustmentStock, int64, error)
} }
type adjustmentService struct { type adjustmentService struct {
Log *logrus.Logger Log *logrus.Logger
Validate *validator.Validate Validate *validator.Validate
StockLogsRepository stockLogsRepo.StockLogRepository StockLogsRepository stockLogsRepo.StockLogRepository
WarehouseRepo warehouseRepo.WarehouseRepository WarehouseRepo warehouseRepo.WarehouseRepository
ProductWarehouseRepo ProductWarehouse.ProductWarehouseRepository ProductWarehouseRepo ProductWarehouse.ProductWarehouseRepository
ProductRepo productRepo.ProductRepository ProductRepo productRepo.ProductRepository
ProjectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository ProjectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository
AdjustmentStockRepository adjustmentStockRepo.AdjustmentStockRepository ProjectFlockPopulationRepo projectFlockKandangRepo.ProjectFlockPopulationRepository
FifoSvc common.FifoService AdjustmentStockRepository adjustmentStockRepo.AdjustmentStockRepository
FifoStockV2Svc common.FifoStockV2Service FifoStockV2Svc common.FifoStockV2Service
} }
const ( const (
@@ -55,42 +58,40 @@ func NewAdjustmentService(
warehouseRepo warehouseRepo.WarehouseRepository, warehouseRepo warehouseRepo.WarehouseRepository,
productWarehouseRepo ProductWarehouse.ProductWarehouseRepository, productWarehouseRepo ProductWarehouse.ProductWarehouseRepository,
adjustmentStockRepo adjustmentStockRepo.AdjustmentStockRepository, adjustmentStockRepo adjustmentStockRepo.AdjustmentStockRepository,
fifoSvc common.FifoService,
fifoStockV2Svc common.FifoStockV2Service, fifoStockV2Svc common.FifoStockV2Service,
validate *validator.Validate, validate *validator.Validate,
projectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository, projectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository,
projectFlockPopulationRepo projectFlockKandangRepo.ProjectFlockPopulationRepository,
) AdjustmentService { ) AdjustmentService {
return &adjustmentService{ return &adjustmentService{
Log: utils.Log, Log: utils.Log,
Validate: validate, Validate: validate,
StockLogsRepository: stockLogsRepo, StockLogsRepository: stockLogsRepo,
WarehouseRepo: warehouseRepo, WarehouseRepo: warehouseRepo,
ProductWarehouseRepo: productWarehouseRepo, ProductWarehouseRepo: productWarehouseRepo,
ProductRepo: productRepo, ProductRepo: productRepo,
ProjectFlockKandangRepo: projectFlockKandangRepo, ProjectFlockKandangRepo: projectFlockKandangRepo,
AdjustmentStockRepository: adjustmentStockRepo, ProjectFlockPopulationRepo: projectFlockPopulationRepo,
FifoSvc: fifoSvc, AdjustmentStockRepository: adjustmentStockRepo,
FifoStockV2Svc: fifoStockV2Svc, FifoStockV2Svc: fifoStockV2Svc,
} }
} }
func (s *adjustmentService) withRelations(db *gorm.DB) *gorm.DB {
return db.
Preload("ProductWarehouse").
Preload("ProductWarehouse.Product").
Preload("ProductWarehouse.Warehouse").
Preload("ProductWarehouse.Warehouse.Location").
Preload("ProductWarehouse.ProjectFlockKandang").
Preload("ProductWarehouse.ProjectFlockKandang.ProjectFlock").
Preload("StockLog.CreatedUser")
}
func (s *adjustmentService) GetOne(c *fiber.Ctx, id uint) (*entity.AdjustmentStock, error) { func (s *adjustmentService) GetOne(c *fiber.Ctx, id uint) (*entity.AdjustmentStock, error) {
if err := m.EnsureStockLogAccess(c, s.StockLogsRepository.DB(), id); err != nil { if err := m.EnsureStockLogAccess(c, s.StockLogsRepository.DB(), id); err != nil {
return nil, err return nil, err
} }
adjustmentStock, err := s.AdjustmentStockRepository.GetByID(c.Context(), id, s.withRelations) adjustmentStock, err := s.AdjustmentStockRepository.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB {
return db.
Preload("ProductWarehouse").
Preload("ProductWarehouse.Product").
Preload("ProductWarehouse.Warehouse").
Preload("ProductWarehouse.Warehouse.Location").
Preload("ProductWarehouse.ProjectFlockKandang").
Preload("ProductWarehouse.ProjectFlockKandang.ProjectFlock").
Preload("StockLog.CreatedUser")
})
if err != nil { if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusNotFound, "Adjustment not found") return nil, fiber.NewError(fiber.StatusNotFound, "Adjustment not found")
@@ -102,6 +103,250 @@ func (s *adjustmentService) GetOne(c *fiber.Ctx, id uint) (*entity.AdjustmentSto
return adjustmentStock, nil return adjustmentStock, nil
} }
func (s *adjustmentService) DeleteOne(c *fiber.Ctx, id uint) error {
if id == 0 {
return fiber.NewError(fiber.StatusBadRequest, "Invalid adjustment id")
}
if s.FifoStockV2Svc == nil {
return fiber.NewError(fiber.StatusInternalServerError, "FIFO v2 service is not available")
}
if err := m.EnsureStockLogAccess(c, s.StockLogsRepository.DB(), id); err != nil {
return err
}
ctx := c.Context()
actorID, err := m.ActorIDFromContext(c)
if err != nil {
return err
}
return s.StockLogsRepository.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
adjustments, err := s.collectAdjustmentsForDelete(ctx, tx, id)
if err != nil {
return err
}
for _, item := range adjustments {
if err := s.deleteSingleAdjustmentInTx(ctx, tx, item, actorID); err != nil {
return err
}
}
return nil
})
}
func (s *adjustmentService) collectAdjustmentsForDelete(ctx context.Context, tx *gorm.DB, id uint) ([]entity.AdjustmentStock, error) {
repoTx := s.AdjustmentStockRepository.WithTx(tx)
adjustment, err := repoTx.GetByIDForUpdate(ctx, id)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusNotFound, "Adjustment not found")
}
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to load adjustment")
}
adjustments := []entity.AdjustmentStock{*adjustment}
leftPairCode := utils.NormalizeUpper(adjustment.FunctionCode)
isDepletionCode := leftPairCode == string(utils.AdjustmentTransactionSubtypeRecordingDepletionIn) ||
leftPairCode == string(utils.AdjustmentTransactionSubtypeRecordingDepletionOut)
if !isDepletionCode {
return adjustments, nil
}
if adjustment.PairedAdjustmentId == nil || *adjustment.PairedAdjustmentId == 0 {
return nil, fiber.NewError(
fiber.StatusBadRequest,
"Adjustment depletion tidak memiliki pasangan valid. Data harus diperbaiki terlebih dahulu untuk mencegah orphan.",
)
}
pair, err := repoTx.GetByIDForUpdate(ctx, *adjustment.PairedAdjustmentId)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(
fiber.StatusBadRequest,
fmt.Sprintf("Pasangan adjustment depletion (%d) tidak ditemukan. Data harus diperbaiki terlebih dahulu untuk mencegah orphan.", *adjustment.PairedAdjustmentId),
)
}
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to load paired adjustment")
}
rightPairCode := utils.NormalizeUpper(pair.FunctionCode)
isPairDepletionCode := rightPairCode == string(utils.AdjustmentTransactionSubtypeRecordingDepletionIn) ||
rightPairCode == string(utils.AdjustmentTransactionSubtypeRecordingDepletionOut)
if !isPairDepletionCode {
return nil, fiber.NewError(
fiber.StatusBadRequest,
fmt.Sprintf("Pasangan adjustment %d bukan depletion pair yang valid", pair.Id),
)
}
if pair.PairedAdjustmentId == nil || *pair.PairedAdjustmentId != adjustment.Id {
return nil, fiber.NewError(
fiber.StatusBadRequest,
fmt.Sprintf("Pasangan adjustment depletion tidak konsisten (%d <-> %d). Perbaiki pairing terlebih dahulu.", adjustment.Id, pair.Id),
)
}
isValidPair := (leftPairCode == string(utils.AdjustmentTransactionSubtypeRecordingDepletionIn) &&
rightPairCode == string(utils.AdjustmentTransactionSubtypeRecordingDepletionOut)) ||
(leftPairCode == string(utils.AdjustmentTransactionSubtypeRecordingDepletionOut) &&
rightPairCode == string(utils.AdjustmentTransactionSubtypeRecordingDepletionIn))
if !isValidPair {
return nil, fiber.NewError(
fiber.StatusBadRequest,
fmt.Sprintf("Pasangan function_code depletion tidak valid (%s <-> %s)", adjustment.FunctionCode, pair.FunctionCode),
)
}
adjustments = append(adjustments, *pair)
sort.Slice(adjustments, func(i, j int) bool {
return adjustments[i].Id < adjustments[j].Id
})
return adjustments, nil
}
func (s *adjustmentService) deleteSingleAdjustmentInTx(
ctx context.Context,
tx *gorm.DB,
adjustment entity.AdjustmentStock,
actorID uint,
) error {
repoTx := s.AdjustmentStockRepository.WithTx(tx)
productID, err := repoTx.FindProductIDByProductWarehouseID(ctx, adjustment.ProductWarehouseId)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to load product warehouse context")
}
routeMeta, err := s.resolveRouteByFunctionCode(ctx, productID, adjustment.FunctionCode)
if err != nil {
return err
}
isAyamProduct, err := repoTx.IsAyamProduct(ctx, productID)
if err != nil {
s.Log.Errorf("Failed to resolve AYAM flag for product %d: %+v", productID, err)
return fiber.NewError(fiber.StatusInternalServerError, "Failed to validate product flag")
}
stockLogRepoTx := stockLogsRepo.NewStockLogRepository(tx)
notes := fmt.Sprintf("ADJUSTMENT DELETE#%s", utils.NormalizeTrim(adjustment.AdjNumber))
switch routeMeta.Lane {
case adjustmentLaneStockable:
deps, allowPending, err := s.resolveAdjustmentDependenciesAndPolicy(
ctx,
tx,
fifo.StockableKeyAdjustmentIn.String(),
[]uint{adjustment.Id},
)
if err != nil {
return err
}
if len(deps) > 0 && isAyamProduct {
return fiber.NewError(
fiber.StatusBadRequest,
fmt.Sprintf(
"Adjustment tidak dapat dihapus karena produk AYAM sudah dipakai transaksi turunan. Dependensi aktif: %s. Alasan block: produk AYAM yang sudah terpakai tidak dapat dihapus.",
formatAdjustmentDependencySummary(deps),
),
)
}
if len(deps) > 0 && !allowPending {
return fiber.NewError(
fiber.StatusBadRequest,
fmt.Sprintf(
"Adjustment tidak dapat dihapus karena stok adjustment sudah dipakai transaksi turunan. Dependensi aktif: %s. Alasan block: pending disabled by config.",
formatAdjustmentDependencySummary(deps),
),
)
}
oldQty := adjustment.TotalQty
if oldQty > 0 {
if err := repoTx.UpdateTotalQty(ctx, adjustment.Id, 0); err != nil {
return err
}
asOf := adjustment.CreatedAt
if _, err := s.FifoStockV2Svc.Reflow(ctx, common.FifoStockV2ReflowRequest{
FlagGroupCode: routeMeta.FlagGroupCode,
ProductWarehouseID: adjustment.ProductWarehouseId,
AsOf: &asOf,
Tx: tx,
}); err != nil {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Failed to reflow stock via FIFO v2: %v", err))
}
if err := s.createAdjustmentStockLog(
ctx,
stockLogRepoTx,
adjustment.Id,
adjustment.ProductWarehouseId,
notes,
actorID,
0,
oldQty,
); err != nil {
return err
}
}
case adjustmentLaneUsable:
activeBeforeRollback, err := repoTx.CountActiveConsumeAllocationsByUsable(ctx, fifo.UsableKeyAdjustmentOut.String(), adjustment.Id)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to validate adjustment allocations before rollback")
}
rollbackRes, err := s.FifoStockV2Svc.Rollback(ctx, common.FifoStockV2RollbackRequest{
ProductWarehouseID: adjustment.ProductWarehouseId,
Usable: common.FifoStockV2Ref{
ID: adjustment.Id,
LegacyTypeKey: fifo.UsableKeyAdjustmentOut.String(),
},
Reason: notes,
Tx: tx,
})
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Failed to rollback FIFO v2 adjustment: %v", err))
}
activeAfterRollback, err := repoTx.CountActiveConsumeAllocationsByUsable(ctx, fifo.UsableKeyAdjustmentOut.String(), adjustment.Id)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to validate adjustment allocations after rollback")
}
if activeAfterRollback > 0 {
return fiber.NewError(
fiber.StatusBadRequest,
fmt.Sprintf(
"Adjustment tidak dapat dihapus karena masih ada alokasi aktif ADJUSTMENT_OUT=%d (sebelum rollback=%d, sesudah rollback=%d).",
adjustment.Id,
activeBeforeRollback,
activeAfterRollback,
),
)
}
releasedQty := 0.0
if rollbackRes != nil {
releasedQty = rollbackRes.ReleasedQty
}
if releasedQty > 0 {
if err := s.createAdjustmentStockLog(
ctx,
stockLogRepoTx,
adjustment.Id,
adjustment.ProductWarehouseId,
notes,
actorID,
releasedQty,
0,
); err != nil {
return err
}
}
default:
return fiber.NewError(fiber.StatusBadRequest, "Unsupported adjustment lane")
}
if err := repoTx.DeleteStockLogsByAdjustmentID(ctx, adjustment.Id); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to delete adjustment stock logs")
}
if err := repoTx.DeleteAdjustmentByID(ctx, adjustment.Id); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to delete adjustment")
}
return nil
}
func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*entity.AdjustmentStock, error) { func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*entity.AdjustmentStock, error) {
if err := s.Validate.Struct(req); err != nil { if err := s.Validate.Struct(req); err != nil {
return nil, err return nil, err
@@ -120,18 +365,21 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e
return nil, fiber.NewError(fiber.StatusBadRequest, "Quantity must be greater than zero") return nil, fiber.NewError(fiber.StatusBadRequest, "Quantity must be greater than zero")
} }
functionCode := strings.ToUpper(strings.TrimSpace(req.TransactionSubtype)) functionCode := utils.NormalizeUpper(req.TransactionSubtype)
if functionCode == "" { if functionCode == "" {
functionCode = strings.ToUpper(strings.TrimSpace(req.TransactionSubType)) functionCode = utils.NormalizeUpper(req.TransactionSubType)
} }
if functionCode == "" { if functionCode == "" {
functionCode = strings.ToUpper(strings.TrimSpace(req.FunctionCode)) functionCode = utils.NormalizeUpper(req.FunctionCode)
} }
if functionCode == "" { if functionCode == "" {
return nil, fiber.NewError(fiber.StatusBadRequest, "Transaction subtype is required") return nil, fiber.NewError(fiber.StatusBadRequest, "Transaction subtype is required")
} }
if functionCode == string(utils.AdjustmentTransactionSubtypeRecordingDepletionIn) { if functionCode == string(utils.AdjustmentTransactionSubtypeRecordingDepletionOut) {
functionCode = string(utils.AdjustmentTransactionSubtypeRecordingDepletionOut) return nil, fiber.NewError(
fiber.StatusBadRequest,
"RECORDING_DEPLETION_OUT tidak boleh diinput manual. Gunakan RECORDING_DEPLETION_IN, sistem akan otomatis membuat depletion-out AYAM",
)
} }
warehouseID, err := s.resolveWarehouseID(c.Context(), req) warehouseID, err := s.resolveWarehouseID(c.Context(), req)
@@ -139,9 +387,9 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e
return nil, err return nil, err
} }
note := strings.TrimSpace(req.Notes) note := utils.NormalizeTrim(req.Notes)
if note == "" { if note == "" {
note = strings.TrimSpace(req.Note) note = utils.NormalizeTrim(req.Note)
} }
grandTotal := math.Round((qty*req.Price)*1000) / 1000 grandTotal := math.Round((qty*req.Price)*1000) / 1000
@@ -167,15 +415,6 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e
transactionType := utils.ResolveAdjustmentTransactionType(routeMeta.FunctionCode) transactionType := utils.ResolveAdjustmentTransactionType(routeMeta.FunctionCode)
allowPending := false
if routeMeta.Lane == adjustmentLaneUsable {
allowPending, err = s.resolveOverconsumePolicy(ctx, routeMeta)
if err != nil {
s.Log.Errorf("Failed to resolve overconsume rule: %+v", err)
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to resolve FIFO policy")
}
}
var createdAdjustmentStockId uint var createdAdjustmentStockId uint
var projectFlockKandangID *uint var projectFlockKandangID *uint
@@ -221,6 +460,160 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get product warehouse") return fiber.NewError(fiber.StatusInternalServerError, "Failed to get product warehouse")
} }
if functionCode == string(utils.AdjustmentTransactionSubtypeRecordingDepletionIn) {
if routeMeta.Lane != adjustmentLaneStockable {
return fiber.NewError(fiber.StatusBadRequest, "Transaction subtype depletion in harus lane STOCKABLE")
}
if projectFlockKandangID == nil || *projectFlockKandangID == 0 {
return fiber.NewError(fiber.StatusBadRequest, "project_flock_kandang_id aktif wajib tersedia untuk depletion conversion")
}
if s.FifoStockV2Svc == nil {
return fiber.NewError(fiber.StatusInternalServerError, "FIFO v2 service is not available")
}
sourcePW, err := adjustmentStockRepoTX.FindAyamSourceProductWarehouse(ctx, warehouseID, *projectFlockKandangID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return fiber.NewError(fiber.StatusBadRequest, "Produk sumber AYAM pada project flock kandang yang sama tidak ditemukan")
}
return err
}
if err := common.EnsureProjectFlockNotClosedForProductWarehouses(
ctx,
tx,
[]uint{productWarehouse.Id, sourcePW.Id},
); err != nil {
return err
}
sourceRoute, err := s.resolveRouteByFunctionCode(
ctx,
sourcePW.ProductId,
string(utils.AdjustmentTransactionSubtypeRecordingDepletionOut),
)
if err != nil {
return err
}
if sourceRoute.Lane != adjustmentLaneUsable {
return fiber.NewError(fiber.StatusBadRequest, "Route depletion out untuk produk AYAM tidak valid")
}
sourceCode, err := adjustmentStockRepoTX.GenerateSequentialNumber(ctx, utils.AdjustmentStockNumberPrefix)
if err != nil {
return err
}
sourceAdjustment := &entity.AdjustmentStock{
ProductWarehouseId: sourcePW.Id,
TransactionType: transactionType,
FunctionCode: sourceRoute.FunctionCode,
UsageQty: qty,
Price: req.Price,
GrandTotal: grandTotal,
AdjNumber: sourceCode,
}
if err := adjustmentStockRepoTX.CreateOne(ctx, sourceAdjustment, nil); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to create depletion source adjustment stock record")
}
destCode, err := adjustmentStockRepoTX.GenerateSequentialNumber(ctx, utils.AdjustmentStockNumberPrefix)
if err != nil {
return err
}
destinationAdjustment := &entity.AdjustmentStock{
ProductWarehouseId: productWarehouse.Id,
TransactionType: transactionType,
FunctionCode: routeMeta.FunctionCode,
TotalQty: qty,
Price: req.Price,
GrandTotal: grandTotal,
AdjNumber: destCode,
}
if err := adjustmentStockRepoTX.CreateOne(ctx, destinationAdjustment, nil); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to create depletion destination adjustment stock record")
}
if err := adjustmentStockRepoTX.UpdatePairedAdjustmentID(ctx, sourceAdjustment.Id, destinationAdjustment.Id); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to link depletion source adjustment pair")
}
if err := adjustmentStockRepoTX.UpdatePairedAdjustmentID(ctx, destinationAdjustment.Id, sourceAdjustment.Id); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to link depletion destination adjustment pair")
}
sourceAdjustment.PairedAdjustmentId = &destinationAdjustment.Id
destinationAdjustment.PairedAdjustmentId = &sourceAdjustment.Id
sourceAsOf := sourceAdjustment.CreatedAt
if _, err := s.FifoStockV2Svc.Reflow(ctx, common.FifoStockV2ReflowRequest{
FlagGroupCode: sourceRoute.FlagGroupCode,
ProductWarehouseID: sourcePW.Id,
AsOf: &sourceAsOf,
Tx: tx,
}); err != nil {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Failed to auto depletion-out AYAM via FIFO v2: %v", err))
}
destinationAsOf := destinationAdjustment.CreatedAt
if _, err := s.FifoStockV2Svc.Reflow(ctx, common.FifoStockV2ReflowRequest{
FlagGroupCode: routeMeta.FlagGroupCode,
ProductWarehouseID: destinationAdjustment.ProductWarehouseId,
AsOf: &destinationAsOf,
Tx: tx,
}); err != nil {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Failed to auto depletion-in destination via FIFO v2: %v", err))
}
refreshedSource, err := adjustmentStockRepoTX.GetByID(ctx, sourceAdjustment.Id, nil)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to refresh depletion source adjustment stock")
}
refreshedDestination, err := adjustmentStockRepoTX.GetByID(ctx, destinationAdjustment.Id, nil)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to refresh depletion destination adjustment stock")
}
consumedPopulationQty := refreshedSource.UsageQty + refreshedSource.PendingQty
if consumedPopulationQty > 0 {
if err := s.allocatePopulationForDepletionAdjustment(
ctx,
tx,
*projectFlockKandangID,
sourcePW.Id,
refreshedSource.Id,
consumedPopulationQty,
); err != nil {
return err
}
if err := adjustmentStockRepoTX.ResyncProjectFlockPopulationUsage(ctx, *projectFlockKandangID); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to resync project flock population usage")
}
}
if err := s.createAdjustmentStockLog(
ctx,
stockLogRepoTX,
refreshedSource.Id,
refreshedSource.ProductWarehouseId,
note,
actorID,
0,
refreshedSource.UsageQty+refreshedSource.PendingQty,
); err != nil {
return err
}
if err := s.createAdjustmentStockLog(
ctx,
stockLogRepoTX,
refreshedDestination.Id,
refreshedDestination.ProductWarehouseId,
note,
actorID,
refreshedDestination.TotalQty,
0,
); err != nil {
return err
}
createdAdjustmentStockId = destinationAdjustment.Id
return nil
}
adjustmentStock := &entity.AdjustmentStock{ adjustmentStock := &entity.AdjustmentStock{
ProductWarehouseId: productWarehouse.Id, ProductWarehouseId: productWarehouse.Id,
TransactionType: transactionType, TransactionType: transactionType,
@@ -228,6 +621,12 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e
Price: req.Price, Price: req.Price,
GrandTotal: grandTotal, GrandTotal: grandTotal,
} }
switch routeMeta.Lane {
case adjustmentLaneStockable:
adjustmentStock.TotalQty = qty
case adjustmentLaneUsable:
adjustmentStock.UsageQty = qty
}
code, err := adjustmentStockRepoTX.GenerateSequentialNumber(ctx, utils.AdjustmentStockNumberPrefix) code, err := adjustmentStockRepoTX.GenerateSequentialNumber(ctx, utils.AdjustmentStockNumberPrefix)
if err != nil { if err != nil {
return err return err
@@ -240,85 +639,44 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e
var increaseQty float64 var increaseQty float64
var decreaseQty float64 var decreaseQty float64
switch routeMeta.Lane { if routeMeta.Lane != adjustmentLaneStockable && routeMeta.Lane != adjustmentLaneUsable {
case adjustmentLaneStockable:
fifoNote := fmt.Sprintf("Stock Adjustment %s #%s", routeMeta.FunctionCode, adjustmentStock.AdjNumber)
result, err := s.FifoSvc.Replenish(ctx, common.StockReplenishRequest{
StockableKey: fifo.StockableKeyAdjustmentIn,
StockableID: adjustmentStock.Id,
ProductWarehouseID: productWarehouse.Id,
Quantity: qty,
Note: &fifoNote,
Tx: tx,
})
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to replenish stock via FIFO: %v", err))
}
increaseQty = result.AddedQuantity
case adjustmentLaneUsable:
if s.FifoStockV2Svc != nil {
usableLegacyTypeKey := fifo.UsableKeyAdjustmentOut.String()
if routeMeta.SourceTable == "adjustment_stocks" && strings.TrimSpace(routeMeta.LegacyTypeKey) != "" {
usableLegacyTypeKey = strings.TrimSpace(routeMeta.LegacyTypeKey)
}
reflowResult, err := s.FifoStockV2Svc.Reflow(ctx, common.FifoStockV2ReflowRequest{
FlagGroupCode: routeMeta.FlagGroupCode,
ProductWarehouseID: productWarehouse.Id,
Usable: common.FifoStockV2Ref{
ID: adjustmentStock.Id,
LegacyTypeKey: usableLegacyTypeKey,
FunctionCode: routeMeta.FunctionCode,
},
DesiredQty: qty,
AllowOverConsume: &allowPending,
Tx: tx,
})
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Failed to consume stock via FIFO v2: %v", err))
}
decreaseQty = reflowResult.Allocate.AllocatedQty
} else {
result, err := s.FifoSvc.Consume(ctx, common.StockConsumeRequest{
UsableKey: fifo.UsableKeyAdjustmentOut,
UsableID: adjustmentStock.Id,
ProductWarehouseID: productWarehouse.Id,
Quantity: qty,
AllowPending: allowPending,
Tx: tx,
})
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Failed to consume stock via FIFO: %v", err))
}
decreaseQty = result.UsageQuantity
}
default:
return fiber.NewError(fiber.StatusBadRequest, "Unsupported transaction subtype lane") return fiber.NewError(fiber.StatusBadRequest, "Unsupported transaction subtype lane")
} }
if s.FifoStockV2Svc == nil {
return fiber.NewError(fiber.StatusInternalServerError, "FIFO v2 service is not available")
}
stockLogs, err := stockLogRepoTX.GetByProductWarehouse(ctx, productWarehouse.Id, 1) asOf := adjustmentStock.CreatedAt
if _, err := s.FifoStockV2Svc.Reflow(ctx, common.FifoStockV2ReflowRequest{
FlagGroupCode: routeMeta.FlagGroupCode,
ProductWarehouseID: productWarehouse.Id,
AsOf: &asOf,
Tx: tx,
}); err != nil {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Failed to reflow stock via FIFO v2: %v", err))
}
refreshedAdjustment, err := adjustmentStockRepoTX.GetByID(ctx, adjustmentStock.Id, nil)
if err != nil { if err != nil {
s.Log.Errorf("Failed to get stock logs: %+v", err) return fiber.NewError(fiber.StatusInternalServerError, "Failed to refresh adjustment stock")
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs") }
switch routeMeta.Lane {
case adjustmentLaneStockable:
increaseQty = refreshedAdjustment.TotalQty
case adjustmentLaneUsable:
decreaseQty = refreshedAdjustment.UsageQty
} }
currentStock := 0.0 if err := s.createAdjustmentStockLog(
if len(stockLogs) > 0 { ctx,
currentStock = stockLogs[0].Stock stockLogRepoTX,
} adjustmentStock.Id,
productWarehouse.Id,
newLog := &entity.StockLog{ note,
LoggableType: string(utils.StockLogTypeAdjustment), actorID,
LoggableId: adjustmentStock.Id, increaseQty,
Notes: note, decreaseQty,
ProductWarehouseId: productWarehouse.Id, ); err != nil {
CreatedBy: actorID,
Increase: increaseQty,
Decrease: decreaseQty,
Stock: currentStock + increaseQty - decreaseQty,
}
if err := stockLogRepoTX.CreateOne(ctx, newLog, nil); err != nil {
return err return err
} }
@@ -398,29 +756,80 @@ func (s *adjustmentService) resolveRouteByFunctionCode(
} }
} }
func (s *adjustmentService) resolveOverconsumePolicy( func (s *adjustmentService) resolveAdjustmentDependenciesAndPolicy(
ctx context.Context, ctx context.Context,
route *adjustmentStockRepo.AdjustmentRouteResolution, tx *gorm.DB,
) (bool, error) { stockableType string,
if route == nil { stockableIDs []uint,
return false, fmt.Errorf("route is required") ) ([]adjustmentStockRepo.AdjustmentDownstreamDependency, bool, error) {
} deps, err := s.AdjustmentStockRepository.WithTx(tx).LoadDownstreamDependencies(ctx, stockableType, stockableIDs)
defaultValue := route.AllowPendingDefault
selected, err := s.AdjustmentStockRepository.FindOverconsumeRule(
ctx,
route.Lane,
route.FlagGroupCode,
route.FunctionCode,
)
if err != nil { if err != nil {
return false, err s.Log.Errorf("Failed to load downstream adjustment dependencies: %+v", err)
return nil, false, fiber.NewError(fiber.StatusInternalServerError, "Failed to validate downstream adjustment dependencies")
} }
if selected == nil { if len(deps) == 0 {
return defaultValue, nil return nil, true, nil
} }
return *selected, nil allowPending := true
for _, dep := range deps {
policy, policyErr := common.ResolveFifoPendingPolicy(ctx, tx, common.FifoPendingPolicyInput{
Lane: adjustmentLaneUsable,
FlagGroupCode: dep.FlagGroupCode,
FunctionCode: dep.FunctionCode,
LegacyTypeKey: dep.UsableType,
})
if policyErr != nil {
s.Log.Errorf("Failed to resolve FIFO pending policy for adjustment dependency: %+v", policyErr)
return nil, false, fiber.NewError(fiber.StatusInternalServerError, "Failed to read FIFO v2 configuration")
}
if !policy.Found || !policy.AllowPending {
allowPending = false
break
}
}
return deps, allowPending, nil
}
func formatAdjustmentDependencySummary(rows []adjustmentStockRepo.AdjustmentDownstreamDependency) string {
if len(rows) == 0 {
return "-"
}
grouped := make(map[string]map[uint64]struct{})
for _, row := range rows {
label := utils.NormalizeUpper(row.UsableType)
if label == "" {
label = "UNKNOWN"
}
if _, ok := grouped[label]; !ok {
grouped[label] = make(map[uint64]struct{})
}
grouped[label][row.UsableID] = struct{}{}
}
labels := make([]string, 0, len(grouped))
for label := range grouped {
labels = append(labels, label)
}
sort.Strings(labels)
parts := make([]string, 0, len(labels))
for _, label := range labels {
ids := make([]uint64, 0, len(grouped[label]))
for id := range grouped[label] {
ids = append(ids, id)
}
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
idParts := make([]string, 0, len(ids))
for _, id := range ids {
idParts = append(idParts, fmt.Sprintf("%d", id))
}
parts = append(parts, fmt.Sprintf("%s=%s", label, strings.Join(idParts, "|")))
}
return strings.Join(parts, ", ")
} }
func (s *adjustmentService) getActiveProjectFlockKandangID(ctx context.Context, warehouseID uint) (uint, error) { func (s *adjustmentService) getActiveProjectFlockKandangID(ctx context.Context, warehouseID uint) (uint, error) {
@@ -449,6 +858,89 @@ func (s *adjustmentService) getActiveProjectFlockKandangID(ctx context.Context,
return uint(projectFlockKandang.Id), nil return uint(projectFlockKandang.Id), nil
} }
func (s *adjustmentService) createAdjustmentStockLog(
ctx context.Context,
stockLogRepo stockLogsRepo.StockLogRepository,
adjustmentID uint,
productWarehouseID uint,
note string,
actorID uint,
increaseQty float64,
decreaseQty float64,
) error {
if stockLogRepo == nil || adjustmentID == 0 || productWarehouseID == 0 {
return nil
}
if increaseQty == 0 && decreaseQty == 0 {
return nil
}
stockLogs, err := stockLogRepo.GetByProductWarehouse(ctx, productWarehouseID, 1)
if err != nil {
s.Log.Errorf("Failed to get stock logs: %+v", err)
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs")
}
currentStock := 0.0
if len(stockLogs) > 0 {
currentStock = stockLogs[0].Stock
}
newLog := &entity.StockLog{
LoggableType: string(utils.StockLogTypeAdjustment),
LoggableId: adjustmentID,
Notes: note,
ProductWarehouseId: productWarehouseID,
CreatedBy: actorID,
Increase: increaseQty,
Decrease: decreaseQty,
Stock: currentStock + increaseQty - decreaseQty,
}
return stockLogRepo.CreateOne(ctx, newLog, nil)
}
func (s *adjustmentService) allocatePopulationForDepletionAdjustment(
ctx context.Context,
tx *gorm.DB,
projectFlockKandangID uint,
sourceProductWarehouseID uint,
adjustmentID uint,
consumeQty float64,
) error {
if consumeQty <= 0 {
return nil
}
if tx == nil {
return errors.New("transaction is required")
}
if projectFlockKandangID == 0 || sourceProductWarehouseID == 0 || adjustmentID == 0 {
return fiber.NewError(fiber.StatusBadRequest, "Invalid depletion adjustment population context")
}
if s.ProjectFlockPopulationRepo == nil {
return fiber.NewError(fiber.StatusInternalServerError, "Project flock population repository is not available")
}
popRepoTx := s.ProjectFlockPopulationRepo.WithTx(tx)
populations, err := popRepoTx.GetByProjectFlockKandangIDAndProductWarehouseID(ctx, projectFlockKandangID, sourceProductWarehouseID)
if err != nil {
return err
}
if len(populations) == 0 {
return fiber.NewError(fiber.StatusBadRequest, "Populasi tidak ditemukan untuk depletion adjustment")
}
return fifoV2.AllocatePopulationConsumption(
ctx,
tx,
populations,
sourceProductWarehouseID,
fifo.UsableKeyAdjustmentOut.String(),
adjustmentID,
consumeQty,
)
}
func (s *adjustmentService) AdjustmentHistory(c *fiber.Ctx, query *validation.Query) ([]*entity.AdjustmentStock, int64, error) { func (s *adjustmentService) AdjustmentHistory(c *fiber.Ctx, query *validation.Query) ([]*entity.AdjustmentStock, int64, error) {
if err := s.Validate.Struct(query); err != nil { if err := s.Validate.Struct(query); err != nil {
return nil, 0, err return nil, 0, err
@@ -491,11 +983,11 @@ func (s *adjustmentService) AdjustmentHistory(c *fiber.Ctx, query *validation.Qu
} }
} }
functionCode := strings.ToUpper(strings.TrimSpace(query.TransactionSubtype)) functionCode := utils.NormalizeUpper(query.TransactionSubtype)
if functionCode == "" { if functionCode == "" {
functionCode = strings.ToUpper(strings.TrimSpace(query.FunctionCode)) functionCode = utils.NormalizeUpper(query.FunctionCode)
} }
transactionType := strings.ToUpper(strings.TrimSpace(query.TransactionType)) transactionType := utils.NormalizeUpper(query.TransactionType)
adjustmentStocks, total, err := s.AdjustmentStockRepository.FindHistory( adjustmentStocks, total, err := s.AdjustmentStockRepository.FindHistory(
c.Context(), c.Context(),
@@ -32,6 +32,7 @@ func (u *ProductWarehouseController) GetAll(c *fiber.Ctx) error {
Flags: c.Query("flags", ""), Flags: c.Query("flags", ""),
KandangId: uint(c.QueryInt("kandang_id", 0)), KandangId: uint(c.QueryInt("kandang_id", 0)),
TransferContext: c.Query(utils.TransferContextKey, ""), TransferContext: c.Query(utils.TransferContextKey, ""),
StockMode: c.Query("stock_mode", ""),
Type: c.Query("type", ""), Type: c.Query("type", ""),
} }
@@ -12,10 +12,11 @@ import (
// === DTO Structs === // === DTO Structs ===
type ProductWarehouseRelationDTO struct { type ProductWarehouseRelationDTO struct {
Id uint `json:"id"` Id uint `json:"id"`
ProductId uint `json:"product_id"` ProductId uint `json:"product_id"`
WarehouseId uint `json:"warehouse_id"` WarehouseId uint `json:"warehouse_id"`
Quantity float64 `json:"quantity"` Quantity float64 `json:"quantity"`
TransferAvailableQty *float64 `json:"transfer_available_qty,omitempty"`
} }
type ProductWarehouseListDTO struct { type ProductWarehouseListDTO struct {
@@ -61,10 +62,11 @@ type ProjectFlockRelationDTO struct {
func ToProductWarehouseRelationDTO(e entity.ProductWarehouse) ProductWarehouseRelationDTO { func ToProductWarehouseRelationDTO(e entity.ProductWarehouse) ProductWarehouseRelationDTO {
return ProductWarehouseRelationDTO{ return ProductWarehouseRelationDTO{
Id: e.Id, Id: e.Id,
ProductId: e.ProductId, // Field yang benar dari entity ProductId: e.ProductId, // Field yang benar dari entity
WarehouseId: e.WarehouseId, // Field yang benar dari entity WarehouseId: e.WarehouseId, // Field yang benar dari entity
Quantity: e.Quantity, Quantity: e.Quantity,
TransferAvailableQty: e.AvailableQty,
} }
} }
@@ -2,6 +2,7 @@ package service
import ( import (
"errors" "errors"
"strings"
"github.com/go-playground/validator/v10" "github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
@@ -27,6 +28,8 @@ type productWarehouseService struct {
KandangRepo kandangrepo.KandangRepository KandangRepo kandangrepo.KandangRepository
} }
const stockModeExcludeChickin = "exclude_chickin"
func NewProductWarehouseService(repo repository.ProductWarehouseRepository, validate *validator.Validate, kandangRepo kandangrepo.KandangRepository) ProductWarehouseService { func NewProductWarehouseService(repo repository.ProductWarehouseRepository, validate *validator.Validate, kandangRepo kandangrepo.KandangRepository) ProductWarehouseService {
return &productWarehouseService{ return &productWarehouseService{
Log: utils.Log, Log: utils.Log,
@@ -189,6 +192,11 @@ func (s productWarehouseService) GetAll(c *fiber.Ctx, params *validation.Query)
s.Log.Errorf("Failed to get productWarehouses: %+v", err) s.Log.Errorf("Failed to get productWarehouses: %+v", err)
return nil, 0, err return nil, 0, err
} }
productWarehouses, err = s.applyTransferAvailableQty(c, params, productWarehouses)
if err != nil {
return nil, 0, err
}
return productWarehouses, total, nil return productWarehouses, total, nil
} }
@@ -229,3 +237,80 @@ func (s productWarehouseService) GetOne(c *fiber.Ctx, id uint) (*entity.ProductW
} }
return productWarehouse, nil return productWarehouse, nil
} }
func (s productWarehouseService) applyTransferAvailableQty(c *fiber.Ctx, params *validation.Query, rows []entity.ProductWarehouse) ([]entity.ProductWarehouse, error) {
if len(rows) == 0 {
return rows, nil
}
if params == nil ||
params.TransferContext != utils.TransferContextInventoryTransfer ||
params.StockMode != stockModeExcludeChickin {
return rows, nil
}
ayamPWIDs := make([]uint, 0)
for i := range rows {
if isAyamProductByFlags(rows[i].Product.Flags) {
ayamPWIDs = append(ayamPWIDs, rows[i].Id)
}
}
if len(ayamPWIDs) == 0 {
return rows, nil
}
type populationRemainingRow struct {
ProductWarehouseID uint `gorm:"column:product_warehouse_id"`
RemainingQty float64 `gorm:"column:remaining_qty"`
}
var populationRows []populationRemainingRow
if err := s.Repository.DB().WithContext(c.Context()).
Table("project_flock_populations pfp").
Select("pfp.product_warehouse_id, COALESCE(SUM(GREATEST(pfp.total_qty - pfp.total_used_qty, 0)), 0) AS remaining_qty").
Joins("JOIN project_chickins pc ON pc.id = pfp.project_chickin_id").
Where("pfp.product_warehouse_id IN ?", ayamPWIDs).
Where("pfp.deleted_at IS NULL").
Where("pc.deleted_at IS NULL").
Group("pfp.product_warehouse_id").
Scan(&populationRows).Error; err != nil {
s.Log.Errorf("Failed to resolve chickin population remaining for transfer stock filter: %+v", err)
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to resolve transfer stock availability")
}
populationRemainingByPW := make(map[uint]float64, len(populationRows))
for _, row := range populationRows {
populationRemainingByPW[row.ProductWarehouseID] = row.RemainingQty
}
filtered := make([]entity.ProductWarehouse, 0, len(rows))
for i := range rows {
row := rows[i]
if !isAyamProductByFlags(row.Product.Flags) {
filtered = append(filtered, row)
continue
}
available := row.Quantity - populationRemainingByPW[row.Id]
if available < 0 {
available = 0
}
row.AvailableQty = &available
if available <= 0 {
continue
}
filtered = append(filtered, row)
}
return filtered, nil
}
func isAyamProductByFlags(flags []entity.Flag) bool {
for _, flag := range flags {
if utils.CanonicalFlagType(strings.TrimSpace(flag.Name)) == utils.FlagAyam {
return true
}
}
return false
}
@@ -20,5 +20,6 @@ type Query struct {
Flags string `query:"flags" validate:"omitempty"` Flags string `query:"flags" validate:"omitempty"`
KandangId uint `query:"kandang_id" validate:"omitempty,number,min=1"` KandangId uint `query:"kandang_id" validate:"omitempty,number,min=1"`
TransferContext string `query:"transfer_context" validate:"omitempty,oneof=inventory_transfer"` TransferContext string `query:"transfer_context" validate:"omitempty,oneof=inventory_transfer"`
StockMode string `query:"stock_mode" validate:"omitempty,oneof=exclude_chickin"`
Type string `query:"type" validate:"omitempty"` Type string `query:"type" validate:"omitempty"`
} }
@@ -109,3 +109,23 @@ func (u *TransferController) CreateOne(c *fiber.Ctx) error {
Data: dto.ToTransferDetailDTO(*result), Data: dto.ToTransferDetailDTO(*result),
}) })
} }
func (u *TransferController) DeleteOne(c *fiber.Ctx) error {
param := c.Params("id")
id, err := strconv.Atoi(param)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, "Invalid Id")
}
if err := u.TransferService.DeleteOne(c, uint(id)); err != nil {
return err
}
return c.Status(fiber.StatusOK).
JSON(response.Common{
Code: fiber.StatusOK,
Status: "success",
Message: "Delete transfer successfully",
})
}
+2 -36
View File
@@ -24,7 +24,6 @@ import (
rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories"
sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services"
"gitlab.com/mbugroup/lti-api.git/internal/utils" "gitlab.com/mbugroup/lti-api.git/internal/utils"
"gitlab.com/mbugroup/lti-api.git/internal/utils/fifo"
) )
type TransferModule struct{} type TransferModule struct{}
@@ -40,10 +39,10 @@ func (TransferModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate
userRepo := rUser.NewUserRepository(db) userRepo := rUser.NewUserRepository(db)
warehouseRepo := rWarehouse.NewWarehouseRepository(db) warehouseRepo := rWarehouse.NewWarehouseRepository(db)
projectFlockKandangRepo := rProjectFlockKandang.NewProjectFlockKandangRepository(db) projectFlockKandangRepo := rProjectFlockKandang.NewProjectFlockKandangRepository(db)
projectFlockPopulationRepo := rProjectFlockKandang.NewProjectFlockPopulationRepository(db)
kandangRepo := rKandang.NewKandangRepository(db) kandangRepo := rKandang.NewKandangRepository(db)
nonstockRepo := rNonstock.NewNonstockRepository(db) nonstockRepo := rNonstock.NewNonstockRepository(db)
documentRepo := commonRepo.NewDocumentRepository(db) documentRepo := commonRepo.NewDocumentRepository(db)
stockAllocRepo := commonRepo.NewStockAllocationRepository(db)
expenseRepository := expenseRepo.NewExpenseRepository(db) expenseRepository := expenseRepo.NewExpenseRepository(db)
expenseRealizationRepo := expenseRepo.NewExpenseRealizationRepository(db) expenseRealizationRepo := expenseRepo.NewExpenseRealizationRepository(db)
@@ -69,7 +68,6 @@ func (TransferModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate
validate, validate,
) )
fifoService := commonSvc.NewFifoService(db, stockAllocRepo, productWarehouseRepo, utils.Log)
fifoStockV2Service := commonSvc.NewFifoStockV2Service(db, utils.Log) fifoStockV2Service := commonSvc.NewFifoStockV2Service(db, utils.Log)
expenseBridge := sTransfer.NewTransferExpenseBridge( expenseBridge := sTransfer.NewTransferExpenseBridge(
db, db,
@@ -79,39 +77,7 @@ func (TransferModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate
expenseServiceInstance, expenseServiceInstance,
) )
err = fifoService.RegisterStockable(fifo.StockableConfig{ transferService := sTransfer.NewTransferService(validate, stockTransferRepo, stockTransferDetailRepo, stockTransferDeliveryRepo, StockTransferDeliveryItemRepo, stockLogsRepo, productWarehouseRepo, supplierRepo, warehouseRepo, projectFlockKandangRepo, projectFlockPopulationRepo, documentSvc, fifoStockV2Service, expenseBridge)
Key: fifo.StockableKeyStockTransferIn,
Table: "stock_transfer_details",
Columns: fifo.StockableColumns{
ID: "id",
ProductWarehouseID: "dest_product_warehouse_id",
TotalQuantity: "total_qty",
TotalUsedQuantity: "total_used",
CreatedAt: "created_at",
},
OrderBy: []string{"created_at ASC", "id ASC"},
})
if err != nil {
panic(err)
}
err = fifoService.RegisterUsable(fifo.UsableConfig{
Key: fifo.UsableKeyStockTransferOut,
Table: "stock_transfer_details",
Columns: fifo.UsableColumns{
ID: "id",
ProductWarehouseID: "source_product_warehouse_id",
UsageQuantity: "usage_qty",
PendingQuantity: "pending_qty",
CreatedAt: "created_at",
},
OrderBy: []string{"created_at ASC", "id ASC"},
})
if err != nil {
panic(err)
}
transferService := sTransfer.NewTransferService(validate, stockTransferRepo, stockTransferDetailRepo, stockTransferDeliveryRepo, StockTransferDeliveryItemRepo, stockLogsRepo, productWarehouseRepo, supplierRepo, warehouseRepo, projectFlockKandangRepo, documentSvc, fifoService, fifoStockV2Service, expenseBridge)
userService := sUser.NewUserService(userRepo, validate) userService := sUser.NewUserService(userRepo, validate)
TransferRoutes(router, userService, transferService) TransferRoutes(router, userService, transferService)
@@ -18,5 +18,6 @@ func TransferRoutes(v1 fiber.Router, u user.UserService, s transfer.TransferServ
route.Get("/", m.RequirePermissions(m.P_TransferGetAll), ctrl.GetAll) route.Get("/", m.RequirePermissions(m.P_TransferGetAll), ctrl.GetAll)
route.Post("/", m.RequirePermissions(m.P_TransferCreateOne), ctrl.CreateOne) route.Post("/", m.RequirePermissions(m.P_TransferCreateOne), ctrl.CreateOne)
route.Get("/:id", m.RequirePermissions(m.P_TransferGetOne), ctrl.GetOne) route.Get("/:id", m.RequirePermissions(m.P_TransferGetOne), ctrl.GetOne)
route.Delete("/:id", m.RequirePermissions(m.P_TransferDeleteOne), ctrl.DeleteOne)
} }
@@ -5,7 +5,9 @@ import (
"errors" "errors"
"fmt" "fmt"
"mime/multipart" "mime/multipart"
"sort"
"strings" "strings"
"time"
"github.com/go-playground/validator/v10" "github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
@@ -24,12 +26,14 @@ import (
"gitlab.com/mbugroup/lti-api.git/internal/utils/fifo" "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/clause"
) )
type TransferService interface { type TransferService interface {
GetAll(ctx *fiber.Ctx, params *validation.Query) ([]entity.StockTransfer, int64, error) GetAll(ctx *fiber.Ctx, params *validation.Query) ([]entity.StockTransfer, int64, error)
GetOne(ctx *fiber.Ctx, id uint) (*entity.StockTransfer, error) GetOne(ctx *fiber.Ctx, id uint) (*entity.StockTransfer, error)
CreateOne(ctx *fiber.Ctx, req *validation.TransferRequest, files []*multipart.FileHeader) (*entity.StockTransfer, error) CreateOne(ctx *fiber.Ctx, req *validation.TransferRequest, files []*multipart.FileHeader) (*entity.StockTransfer, error)
DeleteOne(ctx *fiber.Ctx, id uint) error
} }
type transferService struct { type transferService struct {
@@ -44,13 +48,22 @@ type transferService struct {
SupplierRepo rSupplier.SupplierRepository SupplierRepo rSupplier.SupplierRepository
WarehouseRepo warehouseRepo.WarehouseRepository WarehouseRepo warehouseRepo.WarehouseRepository
ProjectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository ProjectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository
ProjectFlockPopulationRepo projectFlockKandangRepo.ProjectFlockPopulationRepository
DocumentSvc commonSvc.DocumentService DocumentSvc commonSvc.DocumentService
FifoSvc commonSvc.FifoService
FifoStockV2Svc commonSvc.FifoStockV2Service FifoStockV2Svc commonSvc.FifoStockV2Service
ExpenseBridge TransferExpenseBridge ExpenseBridge TransferExpenseBridge
} }
func NewTransferService(validate *validator.Validate, stockTransferRepo rStockTransfer.StockTransferRepository, stockTransferDetailRepo rStockTransfer.StockTransferDetailRepository, stockTransferDeliveryRepo rStockTransfer.StockTransferDeliveryRepository, stockTransferDeliveryItemRepo rStockTransfer.StockTransferDeliveryItemRepository, stockLogsRepo rStockLogs.StockLogRepository, productWarehouseRepo rProductWarehouse.ProductWarehouseRepository, supplierRepo rSupplier.SupplierRepository, warehouseRepo warehouseRepo.WarehouseRepository, projectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository, documentSvc commonSvc.DocumentService, fifoSvc commonSvc.FifoService, fifoStockV2Svc commonSvc.FifoStockV2Service, expenseBridge TransferExpenseBridge) TransferService { const transferDeleteDownstreamGuardMessage = "Transfer stock tidak dapat dihapus karena stok transfer sudah dipakai transaksi turunan. Hapus dependensi terkait secara manual terlebih dahulu."
type downstreamDependency struct {
UsableType string `gorm:"column:usable_type"`
UsableID uint64 `gorm:"column:usable_id"`
FunctionCode string `gorm:"column:function_code"`
FlagGroupCode string `gorm:"column:flag_group_code"`
}
func NewTransferService(validate *validator.Validate, stockTransferRepo rStockTransfer.StockTransferRepository, stockTransferDetailRepo rStockTransfer.StockTransferDetailRepository, stockTransferDeliveryRepo rStockTransfer.StockTransferDeliveryRepository, stockTransferDeliveryItemRepo rStockTransfer.StockTransferDeliveryItemRepository, stockLogsRepo rStockLogs.StockLogRepository, productWarehouseRepo rProductWarehouse.ProductWarehouseRepository, supplierRepo rSupplier.SupplierRepository, warehouseRepo warehouseRepo.WarehouseRepository, projectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository, projectFlockPopulationRepo projectFlockKandangRepo.ProjectFlockPopulationRepository, documentSvc commonSvc.DocumentService, fifoStockV2Svc commonSvc.FifoStockV2Service, expenseBridge TransferExpenseBridge) TransferService {
return &transferService{ return &transferService{
Log: utils.Log, Log: utils.Log,
Validate: validate, Validate: validate,
@@ -63,8 +76,8 @@ func NewTransferService(validate *validator.Validate, stockTransferRepo rStockTr
SupplierRepo: supplierRepo, SupplierRepo: supplierRepo,
WarehouseRepo: warehouseRepo, WarehouseRepo: warehouseRepo,
ProjectFlockKandangRepo: projectFlockKandangRepo, ProjectFlockKandangRepo: projectFlockKandangRepo,
ProjectFlockPopulationRepo: projectFlockPopulationRepo,
DocumentSvc: documentSvc, DocumentSvc: documentSvc,
FifoSvc: fifoSvc,
FifoStockV2Svc: fifoStockV2Svc, FifoStockV2Svc: fifoStockV2Svc,
ExpenseBridge: expenseBridge, ExpenseBridge: expenseBridge,
} }
@@ -105,6 +118,7 @@ func (s transferService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entit
transfers, total, err := s.StockTransferRepo.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { transfers, total, err := s.StockTransferRepo.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB {
db = s.withRelations(db) db = s.withRelations(db)
db = db.Where("stock_transfers.deleted_at IS NULL")
if scope.Restrict { if scope.Restrict {
if len(scope.IDs) == 0 { if len(scope.IDs) == 0 {
return db.Where("1 = 0") return db.Where("1 = 0")
@@ -146,6 +160,7 @@ func (s transferService) GetOne(c *fiber.Ctx, id uint) (*entity.StockTransfer, e
Joins("JOIN warehouses w_from ON w_from.id = stock_transfers.from_warehouse_id"). Joins("JOIN warehouses w_from ON w_from.id = stock_transfers.from_warehouse_id").
Joins("JOIN warehouses w_to ON w_to.id = stock_transfers.to_warehouse_id"). Joins("JOIN warehouses w_to ON w_to.id = stock_transfers.to_warehouse_id").
Where("stock_transfers.id = ?", id). Where("stock_transfers.id = ?", id).
Where("stock_transfers.deleted_at IS NULL").
Where("w_from.location_id IN ? OR w_to.location_id IN ?", scope.IDs, scope.IDs). Where("w_from.location_id IN ? OR w_to.location_id IN ?", scope.IDs, scope.IDs).
Count(&count).Error; err != nil { Count(&count).Error; err != nil {
return nil, err return nil, err
@@ -156,7 +171,7 @@ func (s transferService) GetOne(c *fiber.Ctx, id uint) (*entity.StockTransfer, e
} }
transferPtr, err := s.StockTransferRepo.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB { transferPtr, err := s.StockTransferRepo.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB {
return s.withRelations(db) return s.withRelations(db).Where("stock_transfers.deleted_at IS NULL")
}) })
if err != nil { if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
@@ -444,83 +459,79 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques
} }
} }
pakanProducts := map[uint]bool{} if s.FifoStockV2Svc == nil {
if s.FifoStockV2Svc != nil && len(req.Products) > 0 { return fiber.NewError(fiber.StatusInternalServerError, "FIFO v2 service is not available")
pakanProducts, err = s.resolvePakanProducts(c.Context(), tx, req.Products)
if err != nil {
return err
}
} }
flagGroupByProduct := make(map[uint]string, len(req.Products))
for _, product := range req.Products { for _, product := range req.Products {
detail := detailMap[uint64(product.ProductID)] detail := detailMap[uint64(product.ProductID)]
if detail == nil || detail.SourceProductWarehouseID == nil || detail.DestProductWarehouseID == nil {
return fiber.NewError(fiber.StatusInternalServerError, "Data transfer detail tidak valid")
}
outUsageQty := 0.0 flagGroupCode, ok := flagGroupByProduct[uint(product.ProductID)]
outPendingQty := 0.0 if !ok {
useFifoV2 := s.FifoStockV2Svc != nil && pakanProducts[uint(product.ProductID)] flagGroupCode, err = s.resolveTransferFlagGroup(c.Context(), tx, uint(product.ProductID))
if useFifoV2 {
s.Log.Infof(
"[fifo-v2][transfer] use reflow movement=%s detail_id=%d product_id=%d source_pw=%d qty=%.3f",
entityTransfer.MovementNumber,
detail.Id,
product.ProductID,
*detail.SourceProductWarehouseID,
product.ProductQty,
)
reflowResult, err := s.FifoStockV2Svc.Reflow(c.Context(), commonSvc.FifoStockV2ReflowRequest{
FlagGroupCode: "PAKAN",
ProductWarehouseID: uint(*detail.SourceProductWarehouseID),
Usable: commonSvc.FifoStockV2Ref{
ID: uint(detail.Id),
LegacyTypeKey: fifo.UsableKeyStockTransferOut.String(),
FunctionCode: "STOCK_TRANSFER_OUT",
},
DesiredQty: product.ProductQty,
Tx: tx,
})
if err != nil { if err != nil {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Stok tidak mencukupi untuk produk %d di gudang asal. Error: %v", product.ProductID, err)) return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("FIFO v2 route tidak ditemukan untuk produk %d: %v", product.ProductID, err))
} }
outUsageQty = reflowResult.Allocate.AllocatedQty flagGroupByProduct[uint(product.ProductID)] = flagGroupCode
outPendingQty = reflowResult.Allocate.PendingQty
s.Log.Infof(
"[fifo-v2][transfer] reflow result movement=%s detail_id=%d usage=%.3f pending=%.3f",
entityTransfer.MovementNumber,
detail.Id,
outUsageQty,
outPendingQty,
)
} else {
consumeResult, err := s.FifoSvc.Consume(c.Context(), commonSvc.StockConsumeRequest{
UsableKey: fifo.UsableKeyStockTransferOut,
UsableID: uint(detail.Id),
ProductWarehouseID: uint(*detail.SourceProductWarehouseID),
Quantity: product.ProductQty,
AllowPending: false,
Tx: tx,
})
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Stok tidak mencukupi untuk produk %d di gudang asal. Error: %v", product.ProductID, err))
}
outUsageQty = consumeResult.UsageQuantity
outPendingQty = consumeResult.PendingQuantity
} }
if err := tx.Model(&entity.StockTransferDetail{}). if err := tx.Model(&entity.StockTransferDetail{}).
Where("id = ?", detail.Id). Where("id = ?", detail.Id).
Updates(map[string]interface{}{ Updates(map[string]interface{}{
"usage_qty": outUsageQty, "usage_qty": product.ProductQty,
"pending_qty": outPendingQty, "pending_qty": 0,
"total_qty": product.ProductQty,
}).Error; err != nil { }).Error; err != nil {
s.Log.Errorf("Failed to update tracking usage for detail_id=%d, product_id=%d: %+v", detail.Id, product.ProductID, err) s.Log.Errorf("Failed to update transfer detail seed fields for detail_id=%d, product_id=%d: %+v", detail.Id, product.ProductID, err)
return fiber.NewError(fiber.StatusInternalServerError, "Gagal memperbarui data tracking") return fiber.NewError(fiber.StatusInternalServerError, "Gagal memperbarui data tracking")
} }
asOf := transferDate
if _, err := s.FifoStockV2Svc.Reflow(c.Context(), commonSvc.FifoStockV2ReflowRequest{
FlagGroupCode: flagGroupCode,
ProductWarehouseID: uint(*detail.SourceProductWarehouseID),
AsOf: &asOf,
Tx: tx,
}); err != nil {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Stok tidak mencukupi untuk produk %d di gudang asal. Error: %v", product.ProductID, err))
}
if _, err := s.FifoStockV2Svc.Reflow(c.Context(), commonSvc.FifoStockV2ReflowRequest{
FlagGroupCode: flagGroupCode,
ProductWarehouseID: uint(*detail.DestProductWarehouseID),
AsOf: &asOf,
Tx: tx,
}); err != nil {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Gagal reflow stok tujuan untuk produk %d. Error: %v", product.ProductID, err))
}
type usageSnapshot struct {
UsageQty float64 `gorm:"column:usage_qty"`
PendingQty float64 `gorm:"column:pending_qty"`
}
var usage usageSnapshot
if err := tx.WithContext(c.Context()).
Table("stock_transfer_details").
Select("usage_qty, pending_qty").
Where("id = ?", detail.Id).
Take(&usage).Error; err != nil {
s.Log.Errorf("Failed to read transfer usage snapshot detail_id=%d, product_id=%d: %+v", detail.Id, product.ProductID, err)
return fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data tracking")
}
outUsageQty := usage.UsageQty
outPendingQty := usage.PendingQty
if outPendingQty > 1e-6 {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Stok tidak mencukupi untuk produk %d di gudang asal", product.ProductID))
}
stockLogDecrease := &entity.StockLog{ stockLogDecrease := &entity.StockLog{
ProductWarehouseId: uint(*detail.SourceProductWarehouseID), ProductWarehouseId: uint(*detail.SourceProductWarehouseID),
CreatedBy: uint(actorID), CreatedBy: uint(actorID),
Increase: 0, Increase: 0,
Decrease: product.ProductQty, Decrease: outUsageQty,
LoggableType: string(utils.StockLogTypeTransfer), LoggableType: string(utils.StockLogTypeTransfer),
LoggableId: uint(detail.Id), LoggableId: uint(detail.Id),
Notes: "", Notes: "",
@@ -541,45 +552,12 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques
return fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat log stok keluar") return fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat log stok keluar")
} }
note := fmt.Sprintf("Transfer #%s", entityTransfer.MovementNumber) inAddedQty := outUsageQty
inAddedQty := 0.0
if useFifoV2 {
s.Log.Infof(
"[fifo-v2][transfer] stock-in uses replenish path movement=%s detail_id=%d product_id=%d dest_pw=%d qty=%.3f",
entityTransfer.MovementNumber,
detail.Id,
product.ProductID,
*detail.DestProductWarehouseID,
product.ProductQty,
)
}
replenishResult, err := s.FifoSvc.Replenish(c.Context(), commonSvc.StockReplenishRequest{
StockableKey: fifo.StockableKeyStockTransferIn,
StockableID: uint(detail.Id),
ProductWarehouseID: uint(*detail.DestProductWarehouseID),
Quantity: product.ProductQty,
Note: &note,
Tx: tx,
})
if err != nil {
s.Log.Errorf("Failed to replenish stock for product_id=%d, pw_id=%d, qty=%.2f: %+v", product.ProductID, *detail.DestProductWarehouseID, product.ProductQty, err)
return fiber.NewError(fiber.StatusInternalServerError, "Gagal menambah stok gudang tujuan")
}
inAddedQty = replenishResult.AddedQuantity
if err := tx.Model(&entity.StockTransferDetail{}).
Where("id = ?", detail.Id).
Updates(map[string]interface{}{
"total_qty": inAddedQty,
}).Error; err != nil {
s.Log.Errorf("Failed to update tracking total for detail_id=%d, product_id=%d: %+v", detail.Id, product.ProductID, err)
return fiber.NewError(fiber.StatusInternalServerError, "Gagal memperbarui data tracking")
}
stockLogIncrease := &entity.StockLog{ stockLogIncrease := &entity.StockLog{
ProductWarehouseId: uint(*detail.DestProductWarehouseID), ProductWarehouseId: uint(*detail.DestProductWarehouseID),
CreatedBy: uint(actorID), CreatedBy: uint(actorID),
Increase: product.ProductQty, Increase: inAddedQty,
Decrease: 0, Decrease: 0,
LoggableType: string(utils.StockLogTypeTransfer), LoggableType: string(utils.StockLogTypeTransfer),
LoggableId: uint(detail.Id), LoggableId: uint(detail.Id),
@@ -657,51 +635,249 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques
return result, nil return result, nil
} }
func (s *transferService) resolvePakanProducts( func (s *transferService) DeleteOne(c *fiber.Ctx, id uint) error {
ctx context.Context, if err := s.ensureTransferAccess(c.Context(), id, c); err != nil {
tx *gorm.DB, return err
products []validation.TransferProduct, }
) (map[uint]bool, error) { if s.FifoStockV2Svc == nil {
out := make(map[uint]bool, len(products)) return fiber.NewError(fiber.StatusInternalServerError, "FIFO v2 service is not available")
if len(products) == 0 {
return out, nil
} }
productIDs := make([]uint, 0, len(products)) actorID, err := m.ActorIDFromContext(c)
seen := make(map[uint]struct{}, len(products)) if err != nil {
for _, product := range products { return err
if product.ProductID == 0 {
continue
}
if _, ok := seen[product.ProductID]; ok {
continue
}
seen[product.ProductID] = struct{}{}
productIDs = append(productIDs, product.ProductID)
} }
if len(productIDs) == 0 {
return out, nil var deletedDetails []entity.StockTransferDetail
err = s.StockTransferRepo.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error {
stockLogRepoTx := rStockLogs.NewStockLogRepository(tx)
var transfer entity.StockTransfer
if err := tx.WithContext(c.Context()).
Clauses(clause.Locking{Strength: "UPDATE"}).
Where("id = ?", uint64(id)).
Where("deleted_at IS NULL").
Take(&transfer).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Transfer dengan ID %d tidak ditemukan", id))
}
return fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data transfer")
}
var details []entity.StockTransferDetail
if err := tx.WithContext(c.Context()).
Clauses(clause.Locking{Strength: "UPDATE"}).
Where("stock_transfer_id = ?", transfer.Id).
Where("deleted_at IS NULL").
Order("id ASC").
Find(&details).Error; err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil detail transfer")
}
if len(details) == 0 {
return fiber.NewError(fiber.StatusBadRequest, "Transfer tidak memiliki detail produk")
}
detailIDs := make([]uint64, 0, len(details))
for _, detail := range details {
detailIDs = append(detailIDs, detail.Id)
}
if err := s.ensureDeletePolicyForDownstreamConsumption(c.Context(), tx, detailIDs); err != nil {
return err
}
type reflowKey struct {
flagGroupCode string
productWarehouseID uint
}
destReflows := make(map[reflowKey]struct{})
for _, detail := range details {
if detail.SourceProductWarehouseID == nil || *detail.SourceProductWarehouseID == 0 {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Detail transfer %d tidak memiliki source product warehouse valid", detail.Id))
}
if detail.DestProductWarehouseID == nil || *detail.DestProductWarehouseID == 0 {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Detail transfer %d tidak memiliki destination product warehouse valid", detail.Id))
}
flagGroupCode, err := s.resolveTransferFlagGroup(c.Context(), tx, uint(detail.ProductId))
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("FIFO v2 route tidak ditemukan untuk produk %d: %v", detail.ProductId, err))
}
rollbackRes, err := s.FifoStockV2Svc.Rollback(c.Context(), commonSvc.FifoStockV2RollbackRequest{
ProductWarehouseID: uint(*detail.SourceProductWarehouseID),
Usable: commonSvc.FifoStockV2Ref{
ID: uint(detail.Id),
LegacyTypeKey: fifo.UsableKeyStockTransferOut.String(),
FunctionCode: "STOCK_TRANSFER_OUT",
},
Reason: fmt.Sprintf("transfer delete #%s", transfer.MovementNumber),
Tx: tx,
})
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Gagal rollback FIFO v2 transfer detail %d: %v", detail.Id, err))
}
releasedQty := 0.0
if rollbackRes != nil {
releasedQty = rollbackRes.ReleasedQty
}
if detail.UsageQty > 1e-6 && releasedQty < detail.UsageQty-1e-6 {
return fiber.NewError(
fiber.StatusBadRequest,
fmt.Sprintf("Rollback FIFO v2 source transfer detail %d tidak lengkap. Dibutuhkan %.3f, terlepas %.3f", detail.Id, detail.UsageQty, releasedQty),
)
}
if releasedQty > 1e-6 {
if err := s.appendStockLog(
c.Context(),
stockLogRepoTx,
uint(*detail.SourceProductWarehouseID),
actorID,
releasedQty,
0,
uint(detail.Id),
fmt.Sprintf("TRANSFER DELETE #%s", transfer.MovementNumber),
); err != nil {
return err
}
}
destDecreaseQty := detail.TotalQty
if destDecreaseQty <= 1e-6 {
destDecreaseQty = detail.UsageQty
}
if destDecreaseQty > 1e-6 {
if err := s.appendStockLog(
c.Context(),
stockLogRepoTx,
uint(*detail.DestProductWarehouseID),
actorID,
0,
destDecreaseQty,
uint(detail.Id),
fmt.Sprintf("TRANSFER DELETE #%s", transfer.MovementNumber),
); err != nil {
return err
}
}
destReflows[reflowKey{
flagGroupCode: flagGroupCode,
productWarehouseID: uint(*detail.DestProductWarehouseID),
}] = struct{}{}
}
now := time.Now().UTC()
if err := tx.WithContext(c.Context()).
Where("stock_transfer_detail_id IN ?", detailIDs).
Delete(&entity.StockTransferDeliveryItem{}).Error; err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Gagal menghapus item delivery transfer")
}
if err := tx.WithContext(c.Context()).
Model(&entity.StockTransferDelivery{}).
Where("stock_transfer_id = ?", transfer.Id).
Where("deleted_at IS NULL").
Updates(map[string]any{
"deleted_at": now,
"updated_at": now,
}).Error; err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Gagal menghapus delivery transfer")
}
if err := tx.WithContext(c.Context()).
Model(&entity.StockTransferDetail{}).
Where("id IN ?", detailIDs).
Where("deleted_at IS NULL").
Updates(map[string]any{
"deleted_at": now,
"updated_at": now,
}).Error; err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Gagal menghapus detail transfer")
}
asOf := transfer.TransferDate
for key := range destReflows {
if _, err := s.FifoStockV2Svc.Reflow(c.Context(), commonSvc.FifoStockV2ReflowRequest{
FlagGroupCode: key.flagGroupCode,
ProductWarehouseID: key.productWarehouseID,
AsOf: &asOf,
Tx: tx,
}); err != nil {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Gagal reflow stok tujuan saat delete transfer: %v", err))
}
}
if err := tx.WithContext(c.Context()).
Model(&entity.StockTransfer{}).
Where("id = ?", transfer.Id).
Where("deleted_at IS NULL").
Updates(map[string]any{
"deleted_at": now,
"updated_at": now,
}).Error; err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Gagal menghapus transfer")
}
deletedDetails = append(deletedDetails, details...)
return nil
})
if err != nil {
if fiberErr, ok := err.(*fiber.Error); ok {
return fiberErr
}
return fiber.NewError(fiber.StatusInternalServerError, "Gagal menghapus transfer")
}
if len(deletedDetails) > 0 && s.ExpenseBridge != nil {
if err := s.ExpenseBridge.OnItemsDeleted(c.Context(), uint64(id), deletedDetails); err != nil {
s.Log.Errorf("Failed to cleanup transfer expense link for transfer_id=%d: %+v", id, err)
return fiber.NewError(fiber.StatusInternalServerError, "Transfer berhasil dihapus, namun sinkronisasi expense gagal. Silakan cek modul expense")
}
}
return nil
}
func (s *transferService) resolveTransferFlagGroup(
ctx context.Context,
tx *gorm.DB,
productID uint,
) (string, error) {
if productID == 0 {
return "", fmt.Errorf("product id is required")
} }
type row struct { type row struct {
ProductID uint `gorm:"column:product_id"` FlagGroupCode string `gorm:"column:flag_group_code"`
} }
var rows []row var selected row
err := tx.WithContext(ctx). err := tx.WithContext(ctx).
Table("flags f"). Table("fifo_stock_v2_route_rules rr").
Select("DISTINCT f.flagable_id AS product_id"). Select("rr.flag_group_code").
Where("f.flagable_type = ?", entity.FlagableTypeProduct). Joins("JOIN fifo_stock_v2_flag_groups fg ON fg.code = rr.flag_group_code AND fg.is_active = TRUE").
Where("f.name IN ?", []string{"PAKAN", "PRE-STARTER", "STARTER", "FINISHER"}). Where("rr.is_active = TRUE").
Where("f.flagable_id IN ?", productIDs). Where("rr.lane = ?", "USABLE").
Scan(&rows).Error Where("rr.function_code = ?", "STOCK_TRANSFER_OUT").
Where("rr.source_table = ?", "stock_transfer_details").
Where(`
EXISTS (
SELECT 1
FROM flags f
JOIN fifo_stock_v2_flag_members fm ON fm.flag_name = f.name AND fm.is_active = TRUE
WHERE f.flagable_type = ?
AND f.flagable_id = ?
AND fm.flag_group_code = rr.flag_group_code
)
`, entity.FlagableTypeProduct, productID).
Order("rr.id ASC").
Limit(1).
Take(&selected).Error
if err != nil { if err != nil {
return nil, err return "", err
} }
for _, row := range rows { return strings.TrimSpace(selected.FlagGroupCode), nil
out[row.ProductID] = true
}
return out, nil
} }
func (s *transferService) notifyExpenseItemsDelivered(c *fiber.Ctx, transferID uint64, payloads []TransferExpenseReceivingPayload) error { func (s *transferService) notifyExpenseItemsDelivered(c *fiber.Ctx, transferID uint64, payloads []TransferExpenseReceivingPayload) error {
@@ -736,3 +912,264 @@ func (s *transferService) getActiveProjectFlockKandangID(ctx context.Context, wa
return uint(projectFlockKandang.Id), nil return uint(projectFlockKandang.Id), nil
} }
func (s *transferService) ensureTransferAccess(ctx context.Context, id uint, c *fiber.Ctx) error {
scope, err := m.ResolveLocationScope(c, s.StockTransferRepo.DB())
if err != nil {
return err
}
if !scope.Restrict {
return nil
}
if len(scope.IDs) == 0 {
return fiber.NewError(fiber.StatusNotFound, "Transfer not found")
}
var count int64
if err := s.StockTransferRepo.DB().WithContext(ctx).
Table("stock_transfers").
Joins("JOIN warehouses w_from ON w_from.id = stock_transfers.from_warehouse_id").
Joins("JOIN warehouses w_to ON w_to.id = stock_transfers.to_warehouse_id").
Where("stock_transfers.id = ?", id).
Where("stock_transfers.deleted_at IS NULL").
Where("w_from.location_id IN ? OR w_to.location_id IN ?", scope.IDs, scope.IDs).
Count(&count).Error; err != nil {
return err
}
if count == 0 {
return fiber.NewError(fiber.StatusNotFound, "Transfer not found")
}
return nil
}
func (s *transferService) ensureDeletePolicyForDownstreamConsumption(ctx context.Context, tx *gorm.DB, detailIDs []uint64) error {
dependencies, err := s.loadActiveTransferDownstreamDependencies(ctx, tx, detailIDs)
if err != nil {
s.Log.Errorf("Failed to load downstream stock transfer consumption: %+v", err)
return fiber.NewError(fiber.StatusInternalServerError, "Gagal memvalidasi transaksi turunan transfer stock")
}
if len(dependencies) == 0 {
return nil
}
ayamDependency, err := s.hasAyamDownstreamConsumption(ctx, tx, detailIDs)
if err != nil {
s.Log.Errorf("Failed to validate AYAM downstream dependency for transfer delete: %+v", err)
return fiber.NewError(fiber.StatusInternalServerError, "Gagal memvalidasi dependensi AYAM pada transfer stock")
}
if ayamDependency {
return fiber.NewError(
fiber.StatusBadRequest,
fmt.Sprintf(
"%s Dependensi aktif: %s. Alasan block: produk AYAM yang sudah terpakai tidak dapat dihapus.",
transferDeleteDownstreamGuardMessage,
formatDownstreamDependencySummary(dependencies),
),
)
}
denyReason := ""
for _, dep := range dependencies {
policy, policyErr := commonSvc.ResolveFifoPendingPolicy(ctx, tx, commonSvc.FifoPendingPolicyInput{
Lane: "USABLE",
FlagGroupCode: dep.FlagGroupCode,
FunctionCode: dep.FunctionCode,
LegacyTypeKey: dep.UsableType,
})
if policyErr != nil {
s.Log.Errorf("Failed to resolve FIFO pending policy for transfer dependency: %+v", policyErr)
return fiber.NewError(fiber.StatusInternalServerError, "Gagal membaca konfigurasi FIFO v2")
}
if !policy.Found || !policy.AllowPending {
denyReason = "pending disabled by config"
break
}
}
if denyReason == "" {
return nil
}
return fiber.NewError(
fiber.StatusBadRequest,
fmt.Sprintf(
"%s Dependensi aktif: %s. Alasan block: %s.",
transferDeleteDownstreamGuardMessage,
formatDownstreamDependencySummary(dependencies),
denyReason,
),
)
}
func (s *transferService) loadActiveTransferDownstreamDependencies(
ctx context.Context,
tx *gorm.DB,
detailIDs []uint64,
) ([]downstreamDependency, error) {
if len(detailIDs) == 0 {
return nil, nil
}
db := s.StockTransferRepo.DB().WithContext(ctx)
if tx != nil {
db = tx.WithContext(ctx)
}
var rows []downstreamDependency
err := db.Table("stock_allocations").
Select("usable_type, usable_id, COALESCE(function_code,'') AS function_code, COALESCE(flag_group_code,'') AS flag_group_code").
Where("stockable_type = ?", fifo.StockableKeyStockTransferIn.String()).
Where("stockable_id IN ?", detailIDs).
Where("status = ?", entity.StockAllocationStatusActive).
Where("allocation_purpose = ?", entity.StockAllocationPurposeConsume).
Where("deleted_at IS NULL").
Group("usable_type, usable_id, function_code, flag_group_code").
Scan(&rows).Error
if err != nil {
return nil, err
}
return rows, nil
}
func formatDownstreamDependencySummary(rows []downstreamDependency) string {
if len(rows) == 0 {
return "-"
}
dependencyMap := make(map[string]map[uint64]struct{})
for _, row := range rows {
label := mapTransferDownstreamUsableLabel(row.UsableType)
if _, ok := dependencyMap[label]; !ok {
dependencyMap[label] = make(map[uint64]struct{})
}
dependencyMap[label][row.UsableID] = struct{}{}
}
labels := make([]string, 0, len(dependencyMap))
for label := range dependencyMap {
labels = append(labels, label)
}
sort.Strings(labels)
details := make([]string, 0, len(labels))
for _, label := range labels {
ids := sortedUint64Keys(dependencyMap[label])
details = append(details, fmt.Sprintf("%s=%s", label, joinUint64(ids)))
}
return strings.Join(details, ", ")
}
func (s *transferService) hasAyamDownstreamConsumption(ctx context.Context, tx *gorm.DB, detailIDs []uint64) (bool, error) {
if len(detailIDs) == 0 {
return false, nil
}
db := s.StockTransferRepo.DB().WithContext(ctx)
if tx != nil {
db = tx.WithContext(ctx)
}
var found int64
err := db.Table("stock_allocations sa").
Joins("JOIN stock_transfer_details std ON std.id = sa.stockable_id AND std.deleted_at IS NULL").
Joins("JOIN flags f ON f.flagable_type = ? AND f.flagable_id = std.product_id", entity.FlagableTypeProduct).
Joins("JOIN fifo_stock_v2_flag_members fm ON fm.flag_name = f.name AND fm.flag_group_code = ? AND fm.is_active = TRUE", "AYAM").
Where("sa.stockable_type = ?", fifo.StockableKeyStockTransferIn.String()).
Where("sa.stockable_id IN ?", detailIDs).
Where("sa.status = ?", entity.StockAllocationStatusActive).
Where("sa.allocation_purpose = ?", entity.StockAllocationPurposeConsume).
Where("sa.deleted_at IS NULL").
Count(&found).Error
if err != nil {
return false, err
}
return found > 0, nil
}
func mapTransferDownstreamUsableLabel(usableType string) string {
switch strings.ToUpper(strings.TrimSpace(usableType)) {
case fifo.UsableKeyRecordingStock.String(), fifo.UsableKeyRecordingDepletion.String():
return "Recording"
case fifo.UsableKeyProjectChickin.String():
return "Chickin"
case fifo.UsableKeyMarketingDelivery.String():
return "Marketing"
case fifo.UsableKeyTransferToLayingOut.String():
return "TransferToLaying"
case fifo.UsableKeyStockTransferOut.String():
return "TransferStock"
case fifo.UsableKeyAdjustmentOut.String():
return "Adjustment"
default:
return strings.ToUpper(strings.TrimSpace(usableType))
}
}
func sortedUint64Keys(input map[uint64]struct{}) []uint64 {
if len(input) == 0 {
return nil
}
out := make([]uint64, 0, len(input))
for id := range input {
if id == 0 {
continue
}
out = append(out, id)
}
sort.Slice(out, func(i, j int) bool { return out[i] < out[j] })
return out
}
func joinUint64(values []uint64) string {
if len(values) == 0 {
return "-"
}
parts := make([]string, 0, len(values))
for _, value := range values {
parts = append(parts, fmt.Sprintf("%d", value))
}
return strings.Join(parts, "|")
}
func (s *transferService) appendStockLog(
ctx context.Context,
stockLogRepo rStockLogs.StockLogRepository,
productWarehouseID uint,
actorID uint,
increase float64,
decrease float64,
loggableID uint,
notes string,
) error {
if productWarehouseID == 0 || (increase <= 1e-6 && decrease <= 1e-6) {
return nil
}
stockLog := &entity.StockLog{
ProductWarehouseId: productWarehouseID,
CreatedBy: actorID,
Increase: increase,
Decrease: decrease,
LoggableType: string(utils.StockLogTypeTransfer),
LoggableId: loggableID,
Notes: notes,
}
stockLogs, err := stockLogRepo.GetByProductWarehouse(ctx, productWarehouseID, 1)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs")
}
if len(stockLogs) > 0 {
latestStockLog := stockLogs[0]
stockLog.Stock = latestStockLog.Stock + increase - decrease
} else {
stockLog.Stock = increase - decrease
}
if err := stockLogRepo.CreateOne(ctx, stockLog, nil); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat stock log saat delete transfer")
}
return nil
}
+4 -22
View File
@@ -2,7 +2,6 @@ package marketing
import ( import (
"fmt" "fmt"
"strings"
"github.com/go-playground/validator/v10" "github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
@@ -20,7 +19,6 @@ import (
rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories"
sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services"
"gitlab.com/mbugroup/lti-api.git/internal/utils" "gitlab.com/mbugroup/lti-api.git/internal/utils"
"gitlab.com/mbugroup/lti-api.git/internal/utils/fifo"
) )
type MarketingModule struct{} type MarketingModule struct{}
@@ -33,26 +31,10 @@ func (MarketingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate
userRepo := rUser.NewUserRepository(db) userRepo := rUser.NewUserRepository(db)
customerRepo := rCustomer.NewCustomerRepository(db) customerRepo := rCustomer.NewCustomerRepository(db)
productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db) productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db)
projectFlockPopulationRepo := rProjectFlockKandang.NewProjectFlockPopulationRepository(db)
stockLogRepo := rShared.NewStockLogRepository(db) stockLogRepo := rShared.NewStockLogRepository(db)
stockAllocationRepo := commonRepo.NewStockAllocationRepository(db) fifoStockV2Service := commonSvc.NewFifoStockV2Service(db, utils.Log)
fifoService := commonSvc.NewFifoService(db, stockAllocationRepo, productWarehouseRepo, utils.Log)
if err := fifoService.RegisterUsable(fifo.UsableConfig{
Key: fifo.UsableKeyMarketingDelivery,
Table: "marketing_delivery_products",
Columns: fifo.UsableColumns{
ID: "id",
ProductWarehouseID: "product_warehouse_id",
UsageQuantity: "usage_qty",
PendingQuantity: "pending_qty",
CreatedAt: "created_at",
},
}); err != nil {
if !strings.Contains(strings.ToLower(err.Error()), "already registered") {
panic(fmt.Sprintf("failed to register marketing delivery usable workflow: %v", err))
}
}
approvalRepo := commonRepo.NewApprovalRepository(db) approvalRepo := commonRepo.NewApprovalRepository(db)
approvalSvc := commonSvc.NewApprovalService(approvalRepo) approvalSvc := commonSvc.NewApprovalService(approvalRepo)
@@ -64,8 +46,8 @@ func (MarketingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate
warehouseRepo := rWarehouse.NewWarehouseRepository(db) warehouseRepo := rWarehouse.NewWarehouseRepository(db)
projectFlockKandangRepo := rProjectFlockKandang.NewProjectFlockKandangRepository(db) projectFlockKandangRepo := rProjectFlockKandang.NewProjectFlockKandangRepository(db)
salesOrdersService := service.NewSalesOrdersService(marketingRepo, customerRepo, productWarehouseRepo, userRepo, approvalSvc, fifoService, warehouseRepo, projectFlockKandangRepo, validate) salesOrdersService := service.NewSalesOrdersService(marketingRepo, customerRepo, productWarehouseRepo, userRepo, approvalSvc, fifoStockV2Service, warehouseRepo, projectFlockKandangRepo, validate)
deliveryOrdersService := service.NewDeliveryOrdersService(marketingRepo, marketingProductRepo, marketingDeliveryProductRepo, stockLogRepo, approvalSvc, fifoService, validate) deliveryOrdersService := service.NewDeliveryOrdersService(marketingRepo, marketingProductRepo, marketingDeliveryProductRepo, stockLogRepo, productWarehouseRepo, projectFlockPopulationRepo, approvalSvc, fifoStockV2Service, validate)
userService := sUser.NewUserService(userRepo, validate) userService := sUser.NewUserService(userRepo, validate)
RegisterRoutes(router, userService, salesOrdersService, deliveryOrdersService) RegisterRoutes(router, userService, salesOrdersService, deliveryOrdersService)
@@ -4,15 +4,19 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"strings"
"time" "time"
commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository"
commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service"
fifoV2 "gitlab.com/mbugroup/lti-api.git/internal/common/service/fifo_stock_v2"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities" entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
m "gitlab.com/mbugroup/lti-api.git/internal/middleware" m "gitlab.com/mbugroup/lti-api.git/internal/middleware"
rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories"
"gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/dto" "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/dto"
marketingRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/repositories" marketingRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/repositories"
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/validations" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/validations"
rProjectFlock "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories"
rShared "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/repositories" rShared "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"
"gitlab.com/mbugroup/lti-api.git/internal/utils/fifo" "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo"
@@ -35,8 +39,10 @@ type deliveryOrdersService struct {
MarketingProductRepo marketingRepo.MarketingProductRepository MarketingProductRepo marketingRepo.MarketingProductRepository
MarketingDeliveryProductRepo marketingRepo.MarketingDeliveryProductRepository MarketingDeliveryProductRepo marketingRepo.MarketingDeliveryProductRepository
StockLogRepo rShared.StockLogRepository StockLogRepo rShared.StockLogRepository
ProductWarehouseRepo rProductWarehouse.ProductWarehouseRepository
ProjectFlockPopulationRepo rProjectFlock.ProjectFlockPopulationRepository
ApprovalSvc commonSvc.ApprovalService ApprovalSvc commonSvc.ApprovalService
FifoSvc commonSvc.FifoService FifoStockV2Svc commonSvc.FifoStockV2Service
} }
func NewDeliveryOrdersService( func NewDeliveryOrdersService(
@@ -44,8 +50,10 @@ func NewDeliveryOrdersService(
marketingProductRepo marketingRepo.MarketingProductRepository, marketingProductRepo marketingRepo.MarketingProductRepository,
marketingDeliveryProductRepo marketingRepo.MarketingDeliveryProductRepository, marketingDeliveryProductRepo marketingRepo.MarketingDeliveryProductRepository,
stockLogRepo rShared.StockLogRepository, stockLogRepo rShared.StockLogRepository,
productWarehouseRepo rProductWarehouse.ProductWarehouseRepository,
projectFlockPopulationRepo rProjectFlock.ProjectFlockPopulationRepository,
approvalSvc commonSvc.ApprovalService, approvalSvc commonSvc.ApprovalService,
fifoSvc commonSvc.FifoService, fifoStockV2Svc commonSvc.FifoStockV2Service,
validate *validator.Validate, validate *validator.Validate,
) DeliveryOrdersService { ) DeliveryOrdersService {
return &deliveryOrdersService{ return &deliveryOrdersService{
@@ -54,8 +62,10 @@ func NewDeliveryOrdersService(
MarketingProductRepo: marketingProductRepo, MarketingProductRepo: marketingProductRepo,
MarketingDeliveryProductRepo: marketingDeliveryProductRepo, MarketingDeliveryProductRepo: marketingDeliveryProductRepo,
StockLogRepo: stockLogRepo, StockLogRepo: stockLogRepo,
ProductWarehouseRepo: productWarehouseRepo,
ProjectFlockPopulationRepo: projectFlockPopulationRepo,
ApprovalSvc: approvalSvc, ApprovalSvc: approvalSvc,
FifoSvc: fifoSvc, FifoStockV2Svc: fifoStockV2Svc,
} }
} }
@@ -549,33 +559,45 @@ func (s deliveryOrdersService) consumeDeliveryStock(ctx context.Context, tx *gor
return fiber.NewError(fiber.StatusInternalServerError, "Delivery product not found") return fiber.NewError(fiber.StatusInternalServerError, "Delivery product not found")
} }
result, err := s.FifoSvc.Consume(ctx, commonSvc.StockConsumeRequest{ deliveryProductRepo := marketingRepo.NewMarketingDeliveryProductRepository(tx)
UsableKey: fifo.UsableKeyMarketingDelivery, previousUsage := deliveryProduct.UsageQty
UsableID: deliveryProduct.Id, deliveryProduct.UsageQty = requestedQty
ProductWarehouseID: marketingProduct.ProductWarehouseId, deliveryProduct.PendingQty = 0
Quantity: requestedQty,
AllowPending: false,
Tx: tx,
})
if err != nil { if err := deliveryProductRepo.UpdateOne(ctx, deliveryProduct.Id, deliveryProduct, nil); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to update delivery product")
}
if err := reflowMarketingScope(
ctx,
s.FifoStockV2Svc,
tx,
marketingProduct.ProductWarehouseId,
resolveMarketingAsOf(deliveryProduct.DeliveryDate, deliveryProduct.CreatedAt),
); err != nil {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Insufficient stock for product warehouse %d: %v", marketingProduct.ProductWarehouseId, err)) return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Insufficient stock for product warehouse %d: %v", marketingProduct.ProductWarehouseId, err))
} }
deliveryProductRepo := marketingRepo.NewMarketingDeliveryProductRepository(tx) refreshed, err := deliveryProductRepo.GetByID(ctx, deliveryProduct.Id, nil)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to refresh delivery product")
}
deliveryProduct.UsageQty = refreshed.UsageQty
deliveryProduct.PendingQty = refreshed.PendingQty
deliveryProduct.CreatedAt = refreshed.CreatedAt
if err := deliveryProductRepo.UpdateFifoFields(ctx, deliveryProduct.Id, result.UsageQuantity, 0); err != nil { if err := s.allocatePopulationForMarketingDelivery(ctx, tx, deliveryProduct, marketingProduct.ProductWarehouseId); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to update delivery product") return err
} }
if actorID > 0 && result.UsageQuantity > 0 { allocatedDelta := deliveryProduct.UsageQty - previousUsage
if actorID > 0 && allocatedDelta > 0 {
decreaseLog := &entity.StockLog{ decreaseLog := &entity.StockLog{
Decrease: result.UsageQuantity, Decrease: allocatedDelta,
LoggableType: string(utils.StockLogTypeMarketing), LoggableType: string(utils.StockLogTypeMarketing),
LoggableId: deliveryProduct.Id, LoggableId: deliveryProduct.Id,
ProductWarehouseId: marketingProduct.ProductWarehouseId, ProductWarehouseId: marketingProduct.ProductWarehouseId,
CreatedBy: actorID, CreatedBy: actorID,
Notes: fmt.Sprintf("FIFO consume (%.2f)", result.UsageQuantity), Notes: fmt.Sprintf("FIFO v2 reflow consume (%.2f)", allocatedDelta),
} }
stockLogs, err := s.StockLogRepo.GetByProductWarehouse(ctx, marketingProduct.ProductWarehouseId, 1) stockLogs, err := s.StockLogRepo.GetByProductWarehouse(ctx, marketingProduct.ProductWarehouseId, 1)
@@ -604,35 +626,49 @@ func (s deliveryOrdersService) releaseDeliveryStock(ctx context.Context, tx *gor
} }
deliveryProductRepo := marketingRepo.NewMarketingDeliveryProductRepository(tx) deliveryProductRepo := marketingRepo.NewMarketingDeliveryProductRepository(tx)
currentUsage, err := deliveryProductRepo.GetUsageQty(ctx, deliveryProduct.Id) currentUsage := deliveryProduct.UsageQty
if err != nil { currentPending := deliveryProduct.PendingQty
currentUsage = 0 if currentUsage <= 0 && currentPending <= 0 {
}
if currentUsage == 0 {
return nil return nil
} }
if err := s.FifoSvc.ReleaseUsage(ctx, commonSvc.StockReleaseRequest{ deliveryProduct.UsageQty = 0
UsableKey: fifo.UsableKeyMarketingDelivery, deliveryProduct.PendingQty = 0
UsableID: deliveryProduct.Id, if err := deliveryProductRepo.UpdateOne(ctx, deliveryProduct.Id, deliveryProduct, nil); err != nil {
Tx: tx, return fiber.NewError(fiber.StatusInternalServerError, "Failed to reset delivery product")
}); err != nil { }
if err := reflowMarketingScope(
ctx,
s.FifoStockV2Svc,
tx,
marketingProduct.ProductWarehouseId,
resolveMarketingAsOf(deliveryProduct.DeliveryDate, deliveryProduct.CreatedAt),
); err != nil {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Failed to reflow stock for product warehouse %d: %v", marketingProduct.ProductWarehouseId, err))
}
refreshed, err := deliveryProductRepo.GetByID(ctx, deliveryProduct.Id, nil)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to refresh delivery product")
}
deliveryProduct.UsageQty = refreshed.UsageQty
deliveryProduct.PendingQty = refreshed.PendingQty
deliveryProduct.CreatedAt = refreshed.CreatedAt
if err := fifoV2.ReleasePopulationConsumptionByUsable(ctx, tx, fifo.UsableKeyMarketingDelivery.String(), deliveryProduct.Id); err != nil {
return err return err
} }
if err := deliveryProductRepo.ResetFifoFields(ctx, deliveryProduct.Id); err != nil { releasedUsage := currentUsage - deliveryProduct.UsageQty
return err if actorID > 0 && releasedUsage > 0 {
}
if actorID > 0 && currentUsage > 0 {
increaseLog := &entity.StockLog{ increaseLog := &entity.StockLog{
Increase: currentUsage, Increase: releasedUsage,
LoggableType: string(utils.StockLogTypeMarketing), LoggableType: string(utils.StockLogTypeMarketing),
LoggableId: deliveryProduct.Id, LoggableId: deliveryProduct.Id,
ProductWarehouseId: marketingProduct.ProductWarehouseId, ProductWarehouseId: marketingProduct.ProductWarehouseId,
CreatedBy: actorID, CreatedBy: actorID,
Notes: fmt.Sprintf("Release delivery stock (%.2f)", currentUsage), Notes: fmt.Sprintf("FIFO v2 reflow release (%.2f)", releasedUsage),
} }
stockLogs, err := s.StockLogRepo.GetByProductWarehouse(ctx, marketingProduct.ProductWarehouseId, 1) stockLogs, err := s.StockLogRepo.GetByProductWarehouse(ctx, marketingProduct.ProductWarehouseId, 1)
if err != nil { if err != nil {
@@ -650,3 +686,57 @@ func (s deliveryOrdersService) releaseDeliveryStock(ctx context.Context, tx *gor
return nil return nil
} }
func (s deliveryOrdersService) allocatePopulationForMarketingDelivery(
ctx context.Context,
tx *gorm.DB,
deliveryProduct *entity.MarketingDeliveryProduct,
productWarehouseID uint,
) error {
if deliveryProduct == nil || deliveryProduct.Id == 0 {
return fiber.NewError(fiber.StatusBadRequest, "Delivery product tidak valid")
}
if tx == nil {
return errors.New("transaction is required")
}
if deliveryProduct.UsageQty <= 0 {
return nil
}
if productWarehouseID == 0 {
return fiber.NewError(fiber.StatusBadRequest, "Product warehouse tidak ditemukan")
}
flagGroupCode, err := resolveMarketingFlagGroupByProductWarehouse(ctx, tx, productWarehouseID)
if err != nil {
return err
}
if !strings.EqualFold(flagGroupCode, "AYAM") {
return nil
}
pw, err := s.ProductWarehouseRepo.WithTx(tx).GetByID(ctx, productWarehouseID, nil)
if err != nil {
return err
}
if pw.ProjectFlockKandangId == nil || *pw.ProjectFlockKandangId == 0 {
return nil
}
populations, err := s.ProjectFlockPopulationRepo.WithTx(tx).GetByProjectFlockKandangIDAndProductWarehouseID(ctx, *pw.ProjectFlockKandangId, productWarehouseID)
if err != nil {
return err
}
if len(populations) == 0 {
return fiber.NewError(fiber.StatusBadRequest, "Populasi tidak ditemukan untuk delivery")
}
return fifoV2.AllocatePopulationConsumption(
ctx,
tx,
populations,
productWarehouseID,
fifo.UsableKeyMarketingDelivery.String(),
deliveryProduct.Id,
deliveryProduct.UsageQty,
)
}
@@ -0,0 +1,97 @@
package service
import (
"context"
"fmt"
"strings"
"time"
commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
"gorm.io/gorm"
)
const (
marketingOutFunctionCode = "MARKETING_OUT"
marketingUsableLane = "USABLE"
marketingSourceTable = "marketing_delivery_products"
)
func reflowMarketingScope(
ctx context.Context,
fifoStockV2Svc commonSvc.FifoStockV2Service,
tx *gorm.DB,
productWarehouseID uint,
asOf *time.Time,
) error {
if fifoStockV2Svc == nil {
return fmt.Errorf("FIFO v2 service is not available")
}
if productWarehouseID == 0 {
return fmt.Errorf("product warehouse id is required")
}
flagGroupCode, err := resolveMarketingFlagGroupByProductWarehouse(ctx, tx, productWarehouseID)
if err != nil {
return err
}
if strings.TrimSpace(flagGroupCode) == "" {
return fmt.Errorf("flag group code is not found for product warehouse %d", productWarehouseID)
}
_, err = fifoStockV2Svc.Reflow(ctx, commonSvc.FifoStockV2ReflowRequest{
FlagGroupCode: flagGroupCode,
ProductWarehouseID: productWarehouseID,
AsOf: asOf,
Tx: tx,
})
return err
}
func resolveMarketingFlagGroupByProductWarehouse(ctx context.Context, tx *gorm.DB, productWarehouseID uint) (string, error) {
type row struct {
FlagGroupCode string `gorm:"column:flag_group_code"`
}
var 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 = ?", marketingUsableLane).
Where("rr.function_code = ?", marketingOutFunctionCode).
Where("rr.source_table = ?", marketingSourceTable).
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("rr.id ASC").
Limit(1).
Take(&selected).Error
if err != nil {
return "", err
}
return strings.TrimSpace(selected.FlagGroupCode), nil
}
func resolveMarketingAsOf(deliveryDate, createdAt *time.Time) *time.Time {
if deliveryDate != nil {
asOf := *deliveryDate
return &asOf
}
if createdAt != nil {
asOf := *createdAt
return &asOf
}
asOf := time.Now()
return &asOf
}
@@ -20,7 +20,6 @@ import (
userRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" userRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories"
"gitlab.com/mbugroup/lti-api.git/internal/utils" "gitlab.com/mbugroup/lti-api.git/internal/utils"
approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals" approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals"
"gitlab.com/mbugroup/lti-api.git/internal/utils/fifo"
"github.com/go-playground/validator/v10" "github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
@@ -43,12 +42,12 @@ type salesOrdersService struct {
ProductWarehouseRepo productWarehouseRepo.ProductWarehouseRepository ProductWarehouseRepo productWarehouseRepo.ProductWarehouseRepository
UserRepo userRepo.UserRepository UserRepo userRepo.UserRepository
ApprovalSvc commonSvc.ApprovalService ApprovalSvc commonSvc.ApprovalService
FifoSvc commonSvc.FifoService FifoStockV2Svc commonSvc.FifoStockV2Service
WarehouseRepo warehouseRepo.WarehouseRepository WarehouseRepo warehouseRepo.WarehouseRepository
ProjectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository ProjectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository
} }
func NewSalesOrdersService(marketingRepo repository.MarketingRepository, customerRepo customerRepo.CustomerRepository, productWarehouseRepo productWarehouseRepo.ProductWarehouseRepository, userRepo userRepo.UserRepository, approvalSvc commonSvc.ApprovalService, fifoSvc commonSvc.FifoService, warehouseRepo warehouseRepo.WarehouseRepository, func NewSalesOrdersService(marketingRepo repository.MarketingRepository, customerRepo customerRepo.CustomerRepository, productWarehouseRepo productWarehouseRepo.ProductWarehouseRepository, userRepo userRepo.UserRepository, approvalSvc commonSvc.ApprovalService, fifoStockV2Svc commonSvc.FifoStockV2Service, warehouseRepo warehouseRepo.WarehouseRepository,
projectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository, validate *validator.Validate) SalesOrdersService { projectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository, validate *validator.Validate) SalesOrdersService {
return &salesOrdersService{ return &salesOrdersService{
Log: utils.Log, Log: utils.Log,
@@ -58,7 +57,7 @@ func NewSalesOrdersService(marketingRepo repository.MarketingRepository, custome
ProductWarehouseRepo: productWarehouseRepo, ProductWarehouseRepo: productWarehouseRepo,
UserRepo: userRepo, UserRepo: userRepo,
ApprovalSvc: approvalSvc, ApprovalSvc: approvalSvc,
FifoSvc: fifoSvc, FifoStockV2Svc: fifoStockV2Svc,
WarehouseRepo: warehouseRepo, WarehouseRepo: warehouseRepo,
ProjectFlockKandangRepo: projectFlockKandangRepo, ProjectFlockKandangRepo: projectFlockKandangRepo,
} }
@@ -376,15 +375,18 @@ func (s salesOrdersService) UpdateOne(c *fiber.Ctx, req *validation.Update, id u
if qtyDiff < 0 { if qtyDiff < 0 {
return fiber.NewError(fiber.StatusBadRequest, "Cannot decrease quantity after stock has been allocated. Please delete and create new product.") return fiber.NewError(fiber.StatusBadRequest, "Cannot decrease quantity after stock has been allocated. Please delete and create new product.")
} else if qtyDiff > 0 { } else if qtyDiff > 0 {
_, err := s.FifoSvc.Consume(c.Context(), commonSvc.StockConsumeRequest{ nextRequestedQty := deliveryProduct.UsageQty + deliveryProduct.PendingQty + qtyDiff
UsableKey: fifo.UsableKeyMarketingDelivery, if err := invDeliveryRepoTx.UpdateFifoFields(c.Context(), deliveryProduct.Id, nextRequestedQty, 0); err != nil {
UsableID: deliveryProduct.Id, return fiber.NewError(fiber.StatusInternalServerError, "Failed to update marketing delivery fifo fields")
ProductWarehouseID: rp.ProductWarehouseId, }
Quantity: qtyDiff, if err := reflowMarketingScope(
Tx: dbTransaction, c.Context(),
}) s.FifoStockV2Svc,
if err != nil { dbTransaction,
return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Insufficient stock for additional quantity: %v", err)) rp.ProductWarehouseId,
resolveMarketingAsOf(deliveryProduct.DeliveryDate, deliveryProduct.CreatedAt),
); err != nil {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Failed to reflow stock via FIFO v2: %v", err))
} }
} }
} }
@@ -439,12 +441,17 @@ func (s salesOrdersService) UpdateOne(c *fiber.Ctx, req *validation.Update, id u
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Cannot delete marketing product %d because it has been delivered", old.Id)) return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Cannot delete marketing product %d because it has been delivered", old.Id))
} }
if err := s.FifoSvc.ReleaseUsage(c.Context(), commonSvc.StockReleaseRequest{ if err := invDeliveryRepoTx.UpdateFifoFields(c.Context(), deliveryProduct.Id, 0, 0); err != nil {
UsableKey: fifo.UsableKeyMarketingDelivery, return fiber.NewError(fiber.StatusInternalServerError, "Failed to reset marketing delivery fifo fields")
UsableID: deliveryProduct.Id, }
Tx: dbTransaction, if err := reflowMarketingScope(
}); err != nil { c.Context(),
return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to release stock: %v", err)) s.FifoStockV2Svc,
dbTransaction,
deliveryProduct.ProductWarehouseId,
resolveMarketingAsOf(deliveryProduct.DeliveryDate, deliveryProduct.CreatedAt),
); err != nil {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Failed to reflow stock via FIFO v2: %v", err))
} }
if err := invDeliveryRepoTx.DeleteOne(c.Context(), deliveryProduct.Id); err != nil { if err := invDeliveryRepoTx.DeleteOne(c.Context(), deliveryProduct.Id); err != nil {
@@ -523,12 +530,17 @@ func (s salesOrdersService) DeleteOne(c *fiber.Ctx, id uint) error {
deliveryProducts, err := marketingDeliveryProductRepoTx.GetByMarketingId(c.Context(), marketing.Id) deliveryProducts, err := marketingDeliveryProductRepoTx.GetByMarketingId(c.Context(), marketing.Id)
if err == nil && len(deliveryProducts) > 0 { if err == nil && len(deliveryProducts) > 0 {
for _, dp := range deliveryProducts { for _, dp := range deliveryProducts {
if err := s.FifoSvc.ReleaseUsage(c.Context(), commonSvc.StockReleaseRequest{ if err := marketingDeliveryProductRepoTx.UpdateFifoFields(c.Context(), dp.Id, 0, 0); err != nil {
UsableKey: fifo.UsableKeyMarketingDelivery, return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to reset fifo fields for delivery product %d", dp.Id))
UsableID: dp.Id, }
Tx: dbTransaction, if err := reflowMarketingScope(
}); err != nil { c.Context(),
return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to release stock for delivery product %d: %v", dp.Id, err)) s.FifoStockV2Svc,
dbTransaction,
dp.ProductWarehouseId,
resolveMarketingAsOf(dp.DeliveryDate, dp.CreatedAt),
); err != nil {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Failed to reflow stock via FIFO v2 for delivery product %d: %v", dp.Id, err))
} }
} }
} }
@@ -6,6 +6,7 @@ import (
"fmt" "fmt"
"strings" "strings"
"gitlab.com/mbugroup/lti-api.git/internal/config"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities" entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
m "gitlab.com/mbugroup/lti-api.git/internal/middleware" m "gitlab.com/mbugroup/lti-api.git/internal/middleware"
repository "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards/repositories" repository "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards/repositories"
@@ -343,17 +344,22 @@ func (s productionStandardService) EnsureWeekStart(ctx context.Context, standard
return nil return nil
} }
layingWeekStart := config.LayingWeekStart()
switch strings.ToUpper(category) { switch strings.ToUpper(category) {
case string(utils.ProjectFlockCategoryLaying): case string(utils.ProjectFlockCategoryLaying):
details, err := s.ProductionStandardDetailRepo.GetByProductionStandardID(ctx, standardID) details, err := s.ProductionStandardDetailRepo.GetByProductionStandardID(ctx, standardID)
if err != nil { if err != nil {
return err return err
} }
startWeek := 0 if len(details) == 0 {
if len(details) > 0 { return fiber.NewError(
startWeek = details[0].Week fiber.StatusBadRequest,
"Standart production tidak tersedia untuk kategori laying",
)
} }
if startWeek != 18 { startWeek := details[0].Week
if startWeek > layingWeekStart {
return fiber.NewError(fiber.StatusBadRequest, "Week tidak sesuai dengan standart kategori project flock") return fiber.NewError(fiber.StatusBadRequest, "Week tidak sesuai dengan standart kategori project flock")
} }
case string(utils.ProjectFlockCategoryGrowing): case string(utils.ProjectFlockCategoryGrowing):
@@ -361,10 +367,13 @@ func (s productionStandardService) EnsureWeekStart(ctx context.Context, standard
if err != nil { if err != nil {
return err return err
} }
startWeek := 0 if len(details) == 0 {
if len(details) > 0 { return fiber.NewError(
startWeek = details[0].Week fiber.StatusBadRequest,
"Standart production tidak tersedia untuk kategori growing",
)
} }
startWeek := details[0].Week
if startWeek != 1 { if startWeek != 1 {
return fiber.NewError(fiber.StatusBadRequest, "Week tidak sesuai dengan standart kategori project flock") return fiber.NewError(fiber.StatusBadRequest, "Week tidak sesuai dengan standart kategori project flock")
} }
@@ -381,7 +390,7 @@ func (s productionStandardService) EnsureWeekAvailable(ctx context.Context, stan
upperCategory := strings.ToUpper(category) upperCategory := strings.ToUpper(category)
weekBase := 1 weekBase := 1
if upperCategory == string(utils.ProjectFlockCategoryLaying) { if upperCategory == string(utils.ProjectFlockCategoryLaying) {
weekBase = 18 weekBase = config.LayingWeekStart()
} }
week := ((day - 1) / 7) + weekBase week := ((day - 1) / 7) + weekBase
if week <= 0 { if week <= 0 {
@@ -30,6 +30,14 @@ func (u *ProductController) GetAll(c *fiber.Ctx) error {
ProductCategoryID: c.QueryInt("product_category_id", 0), ProductCategoryID: c.QueryInt("product_category_id", 0),
} }
if isDepletionParam := c.Query("is_depletion", ""); isDepletionParam != "" {
value, err := strconv.ParseBool(isDepletionParam)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, "invalid is_depletion value")
}
query.IsDepletion = &value
}
if query.Page < 1 || query.Limit < 1 { if query.Page < 1 || query.Limit < 1 {
return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0") return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0")
} }
@@ -7,6 +7,7 @@ import (
productCategoryDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/product-categories/dto" productCategoryDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/product-categories/dto"
uomDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/uoms/dto" uomDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/uoms/dto"
userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto" userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto"
utils "gitlab.com/mbugroup/lti-api.git/internal/utils"
) )
// === DTO Structs === // === DTO Structs ===
@@ -17,6 +18,9 @@ type ProductRelationDTO struct {
ProductPrice float64 `gorm:"type:numeric(15,3);not null"` ProductPrice float64 `gorm:"type:numeric(15,3);not null"`
SellingPrice *float64 `gorm:"type:numeric(15,3)"` SellingPrice *float64 `gorm:"type:numeric(15,3)"`
Uom *uomDTO.UomRelationDTO `json:"uom,omitempty"` Uom *uomDTO.UomRelationDTO `json:"uom,omitempty"`
Flag *string `json:"flag,omitempty"`
SubFlag *string `json:"sub_flag,omitempty"`
SubFlags *[]string `json:"sub_flags,omitempty"`
Flags *[]string `json:"flags,omitempty"` Flags *[]string `json:"flags,omitempty"`
ProductCategory *productCategoryDTO.ProductCategoryRelationDTO `json:"product_category,omitempty"` ProductCategory *productCategoryDTO.ProductCategoryRelationDTO `json:"product_category,omitempty"`
Suppliers []ProductSupplierDTO `json:"suppliers"` Suppliers []ProductSupplierDTO `json:"suppliers"`
@@ -31,6 +35,9 @@ type ProductListDTO struct {
SellingPrice *float64 `json:"selling_price,omitempty"` SellingPrice *float64 `json:"selling_price,omitempty"`
Tax *float64 `json:"tax,omitempty"` Tax *float64 `json:"tax,omitempty"`
ExpiryPeriod *int `json:"expiry_period,omitempty"` ExpiryPeriod *int `json:"expiry_period,omitempty"`
Flag *string `json:"flag,omitempty"`
SubFlag *string `json:"sub_flag,omitempty"`
SubFlags []string `json:"sub_flags,omitempty"`
Flags []string `json:"flags"` Flags []string `json:"flags"`
Uom *uomDTO.UomRelationDTO `json:"uom,omitempty"` Uom *uomDTO.UomRelationDTO `json:"uom,omitempty"`
ProductCategory *productCategoryDTO.ProductCategoryRelationDTO `json:"product_category,omitempty"` ProductCategory *productCategoryDTO.ProductCategoryRelationDTO `json:"product_category,omitempty"`
@@ -59,6 +66,13 @@ func ToProductRelationDTO(e entity.Product) ProductRelationDTO {
for i, f := range e.Flags { for i, f := range e.Flags {
flags[i] = f.Name flags[i] = f.Name
} }
flag, subFlag, subFlags := resolveProductFlagAndSubFlags(flags)
var subFlagsRef *[]string
if len(subFlags) > 0 {
values := make([]string, len(subFlags))
copy(values, subFlags)
subFlagsRef = &values
}
var uomRef *uomDTO.UomRelationDTO var uomRef *uomDTO.UomRelationDTO
if e.Uom.Id != 0 { if e.Uom.Id != 0 {
@@ -77,6 +91,9 @@ func ToProductRelationDTO(e entity.Product) ProductRelationDTO {
Name: e.Name, Name: e.Name,
ProductPrice: e.ProductPrice, ProductPrice: e.ProductPrice,
SellingPrice: e.SellingPrice, SellingPrice: e.SellingPrice,
Flag: flag,
SubFlag: subFlag,
SubFlags: subFlagsRef,
Flags: &flags, Flags: &flags,
Uom: uomRef, Uom: uomRef,
ProductCategory: categoryRef, ProductCategory: categoryRef,
@@ -101,6 +118,7 @@ func ToProductListDTO(e entity.Product) ProductListDTO {
for i, f := range e.Flags { for i, f := range e.Flags {
flags[i] = f.Name flags[i] = f.Name
} }
flag, subFlag, subFlags := resolveProductFlagAndSubFlags(flags)
var uomRef *uomDTO.UomRelationDTO var uomRef *uomDTO.UomRelationDTO
if e.Uom.Id != 0 { if e.Uom.Id != 0 {
@@ -111,6 +129,9 @@ func ToProductListDTO(e entity.Product) ProductListDTO {
return ProductListDTO{ return ProductListDTO{
Id: e.Id, Id: e.Id,
Name: e.Name, Name: e.Name,
Flag: flag,
SubFlag: subFlag,
SubFlags: subFlags,
Flags: flags, Flags: flags,
Uom: uomRef, Uom: uomRef,
Brand: e.Brand, Brand: e.Brand,
@@ -141,6 +162,58 @@ func ToProductDetailDTO(e entity.Product) ProductDetailDTO {
} }
} }
func resolveProductFlagAndSubFlags(flags []string) (*string, *string, []string) {
normalized := utils.NormalizeFlagTypes(flags)
if len(normalized) == 0 {
return nil, nil, nil
}
available := make(map[utils.FlagType]struct{}, len(normalized))
for _, flag := range normalized {
available[flag] = struct{}{}
}
var selectedFlag utils.FlagType
for _, mainFlag := range utils.ProductMainFlags() {
if _, ok := available[mainFlag]; ok {
selectedFlag = mainFlag
break
}
}
if selectedFlag == "" {
subToMain := utils.ProductSubFlagToFlag()
for _, flag := range normalized {
if parent, ok := subToMain[flag]; ok {
selectedFlag = parent
break
}
}
}
if selectedFlag == "" {
return nil, nil, nil
}
flag := string(selectedFlag)
var subFlag *string
subFlagValues := make([]string, 0)
subFlagsByMain := utils.ProductSubFlagsByFlag()
for _, sub := range subFlagsByMain[selectedFlag] {
if _, ok := available[sub]; ok {
subFlagValues = append(subFlagValues, string(sub))
}
}
if len(subFlagValues) > 0 {
first := subFlagValues[0]
subFlag = &first
}
return &flag, subFlag, subFlagValues
}
func toProductSupplierDTOs(relations []entity.ProductSupplier) []ProductSupplierDTO { func toProductSupplierDTOs(relations []entity.ProductSupplier) []ProductSupplierDTO {
if len(relations) == 0 { if len(relations) == 0 {
return make([]ProductSupplierDTO, 0) return make([]ProductSupplierDTO, 0)
@@ -31,6 +31,12 @@ type productService struct {
Repository repository.ProductRepository Repository repository.ProductRepository
} }
var depletionProductFlags = []string{
string(utils.FlagAyamAfkir),
string(utils.FlagAyamCulling),
string(utils.FlagAyamMati),
}
func normalizeProductFlags(raw []string) ([]string, error) { func normalizeProductFlags(raw []string) ([]string, error) {
normalized, invalid := utils.NormalizeFlagsForGroup(raw, utils.FlagGroupProduct) normalized, invalid := utils.NormalizeFlagsForGroup(raw, utils.FlagGroupProduct)
if len(invalid) > 0 { if len(invalid) > 0 {
@@ -41,6 +47,159 @@ func normalizeProductFlags(raw []string) ([]string, error) {
return utils.FlagTypesToStrings(normalized), nil return utils.FlagTypesToStrings(normalized), nil
} }
func productMainFlagOptionsString() []string {
mainFlags := utils.ProductMainFlags()
result := make([]string, len(mainFlags))
for i, flag := range mainFlags {
result[i] = string(flag)
}
return result
}
func productSubFlagOptionsString(flag utils.FlagType) []string {
subFlagsByFlag := utils.ProductSubFlagsByFlag()
subFlags := subFlagsByFlag[flag]
result := make([]string, len(subFlags))
for i, subFlag := range subFlags {
result[i] = string(subFlag)
}
return result
}
func normalizeStructuredSubFlagsInput(subFlagRaw *string, subFlagsRaw []string, hasSubFlagsField bool) ([]utils.FlagType, error) {
values := make([]string, 0, len(subFlagsRaw)+1)
if subFlagRaw != nil {
single := strings.TrimSpace(*subFlagRaw)
if single == "" {
return nil, fiber.NewError(fiber.StatusBadRequest, "sub_flag cannot be empty")
}
values = append(values, single)
}
if hasSubFlagsField {
for _, raw := range subFlagsRaw {
item := strings.TrimSpace(raw)
if item == "" {
return nil, fiber.NewError(fiber.StatusBadRequest, "sub_flags cannot contain empty value")
}
values = append(values, item)
}
}
if len(values) == 0 {
return nil, nil
}
return utils.NormalizeFlagTypes(values), nil
}
func resolveProductFlagsFromFlagInput(flagRaw *string, subFlagRaw *string, subFlagsRaw []string, hasSubFlagsField bool) ([]string, bool, error) {
if flagRaw == nil && subFlagRaw == nil && !hasSubFlagsField {
return nil, false, nil
}
if flagRaw == nil && (subFlagRaw != nil || hasSubFlagsField) {
return nil, false, fiber.NewError(fiber.StatusBadRequest, "flag is required when sub_flag/sub_flags is provided")
}
flagText := strings.TrimSpace(*flagRaw)
if flagText == "" {
return nil, false, fiber.NewError(fiber.StatusBadRequest, "flag cannot be empty")
}
flag := utils.CanonicalFlagType(flagText)
if !utils.IsProductMainFlag(flag) {
return nil, false, fiber.NewError(
fiber.StatusBadRequest,
fmt.Sprintf("Invalid product flag: %s. Allowed flags: %s", flagText, strings.Join(productMainFlagOptionsString(), ", ")),
)
}
out := []string{string(flag)}
normalizedSubFlags, err := normalizeStructuredSubFlagsInput(subFlagRaw, subFlagsRaw, hasSubFlagsField)
if err != nil {
return nil, false, err
}
if len(normalizedSubFlags) == 0 {
if !utils.ProductFlagAllowWithoutSubFlag(flag) {
return nil, false, fiber.NewError(
fiber.StatusBadRequest,
fmt.Sprintf("sub_flag/sub_flags is required for flag %s", string(flag)),
)
}
normalizedOut, normalizeErr := normalizeProductFlags(out)
if normalizeErr != nil {
return nil, false, normalizeErr
}
return normalizedOut, true, nil
}
invalidSubFlags := make([]string, 0)
for _, subFlag := range normalizedSubFlags {
if !utils.IsValidProductSubFlag(flag, subFlag) {
invalidSubFlags = append(invalidSubFlags, string(subFlag))
}
}
if len(invalidSubFlags) > 0 {
return nil, false, fiber.NewError(
fiber.StatusBadRequest,
fmt.Sprintf("Invalid sub_flags %s for flag %s. Allowed sub_flags: %s", strings.Join(invalidSubFlags, ", "), string(flag), strings.Join(productSubFlagOptionsString(flag), ", ")),
)
}
out = append(out, utils.FlagTypesToStrings(normalizedSubFlags)...)
normalizedOut, normalizeErr := normalizeProductFlags(out)
if normalizeErr != nil {
return nil, false, normalizeErr
}
return normalizedOut, true, nil
}
func resolveCreateProductFlags(req *validation.Create) ([]string, error) {
hasStructuredInput := req.Flag != nil || req.SubFlag != nil || req.SubFlags != nil
if len(req.Flags) > 0 && hasStructuredInput {
return nil, fiber.NewError(fiber.StatusBadRequest, "Use either flags or flag/sub_flag/sub_flags, not both")
}
if len(req.Flags) > 0 {
return normalizeProductFlags(req.Flags)
}
flags, _, err := resolveProductFlagsFromFlagInput(req.Flag, req.SubFlag, req.SubFlags, req.SubFlags != nil)
return flags, err
}
func resolveUpdateProductFlags(req *validation.Update) (bool, []string, error) {
hasStructuredInput := req.Flag != nil || req.SubFlag != nil || req.SubFlags != nil
if req.Flags != nil {
if hasStructuredInput {
if len(*req.Flags) > 0 {
return false, nil, fiber.NewError(fiber.StatusBadRequest, "Use either flags or flag/sub_flag/sub_flags, not both")
}
} else {
flags, err := normalizeProductFlags(*req.Flags)
if err != nil {
return false, nil, err
}
return true, flags, nil
}
}
subFlagsRaw := make([]string, 0)
if req.SubFlags != nil {
subFlagsRaw = *req.SubFlags
}
flags, provided, err := resolveProductFlagsFromFlagInput(req.Flag, req.SubFlag, subFlagsRaw, req.SubFlags != nil)
if err != nil {
return false, nil, err
}
return provided, flags, nil
}
func NewProductService(repo repository.ProductRepository, validate *validator.Validate) ProductService { func NewProductService(repo repository.ProductRepository, validate *validator.Validate) ProductService {
return &productService{ return &productService{
Log: utils.Log, Log: utils.Log,
@@ -70,12 +229,32 @@ func (s productService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity
products, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { products, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB {
db = s.withRelations(db) db = s.withRelations(db)
db = db.Where("is_visible = ?", true) // Depletion master products are system products and often stored with is_visible = false.
// When requested explicitly via is_depletion=true, include hidden records.
if params.IsDepletion == nil || !*params.IsDepletion {
db = db.Where("is_visible = ?", true)
}
if params.Search != "" { if params.Search != "" {
return db.Where("name ILIKE ?", "%"+params.Search+"%") db = db.Where("name ILIKE ?", "%"+params.Search+"%")
} }
if params.ProductCategoryID != 0 { if params.ProductCategoryID != 0 {
return db.Where("product_category_id = ?", params.ProductCategoryID) db = db.Where("product_category_id = ?", params.ProductCategoryID)
}
if params.IsDepletion != nil {
existsQuery := `
EXISTS (
SELECT 1
FROM flags f
WHERE f.flagable_type = ?
AND f.flagable_id = products.id
AND UPPER(f.name) IN ?
)
`
if *params.IsDepletion {
db = db.Where(existsQuery, entity.FlagableTypeProduct, depletionProductFlags)
} else {
db = db.Where("NOT "+existsQuery, entity.FlagableTypeProduct, depletionProductFlags)
}
} }
return db.Order("created_at DESC").Order("updated_at DESC") return db.Order("created_at DESC").Order("updated_at DESC")
}) })
@@ -177,7 +356,7 @@ func (s *productService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entit
} }
} }
productFlags, flagErr := normalizeProductFlags(req.Flags) productFlags, flagErr := resolveCreateProductFlags(req)
if flagErr != nil { if flagErr != nil {
return nil, flagErr return nil, flagErr
} }
@@ -337,13 +516,10 @@ func (s productService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint)
flagUpdate bool flagUpdate bool
flagValues []string flagValues []string
) )
if req.Flags != nil { var flagErr error
flagUpdate = true flagUpdate, flagValues, flagErr = resolveUpdateProductFlags(req)
var flagErr error if flagErr != nil {
flagValues, flagErr = normalizeProductFlags(*req.Flags) return nil, flagErr
if flagErr != nil {
return nil, flagErr
}
} }
if len(updateBody) == 0 && !supplierUpdate && !flagUpdate { if len(updateBody) == 0 && !supplierUpdate && !flagUpdate {
@@ -6,31 +6,37 @@ type SupplierPrice struct {
} }
type Create struct { type Create struct {
Name string `json:"name" validate:"required_strict,min=3,max=50"` Name string `json:"name" validate:"required_strict,min=3,max=50"`
Brand string `json:"brand" validate:"required_strict,min=2,max=50"` Brand string `json:"brand" validate:"required_strict,min=2,max=50"`
Sku *string `json:"sku,omitempty" validate:"omitempty,max=100"` Sku *string `json:"sku,omitempty" validate:"omitempty,max=100"`
UomID uint `json:"uom_id" validate:"required,gt=0"` UomID uint `json:"uom_id" validate:"required,gt=0"`
ProductCategoryID uint `json:"product_category_id" validate:"required,gt=0"` ProductCategoryID uint `json:"product_category_id" validate:"required,gt=0"`
ProductPrice float64 `json:"product_price" validate:"required"` ProductPrice float64 `json:"product_price" validate:"required"`
SellingPrice *float64 `json:"selling_price,omitempty" validate:"omitempty"` SellingPrice *float64 `json:"selling_price,omitempty" validate:"omitempty"`
Tax *float64 `json:"tax,omitempty" validate:"omitempty"` Tax *float64 `json:"tax,omitempty" validate:"omitempty"`
ExpiryPeriod *int `json:"expiry_period,omitempty" validate:"omitempty,gt=0"` ExpiryPeriod *int `json:"expiry_period,omitempty" validate:"omitempty,gt=0"`
Suppliers []SupplierPrice `json:"suppliers,omitempty" validate:"omitempty,dive"` Suppliers []SupplierPrice `json:"suppliers,omitempty" validate:"omitempty,dive"`
Flags []string `json:"flags,omitempty" validate:"omitempty,dive"` Flag *string `json:"flag,omitempty" validate:"omitempty,max=50"`
SubFlag *string `json:"sub_flag,omitempty" validate:"omitempty,max=50"`
SubFlags []string `json:"sub_flags,omitempty" validate:"omitempty,dive,max=50"`
Flags []string `json:"flags,omitempty" validate:"omitempty,dive"`
} }
type Update struct { type Update struct {
Name *string `json:"name,omitempty" validate:"omitempty,min=3"` Name *string `json:"name,omitempty" validate:"omitempty,min=3"`
Brand *string `json:"brand,omitempty" validate:"omitempty,min=2"` Brand *string `json:"brand,omitempty" validate:"omitempty,min=2"`
Sku *string `json:"sku,omitempty" validate:"omitempty"` Sku *string `json:"sku,omitempty" validate:"omitempty"`
UomID *uint `json:"uom_id,omitempty" validate:"omitempty,gt=0"` UomID *uint `json:"uom_id,omitempty" validate:"omitempty,gt=0"`
ProductCategoryID *uint `json:"product_category_id,omitempty" validate:"omitempty,gt=0"` ProductCategoryID *uint `json:"product_category_id,omitempty" validate:"omitempty,gt=0"`
ProductPrice *float64 `json:"product_price,omitempty" validate:"omitempty"` ProductPrice *float64 `json:"product_price,omitempty" validate:"omitempty"`
SellingPrice *float64 `json:"selling_price,omitempty" validate:"omitempty"` SellingPrice *float64 `json:"selling_price,omitempty" validate:"omitempty"`
Tax *float64 `json:"tax,omitempty" validate:"omitempty"` Tax *float64 `json:"tax,omitempty" validate:"omitempty"`
ExpiryPeriod *int `json:"expiry_period,omitempty" validate:"omitempty,gt=0"` ExpiryPeriod *int `json:"expiry_period,omitempty" validate:"omitempty,gt=0"`
Suppliers *[]SupplierPrice `json:"suppliers,omitempty" validate:"omitempty,dive"` Suppliers *[]SupplierPrice `json:"suppliers,omitempty" validate:"omitempty,dive"`
Flags *[]string `json:"flags,omitempty" validate:"omitempty,dive"` Flag *string `json:"flag,omitempty" validate:"omitempty,max=50"`
SubFlag *string `json:"sub_flag,omitempty" validate:"omitempty,max=50"`
SubFlags *[]string `json:"sub_flags,omitempty" validate:"omitempty,dive,max=50"`
Flags *[]string `json:"flags,omitempty" validate:"omitempty,dive"`
} }
type Query struct { type Query struct {
@@ -38,4 +44,5 @@ type Query struct {
Limit int `query:"limit" validate:"omitempty,number,min=1"` Limit int `query:"limit" validate:"omitempty,number,min=1"`
Search string `query:"search" validate:"omitempty,max=50"` Search string `query:"search" validate:"omitempty,max=50"`
ProductCategoryID int `query:"product_category_id" validate:"omitempty,number,min=1"` ProductCategoryID int `query:"product_category_id" validate:"omitempty,number,min=1"`
IsDepletion *bool `query:"is_depletion" validate:"omitempty"`
} }
@@ -151,25 +151,25 @@ func (u *ChickinController) GetOne(c *fiber.Ctx) error {
// }) // })
// } // }
// func (u *ChickinController) DeleteOne(c *fiber.Ctx) error { func (u *ChickinController) DeleteOne(c *fiber.Ctx) error {
// param := c.Params("id") param := c.Params("id")
// id, err := strconv.Atoi(param) id, err := strconv.Atoi(param)
// if err != nil { if err != nil {
// return fiber.NewError(fiber.StatusBadRequest, "Invalid Id") return fiber.NewError(fiber.StatusBadRequest, "Invalid Id")
// } }
// if err := u.ChickinService.DeleteOne(c, uint(id)); err != nil { if err := u.ChickinService.DeleteOne(c, uint(id)); err != nil {
// return err return err
// } }
// return c.Status(fiber.StatusOK). return c.Status(fiber.StatusOK).
// JSON(response.Common{ JSON(response.Common{
// Code: fiber.StatusOK, Code: fiber.StatusOK,
// Status: "success", Status: "success",
// Message: "Delete chickin successfully", Message: "Delete chickin successfully",
// }) })
// } }
func (u *ChickinController) Approval(c *fiber.Ctx) error { func (u *ChickinController) Approval(c *fiber.Ctx) error {
req := new(validation.Approve) req := new(validation.Approve)
@@ -3,6 +3,7 @@ package dto
import ( import (
"time" "time"
"gitlab.com/mbugroup/lti-api.git/internal/config"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities" entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
areaRelationDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/areas/dto" areaRelationDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/areas/dto"
flockRelationDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/flocks/dto" flockRelationDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/flocks/dto"
@@ -35,13 +36,13 @@ type ChickinRelationDTO struct {
} }
type ProjectFlockDTO struct { type ProjectFlockDTO struct {
Id uint `json:"id"` Id uint `json:"id"`
Period int `json:"period"` Period int `json:"period"`
Category string `json:"category"` Category string `json:"category"`
Flock *flockRelationDTO.FlockRelationDTO `json:"flock"` Flock *flockRelationDTO.FlockRelationDTO `json:"flock"`
Area *areaRelationDTO.AreaRelationDTO `json:"area"` Area *areaRelationDTO.AreaRelationDTO `json:"area"`
StandardFcr *float64 `json:"standard_fcr"` StandardFcr *float64 `json:"standard_fcr"`
Location *locationRelationDTO.LocationRelationDTO `json:"location"` Location *locationRelationDTO.LocationRelationDTO `json:"location"`
} }
type ProjectFlockKandangDTO struct { type ProjectFlockKandangDTO struct {
@@ -123,13 +124,13 @@ func ToProjectFlockDTO(pfk entity.ProjectFlockKandang) ProjectFlockDTO {
location = &mapped location = &mapped
} }
return ProjectFlockDTO{ return ProjectFlockDTO{
Id: e.Id, Id: e.Id,
Period: pfk.Period, Period: pfk.Period,
Category: e.Category, Category: e.Category,
Flock: flock, Flock: flock,
Area: area, Area: area,
StandardFcr: resolveProjectFlockStandardFcr(e), StandardFcr: resolveProjectFlockStandardFcr(e),
Location: location, Location: location,
} }
} }
@@ -219,7 +220,7 @@ func resolveProjectFlockStandardFcr(e entity.ProjectFlock) *float64 {
} }
week := 1 week := 1
if e.Category == string(utils.ProjectFlockCategoryLaying) { if e.Category == string(utils.ProjectFlockCategoryLaying) {
week = 18 week = config.LayingWeekStart()
} }
for _, detail := range e.ProductionStandard.ProductionStandardDetails { for _, detail := range e.ProductionStandard.ProductionStandardDetails {
if detail.Week == week && detail.StandardFCR != nil { if detail.Week == week && detail.StandardFCR != nil {
+5 -40
View File
@@ -2,7 +2,6 @@ package chickins
import ( import (
"fmt" "fmt"
"strings"
"github.com/go-playground/validator/v10" "github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
@@ -10,7 +9,6 @@ import (
commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository"
commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service"
"gitlab.com/mbugroup/lti-api.git/internal/utils/fifo"
rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories"
rKandang "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/repositories" rKandang "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/repositories"
@@ -20,6 +18,7 @@ import (
sChickin "gitlab.com/mbugroup/lti-api.git/internal/modules/production/chickins/services" sChickin "gitlab.com/mbugroup/lti-api.git/internal/modules/production/chickins/services"
rProjectFlock "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" rProjectFlock "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories"
rTransferLaying "gitlab.com/mbugroup/lti-api.git/internal/modules/production/transfer_layings/repositories"
rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories"
sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services"
@@ -38,47 +37,12 @@ func (ChickinModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *
projectflockkandangrepo := rProjectFlock.NewProjectFlockKandangRepository(db) projectflockkandangrepo := rProjectFlock.NewProjectFlockKandangRepository(db)
projectflockpopulationrepo := rProjectFlock.NewProjectFlockPopulationRepository(db) projectflockpopulationrepo := rProjectFlock.NewProjectFlockPopulationRepository(db)
projectFlockRepo := rProjectFlock.NewProjectflockRepository(db) projectFlockRepo := rProjectFlock.NewProjectflockRepository(db)
transferLayingRepo := rTransferLaying.NewTransferLayingRepository(db)
productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db) productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db)
productRepo := rProduct.NewProductRepository(db) productRepo := rProduct.NewProductRepository(db)
stockAllocationRepo := commonRepo.NewStockAllocationRepository(db) fifoStockV2Service := commonSvc.NewFifoStockV2Service(db, utils.Log)
fifoService := commonSvc.NewFifoService(db, stockAllocationRepo, productWarehouseRepo, utils.Log)
userRepo := rUser.NewUserRepository(db) userRepo := rUser.NewUserRepository(db)
if err := fifoService.RegisterUsable(fifo.UsableConfig{
Key: fifo.UsableKeyProjectChickin,
Table: "project_chickins",
Columns: fifo.UsableColumns{
ID: "id",
ProductWarehouseID: "product_warehouse_id",
UsageQuantity: "usage_qty",
PendingQuantity: "pending_usage_qty",
CreatedAt: "created_at",
},
ExcludedStockables: []fifo.StockableKey{fifo.StockableKeyProjectFlockPopulation},
}); err != nil {
if !strings.Contains(strings.ToLower(err.Error()), "already registered") {
panic(fmt.Sprintf("failed to register chickin usable workflow: %v", err))
}
}
if err := fifoService.RegisterStockable(fifo.StockableConfig{
Key: fifo.StockableKeyProjectFlockPopulation,
Table: "project_flock_populations",
Columns: fifo.StockableColumns{
ID: "id",
ProductWarehouseID: "product_warehouse_id",
TotalQuantity: "total_qty",
TotalUsedQuantity: "total_used_qty",
CreatedAt: "created_at",
},
OrderBy: []string{"created_at ASC", "id ASC"},
}); err != nil {
if !strings.Contains(strings.ToLower(err.Error()), "already registered") {
panic(fmt.Sprintf("failed to register project flock population stockable workflow: %v", err))
}
}
approvalRepo := commonRepo.NewApprovalRepository(db) approvalRepo := commonRepo.NewApprovalRepository(db)
approvalService := commonSvc.NewApprovalService(approvalRepo) approvalService := commonSvc.NewApprovalService(approvalRepo)
if err := approvalService.RegisterWorkflowSteps(utils.ApprovalWorkflowChickin, utils.ChickinApprovalSteps); err != nil { if err := approvalService.RegisterWorkflowSteps(utils.ApprovalWorkflowChickin, utils.ChickinApprovalSteps); err != nil {
@@ -95,8 +59,9 @@ func (ChickinModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *
projectflockkandangrepo, projectflockkandangrepo,
projectflockpopulationrepo, projectflockpopulationrepo,
chickinDetailRepo, chickinDetailRepo,
transferLayingRepo,
validate, validate,
fifoService) fifoStockV2Service)
userService := sUser.NewUserService(userRepo, validate) userService := sUser.NewUserService(userRepo, validate)
ChickinRoutes(router, userService, chickinService) ChickinRoutes(router, userService, chickinService)
@@ -19,6 +19,6 @@ func ChickinRoutes(v1 fiber.Router, u user.UserService, s chickin.ChickinService
route.Post("/",m.RequirePermissions(m.P_ChickinsCreateOne), ctrl.CreateOne) route.Post("/",m.RequirePermissions(m.P_ChickinsCreateOne), ctrl.CreateOne)
route.Get("/:id",m.RequirePermissions(m.P_ChickinsGetOne), ctrl.GetOne) route.Get("/:id",m.RequirePermissions(m.P_ChickinsGetOne), ctrl.GetOne)
// route.Patch("/:id", ctrl.UpdateOne) // route.Patch("/:id", ctrl.UpdateOne)
// route.Delete("/:id", ctrl.DeleteOne) route.Delete("/:id", ctrl.DeleteOne)
route.Post("/approvals",m.RequirePermissions(m.P_ChickinsApproval), ctrl.Approval) route.Post("/approvals",m.RequirePermissions(m.P_ChickinsApproval), ctrl.Approval)
} }
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,82 @@
package service
import (
"context"
"errors"
"fmt"
"strings"
"time"
commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
"gorm.io/gorm"
)
func reflowChickinScope(
ctx context.Context,
fifoStockV2Svc commonSvc.FifoStockV2Service,
tx *gorm.DB,
productWarehouseID uint,
asOf *time.Time,
) error {
if fifoStockV2Svc == nil {
return fmt.Errorf("FIFO v2 service is not available")
}
if tx == nil {
return fmt.Errorf("transaction is required")
}
if productWarehouseID == 0 {
return fmt.Errorf("product warehouse id is required")
}
flagGroupCode, err := resolveChickinFlagGroupByProductWarehouse(ctx, tx, productWarehouseID)
if err != nil {
return err
}
if strings.TrimSpace(flagGroupCode) == "" {
return fmt.Errorf("flag group code is not found for product warehouse %d", productWarehouseID)
}
_, err = fifoStockV2Svc.Reflow(ctx, commonSvc.FifoStockV2ReflowRequest{
FlagGroupCode: flagGroupCode,
ProductWarehouseID: productWarehouseID,
AsOf: asOf,
Tx: tx,
})
return err
}
func resolveChickinFlagGroupByProductWarehouse(ctx context.Context, tx *gorm.DB, productWarehouseID uint) (string, error) {
type row struct {
FlagGroupCode string `gorm:"column:flag_group_code"`
}
var 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(`
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 strings.TrimSpace(selected.FlagGroupCode), nil
}
@@ -7,14 +7,14 @@ import (
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
"gorm.io/gorm" "gorm.io/gorm"
sProjectFlockKandang "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project-flock-kandangs/services"
rProjectFlockKandang "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories"
rKandang "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/repositories"
commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository"
commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service"
rExpense "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/repositories" rExpense "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/repositories"
rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories"
rKandang "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/repositories"
rWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/repositories" rWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/repositories"
sProjectFlockKandang "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project-flock-kandangs/services"
rProjectFlockKandang "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories"
"gitlab.com/mbugroup/lti-api.git/internal/utils" "gitlab.com/mbugroup/lti-api.git/internal/utils"
rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories"
@@ -33,13 +33,14 @@ func (ProjectFlockKandangModule) RegisterRoutes(router fiber.Router, db *gorm.DB
approvalRepo := commonRepo.NewApprovalRepository(db) approvalRepo := commonRepo.NewApprovalRepository(db)
approvalService := commonSvc.NewApprovalService(approvalRepo) approvalService := commonSvc.NewApprovalService(approvalRepo)
fifoStockV2Service := commonSvc.NewFifoStockV2Service(db, utils.Log)
// register workflow steps for chickin approvals // register workflow steps for chickin approvals
if err := approvalService.RegisterWorkflowSteps(utils.ApprovalWorkflowProjectFlockKandang, utils.ProjectFlockKandangApprovalSteps); err != nil { if err := approvalService.RegisterWorkflowSteps(utils.ApprovalWorkflowProjectFlockKandang, utils.ProjectFlockKandangApprovalSteps); err != nil {
panic(fmt.Sprintf("failed to register chickin approval workflow: %v", err)) panic(fmt.Sprintf("failed to register chickin approval workflow: %v", err))
} }
expenseRepo := rExpense.NewExpenseRepository(db) expenseRepo := rExpense.NewExpenseRepository(db)
projectFlockKandangService := sProjectFlockKandang.NewProjectFlockKandangService(projectFlockKandangRepo, approvalService, expenseRepo, warehouseRepo, productWarehouseRepo, projectFlockPopulationRepo,kandangRepo, validate) projectFlockKandangService := sProjectFlockKandang.NewProjectFlockKandangService(projectFlockKandangRepo, approvalService, fifoStockV2Service, expenseRepo, warehouseRepo, productWarehouseRepo, projectFlockPopulationRepo, kandangRepo, validate)
userService := sUser.NewUserService(userRepo, validate) userService := sUser.NewUserService(userRepo, validate)
ProjectFlockKandangRoutes(router, userService, projectFlockKandangService) ProjectFlockKandangRoutes(router, userService, projectFlockKandangService)
@@ -1,8 +1,10 @@
package service package service
import ( import (
"context"
"errors" "errors"
"fmt" "fmt"
"math"
"strings" "strings"
"time" "time"
@@ -35,6 +37,7 @@ type projectFlockKandangService struct {
Validate *validator.Validate Validate *validator.Validate
Repository repository.ProjectFlockKandangRepository Repository repository.ProjectFlockKandangRepository
ApprovalSvc commonSvc.ApprovalService ApprovalSvc commonSvc.ApprovalService
FifoStockV2Svc commonSvc.FifoStockV2Service
ExpenseRepo expenseRepo.ExpenseRepository ExpenseRepo expenseRepo.ExpenseRepository
WarehouseRepo rWarehouse.WarehouseRepository WarehouseRepo rWarehouse.WarehouseRepository
ProductWarehouseRepo rProductWarehouse.ProductWarehouseRepository ProductWarehouseRepo rProductWarehouse.ProductWarehouseRepository
@@ -69,12 +72,13 @@ type ExpenseSummary struct {
Reference string `json:"reference_number"` Reference string `json:"reference_number"`
} }
func NewProjectFlockKandangService(repo repository.ProjectFlockKandangRepository, approvalSvc commonSvc.ApprovalService, expenseRepo expenseRepo.ExpenseRepository, warehouseRepo rWarehouse.WarehouseRepository, productWarehouseRepo rProductWarehouse.ProductWarehouseRepository, populationRepo repository.ProjectFlockPopulationRepository, kandangRepo kandangRepo.KandangRepository, validate *validator.Validate) ProjectFlockKandangService { func NewProjectFlockKandangService(repo repository.ProjectFlockKandangRepository, approvalSvc commonSvc.ApprovalService, fifoStockV2Svc commonSvc.FifoStockV2Service, expenseRepo expenseRepo.ExpenseRepository, warehouseRepo rWarehouse.WarehouseRepository, productWarehouseRepo rProductWarehouse.ProductWarehouseRepository, populationRepo repository.ProjectFlockPopulationRepository, kandangRepo kandangRepo.KandangRepository, validate *validator.Validate) ProjectFlockKandangService {
return &projectFlockKandangService{ return &projectFlockKandangService{
Log: utils.Log, Log: utils.Log,
Validate: validate, Validate: validate,
Repository: repo, Repository: repo,
ApprovalSvc: approvalSvc, ApprovalSvc: approvalSvc,
FifoStockV2Svc: fifoStockV2Svc,
ExpenseRepo: expenseRepo, ExpenseRepo: expenseRepo,
WarehouseRepo: warehouseRepo, WarehouseRepo: warehouseRepo,
ProductWarehouseRepo: productWarehouseRepo, ProductWarehouseRepo: productWarehouseRepo,
@@ -671,7 +675,91 @@ func (s projectFlockKandangService) calculateAvailableQuantityForProductWarehous
if availableQty < 0 { if availableQty < 0 {
availableQty = 0 availableQty = 0
} }
sourceAvailable, err := s.resolveLayingSourceAvailableQty(c.Context(), nil, productWarehouse.Id, nil)
if err != nil {
return 0, err
}
if sourceAvailable < availableQty {
availableQty = sourceAvailable
}
} }
return availableQty, nil return availableQty, nil
} }
func (s projectFlockKandangService) resolveLayingSourceAvailableQty(ctx context.Context, tx *gorm.DB, productWarehouseID uint, asOf *time.Time) (float64, error) {
if productWarehouseID == 0 || s.FifoStockV2Svc == nil {
return 0, nil
}
flagGroupCode, err := s.resolveFlagGroupByProductWarehouse(ctx, tx, productWarehouseID)
if err != nil {
return 0, err
}
if strings.TrimSpace(flagGroupCode) == "" {
return 0, nil
}
gatherRows, err := s.FifoStockV2Svc.Gather(ctx, commonSvc.FifoStockV2GatherRequest{
FlagGroupCode: flagGroupCode,
Lane: commonSvc.FifoStockV2Lane("STOCKABLE"),
AllocationPurpose: entity.StockAllocationPurposeConsume,
ProductWarehouseID: productWarehouseID,
AsOf: asOf,
Limit: 10000,
Tx: tx,
})
if err != nil {
return 0, err
}
total := 0.0
for _, row := range gatherRows {
if row.AvailableQuantity <= 0 {
continue
}
total += row.AvailableQuantity
}
return math.Max(total, 0), nil
}
func (s projectFlockKandangService) resolveFlagGroupByProductWarehouse(ctx context.Context, tx *gorm.DB, productWarehouseID uint) (string, error) {
type row struct {
FlagGroupCode string `gorm:"column:flag_group_code"`
}
selected := row{}
db := s.Repository.DB()
if tx != nil {
db = tx
}
err := db.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 strings.TrimSpace(selected.FlagGroupCode), nil
}
@@ -6,6 +6,7 @@ import (
"math" "math"
"strconv" "strconv"
"strings" "strings"
"time"
warehouseDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/dto" warehouseDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/dto"
"gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/dto" "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/dto"
@@ -62,6 +63,7 @@ func (u *ProjectflockController) GetAll(c *fiber.Ctx) error {
Search: c.Query("search", ""), Search: c.Query("search", ""),
SortBy: c.Query("sort_by", ""), SortBy: c.Query("sort_by", ""),
SortOrder: c.Query("sort_order", ""), SortOrder: c.Query("sort_order", ""),
Status: strings.TrimSpace(c.Query("status", "")),
} }
if area := c.QueryInt("area_id", 0); area > 0 { if area := c.QueryInt("area_id", 0); area > 0 {
@@ -272,10 +274,20 @@ func (u *ProjectflockController) LookupProjectFlockKandang(c *fiber.Ctx) error {
projectFlockId := c.QueryInt("project_flock_id", 0) projectFlockId := c.QueryInt("project_flock_id", 0)
kandangId := c.QueryInt("kandang_id", 0) kandangId := c.QueryInt("kandang_id", 0)
withPopulation := c.QueryBool("withpopulation", false) withPopulation := c.QueryBool("withpopulation", false)
recordDateRaw := strings.TrimSpace(c.Query("record_date", ""))
var recordDate *time.Time
if projectFlockId == 0 || kandangId == 0 { if projectFlockId == 0 || kandangId == 0 {
return fiber.NewError(fiber.StatusBadRequest, "Invalid project_flock_id or kandang_id") return fiber.NewError(fiber.StatusBadRequest, "Invalid project_flock_id or kandang_id")
} }
if recordDateRaw != "" {
parsed, err := time.Parse("2006-01-02", recordDateRaw)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, "record_date must be in YYYY-MM-DD format")
}
utc := parsed.UTC()
recordDate = &utc
}
result, availableStock, err := u.ProjectflockService.GetProjectFlockKandangByProjectAndKandang(c, uint(projectFlockId), uint(kandangId)) result, availableStock, err := u.ProjectflockService.GetProjectFlockKandangByProjectAndKandang(c, uint(projectFlockId), uint(kandangId))
if err != nil { if err != nil {
@@ -300,6 +312,12 @@ func (u *ProjectflockController) LookupProjectFlockKandang(c *fiber.Ctx) error {
mapped := warehouseDTO.ToWarehouseRelationDTO(*warehouse) mapped := warehouseDTO.ToWarehouseRelationDTO(*warehouse)
dtoResult.Warehouse = &mapped dtoResult.Warehouse = &mapped
} }
if isTransition, isLaying, serr := u.ProjectflockService.GetProjectFlockKandangTransferStateAtDate(c, result.Id, recordDate); serr != nil {
return serr
} else {
dtoResult.IsTransition = isTransition
dtoResult.IsLaying = isLaying
}
if withPopulation { if withPopulation {
population := dtoResult.AvailableQuantity population := dtoResult.AvailableQuantity
dtoResult.Population = &population dtoResult.Population = &population
@@ -3,6 +3,7 @@ package dto
import ( import (
"time" "time"
"gitlab.com/mbugroup/lti-api.git/internal/config"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities" entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
approvalDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/approvals/dto" approvalDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/approvals/dto"
areaDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/areas/dto" areaDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/areas/dto"
@@ -25,17 +26,17 @@ type ProjectFlockRelationDTO struct {
type ProjectFlockListDTO struct { type ProjectFlockListDTO struct {
ProjectFlockRelationDTO ProjectFlockRelationDTO
Area *areaDTO.AreaRelationDTO `json:"area,omitempty"` Area *areaDTO.AreaRelationDTO `json:"area,omitempty"`
Category string `json:"category"` Category string `json:"category"`
StandardFcr *float64 `json:"standard_fcr,omitempty"` StandardFcr *float64 `json:"standard_fcr,omitempty"`
ProductionStandard *productionStandardDTO.ProductionStandardRelationDTO `json:"production_standard,omitempty"` ProductionStandard *productionStandardDTO.ProductionStandardRelationDTO `json:"production_standard,omitempty"`
Location *locationDTO.LocationRelationDTO `json:"location,omitempty"` Location *locationDTO.LocationRelationDTO `json:"location,omitempty"`
Kandangs []KandangWithProjectFlockIdDTO `json:"kandangs,omitempty"` Kandangs []KandangWithProjectFlockIdDTO `json:"kandangs,omitempty"`
ProjectBudgets []ProjectBudgetDTO `json:"project_budgets,omitempty"` ProjectBudgets []ProjectBudgetDTO `json:"project_budgets,omitempty"`
CreatedUser *userDTO.UserRelationDTO `json:"created_user"` CreatedUser *userDTO.UserRelationDTO `json:"created_user"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
Approval approvalDTO.ApprovalRelationDTO `json:"approval"` Approval approvalDTO.ApprovalRelationDTO `json:"approval"`
} }
type KandangWithProjectFlockIdDTO struct { type KandangWithProjectFlockIdDTO struct {
@@ -203,7 +204,7 @@ func resolveProjectFlockStandardFcr(e entity.ProjectFlock) *float64 {
} }
week := 1 week := 1
if e.Category == string(utils.ProjectFlockCategoryLaying) { if e.Category == string(utils.ProjectFlockCategoryLaying) {
week = 18 week = config.LayingWeekStart()
} }
for _, detail := range e.ProductionStandard.ProductionStandardDetails { for _, detail := range e.ProductionStandard.ProductionStandardDetails {
if detail.Week == week && detail.StandardFCR != nil { if detail.Week == week && detail.StandardFCR != nil {
@@ -40,6 +40,8 @@ type ProjectFlockKandangDTO struct {
AvailableQuantity float64 `json:"available_quantity"` AvailableQuantity float64 `json:"available_quantity"`
Population *float64 `json:"population,omitempty"` Population *float64 `json:"population,omitempty"`
ChickInDate *time.Time `json:"chick_in_date,omitempty"` ChickInDate *time.Time `json:"chick_in_date,omitempty"`
IsTransition bool `json:"is_transition"`
IsLaying bool `json:"is_laying"`
} }
func ToProjectFlockKandangDTO(e entity.ProjectFlockKandang) ProjectFlockKandangDTO { func ToProjectFlockKandangDTO(e entity.ProjectFlockKandang) ProjectFlockKandangDTO {
@@ -17,6 +17,7 @@ import (
rProjectBudget "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" rProjectBudget "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories"
rProjectflock "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" rProjectflock "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories"
rRecording "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/repositories" rRecording "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/repositories"
rTransferLaying "gitlab.com/mbugroup/lti-api.git/internal/modules/production/transfer_layings/repositories"
sProjectflock "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/services" sProjectflock "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/services"
utils "gitlab.com/mbugroup/lti-api.git/internal/utils" utils "gitlab.com/mbugroup/lti-api.git/internal/utils"
@@ -35,6 +36,7 @@ func (ProjectflockModule) RegisterRoutes(router fiber.Router, db *gorm.DB, valid
projectflockKandangRepo := rProjectflock.NewProjectFlockKandangRepository(db) projectflockKandangRepo := rProjectflock.NewProjectFlockKandangRepository(db)
projectFlockPopulationRepo := rProjectflock.NewProjectFlockPopulationRepository(db) projectFlockPopulationRepo := rProjectflock.NewProjectFlockPopulationRepository(db)
recordingRepo := rRecording.NewRecordingRepository(db) recordingRepo := rRecording.NewRecordingRepository(db)
transferLayingRepo := rTransferLaying.NewTransferLayingRepository(db)
warehouseRepo := rWarehouse.NewWarehouseRepository(db) warehouseRepo := rWarehouse.NewWarehouseRepository(db)
productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db) productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db)
projectBudgetRepo := rProjectBudget.NewProjectBudgetRepository(db) projectBudgetRepo := rProjectBudget.NewProjectBudgetRepository(db)
@@ -46,7 +48,7 @@ func (ProjectflockModule) RegisterRoutes(router fiber.Router, db *gorm.DB, valid
panic(fmt.Sprintf("failed to register project flock approval workflow: %v", err)) panic(fmt.Sprintf("failed to register project flock approval workflow: %v", err))
} }
projectflockService := sProjectflock.NewProjectflockService(projectflockRepo, flockRepo, kandangRepo, projectflockKandangRepo, warehouseRepo, productWarehouseRepo, projectBudgetRepo, nonstockRepo, projectFlockPopulationRepo, recordingRepo, approvalService, validate) projectflockService := sProjectflock.NewProjectflockService(projectflockRepo, flockRepo, kandangRepo, projectflockKandangRepo, warehouseRepo, productWarehouseRepo, projectBudgetRepo, nonstockRepo, projectFlockPopulationRepo, recordingRepo, transferLayingRepo, approvalService, validate)
userService := sUser.NewUserService(userRepo, validate) userService := sUser.NewUserService(userRepo, validate)
ProjectflockRoutes(router, userService, projectflockService) ProjectflockRoutes(router, userService, projectflockService)
@@ -51,6 +51,7 @@ func (r *projectFlockPopulationRepositoryImpl) GetByProjectFlockKandangID(ctx co
err := r.DB().WithContext(ctx). err := r.DB().WithContext(ctx).
Joins("JOIN project_chickins ON project_chickins.id = project_flock_populations.project_chickin_id"). Joins("JOIN project_chickins ON project_chickins.id = project_flock_populations.project_chickin_id").
Where("project_chickins.project_flock_kandang_id = ?", projectFlockKandangID). Where("project_chickins.project_flock_kandang_id = ?", projectFlockKandangID).
Where("project_chickins.deleted_at IS NULL").
Preload("ProjectChickin"). Preload("ProjectChickin").
Find(&records).Error Find(&records).Error
if err != nil { if err != nil {
@@ -87,6 +88,7 @@ func (r *projectFlockPopulationRepositoryImpl) GetByProjectFlockKandangIDAndProd
err := r.DB().WithContext(ctx). err := r.DB().WithContext(ctx).
Joins("JOIN project_chickins ON project_chickins.id = project_flock_populations.project_chickin_id"). Joins("JOIN project_chickins ON project_chickins.id = project_flock_populations.project_chickin_id").
Where("project_chickins.project_flock_kandang_id = ? AND project_flock_populations.product_warehouse_id = ?", projectFlockKandangID, productWarehouseID). Where("project_chickins.project_flock_kandang_id = ? AND project_flock_populations.product_warehouse_id = ?", projectFlockKandangID, productWarehouseID).
Where("project_chickins.deleted_at IS NULL").
Find(&records).Error Find(&records).Error
if err != nil { if err != nil {
return nil, err return nil, err
@@ -99,8 +101,10 @@ func (r *projectFlockPopulationRepositoryImpl) GetTotalQtyByProjectFlockKandangI
err := r.DB().WithContext(ctx). err := r.DB().WithContext(ctx).
Table("project_flock_populations"). Table("project_flock_populations").
Select("COALESCE(SUM(total_qty - total_used_qty), 0) AS available_qty"). Select("COALESCE(SUM(total_qty - total_used_qty), 0) AS available_qty").
Joins("JOIN product_warehouses pw ON project_flock_populations.product_warehouse_id = pw.id"). Joins("JOIN project_chickins ON project_chickins.id = project_flock_populations.project_chickin_id").
Where("pw.project_flock_kandang_id = ?", projectFlockKandangID). Where("project_chickins.project_flock_kandang_id = ?", projectFlockKandangID).
Where("project_chickins.deleted_at IS NULL").
Where("project_flock_populations.deleted_at IS NULL").
Scan(&total).Error Scan(&total).Error
if err != nil { if err != nil {
return 0, err return 0, err
@@ -111,9 +115,12 @@ func (r *projectFlockPopulationRepositoryImpl) GetTotalQtyByProjectFlockKandangI
func (r *projectFlockPopulationRepositoryImpl) GetTotalQtyByProductWarehouseID(ctx context.Context, productWarehouseID uint) (float64, error) { func (r *projectFlockPopulationRepositoryImpl) GetTotalQtyByProductWarehouseID(ctx context.Context, productWarehouseID uint) (float64, error) {
var total float64 var total float64
err := r.DB().WithContext(ctx). err := r.DB().WithContext(ctx).
Model(&entity.ProjectFlockPopulation{}). Table("project_flock_populations").
Where("product_warehouse_id = ?", productWarehouseID). Select("COALESCE(SUM(project_flock_populations.total_qty - project_flock_populations.total_used_qty), 0)").
Select("COALESCE(SUM(total_qty - total_used_qty), 0)"). Joins("JOIN project_chickins ON project_chickins.id = project_flock_populations.project_chickin_id").
Where("project_flock_populations.product_warehouse_id = ?", productWarehouseID).
Where("project_chickins.deleted_at IS NULL").
Where("project_flock_populations.deleted_at IS NULL").
Scan(&total).Error Scan(&total).Error
if err != nil { if err != nil {
return 0, err return 0, err
@@ -128,6 +135,8 @@ func (r *projectFlockPopulationRepositoryImpl) GetAvailableQtyByProjectFlockKand
Select("COALESCE(SUM(total_qty - total_used_qty), 0) AS total_qty"). Select("COALESCE(SUM(total_qty - total_used_qty), 0) AS total_qty").
Joins("JOIN project_chickins ON project_chickins.id = project_flock_populations.project_chickin_id"). Joins("JOIN project_chickins ON project_chickins.id = project_flock_populations.project_chickin_id").
Where("project_chickins.project_flock_kandang_id = ?", projectFlockKandangID). Where("project_chickins.project_flock_kandang_id = ?", projectFlockKandangID).
Where("project_chickins.deleted_at IS NULL").
Where("project_flock_populations.deleted_at IS NULL").
Scan(&total).Error Scan(&total).Error
if err != nil { if err != nil {
return 0, err return 0, err
@@ -145,6 +154,8 @@ func (r *projectFlockPopulationRepositoryImpl) GetTotalChickInByProjectFlockKand
Select("COALESCE(SUM(project_flock_populations.total_qty - project_flock_populations.total_used_qty), 0) AS total_qty"). Select("COALESCE(SUM(project_flock_populations.total_qty - project_flock_populations.total_used_qty), 0) AS total_qty").
Joins("JOIN project_chickins ON project_chickins.id = project_flock_populations.project_chickin_id"). Joins("JOIN project_chickins ON project_chickins.id = project_flock_populations.project_chickin_id").
Where("project_chickins.project_flock_kandang_id = ?", projectFlockKandangID). Where("project_chickins.project_flock_kandang_id = ?", projectFlockKandangID).
Where("project_chickins.deleted_at IS NULL").
Where("project_flock_populations.deleted_at IS NULL").
Scan(&total).Error Scan(&total).Error
if err != nil { if err != nil {
return 0, err return 0, err
@@ -8,6 +8,7 @@ import (
"gitlab.com/mbugroup/lti-api.git/internal/common/repository" "gitlab.com/mbugroup/lti-api.git/internal/common/repository"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities" entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/validations" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/validations"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
"gorm.io/gorm" "gorm.io/gorm"
) )
@@ -110,6 +111,28 @@ func (r *ProjectflockRepositoryImpl) applyQueryFilters(db *gorm.DB, params *vali
AND pfk.kandang_id IN ? AND pfk.kandang_id IN ?
)`, params.KandangIds) )`, params.KandangIds)
} }
if params.Status != "" {
db = db.Where(`
EXISTS (
SELECT 1
FROM approvals latest_approval
WHERE latest_approval.approvable_type = ?
AND latest_approval.approvable_id = project_flocks.id
AND latest_approval.id = (
SELECT a2.id
FROM approvals a2
WHERE a2.approvable_type = ?
AND a2.approvable_id = project_flocks.id
ORDER BY a2.id DESC
LIMIT 1
)
AND LOWER(latest_approval.step_name) = LOWER(?)
)`,
utils.ApprovalWorkflowProjectFlock.String(),
utils.ApprovalWorkflowProjectFlock.String(),
params.Status,
)
}
db = r.applySearchFilters(db, params.Search) db = r.applySearchFilters(db, params.Search)
@@ -23,6 +23,7 @@ import (
pfutils "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/utils" pfutils "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/utils"
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/validations" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/validations"
recordingRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/repositories" recordingRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/repositories"
transferLayingRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/production/transfer_layings/repositories"
uniformityRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/production/uniformities/repositories" uniformityRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/production/uniformities/repositories"
purchaseRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/repositories" purchaseRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/repositories"
utils "gitlab.com/mbugroup/lti-api.git/internal/utils" utils "gitlab.com/mbugroup/lti-api.git/internal/utils"
@@ -44,6 +45,8 @@ type ProjectflockService interface {
GetProjectFlockKandangByProjectAndKandang(ctx *fiber.Ctx, projectFlockID uint, kandangID uint) (*entity.ProjectFlockKandang, float64, error) GetProjectFlockKandangByProjectAndKandang(ctx *fiber.Ctx, projectFlockID uint, kandangID uint) (*entity.ProjectFlockKandang, float64, error)
GetProjectFlockKandangPopulation(ctx *fiber.Ctx, projectFlockKandangID uint) (float64, error) GetProjectFlockKandangPopulation(ctx *fiber.Ctx, projectFlockKandangID uint) (float64, error)
GetProjectFlockKandangChickinDate(ctx *fiber.Ctx, projectFlockKandangID uint) (*time.Time, error) GetProjectFlockKandangChickinDate(ctx *fiber.Ctx, projectFlockKandangID uint) (*time.Time, error)
GetProjectFlockKandangTransferState(ctx *fiber.Ctx, projectFlockKandangID uint) (bool, bool, error)
GetProjectFlockKandangTransferStateAtDate(ctx *fiber.Ctx, projectFlockKandangID uint, referenceDate *time.Time) (bool, bool, error)
GetPeriodSummary(ctx *fiber.Ctx, locationID uint) ([]KandangPeriodSummary, error) GetPeriodSummary(ctx *fiber.Ctx, locationID uint) ([]KandangPeriodSummary, error)
GetProjectPeriods(ctx *fiber.Ctx, projectIDs []uint) (map[uint]int, error) GetProjectPeriods(ctx *fiber.Ctx, projectIDs []uint) (map[uint]int, error)
Approval(ctx *fiber.Ctx, req *validation.Approve) ([]entity.ProjectFlock, error) Approval(ctx *fiber.Ctx, req *validation.Approve) ([]entity.ProjectFlock, error)
@@ -64,6 +67,7 @@ type projectflockService struct {
PivotRepo repository.ProjectFlockKandangRepository PivotRepo repository.ProjectFlockKandangRepository
PopulationRepo repository.ProjectFlockPopulationRepository PopulationRepo repository.ProjectFlockPopulationRepository
RecordingRepo recordingRepo.RecordingRepository RecordingRepo recordingRepo.RecordingRepository
TransferLayingRepo transferLayingRepo.TransferLayingRepository
ApprovalSvc commonSvc.ApprovalService ApprovalSvc commonSvc.ApprovalService
approvalWorkflow approvalutils.ApprovalWorkflowKey approvalWorkflow approvalutils.ApprovalWorkflowKey
} }
@@ -85,6 +89,7 @@ func NewProjectflockService(
nonstockRepo nonstockRepository.NonstockRepository, nonstockRepo nonstockRepository.NonstockRepository,
populationRepo repository.ProjectFlockPopulationRepository, populationRepo repository.ProjectFlockPopulationRepository,
recordingRepo recordingRepo.RecordingRepository, recordingRepo recordingRepo.RecordingRepository,
transferLayingRepo transferLayingRepo.TransferLayingRepository,
approvalSvc commonSvc.ApprovalService, approvalSvc commonSvc.ApprovalService,
validate *validator.Validate, validate *validator.Validate,
@@ -102,6 +107,7 @@ func NewProjectflockService(
PivotRepo: pivotRepo, PivotRepo: pivotRepo,
PopulationRepo: populationRepo, PopulationRepo: populationRepo,
RecordingRepo: recordingRepo, RecordingRepo: recordingRepo,
TransferLayingRepo: transferLayingRepo,
ApprovalSvc: approvalSvc, ApprovalSvc: approvalSvc,
approvalWorkflow: utils.ApprovalWorkflowProjectFlock, approvalWorkflow: utils.ApprovalWorkflowProjectFlock,
} }
@@ -538,6 +544,70 @@ func (s projectflockService) GetProjectFlockKandangChickinDate(ctx *fiber.Ctx, p
return earliest, nil return earliest, nil
} }
func (s projectflockService) GetProjectFlockKandangTransferState(ctx *fiber.Ctx, projectFlockKandangID uint) (bool, bool, error) {
return s.GetProjectFlockKandangTransferStateAtDate(ctx, projectFlockKandangID, nil)
}
func (s projectflockService) GetProjectFlockKandangTransferStateAtDate(ctx *fiber.Ctx, projectFlockKandangID uint, referenceDate *time.Time) (bool, bool, error) {
if projectFlockKandangID == 0 || s.TransferLayingRepo == nil || s.PivotRepo == nil {
return false, false, nil
}
pfk, err := s.PivotRepo.GetByIDLight(ctx.Context(), projectFlockKandangID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return false, false, nil
}
s.Log.Errorf("Failed to resolve project flock kandang %d for transfer state: %+v", projectFlockKandangID, err)
return false, false, fiber.NewError(fiber.StatusInternalServerError, "Failed to resolve transfer state")
}
category := strings.ToUpper(strings.TrimSpace(pfk.ProjectFlock.Category))
var transfer *entity.LayingTransfer
switch category {
case strings.ToUpper(string(utils.ProjectFlockCategoryGrowing)):
transfer, err = s.TransferLayingRepo.GetLatestApprovedBySourceKandang(ctx.Context(), projectFlockKandangID)
case strings.ToUpper(string(utils.ProjectFlockCategoryLaying)):
transfer, err = s.TransferLayingRepo.GetLatestApprovedByTargetKandang(ctx.Context(), projectFlockKandangID)
default:
return false, false, nil
}
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return false, false, nil
}
s.Log.Errorf("Failed to resolve transfer state for project flock kandang %d: %+v", projectFlockKandangID, err)
return false, false, fiber.NewError(fiber.StatusInternalServerError, "Failed to resolve transfer state")
}
if transfer == nil {
return false, false, nil
}
physicalMoveDate := normalizeDateOnlyUTC(transfer.TransferDate)
if physicalMoveDate.IsZero() {
return false, false, nil
}
economicCutoffDate := physicalMoveDate
if transfer.EconomicCutoffDate != nil && !transfer.EconomicCutoffDate.IsZero() {
economicCutoffDate = normalizeDateOnlyUTC(*transfer.EconomicCutoffDate)
} else if transfer.EffectiveMoveDate != nil && !transfer.EffectiveMoveDate.IsZero() {
economicCutoffDate = normalizeDateOnlyUTC(*transfer.EffectiveMoveDate)
}
if economicCutoffDate.Before(physicalMoveDate) {
economicCutoffDate = physicalMoveDate
}
reference := normalizeDateOnlyUTC(time.Now().UTC())
if referenceDate != nil && !referenceDate.IsZero() {
reference = normalizeDateOnlyUTC(referenceDate.UTC())
}
isTransition := !reference.Before(physicalMoveDate) && reference.Before(economicCutoffDate)
isLaying := !reference.Before(economicCutoffDate)
return isTransition, isLaying, nil
}
func (s projectflockService) GetProjectFlockKandangByParams(ctx *fiber.Ctx, idStr string, projectFlockIdStr string, kandangIdStr string) (*entity.ProjectFlockKandang, float64, error) { func (s projectflockService) GetProjectFlockKandangByParams(ctx *fiber.Ctx, idStr string, projectFlockIdStr string, kandangIdStr string) (*entity.ProjectFlockKandang, float64, error) {
idStr = strings.TrimSpace(idStr) idStr = strings.TrimSpace(idStr)
projectFlockIdStr = strings.TrimSpace(projectFlockIdStr) projectFlockIdStr = strings.TrimSpace(projectFlockIdStr)
@@ -579,6 +649,10 @@ func (s projectflockService) GetProjectFlockKandangByParams(ctx *fiber.Ctx, idSt
return s.GetProjectFlockKandangByProjectAndKandang(ctx, uint(pfid), uint(kid)) return s.GetProjectFlockKandangByProjectAndKandang(ctx, uint(pfid), uint(kid))
} }
func normalizeDateOnlyUTC(value time.Time) time.Time {
return time.Date(value.UTC().Year(), value.UTC().Month(), value.UTC().Day(), 0, 0, 0, 0, time.UTC)
}
func (s projectflockService) GetAvailableDocQuantity(ctx *fiber.Ctx, kandangID uint) (float64, error) { func (s projectflockService) GetAvailableDocQuantity(ctx *fiber.Ctx, kandangID uint) (float64, error) {
if s.PopulationRepo == nil { if s.PopulationRepo == nil {
return 0, fiber.NewError(fiber.StatusInternalServerError, "Project flock population repository is not configured") return 0, fiber.NewError(fiber.StatusInternalServerError, "Project flock population repository is not configured")
@@ -20,6 +20,7 @@ type Query struct {
LocationId uint `query:"location_id" validate:"omitempty,number,gt=0"` LocationId uint `query:"location_id" validate:"omitempty,number,gt=0"`
Period int `query:"period" validate:"omitempty,number,gt=0"` Period int `query:"period" validate:"omitempty,number,gt=0"`
Category string `query:"category" validate:"omitempty"` Category string `query:"category" validate:"omitempty"`
Status string `query:"status" validate:"omitempty,oneof=Pengajuan Aktif Selesai"`
KandangIds []uint `query:"kandang_id" validate:"omitempty,dive,gt=0"` KandangIds []uint `query:"kandang_id" validate:"omitempty,dive,gt=0"`
TransferContext string `query:"transfer_context" validate:"omitempty,oneof=transfer_to_laying"` TransferContext string `query:"transfer_context" validate:"omitempty,oneof=transfer_to_laying"`
} }
@@ -5,6 +5,7 @@ import (
"strings" "strings"
"time" "time"
"gitlab.com/mbugroup/lti-api.git/internal/config"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities" entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
approvalDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/approvals/dto" approvalDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/approvals/dto"
productWarehouseDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/adjustments/dto" productWarehouseDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/adjustments/dto"
@@ -69,22 +70,26 @@ type RecordingWarehouseDTO struct {
} }
type RecordingRelationDTO struct { type RecordingRelationDTO struct {
Id uint `json:"id"` Id uint `json:"id"`
ProjectFlock RecordingProjectFlockDTO `json:"project_flock"` ProjectFlock RecordingProjectFlockDTO `json:"project_flock"`
RecordDatetime time.Time `json:"record_datetime"` RecordDatetime time.Time `json:"record_datetime"`
Day int `json:"day"` Day int `json:"day"`
TotalDepletionQty float64 `json:"total_depletion_qty"` TotalDepletionQty float64 `json:"total_depletion_qty"`
TotalDepletionCumQty float64 `json:"total_depletion_cum_qty"` TotalDepletionCumQty float64 `json:"total_depletion_cum_qty"`
CumDepletionRate float64 `json:"cum_depletion_rate"` CumDepletionRate float64 `json:"cum_depletion_rate"`
DepletionRate float64 `json:"depletion_rate"` DepletionRate float64 `json:"depletion_rate"`
CumIntake int `json:"cum_intake"` CumIntake int `json:"cum_intake"`
FcrValue float64 `json:"fcr_value"` FcrValue float64 `json:"fcr_value"`
HenDay float64 `json:"hen_day"` HenDay float64 `json:"hen_day"`
HenHouse float64 `json:"hen_house"` HenHouse float64 `json:"hen_house"`
FeedIntake float64 `json:"feed_intake"` FeedIntake float64 `json:"feed_intake"`
EggMass float64 `json:"egg_mass"` EggMass float64 `json:"egg_mass"`
EggWeight float64 `json:"egg_weight"` EggWeight float64 `json:"egg_weight"`
Approval approvalDTO.ApprovalRelationDTO `json:"approval"` PopulationCanChange bool `json:"population_can_change"`
TransferExecuted *bool `json:"transfer_executed,omitempty"`
IsTransition bool `json:"is_transition"`
IsLaying bool `json:"is_laying"`
Approval approvalDTO.ApprovalRelationDTO `json:"approval"`
} }
type RecordingListDTO struct { type RecordingListDTO struct {
@@ -228,22 +233,26 @@ func toRecordingRelationDTO(e entity.Recording) RecordingRelationDTO {
} }
return RecordingRelationDTO{ return RecordingRelationDTO{
Id: e.Id, Id: e.Id,
ProjectFlock: toRecordingProjectFlockDTO(e), ProjectFlock: toRecordingProjectFlockDTO(e),
RecordDatetime: e.RecordDatetime, RecordDatetime: e.RecordDatetime,
Day: intValue(e.Day), Day: intValue(e.Day),
TotalDepletionQty: floatValue(e.TotalDepletionQty), TotalDepletionQty: floatValue(e.TotalDepletionQty),
TotalDepletionCumQty: floatValue(e.TotalDepletionCumQty), TotalDepletionCumQty: floatValue(e.TotalDepletionCumQty),
CumDepletionRate: roundFloatValue(e.CumDepletionRate, 2), CumDepletionRate: roundFloatValue(e.CumDepletionRate, 2),
DepletionRate: roundFloatValue(e.DepletionRate, 2), DepletionRate: roundFloatValue(e.DepletionRate, 2),
CumIntake: intValue(e.CumIntake), CumIntake: intValue(e.CumIntake),
FcrValue: floatValue(e.FcrValue), FcrValue: floatValue(e.FcrValue),
HenDay: floatValue(e.HenDay), HenDay: floatValue(e.HenDay),
HenHouse: floatValue(e.HenHouse), HenHouse: floatValue(e.HenHouse),
FeedIntake: floatValue(e.FeedIntake), FeedIntake: floatValue(e.FeedIntake),
EggMass: floatValue(e.EggMass), EggMass: floatValue(e.EggMass),
EggWeight: floatValue(e.EggWeight), EggWeight: floatValue(e.EggWeight),
Approval: latestApproval, PopulationCanChange: boolValueDefault(e.PopulationCanChange, true),
TransferExecuted: e.TransferExecuted,
IsTransition: boolValueDefault(e.IsTransition, false),
IsLaying: boolValueDefault(e.IsLaying, false),
Approval: latestApproval,
} }
} }
@@ -300,7 +309,7 @@ func recordingWeekValue(e entity.Recording) int {
} }
weekBase := 1 weekBase := 1
if isLayingRecording(e) { if isLayingRecording(e) {
weekBase = 18 weekBase = config.LayingWeekStart()
} }
return ((day - 1) / 7) + weekBase return ((day - 1) / 7) + weekBase
} }
@@ -449,6 +458,13 @@ func intValue(value *int) int {
return *value return *value
} }
func boolValueDefault(value *bool, fallback bool) bool {
if value == nil {
return fallback
}
return *value
}
func defaultRecordingLatestApproval(e entity.Recording) approvalDTO.ApprovalRelationDTO { func defaultRecordingLatestApproval(e entity.Recording) approvalDTO.ApprovalRelationDTO {
result := approvalDTO.ApprovalRelationDTO{} result := approvalDTO.ApprovalRelationDTO{}
@@ -24,6 +24,8 @@ import (
sProjectFlock "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/services" sProjectFlock "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/services"
rRecording "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/repositories" rRecording "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/repositories"
sRecording "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/services" sRecording "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/services"
rTransferLaying "gitlab.com/mbugroup/lti-api.git/internal/modules/production/transfer_layings/repositories"
sTransferLaying "gitlab.com/mbugroup/lti-api.git/internal/modules/production/transfer_layings/services"
rStockLogs "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/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"
"gitlab.com/mbugroup/lti-api.git/internal/utils/fifo" "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo"
@@ -48,7 +50,9 @@ func (RecordingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate
productRepo := rProduct.NewProductRepository(db) productRepo := rProduct.NewProductRepository(db)
chickinRepo := rChickin.NewChickinRepository(db) chickinRepo := rChickin.NewChickinRepository(db)
chickinDetailRepo := rChickin.NewChickinDetailRepository(db) chickinDetailRepo := rChickin.NewChickinDetailRepository(db)
stockAllocationRepo := commonRepo.NewStockAllocationRepository(db) transferLayingRepo := rTransferLaying.NewTransferLayingRepository(db)
layingTransferSourceRepo := rTransferLaying.NewLayingTransferSourceRepository(db)
layingTransferTargetRepo := rTransferLaying.NewLayingTransferTargetRepository(db)
stockLogRepo := rStockLogs.NewStockLogRepository(db) stockLogRepo := rStockLogs.NewStockLogRepository(db)
productionStandardRepo := rProductionStandard.NewProductionStandardRepository(db) productionStandardRepo := rProductionStandard.NewProductionStandardRepository(db)
productionStandardDetailRepo := rProductionStandard.NewProductionStandardDetailRepository(db) productionStandardDetailRepo := rProductionStandard.NewProductionStandardDetailRepository(db)
@@ -61,74 +65,41 @@ func (RecordingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate
validate, validate,
) )
fifoStockV2Service := commonSvc.NewFifoStockV2Service(db, utils.Log)
stockAllocationRepo := commonRepo.NewStockAllocationRepository(db)
fifoService := commonSvc.NewFifoService(db, stockAllocationRepo, productWarehouseRepo, utils.Log) fifoService := commonSvc.NewFifoService(db, stockAllocationRepo, productWarehouseRepo, utils.Log)
if err := fifoService.RegisterStockable(fifo.StockableConfig{ if err := fifoService.RegisterStockable(fifo.StockableConfig{
Key: fifo.StockableKeyRecordingEgg, Key: fifo.StockableKeyTransferToLayingIn,
Table: "recording_eggs", Table: "laying_transfer_targets",
Columns: fifo.StockableColumns{ Columns: fifo.StockableColumns{
ID: "id", ID: "id",
ProductWarehouseID: "product_warehouse_id", ProductWarehouseID: "product_warehouse_id",
TotalQuantity: "total_qty", TotalQuantity: "total_qty",
TotalUsedQuantity: "total_used", TotalUsedQuantity: "total_used",
CreatedAt: "(SELECT r.record_datetime FROM recordings r WHERE r.id = recording_eggs.recording_id)", CreatedAt: "created_at",
}, },
OrderBy: []string{"(SELECT r.record_datetime FROM recordings r WHERE r.id = recording_eggs.recording_id) ASC", "id ASC"}, OrderBy: []string{"created_at ASC", "id ASC"},
}); err != nil { }); err != nil {
if !strings.Contains(strings.ToLower(err.Error()), "already registered") { if !strings.Contains(strings.ToLower(err.Error()), "already registered") {
panic(fmt.Sprintf("failed to register recording egg stockable workflow: %v", err)) panic(fmt.Sprintf("failed to register transfer to laying stockable workflow: %v", err))
}
}
if err := fifoService.RegisterStockable(fifo.StockableConfig{
Key: fifo.StockableKeyRecordingDepletion,
Table: "recording_depletions",
Columns: fifo.StockableColumns{
ID: "id",
ProductWarehouseID: "product_warehouse_id",
TotalQuantity: "qty",
TotalUsedQuantity: "total_used_qty",
CreatedAt: "(SELECT r.record_datetime FROM recordings r WHERE r.id = recording_depletions.recording_id)",
},
OrderBy: []string{"(SELECT r.record_datetime FROM recordings r WHERE r.id = recording_depletions.recording_id) ASC", "id ASC"},
}); err != nil {
if !strings.Contains(strings.ToLower(err.Error()), "already registered") {
panic(fmt.Sprintf("failed to register recording depletion stockable workflow: %v", err))
} }
} }
if err := fifoService.RegisterUsable(fifo.UsableConfig{ if err := fifoService.RegisterUsable(fifo.UsableConfig{
Key: fifo.UsableKeyRecordingStock, Key: fifo.UsableKeyTransferToLayingOut,
Table: "recording_stocks", Table: "laying_transfers",
Columns: fifo.UsableColumns{
ID: "id",
ProductWarehouseID: "product_warehouse_id",
UsageQuantity: "usage_qty",
PendingQuantity: "pending_qty",
CreatedAt: "(SELECT r.record_datetime FROM recordings r WHERE r.id = recording_stocks.recording_id)",
},
}); err != nil {
if !strings.Contains(strings.ToLower(err.Error()), "already registered") {
panic(fmt.Sprintf("failed to register recording usable workflow: %v", err))
}
}
if err := fifoService.RegisterUsable(fifo.UsableConfig{
Key: fifo.UsableKeyRecordingDepletion,
Table: "recording_depletions",
Columns: fifo.UsableColumns{ Columns: fifo.UsableColumns{
ID: "id", ID: "id",
ProductWarehouseID: "source_product_warehouse_id", ProductWarehouseID: "source_product_warehouse_id",
UsageQuantity: "usage_qty", UsageQuantity: "source_usage_qty",
PendingQuantity: "pending_qty", PendingQuantity: "source_pending_usage_qty",
CreatedAt: "(SELECT r.record_datetime FROM recordings r WHERE r.id = recording_depletions.recording_id)", CreatedAt: "created_at",
},
ExcludedStockables: []fifo.StockableKey{
fifo.StockableKeyTransferToLayingIn,
fifo.StockableKeyStockTransferIn,
fifo.StockableKeyAdjustmentIn,
fifo.StockableKeyPurchaseItems,
fifo.StockableKeyRecordingEgg,
}, },
OrderBy: []string{"created_at ASC", "id ASC"},
}); err != nil { }); err != nil {
if !strings.Contains(strings.ToLower(err.Error()), "already registered") { if !strings.Contains(strings.ToLower(err.Error()), "already registered") {
panic(fmt.Sprintf("failed to register recording depletion usable workflow: %v", err)) panic(fmt.Sprintf("failed to register transfer to laying usable workflow: %v", err))
} }
} }
@@ -154,6 +125,7 @@ func (RecordingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate
nonstockRepo, nonstockRepo,
projectFlockPopulationRepo, projectFlockPopulationRepo,
recordingRepo, recordingRepo,
transferLayingRepo,
approvalService, approvalService,
validate, validate,
) )
@@ -168,8 +140,23 @@ func (RecordingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate
projectFlockKandangRepo, projectFlockKandangRepo,
projectFlockPopulationRepo, projectFlockPopulationRepo,
chickinDetailRepo, chickinDetailRepo,
transferLayingRepo,
validate,
fifoStockV2Service,
)
transferLayingService := sTransferLaying.NewTransferLayingService(
transferLayingRepo,
layingTransferSourceRepo,
layingTransferTargetRepo,
projectFlockRepo,
projectFlockKandangRepo,
projectFlockPopulationRepo,
productWarehouseRepo,
warehouseRepo,
approvalService,
fifoStockV2Service,
validate, validate,
fifoService,
) )
recordingService := sRecording.NewRecordingService( recordingService := sRecording.NewRecordingService(
@@ -179,11 +166,13 @@ func (RecordingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate
projectFlockPopulationRepo, projectFlockPopulationRepo,
approvalRepo, approvalRepo,
approvalService, approvalService,
fifoService, fifoStockV2Service,
stockLogRepo, stockLogRepo,
productionStandardService, productionStandardService,
projectFlockService, projectFlockService,
chickinService, chickinService,
transferLayingRepo,
transferLayingService,
validate, validate,
) )
userService := sUser.NewUserService(userRepo, validate) userService := sUser.NewUserService(userRepo, validate)
@@ -25,20 +25,27 @@ type RecordingRepository interface {
GetLatestByProjectFlockKandangID(ctx context.Context, projectFlockKandangId uint) (*entity.Recording, error) GetLatestByProjectFlockKandangID(ctx context.Context, projectFlockKandangId uint) (*entity.Recording, error)
ListByProjectFlockKandangID(ctx context.Context, tx *gorm.DB, projectFlockKandangId uint, from *time.Time) ([]entity.Recording, error) ListByProjectFlockKandangID(ctx context.Context, tx *gorm.DB, projectFlockKandangId uint, from *time.Time) ([]entity.Recording, error)
GenerateNextDay(tx *gorm.DB, projectFlockKandangId uint) (int, error) GenerateNextDay(tx *gorm.DB, projectFlockKandangId uint) (int, error)
CreateRecording(tx *gorm.DB, recording *entity.Recording) error
CreateStocks(tx *gorm.DB, stocks []entity.RecordingStock) error CreateStocks(tx *gorm.DB, stocks []entity.RecordingStock) error
CreateStock(tx *gorm.DB, stock *entity.RecordingStock) error
DeleteStocks(tx *gorm.DB, recordingID uint) error DeleteStocks(tx *gorm.DB, recordingID uint) error
DeleteStocksByIDs(tx *gorm.DB, ids []uint) error
ListStocks(tx *gorm.DB, recordingID uint) ([]entity.RecordingStock, error) ListStocks(tx *gorm.DB, recordingID uint) ([]entity.RecordingStock, error)
GetStockByID(tx *gorm.DB, stockID uint) (*entity.RecordingStock, error)
UpdateStockUsage(tx *gorm.DB, stockID uint, usageQty, pendingQty float64) error UpdateStockUsage(tx *gorm.DB, stockID uint, usageQty, pendingQty float64) error
UpdateDepletionPending(tx *gorm.DB, depletionID uint, pendingQty float64) error UpdateDepletionPending(tx *gorm.DB, depletionID uint, pendingQty float64) error
UpdateDepletionQuantities(tx *gorm.DB, depletionID uint, qty, usageQty, pendingQty float64) error
CreateDepletions(tx *gorm.DB, depletions []entity.RecordingDepletion) error CreateDepletions(tx *gorm.DB, depletions []entity.RecordingDepletion) error
DeleteDepletions(tx *gorm.DB, recordingID uint) error DeleteDepletions(tx *gorm.DB, recordingID uint) error
ListDepletions(tx *gorm.DB, recordingID uint) ([]entity.RecordingDepletion, error) ListDepletions(tx *gorm.DB, recordingID uint) ([]entity.RecordingDepletion, error)
GetDepletionByID(tx *gorm.DB, depletionID uint) (*entity.RecordingDepletion, error)
CreateEggs(tx *gorm.DB, eggs []entity.RecordingEgg) error CreateEggs(tx *gorm.DB, eggs []entity.RecordingEgg) error
DeleteEggs(tx *gorm.DB, recordingID uint) error DeleteEggs(tx *gorm.DB, recordingID uint) error
ListEggs(tx *gorm.DB, recordingID uint) ([]entity.RecordingEgg, error) ListEggs(tx *gorm.DB, recordingID uint) ([]entity.RecordingEgg, error)
UpdateEggTotalQty(tx *gorm.DB, eggID uint, totalQty float64) error
GetRecordingEggByID(ctx context.Context, id uint, modifier func(*gorm.DB) *gorm.DB) (*entity.RecordingEgg, error) GetRecordingEggByID(ctx context.Context, id uint, modifier func(*gorm.DB) *gorm.DB) (*entity.RecordingEgg, error)
ExistsOnDate(ctx context.Context, projectFlockKandangId uint, recordTime time.Time) (bool, error) ExistsOnDate(ctx context.Context, projectFlockKandangId uint, recordTime time.Time) (bool, error)
@@ -64,6 +71,7 @@ type RecordingRepository interface {
GetLatestAvgWeightByProjectFlockID(ctx context.Context, projectFlockID uint) (avgWeight float64, err error) GetLatestAvgWeightByProjectFlockID(ctx context.Context, projectFlockID uint) (avgWeight float64, err error)
GetTotalEggProductionWeightByProjectFlockID(ctx context.Context, projectFlockID uint) (totalWeightKg float64, err error) GetTotalEggProductionWeightByProjectFlockID(ctx context.Context, projectFlockID uint) (totalWeightKg float64, err error)
GetAverageTargetMetricsByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint, includeTargets bool) (RecordingTargetAverages, error) GetAverageTargetMetricsByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint, includeTargets bool) (RecordingTargetAverages, error)
GetProjectFlockKandangIDsByPopulationWarehouseIDs(ctx context.Context, tx *gorm.DB, productWarehouseIDs []uint) ([]uint, error)
ResyncProjectFlockPopulationUsage(ctx context.Context, tx *gorm.DB, projectFlockKandangID uint) error ResyncProjectFlockPopulationUsage(ctx context.Context, tx *gorm.DB, projectFlockKandangID uint) error
ValidateProductWarehousesByFlags(ctx context.Context, ids []uint, flags []string) (uint, error) ValidateProductWarehousesByFlags(ctx context.Context, ids []uint, flags []string) (uint, error)
} }
@@ -272,6 +280,18 @@ func (r *RecordingRepositoryImpl) GenerateNextDay(tx *gorm.DB, projectFlockKanda
return nextRecordingDay(days), nil return nextRecordingDay(days), nil
} }
func (r *RecordingRepositoryImpl) CreateRecording(tx *gorm.DB, recording *entity.Recording) error {
if recording == nil {
return nil
}
return tx.Select(
"ProjectFlockKandangId",
"RecordDatetime",
"Day",
"CreatedBy",
).Create(recording).Error
}
func (r *RecordingRepositoryImpl) CreateStocks(tx *gorm.DB, stocks []entity.RecordingStock) error { func (r *RecordingRepositoryImpl) CreateStocks(tx *gorm.DB, stocks []entity.RecordingStock) error {
if len(stocks) == 0 { if len(stocks) == 0 {
return nil return nil
@@ -279,10 +299,24 @@ func (r *RecordingRepositoryImpl) CreateStocks(tx *gorm.DB, stocks []entity.Reco
return tx.Create(&stocks).Error return tx.Create(&stocks).Error
} }
func (r *RecordingRepositoryImpl) CreateStock(tx *gorm.DB, stock *entity.RecordingStock) error {
if stock == nil {
return nil
}
return tx.Create(stock).Error
}
func (r *RecordingRepositoryImpl) DeleteStocks(tx *gorm.DB, recordingID uint) error { func (r *RecordingRepositoryImpl) DeleteStocks(tx *gorm.DB, recordingID uint) error {
return tx.Where("recording_id = ?", recordingID).Delete(&entity.RecordingStock{}).Error return tx.Where("recording_id = ?", recordingID).Delete(&entity.RecordingStock{}).Error
} }
func (r *RecordingRepositoryImpl) DeleteStocksByIDs(tx *gorm.DB, ids []uint) error {
if len(ids) == 0 {
return nil
}
return tx.Where("id IN ?", ids).Delete(&entity.RecordingStock{}).Error
}
func (r *RecordingRepositoryImpl) ListStocks(tx *gorm.DB, recordingID uint) ([]entity.RecordingStock, error) { func (r *RecordingRepositoryImpl) ListStocks(tx *gorm.DB, recordingID uint) ([]entity.RecordingStock, error) {
var items []entity.RecordingStock var items []entity.RecordingStock
if err := tx.Where("recording_id = ?", recordingID).Find(&items).Error; err != nil { if err := tx.Where("recording_id = ?", recordingID).Find(&items).Error; err != nil {
@@ -291,6 +325,18 @@ func (r *RecordingRepositoryImpl) ListStocks(tx *gorm.DB, recordingID uint) ([]e
return items, nil return items, nil
} }
func (r *RecordingRepositoryImpl) GetStockByID(tx *gorm.DB, stockID uint) (*entity.RecordingStock, error) {
if stockID == 0 {
return nil, gorm.ErrRecordNotFound
}
var stock entity.RecordingStock
if err := tx.Where("id = ?", stockID).Take(&stock).Error; err != nil {
return nil, err
}
return &stock, nil
}
func (r *RecordingRepositoryImpl) UpdateStockUsage(tx *gorm.DB, stockID uint, usageQty, pendingQty float64) error { func (r *RecordingRepositoryImpl) UpdateStockUsage(tx *gorm.DB, stockID uint, usageQty, pendingQty float64) error {
return tx.Model(&entity.RecordingStock{}). return tx.Model(&entity.RecordingStock{}).
Where("id = ?", stockID). Where("id = ?", stockID).
@@ -306,6 +352,16 @@ func (r *RecordingRepositoryImpl) UpdateDepletionPending(tx *gorm.DB, depletionI
Update("pending_qty", pendingQty).Error Update("pending_qty", pendingQty).Error
} }
func (r *RecordingRepositoryImpl) UpdateDepletionQuantities(tx *gorm.DB, depletionID uint, qty, usageQty, pendingQty float64) error {
return tx.Model(&entity.RecordingDepletion{}).
Where("id = ?", depletionID).
Updates(map[string]any{
"qty": qty,
"usage_qty": usageQty,
"pending_qty": pendingQty,
}).Error
}
func (r *RecordingRepositoryImpl) CreateDepletions(tx *gorm.DB, depletions []entity.RecordingDepletion) error { func (r *RecordingRepositoryImpl) CreateDepletions(tx *gorm.DB, depletions []entity.RecordingDepletion) error {
if len(depletions) == 0 { if len(depletions) == 0 {
return nil return nil
@@ -325,6 +381,18 @@ func (r *RecordingRepositoryImpl) ListDepletions(tx *gorm.DB, recordingID uint)
return items, nil return items, nil
} }
func (r *RecordingRepositoryImpl) GetDepletionByID(tx *gorm.DB, depletionID uint) (*entity.RecordingDepletion, error) {
if depletionID == 0 {
return nil, gorm.ErrRecordNotFound
}
var depletion entity.RecordingDepletion
if err := tx.Where("id = ?", depletionID).Take(&depletion).Error; err != nil {
return nil, err
}
return &depletion, nil
}
func (r *RecordingRepositoryImpl) CreateEggs(tx *gorm.DB, eggs []entity.RecordingEgg) error { func (r *RecordingRepositoryImpl) CreateEggs(tx *gorm.DB, eggs []entity.RecordingEgg) error {
if len(eggs) == 0 { if len(eggs) == 0 {
return nil return nil
@@ -344,6 +412,12 @@ func (r *RecordingRepositoryImpl) ListEggs(tx *gorm.DB, recordingID uint) ([]ent
return items, nil return items, nil
} }
func (r *RecordingRepositoryImpl) UpdateEggTotalQty(tx *gorm.DB, eggID uint, totalQty float64) error {
return tx.Model(&entity.RecordingEgg{}).
Where("id = ?", eggID).
Update("total_qty", totalQty).Error
}
func (r *RecordingRepositoryImpl) GetRecordingEggByID( func (r *RecordingRepositoryImpl) GetRecordingEggByID(
ctx context.Context, ctx context.Context,
id uint, id uint,
@@ -801,6 +875,34 @@ func (r *RecordingRepositoryImpl) GetAverageTargetMetricsByProjectFlockKandangID
return result, nil return result, nil
} }
func (r *RecordingRepositoryImpl) GetProjectFlockKandangIDsByPopulationWarehouseIDs(
ctx context.Context,
tx *gorm.DB,
productWarehouseIDs []uint,
) ([]uint, error) {
if len(productWarehouseIDs) == 0 {
return nil, nil
}
db := r.DB().WithContext(ctx)
if tx != nil {
db = tx.WithContext(ctx)
}
var kandangIDs []uint
if err := db.Table("project_flock_populations pfp").
Select("DISTINCT pc.project_flock_kandang_id").
Joins("JOIN project_chickins pc ON pc.id = pfp.project_chickin_id").
Where("pfp.product_warehouse_id IN ?", productWarehouseIDs).
Where("pfp.deleted_at IS NULL").
Where("pc.deleted_at IS NULL").
Pluck("pc.project_flock_kandang_id", &kandangIDs).Error; err != nil {
return nil, err
}
return kandangIDs, nil
}
func (r *RecordingRepositoryImpl) ResyncProjectFlockPopulationUsage(ctx context.Context, tx *gorm.DB, projectFlockKandangID uint) error { func (r *RecordingRepositoryImpl) ResyncProjectFlockPopulationUsage(ctx context.Context, tx *gorm.DB, projectFlockKandangID uint) error {
if projectFlockKandangID == 0 { if projectFlockKandangID == 0 {
return nil return nil
@@ -821,6 +923,7 @@ func (r *RecordingRepositoryImpl) ResyncProjectFlockPopulationUsage(ctx context.
FROM stock_allocations FROM stock_allocations
WHERE stockable_type = 'PROJECT_FLOCK_POPULATION' WHERE stockable_type = 'PROJECT_FLOCK_POPULATION'
AND status = 'ACTIVE' AND status = 'ACTIVE'
AND allocation_purpose = 'CONSUME'
GROUP BY stockable_id GROUP BY stockable_id
) a ) a
WHERE p.id = a.stockable_id WHERE p.id = a.stockable_id
@@ -831,14 +934,15 @@ func (r *RecordingRepositoryImpl) ResyncProjectFlockPopulationUsage(ctx context.
UPDATE project_flock_populations p UPDATE project_flock_populations p
SET total_used_qty = 0 SET total_used_qty = 0
WHERE p.id IN (` + idsSubquery + `) WHERE p.id IN (` + idsSubquery + `)
AND NOT EXISTS ( AND NOT EXISTS (
SELECT 1 SELECT 1
FROM stock_allocations sa FROM stock_allocations sa
WHERE sa.stockable_type = 'PROJECT_FLOCK_POPULATION' WHERE sa.stockable_type = 'PROJECT_FLOCK_POPULATION'
AND sa.status = 'ACTIVE' AND sa.status = 'ACTIVE'
AND sa.stockable_id = p.id AND sa.allocation_purpose = 'CONSUME'
) AND sa.stockable_id = p.id
` )
`
db := r.DB().WithContext(ctx) db := r.DB().WithContext(ctx)
if tx != nil { if tx != nil {
@@ -0,0 +1,137 @@
package service
import (
"context"
"fmt"
"strings"
"time"
commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
"gorm.io/gorm"
)
const (
recordingLaneUsable = "USABLE"
recordingLaneStockable = "STOCKABLE"
recordingFunctionStockOut = "RECORDING_STOCK_OUT"
recordingFunctionDepletionOut = "RECORDING_DEPLETION_OUT"
recordingFunctionDepletionIn = "RECORDING_DEPLETION_IN"
recordingFunctionEggIn = "RECORDING_EGG_IN"
recordingSourceStocks = "recording_stocks"
recordingSourceDepletions = "recording_depletions"
recordingSourceEggs = "recording_eggs"
)
func (s *recordingService) reflowRecordingScope(
ctx context.Context,
tx *gorm.DB,
productWarehouseID uint,
recordingID uint,
lane string,
functionCode string,
sourceTable string,
) error {
if s == nil || s.FifoStockV2Svc == nil {
return fmt.Errorf("FIFO v2 service is not available")
}
if tx == nil {
return fmt.Errorf("transaction is required")
}
if productWarehouseID == 0 {
return fmt.Errorf("product warehouse id is required")
}
flagGroupCode, err := resolveRecordingFlagGroupByProductWarehouse(ctx, tx, productWarehouseID, lane, functionCode, sourceTable)
if err != nil {
return err
}
if strings.TrimSpace(flagGroupCode) == "" {
return fmt.Errorf("flag group code is not found for product warehouse %d", productWarehouseID)
}
asOf, err := resolveRecordingAsOf(ctx, tx, recordingID)
if err != nil {
return err
}
_, err = s.FifoStockV2Svc.Reflow(ctx, commonSvc.FifoStockV2ReflowRequest{
FlagGroupCode: flagGroupCode,
ProductWarehouseID: productWarehouseID,
AsOf: asOf,
Tx: tx,
})
return err
}
func resolveRecordingFlagGroupByProductWarehouse(
ctx context.Context,
tx *gorm.DB,
productWarehouseID uint,
lane string,
functionCode string,
sourceTable string,
) (string, error) {
type row struct {
FlagGroupCode string `gorm:"column:flag_group_code"`
}
var selected row
q := 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 = ?", lane).
Where("rr.source_table = ?", sourceTable)
if strings.TrimSpace(functionCode) != "" {
q = q.Where("rr.function_code = ?", functionCode)
}
err := q.
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("rr.id ASC").
Limit(1).
Take(&selected).Error
if err != nil {
return "", err
}
return strings.TrimSpace(selected.FlagGroupCode), nil
}
func resolveRecordingAsOf(ctx context.Context, tx *gorm.DB, recordingID uint) (*time.Time, error) {
if recordingID == 0 {
asOf := time.Now().UTC()
return &asOf, nil
}
type row struct {
RecordDatetime time.Time `gorm:"column:record_datetime"`
}
var selected row
if err := tx.WithContext(ctx).
Table("recordings").
Select("record_datetime").
Where("id = ?", recordingID).
Limit(1).
Take(&selected).Error; err != nil {
return nil, err
}
asOf := selected.RecordDatetime.UTC()
return &asOf, nil
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,87 @@
package service
import (
"testing"
"time"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
)
func mustDate(t *testing.T, value string) time.Time {
t.Helper()
parsed, err := time.Parse("2006-01-02", value)
if err != nil {
t.Fatalf("failed parsing date %s: %v", value, err)
}
return parsed
}
func TestTransferRecordingWindow(t *testing.T) {
t.Run("early transfer keeps transition until economic cutoff", func(t *testing.T) {
physical := mustDate(t, "2026-04-08")
cutoff := mustDate(t, "2026-05-13")
transfer := &entity.LayingTransfer{
TransferDate: physical,
EconomicCutoffDate: &cutoff,
}
gotPhysical, gotCutoff := transferRecordingWindow(transfer)
if gotPhysical.Format("2006-01-02") != "2026-04-08" {
t.Fatalf("unexpected physical date: %s", gotPhysical.Format("2006-01-02"))
}
if gotCutoff.Format("2006-01-02") != "2026-05-13" {
t.Fatalf("unexpected cutoff date: %s", gotCutoff.Format("2006-01-02"))
}
})
t.Run("standard transfer has no transition window", func(t *testing.T) {
physical := mustDate(t, "2026-05-13")
cutoff := mustDate(t, "2026-05-13")
transfer := &entity.LayingTransfer{
TransferDate: physical,
EconomicCutoffDate: &cutoff,
}
gotPhysical, gotCutoff := transferRecordingWindow(transfer)
if gotPhysical.Format("2006-01-02") != "2026-05-13" {
t.Fatalf("unexpected physical date: %s", gotPhysical.Format("2006-01-02"))
}
if gotCutoff.Format("2006-01-02") != "2026-05-13" {
t.Fatalf("unexpected cutoff date: %s", gotCutoff.Format("2006-01-02"))
}
})
t.Run("late transfer clamps economic cutoff to physical move", func(t *testing.T) {
physical := mustDate(t, "2026-06-03")
cutoff := mustDate(t, "2026-05-13")
transfer := &entity.LayingTransfer{
TransferDate: physical,
EconomicCutoffDate: &cutoff,
}
gotPhysical, gotCutoff := transferRecordingWindow(transfer)
if gotPhysical.Format("2006-01-02") != "2026-06-03" {
t.Fatalf("unexpected physical date: %s", gotPhysical.Format("2006-01-02"))
}
if gotCutoff.Format("2006-01-02") != "2026-06-03" {
t.Fatalf("unexpected cutoff date: %s", gotCutoff.Format("2006-01-02"))
}
})
t.Run("legacy data falls back to effective move date", func(t *testing.T) {
physical := mustDate(t, "2026-04-08")
legacyEffective := mustDate(t, "2026-05-13")
transfer := &entity.LayingTransfer{
TransferDate: physical,
EffectiveMoveDate: &legacyEffective,
}
gotPhysical, gotCutoff := transferRecordingWindow(transfer)
if gotPhysical.Format("2006-01-02") != "2026-04-08" {
t.Fatalf("unexpected physical date: %s", gotPhysical.Format("2006-01-02"))
}
if gotCutoff.Format("2006-01-02") != "2026-05-13" {
t.Fatalf("unexpected cutoff date: %s", gotCutoff.Format("2006-01-02"))
}
})
}
@@ -186,6 +186,50 @@ func (u *TransferLayingController) Approval(c *fiber.Ctx) error {
}) })
} }
func (u *TransferLayingController) Execute(c *fiber.Ctx) error {
param := c.Params("id")
id, err := strconv.Atoi(param)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, "Invalid Id")
}
result, err := u.TransferLayingService.Execute(c, uint(id))
if err != nil {
return err
}
return c.Status(fiber.StatusOK).
JSON(response.Success{
Code: fiber.StatusOK,
Status: "success",
Message: "Execute transfer laying successfully",
Data: dto.ToTransferLayingDetailDTOWithSingleApproval(*result, result.LatestApproval),
})
}
func (u *TransferLayingController) Unexecute(c *fiber.Ctx) error {
param := c.Params("id")
id, err := strconv.Atoi(param)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, "Invalid Id")
}
result, err := u.TransferLayingService.Unexecute(c, uint(id))
if err != nil {
return err
}
return c.Status(fiber.StatusOK).
JSON(response.Success{
Code: fiber.StatusOK,
Status: "success",
Message: "Unexecute transfer laying successfully",
Data: dto.ToTransferLayingDetailDTOWithSingleApproval(*result, result.LatestApproval),
})
}
func (u *TransferLayingController) GetAvailableQtyPerKandang(c *fiber.Ctx) error { func (u *TransferLayingController) GetAvailableQtyPerKandang(c *fiber.Ctx) error {
projectFlockID, err := strconv.ParseUint(c.Params("project_flock_id"), 10, 32) projectFlockID, err := strconv.ParseUint(c.Params("project_flock_id"), 10, 32)
if err != nil { if err != nil {
@@ -14,10 +14,13 @@ import (
// === DTO Structs === // === DTO Structs ===
type TransferLayingRelationDTO struct { type TransferLayingRelationDTO struct {
Id uint `json:"id"` Id uint `json:"id"`
TransferNumber string `json:"transfer_number"` TransferNumber string `json:"transfer_number"`
TransferDate time.Time `json:"transfer_date"` TransferDate time.Time `json:"transfer_date"`
Notes string `json:"notes"` EconomicCutoffDate *time.Time `json:"economic_cutoff_date,omitempty"`
EffectiveMoveDate *time.Time `json:"effective_move_date,omitempty"`
ExecutedAt *time.Time `json:"executed_at,omitempty"`
Notes string `json:"notes"`
} }
type ProjectFlockKandangWithKandangDTO struct { type ProjectFlockKandangWithKandangDTO struct {
@@ -47,6 +50,8 @@ type TransferLayingListDTO struct {
ToProjectFlock *projectFlockDTO.ProjectFlockRelationDTO `json:"to_project_flock,omitempty"` ToProjectFlock *projectFlockDTO.ProjectFlockRelationDTO `json:"to_project_flock,omitempty"`
CreatedBy uint `json:"created_by"` CreatedBy uint `json:"created_by"`
CreatedUser *userDTO.UserRelationDTO `json:"created_user,omitempty"` CreatedUser *userDTO.UserRelationDTO `json:"created_user,omitempty"`
ExecutedBy *uint `json:"executed_by,omitempty"`
ExecutedUser *userDTO.UserRelationDTO `json:"executed_user,omitempty"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
Approval *approvalDTO.ApprovalRelationDTO `json:"approval,omitempty"` Approval *approvalDTO.ApprovalRelationDTO `json:"approval,omitempty"`
} }
@@ -88,10 +93,13 @@ type MaxTargetQtyForTransferDTO struct {
func ToTransferLayingRelationDTO(e entity.LayingTransfer) TransferLayingRelationDTO { func ToTransferLayingRelationDTO(e entity.LayingTransfer) TransferLayingRelationDTO {
return TransferLayingRelationDTO{ return TransferLayingRelationDTO{
Id: e.Id, Id: e.Id,
TransferNumber: e.TransferNumber, TransferNumber: e.TransferNumber,
TransferDate: e.TransferDate, TransferDate: e.TransferDate,
Notes: e.Notes, EconomicCutoffDate: e.EconomicCutoffDate,
EffectiveMoveDate: e.EffectiveMoveDate,
ExecutedAt: e.ExecutedAt,
Notes: e.Notes,
} }
} }
@@ -144,6 +152,46 @@ func ToLayingTransferSourceDTOs(sources []entity.LayingTransferSource) []LayingT
return result return result
} }
func toLayingTransferSourceDTOsFromTransfer(e entity.LayingTransfer) []LayingTransferSourceDTO {
if len(e.Sources) > 0 {
return ToLayingTransferSourceDTOs(e.Sources)
}
if e.SourceProjectFlockKandangId == nil || *e.SourceProjectFlockKandangId == 0 {
return []LayingTransferSourceDTO{}
}
displayQty := e.SourceRequestedQty
if e.SourceUsageQty > 0 {
displayQty = e.SourceUsageQty
}
pfkDTO := &ProjectFlockKandangWithKandangDTO{
Id: *e.SourceProjectFlockKandangId,
}
if e.SourceProjectFlockKandang != nil && e.SourceProjectFlockKandang.Id != 0 {
pfkDTO.KandangId = e.SourceProjectFlockKandang.KandangId
pfkDTO.ProjectFlockId = e.SourceProjectFlockKandang.ProjectFlockId
if e.SourceProjectFlockKandang.Kandang.Id != 0 {
kandangMapped := kandangDTO.ToKandangRelationDTO(e.SourceProjectFlockKandang.Kandang)
pfkDTO.Kandang = &kandangMapped
}
}
var pwDTO *productWarehouseDTO.ProductWarehouseRelationDTO
if e.SourceProductWarehouse != nil && e.SourceProductWarehouse.Id != 0 {
mapped := productWarehouseDTO.ToProductWarehouseRelationDTO(*e.SourceProductWarehouse)
pwDTO = &mapped
}
return []LayingTransferSourceDTO{
{
SourceProjectFlockKandang: pfkDTO,
Qty: displayQty,
ProductWarehouse: pwDTO,
},
}
}
func ToLayingTransferTargetDTO(target entity.LayingTransferTarget) LayingTransferTargetDTO { func ToLayingTransferTargetDTO(target entity.LayingTransferTarget) LayingTransferTargetDTO {
var pfkDTO *ProjectFlockKandangWithKandangDTO var pfkDTO *ProjectFlockKandangWithKandangDTO
if target.TargetProjectFlockKandang != nil && target.TargetProjectFlockKandang.Id != 0 { if target.TargetProjectFlockKandang != nil && target.TargetProjectFlockKandang.Id != 0 {
@@ -190,6 +238,12 @@ func ToTransferLayingListDTO(e entity.LayingTransfer) TransferLayingListDTO {
createdUser = &mapped createdUser = &mapped
} }
var executedUser *userDTO.UserRelationDTO
if e.ExecutedUser != nil && e.ExecutedUser.Id != 0 {
mapped := userDTO.ToUserRelationDTO(*e.ExecutedUser)
executedUser = &mapped
}
var approval *approvalDTO.ApprovalRelationDTO var approval *approvalDTO.ApprovalRelationDTO
if e.LatestApproval != nil { if e.LatestApproval != nil {
mapped := approvalDTO.ToApprovalDTO(*e.LatestApproval) mapped := approvalDTO.ToApprovalDTO(*e.LatestApproval)
@@ -219,6 +273,8 @@ func ToTransferLayingListDTO(e entity.LayingTransfer) TransferLayingListDTO {
ToProjectFlock: toProjectFlock, ToProjectFlock: toProjectFlock,
CreatedBy: e.CreatedBy, CreatedBy: e.CreatedBy,
CreatedUser: createdUser, CreatedUser: createdUser,
ExecutedBy: e.ExecutedBy,
ExecutedUser: executedUser,
CreatedAt: e.CreatedAt, CreatedAt: e.CreatedAt,
Approval: approval, Approval: approval,
} }
@@ -240,7 +296,7 @@ func ToTransferLayingDetailDTO(e entity.LayingTransfer, approvals []entity.Appro
return TransferLayingDetailDTO{ return TransferLayingDetailDTO{
TransferLayingListDTO: ToTransferLayingListDTO(e), TransferLayingListDTO: ToTransferLayingListDTO(e),
Sources: ToLayingTransferSourceDTOs(e.Sources), Sources: toLayingTransferSourceDTOsFromTransfer(e),
Targets: ToLayingTransferTargetDTOs(e.Targets), Targets: ToLayingTransferTargetDTOs(e.Targets),
Approval: latestApproval, Approval: latestApproval,
} }
@@ -262,7 +318,7 @@ func ToTransferLayingDetailDTOWithSingleApproval(e entity.LayingTransfer, approv
return TransferLayingDetailDTO{ return TransferLayingDetailDTO{
TransferLayingListDTO: ToTransferLayingListDTO(e), TransferLayingListDTO: ToTransferLayingListDTO(e),
Sources: ToLayingTransferSourceDTOs(e.Sources), Sources: toLayingTransferSourceDTOsFromTransfer(e),
Targets: ToLayingTransferTargetDTOs(e.Targets), Targets: ToLayingTransferTargetDTOs(e.Targets),
Approval: mappedApproval, Approval: mappedApproval,
} }
@@ -37,6 +37,7 @@ func (TransferLayingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, val
stockAllocationRepo := commonRepo.NewStockAllocationRepository(db) stockAllocationRepo := commonRepo.NewStockAllocationRepository(db)
fifoService := commonSvc.NewFifoService(db, stockAllocationRepo, productWarehouseRepo, utils.Log) fifoService := commonSvc.NewFifoService(db, stockAllocationRepo, productWarehouseRepo, utils.Log)
fifoStockV2Service := commonSvc.NewFifoStockV2Service(db, utils.Log)
// daftarin jadi stockable // daftarin jadi stockable
if err := fifoService.RegisterStockable(fifo.StockableConfig{ if err := fifoService.RegisterStockable(fifo.StockableConfig{
@@ -59,12 +60,12 @@ func (TransferLayingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, val
// daftarin jadi usable // daftarin jadi usable
if err := fifoService.RegisterUsable(fifo.UsableConfig{ if err := fifoService.RegisterUsable(fifo.UsableConfig{
Key: fifo.UsableKeyTransferToLayingOut, Key: fifo.UsableKeyTransferToLayingOut,
Table: "laying_transfer_sources", Table: "laying_transfers",
Columns: fifo.UsableColumns{ Columns: fifo.UsableColumns{
ID: "id", ID: "id",
ProductWarehouseID: "product_warehouse_id", ProductWarehouseID: "source_product_warehouse_id",
UsageQuantity: "usage_qty", UsageQuantity: "source_usage_qty",
PendingQuantity: "pending_usage_qty", PendingQuantity: "source_pending_usage_qty",
CreatedAt: "created_at", CreatedAt: "created_at",
}, },
OrderBy: []string{"created_at ASC", "id ASC"}, OrderBy: []string{"created_at ASC", "id ASC"},
@@ -90,7 +91,7 @@ func (TransferLayingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, val
productWarehouseRepo, productWarehouseRepo,
warehouseRepo, warehouseRepo,
approvalService, approvalService,
fifoService, fifoStockV2Service,
validate, validate,
) )
userService := sUser.NewUserService(userRepo, validate) userService := sUser.NewUserService(userRepo, validate)
@@ -7,6 +7,7 @@ import (
"gitlab.com/mbugroup/lti-api.git/internal/common/repository" "gitlab.com/mbugroup/lti-api.git/internal/common/repository"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities" entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
"gorm.io/gorm" "gorm.io/gorm"
) )
@@ -14,6 +15,8 @@ type TransferLayingRepository interface {
repository.BaseRepository[entity.LayingTransfer] repository.BaseRepository[entity.LayingTransfer]
GetByTransferNumber(ctx context.Context, transferNumber string) (*entity.LayingTransfer, error) GetByTransferNumber(ctx context.Context, transferNumber string) (*entity.LayingTransfer, error)
IdExists(ctx context.Context, id uint) (bool, error) IdExists(ctx context.Context, id uint) (bool, error)
GetLatestApprovedBySourceKandang(ctx context.Context, sourceProjectFlockKandangID uint) (*entity.LayingTransfer, error)
GetLatestApprovedByTargetKandang(ctx context.Context, targetProjectFlockKandangID uint) (*entity.LayingTransfer, error)
// Tambah method baru untuk query dengan filter lengkap // Tambah method baru untuk query dengan filter lengkap
GetAllWithFilters(ctx context.Context, offset int, limit int, params *GetAllFilterParams) ([]entity.LayingTransfer, int64, error) GetAllWithFilters(ctx context.Context, offset int, limit int, params *GetAllFilterParams) ([]entity.LayingTransfer, int64, error)
@@ -163,7 +166,11 @@ func (r *TransferLayingRepositoryImpl) GetAllWithFilters(ctx context.Context, of
q = q.Offset(offset).Limit(limit). q = q.Offset(offset).Limit(limit).
Preload("FromProjectFlock"). Preload("FromProjectFlock").
Preload("ToProjectFlock"). Preload("ToProjectFlock").
Preload("SourceProjectFlockKandang").
Preload("SourceProjectFlockKandang.Kandang").
Preload("SourceProductWarehouse").
Preload("CreatedUser"). Preload("CreatedUser").
Preload("ExecutedUser").
Preload("Sources"). Preload("Sources").
Preload("Sources.SourceProjectFlockKandang"). Preload("Sources.SourceProjectFlockKandang").
Preload("Sources.SourceProjectFlockKandang.Kandang"). Preload("Sources.SourceProjectFlockKandang.Kandang").
@@ -180,3 +187,58 @@ func (r *TransferLayingRepositoryImpl) GetAllWithFilters(ctx context.Context, of
return records, total, nil return records, total, nil
} }
func (r *TransferLayingRepositoryImpl) GetLatestApprovedBySourceKandang(ctx context.Context, sourceProjectFlockKandangID uint) (*entity.LayingTransfer, error) {
if sourceProjectFlockKandangID == 0 {
return nil, gorm.ErrRecordNotFound
}
var transfer entity.LayingTransfer
err := r.db.WithContext(ctx).
Model(&entity.LayingTransfer{}).
Distinct("laying_transfers.*").
Joins("LEFT JOIN laying_transfer_sources lts ON lts.laying_transfer_id = laying_transfers.id AND lts.deleted_at IS NULL").
Where("(laying_transfers.source_project_flock_kandang_id = ? OR lts.source_project_flock_kandang_id = ?)", sourceProjectFlockKandangID, sourceProjectFlockKandangID).
Where("laying_transfers.deleted_at IS NULL").
Where(`(
SELECT a.action
FROM approvals a
WHERE a.approvable_type = ?
AND a.approvable_id = laying_transfers.id
ORDER BY a.id DESC
LIMIT 1
) = ?`, string(utils.ApprovalWorkflowTransferToLaying), entity.ApprovalActionApproved).
Order("laying_transfers.id DESC").
First(&transfer).Error
if err != nil {
return nil, err
}
return &transfer, nil
}
func (r *TransferLayingRepositoryImpl) GetLatestApprovedByTargetKandang(ctx context.Context, targetProjectFlockKandangID uint) (*entity.LayingTransfer, error) {
if targetProjectFlockKandangID == 0 {
return nil, gorm.ErrRecordNotFound
}
var transfer entity.LayingTransfer
err := r.db.WithContext(ctx).
Model(&entity.LayingTransfer{}).
Joins("JOIN laying_transfer_targets ltt ON ltt.laying_transfer_id = laying_transfers.id AND ltt.deleted_at IS NULL").
Where("ltt.target_project_flock_kandang_id = ?", targetProjectFlockKandangID).
Where("laying_transfers.deleted_at IS NULL").
Where(`(
SELECT a.action
FROM approvals a
WHERE a.approvable_type = ?
AND a.approvable_id = laying_transfers.id
ORDER BY a.id DESC
LIMIT 1
) = ?`, string(utils.ApprovalWorkflowTransferToLaying), entity.ApprovalActionApproved).
Order("laying_transfers.id DESC").
First(&transfer).Error
if err != nil {
return nil, err
}
return &transfer, nil
}
@@ -2,15 +2,22 @@ package repository
import ( import (
"context" "context"
"errors"
"time"
"gitlab.com/mbugroup/lti-api.git/internal/common/repository" "gitlab.com/mbugroup/lti-api.git/internal/common/repository"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities" entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
"gitlab.com/mbugroup/lti-api.git/internal/utils/fifo"
"gorm.io/gorm" "gorm.io/gorm"
) )
type LayingTransferTargetRepository interface { type LayingTransferTargetRepository interface {
repository.BaseRepository[entity.LayingTransferTarget] repository.BaseRepository[entity.LayingTransferTarget]
GetByLayingTransferId(ctx context.Context, layingTransferId uint) ([]entity.LayingTransferTarget, error) GetByLayingTransferId(ctx context.Context, layingTransferId uint) ([]entity.LayingTransferTarget, error)
GetActiveDownstreamConsumptions(ctx context.Context, targetIDs []uint) ([]TargetDownstreamConsumption, error)
GetEarliestRecordingDateByTarget(ctx context.Context, targetProjectFlockKandangID uint, sinceDate time.Time) (*time.Time, error)
CountActiveTransferSourceConsumeAllocations(ctx context.Context, transferID uint, productWarehouseID uint) (int64, error)
SyncPopulationUsageByProjectFlockKandang(ctx context.Context, projectFlockKandangID uint) error
} }
type LayingTransferTargetRepositoryImpl struct { type LayingTransferTargetRepositoryImpl struct {
@@ -18,6 +25,11 @@ type LayingTransferTargetRepositoryImpl struct {
db *gorm.DB db *gorm.DB
} }
type TargetDownstreamConsumption struct {
UsableType string `gorm:"column:usable_type"`
UsableID uint `gorm:"column:usable_id"`
}
func NewLayingTransferTargetRepository(db *gorm.DB) LayingTransferTargetRepository { func NewLayingTransferTargetRepository(db *gorm.DB) LayingTransferTargetRepository {
return &LayingTransferTargetRepositoryImpl{ return &LayingTransferTargetRepositoryImpl{
BaseRepositoryImpl: repository.NewBaseRepository[entity.LayingTransferTarget](db), BaseRepositoryImpl: repository.NewBaseRepository[entity.LayingTransferTarget](db),
@@ -36,3 +48,123 @@ func (r *LayingTransferTargetRepositoryImpl) GetByLayingTransferId(ctx context.C
} }
return targets, nil return targets, nil
} }
func (r *LayingTransferTargetRepositoryImpl) GetActiveDownstreamConsumptions(ctx context.Context, targetIDs []uint) ([]TargetDownstreamConsumption, error) {
if len(targetIDs) == 0 {
return nil, nil
}
var rows []TargetDownstreamConsumption
err := r.db.WithContext(ctx).
Table("stock_allocations").
Select("usable_type, usable_id").
Where("stockable_type = ?", fifo.StockableKeyTransferToLayingIn.String()).
Where("stockable_id IN ?", targetIDs).
Where("status = ?", entity.StockAllocationStatusActive).
Where("allocation_purpose = ?", entity.StockAllocationPurposeConsume).
Where("deleted_at IS NULL").
Group("usable_type, usable_id").
Scan(&rows).Error
if err != nil {
return nil, err
}
return rows, nil
}
func (r *LayingTransferTargetRepositoryImpl) GetEarliestRecordingDateByTarget(ctx context.Context, targetProjectFlockKandangID uint, sinceDate time.Time) (*time.Time, error) {
if targetProjectFlockKandangID == 0 {
return nil, nil
}
var earliest entity.Recording
query := r.db.WithContext(ctx).
Model(&entity.Recording{}).
Where("project_flock_kandangs_id = ?", targetProjectFlockKandangID).
Where("deleted_at IS NULL")
if !sinceDate.IsZero() {
query = query.Where("record_datetime >= ?", sinceDate)
}
if err := query.Order("record_datetime ASC").Limit(1).Take(&earliest).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
d := earliest.RecordDatetime.UTC()
return &d, nil
}
func (r *LayingTransferTargetRepositoryImpl) CountActiveTransferSourceConsumeAllocations(ctx context.Context, transferID uint, productWarehouseID uint) (int64, error) {
if transferID == 0 || productWarehouseID == 0 {
return 0, nil
}
var count int64
err := r.db.WithContext(ctx).
Model(&entity.StockAllocation{}).
Where("product_warehouse_id = ?", productWarehouseID).
Where("usable_type = ?", fifo.UsableKeyTransferToLayingOut.String()).
Where("usable_id = ?", transferID).
Where("status = ?", entity.StockAllocationStatusActive).
Where("allocation_purpose = ?", entity.StockAllocationPurposeConsume).
Count(&count).Error
if err != nil {
return 0, err
}
return count, nil
}
func (r *LayingTransferTargetRepositoryImpl) SyncPopulationUsageByProjectFlockKandang(ctx context.Context, projectFlockKandangID uint) error {
if projectFlockKandangID == 0 {
return nil
}
var populationIDs []uint
if err := r.db.WithContext(ctx).
Table("project_flock_populations pfp").
Select("pfp.id").
Joins("JOIN project_chickins pc ON pc.id = pfp.project_chickin_id").
Where("pc.project_flock_kandang_id = ?", projectFlockKandangID).
Pluck("pfp.id", &populationIDs).Error; err != nil {
return err
}
if len(populationIDs) == 0 {
return nil
}
type usageRow struct {
StockableID uint `gorm:"column:stockable_id"`
Used float64 `gorm:"column:used"`
}
var usageRows []usageRow
if err := r.db.WithContext(ctx).
Table("stock_allocations").
Select("stockable_id, COALESCE(SUM(qty), 0) AS used").
Where("stockable_type = ?", fifo.StockableKeyProjectFlockPopulation.String()).
Where("status = ?", entity.StockAllocationStatusActive).
Where("allocation_purpose = ?", entity.StockAllocationPurposeConsume).
Where("stockable_id IN ?", populationIDs).
Group("stockable_id").
Scan(&usageRows).Error; err != nil {
return err
}
if err := r.db.WithContext(ctx).
Model(&entity.ProjectFlockPopulation{}).
Where("id IN ?", populationIDs).
Update("total_used_qty", 0).Error; err != nil {
return err
}
for _, row := range usageRows {
if err := r.db.WithContext(ctx).
Model(&entity.ProjectFlockPopulation{}).
Where("id = ?", row.StockableID).
Update("total_used_qty", row.Used).Error; err != nil {
return err
}
}
return nil
}

Some files were not shown because too many files have changed in this diff Show More