fix(T+): 补齐指导成本与读取服务保护
补充业务详情指导成本入口、来源版本和历史封存保护,接入月份锁并完善权限复核。 同步接入读取服务运行状态到页面,补充前端控制、回归测试和服务端只读连接授权校验。 定向隔离测试:89 项通过,1 条既有 Starlette 弃用警告。
This commit is contained in:
1 parent
68e70fd6e2
commit
b8920c0d28
17 files changed
+375
-47
No files matched your search
@@ -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()]):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -13,9 +13,10 @@ pytestmark = pytest.mark.integration
|
||||
BASE = "/api/v1/system/tplus"
|
||||
|
||||
|
||||
def test_新增配置表迁移可往返且不修改同步批次():
|
||||
def test_不可逆迁移拒绝回退且不修改同步批次():
|
||||
from alembic import command
|
||||
from alembic.config import Config
|
||||
from alembic.script import ScriptDirectory
|
||||
from sqlalchemy import inspect, text
|
||||
|
||||
from app.core.db import get_engine
|
||||
@@ -25,12 +26,12 @@ def test_新增配置表迁移可往返且不修改同步批次():
|
||||
with engine.connect() as connection:
|
||||
before = connection.exec_driver_sql('SELECT COUNT(*) FROM tplus_sync_batches').scalar_one()
|
||||
config = Config(str(Path(__file__).resolve().parents[2] / "alembic.ini"))
|
||||
command.downgrade(config, "26e7f8a9b0c1")
|
||||
assert not inspect(engine).has_table("tplus_automation_configs")
|
||||
with pytest.raises(RuntimeError, match="不可直接回退"):
|
||||
command.downgrade(config, "26e7f8a9b0c1")
|
||||
command.upgrade(config, "head")
|
||||
assert inspect(engine).has_table("tplus_automation_configs")
|
||||
with engine.connect() as connection:
|
||||
assert connection.execute(text('SELECT version_num FROM alembic_version')).scalar_one() == '28a9b0c1d2e3'
|
||||
assert connection.execute(text('SELECT version_num FROM alembic_version')).scalar_one() == ScriptDirectory.from_config(config).get_current_head()
|
||||
assert connection.exec_driver_sql('SELECT COUNT(*) FROM tplus_sync_batches').scalar_one() == before
|
||||
|
||||
|
||||
|
||||
@@ -21,7 +21,10 @@ def script(monkeypatch):
|
||||
spec = importlib.util.spec_from_file_location("compatibility_script", path)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
settings = Settings(_env_file=None, app_secret_key="x" * 32, t_plus_encrypt=True)
|
||||
settings = Settings(
|
||||
_env_file=None, app_secret_key="x" * 32, t_plus_encrypt=True,
|
||||
t_plus_allow_dev_sa=False, t_plus_allow_admin_account=False,
|
||||
)
|
||||
monkeypatch.setattr(module, "get_settings", lambda: settings)
|
||||
return module
|
||||
|
||||
@@ -44,7 +47,7 @@ def test_脚本复用连接文件校验与加密设置(script, tmp_path):
|
||||
assert script.get_settings().t_plus_enabled is False
|
||||
payload["user"] = "sa"
|
||||
path.write_text(json.dumps(payload), encoding="utf-8")
|
||||
with pytest.raises(RuntimeError, match="专用只读"):
|
||||
with pytest.raises(RuntimeError, match="管理员账号未获授权"):
|
||||
script.compatibility_settings(path)
|
||||
|
||||
|
||||
|
||||
@@ -72,11 +72,37 @@ def test_连接文件不接受sa或应用配置覆盖(tmp_path):
|
||||
resolve_tplus_settings(settings)
|
||||
|
||||
|
||||
def test_生产连接文件允许显式授权的管理员账号(tmp_path):
|
||||
path = tmp_path / "reader.json"
|
||||
path.write_text(
|
||||
json.dumps({
|
||||
"host": "127.0.0.1", "port": 1433, "database": "test_source",
|
||||
"user": "sa", "password": "test-only-value", "driver": "test_driver",
|
||||
}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
settings = Settings(
|
||||
_env_file=None,
|
||||
app_secret_key="x" * 32,
|
||||
app_env="prod",
|
||||
t_plus_enabled=True,
|
||||
t_plus_allow_admin_account=True,
|
||||
t_plus_connection_file=str(path),
|
||||
)
|
||||
assert resolve_tplus_settings(settings).t_plus_user == "sa"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("environment", "allowed", "accepted"),
|
||||
[("dev", False, False), ("prod", True, False), ("dev", True, True)],
|
||||
("environment", "allowed", "admin_allowed", "accepted"),
|
||||
[
|
||||
("dev", False, False, False),
|
||||
("prod", True, False, False),
|
||||
("dev", True, False, True),
|
||||
("prod", False, True, True),
|
||||
("prod", False, False, False),
|
||||
],
|
||||
)
|
||||
def test_sa仅允许显式启用的开发环境(environment, allowed, accepted):
|
||||
def test_sa仅允许显式授权(environment, allowed, admin_allowed, accepted):
|
||||
settings = Settings(
|
||||
_env_file=None,
|
||||
app_secret_key="x" * 32,
|
||||
@@ -86,9 +112,10 @@ def test_sa仅允许显式启用的开发环境(environment, allowed, accepted):
|
||||
t_plus_database="test_source",
|
||||
t_plus_odbc_driver="test_driver",
|
||||
t_plus_allow_dev_sa=allowed,
|
||||
t_plus_allow_admin_account=admin_allowed,
|
||||
)
|
||||
if accepted:
|
||||
assert resolve_tplus_settings(settings).t_plus_user == "sa"
|
||||
else:
|
||||
with pytest.raises(RuntimeError, match="不能使用 sa"):
|
||||
with pytest.raises(RuntimeError, match="管理员账号未获授权"):
|
||||
resolve_tplus_settings(settings)
|
||||
@@ -433,6 +433,21 @@ def test_资料版本冲突不覆盖_历史保留(work_session):
|
||||
assert s.scalar(select(func.count()).select_from(TPlusFact)) == 2
|
||||
|
||||
|
||||
def test_旧工作台不能绕过指导成本封存检查(work_session):
|
||||
from app.core.exceptions import BadRequestError
|
||||
|
||||
data = {"product_id": "9", "month": "2026-03", "basis": "UNIT", "amount": "25", "unit": "件"}
|
||||
with pytest.raises(BadRequestError, match="业务详情"):
|
||||
fact(work_session, "GUIDANCE", "legacy", {**data, "is_finalized": True})
|
||||
work_session.add(TPlusFact(
|
||||
source_database="test_source", kind="GUIDANCE", fact_key="sealed", version=1,
|
||||
data_json={**data, "is_finalized": True}, evidence="已核对", requested_by=1,
|
||||
))
|
||||
work_session.commit()
|
||||
with pytest.raises(BadRequestError, match="已有封存"):
|
||||
fact(work_session, "GUIDANCE", "another-key", data)
|
||||
|
||||
|
||||
def test_更正事实要求排除或原业务归属():
|
||||
with pytest.raises(ValidationError):
|
||||
FactIn(
|
||||
|
||||
@@ -57,6 +57,13 @@ def test_忙锁时不领取任务也不恢复其他运行记录(tplus_session, m
|
||||
assert batch.status == "QUEUED"
|
||||
|
||||
|
||||
def test_停止请求阻止领取排队任务(tplus_session, monkeypatch):
|
||||
source = _worker_setup(monkeypatch)
|
||||
batch = _queued(tplus_session)
|
||||
assert worker.run_once(tplus_session, source_factory=source, may_claim=lambda _session: False) is None
|
||||
assert batch.status == "QUEUED"
|
||||
|
||||
|
||||
def test_源连接失败写入固定错误不泄漏凭据(tplus_session, monkeypatch):
|
||||
_worker_setup(monkeypatch)
|
||||
batch = _queued(tplus_session)
|
||||
|
||||
Reference in new issue
Block a user