PayrollManagementSystem/backend/app/services/tplus/business_import.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

408 lines
16 KiB
Python

"""已核实的独立业务从源快照进入正式单据;预览、指纹、执行分别留痕。"""
import copy
from collections import Counter
from datetime import date, datetime
from decimal import Decimal
from types import SimpleNamespace
from sqlalchemy import select
from app.models import (
Business,
BusinessItem,
PayrollInput,
Personnel,
Product,
Receipt,
Shipment,
ShipmentItem,
TPlusSourceLink,
TPlusWorkLine,
User,
)
from app.services import payroll_months
from app.services.no_generator import next_no
from app.services.payroll_access import ensure_payroll_writer
from app.services.tplus.calculation_preview import execute_preview
from app.services.tplus.fifo import money
from app.services.tplus.workbench import WorkbenchError, digest, get_run
def _link(session, source, entity, source_id):
return session.scalar(
select(TPlusSourceLink).where(
TPlusSourceLink.source_database == source,
TPlusSourceLink.entity_type == entity,
TPlusSourceLink.source_id == str(source_id),
)
)
def _latest_input(session, business_id):
return session.scalar(
select(PayrollInput)
.where(PayrollInput.business_id == business_id)
.order_by(PayrollInput.version.desc())
.limit(1)
)
def _append_compatible(old, new):
"""只能新增单据、补充事实;既有单据金额、日期与原发货归属变化进入差异处理。"""
for entity, fields, identity in (
(
"shipments",
(
"date",
"amount",
"quantity",
"product_id",
"unit",
"personnel_id",
"business_key",
"performance_excluded",
"correction_pair_key",
),
lambda r: (r["source_id"], r["line_id"]),
),
(
"receipts",
("date", "amount", "voucher_confirmed", "exclusion", "redirect_business_key"),
lambda r: r["source_id"],
),
):
current = {identity(r): r for r in new[entity]}
for previous in old[entity]:
candidate = current.get(identity(previous))
if candidate is None or any(
str(previous.get(f)) != str(candidate.get(f)) for f in fields
):
return False
return all(
old["group"].get(k) == new["group"].get(k)
for k in ("opening_balance", "excluded_receipt_ids", "customer_id")
)
def _target_state(session, link):
if link is None:
return None
business = session.get(Business, link.target_id)
if business is None:
raise WorkbenchError("BUSINESS_TARGET_MISSING")
tables = (
(Business, Business.id == business.id),
(BusinessItem, BusinessItem.business_id == business.id),
(Shipment, Shipment.business_id == business.id),
(Receipt, Receipt.business_id == business.id),
)
result = {}
for model, condition in tables:
result[model.__tablename__] = [
{c.name: str(getattr(r, c.name)) for c in model.__table__.columns}
for r in session.scalars(select(model).where(condition).order_by(model.id))
]
return result
def _build(session, run):
preview = SimpleNamespace(input_json=run.input_json, source_database=run.source_database)
execute_preview(session, preview, save_lines=False, customer_scope=True)
frozen = preview.snapshot_json
rows = []
targets = {}
seen_customers = set()
for key, values in frozen["calculation_inputs"].items():
summary = preview.summary_json["groups"][key]
row = {"entity_type": "businesses", "source_id": key, "action": "PENDING", "reason": None}
rows.append(row)
if summary["status"] not in ("READY", "SUCCEEDED"):
row["reason"] = ",".join(summary.get("reasons", [])) or "BUSINESS_DATA_PENDING"
continue
customer_id = values["group"]["customer_id"]
if customer_id in seen_customers:
row["action"], row["reason"] = "SKIP", "CUSTOMER_SCOPE_ALREADY_SELECTED"
continue
seen_customers.add(customer_id)
if any(not ship.get("guidance_finalized") for ship in values["shipments"]):
row["reason"] = "GUIDANCE_FINALIZATION_REQUIRED"
continue
business_key = "customer:" + customer_id
row["business_key"] = business_key
row["input_hash"] = digest(values)
link = _link(session, run.source_database, "businesses", business_key)
targets[key] = _target_state(session, link)
if link:
current = _latest_input(session, link.target_id)
row["target_id"] = link.target_id
if current and current.fingerprint == row["input_hash"]:
row["action"] = "SKIP"
elif current and _append_compatible(current.data_json, values):
row["action"] = "UPDATE"
else:
row["action"], row["reason"] = "CONFLICT", "FORMAL_SOURCE_CHANGED"
else:
row["action"] = "CREATE"
for entity, ids in (
("shipments", values["group"]["shipment_ids"]),
("receipts", values["group"]["receipt_ids"]),
):
for source_id in ids:
linked = _link(session, run.source_database, entity, source_id)
if linked and (
not link
or not current
or source_id not in current.data_json["maps"].get(entity, {})
):
row["action"], row["reason"] = "CONFLICT", "SOURCE_ALREADY_IMPORTED"
if not _link(session, run.source_database, "customers", values["group"]["customer_id"]):
row["action"], row["reason"] = "PENDING", "CUSTOMER_IMPORT_REQUIRED"
for ship in values["shipments"]:
if not _link(session, run.source_database, "products", ship["product_id"]):
row["action"], row["reason"] = "PENDING", "PRODUCT_IMPORT_REQUIRED"
frozen = {"calculation": frozen, "plan": rows, "targets": targets}
return rows, frozen, digest(frozen)
def _add_link(session, source, entity, source_id, target):
session.add(
TPlusSourceLink(
source_database=source,
entity_type=entity,
source_id=str(source_id),
target_id=target.id,
)
)
def _new_no(session, model, column, prefix):
return next_no(session, model=model, column_name=column, prefix=prefix)
def _create(session, run, row, frozen):
values = copy.deepcopy(frozen["calculation"]["calculation_inputs"][row["source_id"]])
people = {}
for code in values["facts"]["personnel"]:
candidates = list(
session.scalars(
select(Personnel)
.join(TPlusSourceLink, TPlusSourceLink.target_id == Personnel.id)
.where(
TPlusSourceLink.source_database == run.source_database,
TPlusSourceLink.entity_type == "personnel",
Personnel.source_no == code,
)
)
)
if len(candidates) == 1:
people[code] = candidates[0]
needed = {str(s["personnel_id"]) for s in values["shipments"]}
needed.update(str(s["manager_id"]) for s in values["shipments"] if s.get("manager_id"))
for handover in values["facts"]["handovers"]:
needed.update((str(handover["from_personnel_id"]), str(handover["to_personnel_id"])))
if needed - people.keys():
raise WorkbenchError("PERSONNEL_IMPORT_REQUIRED")
customer = _link(session, run.source_database, "customers", values["group"]["customer_id"])
old_input = _latest_input(session, row["target_id"]) if row.get("target_id") else None
business = (
session.get(Business, row["target_id"], with_for_update=True)
if row.get("target_id")
else Business(
business_no=_new_no(session, Business, "business_no", "YW"),
customer_id=customer.target_id,
owner_personnel_id=people[str(values["shipments"][0]["personnel_id"])].id,
business_date=min(date.fromisoformat(s["date"]) for s in values["shipments"]),
status=1,
is_special=0,
t_plus_id=row["business_key"],
source_no=row["business_key"],
sync_batch_no=run.run_no,
synced_at=datetime.now(),
)
)
session.add(business)
session.flush()
if old_input is None:
_add_link(session, run.source_database, "businesses", row["business_key"], business)
maps = (
copy.deepcopy(old_input.data_json["maps"])
if old_input
else {"shipments": {}, "shipment_items": {}, "receipts": {}, "personnel": {}}
)
maps["personnel"].update({code: p.id for code, p in people.items()})
freight_total, sales_total, cost_total = Decimal(0), Decimal(0), Decimal(0)
for source_id in values["group"]["shipment_ids"]:
lines = [s for s in values["shipments"] if s["source_id"] == source_id]
if not lines:
continue
if any(s.get("performance_excluded") for s in lines):
values.setdefault("excluded_corrections", []).append(
{"shipment_id": source_id, "lines": lines}
)
continue
head = lines[0]
freight = sum((money(s.get("freight_amount") or "0") for s in lines), Decimal(0))
if source_id in maps["shipments"]:
existing = session.get(Shipment, maps["shipments"][source_id], with_for_update=True)
if (
existing is None
or existing.total_amount != sum((money(s["amount"]) for s in lines), Decimal(0))
or existing.ship_date.isoformat() != head["date"]
):
raise WorkbenchError("FORMAL_TARGET_CHANGED")
sales_total += existing.total_amount
freight_total += existing.freight_amount
cost_total += sum(
(money(s["cost_total"]) for s in lines if s.get("cost_total") is not None),
Decimal(0),
)
continue
shipment = Shipment(
business_id=business.id,
shipment_no=_new_no(session, Shipment, "shipment_no", "SH"),
ship_date=date.fromisoformat(head["date"]),
total_amount=sum((money(s["amount"]) for s in lines), Decimal(0)),
freight_amount=freight,
source_no=head["source_no"],
t_plus_id=source_id,
sync_batch_no=run.run_no,
synced_at=datetime.now(),
)
session.add(shipment)
session.flush()
maps["shipments"][source_id] = shipment.id
_add_link(session, run.source_database, "shipments", source_id, shipment)
for ship in lines:
product = session.get(
Product,
_link(session, run.source_database, "products", ship["product_id"]).target_id,
)
quantity = money(ship["quantity"])
amount = money(ship["amount"])
cost = money(ship["cost_total"]) if ship.get("cost_total") is not None else None
item = ShipmentItem(
shipment_id=shipment.id,
product_id=product.id,
product_name=product.product_name,
spec_model=product.spec_model or "",
unit=ship["unit"],
quantity=quantity,
sale_price=(amount / quantity).quantize(Decimal("0.0001")),
amount=amount,
)
session.add(item)
session.flush()
maps["shipment_items"][ship["line_id"]] = item.id
session.add(
BusinessItem(
business_id=business.id,
product_id=product.id,
product_name=product.product_name,
spec_model=product.spec_model or "",
unit=ship["unit"],
quantity=quantity,
sale_price=item.sale_price,
sale_amount=amount,
cost_amount=cost,
product_category=ship["category"],
)
)
sales_total += amount
cost_total += cost or Decimal(0)
freight_total += freight
received = Decimal(0)
for cash in values["receipts"]:
if cash.get("exclusion"):
continue
if cash["source_id"] in maps["receipts"]:
existing = session.get(
Receipt, maps["receipts"][cash["source_id"]], with_for_update=True
)
if (
existing is None
or existing.amount != money(cash["amount"])
or existing.receipt_date.isoformat() != cash["date"]
):
raise WorkbenchError("FORMAL_TARGET_CHANGED")
received += existing.amount
continue
receipt = Receipt(
business_id=business.id,
receipt_batch_no=_new_no(session, Receipt, "receipt_batch_no", "HK"),
receipt_date=date.fromisoformat(cash["date"]),
amount=money(cash["amount"]),
is_barter=0,
settlement_status=0,
source_no=cash["source_no"],
t_plus_id=cash["source_id"],
sync_batch_no=run.run_no,
synced_at=datetime.now(),
)
session.add(receipt)
session.flush()
maps["receipts"][cash["source_id"]] = receipt.id
_add_link(session, run.source_database, "receipts", cash["source_id"], receipt)
received += receipt.amount
business.total_sale_amount = sales_total
business.total_ship_amount = sales_total
business.total_cost_amount = cost_total
business.total_received_amount = received
business.total_freight_amount = freight_total
values["maps"] = maps
values["source_facts"] = frozen["calculation"]["facts"]
session.add(
PayrollInput(
business_id=business.id,
version=old_input.version + 1 if old_input else 1,
source_database=run.source_database,
group_key=row["source_id"],
fingerprint=row["input_hash"],
data_json=values,
requested_by=run.requested_by,
)
)
row["target_id"] = business.id
def execute_business(session, run):
operator = session.get(User, run.requested_by)
ensure_payroll_writer(operator)
request = run
if run.kind == "BUSINESS_APPLY":
request = get_run(session, run.input_json["preview_no"], run.source_database)
if request.kind != "BUSINESS_PREVIEW" or request.status not in ("SUCCEEDED", "PARTIAL"):
raise WorkbenchError("PREVIEW_REQUIRED")
payroll_months.ensure_allocation_editable(session, request.input_json["month"], operator)
rows, frozen, fingerprint = _build(session, request)
if run.kind == "BUSINESS_APPLY" and request.snapshot_hash != fingerprint:
raise WorkbenchError("PREVIEW_STALE")
counts = Counter()
for i, row in enumerate(rows, 1):
if run.kind == "BUSINESS_APPLY" and row["action"] in ("CREATE", "UPDATE"):
try:
with session.begin_nested():
_create(session, run, row, frozen)
except WorkbenchError as exc:
row["action"], row["reason"] = "PENDING", str(exc)
counts[row["action"]] += 1
session.add(
TPlusWorkLine(
run_no=run.run_no,
line_no=i,
entity_type="businesses",
source_id=row["source_id"],
action=row["action"],
reason=row.get("reason"),
data_json=row,
)
)
run.snapshot_json, run.snapshot_hash = frozen, fingerprint
run.summary_json = {
"counts": dict(counts),
"total": len(rows),
"formal_business_written": run.kind == "BUSINESS_APPLY",
"formal_payroll_written": False,
}
run.status = "PARTIAL" if counts["PENDING"] or counts["CONFLICT"] else "SUCCEEDED"