Compare commits

..

No commits in common. "b3de35c2eceb7a2f16a36c29573c99b5159ddc4d" and "22605f4106bbc49cae0da29d2252b9df8df4e5f4" have entirely different histories.

243 changed files with 1253 additions and 12143 deletions

View File

@ -1,35 +0,0 @@
# 构建上下文白名单,防止本地配置、客户资料和虚拟环境被发送到构建器。
**
!backend/
!backend/Dockerfile
!backend/pyproject.toml
!backend/uv.lock
!backend/.python-version
!backend/alembic.ini
!backend/app/
!backend/app/**
!backend/alembic/
!backend/alembic/**
!db/
!db/migration/
!db/migration/**
!db/queries/
!db/queries/tplus/
!db/queries/tplus/**
!frontend/
!frontend/Dockerfile
!frontend/package.json
!frontend/pnpm-lock.yaml
!frontend/index.html
!frontend/tsconfig*.json
!frontend/vite.config.ts
!frontend/.env.production
!frontend/src/
!frontend/src/**
!frontend/public/
!frontend/public/**
!docker/
!docker/nginx.prod.conf
!docker/tplus-maintenance.sh
**/__pycache__
**/*.pyc

10
.gitignore vendored
View File

@ -1,7 +1,5 @@
# AI辅助目录(整个目录忽略)
.ai/
.agents/
/AGENTS.md
# 依赖
node_modules/
@ -55,7 +53,6 @@ frontend/e2e/**/*.png
*.tmp
*.bak
*.cache
.tmp-processes*.txt
# Office/WPS 打开文档时生成的锁文件(如 ~$xxx.docx)
~$*
@ -70,12 +67,6 @@ backend/uploads/
db/backups/
# 客户发来的原始账套备份压缩包。
/db/*.zip
# 本地恢复与连通性排查脚本/结果,不作为业务交付物。
/db/restore_*.ps1
/db/restore_*.sql
/db/restore_result.txt
/db/probe*.ps1
/backend/probe_*_status.py
# 外部参考资料(2026-10-20):T+12.3 数据字典压缩包与解压目录不入版本库
docs/**/*.rar
@ -91,4 +82,3 @@ backend/.venv-workbench/
# 本地浏览器验收自动生成的快照
/.playwright-mcp/
/phase2-evidence-*/

View File

@ -2,7 +2,7 @@
营销人员绩效工资核算系统。后端 Python/FastAPI,前端 Vue 3,数据存 MySQL,会话与缓存在 Redis。
> 当前进度:正式工资闭环及同步运维自动化已完成本地开发验证,支持每周原始采集、有限重试、独立心跳和站内提示,并提供 Windows 托管脚本。辽宁兴晟达真实样本已在隔离库完成正式导入、核算、月结、报表与导出验收,整月实际工资仍须另行核定,见[真实样本业务闭环验收](docs/真实样本业务闭环验收_2026-11-03.md)。生产自动化默认关闭,尚未安装启用。审核能力保留但默认关闭,多原单更正仍单独跟踪。相关说明见 [T+ 本地恢复与同步开发手册](docs/T+本地恢复与同步开发手册.md) 和 [Windows 操作说明](scripts/windows/README.md)。
> 当前进度:正式工资闭环及同步运维自动化已完成本地开发验证,支持每周原始采集、有限重试、独立心跳和站内提示,并提供 Windows 托管脚本。生产自动化默认关闭,尚未安装启用;真实金额与整月工资尚未验收,首组样本缺人工指导成本。审核能力保留但默认关闭,多原单更正仍单独跟踪。最新交付见 [开发记录](docs/同步运维自动化与样本核对开发记录_2026-10-30.md) 和 [Windows 操作说明](scripts/windows/README.md)。
## 快速开始
@ -128,8 +128,6 @@ python e2e/system_management.py # 系统管理 13 项
## 相关文档
客户现场部署见 [客户现场部署与 T+ 连接说明](docs/客户现场部署与T+连接说明.md):按 Linux 容器、Windows Server 或 Linux 原生选择托管方式,连接客户既有 T+ SQL Server。生产 Compose 为 `docker/docker-compose.prod.yml`,T+ 恢复库编排仅用于本地开发。
| 文档 | 内容 |
| --- | --- |
| `backend/README.md` | 后端启动、分层约定、接口清单 |
@ -143,7 +141,7 @@ python e2e/system_management.py # 系统管理 13 项
- 应用新增迁移后,T+ 页面提供正式业务导入预览/执行;业务列表“正式核算”准备人工业务资料或核算已导入资料,台账可查看逐段依据。
- “生成月结快照”首次成功即锁定该月分配;审批退回、重新生成不解锁。历史已生成工资保留,差额计调整单生成当月。
- 后端接口说明、验证状态和已知边界见 `docs/正式工资闭环开发记录_2026-10-30.md`。旧引擎仅通过 `PAYROLL_ALLOW_LEGACY_CALCULATION` 显式启用作历史兼容,默认关闭,不用于最新规则验收。
- 集成回归使用 `scripts/run_isolated_tests.py`;原 `.venv` 不可用时,本机使用 `backend/.venv-workbench/Scripts/python.exe`。运行器默认清理本次新建的隔离库和账号,绝不删除原开发数据;需复查失败现场时加 `--keep`。
- 集成回归使用 `scripts/run_isolated_tests.py`;原 `.venv` 不可用时,本机使用 `backend/.venv-workbench/Scripts/python.exe`。测试运行器保留隔离库供复查,不删除原开发数据。
审核默认关闭:`PAYROLL_APPROVAL_ENABLED=false`,工资生成直接完成且不产生审批痕迹;如确需系统内复核,显式开启后沿用财务/领导审核和各类审批参数。此配置与旧核算引擎兼容开关独立。

View File

@ -41,10 +41,6 @@ T_PLUS_PORT=1433
T_PLUS_DATABASE=
T_PLUS_USER=
T_PLUS_PASSWORD=
# 仅用于已确认的开发测试库,默认禁止使用 sa。
T_PLUS_ALLOW_DEV_SA=false
# 仅在客户确认只能使用管理员账号时显式开启。
T_PLUS_ALLOW_ADMIN_ACCOUNT=false
# 可显式指定项目外的只读连接 JSON;不从页面接收该路径。
T_PLUS_CONNECTION_FILE=
# 必须填写在客户 SQL Server 2008 R2 上验证过的驱动名称。
@ -53,8 +49,8 @@ T_PLUS_ODBC_DRIVER=
# 默认启用传输加密。旧实例 TLS 不兼容时,仅隔离开发环境可显式设为 false,
# 记录原因并重新验收;程序不会自动降级。真实环境值后续按验收结果填写。
T_PLUS_ENCRYPT=true
# 默认校验服务器证书。仅隔离开发环境可显式设为 true,并记录原因和验收范围。
T_PLUS_TRUST_SERVER_CERTIFICATE=false
# 内网自签证书可暂用 true;它不校验证书身份。部署可信证书后改为 false。
T_PLUS_TRUST_SERVER_CERTIFICATE=true
T_PLUS_CONNECT_TIMEOUT=10
T_PLUS_QUERY_TIMEOUT=120
# 可选:留空时使用仓库根目录下的 db/queries/tplus/
@ -72,7 +68,5 @@ T_PLUS_SCHEDULE_WEEKDAY=0
T_PLUS_SCHEDULE_TIME=02:00
# 托管启动器自动设置;发现标记后停止排队和领取新任务
T_PLUS_STOP_FILE=
# 仅允许本机 HTTP 健康检查地址;API 更换监听端口时同步修改。
T_PLUS_SUPERVISOR_HEALTH_URL=http://127.0.0.1:8000/api/health
# 可选上传文件根目录;留空使用 backend/uploads,隔离测试由运行器独立指定。
UPLOAD_DIR=

View File

