PayrollManagementSystem/backend/app/services/tplus/business_sync.py

710 lines
40 KiB
Python
Raw Normal View History

"""将已发布的源单据接入业务;绝不以核算资料齐全作为建档条件。"""
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, operator=None):
self.session, self.source = session, source
self.operator = operator
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")
# 新月份可追加,但已生成工资月份的迟到单据必须另行核对。
from app.models import MonthlySettlement
from app.services.payroll_months import lock_month_row
month = day.strftime("%Y-%m")
control = lock_month_row(ctx.session, month, ctx.operator)
locked = ctx.session.scalar(select(MonthlySettlement.id).where(
MonthlySettlement.settlement_month == month).limit(1).with_for_update())
if locked or (control and control.generated_at):
raise WorkbenchError("PAYROLL_MONTH_LOCKED")
business = ctx.business(customer_id, day, run_no)
if business.status != 1:
raise WorkbenchError("BUSINESS_TERMINAL_REVIEW_REQUIRED")
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
operator = session.get(User, run.requested_by)
require_permission(session, operator, "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, operator), 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}