baseline: 薪酬逻辑收口到 shared/payroll.js 后的当前全量状态
这是第一个包含完整工作区的提交,也是后续「拆 SFC / 上 Vite」的回滚基线。 此前的提交均为 index.html 单文件历史快照,由 .bak 文件重建。 当前状态(提交前已实测): - index.html 内联薪酬算法已删除,改调 WHTPayroll - shared/payroll.js 为唯一薪酬实现,浏览器与 Node 共用 - 对外接口未变:aggregateMonth / isCutEdge / payDayKind 等保留同名薄包装 未入库内容见 .gitignore:依赖、Chrome 验证 profile、验证截图、 运行时 sqlite 数据与日志、时间戳备份、.trash。
This commit is contained in:
@@ -0,0 +1,122 @@
|
||||
// 在 Node 里跑 index.html 内联的那坨 setup(),拿到它返回的全部 computed。
|
||||
//
|
||||
// 这个文件是**唯一**焊在 index.html 结构上的东西:它靠正则抠出内联 <script>,
|
||||
// 一旦前端拆成 SFC 就会立刻失效。这是刻意的——它的职责只剩「重构前后行为一致」对照,
|
||||
// 前端拆完那天连同 app-parity 测试一起删掉,而 shared/payroll.js 上的那套断言不受影响。
|
||||
//
|
||||
// 与 verify_payroll.js 里那份内联桩件的区别:
|
||||
// 1. Date 可冻结 → payDayAPassed / isCurrentMonth / cellClass 这些吃「今天」的输出变成确定值,
|
||||
// 否则基准 JSON 每天跑出来都不一样,根本没法当基准。
|
||||
// 2. 会把 shared/payroll.js 先注入沙箱,让重构后的 index.html 找得到 WHTPayroll。
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const vm = require('vm');
|
||||
|
||||
const ROOT = path.join(__dirname, '..', '..');
|
||||
const HTML = path.join(ROOT, 'index.html');
|
||||
const PAYROLL = path.join(ROOT, 'shared', 'payroll.js');
|
||||
|
||||
/** 冻结「现在」:无参 new Date() 与 Date.now() 都回到固定时刻,带参构造照常。 */
|
||||
function frozenDate(iso) {
|
||||
const FIXED = new Date(iso).getTime();
|
||||
return class FrozenDate extends Date {
|
||||
constructor(...args) {
|
||||
if (args.length === 0) super(FIXED);
|
||||
else super(...args);
|
||||
}
|
||||
static now() { return FIXED; }
|
||||
};
|
||||
}
|
||||
|
||||
/** 极简 Vue 桩件:测试只需要能读写与求值,不需要真响应式。 */
|
||||
function makeVue() {
|
||||
const mounted = [];
|
||||
const Vue = {
|
||||
ref: (v) => ({ value: v }),
|
||||
reactive: (o) => o,
|
||||
computed: (fn) => ({ get value() { return fn(); } }),
|
||||
watch: () => {},
|
||||
onMounted: (fn) => mounted.push(fn),
|
||||
createApp(opts) {
|
||||
Vue._captured = opts.setup();
|
||||
return { mount: () => Vue._captured };
|
||||
}
|
||||
};
|
||||
return { Vue, mounted };
|
||||
}
|
||||
|
||||
function makeStore(seed) {
|
||||
const data = { ...seed };
|
||||
return {
|
||||
getItem: (k) => (k in data ? data[k] : null),
|
||||
setItem: (k, v) => { data[k] = v; },
|
||||
removeItem: (k) => { delete data[k]; },
|
||||
_raw: () => data,
|
||||
_records: () => JSON.parse(data.work_records || '{}'),
|
||||
_settings: () => (data.work_settings ? JSON.parse(data.work_settings) : null),
|
||||
_adjust: () => (data.work_month_adjust ? JSON.parse(data.work_month_adjust) : null)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 载入应用。
|
||||
* @param {object} o
|
||||
* @param {object} o.records 打卡记录,key 'YYYY-MM-DD'
|
||||
* @param {number} o.year 当前查看年
|
||||
* @param {number} o.month 当前查看月(0–11)
|
||||
* @param {object} [o.settings] 覆盖设置,null/undefined 表示用默认
|
||||
* @param {object} [o.adjust] 月度奖惩终稿,key 'YYYY-MM'
|
||||
* @param {string} [o.now] 冻结的「现在」(ISO)
|
||||
*/
|
||||
function loadApp({ records = {}, year, month, settings = null, adjust = null, now = null }) {
|
||||
const seed = { work_records: JSON.stringify(records) };
|
||||
if (settings) seed.work_settings = JSON.stringify(settings);
|
||||
if (adjust) seed.work_month_adjust = JSON.stringify(adjust);
|
||||
|
||||
const localStorage = makeStore(seed);
|
||||
const { Vue, mounted } = makeVue();
|
||||
const DateImpl = now ? frozenDate(now) : Date;
|
||||
|
||||
const sandbox = {
|
||||
Vue, localStorage, console,
|
||||
Date: DateImpl, JSON, Math, Number, RegExp, Object, Array, String, Boolean, Error,
|
||||
navigator: {},
|
||||
document: {
|
||||
createElement: () => ({ style: {}, select() {}, click() {} }),
|
||||
body: { appendChild() {}, removeChild() {} },
|
||||
getElementById: () => null,
|
||||
addEventListener() {}
|
||||
},
|
||||
confirm: () => true,
|
||||
setTimeout: (fn) => { if (typeof fn === 'function') fn(); return 0; },
|
||||
Blob: function () {},
|
||||
URL: { createObjectURL: () => 'blob:x', revokeObjectURL() {} },
|
||||
// 刻意不提供 window.WHTSync:测试跑「未登录/纯离线」路径,
|
||||
// 顺带验证同步缺席时算钱逻辑完全不受影响。
|
||||
location: { origin: 'http://test' },
|
||||
fetch: () => Promise.reject(new Error('offline in test'))
|
||||
};
|
||||
sandbox.window = sandbox;
|
||||
sandbox.globalThis = sandbox;
|
||||
vm.createContext(sandbox);
|
||||
|
||||
// 先注入纯模块:重构后的 index.html 依赖它。重构前注入也无害(没人引用)。
|
||||
if (fs.existsSync(PAYROLL)) {
|
||||
vm.runInContext(fs.readFileSync(PAYROLL, 'utf8'), sandbox, { filename: 'shared/payroll.js' });
|
||||
}
|
||||
|
||||
const src = fs.readFileSync(HTML, 'utf8');
|
||||
const m = src.match(/<script>\s*([\s\S]*?)\s*<\/script>\s*<\/body>/);
|
||||
if (!m) throw new Error('index.html 里没找到应用内联 <script>,harness 需要更新');
|
||||
vm.runInContext(m[1], sandbox, { filename: 'index.html#app' });
|
||||
|
||||
mounted.forEach((fn) => fn()); // 触发 records / settings / adjust 载入
|
||||
const app = Vue._captured;
|
||||
app.currentYear.value = year;
|
||||
app.currentMonth.value = month;
|
||||
return { app, localStorage, sandbox };
|
||||
}
|
||||
|
||||
module.exports = { loadApp, frozenDate, ROOT };
|
||||
@@ -0,0 +1,164 @@
|
||||
// 探针:把「一个月的全部薪酬输出」压成一个可比较的扁平对象。
|
||||
//
|
||||
// 两个实现必须产出结构完全相同的对象:
|
||||
// probeApp(app) —— 从 index.html 跑出来的 setup() 返回值上读
|
||||
// probePure(sc) —— 只用 shared/payroll.js 算
|
||||
// 于是「重构有没有改变行为」就退化成一次 deepEqual。
|
||||
'use strict';
|
||||
|
||||
const P = require('../../shared/payroll.js');
|
||||
|
||||
// 浮点噪声必须先掐掉:19.5 × 22 这类乘法在不同写法下可能差 1e-13,
|
||||
// 那不是行为变化,却会让 deepEqual 全线报红。金额留 4 位足够(页面只显示 1 位)。
|
||||
const r4 = (n) => {
|
||||
const v = Number(n);
|
||||
if (!Number.isFinite(v)) return v === undefined ? null : String(v);
|
||||
return Math.round(v * 1e4) / 1e4;
|
||||
};
|
||||
|
||||
const STAT_KEYS = [
|
||||
'h1', 'h2', 'totalHours', 'workDays', 'dayShifts', 'nightShifts', 'restDays', 'leaveDays',
|
||||
'foundCount', 'missedCount', 'otherPenalties',
|
||||
'fullBonus', 'foundBonus', 'missedPenalty', 'adjustNet', 'reviewNet',
|
||||
'rawReview', 'reviewAmount', 'reviewDelta', 'basePay'
|
||||
];
|
||||
|
||||
const CFG_KEYS = [
|
||||
'rate', 'fullDays', 'fullBonusPerHour', 'foundBonus', 'missedPenalty',
|
||||
'cutoff', 'payDayA', 'payDayB', 'dayHours', 'nightHours', 'defaultShift'
|
||||
];
|
||||
|
||||
function pickStats(s) {
|
||||
const out = {};
|
||||
for (const k of STAT_KEYS) out[k] = r4(s[k]);
|
||||
out.finalized = s.finalized === true;
|
||||
out.adjustStatus = s.adjustStatus === undefined ? null : s.adjustStatus;
|
||||
return out;
|
||||
}
|
||||
|
||||
function pickCfg(c) {
|
||||
const out = {};
|
||||
for (const k of CFG_KEYS) out[k] = typeof c[k] === 'string' ? c[k] : r4(c[k]);
|
||||
return out;
|
||||
}
|
||||
|
||||
const pickPenalties = (list) =>
|
||||
Array.prototype.map.call(list, (d) => ({ day: d.day, reason: d.reason, amount: r4(d.amount) }));
|
||||
|
||||
/**
|
||||
* 拉回宿主 realm。
|
||||
* index.html 是在 vm 沙箱里跑的,它造出来的数组/对象带的是**沙箱的** Array.prototype,
|
||||
* 而 deepStrictEqual 会连原型一起比 —— 不做这一步,所有场景都会因为原型不同而全红,
|
||||
* 报错还长得像「结构相同但不相等」,非常容易误判成算法出了问题(实测踩过)。
|
||||
*/
|
||||
const hostify = (o) => JSON.parse(JSON.stringify(o));
|
||||
|
||||
/** 从跑起来的 index.html 应用实例上读。 */
|
||||
function probeApp(app) {
|
||||
const s = app.monthStats.value;
|
||||
const p = app.prevStats.value;
|
||||
const cfg = app.cfg.value;
|
||||
const days = new Date(app.currentYear.value, app.currentMonth.value + 1, 0).getDate();
|
||||
const allDays = Array.from({ length: days }, (_, i) => i + 1);
|
||||
|
||||
return hostify({
|
||||
cfg: pickCfg(cfg),
|
||||
cur: pickStats(s),
|
||||
prev: pickStats(p),
|
||||
prevLabel: { year: app.prevLabel.value.year, month: app.prevLabel.value.month },
|
||||
nextLabel: { year: app.nextLabel.value.year, month: app.nextLabel.value.month },
|
||||
flows: {
|
||||
thisA: r4(app.payoutThisA.value),
|
||||
thisB: r4(app.payoutThisB.value),
|
||||
nextB: r4(app.payoutNextB.value),
|
||||
earnedTotal: r4(app.earnedTotal.value),
|
||||
cashTotal: r4(app.cashTotal.value),
|
||||
carry: r4(app.earnedSplit.value.carry),
|
||||
paid: r4(app.earnedSplit.value.paid),
|
||||
floored: app.earnedFloored.value === true
|
||||
},
|
||||
penalties: pickPenalties(app.penaltyDetails.value),
|
||||
calendar: {
|
||||
days,
|
||||
cutEdge: allDays.filter((d) => app.isCutEdge(d)),
|
||||
payDayA: allDays.filter((d) => app.payDayKind(d) === 'a'),
|
||||
payDayB: allDays.filter((d) => app.payDayKind(d) === 'b'),
|
||||
penaltyFlag: allDays.filter((d) => app.hasPenalty(d))
|
||||
},
|
||||
misc: {
|
||||
fullDaysLeft: app.fullDaysLeft.value,
|
||||
payDayAPassed: app.payDayAPassed.value === true,
|
||||
isCurrentMonth: app.isCurrentMonth.value === true,
|
||||
recordCount: app.recordCount.value,
|
||||
boardInt: String(app.boardMain.value.int),
|
||||
boardFrac: String(app.boardMain.value.frac),
|
||||
boardSign: String(app.boardMain.value.sign),
|
||||
hourOptions: app.hourOptions.join(','),
|
||||
daysInMonth: app.daysInMonth.value,
|
||||
firstDayOfWeek: app.firstDayOfWeek.value
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** 只用 shared/payroll.js 算,不碰 Vue、不碰 index.html。 */
|
||||
function probePure(sc, nowIso) {
|
||||
const cfg = P.normalizeSettings(sc.settings || {});
|
||||
const ctx = { records: sc.records || {}, monthAdjust: sc.adjust || {}, cfg };
|
||||
const cur = P.aggregateMonth(sc.year, sc.month, ctx);
|
||||
const prevL = P.prevMonthOf(sc.year, sc.month);
|
||||
const nextL = P.nextMonthOf(sc.year, sc.month);
|
||||
const prev = P.aggregateMonth(prevL.year, prevL.month, ctx);
|
||||
const flows = P.flowsOf({ cur, prev, cfg });
|
||||
const now = nowIso ? new Date(nowIso) : new Date();
|
||||
|
||||
const days = P.daysInMonth(sc.year, sc.month);
|
||||
const allDays = Array.from({ length: days }, (_, i) => i + 1);
|
||||
const records = sc.records || {};
|
||||
const prefix = P.monthPrefix(sc.year, sc.month);
|
||||
const hasPenalty = (day) => {
|
||||
const rec = records[prefix + P.pad2(day)];
|
||||
return !!rec && rec.status === 'work' &&
|
||||
(Number(rec.contraband_missed || 0) > 0 || Number(rec.other_penalty || 0) > 0);
|
||||
};
|
||||
const parts = P.moneyParts(flows.earnedTotal);
|
||||
|
||||
return {
|
||||
cfg: pickCfg(cfg),
|
||||
cur: pickStats(cur),
|
||||
prev: pickStats(prev),
|
||||
prevLabel: { year: prevL.year, month: prevL.month },
|
||||
nextLabel: { year: nextL.year, month: nextL.month },
|
||||
flows: {
|
||||
thisA: r4(flows.thisA),
|
||||
thisB: r4(flows.thisB),
|
||||
nextB: r4(flows.nextB),
|
||||
earnedTotal: r4(flows.earnedTotal),
|
||||
cashTotal: r4(flows.cashTotal),
|
||||
carry: r4(flows.carry),
|
||||
paid: r4(flows.split.paid),
|
||||
floored: flows.floored === true
|
||||
},
|
||||
penalties: pickPenalties(P.penaltyDetails(sc.year, sc.month, ctx)),
|
||||
calendar: {
|
||||
days,
|
||||
cutEdge: allDays.filter((d) => P.isCutEdge(d, cfg)),
|
||||
payDayA: allDays.filter((d) => P.payDayKind(d, cfg) === 'a'),
|
||||
payDayB: allDays.filter((d) => P.payDayKind(d, cfg) === 'b'),
|
||||
penaltyFlag: allDays.filter(hasPenalty)
|
||||
},
|
||||
misc: {
|
||||
fullDaysLeft: P.fullDaysLeft(cur, cfg),
|
||||
payDayAPassed: P.payDayAPassed(sc.year, sc.month, cfg, now) === true,
|
||||
isCurrentMonth: P.isCurrentMonth(sc.year, sc.month, now) === true,
|
||||
recordCount: Object.keys(records).length,
|
||||
boardInt: String(parts.int),
|
||||
boardFrac: String(parts.frac),
|
||||
boardSign: String(parts.sign),
|
||||
hourOptions: P.hourOptions.join(','),
|
||||
daysInMonth: days,
|
||||
firstDayOfWeek: P.firstDayOfWeek(sc.year, sc.month)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { probeApp, probePure, r4, STAT_KEYS };
|
||||
@@ -0,0 +1,229 @@
|
||||
// 测试场景库 —— payroll 模块、index.html 基准、重构后对照三方共用同一批数据。
|
||||
//
|
||||
// 为什么单独一个文件:基准快照与重构后的实测必须喂**完全相同**的输入,
|
||||
// 否则对不上时分不清是「代码变了」还是「测试数据变了」。
|
||||
'use strict';
|
||||
|
||||
const W = (h, extra) => Object.assign({
|
||||
status: 'work', shift_type: 'day', hours: h,
|
||||
contraband_found: 0, contraband_missed: 0, other_penalty: null, penalty_reason: ''
|
||||
}, extra || {});
|
||||
|
||||
const FINAL = (mk, amount) => ({
|
||||
[mk]: { status: 'final', finalAmount: amount, note: '', finalizedAt: '2026-10-01T09:00:00.000Z' }
|
||||
});
|
||||
const DRAFT = (mk, amount) => ({
|
||||
[mk]: { status: 'draft', finalAmount: amount, note: '', finalizedAt: '' }
|
||||
});
|
||||
|
||||
/** 满勤月:1–N 日全上白班 8h */
|
||||
function fullMonth(days, hours) {
|
||||
const rec = {};
|
||||
for (let d = 1; d <= days; d++) rec[`2026-09-${String(d).padStart(2, '0')}`] = W(hours === undefined ? 8 : hours);
|
||||
return rec;
|
||||
}
|
||||
|
||||
// 冻结的「现在」:不冻结的话 payDayAPassed / isCurrentMonth / 今日高亮
|
||||
// 每天跑出来都不一样,基准 JSON 根本没法比。
|
||||
// 取 2026-09-20 是刻意的:已过 payDayA(22)? 没过 —— 正好覆盖「上半月待发」这一支。
|
||||
const NOW = '2026-09-20T10:00:00+08:00';
|
||||
|
||||
/**
|
||||
* 每个场景都会被喂给三方:纯模块、重构前的 index.html、重构后的 index.html。
|
||||
* 覆盖目标:12 个薪酬 computed 的全部分支。
|
||||
*/
|
||||
const SCENARIOS = [
|
||||
{
|
||||
name: '基础分段·上下半月各2天无奖惩',
|
||||
records: { '2026-09-03': W(8), '2026-09-14': W(8), '2026-09-20': W(10), '2026-09-28': W(10) },
|
||||
year: 2026, month: 8
|
||||
},
|
||||
{
|
||||
name: '边界·15日算上半月/16日算下半月',
|
||||
records: { '2026-09-15': W(12), '2026-09-16': W(12) },
|
||||
year: 2026, month: 8
|
||||
},
|
||||
{
|
||||
name: '夜班跨天·15日夜班10h整段进上半月',
|
||||
records: { '2026-09-15': W(10, { shift_type: 'night' }) },
|
||||
year: 2026, month: 8
|
||||
},
|
||||
{
|
||||
name: '满勤达标·27天×8h',
|
||||
records: fullMonth(27),
|
||||
year: 2026, month: 8
|
||||
},
|
||||
{
|
||||
name: '差1天不满勤·26天×8h',
|
||||
records: fullMonth(26),
|
||||
year: 2026, month: 8
|
||||
},
|
||||
{
|
||||
name: '奖惩四项归集·未定稿只记录',
|
||||
records: {
|
||||
'2026-09-05': W(8, { contraband_found: 3 }),
|
||||
'2026-09-20': W(8, { contraband_missed: 2 }),
|
||||
'2026-09-25': W(8, { other_penalty: 50, penalty_reason: '迟到' })
|
||||
},
|
||||
year: 2026, month: 8
|
||||
},
|
||||
{
|
||||
name: '奖惩已定稿-60',
|
||||
records: {
|
||||
'2026-09-05': W(8, { contraband_found: 3 }),
|
||||
'2026-09-20': W(8, { contraband_missed: 2 }),
|
||||
'2026-09-25': W(8, { other_penalty: 50, penalty_reason: '迟到' })
|
||||
},
|
||||
year: 2026, month: 8, adjust: FINAL('2026-09', -60)
|
||||
},
|
||||
{
|
||||
name: '草稿不生效·draft+30',
|
||||
records: { '2026-09-05': W(8, { contraband_found: 3 }) },
|
||||
year: 2026, month: 8, adjust: DRAFT('2026-09', 30)
|
||||
},
|
||||
{
|
||||
name: '审核改数·累计+30只认+20',
|
||||
records: { '2026-09-05': W(8, { contraband_found: 3 }) },
|
||||
year: 2026, month: 8, adjust: FINAL('2026-09', 20)
|
||||
},
|
||||
{
|
||||
name: '跨月结转·8月未定稿',
|
||||
records: {
|
||||
'2026-08-10': W(8),
|
||||
'2026-08-18': W(10), '2026-08-22': W(10),
|
||||
'2026-08-27': W(10, { contraband_found: 5, contraband_missed: 1 }),
|
||||
'2026-09-02': W(8), '2026-09-09': W(8)
|
||||
},
|
||||
year: 2026, month: 8
|
||||
},
|
||||
{
|
||||
name: '跨月结转·8月已定稿+30',
|
||||
records: {
|
||||
'2026-08-10': W(8),
|
||||
'2026-08-18': W(10), '2026-08-22': W(10),
|
||||
'2026-08-27': W(10, { contraband_found: 5, contraband_missed: 1 }),
|
||||
'2026-09-02': W(8), '2026-09-09': W(8)
|
||||
},
|
||||
year: 2026, month: 8, adjust: FINAL('2026-08', 30)
|
||||
},
|
||||
{
|
||||
name: '跨年边界·2026年1月取2025年12月',
|
||||
records: {
|
||||
'2025-12-20': W(10), '2025-12-28': W(10, { contraband_found: 2 }),
|
||||
'2026-01-05': W(8)
|
||||
},
|
||||
year: 2026, month: 0
|
||||
},
|
||||
{
|
||||
name: '跨年边界·2025-12已定稿+20',
|
||||
records: {
|
||||
'2025-12-20': W(10), '2025-12-28': W(10, { contraband_found: 2 }),
|
||||
'2026-01-05': W(8)
|
||||
},
|
||||
year: 2026, month: 0, adjust: FINAL('2025-12', 20)
|
||||
},
|
||||
{
|
||||
name: '处罚超过下半月工资·未定稿',
|
||||
records: { '2026-09-20': W(6, { contraband_missed: 10, other_penalty: 200 }) },
|
||||
year: 2026, month: 8
|
||||
},
|
||||
{
|
||||
name: '处罚超过下半月工资·定稿-400触发地板0',
|
||||
records: { '2026-09-20': W(6, { contraband_missed: 10, other_penalty: 200 }) },
|
||||
year: 2026, month: 8, adjust: FINAL('2026-09', -400)
|
||||
},
|
||||
{
|
||||
name: '休息请假不计工时',
|
||||
records: {
|
||||
'2026-09-03': W(8),
|
||||
'2026-09-04': { status: 'rest' },
|
||||
'2026-09-05': { status: 'leave' },
|
||||
'2026-09-06': { status: 'rest' }
|
||||
},
|
||||
year: 2026, month: 8
|
||||
},
|
||||
{
|
||||
name: '脏数据·其他扣款负数夹为0',
|
||||
records: { '2026-09-20': W(8, { other_penalty: -500 }) },
|
||||
year: 2026, month: 8
|
||||
},
|
||||
{
|
||||
name: '月份隔离·不串月',
|
||||
records: { '2026-08-05': W(12), '2026-10-05': W(12), '2026-09-05': W(8) },
|
||||
year: 2026, month: 8
|
||||
},
|
||||
{
|
||||
name: '自定义设置·时薪30/满勤20/单价2/截止10/发薪25与5',
|
||||
records: (() => {
|
||||
const rec = {};
|
||||
for (let d = 1; d <= 20; d++) rec[`2026-09-${String(d).padStart(2, '0')}`] = W(10);
|
||||
return rec;
|
||||
})(),
|
||||
year: 2026, month: 8,
|
||||
settings: { rate: 30, fullDays: 20, fullBonusPerHour: 2, cutoff: 10, payDayA: 25, payDayB: 5 }
|
||||
},
|
||||
{
|
||||
name: '非法设置全部回落默认',
|
||||
records: { '2026-09-05': W(8) },
|
||||
year: 2026, month: 8,
|
||||
settings: { rate: null, fullDays: 0, fullBonusPerHour: undefined, cutoff: 99, foundBonus: -5, missedPenalty: NaN }
|
||||
},
|
||||
{
|
||||
name: '处罚明细排序与原因',
|
||||
records: {
|
||||
'2026-09-25': W(8, { other_penalty: 50, penalty_reason: '迟到' }),
|
||||
'2026-09-08': W(8, { contraband_missed: 2 }),
|
||||
'2026-09-12': W(8, { contraband_missed: 1, other_penalty: 30 }),
|
||||
'2026-09-15': W(8)
|
||||
},
|
||||
year: 2026, month: 8
|
||||
},
|
||||
{
|
||||
name: '空数据不崩',
|
||||
records: {},
|
||||
year: 2026, month: 8
|
||||
},
|
||||
{
|
||||
name: '越界旧值原样保留·14h与4h',
|
||||
records: { '2026-09-05': W(14), '2026-09-06': W(4), '2026-09-07': W(8.5) },
|
||||
year: 2026, month: 8
|
||||
},
|
||||
{
|
||||
name: '用户实测场景·3天22h含查获1件未定稿',
|
||||
records: { '2026-09-01': W(8), '2026-09-02': W(8), '2026-09-03': W(6, { contraband_found: 1 }) },
|
||||
year: 2026, month: 8
|
||||
},
|
||||
{
|
||||
name: '用户实测场景·同上已定稿+10',
|
||||
records: { '2026-09-01': W(8), '2026-09-02': W(8), '2026-09-03': W(6, { contraband_found: 1 }) },
|
||||
year: 2026, month: 8, adjust: FINAL('2026-09', 10)
|
||||
},
|
||||
{
|
||||
name: '满勤+查获5件未定稿·满勤计奖惩不计',
|
||||
records: Object.assign(fullMonth(27), { '2026-09-05': W(8, { contraband_found: 5 }) }),
|
||||
year: 2026, month: 8
|
||||
},
|
||||
{
|
||||
name: '满勤+查获5件已定稿+50·两项全计',
|
||||
records: Object.assign(fullMonth(27), { '2026-09-05': W(8, { contraband_found: 5 }) }),
|
||||
year: 2026, month: 8, adjust: FINAL('2026-09', 50)
|
||||
},
|
||||
{
|
||||
name: '历史月份·看8月(payDayA 已过)',
|
||||
records: { '2026-08-05': W(8), '2026-08-20': W(10) },
|
||||
year: 2026, month: 7
|
||||
},
|
||||
{
|
||||
name: '未来月份·看10月(payDayA 未过)',
|
||||
records: { '2026-10-05': W(8), '2026-10-20': W(10) },
|
||||
year: 2026, month: 9
|
||||
},
|
||||
{
|
||||
name: '班次默认工时越界·白班2h夜班20h',
|
||||
records: {},
|
||||
year: 2026, month: 8,
|
||||
settings: { dayHours: 2, nightHours: 20 }
|
||||
}
|
||||
];
|
||||
|
||||
module.exports = { W, FINAL, DRAFT, fullMonth, SCENARIOS, NOW };
|
||||
Reference in New Issue
Block a user