Compare commits

...

30 Commits

Author SHA1 Message Date
Adnan Zahir bc3db38c81 dev: initiate adjustment recording and trf to laying 2026-02-27 15:45:37 +07:00
Hafizh A. Y. a2de21e351 Merge branch 'fix/refactor-adjustment' into 'dev/fifo-v2'
feat: refactor module adjustment stock, etc.

See merge request mbugroup/lti-api!338
2026-02-26 07:40:58 +00:00
Hafizh A. Y a8903b3598 feat: refactor module adjusment stock, adjust constant, adjust table migration and create command reflow and delete module adjusment stock 2026-02-26 14:37:54 +07:00
giovanni 0b35012413 implement fifo-v2 to transfer stock pakan 2026-02-23 11:05:39 +07:00
giovanni c3930ab555 Merge branch 'development' into dev/fifo-v2 2026-02-20 13:36:40 +07:00
Adnan Zahir 5e28721651 Merge branch 'fix/stock' into 'development'
[FIX][BE]: fix insert stock to stocklog

See merge request mbugroup/lti-api!330
2026-02-20 09:51:52 +07:00
giovanni 95547ad7c7 fix insert stock to stocklog 2026-02-19 16:00:48 +07:00
Hafizh A. Y. 3fd96834f9 Merge branch 'fix/BE/fifo-recording-and-closing-perhitungan-sapronak' into 'development'
[FEAT/BE] add coloumn usage_qty and change standart ensure product

See merge request mbugroup/lti-api!329
2026-02-18 09:02:33 +00:00
ragilap 3da05eea02 [FEAT/BE] add coloumn usage_qty and change standart ensure product 2026-02-18 16:01:20 +07:00
Hafizh A. Y. 1e788e46f5 Merge branch 'fix/BE/fifo-recording-and-closing-perhitungan-sapronak' into 'development'
[FEAT/BE] add product flags in stock

See merge request mbugroup/lti-api!328
2026-02-18 08:38:45 +00:00
ragilap e0d42fe6d3 [FEAT/BE] add product flags in stock 2026-02-18 15:30:59 +07:00
Hafizh A. Y. 28f57525f4 Merge branch 'fix/be/fifo-recording-and-closing-perhitungan-sapronak' into 'development'
Fix/be/fifo recording and closing perhitungan sapronak

See merge request mbugroup/lti-api!327
2026-02-18 08:12:09 +00:00
MacBook Air M1 ba6c9f61d2 add base fifo-v2 2026-02-18 14:32:41 +07:00
ragilap 62496f78a8 Merge branch 'development' of https://gitlab.com/mbugroup/lti-api into fix/be/fifo-recording-and-closing-perhitungan-sapronak 2026-02-18 11:49:38 +07:00
ragilap 36ba4f34bb [FEAT/BE] fixing fifo fallback recording,fixing backdate and fixing product category 2026-02-18 11:49:25 +07:00
Hafizh A. Y. 691573fbe5 Merge branch 'fix/closing-keuangan' into 'development'
[FIX][BE] Fix report closing keuangan duplicate ovk, and closing keuangan devided by last recording

See merge request mbugroup/lti-api!326
2026-02-18 04:37:36 +00:00
Hafizh A. Y 7f623c0c1f fix(BE): fix report closing keuangan duplicate ovk, and closing keuangan devided by last recording 2026-02-18 11:34:38 +07:00
Hafizh A. Y. ad93cbba7a Merge branch 'fix/BE/chickin-purchase-relation' into 'development'
Fix/be/change current stock usage product warehouse with pending stocks

See merge request mbugroup/lti-api!325
2026-02-17 08:32:17 +00:00
ragilap 756fc431b3 Merge branch 'development' of https://gitlab.com/mbugroup/lti-api into fix/BE/chickin-purchase-relation 2026-02-12 14:12:05 +07:00
ragilap 71ce855feb [FEAT/BE] change current stock with pending in product warehouse 2026-02-12 14:11:52 +07:00
Hafizh A. Y. 5705e39f53 Merge branch 'fix/BE/chickin-purchase-relation' into 'development'
[FEAT/BE] recording reject productwarehouse rolback

See merge request mbugroup/lti-api!324
2026-02-11 09:35:12 +00:00
ragilap 27c9c8cda7 Merge branch 'development' of https://gitlab.com/mbugroup/lti-api into fix/BE/chickin-purchase-relation 2026-02-11 10:10:21 +07:00
ragilap ad46f8aca0 [FEAT/BE] recording reject add 2026-02-11 09:57:51 +07:00
ragilap cad9328e5d [FEAT/BE] Add restrict purchase chickin 2026-02-10 15:21:48 +07:00
Hafizh A. Y. ac875a328e Merge branch 'feat/delivery-order-purchase-recordings' into 'development'
[FEAT/BE] Add saparator type search get all productwarehouse

See merge request mbugroup/lti-api!322
2026-02-10 03:47:04 +00:00
ragilap 8ad923a90a [FEAT/BE] Add saparator type search get all productwarehouse 2026-02-09 16:48:42 +07:00
Adnan Zahir b43979bbba Merge branch 'feat/delivery-order-purchase-recordings' into 'development'
Feat/delivery order purchase recordings

