mirror of
https://gitlab.com/mbugroup/lti-api.git
synced 2026-05-20 13:31:56 +00:00
Compare commits
104 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f98c73f569 | |||
| 30adbb6b8a | |||
| 45bed3b765 | |||
| 04aad18a4c | |||
| 69d6fc165a | |||
| a2ae139fae | |||
| 58fbceea24 | |||
| 187e497f97 | |||
| fcde3b0a36 | |||
| a54c6184a2 | |||
| f808b5cf79 | |||
| 3d87e85d1e | |||
| ef71093b99 | |||
| 1c19fc058c | |||
| 6a012b75aa | |||
| 9b0335cac8 | |||
| 1c70dccc82 | |||
| 94a7191365 | |||
| 1ab16cfe06 | |||
| d9610537de | |||
| cd549de578 | |||
| 7ca7d0841b | |||
| 82794d3d9b | |||
| ca698ff2ae | |||
| fbe0634d46 | |||
| bca02800d6 | |||
| 45e430f01d | |||
| cff5837ff9 | |||
| 5e2187c46b | |||
| d1612e5c65 | |||
| 30a47ffc71 | |||
| b79738dbe1 | |||
| 3eb225cca8 | |||
| a9a84539eb | |||
| 3702d41954 | |||
| ffd96105ce | |||
| 3d75251c96 | |||
| 7c848bc50d | |||
| ddcf13e2ff | |||
| e8c33f818b | |||
| 3daed7e248 | |||
| cde4647b15 | |||
| abc0ac8258 | |||
| aad4f7dc28 | |||
| bfe7b5129f | |||
| a6995f8e18 | |||
| 7638c183f5 | |||
| 450d1e8cee | |||
| b58e9a10b1 | |||
| aa9863646e | |||
| 2a3154042c | |||
| 80f190b69b | |||
| 079ae01b94 | |||
| ee7fa71139 | |||
| f2827d5352 | |||
| 18cf180982 | |||
| fc0b45b433 | |||
| 4d85d6f320 | |||
| e3cfb2648b | |||
| ba8b512293 | |||
| 7bd9ec9ef8 | |||
| 037f9fc71b | |||
| 8fa41e379d | |||
| 7d9c752432 | |||
| 6342a28f09 | |||
| 945b6aba0a | |||
| 23e49a00e4 | |||
| 480c899f6a | |||
| ba4a5324ed | |||
| 4899cee98f | |||
| 2a39342d55 | |||
| f29f09d7b9 | |||
| 4254cbf576 | |||
| 34a3fc44a8 | |||
| fdfc5e069d | |||
| 6880010424 | |||
| 9cc9146641 | |||
| 8be4b54127 | |||
| 434ae2f246 | |||
| c48f0411d3 | |||
| f40c643876 | |||
| 18cb116a51 | |||
| 1ab1909998 | |||
| 41c910677f | |||
| c9fb4077a6 | |||
| ec17633b84 | |||
| b2f235dcde | |||
| 0bcda8ad82 | |||
| aadc19a3ca | |||
| aabad2b082 | |||
| e323f42c11 | |||
| 0d5044b7bf | |||
| dd2832b8fc | |||
| 119a5e4e25 | |||
| 04068c2a8b | |||
| 0db1aaaab7 | |||
| 2b258908ef | |||
| 74e5542726 | |||
| 2f5ddfe8a6 | |||
| ceba7c5543 | |||
| b32789e515 | |||
| a7611ad0b2 | |||
| 0042cf11ce | |||
| b860a68db2 |
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 = ¬eRaw
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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
@@ -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
|
||||
BIN
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
@@ -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",
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
+9
@@ -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;
|
||||
+10
@@ -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);
|
||||
+6
@@ -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;
|
||||
+18
@@ -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;
|
||||
+4
@@ -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);
|
||||
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
DROP INDEX IF EXISTS idx_farm_depreciation_manual_inputs_project_flock_id;
|
||||
DROP TABLE IF EXISTS farm_depreciation_manual_inputs;
|
||||
+16
@@ -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);
|
||||
+4
@@ -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;
|
||||
+12
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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)"`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
+12
@@ -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")
|
||||
|
||||
|
||||
+7
-1
@@ -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 {
|
||||
|
||||
+21
-6
@@ -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 {
|
||||
|
||||
+2
@@ -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
|
||||
|
||||
+38
-10
@@ -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
|
||||
|
||||
+26
-3
@@ -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)
|
||||
}
|
||||
|
||||
+59
-3
@@ -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
Reference in New Issue
Block a user