diff --git a/internal/database/migrations/20260529085659_normalize_po_number_to_pr_pattern.down.sql b/internal/database/migrations/20260529085659_normalize_po_number_to_pr_pattern.down.sql new file mode 100644 index 00000000..5f07ad95 --- /dev/null +++ b/internal/database/migrations/20260529085659_normalize_po_number_to_pr_pattern.down.sql @@ -0,0 +1,8 @@ +-- IRREVERSIBLE migration: po_number lama (counter-based) tidak di-backup +-- saat UP karena user secara eksplisit pilih "tanpa backup table". +-- Down ini hanya raise notice supaya operator sadar harus restore dari +-- DB-level backup terpisah kalau memang perlu rollback. +DO $$ +BEGIN + RAISE NOTICE 'WARNING: Migration 20260529143940_normalize_po_number_to_pr_pattern is irreversible. Original counter-based PO numbers were not backed up. Restore from DB-level backup if rollback is required.'; +END $$; diff --git a/internal/database/migrations/20260529085659_normalize_po_number_to_pr_pattern.up.sql b/internal/database/migrations/20260529085659_normalize_po_number_to_pr_pattern.up.sql new file mode 100644 index 00000000..296d5b1b --- /dev/null +++ b/internal/database/migrations/20260529085659_normalize_po_number_to_pr_pattern.up.sql @@ -0,0 +1,87 @@ +BEGIN; + +-- ============================================================ +-- Normalize purchases.po_number agar mengikuti pr_number (swap prefix). +-- Contoh: pr_number='PR-LTI-0050' -> po_number='PO-LTI-0050' +-- +-- Konteks: sebelumnya pr_number dan po_number punya counter sequential +-- terpisah (lihat purchase.repository.go NextPrNumber / NextPoNumber yang +-- dihapus seiring migration ini), sehingga selalu diverge. Setelah +-- perubahan code (ApproveManagerPurchase derive PO dari PR), historis +-- perlu di-backfill supaya konsisten. +-- +-- Juga update expenses.po_number (snapshot dari expense_bridge.go) +-- supaya konsisten dengan purchases. +-- +-- Constraint uq_purchases_po_number adalah NOT DEFERRABLE (per-row check), +-- jadi single UPDATE bulk gagal di swap-conflict (contoh: row A mau jadi +-- 'PO-LTI-0700' tapi row B masih punya 'PO-LTI-0700' -> error 23505). +-- Solusi: capture target ke temp table, NULL dulu, baru set nilai derived. +-- +-- IRREVERSIBLE: nilai po_number lama (counter-based) tidak di-backup. +-- Kalau ada kegagalan di tengah, COMMIT tidak terjadi -> ROLLBACK otomatis. +-- ============================================================ + +-- 1. Capture target IDs (snapshot rencana update — sebelum perubahan apapun) +CREATE TEMP TABLE _purchases_po_normalize_ids ON COMMIT DROP AS +SELECT id +FROM purchases +WHERE po_number IS NOT NULL + AND pr_number LIKE 'PR-LTI-%' + AND po_number <> REPLACE(pr_number, 'PR-LTI-', 'PO-LTI-'); + +-- 2. Update expenses DULU — join via current po_number masih valid sebelum step 3-4 +UPDATE expenses e +SET po_number = REPLACE(p.pr_number, 'PR-LTI-', 'PO-LTI-') +FROM purchases p +JOIN _purchases_po_normalize_ids n ON n.id = p.id +WHERE e.po_number = p.po_number + AND e.po_number IS NOT NULL + AND e.po_number <> ''; + +-- 3. NULL-kan purchases.po_number untuk target — lepas constraint conflict +UPDATE purchases +SET po_number = NULL +WHERE id IN (SELECT id FROM _purchases_po_normalize_ids); + +-- 4. Set nilai derived dari pr_number (sekarang aman karena slot lama sudah NULL) +UPDATE purchases p +SET po_number = REPLACE(p.pr_number, 'PR-LTI-', 'PO-LTI-') +FROM _purchases_po_normalize_ids n +WHERE p.id = n.id; + +-- 5. Sanity check — fail (auto-rollback) kalau masih ada mismatch +DO $$ +DECLARE + v_mismatch_purchases INT; + v_mismatch_expenses INT; + v_target_count INT; +BEGIN + SELECT COUNT(*) INTO v_target_count FROM _purchases_po_normalize_ids; + + SELECT COUNT(*) INTO v_mismatch_purchases + FROM purchases + WHERE po_number IS NOT NULL + AND pr_number LIKE 'PR-LTI-%' + AND po_number <> REPLACE(pr_number, 'PR-LTI-', 'PO-LTI-'); + + IF v_mismatch_purchases > 0 THEN + RAISE EXCEPTION 'Normalize failed: % purchases rows still have mismatched po_number', v_mismatch_purchases; + END IF; + + SELECT COUNT(*) INTO v_mismatch_expenses + FROM expenses e + JOIN purchases p ON e.po_number = p.po_number + WHERE p.pr_number LIKE 'PR-LTI-%' + AND e.po_number IS NOT NULL + AND e.po_number <> '' + AND e.po_number <> REPLACE(p.pr_number, 'PR-LTI-', 'PO-LTI-'); + + IF v_mismatch_expenses > 0 THEN + RAISE EXCEPTION 'Normalize failed: % expenses rows still have mismatched po_number', v_mismatch_expenses; + END IF; + + RAISE NOTICE 'Normalize complete: % purchases rows updated', v_target_count; +END $$; + +COMMIT; diff --git a/internal/modules/purchases/repositories/purchase.repository.go b/internal/modules/purchases/repositories/purchase.repository.go index 1504f0f1..c8ae17e9 100644 --- a/internal/modules/purchases/repositories/purchase.repository.go +++ b/internal/modules/purchases/repositories/purchase.repository.go @@ -24,7 +24,6 @@ type PurchaseRepository interface { UpdateReceivingDetails(ctx context.Context, purchaseID uint, updates []PurchaseReceivingUpdate) error DeleteItems(ctx context.Context, purchaseID uint, itemIDs []uint) error NextPrNumber(ctx context.Context, tx *gorm.DB) (string, error) - NextPoNumber(ctx context.Context, tx *gorm.DB) (string, error) BackfillProjectFlockKandang(ctx context.Context, purchaseID uint) error SoftDeleteByProjectFlockKandangIDs(ctx context.Context, projectFlockKandangIDs []uint) error GetItemsByProjectFlockID(ctx context.Context, projectFlockID uint) ([]entity.PurchaseItem, error) @@ -369,9 +368,8 @@ func (r *PurchaseRepositoryImpl) NextPrNumber(ctx context.Context, tx *gorm.DB) return r.generateSequentialNumber(ctx, tx, "pr_number", utils.PurchasePRNumberPrefix, utils.PurchaseNumberPadding) } -func (r *PurchaseRepositoryImpl) NextPoNumber(ctx context.Context, tx *gorm.DB) (string, error) { - return r.generateSequentialNumber(ctx, tx, "po_number", utils.PurchasePONumberPrefix, utils.PurchaseNumberPadding) -} +// NOTE: NextPoNumber dihapus per migration 20260529143940 — po_number sekarang +// di-derive dari pr_number (swap prefix) via derivePoFromPr di purchase.service.go. func (r *PurchaseRepositoryImpl) generateSequentialNumber(ctx context.Context, tx *gorm.DB, column, prefix string, padding int) (string, error) { db := tx diff --git a/internal/modules/purchases/services/purchase.service.go b/internal/modules/purchases/services/purchase.service.go index 5f2253df..3d3125fe 100644 --- a/internal/modules/purchases/services/purchase.service.go +++ b/internal/modules/purchases/services/purchase.service.go @@ -779,8 +779,7 @@ func (s *purchaseService) ApproveManagerPurchase(c *fiber.Ctx, id uint, req *val transactionErr := s.PurchaseRepo.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error { updateData := map[string]any{} if !hasExistingPO { - repoTx := rPurchase.NewPurchaseRepository(tx) - code, err := repoTx.NextPoNumber(c.Context(), tx) + code, err := derivePoFromPr(purchase.PrNumber) if err != nil { return err } @@ -2513,6 +2512,18 @@ func parseApprovalActionInput(raw string) (entity.ApprovalAction, error) { } } +// derivePoFromPr menghasilkan po_number dari pr_number dengan swap prefix. +// Contoh: "PR-LTI-0050" -> "PO-LTI-0050". Mengembalikan error kalau pr_number +// tidak diawali prefix standar — caller harus memastikan PR sudah valid. +func derivePoFromPr(prNumber string) (string, error) { + trimmed := strings.TrimSpace(prNumber) + if !strings.HasPrefix(trimmed, utils.PurchasePRNumberPrefix) { + return "", fmt.Errorf("invalid pr_number %q: missing prefix %q", trimmed, utils.PurchasePRNumberPrefix) + } + suffix := strings.TrimPrefix(trimmed, utils.PurchasePRNumberPrefix) + return utils.PurchasePONumberPrefix + suffix, nil +} + func (s *purchaseService) rejectAndReload( c *fiber.Ctx, step approvalutils.ApprovalStep,