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