See merge request mbugroup/lti-api!315
2026-02-07 16:59:19 +07:00
ragilap 4340828fec [FEAT/BE] Add telur seeder and saparator category 2026-02-06 19:26:00 +07:00
ragilap f74b6476de [FEAT/BE] Add filter delivery order, adjust response purchase and fcr growing recording 2026-02-06 14:13:05 +07:00
ragilap 77ec805931 [FEAT/BE] Add filter delivery order and adjust response purchase 2026-02-06 01:02:03 +07:00
75 changed files with 7387 additions and 1326 deletions
+442
View File
@@ -0,0 +1,442 @@
package main
import (
"context"
"flag"
"fmt"
"log"
"os"
"sort"
"strconv"
"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"
)
type adjustmentRow struct {
ID uint `gorm:"column:id"`
ProductWarehouseID uint `gorm:"column:product_warehouse_id"`
ProductID uint `gorm:"column:product_id"`
FunctionCode string `gorm:"column:function_code"`
TotalQty float64 `gorm:"column:total_qty"`
UsageQty float64 `gorm:"column:usage_qty"`
PendingQty float64 `gorm:"column:pending_qty"`
StockLogIncrease float64 `gorm:"column:stock_log_increase"`
StockLogDecrease float64 `gorm:"column:stock_log_decrease"`
CreatedAt time.Time `gorm:"column:created_at"`
}
type routeResolution struct {
FlagGroupCode string `gorm:"column:flag_group_code"`
Lane string `gorm:"column:lane"`
FunctionCode string `gorm:"column:function_code"`
}
func main() {
var (
idsRaw string
apply bool
)
flag.StringVar(&idsRaw, "ids", "", "Comma-separated adjustment IDs (required), example: 1,2")
flag.BoolVar(&apply, "apply", false, "Apply delete. If false, run as dry-run")
flag.Parse()
ids, err := parseIDs(idsRaw)
if err != nil {
log.Fatalf("invalid --ids: %v", err)
}
if len(ids) == 0 {
log.Fatal("--ids is required")
}
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 {
log.Fatalf("failed to load adjustments: %v", err)
}
if len(adjustments) == 0 {
log.Fatal("no adjustments found for provided IDs")
}
sort.Slice(adjustments, func(i, j int) bool {
return adjustments[i].ID < adjustments[j].ID
})
fmt.Printf("Mode: %s\n", modeLabel(apply))
fmt.Printf("Adjustments loaded: %d\n\n", len(adjustments))
success := 0
failed := 0
skipped := 0
for _, adj := range adjustments {
if strings.TrimSpace(adj.FunctionCode) == "" {
fmt.Printf("SKIP adj=%d reason=function_code empty\n", adj.ID)
skipped++
continue
}
route, err := resolveRouteByFunctionCode(ctx, db, adj.ProductID, strings.ToUpper(strings.TrimSpace(adj.FunctionCode)))
if err != nil {
fmt.Printf("FAIL adj=%d error=resolve route: %v\n", adj.ID, err)
failed++
continue
}
switch route.Lane {
case "USABLE":
desiredQty := adj.UsageQty + adj.PendingQty
if desiredQty <= 0 && adj.StockLogDecrease > 0 {
desiredQty = adj.StockLogDecrease
}
activeAlloc, err := countActiveUsableAllocations(ctx, db, fifo.UsableKeyAdjustmentOut.String(), adj.ID)
if err != nil {
fmt.Printf("FAIL adj=%d error=count usable allocations: %v\n", adj.ID, err)
failed++
continue
}
fmt.Printf(
"PLAN adj=%d lane=USABLE function=%s usage=%.3f pending=%.3f active_alloc=%d action=reflow_to_zero+delete\n",
adj.ID,
route.FunctionCode,
adj.UsageQty,
adj.PendingQty,
activeAlloc,
)
if !apply {
skipped++
continue
}
err = db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
reflowReq := commonSvc.FifoStockV2ReflowRequest{
FlagGroupCode: route.FlagGroupCode,
ProductWarehouseID: adj.ProductWarehouseID,
Usable: commonSvc.FifoStockV2Ref{
ID: adj.ID,
LegacyTypeKey: fifo.UsableKeyAdjustmentOut.String(),
FunctionCode: route.FunctionCode,
},
DesiredQty: 0,
IdempotencyKey: fmt.Sprintf("delete-adjustment-usable-%d-%d", adj.ID, time.Now().UnixNano()),
Tx: tx,
}
if _, err := fifoStockV2Svc.Reflow(ctx, reflowReq); err != nil {
return fmt.Errorf("reflow usable to zero: %w", err)
}
if err := hardDeleteUsableAllocations(ctx, tx, fifo.UsableKeyAdjustmentOut.String(), adj.ID); err != nil {
return err
}
if err := hardDeleteAdjustmentStockLogs(ctx, tx, adj.ID); err != nil {
return err
}
if err := hardDeleteAdjustment(ctx, tx, adj.ID); err != nil {
return err
}
return nil
})
if err != nil {
fmt.Printf("FAIL adj=%d error=%v\n", adj.ID, err)
failed++
continue
}
fmt.Printf("DONE adj=%d deleted\n", adj.ID)
success++
case "STOCKABLE":
removeQty := adj.TotalQty
if removeQty <= 0 && adj.StockLogIncrease > 0 {
removeQty = adj.StockLogIncrease
}
activeAlloc, err := countActiveStockableAllocations(ctx, db, fifo.StockableKeyAdjustmentIn.String(), adj.ID)
if err != nil {
fmt.Printf("FAIL adj=%d error=count stockable allocations: %v\n", adj.ID, err)
failed++
continue
}
if activeAlloc > 0 {
fmt.Printf(
"FAIL adj=%d reason=stockable still allocated active_alloc=%d action=delete blocked\n",
adj.ID,
activeAlloc,
)
failed++
continue
}
fmt.Printf(
"PLAN adj=%d lane=STOCKABLE function=%s total=%.3f remove_qty=%.3f action=reverse_stock+delete\n",
adj.ID,
route.FunctionCode,
adj.TotalQty,
removeQty,
)
if !apply {
skipped++
continue
}
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 := hardDeleteStockableAllocations(ctx, tx, fifo.StockableKeyAdjustmentIn.String(), adj.ID); err != nil {
return err
}
if err := hardDeleteAdjustmentStockLogs(ctx, tx, adj.ID); err != nil {
return err
}
if err := hardDeleteAdjustment(ctx, tx, adj.ID); err != nil {
return err
}
return nil
})
if err != nil {
fmt.Printf("FAIL adj=%d error=%v\n", adj.ID, err)
failed++
continue
}
fmt.Printf("DONE adj=%d deleted\n", adj.ID)
success++
default:
fmt.Printf("SKIP adj=%d reason=unsupported lane=%s\n", adj.ID, route.Lane)
skipped++
}
}
fmt.Println()
fmt.Printf("Summary: success=%d failed=%d skipped=%d\n", success, failed, skipped)
if failed > 0 {
os.Exit(1)
}
}
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"
}
return "DRY-RUN"
}
func parseIDs(raw string) ([]uint, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil, nil
}
parts := strings.Split(raw, ",")
out := make([]uint, 0, len(parts))
seen := map[uint]struct{}{}
for _, part := range parts {
part = strings.TrimSpace(part)
if part == "" {
continue
}
v, err := strconv.ParseUint(part, 10, 64)
if err != nil {
return nil, fmt.Errorf("invalid id %q", part)
}
if v == 0 {
return nil, fmt.Errorf("id must be > 0: %q", part)
}
id := uint(v)
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
out = append(out, id)
}
return out, nil
}
func loadAdjustments(ctx context.Context, db *gorm.DB, ids []uint) ([]adjustmentRow, error) {
var rows []adjustmentRow
err := db.WithContext(ctx).
Table("adjustment_stocks a").
Select(`
a.id,
a.product_warehouse_id,
pw.product_id,
a.function_code,
COALESCE(a.total_qty, 0) AS total_qty,
COALESCE(a.usage_qty, 0) AS usage_qty,
COALESCE(a.pending_qty, 0) AS pending_qty,
COALESCE((
SELECT sl.increase
FROM stock_logs sl
WHERE sl.loggable_type = 'ADJUSTMENT'
AND sl.loggable_id = a.id
ORDER BY sl.id DESC
LIMIT 1
), 0) AS stock_log_increase,
COALESCE((
SELECT sl.decrease
FROM stock_logs sl
WHERE sl.loggable_type = 'ADJUSTMENT'
AND sl.loggable_id = a.id
ORDER BY sl.id DESC
LIMIT 1
), 0) AS stock_log_decrease,
a.created_at
`).
Joins("JOIN product_warehouses pw ON pw.id = a.product_warehouse_id").
Where("a.id IN ?", ids).
Find(&rows).Error
if err != nil {
return nil, err
}
return rows, nil
}
func resolveRouteByFunctionCode(ctx context.Context, db *gorm.DB, productID uint, functionCode string) (*routeResolution, error) {
var rows []routeResolution
err := db.WithContext(ctx).
Table("fifo_stock_v2_route_rules rr").
Select("rr.flag_group_code, rr.lane, rr.function_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.function_code = ?", functionCode).
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("CASE WHEN rr.source_table = 'adjustment_stocks' THEN 0 ELSE 1 END ASC").
Order("rr.id ASC").
Find(&rows).Error
if err != nil {
return nil, err
}
if len(rows) == 0 {
return nil, fmt.Errorf("no route found for product_id=%d function_code=%s", productID, functionCode)
}
selected := rows[0]
for _, row := range rows {
if row.Lane != selected.Lane {
return nil, fmt.Errorf("ambiguous lane for product_id=%d function_code=%s", productID, functionCode)
}
}
selected.FunctionCode = functionCode
return &selected, nil
}
func countActiveUsableAllocations(ctx context.Context, db *gorm.DB, usableType string, usableID uint) (int64, error) {
var count int64
err := db.WithContext(ctx).
Table("stock_allocations").
Where("usable_type = ? AND usable_id = ?", usableType, usableID).
Where("status = ?", entity.StockAllocationStatusActive).
Count(&count).Error
return count, err
}
func countActiveStockableAllocations(ctx context.Context, db *gorm.DB, stockableType string, stockableID uint) (int64, error) {
var count int64
err := db.WithContext(ctx).
Table("stock_allocations").
Where("stockable_type = ? AND stockable_id = ?", stockableType, stockableID).
Where("status = ?", entity.StockAllocationStatusActive).
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).
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).
Error
}
func hardDeleteAdjustmentStockLogs(ctx context.Context, tx *gorm.DB, adjustmentID uint) error {
return tx.WithContext(ctx).
Exec("DELETE FROM stock_logs WHERE loggable_type = ? AND loggable_id = ?", "ADJUSTMENT", adjustmentID).
Error
}
func hardDeleteAdjustment(ctx context.Context, tx *gorm.DB, adjustmentID uint) error {
return tx.WithContext(ctx).
Exec("DELETE FROM adjustment_stocks WHERE id = ?", adjustmentID).
Error
}
+343
View File
@@ -0,0 +1,343 @@
package main
import (
"context"
"flag"
"fmt"
"log"
"os"
"sort"
"strconv"
"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 adjustmentRow struct {
ID uint `gorm:"column:id"`
ProductWarehouseID uint `gorm:"column:product_warehouse_id"`
ProductID uint `gorm:"column:product_id"`
FunctionCode string `gorm:"column:function_code"`
UsageQty float64 `gorm:"column:usage_qty"`
PendingQty float64 `gorm:"column:pending_qty"`
StockLogIncrease float64 `gorm:"column:stock_log_increase"`
StockLogDecrease float64 `gorm:"column:stock_log_decrease"`
CreatedAt time.Time `gorm:"column:created_at"`
}
type routeResolution struct {
FlagGroupCode string `gorm:"column:flag_group_code"`
Lane string `gorm:"column:lane"`
FunctionCode string `gorm:"column:function_code"`
SourceTable string `gorm:"column:source_table"`
LegacyTypeKey string `gorm:"column:legacy_type_key"`
}
func main() {
var (
idsRaw string
apply bool
asOfCreatedAt bool
compensateMissingAlloc bool
)
flag.StringVar(&idsRaw, "ids", "", "Comma-separated adjustment IDs (required), example: 1,2")
flag.BoolVar(&apply, "apply", false, "Apply changes. If false, run as dry-run")
flag.BoolVar(&asOfCreatedAt, "as-of-created-at", true, "Use adjustment created_at as reflow AsOf boundary")
flag.BoolVar(&compensateMissingAlloc, "compensate-missing-alloc", true, "When active allocations are missing and usage_qty > 0, temporarily add back usage_qty before reflow")
flag.Parse()
ids, err := parseIDs(idsRaw)
if err != nil {
log.Fatalf("invalid --ids: %v", err)
}
if len(ids) == 0 {
log.Fatal("--ids is required")
}
ctx := context.Background()
db := database.Connect(config.DBHost, config.DBName)
fifoStockV2Svc := commonSvc.NewFifoStockV2Service(db, nil)
adjustments, err := loadAdjustments(ctx, db, ids)
if err != nil {
log.Fatalf("failed to load adjustments: %v", err)
}
if len(adjustments) == 0 {
log.Fatal("no adjustments found for provided IDs")
}
sort.Slice(adjustments, func(i, j int) bool {
return adjustments[i].ID < adjustments[j].ID
})
fmt.Printf("Mode: %s\n", modeLabel(apply))
fmt.Printf("Adjustments loaded: %d\n\n", len(adjustments))
success := 0
failed := 0
skipped := 0
for _, adj := range adjustments {
if strings.TrimSpace(adj.FunctionCode) == "" {
fmt.Printf("SKIP adj=%d reason=function_code empty\n", adj.ID)
skipped++
continue
}
route, err := resolveRouteByFunctionCode(ctx, db, adj.ProductID, strings.ToUpper(strings.TrimSpace(adj.FunctionCode)))
if err != nil {
fmt.Printf("FAIL adj=%d error=resolve route: %v\n", adj.ID, err)
failed++
continue
}
if route.Lane != "USABLE" {
fmt.Printf("SKIP adj=%d reason=lane=%s (not USABLE)\n", adj.ID, route.Lane)
skipped++
continue
}
desiredQty := adj.UsageQty + adj.PendingQty
desiredQtySource := "usage+pending"
if desiredQty <= 0 && adj.StockLogDecrease > 0 {
desiredQty = adj.StockLogDecrease
desiredQtySource = "stock_log.decrease"
}
if desiredQty <= 0 {
fmt.Printf(
"SKIP adj=%d reason=no usable qty (usage=%.3f pending=%.3f stock_log.decrease=%.3f)\n",
adj.ID,
adj.UsageQty,
adj.PendingQty,
adj.StockLogDecrease,
)
skipped++
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)
if err != nil {
fmt.Printf("FAIL adj=%d error=count allocations: %v\n", adj.ID, err)
failed++
continue
}
compensateQty := adj.UsageQty
if compensateQty <= 0 && desiredQtySource == "stock_log.decrease" {
compensateQty = adj.StockLogDecrease
}
shouldCompensate := compensateMissingAlloc && activeAllocationCount == 0 && compensateQty > 0
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 {
asOf := adj.CreatedAt
reflowReq.AsOf = &asOf
}
fmt.Printf(
"PLAN adj=%d pw=%d product=%d function=%s group=%s desired=%.3f source=%s active_alloc=%d compensate=%t\n",
adj.ID,
adj.ProductWarehouseID,
adj.ProductID,
route.FunctionCode,
route.FlagGroupCode,
desiredQty,
desiredQtySource,
activeAllocationCount,
shouldCompensate,
)
if !apply {
skipped++
continue
}
err = db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if shouldCompensate {
if err := tx.Table("product_warehouses").
Where("id = ?", adj.ProductWarehouseID).
Update("qty", gorm.Expr("COALESCE(qty,0) + ?", compensateQty)).Error; err != nil {
return fmt.Errorf("compensate product_warehouse qty: %w", err)
}
}
reflowReq.Tx = tx
res, err := fifoStockV2Svc.Reflow(ctx, reflowReq)
if err != nil {
return err
}
fmt.Printf(
"DONE adj=%d rollback=%.3f allocate=%.3f pending=%.3f\n",
adj.ID,
res.Rollback.ReleasedQty,
res.Allocate.AllocatedQty,
res.Allocate.PendingQty,
)
return nil
})
if err != nil {
fmt.Printf("FAIL adj=%d error=%v\n", adj.ID, err)
failed++
continue
}
success++
}
fmt.Println()
fmt.Printf("Summary: success=%d failed=%d skipped=%d\n", success, failed, skipped)
if failed > 0 {
os.Exit(1)
}
}
func modeLabel(apply bool) string {
if apply {
return "APPLY"
}
return "DRY-RUN"
}
func parseIDs(raw string) ([]uint, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil, nil
}
parts := strings.Split(raw, ",")
ids := make([]uint, 0, len(parts))
seen := map[uint]struct{}{}
for _, part := range parts {
part = strings.TrimSpace(part)
if part == "" {
continue
}
value, err := strconv.ParseUint(part, 10, 64)
if err != nil {
return nil, fmt.Errorf("invalid id %q", part)
}
if value == 0 {
return nil, fmt.Errorf("id must be > 0: %q", part)
}
id := uint(value)
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
ids = append(ids, id)
}
return ids, nil
}
func loadAdjustments(ctx context.Context, db *gorm.DB, ids []uint) ([]adjustmentRow, error) {
var rows []adjustmentRow
err := db.WithContext(ctx).
Table("adjustment_stocks a").
Select(`
a.id,
a.product_warehouse_id,
pw.product_id,
a.function_code,
COALESCE(a.usage_qty, 0) AS usage_qty,
COALESCE(a.pending_qty, 0) AS pending_qty,
COALESCE((
SELECT sl.increase
FROM stock_logs sl
WHERE sl.loggable_type = 'ADJUSTMENT'
AND sl.loggable_id = a.id
ORDER BY sl.id DESC
LIMIT 1
), 0) AS stock_log_increase,
COALESCE((
SELECT sl.decrease
FROM stock_logs sl
WHERE sl.loggable_type = 'ADJUSTMENT'
AND sl.loggable_id = a.id
ORDER BY sl.id DESC
LIMIT 1
), 0) AS stock_log_decrease,
a.created_at
`).
Joins("JOIN product_warehouses pw ON pw.id = a.product_warehouse_id").
Where("a.id IN ?", ids).
Find(&rows).Error
if err != nil {
return nil, err
}
return rows, nil
}
func resolveRouteByFunctionCode(ctx context.Context, db *gorm.DB, productID uint, functionCode string) (*routeResolution, error) {
var rows []routeResolution
err := db.WithContext(ctx).
Table("fifo_stock_v2_route_rules rr").
Select("rr.flag_group_code, rr.lane, rr.function_code, rr.source_table, rr.legacy_type_key").
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.function_code = ?", functionCode).
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("CASE WHEN rr.source_table = 'adjustment_stocks' THEN 0 ELSE 1 END ASC").
Order("rr.id ASC").
Find(&rows).Error
if err != nil {
return nil, err
}
if len(rows) == 0 {
return nil, fmt.Errorf("no route found for product_id=%d function_code=%s", productID, functionCode)
}
selected := rows[0]
for _, row := range rows {
if row.Lane != selected.Lane {
return nil, fmt.Errorf("ambiguous lane for product_id=%d function_code=%s", productID, functionCode)
}
}
selected.FunctionCode = functionCode
return &selected, nil
}
func countActiveAllocations(ctx context.Context, db *gorm.DB, usableType string, usableID uint) (int64, error) {
var count int64
err := db.WithContext(ctx).
Table("stock_allocations").
Where("usable_type = ? AND usable_id = ?", usableType, usableID).
Where("status = ?", entity.StockAllocationStatusActive).
Count(&count).Error
if err != nil {
return 0, err
}
return count, nil
}
@@ -139,12 +139,11 @@ func (r *HppRepositoryImpl) GetOvkUsageCost(ctx context.Context, projectFlockKan
Select("COALESCE(SUM(rs.usage_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 purchase_items AS pi ON pi.id = sa.stockable_id").
Where("r.project_flock_kandangs_id IN (?)", projectFlockKandangIDs).
Where("r.record_datetime <= ?", *date).
Where("f.name IN ?", flags).
Where("EXISTS (SELECT 1 FROM flags f WHERE f.flagable_id = pw.product_id AND f.flagable_type = ? AND f.name IN ?)", entity.FlagableTypeProduct, flags).
Scan(&total).Error
if err != nil {
return 0, err
@@ -298,6 +297,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
+27 -5
View File
@@ -26,6 +26,7 @@ type FifoService interface {
Consume(ctx context.Context, req StockConsumeRequest) (*StockConsumeResult, error)
ReleaseUsage(ctx context.Context, req StockReleaseRequest) error
AdjustStockableQuantity(ctx context.Context, req StockAdjustRequest) error
ResolvePending(ctx context.Context, req PendingResolveRequest) ([]PendingResolution, error)
}
type fifoService struct {
@@ -111,6 +112,11 @@ type PendingResolution struct {
Quantity float64
}
type PendingResolveRequest struct {
ProductWarehouseID uint
Tx *gorm.DB
}
type StockReplenishResult struct {
AddedQuantity float64
PendingResolved []PendingResolution
@@ -227,6 +233,23 @@ func (s *fifoService) Replenish(ctx context.Context, req StockReplenishRequest)
return result, nil
}
func (s *fifoService) ResolvePending(ctx context.Context, req PendingResolveRequest) ([]PendingResolution, error) {
if req.ProductWarehouseID == 0 {
return nil, errors.New("product warehouse id is required")
}
var resolved []PendingResolution
err := s.withTransaction(ctx, req.Tx, func(tx *gorm.DB) error {
var err error
resolved, err = s.resolvePendingForWarehouse(ctx, tx, req.ProductWarehouseID)
return err
})
if err != nil {
return nil, err
}
return resolved, nil
}
func (s *fifoService) Consume(ctx context.Context, req StockConsumeRequest) (*StockConsumeResult, error) {
if req.UsableID == 0 || strings.TrimSpace(req.UsableKey.String()) == "" {
return nil, errors.New("usable key and id are required")
@@ -849,8 +872,8 @@ func (s *fifoService) fetchPendingCandidates(ctx context.Context, tx *gorm.DB, p
if cfg.Columns.CreatedAt == cfg.Columns.ID {
var rows []struct {
ID uint
Pending float64
CreatedAt int64
Pending float64 `gorm:"column:pending_qty"`
CreatedAt int64 `gorm:"column:created_at"`
}
query := tx.Table(cfg.Table).
@@ -870,7 +893,6 @@ func (s *fifoService) fetchPendingCandidates(ctx context.Context, tx *gorm.DB, p
if err := query.Find(&rows).Error; err != nil {
return nil, err
}
for _, row := range rows {
if row.Pending <= 0 {
continue
@@ -886,8 +908,8 @@ func (s *fifoService) fetchPendingCandidates(ctx context.Context, tx *gorm.DB, p
} else {
var rows []struct {
ID uint
Pending float64
CreatedAt time.Time
Pending float64 `gorm:"column:pending_qty"`
CreatedAt time.Time `gorm:"column:created_at"`
}
query := tx.Table(cfg.Table).
@@ -0,0 +1,41 @@
package service
import (
"github.com/sirupsen/logrus"
fifoStockV2 "gitlab.com/mbugroup/lti-api.git/internal/common/service/fifo_stock_v2"
"gorm.io/gorm"
)
type FifoStockV2Service = fifoStockV2.Service
type FifoStockV2Lane = fifoStockV2.Lane
type FifoStockV2Ref = fifoStockV2.Ref
type FifoStockV2GatherRequest = fifoStockV2.GatherRequest
type FifoStockV2GatherRow = fifoStockV2.GatherRow
type FifoStockV2AllocateRequest = fifoStockV2.AllocateRequest
type FifoStockV2AllocateResult = fifoStockV2.AllocateResult
type FifoStockV2AllocationDetail = fifoStockV2.AllocationDetail
type FifoStockV2RollbackRequest = fifoStockV2.RollbackRequest
type FifoStockV2RollbackResult = fifoStockV2.RollbackResult
type FifoStockV2ReflowRequest = fifoStockV2.ReflowRequest
type FifoStockV2ReflowResult = fifoStockV2.ReflowResult
type FifoStockV2RecalculateRequest = fifoStockV2.RecalculateRequest
type FifoStockV2RecalculateResult = fifoStockV2.RecalculateResult
type FifoStockV2WarehouseDrift = fifoStockV2.WarehouseDrift
func NewFifoStockV2Service(db *gorm.DB, logger *logrus.Logger) FifoStockV2Service {
return fifoStockV2.NewService(db, logger)
}
@@ -0,0 +1,58 @@
# RFC Ringkas: FIFO Stock V2
## Tujuan
`fifo_stock_v2` adalah engine FIFO baru berbasis konfigurasi `Flag Group + Jalur` yang berjalan paralel dengan v1 tanpa memutus kompatibilitas `stock_allocations`, HPP, dan closing/reporting existing.
## Prinsip
- V1 tidak dihapus, V2 jalan paralel.
- Semua operasi transactional.
- FIFO sorting deterministic lintas tabel.
- Default over-consume `ALLOW` (pending), exception dapat `BLOCK`.
- Reflow idempotent.
- Recalculate bisa memperbaiki drift `product_warehouses.qty`.
## Komponen
- `fifo_stock_v2_flag_groups`: master grouping flag produk.
- `fifo_stock_v2_flag_members`: pemetaan flag -> group.
- `fifo_stock_v2_traits`: trait sort per `table:date_column` (+ optional join date source).
- `fifo_stock_v2_route_rules`: rule per `flag_group + lane + function + table`.
- `fifo_stock_v2_overconsume_rules`: policy pending/over-consume.
- `fifo_stock_v2_operation_log`: idempotency + audit operasi.
- `fifo_stock_v2_reflow_runs` + checkpoints + shadow allocations: bulk reflow resumable/observable.
## API Service
- `Gather`: union cross-table berdasarkan route rules + trait sorting.
- `Allocate`: alokasi lot FIFO ke usable.
- `Rollback`: batalkan alokasi aktif.
- `Reflow`: rollback penuh lalu allocate ulang (idempotent).
- `Recalculate`: rekonsiliasi qty warehouse dari ledger FIFO.
## Deterministic Sorting
Urutan gather:
1. `sort_at ASC` (dari trait `date_column`)
2. `sort_priority ASC`
3. `source_table ASC`
4. `source_id ASC`
Fallback waktu: `1970-01-01 00:00:00+00` bila tanggal null.
## Compat Strategy
- Tetap menulis ke `stock_allocations` dengan tambahan metadata:
- `engine_version` (`v1`/`v2`)
- `flag_group_code`
- `function_code`
- `idempotency_key`
- Query lama yang bergantung `stockable_type/usable_type` tetap berjalan.
## Migration Strategy
1. Deploy schema + seed v2.
2. Aktifkan shadow-run comparator v1 vs v2.
3. Canary cutover per flag group.
4. Full cutover jika parity aman.
5. Jalankan bulk reflow existing data.
## Acceptance Criteria Singkat
- Parity mismatch terkendali pada aggregate + detail alokasi.
- Tidak ada regression closing/HPP.
- Drift qty warehouse turun signifikan pasca reflow.
- Rollback via feature flag memungkinkan kembali ke v1.
@@ -0,0 +1,660 @@
package fifo_stock_v2
import (
"context"
"encoding/json"
"errors"
"fmt"
"math"
"strings"
"time"
"gorm.io/gorm"
)
type allocationRow struct {
ID uint `gorm:"column:id"`
ProductWarehouseID uint `gorm:"column:product_warehouse_id"`
StockableType string `gorm:"column:stockable_type"`
StockableID uint `gorm:"column:stockable_id"`
UsableType string `gorm:"column:usable_type"`
UsableID uint `gorm:"column:usable_id"`
Qty float64 `gorm:"column:qty"`
Status string `gorm:"column:status"`
CreatedAt time.Time `gorm:"column:created_at"`
}
type usableQtySnapshot struct {
Usage float64 `gorm:"column:usage_qty"`
Pending float64 `gorm:"column:pending_qty"`
}
func (s *fifoStockV2Service) Allocate(ctx context.Context, req AllocateRequest) (*AllocateResult, error) {
if err := s.validateAllocateRequest(req); err != nil {
return nil, err
}
result := &AllocateResult{}
err := s.withTransaction(ctx, req.Tx, func(tx *gorm.DB) error {
if err := s.ensureStockAllocationColumns(tx); err != nil {
return err
}
if err := s.lockShard(tx, req.FlagGroupCode, req.ProductWarehouseID); err != nil {
return err
}
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,
"need_qty": req.NeedQty,
"as_of": req.AsOf,
"allow_over_consume": req.AllowOverConsume,
})
logRow, reused, err := s.beginOperation(
tx,
OperationAllocate,
req.IdempotencyKey,
hash,
req.ProductWarehouseID,
req.FlagGroupCode,
req.Usable.LegacyTypeKey,
req.Usable.ID,
)
if err != nil {
return err
}
if reused {
if len(logRow.ResultPayload) == 0 {
return fmt.Errorf("idempotent allocate has empty payload")
}
if err := json.Unmarshal(logRow.ResultPayload, result); err != nil {
return err
}
return nil
}
if logRow != nil {
defer func() {
if err != nil {
s.failOperation(tx, logRow, err)
}
}()
}
allocated, allocErr := s.allocateInternal(ctx, tx, req)
if allocErr != nil {
err = allocErr
return allocErr
}
*result = *allocated
if finishErr := s.finishOperation(tx, logRow, result); finishErr != nil {
err = finishErr
return finishErr
}
return nil
})
if err != nil {
return nil, err
}
return result, nil
}
func (s *fifoStockV2Service) allocateInternal(ctx context.Context, tx *gorm.DB, req AllocateRequest) (*AllocateResult, error) {
usableRule, err := s.loadRouteRuleByLegacyType(ctx, tx, LaneUsable, req.FlagGroupCode, req.Usable.LegacyTypeKey)
if err != nil {
return nil, err
}
allowOverConsume := usableRule.AllowPendingDefault
if req.AllowOverConsume != nil {
allowOverConsume = *req.AllowOverConsume
} else {
allowOverConsume, err = s.resolveOverConsume(tx, req.FlagGroupCode, req.Usable.FunctionCode, LaneUsable, allowOverConsume)
if err != nil {
return nil, err
}
}
gatherRows, err := s.gatherRows(ctx, tx, GatherRequest{
FlagGroupCode: req.FlagGroupCode,
Lane: LaneStockable,
ProductWarehouseID: req.ProductWarehouseID,
AsOf: req.AsOf,
Limit: s.defaultGatherLimit,
})
if err != nil {
return nil, err
}
stockableRuleMap, err := s.loadStockableRuleMap(ctx, tx, req.FlagGroupCode)
if err != nil {
return nil, err
}
now := time.Now()
remaining := req.NeedQty
result := &AllocateResult{Details: make([]AllocationDetail, 0)}
for _, lot := range gatherRows {
if remaining <= 0 {
break
}
if lot.AvailableQuantity <= 0 {
continue
}
portion := math.Min(remaining, lot.AvailableQuantity)
if nearlyZero(portion) {
continue
}
allocationInsert := map[string]any{
"product_warehouse_id": req.ProductWarehouseID,
"stockable_type": lot.Ref.LegacyTypeKey,
"stockable_id": lot.Ref.ID,
"usable_type": req.Usable.LegacyTypeKey,
"usable_id": req.Usable.ID,
"qty": portion,
"status": activeAllocationStatus(),
"created_at": now,
"updated_at": now,
"engine_version": "v2",
"flag_group_code": req.FlagGroupCode,
"function_code": req.Usable.FunctionCode,
}
if strings.TrimSpace(req.IdempotencyKey) != "" {
allocationInsert["idempotency_key"] = req.IdempotencyKey
}
if err := tx.Table("stock_allocations").Create(allocationInsert).Error; err != nil {
return nil, err
}
rule, ok := stockableRuleMap[lot.Ref.LegacyTypeKey]
if !ok {
return nil, fmt.Errorf("missing stockable route rule for type %s", lot.Ref.LegacyTypeKey)
}
if err := s.adjustStockableUsedQuantity(tx, rule, lot.Ref.ID, portion); err != nil {
return nil, err
}
result.Details = append(result.Details, AllocationDetail{
StockableType: lot.Ref.LegacyTypeKey,
StockableID: lot.Ref.ID,
Qty: portion,
SortAt: lot.SortAt,
})
remaining -= portion
result.AllocatedQty += portion
}
if remaining > 0 {
if !allowOverConsume {
return nil, fmt.Errorf("%w: requested %.3f, allocated %.3f", ErrInsufficientStock, req.NeedQty, result.AllocatedQty)
}
result.PendingQty = remaining
}
if err := s.applyUsableDeltas(tx, *usableRule, req.Usable.ID, result.AllocatedQty, result.PendingQty); err != nil {
return nil, err
}
if err := s.adjustProductWarehouseQty(tx, req.ProductWarehouseID, -result.AllocatedQty); err != nil {
return nil, err
}
return result, nil
}
func (s *fifoStockV2Service) Rollback(ctx context.Context, req RollbackRequest) (*RollbackResult, error) {
if err := s.validateRollbackRequest(req); err != nil {
return nil, err
}
result := &RollbackResult{}
err := s.withTransaction(ctx, req.Tx, func(tx *gorm.DB) error {
if err := s.ensureStockAllocationColumns(tx); err != nil {
return err
}
flagGroupCode, err := s.resolveRollbackFlagGroup(ctx, tx, req)
if err != nil {
return err
}
if err := s.lockShard(tx, flagGroupCode, req.ProductWarehouseID); err != nil {
return err
}
hash := requestHash(map[string]any{
"product_warehouse_id": req.ProductWarehouseID,
"usable_type": req.Usable.LegacyTypeKey,
"usable_id": req.Usable.ID,
"release_qty": req.ReleaseQty,
"reason": req.Reason,
"flag_group_code": flagGroupCode,
})
logRow, reused, beginErr := s.beginOperation(
tx,
OperationRollback,
req.IdempotencyKey,
hash,
req.ProductWarehouseID,
flagGroupCode,
req.Usable.LegacyTypeKey,
req.Usable.ID,
)
if beginErr != nil {
return beginErr
}
if reused {
if len(logRow.ResultPayload) == 0 {
return fmt.Errorf("idempotent rollback has empty payload")
}
if err := json.Unmarshal(logRow.ResultPayload, result); err != nil {
return err
}
return nil
}
if logRow != nil {
defer func() {
if err != nil {
s.failOperation(tx, logRow, err)
}
}()
}
rolled, rollbackErr := s.rollbackInternal(ctx, tx, req, flagGroupCode)
if rollbackErr != nil {
err = rollbackErr
return rollbackErr
}
*result = *rolled
if finishErr := s.finishOperation(tx, logRow, result); finishErr != nil {
err = finishErr
return finishErr
}
return nil
})
if err != nil {
return nil, err
}
return result, nil
}
func (s *fifoStockV2Service) rollbackInternal(
ctx context.Context,
tx *gorm.DB,
req RollbackRequest,
flagGroupCode string,
) (*RollbackResult, error) {
usableRule, err := s.loadRouteRuleByLegacyType(ctx, tx, LaneUsable, flagGroupCode, req.Usable.LegacyTypeKey)
if err != nil {
return nil, err
}
allocations, err := s.loadActiveAllocations(tx, req.Usable.LegacyTypeKey, req.Usable.ID, req.ProductWarehouseID)
if err != nil {
return nil, err
}
if len(allocations) == 0 {
if req.ReleaseQty == nil {
if err := s.resetUsableQuantities(tx, *usableRule, req.Usable.ID); err != nil {
return nil, err
}
}
return &RollbackResult{}, nil
}
stockableRuleMap, err := s.loadStockableRuleMap(ctx, tx, flagGroupCode)
if err != nil {
return nil, err
}
target := 0.0
for _, alloc := range allocations {
target += alloc.Qty
}
if req.ReleaseQty != nil {
if *req.ReleaseQty < 0 {
return nil, fmt.Errorf("%w: release qty must be >= 0", ErrInvalidRequest)
}
target = *req.ReleaseQty
}
if nearlyZero(target) {
return &RollbackResult{}, nil
}
result := &RollbackResult{Details: make([]AllocationDetail, 0)}
now := time.Now()
remaining := target
for _, alloc := range allocations {
if remaining <= 0 {
break
}
portion := math.Min(remaining, alloc.Qty)
if nearlyZero(portion) {
continue
}
if nearlyZero(alloc.Qty - portion) {
updates := map[string]any{
"status": releasedAllocationStatus(),
"released_at": now,
"updated_at": now,
}
if strings.TrimSpace(req.Reason) != "" {
updates["note"] = req.Reason
}
if err := tx.Table("stock_allocations").Where("id = ?", alloc.ID).Updates(updates).Error; err != nil {
return nil, err
}
} else {
if err := tx.Table("stock_allocations").
Where("id = ?", alloc.ID).
Updates(map[string]any{
"qty": alloc.Qty - portion,
"updated_at": now,
}).Error; err != nil {
return nil, err
}
}
stockableRule, ok := stockableRuleMap[alloc.StockableType]
if !ok {
return nil, fmt.Errorf("missing stockable route rule for type %s", alloc.StockableType)
}
if err := s.adjustStockableUsedQuantity(tx, stockableRule, alloc.StockableID, -portion); err != nil {
return nil, err
}
result.ReleasedQty += portion
remaining -= portion
result.Details = append(result.Details, AllocationDetail{
StockableType: alloc.StockableType,
StockableID: alloc.StockableID,
Qty: portion,
SortAt: alloc.CreatedAt,
})
}
if req.ReleaseQty != nil && remaining > 1e-6 {
return nil, fmt.Errorf("unable to release %.3f; only %.3f allocation exists", target, result.ReleasedQty)
}
if req.ReleaseQty == nil {
if err := s.resetUsableQuantities(tx, *usableRule, req.Usable.ID); err != nil {
return nil, err
}
} else {
if err := s.applyUsableDeltas(tx, *usableRule, req.Usable.ID, -result.ReleasedQty, 0); err != nil {
return nil, err
}
}
if err := s.adjustProductWarehouseQty(tx, req.ProductWarehouseID, result.ReleasedQty); err != nil {
return nil, err
}
return result, nil
}
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) == "" {
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 {
if err := s.ensureStockAllocationColumns(tx); err != nil {
return err
}
if err := s.lockShard(tx, req.FlagGroupCode, req.ProductWarehouseID); err != nil {
return err
}
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,
OperationReflow,
req.IdempotencyKey,
hash,
req.ProductWarehouseID,
req.FlagGroupCode,
req.Usable.LegacyTypeKey,
req.Usable.ID,
)
if err != nil {
return err
}
if reused {
if len(logRow.ResultPayload) == 0 {
return fmt.Errorf("idempotent reflow has empty payload")
}
if err := json.Unmarshal(logRow.ResultPayload, result); err != nil {
return err
}
return nil
}
if logRow != nil {
defer func() {
if err != nil {
s.failOperation(tx, logRow, err)
}
}()
}
rollbackRes, rollbackErr := s.rollbackInternal(ctx, tx, RollbackRequest{
ProductWarehouseID: req.ProductWarehouseID,
Usable: req.Usable,
ReleaseQty: nil,
Reason: "reflow reset",
}, req.FlagGroupCode)
if rollbackErr != nil {
err = rollbackErr
return rollbackErr
}
result.Rollback = *rollbackRes
if req.DesiredQty > 0 {
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,
})
if allocateErr != nil {
err = allocateErr
return allocateErr
}
result.Allocate = *allocateRes
}
if finishErr := s.finishOperation(tx, logRow, result); finishErr != nil {
err = finishErr
return finishErr
}
return nil
})
if err != nil {
return nil, err
}
return result, nil
}
func (s *fifoStockV2Service) loadActiveAllocations(
tx *gorm.DB,
usableType string,
usableID uint,
productWarehouseID uint,
) ([]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())
if productWarehouseID > 0 {
query = query.Where("product_warehouse_id = ?", productWarehouseID)
}
query = query.Order("created_at DESC, id DESC")
var rows []allocationRow
if err := query.Find(&rows).Error; err != nil {
return nil, err
}
return rows, nil
}
func (s *fifoStockV2Service) loadStockableRuleMap(ctx context.Context, tx *gorm.DB, flagGroupCode string) (map[string]routeRule, error) {
rules, err := s.loadRouteRules(ctx, tx, flagGroupCode, LaneStockable)
if err != nil {
return nil, err
}
m := make(map[string]routeRule, len(rules))
for _, rule := range rules {
m[rule.LegacyTypeKey] = rule
}
return m, nil
}
func (s *fifoStockV2Service) adjustStockableUsedQuantity(tx *gorm.DB, rule routeRule, sourceID uint, delta float64) error {
if nearlyZero(delta) || sourceID == 0 {
return nil
}
if rule.UsedQuantityCol == nil || strings.TrimSpace(*rule.UsedQuantityCol) == "" {
return nil
}
usedCol, _ := mustSafeIdentifier(*rule.UsedQuantityCol)
sourceIDCol, _ := mustSafeIdentifier(rule.SourceIDColumn)
sourceTable, _ := mustSafeIdentifier(rule.SourceTable)
expr := fmt.Sprintf("GREATEST(0, COALESCE(%s,0) + ?)", usedCol)
return tx.Table(sourceTable).
Where(fmt.Sprintf("%s = ?", sourceIDCol), sourceID).
Update(usedCol, gorm.Expr(expr, delta)).Error
}
func (s *fifoStockV2Service) applyUsableDeltas(tx *gorm.DB, rule routeRule, sourceID uint, usageDelta, pendingDelta float64) error {
if sourceID == 0 || (nearlyZero(usageDelta) && nearlyZero(pendingDelta)) {
return nil
}
sourceTable, _ := mustSafeIdentifier(rule.SourceTable)
sourceIDCol, _ := mustSafeIdentifier(rule.SourceIDColumn)
usageCol, _ := mustSafeIdentifier(rule.QuantityCol)
updates := map[string]any{}
if !nearlyZero(usageDelta) {
expr := fmt.Sprintf("GREATEST(0, COALESCE(%s,0) + ?)", usageCol)
updates[usageCol] = gorm.Expr(expr, usageDelta)
}
if rule.PendingQuantityCol != nil && strings.TrimSpace(*rule.PendingQuantityCol) != "" && !nearlyZero(pendingDelta) {
pendingCol, _ := mustSafeIdentifier(*rule.PendingQuantityCol)
expr := fmt.Sprintf("GREATEST(0, COALESCE(%s,0) + ?)", pendingCol)
updates[pendingCol] = gorm.Expr(expr, pendingDelta)
}
if len(updates) == 0 {
return nil
}
return tx.Table(sourceTable).
Where(fmt.Sprintf("%s = ?", sourceIDCol), sourceID).
Updates(updates).Error
}
func (s *fifoStockV2Service) resetUsableQuantities(tx *gorm.DB, rule routeRule, sourceID uint) error {
if sourceID == 0 {
return nil
}
sourceTable, _ := mustSafeIdentifier(rule.SourceTable)
sourceIDCol, _ := mustSafeIdentifier(rule.SourceIDColumn)
usageCol, _ := mustSafeIdentifier(rule.QuantityCol)
updates := map[string]any{usageCol: 0}
if rule.PendingQuantityCol != nil && strings.TrimSpace(*rule.PendingQuantityCol) != "" {
pendingCol, _ := mustSafeIdentifier(*rule.PendingQuantityCol)
updates[pendingCol] = 0
}
return tx.Table(sourceTable).
Where(fmt.Sprintf("%s = ?", sourceIDCol), sourceID).
Updates(updates).Error
}
func (s *fifoStockV2Service) resolveRollbackFlagGroup(ctx context.Context, tx *gorm.DB, req RollbackRequest) (string, error) {
type row struct {
FlagGroupCode string `gorm:"column:flag_group_code"`
}
var latest row
err := tx.WithContext(ctx).
Table("stock_allocations").
Select("flag_group_code").
Where("usable_type = ? AND usable_id = ?", req.Usable.LegacyTypeKey, req.Usable.ID).
Where("engine_version = 'v2'").
Where("flag_group_code IS NOT NULL AND flag_group_code <> ''").
Order("id DESC").
Limit(1).
Take(&latest).Error
if err == nil && strings.TrimSpace(latest.FlagGroupCode) != "" {
return latest.FlagGroupCode, nil
}
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return "", err
}
var rules []routeRule
err = tx.WithContext(ctx).
Table("fifo_stock_v2_route_rules").
Where("is_active = TRUE").
Where("lane = ?", string(LaneUsable)).
Where("legacy_type_key = ?", req.Usable.LegacyTypeKey).
Find(&rules).Error
if err != nil {
return "", err
}
if len(rules) == 0 {
return "", fmt.Errorf("cannot resolve flag group for usable type %s", req.Usable.LegacyTypeKey)
}
if len(rules) > 1 {
return "", fmt.Errorf("ambiguous rollback flag group for usable type %s", req.Usable.LegacyTypeKey)
}
return rules[0].FlagGroupCode, nil
}
func (s *fifoStockV2Service) validateAllocateRequest(req AllocateRequest) error {
if strings.TrimSpace(req.FlagGroupCode) == "" || req.ProductWarehouseID == 0 {
return fmt.Errorf("%w: missing flag group or product warehouse", ErrInvalidRequest)
}
if req.Usable.ID == 0 || strings.TrimSpace(req.Usable.LegacyTypeKey) == "" {
return fmt.Errorf("%w: usable id and type are required", ErrInvalidRequest)
}
if req.NeedQty < 0 {
return fmt.Errorf("%w: need qty must be >= 0", ErrInvalidRequest)
}
return nil
}
func (s *fifoStockV2Service) validateRollbackRequest(req RollbackRequest) error {
if req.ProductWarehouseID == 0 {
return fmt.Errorf("%w: product warehouse is required", ErrInvalidRequest)
}
if req.Usable.ID == 0 || strings.TrimSpace(req.Usable.LegacyTypeKey) == "" {
return fmt.Errorf("%w: usable id and type are required", ErrInvalidRequest)
}
if req.ReleaseQty != nil && *req.ReleaseQty < 0 {
return fmt.Errorf("%w: release qty must be >= 0", ErrInvalidRequest)
}
return nil
}
@@ -0,0 +1,170 @@
package fifo_stock_v2
import (
"context"
"fmt"
"strings"
"gorm.io/gorm"
)
type routeRule struct {
ID uint `gorm:"column:id"`
FlagGroupCode string `gorm:"column:flag_group_code"`
Lane string `gorm:"column:lane"`
FunctionCode string `gorm:"column:function_code"`
SourceTable string `gorm:"column:source_table"`
SourceIDColumn string `gorm:"column:source_id_column"`
ProductWarehouseCol string `gorm:"column:product_warehouse_col"`
QuantityCol string `gorm:"column:quantity_col"`
UsedQuantityCol *string `gorm:"column:used_quantity_col"`
PendingQuantityCol *string `gorm:"column:pending_quantity_col"`
ScopeSQL *string `gorm:"column:scope_sql"`
LegacyTypeKey string `gorm:"column:legacy_type_key"`
AllowPendingDefault bool `gorm:"column:allow_pending_default"`
}
type traitRule struct {
ID uint `gorm:"column:id"`
SourceTable string `gorm:"column:source_table"`
Lane string `gorm:"column:lane"`
DateTable *string `gorm:"column:date_table"`
DateJoinLeftCol *string `gorm:"column:date_join_left_col"`
DateJoinRightCol *string `gorm:"column:date_join_right_col"`
DateColumn string `gorm:"column:date_column"`
FallbackDateColumn *string `gorm:"column:fallback_date_column"`
SortPriority int `gorm:"column:sort_priority"`
IDColumn string `gorm:"column:id_column"`
}
func (s *fifoStockV2Service) loadRouteRules(ctx context.Context, tx *gorm.DB, flagGroupCode string, lane Lane) ([]routeRule, error) {
var rules []routeRule
err := tx.WithContext(ctx).
Table("fifo_stock_v2_route_rules").
Where("is_active = TRUE").
Where("flag_group_code = ?", flagGroupCode).
Where("lane = ?", string(lane)).
Order("id ASC").
Find(&rules).Error
if err != nil {
return nil, err
}
for _, rule := range rules {
if err := validateRouteRule(rule); err != nil {
return nil, err
}
}
return rules, nil
}
func (s *fifoStockV2Service) loadRouteRuleByLegacyType(
ctx context.Context,
tx *gorm.DB,
lane Lane,
flagGroupCode string,
legacyTypeKey string,
) (*routeRule, error) {
var rule routeRule
err := tx.WithContext(ctx).
Table("fifo_stock_v2_route_rules").
Where("is_active = TRUE").
Where("lane = ?", string(lane)).
Where("flag_group_code = ?", flagGroupCode).
Where("legacy_type_key = ?", legacyTypeKey).
Order("id ASC").
Limit(1).
Take(&rule).Error
if err != nil {
return nil, err
}
if err := validateRouteRule(rule); err != nil {
return nil, err
}
return &rule, nil
}
func (s *fifoStockV2Service) loadTraitMap(
ctx context.Context,
tx *gorm.DB,
lane Lane,
sourceTables []string,
) (map[string]traitRule, error) {
if len(sourceTables) == 0 {
return map[string]traitRule{}, nil
}
var traits []traitRule
err := tx.WithContext(ctx).
Table("fifo_stock_v2_traits").
Where("is_active = TRUE").
Where("lane = ?", string(lane)).
Where("source_table IN ?", sourceTables).
Find(&traits).Error
if err != nil {
return nil, err
}
out := make(map[string]traitRule, len(traits))
for _, tr := range traits {
if err := validateTraitRule(tr); err != nil {
return nil, err
}
out[tr.SourceTable] = tr
}
return out, nil
}
func validateRouteRule(rule routeRule) error {
fields := []string{rule.SourceTable, rule.SourceIDColumn, rule.ProductWarehouseCol, rule.QuantityCol}
for _, value := range fields {
if _, err := mustSafeIdentifier(value); err != nil {
return err
}
}
if rule.UsedQuantityCol != nil {
if _, err := mustSafeIdentifier(*rule.UsedQuantityCol); err != nil {
return err
}
}
if rule.PendingQuantityCol != nil {
if _, err := mustSafeIdentifier(*rule.PendingQuantityCol); err != nil {
return err
}
}
if strings.TrimSpace(rule.LegacyTypeKey) == "" {
return fmt.Errorf("route rule has empty legacy type key")
}
return nil
}
func validateTraitRule(rule traitRule) error {
if _, err := mustSafeIdentifier(rule.SourceTable); err != nil {
return err
}
if _, err := mustSafeIdentifier(rule.DateColumn); err != nil {
return err
}
if _, err := mustSafeIdentifier(rule.IDColumn); err != nil {
return err
}
if rule.DateTable != nil {
if _, err := mustSafeIdentifier(*rule.DateTable); err != nil {
return err
}
if rule.DateJoinLeftCol == nil || rule.DateJoinRightCol == nil {
return fmt.Errorf("trait %s requires date join columns", rule.SourceTable)
}
if _, err := mustSafeIdentifier(*rule.DateJoinLeftCol); err != nil {
return err
}
if _, err := mustSafeIdentifier(*rule.DateJoinRightCol); err != nil {
return err
}
}
if rule.FallbackDateColumn != nil {
if _, err := mustSafeIdentifier(*rule.FallbackDateColumn); err != nil {
return err
}
}
return nil
}
@@ -0,0 +1,8 @@
package fifo_stock_v2
import "errors"
var (
ErrInvalidRequest = errors.New("invalid fifo stock v2 request")
ErrInsufficientStock = errors.New("insufficient stock")
)
@@ -0,0 +1,268 @@
package fifo_stock_v2
import (
"context"
"fmt"
"strings"
"time"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
"gorm.io/gorm"
)
type gatherSQLRow struct {
SourceTable string `gorm:"column:source_table"`
LegacyTypeKey string `gorm:"column:legacy_type_key"`
FunctionCode string `gorm:"column:function_code"`
SourceID uint `gorm:"column:source_id"`
ProductWarehouseID uint `gorm:"column:product_warehouse_id"`
SortAt time.Time `gorm:"column:sort_at"`
SortPriority int `gorm:"column:sort_priority"`
Quantity float64 `gorm:"column:quantity"`
UsedQuantity float64 `gorm:"column:used_quantity"`
PendingQuantity float64 `gorm:"column:pending_quantity"`
AvailableQuantity float64 `gorm:"column:available_quantity"`
}
func (s *fifoStockV2Service) Gather(ctx context.Context, req GatherRequest) ([]GatherRow, error) {
if strings.TrimSpace(req.FlagGroupCode) == "" || req.ProductWarehouseID == 0 {
return nil, fmt.Errorf("%w: flag group and product warehouse are required", ErrInvalidRequest)
}
if req.Lane != LaneStockable && req.Lane != LaneUsable {
return nil, fmt.Errorf("%w: unsupported lane %q", ErrInvalidRequest, req.Lane)
}
var out []GatherRow
err := s.withTransaction(ctx, req.Tx, func(tx *gorm.DB) error {
rows, err := s.gatherRows(ctx, tx, req)
if err != nil {
return err
}
out = rows
return nil
})
if err != nil {
return nil, err
}
return out, nil
}
func (s *fifoStockV2Service) gatherRows(ctx context.Context, tx *gorm.DB, req GatherRequest) ([]GatherRow, error) {
rules, err := s.loadRouteRules(ctx, tx, req.FlagGroupCode, req.Lane)
if err != nil {
return nil, err
}
if len(rules) == 0 {
return []GatherRow{}, nil
}
tables := make([]string, 0, len(rules))
for _, rule := range rules {
tables = append(tables, rule.SourceTable)
}
traits, err := s.loadTraitMap(ctx, tx, req.Lane, tables)
if err != nil {
return nil, err
}
subqueries := make([]string, 0, len(rules))
args := make([]any, 0, len(rules)*10)
for _, rule := range rules {
trait, ok := traits[rule.SourceTable]
if !ok {
return nil, fmt.Errorf("missing trait for table %s lane %s", rule.SourceTable, req.Lane)
}
subSQL, subArgs, err := s.buildGatherSubquery(rule, trait, req)
if err != nil {
return nil, err
}
subqueries = append(subqueries, subSQL)
args = append(args, subArgs...)
}
if len(subqueries) == 0 {
return []GatherRow{}, nil
}
limit := req.Limit
if limit <= 0 {
limit = s.defaultGatherLimit
}
if limit <= 0 {
limit = 1000
}
query := "SELECT * FROM (" + strings.Join(subqueries, " UNION ALL ") + ") AS g"
if req.AfterSortAt != nil {
query += `
WHERE
(g.sort_at > ?)
OR (g.sort_at = ? AND g.source_table > ?)
OR (g.sort_at = ? AND g.source_table = ? AND g.source_id > ?)
`
args = append(args,
*req.AfterSortAt,
*req.AfterSortAt, req.AfterSourceTable,
*req.AfterSortAt, req.AfterSourceTable, req.AfterSourceID,
)
}
query += " ORDER BY g.sort_at ASC, g.sort_priority ASC, g.source_table ASC, g.source_id ASC LIMIT ?"
args = append(args, limit)
var rows []gatherSQLRow
if err := tx.WithContext(ctx).Raw(query, args...).Scan(&rows).Error; err != nil {
return nil, err
}
out := make([]GatherRow, 0, len(rows))
for _, row := range rows {
out = append(out, GatherRow{
Ref: Ref{
Table: row.SourceTable,
ID: row.SourceID,
LegacyTypeKey: row.LegacyTypeKey,
FunctionCode: row.FunctionCode,
},
FlagGroupCode: req.FlagGroupCode,
ProductWarehouseID: row.ProductWarehouseID,
SortAt: row.SortAt,
SortPriority: row.SortPriority,
Quantity: row.Quantity,
UsedQuantity: row.UsedQuantity,
PendingQuantity: row.PendingQuantity,
AvailableQuantity: row.AvailableQuantity,
SourceTable: row.SourceTable,
SourceID: row.SourceID,
})
}
return out, nil
}
func (s *fifoStockV2Service) buildGatherSubquery(rule routeRule, trait traitRule, req GatherRequest) (string, []any, error) {
sourceTable, _ := mustSafeIdentifier(rule.SourceTable)
sourceIDCol, _ := mustSafeIdentifier(rule.SourceIDColumn)
productWarehouseCol, _ := mustSafeIdentifier(rule.ProductWarehouseCol)
quantityCol, _ := mustSafeIdentifier(rule.QuantityCol)
baseQtyExpr := fmt.Sprintf("COALESCE(src.%s,0)::numeric", quantityCol)
usedExpr := "0::numeric"
pendingExpr := "0::numeric"
availableExpr := baseQtyExpr
extraArgs := make([]any, 0, 1)
if req.Lane == LaneStockable {
if rule.UsedQuantityCol != nil && strings.TrimSpace(*rule.UsedQuantityCol) != "" {
usedCol, _ := mustSafeIdentifier(*rule.UsedQuantityCol)
usedExpr = fmt.Sprintf("COALESCE(src.%s,0)::numeric", usedCol)
} else {
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')",
sourceIDCol,
activeAllocationStatus(),
)
extraArgs = append(extraArgs, rule.LegacyTypeKey)
}
availableExpr = fmt.Sprintf("(%s - %s)", baseQtyExpr, usedExpr)
} else {
if rule.PendingQuantityCol != nil && strings.TrimSpace(*rule.PendingQuantityCol) != "" {
pendingCol, _ := mustSafeIdentifier(*rule.PendingQuantityCol)
pendingExpr = fmt.Sprintf("COALESCE(src.%s,0)::numeric", pendingCol)
}
availableExpr = baseQtyExpr
}
sortExpr, joinClause, err := buildSortExpr(trait)
if err != nil {
return "", nil, err
}
whereParts := []string{
fmt.Sprintf("src.%s = ?", productWarehouseCol),
fmt.Sprintf(`EXISTS (
SELECT 1
FROM product_warehouses pw
JOIN flags f ON f.flagable_type = ? AND 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 = src.%s AND fm.flag_group_code = ?
)`, productWarehouseCol),
}
if req.Lane == LaneStockable {
whereParts = append(whereParts, fmt.Sprintf("%s > 0", availableExpr))
}
if req.AsOf != 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)))
}
subquery := fmt.Sprintf(`
SELECT
?::text AS source_table,
?::text AS legacy_type_key,
?::text AS function_code,
src.%s AS source_id,
src.%s AS product_warehouse_id,
%s AS sort_at,
?::int AS sort_priority,
%s AS quantity,
%s AS used_quantity,
%s AS pending_quantity,
%s AS available_quantity
FROM %s src
%s
WHERE %s
`, 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, extraArgs...)
args = append(args,
req.ProductWarehouseID,
entity.FlagableTypeProduct,
req.FlagGroupCode,
)
if req.AsOf != nil {
args = append(args, *req.AsOf)
}
return subquery, args, nil
}
func buildSortExpr(trait traitRule) (string, string, error) {
dateCol, _ := mustSafeIdentifier(trait.DateColumn)
idCol, _ := mustSafeIdentifier(trait.IDColumn)
_ = idCol
joinClause := ""
sortBase := fmt.Sprintf("src.%s", dateCol)
if trait.DateTable != nil && strings.TrimSpace(*trait.DateTable) != "" {
dateTable, _ := mustSafeIdentifier(*trait.DateTable)
if trait.DateJoinLeftCol == nil || trait.DateJoinRightCol == nil {
return "", "", fmt.Errorf("trait %s requires date join columns", trait.SourceTable)
}
leftCol, _ := mustSafeIdentifier(*trait.DateJoinLeftCol)
rightCol, _ := mustSafeIdentifier(*trait.DateJoinRightCol)
joinClause = fmt.Sprintf("LEFT JOIN %s dt ON src.%s = dt.%s", dateTable, leftCol, rightCol)
sortBase = fmt.Sprintf("dt.%s", dateCol)
}
if trait.FallbackDateColumn != nil && strings.TrimSpace(*trait.FallbackDateColumn) != "" {
fallbackCol, _ := mustSafeIdentifier(*trait.FallbackDateColumn)
sortBase = fmt.Sprintf("COALESCE(%s, src.%s)", sortBase, fallbackCol)
}
sortExpr := fmt.Sprintf("COALESCE(%s, '1970-01-01 00:00:00+00'::timestamptz)", sortBase)
return sortExpr, joinClause, nil
}
@@ -0,0 +1,177 @@
package fifo_stock_v2
import (
"context"
"encoding/json"
"fmt"
"math"
"time"
"gorm.io/gorm"
)
func (s *fifoStockV2Service) Recalculate(ctx context.Context, req RecalculateRequest) (*RecalculateResult, error) {
result := &RecalculateResult{Drifts: make([]WarehouseDrift, 0)}
err := s.withTransaction(ctx, req.Tx, func(tx *gorm.DB) error {
hash := requestHash(map[string]any{
"product_warehouse_ids": req.ProductWarehouseIDs,
"flag_group_codes": req.FlagGroupCodes,
"as_of": req.AsOf,
"fix_drift": req.FixDrift,
})
logRow, reused, err := s.beginOperation(
tx,
OperationRecalculate,
req.IdempotencyKey,
hash,
0,
"RECALCULATE",
"",
0,
)
if err != nil {
return err
}
if reused {
if len(logRow.ResultPayload) == 0 {
return fmt.Errorf("idempotent recalculate has empty payload")
}
if err := json.Unmarshal(logRow.ResultPayload, result); err != nil {
return err
}
return nil
}
if logRow != nil {
defer func() {
if err != nil {
s.failOperation(tx, logRow, err)
}
}()
}
warehouseIDs, err := s.resolveRecalculateWarehouseIDs(ctx, tx, req.ProductWarehouseIDs)
if err != nil {
return err
}
groupCodes, err := s.resolveRecalculateGroupCodes(ctx, tx, req.FlagGroupCodes)
if err != nil {
return err
}
for _, warehouseID := range warehouseIDs {
expected := 0.0
for _, flagGroup := range groupCodes {
available, calcErr := s.calculateWarehouseAvailableForGroup(ctx, tx, warehouseID, flagGroup, req.AsOf)
if calcErr != nil {
return calcErr
}
expected += available
}
actual, actualErr := s.loadWarehouseQty(ctx, tx, warehouseID)
if actualErr != nil {
return actualErr
}
delta := expected - actual
result.Checked++
if math.Abs(delta) < 1e-6 {
continue
}
drift := WarehouseDrift{
ProductWarehouseID: warehouseID,
ExpectedQty: expected,
ActualQty: actual,
Delta: delta,
}
result.Drifts = append(result.Drifts, drift)
if req.FixDrift {
if err := s.adjustProductWarehouseQty(tx, warehouseID, delta); err != nil {
return err
}
result.Fixed++
}
}
if err := s.finishOperation(tx, logRow, result); err != nil {
return err
}
return nil
})
if err != nil {
return nil, err
}
return result, nil
}
func (s *fifoStockV2Service) resolveRecalculateWarehouseIDs(ctx context.Context, tx *gorm.DB, provided []uint) ([]uint, error) {
if len(provided) > 0 {
return provided, nil
}
var ids []uint
err := tx.WithContext(ctx).Table("product_warehouses").Select("id").Order("id ASC").Scan(&ids).Error
if err != nil {
return nil, err
}
return ids, nil
}
func (s *fifoStockV2Service) resolveRecalculateGroupCodes(ctx context.Context, tx *gorm.DB, provided []string) ([]string, error) {
if len(provided) > 0 {
return provided, nil
}
var groups []string
err := tx.WithContext(ctx).
Table("fifo_stock_v2_flag_groups").
Select("code").
Where("is_active = TRUE").
Order("priority ASC, code ASC").
Scan(&groups).Error
if err != nil {
return nil, err
}
return groups, nil
}
func (s *fifoStockV2Service) calculateWarehouseAvailableForGroup(
ctx context.Context,
tx *gorm.DB,
warehouseID uint,
flagGroupCode string,
asOf *time.Time,
) (float64, error) {
rows, err := s.gatherRows(ctx, tx, GatherRequest{
FlagGroupCode: flagGroupCode,
Lane: LaneStockable,
ProductWarehouseID: warehouseID,
AsOf: asOf,
Limit: 50000,
})
if err != nil {
return 0, err
}
total := 0.0
for _, row := range rows {
total += row.AvailableQuantity
}
return total, nil
}
func (s *fifoStockV2Service) loadWarehouseQty(ctx context.Context, tx *gorm.DB, warehouseID uint) (float64, error) {
type row struct {
Qty float64 `gorm:"column:qty"`
}
var out row
err := tx.WithContext(ctx).
Table("product_warehouses").
Select("COALESCE(qty,0) AS qty").
Where("id = ?", warehouseID).
Take(&out).Error
if err != nil {
return 0, err
}
return out.Qty, nil
}
@@ -0,0 +1,100 @@
package fifo_stock_v2
import "strings"
func normalizeScopeSQL(scopeSQL string) string {
scopeSQL = strings.TrimSpace(scopeSQL)
if scopeSQL == "" {
return scopeSQL
}
var out strings.Builder
out.Grow(len(scopeSQL) + 16)
inSingleQuote := false
inDoubleQuote := false
for i := 0; i < len(scopeSQL); {
ch := scopeSQL[i]
if inSingleQuote {
out.WriteByte(ch)
i++
if ch == '\'' {
if i < len(scopeSQL) && scopeSQL[i] == '\'' {
out.WriteByte(scopeSQL[i])
i++
} else {
inSingleQuote = false
}
}
continue
}
if inDoubleQuote {
out.WriteByte(ch)
i++
if ch == '"' {
inDoubleQuote = false
}
continue
}
if ch == '\'' {
inSingleQuote = true
out.WriteByte(ch)
i++
continue
}
if ch == '"' {
inDoubleQuote = true
out.WriteByte(ch)
i++
continue
}
if isIdentifierStart(ch) {
start := i
i++
for i < len(scopeSQL) && isIdentifierPart(scopeSQL[i]) {
i++
}
token := scopeSQL[start:i]
if strings.EqualFold(token, "deleted_at") && !hasAliasQualifier(scopeSQL, start) {
out.WriteString("src.deleted_at")
} else {
out.WriteString(token)
}
continue
}
out.WriteByte(ch)
i++
}
return out.String()
}
func hasAliasQualifier(scopeSQL string, tokenStart int) bool {
for i := tokenStart - 1; i >= 0; i-- {
switch scopeSQL[i] {
case ' ', '\t', '\n', '\r':
continue
case '.':
return true
default:
return false
}
}
return false
}
func isIdentifierStart(ch byte) bool {
return (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || ch == '_'
}
func isIdentifierPart(ch byte) bool {
return isIdentifierStart(ch) || (ch >= '0' && ch <= '9')
}
@@ -0,0 +1,265 @@
package fifo_stock_v2
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"hash/fnv"
"math"
"regexp"
"strings"
"github.com/sirupsen/logrus"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
"gorm.io/gorm"
)
var identifierPattern = regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_]*$`)
type fifoStockV2Service struct {
db *gorm.DB
logger *logrus.Logger
defaultGatherLimit int
}
func NewService(db *gorm.DB, logger *logrus.Logger) Service {
if logger == nil {
logger = logrus.StandardLogger()
}
return &fifoStockV2Service{
db: db,
logger: logger,
defaultGatherLimit: 1000,
}
}
func (s *fifoStockV2Service) withTransaction(
ctx context.Context,
tx *gorm.DB,
fn func(*gorm.DB) error,
) error {
if tx != nil {
return fn(tx.WithContext(ctx))
}
return s.db.WithContext(ctx).Transaction(func(inner *gorm.DB) error {
return fn(inner)
})
}
func isSafeIdentifier(v string) bool {
return identifierPattern.MatchString(strings.TrimSpace(v))
}
func mustSafeIdentifier(v string) (string, error) {
v = strings.TrimSpace(v)
if !isSafeIdentifier(v) {
return "", fmt.Errorf("unsafe identifier: %s", v)
}
return v, nil
}
func requestHash(v any) string {
payload, _ := json.Marshal(v)
sum := sha256.Sum256(payload)
return hex.EncodeToString(sum[:])
}
func shardLockKey(flagGroupCode string, productWarehouseID uint) int64 {
h := fnv.New64a()
_, _ = h.Write([]byte(strings.TrimSpace(strings.ToUpper(flagGroupCode))))
_, _ = h.Write([]byte("|"))
_, _ = h.Write([]byte(fmt.Sprintf("%d", productWarehouseID)))
return int64(h.Sum64())
}
func (s *fifoStockV2Service) lockShard(tx *gorm.DB, flagGroupCode string, productWarehouseID uint) error {
if strings.TrimSpace(flagGroupCode) == "" || productWarehouseID == 0 {
return fmt.Errorf("lock shard requires flag group and product warehouse")
}
return tx.Exec("SELECT pg_advisory_xact_lock(?)", shardLockKey(flagGroupCode, productWarehouseID)).Error
}
type operationLogRow struct {
ID uint `gorm:"column:id"`
Status string `gorm:"column:status"`
RequestHash string `gorm:"column:request_hash"`
ResultPayload json.RawMessage `gorm:"column:result_payload"`
}
func (s *fifoStockV2Service) beginOperation(
tx *gorm.DB,
op Operation,
idempotencyKey string,
requestHashValue string,
productWarehouseID uint,
flagGroupCode string,
usableType string,
usableID uint,
) (*operationLogRow, bool, error) {
if strings.TrimSpace(idempotencyKey) == "" {
return nil, false, nil
}
inserted := operationLogRow{}
insertSQL := `
INSERT INTO fifo_stock_v2_operation_log
(idempotency_key, operation, product_warehouse_id, flag_group_code, usable_type, usable_id, request_hash, status, created_at)
VALUES (?, ?, ?, ?, NULLIF(?, ''), NULLIF(?, 0), ?, 'RUNNING', NOW())
ON CONFLICT (idempotency_key, operation) DO NOTHING
RETURNING id, status, request_hash
`
if err := tx.Raw(insertSQL,
idempotencyKey,
string(op),
productWarehouseID,
flagGroupCode,
usableType,
usableID,
requestHashValue,
).Scan(&inserted).Error; err != nil {
return nil, false, err
}
if inserted.ID != 0 {
return &inserted, false, nil
}
existing := operationLogRow{}
if err := tx.Table("fifo_stock_v2_operation_log").
Select("id, status, request_hash, result_payload").
Where("idempotency_key = ? AND operation = ?", idempotencyKey, string(op)).
Take(&existing).Error; err != nil {
return nil, false, err
}
if existing.RequestHash != requestHashValue {
return nil, false, fmt.Errorf("idempotency key %s reused with different payload", idempotencyKey)
}
switch strings.ToUpper(existing.Status) {
case "DONE":
return &existing, true, nil
case "RUNNING":
return nil, false, fmt.Errorf("operation %s with idempotency key %s is still running", op, idempotencyKey)
case "FAILED":
if err := tx.Table("fifo_stock_v2_operation_log").
Where("id = ?", existing.ID).
Updates(map[string]any{
"status": "RUNNING",
"error_text": nil,
"finished_at": nil,
}).Error; err != nil {
return nil, false, err
}
existing.Status = "RUNNING"
return &existing, false, nil
default:
return nil, false, fmt.Errorf("unknown operation status: %s", existing.Status)
}
}
func (s *fifoStockV2Service) finishOperation(tx *gorm.DB, logRow *operationLogRow, payload any) error {
if logRow == nil || logRow.ID == 0 {
return nil
}
encoded, err := json.Marshal(payload)
if err != nil {
return err
}
return tx.Table("fifo_stock_v2_operation_log").
Where("id = ?", logRow.ID).
Updates(map[string]any{
"status": "DONE",
"result_payload": encoded,
"finished_at": gorm.Expr("NOW()"),
}).Error
}
func (s *fifoStockV2Service) failOperation(tx *gorm.DB, logRow *operationLogRow, failure error) {
if logRow == nil || logRow.ID == 0 || failure == nil {
return
}
_ = tx.Table("fifo_stock_v2_operation_log").
Where("id = ?", logRow.ID).
Updates(map[string]any{
"status": "FAILED",
"error_text": failure.Error(),
"finished_at": gorm.Expr("NOW()"),
}).Error
}
func (s *fifoStockV2Service) resolveOverConsume(
tx *gorm.DB,
flagGroupCode string,
functionCode string,
lane Lane,
defaultValue bool,
) (bool, error) {
type row struct {
Allow bool `gorm:"column:allow_overconsume"`
}
selected := row{}
err := tx.Table("fifo_stock_v2_overconsume_rules").
Select("allow_overconsume").
Where("is_active = TRUE").
Where("lane = ?", string(lane)).
Where("(flag_group_code IS NULL OR flag_group_code = ?)", flagGroupCode).
Where("(function_code IS NULL OR function_code = ?)", functionCode).
Order("CASE WHEN flag_group_code IS NULL THEN 1 ELSE 0 END ASC").
Order("CASE WHEN function_code IS NULL THEN 1 ELSE 0 END ASC").
Order("priority ASC, id ASC").
Limit(1).
Take(&selected).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return defaultValue, nil
}
return false, err
}
return selected.Allow, nil
}
func (s *fifoStockV2Service) adjustProductWarehouseQty(tx *gorm.DB, productWarehouseID uint, delta float64) error {
if productWarehouseID == 0 || delta == 0 {
return nil
}
return tx.Table("product_warehouses").
Where("id = ?", productWarehouseID).
Update("qty", gorm.Expr("COALESCE(qty,0) + ?", delta)).Error
}
func nearlyZero(v float64) bool {
return math.Abs(v) < 1e-6
}
func (s *fifoStockV2Service) ensureStockAllocationColumns(tx *gorm.DB) error {
checkCols := []string{"engine_version", "flag_group_code", "function_code", "idempotency_key"}
for _, col := range checkCols {
var count int64
err := tx.Raw(`
SELECT COUNT(1)
FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = 'stock_allocations' AND column_name = ?
`, col).Scan(&count).Error
if err != nil {
return err
}
if count == 0 {
return fmt.Errorf("stock_allocations.%s does not exist, run fifo_stock_v2 migration first", col)
}
}
return nil
}
func activeAllocationStatus() string {
return entity.StockAllocationStatusActive
}
func releasedAllocationStatus() string {
return entity.StockAllocationStatusReleased
}
@@ -0,0 +1,142 @@
package fifo_stock_v2
import (
"context"
"time"
"gorm.io/gorm"
)
type Lane string
const (
LaneStockable Lane = "STOCKABLE"
LaneUsable Lane = "USABLE"
)
type Operation string
const (
OperationAllocate Operation = "ALLOCATE"
OperationRollback Operation = "ROLLBACK"
OperationReflow Operation = "REFLOW"
OperationRecalculate Operation = "RECALCULATE"
)
type Ref struct {
Table string
ID uint
LegacyTypeKey string
FunctionCode string
}
type GatherRequest struct {
FlagGroupCode string
Lane Lane
ProductWarehouseID uint
AsOf *time.Time
Limit int
AfterSortAt *time.Time
AfterSourceTable string
AfterSourceID uint
ForUpdate bool
Tx *gorm.DB
}
type GatherRow struct {
Ref Ref
FlagGroupCode string
ProductWarehouseID uint
SortAt time.Time
SortPriority int
Quantity float64
UsedQuantity float64
PendingQuantity float64
AvailableQuantity float64
SourceTable string
SourceID uint
}
type AllocateRequest struct {
FlagGroupCode string
ProductWarehouseID uint
Usable Ref
NeedQty float64
AllowOverConsume *bool
IdempotencyKey string
AsOf *time.Time
Tx *gorm.DB
}
type AllocationDetail struct {
StockableType string
StockableID uint
Qty float64
SortAt time.Time
}
type AllocateResult struct {
AllocatedQty float64
PendingQty float64
Details []AllocationDetail
}
type RollbackRequest struct {
ProductWarehouseID uint
Usable Ref
ReleaseQty *float64
Reason string
IdempotencyKey string
Tx *gorm.DB
}
type RollbackResult struct {
ReleasedQty float64
Details []AllocationDetail
}
type ReflowRequest struct {
FlagGroupCode string
ProductWarehouseID uint
Usable Ref
DesiredQty float64
AllowOverConsume *bool
IdempotencyKey string
AsOf *time.Time
Tx *gorm.DB
}
type ReflowResult struct {
Rollback RollbackResult
Allocate AllocateResult
}
type RecalculateRequest struct {
ProductWarehouseIDs []uint
FlagGroupCodes []string
AsOf *time.Time
FixDrift bool
IdempotencyKey string
Tx *gorm.DB
}
type WarehouseDrift struct {
ProductWarehouseID uint
ExpectedQty float64
ActualQty float64
Delta float64
}
type RecalculateResult struct {
Checked int
Fixed int
Drifts []WarehouseDrift
}
type Service interface {
Gather(ctx context.Context, req GatherRequest) ([]GatherRow, error)
Allocate(ctx context.Context, req AllocateRequest) (*AllocateResult, error)
Rollback(ctx context.Context, req RollbackRequest) (*RollbackResult, error)
Reflow(ctx context.Context, req ReflowRequest) (*ReflowResult, error)
Recalculate(ctx context.Context, req RecalculateRequest) (*RecalculateResult, error)
}
+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"))
@@ -0,0 +1,12 @@
BEGIN;
ALTER TABLE recording_depletions
DROP CONSTRAINT IF EXISTS chk_recording_depletions_pending_zero;
ALTER TABLE recording_depletions
DROP COLUMN IF EXISTS total_used_qty;
ALTER TABLE recording_depletions
DROP COLUMN IF EXISTS usage_qty;
COMMIT;
@@ -0,0 +1,15 @@
BEGIN;
ALTER TABLE recording_depletions
ADD COLUMN IF NOT EXISTS total_used_qty numeric(15, 3) NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS usage_qty numeric(15, 3) NOT NULL DEFAULT 0;
UPDATE recording_depletions
SET pending_qty = 0
WHERE pending_qty IS NULL OR pending_qty <> 0;
ALTER TABLE recording_depletions
ADD CONSTRAINT chk_recording_depletions_pending_zero
CHECK (pending_qty = 0);
COMMIT;
@@ -0,0 +1,24 @@
BEGIN;
DROP INDEX IF EXISTS idx_stock_allocations_idempotency;
DROP INDEX IF EXISTS idx_stock_allocations_flag_group;
DROP INDEX IF EXISTS idx_stock_allocations_engine_version;
ALTER TABLE stock_allocations
DROP COLUMN IF EXISTS idempotency_key,
DROP COLUMN IF EXISTS reflow_run_id,
DROP COLUMN IF EXISTS function_code,
DROP COLUMN IF EXISTS flag_group_code,
DROP COLUMN IF EXISTS engine_version;
DROP TABLE IF EXISTS fifo_stock_v2_shadow_allocations;
DROP TABLE IF EXISTS fifo_stock_v2_reflow_checkpoints;
DROP TABLE IF EXISTS fifo_stock_v2_reflow_runs;
DROP TABLE IF EXISTS fifo_stock_v2_operation_log;
DROP TABLE IF EXISTS fifo_stock_v2_overconsume_rules;
DROP TABLE IF EXISTS fifo_stock_v2_route_rules;
DROP TABLE IF EXISTS fifo_stock_v2_traits;
DROP TABLE IF EXISTS fifo_stock_v2_flag_members;
DROP TABLE IF EXISTS fifo_stock_v2_flag_groups;
COMMIT;
@@ -0,0 +1,151 @@
BEGIN;
CREATE TABLE IF NOT EXISTS fifo_stock_v2_flag_groups (
code VARCHAR(64) PRIMARY KEY,
name VARCHAR(128) NOT NULL,
priority INT NOT NULL DEFAULT 100,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS fifo_stock_v2_flag_members (
flag_name VARCHAR(64) PRIMARY KEY,
flag_group_code VARCHAR(64) NOT NULL REFERENCES fifo_stock_v2_flag_groups(code),
priority INT NOT NULL DEFAULT 100,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS fifo_stock_v2_traits (
id BIGSERIAL PRIMARY KEY,
source_table VARCHAR(64) NOT NULL,
lane VARCHAR(16) NOT NULL CHECK (lane IN ('STOCKABLE', 'USABLE')),
date_table VARCHAR(64) NULL,
date_join_left_col VARCHAR(64) NULL,
date_join_right_col VARCHAR(64) NULL,
date_column VARCHAR(64) NOT NULL,
fallback_date_column VARCHAR(64) NULL,
sort_priority INT NOT NULL DEFAULT 100,
id_column VARCHAR(64) NOT NULL DEFAULT 'id',
is_active BOOLEAN NOT NULL DEFAULT TRUE,
UNIQUE (source_table, lane)
);
CREATE TABLE IF NOT EXISTS fifo_stock_v2_route_rules (
id BIGSERIAL PRIMARY KEY,
flag_group_code VARCHAR(64) NOT NULL REFERENCES fifo_stock_v2_flag_groups(code),
lane VARCHAR(16) NOT NULL CHECK (lane IN ('STOCKABLE', 'USABLE')),
function_code VARCHAR(64) NOT NULL,
source_table VARCHAR(64) NOT NULL,
source_id_column VARCHAR(64) NOT NULL DEFAULT 'id',
product_warehouse_col VARCHAR(64) NOT NULL,
quantity_col VARCHAR(64) NOT NULL,
used_quantity_col VARCHAR(64) NULL,
pending_quantity_col VARCHAR(64) NULL,
scope_sql TEXT NULL,
legacy_type_key VARCHAR(100) NOT NULL,
allow_pending_default BOOLEAN NOT NULL DEFAULT TRUE,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (flag_group_code, lane, function_code, source_table)
);
CREATE TABLE IF NOT EXISTS fifo_stock_v2_overconsume_rules (
id BIGSERIAL PRIMARY KEY,
flag_group_code VARCHAR(64) NULL REFERENCES fifo_stock_v2_flag_groups(code),
function_code VARCHAR(64) NULL,
lane VARCHAR(16) NOT NULL DEFAULT 'USABLE' CHECK (lane IN ('STOCKABLE', 'USABLE')),
allow_overconsume BOOLEAN NOT NULL,
priority INT NOT NULL DEFAULT 100,
reason TEXT NULL,
is_active BOOLEAN NOT NULL DEFAULT TRUE
);
CREATE TABLE IF NOT EXISTS fifo_stock_v2_operation_log (
id BIGSERIAL PRIMARY KEY,
idempotency_key VARCHAR(128) NOT NULL,
operation VARCHAR(16) NOT NULL CHECK (operation IN ('ALLOCATE', 'ROLLBACK', 'REFLOW', 'RECALCULATE')),
product_warehouse_id BIGINT NOT NULL,
flag_group_code VARCHAR(64) NOT NULL,
usable_type VARCHAR(100) NULL,
usable_id BIGINT NULL,
request_hash VARCHAR(64) NOT NULL,
status VARCHAR(16) NOT NULL CHECK (status IN ('RUNNING', 'DONE', 'FAILED')),
result_payload JSONB NULL,
error_text TEXT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
finished_at TIMESTAMPTZ NULL,
UNIQUE (idempotency_key, operation)
);
CREATE TABLE IF NOT EXISTS fifo_stock_v2_reflow_runs (
id BIGSERIAL PRIMARY KEY,
mode VARCHAR(16) NOT NULL CHECK (mode IN ('DRY_RUN', 'APPLY')),
status VARCHAR(16) NOT NULL CHECK (status IN ('RUNNING', 'PAUSED', 'DONE', 'FAILED', 'CANCELLED')),
as_of TIMESTAMPTZ NULL,
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
finished_at TIMESTAMPTZ NULL,
total_shards INT NOT NULL DEFAULT 0,
processed_shards INT NOT NULL DEFAULT 0,
processed_rows BIGINT NOT NULL DEFAULT 0,
mismatch_rows BIGINT NOT NULL DEFAULT 0,
created_by BIGINT NULL,
note TEXT NULL
);
CREATE TABLE IF NOT EXISTS fifo_stock_v2_reflow_checkpoints (
id BIGSERIAL PRIMARY KEY,
run_id BIGINT NOT NULL REFERENCES fifo_stock_v2_reflow_runs(id) ON DELETE CASCADE,
flag_group_code VARCHAR(64) NOT NULL,
product_warehouse_id BIGINT NOT NULL,
last_sort_at TIMESTAMPTZ NULL,
last_source_table VARCHAR(64) NULL,
last_source_id BIGINT NULL,
status VARCHAR(16) NOT NULL CHECK (status IN ('PENDING', 'RUNNING', 'DONE', 'FAILED')) DEFAULT 'PENDING',
retry_count INT NOT NULL DEFAULT 0,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (run_id, flag_group_code, product_warehouse_id)
);
CREATE TABLE IF NOT EXISTS fifo_stock_v2_shadow_allocations (
id BIGSERIAL PRIMARY KEY,
run_id BIGINT NOT NULL REFERENCES fifo_stock_v2_reflow_runs(id) ON DELETE CASCADE,
product_warehouse_id BIGINT NOT NULL,
stockable_type VARCHAR(100) NOT NULL,
stockable_id BIGINT NOT NULL,
usable_type VARCHAR(100) NOT NULL,
usable_id BIGINT NOT NULL,
qty NUMERIC(15,3) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
sort_at TIMESTAMPTZ NULL,
source_table VARCHAR(64) NULL,
source_id BIGINT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_fifo_v2_shadow_run_usable
ON fifo_stock_v2_shadow_allocations(run_id, usable_type, usable_id);
CREATE INDEX IF NOT EXISTS idx_fifo_v2_shadow_run_stockable
ON fifo_stock_v2_shadow_allocations(run_id, stockable_type, stockable_id);
ALTER TABLE stock_allocations
ADD COLUMN IF NOT EXISTS engine_version VARCHAR(8) NOT NULL DEFAULT 'v1',
ADD COLUMN IF NOT EXISTS flag_group_code VARCHAR(64) NULL,
ADD COLUMN IF NOT EXISTS function_code VARCHAR(64) NULL,
ADD COLUMN IF NOT EXISTS reflow_run_id BIGINT NULL,
ADD COLUMN IF NOT EXISTS idempotency_key VARCHAR(128) NULL;
CREATE INDEX IF NOT EXISTS idx_stock_allocations_engine_version
ON stock_allocations(engine_version);
CREATE INDEX IF NOT EXISTS idx_stock_allocations_flag_group
ON stock_allocations(flag_group_code);
CREATE INDEX IF NOT EXISTS idx_stock_allocations_idempotency
ON stock_allocations(idempotency_key);
COMMIT;
@@ -0,0 +1,36 @@
BEGIN;
DELETE FROM fifo_stock_v2_overconsume_rules
WHERE reason IN (
'fifo_v2_default_allow',
'fifo_v2_exception_ayam_depletion_block',
'fifo_v2_exception_marketing_block',
'fifo_v2_exception_transfer_block',
'fifo_v2_exception_adjustment_block',
'fifo_v2_exception_transfer_laying_block'
);
DELETE FROM fifo_stock_v2_route_rules
WHERE flag_group_code IN ('AYAM', 'AFKIR_CULLING_MATI', 'PAKAN', 'OVK', 'TELUR', 'TELUR_GRADE');
DELETE FROM fifo_stock_v2_traits
WHERE source_table IN (
'purchase_items',
'stock_transfer_details',
'laying_transfer_targets',
'laying_transfer_sources',
'adjustment_stocks',
'recording_stocks',
'recording_depletions',
'recording_eggs',
'marketing_delivery_products',
'project_chickins'
);
DELETE FROM fifo_stock_v2_flag_members
WHERE flag_group_code IN ('AYAM', 'AFKIR_CULLING_MATI', 'PAKAN', 'OVK', 'TELUR', 'TELUR_GRADE');
DELETE FROM fifo_stock_v2_flag_groups
WHERE code IN ('AYAM', 'AFKIR_CULLING_MATI', 'PAKAN', 'OVK', 'TELUR', 'TELUR_GRADE');
COMMIT;
@@ -0,0 +1,248 @@
BEGIN;
INSERT INTO fifo_stock_v2_flag_groups(code, name, priority)
VALUES
('AYAM', 'AYAM', 10),
('AFKIR_CULLING_MATI', 'AFKIR/CULLING/MATI', 20),
('PAKAN', 'PAKAN', 30),
('OVK', 'OVK', 40),
('TELUR', 'TELUR', 50),
('TELUR_GRADE', 'UTUH/PUTIH/RETAK/PECAH/PAPACAL/JUMBO', 60)
ON CONFLICT (code) DO UPDATE
SET
name = EXCLUDED.name,
priority = EXCLUDED.priority,
updated_at = NOW();
INSERT INTO fifo_stock_v2_flag_members(flag_name, flag_group_code, priority)
VALUES
('DOC', 'AYAM', 10),
('PULLET', 'AYAM', 20),
('LAYER', 'AYAM', 30),
('AYAM-AFKIR', 'AFKIR_CULLING_MATI', 10),
('AYAM-CULLING', 'AFKIR_CULLING_MATI', 20),
('AYAM-MATI', 'AFKIR_CULLING_MATI', 30),
('PAKAN', 'PAKAN', 10),
('PRE-STARTER', 'PAKAN', 20),
('STARTER', 'PAKAN', 30),
('FINISHER', 'PAKAN', 40),
('OVK', 'OVK', 10),
('OBAT', 'OVK', 20),
('VITAMIN', 'OVK', 30),
('KIMIA', 'OVK', 40),
('TELUR', 'TELUR', 10),
('TELUR-UTUH', 'TELUR_GRADE', 10),
('TELUR-PUTIH', 'TELUR_GRADE', 20),
('TELUR-RETAK', 'TELUR_GRADE', 30),
('TELUR-PECAH', 'TELUR_GRADE', 40),
('TELUR-PAPACAL', 'TELUR_GRADE', 50),
('TELUR-JUMBO', 'TELUR_GRADE', 60)
ON CONFLICT (flag_name) DO UPDATE
SET
flag_group_code = EXCLUDED.flag_group_code,
priority = EXCLUDED.priority,
updated_at = NOW();
INSERT INTO fifo_stock_v2_traits(
source_table,
lane,
date_table,
date_join_left_col,
date_join_right_col,
date_column,
fallback_date_column,
sort_priority,
id_column
)
VALUES
('purchase_items', 'STOCKABLE', NULL, NULL, NULL, 'received_date', NULL, 10, 'id'),
('stock_transfer_details', 'STOCKABLE', 'stock_transfers', 'stock_transfer_id', 'id', 'transfer_date', NULL, 20, 'id'),
('stock_transfer_details', 'USABLE', 'stock_transfers', 'stock_transfer_id', 'id', 'transfer_date', NULL, 20, 'id'),
('laying_transfer_targets', 'STOCKABLE', 'laying_transfers', 'laying_transfer_id', 'id', 'transfer_date', NULL, 25, 'id'),
('laying_transfer_sources', 'USABLE', 'laying_transfers', 'laying_transfer_id', 'id', 'transfer_date', NULL, 25, 'id'),
('adjustment_stocks', 'STOCKABLE', NULL, NULL, NULL, 'created_at', NULL, 30, 'id'),
('adjustment_stocks', 'USABLE', NULL, NULL, NULL, 'created_at', NULL, 30, 'id'),
('recording_stocks', 'USABLE', 'recordings', 'recording_id', 'id', 'record_datetime', NULL, 35, 'id'),
('recording_depletions', 'USABLE', 'recordings', 'recording_id', 'id', 'record_datetime', NULL, 35, 'id'),
('recording_depletions', 'STOCKABLE', 'recordings', 'recording_id', 'id', 'record_datetime', NULL, 35, 'id'),
('recording_eggs', 'STOCKABLE', 'recordings', 'recording_id', 'id', 'record_datetime', 'created_at', 40, 'id'),
('marketing_delivery_products', 'USABLE', NULL, NULL, NULL, 'delivery_date', 'created_at', 45, 'id'),
('project_chickins', 'USABLE', NULL, NULL, NULL, 'chick_in_date', 'created_at', 50, 'id')
ON CONFLICT (source_table, lane) DO UPDATE
SET
date_table = EXCLUDED.date_table,
date_join_left_col = EXCLUDED.date_join_left_col,
date_join_right_col = EXCLUDED.date_join_right_col,
date_column = EXCLUDED.date_column,
fallback_date_column = EXCLUDED.fallback_date_column,
sort_priority = EXCLUDED.sort_priority,
id_column = EXCLUDED.id_column,
is_active = TRUE;
INSERT INTO fifo_stock_v2_route_rules(
flag_group_code,
lane,
function_code,
source_table,
source_id_column,
product_warehouse_col,
quantity_col,
used_quantity_col,
pending_quantity_col,
scope_sql,
legacy_type_key,
allow_pending_default,
is_active
)
VALUES
-- AYAM STOCKABLE
('AYAM', 'STOCKABLE', 'ADJUSTMENT_IN', 'adjustment_stocks', 'id', 'product_warehouse_id', 'total_qty', 'total_used', NULL, NULL, 'ADJUSTMENT_IN', TRUE, TRUE),
('AYAM', 'STOCKABLE', 'STOCK_TRANSFER_IN', 'stock_transfer_details', 'id', 'dest_product_warehouse_id', 'total_qty', 'total_used', NULL, 'deleted_at IS NULL', 'STOCK_TRANSFER_IN', TRUE, TRUE),
('AYAM', 'STOCKABLE', 'PURCHASE_IN', 'purchase_items', 'id', 'product_warehouse_id', 'total_qty', 'total_used', NULL, NULL, 'PURCHASE_ITEMS', TRUE, TRUE),
('AYAM', 'STOCKABLE', '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 USABLE
('AYAM', 'USABLE', 'ADJUSTMENT_OUT', 'adjustment_stocks', 'id', 'product_warehouse_id', 'usage_qty', NULL, 'pending_qty', NULL, 'ADJUSTMENT_OUT', TRUE, TRUE),
('AYAM', 'USABLE', 'STOCK_TRANSFER_OUT', 'stock_transfer_details', 'id', 'source_product_warehouse_id', 'usage_qty', NULL, 'pending_qty', 'deleted_at IS NULL', 'STOCK_TRANSFER_OUT', TRUE, TRUE),
('AYAM', 'USABLE', 'CHICKIN_OUT', 'project_chickins', 'id', 'product_warehouse_id', 'usage_qty', NULL, 'pending_usage_qty', 'deleted_at IS NULL', 'PROJECT_CHICKIN', TRUE, TRUE),
('AYAM', 'USABLE', 'RECORDING_DEPLETION_OUT', 'recording_depletions', 'id', 'source_product_warehouse_id', 'qty', NULL, 'pending_qty', NULL, 'RECORDING_DEPLETION', TRUE, TRUE),
('AYAM', 'USABLE', 'MARKETING_OUT', 'marketing_delivery_products', 'id', 'product_warehouse_id', 'usage_qty', NULL, 'pending_qty', NULL, 'MARKETING_DELIVERY', TRUE, TRUE),
('AYAM', 'USABLE', 'TRANSFER_TO_LAYING_OUT', 'laying_transfer_sources', 'id', 'product_warehouse_id', 'usage_qty', NULL, 'pending_usage_qty', 'deleted_at IS NULL', 'TRANSFERTOLAYING_OUT', TRUE, TRUE),
-- AFKIR/CULLING/MATI STOCKABLE
('AFKIR_CULLING_MATI', 'STOCKABLE', 'ADJUSTMENT_IN', 'adjustment_stocks', 'id', 'product_warehouse_id', 'total_qty', 'total_used', NULL, NULL, 'ADJUSTMENT_IN', TRUE, TRUE),
('AFKIR_CULLING_MATI', '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),
('AFKIR_CULLING_MATI', 'STOCKABLE', 'RECORDING_DEPLETION_IN', 'recording_depletions', 'id', 'product_warehouse_id', 'qty', NULL, NULL, NULL, 'RECORDING_DEPLETION', TRUE, TRUE),
-- AFKIR/CULLING/MATI USABLE
('AFKIR_CULLING_MATI', 'USABLE', 'ADJUSTMENT_OUT', 'adjustment_stocks', 'id', 'product_warehouse_id', 'usage_qty', NULL, 'pending_qty', NULL, 'ADJUSTMENT_OUT', TRUE, TRUE),
('AFKIR_CULLING_MATI', 'USABLE', 'STOCK_TRANSFER_OUT', 'stock_transfer_details', 'id', 'source_product_warehouse_id', 'usage_qty', NULL, 'pending_qty', 'deleted_at IS NULL', 'STOCK_TRANSFER_OUT', TRUE, TRUE),
('AFKIR_CULLING_MATI', 'USABLE', 'MARKETING_OUT', 'marketing_delivery_products', 'id', 'product_warehouse_id', 'usage_qty', NULL, 'pending_qty', NULL, 'MARKETING_DELIVERY', TRUE, TRUE),
-- PAKAN STOCKABLE
('PAKAN', 'STOCKABLE', 'ADJUSTMENT_IN', 'adjustment_stocks', 'id', 'product_warehouse_id', 'total_qty', 'total_used', NULL, NULL, 'ADJUSTMENT_IN', TRUE, TRUE),
('PAKAN', '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),
('PAKAN', 'STOCKABLE', 'PURCHASE_IN', 'purchase_items', 'id', 'product_warehouse_id', 'total_qty', 'total_used', NULL, NULL, 'PURCHASE_ITEMS', TRUE, TRUE),
-- PAKAN USABLE
('PAKAN', 'USABLE', 'ADJUSTMENT_OUT', 'adjustment_stocks', 'id', 'product_warehouse_id', 'usage_qty', NULL, 'pending_qty', NULL, 'ADJUSTMENT_OUT', TRUE, TRUE),
('PAKAN', 'USABLE', 'STOCK_TRANSFER_OUT', 'stock_transfer_details', 'id', 'source_product_warehouse_id', 'usage_qty', NULL, 'pending_qty', 'deleted_at IS NULL', 'STOCK_TRANSFER_OUT', TRUE, TRUE),
('PAKAN', 'USABLE', 'RECORDING_STOCK_OUT', 'recording_stocks', 'id', 'product_warehouse_id', 'usage_qty', NULL, 'pending_qty', NULL, 'RECORDING_STOCK', TRUE, TRUE),
('PAKAN', 'USABLE', 'MARKETING_OUT', 'marketing_delivery_products', 'id', 'product_warehouse_id', 'usage_qty', NULL, 'pending_qty', NULL, 'MARKETING_DELIVERY', TRUE, TRUE),
-- OVK STOCKABLE
('OVK', 'STOCKABLE', 'ADJUSTMENT_IN', 'adjustment_stocks', 'id', 'product_warehouse_id', 'total_qty', 'total_used', NULL, NULL, 'ADJUSTMENT_IN', TRUE, TRUE),
('OVK', '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),
('OVK', 'STOCKABLE', 'PURCHASE_IN', 'purchase_items', 'id', 'product_warehouse_id', 'total_qty', 'total_used', NULL, NULL, 'PURCHASE_ITEMS', TRUE, TRUE),
-- OVK USABLE
('OVK', 'USABLE', 'ADJUSTMENT_OUT', 'adjustment_stocks', 'id', 'product_warehouse_id', 'usage_qty', NULL, 'pending_qty', NULL, 'ADJUSTMENT_OUT', TRUE, TRUE),
('OVK', 'USABLE', 'STOCK_TRANSFER_OUT', 'stock_transfer_details', 'id', 'source_product_warehouse_id', 'usage_qty', NULL, 'pending_qty', 'deleted_at IS NULL', 'STOCK_TRANSFER_OUT', TRUE, TRUE),
('OVK', 'USABLE', 'RECORDING_STOCK_OUT', 'recording_stocks', 'id', 'product_warehouse_id', 'usage_qty', NULL, 'pending_qty', NULL, 'RECORDING_STOCK', TRUE, TRUE),
('OVK', 'USABLE', 'MARKETING_OUT', 'marketing_delivery_products', 'id', 'product_warehouse_id', 'usage_qty', NULL, 'pending_qty', NULL, 'MARKETING_DELIVERY', TRUE, TRUE),
-- TELUR STOCKABLE
('TELUR', 'STOCKABLE', 'ADJUSTMENT_IN', 'adjustment_stocks', 'id', 'product_warehouse_id', 'total_qty', 'total_used', NULL, NULL, 'ADJUSTMENT_IN', TRUE, TRUE),
('TELUR', '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),
('TELUR', 'STOCKABLE', 'RECORDING_EGG_IN', 'recording_eggs', 'id', 'product_warehouse_id', 'total_qty', 'total_used', NULL, NULL, 'RECORDING_EGG', TRUE, TRUE),
-- TELUR USABLE
('TELUR', 'USABLE', 'ADJUSTMENT_OUT', 'adjustment_stocks', 'id', 'product_warehouse_id', 'usage_qty', NULL, 'pending_qty', NULL, 'ADJUSTMENT_OUT', TRUE, TRUE),
('TELUR', 'USABLE', 'STOCK_TRANSFER_OUT', 'stock_transfer_details', 'id', 'source_product_warehouse_id', 'usage_qty', NULL, 'pending_qty', 'deleted_at IS NULL', 'STOCK_TRANSFER_OUT', TRUE, TRUE),
('TELUR', 'USABLE', 'MARKETING_OUT', 'marketing_delivery_products', 'id', 'product_warehouse_id', 'usage_qty', NULL, 'pending_qty', NULL, 'MARKETING_DELIVERY', TRUE, TRUE),
-- TELUR_GRADE STOCKABLE
('TELUR_GRADE', 'STOCKABLE', 'ADJUSTMENT_IN', 'adjustment_stocks', 'id', 'product_warehouse_id', 'total_qty', 'total_used', NULL, NULL, 'ADJUSTMENT_IN', TRUE, TRUE),
('TELUR_GRADE', '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),
('TELUR_GRADE', 'STOCKABLE', 'RECORDING_EGG_IN', 'recording_eggs', 'id', 'product_warehouse_id', 'total_qty', 'total_used', NULL, NULL, 'RECORDING_EGG', TRUE, TRUE),
-- TELUR_GRADE USABLE
('TELUR_GRADE', 'USABLE', 'ADJUSTMENT_OUT', 'adjustment_stocks', 'id', 'product_warehouse_id', 'usage_qty', NULL, 'pending_qty', NULL, 'ADJUSTMENT_OUT', TRUE, TRUE),
('TELUR_GRADE', 'USABLE', 'STOCK_TRANSFER_OUT', 'stock_transfer_details', 'id', 'source_product_warehouse_id', 'usage_qty', NULL, 'pending_qty', 'deleted_at IS NULL', 'STOCK_TRANSFER_OUT', TRUE, TRUE),
('TELUR_GRADE', 'USABLE', 'MARKETING_OUT', 'marketing_delivery_products', 'id', 'product_warehouse_id', 'usage_qty', NULL, 'pending_qty', NULL, 'MARKETING_DELIVERY', TRUE, TRUE)
ON CONFLICT (flag_group_code, lane, function_code, source_table) DO UPDATE
SET
source_id_column = EXCLUDED.source_id_column,
product_warehouse_col = EXCLUDED.product_warehouse_col,
quantity_col = EXCLUDED.quantity_col,
used_quantity_col = EXCLUDED.used_quantity_col,
pending_quantity_col = EXCLUDED.pending_quantity_col,
scope_sql = EXCLUDED.scope_sql,
legacy_type_key = EXCLUDED.legacy_type_key,
allow_pending_default = EXCLUDED.allow_pending_default,
is_active = EXCLUDED.is_active,
updated_at = NOW();
INSERT INTO fifo_stock_v2_overconsume_rules(flag_group_code, function_code, lane, allow_overconsume, priority, reason, is_active)
SELECT NULL, NULL, 'USABLE', TRUE, 999, 'fifo_v2_default_allow', TRUE
WHERE NOT EXISTS (
SELECT 1 FROM fifo_stock_v2_overconsume_rules
WHERE flag_group_code IS NULL
AND function_code IS NULL
AND lane = 'USABLE'
AND reason = 'fifo_v2_default_allow'
);
INSERT INTO fifo_stock_v2_overconsume_rules(flag_group_code, function_code, lane, allow_overconsume, priority, reason, is_active)
SELECT 'AYAM', 'RECORDING_DEPLETION_OUT', 'USABLE', FALSE, 10, 'fifo_v2_exception_ayam_depletion_block', TRUE
WHERE NOT EXISTS (
SELECT 1 FROM fifo_stock_v2_overconsume_rules
WHERE flag_group_code = 'AYAM'
AND function_code = 'RECORDING_DEPLETION_OUT'
AND lane = 'USABLE'
AND reason = 'fifo_v2_exception_ayam_depletion_block'
);
INSERT INTO fifo_stock_v2_overconsume_rules(flag_group_code, function_code, lane, allow_overconsume, priority, reason, is_active)
SELECT NULL, 'MARKETING_OUT', 'USABLE', FALSE, 20, 'fifo_v2_exception_marketing_block', TRUE
WHERE NOT EXISTS (
SELECT 1 FROM fifo_stock_v2_overconsume_rules
WHERE flag_group_code IS NULL
AND function_code = 'MARKETING_OUT'
AND lane = 'USABLE'
AND reason = 'fifo_v2_exception_marketing_block'
);
INSERT INTO fifo_stock_v2_overconsume_rules(flag_group_code, function_code, lane, allow_overconsume, priority, reason, is_active)
SELECT NULL, 'STOCK_TRANSFER_OUT', 'USABLE', FALSE, 30, 'fifo_v2_exception_transfer_block', TRUE
WHERE NOT EXISTS (
SELECT 1 FROM fifo_stock_v2_overconsume_rules
WHERE flag_group_code IS NULL
AND function_code = 'STOCK_TRANSFER_OUT'
AND lane = 'USABLE'
AND reason = 'fifo_v2_exception_transfer_block'
);
INSERT INTO fifo_stock_v2_overconsume_rules(flag_group_code, function_code, lane, allow_overconsume, priority, reason, is_active)
SELECT NULL, 'ADJUSTMENT_OUT', 'USABLE', FALSE, 40, 'fifo_v2_exception_adjustment_block', TRUE
WHERE NOT EXISTS (
SELECT 1 FROM fifo_stock_v2_overconsume_rules
WHERE flag_group_code IS NULL
AND function_code = 'ADJUSTMENT_OUT'
AND lane = 'USABLE'
AND reason = 'fifo_v2_exception_adjustment_block'
);
INSERT INTO fifo_stock_v2_overconsume_rules(flag_group_code, function_code, lane, allow_overconsume, priority, reason, is_active)
SELECT NULL, 'TRANSFER_TO_LAYING_OUT', 'USABLE', FALSE, 50, 'fifo_v2_exception_transfer_laying_block', TRUE
WHERE NOT EXISTS (
SELECT 1 FROM fifo_stock_v2_overconsume_rules
WHERE flag_group_code IS NULL
AND function_code = 'TRANSFER_TO_LAYING_OUT'
AND lane = 'USABLE'
AND reason = 'fifo_v2_exception_transfer_laying_block'
);
COMMIT;
@@ -0,0 +1,12 @@
BEGIN;
DROP INDEX IF EXISTS idx_adjustment_stocks_function_code;
DROP INDEX IF EXISTS idx_adjustment_stocks_transaction_type;
ALTER TABLE adjustment_stocks
DROP COLUMN IF EXISTS grand_total,
DROP COLUMN IF EXISTS price,
DROP COLUMN IF EXISTS function_code,
DROP COLUMN IF EXISTS transaction_type;
COMMIT;
@@ -0,0 +1,23 @@
BEGIN;
ALTER TABLE adjustment_stocks
ADD COLUMN IF NOT EXISTS transaction_type VARCHAR(100) NOT NULL DEFAULT 'LEGACY',
ADD COLUMN IF NOT EXISTS function_code VARCHAR(64),
ADD COLUMN IF NOT EXISTS price NUMERIC(15, 3) NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS grand_total NUMERIC(15, 3) NOT NULL DEFAULT 0;
UPDATE adjustment_stocks
SET function_code = CASE
WHEN COALESCE(total_qty, 0) > 0 THEN 'ADJUSTMENT_IN'
WHEN COALESCE(usage_qty, 0) > 0 THEN 'ADJUSTMENT_OUT'
ELSE 'ADJUSTMENT_IN'
END
WHERE function_code IS NULL OR function_code = '';
CREATE INDEX IF NOT EXISTS idx_adjustment_stocks_transaction_type
ON adjustment_stocks(transaction_type);
CREATE INDEX IF NOT EXISTS idx_adjustment_stocks_function_code
ON adjustment_stocks(function_code);
COMMIT;
@@ -0,0 +1,11 @@
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;
@@ -0,0 +1,46 @@
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';
+25 -5
View File
@@ -74,7 +74,7 @@ func seedUsers(tx *gorm.DB) (map[string]uint, error) {
}
func seedUoms(tx *gorm.DB, createdBy uint) (map[string]uint, error) {
names := []string{"Kilogram", "Gram", "Liter", "Unit", "Ekor"}
names := []string{"Kilogram", "Gram", "Liter", "Unit", "Ekor", "Butir"}
result := make(map[string]uint, len(names))
for _, name := range names {
@@ -235,7 +235,7 @@ func seedProducts(tx *gorm.DB, createdBy uint, uoms map[string]uint, categories
Name: "Telur Utuh",
Brand: "-",
Sku: "4",
Uom: "Gram",
Uom: "Butir",
Category: "Telur",
Price: 1,
Flags: []utils.FlagType{utils.FlagTelurUtuh},
@@ -245,7 +245,7 @@ func seedProducts(tx *gorm.DB, createdBy uint, uoms map[string]uint, categories
Name: "Telur Pecah",
Brand: "-",
Sku: "5",
Uom: "Gram",
Uom: "Butir",
Category: "Telur",
Price: 1,
Flags: []utils.FlagType{utils.FlagTelurPecah},
@@ -255,7 +255,7 @@ func seedProducts(tx *gorm.DB, createdBy uint, uoms map[string]uint, categories
Name: "Telur Putih",
Brand: "-",
Sku: "6",
Uom: "Gram",
Uom: "Butir",
Category: "Telur",
Price: 1,
Flags: []utils.FlagType{utils.FlagTelurPutih},
@@ -265,12 +265,32 @@ func seedProducts(tx *gorm.DB, createdBy uint, uoms map[string]uint, categories
Name: "Telur Retak",
Brand: "-",
Sku: "7",
Uom: "Gram",
Uom: "Butir",
Category: "Telur",
Price: 1,
Flags: []utils.FlagType{utils.FlagTelurRetak},
IsVisible: false,
},
{
Name: "Telur Papacal",
Brand: "-",
Sku: "8",
Uom: "Butir",
Category: "Telur",
Price: 1,
Flags: []utils.FlagType{utils.FlagTelur},
IsVisible: false,
},
{
Name: "Telur Jumbo",
Brand: "-",
Sku: "9",
Uom: "Butir",
Category: "Telur",
Price: 1,
Flags: []utils.FlagType{utils.FlagTelur},
IsVisible: false,
},
}
for _, seed := range seeds {
+4
View File
@@ -5,10 +5,14 @@ import "time"
type AdjustmentStock struct {
Id uint `gorm:"primaryKey"`
ProductWarehouseId uint `gorm:"column:product_warehouse_id;not null"`
TransactionType string `gorm:"column:transaction_type;type:varchar(100);not null;default:LEGACY"`
FunctionCode string `gorm:"column:function_code;type:varchar(64)"`
TotalQty float64 `gorm:"column:total_qty;default:0"`
TotalUsed float64 `gorm:"column:total_used;default:0"`
UsageQty float64 `gorm:"column:usage_qty;default:0"`
PendingQty float64 `gorm:"column:pending_qty;default:0"`
Price float64 `gorm:"column:price;type:numeric(15,3);default:0"`
GrandTotal float64 `gorm:"column:grand_total;type:numeric(15,3);default:0"`
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime"`
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime"`
AdjNumber string `gorm:"column:adj_number;uniqueIndex;not null"`
+4
View File
@@ -12,6 +12,9 @@ type LayingTransfer struct {
FromProjectFlockId uint `gorm:"not null"`
ToProjectFlockId uint `gorm:"not null"`
TransferDate time.Time `gorm:"type:date;not null"`
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 +24,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:"-"`
+1
View File
@@ -21,6 +21,7 @@ type PurchaseItem struct {
Price float64 `gorm:"type:numeric(15,3);default:0"`
TotalPrice float64 `gorm:"type:numeric(15,3);default:0"`
ExpenseNonstockId *uint64
HasChickin bool `gorm:"-" json:"-"`
// Relations
ExpenseNonstock *ExpenseNonstock `gorm:"foreignKey:ExpenseNonstockId;references:Id"`
+1
View File
@@ -6,6 +6,7 @@ type RecordingDepletion struct {
ProductWarehouseId uint `gorm:"column:product_warehouse_id;not null"`
SourceProductWarehouseId *uint `gorm:"column:source_product_warehouse_id"`
Qty float64 `gorm:"column:qty;not null"`
UsageQty float64 `gorm:"column:usage_qty"`
PendingQty float64 `gorm:"column:pending_qty"`
Recording Recording `gorm:"foreignKey:RecordingId;references:Id"`
@@ -347,12 +347,12 @@ func (u *ClosingController) GetSapronakByProject(c *fiber.Ctx) error {
return fiber.NewError(fiber.StatusBadRequest, "Invalid project_flock_id")
}
result, err := u.SapronakService.GetSapronakByProject(c, uint(projectID), flag)
result, productFlags, err := u.SapronakService.GetSapronakByProject(c, uint(projectID), flag)
if err != nil {
return err
}
payload := dto.ToSapronakProjectAggregatedFromReports(result, flag)
payload := dto.ToSapronakProjectAggregatedFromReports(result, flag, productFlags)
return c.Status(fiber.StatusOK).
JSON(response.Success{
@@ -377,12 +377,12 @@ func (u *ClosingController) GetSapronakByKandang(c *fiber.Ctx) error {
return fiber.NewError(fiber.StatusBadRequest, "Invalid project_flock_kandang_id")
}
result, err := u.SapronakService.GetSapronakByKandang(c, uint(projectID), uint(pfkID), flag)
result, productFlags, err := u.SapronakService.GetSapronakByKandang(c, uint(projectID), uint(pfkID), flag)
if err != nil {
return err
}
payload := dto.ToSapronakProjectAggregatedFromReport(result, flag)
payload := dto.ToSapronakProjectAggregatedFromReport(result, flag, productFlags)
return c.Status(fiber.StatusOK).
JSON(response.Success{
@@ -1,6 +1,7 @@
package dto
import (
"sort"
"strings"
"time"
)
@@ -64,7 +65,7 @@ type SapronakCategoryRowDTO struct {
QtyOut float64 `json:"qty_out"`
QtyUsed float64 `json:"qty_used"`
Description string `json:"description"`
ProductCategory string `json:"product_category"`
ProductCategory []string `json:"product_category"`
UnitPrice float64 `json:"unit_price"`
TotalAmount float64 `json:"total_amount"`
Notes string `json:"notes"`
@@ -127,7 +128,7 @@ type UomSummaryDTO struct {
// === Mapper Functions for Aggregated Sapronak Response ===
func ToSapronakProjectAggregatedFromReports(reports []SapronakReportDTO, flag string) SapronakProjectAggregatedDTO {
func ToSapronakProjectAggregatedFromReports(reports []SapronakReportDTO, flag string, productFlags map[uint][]string) SapronakProjectAggregatedDTO {
result := SapronakProjectAggregatedDTO{}
if len(reports) == 0 {
@@ -135,10 +136,10 @@ func ToSapronakProjectAggregatedFromReports(reports []SapronakReportDTO, flag st
}
rep := reports[0]
return ToSapronakProjectAggregatedFromReport(&rep, flag)
return ToSapronakProjectAggregatedFromReport(&rep, flag, productFlags)
}
func ToSapronakProjectAggregatedFromReport(report *SapronakReportDTO, flag string) SapronakProjectAggregatedDTO {
func ToSapronakProjectAggregatedFromReport(report *SapronakReportDTO, flag string, productFlags map[uint][]string) SapronakProjectAggregatedDTO {
result := SapronakProjectAggregatedDTO{}
if report == nil {
@@ -175,6 +176,53 @@ func ToSapronakProjectAggregatedFromReport(report *SapronakReportDTO, flag strin
return t.Format("02-Jan-2006")
}
flagOrder := map[string]int{
"DOC": 0,
"PAKAN": 0,
"OVK": 0,
"PULLET": 0,
}
buildFlagList := func(productID uint, fallback string) []string {
rawFlags := productFlags[productID]
if len(rawFlags) == 0 {
if fallback == "" {
return []string{}
}
return []string{fallback}
}
seen := make(map[string]struct{}, len(rawFlags))
ordered := make([]string, 0, len(rawFlags))
for _, f := range rawFlags {
flagName := strings.ToUpper(strings.TrimSpace(f))
if flagName == "" {
continue
}
if _, ok := seen[flagName]; ok {
continue
}
seen[flagName] = struct{}{}
ordered = append(ordered, flagName)
}
sort.SliceStable(ordered, func(i, j int) bool {
li := ordered[i]
lj := ordered[j]
ri, iok := flagOrder[li]
rj, jok := flagOrder[lj]
if iok != jok {
if iok {
return true
}
return false
}
if iok && jok && ri != rj {
return ri < rj
}
return li < lj
})
return ordered
}
for _, group := range report.Groups {
flagKey := normalizeFlag(group.Flag)
ptr := byFlag[flagKey]
@@ -206,7 +254,7 @@ func ToSapronakProjectAggregatedFromReport(report *SapronakReportDTO, flag strin
Date: formatDate(item.Tanggal),
ReferenceNumber: item.NoReferensi,
Description: item.ProductName,
ProductCategory: item.ProductName,
ProductCategory: buildFlagList(item.ProductID, flagKey),
UnitPrice: item.Harga,
Notes: "-",
}
@@ -991,8 +991,8 @@ func (r *ClosingRepositoryImpl) FetchSapronakUsageAllocatedDetails(ctx context.C
query := r.withCtx(ctx).
Table("stock_allocations AS sa").
Select(`
pw.product_id AS product_id,
p.name AS product_name,
p_resolve.id AS product_id,
p_resolve.name AS product_name,
f.name AS flag,
COALESCE(
pi.received_date,
@@ -1013,10 +1013,9 @@ func (r *ClosingRepositoryImpl) FetchSapronakUsageAllocatedDetails(ctx context.C
) AS reference,
0 AS qty_in,
COALESCE(SUM(sa.qty), 0) AS qty_out,
COALESCE(pi.price, p.product_price, 0) AS price
COALESCE(pi.price, p_resolve.product_price, 0) AS price
`).
Joins("JOIN product_warehouses pw ON pw.id = sa.product_warehouse_id").
Joins("JOIN products p ON p.id = pw.product_id").
Joins("LEFT JOIN recording_stocks rs ON rs.id = sa.usable_id AND sa.usable_type = ?", fifo.UsableKeyRecordingStock.String()).
Joins("LEFT JOIN recordings r ON r.id = rs.recording_id").
Joins("LEFT JOIN project_chickins pc_used ON pc_used.id = sa.usable_id AND sa.usable_type = ?", fifo.UsableKeyProjectChickin.String()).
@@ -1026,9 +1025,11 @@ func (r *ClosingRepositoryImpl) FetchSapronakUsageAllocatedDetails(ctx context.C
Joins("LEFT JOIN stock_transfers st ON st.id = std.stock_transfer_id").
Joins("LEFT JOIN laying_transfer_targets ltt ON ltt.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyTransferToLayingIn.String()).
Joins("LEFT JOIN laying_transfers lt ON lt.id = ltt.laying_transfer_id").
Joins("LEFT JOIN product_warehouses pw_ltt ON pw_ltt.id = ltt.product_warehouse_id").
Joins("LEFT JOIN adjustment_stocks ast ON ast.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyAdjustmentIn.String()).
Joins("LEFT JOIN project_flock_populations pfp ON pfp.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyProjectFlockPopulation.String()).
Joins("LEFT JOIN project_chickins pc ON pc.id = pfp.project_chickin_id").
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.stockable_type <> ?", fifo.StockableKeyProjectFlockPopulation.String()).
Where("f.name IN ?", sapronakFlagsAll).
@@ -1040,12 +1041,12 @@ func (r *ClosingRepositoryImpl) FetchSapronakUsageAllocatedDetails(ctx context.C
fifo.UsableKeyRecordingStock.String(), projectFlockKandangID,
fifo.UsableKeyProjectChickin.String(), projectFlockKandangID,
)
query = r.joinSapronakProductFlag(query, "p").
query = r.joinSapronakProductFlag(query, "p_resolve").
Group(`
pw.product_id, p.name, f.name,
p_resolve.id, p_resolve.name, f.name,
pi.received_date, st.transfer_date, lt.transfer_date, ast.created_at, pc.chick_in_date, r.record_datetime,
po.po_number, st.movement_number, lt.transfer_number, ast.id, pc.id, r.id,
pi.price, p.product_price
pi.price, p_resolve.product_price
`)
return scanAndGroupDetails(query)
@@ -294,6 +294,9 @@ func (s closingKeuanganService) calculateProductionData(c *fiber.Ctx, projectFlo
func (s closingKeuanganService) buildHPPSection(c *fiber.Ctx, projectFlock *entity.ProjectFlock, projectFlockKandangs []entity.ProjectFlockKandang, costs *CostData, production *ProductionData) dto.HPPSection {
actualPopulation := production.TotalPopulationIn - production.TotalDepletion
if lastPopulation, ok := s.getLastPopulationFromRecordings(c, projectFlockKandangs); ok {
actualPopulation = lastPopulation
}
totalWeightProduced := production.TotalWeightProduced
totalEggWeightKg := production.TotalEggWeightKg
@@ -529,6 +532,35 @@ func (s closingKeuanganService) buildProfitLossSection(projectFlock *entity.Proj
return dto.ToProfitLossSection(plItems, plSummary)
}
func (s closingKeuanganService) getLastPopulationFromRecordings(c *fiber.Ctx, projectFlockKandangs []entity.ProjectFlockKandang) (float64, bool) {
if s.RecordingRepo == nil || len(projectFlockKandangs) == 0 {
return 0, false
}
total := 0.0
recordedCount := 0
for _, kandang := range projectFlockKandangs {
latest, err := s.RecordingRepo.GetLatestByProjectFlockKandangID(c.Context(), kandang.Id)
if err != nil {
s.Log.Errorf("Failed to fetch latest recording for project_flock_kandang_id=%d: %+v", kandang.Id, err)
return 0, false
}
if latest == nil || latest.TotalChickQty == nil {
continue
}
recordedCount++
if *latest.TotalChickQty > 0 {
total += *latest.TotalChickQty
}
}
if recordedCount != len(projectFlockKandangs) {
return 0, false
}
return total, true
}
func containsFlag(flags []entity.Flag, name string) bool {
for _, flag := range flags {
if flag.Name == name {
@@ -18,8 +18,8 @@ import (
)
type SapronakService interface {
GetSapronakByProject(ctx *fiber.Ctx, projectFlockID uint, flag string) ([]dto.SapronakReportDTO, error)
GetSapronakByKandang(ctx *fiber.Ctx, projectFlockID uint, pfkID uint, flag string) (*dto.SapronakReportDTO, error)
GetSapronakByProject(ctx *fiber.Ctx, projectFlockID uint, flag string) ([]dto.SapronakReportDTO, map[uint][]string, error)
GetSapronakByKandang(ctx *fiber.Ctx, projectFlockID uint, pfkID uint, flag string) (*dto.SapronakReportDTO, map[uint][]string, error)
}
type sapronakService struct {
@@ -42,9 +42,9 @@ func NewSapronakService(
}
}
func (s sapronakService) GetSapronakByProject(c *fiber.Ctx, projectFlockID uint, flag string) ([]dto.SapronakReportDTO, error) {
func (s sapronakService) GetSapronakByProject(c *fiber.Ctx, projectFlockID uint, flag string) ([]dto.SapronakReportDTO, map[uint][]string, error) {
if projectFlockID == 0 {
return nil, fiber.NewError(fiber.StatusBadRequest, "project_flock_id is required")
return nil, nil, fiber.NewError(fiber.StatusBadRequest, "project_flock_id is required")
}
reports, err := s.computeSapronakReports(c.Context(), &validation.CountSapronakQuery{
ProjectFlockID: projectFlockID,
@@ -52,19 +52,27 @@ func (s sapronakService) GetSapronakByProject(c *fiber.Ctx, projectFlockID uint,
Flag: flag,
})
if err != nil {
return nil, err
return nil, nil, err
}
if len(reports) <= 1 {
return reports, nil
flags, err := s.collectProductFlags(c.Context(), reports)
if err != nil {
return nil, nil, err
}
return reports, flags, nil
}
combined := s.combineSapronakReports(reports, projectFlockID)
return []dto.SapronakReportDTO{combined}, nil
flags, err := s.collectProductFlags(c.Context(), []dto.SapronakReportDTO{combined})
if err != nil {
return nil, nil, err
}
return []dto.SapronakReportDTO{combined}, flags, nil
}
func (s sapronakService) GetSapronakByKandang(c *fiber.Ctx, projectFlockID uint, pfkID uint, flag string) (*dto.SapronakReportDTO, error) {
func (s sapronakService) GetSapronakByKandang(c *fiber.Ctx, projectFlockID uint, pfkID uint, flag string) (*dto.SapronakReportDTO, map[uint][]string, error) {
if projectFlockID == 0 || pfkID == 0 {
return nil, fiber.NewError(fiber.StatusBadRequest, "project_flock_id and project_flock_kandang_id are required")
return nil, nil, fiber.NewError(fiber.StatusBadRequest, "project_flock_id and project_flock_kandang_id are required")
}
results, err := s.computeSapronakReports(c.Context(), &validation.CountSapronakQuery{
@@ -74,16 +82,20 @@ func (s sapronakService) GetSapronakByKandang(c *fiber.Ctx, projectFlockID uint,
Flag: flag,
})
if err != nil {
return nil, err
return nil, nil, err
}
for _, res := range results {
if res.ProjectFlockID == projectFlockID && res.ProjectFlockKandangID == pfkID {
return &res, nil
flags, err := s.collectProductFlags(c.Context(), []dto.SapronakReportDTO{res})
if err != nil {
return nil, nil, err
}
return &res, flags, nil
}
}
return nil, fiber.NewError(fiber.StatusNotFound, "Sapronak for kandang not found")
return nil, nil, fiber.NewError(fiber.StatusNotFound, "Sapronak for kandang not found")
}
func (s sapronakService) computeSapronakReports(ctx context.Context, params *validation.CountSapronakQuery) ([]dto.SapronakReportDTO, error) {
@@ -136,6 +148,52 @@ func (s sapronakService) computeSapronakReports(ctx context.Context, params *val
return results, nil
}
func (s sapronakService) collectProductFlags(ctx context.Context, reports []dto.SapronakReportDTO) (map[uint][]string, error) {
productIDs := make(map[uint]struct{})
for _, report := range reports {
for _, group := range report.Groups {
for _, item := range group.Items {
if item.ProductID > 0 {
productIDs[item.ProductID] = struct{}{}
}
}
}
}
if len(productIDs) == 0 {
return map[uint][]string{}, nil
}
ids := make([]uint, 0, len(productIDs))
for id := range productIDs {
ids = append(ids, id)
}
products, err := s.Repository.GetProductsWithFlagsByIDs(ctx, ids)
if err != nil {
return nil, err
}
result := make(map[uint][]string, len(products))
for _, product := range products {
if len(product.Flags) == 0 {
continue
}
flags := make([]string, 0, len(product.Flags))
for _, flag := range product.Flags {
name := strings.TrimSpace(flag.Name)
if name == "" {
continue
}
flags = append(flags, strings.ToUpper(name))
}
if len(flags) > 0 {
result[product.Id] = flags
}
}
return result, nil
}
func (s sapronakService) loadProjectFlockKandangs(ctx context.Context, params *validation.CountSapronakQuery) ([]entity.ProjectFlockKandang, error) {
db := s.ProjectFlockKandangRepo.DB().WithContext(ctx).
Preload("ProjectFlock").
@@ -12,7 +12,7 @@ import (
)
type ConstantRepository interface {
GetConstants() map[string]interface{}
GetConstants() (map[string]interface{}, error)
}
type ConstantRepositoryImpl struct {
@@ -25,7 +25,7 @@ func NewConstantRepository(db *gorm.DB) ConstantRepository {
}
}
func (r *ConstantRepositoryImpl) GetConstants() map[string]interface{} {
func (r *ConstantRepositoryImpl) GetConstants() (map[string]interface{}, error) {
flagList := make([]string, 0)
for f := range utils.AllFlagTypes() {
flagList = append(flagList, string(f))
@@ -75,6 +75,8 @@ func (r *ConstantRepositoryImpl) GetConstants() map[string]interface{} {
})
}
adjustmentSubtypesByType := utils.AdjustmentTransactionSubtypesByTypeForFrontend()
return map[string]interface{}{
"flags": flagList,
"warehouse_types": []string{
@@ -94,6 +96,9 @@ func (r *ConstantRepositoryImpl) GetConstants() map[string]interface{} {
"BISNIS",
"INDIVIDUAL",
},
"adjustment": map[string]interface{}{
"transaction_subtypes": adjustmentSubtypesByType,
},
"approval_workflows": approvalWorkflows,
}
}, nil
}
@@ -22,5 +22,5 @@ func NewConstantService(repo repository.ConstantRepository, validate *validator.
}
func (s constantService) GetAll(c *fiber.Ctx) (map[string]interface{}, error) {
return s.Repository.GetConstants(), nil
return s.Repository.GetConstants()
}
@@ -52,6 +52,8 @@ func (u *AdjustmentController) AdjustmentHistory(c *fiber.Ctx) error {
ProductID: uint(c.QueryInt("product_id", 0)),
WarehouseID: uint(c.QueryInt("warehouse_id", 0)),
TransactionType: c.Query("transaction_type", ""),
TransactionSubtype: c.Query("transaction_subtype", ""),
FunctionCode: c.Query("function_code", ""),
}
result, totalResults, err := u.AdjustmentService.AdjustmentHistory(c, query)
@@ -17,9 +17,21 @@ type ProductRelationDTO struct {
ProductCategory *productCategoryDTO.ProductCategoryRelationDTO `json:"product_category,omitempty"`
}
type LocationRelationDTO struct {
Id uint `json:"id"`
Name string `json:"name"`
}
type ProjectFlockRelationDTO struct {
Id uint `json:"id"`
FlockName string `json:"flock_name"`
Period int `json:"period"`
}
type WarehouseRelationDTO struct {
Id uint `json:"id"`
Name string `json:"name"`
Location *LocationRelationDTO `json:"location,omitempty"`
}
type ProductWarehouseDTO struct {
@@ -29,13 +41,23 @@ type ProductWarehouseDTO struct {
Quantity float64 `json:"quantity"`
Product *ProductRelationDTO `json:"product,omitempty"`
Warehouse *WarehouseRelationDTO `json:"warehouse,omitempty"`
ProjectFlock *ProjectFlockRelationDTO `json:"project_flock,omitempty"`
}
type AdjustmentRelationDTO struct {
Id uint `json:"id"`
AdjNumber string `json:"adj_number"`
TransactionType string `json:"transaction_type"`
TransactionSubtype string `json:"transaction_subtype"`
FunctionCode string `json:"function_code"`
Qty float64 `json:"qty"`
Price float64 `json:"price"`
GrandTotal float64 `json:"grand_total"`
Increase float64 `json:"increase"`
Decrease float64 `json:"decrease"`
Note string `json:"note,omitempty"`
Notes string `json:"notes,omitempty"`
Location *LocationRelationDTO `json:"location,omitempty"`
ProjectFlock *ProjectFlockRelationDTO `json:"project_flock,omitempty"`
ProductWarehouseId uint `json:"product_warehouse_id"`
ProductWarehouse *ProductWarehouseDTO `json:"product_warehouse,omitempty"`
}
@@ -83,6 +105,28 @@ func ToWarehouseRelationDTO(e *entity.Warehouse) *WarehouseRelationDTO {
return &WarehouseRelationDTO{
Id: e.Id,
Name: e.Name,
Location: ToLocationRelationDTO(e.Location),
}
}
func ToLocationRelationDTO(e *entity.Location) *LocationRelationDTO {
if e == nil {
return nil
}
return &LocationRelationDTO{
Id: e.Id,
Name: e.Name,
}
}
func ToProjectFlockRelationDTO(e *entity.ProjectFlockKandang) *ProjectFlockRelationDTO {
if e == nil || e.ProjectFlock.Id == 0 {
return nil
}
return &ProjectFlockRelationDTO{
Id: e.ProjectFlock.Id,
FlockName: e.ProjectFlock.FlockName,
Period: e.Period,
}
}
@@ -97,15 +141,42 @@ func ToProductWarehouseDTO(e *entity.ProductWarehouse) *ProductWarehouseDTO {
Quantity: e.Quantity,
Product: ToProductRelationDTO(&e.Product),
Warehouse: ToWarehouseRelationDTO(&e.Warehouse),
ProjectFlock: ToProjectFlockRelationDTO(e.ProjectFlockKandang),
}
}
func ToAdjustmentRelationDTO(e *entity.AdjustmentStock) AdjustmentRelationDTO {
note := ""
if e.StockLog != nil {
note = e.StockLog.Notes
}
qty := e.TotalQty
if qty <= 0 {
qty = e.UsageQty + e.PendingQty
}
var location *LocationRelationDTO
var projectFlock *ProjectFlockRelationDTO
if e.ProductWarehouse != nil {
location = ToLocationRelationDTO(e.ProductWarehouse.Warehouse.Location)
projectFlock = ToProjectFlockRelationDTO(e.ProductWarehouse.ProjectFlockKandang)
}
return AdjustmentRelationDTO{
Id: e.Id,
Note: "",
AdjNumber: e.AdjNumber,
TransactionType: e.TransactionType,
TransactionSubtype: e.FunctionCode,
FunctionCode: e.FunctionCode,
Qty: qty,
Price: e.Price,
GrandTotal: e.GrandTotal,
Increase: e.TotalQty,
Decrease: e.UsageQty,
Notes: note,
Location: location,
ProjectFlock: projectFlock,
ProductWarehouseId: e.ProductWarehouseId,
ProductWarehouse: ToProductWarehouseDTO(e.ProductWarehouse),
}
@@ -34,6 +34,7 @@ func (AdjustmentModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validat
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,
@@ -74,6 +75,7 @@ func (AdjustmentModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validat
productWarehouseRepo,
adjustmentStockRepo,
fifoService,
fifoStockV2Service,
validate,
projectFlockKandangRepo,
)
@@ -2,6 +2,7 @@ package repositories
import (
"context"
"errors"
"fmt"
"strconv"
"strings"
@@ -14,11 +15,35 @@ import (
type AdjustmentStockRepository interface {
CreateOne(ctx context.Context, data *entity.AdjustmentStock, modifier func(*gorm.DB) *gorm.DB) error
GetByID(ctx context.Context, id uint, modifier func(*gorm.DB) *gorm.DB) (*entity.AdjustmentStock, error)
FindKandangIDByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint) (uint, error)
FindRoutesByFunctionCode(ctx context.Context, productID uint, functionCode string) ([]AdjustmentRouteResolution, error)
FindOverconsumeRule(ctx context.Context, lane, flagGroupCode, functionCode string) (*bool, error)
FindHistory(ctx context.Context, filter AdjustmentHistoryFilter, modifier func(*gorm.DB) *gorm.DB) ([]*entity.AdjustmentStock, int64, error)
WithTx(tx *gorm.DB) AdjustmentStockRepository
DB() *gorm.DB
GenerateSequentialNumber(ctx context.Context, prefix string) (string, error)
}
type AdjustmentRouteResolution struct {
FlagGroupCode string `gorm:"column:flag_group_code"`
Lane string `gorm:"column:lane"`
FunctionCode string `gorm:"column:function_code"`
SourceTable string `gorm:"column:source_table"`
LegacyTypeKey string `gorm:"column:legacy_type_key"`
AllowPendingDefault bool `gorm:"column:allow_pending_default"`
}
type AdjustmentHistoryFilter struct {
ProductID uint
WarehouseID uint
TransactionType string
FunctionCode string
ScopeRestrict bool
ScopeIDs []uint
Offset int
Limit int
}
type adjustmentStockRepositoryImpl struct {
db *gorm.DB
}
@@ -48,6 +73,151 @@ func (r *adjustmentStockRepositoryImpl) GetByID(ctx context.Context, id uint, mo
return &record, nil
}
func (r *adjustmentStockRepositoryImpl) FindKandangIDByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint) (uint, error) {
type pfkRow struct {
KandangID uint `gorm:"column:kandang_id"`
}
var pfk pfkRow
err := r.db.WithContext(ctx).
Table("project_flock_kandangs pfk").
Select("pfk.kandang_id").
Where("pfk.id = ?", projectFlockKandangID).
Where("pfk.closed_at IS NULL").
Take(&pfk).Error
if err != nil {
return 0, err
}
return pfk.KandangID, nil
}
func (r *adjustmentStockRepositoryImpl) FindRoutesByFunctionCode(
ctx context.Context,
productID uint,
functionCode string,
) ([]AdjustmentRouteResolution, error) {
var rows []AdjustmentRouteResolution
err := r.db.WithContext(ctx).
Table("fifo_stock_v2_route_rules rr").
Select("rr.flag_group_code, rr.lane, rr.function_code, rr.source_table, rr.legacy_type_key, rr.allow_pending_default").
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.function_code = ?", functionCode).
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("CASE WHEN rr.source_table = 'adjustment_stocks' THEN 0 ELSE 1 END ASC").
Order("rr.id ASC").
Find(&rows).Error
if err != nil {
return nil, err
}
return rows, nil
}
func (r *adjustmentStockRepositoryImpl) FindOverconsumeRule(
ctx context.Context,
lane string,
flagGroupCode string,
functionCode string,
) (*bool, error) {
type selectedRow struct {
AllowOverconsume bool `gorm:"column:allow_overconsume"`
}
var selected selectedRow
err := r.db.WithContext(ctx).
Table("fifo_stock_v2_overconsume_rules").
Select("allow_overconsume").
Where("is_active = TRUE").
Where("lane = ?", lane).
Where("(flag_group_code IS NULL OR flag_group_code = ?)", flagGroupCode).
Where("(function_code IS NULL OR function_code = ?)", functionCode).
Order("CASE WHEN flag_group_code IS NULL THEN 1 ELSE 0 END ASC").
Order("CASE WHEN function_code IS NULL THEN 1 ELSE 0 END ASC").
Order("priority ASC, id ASC").
Limit(1).
Take(&selected).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &selected.AllowOverconsume, nil
}
func (r *adjustmentStockRepositoryImpl) FindHistory(
ctx context.Context,
filter AdjustmentHistoryFilter,
modifier func(*gorm.DB) *gorm.DB,
) ([]*entity.AdjustmentStock, int64, error) {
q := r.db.WithContext(ctx).Model(&entity.AdjustmentStock{}).
Preload("ProductWarehouse").
Preload("ProductWarehouse.Product").
Preload("ProductWarehouse.Warehouse").
Preload("ProductWarehouse.Warehouse.Location").
Preload("ProductWarehouse.ProjectFlockKandang").
Preload("ProductWarehouse.ProjectFlockKandang.ProjectFlock").
Preload("StockLog.CreatedUser")
if modifier != nil {
q = modifier(q)
}
if filter.ScopeRestrict {
q = q.Joins("JOIN product_warehouses pw_scope ON pw_scope.id = adjustment_stocks.product_warehouse_id").
Joins("JOIN warehouses w_scope ON w_scope.id = pw_scope.warehouse_id")
if len(filter.ScopeIDs) == 0 {
q = q.Where("1 = 0")
} else {
q = q.Where("w_scope.location_id IN ?", filter.ScopeIDs)
}
}
if filter.ProductID > 0 || filter.WarehouseID > 0 {
q = q.Joins("JOIN product_warehouses pw_filter ON pw_filter.id = adjustment_stocks.product_warehouse_id")
if filter.ProductID > 0 {
q = q.Where("pw_filter.product_id = ?", filter.ProductID)
}
if filter.WarehouseID > 0 {
q = q.Where("pw_filter.warehouse_id = ?", filter.WarehouseID)
}
}
if strings.TrimSpace(filter.TransactionType) != "" {
q = q.Where("UPPER(adjustment_stocks.transaction_type) = ?", strings.ToUpper(strings.TrimSpace(filter.TransactionType)))
}
if strings.TrimSpace(filter.FunctionCode) != "" {
q = q.Where("adjustment_stocks.function_code = ?", strings.ToUpper(strings.TrimSpace(filter.FunctionCode)))
}
var total int64
if err := q.Count(&total).Error; err != nil {
return nil, 0, err
}
var rows []entity.AdjustmentStock
if err := q.Offset(filter.Offset).Limit(filter.Limit).Order("created_at DESC").Find(&rows).Error; err != nil {
return nil, 0, err
}
result := make([]*entity.AdjustmentStock, len(rows))
for i := range rows {
result[i] = &rows[i]
}
return result, total, nil
}
func (r *adjustmentStockRepositoryImpl) WithTx(tx *gorm.DB) AdjustmentStockRepository {
return &adjustmentStockRepositoryImpl{db: tx}
}
@@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"math"
"strings"
"github.com/go-playground/validator/v10"
@@ -40,8 +41,14 @@ type adjustmentService struct {
ProjectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository
AdjustmentStockRepository adjustmentStockRepo.AdjustmentStockRepository
FifoSvc common.FifoService
FifoStockV2Svc common.FifoStockV2Service
}
const (
adjustmentLaneStockable = "STOCKABLE"
adjustmentLaneUsable = "USABLE"
)
func NewAdjustmentService(
productRepo productRepo.ProductRepository,
stockLogsRepo stockLogsRepo.StockLogRepository,
@@ -49,6 +56,7 @@ func NewAdjustmentService(
productWarehouseRepo ProductWarehouse.ProductWarehouseRepository,
adjustmentStockRepo adjustmentStockRepo.AdjustmentStockRepository,
fifoSvc common.FifoService,
fifoStockV2Svc common.FifoStockV2Service,
validate *validator.Validate,
projectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository,
) AdjustmentService {
@@ -62,6 +70,7 @@ func NewAdjustmentService(
ProjectFlockKandangRepo: projectFlockKandangRepo,
AdjustmentStockRepository: adjustmentStockRepo,
FifoSvc: fifoSvc,
FifoStockV2Svc: fifoStockV2Svc,
}
}
@@ -70,6 +79,9 @@ func (s *adjustmentService) withRelations(db *gorm.DB) *gorm.DB {
Preload("ProductWarehouse").
Preload("ProductWarehouse.Product").
Preload("ProductWarehouse.Warehouse").
Preload("ProductWarehouse.Warehouse.Location").
Preload("ProductWarehouse.ProjectFlockKandang").
Preload("ProductWarehouse.ProjectFlockKandang.ProjectFlock").
Preload("StockLog.CreatedUser")
}
@@ -94,47 +106,93 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e
if err := s.Validate.Struct(req); err != nil {
return nil, err
}
productID := req.ProductID
if productID == 0 {
return nil, fiber.NewError(fiber.StatusBadRequest, "Product is required")
}
qty := req.Qty
if qty <= 0 {
qty = req.Quantity
}
if qty <= 0 {
return nil, fiber.NewError(fiber.StatusBadRequest, "Quantity must be greater than zero")
}
functionCode := strings.ToUpper(strings.TrimSpace(req.TransactionSubtype))
if functionCode == "" {
functionCode = strings.ToUpper(strings.TrimSpace(req.TransactionSubType))
}
if functionCode == "" {
functionCode = strings.ToUpper(strings.TrimSpace(req.FunctionCode))
}
if functionCode == "" {
return nil, fiber.NewError(fiber.StatusBadRequest, "Transaction subtype is required")
}
if functionCode == string(utils.AdjustmentTransactionSubtypeRecordingDepletionIn) {
functionCode = string(utils.AdjustmentTransactionSubtypeRecordingDepletionOut)
}
warehouseID, err := s.resolveWarehouseID(c.Context(), req)
if err != nil {
return nil, err
}
note := strings.TrimSpace(req.Notes)
if note == "" {
note = strings.TrimSpace(req.Note)
}
grandTotal := math.Round((qty*req.Price)*1000) / 1000
ctx := c.Context()
actorID, err := m.ActorIDFromContext(c)
if err != nil {
return nil, err
}
if err := m.EnsureWarehouseAccess(c, s.WarehouseRepo.DB(), uint(req.WarehouseID)); err != nil {
if err := m.EnsureWarehouseAccess(c, s.WarehouseRepo.DB(), warehouseID); err != nil {
return nil, err
}
if err := common.EnsureRelations(c.Context(),
common.RelationCheck{Name: "Product", ID: &req.ProductID, Exists: s.ProductRepo.IdExists},
common.RelationCheck{Name: "Warehouse", ID: &req.WarehouseID, Exists: s.WarehouseRepo.IdExists},
if err := common.EnsureRelations(ctx,
common.RelationCheck{Name: "Product", ID: &productID, Exists: s.ProductRepo.IdExists},
common.RelationCheck{Name: "Warehouse", ID: &warehouseID, Exists: s.WarehouseRepo.IdExists},
); err != nil {
return nil, err
}
if req.Quantity <= 0 {
return nil, fiber.NewError(fiber.StatusBadRequest, "Quantity must be greater than zero")
routeMeta, err := s.resolveRouteByFunctionCode(ctx, productID, functionCode)
if err != nil {
return nil, err
}
transactionType := strings.ToUpper(req.TransactionType)
if transactionType != string(utils.StockLogTransactionTypeIncrease) && transactionType != string(utils.StockLogTransactionTypeDecrease) {
return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid transaction type")
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
pfkID, err := s.getActiveProjectFlockKandangID(ctx, uint(req.WarehouseID))
pfkID, err := s.getActiveProjectFlockKandangID(ctx, warehouseID)
if err == nil && pfkID > 0 {
projectFlockKandangID = &pfkID
}
pw, err := s.ProductWarehouseRepo.FindByProductWarehouseAndPfk(ctx, uint(req.ProductID), uint(req.WarehouseID), projectFlockKandangID)
pw, err := s.ProductWarehouseRepo.FindByProductWarehouseAndPfk(ctx, productID, warehouseID, projectFlockKandangID)
if err != nil {
if !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to get product warehouse")
}
newPW := &entity.ProductWarehouse{
ProductId: uint(req.ProductID),
WarehouseId: uint(req.WarehouseID),
ProductId: productID,
WarehouseId: warehouseID,
Quantity: 0,
ProjectFlockKandangId: projectFlockKandangID,
}
@@ -153,96 +211,115 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e
return nil, err
}
err = s.StockLogsRepository.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
productWarehouseRepoTX := ProductWarehouse.NewProductWarehouseRepository(tx)
stockLogRepoTX := stockLogsRepo.NewStockLogRepository(tx)
adjustmentStockRepoTX := s.AdjustmentStockRepository.WithTx(tx)
productWarehouse, err := s.ProductWarehouseRepo.FindByProductWarehouseAndPfk(ctx, uint(req.ProductID), uint(req.WarehouseID), projectFlockKandangID)
productWarehouse, err := productWarehouseRepoTX.FindByProductWarehouseAndPfk(ctx, productID, warehouseID, projectFlockKandangID)
if err != nil {
s.Log.Errorf("Failed to get product warehouse: %+v", err)
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get product warehouse")
}
newLog := &entity.StockLog{
LoggableType: string(utils.StockLogTypeAdjustment),
LoggableId: 0,
Notes: req.Note,
ProductWarehouseId: productWarehouse.Id,
CreatedBy: actorID,
}
stockLogs, err := s.StockLogsRepository.GetByProductWarehouse(ctx, productWarehouse.Id, 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]
newLog.Stock = latestStockLog.Stock
} else {
newLog.Stock = 0
}
if transactionType == string(utils.StockLogTransactionTypeIncrease) {
newLog.Increase = req.Quantity
newLog.Stock += newLog.Increase
} else {
if productWarehouse.Quantity < req.Quantity {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Stok tidak mencukupi untuk pengurangan. Stok saat ini: %.2f, Jumlah yang akan dikurangi: %.2f", productWarehouse.Quantity, req.Quantity))
}
newLog.Decrease = req.Quantity
newLog.Stock -= newLog.Decrease
}
if err := s.StockLogsRepository.WithTx(tx).CreateOne(ctx, newLog, nil); err != nil {
return err
}
adjustmentStock := &entity.AdjustmentStock{
ProductWarehouseId: productWarehouse.Id,
TransactionType: transactionType,
FunctionCode: routeMeta.FunctionCode,
Price: req.Price,
GrandTotal: grandTotal,
}
code, err := s.AdjustmentStockRepository.GenerateSequentialNumber(ctx, utils.AdjustmentStockNumberPrefix)
code, err := adjustmentStockRepoTX.GenerateSequentialNumber(ctx, utils.AdjustmentStockNumberPrefix)
if err != nil {
return err
}
adjustmentStock.AdjNumber = code
if err := s.AdjustmentStockRepository.WithTx(tx).CreateOne(ctx, adjustmentStock, nil); err != nil {
if err := adjustmentStockRepoTX.CreateOne(ctx, adjustmentStock, nil); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to create adjustment stock record")
}
newLog.LoggableType = string(utils.StockLogTypeAdjustment)
newLog.LoggableId = adjustmentStock.Id
if err := s.StockLogsRepository.WithTx(tx).UpdateOne(ctx, newLog.Id, newLog, nil); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to link stock log")
}
var increaseQty float64
var decreaseQty float64
if transactionType == string(utils.StockLogTransactionTypeIncrease) {
note := fmt.Sprintf("Stock Adjustment IN #%d", newLog.Id)
_, err := s.FifoSvc.Replenish(ctx, common.StockReplenishRequest{
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: uint(productWarehouse.Id),
Quantity: req.Quantity,
Note: &note,
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 {
_, err := s.FifoSvc.Consume(ctx, common.StockConsumeRequest{
result, err := s.FifoSvc.Consume(ctx, common.StockConsumeRequest{
UsableKey: fifo.UsableKeyAdjustmentOut,
UsableID: adjustmentStock.Id,
ProductWarehouseID: uint(productWarehouse.Id),
Quantity: req.Quantity,
AllowPending: false,
ProductWarehouseID: productWarehouse.Id,
Quantity: qty,
AllowPending: allowPending,
Tx: tx,
})
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Failed to consume stock via FIFO: %v", err))
}
decreaseQty = result.UsageQuantity
}
default:
return fiber.NewError(fiber.StatusBadRequest, "Unsupported transaction subtype lane")
}
stockLogs, err := stockLogRepoTX.GetByProductWarehouse(ctx, productWarehouse.Id, 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: 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 {
return err
}
createdAdjustmentStockId = adjustmentStock.Id
@@ -261,6 +338,91 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e
return s.GetOne(c, createdAdjustmentStockId)
}
func (s *adjustmentService) resolveWarehouseID(ctx context.Context, req *validation.Create) (uint, error) {
if req == nil {
return 0, fiber.NewError(fiber.StatusBadRequest, "Invalid request")
}
if req.WarehouseID > 0 {
return req.WarehouseID, nil
}
if req.ProjectFlockKandangID != nil && *req.ProjectFlockKandangID > 0 {
kandangID, err := s.AdjustmentStockRepository.FindKandangIDByProjectFlockKandangID(ctx, *req.ProjectFlockKandangID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return 0, fiber.NewError(fiber.StatusBadRequest, "project_flock_kandang_id tidak valid atau tidak aktif")
}
return 0, fiber.NewError(fiber.StatusInternalServerError, "Failed to validate project_flock_kandang_id context")
}
warehouse, err := s.WarehouseRepo.GetLatestByKandangID(ctx, kandangID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return 0, fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Warehouse untuk project_flock_kandang_id %d tidak ditemukan", *req.ProjectFlockKandangID))
}
return 0, fiber.NewError(fiber.StatusInternalServerError, "Failed to resolve warehouse by project_flock_kandang_id")
}
return warehouse.Id, nil
}
return 0, fiber.NewError(fiber.StatusBadRequest, "warehouse_id atau project_flock_kandang_id wajib diisi")
}
func (s *adjustmentService) resolveRouteByFunctionCode(
ctx context.Context,
productID uint,
functionCode string,
) (*adjustmentStockRepo.AdjustmentRouteResolution, error) {
rows, err := s.AdjustmentStockRepository.FindRoutesByFunctionCode(ctx, productID, functionCode)
if err != nil {
return nil, err
}
if len(rows) == 0 {
return nil, fiber.NewError(fiber.StatusBadRequest, "Transaction subtype tidak kompatibel dengan konfigurasi FIFO v2 produk")
}
selected := rows[0]
for _, row := range rows {
if row.Lane != selected.Lane {
return nil, fiber.NewError(fiber.StatusBadRequest, "Transaction subtype ambigu: lane FIFO v2 lebih dari satu")
}
}
selected.FunctionCode = functionCode
switch selected.Lane {
case adjustmentLaneStockable, adjustmentLaneUsable:
return &selected, nil
default:
return nil, fiber.NewError(fiber.StatusBadRequest, "Transaction subtype memiliki lane FIFO v2 yang tidak didukung")
}
}
func (s *adjustmentService) resolveOverconsumePolicy(
ctx context.Context,
route *adjustmentStockRepo.AdjustmentRouteResolution,
) (bool, error) {
if route == nil {
return false, fmt.Errorf("route is required")
}
defaultValue := route.AllowPendingDefault
selected, err := s.AdjustmentStockRepository.FindOverconsumeRule(
ctx,
route.Lane,
route.FlagGroupCode,
route.FunctionCode,
)
if err != nil {
return false, err
}
if selected == nil {
return defaultValue, nil
}
return *selected, nil
}
func (s *adjustmentService) getActiveProjectFlockKandangID(ctx context.Context, warehouseID uint) (uint, error) {
warehouse, err := s.WarehouseRepo.GetByID(ctx, warehouseID, nil)
if err != nil {
@@ -291,6 +453,12 @@ func (s *adjustmentService) AdjustmentHistory(c *fiber.Ctx, query *validation.Qu
if err := s.Validate.Struct(query); err != nil {
return nil, 0, err
}
if query.Page <= 0 {
query.Page = 1
}
if query.Limit <= 0 {
query.Limit = 10
}
offset := (query.Page - 1) * query.Limit
var isProductsExist bool
@@ -313,15 +481,6 @@ func (s *adjustmentService) AdjustmentHistory(c *fiber.Ctx, query *validation.Qu
return nil, 0, fiber.NewError(fiber.StatusNotFound, "Product not found")
}
var adjustmentStocks []entity.AdjustmentStock
var total int64
q := s.AdjustmentStockRepository.DB().WithContext(c.Context()).Model(&entity.AdjustmentStock{}).
Preload("ProductWarehouse").
Preload("ProductWarehouse.Product").
Preload("ProductWarehouse.Warehouse").
Preload("StockLog.CreatedUser")
scope, scopeErr := m.ResolveLocationScope(c, s.AdjustmentStockRepository.DB())
if scopeErr != nil {
return nil, 0, scopeErr
@@ -330,42 +489,32 @@ func (s *adjustmentService) AdjustmentHistory(c *fiber.Ctx, query *validation.Qu
if len(scope.IDs) == 0 {
return []*entity.AdjustmentStock{}, 0, nil
}
q = q.Joins("JOIN product_warehouses pw_scope ON pw_scope.id = adjustment_stocks.product_warehouse_id").
Joins("JOIN warehouses w_scope ON w_scope.id = pw_scope.warehouse_id")
q = m.ApplyScopeFilter(q, scope, "w_scope.location_id")
}
if query.ProductID > 0 {
q = q.Joins("JOIN product_warehouses ON product_warehouses.id = adjustment_stocks.product_warehouse_id").
Where("product_warehouses.product_id = ?", query.ProductID)
functionCode := strings.ToUpper(strings.TrimSpace(query.TransactionSubtype))
if functionCode == "" {
functionCode = strings.ToUpper(strings.TrimSpace(query.FunctionCode))
}
transactionType := strings.ToUpper(strings.TrimSpace(query.TransactionType))
if query.WarehouseID > 0 {
q = q.Joins("JOIN product_warehouses ON product_warehouses.id = adjustment_stocks.product_warehouse_id").
Where("product_warehouses.warehouse_id = ?", query.WarehouseID)
}
if query.TransactionType != "" {
q = q.Joins("JOIN stock_logs ON stock_logs.loggable_type = ? AND stock_logs.loggable_id = adjustment_stocks.id", "ADJUSTMENT").
Where("stock_logs.transaction_type = ?", strings.ToUpper(query.TransactionType))
}
if err = q.Count(&total).Error; err != nil {
s.Log.Errorf("Failed to get adjustments: %+v", err)
return nil, 0, fiber.NewError(fiber.StatusInternalServerError, "Failed to get adjustment history")
}
err = q.Offset(offset).Limit(query.Limit).Order("created_at DESC").Find(&adjustmentStocks).Error
adjustmentStocks, total, err := s.AdjustmentStockRepository.FindHistory(
c.Context(),
adjustmentStockRepo.AdjustmentHistoryFilter{
ProductID: query.ProductID,
WarehouseID: query.WarehouseID,
TransactionType: transactionType,
FunctionCode: functionCode,
ScopeRestrict: scope.Restrict,
ScopeIDs: scope.IDs,
Offset: offset,
Limit: query.Limit,
},
nil,
)
if err != nil {
s.Log.Errorf("Failed to get adjustments: %+v", err)
return nil, 0, fiber.NewError(fiber.StatusInternalServerError, "Failed to get adjustment history")
}
result := make([]*entity.AdjustmentStock, len(adjustmentStocks))
for i := range adjustmentStocks {
result[i] = &adjustmentStocks[i]
}
return result, total, nil
return adjustmentStocks, total, nil
}
@@ -1,10 +1,16 @@
package validation
type Create struct {
ProductID uint `json:"product_id" validate:"required"`
WarehouseID uint `json:"warehouse_id" validate:"required"`
TransactionType string `json:"transaction_type" validate:"required,oneof=increase decrease"`
Quantity float64 `json:"quantity" validate:"required,gt=0"`
ProjectFlockKandangID *uint `json:"project_flock_kandang_id" validate:"omitempty,min=1"`
WarehouseID uint `json:"warehouse_id" validate:"omitempty,min=1"`
ProductID uint `json:"product_id" validate:"omitempty,min=1"`
TransactionSubtype string `json:"transaction_subtype" validate:"required_without=TransactionSubType,max=64"`
TransactionSubType string `json:"transaction_sub_type" validate:"required_without=TransactionSubtype,max=64"`
FunctionCode string `json:"function_code" validate:"omitempty,max=64"`
Qty float64 `json:"qty" validate:"omitempty,gt=0"`
Quantity float64 `json:"quantity" validate:"omitempty,gt=0"`
Price float64 `json:"price" validate:"required,gte=0"`
Notes string `json:"notes" validate:"omitempty,max=255"`
Note string `json:"note" validate:"omitempty,max=255"`
}
@@ -13,5 +19,7 @@ type Query struct {
Limit int `query:"limit" validate:"omitempty,min=1,max=100"`
ProductID uint `query:"product_id" validate:"omitempty,min=0"`
WarehouseID uint `query:"warehouse_id" validate:"omitempty,min=0"`
TransactionType string `query:"transaction_type" validate:"omitempty,oneof=increase decrease"`
TransactionType string `query:"transaction_type" validate:"omitempty,max=100"`
TransactionSubtype string `query:"transaction_subtype" validate:"omitempty,max=64"`
FunctionCode string `query:"function_code" validate:"omitempty,max=64"`
}
@@ -175,5 +175,47 @@ func (s productStockService) GetOne(c *fiber.Ctx, id uint) (*entity.Product, err
s.Log.Errorf("Failed get product by id: %+v", err)
return nil, err
}
if len(product.ProductWarehouses) > 0 {
ids := make([]uint, 0, len(product.ProductWarehouses))
for _, pw := range product.ProductWarehouses {
if pw.Id != 0 {
ids = append(ids, pw.Id)
}
}
if len(ids) > 0 {
type pendingUsageRow struct {
ProductWarehouseId uint
PendingQty float64
}
var rows []pendingUsageRow
if err := s.ProductRepository.DB().WithContext(c.Context()).
Table("recording_stocks").
Select("product_warehouse_id, COALESCE(SUM(pending_qty), 0) AS pending_qty").
Where("pending_qty > 0").
Where("product_warehouse_id IN ?", ids).
Group("product_warehouse_id").
Scan(&rows).Error; err != nil {
s.Log.Errorf("Failed to load pending usage for product warehouses: %+v", err)
return nil, err
}
if len(rows) > 0 {
pendingMap := make(map[uint]float64, len(rows))
for _, row := range rows {
pendingMap[row.ProductWarehouseId] = row.PendingQty
}
for i := range product.ProductWarehouses {
pw := &product.ProductWarehouses[i]
if pending, ok := pendingMap[pw.Id]; ok && pending != 0 {
pw.Quantity -= pending
}
}
}
}
}
return product, nil
}
@@ -100,11 +100,15 @@ func (s productWarehouseService) GetAll(c *fiber.Ctx, params *validation.Query)
offset := (params.Page - 1) * params.Limit
var marketingTypes []string
if params.Type != "" {
if !utils.IsValidMarketingType(params.Type) {
marketingTypes = utils.ParseQueryArray(params.Type)
for _, t := range marketingTypes {
if !utils.IsValidMarketingType(t) {
return nil, 0, fiber.NewError(fiber.StatusBadRequest, "Invalid marketing type")
}
}
}
cleanFlags := utils.ParseFlags(params.Flags)
@@ -135,16 +139,42 @@ func (s productWarehouseService) GetAll(c *fiber.Ctx, params *validation.Query)
db = db.Where("warehouse_id = ?", params.WarehouseId)
}
if params.Type != "" {
switch params.Type {
if len(marketingTypes) > 0 {
flagSet := make(map[string]struct{})
for _, t := range marketingTypes {
switch t {
case string(utils.MarketingTypeAyamPullet):
db = s.Repository.ApplyFlagsFilter(db, []string{string(utils.FlagDOC), string(utils.FlagPullet), string(utils.FlagLayer)})
flagSet[string(utils.FlagDOC)] = struct{}{}
flagSet[string(utils.FlagPullet)] = struct{}{}
flagSet[string(utils.FlagLayer)] = struct{}{}
case string(utils.MarketingTypeAyam):
db = s.Repository.ApplyFlagsFilter(db, []string{string(utils.FlagAyamAfkir), string(utils.FlagAyamCulling), string(utils.FlagAyamMati)})
flagSet[string(utils.FlagAyamAfkir)] = struct{}{}
flagSet[string(utils.FlagAyamCulling)] = struct{}{}
flagSet[string(utils.FlagAyamMati)] = struct{}{}
case string(utils.MarketingTypeTelur):
db = s.Repository.ApplyFlagsFilter(db, []string{string(utils.FlagTelur), string(utils.FlagTelurUtuh), string(utils.FlagTelurPecah), string(utils.FlagTelurPutih), string(utils.FlagTelurRetak)})
flagSet[string(utils.FlagTelur)] = struct{}{}
flagSet[string(utils.FlagTelurUtuh)] = struct{}{}
flagSet[string(utils.FlagTelurPecah)] = struct{}{}
flagSet[string(utils.FlagTelurPutih)] = struct{}{}
flagSet[string(utils.FlagTelurRetak)] = struct{}{}
case string(utils.MarketingTypeTrading):
db = s.Repository.ApplyFlagsFilter(db, []string{string(utils.FlagPakan), string(utils.FlagPreStarter), string(utils.FlagStarter), string(utils.FlagFinisher), string(utils.FlagOVK), string(utils.FlagObat), string(utils.FlagVitamin), string(utils.FlagKimia), string(utils.FlagEkspedisi)})
flagSet[string(utils.FlagPakan)] = struct{}{}
flagSet[string(utils.FlagPreStarter)] = struct{}{}
flagSet[string(utils.FlagStarter)] = struct{}{}
flagSet[string(utils.FlagFinisher)] = struct{}{}
flagSet[string(utils.FlagOVK)] = struct{}{}
flagSet[string(utils.FlagObat)] = struct{}{}
flagSet[string(utils.FlagVitamin)] = struct{}{}
flagSet[string(utils.FlagKimia)] = struct{}{}
flagSet[string(utils.FlagEkspedisi)] = struct{}{}
}
}
if len(flagSet) > 0 {
flags := make([]string, 0, len(flagSet))
for f := range flagSet {
flags = append(flags, f)
}
db = s.Repository.ApplyFlagsFilter(db, flags)
}
}
@@ -20,5 +20,5 @@ type Query struct {
Flags string `query:"flags" validate:"omitempty"`
KandangId uint `query:"kandang_id" validate:"omitempty,number,min=1"`
TransferContext string `query:"transfer_context" validate:"omitempty,oneof=inventory_transfer"`
Type string `query:"type" validate:"omitempty,oneof=AYAM TELUR TRADING AYAM_PULLET"`
Type string `query:"type" validate:"omitempty"`
}
@@ -52,7 +52,6 @@ func (TransferModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate
panic(err)
}
approvalRepo := commonRepo.NewApprovalRepository(db)
approvalSvc := commonSvc.NewApprovalService(approvalRepo)
if err := approvalSvc.RegisterWorkflowSteps(utils.ApprovalWorkflowExpense, utils.ExpenseApprovalSteps); err != nil {
@@ -71,6 +70,7 @@ func (TransferModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate
)
fifoService := commonSvc.NewFifoService(db, stockAllocRepo, productWarehouseRepo, utils.Log)
fifoStockV2Service := commonSvc.NewFifoStockV2Service(db, utils.Log)
expenseBridge := sTransfer.NewTransferExpenseBridge(
db,
stockTransferRepo,
@@ -111,7 +111,7 @@ func (TransferModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate
panic(err)
}
transferService := sTransfer.NewTransferService(validate, stockTransferRepo, stockTransferDetailRepo, stockTransferDeliveryRepo, StockTransferDeliveryItemRepo, stockLogsRepo, productWarehouseRepo, supplierRepo, warehouseRepo, projectFlockKandangRepo, documentSvc, fifoService, expenseBridge)
transferService := sTransfer.NewTransferService(validate, stockTransferRepo, stockTransferDetailRepo, stockTransferDeliveryRepo, StockTransferDeliveryItemRepo, stockLogsRepo, productWarehouseRepo, supplierRepo, warehouseRepo, projectFlockKandangRepo, documentSvc, fifoService, fifoStockV2Service, expenseBridge)
userService := sUser.NewUserService(userRepo, validate)
TransferRoutes(router, userService, transferService)
@@ -46,10 +46,11 @@ type transferService struct {
ProjectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository
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, 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, documentSvc commonSvc.DocumentService, fifoSvc commonSvc.FifoService, fifoStockV2Svc commonSvc.FifoStockV2Service, expenseBridge TransferExpenseBridge) TransferService {
return &transferService{
Log: utils.Log,
Validate: validate,
@@ -64,6 +65,7 @@ func NewTransferService(validate *validator.Validate, stockTransferRepo rStockTr
ProjectFlockKandangRepo: projectFlockKandangRepo,
DocumentSvc: documentSvc,
FifoSvc: fifoSvc,
FifoStockV2Svc: fifoStockV2Svc,
ExpenseBridge: expenseBridge,
}
}
@@ -442,9 +444,53 @@ 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
}
}
for _, product := range req.Products {
detail := detailMap[uint64(product.ProductID)]
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,
})
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 = 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),
@@ -456,12 +502,15 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Stok tidak mencukupi untuk produk %d di gudang asal. Error: %v", product.ProductID, err))
}
outUsageQty = consumeResult.UsageQuantity
outPendingQty = consumeResult.PendingQuantity
}
if err := tx.Model(&entity.StockTransferDetail{}).
Where("id = ?", detail.Id).
Updates(map[string]interface{}{
"usage_qty": consumeResult.UsageQuantity,
"pending_qty": consumeResult.PendingQuantity,
"usage_qty": outUsageQty,
"pending_qty": outPendingQty,
}).Error; err != nil {
s.Log.Errorf("Failed to update tracking usage for detail_id=%d, product_id=%d: %+v", detail.Id, product.ProductID, err)
return fiber.NewError(fiber.StatusInternalServerError, "Gagal memperbarui data tracking")
@@ -483,7 +532,7 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques
}
if len(stockLogs) > 0 {
latestStockLog := stockLogs[0]
stockLogDecrease.Stock -= latestStockLog.Stock - stockLogDecrease.Decrease
stockLogDecrease.Stock = latestStockLog.Stock - stockLogDecrease.Decrease
} else {
stockLogDecrease.Stock -= stockLogDecrease.Decrease
}
@@ -493,6 +542,17 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques
}
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),
@@ -505,11 +565,12 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques
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": replenishResult.AddedQuantity,
"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")
@@ -596,6 +657,53 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques
return result, nil
}
func (s *transferService) resolvePakanProducts(
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
}
productIDs := make([]uint, 0, len(products))
seen := make(map[uint]struct{}, len(products))
for _, product := range products {
if product.ProductID == 0 {
continue
}
if _, ok := seen[product.ProductID]; ok {
continue
}
seen[product.ProductID] = struct{}{}
productIDs = append(productIDs, product.ProductID)
}
if len(productIDs) == 0 {
return out, nil
}
type row struct {
ProductID uint `gorm:"column:product_id"`
}
var rows []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
if err != nil {
return nil, err
}
for _, row := range rows {
out[row.ProductID] = true
}
return out, nil
}
func (s *transferService) notifyExpenseItemsDelivered(c *fiber.Ctx, transferID uint64, payloads []TransferExpenseReceivingPayload) error {
if s.ExpenseBridge == nil || transferID == 0 || len(payloads) == 0 {
return nil
@@ -3,6 +3,7 @@ package controller
import (
"math"
"strconv"
"strings"
"gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/dto"
service "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/services"
@@ -23,9 +24,38 @@ func NewDeliveryOrdersController(deliveryOrdersService service.DeliveryOrdersSer
}
func (u *DeliveryOrdersController) GetAll(c *fiber.Ctx) error {
parseUintListParam := func(param string) ([]uint, error) {
if param == "" {
return nil, nil
}
parts := strings.Split(param, ",")
ids := make([]uint, 0, len(parts))
for _, part := range parts {
trimmed := strings.TrimSpace(part)
if trimmed == "" {
return nil, strconv.ErrSyntax
}
parsed, err := strconv.ParseUint(trimmed, 10, 64)
if err != nil {
return nil, err
}
ids = append(ids, uint(parsed))
}
return ids, nil
}
productIDs, err := parseUintListParam(c.Query("product_ids", ""))
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, "Invalid product_ids")
}
query := &validation.DeliveryOrderQuery{
Page: c.QueryInt("page", 1),
Limit: c.QueryInt("limit", 10),
Search: strings.TrimSpace(c.Query("search", "")),
ProductIDs: productIDs,
Status: strings.ReplaceAll(strings.TrimSpace(c.Query("status", "")), "_", " "),
CustomerId: uint(c.QueryInt("customer_id", 0)),
MarketingId: uint(c.QueryInt("marketing_id", 0)),
}
@@ -4,6 +4,7 @@ import (
"fmt"
"math"
"sort"
"strings"
"time"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
@@ -17,6 +18,7 @@ import (
type MarketingRelationDTO struct {
Id uint `json:"id"`
SoNumber string `json:"so_number"`
DoNumber *string `json:"do_number"`
SoDate time.Time `json:"so_date"`
Notes string `json:"notes,omitempty"`
}
@@ -95,9 +97,16 @@ type DeliveryMarketingProductDTO struct {
}
func ToMarketingRelationDTO(marketing *entity.Marketing) MarketingRelationDTO {
var doNumber *string
if doNumbers := collectDoNumbers(marketing); len(doNumbers) > 0 {
value := doNumbers[0]
doNumber = &value
}
return MarketingRelationDTO{
Id: marketing.Id,
SoNumber: marketing.SoNumber,
DoNumber: doNumber,
SoDate: marketing.SoDate,
Notes: marketing.Notes,
}
@@ -182,7 +191,6 @@ func ToMarketingListDTO(marketing *entity.Marketing, deliveryProducts []entity.M
salesOrderProducts[i] = ToDeliveryMarketingProductDTO(product, marketing.MarketingType)
}
}
return MarketingListDTO{
MarketingRelationDTO: ToMarketingRelationDTO(marketing),
Customer: customer,
@@ -239,7 +247,6 @@ func ToMarketingDetailDTO(marketing *entity.Marketing, deliveryProducts []entity
mapped := approvalDTO.ToApprovalDTO(*marketing.LatestApproval)
latestApproval = mapped
}
return MarketingDetailDTO{
MarketingRelationDTO: ToMarketingRelationDTO(marketing),
SoDocs: marketing.SoDocs,
@@ -346,11 +353,46 @@ func groupDeliveryProducts(products []MarketingDeliveryProductDTO, soNumber stri
}
func GenerateDeliveryOrderNumber(soNumber string, deliveryDate *time.Time, warehouseId uint) string {
dateStr := ""
if deliveryDate != nil {
dateStr = deliveryDate.Format("20060102")
numberPrefix := soNumber
if strings.HasPrefix(strings.ToUpper(strings.TrimSpace(soNumber)), "SO-") {
numberPrefix = "DO-" + soNumber[3:]
}
return fmt.Sprintf("%s-%s-%d", soNumber, dateStr, warehouseId)
return numberPrefix
}
func collectDoNumbers(marketing *entity.Marketing) []string {
if marketing == nil || len(marketing.Products) == 0 {
return nil
}
seen := make(map[string]struct{})
for _, product := range marketing.Products {
if product.DeliveryProduct == nil || product.DeliveryProduct.DeliveryDate == nil {
continue
}
warehouseID := product.ProductWarehouse.WarehouseId
if warehouseID == 0 && product.ProductWarehouse.Warehouse.Id != 0 {
warehouseID = product.ProductWarehouse.Warehouse.Id
}
if warehouseID == 0 {
continue
}
doNumber := GenerateDeliveryOrderNumber(marketing.SoNumber, product.DeliveryProduct.DeliveryDate, warehouseID)
if doNumber != "" {
seen[doNumber] = struct{}{}
}
}
if len(seen) == 0 {
return nil
}
result := make([]string, 0, len(seen))
for value := range seen {
result = append(result, value)
}
sort.Strings(result)
return result
}
func getVehicleNumber(e entity.MarketingProduct) string {
@@ -116,6 +116,71 @@ func (s deliveryOrdersService) GetAll(c *fiber.Ctx, params *validation.DeliveryO
Preload("Products.ProductWarehouse.Warehouse").
Preload("Products.DeliveryProduct")
if params.Status != "" {
latestApprovalSubQuery := s.MarketingRepo.DB().
WithContext(c.Context()).
Table("approvals").
Select("DISTINCT ON (approvable_id) approvable_id, step_name").
Where("approvable_type = ?", utils.ApprovalWorkflowMarketing.String()).
Order("approvable_id, id DESC")
db = db.Where(`EXISTS (
SELECT 1
FROM (?) AS latest_approval
WHERE latest_approval.approvable_id = marketings.id
AND LOWER(latest_approval.step_name) = LOWER(?)
)`, latestApprovalSubQuery, params.Status)
}
if params.Search != "" {
searchPattern := "%" + params.Search + "%"
db = db.Where(`(
marketings.so_number ILIKE ? OR
EXISTS (
SELECT 1
FROM customers c
WHERE c.id = marketings.customer_id
AND c.name ILIKE ?
) OR
EXISTS (
SELECT 1
FROM users su
WHERE su.id = marketings.sales_person_id
AND su.name ILIKE ?
) OR
EXISTS (
SELECT 1
FROM marketing_products mp
JOIN product_warehouses pw ON pw.id = mp.product_warehouse_id
JOIN products p ON p.id = pw.product_id
WHERE mp.marketing_id = marketings.id
AND p.name ILIKE ?
) OR
EXISTS (
SELECT 1
FROM marketing_products mp
JOIN product_warehouses pw ON pw.id = mp.product_warehouse_id
JOIN warehouses w ON w.id = pw.warehouse_id
WHERE mp.marketing_id = marketings.id
AND w.name ILIKE ?
)
)`, searchPattern, searchPattern, searchPattern, searchPattern, searchPattern)
}
if len(params.ProductIDs) > 0 {
db = db.Where(`EXISTS (
SELECT 1
FROM marketing_products mp
JOIN product_warehouses pw ON pw.id = mp.product_warehouse_id
JOIN products p ON p.id = pw.product_id
WHERE mp.marketing_id = marketings.id
AND p.id IN ?
)`, params.ProductIDs)
}
if params.CustomerId != 0 {
db = db.Where("marketings.customer_id = ?", params.CustomerId)
}
if scope.Restrict {
if len(scope.IDs) == 0 {
return db.Where("1 = 0")
@@ -21,6 +21,10 @@ type DeliveryOrderUpdate struct {
type DeliveryOrderQuery struct {
Page int `query:"page" validate:"omitempty,number,min=1,gt=0"`
Limit int `query:"limit" validate:"omitempty,number,min=1,max=100,gt=0"`
Search string `query:"search" validate:"omitempty,max=100"`
ProductIDs []uint `query:"product_ids" validate:"omitempty,dive,gt=0"`
Status string `query:"status" validate:"omitempty,max=50"`
CustomerId uint `query:"customer_id" validate:"omitempty,gt=0"`
MarketingId uint `query:"marketing_id" validate:"omitempty,gt=0"`
}
@@ -52,7 +52,22 @@ func (s productCategoryService) GetAll(c *fiber.Ctx, params *validation.Query) (
productCategories, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB {
db = s.withRelations(db)
if params.Search != "" {
return db.Where("name ILIKE ?", "%"+params.Search+"%")
terms := splitSearchTerms(params.Search)
if len(terms) == 0 {
return db
}
if len(terms) == 1 {
return db.Where("name ILIKE ?", "%"+terms[0]+"%")
}
for i, term := range terms {
like := "%" + term + "%"
if i == 0 {
db = db.Where("name ILIKE ?", like)
} else {
db = db.Or("name ILIKE ?", like)
}
}
return db
}
return db.Order("created_at DESC").Order("updated_at DESC")
})
@@ -64,6 +79,20 @@ func (s productCategoryService) GetAll(c *fiber.Ctx, params *validation.Query) (
return productCategories, total, nil
}
func splitSearchTerms(raw string) []string {
parts := strings.FieldsFunc(raw, func(r rune) bool {
return r == ',' || r == ';' || r == '|'
})
terms := make([]string, 0, len(parts))
for _, part := range parts {
trimmed := strings.TrimSpace(part)
if trimmed != "" {
terms = append(terms, trimmed)
}
}
return terms
}
func (s productCategoryService) GetOne(c *fiber.Ctx, id uint) (*entity.ProductCategory, error) {
productCategory, err := s.Repository.GetByID(c.Context(), id, s.withRelations)
if errors.Is(err, gorm.ErrRecordNotFound) {
@@ -36,6 +36,7 @@ type ChickinService interface {
UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint) (*entity.ProjectChickin, error)
DeleteOne(ctx *fiber.Ctx, id uint) error
Approval(ctx *fiber.Ctx, req *validation.Approve) ([]entity.ProjectChickin, error)
EnsureChickInExists(ctx context.Context, projectFlockKandangID uint) error
}
type chickinService struct {
@@ -731,6 +732,30 @@ func (s *chickinService) ReleaseChickinStocks(ctx context.Context, tx *gorm.DB,
return nil
}
func (s chickinService) EnsureChickInExists(ctx context.Context, projectFlockKandangID uint) error {
if projectFlockKandangID == 0 {
return fiber.NewError(fiber.StatusBadRequest, "Project flock kandang tidak valid")
}
populations, err := s.ProjectflockPopulationRepo.GetByProjectFlockKandangID(ctx, projectFlockKandangID)
if err != nil {
s.Log.Errorf("Failed to check project flock population for project_flock_kandang_id=%d: %+v", projectFlockKandangID, err)
return fiber.NewError(fiber.StatusInternalServerError, "Gagal memeriksa data chick in")
}
if len(populations) == 0 {
return fiber.NewError(fiber.StatusBadRequest, "Project flock belum memiliki chick in yang disetujui sehingga belum dapat membuat recording")
}
for _, population := range populations {
if population.TotalQty > 0 {
return nil
}
}
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
@@ -13,6 +13,7 @@ import (
type ProjectFlockKandangRepository interface {
GetByID(ctx context.Context, id uint) (*entity.ProjectFlockKandang, error)
GetByIDLight(ctx context.Context, id uint) (*entity.ProjectFlockKandang, error)
GetByProjectFlockAndKandang(ctx context.Context, projectFlockID uint, kandangID uint) (*entity.ProjectFlockKandang, error)
GetActiveByKandangID(ctx context.Context, kandangID uint) (*entity.ProjectFlockKandang, error)
UpdateClosedAt(ctx context.Context, id uint, t *time.Time) error
@@ -342,6 +343,17 @@ func (r *projectFlockKandangRepositoryImpl) GetByID(ctx context.Context, id uint
return record, nil
}
// GetByIDLight loads only the minimal relations needed for recording flows.
func (r *projectFlockKandangRepositoryImpl) GetByIDLight(ctx context.Context, id uint) (*entity.ProjectFlockKandang, error) {
record := new(entity.ProjectFlockKandang)
if err := r.db.WithContext(ctx).
Preload("ProjectFlock").
First(record, id).Error; err != nil {
return nil, err
}
return record, nil
}
func (r *projectFlockKandangRepositoryImpl) GetByProjectFlockAndKandang(ctx context.Context, projectFlockID uint, kandangID uint) (*entity.ProjectFlockKandang, error) {
record := new(entity.ProjectFlockKandang)
if err := r.db.WithContext(ctx).
@@ -48,6 +48,7 @@ type ProjectflockService interface {
GetProjectPeriods(ctx *fiber.Ctx, projectIDs []uint) (map[uint]int, error)
Approval(ctx *fiber.Ctx, req *validation.Approve) ([]entity.ProjectFlock, error)
Resubmit(ctx *fiber.Ctx, req *validation.Resubmit, id uint) (*entity.ProjectFlock, error)
EnsureProjectFlockApproved(ctx context.Context, projectFlockID uint) error
}
type projectflockService struct {
@@ -112,6 +113,32 @@ func (s projectflockService) approvalQueryModifier() func(*gorm.DB) *gorm.DB {
}
}
func (s projectflockService) EnsureProjectFlockApproved(ctx context.Context, projectFlockID uint) error {
if projectFlockID == 0 {
return fiber.NewError(fiber.StatusBadRequest, "Project flock kandang tidak valid")
}
approvalSvc := s.ApprovalSvc
if approvalSvc == nil {
approvalSvc = commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(s.Repository.DB()))
}
latest, err := approvalSvc.LatestByTarget(ctx, s.approvalWorkflow, projectFlockID, nil)
if err != nil {
s.Log.Errorf("Failed to check project flock %d approval status: %+v", projectFlockID, err)
return fiber.NewError(fiber.StatusInternalServerError, "Gagal memeriksa status project flock")
}
if latest == nil {
return fiber.NewError(fiber.StatusBadRequest, "Project flock masih dalam status pengajuan sehingga belum dapat membuat recording")
}
if latest.StepNumber != uint16(utils.ProjectFlockStepAktif) || latest.Action == nil || *latest.Action != entity.ApprovalActionApproved {
return fiber.NewError(fiber.StatusBadRequest, "Project flock masih dalam status pengajuan sehingga belum dapat membuat recording")
}
return nil
}
func (s projectflockService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.ProjectFlock, int64, map[uint]*flockDTO.FlockRelationDTO, error) {
if err := s.Validate.Struct(params); err != nil {
return nil, 0, nil, err
@@ -459,6 +486,15 @@ func (s projectflockService) GetProjectFlockKandangPopulation(ctx *fiber.Ctx, pr
return 0, fiber.NewError(fiber.StatusBadRequest, "project_flock_kandang_id is required")
}
total, err := s.PopulationRepo.GetAvailableQtyByProjectFlockKandangID(ctx.Context(), projectFlockKandangID)
if err != nil {
s.Log.Errorf("Failed to fetch project flock kandang population %d: %+v", projectFlockKandangID, err)
return 0, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch project flock kandang population")
}
if total > 0 {
return total, nil
}
if s.RecordingRepo != nil {
latest, err := s.RecordingRepo.GetLatestByProjectFlockKandangID(ctx.Context(), projectFlockKandangID)
if err != nil {
@@ -470,12 +506,6 @@ func (s projectflockService) GetProjectFlockKandangPopulation(ctx *fiber.Ctx, pr
}
}
total, err := s.PopulationRepo.GetAvailableQtyByProjectFlockKandangID(ctx.Context(), projectFlockKandangID)
if err != nil {
s.Log.Errorf("Failed to fetch project flock kandang population %d: %+v", projectFlockKandangID, err)
return 0, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch project flock kandang population")
}
return total, nil
}
@@ -550,21 +580,22 @@ func (s projectflockService) GetProjectFlockKandangByParams(ctx *fiber.Ctx, idSt
}
func (s projectflockService) GetAvailableDocQuantity(ctx *fiber.Ctx, kandangID uint) (float64, error) {
if s.PopulationRepo == nil {
return 0, fiber.NewError(fiber.StatusInternalServerError, "Project flock population repository is not configured")
}
wh, err := s.WarehouseRepo.GetByKandangID(ctx.Context(), kandangID)
pfk, err := s.PivotRepo.GetActiveByKandangID(ctx.Context(), kandangID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return 0, fiber.NewError(fiber.StatusNotFound, "ProjectFlockKandang not found")
}
return 0, err
}
productWarehouses, err := s.ProductWarehouseRepo.GetByCategoryCodeAndWarehouseID(ctx.Context(), "DOC", wh.Id)
total, err := s.PopulationRepo.GetAvailableQtyByProjectFlockKandangID(ctx.Context(), pfk.Id)
if err != nil {
return 0, err
}
total := 0.0
for _, pw := range productWarehouses {
total += pw.Quantity
}
return total, nil
}
@@ -27,9 +27,14 @@ func NewRecordingController(recordingService service.RecordingService) *Recordin
func (u *RecordingController) GetAll(c *fiber.Ctx) error {
projectFlockID := c.QueryInt("project_flock_kandang_id", 0)
page := c.QueryInt("page", 1)
limit := c.QueryInt("limit", 10)
offset := (page - 1) * limit
query := &validation.Query{
Page: c.QueryInt("page", 1),
Limit: c.QueryInt("limit", 10),
Page: page,
Limit: limit,
Offset: offset,
Search: c.Query("search"),
}
if projectFlockID > 0 {
@@ -79,25 +84,27 @@ func (u *RecordingController) GetOne(c *fiber.Ctx) error {
}
func (u *RecordingController) GetNextDay(c *fiber.Ctx) error {
projectFlockID := c.QueryInt("project_flock_kandang_id", 0)
if projectFlockID <= 0 {
req := new(validation.GetRecordingNextDay)
if err := c.QueryParser(req); err != nil {
return fiber.NewError(fiber.StatusBadRequest, "Invalid query params")
}
if req.ProjectFlockKandangId == 0 {
return fiber.NewError(fiber.StatusBadRequest, "project_flock_kandang_id is required")
}
recordTime := time.Now().UTC()
if recordDate := strings.TrimSpace(c.Query("record_date")); recordDate != "" {
parsed, err := time.Parse("2006-01-02", recordDate)
if req.RecordTime == nil || strings.TrimSpace(*req.RecordTime) == "" {
return fiber.NewError(fiber.StatusBadRequest, "record_date is required")
}
recordTime, err := time.Parse("2006-01-02", strings.TrimSpace(*req.RecordTime))
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, "record_date must be in YYYY-MM-DD format")
return fiber.NewError(fiber.StatusBadRequest, "record_time must be in YYYY-MM-DD format")
}
recordTime = parsed.UTC()
}
nextDay, err := u.RecordingService.GetNextDay(c, uint(projectFlockID), recordTime)
req.RecordTimeValue = &recordTime
nextDay, err := u.RecordingService.GetNextDay(c, req)
if err != nil {
return err
}
projectFlockID := req.ProjectFlockKandangId
return c.Status(fiber.StatusOK).
JSON(response.Success{
Code: fiber.StatusOK,
@@ -11,11 +11,20 @@ import (
commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository"
commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service"
rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories"
rFlock "gitlab.com/mbugroup/lti-api.git/internal/modules/master/flocks/repositories"
rKandang "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/repositories"
rNonstock "gitlab.com/mbugroup/lti-api.git/internal/modules/master/nonstocks/repositories"
rProductionStandard "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards/repositories"
sProductionStandard "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards/services"
rProduct "gitlab.com/mbugroup/lti-api.git/internal/modules/master/products/repositories"
rWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/repositories"
rChickin "gitlab.com/mbugroup/lti-api.git/internal/modules/production/chickins/repositories"
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"
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"
@@ -28,9 +37,19 @@ type RecordingModule struct{}
func (RecordingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) {
recordingRepo := rRecording.NewRecordingRepository(db)
projectFlockRepo := rProjectFlock.NewProjectflockRepository(db)
projectFlockKandangRepo := rProjectFlock.NewProjectFlockKandangRepository(db)
projectFlockPopulationRepo := rProjectFlock.NewProjectFlockPopulationRepository(db)
projectBudgetRepo := rProjectFlock.NewProjectBudgetRepository(db)
productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db)
flockRepo := rFlock.NewFlockRepository(db)
kandangRepo := rKandang.NewKandangRepository(db)
warehouseRepo := rWarehouse.NewWarehouseRepository(db)
nonstockRepo := rNonstock.NewNonstockRepository(db)
productRepo := rProduct.NewProductRepository(db)
chickinRepo := rChickin.NewChickinRepository(db)
chickinDetailRepo := rChickin.NewChickinDetailRepository(db)
transferLayingRepo := rTransferLaying.NewTransferLayingRepository(db)
stockAllocationRepo := commonRepo.NewStockAllocationRepository(db)
stockLogRepo := rStockLogs.NewStockLogRepository(db)
productionStandardRepo := rProductionStandard.NewProductionStandardRepository(db)
@@ -53,14 +72,30 @@ func (RecordingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate
ProductWarehouseID: "product_warehouse_id",
TotalQuantity: "total_qty",
TotalUsedQuantity: "total_used",
CreatedAt: "created_at",
CreatedAt: "(SELECT r.record_datetime FROM recordings r WHERE r.id = recording_eggs.recording_id)",
},
OrderBy: []string{"created_at ASC", "id ASC"},
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",
@@ -69,7 +104,7 @@ func (RecordingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate
ProductWarehouseID: "product_warehouse_id",
UsageQuantity: "usage_qty",
PendingQuantity: "pending_qty",
CreatedAt: "id",
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") {
@@ -82,9 +117,9 @@ func (RecordingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate
Columns: fifo.UsableColumns{
ID: "id",
ProductWarehouseID: "source_product_warehouse_id",
UsageQuantity: "qty",
UsageQuantity: "usage_qty",
PendingQuantity: "pending_qty",
CreatedAt: "id",
CreatedAt: "(SELECT r.record_datetime FROM recordings r WHERE r.id = recording_depletions.recording_id)",
},
ExcludedStockables: []fifo.StockableKey{
fifo.StockableKeyTransferToLayingIn,
@@ -104,9 +139,41 @@ func (RecordingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate
if err := approvalService.RegisterWorkflowSteps(utils.ApprovalWorkflowRecording, utils.RecordingApprovalSteps); err != nil {
panic(fmt.Sprintf("failed to register recording approval workflow: %v", err))
}
if err := approvalService.RegisterWorkflowSteps(utils.ApprovalWorkflowProjectFlock, utils.ProjectFlockApprovalSteps); err != nil {
panic(fmt.Sprintf("failed to register project flock approval workflow: %v", err))
}
userRepo := rUser.NewUserRepository(db)
projectFlockService := sProjectFlock.NewProjectflockService(
projectFlockRepo,
flockRepo,
kandangRepo,
projectFlockKandangRepo,
warehouseRepo,
productWarehouseRepo,
projectBudgetRepo,
nonstockRepo,
projectFlockPopulationRepo,
recordingRepo,
approvalService,
validate,
)
chickinService := sChickin.NewChickinService(
chickinRepo,
kandangRepo,
warehouseRepo,
productWarehouseRepo,
productRepo,
projectFlockRepo,
projectFlockKandangRepo,
projectFlockPopulationRepo,
chickinDetailRepo,
validate,
fifoService,
)
recordingService := sRecording.NewRecordingService(
recordingRepo,
projectFlockKandangRepo,
@@ -117,6 +184,9 @@ func (RecordingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate
fifoService,
stockLogRepo,
productionStandardService,
projectFlockService,
chickinService,
transferLayingRepo,
validate,
)
userService := sUser.NewUserService(userRepo, validate)
@@ -17,8 +17,13 @@ type RecordingRepository interface {
repository.BaseRepository[entity.Recording]
WithRelations(db *gorm.DB) *gorm.DB
WithRelationsList(db *gorm.DB) *gorm.DB
ApplyListFilters(db *gorm.DB, search string, projectFlockKandangId uint) *gorm.DB
ApplyListCountFilters(db *gorm.DB, search string, projectFlockKandangId uint) *gorm.DB
ApplySearchFilters(db *gorm.DB, rawSearch string) *gorm.DB
GetAllWithFilters(ctx context.Context, offset, limit int, search string, projectFlockKandangId uint, modifier func(*gorm.DB) *gorm.DB) ([]entity.Recording, int64, error)
GetLatestByProjectFlockKandangID(ctx context.Context, projectFlockKandangId uint) (*entity.Recording, error)
ListByProjectFlockKandangID(ctx context.Context, tx *gorm.DB, projectFlockKandangId uint, from *time.Time) ([]entity.Recording, error)
GenerateNextDay(tx *gorm.DB, projectFlockKandangId uint) (int, error)
CreateStocks(tx *gorm.DB, stocks []entity.RecordingStock) error
@@ -40,8 +45,13 @@ type RecordingRepository interface {
SumRecordingDepletions(tx *gorm.DB, recordingID uint) (float64, error)
GetCumulativeDepletionByProjectFlockKandangUntil(tx *gorm.DB, projectFlockKandangId uint, recordTime time.Time) (float64, error)
GetCumulativeDepletionByRecordingIDs(tx *gorm.DB, recordingIDs []uint) (map[uint]float64, error)
GetUniformityMeanBwByWeek(tx *gorm.DB, projectFlockKandangId uint, week int) (float64, bool, error)
FindPreviousRecording(tx *gorm.DB, projectFlockKandangId uint, currentDay int) (*entity.Recording, error)
GetPreviousTotalChickByRecordingIDs(tx *gorm.DB, recordingIDs []uint) (map[uint]*float64, error)
GetTotalChick(tx *gorm.DB, projectFlockKandangId uint) (int64, error)
GetRemainingPopulationByProjectFlockKandang(tx *gorm.DB, projectFlockKandangId uint) (float64, error)
GetTotalChickByProjectFlockKandangIDs(tx *gorm.DB, projectFlockKandangIds []uint) (map[uint]int64, error)
GetTotalChickinByProjectFlockKandang(tx *gorm.DB, projectFlockKandangId uint) (float64, error)
GetFeedUsageInGrams(tx *gorm.DB, recordingID uint) (float64, error)
GetEggSummaryByRecording(tx *gorm.DB, recordingID uint) (totalQty float64, totalWeightGrams float64, err error)
@@ -54,6 +64,8 @@ type RecordingRepository interface {
GetLatestAvgWeightByProjectFlockID(ctx context.Context, projectFlockID uint) (avgWeight float64, err error)
GetTotalEggProductionWeightByProjectFlockID(ctx context.Context, projectFlockID uint) (totalWeightKg float64, err error)
GetAverageTargetMetricsByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint, includeTargets bool) (RecordingTargetAverages, error)
ResyncProjectFlockPopulationUsage(ctx context.Context, tx *gorm.DB, projectFlockKandangID uint) error
ValidateProductWarehousesByFlags(ctx context.Context, ids []uint, flags []string) (uint, error)
}
type RecordingRepositoryImpl struct {
@@ -112,6 +124,64 @@ func (r *RecordingRepositoryImpl) WithRelations(db *gorm.DB) *gorm.DB {
Preload("Eggs.ProductWarehouse.Warehouse.Location")
}
func (r *RecordingRepositoryImpl) WithRelationsList(db *gorm.DB) *gorm.DB {
return db.
Preload("CreatedUser").
Preload("ProjectFlockKandang").
Preload("ProjectFlockKandang.Kandang").
Preload("ProjectFlockKandang.Kandang.Location").
Preload("ProjectFlockKandang.ProjectFlock").
Preload("ProjectFlockKandang.ProjectFlock.ProductionStandard")
}
func (r *RecordingRepositoryImpl) ApplyListFilters(db *gorm.DB, search string, projectFlockKandangId uint) *gorm.DB {
db = r.WithRelationsList(db)
db = db.
Joins("JOIN project_flock_kandangs pfk ON pfk.id = recordings.project_flock_kandangs_id").
Joins("JOIN project_flocks pf ON pf.id = pfk.project_flock_id")
if projectFlockKandangId != 0 {
db = db.Where("recordings.project_flock_kandangs_id = ?", projectFlockKandangId)
}
db = r.ApplySearchFilters(db, search)
return db.Order("recordings.record_datetime DESC").Order("recordings.created_at DESC")
}
func (r *RecordingRepositoryImpl) ApplyListCountFilters(db *gorm.DB, search string, projectFlockKandangId uint) *gorm.DB {
db = db.
Joins("JOIN project_flock_kandangs pfk ON pfk.id = recordings.project_flock_kandangs_id").
Joins("JOIN project_flocks pf ON pf.id = pfk.project_flock_id")
if projectFlockKandangId != 0 {
db = db.Where("recordings.project_flock_kandangs_id = ?", projectFlockKandangId)
}
db = r.ApplySearchFilters(db, search)
return db
}
func (r *RecordingRepositoryImpl) GetAllWithFilters(ctx context.Context, offset, limit int, search string, projectFlockKandangId uint, modifier func(*gorm.DB) *gorm.DB) ([]entity.Recording, int64, error) {
var (
records []entity.Recording
total int64
)
countQ := r.ApplyListCountFilters(r.DB().WithContext(ctx).Model(&entity.Recording{}), search, projectFlockKandangId)
if modifier != nil {
countQ = modifier(countQ)
}
if err := countQ.Count(&total).Error; err != nil {
return nil, 0, err
}
listQ := r.ApplyListFilters(r.DB().WithContext(ctx).Model(&entity.Recording{}), search, projectFlockKandangId)
if modifier != nil {
listQ = modifier(listQ)
}
if err := listQ.Offset(offset).Limit(limit).Find(&records).Error; err != nil {
return nil, 0, err
}
return records, total, nil
}
func (r *RecordingRepositoryImpl) ApplySearchFilters(db *gorm.DB, rawSearch string) *gorm.DB {
normalized := strings.ToLower(strings.TrimSpace(rawSearch))
if normalized == "" {
@@ -169,6 +239,27 @@ func (r *RecordingRepositoryImpl) GetLatestByProjectFlockKandangID(ctx context.C
return &record, nil
}
func (r *RecordingRepositoryImpl) ListByProjectFlockKandangID(ctx context.Context, tx *gorm.DB, projectFlockKandangId uint, from *time.Time) ([]entity.Recording, error) {
if projectFlockKandangId == 0 {
return nil, errors.New("project_flock_kandang_id is required")
}
db := tx.WithContext(ctx).
Model(&entity.Recording{}).
Where("project_flock_kandangs_id = ?", projectFlockKandangId).
Where("deleted_at IS NULL")
if from != nil {
db = db.Where("record_datetime >= ?", *from)
}
var records []entity.Recording
if err := db.Order("record_datetime ASC").Order("created_at ASC").Find(&records).Error; err != nil {
return nil, err
}
return records, nil
}
func (r *RecordingRepositoryImpl) GenerateNextDay(tx *gorm.DB, projectFlockKandangId uint) (int, error) {
var days []int
if err := tx.Model(&entity.Recording{}).
@@ -331,6 +422,68 @@ func (r *RecordingRepositoryImpl) GetCumulativeDepletionByProjectFlockKandangUnt
return total, err
}
func (r *RecordingRepositoryImpl) GetCumulativeDepletionByRecordingIDs(tx *gorm.DB, recordingIDs []uint) (map[uint]float64, error) {
result := make(map[uint]float64)
if len(recordingIDs) == 0 {
return result, nil
}
type row struct {
RecordingID uint `gorm:"column:recording_id"`
Total float64 `gorm:"column:total_qty"`
}
var rows []row
err := tx.
Table("recordings r").
Select("r.id AS recording_id, COALESCE(SUM(rd.qty), 0) AS total_qty").
Joins(`
LEFT JOIN recordings r2
ON r2.project_flock_kandangs_id = r.project_flock_kandangs_id
AND r2.record_datetime <= r.record_datetime
AND r2.deleted_at IS NULL`).
Joins("LEFT JOIN recording_depletions rd ON rd.recording_id = r2.id").
Where("r.id IN ?", recordingIDs).
Where("r.deleted_at IS NULL").
Group("r.id").
Scan(&rows).Error
if err != nil {
return nil, err
}
for _, row := range rows {
result[row.RecordingID] = row.Total
}
return result, nil
}
func (r *RecordingRepositoryImpl) GetUniformityMeanBwByWeek(tx *gorm.DB, projectFlockKandangId uint, week int) (float64, bool, error) {
if projectFlockKandangId == 0 || week <= 0 {
return 0, false, nil
}
var row struct {
ID uint
MeanUp float64
}
if err := tx.
Table("project_flock_kandang_uniformity").
Select("id, mean_up").
Where("project_flock_kandang_id = ?", projectFlockKandangId).
Where("week = ?", week).
Order("id DESC").
Limit(1).
Scan(&row).Error; err != nil {
return 0, false, err
}
if row.ID == 0 {
return 0, false, nil
}
meanBw := row.MeanUp / 1.10
return meanBw, true, nil
}
func (r *RecordingRepositoryImpl) FindPreviousRecording(tx *gorm.DB, projectFlockKandangId uint, currentDay int) (*entity.Recording, error) {
if currentDay <= 1 {
return nil, nil
@@ -353,6 +506,46 @@ func (r *RecordingRepositoryImpl) FindPreviousRecording(tx *gorm.DB, projectFloc
return &prev, nil
}
func (r *RecordingRepositoryImpl) GetPreviousTotalChickByRecordingIDs(tx *gorm.DB, recordingIDs []uint) (map[uint]*float64, error) {
result := make(map[uint]*float64)
if len(recordingIDs) == 0 {
return result, nil
}
type row struct {
RecordingID uint `gorm:"column:recording_id"`
PrevTotalChickQty *float64 `gorm:"column:prev_total_chick_qty"`
}
var rows []row
err := tx.
Table("recordings r").
Select(`
r.id AS recording_id,
(
SELECT r2.total_chick_qty
FROM recordings r2
WHERE r2.project_flock_kandangs_id = r.project_flock_kandangs_id
AND r2.day IS NOT NULL
AND r.day IS NOT NULL
AND r2.day < r.day
AND r2.deleted_at IS NULL
ORDER BY r2.day DESC
LIMIT 1
) AS prev_total_chick_qty`).
Where("r.id IN ?", recordingIDs).
Where("r.deleted_at IS NULL").
Scan(&rows).Error
if err != nil {
return nil, err
}
for _, row := range rows {
result[row.RecordingID] = row.PrevTotalChickQty
}
return result, nil
}
func (r *RecordingRepositoryImpl) GetTotalChick(tx *gorm.DB, projectFlockKandangId uint) (int64, error) {
var total float64
err := tx.
@@ -372,6 +565,57 @@ func (r *RecordingRepositoryImpl) GetTotalChick(tx *gorm.DB, projectFlockKandang
return int64(math.Round(total)), nil
}
func (r *RecordingRepositoryImpl) GetRemainingPopulationByProjectFlockKandang(tx *gorm.DB, projectFlockKandangId uint) (float64, error) {
var total float64
err := tx.
Table("project_flock_populations").
Select("COALESCE(SUM(project_flock_populations.total_qty - project_flock_populations.total_used_qty), 0) AS total_qty").
Joins("JOIN project_chickins ON project_chickins.id = project_flock_populations.project_chickin_id").
Where("project_chickins.project_flock_kandang_id = ?", projectFlockKandangId).
Scan(&total).Error
if err != nil {
return 0, err
}
if total < 0 {
total = 0
}
return total, nil
}
func (r *RecordingRepositoryImpl) GetTotalChickByProjectFlockKandangIDs(tx *gorm.DB, projectFlockKandangIds []uint) (map[uint]int64, error) {
result := make(map[uint]int64)
if len(projectFlockKandangIds) == 0 {
return result, nil
}
type row struct {
ProjectFlockKandangId uint `gorm:"column:project_flock_kandang_id"`
Total float64 `gorm:"column:total_qty"`
}
var rows []row
err := tx.
Table("project_flock_populations pfp").
Select("project_chickins.project_flock_kandang_id, COALESCE(SUM(pfp.total_qty - pfp.total_used_qty), 0) AS total_qty").
Joins("JOIN project_chickins ON project_chickins.id = pfp.project_chickin_id").
Where("project_chickins.project_flock_kandang_id IN ?", projectFlockKandangIds).
Group("project_chickins.project_flock_kandang_id").
Scan(&rows).Error
if err != nil {
return nil, err
}
for _, row := range rows {
total := math.Round(row.Total)
if total < 0 {
total = 0
}
result[row.ProjectFlockKandangId] = int64(total)
}
return result, nil
}
func (r *RecordingRepositoryImpl) GetTotalChickinByProjectFlockKandang(tx *gorm.DB, projectFlockKandangId uint) (float64, error) {
if projectFlockKandangId == 0 {
return 0, nil
@@ -557,6 +801,82 @@ func (r *RecordingRepositoryImpl) GetAverageTargetMetricsByProjectFlockKandangID
return result, nil
}
func (r *RecordingRepositoryImpl) ResyncProjectFlockPopulationUsage(ctx context.Context, tx *gorm.DB, projectFlockKandangID uint) error {
if projectFlockKandangID == 0 {
return nil
}
idsSubquery := `
SELECT pfp.id
FROM project_flock_populations pfp
JOIN project_chickins pc ON pc.id = pfp.project_chickin_id
WHERE pc.project_flock_kandang_id = ?
`
updateWithAlloc := `
UPDATE project_flock_populations p
SET total_used_qty = COALESCE(a.used, 0)
FROM (
SELECT stockable_id, SUM(qty) AS used
FROM stock_allocations
WHERE stockable_type = 'PROJECT_FLOCK_POPULATION'
AND status = 'ACTIVE'
GROUP BY stockable_id
) a
WHERE p.id = a.stockable_id
AND p.id IN (` + idsSubquery + `)
`
resetMissing := `
UPDATE project_flock_populations p
SET total_used_qty = 0
WHERE p.id IN (` + idsSubquery + `)
AND NOT EXISTS (
SELECT 1
FROM stock_allocations sa
WHERE sa.stockable_type = 'PROJECT_FLOCK_POPULATION'
AND sa.status = 'ACTIVE'
AND sa.stockable_id = p.id
)
`
db := r.DB().WithContext(ctx)
if tx != nil {
db = tx.WithContext(ctx)
}
if err := db.Exec(updateWithAlloc, projectFlockKandangID).Error; err != nil {
return err
}
if err := db.Exec(resetMissing, projectFlockKandangID).Error; err != nil {
return err
}
return nil
}
func (r *RecordingRepositoryImpl) ValidateProductWarehousesByFlags(ctx context.Context, ids []uint, flags []string) (uint, error) {
if len(ids) == 0 {
return 0, nil
}
var invalidIDs []uint
if err := r.DB().WithContext(ctx).
Table("product_warehouses pw").
Where("pw.id IN ?", ids).
Where(`NOT EXISTS (
SELECT 1 FROM flags f
WHERE f.flagable_type = 'products'
AND f.flagable_id = pw.product_id
AND UPPER(f.name) IN ?
)`, flags).
Pluck("pw.id", &invalidIDs).Error; err != nil {
return 0, err
}
if len(invalidIDs) > 0 {
return invalidIDs[0], nil
}
return 0, nil
}
func nextRecordingDay(days []int) int {
if len(days) == 0 {
return 1
File diff suppressed because it is too large Load Diff
@@ -3,43 +3,92 @@ package service
import (
"context"
"errors"
"fmt"
"math"
"strings"
"time"
commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories"
repository "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/repositories"
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/validations"
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"
recordingutil "gitlab.com/mbugroup/lti-api.git/internal/utils/recording"
"github.com/gofiber/fiber/v2"
"gorm.io/gorm"
)
type RecordingFIFOIntegrationService interface {
ConsumeRecordingStocks(ctx context.Context, tx *gorm.DB, stocks []entity.RecordingStock, note string, actorID uint) error
ReleaseRecordingStocks(ctx context.Context, tx *gorm.DB, stocks []entity.RecordingStock, note string, actorID uint) error
}
var recordingStockUsableKey = fifo.UsableKeyRecordingStock
var recordingDepletionUsableKey = fifo.UsableKeyRecordingDepletion
func NewRecordingFIFOIntegrationService(
repo repository.RecordingRepository,
productWarehouseRepo rProductWarehouse.ProductWarehouseRepository,
fifoSvc commonSvc.FifoService,
stockLogRepo rStockLogs.StockLogRepository,
) RecordingFIFOIntegrationService {
return &recordingService{
Log: utils.Log,
Repository: repo,
ProductWarehouseRepo: productWarehouseRepo,
FifoSvc: fifoSvc,
StockLogRepo: stockLogRepo,
const depletionUsageTolerance = 0.000001
func (s *recordingService) logStockTrace(action string, stock entity.RecordingStock, extra string) {
if s == nil || s.Log == nil {
return
}
usage := 0.0
if stock.UsageQty != nil {
usage = *stock.UsageQty
}
pending := 0.0
if stock.PendingQty != nil {
pending = *stock.PendingQty
}
s.Log.Infof(
"[recording-stock] action=%s recording_id=%d stock_id=%d pw=%d usage=%.3f pending=%.3f %s",
action,
stock.RecordingId,
stock.Id,
stock.ProductWarehouseId,
usage,
pending,
extra,
)
}
func (s *recordingService) logEggTrace(action string, egg entity.RecordingEgg, extra string) {
if s == nil || s.Log == nil {
return
}
weight := 0.0
if egg.Weight != nil {
weight = *egg.Weight
}
s.Log.Infof(
"[recording-egg] action=%s recording_id=%d egg_id=%d pw=%d qty=%d weight=%.3f total_qty=%.3f total_used=%.3f %s",
action,
egg.RecordingId,
egg.Id,
egg.ProductWarehouseId,
egg.Qty,
weight,
egg.TotalQty,
egg.TotalUsed,
extra,
)
}
func (s *recordingService) logDepletionTrace(action string, dep entity.RecordingDepletion, extra string) {
if s == nil || s.Log == nil {
return
}
sourceWarehouseID := uint(0)
if dep.SourceProductWarehouseId != nil {
sourceWarehouseID = *dep.SourceProductWarehouseId
}
s.Log.Infof(
"[recording-depletion] action=%s recording_id=%d depletion_id=%d source_pw=%d dest_pw=%d qty=%.3f usage=%.3f pending=%.3f %s",
action,
dep.RecordingId,
dep.Id,
sourceWarehouseID,
dep.ProductWarehouseId,
dep.Qty,
dep.UsageQty,
dep.PendingQty,
extra,
)
}
func (s *recordingService) consumeRecordingStocks(
@@ -49,9 +98,13 @@ func (s *recordingService) consumeRecordingStocks(
note string,
actorID uint,
) error {
if len(stocks) == 0 || s.FifoSvc == nil {
if len(stocks) == 0 {
return nil
}
if s.FifoSvc == nil {
s.Log.Errorf("FIFO service is not available for consuming recording stocks")
return errors.New("fifo service is not available")
}
if strings.TrimSpace(note) != "" && s.StockLogRepo == nil {
return errors.New("stock log repository is not available")
}
@@ -60,6 +113,7 @@ func (s *recordingService) consumeRecordingStocks(
if stock.Id == 0 {
continue
}
s.logStockTrace("consume:start", stock, "")
var desired float64
if stock.UsageQty != nil {
@@ -87,6 +141,7 @@ func (s *recordingService) consumeRecordingStocks(
if err := s.Repository.UpdateStockUsage(tx, stock.Id, result.UsageQuantity, result.PendingQuantity); err != nil {
return err
}
s.logStockTrace("consume:done", stock, fmt.Sprintf("desired=%.3f used=%.3f pending=%.3f", desiredTotal, result.UsageQuantity, result.PendingQuantity))
logDecrease := result.UsageQuantity
if result.PendingQuantity > 0 {
@@ -129,9 +184,13 @@ func (s *recordingService) consumeRecordingDepletions(
note string,
actorID uint,
) error {
if len(depletions) == 0 || s.FifoSvc == nil {
if len(depletions) == 0 {
return nil
}
if s.FifoSvc == nil {
s.Log.Errorf("FIFO service is not available for consuming recording depletions")
return errors.New("fifo service is not available")
}
if strings.TrimSpace(note) != "" && s.StockLogRepo == nil {
return errors.New("stock log repository is not available")
}
@@ -140,6 +199,7 @@ func (s *recordingService) consumeRecordingDepletions(
if depletion.Id == 0 {
continue
}
s.logDepletionTrace("consume:start", depletion, "")
sourceWarehouseID := uint(0)
if depletion.SourceProductWarehouseId != nil {
@@ -166,6 +226,7 @@ func (s *recordingService) consumeRecordingDepletions(
if err := s.Repository.UpdateDepletionPending(tx, depletion.Id, result.PendingQuantity); err != nil {
return err
}
s.logDepletionTrace("consume:done", depletion, fmt.Sprintf("desired=%.3f used=%.3f pending=%.3f", desired, result.UsageQuantity, result.PendingQuantity))
logDecrease := result.UsageQuantity
if result.PendingQuantity > 0 {
@@ -231,16 +292,6 @@ func (s *recordingService) consumeRecordingDepletions(
return nil
}
func (s *recordingService) ConsumeRecordingStocks(
ctx context.Context,
tx *gorm.DB,
stocks []entity.RecordingStock,
note string,
actorID uint,
) error {
return s.consumeRecordingStocks(ctx, tx, stocks, note, actorID)
}
func (s *recordingService) releaseRecordingStocks(
ctx context.Context,
tx *gorm.DB,
@@ -248,9 +299,13 @@ func (s *recordingService) releaseRecordingStocks(
note string,
actorID uint,
) error {
if len(stocks) == 0 || s.FifoSvc == nil {
if len(stocks) == 0 {
return nil
}
if s.FifoSvc == nil {
s.Log.Errorf("FIFO service is not available for releasing recording stocks")
return errors.New("fifo service is not available")
}
if strings.TrimSpace(note) != "" && s.StockLogRepo == nil {
return errors.New("stock log repository is not available")
}
@@ -259,6 +314,26 @@ func (s *recordingService) releaseRecordingStocks(
if stock.Id == 0 {
continue
}
if stock.UsageQty != nil && *stock.UsageQty > 0 {
activeCount, err := s.countActiveAllocations(ctx, tx, fifo.UsableKeyRecordingStock, stock.Id)
if err != nil {
return err
}
if activeCount == 0 {
s.Log.Warnf("recording-stock release: no active allocations, forcing usage/pending to 0 (stock_id=%d)", stock.Id)
if err := s.Repository.UpdateStockUsage(tx, stock.Id, 0, 0); err != nil {
return err
}
continue
}
if err := s.resyncStockableUsageFromAllocations(ctx, tx, fifo.UsableKeyRecordingStock, stock.Id); err != nil {
return err
}
if err := s.ensureActiveAllocations(ctx, tx, fifo.UsableKeyRecordingStock, stock.Id); err != nil {
return err
}
}
s.logStockTrace("release:start", stock, "")
if err := s.FifoSvc.ReleaseUsage(ctx, commonSvc.StockReleaseRequest{
UsableKey: recordingStockUsableKey,
UsableID: stock.Id,
@@ -271,6 +346,7 @@ func (s *recordingService) releaseRecordingStocks(
if err := s.Repository.UpdateStockUsage(tx, stock.Id, 0, 0); err != nil {
return err
}
s.logStockTrace("release:done", stock, "")
if stock.UsageQty != nil && *stock.UsageQty > 0 && strings.TrimSpace(note) != "" && actorID != 0 {
log := &entity.StockLog{
@@ -309,9 +385,13 @@ func (s *recordingService) releaseRecordingDepletions(
note string,
actorID uint,
) error {
if len(depletions) == 0 || s.FifoSvc == nil {
if len(depletions) == 0 {
return nil
}
if s.FifoSvc == nil {
s.Log.Errorf("FIFO service is not available for releasing recording depletions")
return errors.New("fifo service is not available")
}
if strings.TrimSpace(note) != "" && s.StockLogRepo == nil {
return errors.New("stock log repository is not available")
}
@@ -320,6 +400,36 @@ func (s *recordingService) releaseRecordingDepletions(
if depletion.Id == 0 {
continue
}
if depletion.UsageQty > 0 {
activeCount, err := s.countActiveAllocations(ctx, tx, fifo.UsableKeyRecordingDepletion, depletion.Id)
if err != nil {
return err
}
if activeCount == 0 {
s.Log.Warnf("recording-depletion release: no active allocations, forcing usage/pending to 0 (depletion_id=%d)", depletion.Id)
if err := s.Repository.UpdateDepletionPending(tx, depletion.Id, 0); err != nil {
return err
}
if err := tx.WithContext(ctx).
Table("recording_depletions").
Where("id = ?", depletion.Id).
Update("usage_qty", 0).Error; err != nil {
return err
}
continue
}
if err := s.resyncStockableUsageFromAllocations(ctx, tx, fifo.UsableKeyRecordingDepletion, depletion.Id); err != nil {
return err
}
if err := s.ensureActiveAllocations(ctx, tx, fifo.UsableKeyRecordingDepletion, depletion.Id); err != nil {
return err
}
}
s.logDepletionTrace("release:start", depletion, "")
if err := validateDepletionUsage(depletion); err != nil {
s.Log.Errorf("FIFO depletion mismatch for recording %d (depletion %d): qty=%.3f usage=%.3f pending=%.3f", depletion.RecordingId, depletion.Id, depletion.Qty, depletion.UsageQty, depletion.PendingQty)
return err
}
sourceWarehouseID := uint(0)
if depletion.SourceProductWarehouseId != nil {
@@ -340,6 +450,7 @@ func (s *recordingService) releaseRecordingDepletions(
if err := s.Repository.UpdateDepletionPending(tx, depletion.Id, 0); err != nil {
return err
}
s.logDepletionTrace("release:done", depletion, "")
logIncrease := depletion.Qty
if depletion.PendingQty > 0 {
@@ -405,14 +516,15 @@ func (s *recordingService) releaseRecordingDepletions(
return nil
}
func (s *recordingService) ReleaseRecordingStocks(
ctx context.Context,
tx *gorm.DB,
stocks []entity.RecordingStock,
note string,
actorID uint,
) error {
return s.releaseRecordingStocks(ctx, tx, stocks, note, actorID)
func validateDepletionUsage(depletion entity.RecordingDepletion) error {
desired := depletion.Qty + depletion.PendingQty
if math.Abs(depletion.UsageQty-desired) <= depletionUsageTolerance {
return nil
}
return fiber.NewError(
fiber.StatusConflict,
fmt.Sprintf("FIFO depletion mismatch (id=%d): qty=%.3f usage=%.3f pending=%.3f", depletion.Id, depletion.Qty, depletion.UsageQty, depletion.PendingQty),
)
}
func (s *recordingService) logRecordingEggUsage(
@@ -503,9 +615,13 @@ func (s *recordingService) replenishRecordingEggs(
note string,
actorID uint,
) error {
if len(eggs) == 0 || s.FifoSvc == nil {
if len(eggs) == 0 {
return nil
}
if s.FifoSvc == nil {
s.Log.Errorf("FIFO service is not available for replenishing recording eggs")
return errors.New("fifo service is not available")
}
if strings.TrimSpace(note) != "" && s.StockLogRepo == nil {
return errors.New("stock log repository is not available")
}
@@ -514,6 +630,7 @@ func (s *recordingService) replenishRecordingEggs(
if egg.Id == 0 || egg.ProductWarehouseId == 0 || egg.Qty <= 0 {
continue
}
s.logEggTrace("replenish:start", egg, "")
if _, err := s.FifoSvc.Replenish(ctx, commonSvc.StockReplenishRequest{
StockableKey: fifo.StockableKeyRecordingEgg,
StockableID: egg.Id,
@@ -524,6 +641,7 @@ func (s *recordingService) replenishRecordingEggs(
s.Log.Errorf("Failed to replenish FIFO stock for recording egg %d: %+v", egg.Id, err)
return err
}
s.logEggTrace("replenish:done", egg, "")
if strings.TrimSpace(note) != "" && actorID != 0 {
log := &entity.StockLog{
@@ -555,6 +673,210 @@ func (s *recordingService) replenishRecordingEggs(
return nil
}
func (s *recordingService) replenishRecordingDepletions(
ctx context.Context,
tx *gorm.DB,
depletions []entity.RecordingDepletion,
) error {
if len(depletions) == 0 {
return nil
}
if s.FifoSvc == nil {
s.Log.Errorf("FIFO service is not available for replenishing recording depletions")
return errors.New("fifo service is not available")
}
for _, depletion := range depletions {
if depletion.Id == 0 || depletion.ProductWarehouseId == 0 || depletion.Qty <= 0 {
continue
}
s.logDepletionTrace("replenish:start", depletion, "")
if _, err := s.FifoSvc.Replenish(ctx, commonSvc.StockReplenishRequest{
StockableKey: fifo.StockableKeyRecordingDepletion,
StockableID: depletion.Id,
ProductWarehouseID: depletion.ProductWarehouseId,
Quantity: depletion.Qty,
Tx: tx,
}); err != nil {
s.Log.Errorf("Failed to replenish FIFO stock for recording depletion %d: %+v", depletion.Id, err)
return err
}
s.logDepletionTrace("replenish:done", depletion, "")
}
return nil
}
func (s *recordingService) reduceRecordingDepletions(
ctx context.Context,
tx *gorm.DB,
depletions []entity.RecordingDepletion,
) error {
if len(depletions) == 0 {
return nil
}
if s.FifoSvc == nil {
s.Log.Errorf("FIFO service is not available for reducing recording depletions")
return errors.New("fifo service is not available")
}
for _, depletion := range depletions {
if depletion.Id == 0 || depletion.ProductWarehouseId == 0 || depletion.Qty <= 0 {
continue
}
s.logDepletionTrace("reduce:start", depletion, "")
if err := s.FifoSvc.AdjustStockableQuantity(ctx, commonSvc.StockAdjustRequest{
StockableKey: fifo.StockableKeyRecordingDepletion,
StockableID: depletion.Id,
ProductWarehouseID: depletion.ProductWarehouseId,
Quantity: -depletion.Qty,
Tx: tx,
}); err != nil {
s.Log.Errorf("Failed to reduce FIFO stock for recording depletion %d: %+v", depletion.Id, err)
return err
}
s.logDepletionTrace("reduce:done", depletion, "")
}
return nil
}
func (s *recordingService) reduceRecordingEggs(
ctx context.Context,
tx *gorm.DB,
eggs []entity.RecordingEgg,
) error {
if len(eggs) == 0 {
return nil
}
if s.FifoSvc == nil {
s.Log.Errorf("FIFO service is not available for reducing recording eggs")
return errors.New("fifo service is not available")
}
for _, egg := range eggs {
if egg.Id == 0 || egg.ProductWarehouseId == 0 || egg.Qty <= 0 {
continue
}
s.logEggTrace("reduce:start", egg, "")
if err := s.FifoSvc.AdjustStockableQuantity(ctx, commonSvc.StockAdjustRequest{
StockableKey: fifo.StockableKeyRecordingEgg,
StockableID: egg.Id,
ProductWarehouseID: egg.ProductWarehouseId,
Quantity: -float64(egg.Qty),
Tx: tx,
}); err != nil {
s.Log.Errorf("Failed to reduce FIFO stock for recording egg %d: %+v", egg.Id, err)
return err
}
s.logEggTrace("reduce:done", egg, "")
}
return nil
}
func (s *recordingService) ensureActiveAllocations(
ctx context.Context,
tx *gorm.DB,
usableKey fifo.UsableKey,
usableID uint,
) error {
if usableID == 0 {
return nil
}
var count int64
if err := tx.WithContext(ctx).
Model(&entity.StockAllocation{}).
Where("usable_type = ? AND usable_id = ? AND status = ?", usableKey, usableID, entity.StockAllocationStatusActive).
Count(&count).Error; err != nil {
return err
}
if count == 0 {
return fiber.NewError(fiber.StatusConflict, fmt.Sprintf("no active allocations for usable %s id=%d", usableKey, usableID))
}
return nil
}
func (s *recordingService) countActiveAllocations(
ctx context.Context,
tx *gorm.DB,
usableKey fifo.UsableKey,
usableID uint,
) (int64, error) {
if usableID == 0 {
return 0, nil
}
var count int64
if err := tx.WithContext(ctx).
Model(&entity.StockAllocation{}).
Where("usable_type = ? AND usable_id = ? AND status = ?", usableKey, usableID, entity.StockAllocationStatusActive).
Count(&count).Error; err != nil {
return 0, err
}
return count, nil
}
func (s *recordingService) resyncStockableUsageFromAllocations(
ctx context.Context,
tx *gorm.DB,
usableKey fifo.UsableKey,
usableID uint,
) error {
if usableID == 0 {
return nil
}
type stockableRef struct {
StockableType string
StockableID uint
}
var refs []stockableRef
if err := tx.WithContext(ctx).
Model(&entity.StockAllocation{}).
Select("stockable_type, stockable_id").
Where("usable_type = ? AND usable_id = ? AND status = ?", usableKey, usableID, entity.StockAllocationStatusActive).
Group("stockable_type, stockable_id").
Scan(&refs).Error; err != nil {
return err
}
if len(refs) == 0 {
return nil
}
for _, ref := range refs {
var total float64
if err := tx.WithContext(ctx).
Model(&entity.StockAllocation{}).
Select("COALESCE(SUM(qty),0)").
Where("stockable_type = ? AND stockable_id = ? AND status = ?", ref.StockableType, ref.StockableID, entity.StockAllocationStatusActive).
Scan(&total).Error; err != nil {
return err
}
switch ref.StockableType {
case string(fifo.StockableKeyProjectFlockPopulation):
if err := tx.WithContext(ctx).
Table("project_flock_populations").
Where("id = ?", ref.StockableID).
Update("total_used_qty", total).Error; err != nil {
return err
}
case string(fifo.StockableKeyPurchaseItems):
if err := tx.WithContext(ctx).
Table("purchase_items").
Where("id = ?", ref.StockableID).
Update("total_used", total).Error; err != nil {
return err
}
default:
// no-op for other stockables
}
}
return nil
}
type desiredStock struct {
Usage float64
Pending float64
@@ -565,7 +887,7 @@ type desiredDepletion struct {
Pending float64
}
func resetStockQuantitiesForFIFO(stocks []entity.RecordingStock, enabled bool) []desiredStock {
func resetStockQuantitiesForFIFO(stocks []entity.RecordingStock) []desiredStock {
desired := make([]desiredStock, len(stocks))
for i := range stocks {
if stocks[i].UsageQty != nil {
@@ -574,9 +896,6 @@ func resetStockQuantitiesForFIFO(stocks []entity.RecordingStock, enabled bool) [
if stocks[i].PendingQty != nil {
desired[i].Pending = *stocks[i].PendingQty
}
if !enabled {
continue
}
zero := 0.0
stocks[i].UsageQty = &zero
stocks[i].PendingQty = &zero
@@ -584,39 +903,19 @@ func resetStockQuantitiesForFIFO(stocks []entity.RecordingStock, enabled bool) [
return desired
}
func applyStockDesiredQuantities(stocks []entity.RecordingStock, desired []desiredStock, enabled bool) {
if !enabled {
return
}
for i := range stocks {
if i >= len(desired) {
break
}
usage := desired[i].Usage
pending := desired[i].Pending
stocks[i].UsageQty = &usage
stocks[i].PendingQty = &pending
}
}
func resetDepletionQuantitiesForFIFO(depletions []entity.RecordingDepletion, enabled bool) []desiredDepletion {
func resetDepletionQuantitiesForFIFO(depletions []entity.RecordingDepletion) []desiredDepletion {
desired := make([]desiredDepletion, len(depletions))
for i := range depletions {
desired[i].Qty = depletions[i].Qty
desired[i].Pending = depletions[i].PendingQty
if !enabled {
continue
}
depletions[i].Qty = 0
depletions[i].UsageQty = 0
depletions[i].PendingQty = 0
}
return desired
}
func applyDepletionDesiredQuantities(depletions []entity.RecordingDepletion, desired []desiredDepletion, enabled bool) {
if !enabled {
return
}
func applyDepletionDesiredQuantities(depletions []entity.RecordingDepletion, desired []desiredDepletion) {
for i := range depletions {
if i >= len(desired) {
break
@@ -636,11 +935,8 @@ func (s *recordingService) syncRecordingStocks(
actorID uint,
) error {
if s.FifoSvc == nil {
if err := s.Repository.DeleteStocks(tx, recordingID); err != nil {
return err
}
mapped := recordingutil.MapStocks(recordingID, incoming)
return s.Repository.CreateStocks(tx, mapped)
s.Log.Errorf("FIFO service is not available for syncing recording stocks")
return errors.New("fifo service is not available")
}
existingByWarehouse := make(map[uint][]entity.RecordingStock)
@@ -701,3 +997,137 @@ func (s *recordingService) syncRecordingStocks(
}
return s.consumeRecordingStocks(ctx, tx, stocksToConsume, note, actorID)
}
func sumDepletionQty(items []entity.RecordingDepletion) float64 {
var total float64
for _, item := range items {
if item.Qty > 0 {
total += item.Qty
}
}
return total
}
func (s *recordingService) ensureDepletionWithinPopulation(ctx context.Context, tx *gorm.DB, projectFlockKandangId uint, newTotal float64, existingTotal float64) error {
if projectFlockKandangId == 0 || newTotal <= 0 {
return nil
}
totalChick, err := s.Repository.GetTotalChick(tx, projectFlockKandangId)
if err != nil {
return err
}
// totalChick already reflects existing depletions; add them back to compare the delta.
available := float64(totalChick) + existingTotal
if newTotal > available {
return fiber.NewError(fiber.StatusBadRequest, "Depletion melebihi populasi yang tersedia")
}
return nil
}
func ensureRecordingEggsUnused(eggs []entity.RecordingEgg) error {
for _, egg := range eggs {
if egg.TotalUsed > 0 {
return fiber.NewError(fiber.StatusBadRequest, "Recording egg sudah digunakan sehingga tidak dapat diubah")
}
}
return nil
}
func (s *recordingService) resolvePopulationWarehouseID(ctx context.Context, projectFlockKandangID uint) (uint, error) {
if projectFlockKandangID == 0 {
return 0, fiber.NewError(fiber.StatusBadRequest, "Project flock kandang tidak valid")
}
populations, err := s.ProjectFlockPopulationRepo.GetByProjectFlockKandangID(ctx, projectFlockKandangID)
if err != nil {
s.Log.Errorf("Failed to fetch populations for project_flock_kandang_id=%d: %+v", projectFlockKandangID, err)
return 0, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data populasi")
}
for _, pop := range populations {
if pop.ProductWarehouseId > 0 && pop.TotalQty > 0 {
return pop.ProductWarehouseId, nil
}
}
for _, pop := range populations {
if pop.ProductWarehouseId > 0 {
return pop.ProductWarehouseId, nil
}
}
return 0, fiber.NewError(fiber.StatusBadRequest, "Source product warehouse populasi tidak ditemukan")
}
func (s *recordingService) recalculateFrom(ctx context.Context, tx *gorm.DB, projectFlockKandangId uint, from time.Time) error {
if tx == nil || projectFlockKandangId == 0 || from.IsZero() {
return nil
}
fromUTC := from.UTC()
records, err := s.Repository.ListByProjectFlockKandangID(ctx, tx, projectFlockKandangId, &fromUTC)
if err != nil {
return err
}
for i := range records {
if err := s.computeAndUpdateMetrics(ctx, tx, &records[i]); err != nil {
return err
}
}
return nil
}
func (s *recordingService) rollbackRecordingInventory(ctx context.Context, tx *gorm.DB, recordingID uint, note string, actorID uint) error {
if recordingID == 0 || tx == nil {
return nil
}
if err := s.requireFIFO(); err != nil {
return err
}
oldDepletions, err := s.Repository.ListDepletions(tx, recordingID)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
s.Log.Errorf("Failed to list depletions: %+v", err)
return err
}
oldEggs, err := s.Repository.ListEggs(tx, recordingID)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
s.Log.Errorf("Failed to list eggs: %+v", err)
return err
}
if err := ensureRecordingEggsUnused(oldEggs); err != nil {
return err
}
if err := s.releaseRecordingDepletions(ctx, tx, oldDepletions, note, actorID); err != nil {
return err
}
oldStocks, err := s.Repository.ListStocks(tx, recordingID)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
s.Log.Errorf("Failed to list stocks: %+v", err)
return err
}
if err := s.releaseRecordingStocks(ctx, tx, oldStocks, note, actorID); err != nil {
return err
}
if err := s.reduceRecordingDepletions(ctx, tx, oldDepletions); err != nil {
return err
}
if err := s.reduceRecordingEggs(ctx, tx, oldEggs); err != nil {
return err
}
if err := s.logRecordingEggRollback(ctx, tx, oldEggs, note, actorID); err != nil {
return err
}
return nil
}
func (s *recordingService) requireFIFO() error {
if s.FifoSvc == nil {
s.Log.Errorf("FIFO service is not available for recording operations")
return fiber.NewError(fiber.StatusInternalServerError, "FIFO service is required for recording operations")
}
return nil
}
@@ -1,5 +1,7 @@
package validation
import "time"
type (
Stock struct {
ProductWarehouseId uint `json:"product_warehouse_id" validate:"required,number,min=1"`
@@ -35,6 +37,7 @@ type Update struct {
type Query struct {
Page int `query:"page" validate:"omitempty,number,min=1"`
Limit int `query:"limit" validate:"omitempty,number,min=1,max=100"`
Offset int `query:"-" validate:"omitempty,number,min=0"`
ProjectFlockKandangId uint `query:"project_flock_kandang_id" validate:"omitempty,number,min=1"`
Search string `query:"search" validate:"omitempty,max=50"`
}
@@ -44,3 +47,9 @@ type Approve struct {
ApprovableIds []uint `json:"approvable_ids" validate:"required_strict,min=1,dive,gt=0"`
Notes *string `json:"notes,omitempty" validate:"omitempty,max=500"`
}
type GetRecordingNextDay struct {
ProjectFlockKandangId uint `json:"project_flock_kandang_id" query:"project_flock_kandang_id" validate:"required,number,min=1"`
RecordTime *string `json:"record_date" query:"record_date" validate:"required,datetime=2006-01-02"`
RecordTimeValue *time.Time `query:"-" validate:"-"`
}
@@ -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,8 @@ type TransferLayingRelationDTO struct {
Id uint `json:"id"`
TransferNumber string `json:"transfer_number"`
TransferDate time.Time `json:"transfer_date"`
EffectiveMoveDate *time.Time `json:"effective_move_date,omitempty"`
ExecutedAt *time.Time `json:"executed_at,omitempty"`
Notes string `json:"notes"`
}
@@ -47,6 +49,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 +95,8 @@ func ToTransferLayingRelationDTO(e entity.LayingTransfer) TransferLayingRelation
Id: e.Id,
TransferNumber: e.TransferNumber,
TransferDate: e.TransferDate,
EffectiveMoveDate: e.EffectiveMoveDate,
ExecutedAt: e.ExecutedAt,
Notes: e.Notes,
}
}
@@ -190,6 +196,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 +231,8 @@ func ToTransferLayingListDTO(e entity.LayingTransfer) TransferLayingListDTO {
ToProjectFlock: toProjectFlock,
CreatedBy: e.CreatedBy,
CreatedUser: createdUser,
ExecutedBy: e.ExecutedBy,
ExecutedUser: executedUser,
CreatedAt: e.CreatedAt,
Approval: approval,
}
@@ -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)
}
@@ -10,6 +10,7 @@ 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/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 +35,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 +54,14 @@ type transferLayingService struct {
StockLogRepo rStockLogs.StockLogRepository
ApprovalService commonSvc.ApprovalService
FifoSvc commonSvc.FifoService
FifoStockV2Svc commonSvc.FifoStockV2Service
}
const (
transferToLayingFlagGroupCode = "AYAM"
transferToLayingOutFunctionCode = "TRANSFER_TO_LAYING_OUT"
)
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,21 @@ 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)
effectiveMoveDate, err := s.calculateEffectiveMoveDate(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{
"effective_move_date": effectiveMoveDate,
"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 +820,362 @@ 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.EffectiveMoveDate == nil || transfer.EffectiveMoveDate.IsZero() {
effectiveMoveDate, calcErr := s.calculateEffectiveMoveDate(c.Context(), sources)
if calcErr != nil {
return calcErr
}
if patchErr := repoTx.PatchOne(c.Context(), transfer.Id, map[string]any{
"effective_move_date": effectiveMoveDate,
}, nil); patchErr != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Gagal menyimpan tanggal efektif transfer laying")
}
transfer.EffectiveMoveDate = &effectiveMoveDate
}
effectiveMoveDate := normalizeDateOnlyUTC(*transfer.EffectiveMoveDate)
today := normalizeDateOnlyUTC(time.Now().UTC())
if today.Before(effectiveMoveDate) {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Transfer laying baru bisa dieksekusi mulai tanggal %s", effectiveMoveDate.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")
}
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
}
usageQty := 0.0
pendingQty := 0.0
if s.FifoStockV2Svc != nil {
allowPending := false
reflowResult, err := s.FifoStockV2Svc.Reflow(ctx, commonSvc.FifoStockV2ReflowRequest{
FlagGroupCode: transferToLayingFlagGroupCode,
ProductWarehouseID: *source.ProductWarehouseId,
Usable: commonSvc.FifoStockV2Ref{
ID: source.Id,
LegacyTypeKey: fifo.UsableKeyTransferToLayingOut.String(),
FunctionCode: transferToLayingOutFunctionCode,
},
DesiredQty: sourceShare,
AllowOverConsume: &allowPending,
Tx: tx,
})
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Gagal consume FIFO v2 stock: %v", err))
}
usageQty = reflowResult.Allocate.AllocatedQty
pendingQty = reflowResult.Allocate.PendingQty
} else {
consumeResult, err := s.FifoSvc.Consume(ctx, commonSvc.StockConsumeRequest{
UsableKey: fifo.UsableKeyTransferToLayingOut,
UsableID: source.Id,
ProductWarehouseID: *source.ProductWarehouseId,
Quantity: sourceShare,
AllowPending: false,
Tx: tx,
})
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Gagal consume FIFO stock: %v", err))
}
usageQty = consumeResult.UsageQuantity
pendingQty = consumeResult.PendingQuantity
}
if err := sourceRepoTx.PatchOne(ctx, source.Id, map[string]any{
"usage_qty": source.UsageQty + usageQty,
"pending_usage_qty": pendingQty,
}, nil); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Gagal update source usage qty")
}
if pendingQty > 0 || usageQty < sourceShare-1e-6 {
return fiber.NewError(
fiber.StatusBadRequest,
fmt.Sprintf("Stok sumber tidak mencukupi untuk mengeksekusi transfer laying %s", transfer.TransferNumber),
)
}
movedQty := sourceShare
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) calculateEffectiveMoveDate(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")
}
effectiveMoveDate := baselineChickInDate.AddDate(0, 0, maxGrowingWeek*7)
return normalizeDateOnlyUTC(effectiveMoveDate), 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 +1290,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{}
@@ -465,6 +465,11 @@ func (s *uniformityService) CreateOne(c *fiber.Ctx, req *validation.Create, file
); err != nil {
return err
}
if strings.EqualFold(category, string(utils.ProjectFlockCategoryGrowing)) {
if err := s.updateGrowingFcrForWeek(tx, createBody.ProjectFlockKandangId, createBody.Week, calculation.MeanUp); err != nil {
return err
}
}
return nil
}); err != nil {
s.Log.Errorf("Failed to create uniformity: %+v", err)
@@ -633,6 +638,9 @@ func (s uniformityService) UpdateOne(c *fiber.Ctx, req *validation.Update, id ui
return nil, err
}
if err := s.updateGrowingFcrFromUniformity(c.Context(), id); err != nil {
return nil, err
}
return s.GetOne(c, id)
}
@@ -694,6 +702,10 @@ func (s uniformityService) UpdateOne(c *fiber.Ctx, req *validation.Update, id ui
}
}
if err := s.updateGrowingFcrFromUniformity(c.Context(), id); err != nil {
return nil, err
}
return s.GetOne(c, id)
}
@@ -724,7 +736,48 @@ func (s uniformityService) DeleteOne(c *fiber.Ctx, id uint) error {
return err
}
if err := s.Repository.DeleteOne(c.Context(), id); err != nil {
type uniformityContext struct {
ID uint
Week int
ProjectFlockKandangId uint
Category string
}
var ctxRow uniformityContext
if err := s.Repository.DB().WithContext(c.Context()).
Table("project_flock_kandang_uniformity u").
Select("u.id, u.week, u.project_flock_kandang_id, pf.category").
Joins("JOIN project_flock_kandangs pfk ON pfk.id = u.project_flock_kandang_id").
Joins("JOIN project_flocks pf ON pf.id = pfk.project_flock_id").
Where("u.id = ?", id).
Scan(&ctxRow).Error; err != nil {
return err
}
if ctxRow.ID == 0 {
return fiber.NewError(fiber.StatusNotFound, "Uniformity not found")
}
if err := s.Repository.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error {
repoTx := s.Repository.WithTx(tx)
if err := repoTx.DeleteOne(c.Context(), id); err != nil {
return err
}
if strings.EqualFold(ctxRow.Category, string(utils.ProjectFlockCategoryGrowing)) {
startDay := (ctxRow.Week-1)*7 + 1
endDay := ctxRow.Week * 7
if ctxRow.Week > 0 {
if err := tx.Model(&entity.Recording{}).
Where("project_flock_kandangs_id = ?", ctxRow.ProjectFlockKandangId).
Where("day BETWEEN ? AND ?", startDay, endDay).
Update("fcr_value", 0).Error; err != nil {
return err
}
}
}
return nil
}); err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return fiber.NewError(fiber.StatusNotFound, "Uniformity not found")
}
@@ -734,6 +787,58 @@ func (s uniformityService) DeleteOne(c *fiber.Ctx, id uint) error {
return nil
}
func (s *uniformityService) updateGrowingFcrFromUniformity(ctx context.Context, uniformityID uint) error {
if uniformityID == 0 {
return nil
}
type uniformityRow struct {
ID uint
Week int
MeanUp float64
ProjectFlockKandangId uint
Category string
}
var row uniformityRow
if err := s.Repository.DB().WithContext(ctx).
Table("project_flock_kandang_uniformity u").
Select("u.id, u.week, u.mean_up, u.project_flock_kandang_id, pf.category").
Joins("JOIN project_flock_kandangs pfk ON pfk.id = u.project_flock_kandang_id").
Joins("JOIN project_flocks pf ON pf.id = pfk.project_flock_id").
Where("u.id = ?", uniformityID).
Scan(&row).Error; err != nil {
return err
}
if row.ID == 0 {
return nil
}
if !strings.EqualFold(row.Category, string(utils.ProjectFlockCategoryGrowing)) {
return nil
}
return s.updateGrowingFcrForWeek(s.Repository.DB().WithContext(ctx), row.ProjectFlockKandangId, row.Week, row.MeanUp)
}
func (s *uniformityService) updateGrowingFcrForWeek(tx *gorm.DB, projectFlockKandangID uint, week int, meanUp float64) error {
if tx == nil || projectFlockKandangID == 0 || week <= 0 {
return nil
}
startDay := (week-1)*7 + 1
endDay := week * 7
meanBw := meanUp / 1.10
if meanBw <= 0 {
return tx.Model(&entity.Recording{}).
Where("project_flock_kandangs_id = ?", projectFlockKandangID).
Where("day BETWEEN ? AND ?", startDay, endDay).
Update("fcr_value", 0).Error
}
return tx.Model(&entity.Recording{}).
Where("project_flock_kandangs_id = ?", projectFlockKandangID).
Where("day BETWEEN ? AND ?", startDay, endDay).
Update("fcr_value", gorm.Expr("CASE WHEN feed_intake IS NULL OR feed_intake = 0 THEN 0 ELSE feed_intake / ? END", meanBw)).Error
}
func (s uniformityService) Approval(c *fiber.Ctx, req *validation.Approve) ([]entity.ProjectFlockKandangUniformity, error) {
if err := s.Validate.Struct(req); err != nil {
return nil, err
+19 -8
View File
@@ -29,7 +29,7 @@ type PurchaseListDTO struct {
DueDate *time.Time `json:"due_date"`
CreatedUser *userDTO.UserRelationDTO `json:"created_user"`
RequesterName string `json:"requester_name"`
PoExpedition []string `json:"po_expedition"`
PoExpedition []PoExpeditionDTO `json:"po_expedition"`
Products []productDTO.ProductRelationDTO `json:"products"`
Location *locationDTO.LocationRelationDTO `json:"location"`
Area *areaDTO.AreaRelationDTO `json:"area"`
@@ -67,6 +67,12 @@ type PurchaseItemDTO struct {
VehicleNumber *string `json:"vehicle_number"`
TransportPerItem *float64 `json:"transport_per_item,omitempty"`
ExpeditionVendor *supplierDTO.SupplierRelationDTO `json:"expedition_vendor,omitempty"`
HasChickin bool `json:"has_chickin"`
}
type PoExpeditionDTO struct {
Id uint64 `json:"id"`
Refrence string `json:"refrence"`
}
func ToPurchaseRelationDTO(p *entity.Purchase) PurchaseRelationDTO {
@@ -95,6 +101,7 @@ func ToPurchaseItemDTO(item entity.PurchaseItem) PurchaseItemDTO {
TravelNumber: item.TravelNumber,
TravelDocumentPath: item.TravelNumberDocs,
VehicleNumber: item.VehicleNumber,
HasChickin: item.HasChickin,
}
if item.Product != nil && item.Product.Id != 0 {
@@ -164,12 +171,12 @@ func ToPurchaseListDTO(p entity.Purchase) PurchaseListDTO {
}
var (
poExpedition []string
poExpedition = make([]PoExpeditionDTO, 0)
location *locationDTO.LocationRelationDTO
area *areaDTO.AreaRelationDTO
)
productMap := make(map[uint]productDTO.ProductRelationDTO)
expeditionRefSet := make(map[string]struct{})
expeditionRefSet := make(map[uint64]struct{})
for i := range p.Items {
item := p.Items[i]
if item.Product != nil && item.Product.Id != 0 {
@@ -178,11 +185,15 @@ func ToPurchaseListDTO(p entity.Purchase) PurchaseListDTO {
}
}
if item.ExpenseNonstock != nil && item.ExpenseNonstock.Expense != nil {
ref := strings.TrimSpace(item.ExpenseNonstock.Expense.ReferenceNumber)
if ref != "" {
if _, exists := expeditionRefSet[ref]; !exists {
expeditionRefSet[ref] = struct{}{}
poExpedition = append(poExpedition, ref)
exp := item.ExpenseNonstock.Expense
ref := strings.TrimSpace(exp.ReferenceNumber)
if exp.Id != 0 && ref != "" {
if _, exists := expeditionRefSet[exp.Id]; !exists {
expeditionRefSet[exp.Id] = struct{}{}
poExpedition = append(poExpedition, PoExpeditionDTO{
Id: exp.Id,
Refrence: ref,
})
}
}
}
@@ -255,6 +255,39 @@ func (s *purchaseService) GetOne(c *fiber.Ctx, id uint) (*entity.Purchase, error
if err := s.attachLatestApproval(c.Context(), purchase); err != nil {
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 {
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 {
purchase.Items[i].HasChickin = true
}
}
}
}
s.applyTravelDocumentURLs(c.Context(), purchase)
return purchase, nil
@@ -498,6 +531,54 @@ func (s *purchaseService) ApproveStaffPurchase(c *fiber.Ctx, id uint, req *valid
return nil, utils.BadRequest("Items must not be empty for staff approval")
}
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 {
return nil, err
}
if len(usedIDs) > 0 {
usedSet := make(map[uint]struct{}, len(usedIDs))
for _, id := range usedIDs {
usedSet[id] = struct{}{}
}
for _, payload := range req.Items {
if payload.PurchaseItemID == 0 || payload.Qty == nil {
continue
}
if _, used := usedSet[payload.PurchaseItemID]; !used {
continue
}
item, ok := itemByID[payload.PurchaseItemID]
if !ok {
continue
}
if *payload.Qty != item.SubQty {
return nil, utils.BadRequest("Purchase sudah chickin, qty tidak bisa diubah")
}
}
}
}
}
payload, err := s.buildStaffAdjustmentPayload(c.Context(), purchase, req, syncReceiving)
if err != nil {
return nil, err
@@ -745,6 +826,54 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation
req.Items[idx].TravelDocumentPath = &uploadedURL
}
}
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 {
return nil, err
}
if len(usedIDs) > 0 {
usedSet := make(map[uint]struct{}, len(usedIDs))
for _, id := range usedIDs {
usedSet[id] = struct{}{}
}
for _, payload := range req.Items {
if _, used := usedSet[payload.PurchaseItemID]; !used {
continue
}
item, ok := itemByID[payload.PurchaseItemID]
if !ok {
continue
}
receivedQty := item.SubQty
if payload.ReceivedQty != nil {
receivedQty = *payload.ReceivedQty
}
if receivedQty != item.TotalQty {
return nil, utils.BadRequest("Purchase sudah chickin, qty penerimaan tidak bisa diubah")
}
}
}
}
}
itemMap := make(map[uint]*entity.PurchaseItem, len(purchase.Items))
for i := range purchase.Items {
@@ -912,6 +1041,7 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation
pwID uint
qty float64
}, 0, len(prepared))
resolvePendingIDs := make(map[uint]struct{})
logEntries := make([]struct {
itemID uint
pwID uint
@@ -934,6 +1064,15 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation
if err != nil {
return err
}
// Safety: ensure the PW we got matches the purchase item product.
if pwDetail, err := pwRepoTx.GetDetailByID(c.Context(), pwID); err != nil {
return err
} else if pwDetail.ProductId != uint(item.ProductId) {
return fiber.NewError(
fiber.StatusBadRequest,
fmt.Sprintf("Product warehouse %d belongs to product %d, not purchase item product %d", pwID, pwDetail.ProductId, item.ProductId),
)
}
newPWID = &pwID
deltaQty := prep.receivedQty - item.TotalQty
@@ -952,6 +1091,7 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation
pwID uint
qty float64
}{itemID: item.Id, pwID: *newPWID, qty: deltaQty})
resolvePendingIDs[*newPWID] = struct{}{}
} else {
deltas[*newPWID] += deltaQty
totalQtyDeltas[item.Id] += deltaQty
@@ -964,11 +1104,14 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation
qty float64
}{itemID: item.Id, pwID: *newPWID, qty: deltaQty})
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
@@ -1066,6 +1209,19 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation
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 {
@@ -1349,6 +1505,30 @@ 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 {
return err
}
if count > 0 {
return utils.BadRequest("Purchase already chickin, failed to delete purchase")
}
}
if err := s.rollbackPurchaseStock(ctx, tx, itemsToDelete, note, actorID); err != nil {
return err
}
@@ -1365,6 +1545,10 @@ func (s *purchaseService) DeletePurchase(c *fiber.Ctx, id uint) error {
return nil
})
if transactionErr != nil {
var fe *fiber.Error
if errors.As(transactionErr, &fe) {
return fe
}
if errors.Is(transactionErr, gorm.ErrRecordNotFound) {
return utils.NotFound("Purchase not found")
}
+130
View File
@@ -198,6 +198,136 @@ const (
TransactionTypeSaldoAwal TransactionType = "SALDO_AWAL"
)
type AdjustmentTransactionType string
const (
AdjustmentTransactionTypePembelian AdjustmentTransactionType = "PEMBELIAN"
AdjustmentTransactionTypeRecording AdjustmentTransactionType = "RECORDING"
AdjustmentTransactionTypePenjualan AdjustmentTransactionType = "PENJUALAN"
)
type AdjustmentTransactionSubtype string
const (
AdjustmentTransactionSubtypePurchaseIn AdjustmentTransactionSubtype = "PURCHASE_IN"
AdjustmentTransactionSubtypeRecordingDepletionOut AdjustmentTransactionSubtype = "RECORDING_DEPLETION_OUT"
AdjustmentTransactionSubtypeMarketingOut AdjustmentTransactionSubtype = "MARKETING_OUT"
AdjustmentTransactionSubtypeRecordingDepletionIn AdjustmentTransactionSubtype = "RECORDING_DEPLETION_IN"
AdjustmentTransactionSubtypeRecordingStockOut AdjustmentTransactionSubtype = "RECORDING_STOCK_OUT"
AdjustmentTransactionSubtypeRecordingEggIn AdjustmentTransactionSubtype = "RECORDING_EGG_IN"
)
var adjustmentSubtypesByType = map[AdjustmentTransactionType][]string{
AdjustmentTransactionTypePembelian: {
string(AdjustmentTransactionSubtypePurchaseIn),
},
AdjustmentTransactionTypeRecording: {
string(AdjustmentTransactionSubtypeRecordingStockOut),
string(AdjustmentTransactionSubtypeRecordingDepletionOut),
string(AdjustmentTransactionSubtypeRecordingDepletionIn),
string(AdjustmentTransactionSubtypeRecordingEggIn),
},
AdjustmentTransactionTypePenjualan: {
string(AdjustmentTransactionSubtypeMarketingOut),
},
}
var hiddenAdjustmentSubtypesForFrontend = map[string]struct{}{
string(AdjustmentTransactionSubtypeRecordingDepletionIn): {},
}
var adjustmentSubtypeToType = func() map[string]AdjustmentTransactionType {
out := make(map[string]AdjustmentTransactionType)
for txType, subtypes := range adjustmentSubtypesByType {
for _, subtype := range subtypes {
code := strings.ToUpper(strings.TrimSpace(subtype))
if code == "" {
continue
}
out[code] = txType
}
}
return out
}()
func AdjustmentTransactionTypes() []string {
return []string{
string(AdjustmentTransactionTypePembelian),
string(AdjustmentTransactionTypeRecording),
string(AdjustmentTransactionTypePenjualan),
}
}
func AdjustmentTransactionSubtypesByType() map[string][]string {
out := make(map[string][]string, len(adjustmentSubtypesByType))
for _, txType := range []AdjustmentTransactionType{
AdjustmentTransactionTypePembelian,
AdjustmentTransactionTypeRecording,
AdjustmentTransactionTypePenjualan,
} {
src := adjustmentSubtypesByType[txType]
dup := make([]string, len(src))
copy(dup, src)
out[string(txType)] = dup
}
return out
}
func AdjustmentTransactionSubtypes() []string {
result := make([]string, 0)
for _, txType := range []AdjustmentTransactionType{
AdjustmentTransactionTypePembelian,
AdjustmentTransactionTypeRecording,
AdjustmentTransactionTypePenjualan,
} {
result = append(result, adjustmentSubtypesByType[txType]...)
}
return result
}
func AdjustmentTransactionSubtypesByTypeForFrontend() map[string][]string {
out := make(map[string][]string, len(adjustmentSubtypesByType))
for txType, subtypes := range AdjustmentTransactionSubtypesByType() {
filtered := make([]string, 0, len(subtypes))
for _, subtype := range subtypes {
if _, hidden := hiddenAdjustmentSubtypesForFrontend[subtype]; hidden {
continue
}
filtered = append(filtered, subtype)
}
out[txType] = filtered
}
return out
}
func AdjustmentTransactionSubtypesForFrontend() []string {
result := make([]string, 0)
for _, subtype := range AdjustmentTransactionSubtypes() {
if _, hidden := hiddenAdjustmentSubtypesForFrontend[subtype]; hidden {
continue
}
result = append(result, subtype)
}
return result
}
func ResolveAdjustmentTransactionType(functionCode string) string {
code := strings.ToUpper(strings.TrimSpace(functionCode))
if txType, ok := adjustmentSubtypeToType[code]; ok {
return string(txType)
}
switch {
case strings.HasPrefix(code, "PURCHASE_"):
return string(AdjustmentTransactionTypePembelian)
case strings.HasPrefix(code, "MARKETING_"):
return string(AdjustmentTransactionTypePenjualan)
case strings.HasPrefix(code, "RECORDING_"):
return string(AdjustmentTransactionTypeRecording)
default:
return ""
}
}
// -------------------------------------------------------------------
// Payment Party
// -------------------------------------------------------------------
+1
View File
@@ -17,4 +17,5 @@ const (
StockableKeyPurchaseItems StockableKey = "PURCHASE_ITEMS"
StockableKeyProjectFlockPopulation StockableKey = "PROJECT_FLOCK_POPULATION"
StockableKeyRecordingEgg StockableKey = "RECORDING_EGG"
StockableKeyRecordingDepletion StockableKey = "RECORDING_DEPLETION_IN"
)
@@ -0,0 +1,317 @@
package recording
import (
"context"
"fmt"
"strings"
commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
rProductionStandard "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards/repositories"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
"gorm.io/gorm"
)
type warnLogger interface {
Warnf(format string, args ...any)
}
type productWarehouseExistsRepo interface {
ExistsByID(ctx context.Context, id uint) (bool, error)
}
type recordingValidationRepo interface {
ValidateProductWarehousesByFlags(ctx context.Context, ids []uint, flags []string) (uint, error)
}
func EnsureProductWarehousesExist(ctx context.Context, repo productWarehouseExistsRepo, ids []uint) error {
if repo == nil || len(ids) == 0 {
return nil
}
for _, id := range ids {
ok, err := repo.ExistsByID(ctx, id)
if err != nil {
return err
}
if !ok {
return fmt.Errorf("product warehouse %d not found", id)
}
}
return nil
}
func EnsureProductWarehousesByFlags(ctx context.Context, repo recordingValidationRepo, ids []uint, flags []string, label string) error {
if repo == nil || len(ids) == 0 {
return nil
}
invalidID, err := repo.ValidateProductWarehousesByFlags(ctx, ids, flags)
if err != nil {
return err
}
if invalidID != 0 {
return fmt.Errorf("product warehouse %d is not a %s warehouse", invalidID, label)
}
return nil
}
type idGetter[T any] func(T) uint
func CollectWarehouseIDs[T any](items []T, getID idGetter[T]) []uint {
if len(items) == 0 {
return nil
}
ids := make([]uint, 0, len(items))
for _, item := range items {
if id := getID(item); id != 0 {
ids = append(ids, id)
}
}
return ids
}
func EnsureProductWarehousesByFlagsForItems[T any](
ctx context.Context,
repo recordingValidationRepo,
items []T,
getID idGetter[T],
flags []string,
label string,
) error {
ids := CollectWarehouseIDs(items, getID)
return EnsureProductWarehousesByFlags(ctx, repo, ids, flags, label)
}
func ComputeDepletionRate(prevRecording *entity.Recording, currentDepletion float64, totalChick int64) float64 {
base := 0.0
if prevRecording != nil && prevRecording.TotalChickQty != nil && *prevRecording.TotalChickQty > 0 {
base = *prevRecording.TotalChickQty
} else if totalChick > 0 {
base = float64(totalChick) + currentDepletion
}
if base <= 0 {
return 0
}
return (currentDepletion / base) * 100
}
func AttachLatestApprovals(ctx context.Context, items []entity.Recording, approvalSvc commonSvc.ApprovalService, logger warnLogger) error {
if len(items) == 0 || approvalSvc == nil {
return nil
}
ids := make([]uint, 0, len(items))
visited := make(map[uint]struct{}, len(items))
for _, item := range items {
if item.Id == 0 {
continue
}
if _, ok := visited[item.Id]; ok {
continue
}
visited[item.Id] = struct{}{}
ids = append(ids, item.Id)
}
if len(ids) == 0 {
return nil
}
latestMap, err := approvalSvc.LatestByTargets(ctx, utils.ApprovalWorkflowRecording, ids, func(db *gorm.DB) *gorm.DB {
return db.Preload("ActionUser")
})
if err != nil {
if logger != nil {
logger.Warnf("Unable to load latest approvals for recordings: %+v", err)
}
return nil
}
if len(latestMap) == 0 {
return nil
}
for i := range items {
if items[i].Id == 0 {
continue
}
if approval, ok := latestMap[items[i].Id]; ok {
items[i].LatestApproval = approval
}
}
return nil
}
func AttachLatestApproval(ctx context.Context, item *entity.Recording, approvalSvc commonSvc.ApprovalService, logger warnLogger) error {
if item == nil || item.Id == 0 || approvalSvc == nil {
return nil
}
latest, err := approvalSvc.LatestByTarget(ctx, utils.ApprovalWorkflowRecording, item.Id, func(db *gorm.DB) *gorm.DB {
return db.Preload("ActionUser")
})
if err != nil {
if logger != nil {
logger.Warnf("Unable to load approvals for recording %d: %+v", item.Id, err)
}
return nil
}
item.LatestApproval = latest
return nil
}
type productionStandardValues struct {
HenDay *float64
HenHouse *float64
FeedIntake *float64
MaxDepletion *float64
EggMass *float64
EggWeight *float64
}
func AttachProductionStandards(ctx context.Context, db *gorm.DB, warnOnly bool, logger warnLogger, items ...*entity.Recording) error {
if len(items) == 0 {
return nil
}
type standardKey struct {
standardID uint
week int
}
type standardCacheEntry struct {
values productionStandardValues
fcr *float64
}
if db == nil {
return nil
}
standardDetailRepo := rProductionStandard.NewProductionStandardDetailRepository(db)
growthDetailRepo := rProductionStandard.NewStandardGrowthDetailRepository(db)
cache := make(map[standardKey]standardCacheEntry, len(items))
standardIDs := make(map[uint]struct{}, len(items))
for _, item := range items {
if item == nil || item.ProjectFlockKandang == nil || item.ProjectFlockKandang.ProjectFlock.Id == 0 {
continue
}
if item.ProjectFlockKandang.ProjectFlock.ProductionStandardId > 0 {
standardIDs[item.ProjectFlockKandang.ProjectFlock.ProductionStandardId] = struct{}{}
}
}
standardDetailByStd := make(map[uint]map[int]*entity.ProductionStandardDetail, len(standardIDs))
growthDetailByStd := make(map[uint]map[int]*entity.StandardGrowthDetail, len(standardIDs))
for standardID := range standardIDs {
details, err := standardDetailRepo.GetByProductionStandardID(ctx, standardID)
if err != nil {
if warnOnly {
if logger != nil {
logger.Warnf("Unable to preload production standard detail for standard %d: %+v", standardID, err)
}
} else {
return err
}
continue
}
detailMap := make(map[int]*entity.ProductionStandardDetail, len(details))
for i := range details {
detail := details[i]
detailMap[detail.Week] = &detail
}
standardDetailByStd[standardID] = detailMap
growths, err := growthDetailRepo.GetByProductionStandardID(ctx, standardID)
if err != nil {
if warnOnly {
if logger != nil {
logger.Warnf("Unable to preload standard growth detail for standard %d: %+v", standardID, err)
}
} else {
return err
}
continue
}
growthMap := make(map[int]*entity.StandardGrowthDetail, len(growths))
for i := range growths {
growth := growths[i]
growthMap[growth.Week] = &growth
}
growthDetailByStd[standardID] = growthMap
}
for _, item := range items {
if item == nil || item.ProjectFlockKandang == nil || item.ProjectFlockKandang.ProjectFlock.Id == 0 {
continue
}
standardID := item.ProjectFlockKandang.ProjectFlock.ProductionStandardId
if standardID == 0 {
continue
}
week := RecordingWeekValue(*item)
cacheKey := standardKey{standardID: standardID, week: week}
if cached, ok := cache[cacheKey]; ok {
applyProductionStandardValues(item, cached.values, cached.fcr)
continue
}
values := productionStandardValues{}
var fcr *float64
if detailMap, ok := standardDetailByStd[standardID]; ok {
if detail, ok := detailMap[week]; ok {
values.HenDay = detail.TargetHenDayProduction
values.HenHouse = detail.TargetHenHouseProduction
values.EggMass = detail.TargetEggMass
values.EggWeight = detail.TargetEggWeight
fcr = detail.StandardFCR
}
}
if growthMap, ok := growthDetailByStd[standardID]; ok {
if growth, ok := growthMap[week]; ok {
values.FeedIntake = growth.FeedIntake
values.MaxDepletion = growth.MaxDepletion
}
}
cache[cacheKey] = standardCacheEntry{values: values, fcr: fcr}
applyProductionStandardValues(item, values, fcr)
}
return nil
}
func applyProductionStandardValues(item *entity.Recording, values productionStandardValues, fcr *float64) {
item.StandardHenDay = values.HenDay
item.StandardHenHouse = values.HenHouse
item.StandardFeedIntake = values.FeedIntake
item.StandardMaxDepletion = values.MaxDepletion
item.StandardEggMass = values.EggMass
item.StandardEggWeight = values.EggWeight
item.StandardFcr = fcr
}
func RecordingWeekValue(e entity.Recording) int {
day := intValue(e.Day)
if day <= 0 {
return 0
}
weekBase := 1
if IsLayingRecording(e) {
weekBase = 18
}
return ((day - 1) / 7) + weekBase
}
func IsLayingRecording(e entity.Recording) bool {
if e.ProjectFlockKandang == nil {
return false
}
return strings.EqualFold(e.ProjectFlockKandang.ProjectFlock.Category, string(utils.ProjectFlockCategoryLaying))
}
func intValue(value *int) int {
if value == nil {
return 0
}
return *value
}
@@ -1,6 +1,9 @@
package recording
import (
"fmt"
"strings"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/validations"
)
@@ -70,3 +73,87 @@ func MapEggs(recordingID uint, createdBy uint, items []validation.Egg) []entity.
}
return result
}
type EggTotals struct {
Qty int
Weight float64
}
func StockUsageByWarehouse(items []entity.RecordingStock) map[uint]float64 {
return TotalsByWarehouse(items, func(stock entity.RecordingStock) (uint, float64) {
var usage float64
if stock.UsageQty != nil {
usage = *stock.UsageQty
}
return stock.ProductWarehouseId, usage
})
}
func StockUsageByWarehouseReq(items []validation.Stock) map[uint]float64 {
return TotalsByWarehouse(items, func(item validation.Stock) (uint, float64) {
return item.ProductWarehouseId, item.Qty
})
}
func FloatMapsEqual(a, b map[uint]float64) bool {
if len(a) != len(b) {
return false
}
for key, value := range a {
other, ok := b[key]
if !ok || !floatNearlyEqual(value, other) {
return false
}
}
return true
}
func EggTotalsEqual(a, b map[uint]EggTotals) bool {
if len(a) != len(b) {
return false
}
for key, value := range a {
other, ok := b[key]
if !ok || value.Qty != other.Qty || !floatNearlyEqual(value.Weight, other.Weight) {
return false
}
}
return true
}
func floatNearlyEqual(a, b float64) bool {
return a-b <= 0.000001 && b-a <= 0.000001
}
func TotalsByWarehouse[T any](items []T, get func(T) (uint, float64)) map[uint]float64 {
result := make(map[uint]float64)
for _, item := range items {
warehouseID, qty := get(item)
result[warehouseID] += qty
}
return result
}
func EggTotalsByWarehouse[T any](items []T, get func(T) (uint, int, *float64)) map[uint]EggTotals {
result := make(map[uint]EggTotals)
for _, item := range items {
warehouseID, qty, weightPtr := get(item)
weight := 0.0
if weightPtr != nil {
weight = *weightPtr
}
current := result[warehouseID]
current.Qty += qty
current.Weight += weight
result[warehouseID] = current
}
return result
}
func RecordingNote(action string, id uint) string {
action = strings.TrimSpace(action)
if action == "" {
return fmt.Sprintf("Recording#%d", id)
}
return fmt.Sprintf("Recording-%s#%d", action, id)
}