feat(核算)!: 添加 T+ 正式核算与采集托管
补齐源快照、资料预演、正式导入及工资核算的依赖链路。 新增版本化输入、逐段 FIFO、生成锁和结构化当月调整, 并提供默认关闭的周期采集、有限重试及 Windows 托管。 原始客户资料、私有配置及首页改动不纳入本次提交。 BREAKING CHANGE: 升级前需应用新增迁移,并通过 PAYROLL_MAKER_USER_ID 绑定实际工资制作人,否则业务只读。 旧单批次核算默认关闭;历史兼容需显式启用 PAYROLL_ALLOW_LEGACY_CALCULATION。生产启用和真实金额待验收。
This commit is contained in:
1 parent
1b074576fe
commit
108bf87225
154 files changed
+9515
-645
No files matched your search
@@ -119,6 +119,26 @@ AdminUser = Annotated[User, Depends(require_admin)]
|
||||
LoginUser = Annotated[User, Depends(get_current_user)]
|
||||
|
||||
|
||||
def require_payroll_writer(current_user: LoginUser) -> User:
|
||||
from app.services.payroll_access import ensure_payroll_writer
|
||||
|
||||
return ensure_payroll_writer(current_user)
|
||||
|
||||
|
||||
PayrollWriter = Annotated[User, Depends(require_payroll_writer)]
|
||||
|
||||
|
||||
def require_workbench_reader(current_user: LoginUser) -> User:
|
||||
from app.services.payroll_access import can_write_payroll
|
||||
|
||||
if ROLE_ADMIN not in user_repository.get_role_codes(current_user) and not can_write_payroll(current_user):
|
||||
raise ForbiddenError("需要管理员或指定工资制作人权限")
|
||||
return current_user
|
||||
|
||||
|
||||
WorkbenchReader = Annotated[User, Depends(require_workbench_reader)]
|
||||
|
||||
|
||||
def require_roles(*role_codes: str, message: str = "无权访问审批管理"):
|
||||
"""要求当前用户具备任一指定角色(依赖工厂)。
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ from app.api.v1.business import receipts as business_receipts
|
||||
from app.api.v1.business import shipments as business_shipments
|
||||
from app.api.v1.calc import router as calc_router
|
||||
from app.api.v1.dashboard import router as dashboard_router
|
||||
from app.api.v1.payroll import router as payroll_router
|
||||
from app.api.v1.reports import router as reports_router
|
||||
from app.api.v1.settlements import router as settlements_router
|
||||
from app.api.v1.system import approvals as system_approvals
|
||||
@@ -69,6 +70,7 @@ v1_router.include_router(business_router)
|
||||
|
||||
# 绩效核算(M3 ④):/api/v1/calc/*,读登录即可、写限管理员与营销后勤
|
||||
v1_router.include_router(calc_router)
|
||||
v1_router.include_router(payroll_router)
|
||||
# 月度结算(M3 ④):/api/v1/settlements/*,读登录即可、写限管理员与营销后勤
|
||||
v1_router.include_router(settlements_router)
|
||||
# 报表统计(M4 ⑤):/api/v1/reports/*,只读,限管理员/财务/领导
|
||||
|
||||
@@ -5,11 +5,13 @@ from typing import Annotated
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
|
||||
from app.api.deps import DbSession, client_ip, get_current_user, get_raw_token
|
||||
from app.core.config import get_settings
|
||||
from app.core.response import ApiResponse, ok
|
||||
from app.models import User
|
||||
from app.repositories.user_repository import get_role_codes
|
||||
from app.schemas.auth import CaptchaOut, CurrentUserOut, LoginIn, LoginOut
|
||||
from app.services import auth_service, captcha_service
|
||||
from app.services.payroll_access import can_write_payroll
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["认证"])
|
||||
|
||||
@@ -71,6 +73,8 @@ def me(current_user: Annotated[User, Depends(get_current_user)]) -> ApiResponse[
|
||||
username=current_user.username,
|
||||
real_name=current_user.real_name,
|
||||
roles=get_role_codes(current_user),
|
||||
can_write_payroll=can_write_payroll(current_user),
|
||||
payroll_approval_enabled=get_settings().payroll_approval_enabled,
|
||||
last_login_time=current_user.last_login_time,
|
||||
)
|
||||
)
|
||||
@@ -13,7 +13,7 @@ from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Path, Query
|
||||
|
||||
from app.api.deps import DbSession, RequestMetaDep, get_current_user
|
||||
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.schemas.customer import (
|
||||
@@ -46,7 +46,7 @@ def list_customers(
|
||||
@router.post("", response_model=ApiResponse[CustomerOut], summary="新增客户")
|
||||
def create_customer(
|
||||
session: DbSession,
|
||||
current_user: CurrentUser,
|
||||
current_user: PayrollWriter,
|
||||
payload: CustomerCreateIn,
|
||||
meta: RequestMetaDep,
|
||||
) -> ApiResponse[CustomerOut]:
|
||||
@@ -70,7 +70,7 @@ def get_customer(
|
||||
@router.put("/{customer_id}", response_model=ApiResponse[CustomerOut], summary="编辑客户")
|
||||
def update_customer(
|
||||
session: DbSession,
|
||||
current_user: CurrentUser,
|
||||
current_user: PayrollWriter,
|
||||
customer_id: Annotated[int, Path(ge=1)],
|
||||
payload: CustomerUpdateIn,
|
||||
meta: RequestMetaDep,
|
||||
@@ -91,7 +91,7 @@ def update_customer(
|
||||
@router.delete("/{customer_id}", response_model=ApiResponse[None], summary="删除客户")
|
||||
def delete_customer(
|
||||
session: DbSession,
|
||||
current_user: CurrentUser,
|
||||
current_user: PayrollWriter,
|
||||
customer_id: Annotated[int, Path(ge=1)],
|
||||
meta: RequestMetaDep,
|
||||
) -> ApiResponse[None]:
|
||||
@@ -110,7 +110,7 @@ def delete_customer(
|
||||
)
|
||||
def rate_customer(
|
||||
session: DbSession,
|
||||
current_user: CurrentUser,
|
||||
current_user: PayrollWriter,
|
||||
customer_id: Annotated[int, Path(ge=1)],
|
||||
payload: CustomerRatingIn,
|
||||
meta: RequestMetaDep,
|
||||
@@ -135,7 +135,7 @@ def rate_customer(
|
||||
)
|
||||
def change_ownership(
|
||||
session: DbSession,
|
||||
current_user: CurrentUser,
|
||||
current_user: PayrollWriter,
|
||||
customer_id: Annotated[int, Path(ge=1)],
|
||||
payload: CustomerOwnershipIn,
|
||||
meta: RequestMetaDep,
|
||||
|
||||
@@ -11,7 +11,7 @@ from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Path, Query
|
||||
|
||||
from app.api.deps import DbSession, RequestMetaDep, get_current_user
|
||||
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.schemas.personnel import (
|
||||
@@ -44,7 +44,7 @@ def list_personnel(
|
||||
@router.post("", response_model=ApiResponse[PersonnelOut], summary="新增人员")
|
||||
def create_personnel(
|
||||
session: DbSession,
|
||||
current_user: CurrentUser,
|
||||
current_user: PayrollWriter,
|
||||
payload: PersonnelCreateIn,
|
||||
meta: RequestMetaDep,
|
||||
) -> ApiResponse[PersonnelOut]:
|
||||
@@ -68,7 +68,7 @@ def get_personnel(
|
||||
@router.put("/{personnel_id}", response_model=ApiResponse[PersonnelOut], summary="编辑人员")
|
||||
def update_personnel(
|
||||
session: DbSession,
|
||||
current_user: CurrentUser,
|
||||
current_user: PayrollWriter,
|
||||
personnel_id: Annotated[int, Path(ge=1)],
|
||||
payload: PersonnelCreateIn,
|
||||
meta: RequestMetaDep,
|
||||
@@ -85,7 +85,7 @@ def update_personnel(
|
||||
@router.delete("/{personnel_id}", response_model=ApiResponse[None], summary="删除人员")
|
||||
def delete_personnel(
|
||||
session: DbSession,
|
||||
current_user: CurrentUser,
|
||||
current_user: PayrollWriter,
|
||||
personnel_id: Annotated[int, Path(ge=1)],
|
||||
meta: RequestMetaDep,
|
||||
) -> ApiResponse[None]:
|
||||
@@ -101,7 +101,7 @@ def delete_personnel(
|
||||
)
|
||||
def set_status(
|
||||
session: DbSession,
|
||||
current_user: CurrentUser,
|
||||
current_user: PayrollWriter,
|
||||
personnel_id: Annotated[int, Path(ge=1)],
|
||||
payload: PersonnelStatusIn,
|
||||
meta: RequestMetaDep,
|
||||
@@ -116,7 +116,7 @@ def set_status(
|
||||
@router.put("/{personnel_id}/regions", response_model=ApiResponse[None], summary="维护辐射区域")
|
||||
def replace_regions(
|
||||
session: DbSession,
|
||||
current_user: CurrentUser,
|
||||
current_user: PayrollWriter,
|
||||
personnel_id: Annotated[int, Path(ge=1)],
|
||||
payload: RegionReplaceIn,
|
||||
meta: RequestMetaDep,
|
||||
@@ -131,7 +131,7 @@ def replace_regions(
|
||||
@router.put("/{personnel_id}/plans", response_model=ApiResponse[None], summary="维护拓展计划")
|
||||
def replace_plans(
|
||||
session: DbSession,
|
||||
current_user: CurrentUser,
|
||||
current_user: PayrollWriter,
|
||||
personnel_id: Annotated[int, Path(ge=1)],
|
||||
payload: PlanReplaceIn,
|
||||
meta: RequestMetaDep,
|
||||
|
||||
@@ -14,7 +14,7 @@ from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Path, Query
|
||||
|
||||
from app.api.deps import DbSession, RequestMetaDep, get_current_user
|
||||
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.schemas.product import (
|
||||
@@ -44,7 +44,7 @@ def list_products(
|
||||
@router.post("", response_model=ApiResponse[ProductOut], summary="新增产品")
|
||||
def create_product(
|
||||
session: DbSession,
|
||||
current_user: CurrentUser,
|
||||
current_user: PayrollWriter,
|
||||
payload: ProductCreateIn,
|
||||
meta: RequestMetaDep,
|
||||
) -> ApiResponse[ProductOut]:
|
||||
@@ -68,7 +68,7 @@ def get_product(
|
||||
@router.put("/{product_id}", response_model=ApiResponse[ProductOut], summary="编辑产品")
|
||||
def update_product(
|
||||
session: DbSession,
|
||||
current_user: CurrentUser,
|
||||
current_user: PayrollWriter,
|
||||
product_id: Annotated[int, Path(ge=1)],
|
||||
payload: ProductUpdateIn,
|
||||
meta: RequestMetaDep,
|
||||
@@ -85,7 +85,7 @@ def update_product(
|
||||
@router.delete("/{product_id}", response_model=ApiResponse[None], summary="删除产品")
|
||||
def delete_product(
|
||||
session: DbSession,
|
||||
current_user: CurrentUser,
|
||||
current_user: PayrollWriter,
|
||||
product_id: Annotated[int, Path(ge=1)],
|
||||
meta: RequestMetaDep,
|
||||
) -> ApiResponse[None]:
|
||||
@@ -100,7 +100,7 @@ def delete_product(
|
||||
@router.put("/{product_id}/status", response_model=ApiResponse[ProductOut], summary="启用/停用产品")
|
||||
def set_status(
|
||||
session: DbSession,
|
||||
current_user: CurrentUser,
|
||||
current_user: PayrollWriter,
|
||||
product_id: Annotated[int, Path(ge=1)],
|
||||
payload: ProductStatusIn,
|
||||
meta: RequestMetaDep,
|
||||
|
||||
@@ -9,7 +9,7 @@ from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Path, Query
|
||||
|
||||
from app.api.deps import DbSession, RequestMetaDep, get_current_user
|
||||
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.schemas.business import (
|
||||
@@ -45,7 +45,7 @@ def list_businesses(
|
||||
@router.post("", response_model=ApiResponse[BusinessOut], summary="创建业务")
|
||||
def create_business(
|
||||
session: DbSession,
|
||||
current_user: CurrentUser,
|
||||
current_user: PayrollWriter,
|
||||
payload: BusinessCreateIn,
|
||||
meta: RequestMetaDep,
|
||||
) -> ApiResponse[BusinessOut]:
|
||||
@@ -69,7 +69,7 @@ def get_business(
|
||||
@router.put("/{business_id}", response_model=ApiResponse[BusinessOut], summary="编辑业务")
|
||||
def update_business(
|
||||
session: DbSession,
|
||||
current_user: CurrentUser,
|
||||
current_user: PayrollWriter,
|
||||
business_id: Annotated[int, Path(ge=1)],
|
||||
payload: BusinessUpdateIn,
|
||||
meta: RequestMetaDep,
|
||||
@@ -86,7 +86,7 @@ def update_business(
|
||||
@router.delete("/{business_id}", response_model=ApiResponse[None], summary="删除业务")
|
||||
def delete_business(
|
||||
session: DbSession,
|
||||
current_user: CurrentUser,
|
||||
current_user: PayrollWriter,
|
||||
business_id: Annotated[int, Path(ge=1)],
|
||||
meta: RequestMetaDep,
|
||||
) -> ApiResponse[None]:
|
||||
@@ -98,7 +98,7 @@ def delete_business(
|
||||
@router.put("/{business_id}/status", response_model=ApiResponse[BusinessOut], summary="状态流转")
|
||||
def change_status(
|
||||
session: DbSession,
|
||||
current_user: CurrentUser,
|
||||
current_user: PayrollWriter,
|
||||
business_id: Annotated[int, Path(ge=1)],
|
||||
payload: BusinessStatusIn,
|
||||
meta: RequestMetaDep,
|
||||
@@ -115,7 +115,7 @@ def change_status(
|
||||
@router.put("/{business_id}/ownership", response_model=ApiResponse[BusinessOut], summary="调整归属")
|
||||
def change_ownership(
|
||||
session: DbSession,
|
||||
current_user: CurrentUser,
|
||||
current_user: PayrollWriter,
|
||||
business_id: Annotated[int, Path(ge=1)],
|
||||
payload: BusinessOwnershipIn,
|
||||
meta: RequestMetaDep,
|
||||
|
||||
@@ -13,7 +13,7 @@ from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Path
|
||||
|
||||
from app.api.deps import DbSession, RequestMetaDep, get_current_user
|
||||
from app.api.deps import DbSession, PayrollWriter, RequestMetaDep, get_current_user
|
||||
from app.core.response import ApiResponse, ok
|
||||
from app.models import User
|
||||
from app.schemas.rating import RatingGradeCreateIn, RatingGradeOut, RatingGradeUpdateIn
|
||||
@@ -36,7 +36,7 @@ def list_grades(
|
||||
@router.post("", response_model=ApiResponse[RatingGradeOut], summary="新增评级等级")
|
||||
def create_grade(
|
||||
session: DbSession,
|
||||
current_user: CurrentUser,
|
||||
current_user: PayrollWriter,
|
||||
payload: RatingGradeCreateIn,
|
||||
meta: RequestMetaDep,
|
||||
) -> ApiResponse[RatingGradeOut]:
|
||||
@@ -50,7 +50,7 @@ def create_grade(
|
||||
@router.put("/{grade_id}", response_model=ApiResponse[RatingGradeOut], summary="编辑评级等级")
|
||||
def update_grade(
|
||||
session: DbSession,
|
||||
current_user: CurrentUser,
|
||||
current_user: PayrollWriter,
|
||||
grade_id: Annotated[int, Path(ge=1)],
|
||||
payload: RatingGradeUpdateIn,
|
||||
meta: RequestMetaDep,
|
||||
@@ -65,7 +65,7 @@ def update_grade(
|
||||
@router.delete("/{grade_id}", response_model=ApiResponse[None], summary="删除评级等级")
|
||||
def delete_grade(
|
||||
session: DbSession,
|
||||
current_user: CurrentUser,
|
||||
current_user: PayrollWriter,
|
||||
grade_id: Annotated[int, Path(ge=1)],
|
||||
meta: RequestMetaDep,
|
||||
) -> ApiResponse[None]:
|
||||
|
||||
@@ -9,7 +9,7 @@ from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Path, Query
|
||||
|
||||
from app.api.deps import DbSession, RequestMetaDep, get_current_user
|
||||
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.schemas.receipt import (
|
||||
@@ -39,7 +39,7 @@ def list_receipts(
|
||||
@router.post("", response_model=ApiResponse[ReceiptOut], summary="新增回款")
|
||||
def create_receipt(
|
||||
session: DbSession,
|
||||
current_user: CurrentUser,
|
||||
current_user: PayrollWriter,
|
||||
payload: ReceiptCreateIn,
|
||||
meta: RequestMetaDep,
|
||||
) -> ApiResponse[ReceiptOut]:
|
||||
@@ -63,7 +63,7 @@ def get_receipt(
|
||||
@router.put("/{receipt_id}", response_model=ApiResponse[ReceiptOut], summary="编辑回款")
|
||||
def update_receipt(
|
||||
session: DbSession,
|
||||
current_user: CurrentUser,
|
||||
current_user: PayrollWriter,
|
||||
receipt_id: Annotated[int, Path(ge=1)],
|
||||
payload: ReceiptUpdateIn,
|
||||
meta: RequestMetaDep,
|
||||
@@ -80,7 +80,7 @@ def update_receipt(
|
||||
@router.delete("/{receipt_id}", response_model=ApiResponse[None], summary="删除回款")
|
||||
def delete_receipt(
|
||||
session: DbSession,
|
||||
current_user: CurrentUser,
|
||||
current_user: PayrollWriter,
|
||||
receipt_id: Annotated[int, Path(ge=1)],
|
||||
meta: RequestMetaDep,
|
||||
) -> ApiResponse[None]:
|
||||
|
||||
@@ -8,7 +8,7 @@ from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Path, Query
|
||||
|
||||
from app.api.deps import DbSession, RequestMetaDep, get_current_user
|
||||
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.schemas.shipment import (
|
||||
@@ -39,7 +39,7 @@ def list_shipments(
|
||||
@router.post("", response_model=ApiResponse[ShipmentOut], summary="创建发货单")
|
||||
def create_shipment(
|
||||
session: DbSession,
|
||||
current_user: CurrentUser,
|
||||
current_user: PayrollWriter,
|
||||
payload: ShipmentCreateIn,
|
||||
meta: RequestMetaDep,
|
||||
) -> ApiResponse[ShipmentOut]:
|
||||
@@ -63,7 +63,7 @@ def get_shipment(
|
||||
@router.put("/{shipment_id}", response_model=ApiResponse[ShipmentOut], summary="编辑发货单")
|
||||
def update_shipment(
|
||||
session: DbSession,
|
||||
current_user: CurrentUser,
|
||||
current_user: PayrollWriter,
|
||||
shipment_id: Annotated[int, Path(ge=1)],
|
||||
payload: ShipmentUpdateIn,
|
||||
meta: RequestMetaDep,
|
||||
@@ -80,7 +80,7 @@ def update_shipment(
|
||||
@router.delete("/{shipment_id}", response_model=ApiResponse[None], summary="删除发货单")
|
||||
def delete_shipment(
|
||||
session: DbSession,
|
||||
current_user: CurrentUser,
|
||||
current_user: PayrollWriter,
|
||||
shipment_id: Annotated[int, Path(ge=1)],
|
||||
meta: RequestMetaDep,
|
||||
) -> ApiResponse[None]:
|
||||
|
||||
@@ -10,18 +10,14 @@ from typing import Annotated
|
||||
from fastapi import APIRouter, Depends, Path, Query
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.api.deps import DbSession, RequestMetaDep, require_roles
|
||||
from app.core.constants import ROLE_ADMIN, ROLE_FINANCE, ROLE_MARKETING_OPS
|
||||
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.schemas.calc import CalcReceiptOut, LedgerOut, LedgerQuery
|
||||
from app.services import calc_service
|
||||
|
||||
router = APIRouter(prefix="/calc", tags=["绩效工资 · 绩效核算"])
|
||||
router = APIRouter(dependencies=[Depends(get_current_user)], prefix="/calc", tags=["绩效工资 · 绩效核算"])
|
||||
|
||||
_CalcWriter = Annotated[User, Depends(require_roles(ROLE_ADMIN, ROLE_MARKETING_OPS))]
|
||||
# 重算是授权动作:管理员/财务(营销后勤不可重算)
|
||||
_RecalcUser = Annotated[User, Depends(require_roles(ROLE_ADMIN, ROLE_FINANCE))]
|
||||
|
||||
|
||||
@router.get("/ledger", response_model=ApiResponse[PageResult[LedgerOut]], summary="绩效台账")
|
||||
@@ -50,7 +46,7 @@ def get_ledger(
|
||||
def calc_receipt(
|
||||
receipt_id: Annotated[int, Path(ge=1)],
|
||||
session: DbSession,
|
||||
current_user: _CalcWriter,
|
||||
current_user: PayrollWriter,
|
||||
meta: RequestMetaDep,
|
||||
) -> ApiResponse[CalcReceiptOut]:
|
||||
"""把回款固化为台账行(每笔回款仅一次;业务级一次性计提,重复回款仅登记进度)。"""
|
||||
@@ -81,7 +77,7 @@ def recalc_receipt(
|
||||
receipt_id: Annotated[int, Path(ge=1)],
|
||||
payload: RecalcIn,
|
||||
session: DbSession,
|
||||
current_user: _RecalcUser,
|
||||
current_user: PayrollWriter,
|
||||
meta: RequestMetaDep,
|
||||
) -> ApiResponse[CalcReceiptOut]:
|
||||
"""重算(管理员/财务):作废旧版本、按当前参数落新版本并留痕。"""
|
||||
@@ -97,7 +93,7 @@ def recalc_receipt(
|
||||
def batch_calc(
|
||||
payload: BatchIn,
|
||||
session: DbSession,
|
||||
current_user: _CalcWriter,
|
||||
current_user: PayrollWriter,
|
||||
meta: RequestMetaDep,
|
||||
) -> ApiResponse[list[dict]]:
|
||||
"""批量核算:逐笔独立事务,返回逐笔成败清单(失败不中断其它笔)。"""
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
"""正式工资资料、FIFO核算和逐段明细入口。"""
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Path, Query
|
||||
|
||||
from app.api.deps import DbSession, LoginUser, PayrollWriter, RequestMetaDep
|
||||
from app.core.response import PageQuery, ok
|
||||
from app.schemas.payroll import AllocationAdjustmentIn, PayrollMonthIn, PayrollSetupIn
|
||||
from app.services import payroll_calculation, payroll_inputs, payroll_months, settlement_service
|
||||
|
||||
router = APIRouter(prefix="/payroll", tags=["正式工资核算"])
|
||||
|
||||
|
||||
@router.get("/months/{month}")
|
||||
def month_state(
|
||||
month: Annotated[str, Path(pattern=r"^20[0-9]{2}-(0[1-9]|1[0-2])$")],
|
||||
session: DbSession,
|
||||
_: LoginUser,
|
||||
):
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.month import validate_month
|
||||
from app.models import MonthlySettlement, PayrollMonth
|
||||
|
||||
validate_month(month)
|
||||
control = session.scalar(select(PayrollMonth).where(PayrollMonth.month == month))
|
||||
existing = session.scalar(
|
||||
select(MonthlySettlement.id).where(MonthlySettlement.settlement_month == month)
|
||||
)
|
||||
return ok(
|
||||
{
|
||||
"month": month,
|
||||
"allocation_locked": bool(existing or (control and control.generated_at)),
|
||||
"maker_name": control.maker_name if control else None,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.get("/businesses/{business_id}/input")
|
||||
def input_detail(business_id: int, session: DbSession, _: LoginUser):
|
||||
record = payroll_calculation.latest_input(session, business_id)
|
||||
return ok(
|
||||
None
|
||||
if record is None
|
||||
else {
|
||||
"id": record.id,
|
||||
"version": record.version,
|
||||
"source": record.source_database,
|
||||
"fingerprint": record.fingerprint,
|
||||
"data": record.data_json,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.post("/businesses/{business_id}/input")
|
||||
def save_input(
|
||||
business_id: int,
|
||||
payload: PayrollSetupIn,
|
||||
session: DbSession,
|
||||
actor: PayrollWriter,
|
||||
meta: RequestMetaDep,
|
||||
):
|
||||
return ok(payroll_inputs.setup(session, business_id, payload, actor, meta))
|
||||
|
||||
|
||||
@router.post("/businesses/{business_id}/calculate")
|
||||
def calculate(
|
||||
business_id: int,
|
||||
payload: PayrollMonthIn,
|
||||
session: DbSession,
|
||||
actor: PayrollWriter,
|
||||
meta: RequestMetaDep,
|
||||
):
|
||||
return ok(
|
||||
payroll_calculation.calculate(
|
||||
session, business_id, payload.month, operator=actor, meta=meta, reason=payload.reason
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@router.get("/ledger/{ledger_id}/allocations")
|
||||
def allocations(
|
||||
ledger_id: int, query: Annotated[PageQuery, Query()], session: DbSession, _: LoginUser
|
||||
):
|
||||
return ok(payroll_calculation.allocation_page(session, ledger_id, query))
|
||||
|
||||
|
||||
@router.post("/allocation-adjustments")
|
||||
def adjustment(
|
||||
payload: AllocationAdjustmentIn, session: DbSession, actor: PayrollWriter, meta: RequestMetaDep
|
||||
):
|
||||
from app.core.exceptions import BadRequestError
|
||||
from app.models import PerformanceLedger
|
||||
from app.schemas.settlement import AdjustmentCreateIn
|
||||
|
||||
old = session.get(PerformanceLedger, payload.ledger_id)
|
||||
if old is None or old.settlement_id is None:
|
||||
raise BadRequestError("请选择已生成工资的原台账")
|
||||
data = AdjustmentCreateIn(
|
||||
settlement_month=payroll_months.current_month(),
|
||||
personnel_id=payload.personnel_id,
|
||||
direction=payload.direction,
|
||||
amount=payload.amount,
|
||||
reason=payload.reason,
|
||||
source_ledger_id=old.id,
|
||||
)
|
||||
return ok(settlement_service.create_adjustment(session, data, operator=actor, meta=meta))
|
||||
@@ -8,10 +8,8 @@ from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Path, Query
|
||||
|
||||
from app.api.deps import DbSession, RequestMetaDep, require_roles
|
||||
from app.core.constants import ROLE_ADMIN, ROLE_MARKETING_OPS
|
||||
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.schemas.settlement import (
|
||||
AdjustmentCreateIn,
|
||||
SettlementDetailOut,
|
||||
@@ -21,16 +19,15 @@ from app.schemas.settlement import (
|
||||
)
|
||||
from app.services import settlement_service
|
||||
|
||||
router = APIRouter(prefix="/settlements", tags=["绩效工资 · 月度结算"])
|
||||
router = APIRouter(dependencies=[Depends(get_current_user)], prefix="/settlements", tags=["绩效工资 · 月度结算"])
|
||||
|
||||
_SettlementWriter = Annotated[User, Depends(require_roles(ROLE_ADMIN, ROLE_MARKETING_OPS))]
|
||||
|
||||
|
||||
@router.post("/generate", response_model=ApiResponse[SettlementDetailOut], summary="生成月结快照")
|
||||
def generate(
|
||||
payload: SettlementGenerateIn,
|
||||
session: DbSession,
|
||||
current_user: _SettlementWriter,
|
||||
current_user: PayrollWriter,
|
||||
meta: RequestMetaDep,
|
||||
) -> ApiResponse[SettlementDetailOut]:
|
||||
"""按确认回款口径生成指定月份的结算快照(草稿)。"""
|
||||
@@ -66,7 +63,7 @@ def get_settlement(
|
||||
def submit(
|
||||
settlement_id: Annotated[int, Path(ge=1)],
|
||||
session: DbSession,
|
||||
current_user: _SettlementWriter,
|
||||
current_user: PayrollWriter,
|
||||
meta: RequestMetaDep,
|
||||
) -> ApiResponse[SettlementDetailOut]:
|
||||
"""提交审批:开关关闭=自动通过并锁定;开启=待财务复核。"""
|
||||
@@ -83,7 +80,7 @@ def submit(
|
||||
def create_adjustment(
|
||||
payload: AdjustmentCreateIn,
|
||||
session: DbSession,
|
||||
current_user: _SettlementWriter,
|
||||
current_user: PayrollWriter,
|
||||
meta: RequestMetaDep,
|
||||
) -> ApiResponse[dict]:
|
||||
"""新建调整单:开关关闭=提交即生效(自动通过留痕);开启=走审批。"""
|
||||
|
||||
@@ -4,7 +4,8 @@ from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Path, Query
|
||||
|
||||
from app.api.deps import AdminUser, DbSession, RequestMetaDep
|
||||
from app.api.deps import DbSession, PayrollWriter, RequestMetaDep, WorkbenchReader
|
||||
from app.api.v1.system.tplus_workbench import router as workbench_router
|
||||
from app.core.response import ApiResponse, PageResult, ok
|
||||
from app.schemas.tplus import (
|
||||
TPlusBatchDetailOut,
|
||||
@@ -19,23 +20,24 @@ from app.schemas.tplus import (
|
||||
from app.services.tplus import query_service, task_service
|
||||
|
||||
router = APIRouter(prefix="/tplus", tags=["系统管理 · T+ 数据同步"])
|
||||
router.include_router(workbench_router)
|
||||
BatchNumber = Annotated[str, Path(min_length=1, max_length=32, pattern=r"^[A-Za-z0-9_-]+$")]
|
||||
|
||||
|
||||
@router.get("/overview", response_model=ApiResponse[TPlusOverviewOut])
|
||||
def overview(session: DbSession, _: AdminUser):
|
||||
def overview(session: DbSession, _: WorkbenchReader):
|
||||
"""查询配置状态、任务统计和最近核验。"""
|
||||
return ok(query_service.overview(session))
|
||||
|
||||
|
||||
@router.get("/batches", response_model=ApiResponse[PageResult[TPlusBatchOut]])
|
||||
def batches(session: DbSession, _: AdminUser, query: Annotated[TPlusBatchQuery, Query()]):
|
||||
def batches(session: DbSession, _: WorkbenchReader, query: Annotated[TPlusBatchQuery, Query()]):
|
||||
"""分页查询批次元数据。"""
|
||||
return ok(query_service.list_batches(session, query))
|
||||
|
||||
|
||||
@router.get("/batches/{batch_no}", response_model=ApiResponse[TPlusBatchDetailOut])
|
||||
def detail(session: DbSession, _: AdminUser, batch_no: BatchNumber):
|
||||
def detail(session: DbSession, _: WorkbenchReader, batch_no: BatchNumber):
|
||||
"""查询批次详情与受控的核验报告。"""
|
||||
return ok(query_service.get_batch(session, batch_no))
|
||||
|
||||
@@ -43,7 +45,7 @@ def detail(session: DbSession, _: AdminUser, batch_no: BatchNumber):
|
||||
@router.get("/batches/{batch_no}/errors", response_model=ApiResponse[PageResult[TPlusErrorOut]])
|
||||
def errors(
|
||||
session: DbSession,
|
||||
_: AdminUser,
|
||||
_: WorkbenchReader,
|
||||
batch_no: BatchNumber,
|
||||
query: Annotated[TPlusErrorQuery, Query()],
|
||||
):
|
||||
@@ -52,7 +54,7 @@ def errors(
|
||||
|
||||
|
||||
@router.post("/batches", response_model=ApiResponse[TPlusBatchDetailOut])
|
||||
def create(session: DbSession, admin: AdminUser, meta: RequestMetaDep, payload: TPlusCreateIn):
|
||||
def create(session: DbSession, admin: PayrollWriter, meta: RequestMetaDep, payload: TPlusCreateIn):
|
||||
"""将读取或核验任务持久化排队。"""
|
||||
batch = task_service.create_task(session, payload, task_service.TaskActor(admin, meta))
|
||||
return ok(query_service.get_batch(session, batch.batch_no))
|
||||
@@ -61,7 +63,7 @@ def create(session: DbSession, admin: AdminUser, meta: RequestMetaDep, payload:
|
||||
@router.post("/batches/{batch_no}/retry", response_model=ApiResponse[TPlusBatchDetailOut])
|
||||
def retry(
|
||||
session: DbSession,
|
||||
admin: AdminUser,
|
||||
admin: PayrollWriter,
|
||||
meta: RequestMetaDep,
|
||||
batch_no: BatchNumber,
|
||||
payload: TPlusRetryIn,
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
"""T+ 基础导入和预演的管理员入口;HTTP 不执行长查询或核算。"""
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Path, Query
|
||||
from sqlalchemy import func, select
|
||||
from starlette.responses import StreamingResponse
|
||||
|
||||
from app.api.deps import DbSession, PayrollWriter, WorkbenchReader
|
||||
from app.core.response import ApiResponse, PageQuery, PageResult, ok
|
||||
from app.models import TPlusSyncRecord, TPlusWorkLine
|
||||
from app.schemas.tplus_workbench import FactIn, SourceChoiceQuery, WorkCreateIn, WorkQuery
|
||||
from app.services.tplus import workbench
|
||||
from app.services.tplus.report_labels import ACTION_LABELS, reason_label, spreadsheet_value
|
||||
from app.services.tplus.snapshots import source_statement
|
||||
from app.services.tplus.task_service import _source_database
|
||||
|
||||
router = APIRouter()
|
||||
RunNo = Annotated[str, Path(pattern=r"^[A-Za-z0-9_-]+$", max_length=32)]
|
||||
|
||||
|
||||
@router.get("/facts", response_model=ApiResponse[PageResult[dict]])
|
||||
def facts(session: DbSession, _: WorkbenchReader, query: Annotated[WorkQuery, Query()]):
|
||||
"""有界查询每项补充资料的当前版本。"""
|
||||
return ok(workbench.list_facts(session, query))
|
||||
|
||||
|
||||
@router.post("/facts", response_model=ApiResponse[dict])
|
||||
def save_fact(session: DbSession, admin: PayrollWriter, payload: FactIn):
|
||||
"""追加有依据的人工事实;旧版本不覆盖。"""
|
||||
return ok(workbench.save_fact(session, payload, admin.id))
|
||||
|
||||
|
||||
@router.get("/facts/{fact_id}/history", response_model=ApiResponse[PageResult[dict]])
|
||||
def fact_history(
|
||||
session: DbSession, _: WorkbenchReader, fact_id: int, query: Annotated[PageQuery, Query()]
|
||||
):
|
||||
"""按事实身份分页查看修改历史,不允许跨账套读取。"""
|
||||
from app.core.exceptions import NotFoundError
|
||||
from app.models import TPlusFact
|
||||
|
||||
fact = session.get(TPlusFact, fact_id)
|
||||
if fact is None or fact.source_database != _source_database():
|
||||
raise NotFoundError("补充资料不存在")
|
||||
conditions = [
|
||||
TPlusFact.source_database == fact.source_database,
|
||||
TPlusFact.kind == fact.kind,
|
||||
TPlusFact.fact_key == fact.fact_key,
|
||||
]
|
||||
total = session.scalar(select(func.count()).select_from(TPlusFact).where(*conditions))
|
||||
rows = session.scalars(
|
||||
select(TPlusFact)
|
||||
.where(*conditions)
|
||||
.order_by(TPlusFact.version.desc())
|
||||
.offset(query.offset)
|
||||
.limit(query.page_size)
|
||||
)
|
||||
return ok(
|
||||
PageResult(
|
||||
records=[workbench.fact_out(f) for f in rows],
|
||||
total=total or 0,
|
||||
page_num=query.page_num,
|
||||
page_size=query.page_size,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@router.post("/work-runs", response_model=ApiResponse[dict])
|
||||
def create_work(session: DbSession, admin: PayrollWriter, payload: WorkCreateIn):
|
||||
"""创建导入预览、执行导入或核算预演任务。"""
|
||||
return ok(workbench.queue_work(session, payload, admin.id))
|
||||
|
||||
|
||||
@router.get("/work-runs", response_model=ApiResponse[PageResult[dict]])
|
||||
def work_runs(session: DbSession, _: WorkbenchReader, query: Annotated[WorkQuery, Query()]):
|
||||
"""分页查询任务,所有金额结果仅为预演。"""
|
||||
return ok(workbench.list_work(session, query))
|
||||
|
||||
|
||||
@router.get("/work-runs/{run_no}", response_model=ApiResponse[dict])
|
||||
def work_detail(session: DbSession, _: WorkbenchReader, run_no: RunNo):
|
||||
"""查询规则版本、输入指纹及结果。"""
|
||||
return ok(workbench.work_out(workbench.get_run(session, run_no)))
|
||||
|
||||
|
||||
@router.get("/work-runs/{run_no}/lines", response_model=ApiResponse[PageResult[dict]])
|
||||
def work_lines(
|
||||
session: DbSession, _: WorkbenchReader, run_no: RunNo, query: Annotated[WorkQuery, Query()]
|
||||
):
|
||||
"""分页查看新增、待补及分配明细,不公开原始快照 JSON。"""
|
||||
return ok(workbench.list_lines(session, run_no, query))
|
||||
|
||||
|
||||
@router.get("/source-records", response_model=ApiResponse[PageResult[dict]])
|
||||
def source_choices(
|
||||
session: DbSession,
|
||||
_: WorkbenchReader,
|
||||
query: Annotated[SourceChoiceQuery, Query()],
|
||||
):
|
||||
"""仅提供挑选资料所需的源编号和名称,不返回电话、地址和原始载荷。"""
|
||||
from app.core.exceptions import BadRequestError
|
||||
|
||||
try:
|
||||
statement = source_statement(session, _source_database(), query.entity)
|
||||
except workbench.WorkbenchError:
|
||||
raise BadRequestError("请先完整读取相关源数据") from None
|
||||
if query.search:
|
||||
escaped = query.search.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||||
statement = statement.where(TPlusSyncRecord.source_no.like(f"%{escaped}%", escape="\\"))
|
||||
total = session.scalar(select(func.count()).select_from(statement.subquery()))
|
||||
rows = session.scalars(
|
||||
statement.order_by(TPlusSyncRecord.source_id).offset(query.offset).limit(query.page_size)
|
||||
)
|
||||
records = [
|
||||
{
|
||||
"source_id": r.source_id,
|
||||
"source_no": r.source_no,
|
||||
"name": str(r.payload_json.get("name") or ""),
|
||||
"parent_id": str(r.payload_json.get("source_parent_id") or ""),
|
||||
"date": str(
|
||||
r.payload_json.get("voucherdate") or r.payload_json.get("voucherDate") or ""
|
||||
)[:10],
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
return ok(
|
||||
PageResult(
|
||||
records=records, total=total or 0, page_num=query.page_num, page_size=query.page_size
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@router.get("/work-runs/{run_no}/report")
|
||||
def work_report(session: DbSession, _: WorkbenchReader, run_no: RunNo):
|
||||
"""导出全部预演明细,防止源单号被电子表格当作公式执行。"""
|
||||
import csv
|
||||
import io
|
||||
|
||||
workbench.get_run(session, run_no)
|
||||
|
||||
def rows():
|
||||
buffer = io.StringIO()
|
||||
writer = csv.writer(buffer)
|
||||
yield "\ufeff"
|
||||
writer.writerow(
|
||||
[
|
||||
"运行编号",
|
||||
"行号",
|
||||
"类型",
|
||||
"源ID或平账组",
|
||||
"处理结果",
|
||||
"源编号",
|
||||
"名称",
|
||||
"回款单号",
|
||||
"关联凭证示例",
|
||||
"发货单号",
|
||||
"匹配金额",
|
||||
"分配后该明细剩余应收",
|
||||
"回款天数",
|
||||
"指导成本分摊",
|
||||
"折算利润",
|
||||
"利润门槛(百分数)",
|
||||
"计提比例",
|
||||
"计提金额",
|
||||
"归属工号",
|
||||
"原因",
|
||||
"说明",
|
||||
]
|
||||
)
|
||||
yield buffer.getvalue()
|
||||
buffer.seek(0)
|
||||
buffer.truncate(0)
|
||||
statement = (
|
||||
select(TPlusWorkLine)
|
||||
.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)
|
||||
|
||||
return StreamingResponse(
|
||||
rows(),
|
||||
media_type="text/csv; charset=utf-8",
|
||||
headers={"Content-Disposition": f'attachment; filename="{run_no}-preview.csv"'},
|
||||
)
|
||||
+81
-4
@@ -10,9 +10,13 @@
|
||||
|
||||
import argparse
|
||||
import getpass
|
||||
import json
|
||||
import signal
|
||||
import sys
|
||||
import time
|
||||
import threading
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
@@ -115,24 +119,79 @@ def _tplus_worker(args: argparse.Namespace) -> int:
|
||||
print("轮询间隔必须在 2 到 60 秒之间", file=sys.stderr)
|
||||
return 1
|
||||
factory = get_session_factory()
|
||||
settings = get_settings()
|
||||
stop_file = getattr(args, "stop_file", None) or settings.t_plus_stop_file
|
||||
stopped = threading.Event()
|
||||
owner = str(uuid4())
|
||||
previous_handlers = {}
|
||||
if threading.current_thread() is threading.main_thread():
|
||||
for sig in (signal.SIGINT, signal.SIGTERM):
|
||||
previous_handlers[sig] = signal.signal(sig, lambda *_: stopped.set())
|
||||
try:
|
||||
while True:
|
||||
while not stopped.is_set() and not (stop_file and Path(stop_file).exists()):
|
||||
with factory() as session:
|
||||
batch_no = run_once(session)
|
||||
batch_no = run_once(session, worker_owner=owner)
|
||||
batch = get_batch(session, batch_no) if batch_no else None
|
||||
if batch_no and batch is None:
|
||||
from app.models import TPlusWorkRun
|
||||
batch = session.scalar(select(TPlusWorkRun).where(TPlusWorkRun.run_no == batch_no))
|
||||
status = batch.status if batch else None
|
||||
if batch_no:
|
||||
print(f"任务完成:{batch_no} 状态={status}", flush=True)
|
||||
if not args.watch:
|
||||
return 0 if status in (None, "SUCCEEDED") else 1
|
||||
if batch_no is None or status != "SUCCEEDED":
|
||||
time.sleep(args.poll_seconds)
|
||||
stopped.wait(args.poll_seconds)
|
||||
print("T+读取服务已停止领取新任务", flush=True)
|
||||
return 0
|
||||
except KeyboardInterrupt:
|
||||
print("T+ 读取服务已停止")
|
||||
return 130
|
||||
except Exception as exc:
|
||||
print(f"T+ 读取服务停止({type(exc).__name__}),请检查运行环境", file=sys.stderr)
|
||||
return 1
|
||||
finally:
|
||||
for sig, handler in previous_handlers.items():
|
||||
signal.signal(sig, handler)
|
||||
try:
|
||||
from app.services.tplus.automation import heartbeat
|
||||
|
||||
heartbeat(factory, settings, owner, "STOPPED")
|
||||
except Exception as exc:
|
||||
print(f"T+停止状态未能写入({type(exc).__name__}),请检查迁移与运行配置", file=sys.stderr)
|
||||
|
||||
|
||||
def _maintenance(args):
|
||||
from app.services.tplus.automation import tick
|
||||
|
||||
if args.connection_file:
|
||||
settings = get_settings()
|
||||
settings.t_plus_enabled, settings.t_plus_connection_file = True, args.connection_file
|
||||
if args.stop_file:
|
||||
get_settings().t_plus_stop_file = args.stop_file
|
||||
with get_session_factory()() as session:
|
||||
result = tick(session)
|
||||
print(json.dumps(result, ensure_ascii=False))
|
||||
return 0
|
||||
|
||||
|
||||
def _materials(args):
|
||||
from app.services.tplus.materials import collect_materials, render_markdown
|
||||
|
||||
if args.connection_file:
|
||||
settings = get_settings()
|
||||
settings.t_plus_enabled, settings.t_plus_connection_file = True, args.connection_file
|
||||
output = Path(args.output_dir).resolve()
|
||||
targets = [output / f"materials-{args.customer_id}-{args.month}.{ext}" for ext in ("json", "md")]
|
||||
if any(p.exists() for p in targets):
|
||||
raise RuntimeError("核对包已存在,请指定新的输出目录,避免覆盖既有验收依据")
|
||||
with get_tplus_session_factory()() as source:
|
||||
report = collect_materials(source, args.customer_id, args.month)
|
||||
output.mkdir(parents=True, exist_ok=True)
|
||||
targets[0].write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
targets[1].write_text(render_markdown(report), encoding="utf-8")
|
||||
print(f"样本核对包已生成;待补{len(report['missing'])}项;真实金额尚未验收:{output}")
|
||||
return 0
|
||||
|
||||
|
||||
def main() -> int:
|
||||
@@ -182,6 +241,17 @@ def main() -> int:
|
||||
modes.add_argument("--watch", action="store_true", help="持续消费手动排队任务")
|
||||
p_worker.add_argument("--connection-file", default=None, help="显式指定项目外只读连接 JSON")
|
||||
p_worker.add_argument("--poll-seconds", type=int, default=3, help="空闲轮询间隔,2–60 秒")
|
||||
p_worker.add_argument("--stop-file", default=None, help="检测到停止标记后不再领取新任务")
|
||||
|
||||
p_maintenance = sub.add_parser("tplus-maintain", help="检查每周原始采集与有限重试,不生成工资")
|
||||
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_materials = sub.add_parser("tplus-materials", help="只读整理客户样本核对资料,不生成工资")
|
||||
p_materials.add_argument("--customer-id", type=int, required=True)
|
||||
p_materials.add_argument("--month", required=True)
|
||||
p_materials.add_argument("--connection-file", default=None)
|
||||
p_materials.add_argument("--output-dir", required=True)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
@@ -190,7 +260,14 @@ def main() -> int:
|
||||
return _create_admin(args)
|
||||
if args.command == "tplus-worker":
|
||||
return _tplus_worker(args)
|
||||
if args.command == "tplus-maintain":
|
||||
return _maintenance(args)
|
||||
if args.command == "tplus-materials":
|
||||
return _materials(args)
|
||||
return _stage_tplus(args)
|
||||
except Exception as exc:
|
||||
print(f"命令执行失败({type(exc).__name__}),请检查参数和只读连接配置", file=sys.stderr)
|
||||
return 1
|
||||
finally:
|
||||
dispose_engine()
|
||||
dispose_tplus_engine()
|
||||
|
||||
@@ -36,6 +36,11 @@ class Settings(BaseSettings):
|
||||
# ===== 应用 =====
|
||||
app_env: str = "dev"
|
||||
app_secret_key: str = Field(description="JWT 签名密钥,至少 32 字符")
|
||||
payroll_maker_user_id: int | None = Field(
|
||||
default=None, ge=1, description="指定工资制作人的现有账号ID;未设置则业务只读"
|
||||
)
|
||||
payroll_allow_legacy_calculation: bool = False
|
||||
payroll_approval_enabled: bool = False
|
||||
|
||||
# ===== 鉴权 =====
|
||||
# 规范第 13 节:Token 有效期 ≤ 2 小时
|
||||
@@ -74,6 +79,10 @@ class Settings(BaseSettings):
|
||||
t_plus_connect_timeout: int = 10
|
||||
t_plus_query_timeout: int = Field(default=120, ge=1, le=600)
|
||||
t_plus_query_dir: str = ""
|
||||
t_plus_automation_enabled: bool = False
|
||||
t_plus_schedule_weekday: int = Field(default=0, ge=0, le=6)
|
||||
t_plus_schedule_time: str = Field(default="02:00", pattern=r"^([01][0-9]|2[0-3]):[0-5][0-9]$")
|
||||
t_plus_stop_file: str = ""
|
||||
|
||||
@field_validator("app_secret_key")
|
||||
@classmethod
|
||||
@@ -120,9 +129,7 @@ class Settings(BaseSettings):
|
||||
query = {
|
||||
"driver": self.t_plus_odbc_driver,
|
||||
"Encrypt": "yes" if self.t_plus_encrypt else "no",
|
||||
"TrustServerCertificate": "yes"
|
||||
if self.t_plus_trust_server_certificate
|
||||
else "no",
|
||||
"TrustServerCertificate": "yes" if self.t_plus_trust_server_certificate else "no",
|
||||
"Connection Timeout": str(self.t_plus_connect_timeout),
|
||||
}
|
||||
return URL.create(
|
||||
|
||||
@@ -17,6 +17,7 @@ from app.models.customer import Customer, OwnershipChangeLog
|
||||
from app.models.login_log import LoginLog
|
||||
from app.models.operation_log import OperationLog
|
||||
from app.models.param import ParamChangeBatch, ParamDefinition, ParamValue
|
||||
from app.models.payroll import PayrollAllocation, PayrollInput, PayrollMonth
|
||||
from app.models.performance_ledger import PerformanceLedger
|
||||
from app.models.personnel import Personnel, PersonnelPlan, PersonnelRegion
|
||||
from app.models.product import Product
|
||||
@@ -26,12 +27,20 @@ from app.models.role import Permission, Role, RolePermission
|
||||
from app.models.settlement import AdjustmentOrder, MonthlySettlement, SettlementItem
|
||||
from app.models.shipment import Shipment, ShipmentItem
|
||||
from app.models.system_setting import SystemSetting
|
||||
from app.models.tplus_automation import TPlusAutomationState
|
||||
from app.models.tplus_sync import (
|
||||
TPlusSyncBatch,
|
||||
TPlusSyncError,
|
||||
TPlusSyncRecord,
|
||||
TPlusSyncWatermark,
|
||||
)
|
||||
from app.models.tplus_workbench import (
|
||||
TPlusFact,
|
||||
TPlusSourceHead,
|
||||
TPlusSourceLink,
|
||||
TPlusWorkLine,
|
||||
TPlusWorkRun,
|
||||
)
|
||||
from app.models.user import User, UserRole
|
||||
|
||||
__all__ = [
|
||||
@@ -52,6 +61,9 @@ __all__ = [
|
||||
"ParamChangeBatch",
|
||||
"ParamDefinition",
|
||||
"ParamValue",
|
||||
"PayrollAllocation",
|
||||
"PayrollInput",
|
||||
"PayrollMonth",
|
||||
"PerformanceLedger",
|
||||
"Permission",
|
||||
"Personnel",
|
||||
@@ -66,10 +78,16 @@ __all__ = [
|
||||
"Shipment",
|
||||
"ShipmentItem",
|
||||
"SystemSetting",
|
||||
"TPlusAutomationState",
|
||||
"TPlusFact",
|
||||
"TPlusSourceHead",
|
||||
"TPlusSourceLink",
|
||||
"TPlusSyncBatch",
|
||||
"TPlusSyncError",
|
||||
"TPlusSyncRecord",
|
||||
"TPlusSyncWatermark",
|
||||
"TPlusWorkLine",
|
||||
"TPlusWorkRun",
|
||||
"TimestampMixin",
|
||||
"User",
|
||||
"UserRole",
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
"""正式工资输入、分配过程与月度生成锁,和隔离预演分别留存。"""
|
||||
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import JSON, DateTime, Index, Integer, Numeric, String, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.models.base import BIGINT_UNSIGNED, Base, TimestampMixin, mysql_table_args
|
||||
|
||||
|
||||
class PayrollMonth(Base, TimestampMixin):
|
||||
"""先锁月份行再修改方案或生成工资;生成标记不随审批退回清除。"""
|
||||
|
||||
__tablename__ = "payroll_months"
|
||||
__table_args__ = mysql_table_args("工资月份控制表(首次生成即锁定分配,审批不解锁)")
|
||||
id: Mapped[int] = mapped_column(
|
||||
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
|
||||
)
|
||||
month: Mapped[str] = mapped_column(String(7), unique=True, comment="工资月份")
|
||||
maker_id: Mapped[int] = mapped_column(BIGINT_UNSIGNED, comment="实际工资制作人账号ID")
|
||||
maker_name: Mapped[str] = mapped_column(String(64), comment="制作人用户名快照")
|
||||
generated_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime, comment="首次成功生成时间(不可解锁)"
|
||||
)
|
||||
|
||||
|
||||
class PayrollInput(Base, TimestampMixin):
|
||||
"""正式核算输入的版本:来源、成本、归属及期初均有依据,旧版本不覆盖。"""
|
||||
|
||||
__tablename__ = "payroll_inputs"
|
||||
__table_args__ = mysql_table_args(
|
||||
"正式工资输入版本表",
|
||||
UniqueConstraint("business_id", "version", name="uk_payroll_inputs_business_version"),
|
||||
Index("idx_payroll_inputs_source_group", "source_database", "group_key"),
|
||||
)
|
||||
id: Mapped[int] = mapped_column(
|
||||
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
|
||||
)
|
||||
business_id: Mapped[int] = mapped_column(BIGINT_UNSIGNED, comment="正式业务ID")
|
||||
version: Mapped[int] = mapped_column(Integer, comment="输入版本")
|
||||
source_database: Mapped[str] = mapped_column(String(128), comment="源账套或人工来源")
|
||||
group_key: Mapped[str] = mapped_column(String(128), comment="独立业务平账组")
|
||||
fingerprint: Mapped[str] = mapped_column(String(64), comment="来源及事实指纹")
|
||||
data_json: Mapped[dict] = mapped_column(JSON, comment="经核实的源数据及人工事实快照")
|
||||
requested_by: Mapped[int] = mapped_column(BIGINT_UNSIGNED, comment="录入人账号ID")
|
||||
|
||||
|
||||
class PayrollAllocation(Base, TimestampMixin):
|
||||
"""一次正式计算的逐段过程;版本不可变,生效性由所属台账控制。"""
|
||||
|
||||
__tablename__ = "payroll_allocations"
|
||||
__table_args__ = mysql_table_args(
|
||||
"正式FIFO分配明细表",
|
||||
UniqueConstraint("ledger_id", "line_no", name="uk_payroll_allocations_ledger_line"),
|
||||
Index("idx_payroll_allocations_business_id", "business_id"),
|
||||
)
|
||||
id: Mapped[int] = mapped_column(
|
||||
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
|
||||
)
|
||||
ledger_id: Mapped[int] = mapped_column(BIGINT_UNSIGNED, comment="所属正式台账ID")
|
||||
business_id: Mapped[int] = mapped_column(BIGINT_UNSIGNED, comment="正式业务ID")
|
||||
line_no: Mapped[int] = mapped_column(Integer, comment="本台账稳定明细序号")
|
||||
input_id: Mapped[int] = mapped_column(BIGINT_UNSIGNED, comment="所用输入版本ID")
|
||||
amount: Mapped[Decimal] = mapped_column(Numeric(14, 2), comment="分配回款金额")
|
||||
data_json: Mapped[dict] = mapped_column(JSON, comment="单据、成本、归属、参数和逐段计算依据")
|
||||
@@ -55,6 +55,7 @@ class PerformanceLedger(Base, TimestampMixin):
|
||||
"receipt_id",
|
||||
"calc_version",
|
||||
"personnel_id",
|
||||
"allocation_key",
|
||||
name="uk_performance_ledgers_receipt_id_calc_version_personnel",
|
||||
),
|
||||
Index("idx_performance_ledgers_month_active", "settlement_month", "is_active"),
|
||||
@@ -65,6 +66,7 @@ class PerformanceLedger(Base, TimestampMixin):
|
||||
"calc_type",
|
||||
),
|
||||
Index("idx_performance_ledgers_personnel_id", "personnel_id"),
|
||||
Index("idx_performance_ledgers_receipt_id", "receipt_id"),
|
||||
Index("idx_performance_ledgers_business_id", "business_id"),
|
||||
)
|
||||
|
||||
@@ -82,6 +84,9 @@ class PerformanceLedger(Base, TimestampMixin):
|
||||
server_default="1",
|
||||
comment="核算版本(重算+1,旧行 is_active=0)",
|
||||
)
|
||||
allocation_key: Mapped[str] = mapped_column(
|
||||
String(64), nullable=False, server_default="''", comment="正式FIFO事件键;历史单笔核算为空串",
|
||||
)
|
||||
is_active: Mapped[int] = mapped_column(
|
||||
TINYINT_TYPE,
|
||||
nullable=False,
|
||||
|
||||
@@ -79,6 +79,10 @@ class MonthlySettlement(Base, TimestampMixin):
|
||||
)
|
||||
locked_by: Mapped[str | None] = mapped_column(String(64), comment="锁定操作人(冗余固化)")
|
||||
|
||||
@property
|
||||
def approval_required(self) -> bool:
|
||||
return bool((self.snapshot_json or {}).get("approval_required", True))
|
||||
|
||||
|
||||
class SettlementItem(Base):
|
||||
"""月结明细表:按人按核算类型固化金额(锁定后只读;重生成=草稿态删旧插新)。"""
|
||||
@@ -154,6 +158,9 @@ class AdjustmentOrder(Base, TimestampMixin):
|
||||
id: Mapped[int] = mapped_column(
|
||||
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
|
||||
)
|
||||
adjustment_type: Mapped[str] = mapped_column(String(32), nullable=False, server_default="'GENERAL'", comment="调整类型:GENERAL普通 ALLOCATION分配差额")
|
||||
source_ledger_id: Mapped[int | None] = mapped_column(BIGINT_UNSIGNED, comment="分配差额对应的原台账ID")
|
||||
attribution_json: Mapped[dict | None] = mapped_column(JSON, comment="原台账业务与分管归属快照")
|
||||
order_no: Mapped[str] = mapped_column(
|
||||
String(32),
|
||||
nullable=False,
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
"""自动采集的周期、重试和进程心跳;不承载业务核算状态。"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, Integer, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.models.base import BIGINT_UNSIGNED, Base, TimestampMixin, mysql_table_args
|
||||
|
||||
|
||||
class TPlusAutomationState(Base, TimestampMixin):
|
||||
__tablename__ = "tplus_automation_states"
|
||||
__table_args__ = mysql_table_args("T+自动采集周期与进程状态(不自动导入正式业务)")
|
||||
id: Mapped[int] = mapped_column(BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID")
|
||||
source_key: Mapped[str] = mapped_column(String(64), unique=True, comment="源连接身份摘要(不含凭据)")
|
||||
source_database: Mapped[str] = mapped_column(String(128), comment="源账套")
|
||||
scheduled_period: Mapped[str | None] = mapped_column(String(16), comment="最近已排队ISO周")
|
||||
root_batch_no: Mapped[str | None] = mapped_column(String(32), comment="本周期原始采集批次")
|
||||
current_batch_no: Mapped[str | None] = mapped_column(String(32), comment="当前原始或重试批次")
|
||||
retry_attempts: Mapped[int] = mapped_column(Integer, server_default="0", comment="本周期自动重试次数")
|
||||
next_retry_at: Mapped[datetime | None] = mapped_column(DateTime, comment="下一次自动重试时间")
|
||||
last_success_at: Mapped[datetime | None] = mapped_column(DateTime, comment="最近成功采集时间")
|
||||
maintenance_at: Mapped[datetime | None] = mapped_column(DateTime, comment="最近周期检查时间")
|
||||
heartbeat_at: Mapped[datetime | None] = mapped_column(DateTime, comment="独立进程心跳时间")
|
||||
worker_owner: Mapped[str | None] = mapped_column(String(36), comment="当前持锁进程身份")
|
||||
worker_phase: Mapped[str] = mapped_column(String(16), server_default="STOPPED", comment="进程状态:IDLE/RUNNING/STOPPED")
|
||||
@@ -63,6 +63,8 @@ class TPlusSyncBatch(Base, TimestampMixin):
|
||||
requested_by: Mapped[int | None] = mapped_column(
|
||||
BIGINT_UNSIGNED, comment="请求用户ID,CLI任务为空,无物理外键"
|
||||
)
|
||||
trigger_type: Mapped[str] = mapped_column(String(16), server_default="'MANUAL'", comment="触发来源:MANUAL/SCHEDULED/AUTO_RETRY")
|
||||
automatic_attempt: Mapped[int] = mapped_column(Integer, server_default="0", comment="自动重试序号(0为原始任务)")
|
||||
audit_result_json: Mapped[dict | None] = mapped_column(
|
||||
JSON, comment="关联核验结果,只含系统字典和汇总"
|
||||
)
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
"""T+ 基础导入与试算的隔离工作区,任何表均不关联正式工资台账。"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import JSON, DateTime, Index, Integer, String, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.models.base import BIGINT_UNSIGNED, Base, TimestampMixin, mysql_table_args
|
||||
|
||||
|
||||
class TPlusSourceHead(Base):
|
||||
"""一次完整读取实际观察到的源版本;解决 A→B→A 去重后的旧版本误选。"""
|
||||
|
||||
__tablename__ = "tplus_source_heads"
|
||||
__table_args__ = mysql_table_args(
|
||||
"T+ 源当前快照指针(按成功全量批次读取,删除不自动回写业务表)",
|
||||
UniqueConstraint(
|
||||
"source_database",
|
||||
"entity_type",
|
||||
"source_id",
|
||||
name="uk_tplus_source_heads_source_entity_id",
|
||||
),
|
||||
Index("idx_tplus_source_heads_batch_entity", "batch_no", "entity_type"),
|
||||
)
|
||||
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")
|
||||
record_id: Mapped[int] = mapped_column(BIGINT_UNSIGNED, comment="原始快照ID")
|
||||
batch_no: Mapped[str] = mapped_column(String(32), comment="最后观察批次")
|
||||
|
||||
|
||||
class TPlusFact(Base, TimestampMixin):
|
||||
"""补充资料追加版本,旧版本永久保留,乐观锁防止静默覆盖。"""
|
||||
|
||||
__tablename__ = "tplus_facts"
|
||||
__table_args__ = mysql_table_args(
|
||||
"T+ 人工补充事实版本表",
|
||||
UniqueConstraint(
|
||||
"source_database",
|
||||
"kind",
|
||||
"fact_key",
|
||||
"version",
|
||||
name="uk_tplus_facts_source_kind_key_version",
|
||||
),
|
||||
)
|
||||
id: Mapped[int] = mapped_column(
|
||||
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
|
||||
)
|
||||
source_database: Mapped[str] = mapped_column(String(128), comment="源账套")
|
||||
kind: Mapped[str] = mapped_column(String(32), comment="事实类别")
|
||||
fact_key: Mapped[str] = mapped_column(String(128), comment="事实业务键")
|
||||
version: Mapped[int] = mapped_column(Integer, comment="版本号")
|
||||
data_json: Mapped[dict] = mapped_column(JSON, comment="经校验的事实内容")
|
||||
evidence: Mapped[str] = mapped_column(String(500), comment="依据和修改原因")
|
||||
requested_by: Mapped[int] = mapped_column(BIGINT_UNSIGNED, comment="操作者ID")
|
||||
|
||||
|
||||
class TPlusSourceLink(Base, TimestampMixin):
|
||||
"""源稳定身份与业务目标的唯一关联;不按名称猜测合并。"""
|
||||
|
||||
__tablename__ = "tplus_source_links"
|
||||
__table_args__ = mysql_table_args(
|
||||
"T+ 基础数据源目标关联",
|
||||
UniqueConstraint(
|
||||
"source_database",
|
||||
"entity_type",
|
||||
"source_id",
|
||||
name="uk_tplus_source_links_source_entity_id",
|
||||
),
|
||||
UniqueConstraint("entity_type", "target_id", name="uk_tplus_source_links_entity_target"),
|
||||
)
|
||||
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")
|
||||
target_id: Mapped[int] = mapped_column(BIGINT_UNSIGNED, comment="业务目标ID")
|
||||
|
||||
|
||||
class TPlusWorkRun(Base, TimestampMixin):
|
||||
"""后台导入、预览和试算运行记录,固定输入,保留终态。"""
|
||||
|
||||
__tablename__ = "tplus_work_runs"
|
||||
__table_args__ = mysql_table_args(
|
||||
"T+ 导入与预演运行表",
|
||||
UniqueConstraint("run_no", name="uk_tplus_work_runs_run_no"),
|
||||
UniqueConstraint("request_key", name="uk_tplus_work_runs_request_key"),
|
||||
Index("idx_tplus_work_runs_status_id", "status", "id"),
|
||||
)
|
||||
id: Mapped[int] = mapped_column(
|
||||
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
|
||||
)
|
||||
run_no: Mapped[str] = mapped_column(String(32), comment="运行编号")
|
||||
request_key: Mapped[str] = mapped_column(String(36), comment="请求幂等键")
|
||||
kind: Mapped[str] = mapped_column(
|
||||
String(32), comment="MASTER_PREVIEW/MASTER_APPLY/CALC_PREVIEW"
|
||||
)
|
||||
status: Mapped[str] = mapped_column(String(16), default="QUEUED", comment="运行状态")
|
||||
source_database: Mapped[str] = mapped_column(String(128), comment="固定源账套")
|
||||
requested_by: Mapped[int] = mapped_column(BIGINT_UNSIGNED, comment="操作者ID")
|
||||
input_json: Mapped[dict] = mapped_column(JSON, comment="请求输入")
|
||||
snapshot_json: Mapped[dict | None] = mapped_column(JSON, comment="规则和补充资料的固定快照")
|
||||
snapshot_hash: Mapped[str | None] = mapped_column(String(64), comment="输入指纹")
|
||||
summary_json: Mapped[dict | None] = mapped_column(JSON, comment="结果汇总")
|
||||
error_code: Mapped[str | None] = mapped_column(String(64), comment="固定错误编码")
|
||||
started_at: Mapped[datetime | None] = mapped_column(DateTime, comment="开始时间")
|
||||
finished_at: Mapped[datetime | None] = mapped_column(DateTime, comment="结束时间")
|
||||
|
||||
|
||||
class TPlusWorkLine(Base):
|
||||
"""固定输入及结果逐行留存,仅经授权的工作区接口可访问。"""
|
||||
|
||||
__tablename__ = "tplus_work_lines"
|
||||
__table_args__ = mysql_table_args(
|
||||
"T+ 导入预览与核算预演明细",
|
||||
UniqueConstraint("run_no", "line_no", name="uk_tplus_work_lines_run_line"),
|
||||
)
|
||||
id: Mapped[int] = mapped_column(
|
||||
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
|
||||
)
|
||||
run_no: Mapped[str] = mapped_column(String(32), comment="运行编号")
|
||||
line_no: Mapped[int] = mapped_column(Integer, comment="稳定行序号")
|
||||
entity_type: Mapped[str] = mapped_column(String(32), comment="实体或分组类型")
|
||||
source_id: Mapped[str] = mapped_column(String(128), comment="源ID或平账组")
|
||||
action: Mapped[str] = mapped_column(String(32), comment="新增/更新/跳过/待补/冲突/分配")
|
||||
reason: Mapped[str | None] = mapped_column(String(128), comment="固定原因编码")
|
||||
data_json: Mapped[dict] = mapped_column(JSON, comment="最小必要的业务明细")
|
||||
|
||||
|
||||
WORKBENCH_MODELS = (TPlusSourceHead, TPlusFact, TPlusSourceLink, TPlusWorkRun, TPlusWorkLine)
|
||||
@@ -56,6 +56,8 @@ class UserBrief(BaseModel):
|
||||
username: str
|
||||
real_name: str | None = None
|
||||
roles: list[str] = Field(default_factory=list, description="角色编码列表")
|
||||
can_write_payroll: bool = False
|
||||
payroll_approval_enabled: bool = False
|
||||
|
||||
|
||||
class LoginOut(BaseModel):
|
||||
|
||||
@@ -250,6 +250,8 @@ class BusinessCreateIn(BaseModel):
|
||||
class BusinessUpdateIn(BaseModel):
|
||||
"""编辑业务(全量替换明细与二次分配方案;归属不在此端点调整,走 /ownership)。"""
|
||||
|
||||
|
||||
|
||||
model_config = ConfigDict(str_strip_whitespace=True)
|
||||
|
||||
customer_id: int = Field(ge=1, description="客户ID")
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
"""正式工资核算的最小输入契约;金额使用字符串,资料版本显式校验。"""
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
from app.core.month import validate_month
|
||||
from app.schemas.tplus_workbench import HandoverFact, _decimal
|
||||
|
||||
|
||||
class PayrollMonthIn(BaseModel):
|
||||
month: str
|
||||
reason: str | None = Field(default=None, min_length=1, max_length=255)
|
||||
|
||||
@field_validator("month")
|
||||
@classmethod
|
||||
def check_month(cls, value):
|
||||
return validate_month(value)
|
||||
|
||||
|
||||
class ShipmentOwnershipIn(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
shipment_item_id: int = Field(ge=1)
|
||||
personnel_id: int = Field(ge=1)
|
||||
manager_id: int | None = Field(default=None, ge=1)
|
||||
unit_cost: str
|
||||
|
||||
@field_validator("unit_cost")
|
||||
@classmethod
|
||||
def check_cost(cls, value):
|
||||
_decimal(value, 4)
|
||||
return value
|
||||
|
||||
|
||||
class PayrollSetupIn(PayrollMonthIn):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
expected_version: int = Field(default=0, ge=0)
|
||||
evidence: str = Field(min_length=1, max_length=500)
|
||||
opening_balance: str
|
||||
history_confirmed: Literal[True]
|
||||
guidance_finalized: Literal[True]
|
||||
shipment_ownerships: list[ShipmentOwnershipIn] = Field(min_length=1, max_length=10000)
|
||||
handovers: list[HandoverFact] = Field(default_factory=list, max_length=1000)
|
||||
|
||||
@field_validator("opening_balance")
|
||||
@classmethod
|
||||
def check_opening(cls, value):
|
||||
_decimal(value, 2)
|
||||
return value
|
||||
|
||||
|
||||
class AllocationAdjustmentIn(BaseModel):
|
||||
"""分配差额仅允许归生成当月;关联已结算原台账留痕。"""
|
||||
|
||||
ledger_id: int = Field(ge=1)
|
||||
personnel_id: int = Field(ge=1)
|
||||
direction: Literal[1, 2]
|
||||
amount: str = Field(pattern=r"^\d{1,12}(\.\d{1,2})?$")
|
||||
reason: str = Field(min_length=1, max_length=255)
|
||||
@@ -62,6 +62,7 @@ class SettlementOut(BaseModel):
|
||||
total_payable: Decimal | None = None
|
||||
person_count: int
|
||||
approval_order_no: str | None = None
|
||||
approval_required: bool = False
|
||||
submitter_name: str
|
||||
locked_at: datetime | datetime | None = None
|
||||
locked_by: str | None = None
|
||||
@@ -89,6 +90,7 @@ class AdjustmentCreateIn(BaseModel):
|
||||
description="金额(元,两位小数,恒正)",
|
||||
)
|
||||
reason: str = Field(min_length=1, max_length=255, description="扣补说明")
|
||||
source_ledger_id: int | None = Field(default=None, ge=1, description="分配差额所对应的已生成工资台账")
|
||||
|
||||
@field_validator("settlement_month")
|
||||
@classmethod
|
||||
|
||||
@@ -77,6 +77,8 @@ class TPlusBatchOut(BaseModel):
|
||||
finished_at: datetime | None
|
||||
can_retry: bool
|
||||
error_summary: str | None = None
|
||||
trigger_type: str = "MANUAL"
|
||||
automatic_attempt: int = 0
|
||||
|
||||
|
||||
class TPlusBatchDetailOut(TPlusBatchOut):
|
||||
@@ -97,6 +99,25 @@ class TPlusErrorOut(BaseModel):
|
||||
create_time: datetime
|
||||
|
||||
|
||||
class TPlusAutomationAlert(BaseModel):
|
||||
code: str
|
||||
message: str
|
||||
batch_no: str | None = None
|
||||
|
||||
|
||||
class TPlusAutomationOverview(BaseModel):
|
||||
enabled: bool = False
|
||||
weekday: int = 0
|
||||
time: str = "02:00"
|
||||
timezone: str = "Asia/Shanghai"
|
||||
worker_phase: str = "UNKNOWN"
|
||||
heartbeat_at: datetime | None = None
|
||||
last_success_at: datetime | None = None
|
||||
next_retry_at: datetime | None = None
|
||||
stop_requested: bool = False
|
||||
alerts: list[TPlusAutomationAlert] = Field(default_factory=list)
|
||||
|
||||
|
||||
class TPlusOverviewOut(BaseModel):
|
||||
"""配置就绪不等同于源库连通或兼容验收通过。"""
|
||||
|
||||
@@ -107,3 +128,4 @@ class TPlusOverviewOut(BaseModel):
|
||||
compatibility_status: str = "PENDING"
|
||||
business_import_enabled: bool = False
|
||||
latest_audit: dict | None = None
|
||||
automation: TPlusAutomationOverview = Field(default_factory=TPlusAutomationOverview)
|
||||
@@ -0,0 +1,253 @@
|
||||
"""基础导入与试算入口:补充事实必须给出依据,金额只接受字符串。"""
|
||||
|
||||
from datetime import date
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from typing import Literal
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
|
||||
from app.core.response import PageQuery
|
||||
|
||||
|
||||
class FactData(BaseModel):
|
||||
"""不同类型通过事实入口分别校验,保留未确定值而不猜默认口径。"""
|
||||
|
||||
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
|
||||
|
||||
|
||||
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]
|
||||
|
||||
|
||||
class ProductFact(FactData):
|
||||
category: Literal["municipal_water", "rail_transit"]
|
||||
unit: str = Field(min_length=1, max_length=16)
|
||||
|
||||
|
||||
class CustomerFact(FactData):
|
||||
customer_type: Literal["personal", "manager_personal", "enterprise"]
|
||||
owner_name: str = Field(min_length=1, max_length=64)
|
||||
|
||||
|
||||
class GroupFact(FactData):
|
||||
customer_id: str = Field(pattern=r"^[0-9]+$", max_length=64)
|
||||
shipment_ids: list[str] = Field(default_factory=list, max_length=10000)
|
||||
receipt_ids: list[str] = Field(default_factory=list, max_length=10000)
|
||||
scope_confirmed: bool = False
|
||||
opening_confirmed: bool = False
|
||||
opening_balance: str | None = None
|
||||
historical_settlement_confirmed: bool = False
|
||||
excluded_receipt_ids: list[str] = Field(default_factory=list, max_length=10000)
|
||||
guidance_month_basis: Literal["SHIPMENT", "RECEIPT"] | None = "SHIPMENT"
|
||||
prepayment_parameter_basis: Literal["SHIPMENT", "RECEIPT"] | None = None
|
||||
august_confirmed: bool = False
|
||||
|
||||
@model_validator(mode="after")
|
||||
def check_group(self):
|
||||
"""只允许显式数字源ID,不能通过组成员重叠重复消耗资金。"""
|
||||
for values in (self.shipment_ids, self.receipt_ids, self.excluded_receipt_ids):
|
||||
if len(set(values)) != len(values) or any(
|
||||
not v.isascii() or not v.isdigit() for v in values
|
||||
):
|
||||
raise ValueError("平账组成员必须是无重复的源数字ID")
|
||||
if set(self.excluded_receipt_ids) - set(self.receipt_ids):
|
||||
raise ValueError("历史已结算款必须属于本组所选回款")
|
||||
if self.opening_balance is not None:
|
||||
_decimal(self.opening_balance, 2)
|
||||
if self.opening_confirmed and self.opening_balance is None:
|
||||
raise ValueError("确认期初应收时必须填写余额,已核实无应收请填0")
|
||||
return self
|
||||
|
||||
|
||||
def _decimal(value: str, scale: int):
|
||||
try:
|
||||
amount = Decimal(value)
|
||||
except InvalidOperation:
|
||||
raise ValueError("金额格式不正确") from None
|
||||
if not amount.is_finite() or amount < 0 or amount > Decimal("999999999999"):
|
||||
raise ValueError("金额必须是有效的非负数")
|
||||
if amount.as_tuple().exponent < -scale:
|
||||
raise ValueError(f"最多保留{scale}位小数")
|
||||
return amount
|
||||
|
||||
|
||||
class GuidanceFact(FactData):
|
||||
product_id: str = Field(pattern=r"^[0-9]+$", max_length=64)
|
||||
month: str = Field(pattern=r"^20[0-9]{2}-(0[1-9]|1[0-2])$")
|
||||
basis: Literal["UNIT", "LINE_TOTAL"]
|
||||
amount: str
|
||||
unit: str = Field(min_length=1, max_length=16)
|
||||
shipment_line_id: str | None = None
|
||||
is_finalized: bool = False
|
||||
|
||||
@model_validator(mode="after")
|
||||
def check_amount(self):
|
||||
_decimal(self.amount, 4 if self.basis == "UNIT" else 2)
|
||||
if self.basis == "LINE_TOTAL" and not self.shipment_line_id:
|
||||
raise ValueError("明细总成本必须指定发货明细ID")
|
||||
return self
|
||||
|
||||
|
||||
class ReceiptFact(FactData):
|
||||
voucher_confirmed: bool
|
||||
voucher_reference: str = Field(min_length=1, max_length=128)
|
||||
|
||||
|
||||
class ShipmentFact(FactData):
|
||||
"""运费是否已剔除须逐明细确认,不将未知运费当作0。"""
|
||||
|
||||
sales_amount_excludes_freight: bool
|
||||
freight_amount: str | None = None
|
||||
business_key: str = Field(min_length=1, max_length=64)
|
||||
personnel_id: str = Field(min_length=1, max_length=64)
|
||||
date_override: date | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def check_date_rule(self):
|
||||
if self.freight_amount is not None:
|
||||
freight = _decimal(self.freight_amount, 2)
|
||||
if self.sales_amount_excludes_freight and freight != 0:
|
||||
raise ValueError("已剔除运费的销售额不能再次扣除运费")
|
||||
if self.date_override is not None:
|
||||
raise ValueError("日期更正规则尚未确认,本轮只采用源销货单日期")
|
||||
return self
|
||||
|
||||
|
||||
class HandoverFact(FactData):
|
||||
business_key: str = Field(min_length=1, max_length=64)
|
||||
mode: Literal["RESIGNATION", "ACTIVE"]
|
||||
from_personnel_id: str = Field(min_length=1, max_length=64)
|
||||
to_personnel_id: str = Field(min_length=1, max_length=64)
|
||||
effective_date: date
|
||||
shipping_ownership_confirmed: bool = False
|
||||
|
||||
|
||||
class ManagerFact(FactData):
|
||||
business_key: str = Field(min_length=1, max_length=64)
|
||||
manager_id: str = Field(min_length=1, max_length=64)
|
||||
effective_date: date
|
||||
|
||||
|
||||
class CorrectionFact(FactData):
|
||||
"""已核对的 T+ 更正单处理事实。
|
||||
|
||||
更正单仍保留在源快照中;绩效核算只消费人工明确排除的发货,
|
||||
跨客户回款则通过 redirect_business_key 归入原业务,避免在新客户下重复计提。
|
||||
"""
|
||||
|
||||
entity: Literal["SHIPMENT", "RECEIPT"]
|
||||
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)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def check_correction(self):
|
||||
if self.entity == "SHIPMENT" and not self.exclude_performance:
|
||||
raise ValueError("更正发货必须明确排除绩效核算")
|
||||
if self.entity == "RECEIPT" and not self.redirect_business_key:
|
||||
raise ValueError("跨客户回款必须指定归入的原业务标识")
|
||||
if self.entity == "SHIPMENT" and self.redirect_business_key:
|
||||
raise ValueError("更正发货不能通过回款归属字段转移业务")
|
||||
return self
|
||||
|
||||
|
||||
FACT_TYPES = {
|
||||
"PERSONNEL": PersonFact,
|
||||
"PRODUCT": ProductFact,
|
||||
"CUSTOMER": CustomerFact,
|
||||
"GROUP": GroupFact,
|
||||
"GUIDANCE": GuidanceFact,
|
||||
"RECEIPT": ReceiptFact,
|
||||
"SHIPMENT": ShipmentFact,
|
||||
"HANDOVER": HandoverFact,
|
||||
"MANAGER": ManagerFact,
|
||||
"CORRECTION": CorrectionFact,
|
||||
}
|
||||
|
||||
|
||||
class FactIn(BaseModel):
|
||||
"""追加资料版本;expected_version=0 表示首次录入。"""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
kind: Literal[
|
||||
"PERSONNEL",
|
||||
"PRODUCT",
|
||||
"CUSTOMER",
|
||||
"GROUP",
|
||||
"GUIDANCE",
|
||||
"RECEIPT",
|
||||
"SHIPMENT",
|
||||
"HANDOVER",
|
||||
"MANAGER",
|
||||
"CORRECTION",
|
||||
]
|
||||
fact_key: str = Field(min_length=1, max_length=128, pattern=r"^[A-Za-z0-9_.:-]+$")
|
||||
expected_version: int = Field(default=0, ge=0)
|
||||
evidence: str = Field(min_length=1, max_length=500)
|
||||
data: dict
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_data(self):
|
||||
self.evidence = self.evidence.strip()
|
||||
if not self.evidence:
|
||||
raise ValueError("确认依据不能为空")
|
||||
self.data = FACT_TYPES[self.kind].model_validate(self.data).model_dump(mode="json")
|
||||
return self
|
||||
|
||||
|
||||
class WorkCreateIn(BaseModel):
|
||||
"""创建异步工作;所有参数固定后再排队,重试须使用新的请求键。"""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
kind: Literal["MASTER_PREVIEW", "MASTER_APPLY", "CALC_PREVIEW", "BUSINESS_PREVIEW", "BUSINESS_APPLY"]
|
||||
request_key: UUID
|
||||
entity_types: list[Literal["personnel", "products", "customers"]] = Field(default_factory=list)
|
||||
source_ids: list[str] = Field(default_factory=list, max_length=10000)
|
||||
preview_no: str | None = Field(default=None, max_length=32, pattern=r"^[A-Za-z0-9_-]+$")
|
||||
replay_no: str | None = Field(default=None, max_length=32, pattern=r"^[A-Za-z0-9_-]+$")
|
||||
group_keys: list[str] = Field(default_factory=list, max_length=100)
|
||||
month: str | None = Field(default=None, pattern=r"^20[0-9]{2}-(0[1-9]|1[0-2])$")
|
||||
|
||||
@model_validator(mode="after")
|
||||
def check_intent(self):
|
||||
if self.kind == "MASTER_PREVIEW" and not self.entity_types:
|
||||
raise ValueError("请选择基础数据类型")
|
||||
if self.kind == "MASTER_APPLY" and not self.preview_no:
|
||||
raise ValueError("执行导入前必须完成预览")
|
||||
if self.kind == "BUSINESS_APPLY" and not self.preview_no:
|
||||
raise ValueError("正式业务导入必须先预览")
|
||||
if self.kind == "BUSINESS_PREVIEW" and (not self.month or not self.group_keys):
|
||||
raise ValueError("请选择正式导入月份及平账组")
|
||||
if self.replay_no and (self.kind != "CALC_PREVIEW" or self.month or self.group_keys):
|
||||
raise ValueError("冻结重跑仅适用于核算预演,不能同时指定新月份或平账组")
|
||||
if (
|
||||
self.kind == "CALC_PREVIEW"
|
||||
and not self.replay_no
|
||||
and (not self.month or not self.group_keys)
|
||||
):
|
||||
raise ValueError("请选择月份及已登记的平账组")
|
||||
if len(set(self.entity_types)) != len(self.entity_types) or len(
|
||||
set(self.group_keys)
|
||||
) != len(self.group_keys):
|
||||
raise ValueError("请求范围不能重复")
|
||||
if any(not v.isascii() or not v.isdigit() for v in self.source_ids):
|
||||
raise ValueError("源ID格式不正确")
|
||||
return self
|
||||
|
||||
|
||||
class WorkQuery(PageQuery):
|
||||
"""运行、资料和明细共用的有界分页参数。"""
|
||||
|
||||
kind: str | None = Field(default=None, max_length=32)
|
||||
action: str | None = Field(default=None, max_length=32)
|
||||
|
||||
|
||||
class SourceChoiceQuery(PageQuery):
|
||||
"""将筛选与分页一起声明,保证查询参数使用同一扁平格式。"""
|
||||
|
||||
entity: Literal["personnel", "products", "customers", "shipments", "shipment_items", "receipts"]
|
||||
search: str = Field(default="", max_length=64)
|
||||
@@ -93,6 +93,13 @@ def pending_statuses_for_roles(role_codes: Iterable[str]) -> tuple[int, ...]:
|
||||
admin 不代办审批,但作为管理视角可查看全部在途单(与审批管理列表
|
||||
的可见性一致);财务/领导只看到本节点可操作的条目。
|
||||
"""
|
||||
from app.core.config import get_settings
|
||||
|
||||
if (
|
||||
not get_settings().payroll_allow_legacy_calculation
|
||||
and not get_settings().payroll_approval_enabled
|
||||
):
|
||||
return ()
|
||||
codes = set(role_codes)
|
||||
if ROLE_ADMIN in codes:
|
||||
return _PENDING_STATUSES
|
||||
@@ -129,8 +136,14 @@ def submit_order(
|
||||
落库时包一层信封并固化采集时间——审批期间业务再改,复核者看到的仍是
|
||||
送审内容。开关关闭的自动通过路径同样写快照,留痕不走样。
|
||||
"""
|
||||
_withdraw_pending(session, order_type=order_type, ref_id=ref_id, operator=submitter)
|
||||
|
||||
from app.core.config import get_settings
|
||||
|
||||
if not get_settings().payroll_allow_legacy_calculation and (
|
||||
not get_settings().payroll_approval_enabled or not is_enabled(session, order_type)
|
||||
):
|
||||
raise BadRequestError("审核功能未开启,不创建审批单或自动通过痕迹")
|
||||
_withdraw_pending(session, order_type=order_type, ref_id=ref_id, operator=submitter)
|
||||
order = ApprovalOrder(
|
||||
order_no=next_no(
|
||||
session,
|
||||
@@ -250,6 +263,13 @@ def act(
|
||||
"""
|
||||
codes = set(user_repository.get_role_codes(operator))
|
||||
verb = ACTION_APPROVE if payload.action == APPROVAL_ACTION_APPROVE else ACTION_REJECT
|
||||
from app.core.config import get_settings
|
||||
|
||||
if (
|
||||
not get_settings().payroll_allow_legacy_calculation
|
||||
and not get_settings().payroll_approval_enabled
|
||||
):
|
||||
raise BadRequestError("当前工资流程无需系统审批,历史记录仅供查看")
|
||||
with audit_service.audit(
|
||||
session,
|
||||
operator=operator,
|
||||
|
||||
@@ -11,11 +11,13 @@ from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.core.exceptions import AuthError, BadRequestError, ForbiddenError
|
||||
from app.core.security import create_access_token, decode_token, verify_password
|
||||
from app.repositories import login_log_repository, user_repository
|
||||
from app.schemas.auth import LoginOut, UserBrief
|
||||
from app.services import captcha_service, login_limiter, token_blacklist
|
||||
from app.services.payroll_access import can_write_payroll
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -143,7 +145,9 @@ def login(
|
||||
access_token=token,
|
||||
expires_in=expires_in,
|
||||
user=UserBrief(
|
||||
id=user.id, username=user.username, real_name=user.real_name, roles=role_codes
|
||||
id=user.id, username=user.username, real_name=user.real_name, roles=role_codes,
|
||||
can_write_payroll=can_write_payroll(user),
|
||||
payroll_approval_enabled=get_settings().payroll_approval_enabled,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -299,6 +299,10 @@ def _build_allocation_rows(
|
||||
余额归合同负责营销人员——超 100% 会把负责人份额算成负数,负责人
|
||||
自分份额则双重计入)
|
||||
"""
|
||||
from app.core.config import get_settings
|
||||
|
||||
if items and not old_ids and not get_settings().payroll_allow_legacy_calculation:
|
||||
raise BadRequestError("新核算按每人发货对应绩效,请在正式核算资料中维护发货归属,不再录入任意比例")
|
||||
business = business_repository.get_by_id(session, business_id)
|
||||
if business is None:
|
||||
raise NotFoundError("业务不存在")
|
||||
@@ -476,6 +480,10 @@ def update_business(
|
||||
:raises NotFoundError: 业务不存在
|
||||
:raises BadRequestError: 业务已完成或已终止 / 归属人员不合法 / 有发货或回款时换客户
|
||||
"""
|
||||
from app.services import payroll_months
|
||||
|
||||
allocation_month = payroll_months.current_month()
|
||||
payroll_months.lock_month(session, allocation_month, operator)
|
||||
row = business_repository.get_with_names(session, business_id, for_update=True)
|
||||
business = row[0] if row else None
|
||||
with audit_service.audit(
|
||||
@@ -527,6 +535,12 @@ def update_business(
|
||||
payload.secondary_allocations,
|
||||
old_ids={a.personnel_id for a in old_allocations},
|
||||
)
|
||||
if _plan_changed(old_allocations, new_rows):
|
||||
from app.core.config import get_settings
|
||||
|
||||
if not get_settings().payroll_allow_legacy_calculation:
|
||||
raise BadRequestError("历史比例方案仅供查看,新分配请维护逐笔发货归属")
|
||||
payroll_months.ensure_allocation_editable(session, allocation_month, operator)
|
||||
if not payload.secondary_allocations:
|
||||
business_repository.replace_allocations(session, business_id, [])
|
||||
elif _plan_changed(old_allocations, new_rows):
|
||||
@@ -539,7 +553,9 @@ def update_business(
|
||||
# - 送审内容与在途单一致 → 保留原单,不撤回不重建(避免反复保存
|
||||
# 刷出「撤回+新建」的审批留痕噪音)
|
||||
# - 其余 → 提新单(内含撤旧建新)
|
||||
if payload.secondary_allocations:
|
||||
from app.core.config import get_settings
|
||||
|
||||
if payload.secondary_allocations and get_settings().payroll_allow_legacy_calculation:
|
||||
snapshot = approval_service.allocation_content(session, business) or None
|
||||
if not approval_service.pending_matches_snapshot(
|
||||
session,
|
||||
@@ -563,7 +579,7 @@ def update_business(
|
||||
if _is_duplicate_entry(exc):
|
||||
raise BadRequestError("提交冲突(版本号生成重复),请重试") from exc
|
||||
raise
|
||||
else:
|
||||
elif get_settings().payroll_allow_legacy_calculation:
|
||||
approval_service.withdraw_pending_orders(
|
||||
session,
|
||||
order_type=APPROVAL_TYPE_SECONDARY_ALLOCATION,
|
||||
|
||||
@@ -95,6 +95,18 @@ def calc_receipt(
|
||||
本函数只做编排:锁行校验 → 模式预处理 → 输入装载 → 逐行折算 →
|
||||
计提组成 → 落库,各段独立私有函数(口径修改只动对应段)。
|
||||
"""
|
||||
from app.core.config import get_settings
|
||||
from app.services import payroll_calculation
|
||||
|
||||
receipt = session.get(Receipt, receipt_id)
|
||||
if receipt is None:
|
||||
raise BadRequestError("回款不存在")
|
||||
if payroll_calculation.latest_input(session, receipt.business_id):
|
||||
result = payroll_calculation.calculate(session, receipt.business_id,
|
||||
receipt.receipt_date.strftime("%Y-%m"), operator=operator, meta=meta, reason=recalc_reason)
|
||||
return CalcReceiptOut(ledger=_to_out(session.get(PerformanceLedger, result["ledger_ids"][0])), repeated=False)
|
||||
if not get_settings().payroll_allow_legacy_calculation:
|
||||
raise BadRequestError("请在业务详情的正式核算入口准备资料;旧单批次核算已停用")
|
||||
is_recalc = recalc_reason is not None
|
||||
action = "重算" if is_recalc else "核算"
|
||||
with audit_service.audit(
|
||||
|
||||
@@ -98,6 +98,11 @@ def collect_team_buckets(
|
||||
"""
|
||||
managers: dict[int, dict] = {}
|
||||
for row in ledger_rows:
|
||||
snapshot = getattr(row, "snapshot_json", None) or {}
|
||||
if snapshot.get("engine") == "FIFO":
|
||||
if not snapshot.get("manager_own_excluded") and snapshot.get("manager_id") is not None:
|
||||
add_team_performance(managers, int(snapshot["manager_id"]), row)
|
||||
continue
|
||||
if row.customer_type == "manager_personal":
|
||||
if include_manager_personal:
|
||||
add_team_performance(managers, row.personnel_id, row)
|
||||
@@ -118,7 +123,8 @@ def company_base_of(ledger_rows: list, include_manager_personal: bool) -> Decima
|
||||
(
|
||||
r.performance_amount or Decimal("0")
|
||||
for r in ledger_rows
|
||||
if include_manager_personal or r.customer_type != "manager_personal"
|
||||
if not (getattr(r, "snapshot_json", None) or {}).get("manager_own_excluded")
|
||||
and (include_manager_personal or r.customer_type != "manager_personal")
|
||||
),
|
||||
Decimal("0"),
|
||||
)
|
||||
@@ -0,0 +1,16 @@
|
||||
"""工资操作按已绑定的账号身份授权,管理员和岗位角色不构成旁路。"""
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.core.exceptions import ForbiddenError
|
||||
|
||||
|
||||
def can_write_payroll(user) -> bool:
|
||||
"""未绑定制作人时关闭业务写入;停用账号不能操作。"""
|
||||
maker = get_settings().payroll_maker_user_id
|
||||
return maker is not None and user is not None and user.id == maker and user.status == 1
|
||||
|
||||
|
||||
def ensure_payroll_writer(user):
|
||||
if not can_write_payroll(user):
|
||||
raise ForbiddenError("仅指定的工资制作人可以修改业务数据")
|
||||
return user
|
||||
@@ -0,0 +1,420 @@
|
||||
"""正式FIFO核算:按月份固化逐段计提和累计可付差额,不覆盖历史工资。"""
|
||||
|
||||
import copy
|
||||
from collections import defaultdict
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
from fractions import Fraction
|
||||
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from app.core.exceptions import BadRequestError, NotFoundError
|
||||
from app.core.response import PageResult
|
||||
from app.models import (
|
||||
Business,
|
||||
BusinessRole,
|
||||
PayrollAllocation,
|
||||
PayrollInput,
|
||||
PerformanceLedger,
|
||||
Personnel,
|
||||
Receipt,
|
||||
Shipment,
|
||||
ShipmentItem,
|
||||
)
|
||||
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.tplus.calculation_preview import _parameters
|
||||
from app.services.tplus.fifo import _beneficiary, amount_text, money, preview_group
|
||||
from app.services.tplus.workbench import digest
|
||||
|
||||
|
||||
def latest_input(session, business_id, *, for_update=False):
|
||||
statement = (
|
||||
select(PayrollInput)
|
||||
.where(PayrollInput.business_id == business_id)
|
||||
.order_by(PayrollInput.version.desc())
|
||||
.limit(1)
|
||||
)
|
||||
if for_update:
|
||||
statement = statement.with_for_update().execution_options(populate_existing=True)
|
||||
return session.scalar(statement)
|
||||
|
||||
|
||||
def _event_key(line):
|
||||
return digest([line.get("receipt_id"), line.get("shipment_id"), line.get("line_id")])
|
||||
|
||||
|
||||
def _previous(session, business_id, month):
|
||||
rows = list(
|
||||
session.scalars(
|
||||
select(PerformanceLedger)
|
||||
.where(
|
||||
PerformanceLedger.business_id == business_id,
|
||||
PerformanceLedger.calc_type == 1,
|
||||
PerformanceLedger.is_active == 1,
|
||||
)
|
||||
.order_by(PerformanceLedger.id)
|
||||
.with_for_update()
|
||||
.execution_options(populate_existing=True)
|
||||
)
|
||||
)
|
||||
if any(r.settlement_month >= month for r in rows):
|
||||
raise BadRequestError("该业务本月或后续月份已有核算;重算需先处理未生成工资的后续月份")
|
||||
events = {}
|
||||
paid = defaultdict(lambda: Decimal(0))
|
||||
for row in rows:
|
||||
snapshot = row.snapshot_json or {}
|
||||
if snapshot.get("engine") != "FIFO":
|
||||
raise BadRequestError("该业务存在旧口径台账,请先核对历史承接,不能混算")
|
||||
key = snapshot["event_key"]
|
||||
if snapshot.get("first_accrual"):
|
||||
events[key] = snapshot["event"]
|
||||
paid[key] += row.payable_amount or Decimal(0)
|
||||
return events, paid
|
||||
|
||||
|
||||
def _inputs(session, record, until):
|
||||
values = copy.deepcopy(record.data_json)
|
||||
ships, cash = values["shipments"], values["receipts"]
|
||||
for row in cash:
|
||||
if row.get("exclusion"):
|
||||
continue
|
||||
current = session.get(Receipt, values["maps"]["receipts"].get(str(row["source_id"])))
|
||||
if (
|
||||
current is None
|
||||
or current.amount != money(row["amount"])
|
||||
or current.receipt_date.isoformat() != row["date"]
|
||||
or current.business_id != record.business_id
|
||||
):
|
||||
raise BadRequestError("正式回款与核算资料不一致,请重新核对资料版本")
|
||||
for row in ships:
|
||||
item_id = values["maps"].get("shipment_items", {}).get(str(row["line_id"]))
|
||||
if item_id is not None:
|
||||
item = session.get(ShipmentItem, item_id)
|
||||
head = session.get(Shipment, item.shipment_id) if item else None
|
||||
if (
|
||||
item is None
|
||||
or head is None
|
||||
or item.amount != money(row["amount"])
|
||||
or head.ship_date.isoformat() != row["date"]
|
||||
or head.business_id != record.business_id
|
||||
):
|
||||
raise BadRequestError("正式发货与核算资料不一致,请重新核对资料版本")
|
||||
days = {row["date"] for row in [*ships, *cash] if row["date"] <= until.isoformat()}
|
||||
params = _parameters(session, days)
|
||||
params["prepayment_parameter_basis"], params["august_confirmed"] = "RECEIPT", True
|
||||
result = preview_group(values["group"], ships, cash, values["facts"], params, until)
|
||||
if result["status"] != "SUCCEEDED":
|
||||
reasons = set(result.get("reasons", []))
|
||||
reasons.update(
|
||||
reason
|
||||
for line in result["lines"]
|
||||
for reason in line.get("reasons", [])
|
||||
if line["status"] == "PENDING"
|
||||
)
|
||||
raise BadRequestError("核算资料待补:" + ",".join(sorted(reasons)))
|
||||
return values, result
|
||||
|
||||
|
||||
def _post(
|
||||
session,
|
||||
business,
|
||||
record,
|
||||
event,
|
||||
key,
|
||||
month,
|
||||
owner,
|
||||
manager,
|
||||
performance,
|
||||
payable,
|
||||
weight_r,
|
||||
first,
|
||||
version,
|
||||
reason=None,
|
||||
):
|
||||
person = session.get(Personnel, owner)
|
||||
if person is None:
|
||||
raise BadRequestError("正式归属人员不存在")
|
||||
role = session.get(BusinessRole, person.business_role_id)
|
||||
if role is None:
|
||||
raise BadRequestError("正式归属人员岗位缺失")
|
||||
receipt_id = record.data_json["maps"]["receipts"].get(str(event["receipt_id"]))
|
||||
if receipt_id is None:
|
||||
raise BadRequestError("回款尚未正式导入")
|
||||
row = PerformanceLedger(
|
||||
receipt_id=receipt_id,
|
||||
calc_version=version,
|
||||
allocation_key=key,
|
||||
is_active=1,
|
||||
calc_type=1,
|
||||
business_id=business.id,
|
||||
business_no=business.business_no,
|
||||
personnel_id=owner,
|
||||
personnel_name=person.personnel_name,
|
||||
role_code=role.role_code,
|
||||
customer_type=event["customer_type"],
|
||||
product_category=event["category"],
|
||||
ship_date=date.fromisoformat(event["shipment_date"]),
|
||||
receipt_date=date.fromisoformat(event["receipt_date"]),
|
||||
payment_days=event["days"],
|
||||
rate_text=event["rate"],
|
||||
converted_profit=money(event["converted_profit"]) if first else Decimal(0),
|
||||
performance_amount=performance,
|
||||
payable_amount=payable,
|
||||
barter_applied=0,
|
||||
weight_r=weight_r,
|
||||
payment_weight=formula.to_decimal(formula.payment_weight(weight_r)),
|
||||
settlement_month=month,
|
||||
is_probation=person.is_probation,
|
||||
missing_cost=0,
|
||||
calc_reason=reason,
|
||||
snapshot_json={
|
||||
"engine": "FIFO",
|
||||
"input_id": record.id,
|
||||
"event_key": key,
|
||||
"event": event,
|
||||
"first_accrual": first,
|
||||
"manager_id": manager,
|
||||
"manager_own_excluded": event.get("excluded_manager_own", False),
|
||||
"payable_delta": amount_text(payable),
|
||||
"rule_version": "2026-10-30.2",
|
||||
"recalc_reason": reason,
|
||||
},
|
||||
)
|
||||
session.add(row)
|
||||
session.flush()
|
||||
session.add(
|
||||
PayrollAllocation(
|
||||
ledger_id=row.id,
|
||||
business_id=business.id,
|
||||
line_no=1,
|
||||
input_id=record.id,
|
||||
amount=money(event["amount"]) if first else Decimal(0),
|
||||
data_json={
|
||||
**event,
|
||||
"event_key": key,
|
||||
"first_accrual": first,
|
||||
"performance_delta": amount_text(performance),
|
||||
"payable_delta": amount_text(payable),
|
||||
"beneficiary_id": owner,
|
||||
"manager_id": manager,
|
||||
"recalc_reason": reason,
|
||||
},
|
||||
)
|
||||
)
|
||||
return row
|
||||
|
||||
|
||||
def calculate(session, business_id, month, *, operator, meta=None, reason=None):
|
||||
"""重算从指定月起级联重建尚未生成工资的月份,同一事务保存全部版本。"""
|
||||
from app.services.manager_share import month_end
|
||||
|
||||
month_end(month)
|
||||
if reason is not None and not reason.strip():
|
||||
raise BadRequestError("重算原因不能为空")
|
||||
with audit_service.audit(
|
||||
session,
|
||||
operator=operator,
|
||||
record=AuditRecord(
|
||||
module="绩效核算",
|
||||
action="正式FIFO重算" if reason else "正式FIFO核算",
|
||||
summary=f"业务 #{business_id} 核算 {month}",
|
||||
request_data={"business_id": business_id, "month": month, "reason": reason},
|
||||
),
|
||||
meta=meta,
|
||||
):
|
||||
months = {month}
|
||||
if reason:
|
||||
months.update(
|
||||
session.scalars(
|
||||
select(PerformanceLedger.settlement_month).where(
|
||||
PerformanceLedger.business_id == business_id,
|
||||
PerformanceLedger.calc_type == 1,
|
||||
PerformanceLedger.settlement_month >= month,
|
||||
)
|
||||
)
|
||||
)
|
||||
for current_month in sorted(months):
|
||||
payroll_months.ensure_allocation_editable(session, current_month, operator)
|
||||
business = session.get(Business, business_id, with_for_update=True)
|
||||
if business is None:
|
||||
raise NotFoundError("业务不存在")
|
||||
record = latest_input(session, business_id, for_update=True)
|
||||
if record is None:
|
||||
raise BadRequestError("请先补录该业务的正式核算资料")
|
||||
if reason:
|
||||
old_rows = list(
|
||||
session.scalars(
|
||||
select(PerformanceLedger)
|
||||
.where(
|
||||
PerformanceLedger.business_id == business_id,
|
||||
PerformanceLedger.is_active == 1,
|
||||
PerformanceLedger.calc_type == 1,
|
||||
PerformanceLedger.settlement_month >= month,
|
||||
)
|
||||
.with_for_update()
|
||||
)
|
||||
)
|
||||
if not any(r.settlement_month == month for r in old_rows):
|
||||
raise BadRequestError("该月尚无可重算台账")
|
||||
if {r.settlement_month for r in old_rows} - months:
|
||||
raise BadRequestError("后续月份在核算期间变化,请刷新后重试")
|
||||
for old in old_rows:
|
||||
if old.settlement_id is not None:
|
||||
raise BadRequestError("后续月份已有工资生成,原工资保留,请走当月调整单")
|
||||
old.is_active = 0
|
||||
session.flush()
|
||||
outputs = [
|
||||
_calculate_month(
|
||||
session, business, record, current_month, reason.strip() if reason else None
|
||||
)
|
||||
for current_month in sorted(months)
|
||||
]
|
||||
out = outputs[0]
|
||||
out["recalculated_months"] = sorted(months) if reason else []
|
||||
session.commit()
|
||||
return out
|
||||
|
||||
|
||||
def _calculate_month(session, business, record, month, reason=None):
|
||||
from app.services.manager_share import month_end
|
||||
|
||||
business_id = business.id
|
||||
until = month_end(month)
|
||||
previous, paid = _previous(session, business_id, month)
|
||||
values, result = _inputs(session, record, until)
|
||||
active_lines = [line for line in result["lines"] if line["kind"] == "SHIPMENT"]
|
||||
current = {}
|
||||
for line in active_lines:
|
||||
key = _event_key(line)
|
||||
if key in previous:
|
||||
if any(
|
||||
str(line.get(field)) != str(previous[key].get(field))
|
||||
for field in ("amount", "shipment_date", "receipt_date")
|
||||
):
|
||||
raise BadRequestError("历史分配金额或日期已变化,请通过更正或调整处理")
|
||||
current[key] = previous[key]
|
||||
elif line["effective_date"][:7] < month:
|
||||
raise BadRequestError(f"请先完成 {line['effective_date'][:7]} 的业务核算")
|
||||
else:
|
||||
ship = next(s for s in values["shipments"] if str(s["line_id"]) == str(line["line_id"]))
|
||||
current[key] = {
|
||||
**line,
|
||||
"category": ship["category"],
|
||||
"customer_type": ship["customer_type"],
|
||||
"source_owner": ship["personnel_id"],
|
||||
"business_key": ship["business_key"],
|
||||
}
|
||||
if set(previous) - current.keys():
|
||||
raise BadRequestError("历史已核算事件发生变化,请核对更正或调整,不可直接覆盖")
|
||||
sales = sum(
|
||||
(
|
||||
money(s["amount"])
|
||||
for s in values["shipments"]
|
||||
if s["date"] <= until.isoformat() and not s.get("performance_excluded")
|
||||
),
|
||||
Decimal(0),
|
||||
)
|
||||
matched = sum((money(line["amount"]) for line in active_lines), Decimal(0))
|
||||
snapshot = resolve_snapshot(session, until)
|
||||
threshold = Fraction(snapshot.get("payment.start.ratio"))
|
||||
weight_r = formula.clamp_weight_r(matched, sales)
|
||||
start_ok = formula.start_payment_ok(matched, sales, threshold)
|
||||
weight = formula.payment_weight(weight_r) if start_ok else Fraction(0)
|
||||
rows = []
|
||||
receipt_versions = {}
|
||||
for key, event in current.items():
|
||||
first = key not in previous
|
||||
performance = money(event["performance"])
|
||||
desired = formula.to_decimal(Fraction(performance) * weight).quantize(
|
||||
Decimal("0.01"), rounding="ROUND_HALF_UP"
|
||||
)
|
||||
delta = desired - paid[key]
|
||||
if not first and delta == 0:
|
||||
continue
|
||||
target = _beneficiary(
|
||||
{"personnel_id": event["personnel_id"], "business_key": event["business_key"]},
|
||||
{},
|
||||
values["facts"].get("handovers", []),
|
||||
until.isoformat(),
|
||||
)
|
||||
owner = values["maps"]["personnel"].get(str(target))
|
||||
if owner is None:
|
||||
raise BadRequestError("接手人员尚未正式导入")
|
||||
source_manager = event.get("manager_id")
|
||||
manager = values["maps"]["personnel"].get(str(source_manager)) if source_manager else None
|
||||
if not event.get("excluded_manager_own") and not manager:
|
||||
raise BadRequestError("请补录该业务在发货时的经理分管关系")
|
||||
rid = values["maps"]["receipts"].get(str(event["receipt_id"]))
|
||||
if rid not in receipt_versions:
|
||||
receipt_versions[rid] = (
|
||||
int(
|
||||
session.scalar(
|
||||
select(func.max(PerformanceLedger.calc_version)).where(
|
||||
PerformanceLedger.receipt_id == rid
|
||||
)
|
||||
)
|
||||
or 0
|
||||
)
|
||||
+ 1
|
||||
)
|
||||
rows.append(
|
||||
_post(
|
||||
session,
|
||||
business,
|
||||
record,
|
||||
event,
|
||||
key,
|
||||
month,
|
||||
owner,
|
||||
manager,
|
||||
performance if first else Decimal(0),
|
||||
delta,
|
||||
weight_r,
|
||||
first,
|
||||
receipt_versions[rid],
|
||||
reason=reason,
|
||||
)
|
||||
)
|
||||
if not rows and not reason:
|
||||
raise BadRequestError("本月没有新增计提或应付差额")
|
||||
for row in rows:
|
||||
row.calc_reason = reason
|
||||
if reason:
|
||||
row.snapshot_json = {**row.snapshot_json, "recalc_reason": reason}
|
||||
for rid in receipt_versions:
|
||||
session.get(Receipt, rid).settlement_status = 1
|
||||
session.flush()
|
||||
out = {
|
||||
"month": month,
|
||||
"business_id": business_id,
|
||||
"ledger_ids": [r.id for r in rows],
|
||||
"performance": amount_text(sum((r.performance_amount for r in rows), Decimal(0))),
|
||||
"payable": amount_text(sum((r.payable_amount for r in rows), Decimal(0))),
|
||||
"start_payment_ok": start_ok,
|
||||
}
|
||||
return out
|
||||
|
||||
|
||||
def allocation_page(session, ledger_id, query):
|
||||
if session.get(PerformanceLedger, ledger_id) is None:
|
||||
raise NotFoundError("台账不存在")
|
||||
condition = PayrollAllocation.ledger_id == ledger_id
|
||||
rows = session.scalars(
|
||||
select(PayrollAllocation)
|
||||
.where(condition)
|
||||
.order_by(PayrollAllocation.line_no)
|
||||
.offset(query.offset)
|
||||
.limit(query.page_size)
|
||||
)
|
||||
return PageResult(
|
||||
records=[
|
||||
{"id": r.id, "input_id": r.input_id, "amount": str(r.amount), **r.data_json}
|
||||
for r in rows
|
||||
],
|
||||
total=session.scalar(select(func.count()).select_from(PayrollAllocation).where(condition))
|
||||
or 0,
|
||||
page_num=query.page_num,
|
||||
page_size=query.page_size,
|
||||
)
|
||||
@@ -0,0 +1,177 @@
|
||||
"""人工业务核算准备:复用已有单据,显式确认期初、成本与发货归属。"""
|
||||
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.exceptions import BadRequestError, NotFoundError
|
||||
from app.models import (
|
||||
Business,
|
||||
BusinessItem,
|
||||
BusinessRole,
|
||||
Customer,
|
||||
PayrollInput,
|
||||
Personnel,
|
||||
Receipt,
|
||||
Shipment,
|
||||
ShipmentItem,
|
||||
)
|
||||
from app.services import audit_service, payroll_months
|
||||
from app.services.audit_service import AuditRecord
|
||||
from app.services.payroll_calculation import latest_input
|
||||
from app.services.tplus.workbench import digest
|
||||
|
||||
|
||||
def setup(session, business_id, payload, operator, meta=None):
|
||||
with audit_service.audit(
|
||||
session,
|
||||
operator=operator,
|
||||
record=AuditRecord(
|
||||
module="绩效核算",
|
||||
action="核算资料版本",
|
||||
summary=f"业务 #{business_id} 核算资料",
|
||||
request_data=payload.model_dump(mode="json"),
|
||||
),
|
||||
meta=meta,
|
||||
):
|
||||
payroll_months.ensure_allocation_editable(session, payload.month, operator)
|
||||
business = session.get(Business, business_id, with_for_update=True)
|
||||
if business is None:
|
||||
raise NotFoundError("业务不存在")
|
||||
current = latest_input(session, business_id, for_update=True)
|
||||
if current and current.source_database != "MANUAL":
|
||||
raise BadRequestError("源导入业务请通过来源差异核对更新,不能用人工资料替换")
|
||||
if (current.version if current else 0) != payload.expected_version:
|
||||
raise BadRequestError("核算资料版本已变化,请刷新")
|
||||
customer = session.get(Customer, business.customer_id)
|
||||
heads = list(session.scalars(select(Shipment).where(Shipment.business_id == business_id)))
|
||||
by_head = {h.id: h for h in heads}
|
||||
items = list(
|
||||
session.scalars(select(ShipmentItem).where(ShipmentItem.shipment_id.in_(by_head)))
|
||||
)
|
||||
assignments = {i.shipment_item_id: i for i in payload.shipment_ownerships}
|
||||
if len(assignments) != len(payload.shipment_ownerships) or set(assignments) != {
|
||||
i.id for i in items
|
||||
}:
|
||||
raise BadRequestError("请逐条核实全部发货明细归属,不可遗漏、重复或跨业务")
|
||||
business_items = list(
|
||||
session.scalars(select(BusinessItem).where(BusinessItem.business_id == business_id))
|
||||
)
|
||||
roles = {r.id: r.role_code for r in session.scalars(select(BusinessRole))}
|
||||
people = {p.id: p for p in session.scalars(select(Personnel))}
|
||||
key = business.business_no
|
||||
ships = []
|
||||
for item in items:
|
||||
owner = assignments[item.id]
|
||||
if owner.personnel_id not in people or owner.manager_id not in people:
|
||||
raise BadRequestError("发货归属人员或分管经理不存在")
|
||||
if roles.get(people[owner.manager_id].business_role_id) != "sales_manager":
|
||||
raise BadRequestError("分管人员必须为营销经理")
|
||||
candidates = [
|
||||
b
|
||||
for b in business_items
|
||||
if (
|
||||
b.product_id == item.product_id
|
||||
if item.product_id
|
||||
else b.product_name == item.product_name and b.spec_model == item.spec_model
|
||||
)
|
||||
]
|
||||
if len(candidates) != 1:
|
||||
raise BadRequestError("发货产品无法唯一确定核算分类,请先核对业务明细")
|
||||
head = by_head[item.shipment_id]
|
||||
if item.amount <= 0:
|
||||
raise BadRequestError("请先补齐发货销售金额")
|
||||
ships.append(
|
||||
{
|
||||
"source_id": str(head.id),
|
||||
"line_id": str(item.id),
|
||||
"source_no": head.shipment_no,
|
||||
"date": head.ship_date.isoformat(),
|
||||
"group_key": key,
|
||||
"business_key": key,
|
||||
"personnel_id": str(owner.personnel_id),
|
||||
"manager_id": str(owner.manager_id),
|
||||
"amount": str(item.amount),
|
||||
"quantity": str(item.quantity),
|
||||
"unit": item.unit,
|
||||
"category": candidates[0].product_category,
|
||||
"customer_type": customer.customer_type,
|
||||
"cost_total": str(
|
||||
(Decimal(owner.unit_cost) * item.quantity).quantize(
|
||||
Decimal("0.01"), rounding="ROUND_HALF_UP"
|
||||
)
|
||||
),
|
||||
"unit_cost": owner.unit_cost,
|
||||
"cost_month_basis": "SHIPMENT",
|
||||
"pending": [],
|
||||
}
|
||||
)
|
||||
receipts = list(session.scalars(select(Receipt).where(Receipt.business_id == business_id)))
|
||||
if any(r.is_barter for r in receipts):
|
||||
raise BadRequestError("抵顶特殊绩效继续线下处理,不进入普通FIFO核算")
|
||||
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 any(h["business_key"] != key for h in handovers):
|
||||
raise BadRequestError("交接记录不属于本业务")
|
||||
values = {
|
||||
"group": {
|
||||
"key": key,
|
||||
"scope_confirmed": True,
|
||||
"opening_confirmed": True,
|
||||
"opening_balance": payload.opening_balance,
|
||||
"historical_settlement_confirmed": True,
|
||||
},
|
||||
"shipments": ships,
|
||||
"receipts": [
|
||||
{
|
||||
"source_id": str(r.id),
|
||||
"source_no": r.receipt_batch_no,
|
||||
"date": r.receipt_date.isoformat(),
|
||||
"group_key": key,
|
||||
"amount": str(r.amount),
|
||||
"voucher_confirmed": True,
|
||||
"pending": [],
|
||||
}
|
||||
for r in receipts
|
||||
],
|
||||
"facts": {
|
||||
"personnel": {
|
||||
str(p.id): {"role_code": roles.get(p.business_role_id), "status": p.status}
|
||||
for p in people.values()
|
||||
},
|
||||
"handovers": handovers,
|
||||
},
|
||||
"maps": {
|
||||
"receipts": {str(r.id): r.id for r in receipts},
|
||||
"personnel": {str(p.id): p.id for p in people.values()},
|
||||
"shipments": {str(h.id): h.id for h in heads},
|
||||
"shipment_items": {str(i.id): i.id for i in items},
|
||||
},
|
||||
"evidence": payload.evidence,
|
||||
"guidance_finalized": True,
|
||||
"as_of_month": payload.month,
|
||||
}
|
||||
# 旧资料包含的单据金额和归属保持稳定,新增版本用于新增发货与接手事实。
|
||||
record = PayrollInput(
|
||||
business_id=business_id,
|
||||
version=payload.expected_version + 1,
|
||||
source_database="MANUAL",
|
||||
group_key=key,
|
||||
fingerprint=digest(values),
|
||||
data_json=values,
|
||||
requested_by=operator.id,
|
||||
)
|
||||
session.add(record)
|
||||
session.flush()
|
||||
result = {
|
||||
"input_id": record.id,
|
||||
"version": record.version,
|
||||
"fingerprint": record.fingerprint,
|
||||
}
|
||||
session.commit()
|
||||
return result
|
||||
@@ -0,0 +1,47 @@
|
||||
"""月份行锁同时保护工资生成与分配事实修改。"""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.dialects.mysql import insert
|
||||
|
||||
from app.core.exceptions import BadRequestError, ForbiddenError
|
||||
from app.core.month import validate_month
|
||||
from app.models import MonthlySettlement, PayrollMonth
|
||||
from app.services.payroll_access import ensure_payroll_writer
|
||||
|
||||
|
||||
def current_month() -> str:
|
||||
return datetime.now(timezone(timedelta(hours=8))).strftime("%Y-%m")
|
||||
|
||||
|
||||
def lock_month(session, month, operator):
|
||||
"""INSERT 的唯一键串行化首次建行,避免两个请求同时认为月份不存在。"""
|
||||
ensure_payroll_writer(operator)
|
||||
validate_month(month)
|
||||
values = {"month": month, "maker_id": operator.id, "maker_name": operator.username}
|
||||
if session.bind.dialect.name == "mysql":
|
||||
stmt = insert(PayrollMonth).values(**values)
|
||||
session.execute(stmt.on_duplicate_key_update(month=stmt.inserted.month))
|
||||
elif session.scalar(select(PayrollMonth.id).where(PayrollMonth.month == month)) is None:
|
||||
session.add(PayrollMonth(**values))
|
||||
session.flush()
|
||||
control = session.scalar(
|
||||
select(PayrollMonth).where(PayrollMonth.month == month).with_for_update()
|
||||
)
|
||||
if control.generated_at is not None and control.maker_id != operator.id:
|
||||
raise ForbiddenError("该月工资由其他制作人生成,不可代为修改或重新生成")
|
||||
if control.generated_at is None:
|
||||
control.maker_id, control.maker_name = operator.id, operator.username
|
||||
return control
|
||||
|
||||
|
||||
def ensure_allocation_editable(session, month, operator):
|
||||
control = lock_month(session, month, operator)
|
||||
# 兼容升级前已有月结;没有控制行不代表历史工资可以修改。
|
||||
old = session.scalar(
|
||||
select(MonthlySettlement.id).where(MonthlySettlement.settlement_month == month)
|
||||
)
|
||||
if control.generated_at is not None or old is not None:
|
||||
raise BadRequestError("该月工资已生成,二次分配已锁定;差额请走当月调整单")
|
||||
return control
|
||||
@@ -154,6 +154,16 @@ def department_summary(session, query) -> dict:
|
||||
manager_of, manager_role, person_info = _team_relations(
|
||||
session, member_map, manager_ids, adjustments
|
||||
)
|
||||
attributed_managers = {mid for a in adjustments.values() for mid in a.get("by_manager", {}) if mid is not None}
|
||||
manager_ids = sorted(set(manager_ids) | attributed_managers)
|
||||
frozen_rows = rows if any((getattr(r, "snapshot_json", None) or {}).get("engine") == "FIFO" for r in rows) or attributed_managers else None
|
||||
for mid in attributed_managers:
|
||||
manager_role[mid] = "sales_manager"
|
||||
if frozen_rows is not None:
|
||||
for row in frozen_rows:
|
||||
snapshot = row.snapshot_json or {}
|
||||
if snapshot.get("engine") == "FIFO" and snapshot.get("manager_id") is not None:
|
||||
manager_role[int(snapshot["manager_id"])] = "sales_manager"
|
||||
# 只有调整单、本月无台账行的人:补成零值成员后参与归属
|
||||
_merge_adjustment_only_members(member_map, adjustments, person_info)
|
||||
|
||||
@@ -167,6 +177,7 @@ def department_summary(session, query) -> dict:
|
||||
"include_mp": include_mp,
|
||||
"share": share,
|
||||
"company_base": company_base,
|
||||
"frozen_rows": frozen_rows,
|
||||
}
|
||||
teams_all = _build_teams(session, manager_ids, context)
|
||||
if query.manager_id is None:
|
||||
@@ -186,7 +197,10 @@ def department_summary(session, query) -> dict:
|
||||
for team in teams_all
|
||||
if query.manager_id is None or team["manager_id"] == query.manager_id
|
||||
]
|
||||
unassigned_all = _unassigned_rows(member_map, manager_of, manager_role, adjustments, include_mp)
|
||||
unassigned_all = (
|
||||
_frozen_member_rows(None, context) if frozen_rows is not None
|
||||
else _unassigned_rows(member_map, manager_of, manager_role, adjustments, include_mp)
|
||||
)
|
||||
# 「仅看某经理团队」时未分配桶不随展示:全公司无经理人员与该团队无关
|
||||
unassigned = [] if query.manager_id is not None else unassigned_all
|
||||
company_share_amount = _company_share(company_base, share.company_pct)
|
||||
@@ -219,6 +233,47 @@ def department_summary(session, query) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def _frozen_member_rows(manager_id, context):
|
||||
"""按台账固化的业务分管拆成员,同一业务员可分别出现在多个团队。"""
|
||||
targets = {}
|
||||
source_rows = context["frozen_rows"]
|
||||
for row in source_rows:
|
||||
snap = row.snapshot_json or {}
|
||||
if snap.get("engine") == "FIFO":
|
||||
target = snap.get("manager_id") if not snap.get("manager_own_excluded") else None
|
||||
else:
|
||||
target = _regular_team_of(row.personnel_id, context["manager_of"], context["manager_role"])
|
||||
if row.customer_type == "manager_personal":
|
||||
target = row.personnel_id if context["include_mp"] else None
|
||||
targets[row.id] = target
|
||||
selected = [r for r in source_rows if targets[r.id] == manager_id]
|
||||
members = _member_map(selected)
|
||||
local_adjustments = {}
|
||||
for pid, adjustment in context["adjustments"].items():
|
||||
candidates = {targets[r.id] for r in source_rows if r.personnel_id == pid}
|
||||
present = sorted(t for t in candidates if t is not None)
|
||||
default_target = present[0] if present else (
|
||||
None if candidates else _regular_team_of(pid, context["manager_of"], context["manager_role"])
|
||||
)
|
||||
totals = {"extra": Decimal(0), "deduct": Decimal(0)}
|
||||
selected_adjustment = adjustment.get("by_manager", {}).get(manager_id, {})
|
||||
for field in totals:
|
||||
totals[field] += selected_adjustment.get(field, Decimal(0))
|
||||
if default_target == manager_id:
|
||||
totals[field] += adjustment.get("unattributed", adjustment)[field]
|
||||
local_adjustments[pid] = totals
|
||||
if any(totals.values()) and pid not in members:
|
||||
members[pid] = context["member_map"][pid]
|
||||
output = []
|
||||
for pid, member in members.items():
|
||||
actual = [r for r in selected if r.personnel_id == pid]
|
||||
output.append(_member_row(pid, member, local_adjustments, "regular",
|
||||
performance=sum((r.performance_amount or Decimal(0) for r in actual), Decimal(0)),
|
||||
payable=sum((r.payable_amount or Decimal(0) for r in actual), Decimal(0)),
|
||||
ledger_ids=[r.id for r in actual], include_adjust=True))
|
||||
return output
|
||||
|
||||
|
||||
def _build_teams(session, manager_ids: list[int], context: dict) -> list[dict]:
|
||||
"""按经理枚举生成团队行(含零成员团队)。"""
|
||||
teams = []
|
||||
@@ -243,6 +298,8 @@ def _build_team(session, manager_id: int, context: dict) -> dict | None:
|
||||
context["adjustments"],
|
||||
context["include_mp"],
|
||||
)
|
||||
if context.get("frozen_rows") is not None:
|
||||
member_rows = _frozen_member_rows(manager_id, context)
|
||||
team_extra = sum((m["adjust_extra"] for m in member_rows), Decimal("0"))
|
||||
team_deduct = sum((m["adjust_deduct"] for m in member_rows), Decimal("0"))
|
||||
team_base = _dec(context["buckets"].get(manager_id, {}).get("performance", Decimal("0")))
|
||||
@@ -397,12 +454,17 @@ def _adjustments_by_person(orders) -> dict[int, dict]:
|
||||
"extra": Decimal("0"),
|
||||
"deduct": Decimal("0"),
|
||||
"personnel_name": order.personnel_name,
|
||||
"by_manager": {},
|
||||
"unattributed": {"extra": Decimal(0), "deduct": Decimal(0)},
|
||||
},
|
||||
)
|
||||
if order.direction == ADJUSTMENT_DIRECTION_EXTRA:
|
||||
acc["extra"] += order.amount
|
||||
else:
|
||||
acc["deduct"] += order.amount
|
||||
attribution = getattr(order, "attribution_json", None)
|
||||
bucket = acc["by_manager"].setdefault(attribution.get("manager_id"), {"extra": Decimal(0), "deduct": Decimal(0)}) if attribution else acc["unattributed"]
|
||||
bucket["extra" if order.direction == ADJUSTMENT_DIRECTION_EXTRA else "deduct"] += order.amount
|
||||
return by_person
|
||||
|
||||
|
||||
|
||||
@@ -82,6 +82,9 @@ def generate(
|
||||
),
|
||||
meta=meta,
|
||||
):
|
||||
from app.services import payroll_months
|
||||
|
||||
control = payroll_months.lock_month(session, month, operator)
|
||||
existing = settlement_repository.get_by_month(session, month)
|
||||
_guard_regeneration(existing, month)
|
||||
_release_previous_round(session, existing, month)
|
||||
@@ -103,6 +106,29 @@ def generate(
|
||||
|
||||
# 出参在 commit 前固化(audit 约定:commit 后查询失败会把已生成的月结
|
||||
# 记成失败审计,与库中事实相反)
|
||||
if control.generated_at is None:
|
||||
from datetime import datetime
|
||||
|
||||
control.generated_at = datetime.now()
|
||||
from app.core.config import get_settings
|
||||
|
||||
approval_enabled = get_settings().payroll_allow_legacy_calculation or (
|
||||
get_settings().payroll_approval_enabled and approval_service.is_enabled(session, 2)
|
||||
)
|
||||
settlement.snapshot_json = {
|
||||
**settlement.snapshot_json,
|
||||
"approval_required": approval_enabled,
|
||||
}
|
||||
if not approval_enabled:
|
||||
# 2026-10-30 用户澄清:生成工资即完成,不建审批单或自动通过痕迹。
|
||||
settlement.status = SETTLEMENT_STATUS_APPROVED
|
||||
settlement.locked_at = control.generated_at
|
||||
settlement.locked_by = control.maker_name
|
||||
settlement.approval_order_no = None
|
||||
settlement.snapshot_json = {
|
||||
"note": "工资生成即完成并锁定,仅供查看,无需系统审核",
|
||||
"approval_required": False,
|
||||
}
|
||||
session.flush()
|
||||
detail = _detail_of(session, settlement)
|
||||
session.commit()
|
||||
@@ -239,6 +265,13 @@ def submit(
|
||||
meta: RequestMeta | None = None,
|
||||
) -> SettlementDetailOut:
|
||||
"""提交审批:开关分流(关=自动通过并锁定;开=待财务复核)。"""
|
||||
from app.core.config import get_settings
|
||||
|
||||
if (
|
||||
not get_settings().payroll_allow_legacy_calculation
|
||||
and not get_settings().payroll_approval_enabled
|
||||
):
|
||||
raise BadRequestError("工资生成后直接完成,仅供查看,无需系统审批")
|
||||
with audit_service.audit(
|
||||
session,
|
||||
operator=operator,
|
||||
@@ -456,7 +489,12 @@ def _build_manager_aggregates(session: Session, ledger_rows: list, month: str) -
|
||||
if manager.business_role_id
|
||||
else None
|
||||
)
|
||||
if role is None or role.role_code != "sales_manager":
|
||||
frozen_manager = any(
|
||||
(r.snapshot_json or {}).get("engine") == "FIFO"
|
||||
and (r.snapshot_json or {}).get("manager_id") == manager_id
|
||||
for r in ledger_rows
|
||||
)
|
||||
if not frozen_manager and (role is None or role.role_code != "sales_manager"):
|
||||
continue # 非营销经理的上级不参与聚合
|
||||
aggregates.append(_aggregate_row(session, manager, bucket, company_base, share))
|
||||
return aggregates
|
||||
@@ -509,6 +547,47 @@ def create_adjustment(
|
||||
from app.services.no_generator import next_no
|
||||
|
||||
amount = _adjustment_amount(payload)
|
||||
from app.core.config import get_settings
|
||||
from app.models import PerformanceLedger
|
||||
from app.services import payroll_months
|
||||
|
||||
source_id = getattr(payload, "source_ledger_id", None)
|
||||
source = session.get(PerformanceLedger, source_id) if source_id else None
|
||||
if source_id and (source is None or source.settlement_id is None):
|
||||
raise BadRequestError("分配调整必须引用已生成工资的原台账")
|
||||
if source_id:
|
||||
from app.models import PayrollInput
|
||||
|
||||
snapshot = source.snapshot_json or {}
|
||||
if source.calc_type != 1 or source.is_active != 1 or snapshot.get("engine") != "FIFO":
|
||||
raise BadRequestError("分配调整必须引用有效的正式FIFO单笔台账")
|
||||
original = session.get(PayrollInput, snapshot.get("input_id"))
|
||||
if original is None:
|
||||
raise BadRequestError("原台账的归属依据缺失")
|
||||
allowed = {source.personnel_id}
|
||||
latest = session.scalar(
|
||||
select(PayrollInput)
|
||||
.where(PayrollInput.business_id == source.business_id)
|
||||
.order_by(PayrollInput.version.desc())
|
||||
.limit(1)
|
||||
)
|
||||
values = (latest or original).data_json
|
||||
business_key = snapshot.get("event", {}).get("business_key")
|
||||
for handover in values.get("facts", {}).get("handovers", []):
|
||||
if handover.get("business_key") == business_key:
|
||||
for field in ("from_personnel_id", "to_personnel_id"):
|
||||
target = (
|
||||
values.get("maps", {}).get("personnel", {}).get(str(handover.get(field)))
|
||||
)
|
||||
if target is not None:
|
||||
allowed.add(target)
|
||||
if payload.personnel_id not in allowed:
|
||||
raise BadRequestError("调整人员不属于该业务已登记的归属或交接范围")
|
||||
adjustment_month = (
|
||||
payroll_months.current_month()
|
||||
if source_id or not get_settings().payroll_allow_legacy_calculation
|
||||
else payload.settlement_month
|
||||
)
|
||||
|
||||
person = session.get(Personnel, payload.personnel_id)
|
||||
if person is None:
|
||||
@@ -532,7 +611,16 @@ def create_adjustment(
|
||||
column_name="order_no",
|
||||
prefix=NO_PREFIX_ADJUSTMENT,
|
||||
),
|
||||
settlement_month=payload.settlement_month,
|
||||
settlement_month=adjustment_month,
|
||||
adjustment_type="ALLOCATION" if source_id else "GENERAL",
|
||||
source_ledger_id=source_id,
|
||||
attribution_json={
|
||||
"business_id": source.business_id,
|
||||
"manager_id": (source.snapshot_json or {}).get("manager_id"),
|
||||
"original_personnel_id": source.personnel_id,
|
||||
}
|
||||
if source
|
||||
else None,
|
||||
personnel_id=payload.personnel_id,
|
||||
personnel_name=person.personnel_name,
|
||||
direction=payload.direction,
|
||||
@@ -544,7 +632,14 @@ def create_adjustment(
|
||||
session.add(order)
|
||||
session.flush()
|
||||
|
||||
_wire_adjustment_approval(session, order, operator)
|
||||
if get_settings().payroll_allow_legacy_calculation or (
|
||||
get_settings().payroll_approval_enabled and approval_service.is_enabled(session, 3)
|
||||
):
|
||||
_wire_adjustment_approval(session, order, operator)
|
||||
else:
|
||||
from app.models.settlement import ADJUSTMENT_STATUS_EFFECTIVE
|
||||
|
||||
order.status = ADJUSTMENT_STATUS_EFFECTIVE
|
||||
out = _adjustment_out(order) # 出参在 commit 前固化(audit 约定)
|
||||
session.commit()
|
||||
logger.info("调整单创建 | order=%s | status=%s", out["order_no"], out["status"])
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# T+ 只读同步
|
||||
# T+ 读取、基础导入与核算预演
|
||||
|
||||
> AI 摘要:读取外部 SQL Server 的原始快照,并管理持久化任务。关键文件为 task_service、worker、sync_service、relationship_audit 和 query_service;管理员 API 为 `/api/v1/system/tplus`。
|
||||
|
||||
@@ -9,6 +9,9 @@
|
||||
- `sync_service` 主键分页读取白名单实体,每页保存原始快照,相同源 ID 和行哈希去重。
|
||||
- `relationship_audit` 执行 `db/queries/tplus/audit/queries.sql`,只返回系统字典和数量;按收款方向与单据类型核对关联。
|
||||
- `query_service` 分页元数据,按需加载白名单报告;不返回原始 payload、驱动异常或源配置。
|
||||
- `source_heads/snapshots` 记录全量读取的当前源版本,包含哈希去重的 A→B→A;中断任务不发布部分指针。
|
||||
- `master_import` 按人员、产品、客户预览及导入;源、事实、目标变化时拒绝旧预览,保留人工核算字段。
|
||||
- `workbench` 保存人工事实版本、幂等任务和逐行结果,与原 worker 共用锁;`fifo/calculation_preview` 固定快照并计算隔离预演,支持原快照重跑。
|
||||
|
||||
## 主要入口
|
||||
|
||||
@@ -20,4 +23,20 @@
|
||||
|
||||
## 验收限制
|
||||
|
||||
当前只写 MySQL 暂存与管理表,不写工资业务表、不推进时间水位。关联、成本、日期和负向事件的业务规则待客户确认。开发验收先以备份源 10.50.1600 为基准,生产完整版本后补;2022 副本结果不能替代该基准的实测。
|
||||
本轮允许已确认的人员、产品、客户进入基础表;不写正式业务、发货、回款、绩效台账、月结、审批或发放记录,不推进时间水位。源库始终只读。日期、含税和人工指导成本已明确;生成凭证资格已按ARAP01来源与有效总账凭证关联核实,未审不等于未生成;平账范围、取价时点、历史余额、特殊更正仍按记录分别待补。
|
||||
|
||||
Docker 是当前开发主线,2008 R2 `10.50.1600` 验收后置;2022 副本不能替代同版本实测。实际导入结果、接口、缺驱动处理及启动方法见 `docs/T+基础导入与核算预演开发记录_2026-10-29.md`,字段证据见同日字段与规则差异表。
|
||||
|
||||
## 正式工资闭环扩展(2026-10-30,优先于上方上一阶段边界)
|
||||
|
||||
已新增 BUSINESS_PREVIEW/BUSINESS_APPLY,允许核实后导入正式业务、发货和回款,正式核算通过独立工资入口执行。源 SQL Server 仍始终只读。新版按同一发货客户名头统一平账、按发货月份指导成本封存;不再按合同拆组或要求重复确认成本月份。
|
||||
|
||||
制作人身份由现有账号ID显式配置;审核能力保留但默认关闭,关闭时工资生成直接完成且不写审批步骤。多原单更正仍待讨论。字段、接口、迁移和最终验收边界见 `docs/正式工资闭环开发记录_2026-10-30.md`,客户回执原文见 `docs/客户补充回执核对_2026-10-30.md`。
|
||||
|
||||
## 自动采集与样本核对(2026-10-30)
|
||||
|
||||
`automation.tick` 通过 `tplus-maintain --once` 排队最近到期周的九类原始采集,默认每周一北京时间 02:00 且关闭。明确断连/超时最多两次自动重试,间隔 5/15 分钟;部分失败、配置/资料错误及正式导入不自动重试。人工重试结果接入同一周期,独立心跳不受长查询阻塞。概况的 `automation` 提供状态及异常,不暴露连接凭据。
|
||||
|
||||
`tplus-materials --customer-id --month --connection-file --output-dir` 从恢复库只读形成样本核对包;空费用和旧初始化只能作为证据,不能推定零运费/切换余额。用户没有首轮样本人工指导成本,真实金额验收待补。
|
||||
|
||||
V20/V21 迁移 head 为 `21f2a3b4c5d6`,现有迁移保留,开发初始化同步。Windows 托管、默认关闭及停止边界见根目录 `scripts/windows/README.md`;本轮完整验证和未验收事项见 `docs/同步运维自动化与样本核对开发记录_2026-10-30.md`。未迁移原业务库或启用生产。
|
||||
@@ -0,0 +1,273 @@
|
||||
"""周期检查只排队源采集:稳定意图键、有限重试、独立心跳和只读异常概况。"""
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import threading
|
||||
from contextlib import contextmanager
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from uuid import NAMESPACE_URL, uuid4, uuid5
|
||||
|
||||
from sqlalchemy import func, inspect, select, text
|
||||
from sqlalchemy.dialects.mysql import insert
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.core.tplus_db import resolve_tplus_settings
|
||||
from app.models import TPlusAutomationState, TPlusSyncBatch, TPlusSyncError, TPlusWorkRun
|
||||
from app.services.tplus import task_service
|
||||
from app.services.tplus.errors import TRANSIENT_CODES
|
||||
from app.services.tplus.mapping import DETAIL_MAPPINGS, TPLUS_MAPPINGS
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
SHANGHAI = timezone(timedelta(hours=8))
|
||||
ENTITIES = tuple(sorted(m.entity_type for m in (*TPLUS_MAPPINGS, *DETAIL_MAPPINGS)))
|
||||
|
||||
|
||||
def local_now():
|
||||
return datetime.now(SHANGHAI).replace(tzinfo=None)
|
||||
|
||||
|
||||
def source_identity(settings):
|
||||
source = resolve_tplus_settings(settings)
|
||||
value = f"{source.t_plus_host.casefold()}:{source.t_plus_port}/{source.t_plus_database}"
|
||||
return hashlib.sha256(value.encode()).hexdigest(), source.t_plus_database
|
||||
|
||||
|
||||
def due_period(now, weekday=0, clock="02:00"):
|
||||
"""取最近一次已到期时段;只补这一个周期,不逐周补发历史任务。"""
|
||||
if now.tzinfo:
|
||||
now = now.astimezone(SHANGHAI).replace(tzinfo=None)
|
||||
hour, minute = map(int, clock.split(":"))
|
||||
day = now.date() - timedelta(days=(now.weekday() - weekday) % 7)
|
||||
due = datetime.combine(day, datetime.min.time()).replace(hour=hour, minute=minute)
|
||||
if due > now:
|
||||
due -= timedelta(days=7)
|
||||
year, week, _ = due.isocalendar()
|
||||
return f"{year}-W{week:02d}", due
|
||||
|
||||
|
||||
def intent_key(source_key, period, attempt=0):
|
||||
return str(uuid5(NAMESPACE_URL, f"payroll:tplus:weekly:{source_key}:{period}:{attempt}"))
|
||||
|
||||
|
||||
def _state(session, key, database, *, lock=False):
|
||||
if lock and session.bind.dialect.name == "mysql":
|
||||
stmt = insert(TPlusAutomationState).values(source_key=key, source_database=database)
|
||||
session.execute(stmt.on_duplicate_key_update(source_key=stmt.inserted.source_key))
|
||||
statement = select(TPlusAutomationState).where(TPlusAutomationState.source_key == key)
|
||||
if lock:
|
||||
statement = statement.with_for_update().execution_options(populate_existing=True)
|
||||
row = session.scalar(statement)
|
||||
if row is None and lock:
|
||||
row = TPlusAutomationState(source_key=key, source_database=database, retry_attempts=0, worker_phase="STOPPED")
|
||||
session.add(row)
|
||||
session.flush()
|
||||
return row
|
||||
|
||||
|
||||
@contextmanager
|
||||
def maintenance_lock(session, key):
|
||||
"""维护检查独立于长采集锁;同一源的两个检查不能重复创建重试。"""
|
||||
if session.bind.dialect.name != "mysql":
|
||||
yield True
|
||||
return
|
||||
connection = session.get_bind().connect()
|
||||
name = "ys:tplus:maintain:" + key[:32]
|
||||
acquired = False
|
||||
try:
|
||||
acquired = connection.execute(text("SELECT GET_LOCK(:name,0)"), {"name": name}).scalar() == 1
|
||||
yield acquired
|
||||
finally:
|
||||
if acquired:
|
||||
try:
|
||||
if connection.execute(text("SELECT RELEASE_LOCK(:name)"), {"name": name}).scalar() != 1:
|
||||
connection.invalidate()
|
||||
except Exception:
|
||||
connection.invalidate()
|
||||
connection.close()
|
||||
|
||||
|
||||
def _has_active(session):
|
||||
# 实际读取仍受原worker命名锁保护;排队检查防止周期任务积压。
|
||||
for model in (TPlusSyncBatch, TPlusWorkRun):
|
||||
if session.scalar(select(model.id).where(model.status.in_(("QUEUED", "RUNNING"))).limit(1)) is not None:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _enqueue(session, state, period, attempt, parent=None):
|
||||
values = {
|
||||
"batch_no": task_service.new_batch_no(), "request_key": intent_key(state.source_key, period, attempt),
|
||||
"sync_mode": "FULL", "status": "QUEUED", "source_database": state.source_database,
|
||||
"entity_types_json": list(ENTITIES), "parent_batch_no": parent,
|
||||
"trigger_type": "AUTO_RETRY" if attempt else "SCHEDULED", "automatic_attempt": attempt,
|
||||
"source_record_count": 0, "staged_record_count": 0, "failed_record_count": 0, "skipped_record_count": 0,
|
||||
}
|
||||
batch, inserted = task_service._insert(session, values)
|
||||
if inserted and parent:
|
||||
from app.repositories.tplus_repository import increment_retry_errors
|
||||
|
||||
increment_retry_errors(session, parent)
|
||||
state.scheduled_period = period
|
||||
state.current_batch_no = batch.batch_no
|
||||
state.retry_attempts = attempt
|
||||
state.next_retry_at = None
|
||||
if not attempt:
|
||||
state.root_batch_no = batch.batch_no
|
||||
return batch
|
||||
|
||||
|
||||
def _cycle_current(session, state):
|
||||
"""锁住本周期重试树;人工恢复也算本周期进度,自动次数不能被重置。"""
|
||||
if not state.root_batch_no:
|
||||
return None
|
||||
root = session.scalar(select(TPlusSyncBatch).where(
|
||||
TPlusSyncBatch.batch_no == state.root_batch_no,
|
||||
).with_for_update().execution_options(populate_existing=True))
|
||||
if root is None:
|
||||
return None
|
||||
current, frontier, seen = root, [root.batch_no], {root.batch_no}
|
||||
while frontier:
|
||||
children = list(session.scalars(select(TPlusSyncBatch).where(
|
||||
TPlusSyncBatch.parent_batch_no.in_(frontier),
|
||||
TPlusSyncBatch.source_database == state.source_database,
|
||||
).order_by(TPlusSyncBatch.id).with_for_update().execution_options(populate_existing=True)))
|
||||
frontier = []
|
||||
for child in children:
|
||||
if child.batch_no in seen:
|
||||
continue
|
||||
seen.add(child.batch_no)
|
||||
frontier.append(child.batch_no)
|
||||
state.retry_attempts = max(state.retry_attempts or 0, child.automatic_attempt or 0)
|
||||
if child.id > current.id:
|
||||
current = child
|
||||
state.current_batch_no = current.batch_no
|
||||
return current
|
||||
|
||||
|
||||
def tick(session, *, settings=None, now=None):
|
||||
settings, now = settings or get_settings(), now or local_now()
|
||||
if not settings.t_plus_automation_enabled:
|
||||
return {"status": "DISABLED", "queued": False}
|
||||
if settings.t_plus_stop_file and Path(settings.t_plus_stop_file).exists():
|
||||
return {"status": "STOP_REQUESTED", "queued": False}
|
||||
key, database = source_identity(settings)
|
||||
with maintenance_lock(session, key) as acquired:
|
||||
if not acquired:
|
||||
return {"status": "BUSY", "queued": False}
|
||||
session.rollback()
|
||||
state = _state(session, key, database, lock=True)
|
||||
state.maintenance_at = now
|
||||
period, _ = due_period(now, settings.t_plus_schedule_weekday, settings.t_plus_schedule_time)
|
||||
current = _cycle_current(session, state)
|
||||
if current and current.status == "SUCCEEDED":
|
||||
state.last_success_at = current.finished_at
|
||||
state.next_retry_at = None
|
||||
if _has_active(session):
|
||||
session.commit()
|
||||
return {"status": "DEFERRED", "queued": False, "period": period}
|
||||
batch = None
|
||||
if state.scheduled_period != period:
|
||||
batch = _enqueue(session, state, period, 0)
|
||||
elif current and current.status == "FAILED" and (state.retry_attempts or 0) < 2:
|
||||
codes = set(session.scalars(select(TPlusSyncError.error_code).where(TPlusSyncError.batch_no == current.batch_no)))
|
||||
if codes and codes <= TRANSIENT_CODES and current.finished_at is not None:
|
||||
delay = (5, 15)[state.retry_attempts or 0]
|
||||
state.next_retry_at = current.finished_at + timedelta(minutes=delay)
|
||||
if now >= state.next_retry_at:
|
||||
batch = _enqueue(session, state, period, (state.retry_attempts or 0) + 1, current.batch_no)
|
||||
else:
|
||||
state.next_retry_at = None
|
||||
elif current and current.status in ("FAILED", "PARTIAL"):
|
||||
state.next_retry_at = None
|
||||
session.commit()
|
||||
return {"status": "QUEUED" if batch else "CHECKED", "queued": batch is not None,
|
||||
"batch_no": batch.batch_no if batch else None, "period": period}
|
||||
|
||||
|
||||
def heartbeat(session_factory, settings, owner, phase, *, now=None, claim=False):
|
||||
key, database = source_identity(settings)
|
||||
with session_factory() as session:
|
||||
state = _state(session, key, database, lock=True)
|
||||
if state.worker_owner not in (None, owner) and not claim:
|
||||
session.rollback()
|
||||
return
|
||||
state.worker_owner, state.worker_phase = owner, phase
|
||||
state.heartbeat_at = now or local_now()
|
||||
session.commit()
|
||||
|
||||
|
||||
class WorkerHeartbeat:
|
||||
"""独立会话和线程写心跳,长查询不阻塞;只在worker已持采集锁时启动。"""
|
||||
def __init__(self, session, phase="RUNNING", interval=30, owner=None):
|
||||
self.factory = sessionmaker(session.get_bind(), expire_on_commit=False)
|
||||
self.settings = get_settings()
|
||||
self.owner, self.phase, self.interval = owner or str(uuid4()), phase, interval
|
||||
self.stop = threading.Event()
|
||||
self.thread = None
|
||||
|
||||
def __enter__(self):
|
||||
try:
|
||||
source_identity(self.settings)
|
||||
with self.factory() as session:
|
||||
if not inspect(session.connection()).has_table(TPlusAutomationState.__tablename__):
|
||||
return self
|
||||
heartbeat(self.factory, self.settings, self.owner, self.phase, claim=True)
|
||||
except Exception:
|
||||
logger.warning("T+心跳暂不可写,请检查迁移与运行配置")
|
||||
return self
|
||||
self.thread = threading.Thread(target=self._pulse, name="tplus-heartbeat", daemon=True)
|
||||
self.thread.start()
|
||||
return self
|
||||
|
||||
def _pulse(self):
|
||||
while not self.stop.wait(self.interval):
|
||||
try:
|
||||
heartbeat(self.factory, self.settings, self.owner, self.phase)
|
||||
except Exception:
|
||||
logger.warning("T+进程心跳更新失败")
|
||||
|
||||
def __exit__(self, *_args):
|
||||
self.stop.set()
|
||||
if self.thread:
|
||||
self.thread.join(timeout=5)
|
||||
try:
|
||||
heartbeat(self.factory, self.settings, self.owner, "IDLE")
|
||||
except Exception:
|
||||
logger.warning("T+进程状态更新失败")
|
||||
|
||||
|
||||
def overview(session, *, settings=None, now=None):
|
||||
settings, now = settings or get_settings(), now or local_now()
|
||||
base = {"enabled": settings.t_plus_automation_enabled, "weekday": settings.t_plus_schedule_weekday,
|
||||
"time": settings.t_plus_schedule_time, "timezone": "Asia/Shanghai", "worker_phase": "UNKNOWN",
|
||||
"heartbeat_at": None, "last_success_at": None, "next_retry_at": None, "alerts": []}
|
||||
base["stop_requested"] = bool(settings.t_plus_stop_file and Path(settings.t_plus_stop_file).exists())
|
||||
try:
|
||||
key, database = source_identity(settings)
|
||||
except RuntimeError:
|
||||
return base
|
||||
if not inspect(session.connection()).has_table(TPlusAutomationState.__tablename__):
|
||||
base["alerts"] = [{"code": "MIGRATION_REQUIRED", "message": "运行状态结构尚未升级"}]
|
||||
return base
|
||||
state = _state(session, key, database)
|
||||
if state:
|
||||
base.update(worker_phase=state.worker_phase, heartbeat_at=state.heartbeat_at,
|
||||
last_success_at=state.last_success_at, next_retry_at=state.next_retry_at)
|
||||
alerts = base["alerts"]
|
||||
latest = session.scalar(select(TPlusSyncBatch).where(TPlusSyncBatch.source_database == database).order_by(TPlusSyncBatch.id.desc()).limit(1))
|
||||
if latest and latest.status in ("FAILED", "PARTIAL"):
|
||||
alerts.append({"code": "SYNC_REQUIRES_ATTENTION", "message": "最近采集失败或部分失败,请核对后处理", "batch_no": latest.batch_no})
|
||||
queued = session.scalar(select(TPlusSyncBatch).where(TPlusSyncBatch.source_database == database,
|
||||
TPlusSyncBatch.status == "QUEUED", TPlusSyncBatch.create_time <= now - timedelta(minutes=15)).order_by(TPlusSyncBatch.id).limit(1))
|
||||
if queued:
|
||||
alerts.append({"code": "QUEUE_OVERDUE", "message": "采集任务排队超过15分钟", "batch_no": queued.batch_no})
|
||||
last_success = session.scalar(select(func.max(TPlusSyncBatch.finished_at)).where(TPlusSyncBatch.source_database == database, TPlusSyncBatch.status == "SUCCEEDED", TPlusSyncBatch.sync_mode == "FULL"))
|
||||
base["last_success_at"] = last_success
|
||||
if settings.t_plus_automation_enabled and not base["stop_requested"]:
|
||||
if last_success is None or now - last_success > timedelta(days=8):
|
||||
alerts.append({"code": "SYNC_STALE", "message": "尚无成功采集或超过8天未成功同步"})
|
||||
if state is None or state.heartbeat_at is None or now - state.heartbeat_at > timedelta(minutes=2) or state.worker_phase == "STOPPED":
|
||||
alerts.append({"code": "WORKER_OFFLINE", "message": "采集进程心跳中断,请检查托管任务"})
|
||||
return base
|
||||
@@ -0,0 +1,407 @@
|
||||
"""已核实的独立业务从源快照进入正式单据;预览、指纹、执行分别留痕。"""
|
||||
|
||||
import copy
|
||||
from collections import Counter
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
from types import SimpleNamespace
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.models import (
|
||||
Business,
|
||||
BusinessItem,
|
||||
PayrollInput,
|
||||
Personnel,
|
||||
Product,
|
||||
Receipt,
|
||||
Shipment,
|
||||
ShipmentItem,
|
||||
TPlusSourceLink,
|
||||
TPlusWorkLine,
|
||||
User,
|
||||
)
|
||||
from app.services import payroll_months
|
||||
from app.services.no_generator import next_no
|
||||
from app.services.payroll_access import ensure_payroll_writer
|
||||
from app.services.tplus.calculation_preview import execute_preview
|
||||
from app.services.tplus.fifo import money
|
||||
from app.services.tplus.workbench import WorkbenchError, digest, get_run
|
||||
|
||||
|
||||
def _link(session, source, entity, source_id):
|
||||
return session.scalar(
|
||||
select(TPlusSourceLink).where(
|
||||
TPlusSourceLink.source_database == source,
|
||||
TPlusSourceLink.entity_type == entity,
|
||||
TPlusSourceLink.source_id == str(source_id),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _latest_input(session, business_id):
|
||||
return session.scalar(
|
||||
select(PayrollInput)
|
||||
.where(PayrollInput.business_id == business_id)
|
||||
.order_by(PayrollInput.version.desc())
|
||||
.limit(1)
|
||||
)
|
||||
|
||||
|
||||
def _append_compatible(old, new):
|
||||
"""只能新增单据、补充事实;既有单据金额、日期与原发货归属变化进入差异处理。"""
|
||||
for entity, fields, identity in (
|
||||
(
|
||||
"shipments",
|
||||
(
|
||||
"date",
|
||||
"amount",
|
||||
"quantity",
|
||||
"product_id",
|
||||
"unit",
|
||||
"personnel_id",
|
||||
"business_key",
|
||||
"performance_excluded",
|
||||
"correction_pair_key",
|
||||
),
|
||||
lambda r: (r["source_id"], r["line_id"]),
|
||||
),
|
||||
(
|
||||
"receipts",
|
||||
("date", "amount", "voucher_confirmed", "exclusion", "redirect_business_key"),
|
||||
lambda r: r["source_id"],
|
||||
),
|
||||
):
|
||||
current = {identity(r): r for r in new[entity]}
|
||||
for previous in old[entity]:
|
||||
candidate = current.get(identity(previous))
|
||||
if candidate is None or any(
|
||||
str(previous.get(f)) != str(candidate.get(f)) for f in fields
|
||||
):
|
||||
return False
|
||||
return all(
|
||||
old["group"].get(k) == new["group"].get(k)
|
||||
for k in ("opening_balance", "excluded_receipt_ids", "customer_id")
|
||||
)
|
||||
|
||||
|
||||
def _target_state(session, link):
|
||||
if link is None:
|
||||
return None
|
||||
business = session.get(Business, link.target_id)
|
||||
if business is None:
|
||||
raise WorkbenchError("BUSINESS_TARGET_MISSING")
|
||||
tables = (
|
||||
(Business, Business.id == business.id),
|
||||
(BusinessItem, BusinessItem.business_id == business.id),
|
||||
(Shipment, Shipment.business_id == business.id),
|
||||
(Receipt, Receipt.business_id == business.id),
|
||||
)
|
||||
result = {}
|
||||
for model, condition in tables:
|
||||
result[model.__tablename__] = [
|
||||
{c.name: str(getattr(r, c.name)) for c in model.__table__.columns}
|
||||
for r in session.scalars(select(model).where(condition).order_by(model.id))
|
||||
]
|
||||
return result
|
||||
|
||||
|
||||
def _build(session, run):
|
||||
preview = SimpleNamespace(input_json=run.input_json, source_database=run.source_database)
|
||||
execute_preview(session, preview, save_lines=False, customer_scope=True)
|
||||
frozen = preview.snapshot_json
|
||||
rows = []
|
||||
targets = {}
|
||||
seen_customers = set()
|
||||
for key, values in frozen["calculation_inputs"].items():
|
||||
summary = preview.summary_json["groups"][key]
|
||||
row = {"entity_type": "businesses", "source_id": key, "action": "PENDING", "reason": None}
|
||||
rows.append(row)
|
||||
if summary["status"] not in ("READY", "SUCCEEDED"):
|
||||
row["reason"] = ",".join(summary.get("reasons", [])) or "BUSINESS_DATA_PENDING"
|
||||
continue
|
||||
customer_id = values["group"]["customer_id"]
|
||||
if customer_id in seen_customers:
|
||||
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"]):
|
||||
row["reason"] = "GUIDANCE_FINALIZATION_REQUIRED"
|
||||
continue
|
||||
business_key = "customer:" + customer_id
|
||||
row["business_key"] = business_key
|
||||
row["input_hash"] = digest(values)
|
||||
link = _link(session, run.source_database, "businesses", business_key)
|
||||
targets[key] = _target_state(session, link)
|
||||
if link:
|
||||
current = _latest_input(session, link.target_id)
|
||||
row["target_id"] = link.target_id
|
||||
if current and current.fingerprint == row["input_hash"]:
|
||||
row["action"] = "SKIP"
|
||||
elif current and _append_compatible(current.data_json, values):
|
||||
row["action"] = "UPDATE"
|
||||
else:
|
||||
row["action"], row["reason"] = "CONFLICT", "FORMAL_SOURCE_CHANGED"
|
||||
else:
|
||||
row["action"] = "CREATE"
|
||||
for entity, ids in (
|
||||
("shipments", values["group"]["shipment_ids"]),
|
||||
("receipts", values["group"]["receipt_ids"]),
|
||||
):
|
||||
for source_id in ids:
|
||||
linked = _link(session, run.source_database, entity, source_id)
|
||||
if linked and (
|
||||
not link
|
||||
or not current
|
||||
or source_id not in current.data_json["maps"].get(entity, {})
|
||||
):
|
||||
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"]:
|
||||
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}
|
||||
return rows, frozen, digest(frozen)
|
||||
|
||||
|
||||
def _add_link(session, source, entity, source_id, target):
|
||||
session.add(
|
||||
TPlusSourceLink(
|
||||
source_database=source,
|
||||
entity_type=entity,
|
||||
source_id=str(source_id),
|
||||
target_id=target.id,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _new_no(session, model, column, prefix):
|
||||
return next_no(session, model=model, column_name=column, prefix=prefix)
|
||||
|
||||
|
||||
def _create(session, run, row, frozen):
|
||||
values = copy.deepcopy(frozen["calculation"]["calculation_inputs"][row["source_id"]])
|
||||
people = {}
|
||||
for code in values["facts"]["personnel"]:
|
||||
candidates = list(
|
||||
session.scalars(
|
||||
select(Personnel)
|
||||
.join(TPlusSourceLink, TPlusSourceLink.target_id == Personnel.id)
|
||||
.where(
|
||||
TPlusSourceLink.source_database == run.source_database,
|
||||
TPlusSourceLink.entity_type == "personnel",
|
||||
Personnel.source_no == code,
|
||||
)
|
||||
)
|
||||
)
|
||||
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"))
|
||||
for handover in values["facts"]["handovers"]:
|
||||
needed.update((str(handover["from_personnel_id"]), str(handover["to_personnel_id"])))
|
||||
if needed - people.keys():
|
||||
raise WorkbenchError("PERSONNEL_IMPORT_REQUIRED")
|
||||
customer = _link(session, run.source_database, "customers", values["group"]["customer_id"])
|
||||
old_input = _latest_input(session, row["target_id"]) if row.get("target_id") else None
|
||||
business = (
|
||||
session.get(Business, row["target_id"], with_for_update=True)
|
||||
if row.get("target_id")
|
||||
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"]),
|
||||
status=1,
|
||||
is_special=0,
|
||||
t_plus_id=row["business_key"],
|
||||
source_no=row["business_key"],
|
||||
sync_batch_no=run.run_no,
|
||||
synced_at=datetime.now(),
|
||||
)
|
||||
)
|
||||
session.add(business)
|
||||
session.flush()
|
||||
if old_input is None:
|
||||
_add_link(session, run.source_database, "businesses", row["business_key"], business)
|
||||
maps = (
|
||||
copy.deepcopy(old_input.data_json["maps"])
|
||||
if old_input
|
||||
else {"shipments": {}, "shipment_items": {}, "receipts": {}, "personnel": {}}
|
||||
)
|
||||
maps["personnel"].update({code: p.id for code, p in people.items()})
|
||||
freight_total, sales_total, cost_total = Decimal(0), Decimal(0), Decimal(0)
|
||||
for source_id in values["group"]["shipment_ids"]:
|
||||
lines = [s for s in values["shipments"] if s["source_id"] == source_id]
|
||||
if not lines:
|
||||
continue
|
||||
if any(s.get("performance_excluded") for s in lines):
|
||||
values.setdefault("excluded_corrections", []).append(
|
||||
{"shipment_id": source_id, "lines": lines}
|
||||
)
|
||||
continue
|
||||
head = lines[0]
|
||||
freight = sum((money(s.get("freight_amount") or "0") for s in lines), Decimal(0))
|
||||
if source_id in maps["shipments"]:
|
||||
existing = session.get(Shipment, maps["shipments"][source_id], with_for_update=True)
|
||||
if (
|
||||
existing is None
|
||||
or existing.total_amount != sum((money(s["amount"]) for s in lines), Decimal(0))
|
||||
or existing.ship_date.isoformat() != head["date"]
|
||||
):
|
||||
raise WorkbenchError("FORMAL_TARGET_CHANGED")
|
||||
sales_total += existing.total_amount
|
||||
freight_total += existing.freight_amount
|
||||
cost_total += sum(
|
||||
(money(s["cost_total"]) for s in lines if s.get("cost_total") is not None),
|
||||
Decimal(0),
|
||||
)
|
||||
continue
|
||||
shipment = Shipment(
|
||||
business_id=business.id,
|
||||
shipment_no=_new_no(session, Shipment, "shipment_no", "SH"),
|
||||
ship_date=date.fromisoformat(head["date"]),
|
||||
total_amount=sum((money(s["amount"]) for s in lines), Decimal(0)),
|
||||
freight_amount=freight,
|
||||
source_no=head["source_no"],
|
||||
t_plus_id=source_id,
|
||||
sync_batch_no=run.run_no,
|
||||
synced_at=datetime.now(),
|
||||
)
|
||||
session.add(shipment)
|
||||
session.flush()
|
||||
maps["shipments"][source_id] = shipment.id
|
||||
_add_link(session, run.source_database, "shipments", source_id, shipment)
|
||||
for ship in lines:
|
||||
product = session.get(
|
||||
Product,
|
||||
_link(session, run.source_database, "products", ship["product_id"]).target_id,
|
||||
)
|
||||
quantity = money(ship["quantity"])
|
||||
amount = money(ship["amount"])
|
||||
cost = money(ship["cost_total"]) if ship.get("cost_total") is not None else None
|
||||
item = ShipmentItem(
|
||||
shipment_id=shipment.id,
|
||||
product_id=product.id,
|
||||
product_name=product.product_name,
|
||||
spec_model=product.spec_model or "",
|
||||
unit=ship["unit"],
|
||||
quantity=quantity,
|
||||
sale_price=(amount / quantity).quantize(Decimal("0.0001")),
|
||||
amount=amount,
|
||||
)
|
||||
session.add(item)
|
||||
session.flush()
|
||||
maps["shipment_items"][ship["line_id"]] = item.id
|
||||
session.add(
|
||||
BusinessItem(
|
||||
business_id=business.id,
|
||||
product_id=product.id,
|
||||
product_name=product.product_name,
|
||||
spec_model=product.spec_model or "",
|
||||
unit=ship["unit"],
|
||||
quantity=quantity,
|
||||
sale_price=item.sale_price,
|
||||
sale_amount=amount,
|
||||
cost_amount=cost,
|
||||
product_category=ship["category"],
|
||||
)
|
||||
)
|
||||
sales_total += amount
|
||||
cost_total += cost or Decimal(0)
|
||||
freight_total += freight
|
||||
received = Decimal(0)
|
||||
for cash in values["receipts"]:
|
||||
if cash.get("exclusion"):
|
||||
continue
|
||||
if cash["source_id"] in maps["receipts"]:
|
||||
existing = session.get(
|
||||
Receipt, maps["receipts"][cash["source_id"]], with_for_update=True
|
||||
)
|
||||
if (
|
||||
existing is None
|
||||
or existing.amount != money(cash["amount"])
|
||||
or existing.receipt_date.isoformat() != cash["date"]
|
||||
):
|
||||
raise WorkbenchError("FORMAL_TARGET_CHANGED")
|
||||
received += existing.amount
|
||||
continue
|
||||
receipt = Receipt(
|
||||
business_id=business.id,
|
||||
receipt_batch_no=_new_no(session, Receipt, "receipt_batch_no", "HK"),
|
||||
receipt_date=date.fromisoformat(cash["date"]),
|
||||
amount=money(cash["amount"]),
|
||||
is_barter=0,
|
||||
settlement_status=0,
|
||||
source_no=cash["source_no"],
|
||||
t_plus_id=cash["source_id"],
|
||||
sync_batch_no=run.run_no,
|
||||
synced_at=datetime.now(),
|
||||
)
|
||||
session.add(receipt)
|
||||
session.flush()
|
||||
maps["receipts"][cash["source_id"]] = receipt.id
|
||||
_add_link(session, run.source_database, "receipts", cash["source_id"], receipt)
|
||||
received += receipt.amount
|
||||
business.total_sale_amount = sales_total
|
||||
business.total_ship_amount = sales_total
|
||||
business.total_cost_amount = cost_total
|
||||
business.total_received_amount = received
|
||||
business.total_freight_amount = freight_total
|
||||
values["maps"] = maps
|
||||
values["source_facts"] = frozen["calculation"]["facts"]
|
||||
session.add(
|
||||
PayrollInput(
|
||||
business_id=business.id,
|
||||
version=old_input.version + 1 if old_input else 1,
|
||||
source_database=run.source_database,
|
||||
group_key=row["source_id"],
|
||||
fingerprint=row["input_hash"],
|
||||
data_json=values,
|
||||
requested_by=run.requested_by,
|
||||
)
|
||||
)
|
||||
row["target_id"] = business.id
|
||||
|
||||
|
||||
def execute_business(session, run):
|
||||
operator = session.get(User, run.requested_by)
|
||||
ensure_payroll_writer(operator)
|
||||
request = run
|
||||
if run.kind == "BUSINESS_APPLY":
|
||||
request = get_run(session, run.input_json["preview_no"], run.source_database)
|
||||
if request.kind != "BUSINESS_PREVIEW" or request.status not in ("SUCCEEDED", "PARTIAL"):
|
||||
raise WorkbenchError("PREVIEW_REQUIRED")
|
||||
payroll_months.ensure_allocation_editable(session, request.input_json["month"], operator)
|
||||
rows, frozen, fingerprint = _build(session, request)
|
||||
if run.kind == "BUSINESS_APPLY" and request.snapshot_hash != fingerprint:
|
||||
raise WorkbenchError("PREVIEW_STALE")
|
||||
counts = Counter()
|
||||
for i, row in enumerate(rows, 1):
|
||||
if run.kind == "BUSINESS_APPLY" and row["action"] in ("CREATE", "UPDATE"):
|
||||
try:
|
||||
with session.begin_nested():
|
||||
_create(session, run, row, frozen)
|
||||
except WorkbenchError as exc:
|
||||
row["action"], row["reason"] = "PENDING", str(exc)
|
||||
counts[row["action"]] += 1
|
||||
session.add(
|
||||
TPlusWorkLine(
|
||||
run_no=run.run_no,
|
||||
line_no=i,
|
||||
entity_type="businesses",
|
||||
source_id=row["source_id"],
|
||||
action=row["action"],
|
||||
reason=row.get("reason"),
|
||||
data_json=row,
|
||||
)
|
||||
)
|
||||
run.snapshot_json, run.snapshot_hash = frozen, fingerprint
|
||||
run.summary_json = {
|
||||
"counts": dict(counts),
|
||||
"total": len(rows),
|
||||
"formal_business_written": run.kind == "BUSINESS_APPLY",
|
||||
"formal_payroll_written": False,
|
||||
}
|
||||
run.status = "PARTIAL" if counts["PENDING"] or counts["CONFLICT"] else "SUCCEEDED"
|
||||
@@ -0,0 +1,541 @@
|
||||
"""固定真实源快照和人工事实,组装隔离的 FIFO 核算预演。"""
|
||||
|
||||
from calendar import monthrange
|
||||
from collections import Counter
|
||||
from datetime import date
|
||||
from decimal import InvalidOperation
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.exceptions import BadRequestError
|
||||
from app.models import Customer, Product, TPlusSourceLink, TPlusSyncRecord, TPlusWorkLine
|
||||
from app.services import param_service
|
||||
from app.services.tplus.fifo import START, amount_text, money, preview_group
|
||||
from app.services.tplus.snapshots import source_records, source_statement
|
||||
from app.services.tplus.workbench import RULE_VERSION, WorkbenchError, digest, get_run, latest_facts
|
||||
|
||||
|
||||
def _parameters(session, days):
|
||||
values = {}
|
||||
for day in sorted(days):
|
||||
snap = param_service.resolve_snapshot(session, date.fromisoformat(day))
|
||||
values[day] = {
|
||||
"start": snap.get("salesperson.rate.start"),
|
||||
"slope": snap.get("salesperson.rate.slope"),
|
||||
"floor": snap.get("salesperson.rate.floor"),
|
||||
"boundary": snap.integer("salesperson.days.boundary"),
|
||||
"enterprise": snap.get("enterprise.rate"),
|
||||
"thresholds": {
|
||||
category: snap.get("product.threshold", product_category=category)
|
||||
for category in ("municipal_water", "rail_transit")
|
||||
},
|
||||
}
|
||||
return values
|
||||
|
||||
|
||||
def _group_records(session, source, entity, ids):
|
||||
rows = source_records(session, source, entity, ids)
|
||||
if {r.source_id for r in rows} != set(ids):
|
||||
raise WorkbenchError("GROUP_SOURCE_MISSING")
|
||||
return rows
|
||||
|
||||
|
||||
def _guidance(facts, product_id, line_id, month):
|
||||
candidates = [
|
||||
f.data_json
|
||||
for (kind, _), f in facts.items()
|
||||
if kind == "GUIDANCE"
|
||||
and f.data_json["product_id"] == product_id
|
||||
and f.data_json["month"] == month
|
||||
and (f.data_json["basis"] == "UNIT" or f.data_json.get("shipment_line_id") == line_id)
|
||||
]
|
||||
return candidates[0] if len(candidates) == 1 else None
|
||||
|
||||
|
||||
def _shipments(session, run, key, group, facts):
|
||||
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"]
|
||||
)
|
||||
heads = {r.source_id: r for r in headers}
|
||||
product_ids = {str(r.payload_json.get("idinventory")) for r in details}
|
||||
product_targets = dict(
|
||||
session.execute(
|
||||
select(TPlusSourceLink.source_id, Product)
|
||||
.join(Product, Product.id == TPlusSourceLink.target_id)
|
||||
.where(
|
||||
TPlusSourceLink.source_database == run.source_database,
|
||||
TPlusSourceLink.entity_type == "products",
|
||||
TPlusSourceLink.source_id.in_(product_ids),
|
||||
)
|
||||
).all()
|
||||
)
|
||||
customer_target = session.scalar(
|
||||
select(Customer)
|
||||
.join(TPlusSourceLink, Customer.id == TPlusSourceLink.target_id)
|
||||
.where(
|
||||
TPlusSourceLink.source_database == run.source_database,
|
||||
TPlusSourceLink.entity_type == "customers",
|
||||
TPlusSourceLink.source_id == group["customer_id"],
|
||||
)
|
||||
)
|
||||
rows, snapshots = [], headers + details
|
||||
for record in details:
|
||||
raw = record.payload_json
|
||||
head_record = heads.get(str(raw.get("source_parent_id")))
|
||||
if head_record is None:
|
||||
continue
|
||||
head = head_record.payload_json
|
||||
if str(head.get("idcustomer")) != group["customer_id"]:
|
||||
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 {}
|
||||
)
|
||||
product_id = str(raw.get("idinventory"))
|
||||
product = facts.get(("PRODUCT", product_id))
|
||||
customer = facts.get(("CUSTOMER", group["customer_id"]))
|
||||
pending = []
|
||||
if (
|
||||
not supplement.get("sales_amount_excludes_freight")
|
||||
and supplement.get("freight_amount") is None
|
||||
):
|
||||
pending.append("FREIGHT_PENDING")
|
||||
if not supplement.get("business_key"):
|
||||
pending.append("SHIPPING_OWNERSHIP_PENDING")
|
||||
# SA_SaleDelivery.isCancel 是 VoucherSettleState(如428未结清、180已结清),
|
||||
# 并非作废标志;不能将非零编码当作红冲或作废。
|
||||
if head.get("voucherState") != 189:
|
||||
pending.append("SHIPMENT_STATE_PENDING")
|
||||
if head.get("isCounteractDelivery") and not correction.get("exclude_performance"):
|
||||
pending.append("NEGATIVE_EVENT_REVIEW_REQUIRED")
|
||||
day = supplement.get("date_override") or str(head["voucherdate"])[:10]
|
||||
managers = [
|
||||
f.data_json
|
||||
for (kind, _), f in facts.items()
|
||||
if kind == "MANAGER"
|
||||
and f.data_json["business_key"] == supplement.get("business_key")
|
||||
and f.data_json["effective_date"] <= day
|
||||
]
|
||||
managers.sort(key=lambda v: v["effective_date"])
|
||||
for (kind, _), fact in facts.items():
|
||||
if (
|
||||
kind == "HANDOVER"
|
||||
and fact.data_json["business_key"] == supplement.get("business_key")
|
||||
and fact.data_json["mode"] == "ACTIVE"
|
||||
and not fact.data_json["shipping_ownership_confirmed"]
|
||||
):
|
||||
pending.append("HANDOVER_SHIPMENTS_PENDING")
|
||||
if raw.get("taxAmount") is None:
|
||||
pending.append("TAX_AMOUNT_PENDING")
|
||||
gross_amount = money(raw.get("taxAmount") or "0")
|
||||
freight = (
|
||||
money(supplement["freight_amount"])
|
||||
if supplement.get("freight_amount") is not None
|
||||
else money("0")
|
||||
)
|
||||
target_product = product_targets.get(product_id)
|
||||
rows.append(
|
||||
{
|
||||
"source_id": head_record.source_id,
|
||||
"line_id": record.source_id,
|
||||
"source_no": head_record.source_no,
|
||||
"date": day,
|
||||
"group_key": key,
|
||||
"business_key": supplement.get("business_key"),
|
||||
"personnel_id": supplement.get("personnel_id"),
|
||||
"amount": str(gross_amount - freight),
|
||||
"gross_tax_amount": str(gross_amount),
|
||||
"freight_amount": str(freight)
|
||||
if supplement.get("freight_amount") is not None
|
||||
else None,
|
||||
"product_id": product_id,
|
||||
"quantity": str(raw.get("quantity") or "0"),
|
||||
"category": target_product.product_category
|
||||
if target_product
|
||||
else product.data_json["category"]
|
||||
if product
|
||||
else None,
|
||||
"unit": target_product.unit
|
||||
if target_product
|
||||
else product.data_json["unit"]
|
||||
if product
|
||||
else None,
|
||||
"customer_type": customer_target.customer_type
|
||||
if customer_target
|
||||
else customer.data_json["customer_type"]
|
||||
if customer
|
||||
else None,
|
||||
"classification_evidence": {
|
||||
"product_target_id": target_product.id if target_product else None,
|
||||
"customer_target_id": customer_target.id if customer_target else None,
|
||||
},
|
||||
"manager_id": managers[-1]["manager_id"] if managers else None,
|
||||
"performance_excluded": bool(correction.get("exclude_performance")),
|
||||
"correction_pair_key": correction.get("pair_key"),
|
||||
"exclusion_reason": "CORRECTION_EXCLUDED" if correction else None,
|
||||
"pending": pending,
|
||||
}
|
||||
)
|
||||
covered = {r["source_id"] for r in rows}
|
||||
if covered != set(group["shipment_ids"]):
|
||||
raise WorkbenchError("SHIPMENT_DETAILS_MISSING")
|
||||
return rows, snapshots
|
||||
|
||||
|
||||
def _receipts(session, run, key, group, facts):
|
||||
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 {}
|
||||
)
|
||||
redirect_target = correction.get("redirect_business_key")
|
||||
if str(raw.get("idpartner")) != group["customer_id"] and redirect_target != key:
|
||||
raise WorkbenchError("GROUP_CUSTOMER_MISMATCH")
|
||||
fact = facts.get(("RECEIPT", record.source_id))
|
||||
confirmed, voucher_evidence, voucher_pending = _voucher_evidence(raw, fact)
|
||||
pending = []
|
||||
pending.extend(voucher_pending)
|
||||
if raw.get("isReceiveFlag") != 1:
|
||||
pending.append("NOT_RECEIPT")
|
||||
# 凭证关系与人工核实冲突时不能强行按人工勾选放行。
|
||||
if pending:
|
||||
confirmed = False
|
||||
if record.source_id in group.get("excluded_receipt_ids", []):
|
||||
continue
|
||||
if raw.get("voucherstate") != 189:
|
||||
confirmed = False
|
||||
exclusion = None
|
||||
if raw.get("isReceiveFlag") == 0:
|
||||
exclusion = "NOT_RECEIPT"
|
||||
elif (
|
||||
voucher_evidence.get("basis") == "GL_SrcVoucherInfo/GL_Doc"
|
||||
and voucher_evidence.get("count") == 0
|
||||
and not voucher_pending
|
||||
):
|
||||
exclusion = "NOT_GENERATED_VOUCHER"
|
||||
if redirect_target and redirect_target != key:
|
||||
exclusion = "REDIRECTED_RECEIPT"
|
||||
rows.append(
|
||||
{
|
||||
"source_id": record.source_id,
|
||||
"source_no": record.source_no,
|
||||
"date": str(raw["voucherDate"])[:10],
|
||||
"group_key": key,
|
||||
"amount": str(raw["amount"]),
|
||||
"voucher_confirmed": confirmed,
|
||||
"exclusion": exclusion,
|
||||
"voucher_evidence": voucher_evidence,
|
||||
"accounting_voucher_id": raw.get("accounting_voucher_id"),
|
||||
"accounting_voucher_code": raw.get("accounting_voucher_code"),
|
||||
"pending": pending,
|
||||
"redirect_business_key": redirect_target,
|
||||
"redirected_from_customer_id": str(raw.get("idpartner"))
|
||||
if redirect_target == key and str(raw.get("idpartner")) != key
|
||||
else None,
|
||||
}
|
||||
)
|
||||
return rows, records
|
||||
|
||||
|
||||
def _voucher_evidence(raw, fact):
|
||||
"""以真实总账关系核实生成资格;旧快照只接受有依据的人工核实。"""
|
||||
count, valid = raw.get("accounting_voucher_count"), raw.get("valid_accounting_voucher_count")
|
||||
if count is None or valid is None:
|
||||
return (
|
||||
bool(fact and fact.data_json["voucher_confirmed"]),
|
||||
{
|
||||
"basis": "MANUAL" if fact else "UNKNOWN",
|
||||
"reference": fact.data_json["voucher_reference"] if fact else None,
|
||||
},
|
||||
[],
|
||||
)
|
||||
count, valid = int(count), int(valid)
|
||||
if count < 0 or valid < 0 or valid > count:
|
||||
raise ValueError("凭证关联数量异常")
|
||||
generated = count > 0 and valid == count and raw.get("accounting_voucher_id") is not None
|
||||
pending = ["ACCOUNTING_VOUCHER_INVALID"] if count > 0 and not generated else []
|
||||
if fact and fact.data_json["voucher_confirmed"] != generated:
|
||||
pending.append("VOUCHER_EVIDENCE_CONFLICT")
|
||||
return (
|
||||
generated and not pending,
|
||||
{
|
||||
"basis": "GL_SrcVoucherInfo/GL_Doc",
|
||||
"count": count,
|
||||
"valid_count": valid,
|
||||
"voucher_id": raw.get("accounting_voucher_id"),
|
||||
"voucher_code": raw.get("accounting_voucher_code"),
|
||||
"reference": fact.data_json["voucher_reference"] if fact else None,
|
||||
},
|
||||
pending,
|
||||
)
|
||||
|
||||
|
||||
def _costed_shipments(shipments, receipts, facts, group):
|
||||
"""回款月定价时成本按每笔回款取值,交给 FIFO 逐段计算。"""
|
||||
basis = "SHIPMENT"
|
||||
for ship in shipments:
|
||||
if basis is None:
|
||||
ship["pending"].append("GUIDANCE_MONTH_BASIS_PENDING")
|
||||
continue
|
||||
months = {r["date"][:7] for r in receipts} if basis == "RECEIPT" else {ship["date"][:7]}
|
||||
costs = {}
|
||||
for month in months:
|
||||
guide = _guidance(facts, ship["product_id"], ship["line_id"], month)
|
||||
if guide is None or guide["unit"] != ship["unit"]:
|
||||
continue
|
||||
ship["guidance_finalized"] = bool(guide.get("is_finalized"))
|
||||
cost = money(guide["amount"])
|
||||
if guide["basis"] == "UNIT":
|
||||
if money(ship["quantity"]) <= 0:
|
||||
ship["pending"].append("QUANTITY_PENDING")
|
||||
continue
|
||||
cost *= money(ship["quantity"])
|
||||
costs[month] = amount_text(cost)
|
||||
ship["cost_by_month"] = costs
|
||||
ship["cost_month_basis"] = basis
|
||||
ship["cost_total"] = costs.get(ship["date"][:7]) if basis == "SHIPMENT" else None
|
||||
|
||||
|
||||
def execute_preview(session, run, *, save_lines=True, customer_scope=False):
|
||||
"""同一次事务固化所有事实、来源指纹、参数和结果,不写正式台账。"""
|
||||
if run.input_json.get("replay_no"):
|
||||
return _replay(session, run)
|
||||
facts = latest_facts(session, run.source_database)
|
||||
month = run.input_json["month"]
|
||||
year, mon = map(int, month.split("-"))
|
||||
until = date(year, mon, monthrange(year, mon)[1])
|
||||
selected = []
|
||||
overlapping = set()
|
||||
for key in run.input_json["group_keys"]:
|
||||
fact = facts.get(("GROUP", key))
|
||||
if fact is None:
|
||||
selected.append((key, {}))
|
||||
continue
|
||||
group = dict(fact.data_json)
|
||||
if customer_scope:
|
||||
peers = [
|
||||
f.data_json
|
||||
for (kind, _), f in facts.items()
|
||||
if kind == "GROUP" and f.data_json["customer_id"] == group["customer_id"]
|
||||
]
|
||||
|
||||
def policy(g):
|
||||
return (
|
||||
money(g["opening_balance"]) if g.get("opening_balance") is not None else None,
|
||||
tuple(sorted(g.get("excluded_receipt_ids", []))),
|
||||
bool(g.get("opening_confirmed")),
|
||||
bool(g.get("historical_settlement_confirmed")),
|
||||
)
|
||||
|
||||
if len({policy(g) for g in peers}) != 1:
|
||||
selected.append((key, {"customer_scope_conflict": True}))
|
||||
continue
|
||||
# 2026-10-30 E7:同一发货客户名头统一平账,不再按合同/人工业务键拆组。
|
||||
group["scope_confirmed"] = True
|
||||
for entity, field, day_field, target_field in (
|
||||
("shipments", "idcustomer", "voucherdate", "shipment_ids"),
|
||||
("receipts", "idpartner", "voucherDate", "receipt_ids"),
|
||||
):
|
||||
statement = source_statement(session, run.source_database, entity).where(
|
||||
TPlusSyncRecord.payload_json[field].as_integer() == int(group["customer_id"])
|
||||
)
|
||||
source_ids = [
|
||||
r.source_id
|
||||
for r in session.scalars(statement)
|
||||
if START.isoformat()
|
||||
<= str(r.payload_json.get(day_field, ""))[:10]
|
||||
<= until.isoformat()
|
||||
]
|
||||
if entity == "receipts":
|
||||
source_ids.extend(
|
||||
f.fact_key
|
||||
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
|
||||
)
|
||||
group[target_field] = sorted(source_ids, key=lambda value: int(value))
|
||||
# 也检查未选组,避免同一单据在另一组被解释为独立资金。
|
||||
for (kind, other_key), other in facts.items():
|
||||
if (
|
||||
kind == "GROUP"
|
||||
and other_key != key
|
||||
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"])
|
||||
)
|
||||
):
|
||||
overlapping.add(key)
|
||||
selected.append((key, group))
|
||||
frozen = {
|
||||
"rule_version": RULE_VERSION,
|
||||
"month": month,
|
||||
"source": [],
|
||||
"facts": [
|
||||
{
|
||||
"id": f.id,
|
||||
"kind": f.kind,
|
||||
"key": f.fact_key,
|
||||
"version": f.version,
|
||||
"data": f.data_json,
|
||||
"evidence": f.evidence,
|
||||
}
|
||||
for f in facts.values()
|
||||
],
|
||||
"parameters": {},
|
||||
"calculation_inputs": {},
|
||||
}
|
||||
count, summaries = 0, {}
|
||||
person_facts = {key: f.data_json for (kind, key), f in facts.items() if kind == "PERSONNEL"}
|
||||
handovers = [f.data_json for (kind, _), f in facts.items() if kind == "HANDOVER"]
|
||||
for key, data in selected:
|
||||
group = {**data, "key": key}
|
||||
if data.get("customer_scope_conflict"):
|
||||
result = {"status": "PENDING", "reasons": ["CUSTOMER_SCOPE_CONFLICT"], "lines": []}
|
||||
elif key in overlapping:
|
||||
result = {"status": "PENDING", "reasons": ["OVERLAPPING_GROUPS"], "lines": []}
|
||||
elif not data:
|
||||
result = {"status": "PENDING", "reasons": ["GROUP_FACT_REQUIRED"], "lines": []}
|
||||
elif (
|
||||
not group.get("scope_confirmed")
|
||||
or not group.get("opening_confirmed")
|
||||
or not group.get("historical_settlement_confirmed")
|
||||
):
|
||||
result = preview_group(group, [], [], {}, {}, until)
|
||||
else:
|
||||
try:
|
||||
ships, ship_refs = _shipments(session, run, key, group, facts)
|
||||
cash, cash_refs = _receipts(session, run, key, group, facts)
|
||||
frozen["source"].extend(
|
||||
(r.entity_type, r.source_id, r.id, r.row_hash) for r in ship_refs + cash_refs
|
||||
)
|
||||
_costed_shipments(ships, cash, facts, group)
|
||||
# max(发货日,回款日) 必为其中一个日期,无需构造单据笛卡尔积。
|
||||
days = {
|
||||
row["date"]
|
||||
for row in [*ships, *cash]
|
||||
if START.isoformat() <= row["date"] <= until.isoformat()
|
||||
}
|
||||
params = _parameters(session, days)
|
||||
# 已确认的原修改方案和到账日口径不再依赖重复人工勾选。
|
||||
params["august_confirmed"] = True
|
||||
params["prepayment_parameter_basis"] = "RECEIPT"
|
||||
frozen["parameters"][key] = params
|
||||
frozen["calculation_inputs"][key] = {
|
||||
"group": group,
|
||||
"shipments": ships,
|
||||
"receipts": cash,
|
||||
"facts": {"personnel": person_facts, "handovers": handovers},
|
||||
"params": params,
|
||||
}
|
||||
result = preview_group(
|
||||
group,
|
||||
ships,
|
||||
cash,
|
||||
{"personnel": person_facts, "handovers": handovers},
|
||||
params,
|
||||
until,
|
||||
)
|
||||
except WorkbenchError as exc:
|
||||
frozen["calculation_inputs"].pop(key, None)
|
||||
result = {"status": "PENDING", "reasons": [str(exc)], "lines": []}
|
||||
except BadRequestError:
|
||||
frozen["calculation_inputs"].pop(key, None)
|
||||
result = {"status": "PENDING", "reasons": ["PARAMETERS_PENDING"], "lines": []}
|
||||
except (ValueError, KeyError, InvalidOperation, TypeError):
|
||||
frozen["calculation_inputs"].pop(key, None)
|
||||
result = {"status": "PENDING", "reasons": ["DATA_VALUE_INVALID"], "lines": []}
|
||||
if key not in frozen["calculation_inputs"]:
|
||||
frozen["calculation_inputs"][key] = {"pending": result}
|
||||
summaries[key] = {k: v for k, v in result.items() if k != "lines"}
|
||||
if save_lines:
|
||||
count = _save_group(session, run, key, result, count)
|
||||
run.snapshot_json, run.snapshot_hash = frozen, digest(frozen)
|
||||
run.summary_json = {
|
||||
"groups": summaries,
|
||||
"total_lines": count,
|
||||
"rule_version": RULE_VERSION,
|
||||
"month": month,
|
||||
"formal_payroll_written": False,
|
||||
}
|
||||
states = Counter(v["status"] for v in summaries.values())
|
||||
run.status = "PARTIAL" if states["PENDING"] or states["PARTIAL"] else "SUCCEEDED"
|
||||
|
||||
|
||||
def _save_group(session, run, key, result, count):
|
||||
lines = result["lines"] or (
|
||||
[{"status": "PENDING", "reasons": result["reasons"], "performance": None}]
|
||||
if result["status"] == "PENDING"
|
||||
else []
|
||||
)
|
||||
for line in lines:
|
||||
count += 1
|
||||
session.add(
|
||||
TPlusWorkLine(
|
||||
run_no=run.run_no,
|
||||
line_no=count,
|
||||
entity_type="group",
|
||||
source_id=key,
|
||||
action=line["status"],
|
||||
reason=",".join(line.get("reasons", []))[:128] or None,
|
||||
data_json={**line, "group_key": key, "formal_payroll_written": False},
|
||||
)
|
||||
)
|
||||
return count
|
||||
|
||||
|
||||
def _replay(session, run):
|
||||
"""只重算原运行冻结输入;不读取当前事实、指导价、参数或源数据。"""
|
||||
import copy
|
||||
|
||||
previous = get_run(session, run.input_json["replay_no"], run.source_database)
|
||||
frozen = copy.deepcopy(previous.snapshot_json or {})
|
||||
if (
|
||||
previous.kind != "CALC_PREVIEW"
|
||||
or frozen.get("rule_version") not in ("2026-10-30.1", RULE_VERSION)
|
||||
or not frozen.get("calculation_inputs")
|
||||
):
|
||||
raise WorkbenchError("REPLAY_UNAVAILABLE")
|
||||
year, month = map(int, frozen["month"].split("-"))
|
||||
until = date(year, month, monthrange(year, month)[1])
|
||||
summaries, count = {}, 0
|
||||
for key, values in frozen["calculation_inputs"].items():
|
||||
result = values.get("pending")
|
||||
if result is None:
|
||||
result = preview_group(
|
||||
values["group"],
|
||||
values["shipments"],
|
||||
values["receipts"],
|
||||
values["facts"],
|
||||
values["params"],
|
||||
until,
|
||||
)
|
||||
summaries[key] = {k: v for k, v in result.items() if k != "lines"}
|
||||
count = _save_group(session, run, key, result, count)
|
||||
run.snapshot_json, run.snapshot_hash = frozen, digest(frozen)
|
||||
run.summary_json = {
|
||||
"groups": summaries,
|
||||
"total_lines": count,
|
||||
"rule_version": RULE_VERSION,
|
||||
"month": frozen["month"],
|
||||
"formal_payroll_written": False,
|
||||
}
|
||||
run.status = (
|
||||
"PARTIAL"
|
||||
if any(v["status"] in ("PENDING", "PARTIAL") for v in summaries.values())
|
||||
else "SUCCEEDED"
|
||||
)
|
||||
@@ -0,0 +1,35 @@
|
||||
"""只按明确的源驱动错误分类,不把含凭据的驱动文本写入响应。"""
|
||||
|
||||
from sqlalchemy.exc import TimeoutError as PoolTimeoutError
|
||||
|
||||
TRANSIENT_CODES = frozenset({"SOURCE_CONNECTION_LOST", "SOURCE_TIMEOUT"})
|
||||
MESSAGES = {
|
||||
"SOURCE_CONNECTION_LOST": "源连接中断,请检查网络和只读连接",
|
||||
"SOURCE_TIMEOUT": "源连接或查询超时",
|
||||
"SOURCE_AUTH_ERROR": "源账号认证失败,请核对只读账号配置",
|
||||
"SOURCE_SCHEMA_ERROR": "源表或字段不匹配,请核对版本与映射",
|
||||
"SOURCE_CONFIG_ERROR": "源连接配置不完整或任务排队后发生变化",
|
||||
"SOURCE_DRIVER_ERROR": "源数据库驱动不可用,请检查已验证的ODBC驱动",
|
||||
"QUERY_FILE_MISSING": "缺少源查询文件,请核对部署包",
|
||||
}
|
||||
|
||||
|
||||
def classify_source_error(exc, fallback="READ_ERROR"):
|
||||
if isinstance(exc, (TimeoutError, PoolTimeoutError)):
|
||||
return "SOURCE_TIMEOUT"
|
||||
if isinstance(exc, ModuleNotFoundError):
|
||||
return "SOURCE_DRIVER_ERROR"
|
||||
if isinstance(exc, FileNotFoundError):
|
||||
return "QUERY_FILE_MISSING"
|
||||
original = getattr(exc, "orig", exc)
|
||||
args = getattr(original, "args", ())
|
||||
state = str(args[0]).upper() if args else ""
|
||||
if state in {"HYT00", "HYT01"}:
|
||||
return "SOURCE_TIMEOUT"
|
||||
if state in {"08S01", "08001", "08003", "08006"}:
|
||||
return "SOURCE_CONNECTION_LOST"
|
||||
if state == "28000":
|
||||
return "SOURCE_AUTH_ERROR"
|
||||
if state in {"42S02", "42S22", "07002"}:
|
||||
return "SOURCE_SCHEMA_ERROR"
|
||||
return fallback
|
||||
@@ -0,0 +1,355 @@
|
||||
"""核算预演纯函数:只分配已明确的平账组,不写正式台账。"""
|
||||
|
||||
from datetime import date
|
||||
from decimal import ROUND_HALF_UP, Decimal
|
||||
from fractions import Fraction
|
||||
|
||||
from app.services import formula
|
||||
|
||||
CENT = Decimal("0.01")
|
||||
ZERO = Decimal("0")
|
||||
START = date(2026, 3, 1)
|
||||
AUGUST = date(2026, 8, 1)
|
||||
|
||||
|
||||
def money(value) -> Decimal:
|
||||
"""拒绝非有限金额,不用浮点数参与计算。"""
|
||||
if isinstance(value, float):
|
||||
raise ValueError("金额必须为十进制字符串")
|
||||
result = Decimal(str(value))
|
||||
if not result.is_finite():
|
||||
raise ValueError("金额必须为有限数字")
|
||||
return result
|
||||
|
||||
|
||||
def amount_text(value: Decimal) -> str:
|
||||
"""在展示边界按分四舍五入。"""
|
||||
return str(value.quantize(CENT, rounding=ROUND_HALF_UP))
|
||||
|
||||
|
||||
def _day(value) -> date:
|
||||
return date.fromisoformat(str(value)[:10])
|
||||
|
||||
|
||||
def _key(row):
|
||||
return (_day(row["date"]), int(row["source_id"]), int(row.get("line_id", 0)))
|
||||
|
||||
|
||||
def _beneficiary(shipment, receipt, facts, as_of):
|
||||
owner = str(shipment.get("personnel_id") or "")
|
||||
applicable = sorted(
|
||||
(f for f in facts if f.get("business_key") == shipment.get("business_key")),
|
||||
key=lambda f: (_day(f["effective_date"]), f.get("version", 0)),
|
||||
)
|
||||
for fact in applicable:
|
||||
# 在职交接仍按每次发货的归属,不把整笔绩效按固定比例拆分。
|
||||
if (
|
||||
fact["mode"] == "RESIGNATION"
|
||||
and _day(fact["effective_date"]) <= _day(as_of)
|
||||
and owner == str(fact["from_personnel_id"])
|
||||
):
|
||||
owner = str(fact["to_personnel_id"])
|
||||
return owner
|
||||
|
||||
|
||||
def _calculate(shipment, receipt, matched, previously_matched, facts, params):
|
||||
reasons = list(shipment.get("pending", [])) + list(receipt.get("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"):
|
||||
reasons.append("PREPAYMENT_PARAMETERS_PENDING")
|
||||
if effective >= AUGUST and not params.get("august_confirmed", False):
|
||||
reasons.append("AUGUST_RULE_PENDING")
|
||||
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")))
|
||||
if not person:
|
||||
reasons.append("PERSONNEL_PENDING")
|
||||
elif person.get("status") == 0:
|
||||
known_later_departure = any(
|
||||
record.get("mode") == "RESIGNATION"
|
||||
and record.get("business_key") == shipment.get("business_key")
|
||||
and str(record.get("from_personnel_id")) == owner
|
||||
and _day(record["effective_date"]) > _day(facts["as_of"])
|
||||
for record in facts.get("handovers", [])
|
||||
)
|
||||
if not known_later_departure:
|
||||
reasons.append("HANDOVER_PENDING")
|
||||
cost = shipment.get("cost_total")
|
||||
if shipment.get("cost_month_basis") == "RECEIPT":
|
||||
cost = shipment.get("cost_by_month", {}).get(str(receipt["date"])[:7])
|
||||
if cost is None:
|
||||
reasons.append("GUIDANCE_PENDING")
|
||||
category = shipment.get("category")
|
||||
if category not in ("municipal_water", "rail_transit"):
|
||||
reasons.append("PRODUCT_CATEGORY_PENDING")
|
||||
customer_type = shipment.get("customer_type")
|
||||
if customer_type not in ("personal", "manager_personal", "enterprise"):
|
||||
reasons.append("CUSTOMER_CATEGORY_PENDING")
|
||||
parameter_date = (
|
||||
_day(receipt["date"])
|
||||
if is_prepayment and params.get("prepayment_parameter_basis") == "RECEIPT"
|
||||
else effective
|
||||
)
|
||||
rate_params = params.get(parameter_date.isoformat())
|
||||
if rate_params is None:
|
||||
reasons.append("PARAMETERS_PENDING")
|
||||
if reasons:
|
||||
return {"status": "PENDING", "reasons": sorted(set(reasons)), "performance": None}
|
||||
total = money(shipment["amount"])
|
||||
cost_total = money(cost)
|
||||
# 累计舍入差分使同一发货拆成多笔回款时,成本分摊合计仍等于总成本。
|
||||
before = (cost_total * previously_matched / total).quantize(CENT, rounding=ROUND_HALF_UP)
|
||||
after = (cost_total * (previously_matched + matched) / total).quantize(
|
||||
CENT, rounding=ROUND_HALF_UP
|
||||
)
|
||||
allocated_cost = after - before
|
||||
threshold = Fraction(str(rate_params["thresholds"][category]))
|
||||
converted = formula.converted_profit(
|
||||
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")
|
||||
if manager_own or person["role_code"] == "sales_manager" or customer_type == "manager_personal":
|
||||
rate = Fraction(0)
|
||||
elif _day(receipt["date"]) < _day(shipment["date"]):
|
||||
rate = Fraction(str(rate_params["start"])) / 1000
|
||||
elif customer_type == "enterprise":
|
||||
rate = Fraction(str(rate_params["enterprise"])) / 1000
|
||||
else:
|
||||
rate = formula.salesperson_rate(
|
||||
days,
|
||||
start=Fraction(str(rate_params["start"])),
|
||||
slope=Fraction(str(rate_params["slope"])),
|
||||
floor=Fraction(str(rate_params["floor"])),
|
||||
boundary_days=int(rate_params["boundary"]),
|
||||
)
|
||||
return {
|
||||
"status": "READY",
|
||||
"reasons": [],
|
||||
"personnel_id": owner,
|
||||
"manager_id": shipment.get("manager_id"),
|
||||
"manager_pending": not bool(shipment.get("manager_id")),
|
||||
"effective_date": effective.isoformat(),
|
||||
"days": days,
|
||||
"allocated_cost": amount_text(allocated_cost),
|
||||
"converted_profit": amount_text(converted),
|
||||
"profit_threshold": str(rate_params["thresholds"][category]),
|
||||
"rate": str(rate),
|
||||
"rate_percent": format(
|
||||
(Decimal(rate.numerator) * 100 / Decimal(rate.denominator)).quantize(Decimal("0.0001")),
|
||||
"f",
|
||||
)
|
||||
.rstrip("0")
|
||||
.rstrip(".")
|
||||
+ "%",
|
||||
"parameters": rate_params,
|
||||
"parameter_date": parameter_date.isoformat(),
|
||||
"performance": amount_text(formula.performance_amount(converted, rate)),
|
||||
"excluded_manager_own": manager_own or person["role_code"] == "sales_manager",
|
||||
}
|
||||
|
||||
|
||||
def preview_group(group, shipments, receipts, facts, params, until: date) -> dict:
|
||||
"""按明确分组分配资金,缺条件时保留金额和原因,不猜测绩效。"""
|
||||
if not group.get("scope_confirmed"):
|
||||
return {"status": "PENDING", "reasons": ["GROUP_SCOPE_PENDING"], "lines": []}
|
||||
if not group.get("opening_confirmed") or group.get("opening_balance") is None:
|
||||
return {"status": "PENDING", "reasons": ["OPENING_BALANCE_PENDING"], "lines": []}
|
||||
if not group.get("historical_settlement_confirmed"):
|
||||
return {"status": "PENDING", "reasons": ["HISTORICAL_SETTLEMENT_PENDING"], "lines": []}
|
||||
shipments = [row for row in shipments if START <= _day(row["date"]) <= until]
|
||||
receipts = [row for row in receipts if START <= _day(row["date"]) <= until]
|
||||
excluded_shipments = [row for row in shipments if row.get("performance_excluded")]
|
||||
shipments = [row for row in shipments if not row.get("performance_excluded")]
|
||||
excluded_receipts = sorted((row for row in receipts if row.get("exclusion")), key=_key)
|
||||
receipts = [row for row in receipts if not row.get("exclusion")]
|
||||
amount_pending = {
|
||||
reason
|
||||
for row in shipments
|
||||
for reason in row.get("pending", [])
|
||||
if reason in ("FREIGHT_PENDING", "TAX_AMOUNT_PENDING")
|
||||
}
|
||||
if amount_pending:
|
||||
# 净销售额未知会改变后续所有分配,不能先猜零运费再计算同组其他业务。
|
||||
return {"status": "PENDING", "reasons": sorted(amount_pending), "lines": []}
|
||||
opening = money(group["opening_balance"])
|
||||
if opening < 0:
|
||||
raise ValueError("期初应收不能为负,预收余额应单独登记")
|
||||
invalid = [r for r in (*shipments, *receipts) if money(r["amount"]) <= 0]
|
||||
if invalid:
|
||||
return {"status": "PENDING", "reasons": ["NEGATIVE_OR_ZERO_DOCUMENT"], "lines": []}
|
||||
if any("NEGATIVE_EVENT_REVIEW_REQUIRED" in r.get("pending", []) for r in shipments):
|
||||
return {"status": "PENDING", "reasons": ["NEGATIVE_EVENT_REVIEW_REQUIRED"], "lines": []}
|
||||
if any(
|
||||
money(r["amount"]) != money(r["amount"]).quantize(CENT) for r in (*shipments, *receipts)
|
||||
):
|
||||
return {"status": "PENDING", "reasons": ["AMOUNT_PRECISION_PENDING"], "lines": []}
|
||||
groups = {str(r.get("group_key", "")) for r in (*shipments, *receipts)}
|
||||
if groups - {str(group["key"])}:
|
||||
raise ValueError("禁止跨平账组分配")
|
||||
ship_rows = sorted(shipments, key=_key)
|
||||
cash_rows = sorted(receipts, key=_key)
|
||||
ship_keys = [(str(r["source_id"]), str(r.get("line_id", 0))) for r in ship_rows]
|
||||
receipt_keys = [str(r["source_id"]) for r in cash_rows]
|
||||
if len(set(ship_keys)) != len(ship_keys) or len(set(receipt_keys)) != len(receipt_keys):
|
||||
raise ValueError("预演输入包含重复单据或明细")
|
||||
# 凭证状态未证实的收款不能消耗后续发货,否则会改变其他款的 FIFO 顺序。
|
||||
if any(not r.get("voucher_confirmed") for r in cash_rows):
|
||||
reasons = {"VOUCHER_STATE_PENDING"}
|
||||
for receipt in cash_rows:
|
||||
reasons.update(receipt.get("pending", []))
|
||||
return {"status": "PENDING", "reasons": sorted(reasons), "lines": []}
|
||||
consumed = [ZERO for _ in ship_rows]
|
||||
lines = [
|
||||
{
|
||||
"kind": "EXCLUDED",
|
||||
"shipment_id": str(row["source_id"]),
|
||||
"shipment_no": row.get("source_no"),
|
||||
"shipment_date": row["date"],
|
||||
"line_id": str(row.get("line_id", 0)),
|
||||
"amount": amount_text(money(row["amount"])),
|
||||
"status": "EXCLUDED",
|
||||
"performance": None,
|
||||
"reasons": [row.get("exclusion_reason") or "CORRECTION_EXCLUDED"],
|
||||
"correction_pair_key": row.get("correction_pair_key"),
|
||||
}
|
||||
for row in excluded_shipments
|
||||
] + [
|
||||
{
|
||||
"kind": "EXCLUDED",
|
||||
"receipt_id": str(row["source_id"]),
|
||||
"receipt_no": row.get("source_no"),
|
||||
"receipt_date": row["date"],
|
||||
"amount": amount_text(money(row["amount"])),
|
||||
"status": "EXCLUDED",
|
||||
"performance": None,
|
||||
"reasons": [row["exclusion"]],
|
||||
"voucher_evidence": row.get("voucher_evidence"),
|
||||
"accounting_voucher_code": row.get("accounting_voucher_code"),
|
||||
}
|
||||
for row in excluded_receipts
|
||||
]
|
||||
remaining_opening = opening
|
||||
cash_total = ZERO
|
||||
for receipt in cash_rows:
|
||||
available = money(receipt["amount"])
|
||||
cash_total += available
|
||||
if remaining_opening:
|
||||
matched = min(available, remaining_opening)
|
||||
if matched:
|
||||
lines.append(
|
||||
{
|
||||
"kind": "OPENING",
|
||||
"receipt_id": str(receipt["source_id"]),
|
||||
"receipt_no": receipt.get("source_no"),
|
||||
"receipt_date": receipt["date"],
|
||||
"voucher_evidence": receipt.get("voucher_evidence"),
|
||||
"accounting_voucher_code": receipt.get("accounting_voucher_code"),
|
||||
"amount": amount_text(matched),
|
||||
"status": "LEGACY",
|
||||
"performance": None,
|
||||
"reasons": ["LEGACY_SYSTEM_CALCULATES"],
|
||||
}
|
||||
)
|
||||
remaining_opening -= matched
|
||||
available -= matched
|
||||
for i, shipment in enumerate(ship_rows):
|
||||
if available == 0:
|
||||
break
|
||||
remaining = money(shipment["amount"]) - consumed[i]
|
||||
matched = min(available, remaining)
|
||||
if matched <= 0:
|
||||
continue
|
||||
result = _calculate(
|
||||
shipment,
|
||||
receipt,
|
||||
matched,
|
||||
consumed[i],
|
||||
{**facts, "as_of": until.isoformat()},
|
||||
params,
|
||||
)
|
||||
lines.append(
|
||||
{
|
||||
"kind": "SHIPMENT",
|
||||
"receipt_id": str(receipt["source_id"]),
|
||||
"receipt_no": receipt.get("source_no"),
|
||||
"receipt_date": receipt["date"],
|
||||
"voucher_evidence": receipt.get("voucher_evidence"),
|
||||
"accounting_voucher_code": receipt.get("accounting_voucher_code"),
|
||||
"shipment_id": str(shipment["source_id"]),
|
||||
"shipment_no": shipment.get("source_no"),
|
||||
"shipment_date": shipment["date"],
|
||||
"line_id": str(shipment.get("line_id", 0)),
|
||||
"product_id": shipment.get("product_id"),
|
||||
"quantity": shipment.get("quantity"),
|
||||
"cost_total": shipment.get("cost_total"),
|
||||
"gross_tax_amount": shipment.get("gross_tax_amount"),
|
||||
"freight_amount": shipment.get("freight_amount"),
|
||||
"cost_month_basis": shipment.get("cost_month_basis"),
|
||||
"cost_by_month": shipment.get("cost_by_month"),
|
||||
"shipment_remaining_after": amount_text(remaining - matched),
|
||||
"amount": amount_text(matched),
|
||||
**result,
|
||||
}
|
||||
)
|
||||
consumed[i] += matched
|
||||
available -= matched
|
||||
if available:
|
||||
lines.append(
|
||||
{
|
||||
"kind": "PREPAYMENT",
|
||||
"receipt_id": str(receipt["source_id"]),
|
||||
"receipt_no": receipt.get("source_no"),
|
||||
"receipt_date": receipt["date"],
|
||||
"voucher_evidence": receipt.get("voucher_evidence"),
|
||||
"accounting_voucher_code": receipt.get("accounting_voucher_code"),
|
||||
"amount": amount_text(available),
|
||||
"status": "WAITING_SHIPMENT",
|
||||
"performance": None,
|
||||
"reasons": ["NO_ACTUAL_SHIPMENT"],
|
||||
}
|
||||
)
|
||||
allocated = sum((money(r["amount"]) for r in lines if r["kind"] != "EXCLUDED"), ZERO)
|
||||
if allocated != cash_total:
|
||||
raise ValueError("回款分配金额不守恒")
|
||||
return {
|
||||
"status": "PARTIAL" if any(r["status"] == "PENDING" for r in lines) else "SUCCEEDED",
|
||||
"reasons": [],
|
||||
"lines": lines,
|
||||
"receipt_total": amount_text(cash_total),
|
||||
"excluded_receipt_total": amount_text(
|
||||
sum((money(r["amount"]) for r in excluded_receipts), ZERO)
|
||||
),
|
||||
"excluded_shipment_total": amount_text(
|
||||
sum((money(r["amount"]) for r in excluded_shipments), ZERO)
|
||||
),
|
||||
"allocated_total": amount_text(allocated),
|
||||
"opening_remaining": amount_text(remaining_opening),
|
||||
"shipment_remaining": amount_text(
|
||||
sum(
|
||||
(
|
||||
money(row["amount"]) - used
|
||||
for row, used in zip(ship_rows, consumed, strict=True)
|
||||
),
|
||||
ZERO,
|
||||
)
|
||||
),
|
||||
"prepayment_remaining": amount_text(
|
||||
sum((money(r["amount"]) for r in lines if r["kind"] == "PREPAYMENT"), ZERO)
|
||||
),
|
||||
"performance_total": amount_text(
|
||||
sum((money(r["performance"]) for r in lines if r.get("performance") is not None), ZERO)
|
||||
),
|
||||
"month_performance_total": amount_text(
|
||||
sum(
|
||||
(
|
||||
money(r["performance"])
|
||||
for r in lines
|
||||
if r.get("performance") is not None
|
||||
and r.get("effective_date", "")[:7] == until.isoformat()[:7]
|
||||
),
|
||||
ZERO,
|
||||
)
|
||||
),
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
"""T+ 字段映射元数据。
|
||||
|
||||
这里记录候选字段和确认状态,不把字典中的候选直接当成业务契约。
|
||||
高风险口径在客户确认前保持 ``PENDING``,同步器只允许写入原始 staging。
|
||||
高风险口径在客户确认前保持 ``PENDING``;源读取只写 staging,基础导入另经事实核对和预览。
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
@@ -27,7 +27,7 @@ class TPlusMapping:
|
||||
target_table: str
|
||||
status: MappingStatus
|
||||
unresolved: tuple[str, ...] = ()
|
||||
version: str = "0.2"
|
||||
version: str = "0.5"
|
||||
schema_evidence: str = "ceshi20261026.bak,2026-10-27 恢复副本结构核验"
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ TPLUS_MAPPINGS: tuple[TPlusMapping, ...] = (
|
||||
("id", "code", "name", "disabled", "mobilePhoneNo", "officePhoneNo", "iddepartment"),
|
||||
"personnel",
|
||||
MappingStatus.CANDIDATE,
|
||||
("工号是否等于 code", "经理隶属关系", "入职日期", "试用期"),
|
||||
("名单缺失编号", "经理分管业务", "入职日期", "试用期"),
|
||||
),
|
||||
TPlusMapping(
|
||||
"products",
|
||||
@@ -46,7 +46,7 @@ TPLUS_MAPPINGS: tuple[TPlusMapping, ...] = (
|
||||
("id", "code", "name", "specification", "idunit", "idinventoryclass"),
|
||||
"products",
|
||||
MappingStatus.CANDIDATE,
|
||||
("库存数量语义", "参考价来源", "系统产品分类"),
|
||||
("人工核算分类", "指导价取值时点"),
|
||||
),
|
||||
TPlusMapping(
|
||||
"customers",
|
||||
@@ -54,7 +54,7 @@ TPLUS_MAPPINGS: tuple[TPlusMapping, ...] = (
|
||||
("id", "code", "name", "Contact", "representative", "disabled", "updated"),
|
||||
"customers",
|
||||
MappingStatus.CANDIDATE,
|
||||
("客户唯一键", "改名/合并规则", "系统客户分类"),
|
||||
("源客户属性为空", "客户负责人资料"),
|
||||
),
|
||||
TPlusMapping(
|
||||
"businesses",
|
||||
@@ -62,7 +62,7 @@ TPLUS_MAPPINGS: tuple[TPlusMapping, ...] = (
|
||||
("id", "code", "voucherdate", "idcustomer", "idclerk", "idbusinesstype", "updated"),
|
||||
"businesses",
|
||||
MappingStatus.PENDING,
|
||||
("成本口径", "业务负责人覆盖规则", "含税口径"),
|
||||
("指导价取值时点", "业务交接依据", "平账范围"),
|
||||
),
|
||||
TPlusMapping(
|
||||
"shipments",
|
||||
@@ -79,7 +79,7 @@ TPLUS_MAPPINGS: tuple[TPlusMapping, ...] = (
|
||||
),
|
||||
"shipments",
|
||||
MappingStatus.PENDING,
|
||||
("发货日期", "订单关联路径", "退货/红冲规则", "运费来源"),
|
||||
("退货/红冲规则", "运费实际金额", "发货归属"),
|
||||
),
|
||||
TPlusMapping(
|
||||
"receipts",
|
||||
@@ -96,7 +96,7 @@ TPLUS_MAPPINGS: tuple[TPlusMapping, ...] = (
|
||||
),
|
||||
"receipts",
|
||||
MappingStatus.PENDING,
|
||||
("到账日期", "收款方式", "核销状态", "回款与发货关联"),
|
||||
("凭证异常例单复核", "特殊款项识别", "平账范围"),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -107,7 +107,7 @@ DETAIL_MAPPINGS: tuple[TPlusMapping, ...] = (
|
||||
("id", "idSaleOrderDTO", "LatestCost"),
|
||||
"business_items",
|
||||
MappingStatus.PENDING,
|
||||
("成本口径", "含税口径"),
|
||||
("不导入T+成本,仅保留追溯快照",),
|
||||
),
|
||||
TPlusMapping(
|
||||
"shipment_items",
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
"""主数据先预览再执行,按源ID关联,只更新允许同步的字段。"""
|
||||
|
||||
import json
|
||||
from collections import Counter
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from app.models import BusinessRole, Customer, Personnel, Product, TPlusSourceLink, TPlusWorkLine
|
||||
from app.services.tplus.snapshots import source_records
|
||||
from app.services.tplus.workbench import RULE_VERSION, WorkbenchError, digest, get_run, latest_facts
|
||||
|
||||
MODELS = {"personnel": Personnel, "products": Product, "customers": Customer}
|
||||
ORDER = {"personnel": 0, "products": 1, "customers": 2}
|
||||
# 恢复库 eap_EnumItem:211=客户,228=客户/供应商,226=供应商。
|
||||
CUSTOMER_ONLY = 211
|
||||
|
||||
|
||||
def _identity(row):
|
||||
keys = {
|
||||
"personnel": ("personnel_name",),
|
||||
"products": ("product_name", "spec_model"),
|
||||
"customers": ("company_name", "owner_name"),
|
||||
}[row["entity_type"]]
|
||||
return row["entity_type"], tuple(str(row["values"][key]).rstrip().casefold() for key in keys)
|
||||
|
||||
|
||||
def _target_snapshot(target):
|
||||
if target is None:
|
||||
return None
|
||||
return {c.name: getattr(target, c.name) for c in target.__table__.columns}
|
||||
|
||||
|
||||
def _master_values(entity, record, facts, session):
|
||||
row = record.payload_json
|
||||
if entity == "personnel":
|
||||
code = str(row.get("code") or "").strip()
|
||||
fact = facts.get(("PERSONNEL", code))
|
||||
if fact is None:
|
||||
return None, "NOT_IN_PERSONNEL_ROSTER"
|
||||
person = fact.data_json
|
||||
if person["code"] != code or person["name"] != str(row.get("name") or "").strip():
|
||||
return None, "PERSONNEL_IDENTITY_CONFLICT"
|
||||
role = session.scalar(
|
||||
select(BusinessRole).where(BusinessRole.role_code == person["role_code"])
|
||||
)
|
||||
if role is None:
|
||||
return None, "BUSINESS_ROLE_MISSING"
|
||||
return {
|
||||
"personnel_name": person["name"],
|
||||
"business_role_id": role.id,
|
||||
"status": person["status"],
|
||||
}, None
|
||||
if entity == "products":
|
||||
fact = facts.get(("PRODUCT", record.source_id))
|
||||
if fact is None:
|
||||
return None, "PRODUCT_CATEGORY_PENDING"
|
||||
unit = row.get("unit_name")
|
||||
if not unit or unit != fact.data_json["unit"]:
|
||||
return None, "PRODUCT_UNIT_PENDING"
|
||||
return {
|
||||
"product_name": row["name"],
|
||||
"spec_model": row.get("specification") or "",
|
||||
"unit": unit,
|
||||
"product_category": fact.data_json["category"],
|
||||
}, None
|
||||
if row.get("partnerType") != CUSTOMER_ONLY:
|
||||
return None, "NOT_CUSTOMER_ONLY"
|
||||
fact = facts.get(("CUSTOMER", record.source_id))
|
||||
if fact is None:
|
||||
return None, "CUSTOMER_ATTRIBUTE_PENDING"
|
||||
return {
|
||||
"company_name": row["name"],
|
||||
"owner_name": fact.data_json["owner_name"],
|
||||
"customer_type": fact.data_json["customer_type"],
|
||||
}, None
|
||||
|
||||
|
||||
def _protected_update(entity, values):
|
||||
allowed = {
|
||||
"personnel": {"personnel_name"},
|
||||
"products": {"product_name", "spec_model", "unit"},
|
||||
"customers": {"company_name"},
|
||||
}[entity]
|
||||
return {k: v for k, v in values.items() if k in allowed}
|
||||
|
||||
|
||||
def _conflict(session, entity, values, target_id):
|
||||
model = MODELS[entity]
|
||||
if entity == "personnel":
|
||||
conditions = [model.personnel_name == values["personnel_name"]]
|
||||
elif entity == "products":
|
||||
conditions = [
|
||||
model.product_name == values["product_name"],
|
||||
model.spec_model == values["spec_model"],
|
||||
]
|
||||
else:
|
||||
conditions = [
|
||||
model.company_name == values["company_name"],
|
||||
model.owner_name == values["owner_name"],
|
||||
]
|
||||
if target_id:
|
||||
conditions.append(model.id != target_id)
|
||||
return session.scalar(select(model.id).where(*conditions).limit(1)) is not None
|
||||
|
||||
|
||||
def build_master_plan(session, run, *, lock_targets=False):
|
||||
"""重新计算源、事实、目标指纹;执行前复核所有输入。"""
|
||||
facts = latest_facts(session, run.source_database)
|
||||
links = {
|
||||
(v.entity_type, v.source_id): v
|
||||
for v in session.scalars(
|
||||
select(TPlusSourceLink).where(TPlusSourceLink.source_database == run.source_database)
|
||||
)
|
||||
}
|
||||
rows = []
|
||||
snapshots = []
|
||||
target_ids = []
|
||||
for entity in sorted(run.input_json["entity_types"], key=ORDER.__getitem__):
|
||||
records = source_records(
|
||||
session, run.source_database, entity, run.input_json.get("source_ids") or None
|
||||
)
|
||||
for record in records:
|
||||
link = links.get((entity, record.source_id))
|
||||
target = (
|
||||
session.get(
|
||||
MODELS[entity],
|
||||
link.target_id,
|
||||
populate_existing=True,
|
||||
with_for_update=lock_targets,
|
||||
)
|
||||
if link
|
||||
else None
|
||||
)
|
||||
values, reason = _master_values(entity, record, facts, session)
|
||||
action = "PENDING" if reason else "CREATE"
|
||||
if reason in ("NOT_IN_PERSONNEL_ROSTER", "NOT_CUSTOMER_ONLY"):
|
||||
action = "SKIP"
|
||||
if link and target is None:
|
||||
action, reason = "CONFLICT", "TARGET_MISSING"
|
||||
elif values:
|
||||
if target:
|
||||
values = _protected_update(entity, values)
|
||||
action = (
|
||||
"UPDATE"
|
||||
if any(getattr(target, k) != v for k, v in values.items())
|
||||
else "SKIP"
|
||||
)
|
||||
if _conflict(
|
||||
session,
|
||||
entity,
|
||||
{**(_target_snapshot(target) or {}), **values},
|
||||
target.id if target else None,
|
||||
):
|
||||
action, reason = "CONFLICT", "TARGET_UNIQUE_CONFLICT"
|
||||
snapshots.append((entity, record.source_id, record.row_hash, record.id))
|
||||
target_ids.append((entity, record.source_id, _target_snapshot(target)))
|
||||
rows.append(
|
||||
{
|
||||
"entity_type": entity,
|
||||
"source_id": record.source_id,
|
||||
"action": action,
|
||||
"reason": reason,
|
||||
"values": values or {},
|
||||
"record_id": record.id,
|
||||
"row_hash": record.row_hash,
|
||||
"target_id": target.id if target else None,
|
||||
"source_no": record.source_no,
|
||||
"source_name": record.payload_json.get("name"),
|
||||
}
|
||||
)
|
||||
identities = Counter(_identity(r) for r in rows if r["action"] == "CREATE")
|
||||
for row in rows:
|
||||
if row["action"] == "CREATE" and identities[_identity(row)] > 1:
|
||||
row["action"], row["reason"] = "CONFLICT", "DUPLICATE_SOURCE_IDENTITY"
|
||||
# 以代码名单检查漏读人员,不能让缺失人员静默成为成功。
|
||||
if "personnel" in run.input_json["entity_types"]:
|
||||
seen = {r["source_no"] for r in rows if r["entity_type"] == "personnel"}
|
||||
for kind, key in facts:
|
||||
if kind == "PERSONNEL" and key not in seen and not run.input_json.get("source_ids"):
|
||||
rows.append(
|
||||
{
|
||||
"entity_type": "personnel",
|
||||
"source_id": key,
|
||||
"action": "PENDING",
|
||||
"reason": "ROSTER_SOURCE_MISSING",
|
||||
"values": {},
|
||||
}
|
||||
)
|
||||
frozen = {
|
||||
"rule_version": RULE_VERSION,
|
||||
"source": snapshots,
|
||||
"targets": target_ids,
|
||||
"plan": rows,
|
||||
"facts": [
|
||||
{
|
||||
"id": f.id,
|
||||
"kind": f.kind,
|
||||
"key": f.fact_key,
|
||||
"version": f.version,
|
||||
"data": f.data_json,
|
||||
"evidence": f.evidence,
|
||||
}
|
||||
for f in facts.values()
|
||||
],
|
||||
}
|
||||
frozen = json.loads(json.dumps(frozen, ensure_ascii=False, default=str))
|
||||
return rows, frozen, digest(frozen)
|
||||
|
||||
|
||||
def _line(session, run, i, row, action=None, reason=None):
|
||||
session.add(
|
||||
TPlusWorkLine(
|
||||
run_no=run.run_no,
|
||||
line_no=i,
|
||||
entity_type=row["entity_type"],
|
||||
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")
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def execute_master(session, run):
|
||||
"""执行预览或原子导入;正式核算表永远不参与此服务。"""
|
||||
if run.kind == "MASTER_PREVIEW":
|
||||
rows, snapshot, fingerprint = build_master_plan(session, run)
|
||||
run.snapshot_json, run.snapshot_hash = snapshot, fingerprint
|
||||
for i, row in enumerate(rows, 1):
|
||||
_line(session, run, i, row)
|
||||
counts = Counter(r["action"] for r in rows)
|
||||
run.summary_json = {
|
||||
"counts": dict(counts),
|
||||
"rule_version": RULE_VERSION,
|
||||
"total": len(rows),
|
||||
"formal_payroll_written": False,
|
||||
}
|
||||
run.status = "PARTIAL" if counts["PENDING"] or counts["CONFLICT"] else "SUCCEEDED"
|
||||
return
|
||||
preview = get_run(session, run.input_json["preview_no"], run.source_database)
|
||||
if preview.kind != "MASTER_PREVIEW" or preview.status not in ("SUCCEEDED", "PARTIAL"):
|
||||
raise WorkbenchError("PREVIEW_REQUIRED")
|
||||
rows, frozen, fingerprint = build_master_plan(session, preview, lock_targets=True)
|
||||
if preview.snapshot_hash != fingerprint:
|
||||
raise WorkbenchError("PREVIEW_STALE")
|
||||
run.snapshot_hash, run.snapshot_json = fingerprint, frozen
|
||||
counts = Counter()
|
||||
for i, row in enumerate(rows, 1):
|
||||
action, reason = row["action"], row.get("reason")
|
||||
if action in ("CREATE", "UPDATE"):
|
||||
try:
|
||||
with session.begin_nested():
|
||||
model = MODELS[row["entity_type"]]
|
||||
if action == "CREATE":
|
||||
target = model(
|
||||
**row["values"],
|
||||
t_plus_id=row["source_id"],
|
||||
source_no=row.get("source_no"),
|
||||
sync_batch_no=run.run_no,
|
||||
synced_at=datetime.now(),
|
||||
)
|
||||
session.add(target)
|
||||
session.flush()
|
||||
session.add(
|
||||
TPlusSourceLink(
|
||||
source_database=run.source_database,
|
||||
entity_type=row["entity_type"],
|
||||
source_id=row["source_id"],
|
||||
target_id=target.id,
|
||||
)
|
||||
)
|
||||
else:
|
||||
target = session.get(model, row["target_id"], with_for_update=True)
|
||||
for field, value in row["values"].items():
|
||||
setattr(target, field, value)
|
||||
target.sync_batch_no, target.synced_at = run.run_no, datetime.now()
|
||||
session.flush()
|
||||
row["target_id"] = target.id
|
||||
except IntegrityError:
|
||||
action, reason = "CONFLICT", "TARGET_UNIQUE_CONFLICT"
|
||||
counts[action] += 1
|
||||
_line(session, run, i, row, action, reason)
|
||||
run.summary_json = {"counts": dict(counts), "total": len(rows), "formal_payroll_written": False}
|
||||
run.status = "PARTIAL" if counts["PENDING"] or counts["CONFLICT"] else "SUCCEEDED"
|
||||
@@ -0,0 +1,84 @@
|
||||
"""从源库只读整理核对资料,不把费用空值或旧初始化余额当作已确认事实。"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.core.exceptions import BadRequestError
|
||||
from app.core.month import validate_month
|
||||
|
||||
QUERIES = ("customer", "shipments", "items", "receipts", "opening", "history", "expenses")
|
||||
ROOT = Path(__file__).resolve().parents[4] / "db/queries/tplus/materials"
|
||||
|
||||
|
||||
def summarize(records, customer_id, month):
|
||||
"""空记录只构成候选余额依据,不自动替代业务确认。"""
|
||||
if len(records["customer"]) != 1:
|
||||
raise BadRequestError("源客户不存在或身份不唯一")
|
||||
truncated = [name for name, rows in records.items() if len(rows) > 200]
|
||||
missing = [
|
||||
{"key": "GUIDANCE", "title": "发货月份人工指导成本", "reason": "T+成本不用于绩效,需填写并封存人工指导成本"},
|
||||
{"key": "PRODUCT_CATEGORY", "title": "产品绩效核算分类", "reason": "源产品分类不等于市政水利/轨道交通核算分类"},
|
||||
{"key": "HISTORICAL_PAYROLL", "title": "样本是否已在旧软件核算或发放", "reason": "源应收及收款记录不能证明绩效已发放"},
|
||||
{"key": "MANAGER_HANDOVER", "title": "样本当时的分管及交接依据", "reason": "保留源业务员工号,不按当前部门推断历史经理或接手人"},
|
||||
]
|
||||
if not records["customer"][0].get("customerType"):
|
||||
missing.append({"key": "CUSTOMER_CATEGORY", "title": "客户绩效分类", "reason": "源客户绩效属性为空"})
|
||||
missing.append({"key": "FREIGHT", "title": "该批发货运费是否已剔除", "reason": "费用表和金额仅作证据,缺少关联或空值不自动等于零运费"})
|
||||
no_old_rows = not records["opening"] and not records["history"]
|
||||
return {
|
||||
"customer_id": customer_id, "month": month, "captured_at": datetime.now().isoformat(),
|
||||
"source_read_only": True, "formal_payroll_written": False,
|
||||
"found": {name: len(rows) for name, rows in records.items()},
|
||||
"opening_candidate": "0.00" if no_old_rows else None,
|
||||
"opening_confirmed": False,
|
||||
"opening_explanation": "未查到初始化及切换日前应收记录,仅作为候选零余额依据" if no_old_rows else "需将初始化记录与截至2026-02-28的应收变动核对,不直接使用2017年期初",
|
||||
"truncated": truncated, "ready_for_amount": False, "missing": missing,
|
||||
"evidence": {name: rows[:200] for name, rows in records.items()},
|
||||
}
|
||||
|
||||
|
||||
def collect_materials(source, customer_id, month):
|
||||
validate_month(month)
|
||||
if customer_id <= 0:
|
||||
raise BadRequestError("客户ID必须为正整数")
|
||||
year, mon = map(int, month.split("-"))
|
||||
until = date(year + (mon == 12), 1 if mon == 12 else mon + 1, 1)
|
||||
args = {"customer_id": customer_id, "until": until, "cutoff": date(2026, 3, 1)}
|
||||
records = {name: [dict(r) for r in source.execute(text((ROOT / f"{name}.sql").read_text(encoding="utf-8")), args).mappings()] for name in QUERIES}
|
||||
result = summarize(records, customer_id, month)
|
||||
normalized = json.loads(json.dumps(result, ensure_ascii=False, default=str))
|
||||
normalized["evidence_hash"] = hashlib.sha256(json.dumps(normalized["evidence"], sort_keys=True, ensure_ascii=False).encode()).hexdigest()
|
||||
return normalized
|
||||
|
||||
|
||||
def render_markdown(report):
|
||||
def money(value):
|
||||
return "待核对" if value is None else f"{Decimal(str(value)):,.2f}"
|
||||
|
||||
customer = report["evidence"]["customer"][0]
|
||||
lines = [f"# 样本资料核对:{customer['name']}({report['month']})", "",
|
||||
f"核对时间:{report['captured_at']}。源库只读;未写入正式工资。", "",
|
||||
"## 已从恢复库整理", "", "| 类别 | 数量 |", "|---|---:|"]
|
||||
labels = {"customer": "客户", "shipments": "发货单", "items": "发货明细", "receipts": "回款凭证关联行", "opening": "初始化应收", "history": "切换日前应收分组", "expenses": "销售费用分摊"}
|
||||
lines += [f"| {labels[name]} | {count} |" for name, count in report["found"].items()]
|
||||
lines += ["", "## 单据与归属", "", "| 发货单 | 日期 | 含税金额 | 业务员工号 |", "|---|---|---:|---|"]
|
||||
lines += [f"| {r['code']} | {str(r['voucherdate'])[:10]} | {money(r['taxAmount'])} | {r.get('personnel_code') or '待核对'} |" for r in report["evidence"]["shipments"]]
|
||||
lines += ["", "| 产品代码 | 品名/规格 | 数量 | 含税单价 | 含税金额 | 运费 |", "|---|---|---:|---:|---:|---:|"]
|
||||
lines += [f"| {r.get('product_code', '待核对')} | {r.get('product_name', '')} {r.get('specification', '')} | {r.get('quantity', '待核对')} | {money(r.get('taxPrice'))} | {money(r.get('taxAmount'))} | {money(r.get('expenseAmount'))} |" for r in report["evidence"]["items"]]
|
||||
lines += ["", "| 回款单 | 日期 | 金额 | 总账凭证 |", "|---|---|---:|---|"]
|
||||
lines += [f"| {r['code']} | {str(r['voucherDate'])[:10]} | {money(r['amount'])} | {r.get('accounting_voucher_code') or '待核对'} |" for r in report["evidence"]["receipts"]]
|
||||
lines += ["", "## 可推导但尚未确认", "", report["opening_explanation"],
|
||||
f"候选期初余额:{report['opening_candidate'] or '待核对'}。本报告不自动确认期初余额。", "",
|
||||
"## 仅需补充的资料", ""]
|
||||
lines += [f"- {item['title']}:{item['reason']}。" for item in report["missing"]]
|
||||
if report.get("customer_reply"):
|
||||
lines += ["", "## 本轮补充回复", "", report["customer_reply"]]
|
||||
if report["truncated"]:
|
||||
lines += ["", "存在超出200行的资料,当前核对包不完整,不能作为完整金额验收依据。"]
|
||||
lines += ["", "指导成本、分类或关键依据缺失,实际工资金额尚未验收,不填默认值。", "", f"证据指纹:`{report['evidence_hash']}`", ""]
|
||||
return "\n".join(lines)
|
||||
@@ -11,6 +11,8 @@ from app.schemas.tplus import (
|
||||
TPlusOverviewOut,
|
||||
)
|
||||
from app.services.tplus import task_service
|
||||
from app.services.tplus.automation import overview as automation_overview
|
||||
from app.services.tplus.errors import MESSAGES
|
||||
from app.services.tplus.mapping import DETAIL_MAPPINGS, TPLUS_MAPPINGS
|
||||
from app.services.tplus.relationship_audit import public_report
|
||||
|
||||
@@ -22,6 +24,7 @@ ERROR_MESSAGES = {
|
||||
"TYPE_UNRESOLVED": "关联核验所需的单据类型缺失或不唯一",
|
||||
}
|
||||
_ENTITY_TYPES = {item.entity_type for item in (*TPLUS_MAPPINGS, *DETAIL_MAPPINGS)}
|
||||
ERROR_MESSAGES.update(MESSAGES)
|
||||
|
||||
|
||||
def batch_out(batch, active_parents: set[str]) -> TPlusBatchOut:
|
||||
@@ -51,6 +54,8 @@ def batch_out(batch, active_parents: set[str]) -> TPlusBatchOut:
|
||||
error_summary="任务有异常,请查看异常明细"
|
||||
if batch.status in ("FAILED", "PARTIAL")
|
||||
else None,
|
||||
trigger_type=getattr(batch, "trigger_type", "MANUAL") or "MANUAL",
|
||||
automatic_attempt=getattr(batch, "automatic_attempt", 0) or 0,
|
||||
)
|
||||
|
||||
|
||||
@@ -119,4 +124,5 @@ def overview(session) -> TPlusOverviewOut:
|
||||
status_counts=repository.status_counts(session),
|
||||
total_staged_records=repository.total_staged_records(session),
|
||||
latest_audit=public_report(latest.audit_result_json) if latest else None,
|
||||
automation=automation_overview(session, settings=task_service.get_settings()),
|
||||
)
|
||||
@@ -29,10 +29,22 @@ _COLUMNS = {
|
||||
"order_coverage": ("row_count", "without_order", "single_order", "multiple_orders"),
|
||||
}
|
||||
PENDING_CONFIRMATIONS = (
|
||||
("cost_basis", "绩效成本取值", "订单最新成本或出库成本的类型、时点和含税口径需业务确认"),
|
||||
("receipt_date", "到账日", "单据日、审核日和明细 arrivalDate 的业务含义需确认"),
|
||||
("ship_date", "发货日", "回款天数应使用哪个发货日期需确认"),
|
||||
("allocation", "多单分摊", "一笔收款涉及多张销货单或订单时,不能自动把整笔金额记给一单"),
|
||||
(
|
||||
"cost_basis",
|
||||
"月度指导成本",
|
||||
"采用人工含税指导成本;单位成本还是明细总成本、取哪个月份仍待明确",
|
||||
),
|
||||
(
|
||||
"voucher_state",
|
||||
"凭证例外核对",
|
||||
"已按收款单与总账凭证的实际关联识别生成资格;无关联、作废或制单异常的记录保留待核对,不以已审核代替",
|
||||
),
|
||||
(
|
||||
"opening_balance",
|
||||
"历史承接",
|
||||
"发货日期已明确取销货单日期;截至2026年2月28日的应收和已核算款待提供",
|
||||
),
|
||||
("allocation", "FIFO平账范围", "先发货先平账已明确;同客户全部业务还是分项目平账仍待确认"),
|
||||
("negative_events", "退货与红冲", "负数、冲销及已结算业务的调整方式需确认"),
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
"""核对报告中的固定业务说明,未知编码保留以便排查。"""
|
||||
|
||||
ACTION_LABELS = {
|
||||
"CREATE": "新增",
|
||||
"UPDATE": "更新",
|
||||
"SKIP": "跳过",
|
||||
"PENDING": "待补充",
|
||||
"CONFLICT": "冲突",
|
||||
"READY": "可核对",
|
||||
"LEGACY": "旧应收冲平",
|
||||
"WAITING_SHIPMENT": "预收待发货",
|
||||
"EXCLUDED": "不纳入本次预演",
|
||||
}
|
||||
REASON_LABELS = {
|
||||
"CUSTOMER_SCOPE_CONFLICT": "同一客户存在不一致的期初或历史范围,请先合并为客户级核对资料",
|
||||
"GUIDANCE_FINALIZATION_REQUIRED": "发货月份指导成本尚未录入完毕并封存",
|
||||
"CUSTOMER_SCOPE_ALREADY_SELECTED": "该客户已纳入本次平账,重复选择已跳过",
|
||||
"FORMAL_TARGET_CHANGED": "正式单据与已导入资料不一致,请先核对差异",
|
||||
"INDEPENDENT_BUSINESS_REQUIRED": "请先按独立业务核实归属,跨业务冲款边界尚待答复",
|
||||
"BUSINESS_DATA_PENDING": "该业务仍有资料待补,请查看对应核算预演",
|
||||
"FORMAL_SOURCE_CHANGED": "源单据或人工依据与正式导入版本不同,请先核对差异",
|
||||
"SOURCE_ALREADY_IMPORTED": "该源单据已经导入其他业务,不能重复导入",
|
||||
"CUSTOMER_IMPORT_REQUIRED": "请先完成该客户的基础导入",
|
||||
"PRODUCT_IMPORT_REQUIRED": "请先完成相关产品的基础导入",
|
||||
"PERSONNEL_IMPORT_REQUIRED": "请先完成相关业务员及经理的基础导入",
|
||||
"NOT_IN_PERSONNEL_ROSTER": "不在已确认人员名单中",
|
||||
"PERSONNEL_IDENTITY_CONFLICT": "工号与名单姓名不一致",
|
||||
"ROSTER_SOURCE_MISSING": "名单工号未在T+找到",
|
||||
"BUSINESS_ROLE_MISSING": "系统岗位不存在",
|
||||
"PRODUCT_CATEGORY_PENDING": "产品核算分类待补",
|
||||
"PRODUCT_UNIT_PENDING": "产品单位待核对",
|
||||
"NOT_CUSTOMER_ONLY": "往来单位不是仅客户性质",
|
||||
"CUSTOMER_ATTRIBUTE_PENDING": "客户属性或负责人待补",
|
||||
"TARGET_MISSING": "原导入目标已不存在",
|
||||
"TARGET_UNIQUE_CONFLICT": "与现有记录唯一键冲突",
|
||||
"DUPLICATE_SOURCE_IDENTITY": "源记录身份重复",
|
||||
"GROUP_SCOPE_PENDING": "平账范围待确认",
|
||||
"GROUP_FACT_REQUIRED": "尚未登记该平账组",
|
||||
"OVERLAPPING_GROUPS": "同一单据被多个平账组重复选取",
|
||||
"OPENING_BALANCE_PENDING": "期初应收待核实",
|
||||
"HISTORICAL_SETTLEMENT_PENDING": "历史已核算或已发款待核实",
|
||||
"VOUCHER_STATE_PENDING": "尚未核实收款已生成凭证",
|
||||
"ACCOUNTING_VOUCHER_INVALID": "关联凭证缺失、已作废或制单状态异常",
|
||||
"VOUCHER_EVIDENCE_CONFLICT": "凭证关联与人工核实不一致,请复核",
|
||||
"NOT_GENERATED_VOUCHER": "无关联总账凭证,不纳入本次预演",
|
||||
"NOT_RECEIPT": "属于付款单,不作为销售回款核算",
|
||||
"GUIDANCE_PENDING": "缺少有效指导成本",
|
||||
"QUANTITY_PENDING": "单位成本对应的发货数量需核对",
|
||||
"GUIDANCE_MONTH_BASIS_PENDING": "指导价取值月份待确认",
|
||||
"PREPAYMENT_PARAMETERS_PENDING": "预收业务采用发货日还是收款日参数待确认",
|
||||
"AUGUST_RULE_PENDING": "8月调整参数待确认",
|
||||
"PERSONNEL_PENDING": "归属人员待确认",
|
||||
"CUSTOMER_CATEGORY_PENDING": "客户分类待确认",
|
||||
"PARAMETERS_PENDING": "计提参数待补",
|
||||
"NEGATIVE_OR_ZERO_DOCUMENT": "负数或零金额单据需人工核对",
|
||||
"NEGATIVE_EVENT_REVIEW_REQUIRED": "红冲或作废业务需人工核对",
|
||||
"LEGACY_SYSTEM_CALCULATES": "只冲旧应收,绩效由原软件计算",
|
||||
"NO_ACTUAL_SHIPMENT": "未实际发货,暂不计提",
|
||||
"FREIGHT_PENDING": "运费是否剔除及实际金额待核实",
|
||||
"SHIPPING_OWNERSHIP_PENDING": "发货业务及人员归属待核实",
|
||||
"HANDOVER_SHIPMENTS_PENDING": "在职交接的发货归属待核实",
|
||||
"HANDOVER_PENDING": "离职日期或接手依据待补,不能猜测归属",
|
||||
"TAX_AMOUNT_PENDING": "源含税金额缺失",
|
||||
"SHIPMENT_STATE_PENDING": "销货单尚未核实为生效状态",
|
||||
"GROUP_SOURCE_MISSING": "所选单据不在当前完整快照中",
|
||||
"GROUP_CUSTOMER_MISMATCH": "平账组含有其他客户单据",
|
||||
"SHIPMENT_DETAILS_MISSING": "发货明细不完整",
|
||||
"SOURCE_REFRESH_REQUIRED": "需先完整读取相关源数据",
|
||||
"AMOUNT_PRECISION_PENDING": "金额精度需核对",
|
||||
"DATA_VALUE_INVALID": "源日期或金额格式需核对",
|
||||
}
|
||||
|
||||
|
||||
def reason_label(value):
|
||||
return ";".join(REASON_LABELS.get(part, part) for part in (value or "").split(",") if part)
|
||||
|
||||
|
||||
def spreadsheet_value(value):
|
||||
"""防公式注入,并让带前导零的编号在电子表格中保持文本。"""
|
||||
text = str(value) if value is not None else ""
|
||||
if text.lstrip(" \t\r\n").startswith(("=", "+", "-", "@")) or (
|
||||
text.isdigit() and ((text.startswith("0") and len(text) > 1) or len(text) > 15)
|
||||
):
|
||||
return "'" + text
|
||||
return value
|
||||
@@ -0,0 +1,53 @@
|
||||
"""工作区只使用成功完整读取发布的当前源快照。"""
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.models import TPlusSourceHead, TPlusSyncBatch, TPlusSyncRecord
|
||||
from app.services.tplus.workbench import WorkbenchError
|
||||
|
||||
|
||||
def source_statement(session, source, entity):
|
||||
"""只发布最后一次完整成功读取;中断覆盖的指针不得部分发布。"""
|
||||
batches = session.scalars(
|
||||
select(TPlusSyncBatch)
|
||||
.where(
|
||||
TPlusSyncBatch.source_database == source,
|
||||
TPlusSyncBatch.sync_mode == "FULL",
|
||||
)
|
||||
.order_by(TPlusSyncBatch.id.desc())
|
||||
)
|
||||
batch_no = None
|
||||
for batch in batches:
|
||||
report = batch.audit_result_json or {}
|
||||
if entity in (batch.entity_types_json or []) and batch.status != "SUCCEEDED":
|
||||
raise WorkbenchError("SOURCE_REFRESH_REQUIRED")
|
||||
if entity in report.get("observed_entities", []):
|
||||
batch_no = batch.batch_no
|
||||
break
|
||||
if batch_no is None:
|
||||
raise WorkbenchError("SOURCE_REFRESH_REQUIRED")
|
||||
return (
|
||||
select(TPlusSyncRecord)
|
||||
.join(TPlusSourceHead, TPlusSourceHead.record_id == TPlusSyncRecord.id)
|
||||
.where(
|
||||
TPlusSourceHead.source_database == source,
|
||||
TPlusSourceHead.entity_type == entity,
|
||||
TPlusSourceHead.batch_no == batch_no,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def source_records(session, source, entity, ids=None, parent_ids=None):
|
||||
"""旧暂存未建立当前指针时要求重新读取,禁止 max(record.id) 猜最新值。"""
|
||||
if ids == [] or parent_ids == []:
|
||||
return []
|
||||
statement = source_statement(session, source, entity)
|
||||
if ids:
|
||||
statement = statement.where(TPlusSourceHead.source_id.in_(ids))
|
||||
if parent_ids is not None:
|
||||
statement = statement.where(
|
||||
TPlusSyncRecord.payload_json["source_parent_id"]
|
||||
.as_integer()
|
||||
.in_([int(v) for v in parent_ids])
|
||||
)
|
||||
return list(session.scalars(statement.order_by(TPlusSyncRecord.source_id)))
|
||||
@@ -0,0 +1,59 @@
|
||||
"""保留成功读取实际观察到的当前版本,不把历史最大ID当成当前值。"""
|
||||
|
||||
from sqlalchemy import inspect, select, tuple_
|
||||
|
||||
from app.models import TPlusSourceHead, TPlusSyncRecord
|
||||
from app.services.tplus.staging import prepare_row
|
||||
|
||||
|
||||
def observe_rows(session, batch, mapping, rows):
|
||||
"""每页提交前记录所有读到的源版本,包括去重跳过的 A→B→A。"""
|
||||
# V15 运行中的升级窗口继续允许原读取功能;工作区本身要求 V16。
|
||||
if not inspect(session.connection()).has_table(TPlusSourceHead.__tablename__):
|
||||
return
|
||||
prepared = [
|
||||
prepare_row(batch.batch_no, mapping.entity_type, mapping.source_table, r)
|
||||
for r in rows
|
||||
if r.get("id") is not None
|
||||
]
|
||||
wanted = [(r["source_id"], r["row_hash"]) for r in prepared]
|
||||
if not wanted:
|
||||
return
|
||||
records = {
|
||||
(r.source_id, r.row_hash): r.id
|
||||
for r in session.scalars(
|
||||
select(TPlusSyncRecord).where(
|
||||
TPlusSyncRecord.entity_type == mapping.entity_type,
|
||||
tuple_(TPlusSyncRecord.source_id, TPlusSyncRecord.row_hash).in_(wanted),
|
||||
)
|
||||
)
|
||||
}
|
||||
source = batch.source_database
|
||||
if not source:
|
||||
raise ValueError("源账套身份缺失,不能发布当前快照")
|
||||
current = {
|
||||
h.source_id: h
|
||||
for h in session.scalars(
|
||||
select(TPlusSourceHead).where(
|
||||
TPlusSourceHead.source_database == source,
|
||||
TPlusSourceHead.entity_type == mapping.entity_type,
|
||||
TPlusSourceHead.source_id.in_([v[0] for v in wanted]),
|
||||
)
|
||||
)
|
||||
}
|
||||
for row in prepared:
|
||||
record_id = records.get((row["source_id"], row["row_hash"]))
|
||||
if record_id is None:
|
||||
continue
|
||||
head = current.get(row["source_id"])
|
||||
if head is None:
|
||||
head = TPlusSourceHead(
|
||||
source_database=source,
|
||||
entity_type=mapping.entity_type,
|
||||
source_id=row["source_id"],
|
||||
record_id=record_id,
|
||||
batch_no=batch.batch_no,
|
||||
)
|
||||
session.add(head)
|
||||
current[row["source_id"]] = head
|
||||
head.record_id, head.batch_no = record_id, batch.batch_no
|
||||
@@ -11,8 +11,10 @@ from sqlalchemy.orm import Session
|
||||
from app.core.config import get_settings
|
||||
from app.core.tplus_lock import sync_lock
|
||||
from app.models import TPlusSyncBatch, TPlusSyncError
|
||||
from app.services.tplus.errors import TRANSIENT_CODES, classify_source_error
|
||||
from app.services.tplus.mapping import TPlusMapping, get_mapping
|
||||
from app.services.tplus.reader import TPlusReader
|
||||
from app.services.tplus.source_heads import observe_rows
|
||||
from app.services.tplus.staging import stage_rows
|
||||
from app.services.tplus.task_service import fail_batch, new_batch_no, recover_running
|
||||
|
||||
@@ -74,13 +76,18 @@ class TPlusSyncService:
|
||||
if batch.status != "RUNNING" or not batch.entity_types_json:
|
||||
raise ValueError("任务尚未领取或读取范围不完整")
|
||||
mappings = [get_mapping(entity) for entity in batch.entity_types_json]
|
||||
interrupted = False
|
||||
for mapping in mappings:
|
||||
try:
|
||||
self._read_entity(batch, mapping, batch_size)
|
||||
except Exception as exc:
|
||||
self.target_session.rollback()
|
||||
self._record_error(batch, mapping, {}, "READ_ERROR", type(exc).__name__)
|
||||
code = classify_source_error(exc)
|
||||
self._record_error(batch, mapping, {}, code, type(exc).__name__)
|
||||
self.target_session.commit()
|
||||
if code in TRANSIENT_CODES:
|
||||
interrupted = True
|
||||
break
|
||||
batch.status = "SUCCEEDED"
|
||||
if batch.failed_record_count:
|
||||
failed_rows = (
|
||||
@@ -94,7 +101,8 @@ class TPlusSyncService:
|
||||
)
|
||||
or 0
|
||||
)
|
||||
batch.status = "PARTIAL" if batch.source_record_count > failed_rows else "FAILED"
|
||||
# 中途断连仍是未完成的全量采集;保留已暂存页,重跑由哈希防重。
|
||||
batch.status = "PARTIAL" if not interrupted and batch.source_record_count > failed_rows else "FAILED"
|
||||
batch.error_message = "部分数据未完成,请查询同步错误记录并重跑全量快照"
|
||||
batch.finished_at = datetime.now()
|
||||
batch.watermark_to = None
|
||||
@@ -106,12 +114,20 @@ class TPlusSyncService:
|
||||
source = self.reader.iter_entity(mapping.entity_type, batch_size=size)
|
||||
for chunk in batched(source, size):
|
||||
inserted, errors = stage_rows(self.target_session, batch.batch_no, mapping, list(chunk))
|
||||
observe_rows(self.target_session, batch, mapping, list(chunk))
|
||||
batch.source_record_count += len(chunk)
|
||||
batch.staged_record_count += inserted
|
||||
batch.skipped_record_count += len(chunk) - inserted - len(errors)
|
||||
for row, reason in errors:
|
||||
self._record_error(batch, mapping, row, "STAGE_ERROR", reason)
|
||||
self.target_session.commit()
|
||||
report = dict(batch.audit_result_json or {})
|
||||
report["source_head_version"] = 1
|
||||
report["observed_entities"] = list(
|
||||
dict.fromkeys([*report.get("observed_entities", []), mapping.entity_type])
|
||||
)
|
||||
batch.audit_result_json = report
|
||||
self.target_session.commit()
|
||||
|
||||
def _record_error(
|
||||
self, batch: TPlusSyncBatch, mapping: TPlusMapping, row: dict, code: str, reason: str
|
||||
|
||||
@@ -16,6 +16,7 @@ from app.repositories import tplus_repository as repository
|
||||
from app.schemas.tplus import TPlusCreateIn, TPlusRetryIn
|
||||
from app.services import audit_service
|
||||
from app.services.audit_service import AuditRecord, RequestMeta
|
||||
from app.services.tplus.errors import MESSAGES
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -164,6 +165,7 @@ def fail_batch(session: Session, batch: TPlusSyncBatch, code: str) -> None:
|
||||
"TYPE_UNRESOLVED": "核验所需单据类型缺失或不唯一,请核对源字典",
|
||||
}
|
||||
message = messages.get(code, "任务未完成,请核对运行配置")
|
||||
message = MESSAGES.get(code, message)
|
||||
batch.status, batch.finished_at, batch.error_message = "FAILED", datetime.now(), message
|
||||
batch.failed_record_count = (batch.failed_record_count or 0) + 1
|
||||
session.add(
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
"""工作区任务与事实存取:与原有 worker 共用执行锁和持久化队列。"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import defer
|
||||
|
||||
from app.core.exceptions import BadRequestError, NotFoundError
|
||||
from app.core.response import PageResult
|
||||
from app.core.tplus_lock import TPlusBusyError, sync_lock
|
||||
from app.models import TPlusFact, TPlusWorkLine, TPlusWorkRun
|
||||
from app.schemas.tplus_workbench import WorkCreateIn
|
||||
from app.services.tplus.task_service import _source_database, new_batch_no
|
||||
|
||||
RULE_VERSION = "2026-10-30.2"
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def digest(data):
|
||||
"""稳定指纹用于固定规则、事实和来源版本。"""
|
||||
return hashlib.sha256(
|
||||
json.dumps(
|
||||
data, ensure_ascii=False, sort_keys=True, default=str, separators=(",", ":")
|
||||
).encode()
|
||||
).hexdigest()
|
||||
|
||||
|
||||
def latest_facts(session, source):
|
||||
"""每个事实键只读取最近版本,历史行保持可追溯。"""
|
||||
ids = (
|
||||
select(func.max(TPlusFact.id))
|
||||
.where(TPlusFact.source_database == source)
|
||||
.group_by(TPlusFact.kind, TPlusFact.fact_key)
|
||||
)
|
||||
return {
|
||||
(f.kind, f.fact_key): f
|
||||
for f in session.scalars(
|
||||
select(TPlusFact).where(TPlusFact.id.in_(ids)).order_by(TPlusFact.id)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
def save_fact(session, payload, actor_id):
|
||||
"""与导入共用锁,乐观版本不匹配时拒绝覆盖。"""
|
||||
source = _source_database()
|
||||
try:
|
||||
with sync_lock(session):
|
||||
session.rollback()
|
||||
current = latest_facts(session, source).get((payload.kind, payload.fact_key))
|
||||
if payload.kind == "GUIDANCE" and current and current.data_json.get("is_finalized"):
|
||||
raise BadRequestError("该发货月份指导成本已封存,后续回款不得修改原指导成本")
|
||||
if (current.version if current else 0) != payload.expected_version:
|
||||
raise BadRequestError("补充资料已经变化,请刷新后再保存")
|
||||
fact = TPlusFact(
|
||||
source_database=source,
|
||||
kind=payload.kind,
|
||||
fact_key=payload.fact_key,
|
||||
version=payload.expected_version + 1,
|
||||
data_json=payload.data,
|
||||
evidence=payload.evidence,
|
||||
requested_by=actor_id,
|
||||
)
|
||||
session.add(fact)
|
||||
session.commit()
|
||||
return fact_out(fact)
|
||||
except (TPlusBusyError, IntegrityError):
|
||||
session.rollback()
|
||||
raise BadRequestError("资料或任务正在更新,请刷新后重试") from None
|
||||
|
||||
|
||||
def fact_out(fact):
|
||||
"""仅工作区授权接口展示核算必需事实,不返回连接凭据。"""
|
||||
return {
|
||||
"id": fact.id,
|
||||
"kind": fact.kind,
|
||||
"fact_key": fact.fact_key,
|
||||
"version": fact.version,
|
||||
"data": fact.data_json,
|
||||
"evidence": fact.evidence,
|
||||
"requested_by": fact.requested_by,
|
||||
"create_time": fact.create_time,
|
||||
}
|
||||
|
||||
|
||||
def list_facts(session, query):
|
||||
source = _source_database()
|
||||
ids = (
|
||||
select(func.max(TPlusFact.id))
|
||||
.where(TPlusFact.source_database == source)
|
||||
.group_by(TPlusFact.kind, TPlusFact.fact_key)
|
||||
)
|
||||
condition = [TPlusFact.id.in_(ids)]
|
||||
if query.kind:
|
||||
condition.append(TPlusFact.kind == query.kind)
|
||||
total = session.scalar(select(func.count()).select_from(TPlusFact).where(*condition))
|
||||
rows = session.scalars(
|
||||
select(TPlusFact)
|
||||
.where(*condition)
|
||||
.order_by(TPlusFact.id)
|
||||
.offset(query.offset)
|
||||
.limit(query.page_size)
|
||||
)
|
||||
return PageResult(
|
||||
records=[fact_out(f) for f in rows],
|
||||
total=total or 0,
|
||||
page_num=query.page_num,
|
||||
page_size=query.page_size,
|
||||
)
|
||||
|
||||
|
||||
def queue_work(session, payload: WorkCreateIn, actor_id):
|
||||
"""HTTP 只排队;同幂等键的不同意图必须被拒绝。"""
|
||||
values = payload.model_dump(mode="json", exclude={"request_key"})
|
||||
source = _source_database()
|
||||
key = str(payload.request_key)
|
||||
found = session.scalar(select(TPlusWorkRun).where(TPlusWorkRun.request_key == key))
|
||||
if found:
|
||||
if (
|
||||
found.input_json != values
|
||||
or found.source_database != source
|
||||
or found.requested_by != actor_id
|
||||
):
|
||||
raise BadRequestError("请求键已用于不同任务")
|
||||
return work_out(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.startswith("BUSINESS_"):
|
||||
from app.models import User
|
||||
from app.services.payroll_access import ensure_payroll_writer
|
||||
|
||||
ensure_payroll_writer(session.get(User, actor_id))
|
||||
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("必须选择已完成的正式业务导入预览")
|
||||
if payload.replay_no:
|
||||
previous = get_run(session, payload.replay_no, source)
|
||||
if previous.kind != "CALC_PREVIEW" or previous.status not in ("SUCCEEDED", "PARTIAL"):
|
||||
raise BadRequestError("只能重跑已完成的核算预演")
|
||||
run = TPlusWorkRun(
|
||||
run_no=new_batch_no(),
|
||||
request_key=key,
|
||||
kind=payload.kind,
|
||||
status="QUEUED",
|
||||
source_database=source,
|
||||
requested_by=actor_id,
|
||||
input_json=values,
|
||||
)
|
||||
try:
|
||||
with session.begin_nested():
|
||||
session.add(run)
|
||||
session.flush()
|
||||
session.commit()
|
||||
except IntegrityError:
|
||||
session.rollback()
|
||||
return queue_work(session, payload, actor_id)
|
||||
return work_out(run)
|
||||
|
||||
|
||||
def get_run(session, number, source=None):
|
||||
source = source or _source_database()
|
||||
run = session.scalar(
|
||||
select(TPlusWorkRun)
|
||||
.where(TPlusWorkRun.run_no == number, TPlusWorkRun.source_database == source)
|
||||
.options(defer(TPlusWorkRun.snapshot_json))
|
||||
)
|
||||
if run is None:
|
||||
raise NotFoundError("工作区运行不存在")
|
||||
return run
|
||||
|
||||
|
||||
def work_out(run):
|
||||
return {
|
||||
"run_no": run.run_no,
|
||||
"kind": run.kind,
|
||||
"status": run.status,
|
||||
"request_key": run.request_key,
|
||||
"input": run.input_json,
|
||||
"snapshot_hash": run.snapshot_hash,
|
||||
"summary": run.summary_json,
|
||||
"error_code": run.error_code,
|
||||
"create_time": run.create_time,
|
||||
"started_at": run.started_at,
|
||||
"finished_at": run.finished_at,
|
||||
"formal_payroll_written": False,
|
||||
}
|
||||
|
||||
|
||||
def list_work(session, query):
|
||||
conditions = [TPlusWorkRun.source_database == _source_database()]
|
||||
if query.kind:
|
||||
conditions.append(TPlusWorkRun.kind == query.kind)
|
||||
total = session.scalar(select(func.count()).select_from(TPlusWorkRun).where(*conditions))
|
||||
rows = session.scalars(
|
||||
select(TPlusWorkRun)
|
||||
.options(defer(TPlusWorkRun.snapshot_json))
|
||||
.where(*conditions)
|
||||
.order_by(TPlusWorkRun.id.desc())
|
||||
.offset(query.offset)
|
||||
.limit(query.page_size)
|
||||
)
|
||||
return PageResult(
|
||||
records=[work_out(r) for r in rows],
|
||||
total=total or 0,
|
||||
page_num=query.page_num,
|
||||
page_size=query.page_size,
|
||||
)
|
||||
|
||||
|
||||
def list_lines(session, number, query):
|
||||
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(
|
||||
select(TPlusWorkLine)
|
||||
.where(*conditions)
|
||||
.order_by(TPlusWorkLine.line_no)
|
||||
.offset(query.offset)
|
||||
.limit(query.page_size)
|
||||
)
|
||||
return PageResult(
|
||||
records=[
|
||||
{
|
||||
"line_no": r.line_no,
|
||||
"entity_type": r.entity_type,
|
||||
"source_id": r.source_id,
|
||||
"action": r.action,
|
||||
"reason": r.reason,
|
||||
"data": r.data_json,
|
||||
}
|
||||
for r in rows
|
||||
],
|
||||
total=total or 0,
|
||||
page_num=query.page_num,
|
||||
page_size=query.page_size,
|
||||
)
|
||||
|
||||
|
||||
def execute_next(session):
|
||||
"""调用方持有原有全局锁;中断任务保留明细,禁止不明状态自动应用。"""
|
||||
for old in session.scalars(select(TPlusWorkRun).where(TPlusWorkRun.status == "RUNNING")):
|
||||
old.status, old.error_code, old.finished_at = "FAILED", "INTERRUPTED", datetime.now()
|
||||
session.commit()
|
||||
run = session.scalar(
|
||||
select(TPlusWorkRun)
|
||||
.where(TPlusWorkRun.status == "QUEUED")
|
||||
.order_by(TPlusWorkRun.id)
|
||||
.limit(1)
|
||||
.with_for_update()
|
||||
)
|
||||
if run is None:
|
||||
session.rollback()
|
||||
return None
|
||||
run.status, run.started_at = "RUNNING", datetime.now()
|
||||
session.commit()
|
||||
try:
|
||||
if run.source_database != _source_database():
|
||||
raise WorkbenchError("SOURCE_CHANGED")
|
||||
if run.kind.startswith("BUSINESS_"):
|
||||
from app.services.tplus.business_import import execute_business
|
||||
|
||||
execute_business(session, run)
|
||||
elif run.kind.startswith("MASTER_"):
|
||||
from app.services.tplus.master_import import execute_master
|
||||
|
||||
execute_master(session, run)
|
||||
else:
|
||||
from app.services.tplus.calculation_preview import execute_preview
|
||||
|
||||
execute_preview(session, run, customer_scope=True)
|
||||
run.finished_at = datetime.now()
|
||||
session.commit()
|
||||
except Exception as exc:
|
||||
session.rollback()
|
||||
run = get_run(session, run.run_no, run.source_database)
|
||||
run.status, run.finished_at = "FAILED", datetime.now()
|
||||
# 不让驱动异常、SQL 或原始数据进入响应。
|
||||
run.error_code = str(exc) if isinstance(exc, WorkbenchError) else "EXECUTION_ERROR"
|
||||
logger.error(
|
||||
"工作区任务失败 | run_no=%s | exception_type=%s", run.run_no, type(exc).__name__
|
||||
)
|
||||
session.commit()
|
||||
return run.run_no
|
||||
|
||||
|
||||
class WorkbenchError(ValueError):
|
||||
"""可公开的固定失败编码;不得传入客户原文或驱动异常。"""
|
||||
@@ -7,14 +7,25 @@ from app.core.tplus_db import get_tplus_session_factory, resolve_tplus_settings
|
||||
from app.core.tplus_lock import TPlusBusyError, sync_lock
|
||||
from app.repositories import tplus_repository as repository
|
||||
from app.services.tplus import task_service
|
||||
from app.services.tplus.automation import WorkerHeartbeat
|
||||
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
|
||||
|
||||
|
||||
def run_once(session, source_factory=None):
|
||||
def run_once(session, source_factory=None, *, worker_owner=None):
|
||||
"""返回本轮处理的批次号;忙锁或队列为空时不假称已执行。"""
|
||||
try:
|
||||
with sync_lock(session):
|
||||
with sync_lock(session), WorkerHeartbeat(session, owner=worker_owner):
|
||||
from sqlalchemy import inspect
|
||||
|
||||
from app.models import TPlusWorkRun
|
||||
from app.services.tplus.workbench import execute_next
|
||||
|
||||
if inspect(session.connection()).has_table(TPlusWorkRun.__tablename__):
|
||||
number = execute_next(session)
|
||||
if number:
|
||||
return number
|
||||
task_service.recover_running(session)
|
||||
batch = repository.next_queued(session)
|
||||
if batch is None:
|
||||
@@ -30,10 +41,12 @@ def run_once(session, source_factory=None):
|
||||
|
||||
|
||||
def _execute(session, batch, source_factory):
|
||||
configured = False
|
||||
try:
|
||||
settings = resolve_tplus_settings(task_service.get_settings())
|
||||
if batch.source_database and batch.source_database != settings.t_plus_database:
|
||||
raise RuntimeError("任务排队后的源账套配置已改变")
|
||||
configured = True
|
||||
factory = source_factory or get_tplus_session_factory()
|
||||
with factory() as source:
|
||||
if batch.sync_mode == "AUDIT":
|
||||
@@ -55,7 +68,7 @@ def _execute(session, batch, source_factory):
|
||||
batch,
|
||||
"INTERRUPTED"
|
||||
if isinstance(exc, (KeyboardInterrupt, SystemExit))
|
||||
else "EXECUTION_ERROR",
|
||||
else classify_source_error(exc, "EXECUTION_ERROR" if configured else "SOURCE_CONFIG_ERROR"),
|
||||
)
|
||||
if isinstance(exc, (KeyboardInterrupt, SystemExit)):
|
||||
raise
|
||||
Reference in new issue
Block a user