@fargowealth/fargo-ai-cli 1.5.0 → 1.5.1
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/CHANGELOG.md +6 -0
- package/README.md +4 -202
- package/package.json +2 -3
- package/src/cli.mjs +1 -7
- package/src/constants.mjs +2 -2
- package/src/mcp-client.mjs +3 -4
- package/src/mcp-proxy.mjs +2 -12
- package/src/retired-tools.mjs +6 -0
- package/THIRD_PARTY_NOTICES.md +0 -28
- package/src/yida-auth.mjs +0 -309
- package/src/yida-bundle-format.mjs +0 -77
- package/src/yida-business.mjs +0 -128
- package/src/yida-cancellation.mjs +0 -121
- package/src/yida-cli.mjs +0 -65
- package/src/yida-confirmation.mjs +0 -86
- package/src/yida-expense-contract.mjs +0 -86
- package/src/yida-expense.mjs +0 -261
- package/src/yida-lookup-plan.mjs +0 -59
- package/src/yida-operation.mjs +0 -64
- package/src/yida-rules.mjs +0 -101
- package/src/yida-runtime.mjs +0 -63
- package/src/yida-schema.mjs +0 -111
- package/src/yida-task.mjs +0 -38
- package/src/yida-tools.mjs +0 -91
- package/src/yida-workflow-contract.mjs +0 -18
package/src/yida-cli.mjs
DELETED
|
@@ -1,65 +0,0 @@
|
|
|
1
|
-
import { yidaLogin, yidaLogout, yidaStatus } from "./yida-auth.mjs";
|
|
2
|
-
import { describeYidaForm } from "./yida-schema.mjs";
|
|
3
|
-
import { getYidaBusinessBundle, listYidaBusinesses } from "./yida-business.mjs";
|
|
4
|
-
|
|
5
|
-
export const YIDA_HELP = `Usage: fargo yida <command>
|
|
6
|
-
|
|
7
|
-
auth login [--endpoint https://www.aliwork.com] [--corp-id dingXXX] [--no-browser]
|
|
8
|
-
auth status|profiles
|
|
9
|
-
auth logout [--profile <profileId>]
|
|
10
|
-
schema <appType> <formUuid> [--profile <profileId>]
|
|
11
|
-
business list [--profile <profileId>]
|
|
12
|
-
business get <businessId> [--revision <number>] [--bundle-hash <sha256:...>] [--profile <profileId>]
|
|
13
|
-
|
|
14
|
-
Personal YiDA credentials stay on this computer, isolated by verified Fargo
|
|
15
|
-
account and YiDA enterprise/member. Login uses the OpenYida-compatible OAuth
|
|
16
|
-
client and YiDA token service. Availability depends on the target environment.
|
|
17
|
-
Schema queries are read-only and may require design permissions. Business list
|
|
18
|
-
returns the publisher's selected employee entries; fetch only the chosen bundle.
|
|
19
|
-
Model output omits default values and executable source. Reviewed local MCP
|
|
20
|
-
adapters separately declare their submission scope and confirmation requirements.
|
|
21
|
-
`;
|
|
22
|
-
|
|
23
|
-
function parse(args, flags) {
|
|
24
|
-
const values = {};
|
|
25
|
-
const positionals = [];
|
|
26
|
-
for (let index = 0; index < args.length; index++) {
|
|
27
|
-
const argument = args[index];
|
|
28
|
-
if (!argument.startsWith("--")) { positionals.push(argument); continue; }
|
|
29
|
-
if (!flags.includes(argument) || Object.hasOwn(values, argument)) throw new Error(`Unsupported or repeated YiDA option: ${argument}`);
|
|
30
|
-
if (argument === "--no-browser") { values[argument] = true; continue; }
|
|
31
|
-
const value = args[++index];
|
|
32
|
-
if (!value || value.startsWith("--")) throw new Error(`Missing YiDA option value: ${argument}`);
|
|
33
|
-
values[argument] = value;
|
|
34
|
-
}
|
|
35
|
-
return { values, positionals };
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
export async function runYidaCli(args, store, options = {}) {
|
|
39
|
-
const stdout = options.stdout ?? process.stdout;
|
|
40
|
-
const stderr = options.stderr ?? process.stderr;
|
|
41
|
-
if (!args.length || args.includes("--help") || args.includes("-h")) { stdout.write(YIDA_HELP); return; }
|
|
42
|
-
let result;
|
|
43
|
-
if (args[0] === "auth" && args[1] === "login") {
|
|
44
|
-
const { values, positionals } = parse(args.slice(2), ["--endpoint", "--corp-id", "--no-browser"]);
|
|
45
|
-
if (positionals.length) throw new Error(YIDA_HELP);
|
|
46
|
-
result = await yidaLogin(store, {
|
|
47
|
-
...options, endpoint: values["--endpoint"], corpId: values["--corp-id"], openBrowser: !values["--no-browser"],
|
|
48
|
-
onYidaAuthorization: options.onYidaAuthorization ?? (({ authorizationUrl }) => stderr.write(`请在浏览器完成宜搭授权:\n${authorizationUrl}\n`))
|
|
49
|
-
});
|
|
50
|
-
} else if (args[0] === "auth" && ["status", "profiles", "logout"].includes(args[1])) {
|
|
51
|
-
const { values, positionals } = parse(args.slice(2), args[1] === "logout" ? ["--profile"] : []);
|
|
52
|
-
if (positionals.length) throw new Error(YIDA_HELP);
|
|
53
|
-
result = args[1] === "logout" ? await yidaLogout(store, { ...options, profile: values["--profile"] }) : await yidaStatus(store, options);
|
|
54
|
-
} else if (args[0] === "business" && ["list", "get"].includes(args[1])) {
|
|
55
|
-
const { values, positionals } = parse(args.slice(2), args[1] === "list" ? ["--profile"] : ["--profile", "--revision", "--bundle-hash"]);
|
|
56
|
-
if (positionals.length !== (args[1] === "get" ? 1 : 0)) throw new Error(YIDA_HELP);
|
|
57
|
-
const businessOptions = { ...options, profile: values["--profile"], ...(values["--revision"] !== undefined ? { revision: Number(values["--revision"]) } : {}), ...(values["--bundle-hash"] ? { bundleHash: values["--bundle-hash"] } : {}) };
|
|
58
|
-
result = args[1] === "list" ? await listYidaBusinesses(store, businessOptions) : await getYidaBusinessBundle(store, positionals[0], businessOptions);
|
|
59
|
-
} else if (args[0] === "schema") {
|
|
60
|
-
const { values, positionals } = parse(args.slice(1), ["--profile"]);
|
|
61
|
-
if (positionals.length !== 2) throw new Error(YIDA_HELP);
|
|
62
|
-
result = await describeYidaForm(store, positionals[0], positionals[1], { ...options, profile: values["--profile"] });
|
|
63
|
-
} else throw new Error(YIDA_HELP);
|
|
64
|
-
stdout.write(`${JSON.stringify(result)}\n`);
|
|
65
|
-
}
|
|
@@ -1,86 +0,0 @@
|
|
|
1
|
-
// Display projections only. These do not enable any additional submission adapter.
|
|
2
|
-
const shared = [
|
|
3
|
-
['employeeField_m41da53i', '申请人'], ['dateField_m41cj93y', '申请日期'],
|
|
4
|
-
['selectField_m41cj946', '支付公司'], ['radioField_m41da53k', '是否项目费用'],
|
|
5
|
-
['selectField_m41da53l', '项目名称'], ['textField_m41cj948', '开户银行'],
|
|
6
|
-
['textField_m41cj949', '收款账号(尾号)'], ['textareaField_m41cj94b', '申请事由']
|
|
7
|
-
];
|
|
8
|
-
export const confirmationLayouts = {
|
|
9
|
-
expense: { title: '费用报销申请',
|
|
10
|
-
header: [...shared, ['textField_m6so5pht', '申请部门'], ['selectField_m8lq9x6l', '成本承担公司'],
|
|
11
|
-
['textField_m4vmcrx7', '收款账户名称'], ['textField_m41cj947', '支付币种'], ['numberField_m41cj94a', '报销总额']],
|
|
12
|
-
columns: [['textField_m41cj94g', '费用内容'], ['selectField_m41cj94f', '费用类别'], ['selectField_m4aplvi5', '会计科目'],
|
|
13
|
-
['numberField_m41cj94j', '原币金额'], ['selectField_m41cj94m', '原币种'], ['numberField_m41cj94n', '汇率'],
|
|
14
|
-
['numberField_m41cj94p', '报销金额(支付币种)'], ['textField_m41cj94q', '备注']] },
|
|
15
|
-
travel: { title: '出差费用报销申请',
|
|
16
|
-
header: [...shared, ['textField_m6sokzua', '申请部门'], ['selectField_m8lo3vpc', '成本承担公司'],
|
|
17
|
-
['textField_m4viq0z9', '收款账户名称'], ['textField_m41cj947', '支付币种'], ['numberField_m41cj94a', '报销总额'],
|
|
18
|
-
['textField_m459scfn', '差旅津贴币种'], ['numberField_m459scfo', '差旅津贴总额']],
|
|
19
|
-
columns: [['associationFormField_m459scfc', '关联出差申请'], ['textField_m459scf4', '出发地'], ['textField_m459scf6', '目的地'],
|
|
20
|
-
['cascadeDateField_m459scfj', '出差日期'], ['numberField_m459scfa', '天数'], ['selectField_m41cj94m', '原币种'],
|
|
21
|
-
['numberField_m41cj94j', '飞机/车船票款'], ['numberField_m459scfk', '实际飞机/车船票款'],
|
|
22
|
-
['numberField_m459scfg', '住宿费'], ['numberField_m459scfh', '实际住宿费'], ['numberField_m459scfi', '市内交通费'],
|
|
23
|
-
['numberField_m459scfd', '其他费用'], ['numberField_m41cj94n', '汇率'], ['numberField_m41cj94p', '报销金额(支付币种)'],
|
|
24
|
-
['numberField_m459scfl', '津贴标准'], ['numberField_m459scfm', '津贴金额']] },
|
|
25
|
-
payment: { title: '对外费用支出申请',
|
|
26
|
-
header: [...shared, ['textField_m6speeqq', '申请部门'], ['selectField_m8g46rue', '成本承担公司'],
|
|
27
|
-
['textField_m453obgi', '合同公司名称'], ['selectField_m453obgj', '支付币种'], ['numberField_m41cj94a', '支付总额'],
|
|
28
|
-
['dateField_m453obgk', '最晚付款日期'], ['associationFormField_m7a8veso', '相关合同审批'],
|
|
29
|
-
['associationFormField_ma3cqw2z', '相关用印审批']],
|
|
30
|
-
columns: [['textField_m41cj94g', '支付内容'], ['selectField_m41cj94f', '费用类别'], ['selectField_m4aq5ht9', '会计科目'],
|
|
31
|
-
['numberField_m41cj94j', '支付金额(支付币种)'], ['textField_m41cj94q', '备注']] }
|
|
32
|
-
};
|
|
33
|
-
|
|
34
|
-
const cell = value => {
|
|
35
|
-
if (value === undefined || value === null || value === '') return '待补充';
|
|
36
|
-
if (!['string', 'number', 'boolean'].includes(typeof value)) throw new Error('YIDA_CONFIRMATION_DISPLAY_VALUE_REQUIRED');
|
|
37
|
-
return String(value).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
38
|
-
.replace(/\|/g, '|').replace(/[\\`*_\[\]~]/g, c => `&#${c.charCodeAt(0)};`).replace(/[\r\n]+/g, ' / ');
|
|
39
|
-
};
|
|
40
|
-
const table = (headers, rows) => [
|
|
41
|
-
`| ${headers.map(cell).join(' | ')} |`, `| ${headers.map(() => '---').join(' | ')} |`,
|
|
42
|
-
...rows.map(row => `| ${row.map(cell).join(' | ')} |`)
|
|
43
|
-
].join('\n');
|
|
44
|
-
|
|
45
|
-
// Callers must resolve member/association names and dates to human-readable
|
|
46
|
-
// display values first. Missing values remain visible; templates are not approval.
|
|
47
|
-
export function renderYidaConfirmation(kind, { headerValues, rows, attachments = [] }) {
|
|
48
|
-
const layout = confirmationLayouts[kind];
|
|
49
|
-
if (!layout || !Array.isArray(rows) || !rows.length) throw new Error('YIDA_CONFIRMATION_ROWS_REQUIRED');
|
|
50
|
-
const overall = layout.header.map(([key, label]) => [label, key === 'textField_m41cj949' && headerValues[key]
|
|
51
|
-
? `****${String(headerValues[key]).slice(-4)}` : headerValues[key]]);
|
|
52
|
-
overall.push(['附件', attachments.length ? attachments.map(a => a.name).join(';') : '待补充']);
|
|
53
|
-
return `**${layout.title} · 待确认,尚未提交**\n\n**整体参数**\n\n${table(['项目', '内容'], overall)}\n\n**明细数据**\n\n${table(['序号', ...layout.columns.map(([, label]) => label)], rows.map((row, i) => [i + 1, ...layout.columns.map(([key]) => row[key])]))}\n\n请核对以上整体参数、明细和附件。回复“确认提交”或“Confirm”后才会正式提交;如需修改,请指出对应项目或明细行。`;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
export const confirmationBusinessKinds = {
|
|
57
|
-
form_6eb2e57a94cf4445bb28508dc45262e3mddx: 'expense',
|
|
58
|
-
travel_reimbursement: 'travel', external_payment: 'payment'
|
|
59
|
-
};
|
|
60
|
-
|
|
61
|
-
export function businessConfirmationContract(schema, kind) {
|
|
62
|
-
const layout = confirmationLayouts[kind];
|
|
63
|
-
const fields = new Map(schema.fields.map(f => [f.fieldId, f]));
|
|
64
|
-
if (!layout || layout.header.some(([id]) => !fields.has(id)) ||
|
|
65
|
-
layout.columns.some(([id]) => fields.get(id)?.parentFieldId !== 'tableField_m41cj94c')) throw new Error('YIDA_CONFIRMATION_SCHEMA_CHANGED');
|
|
66
|
-
return { required: true, format: 'markdown', sections: ['overall_parameters', 'detail_rows'],
|
|
67
|
-
headerFields: layout.header.map(([fieldId, label]) => ({ fieldId, label })),
|
|
68
|
-
detailColumns: layout.columns.map(([fieldId, label]) => ({ fieldId, label })),
|
|
69
|
-
explicitHumanReplyRequired: true, changesRequireReconfirmation: true,
|
|
70
|
-
binding: 'previewHash', callerAttestation: 'userConfirmed',
|
|
71
|
-
executionAvailability: kind === 'expense' ? 'reviewed_non_project_single_row_adapter' : 'display_only_no_new_submission_adapter' };
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
export function businessConfirmationHandbook(kind) {
|
|
75
|
-
const layout = confirmationLayouts[kind];
|
|
76
|
-
if (!layout) throw new Error('YIDA_CONFIRMATION_KIND_INVALID');
|
|
77
|
-
const headerValues = Object.fromEntries(layout.header.map(([id, label]) => [id, `待填:${label}`]));
|
|
78
|
-
headerValues.textField_m41cj949 = '0000';
|
|
79
|
-
const row = Object.fromEntries(layout.columns.map(([id, label]) => [id, `待填:${label}`]));
|
|
80
|
-
return `\n<!-- fargo-user-confirmation:start -->\n## 用户提交确认表\n\n以下为本业务的字段模板,展示时必须替换成同一份实际预览的值;账号 0000 仅为脱敏占位,不可用于提交。数据缺失或规则未解析时先补齐。项目分支的项目名称、项目类别/科目和成本归属应按实际显隐补充;未经适配的分支不能仅凭确认表提交。\n\n${renderYidaConfirmation(kind, { headerValues, rows: [row], attachments: [{ name: '待填:实际附件名称及已核验页数' }] })}\n\n### Confirm 与正式提交\n\n1. 展示完整的整体参数表与所有明细行;申请人/部门显示业务名称,关联单据显示标题,账号仅展示尾号。附件须列出实际待提交文件。不能只显示金额就要求确认。\n2. 等待用户对这版表格明确回复“确认提交”或“Confirm”。最初“帮我申请”的指令、沉默、工具返回或 Agent 自行判断,都不能代替这一步。\n3. 得到明确确认后,才允许调用本地已适配的提交工具,使用这版 revision、bundleHash、previewHash;费用报销工具另传 userConfirmed=true。此参数是调用方对真实人类确认的声明,不是用户签名或权限凭证。\n4. 任意参数、明细、附件发生改变,或执行时预览指纹不匹配,都应重新生成、展示并等待确认。确认状态不能沿用到新内容,也不能跨业务或跨用户复用。\n5. 提交后以 API 回读确认单号、金额和附件。结果不确定只做核验,不自动再次创建。\n\n${kind === 'expense' ? '当前可执行范围为本人、非项目、单明细费用报销;先 dryRun=true 获取 confirmation.markdown,收到确认后再 dryRun=false。' : '本节只定义提交前展示和用户确认流程;当前发布没有新增本业务的通用提交适配器。用户 Confirm 也不解除未验证分支或接口的限制。'}\n<!-- fargo-user-confirmation:end -->\n`;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
export function appendBusinessConfirmation(handbook, kind) {
|
|
84
|
-
const cleaned = handbook.replace(/\n?<!-- fargo-user-confirmation:start -->[\s\S]*?<!-- fargo-user-confirmation:end -->\n?/g, '').trimEnd();
|
|
85
|
-
return cleaned + '\n' + businessConfirmationHandbook(kind);
|
|
86
|
-
}
|
|
@@ -1,86 +0,0 @@
|
|
|
1
|
-
import { confirmationLayouts } from "./yida-confirmation.mjs";
|
|
2
|
-
// Reviewed ordinary-expense adapter contract. Shared by the runtime and the
|
|
3
|
-
// schema publisher; no personal records, credentials, or arbitrary expressions.
|
|
4
|
-
export const EXPENSE_BUSINESS_ID = "form_6eb2e57a94cf4445bb28508dc45262e3mddx";
|
|
5
|
-
export const EXPENSE_SCHEMA_HASH = "sha256:8bbaf86f707819a65c517cff180fbe257c127615706567912ad60d300b797cbd";
|
|
6
|
-
const steps = [
|
|
7
|
-
["company", "selectField_m41cj946", "linked_value", "one"],
|
|
8
|
-
["costCompanies", "selectField_m8lq9x6l", "options", "list"],
|
|
9
|
-
["categories", "selectField_m41cj94f", "options", "list"],
|
|
10
|
-
["currencies", "selectField_m41cj94m", "options", "list"],
|
|
11
|
-
["accounts", "selectField_m4aplvi5", "options", "list"],
|
|
12
|
-
["paymentCurrency", "textField_m41cj947", "linked_value", "one"],
|
|
13
|
-
["framework", "selectField_m8lq9x6m", "linked_value", "one"],
|
|
14
|
-
["businessLine", "selectField_m8lq9x6s", "linked_value", "one"],
|
|
15
|
-
["businessSubline", "selectField_m8lq9x6t", "linked_value", "one"],
|
|
16
|
-
["finance", "employeeField_m45931x3", "linked_value", "list"],
|
|
17
|
-
["cashier", "employeeField_m45931x4", "linked_value", "list"],
|
|
18
|
-
["rate", "numberField_m41cj94n", "linked_value", "one"],
|
|
19
|
-
["hkdRate", "numberField_m41cj959", "linked_value", "one"]
|
|
20
|
-
];
|
|
21
|
-
export const expenseLookupPlan = steps.map(([name, fieldId, kind, result]) => ({ name, fieldId, kind, result }));
|
|
22
|
-
export function expenseExecutionContract(schema) {
|
|
23
|
-
if (schema.businessId !== EXPENSE_BUSINESS_ID || schema.schemaHash !== EXPENSE_SCHEMA_HASH) throw new Error("YIDA_EXPENSE_DEFINITION_NOT_REVIEWED");
|
|
24
|
-
return {
|
|
25
|
-
version: 3, adapter: "ordinary_expense_v1", localTool: "yida_submit_expense", reviewedSchemaHash: EXPENSE_SCHEMA_HASH,
|
|
26
|
-
scope: { ownRequestsOnly: true, project: false, lineCount: 1, attachmentCount: 1, genericSubmission: false },
|
|
27
|
-
inputs: {
|
|
28
|
-
amount: { field: "tableField_m41cj94c.numberField_m41cj94j", meaning: "original_amount", minimum: 0.01, maximum: 100000000, decimalPlaces: 2 },
|
|
29
|
-
currency: { field: "tableField_m41cj94c.selectField_m41cj94m", source: "user_and_live_options" },
|
|
30
|
-
description: { field: "tableField_m41cj94c.textField_m41cj94g", source: "user" },
|
|
31
|
-
expenseCategory: { field: "tableField_m41cj94c.selectField_m41cj94f", source: "live_options" },
|
|
32
|
-
accountingItem: { field: "tableField_m41cj94c.selectField_m4aplvi5", dependsOn: ["expenseCategory"], source: "live_options" },
|
|
33
|
-
costCompany: { field: "selectField_m8lq9x6l", source: "user_and_live_options", distinctFrom: "paymentCompany" },
|
|
34
|
-
invoiceNumber: { storage: "notes_and_row_note", source: "invoice", duplicateKey: true },
|
|
35
|
-
invoiceDate: { storage: "notes", format: "valid_calendar_date_YYYY-MM-DD" },
|
|
36
|
-
paymentOrder: { storage: "notes_and_row_note", source: "payment_evidence", duplicateKey: true },
|
|
37
|
-
attachmentReceiptPath: { scope: "local_only", registeredUploadRequired: true }
|
|
38
|
-
},
|
|
39
|
-
identity: { applicant: "current_OAuth_member", bank: "same_members_HR_record", department: "own_main_department", manager: "first_configured_department_manager", shareHrWithinOperation: true, onlineChecksPerPreparation: 1, localCredentialCheckOnReuse: true, onlineRevalidationBeforeWrite: true, taskLifetime: "single_MCP_invocation_max_5_minutes" },
|
|
40
|
-
lookupPlan: expenseLookupPlan.map(step => ({ ...step, inputs: schema.runtime.rules.find(r => r.fieldId === step.fieldId && r.kind === step.kind).inputs })),
|
|
41
|
-
lookupAliases: { textField_m41cj956: "selectField_m41cj946" },
|
|
42
|
-
scheduling: { dependencyDriven: true, maxConcurrentLookups: 4, cacheLifetime: "one_operation", persistentValueCache: false, revalidateOnExecute: true },
|
|
43
|
-
calculations: schema.runtime.rules.filter(r => ["numberField_m41cj94p", "numberField_m41cj94o", "numberField_m41cj94a", "numberField_m41cj95d"].includes(r.fieldId) && r.kind === "calculation")
|
|
44
|
-
.map(({ fieldId, expression, inputs }) => ({ fieldId, expression, inputs, decimalPlaces: 2, implementation: "decimal_half_up" })),
|
|
45
|
-
currencies: { original: "selectField_m41cj94m", payment: "textField_m41cj947", reporting: "HKD", rates: "live_lookup_each_operation" },
|
|
46
|
-
attachment: { field: "attachmentField_m41cj94r", type: "application/pdf", maxBytes: 20000000, readbackEncoding: ["array", "json_string_array"], compare: ["fileUuid", "name", "size"] },
|
|
47
|
-
confirmation: { required: true, format: "markdown", sections: ["overall_parameters", "detail_rows"], headerFields: confirmationLayouts.expense.header.map(([fieldId, label]) => ({ fieldId, label })), detailColumns: confirmationLayouts.expense.columns.map(([fieldId, label]) => ({ fieldId, label })), showBeforeSubmit: true, explicitHumanReplyRequired: true, callerAttestation: "userConfirmed", binding: "previewHash", changesRequireReconfirmation: true },
|
|
48
|
-
submission: { defaultDryRun: true, pin: ["revision", "bundleHash", "previewHash"], duplicateScope: "own_form_invoice_or_order_excluding_detail_verified_terminated", mutationAttempts: 1,
|
|
49
|
-
gates: ["publisher_admission", "live_schema", "own_identity", "process_binding", "current_options", "preview_match", "duplicate_scan", "operation_lock"],
|
|
50
|
-
unknownResult: "read_only_reconcile_never_resubmit" },
|
|
51
|
-
verification: { compare: ["processInstanceId", "formUuid", "processCode", "originator", "invoiceNumber", "paymentOrder", "amounts", "currencies", "exchangeRates", "category", "accountingItem", "companies", "ownBank", "attachment"],
|
|
52
|
-
evidence: "one_live_non_project_submission_readback_2026-10-15", allBranchesVerified: false },
|
|
53
|
-
limitations: ["project_expenses_not_adapted", "multi_line_not_adapted", "upload_requires_registered_local_receipt", "other_workflow_cancellations_not_live_verified"]
|
|
54
|
-
};
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
export const expenseExecutionHandbook = `
|
|
58
|
-
## 普通费用报销:已验证的本地执行契约
|
|
59
|
-
|
|
60
|
-
本节对应 schema.execution,工具为 yida_submit_expense。已完成一次本人、非项目、单明细、单 PDF 的真实提交与回读;不代表项目报销、多明细或其他业务可直接提交。付款公司来自当前员工配置,成本承担公司需按票据及业务选择;两者可以不同。收款人及银行资料仅来自当前员工自己的 HR。
|
|
61
|
-
|
|
62
|
-
### 最短执行路径
|
|
63
|
-
|
|
64
|
-
1. 从发票取得金额、币种、号码、开票日期,从付款凭证取得订单号。发票与截图合并为同一 PDF,保留页序、原内容并检查页数和可读性。
|
|
65
|
-
2. 使用个人 OAuth 完成 OSS 签名上传和宜搭回调登记,取得绑定本地账号及企业的上传回执。当前上传仍需本地辅助流程,提交工具不接受裸文件 URL,也不负责合并文件。
|
|
66
|
-
3. 提交工具默认 dryRun=true。固定业务包版本并核实实时定义;同一次准备只取一份 HR,同时查询部门经理和 13 项选项/联动。查询按 schema 的 inputs 调度,最多 4 项并行,汇率不等待业务线。
|
|
67
|
-
4. 原币金额与付款币金额分别展示。行付款金额 = ROUND(原币金额 × 原币至付款币汇率, 2);行 HKD 金额 = ROUND(行付款金额 × 付款币至 HKD 汇率, 2)。总额分别汇总对应行金额,按十进制四舍五入。
|
|
68
|
-
5. 同一次工具调用内共用身份上下文,只远程解析一次身份;每次使用前检查本地凭据是否变化。上下文最长 5 分钟,完成或失败即释放;真正写入前再次在线核验身份。
|
|
69
|
-
6. 将 confirmation.markdown 中的整体参数表、明细数据表完整展示给用户,等待其对这版内容明确回复“确认提交”或“Confirm”。之后才复用返回的 revision、bundleHash、previewHash,并指定 userConfirmed=true、dryRun=false。不能将最初“帮我报销”的请求或工具输出视为对预览的确认;修改内容或预览失效后必须重新展示并等待确认。执行重新读取实时值,变化导致预览不匹配时应重新准备并再次确认,不沿用旧汇率或收款资料。
|
|
70
|
-
7. 发起前完整扫描本人此表单的发票号或订单号重复记录,并在操作锁内再次检查。已经通过详情确认本人、同表单同流程、终止状态的旧申请不阻止独立重提;活动、完成和不明状态记录仍阻止重复。每次提交尝试由持久化操作记录保护,不删除旧回执,也不在新申请中添加业务关联;这不是宜搭端全局幂等保证。
|
|
71
|
-
8. 获得实例号后回读,核对身份、流程、金额、币种、汇率、科目、公司、本人收款信息及附件的 UUID/名称/大小。附件既可能是数组,也可能是 JSON 文本数组。
|
|
72
|
-
|
|
73
|
-
### 结果处理
|
|
74
|
-
|
|
75
|
-
- preview:仅准备;不创建申请。保存返回的版本和预览指纹。
|
|
76
|
-
- already_exists:检测到本人相同发票号或订单号,且不能确认是已终止的旧申请;返回现有实例。查详情核对,不作为新提交成功,也不重新提交。
|
|
77
|
-
- submitted 且 verified=true:写入及回读均已确认,可报告单号与状态。
|
|
78
|
-
- verification_pending:已有实例回执,后续只读详情补核验,不能再次创建。
|
|
79
|
-
- result_unknown:写入结果不确定,按操作记录与本人列表对账,不能通过新请求号重试。
|
|
80
|
-
|
|
81
|
-
### 更新与信息边界
|
|
82
|
-
|
|
83
|
-
原始字段/公式的 schemaHash 与适配器审核版本绑定。新增执行规则以新的 Schema + Handbook 业务包 revision 分发,旧版本保持不可变;运行时能力还需更新本地 Fargo CLI,发布云端手册不会自动更新客户端代码。定义或上游返回结构变化时保持失败关闭,补充测试后再升级。
|
|
84
|
-
|
|
85
|
-
持久化缓存仅保存定义包;HR、银行资料、汇率和选项不跨操作缓存。账号与权限检查、执行前实时定义复核保留。此契约不包含用户的银行账号、发票、附件地址或 OAuth 凭据。
|
|
86
|
-
`;
|
package/src/yida-expense.mjs
DELETED
|
@@ -1,261 +0,0 @@
|
|
|
1
|
-
import { renderYidaConfirmation } from "./yida-confirmation.mjs";
|
|
2
|
-
import { createYidaTaskContext } from "./yida-task.mjs";
|
|
3
|
-
import { createHash } from "node:crypto";
|
|
4
|
-
import { lstat, readFile } from "node:fs/promises";
|
|
5
|
-
import { homedir } from "node:os";
|
|
6
|
-
import { isAbsolute, join } from "node:path";
|
|
7
|
-
import { getYidaBusinessBundle } from "./yida-business.mjs";
|
|
8
|
-
import { canonicalJson, HASH } from "./yida-bundle-format.mjs";
|
|
9
|
-
import { resolveYidaOwner, yidaStatus, yidaRead, yidaLookupExpression, yidaApplicantConnector, yidaPostFormOnce } from "./yida-auth.mjs";
|
|
10
|
-
import { compileYidaLookup } from "./yida-rules.mjs";
|
|
11
|
-
import { resolveYidaApplicantFromContext } from "./yida-runtime.mjs";
|
|
12
|
-
import { describeYidaForm } from "./yida-schema.mjs";
|
|
13
|
-
import { runRecordedYidaMutation, extractYidaProcessId } from "./yida-operation.mjs";
|
|
14
|
-
|
|
15
|
-
import { EXPENSE_BUSINESS_ID as businessId, EXPENSE_SCHEMA_HASH as reviewedHash, expenseLookupPlan } from "./yida-expense-contract.mjs";
|
|
16
|
-
import { resolveYidaLookupPlan } from "./yida-lookup-plan.mjs";
|
|
17
|
-
const digest = value => `sha256:${createHash("sha256").update(canonicalJson(value)).digest("hex")}`;
|
|
18
|
-
const fail = code => { throw new Error(code); };
|
|
19
|
-
// Decimal half-up on the API's decimal numbers; binary multiplication can turn
|
|
20
|
-
// 1 * 1.005 into 1.00. Keep exact cents through the rounding step.
|
|
21
|
-
export function roundYidaMoneyProduct(a, b) {
|
|
22
|
-
const decimal = value => {
|
|
23
|
-
if (!Number.isFinite(value) || value < 0) fail("YIDA_EXPENSE_RATE_INVALID");
|
|
24
|
-
const [mantissa, exponent = "0"] = String(value).toLowerCase().split("e");
|
|
25
|
-
const [integer, fraction = ""] = mantissa.split(".");
|
|
26
|
-
return { n: BigInt(integer + fraction), scale: fraction.length - Number(exponent) };
|
|
27
|
-
};
|
|
28
|
-
const x = decimal(a), y = decimal(b), scale = x.scale + y.scale - 2;
|
|
29
|
-
let cents = x.n * y.n;
|
|
30
|
-
if (scale > 0) { const divisor = 10n ** BigInt(scale); cents = (cents * 2n + divisor) / (2n * divisor); }
|
|
31
|
-
else cents *= 10n ** BigInt(-scale);
|
|
32
|
-
if (cents > BigInt(Number.MAX_SAFE_INTEGER)) fail("YIDA_EXPENSE_AMOUNT_OVERFLOW");
|
|
33
|
-
return Number(cents) / 100;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
function validInvoiceDate(value) {
|
|
37
|
-
if (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return false;
|
|
38
|
-
const date = new Date(`${value}T00:00:00Z`);
|
|
39
|
-
return Number.isFinite(date.getTime()) && date.toISOString().slice(0, 10) === value;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
// YiDA returns AttachmentField values as JSON text in getInstanceById,
|
|
43
|
-
// while other collection fields may already be arrays. Normalize by field type.
|
|
44
|
-
export function readYidaExpenseArray(value) {
|
|
45
|
-
if (typeof value === "string") {
|
|
46
|
-
if (value.length > 2_000_000) fail("YIDA_EXPENSE_READBACK_MALFORMED");
|
|
47
|
-
try { value = JSON.parse(value); } catch { fail("YIDA_EXPENSE_READBACK_MALFORMED"); }
|
|
48
|
-
}
|
|
49
|
-
if (!Array.isArray(value)) fail("YIDA_EXPENSE_READBACK_MALFORMED");
|
|
50
|
-
return value;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
// A reviewed adapter for this ordinary, non-project expense form only. This
|
|
54
|
-
// does not enable the catalog's generic submission capability or arbitrary writes.
|
|
55
|
-
export async function submitYidaExpense(store, input, options = {}) {
|
|
56
|
-
const task = createYidaTaskContext(store, options);
|
|
57
|
-
try { return await submitExpenseInTask(store, input, task.options); }
|
|
58
|
-
finally { task.close(); }
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
async function submitExpenseInTask(store, input, options) {
|
|
62
|
-
if (!input || !Number.isFinite(input.amount) || input.amount <= 0 || input.amount > 100_000_000 ||
|
|
63
|
-
Math.abs(input.amount * 100 - Math.round(input.amount * 100)) > 0.000001 || !/^[A-Z]{3}$/.test(input.currency ?? "") ||
|
|
64
|
-
!/^[0-9]{8,30}$/.test(input.invoiceNumber ?? "") || !validInvoiceDate(input.invoiceDate) ||
|
|
65
|
-
!/^[A-Za-z0-9_-]{1,80}$/.test(input.paymentOrder ?? "") ||
|
|
66
|
-
["description", "expenseCategory", "accountingItem", "costCompany"].some(k => typeof input[k] !== "string" || !input[k].trim() || input[k].length > 1000) ||
|
|
67
|
-
typeof input.attachmentReceiptPath !== "string" || !isAbsolute(input.attachmentReceiptPath) ||
|
|
68
|
-
(input.dryRun !== undefined && typeof input.dryRun !== "boolean") || (input.userConfirmed !== undefined && typeof input.userConfirmed !== "boolean")) fail("YIDA_EXPENSE_INPUT_INVALID");
|
|
69
|
-
const execute = input.dryRun === false;
|
|
70
|
-
if (execute && (!HASH.test(input.previewHash ?? "") || !HASH.test(input.bundleHash ?? "") || !Number.isSafeInteger(input.revision) || input.revision < 1)) fail("YIDA_EXPENSE_PREVIEW_REQUIRED");
|
|
71
|
-
if (execute && input.userConfirmed !== true) fail("YIDA_EXPENSE_USER_CONFIRMATION_REQUIRED");
|
|
72
|
-
const owner = await resolveYidaOwner(store, options);
|
|
73
|
-
const loaded = await getYidaBusinessBundle(store, businessId, { ...options, revision: input.revision, bundleHash: input.bundleHash });
|
|
74
|
-
const schema = loaded.bundle.schema;
|
|
75
|
-
if (loaded.status !== "current" || schema.schemaHash !== reviewedHash || schema.formType !== "process") fail("YIDA_EXPENSE_DEFINITION_NOT_REVIEWED");
|
|
76
|
-
const principal = (await yidaStatus(store, options)).profiles.find(p => p.profile === loaded.profile);
|
|
77
|
-
if (!principal || principal.corpId !== loaded.bundle.corpId || principal.baseUrl !== loaded.bundle.yidaOrigin) fail("YIDA_EXPENSE_IDENTITY_MISMATCH");
|
|
78
|
-
const local = { ...options, profile: principal.profile, schemaHash: reviewedHash };
|
|
79
|
-
const assertOwner = async () => { if (digest(owner) !== digest(await resolveYidaOwner(store, options))) fail("YIDA_FARGO_ACCOUNT_CHANGED"); };
|
|
80
|
-
const checkBinding = async () => {
|
|
81
|
-
const x = await yidaRead(store, `/${schema.appType}/query/formProcBinding/getBindingByFormUuid.json?${new URLSearchParams({ formUuid: schema.formUuid })}`, local);
|
|
82
|
-
if (x.content?.appType !== schema.appType || x.content?.procCode !== schema.entry.processCode) fail("YIDA_EXPENSE_PROCESS_MISMATCH");
|
|
83
|
-
};
|
|
84
|
-
await checkBinding();
|
|
85
|
-
const fileInfo = await lstat(input.attachmentReceiptPath);
|
|
86
|
-
if (!fileInfo.isFile() || fileInfo.isSymbolicLink() || fileInfo.size > 100_000 ||
|
|
87
|
-
typeof process.getuid === "function" && fileInfo.uid !== process.getuid()) fail("YIDA_EXPENSE_ATTACHMENT_INVALID");
|
|
88
|
-
const proof = JSON.parse(await readFile(input.attachmentReceiptPath, "utf8"));
|
|
89
|
-
const attachment = proof.attachment;
|
|
90
|
-
if (digest(proof.owner) !== digest(owner) || proof.profile !== principal.profile || !/^[a-f0-9]{64}$/.test(proof.hash ?? "") ||
|
|
91
|
-
!attachment || !attachment.name?.endsWith(".pdf") || !Number.isSafeInteger(attachment.size) || attachment.size <= 0 || attachment.size > 20_000_000 ||
|
|
92
|
-
!attachment.fileUuid?.startsWith(schema.appType + "_") && !attachment.fileUuid?.startsWith(schema.appType + "/")) fail("YIDA_EXPENSE_ATTACHMENT_INVALID");
|
|
93
|
-
for (const key of ["url", "downloadUrl", "previewUrl"]) {
|
|
94
|
-
let url; try { url = new URL(attachment[key]); } catch { fail("YIDA_EXPENSE_ATTACHMENT_INVALID"); }
|
|
95
|
-
if (url.origin !== principal.baseUrl) fail("YIDA_EXPENSE_ATTACHMENT_INVALID");
|
|
96
|
-
}
|
|
97
|
-
const lookup = async (rule, values) => {
|
|
98
|
-
const x = await yidaLookupExpression(store, schema.appType, schema.formUuid, compileYidaLookup(rule, values, schema.appType), local);
|
|
99
|
-
return x.content ?? x.result;
|
|
100
|
-
};
|
|
101
|
-
const memberIds = values => {
|
|
102
|
-
if (!values.length || values.some(v => typeof v !== "string" || !/^[A-Za-z0-9_.@-]{1,200}$/.test(v))) fail("YIDA_EXPENSE_ROUTING_UNRESOLVED");
|
|
103
|
-
return values;
|
|
104
|
-
};
|
|
105
|
-
const hrPromise = yidaApplicantConnector(store, schema.runtime.connectors.find(c => c.name === "getUserMessage"), "self_hr", undefined, local);
|
|
106
|
-
const applicantPromise = hrPromise.then(hr => resolveYidaApplicantFromContext(store, schema, principal, hr, undefined, local));
|
|
107
|
-
const [applicant, hrResponse, resolved] = await Promise.all([
|
|
108
|
-
applicantPromise, hrPromise,
|
|
109
|
-
resolveYidaLookupPlan(schema, expenseLookupPlan, {
|
|
110
|
-
textField_m41cj95b: principal.userId,
|
|
111
|
-
textField_m8lq9x6o: applicantPromise.then(a => a.departmentId),
|
|
112
|
-
selectField_m41cj94f: input.expenseCategory, selectField_m41cj94m: input.currency, textField_m41cj958: "HKD"
|
|
113
|
-
}, { textField_m41cj956: "selectField_m41cj946" }, lookup)
|
|
114
|
-
]);
|
|
115
|
-
const hr = hrResponse.content?.serviceReturnValue ?? hrResponse.content;
|
|
116
|
-
if (hr?.errcode !== 0 || hr.success !== true || hr.result?.length !== 1 || hr.result[0].userid !== principal.userId) fail("YIDA_EXPENSE_HR_IDENTITY_MISMATCH");
|
|
117
|
-
const ownField = code => hr.result[0].field_data_list?.find(f => f.field_code === code)?.field_value_list?.[0]?.value ?? "";
|
|
118
|
-
// Only the bank fields the reviewed page copies from this member's own HR record.
|
|
119
|
-
const bank = { textField_m41cj949: ownField("sys04-bankAccountNo"), textField_m41cj948: ownField("c25a949b-1c28-4ad0-803b-4ce1ba8c7148"),
|
|
120
|
-
textField_m4vmcrx5: ownField("59b7514e-5d01-4a63-b236-e2566cf3e0e6"), textField_m4vmcrx6: ownField("eec62388-645f-4fa0-8ab5-42351a623c0b"),
|
|
121
|
-
textField_m4vmcrx7: ownField("eb1f9d44-a245-42f8-b11a-7b616b56bfcb") };
|
|
122
|
-
if (Object.values(bank).some(v => typeof v !== "string") || !bank.textField_m41cj949 || !bank.textField_m4vmcrx7) fail("YIDA_EXPENSE_OWN_BANK_REQUIRED");
|
|
123
|
-
if (!resolved.costCompanies.includes(input.costCompany) || !resolved.categories.includes(input.expenseCategory) ||
|
|
124
|
-
!resolved.currencies.includes(input.currency) || !resolved.accounts.includes(input.accountingItem)) fail("YIDA_EXPENSE_OPTION_INVALID");
|
|
125
|
-
const company = resolved.company, finance = resolved.finance, cashier = resolved.cashier;
|
|
126
|
-
const values = { textField_m41cj947: resolved.paymentCurrency, selectField_m8lq9x6m: resolved.framework,
|
|
127
|
-
selectField_m8lq9x6s: resolved.businessLine, selectField_m8lq9x6t: resolved.businessSubline };
|
|
128
|
-
const rate = Number(resolved.rate), hkdRate = Number(resolved.hkdRate);
|
|
129
|
-
if (![rate, hkdRate].every(n => Number.isFinite(n) && n > 0)) fail("YIDA_EXPENSE_RATE_INVALID");
|
|
130
|
-
const paid = roundYidaMoneyProduct(input.amount, rate), hkd = roundYidaMoneyProduct(paid, hkdRate);
|
|
131
|
-
const today = new Intl.DateTimeFormat("en-CA", { timeZone: "Asia/Hong_Kong", year: "numeric", month: "2-digit", day: "2-digit" }).format(new Date());
|
|
132
|
-
const note = `${input.description};原币 ${input.amount.toFixed(2)} ${input.currency};发票 ${input.invoiceNumber}(${input.invoiceDate});支付订单 ${input.paymentOrder}。发票及付款凭证合并为同一份附件。`;
|
|
133
|
-
const row = { selectField_m41cj94f: input.expenseCategory, selectField_m4aplvi5: input.accountingItem, textField_m41cj94g: input.description,
|
|
134
|
-
numberField_m41cj94j: input.amount, selectField_m41cj94m: input.currency, numberField_m41cj94n: rate, numberField_m41cj94p: paid,
|
|
135
|
-
numberField_m41cj94o: hkd, numberField_m41cj959: hkdRate, textField_m41cj958: "HKD", textField_m41cj94q: `发票 ${input.invoiceNumber};订单 ${input.paymentOrder}` };
|
|
136
|
-
const data = { ...bank, employeeField_m41da53i: [principal.userId], selectField_m4lf0x1o: applicant.departmentId,
|
|
137
|
-
textField_m6so5pht: applicant.departmentName, employeeField_m4lf0x1p: applicant.managerIds, textField_m8lq9x6n: applicant.departmentId,
|
|
138
|
-
textField_m8lq9x6o: applicant.departmentId, textField_m41cj95b: principal.userId, textField_m41cj957: applicant.workPlace ?? "",
|
|
139
|
-
textField_m41cj95a: applicant.positionLevel ?? "", textField_m41cj95c: principal.userId,
|
|
140
|
-
textField_mam99rbr: String(applicant.managerIds.length === 1 && applicant.managerIds[0] === principal.userId),
|
|
141
|
-
dateField_m41cj93y: Date.parse(`${today}T00:00:00+08:00`),
|
|
142
|
-
selectField_m41cj946: company, textField_m41cj956: company, textField_m41cj947: values.textField_m41cj947,
|
|
143
|
-
radioField_m8lq9x6j: company === input.costCompany ? "HIDDEN" : "NORMAL", selectField_m8lq9x6l: input.costCompany,
|
|
144
|
-
selectField_m8lq9x6m: values.selectField_m8lq9x6m, selectField_m8lq9x6s: values.selectField_m8lq9x6s, selectField_m8lq9x6t: values.selectField_m8lq9x6t,
|
|
145
|
-
radioField_m41da53k: "HIDDEN", textField_ml7ujl1g: "生效", textField_m8omfh8q: "是", textareaField_m41cj94b: note,
|
|
146
|
-
numberField_m41cj94a: paid, numberField_m41cj95d: hkd, employeeField_m45931x3: memberIds(finance), employeeField_m45931x4: memberIds(cashier),
|
|
147
|
-
tableField_m41cj94c: [row], attachmentField_m41cj94r: [attachment] };
|
|
148
|
-
const known = new Set(schema.fields.map(f => f.fieldId));
|
|
149
|
-
if ([...Object.keys(data), ...Object.keys(row)].some(k => !known.has(k))) fail("YIDA_EXPENSE_FIELD_INVALID");
|
|
150
|
-
const fields = { formUuid: schema.formUuid, processCode: schema.entry.processCode, formDataJson: JSON.stringify(data), deptId: applicant.departmentId, _api: "nattyFetch", _mock: "false" };
|
|
151
|
-
await assertOwner();
|
|
152
|
-
let previewHash;
|
|
153
|
-
const summary = { businessId, invoiceNumber: input.invoiceNumber, description: input.description, originalAmount: input.amount, originalCurrency: input.currency,
|
|
154
|
-
paymentAmount: paid, paymentCurrency: values.textField_m41cj947, exchangeRate: rate, paymentCompany: company, costCompany: input.costCompany,
|
|
155
|
-
expenseCategory: input.expenseCategory, accountingItem: input.accountingItem, attachmentName: attachment.name, attachmentSize: attachment.size,
|
|
156
|
-
bankAccountLast4: bank.textField_m41cj949.slice(-4) };
|
|
157
|
-
const listPath = `/dingtalk/web/${schema.appType}/v1/process/getInstances.json`;
|
|
158
|
-
async function reconcile() {
|
|
159
|
-
const terminatedIds = [];
|
|
160
|
-
for (let page = 1, seen = 0; page <= 20; page++) {
|
|
161
|
-
const x = await yidaRead(store, `${listPath}?${new URLSearchParams({ formUuid: schema.formUuid, originatorId: principal.userId, currentPage: String(page), pageSize: "100" })}`, local);
|
|
162
|
-
const content = x.content;
|
|
163
|
-
if (!Array.isArray(content?.data) || !Number.isSafeInteger(content.totalCount)) fail("YIDA_EXPENSE_RECONCILIATION_INCOMPLETE");
|
|
164
|
-
const matches = content.data.filter(r => r.originator?.userId === principal.userId &&
|
|
165
|
-
(JSON.stringify(r.data).includes(input.invoiceNumber) || JSON.stringify(r.data).includes(input.paymentOrder)));
|
|
166
|
-
for (const match of matches) {
|
|
167
|
-
if (!["TERMINATED", "CANCELED"].includes(match.instanceStatus)) return { match, terminatedIds };
|
|
168
|
-
// List states can be stale. Only a current, same-owner/form/detail read
|
|
169
|
-
// proves a prior invoice attempt is terminated and can be excluded.
|
|
170
|
-
if (typeof match.processInstanceId !== "string" || !/^[A-Za-z0-9_-]{1,200}$/.test(match.processInstanceId)) fail("YIDA_EXPENSE_RECONCILIATION_INCOMPLETE");
|
|
171
|
-
const detail = await yidaRead(store, `/dingtalk/web/${schema.appType}/v1/process/getInstanceById.json?${new URLSearchParams({ processInstanceId: match.processInstanceId })}`, local);
|
|
172
|
-
if (!isTerminatedExpense(detail.content, match.processInstanceId, schema, principal, input)) return { match, terminatedIds };
|
|
173
|
-
terminatedIds.push(match.processInstanceId);
|
|
174
|
-
}
|
|
175
|
-
seen += content.data.length;
|
|
176
|
-
if (seen >= content.totalCount) return { match: null, terminatedIds: [...new Set(terminatedIds)].sort() };
|
|
177
|
-
if (!content.data.length) break;
|
|
178
|
-
}
|
|
179
|
-
fail("YIDA_EXPENSE_RECONCILIATION_INCOMPLETE");
|
|
180
|
-
}
|
|
181
|
-
const reconciled = await reconcile();
|
|
182
|
-
const existing = reconciled.match;
|
|
183
|
-
previewHash = digest({ owner, profile: principal.profile, pin: loaded.pin, fields, terminatedIds: reconciled.terminatedIds });
|
|
184
|
-
const base = { pin: loaded.pin, previewHash, summary };
|
|
185
|
-
if (existing) return { ...base, status: "already_exists", requestSent: false, verified: false, actionRequired: "An invoice/order match exists. Read its details; do not resubmit.", processInstanceId: existing.processInstanceId, instanceStatus: existing.instanceStatus };
|
|
186
|
-
if (!execute) {
|
|
187
|
-
const markdown = renderYidaConfirmation("expense", {
|
|
188
|
-
headerValues: { ...data, employeeField_m41da53i: principal.userId, dateField_m41cj93y: today,
|
|
189
|
-
radioField_m41da53k: "否", selectField_m41da53l: "不适用(非项目)",
|
|
190
|
-
numberField_m41cj94a: `${paid.toFixed(2)} ${values.textField_m41cj947}` },
|
|
191
|
-
rows: [{ ...row, numberField_m41cj94j: input.amount.toFixed(2), numberField_m41cj94p: paid.toFixed(2) }], attachments: [attachment]
|
|
192
|
-
});
|
|
193
|
-
return { ...base, status: "preview", requestSent: false,
|
|
194
|
-
confirmation: { required: true, status: "awaiting_user", previewHash, markdown } };
|
|
195
|
-
}
|
|
196
|
-
if (input.previewHash !== previewHash) fail("YIDA_EXPENSE_PREVIEW_CHANGED");
|
|
197
|
-
const operationId = `expense-${digest({ owner, profile: principal.profile, appType: schema.appType, formUuid: schema.formUuid, invoiceNumber: input.invoiceNumber, ...(reconciled.terminatedIds.length ? { terminatedIds: reconciled.terminatedIds } : {}) }).slice(7)}`;
|
|
198
|
-
const operationRoot = options.yidaOperationDirectory ?? (process.platform === "win32" ? join(process.env.APPDATA || homedir(), "Fargo AI", "yida-operations") : join(options.yidaHome ?? homedir(), ".fargo", "yida-operations"));
|
|
199
|
-
let attemptedHere = false, receipt;
|
|
200
|
-
try {
|
|
201
|
-
receipt = await runRecordedYidaMutation({ directory: join(operationRoot, digest({ owner, profile: principal.profile }).slice(7)), requestId: operationId, payloadHash: previewHash,
|
|
202
|
-
send: async () => {
|
|
203
|
-
await assertOwner();
|
|
204
|
-
await checkBinding();
|
|
205
|
-
if ((await describeYidaForm(store, schema.appType, schema.formUuid, local)).schemaHash !== reviewedHash) fail("YIDA_SCHEMA_CHANGED");
|
|
206
|
-
const duplicate = await reconcile();
|
|
207
|
-
if (duplicate.match) return { processInstanceId: duplicate.match.processInstanceId, requestSent: false };
|
|
208
|
-
if (digest(duplicate.terminatedIds) !== digest(reconciled.terminatedIds)) fail("YIDA_EXPENSE_PREVIEW_CHANGED");
|
|
209
|
-
attemptedHere = true;
|
|
210
|
-
const response = await yidaPostFormOnce(store, `/dingtalk/web/${schema.appType}/v1/process/startInstance.json`, fields,
|
|
211
|
-
{ ...local, expectedYidaPrincipal: principal, expectedFargoOwner: owner });
|
|
212
|
-
return { processInstanceId: extractYidaProcessId(response), requestSent: true };
|
|
213
|
-
} });
|
|
214
|
-
} catch (error) {
|
|
215
|
-
// Reconcile only. A new preview/request ID must not replay an ambiguous write.
|
|
216
|
-
const duplicate = await reconcile().catch(() => null);
|
|
217
|
-
if (duplicate?.match) receipt = { processInstanceId: duplicate.match.processInstanceId };
|
|
218
|
-
else return { ...base, status: "result_unknown", verified: false, requestSent: attemptedHere, operationId,
|
|
219
|
-
upstreamCode: error.upstreamCode ?? null, actionRequired: "Read the instance list and resolve the recorded operation; do not resubmit." };
|
|
220
|
-
}
|
|
221
|
-
try {
|
|
222
|
-
await assertOwner();
|
|
223
|
-
const x = await yidaRead(store, `/dingtalk/web/${schema.appType}/v1/process/getInstanceById.json?${new URLSearchParams({ processInstanceId: receipt.processInstanceId })}`, local);
|
|
224
|
-
const record = x.content;
|
|
225
|
-
const serialNumber = verifyYidaExpenseRecord(record, { processInstanceId: receipt.processInstanceId, schema, principal, input, paid, hkd, rate, hkdRate, values, company, bank, attachment });
|
|
226
|
-
await assertOwner();
|
|
227
|
-
return { ...base, status: "submitted", verified: true, verification: "api_readback", requestSent: attemptedHere, operationId,
|
|
228
|
-
processInstanceId: receipt.processInstanceId, instanceStatus: record.instanceStatus, serialNumber };
|
|
229
|
-
} catch {
|
|
230
|
-
return { ...base, status: "verification_pending", verified: false, requestSent: attemptedHere, operationId, processInstanceId: receipt.processInstanceId,
|
|
231
|
-
actionRequired: "A receipt exists. Read the instance to verify its fields; do not resubmit." };
|
|
232
|
-
}
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
export function verifyYidaExpenseRecord(record, { processInstanceId, schema, principal, input, paid, hkd, rate, hkdRate, values, company, bank, attachment }) {
|
|
236
|
-
const d = record?.data;
|
|
237
|
-
const rows = readYidaExpenseArray(d?.tableField_m41cj94c);
|
|
238
|
-
const attachments = readYidaExpenseArray(d?.attachmentField_m41cj94r);
|
|
239
|
-
const line = rows[0];
|
|
240
|
-
if (record?.processInstanceId !== processInstanceId || record.formUuid !== schema.formUuid || record.processCode !== schema.entry.processCode ||
|
|
241
|
-
record.originator?.userId !== principal.userId || !d || !line || rows.length !== 1 ||
|
|
242
|
-
!JSON.stringify(d).includes(input.invoiceNumber) || !JSON.stringify(d).includes(input.paymentOrder) ||
|
|
243
|
-
Number(d.numberField_m41cj94a) !== paid || Number(line.numberField_m41cj94j) !== input.amount || Number(line.numberField_m41cj94p) !== paid ||
|
|
244
|
-
Number(line.numberField_m41cj94n) !== rate || Number(line.numberField_m41cj959) !== hkdRate ||
|
|
245
|
-
Number(line.numberField_m41cj94o) !== hkd || Number(d.numberField_m41cj95d) !== hkd ||
|
|
246
|
-
line.selectField_m41cj94f !== input.expenseCategory || line.selectField_m4aplvi5 !== input.accountingItem ||
|
|
247
|
-
line.selectField_m41cj94m !== input.currency || d.textField_m41cj947 !== values.textField_m41cj947 ||
|
|
248
|
-
d.selectField_m41cj946 !== company || d.selectField_m8lq9x6l !== input.costCompany ||
|
|
249
|
-
d.textField_m41cj949 !== bank.textField_m41cj949 || d.textField_m4vmcrx7 !== bank.textField_m4vmcrx7 ||
|
|
250
|
-
attachments.length !== 1 || attachments[0]?.fileUuid !== attachment.fileUuid ||
|
|
251
|
-
attachments[0]?.name !== attachment.name || Number(attachments[0]?.size) !== attachment.size) fail("YIDA_EXPENSE_READBACK_MISMATCH");
|
|
252
|
-
return record.data.serialNumberField_m41cj93z ?? null;
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
// Terminal records do not block a fresh independent expense. No association is
|
|
256
|
-
// written into business data; local replay protection uses the verified history.
|
|
257
|
-
export function isTerminatedExpense(record, id, schema, principal, input) {
|
|
258
|
-
return record?.processInstanceId === id && record.formUuid === schema.formUuid && record.processCode === schema.entry.processCode &&
|
|
259
|
-
record.originator?.userId === principal.userId && ["TERMINATED", "CANCELED"].includes(record.instanceStatus) &&
|
|
260
|
-
(JSON.stringify(record.data ?? {}).includes(input.invoiceNumber) || JSON.stringify(record.data ?? {}).includes(input.paymentOrder));
|
|
261
|
-
}
|
package/src/yida-lookup-plan.mjs
DELETED
|
@@ -1,59 +0,0 @@
|
|
|
1
|
-
// Operation-scoped dependency execution. No persistent cache, page JavaScript,
|
|
2
|
-
// authentication reuse, or writes. The caller supplies its authorized lookup.
|
|
3
|
-
export async function resolveYidaLookupPlan(schema, plan, seeds, aliases, lookup, concurrency = 4) {
|
|
4
|
-
const fail = () => { throw new Error("YIDA_LOOKUP_PLAN_INVALID"); };
|
|
5
|
-
if (!Number.isInteger(concurrency) || concurrency < 1 || concurrency > 8 || plan.length > 100) fail();
|
|
6
|
-
const nodes = new Map(), providers = new Map();
|
|
7
|
-
for (const step of plan) {
|
|
8
|
-
if (nodes.has(step.name) || !["one", "list"].includes(step.result)) fail();
|
|
9
|
-
const rules = schema.runtime.rules.filter(r => r.fieldId === step.fieldId && r.kind === step.kind);
|
|
10
|
-
if (rules.length !== 1 || !Array.isArray(rules[0].inputs)) fail();
|
|
11
|
-
nodes.set(step.name, { ...step, rule: rules[0] });
|
|
12
|
-
if (step.kind === "linked_value" && step.result === "one") {
|
|
13
|
-
if (providers.has(step.fieldId) || Object.hasOwn(seeds, step.fieldId) || Object.hasOwn(aliases, step.fieldId)) fail();
|
|
14
|
-
providers.set(step.fieldId, step.name);
|
|
15
|
-
}
|
|
16
|
-
}
|
|
17
|
-
// Validate the whole graph before issuing any lookup, including cycles through aliases.
|
|
18
|
-
const visiting = new Set(), visited = new Set();
|
|
19
|
-
function checkField(id) {
|
|
20
|
-
if (Object.hasOwn(seeds, id)) return;
|
|
21
|
-
if (visited.has(id)) return;
|
|
22
|
-
if (visiting.has(id)) fail();
|
|
23
|
-
visiting.add(id);
|
|
24
|
-
if (Object.hasOwn(aliases, id)) checkField(aliases[id]);
|
|
25
|
-
else if (providers.has(id)) nodes.get(providers.get(id)).rule.inputs.forEach(checkField);
|
|
26
|
-
else fail();
|
|
27
|
-
visiting.delete(id); visited.add(id);
|
|
28
|
-
}
|
|
29
|
-
for (const node of nodes.values()) node.rule.inputs.forEach(checkField);
|
|
30
|
-
let active = 0;
|
|
31
|
-
const waiting = [];
|
|
32
|
-
async function limited(fn) {
|
|
33
|
-
if (active >= concurrency) await new Promise(resolve => waiting.push(resolve));
|
|
34
|
-
else active++;
|
|
35
|
-
try { return await fn(); }
|
|
36
|
-
finally { if (waiting.length) waiting.shift()(); else active--; }
|
|
37
|
-
}
|
|
38
|
-
const pending = new Map();
|
|
39
|
-
function field(id) {
|
|
40
|
-
if (Object.hasOwn(seeds, id)) return Promise.resolve(seeds[id]);
|
|
41
|
-
if (Object.hasOwn(aliases, id)) return field(aliases[id]);
|
|
42
|
-
return run(providers.get(id));
|
|
43
|
-
}
|
|
44
|
-
function run(name) {
|
|
45
|
-
if (!pending.has(name)) {
|
|
46
|
-
const node = nodes.get(name);
|
|
47
|
-
pending.set(name, (async () => {
|
|
48
|
-
const pairs = await Promise.all(node.rule.inputs.map(async id => [id, await field(id)]));
|
|
49
|
-
const values = await limited(() => lookup(node.rule, Object.fromEntries(pairs)));
|
|
50
|
-
if (!Array.isArray(values) || values.length >= 100) throw new Error("YIDA_EXPENSE_LOOKUP_INCOMPLETE");
|
|
51
|
-
if (node.result === "list") return values;
|
|
52
|
-
if (values.length !== 1 || !["string", "number"].includes(typeof values[0]) || values[0] === "") throw new Error("YIDA_EXPENSE_DEFAULT_AMBIGUOUS");
|
|
53
|
-
return values[0];
|
|
54
|
-
})());
|
|
55
|
-
}
|
|
56
|
-
return pending.get(name);
|
|
57
|
-
}
|
|
58
|
-
return Object.fromEntries(await Promise.all([...nodes.keys()].map(async name => [name, await run(name)])));
|
|
59
|
-
}
|