mirror of
https://gitlab.com/mbugroup/lti-api.git
synced 2026-05-20 13:31:56 +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)
|
||||
GetTotalPopulation(ctx context.Context, projectFlockKandangIDs []uint) (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)
|
||||
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) {
|
||||
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 {
|
||||
now := time.Now()
|
||||
date = &now
|
||||
}
|
||||
|
||||
var totals struct {
|
||||
var recordingTotals struct {
|
||||
TotalPieces float64
|
||||
TotalWeightKg float64
|
||||
}
|
||||
err := r.db.WithContext(ctx).
|
||||
err = r.db.WithContext(ctx).
|
||||
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").
|
||||
Where("r.project_flock_kandangs_id IN (?)", projectFlockKandangIDs).
|
||||
Where("r.record_datetime <= ?", *date).
|
||||
Scan(&totals).Error
|
||||
Scan(&recordingTotals).Error
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
return 0, 0, 0, 0, err
|
||||
}
|
||||
|
||||
var adjustmentTotals struct {
|
||||
TotalQty 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).
|
||||
Table("(?) AS adjustment_sources", adjustmentSubQuery).
|
||||
Select("COALESCE(SUM(adjustment_sources.total_qty), 0) AS total_qty, COALESCE(SUM(adjustment_sources.price), 0) AS total_weight").
|
||||
Table("adjustment_stocks AS ast").
|
||||
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
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
return 0, 0, 0, 0, err
|
||||
}
|
||||
|
||||
totals.TotalPieces += adjustmentTotals.TotalQty
|
||||
totals.TotalWeightKg += adjustmentTotals.TotalWeight
|
||||
|
||||
return totals.TotalPieces, totals.TotalWeightKg, nil
|
||||
return recordingTotals.TotalPieces, recordingTotals.TotalWeightKg, adjustmentTotals.TotalQty, adjustmentTotals.TotalWeight, nil
|
||||
}
|
||||
|
||||
func (r *HppV2RepositoryImpl) GetEggTerjualPiecesAndWeightKgByProjectFlockKandangIds(
|
||||
@@ -1160,7 +1153,6 @@ CROSS JOIN lokasi_rec_totals lrt
|
||||
string(utils.AdjustmentTransactionTypeRecording),
|
||||
).
|
||||
Scan(&totals).Error
|
||||
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
|
||||
@@ -18,8 +18,9 @@ type HppService interface {
|
||||
}
|
||||
|
||||
type HppCostResponse struct {
|
||||
Estimation HppCostDetail `json:"estimation"`
|
||||
Real HppCostDetail `json:"real"`
|
||||
Estimation HppCostDetail `json:"estimation"`
|
||||
Real HppCostDetail `json:"real"`
|
||||
DebugValues *HppCostDebugValues `json:"debug_values,omitempty"`
|
||||
}
|
||||
|
||||
type HppCostDetail struct {
|
||||
|
||||
@@ -44,6 +44,15 @@ type HppV2Component struct {
|
||||
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 {
|
||||
ProjectFlockKandangID uint `json:"project_flock_kandang_id"`
|
||||
ProjectFlockID uint `json:"project_flock_id"`
|
||||
|
||||
@@ -1489,7 +1489,7 @@ func (s *hppV2Service) GetHppEstimationDanRealisasi(totalProductionCost float64,
|
||||
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 {
|
||||
utils.Log.WithError(err).Errorf(
|
||||
"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
|
||||
}
|
||||
estimPieces := recordingQty + adjustmentQty
|
||||
estimWeightKg := recordingWeight + adjustmentWeight
|
||||
|
||||
realPieces, realWeightKg, err := s.hppRepo.GetEggTerjualPiecesAndWeightKgByProjectFlockKandangIds(context.Background(), []uint{projectFlockKandangId}, startDate, endDate)
|
||||
if err != nil {
|
||||
@@ -1551,6 +1553,14 @@ func (s *hppV2Service) GetHppEstimationDanRealisasi(totalProductionCost float64,
|
||||
return &HppCostResponse{
|
||||
Estimation: estimation,
|
||||
Real: real,
|
||||
DebugValues: &HppCostDebugValues{
|
||||
RecordingEggQty: recordingQty,
|
||||
RecordingEggWeight: recordingWeight,
|
||||
AdjustmentEggQty: adjustmentQty,
|
||||
AdjustmentEggWeight: adjustmentWeight,
|
||||
SoldEggQty: realPieces,
|
||||
SoldEggWeight: realWeightKg,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -132,6 +132,17 @@ func (s *hppV2RepoStub) GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIds(
|
||||
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) {
|
||||
if len(projectFlockKandangIDs) != 1 {
|
||||
return 0, 0, nil
|
||||
|
||||
@@ -127,6 +127,8 @@ const (
|
||||
dailyChecklistCategoryEmptyKandang = "empty_kandang"
|
||||
dailyChecklistStatusRejected = "REJECTED"
|
||||
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 {
|
||||
@@ -538,10 +540,21 @@ func (s *dailyChecklistService) CreateOne(c *fiber.Ctx, req *validation.Create)
|
||||
targetID := uint(0)
|
||||
|
||||
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 err := s.validateNoChecklistOverlapForEmptyKandang(tx, req.KandangId, date, endDate); err != nil {
|
||||
return err
|
||||
}
|
||||
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)
|
||||
})
|
||||
if err != nil {
|
||||
@@ -552,6 +565,56 @@ func (s *dailyChecklistService) CreateOne(c *fiber.Ctx, req *validation.Create)
|
||||
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 {
|
||||
existing := new(entity.DailyChecklist)
|
||||
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,
|
||||
"C": 14,
|
||||
"D": 22,
|
||||
"E": 18,
|
||||
"E": 22,
|
||||
"F": 18,
|
||||
"G": 52,
|
||||
"H": 24,
|
||||
"G": 18,
|
||||
"H": 52,
|
||||
"I": 24,
|
||||
}
|
||||
|
||||
for col, width := range columnWidths {
|
||||
@@ -103,6 +104,7 @@ func setPurchaseExportHeaders(file *excelize.File, sheet string) error {
|
||||
"PO Number",
|
||||
"Tanggal PO",
|
||||
"Supplier",
|
||||
"Lokasi",
|
||||
"Status",
|
||||
"Grand Total",
|
||||
"Products",
|
||||
@@ -134,7 +136,7 @@ func setPurchaseExportHeaders(file *excelize.File, sheet string) error {
|
||||
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 {
|
||||
@@ -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 {
|
||||
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
|
||||
}
|
||||
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
|
||||
}
|
||||
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
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -187,7 +192,7 @@ func setPurchaseExportRows(file *excelize.File, sheet string, items []dto.Purcha
|
||||
if err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -207,7 +212,7 @@ func setPurchaseExportRows(file *excelize.File, sheet string, items []dto.Purcha
|
||||
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 {
|
||||
@@ -229,6 +234,13 @@ func safePurchaseSupplierName(item dto.PurchaseListDTO) string {
|
||||
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 {
|
||||
if item.LatestApproval == nil {
|
||||
return "-"
|
||||
|
||||
@@ -22,9 +22,9 @@ func TestBuildPurchaseExportWorkbookHeadersAndRows(t *testing.T) {
|
||||
nil,
|
||||
"catatan",
|
||||
[]entity.PurchaseItem{
|
||||
buildPurchaseItemForExportTest(11, "Pakan Starter", 1000000),
|
||||
buildPurchaseItemForExportTest(12, "Vitamin A", 350000),
|
||||
buildPurchaseItemForExportTest(11, "Pakan Starter", 0),
|
||||
buildPurchaseItemForExportTest(11, "Pakan Starter", 1000000, "Location A"),
|
||||
buildPurchaseItemForExportTest(12, "Vitamin A", 350000, "Location B"),
|
||||
buildPurchaseItemForExportTest(11, "Pakan Starter", 0, ""),
|
||||
},
|
||||
),
|
||||
buildPurchaseForExportTest(
|
||||
@@ -37,7 +37,7 @@ func TestBuildPurchaseExportWorkbookHeadersAndRows(t *testing.T) {
|
||||
ptrApprovalAction(entity.ApprovalActionRejected),
|
||||
"",
|
||||
[]entity.PurchaseItem{
|
||||
buildPurchaseItemForExportTest(21, "Obat X", 75000),
|
||||
buildPurchaseItemForExportTest(21, "Obat X", 75000, ""),
|
||||
},
|
||||
),
|
||||
})
|
||||
@@ -56,10 +56,11 @@ func TestBuildPurchaseExportWorkbookHeadersAndRows(t *testing.T) {
|
||||
"B1": "PO Number",
|
||||
"C1": "Tanggal PO",
|
||||
"D1": "Supplier",
|
||||
"E1": "Status",
|
||||
"F1": "Grand Total",
|
||||
"G1": "Products",
|
||||
"H1": "Notes",
|
||||
"E1": "Lokasi",
|
||||
"F1": "Status",
|
||||
"G1": "Grand Total",
|
||||
"H1": "Products",
|
||||
"I1": "Notes",
|
||||
}
|
||||
for cell, expected := range expectedHeaders {
|
||||
got, err := file.GetCellValue(purchaseExportSheetName, cell)
|
||||
@@ -75,18 +76,20 @@ func TestBuildPurchaseExportWorkbookHeadersAndRows(t *testing.T) {
|
||||
assertPurchaseCellEquals(t, file, "B2", "PO-00011")
|
||||
assertPurchaseCellEquals(t, file, "C2", "22-04-2026")
|
||||
assertPurchaseCellEquals(t, file, "D2", "Supplier A")
|
||||
assertPurchaseCellEquals(t, file, "E2", "Manager Purchase")
|
||||
assertPurchaseCellEquals(t, file, "F2", "Rp 1.350.000")
|
||||
assertPurchaseCellEquals(t, file, "G2", "Pakan Starter, Vitamin A")
|
||||
assertPurchaseCellEquals(t, file, "H2", "catatan")
|
||||
assertPurchaseCellEquals(t, file, "E2", "Location A")
|
||||
assertPurchaseCellEquals(t, file, "F2", "Manager Purchase")
|
||||
assertPurchaseCellEquals(t, file, "G2", "Rp 1.350.000")
|
||||
assertPurchaseCellEquals(t, file, "H2", "Pakan Starter, Vitamin A")
|
||||
assertPurchaseCellEquals(t, file, "I2", "catatan")
|
||||
|
||||
assertPurchaseCellEquals(t, file, "A3", "PR-00012")
|
||||
assertPurchaseCellEquals(t, file, "B3", "-")
|
||||
assertPurchaseCellEquals(t, file, "C3", "-")
|
||||
assertPurchaseCellEquals(t, file, "E3", "Ditolak")
|
||||
assertPurchaseCellEquals(t, file, "F3", "Rp 75.000")
|
||||
assertPurchaseCellEquals(t, file, "G3", "Obat X")
|
||||
assertPurchaseCellEquals(t, file, "H3", "-")
|
||||
assertPurchaseCellEquals(t, file, "E3", "-")
|
||||
assertPurchaseCellEquals(t, file, "F3", "Ditolak")
|
||||
assertPurchaseCellEquals(t, file, "G3", "Rp 75.000")
|
||||
assertPurchaseCellEquals(t, file, "H3", "Obat X")
|
||||
assertPurchaseCellEquals(t, file, "I3", "-")
|
||||
}
|
||||
|
||||
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 {
|
||||
return entity.PurchaseItem{
|
||||
func buildPurchaseItemForExportTest(productID uint, productName string, totalPrice float64, locationName string) entity.PurchaseItem {
|
||||
item := entity.PurchaseItem{
|
||||
ProductId: productID,
|
||||
TotalPrice: totalPrice,
|
||||
Product: &entity.Product{
|
||||
@@ -150,6 +153,20 @@ func buildPurchaseItemForExportTest(productID uint, productName string, totalPri
|
||||
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 {
|
||||
|
||||
Executable
BIN
Binary file not shown.
Executable
BIN
Binary file not shown.
Reference in New Issue
Block a user