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
+42 -20
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).
@@ -867,27 +890,26 @@ func (s *fifoService) fetchPendingCandidates(ctx context.Context, tx *gorm.DB, p
query = query.Order(order)
}
if err := query.Find(&rows).Error; err != nil {
return nil, err
if err := query.Find(&rows).Error; err != nil {
return nil, err
}
for _, row := range rows {
if row.Pending <= 0 {
continue
}
for _, row := range rows {
if row.Pending <= 0 {
continue
}
candidates = append(candidates, pendingCandidate{
UsableKey: key,
Config: cfg,
UsableID: row.ID,
Pending: row.Pending,
CreatedAt: time.Unix(0, row.CreatedAt),
})
}
} else {
candidates = append(candidates, pendingCandidate{
UsableKey: key,
Config: cfg,
UsableID: row.ID,
Pending: row.Pending,
CreatedAt: time.Unix(0, row.CreatedAt),
})
}
} else {
var rows []struct {
ID uint
Pending float64
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)
}
+60 -54
View File
@@ -22,60 +22,61 @@ type SSOClientConfig struct {
}
var (
IsProd bool
AppHost string
Version string
LogLevel string
AppPort int
DBHost string
DBUser string
DBPassword string
DBName string
DBPort int
DBSSLMode string
DBSSLRootCert string
DBSSLCert string
DBSSLKey string
JWTSecret string
JWTAccessExp int
JWTRefreshExp int
JWTResetPasswordExp int
JWTVerifyEmailExp int
RedisURL string
CORSAllowOrigins []string
CORSAllowMethods []string
CORSAllowHeaders []string
CORSExposeHeaders []string
CORSAllowCredentials bool
CORSMaxAge int
SSOIssuer string
SSOJWKSURL string
SSOAllowedAudiences []string
SSOAuthorizeURL string
SSOTokenURL string
SSOGetMeURL string
SSOPortalURL string
SSOClients map[string]SSOClientConfig
SSOAccessCookieName string
SSORefreshCookieName string
SSOCookieDomain string
SSOCookieSecure bool
SSOCookieSameSite string
SSOAccessTokenMaxBytes int
SSOTokenBlacklistPrefix string
SSOPKCETTL time.Duration
SSOUserSyncDrift time.Duration
SSOUserSyncNonceTTL time.Duration
SSOUserSyncMaxBodyBytes int
S3Endpoint string
S3Region string
S3Bucket string
S3AccessKey string
S3SecretKey string
S3ForcePathStyle bool
S3PublicBaseURL string
S3EnvPrefix string
S3DocumentKeyPrefix string
IsProd bool
AppHost string
Version string
LogLevel string
AppPort int
DBHost string
DBUser string
DBPassword string
DBName string
DBPort int
DBSSLMode string
DBSSLRootCert string
DBSSLCert string
DBSSLKey string
JWTSecret string
JWTAccessExp int
JWTRefreshExp int
JWTResetPasswordExp int
JWTVerifyEmailExp int
RedisURL string
CORSAllowOrigins []string
CORSAllowMethods []string
CORSAllowHeaders []string
CORSExposeHeaders []string
CORSAllowCredentials bool
CORSMaxAge int
SSOIssuer string
SSOJWKSURL string
SSOAllowedAudiences []string
SSOAuthorizeURL string
SSOTokenURL string
SSOGetMeURL string
SSOPortalURL string
SSOClients map[string]SSOClientConfig
SSOAccessCookieName string
SSORefreshCookieName string
SSOCookieDomain string
SSOCookieSecure bool
SSOCookieSameSite string
SSOAccessTokenMaxBytes int
SSOTokenBlacklistPrefix string
SSOPKCETTL time.Duration
SSOUserSyncDrift time.Duration
SSOUserSyncNonceTTL time.Duration
SSOUserSyncMaxBodyBytes int
S3Endpoint string
S3Region string
S3Bucket string
S3AccessKey string
S3SecretKey string
S3ForcePathStyle bool
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"`
+10 -6
View File
@@ -12,16 +12,20 @@ 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"`
UpdatedAt time.Time `gorm:"autoUpdateTime"`
DeletedAt gorm.DeletedAt `gorm:"index"`
FromProjectFlock *ProjectFlock `gorm:"foreignKey:FromProjectFlockId;references:Id"`
ToProjectFlock *ProjectFlock `gorm:"foreignKey:ToProjectFlockId;references:Id"`
CreatedUser *User `gorm:"foreignKey:CreatedBy;references:Id"`
Sources []LayingTransferSource `gorm:"foreignKey:LayingTransferId;constraint:OnDelete:CASCADE"`
Targets []LayingTransferTarget `gorm:"foreignKey:LayingTransferId;constraint:OnDelete:CASCADE"`
LatestApproval *Approval `gorm:"-" json:"-"`
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()
}
@@ -47,11 +47,13 @@ func (u *AdjustmentController) Adjustment(c *fiber.Ctx) error {
func (u *AdjustmentController) AdjustmentHistory(c *fiber.Ctx) error {
query := &validation.Query{
Page: c.QueryInt("page", 1),
Limit: c.QueryInt("limit", 10),
ProductID: uint(c.QueryInt("product_id", 0)),
WarehouseID: uint(c.QueryInt("warehouse_id", 0)),
TransactionType: c.Query("transaction_type", ""),
Page: c.QueryInt("page", 1),
Limit: c.QueryInt("limit", 10),
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,27 +17,49 @@ type ProductRelationDTO struct {
ProductCategory *productCategoryDTO.ProductCategoryRelationDTO `json:"product_category,omitempty"`
}
type WarehouseRelationDTO struct {
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 {
Id uint `json:"id"`
ProductId uint `json:"product_id"`
WarehouseId uint `json:"warehouse_id"`
Quantity float64 `json:"quantity"`
Product *ProductRelationDTO `json:"product,omitempty"`
Warehouse *WarehouseRelationDTO `json:"warehouse,omitempty"`
Id uint `json:"id"`
ProductId uint `json:"product_id"`
WarehouseId uint `json:"warehouse_id"`
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"`
Increase float64 `json:"increase"`
Decrease float64 `json:"decrease"`
Note string `json:"note,omitempty"`
ProductWarehouseId uint `json:"product_warehouse_id"`
ProductWarehouse *ProductWarehouseDTO `json:"product_warehouse,omitempty"`
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"`
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"`
}
type AdjustmentListDTO struct {
@@ -81,31 +103,80 @@ func ToWarehouseRelationDTO(e *entity.Warehouse) *WarehouseRelationDTO {
return nil
}
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,
}
}
func ToProductWarehouseDTO(e *entity.ProductWarehouse) *ProductWarehouseDTO {
if e == nil {
return nil
}
return &ProductWarehouseDTO{
Id: e.Id,
ProductId: e.ProductId,
WarehouseId: e.WarehouseId,
Quantity: e.Quantity,
Product: ToProductRelationDTO(&e.Product),
Warehouse: ToWarehouseRelationDTO(&e.Warehouse),
Id: e.Id,
ProductId: e.ProductId,
WarehouseId: e.WarehouseId,
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)
}
} else {
_, err := s.FifoSvc.Consume(ctx, common.StockConsumeRequest{
UsableKey: fifo.UsableKeyAdjustmentOut,
UsableID: adjustmentStock.Id,
ProductWarehouseID: uint(productWarehouse.Id),
Quantity: req.Quantity,
AllowPending: false,
Tx: tx,
})
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Failed to consume stock via FIFO: %v", err))
reflowResult, err := s.FifoStockV2Svc.Reflow(ctx, common.FifoStockV2ReflowRequest{
FlagGroupCode: routeMeta.FlagGroupCode,
ProductWarehouseID: productWarehouse.Id,
Usable: common.FifoStockV2Ref{
ID: adjustmentStock.Id,
LegacyTypeKey: usableLegacyTypeKey,
FunctionCode: routeMeta.FunctionCode,
},
DesiredQty: qty,
AllowOverConsume: &allowPending,
Tx: tx,
})
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Failed to consume stock via FIFO v2: %v", err))
}
decreaseQty = reflowResult.Allocate.AllocatedQty
} else {
result, err := s.FifoSvc.Consume(ctx, common.StockConsumeRequest{
UsableKey: fifo.UsableKeyAdjustmentOut,
UsableID: adjustmentStock.Id,
ProductWarehouseID: productWarehouse.Id,
Quantity: qty,
AllowPending: allowPending,
Tx: tx,
})
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Failed to consume stock via FIFO: %v", err))
}
decreaseQty = result.UsageQuantity
}
default:
return fiber.NewError(fiber.StatusBadRequest, "Unsupported transaction subtype lane")
}
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,17 +1,25 @@
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"`
Note string `json:"note" validate:"omitempty,max=255"`
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"`
}
type Query struct {
Page int `query:"page" validate:"omitempty,min=1"`
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"`
Page int `query:"page" validate:"omitempty,min=1"`
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,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,9 +100,13 @@ 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) {
return nil, 0, fiber.NewError(fiber.StatusBadRequest, "Invalid marketing type")
marketingTypes = utils.ParseQueryArray(params.Type)
for _, t := range marketingTypes {
if !utils.IsValidMarketingType(t) {
return nil, 0, fiber.NewError(fiber.StatusBadRequest, "Invalid marketing type")
}
}
}
@@ -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 {
case string(utils.MarketingTypeAyamPullet):
db = s.Repository.ApplyFlagsFilter(db, []string{string(utils.FlagDOC), string(utils.FlagPullet), string(utils.FlagLayer)})
case string(utils.MarketingTypeAyam):
db = s.Repository.ApplyFlagsFilter(db, []string{string(utils.FlagAyamAfkir), string(utils.FlagAyamCulling), string(utils.FlagAyamMati)})
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)})
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)})
if len(marketingTypes) > 0 {
flagSet := make(map[string]struct{})
for _, t := range marketingTypes {
switch t {
case string(utils.MarketingTypeAyamPullet):
flagSet[string(utils.FlagDOC)] = struct{}{}
flagSet[string(utils.FlagPullet)] = struct{}{}
flagSet[string(utils.FlagLayer)] = struct{}{}
case string(utils.MarketingTypeAyam):
flagSet[string(utils.FlagAyamAfkir)] = struct{}{}
flagSet[string(utils.FlagAyamCulling)] = struct{}{}
flagSet[string(utils.FlagAyamMati)] = struct{}{}
case string(utils.MarketingTypeTelur):
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):
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,26 +444,73 @@ 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)]
consumeResult, err := s.FifoSvc.Consume(c.Context(), commonSvc.StockConsumeRequest{
UsableKey: fifo.UsableKeyStockTransferOut,
UsableID: uint(detail.Id),
ProductWarehouseID: uint(*detail.SourceProductWarehouseID),
Quantity: product.ProductQty,
AllowPending: false,
Tx: tx,
})
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Stok tidak mencukupi untuk produk %d di gudang asal. Error: %v", product.ProductID, err))
outUsageQty := 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),
ProductWarehouseID: uint(*detail.SourceProductWarehouseID),
Quantity: product.ProductQty,
AllowPending: false,
Tx: tx,
})
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Stok tidak mencukupi untuk produk %d di gudang asal. Error: %v", product.ProductID, err))
}
outUsageQty = consumeResult.UsageQuantity
outPendingQty = consumeResult.PendingQuantity
}
if err := tx.Model(&entity.StockTransferDetail{}).
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")
@@ -19,9 +19,13 @@ 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"`
MarketingId uint `query:"marketing_id" validate:"omitempty,gt=0"`
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"`
}
type DeliveryOrderApprove struct {
@@ -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 err != nil {
return fiber.NewError(fiber.StatusBadRequest, "record_date must be in YYYY-MM-DD format")
}
recordTime = parsed.UTC()
if req.RecordTime == nil || strings.TrimSpace(*req.RecordTime) == "" {
return fiber.NewError(fiber.StatusBadRequest, "record_date is required")
}
nextDay, err := u.RecordingService.GetNextDay(c, uint(projectFlockID), recordTime)
recordTime, err := time.Parse("2006-01-02", strings.TrimSpace(*req.RecordTime))
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, "record_time must be in YYYY-MM-DD format")
}
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 {
@@ -14,10 +14,12 @@ import (
// === DTO Structs ===
type TransferLayingRelationDTO struct {
Id uint `json:"id"`
TransferNumber string `json:"transfer_number"`
TransferDate time.Time `json:"transfer_date"`
Notes string `json:"notes"`
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"`
}
type ProjectFlockKandangWithKandangDTO struct {
@@ -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"`
}
@@ -88,10 +92,12 @@ type MaxTargetQtyForTransferDTO struct {
func ToTransferLayingRelationDTO(e entity.LayingTransfer) TransferLayingRelationDTO {
return TransferLayingRelationDTO{
Id: e.Id,
TransferNumber: e.TransferNumber,
TransferDate: e.TransferDate,
Notes: e.Notes,
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,
}, 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")
}
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 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,11 +465,16 @@ 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)
return nil, err
}
}
if s.DocumentSvc != nil {
actorIDCopy := actorID
@@ -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
+29 -18
View File
@@ -25,17 +25,17 @@ type PurchaseRelationDTO struct {
type PurchaseListDTO struct {
PurchaseRelationDTO
Supplier *supplierDTO.SupplierRelationDTO `json:"supplier"`
DueDate *time.Time `json:"due_date"`
CreatedUser *userDTO.UserRelationDTO `json:"created_user"`
RequesterName string `json:"requester_name"`
PoExpedition []string `json:"po_expedition"`
Products []productDTO.ProductRelationDTO `json:"products"`
Location *locationDTO.LocationRelationDTO `json:"location"`
Area *areaDTO.AreaRelationDTO `json:"area"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
LatestApproval *approvalDTO.ApprovalRelationDTO `json:"latest_approval"`
Supplier *supplierDTO.SupplierRelationDTO `json:"supplier"`
DueDate *time.Time `json:"due_date"`
CreatedUser *userDTO.UserRelationDTO `json:"created_user"`
RequesterName string `json:"requester_name"`
PoExpedition []PoExpeditionDTO `json:"po_expedition"`
Products []productDTO.ProductRelationDTO `json:"products"`
Location *locationDTO.LocationRelationDTO `json:"location"`
Area *areaDTO.AreaRelationDTO `json:"area"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
LatestApproval *approvalDTO.ApprovalRelationDTO `json:"latest_approval"`
}
type PurchaseDetailDTO struct {
@@ -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)
}