fix(T+): 补齐指导成本与读取服务保护

补充业务详情指导成本入口、来源版本和历史封存保护,接入月份锁并完善权限复核。
同步接入读取服务运行状态到页面,补充前端控制、回归测试和服务端只读连接授权校验。

定向隔离测试:89 项通过,1 条既有 Starlette 弃用警告。
This commit is contained in:
liuwenxin committed 2026-11-05 19:32:20 +08:00
1 parent 68e70fd6e2
commit b8920c0d28
17 files changed
+375 -47

No files matched your search

+12
View File
@@ -25,6 +25,7 @@ from app.schemas.business import (
OwnershipChangeLogOut,
)
from app.schemas.business_source import (
GuidanceConfirmIn,
SalesRelationConfirmIn,
SalesRelationQuery,
SalesRelationReleaseIn,
@@ -72,6 +73,17 @@ def confirm_source_price(session: DbSession, actor: PayrollWriter, business_id:
return ok(business_source.confirm_missing_source_price(session, business_id, document_id, payload, actor, meta))
@router.put("/{business_id}/guidance")
def confirm_guidance(session: DbSession, actor: PayrollWriter, business_id: Annotated[int, Path(ge=1)],
payload: GuidanceConfirmIn, meta: RequestMetaDep):
"""从本地发货明细登记来源产品的发货月份指导成本。"""
from app.services.authorization_service import require_permission
require_permission(session, actor, "payroll:prepare")
require_permission(session, actor, "tplus:facts")
return ok(business_source.confirm_guidance(session, business_id, payload, actor, meta))
@router.get("/{business_id}/sales-relation-candidates")
def sales_relation_candidates(session: DbSession, _: CurrentUser, business_id: Annotated[int, Path(ge=1)],
query: Annotated[SalesRelationQuery, Query()]):
+2
View File
@@ -89,6 +89,7 @@ class Settings(BaseSettings):
t_plus_user: str = ""
t_plus_password: str = Field(default="", repr=False)
t_plus_allow_dev_sa: bool = False
t_plus_allow_admin_account: bool = False
t_plus_connection_file: str = ""
# 部署前必须指定已在 SQL Server 2008 R2 实测的驱动,不自动选择最新版。
t_plus_odbc_driver: str = ""
@@ -102,6 +103,7 @@ class Settings(BaseSettings):
t_plus_schedule_weekday: int = Field(default=0, ge=0, le=6)
t_plus_schedule_time: str = Field(default="02:00", pattern=r"^([01][0-9]|2[0-3]):[0-5][0-9]$")
t_plus_stop_file: str = ""
t_plus_supervisor_health_url: str = "http://127.0.0.1:8000/api/health"
@field_validator("app_secret_key")
@classmethod
+4 -2
View File
@@ -56,8 +56,10 @@ def resolve_tplus_settings(settings: Settings) -> Settings:
)
resolved = settings.model_copy(update=values)
allow_dev_sa = settings.app_env.strip().casefold() == "dev" and settings.t_plus_allow_dev_sa
if resolved.t_plus_user.strip().casefold() == "sa" and not allow_dev_sa:
raise RuntimeError("应用必须使用 T+ 专用只读账号,不能使用 sa")
if resolved.t_plus_user.strip().casefold() == "sa" and not (
allow_dev_sa or settings.t_plus_allow_admin_account
):
raise RuntimeError("T+ 管理员账号未获授权,请检查连接配置")
if not resolved.t_plus_database or not resolved.t_plus_user or not resolved.t_plus_odbc_driver:
raise RuntimeError("T+ 只读连接缺少数据库、账号或已验证的 ODBC 驱动")
return resolved
+9 -5
View File
@@ -31,7 +31,12 @@ from app.models.role import Permission, Role, RolePermission
from app.models.settlement import AdjustmentOrder, MonthlySettlement, SettlementItem
from app.models.shipment import Shipment, ShipmentItem
from app.models.system_setting import SystemSetting
from app.models.tplus_automation import TPlusAutomationConfig, TPlusAutomationState
from app.models.tplus_automation import (
TPlusAutomationConfig,
TPlusAutomationState,
TPlusRuntimeControl,
)
from app.models.tplus_business_sync import TPlusBusinessDocument, TPlusBusinessSyncConfig
from app.models.tplus_sync import (
TPlusSyncBatch,
TPlusSyncError,
@@ -47,11 +52,7 @@ from app.models.tplus_workbench import (
)
from app.models.user import User, UserRole
from app.models.tplus_business_sync import TPlusBusinessDocument, TPlusBusinessSyncConfig
__all__ = [
"TPlusBusinessDocument",
"TPlusBusinessSyncConfig",
"AdjustmentOrder",
"ApprovalOrder",
"ApprovalStep",
@@ -95,7 +96,10 @@ __all__ = [
"SystemSetting",
"TPlusAutomationConfig",
"TPlusAutomationState",
"TPlusBusinessDocument",
"TPlusBusinessSyncConfig",
"TPlusFact",
"TPlusRuntimeControl",
"TPlusSourceHead",
"TPlusSourceLink",
"TPlusSyncBatch",
+23
View File
@@ -1,5 +1,6 @@
"""来源业务的人工分类资料。"""
from decimal import Decimal, InvalidOperation
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field, model_validator
@@ -29,6 +30,28 @@ class SourcePriceConfirmIn(BaseModel):
return self
class GuidanceConfirmIn(BaseModel):
"""从业务发货明细登记对应发货月份的单位指导成本。"""
model_config = ConfigDict(extra="forbid")
shipment_item_id: int = Field(ge=1)
amount: str = Field(min_length=1, max_length=32)
evidence: str = Field(min_length=1, max_length=500)
@model_validator(mode="after")
def validate_values(self):
self.evidence = self.evidence.strip()
if not self.evidence:
raise ValueError("核对依据不能为空")
try:
value = Decimal(self.amount)
except (InvalidOperation, TypeError, ValueError):
raise ValueError("指导成本必须为有效数字") from None
if not value.is_finite() or value < 0 or value.as_tuple().exponent < -4:
raise ValueError("指导成本最多保留4位小数且不能为负数")
return self
class SourceProductCategory(BaseModel):
product_id: int = Field(ge=1)
category: Literal["municipal_water", "rail_transit"]
+159 -3
View File
@@ -23,6 +23,7 @@ from app.models import (
ShipmentItem,
TPlusBusinessDocument,
TPlusBusinessSyncConfig,
TPlusFact,
TPlusSourceHead,
TPlusSyncRecord,
TPlusWorkRun,
@@ -631,7 +632,7 @@ def confirm_missing_source_price(session, business_id, document_id, payload, ope
raise BadRequestError("来源单价状态已变化,请刷新后核对")
target.sale_price = decimal_value(raw["price"], 4)
session.flush()
ctx = Context(session, source)
ctx = Context(session, source, operator)
action, checked = _apply_document(ctx, parent_type, head, details(ctx, parent_type, parent.source_id), "MANUAL", START)
if action not in ("UNCHANGED", "CONFLICT") or (action == "CONFLICT" and checked.reason != "SOURCE_PRICE_REVIEW_REQUIRED"):
raise BadRequestError("来源单据已变化,请刷新后核对")
@@ -642,6 +643,156 @@ def confirm_missing_source_price(session, business_id, document_id, payload, ope
return result
def confirm_guidance(session, business_id, payload, operator, meta=None):
"""登记并封存一个来源产品发货月份的单位指导成本。"""
from app.services.audit_service import AuditRecord, audit
from app.services.tplus.business_sync import statement
from app.services.tplus.guidance import (
check_unit_guidance_conflicts,
frozen_guidance_shipments,
)
with source_calculation_lock(session):
audit_record = AuditRecord(
module="业务管理",
action="登记月度指导成本",
summary=f"业务 #{business_id} 发货明细 #{payload.shipment_item_id}",
request_data=payload.model_dump(mode="json"),
)
with audit(session, operator=operator, meta=meta, record=audit_record):
row = session.get(Business, business_id, with_for_update=True)
if row is None or not row.integration_json:
raise BadRequestError("仅可为自动接入业务登记指导成本")
source = row.integration_json.get("source_database")
if not source or not source_snapshot_ready(session, source):
raise BadRequestError("来源快照或业务接入未完成,请稍后重试")
if _latest(session, business_id) is not None:
raise BadRequestError("业务已有封存核算资料,请通过差异核对处理")
item = session.scalar(
select(ShipmentItem)
.join(Shipment, Shipment.id == ShipmentItem.shipment_id)
.where(ShipmentItem.id == payload.shipment_item_id, Shipment.business_id == business_id)
.with_for_update()
)
if item is None or not item.unit:
raise BadRequestError("发货明细不存在或缺少计量单位")
shipment = session.get(Shipment, item.shipment_id, with_for_update=True)
if shipment is None or shipment.business_id != business_id:
raise BadRequestError("发货单不存在或不属于该业务")
documents = list(session.scalars(
select(TPlusBusinessDocument)
.where(
TPlusBusinessDocument.business_id == business_id,
TPlusBusinessDocument.source_database == source,
TPlusBusinessDocument.entity_type == "shipment_items",
TPlusBusinessDocument.target_id == item.id,
TPlusBusinessDocument.status == "APPLIED",
TPlusBusinessDocument.applied_record_id.is_not(None),
)
))
if not documents:
raise BadRequestError("发货明细尚未建立来源关联")
if len(documents) != 1:
raise BadRequestError("发货明细来源关联不唯一,请先核对")
document = documents[0]
parent_id = document.data_json.get("source_parent_id")
parents = list(session.scalars(
select(TPlusBusinessDocument).where(
TPlusBusinessDocument.business_id == business_id,
TPlusBusinessDocument.source_database == source,
TPlusBusinessDocument.entity_type == "shipments",
TPlusBusinessDocument.source_id == str(parent_id),
TPlusBusinessDocument.status == "APPLIED",
TPlusBusinessDocument.applied_record_id.is_not(None),
)
))
if not parents:
raise BadRequestError("发货单尚未建立来源关联")
if len(parents) != 1:
raise BadRequestError("发货单来源关联不唯一,请先核对")
parent = parents[0]
current_line = session.scalar(
statement(session, source, "shipment_items").where(
TPlusSyncRecord.source_id == document.source_id
)
)
current_parent = session.scalar(
statement(session, source, "shipments").where(
TPlusSyncRecord.source_id == parent.source_id
)
)
if (
current_line is None
or current_parent is None
or current_line.id != document.applied_record_id
or current_parent.id != parent.applied_record_id
):
raise BadRequestError("来源单据已变化,请刷新后核对")
source_product_id = current_line.payload_json.get("idinventory")
if source_product_id is None:
raise BadRequestError("来源明细缺少产品身份,请先核对")
source_product_id = str(source_product_id).strip()
if not source_product_id.isascii() or not source_product_id.isdigit() or len(source_product_id) > 64:
raise BadRequestError("来源产品身份格式异常,请先核对")
source_date = current_parent.payload_json.get("voucherdate") or current_parent.payload_json.get("voucherDate")
if source_date is None or str(source_date)[:10] != shipment.ship_date.isoformat():
raise BadRequestError("来源发货日期已变化,请刷新后核对")
month = shipment.ship_date.strftime("%Y-%m")
amount = format(Decimal(payload.amount), "f")
facts = latest_facts(session, source)
fact_key = f"GUIDANCE:{source_product_id}:{month}:UNIT"
existing = facts.get(("GUIDANCE", fact_key))
if existing is not None:
existing_data = existing.data_json
if (
existing_data.get("is_finalized")
and existing_data.get("unit") == item.unit
and Decimal(str(existing_data.get("amount"))) == Decimal(amount)
):
return {
"status": "REUSED",
"fact_id": existing.id,
"product_id": str(source_product_id),
"month": month,
"unit": item.unit,
"amount": existing_data["amount"],
}
raise BadRequestError("同一产品同月已有不同指导成本,请先核对")
frozen = frozen_guidance_shipments(session, source, str(source_product_id), month)
check_unit_guidance_conflicts(
facts, str(source_product_id), month, item.unit, amount, frozen
)
fact = TPlusFact(
source_database=source,
kind="GUIDANCE",
fact_key=fact_key,
version=1,
data_json={
"product_id": str(source_product_id),
"month": month,
"basis": "UNIT",
"amount": amount,
"unit": item.unit,
"is_finalized": True,
},
evidence=payload.evidence,
requested_by=operator.id,
)
session.add(fact)
session.flush()
result = {
"status": "CREATED",
"fact_id": fact.id,
"product_id": str(source_product_id),
"month": month,
"unit": item.unit,
"amount": amount,
}
session.commit()
return result
def save_materials(session, business_id, payload, actor, meta=None):
"""同一业务引用的档案才可批量维护;不接受名称、金额或来源字段。"""
from app.services.audit_service import AuditRecord, audit
@@ -780,7 +931,11 @@ def _prepare_locked(session, business_id, payload, operator, meta=None):
submitted_cost = Decimal(entry.unit_cost) if guidance["basis"] == "UNIT" else cost_total
if submitted_cost != Decimal(guidance["amount"]):
raise BadRequestError("指导成本与已封存依据不一致,请先核对")
ship = {"source_id": str(head.id), "line_id": str(item.id), "source_no": head.source_no,
ship = {
"source_id": str(head.id),
"line_id": str(item.id),
"product_id": str(product_source_id) if product_source_id is not None else None,
"source_no": head.source_no,
"date": head.ship_date.isoformat(), "group_key": key, "business_key": key,
"personnel_id": str(entry.personnel_id), "manager_id": str(entry.manager_id) if entry.manager_id else None,
"amount": str(item.amount - freight), "gross_tax_amount": str(item.amount), "freight_amount": str(freight),
@@ -821,7 +976,8 @@ def _prepare_locked(session, business_id, payload, operator, meta=None):
"amount": str(receipt.amount), "voucher_confirmed": confirmed,
"exclusion": exclusion, "pending": [], "voucher_evidence": evidence})
from app.services.receipt_confirmation import ensure_confirmed
ensure_confirmed(session, [receipt.id for receipt, snapshot in zip(cash, receipts, strict=True) if not snapshot["exclusion"]])
ensure_confirmed(session, [receipt.id for receipt, snapshot in zip(cash, receipts, strict=True)
if not snapshot["exclusion"] and receipt.receipt_date.strftime("%Y-%m") <= payload.month])
values = {"input_schema": "TPLUS_SYNC_V1", "group": {"key": key, "scope_confirmed": True, "opening_confirmed": True,
"opening_balance": payload.opening_balance, "historical_settlement_confirmed": True},
"shipments": ships, "receipts": receipts, "facts": {"personnel": people_facts, "handovers": handovers},
+10 -4
View File
@@ -29,10 +29,8 @@ def month_state(session, month):
}
def lock_month(session, month, operator, *, require_maker=True):
"""INSERT 的唯一键串行化首次建行,避免两个请求同时认为月份不存在。"""
if require_maker:
ensure_payroll_writer(operator)
def lock_month_row(session, month, operator):
"""以月份唯一键串行化首次建行及后续操作,不改变已有制作人。"""
validate_month(month)
values = {"month": month, "maker_id": operator.id, "maker_name": operator.username}
if session.bind.dialect.name == "mysql":
@@ -43,7 +41,15 @@ def lock_month(session, month, operator, *, require_maker=True):
session.flush()
control = session.scalar(
select(PayrollMonth).where(PayrollMonth.month == month).with_for_update()
.execution_options(populate_existing=True)
)
return control
def lock_month(session, month, operator, *, require_maker=True):
if require_maker:
ensure_payroll_writer(operator)
control = lock_month_row(session, month, operator)
if control.generated_at is not None and control.maker_id != operator.id:
raise ForbiddenError("该月工资由其他制作人生成,不可代为修改或重新生成")
if control.generated_at is None:
+14 -10
View File
@@ -234,8 +234,9 @@ def preview_product_scope(session, source, start):
class Context:
def __init__(self, session, source):
def __init__(self, session, source, operator=None):
self.session, self.source = session, source
self.operator = operator
self.facts = latest_facts(session, source)
self.records, self.targets, self.recalculated = {}, {}, set()
self.people = {r.source_id: r for r in session.scalars(statement(session, source, "personnel"))}
@@ -556,16 +557,18 @@ def _apply_document(ctx, entity, record, children, run_no, start):
customer_id = raw.get("idpartner") if entity == "receipts" else raw.get("idcustomer")
if customer_id is None:
raise WorkbenchError("GROUP_CUSTOMER_MISMATCH")
# 新月份可追加,但已生成工资月份的迟到单据必须另行核对。
from app.models import MonthlySettlement
from app.services.payroll_months import lock_month_row
month = day.strftime("%Y-%m")
control = lock_month_row(ctx.session, month, ctx.operator)
locked = ctx.session.scalar(select(MonthlySettlement.id).where(
MonthlySettlement.settlement_month == month).limit(1).with_for_update())
if locked or (control and control.generated_at):
raise WorkbenchError("PAYROLL_MONTH_LOCKED")
business = ctx.business(customer_id, day, run_no)
if business.status != 1:
raise WorkbenchError("BUSINESS_TERMINAL_REVIEW_REQUIRED")
# 新月份可追加,但已生成工资月份的迟到单据必须另行核对。
from app.models import MonthlySettlement, PayrollMonth
month = day.strftime("%Y-%m")
locked = ctx.session.scalar(select(MonthlySettlement.id).where(MonthlySettlement.settlement_month == month).limit(1))
control = ctx.session.scalar(select(PayrollMonth).where(PayrollMonth.month == month))
if locked or (control and control.generated_at):
raise WorkbenchError("PAYROLL_MONTH_LOCKED")
person = ctx.person(raw)
summary = dict(business.integration_json)
for key, value in (("personnel_names", person["source_personnel_name"]), ("personnel_codes", person["source_personnel_no"])):
@@ -621,11 +624,12 @@ def execute_sync(session, run):
"""按单据提交;中断后幂等重跑,不生成 PayrollInput 或工资。"""
from app.models import User
from app.services.authorization_service import require_permission
require_permission(session, session.get(User, run.requested_by), "tplus:import")
operator = session.get(User, run.requested_by)
require_permission(session, operator, "tplus:import")
start = date.fromisoformat(run.input_json.get("start_date", START.isoformat()))
manifest = source_manifest(session, run.source_database)
rebuild_projections(session, run.source_database)
ctx, counts, processed = Context(session, run.source_database), Counter(), 0
ctx, counts, processed = Context(session, run.source_database, operator), Counter(), 0
for entity in ENTITIES:
# 先固定所有依赖的发布状态;失败的全量采集不得泄露半批数据。
if entity in DETAILS:
+17 -2
View File
@@ -64,9 +64,21 @@ def save_fact(session, payload, actor_id):
try:
with sync_lock(session):
session.rollback()
current = latest_facts(session, source).get((payload.kind, payload.fact_key))
facts = latest_facts(session, source)
current = facts.get((payload.kind, payload.fact_key))
if payload.kind == "GUIDANCE" and current and current.data_json.get("is_finalized"):
raise BadRequestError("该发货月份指导成本已封存,后续回款不得修改原指导成本")
if payload.kind == "GUIDANCE":
if payload.data.get("is_finalized"):
raise BadRequestError("请在业务详情中核对来源并封存指导成本")
if any(
kind == "GUIDANCE"
and fact.data_json.get("is_finalized")
and fact.data_json.get("product_id") == payload.data.get("product_id")
and fact.data_json.get("month") == payload.data.get("month")
for (kind, _), fact in facts.items()
):
raise BadRequestError("该产品发货月份已有封存指导成本,请先核对")
if (current.version if current else 0) != payload.expected_version:
raise BadRequestError("补充资料已经变化,请刷新后再保存")
fact = TPlusFact(
@@ -452,11 +464,14 @@ def report_csv(session, run_no):
return rows()
def execute_next(session):
def execute_next(session, *, may_claim=None):
"""调用方持有原有全局锁;中断任务保留明细,禁止不明状态自动应用。"""
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()
if may_claim is not None and not may_claim(session):
session.rollback()
return None
run = session.scalar(
select(TPlusWorkRun)
.where(TPlusWorkRun.status == "QUEUED")