feat(报销): 收敛只读内容与审批上下文 [09-02-reimbursement-readonly-content-consistency]
这个提交包含在:
@@ -7,7 +7,6 @@ export type ReimbursementWorkspaceHeaderModel = {
|
||||
displayCode: string;
|
||||
ownerName: string;
|
||||
reimbursementDate: string | null;
|
||||
totalAmount: string | null;
|
||||
description: string | null;
|
||||
};
|
||||
|
||||
@@ -17,8 +16,14 @@ export type ReimbursementWorkspaceIssueAnchor = {
|
||||
severity: "blocking" | "review" | "info";
|
||||
};
|
||||
|
||||
export type ReimbursementAmountSummary = {
|
||||
authoritativeTotalAmount: string | null;
|
||||
integrity: "ready" | "mismatch" | "unavailable";
|
||||
};
|
||||
|
||||
export type ReimbursementWorkspaceModel = {
|
||||
header: ReimbursementWorkspaceHeaderModel;
|
||||
amountSummary: ReimbursementAmountSummary;
|
||||
evidence: ReimbursementApprovalEvidenceProjection;
|
||||
issueAnchors: ReimbursementWorkspaceIssueAnchor[];
|
||||
};
|
||||
@@ -31,27 +36,69 @@ export type ReimbursementWorkspaceSlots = {
|
||||
afterEvidence?: ReactNode;
|
||||
timeline?: ReactNode;
|
||||
actionPanel?: ReactNode;
|
||||
asideContext?: ReactNode;
|
||||
mobileActions?: ReactNode;
|
||||
transientFeedback?: ReactNode;
|
||||
};
|
||||
|
||||
export function buildReimbursementWorkspaceHeader(
|
||||
detail: Pick<ReimbursementDetail, "formNumber" | "owner" | "reimbursementDate" | "totalAmount" | "description" | "id">,
|
||||
detail: Pick<ReimbursementDetail, "formNumber" | "owner" | "reimbursementDate" | "description" | "id">,
|
||||
): ReimbursementWorkspaceHeaderModel {
|
||||
return {
|
||||
displayCode: resolveReimbursementDisplayCode(detail),
|
||||
ownerName: detail.owner?.name ?? "-",
|
||||
reimbursementDate: detail.reimbursementDate,
|
||||
totalAmount: detail.totalAmount,
|
||||
description: detail.description,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseMoneyToMinorUnits(value: string | null | undefined): bigint | null {
|
||||
if (typeof value !== "string" || !/^(?:0|[1-9]\d*)(?:\.\d{1,2})?$/.test(value)) return null;
|
||||
const [whole, fraction = ""] = value.split(".");
|
||||
return BigInt(whole) * 100n + BigInt(fraction.padEnd(2, "0"));
|
||||
}
|
||||
|
||||
export function buildReimbursementAmountSummary(
|
||||
authoritativeTotalAmount: string | null,
|
||||
items: Array<{ amount: string }>,
|
||||
): ReimbursementAmountSummary {
|
||||
const authoritativeMinorUnits = parseMoneyToMinorUnits(authoritativeTotalAmount);
|
||||
const itemMinorUnits = items.map((item) => parseMoneyToMinorUnits(item.amount));
|
||||
if (authoritativeMinorUnits === null || itemMinorUnits.some((value) => value === null)) {
|
||||
return { authoritativeTotalAmount, integrity: "unavailable" };
|
||||
}
|
||||
const derivedMinorUnits = itemMinorUnits.reduce<bigint>((sum, value) => sum + (value ?? 0n), 0n);
|
||||
return {
|
||||
authoritativeTotalAmount,
|
||||
integrity: authoritativeMinorUnits === derivedMinorUnits ? "ready" : "mismatch",
|
||||
};
|
||||
}
|
||||
|
||||
export function reimbursementAmountIntegrityMessage(integrity: ReimbursementAmountSummary["integrity"]) {
|
||||
if (integrity === "mismatch") return "报销总额与明细汇总不一致,请联系管理员";
|
||||
if (integrity === "unavailable") return "明细金额无法完整核对,请联系管理员";
|
||||
return null;
|
||||
}
|
||||
|
||||
export function buildReimbursementWorkspaceIssueAnchors(
|
||||
evidence: ReimbursementApprovalEvidenceProjection,
|
||||
options: { snapshotMissing?: boolean; snapshotInvalid?: boolean } = {},
|
||||
options: {
|
||||
amountIntegrity?: ReimbursementAmountSummary["integrity"];
|
||||
snapshotMissing?: boolean;
|
||||
snapshotInvalid?: boolean;
|
||||
} = {},
|
||||
): ReimbursementWorkspaceIssueAnchor[] {
|
||||
const anchors: ReimbursementWorkspaceIssueAnchor[] = [];
|
||||
const amountIntegrityMessage = options.amountIntegrity
|
||||
? reimbursementAmountIntegrityMessage(options.amountIntegrity)
|
||||
: null;
|
||||
if (amountIntegrityMessage) {
|
||||
anchors.push({
|
||||
id: "reimbursement-workspace-amount-summary",
|
||||
label: amountIntegrityMessage,
|
||||
severity: "blocking",
|
||||
});
|
||||
}
|
||||
if (options.snapshotInvalid) {
|
||||
anchors.push({ id: "reimbursement-workspace-snapshot", label: "提交记录无法读取", severity: "blocking" });
|
||||
} else if (options.snapshotMissing) {
|
||||
@@ -94,3 +141,23 @@ export function buildReimbursementWorkspaceIssueAnchors(
|
||||
}
|
||||
return anchors;
|
||||
}
|
||||
|
||||
export function buildReimbursementWorkspaceModel(
|
||||
detail: Pick<
|
||||
ReimbursementDetail,
|
||||
"formNumber" | "owner" | "reimbursementDate" | "totalAmount" | "description" | "id"
|
||||
>,
|
||||
evidence: ReimbursementApprovalEvidenceProjection,
|
||||
options: { snapshotMissing?: boolean; snapshotInvalid?: boolean } = {},
|
||||
): ReimbursementWorkspaceModel {
|
||||
const amountSummary = buildReimbursementAmountSummary(detail.totalAmount, evidence.items);
|
||||
return {
|
||||
header: buildReimbursementWorkspaceHeader(detail),
|
||||
amountSummary,
|
||||
evidence,
|
||||
issueAnchors: buildReimbursementWorkspaceIssueAnchors(evidence, {
|
||||
...options,
|
||||
amountIntegrity: amountSummary.integrity,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
在新工单中引用
屏蔽一个用户