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:
OpenClaw Agent
2026-09-05 14:35:00 +08:00
parent 7d48c4ac65
commit 2329955e32
94 changed files with 12770 additions and 237 deletions
+361
View File
@@ -0,0 +1,361 @@
/**
* 薪酬核算纯模块 —— 零依赖,浏览器与 Node 共用同一份。
*
* 存在的理由:这套算钱逻辑原来焊在 index.html 的 setup() 里,唯一能测它的办法是用正则
* 把内联 <script> 抠出来在 vm 里跑。那意味着「前端一拆,测试全废」——网挂在了要拆掉的墙上。
* 抽成模块后,测试直接喂数据进函数,跟前端用什么框架、拆成几个文件都无关。
*
* 硬性约束(改这个文件前先读):
* 1. 不碰 DOM、不碰 localStorage、不碰 window、不读「现在」。
* 需要「今天」的函数一律由调用方显式传 now,否则测试基准每天都不一样。
* 2. 纯函数,不改入参。records / monthAdjust 只读。
* 3. 金额一律返回数字,格式化只在 fmt/money/signed/moneyParts 里做。
*
* 业务口径(用户确认的事实,不是推测):
* 时薪 19.5 | 满勤门槛 27 天 | 满勤奖 1 元/工时(当月总工时 × 1)
* 查获 +10 元/件 | 漏查 -20 元/件 | 其他扣款按日填
* 上半月截止 15 日 | 发薪日 A = 22 号(本月上半月纯工时)
* 发薪日 B = 次月 10 号(本月下半月工时 + 满勤奖 + 已定稿奖惩)
* 只有「上班」计出勤;休息/请假不计工时也不计出勤天数
* 夜班跨天:工时全归开始打卡那一天
* 满勤未达标 → 满勤奖按 0;奖惩未经组长审核定稿 → 一律按 0(同一个道理)
*/
(function (root, factory) {
'use strict';
var api = factory();
if (typeof module === 'object' && module && module.exports) module.exports = api;
if (root) root.WHTPayroll = api;
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
'use strict';
// ===== 默认规则 =====
var DEFAULTS = {
rate: 19.5, // 基础时薪,当月工资只含工时
fullDays: 27, // 满勤门槛(天)
fullBonusPerHour: 1, // 满勤奖 = 当月总工时 × 该单价(属奖励,次月结)
foundBonus: 10, // 查获违禁品 元/件
missedPenalty: 20, // 漏查违禁品 元/件
cutoff: 15, // 上半月截止日
payDayA: 22, // 上半月工时 → 当月该日发
payDayB: 10, // 下半月工时 + 当月奖惩 → 次月该日发
dayHours: 8, // 白班默认工时
nightHours: 10, // 夜班默认工时
defaultShift: 'day' // 打卡默认班次:'day' 白班 / 'night' 夜班
};
// 设置字段中文名:登录合并冲突对照要给人看,不能直接抱 key 上屏
var SETTING_LABELS = {
rate: '基础时薪', fullDays: '满勤门槛(天)', fullBonusPerHour: '满勤奖单价',
foundBonus: '查获奖励/件', missedPenalty: '漏查处罚/件', cutoff: '上半月截止日',
payDayA: '发薪日 A', payDayB: '发薪日 B', dayHours: '白班默认工时',
nightHours: '夜班默认工时', defaultShift: '默认班次'
};
// ===== 工时口径:6–12h,0.5 步进 =====
var HOUR_MIN = 6, HOUR_MAX = 12, HOUR_STEP = 0.5;
var hourOptions = [];
for (var h = HOUR_MIN; h <= HOUR_MAX + 1e-9; h += HOUR_STEP) hourOptions.push(Math.round(h * 10) / 10);
// 整数部/小数部拆开渲染(仅日历格子用):小数位占固定宽度槽位,
// 这样 8 与 8.5 的整数部左右对齐,不会错位。
function hInt(v) { return Math.floor(Number(v) || 0); }
function hFrac(v) { return (Number(v) || 0) % 1 === 0.5 ? '.5' : ''; }
/**
* 归一「设置里的班次默认工时」,保证默认值落在可选范围内。
* 只作用于设置,不改写任何已保存的打卡记录(旧数据里 14h 这类越界值必须原样保留)。
*/
function clampHours(v, fallback) {
var n = Number(v);
if (!isFinite(n)) return fallback === undefined ? DEFAULTS.dayHours : fallback;
var snapped = Math.round(n / HOUR_STEP) * HOUR_STEP;
return Math.min(HOUR_MAX, Math.max(HOUR_MIN, snapped));
}
/**
* 数值兜底:输入框清空 / 脏数据时回落默认值,不让计算变 0 或 NaN。
* 必须先判空 —— Number(null) 与 Number('') 都等于 0,
* 少了这一步「清空时薪输入框」会把时薪静默变成 0。
*/
function sn(v, d) {
if (v === null || v === undefined || v === '') return d;
var n = Number(v);
return isFinite(n) && n >= 0 ? n : d;
}
/** 设置 → 洗过的有效配置。所有算钱函数只认这个返回值,不直接读原始 settings。 */
function normalizeSettings(settings) {
var s = settings || {};
return {
rate: sn(s.rate, DEFAULTS.rate),
fullDays: Math.round(sn(s.fullDays, DEFAULTS.fullDays)) || DEFAULTS.fullDays,
fullBonusPerHour: sn(s.fullBonusPerHour, DEFAULTS.fullBonusPerHour),
foundBonus: sn(s.foundBonus, DEFAULTS.foundBonus),
missedPenalty: sn(s.missedPenalty, DEFAULTS.missedPenalty),
cutoff: Math.min(28, Math.max(1, Math.round(sn(s.cutoff, DEFAULTS.cutoff)))),
// payDayA/B 原本只用于文案,现在参与日历发薪日判定,必须做同样的洗值,
// 否则 payDayKind() 拿到 undefined,比较永不命中,角标与内描边都不出现。
payDayA: Math.min(31, Math.max(1, Math.round(sn(s.payDayA, DEFAULTS.payDayA)))),
payDayB: Math.min(31, Math.max(1, Math.round(sn(s.payDayB, DEFAULTS.payDayB)))),
dayHours: clampHours(sn(s.dayHours, DEFAULTS.dayHours), DEFAULTS.dayHours),
nightHours: clampHours(sn(s.nightHours, DEFAULTS.nightHours), DEFAULTS.nightHours),
defaultShift: s.defaultShift === 'night' ? 'night' : 'day'
};
}
// ===== 日期键 =====
function pad2(n) { return String(n).length < 2 ? '0' + n : String(n); }
function monthPrefix(y, m) { return y + '-' + pad2(m + 1) + '-'; }
function keyOf(y, m, d) { return monthPrefix(y, m) + pad2(d); }
function monthKeyOf(y, m) { return y + '-' + pad2(m + 1); }
function prevMonthOf(y, m) { return m === 0 ? { year: y - 1, month: 11 } : { year: y, month: m - 1 }; }
function nextMonthOf(y, m) { return m === 11 ? { year: y + 1, month: 0 } : { year: y, month: m + 1 }; }
function daysInMonth(y, m) { return new Date(y, m + 1, 0).getDate(); }
function firstDayOfWeek(y, m) { return new Date(y, m, 1).getDay(); }
/** 取某月的奖惩终稿;跨年也安全,因为 key 是 'YYYY-MM' 全量拼出来的。 */
function getMonthAdjust(monthAdjust, y, m) {
return (monthAdjust && monthAdjust[monthKeyOf(y, m)]) || null;
}
// ===== 核心聚合 =====
/**
* 聚合某月。本月与上月共用同一套口径,跨月结转才不会两头对不上。
* @param {number} year
* @param {number} month 0–11
* @param {{records:object, monthAdjust?:object, cfg:object}} ctx
*/
function aggregateMonth(year, month, ctx) {
var c = ctx.cfg;
var records = ctx.records || {};
var prefix = monthPrefix(year, month);
var h1 = 0, h2 = 0, workDays = 0, dayShifts = 0, nightShifts = 0, restDays = 0, leaveDays = 0;
var foundCount = 0, missedCount = 0, otherPenalties = 0;
for (var key in records) {
if (key.indexOf(prefix) !== 0) continue;
var rec = records[key];
if (!rec) continue;
if (rec.status === 'rest') { restDays++; continue; }
if (rec.status === 'leave') { leaveDays++; continue; }
if (rec.status !== 'work') continue;
var day = Number(key.slice(prefix.length));
var hours = Number(rec.hours || 0);
// 夜班跨天:工时整段归入开始打卡那一天,不按自然日切开
if (day <= c.cutoff) h1 += hours; else h2 += hours;
workDays++;
if (rec.shift_type === 'night') nightShifts++; else dayShifts++;
foundCount += Number(rec.contraband_found || 0);
missedCount += Number(rec.contraband_missed || 0);
otherPenalties += Math.max(0, Number(rec.other_penalty || 0));
}
var totalHours = h1 + h2;
// 满勤奖只由出勤天数客观判定,不参与人工审核修正
var fullBonus = workDays >= c.fullDays ? totalHours * c.fullBonusPerHour : 0;
var foundBonus = foundCount * c.foundBonus;
var missedPenalty = missedCount * c.missedPenalty;
// 日常累计奖惩 = 查获 - 漏查 - 其他扣款(不含满勤奖)。
// 这只是「记录」,不是可发金额:当月只记账,实际奖金要等次月初组长审核提交后才确定。
var rawReview = foundBonus - missedPenalty - otherPenalties;
// 与「满勤未达标 → 满勤奖按 0」同一个道理:
// 奖惩未定稿 → 一律按 0,不进任何应得/发放口径;只有定稿后才用审核终稿金额。
var adj = getMonthAdjust(ctx.monthAdjust, year, month);
// 判定条件与重构前逐字一致,包括一个已知怪癖:finalAmount 为 null 时 Number(null)===0 且
// isFinite(0) 为真,于是「status:'final' + finalAmount:null」会被当成「已定稿¥P0」。
// UI 走不到这个分支(writeAdjust 会先拦下非数字),只有导入脏备份才碰得到。
// 重构阶段一律保持原行为,不在抽模块时偺带修 bug;该怪癖已被用例钉住。
var finalized = !!(adj && adj.status === 'final' && isFinite(Number(adj.finalAmount)));
var reviewAmount = finalized ? Number(adj.finalAmount) : 0;
var reviewDelta = finalized ? reviewAmount - rawReview : 0;
var reviewNet = reviewAmount;
// 发放净额 = 满勤奖 + 已定稿奖惩。次月 payDayB 这两笔与下半月工时一起发,
// 「挣得」与「结转」两套口径都复用它,保证同名同数。
var adjustNet = fullBonus + reviewNet;
return {
h1: h1, h2: h2, totalHours: totalHours, workDays: workDays,
dayShifts: dayShifts, nightShifts: nightShifts, restDays: restDays, leaveDays: leaveDays,
foundCount: foundCount, missedCount: missedCount, otherPenalties: otherPenalties,
fullBonus: fullBonus, foundBonus: foundBonus, missedPenalty: missedPenalty,
adjustNet: adjustNet, reviewNet: reviewNet,
rawReview: rawReview, reviewAmount: reviewAmount, reviewDelta: reviewDelta,
finalized: finalized,
adjustStatus: adj ? adj.status : null,
adjustNote: adj && adj.note ? adj.note : '',
finalizedAt: adj && adj.finalizedAt ? adj.finalizedAt : '',
basePay: totalHours * c.rate
};
}
/**
* 现金流与两套记账口径。
*
* 归属制「挣得」:本月 1–月底干出来的钱,不管什么时候发。
* 收付制「进账」:本月卡里实际进的钱,含上月结转。
* 两者永远不相等,必须各自具名,不能都叫「本月」——这是页面之前最主要的歧义源。
*
* @param {{cur:object, prev:object, cfg:object}} o cur/prev 为 aggregateMonth 的返回值
*/
function flowsOf(o) {
var cur = o.cur, prev = o.prev, c = o.cfg;
var thisA = Math.max(0, cur.h1 * c.rate);
var thisB = Math.max(0, prev.h2 * c.rate + prev.adjustNet);
var nextB = Math.max(0, cur.h2 * c.rate + cur.adjustNet);
// 挣得 = 工时收入 + 满勤奖(未达门槛为 0)+ 奖惩净额(未定稿为 0)
// adjustNet 本身 = fullBonus + reviewNet,所以三项相加严丝合缝等于这个值。
var earnedTotal = cur.basePay + cur.adjustNet;
// 结转 = 次月 payDayB 实际到账那一笔。直接复用 nextB,
// 从公式上保证「结转」与「次月实际上账」永远同一个数,不再出现账目缺口。
var carry = nextB;
return {
thisA: thisA, thisB: thisB, nextB: nextB,
earnedTotal: earnedTotal,
cashTotal: thisB + thisA,
carry: carry,
split: { paid: thisA, carry: carry },
// 处罚吃穿下半月工时时结转被 ¥0 兜住,于是 paid+carry > earnedTotal,页面需要明说
floored: cur.h2 * c.rate + cur.adjustNet < 0
};
}
/** 处罚明细,按日排序并带原因。只看「上班」日,金额 <= 0 的不列。 */
function penaltyDetails(year, month, ctx) {
var c = ctx.cfg;
var records = ctx.records || {};
var prefix = monthPrefix(year, month);
var out = [];
for (var key in records) {
if (key.indexOf(prefix) !== 0) continue;
var r = records[key];
if (!r || r.status !== 'work') continue;
var missed = Number(r.contraband_missed || 0);
var other = Math.max(0, Number(r.other_penalty || 0));
var amount = missed * c.missedPenalty + other;
if (amount <= 0) continue;
var parts = [];
if (missed > 0) parts.push('漏查' + missed + '件');
if (other > 0) parts.push(r.penalty_reason ? r.penalty_reason : '其他扣款');
out.push({ day: Number(key.slice(prefix.length)), reason: parts.join('·'), amount: amount });
}
return out.sort(function (a, b) { return a.day - b.day; });
}
// ===== 日历判定 =====
/**
* 上下半月分界格:7 列网格里 day+7 就在 day 正下方,
* 所以给 cutoff-6 … cutoff 这 7 格加底虚线,正好描出一条完整分界线,
* 且不必往网格里插额外元素,星期对齐不会被打乱。
*/
function isCutEdge(day, cfg) { return day <= cfg.cutoff && day + 7 > cfg.cutoff; }
/**
* 发薪日判定:'a' = 当月 payDayA(上半月工时);'b' = 当月 payDayB(上月下半月 + 上月奖惩)。
* 只做统计标注,不处理「遇周末顺延」,实际到账日以单位为准。
*/
function payDayKind(day, cfg) {
if (day === cfg.payDayA) return 'a';
if (day === cfg.payDayB) return 'b';
return null;
}
function fullDaysLeft(stats, cfg) { return Math.max(0, cfg.fullDays - stats.workDays); }
/** 上半月那笔到没到账。翻历史月份一律算已发,只有看当前月才有「待发」说法。 */
function payDayAPassed(year, month, cfg, now) {
var n = now || new Date();
var isCur = n.getFullYear() === year && n.getMonth() === month;
if (!isCur) {
return year < n.getFullYear() || (year === n.getFullYear() && month < n.getMonth());
}
return n.getDate() >= cfg.payDayA;
}
function isCurrentMonth(year, month, now) {
var n = now || new Date();
return n.getFullYear() === year && n.getMonth() === month;
}
function isSameDay(year, month, day, now) {
var n = now || new Date();
return n.getFullYear() === year && n.getMonth() === month && n.getDate() === day;
}
// ===== 格式化 =====
function fmt(n) { return (Number(n) || 0).toFixed(1); }
function money(n) { return '¥' + (Number(n) || 0).toFixed(1); }
function signed(n) {
var v = Number(n) || 0;
return (v >= 0 ? '+¥' : '-¥') + Math.abs(v).toFixed(1);
}
/**
* 金额拆成 符号/整数部/小数部,供看板主数字分层渲染(大字只放整数部)。
* 必须从 toFixed(1) 的结果上切,不能用 Math.floor 另算,
* 否则 99.96 会被 money() 进为 100.0、而拆分出 99.0,两处数字对不上。
*/
function moneyParts(n) {
var v = Number(n) || 0;
var s = Math.abs(v).toFixed(1);
var dot = s.indexOf('.');
return {
sign: v < 0 ? '-' : '',
int: s.slice(0, dot).replace(/\B(?=(\d{3})+(?!\d))/g, ','), // 千分位
frac: s.slice(dot)
};
}
/** 新建一条打卡草稿。班次决定默认工时。 */
function newDraft(shift, cfg) {
return {
status: 'work',
shift_type: shift,
hours: shift === 'night' ? cfg.nightHours : cfg.dayHours,
contraband_found: 0,
contraband_missed: 0,
other_penalty: null,
penalty_reason: ''
};
}
/**
* 保存前归一:非正扣款回落 null;非「上班」日清空全部奖惩字段。
* 原地改传入对象并返回它 —— 调用方持有的就是待落库的那份草稿。
*/
function normalizeDraft(draft) {
var op = Number(draft.other_penalty);
draft.other_penalty = (isFinite(op) && op > 0) ? op : null;
if (draft.status !== 'work') {
draft.contraband_found = 0;
draft.contraband_missed = 0;
draft.other_penalty = null;
draft.penalty_reason = '';
}
return draft;
}
return {
DEFAULTS: DEFAULTS,
SETTING_LABELS: SETTING_LABELS,
HOUR_MIN: HOUR_MIN, HOUR_MAX: HOUR_MAX, HOUR_STEP: HOUR_STEP,
hourOptions: hourOptions,
hInt: hInt, hFrac: hFrac, clampHours: clampHours, sn: sn,
normalizeSettings: normalizeSettings,
pad2: pad2, monthPrefix: monthPrefix, keyOf: keyOf, monthKeyOf: monthKeyOf,
prevMonthOf: prevMonthOf, nextMonthOf: nextMonthOf,
daysInMonth: daysInMonth, firstDayOfWeek: firstDayOfWeek,
getMonthAdjust: getMonthAdjust,
aggregateMonth: aggregateMonth,
flowsOf: flowsOf,
penaltyDetails: penaltyDetails,
isCutEdge: isCutEdge, payDayKind: payDayKind, fullDaysLeft: fullDaysLeft,
payDayAPassed: payDayAPassed, isCurrentMonth: isCurrentMonth, isSameDay: isSameDay,
fmt: fmt, money: money, signed: signed, moneyParts: moneyParts,
newDraft: newDraft, normalizeDraft: normalizeDraft
};
});