199 lines
11 KiB
JavaScript
199 lines
11 KiB
JavaScript
#!/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(`<div\\b[^>]*\\bid=["']page-${escaped}["'][^>]*>`, 'i').exec(source);
|
|
if (!opening) return '';
|
|
const rest = source.slice(opening.index + opening[0].length);
|
|
const nextPage = /<div\b[^>]*\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,
|
|
/<div\b[^>]*\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, /<label\b[^>]*for=["']ardr-month["'][^>]*>统计月份<\/label>/, '筛选项必须改为统计月份');
|
|
assert.match(reportPage, /<input\b[^>]*\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, /<span\b[^>]*\bid=["']ardr-table-title["'][^>]*>应收款明细<\/span>/, '列表标题不应显示统计月份');
|
|
assert.doesNotMatch(reportPage, /position\s*:\s*sticky/i, '标准列表不应使用页面专属冻结列');
|
|
assert.doesNotMatch(reportPage, /ardr-month-group/, '标准列表不应使用月份分组表头');
|
|
|
|
assert.match(overlaysHtml, /<div\b[^>]*\bid=["']modal-receivable-report-edit["']/, '缺少应收款明细编辑弹窗');
|
|
assert.match(overlaysHtml, /<input\b[^>]*\bid=["']ardr-edit-entity["'][^>]*\breadonly\b/, '应收单位必须只读');
|
|
assert.match(overlaysHtml, /<input\b[^>]*\bid=["']ardr-edit-customer["'][^>]*\brequired\b[^>]*\bmaxlength=["']50["']/, '客户必须必填且最多 50 字');
|
|
assert.match(overlaysHtml, /<textarea\b[^>]*\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, /<tr>/g), 1, '标准列表必须使用单层表头');
|
|
assert.equal(countMatches(header, /<th\b/g), 17, '标准列表必须固定 17 列');
|
|
assert.match(header, /<th>操作<\/th><\/tr>$/, '操作必须是最后一列');
|
|
assert.equal(countMatches(header, /ardr-month-group/g), 0, '表头不应生成月份分组');
|
|
['头程', '换标', '仓储转运', '货物废弃', '退货换标', '美国本土卡', '美本土散板']
|
|
.forEach(label => assert.equal(countMatches(header, new RegExp(`<th>${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,
|
|
/<button\b[^>]*\btype=["']button["'][^>]*\bclass=["']btn btn-sm btn-primary["'][^>]*\bonclick="openReceivableReportEdit\([^\"]*\)"[^>]*>编辑<\/button>/,
|
|
'应收款明细行编辑按钮必须使用 btn btn-sm btn-primary 样式并保留编辑行为',
|
|
);
|
|
assert.doesNotMatch(
|
|
appJs,
|
|
/<button\b[^>]*\bclass=["'][^"']*\bbtn-link\b[^"']*["'][^>]*\bonclick=["']openReceivableReportEdit\(/,
|
|
'应收款明细行编辑按钮不应使用未定义的 btn-link 样式',
|
|
);
|
|
|
|
console.log('应收款统计表校验通过');
|