diff --git a/packages/procurement/CHARTER.md b/packages/procurement/CHARTER.md index c61b9e8..5f0c00e 100644 --- a/packages/procurement/CHARTER.md +++ b/packages/procurement/CHARTER.md @@ -28,7 +28,7 @@ portals — those remain pluggable. | State-machine driven lifecycle | PR `draft→submitted→approved→converted` | | Threshold-based approval routing | `pr_approval_required.flow` fires at ≥ $5k | | Auto-record-creation flow | `pr_to_po_convert.flow` drafts the PO | -| 3-way-match rollup | `procurement_receipt.hook.ts` updates PO.received_amount | +| 2-way match signal | PO `match_status` formula (awaiting / partial / matched) from `received_amount` vs `total_amount`. `received_amount` is a stored header field maintained at the top level (client/seed) — a cross-object rollup hook from receipts would re-enter the QuickJS sandbox and crash, so it is NOT done in-hook (see `src/hooks/index.ts`). Full PO↔receipt↔invoice 3-way match is a fork. | | Scheduler-driven alerts | `po_overdue.flow` flags missed deliveries | | Internationalization | English + 简体中文 ship out of the box | | Realistic seed data | 4 vendors, 5 PRs, 4 POs, 3 receipts covering all states | diff --git a/packages/procurement/src/dashboards/spend_at_a_glance.dashboard.ts b/packages/procurement/src/dashboards/spend_at_a_glance.dashboard.ts index d1b4013..2747556 100644 --- a/packages/procurement/src/dashboards/spend_at_a_glance.dashboard.ts +++ b/packages/procurement/src/dashboards/spend_at_a_glance.dashboard.ts @@ -101,7 +101,25 @@ export const SpendAtAGlanceDashboard: Dashboard = { showLegend: true, showDataLabels: false, }, - layout: { x: 0, y: 7, w: 12, h: 5 }, + layout: { x: 0, y: 7, w: 7, h: 5 }, + }, + { + id: 'top_vendors_by_spend', + dataset: 'procurement_order_metrics', + dimensions: ['vendor'], + values: ['sum_total_amount'], + title: 'Top Vendors by Spend', + type: 'bar', + chartConfig: { + type: 'bar', + xAxis: { field: 'vendor', showGridLines: false, logarithmic: false }, + yAxis: [ + { field: 'sum_total_amount', format: '$0,0', showGridLines: true, logarithmic: false }, + ], + showLegend: false, + showDataLabels: false, + }, + layout: { x: 7, y: 7, w: 5, h: 5 }, }, ], }; diff --git a/packages/procurement/src/datasets/procurement_order.dataset.ts b/packages/procurement/src/datasets/procurement_order.dataset.ts index 32878e1..b8ffdae 100644 --- a/packages/procurement/src/datasets/procurement_order.dataset.ts +++ b/packages/procurement/src/datasets/procurement_order.dataset.ts @@ -15,6 +15,8 @@ export const ProcurementOrderDataset = defineDataset({ type: 'date', dateGranularity: 'month', }, + { name: 'vendor', label: 'vendor', field: 'vendor', type: 'string' }, + { name: 'status', label: 'status', field: 'status', type: 'string' }, ], measures: [ { name: 'order_count', label: 'order_count', aggregate: 'count' }, diff --git a/packages/procurement/src/flows/pr_to_po_convert.flow.ts b/packages/procurement/src/flows/pr_to_po_convert.flow.ts index edc4477..3e308ed 100644 --- a/packages/procurement/src/flows/pr_to_po_convert.flow.ts +++ b/packages/procurement/src/flows/pr_to_po_convert.flow.ts @@ -56,6 +56,7 @@ export const PRToPOConvertFlow: Flow = { owner: '{pr.requester}', total_amount: '{pr.estimated_amount}', received_amount: 0, + cost_center: '{pr.cost_center}', order_date: 'today()', expected_delivery: '{pr.needed_by}', notes: diff --git a/packages/procurement/src/objects/procurement_order.object.ts b/packages/procurement/src/objects/procurement_order.object.ts index fda9af9..124619b 100644 --- a/packages/procurement/src/objects/procurement_order.object.ts +++ b/packages/procurement/src/objects/procurement_order.object.ts @@ -92,6 +92,18 @@ export const PurchaseOrder = ObjectSchema.create({ group: 'commercial', expression: F`record.received_amount != null && record.total_amount != null && record.received_amount >= record.total_amount`, }), + match_status: Field.formula({ + label: 'Match Status', + group: 'commercial', + description: 'PO↔receipt match: awaiting (nothing in), partial, or matched (fully received).', + expression: F`record.total_amount != null && record.received_amount != null && record.received_amount >= record.total_amount ? "matched" : (record.received_amount != null && record.received_amount > 0 ? "partial" : "awaiting")`, + }), + cost_center: Field.text({ + label: 'Cost Center', + maxLength: 60, + group: 'commercial', + description: 'Carried from the originating PR so spend can be reported by cost center.', + }), order_date: Field.date({ label: 'Order Date', required: true, @@ -138,7 +150,14 @@ export const PurchaseOrder = ObjectSchema.create({ ], titleFormat: tmpl`{{record.po_number}}`, - compactLayout: ['po_number', 'vendor', 'total_amount', 'status', 'expected_delivery'], + compactLayout: [ + 'po_number', + 'vendor', + 'total_amount', + 'status', + 'match_status', + 'expected_delivery', + ], validations: [ { diff --git a/packages/procurement/src/objects/procurement_vendor.object.ts b/packages/procurement/src/objects/procurement_vendor.object.ts index 4963d4d..9375f05 100644 --- a/packages/procurement/src/objects/procurement_vendor.object.ts +++ b/packages/procurement/src/objects/procurement_vendor.object.ts @@ -59,6 +59,20 @@ export const Vendor = ObjectSchema.create({ { label: 'Upfront', value: 'upfront' }, ], }), + risk_tier: Field.select({ + label: 'Risk Tier', + description: 'Supplier risk rating — drives review cadence and approval scrutiny.', + options: [ + { label: 'Low', value: 'low', color: '#10B981', default: true }, + { label: 'Medium', value: 'medium', color: '#F59E0B' }, + { label: 'High', value: 'high', color: '#EF4444' }, + ], + }), + is_preferred: Field.boolean({ + label: 'Preferred Vendor', + defaultValue: false, + description: 'Preferred suppliers should be favoured when sourcing.', + }), country: Field.text({ label: 'Country', maxLength: 80 }), website: Field.text({ label: 'Website', maxLength: 200 }), primary_contact_name: Field.text({ label: 'Primary Contact', maxLength: 120 }), @@ -80,5 +94,5 @@ export const Vendor = ObjectSchema.create({ titleFormat: tmpl`{{record.name}}`, displayNameField: 'name', - compactLayout: ['name', 'vendor_code', 'category', 'status'], + compactLayout: ['name', 'vendor_code', 'category', 'status', 'risk_tier', 'is_preferred'], }); diff --git a/packages/procurement/src/sharing/index.ts b/packages/procurement/src/sharing/index.ts index add57d4..8520036 100644 --- a/packages/procurement/src/sharing/index.ts +++ b/packages/procurement/src/sharing/index.ts @@ -1,11 +1,18 @@ // Copyright (c) 2026 ObjectStack contributors. Apache-2.0 license. /** - * Two-tier role hierarchy. Procurement Admin inherits Buyer's grants. + * Role hierarchy. Procurement Admin inherits Buyer's grants. `finance_approver` + * is the recipient of the high-value PR approval alert (pr_approval_required + * flow) — kept distinct from the buyer who raised the request. */ export const RoleHierarchy = { roles: [ { name: 'procurement_admin', label: 'Procurement Admin', parentRole: null as string | null }, { name: 'procurement_buyer', label: 'Buyer', parentRole: 'procurement_admin' as string | null }, + { + name: 'finance_approver', + label: 'Finance Approver', + parentRole: 'procurement_admin' as string | null, + }, ], }; diff --git a/packages/procurement/src/translations/en.ts b/packages/procurement/src/translations/en.ts index 8d726f0..f8c90aa 100644 --- a/packages/procurement/src/translations/en.ts +++ b/packages/procurement/src/translations/en.ts @@ -44,6 +44,11 @@ export const en: TranslationData = { upfront: 'Upfront', }, }, + risk_tier: { + label: 'Risk Tier', + options: { low: 'Low', medium: 'Medium', high: 'High' }, + }, + is_preferred: { label: 'Preferred Vendor' }, country: { label: 'Country' }, website: { label: 'Website' }, primary_contact_name: { label: 'Primary Contact' }, @@ -135,6 +140,8 @@ export const en: TranslationData = { }, }, is_fully_received: { label: 'Fully Received' }, + match_status: { label: 'Match Status' }, + cost_center: { label: 'Cost Center' }, order_date: { label: 'Order Date' }, expected_delivery: { label: 'Expected Delivery' }, actual_delivery: { label: 'Actual Delivery' }, diff --git a/packages/procurement/src/translations/zh-CN.ts b/packages/procurement/src/translations/zh-CN.ts index 05d6679..042c5bc 100644 --- a/packages/procurement/src/translations/zh-CN.ts +++ b/packages/procurement/src/translations/zh-CN.ts @@ -44,6 +44,11 @@ export const zhCN: TranslationData = { upfront: '预付', }, }, + risk_tier: { + label: '风险等级', + options: { low: '低', medium: '中', high: '高' }, + }, + is_preferred: { label: '优选供应商' }, country: { label: '国家/地区' }, website: { label: '网站' }, primary_contact_name: { label: '主要联系人' }, @@ -131,6 +136,8 @@ export const zhCN: TranslationData = { }, }, is_fully_received: { label: '已全部收货' }, + match_status: { label: '匹配状态' }, + cost_center: { label: '成本中心' }, order_date: { label: '下单日期' }, expected_delivery: { label: '预计到货' }, actual_delivery: { label: '实际到货' },