完善借款、编号、付款与预算并发及验收(09-06-release-gate-integrity)

汇总已落盘的借款本金与还款、兼容历史编号的周期化编号、付款责任与回单核验、报销进度和排序、PWA/采购事务、错误原因与日期校验及ERP退役。
统一共享预算池锁序和锁后判断,恢复差旅原预算选择,补原因码固定夹具及反向完整性检查。
纳入配套迁移、测试、受管QA、分层验收合同、规约及任务记录;原始证据留在ignored artifacts。

关联任务:09-17-company-loan-principal-repayment、09-17-erp-exclusive-retirement、09-18-reimbursement-periodic-numbering、09-08-reimbursement-payment-closeout、09-18-local-concurrency-product-fixes、09-19-budget-pool-concurrency、09-05-error-explainability-payment-readiness。
预算补审由Claude定向验证2文件928项通过,原4446/28及r6结果仍仅对应各自历史输入;本轮未重跑测试。
完整新候选门禁、R1真实入口接入与12项公网义务仍开放。生产编号未启用、差旅仍冻结;不推送、不部署,不宣称发布资格。
这个提交包含在:
2026-09-19 13:11:04 +08:00
父节点 729d90576f
当前提交 1f8d5b658a
共修改 367 个文件,包含 21079 行新增和 12287 行删除
@@ -19,9 +19,13 @@ type RequestLedgerEntry = {
method: string;
path: string;
status: number;
time: number;
};
type WorkerEvent = { time: number; event: string; state: string | null; documentSha: string | null };
type BrowserWitnessState = {
events: WorkerEvent[];
controllerChanges: number;
updateReadyEvents: number;
};
@@ -29,6 +33,7 @@ type BrowserWitnessState = {
type CacheSnapshot = {
names: string[];
paths: string[];
indexSha256: string[];
};
type ServiceWorkerSnapshot = {
@@ -122,6 +127,7 @@ function recordResponse(request: IncomingMessage, pathname: string, status: numb
method: request.method ?? "GET",
path: pathname,
status,
time: Date.now(),
});
}
@@ -377,19 +383,42 @@ async function installWitnessObservers(context: BrowserContext) {
const readState = (): BrowserWitnessState => {
try {
const current = window.sessionStorage.getItem(storageKey);
return current ? JSON.parse(current) as BrowserWitnessState : { controllerChanges: 0, updateReadyEvents: 0 };
return current ? JSON.parse(current) as BrowserWitnessState : { controllerChanges: 0, updateReadyEvents: 0, events: [] };
} catch {
return { controllerChanges: 0, updateReadyEvents: 0 };
return { controllerChanges: 0, updateReadyEvents: 0, events: [] };
}
};
const writeState = (state: BrowserWitnessState) => {
window.sessionStorage.setItem(storageKey, JSON.stringify(state));
};
writeState(readState());
const record = (event: string, worker?: ServiceWorker | null) => {
const state = readState();
state.events.push({ time: Date.now(), event, state: worker?.state ?? null,
documentSha: document.documentElement?.dataset.buildSha ?? null });
state.events = state.events.slice(-100);
writeState(state);
};
record("document-start", window.navigator.serviceWorker.controller);
document.addEventListener("DOMContentLoaded", () => record("document-ready", window.navigator.serviceWorker.controller));
const watched = new WeakSet<ServiceWorker>();
const watch = (worker: ServiceWorker | null, role: string) => {
if (!worker || watched.has(worker)) return;
watched.add(worker);
record(role, worker);
worker.addEventListener("statechange", () => record("worker-state", worker));
};
void window.navigator.serviceWorker.getRegistration().then((registration) => {
if (!registration) return;
watch(registration.active, "active-observed");
watch(registration.installing, "installing-observed");
registration.addEventListener("updatefound", () => watch(registration.installing, "update-found"));
});
window.navigator.serviceWorker.addEventListener("controllerchange", () => {
const state = readState();
state.controllerChanges += 1;
writeState(state);
record("controller-change", window.navigator.serviceWorker.controller);
});
window.addEventListener("hyb-oa:pwa-update-ready", () => {
const state = readState();
@@ -402,7 +431,7 @@ async function installWitnessObservers(context: BrowserContext) {
async function readWitnessState(page: Page): Promise<BrowserWitnessState> {
return page.evaluate((storageKey) => {
const value = window.sessionStorage.getItem(storageKey);
return value ? JSON.parse(value) as BrowserWitnessState : { controllerChanges: 0, updateReadyEvents: 0 };
return value ? JSON.parse(value) as BrowserWitnessState : { controllerChanges: 0, updateReadyEvents: 0, events: [] };
}, WITNESS_STATE_KEY);
}
@@ -410,13 +439,22 @@ async function readCacheSnapshot(page: Page): Promise<CacheSnapshot> {
return page.evaluate(async () => {
const names = await window.caches.keys();
const paths = new Set<string>();
const indexSha256: string[] = [];
for (const name of names) {
const cache = await window.caches.open(name);
for (const request of await cache.keys()) {
paths.add(new URL(request.url).pathname);
const pathname = new URL(request.url).pathname;
paths.add(pathname);
if (pathname === "/index.html") {
const response = await cache.match(request);
if (response) {
const digest = await window.crypto.subtle.digest("SHA-256", await response.arrayBuffer());
indexSha256.push(Array.from(new Uint8Array(digest), (value) => value.toString(16).padStart(2, "0")).join(""));
}
}
}
}
return { names, paths: [...paths].sort() };
return { names, paths: [...paths].sort(), indexSha256 };
});
}
@@ -433,7 +471,7 @@ async function controllerPath(page: Page) {
async function serviceWorkerSnapshot(page: Page): Promise<ServiceWorkerSnapshot> {
return page.evaluate(async () => {
const registration = await window.navigator.serviceWorker.ready;
const registration = await window.navigator.serviceWorker.getRegistration();
const controller = window.navigator.serviceWorker.controller;
const response = await window.fetch("/sw.js", { cache: "no-store" });
if (!response.ok) {
@@ -441,13 +479,19 @@ async function serviceWorkerSnapshot(page: Page): Promise<ServiceWorkerSnapshot>
}
const digest = await window.crypto.subtle.digest("SHA-256", await response.arrayBuffer());
return {
controllerIsActive: controller !== null && registration.active === controller,
controllerIsActive: controller !== null && registration?.active === controller,
controllerState: controller?.state ?? null,
fetchedSha256: Array.from(new Uint8Array(digest), (value) => value.toString(16).padStart(2, "0")).join(""),
};
});
}
async function loadedAssets(page: Page) {
return page.evaluate(() => performance.getEntriesByType("resource")
.filter((entry) => (entry as PerformanceResourceTiming).responseEnd > 0)
.map((entry) => new URL(entry.name).pathname));
}
function requestCount(build: BuildLabel, pathname: string, status = 200) {
return requestLedger.filter((entry) => entry.build === build && entry.path === pathname && entry.status === status).length;
}
@@ -457,15 +501,16 @@ function safeLedger(input: {
documentSha: string | null;
controller: string | null;
cacheNames: string[];
aOnlyCached: number;
bOnlyCached: number;
bOnlyLoaded: number;
aOnlyCached: number | null;
bOnlyCached: number | null;
bOnlyLoaded: number | null;
navigationCount: number;
witnessState: BrowserWitnessState;
serviceWorker: ServiceWorkerSnapshot;
witnessState: BrowserWitnessState | null;
serviceWorker: ServiceWorkerSnapshot | null;
}) {
return {
phase: input.phase,
workerEvents: input.witnessState?.events ?? null,
documentSha: input.documentSha,
controller: input.controller,
cacheNames: input.cacheNames.sort(),
@@ -473,16 +518,16 @@ function safeLedger(input: {
bOnlyCached: input.bOnlyCached,
bOnlyLoaded: input.bOnlyLoaded,
navigationCount: input.navigationCount,
controllerChanges: input.witnessState.controllerChanges,
controllerIsActive: input.serviceWorker.controllerIsActive,
controllerState: input.serviceWorker.controllerState,
updateReadyEvents: input.witnessState.updateReadyEvents,
controllerChanges: input.witnessState?.controllerChanges ?? null,
controllerIsActive: input.serviceWorker?.controllerIsActive ?? null,
controllerState: input.serviceWorker?.controllerState ?? null,
updateReadyEvents: input.witnessState?.updateReadyEvents ?? null,
refresh200: requestCount(activeBuild.label, "/api/auth/refresh"),
me200: requestCount(activeBuild.label, "/api/me"),
loginNavigations: requestLedger.filter((entry) => entry.path === "/login").length,
static404: requestLedger.filter((entry) => entry.status === 404 && /\.(?:css|js)$/u.test(entry.path)).length,
swSha256Prefix: activeBuild.swSha256.slice(0, 12),
fetchedSwSha256Matches: input.serviceWorker.fetchedSha256 === activeBuild.swSha256,
fetchedSwSha256Matches: input.serviceWorker ? input.serviceWorker.fetchedSha256 === activeBuild.swSha256 : null,
};
}
@@ -576,11 +621,7 @@ test("production PWA 在同 profile 中从旧 build 自动收敛到新 build", a
const cacheB = await readCacheSnapshot(pageB);
const finalAOnly = [...aOnlyAssets].filter((assetPath) => cacheB.paths.includes(assetPath));
const finalBOnly = [...bOnlyAssets].filter((assetPath) => cacheB.paths.includes(assetPath));
const loadedAssetPaths = await pageB.evaluate(() => [
...Array.from(document.querySelectorAll<HTMLScriptElement>("script[src]"), (element) => new URL(element.src).pathname),
...Array.from(document.querySelectorAll<HTMLLinkElement>("link[href]"), (element) => new URL(element.href).pathname),
...performance.getEntriesByType("resource").map((entry) => new URL(entry.name).pathname),
]);
const loadedAssetPaths = await loadedAssets(pageB);
const loadedBOnly = [...bOnlyAssets].filter((assetPath) => loadedAssetPaths.includes(assetPath));
const stateB = await readWitnessState(pageB);
workerB = await serviceWorkerSnapshot(pageB);
@@ -593,7 +634,8 @@ test("production PWA 在同 profile 中从旧 build 自动收敛到新 build", a
fetchedSha256: buildB.swSha256,
});
expect(finalAOnly).toEqual([]);
expect(finalBOnly.length + loadedBOnly.length).toBeGreaterThan(0);
expect(finalBOnly.length).toBeGreaterThan(0);
expect(loadedBOnly.length).toBeGreaterThan(0);
expect(pageB.url()).not.toContain("/login");
expect(requestLedger.filter((entry) => entry.status === 404 && /\.(?:css|js)$/u.test(entry.path))).toEqual([]);
expect(pageErrors).toEqual([]);
@@ -614,21 +656,24 @@ test("production PWA 在同 profile 中从旧 build 自动收敛到新 build", a
serviceWorker: workerB,
})));
} catch (error) {
const cacheB = await readCacheSnapshot(pageB).catch(() => ({ names: [], paths: [] }));
const stateB = await readWitnessState(pageB).catch(() => ({ controllerChanges: 0, updateReadyEvents: 0 }));
const frozenWorkerB = workerB ?? {
controllerIsActive: false,
controllerState: null,
fetchedSha256: "",
};
const cacheB = await readCacheSnapshot(pageB).catch(() => null);
const stateB = await readWitnessState(pageB).catch(() => null);
const frozenWorkerB = await serviceWorkerSnapshot(pageB).catch(() => null);
const loadedPaths = await loadedAssets(pageB).catch(() => null);
console.log("[pwa-cross-version-diagnostic]", JSON.stringify({
time: Date.now(), pageErrors, chunkErrors,
cacheObserved: cacheB !== null,
cachedIndexBuilds: cacheB?.indexSha256.map((digest) => digest === sha256(readFileSync(path.join(buildA.root, "index.html"))) ? "A" : digest === sha256(readFileSync(path.join(buildB.root, "index.html"))) ? "B" : "unknown") ?? null,
documentResponses: requestLedger.filter((entry) => ["/portal", "/index.html", "/sw.js"].includes(entry.path)),
}));
console.log("[pwa-cross-version]", JSON.stringify(safeLedger({
phase: "B_FIRST_FAILURE_FROZEN",
documentSha: await documentBuildSha(pageB).catch(() => null),
controller: await controllerPath(pageB).catch(() => null),
cacheNames: cacheB.names,
aOnlyCached: [...aOnlyAssets].filter((assetPath) => cacheB.paths.includes(assetPath)).length,
bOnlyCached: [...bOnlyAssets].filter((assetPath) => cacheB.paths.includes(assetPath)).length,
bOnlyLoaded: 0,
cacheNames: cacheB?.names ?? [],
aOnlyCached: cacheB ? [...aOnlyAssets].filter((assetPath) => cacheB.paths.includes(assetPath)).length : null,
bOnlyCached: cacheB ? [...bOnlyAssets].filter((assetPath) => cacheB.paths.includes(assetPath)).length : null,
bOnlyLoaded: loadedPaths ? [...bOnlyAssets].filter((assetPath) => loadedPaths.includes(assetPath)).length : null,
navigationCount: bNavigations,
witnessState: stateB,
serviceWorker: frozenWorkerB,