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
@@ -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"'},
|
||||
)
|
||||
Reference in new issue
Block a user