Compare commits
9
Commits
main
...
8a171a572c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a171a572c | ||
|
|
651341b36c | ||
|
|
0d63359acb | ||
|
|
ea41dadb64 | ||
|
|
8242aae454 | ||
|
|
87a0884953 | ||
|
|
ea989ce9ad | ||
|
|
4fe5916c2c | ||
|
|
78bef43c06 |
No files matched your search
+3
-1
@@ -1,5 +1,5 @@
|
|||||||
# AI辅助目录(整个目录忽略)
|
# AI辅助目录(整个目录忽略)
|
||||||
ai-aux/
|
.ai/
|
||||||
|
|
||||||
# 依赖
|
# 依赖
|
||||||
node_modules/
|
node_modules/
|
||||||
@@ -65,6 +65,8 @@ CLAUDE.md
|
|||||||
backend/uploads/
|
backend/uploads/
|
||||||
# 数据库备份导出目录(⑨ 备份脚本产物)
|
# 数据库备份导出目录(⑨ 备份脚本产物)
|
||||||
db/backups/
|
db/backups/
|
||||||
|
# 客户发来的原始账套备份压缩包。
|
||||||
|
/db/*.zip
|
||||||
|
|
||||||
# 外部参考资料(2026-10-20):T+12.3 数据字典压缩包与解压目录不入版本库
|
# 外部参考资料(2026-10-20):T+12.3 数据字典压缩包与解压目录不入版本库
|
||||||
docs/**/*.rar
|
docs/**/*.rar
|
||||||
|
|||||||
+20
-3
@@ -26,6 +26,23 @@ LOGIN_FAIL_LIMIT=5
|
|||||||
LOGIN_LOCK_MINUTES=15
|
LOGIN_LOCK_MINUTES=15
|
||||||
|
|
||||||
# ===== T+ 对接(待畅捷通确认后填写)=====
|
# ===== T+ 对接(待畅捷通确认后填写)=====
|
||||||
T_PLUS_BASE_URL=
|
T_PLUS_ENABLED=false
|
||||||
T_PLUS_APP_KEY=
|
T_PLUS_HOST=127.0.0.1
|
||||||
T_PLUS_APP_SECRET=
|
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=
|
||||||
|
# 默认启用传输加密。旧实例 TLS 不兼容时,仅隔离开发环境可显式设为 false,
|
||||||
|
# 记录原因并重新验收;程序不会自动降级。真实环境值后续按验收结果填写。
|
||||||
|
T_PLUS_ENCRYPT=true
|
||||||
|
# 内网自签证书可暂用 true;它不校验证书身份。部署可信证书后改为 false。
|
||||||
|
T_PLUS_TRUST_SERVER_CERTIFICATE=true
|
||||||
|
T_PLUS_CONNECT_TIMEOUT=10
|
||||||
|
T_PLUS_QUERY_TIMEOUT=120
|
||||||
|
# 可选:留空时使用仓库根目录下的 db/queries/tplus/
|
||||||
|
T_PLUS_QUERY_DIR=
|
||||||
@@ -140,6 +140,19 @@ app/
|
|||||||
(`warning.enabled` / `warning.receipt.overdue.days` / `warning.approval.timeout.days`,
|
(`warning.enabled` / `warning.receipt.overdue.days` / `warning.approval.timeout.days`,
|
||||||
V10 种子);⑥ T+ 同步未建,同步相关条目以 `sync_available=false` 显式降级。
|
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
|
```bash
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
"""V15 T+ 持久化任务、幂等和重试元数据。"""
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects.mysql import BIGINT
|
||||||
|
|
||||||
|
revision: str = "e5f607a8b9c0"
|
||||||
|
down_revision: str | Sequence[str] | None = "d4e5f6a7b8c9"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""仅扩展管理元数据,不改原始暂存行或工资业务表。"""
|
||||||
|
op.add_column(
|
||||||
|
"tplus_sync_batches",
|
||||||
|
sa.Column("entity_types_json", sa.JSON(), comment="请求读取的实体类型;历史批次可为空"),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"tplus_sync_batches",
|
||||||
|
sa.Column(
|
||||||
|
"skipped_record_count",
|
||||||
|
sa.Integer(),
|
||||||
|
nullable=False,
|
||||||
|
server_default="0",
|
||||||
|
comment="重复快照跳过条数",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"tplus_sync_batches",
|
||||||
|
sa.Column("parent_batch_no", sa.String(32), comment="重试来源批次号,保留原批次记录"),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"tplus_sync_batches",
|
||||||
|
sa.Column("request_key", sa.String(36), comment="请求幂等键,空值用于历史和直接CLI任务"),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"tplus_sync_batches",
|
||||||
|
sa.Column(
|
||||||
|
"requested_by", BIGINT(unsigned=True), comment="请求用户ID,CLI任务为空,无物理外键"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"tplus_sync_batches",
|
||||||
|
sa.Column("audit_result_json", sa.JSON(), comment="关联核验结果,只含系统字典和汇总"),
|
||||||
|
)
|
||||||
|
op.alter_column(
|
||||||
|
"tplus_sync_batches",
|
||||||
|
"started_at",
|
||||||
|
existing_type=sa.DateTime(),
|
||||||
|
nullable=True,
|
||||||
|
comment="开始执行时间(排队时为空)",
|
||||||
|
)
|
||||||
|
op.alter_column(
|
||||||
|
"tplus_sync_batches",
|
||||||
|
"sync_mode",
|
||||||
|
existing_type=sa.String(16),
|
||||||
|
nullable=False,
|
||||||
|
comment="任务方式:FULL全量 AUDIT关联核验 INCREMENTAL预留",
|
||||||
|
)
|
||||||
|
op.alter_column(
|
||||||
|
"tplus_sync_batches",
|
||||||
|
"status",
|
||||||
|
existing_type=sa.String(16),
|
||||||
|
nullable=False,
|
||||||
|
comment="状态:QUEUED/RUNNING/SUCCEEDED/PARTIAL/FAILED",
|
||||||
|
)
|
||||||
|
op.create_unique_constraint(
|
||||||
|
"uk_tplus_sync_batches_request_key", "tplus_sync_batches", ["request_key"]
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"idx_tplus_sync_batches_parent_status", "tplus_sync_batches", ["parent_batch_no", "status"]
|
||||||
|
)
|
||||||
|
op.execute(
|
||||||
|
"UPDATE tplus_sync_batches SET skipped_record_count=GREATEST(0, source_record_count-staged_record_count-failed_record_count) WHERE status='SUCCEEDED' AND sync_mode='FULL'"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""回滚前关闭 worker;原始暂存数据和历史批次仍保留。"""
|
||||||
|
op.execute(
|
||||||
|
"UPDATE tplus_sync_batches SET started_at=create_time, status='FAILED', finished_at=CURRENT_TIMESTAMP, error_message='任务管理版本已回滚' WHERE status='QUEUED'"
|
||||||
|
)
|
||||||
|
op.execute("UPDATE tplus_sync_batches SET started_at=create_time WHERE started_at IS NULL")
|
||||||
|
op.drop_index("idx_tplus_sync_batches_parent_status", table_name="tplus_sync_batches")
|
||||||
|
op.drop_constraint("uk_tplus_sync_batches_request_key", "tplus_sync_batches", type_="unique")
|
||||||
|
for name in (
|
||||||
|
"audit_result_json",
|
||||||
|
"requested_by",
|
||||||
|
"request_key",
|
||||||
|
"parent_batch_no",
|
||||||
|
"skipped_record_count",
|
||||||
|
"entity_types_json",
|
||||||
|
):
|
||||||
|
op.drop_column("tplus_sync_batches", name)
|
||||||
|
op.alter_column(
|
||||||
|
"tplus_sync_batches",
|
||||||
|
"started_at",
|
||||||
|
existing_type=sa.DateTime(),
|
||||||
|
nullable=False,
|
||||||
|
comment="开始读取时间",
|
||||||
|
)
|
||||||
|
op.alter_column(
|
||||||
|
"tplus_sync_batches",
|
||||||
|
"sync_mode",
|
||||||
|
existing_type=sa.String(16),
|
||||||
|
nullable=False,
|
||||||
|
comment="同步模式:FULL全量 INCREMENTAL增量",
|
||||||
|
)
|
||||||
|
op.alter_column(
|
||||||
|
"tplus_sync_batches",
|
||||||
|
"status",
|
||||||
|
existing_type=sa.String(16),
|
||||||
|
nullable=False,
|
||||||
|
comment="状态:RUNNING/SUCCEEDED/PARTIAL/FAILED",
|
||||||
|
)
|
||||||
@@ -26,6 +26,7 @@ from app.api.v1.system import logs as system_logs
|
|||||||
from app.api.v1.system import params as system_params
|
from app.api.v1.system import params as system_params
|
||||||
from app.api.v1.system import roles as system_roles
|
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 settings as system_settings
|
||||||
|
from app.api.v1.system import tplus as system_tplus
|
||||||
from app.api.v1.system import users as system_users
|
from app.api.v1.system import users as system_users
|
||||||
|
|
||||||
api_router = APIRouter()
|
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_approvals.router)
|
||||||
system_router.include_router(system_logs.operation_router)
|
system_router.include_router(system_logs.operation_router)
|
||||||
system_router.include_router(system_logs.login_router)
|
system_router.include_router(system_logs.login_router)
|
||||||
|
system_router.include_router(system_tplus.router)
|
||||||
v1_router.include_router(system_router)
|
v1_router.include_router(system_router)
|
||||||
|
|
||||||
# 基础数据(需求 2.1):/api/v1/base/*,业务操作登录即可访问
|
# 基础数据(需求 2.1):/api/v1/base/*,业务操作登录即可访问
|
||||||
|
|||||||
@@ -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))
|
||||||
+93
-1
@@ -11,13 +11,20 @@
|
|||||||
import argparse
|
import argparse
|
||||||
import getpass
|
import getpass
|
||||||
import sys
|
import sys
|
||||||
|
import time
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
from sqlalchemy import select
|
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.constants import PASSWORD_MAX_BYTES, PASSWORD_MIN_BYTES, ROLE_ADMIN
|
||||||
from app.core.db import dispose_engine, get_session_factory
|
from app.core.db import dispose_engine, get_session_factory
|
||||||
from app.core.security import hash_password
|
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.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:
|
def _prompt_password() -> str:
|
||||||
@@ -80,6 +87,54 @@ def _create_admin(args: argparse.Namespace) -> int:
|
|||||||
return 0
|
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:
|
def main() -> int:
|
||||||
"""命令行入口。"""
|
"""命令行入口。"""
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
@@ -96,12 +151,49 @@ def main() -> int:
|
|||||||
help="密码。不传则交互式输入(推荐)——命令行传参会留在 shell 历史中",
|
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()
|
args = parser.parse_args()
|
||||||
|
|
||||||
try:
|
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:
|
finally:
|
||||||
dispose_engine()
|
dispose_engine()
|
||||||
|
dispose_tplus_engine()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -58,6 +58,23 @@ class Settings(BaseSettings):
|
|||||||
redis_password: str = ""
|
redis_password: str = ""
|
||||||
redis_db: int = 0
|
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 = True
|
||||||
|
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")
|
@field_validator("app_secret_key")
|
||||||
@classmethod
|
@classmethod
|
||||||
def _check_secret_key(cls, v: str) -> str:
|
def _check_secret_key(cls, v: str) -> str:
|
||||||
@@ -92,6 +109,32 @@ class Settings(BaseSettings):
|
|||||||
auth = f":{self.redis_password}@" if self.redis_password else ""
|
auth = f":{self.redis_password}@" if self.redis_password else ""
|
||||||
return f"redis://{auth}{self.redis_host}:{self.redis_port}/{self.redis_db}"
|
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()`` 时仍会默认隐藏密码。
|
||||||
|
ODBC 的分号和花括号由方言统一转义,调用方不得再手工拼接连接串。
|
||||||
|
"""
|
||||||
|
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
|
@property
|
||||||
def upload_dir(self) -> Path:
|
def upload_dir(self) -> Path:
|
||||||
"""上传文件根目录(公司 logo 等):backend/uploads/。
|
"""上传文件根目录(公司 logo 等):backend/uploads/。
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
"""同账套任务串行锁;独立持有 MySQL 连接,跨分页 commit 仍保持有效。"""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
from contextlib import contextmanager
|
||||||
|
|
||||||
|
from sqlalchemy import text
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
|
||||||
|
class TPlusBusyError(RuntimeError):
|
||||||
|
"""已有任务持有执行锁。"""
|
||||||
|
|
||||||
|
|
||||||
|
def lock_name(session: Session) -> str:
|
||||||
|
"""生成不含地址、账号或密码的固定长度锁名。"""
|
||||||
|
database = session.get_bind().url.database or "default"
|
||||||
|
return "ys:tplus:read:" + hashlib.sha256(database.encode()).hexdigest()[:24]
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def sync_lock(session: Session):
|
||||||
|
"""只在取得锁后执行;释放失败时使连接失效,防止带锁回池。"""
|
||||||
|
engine = session.get_bind()
|
||||||
|
if engine.dialect.name != "mysql":
|
||||||
|
raise RuntimeError("T+ 执行协调需要 MySQL 命名锁")
|
||||||
|
connection = engine.connect()
|
||||||
|
acquired = False
|
||||||
|
try:
|
||||||
|
acquired = (
|
||||||
|
connection.execute(
|
||||||
|
text("SELECT GET_LOCK(:name, 0)"), {"name": lock_name(session)}
|
||||||
|
).scalar_one()
|
||||||
|
== 1
|
||||||
|
)
|
||||||
|
if not acquired:
|
||||||
|
raise TPlusBusyError("已有 T+ 任务正在执行,请稍后重试")
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
if acquired:
|
||||||
|
try:
|
||||||
|
released = connection.execute(
|
||||||
|
text("SELECT RELEASE_LOCK(:name)"), {"name": lock_name(session)}
|
||||||
|
).scalar_one()
|
||||||
|
if released != 1:
|
||||||
|
connection.invalidate()
|
||||||
|
except Exception:
|
||||||
|
connection.invalidate()
|
||||||
|
connection.close()
|
||||||
@@ -15,6 +15,7 @@ from app.core.db import dispose_engine
|
|||||||
from app.core.exception_handlers import register_exception_handlers
|
from app.core.exception_handlers import register_exception_handlers
|
||||||
from app.core.logging import setup_logging
|
from app.core.logging import setup_logging
|
||||||
from app.core.redis_client import close_redis
|
from app.core.redis_client import close_redis
|
||||||
|
from app.core.tplus_db import dispose_tplus_engine
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -35,6 +36,7 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
|
|||||||
)
|
)
|
||||||
yield
|
yield
|
||||||
dispose_engine()
|
dispose_engine()
|
||||||
|
dispose_tplus_engine()
|
||||||
close_redis()
|
close_redis()
|
||||||
logger.info("应用已停止,连接池已释放")
|
logger.info("应用已停止,连接池已释放")
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,12 @@ from app.models.role import Permission, Role, RolePermission
|
|||||||
from app.models.settlement import AdjustmentOrder, MonthlySettlement, SettlementItem
|
from app.models.settlement import AdjustmentOrder, MonthlySettlement, SettlementItem
|
||||||
from app.models.shipment import Shipment, ShipmentItem
|
from app.models.shipment import Shipment, ShipmentItem
|
||||||
from app.models.system_setting import SystemSetting
|
from app.models.system_setting import SystemSetting
|
||||||
|
from app.models.tplus_sync import (
|
||||||
|
TPlusSyncBatch,
|
||||||
|
TPlusSyncError,
|
||||||
|
TPlusSyncRecord,
|
||||||
|
TPlusSyncWatermark,
|
||||||
|
)
|
||||||
from app.models.user import User, UserRole
|
from app.models.user import User, UserRole
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
@@ -60,6 +66,10 @@ __all__ = [
|
|||||||
"Shipment",
|
"Shipment",
|
||||||
"ShipmentItem",
|
"ShipmentItem",
|
||||||
"SystemSetting",
|
"SystemSetting",
|
||||||
|
"TPlusSyncBatch",
|
||||||
|
"TPlusSyncError",
|
||||||
|
"TPlusSyncRecord",
|
||||||
|
"TPlusSyncWatermark",
|
||||||
"TimestampMixin",
|
"TimestampMixin",
|
||||||
"User",
|
"User",
|
||||||
"UserRole",
|
"UserRole",
|
||||||
|
|||||||
@@ -91,6 +91,9 @@ class TPlusSyncMixin:
|
|||||||
String(32), comment="同步批次号(同步模块写入)"
|
String(32), comment="同步批次号(同步模块写入)"
|
||||||
)
|
)
|
||||||
synced_at: Mapped[datetime | None] = mapped_column(DateTime, 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, ...]:
|
def mysql_table_args(comment: str, *extra: object) -> tuple[object, ...]:
|
||||||
|
|||||||
@@ -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"
|
||||||
|
)
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# 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+ 账套,查询文件随部署交付。
|
||||||
|
|
||||||
|
应用与兼容性脚本共用 `Settings.t_plus_database_url` 和连接文件校验;分号、花括号由 SQLAlchemy ODBC 方言转义。默认启用加密,失败不自动降级;既有私有配置需要按运行手册核对。管理页面不返回原始数据,但暂存 JSON 的生产敏感字段保护仍待 S1 决策。
|
||||||
|
|
||||||
|
## 验收限制
|
||||||
|
|
||||||
|
当前只写 MySQL 暂存与管理表,不写工资业务表、不推进时间水位。关联、成本、日期和负向事件的业务规则待客户确认。开发验收先以备份源 10.50.1600 为基准,生产完整版本后补;2022 副本结果不能替代该基准的实测。
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
"""T+ 只读读取、字段映射和同步 staging。"""
|
||||||
|
|
||||||
|
from app.services.tplus.reader import TPlusReader
|
||||||
|
from app.services.tplus.sync_service import TPlusSyncService
|
||||||
|
|
||||||
|
__all__ = ["TPlusReader", "TPlusSyncService"]
|
||||||
@@ -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}")
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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")
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
"""持久化任务执行者;获取连接级锁后才恢复或领取任务。"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from app.core.tplus_db import get_tplus_session_factory, resolve_tplus_settings
|
||||||
|
from app.core.tplus_lock import TPlusBusyError, sync_lock
|
||||||
|
from app.repositories import tplus_repository as repository
|
||||||
|
from app.services.tplus import task_service
|
||||||
|
from app.services.tplus.relationship_audit import TPlusRelationshipAudit
|
||||||
|
from app.services.tplus.sync_service import TPlusSyncService
|
||||||
|
|
||||||
|
|
||||||
|
def run_once(session, source_factory=None):
|
||||||
|
"""返回本轮处理的批次号;忙锁或队列为空时不假称已执行。"""
|
||||||
|
try:
|
||||||
|
with sync_lock(session):
|
||||||
|
task_service.recover_running(session)
|
||||||
|
batch = repository.next_queued(session)
|
||||||
|
if batch is None:
|
||||||
|
session.rollback()
|
||||||
|
return None
|
||||||
|
batch.status, batch.started_at = "RUNNING", datetime.now()
|
||||||
|
session.commit()
|
||||||
|
_execute(session, batch, source_factory)
|
||||||
|
return batch.batch_no
|
||||||
|
except TPlusBusyError:
|
||||||
|
session.rollback()
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _execute(session, batch, source_factory):
|
||||||
|
try:
|
||||||
|
settings = resolve_tplus_settings(task_service.get_settings())
|
||||||
|
if batch.source_database and batch.source_database != settings.t_plus_database:
|
||||||
|
raise RuntimeError("任务排队后的源账套配置已改变")
|
||||||
|
factory = source_factory or get_tplus_session_factory()
|
||||||
|
with factory() as source:
|
||||||
|
if batch.sync_mode == "AUDIT":
|
||||||
|
root = Path(settings.t_plus_query_dir) if settings.t_plus_query_dir else None
|
||||||
|
report = TPlusRelationshipAudit(source, root).run()
|
||||||
|
batch.audit_result_json = report
|
||||||
|
batch.source_record_count = sum(row["row_count"] for row in report["receipt_kinds"])
|
||||||
|
batch.finished_at, batch.status = datetime.now(), "SUCCEEDED"
|
||||||
|
if report["relationship_status"] != "OBSERVED":
|
||||||
|
task_service.fail_batch(session, batch, "TYPE_UNRESOLVED")
|
||||||
|
else:
|
||||||
|
session.commit()
|
||||||
|
else:
|
||||||
|
TPlusSyncService(source, session).execute_claimed(batch)
|
||||||
|
except BaseException as exc:
|
||||||
|
session.rollback()
|
||||||
|
task_service.fail_batch(
|
||||||
|
session,
|
||||||
|
batch,
|
||||||
|
"INTERRUPTED"
|
||||||
|
if isinstance(exc, (KeyboardInterrupt, SystemExit))
|
||||||
|
else "EXECUTION_ERROR",
|
||||||
|
)
|
||||||
|
if isinstance(exc, (KeyboardInterrupt, SystemExit)):
|
||||||
|
raise
|
||||||
@@ -38,6 +38,11 @@ dependencies = [
|
|||||||
"python-multipart>=0.0.32",
|
"python-multipart>=0.0.32",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
tplus = [
|
||||||
|
"pyodbc>=5.2,<6",
|
||||||
|
]
|
||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
dev = [
|
dev = [
|
||||||
"pytest>=8.3",
|
"pytest>=8.3",
|
||||||
|
|||||||
+25
-1
@@ -1,6 +1,30 @@
|
|||||||
# 后端测试
|
# 后端测试
|
||||||
|
|
||||||
> AI摘要:本项目后端的测试用例清单与运行记录。按「基础设施层随实现即时测、业务模块整模块完成时统一测」分层组织。当前 697 个用例全绿(2026-10-16,M3 增量批次后)。
|
> AI 摘要:2026-10-28 的 P0.5 连接修复已通过 19 项定点测试和 1001 项完整回归;使用本轮独立 MySQL/Redis 容器,未连接客户 T+ 源库。真实备份源 10.50.1600 验收仍未执行。
|
||||||
|
|
||||||
|
## 连接策略修复核验(2026-10-28)
|
||||||
|
|
||||||
|
- Ruff(含兼容性脚本)通过;定点命令 `uv run pytest tests/unit/test_tplus_config.py tests/unit/test_tplus_connection_file.py tests/unit/test_tplus_compatibility_script.py -q`:19 passed。
|
||||||
|
- 新增覆盖:数据库名/用户名/密码中的分号、花括号和中文;默认加密与显式回退;脚本复用连接文件校验;10.50.1600 与其他补丁、2022 版本区分;连接失败不自动降级且报告不带驱动原始异常。
|
||||||
|
- 完整回归:1001 passed,1 warning,869.47 秒。使用已安装的 `mysql:8.0`、`redis:7-alpine` 镜像启动独立容器、随机宿主端口和命名卷,以 `db/init/init-dev.sql` 初始化;未连接现有开发业务库或 T+ 源库。
|
||||||
|
- 完整测试入口为 `uv run python ../.ai/run_isolated_regression.py`,内部调用 `pytest.main(["-q", ...])`,不筛选或跳过用例。运行期配置指向临时隔离实例;单测的 `_env_file=None` 仍保持默认值测试语义。结束后已清理本轮容器、卷与临时凭据配置。
|
||||||
|
- 兼容性脚本、worker 的 `--help` 可运行;保留现有 Starlette TestClient 弃用提醒。本轮未改前端,未重复前端构建。
|
||||||
|
- 版本判断与失败分支是单元模拟结果,不能据此宣称真实 2008 R2 的驱动、TLS、断连和权限已验收。下方 2026-10-27 的结果保留为历史记录。
|
||||||
|
|
||||||
|
## T+ 本轮核验(2026-10-27)
|
||||||
|
|
||||||
|
- 完整回归在独立 MySQL `payroll_tplus_validation_20261027`、Redis 15 库运行:984 passed、2 failed。两项失败均为配置单测预期默认 Redis 0 库,而进程继承了集成用的 `REDIS_DB=15`;没有修改默认配置或削弱断言。
|
||||||
|
- 后续将单元测试放在不设置上述集成覆盖值的独立进程中运行:602 passed。最终管理 API、真实并发和结构一致性定点测试:18 passed,包含新增加的配置关闭及创建/重试审计。
|
||||||
|
- 真实并发:相同请求只生成一个批次;不同键同时重试同父批次只有一个成功,无死锁;执行锁跨 ORM commit 仍有效,退出后可再获取。
|
||||||
|
- V15 在隔离库 downgrade 到 V14,再 upgrade 到 head 通过;当前 head `e5f607a8b9c0`。模型、Alembic、留存 SQL 和初始化脚本的结构检查通过;此前 V1–V14 留存 SQL 已从空库执行通过。
|
||||||
|
- 人员重读 847 条,新增 0、重复跳过 847、失败 0;关联报告覆盖 103,318 条销货类收款明细,50,044 条有明确订单关联,六类业务表行数不变。独立证据位于 D 盘 reports/management_verification_2026-10-27.json。
|
||||||
|
- Chrome 真实接口检查通过:排队、受控失败、异常查看、页面重试、人员去重、关联检查、筛选、普通用户拒绝访问,以及 320/768/1024/1440 宽度。无页面运行错误;临时账号和批次已清理。
|
||||||
|
- 加载、空态、配置关闭、概况失败另外使用浏览器响应模拟验证,不能与真实源链路混记。证据位于 .ai/archive/ai-aux/preview/tplus/。
|
||||||
|
- 320 像素主内容可操作,现有全局顶栏仍有文字挤压;未扩大本轮范围修改共享布局。宽表保留横向滚动,截图和限制见同步管理验收报告。
|
||||||
|
- 前端类型检查、生产构建和 Ruff 通过;构建保留既有的大分块提醒,测试有现存 Starlette TestClient 弃用提醒。
|
||||||
|
- 源查询保持参数化与旧版语法,时间增量和业务表写入均关闭。合法 Windows/SQL Server 2008 R2 安装介质仍未取得,不能把 2022 的结果标记为客户版本兼容。
|
||||||
|
|
||||||
|
单元测试与集成测试建议分独立终端运行:单元测试不设置集成覆盖值;集成测试使用隔离 MySQL 和 Redis。通用集成夹具会清理数据,不能指向开发或客户业务库。Windows 可设置 `PYTEST_DEBUG_TEMPROOT` 到项目 D 盘的 ai-aux/pytest-tmp,避免不同权限令牌共享系统临时目录。
|
||||||
|
|
||||||
## 一、测试约定
|
## 一、测试约定
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,36 @@ import pytest
|
|||||||
from fastapi.testclient import TestClient
|
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")
|
@pytest.fixture(scope="session")
|
||||||
def client() -> TestClient:
|
def client() -> TestClient:
|
||||||
"""构造应用测试客户端(会话级复用,避免重复构建应用)。
|
"""构造应用测试客户端(会话级复用,避免重复构建应用)。
|
||||||
|
|||||||
@@ -324,3 +324,36 @@ def normal(make_user, auth_headers) -> dict:
|
|||||||
user = make_user([ROLE_USER])
|
user = make_user([ROLE_USER])
|
||||||
user["headers"] = auth_headers(user)
|
user["headers"] = auth_headers(user)
|
||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def tplus_tasks(monkeypatch):
|
||||||
|
"""隔离测试任务,仅清理此夹具创建的批次和异常。"""
|
||||||
|
from app.core.config import Settings
|
||||||
|
from app.models import TPlusSyncBatch, TPlusSyncError
|
||||||
|
|
||||||
|
settings = Settings(
|
||||||
|
_env_file=None,
|
||||||
|
app_secret_key="x" * 32,
|
||||||
|
t_plus_enabled=True,
|
||||||
|
t_plus_database="unit_tplus",
|
||||||
|
t_plus_user="reader",
|
||||||
|
t_plus_odbc_driver="unit",
|
||||||
|
)
|
||||||
|
monkeypatch.setattr("app.services.tplus.task_service.get_settings", lambda: settings)
|
||||||
|
keys = []
|
||||||
|
|
||||||
|
def payload():
|
||||||
|
key = str(uuid.uuid4())
|
||||||
|
keys.append(key)
|
||||||
|
return {"task_type": "READ", "entity_types": ["personnel"], "request_key": key}
|
||||||
|
|
||||||
|
yield payload
|
||||||
|
with get_session_factory()() as db:
|
||||||
|
numbers = list(
|
||||||
|
db.scalars(select(TPlusSyncBatch.batch_no).where(TPlusSyncBatch.request_key.in_(keys)))
|
||||||
|
)
|
||||||
|
if numbers:
|
||||||
|
db.execute(delete(TPlusSyncError).where(TPlusSyncError.batch_no.in_(numbers)))
|
||||||
|
db.execute(delete(TPlusSyncBatch).where(TPlusSyncBatch.batch_no.in_(numbers)))
|
||||||
|
db.commit()
|
||||||
@@ -487,8 +487,18 @@ class TestManagerAggregate:
|
|||||||
assert len([i for i in out["items"] if i["calc_type"] == 2]) == 1
|
assert len([i for i in out["items"] if i["calc_type"] == 2]) == 1
|
||||||
assert len([i for i in out["items"] if i["calc_type"] == 1]) == 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 月结。"""
|
"""取参锚点=所属月月末: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(
|
resp = client.post(
|
||||||
"/api/v1/system/params/changes",
|
"/api/v1/system/params/changes",
|
||||||
json={
|
json={
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
"""管理员可管理原始读取任务;权限、幂等和异常脱敏由真实接口验证。"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.core.db import get_session_factory
|
||||||
|
from app.models import TPlusSyncBatch, TPlusSyncError
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.integration
|
||||||
|
BASE = "/api/v1/system/tplus"
|
||||||
|
|
||||||
|
|
||||||
|
def test_未登录和普通用户不能访问管理(client, admin, normal, tplus_tasks):
|
||||||
|
assert client.get(BASE + "/overview").status_code == 401
|
||||||
|
assert client.get(BASE + "/overview", headers=normal["headers"]).status_code == 403
|
||||||
|
assert (
|
||||||
|
client.post(BASE + "/batches", json=tplus_tasks(), headers=normal["headers"]).status_code
|
||||||
|
== 403
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_创建重复请求返回同一批次并可分页查看(client, admin, tplus_tasks):
|
||||||
|
payload = tplus_tasks()
|
||||||
|
first = client.post(BASE + "/batches", json=payload, headers=admin["headers"])
|
||||||
|
assert first.status_code == 200
|
||||||
|
second = client.post(BASE + "/batches", json=payload, headers=admin["headers"])
|
||||||
|
assert second.status_code == 200
|
||||||
|
number = first.json()["data"]["batch_no"]
|
||||||
|
assert second.json()["data"]["batch_no"] == number
|
||||||
|
page = client.get(
|
||||||
|
BASE + "/batches", params={"pageSize": 1, "status": "QUEUED"}, headers=admin["headers"]
|
||||||
|
)
|
||||||
|
assert page.status_code == 200 and page.json()["data"]["pageSize"] == 1
|
||||||
|
detail = client.get(BASE + "/batches/" + number, headers=admin["headers"])
|
||||||
|
assert detail.status_code == 200
|
||||||
|
assert detail.json()["data"]["status"] == "QUEUED"
|
||||||
|
|
||||||
|
|
||||||
|
def test_异常返回固定说明不泄漏数据库参数(client, admin, tplus_tasks):
|
||||||
|
response = client.post(BASE + "/batches", json=tplus_tasks(), headers=admin["headers"])
|
||||||
|
assert response.status_code == 200
|
||||||
|
number = response.json()["data"]["batch_no"]
|
||||||
|
with get_session_factory()() as db:
|
||||||
|
batch = db.scalar(select(TPlusSyncBatch).where(TPlusSyncBatch.batch_no == number))
|
||||||
|
batch.status, batch.error_message = "FAILED", "password=private-value phone=13800000000"
|
||||||
|
db.add(
|
||||||
|
TPlusSyncError(
|
||||||
|
batch_no=number,
|
||||||
|
entity_type="personnel",
|
||||||
|
source_table="AA_Person",
|
||||||
|
source_id="12",
|
||||||
|
error_code="READ_ERROR",
|
||||||
|
error_message=batch.error_message,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
for path in (f"/batches/{number}", f"/batches/{number}/errors"):
|
||||||
|
result = client.get(BASE + path, headers=admin["headers"])
|
||||||
|
assert result.status_code == 200
|
||||||
|
assert "private-value" not in result.text and "13800000000" not in result.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_不支持的实体和不存在的批次有明确错误(client, admin, tplus_tasks):
|
||||||
|
payload = tplus_tasks()
|
||||||
|
payload["entity_types"] = ["users;drop"]
|
||||||
|
assert client.post(BASE + "/batches", json=payload, headers=admin["headers"]).status_code == 400
|
||||||
|
assert client.get(BASE + "/batches/TPmissing", headers=admin["headers"]).status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_配置关闭时可查询历史但不能排队(client, admin, tplus_tasks, monkeypatch):
|
||||||
|
from app.services.tplus import task_service
|
||||||
|
|
||||||
|
settings = task_service.get_settings().model_copy(update={"t_plus_enabled": False})
|
||||||
|
monkeypatch.setattr(task_service, "get_settings", lambda: settings)
|
||||||
|
response = client.get(BASE + "/overview", headers=admin["headers"])
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["data"]["is_configured"] is False
|
||||||
|
assert response.json()["data"]["business_import_enabled"] is False
|
||||||
|
assert response.json()["data"]["compatibility_status"] == "PENDING"
|
||||||
|
assert (
|
||||||
|
client.post(BASE + "/batches", json=tplus_tasks(), headers=admin["headers"]).status_code
|
||||||
|
== 400
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_失败批次重试保留异常并返回新批次(client, admin, tplus_tasks):
|
||||||
|
from app.models import OperationLog
|
||||||
|
|
||||||
|
parent = client.post(BASE + "/batches", json=tplus_tasks(), headers=admin["headers"]).json()[
|
||||||
|
"data"
|
||||||
|
]
|
||||||
|
number = parent["batch_no"]
|
||||||
|
with get_session_factory()() as db:
|
||||||
|
batch = db.scalar(select(TPlusSyncBatch).where(TPlusSyncBatch.batch_no == number))
|
||||||
|
batch.status, batch.failed_record_count = "FAILED", 1
|
||||||
|
db.add(
|
||||||
|
TPlusSyncError(
|
||||||
|
batch_no=number,
|
||||||
|
entity_type="personnel",
|
||||||
|
source_table="AA_Person",
|
||||||
|
source_id="1",
|
||||||
|
error_code="READ_ERROR",
|
||||||
|
error_message="读取失败",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
request = {"request_key": tplus_tasks()["request_key"]}
|
||||||
|
first = client.post(BASE + f"/batches/{number}/retry", json=request, headers=admin["headers"])
|
||||||
|
replay = client.post(BASE + f"/batches/{number}/retry", json=request, headers=admin["headers"])
|
||||||
|
assert first.status_code == replay.status_code == 200
|
||||||
|
child = first.json()["data"]
|
||||||
|
assert child["parent_batch_no"] == number and child["batch_no"] != number
|
||||||
|
assert replay.json()["data"]["batch_no"] == child["batch_no"]
|
||||||
|
detail = client.get(BASE + f"/batches/{number}", headers=admin["headers"]).json()["data"]
|
||||||
|
assert detail["can_retry"] is False
|
||||||
|
with get_session_factory()() as db:
|
||||||
|
assert (
|
||||||
|
db.scalar(select(TPlusSyncError.retry_count).where(TPlusSyncError.batch_no == number))
|
||||||
|
== 1
|
||||||
|
)
|
||||||
|
assert db.scalar(
|
||||||
|
select(OperationLog.id)
|
||||||
|
.where(
|
||||||
|
OperationLog.user_id == admin["id"],
|
||||||
|
OperationLog.module == "T+数据同步",
|
||||||
|
OperationLog.action == "重试读取任务",
|
||||||
|
)
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
@@ -13,7 +13,12 @@ VALID_SECRET = "x" * 32
|
|||||||
|
|
||||||
def _settings(**overrides: object) -> Settings:
|
def _settings(**overrides: object) -> Settings:
|
||||||
"""构造一个不读取 .env 的配置实例。"""
|
"""构造一个不读取 .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)
|
params.update(overrides)
|
||||||
return Settings(**params) # type: ignore[arg-type]
|
return Settings(**params) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
|||||||
@@ -44,6 +44,10 @@ EXPECTED_TABLES = {
|
|||||||
"monthly_settlements",
|
"monthly_settlements",
|
||||||
"settlement_items",
|
"settlement_items",
|
||||||
"adjustment_orders",
|
"adjustment_orders",
|
||||||
|
"tplus_sync_batches",
|
||||||
|
"tplus_sync_errors",
|
||||||
|
"tplus_sync_records",
|
||||||
|
"tplus_sync_watermarks",
|
||||||
}
|
}
|
||||||
|
|
||||||
# 单复数同形的集合名词:本身即复数语义,不受「以 s 结尾」检查约束
|
# 单复数同形的集合名词:本身即复数语义,不受「以 s 结尾」检查约束
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
"""兼容性脚本与应用使用相同连接策略,失败报告不泄露连接数据。"""
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from datetime import datetime
|
||||||
|
from decimal import Decimal
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.core.config import Settings
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def script(monkeypatch):
|
||||||
|
# 纯单元测试不需要安装 ODBC 驱动或连接源库。
|
||||||
|
monkeypatch.setitem(sys.modules, "pyodbc", MagicMock())
|
||||||
|
path = Path(__file__).resolve().parents[3] / "scripts/tplus/check_compatibility.py"
|
||||||
|
spec = importlib.util.spec_from_file_location("compatibility_script", path)
|
||||||
|
module = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(module)
|
||||||
|
settings = Settings(_env_file=None, app_secret_key="x" * 32, t_plus_encrypt=True)
|
||||||
|
monkeypatch.setattr(module, "get_settings", lambda: settings)
|
||||||
|
return module
|
||||||
|
|
||||||
|
|
||||||
|
def test_脚本复用连接文件校验与加密设置(script, tmp_path):
|
||||||
|
path = tmp_path / "reader.json"
|
||||||
|
payload = {
|
||||||
|
"host": "localhost",
|
||||||
|
"port": 1433,
|
||||||
|
"database": "data;x}",
|
||||||
|
"user": "reader;other}",
|
||||||
|
"password": "test-only;x}",
|
||||||
|
"driver": "TestDriver",
|
||||||
|
}
|
||||||
|
path.write_text(json.dumps(payload), encoding="utf-8")
|
||||||
|
resolved = script.compatibility_settings(path)
|
||||||
|
assert resolved.t_plus_database_url.database == payload["database"]
|
||||||
|
assert resolved.t_plus_database_url.username == payload["user"]
|
||||||
|
assert resolved.t_plus_database_url.query["Encrypt"] == "yes"
|
||||||
|
assert script.get_settings().t_plus_enabled is False
|
||||||
|
payload["user"] = "sa"
|
||||||
|
path.write_text(json.dumps(payload), encoding="utf-8")
|
||||||
|
with pytest.raises(RuntimeError, match="专用只读"):
|
||||||
|
script.compatibility_settings(path)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"version,passed",
|
||||||
|
[
|
||||||
|
("10.50.1600", True),
|
||||||
|
("10.50.1600.0", True),
|
||||||
|
("10.50.6000", False),
|
||||||
|
("16.0.4165.4", False),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_同主版本或2022副本不能冒充备份源同版本(script, version, passed):
|
||||||
|
connection = MagicMock()
|
||||||
|
cursor = connection.cursor.return_value
|
||||||
|
cursor.execute.return_value.fetchone.side_effect = [
|
||||||
|
(version,),
|
||||||
|
("中文核验", Decimal("12345.6789"), datetime(2008, 1, 2, 3, 4, 5)),
|
||||||
|
(0, 0, 0),
|
||||||
|
]
|
||||||
|
connection.getinfo.return_value = "test-driver"
|
||||||
|
report = script._inspect_connection(connection, "10.50.1600")
|
||||||
|
assert report["compatible"] is passed
|
||||||
|
cursor.close.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
def test_连接失败不降级且报告不带驱动异常原文(script, tmp_path, monkeypatch, capsys):
|
||||||
|
report_path = tmp_path / "report.json"
|
||||||
|
monkeypatch.setattr(
|
||||||
|
sys, "argv", ["check", "--connection-file", "unused.json", "--report", str(report_path)]
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(script, "compatibility_settings", lambda path: script.get_settings())
|
||||||
|
engine = MagicMock()
|
||||||
|
engine.raw_connection.side_effect = RuntimeError("PWD=secret-test-only;手机号")
|
||||||
|
create = MagicMock(return_value=engine)
|
||||||
|
monkeypatch.setattr(script, "create_engine", create)
|
||||||
|
assert script.main() == 2
|
||||||
|
create.assert_called_once()
|
||||||
|
engine.raw_connection.assert_called_once()
|
||||||
|
engine.dispose.assert_called_once()
|
||||||
|
assert json.loads(report_path.read_text(encoding="utf-8")) == {
|
||||||
|
"compatible": False,
|
||||||
|
"error_type": "RuntimeError",
|
||||||
|
}
|
||||||
|
assert "secret-test-only" not in capsys.readouterr().out
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
"""T+ 配置和读取适配层的纯单元测试。"""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy.dialects.mssql.pyodbc import MSDialect_pyodbc
|
||||||
|
|
||||||
|
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加默认加密且允许显式隔离环境回退(monkeypatch) -> None:
|
||||||
|
monkeypatch.delenv("T_PLUS_ENCRYPT", raising=False)
|
||||||
|
monkeypatch.delenv("T_PLUS_TRUST_SERVER_CERTIFICATE", raising=False)
|
||||||
|
assert _settings().t_plus_database_url.query["Encrypt"] == "yes"
|
||||||
|
assert _settings().t_plus_database_url.query["TrustServerCertificate"] == "yes"
|
||||||
|
url = _settings(t_plus_encrypt=False, t_plus_trust_server_certificate=False).t_plus_database_url
|
||||||
|
assert url.query["Encrypt"] == "no"
|
||||||
|
assert url.query["TrustServerCertificate"] == "no"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("value", ["a;b", "a}b", "{prefix", "中文;UID=other}x"])
|
||||||
|
def test_ODBC特殊字符保持字段边界且不泄露密码(value) -> None:
|
||||||
|
settings = _settings(
|
||||||
|
t_plus_odbc_driver="Driver}Name",
|
||||||
|
t_plus_database=value,
|
||||||
|
t_plus_user=value,
|
||||||
|
t_plus_password=value,
|
||||||
|
t_plus_encrypt=True,
|
||||||
|
)
|
||||||
|
url = settings.t_plus_database_url
|
||||||
|
args, _ = MSDialect_pyodbc().create_connect_args(url)
|
||||||
|
escaped = "{" + value.replace("}", "}}") + "}"
|
||||||
|
assert f"Database={escaped};UID={escaped};PWD={escaped};" in args[0]
|
||||||
|
assert "DRIVER={Driver}}Name}" in args[0]
|
||||||
|
assert ";Encrypt=yes;" in args[0]
|
||||||
|
assert url.render_as_string().split("@", 1)[0].endswith(":***")
|
||||||
|
|
||||||
|
|
||||||
|
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")
|
||||||
@@ -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)
|
||||||
@@ -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()
|
||||||
@@ -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"]
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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()))
|
||||||
@@ -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
|
||||||
Generated
+408
-384
File diff suppressed because it is too large.
Load diff
+95
-1
@@ -16,6 +16,10 @@
|
|||||||
SET NAMES utf8mb4;
|
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 `performance_ledgers`;
|
||||||
DROP TABLE IF EXISTS `settlement_items`;
|
DROP TABLE IF EXISTS `settlement_items`;
|
||||||
DROP TABLE IF EXISTS `adjustment_orders`;
|
DROP TABLE IF EXISTS `adjustment_orders`;
|
||||||
@@ -282,6 +286,7 @@ CREATE TABLE personnel (
|
|||||||
t_plus_id VARCHAR(64) COMMENT 'T+ 系统内部标识',
|
t_plus_id VARCHAR(64) COMMENT 'T+ 系统内部标识',
|
||||||
sync_batch_no VARCHAR(32) COMMENT '同步批次号(同步模块写入)',
|
sync_batch_no VARCHAR(32) COMMENT '同步批次号(同步模块写入)',
|
||||||
synced_at DATETIME COMMENT '最近同步时间',
|
synced_at DATETIME COMMENT '最近同步时间',
|
||||||
|
source_updated_at DATETIME COMMENT 'T+ 源数据更新时间(未确认时可为空)',
|
||||||
CONSTRAINT pk_personnel PRIMARY KEY (id),
|
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_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
|
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+ 系统内部标识',
|
t_plus_id VARCHAR(64) COMMENT 'T+ 系统内部标识',
|
||||||
sync_batch_no VARCHAR(32) COMMENT '同步批次号(同步模块写入)',
|
sync_batch_no VARCHAR(32) COMMENT '同步批次号(同步模块写入)',
|
||||||
synced_at DATETIME COMMENT '最近同步时间',
|
synced_at DATETIME COMMENT '最近同步时间',
|
||||||
|
source_updated_at DATETIME COMMENT 'T+ 源数据更新时间(未确认时可为空)',
|
||||||
CONSTRAINT pk_products PRIMARY KEY (id),
|
CONSTRAINT pk_products PRIMARY KEY (id),
|
||||||
CONSTRAINT uk_products_name_spec UNIQUE (product_name, spec_model)
|
CONSTRAINT uk_products_name_spec UNIQUE (product_name, spec_model)
|
||||||
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='产品表(类别锚定折算阈值参数;T+同步不覆盖产品类别)' COLLATE utf8mb4_general_ci;
|
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='产品表(类别锚定折算阈值参数;T+同步不覆盖产品类别)' COLLATE utf8mb4_general_ci;
|
||||||
@@ -374,6 +380,7 @@ CREATE TABLE customers (
|
|||||||
t_plus_id VARCHAR(64) COMMENT 'T+ 系统内部标识',
|
t_plus_id VARCHAR(64) COMMENT 'T+ 系统内部标识',
|
||||||
sync_batch_no VARCHAR(32) COMMENT '同步批次号(同步模块写入)',
|
sync_batch_no VARCHAR(32) COMMENT '同步批次号(同步模块写入)',
|
||||||
synced_at DATETIME COMMENT '最近同步时间',
|
synced_at DATETIME COMMENT '最近同步时间',
|
||||||
|
source_updated_at DATETIME COMMENT 'T+ 源数据更新时间(未确认时可为空)',
|
||||||
CONSTRAINT pk_customers PRIMARY KEY (id),
|
CONSTRAINT pk_customers PRIMARY KEY (id),
|
||||||
CONSTRAINT uk_customers_company_owner UNIQUE (company_name, owner_name),
|
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
|
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+ 系统内部标识',
|
t_plus_id VARCHAR(64) COMMENT 'T+ 系统内部标识',
|
||||||
sync_batch_no VARCHAR(32) COMMENT '同步批次号(同步模块写入)',
|
sync_batch_no VARCHAR(32) COMMENT '同步批次号(同步模块写入)',
|
||||||
synced_at DATETIME COMMENT '最近同步时间',
|
synced_at DATETIME COMMENT '最近同步时间',
|
||||||
|
source_updated_at DATETIME COMMENT 'T+ 源数据更新时间(未确认时可为空)',
|
||||||
CONSTRAINT pk_businesses PRIMARY KEY (id),
|
CONSTRAINT pk_businesses PRIMARY KEY (id),
|
||||||
CONSTRAINT uk_businesses_business_no UNIQUE (business_no),
|
CONSTRAINT uk_businesses_business_no UNIQUE (business_no),
|
||||||
CONSTRAINT fk_businesses_customer_id FOREIGN KEY(customer_id) REFERENCES customers (id) ON DELETE RESTRICT,
|
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+ 系统内部标识',
|
t_plus_id VARCHAR(64) COMMENT 'T+ 系统内部标识',
|
||||||
sync_batch_no VARCHAR(32) COMMENT '同步批次号(同步模块写入)',
|
sync_batch_no VARCHAR(32) COMMENT '同步批次号(同步模块写入)',
|
||||||
synced_at DATETIME COMMENT '最近同步时间',
|
synced_at DATETIME COMMENT '最近同步时间',
|
||||||
|
source_updated_at DATETIME COMMENT 'T+ 源数据更新时间(未确认时可为空)',
|
||||||
CONSTRAINT pk_shipments PRIMARY KEY (id),
|
CONSTRAINT pk_shipments PRIMARY KEY (id),
|
||||||
CONSTRAINT uk_shipments_shipment_no UNIQUE (shipment_no),
|
CONSTRAINT uk_shipments_shipment_no UNIQUE (shipment_no),
|
||||||
CONSTRAINT fk_shipments_business_id FOREIGN KEY(business_id) REFERENCES businesses (id) ON DELETE RESTRICT
|
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+ 系统内部标识',
|
t_plus_id VARCHAR(64) COMMENT 'T+ 系统内部标识',
|
||||||
sync_batch_no VARCHAR(32) COMMENT '同步批次号(同步模块写入)',
|
sync_batch_no VARCHAR(32) COMMENT '同步批次号(同步模块写入)',
|
||||||
synced_at DATETIME COMMENT '最近同步时间',
|
synced_at DATETIME COMMENT '最近同步时间',
|
||||||
|
source_updated_at DATETIME COMMENT 'T+ 源数据更新时间(未确认时可为空)',
|
||||||
CONSTRAINT pk_receipts PRIMARY KEY (id),
|
CONSTRAINT pk_receipts PRIMARY KEY (id),
|
||||||
CONSTRAINT uk_receipts_receipt_batch_no UNIQUE (receipt_batch_no),
|
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,
|
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);
|
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调整单';
|
ALTER TABLE approval_orders MODIFY order_type TINYINT NOT NULL COMMENT '审批对象类型:1二次分配方案 2月度结算 3调整单';
|
||||||
|
|
||||||
-- 公司信息单行(system_settings 恒为一行,id=1 由迁移保底)
|
-- 公司信息单行(system_settings 恒为一行,id=1 由迁移保底)
|
||||||
@@ -893,4 +987,4 @@ CREATE TABLE alembic_version (
|
|||||||
CONSTRAINT alembic_version_pkc PRIMARY KEY (version_num)
|
CONSTRAINT alembic_version_pkc PRIMARY KEY (version_num)
|
||||||
)CHARSET=utf8mb4 ENGINE=InnoDB COLLATE utf8mb4_general_ci;
|
)CHARSET=utf8mb4 ENGINE=InnoDB COLLATE utf8mb4_general_ci;
|
||||||
|
|
||||||
INSERT INTO alembic_version (version_num) VALUES ('c3a9e1d7b2f4');
|
INSERT INTO alembic_version (version_num) VALUES ('e5f607a8b9c0');
|
||||||
+3
-1
@@ -1,4 +1,4 @@
|
|||||||
-- V10: 客户评级落库(2026-10-23)
|
-- V13: 客户评级落库(2026-10-23,原文件误标为 V10)
|
||||||
-- 评级计算器从独立工具页并入客户管理(行内弹窗),评分保存为客户档案属性;
|
-- 评级计算器从独立工具页并入客户管理(行内弹窗),评分保存为客户档案属性;
|
||||||
-- 两列可空(null=未评分,存量客户无需回填),人工维护字段,T+ 同步不覆盖。
|
-- 两列可空(null=未评分,存量客户无需回填),人工维护字段,T+ 同步不覆盖。
|
||||||
-- 对应迁移:alembic/versions/c3a9e1d7b2f4_v13_客户评级落库.py
|
-- 对应迁移:alembic/versions/c3a9e1d7b2f4_v13_客户评级落库.py
|
||||||
@@ -8,3 +8,5 @@ ALTER TABLE `customers`
|
|||||||
ADD COLUMN `rating_score` TINYINT COMMENT '评级总分(0-100,评级计算器保存;null=未评分;人工维护字段,T+ 同步不覆盖)' AFTER `remark`;
|
ADD COLUMN `rating_score` TINYINT COMMENT '评级总分(0-100,评级计算器保存;null=未评分;人工维护字段,T+ 同步不覆盖)' AFTER `remark`;
|
||||||
ALTER TABLE `customers`
|
ALTER TABLE `customers`
|
||||||
ADD COLUMN `rating_time` DATETIME COMMENT '最近评级时间(评级计算器保存评分时刷新;人工维护字段)' AFTER `rating_score`;
|
ADD COLUMN `rating_time` DATETIME COMMENT '最近评级时间(评级计算器保存评分时刷新;人工维护字段)' AFTER `rating_score`;
|
||||||
|
|
||||||
|
UPDATE alembic_version SET version_num='c3a9e1d7b2f4' WHERE version_num='7f4f7b918bc4';
|
||||||
@@ -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';
|
||||||
@@ -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';
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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
|
||||||
@@ -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=
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
# 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。按用户 2026-10-28 决定,先以该版本搭建开发验收环境;生产完整版本号后补,有差异时再核验(客户版 E01/E03)。
|
||||||
|
- 本地 Docker 已用 SQL Server 2022 副本完成恢复 PoC:完整 CHECKDB 通过、只读账号可用、六类查询两轮核验 933,203 行无失败。该副本只用于结构核验与开发,**不能替代同版本验收**。
|
||||||
|
- Hyper-V 虚拟机外壳已建于 `D:\work\app\tplus-vm`,处于关机、断网状态;Windows 与 SQL Server 2008 R2 安装介质尚未到位(客户版 E03)。
|
||||||
|
|
||||||
|
## 2. 环境约定
|
||||||
|
|
||||||
|
| 项目 | 约定 |
|
||||||
|
| --- | --- |
|
||||||
|
| 虚拟机目录 | `D:\work\app\tplus-vm` |
|
||||||
|
| SQL Server 数据与日志 | 2022 PoC 使用 `D:\work\app\tplus-sqlserver\data` 和 `log`;2008 R2 使用 D 盘虚拟磁盘内的来宾目录,恢复时确认实际路径 |
|
||||||
|
| 备份文件 | 现有脚本使用 `D:\work\app\tplus-sqlserver\backup`;复制到虚拟机前后核对 SHA-256 |
|
||||||
|
| 虚拟机网络 | 安装与验收期间保持仅主机或内网隔离,不接入公网 |
|
||||||
|
| 凭据 | 只读账号凭据走双方认可的私密渠道交接;不写入本文档、代码或 Git |
|
||||||
|
| 项目依赖脚本 | `scripts/tplus/`(工作区准备、备份检查、恢复、只读账号、结构盘点) |
|
||||||
|
|
||||||
|
客户版 E01 先使用本地恢复实例的连接信息和专用只读账号,E02 先用现有开发主机与网络,生产信息后补。E03 的目标版本已确定,但合法介质尚未到位,不能据此把安装或验收标为完成。
|
||||||
|
|
||||||
|
## 3. 装机步骤(依赖介质,介质到位后执行)
|
||||||
|
|
||||||
|
1. 通过 Hyper-V 管理器核对已有虚拟机与 D 盘磁盘位置,保持网络隔离。`prepare_sql2008r2_vm.ps1` 是创建脚本,已有同名虚拟机时拒绝覆盖,不用于状态检查。
|
||||||
|
2. 准备适配 SQL Server 2008 R2 的合法 Windows 介质,安装来宾系统。记录系统版本及补丁,不把来宾系统版本当作未来工资部署主机的要求。
|
||||||
|
3. 安装 SQL Server 2008 R2,基准锁定为 `10.50.1600`。排序规则从备份元数据或恢复库核验,不能凭文档默认值猜测。
|
||||||
|
4. 首轮保留该基准;如后续需要测试 SP3 等其他补丁,另记版本和结果,不覆盖基准验收记录。
|
||||||
|
5. 在运行后端/worker 的电脑上准备 ODBC 驱动。驱动、Windows 和 TLS 的实际组合须实测后记录,不能只根据驱动名称认定兼容。默认启用加密,隔离环境的显式回退步骤见本地开发手册。
|
||||||
|
|
||||||
|
## 4. 备份恢复步骤
|
||||||
|
|
||||||
|
现有备份检查、恢复、创建只读账号及盘点脚本调用 Docker 容器,使用容器内路径,仅适用于 2022 PoC。2008 R2 在虚拟机中按以下步骤操作,不直接套用容器脚本。
|
||||||
|
|
||||||
|
1. 在来宾系统中复制原始备份,核对文件哈希;由测试环境管理员通过数据库管理工具执行备份验证并读取文件列表,记录实际数据/日志逻辑名。
|
||||||
|
2. 恢复到新的测试数据库,在恢复界面为每个逻辑文件指定来宾磁盘路径;确认目标库不存在,不覆盖其他数据库。
|
||||||
|
3. 恢复后执行完整数据库一致性检查并留档;记录版本、排序规则和恢复数据库名。
|
||||||
|
4. 创建仅用于该测试库的登录和用户,授予读取权限,核对角色、显式授权和禁止写入的结果。恢复/建账号使用管理员,应用始终使用专用只读账号。
|
||||||
|
5. 对照 `db/queries/tplus/` 的九类实体查询和关联核验查询盘点结构、行数及汇总金额;保留差异原因。报告不包含凭据或个人明细。
|
||||||
|
|
||||||
|
## 5. 同版本验收清单(逐项通过后关闭 PENDING)
|
||||||
|
|
||||||
|
| # | 验收项 | 通过标准 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 1 | 版本确认 | `SERVERPROPERTY('ProductVersion')` 为 10.50.1600(末尾 .0 等价);其他 10.50 补丁版本单独记录,不能冒充基准通过 |
|
||||||
|
| 2 | 驱动连接 | 从实际运行后端的电脑连接成功,记录驱动名称、版本与加密/证书设置 |
|
||||||
|
| 3 | 只读权限 | 用只读账号执行六类查询成功;执行任何写操作被拒绝 |
|
||||||
|
| 4 | 六类查询 | 人员、客户、产品、订单、发货、收付款九个白名单 SQL 全部返回,行数与 Docker 副本一致 |
|
||||||
|
| 5 | 中文数据 | 姓名、客户名、产品名等中文字段读取无乱码(只读账号仅验证读取) |
|
||||||
|
| 6 | 日期字段 | 凭证日期、审核日期、到账日期等候选字段格式与空值表现正常 |
|
||||||
|
| 7 | 金额精度 | 金额字段以 Decimal 读取,小数位与源数据一致 |
|
||||||
|
| 8 | 断连超时 | 仅在隔离测试实例模拟断连;批次不得误报成功,批次失败或行级错误可追溯,恢复后可重试 |
|
||||||
|
| 9 | 重复同步 | 同一备份重复 staging,相同行哈希全部去重跳过,行数不变 |
|
||||||
|
| 10 | 关联核验 | `relationship_audit` 数量统计与 Docker 副本结果一致 |
|
||||||
|
|
||||||
|
## 6. 产出与归档
|
||||||
|
|
||||||
|
- 验收通过后产出报告(含上表逐项结果与 `@@VERSION` 输出),归档至 `.ai/archive/ai-aux/`;项目文档更新结论为「2008 R2 验收通过」。
|
||||||
|
- 同步更新 `.ai/AI_CONTEXT.md` 与 `.ai/PROJECT_PROGRESS.md`。
|
||||||
|
- `check_compatibility.py` 默认核对 10.50.1600,复用应用连接策略;它的脚本检查通过不能替代本表中的断连、重复同步及完整权限验收。
|
||||||
|
- 验收中发现的字段差异回到 `docs/客户待确认事项表_2026-10-28_客户版.xlsx` 对应编号,不直接改代码。
|
||||||
|
|
||||||
|
## 7. 明确边界
|
||||||
|
|
||||||
|
- 本环境仅验收 T+ 源库只读访问;工资系统生产部署(客户版 E02)另行确认。
|
||||||
|
- 客户生产 T+ 原库不升级、不改动;2022 副本不回迁。
|
||||||
|
- 时间增量(确认单 #13)不在本手册范围内,待 rowversion 验证方案另行评审。
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# T+ 交付部署设计(2026-10-28)
|
||||||
|
|
||||||
|
状态:设计稿。本文约定后续 D1/D2 的实现方向;本轮没有安装服务、创建计划任务或发送告警。
|
||||||
|
|
||||||
|
## 托管方案
|
||||||
|
|
||||||
|
| 方案 | 适用方式 | 本项目取舍 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Windows 任务计划程序 | 启动时运行现有 worker,设置失败重启和禁止重复实例 | 首期推荐;复用现有 CLI,不增加第三方服务包装依赖 |
|
||||||
|
| NSSM | 将长驻进程包装为 Windows 服务 | 如客户要求服务管理器统一管理,再评审安装来源与版本;本轮不引入 |
|
||||||
|
| 原生 Windows 服务包装 | 开发服务生命周期、停止信号和恢复逻辑 | 当前缺少此实现,首期维护成本较高,暂不选用 |
|
||||||
|
|
||||||
|
开发环境先围绕备份源 `10.50.1600` 验证连接;工资后端继续使用现有开发主机与 Python 环境。数据库来宾 Windows 的版本不能用来推断工资部署主机的版本。生产主机、服务账号与目录由 客户版 E02 回填后确定。
|
||||||
|
|
||||||
|
后续托管任务采用启动触发,工作目录指向部署包 `backend/`,直接运行部署虚拟环境中的 `python.exe -m app.cli tplus-worker --watch`。不要在托管任务启动时执行依赖升级。账号仅授予所需目录、日志和私有配置访问权限;凭据不作为任务命令行参数。
|
||||||
|
|
||||||
|
建议恢复参数:进程异常退出后间隔 1 分钟重启,最多 3 次;同一任务已有实例时忽略重复启动;持续运行的 worker 不设通用执行时长上限。三次失败后转人工处理。以上是设计默认值,未写入客户环境。
|
||||||
|
|
||||||
|
## 每周排队与重试
|
||||||
|
|
||||||
|
拟新增单独的排队命令,调用现有 `task_service.create_task`。每周一 02:00(Asia/Shanghai)排队一次全量暂存,时间待业务方确认;尚未验收的时间增量保持关闭。worker 仍只消费队列,两者职责分开。
|
||||||
|
|
||||||
|
排队命令按“源账套 + ISO 周 + 任务类型”生成稳定 UUID 请求键,重复触发复用同一任务;排队时若同账套有活动任务,先记录本周待补状态,结束后再补排,避免队列堆积。停机后补跑一次,不逐周补发过期任务。
|
||||||
|
|
||||||
|
业务任务失败与进程退出分别处理。暂定仅对可确认的断连、连接超时重试,间隔 5 分钟、15 分钟,最多 2 次;每次调用现有重试接口,保留父批次。配置错误、版本不符、字段缺失、类型未确认及 PARTIAL 转人工处理。当前错误码可能合并为 `EXECUTION_ERROR`,D2 实现时先补可测试的错误分类,不能据此宽泛自动重试。
|
||||||
|
|
||||||
|
## 告警与观察
|
||||||
|
|
||||||
|
管理员同步页面与脱敏日志作为首期查看入口。拟对 FAILED/PARTIAL、连续 3 次进程重启失败、QUEUED 超过 15 分钟以及超过 8 天没有成功同步提示异常。运行时长阈值需在同版本首轮全量耗时实测后确定。
|
||||||
|
|
||||||
|
外部邮件或企业消息通道、接收人、网络出口待 客户版 E02 与客户答复,当前不配置也不发送。通知内容限批次号、状态、时间、错误码和处理入口,不包含凭据、手机号或原始 JSON。相同根批次和错误码 30 分钟内合并通知,恢复成功发一次恢复通知;投递失败保留记录,不改变同步批次结果。
|
||||||
|
|
||||||
|
## 实施与验收顺序
|
||||||
|
|
||||||
|
1. 先完成备份源同版本 A3;确认业务规则并完成 C2–C5 后,再启用正式托管和自动排队。开发阶段可用隔离数据库模拟调度行为。
|
||||||
|
2. D1 验证重启后启动、异常退出恢复、重复触发单实例、受限账号读配置、停止后不领取新任务、升级与回退均能重新取得锁。
|
||||||
|
3. D2 验证重复周任务、停机补跑、断连重试次数、PARTIAL 人工处理、通知去重与投递失败。不得用模拟成功代替真实同版本验收。
|
||||||
|
4. 发布时先手动单批运行并对账,再启用周任务。异常时停用排队与 worker,保留批次和原始数据,回退应用版本;不清空业务数据。
|
||||||
|
|
||||||
|
待填内容:客户版 E01 的生产连接、账号和版本差异,E02 的部署主机、网络、同步时段和通知方式,E03 的同版本介质,E04 的工资验收例单,以及 S1 的生产敏感字段策略。没有这些答复时继续文档与隔离开发验证,不虚构生产配置。
|
||||||
@@ -1,289 +0,0 @@
|
|||||||
# T+ 12.3 字段映射草案(v0.1,2026-10-20)
|
|
||||||
|
|
||||||
> 本文仅记录字段级事实与页面索引,不复制厂商文档正文。
|
|
||||||
> 基线:`docs/T+对接数据需求清单_v1.4_2026-10-19.xlsx`(75 项)+ v1.3 IT 18 项 + 《T+12.3数据字典》。
|
|
||||||
|
|
||||||
## 0. 结论摘要
|
|
||||||
|
|
||||||
- 覆盖:清单 **75/75** 项均已给出 T+ 侧候选字段与系统目标列;
|
|
||||||
- 状态分布:候选待确认 42 / 字典明确 23 / 未检出 9 / 需 IT 1;
|
|
||||||
- 数据字典无法让 IT 18 项中的任何一项完全闭环;但 9 项可改写为「候选字段选择题」,6 项部分可答,2 项纯 IT,1 项转业务。
|
|
||||||
- 收敛后:**13 项 IT 待确认 + 1 组业务裁决**(见第 12 节)。
|
|
||||||
- 关键缺口:人员工号/入职日期/试用期无字段、客户 status 缺失、回款 payment_method/confirm_status 待补、明细行无显式行号、无源端更新时间与负向事件模型。
|
|
||||||
|
|
||||||
## 1. 编制说明与证据等级
|
|
||||||
|
|
||||||
| 标记 | 含义 |
|
|
||||||
| --- | --- |
|
|
||||||
| `D` | 字典明确 |
|
|
||||||
| `D?` | 候选待确认 |
|
|
||||||
| `IT` | 需 IT |
|
|
||||||
| `B` | 需业务 |
|
|
||||||
| `S` | 系统自维护 |
|
|
||||||
| `N` | 未检出 |
|
|
||||||
|
|
||||||
- `D` 仅用于字段存在、类型明确且语义直接匹配的项;涉及「取哪个字段 / 业务口径」一律标 `D?` 或 `IT`。
|
|
||||||
- 字典来源为 T+ 12.3 SchemaDoc;同一字段可能标注 `T+12.1/12.2/12.3` 版本,映射按 12.3 可用为准。
|
|
||||||
|
|
||||||
## 2. 公共字段与同步元数据
|
|
||||||
|
|
||||||
| 序号 | 数据项 | T+ 表.字段(字典) | T+ 类型/长度 | 枚举/取值 | 系统目标表.字段 | 转换/清洗规则 | 必要性 | 确认状态 | 来源/字典页 |
|
|
||||||
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
|
|
||||||
| 1 | T+ 原始单据号 / 档案编码 | 各表 code(人员/存货/客户/订单/出库/收付款单均有) | nvarchar(30~64) | — | personnel/products/customers/businesses/shipments/receipts.source_no | TRIM;作废重开是否换号待 IT(#4) | 必须 | D? | AA_Person.code、AA_Inventory.code、AA_Partner.code、SA_SaleOrder.code、SA_SaleDelivery.code、ARAP_ReceivePayment.code |
|
|
||||||
| 2 | T+ 系统内部标识 | 各表 id(整型主键) | int(4) | — | 六表 t_plus_id | int → 字符串存储;辅助对账/改名追溯 | 建议 | D | 各表 id(主键) |
|
|
||||||
|
|
||||||
系统侧六张主表已预留 `source_no / t_plus_id / sync_batch_no / synced_at`;`source_no` 当前为普通索引,IT 确认幂等键后升级唯一约束。
|
|
||||||
|
|
||||||
## 3. 人员档案
|
|
||||||
|
|
||||||
人员主档;隶属关系需经部门树/部门负责人推断,T+ 无直属上级字段。
|
|
||||||
|
|
||||||
| 序号 | 数据项 | T+ 表.字段(字典) | T+ 类型/长度 | 枚举/取值 | 系统目标表.字段 | 转换/清洗规则 | 必要性 | 确认状态 | 来源/字典页 |
|
|
||||||
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
|
|
||||||
| 1 | 人员唯一编码(工号)★ | AA_Person.code | nvarchar(32) | — | personnel.source_no(建议补 personnel_code) | TRIM;编码优先、姓名辅助核对;是否即工号待 IT(#19) | 必须 | D? | AA_Person.code(编号) |
|
|
||||||
| 2 | 姓名 | AA_Person.name | nvarchar(200) | — | personnel.personnel_name | TRIM;仅辅助核对,不作唯一键 | 必须 | D | AA_Person.name(名称) |
|
|
||||||
| 3 | 在职状态 | AA_Person.disabled | tinyint(1) | 0 启用 / 1 停用(候选) | personnel.status(1 在职 / 0 离职) | disabled=1 → 0;无离职日期字段,离职时间需 IT 确认 | 必须 | D? | AA_Person.disabled(停用) |
|
|
||||||
| 4 | 是否试用期 | 未检出 | — | — | personnel.is_probation | T+ 人员档案无试用期字段;系统内人工维护或按入职日期规则推导 | 必须 | N | AA_Person 全表检索无 probation/试用 字段 |
|
|
||||||
| 5 | 入职日期 | 未检出(仅 madeDate 制单日期、birthday 出生日期) | — | — | personnel.hire_date | T+ 无入职日期字段;确认是否有自定义项承载,否则人工维护 | 必须 | N | AA_Person 全表检索无 hire/entry/入职 字段 |
|
|
||||||
| 6 | 联系电话 | AA_Person.mobilePhoneNo / officePhoneNo | nvarchar(20) / nvarchar(100) | — | personnel.phone | 优先 mobilePhoneNo,空则 officePhoneNo;日志脱敏 | 建议 | D | AA_Person.mobilePhoneNo(手机)、officePhoneNo(办公电话) |
|
|
||||||
| 7 | 业务员 ↔ 营销经理隶属关系 | AA_Person.iddepartment → AA_Department.idparent / iddirector | int(4) | — | personnel.manager_id | T+ 人员档案无直属上级字段;可经部门树/部门负责人推断,规则待确认;否则系统人工指定 | 必须 | D? | AA_Person.iddepartment(部门id);AA_Department.idparent(上级id)、iddirector(部门负责人) |
|
|
||||||
| 8 | 人员负责区域 / 产品辐射范围 | 未检出(可用 priuserdefnvc* 自定义项承载,需 IT 确认) | — | — | personnel_regions.region_name | 无专门字段则系统内人工维护 | 建议 | N | AA_Person 无区域/辐射范围字段 |
|
|
||||||
| 9 | 银行账户名 / 账号 / 开户行 | 未检出(AA_Partner 有银行账号字段,人员档案无) | — | — | 暂不落库 | 一期不采集;如后续代发工资再扩展 | 可选 | N | AA_Person 无银行账户字段 |
|
|
||||||
|
|
||||||
## 4. 产品档案
|
|
||||||
|
|
||||||
产品主档不含库存量与售价,数量取现存量表、价格取价目表/订单明细。
|
|
||||||
|
|
||||||
| 序号 | 数据项 | T+ 表.字段(字典) | T+ 类型/长度 | 枚举/取值 | 系统目标表.字段 | 转换/清洗规则 | 必要性 | 确认状态 | 来源/字典页 |
|
|
||||||
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
|
|
||||||
| 1 | 产品名称 | AA_Inventory.name | nvarchar(200) | — | products.product_name | TRIM;与规格型号组成系统组合唯一键 | 必须 | D | AA_Inventory.name(名称) |
|
|
||||||
| 2 | 规格型号 | AA_Inventory.specification | nvarchar(200) | — | products.spec_model | 空值归一为空串 | 必须 | D | AA_Inventory.specification(规格型号) |
|
|
||||||
| 3 | 计量单位 | AA_Inventory.idunit → 计量单位表 | int(4) | — | products.unit | 按 idunit join 单位表取名称;单位表名待索引确认 | 建议 | D? | AA_Inventory.idunit(计量单位id)、idunitgroup |
|
|
||||||
| 4 | 数量 | ST_CurrentStock.baseQuantity(现存量) | decimal(13) | — | products.stock_quantity | 按存货+仓库汇总;语义(现存量/在途/累计销量)待 IT(#12) | 必须 | D? | ST_CurrentStock.baseQuantity(主计量数量);AA_Inventory 无库存量字段 |
|
|
||||||
| 5 | 销售单价 | AA_InventoryPrice(零售价/批发价)、AA_InventoryUnitPrice.retailPrice;订单明细实际成交价 | decimal(13~17) / nvarchar(1000) 公式 | — | products.sale_price | 产品档案参考价取价目表;绩效以订单/发货明细实际单价为准 | 必须 | D? | AA_InventoryPrice、AA_InventoryUnitPrice;AA_Inventory 无销售单价字段 |
|
|
||||||
| 6 | 产品分类(类目) | AA_Inventory.idinventoryclass → AA_InventoryClass.name | int(4) | — | products.product_category | T+ 分类仅辅助判定市政水利/轨交;系统分类人工维护不覆盖 | 必须 | D? | AA_Inventory.idinventoryclass(存货分类id)、AA_InventoryClass.name |
|
|
||||||
| 7 | 计量单位换算关系(如存在) | AA_Inventory_MultiUnit / AA_InventoryUnitPrice.rateOfExchange | decimal(13) | — | 暂不实现(系统单单位存储) | 如业务有多单位换算需求,另行设计 | 可选 | D? | AA_Inventory_MultiUnit、AA_InventoryUnitPrice.rateOfExchange(换算率) |
|
|
||||||
|
|
||||||
## 5. 业务单据(销售订单)
|
|
||||||
|
|
||||||
成本额在订单明细仅有最新成本,出库明细才有成本金额;销售额需按数量×单价计算。
|
|
||||||
|
|
||||||
| 序号 | 数据项 | T+ 表.字段(字典) | T+ 类型/长度 | 枚举/取值 | 系统目标表.字段 | 转换/清洗规则 | 必要性 | 确认状态 | 来源/字典页 |
|
|
||||||
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
|
|
||||||
| 1 | 订单单号 | SA_SaleOrder.code | nvarchar(30) | — | businesses.source_no | TRIM;不得写入内部 business_no | 必须 | D | SA_SaleOrder.code(编号) |
|
|
||||||
| 2 | 业务(订单)日期 | SA_SaleOrder.voucherdate | datetime(8) | — | businesses.business_date | 截断为 DATE | 必须 | D | SA_SaleOrder.voucherdate(单据日期) |
|
|
||||||
| 3 | 客户标识 | SA_SaleOrder.idcustomer → AA_Partner.code | int(4) | — | businesses.customer_id | 按客户编码解析;回退公司名称+负责人 | 必须 | D? | SA_SaleOrder.idcustomer(客户id)、idsettlecustomer(结算客户id) |
|
|
||||||
| 4 | 负责营销人员 | SA_SaleOrder.idclerk → AA_Person.code | int(4) | — | businesses.owner_personnel_id | 按人员编码解析;新插入写 T+ 负责人、更新是否覆盖人工归属待裁决 | 必须 | D? | SA_SaleOrder.idclerk(业务员id) |
|
|
||||||
| 5 | 协助营销人员 | 未检出独立协助人字段 | — | — | businesses.assistant_personnel_id | 业务已回馈 T+ 合同只挂单一负责人;协助人系统内记录 | 建议 | N | SA_SaleOrder 无 assist/协助 字段 |
|
|
||||||
| 6 | 特殊业务标记 | SA_SaleOrder.idbusinesstype → 业务类型表 | int(4) | 业务类型枚举待字典/IT 提供 | businesses.is_special | 业务类型是否等同特殊业务待确认;无则人工标记 | 必须 | D? | SA_SaleOrder.idbusinesstype(业务类型id) |
|
|
||||||
| 7 | 明细行 · 产品名称 + 规格 | SA_SaleOrder_b.idinventory → AA_Inventory.name/specification | int(4) | — | business_items.product_name / spec_model | 冗余固化名称与规格;未建档产品允许自由录入 | 必须 | D | SA_SaleOrder_b.idinventory(存货id) |
|
|
||||||
| 8 | 明细行 · 数量 | SA_SaleOrder_b.quantity | decimal(13) | — | business_items.quantity | 主计量数量;需 >0 校验 | 必须 | D | SA_SaleOrder_b.quantity(数量)、baseQuantity(主计量数量) |
|
|
||||||
| 9 | 明细行 · 销售单价 | SA_SaleOrder_b.price / taxPrice | decimal(13) | — | business_items.sale_price | price 为不含税单价、taxPrice 为含税单价;取哪个待确认 | 必须 | D? | SA_SaleOrder_b.price(单价)、taxPrice(含税单价) |
|
|
||||||
| 10 | 明细行 · 销售额 | SA_SaleOrder_b 无「销售额」字段;由数量×单价计算,或 taxAmount−tax | decimal(13) | — | business_items.sale_amount | 系统按数量×单价固化;T+ 用于对账 | 必须 | D? | SA_SaleOrder_b.quantity、price、taxAmount、tax |
|
|
||||||
| 11 | 明细行 · 成本额 ★ | SA_SaleOrder_b.LatestCost(最新成本);SA_SaleDelivery_b.costPrice/costAmount | decimal(13) | — | business_items.cost_amount | 取订单最新成本还是出库成本、成本类型/时点待 IT(#1) | 必须 | D? | SA_SaleOrder_b.LatestCost;SA_SaleDelivery_b.costPrice/costAmount/CostPriceGetType |
|
|
||||||
| 12 | 项目 / 工程编号或名称(如存在) | SA_SaleOrder.idproject → 项目表 | int(4) | — | 建议预留 project_code(当前不落库) | 按项目查询与报表归集;是否建列待业务 | 可选 | D? | SA_SaleOrder.idproject(项目id)、SA_SaleOrder_b.idproject |
|
|
||||||
| 13 | 服务类 / 维修类及其他收入单据(如存在) | 未检索到服务/维修类独立单据表 | — | — | 暂人工录入 | 业务规则未定前不纳入自动同步 | 可选 | N | SA_* 表索引未发现服务/维修类主表 |
|
|
||||||
|
|
||||||
## 6. 发货单据(销售出库/发货)
|
|
||||||
|
|
||||||
发货日期有四个候选字段;运费未在发货单据检出。
|
|
||||||
|
|
||||||
| 序号 | 数据项 | T+ 表.字段(字典) | T+ 类型/长度 | 枚举/取值 | 系统目标表.字段 | 转换/清洗规则 | 必要性 | 确认状态 | 来源/字典页 |
|
|
||||||
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
|
|
||||||
| 1 | 发货单号 | SA_SaleDelivery.code | nvarchar(30) | — | shipments.source_no | TRIM;不得写入内部 shipment_no | 必须 | D | SA_SaleDelivery.code(编号) |
|
|
||||||
| 2 | 发货日期 ★ | SA_SaleDelivery.voucherdate / SendDate / deliveryDate / auditeddate | datetime(8) | — | shipments.ship_date | 四个日期候选:单据日期/送货日期(T+12.3)/交货日期/审核日期;取哪个待 IT(#2) | 必须 | D? | SA_SaleDelivery.voucherdate、SendDate、deliveryDate、auditeddate |
|
|
||||||
| 3 | 关联订单号 | SA_SaleDelivery.SourceVoucherCode / idsourcevouchertype;明细 saleOrderCode/saleOrderId | nvarchar(50) / int(4) | — | shipments.business_id | 按订单号解析;主关联路径待 IT(#5) | 必须 | D? | SA_SaleDelivery.SourceVoucherCode、SA_SaleDelivery_b.saleOrderCode/saleOrderId/saleOrderDetailId |
|
|
||||||
| 4 | 运费金额 | 未检出运费字段(明细有 origExpenseAmount 费用分摊金额) | decimal(13) | — | shipments.freight_amount | T+ 运费可能在费用单/其他单据,需 IT 确认;系统暂人工 | 必须 | N | SA_SaleDelivery/SA_SaleDelivery_b 无 freight/运费字段 |
|
|
||||||
| 5 | 发货总金额 | SA_SaleDelivery.amount / taxAmount | decimal(13) | — | shipments.total_amount | 系统按明细汇总,不直写;T+ 金额用于对账 | 必须 | D | SA_SaleDelivery.amount(金额)、taxAmount(含税金额) |
|
|
||||||
| 6 | 明细行 · 产品/规格/数量/单价/行金额 | SA_SaleDelivery_b.idinventory / quantity / price / taxPrice / taxAmount | decimal(13) | — | shipment_items.product_name/spec_model/quantity/sale_price/amount | 行金额由系统按数量×单价固化;T+ 用于对账 | 必须 | D | SA_SaleDelivery_b.quantity、price、taxPrice、taxAmount |
|
|
||||||
| 7 | 发货单状态 | SA_SaleDelivery.voucherState / isCancel / auditeddate | int(4) / tinyint(1) | 单据状态枚举待 IT 提供 | 不落库(v1.4 统一) | 仅推已生效单据;状态随行供同步校验 | 必须 | D | SA_SaleDelivery.voucherState(单据状态)、isCancel(状态) |
|
|
||||||
| 8 | 承运商 / 物流单号 | SA_SaleDelivery.deliveryMode(运输方式 id) | int(4) | 运输方式枚举表待确认 | 暂不落库 | 承运商/物流单号无独立字段;如需建列再扩展 | 可选 | D? | SA_SaleDelivery.deliveryMode(运输方式) |
|
|
||||||
|
|
||||||
## 7. 回款单据(收款/核销)
|
|
||||||
|
|
||||||
到账日期无银行到账日字段;收款方式取结算方式表;确认/核销状态字段存在但枚举待 IT。
|
|
||||||
|
|
||||||
| 序号 | 数据项 | T+ 表.字段(字典) | T+ 类型/长度 | 枚举/取值 | 系统目标表.字段 | 转换/清洗规则 | 必要性 | 确认状态 | 来源/字典页 |
|
|
||||||
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
|
|
||||||
| 1 | 回款单号 | ARAP_ReceivePayment.code | nvarchar(30) | — | receipts.source_no | TRIM;不得写入内部 receipt_batch_no | 必须 | D | ARAP_ReceivePayment.code(编号) |
|
|
||||||
| 2 | 到账日期 ★ | ARAP_ReceivePayment.voucherDate / auditedDate / createdTime | datetime(8) | — | receipts.receipt_date | 无银行到账日字段;取单据日期/审核日期/创建时间哪个待 IT(#3) | 必须 | D? | ARAP_ReceivePayment.voucherDate、auditedDate、createdTime |
|
|
||||||
| 3 | 回款金额 | ARAP_ReceivePayment.amount(本币)/ origAmount(原币) | decimal(13) | — | receipts.amount | 取本币金额;一期禁负数,红字规则待 #8 | 必须 | D | ARAP_ReceivePayment.amount、origAmount |
|
|
||||||
| 4 | 关联订单号 | ARAP_ReceivePayment.SaleOrderCode/SaleOrderID;明细 saleordercode/saleorderid/sourceordercode | nvarchar(50) / int(4) | — | receipts.business_id | 回款↔订单按订单号解析;回款↔发货批次系统人工指定 | 必须 | D? | ARAP_ReceivePayment.SaleOrderCode/SaleOrderID;ARAP_ReceivePayment_b.saleordercode/sourceordercode |
|
|
||||||
| 5 | 收款方式 | ARAP_ReceivePayment.IdSettleStyle → AA_SettleStyle.code/name | int(4) | 结算方式枚举表(AA_SettleStyle:code/name/停用) | receipts.payment_method(待补列) | 枚举映射;完整枚举清单需 IT 提供 | 必须 | D? | ARAP_ReceivePayment.IdSettleStyle;AA_SettleStyle |
|
|
||||||
| 6 | 以物抵顶物说明 | 收款方式(以物抵顶枚举值)+ ARAP_ReceivePayment.memo | int(4) / nvarchar(200) | 以物抵顶对应结算方式代码待 IT 提供 | receipts.is_barter / remark | 按结算方式识别;抵顶物说明暂入备注 | 建议 | D? | ARAP_ReceivePayment.IdSettleStyle、memo |
|
|
||||||
| 7 | 回款确认 / 核销状态 | ARAP_ReceivePayment.cancelState / voucherstate / auditedDate | int(4) / datetime(8) | 核销/单据状态枚举待 IT 提供 | receipts.confirm_status(待补列) | 字段存在;哪个可作为人工确认依据待 IT(#16) | 必须 | D? | ARAP_ReceivePayment.cancelState(核销状态)、voucherstate(单据状态) |
|
|
||||||
| 8 | 银行流水号 / 回单参考号 | 未检出银行流水号/回单号字段(MultiSettle.documentCode 为票据号) | — | — | 建议预留 bank_serial_no | 需 IT 确认是否有对应字段 | 建议 | N | ARAP_ReceivePayment/ARAP_ReceivePayment_MultiSettle 仅票据号 |
|
|
||||||
| 9 | 票据信息(票号 / 到期日 / 贴现说明) | ARAP_ReceivePayment_MultiSettle.documentCode / amount / idsettlestyle | nvarchar(30) / decimal(13) | 票据类型枚举待 IT | 系统暂以备注承载 | 无票据到期日字段;承兑到期核销信息需 IT 确认 | 建议 | D? | ARAP_ReceivePayment_MultiSettle.documentCode(票据号) |
|
|
||||||
| 10 | 币种 / 汇率(如存在外币业务) | ARAP_ReceivePayment.idcurrency / exchangeRate | int(4) / decimal(13) | 币种表待索引 | 一期不落库(人民币记账) | 如存在外币业务再扩展 | 可选 | D | ARAP_ReceivePayment.idcurrency、exchangeRate |
|
|
||||||
|
|
||||||
## 8. 客户档案
|
|
||||||
|
|
||||||
采用往来单位表 AA_Partner;客户编码/停用字段存在,status 列需系统补。
|
|
||||||
|
|
||||||
| 序号 | 数据项 | T+ 表.字段(字典) | T+ 类型/长度 | 枚举/取值 | 系统目标表.字段 | 转换/清洗规则 | 必要性 | 确认状态 | 来源/字典页 |
|
|
||||||
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
|
|
||||||
| 1 | 公司名称 | AA_Partner.name | nvarchar(200) | — | customers.company_name | TRIM;与负责人组成系统唯一键 | 必须 | D | AA_Partner.name(名称) |
|
|
||||||
| 2 | 负责人姓名 | AA_Partner.Contact(联系人)/ representative(法人代表) | nvarchar(50) | — | customers.owner_name | 「负责人」对应 Contact 还是 representative 待 IT/业务确认 | 必须 | D? | AA_Partner.Contact、representative |
|
|
||||||
| 3 | 客户编码 / 状态 / 启用停用时间 | AA_Partner.code / disabled / madeDate / updated | nvarchar(32) / tinyint(1) | disabled 0 启用 / 1 停用(候选) | customers.source_no / status(待补列) | 启停时间无独立列,可用 updated 近似(需 IT 确认) | 必须 | D? | AA_Partner.code、disabled、madeDate、updated |
|
|
||||||
| 4 | 联系人 / 电话 / 地址 / 行业 | AA_Partner.Contact / TelephoneNo / MobilePhone / CustomerAddress / iddistrict | nvarchar(30~200) | — | customers.contact_person / contact_phone / address / industry | 行业无直接字段;地区按 iddistrict 关联 | 可选 | D? | AA_Partner.Contact、TelephoneNo、MobilePhone、CustomerAddress、iddistrict |
|
|
||||||
| 5 | 开票信息(税号 / 开票名称 / 开票地址电话) | AA_Partner.taxRegcode / name / CustomerAddress | nvarchar(50~200) | — | 系统暂仅供展示,不落库 | 税号存在;开票地址电话需 IT 确认字段 | 可选 | D? | AA_Partner.taxRegcode(纳税号)、CustomerAddress |
|
|
||||||
|
|
||||||
## 9. 同步完整性与补充字段
|
|
||||||
|
|
||||||
时间戳与负向字段部分存在;无统一事件表、无明细行号、无手续费字段。
|
|
||||||
|
|
||||||
| 序号 | 数据项 | T+ 表.字段(字典) | T+ 类型/长度 | 枚举/取值 | 系统目标表.字段 | 转换/清洗规则 | 必要性 | 确认状态 | 来源/字典页 |
|
|
||||||
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
|
|
||||||
| 1 | 数据最后更新时间 / 单据创建时间 / 修改时间 | 各表 createdTime/createdtime、updated、madeDate、auditedDate、ts | datetime(8) / timestamp(8) | — | 建议新增 source_created_at / source_updated_at(当前无) | 本地 update_time 不可作为源端水位;增量水位字段待 IT(#9) | 必须 | D? | 各表 updated/createdTime;ts(分布时间戳) |
|
|
||||||
| 2 | 变更类型 / 单据事件:新增、修改、删除、作废、红冲、退货、冲销 | 各表 isCancel(状态)、isCounteractDelivery(冲抵销货)、returnQuntity(退货数量) | int(4) / tinyint(1) / decimal(13) | 状态/事件枚举待 IT 提供 | 建议事件表(当前无) | 无统一变更事件表;负向事实需事件模型,规则待 #8 | 必须 | D? | SA_SaleOrder.isCancel、SA_SaleDelivery.isCounteractDelivery、SA_SaleDelivery_b.returnQuntity |
|
|
||||||
| 3 | 单据状态 / 财务审核状态 / 状态变更时间 | voucherState / auditedDate / auditor / auditorid | int(4) / datetime(8) / nvarchar(50) | 单据状态枚举待 IT | 不落库(仅同步校验) | 仅推已生效单据;状态随行校验 | 必须 | D | SA_SaleOrder/SA_SaleDelivery/ARAP_ReceivePayment 的 voucherState、auditedDate |
|
|
||||||
| 4 | 明细行行号 / 行状态 / 行级变更事件 | 明细表主键 ID、code(明细编号);无显式行号字段 | int(4) / nvarchar(30) | — | 建议补 source_line_no / source_item_id | 确认 code 是否即行号;否则用 T+ 明细 ID 作行级幂等键(待 IT) | 必须 | D? | SA_SaleOrder_b.id/code、SA_SaleDelivery_b.ID/code、ARAP_ReceivePayment_b.id/detailCode |
|
|
||||||
| 5 | 税率 / 税额 / 价税合计 / 折扣或折让 | 明细表 taxRate、tax、taxAmount、origTaxAmount、discount、discountRate、discountAmount | decimal(13) | — | 待业务 #20 答复后决定是否建列 | 字段齐全;金额是否含税、折扣关联方式待业务 | 必须 | D? | SA_SaleOrder_b/SA_SaleDelivery_b 税额与折扣字段 |
|
|
||||||
| 6 | 发货客户 / 收货人 / 收货地址 / 联系电话 | SA_SaleDelivery.idcustomer / address / contactPhone / idSendPerson / idConsignor | int(4) / nvarchar(100~200) | — | 建议预留(当前无列) | 用于核对发货对象与代发情形 | 必须 | D | SA_SaleDelivery.idcustomer、address、contactPhone、idSendPerson、idConsignor |
|
|
||||||
| 7 | 发货业务员 / 发货组织 / 仓库 | SA_SaleDelivery.idclerk / iddepartment / idwarehouse | int(4) | — | 建议预留(当前无列) | 识别代发、调拨与多组织差异 | 必须 | D | SA_SaleDelivery.idclerk、iddepartment、idwarehouse |
|
|
||||||
| 8 | 发货退回 / 红字发货 / 退回数量 / 退回金额 / 原发货单关联 | SA_SaleDelivery.isCounteractDelivery、_b.returnQuntity/returnQuntity2/backSentQuantity、sourceVoucher* | tinyint(1) / decimal(13) / nvarchar(50) | 冲抵/退货枚举待 IT | 待业务 #21 答复后决定是否建列 | 原单关联字段存在;是否冲减绩效待业务 | 必须 | D? | SA_SaleDelivery.isCounteractDelivery、SA_SaleDelivery_b.returnQuntity、backSentQuantity |
|
|
||||||
| 9 | 收款客户 / 经手人 / 财务审核状态 | ARAP_ReceivePayment.idpartner / idHandler / auditor / voucherstate | int(4) / nvarchar(50) / int(4) | — | 建议预留(当前无列) | 核对实际付款主体与经办责任 | 必须 | D | ARAP_ReceivePayment.idpartner、idHandler、auditor、voucherstate |
|
|
||||||
| 10 | 收款分配明细 / 分配金额 / 未分配金额 / 分配对象 | ARAP_ReceivePayment_b(源单/订单/金额/未结算)、ARAP_StrikeBalance(核销) | decimal(13) / int(4) | — | 人工指定发货批次的辅助参考 | 分配关系仅参考;系统仍人工指定(对照表 #18) | 必须 | D? | ARAP_ReceivePayment_b.saleordercode/amount/origNoSettleAmount;ARAP_StrikeBalance |
|
|
||||||
| 11 | 预收款 / 保证金 / 代收款 / 折让 / 抵扣 | ARAP_ReceivePayment.paymentAmount/origPaymentAmount(预收/预付)、overPayment(超收转预收)、allowances(折让) | decimal(13) | — | 待业务 #22 答复后决定是否建列 | 字段存在;是否计入绩效回款待业务 | 必须 | D? | ARAP_ReceivePayment.paymentAmount、overPayment、allowances |
|
|
||||||
| 12 | 手续费 / 交易费用 / 收款净额 | 未检出手续费/交易费用字段 | — | — | 待业务 #23 答复后决定 | 需 IT 确认手续费是否在其他单据/字段 | 必须 | N | ARAP_ReceivePayment 无 fee/charge 字段 |
|
|
||||||
| 13 | 红字收款 / 收款作废 / 收款退回 / 冲销及原单关联 | ARAP_ReceivePayment.voucherstate / isReceiveFlag;ARAP_StrikeBalance 冲销 | int(4) / tinyint(1) | 作废/红字状态枚举待 IT | 待业务 #24 答复后决定 | 无红字收款专用字段;负向事实需事件模型 | 必须 | D? | ARAP_ReceivePayment.voucherstate、isReceiveFlag;ARAP_StrikeBalance |
|
|
||||||
| 14 | 客户编码 / 客户状态 / 启用停用时间 | AA_Partner.code / disabled | nvarchar(32) / tinyint(1) | disabled 0 启用 / 1 停用 | customers.source_no / status(待补列) | 同六-3;客户编码唯一性待 IT | 必须 | D | AA_Partner.code、disabled |
|
|
||||||
| 15 | 客户分类 / 渠道 / 区域 / 所属组织 | AA_Partner.idpartnerclass / customerType / iddistrict / idMarketingOrgan | int(4) | 分类/客商类型枚举待 IT | customers.customer_type(不覆盖);渠道无列 | 系统客户分类人工维护;渠道/区域作备考 | 必须 | D? | AA_Partner.idpartnerclass、customerType、iddistrict、idMarketingOrgan |
|
|
||||||
| 16 | 客户名称变更 / 编码停用合并前后的对应关系 | AA_Partner.code(终身不变)+ updated;无变更历史表(待确认) | nvarchar(32) | — | 靠 source_no 追溯同一主体 | 改名/换负责人/合并靠 code 串同一主体;变更历史需 IT 确认 | 必须 | D? | AA_Partner.code、updated、HasEverChanged |
|
|
||||||
| 17 | 人员部门 / 岗位 / 调岗时间 / 离职和组织变更时间 | AA_Person.iddepartment / position / updated;调岗历史表待确认 | int(4) / datetime(8) | — | 不落库(备考/排查) | 字段部分存在;调岗/离职时间需 IT 确认 | 必须 | D? | AA_Person.iddepartment、position、updated |
|
|
||||||
| 18 | 产品编码 / 产品状态 / 启用停用时间 | AA_Inventory.code / disabled / madeDate | nvarchar(32) / tinyint(1) | disabled 0 启用 / 1 停用 | products.source_no / status | 稳定匹配;停用后历史明细仍可回溯 | 必须 | D | AA_Inventory.code、disabled、MadeRecordDate |
|
|
||||||
| 19 | 字典和枚举取值范围 / 编码与名称对照说明 | 枚举表:AA_SettleStyle、业务类型表、状态字典(需随数据导出) | — | 枚举字典随数据提供 | 同步映射表 | 不能只给代码;需编码与名称对照 | 必须 | D? | AA_SettleStyle、AA_BusinessType 等枚举表 |
|
|
||||||
| 20 | 对账汇总信息 / 数据记录数 / 数量合计 / 金额合计 / 抽取时间 | 由 IT 提供抽取汇总(接口/视图层) | — | — | 同步日志/对账表 | 记录明细条数、金额合计、抽取时间 | 必须 | IT | 字典不提供;需对接方式确定 |
|
|
||||||
| 21 | 单据历史版本 / 修改留痕(如 T+ 提供) | 未检索到统一单据历史版本表(待 IT 确认) | — | — | 系统同步快照可比对 | 无此数据不阻塞 | 可选 | D? | 表索引未见 history 类表 |
|
|
||||||
|
|
||||||
## 10. 跨表关联与键
|
|
||||||
|
|
||||||
| 关系 | T+ 字段(字典) | 系统目标 | 状态 |
|
|
||||||
| --- | --- | --- | --- |
|
|
||||||
| 人员唯一键 | AA_Person.code | personnel.source_no(建议补 personnel_code) | D? |
|
|
||||||
| 客户唯一键 | AA_Partner.code;名称+联系人 | customers.source_no;company_name+owner_name | D? |
|
|
||||||
| 订单→发货 | SA_SaleDelivery.SourceVoucherCode / _b.saleOrderCode/saleOrderId | shipments.business_id | D? |
|
|
||||||
| 订单/发货→回款 | ARAP_ReceivePayment.SaleOrderCode/SaleOrderID;_b.saleordercode | receipts.business_id | D? |
|
|
||||||
| 回款→发货批次 | 系统人工指定(对照表 #18) | receipts.shipment_id | S |
|
|
||||||
| 明细行幂等 | 明细表 ID/code;无显式行号 | 建议补 source_line_no | D? |
|
|
||||||
|
|
||||||
## 11. 枚举与状态
|
|
||||||
|
|
||||||
| 枚举 | T+ 来源 | 备注 |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| 收款/结算方式 | AA_SettleStyle(code/name/disabled) | 完整枚举需随数据导出 |
|
|
||||||
| 单据状态 | 各单据 voucherState / isCancel | 仅同步校验,不落库 |
|
|
||||||
| 核销状态 | ARAP_ReceivePayment.cancelState | 含义待 IT |
|
|
||||||
| 业务类型 | SA_SaleOrder.idbusinesstype → 业务类型表 | 是否等同特殊业务待确认 |
|
|
||||||
| 产品分类 | AA_InventoryClass | 仅辅助系统分类人工维护 |
|
|
||||||
|
|
||||||
## 12. 系统侧目标表缺口
|
|
||||||
|
|
||||||
| 缺口 | 说明 | 建议 |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| `personnel.personnel_code` | T+ 工号/编码是否即 `source_no` 未定 | 若不等则补唯一列 |
|
|
||||||
| `customers.status` / 启停时间 | T+ 有 disabled,系统无列 | 补 status 与停用时间 |
|
|
||||||
| `receipts.payment_method` / `confirm_status` | T+ 有结算方式与核销状态 | 补两列并接枚举 |
|
|
||||||
| `source_created_at` / `source_updated_at` | 本地 update_time 不可作源端水位 | 补列或建 staging 表 |
|
|
||||||
| `source_line_no` / `source_item_id` | 明细表无行号 | 补列支持行级幂等 |
|
|
||||||
| 负向事件模型 | 无删除/作废/红冲/退货事件表 | 设计事件表或冲销单 |
|
|
||||||
| `sync_log` / 失败明细表 | 完全缺失 | 同步模块前置 |
|
|
||||||
| T+ 配置项 | `.env` 有 `T_PLUS_*`,`Settings` 未定义 | 补配置字段与校验 |
|
|
||||||
|
|
||||||
## 13. IT 待确认事项收敛(18 → 13 + 1 组业务)
|
|
||||||
|
|
||||||
| 新# | 原# | 事项 | 字典证据 | 字典可答 | 仍需确认 | 优先级 |
|
|
||||||
| --- | --- | --- | --- | --- | --- | --- |
|
|
||||||
| 1 | 1 | 成本额字段与成本口径 | SA_SaleOrder_b.LatestCost;SA_SaleDelivery_b.costPrice/costAmount/CostPriceGetType;AA_Inventory.invSCost/latestCost/avagCost | 字段存在性、类型精度、挂在订单还是出库单 | 请确认绩效成本取哪个字段(订单最新成本 / 出库成本 / 其他),取数时点与精度 | 高(阻塞核算) |
|
|
||||||
| 2 | 2+3 | 发货日与到账日口径 | 发货:SA_SaleDelivery.voucherdate/SendDate/deliveryDate/auditeddate;到账:ARAP_ReceivePayment.voucherDate/auditedDate/createdTime | 各日期字段存在性与类型 | 请分别确认发货日、到账日取哪个字段;签收日/银行到账日能否从 T+ 获取 | 高 |
|
|
||||||
| 3 | 4 | 幂等键与作废重开 | 各表 code + id 主键;SA_SaleOrder.ismodifiedcode、HasEverChanged | 编码字段与主键关系 | 各单据 code 是否唯一终身不变;作废重开是否换号;建议以 code 作 source_no | 高 |
|
|
||||||
| 4 | 5 | 三单关联与核销路径 | SA_SaleDelivery.SourceVoucherCode/idsourcevouchertype;SA_SaleDelivery_b.saleOrderCode/saleOrderId;ARAP_ReceivePayment.SaleOrderCode/SaleOrderID;ARAP_ReceivePayment_b;ARAP_StrikeBalance | 关联字段与核销表存在性 | 请确认订单→发货→回款的主关联路径(按单号还是 ID),以及分批发货/部分核销如何取 | 高 |
|
|
||||||
| 5 | 6 | 人员编码/工号与经理隶属 | AA_Person.code/name/iddepartment/isSalesman;AA_Department.idparent/iddirector | 编码/部门/负责人字段存在;无直属上级字段 | AA_Person.code 是否即工号;业务员→营销经理隶属在 T+ 如何维护(部门负责人?自定义项?) | 高 |
|
|
||||||
| 6 | 7 | 对接方式与安全 | 字典不涉及 | 无 | REST/视图/中间表/文件;只读账号与凭据、接口文档、样例数据 | 高 |
|
|
||||||
| 7 | 8 | 负数/红字/退货/冲销规则 | SA_SaleDelivery.isCounteractDelivery;SA_SaleDelivery_b.returnQuntity/backSentQuantity;ARAP_ReceivePayment.voucherstate;ARAP_StrikeBalance | 负向字段/单据形式存在性 | 负向单据的实际使用形式、原单关联字段、是否推送;绩效影响转业务 | 高 |
|
|
||||||
| 8 | 9 | 存量起始与增量水位 | 各表 updated/createdTime/madeDate/auditedDate/ts | 时间戳字段存在性 | 存量起始日期;增量用哪个时间戳(updated? ts?);时区 | 中 |
|
|
||||||
| 9 | 10+v1.4#18 | 客户主数据匹配与变更 | AA_Partner.code/name/Contact/representative/disabled/updated | 编码/名称/负责人/停用字段 | 客户唯一键;改名/换负责人/合并如何映射同一主体;负责人取 Contact 还是 representative | 中 |
|
|
||||||
| 10 | 11+16 | 收款方式与确认/核销枚举 | ARAP_ReceivePayment.IdSettleStyle;AA_SettleStyle.code/name;ARAP_ReceivePayment.cancelState/voucherstate | 枚举表与状态字段存在性 | 收款方式完整枚举与以物抵顶对应代码;核销/确认状态枚举含义及可否作为人工确认依据 | 高 |
|
|
||||||
| 11 | 12+13 | 产品数量语义与发货单价 | ST_CurrentStock.baseQuantity;AA_InventoryPrice/AA_InventoryUnitPrice;SA_SaleDelivery_b.price/taxPrice | 数量/价格字段存在性 | 产品「数量」取现存量/在途/累计销量;发货单价取订单价还是发货时价、含税与否 | 中 |
|
|
||||||
| 12 | 14+17 | 低频字段存在性:特殊业务标记/市场拓展计划 | SA_SaleOrder.idbusinesstype;AA_Person 自定义项;未检出市场拓展计划表 | 字段/表存在性 | 特殊业务是否有可识别字段;市场拓展计划 T+ 是否有表可提供;无则人工维护 | 低 |
|
|
||||||
| 13 | 15 | 同步窗口 | 字典不涉及 | 无 | 每周同步时间点,避开结账/备份;失败重试策略 | 低 |
|
|
||||||
| B1 | 18 | 业务裁决 5 项 | 税额/折扣/退货/预收/手续费字段已定位(待答复后决定是否建列) | 字段存在性 | 含税口径、退货红字冲减、预收/保证金/代收、手续费净额、负绩效表述 | 业务裁决 |
|
|
||||||
|
|
||||||
### 原 18 项对照
|
|
||||||
|
|
||||||
| 原# | 事项 | 原优先级 | 收敛去向 |
|
|
||||||
| --- | --- | --- | --- |
|
|
||||||
| 1 | 成本额取数口径 | 高(阻塞核算) | 1 |
|
|
||||||
| 2 | 发货日期口径 | 高 | 2 |
|
|
||||||
| 3 | 到账日期口径 | 高 | 2 |
|
|
||||||
| 4 | 幂等唯一键 | 高 | 3 |
|
|
||||||
| 5 | 三单关联键 | 高 | 4 |
|
|
||||||
| 6 | 人员匹配键与经理隶属(v1.2 更新) | 高 | 5 |
|
|
||||||
| 7 | 对接方式与安全 | 高 | 6 |
|
|
||||||
| 8 | 负数 / 红字 / 退货 / 冲销处理规则 | 高 | 7 |
|
|
||||||
| 9 | 存量范围与增量口径 | 中 | 8 |
|
|
||||||
| 10 | 客户档案匹配与唯一键 | 中 | 9 |
|
|
||||||
| 11 | 收款方式枚举 | 中 | 10 |
|
|
||||||
| 12 | 产品「数量」语义 | 中 | 11 |
|
|
||||||
| 13 | 发货单价口径 | 中 | 11 |
|
|
||||||
| 14 | 特殊业务标记 | 低 | 12 |
|
|
||||||
| 15 | 同步窗口 | 低 | 13 |
|
|
||||||
| 16 | 回款确认 / 核销状态 | 高 | 10 |
|
|
||||||
| 17 | 市场拓展计划数据 | 低 | 12 |
|
|
||||||
| 18 | 口径裁决 5 项(移交业务方) | 业务裁决 | B1 |
|
|
||||||
|
|
||||||
## 14. 待确认清单
|
|
||||||
|
|
||||||
**IT(13 项)**:见第 13 节新# 1–13。
|
|
||||||
|
|
||||||
**业务(1 组)**:含税口径、退货红字冲减、预收/保证金/代收、手续费净额、负绩效表述(原 #18 / 待澄清 #20–#24)。
|
|
||||||
|
|
||||||
**系统侧(不阻塞 IT)**:补 `personnel_code`、`customers.status`、`receipts.payment_method/confirm_status`、`source_updated_at`、`source_line_no`、`sync_log` 与 T+ 配置项。
|
|
||||||
|
|
||||||
## 15. 验证记录
|
|
||||||
|
|
||||||
- 解包:1,457 个 HTML、`.hhc/.hhk` 齐全、零字节 0;
|
|
||||||
- 全量解析:1,457 页、938 个 Table 类型页、**931 张表、21,019 个字段、0 失败页**;
|
|
||||||
- 编码:逐页检测,GB18030(无替换字符);
|
|
||||||
- 抽样核验:六类主表结构一致(6 列字段表),关键字段逐项比对通过;
|
|
||||||
- 输入 SHA-256:`3e8cd3146e22bdebe8bacda56b5dafbbd8be744c4279d7b2bbf3d7ecdc7e2726`;
|
|
||||||
|
|
||||||
## 附录 A:六类主表索引
|
|
||||||
|
|
||||||
| 类别 | T+ 表 | 字典页 GUID |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| 人员 | AA_Person | `fcd00c17-6778-4c6a-90ae-3f0dbaf2c102` |
|
|
||||||
| 产品 | AA_Inventory | `93b57dfa-4ca4-411d-a883-6d26c8e01787` |
|
|
||||||
| 客户 | AA_Partner | `871e93dd-c2cf-4bd2-af91-cbeb5951ac5e` |
|
|
||||||
| 业务 | SA_SaleOrder | `9a51f8a9-658a-4f7c-9372-8af2257511ee` |
|
|
||||||
| 发货 | SA_SaleDelivery | `bef98b52-c9f0-4d80-8e62-8968cced5b52` |
|
|
||||||
| 回款 | ARAP_ReceivePayment | `709de6e4-3b3d-4cf8-b5ed-5f657d42599a` |
|
|
||||||
|
|
||||||
## 附录 B:75 项覆盖矩阵
|
|
||||||
|
|
||||||
| 节 | 项数 | 已映射 |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| 公共要求 | 2 | 2 |
|
|
||||||
| 人员 | 9 | 9 |
|
|
||||||
| 产品 | 7 | 7 |
|
|
||||||
| 业务 | 13 | 13 |
|
|
||||||
| 发货 | 8 | 8 |
|
|
||||||
| 回款 | 10 | 10 |
|
|
||||||
| 客户 | 5 | 5 |
|
|
||||||
| 同步完整性 | 21 | 21 |
|
|
||||||
|
|
||||||
## 附录 C:失败页
|
|
||||||
|
|
||||||
无(`failures.csv` 为空)。
|
|
||||||
|
|
||||||
## 附录 D:复现方法
|
|
||||||
|
|
||||||
```text
|
|
||||||
1. C:/Windows/hh.exe -decompile <out> <TPlus123.chm>
|
|
||||||
2. py -3.14 -I 03_extract_fields.py --extracted <out> --out <out>
|
|
||||||
3. py -3.14 -I 08_build_md.py --out <out> --target <md>
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
*本文档由数据字典解析与人工核验生成,字段候选未经 IT 确认前不得视为同步契约。*
|
|
||||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,148 @@
|
|||||||
|
# T+ 数据导入设计:staging → 工资业务表(2026-10-28)
|
||||||
|
|
||||||
|
> 状态:设计稿,未实现。客户确认单(`docs/客户待确认事项表_2026-10-28_客户版.xlsx`)业务口径答复齐全并完成设计评审后,再实现导入。「预留参数」在本稿中表示待确认决策,不代表已建系统设置项;字段映射、流程规则、部署配置须分别回填。
|
||||||
|
|
||||||
|
## 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. 预留参数清单(等待客户答复)
|
||||||
|
|
||||||
|
本节保留旧编号供开发追溯。当前对外文件为《客户待确认事项表_2026-10-28_客户版.xlsx》,旧表已按用户决定删除;回填时按第 10 节对应关系查找,不重复询问已经确定的规则。
|
||||||
|
|
||||||
|
| 编号 | 预留参数 | 等待内容 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| #1 | 成本取值 | 订单 LatestCost 与销货 costPrice/costAmount 的取舍(含税 L1 一并确认) |
|
||||||
|
| #2 | 发货日期口径 | 凭证/发送/交货/审核日的取舍与缺失处理 |
|
||||||
|
| #3 | 到账日期口径 | 主表时间与明细 arrivalDate 的认定,预收与分次到账 |
|
||||||
|
| #4 | 无订单回款 | 补录、人工指定或不纳入 |
|
||||||
|
| #5 | 多单分摊规则 | 分摊方式与尾差对账 |
|
||||||
|
| #6 | 可计提收款范围 | 期初/预收/退款/费用单的排除与承接 |
|
||||||
|
| #7 | 有效性与核销条件 | 生效即可计提还是须核销 |
|
||||||
|
| #8 | 负向事件 | 退货/红冲的冲减月份与重复冲减防护 |
|
||||||
|
| #9 | 人员口径 | 工号确认、取数范围、经理归属(L6 经理个人户只调参数) |
|
||||||
|
| #10 | 客户口径 | 往来单位筛选、改名合并 |
|
||||||
|
| #11 | 产品口径 | 单位换算、价格类型、计提分类维护方 |
|
||||||
|
| #12 | 收款方式 | 结算方式枚举与以物抵顶差异 |
|
||||||
|
| #13 | 初始化与增量 | 起始日期、历史已结算款识别、rowversion 验证 |
|
||||||
|
| L2 | 特殊款项 | 预收、保证金、代收,与 #3/#6 一并确认 |
|
||||||
|
| L3–L5 | 二次分配三项 | 追认后回填分配标的、余额归属、协助关系 |
|
||||||
|
| L7 | 三级角色职权 | 页面验收时核对 |
|
||||||
|
| B1–B5 | 环境接入与介质 | 先用本地备份恢复环境,版本基准 10.50.1600;生产信息后补,介质仍需落实,凭据不入文档和 Git |
|
||||||
|
| S1 | 敏感字段策略 | 当前开发阶段暂接受未单独加密的风险;生产保留、保护和备份范围仍待确认 |
|
||||||
|
|
||||||
|
## 8. 字段核验备注
|
||||||
|
|
||||||
|
2026-10-28 用 T+12.3 数据字典(rar)交叉验证了 11 张目标表的关键字段:字典中文名与待确认清单的表述一致,与恢复库实测没有冲突。有两点差异,实现时需要注意:
|
||||||
|
|
||||||
|
- `SA_SaleDelivery.SendDate`(送货日期)是 T+12.3 新增字段。客户生产库版本未确认(B3),应用器读取映射字段前应先检查列是否存在,缺列时写入 `tplus_sync_errors`,不要让批次直接中断。
|
||||||
|
- `ARAP_ReceivePayment_b.arrivalDate` 在字典中是「到期日」,不是实际到账日。到账日期口径(#3)确认前,该字段只作候选留档,不参与回款天数计算。
|
||||||
|
|
||||||
|
字段描述出现文档间分歧时(如存货表 `InvSCost` 仅见于 12.1 说明文档),以恢复库实测为准。
|
||||||
|
|
||||||
|
## 9. 实现触发条件
|
||||||
|
|
||||||
|
1. 当前客户版的业务答复足以确定导入规则,并完成对应设计评审(检查点 A);生产地址等资料可在正式联调前补齐。
|
||||||
|
2. 本设计按答复修订并评审定稿。
|
||||||
|
3. 先实现主数据导入并通过可回滚验收(检查点 B),再进入交易数据与负向事件。
|
||||||
|
|
||||||
|
开发设计与隔离测试不等待生产地址/账号。备份源同版本环境仍须实际安装和验收;正式上线须补齐生产环境信息并完成 S1 决策。
|
||||||
|
|
||||||
|
## 10. 口径答复回填清单
|
||||||
|
|
||||||
|
所有答复先写入当前客户版 F 列,记录答复人、日期、例单或来源,再修订本稿。技术字段存在只能证明可取数,不能代替业务方选择计提口径。旧表问题已归并为下表编号,旧编号仅供内部追溯。
|
||||||
|
|
||||||
|
| 原编号 | 当前客户版编号 |
|
||||||
|
| --- | --- |
|
||||||
|
| #1、L1 | B02 成本取法、B01 含税口径 |
|
||||||
|
| #2、#3 | T01 发货日、T02 到账日 |
|
||||||
|
| #4、#5 | T03 回款归属与跨单分摊 |
|
||||||
|
| #6、L2 | B08 特殊款项 |
|
||||||
|
| #7、#8、#12 | T04 状态、T05 负向事件、T06 抵顶方式 |
|
||||||
|
| #9、#10、#11 | T07 人员、T08 客户、T09 产品 |
|
||||||
|
| #13 | B07 启用与历史承接;增量发现属于开发设计 |
|
||||||
|
| L3–L5 | B03 分配规则;修改窗口另见 B04 |
|
||||||
|
| L6、L7 | B05 经理本人业务、B06 岗位权限 |
|
||||||
|
| B1/M1、B2/M2、B3/M3 | E01 生产连接,E03 备份源版本验收 |
|
||||||
|
| B4/M4、B5/M5 | E02 部署与同步,E03 同版本介质 |
|
||||||
|
| S1 | S1 敏感字段策略 |
|
||||||
|
|
||||||
|
客户版 E04 另列实际工资结果核对:先单笔、再个人与部门月结,复用已提供例单并逐笔解释差异。它是正式交付验收项,不能由技术测试代替。
|
||||||
|
|
||||||
|
| 编号 | 设计回填位置与核验重点 | 定稿后的实现顺序 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| #1 / L1 | 销售额、成本字段,含税口径;用成本调整例单核对 | C3 明细映射与金额校验 |
|
||||||
|
| #2 | 发货日期及缺失处理,核对 SendDate 实际存在性 | C3 发货导入 |
|
||||||
|
| #3 / L2 | 到账日期与预收转正式回款;arrivalDate 字典为到期日,不默认作到账日 | C3 回款导入 |
|
||||||
|
| #4 | 无订单关联数据的人工补录或排除规则 | C3 关联异常处理 |
|
||||||
|
| #5 | 多单分摊、金额合计、尾差规则 | C3 分摊与对账 |
|
||||||
|
| #6 / L2 | 期初、保证金、代收和历史已核算款的纳入范围 | C3 回款过滤 |
|
||||||
|
| #7 | 生效、核销、作废的真实状态对应关系 | C3 有效性校验 |
|
||||||
|
| #8 | 原单追溯、冲减月份、已结算调整和防重复 | C4 负向事件 |
|
||||||
|
| #9 | 人员工号、范围、经理关系,保护人工岗位字段 | C2 人员导入 |
|
||||||
|
| #10 | 客户筛选、改名合并和源 ID 追溯 | C2 客户导入 |
|
||||||
|
| #11 | 单位换算、价格、指导价与计提分类维护方 | C2 产品导入 |
|
||||||
|
| #12 | 结算方式枚举与以物抵顶的核算差异 | C3 回款方式 |
|
||||||
|
| #13 | 初始化起点、历史核算识别、变更/作废/删除发现 | C5 增量方案,验证前不启用时间水位 |
|
||||||
|
| L3 | 二次分配标的与现有拆行实现的差异 | 校准已有分配逻辑并回归 |
|
||||||
|
| L4 | 不足 100% 的余额归属与留存方式 | 与 L3 一并校准 |
|
||||||
|
| L5 | 协助仅记录、不计提的追认 | 核对独立二次分配机制 |
|
||||||
|
| L6 | 经理个人户客户确认结果 | 核对生效日期后调整既有参数并复核例单 |
|
||||||
|
| L7 | 角色名称、人员和可操作项 | 权限配置及页面验收 |
|
||||||
|
| B1/M1、B2/M2 | 开发先用本地只读连接;生产地址和凭据单独交接 | 部署前配置及最小权限检查 |
|
||||||
|
| B3/M3 | 先按备份源 10.50.1600;生产完整版本后补 | A3 基准验收,再验证生产差异 |
|
||||||
|
| B4/M4 | 开发现有主机先行;生产系统和网络后补 | D1/D2 托管与告警配置 |
|
||||||
|
| B5/M5 | 合法介质来源、版本与可用性 | A2 安装后进入 A3 |
|
||||||
|
| S1 | 人员表、暂存表和备份的保护、访问、保留期 | 全库策略确认后另行实施,上线前验收 |
|
||||||
|
|
||||||
|
- [ ] 核对每项答复与原始附件,冲突仍标待确认,不填猜测默认值。
|
||||||
|
- [ ] 补齐源字段到目标字段、空值处理、人工字段保护和幂等映射,评审后定稿。
|
||||||
|
- [ ] 按 C2 → C3 → C4/C5 顺序实施,先定点测试与对账,再做隔离回归。
|
||||||
|
- [ ] 业务清单、设计、实现和验收结论同步更新;未答复项保留原编号。
|
||||||
Binary file not shown.
@@ -0,0 +1,64 @@
|
|||||||
|
# T+ 本地恢复与同步开发手册
|
||||||
|
|
||||||
|
日期:2026-10-28。当前程序把 T+ 原始数据写入 MySQL 暂存表,管理员可排队、查看结果和手动重试;工资业务表导入、自动周任务和告警投递尚未实现。
|
||||||
|
|
||||||
|
## 环境与版本
|
||||||
|
|
||||||
|
按用户本轮决定,先以备份源 SQL Server 2008 R2 `10.50.1600` 搭建开发验收环境。开发连接使用本地恢复实例的地址、端口和专用只读账号;客户生产信息到位后另行回填。现有 SQL Server 2022 恢复副本可继续用于开发,但不能证明备份源同版本兼容。安装和恢复步骤见 [2008 R2 测试环境](SQLServer2008R2测试环境.md)。
|
||||||
|
|
||||||
|
`scripts/tplus/inspect_sqlserver_backup.ps1`、`restore_sqlserver_backup.ps1`、`create_tplus_readonly_login.ps1` 使用 Docker 容器路径,只用于现有 2022 PoC。不能把这些脚本原样用于 Windows 虚拟机。已有虚拟机不重复运行创建脚本。
|
||||||
|
|
||||||
|
## 连接配置
|
||||||
|
|
||||||
|
在 `backend/` 运行应用与 worker,两者使用同一套 MySQL 和 T+ 账套配置。参考 `backend/.env.example` 填写本机私有配置:
|
||||||
|
|
||||||
|
| 配置项 | 用途 |
|
||||||
|
| --- | --- |
|
||||||
|
| `T_PLUS_ENABLED` | 默认 false;准备好只读数据源后显式启用 |
|
||||||
|
| `T_PLUS_CONNECTION_FILE` | 项目外的本地 JSON 文件路径;管理页面不接收此路径 |
|
||||||
|
| `T_PLUS_HOST/PORT/DATABASE/USER/PASSWORD/ODBC_DRIVER` | 未使用连接文件时填写;驱动名称必须与运行后端的电脑上已安装、实测过的驱动一致 |
|
||||||
|
| `T_PLUS_ENCRYPT` | 默认 true;连接失败不会自动退回明文 |
|
||||||
|
| `T_PLUS_TRUST_SERVER_CERTIFICATE` | 默认 true,适用于当前自签证书开发配置;不校验证书身份,部署可信证书后应设为 false |
|
||||||
|
| `T_PLUS_CONNECT_TIMEOUT/QUERY_TIMEOUT` | 默认 10/120 秒;查询超时范围 1–600 秒 |
|
||||||
|
| `T_PLUS_QUERY_DIR` | 留空使用仓库 `db/queries/tplus/`;部署时须交付查询文件 |
|
||||||
|
|
||||||
|
连接文件只接受 `host`、`port`(整数)、`database`、`user`、`password`、`driver` 六个键,最大 16 KB;拒绝 `sa` 和额外配置项。文件覆盖源库连接字段,TLS 和超时仍取应用配置。连接文件、真实密码与完整连接串不入 Git、不贴到确认单或报告。
|
||||||
|
|
||||||
|
默认加密可能使旧驱动或未配置 TLS 的实例连接失败。先核对驱动与服务端能力;确需临时回退时,仅在隔离开发环境显式设置 `T_PLUS_ENCRYPT=false`,记录原因、范围和复测结果。此次变更只更新代码默认值与模板,既有私有配置不会自动被覆盖。真实 10.50.1600 的 TLS 兼容仍待实测。
|
||||||
|
|
||||||
|
## 启动与排队
|
||||||
|
|
||||||
|
依赖安装使用项目锁文件;已有环境无需重新安装。需要 T+ 驱动依赖时,在 `backend/` 执行 `uv sync --extra tplus`,ODBC 系统驱动另按实际环境准备。
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# 在 backend/ 目录执行,先完成本机私有配置
|
||||||
|
uv run uvicorn app.main:app --host 127.0.0.1 --port 8000
|
||||||
|
# 另开终端,在 backend/ 目录消费已排队任务
|
||||||
|
uv run python -m app.cli tplus-worker --watch
|
||||||
|
# 单次消费队列中的一个任务
|
||||||
|
uv run python -m app.cli tplus-worker --once
|
||||||
|
```
|
||||||
|
|
||||||
|
管理员在 `/system/tplus` 创建读取或关联核验任务。HTTP 只入队,worker 负责执行;启动 worker 本身不会创建定时任务。`--connection-file` 可显式指定本地连接文件,后端仍需配置相同账套。
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# 直接暂存一个实体;不会写工资业务表
|
||||||
|
uv run python -m app.cli tplus-stage --entity personnel
|
||||||
|
# 只读兼容检查;报告写入忽略目录,默认基准 10.50.1600
|
||||||
|
uv run python ../scripts/tplus/check_compatibility.py --connection-file "D:\work\app\tplus-sqlserver\reader.local.json" --report "../.ai/tplus-compatibility.json"
|
||||||
|
```
|
||||||
|
|
||||||
|
兼容脚本与应用共用配置入口。退出码 0 表示脚本覆盖的检查通过,2 表示不通过;完整验收还需权限审计、断连、重跑和对账。只有经确认需要验证其他补丁版本时才使用 `--expected-version`,报告中的版本不能冒充默认基准通过。
|
||||||
|
|
||||||
|
## 常见问题
|
||||||
|
|
||||||
|
| 现象 | 处理 |
|
||||||
|
| --- | --- |
|
||||||
|
| 页面显示未配置 | 核对开关、连接文件权限、只读账号与驱动;配置就绪不等于连通 |
|
||||||
|
| 批次停在 QUEUED | 核对 worker 是否运行,以及它与后端的 MySQL 配置是否一致 |
|
||||||
|
| 批次 FAILED/PARTIAL | 查看脱敏错误码;排除原因后在页面重试,保留原批次,勿直接改终态 |
|
||||||
|
| 进程中断后留有 RUNNING | worker 下次取得执行锁后恢复中断状态;确认原因后重试 |
|
||||||
|
| 加密连接失败 | 按上文核对 TLS;程序不自动关闭加密 |
|
||||||
|
| 版本检查不通过 | 核对报告实际版本;2022 或其他补丁版本都不算 10.50.1600 同版本通过 |
|
||||||
|
|
||||||
|
当前暂存 JSON 可能包含手机号。本轮按用户批准的计划暂不加密或掩码,已在确认清单 S1 记录开发阶段风险接受;这不代表客户已批准生产保留。数据库访问、备份范围和全库敏感字段策略仍需统一确认。
|
||||||
@@ -265,4 +265,4 @@ def start_payment_ok(received, revenue, ratio) -> bool
|
|||||||
|
|
||||||
1. 按 T1–T8 的验证节点逐任务执行(见第二章各任务);T8 后全量回归 + e2e;
|
1. 按 T1–T8 的验证节点逐任务执行(见第二章各任务);T8 后全量回归 + e2e;
|
||||||
2. 验收标准 6 条逐条核对(见第四章);
|
2. 验收标准 6 条逐条核对(见第四章);
|
||||||
3. 与 `ai-aux/AI_CONTEXT.md` 需求裁决节交叉核对口径无冲突。
|
3. 与 `.ai/AI_CONTEXT.md` 需求裁决节交叉核对口径无冲突。
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
**技术栈:** 后端 Python 3.12 + FastAPI + SQLAlchemy 2.0(同步)+ MySQL 8.0 + Redis 7;前端 Vue 3.5 + TS 5.9 + Vite 8 + Element Plus 2.14 + Pinia 4。
|
**技术栈:** 后端 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);
|
2. 计划中的命令与项目现有命令体系一致(uv / pnpm / docker compose);
|
||||||
3. 首块每个任务有独立可测交付物;测试要点符合「模块整体完成时统一编写」约定(纯函数与 core 级随实现即时测);
|
3. 首块每个任务有独立可测交付物;测试要点符合「模块整体完成时统一编写」约定(纯函数与 core 级随实现即时测);
|
||||||
4. 执行阶段验证:T1 后 `test_schema_sync.py` 通过;T2/T3 后纯单元全绿;T5 后冒烟链路完整;T8 后全量后端用例与 e2e 全绿(以 `backend/tests/README.md` 最新运行记录为准)。
|
4. 执行阶段验证:T1 后 `test_schema_sync.py` 通过;T2/T3 后纯单元全绿;T5 后冒烟链路完整;T8 后全量后端用例与 e2e 全绿(以 `backend/tests/README.md` 最新运行记录为准)。
|
||||||
@@ -92,7 +92,7 @@
|
|||||||
- T1/T6:`uv run pytest tests/integration/test_schema_sync.py tests/unit/test_models.py -q` 全绿(双轨零漂移是硬关口)。
|
- T1/T6:`uv run pytest tests/integration/test_schema_sync.py tests/unit/test_models.py -q` 全绿(双轨零漂移是硬关口)。
|
||||||
- T5/T11:模块集成用例统一编写并执行;T11 后 `uv run pytest -q` 全量回归。
|
- T5/T11:模块集成用例统一编写并执行;T11 后 `uv run pytest -q` 全量回归。
|
||||||
- T12–T14:`pnpm type-check && pnpm build`。
|
- T12–T14:`pnpm type-check && pnpm build`。
|
||||||
- T15:后端全量 pytest + 前端构建 + 两个 e2e 脚本全过;`ai-aux/AI_CONTEXT.md` 历史任务摘要由主会话收尾时更新。
|
- T15:后端全量 pytest + 前端构建 + 两个 e2e 脚本全过;`.ai/AI_CONTEXT.md` 历史任务摘要由主会话收尾时更新。
|
||||||
|
|
||||||
## 风险与开放问题(默认处理,不阻塞开工)
|
## 风险与开放问题(默认处理,不阻塞开工)
|
||||||
|
|
||||||
|
|||||||
-101
@@ -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.
@@ -1,95 +0,0 @@
|
|||||||
# 待答复事项催办清单(2026-10-19)
|
|
||||||
|
|
||||||
> **用途**:对外跟催材料,分三块可直接拆分发送——一、业务方;二、IT/T+ 侧;三、报表口径补充确认(本轮新增)。
|
|
||||||
> **基线**:《待澄清问题清单_2026-10-15(1)(2).xlsx》(业务 24 项,2026-10-16 已答复 19 项,其中部分答复 3 项;#1–#3/#20 仍空白)、《T+对接数据需求清单》v1.3「待IT确认事项」18 项 + v1.4(2026-10-19 勘误)、《问题与解决方案对照表_v2》(28 项已裁决口径)。
|
|
||||||
> **原则**:已答复项不重复询问;未答复项按清单内「建议默认」执行中,答复后以答复为准并回填文档。
|
|
||||||
|
|
||||||
## 一、业务方(建议回复期限:____)
|
|
||||||
|
|
||||||
### A. 仍未答复 —— 阻塞对应模块开工(4 项)
|
|
||||||
|
|
||||||
| # | 问题(原文口径) | 当前执行默认 | 阻塞 |
|
|
||||||
| --- | --- | --- | --- |
|
|
||||||
| 1 | 二次分配的分配标的是什么——绩效工资金额、折算毛利润额,还是销售额(业绩额)? | 方案录入已交付;**分摊已按开发侧暂定口径实现**(非业务答复,答复后校准) | ④ 分摊计算 |
|
|
||||||
| 2 | 分配比例合计不足 100% 时,剩余部分归属谁? | 余额归合同负责营销人员(仅展示不落库) | ④ 分摊计算 |
|
|
||||||
| 3 | 二次分配与已作废的「协助 25% 分成」是什么关系? | 独立新机制;协助仍仅记录不核算 | ④ 分摊 |
|
|
||||||
| 20 | 含税口径:销售/成本/毛利润/运费各项是否含税?价税合计与折扣折让如何处理? | 按不含税口径实现中 | ④ 核算口径终验、⑥ 同步建列(是否建税率/税额列) |
|
|
||||||
|
|
||||||
> 说明:#1–#3 未答复期间,二次分配方案可正常录入与审批;开发侧已按暂定口径把分摊拆行计入绩效(参与人份额×比例、负责人余额),**标的与余额归属仍待业务答复,答复后可能调整**;#20 影响历史算出的绩效是否需要按含税口径重算,越早答复返工面越小。
|
|
||||||
|
|
||||||
### B. 已有答复但需补具体值 / 勾选(4 项)
|
|
||||||
|
|
||||||
| # | 事项 | 需补充 |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| 12 | 经理个人户业绩是否计入其 12.5% 团队基数 | 答复为「做参数可调」,**请给出初始值**:计入 / 不计入(系统已实现为开关参数,当前默认不计入) |
|
|
||||||
| 14 | 「第 1 天 10‰」实现口径 | 请勾选:**A 仅首日覆盖**(推荐,保「360 天 = 6‰」锚点)或 B 整体平移(破坏锚点)。系统当前按 A 实现(首日 10‰、其余按公式 10 − 天数 × 4/360) |
|
|
||||||
| 4 | 二次分配方案修改窗口与权限 | 答复「暂定允许修改,走调整单」——请定:首次月结前可改(留痕)?月结后只读、仅走调整单?谁有权改(管理员 / 营销后勤)? |
|
|
||||||
| 6 | 营销后勤/财务/领导三类角色的账号归属、可见菜单、可操作点 | 答复「先预设角色,需与客户确认」——请给权限清单(三类角色各能看什么、能操作什么)。系统已按最小授权运行:后勤=提交录入,财务=复核+导出,领导=审核只读,营销后勤暂不可见审批列表 |
|
|
||||||
|
|
||||||
### C. 请确认两处勘误(已按勘误实施,需业务回执存档)(2 项)
|
|
||||||
|
|
||||||
| 项 | 原表述 | 现行勘误(v1.4) |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| 人员识别 | 「负责营销人员以姓名为唯一识别」 | 改为「以人员编码(工号)为唯一识别、姓名辅助核对」——原表述与「编码为业绩归属唯一锚点(防重名错钱)」矛盾,疑笔误,请确认 |
|
|
||||||
| 回款↔发货批次 | 「由 T+ 收款分配关系指定,人工辅助」 | 改为「**系统人工指定**(对照表 #18 定稿),T+ 收款分配关系仅作辅助」——主次相反疑漏改,请确认 |
|
|
||||||
|
|
||||||
### D. 2026-10-20 新增:需业务定口径(2 项)
|
|
||||||
|
|
||||||
> 随「审批单内容快照 + 二次分配方案版本化」收口带出,开发侧已按现行默认实现,答复后按答复调整。
|
|
||||||
|
|
||||||
| # | 事项 | 当前默认(开发侧已实现) | 需确认 |
|
|
||||||
| --- | --- | --- | --- |
|
|
||||||
| 25 | **业务硬删除时,方案版本历史是否保留?** | 随业务一并删除(与既有「终态审批单随业务删除一并清理」规则一致)——业务被删后,方案参与人与比例的版本痕迹也不复存在 | 是否需要保留(如留归档表/只留审批单快照),还是维持一并删除 |
|
|
||||||
| 26 | **离职的历史参与人重新加入方案,是否允许?** | 按新参与人处理:曾参与过但当前不在生效方案里的人,若已离职,重新加入会被「离职人员禁入」拦截 | 是否应放行(把「历史参与过」视为豁免),还是维持按现任职校验 |
|
|
||||||
|
|
||||||
### 已按默认执行、无需回复的项(供核阅)
|
|
||||||
|
|
||||||
\#7 月结审批链默认开启、#8 人工确认回款可手动关闭、#9 调整单同链且跨月需授权、#10 风险预警参数化、#11 待办范围(待审批+同步失败+回款未指定批次)、#13 支付环节 5 默认值、#15 一期仅 FULL 模式、#17 参数查看放开/修改限管理员、#18 企业户 2‰ 回溯、#21 退货当期冲减已结算走调整单、#22 预收/保证金/代收款暂不计入、#23 按到账金额、#24 负绩效照算不归零。
|
|
||||||
|
|
||||||
## 二、IT / T+ 侧(建议回复期限:____)
|
|
||||||
|
|
||||||
> **2026-10-20 更新**:已用 T+12.3 数据字典完成字段级核对(1,457 页 / 931 张表 / 21,019 个字段),下列问题由 18 项收敛为 **13 项 IT 待确认 + 1 组业务裁决**,每项附字典候选字段。字段级证据与来源页见《T+字段映射草案_v0.1_2026-10-20.md》,完整清单见《T+对接数据需求清单_v1.5_2026-10-20.xlsx》。
|
|
||||||
|
|
||||||
### A. 待确认(按优先级)
|
|
||||||
|
|
||||||
| 新# | 原# | 事项 | 字典候选(已定位) | 仍需答复 |
|
|
||||||
| --- | --- | --- | --- | --- |
|
|
||||||
| 1 | 1 | 成本额字段与成本口径 | SA_SaleOrder_b.LatestCost;SA_SaleDelivery_b.costPrice/costAmount/CostPriceGetType | 取订单最新成本还是出库成本;取数时点与精度 |
|
|
||||||
| 2 | 2+3 | 发货日与到账日口径 | 发货:voucherdate/SendDate/deliveryDate/auditeddate;到账:voucherDate/auditedDate/createdTime | 分别取哪个字段;签收日/银行到账日能否提供 |
|
|
||||||
| 3 | 4 | 幂等键与作废重开 | 各表 code + id 主键 | code 是否终身唯一;作废重开是否换号 |
|
|
||||||
| 4 | 5 | 三单关联与核销路径 | SA_SaleDelivery.SourceVoucherCode、_b.saleOrderCode/saleOrderId;ARAP_ReceivePayment.SaleOrderCode/SaleOrderID;ARAP_StrikeBalance | 主关联路径;分批发货/部分核销如何取 |
|
|
||||||
| 5 | 6 | 人员编码/工号与经理隶属 | AA_Person.code/name/iddepartment;AA_Department.idparent/iddirector(无直属上级字段) | code 是否即工号;业务员→经理隶属如何维护 |
|
|
||||||
| 6 | 7 | 对接方式与安全 | 字典不涉及 | REST/视图/中间表/文件;只读账号、接口文档、样例数据 |
|
|
||||||
| 7 | 8 | 负数/红字/退货/冲销规则 | isCounteractDelivery、returnQuntity/backSentQuantity、voucherstate、ARAP_StrikeBalance | 实际使用形式、原单关联、是否推送 |
|
|
||||||
| 8 | 9 | 存量起始与增量水位 | updated/createdTime/madeDate/auditedDate/ts | 存量起始日;增量用哪个时间戳;时区 |
|
|
||||||
| 9 | 10+v1.4#18 | 客户主数据匹配与变更 | AA_Partner.code/name/Contact/representative/disabled/updated | 唯一键;改名/换负责人/合并;负责人取 Contact 还是 representative |
|
|
||||||
| 10 | 11+16 | 收款方式与确认/核销枚举 | AA_SettleStyle;ARAP_ReceivePayment.IdSettleStyle/cancelState/voucherstate | 完整枚举与以物抵顶代码;核销状态含义与人工确认依据 |
|
|
||||||
| 11 | 12+13 | 产品数量语义与发货单价 | ST_CurrentStock.baseQuantity;AA_InventoryPrice/AA_InventoryUnitPrice;SA_SaleDelivery_b.price/taxPrice | 数量取现存量/在途/累计销量;发货单价口径与含税 |
|
|
||||||
| 12 | 14+17 | 低频字段存在性:特殊业务标记/市场拓展计划 | SA_SaleOrder.idbusinesstype;AA_Person 自定义项;未检出市场拓展计划表 | 有无可识别字段/表;无则人工维护 |
|
|
||||||
| 13 | 15 | 同步窗口 | 字典不涉及 | 每周时间点,避结账/备份;失败重试策略 |
|
|
||||||
|
|
||||||
### B. 口径裁决(1 组,转业务方)
|
|
||||||
|
|
||||||
含税口径、退货红字冲减、预收/保证金/代收、手续费净额、负绩效表述(原 #18,对应《待澄清问题清单》#20–#24)。
|
|
||||||
|
|
||||||
### C. 已由字典补齐、仅需确认可用性
|
|
||||||
|
|
||||||
1. **人员编码**:字典已定位 `AA_Person.code`,请确认是否即工号;若为自动内部编码,请指明工号字段。
|
|
||||||
2. **客户档案**:字典已定位 `AA_Partner` 主表(编码/名称/联系人/停用/地区等),请确认可提供。
|
|
||||||
3. **映射列**:v1.5 清单已回填「T+ 表.字段 / 类型 / 枚举 / 系统目标列」,请按实际环境核对并修正。
|
|
||||||
|
|
||||||
## 三、报表口径补充确认(2026-10-19 新增,随本轮开发一并排)
|
|
||||||
|
|
||||||
> 报表统计模块(需规 2.4 / FR-029~FR-032)开工在即,以下三项请确认;未答复按「建议默认」实现,后续可调。
|
|
||||||
|
|
||||||
| # | 事项 | 建议默认(将按此实现) |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| R1 | 部门绩效汇总表的「部门」口径 | 部门 = 经理团队(经理 + 其直属业务员),按人员档案的隶属关系展开;离职人员历史业绩留在原团队;经理的 5% 全公司部分单独一列 |
|
|
||||||
| R2 | 回款统计的「回款天数区间」档位 | ≤0 天(先款后货)|1-30|31-60|61-90|91-180|181-360|361 以上|无回款天数,共 8 档;未命中区间显示 0 |
|
|
||||||
| R3 | 导出/打印模板要素 | 公司信息页眉(名称+logo)、报表标题、筛选条件、导出时间与导出人、模板版本号、财务复核栏、领导签字栏、日期栏 |
|
|
||||||
|
|
||||||
另:报表查看权限(FR-029 参与者含「营销后勤(授权范围内)」)——请随第一部分 B-6 权限清单一并明确营销后勤是否需要查看报表、可见哪些列。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**发送建议**:第一部分发业务方(含 C 两项勘误确认);第二部分发 IT/T+(v1.4 附表一并);第三部分随开发进度与业务方顺带确认。
|
|
||||||
-241
@@ -1,241 +0,0 @@
|
|||||||
衍水集团营销人员工资管理系统
|
|
||||||
部署硬件需求说明
|
|
||||||
================================================================
|
|
||||||
版本:v1.0
|
|
||||||
日期:2026-10-21
|
|
||||||
适用:内部系统部署(数十人并发,无公网访问)
|
|
||||||
依据:仓库实际运行架构(docker/docker-compose.dev.yml、backend/pyproject.toml、
|
|
||||||
backend/app/core/db.py、docs/备份恢复手册.md、ai-aux/AI_CONTEXT.md)
|
|
||||||
|
|
||||||
|
|
||||||
================================================================
|
|
||||||
一、结论摘要(先看这一节)
|
|
||||||
================================================================
|
|
||||||
|
|
||||||
推荐配置:2 核 CPU / 8 GB 内存 / 100 GB SSD(Linux 单机)
|
|
||||||
最低配置:2 核 CPU / 4 GB 内存 / 100 GB SSD
|
|
||||||
演示配置:1 核 CPU / 2 GB 内存 / 40 GB SSD(仅限试用,不适合正式使用)
|
|
||||||
|
|
||||||
一句话:内部系统的瓶颈不在 CPU,而在「内存余量」和「磁盘容量」。
|
|
||||||
内存从 4 GB 升到 8 GB 的成本极低,却能一次性消除以后所有内存不足的排查;
|
|
||||||
磁盘不要按当前数据量买,要按「备份 + 恢复演练」的双倍空间买。
|
|
||||||
|
|
||||||
|
|
||||||
================================================================
|
|
||||||
二、负载前提(为什么是这个配置)
|
|
||||||
================================================================
|
|
||||||
|
|
||||||
以下事实全部来自本仓库,不是通用模板:
|
|
||||||
|
|
||||||
1) 内部系统、数十人并发
|
|
||||||
出处:ai-aux/AI_CONTEXT.md:101「并发极低(内部系统数十人)」
|
|
||||||
含义:CPU 核心数不是瓶颈,2 核足以支撑全部并发请求。
|
|
||||||
|
|
||||||
2) 全栈同步架构 + 单 worker,重活走线程池
|
|
||||||
出处:CLAUDE.md(分层约定)、backend/app/core/db.py:29
|
|
||||||
含义:一个请求占用一个线程;绩效批量核算、Excel 导出会短暂吃满一个核,
|
|
||||||
因此要留出至少 1 个空闲核给后端,避免请求排队。
|
|
||||||
|
|
||||||
3) MySQL 8.0 + Redis 7 双容器,Redis 开启 AOF
|
|
||||||
出处:docker/docker-compose.dev.yml:11、:44
|
|
||||||
含义:MySQL 是内存消耗大户(默认配置下性能很差,必须调参);
|
|
||||||
Redis 只存验证码、Token 黑名单、登录失败计数,用量极小(<50 MB)。
|
|
||||||
|
|
||||||
4) 备份策略:每日全量 gz + binlog 增量,各自保留 30 天
|
|
||||||
出处:docs/备份恢复手册.md:11
|
|
||||||
含义:磁盘需按「业务数据 + 索引 + 30 天 binlog + 30 份全量备份」计算。
|
|
||||||
|
|
||||||
5) 恢复演练需恢复到独立临时库 payroll_drill_*
|
|
||||||
出处:docs/备份恢复手册.md:45
|
|
||||||
含义:演练瞬间需要约「2 倍数据库体积」的可用空间。
|
|
||||||
这是最容易被低估的一项,磁盘卡着下限买会在第一次演练时爆盘。
|
|
||||||
|
|
||||||
6) 报表导出用 openpyxl 全量载入内存
|
|
||||||
出处:backend/app/services/report_export.py:18
|
|
||||||
含义:导出大报表时后端进程内存峰值会翻倍,必须在内存预算里留余量。
|
|
||||||
|
|
||||||
|
|
||||||
================================================================
|
|
||||||
三、正式生产推荐配置(内部系统,数十人)
|
|
||||||
================================================================
|
|
||||||
|
|
||||||
项目 规格 说明
|
|
||||||
----------------------------------------------------------------
|
|
||||||
CPU 2 核(推荐 4 核) 2 核给容器与 MySQL,
|
|
||||||
1 核给 uvicorn(workers=2 上限),
|
|
||||||
1 核余量给系统与备份任务。
|
|
||||||
|
|
||||||
内存 8 GB(最低 4 GB) 8 GB 才有余量承受
|
|
||||||
「导出 + 核算 + 备份」同时发生。
|
|
||||||
|
|
||||||
磁盘 100 GB SSD(推荐 200 GB) 必须是 SSD。HDD 上 MySQL 的
|
|
||||||
ON UPDATE CURRENT_TIMESTAMP
|
|
||||||
高频写入会明显拖慢。
|
|
||||||
|
|
||||||
网络 内网千兆 无需公网 IP、域名、SSL 证书、CDN。
|
|
||||||
|
|
||||||
操作系统 Linux 推荐 Ubuntu 22.04 LTS / Rocky 9。
|
|
||||||
(Ubuntu 22.04 / Rocky 9) 备份、恢复、巡检脚本全部是 .sh
|
|
||||||
(scripts/ 目录),Linux 为原生路径。
|
|
||||||
若必须用 Windows Server,需 2022 +
|
|
||||||
WSL2 或 Git Bash 运行脚本。
|
|
||||||
|
|
||||||
|
|
||||||
================================================================
|
|
||||||
四、内存分配明细(8 GB 的具体切法)
|
|
||||||
================================================================
|
|
||||||
|
|
||||||
组件 建议占用 关键设置与说明
|
|
||||||
----------------------------------------------------------------
|
|
||||||
MySQL 8.0 3.5 GB innodb_buffer_pool_size = 2G
|
|
||||||
★ 默认仅 128 MB,不调会大量走磁盘,
|
|
||||||
MySQL 会成为整机最慢的一环。
|
|
||||||
|
|
||||||
后端 uvicorn 1.5 GB workers = 2
|
|
||||||
报表导出时单 worker 峰值可达 600 MB ~ 1 GB
|
|
||||||
(openpyxl 全量载入内存)。
|
|
||||||
|
|
||||||
Redis 7 256 MB maxmemory 256mb
|
|
||||||
实际用量 < 50 MB,设置上限只为兜底防失控。
|
|
||||||
|
|
||||||
Nginx + 系统 1 GB 静态资源(前端 dist)+ 反向代理 /api。
|
|
||||||
|
|
||||||
余量 1.75 GB mysqldump 备份进程、binlog 压缩、系统缓存。
|
|
||||||
|
|
||||||
----------------------------------------------------------------
|
|
||||||
4 GB 内存版(最低配置)的分配:
|
|
||||||
MySQL 1.5~2 GB(innodb_buffer_pool_size = 1G)
|
|
||||||
后端 0.8~1 GB
|
|
||||||
Redis 128 MB(maxmemory 128mb)
|
|
||||||
Docker + Nginx + 系统 0.6 GB
|
|
||||||
余量 ~0.6 GB
|
|
||||||
★ 这是「刚好够用」,一旦「导出报表」与「每日备份」撞在同一时刻,
|
|
||||||
OOM Killer 会优先杀 MySQL。这就是推荐 8 GB 的原因。
|
|
||||||
|
|
||||||
|
|
||||||
================================================================
|
|
||||||
五、磁盘空间算式(100 GB 的来历)
|
|
||||||
================================================================
|
|
||||||
|
|
||||||
项目 估算占用
|
|
||||||
----------------------------------------------------------------
|
|
||||||
业务数据(数十人,5 年) ≈ 1 GB
|
|
||||||
索引 + binlog 30 天(ROW 格式) ≈ 5 GB
|
|
||||||
全量备份 30 份(gz,约数据量 20%) ≈ 6 GB
|
|
||||||
恢复演练临时库(峰值 = 2 倍数据量) ≈ 2 GB
|
|
||||||
系统 + Docker 镜像 + 应用日志 ≈ 25 GB
|
|
||||||
----------------------------------------------------------------
|
|
||||||
合计 ≈ 39 GB
|
|
||||||
|
|
||||||
结论:100 GB 可支撑 2~3 年不需扩容。
|
|
||||||
按 docs/备份恢复手册.md:87 的 80% 磁盘水位告警,100 GB 有充足缓冲。
|
|
||||||
|
|
||||||
|
|
||||||
================================================================
|
|
||||||
六、内部部署必须调整的四项(现有配置存在隐患)
|
|
||||||
================================================================
|
|
||||||
|
|
||||||
1) 数据库端口必须收敛,不能监听所有网卡
|
|
||||||
现状:docker/docker-compose.dev.yml:27 为 "3306:3306",
|
|
||||||
:48 为 "6379:6379",等价于绑定 0.0.0.0。
|
|
||||||
风险:内网任何一台办公电脑都能直连 MySQL 试 root 密码。
|
|
||||||
改法(二选一):
|
|
||||||
A. 只允许宿主机访问
|
|
||||||
ports:
|
|
||||||
- "127.0.0.1:3306:3306"
|
|
||||||
- "127.0.0.1:6379:6379"
|
|
||||||
B. 后端与数据库在同一 compose 网络内时,直接删掉这两行 ports,
|
|
||||||
容器之间通过服务名互访,宿主机只暴露 80/443。
|
|
||||||
|
|
||||||
2) 前端必须构建成静态文件 + Nginx 反向代理
|
|
||||||
禁止把 vite dev 服务器当正式环境用:
|
|
||||||
开发服务器会暴露全部源码,且不提供任何访问控制。
|
|
||||||
正式做法:pnpm build 产出 frontend/dist,由 Nginx 托管,
|
|
||||||
/api 反向代理到后端 8000 端口。
|
|
||||||
|
|
||||||
3) 备份必须异地留一份
|
|
||||||
内部系统最常见的「数据全丢」场景就是单机故障。
|
|
||||||
在 scripts/backup_full.sh 之后追加一步 rsync 到 NAS 或公司文件服务器。
|
|
||||||
|
|
||||||
4) 提前预留反向代理层
|
|
||||||
不接公网就不需要 WAF / CDN / SSL。
|
|
||||||
但如果公司后续有统一域名单点登录(SSO)规划,
|
|
||||||
Nginx 反代层要提前留好,避免到时大改架构。
|
|
||||||
|
|
||||||
|
|
||||||
================================================================
|
|
||||||
七、三档速查表
|
|
||||||
================================================================
|
|
||||||
|
|
||||||
场景 CPU 内存 磁盘 备注
|
|
||||||
--------------------------------------------------------------------------------
|
|
||||||
试用 / 演示(< 5 人) 1 核 2 GB 40 GB SSD 仅跑小数据量;
|
|
||||||
导出大报表会 OOM
|
|
||||||
|
|
||||||
正式内部(数十人) 2 核 4 GB(建议 8 GB)100 GB SSD 推荐方案
|
|
||||||
【推荐】
|
|
||||||
|
|
||||||
内部 + 冗余 4 核 ×2 8 GB ×2 200 GB SSD ×2 含从库与监控;
|
|
||||||
(从库 + 监控) 工资系统若需
|
|
||||||
审计级可靠性
|
|
||||||
值得投入
|
|
||||||
--------------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
================================================================
|
|
||||||
八、可选加速项(非必须)
|
|
||||||
================================================================
|
|
||||||
|
|
||||||
1) 图表库已本地化
|
|
||||||
ECharts 静态文件已放在 docs/design/echarts.min.js,
|
|
||||||
前端自托管即可,不依赖外网 CDN,符合「内网加载不可靠」的既定决策。
|
|
||||||
|
|
||||||
2) 日志盘分离
|
|
||||||
若开启审计日志长期留存,把 backend/logs/ 挂载到独立磁盘,
|
|
||||||
避免日志写满系统盘导致 MySQL 无法写入。
|
|
||||||
|
|
||||||
3) 备份异地化
|
|
||||||
本机备份 + 定时 rsync 到 NAS 或对象存储,构成「本地 + 异地」两份。
|
|
||||||
|
|
||||||
|
|
||||||
================================================================
|
|
||||||
九、生产环境仍需完成的事项(本文件之外)
|
|
||||||
================================================================
|
|
||||||
|
|
||||||
1) 生成 docker/docker-compose.prod.yml:
|
|
||||||
- 端口收敛到 127.0.0.1
|
|
||||||
- MySQL innodb_buffer_pool_size 等参数调优
|
|
||||||
- 容器 mem_limit / cpus 资源限制
|
|
||||||
- Nginx 反向代理静态前端 + /api
|
|
||||||
|
|
||||||
2) 后端启动参数由开发模式改为生产模式:
|
|
||||||
- 去掉 --reload(生产环境必须去掉)
|
|
||||||
- 设置 workers=2
|
|
||||||
|
|
||||||
3) backend/.env 中 APP_SECRET_KEY 必须换成强随机值,
|
|
||||||
MYSQL_PASSWORD / REDIS_PASSWORD 两处(docker/.env 与 backend/.env)保持一致。
|
|
||||||
|
|
||||||
4) 首次部署执行 alembic upgrade head,并创建管理员账号:
|
|
||||||
uv run python -m app.cli create-admin --username admin
|
|
||||||
|
|
||||||
5) 配置每日定时任务:全量备份 + 增量归档(见 docs/备份恢复手册.md)
|
|
||||||
|
|
||||||
|
|
||||||
================================================================
|
|
||||||
十、附:数据来源索引
|
|
||||||
================================================================
|
|
||||||
|
|
||||||
ai-aux/AI_CONTEXT.md:101 并发规模判断(数十人)
|
|
||||||
docker/docker-compose.dev.yml:11 MySQL 8.0 容器
|
|
||||||
docker/docker-compose.dev.yml:27 MySQL 端口映射(需收敛)
|
|
||||||
docker/docker-compose.dev.yml:44 Redis 7 容器与 AOF
|
|
||||||
docker/docker-compose.dev.yml:48 Redis 端口映射(需收敛)
|
|
||||||
backend/app/core/db.py:29 同步引擎与连接池配置
|
|
||||||
backend/app/services/report_export.py:18 报表导出(内存峰值来源)
|
|
||||||
docs/备份恢复手册.md:11 备份策略
|
|
||||||
docs/备份恢复手册.md:45 恢复演练临时库
|
|
||||||
docs/备份恢复手册.md:87 磁盘水位 80% 告警阈值
|
|
||||||
scripts/ 备份 / 恢复 / 巡检脚本(.sh)
|
|
||||||
|
|
||||||
================================================================
|
|
||||||
(文件结束)
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
# 衍水集团营销人员工资管理系统项目阶段报告
|
|
||||||
|
|
||||||
- **报告日期**:2026-10-20
|
|
||||||
- **报告范围**:M4 ⑤ 报表统计、⑧ 工作台完成情况与 M4 收尾结论
|
|
||||||
- **项目分支**:`feat/m3-increments`(已合并 `main`,merge commit `3611474`)
|
|
||||||
- **阶段结论**:除 ⑥ T+ 数据同步受外部答复阻塞外,十大功能块全部交付;后端全量 **862 用例通过**,报表、工作台、核算月结三条 e2e 链路实跑通过。
|
|
||||||
|
|
||||||
## 一、3P 阶段摘要
|
|
||||||
|
|
||||||
### Progress(进展)
|
|
||||||
|
|
||||||
M4 两块(⑤ 报表统计、⑧ 工作台)已完成并通过 code-review 修复。报表侧交付个人绩效明细、部门汇总、业务统计、回款统计四张报表、Excel 签字导出与独立打印视图;工作台侧交付经营总览、待办事项、风险预警、最近动态四区块,待办与预警实时派生并参数化。本阶段同时完成 M4 修复批次的提交、全量回归与 e2e 实跑,开发计划 3.3 答复状态已回填。
|
|
||||||
|
|
||||||
### Plans(计划)
|
|
||||||
|
|
||||||
下一步优先解析 T+12.3 数据字典,形成六类单据字段映射草案并自答 IT 清单中可自答部分;~~其次补齐 code-review 遗留的方案版本承载与审批单快照~~(已于 2026-10-20 完成,V11);需规 V2.2 修订与对照表回填择期进行。
|
|
||||||
|
|
||||||
### Problems(问题)
|
|
||||||
|
|
||||||
⑥ T+ 数据同步仍受 IT 18 项答复阻塞,同步失败类待办/预警暂以「模块未启用」显式降级。业务方仍有 #1–#3 二次分摊、#20 含税口径、#4/#6/#12 补值与两处勘误待确认;审批角色权限细目未给出,当前按 `role_code` 粗粒度运行。需规仍含已作废条款,对外引用需注明以对照表/计划第三章为准。
|
|
||||||
|
|
||||||
## 二、阶段定位与完成度
|
|
||||||
|
|
||||||
| 维度 | 当前状态 | 说明 |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| 项目阶段 | 开发收尾期 | M1–M3 与 M4 ⑤⑧ 已交付,仅 ⑥ 待外部输入 |
|
|
||||||
| 整体完成度 | 90% | 十大功能块交付;⑥ 按 0% 计入 |
|
|
||||||
| 交付主线 | M4 报表统计 + 工作台 | 前后端闭环,已合并 main |
|
|
||||||
| 质量状态 | 全绿 | 后端 862 passed(11:32);e2e role_home 4/4、report 12/12、calc_settlement 8/8 |
|
|
||||||
| 主要风险 | 外部对接与口径变更 | T+ 映射未定;含税口径未答复可能触发历史绩效重算 |
|
|
||||||
|
|
||||||
## 三、已完成成果
|
|
||||||
|
|
||||||
### 1. 报表统计(M4 ⑤)
|
|
||||||
|
|
||||||
- 四表接口 `/api/v1/reports/*`:个人绩效明细、部门绩效汇总、业务统计、回款区间统计。
|
|
||||||
- 部门归属按 `personnel.manager_id` 实时上溯;回款区间 8 档(≤0 / 1-30 / 31-60 / 61-90 / 91-180 / 181-360 / 361+ / 无天数)。
|
|
||||||
- 全公司 5% 按「有聚合行经理各计一次」计算,并与月结 `total_payable` 对拍;对账块单列聚合行舍入差。
|
|
||||||
- Excel 签字导出(openpyxl + StreamingResponse,ASCII 文件名)与独立打印视图(五块结构、每页表头、签字栏)。
|
|
||||||
- 权限 `admin/finance/leader` 前后端双层。
|
|
||||||
|
|
||||||
### 2. 工作台(M4 ⑧)
|
|
||||||
|
|
||||||
- 单端点 `/api/v1/dashboard` 一次返回四区块:经营总览、待办事项、风险预警、最近动态。
|
|
||||||
- 待办与预警**实时派生**(不落表、不引调度器);待办按角色过滤;逾期预警排除已终止业务。
|
|
||||||
- 预警阈值参数化:`warning.enabled`、`warning.receipt.overdue.days=90`、`warning.approval.timeout.days=3`(V10,参数注册表 22 项)。
|
|
||||||
- 前端 `/dashboard` 四区块 + ECharts 5.6 按需引入(SVG,显式思源黑体,规避 Windows 默认雅黑)。
|
|
||||||
- 普通用户落地页统一由 `router/access.ts` 解析(工作台 → 业务数据 → 回款),修复无限重定向。
|
|
||||||
|
|
||||||
### 3. 质量与收尾
|
|
||||||
|
|
||||||
- 后端全量 `uv run pytest -q` → **862 passed(11:32)**;`pnpm type-check` 与 `pnpm build` 通过。
|
|
||||||
- e2e 实跑:`role_home_flow` 4/4、`report_flow` 12/12、`calc_settlement_flow` 8/8;`report_flow` 回款统计改为按本次业务编号下钻断言,脚本可重复运行。
|
|
||||||
- 运行后核对:月结审批开关恢复为 1,无 `e2e_role_*` 残留账号。
|
|
||||||
- 开发计划 3.3.1 与《待确认事项汇总》已按 24 项答复分组回填(13 默认 / 4 按答复 / 3 部分 / 4 开放)。
|
|
||||||
|
|
||||||
## 四、当前进行中工作
|
|
||||||
|
|
||||||
无。M4 代码与文档已提交并合并 `main`(`3611474`)。
|
|
||||||
|
|
||||||
## 五、下一步计划
|
|
||||||
|
|
||||||
1. **T+ 同步前置**:解析 `docs/T_12.3数据字典/`,产出人员/产品/业务/发货/回款/客户六类字段映射草案,自答 IT 18 项中可从数据字典确认的部分。
|
|
||||||
2. ~~**code-review 遗留 2 项**:方案生效版本承载、审批单内容快照(orders 加方案 JSON 快照列 + 前端详情展示)。~~ ✅ 已完成(2026-10-20,V11 迁移):方案行版本化 + 审批单版本号/内容快照 + 前端展示。
|
|
||||||
3. **需规 V2.2**:修订 26 天示例、含运费公式、协助 25% 等过时条款,回填 2026-10-15 补充需求;对照表 v2 收编 24 项答复。
|
|
||||||
4. **清理类**:5 处超长函数、散参数 DTO、`useDropdownOptions` 余两页、前端菜单图标。
|
|
||||||
5. **待外部输入**:业务方催办、审批权限清单、IT 18 项答复。
|
|
||||||
|
|
||||||
## 六、风险与应对
|
|
||||||
|
|
||||||
| 风险 | 影响 | 应对 |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| IT 答复持续缺失 | ⑥ 无法开工,同步类待办/预警无法真实展示 | 先做数据字典映射草案;催办清单已含高优 9 项与三缺口 |
|
|
||||||
| 含税口径(#20)改变 | 历史绩效可能需要重算 | 当前按不含税实现;答复后评估重算范围并授权留痕 |
|
|
||||||
| 二次分摊口径(#1–#3)改变 | 已生成台账/月结可能需要调整 | 暂定口径已标注;变更走口径调整,重算需授权留痕 |
|
|
||||||
| 需规含作废条款 | 对外交付物误导 | 引用以对照表/计划第三章为准;V2.2 修订前排期 |
|
|
||||||
|
|
||||||
## 七、报告结论
|
|
||||||
|
|
||||||
M4 报表统计与工作台已完成交付并合并 `main`,系统除 ⑥ T+ 数据同步外具备完整业务闭环。质量证据充分(全量测试与三条 e2e 链路),剩余工作以外部输入与审计性技术债为主,可按计划推进。
|
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
# Vite 开发代理目标,仅开发服务器读取;前端请求仍使用 /api 相对地址。
|
||||||
|
DEV_API_TARGET=http://127.0.0.1:8000
|
||||||
@@ -6,6 +6,9 @@
|
|||||||
|
|
||||||
- 登录页与图形验证码交互
|
- 登录页与图形验证码交互
|
||||||
- 会话状态管理与路由守卫
|
- 会话状态管理与路由守卫
|
||||||
|
- 管理员 T+ 数据同步页:`/system/tplus`,支持读取排队、关联检查、批次/异常详情和失败重试;只读配置未就绪时禁用创建。
|
||||||
|
- T+ 页面位于 `src/views/system/tplus/`,合同在 `src/api/tplus.ts`;创建或重试失败后保留请求键,范围改变或提交成功后才换键。任务由独立读取服务执行,页面刷新查看状态,运行命令见 T+ 本地恢复手册。
|
||||||
|
- Vite 开发代理可通过 `DEV_API_TARGET` 指定后端,默认本地 8000;浏览器请求继续使用 `/api` 相对地址。
|
||||||
- 岗位角色管理页(模板绑定)、计提参数配置页(分组目录/批次提交/版本对比/导出/计算预览)
|
- 岗位角色管理页(模板绑定)、计提参数配置页(分组目录/批次提交/版本对比/导出/计算预览)
|
||||||
- 首页 Bento 仪表盘(M1 空态设计;业务上线后接入真实统计,图表用 ECharts)
|
- 首页 Bento 仪表盘(M1 空态设计;业务上线后接入真实统计,图表用 ECharts)
|
||||||
- 后续:基础数据、业务数据、绩效核算、报表等业务页面
|
- 后续:基础数据、业务数据、绩效核算、报表等业务页面
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { request } from './request'
|
||||||
|
import type { PageData } from './types'
|
||||||
|
|
||||||
|
export type TaskType = 'READ' | 'AUDIT'
|
||||||
|
export type TaskStatus = 'QUEUED' | 'RUNNING' | 'SUCCEEDED' | 'PARTIAL' | 'FAILED'
|
||||||
|
export type EntityType = 'personnel' | 'products' | 'customers' | 'businesses' | 'shipments'
|
||||||
|
| 'receipts' | 'business_items' | 'shipment_items' | 'receipt_items'
|
||||||
|
|
||||||
|
/** 仅包含系统字典和汇总,不包含客户记录或源配置。 */
|
||||||
|
export interface AuditReport {
|
||||||
|
captured_at: string
|
||||||
|
source_version: string
|
||||||
|
relationship_status: string
|
||||||
|
compatibility_status: string
|
||||||
|
sales_links: Record<string, number> | null
|
||||||
|
order_coverage: Record<string, number> | null
|
||||||
|
state_enums: { id: number; code: string; name: string; enum_name: string }[]
|
||||||
|
receipt_kinds: { voucher_type_name: string; row_count: number }[]
|
||||||
|
receipt_line_kinds: { voucher_type_name: string | null; receive_flag: number; row_count: number }[]
|
||||||
|
pending_confirmations: { key: string; title: string; reason: string }[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TPlusBatch {
|
||||||
|
batch_no: string
|
||||||
|
task_type: TaskType
|
||||||
|
status: TaskStatus
|
||||||
|
entity_types: EntityType[] | null
|
||||||
|
source_record_count: number
|
||||||
|
staged_record_count: number
|
||||||
|
skipped_record_count: number
|
||||||
|
failed_record_count: number
|
||||||
|
parent_batch_no: string | null
|
||||||
|
create_time: string
|
||||||
|
started_at: string | null
|
||||||
|
finished_at: string | null
|
||||||
|
can_retry: boolean
|
||||||
|
error_summary: string | null
|
||||||
|
}
|
||||||
|
export interface TPlusDetail extends TPlusBatch { audit_result: AuditReport | null }
|
||||||
|
export interface TPlusError {
|
||||||
|
id: number
|
||||||
|
entity_type: string
|
||||||
|
source_id: string | null
|
||||||
|
error_code: string
|
||||||
|
error_message: string
|
||||||
|
retry_count: number
|
||||||
|
create_time: string
|
||||||
|
}
|
||||||
|
export interface TPlusOverview {
|
||||||
|
is_configured: boolean
|
||||||
|
configuration_message: string
|
||||||
|
status_counts: Partial<Record<TaskStatus, number>>
|
||||||
|
total_staged_records: number
|
||||||
|
compatibility_status: string
|
||||||
|
business_import_enabled: boolean
|
||||||
|
latest_audit: AuditReport | null
|
||||||
|
}
|
||||||
|
export interface BatchQuery { status?: TaskStatus; task_type?: TaskType; pageNum?: number; pageSize?: number }
|
||||||
|
export interface ErrorQuery { entity_type?: EntityType; pageNum?: number; pageSize?: number }
|
||||||
|
export interface CreateTask { task_type: TaskType; entity_types: EntityType[]; request_key: string }
|
||||||
|
|
||||||
|
const base = '/v1/system/tplus'
|
||||||
|
/** 查询配置和任务状态;不会触发源库连接。 */
|
||||||
|
export function getOverview(): Promise<TPlusOverview> { return request({ url: `${base}/overview` }) }
|
||||||
|
/** 分页查询任务。 */
|
||||||
|
export function listBatches(params: BatchQuery): Promise<PageData<TPlusBatch>> {
|
||||||
|
return request({ url: `${base}/batches`, params })
|
||||||
|
}
|
||||||
|
/** 按需查询报告。 */
|
||||||
|
export function getBatch(number: string): Promise<TPlusDetail> {
|
||||||
|
return request({ url: `${base}/batches/${encodeURIComponent(number)}` })
|
||||||
|
}
|
||||||
|
/** 分页查询固定错误说明。 */
|
||||||
|
export function listErrors(number: string, params: ErrorQuery): Promise<PageData<TPlusError>> {
|
||||||
|
return request({ url: `${base}/batches/${encodeURIComponent(number)}/errors`, params })
|
||||||
|
}
|
||||||
|
/** 以调用方保留的请求键持久化排队。 */
|
||||||
|
export function createTask(data: CreateTask): Promise<TPlusDetail> {
|
||||||
|
return request({ url: `${base}/batches`, method: 'post', data })
|
||||||
|
}
|
||||||
|
/** 重试会创建新批次,原异常记录保留。 */
|
||||||
|
export function retryTask(number: string, request_key: string): Promise<TPlusDetail> {
|
||||||
|
return request({ url: `${base}/batches/${encodeURIComponent(number)}/retry`, method: 'post', data: { request_key } })
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
PieChart,
|
PieChart,
|
||||||
Postcard,
|
Postcard,
|
||||||
SetUp,
|
SetUp,
|
||||||
|
Refresh,
|
||||||
Shop,
|
Shop,
|
||||||
Stamp,
|
Stamp,
|
||||||
Tickets,
|
Tickets,
|
||||||
@@ -47,6 +48,7 @@ const ICONS = {
|
|||||||
settings: OfficeBuilding,
|
settings: OfficeBuilding,
|
||||||
approvals: Stamp,
|
approvals: Stamp,
|
||||||
logs: Tickets,
|
logs: Tickets,
|
||||||
|
tplus: Refresh,
|
||||||
personnel: Postcard,
|
personnel: Postcard,
|
||||||
products: Box,
|
products: Box,
|
||||||
customers: Shop,
|
customers: Shop,
|
||||||
|
|||||||
@@ -63,6 +63,12 @@ const routes: RouteRecordRaw[] = [
|
|||||||
component: () => import('@/views/system/users/index.vue'),
|
component: () => import('@/views/system/users/index.vue'),
|
||||||
meta: { title: '用户管理', icon: 'users', group: '系统管理', requiresAdmin: true, order: 2 },
|
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',
|
path: '/system/roles',
|
||||||
name: 'system-roles',
|
name: 'system-roles',
|
||||||
|
|||||||
@@ -19,8 +19,9 @@ export const MODULE_OPTIONS = [
|
|||||||
'发货管理',
|
'发货管理',
|
||||||
'回款管理',
|
'回款管理',
|
||||||
'客户评级',
|
'客户评级',
|
||||||
|
'T+数据同步',
|
||||||
] as const
|
] as const
|
||||||
export const ACTION_OPTIONS = ['新增', '修改', '删除', '重置密码', '启用', '禁用'] as const
|
export const ACTION_OPTIONS = ['新增', '修改', '删除', '重置密码', '启用', '禁用', '创建读取任务', '重试读取任务'] as const
|
||||||
|
|
||||||
/** 启用/禁用筛选选项(文案与 StatusTag 的展示一致) */
|
/** 启用/禁用筛选选项(文案与 StatusTag 的展示一致) */
|
||||||
export const STATUS_OPTIONS = [
|
export const STATUS_OPTIONS = [
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import type { EntityType, TaskStatus, TPlusBatch } from '@/api/tplus'
|
||||||
|
|
||||||
|
export const entities: { value: EntityType; label: string }[] = [
|
||||||
|
{ value: 'personnel', label: '人员' }, { value: 'products', label: '产品' },
|
||||||
|
{ value: 'customers', label: '客户' }, { value: 'businesses', label: '订单' },
|
||||||
|
{ value: 'business_items', label: '订单明细' }, { value: 'shipments', label: '销货单' },
|
||||||
|
{ value: 'shipment_items', label: '销货明细' }, { value: 'receipts', label: '收付款单' },
|
||||||
|
{ value: 'receipt_items', label: '收付款明细' },
|
||||||
|
]
|
||||||
|
export const statuses: Record<TaskStatus, string> = {
|
||||||
|
QUEUED: '等待处理', RUNNING: '处理中', SUCCEEDED: '已完成', PARTIAL: '部分失败', FAILED: '失败',
|
||||||
|
}
|
||||||
|
/** 历史范围未记录时保持未知,不默认成全部。 */
|
||||||
|
export function scopeLabel(row: TPlusBatch): string {
|
||||||
|
if (row.task_type === 'AUDIT') return '关联与枚举检查'
|
||||||
|
return row.entity_types?.map((key) => entities.find((item) => item.value === key)?.label ?? key).join('、') || '历史批次,范围未记录'
|
||||||
|
}
|
||||||
@@ -28,7 +28,7 @@ export default defineConfig(({ mode }) => {
|
|||||||
// 开发期走代理而非后端 CORS,符合规范「跨域在网关统一处理」;
|
// 开发期走代理而非后端 CORS,符合规范「跨域在网关统一处理」;
|
||||||
// 副作用是 /api/docs(Swagger)也能从 5173 端口打开,联调期只用一个域名。
|
// 副作用是 /api/docs(Swagger)也能从 5173 端口打开,联调期只用一个域名。
|
||||||
'/api': {
|
'/api': {
|
||||||
target: 'http://127.0.0.1:8000',
|
target: env.DEV_API_TARGET || 'http://127.0.0.1:8000',
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
"""只读核验备份源同版本 SQL Server;2022 PoC 不能获得兼容通过结果。"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from datetime import datetime
|
||||||
|
from decimal import Decimal
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pyodbc
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "backend"))
|
||||||
|
|
||||||
|
from app.core.config import Settings, get_settings
|
||||||
|
from app.core.tplus_db import resolve_tplus_settings
|
||||||
|
from app.services.tplus.mapping import DETAIL_MAPPINGS, TPLUS_MAPPINGS
|
||||||
|
from app.services.tplus.reader import TPlusReader
|
||||||
|
|
||||||
|
|
||||||
|
def compatibility_settings(path: Path) -> Settings:
|
||||||
|
"""复用应用的连接文件校验、URL 转义和 TLS 配置,不修改全局设置。"""
|
||||||
|
settings = get_settings().model_copy(
|
||||||
|
update={"t_plus_enabled": True, "t_plus_connection_file": str(path)}
|
||||||
|
)
|
||||||
|
return resolve_tplus_settings(settings)
|
||||||
|
|
||||||
|
|
||||||
|
def _version_matches(actual: str, expected: str) -> bool:
|
||||||
|
def parts(value):
|
||||||
|
values = value.split(".")
|
||||||
|
return tuple(values + ["0"] * (4 - len(values)))
|
||||||
|
|
||||||
|
return parts(actual) == parts(expected)
|
||||||
|
|
||||||
|
|
||||||
|
def _inspect_connection(connection, expected_version: str) -> dict:
|
||||||
|
cursor = connection.cursor()
|
||||||
|
try:
|
||||||
|
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()
|
||||||
|
finally:
|
||||||
|
cursor.close()
|
||||||
|
checks = {
|
||||||
|
"sqlserver_2008r2": version.startswith("10.50."),
|
||||||
|
"backup_source_version": _version_matches(version, expected_version),
|
||||||
|
"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),
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"server_version": version,
|
||||||
|
"expected_version": expected_version,
|
||||||
|
"driver_name": connection.getinfo(pyodbc.SQL_DRIVER_NAME),
|
||||||
|
"driver_version": connection.getinfo(pyodbc.SQL_DRIVER_VER),
|
||||||
|
"checks": checks,
|
||||||
|
"compatible": all(checks.values()),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _inspect_adapter(engine, report: dict) -> None:
|
||||||
|
checks = {}
|
||||||
|
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)
|
||||||
|
checks[mapping.entity_type] = True
|
||||||
|
except Exception as exc:
|
||||||
|
session.rollback()
|
||||||
|
checks[mapping.entity_type] = False
|
||||||
|
report.setdefault("errors", {})[mapping.entity_type] = type(exc).__name__
|
||||||
|
report["adapter_queries"] = checks
|
||||||
|
report["compatible"] = report["compatible"] and all(checks.values())
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
parser.add_argument("--expected-version", default="10.50.1600", help="备份源版本基准")
|
||||||
|
args = parser.parse_args()
|
||||||
|
engine = None
|
||||||
|
try:
|
||||||
|
settings = compatibility_settings(args.connection_file)
|
||||||
|
engine = create_engine(
|
||||||
|
settings.t_plus_database_url,
|
||||||
|
hide_parameters=True,
|
||||||
|
connect_args={"timeout": settings.t_plus_connect_timeout},
|
||||||
|
)
|
||||||
|
connection = engine.raw_connection()
|
||||||
|
try:
|
||||||
|
connection.driver_connection.timeout = settings.t_plus_query_timeout
|
||||||
|
report = _inspect_connection(connection, args.expected_version)
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
_inspect_adapter(engine, report)
|
||||||
|
except Exception as exc:
|
||||||
|
# 驱动异常可能包含完整连接串,报告仅保留类型。
|
||||||
|
report = {"compatible": False, "error_type": type(exc).__name__}
|
||||||
|
finally:
|
||||||
|
if engine is not None:
|
||||||
|
engine.dispose()
|
||||||
|
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())
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(未输出密码)"
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
@@ -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 承载此大账套。"
|
||||||
@@ -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"
|
||||||
Loaded 100 of 107 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user