PayrollManagementSystem/backend/app/services/tplus/corrections.py
liuwenxin 1e53792ad6 feat(T+): 完成数据贯通与自动接入闭环
打通 T+ 快照、业务、发货、回款和核算准备链路,保留来源差异并提供待补状态。
新增业务自动接入任务、跨平台读取服务托管、权限复核、来源关联和前端核对页面。
补充迁移、隔离集成测试、浏览器模拟验收及角色授权边界修复。
2026-11-05 18:47:13 +08:00

196 lines
8.7 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.

"""转客户更正:成对确认、原始单据保留,同一回款只归一个业务。"""
from decimal import Decimal
from sqlalchemy import select
from app.core.exceptions import BadRequestError
from app.core.tplus_lock import TPlusBusyError, sync_lock
from app.models import TPlusFact, TPlusSourceLink
from app.services.tplus.fifo import money
from app.services.tplus.snapshots import source_records
from app.services.tplus.workbench import (
WorkbenchError,
_work_source_database,
digest,
fact_out,
latest_facts,
)
def correction_fact(facts, entity, source_id):
"""实体前缀避免发货ID与收款ID相同相互覆盖;旧事实只供核对。"""
found = facts.get(("CORRECTION", f"{entity}:{source_id}")) or facts.get(
("CORRECTION", str(source_id))
)
return found if found and found.data_json.get("entity") == entity else None
def verified_correction(session, source, facts, entity, record):
fact = correction_fact(facts, entity, record.source_id)
if fact is None:
return {}
data = fact.data_json
if data.get("source_hash") != record.row_hash:
raise WorkbenchError("CORRECTION_RECONFIRM_REQUIRED")
if entity == "SHIPMENT":
if data.get("details_hash") != _details_hash(session, source, record):
raise WorkbenchError("CORRECTION_RECONFIRM_REQUIRED")
other = correction_fact(facts, entity, data.get("paired_source_id"))
if not other or (
other.data_json.get("paired_source_id") != record.source_id
or other.data_json.get("pair_key") != data["pair_key"]
or other.data_json.get("source_hash") != data.get("paired_hash")
):
raise WorkbenchError("CORRECTION_PAIR_REQUIRED")
peers = source_records(session, source, "shipments", ids=[data["paired_source_id"]])
if len(peers) != 1 or peers[0].row_hash != data["paired_hash"]:
raise WorkbenchError("CORRECTION_RECONFIRM_REQUIRED")
if other.data_json.get("details_hash") != _details_hash(session, source, peers[0]):
raise WorkbenchError("CORRECTION_RECONFIRM_REQUIRED")
neg, pos = _amount(session, source, record), _amount(session, source, peers[0])
if neg + pos != 0 or neg * pos >= 0:
raise WorkbenchError("CORRECTION_RECONFIRM_REQUIRED")
return data
def _records(session, source, entity, ids):
rows = source_records(session, source, entity, ids=ids)
if {r.source_id for r in rows} != set(ids):
raise BadRequestError("所选更正单或实际回款不在当前完整源快照中")
return {r.source_id: r for r in rows}
def _amount(session, source, head):
lines = source_records(session, source, "shipment_items", parent_ids=[head.source_id])
if not lines or any(r.payload_json.get("taxAmount") is None for r in lines):
raise BadRequestError("更正单缺少完整含税金额明细,不能成对排除")
return sum((money(r.payload_json["taxAmount"]) for r in lines), Decimal(0))
def _details_hash(session, source, head):
return digest(
[
(r.source_id, r.row_hash)
for r in source_records(session, source, "shipment_items", parent_ids=[head.source_id])
]
)
def save_pair(session, payload, actor_id):
"""沿用源级互斥锁,全部事实同事务追加;同一提交幂等。"""
source = _work_source_database(session)
try:
with sync_lock(session):
session.rollback()
facts = latest_facts(session, source)
heads = _records(
session,
source,
"shipments",
[
payload.negative_shipment_id,
payload.positive_shipment_id,
],
)
negative, positive = (
heads[payload.negative_shipment_id],
heads[payload.positive_shipment_id],
)
if negative.payload_json.get("idcustomer") == positive.payload_json.get("idcustomer"):
raise BadRequestError("转客户更正对必须分别属于原客户和新客户")
if any(h.payload_json.get("voucherState") != 189 for h in heads.values()):
raise BadRequestError("更正销货单尚未生效")
neg, pos = _amount(session, source, negative), _amount(session, source, positive)
if not (neg < 0 < pos and neg + pos == 0):
raise BadRequestError("必须选择金额相抵的一张负数及一张正数更正单")
target = facts.get(("GROUP", payload.target_group_key))
if not target or str(target.data_json["customer_id"]) != str(
negative.payload_json["idcustomer"]
):
raise BadRequestError("目标平账组必须属于负数更正单的原客户")
cash = _records(session, source, "receipts", payload.receipt_ids)
if any(
str(r.payload_json.get("idpartner")) != str(positive.payload_json["idcustomer"])
or r.payload_json.get("isReceiveFlag") != 1
or money(r.payload_json.get("amount", "0")) <= 0
for r in cash.values()
):
raise BadRequestError("实际回款须为新客户名下的正数收款单")
pair_key = digest([source, negative.source_id, positive.source_id])
proposed = []
for head, peer in ((negative, positive), (positive, negative)):
proposed.append(
(
f"SHIPMENT:{head.source_id}",
{
"entity": "SHIPMENT",
"pair_key": pair_key,
"exclude_performance": True,
"paired_source_id": peer.source_id,
"source_hash": head.row_hash,
"paired_hash": peer.row_hash,
"details_hash": _details_hash(session, source, head),
},
)
)
for row in cash.values():
proposed.append(
(
f"RECEIPT:{row.source_id}",
{
"entity": "RECEIPT",
"pair_key": pair_key,
"exclude_performance": False,
"redirect_business_key": payload.target_group_key,
"source_hash": row.row_hash,
},
)
)
result = []
for key, data in proposed:
entity, source_id = key.split(":", 1)
current = correction_fact(facts, entity, source_id)
if current and current.data_json.get("pair_key") != pair_key:
raise BadRequestError("该源单据已有其他更正配对,不能重复处理")
# 已导入单据只能重放同一事实;修改需来源差异及当月调整处理。
linked = session.scalar(
select(TPlusSourceLink).where(
TPlusSourceLink.source_database == source,
TPlusSourceLink.entity_type
== ("shipments" if entity == "SHIPMENT" else "receipts"),
TPlusSourceLink.source_id == source_id,
)
)
same = bool(current and all(current.data_json.get(k) == v for k, v in data.items()))
if linked and not same:
raise BadRequestError(
"该单据已正式导入,请核对来源差异;已生成工资的差额使用当月调整单"
)
if same:
result.append(current)
continue
fact = TPlusFact(
source_database=source,
kind="CORRECTION",
fact_key=key,
version=(current.version if current else 0) + 1,
data_json=data,
evidence=payload.evidence,
requested_by=actor_id,
)
session.add(fact)
result.append(fact)
session.flush()
session.commit()
return {"pair_key": pair_key, "facts": [fact_out(f) for f in result]}
except TPlusBusyError:
session.rollback()
raise BadRequestError("资料或任务正在更新,请稍后重试") from None
except WorkbenchError:
session.rollback()
raise BadRequestError("请先完整采集更正发货、发货明细及收款数据") from None
except Exception:
session.rollback()
raise