Compare commits

..

1 Commits

Author SHA1 Message Date
giovanni a70a69a5be add validasi overselling telur 2026-06-03 10:26:40 +07:00
7 changed files with 94 additions and 63 deletions
@@ -0,0 +1,17 @@
BEGIN;
-- Revert the TELUR / TELUR_GRADE marketing over-sell block. Removing these rows
-- makes resolveOverConsume() fall back to the default allow rule again (the
-- post-20260313061525 behaviour). The reasons are unique to this migration, so
-- the DELETE only touches rows created here.
DELETE FROM fifo_stock_v2_overconsume_rules
WHERE lane = 'USABLE'
AND function_code = 'MARKETING_OUT'
AND flag_group_code IN ('TELUR', 'TELUR_GRADE')
AND reason IN (
'fifo_v2_exception_marketing_block_telur',
'fifo_v2_exception_marketing_block_telur_grade'
);
COMMIT;
@@ -0,0 +1,54 @@
BEGIN;
-- Restore the marketing over-sell block for TELUR and TELUR_GRADE only.
--
-- Migration 20260313061525 narrowed the MARKETING_OUT over-sell block to
-- flag_group_code='AYAM' and deactivated the global rule. That left TELUR /
-- TELUR_GRADE with no matching block, so resolveOverConsume() fell back to the
-- default rule 'fifo_v2_default_allow' (allow_overconsume=TRUE) and egg
-- Delivery Orders could over-sell silently into marketing_delivery_products.pending_qty.
--
-- These rules make resolveOverConsume('TELUR'|'TELUR_GRADE','MARKETING_OUT') = FALSE,
-- so an egg DO that exceeds available stock is REJECTED (ErrInsufficientStock)
-- instead of being recorded as pending. Scope is "Telur saja" — AYAM and
-- transfer behaviour are intentionally left unchanged.
--
-- NOTE: run the total_used reconciliation (cmd/reconcile-fifo-total-used) BEFORE
-- applying this in production. Enabling the block while phantom total_used still
-- inflates consumption would reject otherwise-valid egg orders.
INSERT INTO fifo_stock_v2_overconsume_rules(flag_group_code, function_code, lane, allow_overconsume, priority, reason, is_active)
SELECT 'TELUR', 'MARKETING_OUT', 'USABLE', FALSE, 20, 'fifo_v2_exception_marketing_block_telur', TRUE
WHERE NOT EXISTS (
SELECT 1 FROM fifo_stock_v2_overconsume_rules
WHERE lane = 'USABLE'
AND function_code = 'MARKETING_OUT'
AND flag_group_code = 'TELUR'
AND reason = 'fifo_v2_exception_marketing_block_telur'
);
UPDATE fifo_stock_v2_overconsume_rules
SET allow_overconsume = FALSE, priority = 20, is_active = TRUE
WHERE lane = 'USABLE'
AND function_code = 'MARKETING_OUT'
AND flag_group_code = 'TELUR'
AND reason = 'fifo_v2_exception_marketing_block_telur';
INSERT INTO fifo_stock_v2_overconsume_rules(flag_group_code, function_code, lane, allow_overconsume, priority, reason, is_active)
SELECT 'TELUR_GRADE', 'MARKETING_OUT', 'USABLE', FALSE, 20, 'fifo_v2_exception_marketing_block_telur_grade', TRUE
WHERE NOT EXISTS (
SELECT 1 FROM fifo_stock_v2_overconsume_rules
WHERE lane = 'USABLE'
AND function_code = 'MARKETING_OUT'
AND flag_group_code = 'TELUR_GRADE'
AND reason = 'fifo_v2_exception_marketing_block_telur_grade'
);
UPDATE fifo_stock_v2_overconsume_rules
SET allow_overconsume = FALSE, priority = 20, is_active = TRUE
WHERE lane = 'USABLE'
AND function_code = 'MARKETING_OUT'
AND flag_group_code = 'TELUR_GRADE'
AND reason = 'fifo_v2_exception_marketing_block_telur_grade';
COMMIT;
@@ -75,9 +75,6 @@ func (u *DeliveryOrdersController) GetAll(c *fiber.Ctx) error {
WarehouseID: uint(c.QueryInt("warehouse_id", 0)),
SortBy: sortBy,
SortOrder: sortOrder,
StartDate: strings.TrimSpace(c.Query("start_date", "")),
EndDate: strings.TrimSpace(c.Query("end_date", "")),
FilterBy: strings.TrimSpace(c.Query("filter_by", "")),
}
if isAllExcelExportRequest(c) {
@@ -201,6 +201,11 @@ func setMarketingExportRows(file *excelize.File, sheet string, items []dto.Marke
for _, group := range item.DeliveryOrder {
doNumber := safeMarketingExportText(group.DoNumber)
doDate := "-"
if group.DeliveryDate != nil {
doDate = formatMarketingExportDate(*group.DeliveryDate)
}
gudang := "-"
if group.Warehouse != nil {
gudang = safeMarketingExportText(group.Warehouse.Name)
@@ -210,7 +215,7 @@ func setMarketingExportRows(file *excelize.File, sheet string, items []dto.Marke
row++
r := strconv.Itoa(row)
vals := map[string]interface{}{
"A": doNumber, "B": soDate, "C": status, "D": customer, "E": salesPerson,
"A": doNumber, "B": doDate, "C": status, "D": customer, "E": salesPerson,
"F": "-", "G": "-", "H": gudang, "I": "-", "J": "-", "K": "-",
"L": "-", "M": "-", "N": "-", "O": "-",
"P": grandTotal, "Q": notes,
@@ -246,7 +251,7 @@ func setMarketingExportRows(file *excelize.File, sheet string, items []dto.Marke
if err := file.SetCellValue(sheet, "A"+r, doNumber); err != nil {
return err
}
if err := file.SetCellValue(sheet, "B"+r, soDate); err != nil {
if err := file.SetCellValue(sheet, "B"+r, doDate); err != nil {
return err
}
if err := file.SetCellValue(sheet, "C"+r, status); err != nil {
@@ -342,7 +347,7 @@ func setMarketingExportRows(file *excelize.File, sheet string, items []dto.Marke
}
gudang := "-"
if prod.ProductWarehouse != nil && prod.ProductWarehouse.Warehouse != nil {
if prod.ProductWarehouse != nil {
gudang = safeMarketingExportText(prod.ProductWarehouse.Warehouse.Name)
}
@@ -15,10 +15,6 @@ import (
)
func TestBuildMarketingExportWorkbookHeadersAndRows(t *testing.T) {
// DO item has soDate=2026-05-31 and deliveryDate=2026-06-01 to verify
// the export uses soDate (not deliveryDate) in column B.
deliveryDate := time.Date(2026, time.June, 1, 0, 0, 0, 0, time.UTC)
items := []dto.MarketingListDTO{
{
MarketingRelationDTO: dto.MarketingRelationDTO{
@@ -55,22 +51,6 @@ func TestBuildMarketingExportWorkbookHeadersAndRows(t *testing.T) {
Action: strPtr("REJECTED"),
},
},
{
MarketingRelationDTO: dto.MarketingRelationDTO{
SoNumber: "SO-00760",
SoDate: time.Date(2026, time.May, 31, 0, 0, 0, 0, time.UTC),
},
Customer: customerDTO.CustomerRelationDTO{Name: "CORDELA"},
DeliveryOrder: []dto.DeliveryGroupDTO{
{
DoNumber: "DO-01954",
DeliveryDate: &deliveryDate,
},
},
LatestApproval: approvalDTO.ApprovalRelationDTO{
StepName: "Delivery Order",
},
},
}
content, err := buildMarketingExportWorkbook(items)
@@ -89,10 +69,9 @@ func TestBuildMarketingExportWorkbookHeadersAndRows(t *testing.T) {
"B1": "Tanggal",
"C1": "Status",
"D1": "Customer",
"E1": "Sales",
"G1": "Nama Produk",
"P1": "Grand Total",
"Q1": "Catatan",
"E1": "Grand Total",
"F1": "Products",
"G1": "Notes",
}
for cell, expected := range expectedHeaders {
got, err := file.GetCellValue(marketingExportSheetName, cell)
@@ -104,25 +83,19 @@ func TestBuildMarketingExportWorkbookHeadersAndRows(t *testing.T) {
}
}
// SO-00762: 3 products → rows 2, 3, 4
assertCellEquals(t, file, "A2", "SO-00762")
assertCellEquals(t, file, "B2", "22-04-2026")
assertCellEquals(t, file, "C2", "Pengajuan")
assertCellEquals(t, file, "D2", "AJAT")
assertCellEquals(t, file, "G2", "PAKAN GROWING CRUMBLE 8603 MALINDO")
assertCellEquals(t, file, "Q2", "tes")
assertCellEquals(t, file, "E2", "Rp 5.206.200.000")
assertCellEquals(t, file, "F2", "PAKAN GROWING CRUMBLE 8603 MALINDO, 295 GOLD PELLET")
assertCellEquals(t, file, "G2", "tes")
// SO-00761 (rejected): 1 product → row 5
assertCellEquals(t, file, "A5", "SO-00761")
assertCellEquals(t, file, "C5", "Ditolak")
assertCellEquals(t, file, "G5", "HS30 FOAM @20 LITER")
assertCellEquals(t, file, "Q5", "-")
// DO-01954: column B must use soDate (31-05-2026), not deliveryDate (01-06-2026)
assertCellEquals(t, file, "A6", "DO-01954")
assertCellEquals(t, file, "B6", "31-05-2026")
assertCellEquals(t, file, "C6", "Delivery Order")
assertCellEquals(t, file, "D6", "CORDELA")
assertCellEquals(t, file, "A3", "SO-00761")
assertCellEquals(t, file, "C3", "Ditolak")
assertCellEquals(t, file, "E3", "Rp 75.000")
assertCellEquals(t, file, "F3", "HS30 FOAM @20 LITER")
assertCellEquals(t, file, "G3", "-")
}
func assertCellEquals(t *testing.T, file *excelize.File, cell, expected string) {
@@ -321,21 +321,6 @@ func (s deliveryOrdersService) GetAll(c *fiber.Ctx, params *validation.DeliveryO
return db.Where("id = ?", params.MarketingId)
}
dateStart, dateEnd, dateErr := utils.ParseDateRangeForQuery(params.StartDate, params.EndDate)
if dateErr != nil {
return db.Where("1 = 0")
}
dateCol := "marketings.so_date"
if strings.TrimSpace(params.FilterBy) == "created_at" {
dateCol = "marketings.created_at"
}
if dateStart != nil {
db = db.Where(dateCol+" >= ?", *dateStart)
}
if dateEnd != nil {
db = db.Where(dateCol+" < ?", *dateEnd)
}
orderDir := "DESC"
if params.SortOrder != "" {
orderDir = strings.ToUpper(params.SortOrder)
@@ -979,7 +964,10 @@ func (s deliveryOrdersService) consumeDeliveryStock(ctx context.Context, tx *gor
marketingProduct.ProductWarehouseId,
resolveMarketingAsOf(deliveryProduct.DeliveryDate, deliveryProduct.CreatedAt),
); err != nil {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Insufficient stock for product warehouse %d: %v", marketingProduct.ProductWarehouseId, err))
if errors.Is(err, fifoV2.ErrInsufficientStock) {
return fiber.NewError(fiber.StatusBadRequest, "Stok tidak mencukupi untuk memenuhi permintaan delivery order ini")
}
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Gagal mengalokasikan stok: %v", err))
}
refreshed, err := deliveryProductRepo.GetByID(ctx, deliveryProduct.Id, nil)
@@ -34,9 +34,6 @@ type DeliveryOrderQuery struct {
WarehouseID uint `query:"warehouse_id" validate:"omitempty,gt=0"`
SortBy string `query:"sort_by" validate:"omitempty,oneof=so_number so_date status customer grand_total created_at"`
SortOrder string `query:"sort_order" validate:"omitempty,oneof=asc desc"`
StartDate string `query:"start_date" validate:"omitempty,datetime=2006-01-02"`
EndDate string `query:"end_date" validate:"omitempty,datetime=2006-01-02"`
FilterBy string `query:"filter_by" validate:"omitempty,oneof=so_date created_at"`
}
type DeliveryOrderApprove struct {