补齐源快照、资料预演、正式导入及工资核算的依赖链路。 新增版本化输入、逐段 FIFO、生成锁和结构化当月调整, 并提供默认关闭的周期采集、有限重试及 Windows 托管。 原始客户资料、私有配置及首页改动不纳入本次提交。 BREAKING CHANGE: 升级前需应用新增迁移,并通过 PAYROLL_MAKER_USER_ID 绑定实际工资制作人,否则业务只读。 旧单批次核算默认关闭;历史兼容需显式启用 PAYROLL_ALLOW_LEGACY_CALCULATION。生产启用和真实金额待验收。
85 lines
5.8 KiB
Python
85 lines
5.8 KiB
Python
"""从源库只读整理核对资料,不把费用空值或旧初始化余额当作已确认事实。"""
|
||
|
||
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)
|