PayrollManagementSystem/backend/app/services/tplus/materials.py
liuwenxin 108bf87225 feat(核算)!: 添加 T+ 正式核算与采集托管
补齐源快照、资料预演、正式导入及工资核算的依赖链路。
新增版本化输入、逐段 FIFO、生成锁和结构化当月调整,
并提供默认关闭的周期采集、有限重试及 Windows 托管。

原始客户资料、私有配置及首页改动不纳入本次提交。

BREAKING CHANGE: 升级前需应用新增迁移,并通过
PAYROLL_MAKER_USER_ID 绑定实际工资制作人,否则业务只读。
旧单批次核算默认关闭;历史兼容需显式启用
PAYROLL_ALLOW_LEGACY_CALCULATION。生产启用和真实金额待验收。
2026-11-02 09:42:53 +08:00

85 lines
5.8 KiB
Python
Raw 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.

"""从源库只读整理核对资料,不把费用空值或旧初始化余额当作已确认事实。"""
import hashlib
import json
from datetime import date, datetime
from decimal import Decimal
from pathlib import Path
from sqlalchemy import text
from app.core.exceptions import BadRequestError
from app.core.month import validate_month
QUERIES = ("customer", "shipments", "items", "receipts", "opening", "history", "expenses")
ROOT = Path(__file__).resolve().parents[4] / "db/queries/tplus/materials"
def summarize(records, customer_id, month):
"""空记录只构成候选余额依据,不自动替代业务确认。"""
if len(records["customer"]) != 1:
raise BadRequestError("源客户不存在或身份不唯一")
truncated = [name for name, rows in records.items() if len(rows) > 200]
missing = [
{"key": "GUIDANCE", "title": "发货月份人工指导成本", "reason": "T+成本不用于绩效,需填写并封存人工指导成本"},
{"key": "PRODUCT_CATEGORY", "title": "产品绩效核算分类", "reason": "源产品分类不等于市政水利/轨道交通核算分类"},
{"key": "HISTORICAL_PAYROLL", "title": "样本是否已在旧软件核算或发放", "reason": "源应收及收款记录不能证明绩效已发放"},
{"key": "MANAGER_HANDOVER", "title": "样本当时的分管及交接依据", "reason": "保留源业务员工号,不按当前部门推断历史经理或接手人"},
]
if not records["customer"][0].get("customerType"):
missing.append({"key": "CUSTOMER_CATEGORY", "title": "客户绩效分类", "reason": "源客户绩效属性为空"})
missing.append({"key": "FREIGHT", "title": "该批发货运费是否已剔除", "reason": "费用表和金额仅作证据,缺少关联或空值不自动等于零运费"})
no_old_rows = not records["opening"] and not records["history"]
return {
"customer_id": customer_id, "month": month, "captured_at": datetime.now().isoformat(),
"source_read_only": True, "formal_payroll_written": False,
"found": {name: len(rows) for name, rows in records.items()},
"opening_candidate": "0.00" if no_old_rows else None,
"opening_confirmed": False,
"opening_explanation": "未查到初始化及切换日前应收记录,仅作为候选零余额依据" if no_old_rows else "需将初始化记录与截至2026-02-28的应收变动核对,不直接使用2017年期初",
"truncated": truncated, "ready_for_amount": False, "missing": missing,
"evidence": {name: rows[:200] for name, rows in records.items()},
}
def collect_materials(source, customer_id, month):
validate_month(month)
if customer_id <= 0:
raise BadRequestError("客户ID必须为正整数")
year, mon = map(int, month.split("-"))
until = date(year + (mon == 12), 1 if mon == 12 else mon + 1, 1)
args = {"customer_id": customer_id, "until": until, "cutoff": date(2026, 3, 1)}
records = {name: [dict(r) for r in source.execute(text((ROOT / f"{name}.sql").read_text(encoding="utf-8")), args).mappings()] for name in QUERIES}
result = summarize(records, customer_id, month)
normalized = json.loads(json.dumps(result, ensure_ascii=False, default=str))
normalized["evidence_hash"] = hashlib.sha256(json.dumps(normalized["evidence"], sort_keys=True, ensure_ascii=False).encode()).hexdigest()
return normalized
def render_markdown(report):
def money(value):
return "待核对" if value is None else f"{Decimal(str(value)):,.2f}"
customer = report["evidence"]["customer"][0]
lines = [f"# 样本资料核对:{customer['name']}({report['month']})", "",
f"核对时间:{report['captured_at']}。源库只读;未写入正式工资。", "",
"## 已从恢复库整理", "", "| 类别 | 数量 |", "|---|---:|"]
labels = {"customer": "客户", "shipments": "发货单", "items": "发货明细", "receipts": "回款凭证关联行", "opening": "初始化应收", "history": "切换日前应收分组", "expenses": "销售费用分摊"}
lines += [f"| {labels[name]} | {count} |" for name, count in report["found"].items()]
lines += ["", "## 单据与归属", "", "| 发货单 | 日期 | 含税金额 | 业务员工号 |", "|---|---|---:|---|"]
lines += [f"| {r['code']} | {str(r['voucherdate'])[:10]} | {money(r['taxAmount'])} | {r.get('personnel_code') or '待核对'} |" for r in report["evidence"]["shipments"]]
lines += ["", "| 产品代码 | 品名/规格 | 数量 | 含税单价 | 含税金额 | 运费 |", "|---|---|---:|---:|---:|---:|"]
lines += [f"| {r.get('product_code', '待核对')} | {r.get('product_name', '')} {r.get('specification', '')} | {r.get('quantity', '待核对')} | {money(r.get('taxPrice'))} | {money(r.get('taxAmount'))} | {money(r.get('expenseAmount'))} |" for r in report["evidence"]["items"]]
lines += ["", "| 回款单 | 日期 | 金额 | 总账凭证 |", "|---|---|---:|---|"]
lines += [f"| {r['code']} | {str(r['voucherDate'])[:10]} | {money(r['amount'])} | {r.get('accounting_voucher_code') or '待核对'} |" for r in report["evidence"]["receipts"]]
lines += ["", "## 可推导但尚未确认", "", report["opening_explanation"],
f"候选期初余额:{report['opening_candidate'] or '待核对'}。本报告不自动确认期初余额。", "",
"## 仅需补充的资料", ""]
lines += [f"- {item['title']}:{item['reason']}。" for item in report["missing"]]
if report.get("customer_reply"):
lines += ["", "## 本轮补充回复", "", report["customer_reply"]]
if report["truncated"]:
lines += ["", "存在超出200行的资料,当前核对包不完整,不能作为完整金额验收依据。"]
lines += ["", "指导成本、分类或关键依据缺失,实际工资金额尚未验收,不填默认值。", "", f"证据指纹:`{report['evidence_hash']}`", ""]
return "\n".join(lines)