feat(T+): 完成数据贯通与自动接入闭环

打通 T+ 快照、业务、发货、回款和核算准备链路,保留来源差异并提供待补状态。
新增业务自动接入任务、跨平台读取服务托管、权限复核、来源关联和前端核对页面。
补充迁移、隔离集成测试、浏览器模拟验收及角色授权边界修复。
This commit is contained in:
liuwenxin 2026-11-05 18:47:13 +08:00
parent 3efe72c344
commit 1e53792ad6
151 changed files with 8842 additions and 401 deletions

View File

@ -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 上验证过的驱动名称。

View File

@ -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)

View File

@ -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}"))

View File

@ -0,0 +1,14 @@
"""合并业务接入和回款确认迁移分支。"""
revision = "31d2e3f4a5b6"
down_revision = ("29b0c1d2e3f4", "30c1d2e3f4a5")
branch_labels = None
depends_on = None
def upgrade():
pass
def downgrade():
pass

View File

@ -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,

View File

@ -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

View File

@ -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,

View File

@ -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,

View File

@ -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")

View File

@ -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()]):
"""分页查询批次元数据。"""

View File

@ -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()]):

View File

@ -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)

View File

@ -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 = ""

View File

@ -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 驱动")

View File

@ -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",

View File

@ -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)")

View File

@ -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+同步不覆盖)",
)

View File

@ -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(

View File

@ -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="备注")

View File

@ -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(

View File

@ -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+自动采集周期与进程状态(不自动导入正式业务)")

View File

@ -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="最小必要来源与差异说明")

View File

@ -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="最后观察批次")

View File

@ -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]]:

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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()

View File

@ -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)}
# ==================== 写入 ====================

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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 评级总分"
)

View File

@ -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

View File

@ -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="状态筛选")

View File

@ -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="页面核对的回款关键内容指纹")

View File

@ -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):

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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,

View File

@ -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()

View File

@ -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):

View File

@ -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"

View File

@ -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()

View File

@ -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

View File

@ -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

View File

@ -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)}

View File

@ -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)

View File

@ -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="支付权重应用方式",

View File

@ -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(

View File

@ -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 = {

View File

@ -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": "预览",

View File

@ -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

View File

@ -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("存在未人工确认或确认后内容已变化的回款,请先在回款管理逐笔确认")

View File

@ -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, "删除")

View File

@ -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)

View File

@ -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),

View File

@ -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

View File

@ -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"]

View File

@ -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:

View File

@ -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

Binary file not shown.

View File

@ -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"],

View File

@ -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}

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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",
}

View File

@ -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

View File

@ -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,
)),
)
)

View File

@ -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": "负数或零金额单据需人工核对",

View File

@ -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)

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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__
)

View File

@ -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(

View File

@ -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"])

View File

@ -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)

View File

@ -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),

View File

@ -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"]

View File

@ -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]

View File

@ -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)

View File

@ -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

View File

@ -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"

View File

@ -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"] == []

View File

@ -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",

View File

@ -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()

View File

@ -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

View File

@ -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)

View File

@ -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)

View File

@ -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

View File

@ -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()

View File

@ -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)

View File

@ -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)

View File

@ -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):

View File

@ -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"))

View File

@ -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, [])

Some files were not shown because too many files have changed in this diff Show More