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!461
This commit is contained in:
@@ -0,0 +1,423 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"text/tabwriter"
|
||||||
|
|
||||||
|
commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service"
|
||||||
|
"gitlab.com/mbugroup/lti-api.git/internal/config"
|
||||||
|
"gitlab.com/mbugroup/lti-api.git/internal/database"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
outputTable = "table"
|
||||||
|
outputJSON = "json"
|
||||||
|
)
|
||||||
|
|
||||||
|
type options struct {
|
||||||
|
Apply bool
|
||||||
|
Output string
|
||||||
|
DBSSLMode string
|
||||||
|
AreaName string
|
||||||
|
}
|
||||||
|
|
||||||
|
type duplicateGroup struct {
|
||||||
|
WarehouseID uint `json:"warehouse_id"`
|
||||||
|
WarehouseName string `json:"warehouse_name"`
|
||||||
|
ProductID uint `json:"product_id"`
|
||||||
|
ProductName string `json:"product_name"`
|
||||||
|
AreaName string `json:"area_name"`
|
||||||
|
LocationName string `json:"location_name"`
|
||||||
|
ProjectFlockKandangID *uint `json:"project_flock_kandang_id,omitempty"`
|
||||||
|
|
||||||
|
SurvivorID uint `json:"survivor_id"`
|
||||||
|
SurvivorQty float64 `json:"survivor_qty"`
|
||||||
|
AbsorbedCount int `json:"absorbed_count"`
|
||||||
|
TotalMergedQty float64 `json:"total_merged_qty"`
|
||||||
|
AbsorbedIDs string `json:"absorbed_ids"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type consolidateSummary struct {
|
||||||
|
TotalDuplicateGroups int `json:"total_duplicate_groups"`
|
||||||
|
TotalProductWarehouses int64 `json:"total_product_warehouses"`
|
||||||
|
UpdatedReferences map[string]int64 `json:"updated_references,omitempty"`
|
||||||
|
DeletedProductWarehouses int64 `json:"deleted_product_warehouses,omitempty"`
|
||||||
|
OverallStatus string `json:"overall_status"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
opts, err := parseFlags()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("invalid flags: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if opts.DBSSLMode != "" {
|
||||||
|
config.DBSSLMode = opts.DBSSLMode
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
db := database.Connect(config.DBHost, config.DBName)
|
||||||
|
|
||||||
|
// Find duplicate groups
|
||||||
|
groups, err := findDuplicateProductWarehouses(ctx, db, opts)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("failed to find duplicates: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(groups) == 0 {
|
||||||
|
fmt.Println("No duplicate product_warehouses found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
summary := summarizeGroups(groups)
|
||||||
|
if !opts.Apply {
|
||||||
|
renderConsolidation(opts.Output, groups, summary)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
applied, err := applyConsolidation(ctx, db, groups)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("apply failed: %v", err)
|
||||||
|
}
|
||||||
|
renderConsolidation(opts.Output, groups, applied)
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseFlags() (*options, error) {
|
||||||
|
var opts options
|
||||||
|
flag.BoolVar(&opts.Apply, "apply", false, "Apply consolidation (omit for dry-run)")
|
||||||
|
flag.StringVar(&opts.Output, "output", outputTable, "Output format: table or json")
|
||||||
|
flag.StringVar(&opts.DBSSLMode, "db-sslmode", "", "Database sslmode override")
|
||||||
|
flag.StringVar(&opts.AreaName, "area-name", "", "Optional area filter")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
opts.Output = strings.ToLower(strings.TrimSpace(opts.Output))
|
||||||
|
opts.AreaName = strings.TrimSpace(opts.AreaName)
|
||||||
|
opts.DBSSLMode = strings.TrimSpace(opts.DBSSLMode)
|
||||||
|
|
||||||
|
if opts.Output == "" {
|
||||||
|
opts.Output = outputTable
|
||||||
|
}
|
||||||
|
if opts.Output != outputTable && opts.Output != outputJSON {
|
||||||
|
return nil, fmt.Errorf("unsupported --output=%s", opts.Output)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &opts, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func findDuplicateProductWarehouses(ctx context.Context, db *gorm.DB, opts *options) ([]duplicateGroup, error) {
|
||||||
|
filters := ""
|
||||||
|
args := []any{}
|
||||||
|
if opts.AreaName != "" {
|
||||||
|
filters = "WHERE a.name = ?"
|
||||||
|
args = append(args, opts.AreaName)
|
||||||
|
}
|
||||||
|
|
||||||
|
query := fmt.Sprintf(`
|
||||||
|
WITH duplicates AS (
|
||||||
|
SELECT
|
||||||
|
pw.warehouse_id,
|
||||||
|
w.name AS warehouse_name,
|
||||||
|
pw.product_id,
|
||||||
|
p.name AS product_name,
|
||||||
|
COALESCE(a.name, 'N/A') AS area_name,
|
||||||
|
COALESCE(l.name, 'N/A') AS location_name,
|
||||||
|
pw.project_flock_kandang_id,
|
||||||
|
pw.id,
|
||||||
|
pw.qty,
|
||||||
|
MIN(pw.id) OVER (PARTITION BY pw.warehouse_id, pw.product_id, pw.project_flock_kandang_id) AS survivor_id,
|
||||||
|
COUNT(*) OVER (PARTITION BY pw.warehouse_id, pw.product_id, pw.project_flock_kandang_id) AS duplicate_count,
|
||||||
|
SUM(pw.qty) OVER (PARTITION BY pw.warehouse_id, pw.product_id, pw.project_flock_kandang_id) AS total_qty
|
||||||
|
FROM product_warehouses pw
|
||||||
|
JOIN warehouses w ON w.id = pw.warehouse_id
|
||||||
|
JOIN products p ON p.id = pw.product_id
|
||||||
|
LEFT JOIN locations l ON l.id = w.location_id
|
||||||
|
LEFT JOIN areas a ON a.id = l.area_id
|
||||||
|
%s
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
warehouse_id,
|
||||||
|
warehouse_name,
|
||||||
|
product_id,
|
||||||
|
product_name,
|
||||||
|
area_name,
|
||||||
|
location_name,
|
||||||
|
project_flock_kandang_id,
|
||||||
|
survivor_id,
|
||||||
|
(SELECT qty FROM duplicates d2 WHERE d2.id = survivor_id LIMIT 1) AS survivor_qty,
|
||||||
|
duplicate_count - 1 AS absorbed_count,
|
||||||
|
total_qty AS total_merged_qty,
|
||||||
|
STRING_AGG(id::text, ', ' ORDER BY id::text) FILTER (WHERE id <> survivor_id) AS absorbed_ids
|
||||||
|
FROM duplicates
|
||||||
|
WHERE duplicate_count > 1
|
||||||
|
GROUP BY warehouse_id, warehouse_name, product_id, product_name, area_name, location_name, project_flock_kandang_id, survivor_id, total_qty, duplicate_count
|
||||||
|
ORDER BY area_name, location_name, warehouse_name, product_name
|
||||||
|
`, filters)
|
||||||
|
|
||||||
|
rows := make([]duplicateGroup, 0)
|
||||||
|
if err := db.WithContext(ctx).Raw(query, args...).Scan(&rows).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyConsolidation(ctx context.Context, db *gorm.DB, groups []duplicateGroup) (consolidateSummary, error) {
|
||||||
|
summary := consolidateSummary{
|
||||||
|
TotalDuplicateGroups: len(groups),
|
||||||
|
UpdatedReferences: make(map[string]int64),
|
||||||
|
OverallStatus: "PASS",
|
||||||
|
}
|
||||||
|
|
||||||
|
fifoSvc := commonSvc.NewFifoStockV2Service(db, nil)
|
||||||
|
|
||||||
|
err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
for _, group := range groups {
|
||||||
|
absorbedIDs := []uint{}
|
||||||
|
if group.AbsorbedIDs != "" {
|
||||||
|
parts := strings.Split(group.AbsorbedIDs, ", ")
|
||||||
|
for _, p := range parts {
|
||||||
|
var id uint
|
||||||
|
fmt.Sscanf(p, "%d", &id)
|
||||||
|
absorbedIDs = append(absorbedIDs, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(absorbedIDs) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update all references to point to survivor
|
||||||
|
refTables := []struct {
|
||||||
|
table string
|
||||||
|
column string
|
||||||
|
}{
|
||||||
|
{"stock_allocations", "product_warehouse_id"},
|
||||||
|
{"stock_logs", "product_warehouse_id"},
|
||||||
|
{"purchase_items", "product_warehouse_id"},
|
||||||
|
{"recording_stocks", "product_warehouse_id"},
|
||||||
|
{"recording_eggs", "product_warehouse_id"},
|
||||||
|
{"recording_depletions", "product_warehouse_id"},
|
||||||
|
{"recording_depletions", "source_product_warehouse_id"},
|
||||||
|
{"marketing_delivery_products", "product_warehouse_id"},
|
||||||
|
{"marketing_products", "product_warehouse_id"},
|
||||||
|
{"stock_transfer_details", "source_product_warehouse_id"},
|
||||||
|
{"stock_transfer_details", "dest_product_warehouse_id"},
|
||||||
|
{"adjustment_stocks", "product_warehouse_id"},
|
||||||
|
{"laying_transfer_sources", "product_warehouse_id"},
|
||||||
|
{"laying_transfer_targets", "product_warehouse_id"},
|
||||||
|
{"laying_transfers", "source_product_warehouse_id"},
|
||||||
|
{"project_chickin_details", "product_warehouse_id"},
|
||||||
|
{"project_chickins", "product_warehouse_id"},
|
||||||
|
{"project_flock_populations", "product_warehouse_id"},
|
||||||
|
{"fifo_stock_v2_operation_log", "product_warehouse_id"},
|
||||||
|
{"fifo_stock_v2_reflow_checkpoints", "product_warehouse_id"},
|
||||||
|
{"fifo_stock_v2_shadow_allocations", "product_warehouse_id"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, ref := range refTables {
|
||||||
|
res := tx.WithContext(ctx).
|
||||||
|
Table(ref.table).
|
||||||
|
Where(fmt.Sprintf("%s IN ?", ref.column), absorbedIDs).
|
||||||
|
Update(ref.column, group.SurvivorID)
|
||||||
|
if res.Error != nil {
|
||||||
|
return fmt.Errorf("update %s.%s: %w", ref.table, ref.column, res.Error)
|
||||||
|
}
|
||||||
|
if res.RowsAffected > 0 {
|
||||||
|
summary.UpdatedReferences[ref.table+"."+ref.column] += res.RowsAffected
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update survivor qty to merged total
|
||||||
|
res := tx.WithContext(ctx).
|
||||||
|
Table("product_warehouses").
|
||||||
|
Where("id = ?", group.SurvivorID).
|
||||||
|
Update("qty", group.TotalMergedQty)
|
||||||
|
if res.Error != nil {
|
||||||
|
return fmt.Errorf("update survivor qty: %w", res.Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete absorbed product_warehouses
|
||||||
|
res = tx.WithContext(ctx).
|
||||||
|
Table("product_warehouses").
|
||||||
|
Where("id IN ?", absorbedIDs).
|
||||||
|
Delete(nil)
|
||||||
|
if res.Error != nil {
|
||||||
|
return fmt.Errorf("delete absorbed: %w", res.Error)
|
||||||
|
}
|
||||||
|
summary.DeletedProductWarehouses += res.RowsAffected
|
||||||
|
|
||||||
|
// Recalculate stock_logs for survivor
|
||||||
|
if err := recalculateStockLogs(ctx, tx, []uint{group.SurvivorID}); err != nil {
|
||||||
|
return fmt.Errorf("recalculate stock_logs: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reflow and recalculate FIFO
|
||||||
|
if err := reflowProductWarehouse(ctx, fifoSvc, tx, group.SurvivorID); err != nil {
|
||||||
|
return fmt.Errorf("reflow product_warehouse %d: %w", group.SurvivorID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
summary.OverallStatus = "FAIL"
|
||||||
|
return summary, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return summary, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func recalculateStockLogs(ctx context.Context, tx *gorm.DB, productWarehouseIDs []uint) error {
|
||||||
|
if len(productWarehouseIDs) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
query := `
|
||||||
|
WITH recalculated AS (
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
SUM(COALESCE(increase, 0) - COALESCE(decrease, 0))
|
||||||
|
OVER (PARTITION BY product_warehouse_id ORDER BY created_at ASC, id ASC) AS running_stock
|
||||||
|
FROM stock_logs
|
||||||
|
WHERE product_warehouse_id IN ?
|
||||||
|
)
|
||||||
|
UPDATE stock_logs sl
|
||||||
|
SET stock = recalculated.running_stock
|
||||||
|
FROM recalculated
|
||||||
|
WHERE sl.id = recalculated.id
|
||||||
|
`
|
||||||
|
return tx.WithContext(ctx).Exec(query, productWarehouseIDs).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func reflowProductWarehouse(ctx context.Context, fifoSvc commonSvc.FifoStockV2Service, tx *gorm.DB, productWarehouseID uint) error {
|
||||||
|
type row struct {
|
||||||
|
FlagGroupCode string `gorm:"column:flag_group_code"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var selected row
|
||||||
|
err := tx.WithContext(ctx).
|
||||||
|
Table("fifo_stock_v2_route_rules rr").
|
||||||
|
Select("rr.flag_group_code").
|
||||||
|
Joins("JOIN fifo_stock_v2_flag_groups fg ON fg.code = rr.flag_group_code AND fg.is_active = TRUE").
|
||||||
|
Where("rr.is_active = TRUE").
|
||||||
|
Where("rr.lane = ?", "STOCKABLE").
|
||||||
|
Where("rr.function_code = ?", "PURCHASE_IN").
|
||||||
|
Where("rr.source_table = ?", "purchase_items").
|
||||||
|
Where(`EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM product_warehouses pw
|
||||||
|
JOIN flags f ON f.flagable_id = pw.product_id
|
||||||
|
JOIN fifo_stock_v2_flag_members fm ON fm.flag_name = f.name AND fm.is_active = TRUE
|
||||||
|
WHERE pw.id = ?
|
||||||
|
AND f.flagable_type = 'products'
|
||||||
|
AND fm.flag_group_code = rr.flag_group_code
|
||||||
|
)`, productWarehouseID).
|
||||||
|
Order("rr.id ASC").
|
||||||
|
Limit(1).
|
||||||
|
Take(&selected).Error
|
||||||
|
|
||||||
|
if err != nil && err != gorm.ErrRecordNotFound {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
flagGroupCode := strings.TrimSpace(selected.FlagGroupCode)
|
||||||
|
|
||||||
|
if _, err := fifoSvc.Reflow(ctx, commonSvc.FifoStockV2ReflowRequest{
|
||||||
|
FlagGroupCode: flagGroupCode,
|
||||||
|
ProductWarehouseID: productWarehouseID,
|
||||||
|
Tx: tx,
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := fifoSvc.Recalculate(ctx, commonSvc.FifoStockV2RecalculateRequest{
|
||||||
|
ProductWarehouseIDs: []uint{productWarehouseID},
|
||||||
|
FlagGroupCodes: []string{flagGroupCode},
|
||||||
|
FixDrift: true,
|
||||||
|
Tx: tx,
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func summarizeGroups(groups []duplicateGroup) consolidateSummary {
|
||||||
|
var totalQty int64
|
||||||
|
for _, g := range groups {
|
||||||
|
totalQty += int64(g.AbsorbedCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
return consolidateSummary{
|
||||||
|
TotalDuplicateGroups: len(groups),
|
||||||
|
TotalProductWarehouses: totalQty,
|
||||||
|
OverallStatus: "PASS",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderConsolidation(mode string, groups []duplicateGroup, summary consolidateSummary) {
|
||||||
|
if mode == outputJSON {
|
||||||
|
payload := map[string]any{
|
||||||
|
"groups": groups,
|
||||||
|
"summary": summary,
|
||||||
|
}
|
||||||
|
enc := json.NewEncoder(os.Stdout)
|
||||||
|
enc.SetIndent("", " ")
|
||||||
|
_ = enc.Encode(payload)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w := tabwriter.NewWriter(os.Stdout, 2, 8, 2, ' ', 0)
|
||||||
|
fmt.Fprintln(w, "AREA\tLOCATION\tWAREHOUSE\tPRODUCT\tPFK_ID\tSURVIVOR_ID\tSURVIVOR_QTY\tABSORBED_COUNT\tTOTAL_MERGED_QTY\tABSORBED_IDS")
|
||||||
|
for _, g := range groups {
|
||||||
|
pfkID := "-"
|
||||||
|
if g.ProjectFlockKandangID != nil {
|
||||||
|
pfkID = fmt.Sprintf("%d", *g.ProjectFlockKandangID)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(
|
||||||
|
w,
|
||||||
|
"%s\t%s\t%s\t%s\t%s\t%d\t%.3f\t%d\t%.3f\t%s\n",
|
||||||
|
g.AreaName,
|
||||||
|
g.LocationName,
|
||||||
|
g.WarehouseName,
|
||||||
|
g.ProductName,
|
||||||
|
pfkID,
|
||||||
|
g.SurvivorID,
|
||||||
|
g.SurvivorQty,
|
||||||
|
g.AbsorbedCount,
|
||||||
|
g.TotalMergedQty,
|
||||||
|
g.AbsorbedIDs,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
_ = w.Flush()
|
||||||
|
|
||||||
|
fmt.Printf("\n=== SUMMARY ===\n")
|
||||||
|
fmt.Printf("Duplicate groups found: %d\n", summary.TotalDuplicateGroups)
|
||||||
|
fmt.Printf("Product warehouses to delete: %d\n", summary.TotalProductWarehouses)
|
||||||
|
fmt.Printf("Overall status: %s\n", summary.OverallStatus)
|
||||||
|
|
||||||
|
if len(summary.UpdatedReferences) > 0 {
|
||||||
|
fmt.Println("\nUpdated references:")
|
||||||
|
keys := make([]string, 0, len(summary.UpdatedReferences))
|
||||||
|
for k := range summary.UpdatedReferences {
|
||||||
|
keys = append(keys, k)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
for _, k := range keys {
|
||||||
|
fmt.Printf(" %s=%d\n", k, summary.UpdatedReferences[k])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,466 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"text/tabwriter"
|
||||||
|
|
||||||
|
"gitlab.com/mbugroup/lti-api.git/internal/config"
|
||||||
|
"gitlab.com/mbugroup/lti-api.git/internal/database"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
outputModeTable = "table"
|
||||||
|
outputModeJSON = "json"
|
||||||
|
|
||||||
|
reportUsage = "usage"
|
||||||
|
reportWarehouses = "warehouses"
|
||||||
|
)
|
||||||
|
|
||||||
|
type options struct {
|
||||||
|
Output string
|
||||||
|
Report string
|
||||||
|
AreaName string
|
||||||
|
KandangLocationName string
|
||||||
|
WrongWarehouseName string
|
||||||
|
CorrectWarehouseName string
|
||||||
|
UsableType string
|
||||||
|
DBSSLMode string
|
||||||
|
}
|
||||||
|
|
||||||
|
type usageRow struct {
|
||||||
|
UsableType string `gorm:"column:usable_type" json:"usable_type"`
|
||||||
|
UsableID uint `gorm:"column:usable_id" json:"usable_id"`
|
||||||
|
AreaName string `gorm:"column:area_name" json:"area_name"`
|
||||||
|
LokasiName string `gorm:"column:lokasi_name" json:"lokasi_name"`
|
||||||
|
KandangName string `gorm:"column:kandang_name" json:"kandang_name"`
|
||||||
|
WrongWarehouseID uint `gorm:"column:wrong_warehouse_id" json:"wrong_warehouse_id"`
|
||||||
|
WrongWarehouseName string `gorm:"column:wrong_warehouse_name" json:"wrong_warehouse_name"`
|
||||||
|
CorrectWarehouseID uint `gorm:"column:correct_warehouse_id" json:"correct_warehouse_id"`
|
||||||
|
CorrectWarehouseName string `gorm:"column:correct_warehouse_name" json:"correct_warehouse_name"`
|
||||||
|
ProductNames string `gorm:"column:product_names" json:"product_names"`
|
||||||
|
SourcePurchaseNumbers string `gorm:"column:source_purchase_numbers" json:"source_purchase_numbers"`
|
||||||
|
SourcePurchaseItemIDs string `gorm:"column:source_purchase_item_ids" json:"source_purchase_item_ids"`
|
||||||
|
QtyFromWrongStock float64 `gorm:"column:qty_from_wrong_stock" json:"qty_from_wrong_stock"`
|
||||||
|
RecordingID *uint `gorm:"column:recording_id" json:"recording_id,omitempty"`
|
||||||
|
RecordingDate *string `gorm:"column:recording_date" json:"recording_date,omitempty"`
|
||||||
|
SoNumber *string `gorm:"column:so_number" json:"so_number,omitempty"`
|
||||||
|
SoDate *string `gorm:"column:so_date" json:"so_date,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type warehouseMismatchRow struct {
|
||||||
|
AreaName string `gorm:"column:area_name" json:"area_name"`
|
||||||
|
WrongLocationName string `gorm:"column:wrong_location_name" json:"wrong_location_name"`
|
||||||
|
KandangLocationName string `gorm:"column:kandang_location_name" json:"kandang_location_name"`
|
||||||
|
KandangID uint `gorm:"column:kandang_id" json:"kandang_id"`
|
||||||
|
KandangName string `gorm:"column:kandang_name" json:"kandang_name"`
|
||||||
|
WrongWarehouseID uint `gorm:"column:wrong_warehouse_id" json:"wrong_warehouse_id"`
|
||||||
|
WrongWarehouseName string `gorm:"column:wrong_warehouse_name" json:"wrong_warehouse_name"`
|
||||||
|
WrongWarehouseType string `gorm:"column:wrong_warehouse_type" json:"wrong_warehouse_type"`
|
||||||
|
CorrectWarehouseID uint `gorm:"column:correct_warehouse_id" json:"correct_warehouse_id"`
|
||||||
|
CorrectWarehouseName string `gorm:"column:correct_warehouse_name" json:"correct_warehouse_name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type summary struct {
|
||||||
|
Rows int `json:"rows"`
|
||||||
|
TotalQty float64 `json:"total_qty,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
opts, err := parseFlags()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("invalid flags: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
if opts.DBSSLMode != "" {
|
||||||
|
config.DBSSLMode = opts.DBSSLMode
|
||||||
|
}
|
||||||
|
db := database.Connect(config.DBHost, config.DBName)
|
||||||
|
|
||||||
|
switch opts.Report {
|
||||||
|
case reportUsage:
|
||||||
|
rows, err := loadUsageRows(ctx, db, opts)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("failed loading usage rows: %v", err)
|
||||||
|
}
|
||||||
|
renderUsageReport(opts.Output, rows)
|
||||||
|
case reportWarehouses:
|
||||||
|
rows, err := loadWarehouseMismatchRows(ctx, db, opts)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("failed loading warehouse mismatch rows: %v", err)
|
||||||
|
}
|
||||||
|
renderWarehouseReport(opts.Output, rows)
|
||||||
|
default:
|
||||||
|
log.Fatalf("unsupported --report=%s", opts.Report)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseFlags() (*options, error) {
|
||||||
|
var opts options
|
||||||
|
flag.StringVar(&opts.Output, "output", outputModeTable, "Output format: table or json")
|
||||||
|
flag.StringVar(&opts.Report, "report", reportUsage, "Report type: usage or warehouses")
|
||||||
|
flag.StringVar(&opts.AreaName, "area-name", "", "Optional exact area name filter")
|
||||||
|
flag.StringVar(&opts.KandangLocationName, "kandang-location-name", "", "Optional exact canonical kandang location filter")
|
||||||
|
flag.StringVar(&opts.WrongWarehouseName, "wrong-warehouse-name", "", "Optional exact wrong warehouse name filter")
|
||||||
|
flag.StringVar(&opts.CorrectWarehouseName, "correct-warehouse-name", "", "Optional exact correct warehouse name filter")
|
||||||
|
flag.StringVar(&opts.UsableType, "usable-type", "", "Optional usage type filter: RECORDING_STOCK or MARKETING_DELIVERY")
|
||||||
|
flag.StringVar(&opts.DBSSLMode, "db-sslmode", "", "Optional database sslmode override, for example: require")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
opts.Output = strings.ToLower(strings.TrimSpace(opts.Output))
|
||||||
|
opts.Report = strings.ToLower(strings.TrimSpace(opts.Report))
|
||||||
|
opts.AreaName = strings.TrimSpace(opts.AreaName)
|
||||||
|
opts.KandangLocationName = strings.TrimSpace(opts.KandangLocationName)
|
||||||
|
opts.WrongWarehouseName = strings.TrimSpace(opts.WrongWarehouseName)
|
||||||
|
opts.CorrectWarehouseName = strings.TrimSpace(opts.CorrectWarehouseName)
|
||||||
|
opts.UsableType = strings.ToUpper(strings.TrimSpace(opts.UsableType))
|
||||||
|
opts.DBSSLMode = strings.TrimSpace(opts.DBSSLMode)
|
||||||
|
|
||||||
|
if opts.Output == "" {
|
||||||
|
opts.Output = outputModeTable
|
||||||
|
}
|
||||||
|
if opts.Output != outputModeTable && opts.Output != outputModeJSON {
|
||||||
|
return nil, fmt.Errorf("unsupported --output=%s", opts.Output)
|
||||||
|
}
|
||||||
|
if opts.Report == "" {
|
||||||
|
opts.Report = reportUsage
|
||||||
|
}
|
||||||
|
if opts.Report != reportUsage && opts.Report != reportWarehouses {
|
||||||
|
return nil, fmt.Errorf("unsupported --report=%s", opts.Report)
|
||||||
|
}
|
||||||
|
if opts.UsableType != "" && opts.UsableType != "RECORDING_STOCK" && opts.UsableType != "MARKETING_DELIVERY" {
|
||||||
|
return nil, fmt.Errorf("unsupported --usable-type=%s", opts.UsableType)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &opts, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadUsageRows(ctx context.Context, db *gorm.DB, opts *options) ([]usageRow, error) {
|
||||||
|
warehouseFilters, warehouseArgs := buildWarehouseFilters(opts)
|
||||||
|
|
||||||
|
usageFilters := make([]string, 0, 1)
|
||||||
|
usageArgs := make([]any, 0, 1)
|
||||||
|
if opts.UsableType != "" {
|
||||||
|
usageFilters = append(usageFilters, "sa.usable_type = ?")
|
||||||
|
usageArgs = append(usageArgs, opts.UsableType)
|
||||||
|
}
|
||||||
|
|
||||||
|
args := append([]any{}, warehouseArgs...)
|
||||||
|
args = append(args, usageArgs...)
|
||||||
|
|
||||||
|
query := fmt.Sprintf(`
|
||||||
|
WITH wrong_warehouses AS (
|
||||||
|
SELECT
|
||||||
|
w.id AS wrong_warehouse_id,
|
||||||
|
w.name AS wrong_warehouse_name,
|
||||||
|
k.id AS kandang_id,
|
||||||
|
k.name AS kandang_name,
|
||||||
|
a.name AS area_name,
|
||||||
|
kl.name AS kandang_location_name,
|
||||||
|
correct_w.id AS correct_warehouse_id,
|
||||||
|
correct_w.name AS correct_warehouse_name
|
||||||
|
FROM warehouses w
|
||||||
|
JOIN kandangs k
|
||||||
|
ON k.id = w.kandang_id
|
||||||
|
AND k.deleted_at IS NULL
|
||||||
|
JOIN locations kl
|
||||||
|
ON kl.id = k.location_id
|
||||||
|
JOIN areas a
|
||||||
|
ON a.id = kl.area_id
|
||||||
|
JOIN LATERAL (
|
||||||
|
SELECT w2.id, w2.name
|
||||||
|
FROM warehouses w2
|
||||||
|
WHERE w2.kandang_id = w.kandang_id
|
||||||
|
AND w2.location_id = k.location_id
|
||||||
|
AND w2.deleted_at IS NULL
|
||||||
|
AND w2.id <> w.id
|
||||||
|
ORDER BY w2.id ASC
|
||||||
|
LIMIT 1
|
||||||
|
) AS correct_w ON TRUE
|
||||||
|
WHERE w.deleted_at IS NULL
|
||||||
|
AND w.kandang_id IS NOT NULL
|
||||||
|
AND w.location_id IS DISTINCT FROM k.location_id
|
||||||
|
%s
|
||||||
|
),
|
||||||
|
wrong_allocs AS (
|
||||||
|
SELECT
|
||||||
|
sa.usable_type,
|
||||||
|
sa.usable_id,
|
||||||
|
sa.qty,
|
||||||
|
pi.id AS purchase_item_id,
|
||||||
|
COALESCE(p.po_number, p.pr_number) AS purchase_number,
|
||||||
|
pr.name AS product_name,
|
||||||
|
ww.area_name,
|
||||||
|
ww.kandang_location_name,
|
||||||
|
ww.kandang_name,
|
||||||
|
ww.wrong_warehouse_id,
|
||||||
|
ww.wrong_warehouse_name,
|
||||||
|
ww.correct_warehouse_id,
|
||||||
|
ww.correct_warehouse_name
|
||||||
|
FROM stock_allocations sa
|
||||||
|
JOIN purchase_items pi
|
||||||
|
ON pi.id = sa.stockable_id
|
||||||
|
JOIN purchases p
|
||||||
|
ON p.id = pi.purchase_id
|
||||||
|
AND p.deleted_at IS NULL
|
||||||
|
JOIN products pr
|
||||||
|
ON pr.id = pi.product_id
|
||||||
|
JOIN wrong_warehouses ww
|
||||||
|
ON ww.wrong_warehouse_id = pi.warehouse_id
|
||||||
|
WHERE sa.stockable_type = 'PURCHASE_ITEMS'
|
||||||
|
AND sa.status = 'ACTIVE'
|
||||||
|
AND sa.allocation_purpose = 'CONSUME'
|
||||||
|
AND sa.deleted_at IS NULL
|
||||||
|
%s
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
wa.usable_type,
|
||||||
|
wa.usable_id,
|
||||||
|
wa.area_name,
|
||||||
|
wa.kandang_location_name AS lokasi_name,
|
||||||
|
wa.kandang_name,
|
||||||
|
wa.wrong_warehouse_id,
|
||||||
|
wa.wrong_warehouse_name,
|
||||||
|
wa.correct_warehouse_id,
|
||||||
|
wa.correct_warehouse_name,
|
||||||
|
STRING_AGG(DISTINCT wa.product_name, ' | ') AS product_names,
|
||||||
|
STRING_AGG(DISTINCT wa.purchase_number, ', ') AS source_purchase_numbers,
|
||||||
|
STRING_AGG(DISTINCT wa.purchase_item_id::text, ', ') AS source_purchase_item_ids,
|
||||||
|
SUM(wa.qty) AS qty_from_wrong_stock,
|
||||||
|
rs.recording_id,
|
||||||
|
TO_CHAR(r.record_datetime::date, 'YYYY-MM-DD') AS recording_date,
|
||||||
|
m.so_number,
|
||||||
|
TO_CHAR(m.so_date::date, 'YYYY-MM-DD') AS so_date
|
||||||
|
FROM wrong_allocs wa
|
||||||
|
LEFT JOIN recording_stocks rs
|
||||||
|
ON wa.usable_type = 'RECORDING_STOCK'
|
||||||
|
AND rs.id = wa.usable_id
|
||||||
|
LEFT JOIN recordings r
|
||||||
|
ON r.id = rs.recording_id
|
||||||
|
LEFT JOIN marketing_delivery_products mdp
|
||||||
|
ON wa.usable_type = 'MARKETING_DELIVERY'
|
||||||
|
AND mdp.id = wa.usable_id
|
||||||
|
LEFT JOIN marketing_products mp
|
||||||
|
ON mp.id = mdp.marketing_product_id
|
||||||
|
LEFT JOIN marketings m
|
||||||
|
ON m.id = mp.marketing_id
|
||||||
|
GROUP BY
|
||||||
|
wa.usable_type,
|
||||||
|
wa.usable_id,
|
||||||
|
wa.area_name,
|
||||||
|
wa.kandang_location_name,
|
||||||
|
wa.kandang_name,
|
||||||
|
wa.wrong_warehouse_id,
|
||||||
|
wa.wrong_warehouse_name,
|
||||||
|
wa.correct_warehouse_id,
|
||||||
|
wa.correct_warehouse_name,
|
||||||
|
rs.recording_id,
|
||||||
|
r.record_datetime,
|
||||||
|
m.so_number,
|
||||||
|
m.so_date
|
||||||
|
ORDER BY
|
||||||
|
wa.area_name ASC,
|
||||||
|
wa.kandang_location_name ASC,
|
||||||
|
wa.wrong_warehouse_name ASC,
|
||||||
|
wa.usable_type ASC,
|
||||||
|
wa.usable_id ASC
|
||||||
|
`, andClause(warehouseFilters), andClause(usageFilters))
|
||||||
|
|
||||||
|
rows := make([]usageRow, 0)
|
||||||
|
if err := db.WithContext(ctx).Raw(query, args...).Scan(&rows).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadWarehouseMismatchRows(ctx context.Context, db *gorm.DB, opts *options) ([]warehouseMismatchRow, error) {
|
||||||
|
warehouseFilters, args := buildWarehouseFilters(opts)
|
||||||
|
|
||||||
|
query := fmt.Sprintf(`
|
||||||
|
SELECT
|
||||||
|
a.name AS area_name,
|
||||||
|
wl.name AS wrong_location_name,
|
||||||
|
kl.name AS kandang_location_name,
|
||||||
|
k.id AS kandang_id,
|
||||||
|
k.name AS kandang_name,
|
||||||
|
w.id AS wrong_warehouse_id,
|
||||||
|
w.name AS wrong_warehouse_name,
|
||||||
|
w.type AS wrong_warehouse_type,
|
||||||
|
correct_w.id AS correct_warehouse_id,
|
||||||
|
correct_w.name AS correct_warehouse_name
|
||||||
|
FROM warehouses w
|
||||||
|
JOIN kandangs k
|
||||||
|
ON k.id = w.kandang_id
|
||||||
|
AND k.deleted_at IS NULL
|
||||||
|
JOIN locations kl
|
||||||
|
ON kl.id = k.location_id
|
||||||
|
JOIN areas a
|
||||||
|
ON a.id = kl.area_id
|
||||||
|
LEFT JOIN locations wl
|
||||||
|
ON wl.id = w.location_id
|
||||||
|
JOIN LATERAL (
|
||||||
|
SELECT w2.id, w2.name
|
||||||
|
FROM warehouses w2
|
||||||
|
WHERE w2.kandang_id = w.kandang_id
|
||||||
|
AND w2.location_id = k.location_id
|
||||||
|
AND w2.deleted_at IS NULL
|
||||||
|
AND w2.id <> w.id
|
||||||
|
ORDER BY w2.id ASC
|
||||||
|
LIMIT 1
|
||||||
|
) AS correct_w ON TRUE
|
||||||
|
WHERE w.deleted_at IS NULL
|
||||||
|
AND w.kandang_id IS NOT NULL
|
||||||
|
AND w.location_id IS DISTINCT FROM k.location_id
|
||||||
|
%s
|
||||||
|
ORDER BY a.name ASC, kl.name ASC, k.name ASC, w.id ASC
|
||||||
|
`, andClause(warehouseFilters))
|
||||||
|
|
||||||
|
rows := make([]warehouseMismatchRow, 0)
|
||||||
|
if err := db.WithContext(ctx).Raw(query, args...).Scan(&rows).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildWarehouseFilters(opts *options) ([]string, []any) {
|
||||||
|
filters := make([]string, 0, 4)
|
||||||
|
args := make([]any, 0, 4)
|
||||||
|
|
||||||
|
if opts == nil {
|
||||||
|
return filters, args
|
||||||
|
}
|
||||||
|
if opts.AreaName != "" {
|
||||||
|
filters = append(filters, "a.name = ?")
|
||||||
|
args = append(args, opts.AreaName)
|
||||||
|
}
|
||||||
|
if opts.KandangLocationName != "" {
|
||||||
|
filters = append(filters, "kl.name = ?")
|
||||||
|
args = append(args, opts.KandangLocationName)
|
||||||
|
}
|
||||||
|
if opts.WrongWarehouseName != "" {
|
||||||
|
filters = append(filters, "w.name = ?")
|
||||||
|
args = append(args, opts.WrongWarehouseName)
|
||||||
|
}
|
||||||
|
if opts.CorrectWarehouseName != "" {
|
||||||
|
filters = append(filters, "correct_w.name = ?")
|
||||||
|
args = append(args, opts.CorrectWarehouseName)
|
||||||
|
}
|
||||||
|
|
||||||
|
return filters, args
|
||||||
|
}
|
||||||
|
|
||||||
|
func andClause(filters []string) string {
|
||||||
|
if len(filters) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return " AND " + strings.Join(filters, " AND ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderUsageReport(mode string, rows []usageRow) {
|
||||||
|
if mode == outputModeJSON {
|
||||||
|
payload := map[string]any{
|
||||||
|
"report": reportUsage,
|
||||||
|
"rows": rows,
|
||||||
|
"summary": summarizeUsage(rows),
|
||||||
|
}
|
||||||
|
enc := json.NewEncoder(os.Stdout)
|
||||||
|
enc.SetIndent("", " ")
|
||||||
|
_ = enc.Encode(payload)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w := tabwriter.NewWriter(os.Stdout, 2, 8, 2, ' ', 0)
|
||||||
|
fmt.Fprintln(w, "USABLE_TYPE\tUSABLE_ID\tAREA\tLOKASI\tKANDANG\tWRONG_WAREHOUSE\tCORRECT_WAREHOUSE\tPRODUCTS\tQTY_FROM_WRONG_STOCK\tRECORDING_ID\tRECORDING_DATE\tSO_NUMBER\tSO_DATE\tSOURCE_PURCHASES\tSOURCE_PURCHASE_ITEM_IDS")
|
||||||
|
for _, row := range rows {
|
||||||
|
fmt.Fprintf(
|
||||||
|
w,
|
||||||
|
"%s\t%d\t%s\t%s\t%s\t%s\t%s\t%s\t%.3f\t%s\t%s\t%s\t%s\t%s\t%s\n",
|
||||||
|
row.UsableType,
|
||||||
|
row.UsableID,
|
||||||
|
row.AreaName,
|
||||||
|
row.LokasiName,
|
||||||
|
row.KandangName,
|
||||||
|
row.WrongWarehouseName,
|
||||||
|
row.CorrectWarehouseName,
|
||||||
|
row.ProductNames,
|
||||||
|
row.QtyFromWrongStock,
|
||||||
|
displayOptionalUint(row.RecordingID),
|
||||||
|
displayOptionalString(row.RecordingDate),
|
||||||
|
displayOptionalString(row.SoNumber),
|
||||||
|
displayOptionalString(row.SoDate),
|
||||||
|
row.SourcePurchaseNumbers,
|
||||||
|
row.SourcePurchaseItemIDs,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
_ = w.Flush()
|
||||||
|
|
||||||
|
s := summarizeUsage(rows)
|
||||||
|
fmt.Printf("\nSummary: rows=%d total_qty=%.3f\n", s.Rows, s.TotalQty)
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderWarehouseReport(mode string, rows []warehouseMismatchRow) {
|
||||||
|
if mode == outputModeJSON {
|
||||||
|
payload := map[string]any{
|
||||||
|
"report": reportWarehouses,
|
||||||
|
"rows": rows,
|
||||||
|
"summary": summary{Rows: len(rows)},
|
||||||
|
}
|
||||||
|
enc := json.NewEncoder(os.Stdout)
|
||||||
|
enc.SetIndent("", " ")
|
||||||
|
_ = enc.Encode(payload)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w := tabwriter.NewWriter(os.Stdout, 2, 8, 2, ' ', 0)
|
||||||
|
fmt.Fprintln(w, "AREA\tKANDANG_LOCATION\tKANDANG_ID\tKANDANG\tWRONG_LOCATION\tWRONG_WAREHOUSE_ID\tWRONG_WAREHOUSE\tWRONG_WAREHOUSE_TYPE\tCORRECT_WAREHOUSE_ID\tCORRECT_WAREHOUSE")
|
||||||
|
for _, row := range rows {
|
||||||
|
fmt.Fprintf(
|
||||||
|
w,
|
||||||
|
"%s\t%s\t%d\t%s\t%s\t%d\t%s\t%s\t%d\t%s\n",
|
||||||
|
row.AreaName,
|
||||||
|
row.KandangLocationName,
|
||||||
|
row.KandangID,
|
||||||
|
row.KandangName,
|
||||||
|
row.WrongLocationName,
|
||||||
|
row.WrongWarehouseID,
|
||||||
|
row.WrongWarehouseName,
|
||||||
|
row.WrongWarehouseType,
|
||||||
|
row.CorrectWarehouseID,
|
||||||
|
row.CorrectWarehouseName,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
_ = w.Flush()
|
||||||
|
|
||||||
|
fmt.Printf("\nSummary: rows=%d\n", len(rows))
|
||||||
|
}
|
||||||
|
|
||||||
|
func summarizeUsage(rows []usageRow) summary {
|
||||||
|
out := summary{Rows: len(rows)}
|
||||||
|
for _, row := range rows {
|
||||||
|
out.TotalQty += row.QtyFromWrongStock
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func displayOptionalUint(value *uint) string {
|
||||||
|
if value == nil || *value == 0 {
|
||||||
|
return "-"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%d", *value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func displayOptionalString(value *string) string {
|
||||||
|
if value == nil || strings.TrimSpace(*value) == "" {
|
||||||
|
return "-"
|
||||||
|
}
|
||||||
|
return *value
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,524 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"text/tabwriter"
|
||||||
|
|
||||||
|
"gitlab.com/mbugroup/lti-api.git/internal/config"
|
||||||
|
"gitlab.com/mbugroup/lti-api.git/internal/database"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
outputTable = "table"
|
||||||
|
outputJSON = "json"
|
||||||
|
|
||||||
|
caseA = "A"
|
||||||
|
caseB = "B"
|
||||||
|
caseAll = "ALL"
|
||||||
|
)
|
||||||
|
|
||||||
|
type options struct {
|
||||||
|
Output string
|
||||||
|
AreaName string
|
||||||
|
KandangLocationName string
|
||||||
|
DBSSLMode string
|
||||||
|
VerifyCase string
|
||||||
|
}
|
||||||
|
|
||||||
|
type sourceWarehouseCheck struct {
|
||||||
|
AreaName string `json:"area_name"`
|
||||||
|
KandangLocationName string `json:"kandang_location_name"`
|
||||||
|
KandangID uint `json:"kandang_id"`
|
||||||
|
KandangName string `json:"kandang_name"`
|
||||||
|
SourceWarehouseID uint `json:"source_warehouse_id"`
|
||||||
|
SourceWarehouseName string `json:"source_warehouse_name"`
|
||||||
|
Case string `json:"case" gorm:"column:case_type"`
|
||||||
|
DeletedAt *string `json:"deleted_at"`
|
||||||
|
StockInProductWH float64 `json:"stock_in_product_wh"`
|
||||||
|
ActivePurchaseItems int64 `json:"active_purchase_items"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type destinationWarehouseCheck struct {
|
||||||
|
AreaName string `json:"area_name"`
|
||||||
|
KandangLocationName string `json:"kandang_location_name"`
|
||||||
|
FarmWarehouseID uint `json:"farm_warehouse_id"`
|
||||||
|
FarmWarehouseName string `json:"farm_warehouse_name"`
|
||||||
|
ProductID uint `json:"product_id"`
|
||||||
|
ProductName string `json:"product_name"`
|
||||||
|
CurrentQty float64 `json:"current_qty"`
|
||||||
|
StockLogsTotal float64 `json:"stock_logs_total"`
|
||||||
|
StockLogsCount int64 `json:"stock_logs_count"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type orphanedReferenceCheck struct {
|
||||||
|
Table string `json:"table"`
|
||||||
|
Column string `json:"column"`
|
||||||
|
ReferenceCount int64 `json:"reference_count"`
|
||||||
|
DeletedWarehouseIDs string `json:"deleted_warehouse_ids"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type verificationSummary struct {
|
||||||
|
TotalSourceWarehouses int `json:"total_source_warehouses"`
|
||||||
|
CleanSourceWarehouses int `json:"clean_source_warehouses"`
|
||||||
|
DirtySourceWarehouses int `json:"dirty_source_warehouses"`
|
||||||
|
TotalDestinationWarehouses int `json:"total_destination_warehouses"`
|
||||||
|
MatchingDestinations int `json:"matching_destinations"`
|
||||||
|
DiscrepancyDestinations int `json:"discrepancy_destinations"`
|
||||||
|
TotalOrphanedReferences int64 `json:"total_orphaned_references"`
|
||||||
|
OverallStatus string `json:"overall_status"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
opts, err := parseFlags()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("invalid flags: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if opts.DBSSLMode != "" {
|
||||||
|
config.DBSSLMode = opts.DBSSLMode
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
db := database.Connect(config.DBHost, config.DBName)
|
||||||
|
|
||||||
|
// Verify source warehouses
|
||||||
|
sourceChecks, err := verifySourceWarehouses(ctx, db, opts)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("failed to verify source warehouses: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify destination warehouses
|
||||||
|
destChecks, err := verifyDestinationWarehouses(ctx, db, opts, sourceChecks)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("failed to verify destination warehouses: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify no orphaned references
|
||||||
|
orphanedRefs, err := verifyOrphanedReferences(ctx, db, sourceChecks)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("failed to verify orphaned references: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render results
|
||||||
|
summary := buildSummary(sourceChecks, destChecks, orphanedRefs)
|
||||||
|
renderVerification(opts.Output, sourceChecks, destChecks, orphanedRefs, summary)
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseFlags() (*options, error) {
|
||||||
|
var opts options
|
||||||
|
flag.StringVar(&opts.Output, "output", outputTable, "Output format: table or json")
|
||||||
|
flag.StringVar(&opts.AreaName, "area-name", "", "Optional exact area name filter")
|
||||||
|
flag.StringVar(&opts.KandangLocationName, "kandang-location-name", "", "Optional exact canonical kandang location filter")
|
||||||
|
flag.StringVar(&opts.DBSSLMode, "db-sslmode", "", "Optional database sslmode override, for example: require")
|
||||||
|
flag.StringVar(&opts.VerifyCase, "verify-case", caseAll, "Verify specific case: A, B, or all")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
opts.Output = strings.ToLower(strings.TrimSpace(opts.Output))
|
||||||
|
opts.AreaName = strings.TrimSpace(opts.AreaName)
|
||||||
|
opts.KandangLocationName = strings.TrimSpace(opts.KandangLocationName)
|
||||||
|
opts.DBSSLMode = strings.TrimSpace(opts.DBSSLMode)
|
||||||
|
opts.VerifyCase = strings.ToUpper(strings.TrimSpace(opts.VerifyCase))
|
||||||
|
|
||||||
|
if opts.Output == "" {
|
||||||
|
opts.Output = outputTable
|
||||||
|
}
|
||||||
|
if opts.Output != outputTable && opts.Output != outputJSON {
|
||||||
|
return nil, fmt.Errorf("unsupported --output=%s", opts.Output)
|
||||||
|
}
|
||||||
|
if opts.VerifyCase == "" {
|
||||||
|
opts.VerifyCase = caseAll
|
||||||
|
}
|
||||||
|
if opts.VerifyCase != caseA && opts.VerifyCase != caseB && opts.VerifyCase != caseAll {
|
||||||
|
return nil, fmt.Errorf("unsupported --verify-case=%s", opts.VerifyCase)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &opts, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func verifySourceWarehouses(ctx context.Context, db *gorm.DB, opts *options) ([]sourceWarehouseCheck, error) {
|
||||||
|
filters, args := buildFilters(opts)
|
||||||
|
query := fmt.Sprintf(`
|
||||||
|
WITH case_a_warehouses AS (
|
||||||
|
-- Case A: Kandang-level warehouses (type != 'LOKASI' or NULL)
|
||||||
|
SELECT
|
||||||
|
w.id,
|
||||||
|
a.name AS area_name,
|
||||||
|
kl.name AS kandang_location_name,
|
||||||
|
k.id,
|
||||||
|
k.name,
|
||||||
|
'A'::text AS case_type
|
||||||
|
FROM warehouses w
|
||||||
|
JOIN kandangs k ON k.id = w.kandang_id AND k.deleted_at IS NULL
|
||||||
|
JOIN locations kl ON kl.id = k.location_id
|
||||||
|
JOIN areas a ON a.id = kl.area_id
|
||||||
|
WHERE w.deleted_at IS NOT NULL
|
||||||
|
AND w.kandang_id IS NOT NULL
|
||||||
|
AND UPPER(COALESCE(w.type, '')) <> 'LOKASI'
|
||||||
|
),
|
||||||
|
case_b_warehouses AS (
|
||||||
|
-- Case B: Wrong-location warehouses (location_id != kandang.location_id)
|
||||||
|
SELECT
|
||||||
|
w.id,
|
||||||
|
a.name AS area_name,
|
||||||
|
kl.name AS kandang_location_name,
|
||||||
|
k.id,
|
||||||
|
k.name,
|
||||||
|
'B'::text AS case_type
|
||||||
|
FROM warehouses w
|
||||||
|
JOIN kandangs k ON k.id = w.kandang_id AND k.deleted_at IS NULL
|
||||||
|
JOIN locations kl ON kl.id = k.location_id
|
||||||
|
JOIN areas a ON a.id = kl.area_id
|
||||||
|
WHERE w.deleted_at IS NOT NULL
|
||||||
|
AND w.kandang_id IS NOT NULL
|
||||||
|
AND w.location_id IS DISTINCT FROM k.location_id
|
||||||
|
),
|
||||||
|
all_source_warehouses AS (
|
||||||
|
SELECT w_id, area_name, kandang_location_name, k_id AS kandang_id, name, case_type FROM (
|
||||||
|
SELECT w.id as w_id, a.name AS area_name, kl.name AS kandang_location_name, k.id as k_id, k.name, 'A'::text AS case_type
|
||||||
|
FROM warehouses w
|
||||||
|
JOIN kandangs k ON k.id = w.kandang_id AND k.deleted_at IS NULL
|
||||||
|
JOIN locations kl ON kl.id = k.location_id
|
||||||
|
JOIN areas a ON a.id = kl.area_id
|
||||||
|
WHERE w.deleted_at IS NOT NULL
|
||||||
|
AND w.kandang_id IS NOT NULL
|
||||||
|
AND UPPER(COALESCE(w.type, '')) <> 'LOKASI'
|
||||||
|
) case_a_warehouses
|
||||||
|
UNION ALL
|
||||||
|
SELECT w_id, area_name, kandang_location_name, k_id AS kandang_id, name, case_type FROM (
|
||||||
|
SELECT w.id as w_id, a.name AS area_name, kl.name AS kandang_location_name, k.id as k_id, k.name, 'B'::text AS case_type
|
||||||
|
FROM warehouses w
|
||||||
|
JOIN kandangs k ON k.id = w.kandang_id AND k.deleted_at IS NULL
|
||||||
|
JOIN locations kl ON kl.id = k.location_id
|
||||||
|
JOIN areas a ON a.id = kl.area_id
|
||||||
|
WHERE w.deleted_at IS NOT NULL
|
||||||
|
AND w.kandang_id IS NOT NULL
|
||||||
|
AND w.location_id IS DISTINCT FROM k.location_id
|
||||||
|
) case_b_warehouses
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
asw.area_name,
|
||||||
|
asw.kandang_location_name,
|
||||||
|
asw.kandang_id,
|
||||||
|
asw.name AS kandang_name,
|
||||||
|
w.id AS source_warehouse_id,
|
||||||
|
w.name AS source_warehouse_name,
|
||||||
|
asw.case_type,
|
||||||
|
TO_CHAR(w.deleted_at, 'YYYY-MM-DD') AS deleted_at,
|
||||||
|
COALESCE(SUM(pw.qty), 0) AS stock_in_product_wh,
|
||||||
|
COUNT(DISTINCT pi.id) AS active_purchase_items
|
||||||
|
FROM all_source_warehouses asw
|
||||||
|
JOIN warehouses w ON w.id = asw.w_id
|
||||||
|
LEFT JOIN product_warehouses pw ON pw.warehouse_id = w.id
|
||||||
|
LEFT JOIN purchase_items pi ON pi.warehouse_id = w.id
|
||||||
|
WHERE true
|
||||||
|
%s
|
||||||
|
GROUP BY
|
||||||
|
asw.area_name,
|
||||||
|
asw.kandang_location_name,
|
||||||
|
asw.kandang_id,
|
||||||
|
asw.name,
|
||||||
|
w.id,
|
||||||
|
w.name,
|
||||||
|
asw.case_type,
|
||||||
|
w.deleted_at
|
||||||
|
ORDER BY asw.area_name ASC, asw.kandang_location_name ASC, w.name ASC
|
||||||
|
`, andClause(filters))
|
||||||
|
|
||||||
|
rows := make([]sourceWarehouseCheck, 0)
|
||||||
|
if err := db.WithContext(ctx).Raw(query, args...).Scan(&rows).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine status for each row
|
||||||
|
for i := range rows {
|
||||||
|
if rows[i].StockInProductWH == 0 && rows[i].ActivePurchaseItems == 0 {
|
||||||
|
rows[i].Status = "CLEAN"
|
||||||
|
} else {
|
||||||
|
rows[i].Status = "DIRTY"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by case if requested
|
||||||
|
if opts.VerifyCase != caseAll && rows[i].Case != opts.VerifyCase {
|
||||||
|
rows = append(rows[:i], rows[i+1:]...)
|
||||||
|
i--
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func verifyDestinationWarehouses(ctx context.Context, db *gorm.DB, opts *options, sourceChecks []sourceWarehouseCheck) ([]destinationWarehouseCheck, error) {
|
||||||
|
filters, args := buildFilters(opts)
|
||||||
|
query := fmt.Sprintf(`
|
||||||
|
SELECT
|
||||||
|
a.name AS area_name,
|
||||||
|
kl.name AS kandang_location_name,
|
||||||
|
fw.id AS farm_warehouse_id,
|
||||||
|
fw.name AS farm_warehouse_name,
|
||||||
|
p.id AS product_id,
|
||||||
|
p.name AS product_name,
|
||||||
|
COALESCE(pw.qty, 0) AS current_qty,
|
||||||
|
COALESCE(SUM(sl.stock), 0) AS stock_logs_total,
|
||||||
|
COUNT(DISTINCT sl.id) AS stock_logs_count
|
||||||
|
FROM warehouses fw
|
||||||
|
JOIN locations kl ON kl.id = fw.location_id
|
||||||
|
JOIN areas a ON a.id = kl.area_id
|
||||||
|
JOIN product_warehouses pw ON pw.warehouse_id = fw.id
|
||||||
|
JOIN products p ON p.id = pw.product_id
|
||||||
|
JOIN flags f ON f.flagable_id = p.id AND f.flagable_type = 'products' AND UPPER(f.name) IN ('PAKAN', 'OVK')
|
||||||
|
LEFT JOIN stock_logs sl ON sl.product_warehouse_id = pw.id
|
||||||
|
WHERE fw.deleted_at IS NULL
|
||||||
|
AND UPPER(COALESCE(fw.type, '')) = 'LOKASI'
|
||||||
|
%s
|
||||||
|
GROUP BY
|
||||||
|
a.name,
|
||||||
|
kl.name,
|
||||||
|
fw.id,
|
||||||
|
fw.name,
|
||||||
|
p.id,
|
||||||
|
p.name,
|
||||||
|
pw.qty
|
||||||
|
ORDER BY a.name ASC, kl.name ASC, fw.name ASC, p.name ASC
|
||||||
|
`, andClause(filters))
|
||||||
|
|
||||||
|
rows := make([]destinationWarehouseCheck, 0)
|
||||||
|
if err := db.WithContext(ctx).Raw(query, args...).Scan(&rows).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine status: check if current_qty matches stock_logs
|
||||||
|
for i := range rows {
|
||||||
|
if rows[i].CurrentQty > 0 {
|
||||||
|
// Allow small floating point discrepancies
|
||||||
|
if abs(rows[i].CurrentQty-rows[i].StockLogsTotal) < 0.001 {
|
||||||
|
rows[i].Status = "MATCHED"
|
||||||
|
} else {
|
||||||
|
rows[i].Status = "DISCREPANCY"
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
rows[i].Status = "EMPTY"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func verifyOrphanedReferences(ctx context.Context, db *gorm.DB, sourceChecks []sourceWarehouseCheck) ([]orphanedReferenceCheck, error) {
|
||||||
|
if len(sourceChecks) == 0 {
|
||||||
|
return []orphanedReferenceCheck{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get unique warehouse IDs from source checks
|
||||||
|
warehouseIDs := make([]uint, 0)
|
||||||
|
for _, check := range sourceChecks {
|
||||||
|
warehouseIDs = append(warehouseIDs, check.SourceWarehouseID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check common references
|
||||||
|
var results []orphanedReferenceCheck
|
||||||
|
|
||||||
|
refChecks := []struct {
|
||||||
|
table string
|
||||||
|
column string
|
||||||
|
}{
|
||||||
|
{"purchase_items", "warehouse_id"},
|
||||||
|
{"stock_transfers", "from_warehouse_id"},
|
||||||
|
{"stock_transfers", "to_warehouse_id"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, ref := range refChecks {
|
||||||
|
var count int64
|
||||||
|
if err := db.Table(ref.table).
|
||||||
|
Where(fmt.Sprintf("%s IN ?", ref.column), warehouseIDs).
|
||||||
|
Count(&count).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if count > 0 {
|
||||||
|
// Get the specific warehouse IDs using raw SQL
|
||||||
|
var ids []uint
|
||||||
|
query := fmt.Sprintf("SELECT DISTINCT %s FROM %s WHERE %s IN ?",
|
||||||
|
ref.column, ref.table, ref.column)
|
||||||
|
if err := db.Raw(query, warehouseIDs).Scan(&ids).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
idStrs := make([]string, 0, len(ids))
|
||||||
|
for _, id := range ids {
|
||||||
|
idStrs = append(idStrs, fmt.Sprintf("%d", id))
|
||||||
|
}
|
||||||
|
|
||||||
|
results = append(results, orphanedReferenceCheck{
|
||||||
|
Table: ref.table,
|
||||||
|
Column: ref.column,
|
||||||
|
ReferenceCount: count,
|
||||||
|
DeletedWarehouseIDs: strings.Join(idStrs, ", "),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildFilters(opts *options) ([]string, []any) {
|
||||||
|
filters := make([]string, 0, 2)
|
||||||
|
args := make([]any, 0, 2)
|
||||||
|
if opts.AreaName != "" {
|
||||||
|
filters = append(filters, "a.name = ?")
|
||||||
|
args = append(args, opts.AreaName)
|
||||||
|
}
|
||||||
|
if opts.KandangLocationName != "" {
|
||||||
|
filters = append(filters, "kl.name = ?")
|
||||||
|
args = append(args, opts.KandangLocationName)
|
||||||
|
}
|
||||||
|
return filters, args
|
||||||
|
}
|
||||||
|
|
||||||
|
func andClause(filters []string) string {
|
||||||
|
if len(filters) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return " AND " + strings.Join(filters, " AND ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildSummary(sourceChecks []sourceWarehouseCheck, destChecks []destinationWarehouseCheck, orphanedRefs []orphanedReferenceCheck) verificationSummary {
|
||||||
|
summary := verificationSummary{
|
||||||
|
TotalSourceWarehouses: len(sourceChecks),
|
||||||
|
OverallStatus: "PASS",
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, check := range sourceChecks {
|
||||||
|
if check.Status == "CLEAN" {
|
||||||
|
summary.CleanSourceWarehouses++
|
||||||
|
} else {
|
||||||
|
summary.DirtySourceWarehouses++
|
||||||
|
summary.OverallStatus = "FAIL"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
summary.TotalDestinationWarehouses = len(destChecks)
|
||||||
|
for _, check := range destChecks {
|
||||||
|
if check.Status == "MATCHED" || check.Status == "EMPTY" {
|
||||||
|
summary.MatchingDestinations++
|
||||||
|
} else if check.Status == "DISCREPANCY" {
|
||||||
|
summary.DiscrepancyDestinations++
|
||||||
|
summary.OverallStatus = "FAIL"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, ref := range orphanedRefs {
|
||||||
|
summary.TotalOrphanedReferences += ref.ReferenceCount
|
||||||
|
summary.OverallStatus = "FAIL"
|
||||||
|
}
|
||||||
|
|
||||||
|
return summary
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderVerification(mode string, sourceChecks []sourceWarehouseCheck, destChecks []destinationWarehouseCheck, orphanedRefs []orphanedReferenceCheck, summary verificationSummary) {
|
||||||
|
if mode == outputJSON {
|
||||||
|
payload := map[string]any{
|
||||||
|
"source_warehouses": sourceChecks,
|
||||||
|
"destination_warehouses": destChecks,
|
||||||
|
"orphaned_references": orphanedRefs,
|
||||||
|
"summary": summary,
|
||||||
|
}
|
||||||
|
enc := json.NewEncoder(os.Stdout)
|
||||||
|
enc.SetIndent("", " ")
|
||||||
|
_ = enc.Encode(payload)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Table mode
|
||||||
|
fmt.Println("\n=== SOURCE WAREHOUSES VERIFICATION ===")
|
||||||
|
if len(sourceChecks) == 0 {
|
||||||
|
fmt.Println("No deleted warehouses found")
|
||||||
|
} else {
|
||||||
|
w := tabwriter.NewWriter(os.Stdout, 2, 8, 2, ' ', 0)
|
||||||
|
fmt.Fprintln(w, "AREA\tLOKASI\tKANDANG\tWAREHOUSE\tCASE\tDELETED_AT\tSTOCK_IN_PW\tPURCHASE_ITEMS\tSTATUS")
|
||||||
|
for _, check := range sourceChecks {
|
||||||
|
fmt.Fprintf(
|
||||||
|
w,
|
||||||
|
"%s\t%s\t%s\t%s\t%s\t%s\t%.3f\t%d\t%s\n",
|
||||||
|
check.AreaName,
|
||||||
|
check.KandangLocationName,
|
||||||
|
check.KandangName,
|
||||||
|
check.SourceWarehouseName,
|
||||||
|
check.Case,
|
||||||
|
displayOptionalString(check.DeletedAt),
|
||||||
|
check.StockInProductWH,
|
||||||
|
check.ActivePurchaseItems,
|
||||||
|
check.Status,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
_ = w.Flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("\n=== DESTINATION WAREHOUSES VERIFICATION ===")
|
||||||
|
if len(destChecks) == 0 {
|
||||||
|
fmt.Println("No destination warehouses found")
|
||||||
|
} else {
|
||||||
|
w := tabwriter.NewWriter(os.Stdout, 2, 8, 2, ' ', 0)
|
||||||
|
fmt.Fprintln(w, "AREA\tLOKASI\tFARM_WAREHOUSE\tPRODUCT\tCURRENT_QTY\tSTOCK_LOGS_TOTAL\tLOGS_COUNT\tSTATUS")
|
||||||
|
for _, check := range destChecks {
|
||||||
|
fmt.Fprintf(
|
||||||
|
w,
|
||||||
|
"%s\t%s\t%s\t%s\t%.3f\t%.3f\t%d\t%s\n",
|
||||||
|
check.AreaName,
|
||||||
|
check.KandangLocationName,
|
||||||
|
check.FarmWarehouseName,
|
||||||
|
check.ProductName,
|
||||||
|
check.CurrentQty,
|
||||||
|
check.StockLogsTotal,
|
||||||
|
check.StockLogsCount,
|
||||||
|
check.Status,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
_ = w.Flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(orphanedRefs) > 0 {
|
||||||
|
fmt.Println("\n=== ORPHANED REFERENCES (ERRORS) ===")
|
||||||
|
w := tabwriter.NewWriter(os.Stdout, 2, 8, 2, ' ', 0)
|
||||||
|
fmt.Fprintln(w, "TABLE\tCOLUMN\tCOUNT\tWAREHOUSE_IDS")
|
||||||
|
for _, ref := range orphanedRefs {
|
||||||
|
fmt.Fprintf(
|
||||||
|
w,
|
||||||
|
"%s\t%s\t%d\t%s\n",
|
||||||
|
ref.Table,
|
||||||
|
ref.Column,
|
||||||
|
ref.ReferenceCount,
|
||||||
|
ref.DeletedWarehouseIDs,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
_ = w.Flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("\n=== SUMMARY ===\n")
|
||||||
|
fmt.Printf("Source Warehouses: %d total, %d clean, %d dirty\n", summary.TotalSourceWarehouses, summary.CleanSourceWarehouses, summary.DirtySourceWarehouses)
|
||||||
|
fmt.Printf("Destination Warehouses: %d total, %d matching, %d discrepancies\n", summary.TotalDestinationWarehouses, summary.MatchingDestinations, summary.DiscrepancyDestinations)
|
||||||
|
fmt.Printf("Orphaned References: %d\n", summary.TotalOrphanedReferences)
|
||||||
|
fmt.Printf("Overall Status: %s\n", summary.OverallStatus)
|
||||||
|
}
|
||||||
|
|
||||||
|
func displayOptionalString(value *string) string {
|
||||||
|
if value == nil || strings.TrimSpace(*value) == "" {
|
||||||
|
return "-"
|
||||||
|
}
|
||||||
|
return *value
|
||||||
|
}
|
||||||
|
|
||||||
|
func abs(x float64) float64 {
|
||||||
|
if x < 0 {
|
||||||
|
return -x
|
||||||
|
}
|
||||||
|
return x
|
||||||
|
}
|
||||||
Executable
BIN
Binary file not shown.
Executable
BIN
Binary file not shown.
@@ -0,0 +1,460 @@
|
|||||||
|
# Stock Consolidation Operations Guide
|
||||||
|
|
||||||
|
This guide explains how to use the warehouse consolidation commands to fix misplaced PAKAN/OVK stocks and migrate them to the correct farm-level warehouses.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The stock consolidation system handles two main scenarios:
|
||||||
|
|
||||||
|
| Case | Scenario | Root Cause | Solution |
|
||||||
|
|------|----------|-----------|----------|
|
||||||
|
| **Case B** | Invalid kandang references | Purchases pointed to warehouses with location mismatch | Move unused stocks to correct farm-level warehouse |
|
||||||
|
| **Case A** | General kandang cleanup | Any kandang-level warehouse with unused PAKAN/OVK stocks | Consolidate to farm-level warehouse |
|
||||||
|
|
||||||
|
## Recommended Execution Order
|
||||||
|
|
||||||
|
For a complete stock consolidation operation, follow this sequence:
|
||||||
|
|
||||||
|
```
|
||||||
|
1. find-wrong-warehouse-records ← Diagnose issues
|
||||||
|
2. repoint-wrong-warehouse-relations ← Fix Case B (invalid references)
|
||||||
|
3. consolidate-kandang-to-farm-stocks ← Fix Case A (general cleanup)
|
||||||
|
4. verify-stock-consolidation ← Audit and verify results
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Command Reference
|
||||||
|
|
||||||
|
### 1. `find-wrong-warehouse-records` — Diagnostic Tool
|
||||||
|
|
||||||
|
**Purpose:** Identify problematic warehouses and their associated stocks before making any changes.
|
||||||
|
|
||||||
|
**Applies to:** Both Case A and Case B scenarios
|
||||||
|
|
||||||
|
**What it does:**
|
||||||
|
- Lists warehouses with location mismatches (Case B)
|
||||||
|
- Shows stock allocations that reference wrong warehouses
|
||||||
|
- Helps identify scope of work needed
|
||||||
|
|
||||||
|
#### Usage:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Report 1: Find warehouses with location mismatches (Case B issues)
|
||||||
|
./find-wrong-warehouse-records --report=warehouses
|
||||||
|
|
||||||
|
# Report 2: Find stock allocations in wrong warehouses (Case B impact)
|
||||||
|
./find-wrong-warehouse-records --report=usage
|
||||||
|
|
||||||
|
# Filter by area
|
||||||
|
./find-wrong-warehouse-records --report=warehouses --area-name "East Region"
|
||||||
|
|
||||||
|
# Filter by kandang location
|
||||||
|
./find-wrong-warehouse-records --report=usage --kandang-location-name "Location 1"
|
||||||
|
|
||||||
|
# Filter by product type
|
||||||
|
./find-wrong-warehouse-records --report=usage --usable-type=RECORDING_STOCK
|
||||||
|
|
||||||
|
# JSON output for analysis
|
||||||
|
./find-wrong-warehouse-records --report=usage --output=json > analysis.json
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Output Columns (Warehouses Report):
|
||||||
|
- **AREA**: Geographic area
|
||||||
|
- **KANDANG_LOCATION**: Kandang's intended location
|
||||||
|
- **KANDANG**: Kandang name
|
||||||
|
- **WRONG_LOCATION**: Where the warehouse actually is
|
||||||
|
- **WRONG_WAREHOUSE**: Problematic warehouse name
|
||||||
|
- **CORRECT_WAREHOUSE**: Where stocks should be
|
||||||
|
|
||||||
|
#### Output Columns (Usage Report):
|
||||||
|
- **USABLE_TYPE**: RECORDING_STOCK or MARKETING_DELIVERY
|
||||||
|
- **PRODUCTS**: Which products are affected
|
||||||
|
- **QTY_FROM_WRONG_STOCK**: How much stock is misplaced
|
||||||
|
- **SOURCE_PURCHASES**: Which purchase orders are affected
|
||||||
|
|
||||||
|
**When to use:**
|
||||||
|
- Before starting any consolidation
|
||||||
|
- To understand the scope of issues
|
||||||
|
- To get metrics on how much stock needs moving
|
||||||
|
- To identify which areas are most affected
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. `repoint-wrong-warehouse-relations` — Fix Case B (Invalid References)
|
||||||
|
|
||||||
|
**Purpose:** Fix purchases pointed to invalid kandang warehouses (location mismatch).
|
||||||
|
|
||||||
|
**Applies to:** Case B only
|
||||||
|
|
||||||
|
**Cases it handles:**
|
||||||
|
- ✅ Warehouses with `location_id ≠ kandang.location_id` (location mismatch)
|
||||||
|
- ✅ Only PAKAN/OVK products
|
||||||
|
- ✅ Only unused/leftover stocks (no active allocations)
|
||||||
|
- ✅ Moves to farm-level warehouse at correct location
|
||||||
|
|
||||||
|
**What it does:**
|
||||||
|
1. Finds product_warehouses in wrong locations
|
||||||
|
2. Consolidates duplicates into survivor warehouses
|
||||||
|
3. Updates all references across the system
|
||||||
|
4. Recalculates FIFO stocks if needed
|
||||||
|
5. Optionally soft-deletes the wrong warehouse
|
||||||
|
|
||||||
|
#### Usage:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Dry-run: See what would be moved (always run first!)
|
||||||
|
./repoint-wrong-warehouse-relations
|
||||||
|
|
||||||
|
# Dry-run with specific filters
|
||||||
|
./repoint-wrong-warehouse-relations --area-name "East Region"
|
||||||
|
./repoint-wrong-warehouse-relations --kandang-location-name "Location 1"
|
||||||
|
|
||||||
|
# Actually apply the migration
|
||||||
|
./repoint-wrong-warehouse-relations --apply
|
||||||
|
|
||||||
|
# Apply but keep the wrong warehouses (for audit trail)
|
||||||
|
./repoint-wrong-warehouse-relations --apply --delete-wrong-warehouses=false
|
||||||
|
|
||||||
|
# JSON output for automation/logging
|
||||||
|
./repoint-wrong-warehouse-relations --apply --output=json > migration.json
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Flags:
|
||||||
|
- `--apply`: Apply changes (omit for dry-run)
|
||||||
|
- `--output`: `table` (default) or `json`
|
||||||
|
- `--area-name`: Filter by exact area name
|
||||||
|
- `--kandang-location-name`: Filter by exact location name
|
||||||
|
- `--delete-wrong-warehouses`: Soft-delete wrong warehouses (default: true)
|
||||||
|
- `--db-sslmode`: PostgreSQL SSL mode override (e.g., `require`)
|
||||||
|
|
||||||
|
#### Output:
|
||||||
|
|
||||||
|
**Table mode shows:**
|
||||||
|
- AREA, LOCATION, KANDANG: Where the issue is
|
||||||
|
- WRONG_WAREHOUSE: Source (will be deleted)
|
||||||
|
- TARGET_WAREHOUSE: Destination (farm-level)
|
||||||
|
- PRODUCT: What's being moved
|
||||||
|
- SURVIVOR_PW / ABSORBED_PW: Consolidation details
|
||||||
|
- NEEDS_REFLOW: Whether FIFO recalculation is needed
|
||||||
|
|
||||||
|
**Summary shows:**
|
||||||
|
```
|
||||||
|
Summary: plan_rows=15 wrong_warehouses=3 survivor_pws=12 absorbed_pws=5
|
||||||
|
needs_reflow_pws=3 deleted_product_warehouses=5 soft_deleted_warehouses=3
|
||||||
|
|
||||||
|
Updated product_warehouse refs:
|
||||||
|
fifo_stock_v2_operation_log.product_warehouse_id=8
|
||||||
|
fifo_stock_v2_reflow_checkpoints.product_warehouse_id=3
|
||||||
|
purchase_items.warehouse_id=12
|
||||||
|
|
||||||
|
Updated warehouse refs:
|
||||||
|
purchase_items.warehouse_id=12
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Safety Features:
|
||||||
|
- **Dry-run first**: Always preview before applying
|
||||||
|
- **Prechecks**: Verifies no blocked references or FIFO conflicts
|
||||||
|
- **Atomic transactions**: All-or-nothing database updates
|
||||||
|
- **Reference verification**: Confirms all references were updated
|
||||||
|
- **Stock log recalculation**: Ensures FIFO accuracy after moves
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. `consolidate-kandang-to-farm-stocks` — Fix Case A (General Cleanup)
|
||||||
|
|
||||||
|
**Purpose:** Consolidate ALL kandang-level PAKAN/OVK stocks to farm-level warehouse.
|
||||||
|
|
||||||
|
**Applies to:** Case A only
|
||||||
|
|
||||||
|
**Cases it handles:**
|
||||||
|
- ✅ ALL kandang-level warehouses (type ≠ 'LOKASI')
|
||||||
|
- ✅ Only PAKAN/OVK products
|
||||||
|
- ✅ Only unused/leftover stocks (no active allocations)
|
||||||
|
- ✅ Moves to farm-level warehouse regardless of warehouse validity
|
||||||
|
- ✅ No location validation (processes all kandang warehouses)
|
||||||
|
|
||||||
|
**What it does:**
|
||||||
|
1. Finds all kandang-level warehouses with unused stocks
|
||||||
|
2. Consolidates duplicates into survivor warehouses
|
||||||
|
3. Updates all references across the system
|
||||||
|
4. Recalculates FIFO stocks if needed
|
||||||
|
5. Optionally soft-deletes the kandang warehouse
|
||||||
|
|
||||||
|
#### Usage:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Dry-run: See what would be consolidated
|
||||||
|
./consolidate-kandang-to-farm-stocks
|
||||||
|
|
||||||
|
# Dry-run with filters
|
||||||
|
./consolidate-kandang-to-farm-stocks --area-name "East Region"
|
||||||
|
./consolidate-kandang-to-farm-stocks --kandang-location-name "Location 1"
|
||||||
|
|
||||||
|
# Actually apply the consolidation
|
||||||
|
./consolidate-kandang-to-farm-stocks --apply
|
||||||
|
|
||||||
|
# Apply but keep kandang warehouses
|
||||||
|
./consolidate-kandang-to-farm-stocks --apply --delete-kandang-warehouses=false
|
||||||
|
|
||||||
|
# JSON output for logging
|
||||||
|
./consolidate-kandang-to-farm-stocks --apply --output=json > consolidation.json
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Flags:
|
||||||
|
- `--apply`: Apply changes (omit for dry-run)
|
||||||
|
- `--output`: `table` (default) or `json`
|
||||||
|
- `--area-name`: Filter by exact area name
|
||||||
|
- `--kandang-location-name`: Filter by exact location name
|
||||||
|
- `--delete-kandang-warehouses`: Soft-delete kandang warehouses (default: true)
|
||||||
|
- `--db-sslmode`: PostgreSQL SSL mode override
|
||||||
|
|
||||||
|
#### Output Format:
|
||||||
|
Similar to Case B, shows:
|
||||||
|
- Source kandang warehouse → Destination farm warehouse
|
||||||
|
- Product and quantity details
|
||||||
|
- Consolidation and FIFO reflow information
|
||||||
|
|
||||||
|
#### Key Differences from Case B:
|
||||||
|
| Aspect | Case B | Case A |
|
||||||
|
|--------|--------|--------|
|
||||||
|
| Scope | Wrong-location warehouses only | ALL kandang-level warehouses |
|
||||||
|
| Validation | Checks location mismatch | No validation checks |
|
||||||
|
| When to use | After finding mismatches | General cleanup/consolidation |
|
||||||
|
| Risk level | Lower (targeted fix) | Higher (broader scope) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. `verify-stock-consolidation` — Audit and Verify
|
||||||
|
|
||||||
|
**Purpose:** Verify that stock consolidations were successful and no stocks were lost.
|
||||||
|
|
||||||
|
**Applies to:** Both Case A and Case B (post-migration verification)
|
||||||
|
|
||||||
|
**What it checks:**
|
||||||
|
|
||||||
|
#### ✅ Source Warehouse Verification
|
||||||
|
Ensures deleted warehouses are clean:
|
||||||
|
- **CLEAN**: No remaining stock or purchase references
|
||||||
|
- **DIRTY**: Still has orphaned data (migration incomplete)
|
||||||
|
|
||||||
|
#### ✅ Destination Warehouse Verification
|
||||||
|
Ensures farm-level warehouses received stocks correctly:
|
||||||
|
- **MATCHED**: Quantity in product_warehouse matches stock_logs
|
||||||
|
- **DISCREPANCY**: Quantity mismatch (data integrity issue!)
|
||||||
|
- **EMPTY**: No stocks (correct if nothing was supposed to move)
|
||||||
|
|
||||||
|
#### ✅ Orphaned Reference Detection
|
||||||
|
Finds any remaining references to deleted warehouses in:
|
||||||
|
- `purchase_items.warehouse_id`
|
||||||
|
- `stock_transfers.from/to_warehouse_id`
|
||||||
|
- `fifo_stock_v2_operation_log.warehouse_id`
|
||||||
|
|
||||||
|
#### Usage:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Verify all consolidations (Case A + B together)
|
||||||
|
./verify-stock-consolidation
|
||||||
|
|
||||||
|
# Verify only Case B results
|
||||||
|
./verify-stock-consolidation --verify-case=B
|
||||||
|
|
||||||
|
# Verify only Case A results
|
||||||
|
./verify-stock-consolidation --verify-case=A
|
||||||
|
|
||||||
|
# Filter by area
|
||||||
|
./verify-stock-consolidation --area-name "East Region"
|
||||||
|
|
||||||
|
# Filter by location
|
||||||
|
./verify-stock-consolidation --kandang-location-name "Location 1"
|
||||||
|
|
||||||
|
# JSON output for reporting
|
||||||
|
./verify-stock-consolidation --output=json > verification_report.json
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Flags:
|
||||||
|
- `--verify-case`: `A`, `B`, or `all` (default)
|
||||||
|
- `--output`: `table` (default) or `json`
|
||||||
|
- `--area-name`: Filter by exact area name
|
||||||
|
- `--kandang-location-name`: Filter by exact location name
|
||||||
|
- `--db-sslmode`: PostgreSQL SSL mode override
|
||||||
|
|
||||||
|
#### Output Sections:
|
||||||
|
|
||||||
|
**1. Source Warehouses**
|
||||||
|
```
|
||||||
|
AREA LOKASI KANDANG WAREHOUSE CASE DELETED_AT STOCK PURCHASES STATUS
|
||||||
|
Area A Location 1 Kandang A KWH-A-01 A 2026-04-23 0.000 0 CLEAN
|
||||||
|
Area A Location 1 Kandang B WH-WRONG-001 B 2026-04-23 2.500 1 DIRTY ❌
|
||||||
|
```
|
||||||
|
|
||||||
|
**2. Destination Warehouses**
|
||||||
|
```
|
||||||
|
AREA LOKASI FARM_WAREHOUSE PRODUCT QTY LOGS_TOTAL LOGS STATUS
|
||||||
|
Area A Location 1 FWH-LOC-001 PAKAN A 2.500 2.500 3 MATCHED ✅
|
||||||
|
Area A Location 1 FWH-LOC-001 OVK B 5.000 4.999 5 DISCREPANCY ❌
|
||||||
|
```
|
||||||
|
|
||||||
|
**3. Orphaned References** (if any)
|
||||||
|
```
|
||||||
|
TABLE COLUMN COUNT WAREHOUSE_IDS
|
||||||
|
purchase_items warehouse_id 3 1001, 1002, 1003
|
||||||
|
stock_transfers from_warehouse_id 1 1001
|
||||||
|
```
|
||||||
|
|
||||||
|
**4. Summary**
|
||||||
|
```
|
||||||
|
Source Warehouses: 10 total, 8 clean, 2 dirty
|
||||||
|
Destination Warehouses: 15 total, 14 matching, 1 discrepancy
|
||||||
|
Orphaned References: 4
|
||||||
|
Overall Status: FAIL ❌
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Interpreting Results:
|
||||||
|
|
||||||
|
| Scenario | Meaning | Action |
|
||||||
|
|----------|---------|--------|
|
||||||
|
| ✅ Overall Status: PASS | All migrations successful | No action needed |
|
||||||
|
| ❌ Dirty Source Warehouses | Stocks not fully moved | Re-run repoint/consolidate |
|
||||||
|
| ❌ Discrepancy Destinations | Quantity mismatch | Investigate data integrity |
|
||||||
|
| ❌ Orphaned References | Broken references remain | Manual cleanup needed |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Complete Workflow Example
|
||||||
|
|
||||||
|
### Scenario: Consolidate East Region stocks
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Step 1: Understand the scope (Case B issues)
|
||||||
|
./find-wrong-warehouse-records --report=warehouses --area-name "East Region"
|
||||||
|
./find-wrong-warehouse-records --report=usage --area-name "East Region"
|
||||||
|
|
||||||
|
# Review the output to understand:
|
||||||
|
# - How many wrong warehouses
|
||||||
|
# - How much stock needs moving
|
||||||
|
# - Which products are affected
|
||||||
|
|
||||||
|
# Step 2: Fix Case B (invalid kandang references)
|
||||||
|
./repoint-wrong-warehouse-relations --area-name "East Region"
|
||||||
|
# Review dry-run output
|
||||||
|
|
||||||
|
./repoint-wrong-warehouse-relations --apply --area-name "East Region"
|
||||||
|
# Watch for summary - should show successful updates
|
||||||
|
|
||||||
|
# Step 3: Fix Case A (general kandang cleanup)
|
||||||
|
./consolidate-kandang-to-farm-stocks --area-name "East Region"
|
||||||
|
# Review dry-run output
|
||||||
|
|
||||||
|
./consolidate-kandang-to-farm-stocks --apply --area-name "East Region"
|
||||||
|
# Watch for summary - should show consolidation complete
|
||||||
|
|
||||||
|
# Step 4: Verify everything worked
|
||||||
|
./verify-stock-consolidation --area-name "East Region"
|
||||||
|
# Should show:
|
||||||
|
# - All source warehouses: CLEAN
|
||||||
|
# - All destination warehouses: MATCHED
|
||||||
|
# - Orphaned references: 0
|
||||||
|
# - Overall Status: PASS ✅
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Flags Reference
|
||||||
|
|
||||||
|
### Common Flags (All Commands)
|
||||||
|
|
||||||
|
| Flag | Description | Example |
|
||||||
|
|------|-------------|---------|
|
||||||
|
| `--output` | Output format | `--output=json` |
|
||||||
|
| `--area-name` | Filter by area | `--area-name "East Region"` |
|
||||||
|
| `--kandang-location-name` | Filter by location | `--kandang-location-name "Location 1"` |
|
||||||
|
| `--db-sslmode` | PostgreSQL SSL mode | `--db-sslmode=require` |
|
||||||
|
|
||||||
|
### Migration-Specific Flags
|
||||||
|
|
||||||
|
| Command | Flag | Description |
|
||||||
|
|---------|------|-------------|
|
||||||
|
| `repoint-wrong-warehouse-relations` | `--apply` | Apply changes |
|
||||||
|
| `repoint-wrong-warehouse-relations` | `--delete-wrong-warehouses` | Delete wrong warehouses (default: true) |
|
||||||
|
| `consolidate-kandang-to-farm-stocks` | `--apply` | Apply changes |
|
||||||
|
| `consolidate-kandang-to-farm-stocks` | `--delete-kandang-warehouses` | Delete kandang warehouses (default: true) |
|
||||||
|
| `verify-stock-consolidation` | `--verify-case` | Verify specific case (A, B, or all) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
### Before Running Any Command
|
||||||
|
|
||||||
|
1. **Back up the database** — These operations modify stock data
|
||||||
|
2. **Run in dry-run mode first** — Always preview changes before applying
|
||||||
|
3. **Check during low-traffic periods** — Avoid peak hours
|
||||||
|
4. **Have a rollback plan** — Know how to restore from backup if needed
|
||||||
|
|
||||||
|
### When Running Migrations
|
||||||
|
|
||||||
|
1. **Start small** — Use `--area-name` to test on one area first
|
||||||
|
2. **Check the summary** — Verify numbers make sense
|
||||||
|
3. **Watch for errors** — Stop if you see unexpected error messages
|
||||||
|
4. **Run verification immediately after** — Don't wait to verify
|
||||||
|
|
||||||
|
### Red Flags (Stop and Investigate)
|
||||||
|
|
||||||
|
- ❌ More rows affected than expected
|
||||||
|
- ❌ Negative quantities or zero counts where expecting data
|
||||||
|
- ❌ Errors about blocked references
|
||||||
|
- ❌ FIFO conflicts or in-flight artifacts
|
||||||
|
- ❌ Very large numbers in NEEDS_REFLOW
|
||||||
|
|
||||||
|
### JSON Output for Automation
|
||||||
|
|
||||||
|
All commands support `--output=json` for:
|
||||||
|
- Piping to other tools
|
||||||
|
- Parsing in scripts
|
||||||
|
- Generating reports
|
||||||
|
- Integration with monitoring systems
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Example: Extract all affected warehouses to CSV
|
||||||
|
./find-wrong-warehouse-records --report=warehouses --output=json \
|
||||||
|
| jq -r '.rows[] | [.area_name, .kandang_name, .wrong_warehouse_name] | @csv' \
|
||||||
|
> affected_warehouses.csv
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Issue: "No wrong warehouse relations found"
|
||||||
|
- **Cause**: No matching Case B issues in the filter scope
|
||||||
|
- **Solution**: Remove filters or use different criteria
|
||||||
|
|
||||||
|
### Issue: "found X rows still point to wrong warehouses"
|
||||||
|
- **Cause**: References not fully migrated
|
||||||
|
- **Solution**: Check for blocked references, re-run command
|
||||||
|
|
||||||
|
### Issue: "discrepancy_destinations > 0" in verification
|
||||||
|
- **Cause**: Quantity mismatch in farm warehouse
|
||||||
|
- **Solution**: Investigate manually or rollback and retry
|
||||||
|
|
||||||
|
### Issue: "DIRTY source warehouses" in verification
|
||||||
|
- **Cause**: Deleted warehouses still have stock/references
|
||||||
|
- **Solution**: May need manual cleanup or re-run migrations
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Performance Notes
|
||||||
|
|
||||||
|
- Commands use efficient SQL queries with proper filtering
|
||||||
|
- Large operations (100K+ rows) may take a few minutes
|
||||||
|
- Use area/location filters to reduce scope for testing
|
||||||
|
- Dry-runs don't modify database and complete quickly
|
||||||
|
|
||||||
|
## Support
|
||||||
|
|
||||||
|
For issues or questions:
|
||||||
|
1. Review the relevant section of this guide
|
||||||
|
2. Check the command output for specific error messages
|
||||||
|
3. Run verification to diagnose state issues
|
||||||
|
4. Contact the development team with JSON outputs from failed operations
|
||||||
@@ -115,6 +115,7 @@ type HppV2CostRepository interface {
|
|||||||
GetFeedUsageCost(ctx context.Context, projectFlockKandangIDs []uint, date *time.Time) (float64, error)
|
GetFeedUsageCost(ctx context.Context, projectFlockKandangIDs []uint, date *time.Time) (float64, error)
|
||||||
GetTotalPopulation(ctx context.Context, projectFlockKandangIDs []uint) (float64, error)
|
GetTotalPopulation(ctx context.Context, projectFlockKandangIDs []uint) (float64, error)
|
||||||
GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIds(ctx context.Context, projectFlockKandangIDs []uint, date *time.Time) (float64, float64, error)
|
GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIds(ctx context.Context, projectFlockKandangIDs []uint, date *time.Time) (float64, float64, error)
|
||||||
|
GetEggProduksiBreakdownByProjectFlockKandangIds(ctx context.Context, projectFlockKandangIDs []uint, date *time.Time) (recordingQty, recordingWeight, adjustmentQty, adjustmentWeight float64, err error)
|
||||||
GetEggTerjualPiecesAndWeightKgByProjectFlockKandangIds(ctx context.Context, projectFlockKandangIDs []uint, startDate *time.Time, endDate *time.Time) (float64, float64, error)
|
GetEggTerjualPiecesAndWeightKgByProjectFlockKandangIds(ctx context.Context, projectFlockKandangIDs []uint, startDate *time.Time, endDate *time.Time) (float64, float64, error)
|
||||||
GetTransferSourceSummary(ctx context.Context, projectFlockKandangId uint) (uint, float64, error)
|
GetTransferSourceSummary(ctx context.Context, projectFlockKandangId uint) (uint, float64, error)
|
||||||
}
|
}
|
||||||
@@ -858,58 +859,50 @@ func (r *HppV2RepositoryImpl) GetTotalPopulation(ctx context.Context, projectFlo
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (r *HppV2RepositoryImpl) GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIds(ctx context.Context, projectFlockKandangIDs []uint, date *time.Time) (float64, float64, error) {
|
func (r *HppV2RepositoryImpl) GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIds(ctx context.Context, projectFlockKandangIDs []uint, date *time.Time) (float64, float64, error) {
|
||||||
|
rQty, rWeight, aQty, aWeight, err := r.GetEggProduksiBreakdownByProjectFlockKandangIds(ctx, projectFlockKandangIDs, date)
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, err
|
||||||
|
}
|
||||||
|
return rQty + aQty, rWeight + aWeight, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *HppV2RepositoryImpl) GetEggProduksiBreakdownByProjectFlockKandangIds(ctx context.Context, projectFlockKandangIDs []uint, date *time.Time) (recordingQty, recordingWeight, adjustmentQty, adjustmentWeight float64, err error) {
|
||||||
if date == nil {
|
if date == nil {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
date = &now
|
date = &now
|
||||||
}
|
}
|
||||||
|
|
||||||
var totals struct {
|
var recordingTotals struct {
|
||||||
TotalPieces float64
|
TotalPieces float64
|
||||||
TotalWeightKg float64
|
TotalWeightKg float64
|
||||||
}
|
}
|
||||||
err := r.db.WithContext(ctx).
|
err = r.db.WithContext(ctx).
|
||||||
Table("recordings AS r").
|
Table("recordings AS r").
|
||||||
Select("COALESCE(SUM(re.qty), 0) AS total_pieces, COALESCE(SUM(re.weight), 0)AS total_weight_kg").
|
Select("COALESCE(SUM(re.qty), 0) AS total_pieces, COALESCE(SUM(re.weight), 0) AS total_weight_kg").
|
||||||
Joins("JOIN recording_eggs AS re ON re.recording_id = r.id").
|
Joins("JOIN recording_eggs AS re ON re.recording_id = r.id").
|
||||||
Where("r.project_flock_kandangs_id IN (?)", projectFlockKandangIDs).
|
Where("r.project_flock_kandangs_id IN (?)", projectFlockKandangIDs).
|
||||||
Where("r.record_datetime <= ?", *date).
|
Where("r.record_datetime <= ?", *date).
|
||||||
Scan(&totals).Error
|
Scan(&recordingTotals).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, 0, err
|
return 0, 0, 0, 0, err
|
||||||
}
|
}
|
||||||
|
|
||||||
var adjustmentTotals struct {
|
var adjustmentTotals struct {
|
||||||
TotalQty float64
|
TotalQty float64
|
||||||
TotalWeight float64
|
TotalWeight float64
|
||||||
}
|
}
|
||||||
adjustmentSubQuery := r.db.WithContext(ctx).
|
|
||||||
Table("recordings AS r").
|
|
||||||
Select("DISTINCT ast.id AS adjustment_id, ast.total_qty AS total_qty, 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).
|
err = r.db.WithContext(ctx).
|
||||||
Table("(?) AS adjustment_sources", adjustmentSubQuery).
|
Table("adjustment_stocks AS ast").
|
||||||
Select("COALESCE(SUM(adjustment_sources.total_qty), 0) AS total_qty, COALESCE(SUM(adjustment_sources.price), 0) AS total_weight").
|
Select("COALESCE(SUM(ast.total_qty), 0) AS total_qty, COALESCE(SUM(ast.price), 0) AS total_weight").
|
||||||
|
Joins("JOIN product_warehouses AS pw ON pw.id = ast.product_warehouse_id").
|
||||||
|
Where("pw.project_flock_kandang_id IN (?)", projectFlockKandangIDs).
|
||||||
|
Where("ast.function_code = ?", string(utils.AdjustmentTransactionSubtypeRecordingEggIn)).
|
||||||
Scan(&adjustmentTotals).Error
|
Scan(&adjustmentTotals).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, 0, err
|
return 0, 0, 0, 0, err
|
||||||
}
|
}
|
||||||
|
|
||||||
totals.TotalPieces += adjustmentTotals.TotalQty
|
return recordingTotals.TotalPieces, recordingTotals.TotalWeightKg, adjustmentTotals.TotalQty, adjustmentTotals.TotalWeight, nil
|
||||||
totals.TotalWeightKg += adjustmentTotals.TotalWeight
|
|
||||||
|
|
||||||
return totals.TotalPieces, totals.TotalWeightKg, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *HppV2RepositoryImpl) GetEggTerjualPiecesAndWeightKgByProjectFlockKandangIds(
|
func (r *HppV2RepositoryImpl) GetEggTerjualPiecesAndWeightKgByProjectFlockKandangIds(
|
||||||
@@ -1160,7 +1153,6 @@ CROSS JOIN lokasi_rec_totals lrt
|
|||||||
string(utils.AdjustmentTransactionTypeRecording),
|
string(utils.AdjustmentTransactionTypeRecording),
|
||||||
).
|
).
|
||||||
Scan(&totals).Error
|
Scan(&totals).Error
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, 0, err
|
return 0, 0, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,8 +18,9 @@ type HppService interface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type HppCostResponse struct {
|
type HppCostResponse struct {
|
||||||
Estimation HppCostDetail `json:"estimation"`
|
Estimation HppCostDetail `json:"estimation"`
|
||||||
Real HppCostDetail `json:"real"`
|
Real HppCostDetail `json:"real"`
|
||||||
|
DebugValues *HppCostDebugValues `json:"debug_values,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type HppCostDetail struct {
|
type HppCostDetail struct {
|
||||||
|
|||||||
@@ -44,6 +44,15 @@ type HppV2Component struct {
|
|||||||
Parts []HppV2ComponentPart `json:"parts"`
|
Parts []HppV2ComponentPart `json:"parts"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type HppCostDebugValues struct {
|
||||||
|
RecordingEggQty float64 `json:"recording_egg_qty"`
|
||||||
|
RecordingEggWeight float64 `json:"recording_egg_weight"`
|
||||||
|
AdjustmentEggQty float64 `json:"adjustment_egg_qty"`
|
||||||
|
AdjustmentEggWeight float64 `json:"adjustment_egg_weight"`
|
||||||
|
SoldEggQty float64 `json:"sold_egg_qty"`
|
||||||
|
SoldEggWeight float64 `json:"sold_egg_weight"`
|
||||||
|
}
|
||||||
|
|
||||||
type HppV2Breakdown struct {
|
type HppV2Breakdown struct {
|
||||||
ProjectFlockKandangID uint `json:"project_flock_kandang_id"`
|
ProjectFlockKandangID uint `json:"project_flock_kandang_id"`
|
||||||
ProjectFlockID uint `json:"project_flock_id"`
|
ProjectFlockID uint `json:"project_flock_id"`
|
||||||
|
|||||||
@@ -1489,7 +1489,7 @@ func (s *hppV2Service) GetHppEstimationDanRealisasi(totalProductionCost float64,
|
|||||||
return &HppCostResponse{}, nil
|
return &HppCostResponse{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
estimPieces, estimWeightKg, err := s.hppRepo.GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIds(context.Background(), []uint{projectFlockKandangId}, endDate)
|
recordingQty, recordingWeight, adjustmentQty, adjustmentWeight, err := s.hppRepo.GetEggProduksiBreakdownByProjectFlockKandangIds(context.Background(), []uint{projectFlockKandangId}, endDate)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
utils.Log.WithError(err).Errorf(
|
utils.Log.WithError(err).Errorf(
|
||||||
"GetHppEstimationDanRealisasi failed to get estimation egg production: project_flock_kandang_id=%d end_date=%s",
|
"GetHppEstimationDanRealisasi failed to get estimation egg production: project_flock_kandang_id=%d end_date=%s",
|
||||||
@@ -1498,6 +1498,8 @@ func (s *hppV2Service) GetHppEstimationDanRealisasi(totalProductionCost float64,
|
|||||||
)
|
)
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
estimPieces := recordingQty + adjustmentQty
|
||||||
|
estimWeightKg := recordingWeight + adjustmentWeight
|
||||||
|
|
||||||
realPieces, realWeightKg, err := s.hppRepo.GetEggTerjualPiecesAndWeightKgByProjectFlockKandangIds(context.Background(), []uint{projectFlockKandangId}, startDate, endDate)
|
realPieces, realWeightKg, err := s.hppRepo.GetEggTerjualPiecesAndWeightKgByProjectFlockKandangIds(context.Background(), []uint{projectFlockKandangId}, startDate, endDate)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1551,6 +1553,14 @@ func (s *hppV2Service) GetHppEstimationDanRealisasi(totalProductionCost float64,
|
|||||||
return &HppCostResponse{
|
return &HppCostResponse{
|
||||||
Estimation: estimation,
|
Estimation: estimation,
|
||||||
Real: real,
|
Real: real,
|
||||||
|
DebugValues: &HppCostDebugValues{
|
||||||
|
RecordingEggQty: recordingQty,
|
||||||
|
RecordingEggWeight: recordingWeight,
|
||||||
|
AdjustmentEggQty: adjustmentQty,
|
||||||
|
AdjustmentEggWeight: adjustmentWeight,
|
||||||
|
SoldEggQty: realPieces,
|
||||||
|
SoldEggWeight: realWeightKg,
|
||||||
|
},
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -132,6 +132,17 @@ func (s *hppV2RepoStub) GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIds(
|
|||||||
return totalPieces, totalKg, nil
|
return totalPieces, totalKg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *hppV2RepoStub) GetEggProduksiBreakdownByProjectFlockKandangIds(_ context.Context, projectFlockKandangIDs []uint, _ *time.Time) (float64, float64, 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, 0, 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *hppV2RepoStub) GetEggTerjualPiecesAndWeightKgByProjectFlockKandangIds(_ context.Context, projectFlockKandangIDs []uint, _ *time.Time, _ *time.Time) (float64, float64, error) {
|
func (s *hppV2RepoStub) GetEggTerjualPiecesAndWeightKgByProjectFlockKandangIds(_ context.Context, projectFlockKandangIDs []uint, _ *time.Time, _ *time.Time) (float64, float64, error) {
|
||||||
if len(projectFlockKandangIDs) != 1 {
|
if len(projectFlockKandangIDs) != 1 {
|
||||||
return 0, 0, nil
|
return 0, 0, nil
|
||||||
|
|||||||
@@ -127,6 +127,8 @@ const (
|
|||||||
dailyChecklistCategoryEmptyKandang = "empty_kandang"
|
dailyChecklistCategoryEmptyKandang = "empty_kandang"
|
||||||
dailyChecklistStatusRejected = "REJECTED"
|
dailyChecklistStatusRejected = "REJECTED"
|
||||||
dailyChecklistStatusDraft = "DRAFT"
|
dailyChecklistStatusDraft = "DRAFT"
|
||||||
|
dailyChecklistErrEmptyKandangExist = "DailyChecklist cannot be created because empty_kandang already exists for at least one date in range"
|
||||||
|
dailyChecklistErrDateOverlapExist = "DailyChecklist cannot be created because at least one date in range already has a checklist"
|
||||||
)
|
)
|
||||||
|
|
||||||
func NewDailyChecklistService(repo repository.DailyChecklistRepository, phaseRepo phaseRepo.PhasesRepository, validate *validator.Validate, documentSvc commonSvc.DocumentService) DailyChecklistService {
|
func NewDailyChecklistService(repo repository.DailyChecklistRepository, phaseRepo phaseRepo.PhasesRepository, validate *validator.Validate, documentSvc commonSvc.DocumentService) DailyChecklistService {
|
||||||
@@ -538,10 +540,21 @@ func (s *dailyChecklistService) CreateOne(c *fiber.Ctx, req *validation.Create)
|
|||||||
targetID := uint(0)
|
targetID := uint(0)
|
||||||
|
|
||||||
err = s.Repository.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error {
|
err = s.Repository.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error {
|
||||||
|
if err := s.lockKandangForChecklistCreation(tx, req.KandangId); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
if req.EmptyKandang {
|
if req.EmptyKandang {
|
||||||
|
if err := s.validateNoChecklistOverlapForEmptyKandang(tx, req.KandangId, date, endDate); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
return s.createBulkDailyChecklists(tx, req.KandangId, date, endDate, category, status, &targetID)
|
return s.createBulkDailyChecklists(tx, req.KandangId, date, endDate, category, status, &targetID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := s.validateNoEmptyKandangConflict(tx, req.KandangId, date, endDate); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
return s.createOrReuseSingleDailyChecklist(tx, req.KandangId, date, category, status, &targetID)
|
return s.createOrReuseSingleDailyChecklist(tx, req.KandangId, date, category, status, &targetID)
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -552,6 +565,56 @@ func (s *dailyChecklistService) CreateOne(c *fiber.Ctx, req *validation.Create)
|
|||||||
return s.GetOne(c, targetID)
|
return s.GetOne(c, targetID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *dailyChecklistService) lockKandangForChecklistCreation(tx *gorm.DB, kandangID uint) error {
|
||||||
|
if kandangID == 0 {
|
||||||
|
return fiber.NewError(fiber.StatusBadRequest, "Invalid kandang id")
|
||||||
|
}
|
||||||
|
|
||||||
|
var lockedKandangID uint
|
||||||
|
query := tx.Table("kandang_groups").Select("id").Where("id = ?", kandangID)
|
||||||
|
if tx.Dialector.Name() != "sqlite" {
|
||||||
|
query = query.Clauses(clause.Locking{Strength: "UPDATE"})
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := query.Take(&lockedKandangID).Error; err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return fiber.NewError(fiber.StatusNotFound, "Kandang not found")
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *dailyChecklistService) validateNoChecklistOverlapForEmptyKandang(tx *gorm.DB, kandangID uint, startDate, endDate time.Time) error {
|
||||||
|
var conflictCount int64
|
||||||
|
if err := tx.Model(&entity.DailyChecklist{}).
|
||||||
|
Where("kandang_id = ? AND date BETWEEN ? AND ? AND deleted_at IS NULL", kandangID, startDate, endDate).
|
||||||
|
Count(&conflictCount).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if conflictCount > 0 {
|
||||||
|
return fiber.NewError(fiber.StatusConflict, dailyChecklistErrDateOverlapExist)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *dailyChecklistService) validateNoEmptyKandangConflict(tx *gorm.DB, kandangID uint, startDate, endDate time.Time) error {
|
||||||
|
var conflictCount int64
|
||||||
|
if err := tx.Model(&entity.DailyChecklist{}).
|
||||||
|
Where("kandang_id = ? AND date BETWEEN ? AND ? AND category = ? AND deleted_at IS NULL", kandangID, startDate, endDate, dailyChecklistCategoryEmptyKandang).
|
||||||
|
Count(&conflictCount).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if conflictCount > 0 {
|
||||||
|
return fiber.NewError(fiber.StatusConflict, dailyChecklistErrEmptyKandangExist)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *dailyChecklistService) createOrReuseSingleDailyChecklist(tx *gorm.DB, kandangID uint, date time.Time, category, status string, targetID *uint) error {
|
func (s *dailyChecklistService) createOrReuseSingleDailyChecklist(tx *gorm.DB, kandangID uint, date time.Time, category, status string, targetID *uint) error {
|
||||||
existing := new(entity.DailyChecklist)
|
existing := new(entity.DailyChecklist)
|
||||||
err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||||
|
|||||||
@@ -0,0 +1,409 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/glebarez/sqlite"
|
||||||
|
"github.com/go-playground/validator/v10"
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
|
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
|
||||||
|
repository "gitlab.com/mbugroup/lti-api.git/internal/modules/daily-checklists/repositories"
|
||||||
|
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/daily-checklists/validations"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCreateOneRejectsWhenSameDateHasActiveEmptyKandang(t *testing.T) {
|
||||||
|
svc, db := setupDailyChecklistServiceTest(t)
|
||||||
|
|
||||||
|
insertDailyChecklistRow(t, db, 1, mustDate(t, "2026-01-10"), dailyChecklistCategoryEmptyKandang, strPtr("DRAFT"), nil)
|
||||||
|
|
||||||
|
result, serviceErr, resp := runCreateOneRequest(t, svc, &validation.Create{
|
||||||
|
Date: "2026-01-10",
|
||||||
|
KandangId: 1,
|
||||||
|
Category: "cleaning",
|
||||||
|
Status: "DRAFT",
|
||||||
|
})
|
||||||
|
|
||||||
|
if result != nil {
|
||||||
|
t.Fatalf("expected nil result, got %+v", result)
|
||||||
|
}
|
||||||
|
assertFiberErrorCode(t, serviceErr, fiber.StatusConflict)
|
||||||
|
if resp.StatusCode != fiber.StatusConflict {
|
||||||
|
t.Fatalf("expected HTTP status %d, got %d", fiber.StatusConflict, resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateOneRejectsWhenSameDateHasRejectedEmptyKandang(t *testing.T) {
|
||||||
|
svc, db := setupDailyChecklistServiceTest(t)
|
||||||
|
|
||||||
|
insertDailyChecklistRow(t, db, 1, mustDate(t, "2026-01-10"), dailyChecklistCategoryEmptyKandang, strPtr(dailyChecklistStatusRejected), nil)
|
||||||
|
|
||||||
|
result, serviceErr, resp := runCreateOneRequest(t, svc, &validation.Create{
|
||||||
|
Date: "2026-01-10",
|
||||||
|
KandangId: 1,
|
||||||
|
Category: "cleaning",
|
||||||
|
Status: "DRAFT",
|
||||||
|
})
|
||||||
|
|
||||||
|
if result != nil {
|
||||||
|
t.Fatalf("expected nil result, got %+v", result)
|
||||||
|
}
|
||||||
|
assertFiberErrorCode(t, serviceErr, fiber.StatusConflict)
|
||||||
|
if resp.StatusCode != fiber.StatusConflict {
|
||||||
|
t.Fatalf("expected HTTP status %d, got %d", fiber.StatusConflict, resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateOneAllowsWhenOnlySoftDeletedEmptyKandangExists(t *testing.T) {
|
||||||
|
svc, db := setupDailyChecklistServiceTest(t)
|
||||||
|
|
||||||
|
deletedAt := mustDateTime(t, "2026-01-11 10:00:00")
|
||||||
|
insertDailyChecklistRow(t, db, 1, mustDate(t, "2026-01-10"), dailyChecklistCategoryEmptyKandang, strPtr("DRAFT"), &deletedAt)
|
||||||
|
|
||||||
|
result, serviceErr, resp := runCreateOneRequest(t, svc, &validation.Create{
|
||||||
|
Date: "2026-01-10",
|
||||||
|
KandangId: 1,
|
||||||
|
Category: "cleaning",
|
||||||
|
Status: "DRAFT",
|
||||||
|
})
|
||||||
|
|
||||||
|
if serviceErr != nil {
|
||||||
|
t.Fatalf("expected no error, got %v", serviceErr)
|
||||||
|
}
|
||||||
|
if resp.StatusCode != fiber.StatusCreated {
|
||||||
|
t.Fatalf("expected HTTP status %d, got %d", fiber.StatusCreated, resp.StatusCode)
|
||||||
|
}
|
||||||
|
if result == nil {
|
||||||
|
t.Fatal("expected non-nil result")
|
||||||
|
}
|
||||||
|
if result.Category != "cleaning" {
|
||||||
|
t.Fatalf("expected category cleaning, got %s", result.Category)
|
||||||
|
}
|
||||||
|
|
||||||
|
var activeCount int64
|
||||||
|
if err := db.Model(&entity.DailyChecklist{}).
|
||||||
|
Where("kandang_id = ? AND date = ? AND category = ? AND deleted_at IS NULL", 1, mustDate(t, "2026-01-10"), "cleaning").
|
||||||
|
Count(&activeCount).Error; err != nil {
|
||||||
|
t.Fatalf("failed counting active checklists: %v", err)
|
||||||
|
}
|
||||||
|
if activeCount != 1 {
|
||||||
|
t.Fatalf("expected 1 active cleaning checklist, got %d", activeCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateOneRejectsBulkEmptyKandangWhenDateRangeHasConflict(t *testing.T) {
|
||||||
|
svc, db := setupDailyChecklistServiceTest(t)
|
||||||
|
|
||||||
|
insertDailyChecklistRow(t, db, 1, mustDate(t, "2026-01-03"), dailyChecklistCategoryEmptyKandang, strPtr("APPROVED"), nil)
|
||||||
|
|
||||||
|
result, serviceErr, resp := runCreateOneRequest(t, svc, &validation.Create{
|
||||||
|
Date: "2026-01-01",
|
||||||
|
KandangId: 1,
|
||||||
|
Category: "cleaning",
|
||||||
|
Status: "DRAFT",
|
||||||
|
EmptyKandang: true,
|
||||||
|
EmptyKandangEndDate: "2026-01-05",
|
||||||
|
})
|
||||||
|
|
||||||
|
if result != nil {
|
||||||
|
t.Fatalf("expected nil result, got %+v", result)
|
||||||
|
}
|
||||||
|
assertFiberErrorCode(t, serviceErr, fiber.StatusConflict)
|
||||||
|
if resp.StatusCode != fiber.StatusConflict {
|
||||||
|
t.Fatalf("expected HTTP status %d, got %d", fiber.StatusConflict, resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
var activeInRange int64
|
||||||
|
if err := db.Model(&entity.DailyChecklist{}).
|
||||||
|
Where("kandang_id = ? AND date BETWEEN ? AND ? AND deleted_at IS NULL", 1, mustDate(t, "2026-01-01"), mustDate(t, "2026-01-05")).
|
||||||
|
Count(&activeInRange).Error; err != nil {
|
||||||
|
t.Fatalf("failed counting checklists in range: %v", err)
|
||||||
|
}
|
||||||
|
if activeInRange != 1 {
|
||||||
|
t.Fatalf("expected only pre-existing row to remain in range, got %d rows", activeInRange)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateOneRejectsBulkEmptyKandangWhenRangeHasNonEmptyChecklist(t *testing.T) {
|
||||||
|
svc, db := setupDailyChecklistServiceTest(t)
|
||||||
|
|
||||||
|
insertDailyChecklistRow(t, db, 1, mustDate(t, "2026-01-03"), "cleaning", strPtr("APPROVED"), nil)
|
||||||
|
|
||||||
|
result, serviceErr, resp := runCreateOneRequest(t, svc, &validation.Create{
|
||||||
|
Date: "2026-01-01",
|
||||||
|
KandangId: 1,
|
||||||
|
Category: "cleaning",
|
||||||
|
Status: "DRAFT",
|
||||||
|
EmptyKandang: true,
|
||||||
|
EmptyKandangEndDate: "2026-01-05",
|
||||||
|
})
|
||||||
|
|
||||||
|
if result != nil {
|
||||||
|
t.Fatalf("expected nil result, got %+v", result)
|
||||||
|
}
|
||||||
|
assertFiberErrorCode(t, serviceErr, fiber.StatusConflict)
|
||||||
|
if resp.StatusCode != fiber.StatusConflict {
|
||||||
|
t.Fatalf("expected HTTP status %d, got %d", fiber.StatusConflict, resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateOneRejectsBulkEmptyKandangWhenRangeHasRejectedChecklist(t *testing.T) {
|
||||||
|
svc, db := setupDailyChecklistServiceTest(t)
|
||||||
|
|
||||||
|
insertDailyChecklistRow(t, db, 1, mustDate(t, "2026-01-03"), "cleaning", strPtr(dailyChecklistStatusRejected), nil)
|
||||||
|
|
||||||
|
result, serviceErr, resp := runCreateOneRequest(t, svc, &validation.Create{
|
||||||
|
Date: "2026-01-01",
|
||||||
|
KandangId: 1,
|
||||||
|
Category: "cleaning",
|
||||||
|
Status: "DRAFT",
|
||||||
|
EmptyKandang: true,
|
||||||
|
EmptyKandangEndDate: "2026-01-05",
|
||||||
|
})
|
||||||
|
|
||||||
|
if result != nil {
|
||||||
|
t.Fatalf("expected nil result, got %+v", result)
|
||||||
|
}
|
||||||
|
assertFiberErrorCode(t, serviceErr, fiber.StatusConflict)
|
||||||
|
if resp.StatusCode != fiber.StatusConflict {
|
||||||
|
t.Fatalf("expected HTTP status %d, got %d", fiber.StatusConflict, resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateOneAllowsBulkEmptyKandangWhenRangeHasOnlySoftDeletedChecklist(t *testing.T) {
|
||||||
|
svc, db := setupDailyChecklistServiceTest(t)
|
||||||
|
|
||||||
|
deletedAt := mustDateTime(t, "2026-01-11 10:00:00")
|
||||||
|
insertDailyChecklistRow(t, db, 1, mustDate(t, "2026-01-03"), "cleaning", strPtr("APPROVED"), &deletedAt)
|
||||||
|
|
||||||
|
result, serviceErr, resp := runCreateOneRequest(t, svc, &validation.Create{
|
||||||
|
Date: "2026-01-01",
|
||||||
|
KandangId: 1,
|
||||||
|
Category: "cleaning",
|
||||||
|
Status: "DRAFT",
|
||||||
|
EmptyKandang: true,
|
||||||
|
EmptyKandangEndDate: "2026-01-05",
|
||||||
|
})
|
||||||
|
|
||||||
|
if serviceErr != nil {
|
||||||
|
t.Fatalf("expected no error, got %v", serviceErr)
|
||||||
|
}
|
||||||
|
if resp.StatusCode != fiber.StatusCreated {
|
||||||
|
t.Fatalf("expected HTTP status %d, got %d", fiber.StatusCreated, resp.StatusCode)
|
||||||
|
}
|
||||||
|
if result == nil {
|
||||||
|
t.Fatal("expected non-nil result")
|
||||||
|
}
|
||||||
|
if result.Category != dailyChecklistCategoryEmptyKandang {
|
||||||
|
t.Fatalf("expected category %s, got %s", dailyChecklistCategoryEmptyKandang, result.Category)
|
||||||
|
}
|
||||||
|
|
||||||
|
var activeInRange int64
|
||||||
|
if err := db.Model(&entity.DailyChecklist{}).
|
||||||
|
Where("kandang_id = ? AND date BETWEEN ? AND ? AND deleted_at IS NULL", 1, mustDate(t, "2026-01-01"), mustDate(t, "2026-01-05")).
|
||||||
|
Count(&activeInRange).Error; err != nil {
|
||||||
|
t.Fatalf("failed counting checklists in range: %v", err)
|
||||||
|
}
|
||||||
|
if activeInRange != 5 {
|
||||||
|
t.Fatalf("expected 5 active checklists created for range, got %d", activeInRange)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateOneReusesExistingChecklistWhenNoEmptyKandangConflict(t *testing.T) {
|
||||||
|
svc, db := setupDailyChecklistServiceTest(t)
|
||||||
|
|
||||||
|
existingID := insertDailyChecklistRow(t, db, 1, mustDate(t, "2026-01-10"), "cleaning", strPtr("APPROVED"), nil)
|
||||||
|
|
||||||
|
result, serviceErr, resp := runCreateOneRequest(t, svc, &validation.Create{
|
||||||
|
Date: "2026-01-10",
|
||||||
|
KandangId: 1,
|
||||||
|
Category: "cleaning",
|
||||||
|
Status: "DRAFT",
|
||||||
|
})
|
||||||
|
|
||||||
|
if serviceErr != nil {
|
||||||
|
t.Fatalf("expected no error, got %v", serviceErr)
|
||||||
|
}
|
||||||
|
if resp.StatusCode != fiber.StatusCreated {
|
||||||
|
t.Fatalf("expected HTTP status %d, got %d", fiber.StatusCreated, resp.StatusCode)
|
||||||
|
}
|
||||||
|
if result == nil {
|
||||||
|
t.Fatal("expected non-nil result")
|
||||||
|
}
|
||||||
|
if result.Id != existingID {
|
||||||
|
t.Fatalf("expected existing checklist id %d to be reused, got %d", existingID, result.Id)
|
||||||
|
}
|
||||||
|
|
||||||
|
var activeCount int64
|
||||||
|
if err := db.Model(&entity.DailyChecklist{}).
|
||||||
|
Where("kandang_id = ? AND date = ? AND category = ? AND deleted_at IS NULL", 1, mustDate(t, "2026-01-10"), "cleaning").
|
||||||
|
Count(&activeCount).Error; err != nil {
|
||||||
|
t.Fatalf("failed counting active checklists: %v", err)
|
||||||
|
}
|
||||||
|
if activeCount != 1 {
|
||||||
|
t.Fatalf("expected 1 active cleaning checklist, got %d", activeCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupDailyChecklistServiceTest(t *testing.T) (DailyChecklistService, *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)
|
||||||
|
}
|
||||||
|
|
||||||
|
statements := []string{
|
||||||
|
`CREATE TABLE areas (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
created_by INTEGER NOT NULL,
|
||||||
|
created_at DATETIME NULL,
|
||||||
|
updated_at DATETIME NULL,
|
||||||
|
deleted_at DATETIME NULL
|
||||||
|
)`,
|
||||||
|
`CREATE TABLE locations (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
address TEXT NOT NULL,
|
||||||
|
area_id INTEGER NOT NULL,
|
||||||
|
created_by INTEGER NOT NULL,
|
||||||
|
created_at DATETIME NULL,
|
||||||
|
updated_at DATETIME NULL,
|
||||||
|
deleted_at DATETIME NULL
|
||||||
|
)`,
|
||||||
|
`CREATE TABLE kandang_groups (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
location_id INTEGER NOT NULL,
|
||||||
|
pic_id INTEGER NOT NULL,
|
||||||
|
created_by INTEGER NOT NULL,
|
||||||
|
created_at DATETIME NULL,
|
||||||
|
updated_at DATETIME NULL,
|
||||||
|
deleted_at DATETIME NULL
|
||||||
|
)`,
|
||||||
|
`CREATE TABLE daily_checklists (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
kandang_id INTEGER NOT NULL,
|
||||||
|
checklist_id INTEGER NULL,
|
||||||
|
date DATE NOT NULL,
|
||||||
|
name TEXT NULL,
|
||||||
|
status TEXT NULL,
|
||||||
|
category TEXT NOT NULL,
|
||||||
|
total_score INTEGER NULL,
|
||||||
|
document_path TEXT NULL,
|
||||||
|
reject_reason TEXT NULL,
|
||||||
|
created_by INTEGER NULL,
|
||||||
|
deleted_by INTEGER NULL,
|
||||||
|
created_at DATETIME NULL,
|
||||||
|
updated_at DATETIME NULL,
|
||||||
|
deleted_at DATETIME NULL
|
||||||
|
)`,
|
||||||
|
`INSERT INTO areas (id, name, created_by, created_at, updated_at, deleted_at) VALUES (1, 'Area A', 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL)`,
|
||||||
|
`INSERT INTO locations (id, name, address, area_id, created_by, created_at, updated_at, deleted_at) VALUES (1, 'Farm A', 'Address', 1, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL)`,
|
||||||
|
`INSERT INTO kandang_groups (id, name, status, location_id, pic_id, created_by, created_at, updated_at, deleted_at) VALUES (1, 'Kandang A', 'ACTIVE', 1, 1, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL)`,
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, stmt := range statements {
|
||||||
|
if err := db.Exec(stmt).Error; err != nil {
|
||||||
|
t.Fatalf("failed preparing schema: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
repo := repository.NewDailyChecklistRepository(db)
|
||||||
|
svc := NewDailyChecklistService(repo, nil, validator.New(), nil)
|
||||||
|
return svc, db
|
||||||
|
}
|
||||||
|
|
||||||
|
func runCreateOneRequest(t *testing.T, svc DailyChecklistService, req *validation.Create) (*entity.DailyChecklist, error, *http.Response) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
app := fiber.New()
|
||||||
|
var (
|
||||||
|
result *entity.DailyChecklist
|
||||||
|
serviceErr error
|
||||||
|
)
|
||||||
|
|
||||||
|
app.Post("/", func(c *fiber.Ctx) error {
|
||||||
|
result, serviceErr = svc.CreateOne(c, req)
|
||||||
|
if serviceErr != nil {
|
||||||
|
return serviceErr
|
||||||
|
}
|
||||||
|
return c.SendStatus(fiber.StatusCreated)
|
||||||
|
})
|
||||||
|
|
||||||
|
resp, err := app.Test(httptest.NewRequest(http.MethodPost, "/", nil))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed running fiber request: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, serviceErr, resp
|
||||||
|
}
|
||||||
|
|
||||||
|
func insertDailyChecklistRow(t *testing.T, db *gorm.DB, kandangID uint, date time.Time, category string, status *string, deletedAt *time.Time) uint {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
row := &entity.DailyChecklist{
|
||||||
|
KandangId: kandangID,
|
||||||
|
Date: date,
|
||||||
|
Category: category,
|
||||||
|
Status: status,
|
||||||
|
}
|
||||||
|
if deletedAt != nil {
|
||||||
|
row.DeletedAt = gorm.DeletedAt{
|
||||||
|
Time: *deletedAt,
|
||||||
|
Valid: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := db.Create(row).Error; err != nil {
|
||||||
|
t.Fatalf("failed inserting daily checklist row: %v", err)
|
||||||
|
}
|
||||||
|
return row.Id
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertFiberErrorCode(t *testing.T, err error, expectedCode int) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error, got nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
var fiberErr *fiber.Error
|
||||||
|
if !errors.As(err, &fiberErr) {
|
||||||
|
t.Fatalf("expected *fiber.Error, got %T (%v)", err, err)
|
||||||
|
}
|
||||||
|
if fiberErr.Code != expectedCode {
|
||||||
|
t.Fatalf("expected fiber error code %d, got %d", expectedCode, fiberErr.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustDate(t *testing.T, raw string) time.Time {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
value, err := time.Parse(dailyChecklistDateLayout, raw)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed parsing date %q: %v", raw, err)
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustDateTime(t *testing.T, raw string) time.Time {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
value, err := time.Parse("2006-01-02 15:04:05", raw)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed parsing datetime %q: %v", raw, err)
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func strPtr(value string) *string {
|
||||||
|
v := value
|
||||||
|
return &v
|
||||||
|
}
|
||||||
@@ -79,10 +79,11 @@ func setPurchaseExportColumns(file *excelize.File, sheet string) error {
|
|||||||
"B": 16,
|
"B": 16,
|
||||||
"C": 14,
|
"C": 14,
|
||||||
"D": 22,
|
"D": 22,
|
||||||
"E": 18,
|
"E": 22,
|
||||||
"F": 18,
|
"F": 18,
|
||||||
"G": 52,
|
"G": 18,
|
||||||
"H": 24,
|
"H": 52,
|
||||||
|
"I": 24,
|
||||||
}
|
}
|
||||||
|
|
||||||
for col, width := range columnWidths {
|
for col, width := range columnWidths {
|
||||||
@@ -103,6 +104,7 @@ func setPurchaseExportHeaders(file *excelize.File, sheet string) error {
|
|||||||
"PO Number",
|
"PO Number",
|
||||||
"Tanggal PO",
|
"Tanggal PO",
|
||||||
"Supplier",
|
"Supplier",
|
||||||
|
"Lokasi",
|
||||||
"Status",
|
"Status",
|
||||||
"Grand Total",
|
"Grand Total",
|
||||||
"Products",
|
"Products",
|
||||||
@@ -134,7 +136,7 @@ func setPurchaseExportHeaders(file *excelize.File, sheet string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return file.SetCellStyle(sheet, "A1", "H1", headerStyle)
|
return file.SetCellStyle(sheet, "A1", "I1", headerStyle)
|
||||||
}
|
}
|
||||||
|
|
||||||
func setPurchaseExportRows(file *excelize.File, sheet string, items []dto.PurchaseListDTO, grandTotals map[uint]float64) error {
|
func setPurchaseExportRows(file *excelize.File, sheet string, items []dto.PurchaseListDTO, grandTotals map[uint]float64) error {
|
||||||
@@ -156,16 +158,19 @@ func setPurchaseExportRows(file *excelize.File, sheet string, items []dto.Purcha
|
|||||||
if err := file.SetCellValue(sheet, "D"+row, safePurchaseSupplierName(item)); err != nil {
|
if err := file.SetCellValue(sheet, "D"+row, safePurchaseSupplierName(item)); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := file.SetCellValue(sheet, "E"+row, formatPurchaseExportStatus(item)); err != nil {
|
if err := file.SetCellValue(sheet, "E"+row, safePurchaseLocationName(item)); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := file.SetCellValue(sheet, "F"+row, formatPurchaseRupiah(grandTotals[item.Id])); err != nil {
|
if err := file.SetCellValue(sheet, "F"+row, formatPurchaseExportStatus(item)); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := file.SetCellValue(sheet, "G"+row, formatPurchaseProducts(item)); err != nil {
|
if err := file.SetCellValue(sheet, "G"+row, formatPurchaseRupiah(grandTotals[item.Id])); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := file.SetCellValue(sheet, "H"+row, safePurchaseExportPointerText(item.Notes)); err != nil {
|
if err := file.SetCellValue(sheet, "H"+row, formatPurchaseProducts(item)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := file.SetCellValue(sheet, "I"+row, safePurchaseExportPointerText(item.Notes)); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -187,7 +192,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", "H"+strconv.Itoa(lastRow), dataStyle); err != nil {
|
if err := file.SetCellStyle(sheet, "A2", "I"+strconv.Itoa(lastRow), dataStyle); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -207,7 +212,7 @@ func setPurchaseExportRows(file *excelize.File, sheet string, items []dto.Purcha
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return file.SetCellStyle(sheet, "F2", "F"+strconv.Itoa(lastRow), moneyStyle)
|
return file.SetCellStyle(sheet, "G2", "G"+strconv.Itoa(lastRow), moneyStyle)
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildPurchaseGrandTotalMap(items []entity.Purchase) map[uint]float64 {
|
func buildPurchaseGrandTotalMap(items []entity.Purchase) map[uint]float64 {
|
||||||
@@ -229,6 +234,13 @@ func safePurchaseSupplierName(item dto.PurchaseListDTO) string {
|
|||||||
return safePurchaseExportText(item.Supplier.Name)
|
return safePurchaseExportText(item.Supplier.Name)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func safePurchaseLocationName(item dto.PurchaseListDTO) string {
|
||||||
|
if item.Location == nil {
|
||||||
|
return "-"
|
||||||
|
}
|
||||||
|
return safePurchaseExportText(item.Location.Name)
|
||||||
|
}
|
||||||
|
|
||||||
func formatPurchaseExportStatus(item dto.PurchaseListDTO) string {
|
func formatPurchaseExportStatus(item dto.PurchaseListDTO) string {
|
||||||
if item.LatestApproval == nil {
|
if item.LatestApproval == nil {
|
||||||
return "-"
|
return "-"
|
||||||
|
|||||||
@@ -22,9 +22,9 @@ func TestBuildPurchaseExportWorkbookHeadersAndRows(t *testing.T) {
|
|||||||
nil,
|
nil,
|
||||||
"catatan",
|
"catatan",
|
||||||
[]entity.PurchaseItem{
|
[]entity.PurchaseItem{
|
||||||
buildPurchaseItemForExportTest(11, "Pakan Starter", 1000000),
|
buildPurchaseItemForExportTest(11, "Pakan Starter", 1000000, "Location A"),
|
||||||
buildPurchaseItemForExportTest(12, "Vitamin A", 350000),
|
buildPurchaseItemForExportTest(12, "Vitamin A", 350000, "Location B"),
|
||||||
buildPurchaseItemForExportTest(11, "Pakan Starter", 0),
|
buildPurchaseItemForExportTest(11, "Pakan Starter", 0, ""),
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
buildPurchaseForExportTest(
|
buildPurchaseForExportTest(
|
||||||
@@ -37,7 +37,7 @@ func TestBuildPurchaseExportWorkbookHeadersAndRows(t *testing.T) {
|
|||||||
ptrApprovalAction(entity.ApprovalActionRejected),
|
ptrApprovalAction(entity.ApprovalActionRejected),
|
||||||
"",
|
"",
|
||||||
[]entity.PurchaseItem{
|
[]entity.PurchaseItem{
|
||||||
buildPurchaseItemForExportTest(21, "Obat X", 75000),
|
buildPurchaseItemForExportTest(21, "Obat X", 75000, ""),
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
})
|
})
|
||||||
@@ -56,10 +56,11 @@ func TestBuildPurchaseExportWorkbookHeadersAndRows(t *testing.T) {
|
|||||||
"B1": "PO Number",
|
"B1": "PO Number",
|
||||||
"C1": "Tanggal PO",
|
"C1": "Tanggal PO",
|
||||||
"D1": "Supplier",
|
"D1": "Supplier",
|
||||||
"E1": "Status",
|
"E1": "Lokasi",
|
||||||
"F1": "Grand Total",
|
"F1": "Status",
|
||||||
"G1": "Products",
|
"G1": "Grand Total",
|
||||||
"H1": "Notes",
|
"H1": "Products",
|
||||||
|
"I1": "Notes",
|
||||||
}
|
}
|
||||||
for cell, expected := range expectedHeaders {
|
for cell, expected := range expectedHeaders {
|
||||||
got, err := file.GetCellValue(purchaseExportSheetName, cell)
|
got, err := file.GetCellValue(purchaseExportSheetName, cell)
|
||||||
@@ -75,18 +76,20 @@ func TestBuildPurchaseExportWorkbookHeadersAndRows(t *testing.T) {
|
|||||||
assertPurchaseCellEquals(t, file, "B2", "PO-00011")
|
assertPurchaseCellEquals(t, file, "B2", "PO-00011")
|
||||||
assertPurchaseCellEquals(t, file, "C2", "22-04-2026")
|
assertPurchaseCellEquals(t, file, "C2", "22-04-2026")
|
||||||
assertPurchaseCellEquals(t, file, "D2", "Supplier A")
|
assertPurchaseCellEquals(t, file, "D2", "Supplier A")
|
||||||
assertPurchaseCellEquals(t, file, "E2", "Manager Purchase")
|
assertPurchaseCellEquals(t, file, "E2", "Location A")
|
||||||
assertPurchaseCellEquals(t, file, "F2", "Rp 1.350.000")
|
assertPurchaseCellEquals(t, file, "F2", "Manager Purchase")
|
||||||
assertPurchaseCellEquals(t, file, "G2", "Pakan Starter, Vitamin A")
|
assertPurchaseCellEquals(t, file, "G2", "Rp 1.350.000")
|
||||||
assertPurchaseCellEquals(t, file, "H2", "catatan")
|
assertPurchaseCellEquals(t, file, "H2", "Pakan Starter, Vitamin A")
|
||||||
|
assertPurchaseCellEquals(t, file, "I2", "catatan")
|
||||||
|
|
||||||
assertPurchaseCellEquals(t, file, "A3", "PR-00012")
|
assertPurchaseCellEquals(t, file, "A3", "PR-00012")
|
||||||
assertPurchaseCellEquals(t, file, "B3", "-")
|
assertPurchaseCellEquals(t, file, "B3", "-")
|
||||||
assertPurchaseCellEquals(t, file, "C3", "-")
|
assertPurchaseCellEquals(t, file, "C3", "-")
|
||||||
assertPurchaseCellEquals(t, file, "E3", "Ditolak")
|
assertPurchaseCellEquals(t, file, "E3", "-")
|
||||||
assertPurchaseCellEquals(t, file, "F3", "Rp 75.000")
|
assertPurchaseCellEquals(t, file, "F3", "Ditolak")
|
||||||
assertPurchaseCellEquals(t, file, "G3", "Obat X")
|
assertPurchaseCellEquals(t, file, "G3", "Rp 75.000")
|
||||||
assertPurchaseCellEquals(t, file, "H3", "-")
|
assertPurchaseCellEquals(t, file, "H3", "Obat X")
|
||||||
|
assertPurchaseCellEquals(t, file, "I3", "-")
|
||||||
}
|
}
|
||||||
|
|
||||||
func assertPurchaseCellEquals(t *testing.T, file *excelize.File, cell, expected string) {
|
func assertPurchaseCellEquals(t *testing.T, file *excelize.File, cell, expected string) {
|
||||||
@@ -141,8 +144,8 @@ func buildPurchaseForExportTest(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildPurchaseItemForExportTest(productID uint, productName string, totalPrice float64) entity.PurchaseItem {
|
func buildPurchaseItemForExportTest(productID uint, productName string, totalPrice float64, locationName string) entity.PurchaseItem {
|
||||||
return entity.PurchaseItem{
|
item := entity.PurchaseItem{
|
||||||
ProductId: productID,
|
ProductId: productID,
|
||||||
TotalPrice: totalPrice,
|
TotalPrice: totalPrice,
|
||||||
Product: &entity.Product{
|
Product: &entity.Product{
|
||||||
@@ -150,6 +153,20 @@ func buildPurchaseItemForExportTest(productID uint, productName string, totalPri
|
|||||||
Name: productName,
|
Name: productName,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if locationName != "" {
|
||||||
|
locationID := productID + 1000
|
||||||
|
warehouseID := productID + 500
|
||||||
|
item.Warehouse = &entity.Warehouse{
|
||||||
|
Id: warehouseID,
|
||||||
|
Location: &entity.Location{
|
||||||
|
Id: locationID,
|
||||||
|
Name: locationName,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return item
|
||||||
}
|
}
|
||||||
|
|
||||||
func ptrApprovalAction(value entity.ApprovalAction) *entity.ApprovalAction {
|
func ptrApprovalAction(value entity.ApprovalAction) *entity.ApprovalAction {
|
||||||
|
|||||||
Executable
BIN
Binary file not shown.
Executable
BIN
Binary file not shown.
Reference in New Issue
Block a user