Files
OpenClaw Agent 9efd9a3099
Build Android APK / Build Release APK (push) Canceled after 0s
Sync Release Notes / update-releases (push) Canceled after 0s
fix: 金额计算展示保留原始数据精度,去除 toFixed(1) 进位截断并升级至 v1.0.3
2026-09-16 03:04:10 +08:00

368 lines
17 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 薪酬核算纯模块 —— 零依赖,浏览器与 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 rawAmount(n) {
var v = Number(n) || 0;
return Math.round((v + Number.EPSILON) * 100) / 100;
}
function fmt(n) { return (Number(n) || 0).toFixed(1); }
function money(n) { return '¥' + rawAmount(n); }
function signed(n) {
var v = rawAmount(n);
return (v >= 0 ? '+¥' : '-¥') + Math.abs(v);
}
/**
* 金额拆成 符号/整数部/小数部,供看板主数字分层渲染(大字只放整数部)。
* 保留原始数据的小数部分,不进行有损四舍五入。
*/
function moneyParts(n) {
var v = rawAmount(n);
var s = String(Math.abs(v));
var dot = s.indexOf('.');
var intPart = dot !== -1 ? s.slice(0, dot) : s;
var fracPart = dot !== -1 ? s.slice(dot) : '';
return {
sign: v < 0 ? '-' : '',
int: intPart.replace(/\B(?=(\d{3})+(?!\d))/g, ','), // 千分位
frac: fracPart
};
}
/** 新建一条打卡草稿。班次决定默认工时。 */
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
};
});