"""转客户更正:成对确认、原始单据保留,同一回款只归一个业务。""" 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