mirror of
https://gitlab.com/mbugroup/lti-api.git
synced 2026-05-20 05:21:57 +00:00
1173 lines
41 KiB
Go
1173 lines
41 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"flag"
|
|
"fmt"
|
|
"log"
|
|
"os"
|
|
"sort"
|
|
"strings"
|
|
"text/tabwriter"
|
|
"time"
|
|
|
|
"github.com/go-playground/validator/v10"
|
|
"github.com/sirupsen/logrus"
|
|
"gitlab.com/mbugroup/lti-api.git/internal/common/service"
|
|
"gitlab.com/mbugroup/lti-api.git/internal/config"
|
|
"gitlab.com/mbugroup/lti-api.git/internal/database"
|
|
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
|
|
pwRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories"
|
|
transferRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/transfers/repositories"
|
|
transferSvc "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/transfers/services"
|
|
warehouseRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/repositories"
|
|
pfkRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories"
|
|
stockLogRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/repositories"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
const (
|
|
transferReasonPrefix = "PRODUCT_FARM_TRANSFER"
|
|
outputModeTable = "table"
|
|
outputModeJSON = "json"
|
|
|
|
sourceTypeKandang = "kandang_to_farm"
|
|
sourceTypeFarmConsol = "farm_consolidation"
|
|
)
|
|
|
|
// commandOptions holds all parsed CLI flags.
|
|
type commandOptions struct {
|
|
Apply bool
|
|
RollbackRunID string
|
|
LocationID uint
|
|
LocationName string
|
|
TransferDate time.Time
|
|
TransferDateRaw string
|
|
AllLocations bool
|
|
FarmWarehouseOverrideID uint
|
|
SkipAmbiguous bool
|
|
// FlagFilter is an optional set of product flag names (upper-cased).
|
|
// When non-empty only products that carry at least one of these flags are
|
|
// included. Populated from --flags="PAKAN,OVK".
|
|
FlagFilter []string
|
|
Output string
|
|
ActorID uint
|
|
RunID string
|
|
}
|
|
|
|
// farmWarehouseEntry is a single LOKASI-type warehouse belonging to a location.
|
|
type farmWarehouseEntry struct {
|
|
ID uint
|
|
Name string
|
|
}
|
|
|
|
// farmWarehouseInfo holds all LOKASI warehouses for a location plus the
|
|
// resolved target warehouse (ChosenID). When a location has exactly one
|
|
// LOKASI warehouse, ChosenID is set automatically. When multiple exist,
|
|
// ChosenID is only set after applying --farm-warehouse-id; until then it
|
|
// stays 0 and the location is flagged as an error.
|
|
type farmWarehouseInfo struct {
|
|
LocationID uint
|
|
LocationName string
|
|
// AllFarm holds every LOKASI warehouse found for this location, sorted by id.
|
|
AllFarm []farmWarehouseEntry
|
|
// ChosenID is the resolved transfer destination (0 = unresolved ambiguity).
|
|
ChosenID uint
|
|
ChosenName string
|
|
// OtherFarm holds non-chosen LOKASI warehouses that must be consolidated
|
|
// into ChosenID. Populated only when --farm-warehouse-id resolves a
|
|
// multi-warehouse location.
|
|
OtherFarm []farmWarehouseEntry
|
|
}
|
|
|
|
func (f farmWarehouseInfo) farmCount() int { return len(f.AllFarm) }
|
|
func (f farmWarehouseInfo) isResolved() bool { return f.ChosenID > 0 }
|
|
func (f farmWarehouseInfo) hasFarm() bool { return len(f.AllFarm) > 0 }
|
|
|
|
// kandangStockRow is a single product-warehouse row loaded from the DB.
|
|
// SourceType distinguishes ordinary kandang stocks from extra farm-warehouse
|
|
// stocks that need inter-farm consolidation.
|
|
type kandangStockRow struct {
|
|
LocationID uint
|
|
LocationName string
|
|
SourceWarehouseID uint
|
|
SourceWarehouseName string
|
|
ProductWarehouseID uint
|
|
ProductID uint
|
|
ProductName string
|
|
OnHandQty float64
|
|
AllocatedQty float64 // sum of ACTIVE CONSUME stock_allocations
|
|
LeftoverQty float64 // OnHandQty - AllocatedQty
|
|
SourceType string // sourceTypeKandang or sourceTypeFarmConsol
|
|
}
|
|
|
|
// transferReportRow is one row in the plan/apply report.
|
|
type transferReportRow struct {
|
|
RunID string `json:"run_id"`
|
|
SourceType string `json:"source_type"`
|
|
LocationID uint `json:"location_id"`
|
|
LocationName string `json:"location_name"`
|
|
SourceWarehouseID uint `json:"source_warehouse_id"`
|
|
SourceWarehouseName string `json:"source_warehouse_name"`
|
|
FarmWarehouseID *uint `json:"farm_warehouse_id,omitempty"`
|
|
FarmWarehouseName *string `json:"farm_warehouse_name,omitempty"`
|
|
ProductWarehouseID uint `json:"product_warehouse_id"`
|
|
ProductID uint `json:"product_id"`
|
|
ProductName string `json:"product_name"`
|
|
OnHandQty float64 `json:"on_hand_qty"`
|
|
AllocatedQty float64 `json:"allocated_qty"`
|
|
Qty float64 `json:"qty"` // leftover qty to transfer
|
|
Status string `json:"status"`
|
|
Reason string `json:"reason,omitempty"`
|
|
TransferID *uint64 `json:"transfer_id,omitempty"`
|
|
MovementNumber *string `json:"movement_number,omitempty"`
|
|
}
|
|
|
|
// transferGroup is one stock transfer document: source → farm, N products.
|
|
type transferGroup struct {
|
|
SourceType string
|
|
LocationID uint
|
|
LocationName string
|
|
SourceWarehouseID uint
|
|
SourceWarehouseName string
|
|
FarmWarehouseID uint
|
|
FarmWarehouseName string
|
|
Rows []*transferReportRow
|
|
}
|
|
|
|
// applySummary is printed at the end of a plan or apply run.
|
|
type applySummary struct {
|
|
RowsPlanned int `json:"rows_planned"`
|
|
RowsApplied int `json:"rows_applied"`
|
|
RowsSkipped int `json:"rows_skipped"`
|
|
RowsError int `json:"rows_error"`
|
|
RowsFailed int `json:"rows_failed"`
|
|
GroupsPlanned int `json:"groups_planned"`
|
|
GroupsApplied int `json:"groups_applied"`
|
|
}
|
|
|
|
// rollbackDetailRow is one product line created by a previous run.
|
|
type rollbackDetailRow struct {
|
|
RunID string `json:"run_id"`
|
|
TransferID uint64 `json:"transfer_id"`
|
|
MovementNumber string `json:"movement_number"`
|
|
LocationName string `json:"location_name"`
|
|
SourceWarehouseName string `json:"source_warehouse_name"`
|
|
FarmWarehouseName string `json:"farm_warehouse_name"`
|
|
ProductName string `json:"product_name"`
|
|
Qty float64 `json:"qty"`
|
|
Status string `json:"status"`
|
|
Reason string `json:"reason,omitempty"`
|
|
}
|
|
|
|
// systemTransferExecutor abstracts the transfer service for testability.
|
|
type systemTransferExecutor interface {
|
|
CreateSystemTransfer(ctx context.Context, req *transferSvc.SystemTransferRequest) (*entity.StockTransfer, error)
|
|
DeleteSystemTransfer(ctx context.Context, id uint, actorID uint) error
|
|
}
|
|
|
|
// ── Entry point ───────────────────────────────────────────────────────────────
|
|
|
|
func main() {
|
|
opts, err := parseFlags()
|
|
if err != nil {
|
|
log.Fatalf("invalid flags: %v", err)
|
|
}
|
|
|
|
db := database.Connect(config.DBHost, config.DBName)
|
|
ctx := context.Background()
|
|
|
|
// ── Rollback path ────────────────────────────────────────────────────────
|
|
if strings.TrimSpace(opts.RollbackRunID) != "" {
|
|
rows, err := loadRollbackDetails(ctx, db, opts.RollbackRunID)
|
|
if err != nil {
|
|
log.Fatalf("failed to load rollback details: %v", err)
|
|
}
|
|
if len(rows) == 0 {
|
|
fmt.Fprintf(os.Stderr, "no transfers found for run_id=%s\n", opts.RollbackRunID)
|
|
os.Exit(1)
|
|
}
|
|
if !opts.Apply {
|
|
for i := range rows {
|
|
rows[i].Status = "eligible"
|
|
}
|
|
renderRollbackReport(opts.Output, rows)
|
|
return
|
|
}
|
|
if err := executeRollback(ctx, newSystemTransferService(db), rows, opts.ActorID); err != nil {
|
|
log.Fatalf("rollback failed: %v", err)
|
|
}
|
|
renderRollbackReport(opts.Output, rows)
|
|
return
|
|
}
|
|
|
|
// ── Plan / Apply path ────────────────────────────────────────────────────
|
|
|
|
// Step 1: resolve which farm warehouse each location should use.
|
|
farmMap, err := loadFarmWarehouseMap(ctx, db, opts)
|
|
if err != nil {
|
|
log.Fatalf("failed to load farm warehouse map: %v", err)
|
|
}
|
|
if err := applyFarmWarehouseOverride(farmMap, opts.FarmWarehouseOverrideID); err != nil {
|
|
log.Fatalf("invalid --farm-warehouse-id: %v", err)
|
|
}
|
|
|
|
// In apply mode, warn about or hard-stop on unresolved locations.
|
|
if opts.Apply {
|
|
if msgs := listUnresolvedLocations(farmMap); len(msgs) > 0 {
|
|
for _, m := range msgs {
|
|
if opts.SkipAmbiguous {
|
|
fmt.Fprintln(os.Stderr, "WARN (skipping):", m)
|
|
} else {
|
|
fmt.Fprintln(os.Stderr, "ERROR:", m)
|
|
}
|
|
}
|
|
if !opts.SkipAmbiguous {
|
|
log.Fatalf("aborting: use --farm-warehouse-id to choose the target warehouse for each location listed above, or pass --skip-ambiguous to skip them and process the rest")
|
|
}
|
|
}
|
|
}
|
|
|
|
// Step 2: load leftover stocks from kandang warehouses.
|
|
kandangStocks, err := loadKandangLeftoverStocks(ctx, db, opts)
|
|
if err != nil {
|
|
log.Fatalf("failed to load kandang leftover stocks: %v", err)
|
|
}
|
|
|
|
// Step 3: load leftover stocks from extra farm warehouses that need
|
|
// consolidation into the chosen farm warehouse.
|
|
extraFarmStocks, err := loadExtraFarmLeftoverStocks(ctx, db, farmMap, opts)
|
|
if err != nil {
|
|
log.Fatalf("failed to load extra farm leftover stocks: %v", err)
|
|
}
|
|
|
|
// Step 4: merge and plan.
|
|
allStocks := append(kandangStocks, extraFarmStocks...)
|
|
reportRows, groups := buildTransferPlan(opts, farmMap, allStocks)
|
|
|
|
if !opts.Apply {
|
|
renderTransferReport(opts.Output, reportRows, summarizeReport(reportRows, groups, 0))
|
|
return
|
|
}
|
|
|
|
summary, err := executeApply(ctx, newSystemTransferService(db), opts, groups)
|
|
if err != nil {
|
|
log.Fatalf("apply failed: %v", err)
|
|
}
|
|
finalRows := flattenGroups(groups, reportRows)
|
|
summary = summarizeReport(finalRows, groups, summary.GroupsApplied)
|
|
renderTransferReport(opts.Output, finalRows, summary)
|
|
}
|
|
|
|
// ── Flag parsing ──────────────────────────────────────────────────────────────
|
|
|
|
func parseFlags() (*commandOptions, error) {
|
|
var opts commandOptions
|
|
flag.BoolVar(&opts.Apply, "apply", false, "Apply transfers. If false, run as dry-run and print the plan")
|
|
flag.StringVar(&opts.RollbackRunID, "rollback-run-id", "", "Rollback all transfers created by the provided run_id")
|
|
flag.UintVar(&opts.LocationID, "location-id", 0, "Filter by location id")
|
|
flag.StringVar(&opts.LocationName, "location-name", "", "Filter by exact location name")
|
|
flag.StringVar(&opts.TransferDateRaw, "transfer-date", "", "Transfer date in YYYY-MM-DD format (default: today in Asia/Jakarta)")
|
|
flag.BoolVar(&opts.AllLocations, "all-locations", false, "Allow apply without a location filter (transfers all locations at once)")
|
|
flag.BoolVar(&opts.SkipAmbiguous, "skip-ambiguous", false,
|
|
"When a location has multiple LOKASI warehouses and no --farm-warehouse-id is set, "+
|
|
"skip that location (status=skipped) instead of treating it as an error. "+
|
|
"Useful for an initial global run: unambiguous locations transfer immediately while "+
|
|
"ambiguous ones are left for a follow-up run with --farm-warehouse-id.")
|
|
flag.UintVar(&opts.FarmWarehouseOverrideID, "farm-warehouse-id", 0,
|
|
"When a location has multiple LOKASI warehouses, use this warehouse id as the chosen target. "+
|
|
"Stocks from the other LOKASI warehouses are also transferred to the chosen one. "+
|
|
"Requires --location-id or --location-name.")
|
|
var flagsRaw string
|
|
flag.StringVar(&flagsRaw, "flags", "",
|
|
"Comma-separated list of product flag names to include (e.g. PAKAN,OVK). "+
|
|
"Only products that carry at least one of these flags are transferred. "+
|
|
"Leave empty to transfer all products regardless of flags.")
|
|
flag.StringVar(&opts.Output, "output", outputModeTable, "Output format: table or json")
|
|
flag.UintVar(&opts.ActorID, "actor-id", 1, "Actor id used for created/deleted transfers")
|
|
flag.Parse()
|
|
|
|
for _, f := range strings.Split(flagsRaw, ",") {
|
|
if name := strings.ToUpper(strings.TrimSpace(f)); name != "" {
|
|
opts.FlagFilter = append(opts.FlagFilter, name)
|
|
}
|
|
}
|
|
|
|
opts.LocationName = strings.TrimSpace(opts.LocationName)
|
|
opts.RollbackRunID = strings.TrimSpace(opts.RollbackRunID)
|
|
opts.Output = strings.ToLower(strings.TrimSpace(opts.Output))
|
|
if opts.Output == "" {
|
|
opts.Output = outputModeTable
|
|
}
|
|
if opts.Output != outputModeTable && opts.Output != outputModeJSON {
|
|
return nil, fmt.Errorf("unsupported --output=%s; must be 'table' or 'json'", opts.Output)
|
|
}
|
|
|
|
if opts.LocationID > 0 && opts.LocationName != "" {
|
|
return nil, errors.New("use either --location-id or --location-name, not both")
|
|
}
|
|
|
|
if opts.FarmWarehouseOverrideID > 0 {
|
|
if opts.AllLocations {
|
|
return nil, errors.New("--farm-warehouse-id cannot be combined with --all-locations; specify --location-id or --location-name so the override targets the right location")
|
|
}
|
|
if opts.LocationID == 0 && opts.LocationName == "" {
|
|
return nil, errors.New("--farm-warehouse-id requires --location-id or --location-name")
|
|
}
|
|
}
|
|
|
|
if opts.RollbackRunID != "" {
|
|
if opts.LocationID > 0 || opts.LocationName != "" {
|
|
return nil, errors.New("location filters are not supported with --rollback-run-id")
|
|
}
|
|
if opts.TransferDateRaw != "" {
|
|
return nil, errors.New("--transfer-date is not used with --rollback-run-id")
|
|
}
|
|
if opts.FarmWarehouseOverrideID > 0 {
|
|
return nil, errors.New("--farm-warehouse-id is not used with --rollback-run-id")
|
|
}
|
|
} else if opts.Apply {
|
|
if !opts.AllLocations && opts.LocationID == 0 && opts.LocationName == "" {
|
|
return nil, errors.New(
|
|
"apply mode requires --location-id, --location-name, or --all-locations for safety; " +
|
|
"use --all-locations only after reviewing the dry-run output for all locations",
|
|
)
|
|
}
|
|
}
|
|
|
|
jakarta := time.FixedZone("Asia/Jakarta", 7*3600)
|
|
if strings.TrimSpace(opts.TransferDateRaw) == "" {
|
|
opts.TransferDate = normalizeDateOnly(time.Now().In(jakarta))
|
|
} else {
|
|
t, err := time.Parse("2006-01-02", opts.TransferDateRaw)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid --transfer-date: %w", err)
|
|
}
|
|
opts.TransferDate = normalizeDateOnly(t)
|
|
}
|
|
|
|
opts.RunID = buildRunID()
|
|
return &opts, nil
|
|
}
|
|
|
|
// ── Service wiring ────────────────────────────────────────────────────────────
|
|
|
|
func newSystemTransferService(db *gorm.DB) systemTransferExecutor {
|
|
validate := validator.New()
|
|
stockTransferRepo := transferRepo.NewStockTransferRepository(db)
|
|
stockTransferDetailRepo := transferRepo.NewStockTransferDetailRepository(db)
|
|
stockTransferDeliveryRepo := transferRepo.NewStockTransferDeliveryRepository(db)
|
|
stockTransferDeliveryItemRepo := transferRepo.NewStockTransferDeliveryItemRepository(db)
|
|
stockLogsRepo := stockLogRepo.NewStockLogRepository(db)
|
|
productWarehouseRepo := pwRepo.NewProductWarehouseRepository(db)
|
|
warehouseRepository := warehouseRepo.NewWarehouseRepository(db)
|
|
projectFlockKandangRepo := pfkRepo.NewProjectFlockKandangRepository(db)
|
|
projectFlockPopulationRepo := pfkRepo.NewProjectFlockPopulationRepository(db)
|
|
fifoSvc := service.NewFifoStockV2Service(db, logrus.StandardLogger())
|
|
|
|
return transferSvc.NewTransferService(
|
|
validate,
|
|
stockTransferRepo,
|
|
stockTransferDetailRepo,
|
|
stockTransferDeliveryRepo,
|
|
stockTransferDeliveryItemRepo,
|
|
stockLogsRepo,
|
|
productWarehouseRepo,
|
|
nil,
|
|
warehouseRepository,
|
|
projectFlockKandangRepo,
|
|
projectFlockPopulationRepo,
|
|
nil,
|
|
fifoSvc,
|
|
nil,
|
|
)
|
|
}
|
|
|
|
// ── DB loading ────────────────────────────────────────────────────────────────
|
|
|
|
// loadFarmWarehouseMap returns one farmWarehouseInfo per location_id that has
|
|
// at least one KANDANG warehouse. It fetches every LOKASI warehouse for each
|
|
// location as distinct rows and builds the list in Go, so there is no
|
|
// aggregation that could miscount farm warehouses.
|
|
func loadFarmWarehouseMap(ctx context.Context, db *gorm.DB, opts *commandOptions) (map[uint]farmWarehouseInfo, error) {
|
|
type row struct {
|
|
LocationID uint `gorm:"column:location_id"`
|
|
LocationName string `gorm:"column:location_name"`
|
|
FarmWHID *uint `gorm:"column:farm_wh_id"`
|
|
FarmWHName *string `gorm:"column:farm_wh_name"`
|
|
}
|
|
|
|
// DISTINCT on (location_id, farm_wh_id) so multiple KANDANG warehouses in
|
|
// the same location don't produce duplicate farm-warehouse rows.
|
|
query := db.WithContext(ctx).
|
|
Table("warehouses kw").
|
|
Select(`
|
|
DISTINCT
|
|
kw.location_id AS location_id,
|
|
l.name AS location_name,
|
|
fw.id AS farm_wh_id,
|
|
fw.name AS farm_wh_name
|
|
`).
|
|
Joins("JOIN locations l ON l.id = kw.location_id").
|
|
Joins(`LEFT JOIN warehouses fw
|
|
ON fw.location_id = kw.location_id
|
|
AND UPPER(fw.type) = 'LOKASI'
|
|
AND fw.deleted_at IS NULL`).
|
|
Where("UPPER(kw.type) = 'KANDANG'").
|
|
Where("kw.deleted_at IS NULL").
|
|
Order("kw.location_id ASC, fw.id ASC")
|
|
|
|
query = applyLocationFilter(query, opts, "kw")
|
|
|
|
var rows []row
|
|
if err := query.Scan(&rows).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
result := make(map[uint]farmWarehouseInfo)
|
|
for _, r := range rows {
|
|
info := result[r.LocationID]
|
|
info.LocationID = r.LocationID
|
|
info.LocationName = r.LocationName
|
|
if r.FarmWHID != nil && *r.FarmWHID > 0 {
|
|
// Guard against duplicates that DISTINCT might not eliminate across
|
|
// different location_id groupings due to Go map updates.
|
|
alreadySeen := false
|
|
for _, e := range info.AllFarm {
|
|
if e.ID == *r.FarmWHID {
|
|
alreadySeen = true
|
|
break
|
|
}
|
|
}
|
|
if !alreadySeen {
|
|
info.AllFarm = append(info.AllFarm, farmWarehouseEntry{
|
|
ID: *r.FarmWHID,
|
|
Name: derefString(r.FarmWHName),
|
|
})
|
|
}
|
|
}
|
|
result[r.LocationID] = info
|
|
}
|
|
|
|
// Automatically resolve locations that have exactly one farm warehouse.
|
|
for locID, info := range result {
|
|
if len(info.AllFarm) == 1 {
|
|
info.ChosenID = info.AllFarm[0].ID
|
|
info.ChosenName = info.AllFarm[0].Name
|
|
result[locID] = info
|
|
}
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
// applyFarmWarehouseOverride sets ChosenID/OtherFarm on every location in the
|
|
// map that still has multiple unresolved farm warehouses. overrideID must
|
|
// appear in the location's AllFarm list; if it does not, an error is returned
|
|
// so the operator knows the ID is wrong before any transfer is attempted.
|
|
// Locations with 0 or 1 farm warehouses are left untouched.
|
|
func applyFarmWarehouseOverride(farmMap map[uint]farmWarehouseInfo, overrideID uint) error {
|
|
if overrideID == 0 {
|
|
return nil
|
|
}
|
|
for locID, info := range farmMap {
|
|
if len(info.AllFarm) <= 1 {
|
|
continue // no ambiguity; override is irrelevant for this location
|
|
}
|
|
|
|
found := false
|
|
others := make([]farmWarehouseEntry, 0, len(info.AllFarm)-1)
|
|
for _, fw := range info.AllFarm {
|
|
if fw.ID == overrideID {
|
|
info.ChosenID = fw.ID
|
|
info.ChosenName = fw.Name
|
|
found = true
|
|
} else {
|
|
others = append(others, fw)
|
|
}
|
|
}
|
|
if !found {
|
|
available := make([]string, 0, len(info.AllFarm))
|
|
for _, fw := range info.AllFarm {
|
|
available = append(available, fmt.Sprintf("%s (id=%d)", fw.Name, fw.ID))
|
|
}
|
|
return fmt.Errorf(
|
|
"warehouse id %d is not a LOKASI warehouse for location %q (id=%d)\n available farm warehouses: %s",
|
|
overrideID, info.LocationName, info.LocationID, strings.Join(available, ", "),
|
|
)
|
|
}
|
|
info.OtherFarm = others
|
|
farmMap[locID] = info
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// listUnresolvedLocations returns one human-readable error message per location
|
|
// that still has multiple farm warehouses with no override chosen.
|
|
func listUnresolvedLocations(farmMap map[uint]farmWarehouseInfo) []string {
|
|
var msgs []string
|
|
for _, info := range farmMap {
|
|
if len(info.AllFarm) > 1 && !info.isResolved() {
|
|
available := make([]string, 0, len(info.AllFarm))
|
|
for _, fw := range info.AllFarm {
|
|
available = append(available, fmt.Sprintf("%s (id=%d)", fw.Name, fw.ID))
|
|
}
|
|
msgs = append(msgs, fmt.Sprintf(
|
|
"location %q (id=%d) has %d LOKASI warehouses — rerun with --farm-warehouse-id=<id> to choose one: %s",
|
|
info.LocationName, info.LocationID, len(info.AllFarm), strings.Join(available, ", "),
|
|
))
|
|
}
|
|
}
|
|
sort.Strings(msgs)
|
|
return msgs
|
|
}
|
|
|
|
// loadKandangLeftoverStocks returns all product_warehouse rows for KANDANG-type
|
|
// warehouses where on_hand_qty > 0, together with their active CONSUME
|
|
// allocations so the caller can derive leftover qty.
|
|
func loadKandangLeftoverStocks(ctx context.Context, db *gorm.DB, opts *commandOptions) ([]kandangStockRow, error) {
|
|
type row struct {
|
|
LocationID uint `gorm:"column:location_id"`
|
|
LocationName string `gorm:"column:location_name"`
|
|
SourceWarehouseID uint `gorm:"column:source_warehouse_id"`
|
|
SourceWarehouseName string `gorm:"column:source_warehouse_name"`
|
|
ProductWarehouseID uint `gorm:"column:product_warehouse_id"`
|
|
ProductID uint `gorm:"column:product_id"`
|
|
ProductName string `gorm:"column:product_name"`
|
|
OnHandQty float64 `gorm:"column:on_hand_qty"`
|
|
AllocatedQty float64 `gorm:"column:allocated_qty"`
|
|
}
|
|
|
|
query := db.WithContext(ctx).
|
|
Table("product_warehouses pw").
|
|
Select(`
|
|
kw.location_id AS location_id,
|
|
l.name AS location_name,
|
|
kw.id AS source_warehouse_id,
|
|
kw.name AS source_warehouse_name,
|
|
pw.id AS product_warehouse_id,
|
|
pw.product_id AS product_id,
|
|
p.name AS product_name,
|
|
COALESCE(pw.qty, 0) AS on_hand_qty,
|
|
COALESCE((
|
|
SELECT SUM(sa.qty)
|
|
FROM stock_allocations sa
|
|
WHERE sa.product_warehouse_id = pw.id
|
|
AND sa.status = 'ACTIVE'
|
|
AND sa.allocation_purpose = 'CONSUME'
|
|
AND sa.deleted_at IS NULL
|
|
), 0) AS allocated_qty
|
|
`).
|
|
Joins("JOIN warehouses kw ON kw.id = pw.warehouse_id AND kw.deleted_at IS NULL").
|
|
Joins("JOIN locations l ON l.id = kw.location_id").
|
|
Joins("JOIN products p ON p.id = pw.product_id AND p.deleted_at IS NULL").
|
|
Where("UPPER(kw.type) = 'KANDANG'").
|
|
Where("COALESCE(pw.qty, 0) > 0").
|
|
Order("l.name ASC, kw.name ASC, p.name ASC")
|
|
|
|
query = applyLocationFilter(query, opts, "kw")
|
|
query = applyFlagFilter(query, opts)
|
|
|
|
var rows []row
|
|
if err := query.Scan(&rows).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
result := make([]kandangStockRow, 0, len(rows))
|
|
for _, r := range rows {
|
|
result = append(result, kandangStockRow{
|
|
LocationID: r.LocationID,
|
|
LocationName: r.LocationName,
|
|
SourceWarehouseID: r.SourceWarehouseID,
|
|
SourceWarehouseName: r.SourceWarehouseName,
|
|
ProductWarehouseID: r.ProductWarehouseID,
|
|
ProductID: r.ProductID,
|
|
ProductName: r.ProductName,
|
|
OnHandQty: r.OnHandQty,
|
|
AllocatedQty: r.AllocatedQty,
|
|
LeftoverQty: r.OnHandQty - r.AllocatedQty,
|
|
SourceType: sourceTypeKandang,
|
|
})
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// loadExtraFarmLeftoverStocks loads leftover stocks from every OtherFarm
|
|
// warehouse in the map. These are LOKASI-type warehouses that will be
|
|
// consolidated into the chosen farm warehouse when --farm-warehouse-id is used.
|
|
func loadExtraFarmLeftoverStocks(ctx context.Context, db *gorm.DB, farmMap map[uint]farmWarehouseInfo, opts *commandOptions) ([]kandangStockRow, error) {
|
|
// Collect extra farm warehouse IDs together with their location context.
|
|
type extraSource struct {
|
|
LocationID uint
|
|
LocationName string
|
|
WHID uint
|
|
WHName string
|
|
}
|
|
sources := make([]extraSource, 0)
|
|
for _, info := range farmMap {
|
|
for _, fw := range info.OtherFarm {
|
|
sources = append(sources, extraSource{
|
|
LocationID: info.LocationID,
|
|
LocationName: info.LocationName,
|
|
WHID: fw.ID,
|
|
WHName: fw.Name,
|
|
})
|
|
}
|
|
}
|
|
if len(sources) == 0 {
|
|
return nil, nil
|
|
}
|
|
|
|
warehouseIDs := make([]uint, 0, len(sources))
|
|
for _, s := range sources {
|
|
warehouseIDs = append(warehouseIDs, s.WHID)
|
|
}
|
|
|
|
type row struct {
|
|
WarehouseID uint `gorm:"column:source_warehouse_id"`
|
|
WarehouseName string `gorm:"column:source_warehouse_name"`
|
|
ProductWarehouseID uint `gorm:"column:product_warehouse_id"`
|
|
ProductID uint `gorm:"column:product_id"`
|
|
ProductName string `gorm:"column:product_name"`
|
|
OnHandQty float64 `gorm:"column:on_hand_qty"`
|
|
AllocatedQty float64 `gorm:"column:allocated_qty"`
|
|
}
|
|
|
|
var rows []row
|
|
q := db.WithContext(ctx).
|
|
Table("product_warehouses pw").
|
|
Select(`
|
|
fw.id AS source_warehouse_id,
|
|
fw.name AS source_warehouse_name,
|
|
pw.id AS product_warehouse_id,
|
|
pw.product_id AS product_id,
|
|
p.name AS product_name,
|
|
COALESCE(pw.qty, 0) AS on_hand_qty,
|
|
COALESCE((
|
|
SELECT SUM(sa.qty)
|
|
FROM stock_allocations sa
|
|
WHERE sa.product_warehouse_id = pw.id
|
|
AND sa.status = 'ACTIVE'
|
|
AND sa.allocation_purpose = 'CONSUME'
|
|
AND sa.deleted_at IS NULL
|
|
), 0) AS allocated_qty
|
|
`).
|
|
Joins("JOIN warehouses fw ON fw.id = pw.warehouse_id AND fw.deleted_at IS NULL").
|
|
Joins("JOIN products p ON p.id = pw.product_id AND p.deleted_at IS NULL").
|
|
Where("fw.id IN ?", warehouseIDs).
|
|
Where("COALESCE(pw.qty, 0) > 0")
|
|
q = applyFlagFilter(q, opts)
|
|
if err := q.Order("fw.name ASC, p.name ASC").Scan(&rows).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Build a lookup: warehouseID → extraSource for location context.
|
|
srcByWH := make(map[uint]extraSource, len(sources))
|
|
for _, s := range sources {
|
|
srcByWH[s.WHID] = s
|
|
}
|
|
|
|
result := make([]kandangStockRow, 0, len(rows))
|
|
for _, r := range rows {
|
|
src := srcByWH[r.WarehouseID]
|
|
result = append(result, kandangStockRow{
|
|
LocationID: src.LocationID,
|
|
LocationName: src.LocationName,
|
|
SourceWarehouseID: r.WarehouseID,
|
|
SourceWarehouseName: r.WarehouseName,
|
|
ProductWarehouseID: r.ProductWarehouseID,
|
|
ProductID: r.ProductID,
|
|
ProductName: r.ProductName,
|
|
OnHandQty: r.OnHandQty,
|
|
AllocatedQty: r.AllocatedQty,
|
|
LeftoverQty: r.OnHandQty - r.AllocatedQty,
|
|
SourceType: sourceTypeFarmConsol,
|
|
})
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// ── Plan building ─────────────────────────────────────────────────────────────
|
|
|
|
func buildTransferPlan(
|
|
opts *commandOptions,
|
|
farmMap map[uint]farmWarehouseInfo,
|
|
stocks []kandangStockRow,
|
|
) ([]transferReportRow, []transferGroup) {
|
|
reportRows := make([]transferReportRow, 0, len(stocks))
|
|
groupMap := make(map[string]*transferGroup) // key: "srcWarehouseID:farmWarehouseID"
|
|
|
|
for _, s := range stocks {
|
|
farm := farmMap[s.LocationID]
|
|
|
|
report := transferReportRow{
|
|
RunID: opts.RunID,
|
|
SourceType: s.SourceType,
|
|
LocationID: s.LocationID,
|
|
LocationName: s.LocationName,
|
|
SourceWarehouseID: s.SourceWarehouseID,
|
|
SourceWarehouseName: s.SourceWarehouseName,
|
|
ProductWarehouseID: s.ProductWarehouseID,
|
|
ProductID: s.ProductID,
|
|
ProductName: s.ProductName,
|
|
OnHandQty: s.OnHandQty,
|
|
AllocatedQty: s.AllocatedQty,
|
|
Qty: s.LeftoverQty,
|
|
Status: "eligible",
|
|
}
|
|
|
|
switch s.SourceType {
|
|
case sourceTypeFarmConsol:
|
|
// The destination is already resolved (OtherFarm is only populated
|
|
// when ChosenID is set). The only reason to skip is zero leftover.
|
|
if s.LeftoverQty <= 0 {
|
|
report.Status = "skipped"
|
|
if s.AllocatedQty > 0 {
|
|
report.Reason = fmt.Sprintf("fully_allocated (on_hand=%.3f allocated=%.3f)", s.OnHandQty, s.AllocatedQty)
|
|
} else {
|
|
report.Reason = "zero_on_hand_qty"
|
|
}
|
|
}
|
|
default: // sourceTypeKandang
|
|
switch {
|
|
case !farm.hasFarm():
|
|
report.Status = "skipped"
|
|
report.Reason = "missing_farm_warehouse"
|
|
case farm.farmCount() > 1 && !farm.isResolved():
|
|
// Multiple LOKASI warehouses and no override was given. List the
|
|
// available warehouse IDs so the operator knows what to pass to
|
|
// --farm-warehouse-id.
|
|
available := make([]string, 0, len(farm.AllFarm))
|
|
for _, fw := range farm.AllFarm {
|
|
available = append(available, fmt.Sprintf("%s (id=%d)", fw.Name, fw.ID))
|
|
}
|
|
hint := fmt.Sprintf(
|
|
"multiple_farm_warehouses — rerun with --farm-warehouse-id=<id> to choose one: %s",
|
|
strings.Join(available, " | "),
|
|
)
|
|
if opts.SkipAmbiguous {
|
|
report.Status = "skipped"
|
|
report.Reason = hint
|
|
} else {
|
|
report.Status = "error"
|
|
report.Reason = hint
|
|
}
|
|
case s.LeftoverQty <= 0:
|
|
report.Status = "skipped"
|
|
if s.AllocatedQty > 0 {
|
|
report.Reason = fmt.Sprintf("fully_allocated (on_hand=%.3f allocated=%.3f)", s.OnHandQty, s.AllocatedQty)
|
|
} else {
|
|
report.Reason = "zero_on_hand_qty"
|
|
}
|
|
}
|
|
}
|
|
|
|
// Attach the chosen farm warehouse to the report row for visibility.
|
|
if farm.isResolved() {
|
|
fwID := farm.ChosenID
|
|
fwName := farm.ChosenName
|
|
report.FarmWarehouseID = &fwID
|
|
report.FarmWarehouseName = &fwName
|
|
}
|
|
|
|
reportRows = append(reportRows, report)
|
|
if report.Status != "eligible" {
|
|
continue
|
|
}
|
|
|
|
groupKey := fmt.Sprintf("%d:%d", s.SourceWarehouseID, farm.ChosenID)
|
|
grp := groupMap[groupKey]
|
|
if grp == nil {
|
|
grp = &transferGroup{
|
|
SourceType: s.SourceType,
|
|
LocationID: s.LocationID,
|
|
LocationName: s.LocationName,
|
|
SourceWarehouseID: s.SourceWarehouseID,
|
|
SourceWarehouseName: s.SourceWarehouseName,
|
|
FarmWarehouseID: farm.ChosenID,
|
|
FarmWarehouseName: farm.ChosenName,
|
|
}
|
|
groupMap[groupKey] = grp
|
|
}
|
|
grp.Rows = append(grp.Rows, &reportRows[len(reportRows)-1])
|
|
}
|
|
|
|
groups := make([]transferGroup, 0, len(groupMap))
|
|
for _, grp := range groupMap {
|
|
sort.Slice(grp.Rows, func(i, j int) bool {
|
|
return grp.Rows[i].ProductName < grp.Rows[j].ProductName
|
|
})
|
|
groups = append(groups, *grp)
|
|
}
|
|
sort.Slice(groups, func(i, j int) bool {
|
|
if groups[i].LocationName == groups[j].LocationName {
|
|
return groups[i].SourceWarehouseName < groups[j].SourceWarehouseName
|
|
}
|
|
return groups[i].LocationName < groups[j].LocationName
|
|
})
|
|
|
|
return reportRows, groups
|
|
}
|
|
|
|
// ── Apply / Rollback execution ────────────────────────────────────────────────
|
|
|
|
func executeApply(
|
|
ctx context.Context,
|
|
svc systemTransferExecutor,
|
|
opts *commandOptions,
|
|
groups []transferGroup,
|
|
) (applySummary, error) {
|
|
summary := applySummary{GroupsPlanned: len(groups)}
|
|
|
|
for i := range groups {
|
|
grp := &groups[i]
|
|
products := make([]transferSvc.SystemTransferProduct, 0, len(grp.Rows))
|
|
for _, row := range grp.Rows {
|
|
products = append(products, transferSvc.SystemTransferProduct{
|
|
ProductID: row.ProductID,
|
|
ProductQty: row.Qty,
|
|
})
|
|
}
|
|
|
|
reason := buildTransferReason(opts.RunID, grp.LocationName, grp.SourceWarehouseName, grp.FarmWarehouseName, opts.TransferDate)
|
|
notes := buildStockLogNotes(opts.RunID, grp.LocationName, grp.SourceWarehouseName, grp.FarmWarehouseName, opts.TransferDate, grp.SourceType)
|
|
|
|
transfer, err := svc.CreateSystemTransfer(ctx, &transferSvc.SystemTransferRequest{
|
|
TransferReason: reason,
|
|
TransferDate: opts.TransferDate,
|
|
SourceWarehouseID: grp.SourceWarehouseID,
|
|
DestinationWarehouseID: grp.FarmWarehouseID,
|
|
Products: products,
|
|
ActorID: opts.ActorID,
|
|
StockLogNotes: notes,
|
|
})
|
|
if err != nil {
|
|
for _, row := range grp.Rows {
|
|
row.Status = "failed"
|
|
row.Reason = err.Error()
|
|
summary.RowsFailed++
|
|
}
|
|
continue
|
|
}
|
|
|
|
summary.GroupsApplied++
|
|
for _, row := range grp.Rows {
|
|
row.Status = "applied"
|
|
row.TransferID = &transfer.Id
|
|
row.MovementNumber = &transfer.MovementNumber
|
|
summary.RowsApplied++
|
|
}
|
|
}
|
|
|
|
for _, grp := range groups {
|
|
summary.RowsPlanned += len(grp.Rows)
|
|
}
|
|
return summary, nil
|
|
}
|
|
|
|
func executeRollback(
|
|
ctx context.Context,
|
|
svc systemTransferExecutor,
|
|
rows []rollbackDetailRow,
|
|
actorID uint,
|
|
) error {
|
|
if actorID == 0 {
|
|
return fmt.Errorf("--actor-id is required for rollback")
|
|
}
|
|
|
|
byTransfer := make(map[uint64][]int)
|
|
for idx, row := range rows {
|
|
byTransfer[row.TransferID] = append(byTransfer[row.TransferID], idx)
|
|
}
|
|
|
|
transferIDs := make([]uint64, 0, len(byTransfer))
|
|
for id := range byTransfer {
|
|
transferIDs = append(transferIDs, id)
|
|
}
|
|
// Delete in descending order to unwind downstream dependencies first.
|
|
sort.Slice(transferIDs, func(i, j int) bool { return transferIDs[i] > transferIDs[j] })
|
|
|
|
var firstErr error
|
|
for _, id := range transferIDs {
|
|
err := svc.DeleteSystemTransfer(ctx, uint(id), actorID)
|
|
for _, idx := range byTransfer[id] {
|
|
if err != nil {
|
|
rows[idx].Status = "failed"
|
|
rows[idx].Reason = err.Error()
|
|
} else {
|
|
rows[idx].Status = "rolled_back"
|
|
}
|
|
}
|
|
if err != nil && firstErr == nil {
|
|
firstErr = err
|
|
}
|
|
}
|
|
|
|
return firstErr
|
|
}
|
|
|
|
// loadRollbackDetails finds all transfer rows created by a given run_id.
|
|
func loadRollbackDetails(ctx context.Context, db *gorm.DB, runID string) ([]rollbackDetailRow, error) {
|
|
type row struct {
|
|
TransferID uint64 `gorm:"column:transfer_id"`
|
|
MovementNumber string `gorm:"column:movement_number"`
|
|
LocationName string `gorm:"column:location_name"`
|
|
SourceWarehouseName string `gorm:"column:source_warehouse_name"`
|
|
FarmWarehouseName string `gorm:"column:farm_warehouse_name"`
|
|
ProductName string `gorm:"column:product_name"`
|
|
Qty float64 `gorm:"column:qty"`
|
|
}
|
|
|
|
needle := buildRunReasonMatcher(runID)
|
|
var dbRows []row
|
|
err := db.WithContext(ctx).
|
|
Table("stock_transfers st").
|
|
Select(`
|
|
st.id AS transfer_id,
|
|
st.movement_number AS movement_number,
|
|
COALESCE(loc.name, '') AS location_name,
|
|
ws.name AS source_warehouse_name,
|
|
wd.name AS farm_warehouse_name,
|
|
p.name AS product_name,
|
|
COALESCE(std.total_qty, std.usage_qty, 0) AS qty
|
|
`).
|
|
Joins("JOIN warehouses ws ON ws.id = st.from_warehouse_id").
|
|
Joins("JOIN warehouses wd ON wd.id = st.to_warehouse_id").
|
|
Joins("LEFT JOIN locations loc ON loc.id = COALESCE(ws.location_id, wd.location_id)").
|
|
Joins("JOIN stock_transfer_details std ON std.stock_transfer_id = st.id AND std.deleted_at IS NULL").
|
|
Joins("JOIN products p ON p.id = std.product_id").
|
|
Where("st.deleted_at IS NULL").
|
|
Where("st.reason LIKE ?", needle).
|
|
Order("st.id DESC, std.id ASC").
|
|
Scan(&dbRows).Error
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
rows := make([]rollbackDetailRow, 0, len(dbRows))
|
|
for _, r := range dbRows {
|
|
rows = append(rows, rollbackDetailRow{
|
|
RunID: runID,
|
|
TransferID: r.TransferID,
|
|
MovementNumber: r.MovementNumber,
|
|
LocationName: r.LocationName,
|
|
SourceWarehouseName: r.SourceWarehouseName,
|
|
FarmWarehouseName: r.FarmWarehouseName,
|
|
ProductName: r.ProductName,
|
|
Qty: r.Qty,
|
|
})
|
|
}
|
|
return rows, nil
|
|
}
|
|
|
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
|
|
// applyFlagFilter adds an EXISTS subquery that restricts results to products
|
|
// carrying at least one flag from opts.FlagFilter. When the filter is empty
|
|
// the query is returned unchanged so all products are included.
|
|
func applyFlagFilter(q *gorm.DB, opts *commandOptions) *gorm.DB {
|
|
if len(opts.FlagFilter) == 0 {
|
|
return q
|
|
}
|
|
return q.Where(`EXISTS (
|
|
SELECT 1
|
|
FROM flags f
|
|
WHERE f.flagable_id = p.id
|
|
AND f.flagable_type = 'products'
|
|
AND UPPER(f.name) IN ?
|
|
)`, opts.FlagFilter)
|
|
}
|
|
|
|
func applyLocationFilter(q *gorm.DB, opts *commandOptions, tableAlias string) *gorm.DB {
|
|
if opts == nil {
|
|
return q
|
|
}
|
|
switch {
|
|
case opts.LocationID > 0:
|
|
return q.Where(tableAlias+".location_id = ?", opts.LocationID)
|
|
case opts.LocationName != "":
|
|
return q.Where("LOWER(l.name) = LOWER(?)", opts.LocationName)
|
|
default:
|
|
return q
|
|
}
|
|
}
|
|
|
|
// buildTransferReason produces the structured string stored in stock_transfers.reason.
|
|
// This is the rollback lookup key so its format must remain stable.
|
|
func buildTransferReason(runID, locationName, srcWarehouse, farmWarehouse string, date time.Time) string {
|
|
return fmt.Sprintf(
|
|
"%s|run_id=%s|location=%s|src_warehouse=%s|farm_warehouse=%s|transfer_date=%s",
|
|
transferReasonPrefix,
|
|
runID,
|
|
sanitizePipeField(locationName),
|
|
sanitizePipeField(srcWarehouse),
|
|
sanitizePipeField(farmWarehouse),
|
|
date.Format("2006-01-02"),
|
|
)
|
|
}
|
|
|
|
// buildStockLogNotes produces a human-readable note for each stock_log entry.
|
|
func buildStockLogNotes(runID, locationName, srcWarehouse, farmWarehouse string, date time.Time, sourceType string) string {
|
|
kind := "leftover stock transfer from kandang to farm"
|
|
if sourceType == sourceTypeFarmConsol {
|
|
kind = "farm warehouse consolidation (non-primary farm to chosen farm)"
|
|
}
|
|
return fmt.Sprintf(
|
|
"[auto] %s | run_id=%s | location=%s | from=%s | to=%s | date=%s",
|
|
kind, runID, locationName, srcWarehouse, farmWarehouse, date.Format("2006-01-02"),
|
|
)
|
|
}
|
|
|
|
// buildRunReasonMatcher returns a SQL LIKE pattern matching all transfers from a run.
|
|
func buildRunReasonMatcher(runID string) string {
|
|
return fmt.Sprintf("%s|run_id=%s|%%", transferReasonPrefix, strings.TrimSpace(runID))
|
|
}
|
|
|
|
func buildRunID() string {
|
|
return fmt.Sprintf("product-farm-transfer-%s", time.Now().UTC().Format("20060102T150405.000000000Z"))
|
|
}
|
|
|
|
func sanitizePipeField(s string) string {
|
|
return strings.ReplaceAll(strings.TrimSpace(s), "|", "/")
|
|
}
|
|
|
|
func normalizeDateOnly(t time.Time) time.Time {
|
|
return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC)
|
|
}
|
|
|
|
func derefString(s *string) string {
|
|
if s == nil {
|
|
return ""
|
|
}
|
|
return *s
|
|
}
|
|
|
|
// flattenGroups rebuilds the row list from groups (which carry applied/failed
|
|
// status after apply) then appends skipped and error rows from the plan.
|
|
func flattenGroups(groups []transferGroup, fallback []transferReportRow) []transferReportRow {
|
|
if len(groups) == 0 {
|
|
return fallback
|
|
}
|
|
rows := make([]transferReportRow, 0, len(fallback))
|
|
for _, grp := range groups {
|
|
for _, row := range grp.Rows {
|
|
rows = append(rows, *row)
|
|
}
|
|
}
|
|
for _, row := range fallback {
|
|
if row.Status == "skipped" || row.Status == "error" {
|
|
rows = append(rows, row)
|
|
}
|
|
}
|
|
sort.Slice(rows, func(i, j int) bool {
|
|
if rows[i].LocationName == rows[j].LocationName {
|
|
if rows[i].SourceWarehouseName == rows[j].SourceWarehouseName {
|
|
return rows[i].ProductName < rows[j].ProductName
|
|
}
|
|
return rows[i].SourceWarehouseName < rows[j].SourceWarehouseName
|
|
}
|
|
return rows[i].LocationName < rows[j].LocationName
|
|
})
|
|
return rows
|
|
}
|
|
|
|
func summarizeReport(rows []transferReportRow, groups []transferGroup, appliedGroups int) applySummary {
|
|
summary := applySummary{
|
|
GroupsPlanned: len(groups),
|
|
GroupsApplied: appliedGroups,
|
|
}
|
|
for _, row := range rows {
|
|
switch row.Status {
|
|
case "eligible":
|
|
summary.RowsPlanned++
|
|
case "applied":
|
|
summary.RowsPlanned++
|
|
summary.RowsApplied++
|
|
case "failed":
|
|
summary.RowsPlanned++
|
|
summary.RowsFailed++
|
|
case "skipped":
|
|
summary.RowsSkipped++
|
|
case "error":
|
|
summary.RowsError++
|
|
}
|
|
}
|
|
return summary
|
|
}
|
|
|
|
// ── Rendering ─────────────────────────────────────────────────────────────────
|
|
|
|
func renderTransferReport(mode string, rows []transferReportRow, summary applySummary) {
|
|
if mode == outputModeJSON {
|
|
enc := json.NewEncoder(os.Stdout)
|
|
enc.SetIndent("", " ")
|
|
_ = enc.Encode(map[string]any{"rows": rows, "summary": summary})
|
|
return
|
|
}
|
|
|
|
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
|
fmt.Fprintln(w, "RUN_ID\tSOURCE_TYPE\tLOCATION\tSOURCE_WAREHOUSE\tFARM_WAREHOUSE\tPRODUCT\tON_HAND\tALLOCATED\tLEFTOVER\tSTATUS\tREASON\tTRANSFER_ID\tMOVEMENT_NUMBER")
|
|
for _, row := range rows {
|
|
transferID := "-"
|
|
if row.TransferID != nil {
|
|
transferID = fmt.Sprintf("%d", *row.TransferID)
|
|
}
|
|
movementNumber := "-"
|
|
if row.MovementNumber != nil {
|
|
movementNumber = *row.MovementNumber
|
|
}
|
|
fmt.Fprintf(w,
|
|
"%s\t%s\t%s\t%s\t%s\t%s\t%.3f\t%.3f\t%.3f\t%s\t%s\t%s\t%s\n",
|
|
row.RunID,
|
|
row.SourceType,
|
|
row.LocationName,
|
|
row.SourceWarehouseName,
|
|
derefString(row.FarmWarehouseName),
|
|
row.ProductName,
|
|
row.OnHandQty,
|
|
row.AllocatedQty,
|
|
row.Qty,
|
|
row.Status,
|
|
row.Reason,
|
|
transferID,
|
|
movementNumber,
|
|
)
|
|
}
|
|
_ = w.Flush()
|
|
fmt.Printf(
|
|
"\nSummary: rows_planned=%d rows_applied=%d rows_skipped=%d rows_error=%d rows_failed=%d groups_planned=%d groups_applied=%d\n",
|
|
summary.RowsPlanned, summary.RowsApplied, summary.RowsSkipped, summary.RowsError, summary.RowsFailed,
|
|
summary.GroupsPlanned, summary.GroupsApplied,
|
|
)
|
|
}
|
|
|
|
func renderRollbackReport(mode string, rows []rollbackDetailRow) {
|
|
if mode == outputModeJSON {
|
|
enc := json.NewEncoder(os.Stdout)
|
|
enc.SetIndent("", " ")
|
|
_ = enc.Encode(map[string]any{"rows": rows})
|
|
return
|
|
}
|
|
|
|
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
|
fmt.Fprintln(w, "RUN_ID\tTRANSFER_ID\tMOVEMENT_NUMBER\tLOCATION\tSOURCE_WAREHOUSE\tFARM_WAREHOUSE\tPRODUCT\tQTY\tSTATUS\tREASON")
|
|
for _, row := range rows {
|
|
fmt.Fprintf(w,
|
|
"%s\t%d\t%s\t%s\t%s\t%s\t%s\t%.3f\t%s\t%s\n",
|
|
row.RunID,
|
|
row.TransferID,
|
|
row.MovementNumber,
|
|
row.LocationName,
|
|
row.SourceWarehouseName,
|
|
row.FarmWarehouseName,
|
|
row.ProductName,
|
|
row.Qty,
|
|
row.Status,
|
|
row.Reason,
|
|
)
|
|
}
|
|
_ = w.Flush()
|
|
}
|