打通 T+ 快照、业务、发货、回款和核算准备链路,保留来源差异并提供待补状态。 新增业务自动接入任务、跨平台读取服务托管、权限复核、来源关联和前端核对页面。 补充迁移、隔离集成测试、浏览器模拟验收及角色授权边界修复。
196 lines
8.7 KiB
Python
196 lines
8.7 KiB
Python
"""转客户更正:成对确认、原始单据保留,同一回款只归一个业务。"""
|
||
|
||
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
|