2026-10-27 23:42:15 -04:00
|
|
|
|
"""读取实际系统字典并统计带单据类型的收款关联,不推断计提口径。"""
|
|
|
|
|
|
|
|
|
|
|
|
import re
|
|
|
|
|
|
from datetime import datetime
|
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
|
|
from sqlalchemy import text
|
|
|
|
|
|
from sqlalchemy.orm import Session
|
|
|
|
|
|
|
|
|
|
|
|
from app.core.logging import scrub
|
|
|
|
|
|
from app.services.tplus.reader import TPlusReader
|
|
|
|
|
|
|
|
|
|
|
|
_COLUMNS = {
|
|
|
|
|
|
"source_version": ("source_version",),
|
|
|
|
|
|
"voucher_types": ("id", "code", "name"),
|
|
|
|
|
|
"state_enums": ("id", "code", "name", "enum_name"),
|
|
|
|
|
|
"receipt_kinds": ("voucher_type_code", "voucher_type_name", "receive_flag", "row_count"),
|
|
|
|
|
|
"receipt_line_kinds": ("voucher_type_id", "voucher_type_name", "receive_flag", "row_count"),
|
|
|
|
|
|
"sales_links": (
|
|
|
|
|
|
"row_count",
|
|
|
|
|
|
"delivery_matches",
|
|
|
|
|
|
"detail_matches",
|
|
|
|
|
|
"direct_order_matches",
|
|
|
|
|
|
"detail_order_matches",
|
|
|
|
|
|
"unambiguous_order_matches",
|
|
|
|
|
|
"invalid_order_ids",
|
|
|
|
|
|
"conflicting_order_ids",
|
|
|
|
|
|
),
|
|
|
|
|
|
"order_coverage": ("row_count", "without_order", "single_order", "multiple_orders"),
|
|
|
|
|
|
}
|
|
|
|
|
|
PENDING_CONFIRMATIONS = (
|
2026-11-01 20:42:53 -05:00
|
|
|
|
(
|
|
|
|
|
|
"cost_basis",
|
|
|
|
|
|
"月度指导成本",
|
|
|
|
|
|
"采用人工含税指导成本;单位成本还是明细总成本、取哪个月份仍待明确",
|
|
|
|
|
|
),
|
|
|
|
|
|
(
|
|
|
|
|
|
"voucher_state",
|
|
|
|
|
|
"凭证例外核对",
|
|
|
|
|
|
"已按收款单与总账凭证的实际关联识别生成资格;无关联、作废或制单异常的记录保留待核对,不以已审核代替",
|
|
|
|
|
|
),
|
|
|
|
|
|
(
|
|
|
|
|
|
"opening_balance",
|
|
|
|
|
|
"历史承接",
|
|
|
|
|
|
"发货日期已明确取销货单日期;截至2026年2月28日的应收和已核算款待提供",
|
|
|
|
|
|
),
|
|
|
|
|
|
("allocation", "FIFO平账范围", "先发货先平账已明确;同客户全部业务还是分项目平账仍待确认"),
|
2026-10-27 23:42:15 -04:00
|
|
|
|
("negative_events", "退货与红冲", "负数、冲销及已结算业务的调整方式需确认"),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def public_report(raw: dict | None) -> dict | None:
|
|
|
|
|
|
"""历史报告也按当前字段白名单输出,禁止透传未知嵌套数据。"""
|
|
|
|
|
|
if not isinstance(raw, dict):
|
|
|
|
|
|
return None
|
|
|
|
|
|
result = {
|
|
|
|
|
|
key: TPlusRelationshipAudit._safe_value(raw.get(key))
|
|
|
|
|
|
for key in (
|
|
|
|
|
|
"schema_version",
|
|
|
|
|
|
"captured_at",
|
|
|
|
|
|
"source_version",
|
|
|
|
|
|
"is_target_engine",
|
|
|
|
|
|
"relationship_status",
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
result["compatibility_status"] = "PENDING"
|
|
|
|
|
|
for section, columns in _COLUMNS.items():
|
|
|
|
|
|
if section in ("source_version", "voucher_types"):
|
|
|
|
|
|
continue
|
|
|
|
|
|
value = raw.get(section)
|
|
|
|
|
|
if isinstance(value, list):
|
|
|
|
|
|
result[section] = [
|
|
|
|
|
|
{key: TPlusRelationshipAudit._safe_value(row.get(key)) for key in columns}
|
|
|
|
|
|
for row in value[:200]
|
|
|
|
|
|
if isinstance(row, dict)
|
|
|
|
|
|
]
|
|
|
|
|
|
elif isinstance(value, dict):
|
|
|
|
|
|
result[section] = {
|
|
|
|
|
|
key: TPlusRelationshipAudit._safe_value(value.get(key)) for key in columns
|
|
|
|
|
|
}
|
|
|
|
|
|
else:
|
|
|
|
|
|
result[section] = None
|
|
|
|
|
|
result["pending_confirmations"] = [
|
|
|
|
|
|
{"key": k, "title": t, "reason": r} for k, t, r in PENDING_CONFIRMATIONS
|
|
|
|
|
|
]
|
|
|
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TPlusRelationshipAudit:
|
|
|
|
|
|
"""执行固定核验查询;结果只包含系统字典和数量,不包含客户记录。"""
|
|
|
|
|
|
|
|
|
|
|
|
def __init__(self, session: Session, query_dir: Path | None = None) -> None:
|
|
|
|
|
|
self.session = session
|
|
|
|
|
|
root = query_dir or TPlusReader._default_query_dir()
|
|
|
|
|
|
source = (root / "audit" / "queries.sql").read_text(encoding="utf-8")
|
|
|
|
|
|
sections = re.split(r"(?m)^-- @query (\w+)\s*$", source)
|
|
|
|
|
|
self.queries = dict(zip(sections[1::2], sections[2::2], strict=True))
|
|
|
|
|
|
if set(self.queries) != set(_COLUMNS):
|
|
|
|
|
|
raise ValueError("关联核验查询清单不完整")
|
|
|
|
|
|
|
|
|
|
|
|
def fetch(self, name: str, **params) -> list[dict]:
|
|
|
|
|
|
"""只接受登记的查询名与绑定参数,过滤非合同字段。"""
|
|
|
|
|
|
if name not in self.queries:
|
|
|
|
|
|
raise ValueError("不支持的核验查询")
|
|
|
|
|
|
result = self.session.execute(text(self.queries[name]), params)
|
|
|
|
|
|
try:
|
|
|
|
|
|
return [
|
|
|
|
|
|
{key: self._safe_value(row.get(key)) for key in _COLUMNS[name]}
|
|
|
|
|
|
for row in result.mappings().all()
|
|
|
|
|
|
]
|
|
|
|
|
|
finally:
|
|
|
|
|
|
result.close()
|
|
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _safe_value(value):
|
|
|
|
|
|
if value is None or isinstance(value, (int, bool)):
|
|
|
|
|
|
return value
|
|
|
|
|
|
return scrub(re.sub(r"[\x00-\x1f]", " ", str(value)))[:200]
|
|
|
|
|
|
|
|
|
|
|
|
def run(self) -> dict:
|
|
|
|
|
|
"""生成事实报告;类型缺失或有歧义时不执行该关联链路。"""
|
|
|
|
|
|
version = self.fetch("source_version")[0]["source_version"]
|
|
|
|
|
|
types = self.fetch("voucher_types")
|
|
|
|
|
|
report = {
|
|
|
|
|
|
"schema_version": 1,
|
|
|
|
|
|
"captured_at": datetime.now().isoformat(),
|
|
|
|
|
|
"source_version": version,
|
|
|
|
|
|
"is_target_engine": str(version).startswith("10.50."),
|
|
|
|
|
|
"compatibility_status": "PENDING",
|
|
|
|
|
|
"state_enums": self.fetch("state_enums"),
|
|
|
|
|
|
"receipt_kinds": self.fetch("receipt_kinds"),
|
|
|
|
|
|
"receipt_line_kinds": self.fetch("receipt_line_kinds"),
|
|
|
|
|
|
"sales_links": None,
|
|
|
|
|
|
"order_coverage": None,
|
|
|
|
|
|
"relationship_status": "UNRESOLVED",
|
|
|
|
|
|
"relationship_basis": "主表按 isReceiveFlag=1(是否收款)筛选;明细按 SM_VoucherType 的 SA04 类型关联。主表 idvouchertype 是来源类型,不能据此筛本单类型。",
|
|
|
|
|
|
"pending_confirmations": [
|
|
|
|
|
|
{"key": k, "title": t, "reason": r} for k, t, r in PENDING_CONFIRMATIONS
|
|
|
|
|
|
],
|
|
|
|
|
|
}
|
|
|
|
|
|
sale = [r["id"] for r in types if r["code"] == "SA04"]
|
|
|
|
|
|
if len(sale) == 1:
|
|
|
|
|
|
params = {"sale_delivery_type_id": sale[0], "receive_flag": 1}
|
|
|
|
|
|
report["sales_links"] = self.fetch("sales_links", **params)[0]
|
|
|
|
|
|
report["order_coverage"] = self.fetch("order_coverage", **params)[0]
|
|
|
|
|
|
report["relationship_status"] = "OBSERVED"
|
|
|
|
|
|
return report
|