fix(T+): 补齐指导成本与读取服务保护
补充业务详情指导成本入口、来源版本和历史封存保护,接入月份锁并完善权限复核。 同步接入读取服务运行状态到页面,补充前端控制、回归测试和服务端只读连接授权校验。 定向隔离测试:89 项通过,1 条既有 Starlette 弃用警告。
This commit is contained in:
parent
68e70fd6e2
commit
b8920c0d28
@ -25,6 +25,7 @@ from app.schemas.business import (
|
|||||||
OwnershipChangeLogOut,
|
OwnershipChangeLogOut,
|
||||||
)
|
)
|
||||||
from app.schemas.business_source import (
|
from app.schemas.business_source import (
|
||||||
|
GuidanceConfirmIn,
|
||||||
SalesRelationConfirmIn,
|
SalesRelationConfirmIn,
|
||||||
SalesRelationQuery,
|
SalesRelationQuery,
|
||||||
SalesRelationReleaseIn,
|
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))
|
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")
|
@router.get("/{business_id}/sales-relation-candidates")
|
||||||
def sales_relation_candidates(session: DbSession, _: CurrentUser, business_id: Annotated[int, Path(ge=1)],
|
def sales_relation_candidates(session: DbSession, _: CurrentUser, business_id: Annotated[int, Path(ge=1)],
|
||||||
query: Annotated[SalesRelationQuery, Query()]):
|
query: Annotated[SalesRelationQuery, Query()]):
|
||||||
|
|||||||
@ -89,6 +89,7 @@ class Settings(BaseSettings):
|
|||||||
t_plus_user: str = ""
|
t_plus_user: str = ""
|
||||||
t_plus_password: str = Field(default="", repr=False)
|
t_plus_password: str = Field(default="", repr=False)
|
||||||
t_plus_allow_dev_sa: bool = False
|
t_plus_allow_dev_sa: bool = False
|
||||||
|
t_plus_allow_admin_account: bool = False
|
||||||
t_plus_connection_file: str = ""
|
t_plus_connection_file: str = ""
|
||||||
# 部署前必须指定已在 SQL Server 2008 R2 实测的驱动,不自动选择最新版。
|
# 部署前必须指定已在 SQL Server 2008 R2 实测的驱动,不自动选择最新版。
|
||||||
t_plus_odbc_driver: str = ""
|
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_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_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_stop_file: str = ""
|
||||||
|
t_plus_supervisor_health_url: str = "http://127.0.0.1:8000/api/health"
|
||||||
|
|
||||||
@field_validator("app_secret_key")
|
@field_validator("app_secret_key")
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|||||||
@ -56,8 +56,10 @@ def resolve_tplus_settings(settings: Settings) -> Settings:
|
|||||||
)
|
)
|
||||||
resolved = settings.model_copy(update=values)
|
resolved = settings.model_copy(update=values)
|
||||||
allow_dev_sa = settings.app_env.strip().casefold() == "dev" and settings.t_plus_allow_dev_sa
|
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:
|
if resolved.t_plus_user.strip().casefold() == "sa" and not (
|
||||||
raise RuntimeError("应用必须使用 T+ 专用只读账号,不能使用 sa")
|
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:
|
if not resolved.t_plus_database or not resolved.t_plus_user or not resolved.t_plus_odbc_driver:
|
||||||
raise RuntimeError("T+ 只读连接缺少数据库、账号或已验证的 ODBC 驱动")
|
raise RuntimeError("T+ 只读连接缺少数据库、账号或已验证的 ODBC 驱动")
|
||||||
return resolved
|
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.settlement import AdjustmentOrder, MonthlySettlement, SettlementItem
|
||||||
from app.models.shipment import Shipment, ShipmentItem
|
from app.models.shipment import Shipment, ShipmentItem
|
||||||
from app.models.system_setting import SystemSetting
|
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 (
|
from app.models.tplus_sync import (
|
||||||
TPlusSyncBatch,
|
TPlusSyncBatch,
|
||||||
TPlusSyncError,
|
TPlusSyncError,
|
||||||
@ -47,11 +52,7 @@ from app.models.tplus_workbench import (
|
|||||||
)
|
)
|
||||||
from app.models.user import User, UserRole
|
from app.models.user import User, UserRole
|
||||||
|
|
||||||
from app.models.tplus_business_sync import TPlusBusinessDocument, TPlusBusinessSyncConfig
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"TPlusBusinessDocument",
|
|
||||||
"TPlusBusinessSyncConfig",
|
|
||||||
"AdjustmentOrder",
|
"AdjustmentOrder",
|
||||||
"ApprovalOrder",
|
"ApprovalOrder",
|
||||||
"ApprovalStep",
|
"ApprovalStep",
|
||||||
@ -95,7 +96,10 @@ __all__ = [
|
|||||||
"SystemSetting",
|
"SystemSetting",
|
||||||
"TPlusAutomationConfig",
|
"TPlusAutomationConfig",
|
||||||
"TPlusAutomationState",
|
"TPlusAutomationState",
|
||||||
|
"TPlusBusinessDocument",
|
||||||
|
"TPlusBusinessSyncConfig",
|
||||||
"TPlusFact",
|
"TPlusFact",
|
||||||
|
"TPlusRuntimeControl",
|
||||||
"TPlusSourceHead",
|
"TPlusSourceHead",
|
||||||
"TPlusSourceLink",
|
"TPlusSourceLink",
|
||||||
"TPlusSyncBatch",
|
"TPlusSyncBatch",
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
"""来源业务的人工分类资料。"""
|
"""来源业务的人工分类资料。"""
|
||||||
|
|
||||||
|
from decimal import Decimal, InvalidOperation
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||||
@ -29,6 +30,28 @@ class SourcePriceConfirmIn(BaseModel):
|
|||||||
return self
|
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):
|
class SourceProductCategory(BaseModel):
|
||||||
product_id: int = Field(ge=1)
|
product_id: int = Field(ge=1)
|
||||||
category: Literal["municipal_water", "rail_transit"]
|
category: Literal["municipal_water", "rail_transit"]
|
||||||
|
|||||||
@ -23,6 +23,7 @@ from app.models import (
|
|||||||
ShipmentItem,
|
ShipmentItem,
|
||||||
TPlusBusinessDocument,
|
TPlusBusinessDocument,
|
||||||
TPlusBusinessSyncConfig,
|
TPlusBusinessSyncConfig,
|
||||||
|
TPlusFact,
|
||||||
TPlusSourceHead,
|
TPlusSourceHead,
|
||||||
TPlusSyncRecord,
|
TPlusSyncRecord,
|
||||||
TPlusWorkRun,
|
TPlusWorkRun,
|
||||||
@ -631,7 +632,7 @@ def confirm_missing_source_price(session, business_id, document_id, payload, ope
|
|||||||
raise BadRequestError("来源单价状态已变化,请刷新后核对")
|
raise BadRequestError("来源单价状态已变化,请刷新后核对")
|
||||||
target.sale_price = decimal_value(raw["price"], 4)
|
target.sale_price = decimal_value(raw["price"], 4)
|
||||||
session.flush()
|
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)
|
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"):
|
if action not in ("UNCHANGED", "CONFLICT") or (action == "CONFLICT" and checked.reason != "SOURCE_PRICE_REVIEW_REQUIRED"):
|
||||||
raise BadRequestError("来源单据已变化,请刷新后核对")
|
raise BadRequestError("来源单据已变化,请刷新后核对")
|
||||||
@ -642,6 +643,156 @@ def confirm_missing_source_price(session, business_id, document_id, payload, ope
|
|||||||
return result
|
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):
|
def save_materials(session, business_id, payload, actor, meta=None):
|
||||||
"""同一业务引用的档案才可批量维护;不接受名称、金额或来源字段。"""
|
"""同一业务引用的档案才可批量维护;不接受名称、金额或来源字段。"""
|
||||||
from app.services.audit_service import AuditRecord, audit
|
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
|
submitted_cost = Decimal(entry.unit_cost) if guidance["basis"] == "UNIT" else cost_total
|
||||||
if submitted_cost != Decimal(guidance["amount"]):
|
if submitted_cost != Decimal(guidance["amount"]):
|
||||||
raise BadRequestError("指导成本与已封存依据不一致,请先核对")
|
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,
|
"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,
|
"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),
|
"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,
|
"amount": str(receipt.amount), "voucher_confirmed": confirmed,
|
||||||
"exclusion": exclusion, "pending": [], "voucher_evidence": evidence})
|
"exclusion": exclusion, "pending": [], "voucher_evidence": evidence})
|
||||||
from app.services.receipt_confirmation import ensure_confirmed
|
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,
|
values = {"input_schema": "TPLUS_SYNC_V1", "group": {"key": key, "scope_confirmed": True, "opening_confirmed": True,
|
||||||
"opening_balance": payload.opening_balance, "historical_settlement_confirmed": True},
|
"opening_balance": payload.opening_balance, "historical_settlement_confirmed": True},
|
||||||
"shipments": ships, "receipts": receipts, "facts": {"personnel": people_facts, "handovers": handovers},
|
"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):
|
def lock_month_row(session, month, operator):
|
||||||
"""INSERT 的唯一键串行化首次建行,避免两个请求同时认为月份不存在。"""
|
"""以月份唯一键串行化首次建行及后续操作,不改变已有制作人。"""
|
||||||
if require_maker:
|
|
||||||
ensure_payroll_writer(operator)
|
|
||||||
validate_month(month)
|
validate_month(month)
|
||||||
values = {"month": month, "maker_id": operator.id, "maker_name": operator.username}
|
values = {"month": month, "maker_id": operator.id, "maker_name": operator.username}
|
||||||
if session.bind.dialect.name == "mysql":
|
if session.bind.dialect.name == "mysql":
|
||||||
@ -43,7 +41,15 @@ def lock_month(session, month, operator, *, require_maker=True):
|
|||||||
session.flush()
|
session.flush()
|
||||||
control = session.scalar(
|
control = session.scalar(
|
||||||
select(PayrollMonth).where(PayrollMonth.month == month).with_for_update()
|
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:
|
if control.generated_at is not None and control.maker_id != operator.id:
|
||||||
raise ForbiddenError("该月工资由其他制作人生成,不可代为修改或重新生成")
|
raise ForbiddenError("该月工资由其他制作人生成,不可代为修改或重新生成")
|
||||||
if control.generated_at is None:
|
if control.generated_at is None:
|
||||||
|
|||||||
@ -234,8 +234,9 @@ def preview_product_scope(session, source, start):
|
|||||||
|
|
||||||
|
|
||||||
class Context:
|
class Context:
|
||||||
def __init__(self, session, source):
|
def __init__(self, session, source, operator=None):
|
||||||
self.session, self.source = session, source
|
self.session, self.source = session, source
|
||||||
|
self.operator = operator
|
||||||
self.facts = latest_facts(session, source)
|
self.facts = latest_facts(session, source)
|
||||||
self.records, self.targets, self.recalculated = {}, {}, set()
|
self.records, self.targets, self.recalculated = {}, {}, set()
|
||||||
self.people = {r.source_id: r for r in session.scalars(statement(session, source, "personnel"))}
|
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")
|
customer_id = raw.get("idpartner") if entity == "receipts" else raw.get("idcustomer")
|
||||||
if customer_id is None:
|
if customer_id is None:
|
||||||
raise WorkbenchError("GROUP_CUSTOMER_MISMATCH")
|
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)
|
business = ctx.business(customer_id, day, run_no)
|
||||||
if business.status != 1:
|
if business.status != 1:
|
||||||
raise WorkbenchError("BUSINESS_TERMINAL_REVIEW_REQUIRED")
|
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)
|
person = ctx.person(raw)
|
||||||
summary = dict(business.integration_json)
|
summary = dict(business.integration_json)
|
||||||
for key, value in (("personnel_names", person["source_personnel_name"]), ("personnel_codes", person["source_personnel_no"])):
|
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 或工资。"""
|
"""按单据提交;中断后幂等重跑,不生成 PayrollInput 或工资。"""
|
||||||
from app.models import User
|
from app.models import User
|
||||||
from app.services.authorization_service import require_permission
|
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()))
|
start = date.fromisoformat(run.input_json.get("start_date", START.isoformat()))
|
||||||
manifest = source_manifest(session, run.source_database)
|
manifest = source_manifest(session, run.source_database)
|
||||||
rebuild_projections(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:
|
for entity in ENTITIES:
|
||||||
# 先固定所有依赖的发布状态;失败的全量采集不得泄露半批数据。
|
# 先固定所有依赖的发布状态;失败的全量采集不得泄露半批数据。
|
||||||
if entity in DETAILS:
|
if entity in DETAILS:
|
||||||
|
|||||||
@ -64,9 +64,21 @@ def save_fact(session, payload, actor_id):
|
|||||||
try:
|
try:
|
||||||
with sync_lock(session):
|
with sync_lock(session):
|
||||||
session.rollback()
|
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"):
|
if payload.kind == "GUIDANCE" and current and current.data_json.get("is_finalized"):
|
||||||
raise BadRequestError("该发货月份指导成本已封存,后续回款不得修改原指导成本")
|
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:
|
if (current.version if current else 0) != payload.expected_version:
|
||||||
raise BadRequestError("补充资料已经变化,请刷新后再保存")
|
raise BadRequestError("补充资料已经变化,请刷新后再保存")
|
||||||
fact = TPlusFact(
|
fact = TPlusFact(
|
||||||
@ -452,11 +464,14 @@ def report_csv(session, run_no):
|
|||||||
return rows()
|
return rows()
|
||||||
|
|
||||||
|
|
||||||
def execute_next(session):
|
def execute_next(session, *, may_claim=None):
|
||||||
"""调用方持有原有全局锁;中断任务保留明细,禁止不明状态自动应用。"""
|
"""调用方持有原有全局锁;中断任务保留明细,禁止不明状态自动应用。"""
|
||||||
for old in session.scalars(select(TPlusWorkRun).where(TPlusWorkRun.status == "RUNNING")):
|
for old in session.scalars(select(TPlusWorkRun).where(TPlusWorkRun.status == "RUNNING")):
|
||||||
old.status, old.error_code, old.finished_at = "FAILED", "INTERRUPTED", datetime.now()
|
old.status, old.error_code, old.finished_at = "FAILED", "INTERRUPTED", datetime.now()
|
||||||
session.commit()
|
session.commit()
|
||||||
|
if may_claim is not None and not may_claim(session):
|
||||||
|
session.rollback()
|
||||||
|
return None
|
||||||
run = session.scalar(
|
run = session.scalar(
|
||||||
select(TPlusWorkRun)
|
select(TPlusWorkRun)
|
||||||
.where(TPlusWorkRun.status == "QUEUED")
|
.where(TPlusWorkRun.status == "QUEUED")
|
||||||
|
|||||||
@ -13,9 +13,10 @@ pytestmark = pytest.mark.integration
|
|||||||
BASE = "/api/v1/system/tplus"
|
BASE = "/api/v1/system/tplus"
|
||||||
|
|
||||||
|
|
||||||
def test_新增配置表迁移可往返且不修改同步批次():
|
def test_不可逆迁移拒绝回退且不修改同步批次():
|
||||||
from alembic import command
|
from alembic import command
|
||||||
from alembic.config import Config
|
from alembic.config import Config
|
||||||
|
from alembic.script import ScriptDirectory
|
||||||
from sqlalchemy import inspect, text
|
from sqlalchemy import inspect, text
|
||||||
|
|
||||||
from app.core.db import get_engine
|
from app.core.db import get_engine
|
||||||
@ -25,12 +26,12 @@ def test_新增配置表迁移可往返且不修改同步批次():
|
|||||||
with engine.connect() as connection:
|
with engine.connect() as connection:
|
||||||
before = connection.exec_driver_sql('SELECT COUNT(*) FROM tplus_sync_batches').scalar_one()
|
before = connection.exec_driver_sql('SELECT COUNT(*) FROM tplus_sync_batches').scalar_one()
|
||||||
config = Config(str(Path(__file__).resolve().parents[2] / "alembic.ini"))
|
config = Config(str(Path(__file__).resolve().parents[2] / "alembic.ini"))
|
||||||
command.downgrade(config, "26e7f8a9b0c1")
|
with pytest.raises(RuntimeError, match="不可直接回退"):
|
||||||
assert not inspect(engine).has_table("tplus_automation_configs")
|
command.downgrade(config, "26e7f8a9b0c1")
|
||||||
command.upgrade(config, "head")
|
command.upgrade(config, "head")
|
||||||
assert inspect(engine).has_table("tplus_automation_configs")
|
assert inspect(engine).has_table("tplus_automation_configs")
|
||||||
with engine.connect() as connection:
|
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
|
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)
|
spec = importlib.util.spec_from_file_location("compatibility_script", path)
|
||||||
module = importlib.util.module_from_spec(spec)
|
module = importlib.util.module_from_spec(spec)
|
||||||
spec.loader.exec_module(module)
|
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)
|
monkeypatch.setattr(module, "get_settings", lambda: settings)
|
||||||
return module
|
return module
|
||||||
|
|
||||||
@ -44,7 +47,7 @@ def test_脚本复用连接文件校验与加密设置(script, tmp_path):
|
|||||||
assert script.get_settings().t_plus_enabled is False
|
assert script.get_settings().t_plus_enabled is False
|
||||||
payload["user"] = "sa"
|
payload["user"] = "sa"
|
||||||
path.write_text(json.dumps(payload), encoding="utf-8")
|
path.write_text(json.dumps(payload), encoding="utf-8")
|
||||||
with pytest.raises(RuntimeError, match="专用只读"):
|
with pytest.raises(RuntimeError, match="管理员账号未获授权"):
|
||||||
script.compatibility_settings(path)
|
script.compatibility_settings(path)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -72,11 +72,37 @@ def test_连接文件不接受sa或应用配置覆盖(tmp_path):
|
|||||||
resolve_tplus_settings(settings)
|
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(
|
@pytest.mark.parametrize(
|
||||||
("environment", "allowed", "accepted"),
|
("environment", "allowed", "admin_allowed", "accepted"),
|
||||||
[("dev", False, False), ("prod", True, False), ("dev", True, True)],
|
[
|
||||||
|
("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(
|
settings = Settings(
|
||||||
_env_file=None,
|
_env_file=None,
|
||||||
app_secret_key="x" * 32,
|
app_secret_key="x" * 32,
|
||||||
@ -86,9 +112,10 @@ def test_sa仅允许显式启用的开发环境(environment, allowed, accepted):
|
|||||||
t_plus_database="test_source",
|
t_plus_database="test_source",
|
||||||
t_plus_odbc_driver="test_driver",
|
t_plus_odbc_driver="test_driver",
|
||||||
t_plus_allow_dev_sa=allowed,
|
t_plus_allow_dev_sa=allowed,
|
||||||
|
t_plus_allow_admin_account=admin_allowed,
|
||||||
)
|
)
|
||||||
if accepted:
|
if accepted:
|
||||||
assert resolve_tplus_settings(settings).t_plus_user == "sa"
|
assert resolve_tplus_settings(settings).t_plus_user == "sa"
|
||||||
else:
|
else:
|
||||||
with pytest.raises(RuntimeError, match="不能使用 sa"):
|
with pytest.raises(RuntimeError, match="管理员账号未获授权"):
|
||||||
resolve_tplus_settings(settings)
|
resolve_tplus_settings(settings)
|
||||||
|
|||||||
@ -433,6 +433,21 @@ def test_资料版本冲突不覆盖_历史保留(work_session):
|
|||||||
assert s.scalar(select(func.count()).select_from(TPlusFact)) == 2
|
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_更正事实要求排除或原业务归属():
|
def test_更正事实要求排除或原业务归属():
|
||||||
with pytest.raises(ValidationError):
|
with pytest.raises(ValidationError):
|
||||||
FactIn(
|
FactIn(
|
||||||
|
|||||||
@ -57,6 +57,13 @@ def test_忙锁时不领取任务也不恢复其他运行记录(tplus_session, m
|
|||||||
assert batch.status == "QUEUED"
|
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):
|
def test_源连接失败写入固定错误不泄漏凭据(tplus_session, monkeypatch):
|
||||||
_worker_setup(monkeypatch)
|
_worker_setup(monkeypatch)
|
||||||
batch = _queued(tplus_session)
|
batch = _queued(tplus_session)
|
||||||
|
|||||||
@ -90,6 +90,18 @@ export interface AutomationStatus {
|
|||||||
next_retry_at: string | null
|
next_retry_at: string | null
|
||||||
alerts: {code: string; message: string; batch_no?: string | null}[]
|
alerts: {code: string; message: string; batch_no?: string | null}[]
|
||||||
}
|
}
|
||||||
|
export interface RuntimeStatus {
|
||||||
|
desired_state: 'RUNNING' | 'STOPPED'
|
||||||
|
actual_state: 'RUNNING' | 'STOPPED' | 'STOPPING' | 'DEGRADED' | 'OFFLINE'
|
||||||
|
worker_phase: string
|
||||||
|
heartbeat_at: string | null
|
||||||
|
last_error: string | null
|
||||||
|
host: string | null
|
||||||
|
platform: string | null
|
||||||
|
version: number
|
||||||
|
updated_by: number | null
|
||||||
|
editable: boolean
|
||||||
|
}
|
||||||
export interface BatchQuery { status?: TaskStatus; task_type?: TaskType; pageNum?: number; pageSize?: number }
|
export interface BatchQuery { status?: TaskStatus; task_type?: TaskType; pageNum?: number; pageSize?: number }
|
||||||
export interface ErrorQuery { entity_type?: EntityType; pageNum?: number; pageSize?: number }
|
export interface ErrorQuery { entity_type?: EntityType; pageNum?: number; pageSize?: number }
|
||||||
export interface CreateTask { task_type: TaskType; entity_types: EntityType[]; request_key: string }
|
export interface CreateTask { task_type: TaskType; entity_types: EntityType[]; request_key: string }
|
||||||
@ -105,6 +117,12 @@ export function listBatches(params: BatchQuery, options: ReadOptions = {}): Prom
|
|||||||
export function updateAutomation(data: { enabled: boolean; expected_version: number }): Promise<AutomationStatus> {
|
export function updateAutomation(data: { enabled: boolean; expected_version: number }): Promise<AutomationStatus> {
|
||||||
return request({ url: `${base}/automation`, method: 'put', data })
|
return request({ url: `${base}/automation`, method: 'put', data })
|
||||||
}
|
}
|
||||||
|
export function getRuntime(options: ReadOptions = {}): Promise<RuntimeStatus> {
|
||||||
|
return request({ url: `${base}/runtime`, ...options })
|
||||||
|
}
|
||||||
|
export function updateRuntime(data: { desired_state: 'RUNNING' | 'STOPPED'; expected_version: number }): Promise<RuntimeStatus> {
|
||||||
|
return request({ url: `${base}/runtime`, method: 'put', data })
|
||||||
|
}
|
||||||
/** 按需查询报告。 */
|
/** 按需查询报告。 */
|
||||||
export function getBatch(number: string, options: ReadOptions = {}): Promise<TPlusDetail> {
|
export function getBatch(number: string, options: ReadOptions = {}): Promise<TPlusDetail> {
|
||||||
return request({ url: `${base}/batches/${encodeURIComponent(number)}`, ...options })
|
return request({ url: `${base}/batches/${encodeURIComponent(number)}`, ...options })
|
||||||
|
|||||||
@ -20,8 +20,8 @@ import { useSyncPage } from './useSyncPage'
|
|||||||
|
|
||||||
const user = useUserStore()
|
const user = useUserStore()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const {overview, records, total, pageNum, pageSize, status, taskType, loading, failed,
|
const {overview, runtime, records, total, pageNum, pageSize, status, taskType, loading, failed,
|
||||||
saving, refreshedAt, refresh, changeAutomation, search, onPageChange, onSizeChange} = useSyncPage()
|
saving, runtimeSaving, refreshedAt, refresh, changeAutomation, changeRuntime, search, onPageChange, onSizeChange} = useSyncPage()
|
||||||
const activeTab = ref('records'), creating = ref(false), retrying = ref<string | null>(null)
|
const activeTab = ref('records'), creating = ref(false), retrying = ref<string | null>(null)
|
||||||
const submittedNumber = ref<string | null>(null)
|
const submittedNumber = ref<string | null>(null)
|
||||||
const detailNumber = ref<string | null>(typeof route.query.batch === 'string' && /^[A-Za-z0-9_-]{1,32}$/.test(route.query.batch) ? route.query.batch : null)
|
const detailNumber = ref<string | null>(typeof route.query.batch === 'string' && /^[A-Za-z0-9_-]{1,32}$/.test(route.query.batch) ? route.query.batch : null)
|
||||||
@ -32,6 +32,8 @@ const phases: Record<string, string> = {UNKNOWN:'暂未收到心跳', IDLE:'等
|
|||||||
const connectionLabel = computed(() => !overview.value ? '正在检查连接配置' : overview.value.is_configured ? 'T+ 读取配置就绪' : '读取配置尚未就绪')
|
const connectionLabel = computed(() => !overview.value ? '正在检查连接配置' : overview.value.is_configured ? 'T+ 读取配置就绪' : '读取配置尚未就绪')
|
||||||
const canCreate = computed(() => user.can('tplus:collect') && overview.value?.is_configured && !failed.value)
|
const canCreate = computed(() => user.can('tplus:collect') && overview.value?.is_configured && !failed.value)
|
||||||
const canConfigure = computed(() => user.can('tplus:collect') && automation.value?.editable && !failed.value)
|
const canConfigure = computed(() => user.can('tplus:collect') && automation.value?.editable && !failed.value)
|
||||||
|
const canControl = computed(() => user.can('tplus:collect') && runtime.value?.editable && !failed.value && !saving.value)
|
||||||
|
const runtimeLabels: Record<string, string> = { RUNNING: '运行中', STOPPED: '已停止', STOPPING: '正在停止', DEGRADED: '等待依赖', OFFLINE: '服务离线' }
|
||||||
const switchReason = computed(() => !user.can('tplus:collect') ? user.permissionReason('tplus:collect') : !automation.value?.editable ? '连接配置或设置服务尚未就绪,请联系维护人员' : '开关保存后自动生效,当前读取任务不会中断')
|
const switchReason = computed(() => !user.can('tplus:collect') ? user.permissionReason('tplus:collect') : !automation.value?.editable ? '连接配置或设置服务尚未就绪,请联系维护人员' : '开关保存后自动生效,当前读取任务不会中断')
|
||||||
const alerts: Record<string, string> = {
|
const alerts: Record<string, string> = {
|
||||||
MIGRATION_REQUIRED:'采集设置尚未就绪,请联系维护人员检查服务。',
|
MIGRATION_REQUIRED:'采集设置尚未就绪,请联系维护人员检查服务。',
|
||||||
@ -77,11 +79,18 @@ async function retry(row: api.TPlusBatch): Promise<void> {
|
|||||||
<section class="console-panel automation-panel" aria-labelledby="automation-title">
|
<section class="console-panel automation-panel" aria-labelledby="automation-title">
|
||||||
<div class="automation-title"><h2 id="automation-title">每周自动采集</h2><el-switch :model-value="!!automation?.enabled" :loading="saving" :disabled="!canConfigure" :title="switchReason" aria-label="自动采集开关" @change="changeAutomation" /></div>
|
<div class="automation-title"><h2 id="automation-title">每周自动采集</h2><el-switch :model-value="!!automation?.enabled" :loading="saving" :disabled="!canConfigure" :title="switchReason" aria-label="自动采集开关" @change="changeAutomation" /></div>
|
||||||
<p class="automation-state" role="status">{{ saving ? '正在保存设置…' : automation?.enabled ? '自动采集已开启' : '自动采集已关闭' }}</p>
|
<p class="automation-state" role="status">{{ saving ? '正在保存设置…' : automation?.enabled ? '自动采集已开启' : '自动采集已关闭' }}</p>
|
||||||
<dl class="schedule-details"><div><dt>采集时间</dt><dd>每周{{ weekdays[automation?.weekday ?? 0] }} {{ automation?.time ?? '02:00' }}<small>北京时间</small></dd></div><div><dt>读取服务</dt><dd>{{ phases[automation?.worker_phase ?? 'UNKNOWN'] }}</dd></div><div><dt>最近成功</dt><dd>{{ formatDateTime(automation?.last_success_at) }}</dd></div><div v-if="automation?.next_retry_at"><dt>下次重试</dt><dd>{{ formatDateTime(automation.next_retry_at) }}</dd></div></dl>
|
<dl class="schedule-details"><div><dt>采集时间</dt><dd>每周{{ weekdays[automation?.weekday ?? 0] }} {{ automation?.time ?? '02:00' }}<small>北京时间</small></dd></div><div><dt>读取阶段</dt><dd>{{ phases[runtime?.worker_phase ?? 'UNKNOWN'] }}</dd></div><div><dt>最近成功</dt><dd>{{ formatDateTime(automation?.last_success_at) }}</dd></div><div v-if="automation?.next_retry_at"><dt>下次重试</dt><dd>{{ formatDateTime(automation.next_retry_at) }}</dd></div></dl>
|
||||||
<p class="settings-help">开启后按周读取;关闭只停止后续自动排队,当前任务继续完成。手动同步仍可使用。</p>
|
<p class="settings-help">开启后按周读取;关闭只停止后续自动排队,当前任务继续完成。手动同步仍可使用。</p>
|
||||||
<p v-if="!canConfigure" class="settings-help">{{ switchReason }}</p>
|
<p v-if="!canConfigure" class="settings-help">{{ switchReason }}</p>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
<section class="runtime-section" aria-labelledby="runtime-title">
|
||||||
|
<div class="runtime-heading"><h2 id="runtime-title">读取服务</h2><el-tag :type="runtime?.actual_state === 'RUNNING' ? 'success' : runtime?.actual_state === 'STOPPED' ? 'info' : 'warning'">{{ runtimeLabels[runtime?.actual_state ?? 'OFFLINE'] }}</el-tag></div>
|
||||||
|
<div class="runtime-details"><span>期望状态:{{ runtime?.desired_state === 'RUNNING' ? '运行' : '停止' }}</span><span>最近心跳:{{ formatDateTime(runtime?.heartbeat_at) }}</span><span v-if="runtime?.host">运行主机:{{ runtime.host }}({{ runtime.platform }})</span></div>
|
||||||
|
<p v-if="runtime?.last_error" class="runtime-error">{{ runtime.last_error }}</p>
|
||||||
|
<p v-if="!runtime?.editable" class="settings-help">服务管理尚未就绪,请联系维护人员。</p>
|
||||||
|
<div class="runtime-actions"><el-button type="primary" :disabled="!canControl || runtime?.desired_state === 'RUNNING'" :loading="runtimeSaving" @click="changeRuntime('RUNNING')">启动读取服务</el-button><el-button :disabled="!canControl || runtime?.desired_state === 'STOPPED'" :loading="runtimeSaving" @click="changeRuntime('STOPPED')">停止读取服务</el-button></div>
|
||||||
|
</section>
|
||||||
<el-alert v-for="alert in automation?.alerts ?? []" :key="alert.code" class="connection-alert" :title="alerts[alert.code] ?? '采集状态需要检查,请查看相关任务。'" type="warning" :closable="false" show-icon><el-button v-if="alert.batch_no" link type="primary" @click="detailNumber = alert.batch_no">查看相关任务</el-button></el-alert>
|
<el-alert v-for="alert in automation?.alerts ?? []" :key="alert.code" class="connection-alert" :title="alerts[alert.code] ?? '采集状态需要检查,请查看相关任务。'" type="warning" :closable="false" show-icon><el-button v-if="alert.batch_no" link type="primary" @click="detailNumber = alert.batch_no">查看相关任务</el-button></el-alert>
|
||||||
|
|
||||||
<BusinessSyncPanel />
|
<BusinessSyncPanel />
|
||||||
@ -134,6 +143,13 @@ async function retry(row: api.TPlusBatch): Promise<void> {
|
|||||||
.schedule-details dd { margin: 0; line-height: 1.65; font-variant-numeric: tabular-nums; }
|
.schedule-details dd { margin: 0; line-height: 1.65; font-variant-numeric: tabular-nums; }
|
||||||
.schedule-details small { margin-left: 6px; color: var(--ys-ink-2); font-size: 13px; }
|
.schedule-details small { margin-left: 6px; color: var(--ys-ink-2); font-size: 13px; }
|
||||||
.settings-help { font-size: 12px; line-height: 1.8; color: var(--ys-ink-2); margin: 18px 0 0; }
|
.settings-help { font-size: 12px; line-height: 1.8; color: var(--ys-ink-2); margin: 18px 0 0; }
|
||||||
|
.runtime-section { margin-top: 20px; padding: 18px 0; border-bottom: 1px solid var(--ys-line); }
|
||||||
|
.runtime-heading { display: flex; align-items: center; gap: 12px; }
|
||||||
|
.runtime-heading h2 { margin: 0; font-size: 16px; }
|
||||||
|
.runtime-details { display: flex; flex-wrap: wrap; gap: 8px 22px; margin-top: 12px; font-size: 13px; color: var(--ys-ink-2); }
|
||||||
|
.runtime-error { color: var(--el-color-danger); font-size: 13px; }
|
||||||
|
.runtime-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 14px; }
|
||||||
|
.runtime-actions .el-button + .el-button { margin-left: 0; }
|
||||||
.sync-boundary { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 10px 20px; padding: 18px 0; font-size: 13px; line-height: 1.7; }
|
.sync-boundary { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 10px 20px; padding: 18px 0; font-size: 13px; line-height: 1.7; }
|
||||||
.sync-boundary b { font-weight: 600; }
|
.sync-boundary b { font-weight: 600; }
|
||||||
.sync-boundary p { color: var(--ys-ink-2); margin: 4px 0 0; font-size: 12px; }
|
.sync-boundary p { color: var(--ys-ink-2); margin: 4px 0 0; font-size: 12px; }
|
||||||
|
|||||||
@ -6,10 +6,11 @@ import { handledError } from '@/utils/error'
|
|||||||
/** 页面与筛选使用同一请求序号,后台刷新保留已显示数据和交互位置。 */
|
/** 页面与筛选使用同一请求序号,后台刷新保留已显示数据和交互位置。 */
|
||||||
export function useSyncPage() {
|
export function useSyncPage() {
|
||||||
const overview = ref<api.TPlusOverview | null>(null)
|
const overview = ref<api.TPlusOverview | null>(null)
|
||||||
|
const runtime = ref<api.RuntimeStatus | null>(null)
|
||||||
const records = ref<api.TPlusBatch[]>([])
|
const records = ref<api.TPlusBatch[]>([])
|
||||||
const total = ref(0), pageNum = ref(1), pageSize = ref(10)
|
const total = ref(0), pageNum = ref(1), pageSize = ref(10)
|
||||||
const status = ref<api.TaskStatus>(), taskType = ref<api.TaskType>()
|
const status = ref<api.TaskStatus>(), taskType = ref<api.TaskType>()
|
||||||
const loading = ref(false), failed = ref(false), saving = ref(false)
|
const loading = ref(false), failed = ref(false), saving = ref(false), runtimeSaving = ref(false)
|
||||||
const refreshedAt = ref<Date | null>(null)
|
const refreshedAt = ref<Date | null>(null)
|
||||||
let shownPage = 1, shownSize = 10, sequence = 0, alive = false
|
let shownPage = 1, shownSize = 10, sequence = 0, alive = false
|
||||||
let timer: ReturnType<typeof setTimeout> | undefined
|
let timer: ReturnType<typeof setTimeout> | undefined
|
||||||
@ -18,16 +19,17 @@ export function useSyncPage() {
|
|||||||
function schedule(): void {
|
function schedule(): void {
|
||||||
clearTimeout(timer)
|
clearTimeout(timer)
|
||||||
if (!alive) return
|
if (!alive) return
|
||||||
const active = ['RUNNING', 'QUEUED'].includes(overview.value?.current_batch?.status ?? '')
|
const active = ['RUNNING', 'QUEUED'].includes(overview.value?.current_batch?.status ?? '') ||
|
||||||
|
(runtime.value?.actual_state !== 'OFFLINE' && runtime.value?.desired_state !== runtime.value?.actual_state)
|
||||||
timer = setTimeout(() => {
|
timer = setTimeout(() => {
|
||||||
if (document.hidden || saving.value) schedule()
|
if (document.hidden || saving.value || runtimeSaving.value) schedule()
|
||||||
else void refresh(true)
|
else void refresh(true)
|
||||||
}, active ? 5000 : 30000)
|
}, active ? 5000 : 30000)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refresh(background = false): Promise<void> {
|
async function refresh(background = false): Promise<void> {
|
||||||
if (!alive) return
|
if (!alive) return
|
||||||
if (saving.value) { schedule(); return }
|
if (saving.value || runtimeSaving.value) { schedule(); return }
|
||||||
const current = ++sequence
|
const current = ++sequence
|
||||||
controller?.abort()
|
controller?.abort()
|
||||||
controller = new AbortController()
|
controller = new AbortController()
|
||||||
@ -35,9 +37,10 @@ export function useSyncPage() {
|
|||||||
const query = {pageNum: pageNum.value, pageSize: pageSize.value, status: status.value, task_type: taskType.value}
|
const query = {pageNum: pageNum.value, pageSize: pageSize.value, status: status.value, task_type: taskType.value}
|
||||||
const options = {silentError: background, signal: controller.signal}
|
const options = {silentError: background, signal: controller.signal}
|
||||||
try {
|
try {
|
||||||
const [summary, page] = await Promise.all([api.getOverview(options), api.listBatches(query, options)])
|
const [summary, page, service] = await Promise.all([api.getOverview(options), api.listBatches(query, options), api.getRuntime(options)])
|
||||||
if (!alive || current !== sequence) return
|
if (!alive || current !== sequence) return
|
||||||
overview.value = summary
|
overview.value = summary
|
||||||
|
runtime.value = service
|
||||||
records.value = page.list
|
records.value = page.list
|
||||||
total.value = page.total
|
total.value = page.total
|
||||||
shownPage = query.pageNum
|
shownPage = query.pageNum
|
||||||
@ -57,6 +60,20 @@ export function useSyncPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function changeRuntime(desired_state: 'RUNNING' | 'STOPPED'): Promise<void> {
|
||||||
|
const current = runtime.value
|
||||||
|
if (!current || runtimeSaving.value || saving.value) return
|
||||||
|
runtimeSaving.value = true
|
||||||
|
++sequence
|
||||||
|
controller?.abort()
|
||||||
|
loading.value = false
|
||||||
|
try {
|
||||||
|
runtime.value = await api.updateRuntime({desired_state, expected_version: current.version})
|
||||||
|
ElMessage.success(desired_state === 'RUNNING' ? '已请求启动读取服务,等待状态更新' : '已请求停止读取服务,当前任务完成后停止')
|
||||||
|
} catch (error) { handledError(error) }
|
||||||
|
finally { runtimeSaving.value = false; if (alive) await refresh(true) }
|
||||||
|
}
|
||||||
|
|
||||||
async function changeAutomation(value: boolean | string | number): Promise<void> {
|
async function changeAutomation(value: boolean | string | number): Promise<void> {
|
||||||
const config = overview.value?.automation
|
const config = overview.value?.automation
|
||||||
if (typeof value !== 'boolean' || !config || saving.value) return
|
if (typeof value !== 'boolean' || !config || saving.value) return
|
||||||
@ -80,9 +97,9 @@ export function useSyncPage() {
|
|||||||
function search(): void { pageNum.value = 1; void refresh() }
|
function search(): void { pageNum.value = 1; void refresh() }
|
||||||
function onPageChange(value: number): void { pageNum.value = value; void refresh() }
|
function onPageChange(value: number): void { pageNum.value = value; void refresh() }
|
||||||
function onSizeChange(value: number): void { pageSize.value = value; pageNum.value = 1; void refresh() }
|
function onSizeChange(value: number): void { pageSize.value = value; pageNum.value = 1; void refresh() }
|
||||||
function visible(): void { if (!document.hidden && !saving.value) void refresh(true) }
|
function visible(): void { if (!document.hidden && !saving.value && !runtimeSaving.value) void refresh(true) }
|
||||||
onMounted(() => { alive = true; document.addEventListener('visibilitychange', visible); void refresh() })
|
onMounted(() => { alive = true; document.addEventListener('visibilitychange', visible); void refresh() })
|
||||||
onUnmounted(() => { alive = false; ++sequence; clearTimeout(timer); controller?.abort(); document.removeEventListener('visibilitychange', visible) })
|
onUnmounted(() => { alive = false; ++sequence; clearTimeout(timer); controller?.abort(); document.removeEventListener('visibilitychange', visible) })
|
||||||
return {overview, records, total, pageNum, pageSize, status, taskType, loading, failed,
|
return {overview, runtime, records, total, pageNum, pageSize, status, taskType, loading, failed,
|
||||||
saving, refreshedAt, refresh, changeAutomation, search, onPageChange, onSizeChange}
|
saving, runtimeSaving, refreshedAt, refresh, changeAutomation, changeRuntime, search, onPageChange, onSizeChange}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user