mirror of
https://gitlab.com/mbugroup/lti-api.git
synced 2026-05-20 13:31:56 +00:00
826 lines
26 KiB
Go
826 lines
26 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 (
|
|
cutoverReasonPrefix = "EGG_FARM_CUTOVER"
|
|
outputModeTable = "table"
|
|
outputModeJSON = "json"
|
|
)
|
|
|
|
type commandOptions struct {
|
|
Apply bool
|
|
DryRun bool
|
|
RollbackRunID string
|
|
LocationID uint
|
|
LocationName string
|
|
CutoverDate time.Time
|
|
CutoverDateRaw string
|
|
IncludeOverlap bool
|
|
Output string
|
|
ActorID uint
|
|
RunID string
|
|
}
|
|
|
|
type locationTiming struct {
|
|
LocationID uint
|
|
LocationName string
|
|
FirstKandangDate *time.Time
|
|
LastKandangDate *time.Time
|
|
FirstFarmDate *time.Time
|
|
LastFarmDate *time.Time
|
|
Status string
|
|
}
|
|
|
|
type legacyEggStockRow struct {
|
|
LocationID uint
|
|
LocationName string
|
|
SourceWarehouseID uint
|
|
SourceWarehouseName string
|
|
FarmWarehouseID *uint
|
|
FarmWarehouseName *string
|
|
ProductWarehouseID uint
|
|
ProductID uint
|
|
ProductName string
|
|
OnHandQty float64
|
|
}
|
|
|
|
type migrationReportRow struct {
|
|
RunID string `json:"run_id"`
|
|
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"`
|
|
Qty float64 `json:"qty"`
|
|
LocationStatus string `json:"location_status"`
|
|
Status string `json:"status"`
|
|
Reason string `json:"reason,omitempty"`
|
|
TransferID *uint64 `json:"transfer_id,omitempty"`
|
|
MovementNumber *string `json:"movement_number,omitempty"`
|
|
}
|
|
|
|
type applySummary struct {
|
|
RowsPlanned int `json:"rows_planned"`
|
|
RowsApplied int `json:"rows_applied"`
|
|
RowsSkipped int `json:"rows_skipped"`
|
|
RowsFailed int `json:"rows_failed"`
|
|
GroupsPlanned int `json:"groups_planned"`
|
|
GroupsApplied int `json:"groups_applied"`
|
|
}
|
|
|
|
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"`
|
|
}
|
|
|
|
type systemTransferExecutor interface {
|
|
CreateSystemTransfer(ctx context.Context, req *transferSvc.SystemTransferRequest) (*entity.StockTransfer, error)
|
|
DeleteSystemTransfer(ctx context.Context, id uint, actorID uint) error
|
|
}
|
|
|
|
type transferGroup struct {
|
|
LocationID uint
|
|
LocationName string
|
|
SourceWarehouseID uint
|
|
SourceWarehouseName string
|
|
FarmWarehouseID uint
|
|
FarmWarehouseName string
|
|
Rows []*migrationReportRow
|
|
}
|
|
|
|
func main() {
|
|
opts, err := parseFlags()
|
|
if err != nil {
|
|
log.Fatalf("invalid flags: %v", err)
|
|
}
|
|
|
|
db := database.Connect(config.DBHost, config.DBName)
|
|
ctx := context.Background()
|
|
|
|
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 !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
|
|
}
|
|
|
|
timings, err := loadLocationTimings(ctx, db, opts)
|
|
if err != nil {
|
|
log.Fatalf("failed to load location timings: %v", err)
|
|
}
|
|
legacyRows, err := loadLegacyEggStocks(ctx, db, opts)
|
|
if err != nil {
|
|
log.Fatalf("failed to load legacy egg stocks: %v", err)
|
|
}
|
|
|
|
reportRows, groups := buildMigrationPlan(opts, timings, legacyRows)
|
|
if !opts.Apply {
|
|
renderMigrationReport(opts.Output, reportRows, summarizeApply(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 = summarizeApply(finalRows, groups, summary.GroupsApplied)
|
|
renderMigrationReport(opts.Output, finalRows, summary)
|
|
}
|
|
|
|
func parseFlags() (*commandOptions, error) {
|
|
var opts commandOptions
|
|
flag.BoolVar(&opts.Apply, "apply", false, "Apply migration. If false, run as dry-run")
|
|
flag.BoolVar(&opts.DryRun, "dry-run", true, "Run as dry-run")
|
|
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.CutoverDateRaw, "cutover-date", "", "Cutover date in YYYY-MM-DD format")
|
|
flag.BoolVar(&opts.IncludeOverlap, "include-overlap", false, "Include overlap locations in plan/apply")
|
|
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()
|
|
|
|
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", opts.Output)
|
|
}
|
|
if opts.Apply {
|
|
opts.DryRun = false
|
|
}
|
|
if opts.LocationID > 0 && opts.LocationName != "" {
|
|
return nil, errors.New("use either --location-id or --location-name, not both")
|
|
}
|
|
if opts.RollbackRunID != "" {
|
|
if opts.LocationID > 0 || opts.LocationName != "" {
|
|
return nil, errors.New("location filters are not supported with --rollback-run-id")
|
|
}
|
|
if opts.CutoverDateRaw != "" {
|
|
return nil, errors.New("--cutover-date is not used with --rollback-run-id")
|
|
}
|
|
} else if opts.Apply {
|
|
if opts.LocationID == 0 && opts.LocationName == "" {
|
|
return nil, errors.New("apply mode requires --location-id or --location-name for safety")
|
|
}
|
|
if strings.TrimSpace(opts.CutoverDateRaw) == "" {
|
|
return nil, errors.New("--cutover-date is required in apply mode")
|
|
}
|
|
}
|
|
|
|
if strings.TrimSpace(opts.CutoverDateRaw) == "" {
|
|
opts.CutoverDate = normalizeDateOnly(time.Now().In(time.FixedZone("Asia/Jakarta", 7*3600)))
|
|
} else {
|
|
t, err := time.Parse("2006-01-02", opts.CutoverDateRaw)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid --cutover-date: %w", err)
|
|
}
|
|
opts.CutoverDate = normalizeDateOnly(t)
|
|
}
|
|
|
|
opts.RunID = buildRunID()
|
|
return &opts, nil
|
|
}
|
|
|
|
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,
|
|
)
|
|
}
|
|
|
|
func loadLocationTimings(ctx context.Context, db *gorm.DB, opts *commandOptions) (map[uint]locationTiming, error) {
|
|
type row struct {
|
|
LocationID uint `gorm:"column:location_id"`
|
|
LocationName string `gorm:"column:location_name"`
|
|
FirstKandangDate *time.Time `gorm:"column:first_kandang_date"`
|
|
LastKandangDate *time.Time `gorm:"column:last_kandang_date"`
|
|
FirstFarmDate *time.Time `gorm:"column:first_farm_date"`
|
|
LastFarmDate *time.Time `gorm:"column:last_farm_date"`
|
|
}
|
|
|
|
query := db.WithContext(ctx).
|
|
Table("recording_eggs re").
|
|
Select(`
|
|
pf.location_id AS location_id,
|
|
l.name AS location_name,
|
|
MIN(CASE WHEN w.type = 'KANDANG' THEN DATE(r.record_datetime) END) AS first_kandang_date,
|
|
MAX(CASE WHEN w.type = 'KANDANG' THEN DATE(r.record_datetime) END) AS last_kandang_date,
|
|
MIN(CASE WHEN w.type = 'LOKASI' THEN DATE(r.record_datetime) END) AS first_farm_date,
|
|
MAX(CASE WHEN w.type = 'LOKASI' THEN DATE(r.record_datetime) END) AS last_farm_date
|
|
`).
|
|
Joins("JOIN recordings r ON r.id = re.recording_id").
|
|
Joins("JOIN project_flock_kandangs pk ON pk.id = COALESCE(re.project_flock_kandang_id, r.project_flock_kandangs_id)").
|
|
Joins("JOIN project_flocks pf ON pf.id = pk.project_flock_id").
|
|
Joins("JOIN locations l ON l.id = pf.location_id").
|
|
Joins("JOIN product_warehouses pw ON pw.id = re.product_warehouse_id").
|
|
Joins("JOIN warehouses w ON w.id = pw.warehouse_id").
|
|
Group("pf.location_id, l.name")
|
|
query = applyTimingLocationFilter(query, opts)
|
|
|
|
var rows []row
|
|
if err := query.Scan(&rows).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
result := make(map[uint]locationTiming, len(rows))
|
|
for _, row := range rows {
|
|
status := "KANDANG_ONLY"
|
|
if row.FirstFarmDate != nil {
|
|
status = "OVERLAP"
|
|
if row.LastKandangDate == nil || row.FirstFarmDate.After(normalizeDateOnly(*row.LastKandangDate)) {
|
|
status = "CLEAN_CUTOVER"
|
|
}
|
|
}
|
|
result[row.LocationID] = locationTiming{
|
|
LocationID: row.LocationID,
|
|
LocationName: row.LocationName,
|
|
FirstKandangDate: normalizeDatePtr(row.FirstKandangDate),
|
|
LastKandangDate: normalizeDatePtr(row.LastKandangDate),
|
|
FirstFarmDate: normalizeDatePtr(row.FirstFarmDate),
|
|
LastFarmDate: normalizeDatePtr(row.LastFarmDate),
|
|
Status: status,
|
|
}
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
func loadLegacyEggStocks(ctx context.Context, db *gorm.DB, opts *commandOptions) ([]legacyEggStockRow, 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"`
|
|
FarmWarehouseID *uint `gorm:"column:farm_warehouse_id"`
|
|
FarmWarehouseName *string `gorm:"column:farm_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"`
|
|
}
|
|
|
|
firstFarmSub := db.WithContext(ctx).
|
|
Table("warehouses fw").
|
|
Select("fw.location_id AS location_id, MIN(fw.id) AS farm_warehouse_id").
|
|
Where("fw.deleted_at IS NULL").
|
|
Where("fw.type = ?", "LOKASI").
|
|
Group("fw.location_id")
|
|
|
|
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,
|
|
fw.id AS farm_warehouse_id,
|
|
fw.name AS farm_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
|
|
`).
|
|
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").
|
|
Joins("LEFT JOIN product_categories pc ON pc.id = p.product_category_id").
|
|
Joins("LEFT JOIN (?) ff ON ff.location_id = kw.location_id", firstFarmSub).
|
|
Joins("LEFT JOIN warehouses fw ON fw.id = ff.farm_warehouse_id").
|
|
Where("kw.type = ?", "KANDANG").
|
|
Where(`
|
|
EXISTS (
|
|
SELECT 1
|
|
FROM recording_eggs re
|
|
WHERE re.product_warehouse_id = pw.id
|
|
)
|
|
`).
|
|
Where(`
|
|
EXISTS (
|
|
SELECT 1
|
|
FROM flags f
|
|
WHERE f.flagable_type = ?
|
|
AND f.flagable_id = p.id
|
|
AND (UPPER(f.name) = 'TELUR' OR UPPER(f.name) LIKE 'TELUR-%')
|
|
)
|
|
OR (
|
|
NOT EXISTS (
|
|
SELECT 1
|
|
FROM flags f_any
|
|
WHERE f_any.flagable_type = ?
|
|
AND f_any.flagable_id = p.id
|
|
)
|
|
AND UPPER(COALESCE(pc.code, '')) = 'EGG'
|
|
)
|
|
`, entity.FlagableTypeProduct, entity.FlagableTypeProduct).
|
|
Order("l.name ASC, kw.name ASC, p.name ASC")
|
|
query = applyLegacyStockLocationFilter(query, opts)
|
|
|
|
var rows []row
|
|
if err := query.Scan(&rows).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
result := make([]legacyEggStockRow, 0, len(rows))
|
|
for _, row := range rows {
|
|
result = append(result, legacyEggStockRow{
|
|
LocationID: row.LocationID,
|
|
LocationName: row.LocationName,
|
|
SourceWarehouseID: row.SourceWarehouseID,
|
|
SourceWarehouseName: row.SourceWarehouseName,
|
|
FarmWarehouseID: row.FarmWarehouseID,
|
|
FarmWarehouseName: row.FarmWarehouseName,
|
|
ProductWarehouseID: row.ProductWarehouseID,
|
|
ProductID: row.ProductID,
|
|
ProductName: row.ProductName,
|
|
OnHandQty: row.OnHandQty,
|
|
})
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
func buildMigrationPlan(
|
|
opts *commandOptions,
|
|
timings map[uint]locationTiming,
|
|
rows []legacyEggStockRow,
|
|
) ([]migrationReportRow, []transferGroup) {
|
|
reportRows := make([]migrationReportRow, 0, len(rows))
|
|
groupMap := make(map[string]*transferGroup)
|
|
|
|
for _, row := range rows {
|
|
locationStatus := "UNKNOWN"
|
|
if timing, ok := timings[row.LocationID]; ok {
|
|
locationStatus = timing.Status
|
|
}
|
|
|
|
report := migrationReportRow{
|
|
RunID: opts.RunID,
|
|
LocationID: row.LocationID,
|
|
LocationName: row.LocationName,
|
|
SourceWarehouseID: row.SourceWarehouseID,
|
|
SourceWarehouseName: row.SourceWarehouseName,
|
|
FarmWarehouseID: row.FarmWarehouseID,
|
|
FarmWarehouseName: row.FarmWarehouseName,
|
|
ProductWarehouseID: row.ProductWarehouseID,
|
|
ProductID: row.ProductID,
|
|
ProductName: row.ProductName,
|
|
Qty: row.OnHandQty,
|
|
LocationStatus: locationStatus,
|
|
Status: "eligible",
|
|
}
|
|
|
|
switch {
|
|
case row.FarmWarehouseID == nil || row.FarmWarehouseName == nil:
|
|
report.Status = "skipped"
|
|
report.Reason = "missing_farm_warehouse"
|
|
case row.OnHandQty <= 0:
|
|
report.Status = "skipped"
|
|
report.Reason = "non_positive_qty"
|
|
case locationStatus == "OVERLAP" && !opts.IncludeOverlap:
|
|
report.Status = "skipped"
|
|
report.Reason = "overlap_location"
|
|
}
|
|
|
|
reportRows = append(reportRows, report)
|
|
if report.Status != "eligible" {
|
|
continue
|
|
}
|
|
|
|
groupKey := fmt.Sprintf("%d:%d", row.SourceWarehouseID, *row.FarmWarehouseID)
|
|
group := groupMap[groupKey]
|
|
if group == nil {
|
|
group = &transferGroup{
|
|
LocationID: row.LocationID,
|
|
LocationName: row.LocationName,
|
|
SourceWarehouseID: row.SourceWarehouseID,
|
|
SourceWarehouseName: row.SourceWarehouseName,
|
|
FarmWarehouseID: *row.FarmWarehouseID,
|
|
FarmWarehouseName: derefString(row.FarmWarehouseName),
|
|
}
|
|
groupMap[groupKey] = group
|
|
}
|
|
group.Rows = append(group.Rows, &reportRows[len(reportRows)-1])
|
|
}
|
|
|
|
groups := make([]transferGroup, 0, len(groupMap))
|
|
for _, group := range groupMap {
|
|
sort.Slice(group.Rows, func(i, j int) bool {
|
|
return group.Rows[i].ProductName < group.Rows[j].ProductName
|
|
})
|
|
groups = append(groups, *group)
|
|
}
|
|
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
|
|
}
|
|
|
|
func executeApply(
|
|
ctx context.Context,
|
|
svc systemTransferExecutor,
|
|
opts *commandOptions,
|
|
groups []transferGroup,
|
|
) (applySummary, error) {
|
|
summary := applySummary{GroupsPlanned: len(groups)}
|
|
for _, group := range groups {
|
|
products := make([]transferSvc.SystemTransferProduct, 0, len(group.Rows))
|
|
for _, row := range group.Rows {
|
|
products = append(products, transferSvc.SystemTransferProduct{
|
|
ProductID: row.ProductID,
|
|
ProductQty: row.Qty,
|
|
})
|
|
}
|
|
reason := buildCutoverReason(opts.RunID, group.LocationName, opts.CutoverDate)
|
|
transfer, err := svc.CreateSystemTransfer(ctx, &transferSvc.SystemTransferRequest{
|
|
TransferReason: reason,
|
|
TransferDate: opts.CutoverDate,
|
|
SourceWarehouseID: group.SourceWarehouseID,
|
|
DestinationWarehouseID: group.FarmWarehouseID,
|
|
Products: products,
|
|
ActorID: opts.ActorID,
|
|
StockLogNotes: reason,
|
|
})
|
|
if err != nil {
|
|
for _, row := range group.Rows {
|
|
row.Status = "failed"
|
|
row.Reason = err.Error()
|
|
summary.RowsFailed++
|
|
}
|
|
continue
|
|
}
|
|
|
|
summary.GroupsApplied++
|
|
for _, row := range group.Rows {
|
|
row.Status = "applied"
|
|
row.TransferID = &transfer.Id
|
|
row.MovementNumber = &transfer.MovementNumber
|
|
summary.RowsApplied++
|
|
}
|
|
}
|
|
|
|
for _, group := range groups {
|
|
summary.RowsPlanned += len(group.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 transferID := range byTransfer {
|
|
transferIDs = append(transferIDs, transferID)
|
|
}
|
|
sort.Slice(transferIDs, func(i, j int) bool { return transferIDs[i] > transferIDs[j] })
|
|
|
|
var firstErr error
|
|
for _, transferID := range transferIDs {
|
|
err := svc.DeleteSystemTransfer(ctx, uint(transferID), actorID)
|
|
for _, idx := range byTransfer[transferID] {
|
|
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
|
|
}
|
|
|
|
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 _, row := range dbRows {
|
|
rows = append(rows, rollbackDetailRow{
|
|
RunID: runID,
|
|
TransferID: row.TransferID,
|
|
MovementNumber: row.MovementNumber,
|
|
LocationName: row.LocationName,
|
|
SourceWarehouseName: row.SourceWarehouseName,
|
|
FarmWarehouseName: row.FarmWarehouseName,
|
|
ProductName: row.ProductName,
|
|
Qty: row.Qty,
|
|
})
|
|
}
|
|
|
|
return rows, nil
|
|
}
|
|
|
|
func applyTimingLocationFilter(db *gorm.DB, opts *commandOptions) *gorm.DB {
|
|
if opts == nil {
|
|
return db
|
|
}
|
|
switch {
|
|
case opts.LocationID > 0:
|
|
return db.Where("pf.location_id = ?", opts.LocationID)
|
|
case opts.LocationName != "":
|
|
return db.Where("LOWER(l.name) = LOWER(?)", opts.LocationName)
|
|
default:
|
|
return db
|
|
}
|
|
}
|
|
|
|
func applyLegacyStockLocationFilter(db *gorm.DB, opts *commandOptions) *gorm.DB {
|
|
if opts == nil {
|
|
return db
|
|
}
|
|
switch {
|
|
case opts.LocationID > 0:
|
|
return db.Where("kw.location_id = ?", opts.LocationID)
|
|
case opts.LocationName != "":
|
|
return db.Where("LOWER(l.name) = LOWER(?)", opts.LocationName)
|
|
default:
|
|
return db
|
|
}
|
|
}
|
|
|
|
func buildCutoverReason(runID, locationName string, cutoverDate time.Time) string {
|
|
locationName = strings.ReplaceAll(strings.TrimSpace(locationName), "|", "/")
|
|
return fmt.Sprintf("%s|run_id=%s|location=%s|cutover_date=%s", cutoverReasonPrefix, runID, locationName, cutoverDate.Format("2006-01-02"))
|
|
}
|
|
|
|
func buildRunReasonMatcher(runID string) string {
|
|
return fmt.Sprintf("%s|run_id=%s|%%", cutoverReasonPrefix, strings.TrimSpace(runID))
|
|
}
|
|
|
|
func buildRunID() string {
|
|
return fmt.Sprintf("egg-cutover-%s", time.Now().UTC().Format("20060102T150405.000000000Z"))
|
|
}
|
|
|
|
func normalizeDateOnly(value time.Time) time.Time {
|
|
return time.Date(value.Year(), value.Month(), value.Day(), 0, 0, 0, 0, time.UTC)
|
|
}
|
|
|
|
func normalizeDatePtr(value *time.Time) *time.Time {
|
|
if value == nil {
|
|
return nil
|
|
}
|
|
normalized := normalizeDateOnly(*value)
|
|
return &normalized
|
|
}
|
|
|
|
func derefString(value *string) string {
|
|
if value == nil {
|
|
return ""
|
|
}
|
|
return *value
|
|
}
|
|
|
|
func summarizeApply(rows []migrationReportRow, 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++
|
|
}
|
|
}
|
|
return summary
|
|
}
|
|
|
|
func flattenGroups(groups []transferGroup, fallback []migrationReportRow) []migrationReportRow {
|
|
if len(groups) == 0 {
|
|
return fallback
|
|
}
|
|
rows := make([]migrationReportRow, 0, len(fallback))
|
|
for _, group := range groups {
|
|
for _, row := range group.Rows {
|
|
rows = append(rows, *row)
|
|
}
|
|
}
|
|
for _, row := range fallback {
|
|
if row.Status == "skipped" {
|
|
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 renderMigrationReport(mode string, rows []migrationReportRow, summary applySummary) {
|
|
if mode == outputModeJSON {
|
|
payload := map[string]any{
|
|
"rows": rows,
|
|
"summary": summary,
|
|
}
|
|
enc := json.NewEncoder(os.Stdout)
|
|
enc.SetIndent("", " ")
|
|
_ = enc.Encode(payload)
|
|
return
|
|
}
|
|
|
|
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
|
fmt.Fprintln(w, "RUN_ID\tLOCATION\tSOURCE_WAREHOUSE\tFARM_WAREHOUSE\tPRODUCT\tQTY\tLOCATION_STATUS\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%.3f\t%s\t%s\t%s\t%s\t%s\n",
|
|
row.RunID,
|
|
row.LocationName,
|
|
row.SourceWarehouseName,
|
|
derefString(row.FarmWarehouseName),
|
|
row.ProductName,
|
|
row.Qty,
|
|
row.LocationStatus,
|
|
row.Status,
|
|
row.Reason,
|
|
transferID,
|
|
movementNumber,
|
|
)
|
|
}
|
|
_ = w.Flush()
|
|
fmt.Printf("\nSummary: rows_planned=%d rows_applied=%d rows_skipped=%d rows_failed=%d groups_planned=%d groups_applied=%d\n",
|
|
summary.RowsPlanned, summary.RowsApplied, summary.RowsSkipped, 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()
|
|
}
|