Compare commits
29 Commits
22605f4106
...
b3de35c2ec
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b3de35c2ec | ||
|
|
35fea3802f | ||
|
|
a4794489b1 | ||
|
|
eb4e06d1e8 | ||
|
|
ce0961a498 | ||
|
|
28d01dcd75 | ||
|
|
560b6878d6 | ||
|
|
d6cac0a914 | ||
|
|
65873bc894 | ||
|
|
a8adf064d2 | ||
|
|
b21dff12f4 | ||
|
|
fc4428abb5 | ||
|
|
b8920c0d28 | ||
|
|
68e70fd6e2 | ||
|
|
515b83b660 | ||
|
|
4a888e9974 | ||
|
|
73dbf08d4b | ||
|
|
1e53792ad6 | ||
|
|
3efe72c344 | ||
|
|
ae12403c0b | ||
|
|
b552f1e567 | ||
|
|
6bac9586bf | ||
|
|
bf404c3b68 | ||
|
|
2ac4c71106 | ||
|
|
2c003fc4a7 | ||
|
|
5e5304eece | ||
|
|
908851a704 | ||
|
|
c684a44753 | ||
|
|
67adecbf1b |
35
.dockerignore
Normal file
35
.dockerignore
Normal file
@ -0,0 +1,35 @@
|
||||
# 构建上下文白名单,防止本地配置、客户资料和虚拟环境被发送到构建器。
|
||||
**
|
||||
!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
10
.gitignore
vendored
@ -1,5 +1,7 @@
|
||||
# AI辅助目录(整个目录忽略)
|
||||
.ai/
|
||||
.agents/
|
||||
/AGENTS.md
|
||||
|
||||
# 依赖
|
||||
node_modules/
|
||||
@ -53,6 +55,7 @@ frontend/e2e/**/*.png
|
||||
*.tmp
|
||||
*.bak
|
||||
*.cache
|
||||
.tmp-processes*.txt
|
||||
# Office/WPS 打开文档时生成的锁文件(如 ~$xxx.docx)
|
||||
~$*
|
||||
|
||||
@ -67,6 +70,12 @@ 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
|
||||
@ -82,3 +91,4 @@ backend/.venv-workbench/
|
||||
|
||||
# 本地浏览器验收自动生成的快照
|
||||
/.playwright-mcp/
|
||||
/phase2-evidence-*/
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
营销人员绩效工资核算系统。后端 Python/FastAPI,前端 Vue 3,数据存 MySQL,会话与缓存在 Redis。
|
||||
|
||||
> 当前进度:正式工资闭环及同步运维自动化已完成本地开发验证,支持每周原始采集、有限重试、独立心跳和站内提示,并提供 Windows 托管脚本。生产自动化默认关闭,尚未安装启用;真实金额与整月工资尚未验收,首组样本缺人工指导成本。审核能力保留但默认关闭,多原单更正仍单独跟踪。最新交付见 [开发记录](docs/同步运维自动化与样本核对开发记录_2026-10-30.md) 和 [Windows 操作说明](scripts/windows/README.md)。
|
||||
> 当前进度:正式工资闭环及同步运维自动化已完成本地开发验证,支持每周原始采集、有限重试、独立心跳和站内提示,并提供 Windows 托管脚本。辽宁兴晟达真实样本已在隔离库完成正式导入、核算、月结、报表与导出验收,整月实际工资仍须另行核定,见[真实样本业务闭环验收](docs/真实样本业务闭环验收_2026-11-03.md)。生产自动化默认关闭,尚未安装启用。审核能力保留但默认关闭,多原单更正仍单独跟踪。相关说明见 [T+ 本地恢复与同步开发手册](docs/T+本地恢复与同步开发手册.md) 和 [Windows 操作说明](scripts/windows/README.md)。
|
||||
|
||||
## 快速开始
|
||||
|
||||
@ -128,6 +128,8 @@ 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` | 后端启动、分层约定、接口清单 |
|
||||
@ -141,7 +143,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`。测试运行器保留隔离库供复查,不删除原开发数据。
|
||||
- 集成回归使用 `scripts/run_isolated_tests.py`;原 `.venv` 不可用时,本机使用 `backend/.venv-workbench/Scripts/python.exe`。运行器默认清理本次新建的隔离库和账号,绝不删除原开发数据;需复查失败现场时加 `--keep`。
|
||||
|
||||
审核默认关闭:`PAYROLL_APPROVAL_ENABLED=false`,工资生成直接完成且不产生审批痕迹;如确需系统内复核,显式开启后沿用财务/领导审核和各类审批参数。此配置与旧核算引擎兼容开关独立。
|
||||
|
||||
|
||||
@ -41,6 +41,10 @@ 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 上验证过的驱动名称。
|
||||
@ -49,8 +53,8 @@ T_PLUS_ODBC_DRIVER=
|
||||
# 默认启用传输加密。旧实例 TLS 不兼容时,仅隔离开发环境可显式设为 false,
|
||||
# 记录原因并重新验收;程序不会自动降级。真实环境值后续按验收结果填写。
|
||||
T_PLUS_ENCRYPT=true
|
||||
# 内网自签证书可暂用 true;它不校验证书身份。部署可信证书后改为 false。
|
||||
T_PLUS_TRUST_SERVER_CERTIFICATE=true
|
||||
# 默认校验服务器证书。仅隔离开发环境可显式设为 true,并记录原因和验收范围。
|
||||
T_PLUS_TRUST_SERVER_CERTIFICATE=false
|
||||
T_PLUS_CONNECT_TIMEOUT=10
|
||||
T_PLUS_QUERY_TIMEOUT=120
|
||||
# 可选:留空时使用仓库根目录下的 db/queries/tplus/
|
||||
@ -68,5 +72,7 @@ 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=
|
||||
|
||||
43
backend/Dockerfile
Normal file
43
backend/Dockerfile
Normal file
@ -0,0 +1,43 @@
|
||||
# 从仓库根目录构建: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"]
|
||||
@ -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)"),dict(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)"),{"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"),dict(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"),{"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"),dict(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"),{"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"),dict(id=maker)).scalar():
|
||||
if maker is not None and not bind.execute(sa.text("SELECT id FROM users WHERE id=:id AND status=1"),{"id": maker}).scalar():
|
||||
raise RuntimeError("旧工资制作人账号无效,请修正配置后重试迁移")
|
||||
bind.execute(sa.text("INSERT INTO authorization_settings (id,maker_user_id,revision) VALUES (1,:maker,1)"),dict(maker=maker))
|
||||
bind.execute(sa.text("INSERT INTO authorization_settings (id,maker_user_id,revision) VALUES (1,:maker,1)"),{"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'"),dict(maker=maker))
|
||||
bind.execute(sa.text("INSERT INTO user_roles (user_id,role_id) SELECT :maker,id FROM roles WHERE role_code='payroll_maker'"),{"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"),dict(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"),{"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"),dict(code=code))
|
||||
bind.execute(sa.text("DELETE FROM permissions WHERE perm_code=:code"),{"code": code})
|
||||
|
||||
@ -105,19 +105,19 @@ def upgrade():
|
||||
op.bulk_insert(
|
||||
definitions,
|
||||
[
|
||||
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,
|
||||
)
|
||||
{
|
||||
"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)
|
||||
],
|
||||
)
|
||||
|
||||
@ -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)"), 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("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("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'"))
|
||||
|
||||
|
||||
|
||||
@ -0,0 +1,47 @@
|
||||
"""V29 回款独立人工确认及默认开启开关;不自动确认历史回款。"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from alembic import op
|
||||
from sqlalchemy import inspect, text
|
||||
|
||||
|
||||
revision = "29b0c1d2e3f4"
|
||||
down_revision = "28a9b0c1d2e3"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
sql = Path(__file__).resolve().parents[3] / "db/migration/V29__receipt_manual_confirmation.sql"
|
||||
columns = {column["name"]: column for column in inspect(op.get_bind()).get_columns("receipts")}
|
||||
existing = {name for name in ("confirmation_status", "confirmed_by", "confirmed_at", "confirmed_revision") if name in columns}
|
||||
if existing and len(existing) != 4:
|
||||
raise RuntimeError("回款确认字段不完整,请先核对数据库结构")
|
||||
if existing:
|
||||
expected = {"confirmation_status": ("TINYINT", None, False), "confirmed_by": ("VARCHAR", 64, True),
|
||||
"confirmed_at": ("DATETIME", None, True), "confirmed_revision": ("VARCHAR", 64, True)}
|
||||
for name, (kind, length, nullable) in expected.items():
|
||||
column = columns[name]
|
||||
actual_type = column["type"]
|
||||
if (actual_type.__visit_name__.upper() != kind or getattr(actual_type, "length", None) != length
|
||||
or column["nullable"] != nullable):
|
||||
raise RuntimeError(f"回款确认字段 {name} 与迁移定义不一致,请先核对数据库结构")
|
||||
if str(columns["confirmation_status"]["default"]).strip("'\"") != "0":
|
||||
raise RuntimeError("回款确认状态默认值不是待确认,请先核对数据库结构")
|
||||
for statement in sql.read_text(encoding="utf-8").split(";\n"):
|
||||
clean = "\n".join(line for line in statement.splitlines() if not line.lstrip().startswith("--")).strip()
|
||||
if clean and not (existing and clean.startswith("ALTER TABLE receipts ADD COLUMN")):
|
||||
op.get_bind().execute(text(clean))
|
||||
|
||||
|
||||
def downgrade():
|
||||
bind = op.get_bind()
|
||||
if bind.scalar(text("SELECT COUNT(*) FROM receipts WHERE confirmed_at IS NOT NULL OR confirmation_status<>0")):
|
||||
raise RuntimeError("已有回款人工确认记录,禁止删除确认依据;请仅回退应用并保留结构")
|
||||
if bind.scalar(text("SELECT COUNT(*) FROM param_values v JOIN param_definitions d ON d.id=v.param_id WHERE d.param_code='receipt.confirmation.enabled'")):
|
||||
raise RuntimeError("已维护人工确认开关版本,禁止删除历史参数")
|
||||
bind.execute(text("DELETE FROM param_definitions WHERE param_code='receipt.confirmation.enabled'"))
|
||||
bind.execute(text("UPDATE permissions SET status=0 WHERE perm_code='business:receipts:confirm'"))
|
||||
for column in ("confirmed_revision", "confirmed_at", "confirmed_by", "confirmation_status"):
|
||||
op.drop_column("receipts", column)
|
||||
40
backend/alembic/versions/30c1d2e3f4a5_tplus_business_sync.py
Normal file
40
backend/alembic/versions/30c1d2e3f4a5_tplus_business_sync.py
Normal file
@ -0,0 +1,40 @@
|
||||
"""V30 业务接入与核算准备解耦;无默认回填。"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from alembic import op
|
||||
from sqlalchemy import text
|
||||
|
||||
revision = "30c1d2e3f4a5"
|
||||
down_revision = "29b0c1d2e3f4"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
script = Path(__file__).resolve().parents[3] / "db/migration/V30__tplus_business_sync.sql"
|
||||
for statement in script.read_text(encoding="utf-8").split(";\n"):
|
||||
sql = "\n".join(line for line in statement.splitlines() if not line.lstrip().startswith("--")).strip()
|
||||
if sql:
|
||||
op.get_bind().execute(text(sql))
|
||||
|
||||
|
||||
def downgrade():
|
||||
connection = op.get_bind()
|
||||
for table, condition in (("tplus_business_documents", "1=1"), ("products", "product_category IS NULL"),
|
||||
("customers", "customer_type IS NULL"), ("business_items", "product_category IS NULL"),
|
||||
("shipments", "freight_amount IS NULL")):
|
||||
if connection.scalar(text(f"SELECT COUNT(*) FROM {table} WHERE {condition}")):
|
||||
raise RuntimeError("已有接入或待补资料,请关闭接入并回退兼容版本;不删除业务数据以降级结构")
|
||||
op.drop_table("tplus_business_documents")
|
||||
op.drop_table("tplus_business_sync_configs")
|
||||
for index in ("idx_tplus_source_heads_parent", "idx_tplus_source_heads_customer_date", "idx_tplus_source_heads_date"):
|
||||
op.drop_index(index, "tplus_source_heads")
|
||||
for column in ("source_parent_id", "source_customer_id", "source_date"):
|
||||
op.drop_column("tplus_source_heads", column)
|
||||
op.drop_column("businesses", "integration_json")
|
||||
for table, column, sql_type, default in (("products", "product_category", "VARCHAR(32)", ""),
|
||||
("customers", "customer_type", "VARCHAR(32)", " DEFAULT 'personal'"),
|
||||
("business_items", "product_category", "VARCHAR(32)", ""),
|
||||
("shipments", "freight_amount", "DECIMAL(14,2)", " DEFAULT 0")):
|
||||
connection.execute(text(f"ALTER TABLE {table} MODIFY {column} {sql_type} NOT NULL{default}"))
|
||||
@ -0,0 +1,14 @@
|
||||
"""合并业务接入和回款确认迁移分支。"""
|
||||
|
||||
revision = "31d2e3f4a5b6"
|
||||
down_revision = ("29b0c1d2e3f4", "30c1d2e3f4a5")
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
pass
|
||||
|
||||
|
||||
def downgrade():
|
||||
pass
|
||||
@ -0,0 +1,22 @@
|
||||
"""增加默认停止的 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("运行控制包含用户设置,请停止服务并备份后采用应用回退")
|
||||
@ -0,0 +1,22 @@
|
||||
"""将 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("运行控制表名迁移不可直接回退,请停止服务并按兼容版本处理")
|
||||
@ -15,7 +15,8 @@ from fastapi import APIRouter, Depends, Path, Query
|
||||
|
||||
from app.api.deps import DbSession, PayrollWriter, RequestMetaDep, get_current_user
|
||||
from app.core.response import ApiResponse, PageQuery, PageResult, ok
|
||||
from app.models import User
|
||||
from app.models import Customer, User
|
||||
from app.schemas.category_batch import CustomerCategoryBatch, CustomerOwnershipBatch
|
||||
from app.schemas.customer import (
|
||||
CustomerCreateIn,
|
||||
CustomerOut,
|
||||
@ -33,6 +34,18 @@ router = APIRouter(prefix="/customers", tags=["客户管理"])
|
||||
CurrentUser = Annotated[User, Depends(get_current_user)]
|
||||
|
||||
|
||||
@router.put("/batch-category")
|
||||
def batch_category(payload: CustomerCategoryBatch, session: DbSession, actor: PayrollWriter, meta: RequestMetaDep):
|
||||
from app.services.category_batch import fill_missing
|
||||
|
||||
return ok(fill_missing(session, Customer, payload, actor, meta))
|
||||
|
||||
|
||||
@router.put("/batch-ownership")
|
||||
def batch_ownership(payload: CustomerOwnershipBatch, session: DbSession, actor: PayrollWriter, meta: RequestMetaDep):
|
||||
return ok(customer_service.batch_change_ownership(session, payload, operator=actor, meta=meta))
|
||||
|
||||
|
||||
@router.get("", response_model=ApiResponse[PageResult[CustomerOut]], summary="客户列表")
|
||||
def list_customers(
|
||||
session: DbSession,
|
||||
|
||||
@ -16,7 +16,8 @@ from fastapi import APIRouter, Depends, Path, Query
|
||||
|
||||
from app.api.deps import DbSession, PayrollWriter, RequestMetaDep, get_current_user
|
||||
from app.core.response import ApiResponse, PageResult, ok
|
||||
from app.models import User
|
||||
from app.models import Product, User
|
||||
from app.schemas.category_batch import ProductCategoryBatch
|
||||
from app.schemas.product import (
|
||||
ProductCreateIn,
|
||||
ProductExtractIn,
|
||||
@ -32,6 +33,13 @@ CurrentUser = Annotated[User, Depends(get_current_user)]
|
||||
router = APIRouter(prefix="/products", tags=["产品管理"])
|
||||
|
||||
|
||||
@router.put("/batch-category")
|
||||
def batch_category(payload: ProductCategoryBatch, session: DbSession, actor: PayrollWriter, meta: RequestMetaDep):
|
||||
from app.services.category_batch import fill_missing
|
||||
|
||||
return ok(fill_missing(session, Product, payload, actor, meta))
|
||||
|
||||
|
||||
@router.get("/extract/candidates")
|
||||
def extraction_candidates(session: DbSession, _: CurrentUser, keyword: str = "", page_num: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100)):
|
||||
from app.services.product_completion import candidates
|
||||
|
||||
@ -24,7 +24,16 @@ from app.schemas.business import (
|
||||
GrossProfitOut,
|
||||
OwnershipChangeLogOut,
|
||||
)
|
||||
from app.services import business_service
|
||||
from app.schemas.business_source import (
|
||||
GuidanceConfirmIn,
|
||||
SalesRelationConfirmIn,
|
||||
SalesRelationQuery,
|
||||
SalesRelationReleaseIn,
|
||||
SourceDocumentsQuery,
|
||||
SourceMaterialsIn,
|
||||
SourcePriceConfirmIn,
|
||||
)
|
||||
from app.services import business_service, business_source
|
||||
|
||||
# 基础数据是业务操作:登录即可,不要求管理员(区别于系统管理的 AdminUser)
|
||||
CurrentUser = Annotated[User, Depends(get_current_user)]
|
||||
@ -32,6 +41,83 @@ 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,
|
||||
|
||||
@ -13,12 +13,13 @@ from app.api.deps import DbSession, PayrollWriter, RequestMetaDep, get_current_u
|
||||
from app.core.response import ApiResponse, PageResult, ok
|
||||
from app.models import User
|
||||
from app.schemas.receipt import (
|
||||
ReceiptConfirmIn,
|
||||
ReceiptCreateIn,
|
||||
ReceiptOut,
|
||||
ReceiptQuery,
|
||||
ReceiptUpdateIn,
|
||||
)
|
||||
from app.services import receipt_service
|
||||
from app.services import receipt_confirmation, receipt_service
|
||||
|
||||
# 基础数据是业务操作:登录即可,不要求管理员(区别于系统管理的 AdminUser)
|
||||
CurrentUser = Annotated[User, Depends(get_current_user)]
|
||||
@ -26,6 +27,21 @@ CurrentUser = Annotated[User, Depends(get_current_user)]
|
||||
router = APIRouter(prefix="/receipts", tags=["回款管理"])
|
||||
|
||||
|
||||
@router.get("/confirmation-policy", summary="回款人工确认开关状态")
|
||||
def confirmation_policy(session: DbSession, _: CurrentUser):
|
||||
return ok({"enabled": receipt_confirmation.enabled(session)})
|
||||
|
||||
|
||||
@router.post("/{receipt_id}/confirm", response_model=ApiResponse[ReceiptOut], summary="独立人工确认回款")
|
||||
def confirm_receipt(
|
||||
receipt_id: Annotated[int, Path(ge=1)], payload: ReceiptConfirmIn,
|
||||
session: DbSession, current_user: CurrentUser, meta: RequestMetaDep,
|
||||
):
|
||||
return ok(receipt_service.confirm_receipt(
|
||||
session, receipt_id, payload, operator=current_user, meta=meta,
|
||||
), message="回款已人工确认")
|
||||
|
||||
|
||||
@router.get("", response_model=ApiResponse[PageResult[ReceiptOut]], summary="回款列表")
|
||||
def list_receipts(
|
||||
session: DbSession,
|
||||
|
||||
@ -11,8 +11,10 @@ router = APIRouter(prefix="/meta", tags=["业务参照"])
|
||||
|
||||
|
||||
@router.get("/payroll-input/{business_id}")
|
||||
def payroll_metadata(business_id: int, session: DbSession, actor: LoginUser):
|
||||
return ok(payroll_options(session, actor, business_id))
|
||||
def payroll_metadata(business_id: int, session: DbSession, actor: LoginUser,
|
||||
page_num: Annotated[int, Query(alias="pageNum", ge=1)] = 1,
|
||||
page_size: Annotated[int, Query(alias="pageSize", ge=1, le=200)] = 200):
|
||||
return ok(payroll_options(session, actor, business_id, page_num=page_num, page_size=page_size))
|
||||
|
||||
|
||||
@router.get("/options")
|
||||
|
||||
@ -18,8 +18,10 @@ from app.schemas.tplus import (
|
||||
TPlusErrorQuery,
|
||||
TPlusOverviewOut,
|
||||
TPlusRetryIn,
|
||||
TPlusRuntimeOut,
|
||||
TPlusRuntimeUpdate,
|
||||
)
|
||||
from app.services.tplus import automation_config, query_service, task_service
|
||||
from app.services.tplus import automation_config, query_service, runtime_control, task_service
|
||||
|
||||
router = APIRouter(prefix="/tplus", tags=["系统管理 · T+ 数据同步"])
|
||||
router.include_router(workbench_router)
|
||||
@ -39,6 +41,17 @@ def update_automation(session: DbSession, admin: PayrollWriter, meta: RequestMet
|
||||
return ok(automation_config.save(session, payload, task_service.TaskActor(admin, meta)))
|
||||
|
||||
|
||||
@router.get("/runtime", response_model=ApiResponse[TPlusRuntimeOut])
|
||||
def runtime_status(session: DbSession, _: WorkbenchReader):
|
||||
return ok(runtime_control.overview(session))
|
||||
|
||||
|
||||
@router.put("/runtime", response_model=ApiResponse[TPlusRuntimeOut])
|
||||
def update_runtime(session: DbSession, admin: PayrollWriter, meta: RequestMetaDep,
|
||||
payload: TPlusRuntimeUpdate):
|
||||
return ok(runtime_control.save(session, payload, task_service.TaskActor(admin, meta)))
|
||||
|
||||
|
||||
@router.get("/batches", response_model=ApiResponse[PageResult[TPlusBatchOut]])
|
||||
def batches(session: DbSession, _: WorkbenchReader, query: Annotated[TPlusBatchQuery, Query()]):
|
||||
"""分页查询批次元数据。"""
|
||||
|
||||
@ -7,12 +7,46 @@ from starlette.responses import StreamingResponse
|
||||
|
||||
from app.api.deps import DbSession, PayrollWriter, WorkbenchReader
|
||||
from app.core.response import ApiResponse, PageQuery, PageResult, ok
|
||||
from app.schemas.tplus_workbench import FactIn, SourceChoiceQuery, WorkCreateIn, WorkQuery
|
||||
from app.services.tplus import workbench
|
||||
from app.schemas.tplus_business_sync import BusinessSyncRun, BusinessSyncUpdate, SyncRange
|
||||
from app.schemas.tplus_workbench import (
|
||||
CorrectionPairIn,
|
||||
FactIn,
|
||||
SourceChoiceQuery,
|
||||
WorkCreateIn,
|
||||
WorkQuery,
|
||||
)
|
||||
from app.services.tplus import business_sync_control, workbench
|
||||
|
||||
router = APIRouter()
|
||||
RunNo = Annotated[str, Path(pattern=r"^[A-Za-z0-9_-]+$", max_length=32)]
|
||||
|
||||
@router.get("/business-sync")
|
||||
def business_sync_config(session: DbSession, _: WorkbenchReader):
|
||||
return ok(business_sync_control.configuration(session))
|
||||
|
||||
|
||||
@router.put("/business-sync")
|
||||
def business_sync_save(session: DbSession, actor: PayrollWriter, payload: BusinessSyncUpdate):
|
||||
return ok(business_sync_control.save(session, payload, actor.id))
|
||||
|
||||
|
||||
@router.post("/business-sync/preview")
|
||||
def business_sync_preview(session: DbSession, _: PayrollWriter, payload: SyncRange):
|
||||
return ok(business_sync_control.readonly_preview(session, payload.start_date))
|
||||
|
||||
|
||||
@router.post("/business-sync/runs")
|
||||
def business_sync_run(session: DbSession, actor: PayrollWriter, payload: BusinessSyncRun):
|
||||
return ok(business_sync_control.queue(session, actor.id, payload.start_date, payload.request_key))
|
||||
|
||||
|
||||
@router.post("/correction-pairs")
|
||||
def save_correction_pair(session: DbSession, actor: PayrollWriter, payload: CorrectionPairIn):
|
||||
"""成对排除转客户更正单,将实际回款关联原业务,保留实际到账日。"""
|
||||
from app.services.tplus.corrections import save_pair
|
||||
|
||||
return ok(save_pair(session, payload, actor.id))
|
||||
|
||||
|
||||
@router.get("/facts", response_model=ApiResponse[PageResult[dict]])
|
||||
def facts(session: DbSession, _: WorkbenchReader, query: Annotated[WorkQuery, Query()]):
|
||||
|
||||
@ -175,6 +175,12 @@ def _maintenance(args):
|
||||
return 0
|
||||
|
||||
|
||||
def _tplus_supervisor(args):
|
||||
from app.services.tplus.supervisor import run
|
||||
|
||||
return run(get_session_factory(), get_settings(), interval=args.poll_seconds)
|
||||
|
||||
|
||||
def _materials(args):
|
||||
from app.services.tplus.materials import collect_materials, render_markdown
|
||||
|
||||
@ -247,6 +253,8 @@ def main() -> int:
|
||||
p_maintenance.add_argument("--once", action="store_true", help="执行一次检查(默认)")
|
||||
p_maintenance.add_argument("--connection-file", default=None)
|
||||
p_maintenance.add_argument("--stop-file", default=None)
|
||||
p_supervisor = sub.add_parser("tplus-supervisor", help="按页面设置托管 T+ 读取服务")
|
||||
p_supervisor.add_argument("--poll-seconds", type=int, choices=range(2, 61), default=5)
|
||||
p_materials = sub.add_parser("tplus-materials", help="只读整理客户样本核对资料,不生成工资")
|
||||
p_materials.add_argument("--customer-id", type=int, required=True)
|
||||
p_materials.add_argument("--month", required=True)
|
||||
@ -262,6 +270,8 @@ def main() -> int:
|
||||
return _tplus_worker(args)
|
||||
if args.command == "tplus-maintain":
|
||||
return _maintenance(args)
|
||||
if args.command == "tplus-supervisor":
|
||||
return _tplus_supervisor(args)
|
||||
if args.command == "tplus-materials":
|
||||
return _materials(args)
|
||||
return _stage_tplus(args)
|
||||
|
||||
@ -88,11 +88,14 @@ 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 = True
|
||||
# 默认校验服务器证书;自签回退只允许隔离开发显式开启,避免中间人替换证书。
|
||||
t_plus_trust_server_certificate: bool = False
|
||||
t_plus_connect_timeout: int = 10
|
||||
t_plus_query_timeout: int = Field(default=120, ge=1, le=600)
|
||||
t_plus_query_dir: str = ""
|
||||
@ -100,6 +103,7 @@ 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
|
||||
|
||||
@ -55,8 +55,11 @@ def resolve_tplus_settings(settings: Settings) -> Settings:
|
||||
t_plus_password=config.password.get_secret_value(), t_plus_odbc_driver=config.driver
|
||||
)
|
||||
resolved = settings.model_copy(update=values)
|
||||
if resolved.t_plus_user.strip().casefold() == "sa":
|
||||
raise RuntimeError("应用必须使用 T+ 专用只读账号,不能使用 sa")
|
||||
allow_dev_sa = settings.app_env.strip().casefold() == "dev" and settings.t_plus_allow_dev_sa
|
||||
if resolved.t_plus_user.strip().casefold() == "sa" and not (
|
||||
allow_dev_sa or settings.t_plus_allow_admin_account
|
||||
):
|
||||
raise RuntimeError("T+ 管理员账号未获授权,请检查连接配置")
|
||||
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
|
||||
|
||||
@ -31,7 +31,12 @@ 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
|
||||
from app.models.tplus_automation import (
|
||||
TPlusAutomationConfig,
|
||||
TPlusAutomationState,
|
||||
TPlusRuntimeControl,
|
||||
)
|
||||
from app.models.tplus_business_sync import TPlusBusinessDocument, TPlusBusinessSyncConfig
|
||||
from app.models.tplus_sync import (
|
||||
TPlusSyncBatch,
|
||||
TPlusSyncError,
|
||||
@ -91,7 +96,10 @@ __all__ = [
|
||||
"SystemSetting",
|
||||
"TPlusAutomationConfig",
|
||||
"TPlusAutomationState",
|
||||
"TPlusBusinessDocument",
|
||||
"TPlusBusinessSyncConfig",
|
||||
"TPlusFact",
|
||||
"TPlusRuntimeControl",
|
||||
"TPlusSourceHead",
|
||||
"TPlusSourceLink",
|
||||
"TPlusSyncBatch",
|
||||
|
||||
@ -16,6 +16,7 @@ from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import (
|
||||
JSON,
|
||||
Date,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
@ -147,6 +148,7 @@ class Business(Base, TimestampMixin, TPlusSyncMixin):
|
||||
comment="客户ID(customers.id)",
|
||||
)
|
||||
business_date: Mapped[date | None] = mapped_column(Date, comment="业务日期(T+ 同步口径)")
|
||||
integration_json: Mapped[dict | None] = mapped_column(JSON, comment="T+业务接入范围及来源人员摘要,非核算快照")
|
||||
status: Mapped[int] = mapped_column(
|
||||
TINYINT_TYPE,
|
||||
nullable=False,
|
||||
@ -239,9 +241,9 @@ class BusinessItem(Base, TimestampMixin):
|
||||
comment="规格型号(冗余固化;空串对齐 products 组合唯一键口径)",
|
||||
)
|
||||
unit: Mapped[str | None] = mapped_column(String(16), comment="计量单位")
|
||||
product_category: Mapped[str] = mapped_column(
|
||||
product_category: Mapped[str | None] = mapped_column(
|
||||
String(32),
|
||||
nullable=False,
|
||||
nullable=True,
|
||||
comment="产品类别(行级折算锚点,混合业务按产品逐条计算,对照表 #14;值域同 PRODUCT_CATEGORIES)",
|
||||
)
|
||||
quantity: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False, comment="数量(>0)")
|
||||
|
||||
@ -49,10 +49,9 @@ class Customer(Base, TimestampMixin, TPlusSyncMixin):
|
||||
contact_phone: Mapped[str | None] = mapped_column(String(20), comment="联系电话")
|
||||
address: Mapped[str | None] = mapped_column(String(255), comment="地址")
|
||||
industry: Mapped[str | None] = mapped_column(String(64), comment="所属行业(自由文本)")
|
||||
customer_type: Mapped[str] = mapped_column(
|
||||
customer_type: Mapped[str | None] = mapped_column(
|
||||
String(32),
|
||||
nullable=False,
|
||||
server_default="personal",
|
||||
nullable=True,
|
||||
index=True,
|
||||
comment="客户分类:personal个人户 manager_personal经理个人户 enterprise企业户(计提规则匹配锚点;T+同步不覆盖)",
|
||||
)
|
||||
|
||||
@ -148,7 +148,7 @@ class PerformanceLedger(Base, TimestampMixin):
|
||||
TINYINT_TYPE,
|
||||
nullable=False,
|
||||
server_default="0",
|
||||
comment="以物抵顶折算:1已按 80% 折算 0否",
|
||||
comment="以物抵顶处理标记:当前系统不自动折算,1表示由线下规则处理 0表示未标记",
|
||||
)
|
||||
weight_r: Mapped[int | None] = mapped_column(comment="支付权重 R(回款占销售额百分比向下取整)")
|
||||
payment_weight: Mapped[Decimal | None] = mapped_column(
|
||||
|
||||
@ -47,9 +47,9 @@ class Product(Base, TimestampMixin, TPlusSyncMixin):
|
||||
sale_price: Mapped[Decimal | None] = mapped_column(
|
||||
Numeric(14, 4), comment="销售单价(元,T+ 同步)"
|
||||
)
|
||||
product_category: Mapped[str] = mapped_column(
|
||||
product_category: Mapped[str | None] = mapped_column(
|
||||
String(32),
|
||||
nullable=False,
|
||||
nullable=True,
|
||||
comment="产品类别:municipal_water市政水利 rail_transit轨道交通(决定折算阈值 30%/20%;T+同步不覆盖)",
|
||||
)
|
||||
status: Mapped[int] = mapped_column(
|
||||
|
||||
@ -10,10 +10,10 @@
|
||||
- 一期禁负数金额(≥0):红字/退款冲销需求出现时先补设计再放开
|
||||
"""
|
||||
|
||||
from datetime import date
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import Date, ForeignKey, Index, Numeric, String
|
||||
from sqlalchemy import Date, DateTime, ForeignKey, Index, Numeric, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.models.base import (
|
||||
@ -72,7 +72,7 @@ class Receipt(Base, TimestampMixin, TPlusSyncMixin):
|
||||
TINYINT_TYPE,
|
||||
nullable=False,
|
||||
server_default="0",
|
||||
comment="以物抵顶标记:1是 0否(M3 核算按应发绩效×barter.discount 折算,M2 仅标记)",
|
||||
comment="以物抵顶标记:1是 0否(特殊绩效线下核实,系统不自动折算)",
|
||||
)
|
||||
settlement_status: Mapped[int] = mapped_column(
|
||||
TINYINT_TYPE,
|
||||
@ -80,4 +80,10 @@ class Receipt(Base, TimestampMixin, TPlusSyncMixin):
|
||||
server_default="0",
|
||||
comment="绩效核算状态:0未核算 1已核算(M3 核算防重锚点;已核算回款禁改禁删)",
|
||||
)
|
||||
confirmation_status: Mapped[int] = mapped_column(
|
||||
TINYINT_TYPE, nullable=False, server_default="0", comment="人工确认状态:0待确认 1已确认,独立于凭证及核算状态"
|
||||
)
|
||||
confirmed_by: Mapped[str | None] = mapped_column(String(64), comment="人工确认人账号快照")
|
||||
confirmed_at: Mapped[datetime | None] = mapped_column(DateTime, comment="人工确认时间")
|
||||
confirmed_revision: Mapped[str | None] = mapped_column(String(64), comment="人工确认时回款关键内容指纹")
|
||||
remark: Mapped[str | None] = mapped_column(String(255), comment="备注")
|
||||
|
||||
@ -49,10 +49,9 @@ class Shipment(Base, TimestampMixin, TPlusSyncMixin):
|
||||
ship_date: Mapped[date] = mapped_column(
|
||||
Date, nullable=False, comment="发货日期(回款天数基准日,发货日首日=第 1 天)"
|
||||
)
|
||||
freight_amount: Mapped[Decimal] = mapped_column(
|
||||
freight_amount: Mapped[Decimal | None] = mapped_column(
|
||||
Numeric(14, 2),
|
||||
nullable=False,
|
||||
server_default="0",
|
||||
nullable=True,
|
||||
comment="运费金额(仅记录展示;不计入毛利润口径,对照表 #2)",
|
||||
)
|
||||
total_amount: Mapped[Decimal] = mapped_column(
|
||||
|
||||
@ -24,6 +24,24 @@ class TPlusAutomationConfig(Base, TimestampMixin):
|
||||
updated_by: Mapped[int] = mapped_column(BIGINT_UNSIGNED, comment="最近修改账号ID")
|
||||
|
||||
|
||||
class TPlusRuntimeControl(Base, TimestampMixin):
|
||||
"""单部署的服务期望状态与托管进程报告,默认不启动。"""
|
||||
|
||||
__tablename__ = "tplus_runtime_controls"
|
||||
__table_args__ = mysql_table_args("T+读取服务运行控制")
|
||||
id: Mapped[int] = mapped_column(BIGINT_UNSIGNED, primary_key=True, autoincrement=False, comment="主键ID")
|
||||
desired_state: Mapped[str] = mapped_column(String(16), server_default="STOPPED", comment="期望状态:RUNNING/STOPPED")
|
||||
actual_state: Mapped[str] = mapped_column(String(16), server_default="STOPPED", comment="实际状态:RUNNING/STOPPED/STOPPING/DEGRADED/OFFLINE")
|
||||
worker_phase: Mapped[str] = mapped_column(String(16), server_default="STOPPED", comment="读取服务阶段")
|
||||
heartbeat_at: Mapped[datetime | None] = mapped_column(DateTime, comment="最近心跳时间")
|
||||
last_error: Mapped[str | None] = mapped_column(String(255), comment="最近受控错误")
|
||||
host: Mapped[str | None] = mapped_column(String(128), comment="运行主机")
|
||||
platform: Mapped[str | None] = mapped_column(String(32), comment="运行平台")
|
||||
owner: Mapped[str | None] = mapped_column(String(36), comment="当前托管进程身份")
|
||||
version: Mapped[int] = mapped_column(Integer, server_default="0", comment="状态版本号")
|
||||
updated_by: Mapped[int | None] = mapped_column(BIGINT_UNSIGNED, comment="最近修改账号ID")
|
||||
|
||||
|
||||
class TPlusAutomationState(Base, TimestampMixin):
|
||||
__tablename__ = "tplus_automation_states"
|
||||
__table_args__ = mysql_table_args("T+自动采集周期与进程状态(不自动导入正式业务)")
|
||||
|
||||
51
backend/app/models/tplus_business_sync.py
Normal file
51
backend/app/models/tplus_business_sync.py
Normal file
@ -0,0 +1,51 @@
|
||||
"""T+ 业务自动接入:源级开关与单据差异账本,不承载核算结果。"""
|
||||
|
||||
from datetime import date
|
||||
|
||||
from sqlalchemy import JSON, Date, Integer, String, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.models.base import BIGINT_UNSIGNED, TINYINT_TYPE, Base, TimestampMixin, mysql_table_args
|
||||
|
||||
|
||||
class TPlusBusinessSyncConfig(Base, TimestampMixin):
|
||||
"""每个源账套一条设置;启用后才按起点自动排队接入。"""
|
||||
|
||||
__tablename__ = "tplus_business_sync_configs"
|
||||
__table_args__ = mysql_table_args(
|
||||
"T+业务自动接入设置",
|
||||
UniqueConstraint("source_database", name="uk_tplus_business_sync_configs_source"),
|
||||
)
|
||||
id: Mapped[int] = mapped_column(BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID")
|
||||
source_database: Mapped[str] = mapped_column(String(128), comment="源账套")
|
||||
enabled: Mapped[int] = mapped_column(TINYINT_TYPE, server_default="0", comment="自动接入是否启用:1启用0关闭")
|
||||
start_date: Mapped[date] = mapped_column(Date, comment="业务接入起点")
|
||||
version: Mapped[int] = mapped_column(Integer, server_default="0", comment="配置乐观版本")
|
||||
updated_by: Mapped[int] = mapped_column(BIGINT_UNSIGNED, comment="授权启用账号")
|
||||
last_error: Mapped[str | None] = mapped_column(String(64), comment="后台接入暂停原因")
|
||||
|
||||
|
||||
class TPlusBusinessDocument(Base, TimestampMixin):
|
||||
"""源单线与正式单据的对应关系与差异结论;重复同步只更新本表。"""
|
||||
|
||||
__tablename__ = "tplus_business_documents"
|
||||
__table_args__ = mysql_table_args(
|
||||
"T+业务单据接入与差异账本",
|
||||
UniqueConstraint(
|
||||
"source_database", "entity_type", "source_id",
|
||||
name="uk_tplus_business_documents_source_entity_id",
|
||||
),
|
||||
)
|
||||
id: Mapped[int] = mapped_column(BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID")
|
||||
source_database: Mapped[str] = mapped_column(String(128), comment="源账套")
|
||||
entity_type: Mapped[str] = mapped_column(String(32), comment="源实体")
|
||||
source_id: Mapped[str] = mapped_column(String(64), comment="源单据或明细ID")
|
||||
source_no: Mapped[str | None] = mapped_column(String(128), comment="原始单号")
|
||||
record_id: Mapped[int] = mapped_column(BIGINT_UNSIGNED, comment="当前观察到的源快照ID")
|
||||
applied_record_id: Mapped[int | None] = mapped_column(BIGINT_UNSIGNED, comment="已接入的源快照ID")
|
||||
fingerprint: Mapped[str | None] = mapped_column(String(64), comment="已应用的业务字段指纹")
|
||||
business_id: Mapped[int | None] = mapped_column(BIGINT_UNSIGNED, comment="关联业务ID")
|
||||
target_id: Mapped[int | None] = mapped_column(BIGINT_UNSIGNED, comment="正式单据或明细ID")
|
||||
status: Mapped[str] = mapped_column(String(24), default="PENDING", comment="APPLIED/PENDING/CONFLICT/EXCLUDED")
|
||||
reason: Mapped[str | None] = mapped_column(String(128), comment="固定待补或差异编码")
|
||||
data_json: Mapped[dict] = mapped_column(JSON, comment="最小必要来源与差异说明")
|
||||
@ -21,6 +21,9 @@ class TPlusSourceHead(Base):
|
||||
name="uk_tplus_source_heads_source_entity_id",
|
||||
),
|
||||
Index("idx_tplus_source_heads_batch_entity", "batch_no", "entity_type"),
|
||||
Index("idx_tplus_source_heads_parent", "source_database", "entity_type", "source_parent_id"),
|
||||
Index("idx_tplus_source_heads_customer_date", "source_database", "entity_type", "source_customer_id", "source_date"),
|
||||
Index("idx_tplus_source_heads_date", "source_database", "entity_type", "source_date", "id"),
|
||||
)
|
||||
id: Mapped[int] = mapped_column(
|
||||
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
|
||||
@ -29,6 +32,9 @@ class TPlusSourceHead(Base):
|
||||
entity_type: Mapped[str] = mapped_column(String(32), comment="实体类型")
|
||||
source_id: Mapped[str] = mapped_column(String(64), comment="源内部ID")
|
||||
record_id: Mapped[int] = mapped_column(BIGINT_UNSIGNED, comment="原始快照ID")
|
||||
source_parent_id: Mapped[str | None] = mapped_column(String(64), comment="源父单据ID查询投影")
|
||||
source_customer_id: Mapped[str | None] = mapped_column(String(64), comment="源客户ID查询投影")
|
||||
source_date: Mapped[str | None] = mapped_column(String(10), comment="源业务日期查询投影")
|
||||
batch_no: Mapped[str] = mapped_column(String(32), comment="最后观察批次")
|
||||
|
||||
|
||||
|
||||
@ -15,15 +15,21 @@ from app.models import (
|
||||
Customer,
|
||||
OwnershipChangeLog,
|
||||
Personnel,
|
||||
Product,
|
||||
Receipt,
|
||||
SecondaryAllocation,
|
||||
Shipment,
|
||||
TPlusBusinessDocument,
|
||||
)
|
||||
from app.models.customer import OWNER_TYPE_BUSINESS
|
||||
from app.repositories import ownership_log_repository
|
||||
from app.repositories.ownership_log_repository import OwnershipLogRef
|
||||
from app.schemas.business import BusinessQuery
|
||||
|
||||
READINESS_SQL_REASONS = frozenset({
|
||||
"CUSTOMER_CATEGORY_PENDING", "PRODUCT_CATEGORY_PENDING", "NO_ACTUAL_SHIPMENT", "SOURCE_CONFLICT",
|
||||
})
|
||||
|
||||
|
||||
def get_by_id(session: Session, business_id: int, *, for_update: bool = False) -> Business | None:
|
||||
"""按主键取业务行;for_update 供删除保护、编辑门槛与状态机使用。"""
|
||||
@ -98,9 +104,40 @@ def _apply_filters(stmt: Select, query: BusinessQuery) -> Select:
|
||||
stmt = stmt.where(Business.business_date >= query.date_from)
|
||||
if query.date_to is not None:
|
||||
stmt = stmt.where(Business.business_date <= query.date_to)
|
||||
if query.source_type == "TPLUS":
|
||||
stmt = stmt.where(Business.t_plus_id.is_not(None))
|
||||
elif query.source_type == "MANUAL":
|
||||
stmt = stmt.where(Business.t_plus_id.is_(None))
|
||||
if query.source_personnel:
|
||||
stmt = stmt.where(Business.integration_json["personnel_names"].as_string().contains(query.source_personnel, autoescape=True))
|
||||
if query.readiness_reason in READINESS_SQL_REASONS:
|
||||
stmt = stmt.where(Business.integration_json["source_database"].as_string().is_not(None))
|
||||
if query.readiness_reason == "CUSTOMER_CATEGORY_PENDING":
|
||||
stmt = stmt.where(Customer.customer_type.is_(None))
|
||||
elif query.readiness_reason == "PRODUCT_CATEGORY_PENDING":
|
||||
stmt = stmt.where(exists(select(BusinessItem.id).join(Product, Product.id == BusinessItem.product_id).where(
|
||||
BusinessItem.business_id == Business.id, Product.product_category.is_(None))))
|
||||
elif query.readiness_reason == "NO_ACTUAL_SHIPMENT":
|
||||
stmt = stmt.where(~exists().where(Shipment.business_id == Business.id))
|
||||
elif query.readiness_reason == "SOURCE_CONFLICT":
|
||||
stmt = stmt.where(exists().where(TPlusBusinessDocument.business_id == Business.id,
|
||||
TPlusBusinessDocument.status.in_(("CONFLICT", "PENDING"))))
|
||||
return stmt
|
||||
|
||||
|
||||
def scan_businesses(session: Session, query: BusinessQuery, before_id: int | None, limit: int = 100):
|
||||
"""按主键倒序分批读取,供需要计算准备状态的筛选使用。"""
|
||||
owner = aliased(Personnel)
|
||||
assistant = aliased(Personnel)
|
||||
stmt = select(Business, Customer.company_name, owner.personnel_name, assistant.personnel_name).join(
|
||||
Customer, Customer.id == Business.customer_id)
|
||||
stmt = _apply_filters(stmt, query).outerjoin(owner, owner.id == Business.owner_personnel_id).outerjoin(
|
||||
assistant, assistant.id == Business.assistant_personnel_id)
|
||||
if before_id is not None:
|
||||
stmt = stmt.where(Business.id < before_id)
|
||||
return list(session.execute(stmt.order_by(Business.id.desc()).limit(limit)).all())
|
||||
|
||||
|
||||
def list_businesses(
|
||||
session: Session, query: BusinessQuery
|
||||
) -> list[tuple[Business, str, str | None, str | None]]:
|
||||
|
||||
@ -114,8 +114,12 @@ def _apply_filters(stmt: Select, query: CustomerQuery) -> Select:
|
||||
stmt = stmt.where(_keyword_clause(query.keyword))
|
||||
if query.customer_type:
|
||||
stmt = stmt.where(Customer.customer_type == query.customer_type)
|
||||
if query.category_missing:
|
||||
stmt = stmt.where(Customer.customer_type.is_(None))
|
||||
if query.owner_personnel_id is not None:
|
||||
stmt = stmt.where(Customer.owner_personnel_id == query.owner_personnel_id)
|
||||
if query.owner_missing:
|
||||
stmt = stmt.where(Customer.owner_personnel_id.is_(None))
|
||||
return stmt
|
||||
|
||||
|
||||
|
||||
@ -66,6 +66,8 @@ def _apply_filters(stmt: Select, query: ProductQuery) -> Select:
|
||||
)
|
||||
if query.product_category:
|
||||
stmt = stmt.where(Product.product_category == query.product_category)
|
||||
if query.category_missing:
|
||||
stmt = stmt.where(Product.product_category.is_(None))
|
||||
if query.status is not None:
|
||||
stmt = stmt.where(Product.status == query.status)
|
||||
return stmt
|
||||
|
||||
@ -57,6 +57,8 @@ def _apply_filters(stmt: Select, query: ReceiptQuery) -> Select:
|
||||
# settlement_status 是 M3 核算防重锚点(需规 2.3.3),必须保持具名字段
|
||||
if query.settlement_status is not None:
|
||||
stmt = stmt.where(Receipt.settlement_status == query.settlement_status)
|
||||
if query.confirmation_status is not None:
|
||||
stmt = stmt.where(Receipt.confirmation_status == query.confirmation_status)
|
||||
return stmt
|
||||
|
||||
|
||||
|
||||
@ -217,6 +217,16 @@ def count_business_stats(session: Session, query: BusinessStatsQuery) -> int:
|
||||
return int(session.execute(stmt).scalar_one())
|
||||
|
||||
|
||||
def list_source_business_sales(session: Session, query: BusinessStatsQuery, *, after_id: int, limit: int):
|
||||
"""键集分页读取筛选范围内的来源业务,不加载完整业务或用户数据。"""
|
||||
stmt = (select(Business.id, Business.total_sale_amount).select_from(Business)
|
||||
.outerjoin(Customer, Customer.id == Business.customer_id)
|
||||
.where(*_business_filters(query),
|
||||
Business.integration_json["source_database"].as_string().is_not(None),
|
||||
Business.id > after_id).order_by(Business.id).limit(limit))
|
||||
return list(session.execute(stmt))
|
||||
|
||||
|
||||
def sum_business_stats(session: Session, query: BusinessStatsQuery) -> dict:
|
||||
"""业务统计全筛选合计(业务数/发货/回款/销售额/绩效/应付)。"""
|
||||
perf_sq = _business_perf_subquery()
|
||||
|
||||
@ -9,8 +9,9 @@
|
||||
from sqlalchemy import Select, func, or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import Role, UserRole
|
||||
from app.models import Permission, Role, RolePermission, UserRole
|
||||
from app.schemas.role import RoleQuery
|
||||
from app.services.permission_catalog import CATALOG
|
||||
|
||||
# ==================== 读取 ====================
|
||||
|
||||
@ -97,6 +98,19 @@ def count_users_by_role(session: Session, role_ids: list[int]) -> dict[int, int]
|
||||
return {int(role_id): int(count) for role_id, count in session.execute(stmt)}
|
||||
|
||||
|
||||
def count_permissions_by_role(session: Session, role_ids: list[int]) -> dict[int, int]:
|
||||
"""批量统计角色已配置的权限,避免列表逐行查询。"""
|
||||
if not role_ids:
|
||||
return {}
|
||||
stmt = (
|
||||
select(RolePermission.role_id, func.count())
|
||||
.join(Permission, Permission.id == RolePermission.permission_id)
|
||||
.where(RolePermission.role_id.in_(role_ids), Permission.perm_code.in_(CATALOG))
|
||||
.group_by(RolePermission.role_id)
|
||||
)
|
||||
return {int(role_id): int(count) for role_id, count in session.execute(stmt)}
|
||||
|
||||
|
||||
# ==================== 写入 ====================
|
||||
|
||||
|
||||
|
||||
@ -126,7 +126,7 @@ class BusinessItemOut(BaseModel):
|
||||
product_name: str
|
||||
spec_model: str | None = None
|
||||
unit: str | None = None
|
||||
product_category: str
|
||||
product_category: str | None = None
|
||||
quantity: str
|
||||
sale_price: str | None = None
|
||||
sale_amount: str
|
||||
@ -158,6 +158,12 @@ class BusinessOut(BaseModel):
|
||||
total_received_amount: str | None = None
|
||||
total_freight_amount: str | None = None
|
||||
remark: str | None = None
|
||||
source_type: str = "MANUAL"
|
||||
source_no: str | None = None
|
||||
source_personnel: list[str] = Field(default_factory=list)
|
||||
integration_status: str | None = None
|
||||
readiness_status: str | None = None
|
||||
readiness_reasons: list[dict[str, str]] = Field(default_factory=list)
|
||||
|
||||
|
||||
class SecondaryAllocationIn(BaseModel):
|
||||
@ -331,13 +337,18 @@ class BusinessQuery(PageQuery):
|
||||
owner_personnel_id: int | None = Field(default=None, ge=1, description="负责营销人员筛选")
|
||||
date_from: date | None = Field(default=None, description="业务日期起(含)")
|
||||
date_to: date | None = Field(default=None, description="业务日期止(含)")
|
||||
source_type: str | None = Field(default=None, pattern=r"^(MANUAL|TPLUS)$")
|
||||
source_personnel: str | None = Field(default=None, max_length=64)
|
||||
pending_only: bool = False
|
||||
readiness_status: str | None = Field(default=None, pattern=r"^(READY|PENDING)$")
|
||||
readiness_reason: str | None = Field(default=None, max_length=64, pattern=r"^[A-Z_]+$")
|
||||
|
||||
|
||||
class GrossProfitRowOut(BaseModel):
|
||||
"""毛利润视图行:行级源数据 + 派生值(实时计算,不落库)。"""
|
||||
|
||||
item_id: int
|
||||
product_category: str
|
||||
product_category: str | None = None
|
||||
product_name: str
|
||||
spec_model: str | None = None
|
||||
quantity: str
|
||||
@ -346,7 +357,7 @@ class GrossProfitRowOut(BaseModel):
|
||||
"""成本缺失(未录入)时为 None,派生列同为 None 并置 missing_reason"""
|
||||
profit_amount: str | None = None
|
||||
profit_rate: str | None = None
|
||||
threshold: str
|
||||
threshold: str | None = None
|
||||
converted_profit: str | None = None
|
||||
missing_reason: str | None = None
|
||||
|
||||
|
||||
109
backend/app/schemas/business_source.py
Normal file
109
backend/app/schemas/business_source.py
Normal file
@ -0,0 +1,109 @@
|
||||
"""来源业务的人工分类资料。"""
|
||||
|
||||
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
|
||||
@ -43,12 +43,18 @@ 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):
|
||||
|
||||
30
backend/app/schemas/category_batch.py
Normal file
30
backend/app/schemas/category_batch.py
Normal file
@ -0,0 +1,30 @@
|
||||
"""基础档案待确认分类的批量补充。"""
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
|
||||
class CategoryBatch(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
ids: list[int] = Field(min_length=1, max_length=200)
|
||||
|
||||
@field_validator("ids")
|
||||
@classmethod
|
||||
def validate_ids(cls, value):
|
||||
if any(item < 1 for item in value) or len(value) != len(set(value)):
|
||||
raise ValueError("档案 ID 必须为不重复的正整数")
|
||||
return value
|
||||
|
||||
|
||||
class CustomerCategoryBatch(CategoryBatch):
|
||||
category: Literal["personal", "manager_personal", "enterprise"]
|
||||
|
||||
|
||||
class ProductCategoryBatch(CategoryBatch):
|
||||
category: Literal["municipal_water", "rail_transit"]
|
||||
|
||||
|
||||
class CustomerOwnershipBatch(CategoryBatch):
|
||||
new_owner_personnel_id: int = Field(ge=1)
|
||||
reason: str | None = Field(default=None, max_length=255)
|
||||
@ -25,7 +25,7 @@ class CustomerOut(BaseModel):
|
||||
contact_phone: str | None = None
|
||||
address: str | None = None
|
||||
industry: str | None = None
|
||||
customer_type: str
|
||||
customer_type: str | None = None
|
||||
owner_personnel_id: int | None = None
|
||||
owner_personnel_name: str | None = None
|
||||
remark: str | None = None
|
||||
@ -104,7 +104,9 @@ class CustomerQuery(PageQuery):
|
||||
default=None, max_length=64, description="公司名称或客户负责人,模糊匹配"
|
||||
)
|
||||
customer_type: str | None = Field(default=None, max_length=32, description="客户分类筛选")
|
||||
category_missing: bool = Field(default=False, description="仅显示客户分类待确认")
|
||||
owner_personnel_id: int | None = Field(default=None, ge=1, description="归属人员ID筛选")
|
||||
owner_missing: bool = Field(default=False, description="仅显示归属待确认")
|
||||
order_by: Literal["id", "rating_score"] = Field(
|
||||
default="id", description="排序键:id 建档序 / rating_score 评级总分"
|
||||
)
|
||||
|
||||
@ -24,6 +24,14 @@ class ShipmentOwnershipIn(BaseModel):
|
||||
personnel_id: int = Field(ge=1)
|
||||
manager_id: int | None = Field(default=None, ge=1)
|
||||
unit_cost: str
|
||||
freight_amount: str | None = None
|
||||
|
||||
@field_validator("freight_amount")
|
||||
@classmethod
|
||||
def check_freight(cls, value):
|
||||
if value is not None:
|
||||
_decimal(value, 2)
|
||||
return value
|
||||
|
||||
@field_validator("unit_cost")
|
||||
@classmethod
|
||||
|
||||
@ -23,7 +23,7 @@ class ProductOut(BaseModel):
|
||||
unit: str | None = None
|
||||
stock_quantity: str | None = None
|
||||
sale_price: str | None = None
|
||||
product_category: str
|
||||
product_category: str | None = None
|
||||
status: int = 1
|
||||
remark: str | None = None
|
||||
create_time: datetime
|
||||
@ -96,6 +96,7 @@ class ProductQuery(PageQuery):
|
||||
|
||||
keyword: str | None = Field(default=None, max_length=64, description="名称或规格,模糊匹配")
|
||||
product_category: str | None = Field(default=None, max_length=32, description="按类别筛选")
|
||||
category_missing: bool = Field(default=False, description="仅显示产品分类待确认")
|
||||
status: int | None = Field(default=None, ge=0, le=1, description="状态筛选")
|
||||
|
||||
|
||||
|
||||
@ -8,7 +8,7 @@ payment_days 是派生值(发货日 → 回款日的天数,对照表 #1)
|
||||
后无需回写历史。
|
||||
"""
|
||||
|
||||
from datetime import date
|
||||
from datetime import date, datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
@ -86,6 +86,10 @@ class ReceiptOut(BaseModel):
|
||||
amount: str
|
||||
is_barter: int
|
||||
settlement_status: int
|
||||
confirmation_status: int = 0
|
||||
confirmed_by: str | None = None
|
||||
confirmed_at: datetime | None = None
|
||||
confirmation_revision: str = ""
|
||||
payment_days: int | None = None
|
||||
remark: str | None = None
|
||||
|
||||
@ -100,3 +104,9 @@ class ReceiptQuery(PageQuery):
|
||||
settlement_status: int | None = Field(
|
||||
default=None, ge=0, le=1, description="核算状态筛选:0未核算 1已核算"
|
||||
)
|
||||
confirmation_status: int | None = Field(default=None, ge=0, le=1, description="人工确认状态筛选")
|
||||
|
||||
|
||||
class ReceiptConfirmIn(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
expected_revision: str = Field(pattern=r"^[0-9a-f]{64}$", description="页面核对的回款关键内容指纹")
|
||||
|
||||
@ -114,6 +114,7 @@ class ReportSummary(BaseModel):
|
||||
ship_total: Decimal | None = Field(default=None, description="发货额合计")
|
||||
received_total: Decimal | None = Field(default=None, description="回款额合计")
|
||||
sale_total: Decimal | None = Field(default=None, description="销售额合计")
|
||||
sale_pending_count: int = Field(default=0, description="销售额待核对业务数")
|
||||
|
||||
|
||||
class ReportPage[T](BaseModel):
|
||||
|
||||
@ -69,7 +69,7 @@ class ShipmentOut(BaseModel):
|
||||
business_id: int
|
||||
business_no: str | None = None
|
||||
ship_date: date
|
||||
freight_amount: str
|
||||
freight_amount: str | None = None
|
||||
total_amount: str
|
||||
remark: str | None = None
|
||||
|
||||
|
||||
@ -129,6 +129,24 @@ class TPlusAutomationUpdate(BaseModel):
|
||||
expected_version: int = Field(ge=0, strict=True)
|
||||
|
||||
|
||||
class TPlusRuntimeUpdate(BaseModel):
|
||||
desired_state: Literal["RUNNING", "STOPPED"]
|
||||
expected_version: int = Field(ge=0, strict=True)
|
||||
|
||||
|
||||
class TPlusRuntimeOut(BaseModel):
|
||||
desired_state: str = "STOPPED"
|
||||
actual_state: str = "OFFLINE"
|
||||
worker_phase: str = "UNKNOWN"
|
||||
heartbeat_at: datetime | None = None
|
||||
last_error: str | None = None
|
||||
host: str | None = None
|
||||
platform: str | None = None
|
||||
version: int = 0
|
||||
updated_by: int | None = None
|
||||
editable: bool = False
|
||||
|
||||
|
||||
class TPlusAutomationOverview(BaseModel):
|
||||
enabled: bool = False
|
||||
version: int = 0
|
||||
|
||||
27
backend/app/schemas/tplus_business_sync.py
Normal file
27
backend/app/schemas/tplus_business_sync.py
Normal file
@ -0,0 +1,27 @@
|
||||
"""业务接入边界:独立于核算月份和内部平账组。"""
|
||||
|
||||
from datetime import date
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
|
||||
class SyncRange(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
start_date: date = date(2026, 3, 1)
|
||||
|
||||
@field_validator("start_date")
|
||||
@classmethod
|
||||
def check_start(cls, value):
|
||||
if value < date(2026, 3, 1):
|
||||
raise ValueError("本次接入从2026年3月起,更早欠款通过期初承接")
|
||||
return value
|
||||
|
||||
|
||||
class BusinessSyncUpdate(SyncRange):
|
||||
enabled: bool
|
||||
expected_version: int = Field(ge=0)
|
||||
|
||||
|
||||
class BusinessSyncRun(SyncRange):
|
||||
request_key: UUID
|
||||
@ -2,11 +2,13 @@
|
||||
|
||||
from datetime import date
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from itertools import pairwise
|
||||
from typing import Literal
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||
|
||||
from app.core.month import validate_month
|
||||
from app.core.response import PageQuery
|
||||
|
||||
|
||||
@ -16,11 +18,39 @@ class FactData(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
|
||||
|
||||
|
||||
class RolePeriod(FactData):
|
||||
"""客户只确认到月份时保留月份精度,区间含首尾月份。"""
|
||||
|
||||
start_month: str
|
||||
end_month: str
|
||||
role_code: Literal["salesperson", "sales_manager"]
|
||||
|
||||
@field_validator("start_month", "end_month")
|
||||
@classmethod
|
||||
def check_month(cls, value):
|
||||
return validate_month(value)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def check_range(self):
|
||||
if self.end_month < self.start_month:
|
||||
raise ValueError("岗位结束月份不能早于开始月份")
|
||||
return self
|
||||
|
||||
|
||||
class PersonFact(FactData):
|
||||
code: str = Field(min_length=1, max_length=64)
|
||||
name: str = Field(min_length=1, max_length=64)
|
||||
role_code: Literal["salesperson", "sales_manager"]
|
||||
status: Literal[0, 1]
|
||||
role_history: list[RolePeriod] = Field(default_factory=list, max_length=120)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def check_role_history(self):
|
||||
periods = sorted(self.role_history, key=lambda period: period.start_month)
|
||||
if any(left.end_month >= right.start_month for left, right in pairwise(periods)):
|
||||
raise ValueError("历史岗位月份不能重叠")
|
||||
self.role_history = periods
|
||||
return self
|
||||
|
||||
|
||||
class ProductFact(FactData):
|
||||
@ -30,7 +60,7 @@ class ProductFact(FactData):
|
||||
|
||||
class CustomerFact(FactData):
|
||||
customer_type: Literal["personal", "manager_personal", "enterprise"]
|
||||
owner_name: str = Field(min_length=1, max_length=64)
|
||||
owner_name: str = Field(default="", max_length=64)
|
||||
|
||||
|
||||
class GroupFact(FactData):
|
||||
@ -143,6 +173,10 @@ class CorrectionFact(FactData):
|
||||
pair_key: str = Field(min_length=1, max_length=128)
|
||||
exclude_performance: bool = False
|
||||
redirect_business_key: str | None = Field(default=None, max_length=128)
|
||||
paired_source_id: str | None = Field(default=None, pattern=r"^[0-9]+$", max_length=64)
|
||||
source_hash: str | None = Field(default=None, min_length=64, max_length=64)
|
||||
paired_hash: str | None = Field(default=None, min_length=64, max_length=64)
|
||||
details_hash: str | None = Field(default=None, min_length=64, max_length=64)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def check_correction(self):
|
||||
@ -155,6 +189,26 @@ class CorrectionFact(FactData):
|
||||
return self
|
||||
|
||||
|
||||
class CorrectionPairIn(FactData):
|
||||
"""一次登记完整更正对及实际回款,不能单边排除。"""
|
||||
|
||||
negative_shipment_id: str = Field(pattern=r"^[0-9]+$", max_length=64)
|
||||
positive_shipment_id: str = Field(pattern=r"^[0-9]+$", max_length=64)
|
||||
receipt_ids: list[str] = Field(default_factory=list, max_length=100)
|
||||
target_group_key: str = Field(min_length=1, max_length=128, pattern=r"^[A-Za-z0-9_.:-]+$")
|
||||
evidence: str = Field(min_length=1, max_length=500)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def check_members(self):
|
||||
if self.negative_shipment_id == self.positive_shipment_id:
|
||||
raise ValueError("正负更正单不能为同一张单据")
|
||||
if len(set(self.receipt_ids)) != len(self.receipt_ids) or any(
|
||||
not v.isascii() or not v.isdigit() for v in self.receipt_ids
|
||||
):
|
||||
raise ValueError("实际回款必须使用不重复的源数字标识")
|
||||
return self
|
||||
|
||||
|
||||
FACT_TYPES = {
|
||||
"PERSONNEL": PersonFact,
|
||||
"PRODUCT": ProductFact,
|
||||
|
||||
@ -24,6 +24,10 @@ logger = logging.getLogger(__name__)
|
||||
# 攻击者据此无法判断某个用户名是否真实存在。
|
||||
INVALID_CREDENTIALS = "用户名或密码错误"
|
||||
|
||||
# 与真实库哈希同算法、同轮次的假哈希。用户不存在时也执行一次校验,
|
||||
# 让两条失败路径耗时接近,避免响应时间暴露有效用户名。
|
||||
_DUMMY_PASSWORD_HASH = "$2b$12$T7f7tDu4ljCgkXigiJ0EHea3ik07F0Dqpv2B1rlUF1FljlylH68Um"
|
||||
|
||||
|
||||
def _record(
|
||||
session: Session,
|
||||
@ -103,7 +107,10 @@ def login(
|
||||
|
||||
# ---- 3. 凭据校验 ----
|
||||
user = user_repository.get_by_username(session, username)
|
||||
if user is None or not verify_password(password, user.password):
|
||||
credentials_ok = verify_password(
|
||||
password, user.password if user is not None else _DUMMY_PASSWORD_HASH
|
||||
)
|
||||
if user is None or not credentials_ok:
|
||||
login_limiter.record_failure(username)
|
||||
_record(
|
||||
session,
|
||||
|
||||
@ -135,6 +135,16 @@ def validate_role_permissions(permission_codes):
|
||||
return codes
|
||||
|
||||
|
||||
def active_permission_ids(session, codes):
|
||||
"""核验目录对应的启用记录齐全,避免保存不可生效的授权。"""
|
||||
ids = dict(session.execute(select(Permission.perm_code, Permission.id).where(
|
||||
Permission.perm_code.in_(codes), Permission.status == 1
|
||||
)).all())
|
||||
if set(ids) != codes:
|
||||
raise BadRequestError("权限目录尚未就绪,请联系管理员")
|
||||
return ids
|
||||
|
||||
|
||||
def save_role(session, role_id, payload, actor, meta=None):
|
||||
audit_data = payload.model_dump(mode="json")
|
||||
with audit_service.audit(session, operator=actor, meta=meta, record=AuditRecord(
|
||||
@ -147,10 +157,10 @@ def save_role(session, role_id, payload, actor, meta=None):
|
||||
if role.role_code == "admin":
|
||||
raise ForbiddenError("管理员角色权限固定,不能修改")
|
||||
codes = validate_role_permissions(payload.permission_codes)
|
||||
ids = active_permission_ids(session, codes)
|
||||
audit_data["before"] = role_permissions(session, role.id)["permission_codes"]
|
||||
audit_data["after"] = sorted(codes)
|
||||
session.execute(delete(RolePermission).where(RolePermission.role_id == role.id))
|
||||
ids = dict(session.execute(select(Permission.perm_code, Permission.id)).all())
|
||||
session.add_all([RolePermission(role_id=role.id, permission_id=ids[c]) for c in codes])
|
||||
bump(policy, actor)
|
||||
session.flush()
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
"""业务表单参照只返回必要字段,不借下拉权限开放整个资料接口。"""
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from app.core.exceptions import BadRequestError, ForbiddenError
|
||||
from app.models import (
|
||||
@ -49,28 +49,75 @@ OPTIONS = {
|
||||
}
|
||||
|
||||
|
||||
def payroll_options(session, actor, business_id):
|
||||
def payroll_options(session, actor, business_id, *, page_num=1, page_size=200):
|
||||
"""核算资料所需的人员及发货明细,不开放人员薪酬或联系方式。"""
|
||||
from app.core.exceptions import NotFoundError
|
||||
from app.models import ShipmentItem
|
||||
from app.models import ShipmentItem, TPlusBusinessDocument, TPlusSourceLink, TPlusSyncRecord
|
||||
from app.services.authorization_service import require_permission
|
||||
from app.services.tplus.guidance import latest_guidance_facts, resolve_guidance
|
||||
|
||||
if page_num < 1 or not 1 <= page_size <= 200:
|
||||
raise ValueError("无效的核算参照分页")
|
||||
require_permission(session, actor, "payroll:view")
|
||||
if session.get(Business, business_id) is None:
|
||||
business = session.get(Business, business_id)
|
||||
if business is None:
|
||||
raise NotFoundError("业务不存在")
|
||||
heads = list(session.scalars(select(Shipment).where(Shipment.business_id == business_id).order_by(Shipment.id).limit(201)))
|
||||
if len(heads) > 200:
|
||||
raise BadRequestError("单笔业务超过 200 张发货单,请分批核对资料")
|
||||
people = [dict(row) for row in session.execute(select(Personnel.id, Personnel.personnel_name).order_by(Personnel.id).limit(201)).mappings()]
|
||||
if len(people) > 200:
|
||||
raise BadRequestError("核算人员超过单次范围,请按实际归属分批处理")
|
||||
lines = session.execute(select(ShipmentItem.id, ShipmentItem.shipment_id, ShipmentItem.product_name, ShipmentItem.amount).where(ShipmentItem.shipment_id.in_([h.id for h in heads]))).mappings()
|
||||
by_head = {head.id: head for head in heads}
|
||||
return {"personnel": people, "shipments": [
|
||||
{"id": row["id"], "shipment_no": by_head[row["shipment_id"]].shipment_no,
|
||||
"ship_date": str(by_head[row["shipment_id"]].ship_date), "product_name": row["product_name"], "amount": str(row["amount"])}
|
||||
for row in lines
|
||||
]}
|
||||
source_customer = (business.integration_json or {}).get("customer_source_id")
|
||||
offset = (page_num - 1) * page_size
|
||||
people = session.execute(select(Personnel.id, Personnel.personnel_name).order_by(Personnel.id)
|
||||
.offset(offset).limit(page_size)).mappings()
|
||||
lines_query = select(ShipmentItem.id, Shipment.shipment_no, Shipment.ship_date,
|
||||
ShipmentItem.product_name, ShipmentItem.amount, ShipmentItem.unit).join(
|
||||
Shipment, Shipment.id == ShipmentItem.shipment_id).where(Shipment.business_id == business_id)
|
||||
lines = list(session.execute(lines_query.order_by(Shipment.id, ShipmentItem.id).offset(offset).limit(page_size)).mappings())
|
||||
source = (business.integration_json or {}).get("source_database")
|
||||
candidates = {}
|
||||
if source and lines:
|
||||
documents = session.scalars(select(TPlusBusinessDocument).where(
|
||||
TPlusBusinessDocument.business_id == business_id,
|
||||
TPlusBusinessDocument.source_database == source,
|
||||
TPlusBusinessDocument.entity_type == "shipment_items",
|
||||
TPlusBusinessDocument.status == "APPLIED",
|
||||
TPlusBusinessDocument.target_id.in_([row["id"] for row in lines]),
|
||||
))
|
||||
for document in documents:
|
||||
candidates.setdefault(document.target_id, []).append(document)
|
||||
person_sources = {str(data["source_personnel_id"]) for values in candidates.values() for doc in values
|
||||
if (data := doc.data_json or {}).get("source_personnel_id") is not None}
|
||||
links = dict(session.execute(select(TPlusSourceLink.source_id, Personnel.id).join(
|
||||
Personnel, Personnel.id == TPlusSourceLink.target_id).where(
|
||||
TPlusSourceLink.source_database == source, TPlusSourceLink.entity_type == "personnel",
|
||||
TPlusSourceLink.source_id.in_(person_sources))).all()) if person_sources else {}
|
||||
record_ids = {doc.record_id for values in candidates.values() for doc in values}
|
||||
records = {r.id: r for r in session.scalars(select(TPlusSyncRecord).where(
|
||||
TPlusSyncRecord.id.in_(record_ids)))} if record_ids else {}
|
||||
guidance_facts = latest_guidance_facts(session, source) if record_ids else {}
|
||||
|
||||
def source_details(row):
|
||||
matches = candidates.get(row["id"], [])
|
||||
document = matches[0] if len(matches) == 1 else None
|
||||
data = (document.data_json or {}) if document else {}
|
||||
source_id = data.get("source_personnel_id")
|
||||
record = records.get(document.record_id) if document else None
|
||||
product_id = record.payload_json.get("idinventory") if record else None
|
||||
status, guidance = resolve_guidance(guidance_facts, str(product_id), document.source_id,
|
||||
row["ship_date"].strftime("%Y-%m"), row["unit"]) if product_id is not None else ("MISSING", None)
|
||||
if guidance:
|
||||
status = "FINALIZED_" + guidance["basis"]
|
||||
return {"source_personnel_name": data.get("source_personnel_name"),
|
||||
"matched_personnel_id": links.get(str(source_id)) if source_id is not None else None,
|
||||
"unit": row["unit"],
|
||||
"guidance_status": status,
|
||||
"guidance_unit_cost": guidance["amount"] if status == "FINALIZED_UNIT" else None,
|
||||
"guidance_line_total": guidance["amount"] if status == "FINALIZED_LINE_TOTAL" else None}
|
||||
return {"business_key": f"customer:{source_customer}" if source_customer is not None else None,
|
||||
"personnel": [dict(row) for row in people],
|
||||
"personnel_total": session.scalar(select(func.count()).select_from(Personnel)),
|
||||
"shipments_total": session.scalar(select(func.count()).select_from(ShipmentItem).join(
|
||||
Shipment, Shipment.id == ShipmentItem.shipment_id).where(Shipment.business_id == business_id)),
|
||||
"shipments": [{"id": row["id"], "shipment_no": row["shipment_no"],
|
||||
"ship_date": str(row["ship_date"]), "product_name": row["product_name"],
|
||||
"amount": str(row["amount"]), **source_details(row)} for row in lines]}
|
||||
|
||||
|
||||
def options(session, actor, resource, *, business_id=None, search=None):
|
||||
|
||||
@ -39,8 +39,9 @@ def permission_for(module, name, method, *, params=None, body=None):
|
||||
if method == "GET":
|
||||
return resource + ":view"
|
||||
special = {"set_status": "status", "change_status": "status", "reset_password": "password",
|
||||
"confirm_receipt": "confirm",
|
||||
"replace_regions": "regions", "replace_plans": "plans", "rate_customer": "rating",
|
||||
"change_ownership": "ownership", "upload_logo": "update"}
|
||||
"change_ownership": "ownership", "batch_ownership": "ownership", "upload_logo": "update"}
|
||||
action = special.get(name) or {"POST": "create", "PUT": "update", "DELETE": "delete"}.get(method)
|
||||
return resource + ":" + action if action else None
|
||||
if module == "system.params":
|
||||
@ -57,11 +58,15 @@ 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 == "save_fact":
|
||||
if name in ("save_fact", "save_correction_pair"):
|
||||
return "tplus:facts"
|
||||
if name == "create_work":
|
||||
return "tplus:import" if body.get("kind") in ("MASTER_APPLY", "BUSINESS_APPLY") else "tplus:preview"
|
||||
|
||||
@ -106,12 +106,16 @@ def _to_out(row: tuple) -> BusinessOut:
|
||||
assistant_personnel_name=assistant_name,
|
||||
is_special=business.is_special,
|
||||
special_note=business.special_note,
|
||||
total_sale_amount=_str_or_zero(business.total_sale_amount),
|
||||
total_cost_amount=_str_or_zero(business.total_cost_amount),
|
||||
total_sale_amount=None if business.integration_json and business.total_sale_amount is None else _str_or_zero(business.total_sale_amount),
|
||||
total_cost_amount=None if business.integration_json and business.total_cost_amount is None else _str_or_zero(business.total_cost_amount),
|
||||
total_ship_amount=_str_or_zero(business.total_ship_amount),
|
||||
total_received_amount=_str_or_zero(business.total_received_amount),
|
||||
total_freight_amount=_str_or_zero(business.total_freight_amount),
|
||||
total_freight_amount=None if business.integration_json and business.total_freight_amount is None else _str_or_zero(business.total_freight_amount),
|
||||
remark=business.remark,
|
||||
source_type="TPLUS" if business.t_plus_id else "MANUAL",
|
||||
source_no=business.source_no,
|
||||
source_personnel=(business.integration_json or {}).get("personnel_names", []),
|
||||
integration_status="WAITING_MATERIALS" if business.integration_json else None,
|
||||
)
|
||||
|
||||
|
||||
@ -179,6 +183,12 @@ def _recalc_totals(session: Session, business: Business) -> None:
|
||||
五条独立聚合查询(各表单列 SUM,走 business_id 索引)——业务单据量级
|
||||
为单业务数行,不值得为省查询做单大 JOIN。
|
||||
"""
|
||||
if business.integration_json:
|
||||
from app.services.tplus.business_sync import _refresh_business_totals
|
||||
|
||||
session.flush()
|
||||
_refresh_business_totals(session, business)
|
||||
return
|
||||
sale = session.execute(
|
||||
_sum(BusinessItem.sale_amount, BusinessItem.business_id, business.id)
|
||||
).scalar_one()
|
||||
@ -236,10 +246,52 @@ def _is_duplicate_entry(exc: IntegrityError) -> bool:
|
||||
|
||||
def list_businesses(session: Session, query: BusinessQuery) -> PageResult[BusinessOut]:
|
||||
"""分页查询业务(附客户名与归属人名)。"""
|
||||
rows = business_repository.list_businesses(session, query)
|
||||
total = business_repository.count_businesses(session, query)
|
||||
from app.services.business_source import readiness_summaries
|
||||
|
||||
summaries = {}
|
||||
sql_reason = query.readiness_reason in business_repository.READINESS_SQL_REASONS
|
||||
if sql_reason and query.readiness_status == "READY":
|
||||
rows, total = [], 0
|
||||
elif sql_reason:
|
||||
rows = business_repository.list_businesses(session, query)
|
||||
total = business_repository.count_businesses(session, query)
|
||||
summaries = readiness_summaries(session, [row[0] for row in rows])
|
||||
elif query.pending_only or query.readiness_status or query.readiness_reason:
|
||||
rows, total, before_id = [], 0, None
|
||||
snapshots = {}
|
||||
while batch := business_repository.scan_businesses(session, query, before_id):
|
||||
states = readiness_summaries(session, [row[0] for row in batch], snapshot_cache=snapshots)
|
||||
for row in batch:
|
||||
state = states.get(row[0].id)
|
||||
if not state:
|
||||
continue
|
||||
if query.pending_only and state["status"] != "PENDING":
|
||||
continue
|
||||
if query.readiness_status and state["status"] != query.readiness_status:
|
||||
continue
|
||||
if query.readiness_reason and query.readiness_reason not in {r["code"] for r in state["reasons"]}:
|
||||
continue
|
||||
if query.offset <= total < query.offset + query.page_size:
|
||||
rows.append(row)
|
||||
summaries[row[0].id] = state
|
||||
total += 1
|
||||
before_id = batch[-1][0].id
|
||||
else:
|
||||
rows = business_repository.list_businesses(session, query)
|
||||
total = business_repository.count_businesses(session, query)
|
||||
summaries = readiness_summaries(session, [row[0] for row in rows])
|
||||
records = []
|
||||
for row in rows:
|
||||
out = _to_out(row)
|
||||
state = summaries.get(out.id)
|
||||
if state:
|
||||
out.readiness_status = state["status"]
|
||||
out.readiness_reasons = state["reasons"]
|
||||
if any(reason["code"] == "SALES_RELATION_PENDING" for reason in state["reasons"]):
|
||||
out.total_sale_amount = out.total_cost_amount = None
|
||||
records.append(out)
|
||||
return PageResult[BusinessOut](
|
||||
records=[_to_out(row) for row in rows],
|
||||
records=records,
|
||||
total=total,
|
||||
page_num=query.page_num,
|
||||
page_size=query.page_size,
|
||||
@ -255,6 +307,11 @@ def get_detail(session: Session, business_id: int) -> BusinessDetailOut:
|
||||
if row is None:
|
||||
raise NotFoundError("业务不存在")
|
||||
out = BusinessDetailOut(**_to_out(row).model_dump())
|
||||
if row[0].integration_json:
|
||||
from app.services.business_source import sales_relation_pending
|
||||
|
||||
if sales_relation_pending(session, business_id):
|
||||
out.total_sale_amount = out.total_cost_amount = None
|
||||
out.items = [
|
||||
_item_to_out(item) for item in business_repository.list_items(session, business_id)
|
||||
]
|
||||
@ -500,6 +557,9 @@ def update_business(
|
||||
raise NotFoundError("业务不存在")
|
||||
business, _, owner_name, assistant_name = row
|
||||
|
||||
if business.integration_json:
|
||||
raise BadRequestError("T+ 来源业务请通过差异核对处理,不能直接替换明细")
|
||||
|
||||
# 终态门槛与发货/回款写准入同口径:明细与汇总是 M3 核算基线,
|
||||
# 已完成/已终止的业务禁改(发货/回款服务以同一理由禁写终态业务)
|
||||
if business.status != BUSINESS_STATUS_ACTIVE:
|
||||
@ -631,6 +691,8 @@ def delete_business(
|
||||
):
|
||||
if business is None:
|
||||
raise NotFoundError("业务不存在")
|
||||
if business.integration_json:
|
||||
raise BadRequestError("T+ 来源业务不能直接删除,请通过差异核对处理")
|
||||
if business_repository.has_docs(session, business_id):
|
||||
raise BadRequestError("业务已有发货或回款记录,请改为已终止而非删除")
|
||||
# 在途审批单(ref_id 固化无外键,业务删了单还挂着且可被继续审批):
|
||||
@ -993,7 +1055,9 @@ def _gross_profit_row(
|
||||
|
||||
|
||||
def _scan_gross_profit_rows(
|
||||
session: Session, business_id: int, threshold_of: Callable[[str], Fraction]
|
||||
session: Session, business_id: int, threshold_of: Callable[[str], Fraction], *,
|
||||
sales_relation_pending: bool = False, items: list[BusinessItem] | None = None,
|
||||
included_item_ids: set[int] | None = None,
|
||||
) -> _GrossProfitScan:
|
||||
"""逐行扫描业务明细:出行 DTO、类别聚合桶与成本缺失计数。
|
||||
|
||||
@ -1002,7 +1066,24 @@ def _scan_gross_profit_rows(
|
||||
rows: list[GrossProfitRowOut] = []
|
||||
agg: dict[str, list[Decimal]] = {}
|
||||
missing_count = 0
|
||||
for item in business_repository.list_items(session, business_id):
|
||||
for item in items if items is not None else business_repository.list_items(session, business_id):
|
||||
if sales_relation_pending:
|
||||
rows.append(GrossProfitRowOut(item_id=item.id, product_category=item.product_category,
|
||||
product_name=item.product_name, spec_model=item.spec_model, quantity=str(item.quantity),
|
||||
sale_amount=str(item.sale_amount), missing_reason="订单与发货关联待核对,无法汇总毛利润"))
|
||||
missing_count += 1
|
||||
continue
|
||||
if included_item_ids is not None and item.id not in included_item_ids:
|
||||
rows.append(GrossProfitRowOut(item_id=item.id, product_category=item.product_category,
|
||||
product_name=item.product_name, spec_model=item.spec_model, quantity=str(item.quantity),
|
||||
sale_amount=str(item.sale_amount), missing_reason="已计入订单,不重复汇总"))
|
||||
continue
|
||||
if item.product_category is None:
|
||||
rows.append(GrossProfitRowOut(item_id=item.id, product_category=None, product_name=item.product_name,
|
||||
spec_model=item.spec_model, quantity=str(item.quantity), sale_amount=str(item.sale_amount),
|
||||
missing_reason="产品核算分类待确认"))
|
||||
missing_count += 1
|
||||
continue
|
||||
row, bucket = _gross_profit_row(item, threshold_of(item.product_category))
|
||||
rows.append(row)
|
||||
if bucket is None:
|
||||
@ -1041,7 +1122,8 @@ def gross_profit(session: Session, business_id: int, as_of: date | None) -> Gros
|
||||
|
||||
:raises NotFoundError: 业务不存在
|
||||
"""
|
||||
if business_repository.get_by_id(session, business_id) is None:
|
||||
business = business_repository.get_by_id(session, business_id)
|
||||
if business is None:
|
||||
raise NotFoundError("业务不存在")
|
||||
as_of = as_of or date.today()
|
||||
snapshot = param_service.resolve_snapshot(session, as_of)
|
||||
@ -1049,7 +1131,20 @@ def gross_profit(session: Session, business_id: int, as_of: date | None) -> Gros
|
||||
def threshold_of(category: str) -> Fraction:
|
||||
return snapshot.fraction("product.threshold", product_category=category)
|
||||
|
||||
scan = _scan_gross_profit_rows(session, business_id, threshold_of)
|
||||
items = business_repository.list_items(session, business_id)
|
||||
pending, included_ids = False, None
|
||||
if business.integration_json and business.integration_json.get("source_database"):
|
||||
from app.models import TPlusBusinessDocument
|
||||
from app.services.tplus.sales_relations import resolve_items
|
||||
|
||||
order_ids = list(session.scalars(select(TPlusBusinessDocument.target_id).where(
|
||||
TPlusBusinessDocument.business_id == business_id,
|
||||
TPlusBusinessDocument.entity_type == "business_items",
|
||||
TPlusBusinessDocument.status == "APPLIED")))
|
||||
pending, included = resolve_items(session, business, items, order_ids)
|
||||
included_ids = {item.id for item in included}
|
||||
scan = _scan_gross_profit_rows(session, business_id, threshold_of, sales_relation_pending=pending,
|
||||
items=items, included_item_ids=included_ids)
|
||||
categories = [
|
||||
_gross_profit_category(category, sale, cost, threshold_of(category))
|
||||
for category, [sale, cost] in scan.category_agg.items()
|
||||
|
||||
994
backend/app/services/business_source.py
Normal file
994
backend/app/services/business_source.py
Normal file
@ -0,0 +1,994 @@
|
||||
"""业务来源、待补资料和来源业务核算准备;不重新录入源单据。"""
|
||||
|
||||
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
|
||||
@ -45,7 +45,6 @@ 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,
|
||||
@ -188,6 +187,9 @@ def _load_and_guard(
|
||||
raise BadRequestError("回款关联的业务不存在")
|
||||
if business.owner_personnel_id is None:
|
||||
raise BadRequestError("业务未指定负责营销人员,无法核算(先在业务归属中指定)")
|
||||
from app.services.receipt_confirmation import ensure_confirmed
|
||||
|
||||
ensure_confirmed(session, [receipt.id])
|
||||
return receipt, business
|
||||
|
||||
|
||||
@ -363,12 +365,10 @@ def _compose_amounts(
|
||||
performance = (
|
||||
Decimal("0") if decision.manager_pending else performance_amount(converted, decision.rate)
|
||||
)
|
||||
barter_applied = receipt.is_barter == 1
|
||||
barter_applied = False
|
||||
# 以物抵顶口径(2026-11-03 决策):抵顶回款线下核算绩效,系统不做自动折算。
|
||||
# 原回款的 is_barter 标记保留;barter_applied=False 表示没有执行折算。
|
||||
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))
|
||||
|
||||
@ -75,7 +75,14 @@ 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)}
|
||||
{
|
||||
"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),
|
||||
}
|
||||
for spec in FORMULAS.values()
|
||||
]
|
||||
|
||||
|
||||
@ -12,11 +12,17 @@ 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
|
||||
|
||||
|
||||
@ -26,30 +32,40 @@ 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="预付回款额 × 固定比例",
|
||||
),
|
||||
}
|
||||
|
||||
33
backend/app/services/category_batch.py
Normal file
33
backend/app/services/category_batch.py
Normal file
@ -0,0 +1,33 @@
|
||||
"""批量补充空分类,不覆盖已有分类或冻结资料。"""
|
||||
|
||||
from sqlalchemy import select, update
|
||||
|
||||
from app.core.exceptions import BadRequestError
|
||||
from app.models import BusinessItem, Customer, PayrollInput, Product
|
||||
from app.services.audit_service import AuditRecord, audit
|
||||
|
||||
|
||||
def fill_missing_product_items(session, product_ids, category):
|
||||
"""补齐未封存业务的空分类,保留既有分类及历史核算依据。"""
|
||||
frozen = select(PayrollInput.id).where(PayrollInput.business_id == BusinessItem.business_id).exists()
|
||||
session.execute(update(BusinessItem).where(
|
||||
BusinessItem.product_id.in_(product_ids), BusinessItem.product_category.is_(None), ~frozen,
|
||||
).values(product_category=category))
|
||||
|
||||
|
||||
def fill_missing(session, model, payload, actor, meta=None):
|
||||
field = Customer.customer_type if model is Customer else Product.product_category
|
||||
module = "客户管理" if model is Customer else "产品管理"
|
||||
with audit(session, operator=actor, meta=meta, record=AuditRecord(
|
||||
module=module, action="批量补充分类", summary=f"批量补充 {len(payload.ids)} 条档案分类",
|
||||
request_data=payload.model_dump(mode="json"),
|
||||
)):
|
||||
rows = list(session.scalars(select(model).where(model.id.in_(payload.ids)).order_by(model.id).with_for_update()))
|
||||
if len(rows) != len(payload.ids) or any(getattr(row, field.key) is not None for row in rows):
|
||||
raise BadRequestError("所选档案不存在或分类已变化,请刷新后重新选择")
|
||||
for row in rows:
|
||||
setattr(row, field.key, payload.category)
|
||||
if model is Product:
|
||||
fill_missing_product_items(session, payload.ids, payload.category)
|
||||
session.commit()
|
||||
return {"updated": len(rows)}
|
||||
@ -17,7 +17,7 @@ import logging
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Row
|
||||
from sqlalchemy import Row, select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@ -422,6 +422,25 @@ def _customer_ownership_log(
|
||||
)
|
||||
|
||||
|
||||
def batch_change_ownership(session, payload, *, operator, meta=None):
|
||||
"""只为未归属客户补充同一负责人;主档和逐条留痕同事务提交。"""
|
||||
with audit_service.audit(session, operator=operator, meta=meta, record=AuditRecord(
|
||||
module=MODULE_CUSTOMER, action=ACTION_UPDATE,
|
||||
summary=f"批量补充 {len(payload.ids)} 条客户归属", request_data=payload.model_dump(mode="json"),
|
||||
)):
|
||||
_ensure_owner_personnel(session, payload.new_owner_personnel_id)
|
||||
customers = list(session.scalars(select(Customer).where(Customer.id.in_(payload.ids))
|
||||
.order_by(Customer.id).with_for_update()))
|
||||
if len(customers) != len(payload.ids) or any(customer.owner_personnel_id is not None for customer in customers):
|
||||
raise BadRequestError("所选客户不存在或归属已变化,请刷新后重新选择")
|
||||
change = CustomerOwnershipIn(new_owner_personnel_id=payload.new_owner_personnel_id, reason=payload.reason)
|
||||
for customer in customers:
|
||||
customer.owner_personnel_id = payload.new_owner_personnel_id
|
||||
customer_repository.add_ownership_log(session, _customer_ownership_log(customer, change, None, operator))
|
||||
session.commit()
|
||||
return {"updated": len(customers)}
|
||||
|
||||
|
||||
def change_ownership(
|
||||
session: Session,
|
||||
customer_id: int,
|
||||
@ -457,7 +476,10 @@ def change_ownership(
|
||||
):
|
||||
if prefetch.row is None:
|
||||
raise NotFoundError("客户不存在")
|
||||
customer, _ = prefetch.row
|
||||
customer = session.scalar(select(Customer).where(Customer.id == customer_id)
|
||||
.with_for_update().execution_options(populate_existing=True))
|
||||
if customer is None:
|
||||
raise NotFoundError("客户不存在")
|
||||
|
||||
old_owner_id = _validate_customer_ownership(session, customer, payload)
|
||||
|
||||
|
||||
@ -73,8 +73,11 @@ 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))
|
||||
|
||||
|
||||
@ -84,7 +87,11 @@ 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)
|
||||
|
||||
|
||||
|
||||
@ -43,10 +43,13 @@ def record_failure(username: str) -> int:
|
||||
正好给撞库留出缝隙。
|
||||
"""
|
||||
key = _key(username)
|
||||
redis = get_redis()
|
||||
count = int(redis.incr(key))
|
||||
redis.expire(key, get_settings().login_lock_minutes * 60)
|
||||
return count
|
||||
# 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)
|
||||
|
||||
|
||||
def clear(username: str) -> None:
|
||||
|
||||
@ -190,6 +190,20 @@ PARAM_REGISTRY: dict[str, ParamSpec] = {
|
||||
description="累计回款达到不含运费销售额的设定比例后,开始计付绩效。启用前应收先冲抵,再核算新业务。",
|
||||
sort_order=11,
|
||||
),
|
||||
ParamSpec(
|
||||
code="receipt.confirmation.enabled",
|
||||
name="回款人工确认开关",
|
||||
group="回款确认",
|
||||
value_type="BOOL",
|
||||
unit=None,
|
||||
dimension_type="NONE",
|
||||
options=None,
|
||||
default_value="1",
|
||||
value_min=None,
|
||||
value_max=None,
|
||||
description="默认开启:未人工确认的回款不得正式核算。关闭仅跳过人工确认,仍检查凭证等资格;与工资审核开关独立。",
|
||||
sort_order=16,
|
||||
),
|
||||
ParamSpec(
|
||||
code="payment.weight.mode",
|
||||
name="支付权重应用方式",
|
||||
|
||||
@ -622,14 +622,14 @@ def _preview_earning(ctx: _PreviewContext, decision: _PreviewRateDecision) -> _P
|
||||
|
||||
|
||||
def _preview_barter(ctx: _PreviewContext, performance: Decimal) -> _PreviewBarter:
|
||||
"""以物抵顶折算(按应发绩效的固定比例折算,比例来自参数)。"""
|
||||
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)
|
||||
"""以物抵顶标记(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)
|
||||
|
||||
|
||||
def _preview_payment(
|
||||
|
||||
@ -24,12 +24,13 @@ from app.models import (
|
||||
from app.services import audit_service, formula, payroll_months
|
||||
from app.services.audit_service import AuditRecord
|
||||
from app.services.param_service import resolve_snapshot
|
||||
from app.services.personnel_roles import input_role
|
||||
from app.services.tplus.calculation_preview import _parameters
|
||||
from app.services.tplus.fifo import START, _beneficiary, amount_text, money, preview_group
|
||||
from app.services.tplus.workbench import digest
|
||||
|
||||
# 核算规则版本:改公式或口径时同步更新,写入台账快照用于追溯。
|
||||
RULE_VERSION = "2026-10-30.2"
|
||||
RULE_VERSION = "2026-11-04.1"
|
||||
|
||||
|
||||
def latest_input(session, business_id, *, for_update=False):
|
||||
@ -78,6 +79,8 @@ def _previous(session, business_id, month):
|
||||
|
||||
|
||||
def _inputs(session, record, until):
|
||||
from app.services.receipt_confirmation import ensure_confirmed
|
||||
|
||||
values = copy.deepcopy(record.data_json)
|
||||
ships, cash = values["shipments"], values["receipts"]
|
||||
for row in cash:
|
||||
@ -91,6 +94,10 @@ def _inputs(session, record, until):
|
||||
or current.business_id != record.business_id
|
||||
):
|
||||
raise BadRequestError("正式回款与核算资料不一致,请重新核对资料版本")
|
||||
ensure_confirmed(session, [
|
||||
values["maps"]["receipts"].get(str(row["source_id"]))
|
||||
for row in cash if not row.get("exclusion") and row["date"] <= until.isoformat()
|
||||
])
|
||||
for row in ships:
|
||||
item_id = values["maps"].get("shipment_items", {}).get(str(row["line_id"]))
|
||||
if item_id is not None:
|
||||
@ -99,7 +106,7 @@ def _inputs(session, record, until):
|
||||
if (
|
||||
item is None
|
||||
or head is None
|
||||
or item.amount != money(row["amount"])
|
||||
or item.amount != money(row.get("gross_tax_amount", row["amount"]) if values.get("input_schema") == "TPLUS_SYNC_V1" else row["amount"])
|
||||
or head.ship_date.isoformat() != row["date"]
|
||||
or head.business_id != record.business_id
|
||||
):
|
||||
@ -157,7 +164,7 @@ def _post(
|
||||
business_no=business.business_no,
|
||||
personnel_id=owner,
|
||||
personnel_name=person.personnel_name,
|
||||
role_code=role.role_code,
|
||||
role_code=input_role(record.data_json, owner, event["effective_date"]) or role.role_code,
|
||||
customer_type=event["customer_type"],
|
||||
product_category=event["category"],
|
||||
ship_date=date.fromisoformat(event["shipment_date"]),
|
||||
@ -213,6 +220,16 @@ def _post(
|
||||
|
||||
|
||||
def calculate(session, business_id, month, *, operator, meta=None, reason=None):
|
||||
business = session.get(Business, business_id)
|
||||
if business is not None and business.integration_json:
|
||||
from app.services.business_source import source_calculation_lock
|
||||
|
||||
with source_calculation_lock(session):
|
||||
return _calculate_locked(session, business_id, month, operator=operator, meta=meta, reason=reason)
|
||||
return _calculate_locked(session, business_id, month, operator=operator, meta=meta, reason=reason)
|
||||
|
||||
|
||||
def _calculate_locked(session, business_id, month, *, operator, meta=None, reason=None):
|
||||
"""重算从指定月起级联重建尚未生成工资的月份,同一事务保存全部版本。"""
|
||||
from app.services.manager_share import month_end
|
||||
|
||||
@ -249,6 +266,16 @@ def calculate(session, business_id, month, *, operator, meta=None, reason=None):
|
||||
record = latest_input(session, business_id, for_update=True)
|
||||
if record is None:
|
||||
raise BadRequestError("请先补录该业务的正式核算资料")
|
||||
if record.data_json.get("input_schema") == "TPLUS_SYNC_V1":
|
||||
from app.services.business_source import LABELS, readiness
|
||||
|
||||
state = readiness(session, business_id)
|
||||
blocking = {r["code"] for r in state["reasons"]} & {
|
||||
"SOURCE_CONFLICT", "SOURCE_SNAPSHOT_PENDING", "INPUT_REFRESH_REQUIRED", "CUSTOMER_CATEGORY_PENDING",
|
||||
"PRODUCT_CATEGORY_PENDING", "NO_ACTUAL_SHIPMENT", "SALES_RELATION_PENDING",
|
||||
}
|
||||
if blocking:
|
||||
raise BadRequestError("核算资料待补:" + ";".join(LABELS[code] for code in sorted(blocking)))
|
||||
if reason:
|
||||
old_rows = list(
|
||||
session.scalars(
|
||||
|
||||
@ -37,6 +37,11 @@ def detail(session, business_id):
|
||||
|
||||
|
||||
def setup(session, business_id, payload, operator, meta=None):
|
||||
source_business = session.get(Business, business_id)
|
||||
if source_business and source_business.integration_json:
|
||||
from app.services.business_source import prepare
|
||||
|
||||
return prepare(session, business_id, payload, operator, meta)
|
||||
with audit_service.audit(
|
||||
session,
|
||||
operator=operator,
|
||||
@ -121,15 +126,21 @@ def setup(session, business_id, payload, operator, meta=None):
|
||||
}
|
||||
)
|
||||
receipts = list(session.scalars(select(Receipt).where(Receipt.business_id == business_id)))
|
||||
if any(r.is_barter for r in receipts):
|
||||
raise BadRequestError("抵顶特殊绩效继续线下处理,不进入普通FIFO核算")
|
||||
# 用户 2026-11-03:抵顶按普通业务计算,特殊应得绩效线下核实,
|
||||
# 不自动乘80%,也不因抵顶标记阻断整个客户的正常平账。
|
||||
if current:
|
||||
previous_costs = {s["line_id"]: s for s in current.data_json["shipments"]}
|
||||
for ship in ships:
|
||||
old = previous_costs.get(ship["line_id"])
|
||||
if old and (old["cost_total"] != ship["cost_total"] or old["date"] != ship["date"]):
|
||||
raise BadRequestError("原发货月份指导成本已封存,不得随后续回款更改")
|
||||
handovers = [h.model_dump(mode="json") for h in payload.handovers]
|
||||
if old:
|
||||
old_unit_cost = old.get("unit_cost")
|
||||
if (old["cost_total"] != ship["cost_total"] or old["date"] != ship["date"]
|
||||
or old_unit_cost is None or Decimal(str(old_unit_cost)) != Decimal(ship["unit_cost"])):
|
||||
raise BadRequestError("原发货月份指导成本已封存,不得随后续回款更改")
|
||||
if current and "handovers" not in payload.model_fields_set:
|
||||
handovers = current.data_json.get("facts", {}).get("handovers", [])
|
||||
else:
|
||||
handovers = [h.model_dump(mode="json") for h in payload.handovers]
|
||||
if any(h["business_key"] != key for h in handovers):
|
||||
raise BadRequestError("交接记录不属于本业务")
|
||||
values = {
|
||||
|
||||
@ -29,10 +29,8 @@ def month_state(session, month):
|
||||
}
|
||||
|
||||
|
||||
def lock_month(session, month, operator, *, require_maker=True):
|
||||
"""INSERT 的唯一键串行化首次建行,避免两个请求同时认为月份不存在。"""
|
||||
if require_maker:
|
||||
ensure_payroll_writer(operator)
|
||||
def lock_month_row(session, month, operator):
|
||||
"""以月份唯一键串行化首次建行及后续操作,不改变已有制作人。"""
|
||||
validate_month(month)
|
||||
values = {"month": month, "maker_id": operator.id, "maker_name": operator.username}
|
||||
if session.bind.dialect.name == "mysql":
|
||||
@ -43,7 +41,15 @@ def lock_month(session, month, operator, *, require_maker=True):
|
||||
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:
|
||||
|
||||
@ -14,7 +14,7 @@ MODULES = (
|
||||
("base:customers", "基础资料", "客户管理", ("view", "create", "update", "delete", "rating", "ownership")),
|
||||
("business:businesses", "业务数据", "业务管理", ("view", "create", "update", "delete", "status", "ownership")),
|
||||
("business:shipments", "业务数据", "发货管理", ("view", "create", "update", "delete")),
|
||||
("business:receipts", "业务数据", "回款管理", ("view", "create", "update", "delete")),
|
||||
("business:receipts", "业务数据", "回款管理", ("view", "create", "update", "delete", "confirm")),
|
||||
("business:ratings", "业务数据", "评级标准", ("view", "create", "update", "delete")),
|
||||
("tplus", "T+ 对接", "采集与导入", ("view", "collect", "retry", "preview", "import", "facts", "export")),
|
||||
("payroll", "绩效工资", "正式核算与分配", ("view", "prepare", "calculate", "allocation")),
|
||||
@ -27,6 +27,7 @@ MODULES = (
|
||||
("reports:payments", "报表统计", "回款统计", ("view", "export")),
|
||||
)
|
||||
ACTION_NAMES = {
|
||||
"confirm": "人工确认回款",
|
||||
"view": "查看", "create": "新增", "update": "编辑", "delete": "删除",
|
||||
"status": "变更状态", "password": "重置密码", "regions": "维护区域", "plans": "维护计划",
|
||||
"rating": "客户评级", "ownership": "变更归属", "export": "导出", "preview": "预览",
|
||||
|
||||
47
backend/app/services/personnel_roles.py
Normal file
47
backend/app/services/personnel_roles.py
Normal file
@ -0,0 +1,47 @@
|
||||
"""从已冻结的任职依据取岗位,回补历史工资不改当前人员档案。"""
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.exceptions import BadRequestError
|
||||
from app.models import PayrollInput
|
||||
|
||||
|
||||
def role_on(person, day):
|
||||
if not person:
|
||||
return None
|
||||
month = str(day)[:7]
|
||||
for period in person.get("role_history", []):
|
||||
if period["start_month"] <= month <= period["end_month"]:
|
||||
return period["role_code"]
|
||||
return person.get("role_code")
|
||||
|
||||
|
||||
def input_role(values, personnel_id, day):
|
||||
people = values.get("facts", {}).get("personnel", {})
|
||||
codes = {
|
||||
role_on(people[source_id], day)
|
||||
for source_id, target_id in values.get("maps", {}).get("personnel", {}).items()
|
||||
if target_id == personnel_id and source_id in people
|
||||
}
|
||||
if len(codes) > 1:
|
||||
raise BadRequestError("同一人员的历史岗位依据冲突,请先核实任职资料")
|
||||
return next(iter(codes), None)
|
||||
|
||||
|
||||
def frozen_roles(session, rows, day):
|
||||
"""按入结台账引用的资料版本解析任职,冲突时拒绝猜测。"""
|
||||
inputs = {}
|
||||
for row in rows:
|
||||
snapshot = row.snapshot_json or {}
|
||||
if snapshot.get("engine") == "FIFO" and snapshot.get("input_id"):
|
||||
inputs.setdefault(snapshot["input_id"], set()).add(row.personnel_id)
|
||||
result = {}
|
||||
for record in session.scalars(select(PayrollInput).where(PayrollInput.id.in_(inputs))):
|
||||
for pid in inputs[record.id]:
|
||||
role = input_role(record.data_json, pid, day)
|
||||
if role is None:
|
||||
raise BadRequestError("月结人员的历史岗位依据缺失,请先核实任职资料")
|
||||
if pid in result and result[pid] != role:
|
||||
raise BadRequestError("同一人员的历史岗位依据冲突,请先核实任职资料")
|
||||
result[pid] = role
|
||||
return result
|
||||
56
backend/app/services/receipt_confirmation.py
Normal file
56
backend/app/services/receipt_confirmation.py
Normal file
@ -0,0 +1,56 @@
|
||||
"""回款人工确认门槛;与生成凭证、保存资料和工资审核分别管理。"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.exceptions import BadRequestError
|
||||
from app.models import Receipt
|
||||
from app.services.param_service import resolve_snapshot
|
||||
|
||||
CONFIRMATION_PARAM = "receipt.confirmation.enabled"
|
||||
|
||||
|
||||
def enabled(session) -> bool:
|
||||
"""操作开关取当前生效值,不按历史到账日绕过本次人工确认门槛。"""
|
||||
return resolve_snapshot(session, date.today()).boolean(CONFIRMATION_PARAM)
|
||||
|
||||
|
||||
def revision(receipt) -> str:
|
||||
"""只绑定经办人核对的业务内容;备注和同步时间不使确认无故失效。"""
|
||||
values = [
|
||||
receipt.id, receipt.business_id, receipt.shipment_id,
|
||||
receipt.receipt_date.isoformat(), format(Decimal(receipt.amount).quantize(Decimal("0.01")), "f"),
|
||||
receipt.is_barter, receipt.t_plus_id, receipt.source_no,
|
||||
]
|
||||
return hashlib.sha256(json.dumps(values, ensure_ascii=False).encode()).hexdigest()
|
||||
|
||||
|
||||
def is_confirmed(receipt) -> bool:
|
||||
return bool(
|
||||
receipt.confirmation_status == 1
|
||||
and receipt.confirmed_by
|
||||
and receipt.confirmed_at
|
||||
and receipt.confirmed_revision == revision(receipt)
|
||||
)
|
||||
|
||||
|
||||
def invalidate(receipt) -> None:
|
||||
receipt.confirmation_status = 0
|
||||
receipt.confirmed_by = receipt.confirmed_at = receipt.confirmed_revision = None
|
||||
|
||||
|
||||
def ensure_confirmed(session, receipt_ids) -> None:
|
||||
"""在原核算事务内锁定并核实回款;关闭只跳过人工门槛,不改变凭证资格。"""
|
||||
ids = sorted(set(receipt_ids))
|
||||
if not ids or not enabled(session):
|
||||
return
|
||||
rows = list(session.scalars(
|
||||
select(Receipt).where(Receipt.id.in_(ids)).order_by(Receipt.id)
|
||||
.with_for_update().execution_options(populate_existing=True)
|
||||
))
|
||||
if len(rows) != len(ids) or any(not is_confirmed(row) for row in rows):
|
||||
raise BadRequestError("存在未人工确认或确认后内容已变化的回款,请先在回款管理逐笔确认")
|
||||
@ -24,7 +24,7 @@
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from datetime import date
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
@ -38,7 +38,7 @@ from app.core.constants import (
|
||||
NO_PREFIX_RECEIPT,
|
||||
)
|
||||
from app.core.db_errors import is_duplicate_entry
|
||||
from app.core.exceptions import BadRequestError, NotFoundError
|
||||
from app.core.exceptions import BadRequestError, ConflictError, NotFoundError
|
||||
from app.core.response import PageResult
|
||||
from app.models import Business, Receipt
|
||||
from app.models.business import BUSINESS_STATUS_ACTIVE
|
||||
@ -50,7 +50,7 @@ from app.schemas.receipt import (
|
||||
ReceiptQuery,
|
||||
ReceiptUpdateIn,
|
||||
)
|
||||
from app.services import audit_service
|
||||
from app.services import audit_service, receipt_confirmation
|
||||
from app.services.audit_service import AuditRecord, RequestMeta
|
||||
from app.services.business_service import _recalc_totals
|
||||
from app.services.formula import payment_days
|
||||
@ -81,6 +81,10 @@ def _to_out(row: tuple) -> ReceiptOut:
|
||||
amount=str(receipt.amount),
|
||||
is_barter=receipt.is_barter,
|
||||
settlement_status=receipt.settlement_status,
|
||||
confirmation_status=1 if receipt_confirmation.is_confirmed(receipt) else 0,
|
||||
confirmed_by=receipt.confirmed_by,
|
||||
confirmed_at=receipt.confirmed_at,
|
||||
confirmation_revision=receipt_confirmation.revision(receipt),
|
||||
payment_days=(
|
||||
payment_days(ship_date, receipt.receipt_date) if ship_date is not None else None
|
||||
),
|
||||
@ -151,6 +155,33 @@ def get_detail(session: Session, receipt_id: int) -> ReceiptOut:
|
||||
return _to_out(row)
|
||||
|
||||
|
||||
def confirm_receipt(session, receipt_id, payload, *, operator, meta=None) -> ReceiptOut:
|
||||
"""独立确认已核对内容;锁内复核指纹,重复确认不改首次确认人/时间。"""
|
||||
from app.services.authorization_service import require_permission
|
||||
|
||||
require_permission(session, operator, "business:receipts:confirm")
|
||||
with audit_service.audit(
|
||||
session, operator=operator,
|
||||
record=AuditRecord(module=MODULE_RECEIPT, action="人工确认", summary=f"人工确认回款 #{receipt_id}"),
|
||||
meta=meta,
|
||||
):
|
||||
receipt = receipt_repository.get_by_id(session, receipt_id)
|
||||
if receipt is None:
|
||||
raise NotFoundError("回款不存在")
|
||||
_ensure_business_writable(session, receipt.business_id, "确认")
|
||||
session.refresh(receipt, with_for_update=True)
|
||||
if payload.expected_revision != receipt_confirmation.revision(receipt):
|
||||
raise ConflictError("回款内容已变化,请重新查看并核对后确认")
|
||||
if not receipt_confirmation.is_confirmed(receipt):
|
||||
receipt.confirmation_status = 1
|
||||
receipt.confirmed_by = operator.username
|
||||
receipt.confirmed_at = datetime.now().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 属性)。"""
|
||||
@ -296,9 +327,13 @@ def update_receipt(
|
||||
):
|
||||
if receipt is None:
|
||||
raise NotFoundError("回款不存在")
|
||||
from app.services.business_source import ensure_source_editable
|
||||
|
||||
ensure_source_editable(session, "receipts", receipt_id)
|
||||
business = _ensure_business_writable(session, receipt.business_id, "修改")
|
||||
session.refresh(receipt, with_for_update=True)
|
||||
if receipt.settlement_status == SETTLEMENT_DONE:
|
||||
raise BadRequestError("已核算回款禁止修改")
|
||||
business = _ensure_business_writable(session, receipt.business_id, "修改")
|
||||
# 月结锁定拦截:原月与新月都不得落入已锁定月份(改到账日=跨月搬数,
|
||||
# 只查新月会让「从锁定月改到未锁定月」绕过拦截)
|
||||
from app.services import settlement_service
|
||||
@ -311,11 +346,14 @@ def update_receipt(
|
||||
session, payload.shipment_id, receipt.business_id
|
||||
)
|
||||
|
||||
old_revision = receipt_confirmation.revision(receipt)
|
||||
receipt.receipt_date = payload.receipt_date
|
||||
receipt.shipment_id = payload.shipment_id
|
||||
receipt.amount = Decimal(payload.amount)
|
||||
receipt.is_barter = payload.is_barter
|
||||
receipt.remark = payload.remark
|
||||
if receipt_confirmation.revision(receipt) != old_revision:
|
||||
receipt_confirmation.invalidate(receipt)
|
||||
_recalc_totals(session, business)
|
||||
out = _to_out((receipt, business.business_no, shipment_no, ship_date))
|
||||
batch_no = receipt.receipt_batch_no
|
||||
@ -353,6 +391,9 @@ def delete_receipt(
|
||||
):
|
||||
if receipt is None:
|
||||
raise NotFoundError("回款不存在")
|
||||
from app.services.business_source import ensure_source_editable
|
||||
|
||||
ensure_source_editable(session, "receipts", receipt_id)
|
||||
if receipt.settlement_status == SETTLEMENT_DONE:
|
||||
raise BadRequestError("已核算回款禁止删除")
|
||||
business = _ensure_business_writable(session, receipt.business_id, "删除")
|
||||
|
||||
@ -280,7 +280,7 @@ def _write_row(sheet, columns: tuple[_Column, ...], row: dict) -> None:
|
||||
def _cell_value(column: _Column, raw):
|
||||
"""单值转换:标签列转中文,文本列防公式注入。"""
|
||||
if raw is None:
|
||||
return None
|
||||
return "待核对" if column.key == "total_sale_amount" else None
|
||||
if column.kind == "amount":
|
||||
return raw if isinstance(raw, Decimal) else Decimal(str(raw))
|
||||
if column.kind == "date":
|
||||
@ -317,6 +317,9 @@ def _write_total(sheet, columns: tuple[_Column, ...], rows: list[dict]) -> None:
|
||||
totals.append("合计" if not label_written else None)
|
||||
label_written = True
|
||||
continue
|
||||
if column.key == "total_sale_amount" and any(row.get(column.key) is None for row in rows):
|
||||
totals.append("待核对")
|
||||
continue
|
||||
total = sum(
|
||||
(
|
||||
row.get(column.key)
|
||||
|
||||
@ -812,14 +812,31 @@ def _unassigned_rows(
|
||||
# ==== 业务统计(FR-031 之一)====
|
||||
|
||||
|
||||
def _pending_sales_count(session, query):
|
||||
from app.services.business_source import sales_relations_pending
|
||||
|
||||
count, cursor = 0, 0
|
||||
while True:
|
||||
rows = report_repository.list_source_business_sales(session, query, after_id=cursor, limit=200)
|
||||
if not rows:
|
||||
return count
|
||||
pending = sales_relations_pending(session, [row.id for row in rows])
|
||||
count += len(pending | {row.id for row in rows if row.total_sale_amount is None})
|
||||
cursor = rows[-1].id
|
||||
|
||||
|
||||
def business_stats(session, query) -> dict:
|
||||
"""业务统计分页 + 全筛选合计;回款/绩效为业务累计值。"""
|
||||
rows = report_repository.list_business_stats(
|
||||
session, query, offset=query.offset, limit=query.page_size
|
||||
)
|
||||
sums = report_repository.sum_business_stats(session, query)
|
||||
pending_count = _pending_sales_count(session, query)
|
||||
from app.services.business_source import sales_relations_pending
|
||||
|
||||
pending = sales_relations_pending(session, [row[0].id for row in rows if row[0].integration_json])
|
||||
return {
|
||||
"list": [_business_row(r) for r in rows],
|
||||
"list": [_business_row(r, sales_pending=r[0].id in pending) for r in rows],
|
||||
"total": report_repository.count_business_stats(session, query),
|
||||
"pageNum": query.page_num,
|
||||
"pageSize": query.page_size,
|
||||
@ -827,14 +844,15 @@ def business_stats(session, query) -> dict:
|
||||
"row_count": sums["row_count"],
|
||||
"ship_total": _q2(_dec(sums["ship_total"])),
|
||||
"received_total": _q2(_dec(sums["received_total"])),
|
||||
"sale_total": _q2(_dec(sums["sale_total"])),
|
||||
"sale_total": None if pending_count else _q2(_dec(sums["sale_total"])),
|
||||
"sale_pending_count": pending_count,
|
||||
"performance_total": _q2(_dec(sums["performance_total"])),
|
||||
"payable_total": _q2(_dec(sums["payable_total"])),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _business_row(row) -> dict:
|
||||
def _business_row(row, *, sales_pending=False) -> dict:
|
||||
"""业务统计行(ORM 行与三个联查列)。"""
|
||||
business = row[0]
|
||||
return {
|
||||
@ -844,7 +862,7 @@ def _business_row(row) -> dict:
|
||||
"status": business.status,
|
||||
"customer_name": row.customer_name,
|
||||
"owner_personnel_name": row.owner_personnel_name,
|
||||
"total_sale_amount": business.total_sale_amount,
|
||||
"total_sale_amount": None if sales_pending else business.total_sale_amount,
|
||||
"total_ship_amount": business.total_ship_amount,
|
||||
"total_received_amount": business.total_received_amount,
|
||||
"performance_amount": row.performance_amount,
|
||||
@ -1082,7 +1100,10 @@ def _export_payload(session, code: str, query) -> tuple[list[dict], dict]:
|
||||
session, query, offset=0, limit=REPORT_EXPORT_MAX_ROWS
|
||||
)
|
||||
sums = report_repository.sum_business_stats(session, query)
|
||||
return [_business_row(r) for r in rows], {
|
||||
from app.services.business_source import sales_relations_pending
|
||||
|
||||
pending = sales_relations_pending(session, [row[0].id for row in rows if row[0].integration_json])
|
||||
return [_business_row(r, sales_pending=r[0].id in pending) for r in rows], {
|
||||
"title": EXPORT_TITLES["business-stats"],
|
||||
"row_count": total,
|
||||
"filters": _filter_text(session, query),
|
||||
|
||||
@ -2,8 +2,7 @@
|
||||
|
||||
需求 2.5.1 定义 admin / user 两个登录角色;2026-10-15 复核审批补充需求
|
||||
新增营销后勤/财务/领导三个职能登录角色(营销后勤与财务/领导分区于审批
|
||||
节点准入)。本模块仍为轻量版(不含权限点分配 —— `permissions` /
|
||||
`role_permissions` 表继续闲置,待业务方给出权限清单后再启用)。
|
||||
节点准入)。权限点按当前权限目录分配,管理员角色拥有固定权限基线。
|
||||
|
||||
保护规则(都是系统自举与授权安全的最低防线,不只是界面体验):
|
||||
|
||||
@ -15,8 +14,7 @@
|
||||
剥离绑定用户的权限,属安全与审计事故;删除前的校验走 `FOR UPDATE`
|
||||
锁行,堵住「校验与删除之间并发绑定」的窗口
|
||||
- `role_code` 创建后不可改 —— 它是授权判定(`BUILTIN_ROLE_CODES` 白名单按编码比对)的锚点
|
||||
- 停用角色**不解除绑定、不影响既有授权**(授权判定只看编码、不看状态),
|
||||
但**不可再分配给用户**(`user_service._resolve_roles` 拦截)
|
||||
- 停用角色不解除用户绑定,但该角色不再提供有效授权,也不可再分配给用户。
|
||||
"""
|
||||
|
||||
import logging
|
||||
@ -35,16 +33,17 @@ from app.core.constants import (
|
||||
)
|
||||
from app.core.exceptions import BadRequestError, ForbiddenError, NotFoundError
|
||||
from app.core.response import PageResult
|
||||
from app.models import Role, User
|
||||
from app.models import Role, RolePermission, User
|
||||
from app.repositories import role_repository
|
||||
from app.schemas.role import RoleCreateIn, RoleManageOut, RoleQuery, RoleUpdateIn
|
||||
from app.services import audit_service
|
||||
from app.services import audit_service, authorization_management
|
||||
from app.services.audit_service import AuditRecord, RequestMeta
|
||||
from app.services.permission_catalog import CATALOG
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _to_out(role: Role, user_count: int) -> RoleManageOut:
|
||||
def _to_out(role: Role, user_count: int, permission_count: int = 0) -> RoleManageOut:
|
||||
"""ORM 对象转响应模型。禁止把 ORM 对象直接当 response_model。"""
|
||||
return RoleManageOut(
|
||||
id=role.id,
|
||||
@ -54,6 +53,7 @@ def _to_out(role: Role, user_count: int) -> RoleManageOut:
|
||||
sort_order=role.sort_order,
|
||||
status=role.status,
|
||||
user_count=user_count,
|
||||
permission_count=len(CATALOG) if role.role_code == "admin" else permission_count,
|
||||
)
|
||||
|
||||
|
||||
@ -62,6 +62,10 @@ def _user_count(session: Session, role_id: int) -> int:
|
||||
return role_repository.count_users_by_role(session, [role_id]).get(role_id, 0)
|
||||
|
||||
|
||||
def _permission_count(session: Session, role_id: int) -> int:
|
||||
return role_repository.count_permissions_by_role(session, [role_id]).get(role_id, 0)
|
||||
|
||||
|
||||
def list_roles(session: Session, query: RoleQuery) -> PageResult[RoleManageOut]:
|
||||
"""分页查询角色。
|
||||
|
||||
@ -71,8 +75,9 @@ def list_roles(session: Session, query: RoleQuery) -> PageResult[RoleManageOut]:
|
||||
roles = role_repository.list_roles(session, query)
|
||||
total = role_repository.count_roles(session, query)
|
||||
counts = role_repository.count_users_by_role(session, [role.id for role in roles])
|
||||
permissions = role_repository.count_permissions_by_role(session, [role.id for role in roles])
|
||||
return PageResult[RoleManageOut](
|
||||
records=[_to_out(role, counts.get(role.id, 0)) for role in roles],
|
||||
records=[_to_out(role, counts.get(role.id, 0), permissions.get(role.id, 0)) for role in roles],
|
||||
total=total,
|
||||
page_num=query.page_num,
|
||||
page_size=query.page_size,
|
||||
@ -90,6 +95,9 @@ def create_role(
|
||||
|
||||
:raises BadRequestError: 角色编码已存在(含并发提交的兜底)
|
||||
"""
|
||||
audit_data = payload.model_dump(exclude_none=True)
|
||||
if payload.permission_codes is not None and not payload.reason:
|
||||
audit_data["reason"] = "创建角色并配置初始权限"
|
||||
with audit_service.audit(
|
||||
session,
|
||||
operator=operator,
|
||||
@ -97,13 +105,18 @@ def create_role(
|
||||
module=MODULE_ROLE,
|
||||
action=ACTION_CREATE,
|
||||
summary=f"新增角色 {payload.role_name}({payload.role_code})",
|
||||
request_data=payload.model_dump(exclude_none=True),
|
||||
request_data=audit_data,
|
||||
),
|
||||
meta=meta,
|
||||
):
|
||||
if role_repository.get_by_code(session, payload.role_code) is not None:
|
||||
raise BadRequestError(f"角色编码 {payload.role_code!r} 已存在")
|
||||
|
||||
codes = None
|
||||
policy = None
|
||||
if payload.permission_codes is not None:
|
||||
policy = authorization_management.lock_policy(session, operator)
|
||||
codes = authorization_management.validate_role_permissions(payload.permission_codes)
|
||||
role = Role(
|
||||
role_code=payload.role_code,
|
||||
role_name=payload.role_name,
|
||||
@ -113,7 +126,12 @@ def create_role(
|
||||
)
|
||||
try:
|
||||
role_repository.create(session, role)
|
||||
out = _to_out(role, 0)
|
||||
if codes is not None:
|
||||
ids = authorization_management.active_permission_ids(session, codes)
|
||||
session.add_all([RolePermission(role_id=role.id, permission_id=ids[code]) for code in codes])
|
||||
authorization_management.bump(policy, operator)
|
||||
session.flush()
|
||||
out = _to_out(role, 0, len(codes) if codes is not None else 0)
|
||||
session.commit()
|
||||
except IntegrityError as exc:
|
||||
# 先查后写不是原子操作:并发提交同一编码会撞 uk_roles_role_code。
|
||||
@ -156,7 +174,7 @@ def update_role(
|
||||
role.description = payload.description
|
||||
role.sort_order = payload.sort_order
|
||||
# 响应与后置动作全部在 commit 之前完成(见 user_service 顶部说明)
|
||||
out = _to_out(role, _user_count(session, role_id))
|
||||
out = _to_out(role, _user_count(session, role_id), _permission_count(session, role_id))
|
||||
session.commit()
|
||||
logger.info("修改角色 | role_id=%s", role_id)
|
||||
return out
|
||||
@ -172,8 +190,7 @@ def set_status(
|
||||
) -> RoleManageOut:
|
||||
"""启用或禁用角色。
|
||||
|
||||
停用不解除用户绑定、不影响既有授权(授权判定只看编码不看状态),
|
||||
但该角色不可再分配给用户(`user_service._resolve_roles` 拦截);
|
||||
停用不解除用户绑定,但该角色不再提供有效授权,也不可再分配给用户;
|
||||
内置角色不可停用。
|
||||
|
||||
:raises NotFoundError: 角色不存在
|
||||
@ -199,7 +216,7 @@ def set_status(
|
||||
raise ForbiddenError("内置角色不可停用")
|
||||
|
||||
role.status = status
|
||||
out = _to_out(role, _user_count(session, role_id))
|
||||
out = _to_out(role, _user_count(session, role_id), _permission_count(session, role_id))
|
||||
session.commit()
|
||||
logger.info("变更角色状态 | role_id=%s | status=%s", role_id, status)
|
||||
return out
|
||||
|
||||
@ -16,6 +16,9 @@ def build_items(session, rows, month):
|
||||
from app.services.settlement_service import _salary_mode_of
|
||||
|
||||
end = month_end(month)
|
||||
from app.services.personnel_roles import frozen_roles
|
||||
|
||||
historical_roles = frozen_roles(session, rows, end)
|
||||
# 人员锁同时串行化不同工资月份对同一年度缓发余额的结清。
|
||||
people = {
|
||||
p.id: p for p in session.scalars(select(Personnel).order_by(Personnel.id).with_for_update())
|
||||
@ -84,7 +87,7 @@ def build_items(session, rows, month):
|
||||
for pid, values in sorted(buckets.items()):
|
||||
person = values["person"]
|
||||
profile = profiles[pid]
|
||||
role = roles.get(profile.business_role_id, "salesperson")
|
||||
role = historical_roles.get(pid, roles.get(profile.business_role_id, "salesperson"))
|
||||
mode = _salary_mode_of(session, role, month)
|
||||
annual = sum((Decimal(r["delta"]) for r in values["annual"]), ZERO)
|
||||
normal = values["normal"]
|
||||
|
||||
@ -90,6 +90,9 @@ def generate(
|
||||
_release_previous_round(session, existing, month)
|
||||
|
||||
ledger_rows = _confirm_scope(session, month)
|
||||
from app.services.receipt_confirmation import ensure_confirmed
|
||||
|
||||
ensure_confirmed(session, [row.receipt_id for row in ledger_rows if row.receipt_id is not None])
|
||||
from app.core.config import get_settings
|
||||
|
||||
if not ledger_rows and get_settings().payroll_allow_legacy_calculation:
|
||||
|
||||
@ -70,7 +70,7 @@ def _to_out(row: tuple[Shipment, str]) -> ShipmentOut:
|
||||
business_id=shipment.business_id,
|
||||
business_no=business_no,
|
||||
ship_date=shipment.ship_date,
|
||||
freight_amount=str(shipment.freight_amount),
|
||||
freight_amount=str(shipment.freight_amount) if shipment.freight_amount is not None else None,
|
||||
total_amount=str(shipment.total_amount),
|
||||
remark=shipment.remark,
|
||||
)
|
||||
@ -302,6 +302,9 @@ def update_shipment(
|
||||
):
|
||||
if shipment is None:
|
||||
raise NotFoundError("发货单不存在")
|
||||
from app.services.business_source import ensure_source_editable
|
||||
|
||||
ensure_source_editable(session, "shipments", shipment_id)
|
||||
business = _ensure_business_writable(session, shipment.business_id, "编辑发货单")
|
||||
# 月结锁定拦截:原发货日与新月都不得落入已锁定月份(改日期=跨月搬数)
|
||||
from app.services import settlement_service
|
||||
@ -355,6 +358,9 @@ def delete_shipment(
|
||||
):
|
||||
if shipment is None:
|
||||
raise NotFoundError("发货单不存在")
|
||||
from app.services.business_source import ensure_source_editable
|
||||
|
||||
ensure_source_editable(session, "shipments", shipment_id)
|
||||
business = _ensure_business_writable(session, shipment.business_id, "删除发货单")
|
||||
# 月结锁定拦截:已锁定月份的发货单是月结快照的源数据,不得删除
|
||||
from app.services import settlement_service
|
||||
|
||||
@ -126,14 +126,23 @@ def save_logo(
|
||||
setting = get_setting(session)
|
||||
old_path = setting.logo_path
|
||||
|
||||
# 先写新文件、commit 成功后再清旧扩展名(若把清理放在 commit 之前,
|
||||
# commit 失败会让库指旧路径而旧文件已删——断链无补偿);同扩展名
|
||||
# 由写入覆盖,无需也不会误删新文件
|
||||
# 保留同名旧文件内容,避免覆盖后数据库提交失败造成 logo 断链;
|
||||
# 不同扩展名的新文件在提交失败时删除,不改变当前生效文件。
|
||||
target = _logo_dir() / f"logo.{ext}"
|
||||
target.write_bytes(content)
|
||||
|
||||
setting.logo_path = f"logo/logo.{ext}"
|
||||
session.commit()
|
||||
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
|
||||
|
||||
old_ext = Path(old_path).suffix.lstrip(".") if old_path else None
|
||||
if old_ext and old_ext != ext:
|
||||
|
||||
Binary file not shown.
@ -125,7 +125,11 @@ def _build(session, run):
|
||||
row["action"], row["reason"] = "SKIP", "CUSTOMER_SCOPE_ALREADY_SELECTED"
|
||||
continue
|
||||
seen_customers.add(customer_id)
|
||||
if any(not ship.get("guidance_finalized") for ship in values["shipments"]):
|
||||
active_ships = [s for s in values["shipments"] if not s.get("performance_excluded")]
|
||||
if not active_ships:
|
||||
row["action"], row["reason"] = "SKIP", "CORRECTION_EXCLUDED"
|
||||
continue
|
||||
if any(not ship.get("guidance_finalized") for ship in active_ships):
|
||||
row["reason"] = "GUIDANCE_FINALIZATION_REQUIRED"
|
||||
continue
|
||||
business_key = "customer:" + customer_id
|
||||
@ -150,7 +154,10 @@ def _build(session, run):
|
||||
):
|
||||
for source_id in ids:
|
||||
linked = _link(session, run.source_database, entity, source_id)
|
||||
if linked and (
|
||||
source_rows = values[entity]
|
||||
excluded = all(r.get("exclusion") or r.get("performance_excluded")
|
||||
for r in source_rows if r["source_id"] == source_id)
|
||||
if linked and not excluded and (
|
||||
not link
|
||||
or not current
|
||||
or source_id not in current.data_json["maps"].get(entity, {})
|
||||
@ -158,7 +165,7 @@ def _build(session, run):
|
||||
row["action"], row["reason"] = "CONFLICT", "SOURCE_ALREADY_IMPORTED"
|
||||
if not _link(session, run.source_database, "customers", values["group"]["customer_id"]):
|
||||
row["action"], row["reason"] = "PENDING", "CUSTOMER_IMPORT_REQUIRED"
|
||||
for ship in values["shipments"]:
|
||||
for ship in active_ships:
|
||||
if not _link(session, run.source_database, "products", ship["product_id"]):
|
||||
row["action"], row["reason"] = "PENDING", "PRODUCT_IMPORT_REQUIRED"
|
||||
frozen = {"calculation": frozen, "plan": rows, "targets": targets}
|
||||
@ -197,8 +204,9 @@ def _create(session, run, row, frozen):
|
||||
)
|
||||
if len(candidates) == 1:
|
||||
people[code] = candidates[0]
|
||||
needed = {str(s["personnel_id"]) for s in values["shipments"]}
|
||||
needed.update(str(s["manager_id"]) for s in values["shipments"] if s.get("manager_id"))
|
||||
active_ships = [s for s in values["shipments"] if not s.get("performance_excluded")]
|
||||
needed = {str(s["personnel_id"]) for s in active_ships}
|
||||
needed.update(str(s["manager_id"]) for s in active_ships if s.get("manager_id"))
|
||||
for handover in values["facts"]["handovers"]:
|
||||
needed.update((str(handover["from_personnel_id"]), str(handover["to_personnel_id"])))
|
||||
if needed - people.keys():
|
||||
@ -211,8 +219,8 @@ def _create(session, run, row, frozen):
|
||||
else Business(
|
||||
business_no=_new_no(session, Business, "business_no", "YW"),
|
||||
customer_id=customer.target_id,
|
||||
owner_personnel_id=people[str(values["shipments"][0]["personnel_id"])].id,
|
||||
business_date=min(date.fromisoformat(s["date"]) for s in values["shipments"]),
|
||||
owner_personnel_id=people[str(active_ships[0]["personnel_id"])].id,
|
||||
business_date=min(date.fromisoformat(s["date"]) for s in active_ships),
|
||||
status=1,
|
||||
is_special=0,
|
||||
t_plus_id=row["business_key"],
|
||||
|
||||
714
backend/app/services/tplus/business_sync.py
Normal file
714
backend/app/services/tplus/business_sync.py
Normal file
@ -0,0 +1,714 @@
|
||||
"""将已发布的源单据接入业务;绝不以核算资料齐全作为建档条件。"""
|
||||
|
||||
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}
|
||||
141
backend/app/services/tplus/business_sync_control.py
Normal file
141
backend/app/services/tplus/business_sync_control.py
Normal file
@ -0,0 +1,141 @@
|
||||
"""业务接入开关与幂等排队;HTTP 不执行导入。"""
|
||||
|
||||
from uuid import NAMESPACE_URL, uuid5
|
||||
|
||||
from sqlalchemy import inspect, select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from app.core.exceptions import BadRequestError, ConflictError, ForbiddenError
|
||||
from app.models import TPlusBusinessSyncConfig, TPlusWorkRun, User
|
||||
from app.services.authorization_service import require_permission
|
||||
from app.services.tplus.business_sync import START, preview
|
||||
from app.services.tplus.task_service import history_source_database, new_batch_no
|
||||
from app.services.tplus.workbench import WorkbenchError, digest, work_out
|
||||
|
||||
|
||||
def configuration(session):
|
||||
source = history_source_database(session)
|
||||
ready = inspect(session.connection()).has_table(TPlusBusinessSyncConfig.__tablename__)
|
||||
row = session.scalar(select(TPlusBusinessSyncConfig).where(TPlusBusinessSyncConfig.source_database == source)) if ready else None
|
||||
return {"enabled": bool(row and row.enabled), "start_date": (row.start_date if row else START).isoformat(),
|
||||
"version": row.version if row else 0, "editable": ready and bool(source),
|
||||
"last_error": row.last_error if row else None}
|
||||
|
||||
|
||||
def queue(session, actor_id, start_date, request_key):
|
||||
require_permission(session, session.get(User, actor_id), "tplus:import")
|
||||
source = history_source_database(session)
|
||||
if not source:
|
||||
raise BadRequestError("请先完成 T+ 数据采集")
|
||||
key = str(request_key)
|
||||
found = session.scalar(select(TPlusWorkRun).where(TPlusWorkRun.request_key == key))
|
||||
values = {"start_date": start_date.isoformat()}
|
||||
def existing_run(found):
|
||||
if found.kind != "BUSINESS_SYNC" or found.requested_by != actor_id or found.source_database != source or found.input_json != values:
|
||||
raise BadRequestError("请求键已用于不同任务")
|
||||
return work_out(found)
|
||||
if found:
|
||||
return existing_run(found)
|
||||
run = TPlusWorkRun(run_no=new_batch_no(), request_key=key, source_database=source,
|
||||
requested_by=actor_id, kind="BUSINESS_SYNC", status="QUEUED", input_json=values)
|
||||
try:
|
||||
with session.begin_nested():
|
||||
session.add(run)
|
||||
session.flush()
|
||||
session.commit()
|
||||
except IntegrityError:
|
||||
session.rollback()
|
||||
found = session.scalar(select(TPlusWorkRun).where(TPlusWorkRun.request_key == key))
|
||||
if found is None:
|
||||
raise ConflictError() from None
|
||||
return existing_run(found)
|
||||
return work_out(run)
|
||||
|
||||
|
||||
def save(session, payload, actor_id):
|
||||
require_permission(session, session.get(User, actor_id), "tplus:import")
|
||||
source = history_source_database(session)
|
||||
if not source:
|
||||
raise BadRequestError("请先完成 T+ 数据采集")
|
||||
row = session.scalar(select(TPlusBusinessSyncConfig).where(TPlusBusinessSyncConfig.source_database == source).with_for_update())
|
||||
if (row.version if row else 0) != payload.expected_version:
|
||||
raise ConflictError()
|
||||
if row is None:
|
||||
row = TPlusBusinessSyncConfig(source_database=source, updated_by=actor_id)
|
||||
session.add(row)
|
||||
row.enabled, row.start_date = int(payload.enabled), payload.start_date
|
||||
row.version, row.updated_by, row.last_error = payload.expected_version + 1, actor_id, None
|
||||
try:
|
||||
session.commit()
|
||||
except IntegrityError:
|
||||
session.rollback()
|
||||
raise ConflictError() from None
|
||||
if row.enabled:
|
||||
schedule(session)
|
||||
return configuration(session)
|
||||
|
||||
|
||||
def schedule(session):
|
||||
"""仅成功快照触发;去重周期与配置版本绑定,不改变原采集结果。"""
|
||||
if not inspect(session.connection()).has_table(TPlusBusinessSyncConfig.__tablename__):
|
||||
return None
|
||||
source = history_source_database(session)
|
||||
config = session.scalar(select(TPlusBusinessSyncConfig).where(TPlusBusinessSyncConfig.source_database == source))
|
||||
if config is None or not config.enabled:
|
||||
return None
|
||||
try:
|
||||
require_permission(session, session.get(User, config.updated_by), "tplus:import")
|
||||
except ForbiddenError:
|
||||
config.last_error = "TASK_AUTHORIZATION_REVOKED"
|
||||
session.commit()
|
||||
return None
|
||||
from app.services.tplus.snapshots import source_manifest
|
||||
try:
|
||||
manifest = list(source_manifest(session, source).items())
|
||||
except WorkbenchError:
|
||||
config.last_error = "SOURCE_REFRESH_REQUIRED"
|
||||
session.commit()
|
||||
return None
|
||||
key = uuid5(NAMESPACE_URL, digest([source, config.version, manifest, "business-sync-v1"]))
|
||||
result = queue(session, config.updated_by, config.start_date, key)
|
||||
if result["status"] != "FAILED" and config.last_error is not None:
|
||||
config.last_error = None
|
||||
session.commit()
|
||||
return result
|
||||
|
||||
|
||||
def readonly_preview(session, start_date=START):
|
||||
return preview(session, history_source_database(session), start_date)
|
||||
|
||||
|
||||
def record_schedule_failure(session, source):
|
||||
"""保留采集成功状态,同时在接入设置中提示排队异常。"""
|
||||
config = session.scalar(select(TPlusBusinessSyncConfig).where(
|
||||
TPlusBusinessSyncConfig.source_database == source,
|
||||
TPlusBusinessSyncConfig.enabled == 1,
|
||||
).with_for_update())
|
||||
if config:
|
||||
config.last_error = "BUSINESS_SYNC_SCHEDULE_FAILED"
|
||||
session.commit()
|
||||
|
||||
|
||||
def record_authorization_failure(session, source, actor_id):
|
||||
"""仅标记仍由该授权人启用的配置,由调用方提交任务失败状态。"""
|
||||
config = session.scalar(select(TPlusBusinessSyncConfig).where(
|
||||
TPlusBusinessSyncConfig.source_database == source,
|
||||
TPlusBusinessSyncConfig.enabled == 1,
|
||||
TPlusBusinessSyncConfig.updated_by == actor_id,
|
||||
).with_for_update())
|
||||
if config:
|
||||
config.last_error = "TASK_AUTHORIZATION_REVOKED"
|
||||
|
||||
|
||||
def clear_execution_failure(session, source, actor_id):
|
||||
"""当前授权人重试成功后清除旧错误,由调用方提交任务结果。"""
|
||||
config = session.scalar(select(TPlusBusinessSyncConfig).where(
|
||||
TPlusBusinessSyncConfig.source_database == source,
|
||||
TPlusBusinessSyncConfig.enabled == 1,
|
||||
TPlusBusinessSyncConfig.updated_by == actor_id,
|
||||
).with_for_update())
|
||||
if config:
|
||||
config.last_error = None
|
||||
@ -67,6 +67,7 @@ def _guidance(facts, product_id, line_id, month):
|
||||
|
||||
|
||||
def _shipments(session, run, key, group, facts):
|
||||
from app.services.tplus.corrections import verified_correction
|
||||
headers = _group_records(session, run.source_database, "shipments", group["shipment_ids"])
|
||||
details = source_records(
|
||||
session, run.source_database, "shipment_items", parent_ids=group["shipment_ids"]
|
||||
@ -104,14 +105,7 @@ def _shipments(session, run, key, group, facts):
|
||||
raise WorkbenchError("GROUP_CUSTOMER_MISMATCH")
|
||||
supplement = facts.get(("SHIPMENT", record.source_id))
|
||||
supplement = supplement.data_json if supplement else {}
|
||||
correction_fact = facts.get(("CORRECTION", record.source_id)) or facts.get(
|
||||
("CORRECTION", head_record.source_id)
|
||||
)
|
||||
correction = (
|
||||
correction_fact.data_json
|
||||
if correction_fact and correction_fact.data_json.get("entity") == "SHIPMENT"
|
||||
else {}
|
||||
)
|
||||
correction = verified_correction(session, run.source_database, facts, "SHIPMENT", head_record)
|
||||
product_id = str(raw.get("idinventory"))
|
||||
product = facts.get(("PRODUCT", product_id))
|
||||
customer = facts.get(("CUSTOMER", group["customer_id"]))
|
||||
@ -204,16 +198,12 @@ def _shipments(session, run, key, group, facts):
|
||||
|
||||
|
||||
def _receipts(session, run, key, group, facts):
|
||||
from app.services.tplus.corrections import verified_correction
|
||||
records = _group_records(session, run.source_database, "receipts", group["receipt_ids"])
|
||||
rows = []
|
||||
for record in records:
|
||||
raw = record.payload_json
|
||||
correction_fact = facts.get(("CORRECTION", record.source_id))
|
||||
correction = (
|
||||
correction_fact.data_json
|
||||
if correction_fact and correction_fact.data_json.get("entity") == "RECEIPT"
|
||||
else {}
|
||||
)
|
||||
correction = verified_correction(session, run.source_database, facts, "RECEIPT", record)
|
||||
redirect_target = correction.get("redirect_business_key")
|
||||
if str(raw.get("idpartner")) != group["customer_id"] and redirect_target != key:
|
||||
raise WorkbenchError("GROUP_CUSTOMER_MISMATCH")
|
||||
@ -374,14 +364,31 @@ def execute_preview(session, run, *, save_lines=True, customer_scope=False):
|
||||
]
|
||||
if entity == "receipts":
|
||||
source_ids.extend(
|
||||
f.fact_key
|
||||
f.fact_key.removeprefix("RECEIPT:")
|
||||
for (kind, _), f in facts.items()
|
||||
if kind == "CORRECTION"
|
||||
and f.data_json.get("entity") == "RECEIPT"
|
||||
and f.data_json.get("redirect_business_key") == key
|
||||
and f.fact_key not in source_ids
|
||||
and f.fact_key.removeprefix("RECEIPT:") not in source_ids
|
||||
)
|
||||
group[target_field] = sorted(source_ids, key=lambda value: int(value))
|
||||
else:
|
||||
redirected = [
|
||||
f.fact_key.removeprefix("RECEIPT:")
|
||||
for (kind, _), f in facts.items()
|
||||
if kind == "CORRECTION" and f.data_json.get("entity") == "RECEIPT"
|
||||
and f.data_json.get("redirect_business_key") == key
|
||||
]
|
||||
group["receipt_ids"] = sorted(set(group["receipt_ids"] + redirected), key=int)
|
||||
|
||||
def effective_receipts(value, group_key):
|
||||
from app.services.tplus.corrections import correction_fact
|
||||
|
||||
return {
|
||||
rid for rid in value["receipt_ids"]
|
||||
if not (f := correction_fact(facts, "RECEIPT", rid))
|
||||
or f.data_json.get("redirect_business_key") == group_key
|
||||
}
|
||||
# 也检查未选组,避免同一单据在另一组被解释为独立资金。
|
||||
for (kind, other_key), other in facts.items():
|
||||
if (
|
||||
@ -390,7 +397,7 @@ def execute_preview(session, run, *, save_lines=True, customer_scope=False):
|
||||
and not customer_scope
|
||||
and (
|
||||
set(group["shipment_ids"]) & set(other.data_json["shipment_ids"])
|
||||
or set(group["receipt_ids"]) & set(other.data_json["receipt_ids"])
|
||||
or effective_receipts(group, key) & effective_receipts(other.data_json, other_key)
|
||||
)
|
||||
):
|
||||
overlapping.add(key)
|
||||
|
||||
195
backend/app/services/tplus/corrections.py
Normal file
195
backend/app/services/tplus/corrections.py
Normal file
@ -0,0 +1,195 @@
|
||||
"""转客户更正:成对确认、原始单据保留,同一回款只归一个业务。"""
|
||||
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.exceptions import BadRequestError
|
||||
from app.core.tplus_lock import TPlusBusyError, sync_lock
|
||||
from app.models import TPlusFact, TPlusSourceLink
|
||||
from app.services.tplus.fifo import money
|
||||
from app.services.tplus.snapshots import source_records
|
||||
from app.services.tplus.workbench import (
|
||||
WorkbenchError,
|
||||
_work_source_database,
|
||||
digest,
|
||||
fact_out,
|
||||
latest_facts,
|
||||
)
|
||||
|
||||
|
||||
def correction_fact(facts, entity, source_id):
|
||||
"""实体前缀避免发货ID与收款ID相同相互覆盖;旧事实只供核对。"""
|
||||
found = facts.get(("CORRECTION", f"{entity}:{source_id}")) or facts.get(
|
||||
("CORRECTION", str(source_id))
|
||||
)
|
||||
return found if found and found.data_json.get("entity") == entity else None
|
||||
|
||||
|
||||
def verified_correction(session, source, facts, entity, record):
|
||||
fact = correction_fact(facts, entity, record.source_id)
|
||||
if fact is None:
|
||||
return {}
|
||||
data = fact.data_json
|
||||
if data.get("source_hash") != record.row_hash:
|
||||
raise WorkbenchError("CORRECTION_RECONFIRM_REQUIRED")
|
||||
if entity == "SHIPMENT":
|
||||
if data.get("details_hash") != _details_hash(session, source, record):
|
||||
raise WorkbenchError("CORRECTION_RECONFIRM_REQUIRED")
|
||||
other = correction_fact(facts, entity, data.get("paired_source_id"))
|
||||
if not other or (
|
||||
other.data_json.get("paired_source_id") != record.source_id
|
||||
or other.data_json.get("pair_key") != data["pair_key"]
|
||||
or other.data_json.get("source_hash") != data.get("paired_hash")
|
||||
):
|
||||
raise WorkbenchError("CORRECTION_PAIR_REQUIRED")
|
||||
peers = source_records(session, source, "shipments", ids=[data["paired_source_id"]])
|
||||
if len(peers) != 1 or peers[0].row_hash != data["paired_hash"]:
|
||||
raise WorkbenchError("CORRECTION_RECONFIRM_REQUIRED")
|
||||
if other.data_json.get("details_hash") != _details_hash(session, source, peers[0]):
|
||||
raise WorkbenchError("CORRECTION_RECONFIRM_REQUIRED")
|
||||
neg, pos = _amount(session, source, record), _amount(session, source, peers[0])
|
||||
if neg + pos != 0 or neg * pos >= 0:
|
||||
raise WorkbenchError("CORRECTION_RECONFIRM_REQUIRED")
|
||||
return data
|
||||
|
||||
|
||||
def _records(session, source, entity, ids):
|
||||
rows = source_records(session, source, entity, ids=ids)
|
||||
if {r.source_id for r in rows} != set(ids):
|
||||
raise BadRequestError("所选更正单或实际回款不在当前完整源快照中")
|
||||
return {r.source_id: r for r in rows}
|
||||
|
||||
|
||||
def _amount(session, source, head):
|
||||
lines = source_records(session, source, "shipment_items", parent_ids=[head.source_id])
|
||||
if not lines or any(r.payload_json.get("taxAmount") is None for r in lines):
|
||||
raise BadRequestError("更正单缺少完整含税金额明细,不能成对排除")
|
||||
return sum((money(r.payload_json["taxAmount"]) for r in lines), Decimal(0))
|
||||
|
||||
|
||||
def _details_hash(session, source, head):
|
||||
return digest(
|
||||
[
|
||||
(r.source_id, r.row_hash)
|
||||
for r in source_records(session, source, "shipment_items", parent_ids=[head.source_id])
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def save_pair(session, payload, actor_id):
|
||||
"""沿用源级互斥锁,全部事实同事务追加;同一提交幂等。"""
|
||||
source = _work_source_database(session)
|
||||
try:
|
||||
with sync_lock(session):
|
||||
session.rollback()
|
||||
facts = latest_facts(session, source)
|
||||
heads = _records(
|
||||
session,
|
||||
source,
|
||||
"shipments",
|
||||
[
|
||||
payload.negative_shipment_id,
|
||||
payload.positive_shipment_id,
|
||||
],
|
||||
)
|
||||
negative, positive = (
|
||||
heads[payload.negative_shipment_id],
|
||||
heads[payload.positive_shipment_id],
|
||||
)
|
||||
if negative.payload_json.get("idcustomer") == positive.payload_json.get("idcustomer"):
|
||||
raise BadRequestError("转客户更正对必须分别属于原客户和新客户")
|
||||
if any(h.payload_json.get("voucherState") != 189 for h in heads.values()):
|
||||
raise BadRequestError("更正销货单尚未生效")
|
||||
neg, pos = _amount(session, source, negative), _amount(session, source, positive)
|
||||
if not (neg < 0 < pos and neg + pos == 0):
|
||||
raise BadRequestError("必须选择金额相抵的一张负数及一张正数更正单")
|
||||
target = facts.get(("GROUP", payload.target_group_key))
|
||||
if not target or str(target.data_json["customer_id"]) != str(
|
||||
negative.payload_json["idcustomer"]
|
||||
):
|
||||
raise BadRequestError("目标平账组必须属于负数更正单的原客户")
|
||||
cash = _records(session, source, "receipts", payload.receipt_ids)
|
||||
if any(
|
||||
str(r.payload_json.get("idpartner")) != str(positive.payload_json["idcustomer"])
|
||||
or r.payload_json.get("isReceiveFlag") != 1
|
||||
or money(r.payload_json.get("amount", "0")) <= 0
|
||||
for r in cash.values()
|
||||
):
|
||||
raise BadRequestError("实际回款须为新客户名下的正数收款单")
|
||||
pair_key = digest([source, negative.source_id, positive.source_id])
|
||||
proposed = []
|
||||
for head, peer in ((negative, positive), (positive, negative)):
|
||||
proposed.append(
|
||||
(
|
||||
f"SHIPMENT:{head.source_id}",
|
||||
{
|
||||
"entity": "SHIPMENT",
|
||||
"pair_key": pair_key,
|
||||
"exclude_performance": True,
|
||||
"paired_source_id": peer.source_id,
|
||||
"source_hash": head.row_hash,
|
||||
"paired_hash": peer.row_hash,
|
||||
"details_hash": _details_hash(session, source, head),
|
||||
},
|
||||
)
|
||||
)
|
||||
for row in cash.values():
|
||||
proposed.append(
|
||||
(
|
||||
f"RECEIPT:{row.source_id}",
|
||||
{
|
||||
"entity": "RECEIPT",
|
||||
"pair_key": pair_key,
|
||||
"exclude_performance": False,
|
||||
"redirect_business_key": payload.target_group_key,
|
||||
"source_hash": row.row_hash,
|
||||
},
|
||||
)
|
||||
)
|
||||
result = []
|
||||
for key, data in proposed:
|
||||
entity, source_id = key.split(":", 1)
|
||||
current = correction_fact(facts, entity, source_id)
|
||||
if current and current.data_json.get("pair_key") != pair_key:
|
||||
raise BadRequestError("该源单据已有其他更正配对,不能重复处理")
|
||||
# 已导入单据只能重放同一事实;修改需来源差异及当月调整处理。
|
||||
linked = session.scalar(
|
||||
select(TPlusSourceLink).where(
|
||||
TPlusSourceLink.source_database == source,
|
||||
TPlusSourceLink.entity_type
|
||||
== ("shipments" if entity == "SHIPMENT" else "receipts"),
|
||||
TPlusSourceLink.source_id == source_id,
|
||||
)
|
||||
)
|
||||
same = bool(current and all(current.data_json.get(k) == v for k, v in data.items()))
|
||||
if linked and not same:
|
||||
raise BadRequestError(
|
||||
"该单据已正式导入,请核对来源差异;已生成工资的差额使用当月调整单"
|
||||
)
|
||||
if same:
|
||||
result.append(current)
|
||||
continue
|
||||
fact = TPlusFact(
|
||||
source_database=source,
|
||||
kind="CORRECTION",
|
||||
fact_key=key,
|
||||
version=(current.version if current else 0) + 1,
|
||||
data_json=data,
|
||||
evidence=payload.evidence,
|
||||
requested_by=actor_id,
|
||||
)
|
||||
session.add(fact)
|
||||
result.append(fact)
|
||||
session.flush()
|
||||
session.commit()
|
||||
return {"pair_key": pair_key, "facts": [fact_out(f) for f in result]}
|
||||
except TPlusBusyError:
|
||||
session.rollback()
|
||||
raise BadRequestError("资料或任务正在更新,请稍后重试") from None
|
||||
except WorkbenchError:
|
||||
session.rollback()
|
||||
raise BadRequestError("请先完整采集更正发货、发货明细及收款数据") from None
|
||||
except Exception:
|
||||
session.rollback()
|
||||
raise
|
||||
@ -5,6 +5,7 @@ from decimal import ROUND_HALF_UP, Decimal
|
||||
from fractions import Fraction
|
||||
|
||||
from app.services import formula
|
||||
from app.services.personnel_roles import role_on
|
||||
from app.services.rate_rules import decide
|
||||
|
||||
CENT = Decimal("0.01")
|
||||
@ -55,6 +56,13 @@ def _beneficiary(shipment, receipt, facts, as_of):
|
||||
|
||||
def _calculate(shipment, receipt, matched, previously_matched, facts, params):
|
||||
reasons = list(shipment.get("pending", [])) + list(receipt.get("pending", []))
|
||||
if any(
|
||||
handover.get("business_key") == shipment.get("business_key")
|
||||
and handover.get("mode") == "ACTIVE"
|
||||
and not handover.get("shipping_ownership_confirmed")
|
||||
for handover in facts.get("handovers", [])
|
||||
):
|
||||
reasons.append("HANDOVER_SHIPMENTS_PENDING")
|
||||
effective = max(_day(shipment["date"]), _day(receipt["date"]))
|
||||
is_prepayment = _day(receipt["date"]) < _day(shipment["date"])
|
||||
if is_prepayment and not params.get("prepayment_parameter_basis"):
|
||||
@ -64,6 +72,8 @@ def _calculate(shipment, receipt, matched, previously_matched, facts, params):
|
||||
owner = _beneficiary(shipment, receipt, facts.get("handovers", []), facts["as_of"])
|
||||
person = facts.get("personnel", {}).get(owner)
|
||||
original_person = facts.get("personnel", {}).get(str(shipment.get("personnel_id")))
|
||||
role = role_on(person, effective)
|
||||
original_role = role_on(original_person, _day(shipment["date"]))
|
||||
if not person:
|
||||
reasons.append("PERSONNEL_PENDING")
|
||||
elif person.get("status") == 0:
|
||||
@ -76,6 +86,8 @@ def _calculate(shipment, receipt, matched, previously_matched, facts, params):
|
||||
)
|
||||
if not known_later_departure:
|
||||
reasons.append("HANDOVER_PENDING")
|
||||
if person and (role is None or original_role is None):
|
||||
reasons.append("PERSONNEL_ROLE_PENDING")
|
||||
cost = shipment.get("cost_total")
|
||||
if shipment.get("cost_month_basis") == "RECEIPT":
|
||||
cost = shipment.get("cost_by_month", {}).get(str(receipt["date"])[:7])
|
||||
@ -110,10 +122,10 @@ def _calculate(shipment, receipt, matched, previously_matched, facts, params):
|
||||
matched, formula.profit_rate(matched - allocated_cost, matched), threshold
|
||||
)
|
||||
days = formula.payment_days(_day(shipment["date"]), _day(receipt["date"]))
|
||||
manager_own = bool(original_person and original_person["role_code"] == "sales_manager")
|
||||
manager_own = original_role == "sales_manager"
|
||||
rate, basis, rule = decide(
|
||||
rate_params, category=category, customer_type=customer_type,
|
||||
role_code="sales_manager" if manager_own else person["role_code"],
|
||||
role_code="sales_manager" if manager_own else role,
|
||||
days=days, prepayment=is_prepayment,
|
||||
)
|
||||
calculation_base = matched if basis == "sales" else converted
|
||||
@ -121,6 +133,8 @@ def _calculate(shipment, receipt, matched, previously_matched, facts, params):
|
||||
"status": "READY",
|
||||
"reasons": [],
|
||||
"personnel_id": owner,
|
||||
"role_code": role,
|
||||
"original_role_code": original_role,
|
||||
"manager_id": shipment.get("manager_id"),
|
||||
"manager_pending": not bool(shipment.get("manager_id")),
|
||||
"effective_date": effective.isoformat(),
|
||||
@ -142,7 +156,7 @@ def _calculate(shipment, receipt, matched, previously_matched, facts, params):
|
||||
"calculation_base": amount_text(calculation_base),
|
||||
"basis": basis,
|
||||
"rule": rule,
|
||||
"excluded_manager_own": manager_own or person["role_code"] == "sales_manager",
|
||||
"excluded_manager_own": manager_own or role == "sales_manager",
|
||||
}
|
||||
|
||||
|
||||
|
||||
87
backend/app/services/tplus/guidance.py
Normal file
87
backend/app/services/tplus/guidance.py
Normal file
@ -0,0 +1,87 @@
|
||||
"""解析已登记的月度指导成本,不按名称或单位猜测。"""
|
||||
|
||||
from datetime import date
|
||||
from decimal import Decimal, InvalidOperation
|
||||
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from app.core.exceptions import BadRequestError
|
||||
from app.models import PayrollInput, TPlusFact
|
||||
|
||||
|
||||
def latest_guidance_facts(session, source):
|
||||
"""只读取指定账套每个指导成本事实键的最新版本。"""
|
||||
ids = select(func.max(TPlusFact.id)).where(
|
||||
TPlusFact.source_database == source, TPlusFact.kind == "GUIDANCE"
|
||||
).group_by(TPlusFact.fact_key)
|
||||
return {(fact.kind, fact.fact_key): fact for fact in session.scalars(select(TPlusFact).where(TPlusFact.id.in_(ids)))}
|
||||
|
||||
|
||||
def resolve_guidance(facts, product_id, line_id, month, unit):
|
||||
"""返回匹配状态和已封存事实;存在多条候选时不选择其中一条。"""
|
||||
candidates = [
|
||||
fact.data_json for (kind, _), fact in facts.items()
|
||||
if kind == "GUIDANCE"
|
||||
and fact.data_json.get("product_id") == product_id
|
||||
and fact.data_json.get("month") == month
|
||||
and (fact.data_json.get("basis") == "UNIT" or fact.data_json.get("shipment_line_id") == line_id)
|
||||
]
|
||||
if not candidates:
|
||||
return "MISSING", None
|
||||
if len(candidates) != 1:
|
||||
return "AMBIGUOUS", None
|
||||
guidance = candidates[0]
|
||||
if guidance.get("unit") != unit:
|
||||
return "UNIT_MISMATCH", None
|
||||
if not guidance.get("is_finalized"):
|
||||
return "UNFINALIZED", None
|
||||
return "FINALIZED", guidance
|
||||
|
||||
|
||||
def check_unit_guidance_conflicts(facts, product_id, month, unit, amount, frozen_shipments):
|
||||
"""新增产品月度单价前检查重叠事实和全部相关的历史封存明细。"""
|
||||
if any(
|
||||
kind == "GUIDANCE" and fact.data_json.get("product_id") == product_id
|
||||
and fact.data_json.get("month") == month
|
||||
for (kind, _), fact in facts.items()
|
||||
):
|
||||
raise BadRequestError("指导成本存在重叠记录,请先核对")
|
||||
|
||||
try:
|
||||
expected = Decimal(str(amount))
|
||||
if not expected.is_finite() or expected < 0:
|
||||
raise ValueError
|
||||
for shipment in frozen_shipments:
|
||||
old = Decimal(str(shipment.get("unit_cost")))
|
||||
if not old.is_finite() or old < 0 or old != expected or shipment.get("unit") != unit:
|
||||
raise ValueError
|
||||
except (InvalidOperation, TypeError, ValueError):
|
||||
raise BadRequestError("已封存发货的成本或单位与指导价不一致,请先核对") from None
|
||||
|
||||
|
||||
def frozen_guidance_shipments(session, source, product_id, month):
|
||||
"""按不可变快照中的来源产品读取历史,不能依赖当前正式明细。"""
|
||||
query = (
|
||||
select(PayrollInput.data_json)
|
||||
.where(PayrollInput.source_database == source)
|
||||
.order_by(PayrollInput.id)
|
||||
.execution_options(yield_per=100)
|
||||
)
|
||||
for data in session.scalars(query):
|
||||
shipments = data.get("shipments") if isinstance(data, dict) else None
|
||||
if not isinstance(shipments, list):
|
||||
raise BadRequestError("已封存发货数据异常,请先核对")
|
||||
for shipment in shipments:
|
||||
if not isinstance(shipment, dict) or not shipment.get("line_id"):
|
||||
raise BadRequestError("已封存发货数据异常,请先核对")
|
||||
try:
|
||||
ship_date = date.fromisoformat(shipment["date"])
|
||||
except (KeyError, TypeError, ValueError):
|
||||
raise BadRequestError("已封存发货日期异常,请先核对") from None
|
||||
if ship_date.strftime("%Y-%m") != month:
|
||||
continue
|
||||
frozen_product = shipment.get("product_id")
|
||||
if frozen_product is None or str(frozen_product).strip() in ("", "None"):
|
||||
raise BadRequestError("已封存发货缺少来源产品身份,请先核对")
|
||||
if str(frozen_product) == str(product_id):
|
||||
yield shipment
|
||||
@ -228,9 +228,12 @@ def _line(session, run, i, row, action=None, reason=None):
|
||||
source_id=row["source_id"],
|
||||
action=action or row["action"],
|
||||
reason=reason or row.get("reason"),
|
||||
data_json={
|
||||
k: v for k, v in row.items() if k not in ("entity_type", "action", "reason")
|
||||
},
|
||||
# 真实发货参照带 Decimal 数量、单价;JSON 留存使用十进制原文,
|
||||
# 与上方预览快照一致,不能转 float 或让预览任务在落明细时失败。
|
||||
data_json=json.loads(json.dumps(
|
||||
{k: v for k, v in row.items() if k not in ("entity_type", "action", "reason")},
|
||||
ensure_ascii=False, default=str,
|
||||
)),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@ -12,6 +12,10 @@ ACTION_LABELS = {
|
||||
"EXCLUDED": "不纳入本次预演",
|
||||
}
|
||||
REASON_LABELS = {
|
||||
"CORRECTION_PAIR_REQUIRED": "更正单尚未完成正负成对核对,请使用更正单处理入口",
|
||||
"CORRECTION_RECONFIRM_REQUIRED": "更正单或实际回款来源已变化,请重新核对更正对",
|
||||
"CORRECTION_EXCLUDED": "转客户正负更正单已成对排除,源单保留",
|
||||
"REDIRECTED_RECEIPT": "实际回款已归原业务,在本客户不重复计提",
|
||||
"CUSTOMER_SCOPE_CONFLICT": "同一客户存在不一致的期初或历史范围,请先合并为客户级核对资料",
|
||||
"GUIDANCE_FINALIZATION_REQUIRED": "发货月份指导成本尚未录入完毕并封存",
|
||||
"CUSTOMER_SCOPE_ALREADY_SELECTED": "该客户已纳入本次平账,重复选择已跳过",
|
||||
@ -19,6 +23,12 @@ REASON_LABELS = {
|
||||
"INDEPENDENT_BUSINESS_REQUIRED": "请先按独立业务核实归属,跨业务冲款边界尚待答复",
|
||||
"BUSINESS_DATA_PENDING": "该业务仍有资料待补,请查看对应核算预演",
|
||||
"FORMAL_SOURCE_CHANGED": "源单据或人工依据与正式导入版本不同,请先核对差异",
|
||||
"SOURCE_PRICE_REVIEW_REQUIRED": "已接入单价与来源未税单价不一致,请核对原单及正式明细",
|
||||
"PENDING_SOURCE_CUSTOMER_CHANGED": "待核对单据的来源客户已变化,原关联已保留,请核对原单",
|
||||
"PENDING_SOURCE_OUT_OF_RANGE": "待核对单据的来源日期已早于接入起点,原关联已保留,请核对原单",
|
||||
"LEGACY_BUSINESS_APPLY_DISABLED": "旧规则预览不能执行,请使用自动业务接入重新生成计划",
|
||||
"ORDER_SHIPMENT_MISMATCH": "发货关联的订单明细、订单号或产品不一致,请核对来源",
|
||||
"SOURCE_DOCUMENT_MISSING": "来源单据在最新完整快照中消失,已有记录已保留,请核对",
|
||||
"SOURCE_ALREADY_IMPORTED": "该源单据已经导入其他业务,不能重复导入",
|
||||
"CUSTOMER_IMPORT_REQUIRED": "请先完成该客户的基础导入",
|
||||
"PRODUCT_IMPORT_REQUIRED": "请先完成相关产品的基础导入",
|
||||
@ -30,7 +40,7 @@ REASON_LABELS = {
|
||||
"PRODUCT_CATEGORY_PENDING": "产品核算分类待补",
|
||||
"PRODUCT_UNIT_PENDING": "产品单位待核对",
|
||||
"NOT_CUSTOMER_ONLY": "往来单位不是仅客户性质",
|
||||
"CUSTOMER_ATTRIBUTE_PENDING": "客户属性或负责人待补",
|
||||
"CUSTOMER_ATTRIBUTE_PENDING": "客户核算分类待补",
|
||||
"TARGET_MISSING": "原导入目标已不存在",
|
||||
"TARGET_UNIQUE_CONFLICT": "与现有记录唯一键冲突",
|
||||
"DUPLICATE_SOURCE_IDENTITY": "源记录身份重复",
|
||||
@ -50,6 +60,7 @@ REASON_LABELS = {
|
||||
"PREPAYMENT_PARAMETERS_PENDING": "预收业务采用发货日还是收款日参数待确认",
|
||||
"AUGUST_RULE_PENDING": "8月调整参数待确认",
|
||||
"PERSONNEL_PENDING": "归属人员待确认",
|
||||
"PERSONNEL_ROLE_PENDING": "该业务发生月份的历史岗位待确认",
|
||||
"CUSTOMER_CATEGORY_PENDING": "客户分类待确认",
|
||||
"PARAMETERS_PENDING": "计提参数待补",
|
||||
"NEGATIVE_OR_ZERO_DOCUMENT": "负数或零金额单据需人工核对",
|
||||
|
||||
46
backend/app/services/tplus/runtime_control.py
Normal file
46
backend/app/services/tplus/runtime_control.py
Normal file
@ -0,0 +1,46 @@
|
||||
"""页面期望状态与托管进程心跳,二者分别更新。"""
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
from sqlalchemy import inspect, select, update
|
||||
|
||||
from app.core.exceptions import BadRequestError, ConflictError
|
||||
from app.models import TPlusRuntimeControl
|
||||
from app.services.authorization_service import require_permission
|
||||
from app.services.tplus import task_service
|
||||
from app.services.tplus.automation import local_now
|
||||
|
||||
|
||||
def overview(session, *, now=None):
|
||||
result = {"desired_state": "STOPPED", "actual_state": "OFFLINE", "worker_phase": "UNKNOWN",
|
||||
"heartbeat_at": None, "last_error": None, "host": None, "platform": None,
|
||||
"version": 0, "updated_by": None, "editable": False}
|
||||
if not inspect(session.connection()).has_table(TPlusRuntimeControl.__tablename__):
|
||||
return result
|
||||
row = session.scalar(select(TPlusRuntimeControl).where(TPlusRuntimeControl.id == 1)
|
||||
.execution_options(populate_existing=True))
|
||||
if row is None:
|
||||
return result
|
||||
result.update({key: getattr(row, key) for key in result if key != "editable"})
|
||||
result["editable"] = True
|
||||
if row.heartbeat_at is None or (now or local_now()) - row.heartbeat_at > timedelta(seconds=90):
|
||||
result["actual_state"] = "OFFLINE"
|
||||
result["worker_phase"] = "UNKNOWN"
|
||||
return result
|
||||
|
||||
|
||||
def save(session, payload, actor):
|
||||
require_permission(session, actor.user, "tplus:collect")
|
||||
if not inspect(session.connection()).has_table(TPlusRuntimeControl.__tablename__):
|
||||
raise BadRequestError("读取服务管理尚未升级,请联系维护人员")
|
||||
with task_service._audit(session, actor, "修改读取服务状态", payload.model_dump()):
|
||||
changed = session.execute(update(TPlusRuntimeControl).where(
|
||||
TPlusRuntimeControl.id == 1,
|
||||
TPlusRuntimeControl.version == payload.expected_version,
|
||||
).values(desired_state=payload.desired_state, version=payload.expected_version + 1,
|
||||
updated_by=actor.user.id))
|
||||
if changed.rowcount != 1:
|
||||
session.rollback()
|
||||
raise ConflictError()
|
||||
session.commit()
|
||||
return overview(session)
|
||||
226
backend/app/services/tplus/sales_relations.py
Normal file
226
backend/app/services/tplus/sales_relations.py
Normal file
@ -0,0 +1,226 @@
|
||||
"""来源订单与发货人工关系;仅追加结论,不修改来源或封存数据。"""
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from app.core.exceptions import BadRequestError
|
||||
from app.core.tplus_lock import TPlusBusyError, sync_lock
|
||||
from app.models import (
|
||||
Business,
|
||||
BusinessItem,
|
||||
Shipment,
|
||||
ShipmentItem,
|
||||
TPlusBusinessDocument,
|
||||
TPlusFact,
|
||||
)
|
||||
from app.services.audit_service import AuditRecord, audit
|
||||
from app.services.tplus.workbench import digest
|
||||
|
||||
KIND = "SALES_RELATION"
|
||||
|
||||
|
||||
def item_revision(item):
|
||||
return digest([item.id, item.business_id, item.product_id, item.product_name, item.spec_model,
|
||||
item.unit, str(item.quantity), str(item.sale_price), str(item.sale_amount)])
|
||||
|
||||
|
||||
def document_revision(doc):
|
||||
return [doc.id, doc.entity_type, doc.source_id, doc.record_id, doc.applied_record_id,
|
||||
doc.fingerprint, doc.status, doc.business_id, doc.target_id]
|
||||
|
||||
|
||||
def shipment_revision(doc, head, shipment, line):
|
||||
return digest([document_revision(doc), document_revision(head), shipment.id,
|
||||
shipment.business_id, shipment.ship_date.isoformat(), line.id, line.product_id,
|
||||
str(line.quantity), str(line.sale_price), str(line.amount)])
|
||||
|
||||
|
||||
def order_revision(doc, head, item):
|
||||
return digest([document_revision(doc), document_revision(head), item_revision(item)])
|
||||
|
||||
|
||||
def _head(session, doc, entity, target_id=None, *, lock=True):
|
||||
parent = doc.data_json.get("source_parent_id")
|
||||
query = select(TPlusBusinessDocument).where(
|
||||
TPlusBusinessDocument.source_database == doc.source_database,
|
||||
TPlusBusinessDocument.business_id == doc.business_id,
|
||||
TPlusBusinessDocument.entity_type == entity,
|
||||
TPlusBusinessDocument.status == "APPLIED",
|
||||
TPlusBusinessDocument.source_id == str(parent),
|
||||
)
|
||||
if target_id is not None:
|
||||
query = query.where(TPlusBusinessDocument.target_id == target_id)
|
||||
head = session.scalar(query.with_for_update() if lock else query)
|
||||
if head is None or head.record_id != head.applied_record_id:
|
||||
raise BadRequestError("来源单据已变化,请刷新后核对")
|
||||
return head
|
||||
|
||||
|
||||
def _applied_document(session, document_id, business_id, entity, source, *, lock=True):
|
||||
doc = session.get(TPlusBusinessDocument, document_id, with_for_update=lock)
|
||||
if (doc is None or doc.business_id != business_id or doc.source_database != source
|
||||
or doc.entity_type != entity or doc.status != "APPLIED"
|
||||
or doc.record_id != doc.applied_record_id):
|
||||
raise BadRequestError("来源明细已变化,请刷新后核对")
|
||||
return doc
|
||||
|
||||
|
||||
def _latest_business_facts(session, source, business_id):
|
||||
ids = select(func.max(TPlusFact.id)).where(
|
||||
TPlusFact.source_database == source, TPlusFact.kind == KIND,
|
||||
TPlusFact.fact_key.like(f"sales:{business_id}:%"),
|
||||
).group_by(TPlusFact.fact_key)
|
||||
return list(session.scalars(select(TPlusFact).where(TPlusFact.id.in_(ids))))
|
||||
|
||||
|
||||
def _fact_is_current(session, business, item, fact):
|
||||
"""任何来源身份、版本或业务明细变化均使结论失效。"""
|
||||
data = fact.data_json
|
||||
try:
|
||||
if (data["business_id"] != business.id or data["item_id"] != item.id
|
||||
or data["item_revision"] != item_revision(item)):
|
||||
return False
|
||||
source = business.integration_json["source_database"]
|
||||
doc = _applied_document(session, data["shipment_document_id"], business.id, "shipment_items", source, lock=False)
|
||||
line = session.get(ShipmentItem, doc.target_id)
|
||||
shipment = session.get(Shipment, line.shipment_id) if line else None
|
||||
if (line is None or shipment is None or shipment.business_id != business.id
|
||||
or line.product_id != item.product_id or line.quantity != item.quantity or line.amount != item.sale_amount):
|
||||
return False
|
||||
head = _head(session, doc, "shipments", shipment.id, lock=False)
|
||||
if shipment_revision(doc, head, shipment, line) != data["shipment_revision"]:
|
||||
return False
|
||||
if data["disposition"] == "INDEPENDENT":
|
||||
return data.get("order_document_id") is None and data.get("order_revision") is None
|
||||
if data["disposition"] != "ORDER":
|
||||
return False
|
||||
order = _applied_document(session, data["order_document_id"], business.id, "business_items", source, lock=False)
|
||||
order_item = session.get(BusinessItem, order.target_id)
|
||||
if order_item is None or order_item.business_id != business.id or order_item.product_id != item.product_id:
|
||||
return False
|
||||
order_head = _head(session, order, "businesses", lock=False)
|
||||
return order_revision(order, order_head, order_item) == data["order_revision"]
|
||||
except (BadRequestError, KeyError, TypeError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
def resolve_items(session, business, items, order_ids):
|
||||
"""返回待核对状态和可计入销售额的明细,不猜测来源关系。"""
|
||||
orders = set(order_ids)
|
||||
if not orders:
|
||||
return False, items
|
||||
extra = [item for item in items if item.id not in orders]
|
||||
if not extra:
|
||||
return False, items
|
||||
facts = {fact.fact_key: fact for fact in _latest_business_facts(
|
||||
session, business.integration_json["source_database"], business.id)}
|
||||
included = [item for item in items if item.id in orders]
|
||||
used = set()
|
||||
for item in extra:
|
||||
fact = facts.get(f"sales:{business.id}:{item.id}")
|
||||
if fact is None or not _fact_is_current(session, business, item, fact):
|
||||
return True, []
|
||||
doc_id = fact.data_json["shipment_document_id"]
|
||||
if doc_id in used:
|
||||
return True, []
|
||||
used.add(doc_id)
|
||||
if fact.data_json["disposition"] == "INDEPENDENT":
|
||||
included.append(item)
|
||||
return False, included
|
||||
|
||||
|
||||
def confirm(session, business_id, payload, operator, meta=None):
|
||||
"""绑定明确选择及版本;调用者须具备业务更新和核算准备权限。"""
|
||||
record = AuditRecord(module="业务管理", action="核对订单发货关系", summary=f"业务 #{business_id} 来源关系",
|
||||
request_data=payload.model_dump(mode="json"))
|
||||
try:
|
||||
with audit(session, operator=operator, meta=meta, record=record), sync_lock(session):
|
||||
business = session.get(Business, business_id, with_for_update=True)
|
||||
if business is None or not business.integration_json:
|
||||
raise BadRequestError("仅可核对来源业务")
|
||||
source = business.integration_json["source_database"]
|
||||
item = session.get(BusinessItem, payload.item_id, with_for_update=True)
|
||||
if item is None or item.business_id != business_id or item_revision(item) != payload.item_revision:
|
||||
raise BadRequestError("业务明细已变化,请刷新后核对")
|
||||
existing_order = session.scalar(select(TPlusBusinessDocument.id).where(
|
||||
TPlusBusinessDocument.business_id == business_id,
|
||||
TPlusBusinessDocument.entity_type == "business_items",
|
||||
TPlusBusinessDocument.target_id == item.id,
|
||||
).limit(1))
|
||||
if existing_order is not None:
|
||||
raise BadRequestError("订单明细不能作为待核对发货明细")
|
||||
doc = _applied_document(session, payload.shipment_document_id, business_id, "shipment_items", source)
|
||||
line = session.get(ShipmentItem, doc.target_id, with_for_update=True)
|
||||
shipment = session.get(Shipment, line.shipment_id, with_for_update=True) if line else None
|
||||
if (line is None or shipment is None or shipment.business_id != business_id
|
||||
or line.product_id != item.product_id or line.quantity != item.quantity or line.amount != item.sale_amount):
|
||||
raise BadRequestError("发货明细与业务明细不一致,请重新核对")
|
||||
head = _head(session, doc, "shipments", shipment.id)
|
||||
if shipment_revision(doc, head, shipment, line) != payload.shipment_revision:
|
||||
raise BadRequestError("来源发货已变化,请刷新后核对")
|
||||
order = order_head = order_item = None
|
||||
if payload.disposition == "ORDER":
|
||||
order = _applied_document(session, payload.order_document_id, business_id, "business_items", source)
|
||||
order_item = session.get(BusinessItem, order.target_id, with_for_update=True)
|
||||
if order_item is None or order_item.business_id != business_id or order_item.product_id != item.product_id:
|
||||
raise BadRequestError("订单明细不属于本业务或产品不一致")
|
||||
order_head = _head(session, order, "businesses")
|
||||
if order_revision(order, order_head, order_item) != payload.order_revision:
|
||||
raise BadRequestError("来源订单已变化,请刷新后核对")
|
||||
facts = _latest_business_facts(session, source, business_id)
|
||||
key = f"sales:{business_id}:{item.id}"
|
||||
current = next((fact for fact in facts if fact.fact_key == key), None)
|
||||
if (current.version if current else 0) != payload.expected_version:
|
||||
raise BadRequestError("核对结论已变化,请刷新后重试")
|
||||
if any(fact.fact_key != key and fact.data_json.get("disposition") != "RELEASED"
|
||||
and fact.data_json.get("shipment_document_id") == doc.id for fact in facts):
|
||||
raise BadRequestError("该发货明细已被其他业务明细确认")
|
||||
fact = TPlusFact(source_database=source, kind=KIND, fact_key=key,
|
||||
version=payload.expected_version + 1, evidence=payload.evidence, requested_by=operator.id,
|
||||
data_json={"business_id": business_id, "item_id": item.id,
|
||||
"shipment_document_id": doc.id, "order_document_id": order.id if order else None,
|
||||
"disposition": payload.disposition, "item_revision": payload.item_revision,
|
||||
"shipment_revision": payload.shipment_revision, "order_revision": payload.order_revision})
|
||||
session.add(fact)
|
||||
session.flush()
|
||||
from app.services.tplus.business_sync import _refresh_business_totals
|
||||
|
||||
_refresh_business_totals(session, business)
|
||||
session.commit()
|
||||
return {"item_id": item.id, "version": fact.version}
|
||||
except (TPlusBusyError, IntegrityError):
|
||||
session.rollback()
|
||||
raise BadRequestError("来源数据或核对结论正在更新,请稍后重试") from None
|
||||
|
||||
|
||||
def release(session, business_id, payload, operator, meta=None):
|
||||
"""追加撤销记录,释放发货占用,同时保留既往核对依据。"""
|
||||
record = AuditRecord(module="业务管理", action="撤销订单发货关系", summary=f"业务 #{business_id} 来源关系",
|
||||
request_data=payload.model_dump(mode="json"))
|
||||
try:
|
||||
with audit(session, operator=operator, meta=meta, record=record), sync_lock(session):
|
||||
business = session.get(Business, business_id, with_for_update=True)
|
||||
if business is None or not business.integration_json:
|
||||
raise BadRequestError("仅可核对来源业务")
|
||||
source = business.integration_json["source_database"]
|
||||
key = f"sales:{business_id}:{payload.item_id}"
|
||||
current = next((fact for fact in _latest_business_facts(session, source, business_id)
|
||||
if fact.fact_key == key), None)
|
||||
if current is None or current.version != payload.expected_version:
|
||||
raise BadRequestError("核对结论已变化,请刷新后重试")
|
||||
if current.data_json.get("disposition") == "RELEASED":
|
||||
raise BadRequestError("核对结论已撤销")
|
||||
fact = TPlusFact(source_database=source, kind=KIND, fact_key=key,
|
||||
version=current.version + 1, evidence=payload.evidence, requested_by=operator.id,
|
||||
data_json={"business_id": business_id, "item_id": payload.item_id,
|
||||
"disposition": "RELEASED"})
|
||||
session.add(fact)
|
||||
session.flush()
|
||||
from app.services.tplus.business_sync import _refresh_business_totals
|
||||
|
||||
_refresh_business_totals(session, business)
|
||||
session.commit()
|
||||
return {"item_id": payload.item_id, "version": fact.version}
|
||||
except (TPlusBusyError, IntegrityError):
|
||||
session.rollback()
|
||||
raise BadRequestError("来源数据或核对结论正在更新,请稍后重试") from None
|
||||
@ -6,8 +6,8 @@ from app.models import TPlusSourceHead, TPlusSyncBatch, TPlusSyncRecord
|
||||
from app.services.tplus.workbench import WorkbenchError
|
||||
|
||||
|
||||
def source_statement(session, source, entity):
|
||||
"""只发布最后一次完整成功读取;中断覆盖的指针不得部分发布。"""
|
||||
def published_batch(session, source, entity):
|
||||
"""返回实体最后发布的完整批次;更新失败时不使用不完整的指针。"""
|
||||
batches = session.scalars(
|
||||
select(TPlusSyncBatch)
|
||||
.where(
|
||||
@ -26,6 +26,19 @@ def source_statement(session, source, entity):
|
||||
break
|
||||
if batch_no is None:
|
||||
raise WorkbenchError("SOURCE_REFRESH_REQUIRED")
|
||||
return batch_no
|
||||
|
||||
|
||||
def source_manifest(session, source):
|
||||
"""核算与接入共同使用的完整来源批次清单,包含空实体。"""
|
||||
entities = ("personnel", "customers", "products", "businesses", "business_items",
|
||||
"shipments", "shipment_items", "receipts")
|
||||
return {entity: published_batch(session, source, entity) for entity in entities}
|
||||
|
||||
|
||||
def source_statement(session, source, entity):
|
||||
"""只发布最后一次完整成功读取;中断覆盖的指针不得部分发布。"""
|
||||
batch_no = published_batch(session, source, entity)
|
||||
return (
|
||||
select(TPlusSyncRecord)
|
||||
.join(TPlusSourceHead, TPlusSourceHead.record_id == TPlusSyncRecord.id)
|
||||
|
||||
@ -57,3 +57,9 @@ def observe_rows(session, batch, mapping, rows):
|
||||
session.add(head)
|
||||
current[row["source_id"]] = head
|
||||
head.record_id, head.batch_no = record_id, batch.batch_no
|
||||
raw = row["payload_json"]
|
||||
head.source_parent_id = str(raw["source_parent_id"]) if raw.get("source_parent_id") is not None else None
|
||||
customer = raw.get("idcustomer", raw.get("idpartner"))
|
||||
head.source_customer_id = str(customer) if customer is not None else None
|
||||
day = raw.get("voucherdate", raw.get("voucherDate"))
|
||||
head.source_date = str(day)[:10] if day is not None else None
|
||||
|
||||
186
backend/app/services/tplus/supervisor.py
Normal file
186
backend/app/services/tplus/supervisor.py
Normal file
@ -0,0 +1,186 @@
|
||||
"""跨平台托管循环;单次任务在独立线程执行,停止时等待其完成。"""
|
||||
|
||||
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
|
||||
@ -9,15 +9,15 @@ from sqlalchemy import func, select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import defer
|
||||
|
||||
from app.core.exceptions import BadRequestError, ForbiddenError, NotFoundError
|
||||
from app.core.exceptions import BadRequestError, ConflictError, ForbiddenError, NotFoundError
|
||||
from app.core.response import PageResult
|
||||
from app.core.tplus_lock import TPlusBusyError, sync_lock
|
||||
from app.models import TPlusFact, TPlusSyncRecord, TPlusWorkLine, TPlusWorkRun
|
||||
from app.models import TPlusFact, TPlusSyncBatch, TPlusSyncRecord, TPlusWorkLine, TPlusWorkRun
|
||||
from app.schemas.tplus_workbench import WorkCreateIn
|
||||
from app.services.tplus.report_labels import ACTION_LABELS, reason_label, spreadsheet_value
|
||||
from app.services.tplus.task_service import _source_database, history_source_database, new_batch_no
|
||||
|
||||
RULE_VERSION = "2026-10-30.2"
|
||||
RULE_VERSION = "2026-11-03.2"
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@ -58,13 +58,27 @@ 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()
|
||||
current = latest_facts(session, source).get((payload.kind, payload.fact_key))
|
||||
facts = latest_facts(session, source)
|
||||
current = facts.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(
|
||||
@ -134,8 +148,7 @@ def queue_work(session, payload: WorkCreateIn, actor_id):
|
||||
values = payload.model_dump(mode="json", exclude={"request_key"})
|
||||
source = _work_source_database(session)
|
||||
key = str(payload.request_key)
|
||||
found = session.scalar(select(TPlusWorkRun).where(TPlusWorkRun.request_key == key))
|
||||
if found:
|
||||
def existing_run(found):
|
||||
if (
|
||||
found.input_json != values
|
||||
or found.source_database != source
|
||||
@ -143,14 +156,15 @@ def queue_work(session, payload: WorkCreateIn, actor_id):
|
||||
):
|
||||
raise BadRequestError("请求键已用于不同任务")
|
||||
return work_out(found)
|
||||
found = session.scalar(select(TPlusWorkRun).where(TPlusWorkRun.request_key == key))
|
||||
if found:
|
||||
return existing_run(found)
|
||||
if payload.kind == "MASTER_APPLY":
|
||||
preview = get_run(session, payload.preview_no, source)
|
||||
if preview.kind != "MASTER_PREVIEW" or preview.status not in ("SUCCEEDED", "PARTIAL"):
|
||||
raise BadRequestError("必须选择已完成的基础导入预览")
|
||||
if payload.kind == "BUSINESS_APPLY":
|
||||
preview = get_run(session, payload.preview_no, source)
|
||||
if preview.kind != "BUSINESS_PREVIEW" or preview.status not in ("SUCCEEDED", "PARTIAL"):
|
||||
raise BadRequestError("必须选择已完成的正式业务导入预览")
|
||||
raise BadRequestError("旧规则业务预览仅供核对,请使用自动业务接入重新生成计划")
|
||||
if payload.replay_no:
|
||||
previous = get_run(session, payload.replay_no, source)
|
||||
if previous.kind != "CALC_PREVIEW" or previous.status not in ("SUCCEEDED", "PARTIAL"):
|
||||
@ -171,7 +185,10 @@ def queue_work(session, payload: WorkCreateIn, actor_id):
|
||||
session.commit()
|
||||
except IntegrityError:
|
||||
session.rollback()
|
||||
return queue_work(session, payload, actor_id)
|
||||
found = session.scalar(select(TPlusWorkRun).where(TPlusWorkRun.request_key == key))
|
||||
if found is None:
|
||||
raise ConflictError() from None
|
||||
return existing_run(found)
|
||||
return work_out(run)
|
||||
|
||||
|
||||
@ -225,19 +242,63 @@ def list_work(session, query):
|
||||
)
|
||||
|
||||
|
||||
def _display_data(session, run, rows):
|
||||
"""按原记录版本补齐基础档案展示字段;不改历史结果或使用最新源数据。"""
|
||||
if run.kind not in ("MASTER_PREVIEW", "MASTER_APPLY"):
|
||||
return [row.data_json for row in rows]
|
||||
record_ids = {row.data_json.get("record_id") for row in rows} - {None}
|
||||
records = {
|
||||
record.id: record
|
||||
for record in session.scalars(
|
||||
select(TPlusSyncRecord)
|
||||
.join(TPlusSyncBatch, TPlusSyncBatch.batch_no == TPlusSyncRecord.batch_no)
|
||||
.where(
|
||||
TPlusSyncRecord.id.in_(record_ids),
|
||||
TPlusSyncBatch.source_database == run.source_database,
|
||||
)
|
||||
)
|
||||
} if record_ids else {}
|
||||
roster = {}
|
||||
if any(row.reason == "ROSTER_SOURCE_MISSING" for row in rows):
|
||||
roster = {
|
||||
fact["key"]: fact["data"]
|
||||
for fact in (run.snapshot_json or {}).get("facts", [])
|
||||
if fact.get("kind") == "PERSONNEL"
|
||||
}
|
||||
result = []
|
||||
for row in rows:
|
||||
data = dict(row.data_json)
|
||||
record = records.get(data.get("record_id"))
|
||||
if record and record.entity_type == row.entity_type and record.source_id == row.source_id:
|
||||
source = record.payload_json
|
||||
fields = {"source_name": source.get("name"), "source_no": record.source_no}
|
||||
if row.entity_type == "products":
|
||||
fields.update(source_specification=source.get("specification"),
|
||||
source_unit=source.get("unit_name"))
|
||||
for key, value in fields.items():
|
||||
if data.get(key) is None and value is not None:
|
||||
data[key] = value
|
||||
elif row.reason == "ROSTER_SOURCE_MISSING":
|
||||
person = roster.get(row.source_id, {})
|
||||
data.setdefault("source_name", person.get("name"))
|
||||
data.setdefault("source_no", row.source_id)
|
||||
result.append(data)
|
||||
return result
|
||||
|
||||
|
||||
def list_lines(session, number, query):
|
||||
get_run(session, number)
|
||||
run = get_run(session, number)
|
||||
conditions = [TPlusWorkLine.run_no == number]
|
||||
if query.action:
|
||||
conditions.append(TPlusWorkLine.action == query.action)
|
||||
total = session.scalar(select(func.count()).select_from(TPlusWorkLine).where(*conditions))
|
||||
rows = session.scalars(
|
||||
rows = list(session.scalars(
|
||||
select(TPlusWorkLine)
|
||||
.where(*conditions)
|
||||
.order_by(TPlusWorkLine.line_no)
|
||||
.offset(query.offset)
|
||||
.limit(query.page_size)
|
||||
)
|
||||
))
|
||||
return PageResult(
|
||||
records=[
|
||||
{
|
||||
@ -246,9 +307,9 @@ def list_lines(session, number, query):
|
||||
"source_id": r.source_id,
|
||||
"action": r.action,
|
||||
"reason": r.reason,
|
||||
"data": r.data_json,
|
||||
"data": data,
|
||||
}
|
||||
for r in rows
|
||||
for r, data in zip(rows, _display_data(session, run, rows), strict=True)
|
||||
],
|
||||
total=total or 0,
|
||||
page_num=query.page_num,
|
||||
@ -319,7 +380,7 @@ def report_csv(session, run_no):
|
||||
import csv
|
||||
import io
|
||||
|
||||
get_run(session, run_no)
|
||||
run = get_run(session, run_no)
|
||||
|
||||
def rows():
|
||||
buffer = io.StringIO()
|
||||
@ -358,55 +419,59 @@ def report_csv(session, run_no):
|
||||
.where(TPlusWorkLine.run_no == run_no)
|
||||
.order_by(TPlusWorkLine.line_no)
|
||||
)
|
||||
for row in session.scalars(statement).yield_per(200):
|
||||
data = row.data_json
|
||||
values = data.get("values") or {}
|
||||
fields = [
|
||||
run_no,
|
||||
row.line_no,
|
||||
row.entity_type,
|
||||
row.source_id,
|
||||
ACTION_LABELS.get(row.action, row.action),
|
||||
data.get("source_no", ""),
|
||||
values.get("personnel_name")
|
||||
or values.get("product_name")
|
||||
or values.get("company_name")
|
||||
or data.get("source_name")
|
||||
or "",
|
||||
*[
|
||||
data.get(k, "")
|
||||
for k in (
|
||||
"receipt_no",
|
||||
"accounting_voucher_code",
|
||||
"shipment_no",
|
||||
"amount",
|
||||
"shipment_remaining_after",
|
||||
"days",
|
||||
"allocated_cost",
|
||||
"converted_profit",
|
||||
"profit_threshold",
|
||||
"rate_percent",
|
||||
"performance",
|
||||
"personnel_id",
|
||||
)
|
||||
],
|
||||
reason_label(row.reason)
|
||||
or ("经理本人业务,已排除绩效汇总" if data.get("excluded_manager_own") else ""),
|
||||
"仅供核对,未写入正式工资",
|
||||
]
|
||||
writer.writerow([spreadsheet_value(v) for v in fields])
|
||||
yield buffer.getvalue()
|
||||
buffer.seek(0)
|
||||
buffer.truncate(0)
|
||||
for page in session.scalars(statement).partitions(200):
|
||||
for row, data in zip(page, _display_data(session, run, page), strict=True):
|
||||
values = data.get("values") or {}
|
||||
fields = [
|
||||
run_no,
|
||||
row.line_no,
|
||||
row.entity_type,
|
||||
row.source_id,
|
||||
ACTION_LABELS.get(row.action, row.action),
|
||||
data.get("source_no", ""),
|
||||
values.get("personnel_name")
|
||||
or values.get("product_name")
|
||||
or values.get("company_name")
|
||||
or data.get("source_name")
|
||||
or "",
|
||||
*[
|
||||
data.get(k, "")
|
||||
for k in (
|
||||
"receipt_no",
|
||||
"accounting_voucher_code",
|
||||
"shipment_no",
|
||||
"amount",
|
||||
"shipment_remaining_after",
|
||||
"days",
|
||||
"allocated_cost",
|
||||
"converted_profit",
|
||||
"profit_threshold",
|
||||
"rate_percent",
|
||||
"performance",
|
||||
"personnel_id",
|
||||
)
|
||||
],
|
||||
reason_label(row.reason)
|
||||
or ("已导入且数据无变化" if run.kind in ("MASTER_PREVIEW", "MASTER_APPLY") and row.action == "SKIP"
|
||||
else "经理本人业务,已排除绩效汇总" if data.get("excluded_manager_own") else ""),
|
||||
"仅供核对,未写入正式工资",
|
||||
]
|
||||
writer.writerow([spreadsheet_value(v) for v in fields])
|
||||
yield buffer.getvalue()
|
||||
buffer.seek(0)
|
||||
buffer.truncate(0)
|
||||
|
||||
return rows()
|
||||
|
||||
|
||||
def execute_next(session):
|
||||
def execute_next(session, *, may_claim=None):
|
||||
"""调用方持有原有全局锁;中断任务保留明细,禁止不明状态自动应用。"""
|
||||
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")
|
||||
@ -426,8 +491,16 @@ def execute_next(session):
|
||||
from app.services.authorization_service import require_permission
|
||||
|
||||
require_permission(session, session.get(User, run.requested_by),
|
||||
"tplus:import" if run.kind.endswith("_APPLY") else "tplus:preview")
|
||||
if run.kind.startswith("BUSINESS_"):
|
||||
"tplus:import" if run.kind.endswith("_APPLY") or run.kind == "BUSINESS_SYNC" else "tplus:preview")
|
||||
if run.kind == "BUSINESS_SYNC":
|
||||
from app.services.tplus.business_sync import execute_sync
|
||||
from app.services.tplus.business_sync_control import clear_execution_failure
|
||||
|
||||
execute_sync(session, run)
|
||||
clear_execution_failure(session, run.source_database, run.requested_by)
|
||||
elif run.kind == "BUSINESS_APPLY":
|
||||
raise WorkbenchError("LEGACY_BUSINESS_APPLY_DISABLED")
|
||||
elif run.kind.startswith("BUSINESS_"):
|
||||
from app.services.tplus.business_import import execute_business
|
||||
|
||||
execute_business(session, run)
|
||||
@ -447,6 +520,10 @@ def execute_next(session):
|
||||
run.status, run.finished_at = "FAILED", datetime.now()
|
||||
# 不让驱动异常、SQL 或原始数据进入响应。
|
||||
run.error_code = "TASK_AUTHORIZATION_REVOKED" if isinstance(exc, ForbiddenError) else str(exc) if isinstance(exc, WorkbenchError) else "EXECUTION_ERROR"
|
||||
if run.kind == "BUSINESS_SYNC" and isinstance(exc, ForbiddenError):
|
||||
from app.services.tplus.business_sync_control import record_authorization_failure
|
||||
|
||||
record_authorization_failure(session, run.source_database, run.requested_by)
|
||||
logger.error(
|
||||
"工作区任务失败 | run_no=%s | exception_type=%s", run.run_no, type(exc).__name__
|
||||
)
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
"""持久化任务执行者;获取连接级锁后才恢复或领取任务。"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
@ -12,8 +13,10 @@ from app.services.tplus.errors import classify_source_error
|
||||
from app.services.tplus.relationship_audit import TPlusRelationshipAudit
|
||||
from app.services.tplus.sync_service import TPlusSyncService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def run_once(session, source_factory=None, *, worker_owner=None):
|
||||
|
||||
def run_once(session, source_factory=None, *, worker_owner=None, may_claim=None):
|
||||
"""返回本轮处理的批次号;忙锁或队列为空时不假称已执行。"""
|
||||
try:
|
||||
with sync_lock(session), WorkerHeartbeat(session, owner=worker_owner):
|
||||
@ -23,10 +26,13 @@ def run_once(session, source_factory=None, *, worker_owner=None):
|
||||
from app.services.tplus.workbench import execute_next
|
||||
|
||||
if inspect(session.connection()).has_table(TPlusWorkRun.__tablename__):
|
||||
number = execute_next(session)
|
||||
number = execute_next(session, may_claim=may_claim) if may_claim else execute_next(session)
|
||||
if number:
|
||||
return number
|
||||
task_service.recover_running(session)
|
||||
if may_claim is not None and not may_claim(session):
|
||||
session.rollback()
|
||||
return None
|
||||
batch = repository.next_queued(session)
|
||||
if batch is None:
|
||||
session.rollback()
|
||||
@ -67,6 +73,22 @@ def _execute(session, batch, source_factory):
|
||||
session.commit()
|
||||
else:
|
||||
TPlusSyncService(source, session).execute_claimed(batch)
|
||||
if batch.status == "SUCCEEDED":
|
||||
from app.services.tplus.business_sync_control import (
|
||||
record_schedule_failure,
|
||||
schedule,
|
||||
)
|
||||
|
||||
try:
|
||||
schedule(session)
|
||||
except Exception:
|
||||
session.rollback()
|
||||
logger.error("T+ 采集已完成,但业务自动接入排队失败")
|
||||
try:
|
||||
record_schedule_failure(session, batch.source_database)
|
||||
except Exception:
|
||||
session.rollback()
|
||||
logger.error("无法记录 T+ 业务自动接入排队异常")
|
||||
except BaseException as exc:
|
||||
session.rollback()
|
||||
task_service.fail_batch(
|
||||
|
||||
@ -393,3 +393,15 @@ 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 元结果认定人员整月工资为零。
|
||||
|
||||
@ -390,9 +390,11 @@ 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
Loading…
Reference in New Issue
Block a user