feat(T+): 完成数据贯通与自动接入闭环
打通 T+ 快照、业务、发货、回款和核算准备链路,保留来源差异并提供待补状态。 新增业务自动接入任务、跨平台读取服务托管、权限复核、来源关联和前端核对页面。 补充迁移、隔离集成测试、浏览器模拟验收及角色授权边界修复。
This commit is contained in:
1 parent
3efe72c344
commit
1e53792ad6
151 files changed
+8842
-401
No files matched your search
@@ -135,6 +135,16 @@ def validate_role_permissions(permission_codes):
|
||||
return codes
|
||||
|
||||
|
||||
def active_permission_ids(session, codes):
|
||||
"""核验目录对应的启用记录齐全,避免保存不可生效的授权。"""
|
||||
ids = dict(session.execute(select(Permission.perm_code, Permission.id).where(
|
||||
Permission.perm_code.in_(codes), Permission.status == 1
|
||||
)).all())
|
||||
if set(ids) != codes:
|
||||
raise BadRequestError("权限目录尚未就绪,请联系管理员")
|
||||
return ids
|
||||
|
||||
|
||||
def save_role(session, role_id, payload, actor, meta=None):
|
||||
audit_data = payload.model_dump(mode="json")
|
||||
with audit_service.audit(session, operator=actor, meta=meta, record=AuditRecord(
|
||||
@@ -147,10 +157,10 @@ def save_role(session, role_id, payload, actor, meta=None):
|
||||
if role.role_code == "admin":
|
||||
raise ForbiddenError("管理员角色权限固定,不能修改")
|
||||
codes = validate_role_permissions(payload.permission_codes)
|
||||
ids = active_permission_ids(session, codes)
|
||||
audit_data["before"] = role_permissions(session, role.id)["permission_codes"]
|
||||
audit_data["after"] = sorted(codes)
|
||||
session.execute(delete(RolePermission).where(RolePermission.role_id == role.id))
|
||||
ids = dict(session.execute(select(Permission.perm_code, Permission.id)).all())
|
||||
session.add_all([RolePermission(role_id=role.id, permission_id=ids[c]) for c in codes])
|
||||
bump(policy, actor)
|
||||
session.flush()
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""业务表单参照只返回必要字段,不借下拉权限开放整个资料接口。"""
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from app.core.exceptions import BadRequestError, ForbiddenError
|
||||
from app.models import (
|
||||
@@ -49,28 +49,75 @@ OPTIONS = {
|
||||
}
|
||||
|
||||
|
||||
def payroll_options(session, actor, business_id):
|
||||
def payroll_options(session, actor, business_id, *, page_num=1, page_size=200):
|
||||
"""核算资料所需的人员及发货明细,不开放人员薪酬或联系方式。"""
|
||||
from app.core.exceptions import NotFoundError
|
||||
from app.models import ShipmentItem
|
||||
from app.models import ShipmentItem, TPlusBusinessDocument, TPlusSourceLink, TPlusSyncRecord
|
||||
from app.services.authorization_service import require_permission
|
||||
from app.services.tplus.guidance import latest_guidance_facts, resolve_guidance
|
||||
|
||||
if page_num < 1 or not 1 <= page_size <= 200:
|
||||
raise ValueError("无效的核算参照分页")
|
||||
require_permission(session, actor, "payroll:view")
|
||||
if session.get(Business, business_id) is None:
|
||||
business = session.get(Business, business_id)
|
||||
if business is None:
|
||||
raise NotFoundError("业务不存在")
|
||||
heads = list(session.scalars(select(Shipment).where(Shipment.business_id == business_id).order_by(Shipment.id).limit(201)))
|
||||
if len(heads) > 200:
|
||||
raise BadRequestError("单笔业务超过 200 张发货单,请分批核对资料")
|
||||
people = [dict(row) for row in session.execute(select(Personnel.id, Personnel.personnel_name).order_by(Personnel.id).limit(201)).mappings()]
|
||||
if len(people) > 200:
|
||||
raise BadRequestError("核算人员超过单次范围,请按实际归属分批处理")
|
||||
lines = session.execute(select(ShipmentItem.id, ShipmentItem.shipment_id, ShipmentItem.product_name, ShipmentItem.amount).where(ShipmentItem.shipment_id.in_([h.id for h in heads]))).mappings()
|
||||
by_head = {head.id: head for head in heads}
|
||||
return {"personnel": people, "shipments": [
|
||||
{"id": row["id"], "shipment_no": by_head[row["shipment_id"]].shipment_no,
|
||||
"ship_date": str(by_head[row["shipment_id"]].ship_date), "product_name": row["product_name"], "amount": str(row["amount"])}
|
||||
for row in lines
|
||||
]}
|
||||
source_customer = (business.integration_json or {}).get("customer_source_id")
|
||||
offset = (page_num - 1) * page_size
|
||||
people = session.execute(select(Personnel.id, Personnel.personnel_name).order_by(Personnel.id)
|
||||
.offset(offset).limit(page_size)).mappings()
|
||||
lines_query = select(ShipmentItem.id, Shipment.shipment_no, Shipment.ship_date,
|
||||
ShipmentItem.product_name, ShipmentItem.amount, ShipmentItem.unit).join(
|
||||
Shipment, Shipment.id == ShipmentItem.shipment_id).where(Shipment.business_id == business_id)
|
||||
lines = list(session.execute(lines_query.order_by(Shipment.id, ShipmentItem.id).offset(offset).limit(page_size)).mappings())
|
||||
source = (business.integration_json or {}).get("source_database")
|
||||
candidates = {}
|
||||
if source and lines:
|
||||
documents = session.scalars(select(TPlusBusinessDocument).where(
|
||||
TPlusBusinessDocument.business_id == business_id,
|
||||
TPlusBusinessDocument.source_database == source,
|
||||
TPlusBusinessDocument.entity_type == "shipment_items",
|
||||
TPlusBusinessDocument.status == "APPLIED",
|
||||
TPlusBusinessDocument.target_id.in_([row["id"] for row in lines]),
|
||||
))
|
||||
for document in documents:
|
||||
candidates.setdefault(document.target_id, []).append(document)
|
||||
person_sources = {str(data["source_personnel_id"]) for values in candidates.values() for doc in values
|
||||
if (data := doc.data_json or {}).get("source_personnel_id") is not None}
|
||||
links = dict(session.execute(select(TPlusSourceLink.source_id, Personnel.id).join(
|
||||
Personnel, Personnel.id == TPlusSourceLink.target_id).where(
|
||||
TPlusSourceLink.source_database == source, TPlusSourceLink.entity_type == "personnel",
|
||||
TPlusSourceLink.source_id.in_(person_sources))).all()) if person_sources else {}
|
||||
record_ids = {doc.record_id for values in candidates.values() for doc in values}
|
||||
records = {r.id: r for r in session.scalars(select(TPlusSyncRecord).where(
|
||||
TPlusSyncRecord.id.in_(record_ids)))} if record_ids else {}
|
||||
guidance_facts = latest_guidance_facts(session, source) if record_ids else {}
|
||||
|
||||
def source_details(row):
|
||||
matches = candidates.get(row["id"], [])
|
||||
document = matches[0] if len(matches) == 1 else None
|
||||
data = (document.data_json or {}) if document else {}
|
||||
source_id = data.get("source_personnel_id")
|
||||
record = records.get(document.record_id) if document else None
|
||||
product_id = record.payload_json.get("idinventory") if record else None
|
||||
status, guidance = resolve_guidance(guidance_facts, str(product_id), document.source_id,
|
||||
row["ship_date"].strftime("%Y-%m"), row["unit"]) if product_id is not None else ("MISSING", None)
|
||||
if guidance:
|
||||
status = "FINALIZED_" + guidance["basis"]
|
||||
return {"source_personnel_name": data.get("source_personnel_name"),
|
||||
"matched_personnel_id": links.get(str(source_id)) if source_id is not None else None,
|
||||
"unit": row["unit"],
|
||||
"guidance_status": status,
|
||||
"guidance_unit_cost": guidance["amount"] if status == "FINALIZED_UNIT" else None,
|
||||
"guidance_line_total": guidance["amount"] if status == "FINALIZED_LINE_TOTAL" else None}
|
||||
return {"business_key": f"customer:{source_customer}" if source_customer is not None else None,
|
||||
"personnel": [dict(row) for row in people],
|
||||
"personnel_total": session.scalar(select(func.count()).select_from(Personnel)),
|
||||
"shipments_total": session.scalar(select(func.count()).select_from(ShipmentItem).join(
|
||||
Shipment, Shipment.id == ShipmentItem.shipment_id).where(Shipment.business_id == business_id)),
|
||||
"shipments": [{"id": row["id"], "shipment_no": row["shipment_no"],
|
||||
"ship_date": str(row["ship_date"]), "product_name": row["product_name"],
|
||||
"amount": str(row["amount"]), **source_details(row)} for row in lines]}
|
||||
|
||||
|
||||
def options(session, actor, resource, *, business_id=None, search=None):
|
||||
|
||||
@@ -39,8 +39,9 @@ def permission_for(module, name, method, *, params=None, body=None):
|
||||
if method == "GET":
|
||||
return resource + ":view"
|
||||
special = {"set_status": "status", "change_status": "status", "reset_password": "password",
|
||||
"confirm_receipt": "confirm",
|
||||
"replace_regions": "regions", "replace_plans": "plans", "rate_customer": "rating",
|
||||
"change_ownership": "ownership", "upload_logo": "update"}
|
||||
"change_ownership": "ownership", "batch_ownership": "ownership", "upload_logo": "update"}
|
||||
action = special.get(name) or {"POST": "create", "PUT": "update", "DELETE": "delete"}.get(method)
|
||||
return resource + ":" + action if action else None
|
||||
if module == "system.params":
|
||||
@@ -57,11 +58,13 @@ def permission_for(module, name, method, *, params=None, body=None):
|
||||
if module == "system.tplus":
|
||||
return "tplus:" + ("view" if method == "GET" else "retry" if name == "retry" else "collect")
|
||||
if module == "system.tplus_workbench":
|
||||
if name.startswith("business_sync_"):
|
||||
return "tplus:view" if method == "GET" else "tplus:import"
|
||||
if name == "work_report":
|
||||
return "tplus:export"
|
||||
if method == "GET":
|
||||
return "tplus:view"
|
||||
if name == "save_fact":
|
||||
if name in ("save_fact", "save_correction_pair"):
|
||||
return "tplus:facts"
|
||||
if name == "create_work":
|
||||
return "tplus:import" if body.get("kind") in ("MASTER_APPLY", "BUSINESS_APPLY") else "tplus:preview"
|
||||
|
||||
@@ -106,12 +106,16 @@ def _to_out(row: tuple) -> BusinessOut:
|
||||
assistant_personnel_name=assistant_name,
|
||||
is_special=business.is_special,
|
||||
special_note=business.special_note,
|
||||
total_sale_amount=_str_or_zero(business.total_sale_amount),
|
||||
total_cost_amount=_str_or_zero(business.total_cost_amount),
|
||||
total_sale_amount=None if business.integration_json and business.total_sale_amount is None else _str_or_zero(business.total_sale_amount),
|
||||
total_cost_amount=None if business.integration_json and business.total_cost_amount is None else _str_or_zero(business.total_cost_amount),
|
||||
total_ship_amount=_str_or_zero(business.total_ship_amount),
|
||||
total_received_amount=_str_or_zero(business.total_received_amount),
|
||||
total_freight_amount=_str_or_zero(business.total_freight_amount),
|
||||
total_freight_amount=None if business.integration_json and business.total_freight_amount is None else _str_or_zero(business.total_freight_amount),
|
||||
remark=business.remark,
|
||||
source_type="TPLUS" if business.t_plus_id else "MANUAL",
|
||||
source_no=business.source_no,
|
||||
source_personnel=(business.integration_json or {}).get("personnel_names", []),
|
||||
integration_status="WAITING_MATERIALS" if business.integration_json else None,
|
||||
)
|
||||
|
||||
|
||||
@@ -179,6 +183,12 @@ def _recalc_totals(session: Session, business: Business) -> None:
|
||||
五条独立聚合查询(各表单列 SUM,走 business_id 索引)——业务单据量级
|
||||
为单业务数行,不值得为省查询做单大 JOIN。
|
||||
"""
|
||||
if business.integration_json:
|
||||
from app.services.tplus.business_sync import _refresh_business_totals
|
||||
|
||||
session.flush()
|
||||
_refresh_business_totals(session, business)
|
||||
return
|
||||
sale = session.execute(
|
||||
_sum(BusinessItem.sale_amount, BusinessItem.business_id, business.id)
|
||||
).scalar_one()
|
||||
@@ -236,10 +246,52 @@ def _is_duplicate_entry(exc: IntegrityError) -> bool:
|
||||
|
||||
def list_businesses(session: Session, query: BusinessQuery) -> PageResult[BusinessOut]:
|
||||
"""分页查询业务(附客户名与归属人名)。"""
|
||||
rows = business_repository.list_businesses(session, query)
|
||||
total = business_repository.count_businesses(session, query)
|
||||
from app.services.business_source import readiness_summaries
|
||||
|
||||
summaries = {}
|
||||
sql_reason = query.readiness_reason in business_repository.READINESS_SQL_REASONS
|
||||
if sql_reason and query.readiness_status == "READY":
|
||||
rows, total = [], 0
|
||||
elif sql_reason:
|
||||
rows = business_repository.list_businesses(session, query)
|
||||
total = business_repository.count_businesses(session, query)
|
||||
summaries = readiness_summaries(session, [row[0] for row in rows])
|
||||
elif query.pending_only or query.readiness_status or query.readiness_reason:
|
||||
rows, total, before_id = [], 0, None
|
||||
snapshots = {}
|
||||
while batch := business_repository.scan_businesses(session, query, before_id):
|
||||
states = readiness_summaries(session, [row[0] for row in batch], snapshot_cache=snapshots)
|
||||
for row in batch:
|
||||
state = states.get(row[0].id)
|
||||
if not state:
|
||||
continue
|
||||
if query.pending_only and state["status"] != "PENDING":
|
||||
continue
|
||||
if query.readiness_status and state["status"] != query.readiness_status:
|
||||
continue
|
||||
if query.readiness_reason and query.readiness_reason not in {r["code"] for r in state["reasons"]}:
|
||||
continue
|
||||
if query.offset <= total < query.offset + query.page_size:
|
||||
rows.append(row)
|
||||
summaries[row[0].id] = state
|
||||
total += 1
|
||||
before_id = batch[-1][0].id
|
||||
else:
|
||||
rows = business_repository.list_businesses(session, query)
|
||||
total = business_repository.count_businesses(session, query)
|
||||
summaries = readiness_summaries(session, [row[0] for row in rows])
|
||||
records = []
|
||||
for row in rows:
|
||||
out = _to_out(row)
|
||||
state = summaries.get(out.id)
|
||||
if state:
|
||||
out.readiness_status = state["status"]
|
||||
out.readiness_reasons = state["reasons"]
|
||||
if any(reason["code"] == "SALES_RELATION_PENDING" for reason in state["reasons"]):
|
||||
out.total_sale_amount = out.total_cost_amount = None
|
||||
records.append(out)
|
||||
return PageResult[BusinessOut](
|
||||
records=[_to_out(row) for row in rows],
|
||||
records=records,
|
||||
total=total,
|
||||
page_num=query.page_num,
|
||||
page_size=query.page_size,
|
||||
@@ -255,6 +307,11 @@ def get_detail(session: Session, business_id: int) -> BusinessDetailOut:
|
||||
if row is None:
|
||||
raise NotFoundError("业务不存在")
|
||||
out = BusinessDetailOut(**_to_out(row).model_dump())
|
||||
if row[0].integration_json:
|
||||
from app.services.business_source import sales_relation_pending
|
||||
|
||||
if sales_relation_pending(session, business_id):
|
||||
out.total_sale_amount = out.total_cost_amount = None
|
||||
out.items = [
|
||||
_item_to_out(item) for item in business_repository.list_items(session, business_id)
|
||||
]
|
||||
@@ -500,6 +557,9 @@ def update_business(
|
||||
raise NotFoundError("业务不存在")
|
||||
business, _, owner_name, assistant_name = row
|
||||
|
||||
if business.integration_json:
|
||||
raise BadRequestError("T+ 来源业务请通过差异核对处理,不能直接替换明细")
|
||||
|
||||
# 终态门槛与发货/回款写准入同口径:明细与汇总是 M3 核算基线,
|
||||
# 已完成/已终止的业务禁改(发货/回款服务以同一理由禁写终态业务)
|
||||
if business.status != BUSINESS_STATUS_ACTIVE:
|
||||
@@ -631,6 +691,8 @@ def delete_business(
|
||||
):
|
||||
if business is None:
|
||||
raise NotFoundError("业务不存在")
|
||||
if business.integration_json:
|
||||
raise BadRequestError("T+ 来源业务不能直接删除,请通过差异核对处理")
|
||||
if business_repository.has_docs(session, business_id):
|
||||
raise BadRequestError("业务已有发货或回款记录,请改为已终止而非删除")
|
||||
# 在途审批单(ref_id 固化无外键,业务删了单还挂着且可被继续审批):
|
||||
@@ -993,7 +1055,9 @@ def _gross_profit_row(
|
||||
|
||||
|
||||
def _scan_gross_profit_rows(
|
||||
session: Session, business_id: int, threshold_of: Callable[[str], Fraction]
|
||||
session: Session, business_id: int, threshold_of: Callable[[str], Fraction], *,
|
||||
sales_relation_pending: bool = False, items: list[BusinessItem] | None = None,
|
||||
included_item_ids: set[int] | None = None,
|
||||
) -> _GrossProfitScan:
|
||||
"""逐行扫描业务明细:出行 DTO、类别聚合桶与成本缺失计数。
|
||||
|
||||
@@ -1002,7 +1066,24 @@ def _scan_gross_profit_rows(
|
||||
rows: list[GrossProfitRowOut] = []
|
||||
agg: dict[str, list[Decimal]] = {}
|
||||
missing_count = 0
|
||||
for item in business_repository.list_items(session, business_id):
|
||||
for item in items if items is not None else business_repository.list_items(session, business_id):
|
||||
if sales_relation_pending:
|
||||
rows.append(GrossProfitRowOut(item_id=item.id, product_category=item.product_category,
|
||||
product_name=item.product_name, spec_model=item.spec_model, quantity=str(item.quantity),
|
||||
sale_amount=str(item.sale_amount), missing_reason="订单与发货关联待核对,无法汇总毛利润"))
|
||||
missing_count += 1
|
||||
continue
|
||||
if included_item_ids is not None and item.id not in included_item_ids:
|
||||
rows.append(GrossProfitRowOut(item_id=item.id, product_category=item.product_category,
|
||||
product_name=item.product_name, spec_model=item.spec_model, quantity=str(item.quantity),
|
||||
sale_amount=str(item.sale_amount), missing_reason="已计入订单,不重复汇总"))
|
||||
continue
|
||||
if item.product_category is None:
|
||||
rows.append(GrossProfitRowOut(item_id=item.id, product_category=None, product_name=item.product_name,
|
||||
spec_model=item.spec_model, quantity=str(item.quantity), sale_amount=str(item.sale_amount),
|
||||
missing_reason="产品核算分类待确认"))
|
||||
missing_count += 1
|
||||
continue
|
||||
row, bucket = _gross_profit_row(item, threshold_of(item.product_category))
|
||||
rows.append(row)
|
||||
if bucket is None:
|
||||
@@ -1041,7 +1122,8 @@ def gross_profit(session: Session, business_id: int, as_of: date | None) -> Gros
|
||||
|
||||
:raises NotFoundError: 业务不存在
|
||||
"""
|
||||
if business_repository.get_by_id(session, business_id) is None:
|
||||
business = business_repository.get_by_id(session, business_id)
|
||||
if business is None:
|
||||
raise NotFoundError("业务不存在")
|
||||
as_of = as_of or date.today()
|
||||
snapshot = param_service.resolve_snapshot(session, as_of)
|
||||
@@ -1049,7 +1131,20 @@ def gross_profit(session: Session, business_id: int, as_of: date | None) -> Gros
|
||||
def threshold_of(category: str) -> Fraction:
|
||||
return snapshot.fraction("product.threshold", product_category=category)
|
||||
|
||||
scan = _scan_gross_profit_rows(session, business_id, threshold_of)
|
||||
items = business_repository.list_items(session, business_id)
|
||||
pending, included_ids = False, None
|
||||
if business.integration_json and business.integration_json.get("source_database"):
|
||||
from app.models import TPlusBusinessDocument
|
||||
from app.services.tplus.sales_relations import resolve_items
|
||||
|
||||
order_ids = list(session.scalars(select(TPlusBusinessDocument.target_id).where(
|
||||
TPlusBusinessDocument.business_id == business_id,
|
||||
TPlusBusinessDocument.entity_type == "business_items",
|
||||
TPlusBusinessDocument.status == "APPLIED")))
|
||||
pending, included = resolve_items(session, business, items, order_ids)
|
||||
included_ids = {item.id for item in included}
|
||||
scan = _scan_gross_profit_rows(session, business_id, threshold_of, sales_relation_pending=pending,
|
||||
items=items, included_item_ids=included_ids)
|
||||
categories = [
|
||||
_gross_profit_category(category, sale, cost, threshold_of(category))
|
||||
for category, [sale, cost] in scan.category_agg.items()
|
||||
|
||||
@@ -0,0 +1,838 @@
|
||||
"""业务来源、待补资料和来源业务核算准备;不重新录入源单据。"""
|
||||
|
||||
from collections import defaultdict
|
||||
from contextlib import contextmanager
|
||||
from datetime import date
|
||||
from decimal import ROUND_HALF_UP, Decimal
|
||||
|
||||
from sqlalchemy import Numeric, String, and_, cast, func, literal, or_, select, union
|
||||
from sqlalchemy.orm import aliased
|
||||
|
||||
from app.core.exceptions import BadRequestError, NotFoundError
|
||||
from app.core.response import PageResult
|
||||
from app.models import (
|
||||
Business,
|
||||
BusinessItem,
|
||||
BusinessRole,
|
||||
Customer,
|
||||
PayrollInput,
|
||||
Personnel,
|
||||
Product,
|
||||
Receipt,
|
||||
Shipment,
|
||||
ShipmentItem,
|
||||
TPlusBusinessDocument,
|
||||
TPlusBusinessSyncConfig,
|
||||
TPlusSourceHead,
|
||||
TPlusSyncRecord,
|
||||
TPlusWorkRun,
|
||||
)
|
||||
from app.services import payroll_months
|
||||
from app.services.personnel_roles import role_on
|
||||
from app.services.tplus.calculation_preview import _voucher_evidence
|
||||
from app.services.tplus.workbench import digest, latest_facts
|
||||
|
||||
LABELS = {
|
||||
"CUSTOMER_CATEGORY_PENDING": "请确认客户核算类型",
|
||||
"PRODUCT_CATEGORY_PENDING": "请补齐产品核算分类",
|
||||
"PERSONNEL_PENDING": "请逐条确认发货归属人员及历史岗位",
|
||||
"OPENING_BALANCE_PENDING": "请核实2026年2月末期初应收与历史承接",
|
||||
"GUIDANCE_PENDING": "请录入发货月份指导成本并封存",
|
||||
"FREIGHT_PENDING": "请核实源销售金额中的运费",
|
||||
"NO_ACTUAL_SHIPMENT": "尚未发货,可继续关联订单和预收款",
|
||||
"SOURCE_CONFLICT": "来源有差异或异常记录,请先核对",
|
||||
"SOURCE_SNAPSHOT_PENDING": "最新完整来源快照尚未完成业务接入,请等待或重试接入",
|
||||
"INPUT_REFRESH_REQUIRED": "新增单据或资料已变化,请刷新核算资料版本",
|
||||
"SOURCE_DOCUMENT_INACTIVE": "存在尚未生效的来源单据",
|
||||
"SALES_RELATION_PENDING": "订单与发货明细的关联不完整,请核对销售额,避免重复统计",
|
||||
}
|
||||
|
||||
|
||||
def business(session, business_id):
|
||||
row = session.get(Business, business_id)
|
||||
if row is None:
|
||||
raise NotFoundError("业务不存在")
|
||||
return row
|
||||
|
||||
|
||||
def ensure_source_editable(session, entity, target_id):
|
||||
"""来源单据不得通过手工全量替换接口绕过差异核对。"""
|
||||
linked = session.scalar(select(TPlusBusinessDocument.id).where(
|
||||
TPlusBusinessDocument.entity_type == entity,
|
||||
TPlusBusinessDocument.target_id == target_id,
|
||||
TPlusBusinessDocument.applied_record_id.is_not(None),
|
||||
).limit(1))
|
||||
if linked is not None:
|
||||
raise BadRequestError("T+ 来源单据请通过差异核对处理,不能直接编辑或删除")
|
||||
|
||||
|
||||
def _latest(session, business_id):
|
||||
return session.scalar(select(PayrollInput).where(PayrollInput.business_id == business_id).order_by(PayrollInput.version.desc()).limit(1))
|
||||
|
||||
|
||||
def sales_relations_pending(session, business_ids):
|
||||
"""批量核对当前明细,兼容尚未由新版 worker 更新标记的旧业务。"""
|
||||
if not business_ids:
|
||||
return set()
|
||||
order_match = select(TPlusBusinessDocument.id).where(
|
||||
TPlusBusinessDocument.business_id == BusinessItem.business_id,
|
||||
TPlusBusinessDocument.entity_type == "business_items",
|
||||
TPlusBusinessDocument.status == "APPLIED",
|
||||
TPlusBusinessDocument.target_id == BusinessItem.id,
|
||||
).exists()
|
||||
rows = session.execute(select(BusinessItem.business_id, order_match).where(
|
||||
BusinessItem.business_id.in_(business_ids)).distinct())
|
||||
matches, unmatched = set(), set()
|
||||
for business_id, matched in rows:
|
||||
(matches if matched else unmatched).add(business_id)
|
||||
mismatch = set(session.scalars(select(TPlusBusinessDocument.business_id).where(
|
||||
TPlusBusinessDocument.business_id.in_(business_ids),
|
||||
TPlusBusinessDocument.status == "CONFLICT",
|
||||
TPlusBusinessDocument.reason == "ORDER_SHIPMENT_MISMATCH",
|
||||
).distinct()))
|
||||
pending = (matches & unmatched) | mismatch
|
||||
if not pending:
|
||||
return pending
|
||||
from app.models import TPlusFact
|
||||
from app.services.tplus.sales_relations import KIND, resolve_items
|
||||
|
||||
# 仅曾确认过的业务需要进一步检查事实;大多数待核对业务无需逐条加载。
|
||||
confirmed = set(session.scalars(select(Business.id).join(TPlusFact, (
|
||||
TPlusFact.source_database == Business.integration_json["source_database"].as_string())
|
||||
& (TPlusFact.kind == KIND) & (TPlusFact.fact_key.like(func.concat("sales:", Business.id, ":%")))
|
||||
).where(Business.id.in_(pending)).distinct())) if session.bind.dialect.name == "mysql" else {
|
||||
business_id for business_id in pending if session.scalar(select(TPlusFact.id).where(
|
||||
TPlusFact.kind == KIND, TPlusFact.fact_key.like(f"sales:{business_id}:%")).limit(1))
|
||||
}
|
||||
for business_id in confirmed:
|
||||
business = session.get(Business, business_id)
|
||||
if not business or not business.integration_json:
|
||||
continue
|
||||
items = list(session.scalars(select(BusinessItem).where(BusinessItem.business_id == business_id)))
|
||||
order_ids = list(session.scalars(select(TPlusBusinessDocument.target_id).where(
|
||||
TPlusBusinessDocument.business_id == business_id,
|
||||
TPlusBusinessDocument.entity_type == "business_items", TPlusBusinessDocument.status == "APPLIED")))
|
||||
if business_id not in mismatch and not resolve_items(session, business, items, order_ids)[0]:
|
||||
pending.discard(business_id)
|
||||
return pending
|
||||
|
||||
|
||||
def sales_relation_pending(session, business_id):
|
||||
return business_id in sales_relations_pending(session, [business_id])
|
||||
|
||||
|
||||
def readiness_summaries(session, rows, *, snapshot_cache=None):
|
||||
"""批量计算列表准备状态,与详情使用同一组判断,不按历史输入是否存在猜测。"""
|
||||
sources = [row for row in rows if row.integration_json]
|
||||
if not sources:
|
||||
return {}
|
||||
ids = [row.id for row in sources]
|
||||
customers = {c.id: c for c in session.scalars(select(Customer).where(
|
||||
Customer.id.in_({row.customer_id for row in sources})))}
|
||||
missing_products = set(session.scalars(select(BusinessItem.business_id).join(
|
||||
Product, Product.id == BusinessItem.product_id).where(
|
||||
BusinessItem.business_id.in_(ids), Product.product_category.is_(None)).distinct()))
|
||||
shipped = set(session.scalars(select(Shipment.business_id).where(
|
||||
Shipment.business_id.in_(ids)).distinct()))
|
||||
latest = select(PayrollInput.business_id, func.max(PayrollInput.version).label("version")).where(
|
||||
PayrollInput.business_id.in_(ids)).group_by(PayrollInput.business_id).subquery()
|
||||
inputs = {item.business_id: item for item in session.scalars(select(PayrollInput).join(
|
||||
latest, (PayrollInput.business_id == latest.c.business_id) & (PayrollInput.version == latest.c.version)))}
|
||||
lines = defaultdict(dict)
|
||||
receipts = defaultdict(set)
|
||||
if inputs:
|
||||
for business_id, item_id, category in session.execute(select(
|
||||
Shipment.business_id, ShipmentItem.id, Product.product_category).join(
|
||||
Shipment, Shipment.id == ShipmentItem.shipment_id).outerjoin(
|
||||
Product, Product.id == ShipmentItem.product_id).where(Shipment.business_id.in_(inputs))):
|
||||
lines[business_id][item_id] = category
|
||||
for business_id, receipt_id in session.execute(select(Receipt.business_id, Receipt.id).where(
|
||||
Receipt.business_id.in_(inputs))):
|
||||
receipts[business_id].add(receipt_id)
|
||||
conflicts = set(session.scalars(select(TPlusBusinessDocument.business_id).where(
|
||||
TPlusBusinessDocument.business_id.in_(ids),
|
||||
TPlusBusinessDocument.status.in_(("CONFLICT", "PENDING"))).distinct()))
|
||||
relation_pending = sales_relations_pending(session, ids)
|
||||
snapshots = snapshot_cache if snapshot_cache is not None else {}
|
||||
for source in {row.integration_json["source_database"] for row in sources}:
|
||||
if source not in snapshots:
|
||||
snapshots[source] = source_snapshot_ready(session, source)
|
||||
result = {}
|
||||
for row in sources:
|
||||
reasons = set()
|
||||
customer = customers[row.customer_id]
|
||||
record = inputs.get(row.id)
|
||||
if not snapshots[row.integration_json["source_database"]]:
|
||||
reasons.add("SOURCE_SNAPSHOT_PENDING")
|
||||
if row.id in relation_pending:
|
||||
reasons.add("SALES_RELATION_PENDING")
|
||||
if customer.customer_type is None:
|
||||
reasons.add("CUSTOMER_CATEGORY_PENDING")
|
||||
if row.id in missing_products:
|
||||
reasons.add("PRODUCT_CATEGORY_PENDING")
|
||||
if row.id not in shipped:
|
||||
reasons.add("NO_ACTUAL_SHIPMENT")
|
||||
if record is None:
|
||||
reasons.update(("OPENING_BALANCE_PENDING", "GUIDANCE_PENDING", "PERSONNEL_PENDING"))
|
||||
if row.id in shipped:
|
||||
reasons.add("FREIGHT_PENDING")
|
||||
else:
|
||||
maps = record.data_json.get("maps", {})
|
||||
if (set(lines[row.id]) != set(maps.get("shipment_items", {}).values()) or
|
||||
receipts[row.id] != set(maps.get("receipts", {}).values())):
|
||||
reasons.add("INPUT_REFRESH_REQUIRED")
|
||||
for item in record.data_json.get("shipments", []):
|
||||
item_id = maps.get("shipment_items", {}).get(str(item.get("line_id")))
|
||||
if (item_id not in lines[row.id] or item.get("category") != lines[row.id][item_id]
|
||||
or item.get("customer_type") != customer.customer_type):
|
||||
reasons.add("INPUT_REFRESH_REQUIRED")
|
||||
if row.id in conflicts:
|
||||
reasons.add("SOURCE_CONFLICT")
|
||||
result[row.id] = {"status": "PENDING" if reasons else "READY",
|
||||
"reasons": [{"code": code, "message": LABELS[code]} for code in sorted(reasons)]}
|
||||
return result
|
||||
|
||||
|
||||
def sales_relation_candidates(session, business_id, query, shipment_page=1, order_page=1):
|
||||
"""只读列出候选,金额与产品相同不代表已确认关联。"""
|
||||
from app.services.tplus.sales_relations import (
|
||||
_fact_is_current,
|
||||
_latest_business_facts,
|
||||
item_revision,
|
||||
order_revision,
|
||||
shipment_revision,
|
||||
)
|
||||
|
||||
row = business(session, business_id)
|
||||
if not row.integration_json:
|
||||
raise BadRequestError("仅来源业务需要核对订单与发货关联")
|
||||
if query.page_size > 20:
|
||||
raise BadRequestError("每页最多查询 20 条待核对明细")
|
||||
source = row.integration_json["source_database"]
|
||||
order_match = select(TPlusBusinessDocument.id).where(
|
||||
TPlusBusinessDocument.business_id == business_id,
|
||||
TPlusBusinessDocument.entity_type == "business_items",
|
||||
TPlusBusinessDocument.status == "APPLIED",
|
||||
TPlusBusinessDocument.target_id == BusinessItem.id,
|
||||
).exists()
|
||||
conditions = (BusinessItem.business_id == business_id, ~order_match)
|
||||
total = session.scalar(select(func.count()).select_from(BusinessItem).where(*conditions)) or 0
|
||||
items = list(session.scalars(select(BusinessItem).where(*conditions).order_by(BusinessItem.id)
|
||||
.offset(query.offset).limit(query.page_size)))
|
||||
facts = {fact.fact_key: fact for fact in _latest_business_facts(session, source, business_id)}
|
||||
|
||||
def confirmed_source(document_id, entity, parent_entity):
|
||||
doc = session.get(TPlusBusinessDocument, document_id) if document_id else None
|
||||
if not doc or doc.business_id != business_id or doc.source_database != source or doc.entity_type != entity:
|
||||
return None
|
||||
parent = session.scalar(select(TPlusBusinessDocument).where(
|
||||
TPlusBusinessDocument.business_id == business_id,
|
||||
TPlusBusinessDocument.source_database == source,
|
||||
TPlusBusinessDocument.entity_type == parent_entity,
|
||||
TPlusBusinessDocument.source_id == str(doc.data_json.get("source_parent_id"))).limit(1))
|
||||
return {"source_id": doc.source_id, "source_no": parent.source_no if parent else None}
|
||||
|
||||
results = []
|
||||
for item in items:
|
||||
head_doc = aliased(TPlusBusinessDocument)
|
||||
candidates = list(session.execute(select(TPlusBusinessDocument, head_doc, Shipment, ShipmentItem)
|
||||
.join(ShipmentItem, ShipmentItem.id == TPlusBusinessDocument.target_id)
|
||||
.join(Shipment, Shipment.id == ShipmentItem.shipment_id)
|
||||
.join(head_doc, (head_doc.business_id == business_id) & (head_doc.entity_type == "shipments")
|
||||
& (head_doc.source_database == source)
|
||||
& (head_doc.target_id == Shipment.id)
|
||||
& (head_doc.source_id == TPlusBusinessDocument.data_json["source_parent_id"].as_string()))
|
||||
.where(TPlusBusinessDocument.business_id == business_id,
|
||||
TPlusBusinessDocument.source_database == source,
|
||||
TPlusBusinessDocument.entity_type == "shipment_items",
|
||||
TPlusBusinessDocument.status == "APPLIED",
|
||||
TPlusBusinessDocument.record_id == TPlusBusinessDocument.applied_record_id,
|
||||
head_doc.status == "APPLIED", head_doc.record_id == head_doc.applied_record_id,
|
||||
Shipment.business_id == business_id, ShipmentItem.product_id == item.product_id,
|
||||
ShipmentItem.quantity == item.quantity, ShipmentItem.amount == item.sale_amount)
|
||||
.order_by(TPlusBusinessDocument.id).offset((shipment_page - 1) * 20).limit(21)))
|
||||
order_head = aliased(TPlusBusinessDocument)
|
||||
orders = list(session.execute(select(TPlusBusinessDocument, order_head, BusinessItem)
|
||||
.join(BusinessItem, BusinessItem.id == TPlusBusinessDocument.target_id)
|
||||
.join(order_head, (order_head.business_id == business_id) & (order_head.entity_type == "businesses")
|
||||
& (order_head.source_database == source)
|
||||
& (order_head.source_id == TPlusBusinessDocument.data_json["source_parent_id"].as_string()))
|
||||
.where(TPlusBusinessDocument.business_id == business_id,
|
||||
TPlusBusinessDocument.source_database == source,
|
||||
TPlusBusinessDocument.entity_type == "business_items",
|
||||
TPlusBusinessDocument.status == "APPLIED", BusinessItem.product_id == item.product_id,
|
||||
TPlusBusinessDocument.record_id == TPlusBusinessDocument.applied_record_id,
|
||||
order_head.status == "APPLIED", order_head.record_id == order_head.applied_record_id)
|
||||
.order_by(TPlusBusinessDocument.id).offset((order_page - 1) * 20).limit(21)))
|
||||
fact = facts.get(f"sales:{business_id}:{item.id}")
|
||||
results.append({"item_id": item.id, "product_name": item.product_name, "spec": item.spec_model,
|
||||
"quantity": str(item.quantity), "amount": str(item.sale_amount), "item_revision": item_revision(item),
|
||||
"version": fact.version if fact else 0,
|
||||
"confirmation": ({"disposition": fact.data_json.get("disposition"),
|
||||
"shipment_document_id": fact.data_json.get("shipment_document_id"),
|
||||
"order_document_id": fact.data_json.get("order_document_id"),
|
||||
"shipment": confirmed_source(fact.data_json.get("shipment_document_id"), "shipment_items", "shipments"),
|
||||
"order": confirmed_source(fact.data_json.get("order_document_id"), "business_items", "businesses"),
|
||||
"evidence": fact.evidence, "current": _fact_is_current(session, row, item, fact)}
|
||||
if fact else None),
|
||||
"shipments": [{"document_id": doc.id, "source_id": doc.source_id,
|
||||
"source_no": shipment.source_no, "date": shipment.ship_date.isoformat(),
|
||||
"shipment_item_id": line.id, "revision": shipment_revision(doc, head, shipment, line)}
|
||||
for doc, head, shipment, line in candidates[:20]],
|
||||
"shipments_truncated": len(candidates) > 20,
|
||||
"orders": [{"document_id": doc.id, "source_id": doc.source_id,
|
||||
"item_id": order.id, "quantity": str(order.quantity), "amount": str(order.sale_amount),
|
||||
"revision": order_revision(doc, head, order)}
|
||||
for doc, head, order in orders[:20]], "orders_truncated": len(orders) > 20})
|
||||
return PageResult(records=results, total=total, page_num=query.page_num, page_size=query.page_size)
|
||||
|
||||
|
||||
def readiness(session, business_id):
|
||||
row = business(session, business_id)
|
||||
if not row.integration_json:
|
||||
return {"source_type": "MANUAL", "status": "MANUAL", "reasons": [], "products": [], "customer_type": session.get(Customer, row.customer_id).customer_type}
|
||||
customer = session.get(Customer, row.customer_id)
|
||||
product_ids = select(BusinessItem.product_id).where(BusinessItem.business_id == business_id, BusinessItem.product_id.is_not(None))
|
||||
products = list(session.scalars(select(Product).where(Product.id.in_(product_ids)).order_by(Product.id)))
|
||||
record = _latest(session, business_id)
|
||||
summary = readiness_summaries(session, [row])[business_id]
|
||||
reasons = {reason["code"] for reason in summary["reasons"]}
|
||||
eligible_receipts = None
|
||||
if (record is not None and record.data_json.get("input_schema") == "TPLUS_SYNC_V1"
|
||||
and not reasons.intersection({"SOURCE_CONFLICT", "SOURCE_SNAPSHOT_PENDING", "INPUT_REFRESH_REQUIRED"})):
|
||||
entries = record.data_json.get("receipts", [])
|
||||
if all(r.get("exclusion") or r.get("voucher_confirmed") for r in entries):
|
||||
eligible_receipts = str(sum(
|
||||
(Decimal(str(r["amount"])) for r in entries if not r.get("exclusion")), Decimal("0.00")
|
||||
))
|
||||
return {"source_type": "TPLUS", **summary,
|
||||
"source_database": row.integration_json["source_database"], "customer_type": customer.customer_type,
|
||||
"version": record.version if record else 0,
|
||||
"source_personnel": row.integration_json.get("personnel_names", []),
|
||||
"eligible_receipts_snapshot": eligible_receipts,
|
||||
"products": [{"id": p.id, "name": p.product_name, "spec": p.spec_model, "category": p.product_category} for p in products[:200]],
|
||||
"products_total": len(products)}
|
||||
|
||||
|
||||
def source_snapshot_ready(session, source):
|
||||
"""仅认可覆盖当前完整快照且已处理结束的业务接入任务。"""
|
||||
from app.services.tplus.business_sync import START
|
||||
from app.services.tplus.snapshots import source_manifest
|
||||
from app.services.tplus.workbench import WorkbenchError
|
||||
|
||||
active = session.scalar(select(TPlusWorkRun.id).where(
|
||||
TPlusWorkRun.source_database == source, TPlusWorkRun.kind == "BUSINESS_SYNC",
|
||||
TPlusWorkRun.status.in_(("QUEUED", "RUNNING"))).limit(1))
|
||||
if active is not None:
|
||||
return False
|
||||
try:
|
||||
manifest = source_manifest(session, source)
|
||||
except WorkbenchError:
|
||||
return False
|
||||
run = session.scalar(select(TPlusWorkRun).where(
|
||||
TPlusWorkRun.source_database == source, TPlusWorkRun.kind == "BUSINESS_SYNC"
|
||||
).order_by(TPlusWorkRun.id.desc()).limit(1))
|
||||
if not run or run.status not in ("SUCCEEDED", "PARTIAL") or (run.summary_json or {}).get("source_manifest") != manifest:
|
||||
return False
|
||||
config = session.scalar(select(TPlusBusinessSyncConfig).where(TPlusBusinessSyncConfig.source_database == source))
|
||||
try:
|
||||
run_start = date.fromisoformat((run.input_json or {}).get("start_date", START.isoformat()))
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
return run_start <= (config.start_date if config else START)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def source_calculation_lock(session):
|
||||
"""在新事务中持锁检查快照并提交,避免沿用鉴权查询建立的旧读视图。"""
|
||||
if session.get_bind().dialect.name != "mysql":
|
||||
yield
|
||||
return
|
||||
if session.new or session.deleted or any(session.is_modified(obj) for obj in session.dirty):
|
||||
raise BadRequestError("核算前存在未提交的修改,请先完成当前操作")
|
||||
session.rollback()
|
||||
from app.core.tplus_lock import TPlusBusyError, sync_lock
|
||||
|
||||
try:
|
||||
with sync_lock(session):
|
||||
yield
|
||||
except TPlusBusyError:
|
||||
raise BadRequestError("T+ 数据正在采集或接入,请完成后再核算") from None
|
||||
|
||||
|
||||
def _source_values(record):
|
||||
raw = record.payload_json
|
||||
|
||||
def first(*names):
|
||||
return next((raw[name] for name in names if raw.get(name) is not None), None)
|
||||
|
||||
return {"source_no": record.source_no, "date": first("voucherdate", "voucherDate"),
|
||||
"customer_id": first("idcustomer", "idpartner"), "personnel_id": first("idclerk", "idperson"),
|
||||
"product_id": first("idinventory"), "order_id": first("saleOrderId"),
|
||||
"order_detail_id": first("saleOrderDetailId"),
|
||||
"quantity": first("quantity"), "price": first("taxPrice", "price"),
|
||||
"price_type": ("含税" if raw.get("taxPrice") is not None else "未税" if raw.get("price") is not None else None),
|
||||
"amount": first("taxAmount", "amount"), "state": first("voucherState", "voucherstate")}
|
||||
|
||||
|
||||
def _source_changes(previous, current):
|
||||
labels = {"source_no": "来源单号", "date": "日期", "customer_id": "客户标识", "personnel_id": "经办人标识",
|
||||
"product_id": "产品标识", "order_id": "订单标识", "order_detail_id": "订单明细标识",
|
||||
"quantity": "数量", "price": "单价", "price_type": "单价税别", "amount": "金额", "state": "单据状态"}
|
||||
old, new = _source_values(previous), _source_values(current)
|
||||
def text(value):
|
||||
return str(value) if value is not None else None
|
||||
return [{"field": key, "label": label, "before": text(old[key]), "after": text(new[key])}
|
||||
for key, label in labels.items() if text(old[key]) != text(new[key])]
|
||||
|
||||
|
||||
def source_quantities(session, business_id, query):
|
||||
"""按产品和单位分别统计已接入的来源订单及发货数量。"""
|
||||
business(session, business_id)
|
||||
zero = literal(0, type_=Numeric(14, 2))
|
||||
orders = select(
|
||||
BusinessItem.product_id.label("product_id"), BusinessItem.product_name.label("product_name"),
|
||||
BusinessItem.spec_model.label("spec_model"), BusinessItem.unit.label("unit"),
|
||||
BusinessItem.quantity.label("order_quantity"), zero.label("shipment_quantity"),
|
||||
).join(TPlusBusinessDocument, and_(TPlusBusinessDocument.target_id == BusinessItem.id,
|
||||
TPlusBusinessDocument.entity_type == "business_items")).where(
|
||||
TPlusBusinessDocument.business_id == business_id,
|
||||
TPlusBusinessDocument.applied_record_id.is_not(None), BusinessItem.business_id == business_id)
|
||||
shipments = select(
|
||||
ShipmentItem.product_id.label("product_id"), ShipmentItem.product_name.label("product_name"),
|
||||
ShipmentItem.spec_model.label("spec_model"), ShipmentItem.unit.label("unit"),
|
||||
zero.label("order_quantity"), ShipmentItem.quantity.label("shipment_quantity"),
|
||||
).join(TPlusBusinessDocument, and_(TPlusBusinessDocument.target_id == ShipmentItem.id,
|
||||
TPlusBusinessDocument.entity_type == "shipment_items")).join(
|
||||
Shipment, Shipment.id == ShipmentItem.shipment_id).where(
|
||||
TPlusBusinessDocument.business_id == business_id,
|
||||
TPlusBusinessDocument.applied_record_id.is_not(None), Shipment.business_id == business_id)
|
||||
combined = orders.union_all(shipments).subquery()
|
||||
grouped = select(combined.c.product_id, combined.c.product_name, combined.c.spec_model, combined.c.unit,
|
||||
func.sum(combined.c.order_quantity).label("order_quantity"),
|
||||
func.sum(combined.c.shipment_quantity).label("shipment_quantity")).group_by(
|
||||
combined.c.product_id, combined.c.product_name, combined.c.spec_model, combined.c.unit).subquery()
|
||||
total = session.scalar(select(func.count()).select_from(grouped)) or 0
|
||||
rows = session.execute(select(grouped).order_by(grouped.c.product_name, grouped.c.spec_model,
|
||||
grouped.c.unit, grouped.c.product_id).offset(query.offset).limit(query.page_size)).mappings()
|
||||
result = [{**row, "order_quantity": str(row["order_quantity"]),
|
||||
"shipment_quantity": str(row["shipment_quantity"])} for row in rows]
|
||||
return PageResult(records=result, total=total, page_num=query.page_num, page_size=query.page_size)
|
||||
|
||||
|
||||
def _price_repair_revision(doc, parent, target, record):
|
||||
return digest(["source-price-v1", doc.id, doc.applied_record_id, doc.record_id, doc.target_id,
|
||||
parent.id, parent.record_id, parent.applied_record_id, parent.fingerprint,
|
||||
parent.status, parent.reason, target.id, str(target.sale_price), record.id, record.row_hash])
|
||||
|
||||
|
||||
def documents(session, business_id, query, entity=None):
|
||||
from app.services.tplus.snapshots import source_records
|
||||
from app.services.tplus.workbench import WorkbenchError
|
||||
|
||||
business(session, business_id)
|
||||
conditions = [TPlusBusinessDocument.business_id == business_id]
|
||||
if entity:
|
||||
conditions.append(TPlusBusinessDocument.entity_type == entity)
|
||||
total = session.scalar(select(func.count()).select_from(TPlusBusinessDocument).where(*conditions)) or 0
|
||||
rows = list(session.execute(select(TPlusBusinessDocument, TPlusSyncRecord).join(TPlusSyncRecord, TPlusSyncRecord.id == TPlusBusinessDocument.record_id)
|
||||
.where(*conditions).order_by(TPlusBusinessDocument.id).offset(query.offset).limit(query.page_size)))
|
||||
previous_ids = {doc.applied_record_id for doc, _ in rows if doc.applied_record_id and doc.applied_record_id != doc.record_id}
|
||||
previous_records = {record.id: record for record in session.scalars(select(TPlusSyncRecord).where(TPlusSyncRecord.id.in_(previous_ids)))} if previous_ids else {}
|
||||
details = {}
|
||||
for source_database, entity_type in {(doc.source_database, doc.entity_type) for doc, _ in rows
|
||||
if doc.entity_type in ("business_items", "shipment_items")}:
|
||||
ids = [doc.source_id for doc, _ in rows if doc.source_database == source_database and doc.entity_type == entity_type]
|
||||
try:
|
||||
details[(source_database, entity_type)] = {
|
||||
record.source_id: record for record in source_records(session, source_database, entity_type, ids=ids)
|
||||
}
|
||||
except WorkbenchError:
|
||||
details[(source_database, entity_type)] = None
|
||||
detail_docs = [doc for doc, _ in rows if doc.entity_type in ("business_items", "shipment_items")]
|
||||
parent_ids = {str(doc.data_json["source_parent_id"]) for doc in detail_docs if doc.data_json.get("source_parent_id") is not None}
|
||||
parents = {(doc.source_database, doc.entity_type, doc.source_id): doc for doc in session.scalars(
|
||||
select(TPlusBusinessDocument).where(TPlusBusinessDocument.business_id == business_id,
|
||||
TPlusBusinessDocument.entity_type.in_(("businesses", "shipments")),
|
||||
TPlusBusinessDocument.source_id.in_(parent_ids)))} if parent_ids else {}
|
||||
order_ids = {doc.target_id for doc in detail_docs if doc.entity_type == "business_items" and doc.target_id}
|
||||
shipment_ids = {doc.target_id for doc in detail_docs if doc.entity_type == "shipment_items" and doc.target_id}
|
||||
targets = {("business_items", item.id): item for item in session.scalars(select(BusinessItem).where(BusinessItem.id.in_(order_ids)))} if order_ids else {}
|
||||
if shipment_ids:
|
||||
targets.update({("shipment_items", item.id): item for item in session.scalars(select(ShipmentItem).where(ShipmentItem.id.in_(shipment_ids)))})
|
||||
has_input = bool(detail_docs and session.scalar(select(PayrollInput.id).where(PayrollInput.business_id == business_id).limit(1)))
|
||||
result = []
|
||||
for doc, source in rows:
|
||||
current = source
|
||||
snapshot_state = None
|
||||
if (doc.source_database, doc.entity_type) in details:
|
||||
records = details[(doc.source_database, doc.entity_type)]
|
||||
current = records.get(doc.source_id) if records is not None else None
|
||||
snapshot_state = ("UNAVAILABLE" if records is None else "MISSING" if current is None
|
||||
else "CHANGED" if current.id != doc.applied_record_id else "CURRENT")
|
||||
raw = (current or source).payload_json
|
||||
previous = previous_records.get(doc.applied_record_id, source if doc.applied_record_id == source.id else None)
|
||||
person_id = doc.data_json.get("source_personnel_id")
|
||||
if person_id is None:
|
||||
person_id = raw.get("idclerk") if raw.get("idclerk") is not None else raw.get("idperson")
|
||||
changes = _source_changes(previous, current) if previous and current and previous.id != current.id else []
|
||||
if doc.reason == "PENDING_SOURCE_CUSTOMER_CHANGED" and current:
|
||||
customer_id = _source_values(current)["customer_id"]
|
||||
changes = [{"field": "customer_id", "label": "客户标识",
|
||||
"before": str(doc.data_json["source_customer_id"]),
|
||||
"after": str(customer_id) if customer_id is not None else None}]
|
||||
elif doc.reason == "PENDING_SOURCE_OUT_OF_RANGE" and current:
|
||||
source_date = _source_values(current)["date"]
|
||||
changes = [{"field": "date", "label": "日期", "before": doc.data_json.get("source_date"),
|
||||
"after": str(source_date)[:10] if source_date is not None else None}]
|
||||
source_price = raw.get("taxPrice") if raw.get("taxPrice") is not None else raw.get("price")
|
||||
repair_revision = None
|
||||
parent_type = {"business_items": "businesses", "shipment_items": "shipments"}.get(doc.entity_type)
|
||||
parent = parents.get((doc.source_database, parent_type, str(doc.data_json.get("source_parent_id"))))
|
||||
target = targets.get((doc.entity_type, doc.target_id))
|
||||
if (not has_input and parent and parent.reason == "SOURCE_PRICE_REVIEW_REQUIRED" and target
|
||||
and target.sale_price is None and current and current.id == doc.applied_record_id
|
||||
and raw.get("taxPrice") is None and raw.get("price") is not None):
|
||||
from app.services.tplus.business_sync import decimal_value
|
||||
try:
|
||||
decimal_value(raw["price"], 4)
|
||||
except WorkbenchError:
|
||||
valid_price = False
|
||||
else:
|
||||
valid_price = True
|
||||
if valid_price:
|
||||
repair_revision = _price_repair_revision(doc, parent, target, current)
|
||||
result.append({"id": doc.id, "entity": doc.entity_type, "source_id": doc.source_id, "source_no": doc.source_no,
|
||||
"current_source_no": current.source_no if current else None,
|
||||
"snapshot_state": snapshot_state,
|
||||
"changes": changes,
|
||||
"target_id": doc.target_id, "status": doc.status, "reason": doc.reason,
|
||||
"date": doc.data_json.get("source_date"), "personnel_name": doc.data_json.get("source_personnel_name"),
|
||||
"personnel_source_id": str(person_id) if person_id is not None else None,
|
||||
"quantity": str(raw["quantity"]) if raw.get("quantity") is not None else None,
|
||||
"amount": str(raw.get("taxAmount", raw.get("amount"))) if raw.get("taxAmount", raw.get("amount")) is not None else None,
|
||||
"price": str(source_price) if source_price is not None else None,
|
||||
"price_type": ("TAX_INCLUDED" if raw.get("taxPrice") is not None
|
||||
else "TAX_EXCLUDED" if raw.get("price") is not None else None),
|
||||
"price_repair_revision": repair_revision})
|
||||
return PageResult(records=result, total=total, page_num=query.page_num, page_size=query.page_size)
|
||||
|
||||
|
||||
def document_details(session, business_id, document_id, query):
|
||||
"""对照已接入明细与当前完整快照,新增明细也按来源父单分页展示。"""
|
||||
from app.services.tplus.snapshots import source_statement
|
||||
from app.services.tplus.workbench import WorkbenchError
|
||||
|
||||
business(session, business_id)
|
||||
head = session.get(TPlusBusinessDocument, document_id)
|
||||
if head is None or head.business_id != business_id or head.entity_type not in ("businesses", "shipments"):
|
||||
raise NotFoundError("来源订单或发货单不存在")
|
||||
detail_type = "business_items" if head.entity_type == "businesses" else "shipment_items"
|
||||
old = select(TPlusBusinessDocument.source_id).where(
|
||||
TPlusBusinessDocument.source_database == head.source_database,
|
||||
TPlusBusinessDocument.business_id == business_id,
|
||||
TPlusBusinessDocument.entity_type == detail_type,
|
||||
TPlusBusinessDocument.applied_record_id.is_not(None),
|
||||
TPlusBusinessDocument.data_json["source_parent_id"].as_string() == head.source_id,
|
||||
)
|
||||
try:
|
||||
current = source_statement(session, head.source_database, detail_type).where(or_(
|
||||
TPlusSourceHead.source_parent_id == head.source_id,
|
||||
and_(TPlusSourceHead.source_parent_id.is_(None),
|
||||
cast(TPlusSyncRecord.payload_json["source_parent_id"].as_string(), String) == head.source_id),
|
||||
))
|
||||
except WorkbenchError:
|
||||
raise BadRequestError("来源明细的完整快照不可用,请等待采集完成") from None
|
||||
ids = union(old, current.with_only_columns(TPlusSyncRecord.source_id)).subquery()
|
||||
total = session.scalar(select(func.count()).select_from(ids)) or 0
|
||||
page_ids = list(session.scalars(select(ids.c.source_id).order_by(ids.c.source_id)
|
||||
.offset(query.offset).limit(query.page_size)))
|
||||
previous = dict(session.execute(select(TPlusBusinessDocument.source_id, TPlusSyncRecord)
|
||||
.join(TPlusSyncRecord, TPlusSyncRecord.id == TPlusBusinessDocument.applied_record_id)
|
||||
.where(TPlusBusinessDocument.source_database == head.source_database,
|
||||
TPlusBusinessDocument.business_id == business_id,
|
||||
TPlusBusinessDocument.entity_type == detail_type,
|
||||
TPlusBusinessDocument.data_json["source_parent_id"].as_string() == head.source_id,
|
||||
TPlusBusinessDocument.source_id.in_(page_ids))).all()) if page_ids else {}
|
||||
latest = {record.source_id: record for record in session.scalars(
|
||||
current.where(TPlusSyncRecord.source_id.in_(page_ids)))} if page_ids else {}
|
||||
def values(record):
|
||||
return {key: str(value) if value is not None else None
|
||||
for key, value in _source_values(record).items()} if record else None
|
||||
|
||||
rows = []
|
||||
for source_id in page_ids:
|
||||
before, after = previous.get(source_id), latest.get(source_id)
|
||||
changes = _source_changes(before, after) if before is not None and after is not None else []
|
||||
state = ("ADDED" if before is None else "REMOVED" if after is None
|
||||
else "CHANGED" if before.id != after.id else "UNCHANGED")
|
||||
rows.append({"source_id": source_id, "state": state, "changes": changes,
|
||||
"before": values(before), "after": values(after)})
|
||||
return PageResult(records=rows, total=total, page_num=query.page_num, page_size=query.page_size)
|
||||
|
||||
|
||||
def confirm_missing_source_price(session, business_id, document_id, payload, operator, meta=None):
|
||||
"""仅经人工确认补全未封存的空单价;来源变化或非空人工值均不得覆盖。"""
|
||||
from app.services.audit_service import AuditRecord, audit
|
||||
from app.services.tplus.business_sync import (
|
||||
START,
|
||||
Context,
|
||||
_apply_document,
|
||||
decimal_value,
|
||||
details,
|
||||
statement,
|
||||
)
|
||||
from app.services.tplus.workbench import WorkbenchError
|
||||
|
||||
with source_calculation_lock(session):
|
||||
audit_record = AuditRecord(module="业务管理", action="确认来源缺失单价", summary=f"业务 #{business_id} 来源明细 #{document_id}",
|
||||
request_data=payload.model_dump(mode="json"))
|
||||
with audit(session, operator=operator, meta=meta, record=audit_record):
|
||||
row = session.get(Business, business_id, with_for_update=True)
|
||||
if row is None or not row.integration_json:
|
||||
raise BadRequestError("仅可核对来源业务")
|
||||
source = row.integration_json["source_database"]
|
||||
if not source_snapshot_ready(session, source):
|
||||
raise BadRequestError("来源快照或业务接入未完成,请稍后重试")
|
||||
if session.scalar(select(PayrollInput.id).where(PayrollInput.business_id == business_id).limit(1)):
|
||||
raise BadRequestError("业务已有封存核算资料,请通过差异核对处理")
|
||||
doc = session.get(TPlusBusinessDocument, document_id, with_for_update=True)
|
||||
if (doc is None or doc.business_id != business_id or doc.source_database != source
|
||||
or doc.entity_type not in ("business_items", "shipment_items") or doc.applied_record_id is None):
|
||||
raise BadRequestError("来源明细不存在或尚未接入")
|
||||
parent_type = "businesses" if doc.entity_type == "business_items" else "shipments"
|
||||
parent_id = doc.data_json.get("source_parent_id")
|
||||
parent = session.scalar(select(TPlusBusinessDocument).where(
|
||||
TPlusBusinessDocument.business_id == business_id, TPlusBusinessDocument.source_database == source,
|
||||
TPlusBusinessDocument.entity_type == parent_type, TPlusBusinessDocument.source_id == str(parent_id),
|
||||
).with_for_update())
|
||||
if parent is None or parent.reason != "SOURCE_PRICE_REVIEW_REQUIRED" or parent.status != "CONFLICT":
|
||||
raise BadRequestError("来源单价状态已变化,请刷新后核对")
|
||||
model = BusinessItem if doc.entity_type == "business_items" else ShipmentItem
|
||||
target = session.get(model, doc.target_id, with_for_update=True) if doc.target_id else None
|
||||
if target is None or target.sale_price is not None:
|
||||
raise BadRequestError("正式单价已存在或明细不存在,不能覆盖")
|
||||
if doc.entity_type == "business_items":
|
||||
belongs = target.business_id == business_id
|
||||
else:
|
||||
shipment = session.get(Shipment, target.shipment_id, with_for_update=True)
|
||||
belongs = bool(shipment and shipment.business_id == business_id)
|
||||
if not belongs:
|
||||
raise BadRequestError("正式明细不属于该业务")
|
||||
try:
|
||||
current = session.scalar(statement(session, source, doc.entity_type).where(TPlusSyncRecord.source_id == doc.source_id))
|
||||
head = session.scalar(statement(session, source, parent_type).where(TPlusSyncRecord.source_id == parent.source_id))
|
||||
if current is None or head is None or current.id != doc.applied_record_id or head.id != parent.record_id:
|
||||
raise BadRequestError("来源明细已变化,请刷新后核对")
|
||||
if _price_repair_revision(doc, parent, target, current) != payload.revision:
|
||||
raise BadRequestError("来源单价或正式明细已变化,请刷新后核对")
|
||||
raw = current.payload_json
|
||||
if raw.get("taxPrice") is not None or raw.get("price") is None:
|
||||
raise BadRequestError("来源单价状态已变化,请刷新后核对")
|
||||
target.sale_price = decimal_value(raw["price"], 4)
|
||||
session.flush()
|
||||
ctx = Context(session, source)
|
||||
action, checked = _apply_document(ctx, parent_type, head, details(ctx, parent_type, parent.source_id), "MANUAL", START)
|
||||
if action not in ("UNCHANGED", "CONFLICT") or (action == "CONFLICT" and checked.reason != "SOURCE_PRICE_REVIEW_REQUIRED"):
|
||||
raise BadRequestError("来源单据已变化,请刷新后核对")
|
||||
except WorkbenchError:
|
||||
raise BadRequestError("来源单价无效或超出精度,请核对原单") from None
|
||||
result = {"document_id": doc.id, "parent_status": checked.status}
|
||||
session.commit()
|
||||
return result
|
||||
|
||||
|
||||
def save_materials(session, business_id, payload, actor, meta=None):
|
||||
"""同一业务引用的档案才可批量维护;不接受名称、金额或来源字段。"""
|
||||
from app.services.audit_service import AuditRecord, audit
|
||||
from app.services.authorization_service import require_permission
|
||||
from app.services.category_batch import fill_missing_product_items
|
||||
|
||||
if payload.customer_type:
|
||||
require_permission(session, actor, "base:customers:update")
|
||||
if payload.products:
|
||||
require_permission(session, actor, "base:products:update")
|
||||
with audit(session, operator=actor, meta=meta, record=AuditRecord(module="业务管理", action="补充来源分类", summary=f"业务 #{business_id} 核算分类", request_data=payload.model_dump(mode="json"))):
|
||||
row = session.get(Business, business_id, with_for_update=True)
|
||||
if row is None or not row.integration_json:
|
||||
raise BadRequestError("仅可维护自动接入业务的分类资料")
|
||||
record = _latest(session, business_id)
|
||||
if record is not None:
|
||||
raise BadRequestError("已有封存核算资料,请通过差异核对处理分类变更")
|
||||
if payload.customer_type:
|
||||
customer = session.get(Customer, row.customer_id, with_for_update=True)
|
||||
if customer.customer_type and customer.customer_type != payload.customer_type:
|
||||
raise BadRequestError("客户已有核算类型,请刷新后核对")
|
||||
customer.customer_type = payload.customer_type
|
||||
ids = {item.product_id for item in payload.products}
|
||||
actual = set(session.scalars(select(BusinessItem.product_id).where(BusinessItem.business_id == business_id, BusinessItem.product_id.in_(ids))))
|
||||
if ids != actual:
|
||||
raise BadRequestError("产品不属于本业务")
|
||||
for value in payload.products:
|
||||
product = session.get(Product, value.product_id, with_for_update=True)
|
||||
if product.product_category and product.product_category != value.category:
|
||||
raise BadRequestError("已有核算分类,请在产品档案核对后调整")
|
||||
product.product_category = value.category
|
||||
fill_missing_product_items(session, [product.id], value.category)
|
||||
session.commit()
|
||||
return readiness(session, business_id)
|
||||
|
||||
|
||||
def prepare(session, business_id, payload, operator, meta=None):
|
||||
with source_calculation_lock(session):
|
||||
return _prepare_locked(session, business_id, payload, operator, meta)
|
||||
|
||||
|
||||
def _prepare_locked(session, business_id, payload, operator, meta=None):
|
||||
"""将正式源单据与人工成本、归属合成一个不可变核算输入版本。"""
|
||||
from app.services.audit_service import AuditRecord, audit
|
||||
with audit(session, operator=operator, meta=meta, record=AuditRecord(module="绩效核算", action="来源业务核算准备", summary=f"业务 #{business_id} 核算资料", request_data=payload.model_dump(mode="json"))):
|
||||
payroll_months.ensure_allocation_editable(session, payload.month, operator)
|
||||
row = session.get(Business, business_id, with_for_update=True)
|
||||
if row is None or not row.integration_json:
|
||||
raise BadRequestError("该业务不是自动接入的来源业务")
|
||||
current = _latest(session, business_id)
|
||||
if (current.version if current else 0) != payload.expected_version:
|
||||
raise BadRequestError("核算资料版本已变化,请刷新")
|
||||
state = readiness(session, business_id)
|
||||
blocked = {r["code"] for r in state["reasons"]} & {"SOURCE_CONFLICT", "SOURCE_SNAPSHOT_PENDING", "CUSTOMER_CATEGORY_PENDING", "PRODUCT_CATEGORY_PENDING", "NO_ACTUAL_SHIPMENT", "SALES_RELATION_PENDING"}
|
||||
if blocked:
|
||||
raise BadRequestError("核算资料待补:" + ";".join(LABELS[c] for c in sorted(blocked)))
|
||||
source = row.integration_json["source_database"]
|
||||
facts = latest_facts(session, source)
|
||||
source_docs = list(session.scalars(select(TPlusBusinessDocument).where(TPlusBusinessDocument.business_id == business_id, TPlusBusinessDocument.status == "APPLIED")))
|
||||
refs = {doc.record_id: doc for doc in source_docs}
|
||||
raw = {r.id: r for r in session.scalars(select(TPlusSyncRecord).where(TPlusSyncRecord.id.in_(refs)))}
|
||||
by_target = {(d.entity_type, d.target_id): d for d in source_docs}
|
||||
targets = list(session.scalars(select(ShipmentItem).join(Shipment).where(Shipment.business_id == business_id)))
|
||||
assignments = {o.shipment_item_id: o for o in payload.shipment_ownerships}
|
||||
if set(assignments) != {i.id for i in targets} or len(assignments) != len(payload.shipment_ownerships):
|
||||
raise BadRequestError("请逐条核实全部发货明细,不可遗漏或重复")
|
||||
people, people_facts, ships = {}, {}, []
|
||||
key = f"customer:{row.integration_json['customer_source_id']}"
|
||||
if current and "handovers" not in payload.model_fields_set:
|
||||
handovers = current.data_json.get("facts", {}).get("handovers", [])
|
||||
else:
|
||||
handovers = [handover.model_dump(mode="json") for handover in payload.handovers]
|
||||
if any(handover["business_key"] != key for handover in handovers):
|
||||
raise BadRequestError("交接记录不属于本业务")
|
||||
if any(handover["mode"] == "ACTIVE" and not handover.get("shipping_ownership_confirmed") for handover in handovers):
|
||||
raise BadRequestError("在职交接须先逐次确认发货归属")
|
||||
if any(handover["from_personnel_id"] == handover["to_personnel_id"] for handover in handovers):
|
||||
raise BadRequestError("移交人与接手人不能相同")
|
||||
|
||||
def include_person(pid):
|
||||
if pid in people:
|
||||
return
|
||||
try:
|
||||
person = session.get(Personnel, pid)
|
||||
except OverflowError:
|
||||
raise BadRequestError("交接人员必须是有效的正式人员") from None
|
||||
role = session.get(BusinessRole, person.business_role_id) if person else None
|
||||
if not person or not role:
|
||||
raise BadRequestError("人员或岗位未确认")
|
||||
fact = facts.get(("PERSONNEL", person.source_no))
|
||||
people[pid] = person
|
||||
people_facts[str(pid)] = {**(fact.data_json if fact else {}), "role_code": role.role_code, "status": person.status}
|
||||
|
||||
for handover in handovers:
|
||||
for field in ("from_personnel_id", "to_personnel_id"):
|
||||
value = handover[field]
|
||||
if (not value.isascii() or not value.isdigit() or str(int(value)) != value
|
||||
or not 1 <= int(value) <= 2**64 - 1):
|
||||
raise BadRequestError("交接人员必须是有效的正式人员")
|
||||
include_person(int(value))
|
||||
if role_on(people_facts[handover["to_personnel_id"]], date.fromisoformat(handover["effective_date"])) not in ("salesperson", "sales_manager"):
|
||||
raise BadRequestError("接手人员在交接月份的岗位未确认")
|
||||
for item in targets:
|
||||
entry = assignments[item.id]
|
||||
head = session.get(Shipment, item.shipment_id)
|
||||
for pid in (entry.personnel_id, entry.manager_id):
|
||||
if pid is None or pid in people:
|
||||
continue
|
||||
include_person(pid)
|
||||
if role_on(people_facts[str(entry.personnel_id)], head.ship_date) not in ("salesperson", "sales_manager"):
|
||||
raise BadRequestError("发货月份的人员岗位未确认")
|
||||
if entry.manager_id is not None and role_on(people_facts[str(entry.manager_id)], head.ship_date) != "sales_manager":
|
||||
raise BadRequestError("分管人员在发货月份须为经理")
|
||||
if entry.freight_amount is None:
|
||||
raise BadRequestError("请逐条核实运费金额;已核实无运费时填写0")
|
||||
freight = Decimal(entry.freight_amount)
|
||||
if freight >= item.amount:
|
||||
raise BadRequestError("运费须小于该明细销售额")
|
||||
product = session.get(Product, item.product_id)
|
||||
cost_total = (Decimal(entry.unit_cost) * item.quantity).quantize(Decimal('0.01'), rounding=ROUND_HALF_UP)
|
||||
line_doc = by_target.get(("shipment_items", item.id))
|
||||
source_line = raw.get(line_doc.record_id) if line_doc else None
|
||||
if line_doc and (source_line is None or source_line.payload_json.get("idinventory") is None):
|
||||
raise BadRequestError("发货来源产品标识缺失,请先核对")
|
||||
product_source_id = source_line.payload_json.get("idinventory") if source_line else None
|
||||
if product_source_id is not None:
|
||||
from app.services.tplus.guidance import resolve_guidance
|
||||
status, guidance = resolve_guidance(facts, str(product_source_id), line_doc.source_id,
|
||||
head.ship_date.strftime("%Y-%m"), item.unit)
|
||||
errors = {"AMBIGUOUS": "指导成本存在多条匹配记录,请先核对",
|
||||
"UNIT_MISMATCH": "指导成本单位与发货明细不一致,请先核对",
|
||||
"UNFINALIZED": "指导成本尚未封存,请先核对"}
|
||||
if status in errors:
|
||||
raise BadRequestError(errors[status])
|
||||
if guidance:
|
||||
submitted_cost = Decimal(entry.unit_cost) if guidance["basis"] == "UNIT" else cost_total
|
||||
if submitted_cost != Decimal(guidance["amount"]):
|
||||
raise BadRequestError("指导成本与已封存依据不一致,请先核对")
|
||||
ship = {"source_id": str(head.id), "line_id": str(item.id), "source_no": head.source_no,
|
||||
"date": head.ship_date.isoformat(), "group_key": key, "business_key": key,
|
||||
"personnel_id": str(entry.personnel_id), "manager_id": str(entry.manager_id) if entry.manager_id else None,
|
||||
"amount": str(item.amount - freight), "gross_tax_amount": str(item.amount), "freight_amount": str(freight),
|
||||
"quantity": str(item.quantity), "unit": item.unit, "category": product.product_category,
|
||||
"customer_type": session.get(Customer, row.customer_id).customer_type,
|
||||
"unit_cost": entry.unit_cost, "cost_total": str(cost_total), "pending": []}
|
||||
ships.append(ship)
|
||||
if current:
|
||||
old = {s["line_id"]: s for s in current.data_json["shipments"]}
|
||||
for ship in ships:
|
||||
previous = old.get(ship["line_id"])
|
||||
if previous:
|
||||
changed = any(previous.get(k) != ship.get(k) for k in ("cost_total", "date", "category", "customer_type", "personnel_id", "manager_id", "freight_amount"))
|
||||
old_unit_cost = previous.get("unit_cost")
|
||||
if changed or old_unit_cost is None or Decimal(str(old_unit_cost)) != Decimal(ship["unit_cost"]):
|
||||
raise BadRequestError("既有发货成本或归属已封存,后续新增单据不得改写历史依据")
|
||||
cash = list(session.scalars(select(Receipt).where(Receipt.business_id == business_id)))
|
||||
receipts = []
|
||||
for receipt in cash:
|
||||
doc = by_target.get(("receipts", receipt.id))
|
||||
if doc is None:
|
||||
raise BadRequestError("回款缺少来源关联")
|
||||
record = raw[doc.record_id]
|
||||
if Decimal(str(record.payload_json["amount"])) != receipt.amount or str(record.payload_json["voucherDate"])[:10] != receipt.receipt_date.isoformat():
|
||||
raise BadRequestError("正式回款与来源不一致,请先核对差异")
|
||||
fact = facts.get(("RECEIPT", record.source_id))
|
||||
try:
|
||||
confirmed, evidence, pending = _voucher_evidence(record.payload_json, fact)
|
||||
except (TypeError, ValueError):
|
||||
raise BadRequestError("收款凭证数据异常,请先核对来源") from None
|
||||
if pending:
|
||||
raise BadRequestError("收款凭证依据冲突,请先核实")
|
||||
exclusion = "NOT_GENERATED_VOUCHER" if evidence.get("count") == 0 else None
|
||||
if not confirmed and not exclusion:
|
||||
raise BadRequestError("收款凭证状态待核实,不能封存核算资料")
|
||||
receipts.append({"source_id": str(receipt.id), "source_no": receipt.source_no,
|
||||
"date": receipt.receipt_date.isoformat(), "group_key": key,
|
||||
"amount": str(receipt.amount), "voucher_confirmed": confirmed,
|
||||
"exclusion": exclusion, "pending": [], "voucher_evidence": evidence})
|
||||
from app.services.receipt_confirmation import ensure_confirmed
|
||||
ensure_confirmed(session, [receipt.id for receipt, snapshot in zip(cash, receipts, strict=True) if not snapshot["exclusion"]])
|
||||
values = {"input_schema": "TPLUS_SYNC_V1", "group": {"key": key, "scope_confirmed": True, "opening_confirmed": True,
|
||||
"opening_balance": payload.opening_balance, "historical_settlement_confirmed": True},
|
||||
"shipments": ships, "receipts": receipts, "facts": {"personnel": people_facts, "handovers": handovers},
|
||||
"maps": {"personnel": {str(p): p for p in people}, "shipments": {s["source_id"]: int(s["source_id"]) for s in ships},
|
||||
"shipment_items": {s["line_id"]: int(s["line_id"]) for s in ships}, "receipts": {str(r.id): r.id for r in cash}},
|
||||
"evidence": payload.evidence, "guidance_finalized": True, "as_of_month": payload.month}
|
||||
fingerprint = digest(values)
|
||||
record = PayrollInput(business_id=business_id, version=payload.expected_version + 1, source_database=source,
|
||||
group_key=key, fingerprint=fingerprint, data_json=values, requested_by=operator.id)
|
||||
session.add(record)
|
||||
session.flush()
|
||||
result = {"input_id": record.id, "version": record.version, "fingerprint": fingerprint}
|
||||
session.commit()
|
||||
return result
|
||||
@@ -188,6 +188,9 @@ def _load_and_guard(
|
||||
raise BadRequestError("回款关联的业务不存在")
|
||||
if business.owner_personnel_id is None:
|
||||
raise BadRequestError("业务未指定负责营销人员,无法核算(先在业务归属中指定)")
|
||||
from app.services.receipt_confirmation import ensure_confirmed
|
||||
|
||||
ensure_confirmed(session, [receipt.id])
|
||||
return receipt, business
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
"""批量补充空分类,不覆盖已有分类或冻结资料。"""
|
||||
|
||||
from sqlalchemy import select, update
|
||||
|
||||
from app.core.exceptions import BadRequestError
|
||||
from app.models import BusinessItem, Customer, PayrollInput, Product
|
||||
from app.services.audit_service import AuditRecord, audit
|
||||
|
||||
|
||||
def fill_missing_product_items(session, product_ids, category):
|
||||
"""补齐未封存业务的空分类,保留既有分类及历史核算依据。"""
|
||||
frozen = select(PayrollInput.id).where(PayrollInput.business_id == BusinessItem.business_id).exists()
|
||||
session.execute(update(BusinessItem).where(
|
||||
BusinessItem.product_id.in_(product_ids), BusinessItem.product_category.is_(None), ~frozen,
|
||||
).values(product_category=category))
|
||||
|
||||
|
||||
def fill_missing(session, model, payload, actor, meta=None):
|
||||
field = Customer.customer_type if model is Customer else Product.product_category
|
||||
module = "客户管理" if model is Customer else "产品管理"
|
||||
with audit(session, operator=actor, meta=meta, record=AuditRecord(
|
||||
module=module, action="批量补充分类", summary=f"批量补充 {len(payload.ids)} 条档案分类",
|
||||
request_data=payload.model_dump(mode="json"),
|
||||
)):
|
||||
rows = list(session.scalars(select(model).where(model.id.in_(payload.ids)).order_by(model.id).with_for_update()))
|
||||
if len(rows) != len(payload.ids) or any(getattr(row, field.key) is not None for row in rows):
|
||||
raise BadRequestError("所选档案不存在或分类已变化,请刷新后重新选择")
|
||||
for row in rows:
|
||||
setattr(row, field.key, payload.category)
|
||||
if model is Product:
|
||||
fill_missing_product_items(session, payload.ids, payload.category)
|
||||
session.commit()
|
||||
return {"updated": len(rows)}
|
||||
@@ -17,7 +17,7 @@ import logging
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Row
|
||||
from sqlalchemy import Row, select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -422,6 +422,25 @@ def _customer_ownership_log(
|
||||
)
|
||||
|
||||
|
||||
def batch_change_ownership(session, payload, *, operator, meta=None):
|
||||
"""只为未归属客户补充同一负责人;主档和逐条留痕同事务提交。"""
|
||||
with audit_service.audit(session, operator=operator, meta=meta, record=AuditRecord(
|
||||
module=MODULE_CUSTOMER, action=ACTION_UPDATE,
|
||||
summary=f"批量补充 {len(payload.ids)} 条客户归属", request_data=payload.model_dump(mode="json"),
|
||||
)):
|
||||
_ensure_owner_personnel(session, payload.new_owner_personnel_id)
|
||||
customers = list(session.scalars(select(Customer).where(Customer.id.in_(payload.ids))
|
||||
.order_by(Customer.id).with_for_update()))
|
||||
if len(customers) != len(payload.ids) or any(customer.owner_personnel_id is not None for customer in customers):
|
||||
raise BadRequestError("所选客户不存在或归属已变化,请刷新后重新选择")
|
||||
change = CustomerOwnershipIn(new_owner_personnel_id=payload.new_owner_personnel_id, reason=payload.reason)
|
||||
for customer in customers:
|
||||
customer.owner_personnel_id = payload.new_owner_personnel_id
|
||||
customer_repository.add_ownership_log(session, _customer_ownership_log(customer, change, None, operator))
|
||||
session.commit()
|
||||
return {"updated": len(customers)}
|
||||
|
||||
|
||||
def change_ownership(
|
||||
session: Session,
|
||||
customer_id: int,
|
||||
@@ -457,7 +476,10 @@ def change_ownership(
|
||||
):
|
||||
if prefetch.row is None:
|
||||
raise NotFoundError("客户不存在")
|
||||
customer, _ = prefetch.row
|
||||
customer = session.scalar(select(Customer).where(Customer.id == customer_id)
|
||||
.with_for_update().execution_options(populate_existing=True))
|
||||
if customer is None:
|
||||
raise NotFoundError("客户不存在")
|
||||
|
||||
old_owner_id = _validate_customer_ownership(session, customer, payload)
|
||||
|
||||
|
||||
@@ -190,6 +190,20 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
|
||||
description="累计回款达到不含运费销售额的设定比例后,开始计付绩效。启用前应收先冲抵,再核算新业务。",
|
||||
sort_order=11,
|
||||
),
|
||||
ParamSpec(
|
||||
code="receipt.confirmation.enabled",
|
||||
name="回款人工确认开关",
|
||||
group="回款确认",
|
||||
value_type="BOOL",
|
||||
unit=None,
|
||||
dimension_type="NONE",
|
||||
options=None,
|
||||
default_value="1",
|
||||
value_min=None,
|
||||
value_max=None,
|
||||
description="默认开启:未人工确认的回款不得正式核算。关闭仅跳过人工确认,仍检查凭证等资格;与工资审核开关独立。",
|
||||
sort_order=16,
|
||||
),
|
||||
ParamSpec(
|
||||
code="payment.weight.mode",
|
||||
name="支付权重应用方式",
|
||||
|
||||
@@ -24,12 +24,13 @@ from app.models import (
|
||||
from app.services import audit_service, formula, payroll_months
|
||||
from app.services.audit_service import AuditRecord
|
||||
from app.services.param_service import resolve_snapshot
|
||||
from app.services.personnel_roles import input_role
|
||||
from app.services.tplus.calculation_preview import _parameters
|
||||
from app.services.tplus.fifo import START, _beneficiary, amount_text, money, preview_group
|
||||
from app.services.tplus.workbench import digest
|
||||
|
||||
# 核算规则版本:改公式或口径时同步更新,写入台账快照用于追溯。
|
||||
RULE_VERSION = "2026-10-30.2"
|
||||
RULE_VERSION = "2026-11-04.1"
|
||||
|
||||
|
||||
def latest_input(session, business_id, *, for_update=False):
|
||||
@@ -78,6 +79,8 @@ def _previous(session, business_id, month):
|
||||
|
||||
|
||||
def _inputs(session, record, until):
|
||||
from app.services.receipt_confirmation import ensure_confirmed
|
||||
|
||||
values = copy.deepcopy(record.data_json)
|
||||
ships, cash = values["shipments"], values["receipts"]
|
||||
for row in cash:
|
||||
@@ -91,6 +94,10 @@ def _inputs(session, record, until):
|
||||
or current.business_id != record.business_id
|
||||
):
|
||||
raise BadRequestError("正式回款与核算资料不一致,请重新核对资料版本")
|
||||
ensure_confirmed(session, [
|
||||
values["maps"]["receipts"].get(str(row["source_id"]))
|
||||
for row in cash if not row.get("exclusion") and row["date"] <= until.isoformat()
|
||||
])
|
||||
for row in ships:
|
||||
item_id = values["maps"].get("shipment_items", {}).get(str(row["line_id"]))
|
||||
if item_id is not None:
|
||||
@@ -99,7 +106,7 @@ def _inputs(session, record, until):
|
||||
if (
|
||||
item is None
|
||||
or head is None
|
||||
or item.amount != money(row["amount"])
|
||||
or item.amount != money(row.get("gross_tax_amount", row["amount"]) if values.get("input_schema") == "TPLUS_SYNC_V1" else row["amount"])
|
||||
or head.ship_date.isoformat() != row["date"]
|
||||
or head.business_id != record.business_id
|
||||
):
|
||||
@@ -157,7 +164,7 @@ def _post(
|
||||
business_no=business.business_no,
|
||||
personnel_id=owner,
|
||||
personnel_name=person.personnel_name,
|
||||
role_code=role.role_code,
|
||||
role_code=input_role(record.data_json, owner, event["effective_date"]) or role.role_code,
|
||||
customer_type=event["customer_type"],
|
||||
product_category=event["category"],
|
||||
ship_date=date.fromisoformat(event["shipment_date"]),
|
||||
@@ -213,6 +220,16 @@ def _post(
|
||||
|
||||
|
||||
def calculate(session, business_id, month, *, operator, meta=None, reason=None):
|
||||
business = session.get(Business, business_id)
|
||||
if business is not None and business.integration_json:
|
||||
from app.services.business_source import source_calculation_lock
|
||||
|
||||
with source_calculation_lock(session):
|
||||
return _calculate_locked(session, business_id, month, operator=operator, meta=meta, reason=reason)
|
||||
return _calculate_locked(session, business_id, month, operator=operator, meta=meta, reason=reason)
|
||||
|
||||
|
||||
def _calculate_locked(session, business_id, month, *, operator, meta=None, reason=None):
|
||||
"""重算从指定月起级联重建尚未生成工资的月份,同一事务保存全部版本。"""
|
||||
from app.services.manager_share import month_end
|
||||
|
||||
@@ -249,6 +266,16 @@ def calculate(session, business_id, month, *, operator, meta=None, reason=None):
|
||||
record = latest_input(session, business_id, for_update=True)
|
||||
if record is None:
|
||||
raise BadRequestError("请先补录该业务的正式核算资料")
|
||||
if record.data_json.get("input_schema") == "TPLUS_SYNC_V1":
|
||||
from app.services.business_source import LABELS, readiness
|
||||
|
||||
state = readiness(session, business_id)
|
||||
blocking = {r["code"] for r in state["reasons"]} & {
|
||||
"SOURCE_CONFLICT", "SOURCE_SNAPSHOT_PENDING", "INPUT_REFRESH_REQUIRED", "CUSTOMER_CATEGORY_PENDING",
|
||||
"PRODUCT_CATEGORY_PENDING", "NO_ACTUAL_SHIPMENT", "SALES_RELATION_PENDING",
|
||||
}
|
||||
if blocking:
|
||||
raise BadRequestError("核算资料待补:" + ";".join(LABELS[code] for code in sorted(blocking)))
|
||||
if reason:
|
||||
old_rows = list(
|
||||
session.scalars(
|
||||
|
||||
@@ -37,6 +37,11 @@ def detail(session, business_id):
|
||||
|
||||
|
||||
def setup(session, business_id, payload, operator, meta=None):
|
||||
source_business = session.get(Business, business_id)
|
||||
if source_business and source_business.integration_json:
|
||||
from app.services.business_source import prepare
|
||||
|
||||
return prepare(session, business_id, payload, operator, meta)
|
||||
with audit_service.audit(
|
||||
session,
|
||||
operator=operator,
|
||||
@@ -121,15 +126,21 @@ def setup(session, business_id, payload, operator, meta=None):
|
||||
}
|
||||
)
|
||||
receipts = list(session.scalars(select(Receipt).where(Receipt.business_id == business_id)))
|
||||
if any(r.is_barter for r in receipts):
|
||||
raise BadRequestError("抵顶特殊绩效继续线下处理,不进入普通FIFO核算")
|
||||
# 用户 2026-11-03:抵顶按普通业务计算,特殊应得绩效线下核实,
|
||||
# 不自动乘80%,也不因抵顶标记阻断整个客户的正常平账。
|
||||
if current:
|
||||
previous_costs = {s["line_id"]: s for s in current.data_json["shipments"]}
|
||||
for ship in ships:
|
||||
old = previous_costs.get(ship["line_id"])
|
||||
if old and (old["cost_total"] != ship["cost_total"] or old["date"] != ship["date"]):
|
||||
raise BadRequestError("原发货月份指导成本已封存,不得随后续回款更改")
|
||||
handovers = [h.model_dump(mode="json") for h in payload.handovers]
|
||||
if old:
|
||||
old_unit_cost = old.get("unit_cost")
|
||||
if (old["cost_total"] != ship["cost_total"] or old["date"] != ship["date"]
|
||||
or old_unit_cost is None or Decimal(str(old_unit_cost)) != Decimal(ship["unit_cost"])):
|
||||
raise BadRequestError("原发货月份指导成本已封存,不得随后续回款更改")
|
||||
if current and "handovers" not in payload.model_fields_set:
|
||||
handovers = current.data_json.get("facts", {}).get("handovers", [])
|
||||
else:
|
||||
handovers = [h.model_dump(mode="json") for h in payload.handovers]
|
||||
if any(h["business_key"] != key for h in handovers):
|
||||
raise BadRequestError("交接记录不属于本业务")
|
||||
values = {
|
||||
|
||||
@@ -14,7 +14,7 @@ MODULES = (
|
||||
("base:customers", "基础资料", "客户管理", ("view", "create", "update", "delete", "rating", "ownership")),
|
||||
("business:businesses", "业务数据", "业务管理", ("view", "create", "update", "delete", "status", "ownership")),
|
||||
("business:shipments", "业务数据", "发货管理", ("view", "create", "update", "delete")),
|
||||
("business:receipts", "业务数据", "回款管理", ("view", "create", "update", "delete")),
|
||||
("business:receipts", "业务数据", "回款管理", ("view", "create", "update", "delete", "confirm")),
|
||||
("business:ratings", "业务数据", "评级标准", ("view", "create", "update", "delete")),
|
||||
("tplus", "T+ 对接", "采集与导入", ("view", "collect", "retry", "preview", "import", "facts", "export")),
|
||||
("payroll", "绩效工资", "正式核算与分配", ("view", "prepare", "calculate", "allocation")),
|
||||
@@ -27,6 +27,7 @@ MODULES = (
|
||||
("reports:payments", "报表统计", "回款统计", ("view", "export")),
|
||||
)
|
||||
ACTION_NAMES = {
|
||||
"confirm": "人工确认回款",
|
||||
"view": "查看", "create": "新增", "update": "编辑", "delete": "删除",
|
||||
"status": "变更状态", "password": "重置密码", "regions": "维护区域", "plans": "维护计划",
|
||||
"rating": "客户评级", "ownership": "变更归属", "export": "导出", "preview": "预览",
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
"""从已冻结的任职依据取岗位,回补历史工资不改当前人员档案。"""
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.exceptions import BadRequestError
|
||||
from app.models import PayrollInput
|
||||
|
||||
|
||||
def role_on(person, day):
|
||||
if not person:
|
||||
return None
|
||||
month = str(day)[:7]
|
||||
for period in person.get("role_history", []):
|
||||
if period["start_month"] <= month <= period["end_month"]:
|
||||
return period["role_code"]
|
||||
return person.get("role_code")
|
||||
|
||||
|
||||
def input_role(values, personnel_id, day):
|
||||
people = values.get("facts", {}).get("personnel", {})
|
||||
codes = {
|
||||
role_on(people[source_id], day)
|
||||
for source_id, target_id in values.get("maps", {}).get("personnel", {}).items()
|
||||
if target_id == personnel_id and source_id in people
|
||||
}
|
||||
if len(codes) > 1:
|
||||
raise BadRequestError("同一人员的历史岗位依据冲突,请先核实任职资料")
|
||||
return next(iter(codes), None)
|
||||
|
||||
|
||||
def frozen_roles(session, rows, day):
|
||||
"""按入结台账引用的资料版本解析任职,冲突时拒绝猜测。"""
|
||||
inputs = {}
|
||||
for row in rows:
|
||||
snapshot = row.snapshot_json or {}
|
||||
if snapshot.get("engine") == "FIFO" and snapshot.get("input_id"):
|
||||
inputs.setdefault(snapshot["input_id"], set()).add(row.personnel_id)
|
||||
result = {}
|
||||
for record in session.scalars(select(PayrollInput).where(PayrollInput.id.in_(inputs))):
|
||||
for pid in inputs[record.id]:
|
||||
role = input_role(record.data_json, pid, day)
|
||||
if role is None:
|
||||
raise BadRequestError("月结人员的历史岗位依据缺失,请先核实任职资料")
|
||||
if pid in result and result[pid] != role:
|
||||
raise BadRequestError("同一人员的历史岗位依据冲突,请先核实任职资料")
|
||||
result[pid] = role
|
||||
return result
|
||||
@@ -0,0 +1,56 @@
|
||||
"""回款人工确认门槛;与生成凭证、保存资料和工资审核分别管理。"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.exceptions import BadRequestError
|
||||
from app.models import Receipt
|
||||
from app.services.param_service import resolve_snapshot
|
||||
|
||||
CONFIRMATION_PARAM = "receipt.confirmation.enabled"
|
||||
|
||||
|
||||
def enabled(session) -> bool:
|
||||
"""操作开关取当前生效值,不按历史到账日绕过本次人工确认门槛。"""
|
||||
return resolve_snapshot(session, date.today()).boolean(CONFIRMATION_PARAM)
|
||||
|
||||
|
||||
def revision(receipt) -> str:
|
||||
"""只绑定经办人核对的业务内容;备注和同步时间不使确认无故失效。"""
|
||||
values = [
|
||||
receipt.id, receipt.business_id, receipt.shipment_id,
|
||||
receipt.receipt_date.isoformat(), format(Decimal(receipt.amount).quantize(Decimal("0.01")), "f"),
|
||||
receipt.is_barter, receipt.t_plus_id, receipt.source_no,
|
||||
]
|
||||
return hashlib.sha256(json.dumps(values, ensure_ascii=False).encode()).hexdigest()
|
||||
|
||||
|
||||
def is_confirmed(receipt) -> bool:
|
||||
return bool(
|
||||
receipt.confirmation_status == 1
|
||||
and receipt.confirmed_by
|
||||
and receipt.confirmed_at
|
||||
and receipt.confirmed_revision == revision(receipt)
|
||||
)
|
||||
|
||||
|
||||
def invalidate(receipt) -> None:
|
||||
receipt.confirmation_status = 0
|
||||
receipt.confirmed_by = receipt.confirmed_at = receipt.confirmed_revision = None
|
||||
|
||||
|
||||
def ensure_confirmed(session, receipt_ids) -> None:
|
||||
"""在原核算事务内锁定并核实回款;关闭只跳过人工门槛,不改变凭证资格。"""
|
||||
ids = sorted(set(receipt_ids))
|
||||
if not ids or not enabled(session):
|
||||
return
|
||||
rows = list(session.scalars(
|
||||
select(Receipt).where(Receipt.id.in_(ids)).order_by(Receipt.id)
|
||||
.with_for_update().execution_options(populate_existing=True)
|
||||
))
|
||||
if len(rows) != len(ids) or any(not is_confirmed(row) for row in rows):
|
||||
raise BadRequestError("存在未人工确认或确认后内容已变化的回款,请先在回款管理逐笔确认")
|
||||
@@ -24,7 +24,7 @@
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from datetime import date
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
@@ -38,7 +38,7 @@ from app.core.constants import (
|
||||
NO_PREFIX_RECEIPT,
|
||||
)
|
||||
from app.core.db_errors import is_duplicate_entry
|
||||
from app.core.exceptions import BadRequestError, NotFoundError
|
||||
from app.core.exceptions import BadRequestError, ConflictError, NotFoundError
|
||||
from app.core.response import PageResult
|
||||
from app.models import Business, Receipt
|
||||
from app.models.business import BUSINESS_STATUS_ACTIVE
|
||||
@@ -50,7 +50,7 @@ from app.schemas.receipt import (
|
||||
ReceiptQuery,
|
||||
ReceiptUpdateIn,
|
||||
)
|
||||
from app.services import audit_service
|
||||
from app.services import audit_service, receipt_confirmation
|
||||
from app.services.audit_service import AuditRecord, RequestMeta
|
||||
from app.services.business_service import _recalc_totals
|
||||
from app.services.formula import payment_days
|
||||
@@ -81,6 +81,10 @@ def _to_out(row: tuple) -> ReceiptOut:
|
||||
amount=str(receipt.amount),
|
||||
is_barter=receipt.is_barter,
|
||||
settlement_status=receipt.settlement_status,
|
||||
confirmation_status=1 if receipt_confirmation.is_confirmed(receipt) else 0,
|
||||
confirmed_by=receipt.confirmed_by,
|
||||
confirmed_at=receipt.confirmed_at,
|
||||
confirmation_revision=receipt_confirmation.revision(receipt),
|
||||
payment_days=(
|
||||
payment_days(ship_date, receipt.receipt_date) if ship_date is not None else None
|
||||
),
|
||||
@@ -151,6 +155,33 @@ def get_detail(session: Session, receipt_id: int) -> ReceiptOut:
|
||||
return _to_out(row)
|
||||
|
||||
|
||||
def confirm_receipt(session, receipt_id, payload, *, operator, meta=None) -> ReceiptOut:
|
||||
"""独立确认已核对内容;锁内复核指纹,重复确认不改首次确认人/时间。"""
|
||||
from app.services.authorization_service import require_permission
|
||||
|
||||
require_permission(session, operator, "business:receipts:confirm")
|
||||
with audit_service.audit(
|
||||
session, operator=operator,
|
||||
record=AuditRecord(module=MODULE_RECEIPT, action="人工确认", summary=f"人工确认回款 #{receipt_id}"),
|
||||
meta=meta,
|
||||
):
|
||||
receipt = receipt_repository.get_by_id(session, receipt_id)
|
||||
if receipt is None:
|
||||
raise NotFoundError("回款不存在")
|
||||
_ensure_business_writable(session, receipt.business_id, "确认")
|
||||
session.refresh(receipt, with_for_update=True)
|
||||
if payload.expected_revision != receipt_confirmation.revision(receipt):
|
||||
raise ConflictError("回款内容已变化,请重新查看并核对后确认")
|
||||
if not receipt_confirmation.is_confirmed(receipt):
|
||||
receipt.confirmation_status = 1
|
||||
receipt.confirmed_by = operator.username
|
||||
receipt.confirmed_at = datetime.now()
|
||||
receipt.confirmed_revision = payload.expected_revision
|
||||
out = get_detail(session, receipt_id)
|
||||
session.commit()
|
||||
return out
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _CreatedReceipt:
|
||||
"""建行结果(出参 + 固化的 ID/批次号,commit 后不再触碰 ORM 属性)。"""
|
||||
@@ -296,9 +327,13 @@ def update_receipt(
|
||||
):
|
||||
if receipt is None:
|
||||
raise NotFoundError("回款不存在")
|
||||
from app.services.business_source import ensure_source_editable
|
||||
|
||||
ensure_source_editable(session, "receipts", receipt_id)
|
||||
business = _ensure_business_writable(session, receipt.business_id, "修改")
|
||||
session.refresh(receipt, with_for_update=True)
|
||||
if receipt.settlement_status == SETTLEMENT_DONE:
|
||||
raise BadRequestError("已核算回款禁止修改")
|
||||
business = _ensure_business_writable(session, receipt.business_id, "修改")
|
||||
# 月结锁定拦截:原月与新月都不得落入已锁定月份(改到账日=跨月搬数,
|
||||
# 只查新月会让「从锁定月改到未锁定月」绕过拦截)
|
||||
from app.services import settlement_service
|
||||
@@ -311,11 +346,14 @@ def update_receipt(
|
||||
session, payload.shipment_id, receipt.business_id
|
||||
)
|
||||
|
||||
old_revision = receipt_confirmation.revision(receipt)
|
||||
receipt.receipt_date = payload.receipt_date
|
||||
receipt.shipment_id = payload.shipment_id
|
||||
receipt.amount = Decimal(payload.amount)
|
||||
receipt.is_barter = payload.is_barter
|
||||
receipt.remark = payload.remark
|
||||
if receipt_confirmation.revision(receipt) != old_revision:
|
||||
receipt_confirmation.invalidate(receipt)
|
||||
_recalc_totals(session, business)
|
||||
out = _to_out((receipt, business.business_no, shipment_no, ship_date))
|
||||
batch_no = receipt.receipt_batch_no
|
||||
@@ -353,6 +391,9 @@ def delete_receipt(
|
||||
):
|
||||
if receipt is None:
|
||||
raise NotFoundError("回款不存在")
|
||||
from app.services.business_source import ensure_source_editable
|
||||
|
||||
ensure_source_editable(session, "receipts", receipt_id)
|
||||
if receipt.settlement_status == SETTLEMENT_DONE:
|
||||
raise BadRequestError("已核算回款禁止删除")
|
||||
business = _ensure_business_writable(session, receipt.business_id, "删除")
|
||||
|
||||
@@ -280,7 +280,7 @@ def _write_row(sheet, columns: tuple[_Column, ...], row: dict) -> None:
|
||||
def _cell_value(column: _Column, raw):
|
||||
"""单值转换:标签列转中文,文本列防公式注入。"""
|
||||
if raw is None:
|
||||
return None
|
||||
return "待核对" if column.key == "total_sale_amount" else None
|
||||
if column.kind == "amount":
|
||||
return raw if isinstance(raw, Decimal) else Decimal(str(raw))
|
||||
if column.kind == "date":
|
||||
@@ -317,6 +317,9 @@ def _write_total(sheet, columns: tuple[_Column, ...], rows: list[dict]) -> None:
|
||||
totals.append("合计" if not label_written else None)
|
||||
label_written = True
|
||||
continue
|
||||
if column.key == "total_sale_amount" and any(row.get(column.key) is None for row in rows):
|
||||
totals.append("待核对")
|
||||
continue
|
||||
total = sum(
|
||||
(
|
||||
row.get(column.key)
|
||||
|
||||
@@ -812,14 +812,31 @@ def _unassigned_rows(
|
||||
# ==== 业务统计(FR-031 之一)====
|
||||
|
||||
|
||||
def _pending_sales_count(session, query):
|
||||
from app.services.business_source import sales_relations_pending
|
||||
|
||||
count, cursor = 0, 0
|
||||
while True:
|
||||
rows = report_repository.list_source_business_sales(session, query, after_id=cursor, limit=200)
|
||||
if not rows:
|
||||
return count
|
||||
pending = sales_relations_pending(session, [row.id for row in rows])
|
||||
count += len(pending | {row.id for row in rows if row.total_sale_amount is None})
|
||||
cursor = rows[-1].id
|
||||
|
||||
|
||||
def business_stats(session, query) -> dict:
|
||||
"""业务统计分页 + 全筛选合计;回款/绩效为业务累计值。"""
|
||||
rows = report_repository.list_business_stats(
|
||||
session, query, offset=query.offset, limit=query.page_size
|
||||
)
|
||||
sums = report_repository.sum_business_stats(session, query)
|
||||
pending_count = _pending_sales_count(session, query)
|
||||
from app.services.business_source import sales_relations_pending
|
||||
|
||||
pending = sales_relations_pending(session, [row[0].id for row in rows if row[0].integration_json])
|
||||
return {
|
||||
"list": [_business_row(r) for r in rows],
|
||||
"list": [_business_row(r, sales_pending=r[0].id in pending) for r in rows],
|
||||
"total": report_repository.count_business_stats(session, query),
|
||||
"pageNum": query.page_num,
|
||||
"pageSize": query.page_size,
|
||||
@@ -827,14 +844,15 @@ def business_stats(session, query) -> dict:
|
||||
"row_count": sums["row_count"],
|
||||
"ship_total": _q2(_dec(sums["ship_total"])),
|
||||
"received_total": _q2(_dec(sums["received_total"])),
|
||||
"sale_total": _q2(_dec(sums["sale_total"])),
|
||||
"sale_total": None if pending_count else _q2(_dec(sums["sale_total"])),
|
||||
"sale_pending_count": pending_count,
|
||||
"performance_total": _q2(_dec(sums["performance_total"])),
|
||||
"payable_total": _q2(_dec(sums["payable_total"])),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _business_row(row) -> dict:
|
||||
def _business_row(row, *, sales_pending=False) -> dict:
|
||||
"""业务统计行(ORM 行与三个联查列)。"""
|
||||
business = row[0]
|
||||
return {
|
||||
@@ -844,7 +862,7 @@ def _business_row(row) -> dict:
|
||||
"status": business.status,
|
||||
"customer_name": row.customer_name,
|
||||
"owner_personnel_name": row.owner_personnel_name,
|
||||
"total_sale_amount": business.total_sale_amount,
|
||||
"total_sale_amount": None if sales_pending else business.total_sale_amount,
|
||||
"total_ship_amount": business.total_ship_amount,
|
||||
"total_received_amount": business.total_received_amount,
|
||||
"performance_amount": row.performance_amount,
|
||||
@@ -1082,7 +1100,10 @@ def _export_payload(session, code: str, query) -> tuple[list[dict], dict]:
|
||||
session, query, offset=0, limit=REPORT_EXPORT_MAX_ROWS
|
||||
)
|
||||
sums = report_repository.sum_business_stats(session, query)
|
||||
return [_business_row(r) for r in rows], {
|
||||
from app.services.business_source import sales_relations_pending
|
||||
|
||||
pending = sales_relations_pending(session, [row[0].id for row in rows if row[0].integration_json])
|
||||
return [_business_row(r, sales_pending=r[0].id in pending) for r in rows], {
|
||||
"title": EXPORT_TITLES["business-stats"],
|
||||
"row_count": total,
|
||||
"filters": _filter_text(session, query),
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
|
||||
需求 2.5.1 定义 admin / user 两个登录角色;2026-10-15 复核审批补充需求
|
||||
新增营销后勤/财务/领导三个职能登录角色(营销后勤与财务/领导分区于审批
|
||||
节点准入)。本模块仍为轻量版(不含权限点分配 —— `permissions` /
|
||||
`role_permissions` 表继续闲置,待业务方给出权限清单后再启用)。
|
||||
节点准入)。权限点按当前权限目录分配,管理员角色拥有固定权限基线。
|
||||
|
||||
保护规则(都是系统自举与授权安全的最低防线,不只是界面体验):
|
||||
|
||||
@@ -15,8 +14,7 @@
|
||||
剥离绑定用户的权限,属安全与审计事故;删除前的校验走 `FOR UPDATE`
|
||||
锁行,堵住「校验与删除之间并发绑定」的窗口
|
||||
- `role_code` 创建后不可改 —— 它是授权判定(`BUILTIN_ROLE_CODES` 白名单按编码比对)的锚点
|
||||
- 停用角色**不解除绑定、不影响既有授权**(授权判定只看编码、不看状态),
|
||||
但**不可再分配给用户**(`user_service._resolve_roles` 拦截)
|
||||
- 停用角色不解除用户绑定,但该角色不再提供有效授权,也不可再分配给用户。
|
||||
"""
|
||||
|
||||
import logging
|
||||
@@ -35,16 +33,17 @@ from app.core.constants import (
|
||||
)
|
||||
from app.core.exceptions import BadRequestError, ForbiddenError, NotFoundError
|
||||
from app.core.response import PageResult
|
||||
from app.models import Role, User
|
||||
from app.models import Role, RolePermission, User
|
||||
from app.repositories import role_repository
|
||||
from app.schemas.role import RoleCreateIn, RoleManageOut, RoleQuery, RoleUpdateIn
|
||||
from app.services import audit_service
|
||||
from app.services import audit_service, authorization_management
|
||||
from app.services.audit_service import AuditRecord, RequestMeta
|
||||
from app.services.permission_catalog import CATALOG
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _to_out(role: Role, user_count: int) -> RoleManageOut:
|
||||
def _to_out(role: Role, user_count: int, permission_count: int = 0) -> RoleManageOut:
|
||||
"""ORM 对象转响应模型。禁止把 ORM 对象直接当 response_model。"""
|
||||
return RoleManageOut(
|
||||
id=role.id,
|
||||
@@ -54,6 +53,7 @@ def _to_out(role: Role, user_count: int) -> RoleManageOut:
|
||||
sort_order=role.sort_order,
|
||||
status=role.status,
|
||||
user_count=user_count,
|
||||
permission_count=len(CATALOG) if role.role_code == "admin" else permission_count,
|
||||
)
|
||||
|
||||
|
||||
@@ -62,6 +62,10 @@ def _user_count(session: Session, role_id: int) -> int:
|
||||
return role_repository.count_users_by_role(session, [role_id]).get(role_id, 0)
|
||||
|
||||
|
||||
def _permission_count(session: Session, role_id: int) -> int:
|
||||
return role_repository.count_permissions_by_role(session, [role_id]).get(role_id, 0)
|
||||
|
||||
|
||||
def list_roles(session: Session, query: RoleQuery) -> PageResult[RoleManageOut]:
|
||||
"""分页查询角色。
|
||||
|
||||
@@ -71,8 +75,9 @@ def list_roles(session: Session, query: RoleQuery) -> PageResult[RoleManageOut]:
|
||||
roles = role_repository.list_roles(session, query)
|
||||
total = role_repository.count_roles(session, query)
|
||||
counts = role_repository.count_users_by_role(session, [role.id for role in roles])
|
||||
permissions = role_repository.count_permissions_by_role(session, [role.id for role in roles])
|
||||
return PageResult[RoleManageOut](
|
||||
records=[_to_out(role, counts.get(role.id, 0)) for role in roles],
|
||||
records=[_to_out(role, counts.get(role.id, 0), permissions.get(role.id, 0)) for role in roles],
|
||||
total=total,
|
||||
page_num=query.page_num,
|
||||
page_size=query.page_size,
|
||||
@@ -90,6 +95,9 @@ def create_role(
|
||||
|
||||
:raises BadRequestError: 角色编码已存在(含并发提交的兜底)
|
||||
"""
|
||||
audit_data = payload.model_dump(exclude_none=True)
|
||||
if payload.permission_codes is not None and not payload.reason:
|
||||
audit_data["reason"] = "创建角色并配置初始权限"
|
||||
with audit_service.audit(
|
||||
session,
|
||||
operator=operator,
|
||||
@@ -97,13 +105,18 @@ def create_role(
|
||||
module=MODULE_ROLE,
|
||||
action=ACTION_CREATE,
|
||||
summary=f"新增角色 {payload.role_name}({payload.role_code})",
|
||||
request_data=payload.model_dump(exclude_none=True),
|
||||
request_data=audit_data,
|
||||
),
|
||||
meta=meta,
|
||||
):
|
||||
if role_repository.get_by_code(session, payload.role_code) is not None:
|
||||
raise BadRequestError(f"角色编码 {payload.role_code!r} 已存在")
|
||||
|
||||
codes = None
|
||||
policy = None
|
||||
if payload.permission_codes is not None:
|
||||
policy = authorization_management.lock_policy(session, operator)
|
||||
codes = authorization_management.validate_role_permissions(payload.permission_codes)
|
||||
role = Role(
|
||||
role_code=payload.role_code,
|
||||
role_name=payload.role_name,
|
||||
@@ -113,7 +126,12 @@ def create_role(
|
||||
)
|
||||
try:
|
||||
role_repository.create(session, role)
|
||||
out = _to_out(role, 0)
|
||||
if codes is not None:
|
||||
ids = authorization_management.active_permission_ids(session, codes)
|
||||
session.add_all([RolePermission(role_id=role.id, permission_id=ids[code]) for code in codes])
|
||||
authorization_management.bump(policy, operator)
|
||||
session.flush()
|
||||
out = _to_out(role, 0, len(codes) if codes is not None else 0)
|
||||
session.commit()
|
||||
except IntegrityError as exc:
|
||||
# 先查后写不是原子操作:并发提交同一编码会撞 uk_roles_role_code。
|
||||
@@ -156,7 +174,7 @@ def update_role(
|
||||
role.description = payload.description
|
||||
role.sort_order = payload.sort_order
|
||||
# 响应与后置动作全部在 commit 之前完成(见 user_service 顶部说明)
|
||||
out = _to_out(role, _user_count(session, role_id))
|
||||
out = _to_out(role, _user_count(session, role_id), _permission_count(session, role_id))
|
||||
session.commit()
|
||||
logger.info("修改角色 | role_id=%s", role_id)
|
||||
return out
|
||||
@@ -172,8 +190,7 @@ def set_status(
|
||||
) -> RoleManageOut:
|
||||
"""启用或禁用角色。
|
||||
|
||||
停用不解除用户绑定、不影响既有授权(授权判定只看编码不看状态),
|
||||
但该角色不可再分配给用户(`user_service._resolve_roles` 拦截);
|
||||
停用不解除用户绑定,但该角色不再提供有效授权,也不可再分配给用户;
|
||||
内置角色不可停用。
|
||||
|
||||
:raises NotFoundError: 角色不存在
|
||||
@@ -199,7 +216,7 @@ def set_status(
|
||||
raise ForbiddenError("内置角色不可停用")
|
||||
|
||||
role.status = status
|
||||
out = _to_out(role, _user_count(session, role_id))
|
||||
out = _to_out(role, _user_count(session, role_id), _permission_count(session, role_id))
|
||||
session.commit()
|
||||
logger.info("变更角色状态 | role_id=%s | status=%s", role_id, status)
|
||||
return out
|
||||
|
||||
@@ -16,6 +16,9 @@ def build_items(session, rows, month):
|
||||
from app.services.settlement_service import _salary_mode_of
|
||||
|
||||
end = month_end(month)
|
||||
from app.services.personnel_roles import frozen_roles
|
||||
|
||||
historical_roles = frozen_roles(session, rows, end)
|
||||
# 人员锁同时串行化不同工资月份对同一年度缓发余额的结清。
|
||||
people = {
|
||||
p.id: p for p in session.scalars(select(Personnel).order_by(Personnel.id).with_for_update())
|
||||
@@ -84,7 +87,7 @@ def build_items(session, rows, month):
|
||||
for pid, values in sorted(buckets.items()):
|
||||
person = values["person"]
|
||||
profile = profiles[pid]
|
||||
role = roles.get(profile.business_role_id, "salesperson")
|
||||
role = historical_roles.get(pid, roles.get(profile.business_role_id, "salesperson"))
|
||||
mode = _salary_mode_of(session, role, month)
|
||||
annual = sum((Decimal(r["delta"]) for r in values["annual"]), ZERO)
|
||||
normal = values["normal"]
|
||||
|
||||
@@ -90,6 +90,9 @@ def generate(
|
||||
_release_previous_round(session, existing, month)
|
||||
|
||||
ledger_rows = _confirm_scope(session, month)
|
||||
from app.services.receipt_confirmation import ensure_confirmed
|
||||
|
||||
ensure_confirmed(session, [row.receipt_id for row in ledger_rows if row.receipt_id is not None])
|
||||
from app.core.config import get_settings
|
||||
|
||||
if not ledger_rows and get_settings().payroll_allow_legacy_calculation:
|
||||
|
||||
@@ -70,7 +70,7 @@ def _to_out(row: tuple[Shipment, str]) -> ShipmentOut:
|
||||
business_id=shipment.business_id,
|
||||
business_no=business_no,
|
||||
ship_date=shipment.ship_date,
|
||||
freight_amount=str(shipment.freight_amount),
|
||||
freight_amount=str(shipment.freight_amount) if shipment.freight_amount is not None else None,
|
||||
total_amount=str(shipment.total_amount),
|
||||
remark=shipment.remark,
|
||||
)
|
||||
@@ -302,6 +302,9 @@ def update_shipment(
|
||||
):
|
||||
if shipment is None:
|
||||
raise NotFoundError("发货单不存在")
|
||||
from app.services.business_source import ensure_source_editable
|
||||
|
||||
ensure_source_editable(session, "shipments", shipment_id)
|
||||
business = _ensure_business_writable(session, shipment.business_id, "编辑发货单")
|
||||
# 月结锁定拦截:原发货日与新月都不得落入已锁定月份(改日期=跨月搬数)
|
||||
from app.services import settlement_service
|
||||
@@ -355,6 +358,9 @@ def delete_shipment(
|
||||
):
|
||||
if shipment is None:
|
||||
raise NotFoundError("发货单不存在")
|
||||
from app.services.business_source import ensure_source_editable
|
||||
|
||||
ensure_source_editable(session, "shipments", shipment_id)
|
||||
business = _ensure_business_writable(session, shipment.business_id, "删除发货单")
|
||||
# 月结锁定拦截:已锁定月份的发货单是月结快照的源数据,不得删除
|
||||
from app.services import settlement_service
|
||||
|
||||
Binary file not shown.
@@ -125,7 +125,11 @@ def _build(session, run):
|
||||
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"]):
|
||||
active_ships = [s for s in values["shipments"] if not s.get("performance_excluded")]
|
||||
if not active_ships:
|
||||
row["action"], row["reason"] = "SKIP", "CORRECTION_EXCLUDED"
|
||||
continue
|
||||
if any(not ship.get("guidance_finalized") for ship in active_ships):
|
||||
row["reason"] = "GUIDANCE_FINALIZATION_REQUIRED"
|
||||
continue
|
||||
business_key = "customer:" + customer_id
|
||||
@@ -150,7 +154,10 @@ def _build(session, run):
|
||||
):
|
||||
for source_id in ids:
|
||||
linked = _link(session, run.source_database, entity, source_id)
|
||||
if linked and (
|
||||
source_rows = values[entity]
|
||||
excluded = all(r.get("exclusion") or r.get("performance_excluded")
|
||||
for r in source_rows if r["source_id"] == source_id)
|
||||
if linked and not excluded and (
|
||||
not link
|
||||
or not current
|
||||
or source_id not in current.data_json["maps"].get(entity, {})
|
||||
@@ -158,7 +165,7 @@ def _build(session, run):
|
||||
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"]:
|
||||
for ship in active_ships:
|
||||
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}
|
||||
@@ -197,8 +204,9 @@ def _create(session, run, row, frozen):
|
||||
)
|
||||
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"))
|
||||
active_ships = [s for s in values["shipments"] if not s.get("performance_excluded")]
|
||||
needed = {str(s["personnel_id"]) for s in active_ships}
|
||||
needed.update(str(s["manager_id"]) for s in active_ships 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():
|
||||
@@ -211,8 +219,8 @@ def _create(session, run, row, frozen):
|
||||
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"]),
|
||||
owner_personnel_id=people[str(active_ships[0]["personnel_id"])].id,
|
||||
business_date=min(date.fromisoformat(s["date"]) for s in active_ships),
|
||||
status=1,
|
||||
is_special=0,
|
||||
t_plus_id=row["business_key"],
|
||||
|
||||
@@ -0,0 +1,705 @@
|
||||
"""将已发布的源单据接入业务;绝不以核算资料齐全作为建档条件。"""
|
||||
|
||||
from collections import Counter
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal, InvalidOperation
|
||||
|
||||
from sqlalchemy import String, and_, cast, func, null, or_, select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from app.models import (
|
||||
Business,
|
||||
BusinessItem,
|
||||
BusinessStatusLog,
|
||||
Customer,
|
||||
Personnel,
|
||||
Product,
|
||||
Receipt,
|
||||
Shipment,
|
||||
ShipmentItem,
|
||||
TPlusBusinessDocument,
|
||||
TPlusSourceHead,
|
||||
TPlusSourceLink,
|
||||
TPlusSyncRecord,
|
||||
TPlusWorkLine,
|
||||
)
|
||||
from app.services.no_generator import next_no
|
||||
from app.services.tplus.snapshots import source_manifest, source_statement
|
||||
from app.services.tplus.workbench import WorkbenchError, digest, latest_facts
|
||||
|
||||
START = date(2026, 3, 1)
|
||||
ENTITIES = ("businesses", "shipments", "receipts")
|
||||
DETAILS = {"businesses": "business_items", "shipments": "shipment_items"}
|
||||
SOURCE_FIELDS = {
|
||||
"businesses": ("idcustomer", "voucherdate", "idclerk", "voucherState"),
|
||||
"shipments": ("idcustomer", "voucherdate", "idclerk", "voucherState", "isCounteractDelivery"),
|
||||
"receipts": ("idpartner", "idperson", "idclerk", "voucherDate", "amount", "isReceiveFlag",
|
||||
"voucherstate", "cancelState", "accounting_voucher_count", "valid_accounting_voucher_count",
|
||||
"accounting_voucher_id"),
|
||||
}
|
||||
|
||||
|
||||
def decimal_value(value, scale=2, *, positive=False):
|
||||
"""拒绝精度丢失;源字符串末尾零不算超出精度。"""
|
||||
try:
|
||||
number = Decimal(str(value))
|
||||
if not number.is_finite() or abs(number) >= Decimal(10) ** (14 - scale):
|
||||
raise ValueError
|
||||
if number != number.quantize(Decimal(1).scaleb(-scale)):
|
||||
raise ValueError
|
||||
if positive and number <= 0:
|
||||
raise ValueError
|
||||
return number
|
||||
except (ValueError, InvalidOperation):
|
||||
raise WorkbenchError("SOURCE_VALUE_INVALID") from None
|
||||
|
||||
|
||||
def _source_personnel_id(raw):
|
||||
person_id = raw.get("idclerk")
|
||||
if person_id is None:
|
||||
person_id = raw.get("idperson")
|
||||
return str(person_id) if person_id is not None else None
|
||||
|
||||
|
||||
def statement(session, source, entity, *, start=None):
|
||||
query = source_statement(session, source, entity)
|
||||
if start:
|
||||
field = "voucherDate" if entity == "receipts" else "voucherdate"
|
||||
raw_date = func.substr(func.nullif(TPlusSyncRecord.payload_json[field].as_string(), "null"), 1, 10)
|
||||
projected = TPlusSourceHead.source_date
|
||||
fallback = and_(projected.is_(None), or_(
|
||||
raw_date.is_(None), raw_date >= start.isoformat(), ~raw_date.like("____-__-__"),
|
||||
))
|
||||
applied = select(TPlusBusinessDocument.id).where(
|
||||
TPlusBusinessDocument.source_database == source,
|
||||
TPlusBusinessDocument.entity_type == entity,
|
||||
TPlusBusinessDocument.source_id == TPlusSyncRecord.source_id,
|
||||
or_(TPlusBusinessDocument.applied_record_id.is_not(None),
|
||||
TPlusBusinessDocument.business_id.is_not(None)),
|
||||
).exists()
|
||||
query = query.where(or_(projected >= start.isoformat(), ~projected.like("____-__-__"), fallback, applied))
|
||||
return query
|
||||
|
||||
|
||||
def preview(session, source, start=START):
|
||||
"""只读统计当前完整快照;无需连接 SQL Server 或写业务表。"""
|
||||
counts, candidates = {}, {}
|
||||
for entity in ENTITIES:
|
||||
query = statement(session, source, entity, start=start)
|
||||
if entity == "receipts":
|
||||
query = query.where(TPlusSyncRecord.payload_json["isReceiveFlag"].as_integer() == 1)
|
||||
observed = query.with_only_columns(TPlusSyncRecord.source_id).subquery()
|
||||
counts[entity] = session.scalar(select(func.count()).select_from(observed)) or 0
|
||||
joined = observed.outerjoin(TPlusBusinessDocument, and_(
|
||||
TPlusBusinessDocument.source_database == source,
|
||||
TPlusBusinessDocument.entity_type == entity,
|
||||
TPlusBusinessDocument.source_id == observed.c.source_id,
|
||||
))
|
||||
new_count = session.scalar(select(func.count()).select_from(joined).where(
|
||||
TPlusBusinessDocument.id.is_(None))) or 0
|
||||
candidates[entity] = {"new": new_count, "existing": counts[entity] - new_count}
|
||||
conflicts = session.scalar(select(func.count()).select_from(TPlusBusinessDocument).where(
|
||||
TPlusBusinessDocument.source_database == source, TPlusBusinessDocument.status == "CONFLICT")) or 0
|
||||
customer_scope = preview_customer_scope(session, source, start)
|
||||
product_scope = preview_product_scope(session, source, start)
|
||||
return {"start_date": start.isoformat(), "documents": counts, "candidates": candidates, "existing_conflicts": conflicts,
|
||||
"customer_scope": customer_scope, "product_scope": product_scope,
|
||||
"formal_payroll_written": False, "message": "按客户自动建档;缺少人工资料只阻止核算"}
|
||||
|
||||
|
||||
def preview_customer_scope(session, source, start):
|
||||
"""只读预检客户身份;单据状态、产品及数据库排序规则仍须执行时核验。"""
|
||||
customer_ids = set()
|
||||
for entity in ENTITIES:
|
||||
query = statement(session, source, entity, start=start)
|
||||
if entity == "receipts":
|
||||
query = query.where(TPlusSyncRecord.payload_json["isReceiveFlag"].as_integer() == 1)
|
||||
field = "idpartner" if entity == "receipts" else "idcustomer"
|
||||
for projected, raw in session.execute(query.with_only_columns(
|
||||
TPlusSourceHead.source_customer_id, TPlusSyncRecord.payload_json[field].as_string()
|
||||
)):
|
||||
value = projected if projected is not None else raw
|
||||
if value not in (None, "", "null"):
|
||||
customer_ids.add(str(value))
|
||||
facts = latest_facts(session, source)
|
||||
records, customer_links, business_links = {}, {}, set()
|
||||
ordered = sorted(customer_ids)
|
||||
for offset in range(0, len(ordered), 500):
|
||||
ids = ordered[offset:offset + 500]
|
||||
records.update({row.source_id: row for row in session.scalars(
|
||||
statement(session, source, "customers").where(TPlusSyncRecord.source_id.in_(ids))
|
||||
)})
|
||||
for link in session.scalars(select(TPlusSourceLink).where(
|
||||
TPlusSourceLink.source_database == source,
|
||||
or_(and_(TPlusSourceLink.entity_type == "customers", TPlusSourceLink.source_id.in_(ids)),
|
||||
and_(TPlusSourceLink.entity_type == "businesses", TPlusSourceLink.source_id.in_(["customer:" + i for i in ids]))),
|
||||
)):
|
||||
if link.entity_type == "customers":
|
||||
customer_links[link.source_id] = link.target_id
|
||||
else:
|
||||
business_links.add(link.source_id.removeprefix("customer:"))
|
||||
grouped, problems = {}, []
|
||||
for source_id in ordered:
|
||||
if source_id in customer_links or source_id in business_links:
|
||||
continue
|
||||
record = records.get(source_id)
|
||||
if record is None:
|
||||
problems.append((source_id, "GROUP_SOURCE_MISSING"))
|
||||
continue
|
||||
name = str(record.payload_json.get("name") or "").strip()
|
||||
if not name or len(name) > 128:
|
||||
problems.append((source_id, "SOURCE_VALUE_INVALID"))
|
||||
continue
|
||||
fact = facts.get(("CUSTOMER", source_id))
|
||||
owner = (fact.data_json if fact else {}).get("owner_name", "")
|
||||
grouped.setdefault((name.casefold(), str(owner).casefold()), []).append((source_id, name, owner))
|
||||
for values in grouped.values():
|
||||
if len(values) > 1:
|
||||
problems.extend((source_id, "DUPLICATE_SOURCE_IDENTITY") for source_id, _, _ in values)
|
||||
# 仅查询候选名称;按数据库排序规则匹配后再以来源身份报告,不按名称合并。
|
||||
names = sorted({item[1] for values in grouped.values() for item in values})
|
||||
existing = set()
|
||||
for offset in range(0, len(names), 500):
|
||||
existing.update((str(name).casefold(), str(owner).casefold()) for name, owner in session.execute(
|
||||
select(Customer.company_name, Customer.owner_name).where(Customer.company_name.in_(names[offset:offset + 500]))
|
||||
))
|
||||
for key, values in grouped.items():
|
||||
if key in existing:
|
||||
problems.extend((source_id, "TARGET_UNIQUE_CONFLICT") for source_id, _, _ in values)
|
||||
problems.sort()
|
||||
return {"candidate_customers": len(customer_ids), "without_business_link": len(customer_ids - business_links),
|
||||
"identity_warnings": len(problems), "warning_counts": dict(Counter(reason for _, reason in problems)),
|
||||
"examples": [{"source_id": source_id, "reason": reason} for source_id, reason in problems[:100]],
|
||||
"truncated": len(problems) > 100,
|
||||
"note": "客户范围为候选上限;同名预检不覆盖所有排序规则、产品冲突及单据有效性,执行时仍逐单核对。"}
|
||||
|
||||
|
||||
def preview_product_scope(session, source, start):
|
||||
"""基于候选单据查找涉及的产品,不读取或创建正式档案。"""
|
||||
product_ids = set()
|
||||
for entity, child_entity in DETAILS.items():
|
||||
parents = list(session.scalars(statement(session, source, entity, start=start)
|
||||
.with_only_columns(TPlusSyncRecord.source_id)))
|
||||
for offset in range(0, len(parents), 500):
|
||||
ids = parents[offset:offset + 500]
|
||||
raw_parent = cast(TPlusSyncRecord.payload_json["source_parent_id"].as_string(), String)
|
||||
query = statement(session, source, child_entity).where(or_(
|
||||
TPlusSourceHead.source_parent_id.in_(ids),
|
||||
and_(TPlusSourceHead.source_parent_id.is_(None), raw_parent.in_(ids)),
|
||||
)).with_only_columns(TPlusSyncRecord.payload_json["idinventory"].as_string())
|
||||
product_ids.update(str(value) for value in session.scalars(query) if value not in (None, "", "null"))
|
||||
records, linked = {}, set()
|
||||
ordered = sorted(product_ids)
|
||||
for offset in range(0, len(ordered), 500):
|
||||
ids = ordered[offset:offset + 500]
|
||||
records.update({row.source_id: row for row in session.scalars(
|
||||
statement(session, source, "products").where(TPlusSyncRecord.source_id.in_(ids))
|
||||
)})
|
||||
linked.update(session.scalars(select(TPlusSourceLink.source_id).where(
|
||||
TPlusSourceLink.source_database == source, TPlusSourceLink.entity_type == "products",
|
||||
TPlusSourceLink.source_id.in_(ids),
|
||||
)))
|
||||
grouped, problems = {}, []
|
||||
for source_id in ordered:
|
||||
if source_id in linked:
|
||||
continue
|
||||
record = records.get(source_id)
|
||||
if record is None:
|
||||
problems.append((source_id, "GROUP_SOURCE_MISSING"))
|
||||
continue
|
||||
raw = record.payload_json
|
||||
name, spec, unit = str(raw.get("name") or "").strip(), str(raw.get("specification") or ""), raw.get("unit_name")
|
||||
if not name or len(name) > 128 or len(spec) > 128 or (unit and len(unit) > 16):
|
||||
problems.append((source_id, "SOURCE_VALUE_INVALID"))
|
||||
continue
|
||||
grouped.setdefault((name.casefold(), spec.casefold()), []).append((source_id, name, spec))
|
||||
for values in grouped.values():
|
||||
if len(values) > 1:
|
||||
problems.extend((source_id, "DUPLICATE_SOURCE_IDENTITY") for source_id, _, _ in values)
|
||||
names = sorted({item[1] for values in grouped.values() for item in values})
|
||||
existing = set()
|
||||
for offset in range(0, len(names), 500):
|
||||
existing.update((str(name).casefold(), str(spec).casefold()) for name, spec in session.execute(
|
||||
select(Product.product_name, Product.spec_model).where(Product.product_name.in_(names[offset:offset + 500]))
|
||||
))
|
||||
for key, values in grouped.items():
|
||||
if key in existing:
|
||||
problems.extend((source_id, "TARGET_UNIQUE_CONFLICT") for source_id, _, _ in values)
|
||||
problems.sort()
|
||||
return {"candidate_products": len(product_ids), "without_product_link": len(product_ids - linked),
|
||||
"identity_warnings": len(problems), "warning_counts": dict(Counter(reason for _, reason in problems)),
|
||||
"examples": [{"source_id": source_id, "reason": reason} for source_id, reason in problems[:100]],
|
||||
"truncated": len(problems) > 100,
|
||||
"note": "产品范围为候选上限;同名预检不覆盖所有排序规则或单据有效性,执行时仍逐单核对。"}
|
||||
|
||||
|
||||
class Context:
|
||||
def __init__(self, session, source):
|
||||
self.session, self.source = session, source
|
||||
self.facts = latest_facts(session, source)
|
||||
self.records, self.targets, self.recalculated = {}, {}, set()
|
||||
self.people = {r.source_id: r for r in session.scalars(statement(session, source, "personnel"))}
|
||||
|
||||
def record(self, entity, source_id):
|
||||
key = entity, str(source_id)
|
||||
if key not in self.records:
|
||||
self.records[key] = self.session.scalar(statement(self.session, self.source, entity).where(TPlusSyncRecord.source_id == str(source_id)))
|
||||
if self.records[key] is None:
|
||||
raise WorkbenchError("GROUP_SOURCE_MISSING")
|
||||
return self.records[key]
|
||||
|
||||
def link(self, entity, source_id):
|
||||
return self.session.scalar(select(TPlusSourceLink).where(
|
||||
TPlusSourceLink.source_database == self.source, TPlusSourceLink.entity_type == entity,
|
||||
TPlusSourceLink.source_id == str(source_id)))
|
||||
|
||||
def add_link(self, entity, source_id, target):
|
||||
self.session.add(TPlusSourceLink(source_database=self.source, entity_type=entity,
|
||||
source_id=str(source_id), target_id=target.id))
|
||||
|
||||
def master(self, entity, source_id):
|
||||
key = entity, str(source_id)
|
||||
if key in self.targets:
|
||||
return self.targets[key]
|
||||
model = Customer if entity == "customers" else Product
|
||||
link = self.link(entity, source_id)
|
||||
if link:
|
||||
target = self.session.get(model, link.target_id)
|
||||
if target is None:
|
||||
raise WorkbenchError("TARGET_MISSING")
|
||||
else:
|
||||
record = self.record(entity, source_id)
|
||||
raw = record.payload_json
|
||||
name = str(raw.get("name") or "").strip()
|
||||
if not name or len(name) > 128:
|
||||
raise WorkbenchError("SOURCE_VALUE_INVALID")
|
||||
if entity == "customers":
|
||||
fact = self.facts.get(("CUSTOMER", str(source_id)))
|
||||
values = fact.data_json if fact else {}
|
||||
target = Customer(company_name=name, owner_name=values.get("owner_name", ""),
|
||||
customer_type=values.get("customer_type"))
|
||||
conflict = self.session.scalar(select(Customer.id).where(Customer.company_name == name, Customer.owner_name == target.owner_name).limit(1))
|
||||
else:
|
||||
fact = self.facts.get(("PRODUCT", str(source_id)))
|
||||
values = fact.data_json if fact else {}
|
||||
spec, unit = str(raw.get("specification") or ""), raw.get("unit_name")
|
||||
if len(spec) > 128 or (unit and len(unit) > 16):
|
||||
raise WorkbenchError("SOURCE_VALUE_INVALID")
|
||||
target = Product(product_name=name, spec_model=spec, unit=unit,
|
||||
product_category=values.get("category"), status=0 if raw.get("disabled") else 1)
|
||||
conflict = self.session.scalar(select(Product.id).where(Product.product_name == name, Product.spec_model == spec).limit(1))
|
||||
if conflict:
|
||||
raise WorkbenchError("TARGET_UNIQUE_CONFLICT")
|
||||
target.t_plus_id, target.source_no = str(source_id), record.source_no
|
||||
target.synced_at = datetime.now()
|
||||
self.session.add(target)
|
||||
self.session.flush()
|
||||
self.add_link(entity, source_id, target)
|
||||
self.targets[key] = target
|
||||
return target
|
||||
|
||||
def business(self, customer_id, day, run_no):
|
||||
key = "customer:" + str(customer_id)
|
||||
link = self.link("businesses", key)
|
||||
if link:
|
||||
business = self.session.get(Business, link.target_id, with_for_update=True)
|
||||
if business is None:
|
||||
raise WorkbenchError("TARGET_MISSING")
|
||||
if business.integration_json is None:
|
||||
# 既有完整核算导入不能被新接入器改写。
|
||||
raise WorkbenchError("LEGACY_BUSINESS_REVIEW_REQUIRED")
|
||||
return business
|
||||
customer = self.master("customers", customer_id)
|
||||
business = Business(business_no=next_no(self.session, model=Business, column_name="business_no", prefix="YW"),
|
||||
customer_id=customer.id, business_date=day, status=1, is_special=0,
|
||||
t_plus_id=key, source_no=key, sync_batch_no=run_no, synced_at=datetime.now(),
|
||||
integration_json={"source_database": self.source, "customer_source_id": str(customer_id), "personnel_names": [], "personnel_codes": []},
|
||||
total_cost_amount=null(), total_freight_amount=null())
|
||||
self.session.add(business)
|
||||
self.session.flush()
|
||||
self.add_link("businesses", key, business)
|
||||
self.session.add(BusinessStatusLog(business_id=business.id, old_status=1, new_status=1,
|
||||
change_reason="T+业务自动建档", operator_name="T+业务接入"))
|
||||
return business
|
||||
|
||||
def person(self, raw):
|
||||
source_id = _source_personnel_id(raw)
|
||||
person = self.people.get(source_id)
|
||||
if not person:
|
||||
return {"source_personnel_id": source_id, "source_personnel_name": None,
|
||||
"source_personnel_no": None, "personnel_id": None}
|
||||
code = str(person.payload_json.get("code") or "")
|
||||
link = self.link("personnel", person.source_id)
|
||||
target = self.session.get(Personnel, link.target_id) if link else None
|
||||
return {"source_personnel_id": source_id, "source_personnel_name": person.payload_json.get("name"), "source_personnel_no": code,
|
||||
"personnel_id": target.id if target else None}
|
||||
|
||||
|
||||
def details(ctx, entity, source_id):
|
||||
parent = TPlusSourceHead.source_parent_id
|
||||
raw_parent = cast(TPlusSyncRecord.payload_json["source_parent_id"].as_string(), String)
|
||||
return list(ctx.session.scalars(statement(ctx.session, ctx.source, DETAILS[entity]).where(or_(
|
||||
parent == str(source_id), and_(parent.is_(None), raw_parent == str(source_id)),
|
||||
)).order_by(TPlusSyncRecord.id)))
|
||||
|
||||
|
||||
def rebuild_projections(session, source):
|
||||
"""按已发布快照补齐旧 worker 未写入的索引,不改源记录。"""
|
||||
entities = ("businesses", "business_items", "shipments", "shipment_items", "receipts")
|
||||
queries = {entity: source_statement(session, source, entity) for entity in entities}
|
||||
for entity, query in queries.items():
|
||||
cursor = 0
|
||||
while True:
|
||||
missing = (TPlusSourceHead.source_parent_id.is_(None) if entity in DETAILS.values()
|
||||
else TPlusSourceHead.source_date.is_(None))
|
||||
rows = session.execute(query.with_only_columns(TPlusSourceHead, TPlusSyncRecord.payload_json)
|
||||
.where(TPlusSourceHead.id > cursor, missing)
|
||||
.order_by(TPlusSourceHead.id).limit(500).with_for_update()).all()
|
||||
if not rows:
|
||||
break
|
||||
for head, raw in rows:
|
||||
parent = raw.get("source_parent_id")
|
||||
if parent is not None and len(str(parent)) <= 64:
|
||||
head.source_parent_id = str(parent)
|
||||
customer = raw.get("idcustomer") or raw.get("idpartner")
|
||||
if head.source_customer_id is None and customer is not None and len(str(customer)) <= 64:
|
||||
head.source_customer_id = str(customer)
|
||||
day = raw.get("voucherdate") or raw.get("voucherDate")
|
||||
if head.source_date is None and day is not None:
|
||||
head.source_date = str(day)[:10]
|
||||
cursor = rows[-1][0].id
|
||||
session.commit()
|
||||
|
||||
|
||||
def ledger(ctx, entity, record):
|
||||
row = ctx.session.scalar(select(TPlusBusinessDocument).where(
|
||||
TPlusBusinessDocument.source_database == ctx.source, TPlusBusinessDocument.entity_type == entity,
|
||||
TPlusBusinessDocument.source_id == record.source_id).with_for_update())
|
||||
if row is None:
|
||||
row = TPlusBusinessDocument(source_database=ctx.source, entity_type=entity, source_id=record.source_id,
|
||||
record_id=record.id, source_no=record.source_no, data_json={})
|
||||
ctx.session.add(row)
|
||||
row.record_id = record.id
|
||||
return row
|
||||
|
||||
|
||||
def _line_values(ctx, record):
|
||||
raw = record.payload_json
|
||||
product = ctx.master("products", raw.get("idinventory"))
|
||||
quantity = decimal_value(raw.get("quantity"), positive=True)
|
||||
source_price = raw.get("taxPrice") if raw.get("taxPrice") is not None else raw.get("price")
|
||||
price = decimal_value(source_price, 4) if source_price is not None else None
|
||||
amount = decimal_value(raw.get("taxAmount"), positive=True)
|
||||
return {"product_id": product.id, "product_name": product.product_name, "spec_model": product.spec_model,
|
||||
"unit": product.unit, "quantity": quantity, "sale_price": price}, amount, product.product_category
|
||||
|
||||
|
||||
def _shipment_order_line(ctx, business_id, child):
|
||||
"""来源明细号、订单号和产品均一致时,才将发货视为订单的一部分。"""
|
||||
raw = child.payload_json
|
||||
order_detail_id = raw.get("saleOrderDetailId")
|
||||
if order_detail_id is None:
|
||||
return None, False
|
||||
doc = ctx.session.scalar(select(TPlusBusinessDocument).where(
|
||||
TPlusBusinessDocument.source_database == ctx.source,
|
||||
TPlusBusinessDocument.entity_type == "business_items",
|
||||
TPlusBusinessDocument.source_id == str(order_detail_id),
|
||||
TPlusBusinessDocument.business_id == business_id,
|
||||
))
|
||||
if doc is None:
|
||||
return None, False
|
||||
order_record = ctx.session.get(TPlusSyncRecord, doc.applied_record_id) if doc.applied_record_id else None
|
||||
order_raw = order_record.payload_json if order_record else {}
|
||||
parent = order_raw.get("source_parent_id")
|
||||
order_id = raw.get("saleOrderId")
|
||||
matches = (doc.status == "APPLIED" and order_record is not None
|
||||
and order_raw.get("idinventory") is not None
|
||||
and str(order_raw.get("idinventory")) == str(raw.get("idinventory"))
|
||||
and (order_id is None or (parent is not None and str(parent) == str(order_id))))
|
||||
return (doc if matches else None), not matches
|
||||
|
||||
|
||||
def _recalculate(ctx, business):
|
||||
_refresh_business_totals(ctx.session, business)
|
||||
ctx.recalculated.add(business.id)
|
||||
|
||||
|
||||
def _refresh_business_totals(session, business):
|
||||
from app.services.tplus.sales_relations import resolve_items
|
||||
|
||||
items = list(session.scalars(select(BusinessItem).where(BusinessItem.business_id == business.id)))
|
||||
order_ids = list(session.scalars(select(TPlusBusinessDocument.target_id).where(
|
||||
TPlusBusinessDocument.business_id == business.id, TPlusBusinessDocument.entity_type == "business_items",
|
||||
TPlusBusinessDocument.status == "APPLIED")))
|
||||
ambiguous, sale_items = resolve_items(session, business, items, order_ids)
|
||||
business.total_sale_amount = None if ambiguous else decimal_value(sum((i.sale_amount for i in sale_items), Decimal(0)))
|
||||
business.total_cost_amount = decimal_value(sum((i.cost_amount for i in sale_items), Decimal(0))) if not ambiguous and sale_items and all(i.cost_amount is not None for i in sale_items) else None
|
||||
business.integration_json = {**(business.integration_json or {}), "sales_relation_pending": ambiguous}
|
||||
ships = list(session.scalars(select(Shipment).where(Shipment.business_id == business.id)))
|
||||
business.total_ship_amount = decimal_value(sum((i.total_amount for i in ships), Decimal(0)))
|
||||
business.total_freight_amount = decimal_value(sum((i.freight_amount for i in ships), Decimal(0))) if ships and all(i.freight_amount is not None for i in ships) else None
|
||||
business.total_received_amount = decimal_value(session.scalar(select(func.coalesce(func.sum(Receipt.amount), 0)).where(Receipt.business_id == business.id)))
|
||||
|
||||
|
||||
def _pending_document(ctx, doc, entity, raw, run_no, reason):
|
||||
"""保留未生效或更正单,并关联可识别的客户业务供核对。"""
|
||||
date_value = raw.get("voucherDate") if entity == "receipts" else raw.get("voucherdate")
|
||||
customer_id = raw.get("idpartner") if entity == "receipts" else raw.get("idcustomer")
|
||||
try:
|
||||
day = date.fromisoformat(str(date_value)[:10])
|
||||
except (ValueError, TypeError):
|
||||
raise WorkbenchError("SOURCE_VALUE_INVALID") from None
|
||||
if customer_id is None:
|
||||
raise WorkbenchError("GROUP_CUSTOMER_MISMATCH")
|
||||
business = ctx.business(customer_id, day, run_no)
|
||||
doc.business_id = business.id
|
||||
doc.data_json = {**ctx.person(raw), "source_date": day.isoformat(), "source_customer_id": str(customer_id)}
|
||||
doc.status, doc.reason = "PENDING", reason
|
||||
if business.id not in ctx.recalculated:
|
||||
_recalculate(ctx, business)
|
||||
return "PENDING", doc
|
||||
|
||||
|
||||
def _apply_document(ctx, entity, record, children, run_no, start):
|
||||
raw = record.payload_json
|
||||
doc = ledger(ctx, entity, record)
|
||||
customer_id = raw.get("idpartner") if entity == "receipts" else raw.get("idcustomer")
|
||||
previous_customer = (doc.data_json or {}).get("source_customer_id")
|
||||
if (doc.applied_record_id is None and doc.business_id is not None
|
||||
and previous_customer is not None and customer_id is not None
|
||||
and str(previous_customer) != str(customer_id)):
|
||||
doc.status, doc.reason = "CONFLICT", "PENDING_SOURCE_CUSTOMER_CHANGED"
|
||||
return "CONFLICT", doc
|
||||
if doc.applied_record_id is None and doc.business_id is not None:
|
||||
date_value = raw.get("voucherDate") if entity == "receipts" else raw.get("voucherdate")
|
||||
try:
|
||||
source_day = date.fromisoformat(str(date_value)[:10])
|
||||
except (ValueError, TypeError):
|
||||
source_day = None
|
||||
if source_day is not None and source_day < start:
|
||||
doc.status, doc.reason = "CONFLICT", "PENDING_SOURCE_OUT_OF_RANGE"
|
||||
return "CONFLICT", doc
|
||||
fingerprint = digest({"header": {k: raw.get(k) for k in SOURCE_FIELDS[entity]},
|
||||
"items": [{k: r.payload_json.get(k) for k in ("id", "idinventory", "quantity", "taxPrice", "price", "taxAmount", "saleOrderId", "saleOrderDetailId")} for r in children]})
|
||||
if doc.applied_record_id is not None:
|
||||
person_changed = False
|
||||
header_amount_changed = False
|
||||
if entity in ("businesses", "shipments"):
|
||||
if doc.applied_record_id != record.id:
|
||||
original = ctx.session.get(TPlusSyncRecord, doc.applied_record_id)
|
||||
header_amount_changed = original is None or original.payload_json.get("taxAmount") != raw.get("taxAmount")
|
||||
if "source_personnel_id" in doc.data_json:
|
||||
previous_person = doc.data_json["source_personnel_id"]
|
||||
previous_person = str(previous_person) if previous_person is not None else None
|
||||
else:
|
||||
previous = ctx.session.get(TPlusSyncRecord, doc.applied_record_id)
|
||||
previous_person = _source_personnel_id(previous.payload_json) if previous else None
|
||||
person_changed = previous is None
|
||||
person_changed = person_changed or previous_person != _source_personnel_id(raw)
|
||||
if doc.fingerprint != fingerprint or doc.source_no != record.source_no or person_changed or header_amount_changed:
|
||||
doc.status, doc.reason = "CONFLICT", "FORMAL_SOURCE_CHANGED"
|
||||
return "CONFLICT", doc
|
||||
if entity in DETAILS:
|
||||
for child in children:
|
||||
raw_line = child.payload_json
|
||||
if raw_line.get("taxPrice") is not None or raw_line.get("price") is None:
|
||||
continue
|
||||
line_doc = ctx.session.scalar(select(TPlusBusinessDocument).where(
|
||||
TPlusBusinessDocument.source_database == ctx.source,
|
||||
TPlusBusinessDocument.entity_type == DETAILS[entity],
|
||||
TPlusBusinessDocument.source_id == child.source_id,
|
||||
TPlusBusinessDocument.business_id == doc.business_id,
|
||||
))
|
||||
model = BusinessItem if entity == "businesses" else ShipmentItem
|
||||
target_line = ctx.session.get(model, line_doc.target_id) if line_doc and line_doc.target_id else None
|
||||
try:
|
||||
expected_price = decimal_value(raw_line["price"], 4)
|
||||
except WorkbenchError:
|
||||
expected_price = None
|
||||
if target_line is None or expected_price is None or target_line.sale_price != expected_price:
|
||||
doc.status, doc.reason = "CONFLICT", "SOURCE_PRICE_REVIEW_REQUIRED"
|
||||
return "CONFLICT", doc
|
||||
if entity == "shipments" and doc.business_id is not None:
|
||||
for child in children:
|
||||
_, mismatch = _shipment_order_line(ctx, doc.business_id, child)
|
||||
if mismatch:
|
||||
doc.status, doc.reason = "CONFLICT", "ORDER_SHIPMENT_MISMATCH"
|
||||
return "CONFLICT", doc
|
||||
doc.record_id, doc.status, doc.reason = record.id, "APPLIED", None
|
||||
if doc.business_id is not None and doc.business_id not in ctx.recalculated:
|
||||
business = ctx.session.get(Business, doc.business_id, with_for_update=True)
|
||||
if business is not None:
|
||||
_recalculate(ctx, business)
|
||||
return "UNCHANGED", doc
|
||||
if entity == "receipts" and raw.get("isReceiveFlag") != 1:
|
||||
doc.status, doc.reason = "EXCLUDED", "NOT_RECEIPT"
|
||||
return "EXCLUDED", doc
|
||||
if (entity != "receipts" and raw.get("voucherState") != 189) or (entity == "receipts" and raw.get("voucherstate") != 189):
|
||||
return _pending_document(ctx, doc, entity, raw, run_no, "SOURCE_DOCUMENT_INACTIVE")
|
||||
if entity == "shipments" and raw.get("isCounteractDelivery"):
|
||||
return _pending_document(ctx, doc, entity, raw, run_no, "CORRECTION_PAIR_REQUIRED")
|
||||
day_text = raw.get("voucherDate") if entity == "receipts" else raw.get("voucherdate")
|
||||
try:
|
||||
day = date.fromisoformat(str(day_text)[:10])
|
||||
except (ValueError, TypeError):
|
||||
raise WorkbenchError("SOURCE_VALUE_INVALID") from None
|
||||
if entity != "receipts" and not children:
|
||||
raise WorkbenchError("SHIPMENT_DETAILS_MISSING")
|
||||
# 先校验整张单据,避免不完整来源建出空壳业务。
|
||||
order_mismatch = False
|
||||
if entity == "receipts":
|
||||
decimal_value(raw.get("amount"), positive=True)
|
||||
else:
|
||||
for child in children:
|
||||
decimal_value(child.payload_json.get("quantity"), positive=True)
|
||||
decimal_value(child.payload_json.get("taxAmount"), positive=True)
|
||||
customer_id = raw.get("idpartner") if entity == "receipts" else raw.get("idcustomer")
|
||||
if customer_id is None:
|
||||
raise WorkbenchError("GROUP_CUSTOMER_MISMATCH")
|
||||
business = ctx.business(customer_id, day, run_no)
|
||||
if business.status != 1:
|
||||
raise WorkbenchError("BUSINESS_TERMINAL_REVIEW_REQUIRED")
|
||||
# 新月份可追加,但已生成工资月份的迟到单据必须另行核对。
|
||||
from app.models import MonthlySettlement, PayrollMonth
|
||||
month = day.strftime("%Y-%m")
|
||||
locked = ctx.session.scalar(select(MonthlySettlement.id).where(MonthlySettlement.settlement_month == month).limit(1))
|
||||
control = ctx.session.scalar(select(PayrollMonth).where(PayrollMonth.month == month))
|
||||
if locked or (control and control.generated_at):
|
||||
raise WorkbenchError("PAYROLL_MONTH_LOCKED")
|
||||
person = ctx.person(raw)
|
||||
summary = dict(business.integration_json)
|
||||
for key, value in (("personnel_names", person["source_personnel_name"]), ("personnel_codes", person["source_personnel_no"])):
|
||||
summary[key] = sorted(set(summary.get(key, []) + ([value] if value else [])))
|
||||
business.integration_json = summary
|
||||
doc.business_id = business.id
|
||||
doc.data_json = {**person, "source_date": day.isoformat(), "source_customer_id": str(customer_id)}
|
||||
if entity == "receipts":
|
||||
target = Receipt(business_id=business.id, receipt_batch_no=next_no(ctx.session, model=Receipt, column_name="receipt_batch_no", prefix="HK"),
|
||||
receipt_date=day, amount=decimal_value(raw["amount"]), is_barter=0, settlement_status=0,
|
||||
t_plus_id=record.source_id, source_no=record.source_no, sync_batch_no=run_no, synced_at=datetime.now())
|
||||
ctx.session.add(target)
|
||||
ctx.session.flush()
|
||||
ctx.add_link(entity, record.source_id, target)
|
||||
doc.target_id = target.id
|
||||
else:
|
||||
target = None
|
||||
if entity == "shipments":
|
||||
target = Shipment(business_id=business.id, shipment_no=next_no(ctx.session, model=Shipment, column_name="shipment_no", prefix="SH"),
|
||||
ship_date=day, freight_amount=None, total_amount=decimal_value(sum((decimal_value(c.payload_json["taxAmount"]) for c in children), Decimal(0))),
|
||||
t_plus_id=record.source_id, source_no=record.source_no, sync_batch_no=run_no, synced_at=datetime.now())
|
||||
ctx.session.add(target)
|
||||
ctx.session.flush()
|
||||
ctx.add_link(entity, record.source_id, target)
|
||||
doc.target_id = target.id if target else business.id
|
||||
for child in children:
|
||||
line, amount, category = _line_values(ctx, child)
|
||||
child_doc = ledger(ctx, DETAILS[entity], child)
|
||||
if entity == "businesses":
|
||||
item = BusinessItem(business_id=business.id, **line, sale_amount=amount, product_category=category)
|
||||
else:
|
||||
item = ShipmentItem(shipment_id=target.id, **line, amount=amount)
|
||||
order_line, mismatch = _shipment_order_line(ctx, business.id, child)
|
||||
order_mismatch = order_mismatch or mismatch
|
||||
if order_line is None:
|
||||
ctx.session.add(BusinessItem(business_id=business.id, **line, sale_amount=amount, product_category=category))
|
||||
ctx.session.add(item)
|
||||
ctx.session.flush()
|
||||
child_doc.business_id, child_doc.target_id = business.id, item.id
|
||||
child_doc.applied_record_id, child_doc.record_id = child.id, child.id
|
||||
child_doc.fingerprint, child_doc.status, child_doc.reason = child.row_hash, "APPLIED", None
|
||||
child_doc.data_json = {**person, "source_parent_id": record.source_id, "source_date": day.isoformat(), "source_customer_id": str(customer_id)}
|
||||
doc.applied_record_id, doc.fingerprint, doc.status, doc.reason = record.id, fingerprint, "APPLIED", None
|
||||
ctx.session.flush()
|
||||
_recalculate(ctx, business)
|
||||
if order_mismatch:
|
||||
doc.status, doc.reason = "CONFLICT", "ORDER_SHIPMENT_MISMATCH"
|
||||
return "CONFLICT", doc
|
||||
return "CREATE", doc
|
||||
|
||||
|
||||
def execute_sync(session, run):
|
||||
"""按单据提交;中断后幂等重跑,不生成 PayrollInput 或工资。"""
|
||||
from app.models import User
|
||||
from app.services.authorization_service import require_permission
|
||||
require_permission(session, session.get(User, run.requested_by), "tplus:import")
|
||||
start = date.fromisoformat(run.input_json.get("start_date", START.isoformat()))
|
||||
manifest = source_manifest(session, run.source_database)
|
||||
rebuild_projections(session, run.source_database)
|
||||
ctx, counts, processed = Context(session, run.source_database), Counter(), 0
|
||||
for entity in ENTITIES:
|
||||
# 先固定所有依赖的发布状态;失败的全量采集不得泄露半批数据。
|
||||
if entity in DETAILS:
|
||||
statement(session, ctx.source, DETAILS[entity])
|
||||
cursor = 0
|
||||
while True:
|
||||
records = list(session.scalars(statement(session, ctx.source, entity, start=start)
|
||||
.where(TPlusSourceHead.id > cursor).order_by(TPlusSourceHead.id).limit(200)))
|
||||
if not records:
|
||||
break
|
||||
# record.id 不是 source_head.id(哈希去重可复用旧版本)。
|
||||
head_ids = dict(session.execute(select(TPlusSourceHead.record_id, TPlusSourceHead.id).where(
|
||||
TPlusSourceHead.source_database == ctx.source, TPlusSourceHead.entity_type == entity,
|
||||
TPlusSourceHead.record_id.in_([r.id for r in records]))).all())
|
||||
for record in records:
|
||||
try:
|
||||
with session.begin_nested():
|
||||
children = details(ctx, entity, record.source_id) if entity in DETAILS else []
|
||||
action, doc = _apply_document(ctx, entity, record, children, run.run_no, start)
|
||||
except (WorkbenchError, IntegrityError) as exc:
|
||||
ctx.targets.clear()
|
||||
ctx.recalculated.clear()
|
||||
doc = ledger(ctx, entity, record)
|
||||
status = "CONFLICT" if isinstance(exc, IntegrityError) else "PENDING"
|
||||
reason = "TARGET_UNIQUE_CONFLICT" if isinstance(exc, IntegrityError) else str(exc)
|
||||
if doc.applied_record_id is None:
|
||||
try:
|
||||
with session.begin_nested():
|
||||
_pending_document(ctx, doc, entity, record.payload_json, run.run_no, reason)
|
||||
except (WorkbenchError, IntegrityError):
|
||||
ctx.targets.clear()
|
||||
ctx.recalculated.clear()
|
||||
doc = ledger(ctx, entity, record)
|
||||
elif doc.business_id not in ctx.recalculated:
|
||||
business = session.get(Business, doc.business_id, with_for_update=True)
|
||||
if business is not None:
|
||||
_recalculate(ctx, business)
|
||||
doc.status, doc.reason = status, reason
|
||||
action = doc.status
|
||||
processed += 1
|
||||
counts[action] += 1
|
||||
session.add(TPlusWorkLine(run_no=run.run_no, line_no=processed, entity_type=entity,
|
||||
source_id=record.source_id, action=action, reason=doc.reason,
|
||||
data_json={"source_no": record.source_no, "business_id": doc.business_id, **(doc.data_json or {})}))
|
||||
run.summary_json = {"counts": dict(counts), "total": processed, "formal_payroll_written": False,
|
||||
"start_date": start.isoformat(), "phase": "RUNNING", "entity": entity}
|
||||
session.commit()
|
||||
cursor = head_ids[record.id]
|
||||
observed = statement(session, ctx.source, entity).with_only_columns(TPlusSyncRecord.source_id).subquery()
|
||||
missing = ~select(observed.c.source_id).where(observed.c.source_id == TPlusBusinessDocument.source_id).exists()
|
||||
cursor = 0
|
||||
while True:
|
||||
documents = list(session.scalars(select(TPlusBusinessDocument).where(
|
||||
TPlusBusinessDocument.source_database == ctx.source,
|
||||
TPlusBusinessDocument.entity_type == entity,
|
||||
or_(TPlusBusinessDocument.applied_record_id.is_not(None),
|
||||
TPlusBusinessDocument.business_id.is_not(None)),
|
||||
TPlusBusinessDocument.id > cursor,
|
||||
missing,
|
||||
).order_by(TPlusBusinessDocument.id).limit(200)))
|
||||
if not documents:
|
||||
break
|
||||
for doc in documents:
|
||||
doc.status, doc.reason = "CONFLICT", "SOURCE_DOCUMENT_MISSING"
|
||||
processed += 1
|
||||
counts["CONFLICT"] += 1
|
||||
session.add(TPlusWorkLine(run_no=run.run_no, line_no=processed, entity_type=entity,
|
||||
source_id=doc.source_id, action="CONFLICT", reason=doc.reason,
|
||||
data_json={"source_no": doc.source_no, "business_id": doc.business_id, **(doc.data_json or {})}))
|
||||
cursor = documents[-1].id
|
||||
run.summary_json = {"counts": dict(counts), "total": processed, "formal_payroll_written": False,
|
||||
"start_date": start.isoformat(), "phase": "RUNNING", "entity": entity}
|
||||
session.commit()
|
||||
if source_manifest(session, run.source_database) != manifest:
|
||||
raise WorkbenchError("SOURCE_REFRESH_REQUIRED")
|
||||
run.status = "PARTIAL" if counts["PENDING"] or counts["CONFLICT"] else "SUCCEEDED"
|
||||
run.summary_json = {**(run.summary_json or {}), "phase": "COMPLETED", "source_manifest": manifest}
|
||||
@@ -0,0 +1,141 @@
|
||||
"""业务接入开关与幂等排队;HTTP 不执行导入。"""
|
||||
|
||||
from uuid import NAMESPACE_URL, uuid5
|
||||
|
||||
from sqlalchemy import inspect, select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from app.core.exceptions import BadRequestError, ConflictError, ForbiddenError
|
||||
from app.models import TPlusBusinessSyncConfig, TPlusWorkRun, User
|
||||
from app.services.authorization_service import require_permission
|
||||
from app.services.tplus.business_sync import START, preview
|
||||
from app.services.tplus.task_service import history_source_database, new_batch_no
|
||||
from app.services.tplus.workbench import WorkbenchError, digest, work_out
|
||||
|
||||
|
||||
def configuration(session):
|
||||
source = history_source_database(session)
|
||||
ready = inspect(session.connection()).has_table(TPlusBusinessSyncConfig.__tablename__)
|
||||
row = session.scalar(select(TPlusBusinessSyncConfig).where(TPlusBusinessSyncConfig.source_database == source)) if ready else None
|
||||
return {"enabled": bool(row and row.enabled), "start_date": (row.start_date if row else START).isoformat(),
|
||||
"version": row.version if row else 0, "editable": ready and bool(source),
|
||||
"last_error": row.last_error if row else None}
|
||||
|
||||
|
||||
def queue(session, actor_id, start_date, request_key):
|
||||
require_permission(session, session.get(User, actor_id), "tplus:import")
|
||||
source = history_source_database(session)
|
||||
if not source:
|
||||
raise BadRequestError("请先完成 T+ 数据采集")
|
||||
key = str(request_key)
|
||||
found = session.scalar(select(TPlusWorkRun).where(TPlusWorkRun.request_key == key))
|
||||
values = {"start_date": start_date.isoformat()}
|
||||
def existing_run(found):
|
||||
if found.kind != "BUSINESS_SYNC" or found.requested_by != actor_id or found.source_database != source or found.input_json != values:
|
||||
raise BadRequestError("请求键已用于不同任务")
|
||||
return work_out(found)
|
||||
if found:
|
||||
return existing_run(found)
|
||||
run = TPlusWorkRun(run_no=new_batch_no(), request_key=key, source_database=source,
|
||||
requested_by=actor_id, kind="BUSINESS_SYNC", status="QUEUED", input_json=values)
|
||||
try:
|
||||
with session.begin_nested():
|
||||
session.add(run)
|
||||
session.flush()
|
||||
session.commit()
|
||||
except IntegrityError:
|
||||
session.rollback()
|
||||
found = session.scalar(select(TPlusWorkRun).where(TPlusWorkRun.request_key == key))
|
||||
if found is None:
|
||||
raise ConflictError() from None
|
||||
return existing_run(found)
|
||||
return work_out(run)
|
||||
|
||||
|
||||
def save(session, payload, actor_id):
|
||||
require_permission(session, session.get(User, actor_id), "tplus:import")
|
||||
source = history_source_database(session)
|
||||
if not source:
|
||||
raise BadRequestError("请先完成 T+ 数据采集")
|
||||
row = session.scalar(select(TPlusBusinessSyncConfig).where(TPlusBusinessSyncConfig.source_database == source).with_for_update())
|
||||
if (row.version if row else 0) != payload.expected_version:
|
||||
raise ConflictError()
|
||||
if row is None:
|
||||
row = TPlusBusinessSyncConfig(source_database=source, updated_by=actor_id)
|
||||
session.add(row)
|
||||
row.enabled, row.start_date = int(payload.enabled), payload.start_date
|
||||
row.version, row.updated_by, row.last_error = payload.expected_version + 1, actor_id, None
|
||||
try:
|
||||
session.commit()
|
||||
except IntegrityError:
|
||||
session.rollback()
|
||||
raise ConflictError() from None
|
||||
if row.enabled:
|
||||
schedule(session)
|
||||
return configuration(session)
|
||||
|
||||
|
||||
def schedule(session):
|
||||
"""仅成功快照触发;去重周期与配置版本绑定,不改变原采集结果。"""
|
||||
if not inspect(session.connection()).has_table(TPlusBusinessSyncConfig.__tablename__):
|
||||
return None
|
||||
source = history_source_database(session)
|
||||
config = session.scalar(select(TPlusBusinessSyncConfig).where(TPlusBusinessSyncConfig.source_database == source))
|
||||
if config is None or not config.enabled:
|
||||
return None
|
||||
try:
|
||||
require_permission(session, session.get(User, config.updated_by), "tplus:import")
|
||||
except ForbiddenError:
|
||||
config.last_error = "TASK_AUTHORIZATION_REVOKED"
|
||||
session.commit()
|
||||
return None
|
||||
from app.services.tplus.snapshots import source_manifest
|
||||
try:
|
||||
manifest = list(source_manifest(session, source).items())
|
||||
except WorkbenchError:
|
||||
config.last_error = "SOURCE_REFRESH_REQUIRED"
|
||||
session.commit()
|
||||
return None
|
||||
key = uuid5(NAMESPACE_URL, digest([source, config.version, manifest, "business-sync-v1"]))
|
||||
result = queue(session, config.updated_by, config.start_date, key)
|
||||
if result["status"] != "FAILED" and config.last_error is not None:
|
||||
config.last_error = None
|
||||
session.commit()
|
||||
return result
|
||||
|
||||
|
||||
def readonly_preview(session, start_date=START):
|
||||
return preview(session, history_source_database(session), start_date)
|
||||
|
||||
|
||||
def record_schedule_failure(session, source):
|
||||
"""保留采集成功状态,同时在接入设置中提示排队异常。"""
|
||||
config = session.scalar(select(TPlusBusinessSyncConfig).where(
|
||||
TPlusBusinessSyncConfig.source_database == source,
|
||||
TPlusBusinessSyncConfig.enabled == 1,
|
||||
).with_for_update())
|
||||
if config:
|
||||
config.last_error = "BUSINESS_SYNC_SCHEDULE_FAILED"
|
||||
session.commit()
|
||||
|
||||
|
||||
def record_authorization_failure(session, source, actor_id):
|
||||
"""仅标记仍由该授权人启用的配置,由调用方提交任务失败状态。"""
|
||||
config = session.scalar(select(TPlusBusinessSyncConfig).where(
|
||||
TPlusBusinessSyncConfig.source_database == source,
|
||||
TPlusBusinessSyncConfig.enabled == 1,
|
||||
TPlusBusinessSyncConfig.updated_by == actor_id,
|
||||
).with_for_update())
|
||||
if config:
|
||||
config.last_error = "TASK_AUTHORIZATION_REVOKED"
|
||||
|
||||
|
||||
def clear_execution_failure(session, source, actor_id):
|
||||
"""当前授权人重试成功后清除旧错误,由调用方提交任务结果。"""
|
||||
config = session.scalar(select(TPlusBusinessSyncConfig).where(
|
||||
TPlusBusinessSyncConfig.source_database == source,
|
||||
TPlusBusinessSyncConfig.enabled == 1,
|
||||
TPlusBusinessSyncConfig.updated_by == actor_id,
|
||||
).with_for_update())
|
||||
if config:
|
||||
config.last_error = None
|
||||
@@ -67,6 +67,7 @@ def _guidance(facts, product_id, line_id, month):
|
||||
|
||||
|
||||
def _shipments(session, run, key, group, facts):
|
||||
from app.services.tplus.corrections import verified_correction
|
||||
headers = _group_records(session, run.source_database, "shipments", group["shipment_ids"])
|
||||
details = source_records(
|
||||
session, run.source_database, "shipment_items", parent_ids=group["shipment_ids"]
|
||||
@@ -104,14 +105,7 @@ def _shipments(session, run, key, group, facts):
|
||||
raise WorkbenchError("GROUP_CUSTOMER_MISMATCH")
|
||||
supplement = facts.get(("SHIPMENT", record.source_id))
|
||||
supplement = supplement.data_json if supplement else {}
|
||||
correction_fact = facts.get(("CORRECTION", record.source_id)) or facts.get(
|
||||
("CORRECTION", head_record.source_id)
|
||||
)
|
||||
correction = (
|
||||
correction_fact.data_json
|
||||
if correction_fact and correction_fact.data_json.get("entity") == "SHIPMENT"
|
||||
else {}
|
||||
)
|
||||
correction = verified_correction(session, run.source_database, facts, "SHIPMENT", head_record)
|
||||
product_id = str(raw.get("idinventory"))
|
||||
product = facts.get(("PRODUCT", product_id))
|
||||
customer = facts.get(("CUSTOMER", group["customer_id"]))
|
||||
@@ -204,16 +198,12 @@ def _shipments(session, run, key, group, facts):
|
||||
|
||||
|
||||
def _receipts(session, run, key, group, facts):
|
||||
from app.services.tplus.corrections import verified_correction
|
||||
records = _group_records(session, run.source_database, "receipts", group["receipt_ids"])
|
||||
rows = []
|
||||
for record in records:
|
||||
raw = record.payload_json
|
||||
correction_fact = facts.get(("CORRECTION", record.source_id))
|
||||
correction = (
|
||||
correction_fact.data_json
|
||||
if correction_fact and correction_fact.data_json.get("entity") == "RECEIPT"
|
||||
else {}
|
||||
)
|
||||
correction = verified_correction(session, run.source_database, facts, "RECEIPT", record)
|
||||
redirect_target = correction.get("redirect_business_key")
|
||||
if str(raw.get("idpartner")) != group["customer_id"] and redirect_target != key:
|
||||
raise WorkbenchError("GROUP_CUSTOMER_MISMATCH")
|
||||
@@ -374,14 +364,31 @@ def execute_preview(session, run, *, save_lines=True, customer_scope=False):
|
||||
]
|
||||
if entity == "receipts":
|
||||
source_ids.extend(
|
||||
f.fact_key
|
||||
f.fact_key.removeprefix("RECEIPT:")
|
||||
for (kind, _), f in facts.items()
|
||||
if kind == "CORRECTION"
|
||||
and f.data_json.get("entity") == "RECEIPT"
|
||||
and f.data_json.get("redirect_business_key") == key
|
||||
and f.fact_key not in source_ids
|
||||
and f.fact_key.removeprefix("RECEIPT:") not in source_ids
|
||||
)
|
||||
group[target_field] = sorted(source_ids, key=lambda value: int(value))
|
||||
else:
|
||||
redirected = [
|
||||
f.fact_key.removeprefix("RECEIPT:")
|
||||
for (kind, _), f in facts.items()
|
||||
if kind == "CORRECTION" and f.data_json.get("entity") == "RECEIPT"
|
||||
and f.data_json.get("redirect_business_key") == key
|
||||
]
|
||||
group["receipt_ids"] = sorted(set(group["receipt_ids"] + redirected), key=int)
|
||||
|
||||
def effective_receipts(value, group_key):
|
||||
from app.services.tplus.corrections import correction_fact
|
||||
|
||||
return {
|
||||
rid for rid in value["receipt_ids"]
|
||||
if not (f := correction_fact(facts, "RECEIPT", rid))
|
||||
or f.data_json.get("redirect_business_key") == group_key
|
||||
}
|
||||
# 也检查未选组,避免同一单据在另一组被解释为独立资金。
|
||||
for (kind, other_key), other in facts.items():
|
||||
if (
|
||||
@@ -390,7 +397,7 @@ def execute_preview(session, run, *, save_lines=True, customer_scope=False):
|
||||
and not customer_scope
|
||||
and (
|
||||
set(group["shipment_ids"]) & set(other.data_json["shipment_ids"])
|
||||
or set(group["receipt_ids"]) & set(other.data_json["receipt_ids"])
|
||||
or effective_receipts(group, key) & effective_receipts(other.data_json, other_key)
|
||||
)
|
||||
):
|
||||
overlapping.add(key)
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
"""转客户更正:成对确认、原始单据保留,同一回款只归一个业务。"""
|
||||
|
||||
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
|
||||
@@ -5,6 +5,7 @@ from decimal import ROUND_HALF_UP, Decimal
|
||||
from fractions import Fraction
|
||||
|
||||
from app.services import formula
|
||||
from app.services.personnel_roles import role_on
|
||||
from app.services.rate_rules import decide
|
||||
|
||||
CENT = Decimal("0.01")
|
||||
@@ -55,6 +56,13 @@ def _beneficiary(shipment, receipt, facts, as_of):
|
||||
|
||||
def _calculate(shipment, receipt, matched, previously_matched, facts, params):
|
||||
reasons = list(shipment.get("pending", [])) + list(receipt.get("pending", []))
|
||||
if any(
|
||||
handover.get("business_key") == shipment.get("business_key")
|
||||
and handover.get("mode") == "ACTIVE"
|
||||
and not handover.get("shipping_ownership_confirmed")
|
||||
for handover in facts.get("handovers", [])
|
||||
):
|
||||
reasons.append("HANDOVER_SHIPMENTS_PENDING")
|
||||
effective = max(_day(shipment["date"]), _day(receipt["date"]))
|
||||
is_prepayment = _day(receipt["date"]) < _day(shipment["date"])
|
||||
if is_prepayment and not params.get("prepayment_parameter_basis"):
|
||||
@@ -64,6 +72,8 @@ def _calculate(shipment, receipt, matched, previously_matched, facts, params):
|
||||
owner = _beneficiary(shipment, receipt, facts.get("handovers", []), facts["as_of"])
|
||||
person = facts.get("personnel", {}).get(owner)
|
||||
original_person = facts.get("personnel", {}).get(str(shipment.get("personnel_id")))
|
||||
role = role_on(person, effective)
|
||||
original_role = role_on(original_person, _day(shipment["date"]))
|
||||
if not person:
|
||||
reasons.append("PERSONNEL_PENDING")
|
||||
elif person.get("status") == 0:
|
||||
@@ -76,6 +86,8 @@ def _calculate(shipment, receipt, matched, previously_matched, facts, params):
|
||||
)
|
||||
if not known_later_departure:
|
||||
reasons.append("HANDOVER_PENDING")
|
||||
if person and (role is None or original_role is None):
|
||||
reasons.append("PERSONNEL_ROLE_PENDING")
|
||||
cost = shipment.get("cost_total")
|
||||
if shipment.get("cost_month_basis") == "RECEIPT":
|
||||
cost = shipment.get("cost_by_month", {}).get(str(receipt["date"])[:7])
|
||||
@@ -110,10 +122,10 @@ def _calculate(shipment, receipt, matched, previously_matched, facts, params):
|
||||
matched, formula.profit_rate(matched - allocated_cost, matched), threshold
|
||||
)
|
||||
days = formula.payment_days(_day(shipment["date"]), _day(receipt["date"]))
|
||||
manager_own = bool(original_person and original_person["role_code"] == "sales_manager")
|
||||
manager_own = original_role == "sales_manager"
|
||||
rate, basis, rule = decide(
|
||||
rate_params, category=category, customer_type=customer_type,
|
||||
role_code="sales_manager" if manager_own else person["role_code"],
|
||||
role_code="sales_manager" if manager_own else role,
|
||||
days=days, prepayment=is_prepayment,
|
||||
)
|
||||
calculation_base = matched if basis == "sales" else converted
|
||||
@@ -121,6 +133,8 @@ def _calculate(shipment, receipt, matched, previously_matched, facts, params):
|
||||
"status": "READY",
|
||||
"reasons": [],
|
||||
"personnel_id": owner,
|
||||
"role_code": role,
|
||||
"original_role_code": original_role,
|
||||
"manager_id": shipment.get("manager_id"),
|
||||
"manager_pending": not bool(shipment.get("manager_id")),
|
||||
"effective_date": effective.isoformat(),
|
||||
@@ -142,7 +156,7 @@ def _calculate(shipment, receipt, matched, previously_matched, facts, params):
|
||||
"calculation_base": amount_text(calculation_base),
|
||||
"basis": basis,
|
||||
"rule": rule,
|
||||
"excluded_manager_own": manager_own or person["role_code"] == "sales_manager",
|
||||
"excluded_manager_own": manager_own or role == "sales_manager",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
"""解析已登记的月度指导成本,不按名称或单位猜测。"""
|
||||
|
||||
from datetime import date
|
||||
from decimal import Decimal, InvalidOperation
|
||||
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from app.core.exceptions import BadRequestError
|
||||
from app.models import PayrollInput, TPlusFact
|
||||
|
||||
|
||||
def latest_guidance_facts(session, source):
|
||||
"""只读取指定账套每个指导成本事实键的最新版本。"""
|
||||
ids = select(func.max(TPlusFact.id)).where(
|
||||
TPlusFact.source_database == source, TPlusFact.kind == "GUIDANCE"
|
||||
).group_by(TPlusFact.fact_key)
|
||||
return {(fact.kind, fact.fact_key): fact for fact in session.scalars(select(TPlusFact).where(TPlusFact.id.in_(ids)))}
|
||||
|
||||
|
||||
def resolve_guidance(facts, product_id, line_id, month, unit):
|
||||
"""返回匹配状态和已封存事实;存在多条候选时不选择其中一条。"""
|
||||
candidates = [
|
||||
fact.data_json for (kind, _), fact in facts.items()
|
||||
if kind == "GUIDANCE"
|
||||
and fact.data_json.get("product_id") == product_id
|
||||
and fact.data_json.get("month") == month
|
||||
and (fact.data_json.get("basis") == "UNIT" or fact.data_json.get("shipment_line_id") == line_id)
|
||||
]
|
||||
if not candidates:
|
||||
return "MISSING", None
|
||||
if len(candidates) != 1:
|
||||
return "AMBIGUOUS", None
|
||||
guidance = candidates[0]
|
||||
if guidance.get("unit") != unit:
|
||||
return "UNIT_MISMATCH", None
|
||||
if not guidance.get("is_finalized"):
|
||||
return "UNFINALIZED", None
|
||||
return "FINALIZED", guidance
|
||||
|
||||
|
||||
def check_unit_guidance_conflicts(facts, product_id, month, unit, amount, frozen_shipments):
|
||||
"""新增产品月度单价前检查重叠事实和全部相关的历史封存明细。"""
|
||||
if any(
|
||||
kind == "GUIDANCE" and fact.data_json.get("product_id") == product_id
|
||||
and fact.data_json.get("month") == month
|
||||
for (kind, _), fact in facts.items()
|
||||
):
|
||||
raise BadRequestError("指导成本存在重叠记录,请先核对")
|
||||
|
||||
try:
|
||||
expected = Decimal(str(amount))
|
||||
if not expected.is_finite() or expected < 0:
|
||||
raise ValueError
|
||||
for shipment in frozen_shipments:
|
||||
old = Decimal(str(shipment.get("unit_cost")))
|
||||
if not old.is_finite() or old < 0 or old != expected or shipment.get("unit") != unit:
|
||||
raise ValueError
|
||||
except (InvalidOperation, TypeError, ValueError):
|
||||
raise BadRequestError("已封存发货的成本或单位与指导价不一致,请先核对") from None
|
||||
|
||||
|
||||
def frozen_guidance_shipments(session, source, product_id, month):
|
||||
"""按不可变快照中的来源产品读取历史,不能依赖当前正式明细。"""
|
||||
query = (
|
||||
select(PayrollInput.data_json)
|
||||
.where(PayrollInput.source_database == source)
|
||||
.order_by(PayrollInput.id)
|
||||
.execution_options(yield_per=100)
|
||||
)
|
||||
for data in session.scalars(query):
|
||||
shipments = data.get("shipments") if isinstance(data, dict) else None
|
||||
if not isinstance(shipments, list):
|
||||
raise BadRequestError("已封存发货数据异常,请先核对")
|
||||
for shipment in shipments:
|
||||
if not isinstance(shipment, dict) or not shipment.get("line_id"):
|
||||
raise BadRequestError("已封存发货数据异常,请先核对")
|
||||
try:
|
||||
ship_date = date.fromisoformat(shipment["date"])
|
||||
except (KeyError, TypeError, ValueError):
|
||||
raise BadRequestError("已封存发货日期异常,请先核对") from None
|
||||
if ship_date.strftime("%Y-%m") != month:
|
||||
continue
|
||||
frozen_product = shipment.get("product_id")
|
||||
if frozen_product is None or str(frozen_product).strip() in ("", "None"):
|
||||
raise BadRequestError("已封存发货缺少来源产品身份,请先核对")
|
||||
if str(frozen_product) == str(product_id):
|
||||
yield shipment
|
||||
@@ -228,9 +228,12 @@ def _line(session, run, i, row, action=None, reason=None):
|
||||
source_id=row["source_id"],
|
||||
action=action or row["action"],
|
||||
reason=reason or row.get("reason"),
|
||||
data_json={
|
||||
k: v for k, v in row.items() if k not in ("entity_type", "action", "reason")
|
||||
},
|
||||
# 真实发货参照带 Decimal 数量、单价;JSON 留存使用十进制原文,
|
||||
# 与上方预览快照一致,不能转 float 或让预览任务在落明细时失败。
|
||||
data_json=json.loads(json.dumps(
|
||||
{k: v for k, v in row.items() if k not in ("entity_type", "action", "reason")},
|
||||
ensure_ascii=False, default=str,
|
||||
)),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -12,6 +12,10 @@ ACTION_LABELS = {
|
||||
"EXCLUDED": "不纳入本次预演",
|
||||
}
|
||||
REASON_LABELS = {
|
||||
"CORRECTION_PAIR_REQUIRED": "更正单尚未完成正负成对核对,请使用更正单处理入口",
|
||||
"CORRECTION_RECONFIRM_REQUIRED": "更正单或实际回款来源已变化,请重新核对更正对",
|
||||
"CORRECTION_EXCLUDED": "转客户正负更正单已成对排除,源单保留",
|
||||
"REDIRECTED_RECEIPT": "实际回款已归原业务,在本客户不重复计提",
|
||||
"CUSTOMER_SCOPE_CONFLICT": "同一客户存在不一致的期初或历史范围,请先合并为客户级核对资料",
|
||||
"GUIDANCE_FINALIZATION_REQUIRED": "发货月份指导成本尚未录入完毕并封存",
|
||||
"CUSTOMER_SCOPE_ALREADY_SELECTED": "该客户已纳入本次平账,重复选择已跳过",
|
||||
@@ -19,6 +23,12 @@ REASON_LABELS = {
|
||||
"INDEPENDENT_BUSINESS_REQUIRED": "请先按独立业务核实归属,跨业务冲款边界尚待答复",
|
||||
"BUSINESS_DATA_PENDING": "该业务仍有资料待补,请查看对应核算预演",
|
||||
"FORMAL_SOURCE_CHANGED": "源单据或人工依据与正式导入版本不同,请先核对差异",
|
||||
"SOURCE_PRICE_REVIEW_REQUIRED": "已接入单价与来源未税单价不一致,请核对原单及正式明细",
|
||||
"PENDING_SOURCE_CUSTOMER_CHANGED": "待核对单据的来源客户已变化,原关联已保留,请核对原单",
|
||||
"PENDING_SOURCE_OUT_OF_RANGE": "待核对单据的来源日期已早于接入起点,原关联已保留,请核对原单",
|
||||
"LEGACY_BUSINESS_APPLY_DISABLED": "旧规则预览不能执行,请使用自动业务接入重新生成计划",
|
||||
"ORDER_SHIPMENT_MISMATCH": "发货关联的订单明细、订单号或产品不一致,请核对来源",
|
||||
"SOURCE_DOCUMENT_MISSING": "来源单据在最新完整快照中消失,已有记录已保留,请核对",
|
||||
"SOURCE_ALREADY_IMPORTED": "该源单据已经导入其他业务,不能重复导入",
|
||||
"CUSTOMER_IMPORT_REQUIRED": "请先完成该客户的基础导入",
|
||||
"PRODUCT_IMPORT_REQUIRED": "请先完成相关产品的基础导入",
|
||||
@@ -30,7 +40,7 @@ REASON_LABELS = {
|
||||
"PRODUCT_CATEGORY_PENDING": "产品核算分类待补",
|
||||
"PRODUCT_UNIT_PENDING": "产品单位待核对",
|
||||
"NOT_CUSTOMER_ONLY": "往来单位不是仅客户性质",
|
||||
"CUSTOMER_ATTRIBUTE_PENDING": "客户属性或负责人待补",
|
||||
"CUSTOMER_ATTRIBUTE_PENDING": "客户核算分类待补",
|
||||
"TARGET_MISSING": "原导入目标已不存在",
|
||||
"TARGET_UNIQUE_CONFLICT": "与现有记录唯一键冲突",
|
||||
"DUPLICATE_SOURCE_IDENTITY": "源记录身份重复",
|
||||
@@ -50,6 +60,7 @@ REASON_LABELS = {
|
||||
"PREPAYMENT_PARAMETERS_PENDING": "预收业务采用发货日还是收款日参数待确认",
|
||||
"AUGUST_RULE_PENDING": "8月调整参数待确认",
|
||||
"PERSONNEL_PENDING": "归属人员待确认",
|
||||
"PERSONNEL_ROLE_PENDING": "该业务发生月份的历史岗位待确认",
|
||||
"CUSTOMER_CATEGORY_PENDING": "客户分类待确认",
|
||||
"PARAMETERS_PENDING": "计提参数待补",
|
||||
"NEGATIVE_OR_ZERO_DOCUMENT": "负数或零金额单据需人工核对",
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
"""页面期望状态与托管进程心跳,二者分别更新。"""
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
from sqlalchemy import inspect, select, update
|
||||
|
||||
from app.core.exceptions import BadRequestError, ConflictError
|
||||
from app.models import TPlusRuntimeControl
|
||||
from app.services.authorization_service import require_permission
|
||||
from app.services.tplus import task_service
|
||||
from app.services.tplus.automation import local_now
|
||||
|
||||
|
||||
def overview(session, *, now=None):
|
||||
result = {"desired_state": "STOPPED", "actual_state": "OFFLINE", "worker_phase": "UNKNOWN",
|
||||
"heartbeat_at": None, "last_error": None, "host": None, "platform": None,
|
||||
"version": 0, "updated_by": None, "editable": False}
|
||||
if not inspect(session.connection()).has_table(TPlusRuntimeControl.__tablename__):
|
||||
return result
|
||||
row = session.scalar(select(TPlusRuntimeControl).where(TPlusRuntimeControl.id == 1)
|
||||
.execution_options(populate_existing=True))
|
||||
if row is None:
|
||||
return result
|
||||
result.update({key: getattr(row, key) for key in result if key != "editable"})
|
||||
result["editable"] = True
|
||||
if row.heartbeat_at is None or (now or local_now()) - row.heartbeat_at > timedelta(seconds=90):
|
||||
result["actual_state"] = "OFFLINE"
|
||||
result["worker_phase"] = "UNKNOWN"
|
||||
return result
|
||||
|
||||
|
||||
def save(session, payload, actor):
|
||||
require_permission(session, actor.user, "tplus:collect")
|
||||
if not inspect(session.connection()).has_table(TPlusRuntimeControl.__tablename__):
|
||||
raise BadRequestError("读取服务管理尚未升级,请联系维护人员")
|
||||
with task_service._audit(session, actor, "修改读取服务状态", payload.model_dump()):
|
||||
changed = session.execute(update(TPlusRuntimeControl).where(
|
||||
TPlusRuntimeControl.id == 1,
|
||||
TPlusRuntimeControl.version == payload.expected_version,
|
||||
).values(desired_state=payload.desired_state, version=payload.expected_version + 1,
|
||||
updated_by=actor.user.id))
|
||||
if changed.rowcount != 1:
|
||||
session.rollback()
|
||||
raise ConflictError()
|
||||
session.commit()
|
||||
return overview(session)
|
||||
@@ -0,0 +1,226 @@
|
||||
"""来源订单与发货人工关系;仅追加结论,不修改来源或封存数据。"""
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from app.core.exceptions import BadRequestError
|
||||
from app.core.tplus_lock import TPlusBusyError, sync_lock
|
||||
from app.models import (
|
||||
Business,
|
||||
BusinessItem,
|
||||
Shipment,
|
||||
ShipmentItem,
|
||||
TPlusBusinessDocument,
|
||||
TPlusFact,
|
||||
)
|
||||
from app.services.audit_service import AuditRecord, audit
|
||||
from app.services.tplus.workbench import digest
|
||||
|
||||
KIND = "SALES_RELATION"
|
||||
|
||||
|
||||
def item_revision(item):
|
||||
return digest([item.id, item.business_id, item.product_id, item.product_name, item.spec_model,
|
||||
item.unit, str(item.quantity), str(item.sale_price), str(item.sale_amount)])
|
||||
|
||||
|
||||
def document_revision(doc):
|
||||
return [doc.id, doc.entity_type, doc.source_id, doc.record_id, doc.applied_record_id,
|
||||
doc.fingerprint, doc.status, doc.business_id, doc.target_id]
|
||||
|
||||
|
||||
def shipment_revision(doc, head, shipment, line):
|
||||
return digest([document_revision(doc), document_revision(head), shipment.id,
|
||||
shipment.business_id, shipment.ship_date.isoformat(), line.id, line.product_id,
|
||||
str(line.quantity), str(line.sale_price), str(line.amount)])
|
||||
|
||||
|
||||
def order_revision(doc, head, item):
|
||||
return digest([document_revision(doc), document_revision(head), item_revision(item)])
|
||||
|
||||
|
||||
def _head(session, doc, entity, target_id=None, *, lock=True):
|
||||
parent = doc.data_json.get("source_parent_id")
|
||||
query = select(TPlusBusinessDocument).where(
|
||||
TPlusBusinessDocument.source_database == doc.source_database,
|
||||
TPlusBusinessDocument.business_id == doc.business_id,
|
||||
TPlusBusinessDocument.entity_type == entity,
|
||||
TPlusBusinessDocument.status == "APPLIED",
|
||||
TPlusBusinessDocument.source_id == str(parent),
|
||||
)
|
||||
if target_id is not None:
|
||||
query = query.where(TPlusBusinessDocument.target_id == target_id)
|
||||
head = session.scalar(query.with_for_update() if lock else query)
|
||||
if head is None or head.record_id != head.applied_record_id:
|
||||
raise BadRequestError("来源单据已变化,请刷新后核对")
|
||||
return head
|
||||
|
||||
|
||||
def _applied_document(session, document_id, business_id, entity, source, *, lock=True):
|
||||
doc = session.get(TPlusBusinessDocument, document_id, with_for_update=lock)
|
||||
if (doc is None or doc.business_id != business_id or doc.source_database != source
|
||||
or doc.entity_type != entity or doc.status != "APPLIED"
|
||||
or doc.record_id != doc.applied_record_id):
|
||||
raise BadRequestError("来源明细已变化,请刷新后核对")
|
||||
return doc
|
||||
|
||||
|
||||
def _latest_business_facts(session, source, business_id):
|
||||
ids = select(func.max(TPlusFact.id)).where(
|
||||
TPlusFact.source_database == source, TPlusFact.kind == KIND,
|
||||
TPlusFact.fact_key.like(f"sales:{business_id}:%"),
|
||||
).group_by(TPlusFact.fact_key)
|
||||
return list(session.scalars(select(TPlusFact).where(TPlusFact.id.in_(ids))))
|
||||
|
||||
|
||||
def _fact_is_current(session, business, item, fact):
|
||||
"""任何来源身份、版本或业务明细变化均使结论失效。"""
|
||||
data = fact.data_json
|
||||
try:
|
||||
if (data["business_id"] != business.id or data["item_id"] != item.id
|
||||
or data["item_revision"] != item_revision(item)):
|
||||
return False
|
||||
source = business.integration_json["source_database"]
|
||||
doc = _applied_document(session, data["shipment_document_id"], business.id, "shipment_items", source, lock=False)
|
||||
line = session.get(ShipmentItem, doc.target_id)
|
||||
shipment = session.get(Shipment, line.shipment_id) if line else None
|
||||
if (line is None or shipment is None or shipment.business_id != business.id
|
||||
or line.product_id != item.product_id or line.quantity != item.quantity or line.amount != item.sale_amount):
|
||||
return False
|
||||
head = _head(session, doc, "shipments", shipment.id, lock=False)
|
||||
if shipment_revision(doc, head, shipment, line) != data["shipment_revision"]:
|
||||
return False
|
||||
if data["disposition"] == "INDEPENDENT":
|
||||
return data.get("order_document_id") is None and data.get("order_revision") is None
|
||||
if data["disposition"] != "ORDER":
|
||||
return False
|
||||
order = _applied_document(session, data["order_document_id"], business.id, "business_items", source, lock=False)
|
||||
order_item = session.get(BusinessItem, order.target_id)
|
||||
if order_item is None or order_item.business_id != business.id or order_item.product_id != item.product_id:
|
||||
return False
|
||||
order_head = _head(session, order, "businesses", lock=False)
|
||||
return order_revision(order, order_head, order_item) == data["order_revision"]
|
||||
except (BadRequestError, KeyError, TypeError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
def resolve_items(session, business, items, order_ids):
|
||||
"""返回待核对状态和可计入销售额的明细,不猜测来源关系。"""
|
||||
orders = set(order_ids)
|
||||
if not orders:
|
||||
return False, items
|
||||
extra = [item for item in items if item.id not in orders]
|
||||
if not extra:
|
||||
return False, items
|
||||
facts = {fact.fact_key: fact for fact in _latest_business_facts(
|
||||
session, business.integration_json["source_database"], business.id)}
|
||||
included = [item for item in items if item.id in orders]
|
||||
used = set()
|
||||
for item in extra:
|
||||
fact = facts.get(f"sales:{business.id}:{item.id}")
|
||||
if fact is None or not _fact_is_current(session, business, item, fact):
|
||||
return True, []
|
||||
doc_id = fact.data_json["shipment_document_id"]
|
||||
if doc_id in used:
|
||||
return True, []
|
||||
used.add(doc_id)
|
||||
if fact.data_json["disposition"] == "INDEPENDENT":
|
||||
included.append(item)
|
||||
return False, included
|
||||
|
||||
|
||||
def confirm(session, business_id, payload, operator, meta=None):
|
||||
"""绑定明确选择及版本;调用者须具备业务更新和核算准备权限。"""
|
||||
record = AuditRecord(module="业务管理", action="核对订单发货关系", summary=f"业务 #{business_id} 来源关系",
|
||||
request_data=payload.model_dump(mode="json"))
|
||||
try:
|
||||
with audit(session, operator=operator, meta=meta, record=record), sync_lock(session):
|
||||
business = session.get(Business, business_id, with_for_update=True)
|
||||
if business is None or not business.integration_json:
|
||||
raise BadRequestError("仅可核对来源业务")
|
||||
source = business.integration_json["source_database"]
|
||||
item = session.get(BusinessItem, payload.item_id, with_for_update=True)
|
||||
if item is None or item.business_id != business_id or item_revision(item) != payload.item_revision:
|
||||
raise BadRequestError("业务明细已变化,请刷新后核对")
|
||||
existing_order = session.scalar(select(TPlusBusinessDocument.id).where(
|
||||
TPlusBusinessDocument.business_id == business_id,
|
||||
TPlusBusinessDocument.entity_type == "business_items",
|
||||
TPlusBusinessDocument.target_id == item.id,
|
||||
).limit(1))
|
||||
if existing_order is not None:
|
||||
raise BadRequestError("订单明细不能作为待核对发货明细")
|
||||
doc = _applied_document(session, payload.shipment_document_id, business_id, "shipment_items", source)
|
||||
line = session.get(ShipmentItem, doc.target_id, with_for_update=True)
|
||||
shipment = session.get(Shipment, line.shipment_id, with_for_update=True) if line else None
|
||||
if (line is None or shipment is None or shipment.business_id != business_id
|
||||
or line.product_id != item.product_id or line.quantity != item.quantity or line.amount != item.sale_amount):
|
||||
raise BadRequestError("发货明细与业务明细不一致,请重新核对")
|
||||
head = _head(session, doc, "shipments", shipment.id)
|
||||
if shipment_revision(doc, head, shipment, line) != payload.shipment_revision:
|
||||
raise BadRequestError("来源发货已变化,请刷新后核对")
|
||||
order = order_head = order_item = None
|
||||
if payload.disposition == "ORDER":
|
||||
order = _applied_document(session, payload.order_document_id, business_id, "business_items", source)
|
||||
order_item = session.get(BusinessItem, order.target_id, with_for_update=True)
|
||||
if order_item is None or order_item.business_id != business_id or order_item.product_id != item.product_id:
|
||||
raise BadRequestError("订单明细不属于本业务或产品不一致")
|
||||
order_head = _head(session, order, "businesses")
|
||||
if order_revision(order, order_head, order_item) != payload.order_revision:
|
||||
raise BadRequestError("来源订单已变化,请刷新后核对")
|
||||
facts = _latest_business_facts(session, source, business_id)
|
||||
key = f"sales:{business_id}:{item.id}"
|
||||
current = next((fact for fact in facts if fact.fact_key == key), None)
|
||||
if (current.version if current else 0) != payload.expected_version:
|
||||
raise BadRequestError("核对结论已变化,请刷新后重试")
|
||||
if any(fact.fact_key != key and fact.data_json.get("disposition") != "RELEASED"
|
||||
and fact.data_json.get("shipment_document_id") == doc.id for fact in facts):
|
||||
raise BadRequestError("该发货明细已被其他业务明细确认")
|
||||
fact = TPlusFact(source_database=source, kind=KIND, fact_key=key,
|
||||
version=payload.expected_version + 1, evidence=payload.evidence, requested_by=operator.id,
|
||||
data_json={"business_id": business_id, "item_id": item.id,
|
||||
"shipment_document_id": doc.id, "order_document_id": order.id if order else None,
|
||||
"disposition": payload.disposition, "item_revision": payload.item_revision,
|
||||
"shipment_revision": payload.shipment_revision, "order_revision": payload.order_revision})
|
||||
session.add(fact)
|
||||
session.flush()
|
||||
from app.services.tplus.business_sync import _refresh_business_totals
|
||||
|
||||
_refresh_business_totals(session, business)
|
||||
session.commit()
|
||||
return {"item_id": item.id, "version": fact.version}
|
||||
except (TPlusBusyError, IntegrityError):
|
||||
session.rollback()
|
||||
raise BadRequestError("来源数据或核对结论正在更新,请稍后重试") from None
|
||||
|
||||
|
||||
def release(session, business_id, payload, operator, meta=None):
|
||||
"""追加撤销记录,释放发货占用,同时保留既往核对依据。"""
|
||||
record = AuditRecord(module="业务管理", action="撤销订单发货关系", summary=f"业务 #{business_id} 来源关系",
|
||||
request_data=payload.model_dump(mode="json"))
|
||||
try:
|
||||
with audit(session, operator=operator, meta=meta, record=record), sync_lock(session):
|
||||
business = session.get(Business, business_id, with_for_update=True)
|
||||
if business is None or not business.integration_json:
|
||||
raise BadRequestError("仅可核对来源业务")
|
||||
source = business.integration_json["source_database"]
|
||||
key = f"sales:{business_id}:{payload.item_id}"
|
||||
current = next((fact for fact in _latest_business_facts(session, source, business_id)
|
||||
if fact.fact_key == key), None)
|
||||
if current is None or current.version != payload.expected_version:
|
||||
raise BadRequestError("核对结论已变化,请刷新后重试")
|
||||
if current.data_json.get("disposition") == "RELEASED":
|
||||
raise BadRequestError("核对结论已撤销")
|
||||
fact = TPlusFact(source_database=source, kind=KIND, fact_key=key,
|
||||
version=current.version + 1, evidence=payload.evidence, requested_by=operator.id,
|
||||
data_json={"business_id": business_id, "item_id": payload.item_id,
|
||||
"disposition": "RELEASED"})
|
||||
session.add(fact)
|
||||
session.flush()
|
||||
from app.services.tplus.business_sync import _refresh_business_totals
|
||||
|
||||
_refresh_business_totals(session, business)
|
||||
session.commit()
|
||||
return {"item_id": payload.item_id, "version": fact.version}
|
||||
except (TPlusBusyError, IntegrityError):
|
||||
session.rollback()
|
||||
raise BadRequestError("来源数据或核对结论正在更新,请稍后重试") from None
|
||||
@@ -6,8 +6,8 @@ from app.models import TPlusSourceHead, TPlusSyncBatch, TPlusSyncRecord
|
||||
from app.services.tplus.workbench import WorkbenchError
|
||||
|
||||
|
||||
def source_statement(session, source, entity):
|
||||
"""只发布最后一次完整成功读取;中断覆盖的指针不得部分发布。"""
|
||||
def published_batch(session, source, entity):
|
||||
"""返回实体最后发布的完整批次;更新失败时不使用不完整的指针。"""
|
||||
batches = session.scalars(
|
||||
select(TPlusSyncBatch)
|
||||
.where(
|
||||
@@ -26,6 +26,19 @@ def source_statement(session, source, entity):
|
||||
break
|
||||
if batch_no is None:
|
||||
raise WorkbenchError("SOURCE_REFRESH_REQUIRED")
|
||||
return batch_no
|
||||
|
||||
|
||||
def source_manifest(session, source):
|
||||
"""核算与接入共同使用的完整来源批次清单,包含空实体。"""
|
||||
entities = ("personnel", "customers", "products", "businesses", "business_items",
|
||||
"shipments", "shipment_items", "receipts")
|
||||
return {entity: published_batch(session, source, entity) for entity in entities}
|
||||
|
||||
|
||||
def source_statement(session, source, entity):
|
||||
"""只发布最后一次完整成功读取;中断覆盖的指针不得部分发布。"""
|
||||
batch_no = published_batch(session, source, entity)
|
||||
return (
|
||||
select(TPlusSyncRecord)
|
||||
.join(TPlusSourceHead, TPlusSourceHead.record_id == TPlusSyncRecord.id)
|
||||
|
||||
@@ -57,3 +57,9 @@ def observe_rows(session, batch, mapping, rows):
|
||||
session.add(head)
|
||||
current[row["source_id"]] = head
|
||||
head.record_id, head.batch_no = record_id, batch.batch_no
|
||||
raw = row["payload_json"]
|
||||
head.source_parent_id = str(raw["source_parent_id"]) if raw.get("source_parent_id") is not None else None
|
||||
customer = raw.get("idcustomer", raw.get("idpartner"))
|
||||
head.source_customer_id = str(customer) if customer is not None else None
|
||||
day = raw.get("voucherdate", raw.get("voucherDate"))
|
||||
head.source_date = str(day)[:10] if day is not None else None
|
||||
@@ -0,0 +1,168 @@
|
||||
"""跨平台托管循环;单次任务在独立线程执行,停止时等待其完成。"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import platform
|
||||
import signal
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from urllib.error import URLError
|
||||
from urllib.parse import urlsplit
|
||||
from urllib.request import urlopen
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy import create_engine, select, text, update
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
from sqlalchemy.pool import NullPool
|
||||
|
||||
from app.core.tplus_db import resolve_tplus_settings
|
||||
from app.models import TPlusRuntimeControl
|
||||
from app.services.tplus.automation import local_now, tick
|
||||
from app.services.tplus.worker import run_once
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
LOCK_NAME = "ys:tplus:supervisor"
|
||||
|
||||
|
||||
def health_error(settings):
|
||||
"""只访问本机健康端点,禁止通过配置探测外部网络。"""
|
||||
try:
|
||||
url = settings.t_plus_supervisor_health_url
|
||||
parsed = urlsplit(url)
|
||||
if parsed.scheme != "http" or parsed.hostname not in ("localhost", "127.0.0.1", "::1"):
|
||||
return "后端健康检查地址必须是本机 HTTP 地址"
|
||||
with urlopen(url, timeout=3) as response:
|
||||
body = json.load(response)
|
||||
data = body.get("data") if isinstance(body, dict) else None
|
||||
if not isinstance(data, dict) or not all(data.get(key) is True for key in ("app", "db", "redis")):
|
||||
return "系统或依赖尚未就绪"
|
||||
source = resolve_tplus_settings(settings)
|
||||
try:
|
||||
import pyodbc
|
||||
except ImportError:
|
||||
return "未安装 T+ 数据库连接组件"
|
||||
if source.t_plus_odbc_driver not in pyodbc.drivers():
|
||||
return "T+ 数据库驱动不可用,请联系维护人员"
|
||||
if settings.t_plus_stop_file and Path(settings.t_plus_stop_file).exists():
|
||||
return "旧版停止标记存在,请联系维护人员处理"
|
||||
except (OSError, URLError, ValueError, RuntimeError):
|
||||
return "系统健康检查或 T+ 连接配置未就绪"
|
||||
engine = None
|
||||
try:
|
||||
engine = create_engine(
|
||||
source.t_plus_database_url, poolclass=NullPool, hide_parameters=True,
|
||||
connect_args={"timeout": source.t_plus_connect_timeout},
|
||||
)
|
||||
with engine.connect() as connection:
|
||||
connection.connection.driver_connection.timeout = source.t_plus_query_timeout
|
||||
connection.exec_driver_sql("SELECT 1")
|
||||
except (SQLAlchemyError, OSError, ValueError, RuntimeError):
|
||||
return "T+ 数据库连接不可用,请检查连接配置"
|
||||
finally:
|
||||
if engine is not None:
|
||||
engine.dispose()
|
||||
return None
|
||||
|
||||
|
||||
def may_claim(session):
|
||||
"""与页面更新同一行锁,确认停止后不再领取新任务。"""
|
||||
row = session.scalar(select(TPlusRuntimeControl).where(TPlusRuntimeControl.id == 1)
|
||||
.with_for_update().execution_options(populate_existing=True))
|
||||
return row is not None and row.desired_state == "RUNNING"
|
||||
|
||||
|
||||
def run(factory, settings, *, interval=5, stop_event=None):
|
||||
"""持有数据库命名锁期间,轮询页面设置并完成已领取的工作。"""
|
||||
stopped = stop_event or threading.Event()
|
||||
owner = str(uuid4())
|
||||
worker = None
|
||||
failures = []
|
||||
retry_after = 0.0
|
||||
last_maintenance = 0.0
|
||||
handlers = {}
|
||||
if threading.current_thread() is threading.main_thread():
|
||||
for sig in (signal.SIGINT, signal.SIGTERM):
|
||||
handlers[sig] = signal.signal(sig, lambda *_: stopped.set())
|
||||
connection = None
|
||||
acquired = False
|
||||
|
||||
def execute():
|
||||
try:
|
||||
with factory() as session:
|
||||
run_once(session, worker_owner=owner, may_claim=may_claim)
|
||||
except Exception as exc:
|
||||
logger.error("T+ 读取任务执行异常,异常类型=%s", type(exc).__name__)
|
||||
failures.append("读取服务执行失败,请检查服务日志")
|
||||
|
||||
try:
|
||||
connection = factory.kw["bind"].connect()
|
||||
if connection.dialect.name != "mysql":
|
||||
raise RuntimeError("T+ 托管服务需要 MySQL 命名锁")
|
||||
acquired = connection.execute(text("SELECT GET_LOCK(:name, 0)"), {"name": LOCK_NAME}).scalar() == 1
|
||||
if not acquired:
|
||||
logger.error("T+ 托管服务已在运行")
|
||||
return 1
|
||||
while not stopped.is_set():
|
||||
try:
|
||||
if connection.execute(text("SELECT IS_USED_LOCK(:name) = CONNECTION_ID()"),
|
||||
{"name": LOCK_NAME}).scalar() != 1:
|
||||
raise RuntimeError("托管服务锁已失效")
|
||||
with factory() as session:
|
||||
row = session.scalar(select(TPlusRuntimeControl).where(TPlusRuntimeControl.id == 1)
|
||||
.execution_options(populate_existing=True))
|
||||
if row is None:
|
||||
raise RuntimeError("读取服务控制尚未初始化")
|
||||
desired = row.desired_state
|
||||
error = health_error(settings) if desired == "RUNNING" else None
|
||||
active = worker is not None and worker.is_alive()
|
||||
if failures:
|
||||
error = failures.pop(0)
|
||||
retry_after = time.monotonic() + 30
|
||||
elif time.monotonic() < retry_after:
|
||||
error = "读取服务异常,等待重试"
|
||||
if desired == "RUNNING" and not error and not active:
|
||||
if time.monotonic() - last_maintenance >= 60:
|
||||
tick(session, settings=settings)
|
||||
last_maintenance = time.monotonic()
|
||||
worker = threading.Thread(target=execute, name="tplus-worker")
|
||||
worker.start()
|
||||
active = True
|
||||
state = ("STOPPING" if active else "STOPPED") if desired == "STOPPED" else (
|
||||
"DEGRADED" if error else "RUNNING")
|
||||
session.execute(update(TPlusRuntimeControl).where(TPlusRuntimeControl.id == 1).values(
|
||||
actual_state=state, worker_phase="RUNNING" if active else "IDLE" if state == "RUNNING" else "STOPPED",
|
||||
heartbeat_at=local_now(), last_error=error, host=socket.gethostname()[:128],
|
||||
platform=platform.system()[:32], owner=owner))
|
||||
session.commit()
|
||||
except Exception as exc:
|
||||
logger.error("T+ 托管服务检查失败,异常类型=%s", type(exc).__name__)
|
||||
# 锁或数据库状态不明时退出,绝不启动新的读取任务。
|
||||
break
|
||||
stopped.wait(interval)
|
||||
finally:
|
||||
if worker is not None:
|
||||
worker.join()
|
||||
if acquired:
|
||||
try:
|
||||
with factory() as session:
|
||||
session.execute(update(TPlusRuntimeControl).where(
|
||||
TPlusRuntimeControl.id == 1, TPlusRuntimeControl.owner == owner
|
||||
).values(actual_state="STOPPED", worker_phase="STOPPED", heartbeat_at=local_now()))
|
||||
session.commit()
|
||||
except Exception:
|
||||
logger.warning("无法记录 T+ 托管服务停止状态")
|
||||
try:
|
||||
released = connection.execute(
|
||||
text("SELECT RELEASE_LOCK(:name)"), {"name": LOCK_NAME}
|
||||
).scalar()
|
||||
if released != 1:
|
||||
connection.invalidate()
|
||||
except Exception:
|
||||
connection.invalidate()
|
||||
if connection is not None:
|
||||
connection.close()
|
||||
for sig, handler in handlers.items():
|
||||
signal.signal(sig, handler)
|
||||
return 0 if stopped.is_set() else 1
|
||||
@@ -9,15 +9,15 @@ from sqlalchemy import func, select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import defer
|
||||
|
||||
from app.core.exceptions import BadRequestError, ForbiddenError, NotFoundError
|
||||
from app.core.exceptions import BadRequestError, ConflictError, ForbiddenError, NotFoundError
|
||||
from app.core.response import PageResult
|
||||
from app.core.tplus_lock import TPlusBusyError, sync_lock
|
||||
from app.models import TPlusFact, TPlusSyncRecord, TPlusWorkLine, TPlusWorkRun
|
||||
from app.models import TPlusFact, TPlusSyncBatch, TPlusSyncRecord, TPlusWorkLine, TPlusWorkRun
|
||||
from app.schemas.tplus_workbench import WorkCreateIn
|
||||
from app.services.tplus.report_labels import ACTION_LABELS, reason_label, spreadsheet_value
|
||||
from app.services.tplus.task_service import _source_database, history_source_database, new_batch_no
|
||||
|
||||
RULE_VERSION = "2026-10-30.2"
|
||||
RULE_VERSION = "2026-11-03.2"
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -58,6 +58,8 @@ def latest_facts(session, source):
|
||||
|
||||
def save_fact(session, payload, actor_id):
|
||||
"""与导入共用锁,乐观版本不匹配时拒绝覆盖。"""
|
||||
if payload.kind == "CORRECTION":
|
||||
raise BadRequestError("更正单请使用成对处理入口,不能单独排除或修改一张源单")
|
||||
source = _work_source_database(session)
|
||||
try:
|
||||
with sync_lock(session):
|
||||
@@ -134,8 +136,7 @@ def queue_work(session, payload: WorkCreateIn, actor_id):
|
||||
values = payload.model_dump(mode="json", exclude={"request_key"})
|
||||
source = _work_source_database(session)
|
||||
key = str(payload.request_key)
|
||||
found = session.scalar(select(TPlusWorkRun).where(TPlusWorkRun.request_key == key))
|
||||
if found:
|
||||
def existing_run(found):
|
||||
if (
|
||||
found.input_json != values
|
||||
or found.source_database != source
|
||||
@@ -143,14 +144,15 @@ def queue_work(session, payload: WorkCreateIn, actor_id):
|
||||
):
|
||||
raise BadRequestError("请求键已用于不同任务")
|
||||
return work_out(found)
|
||||
found = session.scalar(select(TPlusWorkRun).where(TPlusWorkRun.request_key == key))
|
||||
if found:
|
||||
return existing_run(found)
|
||||
if payload.kind == "MASTER_APPLY":
|
||||
preview = get_run(session, payload.preview_no, source)
|
||||
if preview.kind != "MASTER_PREVIEW" or preview.status not in ("SUCCEEDED", "PARTIAL"):
|
||||
raise BadRequestError("必须选择已完成的基础导入预览")
|
||||
if payload.kind == "BUSINESS_APPLY":
|
||||
preview = get_run(session, payload.preview_no, source)
|
||||
if preview.kind != "BUSINESS_PREVIEW" or preview.status not in ("SUCCEEDED", "PARTIAL"):
|
||||
raise BadRequestError("必须选择已完成的正式业务导入预览")
|
||||
raise BadRequestError("旧规则业务预览仅供核对,请使用自动业务接入重新生成计划")
|
||||
if payload.replay_no:
|
||||
previous = get_run(session, payload.replay_no, source)
|
||||
if previous.kind != "CALC_PREVIEW" or previous.status not in ("SUCCEEDED", "PARTIAL"):
|
||||
@@ -171,7 +173,10 @@ def queue_work(session, payload: WorkCreateIn, actor_id):
|
||||
session.commit()
|
||||
except IntegrityError:
|
||||
session.rollback()
|
||||
return queue_work(session, payload, actor_id)
|
||||
found = session.scalar(select(TPlusWorkRun).where(TPlusWorkRun.request_key == key))
|
||||
if found is None:
|
||||
raise ConflictError() from None
|
||||
return existing_run(found)
|
||||
return work_out(run)
|
||||
|
||||
|
||||
@@ -225,19 +230,63 @@ def list_work(session, query):
|
||||
)
|
||||
|
||||
|
||||
def _display_data(session, run, rows):
|
||||
"""按原记录版本补齐基础档案展示字段;不改历史结果或使用最新源数据。"""
|
||||
if run.kind not in ("MASTER_PREVIEW", "MASTER_APPLY"):
|
||||
return [row.data_json for row in rows]
|
||||
record_ids = {row.data_json.get("record_id") for row in rows} - {None}
|
||||
records = {
|
||||
record.id: record
|
||||
for record in session.scalars(
|
||||
select(TPlusSyncRecord)
|
||||
.join(TPlusSyncBatch, TPlusSyncBatch.batch_no == TPlusSyncRecord.batch_no)
|
||||
.where(
|
||||
TPlusSyncRecord.id.in_(record_ids),
|
||||
TPlusSyncBatch.source_database == run.source_database,
|
||||
)
|
||||
)
|
||||
} if record_ids else {}
|
||||
roster = {}
|
||||
if any(row.reason == "ROSTER_SOURCE_MISSING" for row in rows):
|
||||
roster = {
|
||||
fact["key"]: fact["data"]
|
||||
for fact in (run.snapshot_json or {}).get("facts", [])
|
||||
if fact.get("kind") == "PERSONNEL"
|
||||
}
|
||||
result = []
|
||||
for row in rows:
|
||||
data = dict(row.data_json)
|
||||
record = records.get(data.get("record_id"))
|
||||
if record and record.entity_type == row.entity_type and record.source_id == row.source_id:
|
||||
source = record.payload_json
|
||||
fields = {"source_name": source.get("name"), "source_no": record.source_no}
|
||||
if row.entity_type == "products":
|
||||
fields.update(source_specification=source.get("specification"),
|
||||
source_unit=source.get("unit_name"))
|
||||
for key, value in fields.items():
|
||||
if data.get(key) is None and value is not None:
|
||||
data[key] = value
|
||||
elif row.reason == "ROSTER_SOURCE_MISSING":
|
||||
person = roster.get(row.source_id, {})
|
||||
data.setdefault("source_name", person.get("name"))
|
||||
data.setdefault("source_no", row.source_id)
|
||||
result.append(data)
|
||||
return result
|
||||
|
||||
|
||||
def list_lines(session, number, query):
|
||||
get_run(session, number)
|
||||
run = get_run(session, number)
|
||||
conditions = [TPlusWorkLine.run_no == number]
|
||||
if query.action:
|
||||
conditions.append(TPlusWorkLine.action == query.action)
|
||||
total = session.scalar(select(func.count()).select_from(TPlusWorkLine).where(*conditions))
|
||||
rows = session.scalars(
|
||||
rows = list(session.scalars(
|
||||
select(TPlusWorkLine)
|
||||
.where(*conditions)
|
||||
.order_by(TPlusWorkLine.line_no)
|
||||
.offset(query.offset)
|
||||
.limit(query.page_size)
|
||||
)
|
||||
))
|
||||
return PageResult(
|
||||
records=[
|
||||
{
|
||||
@@ -246,9 +295,9 @@ def list_lines(session, number, query):
|
||||
"source_id": r.source_id,
|
||||
"action": r.action,
|
||||
"reason": r.reason,
|
||||
"data": r.data_json,
|
||||
"data": data,
|
||||
}
|
||||
for r in rows
|
||||
for r, data in zip(rows, _display_data(session, run, rows), strict=True)
|
||||
],
|
||||
total=total or 0,
|
||||
page_num=query.page_num,
|
||||
@@ -319,7 +368,7 @@ def report_csv(session, run_no):
|
||||
import csv
|
||||
import io
|
||||
|
||||
get_run(session, run_no)
|
||||
run = get_run(session, run_no)
|
||||
|
||||
def rows():
|
||||
buffer = io.StringIO()
|
||||
@@ -358,46 +407,47 @@ def report_csv(session, run_no):
|
||||
.where(TPlusWorkLine.run_no == run_no)
|
||||
.order_by(TPlusWorkLine.line_no)
|
||||
)
|
||||
for row in session.scalars(statement).yield_per(200):
|
||||
data = row.data_json
|
||||
values = data.get("values") or {}
|
||||
fields = [
|
||||
run_no,
|
||||
row.line_no,
|
||||
row.entity_type,
|
||||
row.source_id,
|
||||
ACTION_LABELS.get(row.action, row.action),
|
||||
data.get("source_no", ""),
|
||||
values.get("personnel_name")
|
||||
or values.get("product_name")
|
||||
or values.get("company_name")
|
||||
or data.get("source_name")
|
||||
or "",
|
||||
*[
|
||||
data.get(k, "")
|
||||
for k in (
|
||||
"receipt_no",
|
||||
"accounting_voucher_code",
|
||||
"shipment_no",
|
||||
"amount",
|
||||
"shipment_remaining_after",
|
||||
"days",
|
||||
"allocated_cost",
|
||||
"converted_profit",
|
||||
"profit_threshold",
|
||||
"rate_percent",
|
||||
"performance",
|
||||
"personnel_id",
|
||||
)
|
||||
],
|
||||
reason_label(row.reason)
|
||||
or ("经理本人业务,已排除绩效汇总" if data.get("excluded_manager_own") else ""),
|
||||
"仅供核对,未写入正式工资",
|
||||
]
|
||||
writer.writerow([spreadsheet_value(v) for v in fields])
|
||||
yield buffer.getvalue()
|
||||
buffer.seek(0)
|
||||
buffer.truncate(0)
|
||||
for page in session.scalars(statement).partitions(200):
|
||||
for row, data in zip(page, _display_data(session, run, page), strict=True):
|
||||
values = data.get("values") or {}
|
||||
fields = [
|
||||
run_no,
|
||||
row.line_no,
|
||||
row.entity_type,
|
||||
row.source_id,
|
||||
ACTION_LABELS.get(row.action, row.action),
|
||||
data.get("source_no", ""),
|
||||
values.get("personnel_name")
|
||||
or values.get("product_name")
|
||||
or values.get("company_name")
|
||||
or data.get("source_name")
|
||||
or "",
|
||||
*[
|
||||
data.get(k, "")
|
||||
for k in (
|
||||
"receipt_no",
|
||||
"accounting_voucher_code",
|
||||
"shipment_no",
|
||||
"amount",
|
||||
"shipment_remaining_after",
|
||||
"days",
|
||||
"allocated_cost",
|
||||
"converted_profit",
|
||||
"profit_threshold",
|
||||
"rate_percent",
|
||||
"performance",
|
||||
"personnel_id",
|
||||
)
|
||||
],
|
||||
reason_label(row.reason)
|
||||
or ("已导入且数据无变化" if run.kind in ("MASTER_PREVIEW", "MASTER_APPLY") and row.action == "SKIP"
|
||||
else "经理本人业务,已排除绩效汇总" if data.get("excluded_manager_own") else ""),
|
||||
"仅供核对,未写入正式工资",
|
||||
]
|
||||
writer.writerow([spreadsheet_value(v) for v in fields])
|
||||
yield buffer.getvalue()
|
||||
buffer.seek(0)
|
||||
buffer.truncate(0)
|
||||
|
||||
return rows()
|
||||
|
||||
@@ -426,8 +476,16 @@ def execute_next(session):
|
||||
from app.services.authorization_service import require_permission
|
||||
|
||||
require_permission(session, session.get(User, run.requested_by),
|
||||
"tplus:import" if run.kind.endswith("_APPLY") else "tplus:preview")
|
||||
if run.kind.startswith("BUSINESS_"):
|
||||
"tplus:import" if run.kind.endswith("_APPLY") or run.kind == "BUSINESS_SYNC" else "tplus:preview")
|
||||
if run.kind == "BUSINESS_SYNC":
|
||||
from app.services.tplus.business_sync import execute_sync
|
||||
from app.services.tplus.business_sync_control import clear_execution_failure
|
||||
|
||||
execute_sync(session, run)
|
||||
clear_execution_failure(session, run.source_database, run.requested_by)
|
||||
elif run.kind == "BUSINESS_APPLY":
|
||||
raise WorkbenchError("LEGACY_BUSINESS_APPLY_DISABLED")
|
||||
elif run.kind.startswith("BUSINESS_"):
|
||||
from app.services.tplus.business_import import execute_business
|
||||
|
||||
execute_business(session, run)
|
||||
@@ -447,6 +505,10 @@ def execute_next(session):
|
||||
run.status, run.finished_at = "FAILED", datetime.now()
|
||||
# 不让驱动异常、SQL 或原始数据进入响应。
|
||||
run.error_code = "TASK_AUTHORIZATION_REVOKED" if isinstance(exc, ForbiddenError) else str(exc) if isinstance(exc, WorkbenchError) else "EXECUTION_ERROR"
|
||||
if run.kind == "BUSINESS_SYNC" and isinstance(exc, ForbiddenError):
|
||||
from app.services.tplus.business_sync_control import record_authorization_failure
|
||||
|
||||
record_authorization_failure(session, run.source_database, run.requested_by)
|
||||
logger.error(
|
||||
"工作区任务失败 | run_no=%s | exception_type=%s", run.run_no, type(exc).__name__
|
||||
)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""持久化任务执行者;获取连接级锁后才恢复或领取任务。"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
@@ -12,8 +13,10 @@ from app.services.tplus.errors import classify_source_error
|
||||
from app.services.tplus.relationship_audit import TPlusRelationshipAudit
|
||||
from app.services.tplus.sync_service import TPlusSyncService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def run_once(session, source_factory=None, *, worker_owner=None):
|
||||
|
||||
def run_once(session, source_factory=None, *, worker_owner=None, may_claim=None):
|
||||
"""返回本轮处理的批次号;忙锁或队列为空时不假称已执行。"""
|
||||
try:
|
||||
with sync_lock(session), WorkerHeartbeat(session, owner=worker_owner):
|
||||
@@ -23,10 +26,13 @@ def run_once(session, source_factory=None, *, worker_owner=None):
|
||||
from app.services.tplus.workbench import execute_next
|
||||
|
||||
if inspect(session.connection()).has_table(TPlusWorkRun.__tablename__):
|
||||
number = execute_next(session)
|
||||
number = execute_next(session, may_claim=may_claim) if may_claim else execute_next(session)
|
||||
if number:
|
||||
return number
|
||||
task_service.recover_running(session)
|
||||
if may_claim is not None and not may_claim(session):
|
||||
session.rollback()
|
||||
return None
|
||||
batch = repository.next_queued(session)
|
||||
if batch is None:
|
||||
session.rollback()
|
||||
@@ -67,6 +73,22 @@ def _execute(session, batch, source_factory):
|
||||
session.commit()
|
||||
else:
|
||||
TPlusSyncService(source, session).execute_claimed(batch)
|
||||
if batch.status == "SUCCEEDED":
|
||||
from app.services.tplus.business_sync_control import (
|
||||
record_schedule_failure,
|
||||
schedule,
|
||||
)
|
||||
|
||||
try:
|
||||
schedule(session)
|
||||
except Exception:
|
||||
session.rollback()
|
||||
logger.error("T+ 采集已完成,但业务自动接入排队失败")
|
||||
try:
|
||||
record_schedule_failure(session, batch.source_database)
|
||||
except Exception:
|
||||
session.rollback()
|
||||
logger.error("无法记录 T+ 业务自动接入排队异常")
|
||||
except BaseException as exc:
|
||||
session.rollback()
|
||||
task_service.fail_batch(
|
||||
|
||||
Reference in new issue
Block a user