Merge branch 'development' into 'production'

Development

See merge request mbugroup/lti-api!461
This commit is contained in:
Adnan Zahir
2026-04-24 12:30:41 +07:00
19 changed files with 4663 additions and 60 deletions
@@ -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
+466
View File
@@ -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
+524
View File
@@ -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
}
Binary file not shown.
BIN
View File
Binary file not shown.
+460
View File
@@ -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 {
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.