Lewati ke konten utama

Plan — Vendor Expansion + PO PDF readiness (Ship-11)

Context

The example Purchase Order PDF (IKN/PROC/IV/2026/001 — CoherentPlus Sdn Bhd, Malaysia, USD 19,500) revealed that the merchant.vendors table lacks several fields needed to produce an official PO document for a foreign vendor:

  • SWIFT/BIC code — required for international wire transfers
  • Bank address — some correspondent banks require the branch address
  • Tax registration other than NPWP — MY vendors use SST, EU vendors use VAT, SG vendors use GST-reg, etc.
  • Website — printed on the PO header for dual verification
  • Vendor category — manufacturer / distributor / service / reseller classification for Purchasing Report segmentation

Also for the Ship-11.b PDF engine, before the HTML→PDF generator is built the source data must be complete first — so Ship-11.a focuses on schema + form expansion, Ship-11.b handles PDF rendering.

Decisions about data sources:

  • SWIFT: no master ref table — reason: 8–11-char ISO-9362 codes, rarely change per vendor, vary per bank branch. Operator inputs manually with format validation regex ^[A-Z0-9]{8}([A-Z0-9]{3})?$.
  • Secondary tax reg: also not referenced — SST/VAT/GST is too varied across jurisdictions to enum. Two free-text columns tax_reg_secondary_label + tax_reg_secondary_value.
  • Vendor category: a CHECK constraint at the DB level (manufacturer | distributor | service | reseller | other), not a ref table — the set is small and stable.
  • Primary tax ID (vendors.npwp) is still validated through the existing public.ref_country.tax_id_regex (db_reference migration 009).

Ship-11.a — Vendor expansion (schema + form)

Status (2026-06-23): shipped. Migration 055 applied; dashboard_vendors.go and the Flutter vendor form/mapper carry the new fields. Ship-11.b/c/d remain planned.

Migration 055 — merchant_database/db_kesles_merchant/migrations/legacy/055_vendors_expansion_for_po_pdf.sql

alter table merchant.vendors
add column if not exists swift_code varchar(15),
add column if not exists bank_address text,
add column if not exists tax_reg_secondary_label varchar(40),
add column if not exists tax_reg_secondary_value varchar(80),
add column if not exists website varchar(200),
add column if not exists category varchar(24);

alter table merchant.vendors
add constraint chk_vendors_category
check (category is null or category in (
'manufacturer','distributor','service','reseller','other'
));

create index if not exists idx_vendors_category
on merchant.vendors (category)
where deleted_at is null;

comment on column merchant.vendors.swift_code is
'Bank SWIFT/BIC code (ISO-9362). 8 or 11 chars, uppercase. Required for international wires.';
comment on column merchant.vendors.tax_reg_secondary_label is
'Label for a second tax registration (e.g. SST, VAT, GST) — free-text because variants per jurisdiction are too many to enum.';

All columns nullable — domestic vendors don't need SWIFT / bank_address / tax_reg_secondary. No backfill required.

Backend — services/dashboard_api/internal/app/dashboard_vendors.go

Field additions in:

  • VendorRecord struct (response JSON)
  • vendorPayload struct (input parse)
  • listVendors query (SELECT + scan)
  • createVendor / updateVendor INSERT/UPDATE statements

Validation in normalizeVendorPayload:

  • SWIFT: trim + uppercase + match ^[A-Z0-9]{8}([A-Z0-9]{3})?$ if non-empty → 422 if invalid
  • Category: if non-empty must be one of the 5 enums; the DB CHECK constraint is the safety net
  • Website: trim; not forced to start with http:// — operator may input kesles.com directly

Frontend — VendorItem entity + mapper + form dialog

  • VendorItem (entity): +swiftCode, bankAddress, taxRegSecondaryLabel, taxRegSecondaryValue, website, category
  • VendorInput (payload): same
  • Mapper home_dashboard_mapper.dart: add parsing for swift_code, bank_address, etc.
  • vendor_form_dialog.dart: add fields in the appropriate sections
    • Banking section: after "Bank Account Number" → SWIFT / BIC field (uppercase input) + Bank Address (2-line textarea)
    • Legal & Tax section: after NPWP → two side-by-side fields: Tax Reg Label (e.g. "SST", "VAT") + Tax Reg Value
    • Company section: add Website + Category (dropdown: Manufacturer / Distributor / Service / Reseller / Other)

Verification

  1. Run migration 055
  2. go build ./... in services/dashboard_api clean
  3. flutter analyze lib/dashboard/ clean
  4. Manual: open Vendor panel → Create new vendor → fill all the new fields → Save → reopen → verify the data persists
  5. Smoke: create a vendor with an invalid SWIFT (FOO) → expect 422 from the backend
  6. Smoke: select category "manufacturer" → the table row shows the badge

Estimate: 0.5 day (small schema, additive UI).


Ship-11.b — Multi-currency + Payment milestones + Bill-to/Ship-to/signer snapshot on PO

Context

The example IKN/PROC/IV/2026/001 PDF is USD with a 50/50 payment milestone and a CEO signer snapshot. Not yet supported in the current PO schema.

