Compare commits

...

104 Commits

Author SHA1 Message Date
giovanni f98c73f569 add command normalize data seed standar and price adjustment stocks 2026-04-20 00:03:59 +07:00
giovanni 30adbb6b8a fix do 2026-04-19 21:24:29 +07:00
giovanni 45bed3b765 add adjust migration 2026-04-19 21:13:48 +07:00
giovanni 04aad18a4c adjust common hpp v2 2026-04-19 17:27:42 +07:00
Adnan Zahir 69d6fc165a feat: manual pullet cost 2026-04-19 15:10:53 +07:00
Adnan Zahir a2ae139fae feat: doc direct purchase cost 2026-04-19 14:52:01 +07:00
Adnan Zahir 58fbceea24 feat: reimplement with plan hppv2 flow and logics 2026-04-19 14:06:42 +07:00
giovanni 187e497f97 add common service hpp v2; adjust query marketing without stock allocation 2026-04-18 20:08:42 +07:00
giovanni fcde3b0a36 init depresiasi 2026-04-17 21:26:56 +07:00
Adnan Zahir a54c6184a2 Merge branch 'fix/prod-recording' into 'development'
[FIX][BE]: adjust validation limit recording

See merge request mbugroup/lti-api!421
2026-04-17 14:44:23 +07:00
giovanni f808b5cf79 adjust validation limit recording 2026-04-17 14:36:04 +07:00
Adnan Zahir 3d87e85d1e Merge branch 'fix/purchase' into 'development'
[FIX][BE]: fix value null to string vehicle_number field

See merge request mbugroup/lti-api!420
2026-04-15 20:30:50 +07:00
giovanni ef71093b99 fix value null to string vehicle_number field 2026-04-15 11:22:02 +07:00
Adnan Zahir 1c19fc058c Merge branch 'feat/open-api-v1' into 'development'
Feat/open api v1

See merge request mbugroup/lti-api!419
2026-04-14 16:54:16 +07:00
giovanni 6a012b75aa adjust collection; adjust migration 2026-04-14 16:52:50 +07:00
giovanni 9b0335cac8 Merge branch 'fix/delivery-order' into feat/open-api-v1 2026-04-14 15:30:32 +07:00
Adnan Zahir 1c70dccc82 Merge branch 'fix/dashboard' into 'development'
[FIX][BE]: fix get dashboard

See merge request mbugroup/lti-api!418
2026-04-14 15:25:08 +07:00
giovanni 94a7191365 fix get dashboard 2026-04-14 15:23:08 +07:00
Adnan Zahir 1ab16cfe06 feat: open API v1 and postman collection 2026-04-14 15:14:31 +07:00
Adnan Zahir d9610537de Merge branch 'fix/delivery-order' into 'development'
[FIX][BE]: adjust edit delivery order; add migration for delivery order; adjust response get marketing

See merge request mbugroup/lti-api!417
2026-04-14 15:08:32 +07:00
giovanni cd549de578 adjust edit delivery order; add migration for delivery order; adjust response get marketing 2026-04-14 14:48:56 +07:00
Adnan Zahir 7ca7d0841b Merge branch 'codex/dashboard-without-uniformity' into 'development'
Codex/dashboard without uniformity

See merge request mbugroup/lti-api!415
2026-04-14 14:31:56 +07:00
Adnan Zahir 82794d3d9b Merge branch 'development' into 'codex/dashboard-without-uniformity'
# Conflicts:
#   internal/modules/dashboards/repositories/dashboard_stats.repository.go
#   internal/modules/dashboards/services/dashboard.service.go
#   internal/modules/production/uniformities/services/uniformity.service.go
2026-04-14 14:31:18 +07:00
Adnan Zahir ca698ff2ae codex/fix: uniformity week calculation 2026-04-14 13:09:47 +07:00
Adnan Zahir fbe0634d46 Merge branch 'feat/customer' into 'development'
[FEAT][BE]: add query param get customer has marketing

See merge request mbugroup/lti-api!413
2026-04-13 16:34:29 +07:00
giovanni bca02800d6 add query param get customer has marketing 2026-04-13 14:47:00 +07:00
Adnan Zahir 45e430f01d Merge branch 'feat/kandang-periode' into 'development'
[FEAT][BE]: adjust api get all project flock kandang with periode

See merge request mbugroup/lti-api!409
2026-04-13 12:58:23 +07:00
giovanni cff5837ff9 adjust default order dan sort by 2026-04-13 12:14:54 +07:00
giovanni 5e2187c46b adjust default order by dan sort by 2026-04-13 11:49:41 +07:00
giovanni d1612e5c65 add query param location id 2026-04-13 10:51:12 +07:00
giovanni 30a47ffc71 Merge branch 'development' into feat/kandang-periode 2026-04-13 10:30:56 +07:00
giovanni b79738dbe1 fix calculate egg mass and hen house recordings 2026-04-13 10:30:39 +07:00
giovanni 3eb225cca8 adjust validation from week 19 2026-04-13 10:30:39 +07:00
Adnan Zahir a9a84539eb Merge branch 'fix/recording' into 'development'
[FIX][BE]: fix calculate egg mass and hen house recordings

See merge request mbugroup/lti-api!410
2026-04-11 13:56:32 +07:00
giovanni 3702d41954 fix calculate egg mass and hen house recordings 2026-04-10 17:16:28 +07:00
Adnan Zahir ffd96105ce Merge branch 'fix/uniformity' into 'development'
[FIX][BE]: adjust validation from week 19

See merge request mbugroup/lti-api!408
2026-04-10 15:21:23 +07:00
giovanni 3d75251c96 adjust api get all project flock kandang with periode 2026-04-10 14:09:31 +07:00
giovanni 7c848bc50d adjust validation from week 19 2026-04-09 17:00:03 +07:00
Adnan Zahir ddcf13e2ff Merge branch 'feat/dashboard-uniformity' into 'development'
[FEAT][BE]: adjust dashboard uniformity and validation add uniformity

See merge request mbugroup/lti-api!407
2026-04-09 15:36:44 +07:00
giovanni e8c33f818b adjust dashboard uniformity and validation add uniformity 2026-04-09 15:28:26 +07:00
Adnan Zahir 3daed7e248 Merge branch 'feat/export-recording' into 'development'
[FEAT][BE]: add export excel to get all recording

See merge request mbugroup/lti-api!405
2026-04-09 13:23:36 +07:00
Adnan Zahir cde4647b15 Merge branch 'fix/edit-receipt' into 'development'
[FIX][BE]: fix edit receipt purchase

See merge request mbugroup/lti-api!404
2026-04-09 13:12:16 +07:00
giovanni abc0ac8258 add export excel to get all recording 2026-04-09 11:18:05 +07:00
Adnan Zahir aad4f7dc28 Merge branch 'fix/filter-purchase-order' into 'development'
Fix/filter purchase order

See merge request mbugroup/lti-api!403
2026-04-08 17:01:54 +07:00
Adnan Zahir bfe7b5129f Merge branch 'fix/marketing-delivery' into 'development'
[FIX][BE]: fix get detail marketing delivery

See merge request mbugroup/lti-api!402
2026-04-08 16:54:53 +07:00
giovanni a6995f8e18 fix edit receipt purchase 2026-04-08 16:18:55 +07:00
Adnan Zahir 7638c183f5 codex/fix: dashboard independent recording values without uniformity 2026-04-08 15:13:31 +07:00
giovanni 450d1e8cee add filter lokasi and bop to purchase order 2026-04-08 14:24:04 +07:00
ragilap b58e9a10b1 fix filter purchase supplier repport 2026-04-08 14:13:19 +07:00
ragilap aa9863646e fix filter purchase ?approval_status=approved,rejected and ?product_category_id=1,2,3 2026-04-08 14:12:18 +07:00
ragilap 2a3154042c fix filter purchase query param and search 2026-04-08 14:07:40 +07:00
giovanni 80f190b69b fix get detail marketing delivery 2026-04-08 13:41:17 +07:00
Adnan Zahir 079ae01b94 Merge branch 'codex/sales-at-farm-level' into 'development'
[FIX][BE]: adjust calculate total price create sales order for telur and convertion peti and qty

See merge request mbugroup/lti-api!400
2026-04-07 22:44:49 +07:00
Adnan Zahir ee7fa71139 codex/command: migrate egg stocks from kandang to farm (adjustment_stocks only) 2026-04-07 21:54:51 +07:00
Adnan Zahir f2827d5352 codex/command: migrate egg stocks from kandang to farm 2026-04-07 20:28:05 +07:00
giovanni 18cf180982 adjust calculate total price create sales order for telur and convertion peti and qty 2026-04-07 18:39:32 +07:00
giovanni fc0b45b433 remove migration change type data qty recording eggs 2026-04-07 15:48:59 +07:00
giovanni 4d85d6f320 add migration change data type field qty di table recording eggs 2026-04-07 15:35:14 +07:00
Adnan Zahir e3cfb2648b Merge branch 'codex/sales-at-farm-level' into 'development'
[FIX][BE]: Codex/sales at farm level

See merge request mbugroup/lti-api!397
2026-04-07 14:43:49 +07:00
giovanni ba8b512293 add cmd for reflow quantity product warehouse from stock allocation 2026-04-07 14:33:38 +07:00
giovanni 7bd9ec9ef8 adjust level 2 cmd adjust quantity product warehouse from purchase 2026-04-07 13:58:01 +07:00
Adnan Zahir 037f9fc71b Merge branch 'codex/sales-at-farm-level' into 'development'
[FIX][BE]: add command for adjust data quantity product warehouse from purchase items

See merge request mbugroup/lti-api!396
2026-04-07 12:42:21 +07:00
giovanni 8fa41e379d add command for adjust data quantity product warehouse from purchase items 2026-04-07 12:00:02 +07:00
Adnan Zahir 7d9c752432 Merge branch 'codex/sales-at-farm-level' into 'development'
[FIX][BE]: adjust response detail recording

See merge request mbugroup/lti-api!395
2026-04-07 11:04:20 +07:00
giovanni 6342a28f09 adjust response detail recording 2026-04-07 10:59:59 +07:00
Adnan Zahir 945b6aba0a Merge branch 'codex/sales-at-farm-level' into 'development'
[FIX][BE]: adjust get data product suppliers

See merge request mbugroup/lti-api!394
2026-04-06 23:41:24 +07:00
giovanni 23e49a00e4 adjust get data product suppliers 2026-04-06 23:38:27 +07:00
Adnan Zahir 480c899f6a Merge branch 'codex/sales-at-farm-level' into 'development'
codex/fix: hidden product warehouse depletion and egg <= 0

See merge request mbugroup/lti-api!393
2026-04-06 22:32:51 +07:00
Adnan Zahir ba4a5324ed codex/fix: hidden product warehouse depletion and egg <= 0 2026-04-06 22:27:58 +07:00
Adnan Zahir 4899cee98f Merge branch 'codex/sales-at-farm-level' into 'development'
codex/fix: store stocks on farm warehouse when recording egg

See merge request mbugroup/lti-api!392
2026-04-04 11:22:31 +07:00
Adnan Zahir 2a39342d55 codex/fix: store stocks on farm warehouse when recording egg 2026-04-04 11:21:50 +07:00
Adnan Zahir f29f09d7b9 Merge branch 'codex/sales-at-farm-level' into 'development'
codex/fix: inconsistent stock options and availability

See merge request mbugroup/lti-api!391
2026-04-04 10:08:24 +07:00
Adnan Zahir 4254cbf576 Merge branch 'production' into 'development'
Production (Back Merge after Hotfixes)

See merge request mbugroup/lti-api!390
2026-04-04 09:55:16 +07:00
Adnan Zahir 34a3fc44a8 codex/fix: inconsistent stock options and availability 2026-04-04 09:52:59 +07:00
Adnan Zahir fdfc5e069d Merge branch 'hot-fix/daily-checklist' into 'production'
[FIX][BE]: fix upser daily checklist status rejected; fix search list daily checklist

See merge request mbugroup/lti-api!389
2026-04-02 15:29:34 +07:00
giovanni 6880010424 fix upser daily checklist status rejected; fix search list daily checklist 2026-04-02 14:53:59 +07:00
Adnan Zahir 9cc9146641 Merge branch 'hot-fix/filter-purchase' into 'production'
fix filter purchase supplier repport

See merge request mbugroup/lti-api!388
2026-04-02 14:35:06 +07:00
ragilap 8be4b54127 fix filter purchase supplier repport 2026-04-02 11:43:44 +07:00
Adnan Zahir 434ae2f246 Merge branch 'development' into 'production'
Development

See merge request mbugroup/lti-api!380
2026-03-27 15:39:51 +07:00
Adnan Zahir c48f0411d3 Merge branch 'development' into 'production'
Development

See merge request mbugroup/lti-api!378
2026-03-25 12:47:21 +07:00
Adnan Zahir f40c643876 Merge branch 'development' into 'production'
Development

See merge request mbugroup/lti-api!376
2026-03-18 00:13:22 +07:00
Adnan Zahir 18cb116a51 Merge branch 'development' into 'production'
Development

See merge request mbugroup/lti-api!374
2026-03-17 18:52:44 +07:00
M1 AIR 1ab1909998 Merge branch 'development' into production 2026-03-17 14:46:09 +07:00
Adnan Zahir 41c910677f Merge branch 'development' into 'production'
Development

See merge request mbugroup/lti-api!372
2026-03-17 13:35:13 +07:00
Adnan Zahir c9fb4077a6 Merge branch 'development' into 'production'
v1: go-live

See merge request mbugroup/lti-api!364
2026-03-09 06:35:39 +07:00
kris ec17633b84 Merge branch 'development' into 'production'
Development

See merge request mbugroup/lti-api!356
2026-03-04 17:57:51 +00:00
kris b2f235dcde Merge branch 'development' into 'production'
Development

See merge request mbugroup/lti-api!354
2026-03-04 17:45:35 +00:00
Adnan Zahir 0bcda8ad82 Merge branch 'staging' into 'production'
Staging

See merge request mbugroup/lti-api!339
2026-02-26 16:36:07 +07:00
Adnan Zahir aadc19a3ca Merge branch 'development' into 'staging'
Development

See merge request mbugroup/lti-api!335
2026-02-26 16:17:04 +07:00
Adnan Zahir aabad2b082 Merge branch 'development' into 'staging'
Development

See merge request mbugroup/lti-api!331
2026-02-20 09:53:22 +07:00
Adnan Zahir e323f42c11 Merge branch 'development' into 'staging'
Development

See merge request mbugroup/lti-api!323
2026-02-12 11:04:50 +07:00
Adnan Zahir 0d5044b7bf Merge branch 'staging' into 'production'
Staging

See merge request mbugroup/lti-api!320
2026-02-07 17:07:43 +07:00
Adnan Zahir dd2832b8fc Merge branch 'development' into 'staging'
Development

See merge request mbugroup/lti-api!319
2026-02-07 17:00:36 +07:00
kris 119a5e4e25 Merge branch 'staging' into 'production'
FEAT[BE] :refactor egg production data retrieval to use date parameter in...

See merge request mbugroup/lti-api!317
2026-02-06 17:06:51 +00:00
kris 04068c2a8b Merge branch 'development' into 'staging'
Create job for MR

See merge request mbugroup/lti-api!316
2026-02-06 16:51:05 +00:00
Adnan Zahir 0db1aaaab7 Merge branch 'development' into 'staging'
Development

See merge request mbugroup/lti-api!314
2026-02-06 11:31:55 +07:00
Adnan Zahir 2b258908ef Merge branch 'development' into 'staging'
Development

See merge request mbugroup/lti-api!310
2026-02-05 10:32:25 +07:00
Adnan Zahir 74e5542726 Merge branch 'development' into 'staging'
Development

See merge request mbugroup/lti-api!299
2026-02-03 13:05:23 +07:00
Adnan Zahir 2f5ddfe8a6 Merge branch 'staging' into 'production'
Staging 31 Jan 2026

See merge request mbugroup/lti-api!293
2026-01-31 10:58:02 +07:00
Adnan Zahir ceba7c5543 Merge branch 'production' into 'staging'
Production

See merge request mbugroup/lti-api!291
2026-01-31 10:51:33 +07:00
Adnan Zahir b32789e515 Merge branch 'development' into 'staging'
Development

See merge request mbugroup/lti-api!290
2026-01-31 10:42:07 +07:00
Adnan Zahir a7611ad0b2 Merge branch 'development' into 'staging'
Development

See merge request mbugroup/lti-api!287
2026-01-30 14:43:56 +07:00
Adnan Zahir 0042cf11ce Merge branch 'development' into 'staging'
Development

See merge request mbugroup/lti-api!285
2026-01-30 11:42:14 +07:00
Adnan Zahir b860a68db2 Merge branch 'development' into 'staging'
changes permission to redis and scope

See merge request mbugroup/lti-api!282
2026-01-29 19:12:27 +07:00
133 changed files with 39168 additions and 828 deletions
@@ -0,0 +1,297 @@
package main
import (
"context"
"errors"
"flag"
"fmt"
"log"
"math"
"os"
"strings"
"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"
"gorm.io/gorm"
)
const (
levelAllNoFlagProducts = 1
levelProductName = 2
levelProductWarehouse = 3
qtyEpsilon = 1e-6
)
type targetRow struct {
ProductWarehouseID uint `gorm:"column:product_warehouse_id"`
ProductID uint `gorm:"column:product_id"`
ProductName string `gorm:"column:product_name"`
CurrentQty float64 `gorm:"column:current_qty"`
ComputedQty float64 `gorm:"column:computed_qty"`
}
func main() {
var (
level int
productName string
productWarehouseID uint
apply bool
)
flag.IntVar(
&level,
"level",
levelAllNoFlagProducts,
"CLI level: 1=all products without flags, 2=specific product name (with flags), 3=specific product warehouse id",
)
flag.StringVar(&productName, "product-name", "", "Product name (required for level 2)")
flag.UintVar(&productWarehouseID, "product-warehouse-id", 0, "Product warehouse id (required for level 3)")
flag.BoolVar(&apply, "apply", false, "Apply changes. If false, run as dry-run")
flag.Parse()
productName = strings.TrimSpace(productName)
if err := validateFlags(level, productName, productWarehouseID); err != nil {
log.Fatalf("invalid flags: %v", err)
}
ctx := context.Background()
db := database.Connect(config.DBHost, config.DBName)
targets, err := loadTargets(ctx, db, level, productName, productWarehouseID)
if err != nil {
log.Fatalf("failed to load target product warehouses: %v", err)
}
fmt.Printf("Mode: %s\n", modeLabel(apply))
fmt.Printf("Level: %d (%s)\n", level, levelLabel(level))
if productName != "" {
fmt.Printf("Filter product_name: %s\n", productName)
}
if productWarehouseID > 0 {
fmt.Printf("Filter product_warehouse_id: %d\n", productWarehouseID)
}
fmt.Printf("Targets found: %d\n\n", len(targets))
if len(targets) == 0 {
fmt.Println("No matching product warehouse rows to process")
return
}
for _, row := range targets {
fmt.Printf(
"PLAN pw=%d product_id=%d product=%q current_qty=%.3f computed_qty=%.3f delta=%.3f\n",
row.ProductWarehouseID,
row.ProductID,
row.ProductName,
row.CurrentQty,
row.ComputedQty,
row.ComputedQty-row.CurrentQty,
)
}
if !apply {
fmt.Println()
fmt.Printf("Summary: planned=%d updated=0 skipped=0 failed=0\n", len(targets))
return
}
updated := 0
skipped := 0
err = db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
for _, row := range targets {
if nearlyEqual(row.CurrentQty, row.ComputedQty) {
fmt.Printf(
"SKIP pw=%d reason=no_change current_qty=%.3f computed_qty=%.3f\n",
row.ProductWarehouseID,
row.CurrentQty,
row.ComputedQty,
)
skipped++
continue
}
if err := tx.Table("product_warehouses").
Where("id = ?", row.ProductWarehouseID).
Update("qty", row.ComputedQty).Error; err != nil {
return fmt.Errorf("update qty for product_warehouse_id=%d: %w", row.ProductWarehouseID, err)
}
fmt.Printf(
"DONE pw=%d product_id=%d product=%q old_qty=%.3f new_qty=%.3f\n",
row.ProductWarehouseID,
row.ProductID,
row.ProductName,
row.CurrentQty,
row.ComputedQty,
)
updated++
}
return nil
})
if err != nil {
fmt.Println()
fmt.Printf("Summary: planned=%d updated=%d skipped=%d failed=1\n", len(targets), updated, skipped)
log.Printf("error: %v", err)
os.Exit(1)
}
fmt.Println()
fmt.Printf("Summary: planned=%d updated=%d skipped=%d failed=0\n", len(targets), updated, skipped)
}
func validateFlags(level int, productName string, productWarehouseID uint) error {
switch level {
case levelAllNoFlagProducts:
if productName != "" {
return errors.New("--product-name cannot be used on level 1")
}
if productWarehouseID > 0 {
return errors.New("--product-warehouse-id cannot be used on level 1")
}
case levelProductName:
if productName == "" {
return errors.New("--product-name is required on level 2")
}
if productWarehouseID > 0 {
return errors.New("--product-warehouse-id cannot be used on level 2")
}
case levelProductWarehouse:
if productWarehouseID == 0 {
return errors.New("--product-warehouse-id is required on level 3")
}
if productName != "" {
return errors.New("--product-name cannot be used on level 3")
}
default:
return fmt.Errorf("unsupported --level=%d (allowed: 1, 2, 3)", level)
}
return nil
}
func loadTargets(
ctx context.Context,
db *gorm.DB,
level int,
productName string,
productWarehouseID uint,
) ([]targetRow, error) {
switch level {
case levelAllNoFlagProducts:
return loadTargetsLevel1ByProductWithoutFlags(ctx, db)
case levelProductName:
return loadTargetsLevel2ByProductWarehouseWithFlags(ctx, db, productName)
case levelProductWarehouse:
return loadTargetByProductWarehouseID(ctx, db, productWarehouseID)
default:
return nil, fmt.Errorf("unsupported level %d", level)
}
}
func loadTargetsLevel1ByProductWithoutFlags(ctx context.Context, db *gorm.DB) ([]targetRow, error) {
rows := make([]targetRow, 0)
if err := db.WithContext(ctx).
Table("product_warehouses pw").
Select(`
pw.id AS product_warehouse_id,
pw.product_id AS product_id,
p.name AS product_name,
COALESCE(pw.qty, 0) AS current_qty,
COALESCE(SUM(pi.total_qty), 0) AS computed_qty
`).
Joins("JOIN products p ON p.id = pw.product_id").
Joins("LEFT JOIN flags f ON f.flagable_id = p.id AND f.flagable_type = ?", entity.FlagableTypeProduct).
Joins("LEFT JOIN purchase_items pi ON pi.product_warehouse_id = pw.id").
Where("p.deleted_at IS NULL").
Where("f.id IS NULL").
Group("pw.id, pw.product_id, p.name, pw.qty").
Order("pw.id ASC").
Scan(&rows).Error; err != nil {
return nil, err
}
return rows, nil
}
func loadTargetsLevel2ByProductWarehouseWithFlags(
ctx context.Context,
db *gorm.DB,
productName string,
) ([]targetRow, error) {
rows := make([]targetRow, 0)
if err := db.WithContext(ctx).
Table("product_warehouses pw").
Select(`
pw.id AS product_warehouse_id,
pw.product_id AS product_id,
p.name AS product_name,
COALESCE(pw.qty, 0) AS current_qty,
COALESCE(SUM(pi.total_qty), 0) AS computed_qty
`).
Joins("JOIN products p ON p.id = pw.product_id").
Joins("LEFT JOIN purchase_items pi ON pi.product_warehouse_id = pw.id").
Where("p.deleted_at IS NULL").
Where(`
EXISTS (
SELECT 1
FROM flags f
WHERE f.flagable_id = p.id
AND f.flagable_type = ?
)
`, entity.FlagableTypeProduct).
Where("LOWER(p.name) = LOWER(?)", productName).
Group("pw.id, pw.product_id, p.name, pw.qty").
Order("pw.id ASC").
Scan(&rows).Error; err != nil {
return nil, err
}
return rows, nil
}
func loadTargetByProductWarehouseID(ctx context.Context, db *gorm.DB, productWarehouseID uint) ([]targetRow, error) {
rows := make([]targetRow, 0)
if err := db.WithContext(ctx).
Table("product_warehouses pw").
Select(`
pw.id AS product_warehouse_id,
pw.product_id AS product_id,
p.name AS product_name,
COALESCE(pw.qty, 0) AS current_qty,
COALESCE(SUM(pi.total_qty), 0) AS computed_qty
`).
Joins("JOIN products p ON p.id = pw.product_id").
Joins("LEFT JOIN purchase_items pi ON pi.product_warehouse_id = pw.id").
Where("pw.id = ?", productWarehouseID).
Group("pw.id, pw.product_id, p.name, pw.qty").
Scan(&rows).Error; err != nil {
return nil, err
}
return rows, nil
}
func modeLabel(apply bool) string {
if apply {
return "APPLY"
}
return "DRY-RUN"
}
func levelLabel(level int) string {
switch level {
case levelAllNoFlagProducts:
return "all products without flags (source: purchase_items by product_warehouse_id)"
case levelProductName:
return "specific product name with flags (source: purchase_items by product_warehouse_id)"
case levelProductWarehouse:
return "specific product_warehouse_id (source: purchase_items by product_warehouse_id)"
default:
return "unknown"
}
}
func nearlyEqual(a, b float64) bool {
return math.Abs(a-b) <= qtyEpsilon
}
+132
View File
@@ -0,0 +1,132 @@
package main
import (
"context"
"flag"
"fmt"
"os"
"strings"
"gitlab.com/mbugroup/lti-api.git/internal/apikeys"
"gitlab.com/mbugroup/lti-api.git/internal/config"
"gitlab.com/mbugroup/lti-api.git/internal/database"
)
func main() {
if len(os.Args) < 2 {
usage()
os.Exit(1)
}
db := database.Connect(config.DBHost, config.DBName)
service := apikeys.NewService(db)
ctx := context.Background()
switch os.Args[1] {
case "create":
fs := flag.NewFlagSet("create", flag.ExitOnError)
name := fs.String("name", "dashboard-read-api", "integration client name")
environment := fs.String("env", config.AppEnv, "environment label")
permissions := fs.String("permissions", "", "comma separated permission codes")
allArea := fs.Bool("all-area", true, "grant all areas")
areaIDs := fs.String("area-ids", "", "comma separated area ids")
allLocation := fs.Bool("all-location", true, "grant all locations")
locationIDs := fs.String("location-ids", "", "comma separated location ids")
fs.Parse(os.Args[2:])
permissionCodes := apikeys.DefaultDashboardPermissions()
if strings.TrimSpace(*permissions) != "" {
permissionCodes = splitCSV(*permissions)
}
issued, err := service.Create(ctx, apikeys.CreateInput{
Name: *name,
Environment: *environment,
PermissionCodes: permissionCodes,
AllArea: *allArea,
AreaIDs: parseUintCSV(*areaIDs),
AllLocation: *allLocation,
LocationIDs: parseUintCSV(*locationIDs),
})
if err != nil {
panic(err)
}
fmt.Printf("name: %s\n", issued.Record.Name)
fmt.Printf("environment: %s\n", issued.Record.Environment)
fmt.Printf("prefix: %s\n", issued.Record.KeyPrefix)
fmt.Printf("status: %s\n", issued.Record.Status)
fmt.Printf("api_key: %s\n", issued.Key)
case "list":
fs := flag.NewFlagSet("list", flag.ExitOnError)
environment := fs.String("env", "", "filter by environment")
fs.Parse(os.Args[2:])
records, err := service.List(ctx, *environment)
if err != nil {
panic(err)
}
for _, record := range records {
fmt.Printf("%s\t%s\t%s\t%s\tareas=%t\tlocations=%t\n",
record.Environment,
record.KeyPrefix,
record.Status,
record.Name,
record.AllArea,
record.AllLocation,
)
}
case "revoke":
fs := flag.NewFlagSet("revoke", flag.ExitOnError)
environment := fs.String("env", config.AppEnv, "environment label")
prefix := fs.String("prefix", "", "key prefix to revoke")
fs.Parse(os.Args[2:])
if err := service.Revoke(ctx, *environment, *prefix); err != nil {
panic(err)
}
fmt.Printf("revoked %s/%s\n", *environment, *prefix)
default:
usage()
os.Exit(1)
}
}
func usage() {
fmt.Println("usage:")
fmt.Println(" go run ./cmd/api-key create [flags]")
fmt.Println(" go run ./cmd/api-key list [flags]")
fmt.Println(" go run ./cmd/api-key revoke -env <environment> -prefix <prefix>")
}
func splitCSV(raw string) []string {
if strings.TrimSpace(raw) == "" {
return nil
}
parts := strings.Split(raw, ",")
out := make([]string, 0, len(parts))
for _, part := range parts {
part = strings.TrimSpace(part)
if part != "" {
out = append(out, part)
}
}
return out
}
func parseUintCSV(raw string) []uint {
parts := splitCSV(raw)
if len(parts) == 0 {
return nil
}
values := make([]uint, 0, len(parts))
for _, part := range parts {
var value uint
if _, err := fmt.Sscanf(part, "%d", &value); err == nil && value > 0 {
values = append(values, value)
}
}
return values
}
+5
View File
@@ -9,12 +9,14 @@ import (
"syscall"
"time"
"gitlab.com/mbugroup/lti-api.git/internal/apikeys"
"gitlab.com/mbugroup/lti-api.git/internal/cache"
"gitlab.com/mbugroup/lti-api.git/internal/config"
"gitlab.com/mbugroup/lti-api.git/internal/database"
"gitlab.com/mbugroup/lti-api.git/internal/middleware"
"gitlab.com/mbugroup/lti-api.git/internal/modules/sso/session"
sso "gitlab.com/mbugroup/lti-api.git/internal/modules/sso/verifier"
"gitlab.com/mbugroup/lti-api.git/internal/readapi"
"gitlab.com/mbugroup/lti-api.git/internal/route"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
@@ -131,6 +133,7 @@ func setupDatabase() *gorm.DB {
}
func setupRoutes(app *fiber.App, db *gorm.DB, rdb *redis.Client) {
middleware.SetAPIKeyAuthenticator(apikeys.NewService(db))
// route.Routes(app, db)
// app.Use(utils.NotFoundHandler)
@@ -169,6 +172,8 @@ func setupRoutes(app *fiber.App, db *gorm.DB, rdb *redis.Client) {
return c.Status(status).JSON(body)
})
readAPIRoutes := app.Group("/api")
readapi.RegisterRoutes(readAPIRoutes)
route.Routes(app, db)
app.Use(utils.NotFoundHandler)
}
+74
View File
@@ -0,0 +1,74 @@
package main
import (
"fmt"
"os"
"path/filepath"
"gitlab.com/mbugroup/lti-api.git/internal/cache"
"gitlab.com/mbugroup/lti-api.git/internal/config"
"gitlab.com/mbugroup/lti-api.git/internal/readapi"
"gitlab.com/mbugroup/lti-api.git/internal/route"
"github.com/gofiber/fiber/v2"
"github.com/redis/go-redis/v9"
)
func main() {
root, err := findRepoRoot()
if err != nil {
panic(err)
}
readapi.PrimeBuildConfig()
cache.SetRedis(redis.NewClient(&redis.Options{Addr: "127.0.0.1:6379"}))
app := fiber.New(config.FiberConfig())
app.Get("/healthz", func(c *fiber.Ctx) error {
return c.JSON(fiber.Map{"status": "ok", "service": "api", "version": config.Version})
})
app.Get("/readyz", func(c *fiber.Ctx) error {
return c.JSON(fiber.Map{"status": "ok", "db": "up", "redis": "up"})
})
route.Routes(app, nil)
artifacts, err := readapi.BuildArtifactsFromApp(app)
if err != nil {
panic(err)
}
files := map[string][]byte{
filepath.Join(root, "docs", "openapi", "read-api.json"): artifacts.OpenAPIJSON,
filepath.Join(root, "docs", "openapi", "read-api.yaml"): artifacts.OpenAPIYAML,
filepath.Join(root, "docs", "postman", "read-api.collection.json"): artifacts.PostmanCollectionJSON,
filepath.Join(root, "docs", "postman", "read-api.environment.json"): artifacts.PostmanEnvironmentJSON,
}
for path, body := range files {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
panic(err)
}
if err := os.WriteFile(path, body, 0o644); err != nil {
panic(err)
}
fmt.Printf("wrote %s\n", path)
}
}
func findRepoRoot() (string, error) {
wd, err := os.Getwd()
if err != nil {
return "", err
}
current := wd
for {
if _, err := os.Stat(filepath.Join(current, "go.mod")); err == nil {
return current, nil
}
parent := filepath.Dir(current)
if parent == current {
return "", fmt.Errorf("go.mod not found from %s", wd)
}
current = parent
}
}
+587
View File
@@ -0,0 +1,587 @@
package main
import (
"context"
"flag"
"fmt"
"log"
"os"
"sort"
"strconv"
"strings"
"github.com/xuri/excelize/v2"
"gitlab.com/mbugroup/lti-api.git/internal/config"
"gitlab.com/mbugroup/lti-api.git/internal/database"
"gorm.io/gorm"
)
type importOptions struct {
FilePath string
Sheet string
Apply bool
}
type headerIndexes struct {
AdjustmentID int
Weight int
}
type adjustmentPriceImportRow struct {
RowNumber int
AdjustmentID uint
Weight float64
}
type validationIssue struct {
Row int
Field string
Message string
}
func (i validationIssue) Error() string {
if i.Row > 0 {
return fmt.Sprintf("row=%d field=%s message=%s", i.Row, i.Field, i.Message)
}
return fmt.Sprintf("field=%s message=%s", i.Field, i.Message)
}
type adjustmentResolver interface {
ResolveExistingAdjustmentIDs(ctx context.Context, adjustmentIDs []uint) (map[uint]struct{}, error)
}
type dbAdjustmentResolver struct {
db *gorm.DB
}
type adjustmentPriceStore interface {
UpdatePrice(ctx context.Context, adjustmentID uint, price float64) (bool, error)
}
type txRunner interface {
InTx(ctx context.Context, fn func(store adjustmentPriceStore) error) error
}
type dbTxRunner struct {
db *gorm.DB
}
type dbAdjustmentPriceStore struct {
db *gorm.DB
}
type applyRowResult struct {
RowNumber int
AdjustmentID uint
Price float64
Changed bool
}
func main() {
var opts importOptions
flag.StringVar(&opts.FilePath, "file", "", "Path to .xlsx file (required)")
flag.StringVar(&opts.Sheet, "sheet", "", "Sheet name (optional, default: first sheet)")
flag.BoolVar(&opts.Apply, "apply", false, "Apply changes. If false, run as dry-run")
flag.Parse()
opts.FilePath = strings.TrimSpace(opts.FilePath)
opts.Sheet = strings.TrimSpace(opts.Sheet)
if opts.FilePath == "" {
log.Fatal("--file is required")
}
sheetName, rows, parseIssues, err := parseAdjustmentPriceFile(opts.FilePath, opts.Sheet)
if err != nil {
log.Fatalf("failed reading excel: %v", err)
}
ctx := context.Background()
db := database.Connect(config.DBHost, config.DBName)
resolver := dbAdjustmentResolver{db: db}
existingAdjustmentIDs, err := resolver.ResolveExistingAdjustmentIDs(ctx, collectAdjustmentIDs(rows))
if err != nil {
log.Fatalf("failed checking adjustment_id against adjustment_stocks: %v", err)
}
processableRows, skippedRows := splitRowsByExistingIDs(rows, existingAdjustmentIDs)
issues := append([]validationIssue{}, parseIssues...)
sortValidationIssues(issues)
fmt.Printf("Mode: %s\n", modeLabel(opts.Apply))
fmt.Printf("File: %s\n", opts.FilePath)
fmt.Printf("Sheet: %s\n", sheetName)
fmt.Printf("Rows parsed: %d\n", len(rows))
fmt.Printf("Rows invalid: %d\n", len(issues))
fmt.Printf("Rows processable: %d\n", len(processableRows))
fmt.Printf("Rows skipped_missing: %d\n", len(skippedRows))
fmt.Println()
if len(processableRows) > 0 {
printPlanRows(processableRows)
}
if len(skippedRows) > 0 {
printSkippedRows(skippedRows)
}
if len(processableRows) > 0 || len(skippedRows) > 0 {
fmt.Println()
}
if len(issues) > 0 {
fmt.Println("Validation errors:")
for _, issue := range issues {
fmt.Printf("ERROR %s\n", issue.Error())
}
fmt.Println()
fmt.Printf(
"Summary: planned=%d processable=%d skipped_missing=%d applied=0 failed=%d\n",
len(rows),
len(processableRows),
len(skippedRows),
len(issues),
)
os.Exit(1)
}
if !opts.Apply {
fmt.Printf(
"Summary: planned=%d processable=%d skipped_missing=%d applied=0 failed=0\n",
len(rows),
len(processableRows),
len(skippedRows),
)
return
}
results, err := applyIfRequested(ctx, true, dbTxRunner{db: db}, processableRows)
if err != nil {
log.Fatalf("apply failed: %v", err)
}
for _, result := range results {
fmt.Printf(
"DONE row=%d adjustment_id=%d price=%.3f status=%s\n",
result.RowNumber,
result.AdjustmentID,
result.Price,
applyStatus(result.Changed),
)
}
appliedCount := countChangedRows(results)
if len(results) > 0 {
fmt.Println()
}
fmt.Printf(
"Summary: planned=%d processable=%d skipped_missing=%d applied=%d failed=0\n",
len(rows),
len(processableRows),
len(skippedRows),
appliedCount,
)
}
func parseAdjustmentPriceFile(
filePath string,
requestedSheet string,
) (string, []adjustmentPriceImportRow, []validationIssue, error) {
workbook, err := excelize.OpenFile(filePath)
if err != nil {
return "", nil, nil, err
}
defer func() {
_ = workbook.Close()
}()
sheetName, err := resolveSheetName(workbook, requestedSheet)
if err != nil {
return "", nil, nil, err
}
allRows, err := workbook.GetRows(sheetName, excelize.Options{RawCellValue: true})
if err != nil {
return "", nil, nil, err
}
if len(allRows) == 0 {
return sheetName, nil, []validationIssue{{Field: "header", Message: "sheet is empty"}}, nil
}
indexes, headerIssues := parseHeaderIndexes(allRows[0])
if len(headerIssues) > 0 {
return sheetName, nil, headerIssues, nil
}
rowsByAdjustmentID := make(map[uint]adjustmentPriceImportRow)
issues := make([]validationIssue, 0)
for idx := 1; idx < len(allRows); idx++ {
rowNumber := idx + 1
rawRow := allRows[idx]
if isRowEmpty(rawRow) {
continue
}
parsed, rowIssues := parseDataRow(rawRow, rowNumber, indexes)
if len(rowIssues) > 0 {
issues = append(issues, rowIssues...)
continue
}
rowsByAdjustmentID[parsed.AdjustmentID] = *parsed
}
rows := make([]adjustmentPriceImportRow, 0, len(rowsByAdjustmentID))
for _, row := range rowsByAdjustmentID {
rows = append(rows, row)
}
sort.Slice(rows, func(i, j int) bool {
return rows[i].RowNumber < rows[j].RowNumber
})
if len(rows) == 0 && len(issues) == 0 {
issues = append(issues, validationIssue{Field: "rows", Message: "no data rows found"})
}
return sheetName, rows, issues, nil
}
func resolveSheetName(workbook *excelize.File, requestedSheet string) (string, error) {
if workbook == nil {
return "", fmt.Errorf("workbook is nil")
}
sheets := workbook.GetSheetList()
if len(sheets) == 0 {
return "", fmt.Errorf("workbook has no sheets")
}
if requestedSheet == "" {
return sheets[0], nil
}
for _, sheet := range sheets {
if strings.EqualFold(strings.TrimSpace(sheet), strings.TrimSpace(requestedSheet)) {
return sheet, nil
}
}
return "", fmt.Errorf("sheet %q not found", requestedSheet)
}
func parseHeaderIndexes(headerRow []string) (headerIndexes, []validationIssue) {
indexes := headerIndexes{AdjustmentID: -1, Weight: -1}
issues := make([]validationIssue, 0)
for idx, raw := range headerRow {
header := normalizeHeader(raw)
if header == "" {
continue
}
switch header {
case "adjustment_id":
if indexes.AdjustmentID >= 0 {
issues = append(issues, validationIssue{Field: "header", Message: "duplicate header adjustment_id"})
}
indexes.AdjustmentID = idx
case "weight":
if indexes.Weight >= 0 {
issues = append(issues, validationIssue{Field: "header", Message: "duplicate header weight"})
}
indexes.Weight = idx
}
}
if indexes.AdjustmentID < 0 {
issues = append(issues, validationIssue{Field: "adjustment_id", Message: "required header is missing"})
}
if indexes.Weight < 0 {
issues = append(issues, validationIssue{Field: "weight", Message: "required header is missing"})
}
return indexes, issues
}
func parseDataRow(
rawRow []string,
rowNumber int,
indexes headerIndexes,
) (*adjustmentPriceImportRow, []validationIssue) {
issues := make([]validationIssue, 0)
adjustmentIDRaw := strings.TrimSpace(cellValue(rawRow, indexes.AdjustmentID))
adjustmentID, err := parsePositiveUint(adjustmentIDRaw)
if err != nil {
issues = append(issues, validationIssue{Row: rowNumber, Field: "adjustment_id", Message: err.Error()})
}
weightRaw := strings.TrimSpace(cellValue(rawRow, indexes.Weight))
weight, err := parseNonNegativeFloat(weightRaw)
if err != nil {
issues = append(issues, validationIssue{Row: rowNumber, Field: "weight", Message: err.Error()})
}
if len(issues) > 0 {
return nil, issues
}
return &adjustmentPriceImportRow{
RowNumber: rowNumber,
AdjustmentID: adjustmentID,
Weight: weight,
}, nil
}
func parsePositiveUint(raw string) (uint, error) {
if raw == "" {
return 0, fmt.Errorf("is required")
}
uintValue, err := strconv.ParseUint(raw, 10, 64)
if err == nil {
if uintValue == 0 {
return 0, fmt.Errorf("must be greater than 0")
}
return uint(uintValue), nil
}
floatValue, floatErr := strconv.ParseFloat(raw, 64)
if floatErr != nil {
return 0, fmt.Errorf("must be a positive integer")
}
if floatValue <= 0 {
return 0, fmt.Errorf("must be greater than 0")
}
if floatValue != float64(uint(floatValue)) {
return 0, fmt.Errorf("must be a positive integer")
}
return uint(floatValue), nil
}
func parseNonNegativeFloat(raw string) (float64, error) {
if raw == "" {
return 0, fmt.Errorf("is required")
}
value, err := strconv.ParseFloat(raw, 64)
if err != nil {
return 0, fmt.Errorf("must be numeric")
}
if value < 0 {
return 0, fmt.Errorf("must be greater than or equal to 0")
}
return value, nil
}
func isRowEmpty(row []string) bool {
for _, cell := range row {
if strings.TrimSpace(cell) != "" {
return false
}
}
return true
}
func normalizeHeader(raw string) string {
return strings.ToLower(strings.TrimSpace(raw))
}
func cellValue(row []string, index int) string {
if index < 0 || index >= len(row) {
return ""
}
return row[index]
}
func collectAdjustmentIDs(rows []adjustmentPriceImportRow) []uint {
ids := make([]uint, 0, len(rows))
seen := make(map[uint]struct{}, len(rows))
for _, row := range rows {
if row.AdjustmentID == 0 {
continue
}
if _, exists := seen[row.AdjustmentID]; exists {
continue
}
seen[row.AdjustmentID] = struct{}{}
ids = append(ids, row.AdjustmentID)
}
sort.Slice(ids, func(i, j int) bool {
return ids[i] < ids[j]
})
return ids
}
func (r dbAdjustmentResolver) ResolveExistingAdjustmentIDs(
ctx context.Context,
adjustmentIDs []uint,
) (map[uint]struct{}, error) {
result := make(map[uint]struct{})
if len(adjustmentIDs) == 0 {
return result, nil
}
type adjustmentIDRow struct {
ID uint `gorm:"column:id"`
}
rows := make([]adjustmentIDRow, 0, len(adjustmentIDs))
if err := r.db.WithContext(ctx).
Table("adjustment_stocks").
Select("id").
Where("id IN ?", adjustmentIDs).
Scan(&rows).Error; err != nil {
return nil, err
}
for _, row := range rows {
result[row.ID] = struct{}{}
}
return result, nil
}
func splitRowsByExistingIDs(
rows []adjustmentPriceImportRow,
existing map[uint]struct{},
) ([]adjustmentPriceImportRow, []adjustmentPriceImportRow) {
processable := make([]adjustmentPriceImportRow, 0, len(rows))
skipped := make([]adjustmentPriceImportRow, 0)
for _, row := range rows {
if _, exists := existing[row.AdjustmentID]; exists {
processable = append(processable, row)
continue
}
skipped = append(skipped, row)
}
return processable, skipped
}
func printPlanRows(rows []adjustmentPriceImportRow) {
for _, row := range rows {
fmt.Printf(
"PLAN row=%d adjustment_id=%d price=%.3f\n",
row.RowNumber,
row.AdjustmentID,
row.Weight,
)
}
}
func printSkippedRows(rows []adjustmentPriceImportRow) {
for _, row := range rows {
fmt.Printf(
"SKIP row=%d adjustment_id=%d reason=adjustment_id not found\n",
row.RowNumber,
row.AdjustmentID,
)
}
}
func sortValidationIssues(issues []validationIssue) {
sort.Slice(issues, func(i, j int) bool {
if issues[i].Row == issues[j].Row {
if issues[i].Field == issues[j].Field {
return issues[i].Message < issues[j].Message
}
return issues[i].Field < issues[j].Field
}
return issues[i].Row < issues[j].Row
})
}
func applyIfRequested(
ctx context.Context,
apply bool,
runner txRunner,
rows []adjustmentPriceImportRow,
) ([]applyRowResult, error) {
if !apply || len(rows) == 0 {
return nil, nil
}
return applyImportRows(ctx, runner, rows)
}
func applyImportRows(
ctx context.Context,
runner txRunner,
rows []adjustmentPriceImportRow,
) ([]applyRowResult, error) {
results := make([]applyRowResult, 0, len(rows))
err := runner.InTx(ctx, func(store adjustmentPriceStore) error {
for _, row := range rows {
changed, err := store.UpdatePrice(ctx, row.AdjustmentID, row.Weight)
if err != nil {
return fmt.Errorf("row %d adjustment_id=%d update failed: %w", row.RowNumber, row.AdjustmentID, err)
}
results = append(results, applyRowResult{
RowNumber: row.RowNumber,
AdjustmentID: row.AdjustmentID,
Price: row.Weight,
Changed: changed,
})
}
return nil
})
if err != nil {
return nil, err
}
return results, nil
}
func (r dbTxRunner) InTx(ctx context.Context, fn func(store adjustmentPriceStore) error) error {
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
return fn(dbAdjustmentPriceStore{db: tx})
})
}
func (s dbAdjustmentPriceStore) UpdatePrice(
ctx context.Context,
adjustmentID uint,
price float64,
) (bool, error) {
result := s.db.WithContext(ctx).Exec(`
UPDATE adjustment_stocks
SET price = ?,
updated_at = NOW()
WHERE id = ?
AND price IS DISTINCT FROM ?
`, price, adjustmentID, price)
if result.Error != nil {
return false, result.Error
}
return result.RowsAffected > 0, nil
}
func modeLabel(apply bool) string {
if apply {
return "APPLY"
}
return "DRY-RUN"
}
func applyStatus(changed bool) string {
if changed {
return "UPDATED"
}
return "UNCHANGED"
}
func countChangedRows(results []applyRowResult) int {
count := 0
for _, result := range results {
if result.Changed {
count++
}
}
return count
}
@@ -0,0 +1,362 @@
package main
import (
"context"
"errors"
"fmt"
"path/filepath"
"strings"
"testing"
"github.com/xuri/excelize/v2"
)
func TestParseAdjustmentPriceFile_ValidSingleRow(t *testing.T) {
filePath := createWorkbook(
t,
"adjustment_prices",
[]string{"adjustment_id", "weight"},
[][]string{{"101", "12.345"}},
)
sheet, rows, issues, err := parseAdjustmentPriceFile(filePath, "")
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if sheet != "adjustment_prices" {
t.Fatalf("expected selected sheet adjustment_prices, got %q", sheet)
}
if len(issues) != 0 {
t.Fatalf("expected no issues, got %+v", issues)
}
if len(rows) != 1 {
t.Fatalf("expected 1 row, got %d", len(rows))
}
if rows[0].AdjustmentID != 101 {
t.Fatalf("expected adjustment_id 101, got %d", rows[0].AdjustmentID)
}
if rows[0].Weight != 12.345 {
t.Fatalf("expected weight 12.345, got %v", rows[0].Weight)
}
}
func TestParseAdjustmentPriceFile_ValidMultiRow(t *testing.T) {
filePath := createWorkbook(
t,
"adjustment_prices",
[]string{" Adjustment_ID ", "WEIGHT"},
[][]string{{"101", "10"}, {"102", "11.5"}},
)
_, rows, issues, err := parseAdjustmentPriceFile(filePath, "adjustment_prices")
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if len(issues) != 0 {
t.Fatalf("expected no issues, got %+v", issues)
}
if len(rows) != 2 {
t.Fatalf("expected 2 rows, got %d", len(rows))
}
}
func TestParseAdjustmentPriceFile_MissingRequiredHeader(t *testing.T) {
filePath := createWorkbook(
t,
"adjustment_prices",
[]string{"adjustment_id", "price"},
[][]string{{"101", "12"}},
)
_, rows, issues, err := parseAdjustmentPriceFile(filePath, "")
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if len(rows) != 0 {
t.Fatalf("expected 0 parsed rows when header invalid, got %d", len(rows))
}
if !hasIssue(issues, 0, "weight", "required header is missing") {
t.Fatalf("expected missing weight header issue, got %+v", issues)
}
}
func TestParseAdjustmentPriceFile_InvalidAdjustmentID(t *testing.T) {
filePath := createWorkbook(
t,
"adjustment_prices",
[]string{"adjustment_id", "weight"},
[][]string{{"abc", "10"}, {"0", "12"}},
)
_, rows, issues, err := parseAdjustmentPriceFile(filePath, "")
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if len(rows) != 0 {
t.Fatalf("expected no valid rows, got %d", len(rows))
}
if !hasIssue(issues, 2, "adjustment_id", "must be a positive integer") {
t.Fatalf("expected non numeric adjustment_id issue, got %+v", issues)
}
if !hasIssue(issues, 3, "adjustment_id", "must be greater than 0") {
t.Fatalf("expected adjustment_id >0 issue, got %+v", issues)
}
}
func TestParseAdjustmentPriceFile_InvalidWeight(t *testing.T) {
filePath := createWorkbook(
t,
"adjustment_prices",
[]string{"adjustment_id", "weight"},
[][]string{{"101", "abc"}, {"102", "-1"}},
)
_, rows, issues, err := parseAdjustmentPriceFile(filePath, "")
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if len(rows) != 0 {
t.Fatalf("expected no valid rows, got %d", len(rows))
}
if !hasIssue(issues, 2, "weight", "must be numeric") {
t.Fatalf("expected weight numeric issue, got %+v", issues)
}
if !hasIssue(issues, 3, "weight", "must be greater than or equal to 0") {
t.Fatalf("expected weight >=0 issue, got %+v", issues)
}
}
func TestParseAdjustmentPriceFile_DuplicateAdjustmentID_LastRowWins(t *testing.T) {
filePath := createWorkbook(
t,
"adjustment_prices",
[]string{"adjustment_id", "weight"},
[][]string{{"101", "10"}, {"102", "20"}, {"101", "30"}},
)
_, rows, issues, err := parseAdjustmentPriceFile(filePath, "")
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if len(issues) != 0 {
t.Fatalf("expected no issues, got %+v", issues)
}
if len(rows) != 2 {
t.Fatalf("expected 2 deduped rows, got %d", len(rows))
}
row101, ok := findRowByAdjustmentID(rows, 101)
if !ok {
t.Fatalf("expected adjustment_id 101 to exist, got %+v", rows)
}
if row101.Weight != 30 {
t.Fatalf("expected duplicate adjustment_id to keep last weight 30, got %v", row101.Weight)
}
if row101.RowNumber != 4 {
t.Fatalf("expected duplicate adjustment_id to keep last row number 4, got %d", row101.RowNumber)
}
}
func TestSplitRowsByExistingIDs_SkipMissing(t *testing.T) {
rows := []adjustmentPriceImportRow{
{RowNumber: 2, AdjustmentID: 101, Weight: 10},
{RowNumber: 3, AdjustmentID: 102, Weight: 11},
{RowNumber: 4, AdjustmentID: 103, Weight: 12},
}
existing := map[uint]struct{}{101: {}, 103: {}}
processable, skipped := splitRowsByExistingIDs(rows, existing)
if len(processable) != 2 {
t.Fatalf("expected 2 processable rows, got %d", len(processable))
}
if len(skipped) != 1 {
t.Fatalf("expected 1 skipped row, got %d", len(skipped))
}
if skipped[0].AdjustmentID != 102 {
t.Fatalf("expected adjustment_id 102 skipped, got %+v", skipped)
}
}
func TestApplyIfRequested_DryRunDoesNotWrite(t *testing.T) {
runner := &fakeTransactionRunner{}
rows := []adjustmentPriceImportRow{{RowNumber: 2, AdjustmentID: 101, Weight: 10}}
results, err := applyIfRequested(context.Background(), false, runner, rows)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if results != nil {
t.Fatalf("expected nil results on dry-run, got %+v", results)
}
if runner.txCalls != 0 {
t.Fatalf("expected no transaction call during dry-run, got %d", runner.txCalls)
}
}
func TestApplyImportRows_Success(t *testing.T) {
runner := &fakeTransactionRunner{
changedByID: map[uint]bool{101: true, 102: false},
}
rows := []adjustmentPriceImportRow{
{RowNumber: 2, AdjustmentID: 101, Weight: 10},
{RowNumber: 3, AdjustmentID: 102, Weight: 11},
}
results, err := applyImportRows(context.Background(), runner, rows)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if runner.txCalls != 1 {
t.Fatalf("expected 1 transaction call, got %d", runner.txCalls)
}
if len(runner.committedCalls) != 2 {
t.Fatalf("expected 2 committed updates, got %d", len(runner.committedCalls))
}
if len(results) != 2 {
t.Fatalf("expected 2 row results, got %d", len(results))
}
if !results[0].Changed || results[1].Changed {
t.Fatalf("unexpected changed flags: %+v", results)
}
}
func TestApplyImportRows_RollbackOnError(t *testing.T) {
runner := &fakeTransactionRunner{
errByID: map[uint]error{102: errors.New("boom")},
}
rows := []adjustmentPriceImportRow{
{RowNumber: 2, AdjustmentID: 101, Weight: 10},
{RowNumber: 3, AdjustmentID: 102, Weight: 11},
}
_, err := applyImportRows(context.Background(), runner, rows)
if err == nil {
t.Fatal("expected error due to update failure")
}
if !strings.Contains(err.Error(), "row 3 adjustment_id=102 update failed") {
t.Fatalf("unexpected error message: %v", err)
}
if runner.txCalls != 1 {
t.Fatalf("expected 1 transaction call, got %d", runner.txCalls)
}
if len(runner.committedCalls) != 0 {
t.Fatalf("expected no committed updates on rollback, got %d", len(runner.committedCalls))
}
}
func createWorkbook(t *testing.T, sheetName string, headers []string, rows [][]string) string {
t.Helper()
f := excelize.NewFile()
defaultSheet := f.GetSheetName(f.GetActiveSheetIndex())
if sheetName == "" {
sheetName = defaultSheet
} else if sheetName != defaultSheet {
f.SetSheetName(defaultSheet, sheetName)
}
for idx, header := range headers {
cell, err := excelize.CoordinatesToCellName(idx+1, 1)
if err != nil {
t.Fatalf("failed resolving header cell: %v", err)
}
if err := f.SetCellValue(sheetName, cell, header); err != nil {
t.Fatalf("failed setting header cell: %v", err)
}
}
for rowIdx, row := range rows {
for colIdx, value := range row {
cell, err := excelize.CoordinatesToCellName(colIdx+1, rowIdx+2)
if err != nil {
t.Fatalf("failed resolving data cell: %v", err)
}
if err := f.SetCellValue(sheetName, cell, value); err != nil {
t.Fatalf("failed setting data cell: %v", err)
}
}
}
path := filepath.Join(t.TempDir(), "adjustment_prices.xlsx")
if err := f.SaveAs(path); err != nil {
t.Fatalf("failed saving workbook: %v", err)
}
if err := f.Close(); err != nil {
t.Fatalf("failed closing workbook: %v", err)
}
return path
}
func hasIssue(issues []validationIssue, row int, field, messageContains string) bool {
for _, issue := range issues {
if issue.Row != row {
continue
}
if issue.Field != field {
continue
}
if strings.Contains(issue.Message, messageContains) {
return true
}
}
return false
}
func findRowByAdjustmentID(rows []adjustmentPriceImportRow, adjustmentID uint) (adjustmentPriceImportRow, bool) {
for _, row := range rows {
if row.AdjustmentID == adjustmentID {
return row, true
}
}
return adjustmentPriceImportRow{}, false
}
type updateCall struct {
adjustmentID uint
price float64
}
type fakeAdjustmentPriceStore struct {
changedByID map[uint]bool
errByID map[uint]error
calls []updateCall
}
func (s *fakeAdjustmentPriceStore) UpdatePrice(_ context.Context, adjustmentID uint, price float64) (bool, error) {
s.calls = append(s.calls, updateCall{adjustmentID: adjustmentID, price: price})
if err, exists := s.errByID[adjustmentID]; exists {
return false, fmt.Errorf("forced update failure for adjustment_id=%d: %w", adjustmentID, err)
}
if changed, exists := s.changedByID[adjustmentID]; exists {
return changed, nil
}
return true, nil
}
type fakeTransactionRunner struct {
txCalls int
changedByID map[uint]bool
errByID map[uint]error
committedCalls []updateCall
}
func (r *fakeTransactionRunner) InTx(ctx context.Context, fn func(store adjustmentPriceStore) error) error {
r.txCalls++
txStore := &fakeAdjustmentPriceStore{
changedByID: r.changedByID,
errByID: r.errByID,
calls: make([]updateCall, 0),
}
if err := fn(txStore); err != nil {
return err
}
r.committedCalls = append(r.committedCalls, txStore.calls...)
return nil
}
var _ txRunner = (*fakeTransactionRunner)(nil)
var _ adjustmentPriceStore = (*fakeAdjustmentPriceStore)(nil)
@@ -0,0 +1,632 @@
package main
import (
"context"
"flag"
"fmt"
"log"
"os"
"sort"
"strconv"
"strings"
"time"
"github.com/xuri/excelize/v2"
"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"
repportRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/repports/repositories"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
"gorm.io/gorm"
)
const dateLayout = "2006-01-02"
type importOptions struct {
FilePath string
Sheet string
Apply bool
}
type headerIndexes struct {
ProjectFlockID int
TotalCost int
CutoverDate int
Note int
}
type manualInputImportRow struct {
RowNumber int
ProjectFlockID uint
TotalCost float64
CutoverDate time.Time
Note *string
}
type validationIssue struct {
Row int
Field string
Message string
}
func (i validationIssue) Error() string {
if i.Row > 0 {
return fmt.Sprintf("row=%d field=%s message=%s", i.Row, i.Field, i.Message)
}
return fmt.Sprintf("field=%s message=%s", i.Field, i.Message)
}
type farmResolver interface {
ResolveActiveLayingFarms(ctx context.Context, projectFlockIDs []uint) (map[uint]string, error)
}
type dbFarmResolver struct {
db *gorm.DB
}
type manualInputStore interface {
UpsertManualInput(ctx context.Context, row *entity.FarmDepreciationManualInput) error
DeleteSnapshotsFromDate(ctx context.Context, fromDate time.Time, farmIDs []uint) error
}
type txRunner interface {
InTx(ctx context.Context, fn func(store manualInputStore) error) error
}
type dbTxRunner struct {
db *gorm.DB
}
type expenseDepreciationStore struct {
repo repportRepo.ExpenseDepreciationRepository
}
type farmIdentityRow struct {
ID uint `gorm:"column:id"`
FarmName string `gorm:"column:farm_name"`
}
func main() {
var opts importOptions
flag.StringVar(&opts.FilePath, "file", "", "Path to .xlsx file (required)")
flag.StringVar(&opts.Sheet, "sheet", "", "Sheet name (optional, default: first sheet)")
flag.BoolVar(&opts.Apply, "apply", false, "Apply changes. If false, run as dry-run")
flag.Parse()
opts.FilePath = strings.TrimSpace(opts.FilePath)
opts.Sheet = strings.TrimSpace(opts.Sheet)
if opts.FilePath == "" {
log.Fatal("--file is required")
}
location, err := time.LoadLocation("Asia/Jakarta")
if err != nil {
log.Fatalf("failed to load timezone Asia/Jakarta: %v", err)
}
sheetName, rows, parseIssues, err := parseManualInputFile(opts.FilePath, opts.Sheet, location)
if err != nil {
log.Fatalf("failed reading excel: %v", err)
}
ctx := context.Background()
db := database.Connect(config.DBHost, config.DBName)
resolver := dbFarmResolver{db: db}
farmNameByID, err := resolver.ResolveActiveLayingFarms(ctx, collectProjectFlockIDs(rows))
if err != nil {
log.Fatalf("failed validating project_flock_id against project_flocks: %v", err)
}
issues := append([]validationIssue{}, parseIssues...)
issues = append(issues, buildMissingFarmIssues(rows, farmNameByID)...)
sortValidationIssues(issues)
fmt.Printf("Mode: %s\n", modeLabel(opts.Apply))
fmt.Printf("File: %s\n", opts.FilePath)
fmt.Printf("Sheet: %s\n", sheetName)
fmt.Printf("Rows parsed: %d\n", len(rows))
fmt.Printf("Rows invalid: %d\n", len(issues))
fmt.Println()
if len(rows) > 0 {
printPlanRows(rows, farmNameByID)
fmt.Println()
}
if len(issues) > 0 {
fmt.Println("Validation errors:")
for _, issue := range issues {
fmt.Printf("ERROR %s\n", issue.Error())
}
fmt.Println()
fmt.Printf("Summary: planned=%d applied=0 failed=%d\n", len(rows), len(issues))
os.Exit(1)
}
if !opts.Apply {
fmt.Printf("Summary: planned=%d applied=0 failed=0\n", len(rows))
return
}
if len(rows) == 0 {
fmt.Println("Summary: planned=0 applied=0 failed=0")
return
}
if err := applyIfRequested(ctx, true, dbTxRunner{db: db}, rows); err != nil {
log.Fatalf("apply failed: %v", err)
}
for _, row := range rows {
fmt.Printf(
"DONE row=%d project_flock_id=%d cutover_date=%s\n",
row.RowNumber,
row.ProjectFlockID,
row.CutoverDate.In(location).Format(dateLayout),
)
}
fmt.Println()
fmt.Printf("Summary: planned=%d applied=%d failed=0\n", len(rows), len(rows))
}
func parseManualInputFile(
filePath string,
requestedSheet string,
location *time.Location,
) (string, []manualInputImportRow, []validationIssue, error) {
workbook, err := excelize.OpenFile(filePath)
if err != nil {
return "", nil, nil, err
}
defer func() {
_ = workbook.Close()
}()
sheetName, err := resolveSheetName(workbook, requestedSheet)
if err != nil {
return "", nil, nil, err
}
allRows, err := workbook.GetRows(sheetName, excelize.Options{RawCellValue: true})
if err != nil {
return "", nil, nil, err
}
if len(allRows) == 0 {
return sheetName, nil, []validationIssue{
{Field: "header", Message: "sheet is empty"},
}, nil
}
indexes, headerIssues := parseHeaderIndexes(allRows[0])
if len(headerIssues) > 0 {
return sheetName, nil, headerIssues, nil
}
rows := make([]manualInputImportRow, 0, len(allRows)-1)
issues := make([]validationIssue, 0)
seenProjectFlockIDs := make(map[uint]int)
for idx := 1; idx < len(allRows); idx++ {
rowNumber := idx + 1
rawRow := allRows[idx]
if isRowEmpty(rawRow) {
continue
}
parsed, rowIssues := parseDataRow(rawRow, rowNumber, indexes, location, seenProjectFlockIDs)
if len(rowIssues) > 0 {
issues = append(issues, rowIssues...)
continue
}
rows = append(rows, *parsed)
}
if len(rows) == 0 && len(issues) == 0 {
issues = append(issues, validationIssue{
Field: "rows",
Message: "no data rows found",
})
}
return sheetName, rows, issues, nil
}
func resolveSheetName(workbook *excelize.File, requestedSheet string) (string, error) {
if workbook == nil {
return "", fmt.Errorf("workbook is nil")
}
sheets := workbook.GetSheetList()
if len(sheets) == 0 {
return "", fmt.Errorf("workbook has no sheets")
}
if requestedSheet == "" {
return sheets[0], nil
}
for _, sheet := range sheets {
if strings.EqualFold(strings.TrimSpace(sheet), strings.TrimSpace(requestedSheet)) {
return sheet, nil
}
}
return "", fmt.Errorf("sheet %q not found", requestedSheet)
}
func parseHeaderIndexes(headerRow []string) (headerIndexes, []validationIssue) {
indexes := headerIndexes{
ProjectFlockID: -1,
TotalCost: -1,
CutoverDate: -1,
Note: -1,
}
issues := make([]validationIssue, 0)
for idx, raw := range headerRow {
header := normalizeHeader(raw)
if header == "" {
continue
}
switch header {
case "project_flock_id":
if indexes.ProjectFlockID >= 0 {
issues = append(issues, validationIssue{
Field: "header",
Message: "duplicate header project_flock_id",
})
}
indexes.ProjectFlockID = idx
case "total_cost":
if indexes.TotalCost >= 0 {
issues = append(issues, validationIssue{
Field: "header",
Message: "duplicate header total_cost",
})
}
indexes.TotalCost = idx
case "cutover_date":
if indexes.CutoverDate >= 0 {
issues = append(issues, validationIssue{
Field: "header",
Message: "duplicate header cutover_date",
})
}
indexes.CutoverDate = idx
case "note":
if indexes.Note >= 0 {
issues = append(issues, validationIssue{
Field: "header",
Message: "duplicate header note",
})
}
indexes.Note = idx
}
}
if indexes.ProjectFlockID < 0 {
issues = append(issues, validationIssue{
Field: "project_flock_id",
Message: "required header is missing",
})
}
if indexes.TotalCost < 0 {
issues = append(issues, validationIssue{
Field: "total_cost",
Message: "required header is missing",
})
}
if indexes.CutoverDate < 0 {
issues = append(issues, validationIssue{
Field: "cutover_date",
Message: "required header is missing",
})
}
return indexes, issues
}
func parseDataRow(
rawRow []string,
rowNumber int,
indexes headerIndexes,
location *time.Location,
seenProjectFlockIDs map[uint]int,
) (*manualInputImportRow, []validationIssue) {
issues := make([]validationIssue, 0)
projectFlockIDRaw := strings.TrimSpace(cellValue(rawRow, indexes.ProjectFlockID))
projectFlockID, err := parsePositiveUint(projectFlockIDRaw)
if err != nil {
issues = append(issues, validationIssue{
Row: rowNumber,
Field: "project_flock_id",
Message: err.Error(),
})
}
totalCostRaw := strings.TrimSpace(cellValue(rawRow, indexes.TotalCost))
totalCost, err := parseNonNegativeFloat(totalCostRaw)
if err != nil {
issues = append(issues, validationIssue{
Row: rowNumber,
Field: "total_cost",
Message: err.Error(),
})
}
cutoverDateRaw := strings.TrimSpace(cellValue(rawRow, indexes.CutoverDate))
cutoverDate, err := parseDateOnlyInLocation(cutoverDateRaw, location)
if err != nil {
issues = append(issues, validationIssue{
Row: rowNumber,
Field: "cutover_date",
Message: err.Error(),
})
}
var note *string
noteRaw := strings.TrimSpace(cellValue(rawRow, indexes.Note))
if noteRaw != "" {
if len([]rune(noteRaw)) > 1000 {
issues = append(issues, validationIssue{
Row: rowNumber,
Field: "note",
Message: "must have at most 1000 characters",
})
} else {
note = &noteRaw
}
}
if projectFlockID > 0 {
if previousRow, exists := seenProjectFlockIDs[projectFlockID]; exists {
issues = append(issues, validationIssue{
Row: rowNumber,
Field: "project_flock_id",
Message: fmt.Sprintf("duplicate value %d (already used in row %d)", projectFlockID, previousRow),
})
} else {
seenProjectFlockIDs[projectFlockID] = rowNumber
}
}
if len(issues) > 0 {
return nil, issues
}
return &manualInputImportRow{
RowNumber: rowNumber,
ProjectFlockID: projectFlockID,
TotalCost: totalCost,
CutoverDate: cutoverDate,
Note: note,
}, nil
}
func parsePositiveUint(raw string) (uint, error) {
if raw == "" {
return 0, fmt.Errorf("is required")
}
uintValue, err := strconv.ParseUint(raw, 10, 64)
if err == nil {
if uintValue == 0 {
return 0, fmt.Errorf("must be greater than 0")
}
return uint(uintValue), nil
}
floatValue, floatErr := strconv.ParseFloat(raw, 64)
if floatErr != nil {
return 0, fmt.Errorf("must be a positive integer")
}
if floatValue <= 0 {
return 0, fmt.Errorf("must be greater than 0")
}
if floatValue != float64(uint(floatValue)) {
return 0, fmt.Errorf("must be a positive integer")
}
return uint(floatValue), nil
}
func parseNonNegativeFloat(raw string) (float64, error) {
if raw == "" {
return 0, fmt.Errorf("is required")
}
value, err := strconv.ParseFloat(raw, 64)
if err != nil {
return 0, fmt.Errorf("must be numeric")
}
if value < 0 {
return 0, fmt.Errorf("must be greater than or equal to 0")
}
return value, nil
}
func parseDateOnlyInLocation(raw string, location *time.Location) (time.Time, error) {
if raw == "" {
return time.Time{}, fmt.Errorf("is required")
}
value, err := time.ParseInLocation(dateLayout, raw, location)
if err != nil {
return time.Time{}, fmt.Errorf("must follow format YYYY-MM-DD")
}
return value, nil
}
func isRowEmpty(row []string) bool {
for _, cell := range row {
if strings.TrimSpace(cell) != "" {
return false
}
}
return true
}
func normalizeHeader(raw string) string {
return strings.ToLower(strings.TrimSpace(raw))
}
func cellValue(row []string, index int) string {
if index < 0 || index >= len(row) {
return ""
}
return row[index]
}
func collectProjectFlockIDs(rows []manualInputImportRow) []uint {
ids := make([]uint, 0, len(rows))
seen := make(map[uint]struct{}, len(rows))
for _, row := range rows {
if row.ProjectFlockID == 0 {
continue
}
if _, exists := seen[row.ProjectFlockID]; exists {
continue
}
seen[row.ProjectFlockID] = struct{}{}
ids = append(ids, row.ProjectFlockID)
}
sort.Slice(ids, func(i, j int) bool {
return ids[i] < ids[j]
})
return ids
}
func (r dbFarmResolver) ResolveActiveLayingFarms(
ctx context.Context,
projectFlockIDs []uint,
) (map[uint]string, error) {
result := make(map[uint]string)
if len(projectFlockIDs) == 0 {
return result, nil
}
rows := make([]farmIdentityRow, 0, len(projectFlockIDs))
if err := r.db.WithContext(ctx).
Table("project_flocks").
Select("id, flock_name AS farm_name").
Where("id IN ?", projectFlockIDs).
Where("deleted_at IS NULL").
Where("category = ?", utils.ProjectFlockCategoryLaying).
Scan(&rows).Error; err != nil {
return nil, err
}
for _, row := range rows {
result[row.ID] = row.FarmName
}
return result, nil
}
func buildMissingFarmIssues(rows []manualInputImportRow, farmNameByID map[uint]string) []validationIssue {
issues := make([]validationIssue, 0)
for _, row := range rows {
if _, exists := farmNameByID[row.ProjectFlockID]; exists {
continue
}
issues = append(issues, validationIssue{
Row: row.RowNumber,
Field: "project_flock_id",
Message: fmt.Sprintf("value %d must reference an active LAYING project_flock", row.ProjectFlockID),
})
}
return issues
}
func printPlanRows(rows []manualInputImportRow, farmNameByID map[uint]string) {
for _, row := range rows {
farmName := farmNameByID[row.ProjectFlockID]
fmt.Printf(
"PLAN row=%d project_flock_id=%d farm_name=%q total_cost=%.3f cutover_date=%s note=%q\n",
row.RowNumber,
row.ProjectFlockID,
farmName,
row.TotalCost,
row.CutoverDate.Format(dateLayout),
derefString(row.Note),
)
}
}
func sortValidationIssues(issues []validationIssue) {
sort.Slice(issues, func(i, j int) bool {
if issues[i].Row == issues[j].Row {
if issues[i].Field == issues[j].Field {
return issues[i].Message < issues[j].Message
}
return issues[i].Field < issues[j].Field
}
return issues[i].Row < issues[j].Row
})
}
func applyIfRequested(ctx context.Context, apply bool, runner txRunner, rows []manualInputImportRow) error {
if !apply || len(rows) == 0 {
return nil
}
return applyImportRows(ctx, runner, rows)
}
func applyImportRows(ctx context.Context, runner txRunner, rows []manualInputImportRow) error {
return runner.InTx(ctx, func(store manualInputStore) error {
for _, row := range rows {
payload := entity.FarmDepreciationManualInput{
ProjectFlockId: row.ProjectFlockID,
TotalCost: row.TotalCost,
CutoverDate: row.CutoverDate,
Note: row.Note,
}
if err := store.UpsertManualInput(ctx, &payload); err != nil {
return fmt.Errorf("row %d project_flock_id=%d upsert failed: %w", row.RowNumber, row.ProjectFlockID, err)
}
if err := store.DeleteSnapshotsFromDate(ctx, row.CutoverDate, []uint{row.ProjectFlockID}); err != nil {
return fmt.Errorf("row %d project_flock_id=%d snapshot invalidation failed: %w", row.RowNumber, row.ProjectFlockID, err)
}
}
return nil
})
}
func (r dbTxRunner) InTx(ctx context.Context, fn func(store manualInputStore) error) error {
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
repo := repportRepo.NewExpenseDepreciationRepository(tx)
store := expenseDepreciationStore{repo: repo}
return fn(store)
})
}
func (s expenseDepreciationStore) UpsertManualInput(ctx context.Context, row *entity.FarmDepreciationManualInput) error {
return s.repo.UpsertManualInput(ctx, row)
}
func (s expenseDepreciationStore) DeleteSnapshotsFromDate(ctx context.Context, fromDate time.Time, farmIDs []uint) error {
return s.repo.DeleteSnapshotsFromDate(ctx, fromDate, farmIDs)
}
func modeLabel(apply bool) string {
if apply {
return "APPLY"
}
return "DRY-RUN"
}
func derefString(value *string) string {
if value == nil {
return ""
}
return *value
}
@@ -0,0 +1,563 @@
package main
import (
"context"
"errors"
"fmt"
"path/filepath"
"strings"
"testing"
"time"
"github.com/xuri/excelize/v2"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
)
func TestParseManualInputFile_ValidSingleRow(t *testing.T) {
filePath := createManualInputWorkbook(
t,
"manual_inputs",
[]string{"project_flock_id", "total_cost", "cutover_date", "note"},
[][]string{
{"101", "12345.678", "2026-06-01", "manual seed"},
},
)
location := mustJakartaLocation(t)
sheet, rows, issues, err := parseManualInputFile(filePath, "", location)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if sheet != "manual_inputs" {
t.Fatalf("expected selected sheet manual_inputs, got %q", sheet)
}
if len(issues) != 0 {
t.Fatalf("expected no issues, got %+v", issues)
}
if len(rows) != 1 {
t.Fatalf("expected 1 row, got %d", len(rows))
}
if rows[0].ProjectFlockID != 101 {
t.Fatalf("expected project_flock_id 101, got %d", rows[0].ProjectFlockID)
}
if rows[0].TotalCost != 12345.678 {
t.Fatalf("expected total_cost 12345.678, got %v", rows[0].TotalCost)
}
if rows[0].CutoverDate.Format(dateLayout) != "2026-06-01" {
t.Fatalf("expected cutover_date 2026-06-01, got %s", rows[0].CutoverDate.Format(dateLayout))
}
if rows[0].Note == nil || *rows[0].Note != "manual seed" {
t.Fatalf("expected note manual seed, got %+v", rows[0].Note)
}
}
func TestParseManualInputFile_ValidMultiRow(t *testing.T) {
filePath := createManualInputWorkbook(
t,
"manual_inputs",
[]string{" Project_Flock_ID ", "TOTAL_COST", "cutover_date", "NOTE"},
[][]string{
{"101", "1200", "2026-06-01", ""},
{"102", "1300.5", "2026-06-02", "second"},
},
)
location := mustJakartaLocation(t)
_, rows, issues, err := parseManualInputFile(filePath, "manual_inputs", location)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if len(issues) != 0 {
t.Fatalf("expected no issues, got %+v", issues)
}
if len(rows) != 2 {
t.Fatalf("expected 2 rows, got %d", len(rows))
}
if rows[0].Note != nil {
t.Fatalf("expected first row note nil, got %+v", rows[0].Note)
}
if rows[1].Note == nil || *rows[1].Note != "second" {
t.Fatalf("expected second row note second, got %+v", rows[1].Note)
}
}
func TestParseManualInputFile_MissingRequiredHeader(t *testing.T) {
filePath := createManualInputWorkbook(
t,
"manual_inputs",
[]string{"project_flock_id", "totalcost", "cutover_date", "note"},
[][]string{
{"101", "1200", "2026-06-01", ""},
},
)
location := mustJakartaLocation(t)
_, rows, issues, err := parseManualInputFile(filePath, "", location)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if len(rows) != 0 {
t.Fatalf("expected 0 parsed rows when header invalid, got %d", len(rows))
}
if !hasIssue(issues, 0, "total_cost", "required header is missing") {
t.Fatalf("expected missing total_cost header issue, got %+v", issues)
}
}
func TestParseManualInputFile_InvalidProjectFlockID(t *testing.T) {
filePath := createManualInputWorkbook(
t,
"manual_inputs",
[]string{"project_flock_id", "total_cost", "cutover_date", "note"},
[][]string{
{"abc", "1200", "2026-06-01", ""},
{"0", "1300", "2026-06-02", ""},
},
)
location := mustJakartaLocation(t)
_, rows, issues, err := parseManualInputFile(filePath, "", location)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if len(rows) != 0 {
t.Fatalf("expected no valid rows, got %d", len(rows))
}
if !hasIssue(issues, 2, "project_flock_id", "must be a positive integer") {
t.Fatalf("expected non numeric project_flock_id issue, got %+v", issues)
}
if !hasIssue(issues, 3, "project_flock_id", "must be greater than 0") {
t.Fatalf("expected project_flock_id >0 issue, got %+v", issues)
}
}
func TestParseManualInputFile_InvalidTotalCost(t *testing.T) {
filePath := createManualInputWorkbook(
t,
"manual_inputs",
[]string{"project_flock_id", "total_cost", "cutover_date", "note"},
[][]string{
{"101", "abc", "2026-06-01", ""},
{"102", "-1", "2026-06-02", ""},
},
)
location := mustJakartaLocation(t)
_, rows, issues, err := parseManualInputFile(filePath, "", location)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if len(rows) != 0 {
t.Fatalf("expected no valid rows, got %d", len(rows))
}
if !hasIssue(issues, 2, "total_cost", "must be numeric") {
t.Fatalf("expected total_cost numeric issue, got %+v", issues)
}
if !hasIssue(issues, 3, "total_cost", "must be greater than or equal to 0") {
t.Fatalf("expected total_cost >=0 issue, got %+v", issues)
}
}
func TestParseManualInputFile_InvalidCutoverDate(t *testing.T) {
filePath := createManualInputWorkbook(
t,
"manual_inputs",
[]string{"project_flock_id", "total_cost", "cutover_date", "note"},
[][]string{
{"101", "1200", "06-01-2026", ""},
},
)
location := mustJakartaLocation(t)
_, rows, issues, err := parseManualInputFile(filePath, "", location)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if len(rows) != 0 {
t.Fatalf("expected no valid rows, got %d", len(rows))
}
if !hasIssue(issues, 2, "cutover_date", "must follow format YYYY-MM-DD") {
t.Fatalf("expected cutover_date format issue, got %+v", issues)
}
}
func TestParseManualInputFile_DuplicateProjectFlockID(t *testing.T) {
filePath := createManualInputWorkbook(
t,
"manual_inputs",
[]string{"project_flock_id", "total_cost", "cutover_date", "note"},
[][]string{
{"101", "1200", "2026-06-01", ""},
{"101", "1300", "2026-06-02", ""},
},
)
location := mustJakartaLocation(t)
_, rows, issues, err := parseManualInputFile(filePath, "", location)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if len(rows) != 1 {
t.Fatalf("expected first row valid and second row invalid, got %d rows", len(rows))
}
if !hasIssue(issues, 3, "project_flock_id", "duplicate value 101") {
t.Fatalf("expected duplicate project_flock_id issue, got %+v", issues)
}
}
func TestParseManualInputFile_NoteValidation(t *testing.T) {
longNote := strings.Repeat("a", 1001)
filePath := createManualInputWorkbook(
t,
"manual_inputs",
[]string{"project_flock_id", "total_cost", "cutover_date", "note"},
[][]string{
{"101", "1200", "2026-06-01", ""},
{"102", "1300", "2026-06-02", longNote},
},
)
location := mustJakartaLocation(t)
_, rows, issues, err := parseManualInputFile(filePath, "", location)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if len(rows) != 1 {
t.Fatalf("expected only first row valid, got %d", len(rows))
}
if rows[0].Note != nil {
t.Fatalf("expected first row note nil, got %+v", rows[0].Note)
}
if !hasIssue(issues, 3, "note", "at most 1000 characters") {
t.Fatalf("expected note length issue, got %+v", issues)
}
}
func TestApplyImportRows_Success(t *testing.T) {
location := mustJakartaLocation(t)
runner := &fakeTransactionRunner{}
rows := []manualInputImportRow{
{
RowNumber: 2,
ProjectFlockID: 101,
TotalCost: 1000,
CutoverDate: mustDateInLocation(t, "2026-06-01", location),
},
{
RowNumber: 3,
ProjectFlockID: 102,
TotalCost: 2000,
CutoverDate: mustDateInLocation(t, "2026-06-02", location),
},
}
err := applyImportRows(context.Background(), runner, rows)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if runner.txCalls != 1 {
t.Fatalf("expected 1 transaction call, got %d", runner.txCalls)
}
if len(runner.committedUpserts) != 2 {
t.Fatalf("expected 2 committed upserts, got %d", len(runner.committedUpserts))
}
if len(runner.committedInvalidations) != 2 {
t.Fatalf("expected 2 committed invalidations, got %d", len(runner.committedInvalidations))
}
if runner.committedInvalidations[0].farmIDs[0] != 101 || runner.committedInvalidations[1].farmIDs[0] != 102 {
t.Fatalf("unexpected invalidation farm IDs: %+v", runner.committedInvalidations)
}
}
func TestApplyImportRows_RollbackOnError(t *testing.T) {
location := mustJakartaLocation(t)
runner := &fakeTransactionRunner{
failUpsertOnProjectFlockID: 102,
}
rows := []manualInputImportRow{
{
RowNumber: 2,
ProjectFlockID: 101,
TotalCost: 1000,
CutoverDate: mustDateInLocation(t, "2026-06-01", location),
},
{
RowNumber: 3,
ProjectFlockID: 102,
TotalCost: 2000,
CutoverDate: mustDateInLocation(t, "2026-06-02", location),
},
}
err := applyImportRows(context.Background(), runner, rows)
if err == nil {
t.Fatal("expected error due to upsert failure")
}
if runner.txCalls != 1 {
t.Fatalf("expected 1 transaction call, got %d", runner.txCalls)
}
if len(runner.committedUpserts) != 0 {
t.Fatalf("expected no committed upserts on rollback, got %d", len(runner.committedUpserts))
}
if len(runner.committedInvalidations) != 0 {
t.Fatalf("expected no committed invalidations on rollback, got %d", len(runner.committedInvalidations))
}
}
func TestApplyIfRequested_DryRunDoesNotWrite(t *testing.T) {
location := mustJakartaLocation(t)
runner := &fakeTransactionRunner{}
rows := []manualInputImportRow{
{
RowNumber: 2,
ProjectFlockID: 101,
TotalCost: 1000,
CutoverDate: mustDateInLocation(t, "2026-06-01", location),
},
}
err := applyIfRequested(context.Background(), false, runner, rows)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if runner.txCalls != 0 {
t.Fatalf("expected no transaction call during dry-run, got %d", runner.txCalls)
}
}
func createManualInputWorkbook(t *testing.T, sheetName string, headers []string, rows [][]string) string {
t.Helper()
f := excelize.NewFile()
defaultSheet := f.GetSheetName(f.GetActiveSheetIndex())
if sheetName == "" {
sheetName = defaultSheet
} else if sheetName != defaultSheet {
f.SetSheetName(defaultSheet, sheetName)
}
for idx, header := range headers {
cell, err := excelize.CoordinatesToCellName(idx+1, 1)
if err != nil {
t.Fatalf("failed resolving header cell: %v", err)
}
if err := f.SetCellValue(sheetName, cell, header); err != nil {
t.Fatalf("failed setting header cell: %v", err)
}
}
for rowIdx, row := range rows {
for colIdx, value := range row {
cell, err := excelize.CoordinatesToCellName(colIdx+1, rowIdx+2)
if err != nil {
t.Fatalf("failed resolving data cell: %v", err)
}
if err := f.SetCellValue(sheetName, cell, value); err != nil {
t.Fatalf("failed setting data cell: %v", err)
}
}
}
path := filepath.Join(t.TempDir(), "manual_inputs.xlsx")
if err := f.SaveAs(path); err != nil {
t.Fatalf("failed saving workbook: %v", err)
}
if err := f.Close(); err != nil {
t.Fatalf("failed closing workbook: %v", err)
}
return path
}
func mustJakartaLocation(t *testing.T) *time.Location {
t.Helper()
location, err := time.LoadLocation("Asia/Jakarta")
if err != nil {
t.Fatalf("failed loading Asia/Jakarta location: %v", err)
}
return location
}
func mustDateInLocation(t *testing.T, raw string, location *time.Location) time.Time {
t.Helper()
value, err := time.ParseInLocation(dateLayout, raw, location)
if err != nil {
t.Fatalf("failed parsing date %q: %v", raw, err)
}
return value
}
func hasIssue(issues []validationIssue, row int, field, messageContains string) bool {
for _, issue := range issues {
if issue.Row != row {
continue
}
if issue.Field != field {
continue
}
if strings.Contains(issue.Message, messageContains) {
return true
}
}
return false
}
type fakeInvalidation struct {
fromDate time.Time
farmIDs []uint
}
type fakeManualInputStore struct {
failUpsertOnProjectFlockID uint
failDeleteOnProjectFlockID uint
upserts []entity.FarmDepreciationManualInput
invalidations []fakeInvalidation
}
func (s *fakeManualInputStore) UpsertManualInput(_ context.Context, row *entity.FarmDepreciationManualInput) error {
if row == nil {
return nil
}
if s.failUpsertOnProjectFlockID > 0 && row.ProjectFlockId == s.failUpsertOnProjectFlockID {
return fmt.Errorf("forced upsert failure for project_flock_id=%d", row.ProjectFlockId)
}
cloned := *row
s.upserts = append(s.upserts, cloned)
return nil
}
func (s *fakeManualInputStore) DeleteSnapshotsFromDate(_ context.Context, fromDate time.Time, farmIDs []uint) error {
if s.failDeleteOnProjectFlockID > 0 {
for _, farmID := range farmIDs {
if farmID == s.failDeleteOnProjectFlockID {
return fmt.Errorf("forced delete failure for project_flock_id=%d", farmID)
}
}
}
copiedFarmIDs := append([]uint{}, farmIDs...)
s.invalidations = append(s.invalidations, fakeInvalidation{
fromDate: fromDate,
farmIDs: copiedFarmIDs,
})
return nil
}
type fakeTransactionRunner struct {
txCalls int
failUpsertOnProjectFlockID uint
failDeleteOnProjectFlockID uint
committedUpserts []entity.FarmDepreciationManualInput
committedInvalidations []fakeInvalidation
}
func (r *fakeTransactionRunner) InTx(ctx context.Context, fn func(store manualInputStore) error) error {
r.txCalls++
txStore := &fakeManualInputStore{
failUpsertOnProjectFlockID: r.failUpsertOnProjectFlockID,
failDeleteOnProjectFlockID: r.failDeleteOnProjectFlockID,
}
if err := fn(txStore); err != nil {
return err
}
r.committedUpserts = append(r.committedUpserts, txStore.upserts...)
r.committedInvalidations = append(r.committedInvalidations, txStore.invalidations...)
return nil
}
var _ txRunner = (*fakeTransactionRunner)(nil)
var _ manualInputStore = (*fakeManualInputStore)(nil)
func TestBuildMissingFarmIssues(t *testing.T) {
location := mustJakartaLocation(t)
rows := []manualInputImportRow{
{
RowNumber: 2,
ProjectFlockID: 101,
TotalCost: 1000,
CutoverDate: mustDateInLocation(t, "2026-06-01", location),
},
{
RowNumber: 3,
ProjectFlockID: 102,
TotalCost: 1000,
CutoverDate: mustDateInLocation(t, "2026-06-01", location),
},
}
issues := buildMissingFarmIssues(rows, map[uint]string{
101: "Farm A",
})
if len(issues) != 1 {
t.Fatalf("expected 1 issue, got %+v", issues)
}
if issues[0].Row != 3 || issues[0].Field != "project_flock_id" {
t.Fatalf("unexpected issue: %+v", issues[0])
}
}
func TestApplyImportRows_PropagatesDeleteError(t *testing.T) {
location := mustJakartaLocation(t)
runner := &fakeTransactionRunner{
failDeleteOnProjectFlockID: 101,
}
rows := []manualInputImportRow{
{
RowNumber: 2,
ProjectFlockID: 101,
TotalCost: 1000,
CutoverDate: mustDateInLocation(t, "2026-06-01", location),
},
}
err := applyImportRows(context.Background(), runner, rows)
if err == nil {
t.Fatal("expected delete failure")
}
if !strings.Contains(err.Error(), "snapshot invalidation failed") {
t.Fatalf("expected snapshot invalidation error message, got %v", err)
}
}
func TestResolveSheetName_ErrorWhenSheetNotFound(t *testing.T) {
workbook := excelize.NewFile()
defer func() {
_ = workbook.Close()
}()
_, err := resolveSheetName(workbook, "unknown")
if err == nil {
t.Fatal("expected error when sheet is missing")
}
}
func TestApplyIfRequested_ApplyUsesRunnerError(t *testing.T) {
location := mustJakartaLocation(t)
rows := []manualInputImportRow{
{
RowNumber: 2,
ProjectFlockID: 101,
TotalCost: 1000,
CutoverDate: mustDateInLocation(t, "2026-06-01", location),
},
}
runner := &errorTxRunner{err: errors.New("tx failed")}
err := applyIfRequested(context.Background(), true, runner, rows)
if err == nil {
t.Fatal("expected transaction error")
}
if err.Error() != "tx failed" {
t.Fatalf("unexpected error: %v", err)
}
}
type errorTxRunner struct {
err error
}
func (r *errorTxRunner) InTx(_ context.Context, _ func(store manualInputStore) error) error {
return r.err
}
+602
View File
@@ -0,0 +1,602 @@
package main
import (
"context"
"flag"
"fmt"
"log"
"os"
"sort"
"strconv"
"strings"
"github.com/xuri/excelize/v2"
"gitlab.com/mbugroup/lti-api.git/internal/config"
"gitlab.com/mbugroup/lti-api.git/internal/database"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
"gorm.io/gorm"
)
type importOptions struct {
FilePath string
Sheet string
Apply bool
}
type headerIndexes struct {
KandangID int
KandangName int
HouseType int
}
type kandangHouseTypeImportRow struct {
RowNumber int
KandangID uint
KandangName string
HouseType string
}
type validationIssue struct {
Row int
Field string
Message string
}
func (i validationIssue) Error() string {
if i.Row > 0 {
return fmt.Sprintf("row=%d field=%s message=%s", i.Row, i.Field, i.Message)
}
return fmt.Sprintf("field=%s message=%s", i.Field, i.Message)
}
type kandangResolver interface {
ResolveActiveKandangs(ctx context.Context, kandangIDs []uint) (map[uint]string, error)
}
type dbKandangResolver struct {
db *gorm.DB
}
type txRunner interface {
InTx(ctx context.Context, fn func(store kandangHouseTypeStore) error) error
}
type dbTxRunner struct {
db *gorm.DB
}
type kandangHouseTypeStore interface {
UpdateKandangHouseType(ctx context.Context, kandangID uint, houseType string) (bool, error)
NormalizeNullHouseType(ctx context.Context) (int64, error)
}
type dbKandangHouseTypeStore struct {
db *gorm.DB
}
type kandangIdentityRow struct {
ID uint `gorm:"column:id"`
Name string `gorm:"column:name"`
}
type applyRowResult struct {
RowNumber int
KandangID uint
HouseType string
Changed bool
}
func main() {
var opts importOptions
flag.StringVar(&opts.FilePath, "file", "", "Path to .xlsx file (required)")
flag.StringVar(&opts.Sheet, "sheet", "", "Sheet name (optional, default: first sheet)")
flag.BoolVar(&opts.Apply, "apply", false, "Apply changes. If false, run as dry-run")
flag.Parse()
opts.FilePath = strings.TrimSpace(opts.FilePath)
opts.Sheet = strings.TrimSpace(opts.Sheet)
if opts.FilePath == "" {
log.Fatal("--file is required")
}
sheetName, rows, parseIssues, err := parseKandangHouseTypeFile(opts.FilePath, opts.Sheet)
if err != nil {
log.Fatalf("failed reading excel: %v", err)
}
ctx := context.Background()
db := database.Connect(config.DBHost, config.DBName)
resolver := dbKandangResolver{db: db}
kandangNameByID, err := resolver.ResolveActiveKandangs(ctx, collectKandangIDs(rows))
if err != nil {
log.Fatalf("failed validating kandang_id against kandangs: %v", err)
}
issues := append([]validationIssue{}, parseIssues...)
issues = append(issues, buildMissingKandangIssues(rows, kandangNameByID)...)
issues = append(issues, buildNameMismatchIssues(rows, kandangNameByID)...)
sortValidationIssues(issues)
fmt.Printf("Mode: %s\n", modeLabel(opts.Apply))
fmt.Printf("File: %s\n", opts.FilePath)
fmt.Printf("Sheet: %s\n", sheetName)
fmt.Printf("Rows parsed: %d\n", len(rows))
fmt.Printf("Rows invalid: %d\n", len(issues))
fmt.Println()
if len(rows) > 0 {
printPlanRows(rows, kandangNameByID)
fmt.Println()
}
if len(issues) > 0 {
fmt.Println("Validation errors:")
for _, issue := range issues {
fmt.Printf("ERROR %s\n", issue.Error())
}
fmt.Println()
fmt.Printf("Summary: planned=%d applied=0 normalized_null_to_open_house=0 failed=%d\n", len(rows), len(issues))
os.Exit(1)
}
if !opts.Apply {
fmt.Printf("Summary: planned=%d applied=0 normalized_null_to_open_house=0 failed=0\n", len(rows))
return
}
rowResults, normalizedCount, err := applyImportRows(ctx, dbTxRunner{db: db}, rows)
if err != nil {
log.Fatalf("apply failed: %v", err)
}
for _, result := range rowResults {
fmt.Printf(
"DONE row=%d kandang_id=%d house_type=%s status=%s\n",
result.RowNumber,
result.KandangID,
result.HouseType,
applyStatus(result.Changed),
)
}
appliedCount := countChangedRows(rowResults)
fmt.Println()
fmt.Printf(
"Summary: planned=%d applied=%d normalized_null_to_open_house=%d failed=0\n",
len(rows),
appliedCount,
normalizedCount,
)
}
func parseKandangHouseTypeFile(
filePath string,
requestedSheet string,
) (string, []kandangHouseTypeImportRow, []validationIssue, error) {
workbook, err := excelize.OpenFile(filePath)
if err != nil {
return "", nil, nil, err
}
defer func() {
_ = workbook.Close()
}()
sheetName, err := resolveSheetName(workbook, requestedSheet)
if err != nil {
return "", nil, nil, err
}
allRows, err := workbook.GetRows(sheetName, excelize.Options{RawCellValue: true})
if err != nil {
return "", nil, nil, err
}
if len(allRows) == 0 {
return sheetName, nil, []validationIssue{{Field: "header", Message: "sheet is empty"}}, nil
}
indexes, headerIssues := parseHeaderIndexes(allRows[0])
if len(headerIssues) > 0 {
return sheetName, nil, headerIssues, nil
}
rows := make([]kandangHouseTypeImportRow, 0, len(allRows)-1)
issues := make([]validationIssue, 0)
seenKandangIDs := make(map[uint]int)
for idx := 1; idx < len(allRows); idx++ {
rowNumber := idx + 1
rawRow := allRows[idx]
if isRowEmpty(rawRow) {
continue
}
parsed, rowIssues := parseDataRow(rawRow, rowNumber, indexes, seenKandangIDs)
if len(rowIssues) > 0 {
issues = append(issues, rowIssues...)
continue
}
rows = append(rows, *parsed)
}
if len(rows) == 0 && len(issues) == 0 {
issues = append(issues, validationIssue{Field: "rows", Message: "no data rows found"})
}
return sheetName, rows, issues, nil
}
func resolveSheetName(workbook *excelize.File, requestedSheet string) (string, error) {
if workbook == nil {
return "", fmt.Errorf("workbook is nil")
}
sheets := workbook.GetSheetList()
if len(sheets) == 0 {
return "", fmt.Errorf("workbook has no sheets")
}
if requestedSheet == "" {
return sheets[0], nil
}
for _, sheet := range sheets {
if strings.EqualFold(strings.TrimSpace(sheet), strings.TrimSpace(requestedSheet)) {
return sheet, nil
}
}
return "", fmt.Errorf("sheet %q not found", requestedSheet)
}
func parseHeaderIndexes(headerRow []string) (headerIndexes, []validationIssue) {
indexes := headerIndexes{KandangID: -1, KandangName: -1, HouseType: -1}
issues := make([]validationIssue, 0)
for idx, raw := range headerRow {
header := normalizeHeader(raw)
if header == "" {
continue
}
switch header {
case "kandang_id":
if indexes.KandangID >= 0 {
issues = append(issues, validationIssue{Field: "header", Message: "duplicate header kandang_id"})
}
indexes.KandangID = idx
case "kandang_name":
if indexes.KandangName >= 0 {
issues = append(issues, validationIssue{Field: "header", Message: "duplicate header kandang_name"})
}
indexes.KandangName = idx
case "house_type", "type_house":
if indexes.HouseType >= 0 {
issues = append(issues, validationIssue{Field: "header", Message: "duplicate header house_type"})
}
indexes.HouseType = idx
}
}
if indexes.KandangID < 0 {
issues = append(issues, validationIssue{Field: "kandang_id", Message: "required header is missing"})
}
if indexes.KandangName < 0 {
issues = append(issues, validationIssue{Field: "kandang_name", Message: "required header is missing"})
}
if indexes.HouseType < 0 {
issues = append(issues, validationIssue{Field: "house_type", Message: "required header is missing"})
}
return indexes, issues
}
func parseDataRow(
rawRow []string,
rowNumber int,
indexes headerIndexes,
seenKandangIDs map[uint]int,
) (*kandangHouseTypeImportRow, []validationIssue) {
issues := make([]validationIssue, 0)
kandangIDRaw := strings.TrimSpace(cellValue(rawRow, indexes.KandangID))
kandangID, err := parsePositiveUint(kandangIDRaw)
if err != nil {
issues = append(issues, validationIssue{Row: rowNumber, Field: "kandang_id", Message: err.Error()})
}
kandangNameRaw := strings.TrimSpace(cellValue(rawRow, indexes.KandangName))
if kandangNameRaw == "" {
issues = append(issues, validationIssue{Row: rowNumber, Field: "kandang_name", Message: "is required"})
}
houseTypeRaw := strings.TrimSpace(cellValue(rawRow, indexes.HouseType))
houseType, err := normalizeHouseType(houseTypeRaw)
if err != nil {
issues = append(issues, validationIssue{Row: rowNumber, Field: "house_type", Message: err.Error()})
}
if kandangID > 0 {
if previousRow, exists := seenKandangIDs[kandangID]; exists {
issues = append(issues, validationIssue{
Row: rowNumber,
Field: "kandang_id",
Message: fmt.Sprintf("duplicate value %d (already used in row %d)", kandangID, previousRow),
})
} else {
seenKandangIDs[kandangID] = rowNumber
}
}
if len(issues) > 0 {
return nil, issues
}
return &kandangHouseTypeImportRow{
RowNumber: rowNumber,
KandangID: kandangID,
KandangName: kandangNameRaw,
HouseType: houseType,
}, nil
}
func normalizeHouseType(raw string) (string, error) {
normalized := strings.ToLower(strings.TrimSpace(raw))
if normalized == "" {
return string(utils.HouseTypeOpenHouse), nil
}
switch normalized {
case string(utils.HouseTypeOpenHouse), string(utils.HouseTypeCloseHouse):
return normalized, nil
default:
return "", fmt.Errorf("must be one of: open_house, close_house (or empty for default open_house)")
}
}
func parsePositiveUint(raw string) (uint, error) {
if raw == "" {
return 0, fmt.Errorf("is required")
}
uintValue, err := strconv.ParseUint(raw, 10, 64)
if err == nil {
if uintValue == 0 {
return 0, fmt.Errorf("must be greater than 0")
}
return uint(uintValue), nil
}
floatValue, floatErr := strconv.ParseFloat(raw, 64)
if floatErr != nil {
return 0, fmt.Errorf("must be a positive integer")
}
if floatValue <= 0 {
return 0, fmt.Errorf("must be greater than 0")
}
if floatValue != float64(uint(floatValue)) {
return 0, fmt.Errorf("must be a positive integer")
}
return uint(floatValue), nil
}
func isRowEmpty(row []string) bool {
for _, cell := range row {
if strings.TrimSpace(cell) != "" {
return false
}
}
return true
}
func normalizeHeader(raw string) string {
return strings.ToLower(strings.TrimSpace(raw))
}
func cellValue(row []string, index int) string {
if index < 0 || index >= len(row) {
return ""
}
return row[index]
}
func collectKandangIDs(rows []kandangHouseTypeImportRow) []uint {
ids := make([]uint, 0, len(rows))
seen := make(map[uint]struct{}, len(rows))
for _, row := range rows {
if row.KandangID == 0 {
continue
}
if _, exists := seen[row.KandangID]; exists {
continue
}
seen[row.KandangID] = struct{}{}
ids = append(ids, row.KandangID)
}
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
return ids
}
func (r dbKandangResolver) ResolveActiveKandangs(ctx context.Context, kandangIDs []uint) (map[uint]string, error) {
result := make(map[uint]string)
if len(kandangIDs) == 0 {
return result, nil
}
rows := make([]kandangIdentityRow, 0, len(kandangIDs))
if err := r.db.WithContext(ctx).
Table("kandangs").
Select("id, name").
Where("id IN ?", kandangIDs).
Where("deleted_at IS NULL").
Scan(&rows).Error; err != nil {
return nil, err
}
for _, row := range rows {
result[row.ID] = row.Name
}
return result, nil
}
func buildMissingKandangIssues(rows []kandangHouseTypeImportRow, kandangNameByID map[uint]string) []validationIssue {
issues := make([]validationIssue, 0)
for _, row := range rows {
if _, exists := kandangNameByID[row.KandangID]; exists {
continue
}
issues = append(issues, validationIssue{
Row: row.RowNumber,
Field: "kandang_id",
Message: fmt.Sprintf("value %d must reference an active kandang", row.KandangID),
})
}
return issues
}
func buildNameMismatchIssues(rows []kandangHouseTypeImportRow, kandangNameByID map[uint]string) []validationIssue {
issues := make([]validationIssue, 0)
for _, row := range rows {
dbName, exists := kandangNameByID[row.KandangID]
if !exists {
continue
}
if strings.EqualFold(strings.TrimSpace(row.KandangName), strings.TrimSpace(dbName)) {
continue
}
issues = append(issues, validationIssue{
Row: row.RowNumber,
Field: "kandang_name",
Message: fmt.Sprintf("value %q does not match kandang_id %d name %q", row.KandangName, row.KandangID, dbName),
})
}
return issues
}
func printPlanRows(rows []kandangHouseTypeImportRow, kandangNameByID map[uint]string) {
for _, row := range rows {
fmt.Printf(
"PLAN row=%d kandang_id=%d kandang_name_file=%q kandang_name_db=%q house_type=%q\n",
row.RowNumber,
row.KandangID,
row.KandangName,
kandangNameByID[row.KandangID],
row.HouseType,
)
}
}
func sortValidationIssues(issues []validationIssue) {
sort.Slice(issues, func(i, j int) bool {
if issues[i].Row == issues[j].Row {
if issues[i].Field == issues[j].Field {
return issues[i].Message < issues[j].Message
}
return issues[i].Field < issues[j].Field
}
return issues[i].Row < issues[j].Row
})
}
func applyImportRows(
ctx context.Context,
runner txRunner,
rows []kandangHouseTypeImportRow,
) ([]applyRowResult, int64, error) {
results := make([]applyRowResult, 0, len(rows))
normalizedNullCount := int64(0)
err := runner.InTx(ctx, func(store kandangHouseTypeStore) error {
for _, row := range rows {
changed, err := store.UpdateKandangHouseType(ctx, row.KandangID, row.HouseType)
if err != nil {
return fmt.Errorf("row %d kandang_id=%d update failed: %w", row.RowNumber, row.KandangID, err)
}
results = append(results, applyRowResult{
RowNumber: row.RowNumber,
KandangID: row.KandangID,
HouseType: row.HouseType,
Changed: changed,
})
}
normalized, err := store.NormalizeNullHouseType(ctx)
if err != nil {
return fmt.Errorf("normalize null house_type to open_house failed: %w", err)
}
normalizedNullCount = normalized
return nil
})
if err != nil {
return nil, 0, err
}
return results, normalizedNullCount, nil
}
func (r dbTxRunner) InTx(ctx context.Context, fn func(store kandangHouseTypeStore) error) error {
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
return fn(dbKandangHouseTypeStore{db: tx})
})
}
func (s dbKandangHouseTypeStore) UpdateKandangHouseType(ctx context.Context, kandangID uint, houseType string) (bool, error) {
result := s.db.WithContext(ctx).Exec(`
UPDATE kandangs
SET house_type = ?::house_type_enum,
updated_at = NOW()
WHERE id = ?
AND deleted_at IS NULL
AND house_type IS DISTINCT FROM ?::house_type_enum
`, houseType, kandangID, houseType)
if result.Error != nil {
return false, result.Error
}
return result.RowsAffected > 0, nil
}
func (s dbKandangHouseTypeStore) NormalizeNullHouseType(ctx context.Context) (int64, error) {
result := s.db.WithContext(ctx).Exec(`
UPDATE kandangs
SET house_type = 'open_house'::house_type_enum,
updated_at = NOW()
WHERE deleted_at IS NULL
AND house_type IS NULL
`)
if result.Error != nil {
return 0, result.Error
}
return result.RowsAffected, nil
}
func modeLabel(apply bool) string {
if apply {
return "APPLY"
}
return "DRY-RUN"
}
func applyStatus(changed bool) string {
if changed {
return "UPDATED"
}
return "UNCHANGED"
}
func countChangedRows(results []applyRowResult) int {
count := 0
for _, item := range results {
if item.Changed {
count++
}
}
return count
}
+280
View File
@@ -0,0 +1,280 @@
package main
import (
"context"
"errors"
"path/filepath"
"strings"
"testing"
"github.com/xuri/excelize/v2"
)
func TestParseKandangHouseTypeFile_ValidSingleRowAndDefaultHouseType(t *testing.T) {
filePath := createWorkbook(
t,
"kandang_house_type",
[]string{"kandang_id", "kandang_name", "house_type"},
[][]string{{"101", "Kandang A1", ""}},
)
sheet, rows, issues, err := parseKandangHouseTypeFile(filePath, "")
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if sheet != "kandang_house_type" {
t.Fatalf("expected sheet kandang_house_type, got %q", sheet)
}
if len(issues) != 0 {
t.Fatalf("expected no issues, got %+v", issues)
}
if len(rows) != 1 {
t.Fatalf("expected 1 row, got %d", len(rows))
}
if rows[0].KandangID != 101 {
t.Fatalf("expected kandang_id 101, got %d", rows[0].KandangID)
}
if rows[0].KandangName != "Kandang A1" {
t.Fatalf("expected kandang_name Kandang A1, got %q", rows[0].KandangName)
}
if rows[0].HouseType != "open_house" {
t.Fatalf("expected default house_type open_house, got %q", rows[0].HouseType)
}
}
func TestParseKandangHouseTypeFile_TypeHouseHeaderAlias(t *testing.T) {
filePath := createWorkbook(
t,
"kandang_house_type",
[]string{"kandang_id", "kandang_name", "type_house"},
[][]string{{"101", "Kandang A1", "close_house"}},
)
_, rows, issues, err := parseKandangHouseTypeFile(filePath, "kandang_house_type")
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if len(issues) != 0 {
t.Fatalf("expected no issues, got %+v", issues)
}
if len(rows) != 1 || rows[0].HouseType != "close_house" {
t.Fatalf("expected parsed close_house row, got %+v", rows)
}
}
func TestParseKandangHouseTypeFile_InvalidHouseType(t *testing.T) {
filePath := createWorkbook(
t,
"kandang_house_type",
[]string{"kandang_id", "kandang_name", "house_type"},
[][]string{{"101", "Kandang A1", "semi_house"}},
)
_, rows, issues, err := parseKandangHouseTypeFile(filePath, "")
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if len(rows) != 0 {
t.Fatalf("expected no valid rows, got %d", len(rows))
}
if !hasIssue(issues, 2, "house_type", "must be one of") {
t.Fatalf("expected invalid house_type issue, got %+v", issues)
}
}
func TestParseKandangHouseTypeFile_DuplicateKandangID(t *testing.T) {
filePath := createWorkbook(
t,
"kandang_house_type",
[]string{"kandang_id", "kandang_name", "house_type"},
[][]string{
{"101", "Kandang A1", "open_house"},
{"101", "Kandang A2", "close_house"},
},
)
_, rows, issues, err := parseKandangHouseTypeFile(filePath, "")
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if len(rows) != 1 {
t.Fatalf("expected first row valid and second invalid, got %d", len(rows))
}
if !hasIssue(issues, 3, "kandang_id", "duplicate value 101") {
t.Fatalf("expected duplicate kandang_id issue, got %+v", issues)
}
}
func TestBuildNameMismatchIssues(t *testing.T) {
rows := []kandangHouseTypeImportRow{{
RowNumber: 2,
KandangID: 10,
KandangName: "Kandang Salah",
HouseType: "open_house",
}}
issues := buildNameMismatchIssues(rows, map[uint]string{10: "Kandang Benar"})
if !hasIssue(issues, 2, "kandang_name", "does not match") {
t.Fatalf("expected name mismatch issue, got %+v", issues)
}
}
func TestApplyImportRows_Success(t *testing.T) {
store := &fakeStore{
changedByID: map[uint]bool{101: true, 102: false},
normalizeResult: 3,
}
runner := &fakeTransactionRunner{store: store}
rows := []kandangHouseTypeImportRow{
{RowNumber: 2, KandangID: 101, HouseType: "open_house"},
{RowNumber: 3, KandangID: 102, HouseType: "close_house"},
}
results, normalized, err := applyImportRows(context.Background(), runner, rows)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if runner.txCalls != 1 {
t.Fatalf("expected 1 tx call, got %d", runner.txCalls)
}
if len(results) != 2 {
t.Fatalf("expected 2 row results, got %d", len(results))
}
if normalized != 3 {
t.Fatalf("expected normalized count 3, got %d", normalized)
}
if !results[0].Changed || results[1].Changed {
t.Fatalf("unexpected changed flags: %+v", results)
}
if len(store.updateCalls) != 2 {
t.Fatalf("expected 2 update calls, got %d", len(store.updateCalls))
}
}
func TestApplyImportRows_FailOnUpdate(t *testing.T) {
store := &fakeStore{
updateErrByID: map[uint]error{101: errors.New("boom")},
}
runner := &fakeTransactionRunner{store: store}
rows := []kandangHouseTypeImportRow{{RowNumber: 2, KandangID: 101, HouseType: "open_house"}}
_, _, err := applyImportRows(context.Background(), runner, rows)
if err == nil {
t.Fatalf("expected error, got nil")
}
if !strings.Contains(err.Error(), "update failed") {
t.Fatalf("expected update failed error, got %v", err)
}
}
func TestCountChangedRows(t *testing.T) {
count := countChangedRows([]applyRowResult{{Changed: true}, {Changed: false}, {Changed: true}})
if count != 2 {
t.Fatalf("expected 2 changed rows, got %d", count)
}
}
type fakeTransactionRunner struct {
store *fakeStore
txCalls int
}
func (f *fakeTransactionRunner) InTx(_ context.Context, fn func(store kandangHouseTypeStore) error) error {
f.txCalls++
return fn(f.store)
}
type updateCall struct {
kandangID uint
houseType string
}
type fakeStore struct {
updateCalls []updateCall
changedByID map[uint]bool
updateErrByID map[uint]error
normalizeResult int64
normalizeErr error
}
func (f *fakeStore) UpdateKandangHouseType(_ context.Context, kandangID uint, houseType string) (bool, error) {
f.updateCalls = append(f.updateCalls, updateCall{kandangID: kandangID, houseType: houseType})
if err, exists := f.updateErrByID[kandangID]; exists {
return false, err
}
if changed, exists := f.changedByID[kandangID]; exists {
return changed, nil
}
return true, nil
}
func (f *fakeStore) NormalizeNullHouseType(_ context.Context) (int64, error) {
if f.normalizeErr != nil {
return 0, f.normalizeErr
}
return f.normalizeResult, nil
}
func createWorkbook(t *testing.T, sheetName string, headers []string, rows [][]string) string {
t.Helper()
f := excelize.NewFile()
if sheetName == "" {
sheetName = "Sheet1"
}
defaultSheet := f.GetSheetName(0)
if defaultSheet != sheetName {
idx, err := f.NewSheet(sheetName)
if err != nil {
t.Fatalf("failed creating sheet: %v", err)
}
f.SetActiveSheet(idx)
_ = f.DeleteSheet(defaultSheet)
}
for idx, header := range headers {
cell, err := excelize.CoordinatesToCellName(idx+1, 1)
if err != nil {
t.Fatalf("failed computing header cell: %v", err)
}
if err := f.SetCellValue(sheetName, cell, header); err != nil {
t.Fatalf("failed setting header cell: %v", err)
}
}
for rowIdx, row := range rows {
for colIdx, value := range row {
cell, err := excelize.CoordinatesToCellName(colIdx+1, rowIdx+2)
if err != nil {
t.Fatalf("failed computing row cell: %v", err)
}
if err := f.SetCellValue(sheetName, cell, value); err != nil {
t.Fatalf("failed setting row cell: %v", err)
}
}
}
path := filepath.Join(t.TempDir(), "kandang_house_type.xlsx")
if err := f.SaveAs(path); err != nil {
t.Fatalf("failed saving workbook: %v", err)
}
return path
}
func hasIssue(issues []validationIssue, row int, field string, contains string) bool {
for _, issue := range issues {
if issue.Row != row {
continue
}
if issue.Field != field {
continue
}
if strings.Contains(issue.Message, contains) {
return true
}
}
return false
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,212 @@
package main
import (
"context"
"testing"
"time"
commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service"
fifoStockV2 "gitlab.com/mbugroup/lti-api.git/internal/common/service/fifo_stock_v2"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
transferSvc "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/transfers/services"
)
func TestValidateAdjustmentGatherAgainstAllowedIDsEligible(t *testing.T) {
result := validateAdjustmentGatherAgainstAllowedIDs(100, []uint{11, 12}, []commonSvc.FifoStockV2GatherRow{
{SourceTable: "adjustment_stocks", SourceID: 11, AvailableQuantity: 70},
{SourceTable: "adjustment_stocks", SourceID: 12, AvailableQuantity: 40},
})
if result.Status != "eligible" {
t.Fatalf("expected eligible, got %+v", result)
}
if result.VerifiedQty != 100 {
t.Fatalf("expected verified qty 100, got %v", result.VerifiedQty)
}
}
func TestValidateAdjustmentGatherAgainstAllowedIDsRejectsMixedSource(t *testing.T) {
result := validateAdjustmentGatherAgainstAllowedIDs(100, []uint{11}, []commonSvc.FifoStockV2GatherRow{
{SourceTable: "adjustment_stocks", SourceID: 11, AvailableQuantity: 60},
{SourceTable: "recording_eggs", SourceID: 21, AvailableQuantity: 50},
})
if result.Status != "skipped" {
t.Fatalf("expected skipped, got %+v", result)
}
if result.Reason != "mixed_fifo_source_recording_eggs" {
t.Fatalf("unexpected reason: %+v", result)
}
}
func TestBuildAdjustmentMigrationPlanUsesValidator(t *testing.T) {
opts := &adjustmentCommandOptions{RunID: "egg-adjustment-cutover-test"}
farmID := uint(25)
farmName := "Gudang Farm Jamali"
rows := []adjustmentLegacyEggRow{
{
LocationID: 16,
LocationName: "Jamali",
SourceWarehouseID: 46,
SourceWarehouseName: "Gudang Jamali 1",
FarmWarehouseID: &farmID,
FarmWarehouseName: &farmName,
ProductWarehouseID: 101,
ProductID: 8,
ProductName: "Telur Utuh",
RemainingQty: 120,
CurrentPWQty: 150,
AdjustmentIDs: []uint{1},
},
{
LocationID: 16,
LocationName: "Jamali",
SourceWarehouseID: 46,
SourceWarehouseName: "Gudang Jamali 1",
FarmWarehouseID: &farmID,
FarmWarehouseName: &farmName,
ProductWarehouseID: 102,
ProductID: 9,
ProductName: "Telur Putih",
RemainingQty: 20,
CurrentPWQty: 40,
AdjustmentIDs: []uint{2},
},
{
LocationID: 16,
LocationName: "Jamali",
SourceWarehouseID: 46,
SourceWarehouseName: "Gudang Jamali 1",
ProductWarehouseID: 103,
ProductID: 10,
ProductName: "Telur Pecah",
RemainingQty: 10,
CurrentPWQty: 10,
AdjustmentIDs: []uint{3},
},
}
validator := &fakeAdjustmentCandidateValidator{
byProduct: map[string]adjustmentCandidateValidation{
"Telur Utuh": {Status: "eligible", VerifiedQty: 120},
"Telur Putih": {Status: "skipped", Reason: "mixed_fifo_source_recording_eggs", VerifiedQty: 10},
},
}
reportRows, groups := buildAdjustmentMigrationPlan(context.Background(), opts, map[uint]adjustmentLocationTiming{
16: {LocationID: 16, LocationName: "Jamali", Status: "CLEAN_CUTOVER"},
}, rows, validator)
if len(reportRows) != 3 {
t.Fatalf("expected 3 report rows, got %d", len(reportRows))
}
if len(groups) != 1 || len(groups[0].Rows) != 1 {
t.Fatalf("expected only one eligible grouped row, got %+v", groups)
}
if reportRows[0].Status != "eligible" || reportRows[0].VerifiedQty != 120 {
t.Fatalf("unexpected first row: %+v", reportRows[0])
}
if reportRows[1].Reason != "mixed_fifo_source_recording_eggs" {
t.Fatalf("unexpected second row reason: %+v", reportRows[1])
}
if reportRows[2].Reason != "missing_farm_warehouse" {
t.Fatalf("expected missing farm warehouse skip, got %+v", reportRows[2])
}
}
func TestExecuteAdjustmentApplyRevalidatesRowsAndAppliesSubset(t *testing.T) {
opts := &adjustmentCommandOptions{
RunID: "egg-adjustment-cutover-apply",
CutoverDate: time.Date(2026, 4, 7, 0, 0, 0, 0, time.UTC),
ActorID: 99,
}
group := adjustmentTransferGroup{
LocationID: 16,
LocationName: "Jamali",
SourceWarehouseID: 46,
SourceWarehouseName: "Gudang Jamali 1",
FarmWarehouseID: 25,
FarmWarehouseName: "Gudang Farm Jamali",
Rows: []*adjustmentMigrationReportRow{
{LocationID: 16, LocationName: "Jamali", SourceWarehouseID: 46, SourceWarehouseName: "Gudang Jamali 1", FarmWarehouseID: uintPtr(25), FarmWarehouseName: strPtr("Gudang Farm Jamali"), ProductWarehouseID: 101, ProductID: 8, ProductName: "Telur Utuh", RemainingQty: 120, CurrentPWQty: 150, AdjustmentIDs: []uint{1}, Status: "eligible"},
{LocationID: 16, LocationName: "Jamali", SourceWarehouseID: 46, SourceWarehouseName: "Gudang Jamali 1", FarmWarehouseID: uintPtr(25), FarmWarehouseName: strPtr("Gudang Farm Jamali"), ProductWarehouseID: 102, ProductID: 9, ProductName: "Telur Putih", RemainingQty: 20, CurrentPWQty: 40, AdjustmentIDs: []uint{2}, Status: "eligible"},
},
}
validator := &fakeAdjustmentCandidateValidator{
byProduct: map[string]adjustmentCandidateValidation{
"Telur Utuh": {Status: "eligible", VerifiedQty: 120},
"Telur Putih": {Status: "skipped", Reason: "mixed_fifo_source_recording_eggs", VerifiedQty: 10},
},
}
executor := &fakeAdjustmentSystemTransferExecutor{
createResponses: []*entity.StockTransfer{
{Id: 1001, MovementNumber: "PND-LTI-1001"},
},
}
summary, err := executeAdjustmentApply(context.Background(), executor, validator, opts, []adjustmentTransferGroup{group})
if err != nil {
t.Fatalf("expected no fatal apply error, got %v", err)
}
if summary.GroupsApplied != 1 {
t.Fatalf("expected 1 applied group, got %+v", summary)
}
if summary.RowsApplied != 1 || summary.RowsFailed != 1 {
t.Fatalf("unexpected summary: %+v", summary)
}
if len(executor.createRequests) != 1 {
t.Fatalf("expected 1 create request, got %d", len(executor.createRequests))
}
if len(executor.createRequests[0].Products) != 1 || executor.createRequests[0].Products[0].ProductID != 8 {
t.Fatalf("expected only Telur Utuh to be transferred, got %+v", executor.createRequests[0].Products)
}
}
type fakeAdjustmentCandidateValidator struct {
byProduct map[string]adjustmentCandidateValidation
errByProduct map[string]error
}
func (f *fakeAdjustmentCandidateValidator) ValidateCandidate(ctx context.Context, row adjustmentLegacyEggRow) (adjustmentCandidateValidation, error) {
if err, ok := f.errByProduct[row.ProductName]; ok {
return adjustmentCandidateValidation{}, err
}
if result, ok := f.byProduct[row.ProductName]; ok {
return result, nil
}
return adjustmentCandidateValidation{Status: "eligible", VerifiedQty: row.RemainingQty}, nil
}
type fakeAdjustmentSystemTransferExecutor struct {
createRequests []*transferSvc.SystemTransferRequest
createResponses []*entity.StockTransfer
createErrors []error
deletedTransferIDs []uint
deleteErrors map[uint]error
}
func (f *fakeAdjustmentSystemTransferExecutor) CreateSystemTransfer(ctx context.Context, req *transferSvc.SystemTransferRequest) (*entity.StockTransfer, error) {
f.createRequests = append(f.createRequests, req)
idx := len(f.createRequests) - 1
if idx < len(f.createErrors) && f.createErrors[idx] != nil {
return nil, f.createErrors[idx]
}
if idx < len(f.createResponses) && f.createResponses[idx] != nil {
return f.createResponses[idx], nil
}
return &entity.StockTransfer{Id: uint64(1000 + idx), MovementNumber: "PND-LTI-DEFAULT"}, nil
}
func (f *fakeAdjustmentSystemTransferExecutor) DeleteSystemTransfer(ctx context.Context, id uint, actorID uint) error {
f.deletedTransferIDs = append(f.deletedTransferIDs, id)
if f.deleteErrors == nil {
return nil
}
return f.deleteErrors[id]
}
func uintPtr(v uint) *uint { return &v }
func strPtr(v string) *string { return &v }
var _ adjustmentCandidateValidator = (*fakeAdjustmentCandidateValidator)(nil)
var _ adjustmentSystemTransferExecutor = (*fakeAdjustmentSystemTransferExecutor)(nil)
var _ commonSvc.FifoStockV2Lane = fifoStockV2.LaneStockable
@@ -0,0 +1,825 @@
package main
import (
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"log"
"os"
"sort"
"strings"
"text/tabwriter"
"time"
"github.com/go-playground/validator/v10"
"github.com/sirupsen/logrus"
"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"
pwRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories"
transferRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/transfers/repositories"
transferSvc "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/transfers/services"
warehouseRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/repositories"
pfkRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories"
stockLogRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/repositories"
"gorm.io/gorm"
)
const (
cutoverReasonPrefix = "EGG_FARM_CUTOVER"
outputModeTable = "table"
outputModeJSON = "json"
)
type commandOptions struct {
Apply bool
DryRun bool
RollbackRunID string
LocationID uint
LocationName string
CutoverDate time.Time
CutoverDateRaw string
IncludeOverlap bool
Output string
ActorID uint
RunID string
}
type locationTiming struct {
LocationID uint
LocationName string
FirstKandangDate *time.Time
LastKandangDate *time.Time
FirstFarmDate *time.Time
LastFarmDate *time.Time
Status string
}
type legacyEggStockRow struct {
LocationID uint
LocationName string
SourceWarehouseID uint
SourceWarehouseName string
FarmWarehouseID *uint
FarmWarehouseName *string
ProductWarehouseID uint
ProductID uint
ProductName string
OnHandQty float64
}
type migrationReportRow struct {
RunID string `json:"run_id"`
LocationID uint `json:"location_id"`
LocationName string `json:"location_name"`
SourceWarehouseID uint `json:"source_warehouse_id"`
SourceWarehouseName string `json:"source_warehouse_name"`
FarmWarehouseID *uint `json:"farm_warehouse_id,omitempty"`
FarmWarehouseName *string `json:"farm_warehouse_name,omitempty"`
ProductWarehouseID uint `json:"product_warehouse_id"`
ProductID uint `json:"product_id"`
ProductName string `json:"product_name"`
Qty float64 `json:"qty"`
LocationStatus string `json:"location_status"`
Status string `json:"status"`
Reason string `json:"reason,omitempty"`
TransferID *uint64 `json:"transfer_id,omitempty"`
MovementNumber *string `json:"movement_number,omitempty"`
}
type applySummary struct {
RowsPlanned int `json:"rows_planned"`
RowsApplied int `json:"rows_applied"`
RowsSkipped int `json:"rows_skipped"`
RowsFailed int `json:"rows_failed"`
GroupsPlanned int `json:"groups_planned"`
GroupsApplied int `json:"groups_applied"`
}
type rollbackDetailRow struct {
RunID string `json:"run_id"`
TransferID uint64 `json:"transfer_id"`
MovementNumber string `json:"movement_number"`
LocationName string `json:"location_name"`
SourceWarehouseName string `json:"source_warehouse_name"`
FarmWarehouseName string `json:"farm_warehouse_name"`
ProductName string `json:"product_name"`
Qty float64 `json:"qty"`
Status string `json:"status"`
Reason string `json:"reason,omitempty"`
}
type systemTransferExecutor interface {
CreateSystemTransfer(ctx context.Context, req *transferSvc.SystemTransferRequest) (*entity.StockTransfer, error)
DeleteSystemTransfer(ctx context.Context, id uint, actorID uint) error
}
type transferGroup struct {
LocationID uint
LocationName string
SourceWarehouseID uint
SourceWarehouseName string
FarmWarehouseID uint
FarmWarehouseName string
Rows []*migrationReportRow
}
func main() {
opts, err := parseFlags()
if err != nil {
log.Fatalf("invalid flags: %v", err)
}
db := database.Connect(config.DBHost, config.DBName)
ctx := context.Background()
if strings.TrimSpace(opts.RollbackRunID) != "" {
rows, err := loadRollbackDetails(ctx, db, opts.RollbackRunID)
if err != nil {
log.Fatalf("failed to load rollback details: %v", err)
}
if !opts.Apply {
for i := range rows {
rows[i].Status = "eligible"
}
renderRollbackReport(opts.Output, rows)
return
}
if err := executeRollback(ctx, newSystemTransferService(db), rows, opts.ActorID); err != nil {
log.Fatalf("rollback failed: %v", err)
}
renderRollbackReport(opts.Output, rows)
return
}
timings, err := loadLocationTimings(ctx, db, opts)
if err != nil {
log.Fatalf("failed to load location timings: %v", err)
}
legacyRows, err := loadLegacyEggStocks(ctx, db, opts)
if err != nil {
log.Fatalf("failed to load legacy egg stocks: %v", err)
}
reportRows, groups := buildMigrationPlan(opts, timings, legacyRows)
if !opts.Apply {
renderMigrationReport(opts.Output, reportRows, summarizeApply(reportRows, groups, 0))
return
}
summary, err := executeApply(ctx, newSystemTransferService(db), opts, groups)
if err != nil {
log.Fatalf("apply failed: %v", err)
}
finalRows := flattenGroups(groups, reportRows)
summary = summarizeApply(finalRows, groups, summary.GroupsApplied)
renderMigrationReport(opts.Output, finalRows, summary)
}
func parseFlags() (*commandOptions, error) {
var opts commandOptions
flag.BoolVar(&opts.Apply, "apply", false, "Apply migration. If false, run as dry-run")
flag.BoolVar(&opts.DryRun, "dry-run", true, "Run as dry-run")
flag.StringVar(&opts.RollbackRunID, "rollback-run-id", "", "Rollback all transfers created by the provided run id")
flag.UintVar(&opts.LocationID, "location-id", 0, "Filter by location id")
flag.StringVar(&opts.LocationName, "location-name", "", "Filter by exact location name")
flag.StringVar(&opts.CutoverDateRaw, "cutover-date", "", "Cutover date in YYYY-MM-DD format")
flag.BoolVar(&opts.IncludeOverlap, "include-overlap", false, "Include overlap locations in plan/apply")
flag.StringVar(&opts.Output, "output", outputModeTable, "Output format: table or json")
flag.UintVar(&opts.ActorID, "actor-id", 1, "Actor id used for created/deleted transfers")
flag.Parse()
opts.LocationName = strings.TrimSpace(opts.LocationName)
opts.RollbackRunID = strings.TrimSpace(opts.RollbackRunID)
opts.Output = strings.ToLower(strings.TrimSpace(opts.Output))
if opts.Output == "" {
opts.Output = outputModeTable
}
if opts.Output != outputModeTable && opts.Output != outputModeJSON {
return nil, fmt.Errorf("unsupported --output=%s", opts.Output)
}
if opts.Apply {
opts.DryRun = false
}
if opts.LocationID > 0 && opts.LocationName != "" {
return nil, errors.New("use either --location-id or --location-name, not both")
}
if opts.RollbackRunID != "" {
if opts.LocationID > 0 || opts.LocationName != "" {
return nil, errors.New("location filters are not supported with --rollback-run-id")
}
if opts.CutoverDateRaw != "" {
return nil, errors.New("--cutover-date is not used with --rollback-run-id")
}
} else if opts.Apply {
if opts.LocationID == 0 && opts.LocationName == "" {
return nil, errors.New("apply mode requires --location-id or --location-name for safety")
}
if strings.TrimSpace(opts.CutoverDateRaw) == "" {
return nil, errors.New("--cutover-date is required in apply mode")
}
}
if strings.TrimSpace(opts.CutoverDateRaw) == "" {
opts.CutoverDate = normalizeDateOnly(time.Now().In(time.FixedZone("Asia/Jakarta", 7*3600)))
} else {
t, err := time.Parse("2006-01-02", opts.CutoverDateRaw)
if err != nil {
return nil, fmt.Errorf("invalid --cutover-date: %w", err)
}
opts.CutoverDate = normalizeDateOnly(t)
}
opts.RunID = buildRunID()
return &opts, nil
}
func newSystemTransferService(db *gorm.DB) systemTransferExecutor {
validate := validator.New()
stockTransferRepo := transferRepo.NewStockTransferRepository(db)
stockTransferDetailRepo := transferRepo.NewStockTransferDetailRepository(db)
stockTransferDeliveryRepo := transferRepo.NewStockTransferDeliveryRepository(db)
stockTransferDeliveryItemRepo := transferRepo.NewStockTransferDeliveryItemRepository(db)
stockLogsRepo := stockLogRepo.NewStockLogRepository(db)
productWarehouseRepo := pwRepo.NewProductWarehouseRepository(db)
warehouseRepository := warehouseRepo.NewWarehouseRepository(db)
projectFlockKandangRepo := pfkRepo.NewProjectFlockKandangRepository(db)
projectFlockPopulationRepo := pfkRepo.NewProjectFlockPopulationRepository(db)
fifoSvc := service.NewFifoStockV2Service(db, logrus.StandardLogger())
return transferSvc.NewTransferService(
validate,
stockTransferRepo,
stockTransferDetailRepo,
stockTransferDeliveryRepo,
stockTransferDeliveryItemRepo,
stockLogsRepo,
productWarehouseRepo,
nil,
warehouseRepository,
projectFlockKandangRepo,
projectFlockPopulationRepo,
nil,
fifoSvc,
nil,
)
}
func loadLocationTimings(ctx context.Context, db *gorm.DB, opts *commandOptions) (map[uint]locationTiming, error) {
type row struct {
LocationID uint `gorm:"column:location_id"`
LocationName string `gorm:"column:location_name"`
FirstKandangDate *time.Time `gorm:"column:first_kandang_date"`
LastKandangDate *time.Time `gorm:"column:last_kandang_date"`
FirstFarmDate *time.Time `gorm:"column:first_farm_date"`
LastFarmDate *time.Time `gorm:"column:last_farm_date"`
}
query := db.WithContext(ctx).
Table("recording_eggs re").
Select(`
pf.location_id AS location_id,
l.name AS location_name,
MIN(CASE WHEN w.type = 'KANDANG' THEN DATE(r.record_datetime) END) AS first_kandang_date,
MAX(CASE WHEN w.type = 'KANDANG' THEN DATE(r.record_datetime) END) AS last_kandang_date,
MIN(CASE WHEN w.type = 'LOKASI' THEN DATE(r.record_datetime) END) AS first_farm_date,
MAX(CASE WHEN w.type = 'LOKASI' THEN DATE(r.record_datetime) END) AS last_farm_date
`).
Joins("JOIN recordings r ON r.id = re.recording_id").
Joins("JOIN project_flock_kandangs pk ON pk.id = COALESCE(re.project_flock_kandang_id, r.project_flock_kandangs_id)").
Joins("JOIN project_flocks pf ON pf.id = pk.project_flock_id").
Joins("JOIN locations l ON l.id = pf.location_id").
Joins("JOIN product_warehouses pw ON pw.id = re.product_warehouse_id").
Joins("JOIN warehouses w ON w.id = pw.warehouse_id").
Group("pf.location_id, l.name")
query = applyTimingLocationFilter(query, opts)
var rows []row
if err := query.Scan(&rows).Error; err != nil {
return nil, err
}
result := make(map[uint]locationTiming, len(rows))
for _, row := range rows {
status := "KANDANG_ONLY"
if row.FirstFarmDate != nil {
status = "OVERLAP"
if row.LastKandangDate == nil || row.FirstFarmDate.After(normalizeDateOnly(*row.LastKandangDate)) {
status = "CLEAN_CUTOVER"
}
}
result[row.LocationID] = locationTiming{
LocationID: row.LocationID,
LocationName: row.LocationName,
FirstKandangDate: normalizeDatePtr(row.FirstKandangDate),
LastKandangDate: normalizeDatePtr(row.LastKandangDate),
FirstFarmDate: normalizeDatePtr(row.FirstFarmDate),
LastFarmDate: normalizeDatePtr(row.LastFarmDate),
Status: status,
}
}
return result, nil
}
func loadLegacyEggStocks(ctx context.Context, db *gorm.DB, opts *commandOptions) ([]legacyEggStockRow, error) {
type row struct {
LocationID uint `gorm:"column:location_id"`
LocationName string `gorm:"column:location_name"`
SourceWarehouseID uint `gorm:"column:source_warehouse_id"`
SourceWarehouseName string `gorm:"column:source_warehouse_name"`
FarmWarehouseID *uint `gorm:"column:farm_warehouse_id"`
FarmWarehouseName *string `gorm:"column:farm_warehouse_name"`
ProductWarehouseID uint `gorm:"column:product_warehouse_id"`
ProductID uint `gorm:"column:product_id"`
ProductName string `gorm:"column:product_name"`
OnHandQty float64 `gorm:"column:on_hand_qty"`
}
firstFarmSub := db.WithContext(ctx).
Table("warehouses fw").
Select("fw.location_id AS location_id, MIN(fw.id) AS farm_warehouse_id").
Where("fw.deleted_at IS NULL").
Where("fw.type = ?", "LOKASI").
Group("fw.location_id")
query := db.WithContext(ctx).
Table("product_warehouses pw").
Select(`
kw.location_id AS location_id,
l.name AS location_name,
kw.id AS source_warehouse_id,
kw.name AS source_warehouse_name,
fw.id AS farm_warehouse_id,
fw.name AS farm_warehouse_name,
pw.id AS product_warehouse_id,
pw.product_id AS product_id,
p.name AS product_name,
COALESCE(pw.qty, 0) AS on_hand_qty
`).
Joins("JOIN warehouses kw ON kw.id = pw.warehouse_id AND kw.deleted_at IS NULL").
Joins("JOIN locations l ON l.id = kw.location_id").
Joins("JOIN products p ON p.id = pw.product_id").
Joins("LEFT JOIN product_categories pc ON pc.id = p.product_category_id").
Joins("LEFT JOIN (?) ff ON ff.location_id = kw.location_id", firstFarmSub).
Joins("LEFT JOIN warehouses fw ON fw.id = ff.farm_warehouse_id").
Where("kw.type = ?", "KANDANG").
Where(`
EXISTS (
SELECT 1
FROM recording_eggs re
WHERE re.product_warehouse_id = pw.id
)
`).
Where(`
EXISTS (
SELECT 1
FROM flags f
WHERE f.flagable_type = ?
AND f.flagable_id = p.id
AND (UPPER(f.name) = 'TELUR' OR UPPER(f.name) LIKE 'TELUR-%')
)
OR (
NOT EXISTS (
SELECT 1
FROM flags f_any
WHERE f_any.flagable_type = ?
AND f_any.flagable_id = p.id
)
AND UPPER(COALESCE(pc.code, '')) = 'EGG'
)
`, entity.FlagableTypeProduct, entity.FlagableTypeProduct).
Order("l.name ASC, kw.name ASC, p.name ASC")
query = applyLegacyStockLocationFilter(query, opts)
var rows []row
if err := query.Scan(&rows).Error; err != nil {
return nil, err
}
result := make([]legacyEggStockRow, 0, len(rows))
for _, row := range rows {
result = append(result, legacyEggStockRow{
LocationID: row.LocationID,
LocationName: row.LocationName,
SourceWarehouseID: row.SourceWarehouseID,
SourceWarehouseName: row.SourceWarehouseName,
FarmWarehouseID: row.FarmWarehouseID,
FarmWarehouseName: row.FarmWarehouseName,
ProductWarehouseID: row.ProductWarehouseID,
ProductID: row.ProductID,
ProductName: row.ProductName,
OnHandQty: row.OnHandQty,
})
}
return result, nil
}
func buildMigrationPlan(
opts *commandOptions,
timings map[uint]locationTiming,
rows []legacyEggStockRow,
) ([]migrationReportRow, []transferGroup) {
reportRows := make([]migrationReportRow, 0, len(rows))
groupMap := make(map[string]*transferGroup)
for _, row := range rows {
locationStatus := "UNKNOWN"
if timing, ok := timings[row.LocationID]; ok {
locationStatus = timing.Status
}
report := migrationReportRow{
RunID: opts.RunID,
LocationID: row.LocationID,
LocationName: row.LocationName,
SourceWarehouseID: row.SourceWarehouseID,
SourceWarehouseName: row.SourceWarehouseName,
FarmWarehouseID: row.FarmWarehouseID,
FarmWarehouseName: row.FarmWarehouseName,
ProductWarehouseID: row.ProductWarehouseID,
ProductID: row.ProductID,
ProductName: row.ProductName,
Qty: row.OnHandQty,
LocationStatus: locationStatus,
Status: "eligible",
}
switch {
case row.FarmWarehouseID == nil || row.FarmWarehouseName == nil:
report.Status = "skipped"
report.Reason = "missing_farm_warehouse"
case row.OnHandQty <= 0:
report.Status = "skipped"
report.Reason = "non_positive_qty"
case locationStatus == "OVERLAP" && !opts.IncludeOverlap:
report.Status = "skipped"
report.Reason = "overlap_location"
}
reportRows = append(reportRows, report)
if report.Status != "eligible" {
continue
}
groupKey := fmt.Sprintf("%d:%d", row.SourceWarehouseID, *row.FarmWarehouseID)
group := groupMap[groupKey]
if group == nil {
group = &transferGroup{
LocationID: row.LocationID,
LocationName: row.LocationName,
SourceWarehouseID: row.SourceWarehouseID,
SourceWarehouseName: row.SourceWarehouseName,
FarmWarehouseID: *row.FarmWarehouseID,
FarmWarehouseName: derefString(row.FarmWarehouseName),
}
groupMap[groupKey] = group
}
group.Rows = append(group.Rows, &reportRows[len(reportRows)-1])
}
groups := make([]transferGroup, 0, len(groupMap))
for _, group := range groupMap {
sort.Slice(group.Rows, func(i, j int) bool {
return group.Rows[i].ProductName < group.Rows[j].ProductName
})
groups = append(groups, *group)
}
sort.Slice(groups, func(i, j int) bool {
if groups[i].LocationName == groups[j].LocationName {
return groups[i].SourceWarehouseName < groups[j].SourceWarehouseName
}
return groups[i].LocationName < groups[j].LocationName
})
return reportRows, groups
}
func executeApply(
ctx context.Context,
svc systemTransferExecutor,
opts *commandOptions,
groups []transferGroup,
) (applySummary, error) {
summary := applySummary{GroupsPlanned: len(groups)}
for _, group := range groups {
products := make([]transferSvc.SystemTransferProduct, 0, len(group.Rows))
for _, row := range group.Rows {
products = append(products, transferSvc.SystemTransferProduct{
ProductID: row.ProductID,
ProductQty: row.Qty,
})
}
reason := buildCutoverReason(opts.RunID, group.LocationName, opts.CutoverDate)
transfer, err := svc.CreateSystemTransfer(ctx, &transferSvc.SystemTransferRequest{
TransferReason: reason,
TransferDate: opts.CutoverDate,
SourceWarehouseID: group.SourceWarehouseID,
DestinationWarehouseID: group.FarmWarehouseID,
Products: products,
ActorID: opts.ActorID,
StockLogNotes: reason,
})
if err != nil {
for _, row := range group.Rows {
row.Status = "failed"
row.Reason = err.Error()
summary.RowsFailed++
}
continue
}
summary.GroupsApplied++
for _, row := range group.Rows {
row.Status = "applied"
row.TransferID = &transfer.Id
row.MovementNumber = &transfer.MovementNumber
summary.RowsApplied++
}
}
for _, group := range groups {
summary.RowsPlanned += len(group.Rows)
}
return summary, nil
}
func executeRollback(
ctx context.Context,
svc systemTransferExecutor,
rows []rollbackDetailRow,
actorID uint,
) error {
if actorID == 0 {
return fmt.Errorf("actor id is required for rollback")
}
byTransfer := make(map[uint64][]int)
for idx, row := range rows {
byTransfer[row.TransferID] = append(byTransfer[row.TransferID], idx)
}
transferIDs := make([]uint64, 0, len(byTransfer))
for transferID := range byTransfer {
transferIDs = append(transferIDs, transferID)
}
sort.Slice(transferIDs, func(i, j int) bool { return transferIDs[i] > transferIDs[j] })
var firstErr error
for _, transferID := range transferIDs {
err := svc.DeleteSystemTransfer(ctx, uint(transferID), actorID)
for _, idx := range byTransfer[transferID] {
if err != nil {
rows[idx].Status = "failed"
rows[idx].Reason = err.Error()
} else {
rows[idx].Status = "rolled_back"
}
}
if err != nil && firstErr == nil {
firstErr = err
}
}
return firstErr
}
func loadRollbackDetails(ctx context.Context, db *gorm.DB, runID string) ([]rollbackDetailRow, error) {
type row struct {
TransferID uint64 `gorm:"column:transfer_id"`
MovementNumber string `gorm:"column:movement_number"`
LocationName string `gorm:"column:location_name"`
SourceWarehouseName string `gorm:"column:source_warehouse_name"`
FarmWarehouseName string `gorm:"column:farm_warehouse_name"`
ProductName string `gorm:"column:product_name"`
Qty float64 `gorm:"column:qty"`
}
needle := buildRunReasonMatcher(runID)
var dbRows []row
err := db.WithContext(ctx).
Table("stock_transfers st").
Select(`
st.id AS transfer_id,
st.movement_number AS movement_number,
COALESCE(loc.name, '') AS location_name,
ws.name AS source_warehouse_name,
wd.name AS farm_warehouse_name,
p.name AS product_name,
COALESCE(std.total_qty, std.usage_qty, 0) AS qty
`).
Joins("JOIN warehouses ws ON ws.id = st.from_warehouse_id").
Joins("JOIN warehouses wd ON wd.id = st.to_warehouse_id").
Joins("LEFT JOIN locations loc ON loc.id = COALESCE(ws.location_id, wd.location_id)").
Joins("JOIN stock_transfer_details std ON std.stock_transfer_id = st.id AND std.deleted_at IS NULL").
Joins("JOIN products p ON p.id = std.product_id").
Where("st.deleted_at IS NULL").
Where("st.reason LIKE ?", needle).
Order("st.id DESC, std.id ASC").
Scan(&dbRows).Error
if err != nil {
return nil, err
}
rows := make([]rollbackDetailRow, 0, len(dbRows))
for _, row := range dbRows {
rows = append(rows, rollbackDetailRow{
RunID: runID,
TransferID: row.TransferID,
MovementNumber: row.MovementNumber,
LocationName: row.LocationName,
SourceWarehouseName: row.SourceWarehouseName,
FarmWarehouseName: row.FarmWarehouseName,
ProductName: row.ProductName,
Qty: row.Qty,
})
}
return rows, nil
}
func applyTimingLocationFilter(db *gorm.DB, opts *commandOptions) *gorm.DB {
if opts == nil {
return db
}
switch {
case opts.LocationID > 0:
return db.Where("pf.location_id = ?", opts.LocationID)
case opts.LocationName != "":
return db.Where("LOWER(l.name) = LOWER(?)", opts.LocationName)
default:
return db
}
}
func applyLegacyStockLocationFilter(db *gorm.DB, opts *commandOptions) *gorm.DB {
if opts == nil {
return db
}
switch {
case opts.LocationID > 0:
return db.Where("kw.location_id = ?", opts.LocationID)
case opts.LocationName != "":
return db.Where("LOWER(l.name) = LOWER(?)", opts.LocationName)
default:
return db
}
}
func buildCutoverReason(runID, locationName string, cutoverDate time.Time) string {
locationName = strings.ReplaceAll(strings.TrimSpace(locationName), "|", "/")
return fmt.Sprintf("%s|run_id=%s|location=%s|cutover_date=%s", cutoverReasonPrefix, runID, locationName, cutoverDate.Format("2006-01-02"))
}
func buildRunReasonMatcher(runID string) string {
return fmt.Sprintf("%s|run_id=%s|%%", cutoverReasonPrefix, strings.TrimSpace(runID))
}
func buildRunID() string {
return fmt.Sprintf("egg-cutover-%s", time.Now().UTC().Format("20060102T150405.000000000Z"))
}
func normalizeDateOnly(value time.Time) time.Time {
return time.Date(value.Year(), value.Month(), value.Day(), 0, 0, 0, 0, time.UTC)
}
func normalizeDatePtr(value *time.Time) *time.Time {
if value == nil {
return nil
}
normalized := normalizeDateOnly(*value)
return &normalized
}
func derefString(value *string) string {
if value == nil {
return ""
}
return *value
}
func summarizeApply(rows []migrationReportRow, groups []transferGroup, appliedGroups int) applySummary {
summary := applySummary{
GroupsPlanned: len(groups),
GroupsApplied: appliedGroups,
}
for _, row := range rows {
switch row.Status {
case "eligible":
summary.RowsPlanned++
case "applied":
summary.RowsPlanned++
summary.RowsApplied++
case "failed":
summary.RowsPlanned++
summary.RowsFailed++
case "skipped":
summary.RowsSkipped++
}
}
return summary
}
func flattenGroups(groups []transferGroup, fallback []migrationReportRow) []migrationReportRow {
if len(groups) == 0 {
return fallback
}
rows := make([]migrationReportRow, 0, len(fallback))
for _, group := range groups {
for _, row := range group.Rows {
rows = append(rows, *row)
}
}
for _, row := range fallback {
if row.Status == "skipped" {
rows = append(rows, row)
}
}
sort.Slice(rows, func(i, j int) bool {
if rows[i].LocationName == rows[j].LocationName {
if rows[i].SourceWarehouseName == rows[j].SourceWarehouseName {
return rows[i].ProductName < rows[j].ProductName
}
return rows[i].SourceWarehouseName < rows[j].SourceWarehouseName
}
return rows[i].LocationName < rows[j].LocationName
})
return rows
}
func renderMigrationReport(mode string, rows []migrationReportRow, summary applySummary) {
if mode == outputModeJSON {
payload := map[string]any{
"rows": rows,
"summary": summary,
}
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
_ = enc.Encode(payload)
return
}
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
fmt.Fprintln(w, "RUN_ID\tLOCATION\tSOURCE_WAREHOUSE\tFARM_WAREHOUSE\tPRODUCT\tQTY\tLOCATION_STATUS\tSTATUS\tREASON\tTRANSFER_ID\tMOVEMENT_NUMBER")
for _, row := range rows {
transferID := "-"
if row.TransferID != nil {
transferID = fmt.Sprintf("%d", *row.TransferID)
}
movementNumber := "-"
if row.MovementNumber != nil {
movementNumber = *row.MovementNumber
}
fmt.Fprintf(
w,
"%s\t%s\t%s\t%s\t%s\t%.3f\t%s\t%s\t%s\t%s\t%s\n",
row.RunID,
row.LocationName,
row.SourceWarehouseName,
derefString(row.FarmWarehouseName),
row.ProductName,
row.Qty,
row.LocationStatus,
row.Status,
row.Reason,
transferID,
movementNumber,
)
}
_ = w.Flush()
fmt.Printf("\nSummary: rows_planned=%d rows_applied=%d rows_skipped=%d rows_failed=%d groups_planned=%d groups_applied=%d\n",
summary.RowsPlanned, summary.RowsApplied, summary.RowsSkipped, summary.RowsFailed, summary.GroupsPlanned, summary.GroupsApplied)
}
func renderRollbackReport(mode string, rows []rollbackDetailRow) {
if mode == outputModeJSON {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
_ = enc.Encode(map[string]any{"rows": rows})
return
}
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
fmt.Fprintln(w, "RUN_ID\tTRANSFER_ID\tMOVEMENT_NUMBER\tLOCATION\tSOURCE_WAREHOUSE\tFARM_WAREHOUSE\tPRODUCT\tQTY\tSTATUS\tREASON")
for _, row := range rows {
fmt.Fprintf(
w,
"%s\t%d\t%s\t%s\t%s\t%s\t%s\t%.3f\t%s\t%s\n",
row.RunID,
row.TransferID,
row.MovementNumber,
row.LocationName,
row.SourceWarehouseName,
row.FarmWarehouseName,
row.ProductName,
row.Qty,
row.Status,
row.Reason,
)
}
_ = w.Flush()
}
@@ -0,0 +1,251 @@
package main
import (
"context"
"errors"
"strings"
"testing"
"time"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
transferSvc "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/transfers/services"
)
func TestBuildMigrationPlanSkipsOverlapAndGroupsEligibleRows(t *testing.T) {
opts := &commandOptions{
RunID: "egg-cutover-test",
IncludeOverlap: false,
}
timings := map[uint]locationTiming{
16: {LocationID: 16, LocationName: "Jamali", Status: "CLEAN_CUTOVER"},
17: {LocationID: 17, LocationName: "Cijangkar", Status: "OVERLAP"},
}
farmID := uint(25)
farmName := "Gudang Farm Jamali"
rows := []legacyEggStockRow{
{
LocationID: 16,
LocationName: "Jamali",
SourceWarehouseID: 46,
SourceWarehouseName: "Gudang Jamali 1",
FarmWarehouseID: &farmID,
FarmWarehouseName: &farmName,
ProductWarehouseID: 101,
ProductID: 8,
ProductName: "Telur Utuh",
OnHandQty: 120,
},
{
LocationID: 16,
LocationName: "Jamali",
SourceWarehouseID: 46,
SourceWarehouseName: "Gudang Jamali 1",
FarmWarehouseID: &farmID,
FarmWarehouseName: &farmName,
ProductWarehouseID: 102,
ProductID: 9,
ProductName: "Telur Putih",
OnHandQty: 20,
},
{
LocationID: 17,
LocationName: "Cijangkar",
SourceWarehouseID: 51,
SourceWarehouseName: "Gudang Cijangkar 1",
FarmWarehouseID: &farmID,
FarmWarehouseName: &farmName,
ProductWarehouseID: 103,
ProductID: 10,
ProductName: "Telur Jumbo",
OnHandQty: 10,
},
{
LocationID: 16,
LocationName: "Jamali",
SourceWarehouseID: 46,
SourceWarehouseName: "Gudang Jamali 1",
ProductWarehouseID: 104,
ProductID: 11,
ProductName: "Telur Papacal",
OnHandQty: 50,
},
{
LocationID: 16,
LocationName: "Jamali",
SourceWarehouseID: 46,
SourceWarehouseName: "Gudang Jamali 1",
FarmWarehouseID: &farmID,
FarmWarehouseName: &farmName,
ProductWarehouseID: 105,
ProductID: 12,
ProductName: "Telur Retak",
OnHandQty: 0,
},
}
reportRows, groups := buildMigrationPlan(opts, timings, rows)
if len(reportRows) != 5 {
t.Fatalf("expected 5 report rows, got %d", len(reportRows))
}
if len(groups) != 1 {
t.Fatalf("expected 1 eligible transfer group, got %d", len(groups))
}
if len(groups[0].Rows) != 2 {
t.Fatalf("expected 2 eligible products in the transfer group, got %d", len(groups[0].Rows))
}
statusByProduct := make(map[string]string, len(reportRows))
reasonByProduct := make(map[string]string, len(reportRows))
for _, row := range reportRows {
statusByProduct[row.ProductName] = row.Status
reasonByProduct[row.ProductName] = row.Reason
}
if statusByProduct["Telur Utuh"] != "eligible" || statusByProduct["Telur Putih"] != "eligible" {
t.Fatalf("expected Jamali egg rows to stay eligible, got statuses %+v", statusByProduct)
}
if reasonByProduct["Telur Jumbo"] != "overlap_location" {
t.Fatalf("expected overlap location skip, got %q", reasonByProduct["Telur Jumbo"])
}
if reasonByProduct["Telur Papacal"] != "missing_farm_warehouse" {
t.Fatalf("expected missing farm warehouse skip, got %q", reasonByProduct["Telur Papacal"])
}
if reasonByProduct["Telur Retak"] != "non_positive_qty" {
t.Fatalf("expected non positive qty skip, got %q", reasonByProduct["Telur Retak"])
}
}
func TestExecuteApplyBuildsTaggedSystemTransfersAndSummaries(t *testing.T) {
opts := &commandOptions{
RunID: "egg-cutover-apply",
CutoverDate: time.Date(2026, 4, 7, 0, 0, 0, 0, time.UTC),
ActorID: 99,
}
groups := []transferGroup{
{
LocationID: 16,
LocationName: "Jamali",
SourceWarehouseID: 46,
SourceWarehouseName: "Gudang Jamali 1",
FarmWarehouseID: 25,
FarmWarehouseName: "Gudang Farm Jamali",
Rows: []*migrationReportRow{
{ProductID: 8, ProductName: "Telur Utuh", Qty: 120},
{ProductID: 9, ProductName: "Telur Putih", Qty: 20},
},
},
{
LocationID: 18,
LocationName: "Tamansari",
SourceWarehouseID: 91,
SourceWarehouseName: "Gudang Tamansari 1",
FarmWarehouseID: 31,
FarmWarehouseName: "Gudang Farm Tamansari",
Rows: []*migrationReportRow{
{ProductID: 10, ProductName: "Telur Jumbo", Qty: 10},
},
},
}
executor := &fakeSystemTransferExecutor{
createResponses: []*entity.StockTransfer{
{Id: 1001, MovementNumber: "PND-LTI-1001"},
},
createErrors: []error{
nil,
errors.New("destination warehouse locked"),
},
}
summary, err := executeApply(context.Background(), executor, opts, groups)
if err != nil {
t.Fatalf("expected no fatal apply error, got %v", err)
}
if summary.GroupsPlanned != 2 || summary.GroupsApplied != 1 {
t.Fatalf("unexpected group summary: %+v", summary)
}
if summary.RowsApplied != 2 || summary.RowsFailed != 1 {
t.Fatalf("unexpected row summary: %+v", summary)
}
if len(executor.createRequests) != 2 {
t.Fatalf("expected 2 create requests, got %d", len(executor.createRequests))
}
if !strings.Contains(executor.createRequests[0].TransferReason, "EGG_FARM_CUTOVER|run_id=egg-cutover-apply|location=Jamali|cutover_date=2026-04-07") {
t.Fatalf("unexpected transfer reason: %s", executor.createRequests[0].TransferReason)
}
if executor.createRequests[0].MovementNumber != "" {
t.Fatalf("apply path should let transfer service generate movement number, got %q", executor.createRequests[0].MovementNumber)
}
if groups[0].Rows[0].Status != "applied" || groups[0].Rows[1].Status != "applied" {
t.Fatalf("expected first group rows to be applied, got %+v", groups[0].Rows)
}
if groups[1].Rows[0].Status != "failed" {
t.Fatalf("expected second group row to fail, got %+v", groups[1].Rows[0])
}
if groups[0].Rows[0].TransferID == nil || *groups[0].Rows[0].TransferID != 1001 {
t.Fatalf("expected first row to keep created transfer id, got %+v", groups[0].Rows[0].TransferID)
}
}
func TestExecuteRollbackDeletesTransfersDescendingAndMarksFailures(t *testing.T) {
executor := &fakeSystemTransferExecutor{
deleteErrors: map[uint]error{
101: errors.New("already consumed downstream"),
},
}
rows := []rollbackDetailRow{
{TransferID: 100, ProductName: "Telur Utuh"},
{TransferID: 101, ProductName: "Telur Jumbo"},
{TransferID: 100, ProductName: "Telur Putih"},
}
err := executeRollback(context.Background(), executor, rows, 99)
if err == nil {
t.Fatal("expected rollback to return the first transfer error")
}
if err.Error() != "already consumed downstream" {
t.Fatalf("unexpected rollback error: %v", err)
}
if len(executor.deletedTransferIDs) != 2 {
t.Fatalf("expected 2 delete calls, got %d", len(executor.deletedTransferIDs))
}
if executor.deletedTransferIDs[0] != 101 || executor.deletedTransferIDs[1] != 100 {
t.Fatalf("expected delete order [101 100], got %v", executor.deletedTransferIDs)
}
if rows[0].Status != "rolled_back" || rows[2].Status != "rolled_back" {
t.Fatalf("expected transfer 100 rows to be rolled back, got %+v", rows)
}
if rows[1].Status != "failed" {
t.Fatalf("expected transfer 101 row to fail, got %+v", rows[1])
}
}
type fakeSystemTransferExecutor struct {
createRequests []*transferSvc.SystemTransferRequest
createResponses []*entity.StockTransfer
createErrors []error
deletedTransferIDs []uint
deleteErrors map[uint]error
}
func (f *fakeSystemTransferExecutor) CreateSystemTransfer(ctx context.Context, req *transferSvc.SystemTransferRequest) (*entity.StockTransfer, error) {
f.createRequests = append(f.createRequests, req)
idx := len(f.createRequests) - 1
if idx < len(f.createErrors) && f.createErrors[idx] != nil {
return nil, f.createErrors[idx]
}
if idx < len(f.createResponses) && f.createResponses[idx] != nil {
return f.createResponses[idx], nil
}
return &entity.StockTransfer{Id: uint64(1000 + idx), MovementNumber: "PND-LTI-DEFAULT"}, nil
}
func (f *fakeSystemTransferExecutor) DeleteSystemTransfer(ctx context.Context, id uint, actorID uint) error {
f.deletedTransferIDs = append(f.deletedTransferIDs, id)
if f.deleteErrors == nil {
return nil
}
return f.deleteErrors[id]
}
@@ -0,0 +1,380 @@
package main
import (
"context"
"flag"
"fmt"
"log"
"math"
"os"
"strings"
"time"
"gitlab.com/mbugroup/lti-api.git/internal/config"
"gitlab.com/mbugroup/lti-api.git/internal/database"
recordingRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/repositories"
"gorm.io/gorm"
)
const metricEpsilon = 1e-9
type normalizeOptions struct {
Apply bool
RecordingID uint
ProjectFlockKandangID uint
From *time.Time
To *time.Time
BatchSize int
Limit int
}
type normalizeStats struct {
Processed int
Changed int
Updated int
Skipped int
Failed int
}
type recordingMetricRow struct {
ID uint `gorm:"column:id"`
ProjectFlockKandangID uint `gorm:"column:project_flock_kandangs_id"`
RecordDatetime time.Time `gorm:"column:record_datetime"`
HenHouse *float64 `gorm:"column:hen_house"`
EggMass *float64 `gorm:"column:egg_mass"`
}
func main() {
var (
apply bool
recordingID uint
projectFlockKandangID uint
fromRaw string
toRaw string
batchSize int
limit int
)
flag.BoolVar(&apply, "apply", false, "Apply update. If false, run as dry-run")
flag.UintVar(&recordingID, "recording-id", 0, "Target a single recording ID")
flag.UintVar(&projectFlockKandangID, "project-flock-kandang-id", 0, "Filter by project_flock_kandangs_id")
flag.StringVar(&fromRaw, "from", "", "Lower bound record_datetime (RFC3339 / YYYY-MM-DD)")
flag.StringVar(&toRaw, "to", "", "Upper bound record_datetime (RFC3339 / YYYY-MM-DD)")
flag.IntVar(&batchSize, "batch-size", 200, "Batch size when scanning recordings")
flag.IntVar(&limit, "limit", 0, "Max recordings to process (0 = no limit)")
flag.Parse()
if batchSize <= 0 {
log.Fatal("--batch-size must be > 0")
}
if limit < 0 {
log.Fatal("--limit cannot be negative")
}
from, err := parseTimeBound(strings.TrimSpace(fromRaw), false)
if err != nil {
log.Fatalf("invalid --from: %v", err)
}
to, err := parseTimeBound(strings.TrimSpace(toRaw), true)
if err != nil {
log.Fatalf("invalid --to: %v", err)
}
if from != nil && to != nil && to.Before(*from) {
log.Fatal("--to cannot be before --from")
}
opts := normalizeOptions{
Apply: apply,
RecordingID: recordingID,
ProjectFlockKandangID: projectFlockKandangID,
From: from,
To: to,
BatchSize: batchSize,
Limit: limit,
}
ctx := context.Background()
db := database.Connect(config.DBHost, config.DBName)
repo := recordingRepo.NewRecordingRepository(db)
fmt.Printf("Mode: %s\n", modeLabel(opts.Apply))
fmt.Printf("Filter recording_id: %s\n", displayUint(opts.RecordingID))
fmt.Printf("Filter project_flock_kandangs_id: %s\n", displayUint(opts.ProjectFlockKandangID))
fmt.Printf("Filter from: %s\n", displayTime(opts.From))
fmt.Printf("Filter to: %s\n", displayTime(opts.To))
fmt.Printf("Batch size: %d\n", opts.BatchSize)
fmt.Printf("Limit: %d\n\n", opts.Limit)
stats, err := normalizeRecordings(ctx, db, repo, opts)
if err != nil {
log.Fatalf("normalize failed: %v", err)
}
fmt.Println()
fmt.Printf(
"Summary: processed=%d changed=%d updated=%d skipped=%d failed=%d\n",
stats.Processed,
stats.Changed,
stats.Updated,
stats.Skipped,
stats.Failed,
)
if stats.Failed > 0 {
os.Exit(1)
}
}
func normalizeRecordings(
ctx context.Context,
db *gorm.DB,
repo recordingRepo.RecordingRepository,
opts normalizeOptions,
) (normalizeStats, error) {
stats := normalizeStats{}
lastID := uint(0)
initialChickCache := make(map[uint]float64)
for {
batchLimit := opts.BatchSize
if opts.Limit > 0 {
remaining := opts.Limit - stats.Processed
if remaining <= 0 {
break
}
if remaining < batchLimit {
batchLimit = remaining
}
}
rows, err := loadRecordingBatch(ctx, db, opts, lastID, batchLimit)
if err != nil {
return stats, err
}
if len(rows) == 0 {
break
}
for _, row := range rows {
stats.Processed++
lastID = row.ID
initialChick, ok := initialChickCache[row.ProjectFlockKandangID]
if !ok {
initialChick, err = repo.GetTotalChickinByProjectFlockKandang(db.WithContext(ctx), row.ProjectFlockKandangID)
if err != nil {
fmt.Printf("FAIL rec=%d error=getTotalChickinByProjectFlockKandang: %v\n", row.ID, err)
stats.Failed++
continue
}
initialChickCache[row.ProjectFlockKandangID] = initialChick
}
_, totalEggWeightGrams, err := repo.GetEggSummaryByRecording(db.WithContext(ctx), row.ID)
if err != nil {
fmt.Printf("FAIL rec=%d error=getEggSummaryByRecording: %v\n", row.ID, err)
stats.Failed++
continue
}
cumulativeEggQty, err := repo.GetCumulativeEggQtyByProjectFlockKandang(db.WithContext(ctx), row.ProjectFlockKandangID, row.RecordDatetime)
if err != nil {
fmt.Printf("FAIL rec=%d error=getCumulativeEggQtyByProjectFlockKandang: %v\n", row.ID, err)
stats.Failed++
continue
}
newHenHouse, newEggMass := computeNormalizedMetrics(initialChick, cumulativeEggQty, totalEggWeightGrams)
henHouseChanged := metricChanged(row.HenHouse, newHenHouse)
eggMassChanged := metricChanged(row.EggMass, newEggMass)
if !henHouseChanged && !eggMassChanged {
stats.Skipped++
continue
}
stats.Changed++
fmt.Printf(
"PLAN rec=%d pfk=%d at=%s hen_house:%s->%s egg_mass:%s->%s\n",
row.ID,
row.ProjectFlockKandangID,
row.RecordDatetime.UTC().Format(time.RFC3339),
displayFloat(row.HenHouse),
displayFloat(newHenHouse),
displayFloat(row.EggMass),
displayFloat(newEggMass),
)
if !opts.Apply {
continue
}
if err := updateRecordingMetrics(ctx, db, row.ID, newHenHouse, newEggMass); err != nil {
fmt.Printf("FAIL rec=%d error=updateRecordingMetrics: %v\n", row.ID, err)
stats.Failed++
continue
}
fmt.Printf(
"DONE rec=%d hen_house=%s egg_mass=%s\n",
row.ID,
displayFloat(newHenHouse),
displayFloat(newEggMass),
)
stats.Updated++
}
if opts.RecordingID > 0 {
break
}
}
return stats, nil
}
func loadRecordingBatch(
ctx context.Context,
db *gorm.DB,
opts normalizeOptions,
lastID uint,
limit int,
) ([]recordingMetricRow, error) {
query := db.WithContext(ctx).
Table("recordings").
Select("id, project_flock_kandangs_id, record_datetime, hen_house, egg_mass").
Where("recordings.deleted_at IS NULL")
if opts.RecordingID > 0 {
query = query.Where("recordings.id = ?", opts.RecordingID)
}
if opts.ProjectFlockKandangID > 0 {
query = query.Where("recordings.project_flock_kandangs_id = ?", opts.ProjectFlockKandangID)
}
if opts.From != nil {
query = query.Where("recordings.record_datetime >= ?", *opts.From)
}
if opts.To != nil {
query = query.Where("recordings.record_datetime <= ?", *opts.To)
}
if opts.RecordingID == 0 && lastID > 0 {
query = query.Where("recordings.id > ?", lastID)
}
var rows []recordingMetricRow
err := query.
Order("recordings.id ASC").
Limit(limit).
Scan(&rows).Error
return rows, err
}
func computeNormalizedMetrics(initialChick, cumulativeEggQty, totalEggWeightGrams float64) (*float64, *float64) {
var henHouse *float64
if initialChick > 0 && cumulativeEggQty >= 0 {
value := cumulativeEggQty / initialChick
henHouse = &value
}
var eggMass *float64
if initialChick > 0 && totalEggWeightGrams > 0 {
value := totalEggWeightGrams / initialChick
eggMass = &value
}
return henHouse, eggMass
}
func updateRecordingMetrics(ctx context.Context, db *gorm.DB, recordingID uint, henHouse, eggMass *float64) error {
updates := map[string]any{}
if henHouse == nil {
updates["hen_house"] = gorm.Expr("NULL")
} else {
updates["hen_house"] = *henHouse
}
if eggMass == nil {
updates["egg_mass"] = gorm.Expr("NULL")
} else {
updates["egg_mass"] = *eggMass
}
return db.WithContext(ctx).
Table("recordings").
Where("id = ?", recordingID).
Updates(updates).Error
}
func metricChanged(oldValue, newValue *float64) bool {
if oldValue == nil && newValue == nil {
return false
}
if oldValue == nil || newValue == nil {
return true
}
return !nearlyEqual(*oldValue, *newValue)
}
func nearlyEqual(a, b float64) bool {
scale := math.Max(1, math.Max(math.Abs(a), math.Abs(b)))
return math.Abs(a-b) <= metricEpsilon*scale
}
func parseTimeBound(raw string, isUpper bool) (*time.Time, error) {
if raw == "" {
return nil, nil
}
layouts := []string{
time.RFC3339Nano,
time.RFC3339,
"2006-01-02 15:04:05",
"2006-01-02",
}
for _, layout := range layouts {
parsed, err := time.Parse(layout, raw)
if err != nil {
continue
}
if layout == "2006-01-02" {
if isUpper {
endOfDay := time.Date(parsed.Year(), parsed.Month(), parsed.Day(), 23, 59, 59, int(time.Second-time.Nanosecond), time.UTC)
return &endOfDay, nil
}
startOfDay := time.Date(parsed.Year(), parsed.Month(), parsed.Day(), 0, 0, 0, 0, time.UTC)
return &startOfDay, nil
}
t := parsed.UTC()
return &t, nil
}
return nil, fmt.Errorf("unsupported format %q", raw)
}
func modeLabel(apply bool) string {
if apply {
return "APPLY"
}
return "DRY-RUN"
}
func displayFloat(v *float64) string {
if v == nil {
return "NULL"
}
return fmt.Sprintf("%.6f", *v)
}
func displayTime(v *time.Time) string {
if v == nil {
return "<nil>"
}
return v.UTC().Format(time.RFC3339)
}
func displayUint(v uint) string {
if v == 0 {
return "<all>"
}
return fmt.Sprintf("%d", v)
}
@@ -0,0 +1,282 @@
package main
import (
"context"
"errors"
"flag"
"fmt"
"log"
"math"
"os"
"strings"
"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"
)
const qtyEpsilon = 1e-6
const (
levelAll = 1
levelByProductName = 2
levelByProductWarehouse = 3
)
type reflowRow struct {
ProductWarehouseID uint `gorm:"column:product_warehouse_id"`
ProductID uint `gorm:"column:product_id"`
ProductName string `gorm:"column:product_name"`
CurrentQty float64 `gorm:"column:current_qty"`
SumTotalQty float64 `gorm:"column:sum_total_qty"`
SumAllocatedQty float64 `gorm:"column:sum_allocated_qty"`
ComputedQty float64 `gorm:"column:computed_qty"`
}
func main() {
var (
apply bool
level int
productName string
productWarehouseID uint
)
flag.BoolVar(&apply, "apply", false, "Apply changes. If false, run as dry-run")
flag.IntVar(&level, "level", levelAll, "CLI level: 1=all product_warehouse scope, 2=product name scope, 3=product_warehouse_id scope")
flag.StringVar(&productName, "product-name", "", "Product name (required for level 2)")
flag.UintVar(&productWarehouseID, "product-warehouse-id", 0, "Product warehouse id (required for level 3)")
flag.Parse()
productName = strings.TrimSpace(productName)
if err := validateFlags(level, productName, productWarehouseID); err != nil {
log.Fatalf("invalid flags: %v", err)
}
ctx := context.Background()
db := database.Connect(config.DBHost, config.DBName)
rows, err := loadReflowRows(ctx, db, level, productName, productWarehouseID)
if err != nil {
log.Fatalf("failed to calculate reflow qty: %v", err)
}
fmt.Printf("Mode: %s\n", modeLabel(apply))
fmt.Printf("Level: %d (%s)\n", level, levelLabel(level))
if productName != "" {
fmt.Printf("Filter product_name: %s\n", productName)
}
if productWarehouseID > 0 {
fmt.Printf("Filter product_warehouse_id: %d\n", productWarehouseID)
}
fmt.Printf("Targets found: %d\n\n", len(rows))
if len(rows) == 0 {
fmt.Println("No product warehouse found from purchase_items scope")
return
}
negativePlan := 0
for _, row := range rows {
if row.ComputedQty < 0 {
negativePlan++
}
fmt.Printf(
"PLAN pw=%d product_id=%d product=%q current_qty=%.3f total_qty=%.3f allocated_qty=%.3f computed_qty=%.3f delta=%.3f\n",
row.ProductWarehouseID,
row.ProductID,
row.ProductName,
row.CurrentQty,
row.SumTotalQty,
row.SumAllocatedQty,
row.ComputedQty,
row.ComputedQty-row.CurrentQty,
)
}
if !apply {
fmt.Println()
fmt.Printf("Summary: planned=%d updated=0 skipped=0 failed=0 negative_plan=%d\n", len(rows), negativePlan)
return
}
updated := 0
skipped := 0
negativeUpdated := 0
err = db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
for _, row := range rows {
if nearlyEqual(row.CurrentQty, row.ComputedQty) {
fmt.Printf(
"SKIP pw=%d reason=no_change current_qty=%.3f computed_qty=%.3f\n",
row.ProductWarehouseID,
row.CurrentQty,
row.ComputedQty,
)
skipped++
continue
}
if err := tx.Table("product_warehouses").
Where("id = ?", row.ProductWarehouseID).
Update("qty", row.ComputedQty).Error; err != nil {
return fmt.Errorf("update qty for product_warehouse_id=%d: %w", row.ProductWarehouseID, err)
}
if row.ComputedQty < 0 {
negativeUpdated++
}
fmt.Printf(
"DONE pw=%d product_id=%d product=%q old_qty=%.3f new_qty=%.3f\n",
row.ProductWarehouseID,
row.ProductID,
row.ProductName,
row.CurrentQty,
row.ComputedQty,
)
updated++
}
return nil
})
if err != nil {
fmt.Println()
fmt.Printf(
"Summary: planned=%d updated=%d skipped=%d failed=1 negative_plan=%d negative_updated=%d\n",
len(rows),
updated,
skipped,
negativePlan,
negativeUpdated,
)
log.Printf("error: %v", err)
os.Exit(1)
}
fmt.Println()
fmt.Printf(
"Summary: planned=%d updated=%d skipped=%d failed=0 negative_plan=%d negative_updated=%d\n",
len(rows),
updated,
skipped,
negativePlan,
negativeUpdated,
)
}
func validateFlags(level int, productName string, productWarehouseID uint) error {
switch level {
case levelAll:
if productName != "" {
return errors.New("--product-name cannot be used on level 1")
}
if productWarehouseID > 0 {
return errors.New("--product-warehouse-id cannot be used on level 1")
}
case levelByProductName:
if productName == "" {
return errors.New("--product-name is required on level 2")
}
if productWarehouseID > 0 {
return errors.New("--product-warehouse-id cannot be used on level 2")
}
case levelByProductWarehouse:
if productWarehouseID == 0 {
return errors.New("--product-warehouse-id is required on level 3")
}
if productName != "" {
return errors.New("--product-name cannot be used on level 3")
}
default:
return fmt.Errorf("unsupported --level=%d (allowed: 1, 2, 3)", level)
}
return nil
}
func loadReflowRows(
ctx context.Context,
db *gorm.DB,
level int,
productName string,
productWarehouseID uint,
) ([]reflowRow, error) {
allocSub := db.WithContext(ctx).
Table("stock_allocations sa").
Select(`
sa.stockable_id,
COALESCE(SUM(sa.qty), 0) AS used_qty
`).
Where("sa.stockable_type = ?", fifo.StockableKeyPurchaseItems.String()).
Where("sa.status = ?", entity.StockAllocationStatusActive).
Where("sa.deleted_at IS NULL").
Group("sa.stockable_id")
calcSub := db.WithContext(ctx).
Table("purchase_items pi").
Select(`
pi.product_warehouse_id,
COALESCE(SUM(pi.total_qty), 0) AS sum_total_qty,
COALESCE(SUM(COALESCE(alloc.used_qty, 0)), 0) AS sum_allocated_qty,
COALESCE(SUM(COALESCE(pi.total_qty, 0) - COALESCE(alloc.used_qty, 0)), 0) AS computed_qty
`).
Joins("LEFT JOIN (?) alloc ON alloc.stockable_id = pi.id", allocSub).
Where("pi.product_warehouse_id IS NOT NULL").
Group("pi.product_warehouse_id")
query := db.WithContext(ctx).
Table("product_warehouses pw").
Select(`
pw.id AS product_warehouse_id,
pw.product_id AS product_id,
p.name AS product_name,
COALESCE(pw.qty, 0) AS current_qty,
calc.sum_total_qty,
calc.sum_allocated_qty,
calc.computed_qty
`).
Joins("JOIN products p ON p.id = pw.product_id").
Joins("JOIN (?) calc ON calc.product_warehouse_id = pw.id", calcSub).
Order("pw.id ASC")
switch level {
case levelByProductName:
query = query.Where("LOWER(p.name) = LOWER(?)", productName)
case levelByProductWarehouse:
query = query.Where("pw.id = ?", productWarehouseID)
}
rows := make([]reflowRow, 0)
if err := query.Scan(&rows).Error; err != nil {
return nil, err
}
return rows, nil
}
func modeLabel(apply bool) string {
if apply {
return "APPLY"
}
return "DRY-RUN"
}
func levelLabel(level int) string {
switch level {
case levelAll:
return "all product_warehouse from purchase_items"
case levelByProductName:
return "specific product name"
case levelByProductWarehouse:
return "specific product_warehouse_id"
default:
return "unknown"
}
}
func nearlyEqual(a, b float64) bool {
return math.Abs(a-b) <= qtyEpsilon
}
+75
View File
@@ -0,0 +1,75 @@
package main
import (
"flag"
"fmt"
"log"
"os"
"strings"
"gitlab.com/mbugroup/lti-api.git/internal/config"
"gitlab.com/mbugroup/lti-api.git/internal/database"
"gorm.io/gorm"
)
type options struct {
FilePath string
Apply bool
}
func main() {
var opts options
flag.StringVar(&opts.FilePath, "file", "", "Path to .sql file (required)")
flag.BoolVar(&opts.Apply, "apply", false, "Apply SQL to database. If false, run as dry-run")
flag.Parse()
opts.FilePath = strings.TrimSpace(opts.FilePath)
if opts.FilePath == "" {
log.Fatal("--file is required")
}
sqlContent, err := readSQLFile(opts.FilePath)
if err != nil {
log.Fatalf("failed reading sql file: %v", err)
}
mode := "dry-run"
if opts.Apply {
mode = "apply"
}
fmt.Printf("Mode: %s\n", mode)
fmt.Printf("File: %s\n", opts.FilePath)
fmt.Printf("SQL bytes: %d\n", len(sqlContent))
if !opts.Apply {
fmt.Println("Dry-run only. Add --apply to execute the SQL file.")
return
}
db := database.Connect(config.DBHost, config.DBName)
if err := executeSQL(db, sqlContent); err != nil {
log.Fatalf("failed executing sql file: %v", err)
}
fmt.Println("DONE: SQL executed successfully")
}
func readSQLFile(path string) (string, error) {
raw, err := os.ReadFile(path)
if err != nil {
return "", err
}
sql := strings.TrimSpace(strings.TrimPrefix(string(raw), "\ufeff"))
if sql == "" {
return "", fmt.Errorf("sql file is empty")
}
return sql, nil
}
func executeSQL(db *gorm.DB, sql string) error {
return db.Transaction(func(tx *gorm.DB) error {
return tx.Exec(sql).Error
})
}
@@ -0,0 +1,76 @@
# Farm Depreciation Manual Inputs Import
Command ini dipakai untuk bulk import data ke tabel `farm_depreciation_manual_inputs` dari file Excel (`.xlsx`).
## Command
```bash
go run ./cmd/import-farm-depreciation-manual-inputs --file <path.xlsx> [--sheet <name>] [--apply]
```
## Flags
- `--file` (required): path file `.xlsx`.
- `--sheet` (optional): nama sheet. Jika tidak diisi, command pakai sheet pertama.
- `--apply` (optional): default `false` (dry-run). Jika `true`, command menulis ke database.
## Mode
- Dry-run (default):
- parsing dan validasi semua baris.
- validasi `project_flock_id` terhadap farm aktif kategori `LAYING`.
- menampilkan `PLAN` + daftar error.
- tidak menulis data.
- Apply (`--apply`):
- semua validasi tetap dijalankan dulu.
- jika ada 1 error, proses dihentikan.
- jika valid, upsert dijalankan dalam 1 transaksi (fail-fast).
- setelah upsert, snapshot di `farm_depreciation_snapshots` dihapus mulai `cutover_date` untuk `project_flock_id` terkait.
## Format Excel
Template tersedia di:
- `docs/templates/farm_depreciation_manual_inputs.xlsx`
Header wajib ada di baris 1 (case-insensitive, trim-spaces):
- `project_flock_id` (required, integer > 0)
- `total_cost` (required, numeric >= 0)
- `cutover_date` (required, format `YYYY-MM-DD`)
- `note` (optional, max 1000 karakter)
Catatan:
- Dalam 1 file tidak boleh ada duplikat `project_flock_id`.
- `project_flock_id` harus mengarah ke `project_flocks` yang `deleted_at IS NULL` dan `category = LAYING`.
## Contoh
Dry-run:
```bash
env DB_HOST=127.0.0.1 DB_PORT=5432 DB_NAME=lti DB_USER=postgres DB_PASSWORD=postgres \
go run ./cmd/import-farm-depreciation-manual-inputs \
--file docs/templates/farm_depreciation_manual_inputs.xlsx
```
Apply:
```bash
env DB_HOST=127.0.0.1 DB_PORT=5432 DB_NAME=lti DB_USER=postgres DB_PASSWORD=postgres \
go run ./cmd/import-farm-depreciation-manual-inputs \
--file /path/to/farm_depreciation_manual_inputs.xlsx \
--sheet manual_inputs \
--apply
```
## Error Umum
- `required header is missing`: header wajib tidak ditemukan.
- `must be a positive integer`: `project_flock_id` bukan integer valid.
- `must be greater than or equal to 0`: `total_cost` negatif.
- `must follow format YYYY-MM-DD`: `cutover_date` tidak sesuai format.
- `duplicate value ...`: `project_flock_id` duplikat dalam file yang sama.
- `must reference an active LAYING project_flock`: farm tidak valid untuk import ini.
+286
View File
@@ -0,0 +1,286 @@
# Runbook Cutover Stok Telur Historis Kandang ke Gudang Farm
## Tujuan
Runbook ini dipakai untuk memindahkan **stok telur historis yang masih on-hand di gudang kandang** ke **gudang farm** secara aman, audit-able, dan reversible.
Cutover dilakukan dengan **transfer stok eksplisit**, bukan dengan mengubah `recording_eggs.product_warehouse_id` historis.
## Scope
Runbook ini hanya untuk:
- stok telur historis kandang-level yang masih punya saldo on-hand
- lokasi yang masuk kategori **clean cutover**
- lokasi yang sudah punya gudang farm
Runbook ini **tidak** dipakai untuk:
- lokasi overlap seperti `Cijangkar`
- koreksi histori `recording_eggs`
- migrasi stok non-telur
## Kebijakan yang Dikunci
- Sumber qty yang dipindah adalah **`product_warehouses.qty` saat cutover**
- Perintah dijalankan **per lokasi**
- Wajib mulai dari `dry-run`
- `--apply` hanya boleh dijalankan setelah review dry-run dan SQL checklist
- Lokasi overlap tidak ikut otomatis kecuali ada approval khusus dan `--include-overlap`
- Rollback hanya boleh dilakukan jika transfer hasil cutover belum dipakai transaksi turunan
## Lokasi Fase 1
Lokasi yang boleh dieksekusi pada fase pertama:
- `Jamali`
- `Cantilan`
- `Darawati`
- `Tamansari`
Lokasi yang harus ditahan:
- `Cijangkar`
## Prasyarat
Sebelum eksekusi, pastikan:
- backend sudah ter-deploy dengan command [main.go](/Users/macbookair/Documents/coding/projects/LTI-ERP/lti-api/cmd/migrate-legacy-egg-stock-to-farm/main.go)
- reusable transfer core sudah ikut ter-deploy:
- [transfer.service.go](/Users/macbookair/Documents/coding/projects/LTI-ERP/lti-api/internal/modules/inventory/transfers/services/transfer.service.go)
- [system_transfer.go](/Users/macbookair/Documents/coding/projects/LTI-ERP/lti-api/internal/modules/inventory/transfers/services/system_transfer.go)
- migrasi farm stock attribution sebelumnya sudah terpasang
- akses database target sudah tersedia
- environment target memakai SSL bila RDS mewajibkan, contoh:
- `DB_SSLMODE=require`
## Catatan Output Command
Mode `--output table` adalah mode operasional yang direkomendasikan.
Mode `--output json` bisa dipakai, tetapi pada environment saat ini output JSON masih dapat didahului log bootstrap aplikasi atau SQL logger. Untuk review manual gunakan `table`. Untuk parsing otomatis, filter payload mulai dari `{`.
## Format Command
### Dry-run
```bash
DB_SSLMODE=require go run ./cmd/migrate-legacy-egg-stock-to-farm \
--location-name Jamali \
--output table
```
### Apply
```bash
DB_SSLMODE=require go run ./cmd/migrate-legacy-egg-stock-to-farm \
--location-name Jamali \
--cutover-date 2026-04-07 \
--apply \
--output table
```
### Rollback Preview
```bash
DB_SSLMODE=require go run ./cmd/migrate-legacy-egg-stock-to-farm \
--rollback-run-id <run_id> \
--output table
```
### Rollback Apply
```bash
DB_SSLMODE=require go run ./cmd/migrate-legacy-egg-stock-to-farm \
--rollback-run-id <run_id> \
--apply \
--output table
```
## Arti `run_id`
Setiap dry-run/apply menghasilkan `run_id`, misalnya:
```text
egg-cutover-20260407T130344.220407000Z
```
`run_id` ini wajib disimpan karena dipakai untuk:
- audit hasil cutover
- query verifikasi
- rollback
## Prosedur Eksekusi Per Lokasi
### 1. Persiapan
Tentukan:
- `location_name`
- `cutover_date`
- operator yang bertanggung jawab
Contoh:
- lokasi: `Jamali`
- cutover date: `2026-04-07`
### 2. Jalankan Dry-run
```bash
DB_SSLMODE=require go run ./cmd/migrate-legacy-egg-stock-to-farm \
--location-name Jamali \
--output table
```
Yang harus dicek pada hasil dry-run:
- status lokasi `CLEAN_CUTOVER`
- semua baris yang akan dipindah punya `status=eligible`
- gudang tujuan adalah gudang farm lokasi tersebut
- qty yang dipindah masuk akal dan sesuai saldo on-hand aktual
- tidak ada `missing_farm_warehouse`
- tidak ada `overlap_location`
### 3. Jalankan Checklist SQL Before
Gunakan file:
- [legacy_egg_cutover_verification_checklist.sql](/Users/macbookair/Documents/coding/projects/LTI-ERP/lti-api/docs/sql/legacy_egg_cutover_verification_checklist.sql)
Minimal pastikan:
- lokasi memang clean cutover
- stok telur kandang positif masih ada
- gudang farm ada
- belum ada transfer `EGG_FARM_CUTOVER` aktif untuk lokasi yang sama pada run yang akan dipakai
### 4. Simpan Evidence Sebelum Apply
Simpan:
- output dry-run
- hasil query before
- nama operator
- waktu eksekusi
Disarankan simpan dalam ticket / change record.
### 5. Jalankan Apply
```bash
DB_SSLMODE=require go run ./cmd/migrate-legacy-egg-stock-to-farm \
--location-name Jamali \
--cutover-date 2026-04-07 \
--apply \
--output table
```
Setelah apply, simpan:
- `run_id`
- seluruh row dengan `transfer_id`
- movement number yang terbentuk
### 6. Jalankan Checklist SQL After
Masih menggunakan file:
- [legacy_egg_cutover_verification_checklist.sql](/Users/macbookair/Documents/coding/projects/LTI-ERP/lti-api/docs/sql/legacy_egg_cutover_verification_checklist.sql)
Minimal pastikan:
- transfer header/detail tercatat untuk `run_id`
- qty source berkurang sesuai transfer
- qty farm bertambah sesuai transfer
- total gabungan source+dest per produk per lokasi tetap sama
- stok eligible tidak lagi tersedia di gudang kandang
- stok telur sekarang tersedia di gudang farm
### 7. Smoke Test UI
Lakukan minimal:
- buka product stock farm untuk lokasi tersebut
- pastikan produk telur hasil migrasi muncul
- buat SO farm-level dan pastikan opsi produk telur tersedia
- pastikan recording telur baru setelah cutover tetap langsung masuk ke gudang farm
### 8. Tutup Eksekusi
Catat hasil akhir:
- sukses/gagal
- `run_id`
- lokasi
- tanggal cutover
- operator
- link ke evidence SQL/UI
## Kriteria Go / No-Go
### Boleh lanjut apply bila:
- dry-run menunjukkan hanya row yang memang expected
- lokasi `CLEAN_CUTOVER`
- gudang farm valid
- query before menunjukkan tidak ada anomaly blocking
### Wajib stop bila:
- lokasi terdeteksi `OVERLAP`
- ada qty aneh atau tidak sesuai data lapangan
- gudang farm tidak ada
- ada transfer lama serupa yang belum direkonsiliasi
- setelah apply terjadi selisih total source+dest
## Rollback Runbook
### Kapan rollback boleh dilakukan
Rollback boleh jika:
- transfer hasil cutover belum dipakai transaksi turunan
- verifikasi after menunjukkan issue yang membuat hasil cutover tidak dapat diterima
### Langkah rollback
1. Preview rollback:
```bash
DB_SSLMODE=require go run ./cmd/migrate-legacy-egg-stock-to-farm \
--rollback-run-id <run_id> \
--output table
```
2. Jalankan query rollback readiness pada file audit/helper SQL.
3. Jika aman, apply rollback:
```bash
DB_SSLMODE=require go run ./cmd/migrate-legacy-egg-stock-to-farm \
--rollback-run-id <run_id> \
--apply \
--output table
```
4. Jalankan ulang query verifikasi after rollback.
### Kapan rollback akan gagal by design
Rollback memang harus gagal jika:
- transfer hasil cutover sudah dipakai sales/recording/transaksi turunan
- sudah ada `stock_allocations` consume aktif terhadap `STOCK_TRANSFER_IN`
## Urutan Rollout yang Direkomendasikan
### Dev
1. Dry-run per lokasi
2. Review SQL before
3. Apply per lokasi
4. SQL after
5. Smoke UI
6. Simpan `run_id`
### Production
1. Freeze operasional lokasi target bila perlu
2. Dry-run
3. Review by dev + ops + finance/stock owner
4. Apply
5. SQL after
6. Smoke UI
7. Release lokasi berikutnya
## Referensi
- Command cutover: [main.go](/Users/macbookair/Documents/coding/projects/LTI-ERP/lti-api/cmd/migrate-legacy-egg-stock-to-farm/main.go)
- Test command: [main_test.go](/Users/macbookair/Documents/coding/projects/LTI-ERP/lti-api/cmd/migrate-legacy-egg-stock-to-farm/main_test.go)
- Core reusable transfer: [system_transfer.go](/Users/macbookair/Documents/coding/projects/LTI-ERP/lti-api/internal/modules/inventory/transfers/services/system_transfer.go)
- Transfer service refactor: [transfer.service.go](/Users/macbookair/Documents/coding/projects/LTI-ERP/lti-api/internal/modules/inventory/transfers/services/transfer.service.go)
- Checklist SQL: [legacy_egg_cutover_verification_checklist.sql](/Users/macbookair/Documents/coding/projects/LTI-ERP/lti-api/docs/sql/legacy_egg_cutover_verification_checklist.sql)
- Helper query audit: [legacy_egg_cutover_audit_queries.sql](/Users/macbookair/Documents/coding/projects/LTI-ERP/lti-api/docs/sql/legacy_egg_cutover_audit_queries.sql)
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+174
View File
@@ -0,0 +1,174 @@
{
"_postman_exported_at": "2026-04-14T00:00:00Z",
"_postman_exported_using": "Codex",
"_postman_variable_scope": "environment",
"id": "lti-read-api-local",
"name": "LTI ERP Read API.local",
"values": [
{
"enabled": true,
"key": "adjustment_id",
"value": "1"
},
{
"enabled": true,
"key": "api_key",
"value": ""
},
{
"enabled": true,
"key": "area_id",
"value": "1"
},
{
"enabled": true,
"key": "bank_id",
"value": "1"
},
{
"enabled": true,
"key": "base_url",
"value": "http://localhost:8081"
},
{
"enabled": true,
"key": "bearer_token",
"value": ""
},
{
"enabled": true,
"key": "chickin_id",
"value": "1"
},
{
"enabled": true,
"key": "customer_id",
"value": "1"
},
{
"enabled": true,
"key": "employee_id",
"value": "1"
},
{
"enabled": true,
"key": "expense_id",
"value": "1"
},
{
"enabled": true,
"key": "flock_id",
"value": "1"
},
{
"enabled": true,
"key": "id",
"value": "1"
},
{
"enabled": true,
"key": "idDailyChecklist",
"value": "1"
},
{
"enabled": true,
"key": "idProjectFlockKandang",
"value": "1"
},
{
"enabled": true,
"key": "initial_balance_id",
"value": "1"
},
{
"enabled": true,
"key": "injection_id",
"value": "1"
},
{
"enabled": true,
"key": "location_id",
"value": "1"
},
{
"enabled": true,
"key": "nonstock_id",
"value": "1"
},
{
"enabled": true,
"key": "payment_id",
"value": "1"
},
{
"enabled": true,
"key": "product_category_id",
"value": "1"
},
{
"enabled": true,
"key": "product_id",
"value": "1"
},
{
"enabled": true,
"key": "projectFlockId",
"value": "1"
},
{
"enabled": true,
"key": "project_flock_id",
"value": "1"
},
{
"enabled": true,
"key": "project_flock_kandang_id",
"value": "1"
},
{
"enabled": true,
"key": "purchase_id",
"value": "1"
},
{
"enabled": true,
"key": "recording_id",
"value": "1"
},
{
"enabled": true,
"key": "supplier_id",
"value": "1"
},
{
"enabled": true,
"key": "transaction_id",
"value": "1"
},
{
"enabled": true,
"key": "transfer_id",
"value": "1"
},
{
"enabled": true,
"key": "uniformity_id",
"value": "1"
},
{
"enabled": true,
"key": "uom_id",
"value": "1"
},
{
"enabled": true,
"key": "user_id",
"value": "1"
},
{
"enabled": true,
"key": "warehouse_id",
"value": "1"
}
]
}
@@ -0,0 +1,343 @@
-- Legacy Egg Cutover Audit Helper Queries
-- Ad-hoc query pack for investigation, audit, dry-run review, and rollback readiness.
-- =====================================================================
-- AUDIT-01 All locations classified by kandang/farm egg posting timing
-- =====================================================================
WITH timing AS (
SELECT
pf.location_id AS location_id,
l.name AS location_name,
MIN(CASE WHEN w.type = 'KANDANG' THEN DATE(r.record_datetime) END) AS first_kandang_date,
MAX(CASE WHEN w.type = 'KANDANG' THEN DATE(r.record_datetime) END) AS last_kandang_date,
MIN(CASE WHEN w.type = 'LOKASI' THEN DATE(r.record_datetime) END) AS first_farm_date,
MAX(CASE WHEN w.type = 'LOKASI' THEN DATE(r.record_datetime) END) AS last_farm_date
FROM recording_eggs re
JOIN recordings r ON r.id = re.recording_id
JOIN project_flock_kandangs pk ON pk.id = COALESCE(re.project_flock_kandang_id, r.project_flock_kandangs_id)
JOIN project_flocks pf ON pf.id = pk.project_flock_id
JOIN locations l ON l.id = pf.location_id
JOIN product_warehouses pw ON pw.id = re.product_warehouse_id
JOIN warehouses w ON w.id = pw.warehouse_id
GROUP BY pf.location_id, l.name
)
SELECT
location_id,
location_name,
first_kandang_date,
last_kandang_date,
first_farm_date,
last_farm_date,
CASE
WHEN first_farm_date IS NULL THEN 'KANDANG_ONLY'
WHEN last_kandang_date IS NULL OR first_farm_date > last_kandang_date THEN 'CLEAN_CUTOVER'
ELSE 'OVERLAP'
END AS location_status
FROM timing
ORDER BY location_name;
-- =====================================================================
-- AUDIT-02 All legacy kandang egg product warehouses with positive on-hand
-- =====================================================================
WITH first_farm AS (
SELECT location_id, MIN(id) AS farm_warehouse_id
FROM warehouses
WHERE type = 'LOKASI'
AND deleted_at IS NULL
GROUP BY location_id
)
SELECT
l.id AS location_id,
l.name AS location_name,
kw.id AS source_warehouse_id,
kw.name AS source_warehouse_name,
fw.id AS farm_warehouse_id,
fw.name AS farm_warehouse_name,
pw.id AS product_warehouse_id,
p.id AS product_id,
p.name AS product_name,
COALESCE(pw.qty, 0) AS on_hand_qty
FROM product_warehouses pw
JOIN warehouses kw
ON kw.id = pw.warehouse_id
AND kw.type = 'KANDANG'
AND kw.deleted_at IS NULL
JOIN locations l ON l.id = kw.location_id
JOIN products p ON p.id = pw.product_id
LEFT JOIN product_categories pc ON pc.id = p.product_category_id
LEFT JOIN first_farm ff ON ff.location_id = kw.location_id
LEFT JOIN warehouses fw ON fw.id = ff.farm_warehouse_id
WHERE EXISTS (
SELECT 1 FROM recording_eggs re WHERE re.product_warehouse_id = pw.id
)
AND (
EXISTS (
SELECT 1 FROM flags f
WHERE f.flagable_type = 'products'
AND f.flagable_id = p.id
AND (UPPER(f.name) = 'TELUR' OR UPPER(f.name) LIKE 'TELUR-%')
)
OR (
NOT EXISTS (
SELECT 1 FROM flags f_any
WHERE f_any.flagable_type = 'products'
AND f_any.flagable_id = p.id
)
AND UPPER(COALESCE(pc.code, '')) = 'EGG'
)
)
AND COALESCE(pw.qty, 0) > 0
ORDER BY l.name, kw.name, p.name;
-- =====================================================================
-- AUDIT-03 Totals per location for phase sizing
-- =====================================================================
WITH candidates AS (
SELECT
l.name AS location_name,
COALESCE(pw.qty, 0) AS on_hand_qty
FROM product_warehouses pw
JOIN warehouses kw
ON kw.id = pw.warehouse_id
AND kw.type = 'KANDANG'
AND kw.deleted_at IS NULL
JOIN locations l ON l.id = kw.location_id
JOIN products p ON p.id = pw.product_id
LEFT JOIN product_categories pc ON pc.id = p.product_category_id
WHERE EXISTS (
SELECT 1 FROM recording_eggs re WHERE re.product_warehouse_id = pw.id
)
AND (
EXISTS (
SELECT 1 FROM flags f
WHERE f.flagable_type = 'products'
AND f.flagable_id = p.id
AND (UPPER(f.name) = 'TELUR' OR UPPER(f.name) LIKE 'TELUR-%')
)
OR (
NOT EXISTS (
SELECT 1 FROM flags f_any
WHERE f_any.flagable_type = 'products'
AND f_any.flagable_id = p.id
)
AND UPPER(COALESCE(pc.code, '')) = 'EGG'
)
)
AND COALESCE(pw.qty, 0) > 0
)
SELECT
location_name,
COUNT(*) AS positive_rows,
SUM(on_hand_qty) AS total_on_hand_qty
FROM candidates
GROUP BY location_name
ORDER BY location_name;
-- =====================================================================
-- AUDIT-04 Locations missing farm warehouse
-- =====================================================================
SELECT
l.id AS location_id,
l.name AS location_name
FROM locations l
WHERE EXISTS (
SELECT 1
FROM warehouses kw
WHERE kw.location_id = l.id
AND kw.type = 'KANDANG'
AND kw.deleted_at IS NULL
)
AND NOT EXISTS (
SELECT 1
FROM warehouses fw
WHERE fw.location_id = l.id
AND fw.type = 'LOKASI'
AND fw.deleted_at IS NULL
)
ORDER BY l.name;
-- =====================================================================
-- AUDIT-05 Legacy recording_eggs still pointing to kandang warehouse
-- =====================================================================
SELECT
l.name AS location_name,
kw.name AS kandang_warehouse_name,
p.name AS product_name,
COUNT(*) AS recording_rows
FROM recording_eggs re
JOIN product_warehouses pw ON pw.id = re.product_warehouse_id
JOIN warehouses kw ON kw.id = pw.warehouse_id
JOIN locations l ON l.id = kw.location_id
JOIN products p ON p.id = pw.product_id
WHERE kw.type = 'KANDANG'
GROUP BY l.name, kw.name, p.name
ORDER BY l.name, kw.name, p.name;
-- =====================================================================
-- AUDIT-06 Farm-level recording_eggs already present
-- =====================================================================
SELECT
l.name AS location_name,
fw.name AS farm_warehouse_name,
p.name AS product_name,
COUNT(*) AS recording_rows
FROM recording_eggs re
JOIN product_warehouses pw ON pw.id = re.product_warehouse_id
JOIN warehouses fw ON fw.id = pw.warehouse_id
JOIN locations l ON l.id = fw.location_id
JOIN products p ON p.id = pw.product_id
WHERE fw.type = 'LOKASI'
GROUP BY l.name, fw.name, p.name
ORDER BY l.name, fw.name, p.name;
-- =====================================================================
-- AUDIT-07 Transfers created by cutover reason, grouped by run_id
-- =====================================================================
SELECT
SPLIT_PART(SPLIT_PART(st.reason, '|run_id=', 2), '|', 1) AS run_id,
COUNT(DISTINCT st.id) AS transfer_count,
COUNT(std.id) AS detail_count,
SUM(COALESCE(std.total_qty, std.usage_qty, 0)) AS total_moved_qty,
MIN(st.transfer_date) AS first_transfer_date,
MAX(st.transfer_date) AS last_transfer_date
FROM stock_transfers st
JOIN stock_transfer_details std
ON std.stock_transfer_id = st.id
AND std.deleted_at IS NULL
WHERE st.reason LIKE 'EGG_FARM_CUTOVER|run_id=%'
GROUP BY 1
ORDER BY first_transfer_date DESC, run_id DESC;
-- =====================================================================
-- AUDIT-08 Detailed summary per run_id
-- Replace <run_id> before running.
-- =====================================================================
SELECT
st.id AS transfer_id,
st.movement_number,
st.transfer_date,
ws.name AS source_warehouse_name,
wd.name AS farm_warehouse_name,
p.name AS product_name,
COALESCE(std.total_qty, std.usage_qty, 0) AS moved_qty,
st.deleted_at
FROM stock_transfers st
JOIN stock_transfer_details std
ON std.stock_transfer_id = st.id
AND std.deleted_at IS NULL
JOIN products p ON p.id = std.product_id
JOIN warehouses ws ON ws.id = st.from_warehouse_id
JOIN warehouses wd ON wd.id = st.to_warehouse_id
WHERE st.reason LIKE 'EGG_FARM_CUTOVER|run_id=<run_id>|%'
ORDER BY st.id, p.name;
-- =====================================================================
-- AUDIT-09 Downstream consumption check per run_id
-- Replace <run_id> before running.
-- =====================================================================
SELECT
st.id AS transfer_id,
st.movement_number,
p.name AS product_name,
sa.usable_type,
sa.usable_id,
sa.qty,
sa.function_code,
sa.flag_group_code
FROM stock_transfers st
JOIN stock_transfer_details std
ON std.stock_transfer_id = st.id
AND std.deleted_at IS NULL
JOIN products p ON p.id = std.product_id
JOIN stock_allocations sa
ON sa.stockable_type = 'STOCK_TRANSFER_IN'
AND sa.stockable_id = std.id
AND sa.status = 'ACTIVE'
AND sa.allocation_purpose = 'CONSUME'
AND sa.deleted_at IS NULL
WHERE st.deleted_at IS NULL
AND st.reason LIKE 'EGG_FARM_CUTOVER|run_id=<run_id>|%'
ORDER BY st.id, p.name, sa.usable_type, sa.usable_id;
-- =====================================================================
-- AUDIT-10 Stock log reconciliation per cutover transfer detail
-- Replace <run_id> before running.
-- =====================================================================
SELECT
st.id AS transfer_id,
st.movement_number,
p.name AS product_name,
std.id AS transfer_detail_id,
COALESCE(std.total_qty, std.usage_qty, 0) AS moved_qty,
SUM(CASE WHEN sl.decrease > 0 THEN sl.decrease ELSE 0 END) AS total_logged_out,
SUM(CASE WHEN sl.increase > 0 THEN sl.increase ELSE 0 END) AS total_logged_in
FROM stock_transfers st
JOIN stock_transfer_details std
ON std.stock_transfer_id = st.id
AND std.deleted_at IS NULL
JOIN products p ON p.id = std.product_id
LEFT JOIN stock_logs sl
ON sl.loggable_type = 'TRANSFER'
AND sl.loggable_id = std.id
WHERE st.deleted_at IS NULL
AND st.reason LIKE 'EGG_FARM_CUTOVER|run_id=<run_id>|%'
GROUP BY st.id, st.movement_number, p.name, std.id, COALESCE(std.total_qty, std.usage_qty, 0)
ORDER BY st.id, p.name;
-- =====================================================================
-- AUDIT-11 New recording eggs still posting to kandang after cutoff date
-- Replace values before running.
-- =====================================================================
SELECT
DATE(r.record_datetime) AS record_date,
l.name AS location_name,
kw.name AS kandang_warehouse_name,
p.name AS product_name,
re.qty
FROM recording_eggs re
JOIN recordings r ON r.id = re.recording_id
JOIN product_warehouses pw ON pw.id = re.product_warehouse_id
JOIN warehouses kw ON kw.id = pw.warehouse_id
JOIN locations l ON l.id = kw.location_id
JOIN products p ON p.id = pw.product_id
WHERE kw.type = 'KANDANG'
AND LOWER(l.name) = LOWER('<location_name>')
AND DATE(r.record_datetime) >= DATE('<cutover_date>')
ORDER BY r.record_datetime ASC, kw.name, p.name;
-- Expectation:
-- - after deploy and cutover, this should ideally return 0 rows for the location
-- =====================================================================
-- AUDIT-12 Combined kandang + farm egg stock per location after cutover
-- Replace <location_name> before running.
-- =====================================================================
SELECT
l.name AS location_name,
w.type AS warehouse_type,
p.name AS product_name,
SUM(COALESCE(pw.qty, 0)) AS total_qty
FROM product_warehouses pw
JOIN warehouses w ON w.id = pw.warehouse_id
JOIN locations l ON l.id = w.location_id
JOIN products p ON p.id = pw.product_id
LEFT JOIN product_categories pc ON pc.id = p.product_category_id
WHERE LOWER(l.name) = LOWER('<location_name>')
AND (
EXISTS (
SELECT 1 FROM flags f
WHERE f.flagable_type = 'products'
AND f.flagable_id = p.id
AND (UPPER(f.name) = 'TELUR' OR UPPER(f.name) LIKE 'TELUR-%')
)
OR (
NOT EXISTS (
SELECT 1 FROM flags f_any
WHERE f_any.flagable_type = 'products'
AND f_any.flagable_id = p.id
)
AND UPPER(COALESCE(pc.code, '')) = 'EGG'
)
)
GROUP BY l.name, w.type, p.name
ORDER BY w.type, p.name;
@@ -0,0 +1,400 @@
-- Legacy Egg Cutover Verification Checklist
-- Usage:
-- 1. Replace the values below before executing.
-- 2. Run section BEFORE before --apply.
-- 3. Run section AFTER after --apply.
-- 4. Run rollback checks if needed.
-- =====================================================================
-- PARAMETERS
-- =====================================================================
-- Replace manually before running.
-- Example:
-- location_name = Jamali
-- cutover_date = 2026-04-07
-- run_id = egg-cutover-20260407T130344.220407000Z
-- =====================================================================
-- BEFORE APPLY
-- =====================================================================
-- [BEFORE-01] Identify target location and farm warehouse
SELECT
l.id AS location_id,
l.name AS location_name,
fw.id AS farm_warehouse_id,
fw.name AS farm_warehouse_name
FROM locations l
LEFT JOIN warehouses fw
ON fw.location_id = l.id
AND fw.type = 'LOKASI'
AND fw.deleted_at IS NULL
WHERE LOWER(l.name) = LOWER('<location_name>')
ORDER BY fw.id ASC;
-- Expectation:
-- - exactly one target location
-- - at least one farm warehouse exists
-- [BEFORE-02] Verify location timing status (must be CLEAN_CUTOVER for phase 1)
WITH timing AS (
SELECT
pf.location_id AS location_id,
l.name AS location_name,
MIN(CASE WHEN w.type = 'KANDANG' THEN DATE(r.record_datetime) END) AS first_kandang_date,
MAX(CASE WHEN w.type = 'KANDANG' THEN DATE(r.record_datetime) END) AS last_kandang_date,
MIN(CASE WHEN w.type = 'LOKASI' THEN DATE(r.record_datetime) END) AS first_farm_date,
MAX(CASE WHEN w.type = 'LOKASI' THEN DATE(r.record_datetime) END) AS last_farm_date
FROM recording_eggs re
JOIN recordings r ON r.id = re.recording_id
JOIN project_flock_kandangs pk ON pk.id = COALESCE(re.project_flock_kandang_id, r.project_flock_kandangs_id)
JOIN project_flocks pf ON pf.id = pk.project_flock_id
JOIN locations l ON l.id = pf.location_id
JOIN product_warehouses pw ON pw.id = re.product_warehouse_id
JOIN warehouses w ON w.id = pw.warehouse_id
WHERE LOWER(l.name) = LOWER('<location_name>')
GROUP BY pf.location_id, l.name
)
SELECT
location_id,
location_name,
first_kandang_date,
last_kandang_date,
first_farm_date,
last_farm_date,
CASE
WHEN first_farm_date IS NULL THEN 'KANDANG_ONLY'
WHEN last_kandang_date IS NULL OR first_farm_date > last_kandang_date THEN 'CLEAN_CUTOVER'
ELSE 'OVERLAP'
END AS location_status
FROM timing;
-- Expectation:
-- - phase 1 location must be CLEAN_CUTOVER
-- [BEFORE-03] Candidate source rows that should be migrated
WITH first_farm AS (
SELECT location_id, MIN(id) AS farm_warehouse_id
FROM warehouses
WHERE type = 'LOKASI'
AND deleted_at IS NULL
GROUP BY location_id
)
SELECT
l.id AS location_id,
l.name AS location_name,
kw.id AS source_warehouse_id,
kw.name AS source_warehouse_name,
fw.id AS farm_warehouse_id,
fw.name AS farm_warehouse_name,
pw.id AS product_warehouse_id,
p.id AS product_id,
p.name AS product_name,
COALESCE(pw.qty, 0) AS on_hand_qty
FROM product_warehouses pw
JOIN warehouses kw
ON kw.id = pw.warehouse_id
AND kw.type = 'KANDANG'
AND kw.deleted_at IS NULL
JOIN locations l ON l.id = kw.location_id
JOIN products p ON p.id = pw.product_id
LEFT JOIN product_categories pc ON pc.id = p.product_category_id
LEFT JOIN first_farm ff ON ff.location_id = kw.location_id
LEFT JOIN warehouses fw ON fw.id = ff.farm_warehouse_id
WHERE LOWER(l.name) = LOWER('<location_name>')
AND EXISTS (
SELECT 1
FROM recording_eggs re
WHERE re.product_warehouse_id = pw.id
)
AND (
EXISTS (
SELECT 1
FROM flags f
WHERE f.flagable_type = 'products'
AND f.flagable_id = p.id
AND (UPPER(f.name) = 'TELUR' OR UPPER(f.name) LIKE 'TELUR-%')
)
OR (
NOT EXISTS (
SELECT 1
FROM flags f_any
WHERE f_any.flagable_type = 'products'
AND f_any.flagable_id = p.id
)
AND UPPER(COALESCE(pc.code, '')) = 'EGG'
)
)
AND COALESCE(pw.qty, 0) > 0
ORDER BY kw.name, p.name;
-- Expectation:
-- - every row here should match dry-run eligible rows
-- [BEFORE-04] Totals per source warehouse and product
WITH candidates AS (
SELECT
kw.name AS source_warehouse_name,
p.name AS product_name,
COALESCE(pw.qty, 0) AS on_hand_qty
FROM product_warehouses pw
JOIN warehouses kw
ON kw.id = pw.warehouse_id
AND kw.type = 'KANDANG'
AND kw.deleted_at IS NULL
JOIN locations l ON l.id = kw.location_id
JOIN products p ON p.id = pw.product_id
LEFT JOIN product_categories pc ON pc.id = p.product_category_id
WHERE LOWER(l.name) = LOWER('<location_name>')
AND EXISTS (
SELECT 1 FROM recording_eggs re WHERE re.product_warehouse_id = pw.id
)
AND (
EXISTS (
SELECT 1 FROM flags f
WHERE f.flagable_type = 'products'
AND f.flagable_id = p.id
AND (UPPER(f.name) = 'TELUR' OR UPPER(f.name) LIKE 'TELUR-%')
)
OR (
NOT EXISTS (
SELECT 1 FROM flags f_any
WHERE f_any.flagable_type = 'products'
AND f_any.flagable_id = p.id
)
AND UPPER(COALESCE(pc.code, '')) = 'EGG'
)
)
AND COALESCE(pw.qty, 0) > 0
)
SELECT
source_warehouse_name,
product_name,
SUM(on_hand_qty) AS total_qty
FROM candidates
GROUP BY source_warehouse_name, product_name
ORDER BY source_warehouse_name, product_name;
-- [BEFORE-05] Current farm egg stock before cutover
SELECT
fw.name AS farm_warehouse_name,
p.name AS product_name,
COALESCE(pw.qty, 0) AS farm_on_hand_qty
FROM warehouses fw
JOIN locations l ON l.id = fw.location_id
JOIN product_warehouses pw ON pw.warehouse_id = fw.id
JOIN products p ON p.id = pw.product_id
LEFT JOIN product_categories pc ON pc.id = p.product_category_id
WHERE LOWER(l.name) = LOWER('<location_name>')
AND fw.type = 'LOKASI'
AND fw.deleted_at IS NULL
AND (
EXISTS (
SELECT 1 FROM flags f
WHERE f.flagable_type = 'products'
AND f.flagable_id = p.id
AND (UPPER(f.name) = 'TELUR' OR UPPER(f.name) LIKE 'TELUR-%')
)
OR (
NOT EXISTS (
SELECT 1 FROM flags f_any
WHERE f_any.flagable_type = 'products'
AND f_any.flagable_id = p.id
)
AND UPPER(COALESCE(pc.code, '')) = 'EGG'
)
)
ORDER BY p.name;
-- [BEFORE-06] Existing cutover transfers for this location
SELECT
st.id,
st.movement_number,
st.transfer_date,
st.reason,
ws.name AS source_warehouse_name,
wd.name AS farm_warehouse_name,
st.deleted_at
FROM stock_transfers st
JOIN warehouses ws ON ws.id = st.from_warehouse_id
JOIN warehouses wd ON wd.id = st.to_warehouse_id
LEFT JOIN locations l ON l.id = COALESCE(ws.location_id, wd.location_id)
WHERE LOWER(COALESCE(l.name, '')) = LOWER('<location_name>')
AND st.reason LIKE 'EGG_FARM_CUTOVER|%'
ORDER BY st.id DESC;
-- Expectation:
-- - no unexpected older active cutover transfers for the same location
-- =====================================================================
-- AFTER APPLY
-- =====================================================================
-- [AFTER-01] Transfer headers created by run_id
SELECT
st.id,
st.movement_number,
st.transfer_date,
st.reason,
ws.name AS source_warehouse_name,
wd.name AS farm_warehouse_name,
st.deleted_at
FROM stock_transfers st
JOIN warehouses ws ON ws.id = st.from_warehouse_id
JOIN warehouses wd ON wd.id = st.to_warehouse_id
WHERE st.reason LIKE 'EGG_FARM_CUTOVER|run_id=<run_id>|%'
ORDER BY st.id ASC;
-- [AFTER-02] Transfer detail rows created by run_id
SELECT
st.id AS transfer_id,
st.movement_number,
ws.name AS source_warehouse_name,
wd.name AS farm_warehouse_name,
p.name AS product_name,
COALESCE(std.total_qty, std.usage_qty, 0) AS moved_qty,
std.source_product_warehouse_id,
std.dest_product_warehouse_id
FROM stock_transfers st
JOIN stock_transfer_details std
ON std.stock_transfer_id = st.id
AND std.deleted_at IS NULL
JOIN products p ON p.id = std.product_id
JOIN warehouses ws ON ws.id = st.from_warehouse_id
JOIN warehouses wd ON wd.id = st.to_warehouse_id
WHERE st.deleted_at IS NULL
AND st.reason LIKE 'EGG_FARM_CUTOVER|run_id=<run_id>|%'
ORDER BY st.id, p.name;
-- [AFTER-03] Stock logs created by run_id transfer details
SELECT
st.id AS transfer_id,
st.movement_number,
p.name AS product_name,
sl.product_warehouse_id,
sl.increase,
sl.decrease,
sl.stock,
sl.created_at
FROM stock_transfers st
JOIN stock_transfer_details std
ON std.stock_transfer_id = st.id
AND std.deleted_at IS NULL
JOIN products p ON p.id = std.product_id
JOIN stock_logs sl
ON sl.loggable_type = 'TRANSFER'
AND sl.loggable_id = std.id
WHERE st.deleted_at IS NULL
AND st.reason LIKE 'EGG_FARM_CUTOVER|run_id=<run_id>|%'
ORDER BY st.id, p.name, sl.id;
-- Expectation:
-- - every detail has one stock log decrease from source and one stock log increase to destination
-- [AFTER-04] Source rows after cutover
SELECT
kw.name AS source_warehouse_name,
p.name AS product_name,
COALESCE(pw.qty, 0) AS source_qty_after
FROM product_warehouses pw
JOIN warehouses kw ON kw.id = pw.warehouse_id
JOIN locations l ON l.id = kw.location_id
JOIN products p ON p.id = pw.product_id
WHERE LOWER(l.name) = LOWER('<location_name>')
AND kw.type = 'KANDANG'
AND EXISTS (
SELECT 1 FROM recording_eggs re WHERE re.product_warehouse_id = pw.id
)
ORDER BY kw.name, p.name;
-- Expectation:
-- - rows that were transferred should now be 0 or no longer available for use
-- [AFTER-05] Farm rows after cutover
SELECT
fw.name AS farm_warehouse_name,
p.name AS product_name,
COALESCE(pw.qty, 0) AS farm_qty_after
FROM product_warehouses pw
JOIN warehouses fw ON fw.id = pw.warehouse_id
JOIN locations l ON l.id = fw.location_id
JOIN products p ON p.id = pw.product_id
WHERE LOWER(l.name) = LOWER('<location_name>')
AND fw.type = 'LOKASI'
ORDER BY fw.name, p.name;
-- Expectation:
-- - farm qty increases by the moved amount
-- [AFTER-06] Reconciliation: total moved by run
SELECT
p.name AS product_name,
SUM(COALESCE(std.total_qty, std.usage_qty, 0)) AS total_moved_qty
FROM stock_transfers st
JOIN stock_transfer_details std
ON std.stock_transfer_id = st.id
AND std.deleted_at IS NULL
JOIN products p ON p.id = std.product_id
WHERE st.deleted_at IS NULL
AND st.reason LIKE 'EGG_FARM_CUTOVER|run_id=<run_id>|%'
GROUP BY p.name
ORDER BY p.name;
-- [AFTER-07] Farm stock available for SO after cutover
SELECT
fw.name AS farm_warehouse_name,
p.name AS product_name,
COALESCE(pw.qty, 0) AS available_qty
FROM product_warehouses pw
JOIN warehouses fw ON fw.id = pw.warehouse_id
JOIN locations l ON l.id = fw.location_id
JOIN products p ON p.id = pw.product_id
WHERE LOWER(l.name) = LOWER('<location_name>')
AND fw.type = 'LOKASI'
AND COALESCE(pw.qty, 0) > 0
ORDER BY p.name;
-- =====================================================================
-- ROLLBACK CHECKS
-- =====================================================================
-- [ROLLBACK-01] Check downstream consumption guard before rollback
SELECT
st.id AS transfer_id,
st.movement_number,
p.name AS product_name,
sa.usable_type,
sa.usable_id,
sa.qty,
sa.function_code,
sa.flag_group_code
FROM stock_transfers st
JOIN stock_transfer_details std
ON std.stock_transfer_id = st.id
AND std.deleted_at IS NULL
JOIN products p ON p.id = std.product_id
JOIN stock_allocations sa
ON sa.stockable_type = 'STOCK_TRANSFER_IN'
AND sa.stockable_id = std.id
AND sa.status = 'ACTIVE'
AND sa.allocation_purpose = 'CONSUME'
AND sa.deleted_at IS NULL
WHERE st.deleted_at IS NULL
AND st.reason LIKE 'EGG_FARM_CUTOVER|run_id=<run_id>|%'
ORDER BY st.id, p.name, sa.usable_type, sa.usable_id;
-- Expectation:
-- - rollback only safe if this query returns 0 rows
-- [ROLLBACK-02] Verify run is fully rolled back
SELECT
st.id,
st.movement_number,
st.deleted_at
FROM stock_transfers st
WHERE st.reason LIKE 'EGG_FARM_CUTOVER|run_id=<run_id>|%'
ORDER BY st.id;
-- Expectation:
-- - after rollback, deleted_at should be filled for all transfers in the run
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+93
View File
@@ -0,0 +1,93 @@
package apikeys
func DefaultDashboardPermissions() []string {
return []string{
"lti.approval.list",
"lti.closing.list",
"lti.closing.detail",
"lti.daily_checklist.create",
"lti.daily_checklist.dashboard.list",
"lti.daily_checklist.detail",
"lti.daily_checklist.list",
"lti.daily_checklist.master_data.activity",
"lti.daily_checklist.master_data.configuration",
"lti.daily_checklist.master_data.employee",
"lti.daily_checklist.reports",
"lti.dashboard.list",
"lti.expense.detail",
"lti.expense.list",
"lti.finance.initial_balances.detail",
"lti.finance.injections.detail",
"lti.finance.payments.detail",
"lti.finance.transactions.detail",
"lti.finance.transactions.list",
"lti.inventory.detail",
"lti.inventory.list",
"lti.inventory.product_stock.detail",
"lti.inventory.product_stock.list",
"lti.inventory.product_warehouses.detail",
"lti.inventory.product_warehouses.list",
"lti.inventory.transfer.detail",
"lti.inventory.transfer.list",
"lti.marketing.delivery_order.detail",
"lti.marketing.delivery_order.list",
"lti.master.area.detail",
"lti.master.area.list",
"lti.master.banks.detail",
"lti.master.banks.list",
"lti.master.customer.detail",
"lti.master.customer.list",
"lti.master.fcr.detail",
"lti.master.fcr.list",
"lti.master.flocks.detail",
"lti.master.flocks.list",
"lti.master.kandangs.detail",
"lti.master.kandangs.list",
"lti.master.locations.detail",
"lti.master.locations.list",
"lti.master.nonstocks.detail",
"lti.master.nonstocks.list",
"lti.master.product_categories.detail",
"lti.master.product_categories.list",
"lti.master.products.detail",
"lti.master.products.list",
"lti.master.production_standards.detail",
"lti.master.production_standards.list",
"lti.master.suppliers.detail",
"lti.master.suppliers.list",
"lti.master.uoms.detail",
"lti.master.uoms.list",
"lti.master.warehouses.detail",
"lti.master.warehouses.list",
"lti.production.chickins.detail",
"lti.production.project_flock_kandangs.closing.detail",
"lti.production.project_flock_kandangs.detail",
"lti.production.project_flock_kandangs.list",
"lti.production.project_flocks.detail",
"lti.production.project_flocks.list",
"lti.production.project_flocks.lookup",
"lti.production.project_flocks.next_period",
"lti.production.recording.detail",
"lti.production.recording.list",
"lti.production.recording.next_day",
"lti.production.transfer_to_laying.create",
"lti.production.transfer_to_laying.detail",
"lti.production.transfer_to_laying.getavailableqty",
"lti.production.transfer_to_laying.list",
"lti.production.uniformity.detail",
"lti.production.uniformity.list",
"lti.purchase.detail",
"lti.purchase.list",
"lti.repport.customerpayment.list",
"lti.repport.debtsupplier.list",
"lti.repport.delivery.list",
"lti.repport.expense.list",
"lti.repport.expense.depreciation.manage",
"lti.repport.gethppperkandang.list",
"lti.repport.production_result.list",
"lti.repport.purchasesupplier.list",
"lti.users.detail",
"lti.users.list",
"lti.daily_checklist.master_data.kandang",
}
}
+107
View File
@@ -0,0 +1,107 @@
package apikeys
import (
"context"
"errors"
"time"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
"gorm.io/gorm"
)
type Repository interface {
Create(ctx context.Context, record *entity.IntegrationAPIKey) error
GetByEnvironmentAndPrefix(ctx context.Context, environment, prefix string) (*entity.IntegrationAPIKey, error)
List(ctx context.Context, environment string) ([]entity.IntegrationAPIKey, error)
Revoke(ctx context.Context, environment, prefix string, revokedAt time.Time) error
TouchLastUsed(ctx context.Context, id uint, usedAt time.Time, usedFrom string) error
}
type repository struct {
db *gorm.DB
}
func NewRepository(db *gorm.DB) Repository {
return &repository{db: db}
}
func (r *repository) Create(ctx context.Context, record *entity.IntegrationAPIKey) error {
if r.db == nil {
return errors.New("database not configured")
}
return r.db.WithContext(ctx).Create(record).Error
}
func (r *repository) GetByEnvironmentAndPrefix(ctx context.Context, environment, prefix string) (*entity.IntegrationAPIKey, error) {
if r.db == nil {
return nil, errors.New("database not configured")
}
var record entity.IntegrationAPIKey
if err := r.db.WithContext(ctx).
Where("environment = ?", environment).
Where("key_prefix = ?", prefix).
First(&record).Error; err != nil {
return nil, err
}
return &record, nil
}
func (r *repository) List(ctx context.Context, environment string) ([]entity.IntegrationAPIKey, error) {
if r.db == nil {
return nil, errors.New("database not configured")
}
query := r.db.WithContext(ctx).Model(&entity.IntegrationAPIKey{})
if environment != "" {
query = query.Where("environment = ?", environment)
}
var records []entity.IntegrationAPIKey
if err := query.Order("environment ASC").Order("name ASC").Find(&records).Error; err != nil {
return nil, err
}
return records, nil
}
func (r *repository) Revoke(ctx context.Context, environment, prefix string, revokedAt time.Time) error {
if r.db == nil {
return errors.New("database not configured")
}
updates := map[string]any{
"status": entity.IntegrationAPIKeyStatusRevoked,
"revoked_at": revokedAt,
"updated_at": revokedAt,
}
result := r.db.WithContext(ctx).
Model(&entity.IntegrationAPIKey{}).
Where("environment = ?", environment).
Where("key_prefix = ?", prefix).
Updates(updates)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return gorm.ErrRecordNotFound
}
return nil
}
func (r *repository) TouchLastUsed(ctx context.Context, id uint, usedAt time.Time, usedFrom string) error {
if r.db == nil {
return errors.New("database not configured")
}
return r.db.WithContext(ctx).
Model(&entity.IntegrationAPIKey{}).
Where("id = ?", id).
Updates(map[string]any{
"last_used_at": usedAt,
"last_used_from": usedFrom,
"updated_at": usedAt,
}).Error
}
+233
View File
@@ -0,0 +1,233 @@
package apikeys
import (
"context"
"crypto/rand"
"encoding/base32"
"errors"
"fmt"
"strings"
"time"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
"gitlab.com/mbugroup/lti-api.git/internal/utils/secure"
"gorm.io/gorm"
)
var (
ErrInvalidAPIKey = errors.New("invalid api key")
ErrInactiveKey = errors.New("inactive api key")
)
type Principal struct {
ID uint
Name string
Environment string
Permissions []string
AllArea bool
AreaIDs []uint
AllLocation bool
LocationIDs []uint
}
type Authenticator interface {
Authenticate(ctx context.Context, rawKey, source string) (*Principal, error)
}
type Service interface {
Authenticator
Create(ctx context.Context, input CreateInput) (*IssuedKey, error)
List(ctx context.Context, environment string) ([]entity.IntegrationAPIKey, error)
Revoke(ctx context.Context, environment, prefix string) error
}
type CreateInput struct {
Name string
Environment string
PermissionCodes []string
AllArea bool
AreaIDs []uint
AllLocation bool
LocationIDs []uint
}
type IssuedKey struct {
Key string
Record *entity.IntegrationAPIKey
}
type service struct {
repo Repository
now func() time.Time
}
func NewService(db *gorm.DB) Service {
return &service{
repo: NewRepository(db),
now: time.Now,
}
}
func (s *service) Authenticate(ctx context.Context, rawKey, source string) (*Principal, error) {
environment, prefix, secret, err := parseRawKey(rawKey)
if err != nil {
return nil, ErrInvalidAPIKey
}
record, err := s.repo.GetByEnvironmentAndPrefix(ctx, environment, prefix)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrInvalidAPIKey
}
return nil, err
}
if !strings.EqualFold(record.Status, entity.IntegrationAPIKeyStatusActive) || record.RevokedAt != nil {
return nil, ErrInactiveKey
}
if !secure.Verify(record.KeyHash, secret) {
return nil, ErrInvalidAPIKey
}
usedAt := s.now().UTC()
if err := s.repo.TouchLastUsed(ctx, record.ID, usedAt, strings.TrimSpace(source)); err != nil {
utils.Log.WithError(err).Warn("api key: failed to update last_used fields")
}
return &Principal{
ID: record.ID,
Name: record.Name,
Environment: record.Environment,
Permissions: canonicalPermissions(record.PermissionCodes),
AllArea: record.AllArea,
AreaIDs: uniqueUint(record.AreaIDs),
AllLocation: record.AllLocation,
LocationIDs: uniqueUint(record.LocationIDs),
}, nil
}
func (s *service) Create(ctx context.Context, input CreateInput) (*IssuedKey, error) {
name := strings.TrimSpace(input.Name)
environment := strings.ToLower(strings.TrimSpace(input.Environment))
if name == "" || environment == "" {
return nil, fmt.Errorf("name and environment are required")
}
prefix, err := randomToken(10)
if err != nil {
return nil, err
}
secret, err := randomToken(24)
if err != nil {
return nil, err
}
hash, err := secure.Hash(secret, nil)
if err != nil {
return nil, err
}
record := &entity.IntegrationAPIKey{
Name: name,
Environment: environment,
Status: entity.IntegrationAPIKeyStatusActive,
KeyPrefix: prefix,
KeyHash: hash,
PermissionCodes: canonicalPermissions(input.PermissionCodes),
AllArea: input.AllArea,
AreaIDs: uniqueUint(input.AreaIDs),
AllLocation: input.AllLocation,
LocationIDs: uniqueUint(input.LocationIDs),
}
if err := s.repo.Create(ctx, record); err != nil {
return nil, err
}
return &IssuedKey{
Key: fmt.Sprintf("lti_%s_%s_%s", environment, prefix, secret),
Record: record,
}, nil
}
func (s *service) List(ctx context.Context, environment string) ([]entity.IntegrationAPIKey, error) {
return s.repo.List(ctx, strings.ToLower(strings.TrimSpace(environment)))
}
func (s *service) Revoke(ctx context.Context, environment, prefix string) error {
environment = strings.ToLower(strings.TrimSpace(environment))
prefix = strings.TrimSpace(prefix)
if environment == "" || prefix == "" {
return fmt.Errorf("environment and prefix are required")
}
return s.repo.Revoke(ctx, environment, prefix, s.now().UTC())
}
func parseRawKey(rawKey string) (environment string, prefix string, secret string, err error) {
rawKey = strings.TrimSpace(rawKey)
parts := strings.Split(rawKey, "_")
if len(parts) != 4 || parts[0] != "lti" {
return "", "", "", ErrInvalidAPIKey
}
environment = strings.ToLower(strings.TrimSpace(parts[1]))
prefix = strings.TrimSpace(parts[2])
secret = strings.TrimSpace(parts[3])
if environment == "" || prefix == "" || secret == "" {
return "", "", "", ErrInvalidAPIKey
}
return environment, prefix, secret, nil
}
func randomToken(size int) (string, error) {
buf := make([]byte, size)
if _, err := rand.Read(buf); err != nil {
return "", err
}
encoder := base32.StdEncoding.WithPadding(base32.NoPadding)
return strings.ToLower(encoder.EncodeToString(buf)), nil
}
func canonicalPermissions(perms []string) []string {
if len(perms) == 0 {
return []string{}
}
seen := make(map[string]struct{}, len(perms))
result := make([]string, 0, len(perms))
for _, perm := range perms {
perm = strings.ToLower(strings.TrimSpace(perm))
if perm == "" {
continue
}
if _, ok := seen[perm]; ok {
continue
}
seen[perm] = struct{}{}
result = append(result, perm)
}
return result
}
func uniqueUint(values []uint) []uint {
if len(values) == 0 {
return []uint{}
}
seen := make(map[uint]struct{}, len(values))
result := make([]uint, 0, len(values))
for _, value := range values {
if value == 0 {
continue
}
if _, ok := seen[value]; ok {
continue
}
seen[value] = struct{}{}
result = append(result, value)
}
return result
}
@@ -2,6 +2,7 @@ package repository
import (
"context"
"errors"
"time"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
@@ -23,6 +24,7 @@ type HppCostRepository interface {
GetEggTerjualPiecesAndWeightKgByProjectFlockKandangIds(ctx context.Context, projectFlockKandangIDs []uint, startDate *time.Time, endDate *time.Time) (float64, float64, error)
GetProjectFlockIDByProjectFlockKandangID(ctx context.Context, projectFlockKandangId uint) (uint, error)
GetTransferSourceSummary(ctx context.Context, projectFlockKandangId uint) (uint, float64, error)
GetManualDepreciationCostByProjectFlockID(ctx context.Context, projectFlockId uint) (float64, error)
}
type HppRepositoryImpl struct {
@@ -48,12 +50,32 @@ func (r *HppRepositoryImpl) GetProjectFlockKandangIDs(ctx context.Context, proje
}
func (r *HppRepositoryImpl) GetDocCost(ctx context.Context, projectFlockKandangIDs []uint) (float64, error) {
stockablePurchase := fifo.StockableKeyPurchaseItems.String()
stockableAdjustment := fifo.StockableKeyAdjustmentIn.String()
usableProjectChickin := fifo.UsableKeyProjectChickin.String()
var total float64
err := r.db.WithContext(ctx).
Table("project_chickins AS pc").
Select("COALESCE(SUM(sa.qty * COALESCE(pi.price, 0)), 0)").
Joins("JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = pc.id AND sa.stockable_type = ? AND sa.status = ? AND sa.allocation_purpose = ?", fifo.UsableKeyProjectChickin.String(), fifo.StockableKeyPurchaseItems.String(), entity.StockAllocationStatusActive, entity.StockAllocationPurposeTraceChickin).
Joins("JOIN purchase_items AS pi ON pi.id = sa.stockable_id").
Select(`
COALESCE(SUM(sa.qty * CASE
WHEN sa.stockable_type = ? THEN COALESCE(pi.price, 0)
WHEN sa.stockable_type = ? THEN COALESCE(ast.price, 0)
ELSE 0
END), 0)`,
stockablePurchase,
stockableAdjustment,
).
Joins(
"JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = pc.id AND (sa.stockable_type = ? OR sa.stockable_type = ?) AND sa.status = ? AND sa.allocation_purpose = ?",
usableProjectChickin,
stockablePurchase,
stockableAdjustment,
entity.StockAllocationStatusActive,
entity.StockAllocationPurposeTraceChickin,
).
Joins("LEFT JOIN purchase_items AS pi ON pi.id = sa.stockable_id AND sa.stockable_type = ?", stockablePurchase).
Joins("LEFT JOIN adjustment_stocks AS ast ON ast.id = sa.stockable_id AND sa.stockable_type = ?", stockableAdjustment).
Where("pc.project_flock_kandang_id IN (?)", projectFlockKandangIDs).
Scan(&total).Error
if err != nil {
@@ -85,7 +107,7 @@ func (r *HppRepositoryImpl) GetExpedisionCost(ctx context.Context, projectFlockK
Joins("JOIN expense_realizations AS er ON er.expense_nonstock_id = en.id").
Joins("JOIN flags AS f ON f.flagable_id = en.nonstock_id AND f.flagable_type = ?", entity.FlagableTypeNonstock).
Where("en.project_flock_kandang_id IN (?)", projectFlockKandangIDs).
Where("f.name = ?", utils.FlagEkspedisi).
// Where("f.name = ?", utils.FlagEkspedisi).
Scan(&total).Error
if err != nil {
return 0, err
@@ -100,15 +122,35 @@ func (r *HppRepositoryImpl) GetFeedUsageCost(ctx context.Context, projectFlockKa
date = &now
}
stockablePurchase := fifo.StockableKeyPurchaseItems.String()
stockableAdjustment := fifo.StockableKeyAdjustmentIn.String()
usableRecordingStock := fifo.UsableKeyRecordingStock.String()
var total float64
err := r.db.WithContext(ctx).
Table("recordings AS r").
Select("COALESCE(SUM(sa.qty * COALESCE(pi.price, 0)), 0)").
Select(`
COALESCE(SUM(sa.qty * CASE
WHEN sa.stockable_type = ? THEN COALESCE(pi.price, 0)
WHEN sa.stockable_type = ? THEN COALESCE(ast.price, 0)
ELSE 0
END), 0)`,
stockablePurchase,
stockableAdjustment,
).
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 = ? AND sa.status = ? AND sa.allocation_purpose = ?", fifo.UsableKeyRecordingStock.String(), fifo.StockableKeyPurchaseItems.String(), entity.StockAllocationStatusActive, entity.StockAllocationPurposeConsume).
Joins("JOIN purchase_items AS pi ON pi.id = sa.stockable_id").
Joins(
"JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = rs.id AND (sa.stockable_type = ? OR sa.stockable_type = ?) AND sa.status = ? AND sa.allocation_purpose = ?",
usableRecordingStock,
stockablePurchase,
stockableAdjustment,
entity.StockAllocationStatusActive,
entity.StockAllocationPurposeConsume,
).
Joins("LEFT JOIN purchase_items AS pi ON pi.id = sa.stockable_id AND sa.stockable_type = ?", stockablePurchase).
Joins("LEFT JOIN adjustment_stocks AS ast ON ast.id = sa.stockable_id AND sa.stockable_type = ?", stockableAdjustment).
Where("r.project_flock_kandangs_id IN (?)", projectFlockKandangIDs).
Where("r.record_datetime <= ?", *date).
Where("f.name = ?", utils.FlagPakan).
@@ -132,15 +174,34 @@ func (r *HppRepositoryImpl) GetOvkUsageCost(ctx context.Context, projectFlockKan
utils.FlagVitamin,
utils.FlagKimia,
}
stockablePurchase := fifo.StockableKeyPurchaseItems.String()
stockableAdjustment := fifo.StockableKeyAdjustmentIn.String()
usableRecordingStock := fifo.UsableKeyRecordingStock.String()
var total float64
err := r.db.WithContext(ctx).
Table("recordings AS r").
Select("COALESCE(SUM(sa.qty * COALESCE(pi.price, 0)), 0)").
Select(`
COALESCE(SUM(sa.qty * CASE
WHEN sa.stockable_type = ? THEN COALESCE(pi.price, 0)
WHEN sa.stockable_type = ? THEN COALESCE(ast.price, 0)
ELSE 0
END), 0)`,
stockablePurchase,
stockableAdjustment,
).
Joins("JOIN recording_stocks AS rs ON rs.recording_id = r.id").
Joins("JOIN product_warehouses AS pw ON pw.id = rs.product_warehouse_id").
Joins("JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = rs.id AND sa.stockable_type = ? AND sa.status = ? AND sa.allocation_purpose = ?", fifo.UsableKeyRecordingStock.String(), fifo.StockableKeyPurchaseItems.String(), entity.StockAllocationStatusActive, entity.StockAllocationPurposeConsume).
Joins("JOIN purchase_items AS pi ON pi.id = sa.stockable_id").
Joins(
"JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = rs.id AND (sa.stockable_type = ? OR sa.stockable_type = ?) AND sa.status = ? AND sa.allocation_purpose = ?",
usableRecordingStock,
stockablePurchase,
stockableAdjustment,
entity.StockAllocationStatusActive,
entity.StockAllocationPurposeConsume,
).
Joins("LEFT JOIN purchase_items AS pi ON pi.id = sa.stockable_id AND sa.stockable_type = ?", stockablePurchase).
Joins("LEFT JOIN adjustment_stocks AS ast ON ast.id = sa.stockable_id AND sa.stockable_type = ?", stockableAdjustment).
Where("r.project_flock_kandangs_id IN (?)", projectFlockKandangIDs).
Where("r.record_datetime <= ?", *date).
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).
@@ -169,22 +230,28 @@ func (r *HppRepositoryImpl) GetTotalPopulation(ctx context.Context, projectFlock
func (r *HppRepositoryImpl) GetPulletCost(ctx context.Context, projectFlockKandangId uint) (float64, error) {
stockablePurchase := fifo.StockableKeyPurchaseItems.String()
stockableTransferIn := fifo.StockableKeyStockTransferIn.String()
stockableAdjustment := fifo.StockableKeyAdjustmentIn.String()
usableProjectChickin := fifo.UsableKeyProjectChickin.String()
var total float64
err := r.db.WithContext(ctx).
Table("project_chickins AS pc").
Select(`
COALESCE(SUM(sa.qty * CASE
WHEN sa.stockable_type = ? THEN COALESCE(pi.price, 0)
WHEN sa.stockable_type = ? THEN COALESCE(tpi.price, 0)
ELSE 0
END), 0)`,
stockablePurchase, stockableTransferIn).
COALESCE(SUM(sa.qty * CASE
WHEN sa.stockable_type = ? THEN COALESCE(pi.price, 0)
WHEN sa.stockable_type = ? THEN COALESCE(tpi.price, 0)
WHEN sa.stockable_type = ? THEN COALESCE(ast.price, 0)
ELSE 0
END), 0)`,
stockablePurchase,
stockableTransferIn,
stockableAdjustment,
).
Joins("JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = pc.id AND sa.status = ? AND sa.allocation_purpose = ?", usableProjectChickin, entity.StockAllocationStatusActive, entity.StockAllocationPurposeTraceChickin).
Joins("LEFT JOIN purchase_items AS pi ON pi.id = sa.stockable_id AND sa.stockable_type = ?", stockablePurchase).
Joins("LEFT JOIN stock_allocations AS tsa ON tsa.usable_type = ? AND tsa.usable_id = sa.stockable_id AND sa.stockable_type = ? AND tsa.stockable_type = ? AND tsa.status = ? AND tsa.allocation_purpose = ?", stockableTransferIn, stockableTransferIn, stockablePurchase, entity.StockAllocationStatusActive, entity.StockAllocationPurposeConsume).
Joins("LEFT JOIN purchase_items AS tpi ON tpi.id = tsa.stockable_id").
Joins("LEFT JOIN adjustment_stocks AS ast ON ast.id = sa.stockable_id AND sa.stockable_type = ?", stockableAdjustment).
Where("pc.project_flock_kandang_id = ?", projectFlockKandangId).
Scan(&total).Error
if err != nil {
@@ -215,6 +282,33 @@ func (r *HppRepositoryImpl) GetEggProduksiPiecesAndWeightKgByProjectFlockKandang
return 0, 0, err
}
var adjustmentTotalWeight float64
adjustmentSubQuery := r.db.WithContext(ctx).
Table("recordings AS r").
Select("DISTINCT ast.id AS adjustment_id, ast.price AS price").
Joins("JOIN recording_eggs AS re ON re.recording_id = r.id").
Joins("JOIN stock_transfer_details AS std ON std.dest_product_warehouse_id = re.product_warehouse_id").
Joins(
"JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = std.id AND sa.stockable_type = ? AND sa.status = ? AND sa.allocation_purpose = ?",
fifo.UsableKeyStockTransferOut.String(),
fifo.StockableKeyAdjustmentIn.String(),
entity.StockAllocationStatusActive,
entity.StockAllocationPurposeConsume,
).
Joins("JOIN adjustment_stocks AS ast ON ast.id = sa.stockable_id AND ast.product_warehouse_id = std.source_product_warehouse_id").
Where("r.project_flock_kandangs_id IN (?)", projectFlockKandangIDs).
Where("r.record_datetime <= ?", *date)
err = r.db.WithContext(ctx).
Table("(?) AS adjustment_sources", adjustmentSubQuery).
Select("COALESCE(SUM(adjustment_sources.price), 0)").
Scan(&adjustmentTotalWeight).Error
if err != nil {
return 0, 0, err
}
totals.TotalWeightKg += adjustmentTotalWeight
return totals.TotalPieces, totals.TotalWeightKg, nil
}
@@ -311,3 +405,25 @@ func (r *HppRepositoryImpl) GetTransferSourceSummary(ctx context.Context, projec
return summary.ProjectFlockID, summary.TotalQty, nil
}
func (r *HppRepositoryImpl) GetManualDepreciationCostByProjectFlockID(ctx context.Context, projectFlockId uint) (float64, error) {
type row struct {
TotalCost float64
}
var selected row
err := r.db.WithContext(ctx).
Table("farm_depreciation_manual_inputs").
Select("total_cost").
Where("project_flock_id = ?", projectFlockId).
Limit(1).
Take(&selected).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return 0, nil
}
if err != nil {
return 0, err
}
return selected.TotalCost, nil
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,248 @@
package repository
import (
"context"
"math"
"testing"
"time"
"github.com/glebarez/sqlite"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
"gitlab.com/mbugroup/lti-api.git/internal/utils/fifo"
"gorm.io/gorm"
)
func TestHppV2RepositoryGetEggProduksiIncludesTransferredAdjustmentStock(t *testing.T) {
db := setupHppV2RepositoryTestDB(t)
mustExecHppV2(t, db,
`INSERT INTO recordings (id, project_flock_kandangs_id, record_datetime) VALUES (1, 101, '2026-04-19 10:00:00')`,
`INSERT INTO recording_eggs (id, recording_id, product_warehouse_id, qty, weight, project_flock_kandang_id) VALUES (1, 1, 401, 80, 8, 101)`,
`INSERT INTO stock_transfers (id, transfer_date) VALUES (1, '2026-04-18 08:00:00')`,
`INSERT INTO stock_transfer_details (id, stock_transfer_id, source_product_warehouse_id, dest_product_warehouse_id) VALUES (1, 1, 301, 401)`,
`INSERT INTO stock_allocations (id, usable_type, usable_id, stockable_type, stockable_id, status, allocation_purpose) VALUES (1, 'STOCK_TRANSFER_OUT', 1, 'ADJUSTMENT_IN', 501, 'ACTIVE', 'CONSUME')`,
`INSERT INTO adjustment_stocks (id, product_warehouse_id, total_qty, price, created_at) VALUES (501, 301, 20, 2.5, '2026-04-18 07:30:00')`,
)
repo := &HppV2RepositoryImpl{db: db}
endDate := mustJakartaTime(t, "2026-04-20 00:00:00")
totalPieces, totalWeightKg, err := repo.GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIds(context.Background(), []uint{101}, &endDate)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
assertFloatEquals(t, totalPieces, 100)
assertFloatEquals(t, totalWeightKg, 10.5)
}
func TestHppV2RepositoryGetEggTerjualUsesEndDateForSameDayFarmSales(t *testing.T) {
db := setupHppV2RepositoryTestDB(t)
mustExecHppV2(t, db,
`INSERT INTO kandangs (id, location_id) VALUES (1, 10), (2, 10)`,
`INSERT INTO project_flock_kandangs (id, kandang_id) VALUES (101, 1), (102, 2)`,
`INSERT INTO warehouses (id, type, location_id) VALUES (201, 'LOKASI', 10)`,
`INSERT INTO product_warehouses (id, warehouse_id, product_id, project_flock_kandang_id) VALUES (301, 201, 900, NULL)`,
`INSERT INTO flags (id, flagable_type, flagable_id, name) VALUES (1, 'products', 900, 'TELUR')`,
`INSERT INTO recordings (id, project_flock_kandangs_id, record_datetime, deleted_at) VALUES (1, 101, '2026-04-19 08:00:00', NULL), (2, 102, '2026-04-19 09:00:00', NULL)`,
`INSERT INTO recording_eggs (id, recording_id, product_warehouse_id, qty, weight, project_flock_kandang_id) VALUES (1, 1, 301, 60, 6, 101), (2, 2, 301, 40, 4, 102)`,
`INSERT INTO marketing_products (id, product_warehouse_id) VALUES (401, 301)`,
`INSERT INTO marketing_delivery_products (id, marketing_product_id, usage_qty, total_weight, delivery_date) VALUES (501, 401, 50, 5, '2026-04-19 12:00:00')`,
)
repo := &HppV2RepositoryImpl{db: db}
startDate := mustJakartaTime(t, "2026-04-19 00:00:00")
endDate := mustJakartaTime(t, "2026-04-20 00:00:00")
totalPieces, totalWeightKg, err := repo.GetEggTerjualPiecesAndWeightKgByProjectFlockKandangIds(context.Background(), []uint{101}, &startDate, &endDate)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
assertFloatEquals(t, totalPieces, 30)
assertFloatEquals(t, totalWeightKg, 3)
}
func TestHppV2RepositoryGetEggTerjualProratesHistoricalFarmSalesFromAdjustments(t *testing.T) {
db := setupHppV2RepositoryTestDB(t)
mustExecHppV2(t, db,
`INSERT INTO kandangs (id, location_id) VALUES (1, 10), (2, 10)`,
`INSERT INTO project_flock_kandangs (id, kandang_id) VALUES (101, 1), (102, 2)`,
`INSERT INTO warehouses (id, type, location_id) VALUES (201, 'LOKASI', 10), (211, 'KANDANG', 10), (212, 'KANDANG', 10)`,
`INSERT INTO product_warehouses (id, warehouse_id, product_id, project_flock_kandang_id) VALUES (301, 201, 900, NULL), (311, 211, 900, 101), (312, 212, 900, 102)`,
`INSERT INTO flags (id, flagable_type, flagable_id, name) VALUES (1, 'products', 900, 'TELUR')`,
`INSERT INTO stock_transfers (id, transfer_date) VALUES (1, '2026-04-18 08:00:00'), (2, '2026-04-18 08:15:00')`,
`INSERT INTO stock_transfer_details (id, stock_transfer_id, source_product_warehouse_id, dest_product_warehouse_id) VALUES (1, 1, 311, 301), (2, 2, 312, 301)`,
`INSERT INTO adjustment_stocks (id, product_warehouse_id, usage_qty, price, function_code, transaction_type, created_at) VALUES
(801, 311, 70, 7, 'RECORDING_EGG_IN', 'RECORDING', '2026-04-18 07:00:00'),
(802, 312, 30, 3, 'RECORDING_EGG_IN', 'RECORDING', '2026-04-18 07:30:00')`,
`INSERT INTO marketing_products (id, product_warehouse_id) VALUES (401, 301)`,
`INSERT INTO marketing_delivery_products (id, marketing_product_id, usage_qty, total_weight, delivery_date) VALUES (501, 401, 20, 2, '2026-04-19 12:00:00')`,
)
repo := &HppV2RepositoryImpl{db: db}
startDate := mustJakartaTime(t, "2026-04-19 00:00:00")
endDate := mustJakartaTime(t, "2026-04-20 00:00:00")
totalPieces, totalWeightKg, err := repo.GetEggTerjualPiecesAndWeightKgByProjectFlockKandangIds(context.Background(), []uint{101}, &startDate, &endDate)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
assertFloatEquals(t, totalPieces, 14)
assertFloatEquals(t, totalWeightKg, 1.4)
}
func setupHppV2RepositoryTestDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=private"), &gorm.Config{})
if err != nil {
t.Fatalf("failed opening sqlite db: %v", err)
}
mustExecHppV2(t, db,
`CREATE TABLE recordings (
id INTEGER PRIMARY KEY,
project_flock_kandangs_id INTEGER NULL,
record_datetime DATETIME NULL,
deleted_at DATETIME NULL
)`,
`CREATE TABLE recording_eggs (
id INTEGER PRIMARY KEY,
recording_id INTEGER NULL,
product_warehouse_id INTEGER NULL,
qty NUMERIC(15,3) NULL,
weight NUMERIC(15,3) NULL,
project_flock_kandang_id INTEGER NULL
)`,
`CREATE TABLE stock_transfers (
id INTEGER PRIMARY KEY,
transfer_date DATETIME NULL
)`,
`CREATE TABLE stock_transfer_details (
id INTEGER PRIMARY KEY,
stock_transfer_id INTEGER NULL,
source_product_warehouse_id INTEGER NULL,
dest_product_warehouse_id INTEGER NULL
)`,
`CREATE TABLE stock_allocations (
id INTEGER PRIMARY KEY,
usable_type TEXT NULL,
usable_id INTEGER NULL,
stockable_type TEXT NULL,
stockable_id INTEGER NULL,
status TEXT NULL,
allocation_purpose TEXT NULL,
qty NUMERIC(15,3) NULL
)`,
`CREATE TABLE adjustment_stocks (
id INTEGER PRIMARY KEY,
product_warehouse_id INTEGER NULL,
total_qty NUMERIC(15,3) NULL,
usage_qty NUMERIC(15,3) NULL,
price NUMERIC(15,3) NULL,
grand_total NUMERIC(15,3) NULL,
function_code TEXT NULL,
transaction_type TEXT NULL,
created_at DATETIME NULL
)`,
`CREATE TABLE kandangs (
id INTEGER PRIMARY KEY,
location_id INTEGER NULL
)`,
`CREATE TABLE project_flock_kandangs (
id INTEGER PRIMARY KEY,
kandang_id INTEGER NULL,
project_flock_id INTEGER NULL
)`,
`CREATE TABLE warehouses (
id INTEGER PRIMARY KEY,
type TEXT NULL,
location_id INTEGER NULL
)`,
`CREATE TABLE product_warehouses (
id INTEGER PRIMARY KEY,
warehouse_id INTEGER NULL,
product_id INTEGER NULL,
project_flock_kandang_id INTEGER NULL
)`,
`CREATE TABLE marketing_products (
id INTEGER PRIMARY KEY,
product_warehouse_id INTEGER NULL
)`,
`CREATE TABLE marketing_delivery_products (
id INTEGER PRIMARY KEY,
marketing_product_id INTEGER NULL,
usage_qty NUMERIC(15,3) NULL,
total_weight NUMERIC(15,3) NULL,
delivery_date DATETIME NULL
)`,
`CREATE TABLE flags (
id INTEGER PRIMARY KEY,
flagable_type TEXT NULL,
flagable_id INTEGER NULL,
name TEXT NULL
)`,
)
return db
}
func mustExecHppV2(t *testing.T, db *gorm.DB, statements ...string) {
t.Helper()
for _, statement := range statements {
if err := db.Exec(statement).Error; err != nil {
t.Fatalf("failed executing statement %q: %v", statement, err)
}
}
}
func mustJakartaTime(t *testing.T, raw string) time.Time {
t.Helper()
location, err := time.LoadLocation("Asia/Jakarta")
if err != nil {
t.Fatalf("failed loading timezone: %v", err)
}
value, err := time.ParseInLocation("2006-01-02 15:04:05", raw, location)
if err != nil {
t.Fatalf("failed parsing time %q: %v", raw, err)
}
return value
}
func assertFloatEquals(t *testing.T, got float64, want float64) {
t.Helper()
if math.Abs(got-want) > 0.000001 {
t.Fatalf("expected %.6f, got %.6f", want, got)
}
}
func TestHppV2RepositoryConstantsStayAlignedWithProductionQueries(t *testing.T) {
if fifo.UsableKeyStockTransferOut.String() != "STOCK_TRANSFER_OUT" {
t.Fatalf("unexpected stock transfer usable key: %s", fifo.UsableKeyStockTransferOut.String())
}
if fifo.StockableKeyAdjustmentIn.String() != "ADJUSTMENT_IN" {
t.Fatalf("unexpected adjustment stockable key: %s", fifo.StockableKeyAdjustmentIn.String())
}
if entity.StockAllocationStatusActive != "ACTIVE" {
t.Fatalf("unexpected active stock allocation status: %s", entity.StockAllocationStatusActive)
}
if entity.StockAllocationPurposeConsume != "CONSUME" {
t.Fatalf("unexpected consume stock allocation purpose: %s", entity.StockAllocationPurposeConsume)
}
if string(utils.AdjustmentTransactionSubtypeRecordingEggIn) != "RECORDING_EGG_IN" {
t.Fatalf("unexpected adjustment function code: %s", utils.AdjustmentTransactionSubtypeRecordingEggIn)
}
if string(utils.AdjustmentTransactionTypeRecording) != "RECORDING" {
t.Fatalf("unexpected adjustment transaction type: %s", utils.AdjustmentTransactionTypeRecording)
}
}
@@ -0,0 +1,104 @@
package service
import (
"strings"
"time"
)
const (
depreciationStartAgeDayCloseHouse = 155
depreciationStartAgeDayOpenHouse = 176
)
func NormalizeDepreciationHouseType(raw string) string {
return strings.TrimSpace(strings.ToLower(raw))
}
func DepreciationStartAgeDay(houseType string) int {
switch NormalizeDepreciationHouseType(houseType) {
case "close_house":
return depreciationStartAgeDayCloseHouse
case "open_house":
return depreciationStartAgeDayOpenHouse
default:
return 0
}
}
func FlockAgeDay(originDate time.Time, periodDate time.Time) int {
origin := time.Date(originDate.Year(), originDate.Month(), originDate.Day(), 0, 0, 0, 0, originDate.Location())
period := time.Date(periodDate.Year(), periodDate.Month(), periodDate.Day(), 0, 0, 0, 0, periodDate.Location())
if period.Before(origin) {
return 0
}
return int(period.Sub(origin).Hours()/24) + 1
}
func DepreciationScheduleDay(originDate time.Time, periodDate time.Time, houseType string) int {
ageDay := FlockAgeDay(originDate, periodDate)
startAgeDay := DepreciationStartAgeDay(houseType)
if ageDay <= 0 || startAgeDay <= 0 || ageDay < startAgeDay {
return 0
}
return ageDay - startAgeDay + 1
}
func CalculateDepreciationAtDayN(
initialPulletCost float64,
dayN int,
houseType string,
percentByHouseType map[string]map[int]float64,
) (float64, float64, float64) {
return CalculateDepreciationFromDayRange(initialPulletCost, 1, dayN, houseType, percentByHouseType)
}
func CalculateDepreciationFromDayRange(
initialPulletCost float64,
startDay int,
endDay int,
houseType string,
percentByHouseType map[string]map[int]float64,
) (float64, float64, float64) {
if initialPulletCost <= 0 || endDay <= 0 {
return 0, 0, 0
}
if startDay <= 0 {
startDay = 1
}
if endDay < startDay {
return 0, 0, 0
}
normalizedHouseType := NormalizeDepreciationHouseType(houseType)
housePercent, exists := percentByHouseType[normalizedHouseType]
if !exists {
return 0, 0, 0
}
current := initialPulletCost
pulletCostDayN := 0.0
depreciationValue := 0.0
depreciationPercent := 0.0
for day := startDay; day <= endDay; day++ {
pct := housePercent[day]
dep := current * (pct / 100)
if day == endDay {
pulletCostDayN = current
depreciationValue = dep
depreciationPercent = pct
}
current -= dep
if current < 0 {
current = 0
}
}
return pulletCostDayN, depreciationValue, depreciationPercent
}
func CalculateEffectiveDepreciationPercent(totalDepreciationValue, totalPulletCostDayN float64) float64 {
if totalPulletCostDayN <= 0 {
return 0
}
return (totalDepreciationValue / totalPulletCostDayN) * 100
}
@@ -0,0 +1,81 @@
package service
import (
"testing"
"time"
)
func TestDepreciationScheduleDay_UsesHouseTypeOffsets(t *testing.T) {
openOrigin := mustDepreciationDate(t, "2026-01-01")
if got := DepreciationScheduleDay(openOrigin, mustDepreciationDate(t, "2026-06-24"), "open_house"); got != 0 {
t.Fatalf("expected open house day before start to be 0, got %d", got)
}
if got := DepreciationScheduleDay(openOrigin, mustDepreciationDate(t, "2026-06-25"), "open_house"); got != 1 {
t.Fatalf("expected open house start day to map to schedule day 1, got %d", got)
}
closeOrigin := mustDepreciationDate(t, "2026-01-01")
if got := DepreciationScheduleDay(closeOrigin, mustDepreciationDate(t, "2026-06-03"), "close_house"); got != 0 {
t.Fatalf("expected close house day before start to be 0, got %d", got)
}
if got := DepreciationScheduleDay(closeOrigin, mustDepreciationDate(t, "2026-06-04"), "close_house"); got != 1 {
t.Fatalf("expected close house start day to map to schedule day 1, got %d", got)
}
}
func TestCalculateDepreciationAtDayN_UsesRemainingBasisRecursively(t *testing.T) {
percentByHouseType := map[string]map[int]float64{
"close_house": {
1: 10,
2: 20,
},
}
pulletCostDayN, depreciationValue, depreciationPercent := CalculateDepreciationAtDayN(1000, 2, "close_house", percentByHouseType)
if pulletCostDayN != 900 {
t.Fatalf("expected remaining basis entering day 2 to be 900, got %v", pulletCostDayN)
}
if depreciationValue != 180 {
t.Fatalf("expected day 2 depreciation to be 180, got %v", depreciationValue)
}
if depreciationPercent != 20 {
t.Fatalf("expected day 2 depreciation percent to be 20, got %v", depreciationPercent)
}
}
func TestCalculateDepreciationFromDayRange_StartsFromProvidedScheduleDay(t *testing.T) {
percentByHouseType := map[string]map[int]float64{
"close_house": {
1: 10,
2: 20,
3: 5,
},
}
pulletCostDayN, depreciationValue, depreciationPercent := CalculateDepreciationFromDayRange(1000, 2, 3, "close_house", percentByHouseType)
if pulletCostDayN != 800 {
t.Fatalf("expected remaining basis entering day 3 to be 800, got %v", pulletCostDayN)
}
if depreciationValue != 40 {
t.Fatalf("expected day 3 depreciation to be 40, got %v", depreciationValue)
}
if depreciationPercent != 5 {
t.Fatalf("expected day 3 depreciation percent to be 5, got %v", depreciationPercent)
}
}
func mustDepreciationDate(t *testing.T, raw string) time.Time {
t.Helper()
location, err := time.LoadLocation("Asia/Jakarta")
if err != nil {
t.Fatalf("failed loading timezone: %v", err)
}
value, err := time.ParseInLocation("2006-01-02", raw, location)
if err != nil {
t.Fatalf("failed parsing date %q: %v", raw, err)
}
return value
}
+121 -13
View File
@@ -46,6 +46,7 @@ func (s *hppService) CalculateHppCost(projectFlockKandangId uint, date *time.Tim
location, err := time.LoadLocation("Asia/Jakarta")
if err != nil {
return nil, err
}
@@ -54,16 +55,21 @@ func (s *hppService) CalculateHppCost(projectFlockKandangId uint, date *time.Tim
depresiasiTransfer, err := s.GetDepresiasiTransfer(projectFlockKandangId, &endOfDay)
if err != nil {
return nil, err
}
totalProductionCost, err := s.GetTotalProductionCost(projectFlockKandangId, &endOfDay, depresiasiTransfer)
if err != nil {
return nil, err
}
result, err := s.GetHppEstimationDanRealisasi(totalProductionCost, projectFlockKandangId, &startOfDay, &endOfDay)
if err != nil {
return s.GetHppEstimationDanRealisasi(totalProductionCost, projectFlockKandangId, &startOfDay, &endOfDay)
return nil, err
}
return result, nil
}
func (s *hppService) GetTotalDepresiasiFlockGrowing(sourceProjectFlockID uint, date *time.Time) (float64, error) {
@@ -73,40 +79,48 @@ func (s *hppService) GetTotalDepresiasiFlockGrowing(sourceProjectFlockID uint, d
}
if s.hppRepo == nil {
return 0, nil
}
kandangIDs, err := s.hppRepo.GetProjectFlockKandangIDs(context.Background(), sourceProjectFlockID)
if err != nil {
return 0, err
}
docCost, err := s.hppRepo.GetDocCost(context.Background(), kandangIDs)
if err != nil {
return 0, err
}
budgetCost, err := s.hppRepo.GetBudgetCostByProjectFlockId(context.Background(), sourceProjectFlockID)
if err != nil {
return 0, err
}
expedisionCost, err := s.hppRepo.GetExpedisionCost(context.Background(), kandangIDs)
if err != nil {
return 0, err
}
feedCost, err := s.hppRepo.GetFeedUsageCost(context.Background(), kandangIDs, date)
if err != nil {
return 0, err
}
ovkCost, err := s.hppRepo.GetOvkUsageCost(context.Background(), kandangIDs, date)
if err != nil {
return 0, err
}
return docCost + budgetCost + expedisionCost + feedCost + ovkCost, nil
total := docCost + budgetCost + expedisionCost + feedCost + ovkCost
return total, nil
}
func (s *hppService) GetTotalProductionCost(projectFlockKandangId uint, endDate *time.Time, depresiasiTransfer float64) (float64, error) {
@@ -117,30 +131,40 @@ func (s *hppService) GetTotalProductionCost(projectFlockKandangId uint, endDate
costPullet, err := s.hppRepo.GetPulletCost(context.Background(), projectFlockKandangId)
if err != nil {
return 0, err
}
costFeed, err := s.hppRepo.GetFeedUsageCost(context.Background(), []uint{projectFlockKandangId}, endDate)
if err != nil {
return 0, err
}
costOvk, err := s.hppRepo.GetOvkUsageCost(context.Background(), []uint{projectFlockKandangId}, endDate)
if err != nil {
return 0, err
}
costExpedision, err := s.hppRepo.GetExpedisionCost(context.Background(), []uint{projectFlockKandangId})
if err != nil {
return 0, err
}
costBudget, err := s.GetBudgetKandangLaying(projectFlockKandangId, endDate)
if err != nil {
return 0, err
}
return depresiasiTransfer + costPullet + costFeed + costOvk + costExpedision + costBudget, nil
// fmt.Println(costBudget, costExpedision, costOvk, costFeed, costPullet, depresiasiTransfer)
// depresiasiTransfer = 0
total := depresiasiTransfer + costPullet + costFeed + costOvk + costExpedision + costBudget
return total, nil
}
func (s *hppService) GetBudgetKandangLaying(projectFlockKandangId uint, endDate *time.Time) (float64, error) {
@@ -150,48 +174,57 @@ func (s *hppService) GetBudgetKandangLaying(projectFlockKandangId uint, endDate
// }
if s.hppRepo == nil {
return 0, nil
}
projectFlockId, err := s.hppRepo.GetProjectFlockIDByProjectFlockKandangID(context.Background(), projectFlockKandangId)
if err != nil {
return 0, err
}
projectFlockKandangIds, err := s.hppRepo.GetProjectFlockKandangIDs(context.Background(), projectFlockId)
if err != nil {
return 0, err
}
eggProduksiPiecesFlock, _, err := s.hppRepo.GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIds(context.Background(), projectFlockKandangIds, endDate)
if err != nil {
return 0, err
}
eggProduksiPiecesKandang, _, err := s.hppRepo.GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIds(context.Background(), []uint{projectFlockKandangId}, endDate)
if err != nil {
return 0, err
}
totalBudgetCost, err := s.hppRepo.GetBudgetCostByProjectFlockId(context.Background(), projectFlockId)
if err != nil {
return 0, err
}
if eggProduksiPiecesFlock == 0 {
return 0, nil
}
return (totalBudgetCost * eggProduksiPiecesKandang) / eggProduksiPiecesFlock, nil
result := (totalBudgetCost * eggProduksiPiecesKandang) / eggProduksiPiecesFlock
return result, nil
}
func (s *hppService) GetDepresiasiTransfer(projectFlockKandangId uint, endDate *time.Time) (float64, error) {
// if endDate == nil {
// now := time.Now()
// endDate = &now
// }
if endDate == nil {
now := time.Now()
endDate = &now
}
if s.hppRepo == nil {
return 0, nil
}
@@ -199,6 +232,13 @@ func (s *hppService) GetDepresiasiTransfer(projectFlockKandangId uint, endDate *
if err != nil {
return 0, err
}
if sourceProjectFlockID == 0 || transferTotalQty <= 0 {
result, fallbackErr := s.getManualDepresiasiTransferFallback(projectFlockKandangId)
if fallbackErr != nil {
return 0, fallbackErr
}
return result, nil
}
kandangIDsGrowing, err := s.hppRepo.GetProjectFlockKandangIDs(context.Background(), sourceProjectFlockID)
if err != nil {
@@ -218,22 +258,81 @@ func (s *hppService) GetDepresiasiTransfer(projectFlockKandangId uint, endDate *
return 0, err
}
return (totalDepresiasiFlockGrowing * transferTotalQty) / totalPopulationFlockGrowing, nil
result := (totalDepresiasiFlockGrowing * transferTotalQty) / totalPopulationFlockGrowing
return result, nil
}
func (s *hppService) getManualDepresiasiTransferFallback(projectFlockKandangId uint) (float64, error) {
projectFlockID, err := s.hppRepo.GetProjectFlockIDByProjectFlockKandangID(context.Background(), projectFlockKandangId)
if err != nil {
return 0, err
}
if projectFlockID == 0 {
return 0, nil
}
manualCost, err := s.hppRepo.GetManualDepreciationCostByProjectFlockID(context.Background(), projectFlockID)
if err != nil {
return 0, err
}
if manualCost <= 0 {
return 0, nil
}
kandangIDs, err := s.hppRepo.GetProjectFlockKandangIDs(context.Background(), projectFlockID)
if err != nil {
return 0, err
}
if len(kandangIDs) == 0 {
return 0, nil
}
totalUsageQty, err := s.hppRepo.GetTotalPopulation(context.Background(), kandangIDs)
if err != nil {
return 0, err
}
if totalUsageQty <= 0 {
return 0, nil
}
kandangUsageQty, err := s.hppRepo.GetTotalPopulation(context.Background(), []uint{projectFlockKandangId})
if err != nil {
return 0, err
}
if kandangUsageQty <= 0 {
return 0, nil
}
result := manualCost * (kandangUsageQty / totalUsageQty)
return result, nil
}
func (s *hppService) GetHppEstimationDanRealisasi(totalProductionCost float64, projectFlockKandangId uint, startDate *time.Time, endDate *time.Time) (*HppCostResponse, error) {
if s.hppRepo == nil {
return &HppCostResponse{}, nil
}
estimPieces, estimWeightKg, err := s.hppRepo.GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIds(context.Background(), []uint{projectFlockKandangId}, endDate)
if err != nil {
return nil, err
}
realPieces, realWeightKg, err := s.hppRepo.GetEggTerjualPiecesAndWeightKgByProjectFlockKandangIds(context.Background(), []uint{projectFlockKandangId}, startDate, endDate)
if err != nil {
return nil, err
}
@@ -261,12 +360,21 @@ func (s *hppService) GetHppEstimationDanRealisasi(totalProductionCost float64, p
real.HargaButir = roundToTwoDecimals(totalProductionCost / realPieces)
}
return &HppCostResponse{
result := &HppCostResponse{
Estimation: estimation,
Real: real,
}, nil
}
return result, nil
}
func roundToTwoDecimals(value float64) float64 {
return math.Round(value*100) / 100
result := math.Round(value*100) / 100
return result
}
func formatTimePtr(value *time.Time) string {
if value == nil {
return "<nil>"
}
return value.Format(time.RFC3339)
}
@@ -0,0 +1,61 @@
package service
type HppV2DateWindow struct {
Start string `json:"start"`
End string `json:"end"`
}
type HppV2Proration struct {
Basis string `json:"basis"`
Numerator float64 `json:"numerator"`
Denominator float64 `json:"denominator"`
Ratio float64 `json:"ratio"`
}
type HppV2Reference struct {
Type string `json:"type"`
ID uint `json:"id"`
StockableType string `json:"stockable_type,omitempty"`
ProjectFlockKandangID *uint `json:"project_flock_kandang_id,omitempty"`
ProductID uint `json:"product_id,omitempty"`
ProductName string `json:"product_name,omitempty"`
Date string `json:"date,omitempty"`
Qty float64 `json:"qty"`
UnitPrice float64 `json:"unit_price"`
Total float64 `json:"total"`
AppliedTotal float64 `json:"applied_total"`
}
type HppV2ComponentPart struct {
Code string `json:"code"`
Title string `json:"title"`
Scopes []string `json:"scopes,omitempty"`
Total float64 `json:"total"`
Proration *HppV2Proration `json:"proration,omitempty"`
Details map[string]any `json:"details,omitempty"`
References []HppV2Reference `json:"references,omitempty"`
}
type HppV2Component struct {
Code string `json:"code"`
Title string `json:"title"`
Scopes []string `json:"scopes,omitempty"`
Total float64 `json:"total"`
Parts []HppV2ComponentPart `json:"parts"`
}
type HppV2Breakdown struct {
ProjectFlockKandangID uint `json:"project_flock_kandang_id"`
ProjectFlockID uint `json:"project_flock_id"`
ProjectFlockCategory string `json:"project_flock_category,omitempty"`
HouseType string `json:"house_type,omitempty"`
KandangID uint `json:"kandang_id,omitempty"`
KandangName string `json:"kandang_name,omitempty"`
LocationID uint `json:"location_id,omitempty"`
PeriodDate string `json:"period_date"`
Window HppV2DateWindow `json:"window"`
TotalPulletCost float64 `json:"total_pullet_cost"`
TotalProductionCost float64 `json:"total_production_cost"`
Components []HppV2Component `json:"components"`
Hpp HppCostResponse `json:"hpp"`
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,872 @@
package service
import (
"context"
"fmt"
"sort"
"strings"
"testing"
"time"
commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
)
type hppV2RepoStub struct {
contextByPFK map[uint]*commonRepo.HppV2ProjectFlockKandangContext
pfkIDsByProject map[uint][]uint
latestTransferByPFK map[uint]*commonRepo.HppV2LatestTransferInputRow
manualInputByProject map[uint]*commonRepo.HppV2ManualDepreciationInputRow
snapshotByProjectKey map[string]*commonRepo.HppV2FarmDepreciationSnapshotRow
chickInDateByProject map[uint]*time.Time
depreciationByHouse map[string]map[int]float64
usageRowsByKey map[string][]commonRepo.HppV2UsageCostRow
adjustRowsByKey map[string][]commonRepo.HppV2AdjustmentCostRow
chickinRowsByKey map[string][]commonRepo.HppV2ChickinCostRow
expenseRowsByPFKKey map[string][]commonRepo.HppV2ExpenseCostRow
expenseRowsByFarmKey map[string][]commonRepo.HppV2ExpenseCostRow
totalPopulationByKey map[string]float64
transferSummaryByPFK map[uint]struct {
projectFlockID uint
totalQty float64
}
eggProductionByPFK map[uint]struct {
pieces float64
kg float64
}
eggSalesByPFK map[uint]struct {
pieces float64
kg float64
}
}
func (s *hppV2RepoStub) GetProjectFlockKandangContext(_ context.Context, projectFlockKandangId uint) (*commonRepo.HppV2ProjectFlockKandangContext, error) {
row := s.contextByPFK[projectFlockKandangId]
if row == nil {
return nil, fmt.Errorf("pfk %d not found", projectFlockKandangId)
}
return row, nil
}
func (s *hppV2RepoStub) GetProjectFlockKandangIDs(_ context.Context, projectFlockId uint) ([]uint, error) {
return append([]uint{}, s.pfkIDsByProject[projectFlockId]...), nil
}
func (s *hppV2RepoStub) GetLatestTransferInputByProjectFlockKandangID(_ context.Context, projectFlockKandangId uint, _ time.Time) (*commonRepo.HppV2LatestTransferInputRow, error) {
return s.latestTransferByPFK[projectFlockKandangId], nil
}
func (s *hppV2RepoStub) GetManualDepreciationInputByProjectFlockID(_ context.Context, projectFlockID uint) (*commonRepo.HppV2ManualDepreciationInputRow, error) {
return s.manualInputByProject[projectFlockID], nil
}
func (s *hppV2RepoStub) GetFarmDepreciationSnapshotByProjectFlockIDAndPeriod(_ context.Context, projectFlockID uint, periodDate time.Time) (*commonRepo.HppV2FarmDepreciationSnapshotRow, error) {
if s.snapshotByProjectKey == nil {
return nil, nil
}
return s.snapshotByProjectKey[fmt.Sprintf("%d|%s", projectFlockID, periodDate.Format("2006-01-02"))], nil
}
func (s *hppV2RepoStub) GetEarliestChickInDateByProjectFlockID(_ context.Context, projectFlockID uint) (*time.Time, error) {
return s.chickInDateByProject[projectFlockID], nil
}
func (s *hppV2RepoStub) GetDepreciationPercents(_ context.Context, houseTypes []string, maxDay int) (map[string]map[int]float64, error) {
result := make(map[string]map[int]float64)
for _, houseType := range houseTypes {
source := s.depreciationByHouse[houseType]
if len(source) == 0 {
continue
}
result[houseType] = make(map[int]float64)
for day, pct := range source {
if day <= maxDay {
result[houseType][day] = pct
}
}
}
return result, nil
}
func (s *hppV2RepoStub) ListUsageCostRowsByProductFlags(_ context.Context, projectFlockKandangIDs []uint, flagNames []string, _ *time.Time) ([]commonRepo.HppV2UsageCostRow, error) {
return append([]commonRepo.HppV2UsageCostRow{}, s.usageRowsByKey[stubKey(projectFlockKandangIDs, flagNames)]...), nil
}
func (s *hppV2RepoStub) ListAdjustmentCostRowsByProductFlags(_ context.Context, projectFlockKandangIDs []uint, flagNames []string, _ *time.Time) ([]commonRepo.HppV2AdjustmentCostRow, error) {
return append([]commonRepo.HppV2AdjustmentCostRow{}, s.adjustRowsByKey[stubKey(projectFlockKandangIDs, flagNames)]...), nil
}
func (s *hppV2RepoStub) ListExpenseRealizationRowsByProjectFlockKandangIDs(_ context.Context, projectFlockKandangIDs []uint, _ *time.Time, ekspedisi bool) ([]commonRepo.HppV2ExpenseCostRow, error) {
return append([]commonRepo.HppV2ExpenseCostRow{}, s.expenseRowsByPFKKey[expenseStubKey(projectFlockKandangIDs, ekspedisi)]...), nil
}
func (s *hppV2RepoStub) ListExpenseRealizationRowsByProjectFlockID(_ context.Context, projectFlockID uint, _ *time.Time, ekspedisi bool) ([]commonRepo.HppV2ExpenseCostRow, error) {
return append([]commonRepo.HppV2ExpenseCostRow{}, s.expenseRowsByFarmKey[expenseFarmKey(projectFlockID, ekspedisi)]...), nil
}
func (s *hppV2RepoStub) ListChickinCostRowsByProductFlags(_ context.Context, projectFlockKandangIDs []uint, flagNames []string, _ *time.Time, excludeTransferToLaying bool) ([]commonRepo.HppV2ChickinCostRow, error) {
return append([]commonRepo.HppV2ChickinCostRow{}, s.chickinRowsByKey[chickinStubKey(projectFlockKandangIDs, flagNames, excludeTransferToLaying)]...), nil
}
func (s *hppV2RepoStub) GetFeedUsageCost(_ context.Context, _ []uint, _ *time.Time) (float64, error) {
return 0, nil
}
func (s *hppV2RepoStub) GetTotalPopulation(_ context.Context, projectFlockKandangIDs []uint) (float64, error) {
return s.totalPopulationByKey[stubKey(projectFlockKandangIDs, nil)], nil
}
func (s *hppV2RepoStub) GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIds(_ context.Context, projectFlockKandangIDs []uint, _ *time.Time) (float64, float64, error) {
totalPieces := 0.0
totalKg := 0.0
for _, projectFlockKandangID := range projectFlockKandangIDs {
row := s.eggProductionByPFK[projectFlockKandangID]
totalPieces += row.pieces
totalKg += row.kg
}
return totalPieces, totalKg, nil
}
func (s *hppV2RepoStub) GetEggTerjualPiecesAndWeightKgByProjectFlockKandangIds(_ context.Context, projectFlockKandangIDs []uint, _ *time.Time, _ *time.Time) (float64, float64, error) {
if len(projectFlockKandangIDs) != 1 {
return 0, 0, nil
}
row := s.eggSalesByPFK[projectFlockKandangIDs[0]]
return row.pieces, row.kg, nil
}
func (s *hppV2RepoStub) GetTransferSourceSummary(_ context.Context, projectFlockKandangId uint) (uint, float64, error) {
row := s.transferSummaryByPFK[projectFlockKandangId]
return row.projectFlockID, row.totalQty, nil
}
func TestHppV2CalculateHppBreakdown_ComposesPakanSlices(t *testing.T) {
repo := &hppV2RepoStub{
contextByPFK: map[uint]*commonRepo.HppV2ProjectFlockKandangContext{
10: {
ProjectFlockKandangID: 10,
ProjectFlockID: 2,
ProjectFlockCategory: "LAYING",
KandangID: 100,
KandangName: "Kandang A",
LocationID: 16,
},
},
pfkIDsByProject: map[uint][]uint{
1: {101, 102},
},
usageRowsByKey: map[string][]commonRepo.HppV2UsageCostRow{
stubKey([]uint{101, 102}, []string{"PAKAN"}): {
{StockableType: "purchase_items", StockableID: 9001, SourceProductID: 8, SourceProductName: "Pakan Growing", Qty: 100, UnitPrice: 40, TotalCost: 4000},
},
stubKey([]uint{10}, []string{"PAKAN"}): {
{StockableType: "purchase_items", StockableID: 9002, SourceProductID: 9, SourceProductName: "Pakan Laying", Qty: 50, UnitPrice: 30, TotalCost: 1500},
},
},
adjustRowsByKey: map[string][]commonRepo.HppV2AdjustmentCostRow{
stubKey([]uint{101, 102}, []string{"PAKAN-CUTOVER"}): {
{AdjustmentID: 8001, ProductID: 11, ProductName: "Pakan Growing Cut-over", Qty: 20, Price: 30, GrandTotal: 600},
},
stubKey([]uint{10}, []string{"PAKAN-CUTOVER"}): {
{AdjustmentID: 8002, ProductID: 12, ProductName: "Pakan Laying Cut-over", Qty: 10, Price: 30, GrandTotal: 300},
},
},
totalPopulationByKey: map[string]float64{
stubKey([]uint{101, 102}, nil): 1000,
},
transferSummaryByPFK: map[uint]struct {
projectFlockID uint
totalQty float64
}{
10: {projectFlockID: 1, totalQty: 250},
},
eggProductionByPFK: map[uint]struct {
pieces float64
kg float64
}{
10: {pieces: 100, kg: 10},
},
eggSalesByPFK: map[uint]struct {
pieces float64
kg float64
}{
10: {pieces: 40, kg: 4},
},
}
svc := NewHppV2Service(repo)
result, err := svc.CalculateHppBreakdown(10, mustDate(t, "2026-04-19"))
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if result == nil {
t.Fatal("expected breakdown result")
}
if got := result.TotalPulletCost; got != 1150 {
t.Fatalf("expected total pullet cost 1150, got %v", got)
}
if got := result.TotalProductionCost; got != 1800 {
t.Fatalf("expected total production cost 1800, got %v", got)
}
if len(result.Components) != 1 {
t.Fatalf("expected 1 component, got %d", len(result.Components))
}
component := result.Components[0]
if component.Code != "PAKAN" {
t.Fatalf("expected PAKAN component, got %s", component.Code)
}
partTotals := map[string]float64{}
for _, part := range component.Parts {
partTotals[part.Code] = part.Total
}
if partTotals[hppV2PartGrowingNormal] != 1000 {
t.Fatalf("expected growing normal 1000, got %v", partTotals[hppV2PartGrowingNormal])
}
if partTotals[hppV2PartGrowingCutover] != 150 {
t.Fatalf("expected growing cutover 150, got %v", partTotals[hppV2PartGrowingCutover])
}
if partTotals[hppV2PartLayingNormal] != 1500 {
t.Fatalf("expected laying normal 1500, got %v", partTotals[hppV2PartLayingNormal])
}
if partTotals[hppV2PartLayingCutover] != 300 {
t.Fatalf("expected laying cutover 300, got %v", partTotals[hppV2PartLayingCutover])
}
if component.Parts[0].Proration == nil || component.Parts[0].Proration.Ratio != 0.25 {
t.Fatalf("expected growing proration ratio 0.25, got %+v", component.Parts[0].Proration)
}
if result.Hpp.Estimation.HargaKg != 180 {
t.Fatalf("expected estimation harga/kg 180, got %v", result.Hpp.Estimation.HargaKg)
}
if result.Hpp.Real.HargaKg != 450 {
t.Fatalf("expected real harga/kg 450, got %v", result.Hpp.Real.HargaKg)
}
}
func TestHppV2CalculateHppBreakdown_ManualCutoverUsesLayingSlicesOnly(t *testing.T) {
repo := &hppV2RepoStub{
contextByPFK: map[uint]*commonRepo.HppV2ProjectFlockKandangContext{
20: {
ProjectFlockKandangID: 20,
ProjectFlockID: 3,
ProjectFlockCategory: "LAYING",
KandangID: 200,
KandangName: "Kandang B",
LocationID: 17,
},
},
usageRowsByKey: map[string][]commonRepo.HppV2UsageCostRow{
stubKey([]uint{20}, []string{"PAKAN"}): {
{StockableType: "purchase_items", StockableID: 9100, SourceProductID: 21, SourceProductName: "Pakan Laying", Qty: 20, UnitPrice: 10, TotalCost: 200},
},
},
adjustRowsByKey: map[string][]commonRepo.HppV2AdjustmentCostRow{
stubKey([]uint{20}, []string{"PAKAN-CUTOVER"}): {
{AdjustmentID: 8100, ProductID: 22, ProductName: "Pakan Laying Cut-over", Qty: 30, Price: 10, GrandTotal: 300},
},
},
eggProductionByPFK: map[uint]struct {
pieces float64
kg float64
}{
20: {pieces: 50, kg: 5},
},
eggSalesByPFK: map[uint]struct {
pieces float64
kg float64
}{
20: {pieces: 25, kg: 2.5},
},
}
svc := NewHppV2Service(repo)
result, err := svc.CalculateHppBreakdown(20, mustDate(t, "2026-04-19"))
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if result.TotalProductionCost != 500 {
t.Fatalf("expected total production cost 500, got %v", result.TotalProductionCost)
}
component := result.Components[0]
if len(component.Parts) != 2 {
t.Fatalf("expected 2 laying parts, got %d", len(component.Parts))
}
for _, part := range component.Parts {
if strings.HasPrefix(part.Code, "growing_") {
t.Fatalf("expected no growing parts, got %s", part.Code)
}
}
if result.Hpp.Estimation.HargaKg != 100 {
t.Fatalf("expected estimation harga/kg 100, got %v", result.Hpp.Estimation.HargaKg)
}
}
func TestHppV2CalculateHppBreakdown_IncludesOvkComponent(t *testing.T) {
repo := &hppV2RepoStub{
contextByPFK: map[uint]*commonRepo.HppV2ProjectFlockKandangContext{
30: {
ProjectFlockKandangID: 30,
ProjectFlockID: 4,
ProjectFlockCategory: "LAYING",
KandangID: 300,
KandangName: "Kandang C",
LocationID: 18,
},
},
pfkIDsByProject: map[uint][]uint{
5: {301, 302},
},
usageRowsByKey: map[string][]commonRepo.HppV2UsageCostRow{
stubKey([]uint{30}, []string{"PAKAN"}): {
{StockableType: "purchase_items", StockableID: 9200, SourceProductID: 31, SourceProductName: "Pakan Laying", Qty: 20, UnitPrice: 25, TotalCost: 500},
},
stubKey([]uint{301, 302}, []string{string(utils.FlagOVK), string(utils.FlagObat), string(utils.FlagVitamin), string(utils.FlagKimia)}): {
{StockableType: "purchase_items", StockableID: 9201, SourceProductID: 32, SourceProductName: "OVK Growing", Qty: 40, UnitPrice: 10, TotalCost: 400},
},
stubKey([]uint{30}, []string{string(utils.FlagOVK), string(utils.FlagObat), string(utils.FlagVitamin), string(utils.FlagKimia)}): {
{StockableType: "purchase_items", StockableID: 9202, SourceProductID: 33, SourceProductName: "OVK Laying", Qty: 15, UnitPrice: 10, TotalCost: 150},
},
},
adjustRowsByKey: map[string][]commonRepo.HppV2AdjustmentCostRow{
stubKey([]uint{301, 302}, []string{"OVK"}): {
{AdjustmentID: 8201, ProductID: 34, ProductName: "OVK Growing Cut-over", Qty: 10, Price: 10, GrandTotal: 100},
},
stubKey([]uint{30}, []string{"OVK"}): {
{AdjustmentID: 8202, ProductID: 35, ProductName: "OVK Laying Cut-over", Qty: 5, Price: 10, GrandTotal: 50},
},
},
totalPopulationByKey: map[string]float64{
stubKey([]uint{301, 302}, nil): 1000,
},
transferSummaryByPFK: map[uint]struct {
projectFlockID uint
totalQty float64
}{
30: {projectFlockID: 5, totalQty: 500},
},
eggProductionByPFK: map[uint]struct {
pieces float64
kg float64
}{
30: {pieces: 120, kg: 12},
},
eggSalesByPFK: map[uint]struct {
pieces float64
kg float64
}{
30: {pieces: 60, kg: 6},
},
}
svc := NewHppV2Service(repo)
result, err := svc.CalculateHppBreakdown(30, mustDate(t, "2026-04-19"))
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if result == nil {
t.Fatal("expected breakdown result")
}
if len(result.Components) != 2 {
t.Fatalf("expected 2 components, got %d", len(result.Components))
}
componentTotals := map[string]float64{}
for _, component := range result.Components {
componentTotals[component.Code] = component.Total
}
if componentTotals[hppV2ComponentPakan] != 500 {
t.Fatalf("expected pakan total 500, got %v", componentTotals[hppV2ComponentPakan])
}
if componentTotals[hppV2ComponentOvk] != 450 {
t.Fatalf("expected ovk total 450, got %v", componentTotals[hppV2ComponentOvk])
}
if result.TotalPulletCost != 250 {
t.Fatalf("expected total pullet cost 250, got %v", result.TotalPulletCost)
}
if result.TotalProductionCost != 700 {
t.Fatalf("expected total production cost 700, got %v", result.TotalProductionCost)
}
if result.Hpp.Estimation.HargaKg != 58.33 {
t.Fatalf("expected estimation harga/kg 58.33, got %v", result.Hpp.Estimation.HargaKg)
}
}
func TestHppV2CalculateHppBreakdown_IncludesDocAndDirectPulletChickin(t *testing.T) {
repo := &hppV2RepoStub{
contextByPFK: map[uint]*commonRepo.HppV2ProjectFlockKandangContext{
35: {
ProjectFlockKandangID: 35,
ProjectFlockID: 8,
ProjectFlockCategory: "LAYING",
KandangID: 350,
KandangName: "Kandang E",
LocationID: 20,
},
},
pfkIDsByProject: map[uint][]uint{
9: {901, 902},
},
totalPopulationByKey: map[string]float64{
stubKey([]uint{901, 902}, nil): 1000,
},
transferSummaryByPFK: map[uint]struct {
projectFlockID uint
totalQty float64
}{
35: {projectFlockID: 9, totalQty: 250},
},
chickinRowsByKey: map[string][]commonRepo.HppV2ChickinCostRow{
chickinStubKey([]uint{901, 902}, []string{string(utils.FlagDOC)}, false): {
{ProjectChickinID: 1, ProjectFlockKandangID: 901, ChickInDate: mustTime(t, "2026-04-01"), StockableType: "purchase_items", StockableID: 1001, SourceProductID: 77, SourceProductName: "DOC", Qty: 1000, UnitPrice: 2, TotalCost: 2000},
},
chickinStubKey([]uint{35}, []string{string(utils.FlagPullet), string(utils.FlagLayer)}, true): {
{ProjectChickinID: 2, ProjectFlockKandangID: 35, ChickInDate: mustTime(t, "2026-04-15"), StockableType: "purchase_items", StockableID: 1002, SourceProductID: 78, SourceProductName: "Pullet", Qty: 50, UnitPrice: 20, TotalCost: 1000},
},
},
eggProductionByPFK: map[uint]struct {
pieces float64
kg float64
}{
35: {pieces: 100, kg: 10},
},
eggSalesByPFK: map[uint]struct {
pieces float64
kg float64
}{
35: {pieces: 80, kg: 8},
},
}
svc := NewHppV2Service(repo)
result, err := svc.CalculateHppBreakdown(35, mustDate(t, "2026-04-19"))
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
componentTotals := map[string]float64{}
for _, component := range result.Components {
componentTotals[component.Code] = component.Total
}
if componentTotals[hppV2ComponentDocChickin] != 500 {
t.Fatalf("expected doc chickin total 500, got %v", componentTotals[hppV2ComponentDocChickin])
}
if componentTotals[hppV2ComponentDirectPulletPurchase] != 1000 {
t.Fatalf("expected direct pullet purchase total 1000, got %v", componentTotals[hppV2ComponentDirectPulletPurchase])
}
if result.TotalPulletCost != 500 {
t.Fatalf("expected total pullet cost 500, got %v", result.TotalPulletCost)
}
if result.TotalProductionCost != 1000 {
t.Fatalf("expected total production cost 1000, got %v", result.TotalProductionCost)
}
if result.Hpp.Estimation.HargaKg != 100 {
t.Fatalf("expected estimation harga/kg 100, got %v", result.Hpp.Estimation.HargaKg)
}
}
func TestHppV2CalculateHppBreakdown_IncludesBopRegularAndEkspedisi(t *testing.T) {
repo := &hppV2RepoStub{
contextByPFK: map[uint]*commonRepo.HppV2ProjectFlockKandangContext{
40: {
ProjectFlockKandangID: 40,
ProjectFlockID: 6,
ProjectFlockCategory: "LAYING",
KandangID: 400,
KandangName: "Kandang D",
LocationID: 19,
},
},
pfkIDsByProject: map[uint][]uint{
6: {40, 41},
7: {701, 702},
},
totalPopulationByKey: map[string]float64{
stubKey([]uint{701, 702}, nil): 1000,
},
transferSummaryByPFK: map[uint]struct {
projectFlockID uint
totalQty float64
}{
40: {projectFlockID: 7, totalQty: 200},
},
expenseRowsByPFKKey: map[string][]commonRepo.HppV2ExpenseCostRow{
expenseStubKey([]uint{701, 702}, false): {
{ExpenseRealizationID: 1, NonstockID: 11, NonstockName: "Growing BOP", Qty: 1, Price: 500, TotalCost: 500, RealizationDate: mustTime(t, "2026-04-10")},
},
expenseStubKey([]uint{40}, false): {
{ExpenseRealizationID: 2, NonstockID: 12, NonstockName: "Laying BOP", Qty: 1, Price: 80, TotalCost: 80, RealizationDate: mustTime(t, "2026-04-19")},
},
expenseStubKey([]uint{701, 702}, true): {
{ExpenseRealizationID: 3, NonstockID: 13, NonstockName: "Growing Expedition", Qty: 1, Price: 100, TotalCost: 100, RealizationDate: mustTime(t, "2026-04-11")},
},
expenseStubKey([]uint{40}, true): {
{ExpenseRealizationID: 4, NonstockID: 14, NonstockName: "Laying Expedition", Qty: 1, Price: 40, TotalCost: 40, RealizationDate: mustTime(t, "2026-04-19")},
},
},
expenseRowsByFarmKey: map[string][]commonRepo.HppV2ExpenseCostRow{
expenseFarmKey(7, false): {
{ExpenseRealizationID: 5, NonstockID: 15, NonstockName: "Growing Farm BOP", Qty: 1, Price: 300, TotalCost: 300, RealizationDate: mustTime(t, "2026-04-12")},
},
expenseFarmKey(6, false): {
{ExpenseRealizationID: 6, NonstockID: 16, NonstockName: "Laying Farm BOP", Qty: 1, Price: 100, TotalCost: 100, RealizationDate: mustTime(t, "2026-04-19")},
},
expenseFarmKey(7, true): {
{ExpenseRealizationID: 7, NonstockID: 17, NonstockName: "Growing Farm Expedition", Qty: 1, Price: 50, TotalCost: 50, RealizationDate: mustTime(t, "2026-04-12")},
},
expenseFarmKey(6, true): {
{ExpenseRealizationID: 8, NonstockID: 18, NonstockName: "Laying Farm Expedition", Qty: 1, Price: 60, TotalCost: 60, RealizationDate: mustTime(t, "2026-04-19")},
},
},
eggProductionByPFK: map[uint]struct {
pieces float64
kg float64
}{
40: {pieces: 30, kg: 3},
41: {pieces: 70, kg: 7},
},
eggSalesByPFK: map[uint]struct {
pieces float64
kg float64
}{
40: {pieces: 50, kg: 5},
},
}
svc := NewHppV2Service(repo)
result, err := svc.CalculateHppBreakdown(40, mustDate(t, "2026-04-19"))
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
componentTotals := map[string]float64{}
for _, component := range result.Components {
componentTotals[component.Code] = component.Total
}
if componentTotals[hppV2ComponentBopRegular] != 270 {
t.Fatalf("expected regular BOP total 270, got %v", componentTotals[hppV2ComponentBopRegular])
}
if componentTotals[hppV2ComponentBopEksp] != 88 {
t.Fatalf("expected expedition BOP total 88, got %v", componentTotals[hppV2ComponentBopEksp])
}
if result.TotalPulletCost != 190 {
t.Fatalf("expected total pullet cost 190, got %v", result.TotalPulletCost)
}
if result.TotalProductionCost != 168 {
t.Fatalf("expected total production cost 168, got %v", result.TotalProductionCost)
}
if result.Hpp.Estimation.HargaKg != 56 {
t.Fatalf("expected estimation harga/kg 56, got %v", result.Hpp.Estimation.HargaKg)
}
}
func TestHppV2CalculateHppBreakdown_AddsDepreciationForNormalTransfer(t *testing.T) {
sourceChickIn := mustTime(t, "2026-01-01")
reportDate := sourceChickIn.AddDate(0, 0, 154)
repo := &hppV2RepoStub{
contextByPFK: map[uint]*commonRepo.HppV2ProjectFlockKandangContext{
50: {
ProjectFlockKandangID: 50,
ProjectFlockID: 10,
ProjectFlockCategory: "LAYING",
KandangID: 500,
KandangName: "Kandang F",
LocationID: 21,
HouseType: "close_house",
},
},
pfkIDsByProject: map[uint][]uint{
11: {501},
},
latestTransferByPFK: map[uint]*commonRepo.HppV2LatestTransferInputRow{
50: {
ProjectFlockKandangID: 50,
SourceProjectFlockID: 11,
TransferDate: mustTime(t, "2026-05-20"),
TransferQty: 100,
TransferID: 701,
},
},
chickInDateByProject: map[uint]*time.Time{
11: &sourceChickIn,
},
depreciationByHouse: map[string]map[int]float64{
"close_house": {
1: 10,
},
},
usageRowsByKey: map[string][]commonRepo.HppV2UsageCostRow{
stubKey([]uint{501}, []string{"PAKAN"}): {
{StockableType: "purchase_items", StockableID: 9301, SourceProductID: 41, SourceProductName: "Pakan Growing", Qty: 25, UnitPrice: 40, TotalCost: 1000},
},
},
totalPopulationByKey: map[string]float64{
stubKey([]uint{501}, nil): 100,
},
transferSummaryByPFK: map[uint]struct {
projectFlockID uint
totalQty float64
}{
50: {projectFlockID: 11, totalQty: 100},
},
eggProductionByPFK: map[uint]struct {
pieces float64
kg float64
}{
50: {pieces: 20, kg: 10},
},
}
svc := NewHppV2Service(repo)
result, err := svc.CalculateHppBreakdown(50, &reportDate)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if result.TotalPulletCost != 1000 {
t.Fatalf("expected total pullet cost 1000, got %v", result.TotalPulletCost)
}
if result.TotalProductionCost != 100 {
t.Fatalf("expected total production cost 100, got %v", result.TotalProductionCost)
}
var depreciation *HppV2Component
for i := range result.Components {
if result.Components[i].Code == hppV2ComponentDepreciation {
depreciation = &result.Components[i]
break
}
}
if depreciation == nil {
t.Fatal("expected depreciation component")
}
if depreciation.Total != 100 {
t.Fatalf("expected depreciation total 100, got %v", depreciation.Total)
}
if len(depreciation.Parts) != 1 {
t.Fatalf("expected single depreciation part, got %d", len(depreciation.Parts))
}
if depreciation.Parts[0].Details["schedule_day"] != 1 {
t.Fatalf("expected schedule day 1, got %+v", depreciation.Parts[0].Details)
}
if depreciation.Parts[0].Details["origin_date"] != "2026-01-01" {
t.Fatalf("expected origin date 2026-01-01, got %+v", depreciation.Parts[0].Details)
}
if result.Hpp.Estimation.HargaKg != 10 {
t.Fatalf("expected estimation harga/kg 10, got %v", result.Hpp.Estimation.HargaKg)
}
}
func TestHppV2CalculateHppBreakdown_AddsDepreciationForManualCutoverFromCutoverDate(t *testing.T) {
originDate := mustTime(t, "2026-01-01")
cutoverDate := originDate.AddDate(0, 0, 155)
repo := &hppV2RepoStub{
contextByPFK: map[uint]*commonRepo.HppV2ProjectFlockKandangContext{
60: {
ProjectFlockKandangID: 60,
ProjectFlockID: 12,
ProjectFlockCategory: "LAYING",
KandangID: 600,
KandangName: "Kandang G",
LocationID: 22,
HouseType: "close_house",
},
},
pfkIDsByProject: map[uint][]uint{
12: {60},
},
manualInputByProject: map[uint]*commonRepo.HppV2ManualDepreciationInputRow{
12: {
ID: 801,
ProjectFlockID: 12,
TotalCost: 1000,
CutoverDate: cutoverDate,
},
},
chickInDateByProject: map[uint]*time.Time{
12: &originDate,
},
depreciationByHouse: map[string]map[int]float64{
"close_house": {
1: 10,
2: 20,
},
},
totalPopulationByKey: map[string]float64{
stubKey([]uint{60}, nil): 100,
},
eggProductionByPFK: map[uint]struct {
pieces float64
kg float64
}{
60: {pieces: 20, kg: 10},
},
}
svc := NewHppV2Service(repo)
result, err := svc.CalculateHppBreakdown(60, &cutoverDate)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if result.TotalPulletCost != 1000 {
t.Fatalf("expected total pullet cost 1000, got %v", result.TotalPulletCost)
}
if result.TotalProductionCost != 200 {
t.Fatalf("expected total production cost 200, got %v", result.TotalProductionCost)
}
componentTotals := map[string]float64{}
for _, component := range result.Components {
componentTotals[component.Code] = component.Total
}
if componentTotals[hppV2ComponentManualPulletCost] != 1000 {
t.Fatalf("expected manual pullet cost 1000, got %v", componentTotals[hppV2ComponentManualPulletCost])
}
if componentTotals[hppV2ComponentDepreciation] != 200 {
t.Fatalf("expected depreciation 200, got %v", componentTotals[hppV2ComponentDepreciation])
}
var depreciation *HppV2Component
for i := range result.Components {
if result.Components[i].Code == hppV2ComponentDepreciation {
depreciation = &result.Components[i]
break
}
}
if depreciation == nil || len(depreciation.Parts) != 1 {
t.Fatalf("expected one depreciation part, got %+v", depreciation)
}
if depreciation.Parts[0].Details["schedule_day"] != 2 {
t.Fatalf("expected schedule day 2, got %+v", depreciation.Parts[0].Details)
}
if depreciation.Parts[0].Details["start_schedule_day"] != 2 {
t.Fatalf("expected start schedule day 2, got %+v", depreciation.Parts[0].Details)
}
if result.Hpp.Estimation.HargaKg != 20 {
t.Fatalf("expected estimation harga/kg 20, got %v", result.Hpp.Estimation.HargaKg)
}
}
func TestHppV2CalculateHppBreakdown_UsesFarmSnapshotDepreciationProratedByEggProduction(t *testing.T) {
reportDate := mustTime(t, "2026-06-05")
repo := &hppV2RepoStub{
contextByPFK: map[uint]*commonRepo.HppV2ProjectFlockKandangContext{
70: {
ProjectFlockKandangID: 70,
ProjectFlockID: 15,
ProjectFlockCategory: "LAYING",
KandangID: 700,
KandangName: "Kandang Snapshot",
LocationID: 25,
HouseType: "close_house",
},
},
pfkIDsByProject: map[uint][]uint{
15: {70, 71},
},
snapshotByProjectKey: map[string]*commonRepo.HppV2FarmDepreciationSnapshotRow{
"15|2026-06-05": {
ID: 901,
ProjectFlockID: 15,
PeriodDate: reportDate,
DepreciationPercentEffective: 10,
DepreciationValue: 1000,
PulletCostDayNTotal: 10000,
},
},
eggProductionByPFK: map[uint]struct {
pieces float64
kg float64
}{
70: {pieces: 200, kg: 20},
71: {pieces: 800, kg: 80},
},
}
svc := NewHppV2Service(repo)
result, err := svc.CalculateHppBreakdown(70, &reportDate)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if result == nil {
t.Fatal("expected breakdown result")
}
var depreciation *HppV2Component
for i := range result.Components {
if result.Components[i].Code == hppV2ComponentDepreciation {
depreciation = &result.Components[i]
break
}
}
if depreciation == nil {
t.Fatal("expected depreciation component")
}
if depreciation.Total != 200 {
t.Fatalf("expected depreciation total 200, got %v", depreciation.Total)
}
if result.TotalProductionCost != 200 {
t.Fatalf("expected total production cost 200, got %v", result.TotalProductionCost)
}
if len(depreciation.Parts) != 1 {
t.Fatalf("expected one depreciation part, got %d", len(depreciation.Parts))
}
if depreciation.Parts[0].Code != hppV2PartDepreciationFarmSnapshot {
t.Fatalf("expected farm snapshot depreciation part, got %s", depreciation.Parts[0].Code)
}
if depreciation.Parts[0].Proration == nil || depreciation.Parts[0].Proration.Ratio != 0.2 {
t.Fatalf("expected proration ratio 0.2, got %+v", depreciation.Parts[0].Proration)
}
if depreciation.Parts[0].Details["snapshot_id"] != uint(901) {
t.Fatalf("expected snapshot id 901, got %+v", depreciation.Parts[0].Details)
}
}
func stubKey(ids []uint, flags []string) string {
idParts := make([]string, 0, len(ids))
for _, id := range ids {
idParts = append(idParts, fmt.Sprintf("%d", id))
}
sort.Strings(idParts)
flagParts := append([]string{}, flags...)
sort.Strings(flagParts)
return strings.Join(idParts, ",") + "|" + strings.Join(flagParts, ",")
}
func mustDate(t *testing.T, raw string) *time.Time {
t.Helper()
loc, err := time.LoadLocation("Asia/Jakarta")
if err != nil {
t.Fatalf("failed to load timezone: %v", err)
}
value, err := time.ParseInLocation("2006-01-02", raw, loc)
if err != nil {
t.Fatalf("failed to parse date %s: %v", raw, err)
}
return &value
}
func mustTime(t *testing.T, raw string) time.Time {
t.Helper()
value := mustDate(t, raw)
return *value
}
func expenseStubKey(ids []uint, ekspedisi bool) string {
return stubKey(ids, []string{fmt.Sprintf("ekspedisi=%t", ekspedisi)})
}
func expenseFarmKey(projectFlockID uint, ekspedisi bool) string {
return fmt.Sprintf("farm=%d|ekspedisi=%t", projectFlockID, ekspedisi)
}
func chickinStubKey(ids []uint, flags []string, excludeTransferToLaying bool) string {
return stubKey(ids, append(append([]string{}, flags...), fmt.Sprintf("exclude_transfer_to_laying=%t", excludeTransferToLaying)))
}
@@ -0,0 +1,103 @@
package service
import (
"context"
"time"
"gorm.io/gorm"
)
const farmDepreciationSnapshotTable = "farm_depreciation_snapshots"
func NormalizeDateOnlyUTC(value time.Time) time.Time {
if value.IsZero() {
return value
}
v := value.UTC()
return time.Date(v.Year(), v.Month(), v.Day(), 0, 0, 0, 0, time.UTC)
}
func MinNonZeroDateOnlyUTC(values ...time.Time) time.Time {
var out time.Time
for _, value := range values {
if value.IsZero() {
continue
}
normalized := NormalizeDateOnlyUTC(value)
if out.IsZero() || normalized.Before(out) {
out = normalized
}
}
return out
}
func InvalidateFarmDepreciationSnapshotsFromDate(ctx context.Context, db *gorm.DB, farmIDs []uint, fromDate time.Time) error {
if db == nil {
return nil
}
if fromDate.IsZero() {
return nil
}
fromDate = NormalizeDateOnlyUTC(fromDate)
query := db.WithContext(ctx).
Table(farmDepreciationSnapshotTable).
Where("period_date >= ?", fromDate)
if len(farmIDs) > 0 {
query = query.Where("project_flock_id IN ?", farmIDs)
}
return query.Delete(nil).Error
}
func ResolveProjectFlockIDsByProjectFlockKandangIDs(ctx context.Context, db *gorm.DB, pfkIDs []uint) ([]uint, error) {
if db == nil || len(pfkIDs) == 0 {
return []uint{}, nil
}
var projectFlockIDs []uint
if err := db.WithContext(ctx).
Table("project_flock_kandangs").
Distinct("project_flock_id").
Where("id IN ?", pfkIDs).
Pluck("project_flock_id", &projectFlockIDs).Error; err != nil {
return nil, err
}
return projectFlockIDs, nil
}
func ResolveProjectFlockIDsByExpenseID(ctx context.Context, db *gorm.DB, expenseID uint) ([]uint, error) {
if db == nil || expenseID == 0 {
return []uint{}, nil
}
query := `
WITH direct_farms AS (
SELECT DISTINCT pfk.project_flock_id
FROM expense_nonstocks ens
JOIN project_flock_kandangs pfk ON pfk.id = ens.project_flock_kandang_id
WHERE ens.expense_id = @expense_id
),
json_farms AS (
SELECT DISTINCT (jsonb_array_elements_text(e.project_flock_id::jsonb))::bigint AS project_flock_id
FROM expenses e
WHERE e.id = @expense_id
AND e.project_flock_id IS NOT NULL
)
SELECT DISTINCT project_flock_id
FROM (
SELECT project_flock_id FROM direct_farms
UNION ALL
SELECT project_flock_id FROM json_farms
) x
`
var ids []uint
if err := db.WithContext(ctx).Raw(query, map[string]any{
"expense_id": expenseID,
}).Scan(&ids).Error; err != nil {
return nil, err
}
return ids, nil
}
+4 -2
View File
@@ -23,6 +23,7 @@ type SSOClientConfig struct {
var (
IsProd bool
AppEnv string
AppHost string
Version string
LogLevel string
@@ -84,7 +85,8 @@ func init() {
loadConfig()
// server configuration
IsProd = viper.GetString("APP_ENV") == "prod"
AppEnv = defaultString(strings.TrimSpace(viper.GetString("APP_ENV")), "development")
IsProd = AppEnv == "prod"
AppHost = viper.GetString("APP_HOST")
AppPort = viper.GetInt("APP_PORT")
Version = viper.GetString("VERSION")
@@ -111,7 +113,7 @@ func init() {
// Cors
CORSAllowOrigins = parseList("CORS_ALLOW_ORIGINS")
CORSAllowMethods = parseListWithDefault("CORS_ALLOW_METHODS", "GET,POST,PUT,PATCH,DELETE,OPTIONS")
CORSAllowHeaders = parseListWithDefault("CORS_ALLOW_HEADERS", "Content-Type,Authorization,X-Requested-With")
CORSAllowHeaders = parseListWithDefault("CORS_ALLOW_HEADERS", "Content-Type,Authorization,X-API-Key,X-Requested-With")
CORSExposeHeaders = parseList("CORS_EXPOSE_HEADERS")
CORSAllowCredentials = viper.GetBool("CORS_ALLOW_CREDENTIALS")
CORSMaxAge = viper.GetInt("CORS_MAX_AGE")
@@ -0,0 +1,9 @@
BEGIN;
DROP INDEX IF EXISTS idx_daily_checklists_unique_non_rejected;
ALTER TABLE daily_checklists
ADD CONSTRAINT daily_checklists_date_kandang_category_key
UNIQUE (date, kandang_id, category);
COMMIT;
@@ -0,0 +1,10 @@
BEGIN;
ALTER TABLE daily_checklists
DROP CONSTRAINT IF EXISTS daily_checklists_date_kandang_category_key;
CREATE UNIQUE INDEX IF NOT EXISTS idx_daily_checklists_unique_non_rejected
ON daily_checklists (date, kandang_id, category)
WHERE (status IS NULL OR status <> 'REJECTED');
COMMIT;
@@ -0,0 +1,3 @@
-- Remove convertion fields from marketing_delivery_products table
ALTER TABLE marketing_delivery_products
DROP COLUMN IF EXISTS weight_per_convertion;
@@ -0,0 +1,4 @@
-- Add convertion fields to marketing_delivery_products table
ALTER TABLE marketing_delivery_products
ADD COLUMN IF NOT EXISTS weight_per_convertion NUMERIC(15, 3);
@@ -0,0 +1 @@
DROP TABLE IF EXISTS integration_api_keys;
@@ -0,0 +1,23 @@
CREATE TABLE IF NOT EXISTS integration_api_keys (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
environment VARCHAR(50) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'active',
key_prefix VARCHAR(64) NOT NULL,
key_hash TEXT NOT NULL,
permission_codes JSONB NOT NULL DEFAULT '[]'::jsonb,
all_area BOOLEAN NOT NULL DEFAULT FALSE,
area_ids JSONB NOT NULL DEFAULT '[]'::jsonb,
all_location BOOLEAN NOT NULL DEFAULT FALSE,
location_ids JSONB NOT NULL DEFAULT '[]'::jsonb,
last_used_at TIMESTAMPTZ NULL,
last_used_from VARCHAR(128) NULL,
revoked_at TIMESTAMPTZ NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ NULL,
CONSTRAINT uq_integration_api_keys_environment_prefix UNIQUE (environment, key_prefix)
);
CREATE INDEX idx_integration_api_keys_status ON integration_api_keys (status);
CREATE INDEX idx_integration_api_keys_deleted_at ON integration_api_keys (deleted_at);
@@ -0,0 +1,6 @@
ALTER TABLE kandangs
DROP COLUMN IF EXISTS house_type;
DROP TABLE IF EXISTS house_depreciation_standards;
DROP TYPE IF EXISTS house_type_enum;
@@ -0,0 +1,18 @@
CREATE TYPE house_type_enum AS ENUM ('open_house', 'close_house');
CREATE TABLE house_depreciation_standards (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(100),
effective_date DATE,
house_type house_type_enum NOT NULL,
day INT NOT NULL
CHECK (day >= 0),
depreciation_percent NUMERIC(15, 6) NOT NULL
CHECK (depreciation_percent >= 0 AND depreciation_percent <= 100),
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
CONSTRAINT house_depreciation_standards_house_type_day_unique UNIQUE (house_type, day)
);
ALTER TABLE kandangs
ADD COLUMN house_type house_type_enum;
@@ -0,0 +1,4 @@
DROP INDEX IF EXISTS idx_farm_depreciation_snapshots_project_flock_id;
DROP INDEX IF EXISTS idx_farm_depreciation_snapshots_period_date;
DROP TABLE IF EXISTS farm_depreciation_snapshots;
@@ -0,0 +1,22 @@
CREATE TABLE IF NOT EXISTS farm_depreciation_snapshots (
id BIGSERIAL PRIMARY KEY,
project_flock_id BIGINT NOT NULL
REFERENCES project_flocks(id)
ON UPDATE CASCADE
ON DELETE CASCADE,
period_date DATE NOT NULL,
depreciation_percent_effective NUMERIC(15, 6) NOT NULL DEFAULT 0,
depreciation_value NUMERIC(18, 3) NOT NULL DEFAULT 0,
pullet_cost_day_n_total NUMERIC(18, 3) NOT NULL DEFAULT 0,
components JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT farm_depreciation_snapshots_unique UNIQUE (project_flock_id, period_date)
);
CREATE INDEX IF NOT EXISTS idx_farm_depreciation_snapshots_period_date
ON farm_depreciation_snapshots (period_date);
CREATE INDEX IF NOT EXISTS idx_farm_depreciation_snapshots_project_flock_id
ON farm_depreciation_snapshots (project_flock_id);
@@ -0,0 +1,2 @@
DROP INDEX IF EXISTS idx_farm_depreciation_manual_inputs_project_flock_id;
DROP TABLE IF EXISTS farm_depreciation_manual_inputs;
@@ -0,0 +1,16 @@
CREATE TABLE IF NOT EXISTS farm_depreciation_manual_inputs (
id BIGSERIAL PRIMARY KEY,
project_flock_id BIGINT NOT NULL
REFERENCES project_flocks(id)
ON UPDATE CASCADE
ON DELETE CASCADE,
total_cost NUMERIC(18, 3) NOT NULL DEFAULT 0
CHECK (total_cost >= 0),
note TEXT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT farm_depreciation_manual_inputs_unique UNIQUE (project_flock_id)
);
CREATE INDEX IF NOT EXISTS idx_farm_depreciation_manual_inputs_project_flock_id
ON farm_depreciation_manual_inputs (project_flock_id);
@@ -0,0 +1,4 @@
DROP INDEX IF EXISTS idx_farm_depreciation_manual_inputs_cutover_date;
ALTER TABLE farm_depreciation_manual_inputs
DROP COLUMN IF EXISTS cutover_date;
@@ -0,0 +1,12 @@
ALTER TABLE farm_depreciation_manual_inputs
ADD COLUMN IF NOT EXISTS cutover_date DATE;
UPDATE farm_depreciation_manual_inputs
SET cutover_date = COALESCE(cutover_date, DATE(created_at))
WHERE cutover_date IS NULL;
ALTER TABLE farm_depreciation_manual_inputs
ALTER COLUMN cutover_date SET NOT NULL;
CREATE INDEX IF NOT EXISTS idx_farm_depreciation_manual_inputs_cutover_date
ON farm_depreciation_manual_inputs (cutover_date);
@@ -0,0 +1,18 @@
package entities
import "time"
type FarmDepreciationManualInput struct {
Id uint `gorm:"primaryKey"`
ProjectFlockId uint `gorm:"not null;uniqueIndex:idx_farm_depreciation_manual_inputs_unique"`
TotalCost float64 `gorm:"type:numeric(18,3);not null;default:0"`
CutoverDate time.Time `gorm:"type:date;not null"`
Note *string `gorm:"type:text"`
CreatedAt time.Time `gorm:"autoCreateTime"`
UpdatedAt time.Time `gorm:"autoUpdateTime"`
ProjectFlock ProjectFlock `gorm:"foreignKey:ProjectFlockId;references:Id"`
}
func (FarmDepreciationManualInput) TableName() string {
return "farm_depreciation_manual_inputs"
}
@@ -0,0 +1,21 @@
package entities
import (
"time"
)
type FarmDepreciationSnapshot struct {
Id uint `gorm:"primaryKey"`
ProjectFlockId uint `gorm:"not null;uniqueIndex:idx_farm_depreciation_snapshots_unique,priority:1"`
PeriodDate time.Time `gorm:"type:date;not null;uniqueIndex:idx_farm_depreciation_snapshots_unique,priority:2"`
DepreciationPercentEffective float64 `gorm:"type:numeric(15,6);not null;default:0"`
DepreciationValue float64 `gorm:"type:numeric(18,3);not null;default:0"`
PulletCostDayNTotal float64 `gorm:"type:numeric(18,3);not null;default:0"`
Components []byte `gorm:"type:jsonb;default:'{}'::jsonb"`
CreatedAt time.Time `gorm:"autoCreateTime"`
UpdatedAt time.Time `gorm:"autoUpdateTime"`
}
func (FarmDepreciationSnapshot) TableName() string {
return "farm_depreciation_snapshots"
}
@@ -0,0 +1,16 @@
package entities
import "time"
type HouseDepreciationStandard struct {
Id uint `gorm:"primaryKey"`
HouseType string `gorm:"type:house_type_enum;not null;uniqueIndex:house_depreciation_standards_house_type_day_unique,priority:1"`
DayNumber int `gorm:"column:day;not null;uniqueIndex:house_depreciation_standards_house_type_day_unique,priority:2"`
DepreciationPercent float64 `gorm:"type:numeric(15,6);not null"`
CreatedAt time.Time `gorm:"autoCreateTime"`
UpdatedAt time.Time `gorm:"autoUpdateTime"`
}
func (HouseDepreciationStandard) TableName() string {
return "house_depreciation_standards"
}
+36
View File
@@ -0,0 +1,36 @@
package entities
import (
"time"
"gorm.io/gorm"
)
const (
IntegrationAPIKeyStatusActive = "active"
IntegrationAPIKeyStatusRevoked = "revoked"
)
type IntegrationAPIKey struct {
ID uint `gorm:"primaryKey"`
Name string `gorm:"type:varchar(100);not null"`
Environment string `gorm:"type:varchar(50);not null;uniqueIndex:idx_integration_api_keys_env_prefix,priority:1"`
Status string `gorm:"type:varchar(20);not null;default:active;index"`
KeyPrefix string `gorm:"type:varchar(64);not null;uniqueIndex:idx_integration_api_keys_env_prefix,priority:2"`
KeyHash string `gorm:"type:text;not null"`
PermissionCodes []string `gorm:"type:jsonb;serializer:json;not null"`
AllArea bool `gorm:"not null;default:false"`
AreaIDs []uint `gorm:"type:jsonb;serializer:json;not null"`
AllLocation bool `gorm:"not null;default:false"`
LocationIDs []uint `gorm:"type:jsonb;serializer:json;not null"`
LastUsedAt *time.Time
LastUsedFrom string `gorm:"type:varchar(128)"`
RevokedAt *time.Time
CreatedAt time.Time `gorm:"autoCreateTime"`
UpdatedAt time.Time `gorm:"autoUpdateTime"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
}
func (IntegrationAPIKey) TableName() string {
return "integration_api_keys"
}
+1
View File
@@ -10,6 +10,7 @@ type Kandang struct {
Id uint `gorm:"primaryKey"`
Name string `gorm:"type:varchar(50);not null;uniqueIndex:kandangs_name_unique,where:deleted_at IS NULL"`
Status string `gorm:"type:varchar(50);not null"`
HouseType *string `gorm:"type:house_type_enum"`
LocationId uint `gorm:"not null"`
KandangGroupId uint `gorm:"not null"`
Capacity float64 `gorm:"not null"`
@@ -12,6 +12,7 @@ type MarketingDeliveryProduct struct {
UnitPrice float64 `gorm:"type:numeric(15,3)"`
TotalWeight float64 `gorm:"type:numeric(15,3)"`
AvgWeight float64 `gorm:"type:numeric(15,3)"`
WeightPerConvertion *float64 `gorm:"type:numeric(15,3)"`
TotalPrice float64 `gorm:"type:numeric(15,3)"`
DeliveryDate *time.Time `gorm:"type:timestamptz"`
VehicleNumber string `gorm:"type:varchar(50)"`
+86 -2
View File
@@ -1,9 +1,13 @@
package middleware
import (
"context"
"errors"
"strings"
"sync"
"github.com/gofiber/fiber/v2"
"gitlab.com/mbugroup/lti-api.git/internal/apikeys"
"gitlab.com/mbugroup/lti-api.git/internal/config"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
"gitlab.com/mbugroup/lti-api.git/internal/modules/sso/session"
@@ -17,11 +21,21 @@ const (
authUserLocalsKey = "auth.user"
)
var (
verifyAccessTokenFunc = sso.VerifyAccessToken
fetchProfileFunc = sso.FetchProfile
apiKeyAuthMu sync.RWMutex
apiKeyAuthenticator apikeys.Authenticator
)
// AuthContext keeps authentication details captured by the middleware.
type AuthContext struct {
Token string
Verification *sso.VerificationResult
User *entity.User
PrincipalType string
PrincipalName string
Roles []sso.Role
Permissions map[string]struct{}
UserAreaIDs []uint
@@ -30,6 +44,13 @@ type AuthContext struct {
UserAllLocation bool
}
func SetAPIKeyAuthenticator(authenticator apikeys.Authenticator) {
apiKeyAuthMu.Lock()
defer apiKeyAuthMu.Unlock()
apiKeyAuthenticator = authenticator
}
// Auth validates the incoming request against the central SSO access token and
// loads the corresponding local user. Optional scopes can be provided to enforce
// fine-grained authorization using the SSO access token scopes.
@@ -62,10 +83,20 @@ func Auth(userService service.UserService, requiredScopes ...string) fiber.Handl
}
}
if token == "" {
if c.Method() == fiber.MethodGet {
if err := authenticateAPIKey(c); err == nil {
if len(requiredScopes) > 0 {
return fiber.NewError(fiber.StatusForbidden, "Insufficient scope")
}
return c.Next()
} else if err != nil && !errors.Is(err, apikeys.ErrInvalidAPIKey) && !errors.Is(err, apikeys.ErrInactiveKey) {
return err
}
}
return fiber.NewError(fiber.StatusUnauthorized, "Please authenticate")
}
verification, err := sso.VerifyAccessToken(token)
verification, err := verifyAccessTokenFunc(token)
if err != nil {
if sso.IsSignatureError(err) {
logSignatureError("auth", tokenSource, token, err)
@@ -99,7 +130,7 @@ func Auth(userService service.UserService, requiredScopes ...string) fiber.Handl
permissions := make(map[string]struct{})
var profile *sso.UserProfile
if verification.UserID != 0 {
if p, err := sso.FetchProfile(c.Context(), token, verification); err != nil {
if p, err := fetchProfileFunc(c.Context(), token, verification); err != nil {
utils.Log.WithError(err).Warn("auth: failed to fetch sso profile")
} else {
profile = p
@@ -118,6 +149,8 @@ func Auth(userService service.UserService, requiredScopes ...string) fiber.Handl
Token: token,
Verification: verification,
User: user,
PrincipalType: "user",
PrincipalName: user.Name,
Roles: roles,
Permissions: permissions,
UserAreaIDs: nil,
@@ -219,6 +252,57 @@ func bearerToken(c *fiber.Ctx) string {
return ""
}
func authenticateAPIKey(c *fiber.Ctx) error {
rawKey := strings.TrimSpace(c.Get("X-API-Key"))
if rawKey == "" {
return apikeys.ErrInvalidAPIKey
}
authenticator := currentAPIKeyAuthenticator()
if authenticator == nil {
return apikeys.ErrInvalidAPIKey
}
principal, err := authenticator.Authenticate(context.Background(), rawKey, c.IP())
if err != nil {
if errors.Is(err, apikeys.ErrInvalidAPIKey) || errors.Is(err, apikeys.ErrInactiveKey) {
return apikeys.ErrInvalidAPIKey
}
utils.Log.WithError(err).Warn("auth: api key authentication failed")
return fiber.NewError(fiber.StatusInternalServerError, "Failed to authenticate request")
}
permissions := make(map[string]struct{}, len(principal.Permissions))
for _, perm := range principal.Permissions {
if canonical := canonicalPermission(perm); canonical != "" {
permissions[canonical] = struct{}{}
}
}
c.Locals(authContextLocalsKey, &AuthContext{
Token: "",
Verification: nil,
User: nil,
PrincipalType: "api_key",
PrincipalName: principal.Name,
Roles: nil,
Permissions: permissions,
UserAreaIDs: principal.AreaIDs,
UserLocationIDs: principal.LocationIDs,
UserAllArea: principal.AllArea,
UserAllLocation: principal.AllLocation,
})
c.Locals(authUserLocalsKey, nil)
return nil
}
func currentAPIKeyAuthenticator() apikeys.Authenticator {
apiKeyAuthMu.RLock()
defer apiKeyAuthMu.RUnlock()
return apiKeyAuthenticator
}
func hasAllScopes(have, required []string) bool {
if len(required) == 0 {
return true
+239
View File
@@ -0,0 +1,239 @@
package middleware
import (
"context"
"errors"
"net/http/httptest"
"testing"
"time"
"github.com/gofiber/fiber/v2"
"github.com/golang-jwt/jwt/v5"
"gitlab.com/mbugroup/lti-api.git/internal/apikeys"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
sso "gitlab.com/mbugroup/lti-api.git/internal/modules/sso/verifier"
userValidation "gitlab.com/mbugroup/lti-api.git/internal/modules/users/validations"
)
type stubUserService struct {
user *entity.User
err error
}
func (s *stubUserService) GetAll(_ *fiber.Ctx, _ *userValidation.Query) ([]entity.User, int64, error) {
return nil, 0, nil
}
func (s *stubUserService) GetOne(_ *fiber.Ctx, _ uint) (*entity.User, error) {
return s.user, s.err
}
func (s *stubUserService) CreateOne(_ *fiber.Ctx, _ *userValidation.Create) (*entity.User, error) {
return nil, nil
}
func (s *stubUserService) UpdateOne(_ *fiber.Ctx, _ *userValidation.Update, _ uint) (*entity.User, error) {
return nil, nil
}
func (s *stubUserService) DeleteOne(_ *fiber.Ctx, _ uint) error {
return nil
}
func (s *stubUserService) GetBySSOUserID(_ *fiber.Ctx, _ uint) (*entity.User, error) {
return s.user, s.err
}
type stubAPIKeyAuthenticator struct {
principal *apikeys.Principal
err error
}
func (s *stubAPIKeyAuthenticator) Authenticate(_ context.Context, _ string, _ string) (*apikeys.Principal, error) {
return s.principal, s.err
}
func TestAuthAllowsAPIKeyOnGet(t *testing.T) {
SetAPIKeyAuthenticator(&stubAPIKeyAuthenticator{
principal: &apikeys.Principal{
Name: "dashboard",
Permissions: []string{"perm.read"},
LocationIDs: []uint{3, 5},
},
})
defer SetAPIKeyAuthenticator(nil)
app := fiber.New()
app.Get("/reports", Auth(&stubUserService{}), RequirePermissions("perm.read"), func(c *fiber.Ctx) error {
scope, err := ResolveLocationScope(c, nil)
if err != nil {
return err
}
return c.JSON(fiber.Map{
"principal": c.Locals(authContextLocalsKey).(*AuthContext).PrincipalType,
"restrict": scope.Restrict,
"ids": scope.IDs,
})
})
req := httptest.NewRequest(fiber.MethodGet, "/reports", nil)
req.Header.Set("X-API-Key", "lti_dev_prefix_secret")
resp, err := app.Test(req)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp.StatusCode != fiber.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
}
func TestAuthRejectsAPIKeyOnPost(t *testing.T) {
SetAPIKeyAuthenticator(&stubAPIKeyAuthenticator{
principal: &apikeys.Principal{
Name: "dashboard",
Permissions: []string{"perm.write"},
},
})
defer SetAPIKeyAuthenticator(nil)
app := fiber.New()
app.Post("/reports", Auth(&stubUserService{}), RequirePermissions("perm.write"), func(c *fiber.Ctx) error {
return c.SendStatus(fiber.StatusOK)
})
req := httptest.NewRequest(fiber.MethodPost, "/reports", nil)
req.Header.Set("X-API-Key", "lti_dev_prefix_secret")
resp, err := app.Test(req)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp.StatusCode != fiber.StatusUnauthorized {
t.Fatalf("expected 401, got %d", resp.StatusCode)
}
}
func TestAuthRejectsInvalidAPIKey(t *testing.T) {
SetAPIKeyAuthenticator(&stubAPIKeyAuthenticator{err: apikeys.ErrInvalidAPIKey})
defer SetAPIKeyAuthenticator(nil)
app := fiber.New()
app.Get("/reports", Auth(&stubUserService{}), func(c *fiber.Ctx) error {
return c.SendStatus(fiber.StatusOK)
})
req := httptest.NewRequest(fiber.MethodGet, "/reports", nil)
req.Header.Set("X-API-Key", "lti_dev_prefix_secret")
resp, err := app.Test(req)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp.StatusCode != fiber.StatusUnauthorized {
t.Fatalf("expected 401, got %d", resp.StatusCode)
}
}
func TestAuthRejectsInactiveAPIKey(t *testing.T) {
SetAPIKeyAuthenticator(&stubAPIKeyAuthenticator{err: apikeys.ErrInactiveKey})
defer SetAPIKeyAuthenticator(nil)
app := fiber.New()
app.Get("/reports", Auth(&stubUserService{}), func(c *fiber.Ctx) error {
return c.SendStatus(fiber.StatusOK)
})
req := httptest.NewRequest(fiber.MethodGet, "/reports", nil)
req.Header.Set("X-API-Key", "lti_dev_prefix_secret")
resp, err := app.Test(req)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp.StatusCode != fiber.StatusUnauthorized {
t.Fatalf("expected 401, got %d", resp.StatusCode)
}
}
func TestAuthRejectsMissingPermission(t *testing.T) {
SetAPIKeyAuthenticator(&stubAPIKeyAuthenticator{
principal: &apikeys.Principal{
Name: "dashboard",
Permissions: []string{"perm.other"},
},
})
defer SetAPIKeyAuthenticator(nil)
app := fiber.New()
app.Get("/reports", Auth(&stubUserService{}), RequirePermissions("perm.read"), func(c *fiber.Ctx) error {
return c.SendStatus(fiber.StatusOK)
})
req := httptest.NewRequest(fiber.MethodGet, "/reports", nil)
req.Header.Set("X-API-Key", "lti_dev_prefix_secret")
resp, err := app.Test(req)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp.StatusCode != fiber.StatusForbidden {
t.Fatalf("expected 403, got %d", resp.StatusCode)
}
}
func TestAuthAllowsBearerOnGet(t *testing.T) {
previousVerify := verifyAccessTokenFunc
previousProfile := fetchProfileFunc
defer func() {
verifyAccessTokenFunc = previousVerify
fetchProfileFunc = previousProfile
}()
verifyAccessTokenFunc = func(_ string) (*sso.VerificationResult, error) {
return &sso.VerificationResult{
UserID: 1,
Claims: &sso.AccessTokenClaims{
RegisteredClaims: jwt.RegisteredClaims{
IssuedAt: jwt.NewNumericDate(time.Now().UTC()),
},
},
}, nil
}
fetchProfileFunc = func(_ context.Context, _ string, _ *sso.VerificationResult) (*sso.UserProfile, error) {
return &sso.UserProfile{
Permissions: []sso.Permission{{Name: "perm.read"}},
LocationIDs: []uint{7},
}, nil
}
app := fiber.New()
app.Get("/reports", Auth(&stubUserService{user: &entity.User{Id: 9, Name: "API User"}}), RequirePermissions("perm.read"), func(c *fiber.Ctx) error {
return c.JSON(fiber.Map{"principal": c.Locals(authContextLocalsKey).(*AuthContext).PrincipalType})
})
req := httptest.NewRequest(fiber.MethodGet, "/reports", nil)
req.Header.Set("Authorization", "Bearer test-token")
resp, err := app.Test(req)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp.StatusCode != fiber.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
}
func TestAuthReturnsServerErrorWhenAPIKeyVerifierFailsUnexpectedly(t *testing.T) {
SetAPIKeyAuthenticator(&stubAPIKeyAuthenticator{err: errors.New("boom")})
defer SetAPIKeyAuthenticator(nil)
app := fiber.New()
app.Get("/reports", Auth(&stubUserService{}), func(c *fiber.Ctx) error {
return c.SendStatus(fiber.StatusOK)
})
req := httptest.NewRequest(fiber.MethodGet, "/reports", nil)
req.Header.Set("X-API-Key", "lti_dev_prefix_secret")
resp, err := app.Test(req)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp.StatusCode != fiber.StatusInternalServerError {
t.Fatalf("expected 500, got %d", resp.StatusCode)
}
}
+8 -7
View File
@@ -47,13 +47,14 @@ const (
P_ApprovalGetAll = "lti.approval.list"
)
const (
P_ReportExpenseGetAll = "lti.repport.expense.list"
P_ReportDeliveryGetAll = "lti.repport.delivery.list"
P_ReportPurchaseSupplierGetAll = "lti.repport.purchasesupplier.list"
P_ReportDebtSupplierGetAll = "lti.repport.debtsupplier.list"
P_ReportHppPerKandangGetAll = "lti.repport.gethppperkandang.list"
P_ReportProductionResultGetAll = "lti.repport.production_result.list"
P_ReportCustomerPaymentGetAll = "lti.repport.customerpayment.list"
P_ReportExpenseGetAll = "lti.repport.expense.list"
P_ReportExpenseDepreciationManage = "lti.repport.expense.depreciation.manage"
P_ReportDeliveryGetAll = "lti.repport.delivery.list"
P_ReportPurchaseSupplierGetAll = "lti.repport.purchasesupplier.list"
P_ReportDebtSupplierGetAll = "lti.repport.debtsupplier.list"
P_ReportHppPerKandangGetAll = "lti.repport.gethppperkandang.list"
P_ReportProductionResultGetAll = "lti.repport.production_result.list"
P_ReportCustomerPaymentGetAll = "lti.repport.customerpayment.list"
)
const (
@@ -261,8 +261,11 @@ func (s dailyChecklistService) GetAll(c *fiber.Ctx, params *validation.Query) ([
if params.Search != "" {
re := regexp.MustCompile("[^a-zA-Z0-9]")
like := re.ReplaceAll([]byte("%"+params.Search+"%"), []byte(""))
db = db.Where("(regexp_replace(k.name, '[^a-zA-Z0-9]', '', 'g') ILIKE ? OR regexp_replace(dc.category::text, '[^a-zA-Z0-9]', '', 'g') ILIKE ?)", string(like), string(like))
normalizedSearch := re.ReplaceAllString(params.Search, "")
if normalizedSearch != "" {
like := "%" + normalizedSearch + "%"
db = db.Where("(regexp_replace(k.name, '[^a-zA-Z0-9]', '', 'g') ILIKE ? OR regexp_replace(dc.category::text, '[^a-zA-Z0-9]', '', 'g') ILIKE ?)", like, like)
}
}
countDB := db.Session(&gorm.Session{})
@@ -504,24 +507,66 @@ func (s *dailyChecklistService) CreateOne(c *fiber.Ctx, req *validation.Create)
status := req.Status
category := req.Category
targetID := uint(0)
createBody := &entity.DailyChecklist{
KandangId: req.KandangId,
Date: date,
Category: category,
Status: &status,
}
err = s.Repository.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error {
existing := new(entity.DailyChecklist)
err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("date = ? AND kandang_id = ? AND category = ? AND (status IS NULL OR status <> ?)", date, req.KandangId, category, "REJECTED").
Take(existing).Error
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
err = s.Repository.DB().WithContext(c.Context()).Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "date"}, {Name: "kandang_id"}, {Name: "category"}},
DoUpdates: clause.Assignments(map[string]any{"updated_at": time.Now()}),
}).Create(createBody).Error
if err == nil {
if err := tx.Model(&entity.DailyChecklist{}).
Where("id = ?", existing.Id).
Update("updated_at", time.Now()).Error; err != nil {
return err
}
targetID = existing.Id
return nil
}
createStatus := status
var rejectedCount int64
if err := tx.Model(&entity.DailyChecklist{}).
Where("date = ? AND kandang_id = ? AND category = ? AND status = ?", date, req.KandangId, category, "REJECTED").
Count(&rejectedCount).Error; err != nil {
return err
}
if rejectedCount > 0 {
createStatus = "DRAFT"
}
createBody := &entity.DailyChecklist{
KandangId: req.KandangId,
Date: date,
Category: category,
Status: &createStatus,
}
if err := tx.Create(createBody).Error; err != nil {
// Handle concurrent insert for active checklist with same key.
if findErr := tx.
Where("date = ? AND kandang_id = ? AND category = ? AND (status IS NULL OR status <> ?)", date, req.KandangId, category, "REJECTED").
Take(existing).Error; findErr == nil {
targetID = existing.Id
return nil
}
return err
}
targetID = createBody.Id
return nil
})
if err != nil {
s.Log.Errorf("Failed to upsert dailyChecklist: %+v", err)
s.Log.Errorf("Failed to create/upsert dailyChecklist: %+v", err)
return nil, err
}
return s.GetOne(c, createBody.Id)
return s.GetOne(c, targetID)
}
func (s dailyChecklistService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) (*entity.DailyChecklist, error) {
+3 -3
View File
@@ -18,11 +18,11 @@ type DashboardModule struct{}
func (DashboardModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) {
dashboardRepo := rDashboard.NewDashboardRepository(db)
hppCostRepo := commonRepo.NewHppCostRepository(db)
hppV2CostRepo := commonRepo.NewHppV2CostRepository(db)
userRepo := rUser.NewUserRepository(db)
hppSvc := commonService.NewHppService(hppCostRepo)
dashboardService := sDashboard.NewDashboardService(dashboardRepo, validate, hppSvc)
hppV2Svc := commonService.NewHppV2Service(hppV2CostRepo)
dashboardService := sDashboard.NewDashboardService(dashboardRepo, validate, hppV2Svc)
userService := sUser.NewUserService(userRepo, validate)
DashboardRoutes(router, userService, dashboardService)
@@ -6,6 +6,7 @@ import (
"strings"
"time"
"gitlab.com/mbugroup/lti-api.git/internal/config"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/dashboards/validations"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
@@ -35,6 +36,7 @@ type UniformityWeeklyMetric struct {
Week int
Uniformity float64
AverageWeight float64
UniformDate time.Time
}
type StandardWeeklyMetric struct {
@@ -104,6 +106,15 @@ func applyDashboardFilters(db *gorm.DB, filters *validation.DashboardFilter) *go
return db
}
func dashboardUniformityWeekExpr() string {
return fmt.Sprintf(`CASE
WHEN u.uniform_date IS NULL OR pc.chick_in_date IS NULL THEN 0
WHEN u.uniform_date::date < pc.chick_in_date THEN 0
WHEN UPPER(pf.category) = 'LAYING' THEN (((u.uniform_date::date - pc.chick_in_date)::int) / 7) + %d
ELSE (((u.uniform_date::date - pc.chick_in_date)::int) / 7) + 1
END`, config.LayingWeekStart())
}
func (r *DashboardRepositoryImpl) GetRecordingWeeklyMetrics(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) ([]RecordingWeeklyMetric, error) {
var rows []RecordingWeeklyMetric
@@ -139,20 +150,29 @@ func (r *DashboardRepositoryImpl) GetRecordingWeeklyMetrics(ctx context.Context,
func (r *DashboardRepositoryImpl) GetUniformityWeeklyMetrics(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) ([]UniformityWeeklyMetric, error) {
var rows []UniformityWeeklyMetric
weekExpr := dashboardUniformityWeekExpr()
db := r.DB().WithContext(ctx).
Table("project_flock_kandang_uniformity AS u").
Select(`u.week AS week,
Select(fmt.Sprintf(`%s AS week,
COALESCE(AVG(u.uniformity), 0) AS uniformity,
COALESCE(AVG((u.chart_data->'statistics'->>'average_weight')::numeric), 0) AS average_weight`).
COALESCE(AVG((u.chart_data->'statistics'->>'average_weight')::numeric), 0) AS average_weight`, weekExpr)).
Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = u.project_flock_kandang_id").
Joins("JOIN project_flocks AS pf ON pf.id = pfk.project_flock_id").
Joins("JOIN kandangs AS k ON k.id = pfk.kandang_id").
Joins(`JOIN (
SELECT project_flock_kandang_id, MIN(chick_in_date)::date AS chick_in_date
FROM project_chickins
WHERE deleted_at IS NULL
GROUP BY project_flock_kandang_id
) AS pc ON pc.project_flock_kandang_id = u.project_flock_kandang_id`).
Where("u.uniform_date IS NOT NULL").
Where("u.uniform_date >= ? AND u.uniform_date < ?", start, end)
Where("u.uniform_date >= ? AND u.uniform_date < ?", start, end).
Where("u.uniform_date::date >= pc.chick_in_date")
db = applyDashboardFilters(db, filters)
if err := db.Group("u.week").Order("u.week ASC").Scan(&rows).Error; err != nil {
if err := db.Group(weekExpr).Order("1 ASC").Scan(&rows).Error; err != nil {
return nil, err
}
@@ -518,23 +538,31 @@ func (r *DashboardRepositoryImpl) GetComparisonWeeklyUniformityMetrics(ctx conte
}
var rows []ComparisonUniformityMetric
weekExpr := dashboardUniformityWeekExpr()
db := r.DB().WithContext(ctx).
Table("project_flock_kandang_uniformity AS u").
Select(fmt.Sprintf(`u.week AS week,
Select(fmt.Sprintf(`%s AS week,
%s AS series_id,
COALESCE(AVG(u.uniformity), 0) AS uniformity,
COALESCE(AVG((u.chart_data->'statistics'->>'average_weight')::numeric), 0) AS average_weight`, seriesExpr)).
COALESCE(AVG((u.chart_data->'statistics'->>'average_weight')::numeric), 0) AS average_weight`, weekExpr, seriesExpr)).
Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = u.project_flock_kandang_id").
Joins("JOIN kandangs AS k ON k.id = pfk.kandang_id").
Joins("JOIN project_flocks AS pf ON pf.id = pfk.project_flock_id").
Joins("JOIN locations AS loc ON loc.id = k.location_id").
Joins(`JOIN (
SELECT project_flock_kandang_id, MIN(chick_in_date)::date AS chick_in_date
FROM project_chickins
WHERE deleted_at IS NULL
GROUP BY project_flock_kandang_id
) AS pc ON pc.project_flock_kandang_id = u.project_flock_kandang_id`).
Where("u.uniform_date IS NOT NULL").
Where("u.uniform_date >= ? AND u.uniform_date < ?", start, end)
Where("u.uniform_date >= ? AND u.uniform_date < ?", start, end).
Where("u.uniform_date::date >= pc.chick_in_date")
db = applyDashboardFilters(db, filters)
groupBy := fmt.Sprintf("u.week, %s", groupExpr)
orderBy := fmt.Sprintf("u.week ASC, %s", orderExpr)
groupBy := fmt.Sprintf("%s, %s", weekExpr, groupExpr)
orderBy := fmt.Sprintf("1 ASC, %s", orderExpr)
if err := db.Group(groupBy).Order(orderBy).Scan(&rows).Error; err != nil {
return nil, err
}
@@ -30,10 +30,10 @@ type dashboardService struct {
Log *logrus.Logger
Validate *validator.Validate
Repository repository.DashboardRepository
HppSvc commonService.HppService
HppSvc commonService.HppV2Service
}
func NewDashboardService(repo repository.DashboardRepository, validate *validator.Validate, hppSvc commonService.HppService) DashboardService {
func NewDashboardService(repo repository.DashboardRepository, validate *validator.Validate, hppSvc commonService.HppV2Service) DashboardService {
return &dashboardService{
Log: utils.Log,
Validate: validate,
@@ -265,6 +265,7 @@ func (s dashboardService) buildPerformanceCharts(ctx context.Context, params *va
}
bodyWeightDataset := make([]map[string]interface{}, 0, len(weeks))
bodyWeightDatasetIndexByWeek := make(map[int]int, len(weeks))
performanceDataset := make([]map[string]interface{}, 0, len(weeks))
fcrDataset := make([]map[string]interface{}, 0, len(weeks))
deplesiDataset := make([]map[string]interface{}, 0, len(weeks))
@@ -274,10 +275,10 @@ func (s dashboardService) buildPerformanceCharts(ctx context.Context, params *va
cumFeed := 0.0
for _, week := range weeks {
rec := recordingMap[week]
uni := uniformityMap[week]
std := standardMap[week]
stdFcr := standardFcrMap[week]
rec, hasRec := recordingMap[week]
uni, hasUni := uniformityMap[week]
std, hasStd := standardMap[week]
stdFcr, hasStdFcr := standardFcrMap[week]
weekEgg := weeklyEggMap[week]
weekFeed := weeklyFeedMap[week]
@@ -293,39 +294,80 @@ func (s dashboardService) buildPerformanceCharts(ctx context.Context, params *va
actFcrCum = cumFeed / cumEgg
}
bodyWeightDataset = append(bodyWeightDataset, map[string]interface{}{
"week": week,
"body_weight": roundTo(uni.AverageWeight, 2),
"std_body_weight": roundTo(std.StdBodyWeight, 2),
})
bodyWeightRow := map[string]interface{}{
"week": week,
}
if hasUni {
bodyWeightRow["body_weight"] = roundTo(uni.AverageWeight, 2)
}
if hasStd {
bodyWeightRow["std_body_weight"] = roundTo(std.StdBodyWeight, 2)
}
if len(bodyWeightRow) > 1 {
bodyWeightDataset = append(bodyWeightDataset, bodyWeightRow)
}
performanceDataset = append(performanceDataset, map[string]interface{}{
"week": week,
"act_laying": roundTo(rec.HenDay, 2),
"std_laying": roundTo(std.StdLaying, 2),
"act_egg_weight": roundTo(rec.EggWeight, 2),
"std_egg_weight": roundTo(std.StdEggWeight, 2),
"act_feed_intake": roundTo(rec.FeedIntake, 2),
"std_feed_intake": roundTo(std.StdFeedIntake, 2),
"act_uniformity": roundTo(uni.Uniformity, 2),
"std_uniformity": roundTo(std.StdUniformity, 2),
})
performanceRow := map[string]interface{}{
"week": week,
}
if hasRec {
performanceRow["act_laying"] = roundTo(rec.HenDay, 2)
performanceRow["act_egg_weight"] = roundTo(rec.EggWeight, 2)
performanceRow["act_feed_intake"] = roundTo(rec.FeedIntake, 2)
}
if hasUni {
performanceRow["act_uniformity"] = roundTo(uni.Uniformity, 2)
}
if hasStd {
performanceRow["std_laying"] = roundTo(std.StdLaying, 2)
performanceRow["std_egg_weight"] = roundTo(std.StdEggWeight, 2)
performanceRow["std_feed_intake"] = roundTo(std.StdFeedIntake, 2)
performanceRow["std_uniformity"] = roundTo(std.StdUniformity, 2)
}
if len(performanceRow) > 1 {
performanceDataset = append(performanceDataset, performanceRow)
}
fcrDataset = append(fcrDataset, map[string]interface{}{
"week": week,
"act_fcr": roundTo(actFcr, 2),
"std_fcr": roundTo(stdFcr, 2),
"act_fcr_cum": roundTo(actFcrCum, 2),
"std_fcr_cum": roundTo(stdFcr, 2),
})
fcrRow := map[string]interface{}{
"week": week,
}
if weekEgg > 0 && weekFeed > 0 {
fcrRow["act_fcr"] = roundTo(actFcr, 2)
}
if cumEgg > 0 && cumFeed > 0 {
fcrRow["act_fcr_cum"] = roundTo(actFcrCum, 2)
}
if hasStdFcr {
fcrRow["std_fcr"] = roundTo(stdFcr, 2)
fcrRow["std_fcr_cum"] = roundTo(stdFcr, 2)
}
if len(fcrRow) > 1 {
fcrDataset = append(fcrDataset, fcrRow)
}
deplesiDataset = append(deplesiDataset, map[string]interface{}{
"week": week,
"act_deplesi": roundTo(rec.CumDepletionRate, 2),
"std_deplesi": roundTo(std.StdDepletion, 2),
})
deplesiRow := map[string]interface{}{
"week": week,
}
if hasRec {
deplesiRow["act_deplesi"] = roundTo(rec.CumDepletionRate, 2)
}
if hasStd {
deplesiRow["std_deplesi"] = roundTo(std.StdDepletion, 2)
}
if len(deplesiRow) > 1 {
deplesiDataset = append(deplesiDataset, deplesiRow)
}
}
bodyWeightDataset = extendBodyWeightDatasetUntilEndDate(
bodyWeightDataset,
bodyWeightDatasetIndexByWeek,
uniformities,
uniformityMap,
standardMap,
params.PeriodEnd,
)
qualityRows, err := s.Repository.GetEggQualityWeeklyMetrics(ctx, startDate, endExclusive, filter)
if err != nil {
return nil, err
@@ -1049,6 +1091,69 @@ func (s dashboardService) avgSellingPrice(ctx context.Context, filter *validatio
return result.TotalPrice / result.TotalWeight, nil
}
func extendBodyWeightDatasetUntilEndDate(
dataset []map[string]interface{},
indexByWeek map[int]int,
uniformities []repository.UniformityWeeklyMetric,
uniformityMap map[int]repository.UniformityWeeklyMetric,
standardMap map[int]repository.StandardWeeklyMetric,
periodEnd time.Time,
) []map[string]interface{} {
latestUniformityWeek := 0
var latestUniformityDate time.Time
for _, row := range uniformities {
if row.Week <= 0 || row.UniformDate.IsZero() {
continue
}
if latestUniformityDate.IsZero() || row.UniformDate.After(latestUniformityDate) || (row.UniformDate.Equal(latestUniformityDate) && row.Week > latestUniformityWeek) {
latestUniformityDate = row.UniformDate
latestUniformityWeek = row.Week
}
}
if latestUniformityWeek <= 0 || latestUniformityDate.IsZero() || periodEnd.IsZero() || !periodEnd.After(latestUniformityDate) {
return dataset
}
additionalWeeks := int(math.Ceil(periodEnd.Sub(latestUniformityDate).Hours() / (24 * 7)))
if additionalWeeks <= 0 {
return dataset
}
lastUniformity := uniformityMap[latestUniformityWeek]
lastStandard := standardMap[latestUniformityWeek]
latestBodyWeight := roundTo(lastUniformity.AverageWeight, 2)
latestStdBodyWeight := roundTo(lastStandard.StdBodyWeight, 2)
targetWeek := latestUniformityWeek + additionalWeeks
for week := latestUniformityWeek + 1; week <= targetWeek; week++ {
row := map[string]interface{}{
"week": week,
"body_weight": latestBodyWeight,
"std_body_weight": latestStdBodyWeight,
}
if idx, ok := indexByWeek[week]; ok {
dataset[idx] = row
continue
}
dataset = append(dataset, row)
indexByWeek[week] = len(dataset) - 1
}
sort.Slice(dataset, func(i, j int) bool {
return datasetWeek(dataset[i]) < datasetWeek(dataset[j])
})
return dataset
}
func datasetWeek(row map[string]interface{}) int {
week, _ := row["week"].(int)
return week
}
func feedUsageToGrams(rows []repository.FeedUsageByUom) float64 {
total := 0.0
for _, row := range rows {
@@ -5,6 +5,7 @@ import (
"encoding/json"
"errors"
"fmt"
"time"
commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository"
commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service"
@@ -358,6 +359,7 @@ func (s *expenseService) CreateOne(c *fiber.Ctx, req *validation.Create) (*expen
if err != nil {
return nil, err
}
s.invalidateDepreciationSnapshotsByExpense(c.Context(), nil, uint(expense.Id), expenseDate, nil)
return responseDTO, nil
}
@@ -385,6 +387,7 @@ func (s expenseService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint)
}
updateBody := make(map[string]any)
var requestedTransactionDate *time.Time
if req.TransactionDate != nil {
expenseDate, err := utils.ParseDateString(*req.TransactionDate)
@@ -392,6 +395,7 @@ func (s expenseService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint)
return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid transaction_date format")
}
updateBody["transaction_date"] = expenseDate
requestedTransactionDate = &expenseDate
}
if req.Category != nil {
@@ -429,6 +433,8 @@ func (s expenseService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint)
return responseDTO, nil
}
var invalidationFromDate time.Time
var invalidationFarmIDs []uint
err = s.Repository.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error {
expenseRepoTx := repository.NewExpenseRepository(tx)
@@ -446,6 +452,16 @@ func (s expenseService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint)
if err := s.ensureProjectFlockNotClosedForExpense(c.Context(), currentExpense); err != nil {
return err
}
oldFarmIDs, resolveOldFarmErr := commonSvc.ResolveProjectFlockIDsByExpenseID(c.Context(), tx, id)
if resolveOldFarmErr != nil {
s.Log.Warnf("Failed to resolve old expense farm ids for invalidation (expense_id=%d): %+v", id, resolveOldFarmErr)
}
invalidationFarmIDs = append(invalidationFarmIDs, oldFarmIDs...)
invalidationFromDate = currentExpense.TransactionDate
if requestedTransactionDate != nil {
invalidationFromDate = commonSvc.MinNonZeroDateOnlyUTC(currentExpense.TransactionDate, *requestedTransactionDate)
}
categoryChanged := false
var newCategory string
if req.Category != nil && *req.Category != currentExpense.Category {
@@ -631,6 +647,12 @@ func (s expenseService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint)
}
}
newFarmIDs, resolveNewFarmErr := commonSvc.ResolveProjectFlockIDsByExpenseID(c.Context(), tx, id)
if resolveNewFarmErr != nil {
s.Log.Warnf("Failed to resolve new expense farm ids for invalidation (expense_id=%d): %+v", id, resolveNewFarmErr)
}
invalidationFarmIDs = append(invalidationFarmIDs, newFarmIDs...)
return nil
})
@@ -645,6 +667,7 @@ func (s expenseService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint)
if err != nil {
return nil, err
}
s.invalidateDepreciationSnapshots(c.Context(), nil, invalidationFarmIDs, invalidationFromDate)
return responseDTO, nil
}
@@ -671,6 +694,10 @@ func (s expenseService) DeleteOne(c *fiber.Ctx, id uint64) error {
if err := s.ensureProjectFlockNotClosedForExpense(c.Context(), expense); err != nil {
return err
}
farmIDs, resolveFarmErr := commonSvc.ResolveProjectFlockIDsByExpenseID(c.Context(), s.Repository.DB(), idUint)
if resolveFarmErr != nil {
s.Log.Warnf("Failed to resolve expense farm ids before delete (expense_id=%d): %+v", idUint, resolveFarmErr)
}
if err := s.Repository.DeleteOne(c.Context(), idUint); err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
s.Log.Errorf("Expense not found for ID %d: %+v", id, err)
@@ -680,6 +707,8 @@ func (s expenseService) DeleteOne(c *fiber.Ctx, id uint64) error {
return err
}
s.Log.Infof("Successfully deleted expense with ID %d", id)
invalidationFromDate := commonSvc.MinNonZeroDateOnlyUTC(expense.TransactionDate, expense.RealizationDate)
s.invalidateDepreciationSnapshots(c.Context(), nil, farmIDs, invalidationFromDate)
return nil
}
@@ -800,6 +829,8 @@ func (s *expenseService) CreateRealization(c *fiber.Ctx, expenseID uint, req *va
if err != nil {
return nil, err
}
invalidateFromDate := commonSvc.MinNonZeroDateOnlyUTC(expense.TransactionDate, realizationDate, expense.RealizationDate)
s.invalidateDepreciationSnapshotsByExpense(c.Context(), nil, expenseID, invalidateFromDate, nil)
return responseDTO, nil
}
@@ -857,6 +888,13 @@ func (s *expenseService) CompleteExpense(c *fiber.Ctx, id uint, notes *string) (
if err != nil {
return nil, err
}
expense, expenseErr := s.Repository.GetByID(c.Context(), id, nil)
if expenseErr != nil {
s.Log.Warnf("Failed to load expense for depreciation invalidation after complete (expense_id=%d): %+v", id, expenseErr)
} else {
invalidateFromDate := commonSvc.MinNonZeroDateOnlyUTC(expense.TransactionDate, expense.RealizationDate)
s.invalidateDepreciationSnapshotsByExpense(c.Context(), nil, id, invalidateFromDate, nil)
}
return responseDTO, nil
}
@@ -884,6 +922,12 @@ func (s *expenseService) UpdateRealization(c *fiber.Ctx, expenseID uint, req *va
if err := s.ensureProjectFlockNotClosedForExpense(c.Context(), expense); err != nil {
return nil, err
}
invalidateFromDate := commonSvc.MinNonZeroDateOnlyUTC(expense.TransactionDate, expense.RealizationDate)
if req.RealizationDate != nil {
if parsedDate, parseErr := utils.ParseDateString(*req.RealizationDate); parseErr == nil {
invalidateFromDate = commonSvc.MinNonZeroDateOnlyUTC(invalidateFromDate, parsedDate)
}
}
latestApproval, err := s.ApprovalSvc.LatestByTarget(c.Context(), utils.ApprovalWorkflowExpense, expenseID, nil)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to validate workflow")
@@ -996,6 +1040,7 @@ func (s *expenseService) UpdateRealization(c *fiber.Ctx, expenseID uint, req *va
if err != nil {
return nil, err
}
s.invalidateDepreciationSnapshotsByExpense(c.Context(), nil, expenseID, invalidateFromDate, nil)
return responseDTO, nil
}
@@ -1057,6 +1102,7 @@ func (s *expenseService) Approval(c *fiber.Ctx, req *validation.ApprovalRequest,
}
var results []expenseDto.ExpenseDetailDTO
invalidateFromDateByExpenseID := make(map[uint]time.Time)
err = s.Repository.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error {
@@ -1069,6 +1115,17 @@ func (s *expenseService) Approval(c *fiber.Ctx, req *validation.ApprovalRequest,
); err != nil {
return err
}
expenseForInvalidation, err := expenseRepoTx.GetByID(c.Context(), id, nil)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return fiber.NewError(fiber.StatusNotFound, "Expense not found")
}
return fiber.NewError(fiber.StatusInternalServerError, "Failed to load expense")
}
invalidateFromDateByExpenseID[id] = commonSvc.MinNonZeroDateOnlyUTC(
expenseForInvalidation.TransactionDate,
expenseForInvalidation.RealizationDate,
)
latestApproval, err := s.ApprovalSvc.LatestByTarget(c.Context(), utils.ApprovalWorkflowExpense, id, nil)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
@@ -1170,10 +1227,73 @@ func (s *expenseService) Approval(c *fiber.Ctx, req *validation.ApprovalRequest,
}
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed approve expenses")
}
for expenseID, invalidateFromDate := range invalidateFromDateByExpenseID {
s.invalidateDepreciationSnapshotsByExpense(c.Context(), nil, expenseID, invalidateFromDate, nil)
}
return results, nil
}
func (s *expenseService) invalidateDepreciationSnapshotsByExpense(
ctx context.Context,
tx *gorm.DB,
expenseID uint,
fromDate time.Time,
fallbackFarmIDs []uint,
) {
targetDB := s.Repository.DB()
if tx != nil {
targetDB = tx
}
farmIDs := append([]uint{}, fallbackFarmIDs...)
if expenseID != 0 {
resolvedFarmIDs, err := commonSvc.ResolveProjectFlockIDsByExpenseID(ctx, targetDB, expenseID)
if err != nil {
s.Log.Warnf("Failed to resolve expense farm ids for invalidation (expense_id=%d): %+v", expenseID, err)
} else {
farmIDs = append(farmIDs, resolvedFarmIDs...)
}
}
s.invalidateDepreciationSnapshots(ctx, tx, farmIDs, fromDate)
}
func (s *expenseService) invalidateDepreciationSnapshots(
ctx context.Context,
tx *gorm.DB,
farmIDs []uint,
fromDate time.Time,
) {
if fromDate.IsZero() {
return
}
targetDB := s.Repository.DB()
if tx != nil {
targetDB = tx
}
farmIDs = utils.UniqueUintSlice(farmIDs)
if len(farmIDs) == 0 {
if err := commonSvc.InvalidateFarmDepreciationSnapshotsFromDate(ctx, targetDB, nil, fromDate); err != nil {
s.Log.Warnf(
"Failed to invalidate depreciation snapshots globally (from=%s): %+v",
fromDate.Format("2006-01-02"),
err,
)
}
return
}
if err := commonSvc.InvalidateFarmDepreciationSnapshotsFromDate(ctx, targetDB, farmIDs, fromDate); err != nil {
s.Log.Warnf(
"Failed to invalidate depreciation snapshots (farm_ids=%v, from=%s): %+v",
farmIDs,
fromDate.Format("2006-01-02"),
err,
)
}
}
func (s *expenseService) generatePoNumber(ctx *gorm.DB, expenseID uint) (string, error) {
expenseRepoTx := repository.NewExpenseRepository(ctx)
@@ -5,6 +5,7 @@ import (
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
productCategoryDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/product-categories/dto"
uomDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/uoms/dto"
userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto"
)
@@ -14,6 +15,7 @@ type ProductRelationDTO struct {
Id uint `json:"id"`
Name string `json:"name"`
SKU string `json:"sku"`
Uom *uomDTO.UomRelationDTO `json:"uom,omitempty"`
ProductCategory *productCategoryDTO.ProductCategoryRelationDTO `json:"product_category,omitempty"`
}
@@ -89,11 +91,17 @@ func ToProductRelationDTO(e *entity.Product) *ProductRelationDTO {
mapped := productCategoryDTO.ToProductCategoryRelationDTO(e.ProductCategory)
category = &mapped
}
var uom *uomDTO.UomRelationDTO
if e.Uom.Id != 0 {
mapped := uomDTO.ToUomRelationDTO(e.Uom)
uom = &mapped
}
return &ProductRelationDTO{
Id: e.Id,
Name: e.Name,
SKU: sku,
Uom: uom,
ProductCategory: category,
}
}
@@ -3,6 +3,7 @@ package controller
import (
"math"
"strconv"
"strings"
"gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/dto"
service "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/services"
@@ -27,11 +28,13 @@ func (u *ProductWarehouseController) GetAll(c *fiber.Ctx) error {
query := &validation.Query{
Page: c.QueryInt("page", 1),
Limit: c.QueryInt("limit", 10),
Search: c.Query("search", ""),
ProductId: uint(c.QueryInt("product_id", 0)),
WarehouseId: uint(c.QueryInt("warehouse_id", 0)),
LocationId: uint(c.QueryInt("location_id", 0)),
Flags: c.Query("flags", ""),
KandangId: uint(c.QueryInt("kandang_id", 0)),
AvailableOnly: parseBoolQuery(c.Query("available_only", "")),
TransferContext: c.Query(utils.TransferContextKey, ""),
StockMode: c.Query("stock_mode", ""),
Type: c.Query("type", ""),
@@ -61,6 +64,15 @@ func (u *ProductWarehouseController) GetAll(c *fiber.Ctx) error {
})
}
func parseBoolQuery(raw string) bool {
switch strings.TrimSpace(strings.ToLower(raw)) {
case "1", "true", "yes", "y":
return true
default:
return false
}
}
func (u *ProductWarehouseController) GetOne(c *fiber.Ctx) error {
param := c.Params("id")
@@ -34,7 +34,7 @@ func TestGetAllParsesLocationID(t *testing.T) {
ctrl := NewProductWarehouseController(stub)
app.Get("/product-warehouses", ctrl.GetAll)
req := httptest.NewRequest("GET", "/product-warehouses?location_id=16&kandang_id=59&limit=25", nil)
req := httptest.NewRequest("GET", "/product-warehouses?location_id=16&kandang_id=59&limit=25&search=tektrol&available_only=true", nil)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("unexpected error: %v", err)
@@ -54,6 +54,12 @@ func TestGetAllParsesLocationID(t *testing.T) {
if stub.lastQuery.Limit != 25 {
t.Fatalf("expected limit 25, got %d", stub.lastQuery.Limit)
}
if stub.lastQuery.Search != "tektrol" {
t.Fatalf("expected search tektrol, got %s", stub.lastQuery.Search)
}
if !stub.lastQuery.AvailableOnly {
t.Fatalf("expected available_only true")
}
}
func TestStubImplementsServiceContract(t *testing.T) {
@@ -71,6 +71,13 @@ func applyWarehouseSelectionFilter(db *gorm.DB, kandangID, locationID uint) *gor
}
}
func applyAvailableOnlyFilter(db *gorm.DB, availableOnly bool) *gorm.DB {
if !availableOnly {
return db
}
return db.Where("COALESCE(product_warehouses.qty, 0) > 0")
}
func (s productWarehouseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.ProductWarehouse, int64, error) {
if err := s.Validate.Struct(params); err != nil {
return nil, 0, err
@@ -151,12 +158,31 @@ func (s productWarehouseService) GetAll(c *fiber.Ctx, params *validation.Query)
db = db.Where("product_id = ?", params.ProductId)
}
db = applyAvailableOnlyFilter(db, params.AvailableOnly)
db = applyWarehouseSelectionFilter(db, params.KandangId, params.LocationId)
if params.WarehouseId != 0 {
db = db.Where("warehouse_id = ?", params.WarehouseId)
}
if strings.TrimSpace(params.Search) != "" {
searchPattern := "%" + strings.TrimSpace(params.Search) + "%"
db = db.Where(
`(
EXISTS (
SELECT 1
FROM products p_search
WHERE p_search.id = product_warehouses.product_id
AND p_search.name ILIKE ?
)
OR w_scope.name ILIKE ?
)`,
searchPattern,
searchPattern,
)
}
if len(marketingTypes) > 0 {
flagSet := make(map[string]struct{})
for _, t := range marketingTypes {
@@ -49,6 +49,20 @@ func TestApplyWarehouseSelectionFilterSupportsLocationOnlyQuery(t *testing.T) {
assertUintIDs(t, ids, []uint{1, 2, 3})
}
func TestApplyAvailableOnlyFilterRemovesZeroQtyRows(t *testing.T) {
db := setupProductWarehouseServiceTestDB(t)
var ids []uint
err := applyAvailableOnlyFilter(baseProductWarehouseSelectionQuery(db), true).
Order("product_warehouses.id").
Pluck("product_warehouses.id", &ids).Error
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
assertUintIDs(t, ids, []uint{1, 2, 4})
}
func setupProductWarehouseServiceTestDB(t *testing.T) *gorm.DB {
t.Helper()
@@ -67,18 +81,19 @@ func setupProductWarehouseServiceTestDB(t *testing.T) *gorm.DB {
)`,
`CREATE TABLE product_warehouses (
id INTEGER PRIMARY KEY,
warehouse_id INTEGER NOT NULL
warehouse_id INTEGER NOT NULL,
qty NUMERIC NULL
)`,
`INSERT INTO warehouses (id, type, location_id, kandang_id, deleted_at) VALUES
(1, 'KANDANG', 101, 11, NULL),
(2, 'LOKASI', 101, NULL, NULL),
(3, 'KANDANG', 101, 12, NULL),
(4, 'LOKASI', 102, NULL, NULL)`,
`INSERT INTO product_warehouses (id, warehouse_id) VALUES
(1, 1),
(2, 2),
(3, 3),
(4, 4)`,
`INSERT INTO product_warehouses (id, warehouse_id, qty) VALUES
(1, 1, 10),
(2, 2, 20),
(3, 3, 0),
(4, 4, 15)`,
}
for _, stmt := range statements {
@@ -15,11 +15,13 @@ 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"`
Search string `query:"search" validate:"omitempty"`
ProductId uint `query:"product_id" validate:"omitempty,number,min=1"`
WarehouseId uint `query:"warehouse_id" validate:"omitempty,number,min=1"`
LocationId uint `query:"location_id" validate:"omitempty,number,min=1"`
Flags string `query:"flags" validate:"omitempty"`
KandangId uint `query:"kandang_id" validate:"omitempty,number,min=1"`
AvailableOnly bool `query:"available_only"`
TransferContext string `query:"transfer_context" validate:"omitempty,oneof=inventory_transfer"`
StockMode string `query:"stock_mode" validate:"omitempty,oneof=exclude_chickin"`
Type string `query:"type" validate:"omitempty"`
@@ -0,0 +1,548 @@
package service
import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/gofiber/fiber/v2"
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"
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"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
func (s *transferService) CreateSystemTransfer(ctx context.Context, req *SystemTransferRequest) (*entity.StockTransfer, error) {
if req == nil {
return nil, fmt.Errorf("system transfer request is required")
}
if strings.TrimSpace(req.TransferReason) == "" {
return nil, fmt.Errorf("transfer reason is required")
}
if req.TransferDate.IsZero() {
return nil, fmt.Errorf("transfer date is required")
}
if req.SourceWarehouseID == 0 || req.DestinationWarehouseID == 0 {
return nil, fmt.Errorf("source and destination warehouse are required")
}
if req.SourceWarehouseID == req.DestinationWarehouseID {
return nil, fmt.Errorf("source and destination warehouse must be different")
}
if req.ActorID == 0 {
return nil, fmt.Errorf("actor id is required")
}
if err := s.validateTransferWarehousesAndProducts(ctx, req.SourceWarehouseID, req.DestinationWarehouseID, req.Products); err != nil {
return nil, err
}
var result *entity.StockTransfer
err := s.StockTransferRepo.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
movementResult, err := s.createTransferMovement(ctx, tx, req)
if err != nil {
return err
}
result = movementResult.Transfer
return nil
})
if err != nil {
return nil, err
}
return result, nil
}
func (s *transferService) DeleteSystemTransfer(ctx context.Context, id uint, actorID uint) error {
if id == 0 {
return fmt.Errorf("transfer id is required")
}
if actorID == 0 {
return fmt.Errorf("actor id is required")
}
var deletedDetails []entity.StockTransferDetail
err := s.StockTransferRepo.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var err error
deletedDetails, err = s.deleteTransferCore(ctx, tx, uint64(id), actorID)
return err
})
if err != nil {
return err
}
if len(deletedDetails) > 0 && s.ExpenseBridge != nil {
if err := s.ExpenseBridge.OnItemsDeleted(ctx, uint64(id), deletedDetails); err != nil {
s.Log.Errorf("Failed to cleanup transfer expense link for transfer_id=%d: %+v", id, err)
return fiber.NewError(fiber.StatusInternalServerError, "Transfer berhasil dihapus, namun sinkronisasi expense gagal. Silakan cek modul expense")
}
}
return nil
}
func (s *transferService) validateTransferWarehousesAndProducts(
ctx context.Context,
sourceWarehouseID uint,
destinationWarehouseID uint,
products []SystemTransferProduct,
) error {
if len(products) == 0 {
return fmt.Errorf("transfer products are required")
}
pwIDs := make([]uint, 0, len(products))
for _, product := range products {
if product.ProductID == 0 {
return fmt.Errorf("product id is required")
}
if product.ProductQty <= 0 {
return fmt.Errorf("product qty must be greater than 0 for product %d", product.ProductID)
}
sourcePW, err := s.ProductWarehouseRepo.GetProductWarehouseByProductAndWarehouseID(
ctx, product.ProductID, sourceWarehouseID,
)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Produk dengan ID %d tidak ditemukan di gudang asal (ID: %d)", product.ProductID, sourceWarehouseID))
}
s.Log.Errorf("Failed to fetch product warehouse for product_id=%d, warehouse_id=%d: %+v", product.ProductID, sourceWarehouseID, err)
return fiber.NewError(fiber.StatusInternalServerError, "Gagal mengecek stok produk")
}
if sourcePW.Quantity < product.ProductQty {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Stok produk %d di gudang asal tidak mencukupi. Tersedia: %.2f, Diminta: %.2f", product.ProductID, sourcePW.Quantity, product.ProductQty))
}
pwIDs = append(pwIDs, sourcePW.Id)
}
if err := commonSvc.EnsureProjectFlockNotClosedForProductWarehouses(ctx, s.StockTransferRepo.DB(), pwIDs); err != nil {
return err
}
destPfkID, err := s.getActiveProjectFlockKandangID(ctx, destinationWarehouseID)
if err != nil {
return err
}
if destPfkID == 0 {
return nil
}
projectFlockKandang, err := s.ProjectFlockKandangRepo.GetByID(ctx, destPfkID)
if err != nil {
s.Log.Errorf("Failed to fetch project flock kandang by ID %d: %+v", destPfkID, err)
return fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data project flock")
}
if projectFlockKandang.ClosedAt != nil {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Project flock untuk gudang tujuan sudah ditutup (closing) pada %s", projectFlockKandang.ClosedAt.Format("2006-01-02")))
}
return nil
}
func (s *transferService) createTransferMovement(
ctx context.Context,
tx *gorm.DB,
req *SystemTransferRequest,
) (*transferMovementResult, error) {
if tx == nil {
return nil, fmt.Errorf("transaction is required")
}
stockTransferRepoTX := s.StockTransferRepo.WithTx(tx)
stockTransferDetailRepoTX := s.StockTransferDetailRepo.WithTx(tx)
productWarehouseRepoTX := rProductWarehouse.NewProductWarehouseRepository(tx)
stockLogsRepoTX := rStockLogs.NewStockLogRepository(tx)
movementNumber := strings.TrimSpace(req.MovementNumber)
if movementNumber == "" {
var err error
movementNumber, err = s.StockTransferRepo.GenerateMovementNumber(ctx)
if err != nil {
s.Log.Errorf("Failed to generate movement number: %+v", err)
return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat nomor transfer")
}
}
entityTransfer := &entity.StockTransfer{
FromWarehouseId: uint64(req.SourceWarehouseID),
ToWarehouseId: uint64(req.DestinationWarehouseID),
Reason: req.TransferReason,
TransferDate: req.TransferDate,
MovementNumber: movementNumber,
CreatedBy: uint64(req.ActorID),
}
if err := stockTransferRepoTX.CreateOne(ctx, entityTransfer, nil); err != nil {
return nil, err
}
details := make([]*entity.StockTransferDetail, 0, len(req.Products))
detailMap := make(map[uint64]*entity.StockTransferDetail, len(req.Products))
for _, product := range req.Products {
sourcePW, err := productWarehouseRepoTX.GetProductWarehouseByProductAndWarehouseID(
ctx, product.ProductID, req.SourceWarehouseID,
)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Produk %d tidak ditemukan di gudang asal (ID: %d)", product.ProductID, req.SourceWarehouseID))
}
s.Log.Errorf("Failed to fetch source product warehouse for product_id=%d, warehouse_id=%d: %+v", product.ProductID, req.SourceWarehouseID, err)
return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data stok gudang asal")
}
destPW, err := productWarehouseRepoTX.GetProductWarehouseByProductAndWarehouseID(
ctx, product.ProductID, req.DestinationWarehouseID,
)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
s.Log.Errorf("Failed to fetch dest product warehouse for product_id=%d, warehouse_id=%d: %+v", product.ProductID, req.DestinationWarehouseID, err)
return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data stok gudang tujuan")
}
if errors.Is(err, gorm.ErrRecordNotFound) {
projectFlockKandangID, err := s.getActiveProjectFlockKandangID(ctx, req.DestinationWarehouseID)
if err != nil {
return nil, err
}
var pfkID *uint
if projectFlockKandangID > 0 {
pfkID = &projectFlockKandangID
}
destPW = &entity.ProductWarehouse{
ProductId: product.ProductID,
WarehouseId: req.DestinationWarehouseID,
Quantity: 0,
ProjectFlockKandangId: pfkID,
}
if err := productWarehouseRepoTX.CreateOne(ctx, destPW, nil); err != nil {
s.Log.Errorf("Failed to create product warehouse for product_id=%d, warehouse_id=%d: %+v", product.ProductID, req.DestinationWarehouseID, err)
return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat data stok gudang tujuan")
}
}
detail := &entity.StockTransferDetail{
StockTransferId: entityTransfer.Id,
ProductId: uint64(product.ProductID),
SourceProductWarehouseID: func() *uint64 {
id := uint64(sourcePW.Id)
return &id
}(),
UsageQty: 0,
PendingQty: 0,
DestProductWarehouseID: func() *uint64 {
id := uint64(destPW.Id)
return &id
}(),
TotalQty: 0,
TotalUsed: 0,
}
details = append(details, detail)
detailMap[uint64(product.ProductID)] = detail
}
if err := stockTransferDetailRepoTX.CreateMany(ctx, details, nil); err != nil {
return nil, err
}
flagGroupByProduct := make(map[uint]string, len(req.Products))
for _, product := range req.Products {
detail := detailMap[uint64(product.ProductID)]
if detail == nil || detail.SourceProductWarehouseID == nil || detail.DestProductWarehouseID == nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, "Data transfer detail tidak valid")
}
flagGroupCode, ok := flagGroupByProduct[product.ProductID]
if !ok {
var err error
flagGroupCode, err = s.resolveTransferFlagGroup(ctx, tx, product.ProductID)
if err != nil {
return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("FIFO v2 route tidak ditemukan untuk produk %d: %v", product.ProductID, err))
}
flagGroupByProduct[product.ProductID] = flagGroupCode
}
if err := tx.Model(&entity.StockTransferDetail{}).
Where("id = ?", detail.Id).
Updates(map[string]interface{}{
"usage_qty": product.ProductQty,
"pending_qty": 0,
"total_qty": product.ProductQty,
}).Error; err != nil {
s.Log.Errorf("Failed to update transfer detail seed fields for detail_id=%d, product_id=%d: %+v", detail.Id, product.ProductID, err)
return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal memperbarui data tracking")
}
asOf := req.TransferDate
if _, err := s.FifoStockV2Svc.Reflow(ctx, commonSvc.FifoStockV2ReflowRequest{
FlagGroupCode: flagGroupCode,
ProductWarehouseID: uint(*detail.SourceProductWarehouseID),
AsOf: &asOf,
Tx: tx,
}); err != nil {
return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Stok tidak mencukupi untuk produk %d di gudang asal. Error: %v", product.ProductID, err))
}
if _, err := s.FifoStockV2Svc.Reflow(ctx, commonSvc.FifoStockV2ReflowRequest{
FlagGroupCode: flagGroupCode,
ProductWarehouseID: uint(*detail.DestProductWarehouseID),
AsOf: &asOf,
Tx: tx,
}); err != nil {
return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Gagal reflow stok tujuan untuk produk %d. Error: %v", product.ProductID, err))
}
type usageSnapshot struct {
UsageQty float64 `gorm:"column:usage_qty"`
PendingQty float64 `gorm:"column:pending_qty"`
}
var usage usageSnapshot
if err := tx.WithContext(ctx).
Table("stock_transfer_details").
Select("usage_qty, pending_qty").
Where("id = ?", detail.Id).
Take(&usage).Error; err != nil {
s.Log.Errorf("Failed to read transfer usage snapshot detail_id=%d, product_id=%d: %+v", detail.Id, product.ProductID, err)
return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data tracking")
}
outUsageQty := usage.UsageQty
outPendingQty := usage.PendingQty
if outPendingQty > 1e-6 {
return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Stok tidak mencukupi untuk produk %d di gudang asal", product.ProductID))
}
stockLogDecrease := &entity.StockLog{
ProductWarehouseId: uint(*detail.SourceProductWarehouseID),
CreatedBy: req.ActorID,
Increase: 0,
Decrease: outUsageQty,
LoggableType: string(utils.StockLogTypeTransfer),
LoggableId: uint(detail.Id),
Notes: req.StockLogNotes,
}
stockLogs, err := stockLogsRepoTX.GetByProductWarehouse(ctx, uint(*detail.SourceProductWarehouseID), 1)
if err != nil {
s.Log.Errorf("Failed to get stock logs: %+v", err)
return nil, 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 := stockLogsRepoTX.CreateOne(ctx, stockLogDecrease, nil); err != nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat log stok keluar")
}
stockLogIncrease := &entity.StockLog{
ProductWarehouseId: uint(*detail.DestProductWarehouseID),
CreatedBy: req.ActorID,
Increase: outUsageQty,
Decrease: 0,
LoggableType: string(utils.StockLogTypeTransfer),
LoggableId: uint(detail.Id),
Notes: req.StockLogNotes,
}
stockLogs, err = stockLogsRepoTX.GetByProductWarehouse(ctx, uint(*detail.DestProductWarehouseID), 1)
if err != nil {
s.Log.Errorf("Failed to get stock logs: %+v", err)
return nil, 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 := stockLogsRepoTX.CreateOne(ctx, stockLogIncrease, nil); err != nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat log stok masuk")
}
}
return &transferMovementResult{
Transfer: entityTransfer,
DetailByPID: detailMap,
}, nil
}
func (s *transferService) deleteTransferCore(
ctx context.Context,
tx *gorm.DB,
transferID uint64,
actorID uint,
) ([]entity.StockTransferDetail, error) {
stockLogRepoTx := rStockLogs.NewStockLogRepository(tx)
var transfer entity.StockTransfer
if err := tx.WithContext(ctx).
Clauses(clause.Locking{Strength: "UPDATE"}).
Where("id = ?", transferID).
Where("deleted_at IS NULL").
Take(&transfer).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Transfer dengan ID %d tidak ditemukan", transferID))
}
return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data transfer")
}
var details []entity.StockTransferDetail
if err := tx.WithContext(ctx).
Clauses(clause.Locking{Strength: "UPDATE"}).
Where("stock_transfer_id = ?", transfer.Id).
Where("deleted_at IS NULL").
Order("id ASC").
Find(&details).Error; err != nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil detail transfer")
}
if len(details) == 0 {
return nil, fiber.NewError(fiber.StatusBadRequest, "Transfer tidak memiliki detail produk")
}
detailIDs := make([]uint64, 0, len(details))
for _, detail := range details {
detailIDs = append(detailIDs, detail.Id)
}
if err := s.ensureDeletePolicyForDownstreamConsumption(ctx, tx, detailIDs); err != nil {
return nil, err
}
type reflowKey struct {
flagGroupCode string
productWarehouseID uint
}
destReflows := make(map[reflowKey]struct{})
for _, detail := range details {
if detail.SourceProductWarehouseID == nil || *detail.SourceProductWarehouseID == 0 {
return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Detail transfer %d tidak memiliki source product warehouse valid", detail.Id))
}
if detail.DestProductWarehouseID == nil || *detail.DestProductWarehouseID == 0 {
return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Detail transfer %d tidak memiliki destination product warehouse valid", detail.Id))
}
flagGroupCode, err := s.resolveTransferFlagGroup(ctx, tx, uint(detail.ProductId))
if err != nil {
return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("FIFO v2 route tidak ditemukan untuk produk %d: %v", detail.ProductId, err))
}
rollbackRes, err := s.FifoStockV2Svc.Rollback(ctx, commonSvc.FifoStockV2RollbackRequest{
ProductWarehouseID: uint(*detail.SourceProductWarehouseID),
Usable: commonSvc.FifoStockV2Ref{
ID: uint(detail.Id),
LegacyTypeKey: fifo.UsableKeyStockTransferOut.String(),
FunctionCode: "STOCK_TRANSFER_OUT",
},
Reason: fmt.Sprintf("transfer delete #%s", transfer.MovementNumber),
Tx: tx,
})
if err != nil {
return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Gagal rollback FIFO v2 transfer detail %d: %v", detail.Id, err))
}
releasedQty := 0.0
if rollbackRes != nil {
releasedQty = rollbackRes.ReleasedQty
}
if detail.UsageQty > 1e-6 && releasedQty < detail.UsageQty-1e-6 {
return nil, fiber.NewError(
fiber.StatusBadRequest,
fmt.Sprintf("Rollback FIFO v2 source transfer detail %d tidak lengkap. Dibutuhkan %.3f, terlepas %.3f", detail.Id, detail.UsageQty, releasedQty),
)
}
if releasedQty > 1e-6 {
if err := s.appendStockLog(
ctx,
stockLogRepoTx,
uint(*detail.SourceProductWarehouseID),
actorID,
releasedQty,
0,
uint(detail.Id),
fmt.Sprintf("TRANSFER DELETE #%s", transfer.MovementNumber),
); err != nil {
return nil, err
}
}
destDecreaseQty := detail.TotalQty
if destDecreaseQty <= 1e-6 {
destDecreaseQty = detail.UsageQty
}
if destDecreaseQty > 1e-6 {
if err := s.appendStockLog(
ctx,
stockLogRepoTx,
uint(*detail.DestProductWarehouseID),
actorID,
0,
destDecreaseQty,
uint(detail.Id),
fmt.Sprintf("TRANSFER DELETE #%s", transfer.MovementNumber),
); err != nil {
return nil, err
}
}
destReflows[reflowKey{
flagGroupCode: flagGroupCode,
productWarehouseID: uint(*detail.DestProductWarehouseID),
}] = struct{}{}
}
now := time.Now().UTC()
if err := tx.WithContext(ctx).
Where("stock_transfer_detail_id IN ?", detailIDs).
Delete(&entity.StockTransferDeliveryItem{}).Error; err != nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal menghapus item delivery transfer")
}
if err := tx.WithContext(ctx).
Model(&entity.StockTransferDelivery{}).
Where("stock_transfer_id = ?", transfer.Id).
Where("deleted_at IS NULL").
Updates(map[string]any{
"deleted_at": now,
"updated_at": now,
}).Error; err != nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal menghapus delivery transfer")
}
if err := tx.WithContext(ctx).
Model(&entity.StockTransferDetail{}).
Where("id IN ?", detailIDs).
Where("deleted_at IS NULL").
Updates(map[string]any{
"deleted_at": now,
"updated_at": now,
}).Error; err != nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal menghapus detail transfer")
}
asOf := transfer.TransferDate
for key := range destReflows {
if _, err := s.FifoStockV2Svc.Reflow(ctx, commonSvc.FifoStockV2ReflowRequest{
FlagGroupCode: key.flagGroupCode,
ProductWarehouseID: key.productWarehouseID,
AsOf: &asOf,
Tx: tx,
}); err != nil {
return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Gagal reflow stok tujuan saat delete transfer: %v", err))
}
}
if err := tx.WithContext(ctx).
Model(&entity.StockTransfer{}).
Where("id = ?", transfer.Id).
Where("deleted_at IS NULL").
Updates(map[string]any{
"deleted_at": now,
"updated_at": now,
}).Error; err != nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal menghapus transfer")
}
return details, nil
}
@@ -0,0 +1,481 @@
package service
import (
"context"
"strings"
"testing"
"time"
"github.com/glebarez/sqlite"
"github.com/go-playground/validator/v10"
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"
rTransfer "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/transfers/repositories"
rWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/repositories"
rStockLogs "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/repositories"
"gitlab.com/mbugroup/lti-api.git/internal/utils/fifo"
"gorm.io/gorm"
)
func TestCreateSystemTransferCreatesAuditableMovement(t *testing.T) {
db := setupSystemTransferTestDB(t)
svc, fifoStub := newSystemTransferTestService(t, db)
transferDate := time.Date(2026, 4, 7, 0, 0, 0, 0, time.UTC)
result, err := svc.CreateSystemTransfer(context.Background(), &SystemTransferRequest{
TransferReason: "EGG_FARM_CUTOVER|run_id=test-1|location=Jamali|cutover_date=2026-04-07",
TransferDate: transferDate,
SourceWarehouseID: 1,
DestinationWarehouseID: 2,
Products: []SystemTransferProduct{
{ProductID: 8, ProductQty: 50},
},
ActorID: 99,
MovementNumber: "PND-LTI-TEST-0001",
StockLogNotes: "EGG_FARM_CUTOVER|run_id=test-1|location=Jamali|cutover_date=2026-04-07",
})
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if result == nil {
t.Fatal("expected transfer result")
}
if result.MovementNumber != "PND-LTI-TEST-0001" {
t.Fatalf("expected movement number to be preserved, got %s", result.MovementNumber)
}
var transfer entity.StockTransfer
if err := db.WithContext(context.Background()).First(&transfer, result.Id).Error; err != nil {
t.Fatalf("failed to load created transfer: %v", err)
}
var detail entity.StockTransferDetail
if err := db.WithContext(context.Background()).
Where("stock_transfer_id = ?", transfer.Id).
First(&detail).Error; err != nil {
t.Fatalf("failed to load transfer detail: %v", err)
}
if detail.UsageQty != 50 {
t.Fatalf("expected usage qty 50, got %v", detail.UsageQty)
}
if detail.TotalQty != 50 {
t.Fatalf("expected total qty 50, got %v", detail.TotalQty)
}
if detail.SourceProductWarehouseID == nil || *detail.SourceProductWarehouseID != 10 {
t.Fatalf("expected source product warehouse 10, got %+v", detail.SourceProductWarehouseID)
}
if detail.DestProductWarehouseID == nil {
t.Fatal("expected destination product warehouse to be created")
}
var destPW entity.ProductWarehouse
if err := db.WithContext(context.Background()).
First(&destPW, *detail.DestProductWarehouseID).Error; err != nil {
t.Fatalf("failed to load destination product warehouse: %v", err)
}
if destPW.WarehouseId != 2 {
t.Fatalf("expected destination warehouse id 2, got %d", destPW.WarehouseId)
}
if destPW.ProductId != 8 {
t.Fatalf("expected destination product id 8, got %d", destPW.ProductId)
}
if destPW.ProjectFlockKandangId != nil {
t.Fatalf("expected destination product warehouse to stay shared, got %+v", destPW.ProjectFlockKandangId)
}
var stockLogs []entity.StockLog
if err := db.WithContext(context.Background()).
Order("id ASC").
Find(&stockLogs).Error; err != nil {
t.Fatalf("failed to load stock logs: %v", err)
}
if len(stockLogs) != 3 {
t.Fatalf("expected 3 stock logs (seed + out + in), got %d", len(stockLogs))
}
if stockLogs[1].ProductWarehouseId != 10 || stockLogs[1].Decrease != 50 || stockLogs[1].Stock != 0 {
t.Fatalf("unexpected source stock log after transfer: %+v", stockLogs[1])
}
if stockLogs[2].ProductWarehouseId != destPW.Id || stockLogs[2].Increase != 50 || stockLogs[2].Stock != 50 {
t.Fatalf("unexpected destination stock log after transfer: %+v", stockLogs[2])
}
if len(fifoStub.reflowCalls) != 2 {
t.Fatalf("expected 2 reflow calls, got %d", len(fifoStub.reflowCalls))
}
if fifoStub.reflowCalls[0].ProductWarehouseID != 10 {
t.Fatalf("expected first reflow on source pw 10, got %d", fifoStub.reflowCalls[0].ProductWarehouseID)
}
if fifoStub.reflowCalls[1].ProductWarehouseID != destPW.Id {
t.Fatalf("expected second reflow on destination pw %d, got %d", destPW.Id, fifoStub.reflowCalls[1].ProductWarehouseID)
}
}
func TestDeleteSystemTransferRollsBackTransferWhenUnused(t *testing.T) {
db := setupSystemTransferTestDB(t)
svc, fifoStub := newSystemTransferTestService(t, db)
created, err := svc.CreateSystemTransfer(context.Background(), &SystemTransferRequest{
TransferReason: "EGG_FARM_CUTOVER|run_id=test-rollback|location=Jamali|cutover_date=2026-04-07",
TransferDate: time.Date(2026, 4, 7, 0, 0, 0, 0, time.UTC),
SourceWarehouseID: 1,
DestinationWarehouseID: 2,
Products: []SystemTransferProduct{{ProductID: 8, ProductQty: 50}},
ActorID: 99,
MovementNumber: "PND-LTI-TEST-ROLLBACK",
StockLogNotes: "EGG_FARM_CUTOVER|run_id=test-rollback|location=Jamali|cutover_date=2026-04-07",
})
if err != nil {
t.Fatalf("failed to create transfer: %v", err)
}
var detail entity.StockTransferDetail
if err := db.WithContext(context.Background()).
Where("stock_transfer_id = ?", created.Id).
First(&detail).Error; err != nil {
t.Fatalf("failed to load transfer detail: %v", err)
}
fifoStub.rollbackReleasedQty[detail.Id] = detail.UsageQty
if err := svc.DeleteSystemTransfer(context.Background(), uint(created.Id), 99); err != nil {
t.Fatalf("expected delete to succeed, got %v", err)
}
var deletedTransfer entity.StockTransfer
if err := db.WithContext(context.Background()).Unscoped().First(&deletedTransfer, created.Id).Error; err != nil {
t.Fatalf("failed to load deleted transfer: %v", err)
}
if deletedTransfer.DeletedAt == nil {
t.Fatal("expected transfer to be soft deleted")
}
var deletedDetail entity.StockTransferDetail
if err := db.WithContext(context.Background()).Unscoped().First(&deletedDetail, detail.Id).Error; err != nil {
t.Fatalf("failed to load deleted transfer detail: %v", err)
}
if deletedDetail.DeletedAt == nil {
t.Fatal("expected transfer detail to be soft deleted")
}
var stockLogs []entity.StockLog
if err := db.WithContext(context.Background()).
Order("id ASC").
Find(&stockLogs).Error; err != nil {
t.Fatalf("failed to load stock logs: %v", err)
}
if len(stockLogs) != 5 {
t.Fatalf("expected 5 stock logs (seed + create out/in + delete in/out), got %d", len(stockLogs))
}
if stockLogs[3].ProductWarehouseId != 10 || stockLogs[3].Increase != 50 || stockLogs[3].Stock != 50 {
t.Fatalf("unexpected rollback source stock log: %+v", stockLogs[3])
}
if stockLogs[4].Decrease != 50 || stockLogs[4].Stock != 0 {
t.Fatalf("unexpected rollback destination stock log: %+v", stockLogs[4])
}
if len(fifoStub.rollbackCalls) != 1 {
t.Fatalf("expected 1 rollback call, got %d", len(fifoStub.rollbackCalls))
}
if len(fifoStub.reflowCalls) != 3 {
t.Fatalf("expected 3 reflow calls (2 create + 1 delete), got %d", len(fifoStub.reflowCalls))
}
}
func TestDeleteSystemTransferRejectsRollbackWhenDownstreamConsumptionExists(t *testing.T) {
db := setupSystemTransferTestDB(t)
svc, fifoStub := newSystemTransferTestService(t, db)
created, err := svc.CreateSystemTransfer(context.Background(), &SystemTransferRequest{
TransferReason: "EGG_FARM_CUTOVER|run_id=test-guard|location=Jamali|cutover_date=2026-04-07",
TransferDate: time.Date(2026, 4, 7, 0, 0, 0, 0, time.UTC),
SourceWarehouseID: 1,
DestinationWarehouseID: 2,
Products: []SystemTransferProduct{{ProductID: 8, ProductQty: 50}},
ActorID: 99,
MovementNumber: "PND-LTI-TEST-GUARD",
StockLogNotes: "EGG_FARM_CUTOVER|run_id=test-guard|location=Jamali|cutover_date=2026-04-07",
})
if err != nil {
t.Fatalf("failed to create transfer: %v", err)
}
var detail entity.StockTransferDetail
if err := db.WithContext(context.Background()).
Where("stock_transfer_id = ?", created.Id).
First(&detail).Error; err != nil {
t.Fatalf("failed to load transfer detail: %v", err)
}
if err := db.Exec(`
INSERT INTO stock_allocations (
id, product_warehouse_id, stockable_type, stockable_id, usable_type, usable_id, qty,
allocation_purpose, status, function_code, flag_group_code, deleted_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL)
`, 1, *detail.DestProductWarehouseID, fifo.StockableKeyStockTransferIn.String(), detail.Id, fifo.UsableKeyRecordingStock.String(), 9001, 10,
entity.StockAllocationPurposeConsume, entity.StockAllocationStatusActive, "RECORDING_STOCK_OUT", "EGG").Error; err != nil {
t.Fatalf("failed to seed stock allocation: %v", err)
}
err = svc.DeleteSystemTransfer(context.Background(), uint(created.Id), 99)
if err == nil {
t.Fatal("expected delete to be blocked by downstream consumption")
}
if !strings.Contains(err.Error(), "tidak dapat dihapus") {
t.Fatalf("expected downstream guard error, got %v", err)
}
if len(fifoStub.rollbackCalls) != 0 {
t.Fatalf("expected rollback not to be called, got %d calls", len(fifoStub.rollbackCalls))
}
var transfer entity.StockTransfer
if err := db.WithContext(context.Background()).First(&transfer, created.Id).Error; err != nil {
t.Fatalf("failed to reload transfer: %v", err)
}
if transfer.DeletedAt != nil {
t.Fatal("expected transfer to remain active after guard failure")
}
}
type fifoStockV2Stub struct {
reflowCalls []commonSvc.FifoStockV2ReflowRequest
rollbackCalls []commonSvc.FifoStockV2RollbackRequest
rollbackReleasedQty map[uint64]float64
}
func (f *fifoStockV2Stub) Gather(ctx context.Context, req commonSvc.FifoStockV2GatherRequest) ([]commonSvc.FifoStockV2GatherRow, error) {
return nil, nil
}
func (f *fifoStockV2Stub) Allocate(ctx context.Context, req commonSvc.FifoStockV2AllocateRequest) (*commonSvc.FifoStockV2AllocateResult, error) {
return nil, nil
}
func (f *fifoStockV2Stub) Rollback(ctx context.Context, req commonSvc.FifoStockV2RollbackRequest) (*commonSvc.FifoStockV2RollbackResult, error) {
f.rollbackCalls = append(f.rollbackCalls, req)
return &commonSvc.FifoStockV2RollbackResult{
ReleasedQty: f.rollbackReleasedQty[uint64(req.Usable.ID)],
}, nil
}
func (f *fifoStockV2Stub) Reflow(ctx context.Context, req commonSvc.FifoStockV2ReflowRequest) (*commonSvc.FifoStockV2ReflowResult, error) {
f.reflowCalls = append(f.reflowCalls, req)
return &commonSvc.FifoStockV2ReflowResult{}, nil
}
func (f *fifoStockV2Stub) Recalculate(ctx context.Context, req commonSvc.FifoStockV2RecalculateRequest) (*commonSvc.FifoStockV2RecalculateResult, error) {
return &commonSvc.FifoStockV2RecalculateResult{}, nil
}
func newSystemTransferTestService(t *testing.T, db *gorm.DB) (TransferService, *fifoStockV2Stub) {
t.Helper()
fifoStub := &fifoStockV2Stub{rollbackReleasedQty: make(map[uint64]float64)}
return NewTransferService(
validator.New(),
rTransfer.NewStockTransferRepository(db),
rTransfer.NewStockTransferDetailRepository(db),
rTransfer.NewStockTransferDeliveryRepository(db),
rTransfer.NewStockTransferDeliveryItemRepository(db),
rStockLogs.NewStockLogRepository(db),
rProductWarehouse.NewProductWarehouseRepository(db),
nil,
rWarehouse.NewWarehouseRepository(db),
nil,
nil,
nil,
fifoStub,
nil,
), fifoStub
}
func setupSystemTransferTestDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{})
if err != nil {
t.Fatalf("failed opening sqlite db: %v", err)
}
statements := []string{
`CREATE TABLE warehouses (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
type TEXT NOT NULL,
area_id INTEGER NOT NULL DEFAULT 1,
location_id INTEGER NULL,
kandang_id INTEGER NULL,
created_by INTEGER NOT NULL DEFAULT 1,
created_at TIMESTAMP NULL,
updated_at TIMESTAMP NULL,
deleted_at TIMESTAMP NULL
)`,
`CREATE TABLE product_categories (
id INTEGER PRIMARY KEY,
name TEXT NULL,
code TEXT NOT NULL,
created_by INTEGER NOT NULL DEFAULT 1,
created_at TIMESTAMP NULL,
updated_at TIMESTAMP NULL,
deleted_at TIMESTAMP NULL
)`,
`CREATE TABLE products (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
brand TEXT NOT NULL DEFAULT '',
sku TEXT NULL,
uom_id INTEGER NOT NULL DEFAULT 1,
product_category_id INTEGER NULL,
product_price NUMERIC NOT NULL DEFAULT 0,
selling_price NUMERIC NULL,
tax NUMERIC NULL,
expiry_period INTEGER NULL,
created_by INTEGER NOT NULL DEFAULT 1,
created_at TIMESTAMP NULL,
updated_at TIMESTAMP NULL,
deleted_at TIMESTAMP NULL,
is_visible BOOLEAN NOT NULL DEFAULT 1
)`,
`CREATE TABLE product_warehouses (
id INTEGER PRIMARY KEY AUTOINCREMENT,
product_id INTEGER NOT NULL,
warehouse_id INTEGER NOT NULL,
project_flock_kandang_id INTEGER NULL,
qty NUMERIC NOT NULL DEFAULT 0
)`,
`CREATE TABLE flags (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
flagable_id INTEGER NOT NULL,
flagable_type TEXT NOT NULL
)`,
`CREATE TABLE fifo_stock_v2_flag_groups (
code TEXT PRIMARY KEY,
is_active BOOLEAN NOT NULL
)`,
`CREATE TABLE fifo_stock_v2_flag_members (
id INTEGER PRIMARY KEY AUTOINCREMENT,
flag_name TEXT NOT NULL,
flag_group_code TEXT NOT NULL,
is_active BOOLEAN NOT NULL
)`,
`CREATE TABLE fifo_stock_v2_route_rules (
id INTEGER PRIMARY KEY AUTOINCREMENT,
lane TEXT NOT NULL,
function_code TEXT NOT NULL,
source_table TEXT NOT NULL,
flag_group_code TEXT NOT NULL,
legacy_type_key TEXT NULL,
allow_pending_default BOOLEAN NOT NULL DEFAULT 0,
is_active BOOLEAN NOT NULL
)`,
`CREATE TABLE fifo_stock_v2_overconsume_rules (
id INTEGER PRIMARY KEY AUTOINCREMENT,
lane TEXT NOT NULL,
flag_group_code TEXT NULL,
function_code TEXT NULL,
allow_overconsume BOOLEAN NOT NULL DEFAULT 0,
is_active BOOLEAN NOT NULL DEFAULT 1,
priority INTEGER NOT NULL DEFAULT 1
)`,
`CREATE TABLE stock_transfers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
movement_number TEXT NOT NULL,
from_warehouse_id INTEGER NOT NULL,
to_warehouse_id INTEGER NOT NULL,
transfer_date TIMESTAMP NOT NULL,
reason TEXT,
created_by INTEGER NOT NULL,
created_at TIMESTAMP NULL,
updated_at TIMESTAMP NULL,
deleted_at TIMESTAMP NULL
)`,
`CREATE TABLE stock_transfer_details (
id INTEGER PRIMARY KEY AUTOINCREMENT,
stock_transfer_id INTEGER NOT NULL,
product_id INTEGER NOT NULL,
source_product_warehouse_id INTEGER NULL,
usage_qty NUMERIC NOT NULL DEFAULT 0,
pending_qty NUMERIC NOT NULL DEFAULT 0,
dest_product_warehouse_id INTEGER NULL,
total_qty NUMERIC NOT NULL DEFAULT 0,
total_used NUMERIC NOT NULL DEFAULT 0,
expense_nonstock_id INTEGER NULL,
created_at TIMESTAMP NULL,
updated_at TIMESTAMP NULL,
deleted_at TIMESTAMP NULL
)`,
`CREATE TABLE stock_transfer_deliveries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
stock_transfer_id INTEGER NOT NULL,
supplier_id INTEGER NULL,
vehicle_plate TEXT NULL,
driver_name TEXT NULL,
shipping_cost_item NUMERIC NULL,
shipping_cost_total NUMERIC NULL,
created_at TIMESTAMP NULL,
updated_at TIMESTAMP NULL,
deleted_at TIMESTAMP NULL
)`,
`CREATE TABLE stock_transfer_delivery_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
stock_transfer_delivery_id INTEGER NOT NULL,
stock_transfer_detail_id INTEGER NOT NULL,
quantity NUMERIC NOT NULL DEFAULT 0,
created_at TIMESTAMP NULL,
updated_at TIMESTAMP NULL,
deleted_at TIMESTAMP NULL
)`,
`CREATE TABLE stock_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
product_warehouse_id INTEGER NOT NULL,
created_by INTEGER NOT NULL,
increase NUMERIC NOT NULL DEFAULT 0,
decrease NUMERIC NOT NULL DEFAULT 0,
stock NUMERIC NOT NULL DEFAULT 0,
loggable_type TEXT NOT NULL,
loggable_id INTEGER NOT NULL,
notes TEXT NULL,
created_at TIMESTAMP NULL
)`,
`CREATE TABLE stock_allocations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
product_warehouse_id INTEGER NOT NULL,
stockable_type TEXT NOT NULL,
stockable_id INTEGER NOT NULL,
usable_type TEXT NOT NULL,
usable_id INTEGER NOT NULL,
qty NUMERIC NOT NULL DEFAULT 0,
allocation_purpose TEXT NOT NULL,
status TEXT NOT NULL,
function_code TEXT NULL,
flag_group_code TEXT NULL,
deleted_at TIMESTAMP NULL
)`,
`INSERT INTO warehouses (id, name, type, area_id, location_id, kandang_id, created_by, created_at, updated_at, deleted_at) VALUES
(1, 'Gudang Kandang Legacy', 'LOKASI', 1, 16, NULL, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL),
(2, 'Gudang Farm Jamali', 'LOKASI', 1, 16, NULL, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL)`,
`INSERT INTO product_categories (id, name, code, created_by, created_at, updated_at, deleted_at) VALUES (1, 'Egg', 'EGG', 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL)`,
`INSERT INTO products (
id, name, brand, sku, uom_id, product_category_id, product_price, selling_price, tax,
expiry_period, created_by, created_at, updated_at, deleted_at, is_visible
) VALUES (
8, 'Telur Utuh', '', NULL, 1, 1, 0, NULL, NULL, NULL, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL, 1
)`,
`INSERT INTO product_warehouses (id, product_id, warehouse_id, project_flock_kandang_id, qty) VALUES
(10, 8, 1, NULL, 50)`,
`INSERT INTO flags (name, flagable_id, flagable_type) VALUES ('TELUR', 8, 'products')`,
`INSERT INTO fifo_stock_v2_flag_groups (code, is_active) VALUES ('EGG', 1)`,
`INSERT INTO fifo_stock_v2_flag_members (flag_name, flag_group_code, is_active) VALUES ('TELUR', 'EGG', 1)`,
`INSERT INTO fifo_stock_v2_route_rules (lane, function_code, source_table, flag_group_code, legacy_type_key, allow_pending_default, is_active) VALUES
('USABLE', 'STOCK_TRANSFER_OUT', 'stock_transfer_details', 'EGG', 'STOCK_TRANSFER_OUT', 0, 1)`,
`INSERT INTO stock_logs (id, product_warehouse_id, created_by, increase, decrease, stock, loggable_type, loggable_id, notes, created_at) VALUES
(1, 10, 1, 50, 0, 50, 'PURCHASE', 1, 'seed', CURRENT_TIMESTAMP)`,
}
for _, stmt := range statements {
if err := db.Exec(stmt).Error; err != nil {
t.Fatalf("failed preparing test schema: %v\nstatement: %s", err, stmt)
}
}
return db
}
@@ -26,7 +26,6 @@ import (
"gitlab.com/mbugroup/lti-api.git/internal/utils/fifo"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type TransferService interface {
@@ -34,6 +33,8 @@ type TransferService interface {
GetOne(ctx *fiber.Ctx, id uint) (*entity.StockTransfer, error)
CreateOne(ctx *fiber.Ctx, req *validation.TransferRequest, files []*multipart.FileHeader) (*entity.StockTransfer, error)
DeleteOne(ctx *fiber.Ctx, id uint) error
CreateSystemTransfer(ctx context.Context, req *SystemTransferRequest) (*entity.StockTransfer, error)
DeleteSystemTransfer(ctx context.Context, id uint, actorID uint) error
}
type transferService struct {
@@ -63,6 +64,27 @@ type downstreamDependency struct {
FlagGroupCode string `gorm:"column:flag_group_code"`
}
type SystemTransferProduct struct {
ProductID uint
ProductQty float64
}
type SystemTransferRequest struct {
TransferReason string
TransferDate time.Time
SourceWarehouseID uint
DestinationWarehouseID uint
Products []SystemTransferProduct
ActorID uint
MovementNumber string
StockLogNotes string
}
type transferMovementResult struct {
Transfer *entity.StockTransfer
DetailByPID map[uint64]*entity.StockTransferDetail
}
func NewTransferService(validate *validator.Validate, stockTransferRepo rStockTransfer.StockTransferRepository, stockTransferDetailRepo rStockTransfer.StockTransferDetailRepository, stockTransferDeliveryRepo rStockTransfer.StockTransferDeliveryRepository, stockTransferDeliveryItemRepo rStockTransfer.StockTransferDeliveryItemRepository, stockLogsRepo rStockLogs.StockLogRepository, productWarehouseRepo rProductWarehouse.ProductWarehouseRepository, supplierRepo rSupplier.SupplierRepository, warehouseRepo warehouseRepo.WarehouseRepository, projectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository, projectFlockPopulationRepo projectFlockKandangRepo.ProjectFlockPopulationRepository, documentSvc commonSvc.DocumentService, fifoStockV2Svc commonSvc.FifoStockV2Service, expenseBridge TransferExpenseBridge) TransferService {
return &transferService{
Log: utils.Log,
@@ -185,50 +207,17 @@ func (s transferService) GetOne(c *fiber.Ctx, id uint) (*entity.StockTransfer, e
}
func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferRequest, files []*multipart.FileHeader) (*entity.StockTransfer, error) {
pwIDs := make([]uint, 0, len(req.Products))
products := make([]SystemTransferProduct, 0, len(req.Products))
for _, product := range req.Products {
sourcePW, err := s.ProductWarehouseRepo.GetProductWarehouseByProductAndWarehouseID(
c.Context(), uint(product.ProductID), uint(req.SourceWarehouseID),
)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Produk dengan ID %d tidak ditemukan di gudang asal (ID: %d)", product.ProductID, req.SourceWarehouseID))
}
s.Log.Errorf("Failed to fetch product warehouse for product_id=%d, warehouse_id=%d: %+v", product.ProductID, req.SourceWarehouseID, err)
return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengecek stok produk")
}
if sourcePW.Quantity < product.ProductQty {
return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Stok produk %d di gudang asal tidak mencukupi. Tersedia: %.2f, Diminta: %.2f", product.ProductID, sourcePW.Quantity, product.ProductQty))
}
pwIDs = append(pwIDs, sourcePW.Id)
products = append(products, SystemTransferProduct{
ProductID: uint(product.ProductID),
ProductQty: product.ProductQty,
})
}
if err := commonSvc.EnsureProjectFlockNotClosedForProductWarehouses(
c.Context(),
s.StockTransferRepo.DB(),
pwIDs,
); err != nil {
if err := s.validateTransferWarehousesAndProducts(c.Context(), uint(req.SourceWarehouseID), uint(req.DestinationWarehouseID), products); err != nil {
return nil, err
}
destPfkID, err := s.getActiveProjectFlockKandangID(c.Context(), uint(req.DestinationWarehouseID))
if err != nil {
return nil, err
}
if destPfkID > 0 {
projectFlockKandang, err := s.ProjectFlockKandangRepo.GetByID(c.Context(), destPfkID)
if err != nil {
s.Log.Errorf("Failed to fetch project flock kandang by ID %d: %+v", destPfkID, err)
return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data project flock")
}
if projectFlockKandang.ClosedAt != nil {
return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Project flock untuk gudang tujuan sudah ditutup (closing) pada %s", projectFlockKandang.ClosedAt.Format("2006-01-02")))
}
}
actorID, err := m.ActorIDFromContext(c)
if err != nil {
return nil, err
@@ -249,11 +238,9 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques
}
for _, delivery := range req.Deliveries {
if delivery.SupplierID == 0 {
continue
}
if delivery.VehiclePlate == "" {
return nil, fiber.NewError(fiber.StatusBadRequest, "Vehicle plate wajib diisi ketika supplier dipilih")
}
@@ -280,104 +267,28 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques
}
}
movementNumber, err := s.StockTransferRepo.GenerateMovementNumber(c.Context())
if err != nil {
s.Log.Errorf("Failed to generate movement number: %+v", err)
return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat nomor transfer")
}
transferDate, _ := utils.ParseDateString(req.TransferDate)
entityTransfer := &entity.StockTransfer{
FromWarehouseId: uint64(req.SourceWarehouseID),
ToWarehouseId: uint64(req.DestinationWarehouseID),
Reason: req.TransferReason,
TransferDate: transferDate,
MovementNumber: movementNumber,
CreatedBy: uint64(actorID),
}
expensePayloads := make([]TransferExpenseReceivingPayload, 0)
var detailMap map[uint64]*entity.StockTransferDetail
var createdTransfer *entity.StockTransfer
err = s.StockTransferRepo.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error {
stockTransferRepoTX := s.StockTransferRepo.WithTx(tx)
stockTransferDetailRepoTX := s.StockTransferDetailRepo.WithTx(tx)
stockTransferDeliveryRepoTX := s.StockTransferDeliveryRepo.WithTx(tx)
stockTransferDeliveryItemRepoTX := s.StockTransferDeliveryItemRepo.WithTx(tx)
productWarehouseRepoTX := rProductWarehouse.NewProductWarehouseRepository(tx)
stocklogsRepoTx := s.StockLogsRepository.WithTx(tx)
if err := stockTransferRepoTX.CreateOne(c.Context(), entityTransfer, nil); err != nil {
return err
}
details := make([]*entity.StockTransferDetail, 0, len(req.Products))
detailMap := make(map[uint64]*entity.StockTransferDetail)
for _, product := range req.Products {
sourcePW, err := productWarehouseRepoTX.GetProductWarehouseByProductAndWarehouseID(
c.Context(), uint(product.ProductID), uint(req.SourceWarehouseID),
)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Produk %d tidak ditemukan di gudang asal (ID: %d)", product.ProductID, req.SourceWarehouseID))
}
s.Log.Errorf("Failed to fetch source product warehouse for product_id=%d, warehouse_id=%d: %+v", product.ProductID, req.SourceWarehouseID, err)
return fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data stok gudang asal")
}
destPW, err := productWarehouseRepoTX.GetProductWarehouseByProductAndWarehouseID(
c.Context(), uint(product.ProductID), uint(req.DestinationWarehouseID),
)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
s.Log.Errorf("Failed to fetch dest product warehouse for product_id=%d, warehouse_id=%d: %+v", product.ProductID, req.DestinationWarehouseID, err)
return fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data stok gudang tujuan")
}
if errors.Is(err, gorm.ErrRecordNotFound) {
ctx := c.Context()
projectFlockKandangID, err := s.getActiveProjectFlockKandangID(ctx, uint(req.DestinationWarehouseID))
if err != nil {
return err
}
var pfkID *uint
if projectFlockKandangID > 0 {
pfkID = &projectFlockKandangID
}
destPW = &entity.ProductWarehouse{
ProductId: uint(product.ProductID),
WarehouseId: uint(req.DestinationWarehouseID),
Quantity: 0,
ProjectFlockKandangId: pfkID,
}
if err := productWarehouseRepoTX.CreateOne(c.Context(), destPW, nil); err != nil {
s.Log.Errorf("Failed to create product warehouse for product_id=%d, warehouse_id=%d: %+v", product.ProductID, req.DestinationWarehouseID, err)
return fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat data stok gudang tujuan")
}
}
detail := &entity.StockTransferDetail{
StockTransferId: entityTransfer.Id,
ProductId: uint64(product.ProductID),
SourceProductWarehouseID: func() *uint64 { id := uint64(sourcePW.Id); return &id }(),
UsageQty: 0,
PendingQty: 0,
DestProductWarehouseID: func() *uint64 { id := uint64(destPW.Id); return &id }(),
TotalQty: 0,
TotalUsed: 0,
}
details = append(details, detail)
detailMap[uint64(product.ProductID)] = detail
}
if err := stockTransferDetailRepoTX.CreateMany(c.Context(), details, nil); err != nil {
movementResult, err := s.createTransferMovement(c.Context(), tx, &SystemTransferRequest{
TransferReason: req.TransferReason,
TransferDate: transferDate,
SourceWarehouseID: uint(req.SourceWarehouseID),
DestinationWarehouseID: uint(req.DestinationWarehouseID),
Products: products,
ActorID: actorID,
})
if err != nil {
return err
}
detailMap = movementResult.DetailByPID
createdTransfer = movementResult.Transfer
var deliveries []*entity.StockTransferDelivery
for _, delivery := range req.Deliveries {
@@ -389,7 +300,7 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques
return nil
}()
deliveries = append(deliveries, &entity.StockTransferDelivery{
StockTransferId: entityTransfer.Id,
StockTransferId: createdTransfer.Id,
SupplierId: supplierId,
VehiclePlate: delivery.VehiclePlate,
DriverName: delivery.DriverName,
@@ -402,7 +313,6 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques
}
var deliveryItems []*entity.StockTransferDeliveryItem
for i, delivery := range deliveries {
item := req.Deliveries[i]
for _, prod := range item.Products {
@@ -422,14 +332,11 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques
}
if s.DocumentSvc != nil && len(files) > 0 {
for deliveryIdx, delivery := range deliveries {
reqDelivery := req.Deliveries[deliveryIdx]
if reqDelivery.DocumentIndex < 0 {
continue
}
if reqDelivery.DocumentIndex >= len(files) {
return fiber.NewError(fiber.StatusBadRequest,
fmt.Sprintf("DocumentIndex %d untuk delivery %d melebihi jumlah file yang diupload (%d)",
@@ -437,14 +344,11 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques
}
file := files[reqDelivery.DocumentIndex]
documentFiles := []commonSvc.DocumentFile{
{
File: file,
Type: string(utils.DocumentTypeTransfer),
Index: &reqDelivery.DocumentIndex,
},
}
documentFiles := []commonSvc.DocumentFile{{
File: file,
Type: string(utils.DocumentTypeTransfer),
Index: &reqDelivery.DocumentIndex,
}}
_, err := s.DocumentSvc.UploadDocuments(c.Context(), commonSvc.DocumentUploadRequest{
DocumentableType: string(utils.DocumentableTypeTransfer),
DocumentableID: delivery.Id,
@@ -459,160 +363,31 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques
}
}
if s.FifoStockV2Svc == nil {
return fiber.NewError(fiber.StatusInternalServerError, "FIFO v2 service is not available")
}
flagGroupByProduct := make(map[uint]string, len(req.Products))
for _, product := range req.Products {
detail := detailMap[uint64(product.ProductID)]
if detail == nil || detail.SourceProductWarehouseID == nil || detail.DestProductWarehouseID == nil {
return fiber.NewError(fiber.StatusInternalServerError, "Data transfer detail tidak valid")
for _, delivery := range req.Deliveries {
if delivery.SupplierID == 0 {
continue
}
flagGroupCode, ok := flagGroupByProduct[uint(product.ProductID)]
if !ok {
flagGroupCode, err = s.resolveTransferFlagGroup(c.Context(), tx, uint(product.ProductID))
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("FIFO v2 route tidak ditemukan untuk produk %d: %v", product.ProductID, err))
}
flagGroupByProduct[uint(product.ProductID)] = flagGroupCode
}
if err := tx.Model(&entity.StockTransferDetail{}).
Where("id = ?", detail.Id).
Updates(map[string]interface{}{
"usage_qty": product.ProductQty,
"pending_qty": 0,
"total_qty": product.ProductQty,
}).Error; err != nil {
s.Log.Errorf("Failed to update transfer detail seed fields for detail_id=%d, product_id=%d: %+v", detail.Id, product.ProductID, err)
return fiber.NewError(fiber.StatusInternalServerError, "Gagal memperbarui data tracking")
}
asOf := transferDate
if _, err := s.FifoStockV2Svc.Reflow(c.Context(), commonSvc.FifoStockV2ReflowRequest{
FlagGroupCode: flagGroupCode,
ProductWarehouseID: uint(*detail.SourceProductWarehouseID),
AsOf: &asOf,
Tx: tx,
}); err != nil {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Stok tidak mencukupi untuk produk %d di gudang asal. Error: %v", product.ProductID, err))
}
if _, err := s.FifoStockV2Svc.Reflow(c.Context(), commonSvc.FifoStockV2ReflowRequest{
FlagGroupCode: flagGroupCode,
ProductWarehouseID: uint(*detail.DestProductWarehouseID),
AsOf: &asOf,
Tx: tx,
}); err != nil {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Gagal reflow stok tujuan untuk produk %d. Error: %v", product.ProductID, err))
}
type usageSnapshot struct {
UsageQty float64 `gorm:"column:usage_qty"`
PendingQty float64 `gorm:"column:pending_qty"`
}
var usage usageSnapshot
if err := tx.WithContext(c.Context()).
Table("stock_transfer_details").
Select("usage_qty, pending_qty").
Where("id = ?", detail.Id).
Take(&usage).Error; err != nil {
s.Log.Errorf("Failed to read transfer usage snapshot detail_id=%d, product_id=%d: %+v", detail.Id, product.ProductID, err)
return fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data tracking")
}
outUsageQty := usage.UsageQty
outPendingQty := usage.PendingQty
if outPendingQty > 1e-6 {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Stok tidak mencukupi untuk produk %d di gudang asal", product.ProductID))
}
stockLogDecrease := &entity.StockLog{
ProductWarehouseId: uint(*detail.SourceProductWarehouseID),
CreatedBy: uint(actorID),
Increase: 0,
Decrease: outUsageQty,
LoggableType: string(utils.StockLogTypeTransfer),
LoggableId: uint(detail.Id),
Notes: "",
}
stockLogs, err := s.StockLogsRepository.GetByProductWarehouse(c.Context(), uint(*detail.SourceProductWarehouseID), 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 := stocklogsRepoTx.CreateOne(c.Context(), stockLogDecrease, nil); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat log stok keluar")
}
inAddedQty := outUsageQty
stockLogIncrease := &entity.StockLog{
ProductWarehouseId: uint(*detail.DestProductWarehouseID),
CreatedBy: uint(actorID),
Increase: inAddedQty,
Decrease: 0,
LoggableType: string(utils.StockLogTypeTransfer),
LoggableId: uint(detail.Id),
Notes: "",
}
stockLogs, err = s.StockLogsRepository.GetByProductWarehouse(c.Context(), uint(*detail.DestProductWarehouseID), 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 := stocklogsRepoTx.CreateOne(c.Context(), stockLogIncrease, nil); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat log stok masuk")
}
}
if len(req.Deliveries) > 0 {
for _, delivery := range req.Deliveries {
// Skip adding to expensePayloads if SupplierID is 0 (optional)
if delivery.SupplierID == 0 {
for _, prod := range delivery.Products {
detail := detailMap[uint64(prod.ProductID)]
if detail == nil {
continue
}
for _, prod := range delivery.Products {
detail := detailMap[uint64(prod.ProductID)]
if detail == nil {
continue
}
warehouseID := uint(req.DestinationWarehouseID)
supplierID := uint(delivery.SupplierID)
deliveredDate := transferDate
deliveredQty := prod.ProductQty
payload := TransferExpenseReceivingPayload{
TransferDetailID: detail.Id,
ProductID: uint64(prod.ProductID),
WarehouseID: uint64(warehouseID),
SupplierID: uint64(supplierID),
DeliveredQty: deliveredQty,
DeliveredDate: &deliveredDate,
}
expensePayloads = append(expensePayloads, payload)
}
warehouseID := uint(req.DestinationWarehouseID)
supplierID := uint(delivery.SupplierID)
deliveredDate := transferDate
expensePayloads = append(expensePayloads, TransferExpenseReceivingPayload{
TransferDetailID: detail.Id,
ProductID: uint64(prod.ProductID),
WarehouseID: uint64(warehouseID),
SupplierID: uint64(supplierID),
DeliveredQty: prod.ProductQty,
DeliveredDate: &deliveredDate,
})
}
}
return nil
})
if err != nil {
if fiberErr, ok := err.(*fiber.Error); ok {
return nil, fiberErr
@@ -620,14 +395,13 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques
return nil, fiber.NewError(fiber.StatusInternalServerError, "Internal server error")
}
result, err := s.GetOne(c, uint(entityTransfer.Id))
result, err := s.GetOne(c, uint(createdTransfer.Id))
if err != nil {
return nil, err
}
if len(expensePayloads) > 0 {
if err := s.notifyExpenseItemsDelivered(c, entityTransfer.Id, expensePayloads); err != nil {
s.Log.Errorf("Failed to sync expense for transfer_id=%d, movement_number=%s: %+v", entityTransfer.Id, entityTransfer.MovementNumber, err)
if err := s.notifyExpenseItemsDelivered(c, createdTransfer.Id, expensePayloads); err != nil {
s.Log.Errorf("Failed to sync expense for transfer_id=%d, movement_number=%s: %+v", createdTransfer.Id, createdTransfer.MovementNumber, err)
return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal sinkronisasi data expense. Silakan cek manual di module expense")
}
}
@@ -650,177 +424,9 @@ func (s *transferService) DeleteOne(c *fiber.Ctx, id uint) error {
var deletedDetails []entity.StockTransferDetail
err = s.StockTransferRepo.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error {
stockLogRepoTx := rStockLogs.NewStockLogRepository(tx)
var transfer entity.StockTransfer
if err := tx.WithContext(c.Context()).
Clauses(clause.Locking{Strength: "UPDATE"}).
Where("id = ?", uint64(id)).
Where("deleted_at IS NULL").
Take(&transfer).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Transfer dengan ID %d tidak ditemukan", id))
}
return fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data transfer")
}
var details []entity.StockTransferDetail
if err := tx.WithContext(c.Context()).
Clauses(clause.Locking{Strength: "UPDATE"}).
Where("stock_transfer_id = ?", transfer.Id).
Where("deleted_at IS NULL").
Order("id ASC").
Find(&details).Error; err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil detail transfer")
}
if len(details) == 0 {
return fiber.NewError(fiber.StatusBadRequest, "Transfer tidak memiliki detail produk")
}
detailIDs := make([]uint64, 0, len(details))
for _, detail := range details {
detailIDs = append(detailIDs, detail.Id)
}
if err := s.ensureDeletePolicyForDownstreamConsumption(c.Context(), tx, detailIDs); err != nil {
return err
}
type reflowKey struct {
flagGroupCode string
productWarehouseID uint
}
destReflows := make(map[reflowKey]struct{})
for _, detail := range details {
if detail.SourceProductWarehouseID == nil || *detail.SourceProductWarehouseID == 0 {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Detail transfer %d tidak memiliki source product warehouse valid", detail.Id))
}
if detail.DestProductWarehouseID == nil || *detail.DestProductWarehouseID == 0 {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Detail transfer %d tidak memiliki destination product warehouse valid", detail.Id))
}
flagGroupCode, err := s.resolveTransferFlagGroup(c.Context(), tx, uint(detail.ProductId))
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("FIFO v2 route tidak ditemukan untuk produk %d: %v", detail.ProductId, err))
}
rollbackRes, err := s.FifoStockV2Svc.Rollback(c.Context(), commonSvc.FifoStockV2RollbackRequest{
ProductWarehouseID: uint(*detail.SourceProductWarehouseID),
Usable: commonSvc.FifoStockV2Ref{
ID: uint(detail.Id),
LegacyTypeKey: fifo.UsableKeyStockTransferOut.String(),
FunctionCode: "STOCK_TRANSFER_OUT",
},
Reason: fmt.Sprintf("transfer delete #%s", transfer.MovementNumber),
Tx: tx,
})
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Gagal rollback FIFO v2 transfer detail %d: %v", detail.Id, err))
}
releasedQty := 0.0
if rollbackRes != nil {
releasedQty = rollbackRes.ReleasedQty
}
if detail.UsageQty > 1e-6 && releasedQty < detail.UsageQty-1e-6 {
return fiber.NewError(
fiber.StatusBadRequest,
fmt.Sprintf("Rollback FIFO v2 source transfer detail %d tidak lengkap. Dibutuhkan %.3f, terlepas %.3f", detail.Id, detail.UsageQty, releasedQty),
)
}
if releasedQty > 1e-6 {
if err := s.appendStockLog(
c.Context(),
stockLogRepoTx,
uint(*detail.SourceProductWarehouseID),
actorID,
releasedQty,
0,
uint(detail.Id),
fmt.Sprintf("TRANSFER DELETE #%s", transfer.MovementNumber),
); err != nil {
return err
}
}
destDecreaseQty := detail.TotalQty
if destDecreaseQty <= 1e-6 {
destDecreaseQty = detail.UsageQty
}
if destDecreaseQty > 1e-6 {
if err := s.appendStockLog(
c.Context(),
stockLogRepoTx,
uint(*detail.DestProductWarehouseID),
actorID,
0,
destDecreaseQty,
uint(detail.Id),
fmt.Sprintf("TRANSFER DELETE #%s", transfer.MovementNumber),
); err != nil {
return err
}
}
destReflows[reflowKey{
flagGroupCode: flagGroupCode,
productWarehouseID: uint(*detail.DestProductWarehouseID),
}] = struct{}{}
}
now := time.Now().UTC()
if err := tx.WithContext(c.Context()).
Where("stock_transfer_detail_id IN ?", detailIDs).
Delete(&entity.StockTransferDeliveryItem{}).Error; err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Gagal menghapus item delivery transfer")
}
if err := tx.WithContext(c.Context()).
Model(&entity.StockTransferDelivery{}).
Where("stock_transfer_id = ?", transfer.Id).
Where("deleted_at IS NULL").
Updates(map[string]any{
"deleted_at": now,
"updated_at": now,
}).Error; err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Gagal menghapus delivery transfer")
}
if err := tx.WithContext(c.Context()).
Model(&entity.StockTransferDetail{}).
Where("id IN ?", detailIDs).
Where("deleted_at IS NULL").
Updates(map[string]any{
"deleted_at": now,
"updated_at": now,
}).Error; err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Gagal menghapus detail transfer")
}
asOf := transfer.TransferDate
for key := range destReflows {
if _, err := s.FifoStockV2Svc.Reflow(c.Context(), commonSvc.FifoStockV2ReflowRequest{
FlagGroupCode: key.flagGroupCode,
ProductWarehouseID: key.productWarehouseID,
AsOf: &asOf,
Tx: tx,
}); err != nil {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Gagal reflow stok tujuan saat delete transfer: %v", err))
}
}
if err := tx.WithContext(c.Context()).
Model(&entity.StockTransfer{}).
Where("id = ?", transfer.Id).
Where("deleted_at IS NULL").
Updates(map[string]any{
"deleted_at": now,
"updated_at": now,
}).Error; err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Gagal menghapus transfer")
}
deletedDetails = append(deletedDetails, details...)
return nil
var err error
deletedDetails, err = s.deleteTransferCore(c.Context(), tx, uint64(id), actorID)
return err
})
if err != nil {
if fiberErr, ok := err.(*fiber.Error); ok {
@@ -863,13 +469,31 @@ func (s *transferService) resolveTransferFlagGroup(
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
FROM products p
LEFT JOIN product_categories pc ON pc.id = p.product_category_id
WHERE p.id = ?
AND (
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 = p.id
AND fm.flag_group_code = rr.flag_group_code
)
OR (
NOT EXISTS (
SELECT 1
FROM flags f_any
WHERE f_any.flagable_type = ?
AND f_any.flagable_id = p.id
)
AND rr.flag_group_code = ?
AND UPPER(COALESCE(pc.code, '')) = 'EGG'
)
)
)
`, entity.FlagableTypeProduct, productID).
`, productID, entity.FlagableTypeProduct, entity.FlagableTypeProduct, utils.LegacyFlagGroupCodeByProductCategoryCode("EGG")).
Order("rr.id ASC").
Limit(1).
Take(&selected).Error
@@ -49,26 +49,30 @@ type MarketingDetailDTO struct {
}
type MarketingDeliveryProductDTO struct {
Id uint `json:"id"`
MarketingProductId uint `json:"marketing_product_id"`
Qty float64 `json:"qty"`
UnitPrice float64 `json:"unit_price"`
TotalWeight float64 `json:"total_weight"`
AvgWeight float64 `json:"avg_weight"`
TotalPrice float64 `json:"total_price"`
DeliveryDate *time.Time `json:"delivery_date"`
VehicleNumber string `json:"vehicle_number"`
ProductWarehouse *productwarehouseDTO.ProductWarehousNestedDTO `json:"product_warehouse,omitempty"`
Id uint `json:"id"`
MarketingProductId uint `json:"marketing_product_id"`
Qty float64 `json:"qty"`
UnitPrice float64 `json:"unit_price"`
TotalWeight float64 `json:"total_weight"`
AvgWeight float64 `json:"avg_weight"`
TotalPrice float64 `json:"total_price"`
DeliveryDate *time.Time `json:"delivery_date"`
VehicleNumber string `json:"vehicle_number"`
ConvertionUnit *string `json:"-"`
WeightPerConvertion *float64 `json:"-"`
ProductWarehouse *productwarehouseDTO.ProductWarehousNestedDTO `json:"product_warehouse,omitempty"`
}
type DeliveryItemDTO struct {
ProductWarehouse *productwarehouseDTO.ProductWarehousNestedDTO `json:"product_warehouse"`
Qty float64 `json:"qty"`
UnitPrice float64 `json:"unit_price"`
TotalWeight float64 `json:"total_weight"`
AvgWeight float64 `json:"avg_weight"`
TotalPrice float64 `json:"total_price"`
VehicleNumber string `json:"vehicle_number"`
ProductWarehouse *productwarehouseDTO.ProductWarehousNestedDTO `json:"product_warehouse"`
Qty float64 `json:"qty"`
UnitPrice float64 `json:"unit_price"`
TotalWeight float64 `json:"total_weight"`
AvgWeight float64 `json:"avg_weight"`
WeightPerConvertion *float64 `json:"weight_per_convertion"`
TotalPeti *float64 `json:"total_peti"`
TotalPrice float64 `json:"total_price"`
VehicleNumber string `json:"vehicle_number"`
}
type DeliveryGroupDTO struct {
@@ -147,15 +151,16 @@ func ToDeliveryMarketingProductDTO(e entity.MarketingProduct, marketingType stri
func ToMarketingDeliveryProductDTO(e entity.MarketingDeliveryProduct) MarketingDeliveryProductDTO {
return MarketingDeliveryProductDTO{
Id: e.Id,
MarketingProductId: e.MarketingProductId,
Qty: e.UsageQty,
UnitPrice: e.UnitPrice,
TotalWeight: e.TotalWeight,
AvgWeight: e.AvgWeight,
TotalPrice: e.TotalPrice,
DeliveryDate: e.DeliveryDate,
VehicleNumber: e.VehicleNumber,
Id: e.Id,
MarketingProductId: e.MarketingProductId,
Qty: e.UsageQty,
UnitPrice: e.UnitPrice,
TotalWeight: e.TotalWeight,
AvgWeight: e.AvgWeight,
TotalPrice: e.TotalPrice,
DeliveryDate: e.DeliveryDate,
VehicleNumber: e.VehicleNumber,
WeightPerConvertion: e.WeightPerConvertion,
}
}
@@ -285,6 +290,7 @@ func enrichDeliveryProductDTOsWithWarehouse(deliveryProductDTOs []MarketingDeliv
mapped := productwarehouseDTO.ToProductWarehouseNestedDTO(product.ProductWarehouse)
deliveryProductDTOs[i].ProductWarehouse = &mapped
}
deliveryProductDTOs[i].ConvertionUnit = product.ConvertionUnit
}
}
@@ -322,13 +328,21 @@ func groupDeliveryProducts(products []MarketingDeliveryProductDTO, soNumber stri
}
deliveryItem := DeliveryItemDTO{
ProductWarehouse: product.ProductWarehouse,
Qty: product.Qty,
UnitPrice: product.UnitPrice,
TotalWeight: product.TotalWeight,
AvgWeight: product.AvgWeight,
TotalPrice: product.TotalPrice,
VehicleNumber: product.VehicleNumber,
ProductWarehouse: product.ProductWarehouse,
Qty: product.Qty,
UnitPrice: product.UnitPrice,
TotalWeight: product.TotalWeight,
AvgWeight: product.AvgWeight,
WeightPerConvertion: product.WeightPerConvertion,
TotalPrice: product.TotalPrice,
VehicleNumber: product.VehicleNumber,
}
if product.ConvertionUnit != nil &&
strings.EqualFold(*product.ConvertionUnit, "PETI") &&
product.WeightPerConvertion != nil &&
*product.WeightPerConvertion > 0 {
totalPeti := product.TotalWeight / *product.WeightPerConvertion
deliveryItem.TotalPeti = &totalPeti
}
group.Deliveries = append(group.Deliveries, deliveryItem)
}
@@ -31,6 +31,8 @@ type MarketingDeliveryProductRepositoryImpl struct {
*commonRepo.BaseRepositoryImpl[entity.MarketingDeliveryProduct]
}
const marketingDeliveryProductSelectWithNullAttributed = "marketing_delivery_products.*, NULL AS attributed_project_flock_kandang_id"
func NewMarketingDeliveryProductRepository(db *gorm.DB) MarketingDeliveryProductRepository {
return &MarketingDeliveryProductRepositoryImpl{
BaseRepositoryImpl: commonRepo.NewBaseRepository[entity.MarketingDeliveryProduct](db),
@@ -43,9 +45,9 @@ func (r *MarketingDeliveryProductRepositoryImpl) GetDeliveryProductsByProjectFlo
attributionQuery := commonRepo.MarketingDeliveryAttributionRowsQuery(r.DB().WithContext(ctx))
db := r.DB().WithContext(ctx).
Select("DISTINCT "+marketingDeliveryProductSelectWithNullAttributed).
Joins("JOIN (?) AS mda ON mda.marketing_delivery_product_id = marketing_delivery_products.id", attributionQuery).
Where("mda.project_flock_id = ?", projectFlockID).
Distinct("marketing_delivery_products.*")
Where("mda.project_flock_id = ?", projectFlockID)
if callback != nil {
db = callback(db)
@@ -110,6 +112,7 @@ func (r *MarketingDeliveryProductRepositoryImpl) GetByMarketingId(ctx context.Co
// JOIN untuk filter by marketing_id yang ada di related table
db := r.DB().WithContext(ctx).
Select(marketingDeliveryProductSelectWithNullAttributed).
Joins("JOIN marketing_products ON marketing_products.id = marketing_delivery_products.marketing_product_id").
Where("marketing_products.marketing_id = ?", marketingId)
@@ -124,6 +127,8 @@ func (r *MarketingDeliveryProductRepositoryImpl) GetByMarketingProductID(ctx con
var deliveryProduct entity.MarketingDeliveryProduct
if err := r.DB().WithContext(ctx).
Model(&entity.MarketingDeliveryProduct{}).
Select(marketingDeliveryProductSelectWithNullAttributed).
Where("marketing_product_id = ?", marketingProductID).
First(&deliveryProduct).Error; err != nil {
return nil, err
@@ -132,6 +137,27 @@ func (r *MarketingDeliveryProductRepositoryImpl) GetByMarketingProductID(ctx con
return &deliveryProduct, nil
}
func (r *MarketingDeliveryProductRepositoryImpl) GetByID(
ctx context.Context,
id uint,
modifier func(*gorm.DB) *gorm.DB,
) (*entity.MarketingDeliveryProduct, error) {
var deliveryProduct entity.MarketingDeliveryProduct
q := r.DB().WithContext(ctx).
Model(&entity.MarketingDeliveryProduct{}).
Select(marketingDeliveryProductSelectWithNullAttributed)
if modifier != nil {
q = modifier(q)
}
if err := q.First(&deliveryProduct, id).Error; err != nil {
return nil, err
}
return &deliveryProduct, nil
}
func (r *MarketingDeliveryProductRepositoryImpl) GetAttributionRowsByDeliveryProductIDs(ctx context.Context, deliveryProductIDs []uint) ([]commonRepo.MarketingDeliveryAttributionRow, error) {
if len(deliveryProductIDs) == 0 {
return []commonRepo.MarketingDeliveryAttributionRow{}, nil
@@ -211,6 +237,7 @@ func (r *MarketingDeliveryProductRepositoryImpl) fetchClosingDeliveryProducts(
}
query := r.closingDeliveryProductsQuery(ctx).
Select(marketingDeliveryProductSelectWithNullAttributed).
Where("marketing_delivery_products.id IN ?", deliveryIDs).
Order("marketing_delivery_products.delivery_date DESC")
@@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"math"
"strings"
"time"
@@ -375,11 +376,12 @@ func (s *deliveryOrdersService) CreateOne(c *fiber.Ctx, req *validation.Delivery
itemDeliveryDate = &parsedDate
}
totalWeight, totalPrice := s.calculatePriceByMarketingType(marketing.MarketingType, requestedProduct.Qty, requestedProduct.AvgWeight, requestedProduct.UnitPrice, foundMarketingProduct.Week)
totalWeight, totalPrice := s.resolveDeliveryTotals(marketing.MarketingType, requestedProduct, foundMarketingProduct)
deliveryProduct.ProductWarehouseId = foundMarketingProduct.ProductWarehouseId
deliveryProduct.UnitPrice = requestedProduct.UnitPrice
deliveryProduct.AvgWeight = requestedProduct.AvgWeight
deliveryProduct.WeightPerConvertion = requestedProduct.WeightPerConvertion
deliveryProduct.TotalWeight = totalWeight
deliveryProduct.TotalPrice = totalPrice
deliveryProduct.DeliveryDate = itemDeliveryDate
@@ -498,11 +500,12 @@ func (s deliveryOrdersService) UpdateOne(c *fiber.Ctx, req *validation.DeliveryO
itemDeliveryDate = deliveryProduct.DeliveryDate
}
totalWeight, totalPrice := s.calculatePriceByMarketingType(marketing.MarketingType, requestedProduct.Qty, requestedProduct.AvgWeight, requestedProduct.UnitPrice, foundMarketingProduct.Week)
totalWeight, totalPrice := s.resolveDeliveryTotals(marketing.MarketingType, requestedProduct, foundMarketingProduct)
deliveryProduct.ProductWarehouseId = foundMarketingProduct.ProductWarehouseId
deliveryProduct.UnitPrice = requestedProduct.UnitPrice
deliveryProduct.AvgWeight = requestedProduct.AvgWeight
deliveryProduct.WeightPerConvertion = requestedProduct.WeightPerConvertion
deliveryProduct.TotalWeight = totalWeight
deliveryProduct.TotalPrice = totalPrice
deliveryProduct.DeliveryDate = itemDeliveryDate
@@ -541,20 +544,53 @@ func (s deliveryOrdersService) UpdateOne(c *fiber.Ctx, req *validation.DeliveryO
return s.getMarketingWithDeliveries(c, id)
}
func (s *deliveryOrdersService) calculatePriceByMarketingType(marketingType string, qty, avgWeight, unitPrice float64, week *int) (totalWeight, totalPrice float64) {
func (s *deliveryOrdersService) calculatePriceByMarketingType(marketingType string, qty, avgWeight, unitPrice float64, week *int, convertionUnit *string, _ *float64) (totalWeight, totalPrice float64) {
if marketingType == string(utils.MarketingTypeTrading) {
totalWeight = 0
totalPrice = qty * unitPrice
totalPrice = math.Round(qty*unitPrice*100) / 100
} else if marketingType == string(utils.MarketingTypeAyamPullet) && week != nil && *week > 0 {
totalWeight = qty * avgWeight
totalPrice = unitPrice * float64(*week) * qty
totalWeight = math.Round(qty*avgWeight*100) / 100
totalPrice = math.Round(unitPrice*float64(*week)*qty*100) / 100
} else {
totalWeight = qty * avgWeight
totalPrice = totalWeight * unitPrice
totalWeight = math.Round(qty*avgWeight*100) / 100
if marketingType == string(utils.MarketingTypeTelur) && convertionUnit != nil {
switch *convertionUnit {
case string(utils.ConvertionUnitQty):
totalPrice = math.Round(qty*unitPrice*100) / 100
return totalWeight, totalPrice
case string(utils.ConvertionUnitPeti):
totalPrice = math.Round(totalWeight*unitPrice*100) / 100
return totalWeight, totalPrice
}
}
totalPrice = math.Round(totalWeight*unitPrice*100) / 100
}
return totalWeight, totalPrice
}
func (s *deliveryOrdersService) resolveDeliveryTotals(marketingType string, requestedProduct validation.DeliveryProduct, marketingProduct *entity.MarketingProduct) (totalWeight, totalPrice float64) {
totalWeight, totalPrice = s.calculatePriceByMarketingType(
marketingType,
requestedProduct.Qty,
requestedProduct.AvgWeight,
requestedProduct.UnitPrice,
marketingProduct.Week,
marketingProduct.ConvertionUnit,
marketingProduct.WeightPerConvertion,
)
if requestedProduct.TotalWeight != nil {
totalWeight = *requestedProduct.TotalWeight
}
if requestedProduct.TotalPrice != nil {
totalPrice = *requestedProduct.TotalPrice
}
return totalWeight, totalPrice
}
func (s deliveryOrdersService) consumeDeliveryStock(ctx context.Context, tx *gorm.DB, deliveryProduct *entity.MarketingDeliveryProduct, marketingProduct *entity.MarketingProduct, requestedQty float64, actorID uint) error {
if marketingProduct == nil || marketingProduct.ProductWarehouseId == 0 {
return fiber.NewError(fiber.StatusInternalServerError, "Product warehouse not found")
@@ -141,6 +141,12 @@ func (s *salesOrdersService) CreateOne(c *fiber.Ctx, req *validation.Create) (*e
if item.ConvertionUnit != nil && !utils.IsValidConvertionUnit(*item.ConvertionUnit) {
return nil, fiber.NewError(fiber.StatusBadRequest, "Unit konversi tidak valid")
}
if item.MarketingType == string(utils.MarketingTypeTelur) &&
item.ConvertionUnit != nil &&
*item.ConvertionUnit == string(utils.ConvertionUnitPeti) &&
(item.WeightPerConvertion == nil || *item.WeightPerConvertion <= 0) {
return nil, fiber.NewError(fiber.StatusBadRequest, "weight_per_convertion wajib diisi dan > 0 untuk TELUR dengan convertion_unit PETI")
}
if err := m.EnsureProductWarehouseAccess(c, s.MarketingRepo.DB(), item.ProductWarehouseId); err != nil {
return nil, err
}
@@ -308,6 +314,12 @@ func (s salesOrdersService) UpdateOne(c *fiber.Ctx, req *validation.Update, id u
if item.ConvertionUnit != nil && !utils.IsValidConvertionUnit(*item.ConvertionUnit) {
return nil, fiber.NewError(fiber.StatusBadRequest, "Unit konversi tidak valid")
}
if item.MarketingType == string(utils.MarketingTypeTelur) &&
item.ConvertionUnit != nil &&
*item.ConvertionUnit == string(utils.ConvertionUnitPeti) &&
(item.WeightPerConvertion == nil || *item.WeightPerConvertion <= 0) {
return nil, fiber.NewError(fiber.StatusBadRequest, "weight_per_convertion wajib diisi dan > 0 untuk TELUR dengan convertion_unit PETI")
}
if err := m.EnsureProductWarehouseAccess(c, s.MarketingRepo.DB(), item.ProductWarehouseId); err != nil {
return nil, err
}
@@ -386,7 +398,15 @@ func (s salesOrdersService) UpdateOne(c *fiber.Ctx, req *validation.Update, id u
for _, rp := range req.MarketingProducts {
if old, ok := oldByPW[rp.ProductWarehouseId]; ok {
totalWeight, totalPrice := s.calculatePriceByMarketingType(rp.MarketingType, rp.Qty, rp.AvgWeight, rp.UnitPrice, rp.Week)
totalWeight, totalPrice := s.calculatePriceByMarketingType(
rp.MarketingType,
rp.Qty,
rp.AvgWeight,
rp.UnitPrice,
rp.Week,
rp.ConvertionUnit,
rp.WeightPerConvertion,
)
deliveryProduct, err := invDeliveryRepoTx.GetByMarketingProductID(c.Context(), old.Id)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
@@ -750,7 +770,15 @@ func (s salesOrdersService) Approval(c *fiber.Ctx, req *validation.Approve) ([]e
func (s *salesOrdersService) createMarketingProductWithDelivery(ctx context.Context, marketingId uint, marketingType string, rp validation.CreateMarketingProduct, marketingProductRepo repository.MarketingProductRepository, invDeliveryRepo repository.MarketingDeliveryProductRepository) error {
totalWeight, totalPrice := s.calculatePriceByMarketingType(marketingType, rp.Qty, rp.AvgWeight, rp.UnitPrice, rp.Week)
totalWeight, totalPrice := s.calculatePriceByMarketingType(
marketingType,
rp.Qty,
rp.AvgWeight,
rp.UnitPrice,
rp.Week,
rp.ConvertionUnit,
rp.WeightPerConvertion,
)
marketingProduct := &entity.MarketingProduct{
MarketingId: marketingId,
@@ -787,7 +815,7 @@ func (s *salesOrdersService) createMarketingProductWithDelivery(ctx context.Cont
return nil
}
func (s *salesOrdersService) calculatePriceByMarketingType(marketingType string, qty, avgWeight, unitPrice float64, week *int) (totalWeight, totalPrice float64) {
func (s *salesOrdersService) calculatePriceByMarketingType(marketingType string, qty, avgWeight, unitPrice float64, week *int, convertionUnit *string, _ *float64) (totalWeight, totalPrice float64) {
if marketingType == string(utils.MarketingTypeTrading) {
totalWeight = 0
totalPrice = math.Round(qty*unitPrice*100) / 100
@@ -796,6 +824,18 @@ func (s *salesOrdersService) calculatePriceByMarketingType(marketingType string,
totalPrice = math.Round(unitPrice*float64(*week)*qty*100) / 100
} else {
totalWeight = math.Round(qty*avgWeight*100) / 100
if marketingType == string(utils.MarketingTypeTelur) && convertionUnit != nil {
switch *convertionUnit {
case string(utils.ConvertionUnitQty):
totalPrice = math.Round(qty*unitPrice*100) / 100
return totalWeight, totalPrice
case string(utils.ConvertionUnitPeti):
totalPrice = math.Round(totalWeight*unitPrice*100) / 100
return totalWeight, totalPrice
}
}
totalPrice = math.Round(totalWeight*unitPrice*100) / 100
}
return totalWeight, totalPrice
@@ -1,12 +1,15 @@
package validation
type DeliveryProduct struct {
MarketingProductId uint `json:"marketing_product_id" validate:"required,gt=0"`
Qty float64 `json:"qty" validate:"omitempty,gte=0"`
UnitPrice float64 `json:"unit_price" validate:"omitempty,gte=0"`
AvgWeight float64 `json:"avg_weight" validate:"omitempty,gte=0"`
DeliveryDate string `json:"delivery_date" validate:"omitempty,datetime=2006-01-02"`
VehicleNumber string `json:"vehicle_number" validate:"omitempty,max=50"`
MarketingProductId uint `json:"marketing_product_id" validate:"required,gt=0"`
Qty float64 `json:"qty" validate:"omitempty,gte=0"`
UnitPrice float64 `json:"unit_price" validate:"omitempty,gte=0"`
AvgWeight float64 `json:"avg_weight" validate:"omitempty,gte=0"`
WeightPerConvertion *float64 `json:"weight_per_convertion" validate:"omitempty,gte=0"`
TotalWeight *float64 `json:"total_weight" validate:"omitempty,gte=0"`
TotalPrice *float64 `json:"total_price" validate:"omitempty,gte=0"`
DeliveryDate string `json:"delivery_date" validate:"omitempty,datetime=2006-01-02"`
VehicleNumber string `json:"vehicle_number" validate:"omitempty,max=50"`
}
type DeliveryOrderCreate struct {
@@ -55,9 +55,9 @@ func (s areaService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.Ar
db = s.withRelations(db)
db, scopeErr = m.ApplyAreaScope(c, db, "id")
if params.Search != "" {
return db.Where("name ILIKE ?", "%"+params.Search+"%")
db = db.Where("name ILIKE ?", "%"+params.Search+"%")
}
return db.Order("created_at DESC").Order("updated_at DESC")
return db.Order("name ASC").Order("id ASC")
})
if scopeErr != nil {
@@ -33,6 +33,14 @@ func (u *CustomerController) GetAll(c *fiber.Ctx) error {
return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0")
}
if hasMarketingParam := c.Query("has_marketing", ""); hasMarketingParam != "" {
value, err := strconv.ParseBool(hasMarketingParam)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, "invalid has_marketing value")
}
query.HasMarketing = &value
}
result, totalResults, err := u.CustomerService.GetAll(c, query)
if err != nil {
return err
@@ -53,7 +53,28 @@ func (s customerService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entit
customers, 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+"%")
db = db.Where("name ILIKE ?", "%"+params.Search+"%")
if params.HasMarketing != nil && *params.HasMarketing {
db = db.Where(`
EXISTS (
SELECT 1
FROM marketings
WHERE marketings.customer_id = customers.id
AND marketings.deleted_at IS NULL
)
`)
}
return db
}
if params.HasMarketing != nil && *params.HasMarketing {
db = db.Where(`
EXISTS (
SELECT 1
FROM marketings
WHERE marketings.customer_id = customers.id
AND marketings.deleted_at IS NULL
)
`)
}
return db.Order("created_at DESC").Order("updated_at DESC")
})
@@ -21,7 +21,8 @@ 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"`
Search string `query:"search" validate:"omitempty,max=50"`
Page int `query:"page" validate:"omitempty,number,min=1"`
Limit int `query:"limit" validate:"omitempty,number,min=1,max=100"`
Search string `query:"search" validate:"omitempty,max=50"`
HasMarketing *bool `query:"has_marketing" validate:"omitempty"`
}
@@ -71,7 +71,7 @@ func (s locationService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entit
)
`, utils.ProjectFlockCategoryLaying)
}
return db.Order("created_at DESC").Order("updated_at DESC")
return db.Order("locations.name ASC").Order("locations.id ASC")
})
if scopeErr != nil {
@@ -30,6 +30,9 @@ func toSupplierProductDTOs(relations []entity.ProductSupplier) []SupplierProduct
if product.Id == 0 {
continue
}
if len(product.Flags) == 0 {
continue
}
flags := make([]string, len(product.Flags))
for i, f := range product.Flags {
@@ -16,6 +16,7 @@ type WarehouseRepository interface {
NameExists(ctx context.Context, name string, excludeID *uint) (bool, error)
IdExists(ctx context.Context, id uint) (bool, error)
GetByKandangID(ctx context.Context, kandangId uint) (*entity.Warehouse, error)
GetByKandangIDAndLocationID(ctx context.Context, kandangId uint, locationId uint) (*entity.Warehouse, error)
GetLatestByKandangID(ctx context.Context, kandangId uint) (*entity.Warehouse, error)
}
@@ -62,6 +63,20 @@ func (r *WarehouseRepositoryImpl) GetByKandangID(ctx context.Context, kandangId
return &warehouse, nil
}
func (r *WarehouseRepositoryImpl) GetByKandangIDAndLocationID(ctx context.Context, kandangId uint, locationId uint) (*entity.Warehouse, error) {
var warehouse entity.Warehouse
err := r.db.WithContext(ctx).
Where("kandang_id = ?", kandangId).
Where("location_id = ?", locationId).
Where("deleted_at IS NULL").
Order("id ASC").
First(&warehouse).Error
if err != nil {
return nil, err
}
return &warehouse, nil
}
func (r *WarehouseRepositoryImpl) GetLatestByKandangID(ctx context.Context, kandangId uint) (*entity.Warehouse, error) {
var warehouse entity.Warehouse
err := r.db.WithContext(ctx).
@@ -0,0 +1,63 @@
package repository
import (
"context"
"testing"
"github.com/glebarez/sqlite"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
"gorm.io/gorm"
)
func TestGetByKandangIDAndLocationIDReturnsLocationMatchedWarehouse(t *testing.T) {
db := setupWarehouseRepositoryTestDB(t)
repo := NewWarehouseRepository(db)
warehouse, err := repo.GetByKandangIDAndLocationID(context.Background(), 5, 13)
if err != nil {
t.Fatalf("expected location-matched warehouse, got error: %v", err)
}
if warehouse.Id != 33 {
t.Fatalf("expected warehouse 33, got %d", warehouse.Id)
}
}
func TestGetByKandangIDKeepsLegacyFirstWarehouseBehavior(t *testing.T) {
db := setupWarehouseRepositoryTestDB(t)
repo := NewWarehouseRepository(db)
warehouse, err := repo.GetByKandangID(context.Background(), 5)
if err != nil {
t.Fatalf("expected warehouse, got error: %v", err)
}
if warehouse.Id != 17 {
t.Fatalf("expected legacy first warehouse 17, got %d", warehouse.Id)
}
}
func setupWarehouseRepositoryTestDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=private"), &gorm.Config{})
if err != nil {
t.Fatalf("failed opening sqlite db: %v", err)
}
if err := db.AutoMigrate(&entity.Warehouse{}); err != nil {
t.Fatalf("failed migrating warehouses: %v", err)
}
warehouses := []entity.Warehouse{
{Id: 17, Name: "Cijangkar 1", Type: "KANDANG", AreaId: 1, LocationId: uintPtr(1), KandangId: uintPtr(5), CreatedBy: 1},
{Id: 33, Name: "Gudang Cijangkar 1", Type: "KANDANG", AreaId: 1, LocationId: uintPtr(13), KandangId: uintPtr(5), CreatedBy: 1},
}
if err := db.Create(&warehouses).Error; err != nil {
t.Fatalf("failed seeding warehouses: %v", err)
}
return db
}
func uintPtr(v uint) *uint {
return &v
}
@@ -101,6 +101,25 @@ func (s chickinService) withRelations(db *gorm.DB) *gorm.DB {
}
func resolveWarehouseForProjectFlockKandang(ctx context.Context, warehouseRepo rWarehouse.WarehouseRepository, kandangID uint, locationID uint) (*entity.Warehouse, error) {
if warehouseRepo == nil {
return nil, gorm.ErrRecordNotFound
}
if kandangID == 0 {
return nil, gorm.ErrRecordNotFound
}
if locationID != 0 {
warehouse, err := warehouseRepo.GetByKandangIDAndLocationID(ctx, kandangID, locationID)
if err == nil {
return warehouse, nil
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err
}
}
return warehouseRepo.GetByKandangID(ctx, kandangID)
}
func (s chickinService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.ProjectChickin, int64, error) {
if err := s.Validate.Struct(params); err != nil {
return nil, 0, err
@@ -183,7 +202,12 @@ func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) ([]enti
return nil, fiber.NewError(fiber.StatusNotFound, "Project Flock Kandang not found")
}
warehouse, err := s.WarehouseRepo.GetByKandangID(c.Context(), projectFlockKandang.KandangId)
warehouse, err := resolveWarehouseForProjectFlockKandang(
c.Context(),
s.WarehouseRepo,
projectFlockKandang.KandangId,
projectFlockKandang.ProjectFlock.LocationId,
)
if err != nil {
return nil, fiber.NewError(fiber.StatusNotFound, "Warehouse for Kandang not found")
}
@@ -395,6 +419,11 @@ func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) ([]enti
if len(result) == 0 {
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to load created chickins")
}
invalidateFromDate := time.Time{}
for i := range result {
invalidateFromDate = commonSvc.MinNonZeroDateOnlyUTC(invalidateFromDate, result[i].ChickInDate)
}
s.invalidateDepreciationSnapshots(c.Context(), nil, []uint{req.ProjectFlockKandangId}, invalidateFromDate)
return result, nil
}
@@ -438,6 +467,8 @@ func (s chickinService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint)
if err != nil {
return nil, err
}
invalidateFromDate := commonSvc.MinNonZeroDateOnlyUTC(chickin.ChickInDate, updated.ChickInDate)
s.invalidateDepreciationSnapshots(c.Context(), nil, []uint{updated.ProjectFlockKandangId}, invalidateFromDate)
if updated.UsageQty > 0 {
if err := s.syncChickinTraceForProductWarehouse(c.Context(), nil, updated.ProductWarehouseId); err != nil {
@@ -542,6 +573,7 @@ func (s chickinService) DeleteOne(c *fiber.Ctx, id uint) error {
consumeAllocAfter,
traceAllocAfter,
)
s.invalidateDepreciationSnapshots(c.Context(), tx, []uint{lockedChickin.ProjectFlockKandangId}, lockedChickin.ChickInDate)
return nil
})
@@ -1136,6 +1168,7 @@ func (s chickinService) Approval(c *fiber.Ctx, req *validation.Approve) ([]entit
if action == entity.ApprovalActionApproved {
step = utils.ChickinStepDisetujui
}
invalidateFromByPFK := make(map[uint]time.Time, len(approvableIDs))
err = s.Repository.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error {
if err := s.ensurePopulationRouteScope(c.Context(), dbTransaction); err != nil {
@@ -1180,6 +1213,12 @@ func (s chickinService) Approval(c *fiber.Ctx, req *validation.Approve) ([]entit
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to get chickins for approval %d", approvableID))
}
for _, chickin := range chickins {
invalidateFromByPFK[approvableID] = commonSvc.MinNonZeroDateOnlyUTC(
invalidateFromByPFK[approvableID],
chickin.ChickInDate,
)
}
kandangForApproval, err := s.ProjectflockKandangRepo.GetByID(c.Context(), approvableID)
if err != nil {
@@ -1257,6 +1296,12 @@ func (s chickinService) Approval(c *fiber.Ctx, req *validation.Approve) ([]entit
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to get pending chickins for rejection %d", approvableID))
}
for _, chickin := range chickins {
invalidateFromByPFK[approvableID] = commonSvc.MinNonZeroDateOnlyUTC(
invalidateFromByPFK[approvableID],
chickin.ChickInDate,
)
}
if len(chickins) == 0 {
continue
@@ -1304,6 +1349,9 @@ func (s chickinService) Approval(c *fiber.Ctx, req *validation.Approve) ([]entit
}
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to record approval")
}
for projectFlockKandangID, invalidateFromDate := range invalidateFromByPFK {
s.invalidateDepreciationSnapshots(c.Context(), nil, []uint{projectFlockKandangID}, invalidateFromDate)
}
updated := make([]entity.ProjectChickin, 0)
for _, kandangID := range approvableIDs {
@@ -1813,6 +1861,57 @@ func normalizeDateOnlyUTC(value time.Time) time.Time {
return time.Date(value.UTC().Year(), value.UTC().Month(), value.UTC().Day(), 0, 0, 0, 0, time.UTC)
}
func (s chickinService) invalidateDepreciationSnapshots(
ctx context.Context,
tx *gorm.DB,
projectFlockKandangIDs []uint,
fromDate time.Time,
) {
if fromDate.IsZero() {
return
}
projectFlockKandangIDs = uniqueUint(projectFlockKandangIDs)
if len(projectFlockKandangIDs) == 0 {
return
}
targetDB := s.Repository.DB()
if tx != nil {
targetDB = tx
}
farmIDs, err := commonSvc.ResolveProjectFlockIDsByProjectFlockKandangIDs(ctx, targetDB, projectFlockKandangIDs)
if err != nil {
s.Log.Warnf(
"Failed to resolve farm ids for chickin depreciation invalidation (pfk_ids=%v): %+v",
projectFlockKandangIDs,
err,
)
farmIDs = nil
}
if len(farmIDs) == 0 {
if err := commonSvc.InvalidateFarmDepreciationSnapshotsFromDate(ctx, targetDB, nil, fromDate); err != nil {
s.Log.Warnf(
"Failed to invalidate depreciation snapshots globally (from=%s): %+v",
fromDate.Format("2006-01-02"),
err,
)
}
return
}
if err := commonSvc.InvalidateFarmDepreciationSnapshotsFromDate(ctx, targetDB, farmIDs, fromDate); err != nil {
s.Log.Warnf(
"Failed to invalidate depreciation snapshots (farm_ids=%v, from=%s): %+v",
farmIDs,
fromDate.Format("2006-01-02"),
err,
)
}
}
func (s *chickinService) syncChickinTraceForProductWarehouse(ctx context.Context, tx *gorm.DB, productWarehouseID uint) error {
if productWarehouseID == 0 {
return nil
@@ -24,22 +24,50 @@ func NewProjectFlockKandangController(projectFlockKandangService service.Project
func (u *ProjectFlockKandangController) GetAll(c *fiber.Ctx) error {
query := &validation.Query{
Page: c.QueryInt("page", 1),
Limit: c.QueryInt("limit", 10),
Search: c.Query("search", ""),
ProjectFlockId: uint(c.QueryInt("project_flock_id", 0)),
KandangId: uint(c.QueryInt("kandang_id", 0)),
Category: c.Query("category", ""),
AreaId: uint(c.QueryInt("area_id", 0)),
SortBy: c.Query("sort_by", ""),
SortOrder: c.Query("sort_order", ""),
StepName: c.Query("step_name", ""),
Page: c.QueryInt("page", 1),
Limit: c.QueryInt("limit", 10),
Search: c.Query("search", ""),
NameWithPeriode: c.QueryBool("name_with_periode", false),
ProjectFlockId: uint(c.QueryInt("project_flock_id", 0)),
KandangId: uint(c.QueryInt("kandang_id", 0)),
Category: c.Query("category", ""),
AreaId: uint(c.QueryInt("area_id", 0)),
LocationId: uint(c.QueryInt("location_id", 0)),
SortBy: c.Query("sort_by", ""),
SortOrder: c.Query("sort_order", ""),
StepName: c.Query("step_name", ""),
}
if query.Page < 1 || query.Limit < 1 {
return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0")
}
if query.NameWithPeriode {
results, totalResults, err := u.ProjectFlockKandangService.GetAllNameWithPeriode(c, query)
if err != nil {
return err
}
data := make([]dto.ProjectFlockKandangNameWithPeriodDTO, 0, len(results))
for _, result := range results {
data = append(data, dto.ToProjectFlockKandangNameWithPeriodDTOValues(result.Id, result.KandangName, result.Period))
}
return c.Status(fiber.StatusOK).
JSON(response.SuccessWithPaginate[dto.ProjectFlockKandangNameWithPeriodDTO]{
Code: fiber.StatusOK,
Status: "success",
Message: "Get all projectFlockKandangs successfully",
Meta: response.Meta{
Page: query.Page,
Limit: query.Limit,
TotalPages: int64(math.Ceil(float64(totalResults) / float64(query.Limit))),
TotalResults: totalResults,
},
Data: data,
})
}
results, totalResults, err := u.ProjectFlockKandangService.GetAll(c, query)
if err != nil {
return err
@@ -60,6 +60,11 @@ type ProjectFlockKandangListDTO struct {
ChickinApproval *approvalDTO.ApprovalRelationDTO `json:"chickin_approval,omitempty"`
}
type ProjectFlockKandangNameWithPeriodDTO struct {
Id uint `json:"id"`
NameWithPeriod string `json:"name_with_period"`
}
type ProjectFlockKandangDetailDTO struct {
ProjectFlockKandangListDTO
Chickins []chickinDTO.ChickinRelationDTO `json:"chickins,omitempty"`
@@ -129,13 +134,17 @@ func toKandangRelation(kandang entity.Kandang) *kandangDTO.KandangRelationDTO {
}
func toNameWithPeriod(kandang entity.Kandang, period int) string {
if kandang.Name == "" {
return toNameWithPeriodValue(kandang.Name, period)
}
func toNameWithPeriodValue(kandangName string, period int) string {
if kandangName == "" {
return ""
}
if period == 0 {
return kandang.Name
return kandangName
}
return kandang.Name + " Period " + strconv.Itoa(period)
return kandangName + " Period " + strconv.Itoa(period)
}
func toApprovalDTOSelector(
@@ -167,6 +176,20 @@ func ToProjectFlockKandangListDTO(e entity.ProjectFlockKandang) ProjectFlockKand
}
}
func ToProjectFlockKandangNameWithPeriodDTO(e entity.ProjectFlockKandang) ProjectFlockKandangNameWithPeriodDTO {
return ProjectFlockKandangNameWithPeriodDTO{
Id: e.Id,
NameWithPeriod: toNameWithPeriod(e.Kandang, e.Period),
}
}
func ToProjectFlockKandangNameWithPeriodDTOValues(id uint, kandangName string, period int) ProjectFlockKandangNameWithPeriodDTO {
return ProjectFlockKandangNameWithPeriodDTO{
Id: id,
NameWithPeriod: toNameWithPeriodValue(kandangName, period),
}
}
func toCreatedUserDTO(pf entity.ProjectFlock) *userDTO.UserRelationDTO {
if pf.CreatedUser.Id != 0 {
mapped := userDTO.ToUserRelationDTO(pf.CreatedUser)
@@ -13,11 +13,11 @@ func ProjectFlockKandangRoutes(v1 fiber.Router, u user.UserService, s projectFlo
ctrl := controller.NewProjectFlockKandangController(s)
route := v1.Group("/project-flock-kandangs")
route.Use(m.Auth(u))
route.Get("/",m.RequirePermissions(m.P_ProjectFlockKandangsGetAll), ctrl.GetAll)
route.Get("/:id",m.RequirePermissions(m.P_ProjectFlockKandangsGetOne), ctrl.GetOne)
// route.Use(m.Auth(u))
route.Get("/", ctrl.GetAll)
route.Get("/:id", m.RequirePermissions(m.P_ProjectFlockKandangsGetOne), ctrl.GetOne)
// route.Post("/:id/closing", m.RequirePermissions(m.PermissionProjectFlockClosing), ctrl.Closing)
// route.Get("/:id/closing/check", m.RequirePermissions(m.PermissionProjectFlockClosing), ctrl.CheckClosing)
route.Post("/:id/closing",m.RequirePermissions(m.P_ProjectFlockKandangsClosing), ctrl.Closing)
route.Post("/:id/closing", m.RequirePermissions(m.P_ProjectFlockKandangsClosing), ctrl.Closing)
route.Get("/:id/closing/check", m.RequirePermissions(m.P_ProjectFlockKandangsCheckClosing), ctrl.CheckClosing)
}
@@ -26,6 +26,7 @@ import (
type ProjectFlockKandangService interface {
GetAll(ctx *fiber.Ctx, params *validation.Query) ([]entity.ProjectFlockKandang, int64, error)
GetAllNameWithPeriode(ctx *fiber.Ctx, params *validation.Query) ([]ProjectFlockKandangNameWithPeriode, int64, error)
GetOne(ctx *fiber.Ctx, id uint) (*entity.ProjectFlockKandang, map[uint]float64, []entity.ProductWarehouse, error)
CheckClosing(ctx *fiber.Ctx, id uint) (*ClosingCheckResult, error)
Closing(ctx *fiber.Ctx, id uint, req *validation.Closing) (*entity.ProjectFlockKandang, error)
@@ -51,6 +52,12 @@ type ClosingCheckResult struct {
Expenses []ExpenseSummary `json:"expenses"`
}
type ProjectFlockKandangNameWithPeriode struct {
Id uint
KandangName string
Period int
}
type StockRemainingDetail struct {
FlagName string `json:"flag_name"`
ProductWarehouseId uint `json:"product_warehouse_id"`
@@ -87,6 +94,25 @@ func NewProjectFlockKandangService(repo repository.ProjectFlockKandangRepository
}
}
func resolveWarehouseForProjectFlockKandang(ctx context.Context, warehouseRepo rWarehouse.WarehouseRepository, pfk *entity.ProjectFlockKandang) (*entity.Warehouse, error) {
if warehouseRepo == nil || pfk == nil {
return nil, gorm.ErrRecordNotFound
}
if pfk.KandangId == 0 {
return nil, gorm.ErrRecordNotFound
}
if pfk.ProjectFlock.LocationId != 0 {
warehouse, err := warehouseRepo.GetByKandangIDAndLocationID(ctx, pfk.KandangId, pfk.ProjectFlock.LocationId)
if err == nil {
return warehouse, nil
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err
}
}
return warehouseRepo.GetByKandangID(ctx, pfk.KandangId)
}
func (s projectFlockKandangService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.ProjectFlockKandang, int64, error) {
if err := s.Validate.Struct(params); err != nil {
return nil, 0, err
@@ -114,6 +140,36 @@ func (s projectFlockKandangService) GetAll(c *fiber.Ctx, params *validation.Quer
return projectFlockKandangs, total, nil
}
func (s projectFlockKandangService) GetAllNameWithPeriode(c *fiber.Ctx, params *validation.Query) ([]ProjectFlockKandangNameWithPeriode, int64, error) {
if err := s.Validate.Struct(params); err != nil {
return nil, 0, err
}
scope, err := m.ResolveLocationScope(c, s.Repository.DB())
if err != nil {
return nil, 0, err
}
offset := (params.Page - 1) * params.Limit
rows, total, err := s.Repository.GetAllNameWithPeriodeScoped(c.Context(), offset, params.Limit, params, scope.IDs, scope.Restrict)
if err != nil {
s.Log.Errorf("Failed to get projectFlockKandangs name_with_periode: %+v", err)
return nil, 0, err
}
results := make([]ProjectFlockKandangNameWithPeriode, 0, len(rows))
for _, row := range rows {
results = append(results, ProjectFlockKandangNameWithPeriode{
Id: row.Id,
KandangName: row.KandangName,
Period: row.Period,
})
}
return results, total, nil
}
func (s projectFlockKandangService) GetOne(c *fiber.Ctx, id uint) (*entity.ProjectFlockKandang, map[uint]float64, []entity.ProductWarehouse, error) {
scope, err := m.ResolveLocationScope(c, s.Repository.DB())
if err != nil {
@@ -241,7 +297,7 @@ func (s projectFlockKandangService) getAvailableQuantities(c *fiber.Ctx, project
return nil, nil
}
warehouse, err := s.WarehouseRepo.GetByKandangID(c.Context(), projectFlockKandang.Kandang.Id)
warehouse, err := resolveWarehouseForProjectFlockKandang(c.Context(), s.WarehouseRepo, projectFlockKandang)
if err != nil || warehouse == nil {
return nil, nil
}
@@ -300,7 +356,7 @@ func (s projectFlockKandangService) CheckClosing(c *fiber.Ctx, id uint) (*Closin
stockRemain := make([]StockRemainingDetail, 0)
if s.WarehouseRepo != nil && s.ProductWarehouseRepo != nil {
warehouse, werr := s.WarehouseRepo.GetByKandangID(c.Context(), pfk.KandangId)
warehouse, werr := resolveWarehouseForProjectFlockKandang(c.Context(), s.WarehouseRepo, pfk)
if werr != nil {
return nil, werr
}
@@ -464,7 +520,7 @@ func (s projectFlockKandangService) Closing(c *fiber.Ctx, id uint, req *validati
}
if s.WarehouseRepo != nil && s.ProductWarehouseRepo != nil {
warehouse, werr := s.WarehouseRepo.GetByKandangID(c.Context(), pfk.KandangId)
warehouse, werr := resolveWarehouseForProjectFlockKandang(c.Context(), s.WarehouseRepo, pfk)
if werr != nil {
return nil, werr
}

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