feat(T+): 完成数据贯通与自动接入闭环
打通 T+ 快照、业务、发货、回款和核算准备链路,保留来源差异并提供待补状态。 新增业务自动接入任务、跨平台读取服务托管、权限复核、来源关联和前端核对页面。 补充迁移、隔离集成测试、浏览器模拟验收及角色授权边界修复。
This commit is contained in:
parent
3efe72c344
commit
1e53792ad6
@ -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 上验证过的驱动名称。
|
||||
|
||||
@ -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)
|
||||
40
backend/alembic/versions/30c1d2e3f4a5_tplus_business_sync.py
Normal file
40
backend/alembic/versions/30c1d2e3f4a5_tplus_business_sync.py
Normal 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}"))
|
||||
@ -0,0 +1,14 @@
|
||||
"""合并业务接入和回款确认迁移分支。"""
|
||||
|
||||
revision = "31d2e3f4a5b6"
|
||||
down_revision = ("29b0c1d2e3f4", "30c1d2e3f4a5")
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
pass
|
||||
|
||||
|
||||
def downgrade():
|
||||
pass
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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()]):
|
||||
"""分页查询批次元数据。"""
|
||||
|
||||
@ -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()]):
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 = ""
|
||||
|
||||
@ -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 驱动")
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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)")
|
||||
|
||||
@ -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+同步不覆盖)",
|
||||
)
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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="备注")
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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+自动采集周期与进程状态(不自动导入正式业务)")
|
||||
|
||||
51
backend/app/models/tplus_business_sync.py
Normal file
51
backend/app/models/tplus_business_sync.py
Normal 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="最小必要来源与差异说明")
|
||||
@ -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="最后观察批次")
|
||||
|
||||
|
||||
|
||||
@ -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]]:
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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)}
|
||||
|
||||
|
||||
# ==================== 写入 ====================
|
||||
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
86
backend/app/schemas/business_source.py
Normal file
86
backend/app/schemas/business_source.py
Normal 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
|
||||
30
backend/app/schemas/category_batch.py
Normal file
30
backend/app/schemas/category_batch.py
Normal 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)
|
||||
@ -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 评级总分"
|
||||
)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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="状态筛选")
|
||||
|
||||
|
||||
|
||||
@ -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="页面核对的回款关键内容指纹")
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
27
backend/app/schemas/tplus_business_sync.py
Normal file
27
backend/app/schemas/tplus_business_sync.py
Normal 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
|
||||
@ -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,
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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()
|
||||
|
||||
838
backend/app/services/business_source.py
Normal file
838
backend/app/services/business_source.py
Normal 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
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
33
backend/app/services/category_batch.py
Normal file
33
backend/app/services/category_batch.py
Normal 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)}
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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="支付权重应用方式",
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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 = {
|
||||
|
||||
@ -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": "预览",
|
||||
|
||||
47
backend/app/services/personnel_roles.py
Normal file
47
backend/app/services/personnel_roles.py
Normal 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
|
||||
56
backend/app/services/receipt_confirmation.py
Normal file
56
backend/app/services/receipt_confirmation.py
Normal 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("存在未人工确认或确认后内容已变化的回款,请先在回款管理逐笔确认")
|
||||
@ -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, "删除")
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"]
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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.
@ -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"],
|
||||
|
||||
705
backend/app/services/tplus/business_sync.py
Normal file
705
backend/app/services/tplus/business_sync.py
Normal 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}
|
||||
141
backend/app/services/tplus/business_sync_control.py
Normal file
141
backend/app/services/tplus/business_sync_control.py
Normal 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
|
||||
@ -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)
|
||||
|
||||
195
backend/app/services/tplus/corrections.py
Normal file
195
backend/app/services/tplus/corrections.py
Normal 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
|
||||
@ -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",
|
||||
}
|
||||
|
||||
|
||||
|
||||
87
backend/app/services/tplus/guidance.py
Normal file
87
backend/app/services/tplus/guidance.py
Normal 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
|
||||
@ -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,
|
||||
)),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@ -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": "负数或零金额单据需人工核对",
|
||||
|
||||
46
backend/app/services/tplus/runtime_control.py
Normal file
46
backend/app/services/tplus/runtime_control.py
Normal 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)
|
||||
226
backend/app/services/tplus/sales_relations.py
Normal file
226
backend/app/services/tplus/sales_relations.py
Normal 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
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
168
backend/app/services/tplus/supervisor.py
Normal file
168
backend/app/services/tplus/supervisor.py
Normal 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
|
||||
@ -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__
|
||||
)
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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"])
|
||||
|
||||
140
backend/tests/integration/test_receipt_confirmation.py
Normal file
140
backend/tests/integration/test_receipt_confirmation.py
Normal 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)
|
||||
@ -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),
|
||||
|
||||
23
backend/tests/integration/test_source_price_authorization.py
Normal file
23
backend/tests/integration/test_source_price_authorization.py
Normal 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"]
|
||||
104
backend/tests/integration/test_tplus_supervisor.py
Normal file
104
backend/tests/integration/test_tplus_supervisor.py
Normal 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]
|
||||
31
backend/tests/unit/test_business_readiness_list.py
Normal file
31
backend/tests/unit/test_business_readiness_list.py
Normal 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)
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
137
backend/tests/unit/test_historical_roles.py
Normal file
137
backend/tests/unit/test_historical_roles.py
Normal 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"
|
||||
71
backend/tests/unit/test_manual_payroll_preservation.py
Normal file
71
backend/tests/unit/test_manual_payroll_preservation.py
Normal 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"] == []
|
||||
@ -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",
|
||||
|
||||
62
backend/tests/unit/test_mysql_month_lock.py
Normal file
62
backend/tests/unit/test_mysql_month_lock.py
Normal 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()
|
||||
@ -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
|
||||
|
||||
106
backend/tests/unit/test_payroll_metadata_pagination.py
Normal file
106
backend/tests/unit/test_payroll_metadata_pagination.py
Normal 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)
|
||||
38
backend/tests/unit/test_receipt_confirmation.py
Normal file
38
backend/tests/unit/test_receipt_confirmation.py
Normal 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)
|
||||
44
backend/tests/unit/test_receipt_confirmation_migration.py
Normal file
44
backend/tests/unit/test_receipt_confirmation_migration.py
Normal 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
|
||||
31
backend/tests/unit/test_source_price_authorization.py
Normal file
31
backend/tests/unit/test_source_price_authorization.py
Normal 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()
|
||||
573
backend/tests/unit/test_tplus_business_sync.py
Normal file
573
backend/tests/unit/test_tplus_business_sync.py
Normal 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)
|
||||
@ -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)
|
||||
|
||||
@ -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):
|
||||
|
||||
95
backend/tests/unit/test_tplus_guidance_history.py
Normal file
95
backend/tests/unit/test_tplus_guidance_history.py
Normal 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"))
|
||||
103
backend/tests/unit/test_tplus_guidance_resolution.py
Normal file
103
backend/tests/unit/test_tplus_guidance_resolution.py
Normal 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
Loading…
Reference in New Issue
Block a user