@viccydev/pi-fpa 0.9.4 → 0.9.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/extensions/fpa-dashboard/calibration-projector.ts +359 -0
- package/extensions/fpa-dashboard/compat-publisher.ts +89 -3
- package/extensions/fpa-dashboard/decision-ledger-projector.ts +323 -0
- package/extensions/fpa-dashboard/decision-package.ts +437 -0
- package/extensions/fpa-dashboard/finance-projector.ts +375 -0
- package/extensions/fpa-dashboard/index.ts +186 -0
- package/extensions/fpa-dashboard/projector.ts +26 -1
- package/extensions/fpa-dashboard/schema.ts +57 -1
- package/extensions/fpa-dashboard/strategy-decision.ts +72 -1
- package/extensions/fpa-data/csvsource.ts +296 -0
- package/extensions/fpa-data/registry.ts +324 -0
- package/extensions/fpa-data/runtime.ts +21 -1
- package/package.json +3 -3
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
import type { DashboardModuleBuild } from "./module-publisher.ts";
|
|
2
|
+
import type { DashboardWidget, TableCell, TableDataset } from "./projector.ts";
|
|
3
|
+
import type { DecisionEvent, DecisionPackage } from "./decision-package.ts";
|
|
4
|
+
|
|
5
|
+
// ============================================================================
|
|
6
|
+
// The decision ledger module.
|
|
7
|
+
//
|
|
8
|
+
// Answers "where did the approved decisions get to" — the question the
|
|
9
|
+
// dashboard could not answer at all before, because confirming a strategy left
|
|
10
|
+
// nothing behind to track.
|
|
11
|
+
//
|
|
12
|
+
// Spend is not computed here. It arrives measured, from the execution receipt
|
|
13
|
+
// or the actuals source, and stays null when nothing has measured it yet. A
|
|
14
|
+
// decision showing $0 spent looks like a decision nobody acted on, which is a
|
|
15
|
+
// very different thing from one nobody has reported on.
|
|
16
|
+
// ============================================================================
|
|
17
|
+
|
|
18
|
+
const EVENT_TYPE_LABELS = {
|
|
19
|
+
approved: "批准",
|
|
20
|
+
parameters_locked: "参数锁定",
|
|
21
|
+
task_accepted: "任务接受",
|
|
22
|
+
task_completed: "任务完成",
|
|
23
|
+
parameter_adjusted: "参数调整",
|
|
24
|
+
threshold_breached: "阈值触发",
|
|
25
|
+
closed: "关闭",
|
|
26
|
+
} as const;
|
|
27
|
+
|
|
28
|
+
const TASK_STATUS_LABELS = {
|
|
29
|
+
not_started: "未开始",
|
|
30
|
+
in_progress: "进行中",
|
|
31
|
+
completed: "已完成",
|
|
32
|
+
adjusted: "已调整",
|
|
33
|
+
synced: "已同步",
|
|
34
|
+
configured: "已配置",
|
|
35
|
+
not_completed: "未完成",
|
|
36
|
+
} as const;
|
|
37
|
+
|
|
38
|
+
export interface DecisionSpend {
|
|
39
|
+
decision_id: string;
|
|
40
|
+
/** Measured spend to date, or null when nothing has measured it. */
|
|
41
|
+
actual_spend_usd: number | null;
|
|
42
|
+
/** 0..1 execution progress, or null when unknown. */
|
|
43
|
+
progress?: number | null;
|
|
44
|
+
/** Where the measurement came from, shown as evidence. */
|
|
45
|
+
source?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface DecisionLedgerInput {
|
|
49
|
+
packages: DecisionPackage[];
|
|
50
|
+
events: Record<string, DecisionEvent[]>;
|
|
51
|
+
spend?: DecisionSpend[];
|
|
52
|
+
locale?: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface DecisionLedgerProjection {
|
|
56
|
+
module: DashboardModuleBuild;
|
|
57
|
+
/** Decisions whose measured spend has passed their approved threshold. */
|
|
58
|
+
breaches: Array<{ decision_id: string; title: string; over_pct: number }>;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function currency(value: number | null, code: string, locale: string): string | null {
|
|
62
|
+
if (value === null) return null;
|
|
63
|
+
return new Intl.NumberFormat(locale, { style: "currency", currency: code, maximumFractionDigits: 0 }).format(value);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function percent(value: number | null, locale: string): string | null {
|
|
67
|
+
if (value === null) return null;
|
|
68
|
+
return new Intl.NumberFormat(locale, { style: "percent", maximumFractionDigits: 1 }).format(value);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function toned(value: string | null, tone?: "positive" | "negative" | "warning" | "neutral"): TableCell {
|
|
72
|
+
return tone ? { value, tone } : value;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Over-budget fraction, or null when it cannot be computed.
|
|
77
|
+
*
|
|
78
|
+
* Returns null rather than 0 when the budget is missing or zero: "we cannot
|
|
79
|
+
* tell whether this is over budget" must not render as "this is exactly on
|
|
80
|
+
* budget", which is what a 0 here would claim.
|
|
81
|
+
*/
|
|
82
|
+
function overBudgetFraction(pkg: DecisionPackage, spend: number | null): number | null {
|
|
83
|
+
if (spend === null || pkg.approved_budget_usd === null || pkg.approved_budget_usd <= 0) return null;
|
|
84
|
+
return spend / pkg.approved_budget_usd - 1;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function summaryTable(
|
|
88
|
+
packages: DecisionPackage[],
|
|
89
|
+
spendById: Map<string, DecisionSpend>,
|
|
90
|
+
locale: string,
|
|
91
|
+
): TableDataset {
|
|
92
|
+
return {
|
|
93
|
+
label: "决策归档包",
|
|
94
|
+
description: "已批准决策的批准预算、实际支出与执行进度",
|
|
95
|
+
columns: [
|
|
96
|
+
{ key: "decision", label: "决策包" },
|
|
97
|
+
{ key: "version", label: "版本" },
|
|
98
|
+
{ key: "approver", label: "批准人" },
|
|
99
|
+
{ key: "owner", label: "负责人" },
|
|
100
|
+
{ key: "budget", label: "批准预算", align: "right" },
|
|
101
|
+
{ key: "actual", label: "实际支出", align: "right" },
|
|
102
|
+
{ key: "variance", label: "偏差", align: "right" },
|
|
103
|
+
{ key: "progress", label: "执行进度", align: "right" },
|
|
104
|
+
{ key: "status", label: "状态" },
|
|
105
|
+
],
|
|
106
|
+
rows: packages.map((pkg) => {
|
|
107
|
+
const spend = spendById.get(pkg.decision_id);
|
|
108
|
+
const actual = spend?.actual_spend_usd ?? null;
|
|
109
|
+
const over = overBudgetFraction(pkg, actual);
|
|
110
|
+
const breached = over !== null && over > pkg.budget_threshold_pct;
|
|
111
|
+
return {
|
|
112
|
+
decision: `${pkg.decision_id} · ${pkg.title}`,
|
|
113
|
+
version: pkg.version,
|
|
114
|
+
approver: pkg.approver_role,
|
|
115
|
+
owner: pkg.owner,
|
|
116
|
+
budget: currency(pkg.approved_budget_usd, pkg.reporting_currency, locale),
|
|
117
|
+
actual: currency(actual, pkg.reporting_currency, locale),
|
|
118
|
+
variance: toned(
|
|
119
|
+
over === null ? null : `${over > 0 ? "+" : ""}${percent(over, locale)}`,
|
|
120
|
+
over === null ? undefined : breached ? "negative" : over > 0 ? "warning" : "positive",
|
|
121
|
+
),
|
|
122
|
+
progress: spend?.progress === null || spend?.progress === undefined ? null : percent(spend.progress, locale),
|
|
123
|
+
status: toned(
|
|
124
|
+
breached ? "超阈值" : spend?.progress === 1 ? "已完成" : "执行中",
|
|
125
|
+
breached ? "negative" : spend?.progress === 1 ? "positive" : "neutral",
|
|
126
|
+
),
|
|
127
|
+
};
|
|
128
|
+
}),
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function approvalsTable(packages: DecisionPackage[]): TableDataset {
|
|
133
|
+
return {
|
|
134
|
+
label: "审批留痕",
|
|
135
|
+
description: "每个决策包的审批链与时间戳",
|
|
136
|
+
columns: [
|
|
137
|
+
{ key: "decision", label: "决策包" },
|
|
138
|
+
{ key: "step", label: "步骤", align: "right" },
|
|
139
|
+
{ key: "role", label: "角色" },
|
|
140
|
+
{ key: "approver", label: "审批人" },
|
|
141
|
+
{ key: "action", label: "动作" },
|
|
142
|
+
{ key: "at", label: "时间" },
|
|
143
|
+
{ key: "comment", label: "备注" },
|
|
144
|
+
],
|
|
145
|
+
rows: packages.flatMap((pkg) =>
|
|
146
|
+
pkg.approvals.map((approval) => ({
|
|
147
|
+
decision: pkg.decision_id,
|
|
148
|
+
step: String(approval.step_order),
|
|
149
|
+
role: approval.role,
|
|
150
|
+
approver: approval.approver,
|
|
151
|
+
action: approval.action === "approved" ? "批准" : approval.action === "acknowledged" ? "确认" : "驳回",
|
|
152
|
+
at: approval.decided_at,
|
|
153
|
+
comment: approval.comment ?? null,
|
|
154
|
+
})),
|
|
155
|
+
),
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function tasksTable(packages: DecisionPackage[]): TableDataset {
|
|
160
|
+
return {
|
|
161
|
+
label: "执行任务清单",
|
|
162
|
+
columns: [
|
|
163
|
+
{ key: "decision", label: "决策包" },
|
|
164
|
+
{ key: "task", label: "任务" },
|
|
165
|
+
{ key: "owner", label: "负责人" },
|
|
166
|
+
{ key: "due", label: "截止" },
|
|
167
|
+
{ key: "status", label: "状态" },
|
|
168
|
+
],
|
|
169
|
+
rows: packages.flatMap((pkg) =>
|
|
170
|
+
pkg.tasks.map((task) => ({
|
|
171
|
+
decision: pkg.decision_id,
|
|
172
|
+
task: task.task_name,
|
|
173
|
+
owner: task.owner,
|
|
174
|
+
due: task.due_date,
|
|
175
|
+
status: toned(
|
|
176
|
+
TASK_STATUS_LABELS[task.status],
|
|
177
|
+
task.status === "completed" ? "positive" : task.status === "not_completed" ? "negative" : "neutral",
|
|
178
|
+
),
|
|
179
|
+
})),
|
|
180
|
+
),
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function auditTable(packages: DecisionPackage[], events: Record<string, DecisionEvent[]>): TableDataset {
|
|
185
|
+
const rows = packages.flatMap((pkg) =>
|
|
186
|
+
(events[pkg.decision_id] ?? []).map((event) => ({
|
|
187
|
+
at: event.event_at,
|
|
188
|
+
decision: pkg.decision_id,
|
|
189
|
+
actor: `${event.actor}${event.actor_type === "system" ? " (系统)" : ""}`,
|
|
190
|
+
type: toned(
|
|
191
|
+
EVENT_TYPE_LABELS[event.event_type],
|
|
192
|
+
event.event_type === "threshold_breached" ? "negative" : event.event_type === "approved" ? "positive" : "neutral",
|
|
193
|
+
),
|
|
194
|
+
summary: event.summary,
|
|
195
|
+
reason: event.reason ?? null,
|
|
196
|
+
detail: event.change_detail ?? null,
|
|
197
|
+
})),
|
|
198
|
+
);
|
|
199
|
+
return {
|
|
200
|
+
label: "审计日志",
|
|
201
|
+
description: "谁在什么时候、因为什么、改了什么",
|
|
202
|
+
columns: [
|
|
203
|
+
{ key: "at", label: "时间" },
|
|
204
|
+
{ key: "decision", label: "决策包" },
|
|
205
|
+
{ key: "actor", label: "操作者" },
|
|
206
|
+
{ key: "type", label: "事件" },
|
|
207
|
+
{ key: "summary", label: "说明" },
|
|
208
|
+
{ key: "reason", label: "原因" },
|
|
209
|
+
{ key: "detail", label: "变更" },
|
|
210
|
+
],
|
|
211
|
+
// Newest first: the audit log is read to answer "what just happened".
|
|
212
|
+
rows: rows.sort((left, right) => String(right.at).localeCompare(String(left.at))),
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export function projectDecisionLedger(input: DecisionLedgerInput): DecisionLedgerProjection {
|
|
217
|
+
const locale = input.locale ?? "zh-CN";
|
|
218
|
+
const packages = input.packages;
|
|
219
|
+
const spendById = new Map((input.spend ?? []).map((entry) => [entry.decision_id, entry]));
|
|
220
|
+
const warnings: string[] = [];
|
|
221
|
+
|
|
222
|
+
const breaches: DecisionLedgerProjection["breaches"] = [];
|
|
223
|
+
let unmeasured = 0;
|
|
224
|
+
for (const pkg of packages) {
|
|
225
|
+
const actual = spendById.get(pkg.decision_id)?.actual_spend_usd ?? null;
|
|
226
|
+
if (actual === null) {
|
|
227
|
+
unmeasured += 1;
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
const over = overBudgetFraction(pkg, actual);
|
|
231
|
+
if (over !== null && over > pkg.budget_threshold_pct) {
|
|
232
|
+
breaches.push({ decision_id: pkg.decision_id, title: pkg.title, over_pct: over });
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// The limits belong to this module, next to the numbers they qualify.
|
|
237
|
+
if (unmeasured > 0) {
|
|
238
|
+
warnings.push(`${unmeasured} 个决策包尚无实际支出计量,其偏差与状态显示为缺失而非零。`);
|
|
239
|
+
}
|
|
240
|
+
for (const breach of breaches) {
|
|
241
|
+
warnings.push(`${breach.decision_id}「${breach.title}」实际支出超批准预算 ${(breach.over_pct * 100).toFixed(1)}%,已超过其阈值。`);
|
|
242
|
+
}
|
|
243
|
+
const missingBudget = packages.filter((pkg) => pkg.approved_budget_usd === null);
|
|
244
|
+
if (missingBudget.length > 0) {
|
|
245
|
+
warnings.push(`${missingBudget.length} 个决策包没有可追踪的批准预算,无法判断是否超支。`);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const measured = packages.length - unmeasured;
|
|
249
|
+
const widgets: DashboardWidget[] = [
|
|
250
|
+
{
|
|
251
|
+
id: "decision-active-count",
|
|
252
|
+
type: "stat",
|
|
253
|
+
span: "quarter",
|
|
254
|
+
dataset: "decision-active-count.json",
|
|
255
|
+
data: {
|
|
256
|
+
label: "进行中决策",
|
|
257
|
+
value: String(packages.filter((pkg) => (spendById.get(pkg.decision_id)?.progress ?? 0) < 1).length),
|
|
258
|
+
description: `共 ${packages.length} 个已批准决策包`,
|
|
259
|
+
},
|
|
260
|
+
},
|
|
261
|
+
{
|
|
262
|
+
id: "decision-breach-count",
|
|
263
|
+
type: "stat",
|
|
264
|
+
span: "quarter",
|
|
265
|
+
dataset: "decision-breach-count.json",
|
|
266
|
+
data: {
|
|
267
|
+
label: "需关注",
|
|
268
|
+
value: String(breaches.length),
|
|
269
|
+
...(breaches.length > 0
|
|
270
|
+
? { description: breaches.map((breach) => breach.decision_id).join("、") }
|
|
271
|
+
: {}),
|
|
272
|
+
...(measured === 0
|
|
273
|
+
? { footnote: "尚无实际支出计量,此计数仅覆盖已计量的决策包。" }
|
|
274
|
+
: {}),
|
|
275
|
+
delta: breaches.length > 0
|
|
276
|
+
? { direction: "up", label: `${breaches.length} 个超阈值`, sentiment: "negative" }
|
|
277
|
+
: { direction: "flat", label: "无超阈值", sentiment: "positive" },
|
|
278
|
+
},
|
|
279
|
+
},
|
|
280
|
+
{
|
|
281
|
+
id: "decision-measured-coverage",
|
|
282
|
+
type: "stat",
|
|
283
|
+
span: "quarter",
|
|
284
|
+
dataset: "decision-measured-coverage.json",
|
|
285
|
+
data: {
|
|
286
|
+
label: "支出计量覆盖",
|
|
287
|
+
value: packages.length === 0 ? null : `${measured}/${packages.length}`,
|
|
288
|
+
...(packages.length === 0 ? { missingReason: "还没有已批准的决策包。" } : {}),
|
|
289
|
+
...(packages.length > 0
|
|
290
|
+
? {
|
|
291
|
+
progress: {
|
|
292
|
+
fraction: measured / packages.length,
|
|
293
|
+
label: percent(measured / packages.length, locale) ?? "—",
|
|
294
|
+
tone: measured === packages.length ? "positive" : "warning",
|
|
295
|
+
},
|
|
296
|
+
}
|
|
297
|
+
: {}),
|
|
298
|
+
},
|
|
299
|
+
},
|
|
300
|
+
{ id: "decision-summary", type: "table", span: "full", dataset: "decision-summary.json", data: summaryTable(packages, spendById, locale) },
|
|
301
|
+
{ id: "decision-tasks", type: "table", span: "full", dataset: "decision-tasks.json", data: tasksTable(packages) },
|
|
302
|
+
{ id: "decision-approvals", type: "table", span: "full", dataset: "decision-approvals.json", data: approvalsTable(packages) },
|
|
303
|
+
{ id: "decision-audit", type: "table", span: "full", dataset: "decision-audit.json", data: auditTable(packages, input.events) },
|
|
304
|
+
];
|
|
305
|
+
|
|
306
|
+
return {
|
|
307
|
+
module: {
|
|
308
|
+
id: "decision-ledger",
|
|
309
|
+
title: "决策履约与审计",
|
|
310
|
+
status: "published",
|
|
311
|
+
source: {
|
|
312
|
+
artifact_type: "decision_package",
|
|
313
|
+
package_count: packages.length,
|
|
314
|
+
decision_ids: packages.map((pkg) => pkg.decision_id),
|
|
315
|
+
package_fingerprints: packages.map((pkg) => pkg.package_fingerprint),
|
|
316
|
+
measured_spend_count: measured,
|
|
317
|
+
},
|
|
318
|
+
warnings,
|
|
319
|
+
widgets,
|
|
320
|
+
},
|
|
321
|
+
breaches,
|
|
322
|
+
};
|
|
323
|
+
}
|