Compare commits

...

17 Commits

Author SHA1 Message Date
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
59 changed files with 5210 additions and 2374 deletions
+24 -59
View File
@@ -11,12 +11,10 @@ import (
"strings"
"time"
commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository"
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"
rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories"
"gitlab.com/mbugroup/lti-api.git/internal/utils/fifo"
"gorm.io/gorm"
)
@@ -61,13 +59,7 @@ func main() {
ctx := context.Background()
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)
if err := registerAdjustmentFIFO(fifoSvc); err != nil {
log.Fatalf("failed to register adjustment fifo config: %v", err)
}
adjustments, err := loadAdjustments(ctx, db, ids)
if err != nil {
@@ -134,12 +126,7 @@ func main() {
reflowReq := commonSvc.FifoStockV2ReflowRequest{
FlagGroupCode: route.FlagGroupCode,
ProductWarehouseID: adj.ProductWarehouseID,
Usable: commonSvc.FifoStockV2Ref{
ID: adj.ID,
LegacyTypeKey: fifo.UsableKeyAdjustmentOut.String(),
FunctionCode: route.FunctionCode,
},
DesiredQty: 0,
AsOf: &adj.CreatedAt,
IdempotencyKey: fmt.Sprintf("delete-adjustment-usable-%d-%d", adj.ID, time.Now().UnixNano()),
Tx: tx,
}
@@ -190,7 +177,7 @@ func main() {
}
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,
route.FunctionCode,
adj.TotalQty,
@@ -203,16 +190,25 @@ func main() {
}
err = db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if removeQty > 0 {
if err := fifoSvc.AdjustStockableQuantity(ctx, commonSvc.StockAdjustRequest{
StockableKey: fifo.StockableKeyAdjustmentIn,
StockableID: adj.ID,
ProductWarehouseID: adj.ProductWarehouseID,
Quantity: -removeQty,
Tx: tx,
}); err != nil {
return fmt.Errorf("reverse stockable quantity: %w", err)
if err := tx.WithContext(ctx).
Table("adjustment_stocks").
Where("id = ?", adj.ID).
Updates(map[string]any{
"total_qty": 0,
"total_used": 0,
}).Error; err != nil {
return fmt.Errorf("set stockable qty to zero: %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 {
@@ -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 {
if apply {
return "APPLY"
@@ -403,6 +366,7 @@ func countActiveUsableAllocations(ctx context.Context, db *gorm.DB, usableType s
Table("stock_allocations").
Where("usable_type = ? AND usable_id = ?", usableType, usableID).
Where("status = ?", entity.StockAllocationStatusActive).
Where("allocation_purpose = ?", entity.StockAllocationPurposeConsume).
Count(&count).Error
return count, err
}
@@ -413,19 +377,20 @@ func countActiveStockableAllocations(ctx context.Context, db *gorm.DB, stockable
Table("stock_allocations").
Where("stockable_type = ? AND stockable_id = ?", stockableType, stockableID).
Where("status = ?", entity.StockAllocationStatusActive).
Where("allocation_purpose = ?", entity.StockAllocationPurposeConsume).
Count(&count).Error
return count, err
}
func hardDeleteUsableAllocations(ctx context.Context, tx *gorm.DB, usableType string, usableID uint) error {
return tx.WithContext(ctx).
Exec("DELETE FROM stock_allocations WHERE usable_type = ? AND usable_id = ?", usableType, usableID).
Exec("DELETE FROM stock_allocations WHERE usable_type = ? AND usable_id = ? AND allocation_purpose = ?", usableType, usableID, entity.StockAllocationPurposeConsume).
Error
}
func hardDeleteStockableAllocations(ctx context.Context, tx *gorm.DB, stockableType string, stockableID uint) error {
return tx.WithContext(ctx).
Exec("DELETE FROM stock_allocations WHERE stockable_type = ? AND stockable_id = ?", stockableType, stockableID).
Exec("DELETE FROM stock_allocations WHERE stockable_type = ? AND stockable_id = ? AND allocation_purpose = ?", stockableType, stockableID, entity.StockAllocationPurposeConsume).
Error
}
+2 -12
View File
@@ -121,12 +121,7 @@ func main() {
continue
}
usableType := fifo.UsableKeyAdjustmentOut.String()
if route.SourceTable == "adjustment_stocks" && strings.TrimSpace(route.LegacyTypeKey) != "" {
usableType = strings.TrimSpace(route.LegacyTypeKey)
}
activeAllocationCount, err := countActiveAllocations(ctx, db, usableType, adj.ID)
activeAllocationCount, err := countActiveAllocations(ctx, db, fifo.UsableKeyAdjustmentOut.String(), adj.ID)
if err != nil {
fmt.Printf("FAIL adj=%d error=count allocations: %v\n", adj.ID, err)
failed++
@@ -142,12 +137,6 @@ func main() {
reflowReq := commonSvc.FifoStockV2ReflowRequest{
FlagGroupCode: route.FlagGroupCode,
ProductWarehouseID: adj.ProductWarehouseID,
Usable: commonSvc.FifoStockV2Ref{
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 {
@@ -335,6 +324,7 @@ func countActiveAllocations(ctx context.Context, db *gorm.DB, usableType string,
Table("stock_allocations").
Where("usable_type = ? AND usable_id = ?", usableType, usableID).
Where("status = ?", entity.StockAllocationStatusActive).
Where("allocation_purpose = ?", entity.StockAllocationPurposeConsume).
Count(&count).Error
if err != nil {
return 0, err
+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
err := r.db.WithContext(ctx).
Table("project_chickins AS pc").
Select("COALESCE(SUM(pc.usage_qty * COALESCE(pi.price, 0)), 0)").
Joins("JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = pc.id AND sa.stockable_type = ?", fifo.UsableKeyProjectChickin.String(), fifo.StockableKeyPurchaseItems.String()).
Select("COALESCE(SUM(sa.qty * COALESCE(pi.price, 0)), 0)").
Joins("JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = pc.id AND sa.stockable_type = ? AND sa.status = ? AND sa.allocation_purpose = ?", fifo.UsableKeyProjectChickin.String(), fifo.StockableKeyPurchaseItems.String(), entity.StockAllocationStatusActive, entity.StockAllocationPurposeTraceChickin).
Joins("JOIN purchase_items AS pi ON pi.id = sa.stockable_id").
Where("pc.project_flock_kandang_id IN (?)", projectFlockKandangIDs).
Scan(&total).Error
@@ -103,11 +103,11 @@ func (r *HppRepositoryImpl) GetFeedUsageCost(ctx context.Context, projectFlockKa
var total float64
err := r.db.WithContext(ctx).
Table("recordings AS r").
Select("COALESCE(SUM(rs.usage_qty * COALESCE(pi.price, 0)), 0)").
Select("COALESCE(SUM(sa.qty * COALESCE(pi.price, 0)), 0)").
Joins("JOIN recording_stocks AS rs ON rs.recording_id = r.id").
Joins("JOIN product_warehouses AS pw ON pw.id = rs.product_warehouse_id").
Joins("JOIN flags AS f ON f.flagable_id = pw.product_id AND f.flagable_type = ?", entity.FlagableTypeProduct).
Joins("JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = rs.id AND sa.stockable_type = ?", fifo.UsableKeyRecordingStock.String(), fifo.StockableKeyPurchaseItems.String()).
Joins("JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = rs.id AND sa.stockable_type = ? AND sa.status = ? AND sa.allocation_purpose = ?", fifo.UsableKeyRecordingStock.String(), fifo.StockableKeyPurchaseItems.String(), entity.StockAllocationStatusActive, entity.StockAllocationPurposeConsume).
Joins("JOIN purchase_items AS pi ON pi.id = sa.stockable_id").
Where("r.project_flock_kandangs_id IN (?)", projectFlockKandangIDs).
Where("r.record_datetime <= ?", *date).
@@ -136,10 +136,10 @@ func (r *HppRepositoryImpl) GetOvkUsageCost(ctx context.Context, projectFlockKan
var total float64
err := r.db.WithContext(ctx).
Table("recordings AS r").
Select("COALESCE(SUM(rs.usage_qty * COALESCE(pi.price, 0)), 0)").
Select("COALESCE(SUM(sa.qty * COALESCE(pi.price, 0)), 0)").
Joins("JOIN recording_stocks AS rs ON rs.recording_id = r.id").
Joins("JOIN product_warehouses AS pw ON pw.id = rs.product_warehouse_id").
Joins("JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = rs.id AND sa.stockable_type = ?", fifo.UsableKeyRecordingStock.String(), fifo.StockableKeyPurchaseItems.String()).
Joins("JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = rs.id AND sa.stockable_type = ? AND sa.status = ? AND sa.allocation_purpose = ?", fifo.UsableKeyRecordingStock.String(), fifo.StockableKeyPurchaseItems.String(), entity.StockAllocationStatusActive, entity.StockAllocationPurposeConsume).
Joins("JOIN purchase_items AS pi ON pi.id = sa.stockable_id").
Where("r.project_flock_kandangs_id IN (?)", projectFlockKandangIDs).
Where("r.record_datetime <= ?", *date).
@@ -175,15 +175,15 @@ func (r *HppRepositoryImpl) GetPulletCost(ctx context.Context, projectFlockKanda
err := r.db.WithContext(ctx).
Table("project_chickins AS pc").
Select(`
COALESCE(SUM(pc.usage_qty * CASE
COALESCE(SUM(sa.qty * CASE
WHEN sa.stockable_type = ? THEN COALESCE(pi.price, 0)
WHEN sa.stockable_type = ? THEN COALESCE(tpi.price, 0)
ELSE 0
END), 0)`,
stockablePurchase, stockableTransferIn).
Joins("JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = pc.id", usableProjectChickin).
Joins("JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = pc.id AND sa.status = ? AND sa.allocation_purpose = ?", usableProjectChickin, entity.StockAllocationStatusActive, entity.StockAllocationPurposeTraceChickin).
Joins("LEFT JOIN purchase_items AS pi ON pi.id = sa.stockable_id AND sa.stockable_type = ?", stockablePurchase).
Joins("LEFT JOIN stock_allocations AS tsa ON tsa.usable_type = ? AND tsa.usable_id = sa.stockable_id AND sa.stockable_type = ? AND tsa.stockable_type = ?", stockableTransferIn, stockableTransferIn, stockablePurchase).
Joins("LEFT JOIN stock_allocations AS tsa ON tsa.usable_type = ? AND tsa.usable_id = sa.stockable_id AND sa.stockable_type = ? AND tsa.stockable_type = ? AND tsa.status = ? AND tsa.allocation_purpose = ?", stockableTransferIn, stockableTransferIn, stockablePurchase, entity.StockAllocationStatusActive, entity.StockAllocationPurposeConsume).
Joins("LEFT JOIN purchase_items AS tpi ON tpi.id = tsa.stockable_id").
Where("pc.project_flock_kandang_id = ?", projectFlockKandangId).
Scan(&total).Error
@@ -245,9 +245,11 @@ func (r *HppRepositoryImpl) GetEggTerjualPiecesAndWeightKgByProjectFlockKandangI
`).
Joins("JOIN recording_eggs re ON re.recording_id = r.id").
Joins(
"JOIN stock_allocations sa ON sa.stockable_type = ? AND sa.stockable_id = re.id AND sa.usable_type = ?",
"JOIN stock_allocations sa ON sa.stockable_type = ? AND sa.stockable_id = re.id AND sa.usable_type = ? AND sa.status = ? AND sa.allocation_purpose = ?",
fifo.StockableKeyRecordingEgg.String(),
fifo.UsableKeyMarketingDelivery.String(),
entity.StockAllocationStatusActive,
entity.StockAllocationPurposeConsume,
).
Joins("JOIN marketing_delivery_products mdp ON mdp.id = sa.usable_id").
Where("r.project_flock_kandangs_id IN (?)", projectFlockKandangIDs).
@@ -297,6 +299,9 @@ func (r *HppRepositoryImpl) GetTransferSourceSummary(ctx context.Context, projec
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").
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).
Group("lt.from_project_flock_id").
Scan(&summary).Error
@@ -33,7 +33,7 @@ func (r *StockAllocationRepositoryImpl) FindActiveByUsable(
var allocations []entity.StockAllocation
q := r.DB().WithContext(ctx).
Where("usable_type = ? AND usable_id = ? AND status = ?", usableType, usableID, entity.StockAllocationStatusActive)
Where("usable_type = ? AND usable_id = ? AND status = ? AND allocation_purpose = ?", usableType, usableID, entity.StockAllocationStatusActive, entity.StockAllocationPurposeConsume)
if modifier != nil {
q = modifier(q)
@@ -70,7 +70,7 @@ func (r *StockAllocationRepositoryImpl) ReleaseByUsable(
q := baseDB.WithContext(ctx).
Model(&entity.StockAllocation{}).
Where("usable_type = ? AND usable_id = ? AND status = ?", usableType, usableID, entity.StockAllocationStatusActive)
Where("usable_type = ? AND usable_id = ? AND status = ? AND allocation_purpose = ?", usableType, usableID, entity.StockAllocationStatusActive, entity.StockAllocationPurposeConsume)
return q.Updates(updates).Error
}
@@ -528,6 +528,7 @@ func (s *fifoService) allocateFromStock(
UsableType: usableKey.String(),
UsableId: usableID,
Qty: portion,
AllocationPurpose: entities.StockAllocationPurposeConsume,
Status: entities.StockAllocationStatusActive,
})
+107 -19
View File
@@ -157,6 +157,7 @@ func (s *fifoStockV2Service) allocateInternal(ctx context.Context, tx *gorm.DB,
"usable_id": req.Usable.ID,
"qty": portion,
"status": activeAllocationStatus(),
"allocation_purpose": defaultAllocationPurpose(),
"created_at": now,
"updated_at": now,
"engine_version": "v2",
@@ -401,12 +402,9 @@ func (s *fifoStockV2Service) rollbackInternal(
}
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)
}
if req.DesiredQty < 0 {
return nil, fmt.Errorf("%w: desired qty must be >= 0", ErrInvalidRequest)
}
result := &ReflowResult{}
err := s.withTransaction(ctx, req.Tx, func(tx *gorm.DB) error {
@@ -420,11 +418,7 @@ func (s *fifoStockV2Service) Reflow(ctx context.Context, req ReflowRequest) (*Re
hash := requestHash(map[string]any{
"flag_group_code": req.FlagGroupCode,
"product_warehouse_id": req.ProductWarehouseID,
"usable_type": req.Usable.LegacyTypeKey,
"usable_id": req.Usable.ID,
"desired_qty": req.DesiredQty,
"as_of": req.AsOf,
"allow_over_consume": req.AllowOverConsume,
})
logRow, reused, err := s.beginOperation(
tx,
@@ -433,8 +427,8 @@ func (s *fifoStockV2Service) Reflow(ctx context.Context, req ReflowRequest) (*Re
hash,
req.ProductWarehouseID,
req.FlagGroupCode,
req.Usable.LegacyTypeKey,
req.Usable.ID,
"",
0,
)
if err != nil {
return err
@@ -456,9 +450,24 @@ func (s *fifoStockV2Service) Reflow(ctx context.Context, req ReflowRequest) (*Re
}()
}
usableRows, gatherErr := s.gatherAllRows(ctx, tx, GatherRequest{
FlagGroupCode: req.FlagGroupCode,
Lane: LaneUsable,
ProductWarehouseID: req.ProductWarehouseID,
Limit: s.defaultGatherLimit,
})
if gatherErr != nil {
err = gatherErr
return gatherErr
}
result.ProcessedUsables = len(usableRows)
for _, usableRow := range usableRows {
desiredQty := usableRow.Quantity + usableRow.PendingQuantity
rollbackRes, rollbackErr := s.rollbackInternal(ctx, tx, RollbackRequest{
ProductWarehouseID: req.ProductWarehouseID,
Usable: req.Usable,
Usable: usableRow.Ref,
ReleaseQty: nil,
Reason: "reflow reset",
}, req.FlagGroupCode)
@@ -466,22 +475,52 @@ func (s *fifoStockV2Service) Reflow(ctx context.Context, req ReflowRequest) (*Re
err = rollbackErr
return rollbackErr
}
result.Rollback = *rollbackRes
result.Rollback.ReleasedQty += rollbackRes.ReleasedQty
if len(rollbackRes.Details) > 0 {
result.Rollback.Details = append(result.Rollback.Details, rollbackRes.Details...)
}
minDesired := rollbackRes.ReleasedQty + usableRow.PendingQuantity
if desiredQty < minDesired {
desiredQty = minDesired
}
if req.DesiredQty > 0 {
if desiredQty <= 0 {
continue
}
allocateRes, allocateErr := s.allocateInternal(ctx, tx, AllocateRequest{
FlagGroupCode: req.FlagGroupCode,
ProductWarehouseID: req.ProductWarehouseID,
Usable: req.Usable,
NeedQty: req.DesiredQty,
AllowOverConsume: req.AllowOverConsume,
AsOf: req.AsOf,
Usable: usableRow.Ref,
NeedQty: desiredQty,
AsOf: nil,
})
if allocateErr != nil {
err = 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 {
@@ -496,6 +535,54 @@ func (s *fifoStockV2Service) Reflow(ctx context.Context, req ReflowRequest) (*Re
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(
tx *gorm.DB,
usableType string,
@@ -504,7 +591,7 @@ func (s *fifoStockV2Service) loadActiveAllocations(
) ([]allocationRow, error) {
query := tx.Table("stock_allocations").
Select("id, product_warehouse_id, stockable_type, stockable_id, usable_type, usable_id, qty, status, created_at").
Where("usable_type = ? AND usable_id = ? AND status = ?", usableType, usableID, activeAllocationStatus())
Where("usable_type = ? AND usable_id = ? AND status = ? AND allocation_purpose = ?", usableType, usableID, activeAllocationStatus(), defaultAllocationPurpose())
if productWarehouseID > 0 {
query = query.Where("product_warehouse_id = ?", productWarehouseID)
}
@@ -603,6 +690,7 @@ func (s *fifoStockV2Service) resolveRollbackFlagGroup(ctx context.Context, tx *g
Select("flag_group_code").
Where("usable_type = ? AND usable_id = ?", req.Usable.LegacyTypeKey, req.Usable.ID).
Where("engine_version = 'v2'").
Where("allocation_purpose = ?", defaultAllocationPurpose()).
Where("flag_group_code IS NOT NULL AND flag_group_code <> ''").
Order("id DESC").
Limit(1).
@@ -48,6 +48,8 @@ func (s *fifoStockV2Service) Gather(ctx context.Context, req GatherRequest) ([]G
}
func (s *fifoStockV2Service) gatherRows(ctx context.Context, tx *gorm.DB, req GatherRequest) ([]GatherRow, error) {
req.AllocationPurpose = normalizeAllocationPurpose(req.AllocationPurpose)
rules, err := s.loadRouteRules(ctx, tx, req.FlagGroupCode, req.Lane)
if err != nil {
return nil, err
@@ -151,19 +153,29 @@ func (s *fifoStockV2Service) buildGatherSubquery(rule routeRule, trait traitRule
usedExpr := "0::numeric"
pendingExpr := "0::numeric"
availableExpr := baseQtyExpr
extraArgs := make([]any, 0, 1)
extraArgs := make([]any, 0, 2)
whereExtraArgs := make([]any, 0, 1)
if req.Lane == LaneStockable {
if rule.UsedQuantityCol != nil && strings.TrimSpace(*rule.UsedQuantityCol) != "" {
if !req.IgnoreSourceUsed && rule.UsedQuantityCol != nil && strings.TrimSpace(*rule.UsedQuantityCol) != "" {
usedCol, _ := mustSafeIdentifier(*rule.UsedQuantityCol)
usedExpr = fmt.Sprintf("COALESCE(src.%s,0)::numeric", usedCol)
} else {
// 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(
"(SELECT COALESCE(SUM(sa.qty),0)::numeric FROM stock_allocations sa WHERE sa.stockable_type = ? AND sa.stockable_id = src.%s AND sa.status = '%s')",
"(SELECT COALESCE(SUM(sa.qty),0)::numeric FROM stock_allocations sa WHERE sa.stockable_type = ? AND sa.stockable_id = src.%s AND sa.status = '%s' AND sa.allocation_purpose = ?)",
sourceIDCol,
activeAllocationStatus(),
)
extraArgs = append(extraArgs, rule.LegacyTypeKey)
extraArgs = append(extraArgs, rule.LegacyTypeKey, req.AllocationPurpose)
extraArgs = append(extraArgs, rule.LegacyTypeKey, req.AllocationPurpose)
whereExtraArgs = append(whereExtraArgs, rule.LegacyTypeKey, req.AllocationPurpose)
}
availableExpr = fmt.Sprintf("(%s - %s)", baseQtyExpr, usedExpr)
} else {
@@ -179,6 +191,12 @@ func (s *fifoStockV2Service) buildGatherSubquery(rule routeRule, trait traitRule
return "", nil, err
}
functionCodeExpr := "?::text"
functionCodeArgs := []any{rule.FunctionCode}
if rule.SourceTable == "adjustment_stocks" {
functionCodeExpr = "COALESCE(NULLIF(src.function_code,''), ?::text)"
}
whereParts := []string{
fmt.Sprintf("src.%s = ?", productWarehouseCol),
fmt.Sprintf(`EXISTS (
@@ -197,6 +215,9 @@ func (s *fifoStockV2Service) buildGatherSubquery(rule routeRule, trait traitRule
if req.AsOf != nil {
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) != "" {
whereParts = append(whereParts, fmt.Sprintf("(%s)", normalizeScopeSQL(*rule.ScopeSQL)))
@@ -206,7 +227,7 @@ func (s *fifoStockV2Service) buildGatherSubquery(rule routeRule, trait traitRule
SELECT
?::text AS source_table,
?::text AS legacy_type_key,
?::text AS function_code,
%s AS function_code,
src.%s AS source_id,
src.%s AS product_warehouse_id,
%s AS sort_at,
@@ -218,24 +239,28 @@ func (s *fifoStockV2Service) buildGatherSubquery(rule routeRule, trait traitRule
FROM %s src
%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{
rule.SourceTable,
rule.LegacyTypeKey,
rule.FunctionCode,
trait.SortPriority,
}
args = append(args, functionCodeArgs...)
args = append(args, trait.SortPriority)
args = append(args, extraArgs...)
args = append(args,
req.ProductWarehouseID,
entity.FlagableTypeProduct,
req.FlagGroupCode,
)
args = append(args, whereExtraArgs...)
if req.AsOf != nil {
args = append(args, *req.AsOf)
}
if req.From != nil {
args = append(args, *req.From)
}
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 {
checkCols := []string{"engine_version", "flag_group_code", "function_code", "idempotency_key"}
checkCols := []string{"engine_version", "flag_group_code", "function_code", "idempotency_key", "allocation_purpose"}
for _, col := range checkCols {
var count int64
err := tx.Raw(`
@@ -263,3 +263,15 @@ func activeAllocationStatus() string {
func releasedAllocationStatus() string {
return entity.StockAllocationStatusReleased
}
func defaultAllocationPurpose() string {
return entity.StockAllocationPurposeConsume
}
func normalizeAllocationPurpose(purpose string) string {
purpose = strings.TrimSpace(strings.ToUpper(purpose))
if purpose == "" {
return defaultAllocationPurpose()
}
return purpose
}
@@ -33,7 +33,10 @@ type Ref struct {
type GatherRequest struct {
FlagGroupCode string
Lane Lane
AllocationPurpose string
IgnoreSourceUsed bool
ProductWarehouseID uint
From *time.Time
AsOf *time.Time
Limit int
AfterSortAt *time.Time
@@ -98,15 +101,13 @@ type RollbackResult struct {
type ReflowRequest struct {
FlagGroupCode string
ProductWarehouseID uint
Usable Ref
DesiredQty float64
AllowOverConsume *bool
IdempotencyKey string
AsOf *time.Time
IdempotencyKey string
Tx *gorm.DB
}
type ReflowResult struct {
ProcessedUsables int
Rollback RollbackResult
Allocate AllocateResult
}
+6
View File
@@ -76,6 +76,7 @@ var (
S3PublicBaseURL string
S3EnvPrefix string
S3DocumentKeyPrefix string
TransferToLayingGrowingMaxWeek int
)
func init() {
@@ -117,6 +118,11 @@ func init() {
// Redis
RedisURL = viper.GetString("REDIS_URL")
TransferToLayingGrowingMaxWeek = viper.GetInt("TRANSFER_TO_LAYING_GROWING_MAX_WEEK")
if TransferToLayingGrowingMaxWeek <= 0 {
TransferToLayingGrowingMaxWeek = 19
}
// Object storage
S3Endpoint = strings.TrimSpace(viper.GetString("S3_ENDPOINT"))
S3Region = strings.TrimSpace(viper.GetString("S3_REGION"))
@@ -24,7 +24,8 @@ WHERE source_table IN (
'recording_depletions',
'recording_eggs',
'marketing_delivery_products',
'project_chickins'
'project_chickins',
'project_flock_populations'
);
DELETE FROM fifo_stock_v2_flag_members
@@ -79,7 +79,8 @@ VALUES
('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
SET
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', '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', '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', 'ADJUSTMENT_OUT', 'adjustment_stocks', 'id', 'product_warehouse_id', 'usage_qty', NULL, 'pending_qty', NULL, 'ADJUSTMENT_OUT', TRUE, TRUE),
@@ -0,0 +1,13 @@
BEGIN;
-- Restore CHICKIN route if rollback is required.
-- NOTE: released PROJECT_CHICKIN allocations are not restored by this down migration.
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';
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,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;
+5
View File
@@ -12,6 +12,10 @@ type LayingTransfer struct {
FromProjectFlockId uint `gorm:"not null"`
ToProjectFlockId uint `gorm:"not null"`
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"`
@@ -21,6 +25,7 @@ type LayingTransfer struct {
FromProjectFlock *ProjectFlock `gorm:"foreignKey:FromProjectFlockId;references:Id"`
ToProjectFlock *ProjectFlock `gorm:"foreignKey:ToProjectFlockId;references:Id"`
CreatedUser *User `gorm:"foreignKey:CreatedBy;references:Id"`
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:"-"`
+5 -1
View File
@@ -10,6 +10,9 @@ const (
StockAllocationStatusPending = "PENDING"
StockAllocationStatusActive = "ACTIVE"
StockAllocationStatusReleased = "RELEASED"
StockAllocationPurposeConsume = "CONSUME"
StockAllocationPurposeTraceChickin = "TRACE_CHICKIN"
)
// StockAllocation links a usable record (consumption) with an incoming stock record.
@@ -22,7 +25,8 @@ type StockAllocation struct {
UsableType string `gorm:"size:100;not null;index:stock_allocations_usage_lookup,priority:1"`
UsableId uint `gorm:"not null;index:stock_allocations_usage_lookup,priority:2"`
Qty float64 `gorm:"type:numeric(15,3);not null"`
Status string `gorm:"size:20;not null;default:ACTIVE"`
AllocationPurpose string `gorm:"size:32;not null;default:CONSUME;index:stock_allocations_purpose_status,priority:1"`
Status string `gorm:"size:20;not null;default:ACTIVE;index:stock_allocations_purpose_status,priority:2"`
Note *string `gorm:"type:text"`
CreatedAt time.Time `gorm:"autoCreateTime"`
UpdatedAt time.Time `gorm:"autoUpdateTime"`
@@ -1031,6 +1031,7 @@ func (r *ClosingRepositoryImpl) FetchSapronakUsageAllocatedDetails(ctx context.C
Joins("LEFT JOIN project_chickins pc ON pc.id = pfp.project_chickin_id").
Joins("LEFT JOIN products p_resolve ON p_resolve.id = COALESCE(pi.product_id, pw_ltt.product_id, pw.product_id)").
Where("sa.status = ?", entity.StockAllocationStatusActive).
Where("sa.allocation_purpose = ?", entity.StockAllocationPurposeConsume).
Where("sa.stockable_type <> ?", fifo.StockableKeyProjectFlockPopulation.String()).
Where("f.name IN ?", sapronakFlagsAll).
Where(`
@@ -1236,6 +1237,7 @@ func (r *ClosingRepositoryImpl) FetchSapronakAdjustments(ctx context.Context, ka
Joins("JOIN warehouses w ON w.id = pw.warehouse_id").
Joins("JOIN products p ON p.id = pw.product_id").
Where("sa.status = ?", entity.StockAllocationStatusActive).
Where("sa.allocation_purpose = ?", entity.StockAllocationPurposeConsume).
Where("w.kandang_id = ?", kandangID).
Where("f.name IN ?", sapronakFlagsAll).
Where("f.name NOT IN ?", sapronakFlags(utils.FlagDOC, utils.FlagPullet)).
@@ -1327,6 +1329,7 @@ func (r *ClosingRepositoryImpl) FetchSapronakTransfers(ctx context.Context, kand
Joins("LEFT JOIN warehouses w_dest ON w_dest.id = pw_dest.warehouse_id").
Joins("JOIN products p ON p.id = std.product_id").
Where("sa.status = ?", entity.StockAllocationStatusActive).
Where("sa.allocation_purpose = ?", entity.StockAllocationPurposeConsume).
Where("w.kandang_id = ?", kandangID).
Where("(w_dest.kandang_id IS NULL OR w_dest.kandang_id <> w.kandang_id)").
Where("f.name IN ?", sapronakFlagsAll).
@@ -1358,6 +1361,7 @@ func (r *ClosingRepositoryImpl) FetchSapronakTransfers(ctx context.Context, kand
Joins("JOIN warehouses w ON w.id = pw.warehouse_id").
Joins("JOIN products p ON p.id = pw.product_id").
Where("sa.status = ?", entity.StockAllocationStatusActive).
Where("sa.allocation_purpose = ?", entity.StockAllocationPurposeConsume).
Where("w.kandang_id = ?", kandangID).
Where("(w_dest.kandang_id IS NULL OR w_dest.kandang_id <> w.kandang_id)").
Where("f.name IN ?", sapronakFlagsAll).
@@ -1393,6 +1397,7 @@ func (r *ClosingRepositoryImpl) FetchSapronakSales(ctx context.Context, projectF
Joins("JOIN product_warehouses pw ON pw.id = sa.product_warehouse_id").
Joins("JOIN products p ON p.id = pw.product_id").
Where("sa.status = ?", entity.StockAllocationStatusActive).
Where("sa.allocation_purpose = ?", entity.StockAllocationPurposeConsume).
Where("pw.project_flock_kandang_id = ?", projectFlockKandangID).
Where("f.name IN ?", sapronakFlagsAll).
Group("mdp.id, pw.product_id, p.name, f.name, mdp.delivery_date, mdp.created_at, m.so_number, mdp.unit_price, mp.unit_price")
@@ -1419,9 +1424,10 @@ func (r *ClosingRepositoryImpl) FetchSapronakSales(ctx context.Context, projectF
Joins("JOIN marketings m ON m.id = mp.marketing_id").
Joins("JOIN product_warehouses pw ON pw.id = mp.product_warehouse_id").
Joins("JOIN products p ON p.id = pw.product_id").
Joins("LEFT JOIN stock_allocations sa ON sa.usable_id = mdp.id AND sa.usable_type = ? AND sa.status = ?",
Joins("LEFT JOIN stock_allocations sa ON sa.usable_id = mdp.id AND sa.usable_type = ? AND sa.status = ? AND sa.allocation_purpose = ?",
fifo.UsableKeyMarketingDelivery.String(),
entity.StockAllocationStatusActive,
entity.StockAllocationPurposeConsume,
).
Where("mdp.usage_qty > 0").
Where("sa.id IS NULL").
@@ -1481,6 +1487,7 @@ func (r *ClosingRepositoryImpl) FetchSapronakSalesAllocatedDetails(ctx context.C
Joins("LEFT JOIN laying_transfers lt ON lt.id = ltt.laying_transfer_id").
Joins("LEFT JOIN adjustment_stocks ast ON ast.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyAdjustmentIn.String()).
Where("sa.status = ?", entity.StockAllocationStatusActive).
Where("sa.allocation_purpose = ?", entity.StockAllocationPurposeConsume).
Where("sa.stockable_type <> ?", fifo.StockableKeyProjectFlockPopulation.String()).
Where("pw.project_flock_kandang_id = ?", projectFlockKandangID).
Where("f.name IN ?", sapronakFlagsAll).
@@ -5,7 +5,6 @@ import (
"github.com/gofiber/fiber/v2"
"gorm.io/gorm"
commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository"
commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service"
rAdjustmentStock "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/adjustments/repositories"
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"
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/fifo"
)
type AdjustmentModule struct{}
@@ -31,50 +29,14 @@ func (AdjustmentModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validat
userRepo := rUser.NewUserRepository(db)
productRepo := rproduct.NewProductRepository(db)
adjustmentStockRepo := rAdjustmentStock.NewAdjustmentStockRepository(db)
stockAllocRepo := commonRepo.NewStockAllocationRepository(db)
fifoService := commonSvc.NewFifoService(db, stockAllocRepo, productWarehouseRepo, 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(
productRepo,
stockLogsRepo,
warehouseRepo,
productWarehouseRepo,
adjustmentStockRepo,
fifoService,
fifoStockV2Service,
validate,
projectFlockKandangRepo,
@@ -21,7 +21,6 @@ import (
projectFlockKandangRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories"
stockLogsRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/repositories"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
"gitlab.com/mbugroup/lti-api.git/internal/utils/fifo"
"gorm.io/gorm"
)
@@ -40,13 +39,13 @@ type adjustmentService struct {
ProductRepo productRepo.ProductRepository
ProjectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository
AdjustmentStockRepository adjustmentStockRepo.AdjustmentStockRepository
FifoSvc common.FifoService
FifoStockV2Svc common.FifoStockV2Service
}
const (
adjustmentLaneStockable = "STOCKABLE"
adjustmentLaneUsable = "USABLE"
flagGroupAyam = "AYAM"
)
func NewAdjustmentService(
@@ -55,7 +54,6 @@ func NewAdjustmentService(
warehouseRepo warehouseRepo.WarehouseRepository,
productWarehouseRepo ProductWarehouse.ProductWarehouseRepository,
adjustmentStockRepo adjustmentStockRepo.AdjustmentStockRepository,
fifoSvc common.FifoService,
fifoStockV2Svc common.FifoStockV2Service,
validate *validator.Validate,
projectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository,
@@ -69,7 +67,6 @@ func NewAdjustmentService(
ProductRepo: productRepo,
ProjectFlockKandangRepo: projectFlockKandangRepo,
AdjustmentStockRepository: adjustmentStockRepo,
FifoSvc: fifoSvc,
FifoStockV2Svc: fifoStockV2Svc,
}
}
@@ -130,8 +127,11 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e
if functionCode == "" {
return nil, fiber.NewError(fiber.StatusBadRequest, "Transaction subtype is required")
}
if functionCode == string(utils.AdjustmentTransactionSubtypeRecordingDepletionIn) {
functionCode = string(utils.AdjustmentTransactionSubtypeRecordingDepletionOut)
if 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)
@@ -167,15 +167,6 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e
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 projectFlockKandangID *uint
@@ -221,6 +212,133 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e
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 := s.resolveAyamSourceProductWarehouse(ctx, tx, warehouseID, *projectFlockKandangID)
if err != nil {
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")
}
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")
}
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{
ProductWarehouseId: productWarehouse.Id,
TransactionType: transactionType,
@@ -228,6 +346,12 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e
Price: req.Price,
GrandTotal: grandTotal,
}
switch routeMeta.Lane {
case adjustmentLaneStockable:
adjustmentStock.TotalQty = qty
case adjustmentLaneUsable:
adjustmentStock.UsageQty = qty
}
code, err := adjustmentStockRepoTX.GenerateSequentialNumber(ctx, utils.AdjustmentStockNumberPrefix)
if err != nil {
return err
@@ -240,85 +364,44 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e
var increaseQty float64
var decreaseQty float64
switch routeMeta.Lane {
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:
if routeMeta.Lane != adjustmentLaneStockable && routeMeta.Lane != adjustmentLaneUsable {
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 {
s.Log.Errorf("Failed to get stock logs: %+v", err)
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs")
return fiber.NewError(fiber.StatusInternalServerError, "Failed to refresh adjustment stock")
}
switch routeMeta.Lane {
case adjustmentLaneStockable:
increaseQty = refreshedAdjustment.TotalQty
case adjustmentLaneUsable:
decreaseQty = refreshedAdjustment.UsageQty
}
currentStock := 0.0
if len(stockLogs) > 0 {
currentStock = stockLogs[0].Stock
}
newLog := &entity.StockLog{
LoggableType: string(utils.StockLogTypeAdjustment),
LoggableId: adjustmentStock.Id,
Notes: note,
ProductWarehouseId: productWarehouse.Id,
CreatedBy: actorID,
Increase: increaseQty,
Decrease: decreaseQty,
Stock: currentStock + increaseQty - decreaseQty,
}
if err := stockLogRepoTX.CreateOne(ctx, newLog, nil); err != nil {
if err := s.createAdjustmentStockLog(
ctx,
stockLogRepoTX,
adjustmentStock.Id,
productWarehouse.Id,
note,
actorID,
increaseQty,
decreaseQty,
); err != nil {
return err
}
@@ -449,6 +532,88 @@ func (s *adjustmentService) getActiveProjectFlockKandangID(ctx context.Context,
return uint(projectFlockKandang.Id), nil
}
func (s *adjustmentService) resolveAyamSourceProductWarehouse(
ctx context.Context,
tx *gorm.DB,
warehouseID uint,
projectFlockKandangID uint,
) (*entity.ProductWarehouse, error) {
if tx == nil {
return nil, fmt.Errorf("transaction is required")
}
if projectFlockKandangID == 0 {
return nil, fiber.NewError(fiber.StatusBadRequest, "project_flock_kandang_id tidak valid untuk depletion conversion")
}
var sourcePW entity.ProductWarehouse
err := tx.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, flagGroupAyam).
Order(gorm.Expr("CASE WHEN warehouse_id = ? THEN 0 ELSE 1 END ASC", warehouseID)).
Order("id ASC").
Take(&sourcePW).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusBadRequest, "Produk sumber AYAM pada project flock kandang yang sama tidak ditemukan")
}
return nil, err
}
return &sourcePW, 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) AdjustmentHistory(c *fiber.Ctx, query *validation.Query) ([]*entity.AdjustmentStock, int64, error) {
if err := s.Validate.Struct(query); err != nil {
return nil, 0, err
+2 -36
View File
@@ -24,7 +24,6 @@ import (
rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories"
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/fifo"
)
type TransferModule struct{}
@@ -40,10 +39,10 @@ func (TransferModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate
userRepo := rUser.NewUserRepository(db)
warehouseRepo := rWarehouse.NewWarehouseRepository(db)
projectFlockKandangRepo := rProjectFlockKandang.NewProjectFlockKandangRepository(db)
projectFlockPopulationRepo := rProjectFlockKandang.NewProjectFlockPopulationRepository(db)
kandangRepo := rKandang.NewKandangRepository(db)
nonstockRepo := rNonstock.NewNonstockRepository(db)
documentRepo := commonRepo.NewDocumentRepository(db)
stockAllocRepo := commonRepo.NewStockAllocationRepository(db)
expenseRepository := expenseRepo.NewExpenseRepository(db)
expenseRealizationRepo := expenseRepo.NewExpenseRealizationRepository(db)
@@ -69,7 +68,6 @@ func (TransferModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate
validate,
)
fifoService := commonSvc.NewFifoService(db, stockAllocRepo, productWarehouseRepo, utils.Log)
fifoStockV2Service := commonSvc.NewFifoStockV2Service(db, utils.Log)
expenseBridge := sTransfer.NewTransferExpenseBridge(
db,
@@ -79,39 +77,7 @@ func (TransferModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate
expenseServiceInstance,
)
err = fifoService.RegisterStockable(fifo.StockableConfig{
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)
transferService := sTransfer.NewTransferService(validate, stockTransferRepo, stockTransferDetailRepo, stockTransferDeliveryRepo, StockTransferDeliveryItemRepo, stockLogsRepo, productWarehouseRepo, supplierRepo, warehouseRepo, projectFlockKandangRepo, projectFlockPopulationRepo, documentSvc, fifoStockV2Service, expenseBridge)
userService := sUser.NewUserService(userRepo, validate)
TransferRoutes(router, userService, transferService)
@@ -11,6 +11,7 @@ import (
"github.com/gofiber/fiber/v2"
"github.com/sirupsen/logrus"
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"
m "gitlab.com/mbugroup/lti-api.git/internal/middleware"
rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories"
@@ -44,13 +45,13 @@ type transferService struct {
SupplierRepo rSupplier.SupplierRepository
WarehouseRepo warehouseRepo.WarehouseRepository
ProjectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository
ProjectFlockPopulationRepo projectFlockKandangRepo.ProjectFlockPopulationRepository
DocumentSvc commonSvc.DocumentService
FifoSvc commonSvc.FifoService
FifoStockV2Svc commonSvc.FifoStockV2Service
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 {
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{
Log: utils.Log,
Validate: validate,
@@ -63,8 +64,8 @@ func NewTransferService(validate *validator.Validate, stockTransferRepo rStockTr
SupplierRepo: supplierRepo,
WarehouseRepo: warehouseRepo,
ProjectFlockKandangRepo: projectFlockKandangRepo,
ProjectFlockPopulationRepo: projectFlockPopulationRepo,
DocumentSvc: documentSvc,
FifoSvc: fifoSvc,
FifoStockV2Svc: fifoStockV2Svc,
ExpenseBridge: expenseBridge,
}
@@ -444,83 +445,91 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques
}
}
pakanProducts := map[uint]bool{}
if s.FifoStockV2Svc != nil && len(req.Products) > 0 {
pakanProducts, err = s.resolvePakanProducts(c.Context(), tx, req.Products)
if err != nil {
return err
}
if s.FifoStockV2Svc == nil {
return fiber.NewError(fiber.StatusInternalServerError, "FIFO v2 service is not available")
}
flagGroupByProduct := make(map[uint]string, len(req.Products))
for _, product := range req.Products {
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
outPendingQty := 0.0
useFifoV2 := s.FifoStockV2Svc != nil && pakanProducts[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,
})
flagGroupCode, ok := flagGroupByProduct[uint(product.ProductID)]
if !ok {
flagGroupCode, err = s.resolveTransferFlagGroup(c.Context(), tx, uint(product.ProductID))
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
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
flagGroupByProduct[uint(product.ProductID)] = flagGroupCode
}
if err := tx.Model(&entity.StockTransferDetail{}).
Where("id = ?", detail.Id).
Updates(map[string]interface{}{
"usage_qty": outUsageQty,
"pending_qty": outPendingQty,
"usage_qty": product.ProductQty,
"pending_qty": 0,
"total_qty": product.ProductQty,
}).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")
}
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))
}
if strings.EqualFold(flagGroupCode, "AYAM") && outUsageQty > 0 {
if err := s.allocatePopulationForStockTransferOut(
c.Context(),
tx,
detail,
uint(*detail.SourceProductWarehouseID),
outUsageQty,
); err != nil {
return err
}
}
stockLogDecrease := &entity.StockLog{
ProductWarehouseId: uint(*detail.SourceProductWarehouseID),
CreatedBy: uint(actorID),
Increase: 0,
Decrease: product.ProductQty,
Decrease: outUsageQty,
LoggableType: string(utils.StockLogTypeTransfer),
LoggableId: uint(detail.Id),
Notes: "",
@@ -541,45 +550,12 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques
return fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat log stok keluar")
}
note := fmt.Sprintf("Transfer #%s", entityTransfer.MovementNumber)
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")
}
inAddedQty := outUsageQty
stockLogIncrease := &entity.StockLog{
ProductWarehouseId: uint(*detail.DestProductWarehouseID),
CreatedBy: uint(actorID),
Increase: product.ProductQty,
Increase: inAddedQty,
Decrease: 0,
LoggableType: string(utils.StockLogTypeTransfer),
LoggableId: uint(detail.Id),
@@ -657,51 +633,96 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques
return result, nil
}
func (s *transferService) resolvePakanProducts(
func (s *transferService) allocatePopulationForStockTransferOut(
ctx context.Context,
tx *gorm.DB,
products []validation.TransferProduct,
) (map[uint]bool, error) {
out := make(map[uint]bool, len(products))
if len(products) == 0 {
return out, nil
detail *entity.StockTransferDetail,
sourceProductWarehouseID uint,
consumeQty float64,
) error {
if consumeQty <= 0 {
return nil
}
if tx == nil {
return errors.New("transaction is required")
}
if detail == nil || detail.Id == 0 {
return fiber.NewError(fiber.StatusBadRequest, "Data transfer detail tidak valid")
}
if sourceProductWarehouseID == 0 {
return fiber.NewError(fiber.StatusBadRequest, "Gudang sumber tidak valid")
}
productIDs := make([]uint, 0, len(products))
seen := make(map[uint]struct{}, len(products))
for _, product := range products {
if product.ProductID == 0 {
continue
pw, err := s.ProductWarehouseRepo.WithTx(tx).GetByID(ctx, sourceProductWarehouseID, nil)
if err != nil {
return err
}
if _, ok := seen[product.ProductID]; ok {
continue
if pw.ProjectFlockKandangId == nil || *pw.ProjectFlockKandangId == 0 {
return nil
}
seen[product.ProductID] = struct{}{}
productIDs = append(productIDs, product.ProductID)
populations, err := s.ProjectFlockPopulationRepo.WithTx(tx).GetByProjectFlockKandangIDAndProductWarehouseID(
ctx,
*pw.ProjectFlockKandangId,
sourceProductWarehouseID,
)
if err != nil {
return err
}
if len(productIDs) == 0 {
return out, nil
if len(populations) == 0 {
return fiber.NewError(fiber.StatusBadRequest, "Populasi tidak ditemukan untuk transfer")
}
return fifoV2.AllocatePopulationConsumption(
ctx,
tx,
populations,
sourceProductWarehouseID,
fifo.UsableKeyStockTransferOut.String(),
uint(detail.Id),
consumeQty,
)
}
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 {
ProductID uint `gorm:"column:product_id"`
FlagGroupCode string `gorm:"column:flag_group_code"`
}
var rows []row
var selected row
err := tx.WithContext(ctx).
Table("flags f").
Select("DISTINCT f.flagable_id AS product_id").
Where("f.flagable_type = ?", entity.FlagableTypeProduct).
Where("f.name IN ?", []string{"PAKAN", "PRE-STARTER", "STARTER", "FINISHER"}).
Where("f.flagable_id IN ?", productIDs).
Scan(&rows).Error
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 = ?", "USABLE").
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 {
return nil, err
return "", err
}
for _, row := range rows {
out[row.ProductID] = true
}
return out, nil
return strings.TrimSpace(selected.FlagGroupCode), nil
}
func (s *transferService) notifyExpenseItemsDelivered(c *fiber.Ctx, transferID uint64, payloads []TransferExpenseReceivingPayload) error {
+4 -22
View File
@@ -2,7 +2,6 @@ package marketing
import (
"fmt"
"strings"
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2"
@@ -20,7 +19,6 @@ import (
rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories"
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/fifo"
)
type MarketingModule struct{}
@@ -33,26 +31,10 @@ func (MarketingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate
userRepo := rUser.NewUserRepository(db)
customerRepo := rCustomer.NewCustomerRepository(db)
productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db)
projectFlockPopulationRepo := rProjectFlockKandang.NewProjectFlockPopulationRepository(db)
stockLogRepo := rShared.NewStockLogRepository(db)
stockAllocationRepo := commonRepo.NewStockAllocationRepository(db)
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))
}
}
fifoStockV2Service := commonSvc.NewFifoStockV2Service(db, utils.Log)
approvalRepo := commonRepo.NewApprovalRepository(db)
approvalSvc := commonSvc.NewApprovalService(approvalRepo)
@@ -64,8 +46,8 @@ func (MarketingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate
warehouseRepo := rWarehouse.NewWarehouseRepository(db)
projectFlockKandangRepo := rProjectFlockKandang.NewProjectFlockKandangRepository(db)
salesOrdersService := service.NewSalesOrdersService(marketingRepo, customerRepo, productWarehouseRepo, userRepo, approvalSvc, fifoService, warehouseRepo, projectFlockKandangRepo, validate)
deliveryOrdersService := service.NewDeliveryOrdersService(marketingRepo, marketingProductRepo, marketingDeliveryProductRepo, stockLogRepo, approvalSvc, fifoService, validate)
salesOrdersService := service.NewSalesOrdersService(marketingRepo, customerRepo, productWarehouseRepo, userRepo, approvalSvc, fifoStockV2Service, warehouseRepo, projectFlockKandangRepo, validate)
deliveryOrdersService := service.NewDeliveryOrdersService(marketingRepo, marketingProductRepo, marketingDeliveryProductRepo, stockLogRepo, productWarehouseRepo, projectFlockPopulationRepo, approvalSvc, fifoStockV2Service, validate)
userService := sUser.NewUserService(userRepo, validate)
RegisterRoutes(router, userService, salesOrdersService, deliveryOrdersService)
@@ -4,15 +4,19 @@ import (
"context"
"errors"
"fmt"
"strings"
"time"
commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository"
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"
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"
marketingRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/repositories"
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"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
"gitlab.com/mbugroup/lti-api.git/internal/utils/fifo"
@@ -35,8 +39,10 @@ type deliveryOrdersService struct {
MarketingProductRepo marketingRepo.MarketingProductRepository
MarketingDeliveryProductRepo marketingRepo.MarketingDeliveryProductRepository
StockLogRepo rShared.StockLogRepository
ProductWarehouseRepo rProductWarehouse.ProductWarehouseRepository
ProjectFlockPopulationRepo rProjectFlock.ProjectFlockPopulationRepository
ApprovalSvc commonSvc.ApprovalService
FifoSvc commonSvc.FifoService
FifoStockV2Svc commonSvc.FifoStockV2Service
}
func NewDeliveryOrdersService(
@@ -44,8 +50,10 @@ func NewDeliveryOrdersService(
marketingProductRepo marketingRepo.MarketingProductRepository,
marketingDeliveryProductRepo marketingRepo.MarketingDeliveryProductRepository,
stockLogRepo rShared.StockLogRepository,
productWarehouseRepo rProductWarehouse.ProductWarehouseRepository,
projectFlockPopulationRepo rProjectFlock.ProjectFlockPopulationRepository,
approvalSvc commonSvc.ApprovalService,
fifoSvc commonSvc.FifoService,
fifoStockV2Svc commonSvc.FifoStockV2Service,
validate *validator.Validate,
) DeliveryOrdersService {
return &deliveryOrdersService{
@@ -54,8 +62,10 @@ func NewDeliveryOrdersService(
MarketingProductRepo: marketingProductRepo,
MarketingDeliveryProductRepo: marketingDeliveryProductRepo,
StockLogRepo: stockLogRepo,
ProductWarehouseRepo: productWarehouseRepo,
ProjectFlockPopulationRepo: projectFlockPopulationRepo,
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")
}
result, err := s.FifoSvc.Consume(ctx, commonSvc.StockConsumeRequest{
UsableKey: fifo.UsableKeyMarketingDelivery,
UsableID: deliveryProduct.Id,
ProductWarehouseID: marketingProduct.ProductWarehouseId,
Quantity: requestedQty,
AllowPending: false,
Tx: tx,
})
deliveryProductRepo := marketingRepo.NewMarketingDeliveryProductRepository(tx)
previousUsage := deliveryProduct.UsageQty
deliveryProduct.UsageQty = requestedQty
deliveryProduct.PendingQty = 0
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))
}
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 {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to update delivery product")
if err := s.allocatePopulationForMarketingDelivery(ctx, tx, deliveryProduct, marketingProduct.ProductWarehouseId); err != nil {
return err
}
if actorID > 0 && result.UsageQuantity > 0 {
allocatedDelta := deliveryProduct.UsageQty - previousUsage
if actorID > 0 && allocatedDelta > 0 {
decreaseLog := &entity.StockLog{
Decrease: result.UsageQuantity,
Decrease: allocatedDelta,
LoggableType: string(utils.StockLogTypeMarketing),
LoggableId: deliveryProduct.Id,
ProductWarehouseId: marketingProduct.ProductWarehouseId,
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)
@@ -604,35 +626,49 @@ func (s deliveryOrdersService) releaseDeliveryStock(ctx context.Context, tx *gor
}
deliveryProductRepo := marketingRepo.NewMarketingDeliveryProductRepository(tx)
currentUsage, err := deliveryProductRepo.GetUsageQty(ctx, deliveryProduct.Id)
if err != nil {
currentUsage = 0
}
if currentUsage == 0 {
currentUsage := deliveryProduct.UsageQty
currentPending := deliveryProduct.PendingQty
if currentUsage <= 0 && currentPending <= 0 {
return nil
}
if err := s.FifoSvc.ReleaseUsage(ctx, commonSvc.StockReleaseRequest{
UsableKey: fifo.UsableKeyMarketingDelivery,
UsableID: deliveryProduct.Id,
Tx: tx,
}); err != nil {
deliveryProduct.UsageQty = 0
deliveryProduct.PendingQty = 0
if err := deliveryProductRepo.UpdateOne(ctx, deliveryProduct.Id, deliveryProduct, nil); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to reset 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("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
}
if err := deliveryProductRepo.ResetFifoFields(ctx, deliveryProduct.Id); err != nil {
return err
}
if actorID > 0 && currentUsage > 0 {
releasedUsage := currentUsage - deliveryProduct.UsageQty
if actorID > 0 && releasedUsage > 0 {
increaseLog := &entity.StockLog{
Increase: currentUsage,
Increase: releasedUsage,
LoggableType: string(utils.StockLogTypeMarketing),
LoggableId: deliveryProduct.Id,
ProductWarehouseId: marketingProduct.ProductWarehouseId,
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)
if err != nil {
@@ -650,3 +686,57 @@ func (s deliveryOrdersService) releaseDeliveryStock(ctx context.Context, tx *gor
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"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
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/gofiber/fiber/v2"
@@ -43,12 +42,12 @@ type salesOrdersService struct {
ProductWarehouseRepo productWarehouseRepo.ProductWarehouseRepository
UserRepo userRepo.UserRepository
ApprovalSvc commonSvc.ApprovalService
FifoSvc commonSvc.FifoService
FifoStockV2Svc commonSvc.FifoStockV2Service
WarehouseRepo warehouseRepo.WarehouseRepository
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 {
return &salesOrdersService{
Log: utils.Log,
@@ -58,7 +57,7 @@ func NewSalesOrdersService(marketingRepo repository.MarketingRepository, custome
ProductWarehouseRepo: productWarehouseRepo,
UserRepo: userRepo,
ApprovalSvc: approvalSvc,
FifoSvc: fifoSvc,
FifoStockV2Svc: fifoStockV2Svc,
WarehouseRepo: warehouseRepo,
ProjectFlockKandangRepo: projectFlockKandangRepo,
}
@@ -376,15 +375,18 @@ func (s salesOrdersService) UpdateOne(c *fiber.Ctx, req *validation.Update, id u
if qtyDiff < 0 {
return fiber.NewError(fiber.StatusBadRequest, "Cannot decrease quantity after stock has been allocated. Please delete and create new product.")
} else if qtyDiff > 0 {
_, err := s.FifoSvc.Consume(c.Context(), commonSvc.StockConsumeRequest{
UsableKey: fifo.UsableKeyMarketingDelivery,
UsableID: deliveryProduct.Id,
ProductWarehouseID: rp.ProductWarehouseId,
Quantity: qtyDiff,
Tx: dbTransaction,
})
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Insufficient stock for additional quantity: %v", err))
nextRequestedQty := deliveryProduct.UsageQty + deliveryProduct.PendingQty + qtyDiff
if err := invDeliveryRepoTx.UpdateFifoFields(c.Context(), deliveryProduct.Id, nextRequestedQty, 0); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to update marketing delivery fifo fields")
}
if err := reflowMarketingScope(
c.Context(),
s.FifoStockV2Svc,
dbTransaction,
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))
}
if err := s.FifoSvc.ReleaseUsage(c.Context(), commonSvc.StockReleaseRequest{
UsableKey: fifo.UsableKeyMarketingDelivery,
UsableID: deliveryProduct.Id,
Tx: dbTransaction,
}); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to release stock: %v", err))
if err := invDeliveryRepoTx.UpdateFifoFields(c.Context(), deliveryProduct.Id, 0, 0); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to reset marketing delivery fifo fields")
}
if err := reflowMarketingScope(
c.Context(),
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 {
@@ -523,12 +530,17 @@ func (s salesOrdersService) DeleteOne(c *fiber.Ctx, id uint) error {
deliveryProducts, err := marketingDeliveryProductRepoTx.GetByMarketingId(c.Context(), marketing.Id)
if err == nil && len(deliveryProducts) > 0 {
for _, dp := range deliveryProducts {
if err := s.FifoSvc.ReleaseUsage(c.Context(), commonSvc.StockReleaseRequest{
UsableKey: fifo.UsableKeyMarketingDelivery,
UsableID: dp.Id,
Tx: dbTransaction,
}); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to release stock for delivery product %d: %v", dp.Id, err))
if err := marketingDeliveryProductRepoTx.UpdateFifoFields(c.Context(), dp.Id, 0, 0); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to reset fifo fields for delivery product %d", dp.Id))
}
if err := reflowMarketingScope(
c.Context(),
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))
}
}
}
+5 -40
View File
@@ -2,7 +2,6 @@ package chickins
import (
"fmt"
"strings"
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2"
@@ -10,7 +9,6 @@ import (
commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository"
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"
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"
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"
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)
projectflockpopulationrepo := rProjectFlock.NewProjectFlockPopulationRepository(db)
projectFlockRepo := rProjectFlock.NewProjectflockRepository(db)
transferLayingRepo := rTransferLaying.NewTransferLayingRepository(db)
productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db)
productRepo := rProduct.NewProductRepository(db)
stockAllocationRepo := commonRepo.NewStockAllocationRepository(db)
fifoService := commonSvc.NewFifoService(db, stockAllocationRepo, productWarehouseRepo, utils.Log)
fifoStockV2Service := commonSvc.NewFifoStockV2Service(db, utils.Log)
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)
approvalService := commonSvc.NewApprovalService(approvalRepo)
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,
projectflockpopulationrepo,
chickinDetailRepo,
transferLayingRepo,
validate,
fifoService)
fifoStockV2Service)
userService := sUser.NewUserService(userRepo, validate)
ChickinRoutes(router, userService, chickinService)
@@ -4,7 +4,9 @@ import (
"context"
"errors"
"fmt"
"math"
"strings"
"time"
commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository"
commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service"
@@ -17,6 +19,7 @@ import (
repository "gitlab.com/mbugroup/lti-api.git/internal/modules/production/chickins/repositories"
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/chickins/validations"
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"
rStockLogs "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/repositories"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
"gitlab.com/mbugroup/lti-api.git/internal/utils/fifo"
@@ -25,10 +28,9 @@ import (
"github.com/gofiber/fiber/v2"
"github.com/sirupsen/logrus"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
var chickinUsableKey = fifo.UsableKeyProjectChickin
type ChickinService interface {
GetAll(ctx *fiber.Ctx, params *validation.Query) ([]entity.ProjectChickin, int64, error)
GetOne(ctx *fiber.Ctx, id uint) (*entity.ProjectChickin, error)
@@ -51,11 +53,12 @@ type chickinService struct {
ProjectflockKandangRepo rProjectFlock.ProjectFlockKandangRepository
ProjectflockPopulationRepo rProjectFlock.ProjectFlockPopulationRepository
ProjectChickinDetailRepo repository.ProjectChickinDetailRepository
FifoSvc commonSvc.FifoService
TransferLayingRepo rTransferLaying.TransferLayingRepository
FifoStockV2Svc commonSvc.FifoStockV2Service
StockLogRepo rStockLogs.StockLogRepository
}
func NewChickinService(repo repository.ProjectChickinRepository, kandangRepo KandangRepo.KandangRepository, warehouseRepo rWarehouse.WarehouseRepository, productWarehouseRepo rProductWarehouse.ProductWarehouseRepository, productRepo rProduct.ProductRepository, projectFlockRepo rProjectFlock.ProjectflockRepository, projectflockkandangRepo rProjectFlock.ProjectFlockKandangRepository, projectflockpopulationRepo rProjectFlock.ProjectFlockPopulationRepository, projectChickinDetailRepo repository.ProjectChickinDetailRepository, validate *validator.Validate, fifoSvc commonSvc.FifoService) ChickinService {
func NewChickinService(repo repository.ProjectChickinRepository, kandangRepo KandangRepo.KandangRepository, warehouseRepo rWarehouse.WarehouseRepository, productWarehouseRepo rProductWarehouse.ProductWarehouseRepository, productRepo rProduct.ProductRepository, projectFlockRepo rProjectFlock.ProjectflockRepository, projectflockkandangRepo rProjectFlock.ProjectFlockKandangRepository, projectflockpopulationRepo rProjectFlock.ProjectFlockPopulationRepository, projectChickinDetailRepo repository.ProjectChickinDetailRepository, transferLayingRepo rTransferLaying.TransferLayingRepository, validate *validator.Validate, fifoStockV2Svc commonSvc.FifoStockV2Service) ChickinService {
return &chickinService{
Log: utils.Log,
Validate: validate,
@@ -68,7 +71,8 @@ func NewChickinService(repo repository.ProjectChickinRepository, kandangRepo Kan
ProjectflockKandangRepo: projectflockkandangRepo,
ProjectflockPopulationRepo: projectflockpopulationRepo,
ProjectChickinDetailRepo: projectChickinDetailRepo,
FifoSvc: fifoSvc,
TransferLayingRepo: transferLayingRepo,
FifoStockV2Svc: fifoStockV2Svc,
StockLogRepo: rStockLogs.NewStockLogRepository(repo.DB()),
}
}
@@ -120,11 +124,36 @@ func (s chickinService) GetOne(c *fiber.Ctx, id uint) (*entity.ProjectChickin, e
return chickin, nil
}
func (s chickinService) ensureNotTransferred(ctx context.Context, projectFlockKandangID uint) error {
if projectFlockKandangID == 0 || s.TransferLayingRepo == nil {
return nil
}
transfer, err := s.TransferLayingRepo.GetLatestApprovedBySourceKandang(ctx, projectFlockKandangID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil
}
s.Log.Errorf("Failed to resolve transfer laying by source kandang %d: %+v", projectFlockKandangID, err)
return fiber.NewError(fiber.StatusInternalServerError, "Gagal memvalidasi transfer laying")
}
if transfer != nil && transfer.ExecutedAt != nil && !transfer.ExecutedAt.IsZero() {
return fiber.NewError(fiber.StatusBadRequest, "Project flock kandang sudah dipindahkan ke laying")
}
return nil
}
func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) ([]entity.ProjectChickin, error) {
if err := s.Validate.Struct(req); err != nil {
return nil, err
}
if err := s.ensureNotTransferred(c.Context(), req.ProjectFlockKandangId); err != nil {
return nil, err
}
projectFlockKandang, err := s.ProjectflockKandangRepo.GetByID(c.Context(), req.ProjectFlockKandangId)
if err != nil {
return nil, fiber.NewError(fiber.StatusNotFound, "Project Flock Kandang not found")
@@ -260,7 +289,7 @@ func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) ([]enti
for idx, chickin := range newChikins {
desiredQty := chickinQtyMap[uint(idx)]
if err := s.ConsumeChickinStocks(c.Context(), dbTransaction, chickin, desiredQty, actorID); err != nil {
if err := s.StageChickinStocks(c.Context(), dbTransaction, chickin, desiredQty, actorID); err != nil {
return err
}
}
@@ -334,6 +363,17 @@ func (s chickinService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint)
return nil, err
}
chickin, err := s.Repository.GetByID(c.Context(), id, nil)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusNotFound, "Chickin not found")
}
return nil, err
}
if err := s.ensureNotTransferred(c.Context(), chickin.ProjectFlockKandangId); err != nil {
return nil, err
}
updateBody := make(map[string]any)
if req.ChickInDate != "" {
@@ -353,7 +393,18 @@ func (s chickinService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint)
return nil, err
}
return s.GetOne(c, id)
updated, err := s.GetOne(c, id)
if err != nil {
return nil, err
}
if updated.UsageQty > 0 {
if err := s.syncChickinTraceForProductWarehouse(c.Context(), nil, updated.ProductWarehouseId); err != nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to sync chickin stock trace")
}
}
return updated, nil
}
func (s chickinService) DeleteOne(c *fiber.Ctx, id uint) error {
@@ -366,33 +417,44 @@ func (s chickinService) DeleteOne(c *fiber.Ctx, id uint) error {
return err
}
if err := s.ensureNotTransferred(c.Context(), chickin.ProjectFlockKandangId); err != nil {
return err
}
actorID, err := m.ActorIDFromContext(c)
if err != nil {
return err
}
if chickin.UsageQty > 0 {
err = s.Repository.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error {
chickinRepoTx := repository.NewChickinRepository(tx)
currentUsageQty := chickin.UsageQty
if err := s.ReleaseChickinStocks(c.Context(), s.Repository.DB(), chickin, actorID); err != nil {
return err
}
warehouseDeltas := make(map[uint]float64)
warehouseDeltas[chickin.ProductWarehouseId] += currentUsageQty
if err := s.adjustProductWarehouseQuantities(c.Context(), s.Repository.DB(), warehouseDeltas); err != nil {
if chickin.UsageQty > 0 || chickin.PendingUsageQty > 0 {
if err := s.ReleaseChickinStocks(c.Context(), tx, chickin, actorID); err != nil {
return err
}
}
if err := s.Repository.DeleteOne(c.Context(), id); err != nil {
if err := chickinRepoTx.DeleteOne(c.Context(), id); err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return fiber.NewError(fiber.StatusNotFound, "Chickin not found")
}
return err
}
if err := s.syncChickinTraceForProductWarehouse(c.Context(), tx, chickin.ProductWarehouseId); err != nil {
return err
}
return nil
})
if err != nil {
if fiberErr, ok := err.(*fiber.Error); ok {
return fiberErr
}
return err
}
return nil
}
@@ -428,6 +490,9 @@ func (s chickinService) Approval(c *fiber.Ctx, req *validation.Approve) ([]entit
if err := commonSvc.EnsureRelations(c.Context(), commonSvc.RelationCheck{Name: "ProjectFlockKandang", ID: &id, Exists: s.ProjectflockKandangRepo.IdExists}); err != nil {
return nil, err
}
if err := s.ensureNotTransferred(c.Context(), id); err != nil {
return nil, err
}
latestApproval, err := approvalSvc.LatestByTarget(c.Context(), utils.ApprovalWorkflowChickin, id, nil)
if err != nil {
@@ -451,8 +516,24 @@ func (s chickinService) Approval(c *fiber.Ctx, req *validation.Approve) ([]entit
approvalSvc := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(dbTransaction))
chickinRepoTx := repository.NewChickinRepository(dbTransaction)
ProjectFlockPopulationRepotx := s.ProjectflockPopulationRepo.WithTx(dbTransaction)
touchedProductWarehouseIDs := make(map[uint]struct{})
for _, approvableID := range approvableIDs {
// Re-check latest approval inside transaction to prevent double-approve races.
var latest entity.Approval
if err := dbTransaction.WithContext(c.Context()).
Table("approvals").
Where("approvable_type = ? AND approvable_id = ?", utils.ApprovalWorkflowChickin.String(), approvableID).
Order("id DESC").
Limit(1).
Clauses(clause.Locking{Strength: "UPDATE"}).
Take(&latest).Error; err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to recheck approval status")
}
if latest.Id != 0 && latest.StepNumber != uint16(utils.ChickinStepPengajuan) {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("ProjectFlockKandang %d sudah tidak berada di tahap PENGAJUAN", approvableID))
}
if _, err := approvalSvc.CreateApproval(
c.Context(),
utils.ApprovalWorkflowChickin,
@@ -491,6 +572,21 @@ func (s chickinService) Approval(c *fiber.Ctx, req *validation.Approve) ([]entit
}
for _, chickin := range chickins {
approvedQty := chickin.UsageQty
if approvedQty <= 0 {
approvedQty = chickin.PendingUsageQty
}
if approvedQty < 0 {
approvedQty = 0
}
if err := s.ConsumeChickinStocks(c.Context(), dbTransaction, &chickin, approvedQty, actorID); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to finalize usage qty for chickin %d", chickin.Id))
}
chickin.UsageQty = approvedQty
chickin.PendingUsageQty = 0
touchedProductWarehouseIDs[chickin.ProductWarehouseId] = struct{}{}
populationExists, err := ProjectFlockPopulationRepotx.ExistsByProjectChickinID(c.Context(), chickin.Id)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to check population for chickin %d", chickin.Id))
@@ -522,19 +618,13 @@ func (s chickinService) Approval(c *fiber.Ctx, req *validation.Approve) ([]entit
return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to create population for chickin %d", chickin.Id))
}
if err := chickinRepoTx.PatchOne(c.Context(), chickin.Id, map[string]any{
"pending_usage_qty": 0,
}, nil); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to reset pending usage qty for chickin %d", chickin.Id))
}
if err := s.ReplenishChickinStocks(c.Context(), dbTransaction, &chickin, sourcePW, population, actorID); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to replenish stock for chickin %d", chickin.Id))
}
}
}
if action == entity.ApprovalActionRejected {
chickins, err := chickinRepoTx.GetPendingByProjectFlockKandangID(c.Context(), approvableID)
chickins, err := chickinRepoTx.GetByProjectFlockKandangID(c.Context(), approvableID)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to get pending chickins for rejection %d", approvableID))
}
@@ -544,16 +634,22 @@ func (s chickinService) Approval(c *fiber.Ctx, req *validation.Approve) ([]entit
}
for _, chickin := range chickins {
populationExists, err := ProjectFlockPopulationRepotx.ExistsByProjectChickinID(c.Context(), chickin.Id)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to check population for chickin %d", chickin.Id))
}
if populationExists {
continue
}
if chickin.UsageQty <= 0 && chickin.PendingUsageQty <= 0 {
continue
}
if err := s.ReleaseChickinStocks(c.Context(), dbTransaction, &chickin, actorID); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to release stock for rejected chickin %d: %v", chickin.Id, err))
}
warehouseDeltas := make(map[uint]float64)
warehouseDeltas[chickin.ProductWarehouseId] += chickin.UsageQty
if err := s.adjustProductWarehouseQuantities(c.Context(), dbTransaction, warehouseDeltas); err != nil {
return err
}
touchedProductWarehouseIDs[chickin.ProductWarehouseId] = struct{}{}
if err := chickinRepoTx.DeleteOne(c.Context(), chickin.Id); err != nil {
if !errors.Is(err, gorm.ErrRecordNotFound) {
@@ -563,6 +659,13 @@ func (s chickinService) Approval(c *fiber.Ctx, req *validation.Approve) ([]entit
}
}
}
for productWarehouseID := range touchedProductWarehouseIDs {
if err := s.syncChickinTraceForProductWarehouse(c.Context(), dbTransaction, productWarehouseID); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to sync chickin trace for product warehouse %d", productWarehouseID))
}
}
return nil
})
@@ -617,121 +720,245 @@ func (s *chickinService) autoAddFlagToProduct(ctx context.Context, tx *gorm.DB,
}
func (s *chickinService) ConsumeChickinStocks(ctx context.Context, tx *gorm.DB, chickin *entity.ProjectChickin, desiredQty float64, actorID uint) error {
if chickin == nil || s.FifoSvc == nil {
if chickin == nil {
return nil
}
result, err := s.FifoSvc.Consume(ctx, commonSvc.StockConsumeRequest{
UsableKey: chickinUsableKey,
UsableID: chickin.Id,
ProductWarehouseID: chickin.ProductWarehouseId,
Quantity: desiredQty,
AllowPending: true,
Tx: tx,
})
if err != nil {
return err
if tx == nil {
return errors.New("transaction is required")
}
if desiredQty < 0 {
return errors.New("desired quantity must be zero or greater")
}
if err := s.Repository.UpdateUsageFields(ctx, tx, chickin.Id, result.UsageQuantity, result.PendingQuantity); err != nil {
return err
}
if result.UsageQuantity > 0 {
decreaseLog := &entity.StockLog{
Decrease: result.UsageQuantity,
LoggableType: string(utils.StockLogTypeChikin),
LoggableId: chickin.Id,
ProductWarehouseId: chickin.ProductWarehouseId,
CreatedBy: actorID,
Notes: fmt.Sprintf("Chickin #%d", chickin.Id),
}
stockLogs, err := s.StockLogRepo.GetByProductWarehouse(ctx, chickin.ProductWarehouseId, 1)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs")
}
if len(stockLogs) > 0 {
latestStockLog := stockLogs[0]
decreaseLog.Stock = latestStockLog.Stock
decreaseLog.Stock -= decreaseLog.Decrease
} else {
decreaseLog.Stock -= decreaseLog.Decrease
}
s.StockLogRepo.CreateOne(ctx, decreaseLog, nil)
}
return s.Repository.UpdateUsageFields(ctx, tx, chickin.Id, desiredQty, 0)
}
func (s *chickinService) StageChickinStocks(ctx context.Context, tx *gorm.DB, chickin *entity.ProjectChickin, desiredQty float64, actorID uint) error {
if chickin == nil {
return nil
}
if tx == nil {
return errors.New("transaction is required")
}
if desiredQty < 0 {
return errors.New("desired quantity must be zero or greater")
}
return s.Repository.UpdateUsageFields(ctx, tx, chickin.Id, 0, desiredQty)
}
func (s *chickinService) ReplenishChickinStocks(ctx context.Context, tx *gorm.DB, chickin *entity.ProjectChickin, targetPW *entity.ProductWarehouse, population *entity.ProjectFlockPopulation, actorID uint) error {
if chickin == nil || targetPW == nil || population == nil || s.FifoSvc == nil {
if chickin == nil || targetPW == nil || population == nil {
return nil
}
if tx == nil {
return errors.New("transaction is required")
}
if s.FifoStockV2Svc == nil {
return errors.New("fifo v2 service is not available")
}
_, err := s.FifoSvc.Replenish(ctx, commonSvc.StockReplenishRequest{
StockableKey: fifo.StockableKeyProjectFlockPopulation,
StockableID: population.Id,
ProductWarehouseID: targetPW.Id,
Quantity: chickin.UsageQty,
Tx: tx,
})
if err != nil {
if err := tx.WithContext(ctx).
Model(&entity.ProjectFlockPopulation{}).
Where("id = ?", population.Id).
Update("total_qty", chickin.UsageQty).Error; err != nil {
return err
}
return nil
asOf := chickin.ChickInDate
if asOf.IsZero() {
asOf = chickin.CreatedAt
}
return reflowChickinScope(ctx, s.FifoStockV2Svc, tx, targetPW.Id, &asOf)
}
func (s *chickinService) ReleaseChickinStocks(ctx context.Context, tx *gorm.DB, chickin *entity.ProjectChickin, actorID uint) error {
if chickin == nil || s.FifoSvc == nil {
if chickin == nil {
return nil
}
var currentUsage float64
if err := tx.Model(&entity.ProjectChickin{}).Where("id = ?", chickin.Id).Select("usage_qty").Scan(&currentUsage).Error; err != nil {
}
if err := s.FifoSvc.ReleaseUsage(ctx, commonSvc.StockReleaseRequest{
UsableKey: chickinUsableKey,
UsableID: chickin.Id,
Tx: tx,
}); err != nil {
return err
if tx == nil {
return errors.New("transaction is required")
}
if err := s.Repository.UpdateUsageFields(ctx, tx, chickin.Id, 0, 0); err != nil {
return err
}
if currentUsage > 0 {
increaseLog := &entity.StockLog{
Increase: currentUsage,
LoggableType: string(utils.StockLogTypeChikin),
LoggableId: chickin.Id,
ProductWarehouseId: chickin.ProductWarehouseId,
CreatedBy: actorID,
Notes: fmt.Sprintf("Chickin #%d - Stock released", chickin.Id),
return nil
}
func (s *chickinService) syncChickinTraceForProductWarehouse(ctx context.Context, tx *gorm.DB, productWarehouseID uint) error {
if productWarehouseID == 0 {
return nil
}
stockLogs, err := s.StockLogRepo.GetByProductWarehouse(ctx, chickin.ProductWarehouseId, 1)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs")
}
if len(stockLogs) > 0 {
latestStockLog := stockLogs[0]
increaseLog.Stock = latestStockLog.Stock
increaseLog.Stock += increaseLog.Increase
} else {
increaseLog.Stock += increaseLog.Increase
if s.FifoStockV2Svc == nil {
return nil
}
s.StockLogRepo.CreateOne(ctx, increaseLog, nil)
if tx == nil {
return s.Repository.DB().WithContext(ctx).Transaction(func(innerTx *gorm.DB) error {
return s.syncChickinTraceForProductWarehouse(ctx, innerTx, productWarehouseID)
})
}
flagGroupCode, err := s.resolveFlagGroupByProductWarehouse(ctx, tx, productWarehouseID)
if err != nil {
return err
}
if strings.TrimSpace(flagGroupCode) == "" {
return nil
}
now := time.Now()
if err := tx.WithContext(ctx).
Table("stock_allocations").
Where("product_warehouse_id = ?", productWarehouseID).
Where("usable_type = ?", fifo.UsableKeyProjectChickin.String()).
Where("allocation_purpose = ?", entity.StockAllocationPurposeTraceChickin).
Where("status = ?", entity.StockAllocationStatusActive).
Updates(map[string]any{
"status": entity.StockAllocationStatusReleased,
"released_at": now,
"updated_at": now,
"note": "chickin_trace_reflow_reset",
}).Error; err != nil {
return err
}
type chickinTraceRow struct {
ID uint `gorm:"column:id"`
UsageQty float64 `gorm:"column:usage_qty"`
ChickIn time.Time `gorm:"column:chick_in_date"`
}
chickins := make([]chickinTraceRow, 0)
if err := tx.WithContext(ctx).
Table("project_chickins").
Select("id, usage_qty, chick_in_date").
Where("product_warehouse_id = ?", productWarehouseID).
Where("deleted_at IS NULL").
Where("usage_qty > 0").
Order("chick_in_date ASC, id ASC").
Scan(&chickins).Error; err != nil {
return err
}
if len(chickins) == 0 {
return nil
}
gatherRows, err := s.FifoStockV2Svc.Gather(ctx, commonSvc.FifoStockV2GatherRequest{
FlagGroupCode: flagGroupCode,
Lane: "STOCKABLE",
ProductWarehouseID: productWarehouseID,
Limit: 50000,
Tx: tx,
})
if err != nil {
return err
}
if len(gatherRows) == 0 {
return nil
}
type lotKey struct {
StockableType string
StockableID uint
}
remainingByLot := make(map[lotKey]float64, len(gatherRows))
for _, row := range gatherRows {
key := lotKey{StockableType: row.Ref.LegacyTypeKey, StockableID: row.Ref.ID}
remainingByLot[key] = row.AvailableQuantity
}
lotIndex := 0
traceNow := time.Now()
for _, chickin := range chickins {
remaining := chickin.UsageQty
for remaining > 1e-6 && lotIndex < len(gatherRows) {
lot := gatherRows[lotIndex]
key := lotKey{StockableType: lot.Ref.LegacyTypeKey, StockableID: lot.Ref.ID}
available := remainingByLot[key]
if available <= 1e-6 {
lotIndex++
continue
}
portion := math.Min(remaining, available)
if portion <= 1e-6 {
lotIndex++
continue
}
insert := map[string]any{
"product_warehouse_id": productWarehouseID,
"stockable_type": lot.Ref.LegacyTypeKey,
"stockable_id": lot.Ref.ID,
"usable_type": fifo.UsableKeyProjectChickin.String(),
"usable_id": chickin.ID,
"qty": portion,
"status": entity.StockAllocationStatusActive,
"allocation_purpose": entity.StockAllocationPurposeTraceChickin,
"engine_version": "v2",
"flag_group_code": flagGroupCode,
"function_code": "CHICKIN_TRACE",
"created_at": traceNow,
"updated_at": traceNow,
}
if err := tx.WithContext(ctx).Table("stock_allocations").Create(insert).Error; err != nil {
return err
}
remaining -= portion
remainingByLot[key] = available - portion
}
if remaining > 1e-6 {
s.Log.Warnf(
"chickin trace partial allocation for product_warehouse_id=%d chickin_id=%d: remaining=%.3f",
productWarehouseID,
chickin.ID,
remaining,
)
}
}
return nil
}
func (s *chickinService) resolveFlagGroupByProductWarehouse(ctx context.Context, tx *gorm.DB, productWarehouseID uint) (string, error) {
type row struct {
FlagGroupCode string `gorm:"column:flag_group_code"`
}
selected := row{}
err := tx.WithContext(ctx).
Table("fifo_stock_v2_route_rules rr").
Select("rr.flag_group_code").
Joins("JOIN fifo_stock_v2_flag_groups fg ON fg.code = rr.flag_group_code AND fg.is_active = TRUE").
Where("rr.is_active = TRUE").
Where("rr.lane = 'STOCKABLE'").
Where(`
EXISTS (
SELECT 1
FROM product_warehouses pw
JOIN flags f ON f.flagable_id = pw.product_id
JOIN fifo_stock_v2_flag_members fm ON fm.flag_name = f.name AND fm.is_active = TRUE
WHERE pw.id = ?
AND f.flagable_type = ?
AND fm.flag_group_code = rr.flag_group_code
)
`, productWarehouseID, entity.FlagableTypeProduct).
Order("fg.priority ASC, rr.id ASC").
Limit(1).
Take(&selected).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return "", nil
}
return "", err
}
return selected.FlagGroupCode, nil
}
func (s chickinService) EnsureChickInExists(ctx context.Context, projectFlockKandangID uint) error {
if projectFlockKandangID == 0 {
return fiber.NewError(fiber.StatusBadRequest, "Project flock kandang tidak valid")
@@ -755,10 +982,3 @@ func (s chickinService) EnsureChickInExists(ctx context.Context, projectFlockKan
return fiber.NewError(fiber.StatusBadRequest, "Chick in project flock belum disetujui sehingga belum dapat membuat recording")
}
func (s *chickinService) adjustProductWarehouseQuantities(ctx context.Context, tx *gorm.DB, deltas map[uint]float64) error {
if len(deltas) == 0 {
return nil
}
return s.ProductWarehouseRepo.AdjustQuantities(ctx, deltas, func(*gorm.DB) *gorm.DB { return tx })
}
@@ -0,0 +1,87 @@
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 (
chickinOutFunctionCode = "CHICKIN_OUT"
chickinUsableLane = "USABLE"
chickinSourceTable = "project_chickins"
)
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("rr.lane = ?", chickinUsableLane).
Where("rr.function_code = ?", chickinOutFunctionCode).
Where("rr.source_table = ?", chickinSourceTable).
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
}
@@ -2,7 +2,6 @@ package recordings
import (
"fmt"
"strings"
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2"
@@ -24,9 +23,9 @@ import (
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"
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"
rStockLogs "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/repositories"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
"gitlab.com/mbugroup/lti-api.git/internal/utils/fifo"
rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories"
sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services"
@@ -48,7 +47,7 @@ func (RecordingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate
productRepo := rProduct.NewProductRepository(db)
chickinRepo := rChickin.NewChickinRepository(db)
chickinDetailRepo := rChickin.NewChickinDetailRepository(db)
stockAllocationRepo := commonRepo.NewStockAllocationRepository(db)
transferLayingRepo := rTransferLaying.NewTransferLayingRepository(db)
stockLogRepo := rStockLogs.NewStockLogRepository(db)
productionStandardRepo := rProductionStandard.NewProductionStandardRepository(db)
productionStandardDetailRepo := rProductionStandard.NewProductionStandardDetailRepository(db)
@@ -61,76 +60,7 @@ func (RecordingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate
validate,
)
fifoService := commonSvc.NewFifoService(db, stockAllocationRepo, productWarehouseRepo, utils.Log)
if err := fifoService.RegisterStockable(fifo.StockableConfig{
Key: fifo.StockableKeyRecordingEgg,
Table: "recording_eggs",
Columns: fifo.StockableColumns{
ID: "id",
ProductWarehouseID: "product_warehouse_id",
TotalQuantity: "total_qty",
TotalUsedQuantity: "total_used",
CreatedAt: "(SELECT r.record_datetime FROM recordings r WHERE r.id = recording_eggs.recording_id)",
},
OrderBy: []string{"(SELECT r.record_datetime FROM recordings r WHERE r.id = recording_eggs.recording_id) ASC", "id ASC"},
}); err != nil {
if !strings.Contains(strings.ToLower(err.Error()), "already registered") {
panic(fmt.Sprintf("failed to register recording egg 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{
Key: fifo.UsableKeyRecordingStock,
Table: "recording_stocks",
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{
ID: "id",
ProductWarehouseID: "source_product_warehouse_id",
UsageQuantity: "usage_qty",
PendingQuantity: "pending_qty",
CreatedAt: "(SELECT r.record_datetime FROM recordings r WHERE r.id = recording_depletions.recording_id)",
},
ExcludedStockables: []fifo.StockableKey{
fifo.StockableKeyTransferToLayingIn,
fifo.StockableKeyStockTransferIn,
fifo.StockableKeyAdjustmentIn,
fifo.StockableKeyPurchaseItems,
fifo.StockableKeyRecordingEgg,
},
}); err != nil {
if !strings.Contains(strings.ToLower(err.Error()), "already registered") {
panic(fmt.Sprintf("failed to register recording depletion usable workflow: %v", err))
}
}
fifoStockV2Service := commonSvc.NewFifoStockV2Service(db, utils.Log)
approvalRepo := commonRepo.NewApprovalRepository(db)
approvalService := commonSvc.NewApprovalService(approvalRepo)
@@ -168,8 +98,9 @@ func (RecordingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate
projectFlockKandangRepo,
projectFlockPopulationRepo,
chickinDetailRepo,
transferLayingRepo,
validate,
fifoService,
fifoStockV2Service,
)
recordingService := sRecording.NewRecordingService(
@@ -179,11 +110,12 @@ func (RecordingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate
projectFlockPopulationRepo,
approvalRepo,
approvalService,
fifoService,
fifoStockV2Service,
stockLogRepo,
productionStandardService,
projectFlockService,
chickinService,
transferLayingRepo,
validate,
)
userService := sUser.NewUserService(userRepo, validate)
@@ -25,20 +25,27 @@ type RecordingRepository interface {
GetLatestByProjectFlockKandangID(ctx context.Context, projectFlockKandangId uint) (*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)
CreateRecording(tx *gorm.DB, recording *entity.Recording) error
CreateStocks(tx *gorm.DB, stocks []entity.RecordingStock) error
CreateStock(tx *gorm.DB, stock *entity.RecordingStock) error
DeleteStocks(tx *gorm.DB, recordingID uint) error
DeleteStocksByIDs(tx *gorm.DB, ids []uint) 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
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
DeleteDepletions(tx *gorm.DB, recordingID uint) 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
DeleteEggs(tx *gorm.DB, recordingID uint) 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)
ExistsOnDate(ctx context.Context, projectFlockKandangId uint, recordTime time.Time) (bool, error)
@@ -272,6 +279,18 @@ func (r *RecordingRepositoryImpl) GenerateNextDay(tx *gorm.DB, projectFlockKanda
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 {
if len(stocks) == 0 {
return nil
@@ -279,10 +298,24 @@ func (r *RecordingRepositoryImpl) CreateStocks(tx *gorm.DB, stocks []entity.Reco
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 {
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) {
var items []entity.RecordingStock
if err := tx.Where("recording_id = ?", recordingID).Find(&items).Error; err != nil {
@@ -291,6 +324,18 @@ func (r *RecordingRepositoryImpl) ListStocks(tx *gorm.DB, recordingID uint) ([]e
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 {
return tx.Model(&entity.RecordingStock{}).
Where("id = ?", stockID).
@@ -306,6 +351,16 @@ func (r *RecordingRepositoryImpl) UpdateDepletionPending(tx *gorm.DB, depletionI
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 {
if len(depletions) == 0 {
return nil
@@ -325,6 +380,18 @@ func (r *RecordingRepositoryImpl) ListDepletions(tx *gorm.DB, recordingID uint)
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 {
if len(eggs) == 0 {
return nil
@@ -344,6 +411,12 @@ func (r *RecordingRepositoryImpl) ListEggs(tx *gorm.DB, recordingID uint) ([]ent
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(
ctx context.Context,
id uint,
@@ -821,6 +894,7 @@ func (r *RecordingRepositoryImpl) ResyncProjectFlockPopulationUsage(ctx context.
FROM stock_allocations
WHERE stockable_type = 'PROJECT_FLOCK_POPULATION'
AND status = 'ACTIVE'
AND allocation_purpose = 'CONSUME'
GROUP BY stockable_id
) a
WHERE p.id = a.stockable_id
@@ -836,6 +910,7 @@ func (r *RecordingRepositoryImpl) ResyncProjectFlockPopulationUsage(ctx context.
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
)
`
@@ -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,28 @@ 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) GetAvailableQtyPerKandang(c *fiber.Ctx) error {
projectFlockID, err := strconv.ParseUint(c.Params("project_flock_id"), 10, 32)
if err != nil {
@@ -17,6 +17,9 @@ type TransferLayingRelationDTO struct {
Id uint `json:"id"`
TransferNumber string `json:"transfer_number"`
TransferDate time.Time `json:"transfer_date"`
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"`
}
@@ -47,6 +50,8 @@ type TransferLayingListDTO struct {
ToProjectFlock *projectFlockDTO.ProjectFlockRelationDTO `json:"to_project_flock,omitempty"`
CreatedBy uint `json:"created_by"`
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"`
Approval *approvalDTO.ApprovalRelationDTO `json:"approval,omitempty"`
}
@@ -91,6 +96,9 @@ func ToTransferLayingRelationDTO(e entity.LayingTransfer) TransferLayingRelation
Id: e.Id,
TransferNumber: e.TransferNumber,
TransferDate: e.TransferDate,
EconomicCutoffDate: e.EconomicCutoffDate,
EffectiveMoveDate: e.EffectiveMoveDate,
ExecutedAt: e.ExecutedAt,
Notes: e.Notes,
}
}
@@ -190,6 +198,12 @@ func ToTransferLayingListDTO(e entity.LayingTransfer) TransferLayingListDTO {
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
if e.LatestApproval != nil {
mapped := approvalDTO.ToApprovalDTO(*e.LatestApproval)
@@ -219,6 +233,8 @@ func ToTransferLayingListDTO(e entity.LayingTransfer) TransferLayingListDTO {
ToProjectFlock: toProjectFlock,
CreatedBy: e.CreatedBy,
CreatedUser: createdUser,
ExecutedBy: e.ExecutedBy,
ExecutedUser: executedUser,
CreatedAt: e.CreatedAt,
Approval: approval,
}
@@ -10,11 +10,11 @@ import (
commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository"
commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service"
"gitlab.com/mbugroup/lti-api.git/internal/utils/fifo"
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"
sTransferLaying "gitlab.com/mbugroup/lti-api.git/internal/modules/production/transfer_layings/services"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
"gitlab.com/mbugroup/lti-api.git/internal/utils/fifo"
rInventory "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories"
rWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/repositories"
@@ -37,6 +37,7 @@ func (TransferLayingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, val
stockAllocationRepo := commonRepo.NewStockAllocationRepository(db)
fifoService := commonSvc.NewFifoService(db, stockAllocationRepo, productWarehouseRepo, utils.Log)
fifoStockV2Service := commonSvc.NewFifoStockV2Service(db, utils.Log)
// daftarin jadi stockable
if err := fifoService.RegisterStockable(fifo.StockableConfig{
@@ -91,6 +92,7 @@ func (TransferLayingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, val
warehouseRepo,
approvalService,
fifoService,
fifoStockV2Service,
validate,
)
userService := sUser.NewUserService(userRepo, validate)
@@ -7,6 +7,7 @@ import (
"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"
"gorm.io/gorm"
)
@@ -14,6 +15,8 @@ type TransferLayingRepository interface {
repository.BaseRepository[entity.LayingTransfer]
GetByTransferNumber(ctx context.Context, transferNumber string) (*entity.LayingTransfer, 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
GetAllWithFilters(ctx context.Context, offset int, limit int, params *GetAllFilterParams) ([]entity.LayingTransfer, int64, error)
@@ -164,6 +167,7 @@ func (r *TransferLayingRepositoryImpl) GetAllWithFilters(ctx context.Context, of
Preload("FromProjectFlock").
Preload("ToProjectFlock").
Preload("CreatedUser").
Preload("ExecutedUser").
Preload("Sources").
Preload("Sources.SourceProjectFlockKandang").
Preload("Sources.SourceProjectFlockKandang.Kandang").
@@ -180,3 +184,57 @@ func (r *TransferLayingRepositoryImpl) GetAllWithFilters(ctx context.Context, of
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{}).
Joins("JOIN laying_transfer_sources lts ON lts.laying_transfer_id = laying_transfers.id AND lts.deleted_at IS NULL").
Where("lts.source_project_flock_kandang_id = ?", 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
}
@@ -27,6 +27,7 @@ func TransferLayingRoutes(v1 fiber.Router, u user.UserService, s transferLaying.
route.Patch("/:id", m.RequirePermissions(m.P_TransferToLaying_UpdateOne), ctrl.UpdateOne)
route.Delete("/:id", m.RequirePermissions(m.P_TransferToLaying_DeleteOne), ctrl.DeleteOne)
route.Post("/approvals", m.RequirePermissions(m.P_TransferToLaying_Approval), ctrl.Approval)
route.Post("/:id/execute", m.RequirePermissions(m.P_TransferToLaying_Approval), ctrl.Execute)
route.Get("/project-flocks/:project_flock_id/available-qty", m.RequirePermissions(m.P_TransferToLaying_GetAvailableQty), ctrl.GetAvailableQtyPerKandang)
route.Get("/project-flocks/:project_flock_id/max-target-qty", m.RequirePermissions(m.P_TransferToLaying_CreateOne), ctrl.GetMaxTargetQtyPerKandang)
}
@@ -0,0 +1,87 @@
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 (
transferLayingInFunctionCode = "TRANSFER_TO_LAYING_IN"
transferLayingStockableLane = "STOCKABLE"
transferLayingSourceTable = "laying_transfer_targets"
)
func reflowTransferLayingScope(
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 := resolveTransferLayingFlagGroupByProductWarehouse(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 resolveTransferLayingFlagGroupByProductWarehouse(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 = ?", transferLayingStockableLane).
Where("rr.function_code = ?", transferLayingInFunctionCode).
Where("rr.source_table = ?", transferLayingSourceTable).
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
}
@@ -10,6 +10,8 @@ import (
commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository"
commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service"
fifoV2 "gitlab.com/mbugroup/lti-api.git/internal/common/service/fifo_stock_v2"
"gitlab.com/mbugroup/lti-api.git/internal/config"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
m "gitlab.com/mbugroup/lti-api.git/internal/middleware"
rInventory "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories"
@@ -34,6 +36,7 @@ type TransferLayingService interface {
UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint) (*entity.LayingTransfer, error)
DeleteOne(ctx *fiber.Ctx, id uint) error
Approval(ctx *fiber.Ctx, req *validation.Approve) ([]entity.LayingTransfer, error)
Execute(ctx *fiber.Ctx, id uint) (*entity.LayingTransfer, error)
GetAvailableQtyPerKandang(ctx *fiber.Ctx, projectFlockID uint) (*entity.ProjectFlock, map[uint]float64, error)
GetMaxTargetQtyPerKandang(ctx *fiber.Ctx, projectFlockID uint) (map[uint]float64, error)
}
@@ -52,8 +55,13 @@ type transferLayingService struct {
StockLogRepo rStockLogs.StockLogRepository
ApprovalService commonSvc.ApprovalService
FifoSvc commonSvc.FifoService
FifoStockV2Svc commonSvc.FifoStockV2Service
}
const (
transferToLayingFlagGroupCode = "AYAM"
)
func NewTransferLayingService(
repo repository.TransferLayingRepository,
layingTransferSourceRepo repository.LayingTransferSourceRepository,
@@ -65,6 +73,7 @@ func NewTransferLayingService(
warehouseRepo rWarehouse.WarehouseRepository,
approvalService commonSvc.ApprovalService,
fifoSvc commonSvc.FifoService,
fifoStockV2Svc commonSvc.FifoStockV2Service,
validate *validator.Validate,
) TransferLayingService {
return &transferLayingService{
@@ -81,12 +90,14 @@ func NewTransferLayingService(
StockLogRepo: rStockLogs.NewStockLogRepository(repo.DB()),
ApprovalService: approvalService,
FifoSvc: fifoSvc,
FifoStockV2Svc: fifoStockV2Svc,
}
}
func (s transferLayingService) withRelations(db *gorm.DB) *gorm.DB {
return db.
Preload("CreatedUser").
Preload("ExecutedUser").
Preload("FromProjectFlock").
Preload("ToProjectFlock").
Preload("Sources").
@@ -744,13 +755,10 @@ func (s transferLayingService) Approval(c *fiber.Ctx, req *validation.Approve) (
err = s.Repository.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error {
repoTx := s.Repository.WithTx(dbTransaction)
approvalSvcTx := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(dbTransaction))
stockAllocationRepo := commonRepo.NewStockAllocationRepository(dbTransaction)
sourceRepoTx := repository.NewLayingTransferSourceRepository(dbTransaction)
targetRepoTx := repository.NewLayingTransferTargetRepository(dbTransaction)
stockLogRepoTx := rStockLogs.NewStockLogRepository(dbTransaction)
for _, approvableID := range approvableIDs {
transfer, err := repoTx.GetByID(c.Context(), approvableID, nil)
_, err := repoTx.GetByID(c.Context(), approvableID, nil)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("TransferLaying %d not found", approvableID))
@@ -771,148 +779,22 @@ func (s transferLayingService) Approval(c *fiber.Ctx, req *validation.Approve) (
}
if action == entity.ApprovalActionApproved {
sources, err := sourceRepoTx.GetByLayingTransferId(c.Context(), approvableID)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil sources transfer")
}
targets, err := targetRepoTx.GetByLayingTransferId(c.Context(), approvableID)
economicCutoffDate, err := s.calculateEconomicCutoffDate(c.Context(), sources)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil targets transfer")
return err
}
totalTargetQty := 0.0
for _, target := range targets {
totalTargetQty += target.TotalQty
}
totalSourceRequested := 0.0
for _, source := range sources {
totalSourceRequested += source.RequestedQty
}
for _, source := range sources {
if source.ProductWarehouseId == nil {
return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Source product warehouse tidak ditemukan untuk transfer %d", approvableID))
}
sourceShare := (source.RequestedQty / totalSourceRequested) * totalTargetQty
consumeResult, err := s.FifoSvc.Consume(c.Context(), commonSvc.StockConsumeRequest{
UsableKey: fifo.UsableKeyTransferToLayingOut,
UsableID: source.Id,
ProductWarehouseID: *source.ProductWarehouseId,
Quantity: sourceShare,
AllowPending: false,
Tx: dbTransaction,
})
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Gagal consume FIFO stock: %v", err))
}
if err := sourceRepoTx.PatchOne(c.Context(), source.Id, map[string]interface{}{
"usage_qty": source.UsageQty + consumeResult.UsageQuantity,
"pending_usage_qty": consumeResult.PendingQuantity,
if err := repoTx.PatchOne(c.Context(), approvableID, map[string]any{
"economic_cutoff_date": economicCutoffDate,
"effective_move_date": economicCutoffDate, // Backward-compatible alias for existing clients.
"executed_at": nil,
"executed_by": nil,
}, nil); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Gagal update source usage qty")
}
targetShares := distributeProportionalWithRounding(targets, totalTargetQty, sourceShare)
for i, target := range targets {
roundedQty := math.Round(targetShares[i])
if roundedQty <= 0 {
continue
}
mappingAllocation := &entity.StockAllocation{
StockableType: fifo.UsableKeyTransferToLayingOut.String(),
StockableId: source.Id,
UsableType: fifo.StockableKeyTransferToLayingIn.String(),
UsableId: target.Id,
ProductWarehouseId: *source.ProductWarehouseId,
Qty: roundedQty,
Status: entity.StockAllocationStatusActive,
}
if err := stockAllocationRepo.CreateOne(c.Context(), mappingAllocation, nil); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Gagal create mapping allocation source→target")
}
}
stockLogDecrease := &entity.StockLog{
ProductWarehouseId: *source.ProductWarehouseId,
CreatedBy: actorID,
Increase: 0,
Decrease: sourceShare,
LoggableType: string(utils.StockLogTypeTransferLaying),
LoggableId: approvableID,
Notes: fmt.Sprintf("TL #%s", transfer.TransferNumber),
}
stockLogs, err := stockLogRepoTx.GetByProductWarehouse(c.Context(), *source.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")
}
if len(stockLogs) > 0 {
latestStockLog := stockLogs[0]
stockLogDecrease.Stock = latestStockLog.Stock - stockLogDecrease.Decrease
} else {
stockLogDecrease.Stock -= stockLogDecrease.Decrease
}
if err := stockLogRepoTx.CreateOne(c.Context(), stockLogDecrease, nil); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat log stok keluar")
}
}
for _, target := range targets {
if target.ProductWarehouseId == nil {
return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Target product warehouse tidak ditemukan untuk transfer %d", approvableID))
}
note := fmt.Sprintf("Transfer to Laying #%s", transfer.TransferNumber)
_, err := s.FifoSvc.Replenish(c.Context(), commonSvc.StockReplenishRequest{
StockableKey: fifo.StockableKeyTransferToLayingIn,
StockableID: target.Id,
ProductWarehouseID: *target.ProductWarehouseId,
Quantity: target.TotalQty,
Note: &note,
Tx: dbTransaction,
})
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Gagal replenish stock ke target warehouse: %v", err))
}
if err := targetRepoTx.PatchOne(c.Context(), target.Id, map[string]interface{}{
"total_qty": target.TotalQty,
}, nil); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Gagal update target total qty")
}
stockLogIncrease := &entity.StockLog{
ProductWarehouseId: *target.ProductWarehouseId,
CreatedBy: actorID,
Increase: target.TotalQty,
Decrease: 0,
LoggableType: string(utils.StockLogTypeTransferLaying),
LoggableId: approvableID,
Notes: fmt.Sprintf("TL #%s", transfer.TransferNumber),
}
stockLogs, err := stockLogRepoTx.GetByProductWarehouse(c.Context(), *target.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")
}
if len(stockLogs) > 0 {
latestStockLog := stockLogs[0]
stockLogIncrease.Stock = latestStockLog.Stock + stockLogIncrease.Increase
} else {
stockLogIncrease.Stock += stockLogIncrease.Increase
}
if err := stockLogRepoTx.CreateOne(c.Context(), stockLogIncrease, nil); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat log stok masuk")
}
return fiber.NewError(fiber.StatusInternalServerError, "Gagal menyimpan tanggal efektif transfer laying")
}
}
}
@@ -939,6 +821,392 @@ func (s transferLayingService) Approval(c *fiber.Ctx, req *validation.Approve) (
return updated, nil
}
func (s transferLayingService) Execute(c *fiber.Ctx, id uint) (*entity.LayingTransfer, error) {
if err := m.EnsureLayingTransferAccess(c, s.Repository.DB(), id); err != nil {
return nil, err
}
actorID, err := m.ActorIDFromContext(c)
if err != nil {
return nil, err
}
err = s.Repository.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error {
repoTx := s.Repository.WithTx(dbTransaction)
sourceRepoTx := repository.NewLayingTransferSourceRepository(dbTransaction)
targetRepoTx := repository.NewLayingTransferTargetRepository(dbTransaction)
approvalRepoTx := commonRepo.NewApprovalRepository(dbTransaction)
transfer, err := repoTx.GetByID(c.Context(), id, nil)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return fiber.NewError(fiber.StatusNotFound, "Transfer laying tidak ditemukan")
}
return err
}
if transfer.ExecutedAt != nil {
return nil
}
latestApproval, err := approvalRepoTx.LatestByTarget(c.Context(), string(utils.ApprovalWorkflowTransferToLaying), transfer.Id, nil)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
if latestApproval == nil || latestApproval.Action == nil || *latestApproval.Action != entity.ApprovalActionApproved {
return fiber.NewError(fiber.StatusBadRequest, "Transfer laying harus disetujui sebelum dieksekusi")
}
sources, err := sourceRepoTx.GetByLayingTransferId(c.Context(), transfer.Id)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil sumber transfer laying")
}
targets, err := targetRepoTx.GetByLayingTransferId(c.Context(), transfer.Id)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil target transfer laying")
}
if transfer.EconomicCutoffDate == nil || transfer.EconomicCutoffDate.IsZero() {
economicCutoffDate, calcErr := s.calculateEconomicCutoffDate(c.Context(), sources)
if calcErr != nil {
return calcErr
}
if patchErr := repoTx.PatchOne(c.Context(), transfer.Id, map[string]any{
"economic_cutoff_date": economicCutoffDate,
"effective_move_date": economicCutoffDate, // Keep legacy field in sync.
}, nil); patchErr != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Gagal menyimpan tanggal efektif transfer laying")
}
transfer.EconomicCutoffDate = &economicCutoffDate
transfer.EffectiveMoveDate = &economicCutoffDate
}
physicalMoveDate := normalizeDateOnlyUTC(transfer.TransferDate)
today := normalizeDateOnlyUTC(time.Now().UTC())
if today.Before(physicalMoveDate) {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Transfer laying baru bisa dieksekusi mulai tanggal pindah fisik %s", physicalMoveDate.Format("2006-01-02")))
}
if err := s.executeApprovedTransferMovement(c.Context(), dbTransaction, transfer, actorID, sources, targets); err != nil {
return err
}
executedAt := time.Now().UTC()
if err := repoTx.PatchOne(c.Context(), transfer.Id, map[string]any{
"executed_at": executedAt,
"executed_by": actorID,
}, nil); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Gagal menyimpan status eksekusi transfer laying")
}
return nil
})
if err != nil {
if fiberErr, ok := err.(*fiber.Error); ok {
return nil, fiberErr
}
return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengeksekusi transfer laying")
}
transfer, _, err := s.GetOne(c, id)
if err != nil {
return nil, err
}
return transfer, nil
}
func (s *transferLayingService) executeApprovedTransferMovement(
ctx context.Context,
tx *gorm.DB,
transfer *entity.LayingTransfer,
actorID uint,
sources []entity.LayingTransferSource,
targets []entity.LayingTransferTarget,
) error {
if transfer == nil || transfer.Id == 0 {
return fiber.NewError(fiber.StatusBadRequest, "Transfer laying tidak valid")
}
if len(sources) == 0 {
return fiber.NewError(fiber.StatusBadRequest, "Transfer laying belum memiliki sumber")
}
if len(targets) == 0 {
return fiber.NewError(fiber.StatusBadRequest, "Transfer laying belum memiliki target")
}
if s.FifoStockV2Svc == nil {
return fiber.NewError(fiber.StatusInternalServerError, "FIFO v2 service is not available")
}
if s.FifoSvc == nil {
return fiber.NewError(fiber.StatusInternalServerError, "FIFO service is not available")
}
stockAllocationRepo := commonRepo.NewStockAllocationRepository(tx)
sourceRepoTx := repository.NewLayingTransferSourceRepository(tx)
targetRepoTx := repository.NewLayingTransferTargetRepository(tx)
stockLogRepoTx := rStockLogs.NewStockLogRepository(tx)
totalTargetQty := 0.0
for _, target := range targets {
totalTargetQty += target.TotalQty
}
if totalTargetQty <= 0 {
return fiber.NewError(fiber.StatusBadRequest, "Total kuantitas target transfer laying harus lebih dari 0")
}
totalSourceRequested := 0.0
for _, source := range sources {
totalSourceRequested += source.RequestedQty
}
if totalSourceRequested <= 0 {
return fiber.NewError(fiber.StatusBadRequest, "Total kuantitas sumber transfer laying harus lebih dari 0")
}
for _, source := range sources {
if source.ProductWarehouseId == nil {
return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Source product warehouse tidak ditemukan untuk transfer %d", transfer.Id))
}
if source.RequestedQty <= 0 {
continue
}
sourceShare := (source.RequestedQty / totalSourceRequested) * totalTargetQty
if sourceShare <= 0 {
continue
}
if err := sourceRepoTx.PatchOne(ctx, source.Id, map[string]any{
"usage_qty": source.UsageQty + sourceShare,
"pending_usage_qty": 0,
}, nil); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Gagal update source usage qty")
}
asOf := normalizeDateOnlyUTC(transfer.TransferDate)
if _, err := s.FifoStockV2Svc.Reflow(ctx, commonSvc.FifoStockV2ReflowRequest{
FlagGroupCode: transferToLayingFlagGroupCode,
ProductWarehouseID: *source.ProductWarehouseId,
AsOf: &asOf,
Tx: tx,
}); err != nil {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Gagal consume FIFO v2 stock: %v", err))
}
refreshedSource, err := sourceRepoTx.GetByID(ctx, source.Id, nil)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Gagal refresh source transfer setelah reflow")
}
usageDelta := refreshedSource.UsageQty - source.UsageQty
pendingQty := refreshedSource.PendingUsageQty
if pendingQty > 1e-6 || usageDelta < sourceShare-1e-6 {
return fiber.NewError(
fiber.StatusBadRequest,
fmt.Sprintf("Stok sumber tidak mencukupi untuk mengeksekusi transfer laying %s", transfer.TransferNumber),
)
}
movedQty := usageDelta
if err := s.allocatePopulationForTransfer(ctx, tx, source, movedQty); err != nil {
return err
}
targetShares := distributeProportionalWithRounding(targets, totalTargetQty, movedQty)
for i, target := range targets {
roundedQty := math.Round(targetShares[i])
if roundedQty <= 0 {
continue
}
mappingAllocation := &entity.StockAllocation{
StockableType: fifo.UsableKeyTransferToLayingOut.String(),
StockableId: source.Id,
UsableType: fifo.StockableKeyTransferToLayingIn.String(),
UsableId: target.Id,
ProductWarehouseId: *source.ProductWarehouseId,
Qty: roundedQty,
Status: entity.StockAllocationStatusActive,
}
if err := stockAllocationRepo.CreateOne(ctx, mappingAllocation, nil); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Gagal create mapping allocation source→target")
}
}
stockLogDecrease := &entity.StockLog{
ProductWarehouseId: *source.ProductWarehouseId,
CreatedBy: actorID,
Increase: 0,
Decrease: movedQty,
LoggableType: string(utils.StockLogTypeTransferLaying),
LoggableId: transfer.Id,
Notes: fmt.Sprintf("TL #%s", transfer.TransferNumber),
}
stockLogs, err := stockLogRepoTx.GetByProductWarehouse(ctx, *source.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")
}
if len(stockLogs) > 0 {
latestStockLog := stockLogs[0]
stockLogDecrease.Stock = latestStockLog.Stock - stockLogDecrease.Decrease
} else {
stockLogDecrease.Stock -= stockLogDecrease.Decrease
}
if err := stockLogRepoTx.CreateOne(ctx, stockLogDecrease, nil); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat log stok keluar")
}
}
for _, target := range targets {
if target.ProductWarehouseId == nil {
return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Target product warehouse tidak ditemukan untuk transfer %d", transfer.Id))
}
note := fmt.Sprintf("Transfer to Laying #%s", transfer.TransferNumber)
_, err := s.FifoSvc.Replenish(ctx, commonSvc.StockReplenishRequest{
StockableKey: fifo.StockableKeyTransferToLayingIn,
StockableID: target.Id,
ProductWarehouseID: *target.ProductWarehouseId,
Quantity: target.TotalQty,
Note: &note,
Tx: tx,
})
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Gagal replenish stock ke target warehouse: %v", err))
}
if err := targetRepoTx.PatchOne(ctx, target.Id, map[string]any{
"total_qty": target.TotalQty,
}, nil); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Gagal update target total qty")
}
stockLogIncrease := &entity.StockLog{
ProductWarehouseId: *target.ProductWarehouseId,
CreatedBy: actorID,
Increase: target.TotalQty,
Decrease: 0,
LoggableType: string(utils.StockLogTypeTransferLaying),
LoggableId: transfer.Id,
Notes: fmt.Sprintf("TL #%s", transfer.TransferNumber),
}
stockLogs, err := stockLogRepoTx.GetByProductWarehouse(ctx, *target.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")
}
if len(stockLogs) > 0 {
latestStockLog := stockLogs[0]
stockLogIncrease.Stock = latestStockLog.Stock + stockLogIncrease.Increase
} else {
stockLogIncrease.Stock += stockLogIncrease.Increase
}
if err := stockLogRepoTx.CreateOne(ctx, stockLogIncrease, nil); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat log stok masuk")
}
}
return nil
}
func (s *transferLayingService) allocatePopulationForTransfer(
ctx context.Context,
tx *gorm.DB,
source entity.LayingTransferSource,
consumeQty float64,
) error {
if consumeQty <= 0 {
return nil
}
if tx == nil {
return errors.New("transaction is required")
}
if source.SourceProjectFlockKandangId == 0 || source.ProductWarehouseId == nil || *source.ProductWarehouseId == 0 {
return fiber.NewError(fiber.StatusBadRequest, "Project flock kandang sumber atau product warehouse tidak valid")
}
populations, err := s.ProjectFlockPopulationRepo.GetByProjectFlockKandangIDAndProductWarehouseID(
ctx,
source.SourceProjectFlockKandangId,
*source.ProductWarehouseId,
)
if err != nil {
return err
}
if len(populations) == 0 {
return fiber.NewError(fiber.StatusBadRequest, "Populasi tidak ditemukan untuk transfer laying")
}
return fifoV2.AllocatePopulationConsumption(
ctx,
tx,
populations,
*source.ProductWarehouseId,
fifo.UsableKeyTransferToLayingOut.String(),
source.Id,
consumeQty,
)
}
func (s *transferLayingService) calculateEconomicCutoffDate(ctx context.Context, sources []entity.LayingTransferSource) (time.Time, error) {
if len(sources) == 0 {
return time.Time{}, fiber.NewError(fiber.StatusBadRequest, "Sumber transfer laying tidak ditemukan")
}
maxGrowingWeek := config.TransferToLayingGrowingMaxWeek
if maxGrowingWeek <= 0 {
maxGrowingWeek = 19
}
var baselineChickInDate time.Time
for _, source := range sources {
chickInDate, err := s.resolveSourceChickInDate(ctx, source.SourceProjectFlockKandangId)
if err != nil {
return time.Time{}, err
}
if baselineChickInDate.IsZero() || chickInDate.Before(baselineChickInDate) {
baselineChickInDate = chickInDate
}
}
if baselineChickInDate.IsZero() {
return time.Time{}, fiber.NewError(fiber.StatusBadRequest, "Tanggal chick in sumber transfer laying tidak ditemukan")
}
economicCutoffDate := baselineChickInDate.AddDate(0, 0, maxGrowingWeek*7)
return normalizeDateOnlyUTC(economicCutoffDate), nil
}
func (s *transferLayingService) resolveSourceChickInDate(ctx context.Context, sourceProjectFlockKandangID uint) (time.Time, error) {
if sourceProjectFlockKandangID == 0 {
return time.Time{}, fiber.NewError(fiber.StatusBadRequest, "Project flock kandang sumber tidak valid")
}
populations, err := s.ProjectFlockPopulationRepo.GetByProjectFlockKandangID(ctx, sourceProjectFlockKandangID)
if err != nil {
return time.Time{}, err
}
var earliestChickInDate time.Time
for _, population := range populations {
if population.ProjectChickin == nil || population.ProjectChickin.ChickInDate.IsZero() {
continue
}
chickInDate := normalizeDateOnlyUTC(population.ProjectChickin.ChickInDate)
if earliestChickInDate.IsZero() || chickInDate.Before(earliestChickInDate) {
earliestChickInDate = chickInDate
}
}
if earliestChickInDate.IsZero() {
return time.Time{}, fiber.NewError(
fiber.StatusBadRequest,
fmt.Sprintf("Tanggal chick in untuk kandang sumber %d tidak ditemukan", sourceProjectFlockKandangID),
)
}
return earliestChickInDate, nil
}
func createApprovalTransferLaying(ctx context.Context, tx *gorm.DB, transferLayingID uint, actorID uint) error {
if transferLayingID == 0 || actorID == 0 {
return nil
@@ -1053,6 +1321,10 @@ func (s transferLayingService) GetMaxTargetQtyPerKandang(c *fiber.Ctx, projectFl
return kandangMaxTargetQty, nil
}
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 distributeProportionalWithRounding(targets []entity.LayingTransferTarget, totalTargetQty, sourceShare float64) []float64 {
if len(targets) == 0 {
return []float64{}
+2 -16
View File
@@ -23,7 +23,6 @@ import (
rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories"
sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services"
utils "gitlab.com/mbugroup/lti-api.git/internal/utils"
"gitlab.com/mbugroup/lti-api.git/internal/utils/fifo"
"gorm.io/gorm"
)
@@ -40,7 +39,6 @@ func (PurchaseModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate
expenseRepository := expenseRepo.NewExpenseRepository(db)
expenseRealizationRepo := expenseRepo.NewExpenseRealizationRepository(db)
projectFlockKandangRepository := projectFlockKandangRepo.NewProjectFlockKandangRepository(db)
stockAllocRepo := commonRepo.NewStockAllocationRepository(db)
approvalRepo := commonRepo.NewApprovalRepository(db)
approvalService := commonSvc.NewApprovalService(approvalRepo)
@@ -73,19 +71,7 @@ func (PurchaseModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate
expenseServiceInstance,
)
fifoService := commonSvc.NewFifoService(db, stockAllocRepo, productWarehouseRepo, utils.Log)
_ = fifoService.RegisterStockable(fifo.StockableConfig{
Key: fifo.StockableKeyPurchaseItems,
Table: "purchase_items",
Columns: fifo.StockableColumns{
ID: "id",
ProductWarehouseID: "product_warehouse_id",
TotalQuantity: "total_qty",
TotalUsedQuantity: "total_used",
CreatedAt: "id",
},
OrderBy: []string{"id ASC"},
})
fifoStockV2Service := commonSvc.NewFifoStockV2Service(db, utils.Log)
purchaseService := service.NewPurchaseService(
validate,
@@ -97,7 +83,7 @@ func (PurchaseModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate
projectFlockKandangRepository,
approvalService,
expenseBridge,
fifoService,
fifoStockV2Service,
documentSvc,
)
@@ -0,0 +1,93 @@
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 (
purchaseInFunctionCode = "PURCHASE_IN"
purchaseStockableLane = "STOCKABLE"
purchaseSourceTable = "purchase_items"
)
func reflowPurchaseScope(
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 := resolvePurchaseFlagGroupByProductWarehouse(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 resolvePurchaseFlagGroupByProductWarehouse(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 = ?", purchaseStockableLane).
Where("rr.function_code = ?", purchaseInFunctionCode).
Where("rr.source_table = ?", purchaseSourceTable).
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 assignEarliestAsOf(m map[uint]time.Time, productWarehouseID uint, asOf time.Time) {
if productWarehouseID == 0 {
return
}
if current, ok := m[productWarehouseID]; !ok || asOf.Before(current) {
m[productWarehouseID] = asOf
}
}
@@ -57,7 +57,7 @@ type purchaseService struct {
ProjectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository
ApprovalSvc commonSvc.ApprovalService
ExpenseBridge PurchaseExpenseBridge
FifoSvc commonSvc.FifoService
FifoStockV2Svc commonSvc.FifoStockV2Service
DocumentSvc commonSvc.DocumentService
approvalWorkflow approvalutils.ApprovalWorkflowKey
}
@@ -77,7 +77,7 @@ func NewPurchaseService(
projectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository,
approvalSvc commonSvc.ApprovalService,
expenseBridge PurchaseExpenseBridge,
fifoSvc commonSvc.FifoService,
fifoStockV2Svc commonSvc.FifoStockV2Service,
documentSvc commonSvc.DocumentService,
) PurchaseService {
return &purchaseService{
@@ -91,7 +91,7 @@ func NewPurchaseService(
ProjectFlockKandangRepo: projectFlockKandangRepo,
ApprovalSvc: approvalSvc,
ExpenseBridge: expenseBridge,
FifoSvc: fifoSvc,
FifoStockV2Svc: fifoStockV2Svc,
DocumentSvc: documentSvc,
approvalWorkflow: utils.ApprovalWorkflowPurchase,
}
@@ -256,38 +256,16 @@ func (s *purchaseService) GetOne(c *fiber.Ctx, id uint) (*entity.Purchase, error
s.Log.Warnf("Unable to attach latest approval for purchase %d: %+v", id, err)
}
if len(purchase.Items) > 0 {
itemIDs := make([]uint, 0, len(purchase.Items))
for i := range purchase.Items {
if purchase.Items[i].Id == 0 {
continue
}
itemIDs = append(itemIDs, purchase.Items[i].Id)
}
if len(itemIDs) > 0 {
var usedIDs []uint
if err := s.PurchaseRepo.DB().WithContext(c.Context()).
Model(&entity.StockAllocation{}).
Distinct("stockable_id").
Where("stockable_type = ? AND stockable_id IN ? AND usable_type = ? AND status IN ?",
fifo.StockableKeyPurchaseItems.String(),
itemIDs,
fifo.UsableKeyProjectChickin.String(),
[]string{entity.StockAllocationStatusActive, entity.StockAllocationStatusPending},
).
Pluck("stockable_id", &usedIDs).Error; err != nil {
lockedIDs, err := s.resolveChickinLockedItemIDs(c.Context(), s.PurchaseRepo.DB(), purchase.Items)
if err != nil {
return nil, err
}
usedSet := make(map[uint]struct{}, len(usedIDs))
for _, id := range usedIDs {
usedSet[id] = struct{}{}
}
for i := range purchase.Items {
if _, ok := usedSet[purchase.Items[i].Id]; ok {
if _, ok := lockedIDs[purchase.Items[i].Id]; ok {
purchase.Items[i].HasChickin = true
}
}
}
}
s.applyTravelDocumentURLs(c.Context(), purchase)
return purchase, nil
@@ -532,39 +510,23 @@ func (s *purchaseService) ApproveStaffPurchase(c *fiber.Ctx, id uint, req *valid
}
if action == entity.ApprovalActionApproved {
itemIDs := make([]uint, 0, len(purchase.Items))
itemByID := make(map[uint]entity.PurchaseItem, len(purchase.Items))
for i := range purchase.Items {
if purchase.Items[i].Id == 0 {
continue
}
itemIDs = append(itemIDs, purchase.Items[i].Id)
itemByID[purchase.Items[i].Id] = purchase.Items[i]
}
if len(itemIDs) > 0 {
var usedIDs []uint
if err := s.PurchaseRepo.DB().WithContext(ctx).
Model(&entity.StockAllocation{}).
Distinct("stockable_id").
Where("stockable_type = ? AND stockable_id IN ? AND usable_type = ? AND status IN ?",
fifo.StockableKeyPurchaseItems.String(),
itemIDs,
fifo.UsableKeyProjectChickin.String(),
[]string{entity.StockAllocationStatusActive, entity.StockAllocationStatusPending},
).
Pluck("stockable_id", &usedIDs).Error; err != nil {
lockedIDs, err := s.resolveChickinLockedItemIDs(ctx, s.PurchaseRepo.DB(), purchase.Items)
if err != nil {
return nil, err
}
if len(usedIDs) > 0 {
usedSet := make(map[uint]struct{}, len(usedIDs))
for _, id := range usedIDs {
usedSet[id] = struct{}{}
}
if len(lockedIDs) > 0 {
for _, payload := range req.Items {
if payload.PurchaseItemID == 0 || payload.Qty == nil {
continue
}
if _, used := usedSet[payload.PurchaseItemID]; !used {
if _, locked := lockedIDs[payload.PurchaseItemID]; !locked {
continue
}
item, ok := itemByID[payload.PurchaseItemID]
@@ -577,7 +539,6 @@ func (s *purchaseService) ApproveStaffPurchase(c *fiber.Ctx, id uint, req *valid
}
}
}
}
payload, err := s.buildStaffAdjustmentPayload(c.Context(), purchase, req, syncReceiving)
if err != nil {
@@ -826,37 +787,25 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation
req.Items[idx].TravelDocumentPath = &uploadedURL
}
}
lockedIDs := map[uint]struct{}{}
if action == entity.ApprovalActionApproved {
itemIDs := make([]uint, 0, len(purchase.Items))
itemByID := make(map[uint]entity.PurchaseItem, len(purchase.Items))
for i := range purchase.Items {
if purchase.Items[i].Id == 0 {
continue
}
itemIDs = append(itemIDs, purchase.Items[i].Id)
itemByID[purchase.Items[i].Id] = purchase.Items[i]
}
if len(itemIDs) > 0 {
var usedIDs []uint
if err := s.PurchaseRepo.DB().WithContext(ctx).
Model(&entity.StockAllocation{}).
Distinct("stockable_id").
Where("stockable_type = ? AND stockable_id IN ? AND usable_type = ? AND status IN ?",
fifo.StockableKeyPurchaseItems.String(),
itemIDs,
fifo.UsableKeyProjectChickin.String(),
[]string{entity.StockAllocationStatusActive, entity.StockAllocationStatusPending},
).
Pluck("stockable_id", &usedIDs).Error; err != nil {
locked, err := s.resolveChickinLockedItemIDs(ctx, s.PurchaseRepo.DB(), purchase.Items)
if err != nil {
return nil, err
}
if len(usedIDs) > 0 {
usedSet := make(map[uint]struct{}, len(usedIDs))
for _, id := range usedIDs {
usedSet[id] = struct{}{}
if len(locked) > 0 {
for id := range locked {
lockedIDs[id] = struct{}{}
}
for _, payload := range req.Items {
if _, used := usedSet[payload.PurchaseItemID]; !used {
if _, used := lockedIDs[payload.PurchaseItemID]; !used {
continue
}
item, ok := itemByID[payload.PurchaseItemID]
@@ -873,7 +822,6 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation
}
}
}
}
itemMap := make(map[uint]*entity.PurchaseItem, len(purchase.Items))
for i := range purchase.Items {
@@ -936,7 +884,7 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation
if receivedQty > item.SubQty {
return nil, utils.BadRequest(fmt.Sprintf("Received quantity for item %d cannot exceed ordered quantity (%.3f)", payload.PurchaseItemID, item.SubQty))
}
if receivedQty < item.TotalUsed {
if receivedQty < item.TotalUsed && isReceivingBelowUsedBlocked(item, lockedIDs) {
return nil, utils.BadRequest(fmt.Sprintf("Received quantity for item %d cannot be lower than used amount (%.3f)", payload.PurchaseItemID, item.TotalUsed))
}
@@ -1026,22 +974,11 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation
pwRepoTx := rProductWarehouse.NewProductWarehouseRepository(tx)
stockLogRepoTx := rStockLogs.NewStockLogRepository(tx)
deltas := make(map[uint]float64)
affected := make(map[uint]struct{})
updates := make([]rPurchase.PurchaseReceivingUpdate, 0, len(prepared))
priceUpdates := make([]rPurchase.PurchasePricingUpdate, 0, len(prepared))
totalQtyDeltas := make(map[uint]float64)
fifoAdds := make([]struct {
itemID uint
pwID uint
qty float64
}, 0, len(prepared))
fifoSubs := make([]struct {
itemID uint
pwID uint
qty float64
}, 0, len(prepared))
resolvePendingIDs := make(map[uint]struct{})
reflowAsOfByPW := make(map[uint]time.Time)
logEntries := make([]struct {
itemID uint
pwID uint
@@ -1083,35 +1020,14 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation
delta float64
}{itemID: item.Id, pwID: *newPWID, delta: deltaQty})
}
switch {
case deltaQty > 0 && newPWID != nil:
if s.FifoSvc != nil {
fifoAdds = append(fifoAdds, struct {
itemID uint
pwID uint
qty float64
}{itemID: item.Id, pwID: *newPWID, qty: deltaQty})
resolvePendingIDs[*newPWID] = struct{}{}
} else {
deltas[*newPWID] += deltaQty
if newPWID != nil {
assignEarliestAsOf(reflowAsOfByPW, *newPWID, prep.receivedDate.UTC())
}
if deltaQty != 0 {
totalQtyDeltas[item.Id] += deltaQty
}
case deltaQty < 0 && newPWID != nil:
if s.FifoSvc != nil {
fifoSubs = append(fifoSubs, struct {
itemID uint
pwID uint
qty float64
}{itemID: item.Id, pwID: *newPWID, qty: deltaQty})
if deltaQty < 0 && newPWID != nil {
affected[*newPWID] = struct{}{}
resolvePendingIDs[*newPWID] = struct{}{}
} else {
deltas[*newPWID] += deltaQty // negative
affected[*newPWID] = struct{}{}
totalQtyDeltas[item.Id] += deltaQty
}
case newPWID != nil:
resolvePendingIDs[*newPWID] = struct{}{}
}
dateCopy := prep.receivedDate
@@ -1147,10 +1063,6 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation
return err
}
if err := pwRepoTx.AdjustQuantities(c.Context(), deltas, nil); err != nil {
return err
}
if len(priceUpdates) > 0 {
if err := repoTx.UpdatePricing(c.Context(), purchase.Id, priceUpdates); err != nil {
return err
@@ -1180,48 +1092,16 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation
}
}
if s.FifoSvc != nil {
for _, adj := range fifoAdds {
if adj.pwID == 0 || adj.qty <= 0 {
continue
if len(reflowAsOfByPW) > 0 {
if s.FifoStockV2Svc == nil {
return fiber.NewError(fiber.StatusInternalServerError, "FIFO v2 service is not available")
}
if _, err := s.FifoSvc.Replenish(c.Context(), commonSvc.StockReplenishRequest{
StockableKey: fifo.StockableKeyPurchaseItems,
StockableID: adj.itemID,
ProductWarehouseID: adj.pwID,
Quantity: adj.qty,
Tx: tx,
}); err != nil {
for pwID, asOf := range reflowAsOfByPW {
asOfCopy := asOf
if err := reflowPurchaseScope(c.Context(), s.FifoStockV2Svc, tx, pwID, &asOfCopy); err != nil {
return err
}
}
for _, adj := range fifoSubs {
if adj.pwID == 0 || adj.qty >= 0 {
continue
}
if err := s.FifoSvc.AdjustStockableQuantity(c.Context(), commonSvc.StockAdjustRequest{
StockableKey: fifo.StockableKeyPurchaseItems,
StockableID: adj.itemID,
ProductWarehouseID: adj.pwID,
Quantity: adj.qty,
Tx: tx,
}); err != nil {
return err
}
}
for pwID := range resolvePendingIDs {
if pwID == 0 {
continue
}
resolved, err := s.FifoSvc.ResolvePending(c.Context(), commonSvc.PendingResolveRequest{
ProductWarehouseID: pwID,
Tx: tx,
})
if err != nil {
return err
}
s.Log.Infof("ResolvePending purchase=%d pw=%d resolved=%d", purchase.Id, pwID, len(resolved))
}
}
if len(logEntries) > 0 {
@@ -1505,29 +1385,13 @@ func (s *purchaseService) DeletePurchase(c *fiber.Ctx, id uint) error {
}
transactionErr := s.PurchaseRepo.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
itemIDs := make([]uint, 0, len(itemsToDelete))
for _, item := range itemsToDelete {
if item.Id == 0 {
continue
}
itemIDs = append(itemIDs, item.Id)
}
if len(itemIDs) > 0 {
var count int64
if err := tx.Model(&entity.StockAllocation{}).
Where("stockable_type = ? AND stockable_id IN ? AND usable_type = ? AND status IN ?",
fifo.StockableKeyPurchaseItems.String(),
itemIDs,
fifo.UsableKeyProjectChickin.String(),
[]string{entity.StockAllocationStatusActive, entity.StockAllocationStatusPending},
).
Count(&count).Error; err != nil {
lockedIDs, err := s.resolveChickinLockedItemIDs(ctx, tx, itemsToDelete)
if err != nil {
return err
}
if count > 0 {
if len(lockedIDs) > 0 {
return utils.BadRequest("Purchase already chickin, failed to delete purchase")
}
}
if err := s.rollbackPurchaseStock(ctx, tx, itemsToDelete, note, actorID); err != nil {
return err
@@ -1577,10 +1441,9 @@ func (s *purchaseService) rollbackPurchaseStock(ctx context.Context, tx *gorm.DB
return nil
}
pwRepoTx := rProductWarehouse.NewProductWarehouseRepository(tx)
stockLogRepoTx := rStockLogs.NewStockLogRepository(tx)
deltas := make(map[uint]float64)
affected := make(map[uint]struct{})
reflowAsOfByPW := make(map[uint]time.Time)
logEntries := make([]struct {
pwID uint
qty float64
@@ -1596,41 +1459,42 @@ func (s *purchaseService) rollbackPurchaseStock(ctx context.Context, tx *gorm.DB
pwID := *item.ProductWarehouseId
qty := item.TotalQty
if s.FifoSvc != nil {
if err := s.FifoSvc.AdjustStockableQuantity(ctx, commonSvc.StockAdjustRequest{
StockableKey: fifo.StockableKeyPurchaseItems,
StockableID: item.Id,
ProductWarehouseID: pwID,
Quantity: -qty,
Tx: tx,
}); err != nil {
if err := tx.WithContext(ctx).
Model(&entity.PurchaseItem{}).
Where("id = ?", item.Id).
Update("total_qty", 0).Error; err != nil {
return err
}
logEntries = append(logEntries, struct {
pwID uint
qty float64
}{pwID: pwID, qty: qty})
continue
}
deltas[pwID] -= qty
affected[pwID] = struct{}{}
if item.ReceivedDate != nil {
assignEarliestAsOf(reflowAsOfByPW, pwID, item.ReceivedDate.UTC())
} else {
assignEarliestAsOf(reflowAsOfByPW, pwID, time.Now().UTC())
}
logEntries = append(logEntries, struct {
pwID uint
qty float64
}{pwID: pwID, qty: qty})
}
if s.FifoSvc == nil && len(deltas) > 0 {
if err := pwRepoTx.AdjustQuantities(ctx, deltas, nil); err != nil {
if len(reflowAsOfByPW) > 0 {
if s.FifoStockV2Svc == nil {
return fiber.NewError(fiber.StatusInternalServerError, "FIFO v2 service is not available")
}
for pwID, asOf := range reflowAsOfByPW {
asOfCopy := asOf
if err := reflowPurchaseScope(ctx, s.FifoStockV2Svc, tx, pwID, &asOfCopy); err != nil {
return err
}
}
}
if len(affected) > 0 {
if err := pwRepoTx.CleanupEmpty(ctx, affected); err != nil {
if err := rProductWarehouse.NewProductWarehouseRepository(tx).CleanupEmpty(ctx, affected); err != nil {
return err
}
}
}
if strings.TrimSpace(note) != "" && actorID != 0 && len(logEntries) > 0 {
logs := make([]*entity.StockLog, 0, len(logEntries))
@@ -1799,7 +1663,7 @@ func (s *purchaseService) buildStaffAdjustmentPayload(
if *data.Qty <= 0 {
return nil, utils.BadRequest(fmt.Sprintf("Quantity for item %d must be greater than 0", item.Id))
}
if item.TotalUsed > 0 && *data.Qty < item.TotalUsed {
if item.TotalUsed > 0 && *data.Qty < item.TotalUsed && isReceivingBelowUsedBlocked(&item, nil) {
return nil, utils.BadRequest(fmt.Sprintf("Quantity for item %d cannot be lower than used amount (%.3f)", item.Id, item.TotalUsed))
}
if (item.TotalQty > 0 || item.TotalUsed > 0) && !syncReceiving {
@@ -1918,6 +1782,51 @@ func calculateTotalPrice(quantity float64, price float64, provided *float64, ref
return *provided, nil
}
func purchaseItemHasFlag(item *entity.PurchaseItem, flag utils.FlagType) bool {
if item == nil || item.Product == nil {
return false
}
target := utils.NormalizeFlag(string(flag))
for _, f := range item.Product.Flags {
if utils.NormalizeFlag(f.Name) == target {
return true
}
}
return false
}
func isReceivingBelowUsedBlocked(item *entity.PurchaseItem, lockedIDs map[uint]struct{}) bool {
if item == nil {
return false
}
if !purchaseItemHasAnyFlag(item, []utils.FlagType{
utils.FlagPullet,
utils.FlagLayer,
utils.FlagAyamAfkir,
utils.FlagAyamCulling,
utils.FlagAyamMati,
}) {
return false
}
if lockedIDs == nil {
return true
}
_, locked := lockedIDs[item.Id]
return locked
}
func purchaseItemHasAnyFlag(item *entity.PurchaseItem, flags []utils.FlagType) bool {
if item == nil || item.Product == nil || len(flags) == 0 {
return false
}
for _, flag := range flags {
if purchaseItemHasFlag(item, flag) {
return true
}
}
return false
}
func (s *purchaseService) attachLatestApproval(ctx context.Context, item *entity.Purchase) error {
if item == nil || item.Id == 0 || s.ApprovalSvc == nil {
return nil
@@ -2025,6 +1934,68 @@ func (s *purchaseService) applyTravelDocumentURLs(ctx context.Context, purchase
}
}
func collectPurchaseItemIDs(items []entity.PurchaseItem) []uint {
itemIDs := make([]uint, 0, len(items))
for i := range items {
if items[i].Id == 0 {
continue
}
itemIDs = append(itemIDs, items[i].Id)
}
return itemIDs
}
func (s *purchaseService) resolveChickinLockedItemIDs(ctx context.Context, db *gorm.DB, items []entity.PurchaseItem) (map[uint]struct{}, error) {
itemIDs := collectPurchaseItemIDs(items)
return s.resolveChickinLockedItemIDsByItemID(ctx, db, itemIDs)
}
func (s *purchaseService) resolveChickinLockedItemIDsByItemID(ctx context.Context, db *gorm.DB, itemIDs []uint) (map[uint]struct{}, error) {
locked := make(map[uint]struct{})
if len(itemIDs) == 0 {
return locked, nil
}
if db == nil {
return nil, errors.New("database is required")
}
var allocationLockedIDs []uint
if err := db.WithContext(ctx).
Model(&entity.StockAllocation{}).
Distinct("stockable_id").
Where("stockable_type = ? AND stockable_id IN ? AND usable_type = ? AND status IN ? AND allocation_purpose = ?",
fifo.StockableKeyPurchaseItems.String(),
itemIDs,
fifo.UsableKeyProjectChickin.String(),
[]string{entity.StockAllocationStatusActive, entity.StockAllocationStatusPending},
entity.StockAllocationPurposeConsume,
).
Pluck("stockable_id", &allocationLockedIDs).Error; err != nil {
return nil, err
}
for _, itemID := range allocationLockedIDs {
locked[itemID] = struct{}{}
}
var conversionLockedIDs []uint
if err := db.WithContext(ctx).
Table("purchase_items pi").
Distinct("pi.id").
Joins("JOIN project_chickins pc ON pc.product_warehouse_id = pi.product_warehouse_id AND pc.deleted_at IS NULL").
Joins("JOIN project_flock_populations pfp ON pfp.project_chickin_id = pc.id AND pfp.deleted_at IS NULL").
Where("pi.id IN ?", itemIDs).
Where("pi.project_flock_kandang_id IS NOT NULL").
Where("pc.project_flock_kandang_id = pi.project_flock_kandang_id").
Pluck("pi.id", &conversionLockedIDs).Error; err != nil {
return nil, err
}
for _, itemID := range conversionLockedIDs {
locked[itemID] = struct{}{}
}
return locked, nil
}
func collectPFKIDsFromPurchase(p *entity.Purchase) []uint {
seen := make(map[uint]struct{})
ids := make([]uint, 0)
@@ -147,15 +147,16 @@ func (r *hppPerKandangRepository) GetFeedOvkDocCostByPeriod(ctx context.Context,
Table("project_chickins AS pc").
Select(`
pfk.id AS project_flock_kandang_id,
COALESCE(SUM(pc.usage_qty * COALESCE(pi.price, 0)), 0) AS doc_cost,
COALESCE(SUM(pc.usage_qty), 0) AS doc_qty,
COALESCE(SUM(sa.qty * COALESCE(pi.price, 0)), 0) AS doc_cost,
COALESCE(SUM(sa.qty), 0) AS doc_qty,
s.id AS supplier_id,
s.name AS supplier_name,
s.alias AS supplier_alias`).
Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = pc.project_flock_kandang_id").
Joins("JOIN kandangs AS k ON k.id = pfk.kandang_id").
Joins("JOIN locations AS loc ON loc.id = k.location_id").
Joins("LEFT JOIN purchase_items AS pi ON pi.product_warehouse_id = pc.product_warehouse_id").
Joins("LEFT JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = pc.id AND sa.stockable_type = ? AND sa.status = ? AND sa.allocation_purpose = ?", fifo.UsableKeyProjectChickin.String(), fifo.StockableKeyPurchaseItems.String(), entity.StockAllocationStatusActive, entity.StockAllocationPurposeTraceChickin).
Joins("LEFT JOIN purchase_items AS pi ON pi.id = sa.stockable_id").
Joins("LEFT JOIN purchases AS pur ON pur.id = pi.purchase_id").
Joins("LEFT JOIN suppliers AS s ON s.id = pur.supplier_id").
Where("pc.project_flock_kandang_id IN ?", projectFlockKandangIDs).
@@ -221,13 +222,14 @@ func (r *hppPerKandangRepository) GetFeedOvkDocCostByPeriod(ctx context.Context,
Table("recordings AS r").
Select(`
r.project_flock_kandangs_id AS project_flock_kandang_id,
COALESCE(SUM(sa.qty * COALESCE(pi.price, 0)), 0) AS feed_cost,
s.id AS supplier_id,
s.name AS supplier_name,
s.alias AS supplier_alias`).
Joins("JOIN recording_stocks AS rs ON rs.recording_id = r.id").
Joins("JOIN product_warehouses AS pw ON pw.id = rs.product_warehouse_id").
Joins("JOIN flags AS f ON f.flagable_id = pw.product_id AND f.flagable_type = ?", entity.FlagableTypeProduct).
Joins("JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = rs.id AND sa.stockable_type = ?", fifo.UsableKeyRecordingStock.String(), fifo.StockableKeyPurchaseItems.String()).
Joins("JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = rs.id AND sa.stockable_type = ? AND sa.status = ? AND sa.allocation_purpose = ?", fifo.UsableKeyRecordingStock.String(), fifo.StockableKeyPurchaseItems.String(), entity.StockAllocationStatusActive, entity.StockAllocationPurposeConsume).
Joins("JOIN purchase_items AS pi ON pi.id = sa.stockable_id").
Joins("LEFT JOIN purchases AS pur ON pur.id = pi.purchase_id").
Joins("LEFT JOIN suppliers AS s ON s.id = pur.supplier_id").
+1 -1
View File
@@ -233,7 +233,7 @@ var adjustmentSubtypesByType = map[AdjustmentTransactionType][]string{
}
var hiddenAdjustmentSubtypesForFrontend = map[string]struct{}{
string(AdjustmentTransactionSubtypeRecordingDepletionIn): {},
string(AdjustmentTransactionSubtypeRecordingDepletionOut): {},
}
var adjustmentSubtypeToType = func() map[string]AdjustmentTransactionType {