#!/usr/bin/env node const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); const vm = require('node:vm'); const root = path.resolve(__dirname, '..'); const sourceRoot = path.join(root, 'prototype-src'); const manifest = JSON.parse(fs.readFileSync(path.join(sourceRoot, 'manifest.json'), 'utf8')); const financeHtml = fs.readFileSync(path.join(sourceRoot, 'modules', '08-finance.html'), 'utf8'); const shellHtml = fs.readFileSync(path.join(sourceRoot, 'shell.html'), 'utf8'); const overlaysHtml = fs.readFileSync(path.join(sourceRoot, 'overlays.html'), 'utf8'); const appJs = fs.readFileSync(path.join(sourceRoot, 'app.js'), 'utf8'); function countMatches(source, pattern) { return [...source.matchAll(pattern)].length; } function extractPage(source, pageId) { const escaped = pageId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const opening = new RegExp(`]*\\bid=["']page-${escaped}["'][^>]*>`, 'i').exec(source); if (!opening) return ''; const rest = source.slice(opening.index + opening[0].length); const nextPage = /]*\bclass=["'][^"']*\bpage\b[^"']*["'][^>]*\bid=["']page-/i.exec(rest); return source.slice(opening.index, nextPage ? opening.index + opening[0].length + nextPage.index : source.length); } const pageId = 'finance-ar-detail-report'; const financeModule = manifest.modules.find(item => item.file === 'modules/08-finance.html'); assert.ok(financeModule, '缺少财务模块 manifest 登记'); assert.equal(financeModule.pages.filter(page => page === pageId).length, 1, '财务模块必须唯一登记报表页面'); assert.equal(manifest.pageOrder.filter(page => page === pageId).length, 1, '根 pageOrder 必须唯一登记报表页面'); assert.equal(countMatches(financeHtml, /id=["']page-finance-ar-detail-report["']/g), 1, '财务模块必须唯一包含报表页面'); const navEntry = `switchPage('${pageId}',this)`; const navIndex = shellHtml.indexOf(navEntry); const arapIndex = shellHtml.indexOf("switchPage('customer-arap',this)"); const reconIndex = shellHtml.indexOf("switchPage('customer-recon-stmt',this)"); assert.ok(navIndex >= 0, '财务导航缺少应收款统计表入口'); assert.ok(arapIndex >= 0 && navIndex > arapIndex, '应收款统计表入口应位于客户应收应付之后'); assert.ok(reconIndex >= 0 && navIndex < reconIndex, '应收款统计表入口应位于客户对账单之前'); assert.match( shellHtml, /]*\bonclick=["']switchPage\('finance-ar-detail-report',this\)["'][^>]*>\s*应收款统计表\s*<\/div>/, '财务导航入口必须使用“应收款统计表”文案', ); const reportPage = extractPage(financeHtml, pageId); assert.ok(reportPage, '财务模块缺少应收款统计表页面'); assert.ok(reportPage.includes('应收款统计表'), '页面标题和面包屑必须使用“应收款统计表”'); assert.match(reportPage, /]*for=["']ardr-month["'][^>]*>统计月份<\/label>/, '筛选项必须改为统计月份'); assert.match(reportPage, /]*\bid=["']ardr-month["'][^>]*\btype=["']month["'][^>]*\bvalue=["']2026-07["']/, '统计月份必须使用 month 控件并默认 2026-07'); assert.doesNotMatch(reportPage, /\bid=["']ardr-year["']/, '页面不应继续保留统计年度控件'); [ 'ardr-entity', 'ardr-currency', 'ardr-total-receivable', 'ardr-total-received', 'ardr-total-deduction', 'ardr-total-balance', 'thead-receivable-detail-report', 'tbody-receivable-detail-report', ].forEach(id => assert.match(reportPage, new RegExp(`\\bid=["']${id}["']`), `页面缺少字段 ${id}`)); ['应收总金额', '已收合计', '零头减免', '余额未收', '客户', '备注'] .forEach(label => assert.ok(reportPage.includes(label), `页面缺少文案 ${label}`)); assert.match(reportPage, /]*\bid=["']ardr-table-title["'][^>]*>应收款明细<\/span>/, '列表标题不应显示统计月份'); assert.doesNotMatch(reportPage, /position\s*:\s*sticky/i, '标准列表不应使用页面专属冻结列'); assert.doesNotMatch(reportPage, /ardr-month-group/, '标准列表不应使用月份分组表头'); assert.match(overlaysHtml, /]*\bid=["']modal-receivable-report-edit["']/, '缺少应收款明细编辑弹窗'); assert.match(overlaysHtml, /]*\bid=["']ardr-edit-entity["'][^>]*\breadonly\b/, '应收单位必须只读'); assert.match(overlaysHtml, /]*\bid=["']ardr-edit-customer["'][^>]*\brequired\b[^>]*\bmaxlength=["']50["']/, '客户必须必填且最多 50 字'); assert.match(overlaysHtml, /]*\bid=["']ardr-edit-remark["'][^>]*\bmaxlength=["']200["']/, '备注必须最多 200 字'); assert.match(overlaysHtml, /onclick=["']saveReceivableReportEdit\(\)["']/, '编辑弹窗保存按钮未接入保存函数'); const calculateStart = appJs.indexOf('function calculateReceivableReportRow'); const renderStart = appJs.indexOf('function renderReceivableDetailReport'); assert.ok(calculateStart >= 0 && renderStart > calculateStart, '缺少应收款统计表连续函数区间'); const reportFunctions = appJs.slice(calculateStart, renderStart); ['calculateReceivableReportRow', 'summarizeReceivableReportByCurrency', 'buildReceivableReportHeader', 'updateReceivableReportEditableFields'] .forEach(name => assert.match(reportFunctions, new RegExp(`function\\s+${name}\\s*\\(`), `函数区间缺少 ${name}`)); const sandbox = {}; vm.runInNewContext( `${reportFunctions}\nthis.api={calculateReceivableReportRow,summarizeReceivableReportByCurrency,buildReceivableReportHeader,updateReceivableReportEditableFields};`, sandbox, {filename: 'receivable-report-functions.js', timeout: 1000}, ); const api = sandbox.api; const julyRow = api.calculateReceivableReportRow({ year: 2026, customer: '客户甲', receivableEntity: '主体A', currency: 'CNY', monthlyAmounts: { 6: {direct: 50, special: 10, received: 40, deduction: 0}, 7: {direct: 100, special: 20, received: 90, deduction: 5}, }, }, '2026-07'); assert.equal(julyRow.monthKey, '2026-07', '行数据必须记录所选月份'); assert.deepEqual( JSON.parse(JSON.stringify(julyRow.month)), { firstLeg: 100, relabel: 20, warehouseTransit: 0, goodsDisposal: 0, returnRelabel: 0, usDomesticTruck: 0, usDomesticLooseBoard: 0, }, '行数据只能读取所选月份的 7 项业务金额', ); assert.equal(julyRow.receivableAmount, 120, '应收总金额必须为所选月份两项业务金额之和'); assert.equal(julyRow.receivedAmount, 90, '已收合计必须读取所选月份金额'); assert.equal(julyRow.deductionAmount, 5, '零头减免必须读取所选月份金额'); assert.equal(julyRow.balanceAmount, 25, '余额未收必须按所选月份计算'); const missingRow = api.calculateReceivableReportRow({ year: 2026, currency: 'CNY', monthlyAmounts: {7: {direct: 100, special: 20, received: 90, deduction: 5}}, }, '2026-08'); assert.deepEqual( JSON.parse(JSON.stringify(missingRow.month)), { firstLeg: null, relabel: null, warehouseTransit: null, goodsDisposal: null, returnRelabel: null, usDomesticTruck: null, usDomesticLooseBoard: null, }, '缺失月份必须返回空业务金额', ); assert.equal(missingRow.receivableAmount, 0, '缺失月份应收金额必须为 0'); assert.equal(missingRow.receivedAmount, 0, '缺失月份已收金额必须为 0'); assert.equal(missingRow.balanceAmount, 0, '缺失月份余额必须为 0'); const usdRow = api.calculateReceivableReportRow({ year: 2026, currency: 'USD', monthlyAmounts: {7: {direct: 10, special: 5, received: 3, deduction: 2}}, }, '2026-07'); const summary = api.summarizeReceivableReportByCurrency([julyRow, usdRow]); assert.deepEqual( JSON.parse(JSON.stringify(summary)), { CNY: {receivable: 120, received: 90, deduction: 5, balance: 25}, USD: {receivable: 15, received: 3, deduction: 2, balance: 10}, }, '应按币种汇总所选月份金额', ); const editableRecord = {customer: '原客户', remark: '原备注'}; assert.equal(api.updateReceivableReportEditableFields(editableRecord, ' 新客户 ', ' 新备注 '), true, '有效编辑应返回 true'); assert.deepEqual(editableRecord, {customer: '新客户', remark: '新备注'}, '客户和备注应去除首尾空格后更新'); const invalidRecord = {customer: '保留客户', remark: '保留备注'}; assert.equal(api.updateReceivableReportEditableFields(invalidRecord, ' ', '不应写入'), false, '空客户应返回 false'); assert.deepEqual(invalidRecord, {customer: '保留客户', remark: '保留备注'}, '校验失败时不应修改原数据'); const header = api.buildReceivableReportHeader('2026-07'); assert.equal(typeof header, 'string', '单月表头必须生成 HTML 字符串'); assert.equal(countMatches(header, //g), 1, '标准列表必须使用单层表头'); assert.equal(countMatches(header, /操作<\/th><\/tr>$/, '操作必须是最后一列'); assert.equal(countMatches(header, /ardr-month-group/g), 0, '表头不应生成月份分组'); ['头程', '换标', '仓储转运', '货物废弃', '退货换标', '美国本土卡', '美本土散板'] .forEach(label => assert.equal(countMatches(header, new RegExp(`${label}<\/th>`, 'g')), 1, `表头必须包含一个 ${label}`)); assert.doesNotMatch(header, /FBA直发|FBA专线/, '表头不应继续使用旧 FBA 业务列'); assert.ok(!header.includes('2026.07'), '表头不应显示所选月份'); assert.match(appJs, /if\(page==='finance-ar-detail-report'\)renderReceivableDetailReport\(\)/, 'switchPage 未初始化应收款统计表'); assert.ok(appJs.includes("getElementById('ardr-month')"), '渲染逻辑必须读取统计月份'); assert.ok(appJs.includes("month.value='2026-07'"), '重置必须恢复默认月份 2026-07'); assert.ok(appJs.includes('暂无匹配的应收款明细'), '缺少单月统计空态文案'); assert.match(appJs, /function\s+openReceivableReportEdit\s*\(/, '缺少打开单条编辑弹窗函数'); assert.match(appJs, /function\s+saveReceivableReportEdit\s*\(/, '缺少保存单条编辑函数'); assert.ok(appJs.includes('请输入客户'), '缺少客户必填校验提示'); assert.ok(appJs.includes('应收款明细已更新'), '缺少编辑成功提示'); assert.ok(appJs.includes('应收款统计表已导出'), '缺少新报表名称的导出反馈'); assert.ok(appJs.includes('正在打开打印预览'), '缺少打印反馈'); assert.match( appJs, /]*\btype=["']button["'][^>]*\bclass=["']btn btn-sm btn-primary["'][^>]*\bonclick="openReceivableReportEdit\([^\"]*\)"[^>]*>编辑<\/button>/, '应收款明细行编辑按钮必须使用 btn btn-sm btn-primary 样式并保留编辑行为', ); assert.doesNotMatch( appJs, /]*\bclass=["'][^"']*\bbtn-link\b[^"']*["'][^>]*\bonclick=["']openReceivableReportEdit\(/, '应收款明细行编辑按钮不应使用未定义的 btn-link 样式', ); console.log('应收款统计表校验通过');