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
@@ -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")
|
||||
|
||||
Reference in new issue
Block a user