Migration 056 — PO enrichment

  • purchase_orders.currency char(3) not null default 'IDR' + CHECK in ('IDR','USD','SGD','MYR','EUR') (expand as needed)
  • purchase_orders.exchange_rate_to_base numeric(18,6) — rate dari currency ke base currency company (default IDR). Snapshot at approval; NULL kalau currency = base currency. Pakai purchase_orders.base_currency (atau ambil dari company_profile.base_currency) untuk eksplisit, hindari hardcode IDR di nama kolom.
  • purchase_orders.vendor_quotation_ref varchar(100) — printed on the PDF header (e.g. CPLUS-ADHOC-IKN26-001)
  • Bill To / Ship To snapshots from company_profile:
    • bill_to_name, bill_to_address_line, bill_to_attn
    • ship_to_name, ship_to_address_line, ship_to_attn
  • Signer snapshots (also planned in Ship-10 — combined here):
    • signer_user_id uuid, signer_name, signer_title, signer_signature_url, signed_at
  • Child table purchase_order_payment_milestones:
    id, purchase_order_id fk, line_no, label, amount, percent_basis, due_condition, due_date

Backend

  • approvePurchaseOrder handler → copy company_profile fields into the snapshot columns
  • postMilestones — create/update via array on PO PATCH
  • Validation: sum(milestone.percent) = 100 or sum(milestone.amount) = total_amount

Frontend

  • PO form dialog: Currency dropdown, Exchange Rate input (auto-disabled if IDR), Vendor Quotation Ref
  • Payment Milestones section: repeatable row, template-fill from vendor.default_payment_schedule (optional Ship-11.a.2 field)
  • PO detail dialog: add Bill To / Ship To / Signer snapshot columns (read-only after approval)

Estimate: 2-3 days.


Ship-11.c — PDF Generator + Download button

Engine choice

Options:

  1. Gotenberg (Docker sidecar) — standard HTML/CSS, headless Chrome, sandbox security, scalable. Recommended.
  2. unidoc/unipdf — native Go, but commercial license.
  3. jung-kurt/gofpdf — native Go, free, but layout HTML-less (manual cell positioning).
  4. chromedp — in-process Chrome, no sidecar, but memory-heavy.

Recommendation: Gotenberg. Deploy as services/gotenberg in the docker-compose, reachable at http://gotenberg:3000. The PO handler converts the HTML template → multipart POST → byte stream.

Template

File: merchant_core_api/internal/pdf/templates/purchase_order.html

Structure:

  • Header: logo + company address (from company_profile) + "PURCHASE ORDER" title + number
  • Metadata: PO date, quotation ref, payment terms (the first one in milestones)
  • Vendor block (Ship From): legal name, attn, address, email
  • Bill To block: from the PO snapshot
  • Ship To block: from the PO snapshot
  • Items table: description, qty, unit, unit price, total (currency-aware formatting)
  • Terms & Bank: incoterm, lead time (from vendor default), payment milestones list, bank details (Name / Account Name / Account No + currency / SWIFT / Bank Address)
  • Subtotal + TOTAL AMOUNT
  • Footer: signature block (signer image + name + title + company name)

Endpoint

POST /api/dashboard/purchasing/purchase-orders/{id}/pdf/generate
→ regenerate from current data, upload to MinIO `company-documents/purchase-orders/PO-YYYY-NNNN-v{updated_epoch}.pdf`
→ return { pdf_url, object_key, generated_at }
GET /api/dashboard/purchasing/purchase-orders/{id}/pdf
→ if a cached URL exists + updated_at matches → return cached
→ otherwise regenerate

RBAC: purchaseRead for generate (every role that reads a PO may generate — PDF is read-only).

Frontend

  • PO detail dialog: Download PDF button (download icon) → hit the endpoint → open the URL in a new tab
  • Visible only when PO status >= approved (no PDF for draft)
  • After send: button auto-attaches the PDF to the vendor email (continues in Ship-11.d)

Estimate: 2-3 days (including the Gotenberg setup).


Ship-11.d — Send integration (email + WhatsApp + audit)

New table — purchase_order_send_events

id, purchase_order_id fk, channel (email|whatsapp), recipient, status (pending|sent|failed),
error_message, provider_message_id, payload_json, created_at

/send flow

  1. Validate PO.status in ('approved').
  2. Regenerate the PDF (call 11.c).
  3. Fire the email via core-api's existing emailSender (internal RPC dashboard-api→core-api).
  4. Optionally fire WhatsApp (Meta template with 1 param: PDF download link).
  5. Insert an event row per channel.
  6. Flip PO status → 'sent', stamp sent_at = now() (existing).

Retry

  • If the provider returns a non-4xx error, insert an event with status='pending' + enqueue a retry (a chronos cron on dashboard-api or a goroutine worker — stretch goal).

Estimate: 2 days.


Rollout order

  1. Ship-11.a lands first (schema + form) — vendor data complete before the PDF can be rendered.
  2. Ship-11.b (PO enrichment) — the PDF needs currency + milestones + Bill/Ship/Signer snapshot.
  3. Ship-11.c (PDF engine + template + endpoint + Download button).
  4. Ship-11.d (Send integration) last — highest risk (email/WA provider, retry logic).

Total: 7-8 person-days for the full Ship-11.


Open questions

  1. Do we also need multi-currency on purchase_invoices? (Likely yes — bundle into Ship-11.b.)
  2. The CHECKed currency list — is IDR/USD/SGD/MYR/EUR enough, or wider?
  3. Exchange rate source: manual input vs auto-fetch from an API (BI/Google/Fixer)? Manual for v1, auto later.
  4. Gotenberg deployment: sidecar in the existing docker-compose or a separate k8s?
  5. PDF versioning: regenerate on every PO change (timestamp -v{epoch} in the object key) or overwrite? Recommendation: versioning so the audit trail is preserved.