Compare commits

...

6 Commits

104 changed files with 5407 additions and 1206 deletions

4
.gitignore vendored
View File

@ -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

View File

@ -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=

View File

@ -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

View 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")

View 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",
)

View File

@ -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/*,业务操作登录即可访问

View 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))

View File

@ -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__":

View File

@ -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/。

View 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

View 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()

View File

@ -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("应用已停止,连接池已释放")

View File

@ -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",

View File

@ -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, ...]:

View 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"
)

View 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
)

View 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

View 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)的兼容验收。

View 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"]

View 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}")

View 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,
)

View 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)

View 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

View 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

View 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

View 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")

View 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

View File

@ -38,6 +38,11 @@ dependencies = [
"python-multipart>=0.0.32",
]
[project.optional-dependencies]
tplus = [
"pyodbc>=5.2,<6",
]
[dependency-groups]
dev = [
"pytest>=8.3",

View File

@ -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,避免不同权限令牌共享系统临时目录。
## 一、测试约定

View File

@ -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:
"""构造应用测试客户端(会话级复用,避免重复构建应用)。

View File

@ -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()

View File

@ -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={

View 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

View 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)
)

View File

@ -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]

View File

@ -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 结尾」检查约束

View 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")

View 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)

View 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()

View 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"]

View 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

View 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

View 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

View 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)

View 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()))

View 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

File diff suppressed because it is too large Load Diff

View File

@ -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');

View File

@ -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';

View 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';

View 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';

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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
View 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=

View 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 验证方案另行评审。

View File

@ -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 确认前不得视为同步契约。*

View 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),再进入交易数据与负向事件。

Binary file not shown.

View File

@ -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` 需求裁决节交叉核对口径无冲突。

View File

@ -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` 最新运行记录为准)。

View File

@ -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` 历史任务摘要由主会话收尾时更新。
## 风险与开放问题(默认处理,不阻塞开工)

View File

@ -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 序号连续(中间断号说明有未归档文件,先跑一次增量归档)

Binary file not shown.

Binary file not shown.

View File

@ -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 附表一并);第三部分随开发进度与业务方顺带确认。

View File

@ -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)
================================================================
(文件结束)

View File

@ -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
View File

@ -0,0 +1,2 @@
# Vite 开发代理目标,仅开发服务器读取;前端请求仍使用 /api 相对地址。
DEV_API_TARGET=http://127.0.0.1:8000

View File

@ -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
View 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 } })
}

View File

@ -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,

View File

@ -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',

View File

@ -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 = [

View 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>

View 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>

View 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>

View 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>

View 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('、') || '历史批次,范围未记录'
}

View File

@ -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,
},
},

View 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())

View 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)
}
}

View 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(未输出密码)"

View 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

View 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"

View 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 承载此大账套。"

View 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"

View 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())

View 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 盘绑定目录。"

View 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