diff --git a/backend/.env.example b/backend/.env.example index d2a3cf1..a2fff45 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -41,6 +41,8 @@ T_PLUS_PORT=1433 T_PLUS_DATABASE= T_PLUS_USER= T_PLUS_PASSWORD= +# 仅用于已确认的开发测试库,默认禁止使用 sa。 +T_PLUS_ALLOW_DEV_SA=false # 可显式指定项目外的只读连接 JSON;不从页面接收该路径。 T_PLUS_CONNECTION_FILE= # 必须填写在客户 SQL Server 2008 R2 上验证过的驱动名称。 diff --git a/backend/alembic/versions/29b0c1d2e3f4_receipt_manual_confirmation.py b/backend/alembic/versions/29b0c1d2e3f4_receipt_manual_confirmation.py new file mode 100644 index 0000000..5263047 --- /dev/null +++ b/backend/alembic/versions/29b0c1d2e3f4_receipt_manual_confirmation.py @@ -0,0 +1,47 @@ +"""V29 回款独立人工确认及默认开启开关;不自动确认历史回款。""" + +from pathlib import Path + +from alembic import op +from sqlalchemy import inspect, text + + +revision = "29b0c1d2e3f4" +down_revision = "28a9b0c1d2e3" +branch_labels = None +depends_on = None + + +def upgrade(): + sql = Path(__file__).resolve().parents[3] / "db/migration/V29__receipt_manual_confirmation.sql" + columns = {column["name"]: column for column in inspect(op.get_bind()).get_columns("receipts")} + existing = {name for name in ("confirmation_status", "confirmed_by", "confirmed_at", "confirmed_revision") if name in columns} + if existing and len(existing) != 4: + raise RuntimeError("回款确认字段不完整,请先核对数据库结构") + if existing: + expected = {"confirmation_status": ("TINYINT", None, False), "confirmed_by": ("VARCHAR", 64, True), + "confirmed_at": ("DATETIME", None, True), "confirmed_revision": ("VARCHAR", 64, True)} + for name, (kind, length, nullable) in expected.items(): + column = columns[name] + actual_type = column["type"] + if (actual_type.__visit_name__.upper() != kind or getattr(actual_type, "length", None) != length + or column["nullable"] != nullable): + raise RuntimeError(f"回款确认字段 {name} 与迁移定义不一致,请先核对数据库结构") + if str(columns["confirmation_status"]["default"]).strip("'\"") != "0": + raise RuntimeError("回款确认状态默认值不是待确认,请先核对数据库结构") + for statement in sql.read_text(encoding="utf-8").split(";\n"): + clean = "\n".join(line for line in statement.splitlines() if not line.lstrip().startswith("--")).strip() + if clean and not (existing and clean.startswith("ALTER TABLE receipts ADD COLUMN")): + op.get_bind().execute(text(clean)) + + +def downgrade(): + bind = op.get_bind() + if bind.scalar(text("SELECT COUNT(*) FROM receipts WHERE confirmed_at IS NOT NULL OR confirmation_status<>0")): + raise RuntimeError("已有回款人工确认记录,禁止删除确认依据;请仅回退应用并保留结构") + if bind.scalar(text("SELECT COUNT(*) FROM param_values v JOIN param_definitions d ON d.id=v.param_id WHERE d.param_code='receipt.confirmation.enabled'")): + raise RuntimeError("已维护人工确认开关版本,禁止删除历史参数") + bind.execute(text("DELETE FROM param_definitions WHERE param_code='receipt.confirmation.enabled'")) + bind.execute(text("UPDATE permissions SET status=0 WHERE perm_code='business:receipts:confirm'")) + for column in ("confirmed_revision", "confirmed_at", "confirmed_by", "confirmation_status"): + op.drop_column("receipts", column) diff --git a/backend/alembic/versions/30c1d2e3f4a5_tplus_business_sync.py b/backend/alembic/versions/30c1d2e3f4a5_tplus_business_sync.py new file mode 100644 index 0000000..578e436 --- /dev/null +++ b/backend/alembic/versions/30c1d2e3f4a5_tplus_business_sync.py @@ -0,0 +1,40 @@ +"""V30 业务接入与核算准备解耦;无默认回填。""" + +from pathlib import Path + +from alembic import op +from sqlalchemy import text + +revision = "30c1d2e3f4a5" +down_revision = "29b0c1d2e3f4" +branch_labels = None +depends_on = None + + +def upgrade(): + script = Path(__file__).resolve().parents[3] / "db/migration/V30__tplus_business_sync.sql" + for statement in script.read_text(encoding="utf-8").split(";\n"): + sql = "\n".join(line for line in statement.splitlines() if not line.lstrip().startswith("--")).strip() + if sql: + op.get_bind().execute(text(sql)) + + +def downgrade(): + connection = op.get_bind() + for table, condition in (("tplus_business_documents", "1=1"), ("products", "product_category IS NULL"), + ("customers", "customer_type IS NULL"), ("business_items", "product_category IS NULL"), + ("shipments", "freight_amount IS NULL")): + if connection.scalar(text(f"SELECT COUNT(*) FROM {table} WHERE {condition}")): + raise RuntimeError("已有接入或待补资料,请关闭接入并回退兼容版本;不删除业务数据以降级结构") + op.drop_table("tplus_business_documents") + op.drop_table("tplus_business_sync_configs") + for index in ("idx_tplus_source_heads_parent", "idx_tplus_source_heads_customer_date", "idx_tplus_source_heads_date"): + op.drop_index(index, "tplus_source_heads") + for column in ("source_parent_id", "source_customer_id", "source_date"): + op.drop_column("tplus_source_heads", column) + op.drop_column("businesses", "integration_json") + for table, column, sql_type, default in (("products", "product_category", "VARCHAR(32)", ""), + ("customers", "customer_type", "VARCHAR(32)", " DEFAULT 'personal'"), + ("business_items", "product_category", "VARCHAR(32)", ""), + ("shipments", "freight_amount", "DECIMAL(14,2)", " DEFAULT 0")): + connection.execute(text(f"ALTER TABLE {table} MODIFY {column} {sql_type} NOT NULL{default}")) diff --git a/backend/alembic/versions/31d2e3f4a5b6_merge_business_sync_and_receipt_confirmation.py b/backend/alembic/versions/31d2e3f4a5b6_merge_business_sync_and_receipt_confirmation.py new file mode 100644 index 0000000..9da5669 --- /dev/null +++ b/backend/alembic/versions/31d2e3f4a5b6_merge_business_sync_and_receipt_confirmation.py @@ -0,0 +1,14 @@ +"""合并业务接入和回款确认迁移分支。""" + +revision = "31d2e3f4a5b6" +down_revision = ("29b0c1d2e3f4", "30c1d2e3f4a5") +branch_labels = None +depends_on = None + + +def upgrade(): + pass + + +def downgrade(): + pass diff --git a/backend/app/api/v1/base/customers.py b/backend/app/api/v1/base/customers.py index 0093a53..557a6fe 100644 --- a/backend/app/api/v1/base/customers.py +++ b/backend/app/api/v1/base/customers.py @@ -15,7 +15,8 @@ from fastapi import APIRouter, Depends, Path, Query from app.api.deps import DbSession, PayrollWriter, RequestMetaDep, get_current_user from app.core.response import ApiResponse, PageQuery, PageResult, ok -from app.models import User +from app.models import Customer, User +from app.schemas.category_batch import CustomerCategoryBatch, CustomerOwnershipBatch from app.schemas.customer import ( CustomerCreateIn, CustomerOut, @@ -33,6 +34,18 @@ router = APIRouter(prefix="/customers", tags=["客户管理"]) CurrentUser = Annotated[User, Depends(get_current_user)] +@router.put("/batch-category") +def batch_category(payload: CustomerCategoryBatch, session: DbSession, actor: PayrollWriter, meta: RequestMetaDep): + from app.services.category_batch import fill_missing + + return ok(fill_missing(session, Customer, payload, actor, meta)) + + +@router.put("/batch-ownership") +def batch_ownership(payload: CustomerOwnershipBatch, session: DbSession, actor: PayrollWriter, meta: RequestMetaDep): + return ok(customer_service.batch_change_ownership(session, payload, operator=actor, meta=meta)) + + @router.get("", response_model=ApiResponse[PageResult[CustomerOut]], summary="客户列表") def list_customers( session: DbSession, diff --git a/backend/app/api/v1/base/products.py b/backend/app/api/v1/base/products.py index 9dc1094..b8a7a1e 100644 --- a/backend/app/api/v1/base/products.py +++ b/backend/app/api/v1/base/products.py @@ -16,7 +16,8 @@ from fastapi import APIRouter, Depends, Path, Query from app.api.deps import DbSession, PayrollWriter, RequestMetaDep, get_current_user from app.core.response import ApiResponse, PageResult, ok -from app.models import User +from app.models import Product, User +from app.schemas.category_batch import ProductCategoryBatch from app.schemas.product import ( ProductCreateIn, ProductExtractIn, @@ -32,6 +33,13 @@ CurrentUser = Annotated[User, Depends(get_current_user)] router = APIRouter(prefix="/products", tags=["产品管理"]) +@router.put("/batch-category") +def batch_category(payload: ProductCategoryBatch, session: DbSession, actor: PayrollWriter, meta: RequestMetaDep): + from app.services.category_batch import fill_missing + + return ok(fill_missing(session, Product, payload, actor, meta)) + + @router.get("/extract/candidates") def extraction_candidates(session: DbSession, _: CurrentUser, keyword: str = "", page_num: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100)): from app.services.product_completion import candidates diff --git a/backend/app/api/v1/business/businesses.py b/backend/app/api/v1/business/businesses.py index ce8152b..915cdb0 100644 --- a/backend/app/api/v1/business/businesses.py +++ b/backend/app/api/v1/business/businesses.py @@ -24,7 +24,15 @@ from app.schemas.business import ( GrossProfitOut, OwnershipChangeLogOut, ) -from app.services import business_service +from app.schemas.business_source import ( + SalesRelationConfirmIn, + SalesRelationQuery, + SalesRelationReleaseIn, + SourceDocumentsQuery, + SourceMaterialsIn, + SourcePriceConfirmIn, +) +from app.services import business_service, business_source # 基础数据是业务操作:登录即可,不要求管理员(区别于系统管理的 AdminUser) CurrentUser = Annotated[User, Depends(get_current_user)] @@ -32,6 +40,72 @@ CurrentUser = Annotated[User, Depends(get_current_user)] router = APIRouter(prefix="/businesses", tags=["业务管理"]) +@router.get("/{business_id}/readiness") +def source_readiness(session: DbSession, _: CurrentUser, business_id: Annotated[int, Path(ge=1)]): + return ok(business_source.readiness(session, business_id)) + + +@router.get("/{business_id}/source-documents") +def source_documents(session: DbSession, _: CurrentUser, business_id: Annotated[int, Path(ge=1)], + query: Annotated[SourceDocumentsQuery, Query()]): + return ok(business_source.documents(session, business_id, query, query.entity)) + + +@router.get("/{business_id}/source-quantities") +def source_quantities(session: DbSession, _: CurrentUser, business_id: Annotated[int, Path(ge=1)], + query: Annotated[PageQuery, Query()]): + return ok(business_source.source_quantities(session, business_id, query)) + + +@router.get("/{business_id}/source-documents/{document_id}/details") +def source_document_details(session: DbSession, _: CurrentUser, business_id: Annotated[int, Path(ge=1)], + document_id: Annotated[int, Path(ge=1)], query: Annotated[PageQuery, Query()]): + return ok(business_source.document_details(session, business_id, document_id, query)) + + +@router.put("/{business_id}/source-documents/{document_id}/missing-price") +def confirm_source_price(session: DbSession, actor: PayrollWriter, business_id: Annotated[int, Path(ge=1)], + document_id: Annotated[int, Path(ge=1)], payload: SourcePriceConfirmIn, meta: RequestMetaDep): + from app.services.authorization_service import require_permission + + require_permission(session, actor, "payroll:prepare") + return ok(business_source.confirm_missing_source_price(session, business_id, document_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()]): + return ok(business_source.sales_relation_candidates(session, business_id, query, query.shipment_page, query.order_page)) + + +@router.put("/{business_id}/sales-relation") +def confirm_sales_relation(session: DbSession, actor: PayrollWriter, + business_id: Annotated[int, Path(ge=1)], + payload: SalesRelationConfirmIn, meta: RequestMetaDep): + from app.services.authorization_service import require_permission + from app.services.tplus import sales_relations + + require_permission(session, actor, "payroll:prepare") + return ok(sales_relations.confirm(session, business_id, payload, actor, meta)) + + +@router.put("/{business_id}/sales-relation/release") +def release_sales_relation(session: DbSession, actor: PayrollWriter, + business_id: Annotated[int, Path(ge=1)], + payload: SalesRelationReleaseIn, meta: RequestMetaDep): + from app.services.authorization_service import require_permission + from app.services.tplus import sales_relations + + require_permission(session, actor, "payroll:prepare") + return ok(sales_relations.release(session, business_id, payload, actor, meta)) + + +@router.put("/{business_id}/source-materials") +def source_materials(session: DbSession, actor: PayrollWriter, business_id: Annotated[int, Path(ge=1)], + payload: SourceMaterialsIn, meta: RequestMetaDep): + return ok(business_source.save_materials(session, business_id, payload, actor, meta)) + + @router.get("", response_model=ApiResponse[PageResult[BusinessOut]], summary="业务列表") def list_businesses( session: DbSession, diff --git a/backend/app/api/v1/business/receipts.py b/backend/app/api/v1/business/receipts.py index 3156d74..5dce014 100644 --- a/backend/app/api/v1/business/receipts.py +++ b/backend/app/api/v1/business/receipts.py @@ -13,12 +13,13 @@ from app.api.deps import DbSession, PayrollWriter, RequestMetaDep, get_current_u from app.core.response import ApiResponse, PageResult, ok from app.models import User from app.schemas.receipt import ( + ReceiptConfirmIn, ReceiptCreateIn, ReceiptOut, ReceiptQuery, ReceiptUpdateIn, ) -from app.services import receipt_service +from app.services import receipt_confirmation, receipt_service # 基础数据是业务操作:登录即可,不要求管理员(区别于系统管理的 AdminUser) CurrentUser = Annotated[User, Depends(get_current_user)] @@ -26,6 +27,21 @@ CurrentUser = Annotated[User, Depends(get_current_user)] router = APIRouter(prefix="/receipts", tags=["回款管理"]) +@router.get("/confirmation-policy", summary="回款人工确认开关状态") +def confirmation_policy(session: DbSession, _: CurrentUser): + return ok({"enabled": receipt_confirmation.enabled(session)}) + + +@router.post("/{receipt_id}/confirm", response_model=ApiResponse[ReceiptOut], summary="独立人工确认回款") +def confirm_receipt( + receipt_id: Annotated[int, Path(ge=1)], payload: ReceiptConfirmIn, + session: DbSession, current_user: CurrentUser, meta: RequestMetaDep, +): + return ok(receipt_service.confirm_receipt( + session, receipt_id, payload, operator=current_user, meta=meta, + ), message="回款已人工确认") + + @router.get("", response_model=ApiResponse[PageResult[ReceiptOut]], summary="回款列表") def list_receipts( session: DbSession, diff --git a/backend/app/api/v1/metadata.py b/backend/app/api/v1/metadata.py index a11b429..a5b4148 100644 --- a/backend/app/api/v1/metadata.py +++ b/backend/app/api/v1/metadata.py @@ -11,8 +11,10 @@ router = APIRouter(prefix="/meta", tags=["业务参照"]) @router.get("/payroll-input/{business_id}") -def payroll_metadata(business_id: int, session: DbSession, actor: LoginUser): - return ok(payroll_options(session, actor, business_id)) +def payroll_metadata(business_id: int, session: DbSession, actor: LoginUser, + page_num: Annotated[int, Query(alias="pageNum", ge=1)] = 1, + page_size: Annotated[int, Query(alias="pageSize", ge=1, le=200)] = 200): + return ok(payroll_options(session, actor, business_id, page_num=page_num, page_size=page_size)) @router.get("/options") diff --git a/backend/app/api/v1/system/tplus.py b/backend/app/api/v1/system/tplus.py index 8b7fad8..95bbf58 100644 --- a/backend/app/api/v1/system/tplus.py +++ b/backend/app/api/v1/system/tplus.py @@ -18,8 +18,10 @@ from app.schemas.tplus import ( TPlusErrorQuery, TPlusOverviewOut, TPlusRetryIn, + TPlusRuntimeOut, + TPlusRuntimeUpdate, ) -from app.services.tplus import automation_config, query_service, task_service +from app.services.tplus import automation_config, query_service, runtime_control, task_service router = APIRouter(prefix="/tplus", tags=["系统管理 · T+ 数据同步"]) router.include_router(workbench_router) @@ -39,6 +41,17 @@ def update_automation(session: DbSession, admin: PayrollWriter, meta: RequestMet return ok(automation_config.save(session, payload, task_service.TaskActor(admin, meta))) +@router.get("/runtime", response_model=ApiResponse[TPlusRuntimeOut]) +def runtime_status(session: DbSession, _: WorkbenchReader): + return ok(runtime_control.overview(session)) + + +@router.put("/runtime", response_model=ApiResponse[TPlusRuntimeOut]) +def update_runtime(session: DbSession, admin: PayrollWriter, meta: RequestMetaDep, + payload: TPlusRuntimeUpdate): + return ok(runtime_control.save(session, payload, task_service.TaskActor(admin, meta))) + + @router.get("/batches", response_model=ApiResponse[PageResult[TPlusBatchOut]]) def batches(session: DbSession, _: WorkbenchReader, query: Annotated[TPlusBatchQuery, Query()]): """分页查询批次元数据。""" diff --git a/backend/app/api/v1/system/tplus_workbench.py b/backend/app/api/v1/system/tplus_workbench.py index 859428a..605a618 100644 --- a/backend/app/api/v1/system/tplus_workbench.py +++ b/backend/app/api/v1/system/tplus_workbench.py @@ -7,12 +7,46 @@ from starlette.responses import StreamingResponse from app.api.deps import DbSession, PayrollWriter, WorkbenchReader from app.core.response import ApiResponse, PageQuery, PageResult, ok -from app.schemas.tplus_workbench import FactIn, SourceChoiceQuery, WorkCreateIn, WorkQuery -from app.services.tplus import workbench +from app.schemas.tplus_business_sync import BusinessSyncRun, BusinessSyncUpdate, SyncRange +from app.schemas.tplus_workbench import ( + CorrectionPairIn, + FactIn, + SourceChoiceQuery, + WorkCreateIn, + WorkQuery, +) +from app.services.tplus import business_sync_control, workbench router = APIRouter() RunNo = Annotated[str, Path(pattern=r"^[A-Za-z0-9_-]+$", max_length=32)] +@router.get("/business-sync") +def business_sync_config(session: DbSession, _: WorkbenchReader): + return ok(business_sync_control.configuration(session)) + + +@router.put("/business-sync") +def business_sync_save(session: DbSession, actor: PayrollWriter, payload: BusinessSyncUpdate): + return ok(business_sync_control.save(session, payload, actor.id)) + + +@router.post("/business-sync/preview") +def business_sync_preview(session: DbSession, _: PayrollWriter, payload: SyncRange): + return ok(business_sync_control.readonly_preview(session, payload.start_date)) + + +@router.post("/business-sync/runs") +def business_sync_run(session: DbSession, actor: PayrollWriter, payload: BusinessSyncRun): + return ok(business_sync_control.queue(session, actor.id, payload.start_date, payload.request_key)) + + +@router.post("/correction-pairs") +def save_correction_pair(session: DbSession, actor: PayrollWriter, payload: CorrectionPairIn): + """成对排除转客户更正单,将实际回款关联原业务,保留实际到账日。""" + from app.services.tplus.corrections import save_pair + + return ok(save_pair(session, payload, actor.id)) + @router.get("/facts", response_model=ApiResponse[PageResult[dict]]) def facts(session: DbSession, _: WorkbenchReader, query: Annotated[WorkQuery, Query()]): diff --git a/backend/app/cli.py b/backend/app/cli.py index f47236f..78dddb5 100644 --- a/backend/app/cli.py +++ b/backend/app/cli.py @@ -175,6 +175,12 @@ def _maintenance(args): return 0 +def _tplus_supervisor(args): + from app.services.tplus.supervisor import run + + return run(get_session_factory(), get_settings(), interval=args.poll_seconds) + + def _materials(args): from app.services.tplus.materials import collect_materials, render_markdown @@ -247,6 +253,8 @@ def main() -> int: p_maintenance.add_argument("--once", action="store_true", help="执行一次检查(默认)") p_maintenance.add_argument("--connection-file", default=None) p_maintenance.add_argument("--stop-file", default=None) + p_supervisor = sub.add_parser("tplus-supervisor", help="按页面设置托管 T+ 读取服务") + p_supervisor.add_argument("--poll-seconds", type=int, choices=range(2, 61), default=5) p_materials = sub.add_parser("tplus-materials", help="只读整理客户样本核对资料,不生成工资") p_materials.add_argument("--customer-id", type=int, required=True) p_materials.add_argument("--month", required=True) @@ -262,6 +270,8 @@ def main() -> int: return _tplus_worker(args) if args.command == "tplus-maintain": return _maintenance(args) + if args.command == "tplus-supervisor": + return _tplus_supervisor(args) if args.command == "tplus-materials": return _materials(args) return _stage_tplus(args) diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 9c63420..281f184 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -88,6 +88,7 @@ class Settings(BaseSettings): t_plus_database: str = "" t_plus_user: str = "" t_plus_password: str = Field(default="", repr=False) + t_plus_allow_dev_sa: bool = False t_plus_connection_file: str = "" # 部署前必须指定已在 SQL Server 2008 R2 实测的驱动,不自动选择最新版。 t_plus_odbc_driver: str = "" diff --git a/backend/app/core/tplus_db.py b/backend/app/core/tplus_db.py index 1073ee2..b7770be 100644 --- a/backend/app/core/tplus_db.py +++ b/backend/app/core/tplus_db.py @@ -55,7 +55,8 @@ def resolve_tplus_settings(settings: Settings) -> Settings: t_plus_password=config.password.get_secret_value(), t_plus_odbc_driver=config.driver ) resolved = settings.model_copy(update=values) - if resolved.t_plus_user.strip().casefold() == "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: raise RuntimeError("应用必须使用 T+ 专用只读账号,不能使用 sa") if not resolved.t_plus_database or not resolved.t_plus_user or not resolved.t_plus_odbc_driver: raise RuntimeError("T+ 只读连接缺少数据库、账号或已验证的 ODBC 驱动") diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 2314617..3788802 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -47,7 +47,11 @@ 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", diff --git a/backend/app/models/business.py b/backend/app/models/business.py index 72fa6f2..28624b4 100644 --- a/backend/app/models/business.py +++ b/backend/app/models/business.py @@ -16,6 +16,7 @@ from datetime import date, datetime from decimal import Decimal from sqlalchemy import ( + JSON, Date, DateTime, ForeignKey, @@ -147,6 +148,7 @@ class Business(Base, TimestampMixin, TPlusSyncMixin): comment="客户ID(customers.id)", ) business_date: Mapped[date | None] = mapped_column(Date, comment="业务日期(T+ 同步口径)") + integration_json: Mapped[dict | None] = mapped_column(JSON, comment="T+业务接入范围及来源人员摘要,非核算快照") status: Mapped[int] = mapped_column( TINYINT_TYPE, nullable=False, @@ -239,9 +241,9 @@ class BusinessItem(Base, TimestampMixin): comment="规格型号(冗余固化;空串对齐 products 组合唯一键口径)", ) unit: Mapped[str | None] = mapped_column(String(16), comment="计量单位") - product_category: Mapped[str] = mapped_column( + product_category: Mapped[str | None] = mapped_column( String(32), - nullable=False, + nullable=True, comment="产品类别(行级折算锚点,混合业务按产品逐条计算,对照表 #14;值域同 PRODUCT_CATEGORIES)", ) quantity: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False, comment="数量(>0)") diff --git a/backend/app/models/customer.py b/backend/app/models/customer.py index 0a4d74d..a7f7f3e 100644 --- a/backend/app/models/customer.py +++ b/backend/app/models/customer.py @@ -49,10 +49,9 @@ class Customer(Base, TimestampMixin, TPlusSyncMixin): contact_phone: Mapped[str | None] = mapped_column(String(20), comment="联系电话") address: Mapped[str | None] = mapped_column(String(255), comment="地址") industry: Mapped[str | None] = mapped_column(String(64), comment="所属行业(自由文本)") - customer_type: Mapped[str] = mapped_column( + customer_type: Mapped[str | None] = mapped_column( String(32), - nullable=False, - server_default="personal", + nullable=True, index=True, comment="客户分类:personal个人户 manager_personal经理个人户 enterprise企业户(计提规则匹配锚点;T+同步不覆盖)", ) diff --git a/backend/app/models/product.py b/backend/app/models/product.py index c36d60a..0c364c5 100644 --- a/backend/app/models/product.py +++ b/backend/app/models/product.py @@ -47,9 +47,9 @@ class Product(Base, TimestampMixin, TPlusSyncMixin): sale_price: Mapped[Decimal | None] = mapped_column( Numeric(14, 4), comment="销售单价(元,T+ 同步)" ) - product_category: Mapped[str] = mapped_column( + product_category: Mapped[str | None] = mapped_column( String(32), - nullable=False, + nullable=True, comment="产品类别:municipal_water市政水利 rail_transit轨道交通(决定折算阈值 30%/20%;T+同步不覆盖)", ) status: Mapped[int] = mapped_column( diff --git a/backend/app/models/receipt.py b/backend/app/models/receipt.py index 5930083..0f6e740 100644 --- a/backend/app/models/receipt.py +++ b/backend/app/models/receipt.py @@ -10,10 +10,10 @@ - 一期禁负数金额(≥0):红字/退款冲销需求出现时先补设计再放开 """ -from datetime import date +from datetime import date, datetime from decimal import Decimal -from sqlalchemy import Date, ForeignKey, Index, Numeric, String +from sqlalchemy import Date, DateTime, ForeignKey, Index, Numeric, String from sqlalchemy.orm import Mapped, mapped_column from app.models.base import ( @@ -80,4 +80,10 @@ class Receipt(Base, TimestampMixin, TPlusSyncMixin): server_default="0", comment="绩效核算状态:0未核算 1已核算(M3 核算防重锚点;已核算回款禁改禁删)", ) + confirmation_status: Mapped[int] = mapped_column( + TINYINT_TYPE, nullable=False, server_default="0", comment="人工确认状态:0待确认 1已确认,独立于凭证及核算状态" + ) + confirmed_by: Mapped[str | None] = mapped_column(String(64), comment="人工确认人账号快照") + confirmed_at: Mapped[datetime | None] = mapped_column(DateTime, comment="人工确认时间") + confirmed_revision: Mapped[str | None] = mapped_column(String(64), comment="人工确认时回款关键内容指纹") remark: Mapped[str | None] = mapped_column(String(255), comment="备注") diff --git a/backend/app/models/shipment.py b/backend/app/models/shipment.py index 89c466c..4681ba8 100644 --- a/backend/app/models/shipment.py +++ b/backend/app/models/shipment.py @@ -49,10 +49,9 @@ class Shipment(Base, TimestampMixin, TPlusSyncMixin): ship_date: Mapped[date] = mapped_column( Date, nullable=False, comment="发货日期(回款天数基准日,发货日首日=第 1 天)" ) - freight_amount: Mapped[Decimal] = mapped_column( + freight_amount: Mapped[Decimal | None] = mapped_column( Numeric(14, 2), - nullable=False, - server_default="0", + nullable=True, comment="运费金额(仅记录展示;不计入毛利润口径,对照表 #2)", ) total_amount: Mapped[Decimal] = mapped_column( diff --git a/backend/app/models/tplus_automation.py b/backend/app/models/tplus_automation.py index aa1650b..611f2dd 100644 --- a/backend/app/models/tplus_automation.py +++ b/backend/app/models/tplus_automation.py @@ -24,6 +24,24 @@ class TPlusAutomationConfig(Base, TimestampMixin): updated_by: Mapped[int] = mapped_column(BIGINT_UNSIGNED, comment="最近修改账号ID") +class TPlusRuntimeControl(Base, TimestampMixin): + """单部署的服务期望状态与托管进程报告,默认不启动。""" + + __tablename__ = "tplus_runtime_controls" + __table_args__ = mysql_table_args("T+读取服务运行控制") + id: Mapped[int] = mapped_column(BIGINT_UNSIGNED, primary_key=True, autoincrement=False, comment="主键ID") + desired_state: Mapped[str] = mapped_column(String(16), server_default="STOPPED", comment="期望状态:RUNNING/STOPPED") + actual_state: Mapped[str] = mapped_column(String(16), server_default="STOPPED", comment="实际状态:RUNNING/STOPPED/STOPPING/DEGRADED/OFFLINE") + worker_phase: Mapped[str] = mapped_column(String(16), server_default="STOPPED", comment="读取服务阶段") + heartbeat_at: Mapped[datetime | None] = mapped_column(DateTime, comment="最近心跳时间") + last_error: Mapped[str | None] = mapped_column(String(255), comment="最近受控错误") + host: Mapped[str | None] = mapped_column(String(128), comment="运行主机") + platform: Mapped[str | None] = mapped_column(String(32), comment="运行平台") + owner: Mapped[str | None] = mapped_column(String(36), comment="当前托管进程身份") + version: Mapped[int] = mapped_column(Integer, server_default="0", comment="状态版本号") + updated_by: Mapped[int | None] = mapped_column(BIGINT_UNSIGNED, comment="最近修改账号ID") + + class TPlusAutomationState(Base, TimestampMixin): __tablename__ = "tplus_automation_states" __table_args__ = mysql_table_args("T+自动采集周期与进程状态(不自动导入正式业务)") diff --git a/backend/app/models/tplus_business_sync.py b/backend/app/models/tplus_business_sync.py new file mode 100644 index 0000000..7c5b0e5 --- /dev/null +++ b/backend/app/models/tplus_business_sync.py @@ -0,0 +1,51 @@ +"""T+ 业务自动接入:源级开关与单据差异账本,不承载核算结果。""" + +from datetime import date + +from sqlalchemy import JSON, Date, Integer, String, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column + +from app.models.base import BIGINT_UNSIGNED, TINYINT_TYPE, Base, TimestampMixin, mysql_table_args + + +class TPlusBusinessSyncConfig(Base, TimestampMixin): + """每个源账套一条设置;启用后才按起点自动排队接入。""" + + __tablename__ = "tplus_business_sync_configs" + __table_args__ = mysql_table_args( + "T+业务自动接入设置", + UniqueConstraint("source_database", name="uk_tplus_business_sync_configs_source"), + ) + id: Mapped[int] = mapped_column(BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID") + source_database: Mapped[str] = mapped_column(String(128), comment="源账套") + enabled: Mapped[int] = mapped_column(TINYINT_TYPE, server_default="0", comment="自动接入是否启用:1启用0关闭") + start_date: Mapped[date] = mapped_column(Date, comment="业务接入起点") + version: Mapped[int] = mapped_column(Integer, server_default="0", comment="配置乐观版本") + updated_by: Mapped[int] = mapped_column(BIGINT_UNSIGNED, comment="授权启用账号") + last_error: Mapped[str | None] = mapped_column(String(64), comment="后台接入暂停原因") + + +class TPlusBusinessDocument(Base, TimestampMixin): + """源单线与正式单据的对应关系与差异结论;重复同步只更新本表。""" + + __tablename__ = "tplus_business_documents" + __table_args__ = mysql_table_args( + "T+业务单据接入与差异账本", + UniqueConstraint( + "source_database", "entity_type", "source_id", + name="uk_tplus_business_documents_source_entity_id", + ), + ) + id: Mapped[int] = mapped_column(BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID") + source_database: Mapped[str] = mapped_column(String(128), comment="源账套") + entity_type: Mapped[str] = mapped_column(String(32), comment="源实体") + source_id: Mapped[str] = mapped_column(String(64), comment="源单据或明细ID") + source_no: Mapped[str | None] = mapped_column(String(128), comment="原始单号") + record_id: Mapped[int] = mapped_column(BIGINT_UNSIGNED, comment="当前观察到的源快照ID") + applied_record_id: Mapped[int | None] = mapped_column(BIGINT_UNSIGNED, comment="已接入的源快照ID") + fingerprint: Mapped[str | None] = mapped_column(String(64), comment="已应用的业务字段指纹") + business_id: Mapped[int | None] = mapped_column(BIGINT_UNSIGNED, comment="关联业务ID") + target_id: Mapped[int | None] = mapped_column(BIGINT_UNSIGNED, comment="正式单据或明细ID") + status: Mapped[str] = mapped_column(String(24), default="PENDING", comment="APPLIED/PENDING/CONFLICT/EXCLUDED") + reason: Mapped[str | None] = mapped_column(String(128), comment="固定待补或差异编码") + data_json: Mapped[dict] = mapped_column(JSON, comment="最小必要来源与差异说明") diff --git a/backend/app/models/tplus_workbench.py b/backend/app/models/tplus_workbench.py index dc9a74b..086b180 100644 --- a/backend/app/models/tplus_workbench.py +++ b/backend/app/models/tplus_workbench.py @@ -21,6 +21,9 @@ class TPlusSourceHead(Base): name="uk_tplus_source_heads_source_entity_id", ), Index("idx_tplus_source_heads_batch_entity", "batch_no", "entity_type"), + Index("idx_tplus_source_heads_parent", "source_database", "entity_type", "source_parent_id"), + Index("idx_tplus_source_heads_customer_date", "source_database", "entity_type", "source_customer_id", "source_date"), + Index("idx_tplus_source_heads_date", "source_database", "entity_type", "source_date", "id"), ) id: Mapped[int] = mapped_column( BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID" @@ -29,6 +32,9 @@ class TPlusSourceHead(Base): entity_type: Mapped[str] = mapped_column(String(32), comment="实体类型") source_id: Mapped[str] = mapped_column(String(64), comment="源内部ID") record_id: Mapped[int] = mapped_column(BIGINT_UNSIGNED, comment="原始快照ID") + source_parent_id: Mapped[str | None] = mapped_column(String(64), comment="源父单据ID查询投影") + source_customer_id: Mapped[str | None] = mapped_column(String(64), comment="源客户ID查询投影") + source_date: Mapped[str | None] = mapped_column(String(10), comment="源业务日期查询投影") batch_no: Mapped[str] = mapped_column(String(32), comment="最后观察批次") diff --git a/backend/app/repositories/business_repository.py b/backend/app/repositories/business_repository.py index ef75415..1dbc30d 100644 --- a/backend/app/repositories/business_repository.py +++ b/backend/app/repositories/business_repository.py @@ -15,15 +15,21 @@ from app.models import ( Customer, OwnershipChangeLog, Personnel, + Product, Receipt, SecondaryAllocation, Shipment, + TPlusBusinessDocument, ) from app.models.customer import OWNER_TYPE_BUSINESS from app.repositories import ownership_log_repository from app.repositories.ownership_log_repository import OwnershipLogRef from app.schemas.business import BusinessQuery +READINESS_SQL_REASONS = frozenset({ + "CUSTOMER_CATEGORY_PENDING", "PRODUCT_CATEGORY_PENDING", "NO_ACTUAL_SHIPMENT", "SOURCE_CONFLICT", +}) + def get_by_id(session: Session, business_id: int, *, for_update: bool = False) -> Business | None: """按主键取业务行;for_update 供删除保护、编辑门槛与状态机使用。""" @@ -98,9 +104,40 @@ def _apply_filters(stmt: Select, query: BusinessQuery) -> Select: stmt = stmt.where(Business.business_date >= query.date_from) if query.date_to is not None: stmt = stmt.where(Business.business_date <= query.date_to) + if query.source_type == "TPLUS": + stmt = stmt.where(Business.t_plus_id.is_not(None)) + elif query.source_type == "MANUAL": + stmt = stmt.where(Business.t_plus_id.is_(None)) + if query.source_personnel: + stmt = stmt.where(Business.integration_json["personnel_names"].as_string().contains(query.source_personnel, autoescape=True)) + if query.readiness_reason in READINESS_SQL_REASONS: + stmt = stmt.where(Business.integration_json["source_database"].as_string().is_not(None)) + if query.readiness_reason == "CUSTOMER_CATEGORY_PENDING": + stmt = stmt.where(Customer.customer_type.is_(None)) + elif query.readiness_reason == "PRODUCT_CATEGORY_PENDING": + stmt = stmt.where(exists(select(BusinessItem.id).join(Product, Product.id == BusinessItem.product_id).where( + BusinessItem.business_id == Business.id, Product.product_category.is_(None)))) + elif query.readiness_reason == "NO_ACTUAL_SHIPMENT": + stmt = stmt.where(~exists().where(Shipment.business_id == Business.id)) + elif query.readiness_reason == "SOURCE_CONFLICT": + stmt = stmt.where(exists().where(TPlusBusinessDocument.business_id == Business.id, + TPlusBusinessDocument.status.in_(("CONFLICT", "PENDING")))) return stmt +def scan_businesses(session: Session, query: BusinessQuery, before_id: int | None, limit: int = 100): + """按主键倒序分批读取,供需要计算准备状态的筛选使用。""" + owner = aliased(Personnel) + assistant = aliased(Personnel) + stmt = select(Business, Customer.company_name, owner.personnel_name, assistant.personnel_name).join( + Customer, Customer.id == Business.customer_id) + stmt = _apply_filters(stmt, query).outerjoin(owner, owner.id == Business.owner_personnel_id).outerjoin( + assistant, assistant.id == Business.assistant_personnel_id) + if before_id is not None: + stmt = stmt.where(Business.id < before_id) + return list(session.execute(stmt.order_by(Business.id.desc()).limit(limit)).all()) + + def list_businesses( session: Session, query: BusinessQuery ) -> list[tuple[Business, str, str | None, str | None]]: diff --git a/backend/app/repositories/customer_repository.py b/backend/app/repositories/customer_repository.py index e159f93..b88e6b0 100644 --- a/backend/app/repositories/customer_repository.py +++ b/backend/app/repositories/customer_repository.py @@ -114,8 +114,12 @@ def _apply_filters(stmt: Select, query: CustomerQuery) -> Select: stmt = stmt.where(_keyword_clause(query.keyword)) if query.customer_type: stmt = stmt.where(Customer.customer_type == query.customer_type) + if query.category_missing: + stmt = stmt.where(Customer.customer_type.is_(None)) if query.owner_personnel_id is not None: stmt = stmt.where(Customer.owner_personnel_id == query.owner_personnel_id) + if query.owner_missing: + stmt = stmt.where(Customer.owner_personnel_id.is_(None)) return stmt diff --git a/backend/app/repositories/product_repository.py b/backend/app/repositories/product_repository.py index 3e8f8b0..7ee22b6 100644 --- a/backend/app/repositories/product_repository.py +++ b/backend/app/repositories/product_repository.py @@ -66,6 +66,8 @@ def _apply_filters(stmt: Select, query: ProductQuery) -> Select: ) if query.product_category: stmt = stmt.where(Product.product_category == query.product_category) + if query.category_missing: + stmt = stmt.where(Product.product_category.is_(None)) if query.status is not None: stmt = stmt.where(Product.status == query.status) return stmt diff --git a/backend/app/repositories/receipt_repository.py b/backend/app/repositories/receipt_repository.py index 36b70da..79691b5 100644 --- a/backend/app/repositories/receipt_repository.py +++ b/backend/app/repositories/receipt_repository.py @@ -57,6 +57,8 @@ def _apply_filters(stmt: Select, query: ReceiptQuery) -> Select: # settlement_status 是 M3 核算防重锚点(需规 2.3.3),必须保持具名字段 if query.settlement_status is not None: stmt = stmt.where(Receipt.settlement_status == query.settlement_status) + if query.confirmation_status is not None: + stmt = stmt.where(Receipt.confirmation_status == query.confirmation_status) return stmt diff --git a/backend/app/repositories/report_repository.py b/backend/app/repositories/report_repository.py index c221abe..70573c7 100644 --- a/backend/app/repositories/report_repository.py +++ b/backend/app/repositories/report_repository.py @@ -217,6 +217,16 @@ def count_business_stats(session: Session, query: BusinessStatsQuery) -> int: return int(session.execute(stmt).scalar_one()) +def list_source_business_sales(session: Session, query: BusinessStatsQuery, *, after_id: int, limit: int): + """键集分页读取筛选范围内的来源业务,不加载完整业务或用户数据。""" + stmt = (select(Business.id, Business.total_sale_amount).select_from(Business) + .outerjoin(Customer, Customer.id == Business.customer_id) + .where(*_business_filters(query), + Business.integration_json["source_database"].as_string().is_not(None), + Business.id > after_id).order_by(Business.id).limit(limit)) + return list(session.execute(stmt)) + + def sum_business_stats(session: Session, query: BusinessStatsQuery) -> dict: """业务统计全筛选合计(业务数/发货/回款/销售额/绩效/应付)。""" perf_sq = _business_perf_subquery() diff --git a/backend/app/repositories/role_repository.py b/backend/app/repositories/role_repository.py index e12b029..7674477 100644 --- a/backend/app/repositories/role_repository.py +++ b/backend/app/repositories/role_repository.py @@ -9,8 +9,9 @@ from sqlalchemy import Select, func, or_, select from sqlalchemy.orm import Session -from app.models import Role, UserRole +from app.models import Permission, Role, RolePermission, UserRole from app.schemas.role import RoleQuery +from app.services.permission_catalog import CATALOG # ==================== 读取 ==================== @@ -97,6 +98,19 @@ def count_users_by_role(session: Session, role_ids: list[int]) -> dict[int, int] return {int(role_id): int(count) for role_id, count in session.execute(stmt)} +def count_permissions_by_role(session: Session, role_ids: list[int]) -> dict[int, int]: + """批量统计角色已配置的权限,避免列表逐行查询。""" + if not role_ids: + return {} + stmt = ( + select(RolePermission.role_id, func.count()) + .join(Permission, Permission.id == RolePermission.permission_id) + .where(RolePermission.role_id.in_(role_ids), Permission.perm_code.in_(CATALOG)) + .group_by(RolePermission.role_id) + ) + return {int(role_id): int(count) for role_id, count in session.execute(stmt)} + + # ==================== 写入 ==================== diff --git a/backend/app/schemas/business.py b/backend/app/schemas/business.py index f8739ff..c30e20c 100644 --- a/backend/app/schemas/business.py +++ b/backend/app/schemas/business.py @@ -126,7 +126,7 @@ class BusinessItemOut(BaseModel): product_name: str spec_model: str | None = None unit: str | None = None - product_category: str + product_category: str | None = None quantity: str sale_price: str | None = None sale_amount: str @@ -158,6 +158,12 @@ class BusinessOut(BaseModel): total_received_amount: str | None = None total_freight_amount: str | None = None remark: str | None = None + source_type: str = "MANUAL" + source_no: str | None = None + source_personnel: list[str] = Field(default_factory=list) + integration_status: str | None = None + readiness_status: str | None = None + readiness_reasons: list[dict[str, str]] = Field(default_factory=list) class SecondaryAllocationIn(BaseModel): @@ -331,13 +337,18 @@ class BusinessQuery(PageQuery): owner_personnel_id: int | None = Field(default=None, ge=1, description="负责营销人员筛选") date_from: date | None = Field(default=None, description="业务日期起(含)") date_to: date | None = Field(default=None, description="业务日期止(含)") + source_type: str | None = Field(default=None, pattern=r"^(MANUAL|TPLUS)$") + source_personnel: str | None = Field(default=None, max_length=64) + pending_only: bool = False + readiness_status: str | None = Field(default=None, pattern=r"^(READY|PENDING)$") + readiness_reason: str | None = Field(default=None, max_length=64, pattern=r"^[A-Z_]+$") class GrossProfitRowOut(BaseModel): """毛利润视图行:行级源数据 + 派生值(实时计算,不落库)。""" item_id: int - product_category: str + product_category: str | None = None product_name: str spec_model: str | None = None quantity: str @@ -346,7 +357,7 @@ class GrossProfitRowOut(BaseModel): """成本缺失(未录入)时为 None,派生列同为 None 并置 missing_reason""" profit_amount: str | None = None profit_rate: str | None = None - threshold: str + threshold: str | None = None converted_profit: str | None = None missing_reason: str | None = None diff --git a/backend/app/schemas/business_source.py b/backend/app/schemas/business_source.py new file mode 100644 index 0000000..94b8f61 --- /dev/null +++ b/backend/app/schemas/business_source.py @@ -0,0 +1,86 @@ +"""来源业务的人工分类资料。""" + +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field, model_validator + +from app.core.response import PageQuery + + +class SalesRelationQuery(PageQuery): + shipment_page: int = Field(default=1, ge=1, le=10000) + order_page: int = Field(default=1, ge=1, le=10000) + + +class SourceDocumentsQuery(PageQuery): + entity: str | None = Field(default=None, max_length=32) + + +class SourcePriceConfirmIn(BaseModel): + model_config = ConfigDict(extra="forbid") + revision: str = Field(pattern=r"^[0-9a-f]{64}$") + evidence: str = Field(min_length=1, max_length=500) + + @model_validator(mode="after") + def validate_evidence(self): + self.evidence = self.evidence.strip() + if not self.evidence: + raise ValueError("核对依据不能为空") + return self + + +class SourceProductCategory(BaseModel): + product_id: int = Field(ge=1) + category: Literal["municipal_water", "rail_transit"] + + +class SourceMaterialsIn(BaseModel): + model_config = ConfigDict(extra="forbid") + customer_type: Literal["personal", "manager_personal", "enterprise"] | None = None + products: list[SourceProductCategory] = Field(default_factory=list, max_length=200) + + @model_validator(mode="after") + def unique_products(self): + if len({p.product_id for p in self.products}) != len(self.products): + raise ValueError("产品不能重复") + if self.customer_type is None and not self.products: + raise ValueError("请选择要补充的分类") + return self + + +class SalesRelationConfirmIn(BaseModel): + model_config = ConfigDict(extra="forbid") + item_id: int = Field(ge=1) + shipment_document_id: int = Field(ge=1) + order_document_id: int | None = Field(default=None, ge=1) + disposition: Literal["ORDER", "INDEPENDENT"] + item_revision: str = Field(pattern=r"^[0-9a-f]{64}$") + shipment_revision: str = Field(pattern=r"^[0-9a-f]{64}$") + order_revision: str | None = Field(default=None, pattern=r"^[0-9a-f]{64}$") + expected_version: int = Field(default=0, ge=0) + evidence: str = Field(min_length=1, max_length=500) + + @model_validator(mode="after") + def validate_relation(self): + self.evidence = self.evidence.strip() + if not self.evidence: + raise ValueError("确认依据不能为空") + if self.disposition == "ORDER" and (self.order_document_id is None or self.order_revision is None): + raise ValueError("请选择并核对订单明细") + if self.disposition == "INDEPENDENT" and (self.order_document_id is not None or self.order_revision is not None): + raise ValueError("独立发货不应指定订单明细") + return self + + +class SalesRelationReleaseIn(BaseModel): + model_config = ConfigDict(extra="forbid") + item_id: int = Field(ge=1) + expected_version: int = Field(ge=1) + evidence: str = Field(min_length=1, max_length=500) + + @model_validator(mode="after") + def validate_evidence(self): + self.evidence = self.evidence.strip() + if not self.evidence: + raise ValueError("撤销依据不能为空") + return self diff --git a/backend/app/schemas/category_batch.py b/backend/app/schemas/category_batch.py new file mode 100644 index 0000000..f9eddb5 --- /dev/null +++ b/backend/app/schemas/category_batch.py @@ -0,0 +1,30 @@ +"""基础档案待确认分类的批量补充。""" + +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field, field_validator + + +class CategoryBatch(BaseModel): + model_config = ConfigDict(extra="forbid") + ids: list[int] = Field(min_length=1, max_length=200) + + @field_validator("ids") + @classmethod + def validate_ids(cls, value): + if any(item < 1 for item in value) or len(value) != len(set(value)): + raise ValueError("档案 ID 必须为不重复的正整数") + return value + + +class CustomerCategoryBatch(CategoryBatch): + category: Literal["personal", "manager_personal", "enterprise"] + + +class ProductCategoryBatch(CategoryBatch): + category: Literal["municipal_water", "rail_transit"] + + +class CustomerOwnershipBatch(CategoryBatch): + new_owner_personnel_id: int = Field(ge=1) + reason: str | None = Field(default=None, max_length=255) diff --git a/backend/app/schemas/customer.py b/backend/app/schemas/customer.py index b7b13b7..535c82f 100644 --- a/backend/app/schemas/customer.py +++ b/backend/app/schemas/customer.py @@ -25,7 +25,7 @@ class CustomerOut(BaseModel): contact_phone: str | None = None address: str | None = None industry: str | None = None - customer_type: str + customer_type: str | None = None owner_personnel_id: int | None = None owner_personnel_name: str | None = None remark: str | None = None @@ -104,7 +104,9 @@ class CustomerQuery(PageQuery): default=None, max_length=64, description="公司名称或客户负责人,模糊匹配" ) customer_type: str | None = Field(default=None, max_length=32, description="客户分类筛选") + category_missing: bool = Field(default=False, description="仅显示客户分类待确认") owner_personnel_id: int | None = Field(default=None, ge=1, description="归属人员ID筛选") + owner_missing: bool = Field(default=False, description="仅显示归属待确认") order_by: Literal["id", "rating_score"] = Field( default="id", description="排序键:id 建档序 / rating_score 评级总分" ) diff --git a/backend/app/schemas/payroll.py b/backend/app/schemas/payroll.py index 6b198da..b0aed5a 100644 --- a/backend/app/schemas/payroll.py +++ b/backend/app/schemas/payroll.py @@ -24,6 +24,14 @@ class ShipmentOwnershipIn(BaseModel): personnel_id: int = Field(ge=1) manager_id: int | None = Field(default=None, ge=1) unit_cost: str + freight_amount: str | None = None + + @field_validator("freight_amount") + @classmethod + def check_freight(cls, value): + if value is not None: + _decimal(value, 2) + return value @field_validator("unit_cost") @classmethod diff --git a/backend/app/schemas/product.py b/backend/app/schemas/product.py index c43b0ba..66b6c60 100644 --- a/backend/app/schemas/product.py +++ b/backend/app/schemas/product.py @@ -23,7 +23,7 @@ class ProductOut(BaseModel): unit: str | None = None stock_quantity: str | None = None sale_price: str | None = None - product_category: str + product_category: str | None = None status: int = 1 remark: str | None = None create_time: datetime @@ -96,6 +96,7 @@ class ProductQuery(PageQuery): keyword: str | None = Field(default=None, max_length=64, description="名称或规格,模糊匹配") product_category: str | None = Field(default=None, max_length=32, description="按类别筛选") + category_missing: bool = Field(default=False, description="仅显示产品分类待确认") status: int | None = Field(default=None, ge=0, le=1, description="状态筛选") diff --git a/backend/app/schemas/receipt.py b/backend/app/schemas/receipt.py index dbc0a5c..586704a 100644 --- a/backend/app/schemas/receipt.py +++ b/backend/app/schemas/receipt.py @@ -8,7 +8,7 @@ payment_days 是派生值(发货日 → 回款日的天数,对照表 #1) 后无需回写历史。 """ -from datetime import date +from datetime import date, datetime from pydantic import BaseModel, ConfigDict, Field, field_validator @@ -86,6 +86,10 @@ class ReceiptOut(BaseModel): amount: str is_barter: int settlement_status: int + confirmation_status: int = 0 + confirmed_by: str | None = None + confirmed_at: datetime | None = None + confirmation_revision: str = "" payment_days: int | None = None remark: str | None = None @@ -100,3 +104,9 @@ class ReceiptQuery(PageQuery): settlement_status: int | None = Field( default=None, ge=0, le=1, description="核算状态筛选:0未核算 1已核算" ) + confirmation_status: int | None = Field(default=None, ge=0, le=1, description="人工确认状态筛选") + + +class ReceiptConfirmIn(BaseModel): + model_config = ConfigDict(extra="forbid") + expected_revision: str = Field(pattern=r"^[0-9a-f]{64}$", description="页面核对的回款关键内容指纹") diff --git a/backend/app/schemas/report.py b/backend/app/schemas/report.py index 92caafe..498c02e 100644 --- a/backend/app/schemas/report.py +++ b/backend/app/schemas/report.py @@ -114,6 +114,7 @@ class ReportSummary(BaseModel): ship_total: Decimal | None = Field(default=None, description="发货额合计") received_total: Decimal | None = Field(default=None, description="回款额合计") sale_total: Decimal | None = Field(default=None, description="销售额合计") + sale_pending_count: int = Field(default=0, description="销售额待核对业务数") class ReportPage[T](BaseModel): diff --git a/backend/app/schemas/shipment.py b/backend/app/schemas/shipment.py index 2df61d9..bc05bcb 100644 --- a/backend/app/schemas/shipment.py +++ b/backend/app/schemas/shipment.py @@ -69,7 +69,7 @@ class ShipmentOut(BaseModel): business_id: int business_no: str | None = None ship_date: date - freight_amount: str + freight_amount: str | None = None total_amount: str remark: str | None = None diff --git a/backend/app/schemas/tplus.py b/backend/app/schemas/tplus.py index 0cfa250..bc643f6 100644 --- a/backend/app/schemas/tplus.py +++ b/backend/app/schemas/tplus.py @@ -129,6 +129,24 @@ class TPlusAutomationUpdate(BaseModel): expected_version: int = Field(ge=0, strict=True) +class TPlusRuntimeUpdate(BaseModel): + desired_state: Literal["RUNNING", "STOPPED"] + expected_version: int = Field(ge=0, strict=True) + + +class TPlusRuntimeOut(BaseModel): + desired_state: str = "STOPPED" + actual_state: str = "OFFLINE" + worker_phase: str = "UNKNOWN" + heartbeat_at: datetime | None = None + last_error: str | None = None + host: str | None = None + platform: str | None = None + version: int = 0 + updated_by: int | None = None + editable: bool = False + + class TPlusAutomationOverview(BaseModel): enabled: bool = False version: int = 0 diff --git a/backend/app/schemas/tplus_business_sync.py b/backend/app/schemas/tplus_business_sync.py new file mode 100644 index 0000000..88b9e16 --- /dev/null +++ b/backend/app/schemas/tplus_business_sync.py @@ -0,0 +1,27 @@ +"""业务接入边界:独立于核算月份和内部平账组。""" + +from datetime import date +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field, field_validator + + +class SyncRange(BaseModel): + model_config = ConfigDict(extra="forbid") + start_date: date = date(2026, 3, 1) + + @field_validator("start_date") + @classmethod + def check_start(cls, value): + if value < date(2026, 3, 1): + raise ValueError("本次接入从2026年3月起,更早欠款通过期初承接") + return value + + +class BusinessSyncUpdate(SyncRange): + enabled: bool + expected_version: int = Field(ge=0) + + +class BusinessSyncRun(SyncRange): + request_key: UUID diff --git a/backend/app/schemas/tplus_workbench.py b/backend/app/schemas/tplus_workbench.py index 5fefd72..c287682 100644 --- a/backend/app/schemas/tplus_workbench.py +++ b/backend/app/schemas/tplus_workbench.py @@ -2,11 +2,13 @@ from datetime import date from decimal import Decimal, InvalidOperation +from itertools import pairwise from typing import Literal from uuid import UUID -from pydantic import BaseModel, ConfigDict, Field, model_validator +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator +from app.core.month import validate_month from app.core.response import PageQuery @@ -16,11 +18,39 @@ class FactData(BaseModel): model_config = ConfigDict(extra="forbid", str_strip_whitespace=True) +class RolePeriod(FactData): + """客户只确认到月份时保留月份精度,区间含首尾月份。""" + + start_month: str + end_month: str + role_code: Literal["salesperson", "sales_manager"] + + @field_validator("start_month", "end_month") + @classmethod + def check_month(cls, value): + return validate_month(value) + + @model_validator(mode="after") + def check_range(self): + if self.end_month < self.start_month: + raise ValueError("岗位结束月份不能早于开始月份") + return self + + class PersonFact(FactData): code: str = Field(min_length=1, max_length=64) name: str = Field(min_length=1, max_length=64) role_code: Literal["salesperson", "sales_manager"] status: Literal[0, 1] + role_history: list[RolePeriod] = Field(default_factory=list, max_length=120) + + @model_validator(mode="after") + def check_role_history(self): + periods = sorted(self.role_history, key=lambda period: period.start_month) + if any(left.end_month >= right.start_month for left, right in pairwise(periods)): + raise ValueError("历史岗位月份不能重叠") + self.role_history = periods + return self class ProductFact(FactData): @@ -30,7 +60,7 @@ class ProductFact(FactData): class CustomerFact(FactData): customer_type: Literal["personal", "manager_personal", "enterprise"] - owner_name: str = Field(min_length=1, max_length=64) + owner_name: str = Field(default="", max_length=64) class GroupFact(FactData): @@ -143,6 +173,10 @@ class CorrectionFact(FactData): pair_key: str = Field(min_length=1, max_length=128) exclude_performance: bool = False redirect_business_key: str | None = Field(default=None, max_length=128) + paired_source_id: str | None = Field(default=None, pattern=r"^[0-9]+$", max_length=64) + source_hash: str | None = Field(default=None, min_length=64, max_length=64) + paired_hash: str | None = Field(default=None, min_length=64, max_length=64) + details_hash: str | None = Field(default=None, min_length=64, max_length=64) @model_validator(mode="after") def check_correction(self): @@ -155,6 +189,26 @@ class CorrectionFact(FactData): return self +class CorrectionPairIn(FactData): + """一次登记完整更正对及实际回款,不能单边排除。""" + + negative_shipment_id: str = Field(pattern=r"^[0-9]+$", max_length=64) + positive_shipment_id: str = Field(pattern=r"^[0-9]+$", max_length=64) + receipt_ids: list[str] = Field(default_factory=list, max_length=100) + target_group_key: str = Field(min_length=1, max_length=128, pattern=r"^[A-Za-z0-9_.:-]+$") + evidence: str = Field(min_length=1, max_length=500) + + @model_validator(mode="after") + def check_members(self): + if self.negative_shipment_id == self.positive_shipment_id: + raise ValueError("正负更正单不能为同一张单据") + if len(set(self.receipt_ids)) != len(self.receipt_ids) or any( + not v.isascii() or not v.isdigit() for v in self.receipt_ids + ): + raise ValueError("实际回款必须使用不重复的源数字标识") + return self + + FACT_TYPES = { "PERSONNEL": PersonFact, "PRODUCT": ProductFact, diff --git a/backend/app/services/authorization_management.py b/backend/app/services/authorization_management.py index b939933..fc1169d 100644 --- a/backend/app/services/authorization_management.py +++ b/backend/app/services/authorization_management.py @@ -135,6 +135,16 @@ def validate_role_permissions(permission_codes): return codes +def active_permission_ids(session, codes): + """核验目录对应的启用记录齐全,避免保存不可生效的授权。""" + ids = dict(session.execute(select(Permission.perm_code, Permission.id).where( + Permission.perm_code.in_(codes), Permission.status == 1 + )).all()) + if set(ids) != codes: + raise BadRequestError("权限目录尚未就绪,请联系管理员") + return ids + + def save_role(session, role_id, payload, actor, meta=None): audit_data = payload.model_dump(mode="json") with audit_service.audit(session, operator=actor, meta=meta, record=AuditRecord( @@ -147,10 +157,10 @@ def save_role(session, role_id, payload, actor, meta=None): if role.role_code == "admin": raise ForbiddenError("管理员角色权限固定,不能修改") codes = validate_role_permissions(payload.permission_codes) + ids = active_permission_ids(session, codes) audit_data["before"] = role_permissions(session, role.id)["permission_codes"] audit_data["after"] = sorted(codes) session.execute(delete(RolePermission).where(RolePermission.role_id == role.id)) - ids = dict(session.execute(select(Permission.perm_code, Permission.id)).all()) session.add_all([RolePermission(role_id=role.id, permission_id=ids[c]) for c in codes]) bump(policy, actor) session.flush() diff --git a/backend/app/services/authorization_metadata.py b/backend/app/services/authorization_metadata.py index 1f0914b..5bfa54f 100644 --- a/backend/app/services/authorization_metadata.py +++ b/backend/app/services/authorization_metadata.py @@ -1,5 +1,5 @@ """业务表单参照只返回必要字段,不借下拉权限开放整个资料接口。""" -from sqlalchemy import select +from sqlalchemy import func, select from app.core.exceptions import BadRequestError, ForbiddenError from app.models import ( @@ -49,28 +49,75 @@ OPTIONS = { } -def payroll_options(session, actor, business_id): +def payroll_options(session, actor, business_id, *, page_num=1, page_size=200): """核算资料所需的人员及发货明细,不开放人员薪酬或联系方式。""" from app.core.exceptions import NotFoundError - from app.models import ShipmentItem + from app.models import ShipmentItem, TPlusBusinessDocument, TPlusSourceLink, TPlusSyncRecord from app.services.authorization_service import require_permission + from app.services.tplus.guidance import latest_guidance_facts, resolve_guidance + if page_num < 1 or not 1 <= page_size <= 200: + raise ValueError("无效的核算参照分页") require_permission(session, actor, "payroll:view") - if session.get(Business, business_id) is None: + business = session.get(Business, business_id) + if business is None: raise NotFoundError("业务不存在") - heads = list(session.scalars(select(Shipment).where(Shipment.business_id == business_id).order_by(Shipment.id).limit(201))) - if len(heads) > 200: - raise BadRequestError("单笔业务超过 200 张发货单,请分批核对资料") - people = [dict(row) for row in session.execute(select(Personnel.id, Personnel.personnel_name).order_by(Personnel.id).limit(201)).mappings()] - if len(people) > 200: - raise BadRequestError("核算人员超过单次范围,请按实际归属分批处理") - lines = session.execute(select(ShipmentItem.id, ShipmentItem.shipment_id, ShipmentItem.product_name, ShipmentItem.amount).where(ShipmentItem.shipment_id.in_([h.id for h in heads]))).mappings() - by_head = {head.id: head for head in heads} - return {"personnel": people, "shipments": [ - {"id": row["id"], "shipment_no": by_head[row["shipment_id"]].shipment_no, - "ship_date": str(by_head[row["shipment_id"]].ship_date), "product_name": row["product_name"], "amount": str(row["amount"])} - for row in lines - ]} + source_customer = (business.integration_json or {}).get("customer_source_id") + offset = (page_num - 1) * page_size + people = session.execute(select(Personnel.id, Personnel.personnel_name).order_by(Personnel.id) + .offset(offset).limit(page_size)).mappings() + lines_query = select(ShipmentItem.id, Shipment.shipment_no, Shipment.ship_date, + ShipmentItem.product_name, ShipmentItem.amount, ShipmentItem.unit).join( + Shipment, Shipment.id == ShipmentItem.shipment_id).where(Shipment.business_id == business_id) + lines = list(session.execute(lines_query.order_by(Shipment.id, ShipmentItem.id).offset(offset).limit(page_size)).mappings()) + source = (business.integration_json or {}).get("source_database") + candidates = {} + if source and lines: + documents = session.scalars(select(TPlusBusinessDocument).where( + TPlusBusinessDocument.business_id == business_id, + TPlusBusinessDocument.source_database == source, + TPlusBusinessDocument.entity_type == "shipment_items", + TPlusBusinessDocument.status == "APPLIED", + TPlusBusinessDocument.target_id.in_([row["id"] for row in lines]), + )) + for document in documents: + candidates.setdefault(document.target_id, []).append(document) + person_sources = {str(data["source_personnel_id"]) for values in candidates.values() for doc in values + if (data := doc.data_json or {}).get("source_personnel_id") is not None} + links = dict(session.execute(select(TPlusSourceLink.source_id, Personnel.id).join( + Personnel, Personnel.id == TPlusSourceLink.target_id).where( + TPlusSourceLink.source_database == source, TPlusSourceLink.entity_type == "personnel", + TPlusSourceLink.source_id.in_(person_sources))).all()) if person_sources else {} + record_ids = {doc.record_id for values in candidates.values() for doc in values} + records = {r.id: r for r in session.scalars(select(TPlusSyncRecord).where( + TPlusSyncRecord.id.in_(record_ids)))} if record_ids else {} + guidance_facts = latest_guidance_facts(session, source) if record_ids else {} + + def source_details(row): + matches = candidates.get(row["id"], []) + document = matches[0] if len(matches) == 1 else None + data = (document.data_json or {}) if document else {} + source_id = data.get("source_personnel_id") + record = records.get(document.record_id) if document else None + product_id = record.payload_json.get("idinventory") if record else None + status, guidance = resolve_guidance(guidance_facts, str(product_id), document.source_id, + row["ship_date"].strftime("%Y-%m"), row["unit"]) if product_id is not None else ("MISSING", None) + if guidance: + status = "FINALIZED_" + guidance["basis"] + return {"source_personnel_name": data.get("source_personnel_name"), + "matched_personnel_id": links.get(str(source_id)) if source_id is not None else None, + "unit": row["unit"], + "guidance_status": status, + "guidance_unit_cost": guidance["amount"] if status == "FINALIZED_UNIT" else None, + "guidance_line_total": guidance["amount"] if status == "FINALIZED_LINE_TOTAL" else None} + return {"business_key": f"customer:{source_customer}" if source_customer is not None else None, + "personnel": [dict(row) for row in people], + "personnel_total": session.scalar(select(func.count()).select_from(Personnel)), + "shipments_total": session.scalar(select(func.count()).select_from(ShipmentItem).join( + Shipment, Shipment.id == ShipmentItem.shipment_id).where(Shipment.business_id == business_id)), + "shipments": [{"id": row["id"], "shipment_no": row["shipment_no"], + "ship_date": str(row["ship_date"]), "product_name": row["product_name"], + "amount": str(row["amount"]), **source_details(row)} for row in lines]} def options(session, actor, resource, *, business_id=None, search=None): diff --git a/backend/app/services/authorization_policy.py b/backend/app/services/authorization_policy.py index 913c882..d3d4f5f 100644 --- a/backend/app/services/authorization_policy.py +++ b/backend/app/services/authorization_policy.py @@ -39,8 +39,9 @@ def permission_for(module, name, method, *, params=None, body=None): if method == "GET": return resource + ":view" special = {"set_status": "status", "change_status": "status", "reset_password": "password", + "confirm_receipt": "confirm", "replace_regions": "regions", "replace_plans": "plans", "rate_customer": "rating", - "change_ownership": "ownership", "upload_logo": "update"} + "change_ownership": "ownership", "batch_ownership": "ownership", "upload_logo": "update"} action = special.get(name) or {"POST": "create", "PUT": "update", "DELETE": "delete"}.get(method) return resource + ":" + action if action else None if module == "system.params": @@ -57,11 +58,13 @@ def permission_for(module, name, method, *, params=None, body=None): if module == "system.tplus": return "tplus:" + ("view" if method == "GET" else "retry" if name == "retry" else "collect") if module == "system.tplus_workbench": + if name.startswith("business_sync_"): + return "tplus:view" if method == "GET" else "tplus:import" if name == "work_report": return "tplus:export" if method == "GET": return "tplus:view" - if name == "save_fact": + if name in ("save_fact", "save_correction_pair"): return "tplus:facts" if name == "create_work": return "tplus:import" if body.get("kind") in ("MASTER_APPLY", "BUSINESS_APPLY") else "tplus:preview" diff --git a/backend/app/services/business_service.py b/backend/app/services/business_service.py index 68ae676..33afd84 100644 --- a/backend/app/services/business_service.py +++ b/backend/app/services/business_service.py @@ -106,12 +106,16 @@ def _to_out(row: tuple) -> BusinessOut: assistant_personnel_name=assistant_name, is_special=business.is_special, special_note=business.special_note, - total_sale_amount=_str_or_zero(business.total_sale_amount), - total_cost_amount=_str_or_zero(business.total_cost_amount), + total_sale_amount=None if business.integration_json and business.total_sale_amount is None else _str_or_zero(business.total_sale_amount), + total_cost_amount=None if business.integration_json and business.total_cost_amount is None else _str_or_zero(business.total_cost_amount), total_ship_amount=_str_or_zero(business.total_ship_amount), total_received_amount=_str_or_zero(business.total_received_amount), - total_freight_amount=_str_or_zero(business.total_freight_amount), + total_freight_amount=None if business.integration_json and business.total_freight_amount is None else _str_or_zero(business.total_freight_amount), remark=business.remark, + source_type="TPLUS" if business.t_plus_id else "MANUAL", + source_no=business.source_no, + source_personnel=(business.integration_json or {}).get("personnel_names", []), + integration_status="WAITING_MATERIALS" if business.integration_json else None, ) @@ -179,6 +183,12 @@ def _recalc_totals(session: Session, business: Business) -> None: 五条独立聚合查询(各表单列 SUM,走 business_id 索引)——业务单据量级 为单业务数行,不值得为省查询做单大 JOIN。 """ + if business.integration_json: + from app.services.tplus.business_sync import _refresh_business_totals + + session.flush() + _refresh_business_totals(session, business) + return sale = session.execute( _sum(BusinessItem.sale_amount, BusinessItem.business_id, business.id) ).scalar_one() @@ -236,10 +246,52 @@ def _is_duplicate_entry(exc: IntegrityError) -> bool: def list_businesses(session: Session, query: BusinessQuery) -> PageResult[BusinessOut]: """分页查询业务(附客户名与归属人名)。""" - rows = business_repository.list_businesses(session, query) - total = business_repository.count_businesses(session, query) + from app.services.business_source import readiness_summaries + + summaries = {} + sql_reason = query.readiness_reason in business_repository.READINESS_SQL_REASONS + if sql_reason and query.readiness_status == "READY": + rows, total = [], 0 + elif sql_reason: + rows = business_repository.list_businesses(session, query) + total = business_repository.count_businesses(session, query) + summaries = readiness_summaries(session, [row[0] for row in rows]) + elif query.pending_only or query.readiness_status or query.readiness_reason: + rows, total, before_id = [], 0, None + snapshots = {} + while batch := business_repository.scan_businesses(session, query, before_id): + states = readiness_summaries(session, [row[0] for row in batch], snapshot_cache=snapshots) + for row in batch: + state = states.get(row[0].id) + if not state: + continue + if query.pending_only and state["status"] != "PENDING": + continue + if query.readiness_status and state["status"] != query.readiness_status: + continue + if query.readiness_reason and query.readiness_reason not in {r["code"] for r in state["reasons"]}: + continue + if query.offset <= total < query.offset + query.page_size: + rows.append(row) + summaries[row[0].id] = state + total += 1 + before_id = batch[-1][0].id + else: + rows = business_repository.list_businesses(session, query) + total = business_repository.count_businesses(session, query) + summaries = readiness_summaries(session, [row[0] for row in rows]) + records = [] + for row in rows: + out = _to_out(row) + state = summaries.get(out.id) + if state: + out.readiness_status = state["status"] + out.readiness_reasons = state["reasons"] + if any(reason["code"] == "SALES_RELATION_PENDING" for reason in state["reasons"]): + out.total_sale_amount = out.total_cost_amount = None + records.append(out) return PageResult[BusinessOut]( - records=[_to_out(row) for row in rows], + records=records, total=total, page_num=query.page_num, page_size=query.page_size, @@ -255,6 +307,11 @@ def get_detail(session: Session, business_id: int) -> BusinessDetailOut: if row is None: raise NotFoundError("业务不存在") out = BusinessDetailOut(**_to_out(row).model_dump()) + if row[0].integration_json: + from app.services.business_source import sales_relation_pending + + if sales_relation_pending(session, business_id): + out.total_sale_amount = out.total_cost_amount = None out.items = [ _item_to_out(item) for item in business_repository.list_items(session, business_id) ] @@ -500,6 +557,9 @@ def update_business( raise NotFoundError("业务不存在") business, _, owner_name, assistant_name = row + if business.integration_json: + raise BadRequestError("T+ 来源业务请通过差异核对处理,不能直接替换明细") + # 终态门槛与发货/回款写准入同口径:明细与汇总是 M3 核算基线, # 已完成/已终止的业务禁改(发货/回款服务以同一理由禁写终态业务) if business.status != BUSINESS_STATUS_ACTIVE: @@ -631,6 +691,8 @@ def delete_business( ): if business is None: raise NotFoundError("业务不存在") + if business.integration_json: + raise BadRequestError("T+ 来源业务不能直接删除,请通过差异核对处理") if business_repository.has_docs(session, business_id): raise BadRequestError("业务已有发货或回款记录,请改为已终止而非删除") # 在途审批单(ref_id 固化无外键,业务删了单还挂着且可被继续审批): @@ -993,7 +1055,9 @@ def _gross_profit_row( def _scan_gross_profit_rows( - session: Session, business_id: int, threshold_of: Callable[[str], Fraction] + session: Session, business_id: int, threshold_of: Callable[[str], Fraction], *, + sales_relation_pending: bool = False, items: list[BusinessItem] | None = None, + included_item_ids: set[int] | None = None, ) -> _GrossProfitScan: """逐行扫描业务明细:出行 DTO、类别聚合桶与成本缺失计数。 @@ -1002,7 +1066,24 @@ def _scan_gross_profit_rows( rows: list[GrossProfitRowOut] = [] agg: dict[str, list[Decimal]] = {} missing_count = 0 - for item in business_repository.list_items(session, business_id): + for item in items if items is not None else business_repository.list_items(session, business_id): + if sales_relation_pending: + rows.append(GrossProfitRowOut(item_id=item.id, product_category=item.product_category, + product_name=item.product_name, spec_model=item.spec_model, quantity=str(item.quantity), + sale_amount=str(item.sale_amount), missing_reason="订单与发货关联待核对,无法汇总毛利润")) + missing_count += 1 + continue + if included_item_ids is not None and item.id not in included_item_ids: + rows.append(GrossProfitRowOut(item_id=item.id, product_category=item.product_category, + product_name=item.product_name, spec_model=item.spec_model, quantity=str(item.quantity), + sale_amount=str(item.sale_amount), missing_reason="已计入订单,不重复汇总")) + continue + if item.product_category is None: + rows.append(GrossProfitRowOut(item_id=item.id, product_category=None, product_name=item.product_name, + spec_model=item.spec_model, quantity=str(item.quantity), sale_amount=str(item.sale_amount), + missing_reason="产品核算分类待确认")) + missing_count += 1 + continue row, bucket = _gross_profit_row(item, threshold_of(item.product_category)) rows.append(row) if bucket is None: @@ -1041,7 +1122,8 @@ def gross_profit(session: Session, business_id: int, as_of: date | None) -> Gros :raises NotFoundError: 业务不存在 """ - if business_repository.get_by_id(session, business_id) is None: + business = business_repository.get_by_id(session, business_id) + if business is None: raise NotFoundError("业务不存在") as_of = as_of or date.today() snapshot = param_service.resolve_snapshot(session, as_of) @@ -1049,7 +1131,20 @@ def gross_profit(session: Session, business_id: int, as_of: date | None) -> Gros def threshold_of(category: str) -> Fraction: return snapshot.fraction("product.threshold", product_category=category) - scan = _scan_gross_profit_rows(session, business_id, threshold_of) + items = business_repository.list_items(session, business_id) + pending, included_ids = False, None + if business.integration_json and business.integration_json.get("source_database"): + from app.models import TPlusBusinessDocument + from app.services.tplus.sales_relations import resolve_items + + order_ids = list(session.scalars(select(TPlusBusinessDocument.target_id).where( + TPlusBusinessDocument.business_id == business_id, + TPlusBusinessDocument.entity_type == "business_items", + TPlusBusinessDocument.status == "APPLIED"))) + pending, included = resolve_items(session, business, items, order_ids) + included_ids = {item.id for item in included} + scan = _scan_gross_profit_rows(session, business_id, threshold_of, sales_relation_pending=pending, + items=items, included_item_ids=included_ids) categories = [ _gross_profit_category(category, sale, cost, threshold_of(category)) for category, [sale, cost] in scan.category_agg.items() diff --git a/backend/app/services/business_source.py b/backend/app/services/business_source.py new file mode 100644 index 0000000..1191597 --- /dev/null +++ b/backend/app/services/business_source.py @@ -0,0 +1,838 @@ +"""业务来源、待补资料和来源业务核算准备;不重新录入源单据。""" + +from collections import defaultdict +from contextlib import contextmanager +from datetime import date +from decimal import ROUND_HALF_UP, Decimal + +from sqlalchemy import Numeric, String, and_, cast, func, literal, or_, select, union +from sqlalchemy.orm import aliased + +from app.core.exceptions import BadRequestError, NotFoundError +from app.core.response import PageResult +from app.models import ( + Business, + BusinessItem, + BusinessRole, + Customer, + PayrollInput, + Personnel, + Product, + Receipt, + Shipment, + ShipmentItem, + TPlusBusinessDocument, + TPlusBusinessSyncConfig, + TPlusSourceHead, + TPlusSyncRecord, + TPlusWorkRun, +) +from app.services import payroll_months +from app.services.personnel_roles import role_on +from app.services.tplus.calculation_preview import _voucher_evidence +from app.services.tplus.workbench import digest, latest_facts + +LABELS = { + "CUSTOMER_CATEGORY_PENDING": "请确认客户核算类型", + "PRODUCT_CATEGORY_PENDING": "请补齐产品核算分类", + "PERSONNEL_PENDING": "请逐条确认发货归属人员及历史岗位", + "OPENING_BALANCE_PENDING": "请核实2026年2月末期初应收与历史承接", + "GUIDANCE_PENDING": "请录入发货月份指导成本并封存", + "FREIGHT_PENDING": "请核实源销售金额中的运费", + "NO_ACTUAL_SHIPMENT": "尚未发货,可继续关联订单和预收款", + "SOURCE_CONFLICT": "来源有差异或异常记录,请先核对", + "SOURCE_SNAPSHOT_PENDING": "最新完整来源快照尚未完成业务接入,请等待或重试接入", + "INPUT_REFRESH_REQUIRED": "新增单据或资料已变化,请刷新核算资料版本", + "SOURCE_DOCUMENT_INACTIVE": "存在尚未生效的来源单据", + "SALES_RELATION_PENDING": "订单与发货明细的关联不完整,请核对销售额,避免重复统计", +} + + +def business(session, business_id): + row = session.get(Business, business_id) + if row is None: + raise NotFoundError("业务不存在") + return row + + +def ensure_source_editable(session, entity, target_id): + """来源单据不得通过手工全量替换接口绕过差异核对。""" + linked = session.scalar(select(TPlusBusinessDocument.id).where( + TPlusBusinessDocument.entity_type == entity, + TPlusBusinessDocument.target_id == target_id, + TPlusBusinessDocument.applied_record_id.is_not(None), + ).limit(1)) + if linked is not None: + raise BadRequestError("T+ 来源单据请通过差异核对处理,不能直接编辑或删除") + + +def _latest(session, business_id): + return session.scalar(select(PayrollInput).where(PayrollInput.business_id == business_id).order_by(PayrollInput.version.desc()).limit(1)) + + +def sales_relations_pending(session, business_ids): + """批量核对当前明细,兼容尚未由新版 worker 更新标记的旧业务。""" + if not business_ids: + return set() + order_match = select(TPlusBusinessDocument.id).where( + TPlusBusinessDocument.business_id == BusinessItem.business_id, + TPlusBusinessDocument.entity_type == "business_items", + TPlusBusinessDocument.status == "APPLIED", + TPlusBusinessDocument.target_id == BusinessItem.id, + ).exists() + rows = session.execute(select(BusinessItem.business_id, order_match).where( + BusinessItem.business_id.in_(business_ids)).distinct()) + matches, unmatched = set(), set() + for business_id, matched in rows: + (matches if matched else unmatched).add(business_id) + mismatch = set(session.scalars(select(TPlusBusinessDocument.business_id).where( + TPlusBusinessDocument.business_id.in_(business_ids), + TPlusBusinessDocument.status == "CONFLICT", + TPlusBusinessDocument.reason == "ORDER_SHIPMENT_MISMATCH", + ).distinct())) + pending = (matches & unmatched) | mismatch + if not pending: + return pending + from app.models import TPlusFact + from app.services.tplus.sales_relations import KIND, resolve_items + + # 仅曾确认过的业务需要进一步检查事实;大多数待核对业务无需逐条加载。 + confirmed = set(session.scalars(select(Business.id).join(TPlusFact, ( + TPlusFact.source_database == Business.integration_json["source_database"].as_string()) + & (TPlusFact.kind == KIND) & (TPlusFact.fact_key.like(func.concat("sales:", Business.id, ":%"))) + ).where(Business.id.in_(pending)).distinct())) if session.bind.dialect.name == "mysql" else { + business_id for business_id in pending if session.scalar(select(TPlusFact.id).where( + TPlusFact.kind == KIND, TPlusFact.fact_key.like(f"sales:{business_id}:%")).limit(1)) + } + for business_id in confirmed: + business = session.get(Business, business_id) + if not business or not business.integration_json: + continue + items = list(session.scalars(select(BusinessItem).where(BusinessItem.business_id == business_id))) + order_ids = list(session.scalars(select(TPlusBusinessDocument.target_id).where( + TPlusBusinessDocument.business_id == business_id, + TPlusBusinessDocument.entity_type == "business_items", TPlusBusinessDocument.status == "APPLIED"))) + if business_id not in mismatch and not resolve_items(session, business, items, order_ids)[0]: + pending.discard(business_id) + return pending + + +def sales_relation_pending(session, business_id): + return business_id in sales_relations_pending(session, [business_id]) + + +def readiness_summaries(session, rows, *, snapshot_cache=None): + """批量计算列表准备状态,与详情使用同一组判断,不按历史输入是否存在猜测。""" + sources = [row for row in rows if row.integration_json] + if not sources: + return {} + ids = [row.id for row in sources] + customers = {c.id: c for c in session.scalars(select(Customer).where( + Customer.id.in_({row.customer_id for row in sources})))} + missing_products = set(session.scalars(select(BusinessItem.business_id).join( + Product, Product.id == BusinessItem.product_id).where( + BusinessItem.business_id.in_(ids), Product.product_category.is_(None)).distinct())) + shipped = set(session.scalars(select(Shipment.business_id).where( + Shipment.business_id.in_(ids)).distinct())) + latest = select(PayrollInput.business_id, func.max(PayrollInput.version).label("version")).where( + PayrollInput.business_id.in_(ids)).group_by(PayrollInput.business_id).subquery() + inputs = {item.business_id: item for item in session.scalars(select(PayrollInput).join( + latest, (PayrollInput.business_id == latest.c.business_id) & (PayrollInput.version == latest.c.version)))} + lines = defaultdict(dict) + receipts = defaultdict(set) + if inputs: + for business_id, item_id, category in session.execute(select( + Shipment.business_id, ShipmentItem.id, Product.product_category).join( + Shipment, Shipment.id == ShipmentItem.shipment_id).outerjoin( + Product, Product.id == ShipmentItem.product_id).where(Shipment.business_id.in_(inputs))): + lines[business_id][item_id] = category + for business_id, receipt_id in session.execute(select(Receipt.business_id, Receipt.id).where( + Receipt.business_id.in_(inputs))): + receipts[business_id].add(receipt_id) + conflicts = set(session.scalars(select(TPlusBusinessDocument.business_id).where( + TPlusBusinessDocument.business_id.in_(ids), + TPlusBusinessDocument.status.in_(("CONFLICT", "PENDING"))).distinct())) + relation_pending = sales_relations_pending(session, ids) + snapshots = snapshot_cache if snapshot_cache is not None else {} + for source in {row.integration_json["source_database"] for row in sources}: + if source not in snapshots: + snapshots[source] = source_snapshot_ready(session, source) + result = {} + for row in sources: + reasons = set() + customer = customers[row.customer_id] + record = inputs.get(row.id) + if not snapshots[row.integration_json["source_database"]]: + reasons.add("SOURCE_SNAPSHOT_PENDING") + if row.id in relation_pending: + reasons.add("SALES_RELATION_PENDING") + if customer.customer_type is None: + reasons.add("CUSTOMER_CATEGORY_PENDING") + if row.id in missing_products: + reasons.add("PRODUCT_CATEGORY_PENDING") + if row.id not in shipped: + reasons.add("NO_ACTUAL_SHIPMENT") + if record is None: + reasons.update(("OPENING_BALANCE_PENDING", "GUIDANCE_PENDING", "PERSONNEL_PENDING")) + if row.id in shipped: + reasons.add("FREIGHT_PENDING") + else: + maps = record.data_json.get("maps", {}) + if (set(lines[row.id]) != set(maps.get("shipment_items", {}).values()) or + receipts[row.id] != set(maps.get("receipts", {}).values())): + reasons.add("INPUT_REFRESH_REQUIRED") + for item in record.data_json.get("shipments", []): + item_id = maps.get("shipment_items", {}).get(str(item.get("line_id"))) + if (item_id not in lines[row.id] or item.get("category") != lines[row.id][item_id] + or item.get("customer_type") != customer.customer_type): + reasons.add("INPUT_REFRESH_REQUIRED") + if row.id in conflicts: + reasons.add("SOURCE_CONFLICT") + result[row.id] = {"status": "PENDING" if reasons else "READY", + "reasons": [{"code": code, "message": LABELS[code]} for code in sorted(reasons)]} + return result + + +def sales_relation_candidates(session, business_id, query, shipment_page=1, order_page=1): + """只读列出候选,金额与产品相同不代表已确认关联。""" + from app.services.tplus.sales_relations import ( + _fact_is_current, + _latest_business_facts, + item_revision, + order_revision, + shipment_revision, + ) + + row = business(session, business_id) + if not row.integration_json: + raise BadRequestError("仅来源业务需要核对订单与发货关联") + if query.page_size > 20: + raise BadRequestError("每页最多查询 20 条待核对明细") + source = row.integration_json["source_database"] + order_match = select(TPlusBusinessDocument.id).where( + TPlusBusinessDocument.business_id == business_id, + TPlusBusinessDocument.entity_type == "business_items", + TPlusBusinessDocument.status == "APPLIED", + TPlusBusinessDocument.target_id == BusinessItem.id, + ).exists() + conditions = (BusinessItem.business_id == business_id, ~order_match) + total = session.scalar(select(func.count()).select_from(BusinessItem).where(*conditions)) or 0 + items = list(session.scalars(select(BusinessItem).where(*conditions).order_by(BusinessItem.id) + .offset(query.offset).limit(query.page_size))) + facts = {fact.fact_key: fact for fact in _latest_business_facts(session, source, business_id)} + + def confirmed_source(document_id, entity, parent_entity): + doc = session.get(TPlusBusinessDocument, document_id) if document_id else None + if not doc or doc.business_id != business_id or doc.source_database != source or doc.entity_type != entity: + return None + parent = session.scalar(select(TPlusBusinessDocument).where( + TPlusBusinessDocument.business_id == business_id, + TPlusBusinessDocument.source_database == source, + TPlusBusinessDocument.entity_type == parent_entity, + TPlusBusinessDocument.source_id == str(doc.data_json.get("source_parent_id"))).limit(1)) + return {"source_id": doc.source_id, "source_no": parent.source_no if parent else None} + + results = [] + for item in items: + head_doc = aliased(TPlusBusinessDocument) + candidates = list(session.execute(select(TPlusBusinessDocument, head_doc, Shipment, ShipmentItem) + .join(ShipmentItem, ShipmentItem.id == TPlusBusinessDocument.target_id) + .join(Shipment, Shipment.id == ShipmentItem.shipment_id) + .join(head_doc, (head_doc.business_id == business_id) & (head_doc.entity_type == "shipments") + & (head_doc.source_database == source) + & (head_doc.target_id == Shipment.id) + & (head_doc.source_id == TPlusBusinessDocument.data_json["source_parent_id"].as_string())) + .where(TPlusBusinessDocument.business_id == business_id, + TPlusBusinessDocument.source_database == source, + TPlusBusinessDocument.entity_type == "shipment_items", + TPlusBusinessDocument.status == "APPLIED", + TPlusBusinessDocument.record_id == TPlusBusinessDocument.applied_record_id, + head_doc.status == "APPLIED", head_doc.record_id == head_doc.applied_record_id, + Shipment.business_id == business_id, ShipmentItem.product_id == item.product_id, + ShipmentItem.quantity == item.quantity, ShipmentItem.amount == item.sale_amount) + .order_by(TPlusBusinessDocument.id).offset((shipment_page - 1) * 20).limit(21))) + order_head = aliased(TPlusBusinessDocument) + orders = list(session.execute(select(TPlusBusinessDocument, order_head, BusinessItem) + .join(BusinessItem, BusinessItem.id == TPlusBusinessDocument.target_id) + .join(order_head, (order_head.business_id == business_id) & (order_head.entity_type == "businesses") + & (order_head.source_database == source) + & (order_head.source_id == TPlusBusinessDocument.data_json["source_parent_id"].as_string())) + .where(TPlusBusinessDocument.business_id == business_id, + TPlusBusinessDocument.source_database == source, + TPlusBusinessDocument.entity_type == "business_items", + TPlusBusinessDocument.status == "APPLIED", BusinessItem.product_id == item.product_id, + TPlusBusinessDocument.record_id == TPlusBusinessDocument.applied_record_id, + order_head.status == "APPLIED", order_head.record_id == order_head.applied_record_id) + .order_by(TPlusBusinessDocument.id).offset((order_page - 1) * 20).limit(21))) + fact = facts.get(f"sales:{business_id}:{item.id}") + results.append({"item_id": item.id, "product_name": item.product_name, "spec": item.spec_model, + "quantity": str(item.quantity), "amount": str(item.sale_amount), "item_revision": item_revision(item), + "version": fact.version if fact else 0, + "confirmation": ({"disposition": fact.data_json.get("disposition"), + "shipment_document_id": fact.data_json.get("shipment_document_id"), + "order_document_id": fact.data_json.get("order_document_id"), + "shipment": confirmed_source(fact.data_json.get("shipment_document_id"), "shipment_items", "shipments"), + "order": confirmed_source(fact.data_json.get("order_document_id"), "business_items", "businesses"), + "evidence": fact.evidence, "current": _fact_is_current(session, row, item, fact)} + if fact else None), + "shipments": [{"document_id": doc.id, "source_id": doc.source_id, + "source_no": shipment.source_no, "date": shipment.ship_date.isoformat(), + "shipment_item_id": line.id, "revision": shipment_revision(doc, head, shipment, line)} + for doc, head, shipment, line in candidates[:20]], + "shipments_truncated": len(candidates) > 20, + "orders": [{"document_id": doc.id, "source_id": doc.source_id, + "item_id": order.id, "quantity": str(order.quantity), "amount": str(order.sale_amount), + "revision": order_revision(doc, head, order)} + for doc, head, order in orders[:20]], "orders_truncated": len(orders) > 20}) + return PageResult(records=results, total=total, page_num=query.page_num, page_size=query.page_size) + + +def readiness(session, business_id): + row = business(session, business_id) + if not row.integration_json: + return {"source_type": "MANUAL", "status": "MANUAL", "reasons": [], "products": [], "customer_type": session.get(Customer, row.customer_id).customer_type} + customer = session.get(Customer, row.customer_id) + product_ids = select(BusinessItem.product_id).where(BusinessItem.business_id == business_id, BusinessItem.product_id.is_not(None)) + products = list(session.scalars(select(Product).where(Product.id.in_(product_ids)).order_by(Product.id))) + record = _latest(session, business_id) + summary = readiness_summaries(session, [row])[business_id] + reasons = {reason["code"] for reason in summary["reasons"]} + eligible_receipts = None + if (record is not None and record.data_json.get("input_schema") == "TPLUS_SYNC_V1" + and not reasons.intersection({"SOURCE_CONFLICT", "SOURCE_SNAPSHOT_PENDING", "INPUT_REFRESH_REQUIRED"})): + entries = record.data_json.get("receipts", []) + if all(r.get("exclusion") or r.get("voucher_confirmed") for r in entries): + eligible_receipts = str(sum( + (Decimal(str(r["amount"])) for r in entries if not r.get("exclusion")), Decimal("0.00") + )) + return {"source_type": "TPLUS", **summary, + "source_database": row.integration_json["source_database"], "customer_type": customer.customer_type, + "version": record.version if record else 0, + "source_personnel": row.integration_json.get("personnel_names", []), + "eligible_receipts_snapshot": eligible_receipts, + "products": [{"id": p.id, "name": p.product_name, "spec": p.spec_model, "category": p.product_category} for p in products[:200]], + "products_total": len(products)} + + +def source_snapshot_ready(session, source): + """仅认可覆盖当前完整快照且已处理结束的业务接入任务。""" + from app.services.tplus.business_sync import START + from app.services.tplus.snapshots import source_manifest + from app.services.tplus.workbench import WorkbenchError + + active = session.scalar(select(TPlusWorkRun.id).where( + TPlusWorkRun.source_database == source, TPlusWorkRun.kind == "BUSINESS_SYNC", + TPlusWorkRun.status.in_(("QUEUED", "RUNNING"))).limit(1)) + if active is not None: + return False + try: + manifest = source_manifest(session, source) + except WorkbenchError: + return False + run = session.scalar(select(TPlusWorkRun).where( + TPlusWorkRun.source_database == source, TPlusWorkRun.kind == "BUSINESS_SYNC" + ).order_by(TPlusWorkRun.id.desc()).limit(1)) + if not run or run.status not in ("SUCCEEDED", "PARTIAL") or (run.summary_json or {}).get("source_manifest") != manifest: + return False + config = session.scalar(select(TPlusBusinessSyncConfig).where(TPlusBusinessSyncConfig.source_database == source)) + try: + run_start = date.fromisoformat((run.input_json or {}).get("start_date", START.isoformat())) + except (TypeError, ValueError): + return False + return run_start <= (config.start_date if config else START) + + +@contextmanager +def source_calculation_lock(session): + """在新事务中持锁检查快照并提交,避免沿用鉴权查询建立的旧读视图。""" + if session.get_bind().dialect.name != "mysql": + yield + return + if session.new or session.deleted or any(session.is_modified(obj) for obj in session.dirty): + raise BadRequestError("核算前存在未提交的修改,请先完成当前操作") + session.rollback() + from app.core.tplus_lock import TPlusBusyError, sync_lock + + try: + with sync_lock(session): + yield + except TPlusBusyError: + raise BadRequestError("T+ 数据正在采集或接入,请完成后再核算") from None + + +def _source_values(record): + raw = record.payload_json + + def first(*names): + return next((raw[name] for name in names if raw.get(name) is not None), None) + + return {"source_no": record.source_no, "date": first("voucherdate", "voucherDate"), + "customer_id": first("idcustomer", "idpartner"), "personnel_id": first("idclerk", "idperson"), + "product_id": first("idinventory"), "order_id": first("saleOrderId"), + "order_detail_id": first("saleOrderDetailId"), + "quantity": first("quantity"), "price": first("taxPrice", "price"), + "price_type": ("含税" if raw.get("taxPrice") is not None else "未税" if raw.get("price") is not None else None), + "amount": first("taxAmount", "amount"), "state": first("voucherState", "voucherstate")} + + +def _source_changes(previous, current): + labels = {"source_no": "来源单号", "date": "日期", "customer_id": "客户标识", "personnel_id": "经办人标识", + "product_id": "产品标识", "order_id": "订单标识", "order_detail_id": "订单明细标识", + "quantity": "数量", "price": "单价", "price_type": "单价税别", "amount": "金额", "state": "单据状态"} + old, new = _source_values(previous), _source_values(current) + def text(value): + return str(value) if value is not None else None + return [{"field": key, "label": label, "before": text(old[key]), "after": text(new[key])} + for key, label in labels.items() if text(old[key]) != text(new[key])] + + +def source_quantities(session, business_id, query): + """按产品和单位分别统计已接入的来源订单及发货数量。""" + business(session, business_id) + zero = literal(0, type_=Numeric(14, 2)) + orders = select( + BusinessItem.product_id.label("product_id"), BusinessItem.product_name.label("product_name"), + BusinessItem.spec_model.label("spec_model"), BusinessItem.unit.label("unit"), + BusinessItem.quantity.label("order_quantity"), zero.label("shipment_quantity"), + ).join(TPlusBusinessDocument, and_(TPlusBusinessDocument.target_id == BusinessItem.id, + TPlusBusinessDocument.entity_type == "business_items")).where( + TPlusBusinessDocument.business_id == business_id, + TPlusBusinessDocument.applied_record_id.is_not(None), BusinessItem.business_id == business_id) + shipments = select( + ShipmentItem.product_id.label("product_id"), ShipmentItem.product_name.label("product_name"), + ShipmentItem.spec_model.label("spec_model"), ShipmentItem.unit.label("unit"), + zero.label("order_quantity"), ShipmentItem.quantity.label("shipment_quantity"), + ).join(TPlusBusinessDocument, and_(TPlusBusinessDocument.target_id == ShipmentItem.id, + TPlusBusinessDocument.entity_type == "shipment_items")).join( + Shipment, Shipment.id == ShipmentItem.shipment_id).where( + TPlusBusinessDocument.business_id == business_id, + TPlusBusinessDocument.applied_record_id.is_not(None), Shipment.business_id == business_id) + combined = orders.union_all(shipments).subquery() + grouped = select(combined.c.product_id, combined.c.product_name, combined.c.spec_model, combined.c.unit, + func.sum(combined.c.order_quantity).label("order_quantity"), + func.sum(combined.c.shipment_quantity).label("shipment_quantity")).group_by( + combined.c.product_id, combined.c.product_name, combined.c.spec_model, combined.c.unit).subquery() + total = session.scalar(select(func.count()).select_from(grouped)) or 0 + rows = session.execute(select(grouped).order_by(grouped.c.product_name, grouped.c.spec_model, + grouped.c.unit, grouped.c.product_id).offset(query.offset).limit(query.page_size)).mappings() + result = [{**row, "order_quantity": str(row["order_quantity"]), + "shipment_quantity": str(row["shipment_quantity"])} for row in rows] + return PageResult(records=result, total=total, page_num=query.page_num, page_size=query.page_size) + + +def _price_repair_revision(doc, parent, target, record): + return digest(["source-price-v1", doc.id, doc.applied_record_id, doc.record_id, doc.target_id, + parent.id, parent.record_id, parent.applied_record_id, parent.fingerprint, + parent.status, parent.reason, target.id, str(target.sale_price), record.id, record.row_hash]) + + +def documents(session, business_id, query, entity=None): + from app.services.tplus.snapshots import source_records + from app.services.tplus.workbench import WorkbenchError + + business(session, business_id) + conditions = [TPlusBusinessDocument.business_id == business_id] + if entity: + conditions.append(TPlusBusinessDocument.entity_type == entity) + total = session.scalar(select(func.count()).select_from(TPlusBusinessDocument).where(*conditions)) or 0 + rows = list(session.execute(select(TPlusBusinessDocument, TPlusSyncRecord).join(TPlusSyncRecord, TPlusSyncRecord.id == TPlusBusinessDocument.record_id) + .where(*conditions).order_by(TPlusBusinessDocument.id).offset(query.offset).limit(query.page_size))) + previous_ids = {doc.applied_record_id for doc, _ in rows if doc.applied_record_id and doc.applied_record_id != doc.record_id} + previous_records = {record.id: record for record in session.scalars(select(TPlusSyncRecord).where(TPlusSyncRecord.id.in_(previous_ids)))} if previous_ids else {} + details = {} + for source_database, entity_type in {(doc.source_database, doc.entity_type) for doc, _ in rows + if doc.entity_type in ("business_items", "shipment_items")}: + ids = [doc.source_id for doc, _ in rows if doc.source_database == source_database and doc.entity_type == entity_type] + try: + details[(source_database, entity_type)] = { + record.source_id: record for record in source_records(session, source_database, entity_type, ids=ids) + } + except WorkbenchError: + details[(source_database, entity_type)] = None + detail_docs = [doc for doc, _ in rows if doc.entity_type in ("business_items", "shipment_items")] + parent_ids = {str(doc.data_json["source_parent_id"]) for doc in detail_docs if doc.data_json.get("source_parent_id") is not None} + parents = {(doc.source_database, doc.entity_type, doc.source_id): doc for doc in session.scalars( + select(TPlusBusinessDocument).where(TPlusBusinessDocument.business_id == business_id, + TPlusBusinessDocument.entity_type.in_(("businesses", "shipments")), + TPlusBusinessDocument.source_id.in_(parent_ids)))} if parent_ids else {} + order_ids = {doc.target_id for doc in detail_docs if doc.entity_type == "business_items" and doc.target_id} + shipment_ids = {doc.target_id for doc in detail_docs if doc.entity_type == "shipment_items" and doc.target_id} + targets = {("business_items", item.id): item for item in session.scalars(select(BusinessItem).where(BusinessItem.id.in_(order_ids)))} if order_ids else {} + if shipment_ids: + targets.update({("shipment_items", item.id): item for item in session.scalars(select(ShipmentItem).where(ShipmentItem.id.in_(shipment_ids)))}) + has_input = bool(detail_docs and session.scalar(select(PayrollInput.id).where(PayrollInput.business_id == business_id).limit(1))) + result = [] + for doc, source in rows: + current = source + snapshot_state = None + if (doc.source_database, doc.entity_type) in details: + records = details[(doc.source_database, doc.entity_type)] + current = records.get(doc.source_id) if records is not None else None + snapshot_state = ("UNAVAILABLE" if records is None else "MISSING" if current is None + else "CHANGED" if current.id != doc.applied_record_id else "CURRENT") + raw = (current or source).payload_json + previous = previous_records.get(doc.applied_record_id, source if doc.applied_record_id == source.id else None) + person_id = doc.data_json.get("source_personnel_id") + if person_id is None: + person_id = raw.get("idclerk") if raw.get("idclerk") is not None else raw.get("idperson") + changes = _source_changes(previous, current) if previous and current and previous.id != current.id else [] + if doc.reason == "PENDING_SOURCE_CUSTOMER_CHANGED" and current: + customer_id = _source_values(current)["customer_id"] + changes = [{"field": "customer_id", "label": "客户标识", + "before": str(doc.data_json["source_customer_id"]), + "after": str(customer_id) if customer_id is not None else None}] + elif doc.reason == "PENDING_SOURCE_OUT_OF_RANGE" and current: + source_date = _source_values(current)["date"] + changes = [{"field": "date", "label": "日期", "before": doc.data_json.get("source_date"), + "after": str(source_date)[:10] if source_date is not None else None}] + source_price = raw.get("taxPrice") if raw.get("taxPrice") is not None else raw.get("price") + repair_revision = None + parent_type = {"business_items": "businesses", "shipment_items": "shipments"}.get(doc.entity_type) + parent = parents.get((doc.source_database, parent_type, str(doc.data_json.get("source_parent_id")))) + target = targets.get((doc.entity_type, doc.target_id)) + if (not has_input and parent and parent.reason == "SOURCE_PRICE_REVIEW_REQUIRED" and target + and target.sale_price is None and current and current.id == doc.applied_record_id + and raw.get("taxPrice") is None and raw.get("price") is not None): + from app.services.tplus.business_sync import decimal_value + try: + decimal_value(raw["price"], 4) + except WorkbenchError: + valid_price = False + else: + valid_price = True + if valid_price: + repair_revision = _price_repair_revision(doc, parent, target, current) + result.append({"id": doc.id, "entity": doc.entity_type, "source_id": doc.source_id, "source_no": doc.source_no, + "current_source_no": current.source_no if current else None, + "snapshot_state": snapshot_state, + "changes": changes, + "target_id": doc.target_id, "status": doc.status, "reason": doc.reason, + "date": doc.data_json.get("source_date"), "personnel_name": doc.data_json.get("source_personnel_name"), + "personnel_source_id": str(person_id) if person_id is not None else None, + "quantity": str(raw["quantity"]) if raw.get("quantity") is not None else None, + "amount": str(raw.get("taxAmount", raw.get("amount"))) if raw.get("taxAmount", raw.get("amount")) is not None else None, + "price": str(source_price) if source_price is not None else None, + "price_type": ("TAX_INCLUDED" if raw.get("taxPrice") is not None + else "TAX_EXCLUDED" if raw.get("price") is not None else None), + "price_repair_revision": repair_revision}) + return PageResult(records=result, total=total, page_num=query.page_num, page_size=query.page_size) + + +def document_details(session, business_id, document_id, query): + """对照已接入明细与当前完整快照,新增明细也按来源父单分页展示。""" + from app.services.tplus.snapshots import source_statement + from app.services.tplus.workbench import WorkbenchError + + business(session, business_id) + head = session.get(TPlusBusinessDocument, document_id) + if head is None or head.business_id != business_id or head.entity_type not in ("businesses", "shipments"): + raise NotFoundError("来源订单或发货单不存在") + detail_type = "business_items" if head.entity_type == "businesses" else "shipment_items" + old = select(TPlusBusinessDocument.source_id).where( + TPlusBusinessDocument.source_database == head.source_database, + TPlusBusinessDocument.business_id == business_id, + TPlusBusinessDocument.entity_type == detail_type, + TPlusBusinessDocument.applied_record_id.is_not(None), + TPlusBusinessDocument.data_json["source_parent_id"].as_string() == head.source_id, + ) + try: + current = source_statement(session, head.source_database, detail_type).where(or_( + TPlusSourceHead.source_parent_id == head.source_id, + and_(TPlusSourceHead.source_parent_id.is_(None), + cast(TPlusSyncRecord.payload_json["source_parent_id"].as_string(), String) == head.source_id), + )) + except WorkbenchError: + raise BadRequestError("来源明细的完整快照不可用,请等待采集完成") from None + ids = union(old, current.with_only_columns(TPlusSyncRecord.source_id)).subquery() + total = session.scalar(select(func.count()).select_from(ids)) or 0 + page_ids = list(session.scalars(select(ids.c.source_id).order_by(ids.c.source_id) + .offset(query.offset).limit(query.page_size))) + previous = dict(session.execute(select(TPlusBusinessDocument.source_id, TPlusSyncRecord) + .join(TPlusSyncRecord, TPlusSyncRecord.id == TPlusBusinessDocument.applied_record_id) + .where(TPlusBusinessDocument.source_database == head.source_database, + TPlusBusinessDocument.business_id == business_id, + TPlusBusinessDocument.entity_type == detail_type, + TPlusBusinessDocument.data_json["source_parent_id"].as_string() == head.source_id, + TPlusBusinessDocument.source_id.in_(page_ids))).all()) if page_ids else {} + latest = {record.source_id: record for record in session.scalars( + current.where(TPlusSyncRecord.source_id.in_(page_ids)))} if page_ids else {} + def values(record): + return {key: str(value) if value is not None else None + for key, value in _source_values(record).items()} if record else None + + rows = [] + for source_id in page_ids: + before, after = previous.get(source_id), latest.get(source_id) + changes = _source_changes(before, after) if before is not None and after is not None else [] + state = ("ADDED" if before is None else "REMOVED" if after is None + else "CHANGED" if before.id != after.id else "UNCHANGED") + rows.append({"source_id": source_id, "state": state, "changes": changes, + "before": values(before), "after": values(after)}) + return PageResult(records=rows, total=total, page_num=query.page_num, page_size=query.page_size) + + +def confirm_missing_source_price(session, business_id, document_id, payload, operator, meta=None): + """仅经人工确认补全未封存的空单价;来源变化或非空人工值均不得覆盖。""" + from app.services.audit_service import AuditRecord, audit + from app.services.tplus.business_sync import ( + START, + Context, + _apply_document, + decimal_value, + details, + statement, + ) + from app.services.tplus.workbench import WorkbenchError + + with source_calculation_lock(session): + audit_record = AuditRecord(module="业务管理", action="确认来源缺失单价", summary=f"业务 #{business_id} 来源明细 #{document_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["source_database"] + if not source_snapshot_ready(session, source): + raise BadRequestError("来源快照或业务接入未完成,请稍后重试") + if session.scalar(select(PayrollInput.id).where(PayrollInput.business_id == business_id).limit(1)): + raise BadRequestError("业务已有封存核算资料,请通过差异核对处理") + doc = session.get(TPlusBusinessDocument, document_id, with_for_update=True) + if (doc is None or doc.business_id != business_id or doc.source_database != source + or doc.entity_type not in ("business_items", "shipment_items") or doc.applied_record_id is None): + raise BadRequestError("来源明细不存在或尚未接入") + parent_type = "businesses" if doc.entity_type == "business_items" else "shipments" + parent_id = doc.data_json.get("source_parent_id") + parent = session.scalar(select(TPlusBusinessDocument).where( + TPlusBusinessDocument.business_id == business_id, TPlusBusinessDocument.source_database == source, + TPlusBusinessDocument.entity_type == parent_type, TPlusBusinessDocument.source_id == str(parent_id), + ).with_for_update()) + if parent is None or parent.reason != "SOURCE_PRICE_REVIEW_REQUIRED" or parent.status != "CONFLICT": + raise BadRequestError("来源单价状态已变化,请刷新后核对") + model = BusinessItem if doc.entity_type == "business_items" else ShipmentItem + target = session.get(model, doc.target_id, with_for_update=True) if doc.target_id else None + if target is None or target.sale_price is not None: + raise BadRequestError("正式单价已存在或明细不存在,不能覆盖") + if doc.entity_type == "business_items": + belongs = target.business_id == business_id + else: + shipment = session.get(Shipment, target.shipment_id, with_for_update=True) + belongs = bool(shipment and shipment.business_id == business_id) + if not belongs: + raise BadRequestError("正式明细不属于该业务") + try: + current = session.scalar(statement(session, source, doc.entity_type).where(TPlusSyncRecord.source_id == doc.source_id)) + head = session.scalar(statement(session, source, parent_type).where(TPlusSyncRecord.source_id == parent.source_id)) + if current is None or head is None or current.id != doc.applied_record_id or head.id != parent.record_id: + raise BadRequestError("来源明细已变化,请刷新后核对") + if _price_repair_revision(doc, parent, target, current) != payload.revision: + raise BadRequestError("来源单价或正式明细已变化,请刷新后核对") + raw = current.payload_json + if raw.get("taxPrice") is not None or raw.get("price") is None: + raise BadRequestError("来源单价状态已变化,请刷新后核对") + target.sale_price = decimal_value(raw["price"], 4) + session.flush() + ctx = Context(session, source) + 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("来源单据已变化,请刷新后核对") + except WorkbenchError: + raise BadRequestError("来源单价无效或超出精度,请核对原单") from None + result = {"document_id": doc.id, "parent_status": checked.status} + session.commit() + return result + + +def save_materials(session, business_id, payload, actor, meta=None): + """同一业务引用的档案才可批量维护;不接受名称、金额或来源字段。""" + from app.services.audit_service import AuditRecord, audit + from app.services.authorization_service import require_permission + from app.services.category_batch import fill_missing_product_items + + if payload.customer_type: + require_permission(session, actor, "base:customers:update") + if payload.products: + require_permission(session, actor, "base:products:update") + with audit(session, operator=actor, meta=meta, record=AuditRecord(module="业务管理", action="补充来源分类", summary=f"业务 #{business_id} 核算分类", request_data=payload.model_dump(mode="json"))): + row = session.get(Business, business_id, with_for_update=True) + if row is None or not row.integration_json: + raise BadRequestError("仅可维护自动接入业务的分类资料") + record = _latest(session, business_id) + if record is not None: + raise BadRequestError("已有封存核算资料,请通过差异核对处理分类变更") + if payload.customer_type: + customer = session.get(Customer, row.customer_id, with_for_update=True) + if customer.customer_type and customer.customer_type != payload.customer_type: + raise BadRequestError("客户已有核算类型,请刷新后核对") + customer.customer_type = payload.customer_type + ids = {item.product_id for item in payload.products} + actual = set(session.scalars(select(BusinessItem.product_id).where(BusinessItem.business_id == business_id, BusinessItem.product_id.in_(ids)))) + if ids != actual: + raise BadRequestError("产品不属于本业务") + for value in payload.products: + product = session.get(Product, value.product_id, with_for_update=True) + if product.product_category and product.product_category != value.category: + raise BadRequestError("已有核算分类,请在产品档案核对后调整") + product.product_category = value.category + fill_missing_product_items(session, [product.id], value.category) + session.commit() + return readiness(session, business_id) + + +def prepare(session, business_id, payload, operator, meta=None): + with source_calculation_lock(session): + return _prepare_locked(session, business_id, payload, operator, meta) + + +def _prepare_locked(session, business_id, payload, operator, meta=None): + """将正式源单据与人工成本、归属合成一个不可变核算输入版本。""" + from app.services.audit_service import AuditRecord, audit + with audit(session, operator=operator, meta=meta, record=AuditRecord(module="绩效核算", action="来源业务核算准备", summary=f"业务 #{business_id} 核算资料", request_data=payload.model_dump(mode="json"))): + payroll_months.ensure_allocation_editable(session, payload.month, operator) + row = session.get(Business, business_id, with_for_update=True) + if row is None or not row.integration_json: + raise BadRequestError("该业务不是自动接入的来源业务") + current = _latest(session, business_id) + if (current.version if current else 0) != payload.expected_version: + raise BadRequestError("核算资料版本已变化,请刷新") + state = readiness(session, business_id) + blocked = {r["code"] for r in state["reasons"]} & {"SOURCE_CONFLICT", "SOURCE_SNAPSHOT_PENDING", "CUSTOMER_CATEGORY_PENDING", "PRODUCT_CATEGORY_PENDING", "NO_ACTUAL_SHIPMENT", "SALES_RELATION_PENDING"} + if blocked: + raise BadRequestError("核算资料待补:" + ";".join(LABELS[c] for c in sorted(blocked))) + source = row.integration_json["source_database"] + facts = latest_facts(session, source) + source_docs = list(session.scalars(select(TPlusBusinessDocument).where(TPlusBusinessDocument.business_id == business_id, TPlusBusinessDocument.status == "APPLIED"))) + refs = {doc.record_id: doc for doc in source_docs} + raw = {r.id: r for r in session.scalars(select(TPlusSyncRecord).where(TPlusSyncRecord.id.in_(refs)))} + by_target = {(d.entity_type, d.target_id): d for d in source_docs} + targets = list(session.scalars(select(ShipmentItem).join(Shipment).where(Shipment.business_id == business_id))) + assignments = {o.shipment_item_id: o for o in payload.shipment_ownerships} + if set(assignments) != {i.id for i in targets} or len(assignments) != len(payload.shipment_ownerships): + raise BadRequestError("请逐条核实全部发货明细,不可遗漏或重复") + people, people_facts, ships = {}, {}, [] + key = f"customer:{row.integration_json['customer_source_id']}" + if current and "handovers" not in payload.model_fields_set: + handovers = current.data_json.get("facts", {}).get("handovers", []) + else: + handovers = [handover.model_dump(mode="json") for handover in payload.handovers] + if any(handover["business_key"] != key for handover in handovers): + raise BadRequestError("交接记录不属于本业务") + if any(handover["mode"] == "ACTIVE" and not handover.get("shipping_ownership_confirmed") for handover in handovers): + raise BadRequestError("在职交接须先逐次确认发货归属") + if any(handover["from_personnel_id"] == handover["to_personnel_id"] for handover in handovers): + raise BadRequestError("移交人与接手人不能相同") + + def include_person(pid): + if pid in people: + return + try: + person = session.get(Personnel, pid) + except OverflowError: + raise BadRequestError("交接人员必须是有效的正式人员") from None + role = session.get(BusinessRole, person.business_role_id) if person else None + if not person or not role: + raise BadRequestError("人员或岗位未确认") + fact = facts.get(("PERSONNEL", person.source_no)) + people[pid] = person + people_facts[str(pid)] = {**(fact.data_json if fact else {}), "role_code": role.role_code, "status": person.status} + + for handover in handovers: + for field in ("from_personnel_id", "to_personnel_id"): + value = handover[field] + if (not value.isascii() or not value.isdigit() or str(int(value)) != value + or not 1 <= int(value) <= 2**64 - 1): + raise BadRequestError("交接人员必须是有效的正式人员") + include_person(int(value)) + if role_on(people_facts[handover["to_personnel_id"]], date.fromisoformat(handover["effective_date"])) not in ("salesperson", "sales_manager"): + raise BadRequestError("接手人员在交接月份的岗位未确认") + for item in targets: + entry = assignments[item.id] + head = session.get(Shipment, item.shipment_id) + for pid in (entry.personnel_id, entry.manager_id): + if pid is None or pid in people: + continue + include_person(pid) + if role_on(people_facts[str(entry.personnel_id)], head.ship_date) not in ("salesperson", "sales_manager"): + raise BadRequestError("发货月份的人员岗位未确认") + if entry.manager_id is not None and role_on(people_facts[str(entry.manager_id)], head.ship_date) != "sales_manager": + raise BadRequestError("分管人员在发货月份须为经理") + if entry.freight_amount is None: + raise BadRequestError("请逐条核实运费金额;已核实无运费时填写0") + freight = Decimal(entry.freight_amount) + if freight >= item.amount: + raise BadRequestError("运费须小于该明细销售额") + product = session.get(Product, item.product_id) + cost_total = (Decimal(entry.unit_cost) * item.quantity).quantize(Decimal('0.01'), rounding=ROUND_HALF_UP) + line_doc = by_target.get(("shipment_items", item.id)) + source_line = raw.get(line_doc.record_id) if line_doc else None + if line_doc and (source_line is None or source_line.payload_json.get("idinventory") is None): + raise BadRequestError("发货来源产品标识缺失,请先核对") + product_source_id = source_line.payload_json.get("idinventory") if source_line else None + if product_source_id is not None: + from app.services.tplus.guidance import resolve_guidance + status, guidance = resolve_guidance(facts, str(product_source_id), line_doc.source_id, + head.ship_date.strftime("%Y-%m"), item.unit) + errors = {"AMBIGUOUS": "指导成本存在多条匹配记录,请先核对", + "UNIT_MISMATCH": "指导成本单位与发货明细不一致,请先核对", + "UNFINALIZED": "指导成本尚未封存,请先核对"} + if status in errors: + raise BadRequestError(errors[status]) + if guidance: + 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, + "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), + "quantity": str(item.quantity), "unit": item.unit, "category": product.product_category, + "customer_type": session.get(Customer, row.customer_id).customer_type, + "unit_cost": entry.unit_cost, "cost_total": str(cost_total), "pending": []} + ships.append(ship) + if current: + old = {s["line_id"]: s for s in current.data_json["shipments"]} + for ship in ships: + previous = old.get(ship["line_id"]) + if previous: + changed = any(previous.get(k) != ship.get(k) for k in ("cost_total", "date", "category", "customer_type", "personnel_id", "manager_id", "freight_amount")) + old_unit_cost = previous.get("unit_cost") + if changed or old_unit_cost is None or Decimal(str(old_unit_cost)) != Decimal(ship["unit_cost"]): + raise BadRequestError("既有发货成本或归属已封存,后续新增单据不得改写历史依据") + cash = list(session.scalars(select(Receipt).where(Receipt.business_id == business_id))) + receipts = [] + for receipt in cash: + doc = by_target.get(("receipts", receipt.id)) + if doc is None: + raise BadRequestError("回款缺少来源关联") + record = raw[doc.record_id] + if Decimal(str(record.payload_json["amount"])) != receipt.amount or str(record.payload_json["voucherDate"])[:10] != receipt.receipt_date.isoformat(): + raise BadRequestError("正式回款与来源不一致,请先核对差异") + fact = facts.get(("RECEIPT", record.source_id)) + try: + confirmed, evidence, pending = _voucher_evidence(record.payload_json, fact) + except (TypeError, ValueError): + raise BadRequestError("收款凭证数据异常,请先核对来源") from None + if pending: + raise BadRequestError("收款凭证依据冲突,请先核实") + exclusion = "NOT_GENERATED_VOUCHER" if evidence.get("count") == 0 else None + if not confirmed and not exclusion: + raise BadRequestError("收款凭证状态待核实,不能封存核算资料") + receipts.append({"source_id": str(receipt.id), "source_no": receipt.source_no, + "date": receipt.receipt_date.isoformat(), "group_key": key, + "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"]]) + 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}, + "maps": {"personnel": {str(p): p for p in people}, "shipments": {s["source_id"]: int(s["source_id"]) for s in ships}, + "shipment_items": {s["line_id"]: int(s["line_id"]) for s in ships}, "receipts": {str(r.id): r.id for r in cash}}, + "evidence": payload.evidence, "guidance_finalized": True, "as_of_month": payload.month} + fingerprint = digest(values) + record = PayrollInput(business_id=business_id, version=payload.expected_version + 1, source_database=source, + group_key=key, fingerprint=fingerprint, data_json=values, requested_by=operator.id) + session.add(record) + session.flush() + result = {"input_id": record.id, "version": record.version, "fingerprint": fingerprint} + session.commit() + return result diff --git a/backend/app/services/calc_service.py b/backend/app/services/calc_service.py index 9e9d5d0..af94533 100644 --- a/backend/app/services/calc_service.py +++ b/backend/app/services/calc_service.py @@ -188,6 +188,9 @@ def _load_and_guard( raise BadRequestError("回款关联的业务不存在") if business.owner_personnel_id is None: raise BadRequestError("业务未指定负责营销人员,无法核算(先在业务归属中指定)") + from app.services.receipt_confirmation import ensure_confirmed + + ensure_confirmed(session, [receipt.id]) return receipt, business diff --git a/backend/app/services/category_batch.py b/backend/app/services/category_batch.py new file mode 100644 index 0000000..ae95bfe --- /dev/null +++ b/backend/app/services/category_batch.py @@ -0,0 +1,33 @@ +"""批量补充空分类,不覆盖已有分类或冻结资料。""" + +from sqlalchemy import select, update + +from app.core.exceptions import BadRequestError +from app.models import BusinessItem, Customer, PayrollInput, Product +from app.services.audit_service import AuditRecord, audit + + +def fill_missing_product_items(session, product_ids, category): + """补齐未封存业务的空分类,保留既有分类及历史核算依据。""" + frozen = select(PayrollInput.id).where(PayrollInput.business_id == BusinessItem.business_id).exists() + session.execute(update(BusinessItem).where( + BusinessItem.product_id.in_(product_ids), BusinessItem.product_category.is_(None), ~frozen, + ).values(product_category=category)) + + +def fill_missing(session, model, payload, actor, meta=None): + field = Customer.customer_type if model is Customer else Product.product_category + module = "客户管理" if model is Customer else "产品管理" + with audit(session, operator=actor, meta=meta, record=AuditRecord( + module=module, action="批量补充分类", summary=f"批量补充 {len(payload.ids)} 条档案分类", + request_data=payload.model_dump(mode="json"), + )): + rows = list(session.scalars(select(model).where(model.id.in_(payload.ids)).order_by(model.id).with_for_update())) + if len(rows) != len(payload.ids) or any(getattr(row, field.key) is not None for row in rows): + raise BadRequestError("所选档案不存在或分类已变化,请刷新后重新选择") + for row in rows: + setattr(row, field.key, payload.category) + if model is Product: + fill_missing_product_items(session, payload.ids, payload.category) + session.commit() + return {"updated": len(rows)} diff --git a/backend/app/services/customer_service.py b/backend/app/services/customer_service.py index 6c1ee0c..e3d9488 100644 --- a/backend/app/services/customer_service.py +++ b/backend/app/services/customer_service.py @@ -17,7 +17,7 @@ import logging from dataclasses import dataclass from datetime import datetime -from sqlalchemy import Row +from sqlalchemy import Row, select from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session @@ -422,6 +422,25 @@ def _customer_ownership_log( ) +def batch_change_ownership(session, payload, *, operator, meta=None): + """只为未归属客户补充同一负责人;主档和逐条留痕同事务提交。""" + with audit_service.audit(session, operator=operator, meta=meta, record=AuditRecord( + module=MODULE_CUSTOMER, action=ACTION_UPDATE, + summary=f"批量补充 {len(payload.ids)} 条客户归属", request_data=payload.model_dump(mode="json"), + )): + _ensure_owner_personnel(session, payload.new_owner_personnel_id) + customers = list(session.scalars(select(Customer).where(Customer.id.in_(payload.ids)) + .order_by(Customer.id).with_for_update())) + if len(customers) != len(payload.ids) or any(customer.owner_personnel_id is not None for customer in customers): + raise BadRequestError("所选客户不存在或归属已变化,请刷新后重新选择") + change = CustomerOwnershipIn(new_owner_personnel_id=payload.new_owner_personnel_id, reason=payload.reason) + for customer in customers: + customer.owner_personnel_id = payload.new_owner_personnel_id + customer_repository.add_ownership_log(session, _customer_ownership_log(customer, change, None, operator)) + session.commit() + return {"updated": len(customers)} + + def change_ownership( session: Session, customer_id: int, @@ -457,7 +476,10 @@ def change_ownership( ): if prefetch.row is None: raise NotFoundError("客户不存在") - customer, _ = prefetch.row + customer = session.scalar(select(Customer).where(Customer.id == customer_id) + .with_for_update().execution_options(populate_existing=True)) + if customer is None: + raise NotFoundError("客户不存在") old_owner_id = _validate_customer_ownership(session, customer, payload) diff --git a/backend/app/services/param_registry.py b/backend/app/services/param_registry.py index 423e88a..709167f 100644 --- a/backend/app/services/param_registry.py +++ b/backend/app/services/param_registry.py @@ -190,6 +190,20 @@ PARAM_REGISTRY: dict[str, ParamSpec] = { description="累计回款达到不含运费销售额的设定比例后,开始计付绩效。启用前应收先冲抵,再核算新业务。", sort_order=11, ), + ParamSpec( + code="receipt.confirmation.enabled", + name="回款人工确认开关", + group="回款确认", + value_type="BOOL", + unit=None, + dimension_type="NONE", + options=None, + default_value="1", + value_min=None, + value_max=None, + description="默认开启:未人工确认的回款不得正式核算。关闭仅跳过人工确认,仍检查凭证等资格;与工资审核开关独立。", + sort_order=16, + ), ParamSpec( code="payment.weight.mode", name="支付权重应用方式", diff --git a/backend/app/services/payroll_calculation.py b/backend/app/services/payroll_calculation.py index 94588d7..6ee5508 100644 --- a/backend/app/services/payroll_calculation.py +++ b/backend/app/services/payroll_calculation.py @@ -24,12 +24,13 @@ from app.models import ( from app.services import audit_service, formula, payroll_months from app.services.audit_service import AuditRecord from app.services.param_service import resolve_snapshot +from app.services.personnel_roles import input_role from app.services.tplus.calculation_preview import _parameters from app.services.tplus.fifo import START, _beneficiary, amount_text, money, preview_group from app.services.tplus.workbench import digest # 核算规则版本:改公式或口径时同步更新,写入台账快照用于追溯。 -RULE_VERSION = "2026-10-30.2" +RULE_VERSION = "2026-11-04.1" def latest_input(session, business_id, *, for_update=False): @@ -78,6 +79,8 @@ def _previous(session, business_id, month): def _inputs(session, record, until): + from app.services.receipt_confirmation import ensure_confirmed + values = copy.deepcopy(record.data_json) ships, cash = values["shipments"], values["receipts"] for row in cash: @@ -91,6 +94,10 @@ def _inputs(session, record, until): or current.business_id != record.business_id ): raise BadRequestError("正式回款与核算资料不一致,请重新核对资料版本") + ensure_confirmed(session, [ + values["maps"]["receipts"].get(str(row["source_id"])) + for row in cash if not row.get("exclusion") and row["date"] <= until.isoformat() + ]) for row in ships: item_id = values["maps"].get("shipment_items", {}).get(str(row["line_id"])) if item_id is not None: @@ -99,7 +106,7 @@ def _inputs(session, record, until): if ( item is None or head is None - or item.amount != money(row["amount"]) + or item.amount != money(row.get("gross_tax_amount", row["amount"]) if values.get("input_schema") == "TPLUS_SYNC_V1" else row["amount"]) or head.ship_date.isoformat() != row["date"] or head.business_id != record.business_id ): @@ -157,7 +164,7 @@ def _post( business_no=business.business_no, personnel_id=owner, personnel_name=person.personnel_name, - role_code=role.role_code, + role_code=input_role(record.data_json, owner, event["effective_date"]) or role.role_code, customer_type=event["customer_type"], product_category=event["category"], ship_date=date.fromisoformat(event["shipment_date"]), @@ -213,6 +220,16 @@ def _post( def calculate(session, business_id, month, *, operator, meta=None, reason=None): + business = session.get(Business, business_id) + if business is not None and business.integration_json: + from app.services.business_source import source_calculation_lock + + with source_calculation_lock(session): + return _calculate_locked(session, business_id, month, operator=operator, meta=meta, reason=reason) + return _calculate_locked(session, business_id, month, operator=operator, meta=meta, reason=reason) + + +def _calculate_locked(session, business_id, month, *, operator, meta=None, reason=None): """重算从指定月起级联重建尚未生成工资的月份,同一事务保存全部版本。""" from app.services.manager_share import month_end @@ -249,6 +266,16 @@ def calculate(session, business_id, month, *, operator, meta=None, reason=None): record = latest_input(session, business_id, for_update=True) if record is None: raise BadRequestError("请先补录该业务的正式核算资料") + if record.data_json.get("input_schema") == "TPLUS_SYNC_V1": + from app.services.business_source import LABELS, readiness + + state = readiness(session, business_id) + blocking = {r["code"] for r in state["reasons"]} & { + "SOURCE_CONFLICT", "SOURCE_SNAPSHOT_PENDING", "INPUT_REFRESH_REQUIRED", "CUSTOMER_CATEGORY_PENDING", + "PRODUCT_CATEGORY_PENDING", "NO_ACTUAL_SHIPMENT", "SALES_RELATION_PENDING", + } + if blocking: + raise BadRequestError("核算资料待补:" + ";".join(LABELS[code] for code in sorted(blocking))) if reason: old_rows = list( session.scalars( diff --git a/backend/app/services/payroll_inputs.py b/backend/app/services/payroll_inputs.py index 5ea11a0..f12cfd3 100644 --- a/backend/app/services/payroll_inputs.py +++ b/backend/app/services/payroll_inputs.py @@ -37,6 +37,11 @@ def detail(session, business_id): def setup(session, business_id, payload, operator, meta=None): + source_business = session.get(Business, business_id) + if source_business and source_business.integration_json: + from app.services.business_source import prepare + + return prepare(session, business_id, payload, operator, meta) with audit_service.audit( session, operator=operator, @@ -121,15 +126,21 @@ def setup(session, business_id, payload, operator, meta=None): } ) receipts = list(session.scalars(select(Receipt).where(Receipt.business_id == business_id))) - if any(r.is_barter for r in receipts): - raise BadRequestError("抵顶特殊绩效继续线下处理,不进入普通FIFO核算") + # 用户 2026-11-03:抵顶按普通业务计算,特殊应得绩效线下核实, + # 不自动乘80%,也不因抵顶标记阻断整个客户的正常平账。 if current: previous_costs = {s["line_id"]: s for s in current.data_json["shipments"]} for ship in ships: old = previous_costs.get(ship["line_id"]) - if old and (old["cost_total"] != ship["cost_total"] or old["date"] != ship["date"]): - raise BadRequestError("原发货月份指导成本已封存,不得随后续回款更改") - handovers = [h.model_dump(mode="json") for h in payload.handovers] + if old: + old_unit_cost = old.get("unit_cost") + if (old["cost_total"] != ship["cost_total"] or old["date"] != ship["date"] + or old_unit_cost is None or Decimal(str(old_unit_cost)) != Decimal(ship["unit_cost"])): + raise BadRequestError("原发货月份指导成本已封存,不得随后续回款更改") + if current and "handovers" not in payload.model_fields_set: + handovers = current.data_json.get("facts", {}).get("handovers", []) + else: + handovers = [h.model_dump(mode="json") for h in payload.handovers] if any(h["business_key"] != key for h in handovers): raise BadRequestError("交接记录不属于本业务") values = { diff --git a/backend/app/services/permission_catalog.py b/backend/app/services/permission_catalog.py index b3bd44b..da1f5d2 100644 --- a/backend/app/services/permission_catalog.py +++ b/backend/app/services/permission_catalog.py @@ -14,7 +14,7 @@ MODULES = ( ("base:customers", "基础资料", "客户管理", ("view", "create", "update", "delete", "rating", "ownership")), ("business:businesses", "业务数据", "业务管理", ("view", "create", "update", "delete", "status", "ownership")), ("business:shipments", "业务数据", "发货管理", ("view", "create", "update", "delete")), - ("business:receipts", "业务数据", "回款管理", ("view", "create", "update", "delete")), + ("business:receipts", "业务数据", "回款管理", ("view", "create", "update", "delete", "confirm")), ("business:ratings", "业务数据", "评级标准", ("view", "create", "update", "delete")), ("tplus", "T+ 对接", "采集与导入", ("view", "collect", "retry", "preview", "import", "facts", "export")), ("payroll", "绩效工资", "正式核算与分配", ("view", "prepare", "calculate", "allocation")), @@ -27,6 +27,7 @@ MODULES = ( ("reports:payments", "报表统计", "回款统计", ("view", "export")), ) ACTION_NAMES = { + "confirm": "人工确认回款", "view": "查看", "create": "新增", "update": "编辑", "delete": "删除", "status": "变更状态", "password": "重置密码", "regions": "维护区域", "plans": "维护计划", "rating": "客户评级", "ownership": "变更归属", "export": "导出", "preview": "预览", diff --git a/backend/app/services/personnel_roles.py b/backend/app/services/personnel_roles.py new file mode 100644 index 0000000..6826260 --- /dev/null +++ b/backend/app/services/personnel_roles.py @@ -0,0 +1,47 @@ +"""从已冻结的任职依据取岗位,回补历史工资不改当前人员档案。""" + +from sqlalchemy import select + +from app.core.exceptions import BadRequestError +from app.models import PayrollInput + + +def role_on(person, day): + if not person: + return None + month = str(day)[:7] + for period in person.get("role_history", []): + if period["start_month"] <= month <= period["end_month"]: + return period["role_code"] + return person.get("role_code") + + +def input_role(values, personnel_id, day): + people = values.get("facts", {}).get("personnel", {}) + codes = { + role_on(people[source_id], day) + for source_id, target_id in values.get("maps", {}).get("personnel", {}).items() + if target_id == personnel_id and source_id in people + } + if len(codes) > 1: + raise BadRequestError("同一人员的历史岗位依据冲突,请先核实任职资料") + return next(iter(codes), None) + + +def frozen_roles(session, rows, day): + """按入结台账引用的资料版本解析任职,冲突时拒绝猜测。""" + inputs = {} + for row in rows: + snapshot = row.snapshot_json or {} + if snapshot.get("engine") == "FIFO" and snapshot.get("input_id"): + inputs.setdefault(snapshot["input_id"], set()).add(row.personnel_id) + result = {} + for record in session.scalars(select(PayrollInput).where(PayrollInput.id.in_(inputs))): + for pid in inputs[record.id]: + role = input_role(record.data_json, pid, day) + if role is None: + raise BadRequestError("月结人员的历史岗位依据缺失,请先核实任职资料") + if pid in result and result[pid] != role: + raise BadRequestError("同一人员的历史岗位依据冲突,请先核实任职资料") + result[pid] = role + return result diff --git a/backend/app/services/receipt_confirmation.py b/backend/app/services/receipt_confirmation.py new file mode 100644 index 0000000..c0a383e --- /dev/null +++ b/backend/app/services/receipt_confirmation.py @@ -0,0 +1,56 @@ +"""回款人工确认门槛;与生成凭证、保存资料和工资审核分别管理。""" + +import hashlib +import json +from datetime import date +from decimal import Decimal + +from sqlalchemy import select + +from app.core.exceptions import BadRequestError +from app.models import Receipt +from app.services.param_service import resolve_snapshot + +CONFIRMATION_PARAM = "receipt.confirmation.enabled" + + +def enabled(session) -> bool: + """操作开关取当前生效值,不按历史到账日绕过本次人工确认门槛。""" + return resolve_snapshot(session, date.today()).boolean(CONFIRMATION_PARAM) + + +def revision(receipt) -> str: + """只绑定经办人核对的业务内容;备注和同步时间不使确认无故失效。""" + values = [ + receipt.id, receipt.business_id, receipt.shipment_id, + receipt.receipt_date.isoformat(), format(Decimal(receipt.amount).quantize(Decimal("0.01")), "f"), + receipt.is_barter, receipt.t_plus_id, receipt.source_no, + ] + return hashlib.sha256(json.dumps(values, ensure_ascii=False).encode()).hexdigest() + + +def is_confirmed(receipt) -> bool: + return bool( + receipt.confirmation_status == 1 + and receipt.confirmed_by + and receipt.confirmed_at + and receipt.confirmed_revision == revision(receipt) + ) + + +def invalidate(receipt) -> None: + receipt.confirmation_status = 0 + receipt.confirmed_by = receipt.confirmed_at = receipt.confirmed_revision = None + + +def ensure_confirmed(session, receipt_ids) -> None: + """在原核算事务内锁定并核实回款;关闭只跳过人工门槛,不改变凭证资格。""" + ids = sorted(set(receipt_ids)) + if not ids or not enabled(session): + return + rows = list(session.scalars( + select(Receipt).where(Receipt.id.in_(ids)).order_by(Receipt.id) + .with_for_update().execution_options(populate_existing=True) + )) + if len(rows) != len(ids) or any(not is_confirmed(row) for row in rows): + raise BadRequestError("存在未人工确认或确认后内容已变化的回款,请先在回款管理逐笔确认") diff --git a/backend/app/services/receipt_service.py b/backend/app/services/receipt_service.py index e6e580b..327682a 100644 --- a/backend/app/services/receipt_service.py +++ b/backend/app/services/receipt_service.py @@ -24,7 +24,7 @@ import logging from dataclasses import dataclass -from datetime import date +from datetime import date, datetime from decimal import Decimal from sqlalchemy.exc import IntegrityError @@ -38,7 +38,7 @@ from app.core.constants import ( NO_PREFIX_RECEIPT, ) from app.core.db_errors import is_duplicate_entry -from app.core.exceptions import BadRequestError, NotFoundError +from app.core.exceptions import BadRequestError, ConflictError, NotFoundError from app.core.response import PageResult from app.models import Business, Receipt from app.models.business import BUSINESS_STATUS_ACTIVE @@ -50,7 +50,7 @@ from app.schemas.receipt import ( ReceiptQuery, ReceiptUpdateIn, ) -from app.services import audit_service +from app.services import audit_service, receipt_confirmation from app.services.audit_service import AuditRecord, RequestMeta from app.services.business_service import _recalc_totals from app.services.formula import payment_days @@ -81,6 +81,10 @@ def _to_out(row: tuple) -> ReceiptOut: amount=str(receipt.amount), is_barter=receipt.is_barter, settlement_status=receipt.settlement_status, + confirmation_status=1 if receipt_confirmation.is_confirmed(receipt) else 0, + confirmed_by=receipt.confirmed_by, + confirmed_at=receipt.confirmed_at, + confirmation_revision=receipt_confirmation.revision(receipt), payment_days=( payment_days(ship_date, receipt.receipt_date) if ship_date is not None else None ), @@ -151,6 +155,33 @@ def get_detail(session: Session, receipt_id: int) -> ReceiptOut: return _to_out(row) +def confirm_receipt(session, receipt_id, payload, *, operator, meta=None) -> ReceiptOut: + """独立确认已核对内容;锁内复核指纹,重复确认不改首次确认人/时间。""" + from app.services.authorization_service import require_permission + + require_permission(session, operator, "business:receipts:confirm") + with audit_service.audit( + session, operator=operator, + record=AuditRecord(module=MODULE_RECEIPT, action="人工确认", summary=f"人工确认回款 #{receipt_id}"), + meta=meta, + ): + receipt = receipt_repository.get_by_id(session, receipt_id) + if receipt is None: + raise NotFoundError("回款不存在") + _ensure_business_writable(session, receipt.business_id, "确认") + session.refresh(receipt, with_for_update=True) + if payload.expected_revision != receipt_confirmation.revision(receipt): + raise ConflictError("回款内容已变化,请重新查看并核对后确认") + if not receipt_confirmation.is_confirmed(receipt): + receipt.confirmation_status = 1 + receipt.confirmed_by = operator.username + receipt.confirmed_at = datetime.now() + receipt.confirmed_revision = payload.expected_revision + out = get_detail(session, receipt_id) + session.commit() + return out + + @dataclass(frozen=True, slots=True) class _CreatedReceipt: """建行结果(出参 + 固化的 ID/批次号,commit 后不再触碰 ORM 属性)。""" @@ -296,9 +327,13 @@ def update_receipt( ): if receipt is None: raise NotFoundError("回款不存在") + from app.services.business_source import ensure_source_editable + + ensure_source_editable(session, "receipts", receipt_id) + business = _ensure_business_writable(session, receipt.business_id, "修改") + session.refresh(receipt, with_for_update=True) if receipt.settlement_status == SETTLEMENT_DONE: raise BadRequestError("已核算回款禁止修改") - business = _ensure_business_writable(session, receipt.business_id, "修改") # 月结锁定拦截:原月与新月都不得落入已锁定月份(改到账日=跨月搬数, # 只查新月会让「从锁定月改到未锁定月」绕过拦截) from app.services import settlement_service @@ -311,11 +346,14 @@ def update_receipt( session, payload.shipment_id, receipt.business_id ) + old_revision = receipt_confirmation.revision(receipt) receipt.receipt_date = payload.receipt_date receipt.shipment_id = payload.shipment_id receipt.amount = Decimal(payload.amount) receipt.is_barter = payload.is_barter receipt.remark = payload.remark + if receipt_confirmation.revision(receipt) != old_revision: + receipt_confirmation.invalidate(receipt) _recalc_totals(session, business) out = _to_out((receipt, business.business_no, shipment_no, ship_date)) batch_no = receipt.receipt_batch_no @@ -353,6 +391,9 @@ def delete_receipt( ): if receipt is None: raise NotFoundError("回款不存在") + from app.services.business_source import ensure_source_editable + + ensure_source_editable(session, "receipts", receipt_id) if receipt.settlement_status == SETTLEMENT_DONE: raise BadRequestError("已核算回款禁止删除") business = _ensure_business_writable(session, receipt.business_id, "删除") diff --git a/backend/app/services/report_export.py b/backend/app/services/report_export.py index cc2d3b1..89728f0 100644 --- a/backend/app/services/report_export.py +++ b/backend/app/services/report_export.py @@ -280,7 +280,7 @@ def _write_row(sheet, columns: tuple[_Column, ...], row: dict) -> None: def _cell_value(column: _Column, raw): """单值转换:标签列转中文,文本列防公式注入。""" if raw is None: - return None + return "待核对" if column.key == "total_sale_amount" else None if column.kind == "amount": return raw if isinstance(raw, Decimal) else Decimal(str(raw)) if column.kind == "date": @@ -317,6 +317,9 @@ def _write_total(sheet, columns: tuple[_Column, ...], rows: list[dict]) -> None: totals.append("合计" if not label_written else None) label_written = True continue + if column.key == "total_sale_amount" and any(row.get(column.key) is None for row in rows): + totals.append("待核对") + continue total = sum( ( row.get(column.key) diff --git a/backend/app/services/report_service.py b/backend/app/services/report_service.py index e371287..586697c 100644 --- a/backend/app/services/report_service.py +++ b/backend/app/services/report_service.py @@ -812,14 +812,31 @@ def _unassigned_rows( # ==== 业务统计(FR-031 之一)==== +def _pending_sales_count(session, query): + from app.services.business_source import sales_relations_pending + + count, cursor = 0, 0 + while True: + rows = report_repository.list_source_business_sales(session, query, after_id=cursor, limit=200) + if not rows: + return count + pending = sales_relations_pending(session, [row.id for row in rows]) + count += len(pending | {row.id for row in rows if row.total_sale_amount is None}) + cursor = rows[-1].id + + def business_stats(session, query) -> dict: """业务统计分页 + 全筛选合计;回款/绩效为业务累计值。""" rows = report_repository.list_business_stats( session, query, offset=query.offset, limit=query.page_size ) sums = report_repository.sum_business_stats(session, query) + pending_count = _pending_sales_count(session, query) + from app.services.business_source import sales_relations_pending + + pending = sales_relations_pending(session, [row[0].id for row in rows if row[0].integration_json]) return { - "list": [_business_row(r) for r in rows], + "list": [_business_row(r, sales_pending=r[0].id in pending) for r in rows], "total": report_repository.count_business_stats(session, query), "pageNum": query.page_num, "pageSize": query.page_size, @@ -827,14 +844,15 @@ def business_stats(session, query) -> dict: "row_count": sums["row_count"], "ship_total": _q2(_dec(sums["ship_total"])), "received_total": _q2(_dec(sums["received_total"])), - "sale_total": _q2(_dec(sums["sale_total"])), + "sale_total": None if pending_count else _q2(_dec(sums["sale_total"])), + "sale_pending_count": pending_count, "performance_total": _q2(_dec(sums["performance_total"])), "payable_total": _q2(_dec(sums["payable_total"])), }, } -def _business_row(row) -> dict: +def _business_row(row, *, sales_pending=False) -> dict: """业务统计行(ORM 行与三个联查列)。""" business = row[0] return { @@ -844,7 +862,7 @@ def _business_row(row) -> dict: "status": business.status, "customer_name": row.customer_name, "owner_personnel_name": row.owner_personnel_name, - "total_sale_amount": business.total_sale_amount, + "total_sale_amount": None if sales_pending else business.total_sale_amount, "total_ship_amount": business.total_ship_amount, "total_received_amount": business.total_received_amount, "performance_amount": row.performance_amount, @@ -1082,7 +1100,10 @@ def _export_payload(session, code: str, query) -> tuple[list[dict], dict]: session, query, offset=0, limit=REPORT_EXPORT_MAX_ROWS ) sums = report_repository.sum_business_stats(session, query) - return [_business_row(r) for r in rows], { + from app.services.business_source import sales_relations_pending + + pending = sales_relations_pending(session, [row[0].id for row in rows if row[0].integration_json]) + return [_business_row(r, sales_pending=r[0].id in pending) for r in rows], { "title": EXPORT_TITLES["business-stats"], "row_count": total, "filters": _filter_text(session, query), diff --git a/backend/app/services/role_service.py b/backend/app/services/role_service.py index 71c2065..228aa9a 100644 --- a/backend/app/services/role_service.py +++ b/backend/app/services/role_service.py @@ -2,8 +2,7 @@ 需求 2.5.1 定义 admin / user 两个登录角色;2026-10-15 复核审批补充需求 新增营销后勤/财务/领导三个职能登录角色(营销后勤与财务/领导分区于审批 -节点准入)。本模块仍为轻量版(不含权限点分配 —— `permissions` / -`role_permissions` 表继续闲置,待业务方给出权限清单后再启用)。 +节点准入)。权限点按当前权限目录分配,管理员角色拥有固定权限基线。 保护规则(都是系统自举与授权安全的最低防线,不只是界面体验): @@ -15,8 +14,7 @@ 剥离绑定用户的权限,属安全与审计事故;删除前的校验走 `FOR UPDATE` 锁行,堵住「校验与删除之间并发绑定」的窗口 - `role_code` 创建后不可改 —— 它是授权判定(`BUILTIN_ROLE_CODES` 白名单按编码比对)的锚点 -- 停用角色**不解除绑定、不影响既有授权**(授权判定只看编码、不看状态), - 但**不可再分配给用户**(`user_service._resolve_roles` 拦截) +- 停用角色不解除用户绑定,但该角色不再提供有效授权,也不可再分配给用户。 """ import logging @@ -35,16 +33,17 @@ from app.core.constants import ( ) from app.core.exceptions import BadRequestError, ForbiddenError, NotFoundError from app.core.response import PageResult -from app.models import Role, User +from app.models import Role, RolePermission, User from app.repositories import role_repository from app.schemas.role import RoleCreateIn, RoleManageOut, RoleQuery, RoleUpdateIn -from app.services import audit_service +from app.services import audit_service, authorization_management from app.services.audit_service import AuditRecord, RequestMeta +from app.services.permission_catalog import CATALOG logger = logging.getLogger(__name__) -def _to_out(role: Role, user_count: int) -> RoleManageOut: +def _to_out(role: Role, user_count: int, permission_count: int = 0) -> RoleManageOut: """ORM 对象转响应模型。禁止把 ORM 对象直接当 response_model。""" return RoleManageOut( id=role.id, @@ -54,6 +53,7 @@ def _to_out(role: Role, user_count: int) -> RoleManageOut: sort_order=role.sort_order, status=role.status, user_count=user_count, + permission_count=len(CATALOG) if role.role_code == "admin" else permission_count, ) @@ -62,6 +62,10 @@ def _user_count(session: Session, role_id: int) -> int: return role_repository.count_users_by_role(session, [role_id]).get(role_id, 0) +def _permission_count(session: Session, role_id: int) -> int: + return role_repository.count_permissions_by_role(session, [role_id]).get(role_id, 0) + + def list_roles(session: Session, query: RoleQuery) -> PageResult[RoleManageOut]: """分页查询角色。 @@ -71,8 +75,9 @@ def list_roles(session: Session, query: RoleQuery) -> PageResult[RoleManageOut]: roles = role_repository.list_roles(session, query) total = role_repository.count_roles(session, query) counts = role_repository.count_users_by_role(session, [role.id for role in roles]) + permissions = role_repository.count_permissions_by_role(session, [role.id for role in roles]) return PageResult[RoleManageOut]( - records=[_to_out(role, counts.get(role.id, 0)) for role in roles], + records=[_to_out(role, counts.get(role.id, 0), permissions.get(role.id, 0)) for role in roles], total=total, page_num=query.page_num, page_size=query.page_size, @@ -90,6 +95,9 @@ def create_role( :raises BadRequestError: 角色编码已存在(含并发提交的兜底) """ + audit_data = payload.model_dump(exclude_none=True) + if payload.permission_codes is not None and not payload.reason: + audit_data["reason"] = "创建角色并配置初始权限" with audit_service.audit( session, operator=operator, @@ -97,13 +105,18 @@ def create_role( module=MODULE_ROLE, action=ACTION_CREATE, summary=f"新增角色 {payload.role_name}({payload.role_code})", - request_data=payload.model_dump(exclude_none=True), + request_data=audit_data, ), meta=meta, ): if role_repository.get_by_code(session, payload.role_code) is not None: raise BadRequestError(f"角色编码 {payload.role_code!r} 已存在") + codes = None + policy = None + if payload.permission_codes is not None: + policy = authorization_management.lock_policy(session, operator) + codes = authorization_management.validate_role_permissions(payload.permission_codes) role = Role( role_code=payload.role_code, role_name=payload.role_name, @@ -113,7 +126,12 @@ def create_role( ) try: role_repository.create(session, role) - out = _to_out(role, 0) + if codes is not None: + ids = authorization_management.active_permission_ids(session, codes) + session.add_all([RolePermission(role_id=role.id, permission_id=ids[code]) for code in codes]) + authorization_management.bump(policy, operator) + session.flush() + out = _to_out(role, 0, len(codes) if codes is not None else 0) session.commit() except IntegrityError as exc: # 先查后写不是原子操作:并发提交同一编码会撞 uk_roles_role_code。 @@ -156,7 +174,7 @@ def update_role( role.description = payload.description role.sort_order = payload.sort_order # 响应与后置动作全部在 commit 之前完成(见 user_service 顶部说明) - out = _to_out(role, _user_count(session, role_id)) + out = _to_out(role, _user_count(session, role_id), _permission_count(session, role_id)) session.commit() logger.info("修改角色 | role_id=%s", role_id) return out @@ -172,8 +190,7 @@ def set_status( ) -> RoleManageOut: """启用或禁用角色。 - 停用不解除用户绑定、不影响既有授权(授权判定只看编码不看状态), - 但该角色不可再分配给用户(`user_service._resolve_roles` 拦截); + 停用不解除用户绑定,但该角色不再提供有效授权,也不可再分配给用户; 内置角色不可停用。 :raises NotFoundError: 角色不存在 @@ -199,7 +216,7 @@ def set_status( raise ForbiddenError("内置角色不可停用") role.status = status - out = _to_out(role, _user_count(session, role_id)) + out = _to_out(role, _user_count(session, role_id), _permission_count(session, role_id)) session.commit() logger.info("变更角色状态 | role_id=%s | status=%s", role_id, status) return out diff --git a/backend/app/services/salary_completion.py b/backend/app/services/salary_completion.py index 549b612..c93aec0 100644 --- a/backend/app/services/salary_completion.py +++ b/backend/app/services/salary_completion.py @@ -16,6 +16,9 @@ def build_items(session, rows, month): from app.services.settlement_service import _salary_mode_of end = month_end(month) + from app.services.personnel_roles import frozen_roles + + historical_roles = frozen_roles(session, rows, end) # 人员锁同时串行化不同工资月份对同一年度缓发余额的结清。 people = { p.id: p for p in session.scalars(select(Personnel).order_by(Personnel.id).with_for_update()) @@ -84,7 +87,7 @@ def build_items(session, rows, month): for pid, values in sorted(buckets.items()): person = values["person"] profile = profiles[pid] - role = roles.get(profile.business_role_id, "salesperson") + role = historical_roles.get(pid, roles.get(profile.business_role_id, "salesperson")) mode = _salary_mode_of(session, role, month) annual = sum((Decimal(r["delta"]) for r in values["annual"]), ZERO) normal = values["normal"] diff --git a/backend/app/services/settlement_service.py b/backend/app/services/settlement_service.py index 1990ed2..a8ff157 100644 --- a/backend/app/services/settlement_service.py +++ b/backend/app/services/settlement_service.py @@ -90,6 +90,9 @@ def generate( _release_previous_round(session, existing, month) ledger_rows = _confirm_scope(session, month) + from app.services.receipt_confirmation import ensure_confirmed + + ensure_confirmed(session, [row.receipt_id for row in ledger_rows if row.receipt_id is not None]) from app.core.config import get_settings if not ledger_rows and get_settings().payroll_allow_legacy_calculation: diff --git a/backend/app/services/shipment_service.py b/backend/app/services/shipment_service.py index 1ce99dc..e2ce706 100644 --- a/backend/app/services/shipment_service.py +++ b/backend/app/services/shipment_service.py @@ -70,7 +70,7 @@ def _to_out(row: tuple[Shipment, str]) -> ShipmentOut: business_id=shipment.business_id, business_no=business_no, ship_date=shipment.ship_date, - freight_amount=str(shipment.freight_amount), + freight_amount=str(shipment.freight_amount) if shipment.freight_amount is not None else None, total_amount=str(shipment.total_amount), remark=shipment.remark, ) @@ -302,6 +302,9 @@ def update_shipment( ): if shipment is None: raise NotFoundError("发货单不存在") + from app.services.business_source import ensure_source_editable + + ensure_source_editable(session, "shipments", shipment_id) business = _ensure_business_writable(session, shipment.business_id, "编辑发货单") # 月结锁定拦截:原发货日与新月都不得落入已锁定月份(改日期=跨月搬数) from app.services import settlement_service @@ -355,6 +358,9 @@ def delete_shipment( ): if shipment is None: raise NotFoundError("发货单不存在") + from app.services.business_source import ensure_source_editable + + ensure_source_editable(session, "shipments", shipment_id) business = _ensure_business_writable(session, shipment.business_id, "删除发货单") # 月结锁定拦截:已锁定月份的发货单是月结快照的源数据,不得删除 from app.services import settlement_service diff --git a/backend/app/services/tplus/README.md b/backend/app/services/tplus/README.md index 75c4b32..33f671c 100644 Binary files a/backend/app/services/tplus/README.md and b/backend/app/services/tplus/README.md differ diff --git a/backend/app/services/tplus/business_import.py b/backend/app/services/tplus/business_import.py index 24a315c..51507ff 100644 --- a/backend/app/services/tplus/business_import.py +++ b/backend/app/services/tplus/business_import.py @@ -125,7 +125,11 @@ def _build(session, run): row["action"], row["reason"] = "SKIP", "CUSTOMER_SCOPE_ALREADY_SELECTED" continue seen_customers.add(customer_id) - if any(not ship.get("guidance_finalized") for ship in values["shipments"]): + active_ships = [s for s in values["shipments"] if not s.get("performance_excluded")] + if not active_ships: + row["action"], row["reason"] = "SKIP", "CORRECTION_EXCLUDED" + continue + if any(not ship.get("guidance_finalized") for ship in active_ships): row["reason"] = "GUIDANCE_FINALIZATION_REQUIRED" continue business_key = "customer:" + customer_id @@ -150,7 +154,10 @@ def _build(session, run): ): for source_id in ids: linked = _link(session, run.source_database, entity, source_id) - if linked and ( + source_rows = values[entity] + excluded = all(r.get("exclusion") or r.get("performance_excluded") + for r in source_rows if r["source_id"] == source_id) + if linked and not excluded and ( not link or not current or source_id not in current.data_json["maps"].get(entity, {}) @@ -158,7 +165,7 @@ def _build(session, run): row["action"], row["reason"] = "CONFLICT", "SOURCE_ALREADY_IMPORTED" if not _link(session, run.source_database, "customers", values["group"]["customer_id"]): row["action"], row["reason"] = "PENDING", "CUSTOMER_IMPORT_REQUIRED" - for ship in values["shipments"]: + for ship in active_ships: if not _link(session, run.source_database, "products", ship["product_id"]): row["action"], row["reason"] = "PENDING", "PRODUCT_IMPORT_REQUIRED" frozen = {"calculation": frozen, "plan": rows, "targets": targets} @@ -197,8 +204,9 @@ def _create(session, run, row, frozen): ) if len(candidates) == 1: people[code] = candidates[0] - needed = {str(s["personnel_id"]) for s in values["shipments"]} - needed.update(str(s["manager_id"]) for s in values["shipments"] if s.get("manager_id")) + active_ships = [s for s in values["shipments"] if not s.get("performance_excluded")] + needed = {str(s["personnel_id"]) for s in active_ships} + needed.update(str(s["manager_id"]) for s in active_ships if s.get("manager_id")) for handover in values["facts"]["handovers"]: needed.update((str(handover["from_personnel_id"]), str(handover["to_personnel_id"]))) if needed - people.keys(): @@ -211,8 +219,8 @@ def _create(session, run, row, frozen): else Business( business_no=_new_no(session, Business, "business_no", "YW"), customer_id=customer.target_id, - owner_personnel_id=people[str(values["shipments"][0]["personnel_id"])].id, - business_date=min(date.fromisoformat(s["date"]) for s in values["shipments"]), + owner_personnel_id=people[str(active_ships[0]["personnel_id"])].id, + business_date=min(date.fromisoformat(s["date"]) for s in active_ships), status=1, is_special=0, t_plus_id=row["business_key"], diff --git a/backend/app/services/tplus/business_sync.py b/backend/app/services/tplus/business_sync.py new file mode 100644 index 0000000..875625e --- /dev/null +++ b/backend/app/services/tplus/business_sync.py @@ -0,0 +1,705 @@ +"""将已发布的源单据接入业务;绝不以核算资料齐全作为建档条件。""" + +from collections import Counter +from datetime import date, datetime +from decimal import Decimal, InvalidOperation + +from sqlalchemy import String, and_, cast, func, null, or_, select +from sqlalchemy.exc import IntegrityError + +from app.models import ( + Business, + BusinessItem, + BusinessStatusLog, + Customer, + Personnel, + Product, + Receipt, + Shipment, + ShipmentItem, + TPlusBusinessDocument, + TPlusSourceHead, + TPlusSourceLink, + TPlusSyncRecord, + TPlusWorkLine, +) +from app.services.no_generator import next_no +from app.services.tplus.snapshots import source_manifest, source_statement +from app.services.tplus.workbench import WorkbenchError, digest, latest_facts + +START = date(2026, 3, 1) +ENTITIES = ("businesses", "shipments", "receipts") +DETAILS = {"businesses": "business_items", "shipments": "shipment_items"} +SOURCE_FIELDS = { + "businesses": ("idcustomer", "voucherdate", "idclerk", "voucherState"), + "shipments": ("idcustomer", "voucherdate", "idclerk", "voucherState", "isCounteractDelivery"), + "receipts": ("idpartner", "idperson", "idclerk", "voucherDate", "amount", "isReceiveFlag", + "voucherstate", "cancelState", "accounting_voucher_count", "valid_accounting_voucher_count", + "accounting_voucher_id"), +} + + +def decimal_value(value, scale=2, *, positive=False): + """拒绝精度丢失;源字符串末尾零不算超出精度。""" + try: + number = Decimal(str(value)) + if not number.is_finite() or abs(number) >= Decimal(10) ** (14 - scale): + raise ValueError + if number != number.quantize(Decimal(1).scaleb(-scale)): + raise ValueError + if positive and number <= 0: + raise ValueError + return number + except (ValueError, InvalidOperation): + raise WorkbenchError("SOURCE_VALUE_INVALID") from None + + +def _source_personnel_id(raw): + person_id = raw.get("idclerk") + if person_id is None: + person_id = raw.get("idperson") + return str(person_id) if person_id is not None else None + + +def statement(session, source, entity, *, start=None): + query = source_statement(session, source, entity) + if start: + field = "voucherDate" if entity == "receipts" else "voucherdate" + raw_date = func.substr(func.nullif(TPlusSyncRecord.payload_json[field].as_string(), "null"), 1, 10) + projected = TPlusSourceHead.source_date + fallback = and_(projected.is_(None), or_( + raw_date.is_(None), raw_date >= start.isoformat(), ~raw_date.like("____-__-__"), + )) + applied = select(TPlusBusinessDocument.id).where( + TPlusBusinessDocument.source_database == source, + TPlusBusinessDocument.entity_type == entity, + TPlusBusinessDocument.source_id == TPlusSyncRecord.source_id, + or_(TPlusBusinessDocument.applied_record_id.is_not(None), + TPlusBusinessDocument.business_id.is_not(None)), + ).exists() + query = query.where(or_(projected >= start.isoformat(), ~projected.like("____-__-__"), fallback, applied)) + return query + + +def preview(session, source, start=START): + """只读统计当前完整快照;无需连接 SQL Server 或写业务表。""" + counts, candidates = {}, {} + for entity in ENTITIES: + query = statement(session, source, entity, start=start) + if entity == "receipts": + query = query.where(TPlusSyncRecord.payload_json["isReceiveFlag"].as_integer() == 1) + observed = query.with_only_columns(TPlusSyncRecord.source_id).subquery() + counts[entity] = session.scalar(select(func.count()).select_from(observed)) or 0 + joined = observed.outerjoin(TPlusBusinessDocument, and_( + TPlusBusinessDocument.source_database == source, + TPlusBusinessDocument.entity_type == entity, + TPlusBusinessDocument.source_id == observed.c.source_id, + )) + new_count = session.scalar(select(func.count()).select_from(joined).where( + TPlusBusinessDocument.id.is_(None))) or 0 + candidates[entity] = {"new": new_count, "existing": counts[entity] - new_count} + conflicts = session.scalar(select(func.count()).select_from(TPlusBusinessDocument).where( + TPlusBusinessDocument.source_database == source, TPlusBusinessDocument.status == "CONFLICT")) or 0 + customer_scope = preview_customer_scope(session, source, start) + product_scope = preview_product_scope(session, source, start) + return {"start_date": start.isoformat(), "documents": counts, "candidates": candidates, "existing_conflicts": conflicts, + "customer_scope": customer_scope, "product_scope": product_scope, + "formal_payroll_written": False, "message": "按客户自动建档;缺少人工资料只阻止核算"} + + +def preview_customer_scope(session, source, start): + """只读预检客户身份;单据状态、产品及数据库排序规则仍须执行时核验。""" + customer_ids = set() + for entity in ENTITIES: + query = statement(session, source, entity, start=start) + if entity == "receipts": + query = query.where(TPlusSyncRecord.payload_json["isReceiveFlag"].as_integer() == 1) + field = "idpartner" if entity == "receipts" else "idcustomer" + for projected, raw in session.execute(query.with_only_columns( + TPlusSourceHead.source_customer_id, TPlusSyncRecord.payload_json[field].as_string() + )): + value = projected if projected is not None else raw + if value not in (None, "", "null"): + customer_ids.add(str(value)) + facts = latest_facts(session, source) + records, customer_links, business_links = {}, {}, set() + ordered = sorted(customer_ids) + for offset in range(0, len(ordered), 500): + ids = ordered[offset:offset + 500] + records.update({row.source_id: row for row in session.scalars( + statement(session, source, "customers").where(TPlusSyncRecord.source_id.in_(ids)) + )}) + for link in session.scalars(select(TPlusSourceLink).where( + TPlusSourceLink.source_database == source, + or_(and_(TPlusSourceLink.entity_type == "customers", TPlusSourceLink.source_id.in_(ids)), + and_(TPlusSourceLink.entity_type == "businesses", TPlusSourceLink.source_id.in_(["customer:" + i for i in ids]))), + )): + if link.entity_type == "customers": + customer_links[link.source_id] = link.target_id + else: + business_links.add(link.source_id.removeprefix("customer:")) + grouped, problems = {}, [] + for source_id in ordered: + if source_id in customer_links or source_id in business_links: + continue + record = records.get(source_id) + if record is None: + problems.append((source_id, "GROUP_SOURCE_MISSING")) + continue + name = str(record.payload_json.get("name") or "").strip() + if not name or len(name) > 128: + problems.append((source_id, "SOURCE_VALUE_INVALID")) + continue + fact = facts.get(("CUSTOMER", source_id)) + owner = (fact.data_json if fact else {}).get("owner_name", "") + grouped.setdefault((name.casefold(), str(owner).casefold()), []).append((source_id, name, owner)) + for values in grouped.values(): + if len(values) > 1: + problems.extend((source_id, "DUPLICATE_SOURCE_IDENTITY") for source_id, _, _ in values) + # 仅查询候选名称;按数据库排序规则匹配后再以来源身份报告,不按名称合并。 + names = sorted({item[1] for values in grouped.values() for item in values}) + existing = set() + for offset in range(0, len(names), 500): + existing.update((str(name).casefold(), str(owner).casefold()) for name, owner in session.execute( + select(Customer.company_name, Customer.owner_name).where(Customer.company_name.in_(names[offset:offset + 500])) + )) + for key, values in grouped.items(): + if key in existing: + problems.extend((source_id, "TARGET_UNIQUE_CONFLICT") for source_id, _, _ in values) + problems.sort() + return {"candidate_customers": len(customer_ids), "without_business_link": len(customer_ids - business_links), + "identity_warnings": len(problems), "warning_counts": dict(Counter(reason for _, reason in problems)), + "examples": [{"source_id": source_id, "reason": reason} for source_id, reason in problems[:100]], + "truncated": len(problems) > 100, + "note": "客户范围为候选上限;同名预检不覆盖所有排序规则、产品冲突及单据有效性,执行时仍逐单核对。"} + + +def preview_product_scope(session, source, start): + """基于候选单据查找涉及的产品,不读取或创建正式档案。""" + product_ids = set() + for entity, child_entity in DETAILS.items(): + parents = list(session.scalars(statement(session, source, entity, start=start) + .with_only_columns(TPlusSyncRecord.source_id))) + for offset in range(0, len(parents), 500): + ids = parents[offset:offset + 500] + raw_parent = cast(TPlusSyncRecord.payload_json["source_parent_id"].as_string(), String) + query = statement(session, source, child_entity).where(or_( + TPlusSourceHead.source_parent_id.in_(ids), + and_(TPlusSourceHead.source_parent_id.is_(None), raw_parent.in_(ids)), + )).with_only_columns(TPlusSyncRecord.payload_json["idinventory"].as_string()) + product_ids.update(str(value) for value in session.scalars(query) if value not in (None, "", "null")) + records, linked = {}, set() + ordered = sorted(product_ids) + for offset in range(0, len(ordered), 500): + ids = ordered[offset:offset + 500] + records.update({row.source_id: row for row in session.scalars( + statement(session, source, "products").where(TPlusSyncRecord.source_id.in_(ids)) + )}) + linked.update(session.scalars(select(TPlusSourceLink.source_id).where( + TPlusSourceLink.source_database == source, TPlusSourceLink.entity_type == "products", + TPlusSourceLink.source_id.in_(ids), + ))) + grouped, problems = {}, [] + for source_id in ordered: + if source_id in linked: + continue + record = records.get(source_id) + if record is None: + problems.append((source_id, "GROUP_SOURCE_MISSING")) + continue + raw = record.payload_json + name, spec, unit = str(raw.get("name") or "").strip(), str(raw.get("specification") or ""), raw.get("unit_name") + if not name or len(name) > 128 or len(spec) > 128 or (unit and len(unit) > 16): + problems.append((source_id, "SOURCE_VALUE_INVALID")) + continue + grouped.setdefault((name.casefold(), spec.casefold()), []).append((source_id, name, spec)) + for values in grouped.values(): + if len(values) > 1: + problems.extend((source_id, "DUPLICATE_SOURCE_IDENTITY") for source_id, _, _ in values) + names = sorted({item[1] for values in grouped.values() for item in values}) + existing = set() + for offset in range(0, len(names), 500): + existing.update((str(name).casefold(), str(spec).casefold()) for name, spec in session.execute( + select(Product.product_name, Product.spec_model).where(Product.product_name.in_(names[offset:offset + 500])) + )) + for key, values in grouped.items(): + if key in existing: + problems.extend((source_id, "TARGET_UNIQUE_CONFLICT") for source_id, _, _ in values) + problems.sort() + return {"candidate_products": len(product_ids), "without_product_link": len(product_ids - linked), + "identity_warnings": len(problems), "warning_counts": dict(Counter(reason for _, reason in problems)), + "examples": [{"source_id": source_id, "reason": reason} for source_id, reason in problems[:100]], + "truncated": len(problems) > 100, + "note": "产品范围为候选上限;同名预检不覆盖所有排序规则或单据有效性,执行时仍逐单核对。"} + + +class Context: + def __init__(self, session, source): + self.session, self.source = session, source + 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"))} + + def record(self, entity, source_id): + key = entity, str(source_id) + if key not in self.records: + self.records[key] = self.session.scalar(statement(self.session, self.source, entity).where(TPlusSyncRecord.source_id == str(source_id))) + if self.records[key] is None: + raise WorkbenchError("GROUP_SOURCE_MISSING") + return self.records[key] + + def link(self, entity, source_id): + return self.session.scalar(select(TPlusSourceLink).where( + TPlusSourceLink.source_database == self.source, TPlusSourceLink.entity_type == entity, + TPlusSourceLink.source_id == str(source_id))) + + def add_link(self, entity, source_id, target): + self.session.add(TPlusSourceLink(source_database=self.source, entity_type=entity, + source_id=str(source_id), target_id=target.id)) + + def master(self, entity, source_id): + key = entity, str(source_id) + if key in self.targets: + return self.targets[key] + model = Customer if entity == "customers" else Product + link = self.link(entity, source_id) + if link: + target = self.session.get(model, link.target_id) + if target is None: + raise WorkbenchError("TARGET_MISSING") + else: + record = self.record(entity, source_id) + raw = record.payload_json + name = str(raw.get("name") or "").strip() + if not name or len(name) > 128: + raise WorkbenchError("SOURCE_VALUE_INVALID") + if entity == "customers": + fact = self.facts.get(("CUSTOMER", str(source_id))) + values = fact.data_json if fact else {} + target = Customer(company_name=name, owner_name=values.get("owner_name", ""), + customer_type=values.get("customer_type")) + conflict = self.session.scalar(select(Customer.id).where(Customer.company_name == name, Customer.owner_name == target.owner_name).limit(1)) + else: + fact = self.facts.get(("PRODUCT", str(source_id))) + values = fact.data_json if fact else {} + spec, unit = str(raw.get("specification") or ""), raw.get("unit_name") + if len(spec) > 128 or (unit and len(unit) > 16): + raise WorkbenchError("SOURCE_VALUE_INVALID") + target = Product(product_name=name, spec_model=spec, unit=unit, + product_category=values.get("category"), status=0 if raw.get("disabled") else 1) + conflict = self.session.scalar(select(Product.id).where(Product.product_name == name, Product.spec_model == spec).limit(1)) + if conflict: + raise WorkbenchError("TARGET_UNIQUE_CONFLICT") + target.t_plus_id, target.source_no = str(source_id), record.source_no + target.synced_at = datetime.now() + self.session.add(target) + self.session.flush() + self.add_link(entity, source_id, target) + self.targets[key] = target + return target + + def business(self, customer_id, day, run_no): + key = "customer:" + str(customer_id) + link = self.link("businesses", key) + if link: + business = self.session.get(Business, link.target_id, with_for_update=True) + if business is None: + raise WorkbenchError("TARGET_MISSING") + if business.integration_json is None: + # 既有完整核算导入不能被新接入器改写。 + raise WorkbenchError("LEGACY_BUSINESS_REVIEW_REQUIRED") + return business + customer = self.master("customers", customer_id) + business = Business(business_no=next_no(self.session, model=Business, column_name="business_no", prefix="YW"), + customer_id=customer.id, business_date=day, status=1, is_special=0, + t_plus_id=key, source_no=key, sync_batch_no=run_no, synced_at=datetime.now(), + integration_json={"source_database": self.source, "customer_source_id": str(customer_id), "personnel_names": [], "personnel_codes": []}, + total_cost_amount=null(), total_freight_amount=null()) + self.session.add(business) + self.session.flush() + self.add_link("businesses", key, business) + self.session.add(BusinessStatusLog(business_id=business.id, old_status=1, new_status=1, + change_reason="T+业务自动建档", operator_name="T+业务接入")) + return business + + def person(self, raw): + source_id = _source_personnel_id(raw) + person = self.people.get(source_id) + if not person: + return {"source_personnel_id": source_id, "source_personnel_name": None, + "source_personnel_no": None, "personnel_id": None} + code = str(person.payload_json.get("code") or "") + link = self.link("personnel", person.source_id) + target = self.session.get(Personnel, link.target_id) if link else None + return {"source_personnel_id": source_id, "source_personnel_name": person.payload_json.get("name"), "source_personnel_no": code, + "personnel_id": target.id if target else None} + + +def details(ctx, entity, source_id): + parent = TPlusSourceHead.source_parent_id + raw_parent = cast(TPlusSyncRecord.payload_json["source_parent_id"].as_string(), String) + return list(ctx.session.scalars(statement(ctx.session, ctx.source, DETAILS[entity]).where(or_( + parent == str(source_id), and_(parent.is_(None), raw_parent == str(source_id)), + )).order_by(TPlusSyncRecord.id))) + + +def rebuild_projections(session, source): + """按已发布快照补齐旧 worker 未写入的索引,不改源记录。""" + entities = ("businesses", "business_items", "shipments", "shipment_items", "receipts") + queries = {entity: source_statement(session, source, entity) for entity in entities} + for entity, query in queries.items(): + cursor = 0 + while True: + missing = (TPlusSourceHead.source_parent_id.is_(None) if entity in DETAILS.values() + else TPlusSourceHead.source_date.is_(None)) + rows = session.execute(query.with_only_columns(TPlusSourceHead, TPlusSyncRecord.payload_json) + .where(TPlusSourceHead.id > cursor, missing) + .order_by(TPlusSourceHead.id).limit(500).with_for_update()).all() + if not rows: + break + for head, raw in rows: + parent = raw.get("source_parent_id") + if parent is not None and len(str(parent)) <= 64: + head.source_parent_id = str(parent) + customer = raw.get("idcustomer") or raw.get("idpartner") + if head.source_customer_id is None and customer is not None and len(str(customer)) <= 64: + head.source_customer_id = str(customer) + day = raw.get("voucherdate") or raw.get("voucherDate") + if head.source_date is None and day is not None: + head.source_date = str(day)[:10] + cursor = rows[-1][0].id + session.commit() + + +def ledger(ctx, entity, record): + row = ctx.session.scalar(select(TPlusBusinessDocument).where( + TPlusBusinessDocument.source_database == ctx.source, TPlusBusinessDocument.entity_type == entity, + TPlusBusinessDocument.source_id == record.source_id).with_for_update()) + if row is None: + row = TPlusBusinessDocument(source_database=ctx.source, entity_type=entity, source_id=record.source_id, + record_id=record.id, source_no=record.source_no, data_json={}) + ctx.session.add(row) + row.record_id = record.id + return row + + +def _line_values(ctx, record): + raw = record.payload_json + product = ctx.master("products", raw.get("idinventory")) + quantity = decimal_value(raw.get("quantity"), positive=True) + source_price = raw.get("taxPrice") if raw.get("taxPrice") is not None else raw.get("price") + price = decimal_value(source_price, 4) if source_price is not None else None + amount = decimal_value(raw.get("taxAmount"), positive=True) + return {"product_id": product.id, "product_name": product.product_name, "spec_model": product.spec_model, + "unit": product.unit, "quantity": quantity, "sale_price": price}, amount, product.product_category + + +def _shipment_order_line(ctx, business_id, child): + """来源明细号、订单号和产品均一致时,才将发货视为订单的一部分。""" + raw = child.payload_json + order_detail_id = raw.get("saleOrderDetailId") + if order_detail_id is None: + return None, False + doc = ctx.session.scalar(select(TPlusBusinessDocument).where( + TPlusBusinessDocument.source_database == ctx.source, + TPlusBusinessDocument.entity_type == "business_items", + TPlusBusinessDocument.source_id == str(order_detail_id), + TPlusBusinessDocument.business_id == business_id, + )) + if doc is None: + return None, False + order_record = ctx.session.get(TPlusSyncRecord, doc.applied_record_id) if doc.applied_record_id else None + order_raw = order_record.payload_json if order_record else {} + parent = order_raw.get("source_parent_id") + order_id = raw.get("saleOrderId") + matches = (doc.status == "APPLIED" and order_record is not None + and order_raw.get("idinventory") is not None + and str(order_raw.get("idinventory")) == str(raw.get("idinventory")) + and (order_id is None or (parent is not None and str(parent) == str(order_id)))) + return (doc if matches else None), not matches + + +def _recalculate(ctx, business): + _refresh_business_totals(ctx.session, business) + ctx.recalculated.add(business.id) + + +def _refresh_business_totals(session, business): + from app.services.tplus.sales_relations import resolve_items + + items = list(session.scalars(select(BusinessItem).where(BusinessItem.business_id == business.id))) + order_ids = list(session.scalars(select(TPlusBusinessDocument.target_id).where( + TPlusBusinessDocument.business_id == business.id, TPlusBusinessDocument.entity_type == "business_items", + TPlusBusinessDocument.status == "APPLIED"))) + ambiguous, sale_items = resolve_items(session, business, items, order_ids) + business.total_sale_amount = None if ambiguous else decimal_value(sum((i.sale_amount for i in sale_items), Decimal(0))) + business.total_cost_amount = decimal_value(sum((i.cost_amount for i in sale_items), Decimal(0))) if not ambiguous and sale_items and all(i.cost_amount is not None for i in sale_items) else None + business.integration_json = {**(business.integration_json or {}), "sales_relation_pending": ambiguous} + ships = list(session.scalars(select(Shipment).where(Shipment.business_id == business.id))) + business.total_ship_amount = decimal_value(sum((i.total_amount for i in ships), Decimal(0))) + business.total_freight_amount = decimal_value(sum((i.freight_amount for i in ships), Decimal(0))) if ships and all(i.freight_amount is not None for i in ships) else None + business.total_received_amount = decimal_value(session.scalar(select(func.coalesce(func.sum(Receipt.amount), 0)).where(Receipt.business_id == business.id))) + + +def _pending_document(ctx, doc, entity, raw, run_no, reason): + """保留未生效或更正单,并关联可识别的客户业务供核对。""" + date_value = raw.get("voucherDate") if entity == "receipts" else raw.get("voucherdate") + customer_id = raw.get("idpartner") if entity == "receipts" else raw.get("idcustomer") + try: + day = date.fromisoformat(str(date_value)[:10]) + except (ValueError, TypeError): + raise WorkbenchError("SOURCE_VALUE_INVALID") from None + if customer_id is None: + raise WorkbenchError("GROUP_CUSTOMER_MISMATCH") + business = ctx.business(customer_id, day, run_no) + doc.business_id = business.id + doc.data_json = {**ctx.person(raw), "source_date": day.isoformat(), "source_customer_id": str(customer_id)} + doc.status, doc.reason = "PENDING", reason + if business.id not in ctx.recalculated: + _recalculate(ctx, business) + return "PENDING", doc + + +def _apply_document(ctx, entity, record, children, run_no, start): + raw = record.payload_json + doc = ledger(ctx, entity, record) + customer_id = raw.get("idpartner") if entity == "receipts" else raw.get("idcustomer") + previous_customer = (doc.data_json or {}).get("source_customer_id") + if (doc.applied_record_id is None and doc.business_id is not None + and previous_customer is not None and customer_id is not None + and str(previous_customer) != str(customer_id)): + doc.status, doc.reason = "CONFLICT", "PENDING_SOURCE_CUSTOMER_CHANGED" + return "CONFLICT", doc + if doc.applied_record_id is None and doc.business_id is not None: + date_value = raw.get("voucherDate") if entity == "receipts" else raw.get("voucherdate") + try: + source_day = date.fromisoformat(str(date_value)[:10]) + except (ValueError, TypeError): + source_day = None + if source_day is not None and source_day < start: + doc.status, doc.reason = "CONFLICT", "PENDING_SOURCE_OUT_OF_RANGE" + return "CONFLICT", doc + fingerprint = digest({"header": {k: raw.get(k) for k in SOURCE_FIELDS[entity]}, + "items": [{k: r.payload_json.get(k) for k in ("id", "idinventory", "quantity", "taxPrice", "price", "taxAmount", "saleOrderId", "saleOrderDetailId")} for r in children]}) + if doc.applied_record_id is not None: + person_changed = False + header_amount_changed = False + if entity in ("businesses", "shipments"): + if doc.applied_record_id != record.id: + original = ctx.session.get(TPlusSyncRecord, doc.applied_record_id) + header_amount_changed = original is None or original.payload_json.get("taxAmount") != raw.get("taxAmount") + if "source_personnel_id" in doc.data_json: + previous_person = doc.data_json["source_personnel_id"] + previous_person = str(previous_person) if previous_person is not None else None + else: + previous = ctx.session.get(TPlusSyncRecord, doc.applied_record_id) + previous_person = _source_personnel_id(previous.payload_json) if previous else None + person_changed = previous is None + person_changed = person_changed or previous_person != _source_personnel_id(raw) + if doc.fingerprint != fingerprint or doc.source_no != record.source_no or person_changed or header_amount_changed: + doc.status, doc.reason = "CONFLICT", "FORMAL_SOURCE_CHANGED" + return "CONFLICT", doc + if entity in DETAILS: + for child in children: + raw_line = child.payload_json + if raw_line.get("taxPrice") is not None or raw_line.get("price") is None: + continue + line_doc = ctx.session.scalar(select(TPlusBusinessDocument).where( + TPlusBusinessDocument.source_database == ctx.source, + TPlusBusinessDocument.entity_type == DETAILS[entity], + TPlusBusinessDocument.source_id == child.source_id, + TPlusBusinessDocument.business_id == doc.business_id, + )) + model = BusinessItem if entity == "businesses" else ShipmentItem + target_line = ctx.session.get(model, line_doc.target_id) if line_doc and line_doc.target_id else None + try: + expected_price = decimal_value(raw_line["price"], 4) + except WorkbenchError: + expected_price = None + if target_line is None or expected_price is None or target_line.sale_price != expected_price: + doc.status, doc.reason = "CONFLICT", "SOURCE_PRICE_REVIEW_REQUIRED" + return "CONFLICT", doc + if entity == "shipments" and doc.business_id is not None: + for child in children: + _, mismatch = _shipment_order_line(ctx, doc.business_id, child) + if mismatch: + doc.status, doc.reason = "CONFLICT", "ORDER_SHIPMENT_MISMATCH" + return "CONFLICT", doc + doc.record_id, doc.status, doc.reason = record.id, "APPLIED", None + if doc.business_id is not None and doc.business_id not in ctx.recalculated: + business = ctx.session.get(Business, doc.business_id, with_for_update=True) + if business is not None: + _recalculate(ctx, business) + return "UNCHANGED", doc + if entity == "receipts" and raw.get("isReceiveFlag") != 1: + doc.status, doc.reason = "EXCLUDED", "NOT_RECEIPT" + return "EXCLUDED", doc + if (entity != "receipts" and raw.get("voucherState") != 189) or (entity == "receipts" and raw.get("voucherstate") != 189): + return _pending_document(ctx, doc, entity, raw, run_no, "SOURCE_DOCUMENT_INACTIVE") + if entity == "shipments" and raw.get("isCounteractDelivery"): + return _pending_document(ctx, doc, entity, raw, run_no, "CORRECTION_PAIR_REQUIRED") + day_text = raw.get("voucherDate") if entity == "receipts" else raw.get("voucherdate") + try: + day = date.fromisoformat(str(day_text)[:10]) + except (ValueError, TypeError): + raise WorkbenchError("SOURCE_VALUE_INVALID") from None + if entity != "receipts" and not children: + raise WorkbenchError("SHIPMENT_DETAILS_MISSING") + # 先校验整张单据,避免不完整来源建出空壳业务。 + order_mismatch = False + if entity == "receipts": + decimal_value(raw.get("amount"), positive=True) + else: + for child in children: + decimal_value(child.payload_json.get("quantity"), positive=True) + decimal_value(child.payload_json.get("taxAmount"), positive=True) + customer_id = raw.get("idpartner") if entity == "receipts" else raw.get("idcustomer") + if customer_id is None: + raise WorkbenchError("GROUP_CUSTOMER_MISMATCH") + 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"])): + summary[key] = sorted(set(summary.get(key, []) + ([value] if value else []))) + business.integration_json = summary + doc.business_id = business.id + doc.data_json = {**person, "source_date": day.isoformat(), "source_customer_id": str(customer_id)} + if entity == "receipts": + target = Receipt(business_id=business.id, receipt_batch_no=next_no(ctx.session, model=Receipt, column_name="receipt_batch_no", prefix="HK"), + receipt_date=day, amount=decimal_value(raw["amount"]), is_barter=0, settlement_status=0, + t_plus_id=record.source_id, source_no=record.source_no, sync_batch_no=run_no, synced_at=datetime.now()) + ctx.session.add(target) + ctx.session.flush() + ctx.add_link(entity, record.source_id, target) + doc.target_id = target.id + else: + target = None + if entity == "shipments": + target = Shipment(business_id=business.id, shipment_no=next_no(ctx.session, model=Shipment, column_name="shipment_no", prefix="SH"), + ship_date=day, freight_amount=None, total_amount=decimal_value(sum((decimal_value(c.payload_json["taxAmount"]) for c in children), Decimal(0))), + t_plus_id=record.source_id, source_no=record.source_no, sync_batch_no=run_no, synced_at=datetime.now()) + ctx.session.add(target) + ctx.session.flush() + ctx.add_link(entity, record.source_id, target) + doc.target_id = target.id if target else business.id + for child in children: + line, amount, category = _line_values(ctx, child) + child_doc = ledger(ctx, DETAILS[entity], child) + if entity == "businesses": + item = BusinessItem(business_id=business.id, **line, sale_amount=amount, product_category=category) + else: + item = ShipmentItem(shipment_id=target.id, **line, amount=amount) + order_line, mismatch = _shipment_order_line(ctx, business.id, child) + order_mismatch = order_mismatch or mismatch + if order_line is None: + ctx.session.add(BusinessItem(business_id=business.id, **line, sale_amount=amount, product_category=category)) + ctx.session.add(item) + ctx.session.flush() + child_doc.business_id, child_doc.target_id = business.id, item.id + child_doc.applied_record_id, child_doc.record_id = child.id, child.id + child_doc.fingerprint, child_doc.status, child_doc.reason = child.row_hash, "APPLIED", None + child_doc.data_json = {**person, "source_parent_id": record.source_id, "source_date": day.isoformat(), "source_customer_id": str(customer_id)} + doc.applied_record_id, doc.fingerprint, doc.status, doc.reason = record.id, fingerprint, "APPLIED", None + ctx.session.flush() + _recalculate(ctx, business) + if order_mismatch: + doc.status, doc.reason = "CONFLICT", "ORDER_SHIPMENT_MISMATCH" + return "CONFLICT", doc + return "CREATE", doc + + +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") + 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 + for entity in ENTITIES: + # 先固定所有依赖的发布状态;失败的全量采集不得泄露半批数据。 + if entity in DETAILS: + statement(session, ctx.source, DETAILS[entity]) + cursor = 0 + while True: + records = list(session.scalars(statement(session, ctx.source, entity, start=start) + .where(TPlusSourceHead.id > cursor).order_by(TPlusSourceHead.id).limit(200))) + if not records: + break + # record.id 不是 source_head.id(哈希去重可复用旧版本)。 + head_ids = dict(session.execute(select(TPlusSourceHead.record_id, TPlusSourceHead.id).where( + TPlusSourceHead.source_database == ctx.source, TPlusSourceHead.entity_type == entity, + TPlusSourceHead.record_id.in_([r.id for r in records]))).all()) + for record in records: + try: + with session.begin_nested(): + children = details(ctx, entity, record.source_id) if entity in DETAILS else [] + action, doc = _apply_document(ctx, entity, record, children, run.run_no, start) + except (WorkbenchError, IntegrityError) as exc: + ctx.targets.clear() + ctx.recalculated.clear() + doc = ledger(ctx, entity, record) + status = "CONFLICT" if isinstance(exc, IntegrityError) else "PENDING" + reason = "TARGET_UNIQUE_CONFLICT" if isinstance(exc, IntegrityError) else str(exc) + if doc.applied_record_id is None: + try: + with session.begin_nested(): + _pending_document(ctx, doc, entity, record.payload_json, run.run_no, reason) + except (WorkbenchError, IntegrityError): + ctx.targets.clear() + ctx.recalculated.clear() + doc = ledger(ctx, entity, record) + elif doc.business_id not in ctx.recalculated: + business = session.get(Business, doc.business_id, with_for_update=True) + if business is not None: + _recalculate(ctx, business) + doc.status, doc.reason = status, reason + action = doc.status + processed += 1 + counts[action] += 1 + session.add(TPlusWorkLine(run_no=run.run_no, line_no=processed, entity_type=entity, + source_id=record.source_id, action=action, reason=doc.reason, + data_json={"source_no": record.source_no, "business_id": doc.business_id, **(doc.data_json or {})})) + run.summary_json = {"counts": dict(counts), "total": processed, "formal_payroll_written": False, + "start_date": start.isoformat(), "phase": "RUNNING", "entity": entity} + session.commit() + cursor = head_ids[record.id] + observed = statement(session, ctx.source, entity).with_only_columns(TPlusSyncRecord.source_id).subquery() + missing = ~select(observed.c.source_id).where(observed.c.source_id == TPlusBusinessDocument.source_id).exists() + cursor = 0 + while True: + documents = list(session.scalars(select(TPlusBusinessDocument).where( + TPlusBusinessDocument.source_database == ctx.source, + TPlusBusinessDocument.entity_type == entity, + or_(TPlusBusinessDocument.applied_record_id.is_not(None), + TPlusBusinessDocument.business_id.is_not(None)), + TPlusBusinessDocument.id > cursor, + missing, + ).order_by(TPlusBusinessDocument.id).limit(200))) + if not documents: + break + for doc in documents: + doc.status, doc.reason = "CONFLICT", "SOURCE_DOCUMENT_MISSING" + processed += 1 + counts["CONFLICT"] += 1 + session.add(TPlusWorkLine(run_no=run.run_no, line_no=processed, entity_type=entity, + source_id=doc.source_id, action="CONFLICT", reason=doc.reason, + data_json={"source_no": doc.source_no, "business_id": doc.business_id, **(doc.data_json or {})})) + cursor = documents[-1].id + run.summary_json = {"counts": dict(counts), "total": processed, "formal_payroll_written": False, + "start_date": start.isoformat(), "phase": "RUNNING", "entity": entity} + session.commit() + if source_manifest(session, run.source_database) != manifest: + raise WorkbenchError("SOURCE_REFRESH_REQUIRED") + run.status = "PARTIAL" if counts["PENDING"] or counts["CONFLICT"] else "SUCCEEDED" + run.summary_json = {**(run.summary_json or {}), "phase": "COMPLETED", "source_manifest": manifest} diff --git a/backend/app/services/tplus/business_sync_control.py b/backend/app/services/tplus/business_sync_control.py new file mode 100644 index 0000000..c872096 --- /dev/null +++ b/backend/app/services/tplus/business_sync_control.py @@ -0,0 +1,141 @@ +"""业务接入开关与幂等排队;HTTP 不执行导入。""" + +from uuid import NAMESPACE_URL, uuid5 + +from sqlalchemy import inspect, select +from sqlalchemy.exc import IntegrityError + +from app.core.exceptions import BadRequestError, ConflictError, ForbiddenError +from app.models import TPlusBusinessSyncConfig, TPlusWorkRun, User +from app.services.authorization_service import require_permission +from app.services.tplus.business_sync import START, preview +from app.services.tplus.task_service import history_source_database, new_batch_no +from app.services.tplus.workbench import WorkbenchError, digest, work_out + + +def configuration(session): + source = history_source_database(session) + ready = inspect(session.connection()).has_table(TPlusBusinessSyncConfig.__tablename__) + row = session.scalar(select(TPlusBusinessSyncConfig).where(TPlusBusinessSyncConfig.source_database == source)) if ready else None + return {"enabled": bool(row and row.enabled), "start_date": (row.start_date if row else START).isoformat(), + "version": row.version if row else 0, "editable": ready and bool(source), + "last_error": row.last_error if row else None} + + +def queue(session, actor_id, start_date, request_key): + require_permission(session, session.get(User, actor_id), "tplus:import") + source = history_source_database(session) + if not source: + raise BadRequestError("请先完成 T+ 数据采集") + key = str(request_key) + found = session.scalar(select(TPlusWorkRun).where(TPlusWorkRun.request_key == key)) + values = {"start_date": start_date.isoformat()} + def existing_run(found): + if found.kind != "BUSINESS_SYNC" or found.requested_by != actor_id or found.source_database != source or found.input_json != values: + raise BadRequestError("请求键已用于不同任务") + return work_out(found) + if found: + return existing_run(found) + run = TPlusWorkRun(run_no=new_batch_no(), request_key=key, source_database=source, + requested_by=actor_id, kind="BUSINESS_SYNC", status="QUEUED", input_json=values) + try: + with session.begin_nested(): + session.add(run) + session.flush() + session.commit() + except IntegrityError: + session.rollback() + found = session.scalar(select(TPlusWorkRun).where(TPlusWorkRun.request_key == key)) + if found is None: + raise ConflictError() from None + return existing_run(found) + return work_out(run) + + +def save(session, payload, actor_id): + require_permission(session, session.get(User, actor_id), "tplus:import") + source = history_source_database(session) + if not source: + raise BadRequestError("请先完成 T+ 数据采集") + row = session.scalar(select(TPlusBusinessSyncConfig).where(TPlusBusinessSyncConfig.source_database == source).with_for_update()) + if (row.version if row else 0) != payload.expected_version: + raise ConflictError() + if row is None: + row = TPlusBusinessSyncConfig(source_database=source, updated_by=actor_id) + session.add(row) + row.enabled, row.start_date = int(payload.enabled), payload.start_date + row.version, row.updated_by, row.last_error = payload.expected_version + 1, actor_id, None + try: + session.commit() + except IntegrityError: + session.rollback() + raise ConflictError() from None + if row.enabled: + schedule(session) + return configuration(session) + + +def schedule(session): + """仅成功快照触发;去重周期与配置版本绑定,不改变原采集结果。""" + if not inspect(session.connection()).has_table(TPlusBusinessSyncConfig.__tablename__): + return None + source = history_source_database(session) + config = session.scalar(select(TPlusBusinessSyncConfig).where(TPlusBusinessSyncConfig.source_database == source)) + if config is None or not config.enabled: + return None + try: + require_permission(session, session.get(User, config.updated_by), "tplus:import") + except ForbiddenError: + config.last_error = "TASK_AUTHORIZATION_REVOKED" + session.commit() + return None + from app.services.tplus.snapshots import source_manifest + try: + manifest = list(source_manifest(session, source).items()) + except WorkbenchError: + config.last_error = "SOURCE_REFRESH_REQUIRED" + session.commit() + return None + key = uuid5(NAMESPACE_URL, digest([source, config.version, manifest, "business-sync-v1"])) + result = queue(session, config.updated_by, config.start_date, key) + if result["status"] != "FAILED" and config.last_error is not None: + config.last_error = None + session.commit() + return result + + +def readonly_preview(session, start_date=START): + return preview(session, history_source_database(session), start_date) + + +def record_schedule_failure(session, source): + """保留采集成功状态,同时在接入设置中提示排队异常。""" + config = session.scalar(select(TPlusBusinessSyncConfig).where( + TPlusBusinessSyncConfig.source_database == source, + TPlusBusinessSyncConfig.enabled == 1, + ).with_for_update()) + if config: + config.last_error = "BUSINESS_SYNC_SCHEDULE_FAILED" + session.commit() + + +def record_authorization_failure(session, source, actor_id): + """仅标记仍由该授权人启用的配置,由调用方提交任务失败状态。""" + config = session.scalar(select(TPlusBusinessSyncConfig).where( + TPlusBusinessSyncConfig.source_database == source, + TPlusBusinessSyncConfig.enabled == 1, + TPlusBusinessSyncConfig.updated_by == actor_id, + ).with_for_update()) + if config: + config.last_error = "TASK_AUTHORIZATION_REVOKED" + + +def clear_execution_failure(session, source, actor_id): + """当前授权人重试成功后清除旧错误,由调用方提交任务结果。""" + config = session.scalar(select(TPlusBusinessSyncConfig).where( + TPlusBusinessSyncConfig.source_database == source, + TPlusBusinessSyncConfig.enabled == 1, + TPlusBusinessSyncConfig.updated_by == actor_id, + ).with_for_update()) + if config: + config.last_error = None diff --git a/backend/app/services/tplus/calculation_preview.py b/backend/app/services/tplus/calculation_preview.py index faf9c59..977791d 100644 --- a/backend/app/services/tplus/calculation_preview.py +++ b/backend/app/services/tplus/calculation_preview.py @@ -67,6 +67,7 @@ def _guidance(facts, product_id, line_id, month): def _shipments(session, run, key, group, facts): + from app.services.tplus.corrections import verified_correction headers = _group_records(session, run.source_database, "shipments", group["shipment_ids"]) details = source_records( session, run.source_database, "shipment_items", parent_ids=group["shipment_ids"] @@ -104,14 +105,7 @@ def _shipments(session, run, key, group, facts): raise WorkbenchError("GROUP_CUSTOMER_MISMATCH") supplement = facts.get(("SHIPMENT", record.source_id)) supplement = supplement.data_json if supplement else {} - correction_fact = facts.get(("CORRECTION", record.source_id)) or facts.get( - ("CORRECTION", head_record.source_id) - ) - correction = ( - correction_fact.data_json - if correction_fact and correction_fact.data_json.get("entity") == "SHIPMENT" - else {} - ) + correction = verified_correction(session, run.source_database, facts, "SHIPMENT", head_record) product_id = str(raw.get("idinventory")) product = facts.get(("PRODUCT", product_id)) customer = facts.get(("CUSTOMER", group["customer_id"])) @@ -204,16 +198,12 @@ def _shipments(session, run, key, group, facts): def _receipts(session, run, key, group, facts): + from app.services.tplus.corrections import verified_correction records = _group_records(session, run.source_database, "receipts", group["receipt_ids"]) rows = [] for record in records: raw = record.payload_json - correction_fact = facts.get(("CORRECTION", record.source_id)) - correction = ( - correction_fact.data_json - if correction_fact and correction_fact.data_json.get("entity") == "RECEIPT" - else {} - ) + correction = verified_correction(session, run.source_database, facts, "RECEIPT", record) redirect_target = correction.get("redirect_business_key") if str(raw.get("idpartner")) != group["customer_id"] and redirect_target != key: raise WorkbenchError("GROUP_CUSTOMER_MISMATCH") @@ -374,14 +364,31 @@ def execute_preview(session, run, *, save_lines=True, customer_scope=False): ] if entity == "receipts": source_ids.extend( - f.fact_key + f.fact_key.removeprefix("RECEIPT:") for (kind, _), f in facts.items() if kind == "CORRECTION" and f.data_json.get("entity") == "RECEIPT" and f.data_json.get("redirect_business_key") == key - and f.fact_key not in source_ids + and f.fact_key.removeprefix("RECEIPT:") not in source_ids ) group[target_field] = sorted(source_ids, key=lambda value: int(value)) + else: + redirected = [ + f.fact_key.removeprefix("RECEIPT:") + for (kind, _), f in facts.items() + if kind == "CORRECTION" and f.data_json.get("entity") == "RECEIPT" + and f.data_json.get("redirect_business_key") == key + ] + group["receipt_ids"] = sorted(set(group["receipt_ids"] + redirected), key=int) + + def effective_receipts(value, group_key): + from app.services.tplus.corrections import correction_fact + + return { + rid for rid in value["receipt_ids"] + if not (f := correction_fact(facts, "RECEIPT", rid)) + or f.data_json.get("redirect_business_key") == group_key + } # 也检查未选组,避免同一单据在另一组被解释为独立资金。 for (kind, other_key), other in facts.items(): if ( @@ -390,7 +397,7 @@ def execute_preview(session, run, *, save_lines=True, customer_scope=False): and not customer_scope and ( set(group["shipment_ids"]) & set(other.data_json["shipment_ids"]) - or set(group["receipt_ids"]) & set(other.data_json["receipt_ids"]) + or effective_receipts(group, key) & effective_receipts(other.data_json, other_key) ) ): overlapping.add(key) diff --git a/backend/app/services/tplus/corrections.py b/backend/app/services/tplus/corrections.py new file mode 100644 index 0000000..8dee02c --- /dev/null +++ b/backend/app/services/tplus/corrections.py @@ -0,0 +1,195 @@ +"""转客户更正:成对确认、原始单据保留,同一回款只归一个业务。""" + +from decimal import Decimal + +from sqlalchemy import select + +from app.core.exceptions import BadRequestError +from app.core.tplus_lock import TPlusBusyError, sync_lock +from app.models import TPlusFact, TPlusSourceLink +from app.services.tplus.fifo import money +from app.services.tplus.snapshots import source_records +from app.services.tplus.workbench import ( + WorkbenchError, + _work_source_database, + digest, + fact_out, + latest_facts, +) + + +def correction_fact(facts, entity, source_id): + """实体前缀避免发货ID与收款ID相同相互覆盖;旧事实只供核对。""" + found = facts.get(("CORRECTION", f"{entity}:{source_id}")) or facts.get( + ("CORRECTION", str(source_id)) + ) + return found if found and found.data_json.get("entity") == entity else None + + +def verified_correction(session, source, facts, entity, record): + fact = correction_fact(facts, entity, record.source_id) + if fact is None: + return {} + data = fact.data_json + if data.get("source_hash") != record.row_hash: + raise WorkbenchError("CORRECTION_RECONFIRM_REQUIRED") + if entity == "SHIPMENT": + if data.get("details_hash") != _details_hash(session, source, record): + raise WorkbenchError("CORRECTION_RECONFIRM_REQUIRED") + other = correction_fact(facts, entity, data.get("paired_source_id")) + if not other or ( + other.data_json.get("paired_source_id") != record.source_id + or other.data_json.get("pair_key") != data["pair_key"] + or other.data_json.get("source_hash") != data.get("paired_hash") + ): + raise WorkbenchError("CORRECTION_PAIR_REQUIRED") + peers = source_records(session, source, "shipments", ids=[data["paired_source_id"]]) + if len(peers) != 1 or peers[0].row_hash != data["paired_hash"]: + raise WorkbenchError("CORRECTION_RECONFIRM_REQUIRED") + if other.data_json.get("details_hash") != _details_hash(session, source, peers[0]): + raise WorkbenchError("CORRECTION_RECONFIRM_REQUIRED") + neg, pos = _amount(session, source, record), _amount(session, source, peers[0]) + if neg + pos != 0 or neg * pos >= 0: + raise WorkbenchError("CORRECTION_RECONFIRM_REQUIRED") + return data + + +def _records(session, source, entity, ids): + rows = source_records(session, source, entity, ids=ids) + if {r.source_id for r in rows} != set(ids): + raise BadRequestError("所选更正单或实际回款不在当前完整源快照中") + return {r.source_id: r for r in rows} + + +def _amount(session, source, head): + lines = source_records(session, source, "shipment_items", parent_ids=[head.source_id]) + if not lines or any(r.payload_json.get("taxAmount") is None for r in lines): + raise BadRequestError("更正单缺少完整含税金额明细,不能成对排除") + return sum((money(r.payload_json["taxAmount"]) for r in lines), Decimal(0)) + + +def _details_hash(session, source, head): + return digest( + [ + (r.source_id, r.row_hash) + for r in source_records(session, source, "shipment_items", parent_ids=[head.source_id]) + ] + ) + + +def save_pair(session, payload, actor_id): + """沿用源级互斥锁,全部事实同事务追加;同一提交幂等。""" + source = _work_source_database(session) + try: + with sync_lock(session): + session.rollback() + facts = latest_facts(session, source) + heads = _records( + session, + source, + "shipments", + [ + payload.negative_shipment_id, + payload.positive_shipment_id, + ], + ) + negative, positive = ( + heads[payload.negative_shipment_id], + heads[payload.positive_shipment_id], + ) + if negative.payload_json.get("idcustomer") == positive.payload_json.get("idcustomer"): + raise BadRequestError("转客户更正对必须分别属于原客户和新客户") + if any(h.payload_json.get("voucherState") != 189 for h in heads.values()): + raise BadRequestError("更正销货单尚未生效") + neg, pos = _amount(session, source, negative), _amount(session, source, positive) + if not (neg < 0 < pos and neg + pos == 0): + raise BadRequestError("必须选择金额相抵的一张负数及一张正数更正单") + target = facts.get(("GROUP", payload.target_group_key)) + if not target or str(target.data_json["customer_id"]) != str( + negative.payload_json["idcustomer"] + ): + raise BadRequestError("目标平账组必须属于负数更正单的原客户") + cash = _records(session, source, "receipts", payload.receipt_ids) + if any( + str(r.payload_json.get("idpartner")) != str(positive.payload_json["idcustomer"]) + or r.payload_json.get("isReceiveFlag") != 1 + or money(r.payload_json.get("amount", "0")) <= 0 + for r in cash.values() + ): + raise BadRequestError("实际回款须为新客户名下的正数收款单") + pair_key = digest([source, negative.source_id, positive.source_id]) + proposed = [] + for head, peer in ((negative, positive), (positive, negative)): + proposed.append( + ( + f"SHIPMENT:{head.source_id}", + { + "entity": "SHIPMENT", + "pair_key": pair_key, + "exclude_performance": True, + "paired_source_id": peer.source_id, + "source_hash": head.row_hash, + "paired_hash": peer.row_hash, + "details_hash": _details_hash(session, source, head), + }, + ) + ) + for row in cash.values(): + proposed.append( + ( + f"RECEIPT:{row.source_id}", + { + "entity": "RECEIPT", + "pair_key": pair_key, + "exclude_performance": False, + "redirect_business_key": payload.target_group_key, + "source_hash": row.row_hash, + }, + ) + ) + result = [] + for key, data in proposed: + entity, source_id = key.split(":", 1) + current = correction_fact(facts, entity, source_id) + if current and current.data_json.get("pair_key") != pair_key: + raise BadRequestError("该源单据已有其他更正配对,不能重复处理") + # 已导入单据只能重放同一事实;修改需来源差异及当月调整处理。 + linked = session.scalar( + select(TPlusSourceLink).where( + TPlusSourceLink.source_database == source, + TPlusSourceLink.entity_type + == ("shipments" if entity == "SHIPMENT" else "receipts"), + TPlusSourceLink.source_id == source_id, + ) + ) + same = bool(current and all(current.data_json.get(k) == v for k, v in data.items())) + if linked and not same: + raise BadRequestError( + "该单据已正式导入,请核对来源差异;已生成工资的差额使用当月调整单" + ) + if same: + result.append(current) + continue + fact = TPlusFact( + source_database=source, + kind="CORRECTION", + fact_key=key, + version=(current.version if current else 0) + 1, + data_json=data, + evidence=payload.evidence, + requested_by=actor_id, + ) + session.add(fact) + result.append(fact) + session.flush() + session.commit() + return {"pair_key": pair_key, "facts": [fact_out(f) for f in result]} + except TPlusBusyError: + session.rollback() + raise BadRequestError("资料或任务正在更新,请稍后重试") from None + except WorkbenchError: + session.rollback() + raise BadRequestError("请先完整采集更正发货、发货明细及收款数据") from None + except Exception: + session.rollback() + raise diff --git a/backend/app/services/tplus/fifo.py b/backend/app/services/tplus/fifo.py index 20ce13d..ce2ca1b 100644 --- a/backend/app/services/tplus/fifo.py +++ b/backend/app/services/tplus/fifo.py @@ -5,6 +5,7 @@ from decimal import ROUND_HALF_UP, Decimal from fractions import Fraction from app.services import formula +from app.services.personnel_roles import role_on from app.services.rate_rules import decide CENT = Decimal("0.01") @@ -55,6 +56,13 @@ def _beneficiary(shipment, receipt, facts, as_of): def _calculate(shipment, receipt, matched, previously_matched, facts, params): reasons = list(shipment.get("pending", [])) + list(receipt.get("pending", [])) + if any( + handover.get("business_key") == shipment.get("business_key") + and handover.get("mode") == "ACTIVE" + and not handover.get("shipping_ownership_confirmed") + for handover in facts.get("handovers", []) + ): + reasons.append("HANDOVER_SHIPMENTS_PENDING") effective = max(_day(shipment["date"]), _day(receipt["date"])) is_prepayment = _day(receipt["date"]) < _day(shipment["date"]) if is_prepayment and not params.get("prepayment_parameter_basis"): @@ -64,6 +72,8 @@ def _calculate(shipment, receipt, matched, previously_matched, facts, params): owner = _beneficiary(shipment, receipt, facts.get("handovers", []), facts["as_of"]) person = facts.get("personnel", {}).get(owner) original_person = facts.get("personnel", {}).get(str(shipment.get("personnel_id"))) + role = role_on(person, effective) + original_role = role_on(original_person, _day(shipment["date"])) if not person: reasons.append("PERSONNEL_PENDING") elif person.get("status") == 0: @@ -76,6 +86,8 @@ def _calculate(shipment, receipt, matched, previously_matched, facts, params): ) if not known_later_departure: reasons.append("HANDOVER_PENDING") + if person and (role is None or original_role is None): + reasons.append("PERSONNEL_ROLE_PENDING") cost = shipment.get("cost_total") if shipment.get("cost_month_basis") == "RECEIPT": cost = shipment.get("cost_by_month", {}).get(str(receipt["date"])[:7]) @@ -110,10 +122,10 @@ def _calculate(shipment, receipt, matched, previously_matched, facts, params): matched, formula.profit_rate(matched - allocated_cost, matched), threshold ) days = formula.payment_days(_day(shipment["date"]), _day(receipt["date"])) - manager_own = bool(original_person and original_person["role_code"] == "sales_manager") + manager_own = original_role == "sales_manager" rate, basis, rule = decide( rate_params, category=category, customer_type=customer_type, - role_code="sales_manager" if manager_own else person["role_code"], + role_code="sales_manager" if manager_own else role, days=days, prepayment=is_prepayment, ) calculation_base = matched if basis == "sales" else converted @@ -121,6 +133,8 @@ def _calculate(shipment, receipt, matched, previously_matched, facts, params): "status": "READY", "reasons": [], "personnel_id": owner, + "role_code": role, + "original_role_code": original_role, "manager_id": shipment.get("manager_id"), "manager_pending": not bool(shipment.get("manager_id")), "effective_date": effective.isoformat(), @@ -142,7 +156,7 @@ def _calculate(shipment, receipt, matched, previously_matched, facts, params): "calculation_base": amount_text(calculation_base), "basis": basis, "rule": rule, - "excluded_manager_own": manager_own or person["role_code"] == "sales_manager", + "excluded_manager_own": manager_own or role == "sales_manager", } diff --git a/backend/app/services/tplus/guidance.py b/backend/app/services/tplus/guidance.py new file mode 100644 index 0000000..e2ae64f --- /dev/null +++ b/backend/app/services/tplus/guidance.py @@ -0,0 +1,87 @@ +"""解析已登记的月度指导成本,不按名称或单位猜测。""" + +from datetime import date +from decimal import Decimal, InvalidOperation + +from sqlalchemy import func, select + +from app.core.exceptions import BadRequestError +from app.models import PayrollInput, TPlusFact + + +def latest_guidance_facts(session, source): + """只读取指定账套每个指导成本事实键的最新版本。""" + ids = select(func.max(TPlusFact.id)).where( + TPlusFact.source_database == source, TPlusFact.kind == "GUIDANCE" + ).group_by(TPlusFact.fact_key) + return {(fact.kind, fact.fact_key): fact for fact in session.scalars(select(TPlusFact).where(TPlusFact.id.in_(ids)))} + + +def resolve_guidance(facts, product_id, line_id, month, unit): + """返回匹配状态和已封存事实;存在多条候选时不选择其中一条。""" + candidates = [ + fact.data_json for (kind, _), fact in facts.items() + if kind == "GUIDANCE" + and fact.data_json.get("product_id") == product_id + and fact.data_json.get("month") == month + and (fact.data_json.get("basis") == "UNIT" or fact.data_json.get("shipment_line_id") == line_id) + ] + if not candidates: + return "MISSING", None + if len(candidates) != 1: + return "AMBIGUOUS", None + guidance = candidates[0] + if guidance.get("unit") != unit: + return "UNIT_MISMATCH", None + if not guidance.get("is_finalized"): + return "UNFINALIZED", None + return "FINALIZED", guidance + + +def check_unit_guidance_conflicts(facts, product_id, month, unit, amount, frozen_shipments): + """新增产品月度单价前检查重叠事实和全部相关的历史封存明细。""" + if any( + kind == "GUIDANCE" and fact.data_json.get("product_id") == product_id + and fact.data_json.get("month") == month + for (kind, _), fact in facts.items() + ): + raise BadRequestError("指导成本存在重叠记录,请先核对") + + try: + expected = Decimal(str(amount)) + if not expected.is_finite() or expected < 0: + raise ValueError + for shipment in frozen_shipments: + old = Decimal(str(shipment.get("unit_cost"))) + if not old.is_finite() or old < 0 or old != expected or shipment.get("unit") != unit: + raise ValueError + except (InvalidOperation, TypeError, ValueError): + raise BadRequestError("已封存发货的成本或单位与指导价不一致,请先核对") from None + + +def frozen_guidance_shipments(session, source, product_id, month): + """按不可变快照中的来源产品读取历史,不能依赖当前正式明细。""" + query = ( + select(PayrollInput.data_json) + .where(PayrollInput.source_database == source) + .order_by(PayrollInput.id) + .execution_options(yield_per=100) + ) + for data in session.scalars(query): + shipments = data.get("shipments") if isinstance(data, dict) else None + if not isinstance(shipments, list): + raise BadRequestError("已封存发货数据异常,请先核对") + for shipment in shipments: + if not isinstance(shipment, dict) or not shipment.get("line_id"): + raise BadRequestError("已封存发货数据异常,请先核对") + try: + ship_date = date.fromisoformat(shipment["date"]) + except (KeyError, TypeError, ValueError): + raise BadRequestError("已封存发货日期异常,请先核对") from None + if ship_date.strftime("%Y-%m") != month: + continue + frozen_product = shipment.get("product_id") + if frozen_product is None or str(frozen_product).strip() in ("", "None"): + raise BadRequestError("已封存发货缺少来源产品身份,请先核对") + if str(frozen_product) == str(product_id): + yield shipment diff --git a/backend/app/services/tplus/master_import.py b/backend/app/services/tplus/master_import.py index c7cb5be..c607b9e 100644 --- a/backend/app/services/tplus/master_import.py +++ b/backend/app/services/tplus/master_import.py @@ -228,9 +228,12 @@ def _line(session, run, i, row, action=None, reason=None): source_id=row["source_id"], action=action or row["action"], reason=reason or row.get("reason"), - data_json={ - k: v for k, v in row.items() if k not in ("entity_type", "action", "reason") - }, + # 真实发货参照带 Decimal 数量、单价;JSON 留存使用十进制原文, + # 与上方预览快照一致,不能转 float 或让预览任务在落明细时失败。 + data_json=json.loads(json.dumps( + {k: v for k, v in row.items() if k not in ("entity_type", "action", "reason")}, + ensure_ascii=False, default=str, + )), ) ) diff --git a/backend/app/services/tplus/report_labels.py b/backend/app/services/tplus/report_labels.py index ccc641f..d33d3b2 100644 --- a/backend/app/services/tplus/report_labels.py +++ b/backend/app/services/tplus/report_labels.py @@ -12,6 +12,10 @@ ACTION_LABELS = { "EXCLUDED": "不纳入本次预演", } REASON_LABELS = { + "CORRECTION_PAIR_REQUIRED": "更正单尚未完成正负成对核对,请使用更正单处理入口", + "CORRECTION_RECONFIRM_REQUIRED": "更正单或实际回款来源已变化,请重新核对更正对", + "CORRECTION_EXCLUDED": "转客户正负更正单已成对排除,源单保留", + "REDIRECTED_RECEIPT": "实际回款已归原业务,在本客户不重复计提", "CUSTOMER_SCOPE_CONFLICT": "同一客户存在不一致的期初或历史范围,请先合并为客户级核对资料", "GUIDANCE_FINALIZATION_REQUIRED": "发货月份指导成本尚未录入完毕并封存", "CUSTOMER_SCOPE_ALREADY_SELECTED": "该客户已纳入本次平账,重复选择已跳过", @@ -19,6 +23,12 @@ REASON_LABELS = { "INDEPENDENT_BUSINESS_REQUIRED": "请先按独立业务核实归属,跨业务冲款边界尚待答复", "BUSINESS_DATA_PENDING": "该业务仍有资料待补,请查看对应核算预演", "FORMAL_SOURCE_CHANGED": "源单据或人工依据与正式导入版本不同,请先核对差异", + "SOURCE_PRICE_REVIEW_REQUIRED": "已接入单价与来源未税单价不一致,请核对原单及正式明细", + "PENDING_SOURCE_CUSTOMER_CHANGED": "待核对单据的来源客户已变化,原关联已保留,请核对原单", + "PENDING_SOURCE_OUT_OF_RANGE": "待核对单据的来源日期已早于接入起点,原关联已保留,请核对原单", + "LEGACY_BUSINESS_APPLY_DISABLED": "旧规则预览不能执行,请使用自动业务接入重新生成计划", + "ORDER_SHIPMENT_MISMATCH": "发货关联的订单明细、订单号或产品不一致,请核对来源", + "SOURCE_DOCUMENT_MISSING": "来源单据在最新完整快照中消失,已有记录已保留,请核对", "SOURCE_ALREADY_IMPORTED": "该源单据已经导入其他业务,不能重复导入", "CUSTOMER_IMPORT_REQUIRED": "请先完成该客户的基础导入", "PRODUCT_IMPORT_REQUIRED": "请先完成相关产品的基础导入", @@ -30,7 +40,7 @@ REASON_LABELS = { "PRODUCT_CATEGORY_PENDING": "产品核算分类待补", "PRODUCT_UNIT_PENDING": "产品单位待核对", "NOT_CUSTOMER_ONLY": "往来单位不是仅客户性质", - "CUSTOMER_ATTRIBUTE_PENDING": "客户属性或负责人待补", + "CUSTOMER_ATTRIBUTE_PENDING": "客户核算分类待补", "TARGET_MISSING": "原导入目标已不存在", "TARGET_UNIQUE_CONFLICT": "与现有记录唯一键冲突", "DUPLICATE_SOURCE_IDENTITY": "源记录身份重复", @@ -50,6 +60,7 @@ REASON_LABELS = { "PREPAYMENT_PARAMETERS_PENDING": "预收业务采用发货日还是收款日参数待确认", "AUGUST_RULE_PENDING": "8月调整参数待确认", "PERSONNEL_PENDING": "归属人员待确认", + "PERSONNEL_ROLE_PENDING": "该业务发生月份的历史岗位待确认", "CUSTOMER_CATEGORY_PENDING": "客户分类待确认", "PARAMETERS_PENDING": "计提参数待补", "NEGATIVE_OR_ZERO_DOCUMENT": "负数或零金额单据需人工核对", diff --git a/backend/app/services/tplus/runtime_control.py b/backend/app/services/tplus/runtime_control.py new file mode 100644 index 0000000..5a1ce46 --- /dev/null +++ b/backend/app/services/tplus/runtime_control.py @@ -0,0 +1,46 @@ +"""页面期望状态与托管进程心跳,二者分别更新。""" + +from datetime import timedelta + +from sqlalchemy import inspect, select, update + +from app.core.exceptions import BadRequestError, ConflictError +from app.models import TPlusRuntimeControl +from app.services.authorization_service import require_permission +from app.services.tplus import task_service +from app.services.tplus.automation import local_now + + +def overview(session, *, now=None): + result = {"desired_state": "STOPPED", "actual_state": "OFFLINE", "worker_phase": "UNKNOWN", + "heartbeat_at": None, "last_error": None, "host": None, "platform": None, + "version": 0, "updated_by": None, "editable": False} + if not inspect(session.connection()).has_table(TPlusRuntimeControl.__tablename__): + return result + row = session.scalar(select(TPlusRuntimeControl).where(TPlusRuntimeControl.id == 1) + .execution_options(populate_existing=True)) + if row is None: + return result + result.update({key: getattr(row, key) for key in result if key != "editable"}) + result["editable"] = True + if row.heartbeat_at is None or (now or local_now()) - row.heartbeat_at > timedelta(seconds=90): + result["actual_state"] = "OFFLINE" + result["worker_phase"] = "UNKNOWN" + return result + + +def save(session, payload, actor): + require_permission(session, actor.user, "tplus:collect") + if not inspect(session.connection()).has_table(TPlusRuntimeControl.__tablename__): + raise BadRequestError("读取服务管理尚未升级,请联系维护人员") + with task_service._audit(session, actor, "修改读取服务状态", payload.model_dump()): + changed = session.execute(update(TPlusRuntimeControl).where( + TPlusRuntimeControl.id == 1, + TPlusRuntimeControl.version == payload.expected_version, + ).values(desired_state=payload.desired_state, version=payload.expected_version + 1, + updated_by=actor.user.id)) + if changed.rowcount != 1: + session.rollback() + raise ConflictError() + session.commit() + return overview(session) diff --git a/backend/app/services/tplus/sales_relations.py b/backend/app/services/tplus/sales_relations.py new file mode 100644 index 0000000..1cf3261 --- /dev/null +++ b/backend/app/services/tplus/sales_relations.py @@ -0,0 +1,226 @@ +"""来源订单与发货人工关系;仅追加结论,不修改来源或封存数据。""" + +from sqlalchemy import func, select +from sqlalchemy.exc import IntegrityError + +from app.core.exceptions import BadRequestError +from app.core.tplus_lock import TPlusBusyError, sync_lock +from app.models import ( + Business, + BusinessItem, + Shipment, + ShipmentItem, + TPlusBusinessDocument, + TPlusFact, +) +from app.services.audit_service import AuditRecord, audit +from app.services.tplus.workbench import digest + +KIND = "SALES_RELATION" + + +def item_revision(item): + return digest([item.id, item.business_id, item.product_id, item.product_name, item.spec_model, + item.unit, str(item.quantity), str(item.sale_price), str(item.sale_amount)]) + + +def document_revision(doc): + return [doc.id, doc.entity_type, doc.source_id, doc.record_id, doc.applied_record_id, + doc.fingerprint, doc.status, doc.business_id, doc.target_id] + + +def shipment_revision(doc, head, shipment, line): + return digest([document_revision(doc), document_revision(head), shipment.id, + shipment.business_id, shipment.ship_date.isoformat(), line.id, line.product_id, + str(line.quantity), str(line.sale_price), str(line.amount)]) + + +def order_revision(doc, head, item): + return digest([document_revision(doc), document_revision(head), item_revision(item)]) + + +def _head(session, doc, entity, target_id=None, *, lock=True): + parent = doc.data_json.get("source_parent_id") + query = select(TPlusBusinessDocument).where( + TPlusBusinessDocument.source_database == doc.source_database, + TPlusBusinessDocument.business_id == doc.business_id, + TPlusBusinessDocument.entity_type == entity, + TPlusBusinessDocument.status == "APPLIED", + TPlusBusinessDocument.source_id == str(parent), + ) + if target_id is not None: + query = query.where(TPlusBusinessDocument.target_id == target_id) + head = session.scalar(query.with_for_update() if lock else query) + if head is None or head.record_id != head.applied_record_id: + raise BadRequestError("来源单据已变化,请刷新后核对") + return head + + +def _applied_document(session, document_id, business_id, entity, source, *, lock=True): + doc = session.get(TPlusBusinessDocument, document_id, with_for_update=lock) + if (doc is None or doc.business_id != business_id or doc.source_database != source + or doc.entity_type != entity or doc.status != "APPLIED" + or doc.record_id != doc.applied_record_id): + raise BadRequestError("来源明细已变化,请刷新后核对") + return doc + + +def _latest_business_facts(session, source, business_id): + ids = select(func.max(TPlusFact.id)).where( + TPlusFact.source_database == source, TPlusFact.kind == KIND, + TPlusFact.fact_key.like(f"sales:{business_id}:%"), + ).group_by(TPlusFact.fact_key) + return list(session.scalars(select(TPlusFact).where(TPlusFact.id.in_(ids)))) + + +def _fact_is_current(session, business, item, fact): + """任何来源身份、版本或业务明细变化均使结论失效。""" + data = fact.data_json + try: + if (data["business_id"] != business.id or data["item_id"] != item.id + or data["item_revision"] != item_revision(item)): + return False + source = business.integration_json["source_database"] + doc = _applied_document(session, data["shipment_document_id"], business.id, "shipment_items", source, lock=False) + line = session.get(ShipmentItem, doc.target_id) + shipment = session.get(Shipment, line.shipment_id) if line else None + if (line is None or shipment is None or shipment.business_id != business.id + or line.product_id != item.product_id or line.quantity != item.quantity or line.amount != item.sale_amount): + return False + head = _head(session, doc, "shipments", shipment.id, lock=False) + if shipment_revision(doc, head, shipment, line) != data["shipment_revision"]: + return False + if data["disposition"] == "INDEPENDENT": + return data.get("order_document_id") is None and data.get("order_revision") is None + if data["disposition"] != "ORDER": + return False + order = _applied_document(session, data["order_document_id"], business.id, "business_items", source, lock=False) + order_item = session.get(BusinessItem, order.target_id) + if order_item is None or order_item.business_id != business.id or order_item.product_id != item.product_id: + return False + order_head = _head(session, order, "businesses", lock=False) + return order_revision(order, order_head, order_item) == data["order_revision"] + except (BadRequestError, KeyError, TypeError, ValueError): + return False + + +def resolve_items(session, business, items, order_ids): + """返回待核对状态和可计入销售额的明细,不猜测来源关系。""" + orders = set(order_ids) + if not orders: + return False, items + extra = [item for item in items if item.id not in orders] + if not extra: + return False, items + facts = {fact.fact_key: fact for fact in _latest_business_facts( + session, business.integration_json["source_database"], business.id)} + included = [item for item in items if item.id in orders] + used = set() + for item in extra: + fact = facts.get(f"sales:{business.id}:{item.id}") + if fact is None or not _fact_is_current(session, business, item, fact): + return True, [] + doc_id = fact.data_json["shipment_document_id"] + if doc_id in used: + return True, [] + used.add(doc_id) + if fact.data_json["disposition"] == "INDEPENDENT": + included.append(item) + return False, included + + +def confirm(session, business_id, payload, operator, meta=None): + """绑定明确选择及版本;调用者须具备业务更新和核算准备权限。""" + record = AuditRecord(module="业务管理", action="核对订单发货关系", summary=f"业务 #{business_id} 来源关系", + request_data=payload.model_dump(mode="json")) + try: + with audit(session, operator=operator, meta=meta, record=record), sync_lock(session): + business = session.get(Business, business_id, with_for_update=True) + if business is None or not business.integration_json: + raise BadRequestError("仅可核对来源业务") + source = business.integration_json["source_database"] + item = session.get(BusinessItem, payload.item_id, with_for_update=True) + if item is None or item.business_id != business_id or item_revision(item) != payload.item_revision: + raise BadRequestError("业务明细已变化,请刷新后核对") + existing_order = session.scalar(select(TPlusBusinessDocument.id).where( + TPlusBusinessDocument.business_id == business_id, + TPlusBusinessDocument.entity_type == "business_items", + TPlusBusinessDocument.target_id == item.id, + ).limit(1)) + if existing_order is not None: + raise BadRequestError("订单明细不能作为待核对发货明细") + doc = _applied_document(session, payload.shipment_document_id, business_id, "shipment_items", source) + line = session.get(ShipmentItem, doc.target_id, with_for_update=True) + shipment = session.get(Shipment, line.shipment_id, with_for_update=True) if line else None + if (line is None or shipment is None or shipment.business_id != business_id + or line.product_id != item.product_id or line.quantity != item.quantity or line.amount != item.sale_amount): + raise BadRequestError("发货明细与业务明细不一致,请重新核对") + head = _head(session, doc, "shipments", shipment.id) + if shipment_revision(doc, head, shipment, line) != payload.shipment_revision: + raise BadRequestError("来源发货已变化,请刷新后核对") + order = order_head = order_item = None + if payload.disposition == "ORDER": + order = _applied_document(session, payload.order_document_id, business_id, "business_items", source) + order_item = session.get(BusinessItem, order.target_id, with_for_update=True) + if order_item is None or order_item.business_id != business_id or order_item.product_id != item.product_id: + raise BadRequestError("订单明细不属于本业务或产品不一致") + order_head = _head(session, order, "businesses") + if order_revision(order, order_head, order_item) != payload.order_revision: + raise BadRequestError("来源订单已变化,请刷新后核对") + facts = _latest_business_facts(session, source, business_id) + key = f"sales:{business_id}:{item.id}" + current = next((fact for fact in facts if fact.fact_key == key), None) + if (current.version if current else 0) != payload.expected_version: + raise BadRequestError("核对结论已变化,请刷新后重试") + if any(fact.fact_key != key and fact.data_json.get("disposition") != "RELEASED" + and fact.data_json.get("shipment_document_id") == doc.id for fact in facts): + raise BadRequestError("该发货明细已被其他业务明细确认") + fact = TPlusFact(source_database=source, kind=KIND, fact_key=key, + version=payload.expected_version + 1, evidence=payload.evidence, requested_by=operator.id, + data_json={"business_id": business_id, "item_id": item.id, + "shipment_document_id": doc.id, "order_document_id": order.id if order else None, + "disposition": payload.disposition, "item_revision": payload.item_revision, + "shipment_revision": payload.shipment_revision, "order_revision": payload.order_revision}) + session.add(fact) + session.flush() + from app.services.tplus.business_sync import _refresh_business_totals + + _refresh_business_totals(session, business) + session.commit() + return {"item_id": item.id, "version": fact.version} + except (TPlusBusyError, IntegrityError): + session.rollback() + raise BadRequestError("来源数据或核对结论正在更新,请稍后重试") from None + + +def release(session, business_id, payload, operator, meta=None): + """追加撤销记录,释放发货占用,同时保留既往核对依据。""" + record = AuditRecord(module="业务管理", action="撤销订单发货关系", summary=f"业务 #{business_id} 来源关系", + request_data=payload.model_dump(mode="json")) + try: + with audit(session, operator=operator, meta=meta, record=record), sync_lock(session): + business = session.get(Business, business_id, with_for_update=True) + if business is None or not business.integration_json: + raise BadRequestError("仅可核对来源业务") + source = business.integration_json["source_database"] + key = f"sales:{business_id}:{payload.item_id}" + current = next((fact for fact in _latest_business_facts(session, source, business_id) + if fact.fact_key == key), None) + if current is None or current.version != payload.expected_version: + raise BadRequestError("核对结论已变化,请刷新后重试") + if current.data_json.get("disposition") == "RELEASED": + raise BadRequestError("核对结论已撤销") + fact = TPlusFact(source_database=source, kind=KIND, fact_key=key, + version=current.version + 1, evidence=payload.evidence, requested_by=operator.id, + data_json={"business_id": business_id, "item_id": payload.item_id, + "disposition": "RELEASED"}) + session.add(fact) + session.flush() + from app.services.tplus.business_sync import _refresh_business_totals + + _refresh_business_totals(session, business) + session.commit() + return {"item_id": payload.item_id, "version": fact.version} + except (TPlusBusyError, IntegrityError): + session.rollback() + raise BadRequestError("来源数据或核对结论正在更新,请稍后重试") from None diff --git a/backend/app/services/tplus/snapshots.py b/backend/app/services/tplus/snapshots.py index 8ea132b..92f3a4c 100644 --- a/backend/app/services/tplus/snapshots.py +++ b/backend/app/services/tplus/snapshots.py @@ -6,8 +6,8 @@ from app.models import TPlusSourceHead, TPlusSyncBatch, TPlusSyncRecord from app.services.tplus.workbench import WorkbenchError -def source_statement(session, source, entity): - """只发布最后一次完整成功读取;中断覆盖的指针不得部分发布。""" +def published_batch(session, source, entity): + """返回实体最后发布的完整批次;更新失败时不使用不完整的指针。""" batches = session.scalars( select(TPlusSyncBatch) .where( @@ -26,6 +26,19 @@ def source_statement(session, source, entity): break if batch_no is None: raise WorkbenchError("SOURCE_REFRESH_REQUIRED") + return batch_no + + +def source_manifest(session, source): + """核算与接入共同使用的完整来源批次清单,包含空实体。""" + entities = ("personnel", "customers", "products", "businesses", "business_items", + "shipments", "shipment_items", "receipts") + return {entity: published_batch(session, source, entity) for entity in entities} + + +def source_statement(session, source, entity): + """只发布最后一次完整成功读取;中断覆盖的指针不得部分发布。""" + batch_no = published_batch(session, source, entity) return ( select(TPlusSyncRecord) .join(TPlusSourceHead, TPlusSourceHead.record_id == TPlusSyncRecord.id) diff --git a/backend/app/services/tplus/source_heads.py b/backend/app/services/tplus/source_heads.py index 3745982..9abb904 100644 --- a/backend/app/services/tplus/source_heads.py +++ b/backend/app/services/tplus/source_heads.py @@ -57,3 +57,9 @@ def observe_rows(session, batch, mapping, rows): session.add(head) current[row["source_id"]] = head head.record_id, head.batch_no = record_id, batch.batch_no + raw = row["payload_json"] + head.source_parent_id = str(raw["source_parent_id"]) if raw.get("source_parent_id") is not None else None + customer = raw.get("idcustomer", raw.get("idpartner")) + head.source_customer_id = str(customer) if customer is not None else None + day = raw.get("voucherdate", raw.get("voucherDate")) + head.source_date = str(day)[:10] if day is not None else None diff --git a/backend/app/services/tplus/supervisor.py b/backend/app/services/tplus/supervisor.py new file mode 100644 index 0000000..92b64d9 --- /dev/null +++ b/backend/app/services/tplus/supervisor.py @@ -0,0 +1,168 @@ +"""跨平台托管循环;单次任务在独立线程执行,停止时等待其完成。""" + +import json +import logging +import platform +import signal +import socket +import threading +import time +from pathlib import Path +from urllib.error import URLError +from urllib.parse import urlsplit +from urllib.request import urlopen +from uuid import uuid4 + +from sqlalchemy import create_engine, select, text, update +from sqlalchemy.exc import SQLAlchemyError +from sqlalchemy.pool import NullPool + +from app.core.tplus_db import resolve_tplus_settings +from app.models import TPlusRuntimeControl +from app.services.tplus.automation import local_now, tick +from app.services.tplus.worker import run_once + +logger = logging.getLogger(__name__) +LOCK_NAME = "ys:tplus:supervisor" + + +def health_error(settings): + """只访问本机健康端点,禁止通过配置探测外部网络。""" + try: + url = settings.t_plus_supervisor_health_url + parsed = urlsplit(url) + if parsed.scheme != "http" or parsed.hostname not in ("localhost", "127.0.0.1", "::1"): + return "后端健康检查地址必须是本机 HTTP 地址" + with urlopen(url, timeout=3) as response: + body = json.load(response) + data = body.get("data") if isinstance(body, dict) else None + if not isinstance(data, dict) or not all(data.get(key) is True for key in ("app", "db", "redis")): + return "系统或依赖尚未就绪" + source = resolve_tplus_settings(settings) + try: + import pyodbc + except ImportError: + return "未安装 T+ 数据库连接组件" + if source.t_plus_odbc_driver not in pyodbc.drivers(): + return "T+ 数据库驱动不可用,请联系维护人员" + if settings.t_plus_stop_file and Path(settings.t_plus_stop_file).exists(): + return "旧版停止标记存在,请联系维护人员处理" + except (OSError, URLError, ValueError, RuntimeError): + return "系统健康检查或 T+ 连接配置未就绪" + engine = None + try: + engine = create_engine( + source.t_plus_database_url, poolclass=NullPool, hide_parameters=True, + connect_args={"timeout": source.t_plus_connect_timeout}, + ) + with engine.connect() as connection: + connection.connection.driver_connection.timeout = source.t_plus_query_timeout + connection.exec_driver_sql("SELECT 1") + except (SQLAlchemyError, OSError, ValueError, RuntimeError): + return "T+ 数据库连接不可用,请检查连接配置" + finally: + if engine is not None: + engine.dispose() + return None + + +def may_claim(session): + """与页面更新同一行锁,确认停止后不再领取新任务。""" + row = session.scalar(select(TPlusRuntimeControl).where(TPlusRuntimeControl.id == 1) + .with_for_update().execution_options(populate_existing=True)) + return row is not None and row.desired_state == "RUNNING" + + +def run(factory, settings, *, interval=5, stop_event=None): + """持有数据库命名锁期间,轮询页面设置并完成已领取的工作。""" + stopped = stop_event or threading.Event() + owner = str(uuid4()) + worker = None + failures = [] + retry_after = 0.0 + last_maintenance = 0.0 + handlers = {} + if threading.current_thread() is threading.main_thread(): + for sig in (signal.SIGINT, signal.SIGTERM): + handlers[sig] = signal.signal(sig, lambda *_: stopped.set()) + connection = None + acquired = False + + def execute(): + try: + with factory() as session: + run_once(session, worker_owner=owner, may_claim=may_claim) + except Exception as exc: + logger.error("T+ 读取任务执行异常,异常类型=%s", type(exc).__name__) + failures.append("读取服务执行失败,请检查服务日志") + + try: + connection = factory.kw["bind"].connect() + if connection.dialect.name != "mysql": + raise RuntimeError("T+ 托管服务需要 MySQL 命名锁") + acquired = connection.execute(text("SELECT GET_LOCK(:name, 0)"), {"name": LOCK_NAME}).scalar() == 1 + if not acquired: + logger.error("T+ 托管服务已在运行") + return 1 + while not stopped.is_set(): + try: + if connection.execute(text("SELECT IS_USED_LOCK(:name) = CONNECTION_ID()"), + {"name": LOCK_NAME}).scalar() != 1: + raise RuntimeError("托管服务锁已失效") + with factory() as session: + row = session.scalar(select(TPlusRuntimeControl).where(TPlusRuntimeControl.id == 1) + .execution_options(populate_existing=True)) + if row is None: + raise RuntimeError("读取服务控制尚未初始化") + desired = row.desired_state + error = health_error(settings) if desired == "RUNNING" else None + active = worker is not None and worker.is_alive() + if failures: + error = failures.pop(0) + retry_after = time.monotonic() + 30 + elif time.monotonic() < retry_after: + error = "读取服务异常,等待重试" + if desired == "RUNNING" and not error and not active: + if time.monotonic() - last_maintenance >= 60: + tick(session, settings=settings) + last_maintenance = time.monotonic() + worker = threading.Thread(target=execute, name="tplus-worker") + worker.start() + active = True + state = ("STOPPING" if active else "STOPPED") if desired == "STOPPED" else ( + "DEGRADED" if error else "RUNNING") + session.execute(update(TPlusRuntimeControl).where(TPlusRuntimeControl.id == 1).values( + actual_state=state, worker_phase="RUNNING" if active else "IDLE" if state == "RUNNING" else "STOPPED", + heartbeat_at=local_now(), last_error=error, host=socket.gethostname()[:128], + platform=platform.system()[:32], owner=owner)) + session.commit() + except Exception as exc: + logger.error("T+ 托管服务检查失败,异常类型=%s", type(exc).__name__) + # 锁或数据库状态不明时退出,绝不启动新的读取任务。 + break + stopped.wait(interval) + finally: + if worker is not None: + worker.join() + if acquired: + try: + with factory() as session: + session.execute(update(TPlusRuntimeControl).where( + TPlusRuntimeControl.id == 1, TPlusRuntimeControl.owner == owner + ).values(actual_state="STOPPED", worker_phase="STOPPED", heartbeat_at=local_now())) + session.commit() + except Exception: + logger.warning("无法记录 T+ 托管服务停止状态") + try: + released = connection.execute( + text("SELECT RELEASE_LOCK(:name)"), {"name": LOCK_NAME} + ).scalar() + if released != 1: + connection.invalidate() + except Exception: + connection.invalidate() + if connection is not None: + connection.close() + for sig, handler in handlers.items(): + signal.signal(sig, handler) + return 0 if stopped.is_set() else 1 diff --git a/backend/app/services/tplus/workbench.py b/backend/app/services/tplus/workbench.py index 3a73f4a..6d1d669 100644 --- a/backend/app/services/tplus/workbench.py +++ b/backend/app/services/tplus/workbench.py @@ -9,15 +9,15 @@ from sqlalchemy import func, select from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import defer -from app.core.exceptions import BadRequestError, ForbiddenError, NotFoundError +from app.core.exceptions import BadRequestError, ConflictError, ForbiddenError, NotFoundError from app.core.response import PageResult from app.core.tplus_lock import TPlusBusyError, sync_lock -from app.models import TPlusFact, TPlusSyncRecord, TPlusWorkLine, TPlusWorkRun +from app.models import TPlusFact, TPlusSyncBatch, TPlusSyncRecord, TPlusWorkLine, TPlusWorkRun from app.schemas.tplus_workbench import WorkCreateIn from app.services.tplus.report_labels import ACTION_LABELS, reason_label, spreadsheet_value from app.services.tplus.task_service import _source_database, history_source_database, new_batch_no -RULE_VERSION = "2026-10-30.2" +RULE_VERSION = "2026-11-03.2" logger = logging.getLogger(__name__) @@ -58,6 +58,8 @@ def latest_facts(session, source): def save_fact(session, payload, actor_id): """与导入共用锁,乐观版本不匹配时拒绝覆盖。""" + if payload.kind == "CORRECTION": + raise BadRequestError("更正单请使用成对处理入口,不能单独排除或修改一张源单") source = _work_source_database(session) try: with sync_lock(session): @@ -134,8 +136,7 @@ def queue_work(session, payload: WorkCreateIn, actor_id): values = payload.model_dump(mode="json", exclude={"request_key"}) source = _work_source_database(session) key = str(payload.request_key) - found = session.scalar(select(TPlusWorkRun).where(TPlusWorkRun.request_key == key)) - if found: + def existing_run(found): if ( found.input_json != values or found.source_database != source @@ -143,14 +144,15 @@ def queue_work(session, payload: WorkCreateIn, actor_id): ): raise BadRequestError("请求键已用于不同任务") return work_out(found) + found = session.scalar(select(TPlusWorkRun).where(TPlusWorkRun.request_key == key)) + if found: + return existing_run(found) if payload.kind == "MASTER_APPLY": preview = get_run(session, payload.preview_no, source) if preview.kind != "MASTER_PREVIEW" or preview.status not in ("SUCCEEDED", "PARTIAL"): raise BadRequestError("必须选择已完成的基础导入预览") if payload.kind == "BUSINESS_APPLY": - preview = get_run(session, payload.preview_no, source) - if preview.kind != "BUSINESS_PREVIEW" or preview.status not in ("SUCCEEDED", "PARTIAL"): - raise BadRequestError("必须选择已完成的正式业务导入预览") + raise BadRequestError("旧规则业务预览仅供核对,请使用自动业务接入重新生成计划") if payload.replay_no: previous = get_run(session, payload.replay_no, source) if previous.kind != "CALC_PREVIEW" or previous.status not in ("SUCCEEDED", "PARTIAL"): @@ -171,7 +173,10 @@ def queue_work(session, payload: WorkCreateIn, actor_id): session.commit() except IntegrityError: session.rollback() - return queue_work(session, payload, actor_id) + found = session.scalar(select(TPlusWorkRun).where(TPlusWorkRun.request_key == key)) + if found is None: + raise ConflictError() from None + return existing_run(found) return work_out(run) @@ -225,19 +230,63 @@ def list_work(session, query): ) +def _display_data(session, run, rows): + """按原记录版本补齐基础档案展示字段;不改历史结果或使用最新源数据。""" + if run.kind not in ("MASTER_PREVIEW", "MASTER_APPLY"): + return [row.data_json for row in rows] + record_ids = {row.data_json.get("record_id") for row in rows} - {None} + records = { + record.id: record + for record in session.scalars( + select(TPlusSyncRecord) + .join(TPlusSyncBatch, TPlusSyncBatch.batch_no == TPlusSyncRecord.batch_no) + .where( + TPlusSyncRecord.id.in_(record_ids), + TPlusSyncBatch.source_database == run.source_database, + ) + ) + } if record_ids else {} + roster = {} + if any(row.reason == "ROSTER_SOURCE_MISSING" for row in rows): + roster = { + fact["key"]: fact["data"] + for fact in (run.snapshot_json or {}).get("facts", []) + if fact.get("kind") == "PERSONNEL" + } + result = [] + for row in rows: + data = dict(row.data_json) + record = records.get(data.get("record_id")) + if record and record.entity_type == row.entity_type and record.source_id == row.source_id: + source = record.payload_json + fields = {"source_name": source.get("name"), "source_no": record.source_no} + if row.entity_type == "products": + fields.update(source_specification=source.get("specification"), + source_unit=source.get("unit_name")) + for key, value in fields.items(): + if data.get(key) is None and value is not None: + data[key] = value + elif row.reason == "ROSTER_SOURCE_MISSING": + person = roster.get(row.source_id, {}) + data.setdefault("source_name", person.get("name")) + data.setdefault("source_no", row.source_id) + result.append(data) + return result + + def list_lines(session, number, query): - get_run(session, number) + run = get_run(session, number) conditions = [TPlusWorkLine.run_no == number] if query.action: conditions.append(TPlusWorkLine.action == query.action) total = session.scalar(select(func.count()).select_from(TPlusWorkLine).where(*conditions)) - rows = session.scalars( + rows = list(session.scalars( select(TPlusWorkLine) .where(*conditions) .order_by(TPlusWorkLine.line_no) .offset(query.offset) .limit(query.page_size) - ) + )) return PageResult( records=[ { @@ -246,9 +295,9 @@ def list_lines(session, number, query): "source_id": r.source_id, "action": r.action, "reason": r.reason, - "data": r.data_json, + "data": data, } - for r in rows + for r, data in zip(rows, _display_data(session, run, rows), strict=True) ], total=total or 0, page_num=query.page_num, @@ -319,7 +368,7 @@ def report_csv(session, run_no): import csv import io - get_run(session, run_no) + run = get_run(session, run_no) def rows(): buffer = io.StringIO() @@ -358,46 +407,47 @@ def report_csv(session, run_no): .where(TPlusWorkLine.run_no == run_no) .order_by(TPlusWorkLine.line_no) ) - for row in session.scalars(statement).yield_per(200): - data = row.data_json - values = data.get("values") or {} - fields = [ - run_no, - row.line_no, - row.entity_type, - row.source_id, - ACTION_LABELS.get(row.action, row.action), - data.get("source_no", ""), - values.get("personnel_name") - or values.get("product_name") - or values.get("company_name") - or data.get("source_name") - or "", - *[ - data.get(k, "") - for k in ( - "receipt_no", - "accounting_voucher_code", - "shipment_no", - "amount", - "shipment_remaining_after", - "days", - "allocated_cost", - "converted_profit", - "profit_threshold", - "rate_percent", - "performance", - "personnel_id", - ) - ], - reason_label(row.reason) - or ("经理本人业务,已排除绩效汇总" if data.get("excluded_manager_own") else ""), - "仅供核对,未写入正式工资", - ] - writer.writerow([spreadsheet_value(v) for v in fields]) - yield buffer.getvalue() - buffer.seek(0) - buffer.truncate(0) + for page in session.scalars(statement).partitions(200): + for row, data in zip(page, _display_data(session, run, page), strict=True): + values = data.get("values") or {} + fields = [ + run_no, + row.line_no, + row.entity_type, + row.source_id, + ACTION_LABELS.get(row.action, row.action), + data.get("source_no", ""), + values.get("personnel_name") + or values.get("product_name") + or values.get("company_name") + or data.get("source_name") + or "", + *[ + data.get(k, "") + for k in ( + "receipt_no", + "accounting_voucher_code", + "shipment_no", + "amount", + "shipment_remaining_after", + "days", + "allocated_cost", + "converted_profit", + "profit_threshold", + "rate_percent", + "performance", + "personnel_id", + ) + ], + reason_label(row.reason) + or ("已导入且数据无变化" if run.kind in ("MASTER_PREVIEW", "MASTER_APPLY") and row.action == "SKIP" + else "经理本人业务,已排除绩效汇总" if data.get("excluded_manager_own") else ""), + "仅供核对,未写入正式工资", + ] + writer.writerow([spreadsheet_value(v) for v in fields]) + yield buffer.getvalue() + buffer.seek(0) + buffer.truncate(0) return rows() @@ -426,8 +476,16 @@ def execute_next(session): from app.services.authorization_service import require_permission require_permission(session, session.get(User, run.requested_by), - "tplus:import" if run.kind.endswith("_APPLY") else "tplus:preview") - if run.kind.startswith("BUSINESS_"): + "tplus:import" if run.kind.endswith("_APPLY") or run.kind == "BUSINESS_SYNC" else "tplus:preview") + if run.kind == "BUSINESS_SYNC": + from app.services.tplus.business_sync import execute_sync + from app.services.tplus.business_sync_control import clear_execution_failure + + execute_sync(session, run) + clear_execution_failure(session, run.source_database, run.requested_by) + elif run.kind == "BUSINESS_APPLY": + raise WorkbenchError("LEGACY_BUSINESS_APPLY_DISABLED") + elif run.kind.startswith("BUSINESS_"): from app.services.tplus.business_import import execute_business execute_business(session, run) @@ -447,6 +505,10 @@ def execute_next(session): run.status, run.finished_at = "FAILED", datetime.now() # 不让驱动异常、SQL 或原始数据进入响应。 run.error_code = "TASK_AUTHORIZATION_REVOKED" if isinstance(exc, ForbiddenError) else str(exc) if isinstance(exc, WorkbenchError) else "EXECUTION_ERROR" + if run.kind == "BUSINESS_SYNC" and isinstance(exc, ForbiddenError): + from app.services.tplus.business_sync_control import record_authorization_failure + + record_authorization_failure(session, run.source_database, run.requested_by) logger.error( "工作区任务失败 | run_no=%s | exception_type=%s", run.run_no, type(exc).__name__ ) diff --git a/backend/app/services/tplus/worker.py b/backend/app/services/tplus/worker.py index b1a8b6d..3a0c2f7 100644 --- a/backend/app/services/tplus/worker.py +++ b/backend/app/services/tplus/worker.py @@ -1,5 +1,6 @@ """持久化任务执行者;获取连接级锁后才恢复或领取任务。""" +import logging from datetime import datetime from pathlib import Path @@ -12,8 +13,10 @@ from app.services.tplus.errors import classify_source_error from app.services.tplus.relationship_audit import TPlusRelationshipAudit from app.services.tplus.sync_service import TPlusSyncService +logger = logging.getLogger(__name__) -def run_once(session, source_factory=None, *, worker_owner=None): + +def run_once(session, source_factory=None, *, worker_owner=None, may_claim=None): """返回本轮处理的批次号;忙锁或队列为空时不假称已执行。""" try: with sync_lock(session), WorkerHeartbeat(session, owner=worker_owner): @@ -23,10 +26,13 @@ def run_once(session, source_factory=None, *, worker_owner=None): from app.services.tplus.workbench import execute_next if inspect(session.connection()).has_table(TPlusWorkRun.__tablename__): - number = execute_next(session) + number = execute_next(session, may_claim=may_claim) if may_claim else execute_next(session) if number: return number task_service.recover_running(session) + if may_claim is not None and not may_claim(session): + session.rollback() + return None batch = repository.next_queued(session) if batch is None: session.rollback() @@ -67,6 +73,22 @@ def _execute(session, batch, source_factory): session.commit() else: TPlusSyncService(source, session).execute_claimed(batch) + if batch.status == "SUCCEEDED": + from app.services.tplus.business_sync_control import ( + record_schedule_failure, + schedule, + ) + + try: + schedule(session) + except Exception: + session.rollback() + logger.error("T+ 采集已完成,但业务自动接入排队失败") + try: + record_schedule_failure(session, batch.source_database) + except Exception: + session.rollback() + logger.error("无法记录 T+ 业务自动接入排队异常") except BaseException as exc: session.rollback() task_service.fail_batch( diff --git a/backend/tests/integration/test_authorization.py b/backend/tests/integration/test_authorization.py index 20e45aa..e556950 100644 --- a/backend/tests/integration/test_authorization.py +++ b/backend/tests/integration/test_authorization.py @@ -98,6 +98,21 @@ def test_角色配置后分配给用户并在原会话即时生效(client, admin assert "base:personnel:regions" not in current(client, normal["headers"])["permissions"] +def test_来源销售关系核对需要业务更新和核算准备双权限(client, admin, normal): + business_only = role(client, admin, ["business:businesses:view", "business:businesses:update"]) + payroll_only = role(client, admin, ["payroll:view", "payroll:prepare"]) + payload = {"item_id": 1, "expected_version": 1, "evidence": "权限回归"} + url = "/api/v1/business/businesses/1/sales-relation/release" + for item in (business_only, payroll_only): + assert save(client, admin, normal["id"], role_codes=["user", item["role_code"]]).status_code == 200 + assert client.put(url, headers=normal["headers"], json=payload).status_code == 403 + assert save(client, admin, normal["id"], role_codes=["user", business_only["role_code"], payroll_only["role_code"]]).status_code == 200 + response = client.put(url, headers=normal["headers"], json=payload) + assert response.status_code == 400 + assert "来源业务" in response.json()["message"] or "核对结论" in response.json()["message"] + assert save(client, admin, normal["id"], role_codes=["user"]).status_code == 200 + + def test_旧个人覆盖写入拒绝且不影响既有角色(client, admin, normal): response = save( client, @@ -163,6 +178,57 @@ def test_角色停用撤销旧会话授权且恢复后重新生效(client, admin ) == bool(status) +def test_编辑角色拒绝停用权限且保留原授权(client, admin): + from sqlalchemy import select + + from app.core.db import get_session_factory + from app.models import Permission + + item = role(client, admin, ["base:personnel:view"]) + url = f"{BASE}/roles/{item['id']}/permissions" + before = client.get(url, headers=admin["headers"]).json()["data"] + with get_session_factory()() as session: + permission = session.scalar(select(Permission).where( + Permission.perm_code == "base:personnel:regions" + )) + old_status = permission.status + permission.status = 0 + session.commit() + try: + response = client.put(url, headers=admin["headers"], json={ + "permission_codes": ["base:personnel:view", "base:personnel:regions"], + "expected_revision": before["revision"], "reason": "验证停用权限", + }) + assert response.status_code == 400 + after = client.get(url, headers=admin["headers"]).json()["data"] + assert after["permission_codes"] == before["permission_codes"] + assert after["revision"] == before["revision"] + finally: + with get_session_factory()() as session: + permission = session.scalar(select(Permission).where( + Permission.perm_code == "base:personnel:regions" + )) + permission.status = old_status + session.commit() + + +def test_编辑角色拒绝缺失权限且保留原授权(client, admin, monkeypatch): + from app.services.permission_catalog import CATALOG + + item = role(client, admin, ["base:personnel:view"]) + url = f"{BASE}/roles/{item['id']}/permissions" + before = client.get(url, headers=admin["headers"]).json()["data"] + code = "test:missing:view" + monkeypatch.setitem(CATALOG, code, {"admin_only": False, "view_code": code}) + response = client.put(url, headers=admin["headers"], json={ + "permission_codes": [code], "expected_revision": before["revision"], "reason": "验证缺失权限", + }) + assert response.status_code == 400 + after = client.get(url, headers=admin["headers"]).json()["data"] + assert after["permission_codes"] == before["permission_codes"] + assert after["revision"] == before["revision"] + + def test_角色权限编辑自动影响全部绑定用户(client, admin, normal, make_user): other = make_user(["user"]) item = role(client, admin, ["base:personnel:view"]) diff --git a/backend/tests/integration/test_receipt_confirmation.py b/backend/tests/integration/test_receipt_confirmation.py new file mode 100644 index 0000000..8029331 --- /dev/null +++ b/backend/tests/integration/test_receipt_confirmation.py @@ -0,0 +1,140 @@ +"""隔离MySQL/API验证独立确认、默认门槛、凭证及内容变更,不写真实工资。""" + +import copy +from decimal import Decimal + +import pytest +from sqlalchemy import func, select + +from app.core.db import get_session_factory +from app.models import OperationLog, PayrollInput, PerformanceLedger, Receipt +from tests.integration.test_formal_payroll import ( + post, + unconfirmed_formal_chain as unconfirmed_formal_chain, +) + +pytestmark = pytest.mark.integration +ROOT = "/api/v1/business/receipts" + + +def confirm(client, headers, receipt): + return client.post(f"{ROOT}/{receipt['id']}/confirm", headers=headers, + json={"expected_revision": receipt["confirmation_revision"]}) + + +def calculate(client, headers, business): + return client.post(f"/api/v1/payroll/businesses/{business['id']}/calculate", headers=headers, + json={"month": "2026-03"}) + + +def switch_off(client, headers): + post(client, "/api/v1/system/params/changes", headers, { + "change_type": 1, "remark": "t_独立确认关闭场景", + "items": [{"param_code": "receipt.confirmation.enabled", "dimension_value": "", "value": "0"}], + }) + + +def test_默认开启_保存资料和凭证资格不能代替人工确认(client, admin, unconfirmed_formal_chain): + business, receipt, *_ = unconfirmed_formal_chain + headers = admin["headers"] + assert client.get(ROOT + "/confirmation-policy", headers=headers).json()["data"]["enabled"] is True + assert receipt["confirmation_status"] == 0 + response = calculate(client, headers, business) + assert response.status_code == 400 and "人工确认" in response.text + with get_session_factory()() as s: + assert s.get(Receipt, receipt["id"]).confirmation_status == 0 + assert s.scalar(select(func.count()).select_from(PerformanceLedger)) == 0 + + +def test_独立确认后核算_重复确认幂等及审计(client, admin, unconfirmed_formal_chain): + business, receipt, *_ = unconfirmed_formal_chain + headers = admin["headers"] + response = confirm(client, headers, receipt) + assert response.status_code == 200, response.text + confirmed = response.json()["data"] + assert confirmed["confirmation_status"] == 1 and confirmed["confirmed_by"] + again = confirm(client, headers, receipt).json()["data"] + assert again["confirmed_at"] == confirmed["confirmed_at"] + result = calculate(client, headers, business) + assert result.status_code == 200, result.text + assert result.json()["data"]["performance"] == "0.20" + with get_session_factory()() as s: + assert s.get(Receipt, receipt["id"]).settlement_status == 1 + assert s.scalar(select(func.count()).select_from(OperationLog).where(OperationLog.action == "人工确认")) >= 1 + + +def test_关闭仅跳过人工确认_不产生确认状态(client, admin, unconfirmed_formal_chain): + business, receipt, *_ = unconfirmed_formal_chain + switch_off(client, admin["headers"]) + result = calculate(client, admin["headers"], business) + assert result.status_code == 200, result.text + with get_session_factory()() as s: + assert s.get(Receipt, receipt["id"]).confirmation_status == 0 + + +@pytest.mark.parametrize("manually_confirmed", [False, True]) +def test_关闭开关或人工确认都不能豁免无效凭证(client, admin, unconfirmed_formal_chain, manually_confirmed): + business, receipt, *_ = unconfirmed_formal_chain + if manually_confirmed: + assert confirm(client, admin["headers"], receipt).status_code == 200 + else: + switch_off(client, admin["headers"]) + with get_session_factory()() as s: + record = s.scalar(select(PayrollInput).where(PayrollInput.business_id == business["id"])) + data = copy.deepcopy(record.data_json) + data["receipts"][0]["voucher_confirmed"] = False + record.data_json = data + s.commit() + result = calculate(client, admin["headers"], business) + assert result.status_code == 400 and "VOUCHER_STATE_PENDING" in result.text + + +def test_修改关键内容重置确认_旧页面不能误确认(client, admin, unconfirmed_formal_chain): + _, receipt, *_ = unconfirmed_formal_chain + headers = admin["headers"] + assert confirm(client, headers, receipt).status_code == 200 + changed = client.put(f"{ROOT}/{receipt['id']}", headers=headers, + json={"receipt_date": receipt["receipt_date"], "amount": "101"}) + assert changed.status_code == 200, changed.text + data = changed.json()["data"] + assert data["confirmation_status"] == 0 and data["confirmed_at"] is None + assert confirm(client, headers, receipt).status_code == 409 + assert confirm(client, headers, data).status_code == 200 + + +def test_只读拒绝确认及切换开关(client, admin, normal, unconfirmed_formal_chain): + _, receipt, *_ = unconfirmed_formal_chain + assert confirm(client, normal["headers"], receipt).status_code == 403 + response = client.post("/api/v1/system/params/changes", headers=normal["headers"], json={ + "change_type": 1, "items": [{"param_code": "receipt.confirmation.enabled", "value": "0"}], + }) + assert response.status_code == 403 + + +@pytest.mark.parametrize("unconfirmed_formal_chain,expected", [ + ({"unit_cost": "50"}, "0.00"), ({"unit_cost": "45"}, "-20.00"), +], indirect=["unconfirmed_formal_chain"]) +def test_正式核算零毛利特例但低阈值仍为负(client, admin, unconfirmed_formal_chain, expected): + business, receipt, *_ = unconfirmed_formal_chain + assert confirm(client, admin["headers"], receipt).status_code == 200 + result = calculate(client, admin["headers"], business) + assert result.status_code == 200, result.text + with get_session_factory()() as s: + ledger = s.scalar(select(PerformanceLedger).where(PerformanceLedger.business_id == business["id"])) + assert ledger.converted_profit == Decimal(expected) + assert ledger.performance_amount == Decimal(expected) * Decimal("0.01") + + +@pytest.mark.parametrize("role,profit,converted,performance", [ + ("salesperson", "0", "0", "0"), + ("salesperson", "10", "-20", "-0.20"), + ("info_officer", "0", "0", "0.10"), +]) +def test_试算零毛利与信息员基数保持独立(client, admin, role, profit, converted, performance): + data = post(client, "/api/v1/system/params/preview", admin["headers"], { + "business_role": role, "customer_type": "personal", "product_category": "municipal_water", + "ship_date": "2026-03-10", "receipt_date": "2026-03-10", "revenue": "100", + "profit": profit, "received_amount": "100", + }) + assert Decimal(data["converted_profit"]) == Decimal(converted) + assert Decimal(data["performance_amount"]) == Decimal(performance) diff --git a/backend/tests/integration/test_role_management.py b/backend/tests/integration/test_role_management.py index 82442d3..85821d7 100644 --- a/backend/tests/integration/test_role_management.py +++ b/backend/tests/integration/test_role_management.py @@ -115,6 +115,30 @@ class TestCrudFlow: class TestInitialPermissions: + def test_历史目录外权限不计入角色权限数(self, client, admin, make_role): + from app.core.db import get_session_factory + from app.models import Permission, RolePermission + + role = make_role() + with get_session_factory()() as session: + permission = Permission(perm_code=f"obsolete:{uuid.uuid4().hex}", perm_name="旧权限") + session.add(permission) + session.flush() + permission_id = permission.id + session.add(RolePermission(role_id=role["id"], permission_id=permission_id)) + session.commit() + try: + listing = client.get(_ROLES, headers=admin["headers"], params={ + "keyword": role["role_code"], + }).json()["data"]["list"][0] + detail = client.get(f"{_ROLES}/{role['id']}/permissions", headers=admin["headers"]) + assert detail.status_code == 200 + assert listing["permission_count"] == len(detail.json()["data"]["permission_codes"]) == 0 + finally: + with get_session_factory()() as session: + session.delete(session.get(Permission, permission_id)) + session.commit() + def test_角色和权限一起创建且绑定后原令牌即时继承(self, client, admin, normal): import json @@ -131,6 +155,11 @@ class TestInitialPermissions: assert response.status_code == 200, response.text role = response.json()["data"] assert role["permission_count"] == 2 + listed = client.get(_ROLES, headers=admin["headers"], params={"keyword": code}).json()["data"] + assert listed["list"][0]["permission_count"] == 2 + updated = client.put(f"{_ROLES}/{role['id']}", headers=admin["headers"], + json={"role_name": "区域维护", "sort_order": 0}) + assert updated.status_code == 200 and updated.json()["data"]["permission_count"] == 2 with get_session_factory()() as session: log = session.scalar(select(OperationLog).where( OperationLog.user_id == admin["id"], OperationLog.summary.contains(code), diff --git a/backend/tests/integration/test_source_price_authorization.py b/backend/tests/integration/test_source_price_authorization.py new file mode 100644 index 0000000..30f36e7 --- /dev/null +++ b/backend/tests/integration/test_source_price_authorization.py @@ -0,0 +1,23 @@ +"""来源单价人工确认的双权限校验。""" + +import pytest + +from tests.integration.test_authorization import role, save +from tests.integration.test_tplus_workbench import workspace as workspace + +pytestmark = pytest.mark.integration + + +def test_确认来源单价需要业务更新和核算准备双权限(client, admin, normal, workspace): + business_only = role(client, admin, ["business:businesses:view", "business:businesses:update"]) + payroll_only = role(client, admin, ["payroll:view", "payroll:prepare"]) + url = "/api/v1/business/businesses/1/source-documents/1/missing-price" + payload = {"revision": "0" * 64, "evidence": "权限回归"} + for item in (business_only, payroll_only): + assert save(client, admin, normal["id"], role_codes=["user", item["role_code"]]).status_code == 200 + assert client.put(url, headers=normal["headers"], json=payload).status_code == 403 + assert save(client, admin, normal["id"], role_codes=[ + "user", business_only["role_code"], payroll_only["role_code"]]).status_code == 200 + response = client.put(url, headers=normal["headers"], json=payload) + assert response.status_code == 400 + assert "来源业务" in response.json()["message"] diff --git a/backend/tests/integration/test_tplus_supervisor.py b/backend/tests/integration/test_tplus_supervisor.py new file mode 100644 index 0000000..1f42896 --- /dev/null +++ b/backend/tests/integration/test_tplus_supervisor.py @@ -0,0 +1,104 @@ +"""在隔离 MySQL 中验证托管状态及命名锁,不连接 T+ 源库。""" + +import threading +import time +from uuid import uuid4 + +from app.core.config import get_settings +from app.core.db import get_session_factory +from app.models import TPlusRuntimeControl +from app.services.tplus import supervisor + + +def test_托管服务在真实命名锁下启动并安全停止(monkeypatch): + factory = get_session_factory() + monkeypatch.setattr(supervisor, "LOCK_NAME", f"ys:test:supervisor:{uuid4().hex}") + monkeypatch.setattr(supervisor, "health_error", lambda _settings: None) + monkeypatch.setattr(supervisor, "tick", lambda *_args, **_kwargs: None) + entered, release, stop = threading.Event(), threading.Event(), threading.Event() + calls = [] + + def worker(_session, **_kwargs): + calls.append(1) + entered.set() + assert release.wait(15) + + monkeypatch.setattr(supervisor, "run_once", worker) + with factory() as session: + row = session.get(TPlusRuntimeControl, 1) + row.desired_state = "RUNNING" + session.commit() + + result = [] + thread = threading.Thread(target=lambda: result.append( + supervisor.run(factory, get_settings(), interval=0.05, stop_event=stop) + )) + thread.start() + try: + assert entered.wait(10), "托管服务未启动工作线程" + assert supervisor.run(factory, get_settings(), stop_event=threading.Event()) == 1 + with factory() as session: + row = session.get(TPlusRuntimeControl, 1) + row.desired_state = "STOPPED" + session.commit() + deadline = time.monotonic() + 10 + while time.monotonic() < deadline: + with factory() as session: + state = session.get(TPlusRuntimeControl, 1).actual_state + if state == "STOPPING": + break + time.sleep(0.05) + assert state == "STOPPING" + release.set() + deadline = time.monotonic() + 10 + while time.monotonic() < deadline: + with factory() as session: + state = session.get(TPlusRuntimeControl, 1).actual_state + if state == "STOPPED": + break + time.sleep(0.05) + assert state == "STOPPED" + assert len(calls) == 1 + finally: + release.set() + stop.set() + thread.join(15) + assert not thread.is_alive() + assert result == [0] + + +def test_来源异常时真实数据库记录降级状态且不领取任务(monkeypatch): + factory = get_session_factory() + monkeypatch.setattr(supervisor, "LOCK_NAME", f"ys:test:supervisor:{uuid4().hex}") + monkeypatch.setattr(supervisor, "health_error", lambda _settings: "T+ 数据库连接不可用") + claimed = threading.Event() + monkeypatch.setattr(supervisor, "run_once", lambda *_args, **_kwargs: claimed.set()) + with factory() as session: + session.get(TPlusRuntimeControl, 1).desired_state = "RUNNING" + session.commit() + + stop = threading.Event() + result = [] + thread = threading.Thread(target=lambda: result.append( + supervisor.run(factory, get_settings(), interval=0.05, stop_event=stop) + )) + thread.start() + try: + deadline = time.monotonic() + 10 + state = None + while time.monotonic() < deadline: + with factory() as session: + row = session.get(TPlusRuntimeControl, 1) + state = row.actual_state + error = row.last_error + if state == "DEGRADED": + break + time.sleep(0.05) + assert state == "DEGRADED" + assert error == "T+ 数据库连接不可用" + assert not claimed.is_set() + finally: + stop.set() + thread.join(15) + assert not thread.is_alive() + assert result == [0] diff --git a/backend/tests/unit/test_business_readiness_list.py b/backend/tests/unit/test_business_readiness_list.py new file mode 100644 index 0000000..f345eb3 --- /dev/null +++ b/backend/tests/unit/test_business_readiness_list.py @@ -0,0 +1,31 @@ +"""业务列表按照核算准备结果筛选时保持准确分页。""" + +from types import SimpleNamespace + +from app.schemas.business import BusinessOut, BusinessQuery +from app.services import business_service, business_source + + +def test_准备原因筛选跨批次分页并按实际状态计数(monkeypatch): + rows = [(SimpleNamespace(id=i, integration_json={"source_database": "test"}),) + for i in range(105, 0, -1)] + + def scan(_session, _query, before_id, limit=100): + return [row for row in rows if before_id is None or row[0].id < before_id][:limit] + + def summaries(_session, businesses, *, snapshot_cache=None): + return {row.id: {"status": "PENDING" if row.id % 2 else "READY", + "reasons": ([{"code": "GUIDANCE_PENDING", "message": "指导成本待补"}] + if row.id % 2 else [])} for row in businesses} + + monkeypatch.setattr(business_service.business_repository, "scan_businesses", scan) + monkeypatch.setattr(business_source, "readiness_summaries", summaries) + monkeypatch.setattr(business_service, "_to_out", lambda row: BusinessOut( + id=row[0].id, business_no=str(row[0].id), customer_id=1)) + + result = business_service.list_businesses(None, BusinessQuery( + pageNum=3, pageSize=10, readiness_reason="GUIDANCE_PENDING", pending_only=True)) + + assert result.total == 53 + assert [row.id for row in result.records] == list(range(65, 45, -2)) + assert all(row.readiness_status == "PENDING" for row in result.records) diff --git a/backend/tests/unit/test_formal_payroll.py b/backend/tests/unit/test_formal_payroll.py index 75a9167..059259f 100644 --- a/backend/tests/unit/test_formal_payroll.py +++ b/backend/tests/unit/test_formal_payroll.py @@ -141,16 +141,37 @@ def business_work(s, user, kind, previous=None): return run -def imported(case): +def confirm_test_receipts(s, user): + """金额回归的明确前置:经独立确认服务确认测试回款,不关闭默认门槛。""" + from app.schemas.receipt import ReceiptConfirmIn + from app.services import receipt_confirmation, receipt_service + + for row in list(s.scalars(select(Receipt))): + receipt_service.confirm_receipt(s, row.id, ReceiptConfirmIn( + expected_revision=receipt_confirmation.revision(row), + ), operator=user) + + +def imported(case, *, confirmed=True): s, user, _manager = case pre = business_work(s, user, "BUSINESS_PREVIEW") - assert pre.status == "SUCCEEDED", pre.summary_json + assert pre.status == "SUCCEEDED", pre.snapshot_json["plan"] assert pre.summary_json["counts"] == {"CREATE": 1} run = business_work(s, user, "BUSINESS_APPLY", pre) assert run.status == "SUCCEEDED", run.summary_json + if confirmed: + confirm_test_receipts(s, user) return s.scalar(select(Business)) +def test_正式导入不自动确认_保存资料也不能代替确认(payroll_case): + s, user, _ = payroll_case + business = imported(payroll_case, confirmed=False) + assert s.scalar(select(Receipt)).confirmation_status == 0 + with pytest.raises(BadRequestError, match="人工确认"): + payroll_calculation.calculate(s, business.id, "2026-03", operator=user) + + def test_工资角色可写且未授权对象不能冒用身份(payroll_case): _, user, _ = payroll_case assert can_write_payroll(user) @@ -248,6 +269,7 @@ def replace_input(s, record, values): ) s.add(newer) s.commit() + confirm_test_receipts(s, s.get(User, newer.requested_by)) return newer diff --git a/backend/tests/unit/test_historical_roles.py b/backend/tests/unit/test_historical_roles.py new file mode 100644 index 0000000..7f6c123 --- /dev/null +++ b/backend/tests/unit/test_historical_roles.py @@ -0,0 +1,137 @@ +"""历史任职不能被当前岗位覆盖,预演、台账与工资分项必须使用同一依据。""" + +from datetime import date +from decimal import Decimal + +import pytest +from pydantic import ValidationError +from sqlalchemy import select + +from app.models import Customer, PerformanceLedger, Product, TPlusFact +from app.schemas.tplus_workbench import PersonFact +from app.services import payroll_calculation, settlement_service +from tests.unit.test_formal_payroll import imported +from tests.unit.test_formal_payroll import payroll_case as payroll_case +from tests.unit.test_tplus_fifo import inputs, run +from tests.unit.test_tplus_workbench import apply, fact, preview, source +from tests.unit.test_tplus_workbench import work_session as work_session + + +def periods(): + return [{"start_month": "2026-03", "end_month": "2026-07", "role_code": "sales_manager"}] + + +def test_按月份保存已确认岗位而不补造转岗日(): + person = PersonFact( + code="010110", + name="历史岗位样本", + role_code="salesperson", + status=1, + role_history=periods(), + ) + assert person.model_dump()["role_history"] == periods() + + +@pytest.mark.parametrize( + "history", + [ + [{"start_month": "2026-13", "end_month": "2026-07", "role_code": "sales_manager"}], + [{"start_month": "2026-08", "end_month": "2026-07", "role_code": "sales_manager"}], + [ + *periods(), + {"start_month": "2026-07", "end_month": "2026-09", "role_code": "salesperson"}, + ], + ], +) +def test_无效或重叠历史岗位拒绝入库(history): + with pytest.raises(ValidationError): + PersonFact( + code="010110", + name="历史岗位样本", + role_code="salesperson", + status=1, + role_history=history, + ) + + +def test_七月经理本人业务在八月转岗后仍排除(): + group, ship, cash, facts, params = inputs() + ship["date"], cash["date"] = "2026-07-16", "2026-08-16" + facts["personnel"]["001"].update(role_history=periods()) + params["2026-08-16"] = params.pop("2026-03-10") + params["august_confirmed"] = True + line = run(group, [ship], [cash], facts, params, date(2026, 8, 31))["lines"][0] + assert line["performance"] == "0.00" + assert line["excluded_manager_own"] + assert line["original_role_code"] == "sales_manager" + assert line["role_code"] == "salesperson" + + +def test_八月新业务按业务员且不沿用七月经理岗位(): + group, ship, cash, facts, params = inputs() + ship["date"] = cash["date"] = "2026-08-16" + facts["personnel"]["001"].update(role_history=periods()) + params["2026-08-16"] = params.pop("2026-03-10") + params["august_confirmed"] = True + line = run(group, [ship], [cash], facts, params, date(2026, 8, 31))["lines"][0] + assert line["performance"] == "0.20" and not line["excluded_manager_own"] + + +def test_历史经理贯通正式导入台账及月结岗位(payroll_case): + session, operator, _ = payroll_case + fact = session.scalar( + select(TPlusFact).where(TPlusFact.kind == "PERSONNEL", TPlusFact.fact_key == "00043") + ) + fact.data_json = {**fact.data_json, "role_history": periods()} + session.commit() + business = imported(payroll_case) + fact.data_json = {**fact.data_json, "role_history": []} + session.commit() + result = payroll_calculation.calculate(session, business.id, "2026-03", operator=operator) + assert result["performance"] == result["payable"] == "0.00" + row = session.scalar(select(PerformanceLedger).where(PerformanceLedger.calc_type == 1)) + assert row.role_code == "sales_manager" + assert row.snapshot_json["manager_own_excluded"] + settlement = settlement_service.generate(session, "2026-03", operator=operator) + item = next(item for item in settlement.items if item.personnel_id == row.personnel_id) + assert item.role_code == "sales_manager" + + +def test_源客户允许负责人未知且不冒用营销人员姓名(work_session): + session = work_session + source(session, "customers", [{"id": 9647, "name": "真实样本客户", "partnerType": 211}]) + fact(session, "CUSTOMER", "9647", {"customer_type": "enterprise"}) + imported_run = apply(session, preview(session, ["customers"])) + assert imported_run.summary_json["counts"] == {"CREATE": 1} + customer = session.scalar(select(Customer)) + assert customer.owner_name == "" and customer.customer_type == "enterprise" + + +def test_带真实发货参照价格的产品可预览并正式导入(work_session): + session = work_session + source(session, "products", [{"id": 9, "name": "样本产品", "unit_name": "根"}]) + source( + session, + "shipments", + [{"id": 20, "code": "SA-SAMPLE", "voucherdate": "2026-07-16", "voucherState": 189}], + ) + source( + session, + "shipment_items", + [ + { + "id": 21, + "source_parent_id": 20, + "idinventory": 9, + "taxAmount": "3850.00000000000000", + "quantity": "28.00000000000000", + "taxPrice": "137.50000000000000", + } + ], + ) + fact(session, "PRODUCT", "9", {"category": "municipal_water", "unit": "根"}) + imported_run = apply(session, preview(session, ["products"])) + assert imported_run.summary_json["counts"] == {"CREATE": 1} + product = session.scalar(select(Product)) + assert product.stock_quantity == Decimal("28") + assert product.source_trade_json["price"] == "137.5000" diff --git a/backend/tests/unit/test_manual_payroll_preservation.py b/backend/tests/unit/test_manual_payroll_preservation.py new file mode 100644 index 0000000..82056e9 --- /dev/null +++ b/backend/tests/unit/test_manual_payroll_preservation.py @@ -0,0 +1,71 @@ +"""手工业务再次准备时保留交接及已封存成本。""" + +from datetime import date +from decimal import Decimal + +import pytest +from sqlalchemy import select + +from app.core.exceptions import BadRequestError +from app.models import ( + Business, + BusinessItem, + BusinessRole, + Customer, + PayrollInput, + Personnel, + Shipment, + ShipmentItem, + User, +) +from app.schemas.payroll import PayrollSetupIn +from app.services import payroll_inputs, payroll_months +from tests.unit.test_tplus_workbench import work_session as work_session + + +def test_手工业务再次保存保留交接并拒绝指导单价微小变更(work_session, monkeypatch): + session = work_session + sales = session.scalar(select(BusinessRole).where(BusinessRole.role_code == "salesperson")) + manager_role = BusinessRole(role_name="经理", role_code="sales_manager", calc_template="SALES_MANAGER", sort_order=2) + customer = Customer(company_name="手工客户", owner_name="负责人", customer_type="personal") + session.add_all([manager_role, customer]) + session.flush() + owner = Personnel(personnel_name="业务员", business_role_id=sales.id) + receiver = Personnel(personnel_name="接手人员", business_role_id=sales.id) + manager = Personnel(personnel_name="经理", business_role_id=manager_role.id) + session.add_all([owner, receiver, manager]) + session.flush() + business = Business(business_no="MANUAL-01", customer_id=customer.id) + session.add(business) + session.flush() + session.add(BusinessItem(business_id=business.id, product_name="管材", spec_model="", + product_category="municipal_water", quantity=Decimal("5"), sale_amount=Decimal("500"))) + shipment = Shipment(business_id=business.id, shipment_no="SH-MANUAL", ship_date=date(2026, 3, 10)) + session.add(shipment) + session.flush() + item = ShipmentItem(shipment_id=shipment.id, product_name="管材", spec_model="", + quantity=Decimal("5"), amount=Decimal("500")) + session.add(item) + session.commit() + monkeypatch.setattr(payroll_months, "ensure_allocation_editable", lambda *args: None) + payload = {"month": "2026-03", "evidence": "已核实", "opening_balance": "0", "history_confirmed": True, + "guidance_finalized": True, "shipment_ownerships": [{"shipment_item_id": item.id, + "personnel_id": owner.id, "manager_id": manager.id, "unit_cost": "1"}], + "handovers": [{"business_key": business.business_no, "mode": "RESIGNATION", + "from_personnel_id": str(owner.id), "to_personnel_id": str(receiver.id), "effective_date": "2026-03-20"}]} + payroll_inputs.setup(session, business.id, PayrollSetupIn(**payload), session.get(User, 1)) + retry = {**payload, "expected_version": 1} + retry.pop("handovers") + payroll_inputs.setup(session, business.id, PayrollSetupIn(**retry), session.get(User, 1)) + latest = session.scalar(select(PayrollInput).order_by(PayrollInput.version.desc())) + assert latest.data_json["facts"]["handovers"][0]["to_personnel_id"] == str(receiver.id) + changed = {**retry, "expected_version": 2, + "shipment_ownerships": [{**retry["shipment_ownerships"][0], "unit_cost": "1.0001"}]} + with pytest.raises(BadRequestError, match="指导成本已封存"): + payroll_inputs.setup(session, business.id, PayrollSetupIn(**changed), session.get(User, 1)) + equivalent = {**changed, "shipment_ownerships": [{**changed["shipment_ownerships"][0], "unit_cost": "1.0000"}]} + payroll_inputs.setup(session, business.id, PayrollSetupIn(**equivalent), session.get(User, 1)) + payroll_inputs.setup(session, business.id, PayrollSetupIn(**{**equivalent, "expected_version": 3, "handovers": []}), + session.get(User, 1)) + latest = session.scalar(select(PayrollInput).order_by(PayrollInput.version.desc())) + assert latest.data_json["facts"]["handovers"] == [] diff --git a/backend/tests/unit/test_models.py b/backend/tests/unit/test_models.py index ca4f307..fa578ef 100644 --- a/backend/tests/unit/test_models.py +++ b/backend/tests/unit/test_models.py @@ -51,6 +51,8 @@ EXPECTED_TABLES = { "monthly_settlements", "settlement_items", "adjustment_orders", + "tplus_business_documents", + "tplus_business_sync_configs", "tplus_sync_batches", "tplus_sync_errors", "tplus_sync_records", diff --git a/backend/tests/unit/test_mysql_month_lock.py b/backend/tests/unit/test_mysql_month_lock.py new file mode 100644 index 0000000..4e9302f --- /dev/null +++ b/backend/tests/unit/test_mysql_month_lock.py @@ -0,0 +1,62 @@ +"""在显式指定的临时 MySQL 库中验证工资月份行锁。""" + +import os +from concurrent.futures import ThreadPoolExecutor, TimeoutError +from datetime import datetime +from threading import Event +from types import SimpleNamespace + +import pytest +from sqlalchemy import create_engine +from sqlalchemy.orm import Session + +from app.models import PayrollMonth +from app.services.payroll_months import lock_month, lock_month_row + + +@pytest.mark.skipif(not os.environ.get("PAYROLL_LOCK_TEST_URL"), reason="需要独立的临时 MySQL 测试库") +def test_同步等待工资事务提交并读取最新生成状态(monkeypatch): + from app.services import payroll_months + + engine = create_engine(os.environ["PAYROLL_LOCK_TEST_URL"]) + PayrollMonth.__table__.create(engine) + monkeypatch.setattr(payroll_months, "ensure_payroll_writer", lambda _operator: None) + actor = SimpleNamespace(id=1, username="test") + started = Event() + + def check_sync(): + with Session(engine) as session: + started.set() + control = lock_month_row(session, "2026-03", actor) + return control.generated_at + + try: + with Session(engine) as generation, ThreadPoolExecutor(max_workers=1) as pool: + control = lock_month(generation, "2026-03", actor) + result = pool.submit(check_sync) + assert started.wait(5) + with pytest.raises(TimeoutError): + result.result(timeout=0.25) + control.generated_at = datetime(2026, 3, 31) + generation.commit() + assert result.result(timeout=10) == datetime(2026, 3, 31) + + started.clear() + + def generate(): + with Session(engine) as session: + started.set() + control = lock_month(session, "2026-04", actor) + control.generated_at = datetime(2026, 4, 30) + session.commit() + + with Session(engine) as sync, ThreadPoolExecutor(max_workers=1) as pool: + lock_month_row(sync, "2026-04", actor) + result = pool.submit(generate) + assert started.wait(5) + with pytest.raises(TimeoutError): + result.result(timeout=0.25) + sync.commit() + result.result(timeout=10) + finally: + engine.dispose() diff --git a/backend/tests/unit/test_param_registry.py b/backend/tests/unit/test_param_registry.py index ac585ad..b481bce 100644 --- a/backend/tests/unit/test_param_registry.py +++ b/backend/tests/unit/test_param_registry.py @@ -23,30 +23,35 @@ from app.services.param_registry import ( # + V7 调整单开关;V8 撤试用期统一工资参数 #16、增经理个人户基数开关 #12; # V10 增工作台预警规则三项 #10) EXPECTED_CODES = { - "coefficient.personal.first", "coefficient.personal.start", "coefficient.personal.slope", - "coefficient.personal.floor", "coefficient.personal.boundary", - "salesperson.rate.start", - "salesperson.rate.slope", - "salesperson.rate.floor", - "salesperson.days.boundary", - "manager.share.team", - "manager.share.company", + "approval.adjustment.enabled", + "approval.monthly_settlement.enabled", + "approval.secondary_allocation.enabled", + "barter.discount", + "coefficient.personal.boundary", + "coefficient.personal.first", + "coefficient.personal.floor", + "coefficient.personal.slope", + "coefficient.personal.start", "enterprise.rate", "info.rate", - "prepay.rate", - "barter.discount", + "manager.personal.included", + "manager.share.company", + "manager.share.team", "payment.start.ratio", "payment.weight.mode", - "product.threshold", - "salary.basic.mode", + "prepay.rate", "probation.annual.threshold", - "approval.secondary_allocation.enabled", - "approval.monthly_settlement.enabled", - "approval.adjustment.enabled", - "manager.personal.included", - "warning.enabled", - "warning.receipt.overdue.days", + "product.threshold", + "receipt.confirmation.enabled", + "salary.basic.mode", + "salesperson.days.boundary", + "salesperson.rate.floor", + "salesperson.rate.slope", + "salesperson.rate.start", "warning.approval.timeout.days", + "warning.enabled", + "warning.receipt.overdue.days" + } @@ -87,7 +92,7 @@ class TestRegistry: """注册表元数据完整性(与迁移种子同源)。""" def test_注册表共27项(self) -> None: - assert len(PARAM_REGISTRY) == 27 + assert len(PARAM_REGISTRY) == 28 def test_编码集合与迁移种子一致(self) -> None: assert set(PARAM_REGISTRY) == EXPECTED_CODES diff --git a/backend/tests/unit/test_payroll_metadata_pagination.py b/backend/tests/unit/test_payroll_metadata_pagination.py new file mode 100644 index 0000000..8e496c4 --- /dev/null +++ b/backend/tests/unit/test_payroll_metadata_pagination.py @@ -0,0 +1,106 @@ +"""核算资料参照必须能完整遍历大于单页的发货和人员。""" + +from datetime import date +from decimal import Decimal + +import pytest +from sqlalchemy import select + +from app.models import ( + Business, + BusinessRole, + Personnel, + Shipment, + ShipmentItem, + TPlusFact, + TPlusSourceLink, + User, +) +from app.services import authorization_metadata, authorization_service +from tests.unit.test_tplus_business_sync import dataset, sync +from tests.unit.test_tplus_workbench import work_session as work_session + + +def test_核算参照按页返回超过二百条的发货与人员(work_session, monkeypatch): + session = work_session + dataset(session, shipping=True) + sync(session) + business = session.scalar(select(Business)) + role = session.scalar(select(BusinessRole).where(BusinessRole.role_code == "salesperson")) + for number in range(205): + shipment = Shipment(business_id=business.id, shipment_no=f"P{number:04}", + ship_date=date(2026, 3, 1), total_amount=Decimal("1")) + session.add(shipment) + session.flush() + session.add(ShipmentItem(shipment_id=shipment.id, product_name="测试产品", spec_model="", + quantity=Decimal("1"), amount=Decimal("1"))) + session.add(Personnel(personnel_name=f"测试人员{number:04}", business_role_id=role.id)) + session.flush() + monkeypatch.setattr(authorization_service, "require_permission", lambda *args: None) + first = authorization_metadata.payroll_options(session, session.get(User, 1), business.id, page_num=1, page_size=200) + second = authorization_metadata.payroll_options(session, session.get(User, 1), business.id, page_num=2, page_size=200) + assert first["business_key"] == second["business_key"] == "customer:8" + assert first["shipments_total"] == second["shipments_total"] == 206 + assert first["personnel_total"] == second["personnel_total"] == 205 + assert (len(first["shipments"]), len(second["shipments"])) == (200, 6) + assert (len(first["personnel"]), len(second["personnel"])) == (200, 5) + assert len({row["id"] for row in first["shipments"] + second["shipments"]}) == 206 + + +def test_来源人员只按已确认身份关联预填且未匹配保持空值(work_session, monkeypatch): + session = work_session + dataset(session, shipping=True) + sync(session) + business = session.scalar(select(Business)) + monkeypatch.setattr(authorization_service, "require_permission", lambda *args: None) + before = authorization_metadata.payroll_options(session, session.get(User, 1), business.id) + assert before["shipments"][0]["source_personnel_name"] == "来源员工" + assert before["shipments"][0]["matched_personnel_id"] is None + role = session.scalar(select(BusinessRole).where(BusinessRole.role_code == "salesperson")) + person = Personnel(personnel_name="来源员工", business_role_id=role.id) + session.add(person) + session.flush() + # 相同姓名本身不是身份关联。 + unmatched = authorization_metadata.payroll_options(session, session.get(User, 1), business.id) + assert unmatched["shipments"][0]["matched_personnel_id"] is None + other_source = TPlusSourceLink(source_database="other_source", entity_type="personnel", source_id="1", target_id=person.id) + session.add(other_source) + session.flush() + assert authorization_metadata.payroll_options(session, session.get(User, 1), business.id)["shipments"][0]["matched_personnel_id"] is None + session.delete(other_source) + session.flush() + session.add(TPlusSourceLink(source_database="test_source", entity_type="personnel", source_id="1", target_id=person.id)) + session.flush() + matched = authorization_metadata.payroll_options(session, session.get(User, 1), business.id) + assert matched["shipments"][0]["matched_personnel_id"] == person.id + + +def test_核算参照复用同来源已封存的单位指导成本(work_session, monkeypatch): + session = work_session + dataset(session, shipping=True) + sync(session) + business = session.scalar(select(Business)) + item = session.scalar(select(ShipmentItem)) + monkeypatch.setattr(authorization_service, "require_permission", lambda *args: None) + initial = authorization_metadata.payroll_options(session, session.get(User, 1), business.id)["shipments"][0] + assert initial["guidance_status"] == "MISSING" and initial["guidance_unit_cost"] is None + data = {"product_id": "9", "month": "2026-03", "basis": "UNIT", + "amount": "2.1234", "unit": item.unit, "is_finalized": True} + session.add(TPlusFact(source_database="test_source", kind="GUIDANCE", fact_key="price9-03", + version=1, data_json=data, evidence="历史记录", requested_by=1)) + session.flush() + ready = authorization_metadata.payroll_options(session, session.get(User, 1), business.id)["shipments"][0] + assert (ready["guidance_status"], ready["guidance_unit_cost"]) == ("FINALIZED_UNIT", "2.1234") + session.add(TPlusFact(source_database="test_source", kind="GUIDANCE", fact_key="duplicate9-03", + version=1, data_json=data, evidence="历史重复记录", requested_by=1)) + session.flush() + conflict = authorization_metadata.payroll_options(session, session.get(User, 1), business.id)["shipments"][0] + assert conflict["guidance_status"] == "AMBIGUOUS" and conflict["guidance_unit_cost"] is None + + +@pytest.mark.parametrize("page_num,page_size", [(0, 200), (1, 0), (1, 201)]) +def test_核算参照拒绝无效页码与页大小(work_session, monkeypatch, page_num, page_size): + monkeypatch.setattr(authorization_service, "require_permission", lambda *args: None) + with pytest.raises(ValueError): + authorization_metadata.payroll_options(work_session, work_session.get(User, 1), 1, + page_num=page_num, page_size=page_size) diff --git a/backend/tests/unit/test_receipt_confirmation.py b/backend/tests/unit/test_receipt_confirmation.py new file mode 100644 index 0000000..21ddc9d --- /dev/null +++ b/backend/tests/unit/test_receipt_confirmation.py @@ -0,0 +1,38 @@ +"""人工确认绑定被核对内容,不能由核算状态、凭证或同步状态推导。""" + +from datetime import date, datetime +from decimal import Decimal + +from app.models import Receipt +from app.services import receipt_confirmation +from app.services.param_service import ParamSnapshot + + +def receipt(): + return Receipt(id=1, business_id=2, receipt_date=date(2026, 3, 10), amount=Decimal("100"), is_barter=0) + + +def test_人工确认默认开启且与工资审批独立(monkeypatch): + monkeypatch.setattr(receipt_confirmation, "resolve_snapshot", lambda *args: ParamSnapshot.from_rows(date.today(), [])) + assert receipt_confirmation.enabled(None) + + +def test_已核算及同步来源不能替代确认(): + row = receipt() + row.settlement_status = 1 + row.t_plus_id = "source1" + row.sync_batch_no = "SYNC-SUCCEEDED" + assert not receipt_confirmation.is_confirmed(row) + + +def test_关键内容变化使确认失效而备注不影响(): + row = receipt() + row.confirmation_status = 1 + row.confirmed_by = "test_operator" + row.confirmed_at = datetime.now() + row.confirmed_revision = receipt_confirmation.revision(row) + assert receipt_confirmation.is_confirmed(row) + row.remark = "只改备注" + assert receipt_confirmation.is_confirmed(row) + row.amount = Decimal("101") + assert not receipt_confirmation.is_confirmed(row) diff --git a/backend/tests/unit/test_receipt_confirmation_migration.py b/backend/tests/unit/test_receipt_confirmation_migration.py new file mode 100644 index 0000000..0e9b366 --- /dev/null +++ b/backend/tests/unit/test_receipt_confirmation_migration.py @@ -0,0 +1,44 @@ +"""V29 能识别先于迁移版本登记的兼容字段。""" + +import importlib.util +from pathlib import Path +from types import SimpleNamespace + +import pytest +from sqlalchemy.dialects.mysql import DATETIME, TINYINT, VARCHAR + + +def migration(monkeypatch, columns): + path = Path(__file__).resolve().parents[2] / "alembic/versions/29b0c1d2e3f4_receipt_manual_confirmation.py" + spec = importlib.util.spec_from_file_location("receipt_confirmation_migration", path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + statements = [] + bind = SimpleNamespace(execute=lambda sql: statements.append(str(sql))) + monkeypatch.setattr(module, "op", SimpleNamespace(get_bind=lambda: bind)) + monkeypatch.setattr(module, "inspect", lambda _: SimpleNamespace(get_columns=lambda table: columns)) + return module, statements + + +def existing_columns(): + return [ + {"name": "confirmation_status", "type": TINYINT(), "nullable": False, "default": "'0'"}, + {"name": "confirmed_by", "type": VARCHAR(64, collation="utf8mb4_general_ci"), "nullable": True}, + {"name": "confirmed_at", "type": DATETIME(), "nullable": True}, + {"name": "confirmed_revision", "type": VARCHAR(64), "nullable": True}, + ] + + +def test_兼容已有字段且仍执行权限和参数初始化(monkeypatch): + module, statements = migration(monkeypatch, existing_columns()) + module.upgrade() + assert len(statements) == 4 + assert all(not statement.startswith("ALTER TABLE") for statement in statements) + assert any("receipt.confirmation.enabled" in statement for statement in statements) + + +def test_已有字段不完整时拒绝迁移(monkeypatch): + module, statements = migration(monkeypatch, existing_columns()[:1]) + with pytest.raises(RuntimeError, match="字段不完整"): + module.upgrade() + assert not statements diff --git a/backend/tests/unit/test_source_price_authorization.py b/backend/tests/unit/test_source_price_authorization.py new file mode 100644 index 0000000..10514b4 --- /dev/null +++ b/backend/tests/unit/test_source_price_authorization.py @@ -0,0 +1,31 @@ +"""来源单价确认接口的权限边界。""" + +from unittest.mock import Mock + +import pytest + +from app.api.v1.business.businesses import confirm_source_price +from app.core.exceptions import ForbiddenError +from app.schemas.business_source import SourcePriceConfirmIn +from app.services import authorization_service, business_source +from app.services.authorization_policy import permission_for + + +def test_来源单价确认路由要求业务更新权限(): + assert permission_for("business.businesses", "confirm_source_price", "PUT") == "business:businesses:update" + + +def test_无核算准备权限不能确认来源单价(monkeypatch): + session, actor, meta = Mock(), Mock(), Mock() + confirm = Mock() + + def deny(current_session, current_actor, permission): + assert (current_session, current_actor, permission) == (session, actor, "payroll:prepare") + raise ForbiddenError("无权核算准备") + + monkeypatch.setattr(authorization_service, "require_permission", deny) + monkeypatch.setattr(business_source, "confirm_missing_source_price", confirm) + payload = SourcePriceConfirmIn(revision="0" * 64, evidence="核对原单") + with pytest.raises(ForbiddenError): + confirm_source_price(session, actor, 1, 1, payload, meta) + confirm.assert_not_called() diff --git a/backend/tests/unit/test_tplus_business_sync.py b/backend/tests/unit/test_tplus_business_sync.py new file mode 100644 index 0000000..d394d6e --- /dev/null +++ b/backend/tests/unit/test_tplus_business_sync.py @@ -0,0 +1,573 @@ +"""业务接入以源单据为边界;核算资料缺失不得阻断建档。""" + +from contextlib import nullcontext +from datetime import date +from decimal import Decimal +from fractions import Fraction +from types import SimpleNamespace +from uuid import NAMESPACE_URL, uuid4, uuid5 + +import pytest +from sqlalchemy import func, select + +from app.core.exceptions import BadRequestError, ForbiddenError +from app.core.response import PageQuery +from app.models import ( + Business, + BusinessItem, + BusinessRole, + Customer, + OwnershipChangeLog, + PayrollInput, + Personnel, + Product, + Receipt, + Shipment, + ShipmentItem, + TPlusBusinessDocument, + TPlusBusinessSyncConfig, + TPlusSourceHead, + TPlusSyncBatch, + TPlusWorkRun, + User, +) +from app.repositories import customer_repository, product_repository +from app.schemas.business_source import ( + SalesRelationConfirmIn, + SalesRelationReleaseIn, + SourceMaterialsIn, + SourcePriceConfirmIn, +) +from app.schemas.category_batch import ( + CustomerCategoryBatch, + CustomerOwnershipBatch, + ProductCategoryBatch, +) +from app.schemas.customer import CustomerQuery +from app.schemas.payroll import PayrollSetupIn +from app.schemas.product import ProductQuery +from app.schemas.report import BusinessStatsQuery +from app.services import ( + business_service, + business_source, + category_batch, + customer_service, + payroll_months, + receipt_confirmation, + report_service, +) +from app.services.business_source import ensure_source_editable +from app.services.tplus import business_sync, business_sync_control, sales_relations +from app.services.tplus.snapshots import source_manifest +from app.services.tplus.workbench import WorkbenchError, digest, execute_next +from tests.unit.test_tplus_workbench import fact, source +from tests.unit.test_tplus_workbench import work_session as work_session + + +def dataset(s, *, shipping=False, cash=False): + entities = { + "customers": [{"id": 8, "code": "C8", "name": "自动客户", "partnerType": 228}], + "products": [{"id": 9, "code": "P9", "name": "管材", "specification": "DN300", "unit_name": "根"}], + "personnel": [{"id": 1, "code": "00043", "name": "来源员工", "isSalesman": 1}], + "businesses": [{"id": 11, "code": "SO-11", "idcustomer": 8, "idclerk": 1, "voucherdate": "2026-03-12", "voucherState": 189}], + "business_items": [{"id": 12, "source_parent_id": 11, "idinventory": 9, "quantity": "10", "taxPrice": "100", "taxAmount": "1000"}], + "shipments": [{"id": 21, "code": "SH-21", "idcustomer": 8, "idclerk": 1, "voucherdate": "2026-03-13", "voucherState": 189}] if shipping else [], + "shipment_items": [{"id": 22, "source_parent_id": 21, "idinventory": 9, "saleOrderId": 11, "saleOrderDetailId": 12, "quantity": "5", "taxPrice": "100", "taxAmount": "500"}] if shipping else [], + "receipts": [{"id": 31, "code": "RC-31", "idpartner": 8, "idperson": 1, "isReceiveFlag": 1, "amount": "200", "voucherDate": "2026-03-14", "voucherstate": 189, "accounting_voucher_count": 1, "valid_accounting_voucher_count": 1, "accounting_voucher_id": 501}] if cash else [], + "receipt_items": [], + } + for entity, rows in entities.items(): + source(s, entity, rows) + + +def sync(s): + run = TPlusWorkRun(run_no=uuid4().hex, request_key=str(uuid4()), source_database="test_source", requested_by=1, + kind="BUSINESS_SYNC", status="RUNNING", input_json={"start_date": "2026-03-01"}) + s.add(run) + business_sync.execute_sync(s, run) + s.commit() + return run + + +def test_自动接入按实际发布快照去重(work_session, monkeypatch): + session = work_session + dataset(session) + monkeypatch.setattr(business_sync_control, "history_source_database", lambda _session: "test_source") + config = TPlusBusinessSyncConfig(source_database="test_source", enabled=1, + start_date=date(2026, 3, 1), version=1, updated_by=1) + session.add(config) + session.commit() + manifest = list(source_manifest(session, "test_source").items()) + expected_key = str(uuid5(NAMESPACE_URL, digest(["test_source", 1, manifest, "business-sync-v1"]))) + first = business_sync_control.schedule(session) + assert session.scalar(select(TPlusWorkRun.request_key)) == expected_key + config.last_error = "BUSINESS_SYNC_SCHEDULE_FAILED" + session.commit() + # 历史成功批次可能声明实体,但没有发布该实体;它不能改变实际来源版本。 + session.add(TPlusSyncBatch(batch_no=uuid4().hex[:24], source_database="test_source", + sync_mode="FULL", status="SUCCEEDED", entity_types_json=["customers"], + audit_result_json={"observed_entities": []})) + session.commit() + second = business_sync_control.schedule(session) + session.expire_all() + assert config.last_error is None + assert second["run_no"] == first["run_no"] + assert session.scalar(select(func.count()).select_from(TPlusWorkRun)) == 1 + run = session.scalar(select(TPlusWorkRun)) + run.status, config.last_error = "FAILED", "TASK_AUTHORIZATION_REVOKED" + session.commit() + assert business_sync_control.schedule(session)["status"] == "FAILED" + session.expire_all() + assert config.last_error == "TASK_AUTHORIZATION_REVOKED" + + +@pytest.mark.parametrize("config_owner", [1, 2]) +def test_排队后撤权仅提示对应授权人的自动接入配置(work_session, monkeypatch, config_owner): + from app.services import authorization_service + + session = work_session + if config_owner == 2: + session.add(User(id=2, username="other_operator", password="test_hash_only", status=1)) + config = TPlusBusinessSyncConfig(source_database="test_source", enabled=1, + start_date=date(2026, 3, 1), version=1, updated_by=config_owner) + run = TPlusWorkRun(run_no=uuid4().hex, request_key=str(uuid4()), source_database="test_source", + requested_by=1, kind="BUSINESS_SYNC", status="QUEUED", input_json={}) + session.add_all([config, run]) + session.commit() + + def denied(*_args): + raise ForbiddenError() + + monkeypatch.setattr(authorization_service, "require_permission", denied) + assert execute_next(session) == run.run_no + session.refresh(run) + session.refresh(config) + assert (run.status, run.error_code) == ("FAILED", "TASK_AUTHORIZATION_REVOKED") + assert config.last_error == ("TASK_AUTHORIZATION_REVOKED" if config_owner == 1 else None) + + +@pytest.mark.parametrize("config_owner", [1, 2]) +def test_重试成功仅清除对应授权人的接入错误(work_session, monkeypatch, config_owner): + session = work_session + if config_owner == 2: + session.add(User(id=2, username="other_operator", password="test_hash_only", status=1)) + config = TPlusBusinessSyncConfig(source_database="test_source", enabled=1, + start_date=date(2026, 3, 1), version=1, updated_by=config_owner, + last_error="TASK_AUTHORIZATION_REVOKED") + run = TPlusWorkRun(run_no=uuid4().hex, request_key=str(uuid4()), source_database="test_source", + requested_by=1, kind="BUSINESS_SYNC", status="QUEUED", input_json={}) + session.add_all([config, run]) + session.commit() + + def success(_session, task): + task.status = "SUCCEEDED" + + monkeypatch.setattr(business_sync, "execute_sync", success) + assert execute_next(session) == run.run_no + session.refresh(config) + assert config.last_error == (None if config_owner == 1 else "TASK_AUTHORIZATION_REVOKED") + + +@pytest.mark.parametrize(("value", "scale"), [("999999999999.99", 2), ("9999999999.9999", 4)]) +def test_目标精度范围内的来源数值可接入(value, scale): + assert business_sync.decimal_value(value, scale) == Decimal(value) + + +@pytest.mark.parametrize(("value", "scale"), [("1000000000000", 2), ("10000000000", 4), ("1.001", 2)]) +def test_来源数值超出目标范围或精度时拒绝(value, scale): + with pytest.raises(WorkbenchError, match="SOURCE_VALUE_INVALID"): + business_sync.decimal_value(value, scale) + + +def test_订单明细合计溢出时标记待核对(work_session): + s = work_session + dataset(s) + source(s, "business_items", [ + {"id": 12, "source_parent_id": 11, "idinventory": 9, "quantity": "1", "taxAmount": "600000000000"}, + {"id": 13, "source_parent_id": 11, "idinventory": 9, "quantity": "1", "taxAmount": "600000000000"}, + ]) + run = sync(s) + doc = s.scalar(select(TPlusBusinessDocument).where(TPlusBusinessDocument.entity_type == "businesses")) + assert doc.status == "PENDING" + assert doc.reason == "SOURCE_VALUE_INVALID" + assert run.summary_json["counts"]["PENDING"] == 1 + + +def test_只有订单且分类指导价均缺失仍自动建业务(work_session): + s = work_session + dataset(s) + run = sync(s) + business = s.scalar(select(Business)) + assert business is not None and business.business_date == date(2026, 3, 12) + assert s.scalar(select(Customer)).customer_type is None + assert s.scalar(select(Product)).product_category is None + assert s.scalar(select(BusinessItem)).product_category is None + assert business.total_sale_amount == 1000 + assert business.total_freight_amount is None + assert business.owner_personnel_id is None + assert s.scalar(select(func.count()).select_from(PayrollInput)) == 0 + assert run.status == "SUCCEEDED" + sync(s) + assert business.total_freight_amount is None + business_service._recalc_totals(s, business) + assert business.total_cost_amount is None + assert business.total_freight_amount is None + assert business.total_sale_amount == 1000 + + +def test_已生成工资的月份不接入新来源单据(work_session): + from datetime import datetime + + from app.models import PayrollMonth + + session = work_session + dataset(session, cash=True) + session.add(PayrollMonth(month="2026-03", maker_id=1, maker_name="test_operator", + generated_at=datetime(2026, 3, 31))) + session.commit() + run = sync(session) + assert session.scalar(select(func.count()).select_from(Receipt)) == 0 + documents = list(session.scalars(select(TPlusBusinessDocument))) + assert any(doc.reason == "PAYROLL_MONTH_LOCKED" for doc in documents) + assert run.status == "PARTIAL" + + +def test_运费仅在所有发货均确认后汇总(work_session): + s = work_session + dataset(s, shipping=True) + sync(s) + business = s.scalar(select(Business)) + shipment = s.scalar(select(Shipment)) + assert business.total_freight_amount is None + shipment.freight_amount = Decimal("0.00") + s.flush() + business_sync._refresh_business_totals(s, business) + assert business.total_freight_amount == Decimal("0.00") + + +def test_来源订单与发货数量按产品单位分别汇总(work_session): + s = work_session + dataset(s, shipping=True, cash=True) + sync(s) + business = s.scalar(select(Business)) + rows = business_source.source_quantities(s, business.id, PageQuery()).records + assert len(rows) == 1 + assert rows[0]["product_name"] == "管材" and rows[0]["unit"] == "根" + assert Decimal(rows[0]["order_quantity"]) == Decimal("10") + assert Decimal(rows[0]["shipment_quantity"]) == Decimal("5") + assert business.total_sale_amount == Decimal("1000") + assert business.total_received_amount == Decimal("200") + sync(s) + assert business_source.source_quantities(s, business.id, PageQuery()).records == rows + + +def test_来源数量按不同产品单位分组并分页(work_session): + s = work_session + dataset(s, shipping=True) + source(s, "products", [ + {"id": 9, "code": "P9", "name": "管材", "specification": "DN300", "unit_name": "根"}, + {"id": 10, "code": "P10", "name": "钢板", "specification": "Q235", "unit_name": "吨"}, + ]) + source(s, "business_items", [ + {"id": 12, "source_parent_id": 11, "idinventory": 9, "quantity": "10", "taxAmount": "1000"}, + {"id": 13, "source_parent_id": 11, "idinventory": 10, "quantity": "3", "taxAmount": "300"}, + ]) + source(s, "shipment_items", [ + {"id": 22, "source_parent_id": 21, "idinventory": 9, "saleOrderDetailId": 12, + "quantity": "5", "taxAmount": "500"}, + {"id": 23, "source_parent_id": 21, "idinventory": 10, "saleOrderDetailId": 13, + "quantity": "2", "taxAmount": "200"}, + ]) + sync(s) + business = s.scalar(select(Business)) + first = business_source.source_quantities(s, business.id, PageQuery(page_size=1, page_num=1)) + second = business_source.source_quantities(s, business.id, PageQuery(page_size=1, page_num=2)) + assert first.total == second.total == 2 + rows = {row["unit"]: row for row in first.records + second.records} + assert (Decimal(rows["根"]["order_quantity"]), Decimal(rows["根"]["shipment_quantity"])) == (10, 5) + assert (Decimal(rows["吨"]["order_quantity"]), Decimal(rows["吨"]["shipment_quantity"])) == (3, 2) + + +def test_仅待核对单据的业务不填零成本运费且重试修复旧汇总(work_session): + s = work_session + dataset(s) + source(s, "businesses", [{"id": 11, "code": "SO-11", "idcustomer": 8, + "voucherdate": "2026-03-12", "voucherState": 0}]) + run = sync(s) + business = s.scalar(select(Business)) + assert run.status == "PARTIAL" + assert business.total_cost_amount is None and business.total_freight_amount is None + business.total_cost_amount = Decimal("0.00") + business.total_freight_amount = Decimal("0.00") + s.commit() + sync(s) + assert business.total_cost_amount is None and business.total_freight_amount is None + + +def test_待核对回款来源客户变化时不静默归入原客户(work_session): + s = work_session + dataset(s, cash=True) + source(s, "customers", [ + {"id": 8, "code": "C8", "name": "自动客户", "partnerType": 228}, + {"id": 10, "code": "C10", "name": "其他客户", "partnerType": 228}, + ]) + receipt = {"id": 31, "code": "RC-31", "idpartner": 8, "idperson": 1, + "isReceiveFlag": 1, "amount": "-200", "voucherDate": "2026-03-14", "voucherstate": 189} + source(s, "receipts", [receipt]) + sync(s) + doc = s.scalar(select(TPlusBusinessDocument).where(TPlusBusinessDocument.entity_type == "receipts")) + original_business_id = doc.business_id + assert doc.reason == "SOURCE_VALUE_INVALID" and original_business_id is not None + source(s, "receipts", [{**receipt, "idpartner": 10}]) + sync(s) + assert (doc.status, doc.reason, doc.business_id) == ( + "CONFLICT", "PENDING_SOURCE_CUSTOMER_CHANGED", original_business_id) + assert s.scalar(select(func.count()).select_from(Receipt)) == 0 + displayed = business_source.documents(s, original_business_id, PageQuery(), entity="receipts").records[0] + assert displayed["changes"] == [{"field": "customer_id", "label": "客户标识", "before": "8", "after": "10"}] + source(s, "receipts", [{**receipt, "idpartner": 10, "amount": "200"}]) + sync(s) + assert (doc.status, doc.reason, doc.business_id) == ( + "CONFLICT", "PENDING_SOURCE_CUSTOMER_CHANGED", original_business_id) + assert s.scalar(select(func.count()).select_from(Receipt)) == 0 + source(s, "receipts", [{**receipt, "amount": "200"}]) + sync(s) + assert doc.status == "APPLIED" and doc.business_id == original_business_id + assert s.scalar(select(func.count()).select_from(Receipt)) == 1 + + +def test_待核对回款同客户更新来源日期及人员(work_session): + s = work_session + dataset(s, cash=True) + receipt = {"id": 31, "code": "RC-31", "idpartner": 8, "idperson": 1, + "isReceiveFlag": 1, "amount": "-200", "voucherDate": "2026-03-14", "voucherstate": 189} + source(s, "receipts", [receipt]) + sync(s) + doc = s.scalar(select(TPlusBusinessDocument).where(TPlusBusinessDocument.entity_type == "receipts")) + source(s, "receipts", [{**receipt, "idperson": 2, "voucherDate": "2026-03-15"}]) + sync(s) + assert doc.reason == "SOURCE_VALUE_INVALID" + assert doc.data_json["source_date"] == "2026-03-15" + assert doc.data_json["source_personnel_id"] == "2" + assert s.scalar(select(func.count()).select_from(Receipt)) == 0 + + +@pytest.mark.parametrize("entity", ["businesses", "shipments"]) +def test_待核对订单或发货客户变化需核对(work_session, entity): + s = work_session + dataset(s, shipping=entity == "shipments") + source(s, "customers", [ + {"id": 8, "code": "C8", "name": "自动客户"}, + {"id": 10, "code": "C10", "name": "其他客户"}, + ]) + raw = {"id": 11 if entity == "businesses" else 21, "idcustomer": 8, + "voucherdate": "2026-03-12", "voucherState": 0} + source(s, entity, [raw]) + sync(s) + doc = s.scalar(select(TPlusBusinessDocument).where(TPlusBusinessDocument.entity_type == entity)) + original_business_id = doc.business_id + assert doc.reason == "SOURCE_DOCUMENT_INACTIVE" and original_business_id is not None + source(s, entity, [{**raw, "idcustomer": 10, "voucherState": 189}]) + sync(s) + assert (doc.status, doc.reason, doc.business_id, doc.target_id) == ( + "CONFLICT", "PENDING_SOURCE_CUSTOMER_CHANGED", original_business_id, None) + source(s, entity, [{**raw, "voucherState": 189}]) + sync(s) + assert doc.status == "APPLIED" and doc.business_id == original_business_id + + +@pytest.mark.parametrize("mismatch", ["product", "order"]) +def test_发货关联订单的产品或父单不一致时不静默归并(work_session, mismatch): + s = work_session + dataset(s, shipping=True) + if mismatch == "product": + source(s, "products", [ + {"id": 9, "code": "P9", "name": "管材", "specification": "DN300", "unit_name": "根"}, + {"id": 10, "code": "P10", "name": "其他管材", "specification": "DN200", "unit_name": "根"}, + ]) + source(s, "shipment_items", [{ + "id": 22, "source_parent_id": 21, + "idinventory": 10 if mismatch == "product" else 9, + "saleOrderId": 99 if mismatch == "order" else 11, + "saleOrderDetailId": 12, "quantity": "5", "taxPrice": "100", "taxAmount": "500", + }]) + sync(s) + business = s.scalar(select(Business)) + assert s.scalar(select(func.count()).select_from(BusinessItem)) == 2 + assert business.total_sale_amount is None + assert business.integration_json["sales_relation_pending"] is True + shipment = s.scalar(select(TPlusBusinessDocument).where(TPlusBusinessDocument.entity_type == "shipments")) + assert (shipment.status, shipment.reason) == ("CONFLICT", "ORDER_SHIPMENT_MISMATCH") + sync(s) + assert shipment.status == "CONFLICT" + assert shipment.reason == "ORDER_SHIPMENT_MISMATCH" + + +def test_已有错误关联的发货重跑时标记冲突且保留正式数据(work_session, monkeypatch): + s = work_session + dataset(s, shipping=True) + source(s, "shipment_items", [{ + "id": 22, "source_parent_id": 21, "idinventory": 9, + "saleOrderId": 99, "saleOrderDetailId": 12, + "quantity": "5", "taxPrice": "100", "taxAmount": "500", + }]) + original = business_sync._shipment_order_line + def old_match(ctx, business_id, child): + doc = ctx.session.scalar(select(TPlusBusinessDocument).where( + TPlusBusinessDocument.entity_type == "business_items", + TPlusBusinessDocument.source_id == "12", + )) + return doc, False + monkeypatch.setattr(business_sync, "_shipment_order_line", old_match) + sync(s) + shipment = s.scalar(select(Shipment)) + shipment_id = shipment.id + monkeypatch.setattr(business_sync, "_shipment_order_line", original) + sync(s) + doc = s.scalar(select(TPlusBusinessDocument).where(TPlusBusinessDocument.entity_type == "shipments")) + assert (doc.status, doc.reason) == ("CONFLICT", "ORDER_SHIPMENT_MISMATCH") + assert business_source.sales_relation_pending(s, doc.business_id) + assert s.get(Shipment, shipment_id) is not None + assert s.scalar(select(func.count()).select_from(PayrollInput)) == 0 + + +def test_基础数据可筛选待分类与待归属(work_session): + s = work_session + dataset(s) + sync(s) + role = s.scalar(select(BusinessRole).where(BusinessRole.role_code == "salesperson")) + person = Personnel(personnel_name="已归属人员", business_role_id=role.id) + s.add(person) + s.flush() + s.add(Customer(company_name="已分类客户", owner_name="", customer_type="personal", owner_personnel_id=person.id)) + s.add(Product(product_name="已分类产品", spec_model="", product_category="municipal_water", status=1)) + s.flush() + customers = CustomerQuery(category_missing=True, owner_missing=True) + products = ProductQuery(category_missing=True) + assert customer_repository.count_customers(s, customers) == 1 + assert customer_repository.list_customers(s, customers)[0][0].company_name == "自动客户" + assert product_repository.count_products(s, products) == 1 + assert product_repository.list_products(s, products)[0].product_name == "管材" + assert customer_repository.count_customers(s, CustomerQuery(customer_type="personal", category_missing=True)) == 0 + assert product_repository.count_products(s, ProductQuery(product_category="municipal_water", category_missing=True)) == 0 + + +@pytest.mark.parametrize("frozen", [False, True]) +def test_批量补分类仅补空值并保护冻结明细(work_session, frozen): + s = work_session + dataset(s) + sync(s) + product = s.scalar(select(Product)) + customer = s.scalar(select(Customer)) + item = s.scalar(select(BusinessItem)) + existing = Product(product_name="已有分类", spec_model="", product_category="rail_transit", status=1) + s.add(existing) + if frozen: + s.add(PayrollInput(business_id=item.business_id, version=1, source_database="test_source", + group_key="frozen", fingerprint="f" * 64, data_json={}, requested_by=1)) + s.commit() + actor = s.get(User, 1) + with pytest.raises(BadRequestError, match="分类已变化"): + category_batch.fill_missing(s, Product, ProductCategoryBatch(ids=[product.id, existing.id], category="municipal_water"), actor) + assert s.get(Product, product.id).product_category is None + result = category_batch.fill_missing(s, Product, ProductCategoryBatch(ids=[product.id], category="municipal_water"), actor) + assert result["updated"] == 1 + s.refresh(item) + assert item.product_category == (None if frozen else "municipal_water") + category_batch.fill_missing(s, Customer, CustomerCategoryBatch(ids=[customer.id], category="enterprise"), actor) + assert s.get(Customer, customer.id).customer_type == "enterprise" + with pytest.raises(BadRequestError, match="分类已变化"): + category_batch.fill_missing(s, Customer, CustomerCategoryBatch(ids=[customer.id], category="personal"), actor) + assert s.get(Customer, customer.id).customer_type == "enterprise" + + +def test_业务详情补产品分类同步其他未封存业务(work_session, monkeypatch): + from app.services import authorization_service + + s = work_session + dataset(s) + sync(s) + first = s.scalar(select(Business)) + product = s.scalar(select(Product)) + others = [Business(business_no=f"OTHER-{n}", customer_id=first.customer_id) for n in range(2)] + s.add_all(others) + s.flush() + items = [BusinessItem(business_id=b.id, product_id=product.id, product_name="管材", + quantity=1, sale_amount=100) for b in others] + s.add_all(items) + s.add(PayrollInput(business_id=others[1].id, version=1, source_database="test_source", + group_key="frozen", fingerprint="f" * 64, data_json={}, requested_by=1)) + s.commit() + monkeypatch.setattr(authorization_service, "require_permission", lambda *args: None) + payload = SourceMaterialsIn(products=[{"product_id": product.id, "category": "municipal_water"}]) + business_source.save_materials(s, first.id, payload, s.get(User, 1)) + for item in items: + s.refresh(item) + assert items[0].product_category == "municipal_water" + assert items[1].product_category is None + + +def test_可平账回款仅按有效封存口径展示(work_session): + s = work_session + dataset(s, cash=True) + sync(s) + business = s.scalar(select(Business)) + receipt = s.scalar(select(Receipt)) + assert business_source.readiness(s, business.id)["eligible_receipts_snapshot"] is None + data = {"input_schema": "TPLUS_SYNC_V1", "shipments": [], + "receipts": [{"amount": "200.00", "voucher_confirmed": True, "exclusion": None}], + "maps": {"shipment_items": {}, "receipts": {str(receipt.id): receipt.id}}} + snapshot = PayrollInput(business_id=business.id, version=1, source_database="test_source", + group_key="frozen", fingerprint="f" * 64, data_json=data, requested_by=1) + s.add(snapshot) + s.commit() + assert Decimal(business_source.readiness(s, business.id)["eligible_receipts_snapshot"]) == 200 + snapshot.data_json = {**data, "receipts": [{"amount": "200.00", "voucher_confirmed": False, + "exclusion": "NOT_GENERATED_VOUCHER"}]} + s.commit() + assert Decimal(business_source.readiness(s, business.id)["eligible_receipts_snapshot"]) == 0 + doc = s.scalar(select(TPlusBusinessDocument).where(TPlusBusinessDocument.entity_type == "receipts")) + doc.status = "CONFLICT" + s.commit() + assert business_source.readiness(s, business.id)["eligible_receipts_snapshot"] is None + + +def test_来源核算准备只要求截止月份回款已确认(work_session, monkeypatch): + from datetime import datetime + + s = work_session + dataset(s, shipping=True, cash=True) + cash = {"idpartner": 8, "idperson": 1, "isReceiveFlag": 1, "amount": "200", + "voucherstate": 189, "accounting_voucher_count": 1, + "valid_accounting_voucher_count": 1, "accounting_voucher_id": 501} + source(s, "receipts", [{**cash, "id": 31, "voucherDate": "2026-03-31"}, + {**cash, "id": 32, "voucherDate": "2026-04-01"}]) + sync(s) + business = s.scalar(select(Business)) + role = s.scalar(select(BusinessRole).where(BusinessRole.role_code == "salesperson")) + person = Personnel(personnel_name="经办人", business_role_id=role.id) + s.add(person) + s.flush() + item = s.scalar(select(ShipmentItem)) + payload = PayrollSetupIn(month="2026-03", evidence="已核实", opening_balance="0", + history_confirmed=True, guidance_finalized=True, + shipment_ownerships=[{"shipment_item_id": item.id, "personnel_id": person.id, + "unit_cost": "1", "freight_amount": "0"}]) + actor = s.get(User, 1) + s.commit() + monkeypatch.setattr(payroll_months, "ensure_allocation_editable", lambda *args: None) + monkeypatch.setattr(business_source, "readiness", lambda *args: {"reasons": []}) + receipts = list(s.scalars(select(Receipt).order_by(Receipt.receipt_date))) + with pytest.raises(BadRequestError, match="人工确认"): + business_source.prepare(s, business.id, payload, actor) + past = s.get(Receipt, receipts[0].id) + past.confirmation_status = 1 + past.confirmed_by = "reviewer" + past.confirmed_at = datetime.now() + past.confirmed_revision = receipt_confirmation.revision(past) + s.commit() + result = business_source.prepare(s, business.id, payload, actor) + snapshot = s.get(PayrollInput, result["input_id"]) + assert len(snapshot.data_json["receipts"]) == 2 + with pytest.raises(BadRequestError, match="人工确认"): + business_source.prepare(s, business.id, payload.model_copy(update={"month": "2026-04", "expected_version": 1}), actor) diff --git a/backend/tests/unit/test_tplus_connection_file.py b/backend/tests/unit/test_tplus_connection_file.py index 15fd8a1..fc2220a 100644 --- a/backend/tests/unit/test_tplus_connection_file.py +++ b/backend/tests/unit/test_tplus_connection_file.py @@ -70,3 +70,25 @@ def test_连接文件不接受sa或应用配置覆盖(tmp_path): ) with pytest.raises(RuntimeError): resolve_tplus_settings(settings) + + +@pytest.mark.parametrize( + ("environment", "allowed", "accepted"), + [("dev", False, False), ("prod", True, False), ("dev", True, True)], +) +def test_sa仅允许显式启用的开发环境(environment, allowed, accepted): + settings = Settings( + _env_file=None, + app_secret_key="x" * 32, + app_env=environment, + t_plus_enabled=True, + t_plus_user="sa", + t_plus_database="test_source", + t_plus_odbc_driver="test_driver", + t_plus_allow_dev_sa=allowed, + ) + if accepted: + assert resolve_tplus_settings(settings).t_plus_user == "sa" + else: + with pytest.raises(RuntimeError, match="不能使用 sa"): + resolve_tplus_settings(settings) diff --git a/backend/tests/unit/test_tplus_fifo.py b/backend/tests/unit/test_tplus_fifo.py index 2e9b6a8..f5a3df6 100644 --- a/backend/tests/unit/test_tplus_fifo.py +++ b/backend/tests/unit/test_tplus_fifo.py @@ -243,9 +243,23 @@ def test_离职交接转给新人员_在职交接保留发货人(): f["handovers"] = [handover] assert run(g, [s], [c], f, p)["lines"][0]["personnel_id"] == "002" handover["mode"] = "ACTIVE" + handover["shipping_ownership_confirmed"] = True assert run(g, [s], [c], f, p)["lines"][0]["personnel_id"] == "001" +def test_在职交接未确认发货归属时不生成绩效(): + g, s, c, f, p = inputs() + handover = {"business_key": "business1", "mode": "ACTIVE", "effective_date": "2026-03-05", + "from_personnel_id": "001", "to_personnel_id": "002", "shipping_ownership_confirmed": False} + f["handovers"] = [handover] + result = run(g, [s], [c], f, p) + assert result["status"] == "PARTIAL" + assert "HANDOVER_SHIPMENTS_PENDING" in result["lines"][0]["reasons"] + assert result["lines"][0]["performance"] is None + handover["business_key"] = "another-business" + assert run(g, [s], [c], f, p)["status"] == "SUCCEEDED" + + def test_输入金额拒绝浮点与无穷(): for value in [1.1, "NaN", "Infinity"]: with pytest.raises(ValueError): diff --git a/backend/tests/unit/test_tplus_guidance_history.py b/backend/tests/unit/test_tplus_guidance_history.py new file mode 100644 index 0000000..ccf689c --- /dev/null +++ b/backend/tests/unit/test_tplus_guidance_history.py @@ -0,0 +1,95 @@ +"""月度指导价检查必须读取同账套的历史封存发货。""" + +from datetime import date +from decimal import Decimal + +import pytest +from sqlalchemy import select + +from app.core.exceptions import BadRequestError +from app.models import ( + Business, + Customer, + PayrollInput, + Product, + Shipment, + ShipmentItem, + TPlusBusinessDocument, + TPlusSourceLink, + User, +) +from app.schemas.business_source import GuidanceConfirmIn +from app.services import business_source +from app.services.tplus.guidance import frozen_guidance_shipments +from tests.unit.test_tplus_business_sync import dataset, sync +from tests.unit.test_tplus_workbench import work_session as work_session + + +def test_登记指导成本校验已应用的发货单版本(work_session, monkeypatch): + session = work_session + dataset(session, shipping=True) + sync(session) + business = session.scalar(select(Business)) + item = session.scalar(select(ShipmentItem)) + actor = session.get(User, 1) + monkeypatch.setattr(business_source, "source_snapshot_ready", lambda *_: True) + payload = GuidanceConfirmIn(shipment_item_id=item.id, amount="2.1234", evidence="核对指导成本") + + created = business_source.confirm_guidance(session, business.id, payload, actor) + assert created["status"] == "CREATED" + assert business_source.confirm_guidance(session, business.id, payload, actor)["status"] == "REUSED" + parent = session.scalar(select(TPlusBusinessDocument).where( + TPlusBusinessDocument.entity_type == "shipments" + )) + line = session.scalar(select(TPlusBusinessDocument).where( + TPlusBusinessDocument.entity_type == "shipment_items" + )) + parent.applied_record_id = line.applied_record_id + session.commit() + with pytest.raises(BadRequestError, match="来源单据已变化"): + business_source.confirm_guidance(session, business.id, payload, actor) + + +def test_只返回来源产品与历史月份匹配的已封存发货(work_session): + session = work_session + customer = Customer(company_name="测试客户", owner_name="", customer_type="personal") + product = Product(product_name="测试产品", spec_model="", unit="根", product_category="municipal_water") + session.add_all([customer, product]) + session.flush() + session.add(TPlusSourceLink(source_database="test_source", entity_type="products", source_id="9", target_id=product.id)) + business = Business(business_no="GUIDE-HISTORY", customer_id=customer.id) + session.add(business) + session.flush() + shipment = Shipment(business_id=business.id, shipment_no="GUIDE-SHIP", ship_date=date(2026, 4, 1)) + session.add(shipment) + session.flush() + item = ShipmentItem(shipment_id=shipment.id, product_id=product.id, product_name="测试产品", spec_model="", + unit="根", quantity=Decimal("2"), amount=Decimal("100")) + session.add(item) + session.flush() + record = {"line_id": "source-line-999", "product_id": "9", "date": "2026-03-10", "unit": "根", "unit_cost": "10"} + session.add_all([ + PayrollInput(business_id=business.id, version=1, source_database="test_source", group_key="g", + fingerprint="a" * 64, data_json={"shipments": [record]}, requested_by=1), + PayrollInput(business_id=business.id, version=2, source_database="test_source", group_key="g", + fingerprint="b" * 64, data_json={"shipments": [{**record, "unit_cost": "11"}]}, requested_by=1), + ]) + session.flush() + rows = list(frozen_guidance_shipments(session, "test_source", "9", "2026-03")) + assert [row["unit_cost"] for row in rows] == ["10", "11"] + assert list(frozen_guidance_shipments(session, "test_source", "9", "2026-04")) == [] + assert list(frozen_guidance_shipments(session, "other_source", "9", "2026-03")) == [] + assert list(frozen_guidance_shipments(session, "test_source", "10", "2026-03")) == [] + session.delete(item) + session.flush() + assert len(list(frozen_guidance_shipments(session, "test_source", "9", "2026-03"))) == 2 + broken = session.get(PayrollInput, 1) + broken.data_json = {"shipments": [{**record, "product_id": None}]} + session.flush() + with pytest.raises(BadRequestError, match="缺少来源产品身份"): + list(frozen_guidance_shipments(session, "test_source", "9", "2026-03")) + broken = session.get(PayrollInput, 1) + broken.data_json = {"shipments": [{"date": "2026-03-10", "unit_cost": "10"}]} + session.flush() + with pytest.raises(BadRequestError, match="已封存发货数据异常"): + list(frozen_guidance_shipments(session, "test_source", "9", "2026-03")) diff --git a/backend/tests/unit/test_tplus_guidance_resolution.py b/backend/tests/unit/test_tplus_guidance_resolution.py new file mode 100644 index 0000000..1210cfa --- /dev/null +++ b/backend/tests/unit/test_tplus_guidance_resolution.py @@ -0,0 +1,103 @@ +"""指导成本解析只认已登记事实:多条候选不猜、单位与封存状态必须显式匹配。""" + +from decimal import Decimal + +import pytest + +from app.core.exceptions import BadRequestError +from app.services.tplus.guidance import check_unit_guidance_conflicts, resolve_guidance + + +class Fact: + """最小事实替身:resolve_guidance 只读取 kind 与 data_json。""" + + def __init__(self, data_json): + self.kind = "GUIDANCE" + self.data_json = data_json + + +def facts_of(*rows): + """按 (kind, fact_key) 组装成 latest_guidance_facts 的返回结构。""" + return {("GUIDANCE", str(index)): Fact(row) for index, row in enumerate(rows)} + + +def final_row(**overrides): + row = { + "product_id": 9, + "month": "2026-03", + "basis": "UNIT", + "unit": "根", + "is_finalized": True, + } + row.update(overrides) + return row + + +def test_没有匹配事实时返回缺失(): + state, guidance = resolve_guidance(facts_of(), 9, "line-1", "2026-03", "根") + assert state == "MISSING" + assert guidance is None + + +def test_多条候选时不选择其中一条(): + rows = facts_of(final_row(), final_row(shipment_line_id="line-1")) + state, guidance = resolve_guidance(rows, 9, "line-1", "2026-03", "根") + assert state == "AMBIGUOUS" + assert guidance is None + + +def test_唯一候选且单位一致时返回已封存事实(): + row = final_row() + state, guidance = resolve_guidance(facts_of(row), 9, "line-1", "2026-03", "根") + assert state == "FINALIZED" + assert guidance is row + + +def test_单位不一致时不采用该事实(): + state, guidance = resolve_guidance(facts_of(final_row(unit="吨")), 9, "line-1", "2026-03", "根") + assert state == "UNIT_MISMATCH" + assert guidance is None + + +def test_未封存事实不能作为指导价依据(): + state, guidance = resolve_guidance(facts_of(final_row(is_finalized=False)), 9, "line-1", "2026-03", "根") + assert state == "UNFINALIZED" + assert guidance is None + + +def test_按明细行登记的指导价只对该行生效(): + rows = facts_of(final_row(basis="LINE", shipment_line_id="line-1")) + assert resolve_guidance(rows, 9, "line-1", "2026-03", "根")[0] == "FINALIZED" + assert resolve_guidance(rows, 9, "line-2", "2026-03", "根")[0] == "MISSING" + + +def test_同一产品同月已有事实时拒绝重复登记(): + with pytest.raises(BadRequestError, match="重叠"): + check_unit_guidance_conflicts(facts_of(final_row()), 9, "2026-03", "根", "10", []) + + +def test_历史封存明细的成本或单位不一致时拒绝(): + with pytest.raises(BadRequestError, match="不一致"): + check_unit_guidance_conflicts( + facts_of(), 9, "2026-03", "根", "10", + [{"unit_cost": "11", "unit": "根"}], + ) + with pytest.raises(BadRequestError, match="不一致"): + check_unit_guidance_conflicts( + facts_of(), 9, "2026-03", "根", "10", + [{"unit_cost": "10", "unit": "吨"}], + ) + + +def test_事实为空且历史一致时放行(): + check_unit_guidance_conflicts( + facts_of(), 9, "2026-03", "根", Decimal("10"), + [{"unit_cost": "10", "unit": "根"}, {"unit_cost": "10.00", "unit": "根"}], + ) + + +def test_金额非法时拒绝(): + for bad in ("abc", "-1", "nan"): + with pytest.raises(BadRequestError, match="不一致"): + check_unit_guidance_conflicts(facts_of(), 9, "2026-03", "根", bad, []) + diff --git a/backend/tests/unit/test_tplus_runtime_control.py b/backend/tests/unit/test_tplus_runtime_control.py new file mode 100644 index 0000000..894692f --- /dev/null +++ b/backend/tests/unit/test_tplus_runtime_control.py @@ -0,0 +1,176 @@ +"""运行控制以实际心跳呈现状态,并保护并发变更。""" + +from datetime import datetime, timedelta +from threading import Event +from unittest.mock import MagicMock + +import pytest +from sqlalchemy import select +from sqlalchemy.exc import SQLAlchemyError + +from app.core.exceptions import ConflictError, ForbiddenError +from app.models import OperationLog, TPlusRuntimeControl, User +from app.schemas.tplus import TPlusRuntimeUpdate +from app.services.tplus import runtime_control, supervisor, task_service +from tests.unit.test_tplus_workbench import work_session as work_session + + +def test_启动请求不冒充运行且冲突受保护(work_session): + session = work_session + session.add(TPlusRuntimeControl(id=1)) + session.commit() + actor = task_service.TaskActor(session.get(User, 1)) + payload = TPlusRuntimeUpdate(desired_state="RUNNING", expected_version=0) + status = runtime_control.save(session, payload, actor) + assert status["desired_state"] == "RUNNING" + assert status["actual_state"] == "OFFLINE" + assert status["version"] == 1 + assert session.scalar(select(OperationLog).where(OperationLog.action == "修改读取服务状态")) + with pytest.raises(ConflictError): + runtime_control.save(session, payload, actor) + assert supervisor.may_claim(session) + session.rollback() + runtime_control.save(session, TPlusRuntimeUpdate(desired_state="STOPPED", expected_version=1), actor) + assert not supervisor.may_claim(session) + + +def test_过期心跳显示离线且权限拒绝(work_session, monkeypatch): + session = work_session + now = datetime(2026, 11, 5, 10) + session.add(TPlusRuntimeControl(id=1, desired_state="RUNNING", actual_state="RUNNING", + worker_phase="RUNNING", heartbeat_at=now - timedelta(seconds=91))) + session.commit() + assert runtime_control.overview(session, now=now)["actual_state"] == "OFFLINE" + def denied(*_args): + raise ForbiddenError() + monkeypatch.setattr(runtime_control, "require_permission", denied) + with pytest.raises(ForbiddenError): + runtime_control.save(session, TPlusRuntimeUpdate(desired_state="STOPPED", expected_version=0), + task_service.TaskActor(session.get(User, 1))) + assert session.get(TPlusRuntimeControl, 1).desired_state == "RUNNING" + + +def test_健康检查拒绝外部地址(): + from app.core.config import Settings + + settings = Settings(_env_file=None, app_secret_key="x" * 32, + t_plus_supervisor_health_url="http://example.com/api/health") + assert supervisor.health_error(settings) == "后端健康检查地址必须是本机 HTTP 地址" + + +@pytest.mark.parametrize("body", [b'[]', b'null', b'{"data":null}', b'{"data":[]}', b'{"data":"bad"}']) +def test_健康检查结构异常时返回未就绪(monkeypatch, body): + from app.core.config import Settings + + settings = Settings(_env_file=None, app_secret_key="x" * 32) + response = MagicMock() + response.__enter__.return_value.read.return_value = body + monkeypatch.setattr(supervisor, "urlopen", lambda *_args, **_kwargs: response) + assert supervisor.health_error(settings) == "系统或依赖尚未就绪" + + +@pytest.mark.parametrize("connected", [True, False]) +def test_健康检查实际探测来源连接且不泄露错误(monkeypatch, connected): + from app.core.config import Settings + + settings = Settings(_env_file=None, app_secret_key="x" * 32, t_plus_enabled=True, + t_plus_database="source", t_plus_user="reader", + t_plus_odbc_driver="test-driver") + response = MagicMock() + response.__enter__.return_value.read.return_value = b'{"data":{"app":true,"db":true,"redis":true}}' + monkeypatch.setattr(supervisor, "urlopen", lambda *_args, **_kwargs: response) + import pyodbc + monkeypatch.setattr(pyodbc, "drivers", lambda: ["test-driver"]) + engine = MagicMock() + if not connected: + engine.connect.side_effect = SQLAlchemyError("secret-connection-string") + monkeypatch.setattr(supervisor, "create_engine", lambda *_args, **_kwargs: engine) + + result = supervisor.health_error(settings) + assert result is None if connected else result == "T+ 数据库连接不可用,请检查连接配置" + if connected: + connection = engine.connect.return_value.__enter__.return_value + assert connection.connection.driver_connection.timeout == settings.t_plus_query_timeout + connection.exec_driver_sql.assert_called_once_with("SELECT 1") + engine.dispose.assert_called_once() + + +def test_来源不可用时托管器不排队或领取任务(monkeypatch): + connection = MagicMock() + connection.dialect.name = "mysql" + connection.execute.return_value.scalar.return_value = 1 + factory = MagicMock() + factory.kw = {"bind": MagicMock(connect=lambda: connection)} + session = factory.return_value.__enter__.return_value + session.scalar.return_value.desired_state = "RUNNING" + stopped = Event() + + def unavailable(_settings): + stopped.set() + return "T+ 数据库连接不可用,请检查连接配置" + + maintenance, worker = MagicMock(), MagicMock() + monkeypatch.setattr(supervisor, "health_error", unavailable) + monkeypatch.setattr(supervisor, "tick", maintenance) + monkeypatch.setattr(supervisor, "run_once", worker) + assert supervisor.run(factory, MagicMock(), stop_event=stopped) == 0 + maintenance.assert_not_called() + worker.assert_not_called() + + +def test_托管检查异常记录类型但不泄露内容(caplog): + connection = MagicMock() + connection.dialect.name = "mysql" + connection.execute.side_effect = [MagicMock(scalar=lambda: 1), RuntimeError("secret-value"), + MagicMock(scalar=lambda: 1)] + factory = MagicMock() + factory.kw = {"bind": MagicMock(connect=lambda: connection)} + assert supervisor.run(factory, MagicMock()) == 1 + assert "RuntimeError" in caplog.text + assert "secret-value" not in caplog.text + + +@pytest.mark.parametrize("release", [None, 0]) +def test_托管锁释放失败时使连接失效(release): + connection = MagicMock() + connection.dialect.name = "mysql" + connection.execute.side_effect = [MagicMock(scalar=lambda: 1), + MagicMock(scalar=lambda: release)] + factory = MagicMock() + factory.kw = {"bind": MagicMock(connect=lambda: connection)} + stopped = Event() + stopped.set() + + assert supervisor.run(factory, MagicMock(), stop_event=stopped) == 0 + connection.invalidate.assert_called_once() + connection.close.assert_called_once() + + +def test_停止状态写入失败仍释放托管锁(): + connection = MagicMock() + connection.dialect.name = "mysql" + connection.execute.side_effect = [MagicMock(scalar=lambda: 1), MagicMock(scalar=lambda: 1)] + factory = MagicMock() + factory.kw = {"bind": MagicMock(connect=lambda: connection)} + factory.return_value.__enter__.side_effect = RuntimeError("数据库暂不可用") + stopped = Event() + stopped.set() + + assert supervisor.run(factory, MagicMock(), stop_event=stopped) == 0 + assert connection.execute.call_count == 2 + connection.invalidate.assert_not_called() + connection.close.assert_called_once() + + +def test_释放托管锁抛错时使连接失效(): + connection = MagicMock() + connection.dialect.name = "mysql" + connection.execute.side_effect = [MagicMock(scalar=lambda: 1), RuntimeError("连接异常")] + factory = MagicMock() + factory.kw = {"bind": MagicMock(connect=lambda: connection)} + stopped = Event() + stopped.set() + + assert supervisor.run(factory, MagicMock(), stop_event=stopped) == 0 + connection.invalidate.assert_called_once() + connection.close.assert_called_once() diff --git a/backend/tests/unit/test_tplus_workbench.py b/backend/tests/unit/test_tplus_workbench.py index 560817b..d7f9164 100644 --- a/backend/tests/unit/test_tplus_workbench.py +++ b/backend/tests/unit/test_tplus_workbench.py @@ -3,11 +3,13 @@ from contextlib import nullcontext from datetime import datetime from decimal import Decimal +from types import SimpleNamespace from uuid import uuid4 import pytest from pydantic import ValidationError from sqlalchemy import DefaultClause, Integer, MetaData, create_engine, func, select, text +from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session from app.models import ( @@ -22,6 +24,7 @@ from app.models import ( TPlusSourceLink, TPlusSyncBatch, TPlusSyncRecord, + TPlusWorkLine, TPlusWorkRun, ) from app.schemas.tplus_workbench import FactIn, WorkCreateIn, WorkQuery @@ -163,6 +166,55 @@ def prepare_person(session): ) +def test_旧基础明细从原快照补名称和工号且不改历史(work_session, monkeypatch): + monkeypatch.setattr(workbench, "history_source_database", lambda s: "test_source") + s = work_session + source(s, "personnel", [{"id": 1, "code": "00001", "name": "旧快照人员", "mobilePhoneNo": "PRIVATE"}]) + run = preview(s, ["personnel"]) + line = s.scalar(select(TPlusWorkLine).where(TPlusWorkLine.run_no == run.run_no)) + original = {k: v for k, v in line.data_json.items() if k not in ("source_name", "source_no")} + line.data_json = original + s.commit() + # 后续采集改名,历史任务仍应使用当时的记录版本。 + source(s, "personnel", [{"id": 1, "code": "00001", "name": "后来改名"}]) + result = workbench.list_lines(s, run.run_no, WorkQuery()).records[0] + assert result["data"]["source_name"] == "旧快照人员" + assert result["data"]["source_no"] == "00001" + assert "mobilePhoneNo" not in result["data"] + assert "PRIVATE" not in str(result) + assert line.data_json == original + report = "".join(workbench.report_csv(s, run.run_no)) + assert "旧快照人员" in report and "00001" in report + assert "后来改名" not in report + + +def test_名单缺失人员显示任务冻结的姓名工号(work_session, monkeypatch): + monkeypatch.setattr(workbench, "history_source_database", lambda s: "test_source") + s = work_session + prepare_person(s) + fact(s, "PERSONNEL", "09999", {"code": "09999", "name": "名单待核对人员", "role_code": "salesperson", "status": 1}) + run = preview(s, ["personnel"]) + result = workbench.list_lines(s, run.run_no, WorkQuery(action="PENDING")).records[0] + assert result["reason"] == "ROSTER_SOURCE_MISSING" + assert result["data"]["source_name"] == "名单待核对人员" + assert result["data"]["source_no"] == "09999" + + +def test_基础明细不使用不匹配实体的快照补名称(work_session, monkeypatch): + monkeypatch.setattr(workbench, "history_source_database", lambda s: "test_source") + s = work_session + source(s, "products", [{"id": 1, "code": "P1", "name": "产品私有名称", "unit_name": "根"}]) + record = s.scalar(select(TPlusSyncRecord).where(TPlusSyncRecord.entity_type == "products")) + source(s, "personnel", [{"id": 1, "code": "00001", "name": "人员"}]) + run = preview(s, ["personnel"]) + line = s.scalar(select(TPlusWorkLine).where(TPlusWorkLine.run_no == run.run_no)) + line.data_json = {"record_id": record.id, "values": {}} + s.commit() + result = workbench.list_lines(s, run.run_no, WorkQuery()).records[0] + assert not result["data"].get("source_name") + assert "产品私有名称" not in "".join(workbench.report_csv(s, run.run_no)) + + def test_关闭源读取仍可查询历史资料和任务(work_session, monkeypatch): from app.core.config import Settings from app.services.tplus import task_service @@ -447,6 +499,55 @@ def test_工作请求幂等且不同意图不复用(work_session): workbench.queue_work(s, payload.model_copy(update={"entity_types": ["customers"]}), 1) +@pytest.mark.parametrize("race", ["same", "different", "unrelated"]) +def test_工作区排队完整性错误只核对一次既有任务(monkeypatch, race): + from app.core.exceptions import BadRequestError, ConflictError + from app.services import authorization_service + + payload = WorkCreateIn(kind="MASTER_PREVIEW", request_key=uuid4(), entity_types=["personnel"]) + found = SimpleNamespace(input_json=payload.model_dump(mode="json", exclude={"request_key"}), + source_database="test_source", requested_by=2 if race == "different" else 1) + results = iter([None, found if race != "unrelated" else None]) + rollbacks = [] + + class Session: + def get(self, *_args): return object() + def scalar(self, _query): return next(results) + def begin_nested(self): return nullcontext() + def add(self, _row): pass + def flush(self): raise IntegrityError("insert", {}, Exception()) + def rollback(self): rollbacks.append(True) + + monkeypatch.setattr(authorization_service, "require_permission", lambda *_args: None) + monkeypatch.setattr(workbench, "_work_source_database", lambda _session: "test_source") + monkeypatch.setattr(workbench, "work_out", lambda row: row) + if race == "same": + assert workbench.queue_work(Session(), payload, 1) is found + else: + with pytest.raises(BadRequestError if race == "different" else ConflictError): + workbench.queue_work(Session(), payload, 1) + assert rollbacks == [True] + + +def test_旧业务预览不可排队执行且历史排队任务不写业务(work_session): + from app.core.exceptions import BadRequestError + from app.models import Business, PayrollInput + + s = work_session + payload = WorkCreateIn(kind="BUSINESS_APPLY", request_key=uuid4(), preview_no="old-preview") + with pytest.raises(BadRequestError, match="旧规则业务预览仅供核对"): + workbench.queue_work(s, payload, 1) + legacy = TPlusWorkRun(run_no=uuid4().hex, request_key=str(uuid4()), source_database="test_source", + requested_by=1, kind="BUSINESS_APPLY", status="QUEUED", input_json={"preview_no": "old-preview"}) + s.add(legacy) + s.commit() + assert workbench.execute_next(s) == legacy.run_no + s.refresh(legacy) + assert (legacy.status, legacy.error_code) == ("FAILED", "LEGACY_BUSINESS_APPLY_DISABLED") + assert s.scalar(select(func.count()).select_from(Business)) == 0 + assert s.scalar(select(func.count()).select_from(PayrollInput)) == 0 + + def test_工作区接口拒绝未登录请求(client): for url in ( "/api/v1/system/tplus/facts", diff --git a/backend/tests/unit/test_tplus_worker_business_sync.py b/backend/tests/unit/test_tplus_worker_business_sync.py new file mode 100644 index 0000000..4824096 --- /dev/null +++ b/backend/tests/unit/test_tplus_worker_business_sync.py @@ -0,0 +1,79 @@ +"""采集与业务接入分别记录结果。""" + +from contextlib import nullcontext +from datetime import date +from types import SimpleNamespace + +import pytest +from sqlalchemy.exc import IntegrityError + +from app.core.exceptions import BadRequestError, ConflictError +from app.services.tplus import business_sync_control, worker + + +def test_自动接入排队失败不改变成功采集状态(monkeypatch): + batch = SimpleNamespace(requested_by=None, source_database="test_source", sync_mode="FULL", status="RUNNING") + session = SimpleNamespace(rollback=lambda: None) + failures = [] + schedule_failures = [] + monkeypatch.setattr(worker.task_service, "get_settings", lambda: object()) + monkeypatch.setattr(worker, "resolve_tplus_settings", lambda _: SimpleNamespace(t_plus_database="test_source")) + monkeypatch.setattr(worker.task_service, "fail_batch", lambda *args: failures.append(args)) + + class SyncService: + def __init__(self, source, target): + pass + + def execute_claimed(self, task): + task.status = "SUCCEEDED" + + def fail_schedule(session): + raise RuntimeError("排队失败") + + monkeypatch.setattr(worker, "TPlusSyncService", SyncService) + monkeypatch.setattr(business_sync_control, "schedule", fail_schedule) + monkeypatch.setattr(business_sync_control, "record_schedule_failure", lambda session, source: schedule_failures.append(source)) + worker._execute(session, batch, lambda: nullcontext(object())) + assert batch.status == "SUCCEEDED" + assert failures == [] + assert schedule_failures == ["test_source"] + + +@pytest.mark.parametrize("race", ["same", "different", "unrelated"]) +def test_业务接入排队完整性错误只核对一次既有任务(monkeypatch, race): + found = SimpleNamespace(kind="BUSINESS_SYNC", requested_by=1, source_database="test_source", + input_json={"start_date": "2026-03-01"}) + if race == "different": + found.requested_by = 2 + results = iter([None, found if race != "unrelated" else None]) + rollbacks = [] + + class Session: + def get(self, *_args): + return object() + + def scalar(self, _query): + return next(results) + + def begin_nested(self): + return nullcontext() + + def add(self, _row): + pass + + def flush(self): + raise IntegrityError("insert", {}, Exception()) + + def rollback(self): + rollbacks.append(True) + + monkeypatch.setattr(business_sync_control, "require_permission", lambda *_args: None) + monkeypatch.setattr(business_sync_control, "history_source_database", lambda _session: "test_source") + monkeypatch.setattr(business_sync_control, "work_out", lambda row: row) + args = (Session(), 1, date(2026, 3, 1), "same-key") + if race == "same": + assert business_sync_control.queue(*args) is found + else: + with pytest.raises(BadRequestError if race == "different" else ConflictError): + business_sync_control.queue(*args) + assert rollbacks == [True] diff --git a/db/migration/V29__receipt_manual_confirmation.sql b/db/migration/V29__receipt_manual_confirmation.sql new file mode 100644 index 0000000..7d9732a --- /dev/null +++ b/db/migration/V29__receipt_manual_confirmation.sql @@ -0,0 +1,10 @@ +-- 独立人工确认,不以已核算、已生成凭证或同步成功回填历史确认。 +ALTER TABLE receipts ADD COLUMN confirmation_status TINYINT NOT NULL DEFAULT 0 COMMENT '人工确认状态:0待确认 1已确认,独立于凭证及核算状态'; +ALTER TABLE receipts ADD COLUMN confirmed_by VARCHAR(64) NULL COMMENT '人工确认人账号快照'; +ALTER TABLE receipts ADD COLUMN confirmed_at DATETIME NULL COMMENT '人工确认时间'; +ALTER TABLE receipts ADD COLUMN confirmed_revision VARCHAR(64) NULL COMMENT '人工确认时回款关键内容指纹'; + +INSERT INTO param_definitions (param_code,param_name,param_group,value_type,dimension_type,default_value,description,sort_order,status) SELECT 'receipt.confirmation.enabled','回款人工确认开关','回款确认','BOOL','NONE','1','默认开启。关闭仅跳过人工确认,继续校验凭证等资格;与工资审核独立。',16,1 WHERE NOT EXISTS (SELECT 1 FROM param_definitions WHERE param_code='receipt.confirmation.enabled'); +INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) SELECT 'business:receipts:confirm','回款管理:人工确认',2,100,1 WHERE NOT EXISTS (SELECT 1 FROM permissions WHERE perm_code='business:receipts:confirm'); +UPDATE permissions p JOIN permissions parent ON parent.perm_code='business:receipts:view' SET p.parent_id=parent.id,p.status=1 WHERE p.perm_code='business:receipts:confirm'; +INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code='business:receipts:confirm' WHERE r.role_code IN ('admin','payroll_maker') AND NOT EXISTS (SELECT 1 FROM role_permissions old WHERE old.role_id=r.id AND old.permission_id=p.id); diff --git a/db/migration/V30__tplus_business_sync.sql b/db/migration/V30__tplus_business_sync.sql new file mode 100644 index 0000000..654c0d2 --- /dev/null +++ b/db/migration/V30__tplus_business_sync.sql @@ -0,0 +1,45 @@ +-- V30:业务接入与核算准备解耦;默认关闭自动接入,不执行回填或工资核算。 +ALTER TABLE products MODIFY product_category VARCHAR(32) NULL COMMENT '核算分类,NULL=待确认'; +ALTER TABLE customers MODIFY customer_type VARCHAR(32) NULL DEFAULT NULL COMMENT '核算客户类型,NULL=待确认'; +ALTER TABLE business_items MODIFY product_category VARCHAR(32) NULL COMMENT '核算分类,NULL=待确认'; +ALTER TABLE shipments MODIFY freight_amount DECIMAL(14,2) NULL DEFAULT NULL COMMENT '运费金额,NULL=来源待核实'; +ALTER TABLE businesses ADD integration_json JSON NULL COMMENT 'T+接入范围及来源人员摘要,非核算快照'; +ALTER TABLE tplus_source_heads ADD source_parent_id VARCHAR(64) NULL COMMENT '源父单据ID查询投影', ADD source_customer_id VARCHAR(64) NULL COMMENT '源客户ID查询投影', ADD source_date VARCHAR(10) NULL COMMENT '源业务日期查询投影'; +UPDATE tplus_source_heads h JOIN tplus_sync_records r ON r.id=h.record_id SET h.source_parent_id=NULLIF(JSON_UNQUOTE(JSON_EXTRACT(r.payload_json,'$.source_parent_id')),'null'), h.source_customer_id=NULLIF(JSON_UNQUOTE(COALESCE(JSON_EXTRACT(r.payload_json,'$.idcustomer'),JSON_EXTRACT(r.payload_json,'$.idpartner'))),'null'), h.source_date=LEFT(NULLIF(JSON_UNQUOTE(COALESCE(JSON_EXTRACT(r.payload_json,'$.voucherdate'),JSON_EXTRACT(r.payload_json,'$.voucherDate'))),'null'),10); +CREATE INDEX idx_tplus_source_heads_parent ON tplus_source_heads(source_database,entity_type,source_parent_id); +CREATE INDEX idx_tplus_source_heads_customer_date ON tplus_source_heads(source_database,entity_type,source_customer_id,source_date); +CREATE INDEX idx_tplus_source_heads_date ON tplus_source_heads(source_database,entity_type,source_date,id); +CREATE TABLE tplus_business_sync_configs ( + id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, + source_database VARCHAR(128) NOT NULL COMMENT '源账套', + enabled INTEGER NOT NULL COMMENT '自动接入是否启用', + start_date DATE NOT NULL COMMENT '业务接入起点', + version INTEGER NOT NULL COMMENT '配置乐观版本', + updated_by BIGINT UNSIGNED NOT NULL COMMENT '授权启用账号', + last_error VARCHAR(64) COMMENT '后台接入暂停原因', + create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, + update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT pk_tplus_business_sync_configs PRIMARY KEY (id), + CONSTRAINT uk_tplus_business_sync_configs_source UNIQUE (source_database) +)ENGINE=InnoDB CHARSET=utf8mb4 COMMENT='T+业务自动接入设置' COLLATE utf8mb4_general_ci; +CREATE TABLE tplus_business_documents ( + id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, + source_database VARCHAR(128) NOT NULL COMMENT '源账套', + entity_type VARCHAR(32) NOT NULL COMMENT '源实体', + source_id VARCHAR(64) NOT NULL COMMENT '源单据或明细ID', + source_no VARCHAR(128) COMMENT '原始单号', + record_id BIGINT UNSIGNED NOT NULL COMMENT '当前观察到的源快照ID', + applied_record_id BIGINT UNSIGNED COMMENT '已接入的源快照ID', + fingerprint VARCHAR(64) COMMENT '已应用的业务字段指纹', + business_id BIGINT UNSIGNED COMMENT '关联业务ID', + target_id BIGINT UNSIGNED COMMENT '正式单据或明细ID', + status VARCHAR(24) NOT NULL COMMENT 'APPLIED/PENDING/CONFLICT/EXCLUDED', + reason VARCHAR(128) COMMENT '固定待补或差异编码', + data_json JSON NOT NULL COMMENT '最小必要来源与差异说明', + create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, + update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT pk_tplus_business_documents PRIMARY KEY (id), + CONSTRAINT uk_tplus_business_documents_source_entity_id UNIQUE (source_database, entity_type, source_id) +)ENGINE=InnoDB CHARSET=utf8mb4 COMMENT='T+业务单据接入与差异账本' COLLATE utf8mb4_general_ci; +CREATE INDEX idx_tplus_business_documents_business_entity ON tplus_business_documents (business_id, entity_type, id); +CREATE INDEX idx_tplus_business_documents_source_status ON tplus_business_documents (source_database, status, id); diff --git a/db/migration/V32__tplus_runtime_control.sql b/db/migration/V32__tplus_runtime_control.sql new file mode 100644 index 0000000..83e090e --- /dev/null +++ b/db/migration/V32__tplus_runtime_control.sql @@ -0,0 +1,17 @@ +CREATE TABLE tplus_runtime_control ( + id BIGINT UNSIGNED NOT NULL, + desired_state VARCHAR(16) NOT NULL DEFAULT 'STOPPED', + actual_state VARCHAR(16) NOT NULL DEFAULT 'STOPPED', + worker_phase VARCHAR(16) NOT NULL DEFAULT 'STOPPED', + heartbeat_at DATETIME NULL, + last_error VARCHAR(255) NULL, + host VARCHAR(128) NULL, + platform VARCHAR(32) NULL, + owner VARCHAR(36) NULL, + version INTEGER NOT NULL DEFAULT 0, + updated_by BIGINT UNSIGNED NULL, + create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT pk_tplus_runtime_control PRIMARY KEY (id) +) ENGINE=InnoDB CHARSET=utf8mb4 COLLATE utf8mb4_general_ci COMMENT='T+读取服务运行控制'; +INSERT INTO tplus_runtime_control (id) VALUES (1); diff --git a/db/migration/V33__pluralize_tplus_runtime_control.sql b/db/migration/V33__pluralize_tplus_runtime_control.sql new file mode 100644 index 0000000..7a4b547 --- /dev/null +++ b/db/migration/V33__pluralize_tplus_runtime_control.sql @@ -0,0 +1,2 @@ +RENAME TABLE tplus_runtime_control TO tplus_runtime_controls; +ALTER TABLE tplus_runtime_controls MODIFY COLUMN id BIGINT UNSIGNED NOT NULL; diff --git a/docker/.env.prod.example b/docker/.env.prod.example new file mode 100644 index 0000000..a3223b1 --- /dev/null +++ b/docker/.env.prod.example @@ -0,0 +1,24 @@ +# 复制为 docker/.env.prod.local,在客户服务器填写;真实文件已被忽略。 +# 含 $、#、反斜线的值请用单引号包围,保留字面内容。 +PAYROLL_VERSION= +PAYROLL_HTTP_PORT=8080 +APP_SECRET_KEY= +MYSQL_ROOT_PASSWORD= +MYSQL_DATABASE=payroll +MYSQL_USER=payroll +MYSQL_PASSWORD= +REDIS_PASSWORD= + +# 工资系统服务器需能访问此现场地址及端口,不能填写容器的 127.0.0.1。 +# 主机名必须与服务器证书匹配,应用镜像需信任客户 CA。 +T_PLUS_HOST= +T_PLUS_PORT=1433 +T_PLUS_DATABASE= +T_PLUS_USER= +T_PLUS_ALLOW_ADMIN_ACCOUNT=false +T_PLUS_PASSWORD= +# 初次上线先验证手动采集;已保存的页面开关优先于这个默认值。 +T_PLUS_AUTOMATION_ENABLED=false +T_PLUS_SCHEDULE_WEEKDAY=0 +T_PLUS_SCHEDULE_TIME=02:00 +PAYROLL_APPROVAL_ENABLED=false diff --git a/docker/docker-compose.prod.yml b/docker/docker-compose.prod.yml new file mode 100644 index 0000000..8379499 --- /dev/null +++ b/docker/docker-compose.prod.yml @@ -0,0 +1,147 @@ +# 只部署工资系统及其 MySQL/Redis;T+ 为客户现场的外部只读 SQL Server。 +# 从仓库根目录执行,显式传 --env-file docker/.env.prod.local。 +name: ys-payroll-prod + +x-app-environment: &app-environment + TZ: Asia/Shanghai + APP_ENV: prod + APP_SECRET_KEY: ${APP_SECRET_KEY:?请设置应用密钥} + MYSQL_HOST: mysql + MYSQL_PORT: "3306" + MYSQL_DATABASE: ${MYSQL_DATABASE:-payroll} + MYSQL_USER: ${MYSQL_USER:-payroll} + MYSQL_PASSWORD: ${MYSQL_PASSWORD:?请设置业务库密码} + REDIS_HOST: redis + REDIS_PORT: "6379" + REDIS_PASSWORD: ${REDIS_PASSWORD:?请设置Redis密码} + REDIS_DB: "0" + T_PLUS_ENABLED: "true" + T_PLUS_CONNECTION_FILE: "" + T_PLUS_HOST: ${T_PLUS_HOST:?请填写客户现场T+服务器DNS名} + T_PLUS_PORT: ${T_PLUS_PORT:-1433} + T_PLUS_DATABASE: ${T_PLUS_DATABASE:?请确认客户账套数据库名} + T_PLUS_USER: ${T_PLUS_USER:?请填写T+连接账号} + T_PLUS_ALLOW_ADMIN_ACCOUNT: ${T_PLUS_ALLOW_ADMIN_ACCOUNT:-false} + T_PLUS_PASSWORD: ${T_PLUS_PASSWORD:?请设置T+只读账号密码} + T_PLUS_ODBC_DRIVER: "ODBC Driver 18 for SQL Server" + T_PLUS_ENCRYPT: "true" + T_PLUS_TRUST_SERVER_CERTIFICATE: "false" + T_PLUS_AUTOMATION_ENABLED: ${T_PLUS_AUTOMATION_ENABLED:-false} + T_PLUS_SCHEDULE_WEEKDAY: ${T_PLUS_SCHEDULE_WEEKDAY:-0} + T_PLUS_SCHEDULE_TIME: ${T_PLUS_SCHEDULE_TIME:-02:00} + PAYROLL_APPROVAL_ENABLED: ${PAYROLL_APPROVAL_ENABLED:-false} + PAYROLL_ALLOW_LEGACY_CALCULATION: "false" + +x-app: &app + image: ys-payroll-backend:${PAYROLL_VERSION:?请填写本次发布版本} + build: + context: .. + dockerfile: backend/Dockerfile + environment: *app-environment + init: true + restart: unless-stopped + stop_grace_period: 15m + depends_on: + mysql: + condition: service_healthy + redis: + condition: service_healthy + logging: &bounded-logging + driver: json-file + options: + max-size: "10m" + max-file: "3" + +services: + mysql: + image: mysql:8.0 + restart: unless-stopped + environment: + TZ: Asia/Shanghai + MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:?请设置数据库管理密码} + MYSQL_DATABASE: ${MYSQL_DATABASE:-payroll} + MYSQL_USER: ${MYSQL_USER:-payroll} + MYSQL_PASSWORD: ${MYSQL_PASSWORD:?请设置业务库密码} + command: ["--character-set-server=utf8mb4", "--collation-server=utf8mb4_general_ci"] + volumes: + - mysql-data:/var/lib/mysql + healthcheck: + test: ["CMD-SHELL", "MYSQL_PWD=\"$$MYSQL_PASSWORD\" mysql --host=127.0.0.1 --user=\"$$MYSQL_USER\" --database=\"$$MYSQL_DATABASE\" --execute='SELECT 1' >/dev/null"] + interval: 10s + timeout: 5s + retries: 12 + start_period: 60s + logging: *bounded-logging + + redis: + image: redis:7.4-alpine + restart: unless-stopped + user: redis + environment: + REDIS_PASSWORD: ${REDIS_PASSWORD:?请设置Redis密码} + # 仅将口令的 SHA-256 放入 Redis ACL,避免口令进入服务进程参数。 + command: + - /bin/sh + - -ec + - | + password_hash=$$(printf '%s' "$$REDIS_PASSWORD" | sha256sum | cut -d ' ' -f 1) + umask 077 + printf 'appendonly yes\nuser default reset on #%s ~* &* +@all\n' "$$password_hash" > /tmp/payroll-redis.conf + exec redis-server /tmp/payroll-redis.conf + init: true + volumes: + - redis-data:/data + healthcheck: + test: ["CMD-SHELL", "REDISCLI_AUTH=\"$$REDIS_PASSWORD\" redis-cli --no-auth-warning ping | grep -qx PONG"] + interval: 10s + timeout: 5s + retries: 12 + logging: *bounded-logging + + api: + <<: *app + volumes: + - uploads:/app/backend/uploads + - api-logs:/app/backend/logs + healthcheck: + # /api/health 的 HTTP 200 不代表依赖正常,需要检查 data 三项。 + test: ["CMD", "python", "-c", "import json, urllib.request; d=json.load(urllib.request.urlopen('http://127.0.0.1:8000/api/health', timeout=5))['data']; raise SystemExit(0 if all(d.get(k) is True for k in ('app','db','redis')) else 1)"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 30s + + supervisor: + <<: *app + command: ["python", "-m", "app.cli", "tplus-supervisor"] + network_mode: "service:api" + volumes: + - uploads:/app/backend/uploads + depends_on: + api: + condition: service_healthy + + web: + image: ys-payroll-web:${PAYROLL_VERSION:?请填写本次发布版本} + build: + context: .. + dockerfile: frontend/Dockerfile + restart: unless-stopped + ports: + - "127.0.0.1:${PAYROLL_HTTP_PORT:-8080}:80" + depends_on: + api: + condition: service_healthy + logging: *bounded-logging + + migrate: + <<: *app + profiles: ["tools"] + restart: "no" + command: ["alembic", "upgrade", "head"] + +volumes: + mysql-data: + redis-data: + uploads: + api-logs: diff --git a/docker/nginx.prod.conf b/docker/nginx.prod.conf new file mode 100644 index 0000000..a03c06e --- /dev/null +++ b/docker/nginx.prod.conf @@ -0,0 +1,24 @@ +server { + listen 80; + server_name _; + root /usr/share/nginx/html; + index index.html; + server_tokens off; + client_max_body_size 10m; + + # 健康探针仅通过容器内部访问,不经公开网关暴露依赖状态。 + location = /api/health { return 404; } + + location /api/ { + proxy_pass http://api:8000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 120s; + } + + location / { + try_files $uri $uri/ /index.html; + } +} diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..b52fc7a --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,12 @@ +# 从仓库根目录构建,生产前端通过同源 /api 访问后端。 +FROM node:22-bookworm-slim AS build +WORKDIR /app +RUN npm install --global pnpm@10.11.0 +COPY frontend/package.json frontend/pnpm-lock.yaml ./ +RUN pnpm install --frozen-lockfile +COPY frontend/ ./ +RUN pnpm build + +FROM nginx:1.28-alpine +COPY docker/nginx.prod.conf /etc/nginx/conf.d/default.conf +COPY --from=build /app/dist /usr/share/nginx/html diff --git a/frontend/e2e/batch-categories.mjs b/frontend/e2e/batch-categories.mjs new file mode 100644 index 0000000..5c400e1 --- /dev/null +++ b/frontend/e2e/batch-categories.mjs @@ -0,0 +1,62 @@ +/** 使用隔离模拟接口验证真实页面的批量分类交互。 */ +import assert from 'node:assert/strict' +import { createRequire } from 'node:module' + +const require = createRequire(import.meta.url) +const { chromium } = require(process.env.PLAYWRIGHT_MODULE || 'C:/Users/Administrator/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright') +const base = process.env.OPS_UI_URL || 'http://127.0.0.1:5173' +const calls = [] +const state = { customers: null, products: null, owner: null } +const browser = await chromium.launch({ headless: true, channel: 'msedge' }) +try { + const page = await browser.newPage({ viewport: { width: 1440, height: 900 } }) + const errors = [] + page.on('pageerror', error => errors.push(error.message)) + await page.addInitScript(() => localStorage.setItem('ys_token', 'synthetic-batch-ui')) + await page.route(url => url.pathname.startsWith('/api/'), async route => { + const req = route.request(), url = new URL(req.url()), path = url.pathname.replace('/api/v1', '') + let data = [] + if (path === '/auth/me') data = { id: 1, username: 'ui-test', roles: ['admin'], permissions: ['base:customers:view', 'base:customers:update', 'base:customers:ownership', 'base:products:view', 'base:products:update'] } + else if (path.endsWith('/batch-ownership')) { + const payload = req.postDataJSON(); calls.push({kind: 'ownership', ...payload}); state.owner = payload.new_owner_personnel_id; data = {updated: payload.ids.length} + } + else if (path.endsWith('/batch-category')) { + const payload = req.postDataJSON(), kind = path.includes('customers') ? 'customers' : 'products' + calls.push({kind, ...payload}); state[kind] = payload.category; data = { updated: payload.ids.length } + } else if (path === '/base/customers') { + data = {list: [{id: 11, company_name: '测试待分类客户', owner_name: '', customer_type: state.customers, owner_personnel_id: state.owner, owner_personnel_name: state.owner ? '测试归属人' : null, rating_score: null}], total: 1, pageNum: 1, pageSize: 20} + } else if (path === '/base/products') { + data = {list: [{id: 12, product_name: '测试待分类产品', spec_model: '', unit: '件', product_category: state.products, status: 1, stock_quantity: null, sale_price: null}], total: 1, pageNum: 1, pageSize: 20} + } else if (path.includes('/meta/')) data = url.searchParams.get('resource') === 'personnel' ? [{id: 21, personnel_name: '测试归属人', status: 1}] : [] + await route.fulfill({ json: { code: 200, message: 'success', data } }) + }) + for (const [kind, label, category, id] of [['customers', '测试待分类客户', '企业户', 11], ['products', '测试待分类产品', '市政水利', 12]]) { + await page.goto(`${base}/base/${kind}`) + const row = page.getByRole('row').filter({hasText: label}) + await row.waitFor() + await row.locator('label.el-checkbox').click() + await page.locator('.toolbar .el-select').first().click() + await page.getByRole('option', { name: category, exact: true }).click() + await page.getByRole('button', {name: '批量补充分类(1)'}).click() + await page.getByRole('dialog').getByRole('button', {name: '确定'}).click() + await row.getByText(category, {exact: true}).waitFor() + assert.equal(calls.at(-1).kind, kind) + assert.deepEqual(calls.at(-1).ids, [id]) + } + await page.goto(`${base}/base/customers`) + const customer = page.getByRole('row').filter({hasText: '测试待分类客户'}) + await customer.waitFor() + await customer.locator('label.el-checkbox').click() + await page.locator('.toolbar .el-select').nth(1).click() + await page.getByRole('option', {name: '测试归属人', exact: true}).click() + await page.getByRole('button', {name: '批量补充归属(1)'}).click() + await page.getByRole('dialog').getByRole('button', {name: '确定'}).click() + await customer.getByText('测试归属人', {exact: true}).waitFor() + assert.equal(calls.at(-1).kind, 'ownership') + assert.deepEqual(calls.at(-1).ids, [11]) + assert.equal(calls.at(-1).new_owner_personnel_id, 21) + assert.deepEqual(errors, []) + process.stdout.write('客户和产品批量分类及客户批量归属页面交互通过(模拟接口)。\n') +} finally { + await browser.close() +} diff --git a/frontend/e2e/business-sync-date.mjs b/frontend/e2e/business-sync-date.mjs new file mode 100644 index 0000000..217c575 --- /dev/null +++ b/frontend/e2e/business-sync-date.mjs @@ -0,0 +1,63 @@ +/** 接入日期草稿与设置刷新的浏览器回归,接口全部隔离。 */ +import assert from 'node:assert/strict' +import path from 'node:path' +import { createRequire } from 'node:module' +import { fileURLToPath } from 'node:url' +import vue from '@vitejs/plugin-vue' +import { createServer } from 'vite' + +const require = createRequire(import.meta.url) +const { chromium } = require(process.env.PLAYWRIGHT_MODULE || 'C:/Users/Administrator/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright') +const root = fileURLToPath(new URL('../', import.meta.url)) +const id = 'virtual:business-sync-date' +const harness = `import {createApp,h} from 'vue';import {createPinia} from 'pinia';import ElementPlus from 'element-plus';import 'element-plus/dist/index.css';import Panel from '/src/views/system/tplus/BusinessSyncPanel.vue';import {useUserStore} from '/src/store/user.ts';const pinia=createPinia();createApp({setup(){useUserStore().userInfo={permissions:['tplus:view','tplus:import']};return()=>h(Panel)}}).use(pinia).use(ElementPlus).mount('#app')` +const server = await createServer({ configFile: false, root, + resolve: { alias: { '@': path.join(root, 'src') } }, + define: { 'import.meta.env.VITE_API_BASE_URL': JSON.stringify('/api') }, + cacheDir: path.resolve(root, '../.ai/business-sync-date-browser/vite-cache'), + plugins: [vue(), { name: 'business-sync-date-proof', resolveId: value => value === id ? '\0' + id : undefined, + load: value => value === '\0' + id ? harness : undefined, + configureServer(instance) { instance.middlewares.use('/__business-sync-date', async (_request, response) => { + response.setHeader('Content-Type', 'text/html; charset=utf-8') + response.end(await instance.transformIndexHtml('/__business-sync-date', '
')) + }) }, + }], server: { host: '127.0.0.1', port: 0 }, optimizeDeps: { include: ['vue', 'element-plus', 'pinia'] } }) +let browser +let settings = { enabled: false, start_date: '2026-03-01', version: 1, editable: true, last_error: null } +try { + await server.listen() + browser = await chromium.launch({ headless: true, channel: 'msedge' }) + const page = await browser.newPage() + const errors = [] + page.on('pageerror', error => errors.push(error.message)) + await page.route(url => url.pathname.startsWith('/api/'), async route => { + const pathname = new URL(route.request().url()).pathname + const data = pathname.endsWith('/business-sync') ? settings : pathname.endsWith('/work-runs') ? { list: [], total: 0 } : null + assert.ok(data, '未预期接口:' + pathname) + await route.fulfill({ json: { code: 200, message: 'success', data } }) + }) + await page.goto(`http://127.0.0.1:${server.httpServer.address().port}/__business-sync-date`) + const input = page.locator('.el-date-editor input') + await input.waitFor() + await page.getByText('未开启', { exact: true }).waitFor() + await input.fill('2026-04-01') + await input.press('Tab') + await Promise.all([ + page.waitForResponse(response => new URL(response.url()).pathname.endsWith('/work-runs')), + page.getByRole('button', { name: '刷新', exact: true }).click(), + ]) + assert.equal(await input.inputValue(), '2026-04-01', '刷新应保留正在填写的日期') + settings = { ...settings, enabled: true, start_date: '2026-05-01', version: 2 } + await Promise.all([ + page.waitForResponse(response => new URL(response.url()).pathname.endsWith('/work-runs')), + page.getByRole('button', { name: '刷新', exact: true }).click(), + ]) + await page.getByText('已开启', { exact: true }).waitFor() + assert.equal(await input.inputValue(), '2026-05-01', '远程开启后应显示生效日期') + assert.equal(await input.isDisabled(), true) + assert.deepEqual(errors, []) + console.log('接入日期草稿、刷新与远程启用验证通过') +} finally { + if (browser) await browser.close() + await server.close() +} diff --git a/frontend/e2e/correction-pairs.mjs b/frontend/e2e/correction-pairs.mjs new file mode 100644 index 0000000..133e639 --- /dev/null +++ b/frontend/e2e/correction-pairs.mjs @@ -0,0 +1,90 @@ +/** 隔离渲染真实更正单组件,合成接口仅用于交互与布局验证。 */ +import assert from 'node:assert/strict' +import fs from 'node:fs' +import path from 'node:path' +import { createRequire } from 'node:module' +import { fileURLToPath } from 'node:url' +import vue from '@vitejs/plugin-vue' +import { createServer } from 'vite' + +const require = createRequire(import.meta.url) +const { chromium } = require(process.env.PLAYWRIGHT_MODULE || 'C:/Users/Administrator/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright') +const root = fileURLToPath(new URL('../', import.meta.url)) +const output = path.resolve(root, '../.ai/correction-pairs-browser') +fs.mkdirSync(output, { recursive: true }) +const id = 'virtual:correction-pairs' +const harness = ` +import {createApp,h,ref} from 'vue' +import {createPinia} from 'pinia' +import ElementPlus from 'element-plus' +import 'element-plus/dist/index.css' +import '/src/assets/styles/index.css' +import Dialog from '/src/views/system/tplus/CorrectionPairDialog.vue' +import {useUserStore} from '/src/store/user.ts' +const pinia=createPinia() +createApp({setup(){const open=ref(false);const user=useUserStore();user.userInfo={permissions:['tplus:facts']};return ()=>h('main',[ +h('button',{onClick:()=>open.value=true},'打开更正处理'), +h('button',{onClick:()=>user.userInfo={permissions:[]}},'切换只读'), +h(Dialog,{modelValue:open.value,'onUpdate:modelValue':v=>open.value=v})])}}).use(pinia).use(ElementPlus).mount('#app')` +const server = await createServer({ configFile: false, root, + resolve: { alias: { '@': path.join(root, 'src') } }, + define: { 'import.meta.env.VITE_API_BASE_URL': JSON.stringify('/api') }, + cacheDir: path.join(output, 'vite-cache'), + plugins: [vue(), { name: 'correction-pair-proof', resolveId: value => value === id ? '\0' + id : undefined, + load: value => value === '\0' + id ? harness : undefined, + configureServer(instance) { instance.middlewares.use('/__correction-proof', async (_request, response) => { + response.setHeader('Content-Type', 'text/html; charset=utf-8') + response.end(await instance.transformIndexHtml('/__correction-proof', '')) + }) }, + }], server: { host: '127.0.0.1', port: 0 }, optimizeDeps: { include: ['vue', 'element-plus', 'pinia'] } }) +let browser +const errors = [], saves = [], checks = [] +try { + await server.listen() + browser = await chromium.launch({ headless: true, channel: 'msedge' }) + const page = await browser.newPage({ viewport: { width: 1440, height: 900 }, reducedMotion: 'reduce' }) + page.on('pageerror', error => errors.push(error.message)) + await page.route(url => url.pathname.startsWith('/api/'), async route => { + const request = route.request(), url = new URL(request.url()) + let data + if (url.pathname.endsWith('/correction-pairs')) { saves.push(request.postDataJSON()); data = { pair_key: 'synthetic' } } + else if (url.pathname.endsWith('/facts')) data = { list: [{ fact_key: 'original', data: { customer_id: '8' } }], total: 1 } + else if (url.pathname.endsWith('/source-records')) data = { list: url.searchParams.get('entity') === 'receipts' + ? [{ source_id: '30', source_no: 'B-CASH', date: '2026-03-10' }] + : [{ source_id: '30', source_no: 'A-NEG', date: '2026-03-10' }, { source_id: '40', source_no: 'B-POS', date: '2026-03-10' }], total: 2 } + else throw new Error('未预期接口:' + url.pathname) + return route.fulfill({ json: { code: 200, message: 'success', data } }) + }) + await page.goto(`http://127.0.0.1:${server.httpServer.address().port}/__correction-proof`) + await page.getByRole('button', { name: '打开更正处理' }).click() + const dialog = page.getByRole('dialog', { name: '转客户更正单处理' }) + await dialog.waitFor() + await dialog.getByRole('button', { name: '确认成对处理' }).click() + assert.equal(saves.length, 0) + checks.push('必填资料不完整时阻止提交') + for (const width of [1440, 768, 390]) { + await page.setViewportSize({ width, height: 900 }) + await page.screenshot({ path: path.join(output, `pair-${width}.png`), fullPage: true }) + assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth), false) + } + checks.push('1440、768、390 宽度无页面横向溢出') + await page.setViewportSize({ width: 1440, height: 900 }) + for (const [label, option] of [['负数更正单', 'A-NEG'], ['正数更正单', 'B-POS'], ['原业务平账组', 'original'], ['实际回款', 'B-CASH']]) { + await dialog.getByRole('combobox', { name: label, exact: true }).locator('xpath=ancestor::div[contains(@class,"el-select__wrapper")][1]').click() + await page.getByRole('option').filter({ hasText: option }).click() + await page.keyboard.press('Escape') + } + await dialog.getByRole('textbox', { name: '更正处理依据' }).fill('合成转客户例单已核对') + await dialog.getByRole('button', { name: '确认成对处理' }).click() + await dialog.waitFor({ state: 'hidden' }) + assert.deepEqual(saves[0], { negative_shipment_id: '30', positive_shipment_id: '40', receipt_ids: ['30'], target_group_key: 'original', evidence: '合成转客户例单已核对' }) + checks.push('成对选单、同 ID 不同实体、实际回款和原业务完整提交') + await page.getByRole('button', { name: '切换只读' }).click() + await page.getByRole('button', { name: '打开更正处理' }).click() + await dialog.waitFor() + assert.equal(await dialog.getByRole('button', { name: '确认成对处理' }).isDisabled(), true) + checks.push('无事实维护权限时禁用提交') + assert.deepEqual(errors, []) + fs.writeFileSync(path.join(output, 'result.json'), JSON.stringify({ checks, errors }, null, 2)) + console.log(JSON.stringify({ checks, errors })) +} finally { await browser?.close(); await server.close() } diff --git a/frontend/e2e/sales-relations.mjs b/frontend/e2e/sales-relations.mjs new file mode 100644 index 0000000..654f545 --- /dev/null +++ b/frontend/e2e/sales-relations.mjs @@ -0,0 +1,92 @@ +/** 人工关系页面使用模拟接口验证交互,不修改业务库。 */ +import assert from 'node:assert/strict' +import path from 'node:path' +import { createRequire } from 'node:module' +import { fileURLToPath } from 'node:url' +import vue from '@vitejs/plugin-vue' +import { createServer } from 'vite' + +const require = createRequire(import.meta.url) +const { chromium } = require(process.env.PLAYWRIGHT_MODULE || 'C:/Users/Administrator/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright') +const root = fileURLToPath(new URL('../', import.meta.url)) +const id = 'virtual:sales-relations-proof' +const harness = `import {createApp,h} from 'vue';import {createPinia,setActivePinia} from 'pinia';import ElementPlus from 'element-plus';import 'element-plus/dist/index.css';import Review from '/src/views/business/businesses/SalesRelationReview.vue';import {useUserStore} from '/src/store/user';const pinia=createPinia();setActivePinia(pinia);useUserStore().userInfo={permissions:location.search?[]:['business:businesses:update','payroll:prepare']};createApp({render:()=>h(Review,{businessId:12})}).use(pinia).use(ElementPlus).mount('#app')` +const server = await createServer({ configFile: false, root, + resolve: { alias: { '@': path.join(root, 'src') } }, + define: { 'import.meta.env.VITE_API_BASE_URL': JSON.stringify('/api') }, + plugins: [vue(), { name: 'sales-relations-proof', resolveId: value => value === id ? '\0' + id : undefined, + load: value => value === '\0' + id ? harness : undefined, + configureServer(instance) { instance.middlewares.use('/__relations-proof', async (_request, response) => { + response.setHeader('Content-Type', 'text/html; charset=utf-8') + response.end(await instance.transformIndexHtml('/__relations-proof', '')) + }) }, + }], server: { host: '127.0.0.1', port: 0 } }) + +let browser, confirmation = null, version = 0, saved = null, failNext = false +try { + await server.listen() + browser = await chromium.launch({ headless: true, channel: 'msedge' }) + const page = await browser.newPage({ viewport: { width: 1024, height: 768 } }) + await page.route(url => url.pathname.startsWith('/api/'), async route => { + const url = new URL(route.request().url()) + if (route.request().method() === 'PUT') { + const input = route.request().postDataJSON() + saved = input + version++ + const released = url.pathname.endsWith('/release') + confirmation = { disposition: released ? 'RELEASED' : input.disposition, evidence: input.evidence, current: !released, + shipment: released ? null : { source_id: 'SHIP1', source_no: '发货1' }, + order: released ? null : { source_id: 'ORDER2', source_no: '订单2' } } + return route.fulfill({ json: { code: 200, message: 'success', data: { item_id: 1, version } } }) + } + if (failNext) { + failNext = false + return route.fulfill({ status: 500, json: { code: 500, message: '加载失败' } }) + } + const shipPage = Number(url.searchParams.get('shipment_page')) + const orderPage = Number(url.searchParams.get('order_page')) + const item = { item_id: 1, product_name: '测试产品', spec: '规格', quantity: '2', amount: '200', item_revision: 'a'.repeat(64), version, confirmation, + shipments: [{ document_id: shipPage, source_id: `SHIP${shipPage}`, source_no: `发货${shipPage}`, date: '2026-03-10', revision: 'b'.repeat(64) }], + shipments_truncated: shipPage === 1, + orders: [{ document_id: 100 + orderPage, source_id: `ORDER${orderPage}`, quantity: '2', amount: '200', revision: 'c'.repeat(64) }], orders_truncated: orderPage === 1 } + await route.fulfill({ json: { code: 200, message: 'success', data: { list: [item], total: 1 } } }) + }) + await page.goto(`http://127.0.0.1:${server.httpServer.address().port}/__relations-proof`) + await page.getByText('测试产品').waitFor() + const row = page.locator('.relation-row') + await row.locator('.el-select').nth(0).click() + await page.getByRole('option', { name: /发货1/ }).click() + await row.locator('.el-select').nth(1).click() + await page.getByRole('option', { name: '对应订单发货' }).click() + await row.getByRole('button', { name: '下一页' }).nth(1).click() + await page.getByText('订单候选第 2 页').waitFor() + await row.locator('.el-select').nth(2).click() + await page.getByRole('option', { name: /ORDER2/ }).click() + await row.locator('input[placeholder="填写依据"]').fill('原单核对') + await row.getByRole('button', { name: '保存核对' }).click() + await page.getByText(/已确认/).waitFor() + assert.match(await row.innerText(), /发货1.*SHIP1.*订单2.*ORDER2/) + assert.equal(saved.shipment_document_id, 1) + assert.equal(saved.order_document_id, 102) + assert.equal(saved.evidence, '原单核对') + await row.getByRole('button', { name: '撤销核对' }).click() + await page.getByPlaceholder('填写撤销依据').fill('重新核实') + await page.getByRole('button', { name: '撤销核对' }).last().click() + await page.getByText(/已撤销/).waitFor() + assert.equal(saved.expected_version, 1) + assert.equal(saved.evidence, '重新核实') + await page.setViewportSize({ width: 375, height: 812 }) + assert.ok(await page.locator('.relation-row').evaluate(el => el.getBoundingClientRect().right <= innerWidth)) + await page.goto(`http://127.0.0.1:${server.httpServer.address().port}/__relations-proof?no-permission=1`) + await page.getByText('测试产品').waitFor() + assert.equal(await page.getByRole('button', { name: '保存核对' }).isDisabled(), true) + failNext = true + await page.reload() + await page.getByText('列表加载失败,请重试').waitFor() + await page.getByRole('button', { name: '重新加载' }).click() + await page.getByText('测试产品').waitFor() + process.stdout.write('人工关系跨页选择、确认、撤销、权限、失败重试及窄屏的模拟浏览器回归通过。\n') +} finally { + await browser?.close() + await server.close() +} diff --git a/frontend/e2e/source-documents.mjs b/frontend/e2e/source-documents.mjs new file mode 100644 index 0000000..3f99da2 --- /dev/null +++ b/frontend/e2e/source-documents.mjs @@ -0,0 +1,133 @@ +/** 来源业务详情加载、错误及重试;接口为隔离模拟。 */ +import assert from 'node:assert/strict' +import path from 'node:path' +import { createRequire } from 'node:module' +import { fileURLToPath } from 'node:url' +import vue from '@vitejs/plugin-vue' +import { createServer } from 'vite' + +const require = createRequire(import.meta.url) +const { chromium } = require(process.env.PLAYWRIGHT_MODULE || 'C:/Users/Administrator/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright') +const root = fileURLToPath(new URL('../', import.meta.url)) +const id = 'virtual:source-documents-proof' +const harness = `import {createApp,h} from 'vue';import {createPinia} from 'pinia';import ElementPlus from 'element-plus';import 'element-plus/dist/index.css';import Source from '/src/views/business/businesses/SourceDocuments.vue';import {useUserStore} from '/src/store/user.ts';const pinia=createPinia();createApp({setup(){window.__sourceUser=useUserStore();window.__sourceUser.userInfo={permissions:['business:businesses:update','payroll:prepare']};return()=>h(Source,{businessId:12})}}).use(pinia).use(ElementPlus).mount('#app')` +const server = await createServer({ configFile: false, root, + resolve: { alias: { '@': path.join(root, 'src') } }, + define: { 'import.meta.env.VITE_API_BASE_URL': JSON.stringify('/api') }, + plugins: [vue(), { name: 'source-documents-proof', resolveId: value => value === id ? '\0' + id : undefined, + load: value => value === '\0' + id ? harness : undefined, + configureServer(instance) { instance.middlewares.use('/__source-proof', async (_request, response) => { + response.setHeader('Content-Type', 'text/html; charset=utf-8') + response.end(await instance.transformIndexHtml('/__source-proof', '')) + }) }, + }], server: { host: '127.0.0.1', port: 0 } }) + +let browser, failNext = false, failDetail = false, failQuantity = false, repaired = false, repairRequests = 0 +try { + await server.listen() + browser = await chromium.launch({ headless: true, channel: 'msedge' }) + const page = await browser.newPage() + await page.route(url => url.pathname.startsWith('/api/'), async route => { + const url = new URL(route.request().url()) + if (url.pathname.endsWith('/missing-price')) { + repairRequests++ + const body = route.request().postDataJSON() + assert.equal(body.revision, 'a'.repeat(64)) + assert.equal(body.evidence, '已核对原单') + repaired = true + return route.fulfill({ json: { code: 200, message: 'success', data: { document_id: 2, parent_status: 'APPLIED' } } }) + } + if (url.pathname.endsWith('/source-quantities') && failQuantity) { + failQuantity = false + return route.fulfill({ status: 500, json: { code: 500, message: '加载失败' } }) + } + if (url.pathname.endsWith('/details') && failDetail) { + failDetail = false + return route.fulfill({ status: 500, json: { code: 500, message: '加载失败' } }) + } + if (url.pathname.endsWith('/source-documents') && failNext) { + failNext = false + return route.fulfill({ status: 500, json: { code: 500, message: '加载失败' } }) + } + const data = url.pathname.endsWith('/details') + ? { list: [ + { source_id: '12', state: 'CHANGED', before: { product_id: '9', quantity: '2', amount: '100', price: '50', price_type: '含税' }, after: { product_id: '9', quantity: '3', amount: '150', price: '50', price_type: '未税' }, changes: [{ field: 'quantity', label: '数量', before: '2', after: '3' }] }, + { source_id: '13', state: 'ADDED', before: null, after: { product_id: '9', quantity: '1', amount: '50', price: '10.12345', price_type: '未税' }, changes: [] }, + ], total: 2 } + : url.pathname.endsWith('/readiness') + ? { status: 'PENDING', reasons: [], customer_type: 'personal', products: [], products_total: 0, eligible_receipts_snapshot: null } + : url.pathname.endsWith('/source-quantities') + ? { list: [{ product_id: 9, product_name: '测试管材', spec_model: 'DN300', unit: '根', order_quantity: '10.00', shipment_quantity: '4.00' }], total: 1 } + : { list: [{ id: 1, entity: 'businesses', source_no: 'SO-001', current_source_no: 'SO-002', status: 'CONFLICT', + changes: [{ field: 'amount', label: '金额', before: '100', after: '200' }] }, + { id: 2, entity: 'shipment_items', source_no: 'L-1', snapshot_state: 'CHANGED', price: '10.1234', price_type: 'TAX_EXCLUDED', price_repair_revision: repaired ? null : 'a'.repeat(64), changes: [] }, + { id: 3, entity: 'shipment_items', source_no: 'L-2', snapshot_state: 'MISSING', price: '12.3400', price_type: 'TAX_INCLUDED', changes: [] }, + { id: 4, entity: 'shipment_items', source_no: 'L-3', snapshot_state: 'UNAVAILABLE', price: '5.00', changes: [] }, + { id: 5, entity: 'receipts', source_no: 'RC-001', status: 'CONFLICT', reason: 'PENDING_SOURCE_CUSTOMER_CHANGED', + changes: [{ field: 'customer_id', label: '客户标识', before: '8', after: '10' }] }, + { id: 6, entity: 'receipts', source_no: 'RC-002', status: 'CONFLICT', reason: 'PENDING_SOURCE_OUT_OF_RANGE', + changes: [{ field: 'date', label: '日期', before: '2026-03-14', after: '2026-02-28' }] }], total: 21 } + await route.fulfill({ json: { code: 200, message: 'success', data } }) + }) + await page.goto(`http://127.0.0.1:${server.httpServer.address().port}/__source-proof`) + await page.getByText('SO-001').waitFor() + await page.getByText('SO-002').waitFor() + await page.getByText('10.1234(未税)').waitFor() + await page.getByText('12.3400(含税)').waitFor() + await page.getByText('5.00(税别待确认)').waitFor() + await page.evaluate(() => { window.__sourceUser.userInfo.permissions = ['business:businesses:update'] }) + assert.equal(await page.getByRole('button', { name: '核对单价' }).count(), 0) + await page.evaluate(() => { window.__sourceUser.userInfo.permissions = ['business:businesses:update', 'payroll:prepare'] }) + await page.getByRole('button', { name: '核对单价' }).click() + await page.getByRole('button', { name: '取消' }).click() + assert.equal(repairRequests, 0) + await page.getByRole('button', { name: '核对单价' }).click() + await page.getByPlaceholder('填写核对依据').fill('已核对原单') + await page.getByRole('button', { name: '确认补齐' }).click() + await page.getByText('来源单价已补齐').waitFor() + assert.equal(repairRequests, 1) + const quantityRow = page.getByRole('row').filter({ hasText: '测试管材' }) + await quantityRow.getByText('10.00', { exact: true }).waitFor() + await quantityRow.getByText('4.00', { exact: true }).waitFor() + await page.getByText('金额:100 → 200').waitFor() + await page.getByText('待核对单据的来源客户已变化,原关联已保留,请核对原单').waitFor() + await page.getByText('客户标识:8 → 10').waitFor() + await page.getByText('待核对单据的来源日期已早于接入起点,原关联已保留,请核对原单').waitFor() + await page.getByText('日期:2026-03-14 → 2026-02-28').waitFor() + failQuantity = true + await page.reload() + await page.getByText('SO-001').waitFor() + await page.locator('.quantity-section').getByText('列表加载失败,请重试').waitFor() + await page.locator('.quantity-section').getByRole('button', { name: '重新加载' }).click() + await page.getByRole('row').filter({ hasText: '测试管材' }).getByText('10.00', { exact: true }).waitFor() + await page.getByText('来源明细已变化,待核对').waitFor() + await page.getByText('最新完整快照中没有此明细').waitFor() + await page.getByText('完整快照不可用,显示已接入明细').waitFor() + await page.getByRole('button', { name: '查看明细' }).first().click() + const dialog = page.getByRole('dialog', { name: '来源明细对照' }) + await dialog.getByText('数量:2 → 3').waitFor() + await dialog.getByText('产品 9,数量 1,单价 10.12345(未税),金额 50').waitFor() + await dialog.getByText('新增', { exact: true }).waitFor() + await dialog.locator('.el-dialog__headerbtn').click() + failDetail = true + await page.setViewportSize({ width: 375, height: 800 }) + await page.getByText('客户标识:8 → 10').waitFor() + assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth), true) + await page.getByRole('button', { name: '查看明细' }).first().click() + await dialog.getByText('列表加载失败,请重试').waitFor() + await dialog.getByRole('button', { name: '重新加载' }).click() + await dialog.getByText('数量:2 → 3').waitFor() + await dialog.locator('.el-dialog__headerbtn').click() + assert.match(await page.locator('.source-section').innerText(), /可参与平账回款.*待确认/) + failNext = true + await page.locator('.el-pagination button.btn-next').click() + await page.getByText('列表加载失败,请重试').waitFor() + assert.equal(await page.getByText('SO-001').count(), 0) + await page.getByRole('button', { name: '重新加载' }).click() + await page.getByText('SO-001').waitFor() + assert.equal(await page.getByText('列表加载失败,请重试').count(), 0) + process.stdout.write('来源详情加载失败、清除旧数据和重试通过。\n') +} finally { + await browser?.close() + await server.close() +} diff --git a/frontend/e2e/tplus-payroll-prepare.mjs b/frontend/e2e/tplus-payroll-prepare.mjs new file mode 100644 index 0000000..7b4773c --- /dev/null +++ b/frontend/e2e/tplus-payroll-prepare.mjs @@ -0,0 +1,153 @@ +/** 来源业务核算准备组件回归;所有接口均为隔离模拟。 */ +import assert from 'node:assert/strict' +import fs from 'node:fs' +import path from 'node:path' +import { createRequire } from 'node:module' +import { fileURLToPath } from 'node:url' +import vue from '@vitejs/plugin-vue' +import { createServer } from 'vite' + +const require = createRequire(import.meta.url) +const { chromium } = require(process.env.PLAYWRIGHT_MODULE || 'C:/Users/Administrator/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright') +const root = fileURLToPath(new URL('../', import.meta.url)) +const output = path.resolve(root, '../.ai/tplus-payroll-browser') +fs.mkdirSync(output, { recursive: true }) +const id = 'virtual:tplus-payroll-proof' +const harness = `import {createApp,h,ref} from 'vue';import {createPinia} from 'pinia';import ElementPlus from 'element-plus';import 'element-plus/dist/index.css';import '/src/assets/styles/index.css';import Dialog from '/src/views/business/businesses/PayrollDialog.vue';import {useUserStore} from '/src/store/user.ts';const pinia=createPinia();createApp({setup(){useUserStore().userInfo={permissions:['payroll:view','payroll:prepare','payroll:calculate']};const open=ref(false);setTimeout(()=>open.value=true,0);return()=>h(Dialog,{modelValue:open.value,'onUpdate:modelValue':v=>open.value=v,businessId:12,sourceManaged:true})}}).use(pinia).use(ElementPlus).mount('#app')` +const server = await createServer({ configFile: false, root, + resolve: { alias: { '@': path.join(root, 'src') } }, + define: { 'import.meta.env.VITE_API_BASE_URL': JSON.stringify('/api') }, + cacheDir: path.join(output, 'vite-cache'), + plugins: [vue(), { name: 'tplus-payroll-proof', resolveId: value => value === id ? '\0' + id : undefined, + load: value => value === '\0' + id ? harness : undefined, + configureServer(instance) { instance.middlewares.use('/__payroll-proof', async (_request, response) => { + response.setHeader('Content-Type', 'text/html; charset=utf-8') + response.end(await instance.transformIndexHtml('/__payroll-proof', '')) + }) }, + }], server: { host: '127.0.0.1', port: 0 }, optimizeDeps: { include: ['vue', 'element-plus', 'pinia'] } }) + +let browser, snapshot = null, submitted = null, large = false, failLargePage = false +const largePeople = Array.from({length: 205}, (_, index) => ({id: index + 1, personnel_name: `分页业务员${index + 1}`})) +const largeShipments = Array.from({length: 205}, (_, index) => ({id: index + 101, shipment_no: `SH-${index + 101}`, + product_name: `分页产品${index + 1}`, ship_date: '2026-03-10', amount: '100.00'})) +const errors = [] +try { + await server.listen() + browser = await chromium.launch({ headless: true, channel: 'msedge' }) + const page = await browser.newPage({ viewport: { width: 1440, height: 1000 }, reducedMotion: 'reduce' }) + page.on('pageerror', error => errors.push(error.message)) + await page.route(url => url.pathname.startsWith('/api/'), async route => { + const req = route.request(), pathname = new URL(req.url()).pathname + let data + if (pathname.endsWith('/input') && req.method() === 'POST') { + submitted = req.postDataJSON() + snapshot = { id: 1, version: 1, source: 'test_source', fingerprint: 'test', data: { + group: { opening_balance: submitted.opening_balance }, evidence: submitted.evidence, + facts: { handovers: submitted.handovers ?? snapshot?.data?.facts?.handovers ?? [] }, + shipments: submitted.shipment_ownerships.map(row => ({ line_id: String(row.shipment_item_id), + personnel_id: String(row.personnel_id), manager_id: null, unit_cost: row.unit_cost, freight_amount: row.freight_amount })) } } + data = { version: 1 } + } else if (pathname.endsWith('/input')) data = snapshot + else if (pathname.includes('/meta/payroll-input/') && large) { + const pageNumber = Number(new URL(req.url()).searchParams.get('pageNum') || 1) + if (pageNumber === 2 && failLargePage) return route.fulfill({status: 500, json: {code: 500, message: '加载失败'}}) + const start = (pageNumber - 1) * 200 + data = { business_key: 'customer:8', personnel: largePeople.slice(start, start + 200), personnel_total: largePeople.length, + shipments: largeShipments.slice(start, start + 200), shipments_total: largeShipments.length } + } else if (pathname.includes('/meta/payroll-input/')) data = { business_key: 'customer:8', personnel: [{id: 1, personnel_name: '测试业务员'}, {id: 2, personnel_name: '接手业务员'}], shipments: [ + {id: 101, shipment_no: 'SH-001', product_name: '测试管材', ship_date: '2026-03-10', amount: '100.00', source_personnel_name: '来源员工', matched_personnel_id: 1, guidance_status: 'FINALIZED_UNIT', guidance_unit_cost: '25'}, + ...(snapshot ? [{id: 102, shipment_no: 'SH-002', product_name: '追加发货', ship_date: '2026-03-11', amount: '50.00', source_personnel_name: '未匹配员工', matched_personnel_id: null}] : []), + ] } + else if (pathname.includes('/months/')) data = { month: '2026-03', allocation_locked: false, maker_name: null } + else throw new Error('未预期接口:' + pathname) + await route.fulfill({ json: { code: 200, message: 'success', data } }) + }) + await page.goto(`http://127.0.0.1:${server.httpServer.address().port}/__payroll-proof`) + const dialog = page.getByRole('dialog', { name: '正式绩效核算' }) + await dialog.getByText('测试管材', { exact: false }).first().waitFor() + await dialog.getByText('来源员工', { exact: true }).waitFor() + await dialog.locator('tr').filter({hasText: '测试管材'}).getByText('测试业务员', {exact: true}).waitFor() + await dialog.locator('.el-date-editor input').fill('2026-03') + await dialog.locator('.el-date-editor input').press('Enter') + await page.waitForResponse(response => response.url().includes('/months/2026-03')) + await dialog.locator('.el-form-item').filter({hasText: '期初应收'}).locator('input').fill('0') + await dialog.locator('.el-form-item').filter({hasText: '核对依据'}).locator('input').fill('已核实来源') + await dialog.getByRole('combobox', {name: '发货归属人员'}).first().click() + await page.getByRole('option', {name: '测试业务员'}).click() + assert.equal(await dialog.getByRole('textbox', {name: '含税单位指导成本'}).first().inputValue(), '25') + assert.equal(await dialog.getByRole('textbox', {name: '含税单位指导成本'}).first().isDisabled(), true) + await dialog.getByText('使用已封存的月度指导单价', {exact: true}).waitFor() + await dialog.getByRole('textbox', {name: '发货明细运费'}).first().fill('0') + await dialog.getByRole('button', {name: '添加交接'}).click() + await dialog.locator('.el-form-item').filter({hasText: '交接类型'}).locator('.el-select').click() + await page.getByRole('option', {name: '在职交接'}).click() + await dialog.getByRole('combobox', {name: '移交人员'}).click() + await page.getByRole('option', {name: '测试业务员'}).click() + await dialog.getByRole('combobox', {name: '接手人员'}).click() + await page.getByRole('option', {name: '接手业务员'}).click() + await dialog.locator('.el-form-item').filter({hasText: '生效日期'}).locator('input').fill('2026-03-20') + await dialog.locator('.el-form-item').filter({hasText: '生效日期'}).locator('input').press('Enter') + await dialog.locator('label.el-checkbox').filter({hasText: '已核实期初'}).click() + await dialog.getByRole('button', {name: '保存核算资料'}).click() + assert.equal(submitted, null) + await dialog.locator('label.el-checkbox').filter({hasText: '已逐次核实发货归属'}).click() + await dialog.getByRole('button', {name: '保存核算资料'}).click() + await dialog.getByText('追加发货', {exact: false}).first().waitFor() + assert.equal(submitted.shipment_ownerships[0].freight_amount, '0') + assert.equal(submitted.shipment_ownerships[0].personnel_id, 1) + assert.deepEqual(submitted.handovers, [{business_key: 'customer:8', mode: 'ACTIVE', from_personnel_id: '1', + to_personnel_id: '2', effective_date: '2026-03-20', shipping_ownership_confirmed: true}]) + await dialog.locator('.el-form-item').filter({hasText: '移交人员'}).getByText('测试业务员').waitFor() + const freight = dialog.getByRole('textbox', {name: '发货明细运费'}) + assert.equal(await freight.nth(0).isDisabled(), true) + assert.equal(await freight.nth(1).isEnabled(), true) + await dialog.getByText('未匹配员工(待匹配)', {exact: true}).waitFor() + await dialog.getByRole('combobox', {name: '发货归属人员'}).nth(1).click() + await page.getByRole('option', {name: '测试业务员'}).click() + await dialog.getByRole('textbox', {name: '含税单位指导成本'}).nth(1).fill('25') + await freight.nth(1).fill('0') + submitted = null + const secondSave = page.waitForResponse(response => response.url().endsWith('/input') && response.request().method() === 'POST') + await dialog.getByRole('button', {name: '保存核算资料'}).click() + await secondSave + assert.equal(Object.hasOwn(submitted, 'handovers'), false) + assert.equal(snapshot.data.facts.handovers.length, 1) + for (const width of [1440, 768, 390]) { + await page.setViewportSize({ width, height: 900 }) + await page.screenshot({ path: path.join(output, `payroll-${width}.png`), fullPage: true }) + assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth + 1), false) + } + snapshot.data.shipments[0].personnel_id = '2' + await page.reload() + await dialog.locator('tr').filter({hasText: '测试管材'}).getByText('接手业务员', {exact: true}).waitFor() + assert.equal(await dialog.getByRole('combobox', {name: '发货归属人员'}).first().isDisabled(), true) + large = true + failLargePage = true + snapshot = { id: 1, version: 2, source: 'test_source', fingerprint: 'test', data: { + group: { opening_balance: '0' }, evidence: '分页依据', + shipments: largeShipments.map((row, index) => ({line_id: String(row.id), personnel_id: String(index + 1), + manager_id: null, unit_cost: '25', freight_amount: '0'})) } } + submitted = null + await page.reload() + await dialog.getByText('资料加载失败,请重试。').waitFor() + assert.equal(submitted, null) + failLargePage = false + await dialog.getByRole('button', {name: '重新加载'}).click() + await dialog.getByText('分页产品1', {exact: false}).first().waitFor() + await dialog.locator('.el-pagination .number').getByText('5', {exact: true}).click() + await dialog.getByText('分页产品205', {exact: false}).waitFor() + await dialog.getByText('分页业务员205', {exact: true}).waitFor() + await dialog.locator('.el-date-editor input').fill('2026-03') + await dialog.locator('.el-date-editor input').press('Enter') + await page.waitForResponse(response => response.url().includes('/months/2026-03')) + await dialog.locator('label.el-checkbox').filter({hasText: '已核实期初'}).click() + await dialog.getByRole('button', {name: '保存核算资料'}).click() + await page.waitForFunction(() => document.body.innerText.includes('核算资料已保存为新版本')) + assert.equal(submitted.shipment_ownerships.length, 205) + assert.equal(submitted.shipment_ownerships[204].personnel_id, 205) + assert.deepEqual(errors, []) + process.stdout.write('来源核算分页、加载失败、完整提交及响应式检查通过。\n') +} finally { + await browser?.close() + await server.close() +} diff --git a/frontend/src/api/businesses.ts b/frontend/src/api/businesses.ts index 7eca379..f64451c 100644 --- a/frontend/src/api/businesses.ts +++ b/frontend/src/api/businesses.ts @@ -14,7 +14,7 @@ export interface BusinessItemRow { spec_model: string | null unit: string | null /** municipal_water 市政水利 / rail_transit 轨道交通 —— 行级折算锚点 */ - product_category: string + product_category: string | null /** 数字字符串,>0 */ quantity: string sale_price: string | null @@ -48,6 +48,12 @@ export interface BusinessItem { total_received_amount: string | null total_freight_amount: string | null remark: string | null + source_type: string + source_no: string | null + source_personnel: string[] + integration_status: string | null + readiness_status: 'READY' | 'PENDING' | null + readiness_reasons: { code: string; message: string }[] } /** 二次分配参与人(2026-10-15 补充需求;比例不强制合计 100%) */ @@ -85,12 +91,120 @@ export interface BusinessQueryParams { status?: number customer_id?: number owner_personnel_id?: number + source_type?: string + source_personnel?: string + pending_only?: boolean + readiness_status?: 'READY' | 'PENDING' + readiness_reason?: string /** 业务日期起(含),ISO 串 */ date_from?: string /** 业务日期止(含),ISO 串 */ date_to?: string } +export interface SourceReadiness { + source_type: string + status: string + reasons: { code: string; message: string }[] + customer_type: string | null + products: { id: number; name: string; spec: string; category: string | null }[] + products_total: number + eligible_receipts_snapshot: string | null +} + +export interface SourceDocument { + id: number + entity: string + source_no: string | null + current_source_no: string | null + snapshot_state: 'CURRENT' | 'CHANGED' | 'MISSING' | 'UNAVAILABLE' | null + changes: { field: string; label: string; before: string | null; after: string | null }[] + status: string + reason: string | null + date: string | null + personnel_name: string | null + personnel_source_id: string | null + quantity: string | null + amount: string | null + price: string | null + price_type: 'TAX_INCLUDED' | 'TAX_EXCLUDED' | null + price_repair_revision: string | null +} + +export function sourceReadiness(id: number): Promise若下方保留了记录,请以重新加载后的结果为准。
+本页生成绩效台账;月度工资需在“月度结算”中另行生成。
当前账号可查看核算资料;没有正式核算权限,请联系管理员分配相应角色。
@@ -90,21 +138,55 @@ watch(open, value => { monthRequest++; if (value) { month.value = ''; opening.va资料版本:{{ input?.version ?? '尚未准备' }};来源:{{ input?.source === 'MANUAL' ? '人工核实' : input ? '源单据导入' : '—' }}
- +暂无交接记录
+暂无可核对明细。
+{{ row.confirmation.disposition === 'RELEASED' ? '已撤销' : row.confirmation.current ? '已确认' : '原确认已失效' }}:{{ row.confirmation.disposition === 'ORDER' ? '订单发货' : row.confirmation.disposition === 'INDEPENDENT' ? '独立发货' : '待重新核对' }};依据:{{ row.confirmation.evidence }}
+发货 {{ row.confirmation.shipment.source_no || '无单号' }},明细 {{ row.confirmation.shipment.source_id }};订单 {{ row.confirmation.order.source_no || '无单号' }},明细 {{ row.confirmation.order.source_id }}
+可参与平账回款(最近封存口径):{{ readiness.eligible_receipts_snapshot == null ? '待确认' : `${readiness.eligible_receipts_snapshot} 元` }}
+实际计提金额以期初、发货匹配及正式核算结果为准。
+产品较多,请在基础数据中继续补充其他产品分类。
+检查时间:{{ formatDateTime(report.captured_at) }}
无法确认单据类型,本次未完成关联检查。请核对数据后重新检查。
+按关联销货单统计:无订单 {{ report.order_coverage.without_order?.toLocaleString() }} 行,单订单 {{ report.order_coverage.single_order?.toLocaleString() }} 行,多订单 {{ report.order_coverage.multiple_orders?.toLocaleString() }} 行。统计单位为收款明细行,同一销货单可能出现多次。
-{{ report.receipt_kinds?.map((item) => `${item.voucher_type_name} ${item.row_count.toLocaleString()} 条`).join(';') || '暂无统计' }}
-单据状态:{{ report.state_enums.map((item) => item.name).join('、') }}
+按关联销货单核对,统计单位为收款明细行,同一销货单可能出现多次。
+接入状态加载失败,请刷新重试。
+等待首次完整采集完成。
+{{ config.enabled ? '已开启' : '未开启' }},接入状态需要检查:{{ reasonText(config.last_error) }}
+已有差异:{{ preview.existing_conflicts }} 条
+ +候选客户:{{ preview.customer_scope.candidate_customers }},尚未关联业务:{{ preview.customer_scope.without_business_link }},身份风险:{{ preview.customer_scope.identity_warnings }}
+{{ preview.customer_scope.note }}
+仅展示前 100 条身份风险。
+ + +候选产品:{{ preview.product_scope.candidate_products }},尚未关联档案:{{ preview.product_scope.without_product_link }},身份风险:{{ preview.product_scope.identity_warnings }}
+{{ preview.product_scope.note }}
+仅展示前 100 条身份风险。
+ +最近任务:{{ statuses[latest.status] ?? latest.status }} · {{ latest.run_no }}
+保存后重新预览并导入原业务。已正式导入的单据不能在这里转移;已生成工资的差额通过当月调整单处理。
+按客户确认的月份登记,起止月份均包含在内。核算会按业务日期取对应岗位。
+正在编辑第 {{ version }} 版,保存后新增版本,旧内容保留。
{{ running ? '当前同步' : '最近一次同步' }}
{{ running ? '进度每 5 秒更新' : `完成时间:${formatDateTime(batch.finished_at)}` }}
+连接就绪后,点击“立即同步”读取 T+ 数据;也可以开启每周自动采集。
多个标识用逗号分隔;留空处理所选类型全部数据。
{{ workKinds[current.kind] }} · {{ current.run_no }}
+查看同步进度、管理自动采集,并核对每次读取结果。
查看任务进度、管理每周采集,核对读取结果后再导入业务。
同步计划
{{ saving ? '正在保存设置…' : automation?.enabled ? '自动采集已开启' : '自动采集已关闭' }}
开启后按周读取;关闭只停止后续自动排队,当前任务继续完成。手动同步仍可使用。
@@ -82,12 +84,14 @@ async function retry(row: api.TPlusBatch): Promise绩效核算和工资生成分别执行。
按任务查看读取范围、实际进度和异常结果。
{{ formatDateTime(row.create_time) }}{{ row.trigger_type === 'SCHEDULED' ? '每周自动采集' : row.trigger_type === 'AUTO_RETRY' ? `自动重试 ${row.automatic_attempt}/2` : '手动任务' }}
{{ row.progress?.percentage == null ? row.status === 'QUEUED' ? '等待开始' : `${row.source_record_count.toLocaleString()} 条已处理` : `${row.progress.percentage}% · ${row.source_record_count.toLocaleString()} 条` }}