feat(核算)!: 添加 T+ 正式核算与采集托管

补齐源快照、资料预演、正式导入及工资核算的依赖链路。
新增版本化输入、逐段 FIFO、生成锁和结构化当月调整,
并提供默认关闭的周期采集、有限重试及 Windows 托管。

原始客户资料、私有配置及首页改动不纳入本次提交。

BREAKING CHANGE: 升级前需应用新增迁移,并通过
PAYROLL_MAKER_USER_ID 绑定实际工资制作人,否则业务只读。
旧单批次核算默认关闭;历史兼容需显式启用
PAYROLL_ALLOW_LEGACY_CALCULATION。生产启用和真实金额待验收。
This commit is contained in:
liuwenxin committed 2026-11-02 09:42:53 +08:00
1 parent 1b074576fe
commit 108bf87225
154 files changed
+9515 -645

No files matched your search

+21 -1
View File
@@ -93,6 +93,13 @@ def pending_statuses_for_roles(role_codes: Iterable[str]) -> tuple[int, ...]:
admin 不代办审批,但作为管理视角可查看全部在途单(与审批管理列表
的可见性一致);财务/领导只看到本节点可操作的条目。
"""
from app.core.config import get_settings
if (
not get_settings().payroll_allow_legacy_calculation
and not get_settings().payroll_approval_enabled
):
return ()
codes = set(role_codes)
if ROLE_ADMIN in codes:
return _PENDING_STATUSES
@@ -129,8 +136,14 @@ def submit_order(
落库时包一层信封并固化采集时间——审批期间业务再改,复核者看到的仍是
送审内容。开关关闭的自动通过路径同样写快照,留痕不走样。
"""
_withdraw_pending(session, order_type=order_type, ref_id=ref_id, operator=submitter)
from app.core.config import get_settings
if not get_settings().payroll_allow_legacy_calculation and (
not get_settings().payroll_approval_enabled or not is_enabled(session, order_type)
):
raise BadRequestError("审核功能未开启,不创建审批单或自动通过痕迹")
_withdraw_pending(session, order_type=order_type, ref_id=ref_id, operator=submitter)
order = ApprovalOrder(
order_no=next_no(
session,
@@ -250,6 +263,13 @@ def act(
"""
codes = set(user_repository.get_role_codes(operator))
verb = ACTION_APPROVE if payload.action == APPROVAL_ACTION_APPROVE else ACTION_REJECT
from app.core.config import get_settings
if (
not get_settings().payroll_allow_legacy_calculation
and not get_settings().payroll_approval_enabled
):
raise BadRequestError("当前工资流程无需系统审批,历史记录仅供查看")
with audit_service.audit(
session,
operator=operator,
+5 -1
View File
@@ -11,11 +11,13 @@ from datetime import UTC, datetime
from sqlalchemy.orm import Session
from app.core.config import get_settings
from app.core.exceptions import AuthError, BadRequestError, ForbiddenError
from app.core.security import create_access_token, decode_token, verify_password
from app.repositories import login_log_repository, user_repository
from app.schemas.auth import LoginOut, UserBrief
from app.services import captcha_service, login_limiter, token_blacklist
from app.services.payroll_access import can_write_payroll
logger = logging.getLogger(__name__)
@@ -143,7 +145,9 @@ def login(
access_token=token,
expires_in=expires_in,
user=UserBrief(
id=user.id, username=user.username, real_name=user.real_name, roles=role_codes
id=user.id, username=user.username, real_name=user.real_name, roles=role_codes,
can_write_payroll=can_write_payroll(user),
payroll_approval_enabled=get_settings().payroll_approval_enabled,
),
)
+18 -2
View File
@@ -299,6 +299,10 @@ def _build_allocation_rows(
余额归合同负责营销人员——超 100% 会把负责人份额算成负数,负责人
自分份额则双重计入)
"""
from app.core.config import get_settings
if items and not old_ids and not get_settings().payroll_allow_legacy_calculation:
raise BadRequestError("新核算按每人发货对应绩效,请在正式核算资料中维护发货归属,不再录入任意比例")
business = business_repository.get_by_id(session, business_id)
if business is None:
raise NotFoundError("业务不存在")
@@ -476,6 +480,10 @@ def update_business(
:raises NotFoundError: 业务不存在
:raises BadRequestError: 业务已完成或已终止 / 归属人员不合法 / 有发货或回款时换客户
"""
from app.services import payroll_months
allocation_month = payroll_months.current_month()
payroll_months.lock_month(session, allocation_month, operator)
row = business_repository.get_with_names(session, business_id, for_update=True)
business = row[0] if row else None
with audit_service.audit(
@@ -527,6 +535,12 @@ def update_business(
payload.secondary_allocations,
old_ids={a.personnel_id for a in old_allocations},
)
if _plan_changed(old_allocations, new_rows):
from app.core.config import get_settings
if not get_settings().payroll_allow_legacy_calculation:
raise BadRequestError("历史比例方案仅供查看,新分配请维护逐笔发货归属")
payroll_months.ensure_allocation_editable(session, allocation_month, operator)
if not payload.secondary_allocations:
business_repository.replace_allocations(session, business_id, [])
elif _plan_changed(old_allocations, new_rows):
@@ -539,7 +553,9 @@ def update_business(
# - 送审内容与在途单一致 → 保留原单,不撤回不重建(避免反复保存
# 刷出「撤回+新建」的审批留痕噪音)
# - 其余 → 提新单(内含撤旧建新)
if payload.secondary_allocations:
from app.core.config import get_settings
if payload.secondary_allocations and get_settings().payroll_allow_legacy_calculation:
snapshot = approval_service.allocation_content(session, business) or None
if not approval_service.pending_matches_snapshot(
session,
@@ -563,7 +579,7 @@ def update_business(
if _is_duplicate_entry(exc):
raise BadRequestError("提交冲突(版本号生成重复),请重试") from exc
raise
else:
elif get_settings().payroll_allow_legacy_calculation:
approval_service.withdraw_pending_orders(
session,
order_type=APPROVAL_TYPE_SECONDARY_ALLOCATION,
+12
View File
@@ -95,6 +95,18 @@ def calc_receipt(
本函数只做编排:锁行校验 → 模式预处理 → 输入装载 → 逐行折算 →
计提组成 → 落库,各段独立私有函数(口径修改只动对应段)。
"""
from app.core.config import get_settings
from app.services import payroll_calculation
receipt = session.get(Receipt, receipt_id)
if receipt is None:
raise BadRequestError("回款不存在")
if payroll_calculation.latest_input(session, receipt.business_id):
result = payroll_calculation.calculate(session, receipt.business_id,
receipt.receipt_date.strftime("%Y-%m"), operator=operator, meta=meta, reason=recalc_reason)
return CalcReceiptOut(ledger=_to_out(session.get(PerformanceLedger, result["ledger_ids"][0])), repeated=False)
if not get_settings().payroll_allow_legacy_calculation:
raise BadRequestError("请在业务详情的正式核算入口准备资料;旧单批次核算已停用")
is_recalc = recalc_reason is not None
action = "重算" if is_recalc else "核算"
with audit_service.audit(
+7 -1
View File
@@ -98,6 +98,11 @@ def collect_team_buckets(
"""
managers: dict[int, dict] = {}
for row in ledger_rows:
snapshot = getattr(row, "snapshot_json", None) or {}
if snapshot.get("engine") == "FIFO":
if not snapshot.get("manager_own_excluded") and snapshot.get("manager_id") is not None:
add_team_performance(managers, int(snapshot["manager_id"]), row)
continue
if row.customer_type == "manager_personal":
if include_manager_personal:
add_team_performance(managers, row.personnel_id, row)
@@ -118,7 +123,8 @@ def company_base_of(ledger_rows: list, include_manager_personal: bool) -> Decima
(
r.performance_amount or Decimal("0")
for r in ledger_rows
if include_manager_personal or r.customer_type != "manager_personal"
if not (getattr(r, "snapshot_json", None) or {}).get("manager_own_excluded")
and (include_manager_personal or r.customer_type != "manager_personal")
),
Decimal("0"),
)
+16
View File
@@ -0,0 +1,16 @@
"""工资操作按已绑定的账号身份授权,管理员和岗位角色不构成旁路。"""
from app.core.config import get_settings
from app.core.exceptions import ForbiddenError
def can_write_payroll(user) -> bool:
"""未绑定制作人时关闭业务写入;停用账号不能操作。"""
maker = get_settings().payroll_maker_user_id
return maker is not None and user is not None and user.id == maker and user.status == 1
def ensure_payroll_writer(user):
if not can_write_payroll(user):
raise ForbiddenError("仅指定的工资制作人可以修改业务数据")
return user
+420
View File
@@ -0,0 +1,420 @@
"""正式FIFO核算:按月份固化逐段计提和累计可付差额,不覆盖历史工资。"""
import copy
from collections import defaultdict
from datetime import date
from decimal import Decimal
from fractions import Fraction
from sqlalchemy import func, select
from app.core.exceptions import BadRequestError, NotFoundError
from app.core.response import PageResult
from app.models import (
Business,
BusinessRole,
PayrollAllocation,
PayrollInput,
PerformanceLedger,
Personnel,
Receipt,
Shipment,
ShipmentItem,
)
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.tplus.calculation_preview import _parameters
from app.services.tplus.fifo import _beneficiary, amount_text, money, preview_group
from app.services.tplus.workbench import digest
def latest_input(session, business_id, *, for_update=False):
statement = (
select(PayrollInput)
.where(PayrollInput.business_id == business_id)
.order_by(PayrollInput.version.desc())
.limit(1)
)
if for_update:
statement = statement.with_for_update().execution_options(populate_existing=True)
return session.scalar(statement)
def _event_key(line):
return digest([line.get("receipt_id"), line.get("shipment_id"), line.get("line_id")])
def _previous(session, business_id, month):
rows = list(
session.scalars(
select(PerformanceLedger)
.where(
PerformanceLedger.business_id == business_id,
PerformanceLedger.calc_type == 1,
PerformanceLedger.is_active == 1,
)
.order_by(PerformanceLedger.id)
.with_for_update()
.execution_options(populate_existing=True)
)
)
if any(r.settlement_month >= month for r in rows):
raise BadRequestError("该业务本月或后续月份已有核算;重算需先处理未生成工资的后续月份")
events = {}
paid = defaultdict(lambda: Decimal(0))
for row in rows:
snapshot = row.snapshot_json or {}
if snapshot.get("engine") != "FIFO":
raise BadRequestError("该业务存在旧口径台账,请先核对历史承接,不能混算")
key = snapshot["event_key"]
if snapshot.get("first_accrual"):
events[key] = snapshot["event"]
paid[key] += row.payable_amount or Decimal(0)
return events, paid
def _inputs(session, record, until):
values = copy.deepcopy(record.data_json)
ships, cash = values["shipments"], values["receipts"]
for row in cash:
if row.get("exclusion"):
continue
current = session.get(Receipt, values["maps"]["receipts"].get(str(row["source_id"])))
if (
current is None
or current.amount != money(row["amount"])
or current.receipt_date.isoformat() != row["date"]
or current.business_id != record.business_id
):
raise BadRequestError("正式回款与核算资料不一致,请重新核对资料版本")
for row in ships:
item_id = values["maps"].get("shipment_items", {}).get(str(row["line_id"]))
if item_id is not None:
item = session.get(ShipmentItem, item_id)
head = session.get(Shipment, item.shipment_id) if item else None
if (
item is None
or head is None
or item.amount != money(row["amount"])
or head.ship_date.isoformat() != row["date"]
or head.business_id != record.business_id
):
raise BadRequestError("正式发货与核算资料不一致,请重新核对资料版本")
days = {row["date"] for row in [*ships, *cash] if row["date"] <= until.isoformat()}
params = _parameters(session, days)
params["prepayment_parameter_basis"], params["august_confirmed"] = "RECEIPT", True
result = preview_group(values["group"], ships, cash, values["facts"], params, until)
if result["status"] != "SUCCEEDED":
reasons = set(result.get("reasons", []))
reasons.update(
reason
for line in result["lines"]
for reason in line.get("reasons", [])
if line["status"] == "PENDING"
)
raise BadRequestError("核算资料待补:" + ",".join(sorted(reasons)))
return values, result
def _post(
session,
business,
record,
event,
key,
month,
owner,
manager,
performance,
payable,
weight_r,
first,
version,
reason=None,
):
person = session.get(Personnel, owner)
if person is None:
raise BadRequestError("正式归属人员不存在")
role = session.get(BusinessRole, person.business_role_id)
if role is None:
raise BadRequestError("正式归属人员岗位缺失")
receipt_id = record.data_json["maps"]["receipts"].get(str(event["receipt_id"]))
if receipt_id is None:
raise BadRequestError("回款尚未正式导入")
row = PerformanceLedger(
receipt_id=receipt_id,
calc_version=version,
allocation_key=key,
is_active=1,
calc_type=1,
business_id=business.id,
business_no=business.business_no,
personnel_id=owner,
personnel_name=person.personnel_name,
role_code=role.role_code,
customer_type=event["customer_type"],
product_category=event["category"],
ship_date=date.fromisoformat(event["shipment_date"]),
receipt_date=date.fromisoformat(event["receipt_date"]),
payment_days=event["days"],
rate_text=event["rate"],
converted_profit=money(event["converted_profit"]) if first else Decimal(0),
performance_amount=performance,
payable_amount=payable,
barter_applied=0,
weight_r=weight_r,
payment_weight=formula.to_decimal(formula.payment_weight(weight_r)),
settlement_month=month,
is_probation=person.is_probation,
missing_cost=0,
calc_reason=reason,
snapshot_json={
"engine": "FIFO",
"input_id": record.id,
"event_key": key,
"event": event,
"first_accrual": first,
"manager_id": manager,
"manager_own_excluded": event.get("excluded_manager_own", False),
"payable_delta": amount_text(payable),
"rule_version": "2026-10-30.2",
"recalc_reason": reason,
},
)
session.add(row)
session.flush()
session.add(
PayrollAllocation(
ledger_id=row.id,
business_id=business.id,
line_no=1,
input_id=record.id,
amount=money(event["amount"]) if first else Decimal(0),
data_json={
**event,
"event_key": key,
"first_accrual": first,
"performance_delta": amount_text(performance),
"payable_delta": amount_text(payable),
"beneficiary_id": owner,
"manager_id": manager,
"recalc_reason": reason,
},
)
)
return row
def calculate(session, business_id, month, *, operator, meta=None, reason=None):
"""重算从指定月起级联重建尚未生成工资的月份,同一事务保存全部版本。"""
from app.services.manager_share import month_end
month_end(month)
if reason is not None and not reason.strip():
raise BadRequestError("重算原因不能为空")
with audit_service.audit(
session,
operator=operator,
record=AuditRecord(
module="绩效核算",
action="正式FIFO重算" if reason else "正式FIFO核算",
summary=f"业务 #{business_id} 核算 {month}",
request_data={"business_id": business_id, "month": month, "reason": reason},
),
meta=meta,
):
months = {month}
if reason:
months.update(
session.scalars(
select(PerformanceLedger.settlement_month).where(
PerformanceLedger.business_id == business_id,
PerformanceLedger.calc_type == 1,
PerformanceLedger.settlement_month >= month,
)
)
)
for current_month in sorted(months):
payroll_months.ensure_allocation_editable(session, current_month, operator)
business = session.get(Business, business_id, with_for_update=True)
if business is None:
raise NotFoundError("业务不存在")
record = latest_input(session, business_id, for_update=True)
if record is None:
raise BadRequestError("请先补录该业务的正式核算资料")
if reason:
old_rows = list(
session.scalars(
select(PerformanceLedger)
.where(
PerformanceLedger.business_id == business_id,
PerformanceLedger.is_active == 1,
PerformanceLedger.calc_type == 1,
PerformanceLedger.settlement_month >= month,
)
.with_for_update()
)
)
if not any(r.settlement_month == month for r in old_rows):
raise BadRequestError("该月尚无可重算台账")
if {r.settlement_month for r in old_rows} - months:
raise BadRequestError("后续月份在核算期间变化,请刷新后重试")
for old in old_rows:
if old.settlement_id is not None:
raise BadRequestError("后续月份已有工资生成,原工资保留,请走当月调整单")
old.is_active = 0
session.flush()
outputs = [
_calculate_month(
session, business, record, current_month, reason.strip() if reason else None
)
for current_month in sorted(months)
]
out = outputs[0]
out["recalculated_months"] = sorted(months) if reason else []
session.commit()
return out
def _calculate_month(session, business, record, month, reason=None):
from app.services.manager_share import month_end
business_id = business.id
until = month_end(month)
previous, paid = _previous(session, business_id, month)
values, result = _inputs(session, record, until)
active_lines = [line for line in result["lines"] if line["kind"] == "SHIPMENT"]
current = {}
for line in active_lines:
key = _event_key(line)
if key in previous:
if any(
str(line.get(field)) != str(previous[key].get(field))
for field in ("amount", "shipment_date", "receipt_date")
):
raise BadRequestError("历史分配金额或日期已变化,请通过更正或调整处理")
current[key] = previous[key]
elif line["effective_date"][:7] < month:
raise BadRequestError(f"请先完成 {line['effective_date'][:7]} 的业务核算")
else:
ship = next(s for s in values["shipments"] if str(s["line_id"]) == str(line["line_id"]))
current[key] = {
**line,
"category": ship["category"],
"customer_type": ship["customer_type"],
"source_owner": ship["personnel_id"],
"business_key": ship["business_key"],
}
if set(previous) - current.keys():
raise BadRequestError("历史已核算事件发生变化,请核对更正或调整,不可直接覆盖")
sales = sum(
(
money(s["amount"])
for s in values["shipments"]
if s["date"] <= until.isoformat() and not s.get("performance_excluded")
),
Decimal(0),
)
matched = sum((money(line["amount"]) for line in active_lines), Decimal(0))
snapshot = resolve_snapshot(session, until)
threshold = Fraction(snapshot.get("payment.start.ratio"))
weight_r = formula.clamp_weight_r(matched, sales)
start_ok = formula.start_payment_ok(matched, sales, threshold)
weight = formula.payment_weight(weight_r) if start_ok else Fraction(0)
rows = []
receipt_versions = {}
for key, event in current.items():
first = key not in previous
performance = money(event["performance"])
desired = formula.to_decimal(Fraction(performance) * weight).quantize(
Decimal("0.01"), rounding="ROUND_HALF_UP"
)
delta = desired - paid[key]
if not first and delta == 0:
continue
target = _beneficiary(
{"personnel_id": event["personnel_id"], "business_key": event["business_key"]},
{},
values["facts"].get("handovers", []),
until.isoformat(),
)
owner = values["maps"]["personnel"].get(str(target))
if owner is None:
raise BadRequestError("接手人员尚未正式导入")
source_manager = event.get("manager_id")
manager = values["maps"]["personnel"].get(str(source_manager)) if source_manager else None
if not event.get("excluded_manager_own") and not manager:
raise BadRequestError("请补录该业务在发货时的经理分管关系")
rid = values["maps"]["receipts"].get(str(event["receipt_id"]))
if rid not in receipt_versions:
receipt_versions[rid] = (
int(
session.scalar(
select(func.max(PerformanceLedger.calc_version)).where(
PerformanceLedger.receipt_id == rid
)
)
or 0
)
+ 1
)
rows.append(
_post(
session,
business,
record,
event,
key,
month,
owner,
manager,
performance if first else Decimal(0),
delta,
weight_r,
first,
receipt_versions[rid],
reason=reason,
)
)
if not rows and not reason:
raise BadRequestError("本月没有新增计提或应付差额")
for row in rows:
row.calc_reason = reason
if reason:
row.snapshot_json = {**row.snapshot_json, "recalc_reason": reason}
for rid in receipt_versions:
session.get(Receipt, rid).settlement_status = 1
session.flush()
out = {
"month": month,
"business_id": business_id,
"ledger_ids": [r.id for r in rows],
"performance": amount_text(sum((r.performance_amount for r in rows), Decimal(0))),
"payable": amount_text(sum((r.payable_amount for r in rows), Decimal(0))),
"start_payment_ok": start_ok,
}
return out
def allocation_page(session, ledger_id, query):
if session.get(PerformanceLedger, ledger_id) is None:
raise NotFoundError("台账不存在")
condition = PayrollAllocation.ledger_id == ledger_id
rows = session.scalars(
select(PayrollAllocation)
.where(condition)
.order_by(PayrollAllocation.line_no)
.offset(query.offset)
.limit(query.page_size)
)
return PageResult(
records=[
{"id": r.id, "input_id": r.input_id, "amount": str(r.amount), **r.data_json}
for r in rows
],
total=session.scalar(select(func.count()).select_from(PayrollAllocation).where(condition))
or 0,
page_num=query.page_num,
page_size=query.page_size,
)
+177
View File
@@ -0,0 +1,177 @@
"""人工业务核算准备:复用已有单据,显式确认期初、成本与发货归属。"""
from decimal import Decimal
from sqlalchemy import select
from app.core.exceptions import BadRequestError, NotFoundError
from app.models import (
Business,
BusinessItem,
BusinessRole,
Customer,
PayrollInput,
Personnel,
Receipt,
Shipment,
ShipmentItem,
)
from app.services import audit_service, payroll_months
from app.services.audit_service import AuditRecord
from app.services.payroll_calculation import latest_input
from app.services.tplus.workbench import digest
def setup(session, business_id, payload, operator, meta=None):
with audit_service.audit(
session,
operator=operator,
record=AuditRecord(
module="绩效核算",
action="核算资料版本",
summary=f"业务 #{business_id} 核算资料",
request_data=payload.model_dump(mode="json"),
),
meta=meta,
):
payroll_months.ensure_allocation_editable(session, payload.month, operator)
business = session.get(Business, business_id, with_for_update=True)
if business is None:
raise NotFoundError("业务不存在")
current = latest_input(session, business_id, for_update=True)
if current and current.source_database != "MANUAL":
raise BadRequestError("源导入业务请通过来源差异核对更新,不能用人工资料替换")
if (current.version if current else 0) != payload.expected_version:
raise BadRequestError("核算资料版本已变化,请刷新")
customer = session.get(Customer, business.customer_id)
heads = list(session.scalars(select(Shipment).where(Shipment.business_id == business_id)))
by_head = {h.id: h for h in heads}
items = list(
session.scalars(select(ShipmentItem).where(ShipmentItem.shipment_id.in_(by_head)))
)
assignments = {i.shipment_item_id: i for i in payload.shipment_ownerships}
if len(assignments) != len(payload.shipment_ownerships) or set(assignments) != {
i.id for i in items
}:
raise BadRequestError("请逐条核实全部发货明细归属,不可遗漏、重复或跨业务")
business_items = list(
session.scalars(select(BusinessItem).where(BusinessItem.business_id == business_id))
)
roles = {r.id: r.role_code for r in session.scalars(select(BusinessRole))}
people = {p.id: p for p in session.scalars(select(Personnel))}
key = business.business_no
ships = []
for item in items:
owner = assignments[item.id]
if owner.personnel_id not in people or owner.manager_id not in people:
raise BadRequestError("发货归属人员或分管经理不存在")
if roles.get(people[owner.manager_id].business_role_id) != "sales_manager":
raise BadRequestError("分管人员必须为营销经理")
candidates = [
b
for b in business_items
if (
b.product_id == item.product_id
if item.product_id
else b.product_name == item.product_name and b.spec_model == item.spec_model
)
]
if len(candidates) != 1:
raise BadRequestError("发货产品无法唯一确定核算分类,请先核对业务明细")
head = by_head[item.shipment_id]
if item.amount <= 0:
raise BadRequestError("请先补齐发货销售金额")
ships.append(
{
"source_id": str(head.id),
"line_id": str(item.id),
"source_no": head.shipment_no,
"date": head.ship_date.isoformat(),
"group_key": key,
"business_key": key,
"personnel_id": str(owner.personnel_id),
"manager_id": str(owner.manager_id),
"amount": str(item.amount),
"quantity": str(item.quantity),
"unit": item.unit,
"category": candidates[0].product_category,
"customer_type": customer.customer_type,
"cost_total": str(
(Decimal(owner.unit_cost) * item.quantity).quantize(
Decimal("0.01"), rounding="ROUND_HALF_UP"
)
),
"unit_cost": owner.unit_cost,
"cost_month_basis": "SHIPMENT",
"pending": [],
}
)
receipts = list(session.scalars(select(Receipt).where(Receipt.business_id == business_id)))
if any(r.is_barter for r in receipts):
raise BadRequestError("抵顶特殊绩效继续线下处理,不进入普通FIFO核算")
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 any(h["business_key"] != key for h in handovers):
raise BadRequestError("交接记录不属于本业务")
values = {
"group": {
"key": key,
"scope_confirmed": True,
"opening_confirmed": True,
"opening_balance": payload.opening_balance,
"historical_settlement_confirmed": True,
},
"shipments": ships,
"receipts": [
{
"source_id": str(r.id),
"source_no": r.receipt_batch_no,
"date": r.receipt_date.isoformat(),
"group_key": key,
"amount": str(r.amount),
"voucher_confirmed": True,
"pending": [],
}
for r in receipts
],
"facts": {
"personnel": {
str(p.id): {"role_code": roles.get(p.business_role_id), "status": p.status}
for p in people.values()
},
"handovers": handovers,
},
"maps": {
"receipts": {str(r.id): r.id for r in receipts},
"personnel": {str(p.id): p.id for p in people.values()},
"shipments": {str(h.id): h.id for h in heads},
"shipment_items": {str(i.id): i.id for i in items},
},
"evidence": payload.evidence,
"guidance_finalized": True,
"as_of_month": payload.month,
}
# 旧资料包含的单据金额和归属保持稳定,新增版本用于新增发货与接手事实。
record = PayrollInput(
business_id=business_id,
version=payload.expected_version + 1,
source_database="MANUAL",
group_key=key,
fingerprint=digest(values),
data_json=values,
requested_by=operator.id,
)
session.add(record)
session.flush()
result = {
"input_id": record.id,
"version": record.version,
"fingerprint": record.fingerprint,
}
session.commit()
return result
+47
View File
@@ -0,0 +1,47 @@
"""月份行锁同时保护工资生成与分配事实修改。"""
from datetime import datetime, timedelta, timezone
from sqlalchemy import select
from sqlalchemy.dialects.mysql import insert
from app.core.exceptions import BadRequestError, ForbiddenError
from app.core.month import validate_month
from app.models import MonthlySettlement, PayrollMonth
from app.services.payroll_access import ensure_payroll_writer
def current_month() -> str:
return datetime.now(timezone(timedelta(hours=8))).strftime("%Y-%m")
def lock_month(session, month, operator):
"""INSERT 的唯一键串行化首次建行,避免两个请求同时认为月份不存在。"""
ensure_payroll_writer(operator)
validate_month(month)
values = {"month": month, "maker_id": operator.id, "maker_name": operator.username}
if session.bind.dialect.name == "mysql":
stmt = insert(PayrollMonth).values(**values)
session.execute(stmt.on_duplicate_key_update(month=stmt.inserted.month))
elif session.scalar(select(PayrollMonth.id).where(PayrollMonth.month == month)) is None:
session.add(PayrollMonth(**values))
session.flush()
control = session.scalar(
select(PayrollMonth).where(PayrollMonth.month == month).with_for_update()
)
if control.generated_at is not None and control.maker_id != operator.id:
raise ForbiddenError("该月工资由其他制作人生成,不可代为修改或重新生成")
if control.generated_at is None:
control.maker_id, control.maker_name = operator.id, operator.username
return control
def ensure_allocation_editable(session, month, operator):
control = lock_month(session, month, operator)
# 兼容升级前已有月结;没有控制行不代表历史工资可以修改。
old = session.scalar(
select(MonthlySettlement.id).where(MonthlySettlement.settlement_month == month)
)
if control.generated_at is not None or old is not None:
raise BadRequestError("该月工资已生成,二次分配已锁定;差额请走当月调整单")
return control
+63 -1
View File
@@ -154,6 +154,16 @@ def department_summary(session, query) -> dict:
manager_of, manager_role, person_info = _team_relations(
session, member_map, manager_ids, adjustments
)
attributed_managers = {mid for a in adjustments.values() for mid in a.get("by_manager", {}) if mid is not None}
manager_ids = sorted(set(manager_ids) | attributed_managers)
frozen_rows = rows if any((getattr(r, "snapshot_json", None) or {}).get("engine") == "FIFO" for r in rows) or attributed_managers else None
for mid in attributed_managers:
manager_role[mid] = "sales_manager"
if frozen_rows is not None:
for row in frozen_rows:
snapshot = row.snapshot_json or {}
if snapshot.get("engine") == "FIFO" and snapshot.get("manager_id") is not None:
manager_role[int(snapshot["manager_id"])] = "sales_manager"
# 只有调整单、本月无台账行的人:补成零值成员后参与归属
_merge_adjustment_only_members(member_map, adjustments, person_info)
@@ -167,6 +177,7 @@ def department_summary(session, query) -> dict:
"include_mp": include_mp,
"share": share,
"company_base": company_base,
"frozen_rows": frozen_rows,
}
teams_all = _build_teams(session, manager_ids, context)
if query.manager_id is None:
@@ -186,7 +197,10 @@ def department_summary(session, query) -> dict:
for team in teams_all
if query.manager_id is None or team["manager_id"] == query.manager_id
]
unassigned_all = _unassigned_rows(member_map, manager_of, manager_role, adjustments, include_mp)
unassigned_all = (
_frozen_member_rows(None, context) if frozen_rows is not None
else _unassigned_rows(member_map, manager_of, manager_role, adjustments, include_mp)
)
# 「仅看某经理团队」时未分配桶不随展示:全公司无经理人员与该团队无关
unassigned = [] if query.manager_id is not None else unassigned_all
company_share_amount = _company_share(company_base, share.company_pct)
@@ -219,6 +233,47 @@ def department_summary(session, query) -> dict:
}
def _frozen_member_rows(manager_id, context):
"""按台账固化的业务分管拆成员,同一业务员可分别出现在多个团队。"""
targets = {}
source_rows = context["frozen_rows"]
for row in source_rows:
snap = row.snapshot_json or {}
if snap.get("engine") == "FIFO":
target = snap.get("manager_id") if not snap.get("manager_own_excluded") else None
else:
target = _regular_team_of(row.personnel_id, context["manager_of"], context["manager_role"])
if row.customer_type == "manager_personal":
target = row.personnel_id if context["include_mp"] else None
targets[row.id] = target
selected = [r for r in source_rows if targets[r.id] == manager_id]
members = _member_map(selected)
local_adjustments = {}
for pid, adjustment in context["adjustments"].items():
candidates = {targets[r.id] for r in source_rows if r.personnel_id == pid}
present = sorted(t for t in candidates if t is not None)
default_target = present[0] if present else (
None if candidates else _regular_team_of(pid, context["manager_of"], context["manager_role"])
)
totals = {"extra": Decimal(0), "deduct": Decimal(0)}
selected_adjustment = adjustment.get("by_manager", {}).get(manager_id, {})
for field in totals:
totals[field] += selected_adjustment.get(field, Decimal(0))
if default_target == manager_id:
totals[field] += adjustment.get("unattributed", adjustment)[field]
local_adjustments[pid] = totals
if any(totals.values()) and pid not in members:
members[pid] = context["member_map"][pid]
output = []
for pid, member in members.items():
actual = [r for r in selected if r.personnel_id == pid]
output.append(_member_row(pid, member, local_adjustments, "regular",
performance=sum((r.performance_amount or Decimal(0) for r in actual), Decimal(0)),
payable=sum((r.payable_amount or Decimal(0) for r in actual), Decimal(0)),
ledger_ids=[r.id for r in actual], include_adjust=True))
return output
def _build_teams(session, manager_ids: list[int], context: dict) -> list[dict]:
"""按经理枚举生成团队行(含零成员团队)。"""
teams = []
@@ -243,6 +298,8 @@ def _build_team(session, manager_id: int, context: dict) -> dict | None:
context["adjustments"],
context["include_mp"],
)
if context.get("frozen_rows") is not None:
member_rows = _frozen_member_rows(manager_id, context)
team_extra = sum((m["adjust_extra"] for m in member_rows), Decimal("0"))
team_deduct = sum((m["adjust_deduct"] for m in member_rows), Decimal("0"))
team_base = _dec(context["buckets"].get(manager_id, {}).get("performance", Decimal("0")))
@@ -397,12 +454,17 @@ def _adjustments_by_person(orders) -> dict[int, dict]:
"extra": Decimal("0"),
"deduct": Decimal("0"),
"personnel_name": order.personnel_name,
"by_manager": {},
"unattributed": {"extra": Decimal(0), "deduct": Decimal(0)},
},
)
if order.direction == ADJUSTMENT_DIRECTION_EXTRA:
acc["extra"] += order.amount
else:
acc["deduct"] += order.amount
attribution = getattr(order, "attribution_json", None)
bucket = acc["by_manager"].setdefault(attribution.get("manager_id"), {"extra": Decimal(0), "deduct": Decimal(0)}) if attribution else acc["unattributed"]
bucket["extra" if order.direction == ADJUSTMENT_DIRECTION_EXTRA else "deduct"] += order.amount
return by_person
+98 -3
View File
@@ -82,6 +82,9 @@ def generate(
),
meta=meta,
):
from app.services import payroll_months
control = payroll_months.lock_month(session, month, operator)
existing = settlement_repository.get_by_month(session, month)
_guard_regeneration(existing, month)
_release_previous_round(session, existing, month)
@@ -103,6 +106,29 @@ def generate(
# 出参在 commit 前固化(audit 约定:commit 后查询失败会把已生成的月结
# 记成失败审计,与库中事实相反)
if control.generated_at is None:
from datetime import datetime
control.generated_at = datetime.now()
from app.core.config import get_settings
approval_enabled = get_settings().payroll_allow_legacy_calculation or (
get_settings().payroll_approval_enabled and approval_service.is_enabled(session, 2)
)
settlement.snapshot_json = {
**settlement.snapshot_json,
"approval_required": approval_enabled,
}
if not approval_enabled:
# 2026-10-30 用户澄清:生成工资即完成,不建审批单或自动通过痕迹。
settlement.status = SETTLEMENT_STATUS_APPROVED
settlement.locked_at = control.generated_at
settlement.locked_by = control.maker_name
settlement.approval_order_no = None
settlement.snapshot_json = {
"note": "工资生成即完成并锁定,仅供查看,无需系统审核",
"approval_required": False,
}
session.flush()
detail = _detail_of(session, settlement)
session.commit()
@@ -239,6 +265,13 @@ def submit(
meta: RequestMeta | None = None,
) -> SettlementDetailOut:
"""提交审批:开关分流(关=自动通过并锁定;开=待财务复核)。"""
from app.core.config import get_settings
if (
not get_settings().payroll_allow_legacy_calculation
and not get_settings().payroll_approval_enabled
):
raise BadRequestError("工资生成后直接完成,仅供查看,无需系统审批")
with audit_service.audit(
session,
operator=operator,
@@ -456,7 +489,12 @@ def _build_manager_aggregates(session: Session, ledger_rows: list, month: str) -
if manager.business_role_id
else None
)
if role is None or role.role_code != "sales_manager":
frozen_manager = any(
(r.snapshot_json or {}).get("engine") == "FIFO"
and (r.snapshot_json or {}).get("manager_id") == manager_id
for r in ledger_rows
)
if not frozen_manager and (role is None or role.role_code != "sales_manager"):
continue # 非营销经理的上级不参与聚合
aggregates.append(_aggregate_row(session, manager, bucket, company_base, share))
return aggregates
@@ -509,6 +547,47 @@ def create_adjustment(
from app.services.no_generator import next_no
amount = _adjustment_amount(payload)
from app.core.config import get_settings
from app.models import PerformanceLedger
from app.services import payroll_months
source_id = getattr(payload, "source_ledger_id", None)
source = session.get(PerformanceLedger, source_id) if source_id else None
if source_id and (source is None or source.settlement_id is None):
raise BadRequestError("分配调整必须引用已生成工资的原台账")
if source_id:
from app.models import PayrollInput
snapshot = source.snapshot_json or {}
if source.calc_type != 1 or source.is_active != 1 or snapshot.get("engine") != "FIFO":
raise BadRequestError("分配调整必须引用有效的正式FIFO单笔台账")
original = session.get(PayrollInput, snapshot.get("input_id"))
if original is None:
raise BadRequestError("原台账的归属依据缺失")
allowed = {source.personnel_id}
latest = session.scalar(
select(PayrollInput)
.where(PayrollInput.business_id == source.business_id)
.order_by(PayrollInput.version.desc())
.limit(1)
)
values = (latest or original).data_json
business_key = snapshot.get("event", {}).get("business_key")
for handover in values.get("facts", {}).get("handovers", []):
if handover.get("business_key") == business_key:
for field in ("from_personnel_id", "to_personnel_id"):
target = (
values.get("maps", {}).get("personnel", {}).get(str(handover.get(field)))
)
if target is not None:
allowed.add(target)
if payload.personnel_id not in allowed:
raise BadRequestError("调整人员不属于该业务已登记的归属或交接范围")
adjustment_month = (
payroll_months.current_month()
if source_id or not get_settings().payroll_allow_legacy_calculation
else payload.settlement_month
)
person = session.get(Personnel, payload.personnel_id)
if person is None:
@@ -532,7 +611,16 @@ def create_adjustment(
column_name="order_no",
prefix=NO_PREFIX_ADJUSTMENT,
),
settlement_month=payload.settlement_month,
settlement_month=adjustment_month,
adjustment_type="ALLOCATION" if source_id else "GENERAL",
source_ledger_id=source_id,
attribution_json={
"business_id": source.business_id,
"manager_id": (source.snapshot_json or {}).get("manager_id"),
"original_personnel_id": source.personnel_id,
}
if source
else None,
personnel_id=payload.personnel_id,
personnel_name=person.personnel_name,
direction=payload.direction,
@@ -544,7 +632,14 @@ def create_adjustment(
session.add(order)
session.flush()
_wire_adjustment_approval(session, order, operator)
if get_settings().payroll_allow_legacy_calculation or (
get_settings().payroll_approval_enabled and approval_service.is_enabled(session, 3)
):
_wire_adjustment_approval(session, order, operator)
else:
from app.models.settlement import ADJUSTMENT_STATUS_EFFECTIVE
order.status = ADJUSTMENT_STATUS_EFFECTIVE
out = _adjustment_out(order) # 出参在 commit 前固化(audit 约定)
session.commit()
logger.info("调整单创建 | order=%s | status=%s", out["order_no"], out["status"])
+21 -2
View File
@@ -1,4 +1,4 @@
# T+ 只读同步
# T+ 读取、基础导入与核算预演
> AI 摘要:读取外部 SQL Server 的原始快照,并管理持久化任务。关键文件为 task_service、worker、sync_service、relationship_audit 和 query_service;管理员 API 为 `/api/v1/system/tplus`。
@@ -9,6 +9,9 @@
- `sync_service` 主键分页读取白名单实体,每页保存原始快照,相同源 ID 和行哈希去重。
- `relationship_audit` 执行 `db/queries/tplus/audit/queries.sql`,只返回系统字典和数量;按收款方向与单据类型核对关联。
- `query_service` 分页元数据,按需加载白名单报告;不返回原始 payload、驱动异常或源配置。
- `source_heads/snapshots` 记录全量读取的当前源版本,包含哈希去重的 A→B→A;中断任务不发布部分指针。
- `master_import` 按人员、产品、客户预览及导入;源、事实、目标变化时拒绝旧预览,保留人工核算字段。
- `workbench` 保存人工事实版本、幂等任务和逐行结果,与原 worker 共用锁;`fifo/calculation_preview` 固定快照并计算隔离预演,支持原快照重跑。
## 主要入口
@@ -20,4 +23,20 @@
## 验收限制
当前只写 MySQL 暂存与管理表,不写工资业务表、不推进时间水位。关联、成本、日期和负向事件的业务规则待客户确认。开发验收先以备份源 10.50.1600 为基准,生产完整版本后补;2022 副本结果不能替代该基准的实测。
本轮允许已确认的人员、产品、客户进入基础表;不写正式业务、发货、回款、绩效台账、月结、审批或发放记录,不推进时间水位。源库始终只读。日期、含税和人工指导成本已明确;生成凭证资格已按ARAP01来源与有效总账凭证关联核实,未审不等于未生成;平账范围、取价时点、历史余额、特殊更正仍按记录分别待补。
Docker 是当前开发主线,2008 R2 `10.50.1600` 验收后置;2022 副本不能替代同版本实测。实际导入结果、接口、缺驱动处理及启动方法见 `docs/T+基础导入与核算预演开发记录_2026-10-29.md`,字段证据见同日字段与规则差异表。
## 正式工资闭环扩展(2026-10-30,优先于上方上一阶段边界)
已新增 BUSINESS_PREVIEW/BUSINESS_APPLY,允许核实后导入正式业务、发货和回款,正式核算通过独立工资入口执行。源 SQL Server 仍始终只读。新版按同一发货客户名头统一平账、按发货月份指导成本封存;不再按合同拆组或要求重复确认成本月份。
制作人身份由现有账号ID显式配置;审核能力保留但默认关闭,关闭时工资生成直接完成且不写审批步骤。多原单更正仍待讨论。字段、接口、迁移和最终验收边界见 `docs/正式工资闭环开发记录_2026-10-30.md`,客户回执原文见 `docs/客户补充回执核对_2026-10-30.md`。
## 自动采集与样本核对(2026-10-30)
`automation.tick` 通过 `tplus-maintain --once` 排队最近到期周的九类原始采集,默认每周一北京时间 02:00 且关闭。明确断连/超时最多两次自动重试,间隔 5/15 分钟;部分失败、配置/资料错误及正式导入不自动重试。人工重试结果接入同一周期,独立心跳不受长查询阻塞。概况的 `automation` 提供状态及异常,不暴露连接凭据。
`tplus-materials --customer-id --month --connection-file --output-dir` 从恢复库只读形成样本核对包;空费用和旧初始化只能作为证据,不能推定零运费/切换余额。用户没有首轮样本人工指导成本,真实金额验收待补。
V20/V21 迁移 head 为 `21f2a3b4c5d6`,现有迁移保留,开发初始化同步。Windows 托管、默认关闭及停止边界见根目录 `scripts/windows/README.md`;本轮完整验证和未验收事项见 `docs/同步运维自动化与样本核对开发记录_2026-10-30.md`。未迁移原业务库或启用生产。
+273
View File
@@ -0,0 +1,273 @@
"""周期检查只排队源采集:稳定意图键、有限重试、独立心跳和只读异常概况。"""
import hashlib
import logging
import threading
from contextlib import contextmanager
from datetime import datetime, timedelta, timezone
from pathlib import Path
from uuid import NAMESPACE_URL, uuid4, uuid5
from sqlalchemy import func, inspect, select, text
from sqlalchemy.dialects.mysql import insert
from sqlalchemy.orm import sessionmaker
from app.core.config import get_settings
from app.core.tplus_db import resolve_tplus_settings
from app.models import TPlusAutomationState, TPlusSyncBatch, TPlusSyncError, TPlusWorkRun
from app.services.tplus import task_service
from app.services.tplus.errors import TRANSIENT_CODES
from app.services.tplus.mapping import DETAIL_MAPPINGS, TPLUS_MAPPINGS
logger = logging.getLogger(__name__)
SHANGHAI = timezone(timedelta(hours=8))
ENTITIES = tuple(sorted(m.entity_type for m in (*TPLUS_MAPPINGS, *DETAIL_MAPPINGS)))
def local_now():
return datetime.now(SHANGHAI).replace(tzinfo=None)
def source_identity(settings):
source = resolve_tplus_settings(settings)
value = f"{source.t_plus_host.casefold()}:{source.t_plus_port}/{source.t_plus_database}"
return hashlib.sha256(value.encode()).hexdigest(), source.t_plus_database
def due_period(now, weekday=0, clock="02:00"):
"""取最近一次已到期时段;只补这一个周期,不逐周补发历史任务。"""
if now.tzinfo:
now = now.astimezone(SHANGHAI).replace(tzinfo=None)
hour, minute = map(int, clock.split(":"))
day = now.date() - timedelta(days=(now.weekday() - weekday) % 7)
due = datetime.combine(day, datetime.min.time()).replace(hour=hour, minute=minute)
if due > now:
due -= timedelta(days=7)
year, week, _ = due.isocalendar()
return f"{year}-W{week:02d}", due
def intent_key(source_key, period, attempt=0):
return str(uuid5(NAMESPACE_URL, f"payroll:tplus:weekly:{source_key}:{period}:{attempt}"))
def _state(session, key, database, *, lock=False):
if lock and session.bind.dialect.name == "mysql":
stmt = insert(TPlusAutomationState).values(source_key=key, source_database=database)
session.execute(stmt.on_duplicate_key_update(source_key=stmt.inserted.source_key))
statement = select(TPlusAutomationState).where(TPlusAutomationState.source_key == key)
if lock:
statement = statement.with_for_update().execution_options(populate_existing=True)
row = session.scalar(statement)
if row is None and lock:
row = TPlusAutomationState(source_key=key, source_database=database, retry_attempts=0, worker_phase="STOPPED")
session.add(row)
session.flush()
return row
@contextmanager
def maintenance_lock(session, key):
"""维护检查独立于长采集锁;同一源的两个检查不能重复创建重试。"""
if session.bind.dialect.name != "mysql":
yield True
return
connection = session.get_bind().connect()
name = "ys:tplus:maintain:" + key[:32]
acquired = False
try:
acquired = connection.execute(text("SELECT GET_LOCK(:name,0)"), {"name": name}).scalar() == 1
yield acquired
finally:
if acquired:
try:
if connection.execute(text("SELECT RELEASE_LOCK(:name)"), {"name": name}).scalar() != 1:
connection.invalidate()
except Exception:
connection.invalidate()
connection.close()
def _has_active(session):
# 实际读取仍受原worker命名锁保护;排队检查防止周期任务积压。
for model in (TPlusSyncBatch, TPlusWorkRun):
if session.scalar(select(model.id).where(model.status.in_(("QUEUED", "RUNNING"))).limit(1)) is not None:
return True
return False
def _enqueue(session, state, period, attempt, parent=None):
values = {
"batch_no": task_service.new_batch_no(), "request_key": intent_key(state.source_key, period, attempt),
"sync_mode": "FULL", "status": "QUEUED", "source_database": state.source_database,
"entity_types_json": list(ENTITIES), "parent_batch_no": parent,
"trigger_type": "AUTO_RETRY" if attempt else "SCHEDULED", "automatic_attempt": attempt,
"source_record_count": 0, "staged_record_count": 0, "failed_record_count": 0, "skipped_record_count": 0,
}
batch, inserted = task_service._insert(session, values)
if inserted and parent:
from app.repositories.tplus_repository import increment_retry_errors
increment_retry_errors(session, parent)
state.scheduled_period = period
state.current_batch_no = batch.batch_no
state.retry_attempts = attempt
state.next_retry_at = None
if not attempt:
state.root_batch_no = batch.batch_no
return batch
def _cycle_current(session, state):
"""锁住本周期重试树;人工恢复也算本周期进度,自动次数不能被重置。"""
if not state.root_batch_no:
return None
root = session.scalar(select(TPlusSyncBatch).where(
TPlusSyncBatch.batch_no == state.root_batch_no,
).with_for_update().execution_options(populate_existing=True))
if root is None:
return None
current, frontier, seen = root, [root.batch_no], {root.batch_no}
while frontier:
children = list(session.scalars(select(TPlusSyncBatch).where(
TPlusSyncBatch.parent_batch_no.in_(frontier),
TPlusSyncBatch.source_database == state.source_database,
).order_by(TPlusSyncBatch.id).with_for_update().execution_options(populate_existing=True)))
frontier = []
for child in children:
if child.batch_no in seen:
continue
seen.add(child.batch_no)
frontier.append(child.batch_no)
state.retry_attempts = max(state.retry_attempts or 0, child.automatic_attempt or 0)
if child.id > current.id:
current = child
state.current_batch_no = current.batch_no
return current
def tick(session, *, settings=None, now=None):
settings, now = settings or get_settings(), now or local_now()
if not settings.t_plus_automation_enabled:
return {"status": "DISABLED", "queued": False}
if settings.t_plus_stop_file and Path(settings.t_plus_stop_file).exists():
return {"status": "STOP_REQUESTED", "queued": False}
key, database = source_identity(settings)
with maintenance_lock(session, key) as acquired:
if not acquired:
return {"status": "BUSY", "queued": False}
session.rollback()
state = _state(session, key, database, lock=True)
state.maintenance_at = now
period, _ = due_period(now, settings.t_plus_schedule_weekday, settings.t_plus_schedule_time)
current = _cycle_current(session, state)
if current and current.status == "SUCCEEDED":
state.last_success_at = current.finished_at
state.next_retry_at = None
if _has_active(session):
session.commit()
return {"status": "DEFERRED", "queued": False, "period": period}
batch = None
if state.scheduled_period != period:
batch = _enqueue(session, state, period, 0)
elif current and current.status == "FAILED" and (state.retry_attempts or 0) < 2:
codes = set(session.scalars(select(TPlusSyncError.error_code).where(TPlusSyncError.batch_no == current.batch_no)))
if codes and codes <= TRANSIENT_CODES and current.finished_at is not None:
delay = (5, 15)[state.retry_attempts or 0]
state.next_retry_at = current.finished_at + timedelta(minutes=delay)
if now >= state.next_retry_at:
batch = _enqueue(session, state, period, (state.retry_attempts or 0) + 1, current.batch_no)
else:
state.next_retry_at = None
elif current and current.status in ("FAILED", "PARTIAL"):
state.next_retry_at = None
session.commit()
return {"status": "QUEUED" if batch else "CHECKED", "queued": batch is not None,
"batch_no": batch.batch_no if batch else None, "period": period}
def heartbeat(session_factory, settings, owner, phase, *, now=None, claim=False):
key, database = source_identity(settings)
with session_factory() as session:
state = _state(session, key, database, lock=True)
if state.worker_owner not in (None, owner) and not claim:
session.rollback()
return
state.worker_owner, state.worker_phase = owner, phase
state.heartbeat_at = now or local_now()
session.commit()
class WorkerHeartbeat:
"""独立会话和线程写心跳,长查询不阻塞;只在worker已持采集锁时启动。"""
def __init__(self, session, phase="RUNNING", interval=30, owner=None):
self.factory = sessionmaker(session.get_bind(), expire_on_commit=False)
self.settings = get_settings()
self.owner, self.phase, self.interval = owner or str(uuid4()), phase, interval
self.stop = threading.Event()
self.thread = None
def __enter__(self):
try:
source_identity(self.settings)
with self.factory() as session:
if not inspect(session.connection()).has_table(TPlusAutomationState.__tablename__):
return self
heartbeat(self.factory, self.settings, self.owner, self.phase, claim=True)
except Exception:
logger.warning("T+心跳暂不可写,请检查迁移与运行配置")
return self
self.thread = threading.Thread(target=self._pulse, name="tplus-heartbeat", daemon=True)
self.thread.start()
return self
def _pulse(self):
while not self.stop.wait(self.interval):
try:
heartbeat(self.factory, self.settings, self.owner, self.phase)
except Exception:
logger.warning("T+进程心跳更新失败")
def __exit__(self, *_args):
self.stop.set()
if self.thread:
self.thread.join(timeout=5)
try:
heartbeat(self.factory, self.settings, self.owner, "IDLE")
except Exception:
logger.warning("T+进程状态更新失败")
def overview(session, *, settings=None, now=None):
settings, now = settings or get_settings(), now or local_now()
base = {"enabled": settings.t_plus_automation_enabled, "weekday": settings.t_plus_schedule_weekday,
"time": settings.t_plus_schedule_time, "timezone": "Asia/Shanghai", "worker_phase": "UNKNOWN",
"heartbeat_at": None, "last_success_at": None, "next_retry_at": None, "alerts": []}
base["stop_requested"] = bool(settings.t_plus_stop_file and Path(settings.t_plus_stop_file).exists())
try:
key, database = source_identity(settings)
except RuntimeError:
return base
if not inspect(session.connection()).has_table(TPlusAutomationState.__tablename__):
base["alerts"] = [{"code": "MIGRATION_REQUIRED", "message": "运行状态结构尚未升级"}]
return base
state = _state(session, key, database)
if state:
base.update(worker_phase=state.worker_phase, heartbeat_at=state.heartbeat_at,
last_success_at=state.last_success_at, next_retry_at=state.next_retry_at)
alerts = base["alerts"]
latest = session.scalar(select(TPlusSyncBatch).where(TPlusSyncBatch.source_database == database).order_by(TPlusSyncBatch.id.desc()).limit(1))
if latest and latest.status in ("FAILED", "PARTIAL"):
alerts.append({"code": "SYNC_REQUIRES_ATTENTION", "message": "最近采集失败或部分失败,请核对后处理", "batch_no": latest.batch_no})
queued = session.scalar(select(TPlusSyncBatch).where(TPlusSyncBatch.source_database == database,
TPlusSyncBatch.status == "QUEUED", TPlusSyncBatch.create_time <= now - timedelta(minutes=15)).order_by(TPlusSyncBatch.id).limit(1))
if queued:
alerts.append({"code": "QUEUE_OVERDUE", "message": "采集任务排队超过15分钟", "batch_no": queued.batch_no})
last_success = session.scalar(select(func.max(TPlusSyncBatch.finished_at)).where(TPlusSyncBatch.source_database == database, TPlusSyncBatch.status == "SUCCEEDED", TPlusSyncBatch.sync_mode == "FULL"))
base["last_success_at"] = last_success
if settings.t_plus_automation_enabled and not base["stop_requested"]:
if last_success is None or now - last_success > timedelta(days=8):
alerts.append({"code": "SYNC_STALE", "message": "尚无成功采集或超过8天未成功同步"})
if state is None or state.heartbeat_at is None or now - state.heartbeat_at > timedelta(minutes=2) or state.worker_phase == "STOPPED":
alerts.append({"code": "WORKER_OFFLINE", "message": "采集进程心跳中断,请检查托管任务"})
return base
@@ -0,0 +1,407 @@
"""已核实的独立业务从源快照进入正式单据;预览、指纹、执行分别留痕。"""
import copy
from collections import Counter
from datetime import date, datetime
from decimal import Decimal
from types import SimpleNamespace
from sqlalchemy import select
from app.models import (
Business,
BusinessItem,
PayrollInput,
Personnel,
Product,
Receipt,
Shipment,
ShipmentItem,
TPlusSourceLink,
TPlusWorkLine,
User,
)
from app.services import payroll_months
from app.services.no_generator import next_no
from app.services.payroll_access import ensure_payroll_writer
from app.services.tplus.calculation_preview import execute_preview
from app.services.tplus.fifo import money
from app.services.tplus.workbench import WorkbenchError, digest, get_run
def _link(session, source, entity, source_id):
return session.scalar(
select(TPlusSourceLink).where(
TPlusSourceLink.source_database == source,
TPlusSourceLink.entity_type == entity,
TPlusSourceLink.source_id == str(source_id),
)
)
def _latest_input(session, business_id):
return session.scalar(
select(PayrollInput)
.where(PayrollInput.business_id == business_id)
.order_by(PayrollInput.version.desc())
.limit(1)
)
def _append_compatible(old, new):
"""只能新增单据、补充事实;既有单据金额、日期与原发货归属变化进入差异处理。"""
for entity, fields, identity in (
(
"shipments",
(
"date",
"amount",
"quantity",
"product_id",
"unit",
"personnel_id",
"business_key",
"performance_excluded",
"correction_pair_key",
),
lambda r: (r["source_id"], r["line_id"]),
),
(
"receipts",
("date", "amount", "voucher_confirmed", "exclusion", "redirect_business_key"),
lambda r: r["source_id"],
),
):
current = {identity(r): r for r in new[entity]}
for previous in old[entity]:
candidate = current.get(identity(previous))
if candidate is None or any(
str(previous.get(f)) != str(candidate.get(f)) for f in fields
):
return False
return all(
old["group"].get(k) == new["group"].get(k)
for k in ("opening_balance", "excluded_receipt_ids", "customer_id")
)
def _target_state(session, link):
if link is None:
return None
business = session.get(Business, link.target_id)
if business is None:
raise WorkbenchError("BUSINESS_TARGET_MISSING")
tables = (
(Business, Business.id == business.id),
(BusinessItem, BusinessItem.business_id == business.id),
(Shipment, Shipment.business_id == business.id),
(Receipt, Receipt.business_id == business.id),
)
result = {}
for model, condition in tables:
result[model.__tablename__] = [
{c.name: str(getattr(r, c.name)) for c in model.__table__.columns}
for r in session.scalars(select(model).where(condition).order_by(model.id))
]
return result
def _build(session, run):
preview = SimpleNamespace(input_json=run.input_json, source_database=run.source_database)
execute_preview(session, preview, save_lines=False, customer_scope=True)
frozen = preview.snapshot_json
rows = []
targets = {}
seen_customers = set()
for key, values in frozen["calculation_inputs"].items():
summary = preview.summary_json["groups"][key]
row = {"entity_type": "businesses", "source_id": key, "action": "PENDING", "reason": None}
rows.append(row)
if summary["status"] not in ("READY", "SUCCEEDED"):
row["reason"] = ",".join(summary.get("reasons", [])) or "BUSINESS_DATA_PENDING"
continue
customer_id = values["group"]["customer_id"]
if customer_id in seen_customers:
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"]):
row["reason"] = "GUIDANCE_FINALIZATION_REQUIRED"
continue
business_key = "customer:" + customer_id
row["business_key"] = business_key
row["input_hash"] = digest(values)
link = _link(session, run.source_database, "businesses", business_key)
targets[key] = _target_state(session, link)
if link:
current = _latest_input(session, link.target_id)
row["target_id"] = link.target_id
if current and current.fingerprint == row["input_hash"]:
row["action"] = "SKIP"
elif current and _append_compatible(current.data_json, values):
row["action"] = "UPDATE"
else:
row["action"], row["reason"] = "CONFLICT", "FORMAL_SOURCE_CHANGED"
else:
row["action"] = "CREATE"
for entity, ids in (
("shipments", values["group"]["shipment_ids"]),
("receipts", values["group"]["receipt_ids"]),
):
for source_id in ids:
linked = _link(session, run.source_database, entity, source_id)
if linked and (
not link
or not current
or source_id not in current.data_json["maps"].get(entity, {})
):
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"]:
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}
return rows, frozen, digest(frozen)
def _add_link(session, source, entity, source_id, target):
session.add(
TPlusSourceLink(
source_database=source,
entity_type=entity,
source_id=str(source_id),
target_id=target.id,
)
)
def _new_no(session, model, column, prefix):
return next_no(session, model=model, column_name=column, prefix=prefix)
def _create(session, run, row, frozen):
values = copy.deepcopy(frozen["calculation"]["calculation_inputs"][row["source_id"]])
people = {}
for code in values["facts"]["personnel"]:
candidates = list(
session.scalars(
select(Personnel)
.join(TPlusSourceLink, TPlusSourceLink.target_id == Personnel.id)
.where(
TPlusSourceLink.source_database == run.source_database,
TPlusSourceLink.entity_type == "personnel",
Personnel.source_no == code,
)
)
)
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"))
for handover in values["facts"]["handovers"]:
needed.update((str(handover["from_personnel_id"]), str(handover["to_personnel_id"])))
if needed - people.keys():
raise WorkbenchError("PERSONNEL_IMPORT_REQUIRED")
customer = _link(session, run.source_database, "customers", values["group"]["customer_id"])
old_input = _latest_input(session, row["target_id"]) if row.get("target_id") else None
business = (
session.get(Business, row["target_id"], with_for_update=True)
if row.get("target_id")
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"]),
status=1,
is_special=0,
t_plus_id=row["business_key"],
source_no=row["business_key"],
sync_batch_no=run.run_no,
synced_at=datetime.now(),
)
)
session.add(business)
session.flush()
if old_input is None:
_add_link(session, run.source_database, "businesses", row["business_key"], business)
maps = (
copy.deepcopy(old_input.data_json["maps"])
if old_input
else {"shipments": {}, "shipment_items": {}, "receipts": {}, "personnel": {}}
)
maps["personnel"].update({code: p.id for code, p in people.items()})
freight_total, sales_total, cost_total = Decimal(0), Decimal(0), Decimal(0)
for source_id in values["group"]["shipment_ids"]:
lines = [s for s in values["shipments"] if s["source_id"] == source_id]
if not lines:
continue
if any(s.get("performance_excluded") for s in lines):
values.setdefault("excluded_corrections", []).append(
{"shipment_id": source_id, "lines": lines}
)
continue
head = lines[0]
freight = sum((money(s.get("freight_amount") or "0") for s in lines), Decimal(0))
if source_id in maps["shipments"]:
existing = session.get(Shipment, maps["shipments"][source_id], with_for_update=True)
if (
existing is None
or existing.total_amount != sum((money(s["amount"]) for s in lines), Decimal(0))
or existing.ship_date.isoformat() != head["date"]
):
raise WorkbenchError("FORMAL_TARGET_CHANGED")
sales_total += existing.total_amount
freight_total += existing.freight_amount
cost_total += sum(
(money(s["cost_total"]) for s in lines if s.get("cost_total") is not None),
Decimal(0),
)
continue
shipment = Shipment(
business_id=business.id,
shipment_no=_new_no(session, Shipment, "shipment_no", "SH"),
ship_date=date.fromisoformat(head["date"]),
total_amount=sum((money(s["amount"]) for s in lines), Decimal(0)),
freight_amount=freight,
source_no=head["source_no"],
t_plus_id=source_id,
sync_batch_no=run.run_no,
synced_at=datetime.now(),
)
session.add(shipment)
session.flush()
maps["shipments"][source_id] = shipment.id
_add_link(session, run.source_database, "shipments", source_id, shipment)
for ship in lines:
product = session.get(
Product,
_link(session, run.source_database, "products", ship["product_id"]).target_id,
)
quantity = money(ship["quantity"])
amount = money(ship["amount"])
cost = money(ship["cost_total"]) if ship.get("cost_total") is not None else None
item = ShipmentItem(
shipment_id=shipment.id,
product_id=product.id,
product_name=product.product_name,
spec_model=product.spec_model or "",
unit=ship["unit"],
quantity=quantity,
sale_price=(amount / quantity).quantize(Decimal("0.0001")),
amount=amount,
)
session.add(item)
session.flush()
maps["shipment_items"][ship["line_id"]] = item.id
session.add(
BusinessItem(
business_id=business.id,
product_id=product.id,
product_name=product.product_name,
spec_model=product.spec_model or "",
unit=ship["unit"],
quantity=quantity,
sale_price=item.sale_price,
sale_amount=amount,
cost_amount=cost,
product_category=ship["category"],
)
)
sales_total += amount
cost_total += cost or Decimal(0)
freight_total += freight
received = Decimal(0)
for cash in values["receipts"]:
if cash.get("exclusion"):
continue
if cash["source_id"] in maps["receipts"]:
existing = session.get(
Receipt, maps["receipts"][cash["source_id"]], with_for_update=True
)
if (
existing is None
or existing.amount != money(cash["amount"])
or existing.receipt_date.isoformat() != cash["date"]
):
raise WorkbenchError("FORMAL_TARGET_CHANGED")
received += existing.amount
continue
receipt = Receipt(
business_id=business.id,
receipt_batch_no=_new_no(session, Receipt, "receipt_batch_no", "HK"),
receipt_date=date.fromisoformat(cash["date"]),
amount=money(cash["amount"]),
is_barter=0,
settlement_status=0,
source_no=cash["source_no"],
t_plus_id=cash["source_id"],
sync_batch_no=run.run_no,
synced_at=datetime.now(),
)
session.add(receipt)
session.flush()
maps["receipts"][cash["source_id"]] = receipt.id
_add_link(session, run.source_database, "receipts", cash["source_id"], receipt)
received += receipt.amount
business.total_sale_amount = sales_total
business.total_ship_amount = sales_total
business.total_cost_amount = cost_total
business.total_received_amount = received
business.total_freight_amount = freight_total
values["maps"] = maps
values["source_facts"] = frozen["calculation"]["facts"]
session.add(
PayrollInput(
business_id=business.id,
version=old_input.version + 1 if old_input else 1,
source_database=run.source_database,
group_key=row["source_id"],
fingerprint=row["input_hash"],
data_json=values,
requested_by=run.requested_by,
)
)
row["target_id"] = business.id
def execute_business(session, run):
operator = session.get(User, run.requested_by)
ensure_payroll_writer(operator)
request = run
if run.kind == "BUSINESS_APPLY":
request = get_run(session, run.input_json["preview_no"], run.source_database)
if request.kind != "BUSINESS_PREVIEW" or request.status not in ("SUCCEEDED", "PARTIAL"):
raise WorkbenchError("PREVIEW_REQUIRED")
payroll_months.ensure_allocation_editable(session, request.input_json["month"], operator)
rows, frozen, fingerprint = _build(session, request)
if run.kind == "BUSINESS_APPLY" and request.snapshot_hash != fingerprint:
raise WorkbenchError("PREVIEW_STALE")
counts = Counter()
for i, row in enumerate(rows, 1):
if run.kind == "BUSINESS_APPLY" and row["action"] in ("CREATE", "UPDATE"):
try:
with session.begin_nested():
_create(session, run, row, frozen)
except WorkbenchError as exc:
row["action"], row["reason"] = "PENDING", str(exc)
counts[row["action"]] += 1
session.add(
TPlusWorkLine(
run_no=run.run_no,
line_no=i,
entity_type="businesses",
source_id=row["source_id"],
action=row["action"],
reason=row.get("reason"),
data_json=row,
)
)
run.snapshot_json, run.snapshot_hash = frozen, fingerprint
run.summary_json = {
"counts": dict(counts),
"total": len(rows),
"formal_business_written": run.kind == "BUSINESS_APPLY",
"formal_payroll_written": False,
}
run.status = "PARTIAL" if counts["PENDING"] or counts["CONFLICT"] else "SUCCEEDED"
@@ -0,0 +1,541 @@
"""固定真实源快照和人工事实,组装隔离的 FIFO 核算预演。"""
from calendar import monthrange
from collections import Counter
from datetime import date
from decimal import InvalidOperation
from sqlalchemy import select
from app.core.exceptions import BadRequestError
from app.models import Customer, Product, TPlusSourceLink, TPlusSyncRecord, TPlusWorkLine
from app.services import param_service
from app.services.tplus.fifo import START, amount_text, money, preview_group
from app.services.tplus.snapshots import source_records, source_statement
from app.services.tplus.workbench import RULE_VERSION, WorkbenchError, digest, get_run, latest_facts
def _parameters(session, days):
values = {}
for day in sorted(days):
snap = param_service.resolve_snapshot(session, date.fromisoformat(day))
values[day] = {
"start": snap.get("salesperson.rate.start"),
"slope": snap.get("salesperson.rate.slope"),
"floor": snap.get("salesperson.rate.floor"),
"boundary": snap.integer("salesperson.days.boundary"),
"enterprise": snap.get("enterprise.rate"),
"thresholds": {
category: snap.get("product.threshold", product_category=category)
for category in ("municipal_water", "rail_transit")
},
}
return values
def _group_records(session, source, entity, ids):
rows = source_records(session, source, entity, ids)
if {r.source_id for r in rows} != set(ids):
raise WorkbenchError("GROUP_SOURCE_MISSING")
return rows
def _guidance(facts, product_id, line_id, month):
candidates = [
f.data_json
for (kind, _), f in facts.items()
if kind == "GUIDANCE"
and f.data_json["product_id"] == product_id
and f.data_json["month"] == month
and (f.data_json["basis"] == "UNIT" or f.data_json.get("shipment_line_id") == line_id)
]
return candidates[0] if len(candidates) == 1 else None
def _shipments(session, run, key, group, facts):
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"]
)
heads = {r.source_id: r for r in headers}
product_ids = {str(r.payload_json.get("idinventory")) for r in details}
product_targets = dict(
session.execute(
select(TPlusSourceLink.source_id, Product)
.join(Product, Product.id == TPlusSourceLink.target_id)
.where(
TPlusSourceLink.source_database == run.source_database,
TPlusSourceLink.entity_type == "products",
TPlusSourceLink.source_id.in_(product_ids),
)
).all()
)
customer_target = session.scalar(
select(Customer)
.join(TPlusSourceLink, Customer.id == TPlusSourceLink.target_id)
.where(
TPlusSourceLink.source_database == run.source_database,
TPlusSourceLink.entity_type == "customers",
TPlusSourceLink.source_id == group["customer_id"],
)
)
rows, snapshots = [], headers + details
for record in details:
raw = record.payload_json
head_record = heads.get(str(raw.get("source_parent_id")))
if head_record is None:
continue
head = head_record.payload_json
if str(head.get("idcustomer")) != group["customer_id"]:
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 {}
)
product_id = str(raw.get("idinventory"))
product = facts.get(("PRODUCT", product_id))
customer = facts.get(("CUSTOMER", group["customer_id"]))
pending = []
if (
not supplement.get("sales_amount_excludes_freight")
and supplement.get("freight_amount") is None
):
pending.append("FREIGHT_PENDING")
if not supplement.get("business_key"):
pending.append("SHIPPING_OWNERSHIP_PENDING")
# SA_SaleDelivery.isCancel 是 VoucherSettleState(如428未结清、180已结清),
# 并非作废标志;不能将非零编码当作红冲或作废。
if head.get("voucherState") != 189:
pending.append("SHIPMENT_STATE_PENDING")
if head.get("isCounteractDelivery") and not correction.get("exclude_performance"):
pending.append("NEGATIVE_EVENT_REVIEW_REQUIRED")
day = supplement.get("date_override") or str(head["voucherdate"])[:10]
managers = [
f.data_json
for (kind, _), f in facts.items()
if kind == "MANAGER"
and f.data_json["business_key"] == supplement.get("business_key")
and f.data_json["effective_date"] <= day
]
managers.sort(key=lambda v: v["effective_date"])
for (kind, _), fact in facts.items():
if (
kind == "HANDOVER"
and fact.data_json["business_key"] == supplement.get("business_key")
and fact.data_json["mode"] == "ACTIVE"
and not fact.data_json["shipping_ownership_confirmed"]
):
pending.append("HANDOVER_SHIPMENTS_PENDING")
if raw.get("taxAmount") is None:
pending.append("TAX_AMOUNT_PENDING")
gross_amount = money(raw.get("taxAmount") or "0")
freight = (
money(supplement["freight_amount"])
if supplement.get("freight_amount") is not None
else money("0")
)
target_product = product_targets.get(product_id)
rows.append(
{
"source_id": head_record.source_id,
"line_id": record.source_id,
"source_no": head_record.source_no,
"date": day,
"group_key": key,
"business_key": supplement.get("business_key"),
"personnel_id": supplement.get("personnel_id"),
"amount": str(gross_amount - freight),
"gross_tax_amount": str(gross_amount),
"freight_amount": str(freight)
if supplement.get("freight_amount") is not None
else None,
"product_id": product_id,
"quantity": str(raw.get("quantity") or "0"),
"category": target_product.product_category
if target_product
else product.data_json["category"]
if product
else None,
"unit": target_product.unit
if target_product
else product.data_json["unit"]
if product
else None,
"customer_type": customer_target.customer_type
if customer_target
else customer.data_json["customer_type"]
if customer
else None,
"classification_evidence": {
"product_target_id": target_product.id if target_product else None,
"customer_target_id": customer_target.id if customer_target else None,
},
"manager_id": managers[-1]["manager_id"] if managers else None,
"performance_excluded": bool(correction.get("exclude_performance")),
"correction_pair_key": correction.get("pair_key"),
"exclusion_reason": "CORRECTION_EXCLUDED" if correction else None,
"pending": pending,
}
)
covered = {r["source_id"] for r in rows}
if covered != set(group["shipment_ids"]):
raise WorkbenchError("SHIPMENT_DETAILS_MISSING")
return rows, snapshots
def _receipts(session, run, key, group, facts):
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 {}
)
redirect_target = correction.get("redirect_business_key")
if str(raw.get("idpartner")) != group["customer_id"] and redirect_target != key:
raise WorkbenchError("GROUP_CUSTOMER_MISMATCH")
fact = facts.get(("RECEIPT", record.source_id))
confirmed, voucher_evidence, voucher_pending = _voucher_evidence(raw, fact)
pending = []
pending.extend(voucher_pending)
if raw.get("isReceiveFlag") != 1:
pending.append("NOT_RECEIPT")
# 凭证关系与人工核实冲突时不能强行按人工勾选放行。
if pending:
confirmed = False
if record.source_id in group.get("excluded_receipt_ids", []):
continue
if raw.get("voucherstate") != 189:
confirmed = False
exclusion = None
if raw.get("isReceiveFlag") == 0:
exclusion = "NOT_RECEIPT"
elif (
voucher_evidence.get("basis") == "GL_SrcVoucherInfo/GL_Doc"
and voucher_evidence.get("count") == 0
and not voucher_pending
):
exclusion = "NOT_GENERATED_VOUCHER"
if redirect_target and redirect_target != key:
exclusion = "REDIRECTED_RECEIPT"
rows.append(
{
"source_id": record.source_id,
"source_no": record.source_no,
"date": str(raw["voucherDate"])[:10],
"group_key": key,
"amount": str(raw["amount"]),
"voucher_confirmed": confirmed,
"exclusion": exclusion,
"voucher_evidence": voucher_evidence,
"accounting_voucher_id": raw.get("accounting_voucher_id"),
"accounting_voucher_code": raw.get("accounting_voucher_code"),
"pending": pending,
"redirect_business_key": redirect_target,
"redirected_from_customer_id": str(raw.get("idpartner"))
if redirect_target == key and str(raw.get("idpartner")) != key
else None,
}
)
return rows, records
def _voucher_evidence(raw, fact):
"""以真实总账关系核实生成资格;旧快照只接受有依据的人工核实。"""
count, valid = raw.get("accounting_voucher_count"), raw.get("valid_accounting_voucher_count")
if count is None or valid is None:
return (
bool(fact and fact.data_json["voucher_confirmed"]),
{
"basis": "MANUAL" if fact else "UNKNOWN",
"reference": fact.data_json["voucher_reference"] if fact else None,
},
[],
)
count, valid = int(count), int(valid)
if count < 0 or valid < 0 or valid > count:
raise ValueError("凭证关联数量异常")
generated = count > 0 and valid == count and raw.get("accounting_voucher_id") is not None
pending = ["ACCOUNTING_VOUCHER_INVALID"] if count > 0 and not generated else []
if fact and fact.data_json["voucher_confirmed"] != generated:
pending.append("VOUCHER_EVIDENCE_CONFLICT")
return (
generated and not pending,
{
"basis": "GL_SrcVoucherInfo/GL_Doc",
"count": count,
"valid_count": valid,
"voucher_id": raw.get("accounting_voucher_id"),
"voucher_code": raw.get("accounting_voucher_code"),
"reference": fact.data_json["voucher_reference"] if fact else None,
},
pending,
)
def _costed_shipments(shipments, receipts, facts, group):
"""回款月定价时成本按每笔回款取值,交给 FIFO 逐段计算。"""
basis = "SHIPMENT"
for ship in shipments:
if basis is None:
ship["pending"].append("GUIDANCE_MONTH_BASIS_PENDING")
continue
months = {r["date"][:7] for r in receipts} if basis == "RECEIPT" else {ship["date"][:7]}
costs = {}
for month in months:
guide = _guidance(facts, ship["product_id"], ship["line_id"], month)
if guide is None or guide["unit"] != ship["unit"]:
continue
ship["guidance_finalized"] = bool(guide.get("is_finalized"))
cost = money(guide["amount"])
if guide["basis"] == "UNIT":
if money(ship["quantity"]) <= 0:
ship["pending"].append("QUANTITY_PENDING")
continue
cost *= money(ship["quantity"])
costs[month] = amount_text(cost)
ship["cost_by_month"] = costs
ship["cost_month_basis"] = basis
ship["cost_total"] = costs.get(ship["date"][:7]) if basis == "SHIPMENT" else None
def execute_preview(session, run, *, save_lines=True, customer_scope=False):
"""同一次事务固化所有事实、来源指纹、参数和结果,不写正式台账。"""
if run.input_json.get("replay_no"):
return _replay(session, run)
facts = latest_facts(session, run.source_database)
month = run.input_json["month"]
year, mon = map(int, month.split("-"))
until = date(year, mon, monthrange(year, mon)[1])
selected = []
overlapping = set()
for key in run.input_json["group_keys"]:
fact = facts.get(("GROUP", key))
if fact is None:
selected.append((key, {}))
continue
group = dict(fact.data_json)
if customer_scope:
peers = [
f.data_json
for (kind, _), f in facts.items()
if kind == "GROUP" and f.data_json["customer_id"] == group["customer_id"]
]
def policy(g):
return (
money(g["opening_balance"]) if g.get("opening_balance") is not None else None,
tuple(sorted(g.get("excluded_receipt_ids", []))),
bool(g.get("opening_confirmed")),
bool(g.get("historical_settlement_confirmed")),
)
if len({policy(g) for g in peers}) != 1:
selected.append((key, {"customer_scope_conflict": True}))
continue
# 2026-10-30 E7:同一发货客户名头统一平账,不再按合同/人工业务键拆组。
group["scope_confirmed"] = True
for entity, field, day_field, target_field in (
("shipments", "idcustomer", "voucherdate", "shipment_ids"),
("receipts", "idpartner", "voucherDate", "receipt_ids"),
):
statement = source_statement(session, run.source_database, entity).where(
TPlusSyncRecord.payload_json[field].as_integer() == int(group["customer_id"])
)
source_ids = [
r.source_id
for r in session.scalars(statement)
if START.isoformat()
<= str(r.payload_json.get(day_field, ""))[:10]
<= until.isoformat()
]
if entity == "receipts":
source_ids.extend(
f.fact_key
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
)
group[target_field] = sorted(source_ids, key=lambda value: int(value))
# 也检查未选组,避免同一单据在另一组被解释为独立资金。
for (kind, other_key), other in facts.items():
if (
kind == "GROUP"
and other_key != key
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"])
)
):
overlapping.add(key)
selected.append((key, group))
frozen = {
"rule_version": RULE_VERSION,
"month": month,
"source": [],
"facts": [
{
"id": f.id,
"kind": f.kind,
"key": f.fact_key,
"version": f.version,
"data": f.data_json,
"evidence": f.evidence,
}
for f in facts.values()
],
"parameters": {},
"calculation_inputs": {},
}
count, summaries = 0, {}
person_facts = {key: f.data_json for (kind, key), f in facts.items() if kind == "PERSONNEL"}
handovers = [f.data_json for (kind, _), f in facts.items() if kind == "HANDOVER"]
for key, data in selected:
group = {**data, "key": key}
if data.get("customer_scope_conflict"):
result = {"status": "PENDING", "reasons": ["CUSTOMER_SCOPE_CONFLICT"], "lines": []}
elif key in overlapping:
result = {"status": "PENDING", "reasons": ["OVERLAPPING_GROUPS"], "lines": []}
elif not data:
result = {"status": "PENDING", "reasons": ["GROUP_FACT_REQUIRED"], "lines": []}
elif (
not group.get("scope_confirmed")
or not group.get("opening_confirmed")
or not group.get("historical_settlement_confirmed")
):
result = preview_group(group, [], [], {}, {}, until)
else:
try:
ships, ship_refs = _shipments(session, run, key, group, facts)
cash, cash_refs = _receipts(session, run, key, group, facts)
frozen["source"].extend(
(r.entity_type, r.source_id, r.id, r.row_hash) for r in ship_refs + cash_refs
)
_costed_shipments(ships, cash, facts, group)
# max(发货日,回款日) 必为其中一个日期,无需构造单据笛卡尔积。
days = {
row["date"]
for row in [*ships, *cash]
if START.isoformat() <= row["date"] <= until.isoformat()
}
params = _parameters(session, days)
# 已确认的原修改方案和到账日口径不再依赖重复人工勾选。
params["august_confirmed"] = True
params["prepayment_parameter_basis"] = "RECEIPT"
frozen["parameters"][key] = params
frozen["calculation_inputs"][key] = {
"group": group,
"shipments": ships,
"receipts": cash,
"facts": {"personnel": person_facts, "handovers": handovers},
"params": params,
}
result = preview_group(
group,
ships,
cash,
{"personnel": person_facts, "handovers": handovers},
params,
until,
)
except WorkbenchError as exc:
frozen["calculation_inputs"].pop(key, None)
result = {"status": "PENDING", "reasons": [str(exc)], "lines": []}
except BadRequestError:
frozen["calculation_inputs"].pop(key, None)
result = {"status": "PENDING", "reasons": ["PARAMETERS_PENDING"], "lines": []}
except (ValueError, KeyError, InvalidOperation, TypeError):
frozen["calculation_inputs"].pop(key, None)
result = {"status": "PENDING", "reasons": ["DATA_VALUE_INVALID"], "lines": []}
if key not in frozen["calculation_inputs"]:
frozen["calculation_inputs"][key] = {"pending": result}
summaries[key] = {k: v for k, v in result.items() if k != "lines"}
if save_lines:
count = _save_group(session, run, key, result, count)
run.snapshot_json, run.snapshot_hash = frozen, digest(frozen)
run.summary_json = {
"groups": summaries,
"total_lines": count,
"rule_version": RULE_VERSION,
"month": month,
"formal_payroll_written": False,
}
states = Counter(v["status"] for v in summaries.values())
run.status = "PARTIAL" if states["PENDING"] or states["PARTIAL"] else "SUCCEEDED"
def _save_group(session, run, key, result, count):
lines = result["lines"] or (
[{"status": "PENDING", "reasons": result["reasons"], "performance": None}]
if result["status"] == "PENDING"
else []
)
for line in lines:
count += 1
session.add(
TPlusWorkLine(
run_no=run.run_no,
line_no=count,
entity_type="group",
source_id=key,
action=line["status"],
reason=",".join(line.get("reasons", []))[:128] or None,
data_json={**line, "group_key": key, "formal_payroll_written": False},
)
)
return count
def _replay(session, run):
"""只重算原运行冻结输入;不读取当前事实、指导价、参数或源数据。"""
import copy
previous = get_run(session, run.input_json["replay_no"], run.source_database)
frozen = copy.deepcopy(previous.snapshot_json or {})
if (
previous.kind != "CALC_PREVIEW"
or frozen.get("rule_version") not in ("2026-10-30.1", RULE_VERSION)
or not frozen.get("calculation_inputs")
):
raise WorkbenchError("REPLAY_UNAVAILABLE")
year, month = map(int, frozen["month"].split("-"))
until = date(year, month, monthrange(year, month)[1])
summaries, count = {}, 0
for key, values in frozen["calculation_inputs"].items():
result = values.get("pending")
if result is None:
result = preview_group(
values["group"],
values["shipments"],
values["receipts"],
values["facts"],
values["params"],
until,
)
summaries[key] = {k: v for k, v in result.items() if k != "lines"}
count = _save_group(session, run, key, result, count)
run.snapshot_json, run.snapshot_hash = frozen, digest(frozen)
run.summary_json = {
"groups": summaries,
"total_lines": count,
"rule_version": RULE_VERSION,
"month": frozen["month"],
"formal_payroll_written": False,
}
run.status = (
"PARTIAL"
if any(v["status"] in ("PENDING", "PARTIAL") for v in summaries.values())
else "SUCCEEDED"
)
+35
View File
@@ -0,0 +1,35 @@
"""只按明确的源驱动错误分类,不把含凭据的驱动文本写入响应。"""
from sqlalchemy.exc import TimeoutError as PoolTimeoutError
TRANSIENT_CODES = frozenset({"SOURCE_CONNECTION_LOST", "SOURCE_TIMEOUT"})
MESSAGES = {
"SOURCE_CONNECTION_LOST": "源连接中断,请检查网络和只读连接",
"SOURCE_TIMEOUT": "源连接或查询超时",
"SOURCE_AUTH_ERROR": "源账号认证失败,请核对只读账号配置",
"SOURCE_SCHEMA_ERROR": "源表或字段不匹配,请核对版本与映射",
"SOURCE_CONFIG_ERROR": "源连接配置不完整或任务排队后发生变化",
"SOURCE_DRIVER_ERROR": "源数据库驱动不可用,请检查已验证的ODBC驱动",
"QUERY_FILE_MISSING": "缺少源查询文件,请核对部署包",
}
def classify_source_error(exc, fallback="READ_ERROR"):
if isinstance(exc, (TimeoutError, PoolTimeoutError)):
return "SOURCE_TIMEOUT"
if isinstance(exc, ModuleNotFoundError):
return "SOURCE_DRIVER_ERROR"
if isinstance(exc, FileNotFoundError):
return "QUERY_FILE_MISSING"
original = getattr(exc, "orig", exc)
args = getattr(original, "args", ())
state = str(args[0]).upper() if args else ""
if state in {"HYT00", "HYT01"}:
return "SOURCE_TIMEOUT"
if state in {"08S01", "08001", "08003", "08006"}:
return "SOURCE_CONNECTION_LOST"
if state == "28000":
return "SOURCE_AUTH_ERROR"
if state in {"42S02", "42S22", "07002"}:
return "SOURCE_SCHEMA_ERROR"
return fallback
+355
View File
@@ -0,0 +1,355 @@
"""核算预演纯函数:只分配已明确的平账组,不写正式台账。"""
from datetime import date
from decimal import ROUND_HALF_UP, Decimal
from fractions import Fraction
from app.services import formula
CENT = Decimal("0.01")
ZERO = Decimal("0")
START = date(2026, 3, 1)
AUGUST = date(2026, 8, 1)
def money(value) -> Decimal:
"""拒绝非有限金额,不用浮点数参与计算。"""
if isinstance(value, float):
raise ValueError("金额必须为十进制字符串")
result = Decimal(str(value))
if not result.is_finite():
raise ValueError("金额必须为有限数字")
return result
def amount_text(value: Decimal) -> str:
"""在展示边界按分四舍五入。"""
return str(value.quantize(CENT, rounding=ROUND_HALF_UP))
def _day(value) -> date:
return date.fromisoformat(str(value)[:10])
def _key(row):
return (_day(row["date"]), int(row["source_id"]), int(row.get("line_id", 0)))
def _beneficiary(shipment, receipt, facts, as_of):
owner = str(shipment.get("personnel_id") or "")
applicable = sorted(
(f for f in facts if f.get("business_key") == shipment.get("business_key")),
key=lambda f: (_day(f["effective_date"]), f.get("version", 0)),
)
for fact in applicable:
# 在职交接仍按每次发货的归属,不把整笔绩效按固定比例拆分。
if (
fact["mode"] == "RESIGNATION"
and _day(fact["effective_date"]) <= _day(as_of)
and owner == str(fact["from_personnel_id"])
):
owner = str(fact["to_personnel_id"])
return owner
def _calculate(shipment, receipt, matched, previously_matched, facts, params):
reasons = list(shipment.get("pending", [])) + list(receipt.get("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"):
reasons.append("PREPAYMENT_PARAMETERS_PENDING")
if effective >= AUGUST and not params.get("august_confirmed", False):
reasons.append("AUGUST_RULE_PENDING")
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")))
if not person:
reasons.append("PERSONNEL_PENDING")
elif person.get("status") == 0:
known_later_departure = any(
record.get("mode") == "RESIGNATION"
and record.get("business_key") == shipment.get("business_key")
and str(record.get("from_personnel_id")) == owner
and _day(record["effective_date"]) > _day(facts["as_of"])
for record in facts.get("handovers", [])
)
if not known_later_departure:
reasons.append("HANDOVER_PENDING")
cost = shipment.get("cost_total")
if shipment.get("cost_month_basis") == "RECEIPT":
cost = shipment.get("cost_by_month", {}).get(str(receipt["date"])[:7])
if cost is None:
reasons.append("GUIDANCE_PENDING")
category = shipment.get("category")
if category not in ("municipal_water", "rail_transit"):
reasons.append("PRODUCT_CATEGORY_PENDING")
customer_type = shipment.get("customer_type")
if customer_type not in ("personal", "manager_personal", "enterprise"):
reasons.append("CUSTOMER_CATEGORY_PENDING")
parameter_date = (
_day(receipt["date"])
if is_prepayment and params.get("prepayment_parameter_basis") == "RECEIPT"
else effective
)
rate_params = params.get(parameter_date.isoformat())
if rate_params is None:
reasons.append("PARAMETERS_PENDING")
if reasons:
return {"status": "PENDING", "reasons": sorted(set(reasons)), "performance": None}
total = money(shipment["amount"])
cost_total = money(cost)
# 累计舍入差分使同一发货拆成多笔回款时,成本分摊合计仍等于总成本。
before = (cost_total * previously_matched / total).quantize(CENT, rounding=ROUND_HALF_UP)
after = (cost_total * (previously_matched + matched) / total).quantize(
CENT, rounding=ROUND_HALF_UP
)
allocated_cost = after - before
threshold = Fraction(str(rate_params["thresholds"][category]))
converted = formula.converted_profit(
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")
if manager_own or person["role_code"] == "sales_manager" or customer_type == "manager_personal":
rate = Fraction(0)
elif _day(receipt["date"]) < _day(shipment["date"]):
rate = Fraction(str(rate_params["start"])) / 1000
elif customer_type == "enterprise":
rate = Fraction(str(rate_params["enterprise"])) / 1000
else:
rate = formula.salesperson_rate(
days,
start=Fraction(str(rate_params["start"])),
slope=Fraction(str(rate_params["slope"])),
floor=Fraction(str(rate_params["floor"])),
boundary_days=int(rate_params["boundary"]),
)
return {
"status": "READY",
"reasons": [],
"personnel_id": owner,
"manager_id": shipment.get("manager_id"),
"manager_pending": not bool(shipment.get("manager_id")),
"effective_date": effective.isoformat(),
"days": days,
"allocated_cost": amount_text(allocated_cost),
"converted_profit": amount_text(converted),
"profit_threshold": str(rate_params["thresholds"][category]),
"rate": str(rate),
"rate_percent": format(
(Decimal(rate.numerator) * 100 / Decimal(rate.denominator)).quantize(Decimal("0.0001")),
"f",
)
.rstrip("0")
.rstrip(".")
+ "%",
"parameters": rate_params,
"parameter_date": parameter_date.isoformat(),
"performance": amount_text(formula.performance_amount(converted, rate)),
"excluded_manager_own": manager_own or person["role_code"] == "sales_manager",
}
def preview_group(group, shipments, receipts, facts, params, until: date) -> dict:
"""按明确分组分配资金,缺条件时保留金额和原因,不猜测绩效。"""
if not group.get("scope_confirmed"):
return {"status": "PENDING", "reasons": ["GROUP_SCOPE_PENDING"], "lines": []}
if not group.get("opening_confirmed") or group.get("opening_balance") is None:
return {"status": "PENDING", "reasons": ["OPENING_BALANCE_PENDING"], "lines": []}
if not group.get("historical_settlement_confirmed"):
return {"status": "PENDING", "reasons": ["HISTORICAL_SETTLEMENT_PENDING"], "lines": []}
shipments = [row for row in shipments if START <= _day(row["date"]) <= until]
receipts = [row for row in receipts if START <= _day(row["date"]) <= until]
excluded_shipments = [row for row in shipments if row.get("performance_excluded")]
shipments = [row for row in shipments if not row.get("performance_excluded")]
excluded_receipts = sorted((row for row in receipts if row.get("exclusion")), key=_key)
receipts = [row for row in receipts if not row.get("exclusion")]
amount_pending = {
reason
for row in shipments
for reason in row.get("pending", [])
if reason in ("FREIGHT_PENDING", "TAX_AMOUNT_PENDING")
}
if amount_pending:
# 净销售额未知会改变后续所有分配,不能先猜零运费再计算同组其他业务。
return {"status": "PENDING", "reasons": sorted(amount_pending), "lines": []}
opening = money(group["opening_balance"])
if opening < 0:
raise ValueError("期初应收不能为负,预收余额应单独登记")
invalid = [r for r in (*shipments, *receipts) if money(r["amount"]) <= 0]
if invalid:
return {"status": "PENDING", "reasons": ["NEGATIVE_OR_ZERO_DOCUMENT"], "lines": []}
if any("NEGATIVE_EVENT_REVIEW_REQUIRED" in r.get("pending", []) for r in shipments):
return {"status": "PENDING", "reasons": ["NEGATIVE_EVENT_REVIEW_REQUIRED"], "lines": []}
if any(
money(r["amount"]) != money(r["amount"]).quantize(CENT) for r in (*shipments, *receipts)
):
return {"status": "PENDING", "reasons": ["AMOUNT_PRECISION_PENDING"], "lines": []}
groups = {str(r.get("group_key", "")) for r in (*shipments, *receipts)}
if groups - {str(group["key"])}:
raise ValueError("禁止跨平账组分配")
ship_rows = sorted(shipments, key=_key)
cash_rows = sorted(receipts, key=_key)
ship_keys = [(str(r["source_id"]), str(r.get("line_id", 0))) for r in ship_rows]
receipt_keys = [str(r["source_id"]) for r in cash_rows]
if len(set(ship_keys)) != len(ship_keys) or len(set(receipt_keys)) != len(receipt_keys):
raise ValueError("预演输入包含重复单据或明细")
# 凭证状态未证实的收款不能消耗后续发货,否则会改变其他款的 FIFO 顺序。
if any(not r.get("voucher_confirmed") for r in cash_rows):
reasons = {"VOUCHER_STATE_PENDING"}
for receipt in cash_rows:
reasons.update(receipt.get("pending", []))
return {"status": "PENDING", "reasons": sorted(reasons), "lines": []}
consumed = [ZERO for _ in ship_rows]
lines = [
{
"kind": "EXCLUDED",
"shipment_id": str(row["source_id"]),
"shipment_no": row.get("source_no"),
"shipment_date": row["date"],
"line_id": str(row.get("line_id", 0)),
"amount": amount_text(money(row["amount"])),
"status": "EXCLUDED",
"performance": None,
"reasons": [row.get("exclusion_reason") or "CORRECTION_EXCLUDED"],
"correction_pair_key": row.get("correction_pair_key"),
}
for row in excluded_shipments
] + [
{
"kind": "EXCLUDED",
"receipt_id": str(row["source_id"]),
"receipt_no": row.get("source_no"),
"receipt_date": row["date"],
"amount": amount_text(money(row["amount"])),
"status": "EXCLUDED",
"performance": None,
"reasons": [row["exclusion"]],
"voucher_evidence": row.get("voucher_evidence"),
"accounting_voucher_code": row.get("accounting_voucher_code"),
}
for row in excluded_receipts
]
remaining_opening = opening
cash_total = ZERO
for receipt in cash_rows:
available = money(receipt["amount"])
cash_total += available
if remaining_opening:
matched = min(available, remaining_opening)
if matched:
lines.append(
{
"kind": "OPENING",
"receipt_id": str(receipt["source_id"]),
"receipt_no": receipt.get("source_no"),
"receipt_date": receipt["date"],
"voucher_evidence": receipt.get("voucher_evidence"),
"accounting_voucher_code": receipt.get("accounting_voucher_code"),
"amount": amount_text(matched),
"status": "LEGACY",
"performance": None,
"reasons": ["LEGACY_SYSTEM_CALCULATES"],
}
)
remaining_opening -= matched
available -= matched
for i, shipment in enumerate(ship_rows):
if available == 0:
break
remaining = money(shipment["amount"]) - consumed[i]
matched = min(available, remaining)
if matched <= 0:
continue
result = _calculate(
shipment,
receipt,
matched,
consumed[i],
{**facts, "as_of": until.isoformat()},
params,
)
lines.append(
{
"kind": "SHIPMENT",
"receipt_id": str(receipt["source_id"]),
"receipt_no": receipt.get("source_no"),
"receipt_date": receipt["date"],
"voucher_evidence": receipt.get("voucher_evidence"),
"accounting_voucher_code": receipt.get("accounting_voucher_code"),
"shipment_id": str(shipment["source_id"]),
"shipment_no": shipment.get("source_no"),
"shipment_date": shipment["date"],
"line_id": str(shipment.get("line_id", 0)),
"product_id": shipment.get("product_id"),
"quantity": shipment.get("quantity"),
"cost_total": shipment.get("cost_total"),
"gross_tax_amount": shipment.get("gross_tax_amount"),
"freight_amount": shipment.get("freight_amount"),
"cost_month_basis": shipment.get("cost_month_basis"),
"cost_by_month": shipment.get("cost_by_month"),
"shipment_remaining_after": amount_text(remaining - matched),
"amount": amount_text(matched),
**result,
}
)
consumed[i] += matched
available -= matched
if available:
lines.append(
{
"kind": "PREPAYMENT",
"receipt_id": str(receipt["source_id"]),
"receipt_no": receipt.get("source_no"),
"receipt_date": receipt["date"],
"voucher_evidence": receipt.get("voucher_evidence"),
"accounting_voucher_code": receipt.get("accounting_voucher_code"),
"amount": amount_text(available),
"status": "WAITING_SHIPMENT",
"performance": None,
"reasons": ["NO_ACTUAL_SHIPMENT"],
}
)
allocated = sum((money(r["amount"]) for r in lines if r["kind"] != "EXCLUDED"), ZERO)
if allocated != cash_total:
raise ValueError("回款分配金额不守恒")
return {
"status": "PARTIAL" if any(r["status"] == "PENDING" for r in lines) else "SUCCEEDED",
"reasons": [],
"lines": lines,
"receipt_total": amount_text(cash_total),
"excluded_receipt_total": amount_text(
sum((money(r["amount"]) for r in excluded_receipts), ZERO)
),
"excluded_shipment_total": amount_text(
sum((money(r["amount"]) for r in excluded_shipments), ZERO)
),
"allocated_total": amount_text(allocated),
"opening_remaining": amount_text(remaining_opening),
"shipment_remaining": amount_text(
sum(
(
money(row["amount"]) - used
for row, used in zip(ship_rows, consumed, strict=True)
),
ZERO,
)
),
"prepayment_remaining": amount_text(
sum((money(r["amount"]) for r in lines if r["kind"] == "PREPAYMENT"), ZERO)
),
"performance_total": amount_text(
sum((money(r["performance"]) for r in lines if r.get("performance") is not None), ZERO)
),
"month_performance_total": amount_text(
sum(
(
money(r["performance"])
for r in lines
if r.get("performance") is not None
and r.get("effective_date", "")[:7] == until.isoformat()[:7]
),
ZERO,
)
),
}
+9 -9
View File
@@ -1,7 +1,7 @@
"""T+ 字段映射元数据。
这里记录候选字段和确认状态,不把字典中的候选直接当成业务契约。
高风险口径在客户确认前保持 ``PENDING``,同步器只允许写入原始 staging。
高风险口径在客户确认前保持 ``PENDING``;源读取只写 staging,基础导入另经事实核对和预览。
"""
from dataclasses import dataclass
@@ -27,7 +27,7 @@ class TPlusMapping:
target_table: str
status: MappingStatus
unresolved: tuple[str, ...] = ()
version: str = "0.2"
version: str = "0.5"
schema_evidence: str = "ceshi20261026.bak,2026-10-27 恢复副本结构核验"
@@ -38,7 +38,7 @@ TPLUS_MAPPINGS: tuple[TPlusMapping, ...] = (
("id", "code", "name", "disabled", "mobilePhoneNo", "officePhoneNo", "iddepartment"),
"personnel",
MappingStatus.CANDIDATE,
("工号是否等于 code", "经理隶属关系", "入职日期", "试用期"),
("名单缺失编号", "经理分管业务", "入职日期", "试用期"),
),
TPlusMapping(
"products",
@@ -46,7 +46,7 @@ TPLUS_MAPPINGS: tuple[TPlusMapping, ...] = (
("id", "code", "name", "specification", "idunit", "idinventoryclass"),
"products",
MappingStatus.CANDIDATE,
("库存数量语义", "参考价来源", "系统产品分类"),
("人工核算分类", "指导价取值时点"),
),
TPlusMapping(
"customers",
@@ -54,7 +54,7 @@ TPLUS_MAPPINGS: tuple[TPlusMapping, ...] = (
("id", "code", "name", "Contact", "representative", "disabled", "updated"),
"customers",
MappingStatus.CANDIDATE,
("客户唯一键", "改名/合并规则", "系统客户分类"),
("源客户属性为空", "客户负责人资料"),
),
TPlusMapping(
"businesses",
@@ -62,7 +62,7 @@ TPLUS_MAPPINGS: tuple[TPlusMapping, ...] = (
("id", "code", "voucherdate", "idcustomer", "idclerk", "idbusinesstype", "updated"),
"businesses",
MappingStatus.PENDING,
("成本口径", "业务负责人覆盖规则", "含税口径"),
("指导价取值时点", "业务交接依据", "平账范围"),
),
TPlusMapping(
"shipments",
@@ -79,7 +79,7 @@ TPLUS_MAPPINGS: tuple[TPlusMapping, ...] = (
),
"shipments",
MappingStatus.PENDING,
("发货日期", "订单关联路径", "退货/红冲规则", "运费来源"),
("退货/红冲规则", "运费实际金额", "发货归属"),
),
TPlusMapping(
"receipts",
@@ -96,7 +96,7 @@ TPLUS_MAPPINGS: tuple[TPlusMapping, ...] = (
),
"receipts",
MappingStatus.PENDING,
("到账日期", "收款方式", "核销状态", "回款与发货关联"),
("凭证异常例单复核", "特殊款项识别", "平账范围"),
),
)
@@ -107,7 +107,7 @@ DETAIL_MAPPINGS: tuple[TPlusMapping, ...] = (
("id", "idSaleOrderDTO", "LatestCost"),
"business_items",
MappingStatus.PENDING,
("成本口径", "含税口径"),
("不导入T+成本,仅保留追溯快照",),
),
TPlusMapping(
"shipment_items",
+288
View File
@@ -0,0 +1,288 @@
"""主数据先预览再执行,按源ID关联,只更新允许同步的字段。"""
import json
from collections import Counter
from datetime import datetime
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from app.models import BusinessRole, Customer, Personnel, Product, TPlusSourceLink, TPlusWorkLine
from app.services.tplus.snapshots import source_records
from app.services.tplus.workbench import RULE_VERSION, WorkbenchError, digest, get_run, latest_facts
MODELS = {"personnel": Personnel, "products": Product, "customers": Customer}
ORDER = {"personnel": 0, "products": 1, "customers": 2}
# 恢复库 eap_EnumItem:211=客户,228=客户/供应商,226=供应商。
CUSTOMER_ONLY = 211
def _identity(row):
keys = {
"personnel": ("personnel_name",),
"products": ("product_name", "spec_model"),
"customers": ("company_name", "owner_name"),
}[row["entity_type"]]
return row["entity_type"], tuple(str(row["values"][key]).rstrip().casefold() for key in keys)
def _target_snapshot(target):
if target is None:
return None
return {c.name: getattr(target, c.name) for c in target.__table__.columns}
def _master_values(entity, record, facts, session):
row = record.payload_json
if entity == "personnel":
code = str(row.get("code") or "").strip()
fact = facts.get(("PERSONNEL", code))
if fact is None:
return None, "NOT_IN_PERSONNEL_ROSTER"
person = fact.data_json
if person["code"] != code or person["name"] != str(row.get("name") or "").strip():
return None, "PERSONNEL_IDENTITY_CONFLICT"
role = session.scalar(
select(BusinessRole).where(BusinessRole.role_code == person["role_code"])
)
if role is None:
return None, "BUSINESS_ROLE_MISSING"
return {
"personnel_name": person["name"],
"business_role_id": role.id,
"status": person["status"],
}, None
if entity == "products":
fact = facts.get(("PRODUCT", record.source_id))
if fact is None:
return None, "PRODUCT_CATEGORY_PENDING"
unit = row.get("unit_name")
if not unit or unit != fact.data_json["unit"]:
return None, "PRODUCT_UNIT_PENDING"
return {
"product_name": row["name"],
"spec_model": row.get("specification") or "",
"unit": unit,
"product_category": fact.data_json["category"],
}, None
if row.get("partnerType") != CUSTOMER_ONLY:
return None, "NOT_CUSTOMER_ONLY"
fact = facts.get(("CUSTOMER", record.source_id))
if fact is None:
return None, "CUSTOMER_ATTRIBUTE_PENDING"
return {
"company_name": row["name"],
"owner_name": fact.data_json["owner_name"],
"customer_type": fact.data_json["customer_type"],
}, None
def _protected_update(entity, values):
allowed = {
"personnel": {"personnel_name"},
"products": {"product_name", "spec_model", "unit"},
"customers": {"company_name"},
}[entity]
return {k: v for k, v in values.items() if k in allowed}
def _conflict(session, entity, values, target_id):
model = MODELS[entity]
if entity == "personnel":
conditions = [model.personnel_name == values["personnel_name"]]
elif entity == "products":
conditions = [
model.product_name == values["product_name"],
model.spec_model == values["spec_model"],
]
else:
conditions = [
model.company_name == values["company_name"],
model.owner_name == values["owner_name"],
]
if target_id:
conditions.append(model.id != target_id)
return session.scalar(select(model.id).where(*conditions).limit(1)) is not None
def build_master_plan(session, run, *, lock_targets=False):
"""重新计算源、事实、目标指纹;执行前复核所有输入。"""
facts = latest_facts(session, run.source_database)
links = {
(v.entity_type, v.source_id): v
for v in session.scalars(
select(TPlusSourceLink).where(TPlusSourceLink.source_database == run.source_database)
)
}
rows = []
snapshots = []
target_ids = []
for entity in sorted(run.input_json["entity_types"], key=ORDER.__getitem__):
records = source_records(
session, run.source_database, entity, run.input_json.get("source_ids") or None
)
for record in records:
link = links.get((entity, record.source_id))
target = (
session.get(
MODELS[entity],
link.target_id,
populate_existing=True,
with_for_update=lock_targets,
)
if link
else None
)
values, reason = _master_values(entity, record, facts, session)
action = "PENDING" if reason else "CREATE"
if reason in ("NOT_IN_PERSONNEL_ROSTER", "NOT_CUSTOMER_ONLY"):
action = "SKIP"
if link and target is None:
action, reason = "CONFLICT", "TARGET_MISSING"
elif values:
if target:
values = _protected_update(entity, values)
action = (
"UPDATE"
if any(getattr(target, k) != v for k, v in values.items())
else "SKIP"
)
if _conflict(
session,
entity,
{**(_target_snapshot(target) or {}), **values},
target.id if target else None,
):
action, reason = "CONFLICT", "TARGET_UNIQUE_CONFLICT"
snapshots.append((entity, record.source_id, record.row_hash, record.id))
target_ids.append((entity, record.source_id, _target_snapshot(target)))
rows.append(
{
"entity_type": entity,
"source_id": record.source_id,
"action": action,
"reason": reason,
"values": values or {},
"record_id": record.id,
"row_hash": record.row_hash,
"target_id": target.id if target else None,
"source_no": record.source_no,
"source_name": record.payload_json.get("name"),
}
)
identities = Counter(_identity(r) for r in rows if r["action"] == "CREATE")
for row in rows:
if row["action"] == "CREATE" and identities[_identity(row)] > 1:
row["action"], row["reason"] = "CONFLICT", "DUPLICATE_SOURCE_IDENTITY"
# 以代码名单检查漏读人员,不能让缺失人员静默成为成功。
if "personnel" in run.input_json["entity_types"]:
seen = {r["source_no"] for r in rows if r["entity_type"] == "personnel"}
for kind, key in facts:
if kind == "PERSONNEL" and key not in seen and not run.input_json.get("source_ids"):
rows.append(
{
"entity_type": "personnel",
"source_id": key,
"action": "PENDING",
"reason": "ROSTER_SOURCE_MISSING",
"values": {},
}
)
frozen = {
"rule_version": RULE_VERSION,
"source": snapshots,
"targets": target_ids,
"plan": rows,
"facts": [
{
"id": f.id,
"kind": f.kind,
"key": f.fact_key,
"version": f.version,
"data": f.data_json,
"evidence": f.evidence,
}
for f in facts.values()
],
}
frozen = json.loads(json.dumps(frozen, ensure_ascii=False, default=str))
return rows, frozen, digest(frozen)
def _line(session, run, i, row, action=None, reason=None):
session.add(
TPlusWorkLine(
run_no=run.run_no,
line_no=i,
entity_type=row["entity_type"],
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")
},
)
)
def execute_master(session, run):
"""执行预览或原子导入;正式核算表永远不参与此服务。"""
if run.kind == "MASTER_PREVIEW":
rows, snapshot, fingerprint = build_master_plan(session, run)
run.snapshot_json, run.snapshot_hash = snapshot, fingerprint
for i, row in enumerate(rows, 1):
_line(session, run, i, row)
counts = Counter(r["action"] for r in rows)
run.summary_json = {
"counts": dict(counts),
"rule_version": RULE_VERSION,
"total": len(rows),
"formal_payroll_written": False,
}
run.status = "PARTIAL" if counts["PENDING"] or counts["CONFLICT"] else "SUCCEEDED"
return
preview = get_run(session, run.input_json["preview_no"], run.source_database)
if preview.kind != "MASTER_PREVIEW" or preview.status not in ("SUCCEEDED", "PARTIAL"):
raise WorkbenchError("PREVIEW_REQUIRED")
rows, frozen, fingerprint = build_master_plan(session, preview, lock_targets=True)
if preview.snapshot_hash != fingerprint:
raise WorkbenchError("PREVIEW_STALE")
run.snapshot_hash, run.snapshot_json = fingerprint, frozen
counts = Counter()
for i, row in enumerate(rows, 1):
action, reason = row["action"], row.get("reason")
if action in ("CREATE", "UPDATE"):
try:
with session.begin_nested():
model = MODELS[row["entity_type"]]
if action == "CREATE":
target = model(
**row["values"],
t_plus_id=row["source_id"],
source_no=row.get("source_no"),
sync_batch_no=run.run_no,
synced_at=datetime.now(),
)
session.add(target)
session.flush()
session.add(
TPlusSourceLink(
source_database=run.source_database,
entity_type=row["entity_type"],
source_id=row["source_id"],
target_id=target.id,
)
)
else:
target = session.get(model, row["target_id"], with_for_update=True)
for field, value in row["values"].items():
setattr(target, field, value)
target.sync_batch_no, target.synced_at = run.run_no, datetime.now()
session.flush()
row["target_id"] = target.id
except IntegrityError:
action, reason = "CONFLICT", "TARGET_UNIQUE_CONFLICT"
counts[action] += 1
_line(session, run, i, row, action, reason)
run.summary_json = {"counts": dict(counts), "total": len(rows), "formal_payroll_written": False}
run.status = "PARTIAL" if counts["PENDING"] or counts["CONFLICT"] else "SUCCEEDED"
+84
View File
@@ -0,0 +1,84 @@
"""从源库只读整理核对资料,不把费用空值或旧初始化余额当作已确认事实。"""
import hashlib
import json
from datetime import date, datetime
from decimal import Decimal
from pathlib import Path
from sqlalchemy import text
from app.core.exceptions import BadRequestError
from app.core.month import validate_month
QUERIES = ("customer", "shipments", "items", "receipts", "opening", "history", "expenses")
ROOT = Path(__file__).resolve().parents[4] / "db/queries/tplus/materials"
def summarize(records, customer_id, month):
"""空记录只构成候选余额依据,不自动替代业务确认。"""
if len(records["customer"]) != 1:
raise BadRequestError("源客户不存在或身份不唯一")
truncated = [name for name, rows in records.items() if len(rows) > 200]
missing = [
{"key": "GUIDANCE", "title": "发货月份人工指导成本", "reason": "T+成本不用于绩效,需填写并封存人工指导成本"},
{"key": "PRODUCT_CATEGORY", "title": "产品绩效核算分类", "reason": "源产品分类不等于市政水利/轨道交通核算分类"},
{"key": "HISTORICAL_PAYROLL", "title": "样本是否已在旧软件核算或发放", "reason": "源应收及收款记录不能证明绩效已发放"},
{"key": "MANAGER_HANDOVER", "title": "样本当时的分管及交接依据", "reason": "保留源业务员工号,不按当前部门推断历史经理或接手人"},
]
if not records["customer"][0].get("customerType"):
missing.append({"key": "CUSTOMER_CATEGORY", "title": "客户绩效分类", "reason": "源客户绩效属性为空"})
missing.append({"key": "FREIGHT", "title": "该批发货运费是否已剔除", "reason": "费用表和金额仅作证据,缺少关联或空值不自动等于零运费"})
no_old_rows = not records["opening"] and not records["history"]
return {
"customer_id": customer_id, "month": month, "captured_at": datetime.now().isoformat(),
"source_read_only": True, "formal_payroll_written": False,
"found": {name: len(rows) for name, rows in records.items()},
"opening_candidate": "0.00" if no_old_rows else None,
"opening_confirmed": False,
"opening_explanation": "未查到初始化及切换日前应收记录,仅作为候选零余额依据" if no_old_rows else "需将初始化记录与截至2026-02-28的应收变动核对,不直接使用2017年期初",
"truncated": truncated, "ready_for_amount": False, "missing": missing,
"evidence": {name: rows[:200] for name, rows in records.items()},
}
def collect_materials(source, customer_id, month):
validate_month(month)
if customer_id <= 0:
raise BadRequestError("客户ID必须为正整数")
year, mon = map(int, month.split("-"))
until = date(year + (mon == 12), 1 if mon == 12 else mon + 1, 1)
args = {"customer_id": customer_id, "until": until, "cutoff": date(2026, 3, 1)}
records = {name: [dict(r) for r in source.execute(text((ROOT / f"{name}.sql").read_text(encoding="utf-8")), args).mappings()] for name in QUERIES}
result = summarize(records, customer_id, month)
normalized = json.loads(json.dumps(result, ensure_ascii=False, default=str))
normalized["evidence_hash"] = hashlib.sha256(json.dumps(normalized["evidence"], sort_keys=True, ensure_ascii=False).encode()).hexdigest()
return normalized
def render_markdown(report):
def money(value):
return "待核对" if value is None else f"{Decimal(str(value)):,.2f}"
customer = report["evidence"]["customer"][0]
lines = [f"# 样本资料核对:{customer['name']}({report['month']})", "",
f"核对时间:{report['captured_at']}。源库只读;未写入正式工资。", "",
"## 已从恢复库整理", "", "| 类别 | 数量 |", "|---|---:|"]
labels = {"customer": "客户", "shipments": "发货单", "items": "发货明细", "receipts": "回款凭证关联行", "opening": "初始化应收", "history": "切换日前应收分组", "expenses": "销售费用分摊"}
lines += [f"| {labels[name]} | {count} |" for name, count in report["found"].items()]
lines += ["", "## 单据与归属", "", "| 发货单 | 日期 | 含税金额 | 业务员工号 |", "|---|---|---:|---|"]
lines += [f"| {r['code']} | {str(r['voucherdate'])[:10]} | {money(r['taxAmount'])} | {r.get('personnel_code') or '待核对'} |" for r in report["evidence"]["shipments"]]
lines += ["", "| 产品代码 | 品名/规格 | 数量 | 含税单价 | 含税金额 | 运费 |", "|---|---|---:|---:|---:|---:|"]
lines += [f"| {r.get('product_code', '待核对')} | {r.get('product_name', '')} {r.get('specification', '')} | {r.get('quantity', '待核对')} | {money(r.get('taxPrice'))} | {money(r.get('taxAmount'))} | {money(r.get('expenseAmount'))} |" for r in report["evidence"]["items"]]
lines += ["", "| 回款单 | 日期 | 金额 | 总账凭证 |", "|---|---|---:|---|"]
lines += [f"| {r['code']} | {str(r['voucherDate'])[:10]} | {money(r['amount'])} | {r.get('accounting_voucher_code') or '待核对'} |" for r in report["evidence"]["receipts"]]
lines += ["", "## 可推导但尚未确认", "", report["opening_explanation"],
f"候选期初余额:{report['opening_candidate'] or '待核对'}。本报告不自动确认期初余额。", "",
"## 仅需补充的资料", ""]
lines += [f"- {item['title']}:{item['reason']}。" for item in report["missing"]]
if report.get("customer_reply"):
lines += ["", "## 本轮补充回复", "", report["customer_reply"]]
if report["truncated"]:
lines += ["", "存在超出200行的资料,当前核对包不完整,不能作为完整金额验收依据。"]
lines += ["", "指导成本、分类或关键依据缺失,实际工资金额尚未验收,不填默认值。", "", f"证据指纹:`{report['evidence_hash']}`", ""]
return "\n".join(lines)
@@ -11,6 +11,8 @@ from app.schemas.tplus import (
TPlusOverviewOut,
)
from app.services.tplus import task_service
from app.services.tplus.automation import overview as automation_overview
from app.services.tplus.errors import MESSAGES
from app.services.tplus.mapping import DETAIL_MAPPINGS, TPLUS_MAPPINGS
from app.services.tplus.relationship_audit import public_report
@@ -22,6 +24,7 @@ ERROR_MESSAGES = {
"TYPE_UNRESOLVED": "关联核验所需的单据类型缺失或不唯一",
}
_ENTITY_TYPES = {item.entity_type for item in (*TPLUS_MAPPINGS, *DETAIL_MAPPINGS)}
ERROR_MESSAGES.update(MESSAGES)
def batch_out(batch, active_parents: set[str]) -> TPlusBatchOut:
@@ -51,6 +54,8 @@ def batch_out(batch, active_parents: set[str]) -> TPlusBatchOut:
error_summary="任务有异常,请查看异常明细"
if batch.status in ("FAILED", "PARTIAL")
else None,
trigger_type=getattr(batch, "trigger_type", "MANUAL") or "MANUAL",
automatic_attempt=getattr(batch, "automatic_attempt", 0) or 0,
)
@@ -119,4 +124,5 @@ def overview(session) -> TPlusOverviewOut:
status_counts=repository.status_counts(session),
total_staged_records=repository.total_staged_records(session),
latest_audit=public_report(latest.audit_result_json) if latest else None,
automation=automation_overview(session, settings=task_service.get_settings()),
)
@@ -29,10 +29,22 @@ _COLUMNS = {
"order_coverage": ("row_count", "without_order", "single_order", "multiple_orders"),
}
PENDING_CONFIRMATIONS = (
("cost_basis", "绩效成本取值", "订单最新成本或出库成本的类型、时点和含税口径需业务确认"),
("receipt_date", "到账日", "单据日、审核日和明细 arrivalDate 的业务含义需确认"),
("ship_date", "发货日", "回款天数应使用哪个发货日期需确认"),
("allocation", "多单分摊", "一笔收款涉及多张销货单或订单时,不能自动把整笔金额记给一单"),
(
"cost_basis",
"月度指导成本",
"采用人工含税指导成本;单位成本还是明细总成本、取哪个月份仍待明确",
),
(
"voucher_state",
"凭证例外核对",
"已按收款单与总账凭证的实际关联识别生成资格;无关联、作废或制单异常的记录保留待核对,不以已审核代替",
),
(
"opening_balance",
"历史承接",
"发货日期已明确取销货单日期;截至2026年2月28日的应收和已核算款待提供",
),
("allocation", "FIFO平账范围", "先发货先平账已明确;同客户全部业务还是分项目平账仍待确认"),
("negative_events", "退货与红冲", "负数、冲销及已结算业务的调整方式需确认"),
)
@@ -0,0 +1,85 @@
"""核对报告中的固定业务说明,未知编码保留以便排查。"""
ACTION_LABELS = {
"CREATE": "新增",
"UPDATE": "更新",
"SKIP": "跳过",
"PENDING": "待补充",
"CONFLICT": "冲突",
"READY": "可核对",
"LEGACY": "旧应收冲平",
"WAITING_SHIPMENT": "预收待发货",
"EXCLUDED": "不纳入本次预演",
}
REASON_LABELS = {
"CUSTOMER_SCOPE_CONFLICT": "同一客户存在不一致的期初或历史范围,请先合并为客户级核对资料",
"GUIDANCE_FINALIZATION_REQUIRED": "发货月份指导成本尚未录入完毕并封存",
"CUSTOMER_SCOPE_ALREADY_SELECTED": "该客户已纳入本次平账,重复选择已跳过",
"FORMAL_TARGET_CHANGED": "正式单据与已导入资料不一致,请先核对差异",
"INDEPENDENT_BUSINESS_REQUIRED": "请先按独立业务核实归属,跨业务冲款边界尚待答复",
"BUSINESS_DATA_PENDING": "该业务仍有资料待补,请查看对应核算预演",
"FORMAL_SOURCE_CHANGED": "源单据或人工依据与正式导入版本不同,请先核对差异",
"SOURCE_ALREADY_IMPORTED": "该源单据已经导入其他业务,不能重复导入",
"CUSTOMER_IMPORT_REQUIRED": "请先完成该客户的基础导入",
"PRODUCT_IMPORT_REQUIRED": "请先完成相关产品的基础导入",
"PERSONNEL_IMPORT_REQUIRED": "请先完成相关业务员及经理的基础导入",
"NOT_IN_PERSONNEL_ROSTER": "不在已确认人员名单中",
"PERSONNEL_IDENTITY_CONFLICT": "工号与名单姓名不一致",
"ROSTER_SOURCE_MISSING": "名单工号未在T+找到",
"BUSINESS_ROLE_MISSING": "系统岗位不存在",
"PRODUCT_CATEGORY_PENDING": "产品核算分类待补",
"PRODUCT_UNIT_PENDING": "产品单位待核对",
"NOT_CUSTOMER_ONLY": "往来单位不是仅客户性质",
"CUSTOMER_ATTRIBUTE_PENDING": "客户属性或负责人待补",
"TARGET_MISSING": "原导入目标已不存在",
"TARGET_UNIQUE_CONFLICT": "与现有记录唯一键冲突",
"DUPLICATE_SOURCE_IDENTITY": "源记录身份重复",
"GROUP_SCOPE_PENDING": "平账范围待确认",
"GROUP_FACT_REQUIRED": "尚未登记该平账组",
"OVERLAPPING_GROUPS": "同一单据被多个平账组重复选取",
"OPENING_BALANCE_PENDING": "期初应收待核实",
"HISTORICAL_SETTLEMENT_PENDING": "历史已核算或已发款待核实",
"VOUCHER_STATE_PENDING": "尚未核实收款已生成凭证",
"ACCOUNTING_VOUCHER_INVALID": "关联凭证缺失、已作废或制单状态异常",
"VOUCHER_EVIDENCE_CONFLICT": "凭证关联与人工核实不一致,请复核",
"NOT_GENERATED_VOUCHER": "无关联总账凭证,不纳入本次预演",
"NOT_RECEIPT": "属于付款单,不作为销售回款核算",
"GUIDANCE_PENDING": "缺少有效指导成本",
"QUANTITY_PENDING": "单位成本对应的发货数量需核对",
"GUIDANCE_MONTH_BASIS_PENDING": "指导价取值月份待确认",
"PREPAYMENT_PARAMETERS_PENDING": "预收业务采用发货日还是收款日参数待确认",
"AUGUST_RULE_PENDING": "8月调整参数待确认",
"PERSONNEL_PENDING": "归属人员待确认",
"CUSTOMER_CATEGORY_PENDING": "客户分类待确认",
"PARAMETERS_PENDING": "计提参数待补",
"NEGATIVE_OR_ZERO_DOCUMENT": "负数或零金额单据需人工核对",
"NEGATIVE_EVENT_REVIEW_REQUIRED": "红冲或作废业务需人工核对",
"LEGACY_SYSTEM_CALCULATES": "只冲旧应收,绩效由原软件计算",
"NO_ACTUAL_SHIPMENT": "未实际发货,暂不计提",
"FREIGHT_PENDING": "运费是否剔除及实际金额待核实",
"SHIPPING_OWNERSHIP_PENDING": "发货业务及人员归属待核实",
"HANDOVER_SHIPMENTS_PENDING": "在职交接的发货归属待核实",
"HANDOVER_PENDING": "离职日期或接手依据待补,不能猜测归属",
"TAX_AMOUNT_PENDING": "源含税金额缺失",
"SHIPMENT_STATE_PENDING": "销货单尚未核实为生效状态",
"GROUP_SOURCE_MISSING": "所选单据不在当前完整快照中",
"GROUP_CUSTOMER_MISMATCH": "平账组含有其他客户单据",
"SHIPMENT_DETAILS_MISSING": "发货明细不完整",
"SOURCE_REFRESH_REQUIRED": "需先完整读取相关源数据",
"AMOUNT_PRECISION_PENDING": "金额精度需核对",
"DATA_VALUE_INVALID": "源日期或金额格式需核对",
}
def reason_label(value):
return ";".join(REASON_LABELS.get(part, part) for part in (value or "").split(",") if part)
def spreadsheet_value(value):
"""防公式注入,并让带前导零的编号在电子表格中保持文本。"""
text = str(value) if value is not None else ""
if text.lstrip(" \t\r\n").startswith(("=", "+", "-", "@")) or (
text.isdigit() and ((text.startswith("0") and len(text) > 1) or len(text) > 15)
):
return "'" + text
return value
+53
View File
@@ -0,0 +1,53 @@
"""工作区只使用成功完整读取发布的当前源快照。"""
from sqlalchemy import select
from app.models import TPlusSourceHead, TPlusSyncBatch, TPlusSyncRecord
from app.services.tplus.workbench import WorkbenchError
def source_statement(session, source, entity):
"""只发布最后一次完整成功读取;中断覆盖的指针不得部分发布。"""
batches = session.scalars(
select(TPlusSyncBatch)
.where(
TPlusSyncBatch.source_database == source,
TPlusSyncBatch.sync_mode == "FULL",
)
.order_by(TPlusSyncBatch.id.desc())
)
batch_no = None
for batch in batches:
report = batch.audit_result_json or {}
if entity in (batch.entity_types_json or []) and batch.status != "SUCCEEDED":
raise WorkbenchError("SOURCE_REFRESH_REQUIRED")
if entity in report.get("observed_entities", []):
batch_no = batch.batch_no
break
if batch_no is None:
raise WorkbenchError("SOURCE_REFRESH_REQUIRED")
return (
select(TPlusSyncRecord)
.join(TPlusSourceHead, TPlusSourceHead.record_id == TPlusSyncRecord.id)
.where(
TPlusSourceHead.source_database == source,
TPlusSourceHead.entity_type == entity,
TPlusSourceHead.batch_no == batch_no,
)
)
def source_records(session, source, entity, ids=None, parent_ids=None):
"""旧暂存未建立当前指针时要求重新读取,禁止 max(record.id) 猜最新值。"""
if ids == [] or parent_ids == []:
return []
statement = source_statement(session, source, entity)
if ids:
statement = statement.where(TPlusSourceHead.source_id.in_(ids))
if parent_ids is not None:
statement = statement.where(
TPlusSyncRecord.payload_json["source_parent_id"]
.as_integer()
.in_([int(v) for v in parent_ids])
)
return list(session.scalars(statement.order_by(TPlusSyncRecord.source_id)))
@@ -0,0 +1,59 @@
"""保留成功读取实际观察到的当前版本,不把历史最大ID当成当前值。"""
from sqlalchemy import inspect, select, tuple_
from app.models import TPlusSourceHead, TPlusSyncRecord
from app.services.tplus.staging import prepare_row
def observe_rows(session, batch, mapping, rows):
"""每页提交前记录所有读到的源版本,包括去重跳过的 A→B→A。"""
# V15 运行中的升级窗口继续允许原读取功能;工作区本身要求 V16。
if not inspect(session.connection()).has_table(TPlusSourceHead.__tablename__):
return
prepared = [
prepare_row(batch.batch_no, mapping.entity_type, mapping.source_table, r)
for r in rows
if r.get("id") is not None
]
wanted = [(r["source_id"], r["row_hash"]) for r in prepared]
if not wanted:
return
records = {
(r.source_id, r.row_hash): r.id
for r in session.scalars(
select(TPlusSyncRecord).where(
TPlusSyncRecord.entity_type == mapping.entity_type,
tuple_(TPlusSyncRecord.source_id, TPlusSyncRecord.row_hash).in_(wanted),
)
)
}
source = batch.source_database
if not source:
raise ValueError("源账套身份缺失,不能发布当前快照")
current = {
h.source_id: h
for h in session.scalars(
select(TPlusSourceHead).where(
TPlusSourceHead.source_database == source,
TPlusSourceHead.entity_type == mapping.entity_type,
TPlusSourceHead.source_id.in_([v[0] for v in wanted]),
)
)
}
for row in prepared:
record_id = records.get((row["source_id"], row["row_hash"]))
if record_id is None:
continue
head = current.get(row["source_id"])
if head is None:
head = TPlusSourceHead(
source_database=source,
entity_type=mapping.entity_type,
source_id=row["source_id"],
record_id=record_id,
batch_no=batch.batch_no,
)
session.add(head)
current[row["source_id"]] = head
head.record_id, head.batch_no = record_id, batch.batch_no
+18 -2
View File
@@ -11,8 +11,10 @@ from sqlalchemy.orm import Session
from app.core.config import get_settings
from app.core.tplus_lock import sync_lock
from app.models import TPlusSyncBatch, TPlusSyncError
from app.services.tplus.errors import TRANSIENT_CODES, classify_source_error
from app.services.tplus.mapping import TPlusMapping, get_mapping
from app.services.tplus.reader import TPlusReader
from app.services.tplus.source_heads import observe_rows
from app.services.tplus.staging import stage_rows
from app.services.tplus.task_service import fail_batch, new_batch_no, recover_running
@@ -74,13 +76,18 @@ class TPlusSyncService:
if batch.status != "RUNNING" or not batch.entity_types_json:
raise ValueError("任务尚未领取或读取范围不完整")
mappings = [get_mapping(entity) for entity in batch.entity_types_json]
interrupted = False
for mapping in mappings:
try:
self._read_entity(batch, mapping, batch_size)
except Exception as exc:
self.target_session.rollback()
self._record_error(batch, mapping, {}, "READ_ERROR", type(exc).__name__)
code = classify_source_error(exc)
self._record_error(batch, mapping, {}, code, type(exc).__name__)
self.target_session.commit()
if code in TRANSIENT_CODES:
interrupted = True
break
batch.status = "SUCCEEDED"
if batch.failed_record_count:
failed_rows = (
@@ -94,7 +101,8 @@ class TPlusSyncService:
)
or 0
)
batch.status = "PARTIAL" if batch.source_record_count > failed_rows else "FAILED"
# 中途断连仍是未完成的全量采集;保留已暂存页,重跑由哈希防重。
batch.status = "PARTIAL" if not interrupted and batch.source_record_count > failed_rows else "FAILED"
batch.error_message = "部分数据未完成,请查询同步错误记录并重跑全量快照"
batch.finished_at = datetime.now()
batch.watermark_to = None
@@ -106,12 +114,20 @@ class TPlusSyncService:
source = self.reader.iter_entity(mapping.entity_type, batch_size=size)
for chunk in batched(source, size):
inserted, errors = stage_rows(self.target_session, batch.batch_no, mapping, list(chunk))
observe_rows(self.target_session, batch, mapping, list(chunk))
batch.source_record_count += len(chunk)
batch.staged_record_count += inserted
batch.skipped_record_count += len(chunk) - inserted - len(errors)
for row, reason in errors:
self._record_error(batch, mapping, row, "STAGE_ERROR", reason)
self.target_session.commit()
report = dict(batch.audit_result_json or {})
report["source_head_version"] = 1
report["observed_entities"] = list(
dict.fromkeys([*report.get("observed_entities", []), mapping.entity_type])
)
batch.audit_result_json = report
self.target_session.commit()
def _record_error(
self, batch: TPlusSyncBatch, mapping: TPlusMapping, row: dict, code: str, reason: str
@@ -16,6 +16,7 @@ from app.repositories import tplus_repository as repository
from app.schemas.tplus import TPlusCreateIn, TPlusRetryIn
from app.services import audit_service
from app.services.audit_service import AuditRecord, RequestMeta
from app.services.tplus.errors import MESSAGES
@dataclass(frozen=True)
@@ -164,6 +165,7 @@ def fail_batch(session: Session, batch: TPlusSyncBatch, code: str) -> None:
"TYPE_UNRESOLVED": "核验所需单据类型缺失或不唯一,请核对源字典",
}
message = messages.get(code, "任务未完成,请核对运行配置")
message = MESSAGES.get(code, message)
batch.status, batch.finished_at, batch.error_message = "FAILED", datetime.now(), message
batch.failed_record_count = (batch.failed_record_count or 0) + 1
session.add(
+295
View File
@@ -0,0 +1,295 @@
"""工作区任务与事实存取:与原有 worker 共用执行锁和持久化队列。"""
import hashlib
import json
import logging
from datetime import datetime
from sqlalchemy import func, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import defer
from app.core.exceptions import BadRequestError, NotFoundError
from app.core.response import PageResult
from app.core.tplus_lock import TPlusBusyError, sync_lock
from app.models import TPlusFact, TPlusWorkLine, TPlusWorkRun
from app.schemas.tplus_workbench import WorkCreateIn
from app.services.tplus.task_service import _source_database, new_batch_no
RULE_VERSION = "2026-10-30.2"
logger = logging.getLogger(__name__)
def digest(data):
"""稳定指纹用于固定规则、事实和来源版本。"""
return hashlib.sha256(
json.dumps(
data, ensure_ascii=False, sort_keys=True, default=str, separators=(",", ":")
).encode()
).hexdigest()
def latest_facts(session, source):
"""每个事实键只读取最近版本,历史行保持可追溯。"""
ids = (
select(func.max(TPlusFact.id))
.where(TPlusFact.source_database == source)
.group_by(TPlusFact.kind, TPlusFact.fact_key)
)
return {
(f.kind, f.fact_key): f
for f in session.scalars(
select(TPlusFact).where(TPlusFact.id.in_(ids)).order_by(TPlusFact.id)
)
}
def save_fact(session, payload, actor_id):
"""与导入共用锁,乐观版本不匹配时拒绝覆盖。"""
source = _source_database()
try:
with sync_lock(session):
session.rollback()
current = latest_facts(session, source).get((payload.kind, payload.fact_key))
if payload.kind == "GUIDANCE" and current and current.data_json.get("is_finalized"):
raise BadRequestError("该发货月份指导成本已封存,后续回款不得修改原指导成本")
if (current.version if current else 0) != payload.expected_version:
raise BadRequestError("补充资料已经变化,请刷新后再保存")
fact = TPlusFact(
source_database=source,
kind=payload.kind,
fact_key=payload.fact_key,
version=payload.expected_version + 1,
data_json=payload.data,
evidence=payload.evidence,
requested_by=actor_id,
)
session.add(fact)
session.commit()
return fact_out(fact)
except (TPlusBusyError, IntegrityError):
session.rollback()
raise BadRequestError("资料或任务正在更新,请刷新后重试") from None
def fact_out(fact):
"""仅工作区授权接口展示核算必需事实,不返回连接凭据。"""
return {
"id": fact.id,
"kind": fact.kind,
"fact_key": fact.fact_key,
"version": fact.version,
"data": fact.data_json,
"evidence": fact.evidence,
"requested_by": fact.requested_by,
"create_time": fact.create_time,
}
def list_facts(session, query):
source = _source_database()
ids = (
select(func.max(TPlusFact.id))
.where(TPlusFact.source_database == source)
.group_by(TPlusFact.kind, TPlusFact.fact_key)
)
condition = [TPlusFact.id.in_(ids)]
if query.kind:
condition.append(TPlusFact.kind == query.kind)
total = session.scalar(select(func.count()).select_from(TPlusFact).where(*condition))
rows = session.scalars(
select(TPlusFact)
.where(*condition)
.order_by(TPlusFact.id)
.offset(query.offset)
.limit(query.page_size)
)
return PageResult(
records=[fact_out(f) for f in rows],
total=total or 0,
page_num=query.page_num,
page_size=query.page_size,
)
def queue_work(session, payload: WorkCreateIn, actor_id):
"""HTTP 只排队;同幂等键的不同意图必须被拒绝。"""
values = payload.model_dump(mode="json", exclude={"request_key"})
source = _source_database()
key = str(payload.request_key)
found = session.scalar(select(TPlusWorkRun).where(TPlusWorkRun.request_key == key))
if found:
if (
found.input_json != values
or found.source_database != source
or found.requested_by != actor_id
):
raise BadRequestError("请求键已用于不同任务")
return work_out(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.startswith("BUSINESS_"):
from app.models import User
from app.services.payroll_access import ensure_payroll_writer
ensure_payroll_writer(session.get(User, actor_id))
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("必须选择已完成的正式业务导入预览")
if payload.replay_no:
previous = get_run(session, payload.replay_no, source)
if previous.kind != "CALC_PREVIEW" or previous.status not in ("SUCCEEDED", "PARTIAL"):
raise BadRequestError("只能重跑已完成的核算预演")
run = TPlusWorkRun(
run_no=new_batch_no(),
request_key=key,
kind=payload.kind,
status="QUEUED",
source_database=source,
requested_by=actor_id,
input_json=values,
)
try:
with session.begin_nested():
session.add(run)
session.flush()
session.commit()
except IntegrityError:
session.rollback()
return queue_work(session, payload, actor_id)
return work_out(run)
def get_run(session, number, source=None):
source = source or _source_database()
run = session.scalar(
select(TPlusWorkRun)
.where(TPlusWorkRun.run_no == number, TPlusWorkRun.source_database == source)
.options(defer(TPlusWorkRun.snapshot_json))
)
if run is None:
raise NotFoundError("工作区运行不存在")
return run
def work_out(run):
return {
"run_no": run.run_no,
"kind": run.kind,
"status": run.status,
"request_key": run.request_key,
"input": run.input_json,
"snapshot_hash": run.snapshot_hash,
"summary": run.summary_json,
"error_code": run.error_code,
"create_time": run.create_time,
"started_at": run.started_at,
"finished_at": run.finished_at,
"formal_payroll_written": False,
}
def list_work(session, query):
conditions = [TPlusWorkRun.source_database == _source_database()]
if query.kind:
conditions.append(TPlusWorkRun.kind == query.kind)
total = session.scalar(select(func.count()).select_from(TPlusWorkRun).where(*conditions))
rows = session.scalars(
select(TPlusWorkRun)
.options(defer(TPlusWorkRun.snapshot_json))
.where(*conditions)
.order_by(TPlusWorkRun.id.desc())
.offset(query.offset)
.limit(query.page_size)
)
return PageResult(
records=[work_out(r) for r in rows],
total=total or 0,
page_num=query.page_num,
page_size=query.page_size,
)
def list_lines(session, number, query):
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(
select(TPlusWorkLine)
.where(*conditions)
.order_by(TPlusWorkLine.line_no)
.offset(query.offset)
.limit(query.page_size)
)
return PageResult(
records=[
{
"line_no": r.line_no,
"entity_type": r.entity_type,
"source_id": r.source_id,
"action": r.action,
"reason": r.reason,
"data": r.data_json,
}
for r in rows
],
total=total or 0,
page_num=query.page_num,
page_size=query.page_size,
)
def execute_next(session):
"""调用方持有原有全局锁;中断任务保留明细,禁止不明状态自动应用。"""
for old in session.scalars(select(TPlusWorkRun).where(TPlusWorkRun.status == "RUNNING")):
old.status, old.error_code, old.finished_at = "FAILED", "INTERRUPTED", datetime.now()
session.commit()
run = session.scalar(
select(TPlusWorkRun)
.where(TPlusWorkRun.status == "QUEUED")
.order_by(TPlusWorkRun.id)
.limit(1)
.with_for_update()
)
if run is None:
session.rollback()
return None
run.status, run.started_at = "RUNNING", datetime.now()
session.commit()
try:
if run.source_database != _source_database():
raise WorkbenchError("SOURCE_CHANGED")
if run.kind.startswith("BUSINESS_"):
from app.services.tplus.business_import import execute_business
execute_business(session, run)
elif run.kind.startswith("MASTER_"):
from app.services.tplus.master_import import execute_master
execute_master(session, run)
else:
from app.services.tplus.calculation_preview import execute_preview
execute_preview(session, run, customer_scope=True)
run.finished_at = datetime.now()
session.commit()
except Exception as exc:
session.rollback()
run = get_run(session, run.run_no, run.source_database)
run.status, run.finished_at = "FAILED", datetime.now()
# 不让驱动异常、SQL 或原始数据进入响应。
run.error_code = str(exc) if isinstance(exc, WorkbenchError) else "EXECUTION_ERROR"
logger.error(
"工作区任务失败 | run_no=%s | exception_type=%s", run.run_no, type(exc).__name__
)
session.commit()
return run.run_no
class WorkbenchError(ValueError):
"""可公开的固定失败编码;不得传入客户原文或驱动异常。"""
+16 -3
View File
@@ -7,14 +7,25 @@ from app.core.tplus_db import get_tplus_session_factory, resolve_tplus_settings
from app.core.tplus_lock import TPlusBusyError, sync_lock
from app.repositories import tplus_repository as repository
from app.services.tplus import task_service
from app.services.tplus.automation import WorkerHeartbeat
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
def run_once(session, source_factory=None):
def run_once(session, source_factory=None, *, worker_owner=None):
"""返回本轮处理的批次号;忙锁或队列为空时不假称已执行。"""
try:
with sync_lock(session):
with sync_lock(session), WorkerHeartbeat(session, owner=worker_owner):
from sqlalchemy import inspect
from app.models import TPlusWorkRun
from app.services.tplus.workbench import execute_next
if inspect(session.connection()).has_table(TPlusWorkRun.__tablename__):
number = execute_next(session)
if number:
return number
task_service.recover_running(session)
batch = repository.next_queued(session)
if batch is None:
@@ -30,10 +41,12 @@ def run_once(session, source_factory=None):
def _execute(session, batch, source_factory):
configured = False
try:
settings = resolve_tplus_settings(task_service.get_settings())
if batch.source_database and batch.source_database != settings.t_plus_database:
raise RuntimeError("任务排队后的源账套配置已改变")
configured = True
factory = source_factory or get_tplus_session_factory()
with factory() as source:
if batch.sync_mode == "AUDIT":
@@ -55,7 +68,7 @@ def _execute(session, batch, source_factory):
batch,
"INTERRUPTED"
if isinstance(exc, (KeyboardInterrupt, SystemExit))
else "EXECUTION_ERROR",
else classify_source_error(exc, "EXECUTION_ERROR" if configured else "SOURCE_CONFIG_ERROR"),
)
if isinstance(exc, (KeyboardInterrupt, SystemExit)):
raise