Compare commits
6 Commits
823d9f02ec
...
ea41dadb64
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ea41dadb64 | ||
|
|
8242aae454 | ||
|
|
87a0884953 | ||
|
|
ea989ce9ad | ||
|
|
4fe5916c2c | ||
|
|
78bef43c06 |
4
.gitignore
vendored
4
.gitignore
vendored
@ -1,5 +1,5 @@
|
||||
# AI辅助目录(整个目录忽略)
|
||||
ai-aux/
|
||||
.ai/
|
||||
|
||||
# 依赖
|
||||
node_modules/
|
||||
@ -65,6 +65,8 @@ CLAUDE.md
|
||||
backend/uploads/
|
||||
# 数据库备份导出目录(⑨ 备份脚本产物)
|
||||
db/backups/
|
||||
# 客户发来的原始账套备份压缩包。
|
||||
/db/*.zip
|
||||
|
||||
# 外部参考资料(2026-10-20):T+12.3 数据字典压缩包与解压目录不入版本库
|
||||
docs/**/*.rar
|
||||
|
||||
@ -26,6 +26,20 @@ LOGIN_FAIL_LIMIT=5
|
||||
LOGIN_LOCK_MINUTES=15
|
||||
|
||||
# ===== T+ 对接(待畅捷通确认后填写)=====
|
||||
T_PLUS_BASE_URL=
|
||||
T_PLUS_APP_KEY=
|
||||
T_PLUS_APP_SECRET=
|
||||
T_PLUS_ENABLED=false
|
||||
T_PLUS_HOST=127.0.0.1
|
||||
T_PLUS_PORT=1433
|
||||
T_PLUS_DATABASE=
|
||||
T_PLUS_USER=
|
||||
T_PLUS_PASSWORD=
|
||||
# 可显式指定项目外的只读连接 JSON;不从页面接收该路径。
|
||||
T_PLUS_CONNECTION_FILE=
|
||||
# 必须填写在客户 SQL Server 2008 R2 上验证过的驱动名称。
|
||||
# 本地 PoC 可复用已安装的 SQL Server 驱动;不自动安装或升级客户驱动。
|
||||
T_PLUS_ODBC_DRIVER=
|
||||
T_PLUS_ENCRYPT=false
|
||||
T_PLUS_TRUST_SERVER_CERTIFICATE=true
|
||||
T_PLUS_CONNECT_TIMEOUT=10
|
||||
T_PLUS_QUERY_TIMEOUT=120
|
||||
# 可选:留空时使用仓库根目录下的 db/queries/tplus/
|
||||
T_PLUS_QUERY_DIR=
|
||||
|
||||
@ -140,6 +140,19 @@ app/
|
||||
(`warning.enabled` / `warning.receipt.overdue.days` / `warning.approval.timeout.days`,
|
||||
V10 种子);⑥ T+ 同步未建,同步相关条目以 `sync_available=false` 显式降级。
|
||||
|
||||
### T+ 本地只读同步与管理
|
||||
|
||||
T+ 数据库是 SQL Server 只读源库,业务库仍为 MySQL。Docker 恢复 PoC、备份检查和 D 盘目录约定见 `docs/T+本地恢复与同步开发手册.md`。
|
||||
|
||||
- 连接配置在 `backend/.env` 的 `T_PLUS_*` 变量中,默认关闭,不配置时不影响主业务启动。
|
||||
- `uv run python -m app.cli tplus-stage --entity personnel` 按主键分页,将源行写入 `tplus_sync_records` staging,并记录批次和失败信息。水位表已预留,时间增量未启用,`--watermark` 会明确拒绝。
|
||||
- T+ 驱动通过 `uv sync --extra tplus` 安装到应用环境,ODBC 驱动名称必须显式配置;交付兼容目标为 SQL Server 2008 R2,2022 副本不能代替同版本验收。
|
||||
- 查询目录 `db/queries/tplus/` 随部署交付,或使用 `T_PLUS_QUERY_DIR` 指定受控位置;支持六类主表和三类明细。
|
||||
- T+ 字典中的候选字段在实际备份核验和客户确认前,不得直接覆盖岗位、工资标准、归属、业务状态或核算状态。
|
||||
- 管理 API `/api/v1/system/tplus` 全部限管理员:概况、批次/异常分页、按需报告、创建与失败重试。HTTP 只创建 QUEUED 任务;`uv run python -m app.cli tplus-worker --watch --connection-file <项目外JSON路径>` 独立处理。
|
||||
- 核心文件:`services/tplus/task_service.py`(幂等/重试)、`worker.py`(执行)、`relationship_audit.py`(字典与关联)、`query_service.py`(安全输出)、`core/tplus_lock.py`(跨提交执行锁)。原 CLI 读取与 worker 共用执行锁。
|
||||
- V15 / Alembic `e5f607a8b9c0` 增加范围、跳过数、请求键、父批次、请求人和核验结果。未启用时间增量与工资业务导入;完整运行步骤和异常处理见本地恢复手册。
|
||||
|
||||
## 常用命令
|
||||
|
||||
```bash
|
||||
|
||||
122
backend/alembic/versions/d4e5f6a7b8c9_tplus_sync_staging.py
Normal file
122
backend/alembic/versions/d4e5f6a7b8c9_tplus_sync_staging.py
Normal file
@ -0,0 +1,122 @@
|
||||
"""T+ 只读同步批次、错误、staging 和增量水位。
|
||||
|
||||
Revision ID: d4e5f6a7b8c9
|
||||
Revises: c3a9e1d7b2f4
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.mysql import BIGINT
|
||||
|
||||
revision: str = "d4e5f6a7b8c9"
|
||||
down_revision: str | Sequence[str] | None = "c3a9e1d7b2f4"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""增加 T+ 源更新时间和同步审计/staging 表。"""
|
||||
for table in ("personnel", "products", "customers", "businesses", "shipments", "receipts"):
|
||||
op.execute(
|
||||
f"ALTER TABLE {table} ADD COLUMN source_updated_at DATETIME "
|
||||
"COMMENT 'T+ 源数据更新时间(未确认时可为空)' AFTER synced_at"
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"tplus_sync_batches",
|
||||
sa.Column("id", BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"),
|
||||
sa.Column("batch_no", sa.String(length=32), nullable=False, comment="同步批次号"),
|
||||
sa.Column("sync_mode", sa.String(length=16), nullable=False, comment="同步模式:FULL全量 INCREMENTAL增量"),
|
||||
sa.Column("status", sa.String(length=16), nullable=False, comment="状态:RUNNING/SUCCEEDED/PARTIAL/FAILED"),
|
||||
sa.Column("source_database", sa.String(length=128), nullable=True, comment="T+ 源数据库名"),
|
||||
sa.Column("started_at", sa.DateTime(), nullable=False, comment="开始读取时间"),
|
||||
sa.Column("finished_at", sa.DateTime(), nullable=True, comment="结束时间"),
|
||||
sa.Column("watermark_from", sa.DateTime(), nullable=True, comment="增量起始水位"),
|
||||
sa.Column("watermark_to", sa.DateTime(), nullable=True, comment="本批次结束水位"),
|
||||
sa.Column("source_record_count", sa.Integer(), nullable=False, server_default="0", comment="源记录数"),
|
||||
sa.Column("staged_record_count", sa.Integer(), nullable=False, server_default="0", comment="写入 staging 的记录数"),
|
||||
sa.Column("failed_record_count", sa.Integer(), nullable=False, server_default="0", comment="失败记录数"),
|
||||
sa.Column("error_message", sa.Text(), nullable=True, comment="批次错误摘要"),
|
||||
sa.Column("create_time", sa.DateTime(), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP"), comment="创建时间"),
|
||||
sa.Column("update_time", sa.DateTime(), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"), comment="更新时间"),
|
||||
sa.PrimaryKeyConstraint("id", name="pk_tplus_sync_batches"),
|
||||
sa.UniqueConstraint("batch_no", name="uk_tplus_sync_batches_batch_no"),
|
||||
mysql_engine="InnoDB", mysql_charset="utf8mb4", mysql_collate="utf8mb4_general_ci", comment="T+ 同步批次表(记录读取范围、结果和对账统计)",
|
||||
)
|
||||
op.create_index("idx_tplus_sync_batches_status", "tplus_sync_batches", ["status"])
|
||||
op.create_index("idx_tplus_sync_batches_started_at", "tplus_sync_batches", ["started_at"])
|
||||
|
||||
op.create_table(
|
||||
"tplus_sync_errors",
|
||||
sa.Column("id", BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"),
|
||||
sa.Column("batch_no", sa.String(length=32), nullable=False, comment="同步批次号"),
|
||||
sa.Column("entity_type", sa.String(length=32), nullable=False, comment="业务实体类型"),
|
||||
sa.Column("source_table", sa.String(length=128), nullable=False, comment="T+ 源表"),
|
||||
sa.Column("source_id", sa.String(length=64), nullable=True, comment="T+ 源主键"),
|
||||
sa.Column("source_line_no", sa.String(length=64), nullable=True, comment="T+ 明细行号或明细主键"),
|
||||
sa.Column("error_code", sa.String(length=32), nullable=False, comment="错误编码"),
|
||||
sa.Column("error_message", sa.Text(), nullable=False, comment="错误说明"),
|
||||
sa.Column("retry_count", sa.Integer(), nullable=False, server_default="0", comment="已重试次数"),
|
||||
sa.Column("create_time", sa.DateTime(), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP"), comment="创建时间"),
|
||||
sa.Column("update_time", sa.DateTime(), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"), comment="更新时间"),
|
||||
sa.PrimaryKeyConstraint("id", name="pk_tplus_sync_errors"),
|
||||
mysql_engine="InnoDB", mysql_charset="utf8mb4", mysql_collate="utf8mb4_general_ci", comment="T+ 同步错误表(记录失败行和重试信息)",
|
||||
)
|
||||
op.create_index("idx_tplus_sync_errors_batch_no", "tplus_sync_errors", ["batch_no"])
|
||||
op.create_index("idx_tplus_sync_errors_entity_type", "tplus_sync_errors", ["entity_type"])
|
||||
|
||||
op.create_table(
|
||||
"tplus_sync_records",
|
||||
sa.Column("id", BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"),
|
||||
sa.Column("batch_no", sa.String(length=32), nullable=False, comment="同步批次号"),
|
||||
sa.Column("entity_type", sa.String(length=32), nullable=False, comment="业务实体类型"),
|
||||
sa.Column("source_table", sa.String(length=128), nullable=False, comment="T+ 源表"),
|
||||
sa.Column("source_id", sa.String(length=64), nullable=False, comment="T+ 源主键"),
|
||||
sa.Column("source_no", sa.String(length=64), nullable=True, comment="T+ 原始编号"),
|
||||
sa.Column("source_line_no", sa.String(length=64), nullable=True, comment="T+ 明细行号或明细主键"),
|
||||
sa.Column("source_updated_at", sa.DateTime(), nullable=True, comment="T+ 源数据更新时间"),
|
||||
sa.Column("row_hash", sa.String(length=64), nullable=False, comment="原始行 SHA-256"),
|
||||
sa.Column("state", sa.String(length=16), nullable=False, server_default="PENDING", comment="状态:PENDING/APPLIED/SKIPPED/ERROR"),
|
||||
sa.Column("payload_json", sa.JSON(), nullable=False, comment="原始行 JSON"),
|
||||
sa.Column("create_time", sa.DateTime(), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP"), comment="创建时间"),
|
||||
sa.Column("update_time", sa.DateTime(), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"), comment="更新时间"),
|
||||
sa.PrimaryKeyConstraint("id", name="pk_tplus_sync_records"),
|
||||
sa.UniqueConstraint("entity_type", "source_id", "row_hash", name="uk_tplus_sync_records_entity_source_hash"),
|
||||
mysql_engine="InnoDB", mysql_charset="utf8mb4", mysql_collate="utf8mb4_general_ci", comment="T+ 原始同步 staging 表(映射确认前只保存原始行,不直接改业务表)",
|
||||
)
|
||||
op.create_index("idx_tplus_sync_records_batch_no", "tplus_sync_records", ["batch_no"])
|
||||
op.create_index("idx_tplus_sync_records_source_no", "tplus_sync_records", ["source_no"])
|
||||
op.create_index("idx_tplus_sync_records_state", "tplus_sync_records", ["state"])
|
||||
|
||||
op.create_table(
|
||||
"tplus_sync_watermarks",
|
||||
sa.Column("id", BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"),
|
||||
sa.Column("entity_type", sa.String(length=32), nullable=False, comment="业务实体类型"),
|
||||
sa.Column("last_source_updated_at", sa.DateTime(), nullable=True, comment="最近源更新时间"),
|
||||
sa.Column("last_source_id", sa.String(length=64), nullable=True, comment="同一时间点的最后源主键"),
|
||||
sa.Column("status", sa.String(length=16), nullable=False, server_default="READY", comment="状态:READY/BLOCKED"),
|
||||
sa.Column("create_time", sa.DateTime(), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP"), comment="创建时间"),
|
||||
sa.Column("update_time", sa.DateTime(), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"), comment="更新时间"),
|
||||
sa.PrimaryKeyConstraint("id", name="pk_tplus_sync_watermarks"),
|
||||
sa.UniqueConstraint("entity_type", name="uk_tplus_sync_watermarks_entity_type"),
|
||||
mysql_engine="InnoDB", mysql_charset="utf8mb4", mysql_collate="utf8mb4_general_ci", comment="T+ 增量同步水位表",
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""删除 T+ 同步支撑表和源更新时间列。"""
|
||||
op.drop_table("tplus_sync_watermarks")
|
||||
op.drop_index("idx_tplus_sync_records_state", table_name="tplus_sync_records")
|
||||
op.drop_index("idx_tplus_sync_records_source_no", table_name="tplus_sync_records")
|
||||
op.drop_index("idx_tplus_sync_records_batch_no", table_name="tplus_sync_records")
|
||||
op.drop_table("tplus_sync_records")
|
||||
op.drop_index("idx_tplus_sync_errors_entity_type", table_name="tplus_sync_errors")
|
||||
op.drop_index("idx_tplus_sync_errors_batch_no", table_name="tplus_sync_errors")
|
||||
op.drop_table("tplus_sync_errors")
|
||||
op.drop_index("idx_tplus_sync_batches_started_at", table_name="tplus_sync_batches")
|
||||
op.drop_index("idx_tplus_sync_batches_status", table_name="tplus_sync_batches")
|
||||
op.drop_table("tplus_sync_batches")
|
||||
for table in ("receipts", "shipments", "businesses", "customers", "products", "personnel"):
|
||||
op.drop_column(table, "source_updated_at")
|
||||
118
backend/alembic/versions/e5f607a8b9c0_tplus_task_management.py
Normal file
118
backend/alembic/versions/e5f607a8b9c0_tplus_task_management.py
Normal file
@ -0,0 +1,118 @@
|
||||
"""V15 T+ 持久化任务、幂等和重试元数据。"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.mysql import BIGINT
|
||||
|
||||
revision: str = "e5f607a8b9c0"
|
||||
down_revision: str | Sequence[str] | None = "d4e5f6a7b8c9"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""仅扩展管理元数据,不改原始暂存行或工资业务表。"""
|
||||
op.add_column(
|
||||
"tplus_sync_batches",
|
||||
sa.Column("entity_types_json", sa.JSON(), comment="请求读取的实体类型;历史批次可为空"),
|
||||
)
|
||||
op.add_column(
|
||||
"tplus_sync_batches",
|
||||
sa.Column(
|
||||
"skipped_record_count",
|
||||
sa.Integer(),
|
||||
nullable=False,
|
||||
server_default="0",
|
||||
comment="重复快照跳过条数",
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
"tplus_sync_batches",
|
||||
sa.Column("parent_batch_no", sa.String(32), comment="重试来源批次号,保留原批次记录"),
|
||||
)
|
||||
op.add_column(
|
||||
"tplus_sync_batches",
|
||||
sa.Column("request_key", sa.String(36), comment="请求幂等键,空值用于历史和直接CLI任务"),
|
||||
)
|
||||
op.add_column(
|
||||
"tplus_sync_batches",
|
||||
sa.Column(
|
||||
"requested_by", BIGINT(unsigned=True), comment="请求用户ID,CLI任务为空,无物理外键"
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
"tplus_sync_batches",
|
||||
sa.Column("audit_result_json", sa.JSON(), comment="关联核验结果,只含系统字典和汇总"),
|
||||
)
|
||||
op.alter_column(
|
||||
"tplus_sync_batches",
|
||||
"started_at",
|
||||
existing_type=sa.DateTime(),
|
||||
nullable=True,
|
||||
comment="开始执行时间(排队时为空)",
|
||||
)
|
||||
op.alter_column(
|
||||
"tplus_sync_batches",
|
||||
"sync_mode",
|
||||
existing_type=sa.String(16),
|
||||
nullable=False,
|
||||
comment="任务方式:FULL全量 AUDIT关联核验 INCREMENTAL预留",
|
||||
)
|
||||
op.alter_column(
|
||||
"tplus_sync_batches",
|
||||
"status",
|
||||
existing_type=sa.String(16),
|
||||
nullable=False,
|
||||
comment="状态:QUEUED/RUNNING/SUCCEEDED/PARTIAL/FAILED",
|
||||
)
|
||||
op.create_unique_constraint(
|
||||
"uk_tplus_sync_batches_request_key", "tplus_sync_batches", ["request_key"]
|
||||
)
|
||||
op.create_index(
|
||||
"idx_tplus_sync_batches_parent_status", "tplus_sync_batches", ["parent_batch_no", "status"]
|
||||
)
|
||||
op.execute(
|
||||
"UPDATE tplus_sync_batches SET skipped_record_count=GREATEST(0, source_record_count-staged_record_count-failed_record_count) WHERE status='SUCCEEDED' AND sync_mode='FULL'"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""回滚前关闭 worker;原始暂存数据和历史批次仍保留。"""
|
||||
op.execute(
|
||||
"UPDATE tplus_sync_batches SET started_at=create_time, status='FAILED', finished_at=CURRENT_TIMESTAMP, error_message='任务管理版本已回滚' WHERE status='QUEUED'"
|
||||
)
|
||||
op.execute("UPDATE tplus_sync_batches SET started_at=create_time WHERE started_at IS NULL")
|
||||
op.drop_index("idx_tplus_sync_batches_parent_status", table_name="tplus_sync_batches")
|
||||
op.drop_constraint("uk_tplus_sync_batches_request_key", "tplus_sync_batches", type_="unique")
|
||||
for name in (
|
||||
"audit_result_json",
|
||||
"requested_by",
|
||||
"request_key",
|
||||
"parent_batch_no",
|
||||
"skipped_record_count",
|
||||
"entity_types_json",
|
||||
):
|
||||
op.drop_column("tplus_sync_batches", name)
|
||||
op.alter_column(
|
||||
"tplus_sync_batches",
|
||||
"started_at",
|
||||
existing_type=sa.DateTime(),
|
||||
nullable=False,
|
||||
comment="开始读取时间",
|
||||
)
|
||||
op.alter_column(
|
||||
"tplus_sync_batches",
|
||||
"sync_mode",
|
||||
existing_type=sa.String(16),
|
||||
nullable=False,
|
||||
comment="同步模式:FULL全量 INCREMENTAL增量",
|
||||
)
|
||||
op.alter_column(
|
||||
"tplus_sync_batches",
|
||||
"status",
|
||||
existing_type=sa.String(16),
|
||||
nullable=False,
|
||||
comment="状态:RUNNING/SUCCEEDED/PARTIAL/FAILED",
|
||||
)
|
||||
@ -26,6 +26,7 @@ from app.api.v1.system import logs as system_logs
|
||||
from app.api.v1.system import params as system_params
|
||||
from app.api.v1.system import roles as system_roles
|
||||
from app.api.v1.system import settings as system_settings
|
||||
from app.api.v1.system import tplus as system_tplus
|
||||
from app.api.v1.system import users as system_users
|
||||
|
||||
api_router = APIRouter()
|
||||
@ -47,6 +48,7 @@ system_router.include_router(system_settings.router)
|
||||
system_router.include_router(system_approvals.router)
|
||||
system_router.include_router(system_logs.operation_router)
|
||||
system_router.include_router(system_logs.login_router)
|
||||
system_router.include_router(system_tplus.router)
|
||||
v1_router.include_router(system_router)
|
||||
|
||||
# 基础数据(需求 2.1):/api/v1/base/*,业务操作登录即可访问
|
||||
|
||||
71
backend/app/api/v1/system/tplus.py
Normal file
71
backend/app/api/v1/system/tplus.py
Normal file
@ -0,0 +1,71 @@
|
||||
"""管理员管理 T+ 只读任务;长任务交给独立 worker 执行。"""
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Path, Query
|
||||
|
||||
from app.api.deps import AdminUser, DbSession, RequestMetaDep
|
||||
from app.core.response import ApiResponse, PageResult, ok
|
||||
from app.schemas.tplus import (
|
||||
TPlusBatchDetailOut,
|
||||
TPlusBatchOut,
|
||||
TPlusBatchQuery,
|
||||
TPlusCreateIn,
|
||||
TPlusErrorOut,
|
||||
TPlusErrorQuery,
|
||||
TPlusOverviewOut,
|
||||
TPlusRetryIn,
|
||||
)
|
||||
from app.services.tplus import query_service, task_service
|
||||
|
||||
router = APIRouter(prefix="/tplus", tags=["系统管理 · T+ 数据同步"])
|
||||
BatchNumber = Annotated[str, Path(min_length=1, max_length=32, pattern=r"^[A-Za-z0-9_-]+$")]
|
||||
|
||||
|
||||
@router.get("/overview", response_model=ApiResponse[TPlusOverviewOut])
|
||||
def overview(session: DbSession, _: AdminUser):
|
||||
"""查询配置状态、任务统计和最近核验。"""
|
||||
return ok(query_service.overview(session))
|
||||
|
||||
|
||||
@router.get("/batches", response_model=ApiResponse[PageResult[TPlusBatchOut]])
|
||||
def batches(session: DbSession, _: AdminUser, query: Annotated[TPlusBatchQuery, Query()]):
|
||||
"""分页查询批次元数据。"""
|
||||
return ok(query_service.list_batches(session, query))
|
||||
|
||||
|
||||
@router.get("/batches/{batch_no}", response_model=ApiResponse[TPlusBatchDetailOut])
|
||||
def detail(session: DbSession, _: AdminUser, batch_no: BatchNumber):
|
||||
"""查询批次详情与受控的核验报告。"""
|
||||
return ok(query_service.get_batch(session, batch_no))
|
||||
|
||||
|
||||
@router.get("/batches/{batch_no}/errors", response_model=ApiResponse[PageResult[TPlusErrorOut]])
|
||||
def errors(
|
||||
session: DbSession,
|
||||
_: AdminUser,
|
||||
batch_no: BatchNumber,
|
||||
query: Annotated[TPlusErrorQuery, Query()],
|
||||
):
|
||||
"""分页查询异常定位和固定错误说明。"""
|
||||
return ok(query_service.list_errors(session, batch_no, query))
|
||||
|
||||
|
||||
@router.post("/batches", response_model=ApiResponse[TPlusBatchDetailOut])
|
||||
def create(session: DbSession, admin: AdminUser, meta: RequestMetaDep, payload: TPlusCreateIn):
|
||||
"""将读取或核验任务持久化排队。"""
|
||||
batch = task_service.create_task(session, payload, task_service.TaskActor(admin, meta))
|
||||
return ok(query_service.get_batch(session, batch.batch_no))
|
||||
|
||||
|
||||
@router.post("/batches/{batch_no}/retry", response_model=ApiResponse[TPlusBatchDetailOut])
|
||||
def retry(
|
||||
session: DbSession,
|
||||
admin: AdminUser,
|
||||
meta: RequestMetaDep,
|
||||
batch_no: BatchNumber,
|
||||
payload: TPlusRetryIn,
|
||||
):
|
||||
"""新建重试批次,保留原批次与异常历史。"""
|
||||
batch = task_service.retry_task(session, batch_no, payload, task_service.TaskActor(admin, meta))
|
||||
return ok(query_service.get_batch(session, batch.batch_no))
|
||||
@ -11,13 +11,20 @@
|
||||
import argparse
|
||||
import getpass
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.core.constants import PASSWORD_MAX_BYTES, PASSWORD_MIN_BYTES, ROLE_ADMIN
|
||||
from app.core.db import dispose_engine, get_session_factory
|
||||
from app.core.security import hash_password
|
||||
from app.core.tplus_db import dispose_tplus_engine, get_tplus_session_factory
|
||||
from app.models import Role, User, UserRole
|
||||
from app.repositories.tplus_repository import get_batch
|
||||
from app.services.tplus.sync_service import TPlusSyncService
|
||||
from app.services.tplus.worker import run_once
|
||||
|
||||
|
||||
def _prompt_password() -> str:
|
||||
@ -80,6 +87,54 @@ def _create_admin(args: argparse.Namespace) -> int:
|
||||
return 0
|
||||
|
||||
|
||||
def _stage_tplus(args: argparse.Namespace) -> int:
|
||||
"""读取 T+ 源库并写入 MySQL staging。"""
|
||||
target_factory = get_session_factory()
|
||||
source_factory = get_tplus_session_factory()
|
||||
watermark = datetime.fromisoformat(args.watermark) if args.watermark else None
|
||||
with source_factory() as source_session, target_factory() as target_session:
|
||||
batch = TPlusSyncService(source_session, target_session).run(
|
||||
entity_types=args.entity,
|
||||
watermark=watermark,
|
||||
batch_size=args.batch_size,
|
||||
)
|
||||
print(
|
||||
f"T+ staging 完成:批次={batch.batch_no} 状态={batch.status} "
|
||||
f"源记录={batch.source_record_count} 新增={batch.staged_record_count} "
|
||||
f"失败={batch.failed_record_count}"
|
||||
)
|
||||
return 0 if batch.status == "SUCCEEDED" else 1
|
||||
|
||||
|
||||
def _tplus_worker(args: argparse.Namespace) -> int:
|
||||
"""独立消费持久化任务;HTTP 进程不承担长时间读取。"""
|
||||
if args.connection_file:
|
||||
settings = get_settings()
|
||||
settings.t_plus_enabled, settings.t_plus_connection_file = True, args.connection_file
|
||||
if not 2 <= args.poll_seconds <= 60:
|
||||
print("轮询间隔必须在 2 到 60 秒之间", file=sys.stderr)
|
||||
return 1
|
||||
factory = get_session_factory()
|
||||
try:
|
||||
while True:
|
||||
with factory() as session:
|
||||
batch_no = run_once(session)
|
||||
batch = get_batch(session, batch_no) if batch_no else None
|
||||
status = batch.status if batch else None
|
||||
if batch_no:
|
||||
print(f"任务完成:{batch_no} 状态={status}", flush=True)
|
||||
if not args.watch:
|
||||
return 0 if status in (None, "SUCCEEDED") else 1
|
||||
if batch_no is None or status != "SUCCEEDED":
|
||||
time.sleep(args.poll_seconds)
|
||||
except KeyboardInterrupt:
|
||||
print("T+ 读取服务已停止")
|
||||
return 130
|
||||
except Exception as exc:
|
||||
print(f"T+ 读取服务停止({type(exc).__name__}),请检查运行环境", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""命令行入口。"""
|
||||
parser = argparse.ArgumentParser(
|
||||
@ -96,12 +151,49 @@ def main() -> int:
|
||||
help="密码。不传则交互式输入(推荐)——命令行传参会留在 shell 历史中",
|
||||
)
|
||||
|
||||
p_tplus = sub.add_parser("tplus-stage", help="读取 T+ 源库并写入原始 staging")
|
||||
p_tplus.add_argument(
|
||||
"--entity",
|
||||
action="append",
|
||||
choices=[
|
||||
"personnel",
|
||||
"products",
|
||||
"customers",
|
||||
"businesses",
|
||||
"shipments",
|
||||
"receipts",
|
||||
"business_items",
|
||||
"shipment_items",
|
||||
"receipt_items",
|
||||
],
|
||||
required=True,
|
||||
help="实体类型,可重复指定",
|
||||
)
|
||||
p_tplus.add_argument(
|
||||
"--watermark",
|
||||
default=None,
|
||||
help="增量起始时间,ISO 8601 格式;不传表示全量读取",
|
||||
)
|
||||
p_tplus.add_argument("--batch-size", type=int, default=500, help="每次从源库读取的行数")
|
||||
|
||||
p_worker = sub.add_parser("tplus-worker", help="执行已排队的 T+ 读取或关联核验")
|
||||
modes = p_worker.add_mutually_exclusive_group()
|
||||
modes.add_argument("--once", action="store_true", help="执行一个任务后退出(默认)")
|
||||
modes.add_argument("--watch", action="store_true", help="持续消费手动排队任务")
|
||||
p_worker.add_argument("--connection-file", default=None, help="显式指定项目外只读连接 JSON")
|
||||
p_worker.add_argument("--poll-seconds", type=int, default=3, help="空闲轮询间隔,2–60 秒")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
try:
|
||||
return _create_admin(args)
|
||||
if args.command == "create-admin":
|
||||
return _create_admin(args)
|
||||
if args.command == "tplus-worker":
|
||||
return _tplus_worker(args)
|
||||
return _stage_tplus(args)
|
||||
finally:
|
||||
dispose_engine()
|
||||
dispose_tplus_engine()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@ -58,6 +58,23 @@ class Settings(BaseSettings):
|
||||
redis_password: str = ""
|
||||
redis_db: int = 0
|
||||
|
||||
# ===== T+ 只读 SQL Server =====
|
||||
# T+ 数据库默认关闭,避免开发者未配置驱动时影响主业务启动。
|
||||
t_plus_enabled: bool = False
|
||||
t_plus_host: str = "127.0.0.1"
|
||||
t_plus_port: int = 1433
|
||||
t_plus_database: str = ""
|
||||
t_plus_user: str = ""
|
||||
t_plus_password: str = Field(default="", repr=False)
|
||||
t_plus_connection_file: str = ""
|
||||
# 部署前必须指定已在 SQL Server 2008 R2 实测的驱动,不自动选择最新版。
|
||||
t_plus_odbc_driver: str = ""
|
||||
t_plus_encrypt: bool = False
|
||||
t_plus_trust_server_certificate: bool = True
|
||||
t_plus_connect_timeout: int = 10
|
||||
t_plus_query_timeout: int = Field(default=120, ge=1, le=600)
|
||||
t_plus_query_dir: str = ""
|
||||
|
||||
@field_validator("app_secret_key")
|
||||
@classmethod
|
||||
def _check_secret_key(cls, v: str) -> str:
|
||||
@ -92,6 +109,31 @@ class Settings(BaseSettings):
|
||||
auth = f":{self.redis_password}@" if self.redis_password else ""
|
||||
return f"redis://{auth}{self.redis_host}:{self.redis_port}/{self.redis_db}"
|
||||
|
||||
@property
|
||||
def t_plus_database_url(self) -> URL:
|
||||
"""构造 T+ 只读 SQL Server 连接 URL。
|
||||
|
||||
连接 URL 只在启用 T+ 适配器时使用;密码由 SQLAlchemy URL 对象保存,
|
||||
日志中调用 ``render_as_string()`` 时仍会默认隐藏密码。
|
||||
"""
|
||||
query = {
|
||||
"driver": self.t_plus_odbc_driver,
|
||||
"Encrypt": "yes" if self.t_plus_encrypt else "no",
|
||||
"TrustServerCertificate": "yes"
|
||||
if self.t_plus_trust_server_certificate
|
||||
else "no",
|
||||
"Connection Timeout": str(self.t_plus_connect_timeout),
|
||||
}
|
||||
return URL.create(
|
||||
drivername="mssql+pyodbc",
|
||||
username=self.t_plus_user,
|
||||
password=self.t_plus_password,
|
||||
host=self.t_plus_host,
|
||||
port=self.t_plus_port,
|
||||
database=self.t_plus_database,
|
||||
query=query,
|
||||
)
|
||||
|
||||
@property
|
||||
def upload_dir(self) -> Path:
|
||||
"""上传文件根目录(公司 logo 等):backend/uploads/。
|
||||
|
||||
112
backend/app/core/tplus_db.py
Normal file
112
backend/app/core/tplus_db.py
Normal file
@ -0,0 +1,112 @@
|
||||
"""T+ 只读 SQL Server 连接。
|
||||
|
||||
T+ 是外部源库,业务接口不直接依赖它。连接采用延迟初始化,未配置或未启用
|
||||
时不会影响主业务服务启动;适配器执行查询时才建立连接池。
|
||||
"""
|
||||
|
||||
import json
|
||||
from collections.abc import Generator
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, SecretStr, ValidationError
|
||||
from sqlalchemy import Engine, create_engine, event
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from app.core.config import Settings, get_settings
|
||||
|
||||
_engine: Engine | None = None
|
||||
_session_factory: sessionmaker[Session] | None = None
|
||||
|
||||
|
||||
class _ConnectionFile(BaseModel):
|
||||
"""连接文件仅允许源库字段,密码不会出现在模型表示中。"""
|
||||
|
||||
model_config = ConfigDict(extra="forbid", strict=True)
|
||||
host: str = Field(min_length=1, max_length=255)
|
||||
port: int = Field(ge=1, le=65535)
|
||||
database: str = Field(min_length=1, max_length=128)
|
||||
user: str = Field(min_length=1, max_length=128)
|
||||
password: SecretStr
|
||||
driver: str = Field(min_length=1, max_length=128)
|
||||
|
||||
|
||||
def resolve_tplus_settings(settings: Settings) -> Settings:
|
||||
"""解析显式配置的只读连接,返回副本,不修改应用全局配置。"""
|
||||
if not settings.t_plus_enabled:
|
||||
raise RuntimeError("T+ 数据源未启用,请设置 T_PLUS_ENABLED=true")
|
||||
resolved = settings
|
||||
if settings.t_plus_connection_file:
|
||||
try:
|
||||
path = Path(settings.t_plus_connection_file)
|
||||
if path.stat().st_size > 16384:
|
||||
raise ValueError("连接文件过大")
|
||||
config = _ConnectionFile.model_validate(
|
||||
json.loads(path.read_text(encoding="utf-8-sig"))
|
||||
)
|
||||
except (OSError, ValueError, ValidationError):
|
||||
raise RuntimeError("T+ 只读连接文件不可用或字段不正确") from None
|
||||
values = {
|
||||
"t_plus_" + key: getattr(config, key) for key in ("host", "port", "database", "user")
|
||||
}
|
||||
values.update(
|
||||
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")
|
||||
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
|
||||
|
||||
|
||||
def get_tplus_engine() -> Engine:
|
||||
"""获取 T+ 只读引擎;未启用或缺少 ODBC 驱动时给出可读错误。"""
|
||||
global _engine, _session_factory
|
||||
if _engine is not None:
|
||||
return _engine
|
||||
|
||||
settings = resolve_tplus_settings(get_settings())
|
||||
|
||||
try:
|
||||
_engine = create_engine(
|
||||
settings.t_plus_database_url,
|
||||
pool_pre_ping=True,
|
||||
pool_recycle=1800,
|
||||
pool_timeout=settings.t_plus_connect_timeout,
|
||||
echo=False,
|
||||
hide_parameters=True,
|
||||
)
|
||||
except ModuleNotFoundError as exc:
|
||||
raise RuntimeError(
|
||||
"T+ 连接需要 pyodbc(uv sync --extra tplus)和已验证的 SQL Server ODBC 驱动"
|
||||
) from exc
|
||||
_session_factory = sessionmaker(_engine, expire_on_commit=False)
|
||||
|
||||
def configure_timeout(connection, _record):
|
||||
connection.timeout = settings.t_plus_query_timeout
|
||||
|
||||
event.listen(_engine, "connect", configure_timeout)
|
||||
return _engine
|
||||
|
||||
|
||||
def get_tplus_session_factory() -> sessionmaker[Session]:
|
||||
"""获取 T+ SQLAlchemy 会话工厂。"""
|
||||
get_tplus_engine()
|
||||
assert _session_factory is not None
|
||||
return _session_factory
|
||||
|
||||
|
||||
def get_tplus_db() -> Generator[Session, None, None]:
|
||||
"""提供只读 T+ 会话;调用方不得 commit 或执行写操作。"""
|
||||
factory = get_tplus_session_factory()
|
||||
with factory() as session:
|
||||
yield session
|
||||
|
||||
|
||||
def dispose_tplus_engine() -> None:
|
||||
"""释放 T+ 连接池。"""
|
||||
global _engine, _session_factory
|
||||
if _engine is not None:
|
||||
_engine.dispose()
|
||||
_engine = None
|
||||
_session_factory = None
|
||||
48
backend/app/core/tplus_lock.py
Normal file
48
backend/app/core/tplus_lock.py
Normal file
@ -0,0 +1,48 @@
|
||||
"""同账套任务串行锁;独立持有 MySQL 连接,跨分页 commit 仍保持有效。"""
|
||||
|
||||
import hashlib
|
||||
from contextlib import contextmanager
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
|
||||
class TPlusBusyError(RuntimeError):
|
||||
"""已有任务持有执行锁。"""
|
||||
|
||||
|
||||
def lock_name(session: Session) -> str:
|
||||
"""生成不含地址、账号或密码的固定长度锁名。"""
|
||||
database = session.get_bind().url.database or "default"
|
||||
return "ys:tplus:read:" + hashlib.sha256(database.encode()).hexdigest()[:24]
|
||||
|
||||
|
||||
@contextmanager
|
||||
def sync_lock(session: Session):
|
||||
"""只在取得锁后执行;释放失败时使连接失效,防止带锁回池。"""
|
||||
engine = session.get_bind()
|
||||
if engine.dialect.name != "mysql":
|
||||
raise RuntimeError("T+ 执行协调需要 MySQL 命名锁")
|
||||
connection = engine.connect()
|
||||
acquired = False
|
||||
try:
|
||||
acquired = (
|
||||
connection.execute(
|
||||
text("SELECT GET_LOCK(:name, 0)"), {"name": lock_name(session)}
|
||||
).scalar_one()
|
||||
== 1
|
||||
)
|
||||
if not acquired:
|
||||
raise TPlusBusyError("已有 T+ 任务正在执行,请稍后重试")
|
||||
yield
|
||||
finally:
|
||||
if acquired:
|
||||
try:
|
||||
released = connection.execute(
|
||||
text("SELECT RELEASE_LOCK(:name)"), {"name": lock_name(session)}
|
||||
).scalar_one()
|
||||
if released != 1:
|
||||
connection.invalidate()
|
||||
except Exception:
|
||||
connection.invalidate()
|
||||
connection.close()
|
||||
@ -15,6 +15,7 @@ from app.core.db import dispose_engine
|
||||
from app.core.exception_handlers import register_exception_handlers
|
||||
from app.core.logging import setup_logging
|
||||
from app.core.redis_client import close_redis
|
||||
from app.core.tplus_db import dispose_tplus_engine
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@ -35,6 +36,7 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
|
||||
)
|
||||
yield
|
||||
dispose_engine()
|
||||
dispose_tplus_engine()
|
||||
close_redis()
|
||||
logger.info("应用已停止,连接池已释放")
|
||||
|
||||
|
||||
@ -26,6 +26,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_sync import (
|
||||
TPlusSyncBatch,
|
||||
TPlusSyncError,
|
||||
TPlusSyncRecord,
|
||||
TPlusSyncWatermark,
|
||||
)
|
||||
from app.models.user import User, UserRole
|
||||
|
||||
__all__ = [
|
||||
@ -60,6 +66,10 @@ __all__ = [
|
||||
"Shipment",
|
||||
"ShipmentItem",
|
||||
"SystemSetting",
|
||||
"TPlusSyncBatch",
|
||||
"TPlusSyncError",
|
||||
"TPlusSyncRecord",
|
||||
"TPlusSyncWatermark",
|
||||
"TimestampMixin",
|
||||
"User",
|
||||
"UserRole",
|
||||
|
||||
@ -91,6 +91,9 @@ class TPlusSyncMixin:
|
||||
String(32), comment="同步批次号(同步模块写入)"
|
||||
)
|
||||
synced_at: Mapped[datetime | None] = mapped_column(DateTime, comment="最近同步时间")
|
||||
source_updated_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime, comment="T+ 源数据更新时间(未确认时可为空)"
|
||||
)
|
||||
|
||||
|
||||
def mysql_table_args(comment: str, *extra: object) -> tuple[object, ...]:
|
||||
|
||||
149
backend/app/models/tplus_sync.py
Normal file
149
backend/app/models/tplus_sync.py
Normal file
@ -0,0 +1,149 @@
|
||||
"""T+ 同步批次、错误和原始 staging 记录。"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import JSON, DateTime, Index, Integer, String, Text, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.models.base import BIGINT_UNSIGNED, Base, TimestampMixin, mysql_table_args
|
||||
|
||||
|
||||
class TPlusSyncBatch(Base, TimestampMixin):
|
||||
"""一次 T+ 全量或增量同步的审计记录。"""
|
||||
|
||||
__tablename__ = "tplus_sync_batches"
|
||||
__table_args__ = mysql_table_args(
|
||||
"T+ 同步批次表(记录读取范围、结果和对账统计)",
|
||||
UniqueConstraint("batch_no", name="uk_tplus_sync_batches_batch_no"),
|
||||
UniqueConstraint("request_key", name="uk_tplus_sync_batches_request_key"),
|
||||
Index("idx_tplus_sync_batches_status", "status"),
|
||||
Index("idx_tplus_sync_batches_started_at", "started_at"),
|
||||
Index("idx_tplus_sync_batches_parent_status", "parent_batch_no", "status"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(
|
||||
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
|
||||
)
|
||||
batch_no: Mapped[str] = mapped_column(String(32), nullable=False, comment="同步批次号")
|
||||
sync_mode: Mapped[str] = mapped_column(
|
||||
String(16), nullable=False, comment="任务方式:FULL全量 AUDIT关联核验 INCREMENTAL预留"
|
||||
)
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(16), nullable=False, comment="状态:QUEUED/RUNNING/SUCCEEDED/PARTIAL/FAILED"
|
||||
)
|
||||
source_database: Mapped[str | None] = mapped_column(String(128), comment="T+ 源数据库名")
|
||||
started_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime, comment="开始执行时间(排队时为空)"
|
||||
)
|
||||
finished_at: Mapped[datetime | None] = mapped_column(DateTime, comment="结束时间")
|
||||
watermark_from: Mapped[datetime | None] = mapped_column(DateTime, comment="增量起始水位")
|
||||
watermark_to: Mapped[datetime | None] = mapped_column(DateTime, comment="本批次结束水位")
|
||||
source_record_count: Mapped[int] = mapped_column(
|
||||
Integer, nullable=False, server_default="0", comment="源记录数"
|
||||
)
|
||||
staged_record_count: Mapped[int] = mapped_column(
|
||||
Integer, nullable=False, server_default="0", comment="写入 staging 的记录数"
|
||||
)
|
||||
failed_record_count: Mapped[int] = mapped_column(
|
||||
Integer, nullable=False, server_default="0", comment="失败记录数"
|
||||
)
|
||||
error_message: Mapped[str | None] = mapped_column(Text, comment="批次错误摘要")
|
||||
entity_types_json: Mapped[list[str] | None] = mapped_column(
|
||||
JSON, comment="请求读取的实体类型;历史批次可为空"
|
||||
)
|
||||
skipped_record_count: Mapped[int] = mapped_column(
|
||||
Integer, nullable=False, server_default="0", comment="重复快照跳过条数"
|
||||
)
|
||||
parent_batch_no: Mapped[str | None] = mapped_column(
|
||||
String(32), comment="重试来源批次号,保留原批次记录"
|
||||
)
|
||||
request_key: Mapped[str | None] = mapped_column(
|
||||
String(36), comment="请求幂等键,空值用于历史和直接CLI任务"
|
||||
)
|
||||
requested_by: Mapped[int | None] = mapped_column(
|
||||
BIGINT_UNSIGNED, comment="请求用户ID,CLI任务为空,无物理外键"
|
||||
)
|
||||
audit_result_json: Mapped[dict | None] = mapped_column(
|
||||
JSON, comment="关联核验结果,只含系统字典和汇总"
|
||||
)
|
||||
|
||||
|
||||
class TPlusSyncError(Base, TimestampMixin):
|
||||
"""单条 T+ 数据读取、清洗或 staging 失败记录。"""
|
||||
|
||||
__tablename__ = "tplus_sync_errors"
|
||||
__table_args__ = mysql_table_args(
|
||||
"T+ 同步错误表(记录失败行和重试信息)",
|
||||
Index("idx_tplus_sync_errors_batch_no", "batch_no"),
|
||||
Index("idx_tplus_sync_errors_entity_type", "entity_type"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(
|
||||
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
|
||||
)
|
||||
batch_no: Mapped[str] = mapped_column(String(32), nullable=False, comment="同步批次号")
|
||||
entity_type: Mapped[str] = mapped_column(String(32), nullable=False, comment="业务实体类型")
|
||||
source_table: Mapped[str] = mapped_column(String(128), nullable=False, comment="T+ 源表")
|
||||
source_id: Mapped[str | None] = mapped_column(String(64), comment="T+ 源主键")
|
||||
source_line_no: Mapped[str | None] = mapped_column(String(64), comment="T+ 明细行号或明细主键")
|
||||
error_code: Mapped[str] = mapped_column(String(32), nullable=False, comment="错误编码")
|
||||
error_message: Mapped[str] = mapped_column(Text, nullable=False, comment="错误说明")
|
||||
retry_count: Mapped[int] = mapped_column(
|
||||
Integer, nullable=False, server_default="0", comment="已重试次数"
|
||||
)
|
||||
|
||||
|
||||
class TPlusSyncRecord(Base, TimestampMixin):
|
||||
"""T+ 原始行 staging,保存映射未定前的可追溯数据。"""
|
||||
|
||||
__tablename__ = "tplus_sync_records"
|
||||
__table_args__ = mysql_table_args(
|
||||
"T+ 原始同步 staging 表(映射确认前只保存原始行,不直接改业务表)",
|
||||
UniqueConstraint(
|
||||
"entity_type", "source_id", "row_hash", name="uk_tplus_sync_records_entity_source_hash"
|
||||
),
|
||||
Index("idx_tplus_sync_records_batch_no", "batch_no"),
|
||||
Index("idx_tplus_sync_records_source_no", "source_no"),
|
||||
Index("idx_tplus_sync_records_state", "state"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(
|
||||
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
|
||||
)
|
||||
batch_no: Mapped[str] = mapped_column(String(32), nullable=False, comment="同步批次号")
|
||||
entity_type: Mapped[str] = mapped_column(String(32), nullable=False, comment="业务实体类型")
|
||||
source_table: Mapped[str] = mapped_column(String(128), nullable=False, comment="T+ 源表")
|
||||
source_id: Mapped[str] = mapped_column(String(64), nullable=False, comment="T+ 源主键")
|
||||
source_no: Mapped[str | None] = mapped_column(String(64), comment="T+ 原始编号")
|
||||
source_line_no: Mapped[str | None] = mapped_column(String(64), comment="T+ 明细行号或明细主键")
|
||||
source_updated_at: Mapped[datetime | None] = mapped_column(DateTime, comment="T+ 源更新时间")
|
||||
row_hash: Mapped[str] = mapped_column(String(64), nullable=False, comment="原始行 SHA-256")
|
||||
state: Mapped[str] = mapped_column(
|
||||
String(16),
|
||||
nullable=False,
|
||||
server_default="PENDING",
|
||||
comment="状态:PENDING/APPLIED/SKIPPED/ERROR",
|
||||
)
|
||||
payload_json: Mapped[dict] = mapped_column(JSON, nullable=False, comment="原始行 JSON")
|
||||
|
||||
|
||||
class TPlusSyncWatermark(Base, TimestampMixin):
|
||||
"""按实体保存增量同步水位。"""
|
||||
|
||||
__tablename__ = "tplus_sync_watermarks"
|
||||
__table_args__ = mysql_table_args(
|
||||
"T+ 增量同步水位表",
|
||||
UniqueConstraint("entity_type", name="uk_tplus_sync_watermarks_entity_type"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(
|
||||
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
|
||||
)
|
||||
entity_type: Mapped[str] = mapped_column(String(32), nullable=False, comment="业务实体类型")
|
||||
last_source_updated_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime, comment="最近源更新时间"
|
||||
)
|
||||
last_source_id: Mapped[str | None] = mapped_column(String(64), comment="同一时间点的最后源主键")
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(16), nullable=False, server_default="READY", comment="状态:READY/BLOCKED"
|
||||
)
|
||||
149
backend/app/repositories/tplus_repository.py
Normal file
149
backend/app/repositories/tplus_repository.py
Normal file
@ -0,0 +1,149 @@
|
||||
"""T+ 管理库访问;只 flush,事务由 service 管理。"""
|
||||
|
||||
from sqlalchemy import func, select, update
|
||||
from sqlalchemy.orm import Session, defer
|
||||
|
||||
from app.models import TPlusSyncBatch, TPlusSyncError
|
||||
|
||||
|
||||
def get_batch(
|
||||
session: Session, batch_no: str, *, for_update: bool = False, with_result: bool = False
|
||||
):
|
||||
"""按批次号查询;普通查询不加载核验大字段。"""
|
||||
statement = select(TPlusSyncBatch).where(TPlusSyncBatch.batch_no == batch_no)
|
||||
if not with_result:
|
||||
statement = statement.options(defer(TPlusSyncBatch.audit_result_json))
|
||||
if for_update:
|
||||
statement = statement.with_for_update().execution_options(populate_existing=True)
|
||||
return session.scalar(statement)
|
||||
|
||||
|
||||
def get_request(session: Session, key: str, *, for_update: bool = False):
|
||||
"""锁读用于唯一约束竞争后的当前读,避免旧事务快照看不到新任务。"""
|
||||
statement = select(TPlusSyncBatch).where(TPlusSyncBatch.request_key == key)
|
||||
if for_update:
|
||||
statement = statement.with_for_update().execution_options(populate_existing=True)
|
||||
return session.scalar(statement)
|
||||
|
||||
|
||||
def active_retry(session: Session, parent_no: str):
|
||||
"""调用方先锁父批次,再用当前读判断是否已有未完成重试。"""
|
||||
return session.scalar(
|
||||
select(TPlusSyncBatch)
|
||||
.where(
|
||||
TPlusSyncBatch.parent_batch_no == parent_no,
|
||||
TPlusSyncBatch.status.in_(("QUEUED", "RUNNING")),
|
||||
)
|
||||
.with_for_update()
|
||||
)
|
||||
|
||||
|
||||
def increment_retry_errors(session: Session, parent_no: str) -> None:
|
||||
"""创建新重试时对原异常累计重试次数。"""
|
||||
session.execute(
|
||||
update(TPlusSyncError)
|
||||
.where(TPlusSyncError.batch_no == parent_no)
|
||||
.values(retry_count=TPlusSyncError.retry_count + 1)
|
||||
)
|
||||
session.flush()
|
||||
|
||||
|
||||
def next_queued(session: Session):
|
||||
"""调用方持有账套执行锁后领取最早排队任务。"""
|
||||
return session.scalar(
|
||||
select(TPlusSyncBatch)
|
||||
.where(TPlusSyncBatch.status == "QUEUED")
|
||||
.order_by(TPlusSyncBatch.id)
|
||||
.limit(1)
|
||||
.with_for_update()
|
||||
)
|
||||
|
||||
|
||||
def running_batches(session: Session) -> list[TPlusSyncBatch]:
|
||||
"""只在持有执行锁时使用,不能依据时间戳判断任务已经停止。"""
|
||||
return list(
|
||||
session.scalars(
|
||||
select(TPlusSyncBatch).where(TPlusSyncBatch.status == "RUNNING").with_for_update()
|
||||
).all()
|
||||
)
|
||||
|
||||
|
||||
def list_batches(session: Session, query):
|
||||
"""分页只读元数据,核验报告和原异常不随列表加载。"""
|
||||
filters = []
|
||||
if query.status:
|
||||
filters.append(TPlusSyncBatch.status == query.status)
|
||||
if query.task_type:
|
||||
filters.append(
|
||||
TPlusSyncBatch.sync_mode == ("AUDIT" if query.task_type == "AUDIT" else "FULL")
|
||||
)
|
||||
total = session.scalar(select(func.count()).select_from(TPlusSyncBatch).where(*filters))
|
||||
rows = session.scalars(
|
||||
select(TPlusSyncBatch)
|
||||
.where(*filters)
|
||||
.options(defer(TPlusSyncBatch.audit_result_json), defer(TPlusSyncBatch.error_message))
|
||||
.order_by(TPlusSyncBatch.id.desc())
|
||||
.offset(query.offset)
|
||||
.limit(query.page_size)
|
||||
).all()
|
||||
return rows, total or 0
|
||||
|
||||
|
||||
def active_retry_parents(session: Session, numbers: list[str]) -> set[str]:
|
||||
"""整页一次获取未完成的重试,避免逐行查询。"""
|
||||
if not numbers:
|
||||
return set()
|
||||
return set(
|
||||
session.scalars(
|
||||
select(TPlusSyncBatch.parent_batch_no).where(
|
||||
TPlusSyncBatch.parent_batch_no.in_(numbers),
|
||||
TPlusSyncBatch.status.in_(("QUEUED", "RUNNING")),
|
||||
)
|
||||
).all()
|
||||
)
|
||||
|
||||
|
||||
def list_errors(session: Session, number: str, query):
|
||||
"""只读取当前批次的异常定位元数据。"""
|
||||
filters = [TPlusSyncError.batch_no == number]
|
||||
if query.entity_type:
|
||||
filters.append(TPlusSyncError.entity_type == query.entity_type)
|
||||
total = session.scalar(select(func.count()).select_from(TPlusSyncError).where(*filters))
|
||||
rows = session.scalars(
|
||||
select(TPlusSyncError)
|
||||
.where(*filters)
|
||||
.options(defer(TPlusSyncError.error_message))
|
||||
.order_by(TPlusSyncError.id.desc())
|
||||
.offset(query.offset)
|
||||
.limit(query.page_size)
|
||||
).all()
|
||||
return rows, total or 0
|
||||
|
||||
|
||||
def status_counts(session: Session) -> dict[str, int]:
|
||||
"""汇总任务表,不扫描原始快照。"""
|
||||
return dict(
|
||||
session.execute(
|
||||
select(TPlusSyncBatch.status, func.count()).group_by(TPlusSyncBatch.status)
|
||||
).all()
|
||||
)
|
||||
|
||||
|
||||
def latest_audit(session: Session):
|
||||
"""最近完成的核验;返回批次供查询层过滤报告。"""
|
||||
return session.scalar(
|
||||
select(TPlusSyncBatch)
|
||||
.where(
|
||||
TPlusSyncBatch.sync_mode == "AUDIT",
|
||||
TPlusSyncBatch.finished_at.is_not(None),
|
||||
)
|
||||
.order_by(TPlusSyncBatch.id.desc())
|
||||
.limit(1)
|
||||
)
|
||||
|
||||
|
||||
def total_staged_records(session: Session) -> int:
|
||||
"""从批次元数据汇总累计新增数,避免扫描原始快照。"""
|
||||
return (
|
||||
session.scalar(select(func.coalesce(func.sum(TPlusSyncBatch.staged_record_count), 0))) or 0
|
||||
)
|
||||
109
backend/app/schemas/tplus.py
Normal file
109
backend/app/schemas/tplus.py
Normal file
@ -0,0 +1,109 @@
|
||||
"""T+ 管理接口合同;只暴露范围、数量和受控核验结果。"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Literal
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
|
||||
from app.core.response import PageQuery
|
||||
|
||||
EntityType = Literal[
|
||||
"personnel",
|
||||
"products",
|
||||
"customers",
|
||||
"businesses",
|
||||
"shipments",
|
||||
"receipts",
|
||||
"business_items",
|
||||
"shipment_items",
|
||||
"receipt_items",
|
||||
]
|
||||
TaskType = Literal["READ", "AUDIT"]
|
||||
TaskStatus = Literal["QUEUED", "RUNNING", "SUCCEEDED", "PARTIAL", "FAILED"]
|
||||
|
||||
|
||||
class TPlusCreateIn(BaseModel):
|
||||
"""创建长任务;幂等键是调用意图的一部分,必须由调用方保留。"""
|
||||
|
||||
task_type: TaskType = "READ"
|
||||
entity_types: list[EntityType] = Field(default_factory=list, max_length=9)
|
||||
request_key: UUID
|
||||
|
||||
@model_validator(mode="after")
|
||||
def check_scope(self):
|
||||
if self.task_type == "READ" and not self.entity_types:
|
||||
raise ValueError("请选择至少一种读取数据")
|
||||
if self.task_type == "AUDIT" and self.entity_types:
|
||||
raise ValueError("关联核验不接受自定义读取范围")
|
||||
if len(set(self.entity_types)) != len(self.entity_types):
|
||||
raise ValueError("读取类型不能重复")
|
||||
return self
|
||||
|
||||
|
||||
class TPlusRetryIn(BaseModel):
|
||||
"""失败批次重试不接受任意新范围。"""
|
||||
|
||||
request_key: UUID
|
||||
|
||||
|
||||
class TPlusBatchQuery(PageQuery):
|
||||
"""批次筛选,与读取状态和任务类型保持一致。"""
|
||||
|
||||
status: TaskStatus | None = None
|
||||
task_type: TaskType | None = None
|
||||
|
||||
|
||||
class TPlusErrorQuery(PageQuery):
|
||||
"""异常只能在指定批次内查询。"""
|
||||
|
||||
entity_type: EntityType | None = None
|
||||
|
||||
|
||||
class TPlusBatchOut(BaseModel):
|
||||
"""批次列表不包含原始数据、源配置或数据库异常。"""
|
||||
|
||||
batch_no: str
|
||||
task_type: TaskType
|
||||
status: str
|
||||
entity_types: list[str] | None
|
||||
source_record_count: int
|
||||
staged_record_count: int
|
||||
skipped_record_count: int
|
||||
failed_record_count: int
|
||||
parent_batch_no: str | None
|
||||
create_time: datetime
|
||||
started_at: datetime | None
|
||||
finished_at: datetime | None
|
||||
can_retry: bool
|
||||
error_summary: str | None = None
|
||||
|
||||
|
||||
class TPlusBatchDetailOut(TPlusBatchOut):
|
||||
"""详情按需携带受控的关联核验结果。"""
|
||||
|
||||
audit_result: dict | None = None
|
||||
|
||||
|
||||
class TPlusErrorOut(BaseModel):
|
||||
"""异常使用固定说明,源定位仅开放整数 ID。"""
|
||||
|
||||
id: int
|
||||
entity_type: str
|
||||
source_id: str | None
|
||||
error_code: str
|
||||
error_message: str
|
||||
retry_count: int
|
||||
create_time: datetime
|
||||
|
||||
|
||||
class TPlusOverviewOut(BaseModel):
|
||||
"""配置就绪不等同于源库连通或兼容验收通过。"""
|
||||
|
||||
is_configured: bool
|
||||
configuration_message: str
|
||||
status_counts: dict[str, int]
|
||||
total_staged_records: int = 0
|
||||
compatibility_status: str = "PENDING"
|
||||
business_import_enabled: bool = False
|
||||
latest_audit: dict | None = None
|
||||
21
backend/app/services/tplus/README.md
Normal file
21
backend/app/services/tplus/README.md
Normal file
@ -0,0 +1,21 @@
|
||||
# T+ 只读同步
|
||||
|
||||
> AI 摘要:读取外部 SQL Server 的原始快照,并管理持久化任务。关键文件为 task_service、worker、sync_service、relationship_audit 和 query_service;管理员 API 为 `/api/v1/system/tplus`。
|
||||
|
||||
## 职责与边界
|
||||
|
||||
- `task_service` 创建/重试任务,控制请求幂等和事务,保留原批次与异常;HTTP 不执行源库长查询。
|
||||
- `worker.run_once` 取得 MySQL 执行锁后恢复中断任务、领取一个任务;读取和关联核验共用锁。
|
||||
- `sync_service` 主键分页读取白名单实体,每页保存原始快照,相同源 ID 和行哈希去重。
|
||||
- `relationship_audit` 执行 `db/queries/tplus/audit/queries.sql`,只返回系统字典和数量;按收款方向与单据类型核对关联。
|
||||
- `query_service` 分页元数据,按需加载白名单报告;不返回原始 payload、驱动异常或源配置。
|
||||
|
||||
## 主要入口
|
||||
|
||||
管理员 API 提供概况、批次/异常分页、详情、创建与失败重试。CLI 为 `python -m app.cli tplus-worker --once/--watch`;原 `tplus-stage --entity personnel` 可继续直接读取。
|
||||
|
||||
连接配置、启动命令和异常处理参见项目 `docs/T+本地恢复与同步开发手册.md`。后端与 worker 必须指向同一 MySQL 业务库和 T+ 账套,查询文件随部署交付。
|
||||
|
||||
## 验收限制
|
||||
|
||||
当前只写 MySQL 暂存与管理表,不写工资业务表、不推进时间水位。关联、成本、日期和负向事件的业务规则待客户确认;SQL Server 2022 的实测不能替代客户 2008 R2(10.50.x)的兼容验收。
|
||||
6
backend/app/services/tplus/__init__.py
Normal file
6
backend/app/services/tplus/__init__.py
Normal file
@ -0,0 +1,6 @@
|
||||
"""T+ 只读读取、字段映射和同步 staging。"""
|
||||
|
||||
from app.services.tplus.reader import TPlusReader
|
||||
from app.services.tplus.sync_service import TPlusSyncService
|
||||
|
||||
__all__ = ["TPlusReader", "TPlusSyncService"]
|
||||
136
backend/app/services/tplus/mapping.py
Normal file
136
backend/app/services/tplus/mapping.py
Normal file
@ -0,0 +1,136 @@
|
||||
"""T+ 字段映射元数据。
|
||||
|
||||
这里记录候选字段和确认状态,不把字典中的候选直接当成业务契约。
|
||||
高风险口径在客户确认前保持 ``PENDING``,同步器只允许写入原始 staging。
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from enum import StrEnum
|
||||
|
||||
|
||||
class MappingStatus(StrEnum):
|
||||
"""字段映射的证据状态。"""
|
||||
|
||||
CONFIRMED = "CONFIRMED"
|
||||
CANDIDATE = "CANDIDATE"
|
||||
MISSING = "MISSING"
|
||||
PENDING = "PENDING"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TPlusMapping:
|
||||
"""一个实体的源表、候选字段和目标表说明。"""
|
||||
|
||||
entity_type: str
|
||||
source_table: str
|
||||
source_fields: tuple[str, ...]
|
||||
target_table: str
|
||||
status: MappingStatus
|
||||
unresolved: tuple[str, ...] = ()
|
||||
version: str = "0.2"
|
||||
schema_evidence: str = "ceshi20261026.bak,2026-10-27 恢复副本结构核验"
|
||||
|
||||
|
||||
TPLUS_MAPPINGS: tuple[TPlusMapping, ...] = (
|
||||
TPlusMapping(
|
||||
"personnel",
|
||||
"AA_Person",
|
||||
("id", "code", "name", "disabled", "mobilePhoneNo", "officePhoneNo", "iddepartment"),
|
||||
"personnel",
|
||||
MappingStatus.CANDIDATE,
|
||||
("工号是否等于 code", "经理隶属关系", "入职日期", "试用期"),
|
||||
),
|
||||
TPlusMapping(
|
||||
"products",
|
||||
"AA_Inventory",
|
||||
("id", "code", "name", "specification", "idunit", "idinventoryclass"),
|
||||
"products",
|
||||
MappingStatus.CANDIDATE,
|
||||
("库存数量语义", "参考价来源", "系统产品分类"),
|
||||
),
|
||||
TPlusMapping(
|
||||
"customers",
|
||||
"AA_Partner",
|
||||
("id", "code", "name", "Contact", "representative", "disabled", "updated"),
|
||||
"customers",
|
||||
MappingStatus.CANDIDATE,
|
||||
("客户唯一键", "改名/合并规则", "系统客户分类"),
|
||||
),
|
||||
TPlusMapping(
|
||||
"businesses",
|
||||
"SA_SaleOrder",
|
||||
("id", "code", "voucherdate", "idcustomer", "idclerk", "idbusinesstype", "updated"),
|
||||
"businesses",
|
||||
MappingStatus.PENDING,
|
||||
("成本口径", "业务负责人覆盖规则", "含税口径"),
|
||||
),
|
||||
TPlusMapping(
|
||||
"shipments",
|
||||
"SA_SaleDelivery",
|
||||
(
|
||||
"id",
|
||||
"code",
|
||||
"voucherdate",
|
||||
"SendDate",
|
||||
"deliveryDate",
|
||||
"auditeddate",
|
||||
"SourceVoucherCode",
|
||||
"updated",
|
||||
),
|
||||
"shipments",
|
||||
MappingStatus.PENDING,
|
||||
("发货日期", "订单关联路径", "退货/红冲规则", "运费来源"),
|
||||
),
|
||||
TPlusMapping(
|
||||
"receipts",
|
||||
"ARAP_ReceivePayment",
|
||||
(
|
||||
"id",
|
||||
"code",
|
||||
"voucherDate",
|
||||
"auditedDate",
|
||||
"createdTime",
|
||||
"SaleOrderCode",
|
||||
"SaleOrderID",
|
||||
"ts",
|
||||
),
|
||||
"receipts",
|
||||
MappingStatus.PENDING,
|
||||
("到账日期", "收款方式", "核销状态", "回款与发货关联"),
|
||||
),
|
||||
)
|
||||
|
||||
DETAIL_MAPPINGS: tuple[TPlusMapping, ...] = (
|
||||
TPlusMapping(
|
||||
"business_items",
|
||||
"SA_SaleOrder_b",
|
||||
("id", "idSaleOrderDTO", "LatestCost"),
|
||||
"business_items",
|
||||
MappingStatus.PENDING,
|
||||
("成本口径", "含税口径"),
|
||||
),
|
||||
TPlusMapping(
|
||||
"shipment_items",
|
||||
"SA_SaleDelivery_b",
|
||||
("ID", "idSaleDeliveryDTO", "saleOrderId"),
|
||||
"shipment_items",
|
||||
MappingStatus.PENDING,
|
||||
("发货归属", "红冲和退货"),
|
||||
),
|
||||
TPlusMapping(
|
||||
"receipt_items",
|
||||
"ARAP_ReceivePayment_b",
|
||||
("id", "idArapReceivePaymentDTO", "saleorderid"),
|
||||
"receipts",
|
||||
MappingStatus.PENDING,
|
||||
("回款拆分", "核销语义", "增量水位"),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def get_mapping(entity_type: str) -> TPlusMapping:
|
||||
"""按实体类型取得映射;未知实体直接拒绝。"""
|
||||
for mapping in (*TPLUS_MAPPINGS, *DETAIL_MAPPINGS):
|
||||
if mapping.entity_type == entity_type:
|
||||
return mapping
|
||||
raise KeyError(f"未知 T+ 实体类型:{entity_type}")
|
||||
122
backend/app/services/tplus/query_service.py
Normal file
122
backend/app/services/tplus/query_service.py
Normal file
@ -0,0 +1,122 @@
|
||||
"""同步管理只读查询;只公开受控元数据,不公开源数据和原异常。"""
|
||||
|
||||
from app.core.exceptions import NotFoundError
|
||||
from app.core.response import PageResult
|
||||
from app.core.tplus_db import resolve_tplus_settings
|
||||
from app.repositories import tplus_repository as repository
|
||||
from app.schemas.tplus import (
|
||||
TPlusBatchDetailOut,
|
||||
TPlusBatchOut,
|
||||
TPlusErrorOut,
|
||||
TPlusOverviewOut,
|
||||
)
|
||||
from app.services.tplus import task_service
|
||||
from app.services.tplus.mapping import DETAIL_MAPPINGS, TPLUS_MAPPINGS
|
||||
from app.services.tplus.relationship_audit import public_report
|
||||
|
||||
ERROR_MESSAGES = {
|
||||
"READ_ERROR": "源数据读取失败,请检查只读连接和字段映射",
|
||||
"STAGE_ERROR": "原始数据保存失败,请核对字段类型后重试",
|
||||
"INTERRUPTED": "执行进程中断,可重新读取,已保存数据会自动去重",
|
||||
"EXECUTION_ERROR": "任务执行失败,请检查只读连接和字段映射",
|
||||
"TYPE_UNRESOLVED": "关联核验所需的单据类型缺失或不唯一",
|
||||
}
|
||||
_ENTITY_TYPES = {item.entity_type for item in (*TPLUS_MAPPINGS, *DETAIL_MAPPINGS)}
|
||||
|
||||
|
||||
def batch_out(batch, active_parents: set[str]) -> TPlusBatchOut:
|
||||
"""只使用元数据字段组装输出,历史批次范围保持未知。"""
|
||||
entities = batch.entity_types_json
|
||||
scope_valid = (
|
||||
isinstance(entities, list)
|
||||
and bool(entities)
|
||||
and all(isinstance(item, str) and item in _ENTITY_TYPES for item in entities)
|
||||
)
|
||||
return TPlusBatchOut(
|
||||
batch_no=batch.batch_no,
|
||||
task_type="AUDIT" if batch.sync_mode == "AUDIT" else "READ",
|
||||
status=batch.status,
|
||||
entity_types=entities if scope_valid else None,
|
||||
source_record_count=batch.source_record_count,
|
||||
staged_record_count=batch.staged_record_count,
|
||||
skipped_record_count=batch.skipped_record_count,
|
||||
failed_record_count=batch.failed_record_count,
|
||||
parent_batch_no=batch.parent_batch_no,
|
||||
create_time=batch.create_time,
|
||||
started_at=batch.started_at,
|
||||
finished_at=batch.finished_at,
|
||||
can_retry=batch.status in ("FAILED", "PARTIAL")
|
||||
and (batch.sync_mode == "AUDIT" or (batch.sync_mode == "FULL" and scope_valid))
|
||||
and batch.batch_no not in active_parents,
|
||||
error_summary="任务有异常,请查看异常明细"
|
||||
if batch.status in ("FAILED", "PARTIAL")
|
||||
else None,
|
||||
)
|
||||
|
||||
|
||||
def get_batch(session, number: str) -> TPlusBatchDetailOut:
|
||||
"""按需加载报告,未找到时统一返回 404。"""
|
||||
batch = repository.get_batch(session, number, with_result=True)
|
||||
if batch is None:
|
||||
raise NotFoundError("同步批次不存在")
|
||||
out = batch_out(batch, repository.active_retry_parents(session, [number]))
|
||||
return TPlusBatchDetailOut(
|
||||
**out.model_dump(), audit_result=public_report(batch.audit_result_json)
|
||||
)
|
||||
|
||||
|
||||
def list_batches(session, query):
|
||||
"""分页批次并批量查询重试状态。"""
|
||||
rows, total = repository.list_batches(session, query)
|
||||
active = repository.active_retry_parents(session, [row.batch_no for row in rows])
|
||||
return PageResult[TPlusBatchOut](
|
||||
records=[batch_out(row, active) for row in rows],
|
||||
total=total,
|
||||
page_num=query.page_num,
|
||||
page_size=query.page_size,
|
||||
)
|
||||
|
||||
|
||||
def _safe_id(value) -> str | None:
|
||||
if isinstance(value, str) and value.isascii() and value.isdigit() and len(value) <= 10:
|
||||
return value if int(value) <= 2147483647 else None
|
||||
return None
|
||||
|
||||
|
||||
def list_errors(session, number, query):
|
||||
"""错误按编码生成固定说明,避免驱动异常泄漏连接参数。"""
|
||||
if repository.get_batch(session, number) is None:
|
||||
raise NotFoundError("同步批次不存在")
|
||||
rows, total = repository.list_errors(session, number, query)
|
||||
records = [
|
||||
TPlusErrorOut(
|
||||
id=row.id,
|
||||
entity_type=row.entity_type if row.entity_type in _ENTITY_TYPES else "system",
|
||||
source_id=_safe_id(row.source_id),
|
||||
error_code=row.error_code if row.error_code in ERROR_MESSAGES else "UNKNOWN",
|
||||
error_message=ERROR_MESSAGES.get(row.error_code, "任务未完成,请核对配置后重试"),
|
||||
retry_count=row.retry_count,
|
||||
create_time=row.create_time,
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
return PageResult[TPlusErrorOut](
|
||||
records=records, total=total, page_num=query.page_num, page_size=query.page_size
|
||||
)
|
||||
|
||||
|
||||
def overview(session) -> TPlusOverviewOut:
|
||||
"""只校验配置,不在页面查询期间建立 T+ 连接。"""
|
||||
try:
|
||||
resolve_tplus_settings(task_service.get_settings())
|
||||
configured, message = True, "只读配置已就绪,实际连接结果以任务执行为准"
|
||||
except RuntimeError:
|
||||
configured, message = False, "T+ 只读配置未就绪,请联系管理员配置"
|
||||
latest = repository.latest_audit(session)
|
||||
return TPlusOverviewOut(
|
||||
is_configured=configured,
|
||||
configuration_message=message,
|
||||
status_counts=repository.status_counts(session),
|
||||
total_staged_records=repository.total_staged_records(session),
|
||||
latest_audit=public_report(latest.audit_result_json) if latest else None,
|
||||
)
|
||||
95
backend/app/services/tplus/reader.py
Normal file
95
backend/app/services/tplus/reader.py
Normal file
@ -0,0 +1,95 @@
|
||||
"""T+ 六类源表的只读读取器。"""
|
||||
|
||||
from collections.abc import Iterator
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import BigInteger, Integer, bindparam, text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.services.tplus.mapping import TPLUS_MAPPINGS, get_mapping
|
||||
|
||||
_ENTITY_QUERY_FILES = {
|
||||
"personnel": "personnel.sql",
|
||||
"products": "products.sql",
|
||||
"customers": "customers.sql",
|
||||
"businesses": "businesses.sql",
|
||||
"shipments": "shipments.sql",
|
||||
"receipts": "receipts.sql",
|
||||
"business_items": "business_items.sql",
|
||||
"shipment_items": "shipment_items.sql",
|
||||
"receipt_items": "receipt_items.sql",
|
||||
}
|
||||
|
||||
|
||||
class TPlusReader:
|
||||
"""执行白名单 SQL 文件,禁止调用方传入任意表名或 SQL。"""
|
||||
|
||||
def __init__(self, session: Session, query_dir: Path | None = None) -> None:
|
||||
self.session = session
|
||||
self.query_dir = query_dir or self._default_query_dir()
|
||||
|
||||
@staticmethod
|
||||
def _default_query_dir() -> Path:
|
||||
return Path(__file__).resolve().parents[4] / "db" / "queries" / "tplus"
|
||||
|
||||
def iter_entity(
|
||||
self,
|
||||
entity_type: str,
|
||||
*,
|
||||
watermark: datetime | None = None,
|
||||
batch_size: int = 500,
|
||||
) -> Iterator[dict[str, Any]]:
|
||||
"""按实体流式读取,水位只通过绑定参数传入。"""
|
||||
if entity_type not in _ENTITY_QUERY_FILES:
|
||||
raise KeyError(f"不支持的 T+ 实体类型:{entity_type}")
|
||||
if batch_size < 1 or batch_size > 5000:
|
||||
raise ValueError("batch_size 必须在 1 到 5000 之间")
|
||||
if watermark is not None:
|
||||
raise ValueError("增量水位语义尚未确认,当前只支持全量读取和哈希去重")
|
||||
get_mapping(entity_type)
|
||||
sql_path = self.query_dir / _ENTITY_QUERY_FILES[entity_type]
|
||||
if not sql_path.is_file():
|
||||
raise FileNotFoundError(f"缺少 T+ 查询文件:{sql_path}")
|
||||
|
||||
statement = text(sql_path.read_text(encoding="utf-8")).bindparams(
|
||||
bindparam("batch_size", type_=Integer()), bindparam("after_id", type_=BigInteger())
|
||||
)
|
||||
after_id = -2147483649 # 所有源表主键均已核验为 SQL Server int,包含其最小值。
|
||||
while True:
|
||||
result = self.session.execute(
|
||||
statement, {"batch_size": batch_size, "after_id": after_id}
|
||||
)
|
||||
try:
|
||||
rows = [dict(row) for row in result.mappings().all()]
|
||||
finally:
|
||||
result.close()
|
||||
if not rows:
|
||||
return
|
||||
next_id = int(rows[-1]["id"])
|
||||
if next_id <= after_id:
|
||||
raise ValueError("T+ 源主键未递增,停止读取以避免重复循环")
|
||||
yield from rows
|
||||
after_id = next_id
|
||||
|
||||
def fetch_entity(
|
||||
self,
|
||||
entity_type: str,
|
||||
*,
|
||||
watermark: datetime | None = None,
|
||||
max_rows: int = 5000,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""读取有限数量的实体行,供核验命令和单元测试使用。"""
|
||||
if max_rows < 1:
|
||||
raise ValueError("max_rows 必须大于 0")
|
||||
rows: list[dict[str, Any]] = []
|
||||
for row in self.iter_entity(entity_type, watermark=watermark):
|
||||
rows.append(row)
|
||||
if len(rows) >= max_rows:
|
||||
break
|
||||
return rows
|
||||
|
||||
def inventory(self) -> tuple[str, ...]:
|
||||
"""返回当前适配器支持的实体类型。"""
|
||||
return tuple(mapping.entity_type for mapping in TPLUS_MAPPINGS)
|
||||
135
backend/app/services/tplus/relationship_audit.py
Normal file
135
backend/app/services/tplus/relationship_audit.py
Normal file
@ -0,0 +1,135 @@
|
||||
"""读取实际系统字典并统计带单据类型的收款关联,不推断计提口径。"""
|
||||
|
||||
import re
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.logging import scrub
|
||||
from app.services.tplus.reader import TPlusReader
|
||||
|
||||
_COLUMNS = {
|
||||
"source_version": ("source_version",),
|
||||
"voucher_types": ("id", "code", "name"),
|
||||
"state_enums": ("id", "code", "name", "enum_name"),
|
||||
"receipt_kinds": ("voucher_type_code", "voucher_type_name", "receive_flag", "row_count"),
|
||||
"receipt_line_kinds": ("voucher_type_id", "voucher_type_name", "receive_flag", "row_count"),
|
||||
"sales_links": (
|
||||
"row_count",
|
||||
"delivery_matches",
|
||||
"detail_matches",
|
||||
"direct_order_matches",
|
||||
"detail_order_matches",
|
||||
"unambiguous_order_matches",
|
||||
"invalid_order_ids",
|
||||
"conflicting_order_ids",
|
||||
),
|
||||
"order_coverage": ("row_count", "without_order", "single_order", "multiple_orders"),
|
||||
}
|
||||
PENDING_CONFIRMATIONS = (
|
||||
("cost_basis", "绩效成本取值", "订单最新成本或出库成本的类型、时点和含税口径需业务确认"),
|
||||
("receipt_date", "到账日", "单据日、审核日和明细 arrivalDate 的业务含义需确认"),
|
||||
("ship_date", "发货日", "回款天数应使用哪个发货日期需确认"),
|
||||
("allocation", "多单分摊", "一笔收款涉及多张销货单或订单时,不能自动把整笔金额记给一单"),
|
||||
("negative_events", "退货与红冲", "负数、冲销及已结算业务的调整方式需确认"),
|
||||
)
|
||||
|
||||
|
||||
def public_report(raw: dict | None) -> dict | None:
|
||||
"""历史报告也按当前字段白名单输出,禁止透传未知嵌套数据。"""
|
||||
if not isinstance(raw, dict):
|
||||
return None
|
||||
result = {
|
||||
key: TPlusRelationshipAudit._safe_value(raw.get(key))
|
||||
for key in (
|
||||
"schema_version",
|
||||
"captured_at",
|
||||
"source_version",
|
||||
"is_target_engine",
|
||||
"relationship_status",
|
||||
)
|
||||
}
|
||||
result["compatibility_status"] = "PENDING"
|
||||
for section, columns in _COLUMNS.items():
|
||||
if section in ("source_version", "voucher_types"):
|
||||
continue
|
||||
value = raw.get(section)
|
||||
if isinstance(value, list):
|
||||
result[section] = [
|
||||
{key: TPlusRelationshipAudit._safe_value(row.get(key)) for key in columns}
|
||||
for row in value[:200]
|
||||
if isinstance(row, dict)
|
||||
]
|
||||
elif isinstance(value, dict):
|
||||
result[section] = {
|
||||
key: TPlusRelationshipAudit._safe_value(value.get(key)) for key in columns
|
||||
}
|
||||
else:
|
||||
result[section] = None
|
||||
result["pending_confirmations"] = [
|
||||
{"key": k, "title": t, "reason": r} for k, t, r in PENDING_CONFIRMATIONS
|
||||
]
|
||||
return result
|
||||
|
||||
|
||||
class TPlusRelationshipAudit:
|
||||
"""执行固定核验查询;结果只包含系统字典和数量,不包含客户记录。"""
|
||||
|
||||
def __init__(self, session: Session, query_dir: Path | None = None) -> None:
|
||||
self.session = session
|
||||
root = query_dir or TPlusReader._default_query_dir()
|
||||
source = (root / "audit" / "queries.sql").read_text(encoding="utf-8")
|
||||
sections = re.split(r"(?m)^-- @query (\w+)\s*$", source)
|
||||
self.queries = dict(zip(sections[1::2], sections[2::2], strict=True))
|
||||
if set(self.queries) != set(_COLUMNS):
|
||||
raise ValueError("关联核验查询清单不完整")
|
||||
|
||||
def fetch(self, name: str, **params) -> list[dict]:
|
||||
"""只接受登记的查询名与绑定参数,过滤非合同字段。"""
|
||||
if name not in self.queries:
|
||||
raise ValueError("不支持的核验查询")
|
||||
result = self.session.execute(text(self.queries[name]), params)
|
||||
try:
|
||||
return [
|
||||
{key: self._safe_value(row.get(key)) for key in _COLUMNS[name]}
|
||||
for row in result.mappings().all()
|
||||
]
|
||||
finally:
|
||||
result.close()
|
||||
|
||||
@staticmethod
|
||||
def _safe_value(value):
|
||||
if value is None or isinstance(value, (int, bool)):
|
||||
return value
|
||||
return scrub(re.sub(r"[\x00-\x1f]", " ", str(value)))[:200]
|
||||
|
||||
def run(self) -> dict:
|
||||
"""生成事实报告;类型缺失或有歧义时不执行该关联链路。"""
|
||||
version = self.fetch("source_version")[0]["source_version"]
|
||||
types = self.fetch("voucher_types")
|
||||
report = {
|
||||
"schema_version": 1,
|
||||
"captured_at": datetime.now().isoformat(),
|
||||
"source_version": version,
|
||||
"is_target_engine": str(version).startswith("10.50."),
|
||||
"compatibility_status": "PENDING",
|
||||
"state_enums": self.fetch("state_enums"),
|
||||
"receipt_kinds": self.fetch("receipt_kinds"),
|
||||
"receipt_line_kinds": self.fetch("receipt_line_kinds"),
|
||||
"sales_links": None,
|
||||
"order_coverage": None,
|
||||
"relationship_status": "UNRESOLVED",
|
||||
"relationship_basis": "主表按 isReceiveFlag=1(是否收款)筛选;明细按 SM_VoucherType 的 SA04 类型关联。主表 idvouchertype 是来源类型,不能据此筛本单类型。",
|
||||
"pending_confirmations": [
|
||||
{"key": k, "title": t, "reason": r} for k, t, r in PENDING_CONFIRMATIONS
|
||||
],
|
||||
}
|
||||
sale = [r["id"] for r in types if r["code"] == "SA04"]
|
||||
if len(sale) == 1:
|
||||
params = {"sale_delivery_type_id": sale[0], "receive_flag": 1}
|
||||
report["sales_links"] = self.fetch("sales_links", **params)[0]
|
||||
report["order_coverage"] = self.fetch("order_coverage", **params)[0]
|
||||
report["relationship_status"] = "OBSERVED"
|
||||
return report
|
||||
123
backend/app/services/tplus/staging.py
Normal file
123
backend/app/services/tplus/staging.py
Normal file
@ -0,0 +1,123 @@
|
||||
"""T+ 原始行 staging 服务。"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import insert, select, tuple_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import TPlusSyncRecord
|
||||
|
||||
|
||||
def _json_default(value: Any) -> str:
|
||||
"""将日期、Decimal 等源字段转换为稳定 JSON 文本。"""
|
||||
if isinstance(value, (date, datetime)):
|
||||
return value.isoformat()
|
||||
if isinstance(value, Decimal):
|
||||
return str(value)
|
||||
if isinstance(value, (bytes, bytearray, memoryview)):
|
||||
return "0x" + bytes(value).hex()
|
||||
raise TypeError("T+ 源行包含无法序列化的数据类型")
|
||||
|
||||
|
||||
def stage_row(
|
||||
session: Session,
|
||||
*,
|
||||
batch_no: str,
|
||||
entity_type: str,
|
||||
source_table: str,
|
||||
row: dict[str, Any],
|
||||
) -> tuple[TPlusSyncRecord, bool]:
|
||||
"""幂等写入单条原始行;返回记录和是否新插入。"""
|
||||
values = prepare_row(batch_no, entity_type, source_table, row)
|
||||
existing = session.execute(
|
||||
select(TPlusSyncRecord).where(
|
||||
TPlusSyncRecord.entity_type == entity_type,
|
||||
TPlusSyncRecord.source_id == values["source_id"],
|
||||
TPlusSyncRecord.row_hash == values["row_hash"],
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if existing is not None:
|
||||
return existing, False
|
||||
|
||||
record = TPlusSyncRecord(**values)
|
||||
session.add(record)
|
||||
session.flush()
|
||||
return record, True
|
||||
|
||||
|
||||
def prepare_row(batch_no: str, entity_type: str, source_table: str, row: dict) -> dict:
|
||||
"""规范化原始值,生成与 JSON 列序列化一致的哈希。"""
|
||||
source_id = str(row["id"]) if row.get("id") is not None else ""
|
||||
if not source_id:
|
||||
raise ValueError("T+ 源行缺少 id,无法建立幂等键")
|
||||
source_no = row.get("code")
|
||||
payload = json.dumps(
|
||||
row, ensure_ascii=False, sort_keys=True, default=_json_default, allow_nan=False
|
||||
)
|
||||
return {
|
||||
"batch_no": batch_no,
|
||||
"entity_type": entity_type,
|
||||
"source_table": source_table,
|
||||
"source_id": source_id,
|
||||
"source_no": str(source_no).strip() if source_no is not None else None,
|
||||
"source_line_no": str(row.get("line_no")) if row.get("line_no") is not None else None,
|
||||
"source_updated_at": _source_datetime(row),
|
||||
"row_hash": hashlib.sha256(payload.encode("utf-8")).hexdigest(),
|
||||
"payload_json": json.loads(payload),
|
||||
}
|
||||
|
||||
|
||||
def stage_rows(session: Session, batch_no: str, mapping, rows: list[dict]) -> tuple[int, list]:
|
||||
"""批量去重和插入;数据库拒绝整批时逐条隔离,保留有效行。"""
|
||||
records, errors = {}, []
|
||||
for row in rows:
|
||||
try:
|
||||
values = prepare_row(batch_no, mapping.entity_type, mapping.source_table, row)
|
||||
records[(values["source_id"], values["row_hash"])] = values
|
||||
except (ValueError, TypeError) as exc:
|
||||
errors.append((row, type(exc).__name__))
|
||||
if not records:
|
||||
return 0, errors
|
||||
existing = set(
|
||||
session.execute(
|
||||
select(TPlusSyncRecord.source_id, TPlusSyncRecord.row_hash).where(
|
||||
TPlusSyncRecord.entity_type == mapping.entity_type,
|
||||
tuple_(TPlusSyncRecord.source_id, TPlusSyncRecord.row_hash).in_(list(records)),
|
||||
)
|
||||
).all()
|
||||
)
|
||||
pending = [v for k, v in records.items() if k not in existing]
|
||||
if not pending:
|
||||
return 0, errors
|
||||
try:
|
||||
with session.begin_nested():
|
||||
session.execute(insert(TPlusSyncRecord), pending)
|
||||
return len(pending), errors
|
||||
except Exception:
|
||||
# 唯一约束竞争或坏行触发回滚后,再逐条处理,不能丢掉同批有效行。
|
||||
inserted = 0
|
||||
for row in rows:
|
||||
try:
|
||||
with session.begin_nested():
|
||||
_, is_new = stage_row(
|
||||
session,
|
||||
batch_no=batch_no,
|
||||
entity_type=mapping.entity_type,
|
||||
source_table=mapping.source_table,
|
||||
row=row,
|
||||
)
|
||||
inserted += int(is_new)
|
||||
except Exception as exc:
|
||||
if not any(old is row for old, _ in errors):
|
||||
errors.append((row, type(exc).__name__))
|
||||
return inserted, errors
|
||||
|
||||
|
||||
def _source_datetime(row: dict[str, Any]) -> datetime | None:
|
||||
"""只接收真实时间值;SQL Server rowversion 不能当作日期。"""
|
||||
value = row.get("source_updated_at") or row.get("updated")
|
||||
return value if isinstance(value, datetime) else None
|
||||
131
backend/app/services/tplus/sync_service.py
Normal file
131
backend/app/services/tplus/sync_service.py
Normal file
@ -0,0 +1,131 @@
|
||||
"""T+ 只读快照同步:批量 staging、幂等去重和失败隔离。"""
|
||||
|
||||
from collections.abc import Iterable
|
||||
from datetime import datetime
|
||||
from itertools import batched
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.core.tplus_lock import sync_lock
|
||||
from app.models import TPlusSyncBatch, TPlusSyncError
|
||||
from app.services.tplus.mapping import TPlusMapping, get_mapping
|
||||
from app.services.tplus.reader import TPlusReader
|
||||
from app.services.tplus.staging import stage_rows
|
||||
from app.services.tplus.task_service import fail_batch, new_batch_no, recover_running
|
||||
|
||||
|
||||
class TPlusSyncService:
|
||||
"""按分页提交原始快照,业务表始终不参与本服务。"""
|
||||
|
||||
def __init__(self, source_session: Session, target_session: Session) -> None:
|
||||
self.source_session = source_session
|
||||
self.target_session = target_session
|
||||
configured = get_settings().t_plus_query_dir
|
||||
self.reader = TPlusReader(source_session, Path(configured) if configured else None)
|
||||
|
||||
def run(
|
||||
self,
|
||||
*,
|
||||
entity_types: Iterable[str],
|
||||
watermark: datetime | None = None,
|
||||
batch_no: str | None = None,
|
||||
batch_size: int = 500,
|
||||
) -> TPlusSyncBatch:
|
||||
"""执行可重复的全量读取,提交每页进度;字段语义未定时拒绝时间增量。"""
|
||||
entities = tuple(dict.fromkeys(entity_types))
|
||||
if not entities or not 1 <= batch_size <= 5000:
|
||||
raise ValueError("至少指定一个实体,batch_size 必须在 1 到 5000 之间")
|
||||
if watermark is not None:
|
||||
raise ValueError("增量水位语义尚未确认,当前只支持全量读取和哈希去重")
|
||||
for entity in entities:
|
||||
get_mapping(entity)
|
||||
with sync_lock(self.target_session):
|
||||
recover_running(self.target_session)
|
||||
batch = self._start_direct(entities, batch_no)
|
||||
try:
|
||||
return self.execute_claimed(batch, batch_size)
|
||||
except BaseException:
|
||||
self.target_session.rollback()
|
||||
fail_batch(self.target_session, batch, "INTERRUPTED")
|
||||
raise
|
||||
|
||||
def _start_direct(self, entities, batch_no):
|
||||
batch = TPlusSyncBatch(
|
||||
batch_no=batch_no or new_batch_no(),
|
||||
sync_mode="FULL",
|
||||
status="RUNNING",
|
||||
source_database=get_settings().t_plus_database or None,
|
||||
started_at=datetime.now(),
|
||||
source_record_count=0,
|
||||
staged_record_count=0,
|
||||
failed_record_count=0,
|
||||
skipped_record_count=0,
|
||||
entity_types_json=list(entities),
|
||||
)
|
||||
self.target_session.add(batch)
|
||||
self.target_session.commit()
|
||||
return batch
|
||||
|
||||
def execute_claimed(self, batch: TPlusSyncBatch, batch_size: int = 500):
|
||||
"""处理已持锁且标记 RUNNING 的任务;仅由直接入口或 worker 调用。"""
|
||||
if batch.status != "RUNNING" or not batch.entity_types_json:
|
||||
raise ValueError("任务尚未领取或读取范围不完整")
|
||||
mappings = [get_mapping(entity) for entity in batch.entity_types_json]
|
||||
for mapping in mappings:
|
||||
try:
|
||||
self._read_entity(batch, mapping, batch_size)
|
||||
except Exception as exc:
|
||||
self.target_session.rollback()
|
||||
self._record_error(batch, mapping, {}, "READ_ERROR", type(exc).__name__)
|
||||
self.target_session.commit()
|
||||
batch.status = "SUCCEEDED"
|
||||
if batch.failed_record_count:
|
||||
failed_rows = (
|
||||
self.target_session.scalar(
|
||||
select(func.count())
|
||||
.select_from(TPlusSyncError)
|
||||
.where(
|
||||
TPlusSyncError.batch_no == batch.batch_no,
|
||||
TPlusSyncError.error_code == "STAGE_ERROR",
|
||||
)
|
||||
)
|
||||
or 0
|
||||
)
|
||||
batch.status = "PARTIAL" if batch.source_record_count > failed_rows else "FAILED"
|
||||
batch.error_message = "部分数据未完成,请查询同步错误记录并重跑全量快照"
|
||||
batch.finished_at = datetime.now()
|
||||
batch.watermark_to = None
|
||||
self.target_session.commit()
|
||||
return batch
|
||||
|
||||
def _read_entity(self, batch: TPlusSyncBatch, mapping: TPlusMapping, size: int) -> None:
|
||||
"""批量写入一类数据;每页提交后即使进程中断也可幂等重跑。"""
|
||||
source = self.reader.iter_entity(mapping.entity_type, batch_size=size)
|
||||
for chunk in batched(source, size):
|
||||
inserted, errors = stage_rows(self.target_session, batch.batch_no, mapping, list(chunk))
|
||||
batch.source_record_count += len(chunk)
|
||||
batch.staged_record_count += inserted
|
||||
batch.skipped_record_count += len(chunk) - inserted - len(errors)
|
||||
for row, reason in errors:
|
||||
self._record_error(batch, mapping, row, "STAGE_ERROR", reason)
|
||||
self.target_session.commit()
|
||||
|
||||
def _record_error(
|
||||
self, batch: TPlusSyncBatch, mapping: TPlusMapping, row: dict, code: str, reason: str
|
||||
) -> None:
|
||||
"""只记录错误类别和源 ID,避免数据库异常携带个人信息或凭据。"""
|
||||
self.target_session.add(
|
||||
TPlusSyncError(
|
||||
batch_no=batch.batch_no,
|
||||
entity_type=mapping.entity_type,
|
||||
source_table=mapping.source_table,
|
||||
source_id=str(row["id"]) if row.get("id") is not None else None,
|
||||
source_line_no=str(row["line_no"]) if row.get("line_no") is not None else None,
|
||||
error_code=code,
|
||||
error_message=f"源数据处理失败({reason}),请核对连接和字段",
|
||||
)
|
||||
)
|
||||
batch.failed_record_count += 1
|
||||
184
backend/app/services/tplus/task_service.py
Normal file
184
backend/app/services/tplus/task_service.py
Normal file
@ -0,0 +1,184 @@
|
||||
"""持久化任务创建和重试,不在 HTTP 事务中读取源库。"""
|
||||
|
||||
import secrets
|
||||
from contextlib import nullcontext
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.core.exceptions import BadRequestError, NotFoundError
|
||||
from app.core.tplus_db import resolve_tplus_settings
|
||||
from app.models import TPlusSyncBatch, TPlusSyncError, User
|
||||
from app.repositories import tplus_repository as repository
|
||||
from app.schemas.tplus import TPlusCreateIn, TPlusRetryIn
|
||||
from app.services import audit_service
|
||||
from app.services.audit_service import AuditRecord, RequestMeta
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TaskActor:
|
||||
"""HTTP 操作者与请求元信息,CLI 调用不传。"""
|
||||
|
||||
user: User
|
||||
meta: RequestMeta | None = None
|
||||
|
||||
|
||||
def new_batch_no() -> str:
|
||||
"""生成最长 24 字符的可追溯批次号。"""
|
||||
return f"TP{datetime.now():%Y%m%d%H%M%S}{secrets.token_hex(4)}"
|
||||
|
||||
|
||||
def _audit(session, actor, action, data):
|
||||
if actor is None:
|
||||
return nullcontext()
|
||||
return audit_service.audit(
|
||||
session,
|
||||
operator=actor.user,
|
||||
meta=actor.meta,
|
||||
record=AuditRecord("T+数据同步", action, "T+ 数据任务" + action, data),
|
||||
)
|
||||
|
||||
|
||||
def _source_database() -> str:
|
||||
try:
|
||||
return resolve_tplus_settings(get_settings()).t_plus_database
|
||||
except RuntimeError as exc:
|
||||
raise BadRequestError(str(exc)) from None
|
||||
|
||||
|
||||
def _same_intent(batch, mode, entities, parent):
|
||||
if (
|
||||
batch.sync_mode != mode
|
||||
or sorted(batch.entity_types_json or []) != sorted(entities)
|
||||
or batch.parent_batch_no != parent
|
||||
):
|
||||
raise BadRequestError("幂等键已用于不同的任务请求")
|
||||
return batch
|
||||
|
||||
|
||||
def _insert(session, values):
|
||||
try:
|
||||
with session.begin_nested():
|
||||
batch = TPlusSyncBatch(**values)
|
||||
session.add(batch)
|
||||
session.flush()
|
||||
return batch, True
|
||||
except IntegrityError:
|
||||
batch = repository.get_request(session, values["request_key"], for_update=True)
|
||||
if batch is None:
|
||||
raise
|
||||
return _same_intent(
|
||||
batch, values["sync_mode"], values["entity_types_json"], values["parent_batch_no"]
|
||||
), False
|
||||
|
||||
|
||||
def _values(payload, database, actor, parent=None):
|
||||
return {
|
||||
"batch_no": new_batch_no(),
|
||||
"request_key": str(payload.request_key),
|
||||
"status": "QUEUED",
|
||||
"sync_mode": "AUDIT" if payload.task_type == "AUDIT" else "FULL",
|
||||
"entity_types_json": sorted(payload.entity_types),
|
||||
"source_database": database,
|
||||
"requested_by": actor.user.id if actor else None,
|
||||
"parent_batch_no": parent,
|
||||
"started_at": None,
|
||||
"source_record_count": 0,
|
||||
"staged_record_count": 0,
|
||||
"failed_record_count": 0,
|
||||
"skipped_record_count": 0,
|
||||
}
|
||||
|
||||
|
||||
def create_task(session: Session, payload: TPlusCreateIn, actor: TaskActor | None = None):
|
||||
"""原子创建任务;重放已存在的意图不依赖此刻的源连接状态。"""
|
||||
with _audit(session, actor, "创建读取任务", payload.model_dump(mode="json")):
|
||||
existing = repository.get_request(session, str(payload.request_key))
|
||||
mode = "AUDIT" if payload.task_type == "AUDIT" else "FULL"
|
||||
if existing:
|
||||
batch = _same_intent(existing, mode, payload.entity_types, None)
|
||||
else:
|
||||
batch, _ = _insert(session, _values(payload, _source_database(), actor))
|
||||
session.commit()
|
||||
return batch
|
||||
|
||||
|
||||
def retry_task(
|
||||
session: Session, parent_no: str, payload: TPlusRetryIn, actor: TaskActor | None = None
|
||||
):
|
||||
"""锁父批次后创建一个新任务;不覆盖原批次及异常历史。"""
|
||||
with _audit(
|
||||
session,
|
||||
actor,
|
||||
"重试读取任务",
|
||||
{"parent_batch_no": parent_no, "request_key": str(payload.request_key)},
|
||||
):
|
||||
existing = repository.get_request(session, str(payload.request_key))
|
||||
if existing:
|
||||
if existing.parent_batch_no != parent_no:
|
||||
raise BadRequestError("幂等键已用于不同的重试请求")
|
||||
session.commit()
|
||||
batch = existing
|
||||
else:
|
||||
parent = repository.get_batch(session, parent_no, for_update=True)
|
||||
if parent is None:
|
||||
raise NotFoundError("同步批次不存在")
|
||||
request = _retry_request(parent, payload)
|
||||
active = repository.active_retry(session, parent_no)
|
||||
if active:
|
||||
if active.request_key != str(payload.request_key):
|
||||
raise BadRequestError("该批次已有未完成的重试任务,请刷新列表查看")
|
||||
batch = active
|
||||
else:
|
||||
batch, inserted = _insert(
|
||||
session, _values(request, _source_database(), actor, parent_no)
|
||||
)
|
||||
if inserted:
|
||||
repository.increment_retry_errors(session, parent_no)
|
||||
session.commit()
|
||||
return batch
|
||||
|
||||
|
||||
def _retry_request(parent, payload):
|
||||
if parent.status not in ("FAILED", "PARTIAL"):
|
||||
raise BadRequestError("只能重试失败或部分失败的批次")
|
||||
if parent.sync_mode not in ("FULL", "AUDIT"):
|
||||
raise BadRequestError("该同步方式尚未启用,请重新选择读取任务")
|
||||
if parent.sync_mode != "AUDIT" and parent.entity_types_json is None:
|
||||
raise BadRequestError("历史批次未记录范围,请重新选择读取类型")
|
||||
return TPlusCreateIn(
|
||||
task_type="AUDIT" if parent.sync_mode == "AUDIT" else "READ",
|
||||
entity_types=parent.entity_types_json or [],
|
||||
request_key=payload.request_key,
|
||||
)
|
||||
|
||||
|
||||
def fail_batch(session: Session, batch: TPlusSyncBatch, code: str) -> None:
|
||||
"""只保存固定错误摘要;即使原异常含连接密码也不直接落库。"""
|
||||
messages = {
|
||||
"INTERRUPTED": "执行进程中断,请重新读取;已暂存数据会自动去重",
|
||||
"EXECUTION_ERROR": "任务执行失败,请检查只读连接和字段映射后重试",
|
||||
"TYPE_UNRESOLVED": "核验所需单据类型缺失或不唯一,请核对源字典",
|
||||
}
|
||||
message = messages.get(code, "任务未完成,请核对运行配置")
|
||||
batch.status, batch.finished_at, batch.error_message = "FAILED", datetime.now(), message
|
||||
batch.failed_record_count = (batch.failed_record_count or 0) + 1
|
||||
session.add(
|
||||
TPlusSyncError(
|
||||
batch_no=batch.batch_no,
|
||||
entity_type="system",
|
||||
source_table="system",
|
||||
error_code=code,
|
||||
error_message=message,
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
|
||||
|
||||
def recover_running(session: Session) -> None:
|
||||
"""仅在持有执行锁时调用;锁证明之前的执行者已经退出。"""
|
||||
for batch in repository.running_batches(session):
|
||||
fail_batch(session, batch, "INTERRUPTED")
|
||||
61
backend/app/services/tplus/worker.py
Normal file
61
backend/app/services/tplus/worker.py
Normal file
@ -0,0 +1,61 @@
|
||||
"""持久化任务执行者;获取连接级锁后才恢复或领取任务。"""
|
||||
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from app.core.tplus_db import get_tplus_session_factory, resolve_tplus_settings
|
||||
from app.core.tplus_lock import TPlusBusyError, sync_lock
|
||||
from app.repositories import tplus_repository as repository
|
||||
from app.services.tplus import task_service
|
||||
from app.services.tplus.relationship_audit import TPlusRelationshipAudit
|
||||
from app.services.tplus.sync_service import TPlusSyncService
|
||||
|
||||
|
||||
def run_once(session, source_factory=None):
|
||||
"""返回本轮处理的批次号;忙锁或队列为空时不假称已执行。"""
|
||||
try:
|
||||
with sync_lock(session):
|
||||
task_service.recover_running(session)
|
||||
batch = repository.next_queued(session)
|
||||
if batch is None:
|
||||
session.rollback()
|
||||
return None
|
||||
batch.status, batch.started_at = "RUNNING", datetime.now()
|
||||
session.commit()
|
||||
_execute(session, batch, source_factory)
|
||||
return batch.batch_no
|
||||
except TPlusBusyError:
|
||||
session.rollback()
|
||||
return None
|
||||
|
||||
|
||||
def _execute(session, batch, source_factory):
|
||||
try:
|
||||
settings = resolve_tplus_settings(task_service.get_settings())
|
||||
if batch.source_database and batch.source_database != settings.t_plus_database:
|
||||
raise RuntimeError("任务排队后的源账套配置已改变")
|
||||
factory = source_factory or get_tplus_session_factory()
|
||||
with factory() as source:
|
||||
if batch.sync_mode == "AUDIT":
|
||||
root = Path(settings.t_plus_query_dir) if settings.t_plus_query_dir else None
|
||||
report = TPlusRelationshipAudit(source, root).run()
|
||||
batch.audit_result_json = report
|
||||
batch.source_record_count = sum(row["row_count"] for row in report["receipt_kinds"])
|
||||
batch.finished_at, batch.status = datetime.now(), "SUCCEEDED"
|
||||
if report["relationship_status"] != "OBSERVED":
|
||||
task_service.fail_batch(session, batch, "TYPE_UNRESOLVED")
|
||||
else:
|
||||
session.commit()
|
||||
else:
|
||||
TPlusSyncService(source, session).execute_claimed(batch)
|
||||
except BaseException as exc:
|
||||
session.rollback()
|
||||
task_service.fail_batch(
|
||||
session,
|
||||
batch,
|
||||
"INTERRUPTED"
|
||||
if isinstance(exc, (KeyboardInterrupt, SystemExit))
|
||||
else "EXECUTION_ERROR",
|
||||
)
|
||||
if isinstance(exc, (KeyboardInterrupt, SystemExit)):
|
||||
raise
|
||||
@ -38,6 +38,11 @@ dependencies = [
|
||||
"python-multipart>=0.0.32",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
tplus = [
|
||||
"pyodbc>=5.2,<6",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"pytest>=8.3",
|
||||
|
||||
@ -1,6 +1,21 @@
|
||||
# 后端测试
|
||||
|
||||
> AI摘要:本项目后端的测试用例清单与运行记录。按「基础设施层随实现即时测、业务模块整模块完成时统一测」分层组织。当前 697 个用例全绿(2026-10-16,M3 增量批次后)。
|
||||
> AI 摘要:本轮 T+ 管理已完成真实 MySQL、只读源库与浏览器验证。最终单元测试 602 项通过;管理/并发/结构定点 18 项通过。真正的 2008 R2 验收仍未执行。
|
||||
|
||||
## T+ 本轮核验(2026-10-27)
|
||||
|
||||
- 完整回归在独立 MySQL `payroll_tplus_validation_20261027`、Redis 15 库运行:984 passed、2 failed。两项失败均为配置单测预期默认 Redis 0 库,而进程继承了集成用的 `REDIS_DB=15`;没有修改默认配置或削弱断言。
|
||||
- 后续将单元测试放在不设置上述集成覆盖值的独立进程中运行:602 passed。最终管理 API、真实并发和结构一致性定点测试:18 passed,包含新增加的配置关闭及创建/重试审计。
|
||||
- 真实并发:相同请求只生成一个批次;不同键同时重试同父批次只有一个成功,无死锁;执行锁跨 ORM commit 仍有效,退出后可再获取。
|
||||
- V15 在隔离库 downgrade 到 V14,再 upgrade 到 head 通过;当前 head `e5f607a8b9c0`。模型、Alembic、留存 SQL 和初始化脚本的结构检查通过;此前 V1–V14 留存 SQL 已从空库执行通过。
|
||||
- 人员重读 847 条,新增 0、重复跳过 847、失败 0;关联报告覆盖 103,318 条销货类收款明细,50,044 条有明确订单关联,六类业务表行数不变。独立证据位于 D 盘 reports/management_verification_2026-10-27.json。
|
||||
- Chrome 真实接口检查通过:排队、受控失败、异常查看、页面重试、人员去重、关联检查、筛选、普通用户拒绝访问,以及 320/768/1024/1440 宽度。无页面运行错误;临时账号和批次已清理。
|
||||
- 加载、空态、配置关闭、概况失败另外使用浏览器响应模拟验证,不能与真实源链路混记。证据位于 .ai/archive/ai-aux/preview/tplus/。
|
||||
- 320 像素主内容可操作,现有全局顶栏仍有文字挤压;未扩大本轮范围修改共享布局。宽表保留横向滚动,截图和限制见同步管理验收报告。
|
||||
- 前端类型检查、生产构建和 Ruff 通过;构建保留既有的大分块提醒,测试有现存 Starlette TestClient 弃用提醒。
|
||||
- 源查询保持参数化与旧版语法,时间增量和业务表写入均关闭。合法 Windows/SQL Server 2008 R2 安装介质仍未取得,不能把 2022 的结果标记为客户版本兼容。
|
||||
|
||||
单元测试与集成测试建议分独立终端运行:单元测试不设置集成覆盖值;集成测试使用隔离 MySQL 和 Redis。通用集成夹具会清理数据,不能指向开发或客户业务库。Windows 可设置 `PYTEST_DEBUG_TEMPROOT` 到项目 D 盘的 ai-aux/pytest-tmp,避免不同权限令牌共享系统临时目录。
|
||||
|
||||
## 一、测试约定
|
||||
|
||||
|
||||
@ -4,6 +4,36 @@ import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tplus_session(monkeypatch):
|
||||
"""T+ 管理逻辑的真实内存事务;MySQL 并发锁另做集成验证。"""
|
||||
from sqlalchemy import DefaultClause, Integer, MetaData, create_engine, text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import Settings
|
||||
from app.models import TPlusSyncBatch, TPlusSyncError, TPlusSyncRecord
|
||||
|
||||
engine = create_engine("sqlite:///:memory:")
|
||||
metadata = MetaData()
|
||||
for model in (TPlusSyncBatch, TPlusSyncError, TPlusSyncRecord):
|
||||
table = model.__table__.to_metadata(metadata)
|
||||
table.c.id.type = Integer()
|
||||
table.c.update_time.server_default = DefaultClause(text("CURRENT_TIMESTAMP"))
|
||||
metadata.create_all(engine)
|
||||
settings = Settings(
|
||||
_env_file=None,
|
||||
app_secret_key="x" * 32,
|
||||
t_plus_enabled=True,
|
||||
t_plus_database="unit_tplus",
|
||||
t_plus_user="reader",
|
||||
t_plus_odbc_driver="unit",
|
||||
)
|
||||
monkeypatch.setattr("app.services.tplus.task_service.get_settings", lambda: settings)
|
||||
with Session(engine, expire_on_commit=False) as session:
|
||||
yield session
|
||||
engine.dispose()
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def client() -> TestClient:
|
||||
"""构造应用测试客户端(会话级复用,避免重复构建应用)。
|
||||
|
||||
@ -324,3 +324,36 @@ def normal(make_user, auth_headers) -> dict:
|
||||
user = make_user([ROLE_USER])
|
||||
user["headers"] = auth_headers(user)
|
||||
return user
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tplus_tasks(monkeypatch):
|
||||
"""隔离测试任务,仅清理此夹具创建的批次和异常。"""
|
||||
from app.core.config import Settings
|
||||
from app.models import TPlusSyncBatch, TPlusSyncError
|
||||
|
||||
settings = Settings(
|
||||
_env_file=None,
|
||||
app_secret_key="x" * 32,
|
||||
t_plus_enabled=True,
|
||||
t_plus_database="unit_tplus",
|
||||
t_plus_user="reader",
|
||||
t_plus_odbc_driver="unit",
|
||||
)
|
||||
monkeypatch.setattr("app.services.tplus.task_service.get_settings", lambda: settings)
|
||||
keys = []
|
||||
|
||||
def payload():
|
||||
key = str(uuid.uuid4())
|
||||
keys.append(key)
|
||||
return {"task_type": "READ", "entity_types": ["personnel"], "request_key": key}
|
||||
|
||||
yield payload
|
||||
with get_session_factory()() as db:
|
||||
numbers = list(
|
||||
db.scalars(select(TPlusSyncBatch.batch_no).where(TPlusSyncBatch.request_key.in_(keys)))
|
||||
)
|
||||
if numbers:
|
||||
db.execute(delete(TPlusSyncError).where(TPlusSyncError.batch_no.in_(numbers)))
|
||||
db.execute(delete(TPlusSyncBatch).where(TPlusSyncBatch.batch_no.in_(numbers)))
|
||||
db.commit()
|
||||
|
||||
@ -487,8 +487,18 @@ class TestManagerAggregate:
|
||||
assert len([i for i in out["items"] if i["calc_type"] == 2]) == 1
|
||||
assert len([i for i in out["items"] if i["calc_type"] == 1]) == 1
|
||||
|
||||
def test_月末取参_预约变更影响本月聚合(self, client: TestClient, admin: dict) -> None:
|
||||
def test_月末取参_预约变更影响本月聚合(
|
||||
self, client: TestClient, admin: dict, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""取参锚点=所属月月末:10-25 生效的预约变更应影响 2026-10 月结。"""
|
||||
|
||||
class ScenarioDate(date):
|
||||
@classmethod
|
||||
def today(cls):
|
||||
return cls(2026, 10, 20)
|
||||
|
||||
# 固定预约场景的申请日,避免现实日期超过 10-25 后误测成“禁止追溯修改”。
|
||||
monkeypatch.setattr("app.services.param_service.date", ScenarioDate)
|
||||
resp = client.post(
|
||||
"/api/v1/system/params/changes",
|
||||
json={
|
||||
|
||||
90
backend/tests/integration/test_tplus_concurrency.py
Normal file
90
backend/tests/integration/test_tplus_concurrency.py
Normal file
@ -0,0 +1,90 @@
|
||||
"""在隔离 MySQL 中验证幂等竞争、父批次串行化和连接级锁。"""
|
||||
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from threading import Barrier
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import delete, func, select, text
|
||||
|
||||
from app.core.db import get_session_factory
|
||||
from app.core.exceptions import BadRequestError
|
||||
from app.core.tplus_lock import TPlusBusyError, sync_lock
|
||||
from app.models import TPlusSyncBatch
|
||||
from app.schemas.tplus import TPlusCreateIn, TPlusRetryIn
|
||||
from app.services.tplus import task_service
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
def _parallel(action):
|
||||
barrier = Barrier(2)
|
||||
|
||||
def run():
|
||||
with get_session_factory()() as session:
|
||||
barrier.wait(timeout=10)
|
||||
return action(session)
|
||||
|
||||
with ThreadPoolExecutor(max_workers=2) as pool:
|
||||
futures = [pool.submit(run) for _ in range(2)]
|
||||
return [future.result(timeout=20) for future in futures]
|
||||
|
||||
|
||||
def test_并发相同请求只创建一个批次(tplus_tasks):
|
||||
payload = TPlusCreateIn(**tplus_tasks())
|
||||
numbers = _parallel(lambda session: task_service.create_task(session, payload).batch_no)
|
||||
assert numbers[0] == numbers[1]
|
||||
with get_session_factory()() as session:
|
||||
assert (
|
||||
session.scalar(
|
||||
select(func.count())
|
||||
.select_from(TPlusSyncBatch)
|
||||
.where(TPlusSyncBatch.request_key == str(payload.request_key))
|
||||
)
|
||||
== 1
|
||||
)
|
||||
|
||||
|
||||
def test_并发不同请求重试同父批次只允许一个(tplus_tasks):
|
||||
with get_session_factory()() as session:
|
||||
parent = task_service.create_task(session, TPlusCreateIn(**tplus_tasks()))
|
||||
parent.status = "FAILED"
|
||||
session.commit()
|
||||
number = parent.batch_no
|
||||
|
||||
def retry(session):
|
||||
try:
|
||||
return task_service.retry_task(
|
||||
session, number, TPlusRetryIn(request_key=uuid4())
|
||||
).batch_no
|
||||
except BadRequestError:
|
||||
session.rollback()
|
||||
return "REJECTED"
|
||||
|
||||
try:
|
||||
numbers = _parallel(retry)
|
||||
assert numbers.count("REJECTED") == 1
|
||||
with get_session_factory()() as session:
|
||||
assert (
|
||||
session.scalar(
|
||||
select(func.count())
|
||||
.select_from(TPlusSyncBatch)
|
||||
.where(TPlusSyncBatch.parent_batch_no == number)
|
||||
)
|
||||
== 1
|
||||
)
|
||||
finally:
|
||||
with get_session_factory()() as session:
|
||||
session.execute(delete(TPlusSyncBatch).where(TPlusSyncBatch.parent_batch_no == number))
|
||||
session.commit()
|
||||
|
||||
|
||||
def test_执行锁跨提交保持且释放后可获取():
|
||||
with get_session_factory()() as owner, get_session_factory()() as other:
|
||||
with sync_lock(owner):
|
||||
owner.execute(text("SELECT 1"))
|
||||
owner.commit()
|
||||
with pytest.raises(TPlusBusyError), sync_lock(other):
|
||||
pytest.fail("另一执行者不应取得锁")
|
||||
with sync_lock(other):
|
||||
assert other.scalar(text("SELECT 1")) == 1
|
||||
129
backend/tests/integration/test_tplus_management.py
Normal file
129
backend/tests/integration/test_tplus_management.py
Normal file
@ -0,0 +1,129 @@
|
||||
"""管理员可管理原始读取任务;权限、幂等和异常脱敏由真实接口验证。"""
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.db import get_session_factory
|
||||
from app.models import TPlusSyncBatch, TPlusSyncError
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
BASE = "/api/v1/system/tplus"
|
||||
|
||||
|
||||
def test_未登录和普通用户不能访问管理(client, admin, normal, tplus_tasks):
|
||||
assert client.get(BASE + "/overview").status_code == 401
|
||||
assert client.get(BASE + "/overview", headers=normal["headers"]).status_code == 403
|
||||
assert (
|
||||
client.post(BASE + "/batches", json=tplus_tasks(), headers=normal["headers"]).status_code
|
||||
== 403
|
||||
)
|
||||
|
||||
|
||||
def test_创建重复请求返回同一批次并可分页查看(client, admin, tplus_tasks):
|
||||
payload = tplus_tasks()
|
||||
first = client.post(BASE + "/batches", json=payload, headers=admin["headers"])
|
||||
assert first.status_code == 200
|
||||
second = client.post(BASE + "/batches", json=payload, headers=admin["headers"])
|
||||
assert second.status_code == 200
|
||||
number = first.json()["data"]["batch_no"]
|
||||
assert second.json()["data"]["batch_no"] == number
|
||||
page = client.get(
|
||||
BASE + "/batches", params={"pageSize": 1, "status": "QUEUED"}, headers=admin["headers"]
|
||||
)
|
||||
assert page.status_code == 200 and page.json()["data"]["pageSize"] == 1
|
||||
detail = client.get(BASE + "/batches/" + number, headers=admin["headers"])
|
||||
assert detail.status_code == 200
|
||||
assert detail.json()["data"]["status"] == "QUEUED"
|
||||
|
||||
|
||||
def test_异常返回固定说明不泄漏数据库参数(client, admin, tplus_tasks):
|
||||
response = client.post(BASE + "/batches", json=tplus_tasks(), headers=admin["headers"])
|
||||
assert response.status_code == 200
|
||||
number = response.json()["data"]["batch_no"]
|
||||
with get_session_factory()() as db:
|
||||
batch = db.scalar(select(TPlusSyncBatch).where(TPlusSyncBatch.batch_no == number))
|
||||
batch.status, batch.error_message = "FAILED", "password=private-value phone=13800000000"
|
||||
db.add(
|
||||
TPlusSyncError(
|
||||
batch_no=number,
|
||||
entity_type="personnel",
|
||||
source_table="AA_Person",
|
||||
source_id="12",
|
||||
error_code="READ_ERROR",
|
||||
error_message=batch.error_message,
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
for path in (f"/batches/{number}", f"/batches/{number}/errors"):
|
||||
result = client.get(BASE + path, headers=admin["headers"])
|
||||
assert result.status_code == 200
|
||||
assert "private-value" not in result.text and "13800000000" not in result.text
|
||||
|
||||
|
||||
def test_不支持的实体和不存在的批次有明确错误(client, admin, tplus_tasks):
|
||||
payload = tplus_tasks()
|
||||
payload["entity_types"] = ["users;drop"]
|
||||
assert client.post(BASE + "/batches", json=payload, headers=admin["headers"]).status_code == 400
|
||||
assert client.get(BASE + "/batches/TPmissing", headers=admin["headers"]).status_code == 404
|
||||
|
||||
|
||||
def test_配置关闭时可查询历史但不能排队(client, admin, tplus_tasks, monkeypatch):
|
||||
from app.services.tplus import task_service
|
||||
|
||||
settings = task_service.get_settings().model_copy(update={"t_plus_enabled": False})
|
||||
monkeypatch.setattr(task_service, "get_settings", lambda: settings)
|
||||
response = client.get(BASE + "/overview", headers=admin["headers"])
|
||||
assert response.status_code == 200
|
||||
assert response.json()["data"]["is_configured"] is False
|
||||
assert response.json()["data"]["business_import_enabled"] is False
|
||||
assert response.json()["data"]["compatibility_status"] == "PENDING"
|
||||
assert (
|
||||
client.post(BASE + "/batches", json=tplus_tasks(), headers=admin["headers"]).status_code
|
||||
== 400
|
||||
)
|
||||
|
||||
|
||||
def test_失败批次重试保留异常并返回新批次(client, admin, tplus_tasks):
|
||||
from app.models import OperationLog
|
||||
|
||||
parent = client.post(BASE + "/batches", json=tplus_tasks(), headers=admin["headers"]).json()[
|
||||
"data"
|
||||
]
|
||||
number = parent["batch_no"]
|
||||
with get_session_factory()() as db:
|
||||
batch = db.scalar(select(TPlusSyncBatch).where(TPlusSyncBatch.batch_no == number))
|
||||
batch.status, batch.failed_record_count = "FAILED", 1
|
||||
db.add(
|
||||
TPlusSyncError(
|
||||
batch_no=number,
|
||||
entity_type="personnel",
|
||||
source_table="AA_Person",
|
||||
source_id="1",
|
||||
error_code="READ_ERROR",
|
||||
error_message="读取失败",
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
request = {"request_key": tplus_tasks()["request_key"]}
|
||||
first = client.post(BASE + f"/batches/{number}/retry", json=request, headers=admin["headers"])
|
||||
replay = client.post(BASE + f"/batches/{number}/retry", json=request, headers=admin["headers"])
|
||||
assert first.status_code == replay.status_code == 200
|
||||
child = first.json()["data"]
|
||||
assert child["parent_batch_no"] == number and child["batch_no"] != number
|
||||
assert replay.json()["data"]["batch_no"] == child["batch_no"]
|
||||
detail = client.get(BASE + f"/batches/{number}", headers=admin["headers"]).json()["data"]
|
||||
assert detail["can_retry"] is False
|
||||
with get_session_factory()() as db:
|
||||
assert (
|
||||
db.scalar(select(TPlusSyncError.retry_count).where(TPlusSyncError.batch_no == number))
|
||||
== 1
|
||||
)
|
||||
assert db.scalar(
|
||||
select(OperationLog.id)
|
||||
.where(
|
||||
OperationLog.user_id == admin["id"],
|
||||
OperationLog.module == "T+数据同步",
|
||||
OperationLog.action == "重试读取任务",
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
@ -13,7 +13,12 @@ VALID_SECRET = "x" * 32
|
||||
|
||||
def _settings(**overrides: object) -> Settings:
|
||||
"""构造一个不读取 .env 的配置实例。"""
|
||||
params: dict[str, object] = {"_env_file": None, "app_secret_key": VALID_SECRET}
|
||||
# _env_file=None 只禁用文件读取;显式值避免隔离测试库的环境变量影响默认值断言。
|
||||
params: dict[str, object] = {
|
||||
"_env_file": None,
|
||||
"app_secret_key": VALID_SECRET,
|
||||
"mysql_database": "payroll",
|
||||
}
|
||||
params.update(overrides)
|
||||
return Settings(**params) # type: ignore[arg-type]
|
||||
|
||||
|
||||
@ -44,6 +44,10 @@ EXPECTED_TABLES = {
|
||||
"monthly_settlements",
|
||||
"settlement_items",
|
||||
"adjustment_orders",
|
||||
"tplus_sync_batches",
|
||||
"tplus_sync_errors",
|
||||
"tplus_sync_records",
|
||||
"tplus_sync_watermarks",
|
||||
}
|
||||
|
||||
# 单复数同形的集合名词:本身即复数语义,不受「以 s 结尾」检查约束
|
||||
|
||||
58
backend/tests/unit/test_tplus_config.py
Normal file
58
backend/tests/unit/test_tplus_config.py
Normal file
@ -0,0 +1,58 @@
|
||||
"""T+ 配置和读取适配层的纯单元测试。"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app.core.config import Settings
|
||||
from app.services.tplus.mapping import MappingStatus, get_mapping
|
||||
from app.services.tplus.reader import TPlusReader
|
||||
|
||||
|
||||
def _settings(**overrides: object) -> Settings:
|
||||
params: dict[str, object] = {"_env_file": None, "app_secret_key": "x" * 32}
|
||||
params.update(overrides)
|
||||
return Settings(**params) # type: ignore[arg-type]
|
||||
|
||||
|
||||
def test_T加数据库URL使用mssql_pyodbc并隐藏密码() -> None:
|
||||
settings = _settings(
|
||||
t_plus_enabled=True,
|
||||
t_plus_database="UFTData472115_000001",
|
||||
t_plus_user="reader",
|
||||
t_plus_password="p@ss:w/ord",
|
||||
)
|
||||
url = settings.t_plus_database_url
|
||||
assert url.drivername == "mssql+pyodbc"
|
||||
assert url.database == "UFTData472115_000001"
|
||||
assert "p@ss:w/ord" not in url.render_as_string()
|
||||
|
||||
|
||||
def test_T加未启用时读取器不建立连接() -> None:
|
||||
settings = _settings(t_plus_enabled=False)
|
||||
assert settings.t_plus_enabled is False
|
||||
|
||||
|
||||
def test_T加查询文件覆盖六类实体() -> None:
|
||||
reader = TPlusReader.__new__(TPlusReader)
|
||||
reader.query_dir = Path(__file__).resolve().parents[3] / "db" / "queries" / "tplus"
|
||||
assert reader.inventory() == (
|
||||
"personnel",
|
||||
"products",
|
||||
"customers",
|
||||
"businesses",
|
||||
"shipments",
|
||||
"receipts",
|
||||
)
|
||||
for entity in reader.inventory():
|
||||
assert (reader.query_dir / f"{entity}.sql").is_file()
|
||||
|
||||
|
||||
def test_T加高风险映射保持待确认() -> None:
|
||||
assert get_mapping("businesses").status is MappingStatus.PENDING
|
||||
assert "成本口径" in get_mapping("businesses").unresolved
|
||||
|
||||
|
||||
def test_未知T加实体被拒绝() -> None:
|
||||
with pytest.raises(KeyError):
|
||||
get_mapping("unknown")
|
||||
72
backend/tests/unit/test_tplus_connection_file.py
Normal file
72
backend/tests/unit/test_tplus_connection_file.py
Normal file
@ -0,0 +1,72 @@
|
||||
"""显式连接文件不应污染应用配置,也不能启用未授权的数据源。"""
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from app.core.config import Settings
|
||||
from app.core.tplus_db import resolve_tplus_settings
|
||||
|
||||
|
||||
def test_未开启连接时不读取配置文件(tmp_path):
|
||||
settings = Settings(
|
||||
_env_file=None,
|
||||
app_secret_key="x" * 32,
|
||||
t_plus_connection_file=str(tmp_path / "missing.json"),
|
||||
)
|
||||
with pytest.raises(RuntimeError, match="未启用"):
|
||||
resolve_tplus_settings(settings)
|
||||
|
||||
|
||||
def test_文件连接只覆盖源字段且原Settings不变(tmp_path):
|
||||
path = tmp_path / "reader.json"
|
||||
path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"host": "127.0.0.1",
|
||||
"port": 1433,
|
||||
"database": "UFTData",
|
||||
"user": "reader",
|
||||
"password": "test-only-value",
|
||||
"driver": "SQL Server",
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
settings = Settings(
|
||||
_env_file=None,
|
||||
app_secret_key="x" * 32,
|
||||
t_plus_enabled=True,
|
||||
t_plus_connection_file=str(path),
|
||||
)
|
||||
resolved = resolve_tplus_settings(settings)
|
||||
assert resolved.t_plus_database == "UFTData"
|
||||
assert resolved.mysql_database == settings.mysql_database
|
||||
assert settings.t_plus_password == ""
|
||||
assert "test-only-value" not in str(resolved.t_plus_database_url)
|
||||
|
||||
|
||||
def test_连接文件不接受sa或应用配置覆盖(tmp_path):
|
||||
path = tmp_path / "bad.json"
|
||||
path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"host": "127.0.0.1",
|
||||
"port": 1433,
|
||||
"database": "UFTData",
|
||||
"user": "sa",
|
||||
"password": "test-only-value",
|
||||
"driver": "SQL Server",
|
||||
"app_secret_key": "bad",
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
settings = Settings(
|
||||
_env_file=None,
|
||||
app_secret_key="x" * 32,
|
||||
t_plus_enabled=True,
|
||||
t_plus_connection_file=str(path),
|
||||
)
|
||||
with pytest.raises(RuntimeError):
|
||||
resolve_tplus_settings(settings)
|
||||
43
backend/tests/unit/test_tplus_query_service.py
Normal file
43
backend/tests/unit/test_tplus_query_service.py
Normal file
@ -0,0 +1,43 @@
|
||||
"""对外元数据必须过滤历史原异常和未知报告字段。"""
|
||||
|
||||
from uuid import uuid4
|
||||
|
||||
from app.models import TPlusSyncError
|
||||
from app.schemas.tplus import TPlusCreateIn, TPlusErrorQuery
|
||||
from app.services.tplus import query_service, task_service
|
||||
from app.services.tplus.relationship_audit import public_report
|
||||
|
||||
|
||||
def test_历史报告只开放白名单并保持兼容待验():
|
||||
result = public_report(
|
||||
{
|
||||
"source_version": "16.0",
|
||||
"compatibility_status": "PASSED",
|
||||
"password": "sensitive",
|
||||
"sales_links": {"row_count": 1, "payload": "secret"},
|
||||
"state_enums": [{"name": "password=hidden", "extra": "secret"}],
|
||||
}
|
||||
)
|
||||
assert result["compatibility_status"] == "PENDING"
|
||||
assert "password" not in result and "payload" not in result["sales_links"]
|
||||
assert "hidden" not in str(result) and "secret" not in str(result)
|
||||
|
||||
|
||||
def test_异常不返回手机号或原始错误文本(tplus_session):
|
||||
batch = task_service.create_task(
|
||||
tplus_session, TPlusCreateIn(entity_types=["personnel"], request_key=uuid4())
|
||||
)
|
||||
tplus_session.add(
|
||||
TPlusSyncError(
|
||||
batch_no=batch.batch_no,
|
||||
entity_type="personnel",
|
||||
source_table="AA_Person",
|
||||
source_id="13800000000",
|
||||
error_code="READ_ERROR",
|
||||
error_message="password=private",
|
||||
)
|
||||
)
|
||||
tplus_session.commit()
|
||||
page = query_service.list_errors(tplus_session, batch.batch_no, TPlusErrorQuery())
|
||||
assert page.records[0].source_id is None
|
||||
assert "private" not in page.model_dump_json()
|
||||
97
backend/tests/unit/test_tplus_relationship_audit.py
Normal file
97
backend/tests/unit/test_tplus_relationship_audit.py
Normal file
@ -0,0 +1,97 @@
|
||||
"""关联核验必须使用真实类型字典,不能依靠恰好相同的跨表 ID。"""
|
||||
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from app.services.tplus.relationship_audit import TPlusRelationshipAudit
|
||||
|
||||
|
||||
def _audit(types=None):
|
||||
source = Mock()
|
||||
rows = {
|
||||
"source_version": [{"source_version": "16.0.4165.4"}],
|
||||
"voucher_types": types
|
||||
if types is not None
|
||||
else [
|
||||
{"id": 9104, "code": "SA04", "name": "销货单"},
|
||||
{"id": 9092, "code": "ARAP01", "name": "收款单"},
|
||||
],
|
||||
"state_enums": [{"id": 189, "code": "01", "name": "生效", "enum_name": "VoucherState"}],
|
||||
"receipt_kinds": [
|
||||
{
|
||||
"voucher_type_code": "ARAP01",
|
||||
"voucher_type_name": "收款单",
|
||||
"receive_flag": 1,
|
||||
"row_count": 10,
|
||||
}
|
||||
],
|
||||
"receipt_line_kinds": [
|
||||
{"voucher_type_id": 9104, "voucher_type_name": "销货单", "row_count": 12}
|
||||
],
|
||||
"sales_links": [
|
||||
{
|
||||
"row_count": 12,
|
||||
"delivery_matches": 12,
|
||||
"detail_matches": 7,
|
||||
"direct_order_matches": 4,
|
||||
"invalid_order_ids": 0,
|
||||
}
|
||||
],
|
||||
"order_coverage": [
|
||||
{"row_count": 12, "without_order": 2, "single_order": 8, "multiple_orders": 2}
|
||||
],
|
||||
}
|
||||
audit = TPlusRelationshipAudit(source)
|
||||
audit.fetch = Mock(side_effect=lambda name, **kw: rows[name])
|
||||
return audit
|
||||
|
||||
|
||||
def test_类型标识从字典读取而不是硬编码104():
|
||||
audit = _audit()
|
||||
result = audit.run()
|
||||
args = next(call.kwargs for call in audit.fetch.call_args_list if call.args[0] == "sales_links")
|
||||
assert args == {"sale_delivery_type_id": 9104, "receive_flag": 1}
|
||||
assert result["sales_links"]["delivery_matches"] == 12
|
||||
|
||||
|
||||
def test_枚举名称按源库保留且2022不通过2008验收():
|
||||
result = _audit().run()
|
||||
assert result["state_enums"][0]["name"] == "生效"
|
||||
assert result["is_target_engine"] is False
|
||||
assert result["compatibility_status"] == "PENDING"
|
||||
|
||||
|
||||
def test_同类型字典有歧义就停止该链路统计不猜ID():
|
||||
audit = _audit(
|
||||
[
|
||||
{"id": 1, "code": "SA04", "name": "A"},
|
||||
{"id": 2, "code": "SA04", "name": "B"},
|
||||
{"id": 3, "code": "ARAP01", "name": "收款单"},
|
||||
]
|
||||
)
|
||||
result = audit.run()
|
||||
assert result["relationship_status"] == "UNRESOLVED"
|
||||
assert result["sales_links"] is None
|
||||
assert not any(call.args[0] == "sales_links" for call in audit.fetch.call_args_list)
|
||||
|
||||
|
||||
def test_多订单和成本日期仍需业务确认不自动选择():
|
||||
result = _audit().run()
|
||||
assert result["order_coverage"]["multiple_orders"] == 2
|
||||
keys = {item["key"] for item in result["pending_confirmations"]}
|
||||
assert {"cost_basis", "receipt_date", "ship_date", "allocation"} <= keys
|
||||
|
||||
|
||||
def test_SQL查询有固定白名单且不接受自定义表名():
|
||||
audit = TPlusRelationshipAudit(Mock())
|
||||
with pytest.raises(ValueError, match="核验查询"):
|
||||
audit.fetch("SELECT * FROM customers")
|
||||
|
||||
|
||||
def test_主表来源单据类型为空不妨碍按是否收款核验():
|
||||
# 字典明确 idvouchertype 是“来源单据类型”,不是当前收款单的类型。
|
||||
audit = _audit([{"id": 9104, "code": "SA04", "name": "销货单"}])
|
||||
assert audit.run()["relationship_status"] == "OBSERVED"
|
||||
assert "p.isReceiveFlag = :receive_flag" in audit.queries["sales_links"]
|
||||
assert "p.idvouchertype =" not in audit.queries["sales_links"]
|
||||
40
backend/tests/unit/test_tplus_sql_compatibility.py
Normal file
40
backend/tests/unit/test_tplus_sql_compatibility.py
Normal file
@ -0,0 +1,40 @@
|
||||
"""限制 T+ 源查询语法;真实 2008 R2 的驱动/引擎实测仍是最终验收条件。"""
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
QUERY_ROOT = Path(__file__).resolve().parents[3] / "db" / "queries" / "tplus"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path", sorted(QUERY_ROOT.glob("*.sql")), ids=lambda p: p.stem)
|
||||
def test_源查询只使用旧版本分页并保持只读(path):
|
||||
sql = re.sub(r"--[^\n]*", "", path.read_text(encoding="utf-8"))
|
||||
assert not re.search(
|
||||
r"\b(OFFSET|FETCH|OPENJSON|JSON_VALUE|STRING_AGG|TRY_CONVERT|TRY_CAST|"
|
||||
r"INSERT|UPDATE|DELETE|MERGE|CREATE|ALTER|DROP|TRUNCATE)\b",
|
||||
sql,
|
||||
re.I,
|
||||
)
|
||||
assert "TOP (:batch_size)" in sql
|
||||
assert ":after_id" in sql
|
||||
assert not re.search(r"SELECT\s+\*", sql, re.I)
|
||||
|
||||
|
||||
def test_收付款主表没有更新时间时不虚构水位():
|
||||
sql = (QUERY_ROOT / "receipts.sql").read_text(encoding="utf-8")
|
||||
assert "CAST(NULL AS datetime) AS source_updated_at" in sql
|
||||
assert not re.search(r"h\.updated\b", sql, re.I)
|
||||
|
||||
|
||||
def test_关联核验保持只读且不使用新版函数():
|
||||
sql = re.sub(r"--[^\n]*", "", (QUERY_ROOT / "audit/queries.sql").read_text(encoding="utf-8"))
|
||||
assert not re.search(
|
||||
r"\b(OFFSET|FETCH|OPENJSON|JSON_VALUE|STRING_AGG|TRY_CONVERT|TRY_CAST|"
|
||||
r"INSERT|UPDATE|DELETE|MERGE|CREATE|ALTER|DROP|TRUNCATE)\b",
|
||||
sql,
|
||||
re.I,
|
||||
)
|
||||
assert not re.search(r"SELECT\s+\*", sql, re.I)
|
||||
assert ":sale_delivery_type_id" in sql and ":receive_flag" in sql
|
||||
126
backend/tests/unit/test_tplus_staging.py
Normal file
126
backend/tests/unit/test_tplus_staging.py
Normal file
@ -0,0 +1,126 @@
|
||||
"""T+ staging 的幂等和原始行保护测试。"""
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
|
||||
from app.models import TPlusSyncRecord
|
||||
from app.services.tplus.staging import stage_row
|
||||
|
||||
|
||||
class _Result:
|
||||
def __init__(self, value: TPlusSyncRecord | None) -> None:
|
||||
self.value = value
|
||||
|
||||
def scalar_one_or_none(self) -> TPlusSyncRecord | None:
|
||||
return self.value
|
||||
|
||||
|
||||
class _Session:
|
||||
def __init__(self, existing: TPlusSyncRecord | None = None) -> None:
|
||||
self.existing = existing
|
||||
self.added: list[object] = []
|
||||
self.flushed = False
|
||||
|
||||
def execute(self, _statement: object) -> _Result:
|
||||
return _Result(self.existing)
|
||||
|
||||
def add(self, value: object) -> None:
|
||||
self.added.append(value)
|
||||
|
||||
def flush(self) -> None:
|
||||
self.flushed = True
|
||||
|
||||
|
||||
def _row() -> dict[str, object]:
|
||||
return {
|
||||
"id": 1001,
|
||||
"code": "SO-1001",
|
||||
"updated": datetime(2026, 10, 27, 10, 30),
|
||||
"amount": 12.5,
|
||||
}
|
||||
|
||||
|
||||
def test_stage_row_首次写入保留源编号更新时间和哈希() -> None:
|
||||
session = _Session()
|
||||
record, inserted = stage_row(
|
||||
session,
|
||||
batch_no="TP20261027103000ABCD",
|
||||
entity_type="businesses",
|
||||
source_table="SA_SaleOrder",
|
||||
row=_row(),
|
||||
)
|
||||
|
||||
assert inserted is True
|
||||
assert isinstance(session.added[0], TPlusSyncRecord)
|
||||
assert record.source_id == "1001"
|
||||
assert record.source_no == "SO-1001"
|
||||
assert record.source_updated_at == datetime(2026, 10, 27, 10, 30)
|
||||
assert len(record.row_hash) == 64
|
||||
assert session.flushed is True
|
||||
|
||||
|
||||
def test_stage_row_同一行重复执行返回已有记录() -> None:
|
||||
existing = TPlusSyncRecord(
|
||||
batch_no="TP20261027103000ABCD",
|
||||
entity_type="businesses",
|
||||
source_table="SA_SaleOrder",
|
||||
source_id="1001",
|
||||
row_hash="x" * 64,
|
||||
payload_json={},
|
||||
)
|
||||
session = _Session(existing)
|
||||
record, inserted = stage_row(
|
||||
session,
|
||||
batch_no="TP20261027103000EFGH",
|
||||
entity_type="businesses",
|
||||
source_table="SA_SaleOrder",
|
||||
row=_row(),
|
||||
)
|
||||
|
||||
assert inserted is False
|
||||
assert record is existing
|
||||
assert session.added == []
|
||||
|
||||
|
||||
def test_stage_row_缺少源主键时拒绝写入() -> None:
|
||||
with pytest.raises(ValueError, match="缺少 id"):
|
||||
stage_row(
|
||||
_Session(),
|
||||
batch_no="TP20261027103000ABCD",
|
||||
entity_type="businesses",
|
||||
source_table="SA_SaleOrder",
|
||||
row={"code": "SO-1001"},
|
||||
)
|
||||
|
||||
|
||||
def test_stage_row_日期金额和二进制行版本能够实际序列化() -> None:
|
||||
row = {"id": 0, "code": "A", "updated": b"\x00\x01", "amount": Decimal("12.50")}
|
||||
record, _ = stage_row(
|
||||
_Session(), batch_no="TP1", entity_type="products", source_table="AA_Inventory", row=row
|
||||
)
|
||||
assert record.source_id == "0"
|
||||
assert record.source_updated_at is None
|
||||
assert record.payload_json["updated"] == "0x0001"
|
||||
assert record.payload_json["amount"] == "12.50"
|
||||
assert json.loads(json.dumps(record.payload_json)) == record.payload_json
|
||||
|
||||
|
||||
def test_stage_row_键顺序不影响原始行哈希() -> None:
|
||||
first, _ = stage_row(
|
||||
_Session(),
|
||||
batch_no="TP1",
|
||||
entity_type="products",
|
||||
source_table="AA_Inventory",
|
||||
row={"id": 1, "code": "A"},
|
||||
)
|
||||
second, _ = stage_row(
|
||||
_Session(),
|
||||
batch_no="TP2",
|
||||
entity_type="products",
|
||||
source_table="AA_Inventory",
|
||||
row={"code": "A", "id": 1},
|
||||
)
|
||||
assert first.row_hash == second.row_hash
|
||||
110
backend/tests/unit/test_tplus_sync.py
Normal file
110
backend/tests/unit/test_tplus_sync.py
Normal file
@ -0,0 +1,110 @@
|
||||
"""用真实事务验证 T+ staging 重跑、失败隔离和敏感信息保护。"""
|
||||
|
||||
from contextlib import nullcontext
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import DefaultClause, Integer, MetaData, create_engine, func, select, text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import Settings
|
||||
from app.models import TPlusSyncBatch, TPlusSyncError, TPlusSyncRecord
|
||||
from app.services.tplus.sync_service import TPlusSyncService
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def target(monkeypatch):
|
||||
"""以 SQLite 执行 staging 事务,MySQL 专属 DDL 仍由迁移测试覆盖。"""
|
||||
engine = create_engine("sqlite:///:memory:")
|
||||
metadata = MetaData()
|
||||
for model in (TPlusSyncBatch, TPlusSyncError, TPlusSyncRecord):
|
||||
table = model.__table__.to_metadata(metadata)
|
||||
table.c.id.type = Integer()
|
||||
table.c.update_time.server_default = DefaultClause(text("CURRENT_TIMESTAMP"))
|
||||
metadata.create_all(engine)
|
||||
settings = Settings(_env_file=None, app_secret_key="x" * 32)
|
||||
monkeypatch.setattr("app.services.tplus.sync_service.get_settings", lambda: settings)
|
||||
monkeypatch.setattr("app.services.tplus.sync_service.sync_lock", lambda session: nullcontext())
|
||||
with Session(engine, expire_on_commit=False) as session:
|
||||
yield session
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def _service(target, rows):
|
||||
service = TPlusSyncService(Mock(), target)
|
||||
service.reader = Mock()
|
||||
service.reader.iter_entity.side_effect = lambda *a, **kw: iter(rows)
|
||||
return service
|
||||
|
||||
|
||||
def test_重复全量读取不增加记录且业务表未被引用(target):
|
||||
rows = [{"id": 1, "code": "A"}, {"id": 2, "code": "B"}]
|
||||
service = _service(target, rows)
|
||||
first = service.run(entity_types=["products"])
|
||||
second = service.run(entity_types=["products"])
|
||||
assert first.status == second.status == "SUCCEEDED"
|
||||
assert first.staged_record_count == 2
|
||||
assert second.staged_record_count == 0
|
||||
assert second.source_record_count == 2
|
||||
assert target.scalar(select(func.count()).select_from(TPlusSyncRecord)) == 2
|
||||
assert second.watermark_to is None
|
||||
|
||||
|
||||
def test_单条缺失主键不会回滚其他有效行(target):
|
||||
service = _service(target, [{"id": 1, "code": "A"}, {"code": "bad"}, {"id": 2}])
|
||||
batch = service.run(entity_types=["products"])
|
||||
assert batch.status == "PARTIAL"
|
||||
assert batch.staged_record_count == 2
|
||||
assert batch.failed_record_count == 1
|
||||
assert target.scalar(select(func.count()).select_from(TPlusSyncRecord)) == 2
|
||||
|
||||
|
||||
def test_数据库约束失败后仍可写入下一条(target):
|
||||
service = _service(target, [{"id": 1, "code": "A"}])
|
||||
# 用数据库真实触发器拒绝坏行,验证 executemany 失败后的回滚和逐条隔离。
|
||||
service.reader.iter_entity.side_effect = lambda *a, **kw: iter([{"id": 1}, {"id": 2}])
|
||||
target.execute(
|
||||
text(
|
||||
"CREATE TRIGGER reject_first BEFORE INSERT ON tplus_sync_records "
|
||||
"WHEN NEW.source_id='1' BEGIN SELECT RAISE(ABORT, 'invalid row'); END"
|
||||
)
|
||||
)
|
||||
batch = service.run(entity_types=["products"])
|
||||
assert batch.status == "PARTIAL"
|
||||
assert batch.staged_record_count == 1
|
||||
assert target.scalar(select(TPlusSyncRecord.source_id)) == "2"
|
||||
|
||||
|
||||
def test_读取失败有审计且异常中的敏感参数不落日志(target):
|
||||
service = _service(target, [])
|
||||
service.reader.iter_entity.side_effect = RuntimeError("password=secret phone=13800000000")
|
||||
batch = service.run(entity_types=["products"])
|
||||
assert batch.status == "FAILED"
|
||||
error = target.scalar(select(TPlusSyncError))
|
||||
assert error.error_code == "READ_ERROR"
|
||||
assert "secret" not in error.error_message
|
||||
assert "13800000000" not in error.error_message
|
||||
|
||||
|
||||
def test_成功一类后另一类读取失败应标记部分成功(target):
|
||||
service = _service(target, [])
|
||||
|
||||
def source(entity, **kwargs):
|
||||
if entity == "products":
|
||||
return iter([{"id": 1}])
|
||||
raise RuntimeError("读取中断")
|
||||
|
||||
service.reader.iter_entity.side_effect = source
|
||||
batch = service.run(entity_types=["products", "customers"])
|
||||
assert batch.status == "PARTIAL"
|
||||
assert batch.source_record_count == batch.staged_record_count == 1
|
||||
assert batch.failed_record_count == 1
|
||||
|
||||
|
||||
def test_未确认时间增量不能创建误导性批次(target):
|
||||
from datetime import datetime
|
||||
|
||||
service = _service(target, [])
|
||||
with pytest.raises(ValueError, match="增量水位语义尚未确认"):
|
||||
service.run(entity_types=["receipts"], watermark=datetime(2026, 10, 27))
|
||||
assert target.scalar(select(func.count()).select_from(TPlusSyncBatch)) == 0
|
||||
21
backend/tests/unit/test_tplus_task_models.py
Normal file
21
backend/tests/unit/test_tplus_task_models.py
Normal file
@ -0,0 +1,21 @@
|
||||
"""持久化任务需要完整的幂等和重试证据,不能只有运行中的内存对象。"""
|
||||
|
||||
from app.models import TPlusSyncBatch
|
||||
|
||||
|
||||
def test_批次记录范围重试幂等与报告():
|
||||
table = TPlusSyncBatch.__table__
|
||||
assert {
|
||||
"entity_types_json",
|
||||
"skipped_record_count",
|
||||
"parent_batch_no",
|
||||
"request_key",
|
||||
"requested_by",
|
||||
"audit_result_json",
|
||||
} <= set(table.c.keys())
|
||||
assert table.c.started_at.nullable is True
|
||||
|
||||
|
||||
def test_请求键由数据库唯一约束保护():
|
||||
constraints = TPlusSyncBatch.__table__.constraints
|
||||
assert any(c.name == "uk_tplus_sync_batches_request_key" for c in constraints)
|
||||
93
backend/tests/unit/test_tplus_task_service.py
Normal file
93
backend/tests/unit/test_tplus_task_service.py
Normal file
@ -0,0 +1,93 @@
|
||||
"""同一意图不可重复排队,重试必须保留原始批次和范围。"""
|
||||
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from app.core.exceptions import BadRequestError
|
||||
from app.models import TPlusSyncBatch, TPlusSyncError
|
||||
from app.schemas.tplus import TPlusCreateIn, TPlusRetryIn
|
||||
from app.services.tplus import task_service
|
||||
|
||||
|
||||
def _create(**kwargs):
|
||||
return TPlusCreateIn(request_key=uuid4(), entity_types=["products"], **kwargs)
|
||||
|
||||
|
||||
def test_同键同意图复用同一批次(tplus_session):
|
||||
request = _create()
|
||||
first = task_service.create_task(tplus_session, request)
|
||||
second = task_service.create_task(tplus_session, request)
|
||||
assert first.id == second.id
|
||||
assert first.status == "QUEUED"
|
||||
assert first.started_at is None
|
||||
assert first.entity_types_json == ["products"]
|
||||
assert tplus_session.scalar(select(func.count()).select_from(TPlusSyncBatch)) == 1
|
||||
|
||||
|
||||
def test_同键不同范围拒绝(tplus_session):
|
||||
request = _create()
|
||||
task_service.create_task(tplus_session, request)
|
||||
changed = TPlusCreateIn(request_key=request.request_key, entity_types=["personnel"])
|
||||
with pytest.raises(BadRequestError, match="幂等"):
|
||||
task_service.create_task(tplus_session, changed)
|
||||
|
||||
|
||||
def test_失败重试创建新批次并保留原错误(tplus_session):
|
||||
parent = task_service.create_task(tplus_session, _create())
|
||||
parent.status = "FAILED"
|
||||
tplus_session.add(
|
||||
TPlusSyncError(
|
||||
batch_no=parent.batch_no,
|
||||
entity_type="products",
|
||||
source_table="AA_Inventory",
|
||||
error_code="READ_ERROR",
|
||||
error_message="读取失败",
|
||||
)
|
||||
)
|
||||
tplus_session.commit()
|
||||
retry = TPlusRetryIn(request_key=uuid4())
|
||||
child = task_service.retry_task(tplus_session, parent.batch_no, retry)
|
||||
replay = task_service.retry_task(tplus_session, parent.batch_no, retry)
|
||||
assert child.id == replay.id and child.id != parent.id
|
||||
assert child.parent_batch_no == parent.batch_no
|
||||
assert parent.status == "FAILED"
|
||||
assert tplus_session.scalar(select(TPlusSyncError.retry_count)) == 1
|
||||
|
||||
|
||||
def test_已有未完成重试时不重复排队(tplus_session):
|
||||
parent = task_service.create_task(tplus_session, _create())
|
||||
parent.status = "FAILED"
|
||||
tplus_session.commit()
|
||||
task_service.retry_task(tplus_session, parent.batch_no, TPlusRetryIn(request_key=uuid4()))
|
||||
with pytest.raises(BadRequestError, match="重试任务"):
|
||||
task_service.retry_task(tplus_session, parent.batch_no, TPlusRetryIn(request_key=uuid4()))
|
||||
|
||||
|
||||
def test_成功或未知历史范围不能自动重试(tplus_session):
|
||||
parent = task_service.create_task(tplus_session, _create())
|
||||
parent.status = "SUCCEEDED"
|
||||
tplus_session.commit()
|
||||
with pytest.raises(BadRequestError):
|
||||
task_service.retry_task(tplus_session, parent.batch_no, TPlusRetryIn(request_key=uuid4()))
|
||||
parent.status, parent.entity_types_json = "FAILED", None
|
||||
tplus_session.commit()
|
||||
with pytest.raises(BadRequestError, match="范围"):
|
||||
task_service.retry_task(tplus_session, parent.batch_no, TPlusRetryIn(request_key=uuid4()))
|
||||
|
||||
|
||||
def test_关联核验不需要读取实体范围(tplus_session):
|
||||
batch = task_service.create_task(
|
||||
tplus_session, TPlusCreateIn(task_type="AUDIT", request_key=uuid4())
|
||||
)
|
||||
assert batch.sync_mode == "AUDIT"
|
||||
assert batch.entity_types_json == []
|
||||
|
||||
|
||||
def test_预留增量批次不得静默改为全量重试(tplus_session):
|
||||
batch = task_service.create_task(tplus_session, _create())
|
||||
batch.status, batch.sync_mode = "FAILED", "INCREMENTAL"
|
||||
tplus_session.commit()
|
||||
with pytest.raises(BadRequestError, match="方式"):
|
||||
task_service.retry_task(tplus_session, batch.batch_no, TPlusRetryIn(request_key=uuid4()))
|
||||
100
backend/tests/unit/test_tplus_worker.py
Normal file
100
backend/tests/unit/test_tplus_worker.py
Normal file
@ -0,0 +1,100 @@
|
||||
"""排队任务要有实际执行者;忙锁、断连和进程中断不能被误记成成功。"""
|
||||
|
||||
from contextlib import contextmanager, nullcontext
|
||||
from unittest.mock import Mock
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.tplus_lock import TPlusBusyError
|
||||
from app.models import TPlusSyncError
|
||||
from app.schemas.tplus import TPlusCreateIn
|
||||
from app.services.tplus import task_service, worker
|
||||
|
||||
|
||||
def _queued(session, audit=False):
|
||||
return task_service.create_task(
|
||||
session,
|
||||
TPlusCreateIn(
|
||||
request_key=uuid4(),
|
||||
task_type="AUDIT" if audit else "READ",
|
||||
entity_types=[] if audit else ["products"],
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _worker_setup(monkeypatch):
|
||||
monkeypatch.setattr(worker, "sync_lock", lambda session: nullcontext())
|
||||
monkeypatch.setattr(
|
||||
"app.services.tplus.reader.TPlusReader.iter_entity",
|
||||
lambda *a, **kw: iter([{"id": 1, "code": "A"}]),
|
||||
)
|
||||
return lambda: nullcontext(Mock())
|
||||
|
||||
|
||||
def test_读取任务由worker处理并可观察重复数(tplus_session, monkeypatch):
|
||||
source = _worker_setup(monkeypatch)
|
||||
first = _queued(tplus_session)
|
||||
assert worker.run_once(tplus_session, source_factory=source) == first.batch_no
|
||||
assert first.status == "SUCCEEDED" and first.started_at is not None
|
||||
second = _queued(tplus_session)
|
||||
worker.run_once(tplus_session, source_factory=source)
|
||||
assert second.staged_record_count == 0
|
||||
assert second.skipped_record_count == 1
|
||||
|
||||
|
||||
def test_忙锁时不领取任务也不恢复其他运行记录(tplus_session, monkeypatch):
|
||||
source = _worker_setup(monkeypatch)
|
||||
batch = _queued(tplus_session)
|
||||
|
||||
@contextmanager
|
||||
def busy(_session):
|
||||
raise TPlusBusyError("已有任务")
|
||||
yield
|
||||
|
||||
monkeypatch.setattr(worker, "sync_lock", busy)
|
||||
assert worker.run_once(tplus_session, source_factory=source) is None
|
||||
assert batch.status == "QUEUED"
|
||||
|
||||
|
||||
def test_源连接失败写入固定错误不泄漏凭据(tplus_session, monkeypatch):
|
||||
_worker_setup(monkeypatch)
|
||||
batch = _queued(tplus_session)
|
||||
|
||||
def unavailable():
|
||||
raise RuntimeError("password=private-secret")
|
||||
|
||||
worker.run_once(tplus_session, source_factory=unavailable)
|
||||
assert batch.status == "FAILED"
|
||||
error = tplus_session.scalar(select(TPlusSyncError))
|
||||
assert "private-secret" not in error.error_message
|
||||
assert error.error_code == "EXECUTION_ERROR"
|
||||
|
||||
|
||||
def test_取得锁后把遗留运行中任务记为中断失败(tplus_session, monkeypatch):
|
||||
source = _worker_setup(monkeypatch)
|
||||
batch = _queued(tplus_session)
|
||||
batch.status = "RUNNING"
|
||||
tplus_session.commit()
|
||||
assert worker.run_once(tplus_session, source_factory=source) is None
|
||||
assert batch.status == "FAILED"
|
||||
assert tplus_session.scalar(select(TPlusSyncError.error_code)) == "INTERRUPTED"
|
||||
|
||||
|
||||
def test_核验任务持久化报告但不修改业务数据(tplus_session, monkeypatch):
|
||||
source = _worker_setup(monkeypatch)
|
||||
batch = _queued(tplus_session, audit=True)
|
||||
monkeypatch.setattr(
|
||||
worker.TPlusRelationshipAudit,
|
||||
"run",
|
||||
lambda self: {
|
||||
"relationship_status": "OBSERVED",
|
||||
"receipt_kinds": [{"row_count": 10}],
|
||||
"compatibility_status": "PENDING",
|
||||
"is_target_engine": False,
|
||||
},
|
||||
)
|
||||
worker.run_once(tplus_session, source_factory=source)
|
||||
assert batch.status == "SUCCEEDED"
|
||||
assert batch.audit_result_json["compatibility_status"] == "PENDING"
|
||||
assert batch.staged_record_count == 0
|
||||
792
backend/uv.lock
generated
792
backend/uv.lock
generated
File diff suppressed because it is too large
Load Diff
@ -16,6 +16,10 @@
|
||||
SET NAMES utf8mb4;
|
||||
|
||||
-- 删除顺序遵循外键依赖:先删子表
|
||||
DROP TABLE IF EXISTS `tplus_sync_watermarks`;
|
||||
DROP TABLE IF EXISTS `tplus_sync_records`;
|
||||
DROP TABLE IF EXISTS `tplus_sync_errors`;
|
||||
DROP TABLE IF EXISTS `tplus_sync_batches`;
|
||||
DROP TABLE IF EXISTS `performance_ledgers`;
|
||||
DROP TABLE IF EXISTS `settlement_items`;
|
||||
DROP TABLE IF EXISTS `adjustment_orders`;
|
||||
@ -282,6 +286,7 @@ CREATE TABLE personnel (
|
||||
t_plus_id VARCHAR(64) COMMENT 'T+ 系统内部标识',
|
||||
sync_batch_no VARCHAR(32) COMMENT '同步批次号(同步模块写入)',
|
||||
synced_at DATETIME COMMENT '最近同步时间',
|
||||
source_updated_at DATETIME COMMENT 'T+ 源数据更新时间(未确认时可为空)',
|
||||
CONSTRAINT pk_personnel PRIMARY KEY (id),
|
||||
CONSTRAINT fk_personnel_business_role_id FOREIGN KEY(business_role_id) REFERENCES business_roles (id) ON DELETE RESTRICT,
|
||||
CONSTRAINT fk_personnel_manager_id FOREIGN KEY(manager_id) REFERENCES personnel (id) ON DELETE RESTRICT
|
||||
@ -346,6 +351,7 @@ CREATE TABLE products (
|
||||
t_plus_id VARCHAR(64) COMMENT 'T+ 系统内部标识',
|
||||
sync_batch_no VARCHAR(32) COMMENT '同步批次号(同步模块写入)',
|
||||
synced_at DATETIME COMMENT '最近同步时间',
|
||||
source_updated_at DATETIME COMMENT 'T+ 源数据更新时间(未确认时可为空)',
|
||||
CONSTRAINT pk_products PRIMARY KEY (id),
|
||||
CONSTRAINT uk_products_name_spec UNIQUE (product_name, spec_model)
|
||||
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='产品表(类别锚定折算阈值参数;T+同步不覆盖产品类别)' COLLATE utf8mb4_general_ci;
|
||||
@ -374,6 +380,7 @@ CREATE TABLE customers (
|
||||
t_plus_id VARCHAR(64) COMMENT 'T+ 系统内部标识',
|
||||
sync_batch_no VARCHAR(32) COMMENT '同步批次号(同步模块写入)',
|
||||
synced_at DATETIME COMMENT '最近同步时间',
|
||||
source_updated_at DATETIME COMMENT 'T+ 源数据更新时间(未确认时可为空)',
|
||||
CONSTRAINT pk_customers PRIMARY KEY (id),
|
||||
CONSTRAINT uk_customers_company_owner UNIQUE (company_name, owner_name),
|
||||
CONSTRAINT fk_customers_owner_personnel_id FOREIGN KEY(owner_personnel_id) REFERENCES personnel (id) ON DELETE RESTRICT
|
||||
@ -518,6 +525,7 @@ CREATE TABLE businesses (
|
||||
t_plus_id VARCHAR(64) COMMENT 'T+ 系统内部标识',
|
||||
sync_batch_no VARCHAR(32) COMMENT '同步批次号(同步模块写入)',
|
||||
synced_at DATETIME COMMENT '最近同步时间',
|
||||
source_updated_at DATETIME COMMENT 'T+ 源数据更新时间(未确认时可为空)',
|
||||
CONSTRAINT pk_businesses PRIMARY KEY (id),
|
||||
CONSTRAINT uk_businesses_business_no UNIQUE (business_no),
|
||||
CONSTRAINT fk_businesses_customer_id FOREIGN KEY(customer_id) REFERENCES customers (id) ON DELETE RESTRICT,
|
||||
@ -602,6 +610,7 @@ CREATE TABLE shipments (
|
||||
t_plus_id VARCHAR(64) COMMENT 'T+ 系统内部标识',
|
||||
sync_batch_no VARCHAR(32) COMMENT '同步批次号(同步模块写入)',
|
||||
synced_at DATETIME COMMENT '最近同步时间',
|
||||
source_updated_at DATETIME COMMENT 'T+ 源数据更新时间(未确认时可为空)',
|
||||
CONSTRAINT pk_shipments PRIMARY KEY (id),
|
||||
CONSTRAINT uk_shipments_shipment_no UNIQUE (shipment_no),
|
||||
CONSTRAINT fk_shipments_business_id FOREIGN KEY(business_id) REFERENCES businesses (id) ON DELETE RESTRICT
|
||||
@ -656,6 +665,7 @@ CREATE TABLE receipts (
|
||||
t_plus_id VARCHAR(64) COMMENT 'T+ 系统内部标识',
|
||||
sync_batch_no VARCHAR(32) COMMENT '同步批次号(同步模块写入)',
|
||||
synced_at DATETIME COMMENT '最近同步时间',
|
||||
source_updated_at DATETIME COMMENT 'T+ 源数据更新时间(未确认时可为空)',
|
||||
CONSTRAINT pk_receipts PRIMARY KEY (id),
|
||||
CONSTRAINT uk_receipts_receipt_batch_no UNIQUE (receipt_batch_no),
|
||||
CONSTRAINT fk_receipts_business_id FOREIGN KEY(business_id) REFERENCES businesses (id) ON DELETE RESTRICT,
|
||||
@ -880,6 +890,90 @@ CREATE INDEX idx_performance_ledgers_month_personnel_type ON performance_ledgers
|
||||
|
||||
CREATE INDEX idx_performance_ledgers_personnel_id ON performance_ledgers (personnel_id);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- T+ 同步批次、错误、原始 staging 与增量水位表(V14)
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE TABLE tplus_sync_batches (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
|
||||
batch_no VARCHAR(32) NOT NULL COMMENT '同步批次号',
|
||||
sync_mode VARCHAR(16) NOT NULL COMMENT '任务方式:FULL全量 AUDIT关联核验 INCREMENTAL预留',
|
||||
status VARCHAR(16) NOT NULL COMMENT '状态:QUEUED/RUNNING/SUCCEEDED/PARTIAL/FAILED',
|
||||
source_database VARCHAR(128) COMMENT 'T+ 源数据库名',
|
||||
started_at DATETIME COMMENT '开始执行时间(排队时为空)',
|
||||
finished_at DATETIME COMMENT '结束时间',
|
||||
watermark_from DATETIME COMMENT '增量起始水位',
|
||||
watermark_to DATETIME COMMENT '本批次结束水位',
|
||||
source_record_count INT NOT NULL DEFAULT 0 COMMENT '源记录数',
|
||||
staged_record_count INT NOT NULL DEFAULT 0 COMMENT '写入 staging 的记录数',
|
||||
failed_record_count INT NOT NULL DEFAULT 0 COMMENT '失败记录数',
|
||||
error_message TEXT COMMENT '批次错误摘要',
|
||||
entity_types_json JSON COMMENT '请求读取的实体类型;历史批次可为空',
|
||||
skipped_record_count INT NOT NULL DEFAULT 0 COMMENT '重复快照跳过条数',
|
||||
parent_batch_no VARCHAR(32) COMMENT '重试来源批次号,保留原批次记录',
|
||||
request_key VARCHAR(36) COMMENT '请求幂等键,空值用于历史和直接CLI任务',
|
||||
requested_by BIGINT UNSIGNED COMMENT '请求用户ID,CLI任务为空,无物理外键',
|
||||
audit_result_json JSON COMMENT '关联核验结果,只含系统字典和汇总',
|
||||
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
CONSTRAINT pk_tplus_sync_batches PRIMARY KEY (id),
|
||||
CONSTRAINT uk_tplus_sync_batches_batch_no UNIQUE (batch_no),
|
||||
CONSTRAINT uk_tplus_sync_batches_request_key UNIQUE (request_key)
|
||||
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='T+ 同步批次表(记录读取范围、结果和对账统计)' COLLATE=utf8mb4_general_ci;
|
||||
CREATE INDEX idx_tplus_sync_batches_status ON tplus_sync_batches (status);
|
||||
CREATE INDEX idx_tplus_sync_batches_started_at ON tplus_sync_batches (started_at);
|
||||
|
||||
CREATE INDEX idx_tplus_sync_batches_parent_status ON tplus_sync_batches (parent_batch_no, status);
|
||||
|
||||
CREATE TABLE tplus_sync_errors (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
|
||||
batch_no VARCHAR(32) NOT NULL COMMENT '同步批次号',
|
||||
entity_type VARCHAR(32) NOT NULL COMMENT '业务实体类型',
|
||||
source_table VARCHAR(128) NOT NULL COMMENT 'T+ 源表',
|
||||
source_id VARCHAR(64) COMMENT 'T+ 源主键',
|
||||
source_line_no VARCHAR(64) COMMENT 'T+ 明细行号或明细主键',
|
||||
error_code VARCHAR(32) NOT NULL COMMENT '错误编码',
|
||||
error_message TEXT NOT NULL COMMENT '错误说明',
|
||||
retry_count INT NOT NULL DEFAULT 0 COMMENT '已重试次数',
|
||||
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
CONSTRAINT pk_tplus_sync_errors PRIMARY KEY (id)
|
||||
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='T+ 同步错误表(记录失败行和重试信息)' COLLATE=utf8mb4_general_ci;
|
||||
CREATE INDEX idx_tplus_sync_errors_batch_no ON tplus_sync_errors (batch_no);
|
||||
CREATE INDEX idx_tplus_sync_errors_entity_type ON tplus_sync_errors (entity_type);
|
||||
|
||||
CREATE TABLE tplus_sync_records (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
|
||||
batch_no VARCHAR(32) NOT NULL COMMENT '同步批次号',
|
||||
entity_type VARCHAR(32) NOT NULL COMMENT '业务实体类型',
|
||||
source_table VARCHAR(128) NOT NULL COMMENT 'T+ 源表',
|
||||
source_id VARCHAR(64) NOT NULL COMMENT 'T+ 源主键',
|
||||
source_no VARCHAR(64) COMMENT 'T+ 原始编号',
|
||||
source_line_no VARCHAR(64) COMMENT 'T+ 明细行号或明细主键',
|
||||
source_updated_at DATETIME COMMENT 'T+ 源数据更新时间',
|
||||
row_hash VARCHAR(64) NOT NULL COMMENT '原始行 SHA-256',
|
||||
state VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT '状态:PENDING/APPLIED/SKIPPED/ERROR',
|
||||
payload_json JSON NOT NULL COMMENT '原始行 JSON',
|
||||
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
CONSTRAINT pk_tplus_sync_records PRIMARY KEY (id),
|
||||
CONSTRAINT uk_tplus_sync_records_entity_source_hash UNIQUE (entity_type, source_id, row_hash)
|
||||
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='T+ 原始同步 staging 表(映射确认前只保存原始行,不直接改业务表)' COLLATE=utf8mb4_general_ci;
|
||||
CREATE INDEX idx_tplus_sync_records_batch_no ON tplus_sync_records (batch_no);
|
||||
CREATE INDEX idx_tplus_sync_records_source_no ON tplus_sync_records (source_no);
|
||||
CREATE INDEX idx_tplus_sync_records_state ON tplus_sync_records (state);
|
||||
|
||||
CREATE TABLE tplus_sync_watermarks (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
|
||||
entity_type VARCHAR(32) NOT NULL COMMENT '业务实体类型',
|
||||
last_source_updated_at DATETIME COMMENT '最近源更新时间',
|
||||
last_source_id VARCHAR(64) COMMENT '同一时间点的最后源主键',
|
||||
status VARCHAR(16) NOT NULL DEFAULT 'READY' COMMENT '状态:READY/BLOCKED',
|
||||
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
CONSTRAINT pk_tplus_sync_watermarks PRIMARY KEY (id),
|
||||
CONSTRAINT uk_tplus_sync_watermarks_entity_type UNIQUE (entity_type)
|
||||
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='T+ 增量同步水位表' COLLATE=utf8mb4_general_ci;
|
||||
|
||||
ALTER TABLE approval_orders MODIFY order_type TINYINT NOT NULL COMMENT '审批对象类型:1二次分配方案 2月度结算 3调整单';
|
||||
|
||||
-- 公司信息单行(system_settings 恒为一行,id=1 由迁移保底)
|
||||
@ -893,4 +987,4 @@ CREATE TABLE alembic_version (
|
||||
CONSTRAINT alembic_version_pkc PRIMARY KEY (version_num)
|
||||
)CHARSET=utf8mb4 ENGINE=InnoDB COLLATE utf8mb4_general_ci;
|
||||
|
||||
INSERT INTO alembic_version (version_num) VALUES ('c3a9e1d7b2f4');
|
||||
INSERT INTO alembic_version (version_num) VALUES ('e5f607a8b9c0');
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
-- V10: 客户评级落库(2026-10-23)
|
||||
-- V13: 客户评级落库(2026-10-23,原文件误标为 V10)
|
||||
-- 评级计算器从独立工具页并入客户管理(行内弹窗),评分保存为客户档案属性;
|
||||
-- 两列可空(null=未评分,存量客户无需回填),人工维护字段,T+ 同步不覆盖。
|
||||
-- 对应迁移:alembic/versions/c3a9e1d7b2f4_v13_客户评级落库.py
|
||||
@ -8,3 +8,5 @@ ALTER TABLE `customers`
|
||||
ADD COLUMN `rating_score` TINYINT COMMENT '评级总分(0-100,评级计算器保存;null=未评分;人工维护字段,T+ 同步不覆盖)' AFTER `remark`;
|
||||
ALTER TABLE `customers`
|
||||
ADD COLUMN `rating_time` DATETIME COMMENT '最近评级时间(评级计算器保存评分时刷新;人工维护字段)' AFTER `rating_score`;
|
||||
|
||||
UPDATE alembic_version SET version_num='c3a9e1d7b2f4' WHERE version_num='7f4f7b918bc4';
|
||||
83
db/migration/V14__tplus_sync_staging.sql
Normal file
83
db/migration/V14__tplus_sync_staging.sql
Normal file
@ -0,0 +1,83 @@
|
||||
-- V14: T+ 只读同步批次、错误、staging 和增量水位。
|
||||
-- 对应 Alembic:backend/alembic/versions/d4e5f6a7b8c9_tplus_sync_staging.py
|
||||
|
||||
ALTER TABLE personnel ADD COLUMN source_updated_at DATETIME COMMENT 'T+ 源数据更新时间(未确认时可为空)' AFTER synced_at;
|
||||
ALTER TABLE products ADD COLUMN source_updated_at DATETIME COMMENT 'T+ 源数据更新时间(未确认时可为空)' AFTER synced_at;
|
||||
ALTER TABLE customers ADD COLUMN source_updated_at DATETIME COMMENT 'T+ 源数据更新时间(未确认时可为空)' AFTER synced_at;
|
||||
ALTER TABLE businesses ADD COLUMN source_updated_at DATETIME COMMENT 'T+ 源数据更新时间(未确认时可为空)' AFTER synced_at;
|
||||
ALTER TABLE shipments ADD COLUMN source_updated_at DATETIME COMMENT 'T+ 源数据更新时间(未确认时可为空)' AFTER synced_at;
|
||||
ALTER TABLE receipts ADD COLUMN source_updated_at DATETIME COMMENT 'T+ 源数据更新时间(未确认时可为空)' AFTER synced_at;
|
||||
|
||||
CREATE TABLE tplus_sync_batches (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
||||
batch_no VARCHAR(32) NOT NULL COMMENT '同步批次号',
|
||||
sync_mode VARCHAR(16) NOT NULL COMMENT '同步模式:FULL全量 INCREMENTAL增量',
|
||||
status VARCHAR(16) NOT NULL COMMENT '状态:RUNNING/SUCCEEDED/PARTIAL/FAILED',
|
||||
source_database VARCHAR(128) COMMENT 'T+ 源数据库名',
|
||||
started_at DATETIME NOT NULL COMMENT '开始读取时间',
|
||||
finished_at DATETIME COMMENT '结束时间',
|
||||
watermark_from DATETIME COMMENT '增量起始水位',
|
||||
watermark_to DATETIME COMMENT '本批次结束水位',
|
||||
source_record_count INT NOT NULL DEFAULT 0 COMMENT '源记录数',
|
||||
staged_record_count INT NOT NULL DEFAULT 0 COMMENT '写入 staging 的记录数',
|
||||
failed_record_count INT NOT NULL DEFAULT 0 COMMENT '失败记录数',
|
||||
error_message TEXT COMMENT '批次错误摘要',
|
||||
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
CONSTRAINT pk_tplus_sync_batches PRIMARY KEY (id),
|
||||
CONSTRAINT uk_tplus_sync_batches_batch_no UNIQUE (batch_no)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='T+ 同步批次表(记录读取范围、结果和对账统计)';
|
||||
CREATE INDEX idx_tplus_sync_batches_status ON tplus_sync_batches (status);
|
||||
CREATE INDEX idx_tplus_sync_batches_started_at ON tplus_sync_batches (started_at);
|
||||
|
||||
CREATE TABLE tplus_sync_errors (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
||||
batch_no VARCHAR(32) NOT NULL COMMENT '同步批次号',
|
||||
entity_type VARCHAR(32) NOT NULL COMMENT '业务实体类型',
|
||||
source_table VARCHAR(128) NOT NULL COMMENT 'T+ 源表',
|
||||
source_id VARCHAR(64) COMMENT 'T+ 源主键',
|
||||
source_line_no VARCHAR(64) COMMENT 'T+ 明细行号或明细主键',
|
||||
error_code VARCHAR(32) NOT NULL COMMENT '错误编码',
|
||||
error_message TEXT NOT NULL COMMENT '错误说明',
|
||||
retry_count INT NOT NULL DEFAULT 0 COMMENT '已重试次数',
|
||||
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
CONSTRAINT pk_tplus_sync_errors PRIMARY KEY (id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='T+ 同步错误表(记录失败行和重试信息)';
|
||||
CREATE INDEX idx_tplus_sync_errors_batch_no ON tplus_sync_errors (batch_no);
|
||||
CREATE INDEX idx_tplus_sync_errors_entity_type ON tplus_sync_errors (entity_type);
|
||||
|
||||
CREATE TABLE tplus_sync_records (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
||||
batch_no VARCHAR(32) NOT NULL COMMENT '同步批次号',
|
||||
entity_type VARCHAR(32) NOT NULL COMMENT '业务实体类型',
|
||||
source_table VARCHAR(128) NOT NULL COMMENT 'T+ 源表',
|
||||
source_id VARCHAR(64) NOT NULL COMMENT 'T+ 源主键',
|
||||
source_no VARCHAR(64) COMMENT 'T+ 原始编号',
|
||||
source_line_no VARCHAR(64) COMMENT 'T+ 明细行号或明细主键',
|
||||
source_updated_at DATETIME COMMENT 'T+ 源数据更新时间',
|
||||
row_hash VARCHAR(64) NOT NULL COMMENT '原始行 SHA-256',
|
||||
state VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT '状态:PENDING/APPLIED/SKIPPED/ERROR',
|
||||
payload_json JSON NOT NULL COMMENT '原始行 JSON',
|
||||
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
CONSTRAINT pk_tplus_sync_records PRIMARY KEY (id),
|
||||
CONSTRAINT uk_tplus_sync_records_entity_source_hash UNIQUE (entity_type, source_id, row_hash)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='T+ 原始同步 staging 表(映射确认前只保存原始行,不直接改业务表)';
|
||||
CREATE INDEX idx_tplus_sync_records_batch_no ON tplus_sync_records (batch_no);
|
||||
CREATE INDEX idx_tplus_sync_records_source_no ON tplus_sync_records (source_no);
|
||||
CREATE INDEX idx_tplus_sync_records_state ON tplus_sync_records (state);
|
||||
|
||||
CREATE TABLE tplus_sync_watermarks (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
||||
entity_type VARCHAR(32) NOT NULL COMMENT '业务实体类型',
|
||||
last_source_updated_at DATETIME COMMENT '最近源更新时间',
|
||||
last_source_id VARCHAR(64) COMMENT '同一时间点的最后源主键',
|
||||
status VARCHAR(16) NOT NULL DEFAULT 'READY' COMMENT '状态:READY/BLOCKED',
|
||||
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
CONSTRAINT pk_tplus_sync_watermarks PRIMARY KEY (id),
|
||||
CONSTRAINT uk_tplus_sync_watermarks_entity_type UNIQUE (entity_type)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='T+ 增量同步水位表';
|
||||
|
||||
UPDATE alembic_version SET version_num='d4e5f6a7b8c9' WHERE alembic_version.version_num = 'c3a9e1d7b2f4';
|
||||
14
db/migration/V15__tplus_task_management.sql
Normal file
14
db/migration/V15__tplus_task_management.sql
Normal file
@ -0,0 +1,14 @@
|
||||
-- T+ 任务管理扩展;对应 e5f607a8b9c0,不修改工资业务数据。
|
||||
ALTER TABLE tplus_sync_batches ADD COLUMN entity_types_json JSON COMMENT '请求读取的实体类型;历史批次可为空';
|
||||
ALTER TABLE tplus_sync_batches ADD COLUMN skipped_record_count INT NOT NULL DEFAULT 0 COMMENT '重复快照跳过条数';
|
||||
ALTER TABLE tplus_sync_batches ADD COLUMN parent_batch_no VARCHAR(32) COMMENT '重试来源批次号,保留原批次记录';
|
||||
ALTER TABLE tplus_sync_batches ADD COLUMN request_key VARCHAR(36) COMMENT '请求幂等键,空值用于历史和直接CLI任务';
|
||||
ALTER TABLE tplus_sync_batches ADD COLUMN requested_by BIGINT UNSIGNED COMMENT '请求用户ID,CLI任务为空,无物理外键';
|
||||
ALTER TABLE tplus_sync_batches ADD COLUMN audit_result_json JSON COMMENT '关联核验结果,只含系统字典和汇总';
|
||||
ALTER TABLE tplus_sync_batches MODIFY COLUMN started_at DATETIME NULL COMMENT '开始执行时间(排队时为空)';
|
||||
ALTER TABLE tplus_sync_batches MODIFY COLUMN sync_mode VARCHAR(16) NOT NULL COMMENT '任务方式:FULL全量 AUDIT关联核验 INCREMENTAL预留';
|
||||
ALTER TABLE tplus_sync_batches MODIFY COLUMN status VARCHAR(16) NOT NULL COMMENT '状态:QUEUED/RUNNING/SUCCEEDED/PARTIAL/FAILED';
|
||||
ALTER TABLE tplus_sync_batches ADD CONSTRAINT uk_tplus_sync_batches_request_key UNIQUE (request_key);
|
||||
CREATE INDEX idx_tplus_sync_batches_parent_status ON tplus_sync_batches (parent_batch_no, status);
|
||||
UPDATE tplus_sync_batches SET skipped_record_count=GREATEST(0, source_record_count-staged_record_count-failed_record_count) WHERE status='SUCCEEDED' AND sync_mode='FULL';
|
||||
UPDATE alembic_version SET version_num='e5f607a8b9c0' WHERE version_num='d4e5f6a7b8c9';
|
||||
68
db/queries/tplus/audit/queries.sql
Normal file
68
db/queries/tplus/audit/queries.sql
Normal file
@ -0,0 +1,68 @@
|
||||
-- 关联核验固定查询,全部只读;源库仍按 SQL Server 2008 R2 语法编写。
|
||||
-- @query source_version
|
||||
SELECT CONVERT(varchar(80), SERVERPROPERTY('ProductVersion')) AS source_version;
|
||||
|
||||
-- @query voucher_types
|
||||
SELECT ID AS id, code, name FROM dbo.SM_VoucherType ORDER BY ID;
|
||||
|
||||
-- @query state_enums
|
||||
SELECT i.ID AS id, i.Code AS code, i.Name AS name, e.Name AS enum_name
|
||||
FROM dbo.eap_EnumItem AS i
|
||||
LEFT JOIN dbo.eap_Enum AS e ON e.id = i.idEnum
|
||||
WHERE i.ID IN (
|
||||
SELECT voucherstate FROM dbo.ARAP_ReceivePayment
|
||||
UNION SELECT cancelState FROM dbo.ARAP_ReceivePayment
|
||||
)
|
||||
ORDER BY i.ID;
|
||||
|
||||
-- @query receipt_kinds
|
||||
SELECT CASE h.isReceiveFlag WHEN 1 THEN 'RECEIVE' WHEN 0 THEN 'PAYMENT' ELSE 'UNKNOWN' END AS voucher_type_code,
|
||||
CASE h.isReceiveFlag WHEN 1 THEN N'收款' WHEN 0 THEN N'付款' ELSE N'未知方向' END AS voucher_type_name,
|
||||
h.isReceiveFlag AS receive_flag, COUNT_BIG(*) AS row_count
|
||||
FROM dbo.ARAP_ReceivePayment AS h
|
||||
GROUP BY h.isReceiveFlag;
|
||||
|
||||
-- @query receipt_line_kinds
|
||||
SELECT b.idvouchertype AS voucher_type_id, v.name AS voucher_type_name,
|
||||
h.isReceiveFlag AS receive_flag, COUNT_BIG(*) AS row_count
|
||||
FROM dbo.ARAP_ReceivePayment_b AS b
|
||||
LEFT JOIN dbo.SM_VoucherType AS v ON v.ID = b.idvouchertype
|
||||
LEFT JOIN dbo.ARAP_ReceivePayment AS h ON h.ID = b.idArapReceivePaymentDTO
|
||||
GROUP BY b.idvouchertype, v.name, h.isReceiveFlag;
|
||||
|
||||
-- @query sales_links
|
||||
SELECT COUNT_BIG(*) AS row_count,
|
||||
COALESCE(SUM(CASE WHEN h.ID IS NOT NULL THEN 1 ELSE 0 END), 0) AS delivery_matches,
|
||||
COALESCE(SUM(CASE WHEN d.ID IS NOT NULL THEN 1 ELSE 0 END), 0) AS detail_matches,
|
||||
COALESCE(SUM(CASE WHEN o.ID IS NOT NULL THEN 1 ELSE 0 END), 0) AS direct_order_matches,
|
||||
COALESCE(SUM(CASE WHEN detail_order.ID IS NOT NULL THEN 1 ELSE 0 END), 0) AS detail_order_matches,
|
||||
COALESCE(SUM(CASE WHEN (o.ID IS NOT NULL OR detail_order.ID IS NOT NULL)
|
||||
AND NOT (b.saleorderid > 0 AND d.saleOrderId > 0 AND b.saleorderid <> d.saleOrderId)
|
||||
THEN 1 ELSE 0 END), 0) AS unambiguous_order_matches,
|
||||
COALESCE(SUM(CASE WHEN b.saleorderid > 0 AND o.ID IS NULL THEN 1 ELSE 0 END), 0) AS invalid_order_ids,
|
||||
COALESCE(SUM(CASE WHEN b.saleorderid > 0 AND d.saleOrderId > 0
|
||||
AND b.saleorderid <> d.saleOrderId THEN 1 ELSE 0 END), 0) AS conflicting_order_ids
|
||||
FROM dbo.ARAP_ReceivePayment_b AS b
|
||||
JOIN dbo.ARAP_ReceivePayment AS p ON p.ID = b.idArapReceivePaymentDTO
|
||||
LEFT JOIN dbo.SA_SaleDelivery AS h ON h.ID = b.voucherID
|
||||
LEFT JOIN dbo.SA_SaleDelivery_b AS d ON d.ID = b.voucherDetailID
|
||||
AND d.idSaleDeliveryDTO = b.voucherID
|
||||
LEFT JOIN dbo.SA_SaleOrder AS o ON o.ID = b.saleorderid
|
||||
LEFT JOIN dbo.SA_SaleOrder AS detail_order ON detail_order.ID = d.saleOrderId
|
||||
WHERE b.idvouchertype = :sale_delivery_type_id AND p.isReceiveFlag = :receive_flag;
|
||||
|
||||
-- @query order_coverage
|
||||
WITH delivery_orders AS (
|
||||
SELECT idSaleDeliveryDTO, COUNT(DISTINCT saleOrderId) AS order_count
|
||||
FROM dbo.SA_SaleDelivery_b
|
||||
WHERE saleOrderId > 0
|
||||
GROUP BY idSaleDeliveryDTO
|
||||
)
|
||||
SELECT COUNT_BIG(*) AS row_count,
|
||||
COALESCE(SUM(CASE WHEN d.order_count IS NULL THEN 1 ELSE 0 END), 0) AS without_order,
|
||||
COALESCE(SUM(CASE WHEN d.order_count = 1 THEN 1 ELSE 0 END), 0) AS single_order,
|
||||
COALESCE(SUM(CASE WHEN d.order_count > 1 THEN 1 ELSE 0 END), 0) AS multiple_orders
|
||||
FROM dbo.ARAP_ReceivePayment_b AS b
|
||||
JOIN dbo.ARAP_ReceivePayment AS p ON p.ID = b.idArapReceivePaymentDTO
|
||||
LEFT JOIN delivery_orders AS d ON d.idSaleDeliveryDTO = b.voucherID
|
||||
WHERE b.idvouchertype = :sale_delivery_type_id AND p.isReceiveFlag = :receive_flag;
|
||||
21
db/queries/tplus/business_items.sql
Normal file
21
db/queries/tplus/business_items.sql
Normal file
@ -0,0 +1,21 @@
|
||||
-- 已在客户备份核对列结构;业务语义仍待确认。
|
||||
-- SQL Server 2008 R2 语法;主键游标分页,时间水位尚未启用。
|
||||
SELECT TOP (:batch_size)
|
||||
b.id AS id,
|
||||
b.code AS code,
|
||||
b.id AS line_no,
|
||||
b.idSaleOrderDTO AS source_parent_id,
|
||||
b.idinventory,
|
||||
b.quantity,
|
||||
b.baseQuantity,
|
||||
b.price,
|
||||
b.taxPrice,
|
||||
b.taxAmount,
|
||||
b.discountAmount,
|
||||
b.LatestCost,
|
||||
b.IsClose,
|
||||
b.updated AS source_updated_at,
|
||||
b.ts AS source_rowversion
|
||||
FROM dbo.SA_SaleOrder_b AS b
|
||||
WHERE b.id > :after_id
|
||||
ORDER BY b.id;
|
||||
18
db/queries/tplus/businesses.sql
Normal file
18
db/queries/tplus/businesses.sql
Normal file
@ -0,0 +1,18 @@
|
||||
-- 已在客户备份核对列结构;业务语义仍待确认。
|
||||
-- SQL Server 2008 R2 语法;主键游标分页,时间水位尚未启用。
|
||||
SELECT TOP (:batch_size)
|
||||
h.ID AS id,
|
||||
h.code AS code,
|
||||
h.voucherdate,
|
||||
h.idcustomer,
|
||||
h.idclerk,
|
||||
h.idbusinesstype,
|
||||
h.amount,
|
||||
h.taxAmount,
|
||||
h.voucherState,
|
||||
h.isCancel,
|
||||
h.updated AS source_updated_at,
|
||||
h.ts AS source_rowversion
|
||||
FROM dbo.SA_SaleOrder AS h
|
||||
WHERE h.id > :after_id
|
||||
ORDER BY h.id;
|
||||
16
db/queries/tplus/customers.sql
Normal file
16
db/queries/tplus/customers.sql
Normal file
@ -0,0 +1,16 @@
|
||||
-- 已在客户备份核对列结构;业务语义仍待确认。
|
||||
-- SQL Server 2008 R2 语法;主键游标分页,时间水位尚未启用。
|
||||
SELECT TOP (:batch_size)
|
||||
p.id AS id,
|
||||
p.code AS code,
|
||||
p.name,
|
||||
p.Contact,
|
||||
p.representative,
|
||||
p.disabled,
|
||||
p.idsaleman,
|
||||
p.partnerType,
|
||||
p.updated AS source_updated_at,
|
||||
p.ts AS source_rowversion
|
||||
FROM dbo.AA_Partner AS p
|
||||
WHERE p.id > :after_id
|
||||
ORDER BY p.id;
|
||||
16
db/queries/tplus/personnel.sql
Normal file
16
db/queries/tplus/personnel.sql
Normal file
@ -0,0 +1,16 @@
|
||||
-- 已在客户备份核对列结构;业务语义仍待确认。
|
||||
-- SQL Server 2008 R2 语法;主键游标分页,时间水位尚未启用。
|
||||
SELECT TOP (:batch_size)
|
||||
p.id AS id,
|
||||
p.code AS code,
|
||||
p.name,
|
||||
p.disabled,
|
||||
p.mobilePhoneNo,
|
||||
p.officePhoneNo,
|
||||
p.iddepartment,
|
||||
p.isSalesman,
|
||||
p.updated AS source_updated_at,
|
||||
p.ts AS source_rowversion
|
||||
FROM dbo.AA_Person AS p
|
||||
WHERE p.id > :after_id
|
||||
ORDER BY p.id;
|
||||
16
db/queries/tplus/products.sql
Normal file
16
db/queries/tplus/products.sql
Normal file
@ -0,0 +1,16 @@
|
||||
-- 已在客户备份核对列结构;业务语义仍待确认。
|
||||
-- SQL Server 2008 R2 语法;主键游标分页,时间水位尚未启用。
|
||||
SELECT TOP (:batch_size)
|
||||
i.id AS id,
|
||||
i.code AS code,
|
||||
i.name,
|
||||
i.specification,
|
||||
i.idunit,
|
||||
i.idinventoryclass,
|
||||
i.disabled,
|
||||
i.invSCost,
|
||||
i.updated AS source_updated_at,
|
||||
i.ts AS source_rowversion
|
||||
FROM dbo.AA_Inventory AS i
|
||||
WHERE i.id > :after_id
|
||||
ORDER BY i.id;
|
||||
24
db/queries/tplus/receipt_items.sql
Normal file
24
db/queries/tplus/receipt_items.sql
Normal file
@ -0,0 +1,24 @@
|
||||
-- 已在客户备份核对列结构;业务语义仍待确认。
|
||||
-- SQL Server 2008 R2 语法;主键游标分页,时间水位尚未启用。
|
||||
SELECT TOP (:batch_size)
|
||||
b.id AS id,
|
||||
b.code AS code,
|
||||
b.id AS line_no,
|
||||
b.idArapReceivePaymentDTO AS source_parent_id,
|
||||
b.voucherID,
|
||||
b.voucherDetailID,
|
||||
b.saleorderid,
|
||||
b.saleordercode,
|
||||
b.sourceorderid,
|
||||
b.idsourceordertype,
|
||||
b.idvouchertype,
|
||||
b.voucherDate,
|
||||
b.arrivalDate,
|
||||
b.currentAmount,
|
||||
b.Amount,
|
||||
b.VoucherAmount,
|
||||
CAST(NULL AS datetime) AS source_updated_at,
|
||||
b.ts AS source_rowversion
|
||||
FROM dbo.ARAP_ReceivePayment_b AS b
|
||||
WHERE b.id > :after_id
|
||||
ORDER BY b.id;
|
||||
24
db/queries/tplus/receipts.sql
Normal file
24
db/queries/tplus/receipts.sql
Normal file
@ -0,0 +1,24 @@
|
||||
-- 已在客户备份核对列结构;业务语义仍待确认。
|
||||
-- SQL Server 2008 R2 语法;主键游标分页,时间水位尚未启用。
|
||||
SELECT TOP (:batch_size)
|
||||
h.ID AS id,
|
||||
h.code AS code,
|
||||
h.voucherDate,
|
||||
h.auditedDate,
|
||||
h.createdTime,
|
||||
h.SaleOrderCode,
|
||||
h.SaleOrderID,
|
||||
h.idpartner,
|
||||
h.idperson,
|
||||
h.IdSettleStyle,
|
||||
h.idvouchertype,
|
||||
h.isReceiveFlag,
|
||||
h.cancelState,
|
||||
h.voucherstate,
|
||||
h.amount,
|
||||
h.paymentAmount,
|
||||
CAST(NULL AS datetime) AS source_updated_at,
|
||||
h.ts AS source_rowversion
|
||||
FROM dbo.ARAP_ReceivePayment AS h
|
||||
WHERE h.id > :after_id
|
||||
ORDER BY h.id;
|
||||
26
db/queries/tplus/shipment_items.sql
Normal file
26
db/queries/tplus/shipment_items.sql
Normal file
@ -0,0 +1,26 @@
|
||||
-- 已在客户备份核对列结构;业务语义仍待确认。
|
||||
-- SQL Server 2008 R2 语法;主键游标分页,时间水位尚未启用。
|
||||
SELECT TOP (:batch_size)
|
||||
b.ID AS id,
|
||||
b.code AS code,
|
||||
b.ID AS line_no,
|
||||
b.idSaleDeliveryDTO AS source_parent_id,
|
||||
b.idinventory,
|
||||
b.saleOrderId,
|
||||
b.saleOrderDetailId,
|
||||
b.quantity,
|
||||
b.baseQuantity,
|
||||
b.price,
|
||||
b.taxPrice,
|
||||
b.taxAmount,
|
||||
b.costPrice,
|
||||
b.costAmount,
|
||||
b.CostPriceGetType,
|
||||
b.returnQuntity,
|
||||
b.backSentQuantity,
|
||||
b.isCancel,
|
||||
b.updated AS source_updated_at,
|
||||
b.ts AS source_rowversion
|
||||
FROM dbo.SA_SaleDelivery_b AS b
|
||||
WHERE b.id > :after_id
|
||||
ORDER BY b.id;
|
||||
23
db/queries/tplus/shipments.sql
Normal file
23
db/queries/tplus/shipments.sql
Normal file
@ -0,0 +1,23 @@
|
||||
-- 已在客户备份核对列结构;业务语义仍待确认。
|
||||
-- SQL Server 2008 R2 语法;主键游标分页,时间水位尚未启用。
|
||||
SELECT TOP (:batch_size)
|
||||
h.ID AS id,
|
||||
h.code AS code,
|
||||
h.voucherdate,
|
||||
h.SendDate,
|
||||
h.deliveryDate,
|
||||
h.auditeddate,
|
||||
h.SourceVoucherCode,
|
||||
h.SaleOrderCode,
|
||||
h.idcustomer,
|
||||
h.idclerk,
|
||||
h.amount,
|
||||
h.taxAmount,
|
||||
h.voucherState,
|
||||
h.isCancel,
|
||||
h.isCounteractDelivery,
|
||||
h.updated AS source_updated_at,
|
||||
h.ts AS source_rowversion
|
||||
FROM dbo.SA_SaleDelivery AS h
|
||||
WHERE h.id > :after_id
|
||||
ORDER BY h.id;
|
||||
43
docker/docker-compose.tplus.dev.yml
Normal file
43
docker/docker-compose.tplus.dev.yml
Normal file
@ -0,0 +1,43 @@
|
||||
# T+ SQL Server 恢复 PoC
|
||||
#
|
||||
# 使用前:
|
||||
# 1. 复制 docker/tplus.env.example 为 D:\work\app\tplus-sqlserver\.env;
|
||||
# 2. 将客户 .bak 放入 D:\work\app\tplus-sqlserver\backup;
|
||||
# 3. SQL Server 数据、日志和备份通过绑定目录写入 D 盘;不修改 Docker Desktop 的磁盘镜像设置。
|
||||
name: ys-payroll-tplus
|
||||
|
||||
services:
|
||||
tplus-sqlserver:
|
||||
image: ${TPLUS_SQLSERVER_IMAGE:-mcr.microsoft.com/mssql/server:2022-CU16-ubuntu-22.04}
|
||||
container_name: ys-tplus-sqlserver-dev
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
ACCEPT_EULA: "Y"
|
||||
MSSQL_PID: Developer
|
||||
MSSQL_SA_PASSWORD: ${TPLUS_SA_PASSWORD:?请在 D:/work/app/tplus-sqlserver/.env 设置 TPLUS_SA_PASSWORD}
|
||||
TZ: Asia/Shanghai
|
||||
ports:
|
||||
- "1433:1433"
|
||||
volumes:
|
||||
- type: bind
|
||||
source: ${TPLUS_ROOT:-D:/work/app/tplus-sqlserver}/data
|
||||
target: /var/opt/mssql/data
|
||||
- type: bind
|
||||
source: ${TPLUS_ROOT:-D:/work/app/tplus-sqlserver}/log
|
||||
target: /var/opt/mssql/log
|
||||
- type: bind
|
||||
source: ${TPLUS_ROOT:-D:/work/app/tplus-sqlserver}/backup
|
||||
target: /var/opt/mssql/backup
|
||||
read_only: true
|
||||
- type: bind
|
||||
source: ${TPLUS_ROOT:-D:/work/app/tplus-sqlserver}/scripts
|
||||
target: /opt/tplus/scripts
|
||||
read_only: true
|
||||
healthcheck:
|
||||
test:
|
||||
- CMD-SHELL
|
||||
- /opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P "$${MSSQL_SA_PASSWORD}" -C -Q "SELECT 1" >/dev/null 2>&1
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 20
|
||||
start_period: 30s
|
||||
5
docker/tplus.env.example
Normal file
5
docker/tplus.env.example
Normal file
@ -0,0 +1,5 @@
|
||||
# 复制到 D:\work\app\tplus-sqlserver\.env 后填写。
|
||||
# 不要把真实密码写入仓库或提交 Git。
|
||||
TPLUS_ROOT=D:/work/app/tplus-sqlserver
|
||||
TPLUS_SQLSERVER_IMAGE=mcr.microsoft.com/mssql/server:2022-CU16-ubuntu-22.04
|
||||
TPLUS_SA_PASSWORD=
|
||||
63
docs/SQLServer2008R2测试环境.md
Normal file
63
docs/SQLServer2008R2测试环境.md
Normal file
@ -0,0 +1,63 @@
|
||||
# SQL Server 2008 R2 测试环境与验收手册
|
||||
|
||||
> 目标:在真实 Windows + SQL Server 2008 R2(10.50.x)环境中恢复客户原始备份,完成同版本兼容验收,关闭「2008 R2 状态 PENDING」。本手册在取得合法安装介质后即可照做;介质到位前的准备工作只做第 2、3 节中不依赖介质的部分。
|
||||
|
||||
## 1. 背景与当前状态(2026-10-28)
|
||||
|
||||
- 客户源备份 `ceshi20261026.bak` 版本为 10.50.1600;生产库具体版本待客户提供(确认单 B3)。
|
||||
- 本地 Docker 已用 SQL Server 2022 副本完成恢复 PoC:完整 CHECKDB 通过、只读账号可用、六类查询两轮核验 933,203 行无失败。该副本只用于结构核验与开发,**不能替代同版本验收**。
|
||||
- Hyper-V 虚拟机外壳已建于 `D:\work\app\tplus-vm`,处于关机、断网状态;Windows 与 SQL Server 2008 R2 安装介质尚未到位(确认单 B5)。
|
||||
|
||||
## 2. 环境约定
|
||||
|
||||
| 项目 | 约定 |
|
||||
| --- | --- |
|
||||
| 虚拟机目录 | `D:\work\app\tplus-vm` |
|
||||
| SQL Server 数据与日志 | `D:\work\app\tplus-sqlserver`(绑定目录,不入项目仓库) |
|
||||
| 备份文件 | `D:\work\app\tplus-backup`(先校验 SHA-256 再使用) |
|
||||
| 虚拟机网络 | 安装与验收期间保持仅主机或内网隔离,不接入公网 |
|
||||
| 凭据 | 只读账号凭据走双方认可的私密渠道交接;不写入本文档、代码或 Git |
|
||||
| 项目依赖脚本 | `scripts/tplus/`(工作区准备、备份检查、恢复、只读账号、结构盘点) |
|
||||
|
||||
## 3. 装机步骤(依赖介质,介质到位后执行)
|
||||
|
||||
1. 运行 `scripts/tplus/prepare_sql2008r2_vm.ps1` 核对虚拟机外壳与磁盘位置;确认网卡保持断开。
|
||||
2. 挂载 Windows 安装介质,安装操作系统;完成后安装集成服务与全部安全更新,重新启用受限网络。
|
||||
3. 挂载 SQL Server 2008 R2 安装介质,安装数据库引擎实例;版本不低于 10.50.1600(RTM 即可还原源备份),实例排序规则与源备份一致(默认 Chinese_PRC_CI_AS)。
|
||||
4. 安装 SQL Server 2008 R2 的 SP3 与后续补丁(可选);补丁不影响 10.50 主版本兼容性。
|
||||
5. 在虚拟机内安装 `ODBC Driver 17 for SQL Server`(pyodbc 依赖;Driver 17 支持连接 2008 R2)。
|
||||
|
||||
## 4. 备份恢复步骤
|
||||
|
||||
1. `scripts/tplus/inspect_sqlserver_backup.ps1`:对 `ceshi20261026.bak` 执行 VERIFYONLY 与 FILELISTONLY,确认备份完整并列出数据/日志文件逻辑名。
|
||||
2. `scripts/tplus/restore_sqlserver_backup.ps1`:执行 RESTORE DATABASE,文件落位 `D:\work\app\tplus-sqlserver`;恢复模式按脚本默认。
|
||||
3. 恢复完成后执行 `DBCC CHECKDB` 全库检查,结果留档。
|
||||
4. `scripts/tplus/create_tplus_readonly_login.ps1`:创建专用只读登录与数据库用户,仅授予 `db_datareader`;凭据按第 2 节约定交接。
|
||||
5. `scripts/tplus/inventory_tplus_database.ps1`:盘点核心表结构与行数,与 Docker 副本的结构核验结果比对。
|
||||
|
||||
## 5. 同版本验收清单(逐项通过后关闭 PENDING)
|
||||
|
||||
| # | 验收项 | 通过标准 |
|
||||
| --- | --- | --- |
|
||||
| 1 | 版本确认 | `SELECT @@VERSION` 返回 10.50.x,与源备份主版本一致 |
|
||||
| 2 | 驱动连接 | 后端配置指向该实例,pyodbc + ODBC Driver 17 连接成功 |
|
||||
| 3 | 只读权限 | 用只读账号执行六类查询成功;执行任何写操作被拒绝 |
|
||||
| 4 | 六类查询 | 人员、客户、产品、订单、发货、收付款九个白名单 SQL 全部返回,行数与 Docker 副本一致 |
|
||||
| 5 | 中文数据 | 姓名、客户名、产品名等中文字段读取无乱码(只读账号仅验证读取) |
|
||||
| 6 | 日期字段 | 凭证日期、审核日期、到账日期等候选字段格式与空值表现正常 |
|
||||
| 7 | 金额精度 | 金额字段以 Decimal 读取,小数位与源数据一致 |
|
||||
| 8 | 断连超时 | 人为断网/停实例,同步错误进入 `tplus_sync_errors`,不静默丢数据 |
|
||||
| 9 | 重复同步 | 同一备份重复 staging,相同行哈希全部去重跳过,行数不变 |
|
||||
| 10 | 关联核验 | `relationship_audit` 数量统计与 Docker 副本结果一致 |
|
||||
|
||||
## 6. 产出与归档
|
||||
|
||||
- 验收通过后产出报告(含上表逐项结果与 `@@VERSION` 输出),归档至 `.ai/archive/ai-aux/`;项目文档更新结论为「2008 R2 验收通过」。
|
||||
- 同步更新 `.ai/AI_CONTEXT.md` 与 `.ai/PROJECT_PROGRESS.md`。
|
||||
- 验收中发现的字段差异回到 `docs/待确认事项清单_2026-10-28_v2.xlsx` 对应编号,不直接改代码。
|
||||
|
||||
## 7. 明确边界
|
||||
|
||||
- 本环境仅验收 T+ 源库只读访问;工资系统生产部署(确认单 B4)另行确认。
|
||||
- 客户生产 T+ 原库不升级、不改动;2022 副本不回迁。
|
||||
- 时间增量(确认单 #13)不在本手册范围内,待 rowversion 验证方案另行评审。
|
||||
@ -1,289 +0,0 @@
|
||||
# T+ 12.3 字段映射草案(v0.1,2026-10-20)
|
||||
|
||||
> 本文仅记录字段级事实与页面索引,不复制厂商文档正文。
|
||||
> 基线:`docs/T+对接数据需求清单_v1.4_2026-10-19.xlsx`(75 项)+ v1.3 IT 18 项 + 《T+12.3数据字典》。
|
||||
|
||||
## 0. 结论摘要
|
||||
|
||||
- 覆盖:清单 **75/75** 项均已给出 T+ 侧候选字段与系统目标列;
|
||||
- 状态分布:候选待确认 42 / 字典明确 23 / 未检出 9 / 需 IT 1;
|
||||
- 数据字典无法让 IT 18 项中的任何一项完全闭环;但 9 项可改写为「候选字段选择题」,6 项部分可答,2 项纯 IT,1 项转业务。
|
||||
- 收敛后:**13 项 IT 待确认 + 1 组业务裁决**(见第 12 节)。
|
||||
- 关键缺口:人员工号/入职日期/试用期无字段、客户 status 缺失、回款 payment_method/confirm_status 待补、明细行无显式行号、无源端更新时间与负向事件模型。
|
||||
|
||||
## 1. 编制说明与证据等级
|
||||
|
||||
| 标记 | 含义 |
|
||||
| --- | --- |
|
||||
| `D` | 字典明确 |
|
||||
| `D?` | 候选待确认 |
|
||||
| `IT` | 需 IT |
|
||||
| `B` | 需业务 |
|
||||
| `S` | 系统自维护 |
|
||||
| `N` | 未检出 |
|
||||
|
||||
- `D` 仅用于字段存在、类型明确且语义直接匹配的项;涉及「取哪个字段 / 业务口径」一律标 `D?` 或 `IT`。
|
||||
- 字典来源为 T+ 12.3 SchemaDoc;同一字段可能标注 `T+12.1/12.2/12.3` 版本,映射按 12.3 可用为准。
|
||||
|
||||
## 2. 公共字段与同步元数据
|
||||
|
||||
| 序号 | 数据项 | T+ 表.字段(字典) | T+ 类型/长度 | 枚举/取值 | 系统目标表.字段 | 转换/清洗规则 | 必要性 | 确认状态 | 来源/字典页 |
|
||||
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
|
||||
| 1 | T+ 原始单据号 / 档案编码 | 各表 code(人员/存货/客户/订单/出库/收付款单均有) | nvarchar(30~64) | — | personnel/products/customers/businesses/shipments/receipts.source_no | TRIM;作废重开是否换号待 IT(#4) | 必须 | D? | AA_Person.code、AA_Inventory.code、AA_Partner.code、SA_SaleOrder.code、SA_SaleDelivery.code、ARAP_ReceivePayment.code |
|
||||
| 2 | T+ 系统内部标识 | 各表 id(整型主键) | int(4) | — | 六表 t_plus_id | int → 字符串存储;辅助对账/改名追溯 | 建议 | D | 各表 id(主键) |
|
||||
|
||||
系统侧六张主表已预留 `source_no / t_plus_id / sync_batch_no / synced_at`;`source_no` 当前为普通索引,IT 确认幂等键后升级唯一约束。
|
||||
|
||||
## 3. 人员档案
|
||||
|
||||
人员主档;隶属关系需经部门树/部门负责人推断,T+ 无直属上级字段。
|
||||
|
||||
| 序号 | 数据项 | T+ 表.字段(字典) | T+ 类型/长度 | 枚举/取值 | 系统目标表.字段 | 转换/清洗规则 | 必要性 | 确认状态 | 来源/字典页 |
|
||||
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
|
||||
| 1 | 人员唯一编码(工号)★ | AA_Person.code | nvarchar(32) | — | personnel.source_no(建议补 personnel_code) | TRIM;编码优先、姓名辅助核对;是否即工号待 IT(#19) | 必须 | D? | AA_Person.code(编号) |
|
||||
| 2 | 姓名 | AA_Person.name | nvarchar(200) | — | personnel.personnel_name | TRIM;仅辅助核对,不作唯一键 | 必须 | D | AA_Person.name(名称) |
|
||||
| 3 | 在职状态 | AA_Person.disabled | tinyint(1) | 0 启用 / 1 停用(候选) | personnel.status(1 在职 / 0 离职) | disabled=1 → 0;无离职日期字段,离职时间需 IT 确认 | 必须 | D? | AA_Person.disabled(停用) |
|
||||
| 4 | 是否试用期 | 未检出 | — | — | personnel.is_probation | T+ 人员档案无试用期字段;系统内人工维护或按入职日期规则推导 | 必须 | N | AA_Person 全表检索无 probation/试用 字段 |
|
||||
| 5 | 入职日期 | 未检出(仅 madeDate 制单日期、birthday 出生日期) | — | — | personnel.hire_date | T+ 无入职日期字段;确认是否有自定义项承载,否则人工维护 | 必须 | N | AA_Person 全表检索无 hire/entry/入职 字段 |
|
||||
| 6 | 联系电话 | AA_Person.mobilePhoneNo / officePhoneNo | nvarchar(20) / nvarchar(100) | — | personnel.phone | 优先 mobilePhoneNo,空则 officePhoneNo;日志脱敏 | 建议 | D | AA_Person.mobilePhoneNo(手机)、officePhoneNo(办公电话) |
|
||||
| 7 | 业务员 ↔ 营销经理隶属关系 | AA_Person.iddepartment → AA_Department.idparent / iddirector | int(4) | — | personnel.manager_id | T+ 人员档案无直属上级字段;可经部门树/部门负责人推断,规则待确认;否则系统人工指定 | 必须 | D? | AA_Person.iddepartment(部门id);AA_Department.idparent(上级id)、iddirector(部门负责人) |
|
||||
| 8 | 人员负责区域 / 产品辐射范围 | 未检出(可用 priuserdefnvc* 自定义项承载,需 IT 确认) | — | — | personnel_regions.region_name | 无专门字段则系统内人工维护 | 建议 | N | AA_Person 无区域/辐射范围字段 |
|
||||
| 9 | 银行账户名 / 账号 / 开户行 | 未检出(AA_Partner 有银行账号字段,人员档案无) | — | — | 暂不落库 | 一期不采集;如后续代发工资再扩展 | 可选 | N | AA_Person 无银行账户字段 |
|
||||
|
||||
## 4. 产品档案
|
||||
|
||||
产品主档不含库存量与售价,数量取现存量表、价格取价目表/订单明细。
|
||||
|
||||
| 序号 | 数据项 | T+ 表.字段(字典) | T+ 类型/长度 | 枚举/取值 | 系统目标表.字段 | 转换/清洗规则 | 必要性 | 确认状态 | 来源/字典页 |
|
||||
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
|
||||
| 1 | 产品名称 | AA_Inventory.name | nvarchar(200) | — | products.product_name | TRIM;与规格型号组成系统组合唯一键 | 必须 | D | AA_Inventory.name(名称) |
|
||||
| 2 | 规格型号 | AA_Inventory.specification | nvarchar(200) | — | products.spec_model | 空值归一为空串 | 必须 | D | AA_Inventory.specification(规格型号) |
|
||||
| 3 | 计量单位 | AA_Inventory.idunit → 计量单位表 | int(4) | — | products.unit | 按 idunit join 单位表取名称;单位表名待索引确认 | 建议 | D? | AA_Inventory.idunit(计量单位id)、idunitgroup |
|
||||
| 4 | 数量 | ST_CurrentStock.baseQuantity(现存量) | decimal(13) | — | products.stock_quantity | 按存货+仓库汇总;语义(现存量/在途/累计销量)待 IT(#12) | 必须 | D? | ST_CurrentStock.baseQuantity(主计量数量);AA_Inventory 无库存量字段 |
|
||||
| 5 | 销售单价 | AA_InventoryPrice(零售价/批发价)、AA_InventoryUnitPrice.retailPrice;订单明细实际成交价 | decimal(13~17) / nvarchar(1000) 公式 | — | products.sale_price | 产品档案参考价取价目表;绩效以订单/发货明细实际单价为准 | 必须 | D? | AA_InventoryPrice、AA_InventoryUnitPrice;AA_Inventory 无销售单价字段 |
|
||||
| 6 | 产品分类(类目) | AA_Inventory.idinventoryclass → AA_InventoryClass.name | int(4) | — | products.product_category | T+ 分类仅辅助判定市政水利/轨交;系统分类人工维护不覆盖 | 必须 | D? | AA_Inventory.idinventoryclass(存货分类id)、AA_InventoryClass.name |
|
||||
| 7 | 计量单位换算关系(如存在) | AA_Inventory_MultiUnit / AA_InventoryUnitPrice.rateOfExchange | decimal(13) | — | 暂不实现(系统单单位存储) | 如业务有多单位换算需求,另行设计 | 可选 | D? | AA_Inventory_MultiUnit、AA_InventoryUnitPrice.rateOfExchange(换算率) |
|
||||
|
||||
## 5. 业务单据(销售订单)
|
||||
|
||||
成本额在订单明细仅有最新成本,出库明细才有成本金额;销售额需按数量×单价计算。
|
||||
|
||||
| 序号 | 数据项 | T+ 表.字段(字典) | T+ 类型/长度 | 枚举/取值 | 系统目标表.字段 | 转换/清洗规则 | 必要性 | 确认状态 | 来源/字典页 |
|
||||
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
|
||||
| 1 | 订单单号 | SA_SaleOrder.code | nvarchar(30) | — | businesses.source_no | TRIM;不得写入内部 business_no | 必须 | D | SA_SaleOrder.code(编号) |
|
||||
| 2 | 业务(订单)日期 | SA_SaleOrder.voucherdate | datetime(8) | — | businesses.business_date | 截断为 DATE | 必须 | D | SA_SaleOrder.voucherdate(单据日期) |
|
||||
| 3 | 客户标识 | SA_SaleOrder.idcustomer → AA_Partner.code | int(4) | — | businesses.customer_id | 按客户编码解析;回退公司名称+负责人 | 必须 | D? | SA_SaleOrder.idcustomer(客户id)、idsettlecustomer(结算客户id) |
|
||||
| 4 | 负责营销人员 | SA_SaleOrder.idclerk → AA_Person.code | int(4) | — | businesses.owner_personnel_id | 按人员编码解析;新插入写 T+ 负责人、更新是否覆盖人工归属待裁决 | 必须 | D? | SA_SaleOrder.idclerk(业务员id) |
|
||||
| 5 | 协助营销人员 | 未检出独立协助人字段 | — | — | businesses.assistant_personnel_id | 业务已回馈 T+ 合同只挂单一负责人;协助人系统内记录 | 建议 | N | SA_SaleOrder 无 assist/协助 字段 |
|
||||
| 6 | 特殊业务标记 | SA_SaleOrder.idbusinesstype → 业务类型表 | int(4) | 业务类型枚举待字典/IT 提供 | businesses.is_special | 业务类型是否等同特殊业务待确认;无则人工标记 | 必须 | D? | SA_SaleOrder.idbusinesstype(业务类型id) |
|
||||
| 7 | 明细行 · 产品名称 + 规格 | SA_SaleOrder_b.idinventory → AA_Inventory.name/specification | int(4) | — | business_items.product_name / spec_model | 冗余固化名称与规格;未建档产品允许自由录入 | 必须 | D | SA_SaleOrder_b.idinventory(存货id) |
|
||||
| 8 | 明细行 · 数量 | SA_SaleOrder_b.quantity | decimal(13) | — | business_items.quantity | 主计量数量;需 >0 校验 | 必须 | D | SA_SaleOrder_b.quantity(数量)、baseQuantity(主计量数量) |
|
||||
| 9 | 明细行 · 销售单价 | SA_SaleOrder_b.price / taxPrice | decimal(13) | — | business_items.sale_price | price 为不含税单价、taxPrice 为含税单价;取哪个待确认 | 必须 | D? | SA_SaleOrder_b.price(单价)、taxPrice(含税单价) |
|
||||
| 10 | 明细行 · 销售额 | SA_SaleOrder_b 无「销售额」字段;由数量×单价计算,或 taxAmount−tax | decimal(13) | — | business_items.sale_amount | 系统按数量×单价固化;T+ 用于对账 | 必须 | D? | SA_SaleOrder_b.quantity、price、taxAmount、tax |
|
||||
| 11 | 明细行 · 成本额 ★ | SA_SaleOrder_b.LatestCost(最新成本);SA_SaleDelivery_b.costPrice/costAmount | decimal(13) | — | business_items.cost_amount | 取订单最新成本还是出库成本、成本类型/时点待 IT(#1) | 必须 | D? | SA_SaleOrder_b.LatestCost;SA_SaleDelivery_b.costPrice/costAmount/CostPriceGetType |
|
||||
| 12 | 项目 / 工程编号或名称(如存在) | SA_SaleOrder.idproject → 项目表 | int(4) | — | 建议预留 project_code(当前不落库) | 按项目查询与报表归集;是否建列待业务 | 可选 | D? | SA_SaleOrder.idproject(项目id)、SA_SaleOrder_b.idproject |
|
||||
| 13 | 服务类 / 维修类及其他收入单据(如存在) | 未检索到服务/维修类独立单据表 | — | — | 暂人工录入 | 业务规则未定前不纳入自动同步 | 可选 | N | SA_* 表索引未发现服务/维修类主表 |
|
||||
|
||||
## 6. 发货单据(销售出库/发货)
|
||||
|
||||
发货日期有四个候选字段;运费未在发货单据检出。
|
||||
|
||||
| 序号 | 数据项 | T+ 表.字段(字典) | T+ 类型/长度 | 枚举/取值 | 系统目标表.字段 | 转换/清洗规则 | 必要性 | 确认状态 | 来源/字典页 |
|
||||
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
|
||||
| 1 | 发货单号 | SA_SaleDelivery.code | nvarchar(30) | — | shipments.source_no | TRIM;不得写入内部 shipment_no | 必须 | D | SA_SaleDelivery.code(编号) |
|
||||
| 2 | 发货日期 ★ | SA_SaleDelivery.voucherdate / SendDate / deliveryDate / auditeddate | datetime(8) | — | shipments.ship_date | 四个日期候选:单据日期/送货日期(T+12.3)/交货日期/审核日期;取哪个待 IT(#2) | 必须 | D? | SA_SaleDelivery.voucherdate、SendDate、deliveryDate、auditeddate |
|
||||
| 3 | 关联订单号 | SA_SaleDelivery.SourceVoucherCode / idsourcevouchertype;明细 saleOrderCode/saleOrderId | nvarchar(50) / int(4) | — | shipments.business_id | 按订单号解析;主关联路径待 IT(#5) | 必须 | D? | SA_SaleDelivery.SourceVoucherCode、SA_SaleDelivery_b.saleOrderCode/saleOrderId/saleOrderDetailId |
|
||||
| 4 | 运费金额 | 未检出运费字段(明细有 origExpenseAmount 费用分摊金额) | decimal(13) | — | shipments.freight_amount | T+ 运费可能在费用单/其他单据,需 IT 确认;系统暂人工 | 必须 | N | SA_SaleDelivery/SA_SaleDelivery_b 无 freight/运费字段 |
|
||||
| 5 | 发货总金额 | SA_SaleDelivery.amount / taxAmount | decimal(13) | — | shipments.total_amount | 系统按明细汇总,不直写;T+ 金额用于对账 | 必须 | D | SA_SaleDelivery.amount(金额)、taxAmount(含税金额) |
|
||||
| 6 | 明细行 · 产品/规格/数量/单价/行金额 | SA_SaleDelivery_b.idinventory / quantity / price / taxPrice / taxAmount | decimal(13) | — | shipment_items.product_name/spec_model/quantity/sale_price/amount | 行金额由系统按数量×单价固化;T+ 用于对账 | 必须 | D | SA_SaleDelivery_b.quantity、price、taxPrice、taxAmount |
|
||||
| 7 | 发货单状态 | SA_SaleDelivery.voucherState / isCancel / auditeddate | int(4) / tinyint(1) | 单据状态枚举待 IT 提供 | 不落库(v1.4 统一) | 仅推已生效单据;状态随行供同步校验 | 必须 | D | SA_SaleDelivery.voucherState(单据状态)、isCancel(状态) |
|
||||
| 8 | 承运商 / 物流单号 | SA_SaleDelivery.deliveryMode(运输方式 id) | int(4) | 运输方式枚举表待确认 | 暂不落库 | 承运商/物流单号无独立字段;如需建列再扩展 | 可选 | D? | SA_SaleDelivery.deliveryMode(运输方式) |
|
||||
|
||||
## 7. 回款单据(收款/核销)
|
||||
|
||||
到账日期无银行到账日字段;收款方式取结算方式表;确认/核销状态字段存在但枚举待 IT。
|
||||
|
||||
| 序号 | 数据项 | T+ 表.字段(字典) | T+ 类型/长度 | 枚举/取值 | 系统目标表.字段 | 转换/清洗规则 | 必要性 | 确认状态 | 来源/字典页 |
|
||||
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
|
||||
| 1 | 回款单号 | ARAP_ReceivePayment.code | nvarchar(30) | — | receipts.source_no | TRIM;不得写入内部 receipt_batch_no | 必须 | D | ARAP_ReceivePayment.code(编号) |
|
||||
| 2 | 到账日期 ★ | ARAP_ReceivePayment.voucherDate / auditedDate / createdTime | datetime(8) | — | receipts.receipt_date | 无银行到账日字段;取单据日期/审核日期/创建时间哪个待 IT(#3) | 必须 | D? | ARAP_ReceivePayment.voucherDate、auditedDate、createdTime |
|
||||
| 3 | 回款金额 | ARAP_ReceivePayment.amount(本币)/ origAmount(原币) | decimal(13) | — | receipts.amount | 取本币金额;一期禁负数,红字规则待 #8 | 必须 | D | ARAP_ReceivePayment.amount、origAmount |
|
||||
| 4 | 关联订单号 | ARAP_ReceivePayment.SaleOrderCode/SaleOrderID;明细 saleordercode/saleorderid/sourceordercode | nvarchar(50) / int(4) | — | receipts.business_id | 回款↔订单按订单号解析;回款↔发货批次系统人工指定 | 必须 | D? | ARAP_ReceivePayment.SaleOrderCode/SaleOrderID;ARAP_ReceivePayment_b.saleordercode/sourceordercode |
|
||||
| 5 | 收款方式 | ARAP_ReceivePayment.IdSettleStyle → AA_SettleStyle.code/name | int(4) | 结算方式枚举表(AA_SettleStyle:code/name/停用) | receipts.payment_method(待补列) | 枚举映射;完整枚举清单需 IT 提供 | 必须 | D? | ARAP_ReceivePayment.IdSettleStyle;AA_SettleStyle |
|
||||
| 6 | 以物抵顶物说明 | 收款方式(以物抵顶枚举值)+ ARAP_ReceivePayment.memo | int(4) / nvarchar(200) | 以物抵顶对应结算方式代码待 IT 提供 | receipts.is_barter / remark | 按结算方式识别;抵顶物说明暂入备注 | 建议 | D? | ARAP_ReceivePayment.IdSettleStyle、memo |
|
||||
| 7 | 回款确认 / 核销状态 | ARAP_ReceivePayment.cancelState / voucherstate / auditedDate | int(4) / datetime(8) | 核销/单据状态枚举待 IT 提供 | receipts.confirm_status(待补列) | 字段存在;哪个可作为人工确认依据待 IT(#16) | 必须 | D? | ARAP_ReceivePayment.cancelState(核销状态)、voucherstate(单据状态) |
|
||||
| 8 | 银行流水号 / 回单参考号 | 未检出银行流水号/回单号字段(MultiSettle.documentCode 为票据号) | — | — | 建议预留 bank_serial_no | 需 IT 确认是否有对应字段 | 建议 | N | ARAP_ReceivePayment/ARAP_ReceivePayment_MultiSettle 仅票据号 |
|
||||
| 9 | 票据信息(票号 / 到期日 / 贴现说明) | ARAP_ReceivePayment_MultiSettle.documentCode / amount / idsettlestyle | nvarchar(30) / decimal(13) | 票据类型枚举待 IT | 系统暂以备注承载 | 无票据到期日字段;承兑到期核销信息需 IT 确认 | 建议 | D? | ARAP_ReceivePayment_MultiSettle.documentCode(票据号) |
|
||||
| 10 | 币种 / 汇率(如存在外币业务) | ARAP_ReceivePayment.idcurrency / exchangeRate | int(4) / decimal(13) | 币种表待索引 | 一期不落库(人民币记账) | 如存在外币业务再扩展 | 可选 | D | ARAP_ReceivePayment.idcurrency、exchangeRate |
|
||||
|
||||
## 8. 客户档案
|
||||
|
||||
采用往来单位表 AA_Partner;客户编码/停用字段存在,status 列需系统补。
|
||||
|
||||
| 序号 | 数据项 | T+ 表.字段(字典) | T+ 类型/长度 | 枚举/取值 | 系统目标表.字段 | 转换/清洗规则 | 必要性 | 确认状态 | 来源/字典页 |
|
||||
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
|
||||
| 1 | 公司名称 | AA_Partner.name | nvarchar(200) | — | customers.company_name | TRIM;与负责人组成系统唯一键 | 必须 | D | AA_Partner.name(名称) |
|
||||
| 2 | 负责人姓名 | AA_Partner.Contact(联系人)/ representative(法人代表) | nvarchar(50) | — | customers.owner_name | 「负责人」对应 Contact 还是 representative 待 IT/业务确认 | 必须 | D? | AA_Partner.Contact、representative |
|
||||
| 3 | 客户编码 / 状态 / 启用停用时间 | AA_Partner.code / disabled / madeDate / updated | nvarchar(32) / tinyint(1) | disabled 0 启用 / 1 停用(候选) | customers.source_no / status(待补列) | 启停时间无独立列,可用 updated 近似(需 IT 确认) | 必须 | D? | AA_Partner.code、disabled、madeDate、updated |
|
||||
| 4 | 联系人 / 电话 / 地址 / 行业 | AA_Partner.Contact / TelephoneNo / MobilePhone / CustomerAddress / iddistrict | nvarchar(30~200) | — | customers.contact_person / contact_phone / address / industry | 行业无直接字段;地区按 iddistrict 关联 | 可选 | D? | AA_Partner.Contact、TelephoneNo、MobilePhone、CustomerAddress、iddistrict |
|
||||
| 5 | 开票信息(税号 / 开票名称 / 开票地址电话) | AA_Partner.taxRegcode / name / CustomerAddress | nvarchar(50~200) | — | 系统暂仅供展示,不落库 | 税号存在;开票地址电话需 IT 确认字段 | 可选 | D? | AA_Partner.taxRegcode(纳税号)、CustomerAddress |
|
||||
|
||||
## 9. 同步完整性与补充字段
|
||||
|
||||
时间戳与负向字段部分存在;无统一事件表、无明细行号、无手续费字段。
|
||||
|
||||
| 序号 | 数据项 | T+ 表.字段(字典) | T+ 类型/长度 | 枚举/取值 | 系统目标表.字段 | 转换/清洗规则 | 必要性 | 确认状态 | 来源/字典页 |
|
||||
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
|
||||
| 1 | 数据最后更新时间 / 单据创建时间 / 修改时间 | 各表 createdTime/createdtime、updated、madeDate、auditedDate、ts | datetime(8) / timestamp(8) | — | 建议新增 source_created_at / source_updated_at(当前无) | 本地 update_time 不可作为源端水位;增量水位字段待 IT(#9) | 必须 | D? | 各表 updated/createdTime;ts(分布时间戳) |
|
||||
| 2 | 变更类型 / 单据事件:新增、修改、删除、作废、红冲、退货、冲销 | 各表 isCancel(状态)、isCounteractDelivery(冲抵销货)、returnQuntity(退货数量) | int(4) / tinyint(1) / decimal(13) | 状态/事件枚举待 IT 提供 | 建议事件表(当前无) | 无统一变更事件表;负向事实需事件模型,规则待 #8 | 必须 | D? | SA_SaleOrder.isCancel、SA_SaleDelivery.isCounteractDelivery、SA_SaleDelivery_b.returnQuntity |
|
||||
| 3 | 单据状态 / 财务审核状态 / 状态变更时间 | voucherState / auditedDate / auditor / auditorid | int(4) / datetime(8) / nvarchar(50) | 单据状态枚举待 IT | 不落库(仅同步校验) | 仅推已生效单据;状态随行校验 | 必须 | D | SA_SaleOrder/SA_SaleDelivery/ARAP_ReceivePayment 的 voucherState、auditedDate |
|
||||
| 4 | 明细行行号 / 行状态 / 行级变更事件 | 明细表主键 ID、code(明细编号);无显式行号字段 | int(4) / nvarchar(30) | — | 建议补 source_line_no / source_item_id | 确认 code 是否即行号;否则用 T+ 明细 ID 作行级幂等键(待 IT) | 必须 | D? | SA_SaleOrder_b.id/code、SA_SaleDelivery_b.ID/code、ARAP_ReceivePayment_b.id/detailCode |
|
||||
| 5 | 税率 / 税额 / 价税合计 / 折扣或折让 | 明细表 taxRate、tax、taxAmount、origTaxAmount、discount、discountRate、discountAmount | decimal(13) | — | 待业务 #20 答复后决定是否建列 | 字段齐全;金额是否含税、折扣关联方式待业务 | 必须 | D? | SA_SaleOrder_b/SA_SaleDelivery_b 税额与折扣字段 |
|
||||
| 6 | 发货客户 / 收货人 / 收货地址 / 联系电话 | SA_SaleDelivery.idcustomer / address / contactPhone / idSendPerson / idConsignor | int(4) / nvarchar(100~200) | — | 建议预留(当前无列) | 用于核对发货对象与代发情形 | 必须 | D | SA_SaleDelivery.idcustomer、address、contactPhone、idSendPerson、idConsignor |
|
||||
| 7 | 发货业务员 / 发货组织 / 仓库 | SA_SaleDelivery.idclerk / iddepartment / idwarehouse | int(4) | — | 建议预留(当前无列) | 识别代发、调拨与多组织差异 | 必须 | D | SA_SaleDelivery.idclerk、iddepartment、idwarehouse |
|
||||
| 8 | 发货退回 / 红字发货 / 退回数量 / 退回金额 / 原发货单关联 | SA_SaleDelivery.isCounteractDelivery、_b.returnQuntity/returnQuntity2/backSentQuantity、sourceVoucher* | tinyint(1) / decimal(13) / nvarchar(50) | 冲抵/退货枚举待 IT | 待业务 #21 答复后决定是否建列 | 原单关联字段存在;是否冲减绩效待业务 | 必须 | D? | SA_SaleDelivery.isCounteractDelivery、SA_SaleDelivery_b.returnQuntity、backSentQuantity |
|
||||
| 9 | 收款客户 / 经手人 / 财务审核状态 | ARAP_ReceivePayment.idpartner / idHandler / auditor / voucherstate | int(4) / nvarchar(50) / int(4) | — | 建议预留(当前无列) | 核对实际付款主体与经办责任 | 必须 | D | ARAP_ReceivePayment.idpartner、idHandler、auditor、voucherstate |
|
||||
| 10 | 收款分配明细 / 分配金额 / 未分配金额 / 分配对象 | ARAP_ReceivePayment_b(源单/订单/金额/未结算)、ARAP_StrikeBalance(核销) | decimal(13) / int(4) | — | 人工指定发货批次的辅助参考 | 分配关系仅参考;系统仍人工指定(对照表 #18) | 必须 | D? | ARAP_ReceivePayment_b.saleordercode/amount/origNoSettleAmount;ARAP_StrikeBalance |
|
||||
| 11 | 预收款 / 保证金 / 代收款 / 折让 / 抵扣 | ARAP_ReceivePayment.paymentAmount/origPaymentAmount(预收/预付)、overPayment(超收转预收)、allowances(折让) | decimal(13) | — | 待业务 #22 答复后决定是否建列 | 字段存在;是否计入绩效回款待业务 | 必须 | D? | ARAP_ReceivePayment.paymentAmount、overPayment、allowances |
|
||||
| 12 | 手续费 / 交易费用 / 收款净额 | 未检出手续费/交易费用字段 | — | — | 待业务 #23 答复后决定 | 需 IT 确认手续费是否在其他单据/字段 | 必须 | N | ARAP_ReceivePayment 无 fee/charge 字段 |
|
||||
| 13 | 红字收款 / 收款作废 / 收款退回 / 冲销及原单关联 | ARAP_ReceivePayment.voucherstate / isReceiveFlag;ARAP_StrikeBalance 冲销 | int(4) / tinyint(1) | 作废/红字状态枚举待 IT | 待业务 #24 答复后决定 | 无红字收款专用字段;负向事实需事件模型 | 必须 | D? | ARAP_ReceivePayment.voucherstate、isReceiveFlag;ARAP_StrikeBalance |
|
||||
| 14 | 客户编码 / 客户状态 / 启用停用时间 | AA_Partner.code / disabled | nvarchar(32) / tinyint(1) | disabled 0 启用 / 1 停用 | customers.source_no / status(待补列) | 同六-3;客户编码唯一性待 IT | 必须 | D | AA_Partner.code、disabled |
|
||||
| 15 | 客户分类 / 渠道 / 区域 / 所属组织 | AA_Partner.idpartnerclass / customerType / iddistrict / idMarketingOrgan | int(4) | 分类/客商类型枚举待 IT | customers.customer_type(不覆盖);渠道无列 | 系统客户分类人工维护;渠道/区域作备考 | 必须 | D? | AA_Partner.idpartnerclass、customerType、iddistrict、idMarketingOrgan |
|
||||
| 16 | 客户名称变更 / 编码停用合并前后的对应关系 | AA_Partner.code(终身不变)+ updated;无变更历史表(待确认) | nvarchar(32) | — | 靠 source_no 追溯同一主体 | 改名/换负责人/合并靠 code 串同一主体;变更历史需 IT 确认 | 必须 | D? | AA_Partner.code、updated、HasEverChanged |
|
||||
| 17 | 人员部门 / 岗位 / 调岗时间 / 离职和组织变更时间 | AA_Person.iddepartment / position / updated;调岗历史表待确认 | int(4) / datetime(8) | — | 不落库(备考/排查) | 字段部分存在;调岗/离职时间需 IT 确认 | 必须 | D? | AA_Person.iddepartment、position、updated |
|
||||
| 18 | 产品编码 / 产品状态 / 启用停用时间 | AA_Inventory.code / disabled / madeDate | nvarchar(32) / tinyint(1) | disabled 0 启用 / 1 停用 | products.source_no / status | 稳定匹配;停用后历史明细仍可回溯 | 必须 | D | AA_Inventory.code、disabled、MadeRecordDate |
|
||||
| 19 | 字典和枚举取值范围 / 编码与名称对照说明 | 枚举表:AA_SettleStyle、业务类型表、状态字典(需随数据导出) | — | 枚举字典随数据提供 | 同步映射表 | 不能只给代码;需编码与名称对照 | 必须 | D? | AA_SettleStyle、AA_BusinessType 等枚举表 |
|
||||
| 20 | 对账汇总信息 / 数据记录数 / 数量合计 / 金额合计 / 抽取时间 | 由 IT 提供抽取汇总(接口/视图层) | — | — | 同步日志/对账表 | 记录明细条数、金额合计、抽取时间 | 必须 | IT | 字典不提供;需对接方式确定 |
|
||||
| 21 | 单据历史版本 / 修改留痕(如 T+ 提供) | 未检索到统一单据历史版本表(待 IT 确认) | — | — | 系统同步快照可比对 | 无此数据不阻塞 | 可选 | D? | 表索引未见 history 类表 |
|
||||
|
||||
## 10. 跨表关联与键
|
||||
|
||||
| 关系 | T+ 字段(字典) | 系统目标 | 状态 |
|
||||
| --- | --- | --- | --- |
|
||||
| 人员唯一键 | AA_Person.code | personnel.source_no(建议补 personnel_code) | D? |
|
||||
| 客户唯一键 | AA_Partner.code;名称+联系人 | customers.source_no;company_name+owner_name | D? |
|
||||
| 订单→发货 | SA_SaleDelivery.SourceVoucherCode / _b.saleOrderCode/saleOrderId | shipments.business_id | D? |
|
||||
| 订单/发货→回款 | ARAP_ReceivePayment.SaleOrderCode/SaleOrderID;_b.saleordercode | receipts.business_id | D? |
|
||||
| 回款→发货批次 | 系统人工指定(对照表 #18) | receipts.shipment_id | S |
|
||||
| 明细行幂等 | 明细表 ID/code;无显式行号 | 建议补 source_line_no | D? |
|
||||
|
||||
## 11. 枚举与状态
|
||||
|
||||
| 枚举 | T+ 来源 | 备注 |
|
||||
| --- | --- | --- |
|
||||
| 收款/结算方式 | AA_SettleStyle(code/name/disabled) | 完整枚举需随数据导出 |
|
||||
| 单据状态 | 各单据 voucherState / isCancel | 仅同步校验,不落库 |
|
||||
| 核销状态 | ARAP_ReceivePayment.cancelState | 含义待 IT |
|
||||
| 业务类型 | SA_SaleOrder.idbusinesstype → 业务类型表 | 是否等同特殊业务待确认 |
|
||||
| 产品分类 | AA_InventoryClass | 仅辅助系统分类人工维护 |
|
||||
|
||||
## 12. 系统侧目标表缺口
|
||||
|
||||
| 缺口 | 说明 | 建议 |
|
||||
| --- | --- | --- |
|
||||
| `personnel.personnel_code` | T+ 工号/编码是否即 `source_no` 未定 | 若不等则补唯一列 |
|
||||
| `customers.status` / 启停时间 | T+ 有 disabled,系统无列 | 补 status 与停用时间 |
|
||||
| `receipts.payment_method` / `confirm_status` | T+ 有结算方式与核销状态 | 补两列并接枚举 |
|
||||
| `source_created_at` / `source_updated_at` | 本地 update_time 不可作源端水位 | 补列或建 staging 表 |
|
||||
| `source_line_no` / `source_item_id` | 明细表无行号 | 补列支持行级幂等 |
|
||||
| 负向事件模型 | 无删除/作废/红冲/退货事件表 | 设计事件表或冲销单 |
|
||||
| `sync_log` / 失败明细表 | 完全缺失 | 同步模块前置 |
|
||||
| T+ 配置项 | `.env` 有 `T_PLUS_*`,`Settings` 未定义 | 补配置字段与校验 |
|
||||
|
||||
## 13. IT 待确认事项收敛(18 → 13 + 1 组业务)
|
||||
|
||||
| 新# | 原# | 事项 | 字典证据 | 字典可答 | 仍需确认 | 优先级 |
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| 1 | 1 | 成本额字段与成本口径 | SA_SaleOrder_b.LatestCost;SA_SaleDelivery_b.costPrice/costAmount/CostPriceGetType;AA_Inventory.invSCost/latestCost/avagCost | 字段存在性、类型精度、挂在订单还是出库单 | 请确认绩效成本取哪个字段(订单最新成本 / 出库成本 / 其他),取数时点与精度 | 高(阻塞核算) |
|
||||
| 2 | 2+3 | 发货日与到账日口径 | 发货:SA_SaleDelivery.voucherdate/SendDate/deliveryDate/auditeddate;到账:ARAP_ReceivePayment.voucherDate/auditedDate/createdTime | 各日期字段存在性与类型 | 请分别确认发货日、到账日取哪个字段;签收日/银行到账日能否从 T+ 获取 | 高 |
|
||||
| 3 | 4 | 幂等键与作废重开 | 各表 code + id 主键;SA_SaleOrder.ismodifiedcode、HasEverChanged | 编码字段与主键关系 | 各单据 code 是否唯一终身不变;作废重开是否换号;建议以 code 作 source_no | 高 |
|
||||
| 4 | 5 | 三单关联与核销路径 | SA_SaleDelivery.SourceVoucherCode/idsourcevouchertype;SA_SaleDelivery_b.saleOrderCode/saleOrderId;ARAP_ReceivePayment.SaleOrderCode/SaleOrderID;ARAP_ReceivePayment_b;ARAP_StrikeBalance | 关联字段与核销表存在性 | 请确认订单→发货→回款的主关联路径(按单号还是 ID),以及分批发货/部分核销如何取 | 高 |
|
||||
| 5 | 6 | 人员编码/工号与经理隶属 | AA_Person.code/name/iddepartment/isSalesman;AA_Department.idparent/iddirector | 编码/部门/负责人字段存在;无直属上级字段 | AA_Person.code 是否即工号;业务员→营销经理隶属在 T+ 如何维护(部门负责人?自定义项?) | 高 |
|
||||
| 6 | 7 | 对接方式与安全 | 字典不涉及 | 无 | REST/视图/中间表/文件;只读账号与凭据、接口文档、样例数据 | 高 |
|
||||
| 7 | 8 | 负数/红字/退货/冲销规则 | SA_SaleDelivery.isCounteractDelivery;SA_SaleDelivery_b.returnQuntity/backSentQuantity;ARAP_ReceivePayment.voucherstate;ARAP_StrikeBalance | 负向字段/单据形式存在性 | 负向单据的实际使用形式、原单关联字段、是否推送;绩效影响转业务 | 高 |
|
||||
| 8 | 9 | 存量起始与增量水位 | 各表 updated/createdTime/madeDate/auditedDate/ts | 时间戳字段存在性 | 存量起始日期;增量用哪个时间戳(updated? ts?);时区 | 中 |
|
||||
| 9 | 10+v1.4#18 | 客户主数据匹配与变更 | AA_Partner.code/name/Contact/representative/disabled/updated | 编码/名称/负责人/停用字段 | 客户唯一键;改名/换负责人/合并如何映射同一主体;负责人取 Contact 还是 representative | 中 |
|
||||
| 10 | 11+16 | 收款方式与确认/核销枚举 | ARAP_ReceivePayment.IdSettleStyle;AA_SettleStyle.code/name;ARAP_ReceivePayment.cancelState/voucherstate | 枚举表与状态字段存在性 | 收款方式完整枚举与以物抵顶对应代码;核销/确认状态枚举含义及可否作为人工确认依据 | 高 |
|
||||
| 11 | 12+13 | 产品数量语义与发货单价 | ST_CurrentStock.baseQuantity;AA_InventoryPrice/AA_InventoryUnitPrice;SA_SaleDelivery_b.price/taxPrice | 数量/价格字段存在性 | 产品「数量」取现存量/在途/累计销量;发货单价取订单价还是发货时价、含税与否 | 中 |
|
||||
| 12 | 14+17 | 低频字段存在性:特殊业务标记/市场拓展计划 | SA_SaleOrder.idbusinesstype;AA_Person 自定义项;未检出市场拓展计划表 | 字段/表存在性 | 特殊业务是否有可识别字段;市场拓展计划 T+ 是否有表可提供;无则人工维护 | 低 |
|
||||
| 13 | 15 | 同步窗口 | 字典不涉及 | 无 | 每周同步时间点,避开结账/备份;失败重试策略 | 低 |
|
||||
| B1 | 18 | 业务裁决 5 项 | 税额/折扣/退货/预收/手续费字段已定位(待答复后决定是否建列) | 字段存在性 | 含税口径、退货红字冲减、预收/保证金/代收、手续费净额、负绩效表述 | 业务裁决 |
|
||||
|
||||
### 原 18 项对照
|
||||
|
||||
| 原# | 事项 | 原优先级 | 收敛去向 |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | 成本额取数口径 | 高(阻塞核算) | 1 |
|
||||
| 2 | 发货日期口径 | 高 | 2 |
|
||||
| 3 | 到账日期口径 | 高 | 2 |
|
||||
| 4 | 幂等唯一键 | 高 | 3 |
|
||||
| 5 | 三单关联键 | 高 | 4 |
|
||||
| 6 | 人员匹配键与经理隶属(v1.2 更新) | 高 | 5 |
|
||||
| 7 | 对接方式与安全 | 高 | 6 |
|
||||
| 8 | 负数 / 红字 / 退货 / 冲销处理规则 | 高 | 7 |
|
||||
| 9 | 存量范围与增量口径 | 中 | 8 |
|
||||
| 10 | 客户档案匹配与唯一键 | 中 | 9 |
|
||||
| 11 | 收款方式枚举 | 中 | 10 |
|
||||
| 12 | 产品「数量」语义 | 中 | 11 |
|
||||
| 13 | 发货单价口径 | 中 | 11 |
|
||||
| 14 | 特殊业务标记 | 低 | 12 |
|
||||
| 15 | 同步窗口 | 低 | 13 |
|
||||
| 16 | 回款确认 / 核销状态 | 高 | 10 |
|
||||
| 17 | 市场拓展计划数据 | 低 | 12 |
|
||||
| 18 | 口径裁决 5 项(移交业务方) | 业务裁决 | B1 |
|
||||
|
||||
## 14. 待确认清单
|
||||
|
||||
**IT(13 项)**:见第 13 节新# 1–13。
|
||||
|
||||
**业务(1 组)**:含税口径、退货红字冲减、预收/保证金/代收、手续费净额、负绩效表述(原 #18 / 待澄清 #20–#24)。
|
||||
|
||||
**系统侧(不阻塞 IT)**:补 `personnel_code`、`customers.status`、`receipts.payment_method/confirm_status`、`source_updated_at`、`source_line_no`、`sync_log` 与 T+ 配置项。
|
||||
|
||||
## 15. 验证记录
|
||||
|
||||
- 解包:1,457 个 HTML、`.hhc/.hhk` 齐全、零字节 0;
|
||||
- 全量解析:1,457 页、938 个 Table 类型页、**931 张表、21,019 个字段、0 失败页**;
|
||||
- 编码:逐页检测,GB18030(无替换字符);
|
||||
- 抽样核验:六类主表结构一致(6 列字段表),关键字段逐项比对通过;
|
||||
- 输入 SHA-256:`3e8cd3146e22bdebe8bacda56b5dafbbd8be744c4279d7b2bbf3d7ecdc7e2726`;
|
||||
|
||||
## 附录 A:六类主表索引
|
||||
|
||||
| 类别 | T+ 表 | 字典页 GUID |
|
||||
| --- | --- | --- |
|
||||
| 人员 | AA_Person | `fcd00c17-6778-4c6a-90ae-3f0dbaf2c102` |
|
||||
| 产品 | AA_Inventory | `93b57dfa-4ca4-411d-a883-6d26c8e01787` |
|
||||
| 客户 | AA_Partner | `871e93dd-c2cf-4bd2-af91-cbeb5951ac5e` |
|
||||
| 业务 | SA_SaleOrder | `9a51f8a9-658a-4f7c-9372-8af2257511ee` |
|
||||
| 发货 | SA_SaleDelivery | `bef98b52-c9f0-4d80-8e62-8968cced5b52` |
|
||||
| 回款 | ARAP_ReceivePayment | `709de6e4-3b3d-4cf8-b5ed-5f657d42599a` |
|
||||
|
||||
## 附录 B:75 项覆盖矩阵
|
||||
|
||||
| 节 | 项数 | 已映射 |
|
||||
| --- | --- | --- |
|
||||
| 公共要求 | 2 | 2 |
|
||||
| 人员 | 9 | 9 |
|
||||
| 产品 | 7 | 7 |
|
||||
| 业务 | 13 | 13 |
|
||||
| 发货 | 8 | 8 |
|
||||
| 回款 | 10 | 10 |
|
||||
| 客户 | 5 | 5 |
|
||||
| 同步完整性 | 21 | 21 |
|
||||
|
||||
## 附录 C:失败页
|
||||
|
||||
无(`failures.csv` 为空)。
|
||||
|
||||
## 附录 D:复现方法
|
||||
|
||||
```text
|
||||
1. C:/Windows/hh.exe -decompile <out> <TPlus123.chm>
|
||||
2. py -3.14 -I 03_extract_fields.py --extracted <out> --out <out>
|
||||
3. py -3.14 -I 08_build_md.py --out <out> --target <md>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
*本文档由数据字典解析与人工核验生成,字段候选未经 IT 确认前不得视为同步契约。*
|
||||
Binary file not shown.
Binary file not shown.
81
docs/T+数据导入设计_staging到业务表_2026-10-28.md
Normal file
81
docs/T+数据导入设计_staging到业务表_2026-10-28.md
Normal file
@ -0,0 +1,81 @@
|
||||
# T+ 数据导入设计:staging → 工资业务表(2026-10-28)
|
||||
|
||||
> 状态:设计稿,未实现。客户确认单(`docs/待确认事项清单_2026-10-28_v2.xlsx`)13 项业务口径闭环前,不启动本文档对应代码开发。文中「预留参数」按编号挂到系统设置模块,客户答复后先更新清单、再回填参数、最后实现导入。
|
||||
|
||||
## 1. 目标与边界
|
||||
|
||||
- 将 `tplus_sync_records`(staging)中 PENDING 的原始行,按映射规则写入六类工资业务表:人员 `personnel`、客户 `customers`、产品 `products`、业务 `businesses`(含 `business_items`)、发货 `shipments`(含明细)、回款 `receipts`。
|
||||
- 同步链路已具备:批次/错误/水位/锁/重试由现有 `tplus_sync_batches`、`tplus_sync_errors`、`tplus_sync_watermarks` 与 `tplus-worker` 承担;本设计只增加「应用器」环节,不改动现有读取链路。
|
||||
- 不在范围内:工资核算、月结、审批(沿用既有模块);时间增量(确认单 #13 闭环前水位保持禁用)。
|
||||
|
||||
## 2. 总体流程
|
||||
|
||||
```text
|
||||
staging PENDING
|
||||
→ 应用器按实体分批读取(entity_type + row_hash)
|
||||
→ 字段映射 + 口径参数校验(含预留参数缺省值)
|
||||
→ 幂等检查(锚点已存在则置 SKIPPED)
|
||||
→ 写业务表(应用层关联,不建物理外键)
|
||||
→ 成功置 APPLIED;失败置 ERROR 并写 tplus_sync_errors
|
||||
```
|
||||
|
||||
- 每个实体一个应用器,导入顺序固定:人员 → 产品 → 客户 → 业务(含明细)→ 发货(含明细)→ 回款。上层实体失败时跳过下层并保留 PENDING,供重试。
|
||||
- 新增「导入运行记录」需求:每个批次的每个实体记录读取数、写入数、跳过数、失败数(实现时以新表或复用批次审计字段承载,评审时定稿)。
|
||||
|
||||
## 3. 幂等与锚点
|
||||
|
||||
- staging 层唯一键 `uk_tplus_sync_records_entity_source_hash`(entity_type + source_id + row_hash)保证重复快照不重复入库。
|
||||
- 业务表锚点:订单/发货/回款以 `source_no`(T+ 单据号)幂等;主数据以 `source_id` 幂等。锚点字段在实现时补唯一约束(如 `uk_receipts_source_no`)。
|
||||
- 每笔回款只能核算一次:已存在核算记录的回款,重复导入直接跳过,不做金额合并。
|
||||
|
||||
## 4. 金额与精度
|
||||
|
||||
- 金额字段一律 `Decimal`;比例、日斜率一律 `Fraction`;全程禁用 float。
|
||||
- 业务员计提沿用既定公式:`10‰ − 回款天数 × 4/360‰`,1–360 天线性递减,360 天 6‰,361 天及以上固定 4‰。
|
||||
- 毛利润不含运费(9.8 方案蓝色字体原算法作废);混合产品按明细行分别计算;负绩效照算不归零。
|
||||
- 多单分批回款的分摊与尾差处理属预留参数(#5),未定前不实现分摊逻辑。
|
||||
|
||||
## 5. 人工字段保护(T+ 不覆盖)
|
||||
|
||||
| 字段 | 保护方式 |
|
||||
| --- | --- |
|
||||
| 人员岗位角色ID | T+ 同步不覆盖,只填新人员缺省角色 |
|
||||
| 回款批次号 / 发货单号 | `HK…` / `SH…` 系统生成,同步行只记录 source_no 追溯 |
|
||||
| 业务归属变更 | 只影响变更后的核算,不回溯历史 |
|
||||
| 客户负责人、客户分类 | 由人工维护,同步不覆盖 |
|
||||
| 产品计提分类、指导价 | 由人工维护(#11) |
|
||||
| 回款/发货批次人工指定 | 系统提供人工指定入口,导入只建待指定数据 |
|
||||
|
||||
## 6. 事务与错误边界
|
||||
|
||||
- 应用器按「批次 × 实体 × 每 N 行」开事务;单行失败不回滚整批,错误行写 `tplus_sync_errors`(含 error_code 与说明)。
|
||||
- 批次状态机沿用现有 QUEUED/RUNNING/SUCCEEDED/PARTIAL/FAILED;出现 ERROR 行时批次终态为 PARTIAL。
|
||||
- 应用器与 worker 共用 MySQL 连接级锁,避免与读取任务并发写同一批次。
|
||||
- 重算必须授权并留痕:已核算数据不允许静默覆盖,重算入口生成带请求人、时间、原因的审计记录(复用操作日志)。
|
||||
|
||||
## 7. 预留参数清单(等待客户答复)
|
||||
|
||||
| 编号 | 预留参数 | 等待内容 |
|
||||
| --- | --- | --- |
|
||||
| #1 | 成本取值 | 订单 LatestCost 与销货 costPrice/costAmount 的取舍(含税 L1 一并确认) |
|
||||
| #2 | 发货日期口径 | 凭证/发送/交货/审核日的取舍与缺失处理 |
|
||||
| #3 | 到账日期口径 | 主表时间与明细 arrivalDate 的认定,预收与分次到账 |
|
||||
| #4 | 无订单回款 | 补录、人工指定或不纳入 |
|
||||
| #5 | 多单分摊规则 | 分摊方式与尾差对账 |
|
||||
| #6 | 可计提收款范围 | 期初/预收/退款/费用单的排除与承接 |
|
||||
| #7 | 有效性与核销条件 | 生效即可计提还是须核销 |
|
||||
| #8 | 负向事件 | 退货/红冲的冲减月份与重复冲减防护 |
|
||||
| #9 | 人员口径 | 工号确认、取数范围、经理归属(L6 经理个人户只调参数) |
|
||||
| #10 | 客户口径 | 往来单位筛选、改名合并 |
|
||||
| #11 | 产品口径 | 单位换算、价格类型、计提分类维护方 |
|
||||
| #12 | 收款方式 | 结算方式枚举与以物抵顶差异 |
|
||||
| #13 | 初始化与增量 | 起始日期、历史已结算款识别、rowversion 验证 |
|
||||
| L2–L5 | 二次分配三项 | 追认后回填分摊参数 |
|
||||
| L7 | 三级角色职权 | 页面验收时核对 |
|
||||
| B1–B5 | 环境接入与介质 | 主机地址、只读账号、版本号、部署条件、合法安装介质(写到部署配置,不进代码) |
|
||||
|
||||
## 8. 实现触发条件
|
||||
|
||||
1. 确认单 13 项 + 材料 5 项 + L1–L7 全部闭环(检查点 A)。
|
||||
2. 本设计按答复修订并评审定稿。
|
||||
3. 先实现主数据导入并通过可回滚验收(检查点 B),再进入交易数据与负向事件。
|
||||
BIN
docs/T+数据表说明文档.doc
Normal file
BIN
docs/T+数据表说明文档.doc
Normal file
Binary file not shown.
@ -265,4 +265,4 @@ def start_payment_ok(received, revenue, ratio) -> bool
|
||||
|
||||
1. 按 T1–T8 的验证节点逐任务执行(见第二章各任务);T8 后全量回归 + e2e;
|
||||
2. 验收标准 6 条逐条核对(见第四章);
|
||||
3. 与 `ai-aux/AI_CONTEXT.md` 需求裁决节交叉核对口径无冲突。
|
||||
3. 与 `.ai/AI_CONTEXT.md` 需求裁决节交叉核对口径无冲突。
|
||||
|
||||
@ -8,7 +8,7 @@
|
||||
|
||||
**技术栈:** 后端 Python 3.12 + FastAPI + SQLAlchemy 2.0(同步)+ MySQL 8.0 + Redis 7;前端 Vue 3.5 + TS 5.9 + Vite 8 + Element Plus 2.14 + Pinia 4。
|
||||
|
||||
**规格:** `ai-aux/AI_CONTEXT.md`「需求裁决」节(权威口径);`docs/问题与解决方案对照表_v2.xlsx`(28 项裁决);`docs/2026.9.8修改方案.docx`(公式与生效日期)。需规(`docs/衍水绩效管理系统功能需规.docx`)中 4 类作废条款不再引用:26 天示例、含运费公式、协助 25%、经理个人户 8‰。
|
||||
**规格:** `.ai/AI_CONTEXT.md`「需求裁决」节(权威口径);`docs/问题与解决方案对照表_v2.xlsx`(28 项裁决);`docs/2026.9.8修改方案.docx`(公式与生效日期)。需规(`docs/衍水绩效管理系统功能需规.docx`)中 4 类作废条款不再引用:26 天示例、含运费公式、协助 25%、经理个人户 8‰。
|
||||
|
||||
## 全局约束
|
||||
|
||||
@ -759,7 +759,7 @@ git commit -m "test(system): 参数体系模块用例与测试记录"
|
||||
|
||||
## 验证方式
|
||||
|
||||
1. 计划内容与 `ai-aux/AI_CONTEXT.md` 需求裁决节逐条核对(口径无冲突、无遗漏作废条款);
|
||||
1. 计划内容与 `.ai/AI_CONTEXT.md` 需求裁决节逐条核对(口径无冲突、无遗漏作废条款);
|
||||
2. 计划中的命令与项目现有命令体系一致(uv / pnpm / docker compose);
|
||||
3. 首块每个任务有独立可测交付物;测试要点符合「模块整体完成时统一编写」约定(纯函数与 core 级随实现即时测);
|
||||
4. 执行阶段验证:T1 后 `test_schema_sync.py` 通过;T2/T3 后纯单元全绿;T5 后冒烟链路完整;T8 后全量后端用例与 e2e 全绿(以 `backend/tests/README.md` 最新运行记录为准)。
|
||||
|
||||
@ -92,7 +92,7 @@
|
||||
- T1/T6:`uv run pytest tests/integration/test_schema_sync.py tests/unit/test_models.py -q` 全绿(双轨零漂移是硬关口)。
|
||||
- T5/T11:模块集成用例统一编写并执行;T11 后 `uv run pytest -q` 全量回归。
|
||||
- T12–T14:`pnpm type-check && pnpm build`。
|
||||
- T15:后端全量 pytest + 前端构建 + 两个 e2e 脚本全过;`ai-aux/AI_CONTEXT.md` 历史任务摘要由主会话收尾时更新。
|
||||
- T15:后端全量 pytest + 前端构建 + 两个 e2e 脚本全过;`.ai/AI_CONTEXT.md` 历史任务摘要由主会话收尾时更新。
|
||||
|
||||
## 风险与开放问题(默认处理,不阻塞开工)
|
||||
|
||||
|
||||
101
docs/备份恢复手册.md
101
docs/备份恢复手册.md
@ -1,101 +0,0 @@
|
||||
# 备份恢复手册(⑨ 备份恢复与运维检查)
|
||||
|
||||
> 用户强调「这个备份恢复很重要」。**备份不演练 = 没有备份**——本手册的一页纸目标:任何运维/开发拿到都能完成「备份 → 恢复到临时库核对 → 删临时库」全链路。
|
||||
|
||||
## 一、备份(每日一次,建议凌晨低峰)
|
||||
|
||||
```bash
|
||||
# 项目根目录执行
|
||||
bash scripts/backup_full.sh
|
||||
```
|
||||
|
||||
- 产物:`db/backups/payroll_YYYYMMDD_HHMMSS.sql.gz`(gzip 压缩)
|
||||
- 保留策略:自动清理 30 天前的旧备份
|
||||
- 凭据:从 `docker/.env` 读取(不入脚本、不入版本库)
|
||||
|
||||
**Windows 定时任务**(任务计划程序 / `schtasks`):
|
||||
|
||||
```cmd
|
||||
:: 每日 02:30 备份(经 Git Bash)
|
||||
schtasks /create /tn "ys-payroll-backup" /tr "\"C:\Program Files\Git\bin\bash.exe\" -lc \"bash scripts/backup_full.sh\"" /sc daily /st 02:30 /ru SYSTEM
|
||||
```
|
||||
|
||||
## 二、恢复演练(备份后顺手跑一次,验证备份真能用)
|
||||
|
||||
```bash
|
||||
bash scripts/restore_drill.sh # 恢复到临时库 + 行数核对,临时库默认保留
|
||||
bash scripts/restore_drill.sh --drop-exec # 核对通过后再加此参数删除临时库
|
||||
```
|
||||
|
||||
- **绝不直接覆盖生产库**——演练恢复到独立临时库 `payroll_drill_<时间戳>`
|
||||
- 行数对比覆盖 16 张关键表(users/roles/param_*/business*/personnel/products/customers/shipments*/receipts/approval_*/system_settings)
|
||||
- 全部 ✓ 即备份可恢复;出现 ✗ 时临时库保留供排查
|
||||
|
||||
## 二·五、增量备份(澄清 #19 答复:每日全量 + 有更新时增量补充)
|
||||
|
||||
全量之间的数据变更由 **binlog 归档**覆盖(MySQL 8 默认开启 binlog,ROW 格式、服务端保留 30 天):
|
||||
|
||||
```bash
|
||||
bash scripts/backup_incremental.sh
|
||||
```
|
||||
|
||||
- 产物:`db/backups/binlog.NNNNNN.gz`(原始 binlog 文件逐个压缩)
|
||||
- **有更新时才产出**:状态文件 `db/backups/.incremental_state` 记录上次归档到的文件,无新变更时直接退出
|
||||
- 只归档「已写完」的 binlog(正在写的最后一个留到下次收取)
|
||||
- 保留策略同全量:自动清理 30 天前的增量包
|
||||
|
||||
**配套**:`backup_full.sh` 每次全量会同时产出位点文件 `payroll_*.sql.gz.pos`(binlog 文件名 + 位点),标记「该全量覆盖到此」——恢复重放从位点之后开始。
|
||||
|
||||
**恢复顺序**(真出事时):最新全量 → 依次 `gunzip` 其后的增量包 → 用 `mysqlbinlog` 重放(镜像内无此客户端,宿主机或临时容器执行):
|
||||
|
||||
```bash
|
||||
# 解出增量包后重放(示例:从全量位点之后开始)
|
||||
gunzip -c db/backups/binlog.000003.gz > /tmp/binlog.000003
|
||||
mysqlbinlog --start-position=<全量位点> /tmp/binlog.000003 | \
|
||||
docker exec -i ys-mysql-dev sh -c 'mysql -uroot -p"$MYSQL_ROOT_PASSWORD" payroll'
|
||||
```
|
||||
|
||||
**建议定时**:增量与全量同节奏(每日 02:30 全量后接一次增量;变更频繁的日子可在中午加跑一次):
|
||||
|
||||
```cmd
|
||||
schtasks /create /tn "ys-payroll-backup-incr" /tr "\"C:\Program Files\Git\bin\bash.exe\" -lc \"bash scripts/backup_incremental.sh\"" /sc daily /st 02:50 /ru SYSTEM
|
||||
```
|
||||
|
||||
## 三、生产恢复(真出事了,七步)
|
||||
|
||||
> ⚠️ 生产恢复是**高危操作**:先把容器停了再恢复,避免写冲突。下面假设恢复到**全新空数据卷**(最干净路径)。
|
||||
|
||||
1. **停容器保卷**:`docker compose -f docker/docker-compose.dev.yml down`
|
||||
2. **删旧卷(谨慎!确认要全量重来才执行)**:`docker volume rm ys-payroll_mysql-dev-data`
|
||||
- 若只想恢复到某个时间点**且不丢其它数据**,改用「恢复到临时库 → mysqldump 单表导出 → 手工回灌」的细粒度路径,勿整体覆盖。
|
||||
3. **起空库**:`docker compose -f docker/docker-compose.dev.yml up -d`(首启自动跑 `db/init/init-dev.sql` 建表 + 种子)
|
||||
4. **选一份备份**:`ls -t db/backups/payroll_*.sql.gz | head`
|
||||
5. **导入该备份**:
|
||||
```bash
|
||||
gunzip -c db/backups/payroll_XXXXXXXX_XXXXXX.sql.gz | \
|
||||
docker exec -i ys-mysql-dev sh -c 'mysql -uroot -p"$MYSQL_ROOT_PASSWORD" payroll'
|
||||
```
|
||||
6. **核对行数**:再跑一次 `bash scripts/restore_drill.sh`(此时源库=刚恢复的库,应全 ✓)
|
||||
7. **重启后端**:`cd backend && uv run uvicorn app.main:app --reload --port 8000`,登录抽查几个页面确认数据回来
|
||||
|
||||
## 四、运维健康检查(每日一次)
|
||||
|
||||
```bash
|
||||
bash scripts/health_check.sh
|
||||
```
|
||||
|
||||
六项检查:容器健康 ×2 / 磁盘水位(80% 阈值)/ 备份新鲜度(24h 内)/ Redis 探活 / 24h 登录失败次数(暴力破解预警)。任一项 WARN/FAIL 时退出码非零,方便监控采集。
|
||||
|
||||
**Windows 定时**:
|
||||
|
||||
```cmd
|
||||
:: 每日 08:50 检查(上班前出报告)
|
||||
schtasks /create /tn "ys-payroll-health" /tr "\"C:\Program Files\Git\bin\bash.exe\" -lc \"bash scripts/health_check.sh >> db/backups/health.log 2>&1\"" /sc daily /st 08:50 /ru SYSTEM
|
||||
```
|
||||
|
||||
## 五、注意事项
|
||||
|
||||
- 备份含**全部业务数据与参数种子**,属敏感文件——`db/backups/` 已加入 `.gitignore`,**不要**把它提交到版本库
|
||||
- 恢复演练产生的 `payroll_drill_*` 库会占磁盘,核对完及时 `--drop-exec` 清理
|
||||
- 备份脚本与恢复脚本都只依赖 Docker(无需宿主机装 MySQL 客户端)
|
||||
- 增量重放的 `mysqlbinlog` 是唯一需要宿主机(或临时容器)具备的客户端——官方 mysql:8.0 镜像内不含;重放前务必确认 binlog 序号连续(中间断号说明有未归档文件,先跑一次增量归档)
|
||||
BIN
docs/待澄清问题清单_2026-10-15(1).xlsx
Normal file
BIN
docs/待澄清问题清单_2026-10-15(1).xlsx
Normal file
Binary file not shown.
BIN
docs/待确认事项清单_2026-10-28_v2.xlsx
Normal file
BIN
docs/待确认事项清单_2026-10-28_v2.xlsx
Normal file
Binary file not shown.
@ -1,95 +0,0 @@
|
||||
# 待答复事项催办清单(2026-10-19)
|
||||
|
||||
> **用途**:对外跟催材料,分三块可直接拆分发送——一、业务方;二、IT/T+ 侧;三、报表口径补充确认(本轮新增)。
|
||||
> **基线**:《待澄清问题清单_2026-10-15(1)(2).xlsx》(业务 24 项,2026-10-16 已答复 19 项,其中部分答复 3 项;#1–#3/#20 仍空白)、《T+对接数据需求清单》v1.3「待IT确认事项」18 项 + v1.4(2026-10-19 勘误)、《问题与解决方案对照表_v2》(28 项已裁决口径)。
|
||||
> **原则**:已答复项不重复询问;未答复项按清单内「建议默认」执行中,答复后以答复为准并回填文档。
|
||||
|
||||
## 一、业务方(建议回复期限:____)
|
||||
|
||||
### A. 仍未答复 —— 阻塞对应模块开工(4 项)
|
||||
|
||||
| # | 问题(原文口径) | 当前执行默认 | 阻塞 |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | 二次分配的分配标的是什么——绩效工资金额、折算毛利润额,还是销售额(业绩额)? | 方案录入已交付;**分摊已按开发侧暂定口径实现**(非业务答复,答复后校准) | ④ 分摊计算 |
|
||||
| 2 | 分配比例合计不足 100% 时,剩余部分归属谁? | 余额归合同负责营销人员(仅展示不落库) | ④ 分摊计算 |
|
||||
| 3 | 二次分配与已作废的「协助 25% 分成」是什么关系? | 独立新机制;协助仍仅记录不核算 | ④ 分摊 |
|
||||
| 20 | 含税口径:销售/成本/毛利润/运费各项是否含税?价税合计与折扣折让如何处理? | 按不含税口径实现中 | ④ 核算口径终验、⑥ 同步建列(是否建税率/税额列) |
|
||||
|
||||
> 说明:#1–#3 未答复期间,二次分配方案可正常录入与审批;开发侧已按暂定口径把分摊拆行计入绩效(参与人份额×比例、负责人余额),**标的与余额归属仍待业务答复,答复后可能调整**;#20 影响历史算出的绩效是否需要按含税口径重算,越早答复返工面越小。
|
||||
|
||||
### B. 已有答复但需补具体值 / 勾选(4 项)
|
||||
|
||||
| # | 事项 | 需补充 |
|
||||
| --- | --- | --- |
|
||||
| 12 | 经理个人户业绩是否计入其 12.5% 团队基数 | 答复为「做参数可调」,**请给出初始值**:计入 / 不计入(系统已实现为开关参数,当前默认不计入) |
|
||||
| 14 | 「第 1 天 10‰」实现口径 | 请勾选:**A 仅首日覆盖**(推荐,保「360 天 = 6‰」锚点)或 B 整体平移(破坏锚点)。系统当前按 A 实现(首日 10‰、其余按公式 10 − 天数 × 4/360) |
|
||||
| 4 | 二次分配方案修改窗口与权限 | 答复「暂定允许修改,走调整单」——请定:首次月结前可改(留痕)?月结后只读、仅走调整单?谁有权改(管理员 / 营销后勤)? |
|
||||
| 6 | 营销后勤/财务/领导三类角色的账号归属、可见菜单、可操作点 | 答复「先预设角色,需与客户确认」——请给权限清单(三类角色各能看什么、能操作什么)。系统已按最小授权运行:后勤=提交录入,财务=复核+导出,领导=审核只读,营销后勤暂不可见审批列表 |
|
||||
|
||||
### C. 请确认两处勘误(已按勘误实施,需业务回执存档)(2 项)
|
||||
|
||||
| 项 | 原表述 | 现行勘误(v1.4) |
|
||||
| --- | --- | --- |
|
||||
| 人员识别 | 「负责营销人员以姓名为唯一识别」 | 改为「以人员编码(工号)为唯一识别、姓名辅助核对」——原表述与「编码为业绩归属唯一锚点(防重名错钱)」矛盾,疑笔误,请确认 |
|
||||
| 回款↔发货批次 | 「由 T+ 收款分配关系指定,人工辅助」 | 改为「**系统人工指定**(对照表 #18 定稿),T+ 收款分配关系仅作辅助」——主次相反疑漏改,请确认 |
|
||||
|
||||
### D. 2026-10-20 新增:需业务定口径(2 项)
|
||||
|
||||
> 随「审批单内容快照 + 二次分配方案版本化」收口带出,开发侧已按现行默认实现,答复后按答复调整。
|
||||
|
||||
| # | 事项 | 当前默认(开发侧已实现) | 需确认 |
|
||||
| --- | --- | --- | --- |
|
||||
| 25 | **业务硬删除时,方案版本历史是否保留?** | 随业务一并删除(与既有「终态审批单随业务删除一并清理」规则一致)——业务被删后,方案参与人与比例的版本痕迹也不复存在 | 是否需要保留(如留归档表/只留审批单快照),还是维持一并删除 |
|
||||
| 26 | **离职的历史参与人重新加入方案,是否允许?** | 按新参与人处理:曾参与过但当前不在生效方案里的人,若已离职,重新加入会被「离职人员禁入」拦截 | 是否应放行(把「历史参与过」视为豁免),还是维持按现任职校验 |
|
||||
|
||||
### 已按默认执行、无需回复的项(供核阅)
|
||||
|
||||
\#7 月结审批链默认开启、#8 人工确认回款可手动关闭、#9 调整单同链且跨月需授权、#10 风险预警参数化、#11 待办范围(待审批+同步失败+回款未指定批次)、#13 支付环节 5 默认值、#15 一期仅 FULL 模式、#17 参数查看放开/修改限管理员、#18 企业户 2‰ 回溯、#21 退货当期冲减已结算走调整单、#22 预收/保证金/代收款暂不计入、#23 按到账金额、#24 负绩效照算不归零。
|
||||
|
||||
## 二、IT / T+ 侧(建议回复期限:____)
|
||||
|
||||
> **2026-10-20 更新**:已用 T+12.3 数据字典完成字段级核对(1,457 页 / 931 张表 / 21,019 个字段),下列问题由 18 项收敛为 **13 项 IT 待确认 + 1 组业务裁决**,每项附字典候选字段。字段级证据与来源页见《T+字段映射草案_v0.1_2026-10-20.md》,完整清单见《T+对接数据需求清单_v1.5_2026-10-20.xlsx》。
|
||||
|
||||
### A. 待确认(按优先级)
|
||||
|
||||
| 新# | 原# | 事项 | 字典候选(已定位) | 仍需答复 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 1 | 1 | 成本额字段与成本口径 | SA_SaleOrder_b.LatestCost;SA_SaleDelivery_b.costPrice/costAmount/CostPriceGetType | 取订单最新成本还是出库成本;取数时点与精度 |
|
||||
| 2 | 2+3 | 发货日与到账日口径 | 发货:voucherdate/SendDate/deliveryDate/auditeddate;到账:voucherDate/auditedDate/createdTime | 分别取哪个字段;签收日/银行到账日能否提供 |
|
||||
| 3 | 4 | 幂等键与作废重开 | 各表 code + id 主键 | code 是否终身唯一;作废重开是否换号 |
|
||||
| 4 | 5 | 三单关联与核销路径 | SA_SaleDelivery.SourceVoucherCode、_b.saleOrderCode/saleOrderId;ARAP_ReceivePayment.SaleOrderCode/SaleOrderID;ARAP_StrikeBalance | 主关联路径;分批发货/部分核销如何取 |
|
||||
| 5 | 6 | 人员编码/工号与经理隶属 | AA_Person.code/name/iddepartment;AA_Department.idparent/iddirector(无直属上级字段) | code 是否即工号;业务员→经理隶属如何维护 |
|
||||
| 6 | 7 | 对接方式与安全 | 字典不涉及 | REST/视图/中间表/文件;只读账号、接口文档、样例数据 |
|
||||
| 7 | 8 | 负数/红字/退货/冲销规则 | isCounteractDelivery、returnQuntity/backSentQuantity、voucherstate、ARAP_StrikeBalance | 实际使用形式、原单关联、是否推送 |
|
||||
| 8 | 9 | 存量起始与增量水位 | updated/createdTime/madeDate/auditedDate/ts | 存量起始日;增量用哪个时间戳;时区 |
|
||||
| 9 | 10+v1.4#18 | 客户主数据匹配与变更 | AA_Partner.code/name/Contact/representative/disabled/updated | 唯一键;改名/换负责人/合并;负责人取 Contact 还是 representative |
|
||||
| 10 | 11+16 | 收款方式与确认/核销枚举 | AA_SettleStyle;ARAP_ReceivePayment.IdSettleStyle/cancelState/voucherstate | 完整枚举与以物抵顶代码;核销状态含义与人工确认依据 |
|
||||
| 11 | 12+13 | 产品数量语义与发货单价 | ST_CurrentStock.baseQuantity;AA_InventoryPrice/AA_InventoryUnitPrice;SA_SaleDelivery_b.price/taxPrice | 数量取现存量/在途/累计销量;发货单价口径与含税 |
|
||||
| 12 | 14+17 | 低频字段存在性:特殊业务标记/市场拓展计划 | SA_SaleOrder.idbusinesstype;AA_Person 自定义项;未检出市场拓展计划表 | 有无可识别字段/表;无则人工维护 |
|
||||
| 13 | 15 | 同步窗口 | 字典不涉及 | 每周时间点,避结账/备份;失败重试策略 |
|
||||
|
||||
### B. 口径裁决(1 组,转业务方)
|
||||
|
||||
含税口径、退货红字冲减、预收/保证金/代收、手续费净额、负绩效表述(原 #18,对应《待澄清问题清单》#20–#24)。
|
||||
|
||||
### C. 已由字典补齐、仅需确认可用性
|
||||
|
||||
1. **人员编码**:字典已定位 `AA_Person.code`,请确认是否即工号;若为自动内部编码,请指明工号字段。
|
||||
2. **客户档案**:字典已定位 `AA_Partner` 主表(编码/名称/联系人/停用/地区等),请确认可提供。
|
||||
3. **映射列**:v1.5 清单已回填「T+ 表.字段 / 类型 / 枚举 / 系统目标列」,请按实际环境核对并修正。
|
||||
|
||||
## 三、报表口径补充确认(2026-10-19 新增,随本轮开发一并排)
|
||||
|
||||
> 报表统计模块(需规 2.4 / FR-029~FR-032)开工在即,以下三项请确认;未答复按「建议默认」实现,后续可调。
|
||||
|
||||
| # | 事项 | 建议默认(将按此实现) |
|
||||
| --- | --- | --- |
|
||||
| R1 | 部门绩效汇总表的「部门」口径 | 部门 = 经理团队(经理 + 其直属业务员),按人员档案的隶属关系展开;离职人员历史业绩留在原团队;经理的 5% 全公司部分单独一列 |
|
||||
| R2 | 回款统计的「回款天数区间」档位 | ≤0 天(先款后货)|1-30|31-60|61-90|91-180|181-360|361 以上|无回款天数,共 8 档;未命中区间显示 0 |
|
||||
| R3 | 导出/打印模板要素 | 公司信息页眉(名称+logo)、报表标题、筛选条件、导出时间与导出人、模板版本号、财务复核栏、领导签字栏、日期栏 |
|
||||
|
||||
另:报表查看权限(FR-029 参与者含「营销后勤(授权范围内)」)——请随第一部分 B-6 权限清单一并明确营销后勤是否需要查看报表、可见哪些列。
|
||||
|
||||
---
|
||||
|
||||
**发送建议**:第一部分发业务方(含 C 两项勘误确认);第二部分发 IT/T+(v1.4 附表一并);第三部分随开发进度与业务方顺带确认。
|
||||
241
docs/部署硬件需求.txt
241
docs/部署硬件需求.txt
@ -1,241 +0,0 @@
|
||||
衍水集团营销人员工资管理系统
|
||||
部署硬件需求说明
|
||||
================================================================
|
||||
版本:v1.0
|
||||
日期:2026-10-21
|
||||
适用:内部系统部署(数十人并发,无公网访问)
|
||||
依据:仓库实际运行架构(docker/docker-compose.dev.yml、backend/pyproject.toml、
|
||||
backend/app/core/db.py、docs/备份恢复手册.md、ai-aux/AI_CONTEXT.md)
|
||||
|
||||
|
||||
================================================================
|
||||
一、结论摘要(先看这一节)
|
||||
================================================================
|
||||
|
||||
推荐配置:2 核 CPU / 8 GB 内存 / 100 GB SSD(Linux 单机)
|
||||
最低配置:2 核 CPU / 4 GB 内存 / 100 GB SSD
|
||||
演示配置:1 核 CPU / 2 GB 内存 / 40 GB SSD(仅限试用,不适合正式使用)
|
||||
|
||||
一句话:内部系统的瓶颈不在 CPU,而在「内存余量」和「磁盘容量」。
|
||||
内存从 4 GB 升到 8 GB 的成本极低,却能一次性消除以后所有内存不足的排查;
|
||||
磁盘不要按当前数据量买,要按「备份 + 恢复演练」的双倍空间买。
|
||||
|
||||
|
||||
================================================================
|
||||
二、负载前提(为什么是这个配置)
|
||||
================================================================
|
||||
|
||||
以下事实全部来自本仓库,不是通用模板:
|
||||
|
||||
1) 内部系统、数十人并发
|
||||
出处:ai-aux/AI_CONTEXT.md:101「并发极低(内部系统数十人)」
|
||||
含义:CPU 核心数不是瓶颈,2 核足以支撑全部并发请求。
|
||||
|
||||
2) 全栈同步架构 + 单 worker,重活走线程池
|
||||
出处:CLAUDE.md(分层约定)、backend/app/core/db.py:29
|
||||
含义:一个请求占用一个线程;绩效批量核算、Excel 导出会短暂吃满一个核,
|
||||
因此要留出至少 1 个空闲核给后端,避免请求排队。
|
||||
|
||||
3) MySQL 8.0 + Redis 7 双容器,Redis 开启 AOF
|
||||
出处:docker/docker-compose.dev.yml:11、:44
|
||||
含义:MySQL 是内存消耗大户(默认配置下性能很差,必须调参);
|
||||
Redis 只存验证码、Token 黑名单、登录失败计数,用量极小(<50 MB)。
|
||||
|
||||
4) 备份策略:每日全量 gz + binlog 增量,各自保留 30 天
|
||||
出处:docs/备份恢复手册.md:11
|
||||
含义:磁盘需按「业务数据 + 索引 + 30 天 binlog + 30 份全量备份」计算。
|
||||
|
||||
5) 恢复演练需恢复到独立临时库 payroll_drill_*
|
||||
出处:docs/备份恢复手册.md:45
|
||||
含义:演练瞬间需要约「2 倍数据库体积」的可用空间。
|
||||
这是最容易被低估的一项,磁盘卡着下限买会在第一次演练时爆盘。
|
||||
|
||||
6) 报表导出用 openpyxl 全量载入内存
|
||||
出处:backend/app/services/report_export.py:18
|
||||
含义:导出大报表时后端进程内存峰值会翻倍,必须在内存预算里留余量。
|
||||
|
||||
|
||||
================================================================
|
||||
三、正式生产推荐配置(内部系统,数十人)
|
||||
================================================================
|
||||
|
||||
项目 规格 说明
|
||||
----------------------------------------------------------------
|
||||
CPU 2 核(推荐 4 核) 2 核给容器与 MySQL,
|
||||
1 核给 uvicorn(workers=2 上限),
|
||||
1 核余量给系统与备份任务。
|
||||
|
||||
内存 8 GB(最低 4 GB) 8 GB 才有余量承受
|
||||
「导出 + 核算 + 备份」同时发生。
|
||||
|
||||
磁盘 100 GB SSD(推荐 200 GB) 必须是 SSD。HDD 上 MySQL 的
|
||||
ON UPDATE CURRENT_TIMESTAMP
|
||||
高频写入会明显拖慢。
|
||||
|
||||
网络 内网千兆 无需公网 IP、域名、SSL 证书、CDN。
|
||||
|
||||
操作系统 Linux 推荐 Ubuntu 22.04 LTS / Rocky 9。
|
||||
(Ubuntu 22.04 / Rocky 9) 备份、恢复、巡检脚本全部是 .sh
|
||||
(scripts/ 目录),Linux 为原生路径。
|
||||
若必须用 Windows Server,需 2022 +
|
||||
WSL2 或 Git Bash 运行脚本。
|
||||
|
||||
|
||||
================================================================
|
||||
四、内存分配明细(8 GB 的具体切法)
|
||||
================================================================
|
||||
|
||||
组件 建议占用 关键设置与说明
|
||||
----------------------------------------------------------------
|
||||
MySQL 8.0 3.5 GB innodb_buffer_pool_size = 2G
|
||||
★ 默认仅 128 MB,不调会大量走磁盘,
|
||||
MySQL 会成为整机最慢的一环。
|
||||
|
||||
后端 uvicorn 1.5 GB workers = 2
|
||||
报表导出时单 worker 峰值可达 600 MB ~ 1 GB
|
||||
(openpyxl 全量载入内存)。
|
||||
|
||||
Redis 7 256 MB maxmemory 256mb
|
||||
实际用量 < 50 MB,设置上限只为兜底防失控。
|
||||
|
||||
Nginx + 系统 1 GB 静态资源(前端 dist)+ 反向代理 /api。
|
||||
|
||||
余量 1.75 GB mysqldump 备份进程、binlog 压缩、系统缓存。
|
||||
|
||||
----------------------------------------------------------------
|
||||
4 GB 内存版(最低配置)的分配:
|
||||
MySQL 1.5~2 GB(innodb_buffer_pool_size = 1G)
|
||||
后端 0.8~1 GB
|
||||
Redis 128 MB(maxmemory 128mb)
|
||||
Docker + Nginx + 系统 0.6 GB
|
||||
余量 ~0.6 GB
|
||||
★ 这是「刚好够用」,一旦「导出报表」与「每日备份」撞在同一时刻,
|
||||
OOM Killer 会优先杀 MySQL。这就是推荐 8 GB 的原因。
|
||||
|
||||
|
||||
================================================================
|
||||
五、磁盘空间算式(100 GB 的来历)
|
||||
================================================================
|
||||
|
||||
项目 估算占用
|
||||
----------------------------------------------------------------
|
||||
业务数据(数十人,5 年) ≈ 1 GB
|
||||
索引 + binlog 30 天(ROW 格式) ≈ 5 GB
|
||||
全量备份 30 份(gz,约数据量 20%) ≈ 6 GB
|
||||
恢复演练临时库(峰值 = 2 倍数据量) ≈ 2 GB
|
||||
系统 + Docker 镜像 + 应用日志 ≈ 25 GB
|
||||
----------------------------------------------------------------
|
||||
合计 ≈ 39 GB
|
||||
|
||||
结论:100 GB 可支撑 2~3 年不需扩容。
|
||||
按 docs/备份恢复手册.md:87 的 80% 磁盘水位告警,100 GB 有充足缓冲。
|
||||
|
||||
|
||||
================================================================
|
||||
六、内部部署必须调整的四项(现有配置存在隐患)
|
||||
================================================================
|
||||
|
||||
1) 数据库端口必须收敛,不能监听所有网卡
|
||||
现状:docker/docker-compose.dev.yml:27 为 "3306:3306",
|
||||
:48 为 "6379:6379",等价于绑定 0.0.0.0。
|
||||
风险:内网任何一台办公电脑都能直连 MySQL 试 root 密码。
|
||||
改法(二选一):
|
||||
A. 只允许宿主机访问
|
||||
ports:
|
||||
- "127.0.0.1:3306:3306"
|
||||
- "127.0.0.1:6379:6379"
|
||||
B. 后端与数据库在同一 compose 网络内时,直接删掉这两行 ports,
|
||||
容器之间通过服务名互访,宿主机只暴露 80/443。
|
||||
|
||||
2) 前端必须构建成静态文件 + Nginx 反向代理
|
||||
禁止把 vite dev 服务器当正式环境用:
|
||||
开发服务器会暴露全部源码,且不提供任何访问控制。
|
||||
正式做法:pnpm build 产出 frontend/dist,由 Nginx 托管,
|
||||
/api 反向代理到后端 8000 端口。
|
||||
|
||||
3) 备份必须异地留一份
|
||||
内部系统最常见的「数据全丢」场景就是单机故障。
|
||||
在 scripts/backup_full.sh 之后追加一步 rsync 到 NAS 或公司文件服务器。
|
||||
|
||||
4) 提前预留反向代理层
|
||||
不接公网就不需要 WAF / CDN / SSL。
|
||||
但如果公司后续有统一域名单点登录(SSO)规划,
|
||||
Nginx 反代层要提前留好,避免到时大改架构。
|
||||
|
||||
|
||||
================================================================
|
||||
七、三档速查表
|
||||
================================================================
|
||||
|
||||
场景 CPU 内存 磁盘 备注
|
||||
--------------------------------------------------------------------------------
|
||||
试用 / 演示(< 5 人) 1 核 2 GB 40 GB SSD 仅跑小数据量;
|
||||
导出大报表会 OOM
|
||||
|
||||
正式内部(数十人) 2 核 4 GB(建议 8 GB)100 GB SSD 推荐方案
|
||||
【推荐】
|
||||
|
||||
内部 + 冗余 4 核 ×2 8 GB ×2 200 GB SSD ×2 含从库与监控;
|
||||
(从库 + 监控) 工资系统若需
|
||||
审计级可靠性
|
||||
值得投入
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
|
||||
================================================================
|
||||
八、可选加速项(非必须)
|
||||
================================================================
|
||||
|
||||
1) 图表库已本地化
|
||||
ECharts 静态文件已放在 docs/design/echarts.min.js,
|
||||
前端自托管即可,不依赖外网 CDN,符合「内网加载不可靠」的既定决策。
|
||||
|
||||
2) 日志盘分离
|
||||
若开启审计日志长期留存,把 backend/logs/ 挂载到独立磁盘,
|
||||
避免日志写满系统盘导致 MySQL 无法写入。
|
||||
|
||||
3) 备份异地化
|
||||
本机备份 + 定时 rsync 到 NAS 或对象存储,构成「本地 + 异地」两份。
|
||||
|
||||
|
||||
================================================================
|
||||
九、生产环境仍需完成的事项(本文件之外)
|
||||
================================================================
|
||||
|
||||
1) 生成 docker/docker-compose.prod.yml:
|
||||
- 端口收敛到 127.0.0.1
|
||||
- MySQL innodb_buffer_pool_size 等参数调优
|
||||
- 容器 mem_limit / cpus 资源限制
|
||||
- Nginx 反向代理静态前端 + /api
|
||||
|
||||
2) 后端启动参数由开发模式改为生产模式:
|
||||
- 去掉 --reload(生产环境必须去掉)
|
||||
- 设置 workers=2
|
||||
|
||||
3) backend/.env 中 APP_SECRET_KEY 必须换成强随机值,
|
||||
MYSQL_PASSWORD / REDIS_PASSWORD 两处(docker/.env 与 backend/.env)保持一致。
|
||||
|
||||
4) 首次部署执行 alembic upgrade head,并创建管理员账号:
|
||||
uv run python -m app.cli create-admin --username admin
|
||||
|
||||
5) 配置每日定时任务:全量备份 + 增量归档(见 docs/备份恢复手册.md)
|
||||
|
||||
|
||||
================================================================
|
||||
十、附:数据来源索引
|
||||
================================================================
|
||||
|
||||
ai-aux/AI_CONTEXT.md:101 并发规模判断(数十人)
|
||||
docker/docker-compose.dev.yml:11 MySQL 8.0 容器
|
||||
docker/docker-compose.dev.yml:27 MySQL 端口映射(需收敛)
|
||||
docker/docker-compose.dev.yml:44 Redis 7 容器与 AOF
|
||||
docker/docker-compose.dev.yml:48 Redis 端口映射(需收敛)
|
||||
backend/app/core/db.py:29 同步引擎与连接池配置
|
||||
backend/app/services/report_export.py:18 报表导出(内存峰值来源)
|
||||
docs/备份恢复手册.md:11 备份策略
|
||||
docs/备份恢复手册.md:45 恢复演练临时库
|
||||
docs/备份恢复手册.md:87 磁盘水位 80% 告警阈值
|
||||
scripts/ 备份 / 恢复 / 巡检脚本(.sh)
|
||||
|
||||
================================================================
|
||||
(文件结束)
|
||||
@ -1,80 +0,0 @@
|
||||
# 衍水集团营销人员工资管理系统项目阶段报告
|
||||
|
||||
- **报告日期**:2026-10-20
|
||||
- **报告范围**:M4 ⑤ 报表统计、⑧ 工作台完成情况与 M4 收尾结论
|
||||
- **项目分支**:`feat/m3-increments`(已合并 `main`,merge commit `3611474`)
|
||||
- **阶段结论**:除 ⑥ T+ 数据同步受外部答复阻塞外,十大功能块全部交付;后端全量 **862 用例通过**,报表、工作台、核算月结三条 e2e 链路实跑通过。
|
||||
|
||||
## 一、3P 阶段摘要
|
||||
|
||||
### Progress(进展)
|
||||
|
||||
M4 两块(⑤ 报表统计、⑧ 工作台)已完成并通过 code-review 修复。报表侧交付个人绩效明细、部门汇总、业务统计、回款统计四张报表、Excel 签字导出与独立打印视图;工作台侧交付经营总览、待办事项、风险预警、最近动态四区块,待办与预警实时派生并参数化。本阶段同时完成 M4 修复批次的提交、全量回归与 e2e 实跑,开发计划 3.3 答复状态已回填。
|
||||
|
||||
### Plans(计划)
|
||||
|
||||
下一步优先解析 T+12.3 数据字典,形成六类单据字段映射草案并自答 IT 清单中可自答部分;~~其次补齐 code-review 遗留的方案版本承载与审批单快照~~(已于 2026-10-20 完成,V11);需规 V2.2 修订与对照表回填择期进行。
|
||||
|
||||
### Problems(问题)
|
||||
|
||||
⑥ T+ 数据同步仍受 IT 18 项答复阻塞,同步失败类待办/预警暂以「模块未启用」显式降级。业务方仍有 #1–#3 二次分摊、#20 含税口径、#4/#6/#12 补值与两处勘误待确认;审批角色权限细目未给出,当前按 `role_code` 粗粒度运行。需规仍含已作废条款,对外引用需注明以对照表/计划第三章为准。
|
||||
|
||||
## 二、阶段定位与完成度
|
||||
|
||||
| 维度 | 当前状态 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| 项目阶段 | 开发收尾期 | M1–M3 与 M4 ⑤⑧ 已交付,仅 ⑥ 待外部输入 |
|
||||
| 整体完成度 | 90% | 十大功能块交付;⑥ 按 0% 计入 |
|
||||
| 交付主线 | M4 报表统计 + 工作台 | 前后端闭环,已合并 main |
|
||||
| 质量状态 | 全绿 | 后端 862 passed(11:32);e2e role_home 4/4、report 12/12、calc_settlement 8/8 |
|
||||
| 主要风险 | 外部对接与口径变更 | T+ 映射未定;含税口径未答复可能触发历史绩效重算 |
|
||||
|
||||
## 三、已完成成果
|
||||
|
||||
### 1. 报表统计(M4 ⑤)
|
||||
|
||||
- 四表接口 `/api/v1/reports/*`:个人绩效明细、部门绩效汇总、业务统计、回款区间统计。
|
||||
- 部门归属按 `personnel.manager_id` 实时上溯;回款区间 8 档(≤0 / 1-30 / 31-60 / 61-90 / 91-180 / 181-360 / 361+ / 无天数)。
|
||||
- 全公司 5% 按「有聚合行经理各计一次」计算,并与月结 `total_payable` 对拍;对账块单列聚合行舍入差。
|
||||
- Excel 签字导出(openpyxl + StreamingResponse,ASCII 文件名)与独立打印视图(五块结构、每页表头、签字栏)。
|
||||
- 权限 `admin/finance/leader` 前后端双层。
|
||||
|
||||
### 2. 工作台(M4 ⑧)
|
||||
|
||||
- 单端点 `/api/v1/dashboard` 一次返回四区块:经营总览、待办事项、风险预警、最近动态。
|
||||
- 待办与预警**实时派生**(不落表、不引调度器);待办按角色过滤;逾期预警排除已终止业务。
|
||||
- 预警阈值参数化:`warning.enabled`、`warning.receipt.overdue.days=90`、`warning.approval.timeout.days=3`(V10,参数注册表 22 项)。
|
||||
- 前端 `/dashboard` 四区块 + ECharts 5.6 按需引入(SVG,显式思源黑体,规避 Windows 默认雅黑)。
|
||||
- 普通用户落地页统一由 `router/access.ts` 解析(工作台 → 业务数据 → 回款),修复无限重定向。
|
||||
|
||||
### 3. 质量与收尾
|
||||
|
||||
- 后端全量 `uv run pytest -q` → **862 passed(11:32)**;`pnpm type-check` 与 `pnpm build` 通过。
|
||||
- e2e 实跑:`role_home_flow` 4/4、`report_flow` 12/12、`calc_settlement_flow` 8/8;`report_flow` 回款统计改为按本次业务编号下钻断言,脚本可重复运行。
|
||||
- 运行后核对:月结审批开关恢复为 1,无 `e2e_role_*` 残留账号。
|
||||
- 开发计划 3.3.1 与《待确认事项汇总》已按 24 项答复分组回填(13 默认 / 4 按答复 / 3 部分 / 4 开放)。
|
||||
|
||||
## 四、当前进行中工作
|
||||
|
||||
无。M4 代码与文档已提交并合并 `main`(`3611474`)。
|
||||
|
||||
## 五、下一步计划
|
||||
|
||||
1. **T+ 同步前置**:解析 `docs/T_12.3数据字典/`,产出人员/产品/业务/发货/回款/客户六类字段映射草案,自答 IT 18 项中可从数据字典确认的部分。
|
||||
2. ~~**code-review 遗留 2 项**:方案生效版本承载、审批单内容快照(orders 加方案 JSON 快照列 + 前端详情展示)。~~ ✅ 已完成(2026-10-20,V11 迁移):方案行版本化 + 审批单版本号/内容快照 + 前端展示。
|
||||
3. **需规 V2.2**:修订 26 天示例、含运费公式、协助 25% 等过时条款,回填 2026-10-15 补充需求;对照表 v2 收编 24 项答复。
|
||||
4. **清理类**:5 处超长函数、散参数 DTO、`useDropdownOptions` 余两页、前端菜单图标。
|
||||
5. **待外部输入**:业务方催办、审批权限清单、IT 18 项答复。
|
||||
|
||||
## 六、风险与应对
|
||||
|
||||
| 风险 | 影响 | 应对 |
|
||||
| --- | --- | --- |
|
||||
| IT 答复持续缺失 | ⑥ 无法开工,同步类待办/预警无法真实展示 | 先做数据字典映射草案;催办清单已含高优 9 项与三缺口 |
|
||||
| 含税口径(#20)改变 | 历史绩效可能需要重算 | 当前按不含税实现;答复后评估重算范围并授权留痕 |
|
||||
| 二次分摊口径(#1–#3)改变 | 已生成台账/月结可能需要调整 | 暂定口径已标注;变更走口径调整,重算需授权留痕 |
|
||||
| 需规含作废条款 | 对外交付物误导 | 引用以对照表/计划第三章为准;V2.2 修订前排期 |
|
||||
|
||||
## 七、报告结论
|
||||
|
||||
M4 报表统计与工作台已完成交付并合并 `main`,系统除 ⑥ T+ 数据同步外具备完整业务闭环。质量证据充分(全量测试与三条 e2e 链路),剩余工作以外部输入与审计性技术债为主,可按计划推进。
|
||||
2
frontend/.env.example
Normal file
2
frontend/.env.example
Normal file
@ -0,0 +1,2 @@
|
||||
# Vite 开发代理目标,仅开发服务器读取;前端请求仍使用 /api 相对地址。
|
||||
DEV_API_TARGET=http://127.0.0.1:8000
|
||||
@ -6,6 +6,9 @@
|
||||
|
||||
- 登录页与图形验证码交互
|
||||
- 会话状态管理与路由守卫
|
||||
- 管理员 T+ 数据同步页:`/system/tplus`,支持读取排队、关联检查、批次/异常详情和失败重试;只读配置未就绪时禁用创建。
|
||||
- T+ 页面位于 `src/views/system/tplus/`,合同在 `src/api/tplus.ts`;创建或重试失败后保留请求键,范围改变或提交成功后才换键。任务由独立读取服务执行,页面刷新查看状态,运行命令见 T+ 本地恢复手册。
|
||||
- Vite 开发代理可通过 `DEV_API_TARGET` 指定后端,默认本地 8000;浏览器请求继续使用 `/api` 相对地址。
|
||||
- 岗位角色管理页(模板绑定)、计提参数配置页(分组目录/批次提交/版本对比/导出/计算预览)
|
||||
- 首页 Bento 仪表盘(M1 空态设计;业务上线后接入真实统计,图表用 ECharts)
|
||||
- 后续:基础数据、业务数据、绩效核算、报表等业务页面
|
||||
|
||||
84
frontend/src/api/tplus.ts
Normal file
84
frontend/src/api/tplus.ts
Normal file
@ -0,0 +1,84 @@
|
||||
import { request } from './request'
|
||||
import type { PageData } from './types'
|
||||
|
||||
export type TaskType = 'READ' | 'AUDIT'
|
||||
export type TaskStatus = 'QUEUED' | 'RUNNING' | 'SUCCEEDED' | 'PARTIAL' | 'FAILED'
|
||||
export type EntityType = 'personnel' | 'products' | 'customers' | 'businesses' | 'shipments'
|
||||
| 'receipts' | 'business_items' | 'shipment_items' | 'receipt_items'
|
||||
|
||||
/** 仅包含系统字典和汇总,不包含客户记录或源配置。 */
|
||||
export interface AuditReport {
|
||||
captured_at: string
|
||||
source_version: string
|
||||
relationship_status: string
|
||||
compatibility_status: string
|
||||
sales_links: Record<string, number> | null
|
||||
order_coverage: Record<string, number> | null
|
||||
state_enums: { id: number; code: string; name: string; enum_name: string }[]
|
||||
receipt_kinds: { voucher_type_name: string; row_count: number }[]
|
||||
receipt_line_kinds: { voucher_type_name: string | null; receive_flag: number; row_count: number }[]
|
||||
pending_confirmations: { key: string; title: string; reason: string }[]
|
||||
}
|
||||
|
||||
export interface TPlusBatch {
|
||||
batch_no: string
|
||||
task_type: TaskType
|
||||
status: TaskStatus
|
||||
entity_types: EntityType[] | null
|
||||
source_record_count: number
|
||||
staged_record_count: number
|
||||
skipped_record_count: number
|
||||
failed_record_count: number
|
||||
parent_batch_no: string | null
|
||||
create_time: string
|
||||
started_at: string | null
|
||||
finished_at: string | null
|
||||
can_retry: boolean
|
||||
error_summary: string | null
|
||||
}
|
||||
export interface TPlusDetail extends TPlusBatch { audit_result: AuditReport | null }
|
||||
export interface TPlusError {
|
||||
id: number
|
||||
entity_type: string
|
||||
source_id: string | null
|
||||
error_code: string
|
||||
error_message: string
|
||||
retry_count: number
|
||||
create_time: string
|
||||
}
|
||||
export interface TPlusOverview {
|
||||
is_configured: boolean
|
||||
configuration_message: string
|
||||
status_counts: Partial<Record<TaskStatus, number>>
|
||||
total_staged_records: number
|
||||
compatibility_status: string
|
||||
business_import_enabled: boolean
|
||||
latest_audit: AuditReport | null
|
||||
}
|
||||
export interface BatchQuery { status?: TaskStatus; task_type?: TaskType; pageNum?: number; pageSize?: number }
|
||||
export interface ErrorQuery { entity_type?: EntityType; pageNum?: number; pageSize?: number }
|
||||
export interface CreateTask { task_type: TaskType; entity_types: EntityType[]; request_key: string }
|
||||
|
||||
const base = '/v1/system/tplus'
|
||||
/** 查询配置和任务状态;不会触发源库连接。 */
|
||||
export function getOverview(): Promise<TPlusOverview> { return request({ url: `${base}/overview` }) }
|
||||
/** 分页查询任务。 */
|
||||
export function listBatches(params: BatchQuery): Promise<PageData<TPlusBatch>> {
|
||||
return request({ url: `${base}/batches`, params })
|
||||
}
|
||||
/** 按需查询报告。 */
|
||||
export function getBatch(number: string): Promise<TPlusDetail> {
|
||||
return request({ url: `${base}/batches/${encodeURIComponent(number)}` })
|
||||
}
|
||||
/** 分页查询固定错误说明。 */
|
||||
export function listErrors(number: string, params: ErrorQuery): Promise<PageData<TPlusError>> {
|
||||
return request({ url: `${base}/batches/${encodeURIComponent(number)}/errors`, params })
|
||||
}
|
||||
/** 以调用方保留的请求键持久化排队。 */
|
||||
export function createTask(data: CreateTask): Promise<TPlusDetail> {
|
||||
return request({ url: `${base}/batches`, method: 'post', data })
|
||||
}
|
||||
/** 重试会创建新批次,原异常记录保留。 */
|
||||
export function retryTask(number: string, request_key: string): Promise<TPlusDetail> {
|
||||
return request({ url: `${base}/batches/${encodeURIComponent(number)}/retry`, method: 'post', data: { request_key } })
|
||||
}
|
||||
@ -15,6 +15,7 @@ import {
|
||||
PieChart,
|
||||
Postcard,
|
||||
SetUp,
|
||||
Refresh,
|
||||
Shop,
|
||||
Stamp,
|
||||
Tickets,
|
||||
@ -47,6 +48,7 @@ const ICONS = {
|
||||
settings: OfficeBuilding,
|
||||
approvals: Stamp,
|
||||
logs: Tickets,
|
||||
tplus: Refresh,
|
||||
personnel: Postcard,
|
||||
products: Box,
|
||||
customers: Shop,
|
||||
|
||||
@ -63,6 +63,12 @@ const routes: RouteRecordRaw[] = [
|
||||
component: () => import('@/views/system/users/index.vue'),
|
||||
meta: { title: '用户管理', icon: 'users', group: '系统管理', requiresAdmin: true, order: 2 },
|
||||
},
|
||||
{
|
||||
path: '/system/tplus',
|
||||
name: 'system-tplus',
|
||||
component: () => import('@/views/system/tplus/index.vue'),
|
||||
meta: { title: 'T+ 数据同步', icon: 'tplus', group: '系统管理', requiresAdmin: true, order: 10 },
|
||||
},
|
||||
{
|
||||
path: '/system/roles',
|
||||
name: 'system-roles',
|
||||
|
||||
@ -19,8 +19,9 @@ export const MODULE_OPTIONS = [
|
||||
'发货管理',
|
||||
'回款管理',
|
||||
'客户评级',
|
||||
'T+数据同步',
|
||||
] as const
|
||||
export const ACTION_OPTIONS = ['新增', '修改', '删除', '重置密码', '启用', '禁用'] as const
|
||||
export const ACTION_OPTIONS = ['新增', '修改', '删除', '重置密码', '启用', '禁用', '创建读取任务', '重试读取任务'] as const
|
||||
|
||||
/** 启用/禁用筛选选项(文案与 StatusTag 的展示一致) */
|
||||
export const STATUS_OPTIONS = [
|
||||
|
||||
42
frontend/src/views/system/tplus/AuditResult.vue
Normal file
42
frontend/src/views/system/tplus/AuditResult.vue
Normal file
@ -0,0 +1,42 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { AuditReport } from '@/api/tplus'
|
||||
import { formatDateTime } from '@/utils/format'
|
||||
|
||||
const props = defineProps<{ report: AuditReport }>()
|
||||
const linkLabels: Record<string, string> = {
|
||||
row_count: '销货类收款明细', delivery_matches: '销货单匹配', detail_matches: '销货明细匹配',
|
||||
direct_order_matches: '收款明细直接关联订单', detail_order_matches: '经销货明细关联订单',
|
||||
unambiguous_order_matches: '订单关联无冲突', invalid_order_ids: '悬空直接订单 ID', conflicting_order_ids: '两路径订单冲突',
|
||||
}
|
||||
const rows = computed(() => Object.entries(props.report.sales_links ?? {}).map(([key, value]) => ({
|
||||
label: linkLabels[key] ?? key, value,
|
||||
})))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="audit-result" aria-label="关联核验结果">
|
||||
<p class="audit-caption">核验于 {{ formatDateTime(report.captured_at) }} · 源引擎 {{ report.source_version }}</p>
|
||||
<el-alert type="warning" :closable="false" title="SQL Server 2008 R2 同版本验收待完成;关联存在不代表业务口径已确认。" />
|
||||
<p v-if="report.relationship_status !== 'OBSERVED'" role="alert">单据类型未能唯一确认,关联统计未执行。</p>
|
||||
<el-table v-if="rows.length" :data="rows" size="small" class="audit-table">
|
||||
<el-table-column prop="label" label="核验项目" min-width="190" />
|
||||
<el-table-column label="明细行数" width="120" align="right"><template #default="{ row }"><span class="cell-num">{{ row.value.toLocaleString() }}</span></template></el-table-column>
|
||||
</el-table>
|
||||
<p v-if="report.order_coverage" class="coverage-note">按关联销货单统计:无订单 {{ report.order_coverage.without_order?.toLocaleString() }} 行,单订单 {{ report.order_coverage.single_order?.toLocaleString() }} 行,多订单 {{ report.order_coverage.multiple_orders?.toLocaleString() }} 行。统计单位为收款明细行,同一销货单可能出现多次。</p>
|
||||
<h3>收付款与系统状态</h3>
|
||||
<p>{{ report.receipt_kinds?.map((item) => `${item.voucher_type_name} ${item.row_count.toLocaleString()} 条`).join(';') || '暂无统计' }}</p>
|
||||
<p>{{ report.state_enums?.map((item) => `${item.id}:${item.name}`).join(';') }}</p>
|
||||
<h3>待确认口径</h3>
|
||||
<dl><template v-for="item in report.pending_confirmations" :key="item.key"><dt>{{ item.title }}</dt><dd>{{ item.reason }}</dd></template></dl>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.audit-result { line-height: 1.7; }
|
||||
.audit-caption, .coverage-note, dd { color: var(--ys-ink-2); }
|
||||
.audit-table { margin-top: 16px; }
|
||||
h3 { font-size: 14px; margin: 20px 0 8px; }
|
||||
dt { font-weight: 600; }
|
||||
dd { margin: 0 0 12px; }
|
||||
</style>
|
||||
78
frontend/src/views/system/tplus/BatchDetailDialog.vue
Normal file
78
frontend/src/views/system/tplus/BatchDetailDialog.vue
Normal file
@ -0,0 +1,78 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import * as api from '@/api/tplus'
|
||||
import AppPagination from '@/components/table/AppPagination.vue'
|
||||
import { usePagedList } from '@/composables/usePagedList'
|
||||
import { handledError } from '@/utils/error'
|
||||
import { formatDateTime } from '@/utils/format'
|
||||
import AuditResult from './AuditResult.vue'
|
||||
import { entities, scopeLabel, statuses } from './labels'
|
||||
|
||||
const number = defineModel<string | null>({ required: true })
|
||||
const detail = ref<api.TPlusDetail | null>(null)
|
||||
const loading = ref(false)
|
||||
const failed = ref(false)
|
||||
let sequence = 0
|
||||
const { records, total, pageNum, pageSize, loading: errorsLoading, search, onPageChange, onSizeChange } =
|
||||
usePagedList<api.TPlusError, api.ErrorQuery>((query) => api.listErrors(number.value!, query), () => ({}))
|
||||
|
||||
async function load(): Promise<void> {
|
||||
const current = ++sequence
|
||||
const key = number.value
|
||||
if (!key) return
|
||||
detail.value = null
|
||||
loading.value = true
|
||||
failed.value = false
|
||||
try {
|
||||
const result = await api.getBatch(key)
|
||||
if (current !== sequence) return
|
||||
detail.value = result
|
||||
if (result.failed_record_count) await search()
|
||||
} catch (error) {
|
||||
handledError(error)
|
||||
if (current === sequence) failed.value = true
|
||||
} finally { if (current === sequence) loading.value = false }
|
||||
}
|
||||
watch(number, (value) => { if (value) void load(); else sequence++ })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-dialog :model-value="!!number" title="T+ 任务详情" width="min(880px, 96vw)" @update:model-value="number = null">
|
||||
<div v-loading="loading" class="detail-body">
|
||||
<el-alert v-if="failed" type="error" title="详情加载失败,请重试" :closable="false"><el-button link @click="load">重新加载</el-button></el-alert>
|
||||
<template v-if="detail">
|
||||
<p class="cell-code batch-number">{{ detail.batch_no }}</p>
|
||||
<el-descriptions :column="1" border>
|
||||
<el-descriptions-item label="状态">{{ statuses[detail.status] }}</el-descriptions-item>
|
||||
<el-descriptions-item label="范围">{{ scopeLabel(detail) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="创建时间">{{ formatDateTime(detail.create_time) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="执行时间">{{ formatDateTime(detail.started_at) }} → {{ formatDateTime(detail.finished_at) }}</el-descriptions-item>
|
||||
<el-descriptions-item v-if="detail.parent_batch_no" label="原批次">{{ detail.parent_batch_no }}</el-descriptions-item>
|
||||
<el-descriptions-item label="数量">源记录 {{ detail.source_record_count.toLocaleString() }},新增 {{ detail.staged_record_count.toLocaleString() }},重复跳过 {{ detail.skipped_record_count.toLocaleString() }},异常 {{ detail.failed_record_count.toLocaleString() }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<p v-if="detail.status === 'QUEUED'" role="status">任务等待读取服务处理,可稍后刷新查看。</p>
|
||||
<p v-if="detail.error_summary" role="alert">{{ detail.error_summary }}</p>
|
||||
<AuditResult v-if="detail.audit_result" :report="detail.audit_result" />
|
||||
<section v-if="detail.failed_record_count" class="errors-section" aria-label="异常明细">
|
||||
<h3>异常明细</h3>
|
||||
<el-table v-loading="errorsLoading" :data="records" empty-text="暂无异常记录">
|
||||
<el-table-column label="范围" width="100"><template #default="{ row }">{{ entities.find((item) => item.value === row.entity_type)?.label ?? '任务' }}</template></el-table-column>
|
||||
<el-table-column prop="source_id" label="源 ID" width="90" />
|
||||
<el-table-column prop="error_message" label="异常说明" min-width="240" />
|
||||
<el-table-column prop="retry_count" label="已重试" width="80" />
|
||||
</el-table>
|
||||
<div class="pager-scroll"><AppPagination :total="total" :page-num="pageNum" :page-size="pageSize" @page-change="onPageChange" @size-change="onSizeChange" /></div>
|
||||
</section>
|
||||
</template>
|
||||
</div>
|
||||
<template #footer><el-button @click="number = null">关闭</el-button><el-button :loading="loading" @click="load">刷新详情</el-button></template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.detail-body { min-height: 120px; }
|
||||
.batch-number { overflow-wrap: anywhere; }
|
||||
.errors-section { margin-top: 24px; }
|
||||
h3 { font-size: 14px; }
|
||||
.pager-scroll { overflow-x: auto; }
|
||||
</style>
|
||||
59
frontend/src/views/system/tplus/CreateTaskDialog.vue
Normal file
59
frontend/src/views/system/tplus/CreateTaskDialog.vue
Normal file
@ -0,0 +1,59 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import * as api from '@/api/tplus'
|
||||
import { handledError } from '@/utils/error'
|
||||
import { entities } from './labels'
|
||||
|
||||
const visible = defineModel<boolean>({ required: true })
|
||||
const props = defineProps<{ configured: boolean }>()
|
||||
const emit = defineEmits<{ created: [batch: api.TPlusDetail] }>()
|
||||
const taskType = ref<api.TaskType>('READ')
|
||||
const selected = ref<api.EntityType[]>(['personnel'])
|
||||
const pending = ref(false)
|
||||
let requestKey = crypto.randomUUID()
|
||||
// 请求失败后保留键,只有意图改变或成功后才换键。
|
||||
watch([taskType, selected], () => { requestKey = crypto.randomUUID() }, { deep: true, flush: 'sync' })
|
||||
|
||||
async function submit(): Promise<void> {
|
||||
if (pending.value || !props.configured) return
|
||||
pending.value = true
|
||||
try {
|
||||
const batch = await api.createTask({ task_type: taskType.value,
|
||||
entity_types: taskType.value === 'AUDIT' ? [] : [...selected.value], request_key: requestKey })
|
||||
requestKey = crypto.randomUUID()
|
||||
visible.value = false
|
||||
ElMessage.success('任务已排队,处理完成后可刷新查看')
|
||||
emit('created', batch)
|
||||
} catch (error) { handledError(error) }
|
||||
finally { pending.value = false }
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-dialog v-model="visible" title="新建 T+ 数据任务" width="min(560px, 94vw)" :close-on-click-modal="!pending" :show-close="!pending">
|
||||
<el-form label-position="top" :disabled="pending">
|
||||
<el-form-item label="任务内容">
|
||||
<el-radio-group v-model="taskType">
|
||||
<el-radio-button value="READ">读取数据</el-radio-button>
|
||||
<el-radio-button value="AUDIT">关联检查</el-radio-button>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="taskType === 'READ'" label="读取范围">
|
||||
<el-checkbox-group v-model="selected" class="entity-options">
|
||||
<el-checkbox v-for="item in entities" :key="item.value" :value="item.value">{{ item.label }}</el-checkbox>
|
||||
</el-checkbox-group>
|
||||
</el-form-item>
|
||||
<p class="task-note">{{ taskType === 'READ' ? '全量读取所选范围,重复快照自动跳过。只保存原始数据,不写入工资业务表。' : '核对收款方向、单据类型及订单关联,只返回字典和统计数量。' }}</p>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button :disabled="pending" @click="visible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="pending" :disabled="!configured || (taskType === 'READ' && !selected.length)" @click="submit">加入处理队列</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.entity-options { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); width: 100%; }
|
||||
.task-note { color: var(--ys-ink-2); line-height: 1.7; }
|
||||
</style>
|
||||
114
frontend/src/views/system/tplus/index.vue
Normal file
114
frontend/src/views/system/tplus/index.vue
Normal file
@ -0,0 +1,114 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import * as api from '@/api/tplus'
|
||||
import AppPagination from '@/components/table/AppPagination.vue'
|
||||
import { usePagedList } from '@/composables/usePagedList'
|
||||
import { handledError } from '@/utils/error'
|
||||
import { formatDateTime } from '@/utils/format'
|
||||
import AuditResult from './AuditResult.vue'
|
||||
import BatchDetailDialog from './BatchDetailDialog.vue'
|
||||
import CreateTaskDialog from './CreateTaskDialog.vue'
|
||||
import { scopeLabel, statuses } from './labels'
|
||||
|
||||
const overview = ref<api.TPlusOverview | null>(null)
|
||||
const overviewFailed = ref(false)
|
||||
const refreshing = ref(false)
|
||||
const creating = ref(false)
|
||||
const detailNumber = ref<string | null>(null)
|
||||
const status = ref<api.TaskStatus>()
|
||||
const taskType = ref<api.TaskType>()
|
||||
const retrying = ref<string | null>(null)
|
||||
const retryKeys = new Map<string, string>()
|
||||
const { records, total, pageNum, pageSize, loading, load, search, onPageChange, onSizeChange } =
|
||||
usePagedList<api.TPlusBatch, api.BatchQuery>(api.listBatches, () => ({ status: status.value, task_type: taskType.value }))
|
||||
|
||||
async function refresh(): Promise<void> {
|
||||
if (refreshing.value) return
|
||||
refreshing.value = true
|
||||
overviewFailed.value = false
|
||||
try { overview.value = await api.getOverview() }
|
||||
catch (error) { overviewFailed.value = true; handledError(error) }
|
||||
finally { refreshing.value = false }
|
||||
await load()
|
||||
}
|
||||
async function retry(row: api.TPlusBatch): Promise<void> {
|
||||
if (retrying.value) return
|
||||
retrying.value = row.batch_no
|
||||
const key = retryKeys.get(row.batch_no) ?? crypto.randomUUID()
|
||||
retryKeys.set(row.batch_no, key)
|
||||
try {
|
||||
const batch = await api.retryTask(row.batch_no, key)
|
||||
retryKeys.delete(row.batch_no)
|
||||
ElMessage.success('重试任务已排队,原批次和异常记录已保留')
|
||||
detailNumber.value = batch.batch_no
|
||||
await refresh()
|
||||
} catch (error) { handledError(error) }
|
||||
finally { retrying.value = null }
|
||||
}
|
||||
function created(batch: api.TPlusDetail): void {
|
||||
detailNumber.value = batch.batch_no
|
||||
void refresh()
|
||||
}
|
||||
function statusType(value: api.TaskStatus): 'success' | 'warning' | 'danger' | 'info' {
|
||||
return value === 'SUCCEEDED' ? 'success' : value === 'FAILED' ? 'danger' : value === 'PARTIAL' ? 'warning' : 'info'
|
||||
}
|
||||
onMounted(refresh)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="tplus-page">
|
||||
<header class="page-head">
|
||||
<div><h1 class="page-title">T+ 数据同步</h1><p class="page-desc">读取源数据、检查关联,并追踪每次处理结果。</p><div class="waterline" /></div>
|
||||
<div class="head-actions"><el-button :loading="refreshing" @click="refresh">刷新</el-button><el-button type="primary" :disabled="!overview?.is_configured || overviewFailed" @click="creating = true">新建任务</el-button></div>
|
||||
</header>
|
||||
<el-alert type="warning" title="当前仅保存原始数据,未写入工资业务表。SQL Server 2008 R2 同版本验收及业务口径确认仍待完成。" :closable="false" show-icon />
|
||||
<el-alert v-if="overviewFailed" class="config-note" type="error" title="概况加载失败,请点击刷新重试" :closable="false" />
|
||||
<p v-else class="config-note" role="status">{{ overview?.configuration_message ?? '正在加载配置状态…' }}</p>
|
||||
<div v-if="overview" class="task-counts" aria-label="任务数量概况"><span v-for="(label, key) in statuses" :key="key">{{ label }} <b class="cell-num">{{ (overview.status_counts[key] ?? 0).toLocaleString() }}</b></span><span>累计新增快照 <b>{{ overview.total_staged_records.toLocaleString() }}</b></span></div>
|
||||
<section class="batch-card" aria-label="同步批次">
|
||||
<div class="filter-bar">
|
||||
<el-select v-model="status" clearable placeholder="全部状态" aria-label="批次状态" @change="search"><el-option v-for="(label, key) in statuses" :key="key" :label="label" :value="key" /></el-select>
|
||||
<el-select v-model="taskType" clearable placeholder="全部任务" aria-label="任务类型" @change="search"><el-option label="读取数据" value="READ" /><el-option label="关联检查" value="AUDIT" /></el-select>
|
||||
</div>
|
||||
<div class="card card-body"><el-table v-loading="loading" :data="records" empty-text="暂无任务,可新建读取或关联检查" class="data-table">
|
||||
<el-table-column label="批次 / 创建时间" min-width="235"><template #default="{ row }"><el-button link type="primary" class="cell-code" @click="detailNumber = row.batch_no">{{ row.batch_no }}</el-button><p class="batch-time">{{ formatDateTime(row.create_time) }}</p></template></el-table-column>
|
||||
<el-table-column label="任务 / 范围" min-width="180"><template #default="{ row }"><b>{{ row.task_type === 'AUDIT' ? '关联检查' : '读取数据' }}</b><p class="batch-scope">{{ scopeLabel(row) }}</p></template></el-table-column>
|
||||
<el-table-column label="状态" width="110"><template #default="{ row }"><el-tag :type="statusType(row.status)">{{ statuses[row.status as api.TaskStatus] }}</el-tag></template></el-table-column>
|
||||
<el-table-column prop="source_record_count" label="源记录" width="100" align="right" />
|
||||
<el-table-column prop="staged_record_count" label="新增" width="90" align="right" />
|
||||
<el-table-column prop="skipped_record_count" label="重复跳过" width="100" align="right" />
|
||||
<el-table-column prop="failed_record_count" label="异常" width="80" align="right" />
|
||||
<el-table-column label="操作" width="140" fixed="right"><template #default="{ row }"><el-button link type="primary" @click="detailNumber = row.batch_no">查看</el-button><el-button v-if="row.can_retry" link type="primary" :disabled="!overview?.is_configured || overviewFailed || !!retrying" :loading="retrying === row.batch_no" @click="retry(row)">重试</el-button></template></el-table-column>
|
||||
</el-table>
|
||||
<div class="pager-scroll"><AppPagination :total="total" :page-num="pageNum" :page-size="pageSize" @page-change="onPageChange" @size-change="onSizeChange" /></div></div>
|
||||
</section>
|
||||
<section class="card card-body latest-audit" aria-label="最近关联检查">
|
||||
<h2>最近关联检查</h2>
|
||||
<AuditResult v-if="overview?.latest_audit" :report="overview.latest_audit" />
|
||||
<p v-else class="page-desc">暂无关联检查结果。新建“关联检查”任务后,可在此查看覆盖数量与待确认口径。</p>
|
||||
</section>
|
||||
<CreateTaskDialog v-model="creating" :configured="!!overview?.is_configured && !overviewFailed" @created="created" />
|
||||
<BatchDetailDialog v-model="detailNumber" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page-head { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
|
||||
.head-actions { display: flex; gap: 8px; }
|
||||
.head-actions .el-button + .el-button { margin-left: 0; }
|
||||
.config-note { margin: 12px 0; color: var(--ys-ink-2); }
|
||||
.task-counts { display: flex; flex-wrap: wrap; gap: 12px 24px; margin: 16px 0; color: var(--ys-ink-2); }
|
||||
.task-counts b { color: var(--ys-ink); margin-left: 4px; }
|
||||
.batch-card { margin-top: 16px; }
|
||||
.filter-bar .el-select { width: 160px; }
|
||||
.batch-time, .batch-scope { margin: 4px 0 0; color: var(--ys-ink-2); font-size: 12px; }
|
||||
.latest-audit { margin-top: 20px; }
|
||||
.latest-audit h2 { margin: 0 0 12px; font-size: 16px; }
|
||||
.pager-scroll { overflow-x: auto; }
|
||||
@media (max-width: 640px) {
|
||||
.page-head { align-items: flex-start; gap: 16px; flex-direction: column; }
|
||||
.filter-bar { flex-wrap: wrap; }
|
||||
.filter-bar .el-select { width: 100%; }
|
||||
}
|
||||
</style>
|
||||
17
frontend/src/views/system/tplus/labels.ts
Normal file
17
frontend/src/views/system/tplus/labels.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import type { EntityType, TaskStatus, TPlusBatch } from '@/api/tplus'
|
||||
|
||||
export const entities: { value: EntityType; label: string }[] = [
|
||||
{ value: 'personnel', label: '人员' }, { value: 'products', label: '产品' },
|
||||
{ value: 'customers', label: '客户' }, { value: 'businesses', label: '订单' },
|
||||
{ value: 'business_items', label: '订单明细' }, { value: 'shipments', label: '销货单' },
|
||||
{ value: 'shipment_items', label: '销货明细' }, { value: 'receipts', label: '收付款单' },
|
||||
{ value: 'receipt_items', label: '收付款明细' },
|
||||
]
|
||||
export const statuses: Record<TaskStatus, string> = {
|
||||
QUEUED: '等待处理', RUNNING: '处理中', SUCCEEDED: '已完成', PARTIAL: '部分失败', FAILED: '失败',
|
||||
}
|
||||
/** 历史范围未记录时保持未知,不默认成全部。 */
|
||||
export function scopeLabel(row: TPlusBatch): string {
|
||||
if (row.task_type === 'AUDIT') return '关联与枚举检查'
|
||||
return row.entity_types?.map((key) => entities.find((item) => item.value === key)?.label ?? key).join('、') || '历史批次,范围未记录'
|
||||
}
|
||||
@ -28,7 +28,7 @@ export default defineConfig(({ mode }) => {
|
||||
// 开发期走代理而非后端 CORS,符合规范「跨域在网关统一处理」;
|
||||
// 副作用是 /api/docs(Swagger)也能从 5173 端口打开,联调期只用一个域名。
|
||||
'/api': {
|
||||
target: 'http://127.0.0.1:8000',
|
||||
target: env.DEV_API_TARGET || 'http://127.0.0.1:8000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
|
||||
106
scripts/tplus/check_compatibility.py
Normal file
106
scripts/tplus/check_compatibility.py
Normal file
@ -0,0 +1,106 @@
|
||||
"""只读核验真实 SQL Server 2008 R2;2022 PoC 不能获得兼容通过结果。"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
|
||||
import pyodbc
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "backend"))
|
||||
|
||||
from sqlalchemy import URL, create_engine
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.services.tplus.mapping import DETAIL_MAPPINGS, TPLUS_MAPPINGS
|
||||
from app.services.tplus.reader import TPlusReader
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""检查版本、中文、金额、日期和写权限,结果不包含凭据或客户记录。"""
|
||||
parser = argparse.ArgumentParser(description="SQL Server 2008 R2 兼容性验收")
|
||||
parser.add_argument("--connection-file", required=True, type=Path)
|
||||
parser.add_argument("--report", required=True, type=Path)
|
||||
args = parser.parse_args()
|
||||
config = json.loads(args.connection_file.read_text(encoding="utf-8-sig"))
|
||||
connection_string = (
|
||||
"DRIVER={"
|
||||
+ config["driver"]
|
||||
+ "};SERVER="
|
||||
+ config["host"]
|
||||
+ ","
|
||||
+ str(config["port"])
|
||||
+ ";DATABASE="
|
||||
+ config["database"]
|
||||
+ ";UID="
|
||||
+ config["user"]
|
||||
+ ";PWD={"
|
||||
+ config["password"].replace("}", "}}")
|
||||
+ "};"
|
||||
)
|
||||
with pyodbc.connect(connection_string, timeout=10) as connection:
|
||||
connection.timeout = 15
|
||||
cursor = connection.cursor()
|
||||
version = str(
|
||||
cursor.execute(
|
||||
"SELECT CAST(SERVERPROPERTY('ProductVersion') AS varchar(80))"
|
||||
).fetchone()[0]
|
||||
)
|
||||
values = cursor.execute(
|
||||
"SELECT N'中文核验', CAST('12345.6789' AS decimal(19,4)), "
|
||||
"CAST('2008-01-02T03:04:05' AS datetime)"
|
||||
).fetchone()
|
||||
permissions = cursor.execute(
|
||||
"SELECT HAS_PERMS_BY_NAME(DB_NAME(),'DATABASE','INSERT'), "
|
||||
"HAS_PERMS_BY_NAME(DB_NAME(),'DATABASE','UPDATE'), "
|
||||
"HAS_PERMS_BY_NAME(DB_NAME(),'DATABASE','DELETE')"
|
||||
).fetchone()
|
||||
checks = {
|
||||
"sqlserver_2008r2": version.startswith("10.50."),
|
||||
"unicode_roundtrip": values[0] == "中文核验",
|
||||
"decimal_exact": values[1] == Decimal("12345.6789"),
|
||||
"datetime_exact": values[2] == datetime(2008, 1, 2, 3, 4, 5),
|
||||
"no_dml_permissions": all(x == 0 for x in permissions),
|
||||
}
|
||||
report = {
|
||||
"server_version": version,
|
||||
"driver_name": connection.getinfo(pyodbc.SQL_DRIVER_NAME),
|
||||
"driver_version": connection.getinfo(pyodbc.SQL_DRIVER_VER),
|
||||
"checks": checks,
|
||||
"compatible": all(checks.values()),
|
||||
}
|
||||
url = URL.create(
|
||||
"mssql+pyodbc",
|
||||
username=config["user"],
|
||||
password=config["password"],
|
||||
host=config["host"],
|
||||
port=config["port"],
|
||||
database=config["database"],
|
||||
query={"driver": config["driver"]},
|
||||
)
|
||||
engine = create_engine(url, hide_parameters=True)
|
||||
adapter_checks = {}
|
||||
try:
|
||||
with Session(engine) as session:
|
||||
reader = TPlusReader(session)
|
||||
for mapping in (*TPLUS_MAPPINGS, *DETAIL_MAPPINGS):
|
||||
try:
|
||||
reader.fetch_entity(mapping.entity_type, max_rows=1)
|
||||
adapter_checks[mapping.entity_type] = True
|
||||
except Exception as exc:
|
||||
adapter_checks[mapping.entity_type] = False
|
||||
report.setdefault("errors", {})[mapping.entity_type] = type(exc).__name__
|
||||
finally:
|
||||
engine.dispose()
|
||||
report["adapter_queries"] = adapter_checks
|
||||
report["compatible"] = report["compatible"] and all(adapter_checks.values())
|
||||
args.report.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.report.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
print(json.dumps(report, ensure_ascii=False))
|
||||
return 0 if report["compatible"] else 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
20
scripts/tplus/container_sql.ps1
Normal file
20
scripts/tplus/container_sql.ps1
Normal file
@ -0,0 +1,20 @@
|
||||
# 共享函数:密码只通过环境传入进程,SQL 通过标准输入传入,避免出现在命令行。
|
||||
function Invoke-TPlusContainerSql {
|
||||
param(
|
||||
[string]$ContainerName,
|
||||
[string]$Sql,
|
||||
[SecureString]$SaPassword
|
||||
)
|
||||
$ptr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($SaPassword)
|
||||
$previousPassword = [Environment]::GetEnvironmentVariable("SQLCMDPASSWORD", "Process")
|
||||
try {
|
||||
$env:SQLCMDPASSWORD = [Runtime.InteropServices.Marshal]::PtrToStringBSTR($ptr)
|
||||
$OutputEncoding = [Text.UTF8Encoding]::new($false)
|
||||
$Sql | docker exec -i --env SQLCMDPASSWORD $ContainerName /opt/mssql-tools18/bin/sqlcmd `
|
||||
-S localhost -U sa -C -b -t 0 -w 65535 -W -s "|"
|
||||
if ($LASTEXITCODE -ne 0) { throw "SQL Server 命令失败,请检查已脱敏的核验结果。" }
|
||||
} finally {
|
||||
[Environment]::SetEnvironmentVariable("SQLCMDPASSWORD", $previousPassword, "Process")
|
||||
[Runtime.InteropServices.Marshal]::ZeroFreeBSTR($ptr)
|
||||
}
|
||||
}
|
||||
56
scripts/tplus/create_tplus_readonly_login.ps1
Normal file
56
scripts/tplus/create_tplus_readonly_login.ps1
Normal file
@ -0,0 +1,56 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$DatabaseName,
|
||||
[string]$LoginName = "tplus_reader",
|
||||
[string]$ContainerName = "ys-tplus-sqlserver-dev",
|
||||
[Parameter(Mandatory = $true)]
|
||||
[SecureString]$SaPassword,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[SecureString]$ReaderPassword
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = "Stop"
|
||||
. (Join-Path $PSScriptRoot "container_sql.ps1")
|
||||
|
||||
function Convert-SecureStringToPlainText([SecureString]$Value) {
|
||||
$ptr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($Value)
|
||||
try { return [Runtime.InteropServices.Marshal]::PtrToStringBSTR($ptr) }
|
||||
finally { [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($ptr) }
|
||||
}
|
||||
|
||||
if ($LoginName -notmatch '^[A-Za-z_][A-Za-z0-9_]{0,63}$') {
|
||||
throw "只读账号名只能包含字母、数字和下划线,且不能以数字开头。"
|
||||
}
|
||||
|
||||
$readerPasswordText = Convert-SecureStringToPlainText $ReaderPassword
|
||||
$safeDatabaseIdentifier = $DatabaseName.Replace("]", "]]" )
|
||||
$safeDatabaseLiteral = $DatabaseName.Replace("'", "''")
|
||||
$safeLoginIdentifier = $LoginName.Replace("]", "]]" )
|
||||
$safeReaderPassword = $readerPasswordText.Replace("'", "''")
|
||||
$sql = @"
|
||||
IF DATABASEPROPERTYEX(N'$safeDatabaseLiteral', 'Updateability') = N'READ_ONLY'
|
||||
ALTER DATABASE [$safeDatabaseIdentifier] SET READ_WRITE;
|
||||
IF NOT EXISTS (SELECT 1 FROM sys.server_principals WHERE name = N'$safeLoginIdentifier')
|
||||
BEGIN
|
||||
CREATE LOGIN [$safeLoginIdentifier] WITH PASSWORD = N'$safeReaderPassword', CHECK_POLICY = ON;
|
||||
END;
|
||||
USE [$safeDatabaseIdentifier];
|
||||
IF NOT EXISTS (SELECT 1 FROM sys.database_principals WHERE name = N'$safeLoginIdentifier')
|
||||
BEGIN
|
||||
CREATE USER [$safeLoginIdentifier] FOR LOGIN [$safeLoginIdentifier];
|
||||
END;
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM sys.database_role_members rm
|
||||
JOIN sys.database_principals r ON r.principal_id = rm.role_principal_id
|
||||
JOIN sys.database_principals u ON u.principal_id = rm.member_principal_id
|
||||
WHERE r.name=N'db_datareader' AND u.name=N'$safeLoginIdentifier'
|
||||
)
|
||||
EXEC sp_addrolemember N'db_datareader', N'$safeLoginIdentifier';
|
||||
DENY INSERT, UPDATE, DELETE TO [$safeLoginIdentifier];
|
||||
ALTER DATABASE [$safeDatabaseIdentifier] SET READ_ONLY;
|
||||
"@
|
||||
|
||||
Invoke-TPlusContainerSql -ContainerName $ContainerName -Sql $sql -SaPassword $SaPassword
|
||||
Write-Host "只读账号已配置:$LoginName(未输出密码)"
|
||||
23
scripts/tplus/inspect_sqlserver_backup.ps1
Normal file
23
scripts/tplus/inspect_sqlserver_backup.ps1
Normal file
@ -0,0 +1,23 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$BackupFileName,
|
||||
[string]$ContainerName = "ys-tplus-sqlserver-dev",
|
||||
[string]$BackupRoot = "D:\work\app\tplus-sqlserver\backup",
|
||||
[Parameter(Mandatory = $true)]
|
||||
[SecureString]$SaPassword
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
. (Join-Path $PSScriptRoot "container_sql.ps1")
|
||||
|
||||
$backupPath = Join-Path $BackupRoot $BackupFileName
|
||||
if (-not (Test-Path -LiteralPath $backupPath)) {
|
||||
throw "找不到备份文件:$backupPath"
|
||||
}
|
||||
|
||||
$containerPath = "/var/opt/mssql/backup/$BackupFileName"
|
||||
$sql = "RESTORE VERIFYONLY FROM DISK = N'$($containerPath.Replace("'", "''"))'; RESTORE FILELISTONLY FROM DISK = N'$($containerPath.Replace("'", "''"))';"
|
||||
Invoke-TPlusContainerSql -ContainerName $ContainerName -Sql $sql -SaPassword $SaPassword
|
||||
74
scripts/tplus/inventory_tplus_database.ps1
Normal file
74
scripts/tplus/inventory_tplus_database.ps1
Normal file
@ -0,0 +1,74 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$DatabaseName,
|
||||
[string]$ContainerName = "ys-tplus-sqlserver-dev",
|
||||
[string]$ReportRoot = "D:\work\app\tplus-sqlserver\reports",
|
||||
[Parameter(Mandatory = $true)]
|
||||
[SecureString]$SaPassword
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
. (Join-Path $PSScriptRoot "container_sql.ps1")
|
||||
|
||||
New-Item -ItemType Directory -Path $ReportRoot -Force | Out-Null
|
||||
$reportPath = Join-Path $ReportRoot ("Tplus-核验-{0:yyyyMMdd-HHmmss}.md" -f (Get-Date))
|
||||
$safeDatabaseIdentifier = $DatabaseName.Replace("]", "]]" )
|
||||
$safeDatabaseLiteral = $DatabaseName.Replace("'", "''")
|
||||
|
||||
$sql = @"
|
||||
USE [$safeDatabaseIdentifier];
|
||||
SET NOCOUNT ON;
|
||||
SELECT 'DATABASE' AS section_name, DB_NAME() AS item_name, CAST(SUM(size) * 8.0 / 1024 AS varchar(64)) AS detail
|
||||
FROM sys.database_files;
|
||||
SELECT 'TABLE' AS section_name, s.name + '.' + t.name AS item_name,
|
||||
CAST(SUM(p.rows) AS varchar(64)) AS detail
|
||||
FROM sys.tables t
|
||||
JOIN sys.schemas s ON s.schema_id = t.schema_id
|
||||
LEFT JOIN sys.partitions p ON p.object_id = t.object_id AND p.index_id IN (0, 1)
|
||||
GROUP BY s.name, t.name
|
||||
ORDER BY s.name, t.name;
|
||||
SELECT 'COLUMN' AS section_name,
|
||||
s.name + '.' + t.name + '.' + c.name AS item_name,
|
||||
ty.name + '(' + CAST(c.max_length AS varchar(16)) + ')' AS detail
|
||||
FROM sys.columns c
|
||||
JOIN sys.tables t ON t.object_id = c.object_id
|
||||
JOIN sys.schemas s ON s.schema_id = t.schema_id
|
||||
JOIN sys.types ty ON ty.user_type_id = c.user_type_id
|
||||
ORDER BY s.name, t.name, c.column_id;
|
||||
SELECT 'VIEW' AS section_name, s.name + '.' + v.name AS item_name, 'view' AS detail
|
||||
FROM sys.views v JOIN sys.schemas s ON s.schema_id = v.schema_id
|
||||
ORDER BY s.name, v.name;
|
||||
SELECT 'PROCEDURE' AS section_name, s.name + '.' + p.name AS item_name, 'procedure' AS detail
|
||||
FROM sys.procedures p JOIN sys.schemas s ON s.schema_id = p.schema_id
|
||||
ORDER BY s.name, p.name;
|
||||
"@
|
||||
|
||||
$output = Invoke-TPlusContainerSql -ContainerName $ContainerName -Sql $sql -SaPassword $SaPassword
|
||||
|
||||
$header = @(
|
||||
"# T+ 数据库恢复核验报告",
|
||||
"",
|
||||
"- 数据库:$DatabaseName",
|
||||
"- 容器:$ContainerName",
|
||||
"- 核验时间:$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')",
|
||||
"- 数据目录:D:\work\app\tplus-sqlserver\data",
|
||||
"- 日志目录:D:\work\app\tplus-sqlserver\log",
|
||||
"",
|
||||
"## 查询结果",
|
||||
"",
|
||||
'```text'
|
||||
)
|
||||
$footer = @(
|
||||
'```',
|
||||
"",
|
||||
"## 后续核对",
|
||||
"",
|
||||
"- 对照 T+ 12.3 字段映射草案核对六类源表。",
|
||||
"- 记录更新时间字段、关联键、负向单据和枚举值。",
|
||||
"- 未确认字段不得直接写入工资业务表。"
|
||||
)
|
||||
($header + @($output) + $footer) | Set-Content -LiteralPath $reportPath -Encoding utf8
|
||||
Write-Host "核验报告已生成:$reportPath"
|
||||
37
scripts/tplus/prepare_sql2008r2_vm.ps1
Normal file
37
scripts/tplus/prepare_sql2008r2_vm.ps1
Normal file
@ -0,0 +1,37 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$VmName = "tplus-sql2008r2-test",
|
||||
[string]$Root = "D:\work\app\tplus-vm",
|
||||
[string]$WindowsIso = ""
|
||||
)
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = "Stop"
|
||||
$rootPath = [IO.Path]::GetFullPath($Root)
|
||||
if (-not $rootPath.StartsWith("D:\work\app\", [StringComparison]::OrdinalIgnoreCase)) {
|
||||
throw "虚拟机目录必须位于 D:\work\app 下。"
|
||||
}
|
||||
if ($WindowsIso -and -not (Test-Path -LiteralPath $WindowsIso -PathType Leaf)) {
|
||||
throw "Windows 安装介质不存在。"
|
||||
}
|
||||
if (Get-VM -Name $VmName -ErrorAction SilentlyContinue) {
|
||||
throw "同名虚拟机已存在,拒绝覆盖。"
|
||||
}
|
||||
if ((Get-PSDrive D).Free -lt 40GB) { throw "D 盘剩余不足 40GB,暂不创建测试环境。" }
|
||||
New-Item -ItemType Directory -Path $rootPath -Force | Out-Null
|
||||
$disk = Join-Path $rootPath "sqlserver2008r2.vhdx"
|
||||
if (Test-Path -LiteralPath $disk) { throw "测试磁盘已存在,拒绝覆盖。" }
|
||||
New-VHD -Path $disk -SizeBytes 64GB -Dynamic | Out-Null
|
||||
$vm = New-VM -Name $VmName -Generation 1 -MemoryStartupBytes 4GB `
|
||||
-VHDPath $disk -Path $rootPath
|
||||
Set-VM -VM $vm -ProcessorCount 2 -AutomaticStartAction Nothing `
|
||||
-AutomaticStopAction ShutDown -CheckpointType Disabled `
|
||||
-SnapshotFileLocation $rootPath -SmartPagingFilePath $rootPath
|
||||
# 来宾系统尚未安装,先断开网卡,不改 Docker/WSL 的交换机。
|
||||
Get-VMNetworkAdapter -VM $vm | Disconnect-VMNetworkAdapter
|
||||
if ($WindowsIso) {
|
||||
Add-VMDvdDrive -VM $vm -Path $WindowsIso | Out-Null
|
||||
}
|
||||
Get-VM -Name $VmName | Select-Object Name, State, Generation, Path
|
||||
Get-VMHardDiskDrive -VMName $VmName | Select-Object Path
|
||||
Write-Host "仅创建了未启动的虚拟机和动态磁盘,未安装 Windows 或 SQL Server。"
|
||||
Write-Host "需授权 Windows x64 和 SQL Server 2008 R2 安装介质,不能用 Express 承载此大账套。"
|
||||
52
scripts/tplus/prepare_tplus_workspace.ps1
Normal file
52
scripts/tplus/prepare_tplus_workspace.ps1
Normal file
@ -0,0 +1,52 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$SourceBackupPath = "D:\work\code\PayrollManagementSystem\db\ceshi20261026.bak",
|
||||
[string]$TPlusRoot = "D:\work\app\tplus-sqlserver"
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
function Assert-OnDDrive([string]$Path) {
|
||||
$full = [IO.Path]::GetFullPath($Path)
|
||||
if (-not $full.StartsWith("D:\", [StringComparison]::OrdinalIgnoreCase)) {
|
||||
throw "T+ 数据目录必须位于 D 盘:$full"
|
||||
}
|
||||
}
|
||||
|
||||
Assert-OnDDrive $TPlusRoot
|
||||
if (-not (Test-Path -LiteralPath $SourceBackupPath -PathType Leaf)) {
|
||||
throw "找不到客户备份文件:$SourceBackupPath"
|
||||
}
|
||||
|
||||
$dataDir = Join-Path $TPlusRoot "data"
|
||||
$logDir = Join-Path $TPlusRoot "log"
|
||||
$backupDir = Join-Path $TPlusRoot "backup"
|
||||
$scriptsDir = Join-Path $TPlusRoot "scripts"
|
||||
foreach ($dir in @($dataDir, $logDir, $backupDir, $scriptsDir)) {
|
||||
New-Item -ItemType Directory -Path $dir -Force | Out-Null
|
||||
}
|
||||
|
||||
$destination = Join-Path $backupDir (Split-Path $SourceBackupPath -Leaf)
|
||||
$sourceHash = (Get-FileHash -LiteralPath $SourceBackupPath -Algorithm SHA256).Hash
|
||||
|
||||
if (Test-Path -LiteralPath $destination -PathType Leaf) {
|
||||
$destinationHash = (Get-FileHash -LiteralPath $destination -Algorithm SHA256).Hash
|
||||
if ($destinationHash -ne $sourceHash) {
|
||||
throw "D 盘已有同名备份,但 SHA-256 不一致:$destination"
|
||||
}
|
||||
Write-Host "备份已存在且校验一致:$destination"
|
||||
} else {
|
||||
Write-Host "正在把备份复制到 D 盘,源文件不会被修改……"
|
||||
Copy-Item -LiteralPath $SourceBackupPath -Destination $destination -Force
|
||||
$destinationHash = (Get-FileHash -LiteralPath $destination -Algorithm SHA256).Hash
|
||||
if ($destinationHash -ne $sourceHash) {
|
||||
throw "复制完成后 SHA-256 校验失败:$destination"
|
||||
}
|
||||
Write-Host "备份复制完成:$destination"
|
||||
}
|
||||
|
||||
$hashFile = "$destination.sha256"
|
||||
"$destinationHash $(Split-Path $destination -Leaf)" | Set-Content -LiteralPath $hashFile -Encoding utf8
|
||||
Write-Host "SHA-256:$destinationHash"
|
||||
Write-Host "SQL Server 数据、日志和备份目录已准备在:$TPlusRoot"
|
||||
48
scripts/tplus/profile_source.py
Normal file
48
scripts/tplus/profile_source.py
Normal file
@ -0,0 +1,48 @@
|
||||
"""只读统计主从关系、状态和负数行,不输出业务明细。"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pyodbc
|
||||
|
||||
QUERIES = {
|
||||
"personnel_dates": "SELECT COUNT_BIG(*) AS total, SUM(CASE WHEN updated IS NULL THEN 1 ELSE 0 END) AS null_updated FROM AA_Person",
|
||||
"product_dates": "SELECT COUNT_BIG(*) AS total, SUM(CASE WHEN updated IS NULL THEN 1 ELSE 0 END) AS null_updated FROM AA_Inventory",
|
||||
"order_detail_parent": "SELECT COUNT_BIG(*) AS total, SUM(CASE WHEN h.ID IS NULL THEN 1 ELSE 0 END) AS orphan_rows FROM SA_SaleOrder_b b LEFT JOIN SA_SaleOrder h ON h.ID=b.idSaleOrderDTO",
|
||||
"delivery_detail_parent": "SELECT COUNT_BIG(*) AS total, SUM(CASE WHEN h.ID IS NULL THEN 1 ELSE 0 END) AS orphan_rows FROM SA_SaleDelivery_b b LEFT JOIN SA_SaleDelivery h ON h.ID=b.idSaleDeliveryDTO",
|
||||
"receipt_detail_parent": "SELECT COUNT_BIG(*) AS total, SUM(CASE WHEN h.ID IS NULL THEN 1 ELSE 0 END) AS orphan_rows FROM ARAP_ReceivePayment_b b LEFT JOIN ARAP_ReceivePayment h ON h.ID=b.idArapReceivePaymentDTO",
|
||||
"delivery_order_link": "SELECT COUNT_BIG(*) AS total, SUM(CASE WHEN b.saleOrderId IS NULL OR b.saleOrderId=0 THEN 1 ELSE 0 END) AS missing_order_id, SUM(CASE WHEN b.saleOrderId>0 AND h.ID IS NULL THEN 1 ELSE 0 END) AS invalid_order_id FROM SA_SaleDelivery_b b LEFT JOIN SA_SaleOrder h ON h.ID=b.saleOrderId",
|
||||
"receipt_order_link": "SELECT COUNT_BIG(*) AS total, SUM(CASE WHEN p.SaleOrderID IS NULL OR p.SaleOrderID=0 THEN 1 ELSE 0 END) AS missing_order_id, SUM(CASE WHEN p.SaleOrderID>0 AND h.ID IS NULL THEN 1 ELSE 0 END) AS invalid_order_id FROM ARAP_ReceivePayment p LEFT JOIN SA_SaleOrder h ON h.ID=p.SaleOrderID",
|
||||
"receipt_direction": "SELECT isReceiveFlag, COUNT_BIG(*) AS total FROM ARAP_ReceivePayment GROUP BY isReceiveFlag",
|
||||
"receipt_status": "SELECT voucherstate, cancelState, COUNT_BIG(*) AS total FROM ARAP_ReceivePayment GROUP BY voucherstate,cancelState",
|
||||
"negative_deliveries": "SELECT SUM(CASE WHEN quantity<0 THEN 1 ELSE 0 END) AS negative_quantity, SUM(CASE WHEN costAmount<0 THEN 1 ELSE 0 END) AS negative_cost FROM SA_SaleDelivery_b",
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""执行固定白名单统计,并保存无凭据、无明细的 JSON。"""
|
||||
parser = argparse.ArgumentParser(description="T+ 只读数据质量统计")
|
||||
parser.add_argument("--connection-file", required=True, type=Path)
|
||||
parser.add_argument("--report", required=True, type=Path)
|
||||
args = parser.parse_args()
|
||||
c = json.loads(args.connection_file.read_text(encoding="utf-8-sig"))
|
||||
cs = ("DRIVER={" + c["driver"] + "};SERVER=" + c["host"] + "," + str(c["port"])
|
||||
+ ";DATABASE=" + c["database"] + ";UID=" + c["user"]
|
||||
+ ";PWD={" + c["password"].replace("}", "}}") + "};")
|
||||
result = {}
|
||||
with pyodbc.connect(cs, timeout=10) as connection:
|
||||
connection.timeout = 120
|
||||
cursor = connection.cursor()
|
||||
for name, sql in QUERIES.items():
|
||||
cursor.execute(sql)
|
||||
names = [column[0] for column in cursor.description]
|
||||
result[name] = [dict(zip(names, row, strict=True)) for row in cursor.fetchall()]
|
||||
print(name, json.dumps(result[name]), flush=True)
|
||||
args.report.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.report.write_text(json.dumps(result, indent=2), encoding="utf-8")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
87
scripts/tplus/restore_sqlserver_backup.ps1
Normal file
87
scripts/tplus/restore_sqlserver_backup.ps1
Normal file
@ -0,0 +1,87 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$BackupFileName,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$DatabaseName,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$DataLogicalName,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$LogLogicalName,
|
||||
[string]$ContainerName = "ys-tplus-sqlserver-dev",
|
||||
[string]$BackupRoot = "D:\work\app\tplus-sqlserver\backup",
|
||||
[string]$DataRoot = "D:\work\app\tplus-sqlserver\data",
|
||||
[string]$LogRoot = "D:\work\app\tplus-sqlserver\log",
|
||||
[Parameter(Mandatory = $true)]
|
||||
[SecureString]$SaPassword
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
. (Join-Path $PSScriptRoot "container_sql.ps1")
|
||||
|
||||
if ($DatabaseName -notmatch '^[A-Za-z_][A-Za-z0-9_]{0,127}$') {
|
||||
throw "恢复数据库名只能使用字母、数字和下划线。"
|
||||
}
|
||||
if ([IO.Path]::GetFileName($BackupFileName) -ne $BackupFileName) {
|
||||
throw "备份文件名不能包含目录。"
|
||||
}
|
||||
foreach ($path in @($BackupRoot, $DataRoot, $LogRoot)) {
|
||||
if (-not [IO.Path]::GetFullPath($path).StartsWith("D:\", [StringComparison]::OrdinalIgnoreCase)) {
|
||||
throw "恢复目录必须位于 D 盘。"
|
||||
}
|
||||
}
|
||||
$mounts = docker inspect $ContainerName --format '{{json .Mounts}}' | ConvertFrom-Json
|
||||
if ($LASTEXITCODE -ne 0) { throw "无法读取 SQL Server 容器挂载。" }
|
||||
foreach ($pair in @(
|
||||
@{ Target="/var/opt/mssql/data"; Source=$DataRoot },
|
||||
@{ Target="/var/opt/mssql/log"; Source=$LogRoot },
|
||||
@{ Target="/var/opt/mssql/backup"; Source=$BackupRoot }
|
||||
)) {
|
||||
$mount = @($mounts | Where-Object { $_.Destination -eq $pair.Target })
|
||||
if ($mount.Count -ne 1 -or $mount[0].Type -ne "bind") {
|
||||
throw "目标目录未明确绑定到宿主机 D 盘:$($pair.Target)"
|
||||
}
|
||||
if ([IO.Path]::GetFullPath($mount[0].Source) -ine [IO.Path]::GetFullPath($pair.Source)) {
|
||||
throw "实际挂载与恢复目录配置不一致:$($pair.Target)"
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($path in @($BackupRoot, $DataRoot, $LogRoot)) {
|
||||
if (-not (Test-Path -LiteralPath $path)) {
|
||||
New-Item -ItemType Directory -Path $path -Force | Out-Null
|
||||
}
|
||||
}
|
||||
|
||||
$backupPath = Join-Path $BackupRoot $BackupFileName
|
||||
if (-not (Test-Path -LiteralPath $backupPath)) {
|
||||
throw "找不到备份文件:$backupPath"
|
||||
}
|
||||
|
||||
$containerBackupPath = "/var/opt/mssql/backup/$BackupFileName"
|
||||
$dataPath = "/var/opt/mssql/data/$DatabaseName.mdf"
|
||||
$logPath = "/var/opt/mssql/log/$DatabaseName`_log.ldf"
|
||||
$safeDatabaseIdentifier = $DatabaseName.Replace("]", "]]" )
|
||||
$safeDatabaseLiteral = $DatabaseName.Replace("'", "''")
|
||||
$safeDataLogicalName = $DataLogicalName.Replace("'", "''")
|
||||
$safeLogLogicalName = $LogLogicalName.Replace("'", "''")
|
||||
$safeBackupPath = $containerBackupPath.Replace("'", "''")
|
||||
$sql = @"
|
||||
RESTORE VERIFYONLY FROM DISK = N'$safeBackupPath';
|
||||
IF DB_ID(N'$safeDatabaseLiteral') IS NOT NULL
|
||||
BEGIN
|
||||
THROW 51000, 'Target database exists; refusing to overwrite.', 1;
|
||||
END;
|
||||
RESTORE DATABASE [$safeDatabaseIdentifier]
|
||||
FROM DISK = N'$safeBackupPath'
|
||||
WITH MOVE N'$safeDataLogicalName' TO N'$dataPath',
|
||||
MOVE N'$safeLogLogicalName' TO N'$logPath',
|
||||
RECOVERY, STATS = 5;
|
||||
ALTER DATABASE [$safeDatabaseIdentifier] SET READ_ONLY;
|
||||
DBCC CHECKDB (N'$safeDatabaseLiteral') WITH NO_INFOMSGS;
|
||||
"@
|
||||
|
||||
Write-Host "正在校验并恢复备份(密码不会输出)……"
|
||||
Invoke-TPlusContainerSql -ContainerName $ContainerName -Sql $sql -SaPassword $SaPassword
|
||||
Write-Host "恢复完成:$DatabaseName;数据库文件位于 D 盘绑定目录。"
|
||||
71
scripts/tplus/verify_staging.py
Normal file
71
scripts/tplus/verify_staging.py
Normal file
@ -0,0 +1,71 @@
|
||||
"""使用本地只读凭据执行快照对账,不把凭据复制到项目配置。"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "backend"))
|
||||
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.core.db import dispose_engine, get_session_factory
|
||||
from app.core.tplus_db import dispose_tplus_engine, get_tplus_session_factory
|
||||
from app.models import TPlusSyncRecord
|
||||
from app.services.tplus.mapping import DETAIL_MAPPINGS, TPLUS_MAPPINGS
|
||||
from app.services.tplus.sync_service import TPlusSyncService
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""执行一次或重复快照,并将计数结果保存到指定报告。"""
|
||||
parser = argparse.ArgumentParser(description="T+ 本地只读快照与幂等对账")
|
||||
parser.add_argument("--connection-file", type=Path, required=True)
|
||||
parser.add_argument("--report", type=Path, required=True)
|
||||
parser.add_argument("--repeat", action="store_true")
|
||||
args = parser.parse_args()
|
||||
config = json.loads(args.connection_file.read_text(encoding="utf-8-sig"))
|
||||
settings = get_settings()
|
||||
settings.t_plus_enabled = True
|
||||
for key in ("host", "port", "database", "user", "password"):
|
||||
setattr(settings, "t_plus_" + key, config[key])
|
||||
settings.t_plus_odbc_driver = config["driver"]
|
||||
source_factory, target_factory = get_tplus_session_factory(), get_session_factory()
|
||||
reports, failed = [], False
|
||||
try:
|
||||
with source_factory() as source, target_factory() as target:
|
||||
for run in range(1, 3 if args.repeat else 2):
|
||||
for mapping in (*TPLUS_MAPPINGS, *DETAIL_MAPPINGS):
|
||||
batch = TPlusSyncService(source, target).run(entity_types=[mapping.entity_type])
|
||||
count = target.scalar(
|
||||
select(func.count())
|
||||
.select_from(TPlusSyncRecord)
|
||||
.where(TPlusSyncRecord.entity_type == mapping.entity_type)
|
||||
)
|
||||
row = {
|
||||
"run": run,
|
||||
"entity": mapping.entity_type,
|
||||
"batch": batch.batch_no,
|
||||
"status": batch.status,
|
||||
"source": batch.source_record_count,
|
||||
"inserted": batch.staged_record_count,
|
||||
"errors": batch.failed_record_count,
|
||||
"target_count": count,
|
||||
}
|
||||
print(json.dumps(row), flush=True)
|
||||
reports.append(row)
|
||||
failed |= batch.status != "SUCCEEDED" or (
|
||||
run == 2 and batch.staged_record_count != 0
|
||||
)
|
||||
args.report.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.report.write_text(
|
||||
json.dumps(reports, ensure_ascii=False, indent=2), encoding="utf-8"
|
||||
)
|
||||
finally:
|
||||
dispose_tplus_engine()
|
||||
dispose_engine()
|
||||
return int(failed)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user