mirror of
https://gitlab.com/mbugroup/lti-api.git
synced 2026-05-24 15:25:43 +00:00
Merge branch 'development' into 'production'
Development See merge request mbugroup/lti-api!505
This commit is contained in:
+16
-2
@@ -27,11 +27,25 @@ workflow:
|
|||||||
.ecr_login: &ecr_login |
|
.ecr_login: &ecr_login |
|
||||||
AWS_CLI_ENV_ARGS=""
|
AWS_CLI_ENV_ARGS=""
|
||||||
AWS_CLI_ENV_ARGS="$AWS_CLI_ENV_ARGS -e AWS_REGION=$AWS_REGION"
|
AWS_CLI_ENV_ARGS="$AWS_CLI_ENV_ARGS -e AWS_REGION=$AWS_REGION"
|
||||||
AWS_CLI_ENV_ARGS="$AWS_CLI_ENV_ARGS -e AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID:-}"
|
|
||||||
AWS_CLI_ENV_ARGS="$AWS_CLI_ENV_ARGS -e AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY:-}"
|
HAS_ACCESS_KEY="false"
|
||||||
|
HAS_SECRET_KEY="false"
|
||||||
|
if [ -n "${AWS_ACCESS_KEY_ID:-}" ]; then
|
||||||
|
HAS_ACCESS_KEY="true"
|
||||||
|
fi
|
||||||
|
if [ -n "${AWS_SECRET_ACCESS_KEY:-}" ]; then
|
||||||
|
HAS_SECRET_KEY="true"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$HAS_ACCESS_KEY" = "true" ] && [ "$HAS_SECRET_KEY" = "true" ]; then
|
||||||
|
AWS_CLI_ENV_ARGS="$AWS_CLI_ENV_ARGS -e AWS_ACCESS_KEY_ID=$AWS_ACCESS_KEY_ID"
|
||||||
|
AWS_CLI_ENV_ARGS="$AWS_CLI_ENV_ARGS -e AWS_SECRET_ACCESS_KEY=$AWS_SECRET_ACCESS_KEY"
|
||||||
if [ -n "${AWS_SESSION_TOKEN:-}" ]; then
|
if [ -n "${AWS_SESSION_TOKEN:-}" ]; then
|
||||||
AWS_CLI_ENV_ARGS="$AWS_CLI_ENV_ARGS -e AWS_SESSION_TOKEN=$AWS_SESSION_TOKEN"
|
AWS_CLI_ENV_ARGS="$AWS_CLI_ENV_ARGS -e AWS_SESSION_TOKEN=$AWS_SESSION_TOKEN"
|
||||||
fi
|
fi
|
||||||
|
elif [ "$HAS_ACCESS_KEY" = "true" ] || [ "$HAS_SECRET_KEY" = "true" ] || [ -n "${AWS_SESSION_TOKEN:-}" ]; then
|
||||||
|
echo "WARN: Incomplete AWS_* env vars detected; ignoring injected AWS credentials for ECR login."
|
||||||
|
fi
|
||||||
|
|
||||||
PASS="$(docker run --rm $AWS_CLI_ENV_ARGS public.ecr.aws/aws-cli/aws-cli:latest \
|
PASS="$(docker run --rm $AWS_CLI_ENV_ARGS public.ecr.aws/aws-cli/aws-cli:latest \
|
||||||
ecr get-login-password --region "$AWS_REGION" || true)"
|
ecr get-login-password --region "$AWS_REGION" || true)"
|
||||||
|
|||||||
@@ -0,0 +1,387 @@
|
|||||||
|
// Command: fix-stock-log-drift
|
||||||
|
//
|
||||||
|
// Tujuan:
|
||||||
|
// Sinkronkan `stock_logs.stock` (running ledger) dengan `product_warehouses.qty`
|
||||||
|
// (FIFO truth) ketika keduanya drift.
|
||||||
|
//
|
||||||
|
// Drift biasanya terjadi karena bug di Recording-Edit (sebelum fix) yang
|
||||||
|
// hanya menulis -decrease tanpa +increase saat in-place update. Akibatnya
|
||||||
|
// running ledger di stock_logs tertinggal dari qty riil di product_warehouses.
|
||||||
|
//
|
||||||
|
// Cara kerja:
|
||||||
|
// 1. Ambil product_warehouses.qty (sebagai truth)
|
||||||
|
// 2. Ambil last_stock_log.stock
|
||||||
|
// 3. Cari recording yang berkontribusi pada drift (untuk notes)
|
||||||
|
// 4. Hitung drift = qty - last_stock_log.stock
|
||||||
|
// 5. Jika drift != 0, insert 1 stock_log corrective:
|
||||||
|
// - drift > 0 → increase = drift
|
||||||
|
// - drift < 0 → decrease = |drift|
|
||||||
|
// stock akhir akan sama dengan qty (truth).
|
||||||
|
// Notes otomatis berisi daftar recording IDs yang berkontribusi pada drift.
|
||||||
|
//
|
||||||
|
// Mode:
|
||||||
|
// --apply=false (default) → dry-run, hanya tampilkan rencana
|
||||||
|
// --apply=true → eksekusi insert
|
||||||
|
//
|
||||||
|
// Contoh:
|
||||||
|
// go run cmd/fix-stock-log-drift/main.go --product-warehouse-id=1261
|
||||||
|
// go run cmd/fix-stock-log-drift/main.go --product-warehouse-id=1261 --apply
|
||||||
|
// go run cmd/fix-stock-log-drift/main.go --product-warehouse-id=1261 --apply \
|
||||||
|
// --actor-id=1 --notes="Koreksi manual drift"
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"math"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"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"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
qtyEpsilon = 1e-6
|
||||||
|
defaultActorID uint = 1
|
||||||
|
maxSuspectInNotes = 30
|
||||||
|
)
|
||||||
|
|
||||||
|
type driftRow struct {
|
||||||
|
ProductWarehouseID uint `gorm:"column:product_warehouse_id"`
|
||||||
|
ProductID uint `gorm:"column:product_id"`
|
||||||
|
ProductName string `gorm:"column:product_name"`
|
||||||
|
WarehouseName string `gorm:"column:warehouse_name"`
|
||||||
|
CurrentQty float64 `gorm:"column:current_qty"`
|
||||||
|
LastLogStock float64 `gorm:"column:last_log_stock"`
|
||||||
|
LastLogID uint `gorm:"column:last_log_id"`
|
||||||
|
FifoExpected float64 `gorm:"column:fifo_expected"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type suspectRecording struct {
|
||||||
|
RecordingID uint `gorm:"column:recording_id"`
|
||||||
|
FifoUsage float64 `gorm:"column:fifo_usage"`
|
||||||
|
NetLogConsumed float64 `gorm:"column:net_log_consumed"`
|
||||||
|
Phantom float64 `gorm:"column:phantom"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
var (
|
||||||
|
productWarehouseID uint
|
||||||
|
apply bool
|
||||||
|
actorID uint
|
||||||
|
notes string
|
||||||
|
)
|
||||||
|
|
||||||
|
flag.UintVar(&productWarehouseID, "product-warehouse-id", 0, "Target product_warehouse_id (required)")
|
||||||
|
flag.BoolVar(&apply, "apply", false, "Apply changes. If false, run as dry-run")
|
||||||
|
flag.UintVar(&actorID, "actor-id", defaultActorID, "User id yang akan dicatat sebagai created_by stock_log corrective")
|
||||||
|
flag.StringVar(¬es, "notes", "", "Custom notes untuk stock_log corrective (opsional, default=auto-generate dari data recording)")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
notes = strings.TrimSpace(notes)
|
||||||
|
if err := validateFlags(productWarehouseID, actorID); err != nil {
|
||||||
|
log.Fatalf("invalid flags: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
db := database.Connect(config.DBHost, config.DBName)
|
||||||
|
|
||||||
|
row, err := loadDriftRow(ctx, db, productWarehouseID)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("failed to load product warehouse: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspects, err := loadSuspectRecordings(ctx, db, productWarehouseID)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("failed to load suspect recordings: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
drift := row.CurrentQty - row.LastLogStock
|
||||||
|
|
||||||
|
// Print info header
|
||||||
|
fmt.Printf("Mode: %s\n", modeLabel(apply))
|
||||||
|
fmt.Printf("Target product_warehouse_id: %d\n", productWarehouseID)
|
||||||
|
fmt.Printf("Product: %q\n", row.ProductName)
|
||||||
|
fmt.Printf("Warehouse: %q\n", row.WarehouseName)
|
||||||
|
fmt.Printf("Current qty (product_warehouses): %.3f\n", row.CurrentQty)
|
||||||
|
fmt.Printf("FIFO expected (sum total_qty - total_used): %.3f\n", row.FifoExpected)
|
||||||
|
fmt.Printf("Last stock_log: id=%d stock=%.3f\n", row.LastLogID, row.LastLogStock)
|
||||||
|
fmt.Printf("Drift (qty - last_log_stock): %+.3f\n", drift)
|
||||||
|
|
||||||
|
if !nearlyEqual(row.CurrentQty, row.FifoExpected) {
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Println("⚠️ WARNING: product_warehouses.qty TIDAK match dengan FIFO expected.")
|
||||||
|
fmt.Println(" Disarankan jalankan dulu cmd/reflow-quantity-product-warehouse-from-stock-allocation")
|
||||||
|
fmt.Println(" sebelum fix stock_log drift, agar truth source-nya sudah benar.")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Print suspect recordings
|
||||||
|
fmt.Println()
|
||||||
|
if len(suspects) > 0 {
|
||||||
|
totalPhantom := 0.0
|
||||||
|
for _, s := range suspects {
|
||||||
|
totalPhantom += s.Phantom
|
||||||
|
}
|
||||||
|
fmt.Printf("Suspect recordings (drift contributors): %d\n", len(suspects))
|
||||||
|
for _, s := range suspects {
|
||||||
|
fmt.Printf(
|
||||||
|
" #%-6d fifo=%-10.3f net_log=%-10.3f phantom=%+.3f\n",
|
||||||
|
s.RecordingID, s.FifoUsage, s.NetLogConsumed, s.Phantom,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
fmt.Printf("Total suspect phantom: %+.3f\n", totalPhantom)
|
||||||
|
} else {
|
||||||
|
fmt.Println("Suspect recordings: none found (drift origin unknown)")
|
||||||
|
}
|
||||||
|
fmt.Println()
|
||||||
|
|
||||||
|
if nearlyEqual(drift, 0) {
|
||||||
|
fmt.Println("✓ Tidak ada drift. Stock_log sudah sinkron dengan product_warehouses.qty.")
|
||||||
|
fmt.Println("Summary: planned=0 inserted=0 skipped=1 failed=0")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build notes if not provided
|
||||||
|
if notes == "" {
|
||||||
|
notes = buildDefaultNotes(row, drift, suspects)
|
||||||
|
}
|
||||||
|
|
||||||
|
plan := buildCorrectiveLog(row, drift, actorID, notes)
|
||||||
|
|
||||||
|
fmt.Printf(
|
||||||
|
"PLAN insert stock_log:\n pw=%d increase=%.3f decrease=%.3f stock=%.3f\n notes=%q\n",
|
||||||
|
plan.ProductWarehouseId,
|
||||||
|
plan.Increase,
|
||||||
|
plan.Decrease,
|
||||||
|
plan.Stock,
|
||||||
|
plan.Notes,
|
||||||
|
)
|
||||||
|
|
||||||
|
if !apply {
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Println("Summary: planned=1 inserted=0 skipped=0 failed=0 (dry-run)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
// Re-check di dalam transaction agar aman dari race condition
|
||||||
|
current, err := loadDriftRow(ctx, tx, productWarehouseID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("re-read product_warehouse_id=%d: %w", productWarehouseID, err)
|
||||||
|
}
|
||||||
|
currentDrift := current.CurrentQty - current.LastLogStock
|
||||||
|
if nearlyEqual(currentDrift, 0) {
|
||||||
|
fmt.Println("Drift hilang sebelum insert (kemungkinan ada operasi paralel). Skip.")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
fresh := buildCorrectiveLog(current, currentDrift, actorID, notes)
|
||||||
|
if err := tx.Create(&fresh).Error; err != nil {
|
||||||
|
return fmt.Errorf("insert corrective stock_log for pw=%d: %w", productWarehouseID, err)
|
||||||
|
}
|
||||||
|
fmt.Printf(
|
||||||
|
"DONE inserted stock_log id=%d pw=%d increase=%.3f decrease=%.3f stock=%.3f\n",
|
||||||
|
fresh.Id,
|
||||||
|
fresh.ProductWarehouseId,
|
||||||
|
fresh.Increase,
|
||||||
|
fresh.Decrease,
|
||||||
|
fresh.Stock,
|
||||||
|
)
|
||||||
|
return nil
|
||||||
|
}); err != nil {
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Println("Summary: planned=1 inserted=0 skipped=0 failed=1")
|
||||||
|
log.Printf("error: %v", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Println("Summary: planned=1 inserted=1 skipped=0 failed=0")
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateFlags(productWarehouseID uint, actorID uint) error {
|
||||||
|
if productWarehouseID == 0 {
|
||||||
|
return errors.New("--product-warehouse-id is required (must be > 0)")
|
||||||
|
}
|
||||||
|
if actorID == 0 {
|
||||||
|
return errors.New("--actor-id must be > 0")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadDriftRow(ctx context.Context, db *gorm.DB, productWarehouseID uint) (driftRow, error) {
|
||||||
|
row := driftRow{}
|
||||||
|
|
||||||
|
lastLogSub := db.WithContext(ctx).
|
||||||
|
Table("stock_logs").
|
||||||
|
Select("id, product_warehouse_id, stock").
|
||||||
|
Where("product_warehouse_id = ?", productWarehouseID).
|
||||||
|
Order("id DESC").
|
||||||
|
Limit(1)
|
||||||
|
|
||||||
|
fifoSub := db.WithContext(ctx).
|
||||||
|
Table("purchase_items").
|
||||||
|
Select(`
|
||||||
|
product_warehouse_id,
|
||||||
|
COALESCE(SUM(COALESCE(total_qty, 0) - COALESCE(total_used, 0)), 0) AS fifo_expected
|
||||||
|
`).
|
||||||
|
Where("product_warehouse_id = ?", productWarehouseID).
|
||||||
|
Group("product_warehouse_id")
|
||||||
|
|
||||||
|
if err := db.WithContext(ctx).
|
||||||
|
Table("product_warehouses pw").
|
||||||
|
Select(`
|
||||||
|
pw.id AS product_warehouse_id,
|
||||||
|
pw.product_id AS product_id,
|
||||||
|
COALESCE(p.name, '') AS product_name,
|
||||||
|
COALESCE(w.name, '') AS warehouse_name,
|
||||||
|
COALESCE(pw.qty, 0) AS current_qty,
|
||||||
|
COALESCE(last_log.stock, 0) AS last_log_stock,
|
||||||
|
COALESCE(last_log.id, 0) AS last_log_id,
|
||||||
|
COALESCE(fifo.fifo_expected, 0) AS fifo_expected
|
||||||
|
`).
|
||||||
|
Joins("LEFT JOIN products p ON p.id = pw.product_id").
|
||||||
|
Joins("LEFT JOIN warehouses w ON w.id = pw.warehouse_id").
|
||||||
|
Joins("LEFT JOIN (?) last_log ON last_log.product_warehouse_id = pw.id", lastLogSub).
|
||||||
|
Joins("LEFT JOIN (?) fifo ON fifo.product_warehouse_id = pw.id", fifoSub).
|
||||||
|
Where("pw.id = ?", productWarehouseID).
|
||||||
|
Scan(&row).Error; err != nil {
|
||||||
|
return row, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if row.ProductWarehouseID == 0 {
|
||||||
|
return row, fmt.Errorf("product_warehouse_id=%d not found", productWarehouseID)
|
||||||
|
}
|
||||||
|
|
||||||
|
return row, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadSuspectRecordings mencari recording yang net stock_log consumed-nya
|
||||||
|
// melebihi FIFO usage_qty — ini adalah recording yang berkontribusi pada drift
|
||||||
|
// akibat bug Recording-Edit yang hanya menulis -decrease tanpa +increase.
|
||||||
|
func loadSuspectRecordings(ctx context.Context, db *gorm.DB, productWarehouseID uint) ([]suspectRecording, error) {
|
||||||
|
rows := make([]suspectRecording, 0)
|
||||||
|
if err := db.WithContext(ctx).
|
||||||
|
Table("recording_stocks rs").
|
||||||
|
Select(`
|
||||||
|
rs.recording_id,
|
||||||
|
COALESCE(rs.usage_qty, 0) AS fifo_usage,
|
||||||
|
(
|
||||||
|
COALESCE(SUM(CASE WHEN sl.decrease > 0 THEN sl.decrease ELSE 0 END), 0) -
|
||||||
|
COALESCE(SUM(CASE WHEN sl.increase > 0 THEN sl.increase ELSE 0 END), 0)
|
||||||
|
) AS net_log_consumed,
|
||||||
|
(
|
||||||
|
COALESCE(SUM(CASE WHEN sl.decrease > 0 THEN sl.decrease ELSE 0 END), 0) -
|
||||||
|
COALESCE(SUM(CASE WHEN sl.increase > 0 THEN sl.increase ELSE 0 END), 0) -
|
||||||
|
COALESCE(rs.usage_qty, 0)
|
||||||
|
) AS phantom
|
||||||
|
`).
|
||||||
|
Joins(`
|
||||||
|
JOIN stock_logs sl ON sl.loggable_type = ?
|
||||||
|
AND sl.loggable_id = rs.recording_id
|
||||||
|
AND sl.product_warehouse_id = rs.product_warehouse_id
|
||||||
|
`, string(utils.StockLogTypeRecording)).
|
||||||
|
Where("rs.product_warehouse_id = ?", productWarehouseID).
|
||||||
|
Group("rs.recording_id, rs.usage_qty").
|
||||||
|
Having(`
|
||||||
|
ABS(
|
||||||
|
COALESCE(SUM(CASE WHEN sl.decrease > 0 THEN sl.decrease ELSE 0 END), 0) -
|
||||||
|
COALESCE(SUM(CASE WHEN sl.increase > 0 THEN sl.increase ELSE 0 END), 0) -
|
||||||
|
COALESCE(rs.usage_qty, 0)
|
||||||
|
) > ?
|
||||||
|
`, qtyEpsilon).
|
||||||
|
Order("rs.recording_id ASC").
|
||||||
|
Scan(&rows).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return rows, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildDefaultNotes membuat notes otomatis yang berisi penjelasan drift
|
||||||
|
// beserta daftar recording_id yang berkontribusi + phantom amount masing-masing.
|
||||||
|
func buildDefaultNotes(row driftRow, drift float64, suspects []suspectRecording) string {
|
||||||
|
sign := "+"
|
||||||
|
if drift < 0 {
|
||||||
|
sign = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString(fmt.Sprintf(
|
||||||
|
"Koreksi drift stock_log akibat bug Recording-Edit (in-place update menulis -decrease tanpa +increase). PW=%d (%s) drift=%s%.3f.",
|
||||||
|
row.ProductWarehouseID,
|
||||||
|
row.WarehouseName,
|
||||||
|
sign,
|
||||||
|
drift,
|
||||||
|
))
|
||||||
|
|
||||||
|
if len(suspects) == 0 {
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.WriteString(" Recordings affected:")
|
||||||
|
|
||||||
|
limit := len(suspects)
|
||||||
|
truncated := 0
|
||||||
|
if limit > maxSuspectInNotes {
|
||||||
|
truncated = limit - maxSuspectInNotes
|
||||||
|
limit = maxSuspectInNotes
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := 0; i < limit; i++ {
|
||||||
|
s := suspects[i]
|
||||||
|
phantomSign := "+"
|
||||||
|
if s.Phantom < 0 {
|
||||||
|
phantomSign = ""
|
||||||
|
}
|
||||||
|
sb.WriteString(fmt.Sprintf(" #%d(%s%.0f)", s.RecordingID, phantomSign, s.Phantom))
|
||||||
|
if i < limit-1 || truncated > 0 {
|
||||||
|
sb.WriteString(",")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if truncated > 0 {
|
||||||
|
sb.WriteString(fmt.Sprintf(" ... (+%d more)", truncated))
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.WriteString(".")
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildCorrectiveLog(row driftRow, drift float64, actorID uint, notes string) entity.StockLog {
|
||||||
|
corrective := entity.StockLog{
|
||||||
|
ProductWarehouseId: row.ProductWarehouseID,
|
||||||
|
CreatedBy: actorID,
|
||||||
|
LoggableType: string(utils.StockLogTypeAdjustment),
|
||||||
|
LoggableId: 0,
|
||||||
|
Stock: row.CurrentQty,
|
||||||
|
Notes: notes,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
}
|
||||||
|
if drift > 0 {
|
||||||
|
corrective.Increase = drift
|
||||||
|
corrective.Decrease = 0
|
||||||
|
} else {
|
||||||
|
corrective.Increase = 0
|
||||||
|
corrective.Decrease = -drift
|
||||||
|
}
|
||||||
|
return corrective
|
||||||
|
}
|
||||||
|
|
||||||
|
func modeLabel(apply bool) string {
|
||||||
|
if apply {
|
||||||
|
return "APPLY"
|
||||||
|
}
|
||||||
|
return "DRY-RUN"
|
||||||
|
}
|
||||||
|
|
||||||
|
func nearlyEqual(a, b float64) bool {
|
||||||
|
return math.Abs(a-b) <= qtyEpsilon
|
||||||
|
}
|
||||||
@@ -31,7 +31,7 @@ func FlockAgeDay(originDate time.Time, periodDate time.Time) int {
|
|||||||
if period.Before(origin) {
|
if period.Before(origin) {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
return int(period.Sub(origin).Hours()/24) + 1
|
return int(period.Sub(origin).Hours() / 24)
|
||||||
}
|
}
|
||||||
|
|
||||||
func DepreciationScheduleDay(originDate time.Time, periodDate time.Time, houseType string) int {
|
func DepreciationScheduleDay(originDate time.Time, periodDate time.Time, houseType string) int {
|
||||||
|
|||||||
+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;
|
||||||
+21
@@ -0,0 +1,21 @@
|
|||||||
|
UPDATE recordings r
|
||||||
|
SET day = (
|
||||||
|
SELECT (r.record_datetime::date - MIN(pc.chick_in_date)::date)::int + 1
|
||||||
|
FROM project_chickins pc
|
||||||
|
WHERE pc.project_flock_kandang_id = r.project_flock_kandangs_id
|
||||||
|
AND pc.deleted_at IS NULL
|
||||||
|
)
|
||||||
|
WHERE r.deleted_at IS NULL
|
||||||
|
AND (
|
||||||
|
SELECT MIN(pc.chick_in_date)
|
||||||
|
FROM project_chickins pc
|
||||||
|
WHERE pc.project_flock_kandang_id = r.project_flock_kandangs_id
|
||||||
|
AND pc.deleted_at IS NULL
|
||||||
|
) IS NOT NULL;
|
||||||
|
|
||||||
|
ALTER TABLE recordings
|
||||||
|
DROP CONSTRAINT IF EXISTS chk_recordings_day;
|
||||||
|
|
||||||
|
ALTER TABLE recordings
|
||||||
|
ADD CONSTRAINT chk_recordings_day
|
||||||
|
CHECK (day IS NULL OR day >= 1);
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
ALTER TABLE recordings
|
||||||
|
DROP CONSTRAINT IF EXISTS chk_recordings_day;
|
||||||
|
|
||||||
|
ALTER TABLE recordings
|
||||||
|
ADD CONSTRAINT chk_recordings_day
|
||||||
|
CHECK (day IS NULL OR day >= 0);
|
||||||
|
|
||||||
|
UPDATE recordings r
|
||||||
|
SET day = (
|
||||||
|
SELECT (r.record_datetime::date - MIN(pc.chick_in_date)::date)::int
|
||||||
|
FROM project_chickins pc
|
||||||
|
WHERE pc.project_flock_kandang_id = r.project_flock_kandangs_id
|
||||||
|
AND pc.deleted_at IS NULL
|
||||||
|
)
|
||||||
|
WHERE r.deleted_at IS NULL
|
||||||
|
AND (
|
||||||
|
SELECT MIN(pc.chick_in_date)
|
||||||
|
FROM project_chickins pc
|
||||||
|
WHERE pc.project_flock_kandang_id = r.project_flock_kandangs_id
|
||||||
|
AND pc.deleted_at IS NULL
|
||||||
|
) IS NOT NULL;
|
||||||
@@ -277,7 +277,11 @@ func (s dailyChecklistService) GetAll(c *fiber.Ctx, params *validation.Query) ([
|
|||||||
normalizedSearch := re.ReplaceAllString(params.Search, "")
|
normalizedSearch := re.ReplaceAllString(params.Search, "")
|
||||||
if normalizedSearch != "" {
|
if normalizedSearch != "" {
|
||||||
like := "%" + 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)
|
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 ? OR
|
||||||
|
(dc.category = 'empty_kandang' AND regexp_replace('Kandang Kosong', '[^a-zA-Z0-9]', '', 'g') ILIKE ?)
|
||||||
|
)`, like, like, like)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -119,9 +119,9 @@ func (r *DashboardRepositoryImpl) GetRecordingWeeklyMetrics(ctx context.Context,
|
|||||||
var rows []RecordingWeeklyMetric
|
var rows []RecordingWeeklyMetric
|
||||||
|
|
||||||
weekExpr := `CASE
|
weekExpr := `CASE
|
||||||
WHEN r.day IS NULL OR r.day <= 0 THEN 1
|
WHEN r.day IS NULL OR r.day < 0 THEN 1
|
||||||
WHEN UPPER(pf.category) = 'LAYING' THEN ((r.day - 1) / 7 + 1) + 17
|
WHEN UPPER(pf.category) = 'LAYING' THEN (r.day / 7 + 1) + 17
|
||||||
ELSE ((r.day - 1) / 7 + 1)
|
ELSE (r.day / 7 + 1)
|
||||||
END`
|
END`
|
||||||
|
|
||||||
db := r.DB().WithContext(ctx).
|
db := r.DB().WithContext(ctx).
|
||||||
@@ -503,9 +503,9 @@ func (r *DashboardRepositoryImpl) GetComparisonWeeklyMetrics(ctx context.Context
|
|||||||
|
|
||||||
var rows []ComparisonWeeklyMetric
|
var rows []ComparisonWeeklyMetric
|
||||||
weekExpr := `CASE
|
weekExpr := `CASE
|
||||||
WHEN r.day IS NULL OR r.day <= 0 THEN 1
|
WHEN r.day IS NULL OR r.day < 0 THEN 1
|
||||||
WHEN UPPER(pf.category) = 'LAYING' THEN ((r.day - 1) / 7 + 1) + 17
|
WHEN UPPER(pf.category) = 'LAYING' THEN (r.day / 7 + 1) + 17
|
||||||
ELSE ((r.day - 1) / 7 + 1)
|
ELSE (r.day / 7 + 1)
|
||||||
END`
|
END`
|
||||||
|
|
||||||
db := r.DB().WithContext(ctx).
|
db := r.DB().WithContext(ctx).
|
||||||
@@ -574,9 +574,9 @@ func (r *DashboardRepositoryImpl) GetEggQualityWeeklyMetrics(ctx context.Context
|
|||||||
var rows []EggQualityWeeklyMetric
|
var rows []EggQualityWeeklyMetric
|
||||||
|
|
||||||
weekExpr := `CASE
|
weekExpr := `CASE
|
||||||
WHEN r.day IS NULL OR r.day <= 0 THEN 1
|
WHEN r.day IS NULL OR r.day < 0 THEN 1
|
||||||
WHEN UPPER(pf.category) = 'LAYING' THEN ((r.day - 1) / 7 + 1) + 17
|
WHEN UPPER(pf.category) = 'LAYING' THEN (r.day / 7 + 1) + 17
|
||||||
ELSE ((r.day - 1) / 7 + 1)
|
ELSE (r.day / 7 + 1)
|
||||||
END`
|
END`
|
||||||
|
|
||||||
db := r.DB().WithContext(ctx).
|
db := r.DB().WithContext(ctx).
|
||||||
@@ -616,9 +616,9 @@ func (r *DashboardRepositoryImpl) GetEggWeightWeeklyGrams(ctx context.Context, s
|
|||||||
var rows []WeeklyEggWeightMetric
|
var rows []WeeklyEggWeightMetric
|
||||||
|
|
||||||
weekExpr := `CASE
|
weekExpr := `CASE
|
||||||
WHEN r.day IS NULL OR r.day <= 0 THEN 1
|
WHEN r.day IS NULL OR r.day < 0 THEN 1
|
||||||
WHEN UPPER(pf.category) = 'LAYING' THEN ((r.day - 1) / 7 + 1) + 17
|
WHEN UPPER(pf.category) = 'LAYING' THEN (r.day / 7 + 1) + 17
|
||||||
ELSE ((r.day - 1) / 7 + 1)
|
ELSE (r.day / 7 + 1)
|
||||||
END`
|
END`
|
||||||
|
|
||||||
db := r.DB().WithContext(ctx).
|
db := r.DB().WithContext(ctx).
|
||||||
@@ -647,9 +647,9 @@ func (r *DashboardRepositoryImpl) GetFeedUsageWeeklyByUom(ctx context.Context, s
|
|||||||
var rows []WeeklyFeedUsageMetric
|
var rows []WeeklyFeedUsageMetric
|
||||||
|
|
||||||
weekExpr := `CASE
|
weekExpr := `CASE
|
||||||
WHEN r.day IS NULL OR r.day <= 0 THEN 1
|
WHEN r.day IS NULL OR r.day < 0 THEN 1
|
||||||
WHEN UPPER(pf.category) = 'LAYING' THEN ((r.day - 1) / 7 + 1) + 17
|
WHEN UPPER(pf.category) = 'LAYING' THEN (r.day / 7 + 1) + 17
|
||||||
ELSE ((r.day - 1) / 7 + 1)
|
ELSE (r.day / 7 + 1)
|
||||||
END`
|
END`
|
||||||
|
|
||||||
db := r.DB().WithContext(ctx).
|
db := r.DB().WithContext(ctx).
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ func (u *EmployeesController) GetAll(c *fiber.Ctx) error {
|
|||||||
Page: c.QueryInt("page", 1),
|
Page: c.QueryInt("page", 1),
|
||||||
Limit: c.QueryInt("limit", 10),
|
Limit: c.QueryInt("limit", 10),
|
||||||
Search: c.Query("search", ""),
|
Search: c.Query("search", ""),
|
||||||
|
OrderBy: c.Query("order_by", "desc"),
|
||||||
|
SortBy: c.Query("sort_by", "updated_at"),
|
||||||
}
|
}
|
||||||
|
|
||||||
if query.Page < 1 || query.Limit < 1 {
|
if query.Page < 1 || query.Limit < 1 {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
|
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
|
||||||
@@ -126,11 +127,18 @@ func (s employeesService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti
|
|||||||
if params.IsActive != nil {
|
if params.IsActive != nil {
|
||||||
db = db.Where("employees.is_active = ?", *params.IsActive)
|
db = db.Where("employees.is_active = ?", *params.IsActive)
|
||||||
}
|
}
|
||||||
return db.
|
|
||||||
|
db = db.
|
||||||
Select("employees.id, employees.name, employees.is_active, employees.created_at, employees.updated_at").
|
Select("employees.id, employees.name, employees.is_active, employees.created_at, employees.updated_at").
|
||||||
Group("employees.id, employees.name, employees.is_active, employees.created_at, employees.updated_at").
|
Group("employees.id, employees.name, employees.is_active, employees.created_at, employees.updated_at")
|
||||||
Order("employees.created_at DESC").
|
|
||||||
Order("employees.updated_at DESC")
|
if params.OrderBy == "desc" || params.OrderBy == "" {
|
||||||
|
db = db.Order(fmt.Sprintf("employees.%s DESC", params.SortBy))
|
||||||
|
} else {
|
||||||
|
db = db.Order(fmt.Sprintf("employees.%s ASC", params.SortBy))
|
||||||
|
}
|
||||||
|
|
||||||
|
return db
|
||||||
})
|
})
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -18,4 +18,6 @@ type Query struct {
|
|||||||
Search string `query:"search" validate:"omitempty,max=50"`
|
Search string `query:"search" validate:"omitempty,max=50"`
|
||||||
KandangId *uint `query:"kandang_id" validate:"omitempty"`
|
KandangId *uint `query:"kandang_id" validate:"omitempty"`
|
||||||
IsActive *bool `query:"is_active" validate:"omitempty"`
|
IsActive *bool `query:"is_active" validate:"omitempty"`
|
||||||
|
SortBy string `query:"sort_by" validate:"omitempty,max=50,oneof=name created_at updated_at" default:"updated_at"`
|
||||||
|
OrderBy string `query:"order_by" validate:"omitempty,oneof=asc desc" default:"desc"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ func (u *KandangGroupController) GetAll(c *fiber.Ctx) error {
|
|||||||
Search: c.Query("search", ""),
|
Search: c.Query("search", ""),
|
||||||
LocationId: c.QueryInt("location_id", 0),
|
LocationId: c.QueryInt("location_id", 0),
|
||||||
PicId: c.QueryInt("pic_id", 0),
|
PicId: c.QueryInt("pic_id", 0),
|
||||||
|
OrderBy: c.Query("order_by", "desc"),
|
||||||
|
SortBy: c.Query("sort_by", "updated_at"),
|
||||||
}
|
}
|
||||||
|
|
||||||
if query.Page < 1 || query.Limit < 1 {
|
if query.Page < 1 || query.Limit < 1 {
|
||||||
|
|||||||
@@ -70,7 +70,14 @@ func (s kandangGroupService) GetAll(c *fiber.Ctx, params *validation.Query) ([]e
|
|||||||
if params.PicId != 0 {
|
if params.PicId != 0 {
|
||||||
db = db.Where("kandang_groups.pic_id = ?", params.PicId)
|
db = db.Where("kandang_groups.pic_id = ?", params.PicId)
|
||||||
}
|
}
|
||||||
return db.Order("kandang_groups.created_at DESC").Order("kandang_groups.updated_at DESC")
|
|
||||||
|
if params.OrderBy == "desc" || params.OrderBy == "" {
|
||||||
|
db = db.Order(fmt.Sprintf("kandang_groups.%s DESC", params.SortBy))
|
||||||
|
} else {
|
||||||
|
db = db.Order(fmt.Sprintf("kandang_groups.%s ASC", params.SortBy))
|
||||||
|
}
|
||||||
|
|
||||||
|
return db
|
||||||
})
|
})
|
||||||
|
|
||||||
if scopeErr != nil {
|
if scopeErr != nil {
|
||||||
|
|||||||
@@ -20,4 +20,6 @@ type Query struct {
|
|||||||
Search string `query:"search" validate:"omitempty,max=50"`
|
Search string `query:"search" validate:"omitempty,max=50"`
|
||||||
LocationId int `query:"location_id" validate:"omitempty,number,gt=0"`
|
LocationId int `query:"location_id" validate:"omitempty,number,gt=0"`
|
||||||
PicId int `query:"pic_id" validate:"omitempty,number,gt=0"`
|
PicId int `query:"pic_id" validate:"omitempty,number,gt=0"`
|
||||||
|
SortBy string `query:"sort_by" validate:"omitempty,max=50,oneof=name created_at updated_at" default:"updated_at"`
|
||||||
|
OrderBy string `query:"order_by" validate:"omitempty,oneof=asc desc" default:"desc"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ func (u *KandangController) GetAll(c *fiber.Ctx) error {
|
|||||||
Search: c.Query("search", ""),
|
Search: c.Query("search", ""),
|
||||||
LocationId: c.QueryInt("location_id", 0),
|
LocationId: c.QueryInt("location_id", 0),
|
||||||
PicId: c.QueryInt("pic_id", 0),
|
PicId: c.QueryInt("pic_id", 0),
|
||||||
|
OrderBy: c.Query("order_by", "desc"),
|
||||||
|
SortBy: c.Query("sort_by", "created_at"),
|
||||||
}
|
}
|
||||||
|
|
||||||
if query.Page < 1 || query.Limit < 1 {
|
if query.Page < 1 || query.Limit < 1 {
|
||||||
|
|||||||
@@ -66,7 +66,14 @@ func (s kandangService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity
|
|||||||
if params.PicId != 0 {
|
if params.PicId != 0 {
|
||||||
db = db.Where("pic_id = ?", params.PicId)
|
db = db.Where("pic_id = ?", params.PicId)
|
||||||
}
|
}
|
||||||
return db.Order("created_at DESC").Order("updated_at DESC")
|
|
||||||
|
if params.OrderBy == "desc" || params.OrderBy == "" {
|
||||||
|
db = db.Order(fmt.Sprintf("%s DESC", params.SortBy))
|
||||||
|
} else {
|
||||||
|
db = db.Order(fmt.Sprintf("%s ASC", params.SortBy))
|
||||||
|
}
|
||||||
|
|
||||||
|
return db
|
||||||
})
|
})
|
||||||
|
|
||||||
if scopeErr != nil {
|
if scopeErr != nil {
|
||||||
|
|||||||
@@ -26,4 +26,6 @@ type Query struct {
|
|||||||
Search string `query:"search" validate:"omitempty,max=50"`
|
Search string `query:"search" validate:"omitempty,max=50"`
|
||||||
LocationId int `query:"location_id" validate:"omitempty,number,gt=0"`
|
LocationId int `query:"location_id" validate:"omitempty,number,gt=0"`
|
||||||
PicId int `query:"pic_id" validate:"omitempty,number,gt=0"`
|
PicId int `query:"pic_id" validate:"omitempty,number,gt=0"`
|
||||||
|
SortBy string `query:"sort_by" validate:"omitempty,max=50,oneof=name created_at updated_at" default:"updated_at"`
|
||||||
|
OrderBy string `query:"order_by" validate:"omitempty,oneof=asc desc" default:"desc"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2460,7 +2460,7 @@ func (s *recordingService) computeRecordingDay(ctx context.Context, projectFlock
|
|||||||
return 0, fiber.NewError(fiber.StatusBadRequest, "Record date tidak boleh sebelum tanggal chick in")
|
return 0, fiber.NewError(fiber.StatusBadRequest, "Record date tidak boleh sebelum tanggal chick in")
|
||||||
}
|
}
|
||||||
|
|
||||||
return diff + 1, nil
|
return diff, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *recordingService) computeAndUpdateMetrics(ctx context.Context, tx *gorm.DB, recording *entity.Recording) error {
|
func (s *recordingService) computeAndUpdateMetrics(ctx context.Context, tx *gorm.DB, recording *entity.Recording) error {
|
||||||
@@ -2621,8 +2621,8 @@ func (s *recordingService) computeAndUpdateMetrics(ctx context.Context, tx *gorm
|
|||||||
|
|
||||||
if isGrowing {
|
if isGrowing {
|
||||||
week := 0
|
week := 0
|
||||||
if recording.Day != nil && *recording.Day > 0 {
|
if recording.Day != nil && *recording.Day >= 0 {
|
||||||
week = (*recording.Day-1)/7 + 1
|
week = *recording.Day/7 + 1
|
||||||
}
|
}
|
||||||
if week > 0 && s.Repository != nil {
|
if week > 0 && s.Repository != nil {
|
||||||
meanBw, ok, err := s.Repository.GetUniformityMeanBwByWeek(tx, recording.ProjectFlockKandangId, week)
|
meanBw, ok, err := s.Repository.GetUniformityMeanBwByWeek(tx, recording.ProjectFlockKandangId, week)
|
||||||
@@ -3121,6 +3121,12 @@ func (s *recordingService) reflowSyncRecordingStocks(
|
|||||||
existingByWarehouse[stock.ProductWarehouseId] = append(existingByWarehouse[stock.ProductWarehouseId], stock)
|
existingByWarehouse[stock.ProductWarehouseId] = append(existingByWarehouse[stock.ProductWarehouseId], stock)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
shouldWriteLog := shouldWriteRecordingStockLog(note, actorID)
|
||||||
|
if shouldWriteLog && s.StockLogRepo == nil {
|
||||||
|
return errors.New("stock log repository is not available")
|
||||||
|
}
|
||||||
|
resetLogState := newRecordingStockLogState()
|
||||||
|
|
||||||
stocksToApply := make([]entity.RecordingStock, 0, len(incoming))
|
stocksToApply := make([]entity.RecordingStock, 0, len(incoming))
|
||||||
for _, item := range incoming {
|
for _, item := range incoming {
|
||||||
list := existingByWarehouse[item.ProductWarehouseId]
|
list := existingByWarehouse[item.ProductWarehouseId]
|
||||||
@@ -3128,6 +3134,25 @@ func (s *recordingService) reflowSyncRecordingStocks(
|
|||||||
if len(list) > 0 {
|
if len(list) > 0 {
|
||||||
stock = list[0]
|
stock = list[0]
|
||||||
existingByWarehouse[item.ProductWarehouseId] = list[1:]
|
existingByWarehouse[item.ProductWarehouseId] = list[1:]
|
||||||
|
|
||||||
|
// Write reset (increase) stock_log for the OLD consumption BEFORE overwriting UsageQty.
|
||||||
|
// FIFO internally does Rollback+Reallocate inside reflowApplyRecordingStocks, but the
|
||||||
|
// corresponding +increase stock_log for the rollback step was previously missing, causing
|
||||||
|
// stock_log.stock to drift below the true FIFO qty on every in-place edit.
|
||||||
|
rollbackQty := recordingStockRollbackQty(stock)
|
||||||
|
if rollbackQty > 1e-6 && shouldWriteLog {
|
||||||
|
resetLog := &entity.StockLog{
|
||||||
|
ProductWarehouseId: stock.ProductWarehouseId,
|
||||||
|
CreatedBy: actorID,
|
||||||
|
Increase: rollbackQty,
|
||||||
|
LoggableType: string(utils.StockLogTypeRecording),
|
||||||
|
LoggableId: stock.RecordingId,
|
||||||
|
Notes: note,
|
||||||
|
}
|
||||||
|
if err := s.appendRecordingStockLog(ctx, tx, resetLogState, resetLog); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
zero := 0.0
|
zero := 0.0
|
||||||
stock = entity.RecordingStock{
|
stock = entity.RecordingStock{
|
||||||
|
|||||||
@@ -3,13 +3,11 @@ package controller
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"math"
|
"math"
|
||||||
"sort"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
|
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
|
||||||
"gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/dto"
|
|
||||||
|
|
||||||
"github.com/gofiber/fiber/v2"
|
"github.com/gofiber/fiber/v2"
|
||||||
"github.com/xuri/excelize/v2"
|
"github.com/xuri/excelize/v2"
|
||||||
@@ -45,7 +43,6 @@ func buildPurchaseExportWorkbook(purchases []entity.Purchase) ([]byte, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
listItems := dto.ToPurchaseListDTOs(purchases)
|
|
||||||
grandTotals := buildPurchaseGrandTotalMap(purchases)
|
grandTotals := buildPurchaseGrandTotalMap(purchases)
|
||||||
|
|
||||||
if err := setPurchaseExportColumns(file, purchaseExportSheetName); err != nil {
|
if err := setPurchaseExportColumns(file, purchaseExportSheetName); err != nil {
|
||||||
@@ -54,7 +51,7 @@ func buildPurchaseExportWorkbook(purchases []entity.Purchase) ([]byte, error) {
|
|||||||
if err := setPurchaseExportHeaders(file, purchaseExportSheetName); err != nil {
|
if err := setPurchaseExportHeaders(file, purchaseExportSheetName); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if err := setPurchaseExportRows(file, purchaseExportSheetName, listItems, grandTotals); err != nil {
|
if err := setPurchaseExportRows(file, purchaseExportSheetName, purchases, grandTotals); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if err := file.SetPanes(purchaseExportSheetName, &excelize.Panes{
|
if err := file.SetPanes(purchaseExportSheetName, &excelize.Panes{
|
||||||
@@ -81,10 +78,11 @@ func setPurchaseExportColumns(file *excelize.File, sheet string) error {
|
|||||||
"D": 14,
|
"D": 14,
|
||||||
"E": 22,
|
"E": 22,
|
||||||
"F": 22,
|
"F": 22,
|
||||||
"G": 18,
|
"G": 22,
|
||||||
"H": 18,
|
"H": 32,
|
||||||
"I": 52,
|
"I": 18,
|
||||||
"J": 24,
|
"J": 18,
|
||||||
|
"K": 24,
|
||||||
}
|
}
|
||||||
|
|
||||||
for col, width := range columnWidths {
|
for col, width := range columnWidths {
|
||||||
@@ -107,9 +105,10 @@ func setPurchaseExportHeaders(file *excelize.File, sheet string) error {
|
|||||||
"Tanggal Terima",
|
"Tanggal Terima",
|
||||||
"Supplier",
|
"Supplier",
|
||||||
"Lokasi",
|
"Lokasi",
|
||||||
|
"Gudang",
|
||||||
|
"Product",
|
||||||
"Status",
|
"Status",
|
||||||
"Grand Total",
|
"Grand Total",
|
||||||
"Products",
|
|
||||||
"Notes",
|
"Notes",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,49 +137,34 @@ func setPurchaseExportHeaders(file *excelize.File, sheet string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return file.SetCellStyle(sheet, "A1", "J1", headerStyle)
|
return file.SetCellStyle(sheet, "A1", "K1", headerStyle)
|
||||||
}
|
}
|
||||||
|
|
||||||
func setPurchaseExportRows(file *excelize.File, sheet string, items []dto.PurchaseListDTO, grandTotals map[uint]float64) error {
|
func setPurchaseExportRows(file *excelize.File, sheet string, purchases []entity.Purchase, grandTotals map[uint]float64) error {
|
||||||
if len(items) == 0 {
|
if len(purchases) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
for i, item := range items {
|
rowIdx := 2
|
||||||
row := strconv.Itoa(i + 2)
|
for p := range purchases {
|
||||||
if err := file.SetCellValue(sheet, "A"+row, safePurchaseExportText(item.PrNumber)); err != nil {
|
purchase := &purchases[p]
|
||||||
|
total := grandTotals[purchase.Id]
|
||||||
|
if len(purchase.Items) == 0 {
|
||||||
|
if err := writePurchaseExportRow(file, sheet, rowIdx, purchase, nil, total); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := file.SetCellValue(sheet, "B"+row, safePurchaseExportPointerText(item.PoNumber)); err != nil {
|
rowIdx++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for it := range purchase.Items {
|
||||||
|
if err := writePurchaseExportRow(file, sheet, rowIdx, purchase, &purchase.Items[it], total); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := file.SetCellValue(sheet, "C"+row, formatPurchaseExportDate(item.PoDate)); err != nil {
|
rowIdx++
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := file.SetCellValue(sheet, "D"+row, formatPurchaseExportDate(item.ReceivedDate)); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := file.SetCellValue(sheet, "E"+row, safePurchaseSupplierName(item)); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := file.SetCellValue(sheet, "F"+row, safePurchaseLocationName(item)); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := file.SetCellValue(sheet, "G"+row, formatPurchaseExportStatus(item)); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := file.SetCellValue(sheet, "H"+row, formatPurchaseRupiah(grandTotals[item.Id])); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := file.SetCellValue(sheet, "I"+row, formatPurchaseProducts(item)); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := file.SetCellValue(sheet, "J"+row, safePurchaseExportPointerText(item.Notes)); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
lastRow := len(items) + 1
|
lastRow := rowIdx - 1
|
||||||
dataStyle, err := file.NewStyle(&excelize.Style{
|
dataStyle, err := file.NewStyle(&excelize.Style{
|
||||||
Alignment: &excelize.Alignment{
|
Alignment: &excelize.Alignment{
|
||||||
Horizontal: "left",
|
Horizontal: "left",
|
||||||
@@ -197,7 +181,7 @@ func setPurchaseExportRows(file *excelize.File, sheet string, items []dto.Purcha
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := file.SetCellStyle(sheet, "A2", "J"+strconv.Itoa(lastRow), dataStyle); err != nil {
|
if err := file.SetCellStyle(sheet, "A2", "K"+strconv.Itoa(lastRow), dataStyle); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -217,7 +201,59 @@ func setPurchaseExportRows(file *excelize.File, sheet string, items []dto.Purcha
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return file.SetCellStyle(sheet, "H2", "H"+strconv.Itoa(lastRow), moneyStyle)
|
return file.SetCellStyle(sheet, "J2", "J"+strconv.Itoa(lastRow), moneyStyle)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writePurchaseExportRow(file *excelize.File, sheet string, rowIdx int, purchase *entity.Purchase, item *entity.PurchaseItem, grandTotal float64) error {
|
||||||
|
row := strconv.Itoa(rowIdx)
|
||||||
|
|
||||||
|
// Purchase-level columns (repeat across rows of the same purchase)
|
||||||
|
if err := file.SetCellValue(sheet, "A"+row, safePurchaseExportText(purchase.PrNumber)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := file.SetCellValue(sheet, "B"+row, safePurchaseExportPointerText(purchase.PoNumber)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := file.SetCellValue(sheet, "C"+row, formatPurchaseExportDate(purchase.PoDate)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := file.SetCellValue(sheet, "E"+row, safePurchaseExportEntitySupplierName(purchase)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := file.SetCellValue(sheet, "I"+row, formatPurchaseExportEntityStatus(purchase)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := file.SetCellValue(sheet, "J"+row, formatPurchaseRupiah(grandTotal)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := file.SetCellValue(sheet, "K"+row, safePurchaseExportPointerText(purchase.Notes)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Item-level columns
|
||||||
|
if item == nil {
|
||||||
|
for _, col := range []string{"D", "F", "G", "H"} {
|
||||||
|
if err := file.SetCellValue(sheet, col+row, "-"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := file.SetCellValue(sheet, "D"+row, formatPurchaseExportDate(item.ReceivedDate)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := file.SetCellValue(sheet, "F"+row, safePurchaseItemLocationName(item)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := file.SetCellValue(sheet, "G"+row, safePurchaseWarehouseName(item)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := file.SetCellValue(sheet, "H"+row, safePurchaseItemProductName(item)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildPurchaseGrandTotalMap(items []entity.Purchase) map[uint]float64 {
|
func buildPurchaseGrandTotalMap(items []entity.Purchase) map[uint]float64 {
|
||||||
@@ -232,31 +268,45 @@ func buildPurchaseGrandTotalMap(items []entity.Purchase) map[uint]float64 {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
func safePurchaseSupplierName(item dto.PurchaseListDTO) string {
|
func safePurchaseExportEntitySupplierName(purchase *entity.Purchase) string {
|
||||||
if item.Supplier == nil {
|
if purchase.Supplier.Id == 0 {
|
||||||
return "-"
|
return "-"
|
||||||
}
|
}
|
||||||
return safePurchaseExportText(item.Supplier.Name)
|
return safePurchaseExportText(purchase.Supplier.Name)
|
||||||
}
|
}
|
||||||
|
|
||||||
func safePurchaseLocationName(item dto.PurchaseListDTO) string {
|
func safePurchaseWarehouseName(item *entity.PurchaseItem) string {
|
||||||
if item.Location == nil {
|
if item.Warehouse == nil {
|
||||||
return "-"
|
return "-"
|
||||||
}
|
}
|
||||||
return safePurchaseExportText(item.Location.Name)
|
return safePurchaseExportText(item.Warehouse.Name)
|
||||||
}
|
}
|
||||||
|
|
||||||
func formatPurchaseExportStatus(item dto.PurchaseListDTO) string {
|
func safePurchaseItemLocationName(item *entity.PurchaseItem) string {
|
||||||
if item.LatestApproval == nil {
|
if item.Warehouse == nil || item.Warehouse.Location == nil {
|
||||||
|
return "-"
|
||||||
|
}
|
||||||
|
return safePurchaseExportText(item.Warehouse.Location.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func safePurchaseItemProductName(item *entity.PurchaseItem) string {
|
||||||
|
if item.Product == nil {
|
||||||
|
return "-"
|
||||||
|
}
|
||||||
|
return safePurchaseExportText(item.Product.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatPurchaseExportEntityStatus(purchase *entity.Purchase) string {
|
||||||
|
if purchase.LatestApproval == nil {
|
||||||
return "-"
|
return "-"
|
||||||
}
|
}
|
||||||
|
|
||||||
if item.LatestApproval.Action != nil &&
|
if purchase.LatestApproval.Action != nil &&
|
||||||
strings.EqualFold(strings.TrimSpace(*item.LatestApproval.Action), string(entity.ApprovalActionRejected)) {
|
strings.EqualFold(strings.TrimSpace(string(*purchase.LatestApproval.Action)), string(entity.ApprovalActionRejected)) {
|
||||||
return "Ditolak"
|
return "Ditolak"
|
||||||
}
|
}
|
||||||
|
|
||||||
return safePurchaseExportText(item.LatestApproval.StepName)
|
return safePurchaseExportText(purchase.LatestApproval.StepName)
|
||||||
}
|
}
|
||||||
|
|
||||||
func formatPurchaseExportDate(value *time.Time) string {
|
func formatPurchaseExportDate(value *time.Time) string {
|
||||||
@@ -273,33 +323,6 @@ func formatPurchaseExportDate(value *time.Time) string {
|
|||||||
return t.Format("02-01-2006")
|
return t.Format("02-01-2006")
|
||||||
}
|
}
|
||||||
|
|
||||||
func formatPurchaseProducts(item dto.PurchaseListDTO) string {
|
|
||||||
if len(item.Products) == 0 {
|
|
||||||
return "-"
|
|
||||||
}
|
|
||||||
|
|
||||||
seen := make(map[string]struct{})
|
|
||||||
names := make([]string, 0, len(item.Products))
|
|
||||||
for i := range item.Products {
|
|
||||||
name := strings.TrimSpace(item.Products[i].Name)
|
|
||||||
if name == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if _, exists := seen[name]; exists {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
seen[name] = struct{}{}
|
|
||||||
names = append(names, name)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(names) == 0 {
|
|
||||||
return "-"
|
|
||||||
}
|
|
||||||
|
|
||||||
sort.Strings(names)
|
|
||||||
return strings.Join(names, ", ")
|
|
||||||
}
|
|
||||||
|
|
||||||
func safePurchaseExportPointerText(value *string) string {
|
func safePurchaseExportPointerText(value *string) string {
|
||||||
if value == nil {
|
if value == nil {
|
||||||
return "-"
|
return "-"
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ type PurchaseListDTO struct {
|
|||||||
CreatedUser *userDTO.UserRelationDTO `json:"created_user"`
|
CreatedUser *userDTO.UserRelationDTO `json:"created_user"`
|
||||||
RequesterName string `json:"requester_name"`
|
RequesterName string `json:"requester_name"`
|
||||||
PoExpedition []PoExpeditionDTO `json:"po_expedition"`
|
PoExpedition []PoExpeditionDTO `json:"po_expedition"`
|
||||||
|
Items []PurchaseItemDTO `json:"items"`
|
||||||
Products []productDTO.ProductRelationDTO `json:"products"`
|
Products []productDTO.ProductRelationDTO `json:"products"`
|
||||||
Location *locationDTO.LocationRelationDTO `json:"location"`
|
Location *locationDTO.LocationRelationDTO `json:"location"`
|
||||||
Area *areaDTO.AreaRelationDTO `json:"area"`
|
Area *areaDTO.AreaRelationDTO `json:"area"`
|
||||||
@@ -227,6 +228,7 @@ func ToPurchaseListDTO(p entity.Purchase) PurchaseListDTO {
|
|||||||
CreatedUser: createdUser,
|
CreatedUser: createdUser,
|
||||||
RequesterName: requesterName,
|
RequesterName: requesterName,
|
||||||
PoExpedition: poExpedition,
|
PoExpedition: poExpedition,
|
||||||
|
Items: ToPurchaseItemDTOs(p.Items),
|
||||||
Products: products,
|
Products: products,
|
||||||
Location: location,
|
Location: location,
|
||||||
Area: area,
|
Area: area,
|
||||||
|
|||||||
Reference in New Issue
Block a user