@ -1,43 +0,0 @@
# 从仓库根目录构建:docker build -f backend/Dockerfile -t ys-payroll-backend:release .
FROM ghcr.io/astral-sh/uv:0.12.13 AS uv
FROM python:3.12-slim-bookworm
ENV TZ=Asia/Shanghai \
PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
UV_LINK_MODE=copy \
UV_PYTHON_DOWNLOADS=never \
PATH="/app/backend/.venv/bin:$PATH"
# 这里只安装客户端驱动,连接客户现场 SQL Server;镜像内没有 T+ 数据库。
RUN apt-get update \
&& DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends ca-certificates curl gnupg unixodbc tzdata \
&& curl -fsSL https://packages.microsoft.com/keys/microsoft.asc \
| gpg --dearmor -o /usr/share/keyrings/microsoft-prod.gpg \
&& echo "deb [signed-by=/usr/share/keyrings/microsoft-prod.gpg] https://packages.microsoft.com/debian/12/prod bookworm main" \
> /etc/apt/sources.list.d/mssql-release.list \
&& apt-get update \
&& ACCEPT_EULA=Y DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends msodbcsql18 \
&& apt-get purge -y curl gnupg \
&& apt-get autoremove -y \
&& rm -rf /var/lib/apt/lists/*
COPY --from=uv /uv /usr/local/bin/uv
WORKDIR /app/backend
COPY backend/pyproject.toml backend/uv.lock backend/.python-version ./
RUN uv sync --frozen --no-dev --extra tplus --no-cache
COPY backend/alembic.ini ./
COPY backend/alembic ./alembic
COPY backend/app ./app
# 保留仓库相对布局,覆盖读取、总量、关联核验和样本核对 SQL。
COPY db/queries/tplus /app/db/queries/tplus
# V28 迁移从仓库路径读取留存 SQL,镜像必须携带该运行时依赖。
COPY db/migration /app/db/migration
COPY docker/tplus-maintenance.sh /app/tplus-maintenance.sh
RUN mkdir -p /app/backend/uploads /app/backend/logs \
&& useradd --uid 10001 --create-home appuser \
&& chown -R appuser:appuser /app/backend/uploads /app/backend/logs
USER appuser
CMD ["python", "-m", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

View File

@ -15,22 +15,22 @@ def upgrade():
bind=op.get_bind()
for sql in DDL:
op.execute(sql)
for code,name,kind,_parent,order in PERMISSIONS:
bind.execute(sa.text("INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES (:code,:name,:kind,:order,1)"),{"code": code,"name": name,"kind": kind,"order": order})
for code,name,kind,parent,order in PERMISSIONS:
bind.execute(sa.text("INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) VALUES (:code,:name,:kind,:order,1)"),dict(code=code,name=name,kind=kind,order=order))
for code,_,_,parent,_ in PERMISSIONS:
if parent:
bind.execute(sa.text("UPDATE permissions p JOIN permissions parent ON parent.perm_code=:parent SET p.parent_id=parent.id WHERE p.perm_code=:code"),{"parent": parent,"code": code})
bind.execute(sa.text("UPDATE permissions p JOIN permissions parent ON parent.perm_code=:parent SET p.parent_id=parent.id WHERE p.perm_code=:code"),dict(parent=parent,code=code))
bind.execute(sa.text("INSERT INTO roles (role_code,role_name,description,sort_order,status) VALUES ('payroll_maker','工资制作人','指定制作人还需在权限设置中绑定,角色本身不能绕过月份锁',5,1)"))
for role,codes in DEFAULTS.items():
for code in codes:
bind.execute(sa.text("INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code=:code WHERE r.role_code=:role"),{"code": code,"role": role})
bind.execute(sa.text("INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code=:code WHERE r.role_code=:role"),dict(code=code,role=role))
from app.core.config import get_settings
maker=get_settings().payroll_maker_user_id
if maker is not None and not bind.execute(sa.text("SELECT id FROM users WHERE id=:id AND status=1"),{"id": maker}).scalar():
if maker is not None and not bind.execute(sa.text("SELECT id FROM users WHERE id=:id AND status=1"),dict(id=maker)).scalar():
raise RuntimeError("旧工资制作人账号无效,请修正配置后重试迁移")
bind.execute(sa.text("INSERT INTO authorization_settings (id,maker_user_id,revision) VALUES (1,:maker,1)"),{"maker": maker})
bind.execute(sa.text("INSERT INTO authorization_settings (id,maker_user_id,revision) VALUES (1,:maker,1)"),dict(maker=maker))
if maker is not None:
bind.execute(sa.text("INSERT INTO user_roles (user_id,role_id) SELECT :maker,id FROM roles WHERE role_code='payroll_maker'"),{"maker": maker})
bind.execute(sa.text("INSERT INTO user_roles (user_id,role_id) SELECT :maker,id FROM roles WHERE role_code='payroll_maker'"),dict(maker=maker))
def downgrade():
bind=op.get_bind()
@ -40,8 +40,8 @@ def downgrade():
op.drop_table('authorization_settings')
codes=[row[0] for row in PERMISSIONS]
for code in codes:
bind.execute(sa.text("DELETE rp FROM role_permissions rp JOIN permissions p ON p.id=rp.permission_id WHERE p.perm_code=:code"),{"code": code})
bind.execute(sa.text("DELETE rp FROM role_permissions rp JOIN permissions p ON p.id=rp.permission_id WHERE p.perm_code=:code"),dict(code=code))
bind.execute(sa.text("DELETE ur FROM user_roles ur JOIN roles r ON r.id=ur.role_id WHERE r.role_code='payroll_maker'"))
bind.execute(sa.text("DELETE FROM roles WHERE role_code='payroll_maker'"))
for code in reversed(codes):
bind.execute(sa.text("DELETE FROM permissions WHERE perm_code=:code"),{"code": code})
bind.execute(sa.text("DELETE FROM permissions WHERE perm_code=:code"),dict(code=code))

View File

@ -105,19 +105,19 @@ def upgrade():
op.bulk_insert(
definitions,
[
{
"param_code": f"coefficient.personal.{field}",
"param_name": title,
"param_group": "产品系数表",
"value_type": kind,
"unit": unit,
"dimension_type": "PRODUCT_CATEGORY",
"value_min": "1" if kind == "INT" else "0",
"value_max": "3660" if kind == "INT" else None,
"description": "按产品类别维护;未配置时沿用同日期的通用业务员参数。",
"sort_order": 52 + index,
"status": 1,
}
dict(
param_code=f"coefficient.personal.{field}",
param_name=title,
param_group="产品系数表",
value_type=kind,
unit=unit,
dimension_type="PRODUCT_CATEGORY",
value_min="1" if kind == "INT" else "0",
value_max="3660" if kind == "INT" else None,
description="按产品类别维护;未配置时沿用同日期的通用业务员参数。",
sort_order=52 + index,
status=1,
)
for index, (field, title, kind, unit) in enumerate(fields)
],
)

View File

@ -14,8 +14,8 @@ def upgrade():
("system:monitor:view", "系统监控:查看", 1),
("system:monitor:export", "系统监控:导出", 2),
):
bind.execute(sa.text("INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) SELECT :code,:name,:kind,100,1 WHERE NOT EXISTS (SELECT 1 FROM permissions WHERE perm_code=:code)"), {"code": code,"name": name,"kind": kind})
bind.execute(sa.text("INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code=:code WHERE r.role_code='admin' AND NOT EXISTS (SELECT 1 FROM role_permissions old WHERE old.role_id=r.id AND old.permission_id=p.id)"), {"code": code})
bind.execute(sa.text("INSERT INTO permissions (perm_code,perm_name,perm_type,sort_order,status) SELECT :code,:name,:kind,100,1 WHERE NOT EXISTS (SELECT 1 FROM permissions WHERE perm_code=:code)"), dict(code=code,name=name,kind=kind))
bind.execute(sa.text("INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r JOIN permissions p ON p.perm_code=:code WHERE r.role_code='admin' AND NOT EXISTS (SELECT 1 FROM role_permissions old WHERE old.role_id=r.id AND old.permission_id=p.id)"), dict(code=code))
bind.execute(sa.text("UPDATE permissions p JOIN permissions parent ON parent.perm_code='system:monitor:view' SET p.parent_id=parent.id WHERE p.perm_code='system:monitor:export'"))

View File

@ -1,47 +0,0 @@
"""V29 回款独立人工确认及默认开启开关;不自动确认历史回款。"""
from pathlib import Path
from alembic import op
from sqlalchemy import inspect, text
revision = "29b0c1d2e3f4"
down_revision = "28a9b0c1d2e3"
branch_labels = None
depends_on = None
def upgrade():
sql = Path(__file__).resolve().parents[3] / "db/migration/V29__receipt_manual_confirmation.sql"
columns = {column["name"]: column for column in inspect(op.get_bind()).get_columns("receipts")}
existing = {name for name in ("confirmation_status", "confirmed_by", "confirmed_at", "confirmed_revision") if name in columns}
if existing and len(existing) != 4:
raise RuntimeError("回款确认字段不完整,请先核对数据库结构")
if existing:
expected = {"confirmation_status": ("TINYINT", None, False), "confirmed_by": ("VARCHAR", 64, True),
"confirmed_at": ("DATETIME", None, True), "confirmed_revision": ("VARCHAR", 64, True)}
for name, (kind, length, nullable) in expected.items():
column = columns[name]
actual_type = column["type"]
if (actual_type.__visit_name__.upper() != kind or getattr(actual_type, "length", None) != length
or column["nullable"] != nullable):
raise RuntimeError(f"回款确认字段 {name} 与迁移定义不一致,请先核对数据库结构")
if str(columns["confirmation_status"]["default"]).strip("'\"") != "0":
raise RuntimeError("回款确认状态默认值不是待确认,请先核对数据库结构")
for statement in sql.read_text(encoding="utf-8").split(";\n"):
clean = "\n".join(line for line in statement.splitlines() if not line.lstrip().startswith("--")).strip()
if clean and not (existing and clean.startswith("ALTER TABLE receipts ADD COLUMN")):
op.get_bind().execute(text(clean))
def downgrade():
bind = op.get_bind()
if bind.scalar(text("SELECT COUNT(*) FROM receipts WHERE confirmed_at IS NOT NULL OR confirmation_status<>0")):
raise RuntimeError("已有回款人工确认记录,禁止删除确认依据;请仅回退应用并保留结构")
if bind.scalar(text("SELECT COUNT(*) FROM param_values v JOIN param_definitions d ON d.id=v.param_id WHERE d.param_code='receipt.confirmation.enabled'")):
raise RuntimeError("已维护人工确认开关版本,禁止删除历史参数")
bind.execute(text("DELETE FROM param_definitions WHERE param_code='receipt.confirmation.enabled'"))
bind.execute(text("UPDATE permissions SET status=0 WHERE perm_code='business:receipts:confirm'"))
for column in ("confirmed_revision", "confirmed_at", "confirmed_by", "confirmation_status"):
op.drop_column("receipts", column)

View File

@ -1,40 +0,0 @@
"""V30 业务接入与核算准备解耦;无默认回填。"""
from pathlib import Path
from alembic import op
from sqlalchemy import text
revision = "30c1d2e3f4a5"
down_revision = "29b0c1d2e3f4"
branch_labels = None
depends_on = None
def upgrade():
script = Path(__file__).resolve().parents[3] / "db/migration/V30__tplus_business_sync.sql"
for statement in script.read_text(encoding="utf-8").split(";\n"):
sql = "\n".join(line for line in statement.splitlines() if not line.lstrip().startswith("--")).strip()
if sql:
op.get_bind().execute(text(sql))
def downgrade():
connection = op.get_bind()
for table, condition in (("tplus_business_documents", "1=1"), ("products", "product_category IS NULL"),
("customers", "customer_type IS NULL"), ("business_items", "product_category IS NULL"),
("shipments", "freight_amount IS NULL")):
if connection.scalar(text(f"SELECT COUNT(*) FROM {table} WHERE {condition}")):
raise RuntimeError("已有接入或待补资料,请关闭接入并回退兼容版本;不删除业务数据以降级结构")
op.drop_table("tplus_business_documents")
op.drop_table("tplus_business_sync_configs")
for index in ("idx_tplus_source_heads_parent", "idx_tplus_source_heads_customer_date", "idx_tplus_source_heads_date"):
op.drop_index(index, "tplus_source_heads")
for column in ("source_parent_id", "source_customer_id", "source_date"):
op.drop_column("tplus_source_heads", column)
op.drop_column("businesses", "integration_json")
for table, column, sql_type, default in (("products", "product_category", "VARCHAR(32)", ""),
("customers", "customer_type", "VARCHAR(32)", " DEFAULT 'personal'"),
("business_items", "product_category", "VARCHAR(32)", ""),
("shipments", "freight_amount", "DECIMAL(14,2)", " DEFAULT 0")):
connection.execute(text(f"ALTER TABLE {table} MODIFY {column} {sql_type} NOT NULL{default}"))

View File

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

View File

@ -1,22 +0,0 @@
"""增加默认停止的 T+ 读取服务控制。"""
from pathlib import Path
from alembic import op
from sqlalchemy import text
revision = "32e3f4a5b6c7"
down_revision = "31d2e3f4a5b6"
branch_labels = None
depends_on = None
def upgrade():
sql = Path(__file__).resolve().parents[3] / "db/migration/V32__tplus_runtime_control.sql"
for statement in sql.read_text(encoding="utf-8").split(";"):
if statement.strip():
op.get_bind().execute(text(statement))
def downgrade():
raise RuntimeError("运行控制包含用户设置,请停止服务并备份后采用应用回退")

View File

@ -1,22 +0,0 @@
"""将 T+ 运行控制表名统一为复数。"""
from pathlib import Path
from alembic import op
from sqlalchemy import text
revision = "33e4f5a6b7c8"
down_revision = "32e3f4a5b6c7"
branch_labels = None
depends_on = None
def upgrade():
sql = Path(__file__).resolve().parents[3] / "db/migration/V33__pluralize_tplus_runtime_control.sql"
for statement in sql.read_text(encoding="utf-8").split(";"):
if statement.strip():
op.get_bind().execute(text(statement))
def downgrade():
raise RuntimeError("运行控制表名迁移不可直接回退,请停止服务并按兼容版本处理")

View File

@ -15,8 +15,7 @@ from fastapi import APIRouter, Depends, Path, Query
from app.api.deps import DbSession, PayrollWriter, RequestMetaDep, get_current_user
from app.core.response import ApiResponse, PageQuery, PageResult, ok
from app.models import Customer, User
from app.schemas.category_batch import CustomerCategoryBatch, CustomerOwnershipBatch
from app.models import User
from app.schemas.customer import (
CustomerCreateIn,
CustomerOut,
@ -34,18 +33,6 @@ router = APIRouter(prefix="/customers", tags=["客户管理"])
CurrentUser = Annotated[User, Depends(get_current_user)]
@router.put("/batch-category")
def batch_category(payload: CustomerCategoryBatch, session: DbSession, actor: PayrollWriter, meta: RequestMetaDep):
from app.services.category_batch import fill_missing
return ok(fill_missing(session, Customer, payload, actor, meta))
@router.put("/batch-ownership")
def batch_ownership(payload: CustomerOwnershipBatch, session: DbSession, actor: PayrollWriter, meta: RequestMetaDep):
return ok(customer_service.batch_change_ownership(session, payload, operator=actor, meta=meta))
@router.get("", response_model=ApiResponse[PageResult[CustomerOut]], summary="客户列表")
def list_customers(
session: DbSession,

View File

@ -16,8 +16,7 @@ from fastapi import APIRouter, Depends, Path, Query
from app.api.deps import DbSession, PayrollWriter, RequestMetaDep, get_current_user
from app.core.response import ApiResponse, PageResult, ok
from app.models import Product, User
from app.schemas.category_batch import ProductCategoryBatch
from app.models import User
from app.schemas.product import (
ProductCreateIn,
ProductExtractIn,
@ -33,13 +32,6 @@ CurrentUser = Annotated[User, Depends(get_current_user)]
router = APIRouter(prefix="/products", tags=["产品管理"])
@router.put("/batch-category")
def batch_category(payload: ProductCategoryBatch, session: DbSession, actor: PayrollWriter, meta: RequestMetaDep):
from app.services.category_batch import fill_missing
return ok(fill_missing(session, Product, payload, actor, meta))
@router.get("/extract/candidates")
def extraction_candidates(session: DbSession, _: CurrentUser, keyword: str = "", page_num: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100)):
from app.services.product_completion import candidates

View File

@ -24,16 +24,7 @@ from app.schemas.business import (
GrossProfitOut,
OwnershipChangeLogOut,
)
from app.schemas.business_source import (
GuidanceConfirmIn,
SalesRelationConfirmIn,
SalesRelationQuery,
SalesRelationReleaseIn,
SourceDocumentsQuery,
SourceMaterialsIn,
SourcePriceConfirmIn,
)
from app.services import business_service, business_source
from app.services import business_service
# 基础数据是业务操作:登录即可,不要求管理员(区别于系统管理的 AdminUser)
CurrentUser = Annotated[User, Depends(get_current_user)]
@ -41,83 +32,6 @@ CurrentUser = Annotated[User, Depends(get_current_user)]
router = APIRouter(prefix="/businesses", tags=["业务管理"])
@router.get("/{business_id}/readiness")
def source_readiness(session: DbSession, _: CurrentUser, business_id: Annotated[int, Path(ge=1)]):
return ok(business_source.readiness(session, business_id))
@router.get("/{business_id}/source-documents")
def source_documents(session: DbSession, _: CurrentUser, business_id: Annotated[int, Path(ge=1)],
query: Annotated[SourceDocumentsQuery, Query()]):
return ok(business_source.documents(session, business_id, query, query.entity))
@router.get("/{business_id}/source-quantities")
def source_quantities(session: DbSession, _: CurrentUser, business_id: Annotated[int, Path(ge=1)],
query: Annotated[PageQuery, Query()]):
return ok(business_source.source_quantities(session, business_id, query))
@router.get("/{business_id}/source-documents/{document_id}/details")
def source_document_details(session: DbSession, _: CurrentUser, business_id: Annotated[int, Path(ge=1)],
document_id: Annotated[int, Path(ge=1)], query: Annotated[PageQuery, Query()]):
return ok(business_source.document_details(session, business_id, document_id, query))
@router.put("/{business_id}/source-documents/{document_id}/missing-price")
def confirm_source_price(session: DbSession, actor: PayrollWriter, business_id: Annotated[int, Path(ge=1)],
document_id: Annotated[int, Path(ge=1)], payload: SourcePriceConfirmIn, meta: RequestMetaDep):
from app.services.authorization_service import require_permission
require_permission(session, actor, "payroll:prepare")
return ok(business_source.confirm_missing_source_price(session, business_id, document_id, payload, actor, meta))
@router.put("/{business_id}/guidance")
def confirm_guidance(session: DbSession, actor: PayrollWriter, business_id: Annotated[int, Path(ge=1)],
payload: GuidanceConfirmIn, meta: RequestMetaDep):
"""从本地发货明细登记来源产品的发货月份指导成本。"""
from app.services.authorization_service import require_permission
require_permission(session, actor, "payroll:prepare")
require_permission(session, actor, "tplus:facts")
return ok(business_source.confirm_guidance(session, business_id, payload, actor, meta))
@router.get("/{business_id}/sales-relation-candidates")
def sales_relation_candidates(session: DbSession, _: CurrentUser, business_id: Annotated[int, Path(ge=1)],
query: Annotated[SalesRelationQuery, Query()]):
return ok(business_source.sales_relation_candidates(session, business_id, query, query.shipment_page, query.order_page))
@router.put("/{business_id}/sales-relation")
def confirm_sales_relation(session: DbSession, actor: PayrollWriter,
business_id: Annotated[int, Path(ge=1)],
payload: SalesRelationConfirmIn, meta: RequestMetaDep):
from app.services.authorization_service import require_permission
from app.services.tplus import sales_relations
require_permission(session, actor, "payroll:prepare")
return ok(sales_relations.confirm(session, business_id, payload, actor, meta))
@router.put("/{business_id}/sales-relation/release")
def release_sales_relation(session: DbSession, actor: PayrollWriter,
business_id: Annotated[int, Path(ge=1)],
payload: SalesRelationReleaseIn, meta: RequestMetaDep):
from app.services.authorization_service import require_permission
from app.services.tplus import sales_relations
require_permission(session, actor, "payroll:prepare")
return ok(sales_relations.release(session, business_id, payload, actor, meta))
@router.put("/{business_id}/source-materials")
def source_materials(session: DbSession, actor: PayrollWriter, business_id: Annotated[int, Path(ge=1)],
payload: SourceMaterialsIn, meta: RequestMetaDep):
return ok(business_source.save_materials(session, business_id, payload, actor, meta))
@router.get("", response_model=ApiResponse[PageResult[BusinessOut]], summary="业务列表")
def list_businesses(
session: DbSession,

View File

@ -13,13 +13,12 @@ from app.api.deps import DbSession, PayrollWriter, RequestMetaDep, get_current_u
from app.core.response import ApiResponse, PageResult, ok
from app.models import User
from app.schemas.receipt import (
ReceiptConfirmIn,
ReceiptCreateIn,
ReceiptOut,
ReceiptQuery,
ReceiptUpdateIn,
)
from app.services import receipt_confirmation, receipt_service
from app.services import receipt_service
# 基础数据是业务操作:登录即可,不要求管理员(区别于系统管理的 AdminUser)
CurrentUser = Annotated[User, Depends(get_current_user)]
@ -27,21 +26,6 @@ CurrentUser = Annotated[User, Depends(get_current_user)]
router = APIRouter(prefix="/receipts", tags=["回款管理"])
@router.get("/confirmation-policy", summary="回款人工确认开关状态")
def confirmation_policy(session: DbSession, _: CurrentUser):
return ok({"enabled": receipt_confirmation.enabled(session)})
@router.post("/{receipt_id}/confirm", response_model=ApiResponse[ReceiptOut], summary="独立人工确认回款")
def confirm_receipt(
receipt_id: Annotated[int, Path(ge=1)], payload: ReceiptConfirmIn,
session: DbSession, current_user: CurrentUser, meta: RequestMetaDep,
):
return ok(receipt_service.confirm_receipt(
session, receipt_id, payload, operator=current_user, meta=meta,
), message="回款已人工确认")
@router.get("", response_model=ApiResponse[PageResult[ReceiptOut]], summary="回款列表")
def list_receipts(
session: DbSession,

View File

@ -11,10 +11,8 @@ router = APIRouter(prefix="/meta", tags=["业务参照"])
@router.get("/payroll-input/{business_id}")
def payroll_metadata(business_id: int, session: DbSession, actor: LoginUser,
page_num: Annotated[int, Query(alias="pageNum", ge=1)] = 1,
page_size: Annotated[int, Query(alias="pageSize", ge=1, le=200)] = 200):
return ok(payroll_options(session, actor, business_id, page_num=page_num, page_size=page_size))
def payroll_metadata(business_id: int, session: DbSession, actor: LoginUser):
return ok(payroll_options(session, actor, business_id))
@router.get("/options")

View File

@ -18,10 +18,8 @@ from app.schemas.tplus import (
TPlusErrorQuery,
TPlusOverviewOut,
TPlusRetryIn,
TPlusRuntimeOut,
TPlusRuntimeUpdate,
)
from app.services.tplus import automation_config, query_service, runtime_control, task_service
from app.services.tplus import automation_config, query_service, task_service
router = APIRouter(prefix="/tplus", tags=["系统管理 · T+ 数据同步"])
router.include_router(workbench_router)
@ -41,17 +39,6 @@ def update_automation(session: DbSession, admin: PayrollWriter, meta: RequestMet
return ok(automation_config.save(session, payload, task_service.TaskActor(admin, meta)))
@router.get("/runtime", response_model=ApiResponse[TPlusRuntimeOut])
def runtime_status(session: DbSession, _: WorkbenchReader):
return ok(runtime_control.overview(session))
@router.put("/runtime", response_model=ApiResponse[TPlusRuntimeOut])
def update_runtime(session: DbSession, admin: PayrollWriter, meta: RequestMetaDep,
payload: TPlusRuntimeUpdate):
return ok(runtime_control.save(session, payload, task_service.TaskActor(admin, meta)))
@router.get("/batches", response_model=ApiResponse[PageResult[TPlusBatchOut]])
def batches(session: DbSession, _: WorkbenchReader, query: Annotated[TPlusBatchQuery, Query()]):
"""分页查询批次元数据。"""

View File

@ -7,46 +7,12 @@ from starlette.responses import StreamingResponse
from app.api.deps import DbSession, PayrollWriter, WorkbenchReader
from app.core.response import ApiResponse, PageQuery, PageResult, ok
from app.schemas.tplus_business_sync import BusinessSyncRun, BusinessSyncUpdate, SyncRange
from app.schemas.tplus_workbench import (
CorrectionPairIn,
FactIn,
SourceChoiceQuery,
WorkCreateIn,
WorkQuery,
)
from app.services.tplus import business_sync_control, workbench
from app.schemas.tplus_workbench import FactIn, SourceChoiceQuery, WorkCreateIn, WorkQuery
from app.services.tplus import workbench
router = APIRouter()
RunNo = Annotated[str, Path(pattern=r"^[A-Za-z0-9_-]+$", max_length=32)]
@router.get("/business-sync")
def business_sync_config(session: DbSession, _: WorkbenchReader):
return ok(business_sync_control.configuration(session))
@router.put("/business-sync")
def business_sync_save(session: DbSession, actor: PayrollWriter, payload: BusinessSyncUpdate):
return ok(business_sync_control.save(session, payload, actor.id))
@router.post("/business-sync/preview")
def business_sync_preview(session: DbSession, _: PayrollWriter, payload: SyncRange):
return ok(business_sync_control.readonly_preview(session, payload.start_date))
@router.post("/business-sync/runs")
def business_sync_run(session: DbSession, actor: PayrollWriter, payload: BusinessSyncRun):
return ok(business_sync_control.queue(session, actor.id, payload.start_date, payload.request_key))
@router.post("/correction-pairs")
def save_correction_pair(session: DbSession, actor: PayrollWriter, payload: CorrectionPairIn):
"""成对排除转客户更正单,将实际回款关联原业务,保留实际到账日。"""
from app.services.tplus.corrections import save_pair
return ok(save_pair(session, payload, actor.id))
@router.get("/facts", response_model=ApiResponse[PageResult[dict]])
def facts(session: DbSession, _: WorkbenchReader, query: Annotated[WorkQuery, Query()]):

View File

@ -175,12 +175,6 @@ def _maintenance(args):
return 0
def _tplus_supervisor(args):
from app.services.tplus.supervisor import run
return run(get_session_factory(), get_settings(), interval=args.poll_seconds)
def _materials(args):
from app.services.tplus.materials import collect_materials, render_markdown
@ -253,8 +247,6 @@ def main() -> int:
p_maintenance.add_argument("--once", action="store_true", help="执行一次检查(默认)")
p_maintenance.add_argument("--connection-file", default=None)
p_maintenance.add_argument("--stop-file", default=None)
p_supervisor = sub.add_parser("tplus-supervisor", help="按页面设置托管 T+ 读取服务")
p_supervisor.add_argument("--poll-seconds", type=int, choices=range(2, 61), default=5)
p_materials = sub.add_parser("tplus-materials", help="只读整理客户样本核对资料,不生成工资")
p_materials.add_argument("--customer-id", type=int, required=True)
p_materials.add_argument("--month", required=True)
@ -270,8 +262,6 @@ def main() -> int:
return _tplus_worker(args)
if args.command == "tplus-maintain":
return _maintenance(args)
if args.command == "tplus-supervisor":
return _tplus_supervisor(args)
if args.command == "tplus-materials":
return _materials(args)
return _stage_tplus(args)

View File

@ -88,14 +88,11 @@ class Settings(BaseSettings):
t_plus_database: str = ""
t_plus_user: str = ""
t_plus_password: str = Field(default="", repr=False)
t_plus_allow_dev_sa: bool = False
t_plus_allow_admin_account: bool = False
t_plus_connection_file: str = ""
# 部署前必须指定已在 SQL Server 2008 R2 实测的驱动,不自动选择最新版。
t_plus_odbc_driver: str = ""
t_plus_encrypt: bool = True
# 默认校验服务器证书;自签回退只允许隔离开发显式开启,避免中间人替换证书。
t_plus_trust_server_certificate: bool = False
t_plus_trust_server_certificate: bool = True
t_plus_connect_timeout: int = 10
t_plus_query_timeout: int = Field(default=120, ge=1, le=600)
t_plus_query_dir: str = ""
@ -103,7 +100,6 @@ class Settings(BaseSettings):
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 = ""
t_plus_supervisor_health_url: str = "http://127.0.0.1:8000/api/health"
@field_validator("app_secret_key")
@classmethod

View File

@ -55,11 +55,8 @@ def resolve_tplus_settings(settings: Settings) -> Settings:
t_plus_password=config.password.get_secret_value(), t_plus_odbc_driver=config.driver
)
resolved = settings.model_copy(update=values)
allow_dev_sa = settings.app_env.strip().casefold() == "dev" and settings.t_plus_allow_dev_sa
if resolved.t_plus_user.strip().casefold() == "sa" and not (
allow_dev_sa or settings.t_plus_allow_admin_account
):
raise RuntimeError("T+ 管理员账号未获授权,请检查连接配置")
if resolved.t_plus_user.strip().casefold() == "sa":
raise RuntimeError("应用必须使用 T+ 专用只读账号,不能使用 sa")
if not resolved.t_plus_database or not resolved.t_plus_user or not resolved.t_plus_odbc_driver:
raise RuntimeError("T+ 只读连接缺少数据库、账号或已验证的 ODBC 驱动")
return resolved

View File

@ -31,12 +31,7 @@ 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 (
TPlusAutomationConfig,
TPlusAutomationState,
TPlusRuntimeControl,
)
from app.models.tplus_business_sync import TPlusBusinessDocument, TPlusBusinessSyncConfig
from app.models.tplus_automation import TPlusAutomationConfig, TPlusAutomationState
from app.models.tplus_sync import (
TPlusSyncBatch,
TPlusSyncError,
@ -96,10 +91,7 @@ __all__ = [
"SystemSetting",
"TPlusAutomationConfig",
"TPlusAutomationState",
"TPlusBusinessDocument",
"TPlusBusinessSyncConfig",
"TPlusFact",
"TPlusRuntimeControl",
"TPlusSourceHead",
"TPlusSourceLink",
"TPlusSyncBatch",

View File

@ -16,7 +16,6 @@ from datetime import date, datetime
from decimal import Decimal
from sqlalchemy import (
JSON,
Date,
DateTime,
ForeignKey,
@ -148,7 +147,6 @@ class Business(Base, TimestampMixin, TPlusSyncMixin):
comment="客户ID(customers.id)",
)
business_date: Mapped[date | None] = mapped_column(Date, comment="业务日期(T+ 同步口径)")
integration_json: Mapped[dict | None] = mapped_column(JSON, comment="T+业务接入范围及来源人员摘要,非核算快照")
status: Mapped[int] = mapped_column(
TINYINT_TYPE,
nullable=False,
@ -241,9 +239,9 @@ class BusinessItem(Base, TimestampMixin):
comment="规格型号(冗余固化;空串对齐 products 组合唯一键口径)",
)
unit: Mapped[str | None] = mapped_column(String(16), comment="计量单位")
product_category: Mapped[str | None] = mapped_column(
product_category: Mapped[str] = mapped_column(
String(32),
nullable=True,
nullable=False,
comment="产品类别(行级折算锚点,混合业务按产品逐条计算,对照表 #14;值域同 PRODUCT_CATEGORIES)",
)
quantity: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False, comment="数量(>0)")

View File

@ -49,9 +49,10 @@ class Customer(Base, TimestampMixin, TPlusSyncMixin):
contact_phone: Mapped[str | None] = mapped_column(String(20), comment="联系电话")
address: Mapped[str | None] = mapped_column(String(255), comment="地址")
industry: Mapped[str | None] = mapped_column(String(64), comment="所属行业(自由文本)")
customer_type: Mapped[str | None] = mapped_column(
customer_type: Mapped[str] = mapped_column(
String(32),
nullable=True,
nullable=False,
server_default="personal",
index=True,
comment="客户分类:personal个人户 manager_personal经理个人户 enterprise企业户(计提规则匹配锚点;T+同步不覆盖)",
)

View File

@ -148,7 +148,7 @@ class PerformanceLedger(Base, TimestampMixin):
TINYINT_TYPE,
nullable=False,
server_default="0",
comment="以物抵顶处理标记:当前系统不自动折算,1表示由线下规则处理 0表示未标记",
comment="以物抵顶折算:1已按 80% 折算 0否",
)
weight_r: Mapped[int | None] = mapped_column(comment="支付权重 R(回款占销售额百分比向下取整)")
payment_weight: Mapped[Decimal | None] = mapped_column(

View File

@ -47,9 +47,9 @@ class Product(Base, TimestampMixin, TPlusSyncMixin):
sale_price: Mapped[Decimal | None] = mapped_column(
Numeric(14, 4), comment="销售单价(元,T+ 同步)"
)
product_category: Mapped[str | None] = mapped_column(
product_category: Mapped[str] = mapped_column(
String(32),
nullable=True,
nullable=False,
comment="产品类别:municipal_water市政水利 rail_transit轨道交通(决定折算阈值 30%/20%;T+同步不覆盖)",
)
status: Mapped[int] = mapped_column(

View File

@ -10,10 +10,10 @@
- 一期禁负数金额(≥0):红字/退款冲销需求出现时先补设计再放开
"""
from datetime import date, datetime
from datetime import date
from decimal import Decimal
from sqlalchemy import Date, DateTime, ForeignKey, Index, Numeric, String
from sqlalchemy import Date, ForeignKey, Index, Numeric, String
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import (
@ -72,7 +72,7 @@ class Receipt(Base, TimestampMixin, TPlusSyncMixin):
TINYINT_TYPE,
nullable=False,
server_default="0",
comment="以物抵顶标记:1是 0否(特殊绩效线下核实,系统不自动折算)",
comment="以物抵顶标记:1是 0否(M3 核算按应发绩效×barter.discount 折算,M2 仅标记)",
)
settlement_status: Mapped[int] = mapped_column(
TINYINT_TYPE,
@ -80,10 +80,4 @@ class Receipt(Base, TimestampMixin, TPlusSyncMixin):
server_default="0",
comment="绩效核算状态:0未核算 1已核算(M3 核算防重锚点;已核算回款禁改禁删)",
)
confirmation_status: Mapped[int] = mapped_column(
TINYINT_TYPE, nullable=False, server_default="0", comment="人工确认状态:0待确认 1已确认,独立于凭证及核算状态"
)
confirmed_by: Mapped[str | None] = mapped_column(String(64), comment="人工确认人账号快照")
confirmed_at: Mapped[datetime | None] = mapped_column(DateTime, comment="人工确认时间")
confirmed_revision: Mapped[str | None] = mapped_column(String(64), comment="人工确认时回款关键内容指纹")
remark: Mapped[str | None] = mapped_column(String(255), comment="备注")

View File

@ -49,9 +49,10 @@ class Shipment(Base, TimestampMixin, TPlusSyncMixin):
ship_date: Mapped[date] = mapped_column(
Date, nullable=False, comment="发货日期(回款天数基准日,发货日首日=第 1 天)"
)
freight_amount: Mapped[Decimal | None] = mapped_column(
freight_amount: Mapped[Decimal] = mapped_column(
Numeric(14, 2),
nullable=True,
nullable=False,
server_default="0",
comment="运费金额(仅记录展示;不计入毛利润口径,对照表 #2)",
)
total_amount: Mapped[Decimal] = mapped_column(

View File

@ -24,24 +24,6 @@ class TPlusAutomationConfig(Base, TimestampMixin):
updated_by: Mapped[int] = mapped_column(BIGINT_UNSIGNED, comment="最近修改账号ID")
class TPlusRuntimeControl(Base, TimestampMixin):
"""单部署的服务期望状态与托管进程报告,默认不启动。"""
__tablename__ = "tplus_runtime_controls"
__table_args__ = mysql_table_args("T+读取服务运行控制")
id: Mapped[int] = mapped_column(BIGINT_UNSIGNED, primary_key=True, autoincrement=False, comment="主键ID")
desired_state: Mapped[str] = mapped_column(String(16), server_default="STOPPED", comment="期望状态:RUNNING/STOPPED")
actual_state: Mapped[str] = mapped_column(String(16), server_default="STOPPED", comment="实际状态:RUNNING/STOPPED/STOPPING/DEGRADED/OFFLINE")
worker_phase: Mapped[str] = mapped_column(String(16), server_default="STOPPED", comment="读取服务阶段")
heartbeat_at: Mapped[datetime | None] = mapped_column(DateTime, comment="最近心跳时间")
last_error: Mapped[str | None] = mapped_column(String(255), comment="最近受控错误")
host: Mapped[str | None] = mapped_column(String(128), comment="运行主机")
platform: Mapped[str | None] = mapped_column(String(32), comment="运行平台")
owner: Mapped[str | None] = mapped_column(String(36), comment="当前托管进程身份")
version: Mapped[int] = mapped_column(Integer, server_default="0", comment="状态版本号")
updated_by: Mapped[int | None] = mapped_column(BIGINT_UNSIGNED, comment="最近修改账号ID")
class TPlusAutomationState(Base, TimestampMixin):
__tablename__ = "tplus_automation_states"
__table_args__ = mysql_table_args("T+自动采集周期与进程状态(不自动导入正式业务)")

View File

@ -1,51 +0,0 @@
"""T+ 业务自动接入:源级开关与单据差异账本,不承载核算结果。"""
from datetime import date
from sqlalchemy import JSON, Date, Integer, String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import BIGINT_UNSIGNED, TINYINT_TYPE, Base, TimestampMixin, mysql_table_args
class TPlusBusinessSyncConfig(Base, TimestampMixin):
"""每个源账套一条设置;启用后才按起点自动排队接入。"""
__tablename__ = "tplus_business_sync_configs"
__table_args__ = mysql_table_args(
"T+业务自动接入设置",
UniqueConstraint("source_database", name="uk_tplus_business_sync_configs_source"),
)
id: Mapped[int] = mapped_column(BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID")
source_database: Mapped[str] = mapped_column(String(128), comment="源账套")
enabled: Mapped[int] = mapped_column(TINYINT_TYPE, server_default="0", comment="自动接入是否启用:1启用0关闭")
start_date: Mapped[date] = mapped_column(Date, comment="业务接入起点")
version: Mapped[int] = mapped_column(Integer, server_default="0", comment="配置乐观版本")
updated_by: Mapped[int] = mapped_column(BIGINT_UNSIGNED, comment="授权启用账号")
last_error: Mapped[str | None] = mapped_column(String(64), comment="后台接入暂停原因")
class TPlusBusinessDocument(Base, TimestampMixin):
"""源单线与正式单据的对应关系与差异结论;重复同步只更新本表。"""
__tablename__ = "tplus_business_documents"
__table_args__ = mysql_table_args(
"T+业务单据接入与差异账本",
UniqueConstraint(
"source_database", "entity_type", "source_id",
name="uk_tplus_business_documents_source_entity_id",
),
)
id: Mapped[int] = mapped_column(BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID")
source_database: Mapped[str] = mapped_column(String(128), comment="源账套")
entity_type: Mapped[str] = mapped_column(String(32), comment="源实体")
source_id: Mapped[str] = mapped_column(String(64), comment="源单据或明细ID")
source_no: Mapped[str | None] = mapped_column(String(128), comment="原始单号")
record_id: Mapped[int] = mapped_column(BIGINT_UNSIGNED, comment="当前观察到的源快照ID")
applied_record_id: Mapped[int | None] = mapped_column(BIGINT_UNSIGNED, comment="已接入的源快照ID")
fingerprint: Mapped[str | None] = mapped_column(String(64), comment="已应用的业务字段指纹")
business_id: Mapped[int | None] = mapped_column(BIGINT_UNSIGNED, comment="关联业务ID")
target_id: Mapped[int | None] = mapped_column(BIGINT_UNSIGNED, comment="正式单据或明细ID")
status: Mapped[str] = mapped_column(String(24), default="PENDING", comment="APPLIED/PENDING/CONFLICT/EXCLUDED")
reason: Mapped[str | None] = mapped_column(String(128), comment="固定待补或差异编码")
data_json: Mapped[dict] = mapped_column(JSON, comment="最小必要来源与差异说明")

View File

@ -21,9 +21,6 @@ class TPlusSourceHead(Base):
name="uk_tplus_source_heads_source_entity_id",
),
Index("idx_tplus_source_heads_batch_entity", "batch_no", "entity_type"),
Index("idx_tplus_source_heads_parent", "source_database", "entity_type", "source_parent_id"),
Index("idx_tplus_source_heads_customer_date", "source_database", "entity_type", "source_customer_id", "source_date"),
Index("idx_tplus_source_heads_date", "source_database", "entity_type", "source_date", "id"),
)
id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
@ -32,9 +29,6 @@ class TPlusSourceHead(Base):
entity_type: Mapped[str] = mapped_column(String(32), comment="实体类型")
source_id: Mapped[str] = mapped_column(String(64), comment="源内部ID")
record_id: Mapped[int] = mapped_column(BIGINT_UNSIGNED, comment="原始快照ID")
source_parent_id: Mapped[str | None] = mapped_column(String(64), comment="源父单据ID查询投影")
source_customer_id: Mapped[str | None] = mapped_column(String(64), comment="源客户ID查询投影")
source_date: Mapped[str | None] = mapped_column(String(10), comment="源业务日期查询投影")
batch_no: Mapped[str] = mapped_column(String(32), comment="最后观察批次")

View File

@ -15,21 +15,15 @@ from app.models import (
Customer,
OwnershipChangeLog,
Personnel,
Product,
Receipt,
SecondaryAllocation,
Shipment,
TPlusBusinessDocument,
)
from app.models.customer import OWNER_TYPE_BUSINESS
from app.repositories import ownership_log_repository
from app.repositories.ownership_log_repository import OwnershipLogRef
from app.schemas.business import BusinessQuery
READINESS_SQL_REASONS = frozenset({
"CUSTOMER_CATEGORY_PENDING", "PRODUCT_CATEGORY_PENDING", "NO_ACTUAL_SHIPMENT", "SOURCE_CONFLICT",
})
def get_by_id(session: Session, business_id: int, *, for_update: bool = False) -> Business | None:
"""按主键取业务行;for_update 供删除保护、编辑门槛与状态机使用。"""
@ -104,40 +98,9 @@ def _apply_filters(stmt: Select, query: BusinessQuery) -> Select:
stmt = stmt.where(Business.business_date >= query.date_from)
if query.date_to is not None:
stmt = stmt.where(Business.business_date <= query.date_to)
if query.source_type == "TPLUS":
stmt = stmt.where(Business.t_plus_id.is_not(None))
elif query.source_type == "MANUAL":
stmt = stmt.where(Business.t_plus_id.is_(None))
if query.source_personnel:
stmt = stmt.where(Business.integration_json["personnel_names"].as_string().contains(query.source_personnel, autoescape=True))
if query.readiness_reason in READINESS_SQL_REASONS:
stmt = stmt.where(Business.integration_json["source_database"].as_string().is_not(None))
if query.readiness_reason == "CUSTOMER_CATEGORY_PENDING":
stmt = stmt.where(Customer.customer_type.is_(None))
elif query.readiness_reason == "PRODUCT_CATEGORY_PENDING":
stmt = stmt.where(exists(select(BusinessItem.id).join(Product, Product.id == BusinessItem.product_id).where(
BusinessItem.business_id == Business.id, Product.product_category.is_(None))))
elif query.readiness_reason == "NO_ACTUAL_SHIPMENT":
stmt = stmt.where(~exists().where(Shipment.business_id == Business.id))
elif query.readiness_reason == "SOURCE_CONFLICT":
stmt = stmt.where(exists().where(TPlusBusinessDocument.business_id == Business.id,
TPlusBusinessDocument.status.in_(("CONFLICT", "PENDING"))))
return stmt
def scan_businesses(session: Session, query: BusinessQuery, before_id: int | None, limit: int = 100):
"""按主键倒序分批读取,供需要计算准备状态的筛选使用。"""
owner = aliased(Personnel)
assistant = aliased(Personnel)
stmt = select(Business, Customer.company_name, owner.personnel_name, assistant.personnel_name).join(
Customer, Customer.id == Business.customer_id)
stmt = _apply_filters(stmt, query).outerjoin(owner, owner.id == Business.owner_personnel_id).outerjoin(
assistant, assistant.id == Business.assistant_personnel_id)
if before_id is not None:
stmt = stmt.where(Business.id < before_id)
return list(session.execute(stmt.order_by(Business.id.desc()).limit(limit)).all())
def list_businesses(
session: Session, query: BusinessQuery
) -> list[tuple[Business, str, str | None, str | None]]:

View File

@ -114,12 +114,8 @@ def _apply_filters(stmt: Select, query: CustomerQuery) -> Select:
stmt = stmt.where(_keyword_clause(query.keyword))
if query.customer_type:
stmt = stmt.where(Customer.customer_type == query.customer_type)
if query.category_missing:
stmt = stmt.where(Customer.customer_type.is_(None))
if query.owner_personnel_id is not None:
stmt = stmt.where(Customer.owner_personnel_id == query.owner_personnel_id)
if query.owner_missing:
stmt = stmt.where(Customer.owner_personnel_id.is_(None))
return stmt

View File

@ -66,8 +66,6 @@ def _apply_filters(stmt: Select, query: ProductQuery) -> Select:
)
if query.product_category:
stmt = stmt.where(Product.product_category == query.product_category)
if query.category_missing:
stmt = stmt.where(Product.product_category.is_(None))
if query.status is not None:
stmt = stmt.where(Product.status == query.status)
return stmt

View File

@ -57,8 +57,6 @@ def _apply_filters(stmt: Select, query: ReceiptQuery) -> Select:
# settlement_status 是 M3 核算防重锚点(需规 2.3.3),必须保持具名字段
if query.settlement_status is not None:
stmt = stmt.where(Receipt.settlement_status == query.settlement_status)
if query.confirmation_status is not None:
stmt = stmt.where(Receipt.confirmation_status == query.confirmation_status)
return stmt

View File

@ -217,16 +217,6 @@ def count_business_stats(session: Session, query: BusinessStatsQuery) -> int:
return int(session.execute(stmt).scalar_one())
def list_source_business_sales(session: Session, query: BusinessStatsQuery, *, after_id: int, limit: int):
"""键集分页读取筛选范围内的来源业务,不加载完整业务或用户数据。"""
stmt = (select(Business.id, Business.total_sale_amount).select_from(Business)
.outerjoin(Customer, Customer.id == Business.customer_id)
.where(*_business_filters(query),
Business.integration_json["source_database"].as_string().is_not(None),
Business.id > after_id).order_by(Business.id).limit(limit))
return list(session.execute(stmt))
def sum_business_stats(session: Session, query: BusinessStatsQuery) -> dict:
"""业务统计全筛选合计(业务数/发货/回款/销售额/绩效/应付)。"""
perf_sq = _business_perf_subquery()

View File

@ -9,9 +9,8 @@
from sqlalchemy import Select, func, or_, select
from sqlalchemy.orm import Session
from app.models import Permission, Role, RolePermission, UserRole
from app.models import Role, UserRole
from app.schemas.role import RoleQuery
from app.services.permission_catalog import CATALOG
# ==================== 读取 ====================
@ -98,19 +97,6 @@ def count_users_by_role(session: Session, role_ids: list[int]) -> dict[int, int]
return {int(role_id): int(count) for role_id, count in session.execute(stmt)}
def count_permissions_by_role(session: Session, role_ids: list[int]) -> dict[int, int]:
"""批量统计角色已配置的权限,避免列表逐行查询。"""
if not role_ids:
return {}
stmt = (
select(RolePermission.role_id, func.count())
.join(Permission, Permission.id == RolePermission.permission_id)
.where(RolePermission.role_id.in_(role_ids), Permission.perm_code.in_(CATALOG))
.group_by(RolePermission.role_id)
)
return {int(role_id): int(count) for role_id, count in session.execute(stmt)}
# ==================== 写入 ====================

View File

@ -126,7 +126,7 @@ class BusinessItemOut(BaseModel):
product_name: str
spec_model: str | None = None
unit: str | None = None
product_category: str | None = None
product_category: str
quantity: str
sale_price: str | None = None
sale_amount: str
@ -158,12 +158,6 @@ class BusinessOut(BaseModel):
total_received_amount: str | None = None
total_freight_amount: str | None = None
remark: str | None = None
source_type: str = "MANUAL"
source_no: str | None = None
source_personnel: list[str] = Field(default_factory=list)
integration_status: str | None = None
readiness_status: str | None = None
readiness_reasons: list[dict[str, str]] = Field(default_factory=list)
class SecondaryAllocationIn(BaseModel):
@ -337,18 +331,13 @@ class BusinessQuery(PageQuery):
owner_personnel_id: int | None = Field(default=None, ge=1, description="负责营销人员筛选")
date_from: date | None = Field(default=None, description="业务日期起(含)")
date_to: date | None = Field(default=None, description="业务日期止(含)")
source_type: str | None = Field(default=None, pattern=r"^(MANUAL|TPLUS)$")
source_personnel: str | None = Field(default=None, max_length=64)
pending_only: bool = False
readiness_status: str | None = Field(default=None, pattern=r"^(READY|PENDING)$")
readiness_reason: str | None = Field(default=None, max_length=64, pattern=r"^[A-Z_]+$")
class GrossProfitRowOut(BaseModel):
"""毛利润视图行:行级源数据 + 派生值(实时计算,不落库)。"""
item_id: int
product_category: str | None = None
product_category: str
product_name: str
spec_model: str | None = None
quantity: str
@ -357,7 +346,7 @@ class GrossProfitRowOut(BaseModel):
"""成本缺失(未录入)时为 None,派生列同为 None 并置 missing_reason"""
profit_amount: str | None = None
profit_rate: str | None = None
threshold: str | None = None
threshold: str
converted_profit: str | None = None
missing_reason: str | None = None

View File

@ -1,109 +0,0 @@
"""来源业务的人工分类资料。"""
from decimal import Decimal, InvalidOperation
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field, model_validator
from app.core.response import PageQuery
class SalesRelationQuery(PageQuery):
shipment_page: int = Field(default=1, ge=1, le=10000)
order_page: int = Field(default=1, ge=1, le=10000)
class SourceDocumentsQuery(PageQuery):
entity: str | None = Field(default=None, max_length=32)
class SourcePriceConfirmIn(BaseModel):
model_config = ConfigDict(extra="forbid")
revision: str = Field(pattern=r"^[0-9a-f]{64}$")
evidence: str = Field(min_length=1, max_length=500)
@model_validator(mode="after")
def validate_evidence(self):
self.evidence = self.evidence.strip()
if not self.evidence:
raise ValueError("核对依据不能为空")
return self
class GuidanceConfirmIn(BaseModel):
"""从业务发货明细登记对应发货月份的单位指导成本。"""
model_config = ConfigDict(extra="forbid")
shipment_item_id: int = Field(ge=1)
amount: str = Field(min_length=1, max_length=32)
evidence: str = Field(min_length=1, max_length=500)
@model_validator(mode="after")
def validate_values(self):
self.evidence = self.evidence.strip()
if not self.evidence:
raise ValueError("核对依据不能为空")
try:
value = Decimal(self.amount)
except (InvalidOperation, TypeError, ValueError):
raise ValueError("指导成本必须为有效数字") from None
if not value.is_finite() or value < 0 or value.as_tuple().exponent < -4:
raise ValueError("指导成本最多保留4位小数且不能为负数")
return self
class SourceProductCategory(BaseModel):
product_id: int = Field(ge=1)
category: Literal["municipal_water", "rail_transit"]
class SourceMaterialsIn(BaseModel):
model_config = ConfigDict(extra="forbid")
customer_type: Literal["personal", "manager_personal", "enterprise"] | None = None
products: list[SourceProductCategory] = Field(default_factory=list, max_length=200)
@model_validator(mode="after")
def unique_products(self):
if len({p.product_id for p in self.products}) != len(self.products):
raise ValueError("产品不能重复")
if self.customer_type is None and not self.products:
raise ValueError("请选择要补充的分类")
return self
class SalesRelationConfirmIn(BaseModel):
model_config = ConfigDict(extra="forbid")
item_id: int = Field(ge=1)
shipment_document_id: int = Field(ge=1)
order_document_id: int | None = Field(default=None, ge=1)
disposition: Literal["ORDER", "INDEPENDENT"]
item_revision: str = Field(pattern=r"^[0-9a-f]{64}$")
shipment_revision: str = Field(pattern=r"^[0-9a-f]{64}$")
order_revision: str | None = Field(default=None, pattern=r"^[0-9a-f]{64}$")
expected_version: int = Field(default=0, ge=0)
evidence: str = Field(min_length=1, max_length=500)
@model_validator(mode="after")
def validate_relation(self):
self.evidence = self.evidence.strip()
if not self.evidence:
raise ValueError("确认依据不能为空")
if self.disposition == "ORDER" and (self.order_document_id is None or self.order_revision is None):
raise ValueError("请选择并核对订单明细")
if self.disposition == "INDEPENDENT" and (self.order_document_id is not None or self.order_revision is not None):
raise ValueError("独立发货不应指定订单明细")
return self
class SalesRelationReleaseIn(BaseModel):
model_config = ConfigDict(extra="forbid")
item_id: int = Field(ge=1)
expected_version: int = Field(ge=1)
evidence: str = Field(min_length=1, max_length=500)
@model_validator(mode="after")
def validate_evidence(self):
self.evidence = self.evidence.strip()
if not self.evidence:
raise ValueError("撤销依据不能为空")
return self

View File

@ -43,18 +43,12 @@ class CalcTemplateOptionOut(BaseModel):
class FormulaOptionOut(BaseModel):
"""公式骨架下拉项(新增模板时选择,受控集合)。
role_labels / role_hints 把英文角色键翻译成业务可读的中文标签与提示,
模板表单据此渲染每个参数槽位的标签与 placeholder。
"""
"""公式骨架下拉项(新增模板时选择,受控集合)。"""
code: str
name: str
description: str
roles: list[str]
role_labels: dict[str, str] = Field(default_factory=dict)
role_hints: dict[str, str] = Field(default_factory=dict)
class CalcTemplateCreateIn(BaseModel):

View File

@ -1,30 +0,0 @@
"""基础档案待确认分类的批量补充。"""
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field, field_validator
class CategoryBatch(BaseModel):
model_config = ConfigDict(extra="forbid")
ids: list[int] = Field(min_length=1, max_length=200)
@field_validator("ids")
@classmethod
def validate_ids(cls, value):
if any(item < 1 for item in value) or len(value) != len(set(value)):
raise ValueError("档案 ID 必须为不重复的正整数")
return value
class CustomerCategoryBatch(CategoryBatch):
category: Literal["personal", "manager_personal", "enterprise"]
class ProductCategoryBatch(CategoryBatch):
category: Literal["municipal_water", "rail_transit"]
class CustomerOwnershipBatch(CategoryBatch):
new_owner_personnel_id: int = Field(ge=1)
reason: str | None = Field(default=None, max_length=255)

View File

@ -25,7 +25,7 @@ class CustomerOut(BaseModel):
contact_phone: str | None = None
address: str | None = None
industry: str | None = None
customer_type: str | None = None
customer_type: str
owner_personnel_id: int | None = None
owner_personnel_name: str | None = None
remark: str | None = None
@ -104,9 +104,7 @@ class CustomerQuery(PageQuery):
default=None, max_length=64, description="公司名称或客户负责人,模糊匹配"
)
customer_type: str | None = Field(default=None, max_length=32, description="客户分类筛选")
category_missing: bool = Field(default=False, description="仅显示客户分类待确认")
owner_personnel_id: int | None = Field(default=None, ge=1, description="归属人员ID筛选")
owner_missing: bool = Field(default=False, description="仅显示归属待确认")
order_by: Literal["id", "rating_score"] = Field(
default="id", description="排序键:id 建档序 / rating_score 评级总分"
)

View File

@ -24,14 +24,6 @@ class ShipmentOwnershipIn(BaseModel):
personnel_id: int = Field(ge=1)
manager_id: int | None = Field(default=None, ge=1)
unit_cost: str
freight_amount: str | None = None
@field_validator("freight_amount")
@classmethod
def check_freight(cls, value):
if value is not None:
_decimal(value, 2)
return value
@field_validator("unit_cost")
@classmethod

View File

@ -23,7 +23,7 @@ class ProductOut(BaseModel):
unit: str | None = None
stock_quantity: str | None = None
sale_price: str | None = None
product_category: str | None = None
product_category: str
status: int = 1
remark: str | None = None
create_time: datetime
@ -96,7 +96,6 @@ class ProductQuery(PageQuery):
keyword: str | None = Field(default=None, max_length=64, description="名称或规格,模糊匹配")
product_category: str | None = Field(default=None, max_length=32, description="按类别筛选")
category_missing: bool = Field(default=False, description="仅显示产品分类待确认")
status: int | None = Field(default=None, ge=0, le=1, description="状态筛选")

View File

@ -8,7 +8,7 @@ payment_days 是派生值(发货日 → 回款日的天数,对照表 #1)
后无需回写历史。
"""
from datetime import date, datetime
from datetime import date
from pydantic import BaseModel, ConfigDict, Field, field_validator
@ -86,10 +86,6 @@ class ReceiptOut(BaseModel):
amount: str
is_barter: int
settlement_status: int
confirmation_status: int = 0
confirmed_by: str | None = None
confirmed_at: datetime | None = None
confirmation_revision: str = ""
payment_days: int | None = None
remark: str | None = None
@ -104,9 +100,3 @@ class ReceiptQuery(PageQuery):
settlement_status: int | None = Field(
default=None, ge=0, le=1, description="核算状态筛选:0未核算 1已核算"
)
confirmation_status: int | None = Field(default=None, ge=0, le=1, description="人工确认状态筛选")
class ReceiptConfirmIn(BaseModel):
model_config = ConfigDict(extra="forbid")
expected_revision: str = Field(pattern=r"^[0-9a-f]{64}$", description="页面核对的回款关键内容指纹")

View File

@ -114,7 +114,6 @@ class ReportSummary(BaseModel):
ship_total: Decimal | None = Field(default=None, description="发货额合计")
received_total: Decimal | None = Field(default=None, description="回款额合计")
sale_total: Decimal | None = Field(default=None, description="销售额合计")
sale_pending_count: int = Field(default=0, description="销售额待核对业务数")
class ReportPage[T](BaseModel):

View File

@ -69,7 +69,7 @@ class ShipmentOut(BaseModel):
business_id: int
business_no: str | None = None
ship_date: date
freight_amount: str | None = None
freight_amount: str
total_amount: str
remark: str | None = None

View File

@ -129,24 +129,6 @@ class TPlusAutomationUpdate(BaseModel):
expected_version: int = Field(ge=0, strict=True)
class TPlusRuntimeUpdate(BaseModel):
desired_state: Literal["RUNNING", "STOPPED"]
expected_version: int = Field(ge=0, strict=True)
class TPlusRuntimeOut(BaseModel):
desired_state: str = "STOPPED"
actual_state: str = "OFFLINE"
worker_phase: str = "UNKNOWN"
heartbeat_at: datetime | None = None
last_error: str | None = None
host: str | None = None
platform: str | None = None
version: int = 0
updated_by: int | None = None
editable: bool = False
class TPlusAutomationOverview(BaseModel):
enabled: bool = False
version: int = 0

View File

@ -1,27 +0,0 @@
"""业务接入边界:独立于核算月份和内部平账组。"""
from datetime import date
from uuid import UUID
from pydantic import BaseModel, ConfigDict, Field, field_validator
class SyncRange(BaseModel):
model_config = ConfigDict(extra="forbid")
start_date: date = date(2026, 3, 1)
@field_validator("start_date")
@classmethod
def check_start(cls, value):
if value < date(2026, 3, 1):
raise ValueError("本次接入从2026年3月起,更早欠款通过期初承接")
return value
class BusinessSyncUpdate(SyncRange):
enabled: bool
expected_version: int = Field(ge=0)
class BusinessSyncRun(SyncRange):
request_key: UUID

View File

@ -2,13 +2,11 @@
from datetime import date
from decimal import Decimal, InvalidOperation
from itertools import pairwise
from typing import Literal
from uuid import UUID
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from pydantic import BaseModel, ConfigDict, Field, model_validator
from app.core.month import validate_month
from app.core.response import PageQuery
@ -18,39 +16,11 @@ class FactData(BaseModel):
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
class RolePeriod(FactData):
"""客户只确认到月份时保留月份精度,区间含首尾月份。"""
start_month: str
end_month: str
role_code: Literal["salesperson", "sales_manager"]
@field_validator("start_month", "end_month")
@classmethod
def check_month(cls, value):
return validate_month(value)
@model_validator(mode="after")
def check_range(self):
if self.end_month < self.start_month:
raise ValueError("岗位结束月份不能早于开始月份")
return self
class PersonFact(FactData):
code: str = Field(min_length=1, max_length=64)
name: str = Field(min_length=1, max_length=64)
role_code: Literal["salesperson", "sales_manager"]
status: Literal[0, 1]
role_history: list[RolePeriod] = Field(default_factory=list, max_length=120)
@model_validator(mode="after")
def check_role_history(self):
periods = sorted(self.role_history, key=lambda period: period.start_month)
if any(left.end_month >= right.start_month for left, right in pairwise(periods)):
raise ValueError("历史岗位月份不能重叠")
self.role_history = periods
return self
class ProductFact(FactData):
@ -60,7 +30,7 @@ class ProductFact(FactData):
class CustomerFact(FactData):
customer_type: Literal["personal", "manager_personal", "enterprise"]
owner_name: str = Field(default="", max_length=64)
owner_name: str = Field(min_length=1, max_length=64)
class GroupFact(FactData):
@ -173,10 +143,6 @@ class CorrectionFact(FactData):
pair_key: str = Field(min_length=1, max_length=128)
exclude_performance: bool = False
redirect_business_key: str | None = Field(default=None, max_length=128)
paired_source_id: str | None = Field(default=None, pattern=r"^[0-9]+$", max_length=64)
source_hash: str | None = Field(default=None, min_length=64, max_length=64)
paired_hash: str | None = Field(default=None, min_length=64, max_length=64)
details_hash: str | None = Field(default=None, min_length=64, max_length=64)
@model_validator(mode="after")
def check_correction(self):
@ -189,26 +155,6 @@ class CorrectionFact(FactData):
return self
class CorrectionPairIn(FactData):
"""一次登记完整更正对及实际回款,不能单边排除。"""
negative_shipment_id: str = Field(pattern=r"^[0-9]+$", max_length=64)
positive_shipment_id: str = Field(pattern=r"^[0-9]+$", max_length=64)
receipt_ids: list[str] = Field(default_factory=list, max_length=100)
target_group_key: str = Field(min_length=1, max_length=128, pattern=r"^[A-Za-z0-9_.:-]+$")
evidence: str = Field(min_length=1, max_length=500)
@model_validator(mode="after")
def check_members(self):
if self.negative_shipment_id == self.positive_shipment_id:
raise ValueError("正负更正单不能为同一张单据")
if len(set(self.receipt_ids)) != len(self.receipt_ids) or any(
not v.isascii() or not v.isdigit() for v in self.receipt_ids
):
raise ValueError("实际回款必须使用不重复的源数字标识")
return self
FACT_TYPES = {
"PERSONNEL": PersonFact,
"PRODUCT": ProductFact,

View File

@ -24,10 +24,6 @@ logger = logging.getLogger(__name__)
# 攻击者据此无法判断某个用户名是否真实存在。
INVALID_CREDENTIALS = "用户名或密码错误"
# 与真实库哈希同算法、同轮次的假哈希。用户不存在时也执行一次校验,
# 让两条失败路径耗时接近,避免响应时间暴露有效用户名。
_DUMMY_PASSWORD_HASH = "$2b$12$T7f7tDu4ljCgkXigiJ0EHea3ik07F0Dqpv2B1rlUF1FljlylH68Um"
def _record(
session: Session,
@ -107,10 +103,7 @@ def login(
# ---- 3. 凭据校验 ----
user = user_repository.get_by_username(session, username)
credentials_ok = verify_password(
password, user.password if user is not None else _DUMMY_PASSWORD_HASH
)
if user is None or not credentials_ok:
if user is None or not verify_password(password, user.password):
login_limiter.record_failure(username)
_record(
session,

View File

@ -135,16 +135,6 @@ def validate_role_permissions(permission_codes):
return codes
def active_permission_ids(session, codes):
"""核验目录对应的启用记录齐全,避免保存不可生效的授权。"""
ids = dict(session.execute(select(Permission.perm_code, Permission.id).where(
Permission.perm_code.in_(codes), Permission.status == 1
)).all())
if set(ids) != codes:
raise BadRequestError("权限目录尚未就绪,请联系管理员")
return ids
def save_role(session, role_id, payload, actor, meta=None):
audit_data = payload.model_dump(mode="json")
with audit_service.audit(session, operator=actor, meta=meta, record=AuditRecord(
@ -157,10 +147,10 @@ def save_role(session, role_id, payload, actor, meta=None):
if role.role_code == "admin":
raise ForbiddenError("管理员角色权限固定,不能修改")
codes = validate_role_permissions(payload.permission_codes)
ids = active_permission_ids(session, codes)
audit_data["before"] = role_permissions(session, role.id)["permission_codes"]
audit_data["after"] = sorted(codes)
session.execute(delete(RolePermission).where(RolePermission.role_id == role.id))
ids = dict(session.execute(select(Permission.perm_code, Permission.id)).all())
session.add_all([RolePermission(role_id=role.id, permission_id=ids[c]) for c in codes])
bump(policy, actor)
session.flush()

View File

@ -1,5 +1,5 @@
"""业务表单参照只返回必要字段,不借下拉权限开放整个资料接口。"""
from sqlalchemy import func, select
from sqlalchemy import select
from app.core.exceptions import BadRequestError, ForbiddenError
from app.models import (
@ -49,75 +49,28 @@ OPTIONS = {
}
def payroll_options(session, actor, business_id, *, page_num=1, page_size=200):
def payroll_options(session, actor, business_id):
"""核算资料所需的人员及发货明细,不开放人员薪酬或联系方式。"""
from app.core.exceptions import NotFoundError
from app.models import ShipmentItem, TPlusBusinessDocument, TPlusSourceLink, TPlusSyncRecord
from app.models import ShipmentItem
from app.services.authorization_service import require_permission
from app.services.tplus.guidance import latest_guidance_facts, resolve_guidance
if page_num < 1 or not 1 <= page_size <= 200:
raise ValueError("无效的核算参照分页")
require_permission(session, actor, "payroll:view")
business = session.get(Business, business_id)
if business is None:
if session.get(Business, business_id) is None:
raise NotFoundError("业务不存在")
source_customer = (business.integration_json or {}).get("customer_source_id")
offset = (page_num - 1) * page_size
people = session.execute(select(Personnel.id, Personnel.personnel_name).order_by(Personnel.id)
.offset(offset).limit(page_size)).mappings()
lines_query = select(ShipmentItem.id, Shipment.shipment_no, Shipment.ship_date,
ShipmentItem.product_name, ShipmentItem.amount, ShipmentItem.unit).join(
Shipment, Shipment.id == ShipmentItem.shipment_id).where(Shipment.business_id == business_id)
lines = list(session.execute(lines_query.order_by(Shipment.id, ShipmentItem.id).offset(offset).limit(page_size)).mappings())
source = (business.integration_json or {}).get("source_database")
candidates = {}
if source and lines:
documents = session.scalars(select(TPlusBusinessDocument).where(
TPlusBusinessDocument.business_id == business_id,
TPlusBusinessDocument.source_database == source,
TPlusBusinessDocument.entity_type == "shipment_items",
TPlusBusinessDocument.status == "APPLIED",
TPlusBusinessDocument.target_id.in_([row["id"] for row in lines]),
))
for document in documents:
candidates.setdefault(document.target_id, []).append(document)
person_sources = {str(data["source_personnel_id"]) for values in candidates.values() for doc in values
if (data := doc.data_json or {}).get("source_personnel_id") is not None}
links = dict(session.execute(select(TPlusSourceLink.source_id, Personnel.id).join(
Personnel, Personnel.id == TPlusSourceLink.target_id).where(
TPlusSourceLink.source_database == source, TPlusSourceLink.entity_type == "personnel",
TPlusSourceLink.source_id.in_(person_sources))).all()) if person_sources else {}
record_ids = {doc.record_id for values in candidates.values() for doc in values}
records = {r.id: r for r in session.scalars(select(TPlusSyncRecord).where(
TPlusSyncRecord.id.in_(record_ids)))} if record_ids else {}
guidance_facts = latest_guidance_facts(session, source) if record_ids else {}
def source_details(row):
matches = candidates.get(row["id"], [])
document = matches[0] if len(matches) == 1 else None
data = (document.data_json or {}) if document else {}
source_id = data.get("source_personnel_id")
record = records.get(document.record_id) if document else None
product_id = record.payload_json.get("idinventory") if record else None
status, guidance = resolve_guidance(guidance_facts, str(product_id), document.source_id,
row["ship_date"].strftime("%Y-%m"), row["unit"]) if product_id is not None else ("MISSING", None)
if guidance:
status = "FINALIZED_" + guidance["basis"]
return {"source_personnel_name": data.get("source_personnel_name"),
"matched_personnel_id": links.get(str(source_id)) if source_id is not None else None,
"unit": row["unit"],
"guidance_status": status,
"guidance_unit_cost": guidance["amount"] if status == "FINALIZED_UNIT" else None,
"guidance_line_total": guidance["amount"] if status == "FINALIZED_LINE_TOTAL" else None}
return {"business_key": f"customer:{source_customer}" if source_customer is not None else None,
"personnel": [dict(row) for row in people],
"personnel_total": session.scalar(select(func.count()).select_from(Personnel)),
"shipments_total": session.scalar(select(func.count()).select_from(ShipmentItem).join(
Shipment, Shipment.id == ShipmentItem.shipment_id).where(Shipment.business_id == business_id)),
"shipments": [{"id": row["id"], "shipment_no": row["shipment_no"],
"ship_date": str(row["ship_date"]), "product_name": row["product_name"],
"amount": str(row["amount"]), **source_details(row)} for row in lines]}
heads = list(session.scalars(select(Shipment).where(Shipment.business_id == business_id).order_by(Shipment.id).limit(201)))
if len(heads) > 200:
raise BadRequestError("单笔业务超过 200 张发货单,请分批核对资料")
people = [dict(row) for row in session.execute(select(Personnel.id, Personnel.personnel_name).order_by(Personnel.id).limit(201)).mappings()]
if len(people) > 200:
raise BadRequestError("核算人员超过单次范围,请按实际归属分批处理")
lines = session.execute(select(ShipmentItem.id, ShipmentItem.shipment_id, ShipmentItem.product_name, ShipmentItem.amount).where(ShipmentItem.shipment_id.in_([h.id for h in heads]))).mappings()
by_head = {head.id: head for head in heads}
return {"personnel": people, "shipments": [
{"id": row["id"], "shipment_no": by_head[row["shipment_id"]].shipment_no,
"ship_date": str(by_head[row["shipment_id"]].ship_date), "product_name": row["product_name"], "amount": str(row["amount"])}
for row in lines
]}
def options(session, actor, resource, *, business_id=None, search=None):

View File

@ -39,9 +39,8 @@ def permission_for(module, name, method, *, params=None, body=None):
if method == "GET":
return resource + ":view"
special = {"set_status": "status", "change_status": "status", "reset_password": "password",
"confirm_receipt": "confirm",
"replace_regions": "regions", "replace_plans": "plans", "rate_customer": "rating",
"change_ownership": "ownership", "batch_ownership": "ownership", "upload_logo": "update"}
"change_ownership": "ownership", "upload_logo": "update"}
action = special.get(name) or {"POST": "create", "PUT": "update", "DELETE": "delete"}.get(method)
return resource + ":" + action if action else None
if module == "system.params":
@ -58,15 +57,11 @@ def permission_for(module, name, method, *, params=None, body=None):
if module == "system.tplus":
return "tplus:" + ("view" if method == "GET" else "retry" if name == "retry" else "collect")
if module == "system.tplus_workbench":
if name == "business_sync_preview":
return "tplus:preview"
if name.startswith("business_sync_"):
return "tplus:view" if method == "GET" else "tplus:import"
if name == "work_report":
return "tplus:export"
if method == "GET":
return "tplus:view"
if name in ("save_fact", "save_correction_pair"):
if name == "save_fact":
return "tplus:facts"
if name == "create_work":
return "tplus:import" if body.get("kind") in ("MASTER_APPLY", "BUSINESS_APPLY") else "tplus:preview"

View File

@ -106,16 +106,12 @@ def _to_out(row: tuple) -> BusinessOut:
assistant_personnel_name=assistant_name,
is_special=business.is_special,
special_note=business.special_note,
total_sale_amount=None if business.integration_json and business.total_sale_amount is None else _str_or_zero(business.total_sale_amount),
total_cost_amount=None if business.integration_json and business.total_cost_amount is None else _str_or_zero(business.total_cost_amount),
total_sale_amount=_str_or_zero(business.total_sale_amount),
total_cost_amount=_str_or_zero(business.total_cost_amount),
total_ship_amount=_str_or_zero(business.total_ship_amount),
total_received_amount=_str_or_zero(business.total_received_amount),
total_freight_amount=None if business.integration_json and business.total_freight_amount is None else _str_or_zero(business.total_freight_amount),
total_freight_amount=_str_or_zero(business.total_freight_amount),
remark=business.remark,
source_type="TPLUS" if business.t_plus_id else "MANUAL",
source_no=business.source_no,
source_personnel=(business.integration_json or {}).get("personnel_names", []),
integration_status="WAITING_MATERIALS" if business.integration_json else None,
)
@ -183,12 +179,6 @@ def _recalc_totals(session: Session, business: Business) -> None:
五条独立聚合查询(各表单列 SUM,走 business_id 索引)——业务单据量级
为单业务数行,不值得为省查询做单大 JOIN。
"""
if business.integration_json:
from app.services.tplus.business_sync import _refresh_business_totals
session.flush()
_refresh_business_totals(session, business)
return
sale = session.execute(
_sum(BusinessItem.sale_amount, BusinessItem.business_id, business.id)
).scalar_one()
@ -246,52 +236,10 @@ def _is_duplicate_entry(exc: IntegrityError) -> bool:
def list_businesses(session: Session, query: BusinessQuery) -> PageResult[BusinessOut]:
"""分页查询业务(附客户名与归属人名)。"""
from app.services.business_source import readiness_summaries
summaries = {}
sql_reason = query.readiness_reason in business_repository.READINESS_SQL_REASONS
if sql_reason and query.readiness_status == "READY":
rows, total = [], 0
elif sql_reason:
rows = business_repository.list_businesses(session, query)
total = business_repository.count_businesses(session, query)
summaries = readiness_summaries(session, [row[0] for row in rows])
elif query.pending_only or query.readiness_status or query.readiness_reason:
rows, total, before_id = [], 0, None
snapshots = {}
while batch := business_repository.scan_businesses(session, query, before_id):
states = readiness_summaries(session, [row[0] for row in batch], snapshot_cache=snapshots)
for row in batch:
state = states.get(row[0].id)
if not state:
continue
if query.pending_only and state["status"] != "PENDING":
continue
if query.readiness_status and state["status"] != query.readiness_status:
continue
if query.readiness_reason and query.readiness_reason not in {r["code"] for r in state["reasons"]}:
continue
if query.offset <= total < query.offset + query.page_size:
rows.append(row)
summaries[row[0].id] = state
total += 1
before_id = batch[-1][0].id
else:
rows = business_repository.list_businesses(session, query)
total = business_repository.count_businesses(session, query)
summaries = readiness_summaries(session, [row[0] for row in rows])
records = []
for row in rows:
out = _to_out(row)
state = summaries.get(out.id)
if state:
out.readiness_status = state["status"]
out.readiness_reasons = state["reasons"]
if any(reason["code"] == "SALES_RELATION_PENDING" for reason in state["reasons"]):
out.total_sale_amount = out.total_cost_amount = None
records.append(out)
rows = business_repository.list_businesses(session, query)
total = business_repository.count_businesses(session, query)
return PageResult[BusinessOut](
records=records,
records=[_to_out(row) for row in rows],
total=total,
page_num=query.page_num,
page_size=query.page_size,
@ -307,11 +255,6 @@ def get_detail(session: Session, business_id: int) -> BusinessDetailOut:
if row is None:
raise NotFoundError("业务不存在")
out = BusinessDetailOut(**_to_out(row).model_dump())
if row[0].integration_json:
from app.services.business_source import sales_relation_pending
if sales_relation_pending(session, business_id):
out.total_sale_amount = out.total_cost_amount = None
out.items = [
_item_to_out(item) for item in business_repository.list_items(session, business_id)
]
@ -557,9 +500,6 @@ def update_business(
raise NotFoundError("业务不存在")
business, _, owner_name, assistant_name = row
if business.integration_json:
raise BadRequestError("T+ 来源业务请通过差异核对处理,不能直接替换明细")
# 终态门槛与发货/回款写准入同口径:明细与汇总是 M3 核算基线,
# 已完成/已终止的业务禁改(发货/回款服务以同一理由禁写终态业务)
if business.status != BUSINESS_STATUS_ACTIVE:
@ -691,8 +631,6 @@ def delete_business(
):
if business is None:
raise NotFoundError("业务不存在")
if business.integration_json:
raise BadRequestError("T+ 来源业务不能直接删除,请通过差异核对处理")
if business_repository.has_docs(session, business_id):
raise BadRequestError("业务已有发货或回款记录,请改为已终止而非删除")
# 在途审批单(ref_id 固化无外键,业务删了单还挂着且可被继续审批):
@ -1055,9 +993,7 @@ def _gross_profit_row(
def _scan_gross_profit_rows(
session: Session, business_id: int, threshold_of: Callable[[str], Fraction], *,
sales_relation_pending: bool = False, items: list[BusinessItem] | None = None,
included_item_ids: set[int] | None = None,
session: Session, business_id: int, threshold_of: Callable[[str], Fraction]
) -> _GrossProfitScan:
"""逐行扫描业务明细:出行 DTO、类别聚合桶与成本缺失计数。
@ -1066,24 +1002,7 @@ def _scan_gross_profit_rows(
rows: list[GrossProfitRowOut] = []
agg: dict[str, list[Decimal]] = {}
missing_count = 0
for item in items if items is not None else business_repository.list_items(session, business_id):
if sales_relation_pending:
rows.append(GrossProfitRowOut(item_id=item.id, product_category=item.product_category,
product_name=item.product_name, spec_model=item.spec_model, quantity=str(item.quantity),
sale_amount=str(item.sale_amount), missing_reason="订单与发货关联待核对,无法汇总毛利润"))
missing_count += 1
continue
if included_item_ids is not None and item.id not in included_item_ids:
rows.append(GrossProfitRowOut(item_id=item.id, product_category=item.product_category,
product_name=item.product_name, spec_model=item.spec_model, quantity=str(item.quantity),
sale_amount=str(item.sale_amount), missing_reason="已计入订单,不重复汇总"))
continue
if item.product_category is None:
rows.append(GrossProfitRowOut(item_id=item.id, product_category=None, product_name=item.product_name,
spec_model=item.spec_model, quantity=str(item.quantity), sale_amount=str(item.sale_amount),
missing_reason="产品核算分类待确认"))
missing_count += 1
continue
for item in business_repository.list_items(session, business_id):
row, bucket = _gross_profit_row(item, threshold_of(item.product_category))
rows.append(row)
if bucket is None:
@ -1122,8 +1041,7 @@ def gross_profit(session: Session, business_id: int, as_of: date | None) -> Gros
:raises NotFoundError: 业务不存在
"""
business = business_repository.get_by_id(session, business_id)
if business is None:
if business_repository.get_by_id(session, business_id) is None:
raise NotFoundError("业务不存在")
as_of = as_of or date.today()
snapshot = param_service.resolve_snapshot(session, as_of)
@ -1131,20 +1049,7 @@ def gross_profit(session: Session, business_id: int, as_of: date | None) -> Gros
def threshold_of(category: str) -> Fraction:
return snapshot.fraction("product.threshold", product_category=category)
items = business_repository.list_items(session, business_id)
pending, included_ids = False, None
if business.integration_json and business.integration_json.get("source_database"):
from app.models import TPlusBusinessDocument
from app.services.tplus.sales_relations import resolve_items
order_ids = list(session.scalars(select(TPlusBusinessDocument.target_id).where(
TPlusBusinessDocument.business_id == business_id,
TPlusBusinessDocument.entity_type == "business_items",
TPlusBusinessDocument.status == "APPLIED")))
pending, included = resolve_items(session, business, items, order_ids)
included_ids = {item.id for item in included}
scan = _scan_gross_profit_rows(session, business_id, threshold_of, sales_relation_pending=pending,
items=items, included_item_ids=included_ids)
scan = _scan_gross_profit_rows(session, business_id, threshold_of)
categories = [
_gross_profit_category(category, sale, cost, threshold_of(category))
for category, [sale, cost] in scan.category_agg.items()

View File

@ -1,994 +0,0 @@
"""业务来源、待补资料和来源业务核算准备;不重新录入源单据。"""
from collections import defaultdict
from contextlib import contextmanager
from datetime import date
from decimal import ROUND_HALF_UP, Decimal
from sqlalchemy import Numeric, String, and_, cast, func, literal, or_, select, union
from sqlalchemy.orm import aliased
from app.core.exceptions import BadRequestError, NotFoundError
from app.core.response import PageResult
from app.models import (
Business,
BusinessItem,
BusinessRole,
Customer,
PayrollInput,
Personnel,
Product,
Receipt,
Shipment,
ShipmentItem,
TPlusBusinessDocument,
TPlusBusinessSyncConfig,
TPlusFact,
TPlusSourceHead,
TPlusSyncRecord,
TPlusWorkRun,
)
from app.services import payroll_months
from app.services.personnel_roles import role_on
from app.services.tplus.calculation_preview import _voucher_evidence
from app.services.tplus.workbench import digest, latest_facts
LABELS = {
"CUSTOMER_CATEGORY_PENDING": "请确认客户核算类型",
"PRODUCT_CATEGORY_PENDING": "请补齐产品核算分类",
"PERSONNEL_PENDING": "请逐条确认发货归属人员及历史岗位",
"OPENING_BALANCE_PENDING": "请核实2026年2月末期初应收与历史承接",
"GUIDANCE_PENDING": "请录入发货月份指导成本并封存",
"FREIGHT_PENDING": "请核实源销售金额中的运费",
"NO_ACTUAL_SHIPMENT": "尚未发货,可继续关联订单和预收款",
"SOURCE_CONFLICT": "来源有差异或异常记录,请先核对",
"SOURCE_SNAPSHOT_PENDING": "最新完整来源快照尚未完成业务接入,请等待或重试接入",
"INPUT_REFRESH_REQUIRED": "新增单据或资料已变化,请刷新核算资料版本",
"SOURCE_DOCUMENT_INACTIVE": "存在尚未生效的来源单据",
"SALES_RELATION_PENDING": "订单与发货明细的关联不完整,请核对销售额,避免重复统计",
}
def business(session, business_id):
row = session.get(Business, business_id)
if row is None:
raise NotFoundError("业务不存在")
return row
def ensure_source_editable(session, entity, target_id):
"""来源单据不得通过手工全量替换接口绕过差异核对。"""
linked = session.scalar(select(TPlusBusinessDocument.id).where(
TPlusBusinessDocument.entity_type == entity,
TPlusBusinessDocument.target_id == target_id,
TPlusBusinessDocument.applied_record_id.is_not(None),
).limit(1))
if linked is not None:
raise BadRequestError("T+ 来源单据请通过差异核对处理,不能直接编辑或删除")
def _latest(session, business_id):
return session.scalar(select(PayrollInput).where(PayrollInput.business_id == business_id).order_by(PayrollInput.version.desc()).limit(1))
def sales_relations_pending(session, business_ids):
"""批量核对当前明细,兼容尚未由新版 worker 更新标记的旧业务。"""
if not business_ids:
return set()
order_match = select(TPlusBusinessDocument.id).where(
TPlusBusinessDocument.business_id == BusinessItem.business_id,
TPlusBusinessDocument.entity_type == "business_items",
TPlusBusinessDocument.status == "APPLIED",
TPlusBusinessDocument.target_id == BusinessItem.id,
).exists()
rows = session.execute(select(BusinessItem.business_id, order_match).where(
BusinessItem.business_id.in_(business_ids)).distinct())
matches, unmatched = set(), set()
for business_id, matched in rows:
(matches if matched else unmatched).add(business_id)
mismatch = set(session.scalars(select(TPlusBusinessDocument.business_id).where(
TPlusBusinessDocument.business_id.in_(business_ids),
TPlusBusinessDocument.status == "CONFLICT",
TPlusBusinessDocument.reason == "ORDER_SHIPMENT_MISMATCH",
).distinct()))
pending = (matches & unmatched) | mismatch
if not pending:
return pending
from app.models import TPlusFact
from app.services.tplus.sales_relations import KIND, resolve_items
# 仅曾确认过的业务需要进一步检查事实;大多数待核对业务无需逐条加载。
confirmed = set(session.scalars(select(Business.id).join(TPlusFact, (
TPlusFact.source_database == Business.integration_json["source_database"].as_string())
& (TPlusFact.kind == KIND) & (TPlusFact.fact_key.like(func.concat("sales:", Business.id, ":%")))
).where(Business.id.in_(pending)).distinct())) if session.bind.dialect.name == "mysql" else {
business_id for business_id in pending if session.scalar(select(TPlusFact.id).where(
TPlusFact.kind == KIND, TPlusFact.fact_key.like(f"sales:{business_id}:%")).limit(1))
}
for business_id in confirmed:
business = session.get(Business, business_id)
if not business or not business.integration_json:
continue
items = list(session.scalars(select(BusinessItem).where(BusinessItem.business_id == business_id)))
order_ids = list(session.scalars(select(TPlusBusinessDocument.target_id).where(
TPlusBusinessDocument.business_id == business_id,
TPlusBusinessDocument.entity_type == "business_items", TPlusBusinessDocument.status == "APPLIED")))
if business_id not in mismatch and not resolve_items(session, business, items, order_ids)[0]:
pending.discard(business_id)
return pending
def sales_relation_pending(session, business_id):
return business_id in sales_relations_pending(session, [business_id])
def readiness_summaries(session, rows, *, snapshot_cache=None):
"""批量计算列表准备状态,与详情使用同一组判断,不按历史输入是否存在猜测。"""
sources = [row for row in rows if row.integration_json]
if not sources:
return {}
ids = [row.id for row in sources]
customers = {c.id: c for c in session.scalars(select(Customer).where(
Customer.id.in_({row.customer_id for row in sources})))}
missing_products = set(session.scalars(select(BusinessItem.business_id).join(
Product, Product.id == BusinessItem.product_id).where(
BusinessItem.business_id.in_(ids), Product.product_category.is_(None)).distinct()))
shipped = set(session.scalars(select(Shipment.business_id).where(
Shipment.business_id.in_(ids)).distinct()))
latest = select(PayrollInput.business_id, func.max(PayrollInput.version).label("version")).where(
PayrollInput.business_id.in_(ids)).group_by(PayrollInput.business_id).subquery()
inputs = {item.business_id: item for item in session.scalars(select(PayrollInput).join(
latest, (PayrollInput.business_id == latest.c.business_id) & (PayrollInput.version == latest.c.version)))}
lines = defaultdict(dict)
receipts = defaultdict(set)
if inputs:
for business_id, item_id, category in session.execute(select(
Shipment.business_id, ShipmentItem.id, Product.product_category).join(
Shipment, Shipment.id == ShipmentItem.shipment_id).outerjoin(
Product, Product.id == ShipmentItem.product_id).where(Shipment.business_id.in_(inputs))):
lines[business_id][item_id] = category
for business_id, receipt_id in session.execute(select(Receipt.business_id, Receipt.id).where(
Receipt.business_id.in_(inputs))):
receipts[business_id].add(receipt_id)
conflicts = set(session.scalars(select(TPlusBusinessDocument.business_id).where(
TPlusBusinessDocument.business_id.in_(ids),
TPlusBusinessDocument.status.in_(("CONFLICT", "PENDING"))).distinct()))
relation_pending = sales_relations_pending(session, ids)
snapshots = snapshot_cache if snapshot_cache is not None else {}
for source in {row.integration_json["source_database"] for row in sources}:
if source not in snapshots:
snapshots[source] = source_snapshot_ready(session, source)
result = {}
for row in sources:
reasons = set()
customer = customers[row.customer_id]
record = inputs.get(row.id)
if not snapshots[row.integration_json["source_database"]]:
reasons.add("SOURCE_SNAPSHOT_PENDING")
if row.id in relation_pending:
reasons.add("SALES_RELATION_PENDING")
if customer.customer_type is None:
reasons.add("CUSTOMER_CATEGORY_PENDING")
if row.id in missing_products:
reasons.add("PRODUCT_CATEGORY_PENDING")
if row.id not in shipped:
reasons.add("NO_ACTUAL_SHIPMENT")
if record is None:
reasons.update(("OPENING_BALANCE_PENDING", "GUIDANCE_PENDING", "PERSONNEL_PENDING"))
if row.id in shipped:
reasons.add("FREIGHT_PENDING")
else:
maps = record.data_json.get("maps", {})
if (set(lines[row.id]) != set(maps.get("shipment_items", {}).values()) or
receipts[row.id] != set(maps.get("receipts", {}).values())):
reasons.add("INPUT_REFRESH_REQUIRED")
for item in record.data_json.get("shipments", []):
item_id = maps.get("shipment_items", {}).get(str(item.get("line_id")))
if (item_id not in lines[row.id] or item.get("category") != lines[row.id][item_id]
or item.get("customer_type") != customer.customer_type):
reasons.add("INPUT_REFRESH_REQUIRED")
if row.id in conflicts:
reasons.add("SOURCE_CONFLICT")
result[row.id] = {"status": "PENDING" if reasons else "READY",
"reasons": [{"code": code, "message": LABELS[code]} for code in sorted(reasons)]}
return result
def sales_relation_candidates(session, business_id, query, shipment_page=1, order_page=1):
"""只读列出候选,金额与产品相同不代表已确认关联。"""
from app.services.tplus.sales_relations import (
_fact_is_current,
_latest_business_facts,
item_revision,
order_revision,
shipment_revision,
)
row = business(session, business_id)
if not row.integration_json:
raise BadRequestError("仅来源业务需要核对订单与发货关联")
if query.page_size > 20:
raise BadRequestError("每页最多查询 20 条待核对明细")
source = row.integration_json["source_database"]
order_match = select(TPlusBusinessDocument.id).where(
TPlusBusinessDocument.business_id == business_id,
TPlusBusinessDocument.entity_type == "business_items",
TPlusBusinessDocument.status == "APPLIED",
TPlusBusinessDocument.target_id == BusinessItem.id,
).exists()
conditions = (BusinessItem.business_id == business_id, ~order_match)
total = session.scalar(select(func.count()).select_from(BusinessItem).where(*conditions)) or 0
items = list(session.scalars(select(BusinessItem).where(*conditions).order_by(BusinessItem.id)
.offset(query.offset).limit(query.page_size)))
facts = {fact.fact_key: fact for fact in _latest_business_facts(session, source, business_id)}
def confirmed_source(document_id, entity, parent_entity):
doc = session.get(TPlusBusinessDocument, document_id) if document_id else None
if not doc or doc.business_id != business_id or doc.source_database != source or doc.entity_type != entity:
return None
parent = session.scalar(select(TPlusBusinessDocument).where(
TPlusBusinessDocument.business_id == business_id,
TPlusBusinessDocument.source_database == source,
TPlusBusinessDocument.entity_type == parent_entity,
TPlusBusinessDocument.source_id == str(doc.data_json.get("source_parent_id"))).limit(1))
return {"source_id": doc.source_id, "source_no": parent.source_no if parent else None}
results = []
for item in items:
head_doc = aliased(TPlusBusinessDocument)
candidates = list(session.execute(select(TPlusBusinessDocument, head_doc, Shipment, ShipmentItem)
.join(ShipmentItem, ShipmentItem.id == TPlusBusinessDocument.target_id)
.join(Shipment, Shipment.id == ShipmentItem.shipment_id)
.join(head_doc, (head_doc.business_id == business_id) & (head_doc.entity_type == "shipments")
& (head_doc.source_database == source)
& (head_doc.target_id == Shipment.id)
& (head_doc.source_id == TPlusBusinessDocument.data_json["source_parent_id"].as_string()))
.where(TPlusBusinessDocument.business_id == business_id,
TPlusBusinessDocument.source_database == source,
TPlusBusinessDocument.entity_type == "shipment_items",
TPlusBusinessDocument.status == "APPLIED",
TPlusBusinessDocument.record_id == TPlusBusinessDocument.applied_record_id,
head_doc.status == "APPLIED", head_doc.record_id == head_doc.applied_record_id,
Shipment.business_id == business_id, ShipmentItem.product_id == item.product_id,
ShipmentItem.quantity == item.quantity, ShipmentItem.amount == item.sale_amount)
.order_by(TPlusBusinessDocument.id).offset((shipment_page - 1) * 20).limit(21)))
order_head = aliased(TPlusBusinessDocument)
orders = list(session.execute(select(TPlusBusinessDocument, order_head, BusinessItem)
.join(BusinessItem, BusinessItem.id == TPlusBusinessDocument.target_id)
.join(order_head, (order_head.business_id == business_id) & (order_head.entity_type == "businesses")
& (order_head.source_database == source)
& (order_head.source_id == TPlusBusinessDocument.data_json["source_parent_id"].as_string()))
.where(TPlusBusinessDocument.business_id == business_id,
TPlusBusinessDocument.source_database == source,
TPlusBusinessDocument.entity_type == "business_items",
TPlusBusinessDocument.status == "APPLIED", BusinessItem.product_id == item.product_id,
TPlusBusinessDocument.record_id == TPlusBusinessDocument.applied_record_id,
order_head.status == "APPLIED", order_head.record_id == order_head.applied_record_id)
.order_by(TPlusBusinessDocument.id).offset((order_page - 1) * 20).limit(21)))
fact = facts.get(f"sales:{business_id}:{item.id}")
results.append({"item_id": item.id, "product_name": item.product_name, "spec": item.spec_model,
"quantity": str(item.quantity), "amount": str(item.sale_amount), "item_revision": item_revision(item),
"version": fact.version if fact else 0,
"confirmation": ({"disposition": fact.data_json.get("disposition"),
"shipment_document_id": fact.data_json.get("shipment_document_id"),
"order_document_id": fact.data_json.get("order_document_id"),
"shipment": confirmed_source(fact.data_json.get("shipment_document_id"), "shipment_items", "shipments"),
"order": confirmed_source(fact.data_json.get("order_document_id"), "business_items", "businesses"),
"evidence": fact.evidence, "current": _fact_is_current(session, row, item, fact)}
if fact else None),
"shipments": [{"document_id": doc.id, "source_id": doc.source_id,
"source_no": shipment.source_no, "date": shipment.ship_date.isoformat(),
"shipment_item_id": line.id, "revision": shipment_revision(doc, head, shipment, line)}
for doc, head, shipment, line in candidates[:20]],
"shipments_truncated": len(candidates) > 20,
"orders": [{"document_id": doc.id, "source_id": doc.source_id,
"item_id": order.id, "quantity": str(order.quantity), "amount": str(order.sale_amount),
"revision": order_revision(doc, head, order)}
for doc, head, order in orders[:20]], "orders_truncated": len(orders) > 20})
return PageResult(records=results, total=total, page_num=query.page_num, page_size=query.page_size)
def readiness(session, business_id):
row = business(session, business_id)
if not row.integration_json:
return {"source_type": "MANUAL", "status": "MANUAL", "reasons": [], "products": [], "customer_type": session.get(Customer, row.customer_id).customer_type}
customer = session.get(Customer, row.customer_id)
product_ids = select(BusinessItem.product_id).where(BusinessItem.business_id == business_id, BusinessItem.product_id.is_not(None))
products = list(session.scalars(select(Product).where(Product.id.in_(product_ids)).order_by(Product.id)))
record = _latest(session, business_id)
summary = readiness_summaries(session, [row])[business_id]
reasons = {reason["code"] for reason in summary["reasons"]}
eligible_receipts = None
if (record is not None and record.data_json.get("input_schema") == "TPLUS_SYNC_V1"
and not reasons.intersection({"SOURCE_CONFLICT", "SOURCE_SNAPSHOT_PENDING", "INPUT_REFRESH_REQUIRED"})):
entries = record.data_json.get("receipts", [])
if all(r.get("exclusion") or r.get("voucher_confirmed") for r in entries):
eligible_receipts = str(sum(
(Decimal(str(r["amount"])) for r in entries if not r.get("exclusion")), Decimal("0.00")
))
return {"source_type": "TPLUS", **summary,
"source_database": row.integration_json["source_database"], "customer_type": customer.customer_type,
"version": record.version if record else 0,
"source_personnel": row.integration_json.get("personnel_names", []),
"eligible_receipts_snapshot": eligible_receipts,
"products": [{"id": p.id, "name": p.product_name, "spec": p.spec_model, "category": p.product_category} for p in products[:200]],
"products_total": len(products)}
def source_snapshot_ready(session, source):
"""仅认可覆盖当前完整快照且已处理结束的业务接入任务。"""
from app.services.tplus.business_sync import START
from app.services.tplus.snapshots import source_manifest
from app.services.tplus.workbench import WorkbenchError
active = session.scalar(select(TPlusWorkRun.id).where(
TPlusWorkRun.source_database == source, TPlusWorkRun.kind == "BUSINESS_SYNC",
TPlusWorkRun.status.in_(("QUEUED", "RUNNING"))).limit(1))
if active is not None:
return False
try:
manifest = source_manifest(session, source)
except WorkbenchError:
return False
run = session.scalar(select(TPlusWorkRun).where(
TPlusWorkRun.source_database == source, TPlusWorkRun.kind == "BUSINESS_SYNC"
).order_by(TPlusWorkRun.id.desc()).limit(1))
if not run or run.status not in ("SUCCEEDED", "PARTIAL") or (run.summary_json or {}).get("source_manifest") != manifest:
return False
config = session.scalar(select(TPlusBusinessSyncConfig).where(TPlusBusinessSyncConfig.source_database == source))
try:
run_start = date.fromisoformat((run.input_json or {}).get("start_date", START.isoformat()))
except (TypeError, ValueError):
return False
return run_start <= (config.start_date if config else START)
@contextmanager
def source_calculation_lock(session):
"""在新事务中持锁检查快照并提交,避免沿用鉴权查询建立的旧读视图。"""
if session.get_bind().dialect.name != "mysql":
yield
return
if session.new or session.deleted or any(session.is_modified(obj) for obj in session.dirty):
raise BadRequestError("核算前存在未提交的修改,请先完成当前操作")
session.rollback()
from app.core.tplus_lock import TPlusBusyError, sync_lock
try:
with sync_lock(session):
yield
except TPlusBusyError:
raise BadRequestError("T+ 数据正在采集或接入,请完成后再核算") from None
def _source_values(record):
raw = record.payload_json
def first(*names):
return next((raw[name] for name in names if raw.get(name) is not None), None)
return {"source_no": record.source_no, "date": first("voucherdate", "voucherDate"),
"customer_id": first("idcustomer", "idpartner"), "personnel_id": first("idclerk", "idperson"),
"product_id": first("idinventory"), "order_id": first("saleOrderId"),
"order_detail_id": first("saleOrderDetailId"),
"quantity": first("quantity"), "price": first("taxPrice", "price"),
"price_type": ("含税" if raw.get("taxPrice") is not None else "未税" if raw.get("price") is not None else None),
"amount": first("taxAmount", "amount"), "state": first("voucherState", "voucherstate")}
def _source_changes(previous, current):
labels = {"source_no": "来源单号", "date": "日期", "customer_id": "客户标识", "personnel_id": "经办人标识",
"product_id": "产品标识", "order_id": "订单标识", "order_detail_id": "订单明细标识",
"quantity": "数量", "price": "单价", "price_type": "单价税别", "amount": "金额", "state": "单据状态"}
old, new = _source_values(previous), _source_values(current)
def text(value):
return str(value) if value is not None else None
return [{"field": key, "label": label, "before": text(old[key]), "after": text(new[key])}
for key, label in labels.items() if text(old[key]) != text(new[key])]
def source_quantities(session, business_id, query):
"""按产品和单位分别统计已接入的来源订单及发货数量。"""
business(session, business_id)
zero = literal(0, type_=Numeric(14, 2))
orders = select(
BusinessItem.product_id.label("product_id"), BusinessItem.product_name.label("product_name"),
BusinessItem.spec_model.label("spec_model"), BusinessItem.unit.label("unit"),
BusinessItem.quantity.label("order_quantity"), zero.label("shipment_quantity"),
).join(TPlusBusinessDocument, and_(TPlusBusinessDocument.target_id == BusinessItem.id,
TPlusBusinessDocument.entity_type == "business_items")).where(
TPlusBusinessDocument.business_id == business_id,
TPlusBusinessDocument.applied_record_id.is_not(None), BusinessItem.business_id == business_id)
shipments = select(
ShipmentItem.product_id.label("product_id"), ShipmentItem.product_name.label("product_name"),
ShipmentItem.spec_model.label("spec_model"), ShipmentItem.unit.label("unit"),
zero.label("order_quantity"), ShipmentItem.quantity.label("shipment_quantity"),
).join(TPlusBusinessDocument, and_(TPlusBusinessDocument.target_id == ShipmentItem.id,
TPlusBusinessDocument.entity_type == "shipment_items")).join(
Shipment, Shipment.id == ShipmentItem.shipment_id).where(
TPlusBusinessDocument.business_id == business_id,
TPlusBusinessDocument.applied_record_id.is_not(None), Shipment.business_id == business_id)
combined = orders.union_all(shipments).subquery()
grouped = select(combined.c.product_id, combined.c.product_name, combined.c.spec_model, combined.c.unit,
func.sum(combined.c.order_quantity).label("order_quantity"),
func.sum(combined.c.shipment_quantity).label("shipment_quantity")).group_by(
combined.c.product_id, combined.c.product_name, combined.c.spec_model, combined.c.unit).subquery()
total = session.scalar(select(func.count()).select_from(grouped)) or 0
rows = session.execute(select(grouped).order_by(grouped.c.product_name, grouped.c.spec_model,
grouped.c.unit, grouped.c.product_id).offset(query.offset).limit(query.page_size)).mappings()
result = [{**row, "order_quantity": str(row["order_quantity"]),
"shipment_quantity": str(row["shipment_quantity"])} for row in rows]
return PageResult(records=result, total=total, page_num=query.page_num, page_size=query.page_size)
def _price_repair_revision(doc, parent, target, record):
return digest(["source-price-v1", doc.id, doc.applied_record_id, doc.record_id, doc.target_id,
parent.id, parent.record_id, parent.applied_record_id, parent.fingerprint,
parent.status, parent.reason, target.id, str(target.sale_price), record.id, record.row_hash])
def documents(session, business_id, query, entity=None):
from app.services.tplus.snapshots import source_records
from app.services.tplus.workbench import WorkbenchError
business(session, business_id)
conditions = [TPlusBusinessDocument.business_id == business_id]
if entity:
conditions.append(TPlusBusinessDocument.entity_type == entity)
total = session.scalar(select(func.count()).select_from(TPlusBusinessDocument).where(*conditions)) or 0
rows = list(session.execute(select(TPlusBusinessDocument, TPlusSyncRecord).join(TPlusSyncRecord, TPlusSyncRecord.id == TPlusBusinessDocument.record_id)
.where(*conditions).order_by(TPlusBusinessDocument.id).offset(query.offset).limit(query.page_size)))
previous_ids = {doc.applied_record_id for doc, _ in rows if doc.applied_record_id and doc.applied_record_id != doc.record_id}
previous_records = {record.id: record for record in session.scalars(select(TPlusSyncRecord).where(TPlusSyncRecord.id.in_(previous_ids)))} if previous_ids else {}
details = {}
for source_database, entity_type in {(doc.source_database, doc.entity_type) for doc, _ in rows
if doc.entity_type in ("business_items", "shipment_items")}:
ids = [doc.source_id for doc, _ in rows if doc.source_database == source_database and doc.entity_type == entity_type]
try:
details[(source_database, entity_type)] = {
record.source_id: record for record in source_records(session, source_database, entity_type, ids=ids)
}
except WorkbenchError:
details[(source_database, entity_type)] = None
detail_docs = [doc for doc, _ in rows if doc.entity_type in ("business_items", "shipment_items")]
parent_ids = {str(doc.data_json["source_parent_id"]) for doc in detail_docs if doc.data_json.get("source_parent_id") is not None}
parents = {(doc.source_database, doc.entity_type, doc.source_id): doc for doc in session.scalars(
select(TPlusBusinessDocument).where(TPlusBusinessDocument.business_id == business_id,
TPlusBusinessDocument.entity_type.in_(("businesses", "shipments")),
TPlusBusinessDocument.source_id.in_(parent_ids)))} if parent_ids else {}
order_ids = {doc.target_id for doc in detail_docs if doc.entity_type == "business_items" and doc.target_id}
shipment_ids = {doc.target_id for doc in detail_docs if doc.entity_type == "shipment_items" and doc.target_id}
targets = {("business_items", item.id): item for item in session.scalars(select(BusinessItem).where(BusinessItem.id.in_(order_ids)))} if order_ids else {}
if shipment_ids:
targets.update({("shipment_items", item.id): item for item in session.scalars(select(ShipmentItem).where(ShipmentItem.id.in_(shipment_ids)))})
has_input = bool(detail_docs and session.scalar(select(PayrollInput.id).where(PayrollInput.business_id == business_id).limit(1)))
result = []
for doc, source in rows:
current = source
snapshot_state = None
if (doc.source_database, doc.entity_type) in details:
records = details[(doc.source_database, doc.entity_type)]
current = records.get(doc.source_id) if records is not None else None
snapshot_state = ("UNAVAILABLE" if records is None else "MISSING" if current is None
else "CHANGED" if current.id != doc.applied_record_id else "CURRENT")
raw = (current or source).payload_json
previous = previous_records.get(doc.applied_record_id, source if doc.applied_record_id == source.id else None)
person_id = doc.data_json.get("source_personnel_id")
if person_id is None:
person_id = raw.get("idclerk") if raw.get("idclerk") is not None else raw.get("idperson")
changes = _source_changes(previous, current) if previous and current and previous.id != current.id else []
if doc.reason == "PENDING_SOURCE_CUSTOMER_CHANGED" and current:
customer_id = _source_values(current)["customer_id"]
changes = [{"field": "customer_id", "label": "客户标识",
"before": str(doc.data_json["source_customer_id"]),
"after": str(customer_id) if customer_id is not None else None}]
elif doc.reason == "PENDING_SOURCE_OUT_OF_RANGE" and current:
source_date = _source_values(current)["date"]
changes = [{"field": "date", "label": "日期", "before": doc.data_json.get("source_date"),
"after": str(source_date)[:10] if source_date is not None else None}]
source_price = raw.get("taxPrice") if raw.get("taxPrice") is not None else raw.get("price")
repair_revision = None
parent_type = {"business_items": "businesses", "shipment_items": "shipments"}.get(doc.entity_type)
parent = parents.get((doc.source_database, parent_type, str(doc.data_json.get("source_parent_id"))))
target = targets.get((doc.entity_type, doc.target_id))
if (not has_input and parent and parent.reason == "SOURCE_PRICE_REVIEW_REQUIRED" and target
and target.sale_price is None and current and current.id == doc.applied_record_id
and raw.get("taxPrice") is None and raw.get("price") is not None):
from app.services.tplus.business_sync import decimal_value
try:
decimal_value(raw["price"], 4)
except WorkbenchError:
valid_price = False
else:
valid_price = True
if valid_price:
repair_revision = _price_repair_revision(doc, parent, target, current)
result.append({"id": doc.id, "entity": doc.entity_type, "source_id": doc.source_id, "source_no": doc.source_no,
"current_source_no": current.source_no if current else None,
"snapshot_state": snapshot_state,
"changes": changes,
"target_id": doc.target_id, "status": doc.status, "reason": doc.reason,
"date": doc.data_json.get("source_date"), "personnel_name": doc.data_json.get("source_personnel_name"),
"personnel_source_id": str(person_id) if person_id is not None else None,
"quantity": str(raw["quantity"]) if raw.get("quantity") is not None else None,
"amount": str(raw.get("taxAmount", raw.get("amount"))) if raw.get("taxAmount", raw.get("amount")) is not None else None,
"price": str(source_price) if source_price is not None else None,
"price_type": ("TAX_INCLUDED" if raw.get("taxPrice") is not None
else "TAX_EXCLUDED" if raw.get("price") is not None else None),
"price_repair_revision": repair_revision})
return PageResult(records=result, total=total, page_num=query.page_num, page_size=query.page_size)
def document_details(session, business_id, document_id, query):
"""对照已接入明细与当前完整快照,新增明细也按来源父单分页展示。"""
from app.services.tplus.snapshots import source_statement
from app.services.tplus.workbench import WorkbenchError
business(session, business_id)
head = session.get(TPlusBusinessDocument, document_id)
if head is None or head.business_id != business_id or head.entity_type not in ("businesses", "shipments"):
raise NotFoundError("来源订单或发货单不存在")
detail_type = "business_items" if head.entity_type == "businesses" else "shipment_items"
old = select(TPlusBusinessDocument.source_id).where(
TPlusBusinessDocument.source_database == head.source_database,
TPlusBusinessDocument.business_id == business_id,
TPlusBusinessDocument.entity_type == detail_type,
TPlusBusinessDocument.applied_record_id.is_not(None),
TPlusBusinessDocument.data_json["source_parent_id"].as_string() == head.source_id,
)
try:
current = source_statement(session, head.source_database, detail_type).where(or_(
TPlusSourceHead.source_parent_id == head.source_id,
and_(TPlusSourceHead.source_parent_id.is_(None),
cast(TPlusSyncRecord.payload_json["source_parent_id"].as_string(), String) == head.source_id),
))
except WorkbenchError:
raise BadRequestError("来源明细的完整快照不可用,请等待采集完成") from None
ids = union(old, current.with_only_columns(TPlusSyncRecord.source_id)).subquery()
total = session.scalar(select(func.count()).select_from(ids)) or 0
page_ids = list(session.scalars(select(ids.c.source_id).order_by(ids.c.source_id)
.offset(query.offset).limit(query.page_size)))
previous = dict(session.execute(select(TPlusBusinessDocument.source_id, TPlusSyncRecord)
.join(TPlusSyncRecord, TPlusSyncRecord.id == TPlusBusinessDocument.applied_record_id)
.where(TPlusBusinessDocument.source_database == head.source_database,
TPlusBusinessDocument.business_id == business_id,
TPlusBusinessDocument.entity_type == detail_type,
TPlusBusinessDocument.data_json["source_parent_id"].as_string() == head.source_id,
TPlusBusinessDocument.source_id.in_(page_ids))).all()) if page_ids else {}
latest = {record.source_id: record for record in session.scalars(
current.where(TPlusSyncRecord.source_id.in_(page_ids)))} if page_ids else {}
def values(record):
return {key: str(value) if value is not None else None
for key, value in _source_values(record).items()} if record else None
rows = []
for source_id in page_ids:
before, after = previous.get(source_id), latest.get(source_id)
changes = _source_changes(before, after) if before is not None and after is not None else []
state = ("ADDED" if before is None else "REMOVED" if after is None
else "CHANGED" if before.id != after.id else "UNCHANGED")
rows.append({"source_id": source_id, "state": state, "changes": changes,
"before": values(before), "after": values(after)})
return PageResult(records=rows, total=total, page_num=query.page_num, page_size=query.page_size)
def confirm_missing_source_price(session, business_id, document_id, payload, operator, meta=None):
"""仅经人工确认补全未封存的空单价;来源变化或非空人工值均不得覆盖。"""
from app.services.audit_service import AuditRecord, audit
from app.services.tplus.business_sync import (
START,
Context,
_apply_document,
decimal_value,
details,
statement,
)
from app.services.tplus.workbench import WorkbenchError
with source_calculation_lock(session):
audit_record = AuditRecord(module="业务管理", action="确认来源缺失单价", summary=f"业务 #{business_id} 来源明细 #{document_id}",
request_data=payload.model_dump(mode="json"))
with audit(session, operator=operator, meta=meta, record=audit_record):
row = session.get(Business, business_id, with_for_update=True)
if row is None or not row.integration_json:
raise BadRequestError("仅可核对来源业务")
source = row.integration_json["source_database"]
if not source_snapshot_ready(session, source):
raise BadRequestError("来源快照或业务接入未完成,请稍后重试")
if session.scalar(select(PayrollInput.id).where(PayrollInput.business_id == business_id).limit(1)):
raise BadRequestError("业务已有封存核算资料,请通过差异核对处理")
doc = session.get(TPlusBusinessDocument, document_id, with_for_update=True)
if (doc is None or doc.business_id != business_id or doc.source_database != source
or doc.entity_type not in ("business_items", "shipment_items") or doc.applied_record_id is None):
raise BadRequestError("来源明细不存在或尚未接入")
parent_type = "businesses" if doc.entity_type == "business_items" else "shipments"
parent_id = doc.data_json.get("source_parent_id")
parent = session.scalar(select(TPlusBusinessDocument).where(
TPlusBusinessDocument.business_id == business_id, TPlusBusinessDocument.source_database == source,
TPlusBusinessDocument.entity_type == parent_type, TPlusBusinessDocument.source_id == str(parent_id),
).with_for_update())
if parent is None or parent.reason != "SOURCE_PRICE_REVIEW_REQUIRED" or parent.status != "CONFLICT":
raise BadRequestError("来源单价状态已变化,请刷新后核对")
model = BusinessItem if doc.entity_type == "business_items" else ShipmentItem
target = session.get(model, doc.target_id, with_for_update=True) if doc.target_id else None
if target is None or target.sale_price is not None:
raise BadRequestError("正式单价已存在或明细不存在,不能覆盖")
if doc.entity_type == "business_items":
belongs = target.business_id == business_id
else:
shipment = session.get(Shipment, target.shipment_id, with_for_update=True)
belongs = bool(shipment and shipment.business_id == business_id)
if not belongs:
raise BadRequestError("正式明细不属于该业务")
try:
current = session.scalar(statement(session, source, doc.entity_type).where(TPlusSyncRecord.source_id == doc.source_id))
head = session.scalar(statement(session, source, parent_type).where(TPlusSyncRecord.source_id == parent.source_id))
if current is None or head is None or current.id != doc.applied_record_id or head.id != parent.record_id:
raise BadRequestError("来源明细已变化,请刷新后核对")
if _price_repair_revision(doc, parent, target, current) != payload.revision:
raise BadRequestError("来源单价或正式明细已变化,请刷新后核对")
raw = current.payload_json
if raw.get("taxPrice") is not None or raw.get("price") is None:
raise BadRequestError("来源单价状态已变化,请刷新后核对")
target.sale_price = decimal_value(raw["price"], 4)
session.flush()
ctx = Context(session, source, operator)
action, checked = _apply_document(ctx, parent_type, head, details(ctx, parent_type, parent.source_id), "MANUAL", START)
if action not in ("UNCHANGED", "CONFLICT") or (action == "CONFLICT" and checked.reason != "SOURCE_PRICE_REVIEW_REQUIRED"):
raise BadRequestError("来源单据已变化,请刷新后核对")
except WorkbenchError:
raise BadRequestError("来源单价无效或超出精度,请核对原单") from None
result = {"document_id": doc.id, "parent_status": checked.status}
session.commit()
return result
def confirm_guidance(session, business_id, payload, operator, meta=None):
"""登记并封存一个来源产品发货月份的单位指导成本。"""
from app.services.audit_service import AuditRecord, audit
from app.services.tplus.business_sync import statement
from app.services.tplus.guidance import (
check_unit_guidance_conflicts,
frozen_guidance_shipments,
)
with source_calculation_lock(session):
audit_record = AuditRecord(
module="业务管理",
action="登记月度指导成本",
summary=f"业务 #{business_id} 发货明细 #{payload.shipment_item_id}",
request_data=payload.model_dump(mode="json"),
)
with audit(session, operator=operator, meta=meta, record=audit_record):
row = session.get(Business, business_id, with_for_update=True)
if row is None or not row.integration_json:
raise BadRequestError("仅可为自动接入业务登记指导成本")
source = row.integration_json.get("source_database")
if not source or not source_snapshot_ready(session, source):
raise BadRequestError("来源快照或业务接入未完成,请稍后重试")
if _latest(session, business_id) is not None:
raise BadRequestError("业务已有封存核算资料,请通过差异核对处理")
item = session.scalar(
select(ShipmentItem)
.join(Shipment, Shipment.id == ShipmentItem.shipment_id)
.where(ShipmentItem.id == payload.shipment_item_id, Shipment.business_id == business_id)
.with_for_update()
)
if item is None or not item.unit:
raise BadRequestError("发货明细不存在或缺少计量单位")
shipment = session.get(Shipment, item.shipment_id, with_for_update=True)
if shipment is None or shipment.business_id != business_id:
raise BadRequestError("发货单不存在或不属于该业务")
documents = list(session.scalars(
select(TPlusBusinessDocument)
.where(
TPlusBusinessDocument.business_id == business_id,
TPlusBusinessDocument.source_database == source,
TPlusBusinessDocument.entity_type == "shipment_items",
TPlusBusinessDocument.target_id == item.id,
TPlusBusinessDocument.status == "APPLIED",
TPlusBusinessDocument.applied_record_id.is_not(None),
)
))
if not documents:
raise BadRequestError("发货明细尚未建立来源关联")
if len(documents) != 1:
raise BadRequestError("发货明细来源关联不唯一,请先核对")
document = documents[0]
parent_id = document.data_json.get("source_parent_id")
parents = list(session.scalars(
select(TPlusBusinessDocument).where(
TPlusBusinessDocument.business_id == business_id,
TPlusBusinessDocument.source_database == source,
TPlusBusinessDocument.entity_type == "shipments",
TPlusBusinessDocument.source_id == str(parent_id),
TPlusBusinessDocument.status == "APPLIED",
TPlusBusinessDocument.applied_record_id.is_not(None),
)
))
if not parents:
raise BadRequestError("发货单尚未建立来源关联")
if len(parents) != 1:
raise BadRequestError("发货单来源关联不唯一,请先核对")
parent = parents[0]
current_line = session.scalar(
statement(session, source, "shipment_items").where(
TPlusSyncRecord.source_id == document.source_id
)
)
current_parent = session.scalar(
statement(session, source, "shipments").where(
TPlusSyncRecord.source_id == parent.source_id
)
)
if (
current_line is None
or current_parent is None
or current_line.id != document.applied_record_id
or current_parent.id != parent.applied_record_id
):
raise BadRequestError("来源单据已变化,请刷新后核对")
source_product_id = current_line.payload_json.get("idinventory")
if source_product_id is None:
raise BadRequestError("来源明细缺少产品身份,请先核对")
source_product_id = str(source_product_id).strip()
if not source_product_id.isascii() or not source_product_id.isdigit() or len(source_product_id) > 64:
raise BadRequestError("来源产品身份格式异常,请先核对")
source_date = current_parent.payload_json.get("voucherdate") or current_parent.payload_json.get("voucherDate")
if source_date is None or str(source_date)[:10] != shipment.ship_date.isoformat():
raise BadRequestError("来源发货日期已变化,请刷新后核对")
month = shipment.ship_date.strftime("%Y-%m")
amount = format(Decimal(payload.amount), "f")
facts = latest_facts(session, source)
fact_key = f"GUIDANCE:{source_product_id}:{month}:UNIT"
existing = facts.get(("GUIDANCE", fact_key))
if existing is not None:
existing_data = existing.data_json
if (
existing_data.get("is_finalized")
and existing_data.get("unit") == item.unit
and Decimal(str(existing_data.get("amount"))) == Decimal(amount)
):
return {
"status": "REUSED",
"fact_id": existing.id,
"product_id": str(source_product_id),
"month": month,
"unit": item.unit,
"amount": existing_data["amount"],
}
raise BadRequestError("同一产品同月已有不同指导成本,请先核对")
frozen = frozen_guidance_shipments(session, source, str(source_product_id), month)
check_unit_guidance_conflicts(
facts, str(source_product_id), month, item.unit, amount, frozen
)
fact = TPlusFact(
source_database=source,
kind="GUIDANCE",
fact_key=fact_key,
version=1,
data_json={
"product_id": str(source_product_id),
"month": month,
"basis": "UNIT",
"amount": amount,
"unit": item.unit,
"is_finalized": True,
},
evidence=payload.evidence,
requested_by=operator.id,
)
session.add(fact)
session.flush()
result = {
"status": "CREATED",
"fact_id": fact.id,
"product_id": str(source_product_id),
"month": month,
"unit": item.unit,
"amount": amount,
}
session.commit()
return result
def save_materials(session, business_id, payload, actor, meta=None):
"""同一业务引用的档案才可批量维护;不接受名称、金额或来源字段。"""
from app.services.audit_service import AuditRecord, audit
from app.services.authorization_service import require_permission
from app.services.category_batch import fill_missing_product_items
if payload.customer_type:
require_permission(session, actor, "base:customers:update")
if payload.products:
require_permission(session, actor, "base:products:update")
with audit(session, operator=actor, meta=meta, record=AuditRecord(module="业务管理", action="补充来源分类", summary=f"业务 #{business_id} 核算分类", request_data=payload.model_dump(mode="json"))):
row = session.get(Business, business_id, with_for_update=True)
if row is None or not row.integration_json:
raise BadRequestError("仅可维护自动接入业务的分类资料")
record = _latest(session, business_id)
if record is not None:
raise BadRequestError("已有封存核算资料,请通过差异核对处理分类变更")
if payload.customer_type:
customer = session.get(Customer, row.customer_id, with_for_update=True)
if customer.customer_type and customer.customer_type != payload.customer_type:
raise BadRequestError("客户已有核算类型,请刷新后核对")
customer.customer_type = payload.customer_type
ids = {item.product_id for item in payload.products}
actual = set(session.scalars(select(BusinessItem.product_id).where(BusinessItem.business_id == business_id, BusinessItem.product_id.in_(ids))))
if ids != actual:
raise BadRequestError("产品不属于本业务")
for value in payload.products:
product = session.get(Product, value.product_id, with_for_update=True)
if product.product_category and product.product_category != value.category:
raise BadRequestError("已有核算分类,请在产品档案核对后调整")
product.product_category = value.category
fill_missing_product_items(session, [product.id], value.category)
session.commit()
return readiness(session, business_id)
def prepare(session, business_id, payload, operator, meta=None):
with source_calculation_lock(session):
return _prepare_locked(session, business_id, payload, operator, meta)
def _prepare_locked(session, business_id, payload, operator, meta=None):
"""将正式源单据与人工成本、归属合成一个不可变核算输入版本。"""
from app.services.audit_service import AuditRecord, audit
with audit(session, operator=operator, meta=meta, record=AuditRecord(module="绩效核算", action="来源业务核算准备", summary=f"业务 #{business_id} 核算资料", request_data=payload.model_dump(mode="json"))):
payroll_months.ensure_allocation_editable(session, payload.month, operator)
row = session.get(Business, business_id, with_for_update=True)
if row is None or not row.integration_json:
raise BadRequestError("该业务不是自动接入的来源业务")
current = _latest(session, business_id)
if (current.version if current else 0) != payload.expected_version:
raise BadRequestError("核算资料版本已变化,请刷新")
state = readiness(session, business_id)
blocked = {r["code"] for r in state["reasons"]} & {"SOURCE_CONFLICT", "SOURCE_SNAPSHOT_PENDING", "CUSTOMER_CATEGORY_PENDING", "PRODUCT_CATEGORY_PENDING", "NO_ACTUAL_SHIPMENT", "SALES_RELATION_PENDING"}
if blocked:
raise BadRequestError("核算资料待补:" + ";".join(LABELS[c] for c in sorted(blocked)))
source = row.integration_json["source_database"]
facts = latest_facts(session, source)
source_docs = list(session.scalars(select(TPlusBusinessDocument).where(TPlusBusinessDocument.business_id == business_id, TPlusBusinessDocument.status == "APPLIED")))
refs = {doc.record_id: doc for doc in source_docs}
raw = {r.id: r for r in session.scalars(select(TPlusSyncRecord).where(TPlusSyncRecord.id.in_(refs)))}
by_target = {(d.entity_type, d.target_id): d for d in source_docs}
targets = list(session.scalars(select(ShipmentItem).join(Shipment).where(Shipment.business_id == business_id)))
assignments = {o.shipment_item_id: o for o in payload.shipment_ownerships}
if set(assignments) != {i.id for i in targets} or len(assignments) != len(payload.shipment_ownerships):
raise BadRequestError("请逐条核实全部发货明细,不可遗漏或重复")
people, people_facts, ships = {}, {}, []
key = f"customer:{row.integration_json['customer_source_id']}"
if current and "handovers" not in payload.model_fields_set:
handovers = current.data_json.get("facts", {}).get("handovers", [])
else:
handovers = [handover.model_dump(mode="json") for handover in payload.handovers]
if any(handover["business_key"] != key for handover in handovers):
raise BadRequestError("交接记录不属于本业务")
if any(handover["mode"] == "ACTIVE" and not handover.get("shipping_ownership_confirmed") for handover in handovers):
raise BadRequestError("在职交接须先逐次确认发货归属")
if any(handover["from_personnel_id"] == handover["to_personnel_id"] for handover in handovers):
raise BadRequestError("移交人与接手人不能相同")
def include_person(pid):
if pid in people:
return
try:
person = session.get(Personnel, pid)
except OverflowError:
raise BadRequestError("交接人员必须是有效的正式人员") from None
role = session.get(BusinessRole, person.business_role_id) if person else None
if not person or not role:
raise BadRequestError("人员或岗位未确认")
fact = facts.get(("PERSONNEL", person.source_no))
people[pid] = person
people_facts[str(pid)] = {**(fact.data_json if fact else {}), "role_code": role.role_code, "status": person.status}
for handover in handovers:
for field in ("from_personnel_id", "to_personnel_id"):
value = handover[field]
if (not value.isascii() or not value.isdigit() or str(int(value)) != value
or not 1 <= int(value) <= 2**64 - 1):
raise BadRequestError("交接人员必须是有效的正式人员")
include_person(int(value))
if role_on(people_facts[handover["to_personnel_id"]], date.fromisoformat(handover["effective_date"])) not in ("salesperson", "sales_manager"):
raise BadRequestError("接手人员在交接月份的岗位未确认")
for item in targets:
entry = assignments[item.id]
head = session.get(Shipment, item.shipment_id)
for pid in (entry.personnel_id, entry.manager_id):
if pid is None or pid in people:
continue
include_person(pid)
if role_on(people_facts[str(entry.personnel_id)], head.ship_date) not in ("salesperson", "sales_manager"):
raise BadRequestError("发货月份的人员岗位未确认")
if entry.manager_id is not None and role_on(people_facts[str(entry.manager_id)], head.ship_date) != "sales_manager":
raise BadRequestError("分管人员在发货月份须为经理")
if entry.freight_amount is None:
raise BadRequestError("请逐条核实运费金额;已核实无运费时填写0")
freight = Decimal(entry.freight_amount)
if freight >= item.amount:
raise BadRequestError("运费须小于该明细销售额")
product = session.get(Product, item.product_id)
cost_total = (Decimal(entry.unit_cost) * item.quantity).quantize(Decimal('0.01'), rounding=ROUND_HALF_UP)
line_doc = by_target.get(("shipment_items", item.id))
source_line = raw.get(line_doc.record_id) if line_doc else None
if line_doc and (source_line is None or source_line.payload_json.get("idinventory") is None):
raise BadRequestError("发货来源产品标识缺失,请先核对")
product_source_id = source_line.payload_json.get("idinventory") if source_line else None
if product_source_id is not None:
from app.services.tplus.guidance import resolve_guidance
status, guidance = resolve_guidance(facts, str(product_source_id), line_doc.source_id,
head.ship_date.strftime("%Y-%m"), item.unit)
errors = {"AMBIGUOUS": "指导成本存在多条匹配记录,请先核对",
"UNIT_MISMATCH": "指导成本单位与发货明细不一致,请先核对",
"UNFINALIZED": "指导成本尚未封存,请先核对"}
if status in errors:
raise BadRequestError(errors[status])
if guidance:
submitted_cost = Decimal(entry.unit_cost) if guidance["basis"] == "UNIT" else cost_total
if submitted_cost != Decimal(guidance["amount"]):
raise BadRequestError("指导成本与已封存依据不一致,请先核对")
ship = {
"source_id": str(head.id),
"line_id": str(item.id),
"product_id": str(product_source_id) if product_source_id is not None else None,
"source_no": head.source_no,
"date": head.ship_date.isoformat(), "group_key": key, "business_key": key,
"personnel_id": str(entry.personnel_id), "manager_id": str(entry.manager_id) if entry.manager_id else None,
"amount": str(item.amount - freight), "gross_tax_amount": str(item.amount), "freight_amount": str(freight),
"quantity": str(item.quantity), "unit": item.unit, "category": product.product_category,
"customer_type": session.get(Customer, row.customer_id).customer_type,
"unit_cost": entry.unit_cost, "cost_total": str(cost_total), "pending": []}
ships.append(ship)
if current:
old = {s["line_id"]: s for s in current.data_json["shipments"]}
for ship in ships:
previous = old.get(ship["line_id"])
if previous:
changed = any(previous.get(k) != ship.get(k) for k in ("cost_total", "date", "category", "customer_type", "personnel_id", "manager_id", "freight_amount"))
old_unit_cost = previous.get("unit_cost")
if changed or old_unit_cost is None or Decimal(str(old_unit_cost)) != Decimal(ship["unit_cost"]):
raise BadRequestError("既有发货成本或归属已封存,后续新增单据不得改写历史依据")
cash = list(session.scalars(select(Receipt).where(Receipt.business_id == business_id)))
receipts = []
for receipt in cash:
doc = by_target.get(("receipts", receipt.id))
if doc is None:
raise BadRequestError("回款缺少来源关联")
record = raw[doc.record_id]
if Decimal(str(record.payload_json["amount"])) != receipt.amount or str(record.payload_json["voucherDate"])[:10] != receipt.receipt_date.isoformat():
raise BadRequestError("正式回款与来源不一致,请先核对差异")
fact = facts.get(("RECEIPT", record.source_id))
try:
confirmed, evidence, pending = _voucher_evidence(record.payload_json, fact)
except (TypeError, ValueError):
raise BadRequestError("收款凭证数据异常,请先核对来源") from None
if pending:
raise BadRequestError("收款凭证依据冲突,请先核实")
exclusion = "NOT_GENERATED_VOUCHER" if evidence.get("count") == 0 else None
if not confirmed and not exclusion:
raise BadRequestError("收款凭证状态待核实,不能封存核算资料")
receipts.append({"source_id": str(receipt.id), "source_no": receipt.source_no,
"date": receipt.receipt_date.isoformat(), "group_key": key,
"amount": str(receipt.amount), "voucher_confirmed": confirmed,
"exclusion": exclusion, "pending": [], "voucher_evidence": evidence})
from app.services.receipt_confirmation import ensure_confirmed
ensure_confirmed(session, [receipt.id for receipt, snapshot in zip(cash, receipts, strict=True)
if not snapshot["exclusion"] and receipt.receipt_date.strftime("%Y-%m") <= payload.month])
values = {"input_schema": "TPLUS_SYNC_V1", "group": {"key": key, "scope_confirmed": True, "opening_confirmed": True,
"opening_balance": payload.opening_balance, "historical_settlement_confirmed": True},
"shipments": ships, "receipts": receipts, "facts": {"personnel": people_facts, "handovers": handovers},
"maps": {"personnel": {str(p): p for p in people}, "shipments": {s["source_id"]: int(s["source_id"]) for s in ships},
"shipment_items": {s["line_id"]: int(s["line_id"]) for s in ships}, "receipts": {str(r.id): r.id for r in cash}},
"evidence": payload.evidence, "guidance_finalized": True, "as_of_month": payload.month}
fingerprint = digest(values)
record = PayrollInput(business_id=business_id, version=payload.expected_version + 1, source_database=source,
group_key=key, fingerprint=fingerprint, data_json=values, requested_by=operator.id)
session.add(record)
session.flush()
result = {"input_id": record.id, "version": record.version, "fingerprint": fingerprint}
session.commit()
return result

View File

@ -45,6 +45,7 @@ from app.schemas.calc import CalcReceiptOut
from app.services import audit_service, param_service, personnel_service
from app.services.audit_service import AuditRecord, RequestMeta
from app.services.formula import (
apply_barter,
clamp_weight_r,
converted_profit,
payment_days,
@ -187,9 +188,6 @@ def _load_and_guard(
raise BadRequestError("回款关联的业务不存在")
if business.owner_personnel_id is None:
raise BadRequestError("业务未指定负责营销人员,无法核算(先在业务归属中指定)")
from app.services.receipt_confirmation import ensure_confirmed
ensure_confirmed(session, [receipt.id])
return receipt, business
@ -365,10 +363,12 @@ def _compose_amounts(
performance = (
Decimal("0") if decision.manager_pending else performance_amount(converted, decision.rate)
)
barter_applied = False
# 以物抵顶口径(2026-11-03 决策):抵顶回款线下核算绩效,系统不做自动折算。
# 原回款的 is_barter 标记保留;barter_applied=False 表示没有执行折算。
barter_applied = receipt.is_barter == 1
base_amount = performance
if barter_applied:
discount = parse_fraction(snapshot.get("barter.discount", role_code=role_code_of(person)))
base_amount = apply_barter(performance, discount)
received_total = business.total_received_amount or Decimal("0")
start_ratio = parse_fraction(
snapshot.get("payment.start.ratio", role_code=role_code_of(person))

View File

@ -75,14 +75,7 @@ def list_templates(session) -> PageResult[CalcTemplateOut]:
def list_formula_options() -> list[dict]:
"""骨架下拉(受控集合)。"""
return [
{
"code": spec.code,
"name": spec.name,
"description": spec.description,
"roles": list(spec.roles),
"role_labels": dict(spec.role_labels),
"role_hints": dict(spec.role_hints),
}
{"code": spec.code, "name": spec.name, "description": spec.description, "roles": list(spec.roles)}
for spec in FORMULAS.values()
]

View File

@ -12,17 +12,11 @@ from sqlalchemy.orm import Session
@dataclass(frozen=True, slots=True)
class FormulaSpec:
"""公式骨架元数据:参数角色 → 模板参数映射键。
role_labels 把英文角色键翻译成业务可读的中文标签(模板表单展示),
role_hints 进一步解释该槽位承载的口径(placeholder 提示)。
"""
"""公式骨架元数据:参数角色 → 模板参数映射键。"""
code: str
name: str
roles: tuple[str, ...]
role_labels: dict[str, str]
role_hints: dict[str, str]
description: str
@ -32,40 +26,30 @@ FORMULAS: dict[str, FormulaSpec] = {
code="linear_decay",
name="天数线性递减",
roles=("start", "slope", "floor", "boundary"),
role_labels={"start": "起始比例", "slope": "日递减斜率", "floor": "下限比例", "boundary": "递减截止天数"},
role_hints={"start": "回款第 1 天适用的计提比例", "slope": "每延后一天递减的幅度", "floor": "超过截止天数后固定使用的比例", "boundary": "按日递减的天数上限"},
description="按回款天数线性递减,超过截止天数取下限比例",
),
"fixed_profit": FormulaSpec(
code="fixed_profit",
name="折算毛利固定比例",
roles=("rate",),
role_labels={"rate": "计提比例"},
role_hints={"rate": "折算毛利润额乘以的固定比例"},
description="折算毛利润额 × 固定比例",
),
"fixed_sales": FormulaSpec(
code="fixed_sales",
name="销售额固定比例",
roles=("rate",),
role_labels={"rate": "计提比例"},
role_hints={"rate": "本人销售额乘以的固定比例"},
description="本人销售额 × 固定比例",
),
"manager_share": FormulaSpec(
code="manager_share",
name="经理团队分成",
roles=("team", "company"),
role_labels={"team": "团队分成比例", "company": "全公司分成比例"},
role_hints={"team": "本团队业务员绩效乘以的比例", "company": "全公司业务员绩效乘以的比例"},
description="本团队绩效 × 团队比例 + 全公司绩效 × 公司比例",
),
"prepay_fixed": FormulaSpec(
code="prepay_fixed",
name="预付款固定比例",
roles=("rate",),
role_labels={"rate": "计提比例"},
role_hints={"rate": "预付回款额乘以的固定比例"},
description="预付回款额 × 固定比例",
),
}

View File

@ -1,33 +0,0 @@
"""批量补充空分类,不覆盖已有分类或冻结资料。"""
from sqlalchemy import select, update
from app.core.exceptions import BadRequestError
from app.models import BusinessItem, Customer, PayrollInput, Product
from app.services.audit_service import AuditRecord, audit
def fill_missing_product_items(session, product_ids, category):
"""补齐未封存业务的空分类,保留既有分类及历史核算依据。"""
frozen = select(PayrollInput.id).where(PayrollInput.business_id == BusinessItem.business_id).exists()
session.execute(update(BusinessItem).where(
BusinessItem.product_id.in_(product_ids), BusinessItem.product_category.is_(None), ~frozen,
).values(product_category=category))
def fill_missing(session, model, payload, actor, meta=None):
field = Customer.customer_type if model is Customer else Product.product_category
module = "客户管理" if model is Customer else "产品管理"
with audit(session, operator=actor, meta=meta, record=AuditRecord(
module=module, action="批量补充分类", summary=f"批量补充 {len(payload.ids)} 条档案分类",
request_data=payload.model_dump(mode="json"),
)):
rows = list(session.scalars(select(model).where(model.id.in_(payload.ids)).order_by(model.id).with_for_update()))
if len(rows) != len(payload.ids) or any(getattr(row, field.key) is not None for row in rows):
raise BadRequestError("所选档案不存在或分类已变化,请刷新后重新选择")
for row in rows:
setattr(row, field.key, payload.category)
if model is Product:
fill_missing_product_items(session, payload.ids, payload.category)
session.commit()
return {"updated": len(rows)}

View File

@ -17,7 +17,7 @@ import logging
from dataclasses import dataclass
from datetime import datetime
from sqlalchemy import Row, select
from sqlalchemy import Row
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
@ -422,25 +422,6 @@ def _customer_ownership_log(
)
def batch_change_ownership(session, payload, *, operator, meta=None):
"""只为未归属客户补充同一负责人;主档和逐条留痕同事务提交。"""
with audit_service.audit(session, operator=operator, meta=meta, record=AuditRecord(
module=MODULE_CUSTOMER, action=ACTION_UPDATE,
summary=f"批量补充 {len(payload.ids)} 条客户归属", request_data=payload.model_dump(mode="json"),
)):
_ensure_owner_personnel(session, payload.new_owner_personnel_id)
customers = list(session.scalars(select(Customer).where(Customer.id.in_(payload.ids))
.order_by(Customer.id).with_for_update()))
if len(customers) != len(payload.ids) or any(customer.owner_personnel_id is not None for customer in customers):
raise BadRequestError("所选客户不存在或归属已变化,请刷新后重新选择")
change = CustomerOwnershipIn(new_owner_personnel_id=payload.new_owner_personnel_id, reason=payload.reason)
for customer in customers:
customer.owner_personnel_id = payload.new_owner_personnel_id
customer_repository.add_ownership_log(session, _customer_ownership_log(customer, change, None, operator))
session.commit()
return {"updated": len(customers)}
def change_ownership(
session: Session,
customer_id: int,
@ -476,10 +457,7 @@ def change_ownership(
):
if prefetch.row is None:
raise NotFoundError("客户不存在")
customer = session.scalar(select(Customer).where(Customer.id == customer_id)
.with_for_update().execution_options(populate_existing=True))
if customer is None:
raise NotFoundError("客户不存在")
customer, _ = prefetch.row
old_owner_id = _validate_customer_ownership(session, customer, payload)

View File

@ -73,11 +73,8 @@ def converted_profit(revenue: Decimal, rate: Fraction, threshold: Fraction) -> D
rate 为纯比例(profit_rate 的输出);threshold 为 % 数(存储口径,如 30)。
化简式等价于定义式「毛利润额 / 毛利润率 × (毛利润率 − 阈值)」,且避免
毛利润率为 0 时除零(对照表 #12/#13)。毛利润率低于阈值时结果为负 ——
业务确认负值正常计算、不归零。零毛利润是独立特例:rate=0 时折算为 0,
不能将该特例扩大为低于阈值的折算额一律归零。
业务确认负值正常计算、不归零。
"""
if rate == 0:
return Decimal("0")
return _to_decimal(Fraction(revenue) * (rate - threshold / 100))
@ -87,11 +84,7 @@ def performance_amount(base: Decimal, rate: Fraction) -> Decimal:
def apply_barter(amount: Decimal, discount: Fraction) -> Decimal:
"""保留的历史以物抵顶折算函数;当前正式核算不调用。
当前口径是特殊抵顶由线下核实,系统不自动折算;参数和函数仅为历史
快照兼容保留,新增正式计算路径不得调用。
"""
"""以物抵顶折算:应发绩效 × 折算比例(discount 为 % 数,默认 80,对照表 #24)。"""
return _to_decimal(Fraction(amount) * discount / 100)

View File

@ -43,13 +43,10 @@ def record_failure(username: str) -> int:
正好给撞库留出缝隙。
"""
key = _key(username)
# INCR 和 EXPIRE 必须同事务提交。否则两步之间断连会留下无 TTL 的计数,
# 该用户随后会被永久锁定;pipeline(transaction=True) 用 MULTI/EXEC 原子生效。
pipe = get_redis().pipeline(transaction=True)
pipe.incr(key)
pipe.expire(key, get_settings().login_lock_minutes * 60)
count, _ = pipe.execute()
return int(count)
redis = get_redis()
count = int(redis.incr(key))
redis.expire(key, get_settings().login_lock_minutes * 60)
return count
def clear(username: str) -> None:

View File

@ -190,20 +190,6 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
description="累计回款达到不含运费销售额的设定比例后,开始计付绩效。启用前应收先冲抵,再核算新业务。",
sort_order=11,
),
ParamSpec(
code="receipt.confirmation.enabled",
name="回款人工确认开关",
group="回款确认",
value_type="BOOL",
unit=None,
dimension_type="NONE",
options=None,
default_value="1",
value_min=None,
value_max=None,
description="默认开启:未人工确认的回款不得正式核算。关闭仅跳过人工确认,仍检查凭证等资格;与工资审核开关独立。",
sort_order=16,
),
ParamSpec(
code="payment.weight.mode",
name="支付权重应用方式",

View File

@ -622,14 +622,14 @@ def _preview_earning(ctx: _PreviewContext, decision: _PreviewRateDecision) -> _P
def _preview_barter(ctx: _PreviewContext, performance: Decimal) -> _PreviewBarter:
"""以物抵顶标记(2026-11-03 口径:抵顶线下核算,系统不做自动折算)。
applied 为 False 表示没有自动折算;base_amount 照常取全额应发绩效,
after_barter 恒 None。barter.discount 参数保留作历史兼容,不参与当前试算。
"""
applied = False
base_amount = performance
return _PreviewBarter(applied=applied, after_barter=None, base_amount=base_amount)
"""以物抵顶折算(按应发绩效的固定比例折算,比例来自参数)。"""
applied = ctx.payload.barter
after_barter: Decimal | None = None
if applied:
discount = ctx.take("barter.discount")
after_barter = formula.apply_barter(performance, discount)
base_amount = after_barter if applied else performance
return _PreviewBarter(applied=applied, after_barter=after_barter, base_amount=base_amount)
def _preview_payment(

View File

@ -24,13 +24,12 @@ from app.models import (
from app.services import audit_service, formula, payroll_months
from app.services.audit_service import AuditRecord
from app.services.param_service import resolve_snapshot
from app.services.personnel_roles import input_role
from app.services.tplus.calculation_preview import _parameters
from app.services.tplus.fifo import START, _beneficiary, amount_text, money, preview_group
from app.services.tplus.workbench import digest
# 核算规则版本:改公式或口径时同步更新,写入台账快照用于追溯。
RULE_VERSION = "2026-11-04.1"
RULE_VERSION = "2026-10-30.2"
def latest_input(session, business_id, *, for_update=False):
@ -79,8 +78,6 @@ def _previous(session, business_id, month):
def _inputs(session, record, until):
from app.services.receipt_confirmation import ensure_confirmed
values = copy.deepcopy(record.data_json)
ships, cash = values["shipments"], values["receipts"]
for row in cash:
@ -94,10 +91,6 @@ def _inputs(session, record, until):
or current.business_id != record.business_id
):
raise BadRequestError("正式回款与核算资料不一致,请重新核对资料版本")
ensure_confirmed(session, [
values["maps"]["receipts"].get(str(row["source_id"]))
for row in cash if not row.get("exclusion") and row["date"] <= until.isoformat()
])
for row in ships:
item_id = values["maps"].get("shipment_items", {}).get(str(row["line_id"]))
if item_id is not None:
@ -106,7 +99,7 @@ def _inputs(session, record, until):
if (
item is None
or head is None
or item.amount != money(row.get("gross_tax_amount", row["amount"]) if values.get("input_schema") == "TPLUS_SYNC_V1" else row["amount"])
or item.amount != money(row["amount"])
or head.ship_date.isoformat() != row["date"]
or head.business_id != record.business_id
):
@ -164,7 +157,7 @@ def _post(
business_no=business.business_no,
personnel_id=owner,
personnel_name=person.personnel_name,
role_code=input_role(record.data_json, owner, event["effective_date"]) or role.role_code,
role_code=role.role_code,
customer_type=event["customer_type"],
product_category=event["category"],
ship_date=date.fromisoformat(event["shipment_date"]),
@ -220,16 +213,6 @@ def _post(
def calculate(session, business_id, month, *, operator, meta=None, reason=None):
business = session.get(Business, business_id)
if business is not None and business.integration_json:
from app.services.business_source import source_calculation_lock
with source_calculation_lock(session):
return _calculate_locked(session, business_id, month, operator=operator, meta=meta, reason=reason)
return _calculate_locked(session, business_id, month, operator=operator, meta=meta, reason=reason)
def _calculate_locked(session, business_id, month, *, operator, meta=None, reason=None):
"""重算从指定月起级联重建尚未生成工资的月份,同一事务保存全部版本。"""
from app.services.manager_share import month_end
@ -266,16 +249,6 @@ def _calculate_locked(session, business_id, month, *, operator, meta=None, reaso
record = latest_input(session, business_id, for_update=True)
if record is None:
raise BadRequestError("请先补录该业务的正式核算资料")
if record.data_json.get("input_schema") == "TPLUS_SYNC_V1":
from app.services.business_source import LABELS, readiness
state = readiness(session, business_id)
blocking = {r["code"] for r in state["reasons"]} & {
"SOURCE_CONFLICT", "SOURCE_SNAPSHOT_PENDING", "INPUT_REFRESH_REQUIRED", "CUSTOMER_CATEGORY_PENDING",
"PRODUCT_CATEGORY_PENDING", "NO_ACTUAL_SHIPMENT", "SALES_RELATION_PENDING",
}
if blocking:
raise BadRequestError("核算资料待补:" + ";".join(LABELS[code] for code in sorted(blocking)))
if reason:
old_rows = list(
session.scalars(

View File

@ -37,11 +37,6 @@ def detail(session, business_id):
def setup(session, business_id, payload, operator, meta=None):
source_business = session.get(Business, business_id)
if source_business and source_business.integration_json:
from app.services.business_source import prepare
return prepare(session, business_id, payload, operator, meta)
with audit_service.audit(
session,
operator=operator,
@ -126,21 +121,15 @@ def setup(session, business_id, payload, operator, meta=None):
}
)
receipts = list(session.scalars(select(Receipt).where(Receipt.business_id == business_id)))
# 用户 2026-11-03:抵顶按普通业务计算,特殊应得绩效线下核实,
# 不自动乘80%,也不因抵顶标记阻断整个客户的正常平账。
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:
old_unit_cost = old.get("unit_cost")
if (old["cost_total"] != ship["cost_total"] or old["date"] != ship["date"]
or old_unit_cost is None or Decimal(str(old_unit_cost)) != Decimal(ship["unit_cost"])):
raise BadRequestError("原发货月份指导成本已封存,不得随后续回款更改")
if current and "handovers" not in payload.model_fields_set:
handovers = current.data_json.get("facts", {}).get("handovers", [])
else:
handovers = [h.model_dump(mode="json") for h in payload.handovers]
if 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 = {

View File

@ -29,8 +29,10 @@ def month_state(session, month):
}
def lock_month_row(session, month, operator):
"""以月份唯一键串行化首次建行及后续操作,不改变已有制作人。"""
def lock_month(session, month, operator, *, require_maker=True):
"""INSERT 的唯一键串行化首次建行,避免两个请求同时认为月份不存在。"""
if require_maker:
ensure_payroll_writer(operator)
validate_month(month)
values = {"month": month, "maker_id": operator.id, "maker_name": operator.username}
if session.bind.dialect.name == "mysql":
@ -41,15 +43,7 @@ def lock_month_row(session, month, operator):
session.flush()
control = session.scalar(
select(PayrollMonth).where(PayrollMonth.month == month).with_for_update()
.execution_options(populate_existing=True)
)
return control
def lock_month(session, month, operator, *, require_maker=True):
if require_maker:
ensure_payroll_writer(operator)
control = lock_month_row(session, month, operator)
if control.generated_at is not None and control.maker_id != operator.id:
raise ForbiddenError("该月工资由其他制作人生成,不可代为修改或重新生成")
if control.generated_at is None:

View File

@ -14,7 +14,7 @@ MODULES = (
("base:customers", "基础资料", "客户管理", ("view", "create", "update", "delete", "rating", "ownership")),
("business:businesses", "业务数据", "业务管理", ("view", "create", "update", "delete", "status", "ownership")),
("business:shipments", "业务数据", "发货管理", ("view", "create", "update", "delete")),
("business:receipts", "业务数据", "回款管理", ("view", "create", "update", "delete", "confirm")),
("business:receipts", "业务数据", "回款管理", ("view", "create", "update", "delete")),
("business:ratings", "业务数据", "评级标准", ("view", "create", "update", "delete")),
("tplus", "T+ 对接", "采集与导入", ("view", "collect", "retry", "preview", "import", "facts", "export")),
("payroll", "绩效工资", "正式核算与分配", ("view", "prepare", "calculate", "allocation")),
@ -27,7 +27,6 @@ MODULES = (
("reports:payments", "报表统计", "回款统计", ("view", "export")),
)
ACTION_NAMES = {
"confirm": "人工确认回款",
"view": "查看", "create": "新增", "update": "编辑", "delete": "删除",
"status": "变更状态", "password": "重置密码", "regions": "维护区域", "plans": "维护计划",
"rating": "客户评级", "ownership": "变更归属", "export": "导出", "preview": "预览",

View File

@ -1,47 +0,0 @@
"""从已冻结的任职依据取岗位,回补历史工资不改当前人员档案。"""
from sqlalchemy import select
from app.core.exceptions import BadRequestError
from app.models import PayrollInput
def role_on(person, day):
if not person:
return None
month = str(day)[:7]
for period in person.get("role_history", []):
if period["start_month"] <= month <= period["end_month"]:
return period["role_code"]
return person.get("role_code")
def input_role(values, personnel_id, day):
people = values.get("facts", {}).get("personnel", {})
codes = {
role_on(people[source_id], day)
for source_id, target_id in values.get("maps", {}).get("personnel", {}).items()
if target_id == personnel_id and source_id in people
}
if len(codes) > 1:
raise BadRequestError("同一人员的历史岗位依据冲突,请先核实任职资料")
return next(iter(codes), None)
def frozen_roles(session, rows, day):
"""按入结台账引用的资料版本解析任职,冲突时拒绝猜测。"""
inputs = {}
for row in rows:
snapshot = row.snapshot_json or {}
if snapshot.get("engine") == "FIFO" and snapshot.get("input_id"):
inputs.setdefault(snapshot["input_id"], set()).add(row.personnel_id)
result = {}
for record in session.scalars(select(PayrollInput).where(PayrollInput.id.in_(inputs))):
for pid in inputs[record.id]:
role = input_role(record.data_json, pid, day)
if role is None:
raise BadRequestError("月结人员的历史岗位依据缺失,请先核实任职资料")
if pid in result and result[pid] != role:
raise BadRequestError("同一人员的历史岗位依据冲突,请先核实任职资料")
result[pid] = role
return result

View File

@ -1,56 +0,0 @@
"""回款人工确认门槛;与生成凭证、保存资料和工资审核分别管理。"""
import hashlib
import json
from datetime import date
from decimal import Decimal
from sqlalchemy import select
from app.core.exceptions import BadRequestError
from app.models import Receipt
from app.services.param_service import resolve_snapshot
CONFIRMATION_PARAM = "receipt.confirmation.enabled"
def enabled(session) -> bool:
"""操作开关取当前生效值,不按历史到账日绕过本次人工确认门槛。"""
return resolve_snapshot(session, date.today()).boolean(CONFIRMATION_PARAM)
def revision(receipt) -> str:
"""只绑定经办人核对的业务内容;备注和同步时间不使确认无故失效。"""
values = [
receipt.id, receipt.business_id, receipt.shipment_id,
receipt.receipt_date.isoformat(), format(Decimal(receipt.amount).quantize(Decimal("0.01")), "f"),
receipt.is_barter, receipt.t_plus_id, receipt.source_no,
]
return hashlib.sha256(json.dumps(values, ensure_ascii=False).encode()).hexdigest()
def is_confirmed(receipt) -> bool:
return bool(
receipt.confirmation_status == 1
and receipt.confirmed_by
and receipt.confirmed_at
and receipt.confirmed_revision == revision(receipt)
)
def invalidate(receipt) -> None:
receipt.confirmation_status = 0
receipt.confirmed_by = receipt.confirmed_at = receipt.confirmed_revision = None
def ensure_confirmed(session, receipt_ids) -> None:
"""在原核算事务内锁定并核实回款;关闭只跳过人工门槛,不改变凭证资格。"""
ids = sorted(set(receipt_ids))
if not ids or not enabled(session):
return
rows = list(session.scalars(
select(Receipt).where(Receipt.id.in_(ids)).order_by(Receipt.id)
.with_for_update().execution_options(populate_existing=True)
))
if len(rows) != len(ids) or any(not is_confirmed(row) for row in rows):
raise BadRequestError("存在未人工确认或确认后内容已变化的回款,请先在回款管理逐笔确认")

View File

@ -24,7 +24,7 @@
import logging
from dataclasses import dataclass
from datetime import date, datetime
from datetime import date
from decimal import Decimal
from sqlalchemy.exc import IntegrityError
@ -38,7 +38,7 @@ from app.core.constants import (
NO_PREFIX_RECEIPT,
)
from app.core.db_errors import is_duplicate_entry
from app.core.exceptions import BadRequestError, ConflictError, NotFoundError
from app.core.exceptions import BadRequestError, NotFoundError
from app.core.response import PageResult
from app.models import Business, Receipt
from app.models.business import BUSINESS_STATUS_ACTIVE
@ -50,7 +50,7 @@ from app.schemas.receipt import (
ReceiptQuery,
ReceiptUpdateIn,
)
from app.services import audit_service, receipt_confirmation
from app.services import audit_service
from app.services.audit_service import AuditRecord, RequestMeta
from app.services.business_service import _recalc_totals
from app.services.formula import payment_days
@ -81,10 +81,6 @@ def _to_out(row: tuple) -> ReceiptOut:
amount=str(receipt.amount),
is_barter=receipt.is_barter,
settlement_status=receipt.settlement_status,
confirmation_status=1 if receipt_confirmation.is_confirmed(receipt) else 0,
confirmed_by=receipt.confirmed_by,
confirmed_at=receipt.confirmed_at,
confirmation_revision=receipt_confirmation.revision(receipt),
payment_days=(
payment_days(ship_date, receipt.receipt_date) if ship_date is not None else None
),
@ -155,33 +151,6 @@ def get_detail(session: Session, receipt_id: int) -> ReceiptOut:
return _to_out(row)
def confirm_receipt(session, receipt_id, payload, *, operator, meta=None) -> ReceiptOut:
"""独立确认已核对内容;锁内复核指纹,重复确认不改首次确认人/时间。"""
from app.services.authorization_service import require_permission
require_permission(session, operator, "business:receipts:confirm")
with audit_service.audit(
session, operator=operator,
record=AuditRecord(module=MODULE_RECEIPT, action="人工确认", summary=f"人工确认回款 #{receipt_id}"),
meta=meta,
):
receipt = receipt_repository.get_by_id(session, receipt_id)
if receipt is None:
raise NotFoundError("回款不存在")
_ensure_business_writable(session, receipt.business_id, "确认")
session.refresh(receipt, with_for_update=True)
if payload.expected_revision != receipt_confirmation.revision(receipt):
raise ConflictError("回款内容已变化,请重新查看并核对后确认")
if not receipt_confirmation.is_confirmed(receipt):
receipt.confirmation_status = 1
receipt.confirmed_by = operator.username
receipt.confirmed_at = datetime.now().replace(microsecond=0)
receipt.confirmed_revision = payload.expected_revision
out = get_detail(session, receipt_id)
session.commit()
return out
@dataclass(frozen=True, slots=True)
class _CreatedReceipt:
"""建行结果(出参 + 固化的 ID/批次号,commit 后不再触碰 ORM 属性)。"""
@ -327,13 +296,9 @@ def update_receipt(
):
if receipt is None:
raise NotFoundError("回款不存在")
from app.services.business_source import ensure_source_editable
ensure_source_editable(session, "receipts", receipt_id)
business = _ensure_business_writable(session, receipt.business_id, "修改")
session.refresh(receipt, with_for_update=True)
if receipt.settlement_status == SETTLEMENT_DONE:
raise BadRequestError("已核算回款禁止修改")
business = _ensure_business_writable(session, receipt.business_id, "修改")
# 月结锁定拦截:原月与新月都不得落入已锁定月份(改到账日=跨月搬数,
# 只查新月会让「从锁定月改到未锁定月」绕过拦截)
from app.services import settlement_service
@ -346,14 +311,11 @@ def update_receipt(
session, payload.shipment_id, receipt.business_id
)
old_revision = receipt_confirmation.revision(receipt)
receipt.receipt_date = payload.receipt_date
receipt.shipment_id = payload.shipment_id
receipt.amount = Decimal(payload.amount)
receipt.is_barter = payload.is_barter
receipt.remark = payload.remark
if receipt_confirmation.revision(receipt) != old_revision:
receipt_confirmation.invalidate(receipt)
_recalc_totals(session, business)
out = _to_out((receipt, business.business_no, shipment_no, ship_date))
batch_no = receipt.receipt_batch_no
@ -391,9 +353,6 @@ def delete_receipt(
):
if receipt is None:
raise NotFoundError("回款不存在")
from app.services.business_source import ensure_source_editable
ensure_source_editable(session, "receipts", receipt_id)
if receipt.settlement_status == SETTLEMENT_DONE:
raise BadRequestError("已核算回款禁止删除")
business = _ensure_business_writable(session, receipt.business_id, "删除")

View File

@ -280,7 +280,7 @@ def _write_row(sheet, columns: tuple[_Column, ...], row: dict) -> None:
def _cell_value(column: _Column, raw):
"""单值转换:标签列转中文,文本列防公式注入。"""
if raw is None:
return "待核对" if column.key == "total_sale_amount" else None
return None
if column.kind == "amount":
return raw if isinstance(raw, Decimal) else Decimal(str(raw))
if column.kind == "date":
@ -317,9 +317,6 @@ def _write_total(sheet, columns: tuple[_Column, ...], rows: list[dict]) -> None:
totals.append("合计" if not label_written else None)
label_written = True
continue
if column.key == "total_sale_amount" and any(row.get(column.key) is None for row in rows):
totals.append("待核对")
continue
total = sum(
(
row.get(column.key)

View File

@ -812,31 +812,14 @@ def _unassigned_rows(
# ==== 业务统计(FR-031 之一)====
def _pending_sales_count(session, query):
from app.services.business_source import sales_relations_pending
count, cursor = 0, 0
while True:
rows = report_repository.list_source_business_sales(session, query, after_id=cursor, limit=200)
if not rows:
return count
pending = sales_relations_pending(session, [row.id for row in rows])
count += len(pending | {row.id for row in rows if row.total_sale_amount is None})
cursor = rows[-1].id
def business_stats(session, query) -> dict:
"""业务统计分页 + 全筛选合计;回款/绩效为业务累计值。"""
rows = report_repository.list_business_stats(
session, query, offset=query.offset, limit=query.page_size
)
sums = report_repository.sum_business_stats(session, query)
pending_count = _pending_sales_count(session, query)
from app.services.business_source import sales_relations_pending
pending = sales_relations_pending(session, [row[0].id for row in rows if row[0].integration_json])
return {
"list": [_business_row(r, sales_pending=r[0].id in pending) for r in rows],
"list": [_business_row(r) for r in rows],
"total": report_repository.count_business_stats(session, query),
"pageNum": query.page_num,
"pageSize": query.page_size,
@ -844,15 +827,14 @@ def business_stats(session, query) -> dict:
"row_count": sums["row_count"],
"ship_total": _q2(_dec(sums["ship_total"])),
"received_total": _q2(_dec(sums["received_total"])),
"sale_total": None if pending_count else _q2(_dec(sums["sale_total"])),
"sale_pending_count": pending_count,
"sale_total": _q2(_dec(sums["sale_total"])),
"performance_total": _q2(_dec(sums["performance_total"])),
"payable_total": _q2(_dec(sums["payable_total"])),
},
}
def _business_row(row, *, sales_pending=False) -> dict:
def _business_row(row) -> dict:
"""业务统计行(ORM 行与三个联查列)。"""
business = row[0]
return {
@ -862,7 +844,7 @@ def _business_row(row, *, sales_pending=False) -> dict:
"status": business.status,
"customer_name": row.customer_name,
"owner_personnel_name": row.owner_personnel_name,
"total_sale_amount": None if sales_pending else business.total_sale_amount,
"total_sale_amount": business.total_sale_amount,
"total_ship_amount": business.total_ship_amount,
"total_received_amount": business.total_received_amount,
"performance_amount": row.performance_amount,
@ -1100,10 +1082,7 @@ def _export_payload(session, code: str, query) -> tuple[list[dict], dict]:
session, query, offset=0, limit=REPORT_EXPORT_MAX_ROWS
)
sums = report_repository.sum_business_stats(session, query)
from app.services.business_source import sales_relations_pending
pending = sales_relations_pending(session, [row[0].id for row in rows if row[0].integration_json])
return [_business_row(r, sales_pending=r[0].id in pending) for r in rows], {
return [_business_row(r) for r in rows], {
"title": EXPORT_TITLES["business-stats"],
"row_count": total,
"filters": _filter_text(session, query),

View File

@ -2,7 +2,8 @@
需求 2.5.1 定义 admin / user 两个登录角色;2026-10-15 复核审批补充需求
新增营销后勤/财务/领导三个职能登录角色(营销后勤与财务/领导分区于审批
节点准入)。权限点按当前权限目录分配,管理员角色拥有固定权限基线。
节点准入)。本模块仍为轻量版(不含权限点分配 —— `permissions` /
`role_permissions` 表继续闲置,待业务方给出权限清单后再启用)。
保护规则(都是系统自举与授权安全的最低防线,不只是界面体验):
@ -14,7 +15,8 @@
剥离绑定用户的权限,属安全与审计事故;删除前的校验走 `FOR UPDATE`
锁行,堵住「校验与删除之间并发绑定」的窗口
- `role_code` 创建后不可改 —— 它是授权判定(`BUILTIN_ROLE_CODES` 白名单按编码比对)的锚点
- 停用角色不解除用户绑定,但该角色不再提供有效授权,也不可再分配给用户。
- 停用角色**不解除绑定、不影响既有授权**(授权判定只看编码、不看状态),
但**不可再分配给用户**(`user_service._resolve_roles` 拦截)
"""
import logging
@ -33,17 +35,16 @@ from app.core.constants import (
)
from app.core.exceptions import BadRequestError, ForbiddenError, NotFoundError
from app.core.response import PageResult
from app.models import Role, RolePermission, User
from app.models import Role, User
from app.repositories import role_repository
from app.schemas.role import RoleCreateIn, RoleManageOut, RoleQuery, RoleUpdateIn
from app.services import audit_service, authorization_management
from app.services import audit_service
from app.services.audit_service import AuditRecord, RequestMeta
from app.services.permission_catalog import CATALOG
logger = logging.getLogger(__name__)
def _to_out(role: Role, user_count: int, permission_count: int = 0) -> RoleManageOut:
def _to_out(role: Role, user_count: int) -> RoleManageOut:
"""ORM 对象转响应模型。禁止把 ORM 对象直接当 response_model。"""
return RoleManageOut(
id=role.id,
@ -53,7 +54,6 @@ def _to_out(role: Role, user_count: int, permission_count: int = 0) -> RoleManag
sort_order=role.sort_order,
status=role.status,
user_count=user_count,
permission_count=len(CATALOG) if role.role_code == "admin" else permission_count,
)
@ -62,10 +62,6 @@ def _user_count(session: Session, role_id: int) -> int:
return role_repository.count_users_by_role(session, [role_id]).get(role_id, 0)
def _permission_count(session: Session, role_id: int) -> int:
return role_repository.count_permissions_by_role(session, [role_id]).get(role_id, 0)
def list_roles(session: Session, query: RoleQuery) -> PageResult[RoleManageOut]:
"""分页查询角色。
@ -75,9 +71,8 @@ def list_roles(session: Session, query: RoleQuery) -> PageResult[RoleManageOut]:
roles = role_repository.list_roles(session, query)
total = role_repository.count_roles(session, query)
counts = role_repository.count_users_by_role(session, [role.id for role in roles])
permissions = role_repository.count_permissions_by_role(session, [role.id for role in roles])
return PageResult[RoleManageOut](
records=[_to_out(role, counts.get(role.id, 0), permissions.get(role.id, 0)) for role in roles],
records=[_to_out(role, counts.get(role.id, 0)) for role in roles],
total=total,
page_num=query.page_num,
page_size=query.page_size,
@ -95,9 +90,6 @@ def create_role(
:raises BadRequestError: 角色编码已存在(含并发提交的兜底)
"""
audit_data = payload.model_dump(exclude_none=True)
if payload.permission_codes is not None and not payload.reason:
audit_data["reason"] = "创建角色并配置初始权限"
with audit_service.audit(
session,
operator=operator,
@ -105,18 +97,13 @@ def create_role(
module=MODULE_ROLE,
action=ACTION_CREATE,
summary=f"新增角色 {payload.role_name}({payload.role_code})",
request_data=audit_data,
request_data=payload.model_dump(exclude_none=True),
),
meta=meta,
):
if role_repository.get_by_code(session, payload.role_code) is not None:
raise BadRequestError(f"角色编码 {payload.role_code!r} 已存在")
codes = None
policy = None
if payload.permission_codes is not None:
policy = authorization_management.lock_policy(session, operator)
codes = authorization_management.validate_role_permissions(payload.permission_codes)
role = Role(
role_code=payload.role_code,
role_name=payload.role_name,
@ -126,12 +113,7 @@ def create_role(
)
try:
role_repository.create(session, role)
if codes is not None:
ids = authorization_management.active_permission_ids(session, codes)
session.add_all([RolePermission(role_id=role.id, permission_id=ids[code]) for code in codes])
authorization_management.bump(policy, operator)
session.flush()
out = _to_out(role, 0, len(codes) if codes is not None else 0)
out = _to_out(role, 0)
session.commit()
except IntegrityError as exc:
# 先查后写不是原子操作:并发提交同一编码会撞 uk_roles_role_code。
@ -174,7 +156,7 @@ def update_role(
role.description = payload.description
role.sort_order = payload.sort_order
# 响应与后置动作全部在 commit 之前完成(见 user_service 顶部说明)
out = _to_out(role, _user_count(session, role_id), _permission_count(session, role_id))
out = _to_out(role, _user_count(session, role_id))
session.commit()
logger.info("修改角色 | role_id=%s", role_id)
return out
@ -190,7 +172,8 @@ def set_status(
) -> RoleManageOut:
"""启用或禁用角色。
停用不解除用户绑定,但该角色不再提供有效授权,也不可再分配给用户;
停用不解除用户绑定、不影响既有授权(授权判定只看编码不看状态),
但该角色不可再分配给用户(`user_service._resolve_roles` 拦截);
内置角色不可停用。
:raises NotFoundError: 角色不存在
@ -216,7 +199,7 @@ def set_status(
raise ForbiddenError("内置角色不可停用")
role.status = status
out = _to_out(role, _user_count(session, role_id), _permission_count(session, role_id))
out = _to_out(role, _user_count(session, role_id))
session.commit()
logger.info("变更角色状态 | role_id=%s | status=%s", role_id, status)
return out

View File

@ -16,9 +16,6 @@ def build_items(session, rows, month):
from app.services.settlement_service import _salary_mode_of
end = month_end(month)
from app.services.personnel_roles import frozen_roles
historical_roles = frozen_roles(session, rows, end)
# 人员锁同时串行化不同工资月份对同一年度缓发余额的结清。
people = {
p.id: p for p in session.scalars(select(Personnel).order_by(Personnel.id).with_for_update())
@ -87,7 +84,7 @@ def build_items(session, rows, month):
for pid, values in sorted(buckets.items()):
person = values["person"]
profile = profiles[pid]
role = historical_roles.get(pid, roles.get(profile.business_role_id, "salesperson"))
role = roles.get(profile.business_role_id, "salesperson")
mode = _salary_mode_of(session, role, month)
annual = sum((Decimal(r["delta"]) for r in values["annual"]), ZERO)
normal = values["normal"]

View File

@ -90,9 +90,6 @@ def generate(
_release_previous_round(session, existing, month)
ledger_rows = _confirm_scope(session, month)
from app.services.receipt_confirmation import ensure_confirmed
ensure_confirmed(session, [row.receipt_id for row in ledger_rows if row.receipt_id is not None])
from app.core.config import get_settings
if not ledger_rows and get_settings().payroll_allow_legacy_calculation:

View File

@ -70,7 +70,7 @@ def _to_out(row: tuple[Shipment, str]) -> ShipmentOut:
business_id=shipment.business_id,
business_no=business_no,
ship_date=shipment.ship_date,
freight_amount=str(shipment.freight_amount) if shipment.freight_amount is not None else None,
freight_amount=str(shipment.freight_amount),
total_amount=str(shipment.total_amount),
remark=shipment.remark,
)
@ -302,9 +302,6 @@ def update_shipment(
):
if shipment is None:
raise NotFoundError("发货单不存在")
from app.services.business_source import ensure_source_editable
ensure_source_editable(session, "shipments", shipment_id)
business = _ensure_business_writable(session, shipment.business_id, "编辑发货单")
# 月结锁定拦截:原发货日与新月都不得落入已锁定月份(改日期=跨月搬数)
from app.services import settlement_service
@ -358,9 +355,6 @@ def delete_shipment(
):
if shipment is None:
raise NotFoundError("发货单不存在")
from app.services.business_source import ensure_source_editable
ensure_source_editable(session, "shipments", shipment_id)
business = _ensure_business_writable(session, shipment.business_id, "删除发货单")
# 月结锁定拦截:已锁定月份的发货单是月结快照的源数据,不得删除
from app.services import settlement_service

View File

@ -126,23 +126,14 @@ def save_logo(
setting = get_setting(session)
old_path = setting.logo_path
# 保留同名旧文件内容,避免覆盖后数据库提交失败造成 logo 断链;
# 不同扩展名的新文件在提交失败时删除,不改变当前生效文件。
# 先写新文件、commit 成功后再清旧扩展名(若把清理放在 commit 之前,
# commit 失败会让库指旧路径而旧文件已删——断链无补偿);同扩展名
# 由写入覆盖,无需也不会误删新文件
target = _logo_dir() / f"logo.{ext}"
previous_content = target.read_bytes() if target.exists() else None
try:
target.write_bytes(content)
setting.logo_path = f"logo/logo.{ext}"
session.commit()
except Exception:
try:
if previous_content is None:
target.unlink(missing_ok=True)
else:
target.write_bytes(previous_content)
except OSError:
logger.warning("logo 数据库回滚后的文件恢复失败", exc_info=True)
raise
target.write_bytes(content)
setting.logo_path = f"logo/logo.{ext}"
session.commit()
old_ext = Path(old_path).suffix.lstrip(".") if old_path else None
if old_ext and old_ext != ext:

Binary file not shown.

View File

@ -125,11 +125,7 @@ def _build(session, run):
row["action"], row["reason"] = "SKIP", "CUSTOMER_SCOPE_ALREADY_SELECTED"
continue
seen_customers.add(customer_id)
active_ships = [s for s in values["shipments"] if not s.get("performance_excluded")]
if not active_ships:
row["action"], row["reason"] = "SKIP", "CORRECTION_EXCLUDED"
continue
if any(not ship.get("guidance_finalized") for ship in active_ships):
if any(not ship.get("guidance_finalized") for ship in values["shipments"]):
row["reason"] = "GUIDANCE_FINALIZATION_REQUIRED"
continue
business_key = "customer:" + customer_id
@ -154,10 +150,7 @@ def _build(session, run):
):
for source_id in ids:
linked = _link(session, run.source_database, entity, source_id)
source_rows = values[entity]
excluded = all(r.get("exclusion") or r.get("performance_excluded")
for r in source_rows if r["source_id"] == source_id)
if linked and not excluded and (
if linked and (
not link
or not current
or source_id not in current.data_json["maps"].get(entity, {})
@ -165,7 +158,7 @@ def _build(session, run):
row["action"], row["reason"] = "CONFLICT", "SOURCE_ALREADY_IMPORTED"
if not _link(session, run.source_database, "customers", values["group"]["customer_id"]):
row["action"], row["reason"] = "PENDING", "CUSTOMER_IMPORT_REQUIRED"
for ship in active_ships:
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}
@ -204,9 +197,8 @@ def _create(session, run, row, frozen):
)
if len(candidates) == 1:
people[code] = candidates[0]
active_ships = [s for s in values["shipments"] if not s.get("performance_excluded")]
needed = {str(s["personnel_id"]) for s in active_ships}
needed.update(str(s["manager_id"]) for s in active_ships if s.get("manager_id"))
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():
@ -219,8 +211,8 @@ def _create(session, run, row, frozen):
else Business(
business_no=_new_no(session, Business, "business_no", "YW"),
customer_id=customer.target_id,
owner_personnel_id=people[str(active_ships[0]["personnel_id"])].id,
business_date=min(date.fromisoformat(s["date"]) for s in active_ships),
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"],

View File

@ -1,714 +0,0 @@
"""将已发布的源单据接入业务;绝不以核算资料齐全作为建档条件。"""
from collections import Counter
from datetime import date, datetime
from decimal import Decimal, InvalidOperation
from sqlalchemy import String, and_, cast, func, null, or_, select
from sqlalchemy.exc import IntegrityError
from app.models import (
Business,
BusinessItem,
BusinessStatusLog,
Customer,
Personnel,
Product,
Receipt,
Shipment,
ShipmentItem,
TPlusBusinessDocument,
TPlusSourceHead,
TPlusSourceLink,
TPlusSyncRecord,
TPlusWorkLine,
)
from app.services.no_generator import next_no
from app.services.tplus.snapshots import source_manifest, source_statement
from app.services.tplus.workbench import WorkbenchError, digest, latest_facts
START = date(2026, 3, 1)
ENTITIES = ("businesses", "shipments", "receipts")
DETAILS = {"businesses": "business_items", "shipments": "shipment_items"}
SOURCE_FIELDS = {
"businesses": ("idcustomer", "voucherdate", "idclerk", "voucherState"),
"shipments": ("idcustomer", "voucherdate", "idclerk", "voucherState", "isCounteractDelivery"),
"receipts": ("idpartner", "idperson", "idclerk", "voucherDate", "amount", "isReceiveFlag",
"voucherstate", "cancelState", "accounting_voucher_count", "valid_accounting_voucher_count",
"accounting_voucher_id"),
}
def decimal_value(value, scale=2, *, positive=False):
"""拒绝精度丢失;源字符串末尾零不算超出精度。"""
try:
number = Decimal(str(value))
if not number.is_finite() or abs(number) >= Decimal(10) ** (14 - scale):
raise ValueError
if number != number.quantize(Decimal(1).scaleb(-scale)):
raise ValueError
if positive and number <= 0:
raise ValueError
return number
except (ValueError, InvalidOperation):
raise WorkbenchError("SOURCE_VALUE_INVALID") from None
def _source_personnel_id(raw):
person_id = raw.get("idclerk")
if person_id is None:
person_id = raw.get("idperson")
return str(person_id) if person_id is not None else None
def statement(session, source, entity, *, start=None):
query = source_statement(session, source, entity)
if start:
field = "voucherDate" if entity == "receipts" else "voucherdate"
raw_date = func.substr(func.nullif(TPlusSyncRecord.payload_json[field].as_string(), "null"), 1, 10)
projected = TPlusSourceHead.source_date
fallback = and_(projected.is_(None), or_(
raw_date.is_(None), raw_date >= start.isoformat(), ~raw_date.like("____-__-__"),
))
applied = select(TPlusBusinessDocument.id).where(
TPlusBusinessDocument.source_database == source,
TPlusBusinessDocument.entity_type == entity,
TPlusBusinessDocument.source_id == TPlusSyncRecord.source_id,
or_(TPlusBusinessDocument.applied_record_id.is_not(None),
TPlusBusinessDocument.business_id.is_not(None)),
).exists()
query = query.where(or_(projected >= start.isoformat(), ~projected.like("____-__-__"), fallback, applied))
return query
def preview(session, source, start=START):
"""只读统计当前完整快照;无需连接 SQL Server 或写业务表。"""
counts, candidates = {}, {}
for entity in ENTITIES:
query = statement(session, source, entity, start=start)
if entity == "receipts":
query = query.where(TPlusSyncRecord.payload_json["isReceiveFlag"].as_integer() == 1)
observed = query.with_only_columns(TPlusSyncRecord.source_id).subquery()
counts[entity] = session.scalar(select(func.count()).select_from(observed)) or 0
joined = observed.outerjoin(TPlusBusinessDocument, and_(
TPlusBusinessDocument.source_database == source,
TPlusBusinessDocument.entity_type == entity,
TPlusBusinessDocument.source_id == observed.c.source_id,
))
new_count = session.scalar(select(func.count()).select_from(joined).where(
TPlusBusinessDocument.id.is_(None))) or 0
candidates[entity] = {"new": new_count, "existing": counts[entity] - new_count}
conflicts = session.scalar(select(func.count()).select_from(TPlusBusinessDocument).where(
TPlusBusinessDocument.source_database == source, TPlusBusinessDocument.status == "CONFLICT")) or 0
customer_scope = preview_customer_scope(session, source, start)
product_scope = preview_product_scope(session, source, start)
return {"start_date": start.isoformat(), "documents": counts, "candidates": candidates, "existing_conflicts": conflicts,
"customer_scope": customer_scope, "product_scope": product_scope,
"formal_payroll_written": False, "message": "按客户自动建档;缺少人工资料只阻止核算"}
def preview_customer_scope(session, source, start):
"""只读预检客户身份;单据状态、产品及数据库排序规则仍须执行时核验。"""
customer_ids = set()
for entity in ENTITIES:
query = statement(session, source, entity, start=start)
if entity == "receipts":
query = query.where(TPlusSyncRecord.payload_json["isReceiveFlag"].as_integer() == 1)
field = "idpartner" if entity == "receipts" else "idcustomer"
for projected, raw in session.execute(query.with_only_columns(
TPlusSourceHead.source_customer_id, TPlusSyncRecord.payload_json[field].as_string()
)):
value = projected if projected is not None else raw
if value not in (None, "", "null"):
customer_ids.add(str(value))
facts = latest_facts(session, source)
records, customer_links, business_links = {}, {}, set()
ordered = sorted(customer_ids)
for offset in range(0, len(ordered), 500):
ids = ordered[offset:offset + 500]
records.update({row.source_id: row for row in session.scalars(
statement(session, source, "customers").where(TPlusSyncRecord.source_id.in_(ids))
)})
for link in session.scalars(select(TPlusSourceLink).where(
TPlusSourceLink.source_database == source,
or_(and_(TPlusSourceLink.entity_type == "customers", TPlusSourceLink.source_id.in_(ids)),
and_(TPlusSourceLink.entity_type == "businesses", TPlusSourceLink.source_id.in_(["customer:" + i for i in ids]))),
)):
if link.entity_type == "customers":
customer_links[link.source_id] = link.target_id
else:
business_links.add(link.source_id.removeprefix("customer:"))
grouped, problems = {}, []
for source_id in ordered:
if source_id in customer_links or source_id in business_links:
continue
record = records.get(source_id)
if record is None:
problems.append((source_id, "GROUP_SOURCE_MISSING"))
continue
name = str(record.payload_json.get("name") or "").strip()
if not name or len(name) > 128:
problems.append((source_id, "SOURCE_VALUE_INVALID"))
continue
fact = facts.get(("CUSTOMER", source_id))
owner = (fact.data_json if fact else {}).get("owner_name", "")
grouped.setdefault((name.casefold(), str(owner).casefold()), []).append((source_id, name, owner))
for values in grouped.values():
if len(values) > 1:
problems.extend((source_id, "DUPLICATE_SOURCE_IDENTITY") for source_id, _, _ in values)
# 仅查询候选名称;按数据库排序规则匹配后再以来源身份报告,不按名称合并。
names = sorted({item[1] for values in grouped.values() for item in values})
existing = set()
for offset in range(0, len(names), 500):
existing.update((str(name).casefold(), str(owner).casefold()) for name, owner in session.execute(
select(Customer.company_name, Customer.owner_name).where(Customer.company_name.in_(names[offset:offset + 500]))
))
for key, values in grouped.items():
if key in existing:
problems.extend((source_id, "TARGET_UNIQUE_CONFLICT") for source_id, _, _ in values)
problems.sort()
return {"candidate_customers": len(customer_ids), "without_business_link": len(customer_ids - business_links),
"identity_warnings": len(problems), "warning_counts": dict(Counter(reason for _, reason in problems)),
"examples": [{"source_id": source_id, "reason": reason} for source_id, reason in problems[:100]],
"truncated": len(problems) > 100,
"note": "客户范围为候选上限;同名预检不覆盖所有排序规则、产品冲突及单据有效性,执行时仍逐单核对。"}
def preview_product_scope(session, source, start):
"""基于候选单据查找涉及的产品,不读取或创建正式档案。"""
product_ids = set()
for entity, child_entity in DETAILS.items():
parents = list(session.scalars(statement(session, source, entity, start=start)
.with_only_columns(TPlusSyncRecord.source_id)))
for offset in range(0, len(parents), 500):
ids = parents[offset:offset + 500]
raw_parent = cast(TPlusSyncRecord.payload_json["source_parent_id"].as_string(), String)
query = statement(session, source, child_entity).where(or_(
TPlusSourceHead.source_parent_id.in_(ids),
and_(TPlusSourceHead.source_parent_id.is_(None), raw_parent.in_(ids)),
)).with_only_columns(TPlusSyncRecord.payload_json["idinventory"].as_string())
product_ids.update(str(value) for value in session.scalars(query) if value not in (None, "", "null"))
records, linked = {}, set()
ordered = sorted(product_ids)
for offset in range(0, len(ordered), 500):
ids = ordered[offset:offset + 500]
records.update({row.source_id: row for row in session.scalars(
statement(session, source, "products").where(TPlusSyncRecord.source_id.in_(ids))
)})
linked.update(session.scalars(select(TPlusSourceLink.source_id).where(
TPlusSourceLink.source_database == source, TPlusSourceLink.entity_type == "products",
TPlusSourceLink.source_id.in_(ids),
)))
grouped, problems = {}, []
for source_id in ordered:
if source_id in linked:
continue
record = records.get(source_id)
if record is None:
problems.append((source_id, "GROUP_SOURCE_MISSING"))
continue
raw = record.payload_json
name, spec, unit = str(raw.get("name") or "").strip(), str(raw.get("specification") or ""), raw.get("unit_name")
if not name or len(name) > 128 or len(spec) > 128 or (unit and len(unit) > 16):
problems.append((source_id, "SOURCE_VALUE_INVALID"))
continue
grouped.setdefault((name.casefold(), spec.casefold()), []).append((source_id, name, spec))
for values in grouped.values():
if len(values) > 1:
problems.extend((source_id, "DUPLICATE_SOURCE_IDENTITY") for source_id, _, _ in values)
names = sorted({item[1] for values in grouped.values() for item in values})
existing = set()
for offset in range(0, len(names), 500):
existing.update((str(name).casefold(), str(spec).casefold()) for name, spec in session.execute(
select(Product.product_name, Product.spec_model).where(Product.product_name.in_(names[offset:offset + 500]))
))
for key, values in grouped.items():
if key in existing:
problems.extend((source_id, "TARGET_UNIQUE_CONFLICT") for source_id, _, _ in values)
problems.sort()
return {"candidate_products": len(product_ids), "without_product_link": len(product_ids - linked),
"identity_warnings": len(problems), "warning_counts": dict(Counter(reason for _, reason in problems)),
"examples": [{"source_id": source_id, "reason": reason} for source_id, reason in problems[:100]],
"truncated": len(problems) > 100,
"note": "产品范围为候选上限;同名预检不覆盖所有排序规则或单据有效性,执行时仍逐单核对。"}
class Context:
def __init__(self, session, source, operator=None):
self.session, self.source = session, source
self.operator = operator
self.facts = latest_facts(session, source)
self.records, self.targets, self.recalculated = {}, {}, set()
self.people = {r.source_id: r for r in session.scalars(statement(session, source, "personnel"))}
def record(self, entity, source_id):
key = entity, str(source_id)
if key not in self.records:
self.records[key] = self.session.scalar(statement(self.session, self.source, entity).where(TPlusSyncRecord.source_id == str(source_id)))
if self.records[key] is None:
raise WorkbenchError("GROUP_SOURCE_MISSING")
return self.records[key]
def link(self, entity, source_id):
return self.session.scalar(select(TPlusSourceLink).where(
TPlusSourceLink.source_database == self.source, TPlusSourceLink.entity_type == entity,
TPlusSourceLink.source_id == str(source_id)))
def add_link(self, entity, source_id, target):
self.session.add(TPlusSourceLink(source_database=self.source, entity_type=entity,
source_id=str(source_id), target_id=target.id))
def master(self, entity, source_id):
key = entity, str(source_id)
if key in self.targets:
return self.targets[key]
model = Customer if entity == "customers" else Product
link = self.link(entity, source_id)
if link:
target = self.session.get(model, link.target_id)
if target is None:
raise WorkbenchError("TARGET_MISSING")
else:
record = self.record(entity, source_id)
raw = record.payload_json
name = str(raw.get("name") or "").strip()
if not name or len(name) > 128:
raise WorkbenchError("SOURCE_VALUE_INVALID")
if entity == "customers":
fact = self.facts.get(("CUSTOMER", str(source_id)))
values = fact.data_json if fact else {}
target = Customer(company_name=name, owner_name=values.get("owner_name", ""),
customer_type=values.get("customer_type"))
conflict = self.session.scalar(select(Customer.id).where(Customer.company_name == name, Customer.owner_name == target.owner_name).limit(1))
else:
fact = self.facts.get(("PRODUCT", str(source_id)))
values = fact.data_json if fact else {}
spec, unit = str(raw.get("specification") or ""), raw.get("unit_name")
if len(spec) > 128 or (unit and len(unit) > 16):
raise WorkbenchError("SOURCE_VALUE_INVALID")
target = Product(product_name=name, spec_model=spec, unit=unit,
product_category=values.get("category"), status=0 if raw.get("disabled") else 1)
conflict = self.session.scalar(select(Product.id).where(Product.product_name == name, Product.spec_model == spec).limit(1))
if conflict:
raise WorkbenchError("TARGET_UNIQUE_CONFLICT")
target.t_plus_id, target.source_no = str(source_id), record.source_no
target.synced_at = datetime.now()
self.session.add(target)
self.session.flush()
self.add_link(entity, source_id, target)
self.targets[key] = target
return target
def business(self, customer_id, day, run_no):
key = "customer:" + str(customer_id)
link = self.link("businesses", key)
if link:
business = self.session.get(Business, link.target_id, with_for_update=True)
if business is None:
raise WorkbenchError("TARGET_MISSING")
if business.integration_json is None:
# 既有完整核算导入不能被新接入器改写。
raise WorkbenchError("LEGACY_BUSINESS_REVIEW_REQUIRED")
return business
customer = self.master("customers", customer_id)
business = Business(business_no=next_no(self.session, model=Business, column_name="business_no", prefix="YW"),
customer_id=customer.id, business_date=day, status=1, is_special=0,
t_plus_id=key, source_no=key, sync_batch_no=run_no, synced_at=datetime.now(),
integration_json={"source_database": self.source, "customer_source_id": str(customer_id), "personnel_names": [], "personnel_codes": []},
total_cost_amount=null(), total_freight_amount=null())
self.session.add(business)
self.session.flush()
self.add_link("businesses", key, business)
self.session.add(BusinessStatusLog(business_id=business.id, old_status=1, new_status=1,
change_reason="T+业务自动建档", operator_name="T+业务接入"))
return business
def person(self, raw):
source_id = _source_personnel_id(raw)
person = self.people.get(source_id)
if not person:
return {"source_personnel_id": source_id, "source_personnel_name": None,
"source_personnel_no": None, "personnel_id": None}
code = str(person.payload_json.get("code") or "")
link = self.link("personnel", person.source_id)
target = self.session.get(Personnel, link.target_id) if link else None
return {"source_personnel_id": source_id, "source_personnel_name": person.payload_json.get("name"), "source_personnel_no": code,
"personnel_id": target.id if target else None}
def details(ctx, entity, source_id):
parent = TPlusSourceHead.source_parent_id
raw_parent = cast(TPlusSyncRecord.payload_json["source_parent_id"].as_string(), String)
return list(ctx.session.scalars(statement(ctx.session, ctx.source, DETAILS[entity]).where(or_(
parent == str(source_id), and_(parent.is_(None), raw_parent == str(source_id)),
)).order_by(TPlusSyncRecord.id)))
def rebuild_projections(session, source):
"""按已发布快照补齐旧 worker 未写入的索引,不改源记录。"""
entities = ("businesses", "business_items", "shipments", "shipment_items", "receipts")
queries = {entity: source_statement(session, source, entity) for entity in entities}
for entity, query in queries.items():
cursor = 0
while True:
missing = (TPlusSourceHead.source_parent_id.is_(None) if entity in DETAILS.values()
else TPlusSourceHead.source_date.is_(None))
rows = session.execute(query.with_only_columns(TPlusSourceHead, TPlusSyncRecord.payload_json)
.where(TPlusSourceHead.id > cursor, missing)
.order_by(TPlusSourceHead.id).limit(500).with_for_update()).all()
if not rows:
break
for head, raw in rows:
parent = raw.get("source_parent_id")
if parent is not None and len(str(parent)) <= 64:
head.source_parent_id = str(parent)
customer = raw.get("idcustomer") or raw.get("idpartner")
if head.source_customer_id is None and customer is not None and len(str(customer)) <= 64:
head.source_customer_id = str(customer)
day = raw.get("voucherdate") or raw.get("voucherDate")
if head.source_date is None and day is not None:
head.source_date = str(day)[:10]
cursor = rows[-1][0].id
session.commit()
def ledger(ctx, entity, record):
row = ctx.session.scalar(select(TPlusBusinessDocument).where(
TPlusBusinessDocument.source_database == ctx.source, TPlusBusinessDocument.entity_type == entity,
TPlusBusinessDocument.source_id == record.source_id).with_for_update())
if row is None:
row = TPlusBusinessDocument(source_database=ctx.source, entity_type=entity, source_id=record.source_id,
record_id=record.id, source_no=record.source_no, data_json={})
ctx.session.add(row)
row.record_id = record.id
return row
def _line_values(ctx, record):
raw = record.payload_json
product = ctx.master("products", raw.get("idinventory"))
quantity = decimal_value(raw.get("quantity"), positive=True)
source_price = raw.get("taxPrice") if raw.get("taxPrice") is not None else raw.get("price")
price = decimal_value(source_price, 4) if source_price is not None else None
amount = decimal_value(raw.get("taxAmount"), positive=True)
return {"product_id": product.id, "product_name": product.product_name, "spec_model": product.spec_model,
"unit": product.unit, "quantity": quantity, "sale_price": price}, amount, product.product_category
def _shipment_order_line(ctx, business_id, child):
"""来源明细号、订单号和产品均一致时,才将发货视为订单的一部分。"""
raw = child.payload_json
order_detail_id = raw.get("saleOrderDetailId")
if order_detail_id is None:
return None, False
doc = ctx.session.scalar(select(TPlusBusinessDocument).where(
TPlusBusinessDocument.source_database == ctx.source,
TPlusBusinessDocument.entity_type == "business_items",
TPlusBusinessDocument.source_id == str(order_detail_id),
TPlusBusinessDocument.business_id == business_id,
))
if doc is None:
return None, False
order_record = ctx.session.get(TPlusSyncRecord, doc.applied_record_id) if doc.applied_record_id else None
order_raw = order_record.payload_json if order_record else {}
parent = order_raw.get("source_parent_id")
order_id = raw.get("saleOrderId")
matches = (doc.status == "APPLIED" and order_record is not None
and order_raw.get("idinventory") is not None
and str(order_raw.get("idinventory")) == str(raw.get("idinventory"))
and (order_id is None or (parent is not None and str(parent) == str(order_id))))
return (doc if matches else None), not matches
def _recalculate(ctx, business):
_refresh_business_totals(ctx.session, business)
ctx.recalculated.add(business.id)
def _refresh_business_totals(session, business):
from app.services.tplus.sales_relations import resolve_items
items = list(session.scalars(select(BusinessItem).where(BusinessItem.business_id == business.id)))
order_ids = list(session.scalars(select(TPlusBusinessDocument.target_id).where(
TPlusBusinessDocument.business_id == business.id, TPlusBusinessDocument.entity_type == "business_items",
TPlusBusinessDocument.status == "APPLIED")))
ambiguous, sale_items = resolve_items(session, business, items, order_ids)
business.total_sale_amount = None if ambiguous else decimal_value(sum((i.sale_amount for i in sale_items), Decimal(0)))
business.total_cost_amount = decimal_value(sum((i.cost_amount for i in sale_items), Decimal(0))) if not ambiguous and sale_items and all(i.cost_amount is not None for i in sale_items) else None
business.integration_json = {**(business.integration_json or {}), "sales_relation_pending": ambiguous}
ships = list(session.scalars(select(Shipment).where(Shipment.business_id == business.id)))
business.total_ship_amount = decimal_value(sum((i.total_amount for i in ships), Decimal(0)))
business.total_freight_amount = decimal_value(sum((i.freight_amount for i in ships), Decimal(0))) if ships and all(i.freight_amount is not None for i in ships) else None
business.total_received_amount = decimal_value(session.scalar(select(func.coalesce(func.sum(Receipt.amount), 0)).where(Receipt.business_id == business.id)))
def _pending_document(ctx, doc, entity, raw, run_no, reason):
"""保留未生效或更正单,并关联可识别的客户业务供核对。"""
date_value = raw.get("voucherDate") if entity == "receipts" else raw.get("voucherdate")
customer_id = raw.get("idpartner") if entity == "receipts" else raw.get("idcustomer")
try:
day = date.fromisoformat(str(date_value)[:10])
except (ValueError, TypeError):
raise WorkbenchError("SOURCE_VALUE_INVALID") from None
if customer_id is None:
raise WorkbenchError("GROUP_CUSTOMER_MISMATCH")
business = ctx.business(customer_id, day, run_no)
doc.business_id = business.id
doc.data_json = {**ctx.person(raw), "source_date": day.isoformat(), "source_customer_id": str(customer_id)}
doc.status, doc.reason = "PENDING", reason
if business.id not in ctx.recalculated:
_recalculate(ctx, business)
return "PENDING", doc
def _apply_document(ctx, entity, record, children, run_no, start):
raw = record.payload_json
doc = ledger(ctx, entity, record)
customer_id = raw.get("idpartner") if entity == "receipts" else raw.get("idcustomer")
previous_customer = (doc.data_json or {}).get("source_customer_id")
if (doc.applied_record_id is None and doc.business_id is not None
and previous_customer is not None and customer_id is not None
and str(previous_customer) != str(customer_id)):
doc.status, doc.reason = "CONFLICT", "PENDING_SOURCE_CUSTOMER_CHANGED"
return "CONFLICT", doc
if doc.applied_record_id is None and doc.business_id is not None:
date_value = raw.get("voucherDate") if entity == "receipts" else raw.get("voucherdate")
try:
source_day = date.fromisoformat(str(date_value)[:10])
except (ValueError, TypeError):
source_day = None
if source_day is not None and source_day < start:
doc.status, doc.reason = "CONFLICT", "PENDING_SOURCE_OUT_OF_RANGE"
return "CONFLICT", doc
fingerprint = digest({"header": {k: raw.get(k) for k in SOURCE_FIELDS[entity]},
"items": [{k: r.payload_json.get(k) for k in ("id", "idinventory", "quantity", "taxPrice", "price", "taxAmount", "saleOrderId", "saleOrderDetailId")} for r in children]})
if doc.applied_record_id is not None:
person_changed = False
header_amount_changed = False
if entity in ("businesses", "shipments"):
if doc.applied_record_id != record.id:
original = ctx.session.get(TPlusSyncRecord, doc.applied_record_id)
header_amount_changed = original is None or original.payload_json.get("taxAmount") != raw.get("taxAmount")
if "source_personnel_id" in doc.data_json:
previous_person = doc.data_json["source_personnel_id"]
previous_person = str(previous_person) if previous_person is not None else None
else:
previous = ctx.session.get(TPlusSyncRecord, doc.applied_record_id)
previous_person = _source_personnel_id(previous.payload_json) if previous else None
person_changed = previous is None
person_changed = person_changed or previous_person != _source_personnel_id(raw)
if doc.fingerprint != fingerprint or doc.source_no != record.source_no or person_changed or header_amount_changed:
doc.status, doc.reason = "CONFLICT", "FORMAL_SOURCE_CHANGED"
return "CONFLICT", doc
if entity in DETAILS:
for child in children:
raw_line = child.payload_json
if raw_line.get("taxPrice") is not None or raw_line.get("price") is None:
continue
line_doc = ctx.session.scalar(select(TPlusBusinessDocument).where(
TPlusBusinessDocument.source_database == ctx.source,
TPlusBusinessDocument.entity_type == DETAILS[entity],
TPlusBusinessDocument.source_id == child.source_id,
TPlusBusinessDocument.business_id == doc.business_id,
))
model = BusinessItem if entity == "businesses" else ShipmentItem
target_line = ctx.session.get(model, line_doc.target_id) if line_doc and line_doc.target_id else None
try:
expected_price = decimal_value(raw_line["price"], 4)
except WorkbenchError:
expected_price = None
if target_line is None or expected_price is None or target_line.sale_price != expected_price:
doc.status, doc.reason = "CONFLICT", "SOURCE_PRICE_REVIEW_REQUIRED"
return "CONFLICT", doc
if entity == "shipments" and doc.business_id is not None:
for child in children:
_, mismatch = _shipment_order_line(ctx, doc.business_id, child)
if mismatch:
doc.status, doc.reason = "CONFLICT", "ORDER_SHIPMENT_MISMATCH"
return "CONFLICT", doc
doc.record_id, doc.status, doc.reason = record.id, "APPLIED", None
if doc.business_id is not None and doc.business_id not in ctx.recalculated:
business = ctx.session.get(Business, doc.business_id, with_for_update=True)
if business is not None:
_recalculate(ctx, business)
return "UNCHANGED", doc
if entity == "receipts" and raw.get("isReceiveFlag") != 1:
doc.status, doc.reason = "EXCLUDED", "NOT_RECEIPT"
return "EXCLUDED", doc
if (entity != "receipts" and raw.get("voucherState") != 189) or (entity == "receipts" and raw.get("voucherstate") != 189):
return _pending_document(ctx, doc, entity, raw, run_no, "SOURCE_DOCUMENT_INACTIVE")
if entity == "shipments" and raw.get("isCounteractDelivery"):
return _pending_document(ctx, doc, entity, raw, run_no, "CORRECTION_PAIR_REQUIRED")
day_text = raw.get("voucherDate") if entity == "receipts" else raw.get("voucherdate")
try:
day = date.fromisoformat(str(day_text)[:10])
except (ValueError, TypeError):
raise WorkbenchError("SOURCE_VALUE_INVALID") from None
if entity != "receipts" and not children:
raise WorkbenchError("SHIPMENT_DETAILS_MISSING")
# 先校验整张单据,避免不完整来源建出空壳业务。
order_mismatch = False
if entity == "receipts":
decimal_value(raw.get("amount"), positive=True)
else:
for child in children:
decimal_value(child.payload_json.get("quantity"), positive=True)
decimal_value(child.payload_json.get("taxAmount"), positive=True)
customer_id = raw.get("idpartner") if entity == "receipts" else raw.get("idcustomer")
if customer_id is None:
raise WorkbenchError("GROUP_CUSTOMER_MISMATCH")
# 新月份可追加,但已生成工资月份的迟到单据必须另行核对。
from app.models import MonthlySettlement
from app.services.payroll_months import lock_month_row
month = day.strftime("%Y-%m")
control = lock_month_row(ctx.session, month, ctx.operator)
locked = ctx.session.scalar(select(MonthlySettlement.id).where(
MonthlySettlement.settlement_month == month).limit(1).with_for_update())
if locked or (control and control.generated_at):
raise WorkbenchError("PAYROLL_MONTH_LOCKED")
business = ctx.business(customer_id, day, run_no)
if business.status != 1:
raise WorkbenchError("BUSINESS_TERMINAL_REVIEW_REQUIRED")
person = ctx.person(raw)
summary = dict(business.integration_json)
for key, value in (("personnel_names", person["source_personnel_name"]), ("personnel_codes", person["source_personnel_no"])):
summary[key] = sorted(set(summary.get(key, []) + ([value] if value else [])))
business.integration_json = summary
doc.business_id = business.id
doc.data_json = {**person, "source_date": day.isoformat(), "source_customer_id": str(customer_id)}
if entity == "receipts":
target = Receipt(business_id=business.id, receipt_batch_no=next_no(ctx.session, model=Receipt, column_name="receipt_batch_no", prefix="HK"),
receipt_date=day, amount=decimal_value(raw["amount"]), is_barter=0, settlement_status=0,
t_plus_id=record.source_id, source_no=record.source_no, sync_batch_no=run_no, synced_at=datetime.now())
ctx.session.add(target)
ctx.session.flush()
ctx.add_link(entity, record.source_id, target)
doc.target_id = target.id
else:
target = None
if entity == "shipments":
target = Shipment(business_id=business.id, shipment_no=next_no(ctx.session, model=Shipment, column_name="shipment_no", prefix="SH"),
ship_date=day, freight_amount=None, total_amount=decimal_value(sum((decimal_value(c.payload_json["taxAmount"]) for c in children), Decimal(0))),
t_plus_id=record.source_id, source_no=record.source_no, sync_batch_no=run_no, synced_at=datetime.now())
ctx.session.add(target)
ctx.session.flush()
ctx.add_link(entity, record.source_id, target)
doc.target_id = target.id if target else business.id
for child in children:
line, amount, category = _line_values(ctx, child)
child_doc = ledger(ctx, DETAILS[entity], child)
if entity == "businesses":
item = BusinessItem(business_id=business.id, **line, sale_amount=amount, product_category=category)
else:
item = ShipmentItem(shipment_id=target.id, **line, amount=amount)
order_line, mismatch = _shipment_order_line(ctx, business.id, child)
order_mismatch = order_mismatch or mismatch
if order_line is None:
ctx.session.add(BusinessItem(business_id=business.id, **line, sale_amount=amount, product_category=category))
ctx.session.add(item)
ctx.session.flush()
child_doc.business_id, child_doc.target_id = business.id, item.id
child_doc.applied_record_id, child_doc.record_id = child.id, child.id
child_doc.fingerprint, child_doc.status, child_doc.reason = child.row_hash, "APPLIED", None
child_doc.data_json = {**person, "source_parent_id": record.source_id, "source_date": day.isoformat(), "source_customer_id": str(customer_id)}
doc.applied_record_id, doc.fingerprint, doc.status, doc.reason = record.id, fingerprint, "APPLIED", None
ctx.session.flush()
_recalculate(ctx, business)
if order_mismatch:
doc.status, doc.reason = "CONFLICT", "ORDER_SHIPMENT_MISMATCH"
return "CONFLICT", doc
return "CREATE", doc
def execute_sync(session, run):
"""按单据提交;中断后幂等重跑,不生成 PayrollInput 或工资。
调用方必须先持有与 T+ 原始采集相同的 MySQL 命名锁;这样完整来源
manifest 在本次接入期间不会被另一进程发布新版本。这里仍在结束时
复核 manifest,作为防御性校验,不在本函数内再次获取同一把锁。
"""
from app.models import User
from app.services.authorization_service import require_permission
operator = session.get(User, run.requested_by)
require_permission(session, operator, "tplus:import")
start = date.fromisoformat(run.input_json.get("start_date", START.isoformat()))
manifest = source_manifest(session, run.source_database)
rebuild_projections(session, run.source_database)
ctx, counts, processed = Context(session, run.source_database, operator), Counter(), 0
for entity in ENTITIES:
# 先固定所有依赖的发布状态;失败的全量采集不得泄露半批数据。
if entity in DETAILS:
statement(session, ctx.source, DETAILS[entity])
cursor = 0
while True:
records = list(session.scalars(statement(session, ctx.source, entity, start=start)
.where(TPlusSourceHead.id > cursor).order_by(TPlusSourceHead.id).limit(200)))
if not records:
break
# record.id 不是 source_head.id(哈希去重可复用旧版本)。
head_ids = dict(session.execute(select(TPlusSourceHead.record_id, TPlusSourceHead.id).where(
TPlusSourceHead.source_database == ctx.source, TPlusSourceHead.entity_type == entity,
TPlusSourceHead.record_id.in_([r.id for r in records]))).all())
for record in records:
try:
with session.begin_nested():
children = details(ctx, entity, record.source_id) if entity in DETAILS else []
action, doc = _apply_document(ctx, entity, record, children, run.run_no, start)
except (WorkbenchError, IntegrityError) as exc:
ctx.targets.clear()
ctx.recalculated.clear()
doc = ledger(ctx, entity, record)
status = "CONFLICT" if isinstance(exc, IntegrityError) else "PENDING"
reason = "TARGET_UNIQUE_CONFLICT" if isinstance(exc, IntegrityError) else str(exc)
if doc.applied_record_id is None:
try:
with session.begin_nested():
_pending_document(ctx, doc, entity, record.payload_json, run.run_no, reason)
except (WorkbenchError, IntegrityError):
ctx.targets.clear()
ctx.recalculated.clear()
doc = ledger(ctx, entity, record)
elif doc.business_id not in ctx.recalculated:
business = session.get(Business, doc.business_id, with_for_update=True)
if business is not None:
_recalculate(ctx, business)
doc.status, doc.reason = status, reason
action = doc.status
processed += 1
counts[action] += 1
session.add(TPlusWorkLine(run_no=run.run_no, line_no=processed, entity_type=entity,
source_id=record.source_id, action=action, reason=doc.reason,
data_json={"source_no": record.source_no, "business_id": doc.business_id, **(doc.data_json or {})}))
run.summary_json = {"counts": dict(counts), "total": processed, "formal_payroll_written": False,
"start_date": start.isoformat(), "phase": "RUNNING", "entity": entity}
session.commit()
cursor = head_ids[record.id]
observed = statement(session, ctx.source, entity).with_only_columns(TPlusSyncRecord.source_id).subquery()
missing = ~select(observed.c.source_id).where(observed.c.source_id == TPlusBusinessDocument.source_id).exists()
cursor = 0
while True:
documents = list(session.scalars(select(TPlusBusinessDocument).where(
TPlusBusinessDocument.source_database == ctx.source,
TPlusBusinessDocument.entity_type == entity,
or_(TPlusBusinessDocument.applied_record_id.is_not(None),
TPlusBusinessDocument.business_id.is_not(None)),
TPlusBusinessDocument.id > cursor,
missing,
).order_by(TPlusBusinessDocument.id).limit(200)))
if not documents:
break
for doc in documents:
doc.status, doc.reason = "CONFLICT", "SOURCE_DOCUMENT_MISSING"
processed += 1
counts["CONFLICT"] += 1
session.add(TPlusWorkLine(run_no=run.run_no, line_no=processed, entity_type=entity,
source_id=doc.source_id, action="CONFLICT", reason=doc.reason,
data_json={"source_no": doc.source_no, "business_id": doc.business_id, **(doc.data_json or {})}))
cursor = documents[-1].id
run.summary_json = {"counts": dict(counts), "total": processed, "formal_payroll_written": False,
"start_date": start.isoformat(), "phase": "RUNNING", "entity": entity}
session.commit()
if source_manifest(session, run.source_database) != manifest:
raise WorkbenchError("SOURCE_REFRESH_REQUIRED")
run.status = "PARTIAL" if counts["PENDING"] or counts["CONFLICT"] else "SUCCEEDED"
run.summary_json = {**(run.summary_json or {}), "phase": "COMPLETED", "source_manifest": manifest}

View File

@ -1,141 +0,0 @@
"""业务接入开关与幂等排队;HTTP 不执行导入。"""
from uuid import NAMESPACE_URL, uuid5
from sqlalchemy import inspect, select
from sqlalchemy.exc import IntegrityError
from app.core.exceptions import BadRequestError, ConflictError, ForbiddenError
from app.models import TPlusBusinessSyncConfig, TPlusWorkRun, User
from app.services.authorization_service import require_permission
from app.services.tplus.business_sync import START, preview
from app.services.tplus.task_service import history_source_database, new_batch_no
from app.services.tplus.workbench import WorkbenchError, digest, work_out
def configuration(session):
source = history_source_database(session)
ready = inspect(session.connection()).has_table(TPlusBusinessSyncConfig.__tablename__)
row = session.scalar(select(TPlusBusinessSyncConfig).where(TPlusBusinessSyncConfig.source_database == source)) if ready else None
return {"enabled": bool(row and row.enabled), "start_date": (row.start_date if row else START).isoformat(),
"version": row.version if row else 0, "editable": ready and bool(source),
"last_error": row.last_error if row else None}
def queue(session, actor_id, start_date, request_key):
require_permission(session, session.get(User, actor_id), "tplus:import")
source = history_source_database(session)
if not source:
raise BadRequestError("请先完成 T+ 数据采集")
key = str(request_key)
found = session.scalar(select(TPlusWorkRun).where(TPlusWorkRun.request_key == key))
values = {"start_date": start_date.isoformat()}
def existing_run(found):
if found.kind != "BUSINESS_SYNC" or found.requested_by != actor_id or found.source_database != source or found.input_json != values:
raise BadRequestError("请求键已用于不同任务")
return work_out(found)
if found:
return existing_run(found)
run = TPlusWorkRun(run_no=new_batch_no(), request_key=key, source_database=source,
requested_by=actor_id, kind="BUSINESS_SYNC", status="QUEUED", input_json=values)
try:
with session.begin_nested():
session.add(run)
session.flush()
session.commit()
except IntegrityError:
session.rollback()
found = session.scalar(select(TPlusWorkRun).where(TPlusWorkRun.request_key == key))
if found is None:
raise ConflictError() from None
return existing_run(found)
return work_out(run)
def save(session, payload, actor_id):
require_permission(session, session.get(User, actor_id), "tplus:import")
source = history_source_database(session)
if not source:
raise BadRequestError("请先完成 T+ 数据采集")
row = session.scalar(select(TPlusBusinessSyncConfig).where(TPlusBusinessSyncConfig.source_database == source).with_for_update())
if (row.version if row else 0) != payload.expected_version:
raise ConflictError()
if row is None:
row = TPlusBusinessSyncConfig(source_database=source, updated_by=actor_id)
session.add(row)
row.enabled, row.start_date = int(payload.enabled), payload.start_date
row.version, row.updated_by, row.last_error = payload.expected_version + 1, actor_id, None
try:
session.commit()
except IntegrityError:
session.rollback()
raise ConflictError() from None
if row.enabled:
schedule(session)
return configuration(session)
def schedule(session):
"""仅成功快照触发;去重周期与配置版本绑定,不改变原采集结果。"""
if not inspect(session.connection()).has_table(TPlusBusinessSyncConfig.__tablename__):
return None
source = history_source_database(session)
config = session.scalar(select(TPlusBusinessSyncConfig).where(TPlusBusinessSyncConfig.source_database == source))
if config is None or not config.enabled:
return None
try:
require_permission(session, session.get(User, config.updated_by), "tplus:import")
except ForbiddenError:
config.last_error = "TASK_AUTHORIZATION_REVOKED"
session.commit()
return None
from app.services.tplus.snapshots import source_manifest
try:
manifest = list(source_manifest(session, source).items())
except WorkbenchError:
config.last_error = "SOURCE_REFRESH_REQUIRED"
session.commit()
return None
key = uuid5(NAMESPACE_URL, digest([source, config.version, manifest, "business-sync-v1"]))
result = queue(session, config.updated_by, config.start_date, key)
if result["status"] != "FAILED" and config.last_error is not None:
config.last_error = None
session.commit()
return result
def readonly_preview(session, start_date=START):
return preview(session, history_source_database(session), start_date)
def record_schedule_failure(session, source):
"""保留采集成功状态,同时在接入设置中提示排队异常。"""
config = session.scalar(select(TPlusBusinessSyncConfig).where(
TPlusBusinessSyncConfig.source_database == source,
TPlusBusinessSyncConfig.enabled == 1,
).with_for_update())
if config:
config.last_error = "BUSINESS_SYNC_SCHEDULE_FAILED"
session.commit()
def record_authorization_failure(session, source, actor_id):
"""仅标记仍由该授权人启用的配置,由调用方提交任务失败状态。"""
config = session.scalar(select(TPlusBusinessSyncConfig).where(
TPlusBusinessSyncConfig.source_database == source,
TPlusBusinessSyncConfig.enabled == 1,
TPlusBusinessSyncConfig.updated_by == actor_id,
).with_for_update())
if config:
config.last_error = "TASK_AUTHORIZATION_REVOKED"
def clear_execution_failure(session, source, actor_id):
"""当前授权人重试成功后清除旧错误,由调用方提交任务结果。"""
config = session.scalar(select(TPlusBusinessSyncConfig).where(
TPlusBusinessSyncConfig.source_database == source,
TPlusBusinessSyncConfig.enabled == 1,
TPlusBusinessSyncConfig.updated_by == actor_id,
).with_for_update())
if config:
config.last_error = None

View File

@ -67,7 +67,6 @@ def _guidance(facts, product_id, line_id, month):
def _shipments(session, run, key, group, facts):
from app.services.tplus.corrections import verified_correction
headers = _group_records(session, run.source_database, "shipments", group["shipment_ids"])
details = source_records(
session, run.source_database, "shipment_items", parent_ids=group["shipment_ids"]
@ -105,7 +104,14 @@ def _shipments(session, run, key, group, facts):
raise WorkbenchError("GROUP_CUSTOMER_MISMATCH")
supplement = facts.get(("SHIPMENT", record.source_id))
supplement = supplement.data_json if supplement else {}
correction = verified_correction(session, run.source_database, facts, "SHIPMENT", head_record)
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"]))
@ -198,12 +204,16 @@ def _shipments(session, run, key, group, facts):
def _receipts(session, run, key, group, facts):
from app.services.tplus.corrections import verified_correction
records = _group_records(session, run.source_database, "receipts", group["receipt_ids"])
rows = []
for record in records:
raw = record.payload_json
correction = verified_correction(session, run.source_database, facts, "RECEIPT", record)
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")
@ -364,31 +374,14 @@ def execute_preview(session, run, *, save_lines=True, customer_scope=False):
]
if entity == "receipts":
source_ids.extend(
f.fact_key.removeprefix("RECEIPT:")
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.removeprefix("RECEIPT:") not in source_ids
and f.fact_key not in source_ids
)
group[target_field] = sorted(source_ids, key=lambda value: int(value))
else:
redirected = [
f.fact_key.removeprefix("RECEIPT:")
for (kind, _), f in facts.items()
if kind == "CORRECTION" and f.data_json.get("entity") == "RECEIPT"
and f.data_json.get("redirect_business_key") == key
]
group["receipt_ids"] = sorted(set(group["receipt_ids"] + redirected), key=int)
def effective_receipts(value, group_key):
from app.services.tplus.corrections import correction_fact
return {
rid for rid in value["receipt_ids"]
if not (f := correction_fact(facts, "RECEIPT", rid))
or f.data_json.get("redirect_business_key") == group_key
}
# 也检查未选组,避免同一单据在另一组被解释为独立资金。
for (kind, other_key), other in facts.items():
if (
@ -397,7 +390,7 @@ def execute_preview(session, run, *, save_lines=True, customer_scope=False):
and not customer_scope
and (
set(group["shipment_ids"]) & set(other.data_json["shipment_ids"])
or effective_receipts(group, key) & effective_receipts(other.data_json, other_key)
or set(group["receipt_ids"]) & set(other.data_json["receipt_ids"])
)
):
overlapping.add(key)

View File

@ -1,195 +0,0 @@
"""转客户更正:成对确认、原始单据保留,同一回款只归一个业务。"""
from decimal import Decimal
from sqlalchemy import select
from app.core.exceptions import BadRequestError
from app.core.tplus_lock import TPlusBusyError, sync_lock
from app.models import TPlusFact, TPlusSourceLink
from app.services.tplus.fifo import money
from app.services.tplus.snapshots import source_records
from app.services.tplus.workbench import (
WorkbenchError,
_work_source_database,
digest,
fact_out,
latest_facts,
)
def correction_fact(facts, entity, source_id):
"""实体前缀避免发货ID与收款ID相同相互覆盖;旧事实只供核对。"""
found = facts.get(("CORRECTION", f"{entity}:{source_id}")) or facts.get(
("CORRECTION", str(source_id))
)
return found if found and found.data_json.get("entity") == entity else None
def verified_correction(session, source, facts, entity, record):
fact = correction_fact(facts, entity, record.source_id)
if fact is None:
return {}
data = fact.data_json
if data.get("source_hash") != record.row_hash:
raise WorkbenchError("CORRECTION_RECONFIRM_REQUIRED")
if entity == "SHIPMENT":
if data.get("details_hash") != _details_hash(session, source, record):
raise WorkbenchError("CORRECTION_RECONFIRM_REQUIRED")
other = correction_fact(facts, entity, data.get("paired_source_id"))
if not other or (
other.data_json.get("paired_source_id") != record.source_id
or other.data_json.get("pair_key") != data["pair_key"]
or other.data_json.get("source_hash") != data.get("paired_hash")
):
raise WorkbenchError("CORRECTION_PAIR_REQUIRED")
peers = source_records(session, source, "shipments", ids=[data["paired_source_id"]])
if len(peers) != 1 or peers[0].row_hash != data["paired_hash"]:
raise WorkbenchError("CORRECTION_RECONFIRM_REQUIRED")
if other.data_json.get("details_hash") != _details_hash(session, source, peers[0]):
raise WorkbenchError("CORRECTION_RECONFIRM_REQUIRED")
neg, pos = _amount(session, source, record), _amount(session, source, peers[0])
if neg + pos != 0 or neg * pos >= 0:
raise WorkbenchError("CORRECTION_RECONFIRM_REQUIRED")
return data
def _records(session, source, entity, ids):
rows = source_records(session, source, entity, ids=ids)
if {r.source_id for r in rows} != set(ids):
raise BadRequestError("所选更正单或实际回款不在当前完整源快照中")
return {r.source_id: r for r in rows}
def _amount(session, source, head):
lines = source_records(session, source, "shipment_items", parent_ids=[head.source_id])
if not lines or any(r.payload_json.get("taxAmount") is None for r in lines):
raise BadRequestError("更正单缺少完整含税金额明细,不能成对排除")
return sum((money(r.payload_json["taxAmount"]) for r in lines), Decimal(0))
def _details_hash(session, source, head):
return digest(
[
(r.source_id, r.row_hash)
for r in source_records(session, source, "shipment_items", parent_ids=[head.source_id])
]
)
def save_pair(session, payload, actor_id):
"""沿用源级互斥锁,全部事实同事务追加;同一提交幂等。"""
source = _work_source_database(session)
try:
with sync_lock(session):
session.rollback()
facts = latest_facts(session, source)
heads = _records(
session,
source,
"shipments",
[
payload.negative_shipment_id,
payload.positive_shipment_id,
],
)
negative, positive = (
heads[payload.negative_shipment_id],
heads[payload.positive_shipment_id],
)
if negative.payload_json.get("idcustomer") == positive.payload_json.get("idcustomer"):
raise BadRequestError("转客户更正对必须分别属于原客户和新客户")
if any(h.payload_json.get("voucherState") != 189 for h in heads.values()):
raise BadRequestError("更正销货单尚未生效")
neg, pos = _amount(session, source, negative), _amount(session, source, positive)
if not (neg < 0 < pos and neg + pos == 0):
raise BadRequestError("必须选择金额相抵的一张负数及一张正数更正单")
target = facts.get(("GROUP", payload.target_group_key))
if not target or str(target.data_json["customer_id"]) != str(
negative.payload_json["idcustomer"]
):
raise BadRequestError("目标平账组必须属于负数更正单的原客户")
cash = _records(session, source, "receipts", payload.receipt_ids)
if any(
str(r.payload_json.get("idpartner")) != str(positive.payload_json["idcustomer"])
or r.payload_json.get("isReceiveFlag") != 1
or money(r.payload_json.get("amount", "0")) <= 0
for r in cash.values()
):
raise BadRequestError("实际回款须为新客户名下的正数收款单")
pair_key = digest([source, negative.source_id, positive.source_id])
proposed = []
for head, peer in ((negative, positive), (positive, negative)):
proposed.append(
(
f"SHIPMENT:{head.source_id}",
{
"entity": "SHIPMENT",
"pair_key": pair_key,
"exclude_performance": True,
"paired_source_id": peer.source_id,
"source_hash": head.row_hash,
"paired_hash": peer.row_hash,
"details_hash": _details_hash(session, source, head),
},
)
)
for row in cash.values():
proposed.append(
(
f"RECEIPT:{row.source_id}",
{
"entity": "RECEIPT",
"pair_key": pair_key,
"exclude_performance": False,
"redirect_business_key": payload.target_group_key,
"source_hash": row.row_hash,
},
)
)
result = []
for key, data in proposed:
entity, source_id = key.split(":", 1)
current = correction_fact(facts, entity, source_id)
if current and current.data_json.get("pair_key") != pair_key:
raise BadRequestError("该源单据已有其他更正配对,不能重复处理")
# 已导入单据只能重放同一事实;修改需来源差异及当月调整处理。
linked = session.scalar(
select(TPlusSourceLink).where(
TPlusSourceLink.source_database == source,
TPlusSourceLink.entity_type
== ("shipments" if entity == "SHIPMENT" else "receipts"),
TPlusSourceLink.source_id == source_id,
)
)
same = bool(current and all(current.data_json.get(k) == v for k, v in data.items()))
if linked and not same:
raise BadRequestError(
"该单据已正式导入,请核对来源差异;已生成工资的差额使用当月调整单"
)
if same:
result.append(current)
continue
fact = TPlusFact(
source_database=source,
kind="CORRECTION",
fact_key=key,
version=(current.version if current else 0) + 1,
data_json=data,
evidence=payload.evidence,
requested_by=actor_id,
)
session.add(fact)
result.append(fact)
session.flush()
session.commit()
return {"pair_key": pair_key, "facts": [fact_out(f) for f in result]}
except TPlusBusyError:
session.rollback()
raise BadRequestError("资料或任务正在更新,请稍后重试") from None
except WorkbenchError:
session.rollback()
raise BadRequestError("请先完整采集更正发货、发货明细及收款数据") from None
except Exception:
session.rollback()
raise

View File

@ -5,7 +5,6 @@ from decimal import ROUND_HALF_UP, Decimal
from fractions import Fraction
from app.services import formula
from app.services.personnel_roles import role_on
from app.services.rate_rules import decide
CENT = Decimal("0.01")
@ -56,13 +55,6 @@ def _beneficiary(shipment, receipt, facts, as_of):
def _calculate(shipment, receipt, matched, previously_matched, facts, params):
reasons = list(shipment.get("pending", [])) + list(receipt.get("pending", []))
if any(
handover.get("business_key") == shipment.get("business_key")
and handover.get("mode") == "ACTIVE"
and not handover.get("shipping_ownership_confirmed")
for handover in facts.get("handovers", [])
):
reasons.append("HANDOVER_SHIPMENTS_PENDING")
effective = max(_day(shipment["date"]), _day(receipt["date"]))
is_prepayment = _day(receipt["date"]) < _day(shipment["date"])
if is_prepayment and not params.get("prepayment_parameter_basis"):
@ -72,8 +64,6 @@ def _calculate(shipment, receipt, matched, previously_matched, facts, params):
owner = _beneficiary(shipment, receipt, facts.get("handovers", []), facts["as_of"])
person = facts.get("personnel", {}).get(owner)
original_person = facts.get("personnel", {}).get(str(shipment.get("personnel_id")))
role = role_on(person, effective)
original_role = role_on(original_person, _day(shipment["date"]))
if not person:
reasons.append("PERSONNEL_PENDING")
elif person.get("status") == 0:
@ -86,8 +76,6 @@ def _calculate(shipment, receipt, matched, previously_matched, facts, params):
)
if not known_later_departure:
reasons.append("HANDOVER_PENDING")
if person and (role is None or original_role is None):
reasons.append("PERSONNEL_ROLE_PENDING")
cost = shipment.get("cost_total")
if shipment.get("cost_month_basis") == "RECEIPT":
cost = shipment.get("cost_by_month", {}).get(str(receipt["date"])[:7])
@ -122,10 +110,10 @@ def _calculate(shipment, receipt, matched, previously_matched, facts, params):
matched, formula.profit_rate(matched - allocated_cost, matched), threshold
)
days = formula.payment_days(_day(shipment["date"]), _day(receipt["date"]))
manager_own = original_role == "sales_manager"
manager_own = bool(original_person and original_person["role_code"] == "sales_manager")
rate, basis, rule = decide(
rate_params, category=category, customer_type=customer_type,
role_code="sales_manager" if manager_own else role,
role_code="sales_manager" if manager_own else person["role_code"],
days=days, prepayment=is_prepayment,
)
calculation_base = matched if basis == "sales" else converted
@ -133,8 +121,6 @@ def _calculate(shipment, receipt, matched, previously_matched, facts, params):
"status": "READY",
"reasons": [],
"personnel_id": owner,
"role_code": role,
"original_role_code": original_role,
"manager_id": shipment.get("manager_id"),
"manager_pending": not bool(shipment.get("manager_id")),
"effective_date": effective.isoformat(),
@ -156,7 +142,7 @@ def _calculate(shipment, receipt, matched, previously_matched, facts, params):
"calculation_base": amount_text(calculation_base),
"basis": basis,
"rule": rule,
"excluded_manager_own": manager_own or role == "sales_manager",
"excluded_manager_own": manager_own or person["role_code"] == "sales_manager",
}

View File

@ -1,87 +0,0 @@
"""解析已登记的月度指导成本,不按名称或单位猜测。"""
from datetime import date
from decimal import Decimal, InvalidOperation
from sqlalchemy import func, select
from app.core.exceptions import BadRequestError
from app.models import PayrollInput, TPlusFact
def latest_guidance_facts(session, source):
"""只读取指定账套每个指导成本事实键的最新版本。"""
ids = select(func.max(TPlusFact.id)).where(
TPlusFact.source_database == source, TPlusFact.kind == "GUIDANCE"
).group_by(TPlusFact.fact_key)
return {(fact.kind, fact.fact_key): fact for fact in session.scalars(select(TPlusFact).where(TPlusFact.id.in_(ids)))}
def resolve_guidance(facts, product_id, line_id, month, unit):
"""返回匹配状态和已封存事实;存在多条候选时不选择其中一条。"""
candidates = [
fact.data_json for (kind, _), fact in facts.items()
if kind == "GUIDANCE"
and fact.data_json.get("product_id") == product_id
and fact.data_json.get("month") == month
and (fact.data_json.get("basis") == "UNIT" or fact.data_json.get("shipment_line_id") == line_id)
]
if not candidates:
return "MISSING", None
if len(candidates) != 1:
return "AMBIGUOUS", None
guidance = candidates[0]
if guidance.get("unit") != unit:
return "UNIT_MISMATCH", None
if not guidance.get("is_finalized"):
return "UNFINALIZED", None
return "FINALIZED", guidance
def check_unit_guidance_conflicts(facts, product_id, month, unit, amount, frozen_shipments):
"""新增产品月度单价前检查重叠事实和全部相关的历史封存明细。"""
if any(
kind == "GUIDANCE" and fact.data_json.get("product_id") == product_id
and fact.data_json.get("month") == month
for (kind, _), fact in facts.items()
):
raise BadRequestError("指导成本存在重叠记录,请先核对")
try:
expected = Decimal(str(amount))
if not expected.is_finite() or expected < 0:
raise ValueError
for shipment in frozen_shipments:
old = Decimal(str(shipment.get("unit_cost")))
if not old.is_finite() or old < 0 or old != expected or shipment.get("unit") != unit:
raise ValueError
except (InvalidOperation, TypeError, ValueError):
raise BadRequestError("已封存发货的成本或单位与指导价不一致,请先核对") from None
def frozen_guidance_shipments(session, source, product_id, month):
"""按不可变快照中的来源产品读取历史,不能依赖当前正式明细。"""
query = (
select(PayrollInput.data_json)
.where(PayrollInput.source_database == source)
.order_by(PayrollInput.id)
.execution_options(yield_per=100)
)
for data in session.scalars(query):
shipments = data.get("shipments") if isinstance(data, dict) else None
if not isinstance(shipments, list):
raise BadRequestError("已封存发货数据异常,请先核对")
for shipment in shipments:
if not isinstance(shipment, dict) or not shipment.get("line_id"):
raise BadRequestError("已封存发货数据异常,请先核对")
try:
ship_date = date.fromisoformat(shipment["date"])
except (KeyError, TypeError, ValueError):
raise BadRequestError("已封存发货日期异常,请先核对") from None
if ship_date.strftime("%Y-%m") != month:
continue
frozen_product = shipment.get("product_id")
if frozen_product is None or str(frozen_product).strip() in ("", "None"):
raise BadRequestError("已封存发货缺少来源产品身份,请先核对")
if str(frozen_product) == str(product_id):
yield shipment

View File

@ -228,12 +228,9 @@ def _line(session, run, i, row, action=None, reason=None):
source_id=row["source_id"],
action=action or row["action"],
reason=reason or row.get("reason"),
# 真实发货参照带 Decimal 数量、单价;JSON 留存使用十进制原文,
# 与上方预览快照一致,不能转 float 或让预览任务在落明细时失败。
data_json=json.loads(json.dumps(
{k: v for k, v in row.items() if k not in ("entity_type", "action", "reason")},
ensure_ascii=False, default=str,
)),
data_json={
k: v for k, v in row.items() if k not in ("entity_type", "action", "reason")
},
)
)

View File

@ -12,10 +12,6 @@ ACTION_LABELS = {
"EXCLUDED": "不纳入本次预演",
}
REASON_LABELS = {
"CORRECTION_PAIR_REQUIRED": "更正单尚未完成正负成对核对,请使用更正单处理入口",
"CORRECTION_RECONFIRM_REQUIRED": "更正单或实际回款来源已变化,请重新核对更正对",
"CORRECTION_EXCLUDED": "转客户正负更正单已成对排除,源单保留",
"REDIRECTED_RECEIPT": "实际回款已归原业务,在本客户不重复计提",
"CUSTOMER_SCOPE_CONFLICT": "同一客户存在不一致的期初或历史范围,请先合并为客户级核对资料",
"GUIDANCE_FINALIZATION_REQUIRED": "发货月份指导成本尚未录入完毕并封存",
"CUSTOMER_SCOPE_ALREADY_SELECTED": "该客户已纳入本次平账,重复选择已跳过",
@ -23,12 +19,6 @@ REASON_LABELS = {
"INDEPENDENT_BUSINESS_REQUIRED": "请先按独立业务核实归属,跨业务冲款边界尚待答复",
"BUSINESS_DATA_PENDING": "该业务仍有资料待补,请查看对应核算预演",
"FORMAL_SOURCE_CHANGED": "源单据或人工依据与正式导入版本不同,请先核对差异",
"SOURCE_PRICE_REVIEW_REQUIRED": "已接入单价与来源未税单价不一致,请核对原单及正式明细",
"PENDING_SOURCE_CUSTOMER_CHANGED": "待核对单据的来源客户已变化,原关联已保留,请核对原单",
"PENDING_SOURCE_OUT_OF_RANGE": "待核对单据的来源日期已早于接入起点,原关联已保留,请核对原单",
"LEGACY_BUSINESS_APPLY_DISABLED": "旧规则预览不能执行,请使用自动业务接入重新生成计划",
"ORDER_SHIPMENT_MISMATCH": "发货关联的订单明细、订单号或产品不一致,请核对来源",
"SOURCE_DOCUMENT_MISSING": "来源单据在最新完整快照中消失,已有记录已保留,请核对",
"SOURCE_ALREADY_IMPORTED": "该源单据已经导入其他业务,不能重复导入",
"CUSTOMER_IMPORT_REQUIRED": "请先完成该客户的基础导入",
"PRODUCT_IMPORT_REQUIRED": "请先完成相关产品的基础导入",
@ -40,7 +30,7 @@ REASON_LABELS = {
"PRODUCT_CATEGORY_PENDING": "产品核算分类待补",
"PRODUCT_UNIT_PENDING": "产品单位待核对",
"NOT_CUSTOMER_ONLY": "往来单位不是仅客户性质",
"CUSTOMER_ATTRIBUTE_PENDING": "客户核算分类待补",
"CUSTOMER_ATTRIBUTE_PENDING": "客户属性或负责人待补",
"TARGET_MISSING": "原导入目标已不存在",
"TARGET_UNIQUE_CONFLICT": "与现有记录唯一键冲突",
"DUPLICATE_SOURCE_IDENTITY": "源记录身份重复",
@ -60,7 +50,6 @@ REASON_LABELS = {
"PREPAYMENT_PARAMETERS_PENDING": "预收业务采用发货日还是收款日参数待确认",
"AUGUST_RULE_PENDING": "8月调整参数待确认",
"PERSONNEL_PENDING": "归属人员待确认",
"PERSONNEL_ROLE_PENDING": "该业务发生月份的历史岗位待确认",
"CUSTOMER_CATEGORY_PENDING": "客户分类待确认",
"PARAMETERS_PENDING": "计提参数待补",
"NEGATIVE_OR_ZERO_DOCUMENT": "负数或零金额单据需人工核对",

View File

@ -1,46 +0,0 @@
"""页面期望状态与托管进程心跳,二者分别更新。"""
from datetime import timedelta
from sqlalchemy import inspect, select, update
from app.core.exceptions import BadRequestError, ConflictError
from app.models import TPlusRuntimeControl
from app.services.authorization_service import require_permission
from app.services.tplus import task_service
from app.services.tplus.automation import local_now
def overview(session, *, now=None):
result = {"desired_state": "STOPPED", "actual_state": "OFFLINE", "worker_phase": "UNKNOWN",
"heartbeat_at": None, "last_error": None, "host": None, "platform": None,
"version": 0, "updated_by": None, "editable": False}
if not inspect(session.connection()).has_table(TPlusRuntimeControl.__tablename__):
return result
row = session.scalar(select(TPlusRuntimeControl).where(TPlusRuntimeControl.id == 1)
.execution_options(populate_existing=True))
if row is None:
return result
result.update({key: getattr(row, key) for key in result if key != "editable"})
result["editable"] = True
if row.heartbeat_at is None or (now or local_now()) - row.heartbeat_at > timedelta(seconds=90):
result["actual_state"] = "OFFLINE"
result["worker_phase"] = "UNKNOWN"
return result
def save(session, payload, actor):
require_permission(session, actor.user, "tplus:collect")
if not inspect(session.connection()).has_table(TPlusRuntimeControl.__tablename__):
raise BadRequestError("读取服务管理尚未升级,请联系维护人员")
with task_service._audit(session, actor, "修改读取服务状态", payload.model_dump()):
changed = session.execute(update(TPlusRuntimeControl).where(
TPlusRuntimeControl.id == 1,
TPlusRuntimeControl.version == payload.expected_version,
).values(desired_state=payload.desired_state, version=payload.expected_version + 1,
updated_by=actor.user.id))
if changed.rowcount != 1:
session.rollback()
raise ConflictError()
session.commit()
return overview(session)

View File

@ -1,226 +0,0 @@
"""来源订单与发货人工关系;仅追加结论,不修改来源或封存数据。"""
from sqlalchemy import func, select
from sqlalchemy.exc import IntegrityError
from app.core.exceptions import BadRequestError
from app.core.tplus_lock import TPlusBusyError, sync_lock
from app.models import (
Business,
BusinessItem,
Shipment,
ShipmentItem,
TPlusBusinessDocument,
TPlusFact,
)
from app.services.audit_service import AuditRecord, audit
from app.services.tplus.workbench import digest
KIND = "SALES_RELATION"
def item_revision(item):
return digest([item.id, item.business_id, item.product_id, item.product_name, item.spec_model,
item.unit, str(item.quantity), str(item.sale_price), str(item.sale_amount)])
def document_revision(doc):
return [doc.id, doc.entity_type, doc.source_id, doc.record_id, doc.applied_record_id,
doc.fingerprint, doc.status, doc.business_id, doc.target_id]
def shipment_revision(doc, head, shipment, line):
return digest([document_revision(doc), document_revision(head), shipment.id,
shipment.business_id, shipment.ship_date.isoformat(), line.id, line.product_id,
str(line.quantity), str(line.sale_price), str(line.amount)])
def order_revision(doc, head, item):
return digest([document_revision(doc), document_revision(head), item_revision(item)])
def _head(session, doc, entity, target_id=None, *, lock=True):
parent = doc.data_json.get("source_parent_id")
query = select(TPlusBusinessDocument).where(
TPlusBusinessDocument.source_database == doc.source_database,
TPlusBusinessDocument.business_id == doc.business_id,
TPlusBusinessDocument.entity_type == entity,
TPlusBusinessDocument.status == "APPLIED",
TPlusBusinessDocument.source_id == str(parent),
)
if target_id is not None:
query = query.where(TPlusBusinessDocument.target_id == target_id)
head = session.scalar(query.with_for_update() if lock else query)
if head is None or head.record_id != head.applied_record_id:
raise BadRequestError("来源单据已变化,请刷新后核对")
return head
def _applied_document(session, document_id, business_id, entity, source, *, lock=True):
doc = session.get(TPlusBusinessDocument, document_id, with_for_update=lock)
if (doc is None or doc.business_id != business_id or doc.source_database != source
or doc.entity_type != entity or doc.status != "APPLIED"
or doc.record_id != doc.applied_record_id):
raise BadRequestError("来源明细已变化,请刷新后核对")
return doc
def _latest_business_facts(session, source, business_id):
ids = select(func.max(TPlusFact.id)).where(
TPlusFact.source_database == source, TPlusFact.kind == KIND,
TPlusFact.fact_key.like(f"sales:{business_id}:%"),
).group_by(TPlusFact.fact_key)
return list(session.scalars(select(TPlusFact).where(TPlusFact.id.in_(ids))))
def _fact_is_current(session, business, item, fact):
"""任何来源身份、版本或业务明细变化均使结论失效。"""
data = fact.data_json
try:
if (data["business_id"] != business.id or data["item_id"] != item.id
or data["item_revision"] != item_revision(item)):
return False
source = business.integration_json["source_database"]
doc = _applied_document(session, data["shipment_document_id"], business.id, "shipment_items", source, lock=False)
line = session.get(ShipmentItem, doc.target_id)
shipment = session.get(Shipment, line.shipment_id) if line else None
if (line is None or shipment is None or shipment.business_id != business.id
or line.product_id != item.product_id or line.quantity != item.quantity or line.amount != item.sale_amount):
return False
head = _head(session, doc, "shipments", shipment.id, lock=False)
if shipment_revision(doc, head, shipment, line) != data["shipment_revision"]:
return False
if data["disposition"] == "INDEPENDENT":
return data.get("order_document_id") is None and data.get("order_revision") is None
if data["disposition"] != "ORDER":
return False
order = _applied_document(session, data["order_document_id"], business.id, "business_items", source, lock=False)
order_item = session.get(BusinessItem, order.target_id)
if order_item is None or order_item.business_id != business.id or order_item.product_id != item.product_id:
return False
order_head = _head(session, order, "businesses", lock=False)
return order_revision(order, order_head, order_item) == data["order_revision"]
except (BadRequestError, KeyError, TypeError, ValueError):
return False
def resolve_items(session, business, items, order_ids):
"""返回待核对状态和可计入销售额的明细,不猜测来源关系。"""
orders = set(order_ids)
if not orders:
return False, items
extra = [item for item in items if item.id not in orders]
if not extra:
return False, items
facts = {fact.fact_key: fact for fact in _latest_business_facts(
session, business.integration_json["source_database"], business.id)}
included = [item for item in items if item.id in orders]
used = set()
for item in extra:
fact = facts.get(f"sales:{business.id}:{item.id}")
if fact is None or not _fact_is_current(session, business, item, fact):
return True, []
doc_id = fact.data_json["shipment_document_id"]
if doc_id in used:
return True, []
used.add(doc_id)
if fact.data_json["disposition"] == "INDEPENDENT":
included.append(item)
return False, included
def confirm(session, business_id, payload, operator, meta=None):
"""绑定明确选择及版本;调用者须具备业务更新和核算准备权限。"""
record = AuditRecord(module="业务管理", action="核对订单发货关系", summary=f"业务 #{business_id} 来源关系",
request_data=payload.model_dump(mode="json"))
try:
with audit(session, operator=operator, meta=meta, record=record), sync_lock(session):
business = session.get(Business, business_id, with_for_update=True)
if business is None or not business.integration_json:
raise BadRequestError("仅可核对来源业务")
source = business.integration_json["source_database"]
item = session.get(BusinessItem, payload.item_id, with_for_update=True)
if item is None or item.business_id != business_id or item_revision(item) != payload.item_revision:
raise BadRequestError("业务明细已变化,请刷新后核对")
existing_order = session.scalar(select(TPlusBusinessDocument.id).where(
TPlusBusinessDocument.business_id == business_id,
TPlusBusinessDocument.entity_type == "business_items",
TPlusBusinessDocument.target_id == item.id,
).limit(1))
if existing_order is not None:
raise BadRequestError("订单明细不能作为待核对发货明细")
doc = _applied_document(session, payload.shipment_document_id, business_id, "shipment_items", source)
line = session.get(ShipmentItem, doc.target_id, with_for_update=True)
shipment = session.get(Shipment, line.shipment_id, with_for_update=True) if line else None
if (line is None or shipment is None or shipment.business_id != business_id
or line.product_id != item.product_id or line.quantity != item.quantity or line.amount != item.sale_amount):
raise BadRequestError("发货明细与业务明细不一致,请重新核对")
head = _head(session, doc, "shipments", shipment.id)
if shipment_revision(doc, head, shipment, line) != payload.shipment_revision:
raise BadRequestError("来源发货已变化,请刷新后核对")
order = order_head = order_item = None
if payload.disposition == "ORDER":
order = _applied_document(session, payload.order_document_id, business_id, "business_items", source)
order_item = session.get(BusinessItem, order.target_id, with_for_update=True)
if order_item is None or order_item.business_id != business_id or order_item.product_id != item.product_id:
raise BadRequestError("订单明细不属于本业务或产品不一致")
order_head = _head(session, order, "businesses")
if order_revision(order, order_head, order_item) != payload.order_revision:
raise BadRequestError("来源订单已变化,请刷新后核对")
facts = _latest_business_facts(session, source, business_id)
key = f"sales:{business_id}:{item.id}"
current = next((fact for fact in facts if fact.fact_key == key), None)
if (current.version if current else 0) != payload.expected_version:
raise BadRequestError("核对结论已变化,请刷新后重试")
if any(fact.fact_key != key and fact.data_json.get("disposition") != "RELEASED"
and fact.data_json.get("shipment_document_id") == doc.id for fact in facts):
raise BadRequestError("该发货明细已被其他业务明细确认")
fact = TPlusFact(source_database=source, kind=KIND, fact_key=key,
version=payload.expected_version + 1, evidence=payload.evidence, requested_by=operator.id,
data_json={"business_id": business_id, "item_id": item.id,
"shipment_document_id": doc.id, "order_document_id": order.id if order else None,
"disposition": payload.disposition, "item_revision": payload.item_revision,
"shipment_revision": payload.shipment_revision, "order_revision": payload.order_revision})
session.add(fact)
session.flush()
from app.services.tplus.business_sync import _refresh_business_totals
_refresh_business_totals(session, business)
session.commit()
return {"item_id": item.id, "version": fact.version}
except (TPlusBusyError, IntegrityError):
session.rollback()
raise BadRequestError("来源数据或核对结论正在更新,请稍后重试") from None
def release(session, business_id, payload, operator, meta=None):
"""追加撤销记录,释放发货占用,同时保留既往核对依据。"""
record = AuditRecord(module="业务管理", action="撤销订单发货关系", summary=f"业务 #{business_id} 来源关系",
request_data=payload.model_dump(mode="json"))
try:
with audit(session, operator=operator, meta=meta, record=record), sync_lock(session):
business = session.get(Business, business_id, with_for_update=True)
if business is None or not business.integration_json:
raise BadRequestError("仅可核对来源业务")
source = business.integration_json["source_database"]
key = f"sales:{business_id}:{payload.item_id}"
current = next((fact for fact in _latest_business_facts(session, source, business_id)
if fact.fact_key == key), None)
if current is None or current.version != payload.expected_version:
raise BadRequestError("核对结论已变化,请刷新后重试")
if current.data_json.get("disposition") == "RELEASED":
raise BadRequestError("核对结论已撤销")
fact = TPlusFact(source_database=source, kind=KIND, fact_key=key,
version=current.version + 1, evidence=payload.evidence, requested_by=operator.id,
data_json={"business_id": business_id, "item_id": payload.item_id,
"disposition": "RELEASED"})
session.add(fact)
session.flush()
from app.services.tplus.business_sync import _refresh_business_totals
_refresh_business_totals(session, business)
session.commit()
return {"item_id": payload.item_id, "version": fact.version}
except (TPlusBusyError, IntegrityError):
session.rollback()
raise BadRequestError("来源数据或核对结论正在更新,请稍后重试") from None

View File

@ -6,8 +6,8 @@ from app.models import TPlusSourceHead, TPlusSyncBatch, TPlusSyncRecord
from app.services.tplus.workbench import WorkbenchError
def published_batch(session, source, entity):
"""返回实体最后发布的完整批次;更新失败时不使用不完整的指针。"""
def source_statement(session, source, entity):
"""只发布最后一次完整成功读取;中断覆盖的指针不得部分发布。"""
batches = session.scalars(
select(TPlusSyncBatch)
.where(
@ -26,19 +26,6 @@ def published_batch(session, source, entity):
break
if batch_no is None:
raise WorkbenchError("SOURCE_REFRESH_REQUIRED")
return batch_no
def source_manifest(session, source):
"""核算与接入共同使用的完整来源批次清单,包含空实体。"""
entities = ("personnel", "customers", "products", "businesses", "business_items",
"shipments", "shipment_items", "receipts")
return {entity: published_batch(session, source, entity) for entity in entities}
def source_statement(session, source, entity):
"""只发布最后一次完整成功读取;中断覆盖的指针不得部分发布。"""
batch_no = published_batch(session, source, entity)
return (
select(TPlusSyncRecord)
.join(TPlusSourceHead, TPlusSourceHead.record_id == TPlusSyncRecord.id)

View File

@ -57,9 +57,3 @@ def observe_rows(session, batch, mapping, rows):
session.add(head)
current[row["source_id"]] = head
head.record_id, head.batch_no = record_id, batch.batch_no
raw = row["payload_json"]
head.source_parent_id = str(raw["source_parent_id"]) if raw.get("source_parent_id") is not None else None
customer = raw.get("idcustomer", raw.get("idpartner"))
head.source_customer_id = str(customer) if customer is not None else None
day = raw.get("voucherdate", raw.get("voucherDate"))
head.source_date = str(day)[:10] if day is not None else None

View File

@ -1,186 +0,0 @@
"""跨平台托管循环;单次任务在独立线程执行,停止时等待其完成。"""
import json
import logging
import platform
import signal
import socket
import threading
import time
from pathlib import Path
from urllib.error import URLError
from urllib.parse import urlsplit
from urllib.request import HTTPRedirectHandler, build_opener
from uuid import uuid4
from sqlalchemy import create_engine, select, text, update
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.pool import NullPool
from app.core.tplus_db import resolve_tplus_settings
from app.models import TPlusRuntimeControl
from app.services.tplus.automation import local_now, tick
from app.services.tplus.worker import run_once
logger = logging.getLogger(__name__)
LOCK_NAME = "ys:tplus:supervisor"
class _LocalOnlyRedirectHandler(HTTPRedirectHandler):
"""拒绝健康检查从本机地址跳转到外部地址。"""
def redirect_request(self, req, fp, code, msg, headers, new):
parsed = urlsplit(new)
if parsed.scheme != "http" or parsed.hostname not in ("localhost", "127.0.0.1", "::1"):
return None
return super().redirect_request(req, fp, code, msg, headers, new)
_health_opener = build_opener(_LocalOnlyRedirectHandler)
def _open_health(url, *, timeout):
return _health_opener.open(url, timeout=timeout)
def health_error(settings):
"""只访问本机健康端点,禁止通过配置探测外部网络。"""
try:
url = settings.t_plus_supervisor_health_url
parsed = urlsplit(url)
if parsed.scheme != "http" or parsed.hostname not in ("localhost", "127.0.0.1", "::1"):
return "后端健康检查地址必须是本机 HTTP 地址"
with _open_health(url, timeout=3) as response:
body = json.load(response)
data = body.get("data") if isinstance(body, dict) else None
if not isinstance(data, dict) or not all(data.get(key) is True for key in ("app", "db", "redis")):
return "系统或依赖尚未就绪"
source = resolve_tplus_settings(settings)
try:
import pyodbc
except ImportError:
return "未安装 T+ 数据库连接组件"
if source.t_plus_odbc_driver not in pyodbc.drivers():
return "T+ 数据库驱动不可用,请联系维护人员"
if settings.t_plus_stop_file and Path(settings.t_plus_stop_file).exists():
return "旧版停止标记存在,请联系维护人员处理"
except (OSError, URLError, ValueError, RuntimeError):
return "系统健康检查或 T+ 连接配置未就绪"
engine = None
try:
engine = create_engine(
source.t_plus_database_url, poolclass=NullPool, hide_parameters=True,
connect_args={"timeout": source.t_plus_connect_timeout},
)
with engine.connect() as connection:
connection.connection.driver_connection.timeout = source.t_plus_query_timeout
connection.exec_driver_sql("SELECT 1")
except (SQLAlchemyError, OSError, ValueError, RuntimeError):
return "T+ 数据库连接不可用,请检查连接配置"
finally:
if engine is not None:
engine.dispose()
return None
def may_claim(session):
"""与页面更新同一行锁,确认停止后不再领取新任务。"""
row = session.scalar(select(TPlusRuntimeControl).where(TPlusRuntimeControl.id == 1)
.with_for_update().execution_options(populate_existing=True))
return row is not None and row.desired_state == "RUNNING"
def run(factory, settings, *, interval=5, stop_event=None):
"""持有数据库命名锁期间,轮询页面设置并完成已领取的工作。"""
stopped = stop_event or threading.Event()
owner = str(uuid4())
worker = None
failures = []
retry_after = 0.0
last_maintenance = 0.0
handlers = {}
if threading.current_thread() is threading.main_thread():
for sig in (signal.SIGINT, signal.SIGTERM):
handlers[sig] = signal.signal(sig, lambda *_: stopped.set())
connection = None
acquired = False
def execute():
try:
with factory() as session:
run_once(session, worker_owner=owner, may_claim=may_claim)
except Exception as exc:
logger.error("T+ 读取任务执行异常,异常类型=%s", type(exc).__name__)
failures.append("读取服务执行失败,请检查服务日志")
try:
connection = factory.kw["bind"].connect()
if connection.dialect.name != "mysql":
raise RuntimeError("T+ 托管服务需要 MySQL 命名锁")
acquired = connection.execute(text("SELECT GET_LOCK(:name, 0)"), {"name": LOCK_NAME}).scalar() == 1
if not acquired:
logger.error("T+ 托管服务已在运行")
return 1
while not stopped.is_set():
try:
if connection.execute(text("SELECT IS_USED_LOCK(:name) = CONNECTION_ID()"),
{"name": LOCK_NAME}).scalar() != 1:
raise RuntimeError("托管服务锁已失效")
with factory() as session:
row = session.scalar(select(TPlusRuntimeControl).where(TPlusRuntimeControl.id == 1)
.execution_options(populate_existing=True))
if row is None:
raise RuntimeError("读取服务控制尚未初始化")
desired = row.desired_state
error = health_error(settings) if desired == "RUNNING" else None
active = worker is not None and worker.is_alive()
if failures:
error = failures.pop(0)
retry_after = time.monotonic() + 30
elif time.monotonic() < retry_after:
error = "读取服务异常,等待重试"
if desired == "RUNNING" and not error and not active:
if time.monotonic() - last_maintenance >= 60:
tick(session, settings=settings)
last_maintenance = time.monotonic()
worker = threading.Thread(target=execute, name="tplus-worker")
worker.start()
active = True
state = ("STOPPING" if active else "STOPPED") if desired == "STOPPED" else (
"DEGRADED" if error else "RUNNING")
session.execute(update(TPlusRuntimeControl).where(TPlusRuntimeControl.id == 1).values(
actual_state=state, worker_phase="RUNNING" if active else "IDLE" if state == "RUNNING" else "STOPPED",
heartbeat_at=local_now(), last_error=error, host=socket.gethostname()[:128],
platform=platform.system()[:32], owner=owner))
session.commit()
except Exception as exc:
logger.error("T+ 托管服务检查失败,异常类型=%s", type(exc).__name__)
# 锁或数据库状态不明时退出,绝不启动新的读取任务。
break
stopped.wait(interval)
finally:
if worker is not None:
worker.join()
if acquired:
try:
with factory() as session:
session.execute(update(TPlusRuntimeControl).where(
TPlusRuntimeControl.id == 1, TPlusRuntimeControl.owner == owner
).values(actual_state="STOPPED", worker_phase="STOPPED", heartbeat_at=local_now()))
session.commit()
except Exception:
logger.warning("无法记录 T+ 托管服务停止状态")
try:
released = connection.execute(
text("SELECT RELEASE_LOCK(:name)"), {"name": LOCK_NAME}
).scalar()
if released != 1:
connection.invalidate()
except Exception:
connection.invalidate()
if connection is not None:
connection.close()
for sig, handler in handlers.items():
signal.signal(sig, handler)
return 0 if stopped.is_set() else 1

View File

@ -9,15 +9,15 @@ from sqlalchemy import func, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import defer
from app.core.exceptions import BadRequestError, ConflictError, ForbiddenError, NotFoundError
from app.core.exceptions import BadRequestError, ForbiddenError, NotFoundError
from app.core.response import PageResult
from app.core.tplus_lock import TPlusBusyError, sync_lock
from app.models import TPlusFact, TPlusSyncBatch, TPlusSyncRecord, TPlusWorkLine, TPlusWorkRun
from app.models import TPlusFact, TPlusSyncRecord, TPlusWorkLine, TPlusWorkRun
from app.schemas.tplus_workbench import WorkCreateIn
from app.services.tplus.report_labels import ACTION_LABELS, reason_label, spreadsheet_value
from app.services.tplus.task_service import _source_database, history_source_database, new_batch_no
RULE_VERSION = "2026-11-03.2"
RULE_VERSION = "2026-10-30.2"
logger = logging.getLogger(__name__)
@ -58,27 +58,13 @@ def latest_facts(session, source):
def save_fact(session, payload, actor_id):
"""与导入共用锁,乐观版本不匹配时拒绝覆盖。"""
if payload.kind == "CORRECTION":
raise BadRequestError("更正单请使用成对处理入口,不能单独排除或修改一张源单")
source = _work_source_database(session)
try:
with sync_lock(session):
session.rollback()
facts = latest_facts(session, source)
current = facts.get((payload.kind, payload.fact_key))
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 payload.kind == "GUIDANCE":
if payload.data.get("is_finalized"):
raise BadRequestError("请在业务详情中核对来源并封存指导成本")
if any(
kind == "GUIDANCE"
and fact.data_json.get("is_finalized")
and fact.data_json.get("product_id") == payload.data.get("product_id")
and fact.data_json.get("month") == payload.data.get("month")
for (kind, _), fact in facts.items()
):
raise BadRequestError("该产品发货月份已有封存指导成本,请先核对")
if (current.version if current else 0) != payload.expected_version:
raise BadRequestError("补充资料已经变化,请刷新后再保存")
fact = TPlusFact(
@ -148,7 +134,8 @@ def queue_work(session, payload: WorkCreateIn, actor_id):
values = payload.model_dump(mode="json", exclude={"request_key"})
source = _work_source_database(session)
key = str(payload.request_key)
def existing_run(found):
found = session.scalar(select(TPlusWorkRun).where(TPlusWorkRun.request_key == key))
if found:
if (
found.input_json != values
or found.source_database != source
@ -156,15 +143,14 @@ def queue_work(session, payload: WorkCreateIn, actor_id):
):
raise BadRequestError("请求键已用于不同任务")
return work_out(found)
found = session.scalar(select(TPlusWorkRun).where(TPlusWorkRun.request_key == key))
if found:
return existing_run(found)
if payload.kind == "MASTER_APPLY":
preview = get_run(session, payload.preview_no, source)
if preview.kind != "MASTER_PREVIEW" or preview.status not in ("SUCCEEDED", "PARTIAL"):
raise BadRequestError("必须选择已完成的基础导入预览")
if payload.kind == "BUSINESS_APPLY":
raise BadRequestError("旧规则业务预览仅供核对,请使用自动业务接入重新生成计划")
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"):
@ -185,10 +171,7 @@ def queue_work(session, payload: WorkCreateIn, actor_id):
session.commit()
except IntegrityError:
session.rollback()
found = session.scalar(select(TPlusWorkRun).where(TPlusWorkRun.request_key == key))
if found is None:
raise ConflictError() from None
return existing_run(found)
return queue_work(session, payload, actor_id)
return work_out(run)
@ -242,63 +225,19 @@ def list_work(session, query):
)
def _display_data(session, run, rows):
"""按原记录版本补齐基础档案展示字段;不改历史结果或使用最新源数据。"""
if run.kind not in ("MASTER_PREVIEW", "MASTER_APPLY"):
return [row.data_json for row in rows]
record_ids = {row.data_json.get("record_id") for row in rows} - {None}
records = {
record.id: record
for record in session.scalars(
select(TPlusSyncRecord)
.join(TPlusSyncBatch, TPlusSyncBatch.batch_no == TPlusSyncRecord.batch_no)
.where(
TPlusSyncRecord.id.in_(record_ids),
TPlusSyncBatch.source_database == run.source_database,
)
)
} if record_ids else {}
roster = {}
if any(row.reason == "ROSTER_SOURCE_MISSING" for row in rows):
roster = {
fact["key"]: fact["data"]
for fact in (run.snapshot_json or {}).get("facts", [])
if fact.get("kind") == "PERSONNEL"
}
result = []
for row in rows:
data = dict(row.data_json)
record = records.get(data.get("record_id"))
if record and record.entity_type == row.entity_type and record.source_id == row.source_id:
source = record.payload_json
fields = {"source_name": source.get("name"), "source_no": record.source_no}
if row.entity_type == "products":
fields.update(source_specification=source.get("specification"),
source_unit=source.get("unit_name"))
for key, value in fields.items():
if data.get(key) is None and value is not None:
data[key] = value
elif row.reason == "ROSTER_SOURCE_MISSING":
person = roster.get(row.source_id, {})
data.setdefault("source_name", person.get("name"))
data.setdefault("source_no", row.source_id)
result.append(data)
return result
def list_lines(session, number, query):
run = get_run(session, number)
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 = list(session.scalars(
rows = session.scalars(
select(TPlusWorkLine)
.where(*conditions)
.order_by(TPlusWorkLine.line_no)
.offset(query.offset)
.limit(query.page_size)
))
)
return PageResult(
records=[
{
@ -307,9 +246,9 @@ def list_lines(session, number, query):
"source_id": r.source_id,
"action": r.action,
"reason": r.reason,
"data": data,
"data": r.data_json,
}
for r, data in zip(rows, _display_data(session, run, rows), strict=True)
for r in rows
],
total=total or 0,
page_num=query.page_num,
@ -380,7 +319,7 @@ def report_csv(session, run_no):
import csv
import io
run = get_run(session, run_no)
get_run(session, run_no)
def rows():
buffer = io.StringIO()
@ -419,59 +358,55 @@ def report_csv(session, run_no):
.where(TPlusWorkLine.run_no == run_no)
.order_by(TPlusWorkLine.line_no)
)
for page in session.scalars(statement).partitions(200):
for row, data in zip(page, _display_data(session, run, page), strict=True):
values = data.get("values") or {}
fields = [
run_no,
row.line_no,
row.entity_type,
row.source_id,
ACTION_LABELS.get(row.action, row.action),
data.get("source_no", ""),
values.get("personnel_name")
or values.get("product_name")
or values.get("company_name")
or data.get("source_name")
or "",
*[
data.get(k, "")
for k in (
"receipt_no",
"accounting_voucher_code",
"shipment_no",
"amount",
"shipment_remaining_after",
"days",
"allocated_cost",
"converted_profit",
"profit_threshold",
"rate_percent",
"performance",
"personnel_id",
)
],
reason_label(row.reason)
or ("已导入且数据无变化" if run.kind in ("MASTER_PREVIEW", "MASTER_APPLY") and row.action == "SKIP"
else "经理本人业务,已排除绩效汇总" if data.get("excluded_manager_own") else ""),
"仅供核对,未写入正式工资",
]
writer.writerow([spreadsheet_value(v) for v in fields])
yield buffer.getvalue()
buffer.seek(0)
buffer.truncate(0)
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 rows()
def execute_next(session, *, may_claim=None):
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()
if may_claim is not None and not may_claim(session):
session.rollback()
return None
run = session.scalar(
select(TPlusWorkRun)
.where(TPlusWorkRun.status == "QUEUED")
@ -491,16 +426,8 @@ def execute_next(session, *, may_claim=None):
from app.services.authorization_service import require_permission
require_permission(session, session.get(User, run.requested_by),
"tplus:import" if run.kind.endswith("_APPLY") or run.kind == "BUSINESS_SYNC" else "tplus:preview")
if run.kind == "BUSINESS_SYNC":
from app.services.tplus.business_sync import execute_sync
from app.services.tplus.business_sync_control import clear_execution_failure
execute_sync(session, run)
clear_execution_failure(session, run.source_database, run.requested_by)
elif run.kind == "BUSINESS_APPLY":
raise WorkbenchError("LEGACY_BUSINESS_APPLY_DISABLED")
elif run.kind.startswith("BUSINESS_"):
"tplus:import" if run.kind.endswith("_APPLY") else "tplus:preview")
if run.kind.startswith("BUSINESS_"):
from app.services.tplus.business_import import execute_business
execute_business(session, run)
@ -520,10 +447,6 @@ def execute_next(session, *, may_claim=None):
run.status, run.finished_at = "FAILED", datetime.now()
# 不让驱动异常、SQL 或原始数据进入响应。
run.error_code = "TASK_AUTHORIZATION_REVOKED" if isinstance(exc, ForbiddenError) else str(exc) if isinstance(exc, WorkbenchError) else "EXECUTION_ERROR"
if run.kind == "BUSINESS_SYNC" and isinstance(exc, ForbiddenError):
from app.services.tplus.business_sync_control import record_authorization_failure
record_authorization_failure(session, run.source_database, run.requested_by)
logger.error(
"工作区任务失败 | run_no=%s | exception_type=%s", run.run_no, type(exc).__name__
)

View File

@ -1,6 +1,5 @@
"""持久化任务执行者;获取连接级锁后才恢复或领取任务。"""
import logging
from datetime import datetime
from pathlib import Path
@ -13,10 +12,8 @@ from app.services.tplus.errors import classify_source_error
from app.services.tplus.relationship_audit import TPlusRelationshipAudit
from app.services.tplus.sync_service import TPlusSyncService
logger = logging.getLogger(__name__)
def run_once(session, source_factory=None, *, worker_owner=None, may_claim=None):
def run_once(session, source_factory=None, *, worker_owner=None):
"""返回本轮处理的批次号;忙锁或队列为空时不假称已执行。"""
try:
with sync_lock(session), WorkerHeartbeat(session, owner=worker_owner):
@ -26,13 +23,10 @@ def run_once(session, source_factory=None, *, worker_owner=None, may_claim=None)
from app.services.tplus.workbench import execute_next
if inspect(session.connection()).has_table(TPlusWorkRun.__tablename__):
number = execute_next(session, may_claim=may_claim) if may_claim else execute_next(session)
number = execute_next(session)
if number:
return number
task_service.recover_running(session)
if may_claim is not None and not may_claim(session):
session.rollback()
return None
batch = repository.next_queued(session)
if batch is None:
session.rollback()
@ -73,22 +67,6 @@ def _execute(session, batch, source_factory):
session.commit()
else:
TPlusSyncService(source, session).execute_claimed(batch)
if batch.status == "SUCCEEDED":
from app.services.tplus.business_sync_control import (
record_schedule_failure,
schedule,
)
try:
schedule(session)
except Exception:
session.rollback()
logger.error("T+ 采集已完成,但业务自动接入排队失败")
try:
record_schedule_failure(session, batch.source_database)
except Exception:
session.rollback()
logger.error("无法记录 T+ 业务自动接入排队异常")
except BaseException as exc:
session.rollback()
task_service.fail_batch(

View File

@ -393,15 +393,3 @@ uv run pytest tests/integration -q # 仅集成
- 模板定义迁移至 `calc_template_definitions`(V28 种子 5 个内置模板);骨架 `FORMULAS` 受控,核算/预览按骨架分派,岗位模板校验改为数据库定义优先、旧库注册表兜底。
- 更新 `test_param_registry` 模板断言:内置模板参数绑定覆盖骨架角色且参数编码已注册。单元 24 项通过;`test_calc_helpers`、`test_param_snapshot`、`test_formal_payroll` 合计 63 项通过;Ruff 与前端类型/构建通过。
- 岗位相关 MySQL 集成需独立测试库环境(`PAYROLL_TEST_DATABASE`),本轮未执行;新增模板 CRUD 集成用例待补。
## 更正单与备份恢复(2026-11-03)
105 项定点单元、37 项隔离 MySQL 集成通过。新用例覆盖正负成对核对、回款转原业务、来源防重/原子拒绝/同号实体隔离、来源变化拦截、普通抵顶不打折、备份篡改/断链/服务器更换/无更新不归档。独立 Docker 全量+增量重放逐值核对通过;说明及证据见 `docs/更正单与备份恢复交付_2026-11-03.md`。
## 真实样本闭环与历史岗位(2026-11-03)
新增 `tests/unit/test_historical_roles.py`,覆盖按月任职区间、重叠拒绝、跨月转岗、经理本人业务排除、正式台账与月结冻结岗位、客户负责人暂缺、带实际成交参照的产品导入。完整单元 961 项、隔离 MySQL 专项 19 项通过;辽宁兴晟达真实样本完成正式导入至月结、四张报表及导出,30 个不同检查点通过。证据与边界见 `docs/真实样本业务闭环验收_2026-11-03.md`。
定点复验:`python -m pytest tests/unit/test_historical_roles.py -q`。真实样本只证明本组经理本人排除场景,不能据 0 元结果认定人员整月工资为零。

View File

@ -390,11 +390,9 @@ def tplus_tasks(monkeypatch):
@pytest.fixture
def legacy_calculation(monkeypatch):
"""旧引擎回归使用关闭确认开关的口径;确认门槛由独立用例覆盖。"""
"""明确启用旧引擎兼容回归;生产和正式工资测试默认关闭。"""
from app.core.config import get_settings
from app.services import receipt_confirmation
monkeypatch.setattr(get_settings(), "payroll_allow_legacy_calculation", True)
monkeypatch.setattr(receipt_confirmation, "enabled", lambda _session: False)
@pytest.fixture

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