feat(tplus): 新增同步暂存与任务管理迁移和环境配置
This commit is contained in:
parent
823d9f02ec
commit
78bef43c06
@ -26,6 +26,20 @@ LOGIN_FAIL_LIMIT=5
|
||||
LOGIN_LOCK_MINUTES=15
|
||||
|
||||
# ===== T+ 对接(待畅捷通确认后填写)=====
|
||||
T_PLUS_BASE_URL=
|
||||
T_PLUS_APP_KEY=
|
||||
T_PLUS_APP_SECRET=
|
||||
T_PLUS_ENABLED=false
|
||||
T_PLUS_HOST=127.0.0.1
|
||||
T_PLUS_PORT=1433
|
||||
T_PLUS_DATABASE=
|
||||
T_PLUS_USER=
|
||||
T_PLUS_PASSWORD=
|
||||
# 可显式指定项目外的只读连接 JSON;不从页面接收该路径。
|
||||
T_PLUS_CONNECTION_FILE=
|
||||
# 必须填写在客户 SQL Server 2008 R2 上验证过的驱动名称。
|
||||
# 本地 PoC 可复用已安装的 SQL Server 驱动;不自动安装或升级客户驱动。
|
||||
T_PLUS_ODBC_DRIVER=
|
||||
T_PLUS_ENCRYPT=false
|
||||
T_PLUS_TRUST_SERVER_CERTIFICATE=true
|
||||
T_PLUS_CONNECT_TIMEOUT=10
|
||||
T_PLUS_QUERY_TIMEOUT=120
|
||||
# 可选:留空时使用仓库根目录下的 db/queries/tplus/
|
||||
T_PLUS_QUERY_DIR=
|
||||
|
||||
122
backend/alembic/versions/d4e5f6a7b8c9_tplus_sync_staging.py
Normal file
122
backend/alembic/versions/d4e5f6a7b8c9_tplus_sync_staging.py
Normal file
@ -0,0 +1,122 @@
|
||||
"""T+ 只读同步批次、错误、staging 和增量水位。
|
||||
|
||||
Revision ID: d4e5f6a7b8c9
|
||||
Revises: c3a9e1d7b2f4
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.mysql import BIGINT
|
||||
|
||||
revision: str = "d4e5f6a7b8c9"
|
||||
down_revision: str | Sequence[str] | None = "c3a9e1d7b2f4"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""增加 T+ 源更新时间和同步审计/staging 表。"""
|
||||
for table in ("personnel", "products", "customers", "businesses", "shipments", "receipts"):
|
||||
op.execute(
|
||||
f"ALTER TABLE {table} ADD COLUMN source_updated_at DATETIME "
|
||||
"COMMENT 'T+ 源数据更新时间(未确认时可为空)' AFTER synced_at"
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"tplus_sync_batches",
|
||||
sa.Column("id", BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"),
|
||||
sa.Column("batch_no", sa.String(length=32), nullable=False, comment="同步批次号"),
|
||||
sa.Column("sync_mode", sa.String(length=16), nullable=False, comment="同步模式:FULL全量 INCREMENTAL增量"),
|
||||
sa.Column("status", sa.String(length=16), nullable=False, comment="状态:RUNNING/SUCCEEDED/PARTIAL/FAILED"),
|
||||
sa.Column("source_database", sa.String(length=128), nullable=True, comment="T+ 源数据库名"),
|
||||
sa.Column("started_at", sa.DateTime(), nullable=False, comment="开始读取时间"),
|
||||
sa.Column("finished_at", sa.DateTime(), nullable=True, comment="结束时间"),
|
||||
sa.Column("watermark_from", sa.DateTime(), nullable=True, comment="增量起始水位"),
|
||||
sa.Column("watermark_to", sa.DateTime(), nullable=True, comment="本批次结束水位"),
|
||||
sa.Column("source_record_count", sa.Integer(), nullable=False, server_default="0", comment="源记录数"),
|
||||
sa.Column("staged_record_count", sa.Integer(), nullable=False, server_default="0", comment="写入 staging 的记录数"),
|
||||
sa.Column("failed_record_count", sa.Integer(), nullable=False, server_default="0", comment="失败记录数"),
|
||||
sa.Column("error_message", sa.Text(), nullable=True, comment="批次错误摘要"),
|
||||
sa.Column("create_time", sa.DateTime(), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP"), comment="创建时间"),
|
||||
sa.Column("update_time", sa.DateTime(), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"), comment="更新时间"),
|
||||
sa.PrimaryKeyConstraint("id", name="pk_tplus_sync_batches"),
|
||||
sa.UniqueConstraint("batch_no", name="uk_tplus_sync_batches_batch_no"),
|
||||
mysql_engine="InnoDB", mysql_charset="utf8mb4", mysql_collate="utf8mb4_general_ci", comment="T+ 同步批次表(记录读取范围、结果和对账统计)",
|
||||
)
|
||||
op.create_index("idx_tplus_sync_batches_status", "tplus_sync_batches", ["status"])
|
||||
op.create_index("idx_tplus_sync_batches_started_at", "tplus_sync_batches", ["started_at"])
|
||||
|
||||
op.create_table(
|
||||
"tplus_sync_errors",
|
||||
sa.Column("id", BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"),
|
||||
sa.Column("batch_no", sa.String(length=32), nullable=False, comment="同步批次号"),
|
||||
sa.Column("entity_type", sa.String(length=32), nullable=False, comment="业务实体类型"),
|
||||
sa.Column("source_table", sa.String(length=128), nullable=False, comment="T+ 源表"),
|
||||
sa.Column("source_id", sa.String(length=64), nullable=True, comment="T+ 源主键"),
|
||||
sa.Column("source_line_no", sa.String(length=64), nullable=True, comment="T+ 明细行号或明细主键"),
|
||||
sa.Column("error_code", sa.String(length=32), nullable=False, comment="错误编码"),
|
||||
sa.Column("error_message", sa.Text(), nullable=False, comment="错误说明"),
|
||||
sa.Column("retry_count", sa.Integer(), nullable=False, server_default="0", comment="已重试次数"),
|
||||
sa.Column("create_time", sa.DateTime(), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP"), comment="创建时间"),
|
||||
sa.Column("update_time", sa.DateTime(), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"), comment="更新时间"),
|
||||
sa.PrimaryKeyConstraint("id", name="pk_tplus_sync_errors"),
|
||||
mysql_engine="InnoDB", mysql_charset="utf8mb4", mysql_collate="utf8mb4_general_ci", comment="T+ 同步错误表(记录失败行和重试信息)",
|
||||
)
|
||||
op.create_index("idx_tplus_sync_errors_batch_no", "tplus_sync_errors", ["batch_no"])
|
||||
op.create_index("idx_tplus_sync_errors_entity_type", "tplus_sync_errors", ["entity_type"])
|
||||
|
||||
op.create_table(
|
||||
"tplus_sync_records",
|
||||
sa.Column("id", BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"),
|
||||
sa.Column("batch_no", sa.String(length=32), nullable=False, comment="同步批次号"),
|
||||
sa.Column("entity_type", sa.String(length=32), nullable=False, comment="业务实体类型"),
|
||||
sa.Column("source_table", sa.String(length=128), nullable=False, comment="T+ 源表"),
|
||||
sa.Column("source_id", sa.String(length=64), nullable=False, comment="T+ 源主键"),
|
||||
sa.Column("source_no", sa.String(length=64), nullable=True, comment="T+ 原始编号"),
|
||||
sa.Column("source_line_no", sa.String(length=64), nullable=True, comment="T+ 明细行号或明细主键"),
|
||||
sa.Column("source_updated_at", sa.DateTime(), nullable=True, comment="T+ 源数据更新时间"),
|
||||
sa.Column("row_hash", sa.String(length=64), nullable=False, comment="原始行 SHA-256"),
|
||||
sa.Column("state", sa.String(length=16), nullable=False, server_default="PENDING", comment="状态:PENDING/APPLIED/SKIPPED/ERROR"),
|
||||
sa.Column("payload_json", sa.JSON(), nullable=False, comment="原始行 JSON"),
|
||||
sa.Column("create_time", sa.DateTime(), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP"), comment="创建时间"),
|
||||
sa.Column("update_time", sa.DateTime(), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"), comment="更新时间"),
|
||||
sa.PrimaryKeyConstraint("id", name="pk_tplus_sync_records"),
|
||||
sa.UniqueConstraint("entity_type", "source_id", "row_hash", name="uk_tplus_sync_records_entity_source_hash"),
|
||||
mysql_engine="InnoDB", mysql_charset="utf8mb4", mysql_collate="utf8mb4_general_ci", comment="T+ 原始同步 staging 表(映射确认前只保存原始行,不直接改业务表)",
|
||||
)
|
||||
op.create_index("idx_tplus_sync_records_batch_no", "tplus_sync_records", ["batch_no"])
|
||||
op.create_index("idx_tplus_sync_records_source_no", "tplus_sync_records", ["source_no"])
|
||||
op.create_index("idx_tplus_sync_records_state", "tplus_sync_records", ["state"])
|
||||
|
||||
op.create_table(
|
||||
"tplus_sync_watermarks",
|
||||
sa.Column("id", BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"),
|
||||
sa.Column("entity_type", sa.String(length=32), nullable=False, comment="业务实体类型"),
|
||||
sa.Column("last_source_updated_at", sa.DateTime(), nullable=True, comment="最近源更新时间"),
|
||||
sa.Column("last_source_id", sa.String(length=64), nullable=True, comment="同一时间点的最后源主键"),
|
||||
sa.Column("status", sa.String(length=16), nullable=False, server_default="READY", comment="状态:READY/BLOCKED"),
|
||||
sa.Column("create_time", sa.DateTime(), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP"), comment="创建时间"),
|
||||
sa.Column("update_time", sa.DateTime(), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"), comment="更新时间"),
|
||||
sa.PrimaryKeyConstraint("id", name="pk_tplus_sync_watermarks"),
|
||||
sa.UniqueConstraint("entity_type", name="uk_tplus_sync_watermarks_entity_type"),
|
||||
mysql_engine="InnoDB", mysql_charset="utf8mb4", mysql_collate="utf8mb4_general_ci", comment="T+ 增量同步水位表",
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""删除 T+ 同步支撑表和源更新时间列。"""
|
||||
op.drop_table("tplus_sync_watermarks")
|
||||
op.drop_index("idx_tplus_sync_records_state", table_name="tplus_sync_records")
|
||||
op.drop_index("idx_tplus_sync_records_source_no", table_name="tplus_sync_records")
|
||||
op.drop_index("idx_tplus_sync_records_batch_no", table_name="tplus_sync_records")
|
||||
op.drop_table("tplus_sync_records")
|
||||
op.drop_index("idx_tplus_sync_errors_entity_type", table_name="tplus_sync_errors")
|
||||
op.drop_index("idx_tplus_sync_errors_batch_no", table_name="tplus_sync_errors")
|
||||
op.drop_table("tplus_sync_errors")
|
||||
op.drop_index("idx_tplus_sync_batches_started_at", table_name="tplus_sync_batches")
|
||||
op.drop_index("idx_tplus_sync_batches_status", table_name="tplus_sync_batches")
|
||||
op.drop_table("tplus_sync_batches")
|
||||
for table in ("receipts", "shipments", "businesses", "customers", "products", "personnel"):
|
||||
op.drop_column(table, "source_updated_at")
|
||||
118
backend/alembic/versions/e5f607a8b9c0_tplus_task_management.py
Normal file
118
backend/alembic/versions/e5f607a8b9c0_tplus_task_management.py
Normal file
@ -0,0 +1,118 @@
|
||||
"""V15 T+ 持久化任务、幂等和重试元数据。"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.mysql import BIGINT
|
||||
|
||||
revision: str = "e5f607a8b9c0"
|
||||
down_revision: str | Sequence[str] | None = "d4e5f6a7b8c9"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""仅扩展管理元数据,不改原始暂存行或工资业务表。"""
|
||||
op.add_column(
|
||||
"tplus_sync_batches",
|
||||
sa.Column("entity_types_json", sa.JSON(), comment="请求读取的实体类型;历史批次可为空"),
|
||||
)
|
||||
op.add_column(
|
||||
"tplus_sync_batches",
|
||||
sa.Column(
|
||||
"skipped_record_count",
|
||||
sa.Integer(),
|
||||
nullable=False,
|
||||
server_default="0",
|
||||
comment="重复快照跳过条数",
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
"tplus_sync_batches",
|
||||
sa.Column("parent_batch_no", sa.String(32), comment="重试来源批次号,保留原批次记录"),
|
||||
)
|
||||
op.add_column(
|
||||
"tplus_sync_batches",
|
||||
sa.Column("request_key", sa.String(36), comment="请求幂等键,空值用于历史和直接CLI任务"),
|
||||
)
|
||||
op.add_column(
|
||||
"tplus_sync_batches",
|
||||
sa.Column(
|
||||
"requested_by", BIGINT(unsigned=True), comment="请求用户ID,CLI任务为空,无物理外键"
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
"tplus_sync_batches",
|
||||
sa.Column("audit_result_json", sa.JSON(), comment="关联核验结果,只含系统字典和汇总"),
|
||||
)
|
||||
op.alter_column(
|
||||
"tplus_sync_batches",
|
||||
"started_at",
|
||||
existing_type=sa.DateTime(),
|
||||
nullable=True,
|
||||
comment="开始执行时间(排队时为空)",
|
||||
)
|
||||
op.alter_column(
|
||||
"tplus_sync_batches",
|
||||
"sync_mode",
|
||||
existing_type=sa.String(16),
|
||||
nullable=False,
|
||||
comment="任务方式:FULL全量 AUDIT关联核验 INCREMENTAL预留",
|
||||
)
|
||||
op.alter_column(
|
||||
"tplus_sync_batches",
|
||||
"status",
|
||||
existing_type=sa.String(16),
|
||||
nullable=False,
|
||||
comment="状态:QUEUED/RUNNING/SUCCEEDED/PARTIAL/FAILED",
|
||||
)
|
||||
op.create_unique_constraint(
|
||||
"uk_tplus_sync_batches_request_key", "tplus_sync_batches", ["request_key"]
|
||||
)
|
||||
op.create_index(
|
||||
"idx_tplus_sync_batches_parent_status", "tplus_sync_batches", ["parent_batch_no", "status"]
|
||||
)
|
||||
op.execute(
|
||||
"UPDATE tplus_sync_batches SET skipped_record_count=GREATEST(0, source_record_count-staged_record_count-failed_record_count) WHERE status='SUCCEEDED' AND sync_mode='FULL'"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""回滚前关闭 worker;原始暂存数据和历史批次仍保留。"""
|
||||
op.execute(
|
||||
"UPDATE tplus_sync_batches SET started_at=create_time, status='FAILED', finished_at=CURRENT_TIMESTAMP, error_message='任务管理版本已回滚' WHERE status='QUEUED'"
|
||||
)
|
||||
op.execute("UPDATE tplus_sync_batches SET started_at=create_time WHERE started_at IS NULL")
|
||||
op.drop_index("idx_tplus_sync_batches_parent_status", table_name="tplus_sync_batches")
|
||||
op.drop_constraint("uk_tplus_sync_batches_request_key", "tplus_sync_batches", type_="unique")
|
||||
for name in (
|
||||
"audit_result_json",
|
||||
"requested_by",
|
||||
"request_key",
|
||||
"parent_batch_no",
|
||||
"skipped_record_count",
|
||||
"entity_types_json",
|
||||
):
|
||||
op.drop_column("tplus_sync_batches", name)
|
||||
op.alter_column(
|
||||
"tplus_sync_batches",
|
||||
"started_at",
|
||||
existing_type=sa.DateTime(),
|
||||
nullable=False,
|
||||
comment="开始读取时间",
|
||||
)
|
||||
op.alter_column(
|
||||
"tplus_sync_batches",
|
||||
"sync_mode",
|
||||
existing_type=sa.String(16),
|
||||
nullable=False,
|
||||
comment="同步模式:FULL全量 INCREMENTAL增量",
|
||||
)
|
||||
op.alter_column(
|
||||
"tplus_sync_batches",
|
||||
"status",
|
||||
existing_type=sa.String(16),
|
||||
nullable=False,
|
||||
comment="状态:RUNNING/SUCCEEDED/PARTIAL/FAILED",
|
||||
)
|
||||
@ -16,6 +16,10 @@
|
||||
SET NAMES utf8mb4;
|
||||
|
||||
-- 删除顺序遵循外键依赖:先删子表
|
||||
DROP TABLE IF EXISTS `tplus_sync_watermarks`;
|
||||
DROP TABLE IF EXISTS `tplus_sync_records`;
|
||||
DROP TABLE IF EXISTS `tplus_sync_errors`;
|
||||
DROP TABLE IF EXISTS `tplus_sync_batches`;
|
||||
DROP TABLE IF EXISTS `performance_ledgers`;
|
||||
DROP TABLE IF EXISTS `settlement_items`;
|
||||
DROP TABLE IF EXISTS `adjustment_orders`;
|
||||
@ -282,6 +286,7 @@ CREATE TABLE personnel (
|
||||
t_plus_id VARCHAR(64) COMMENT 'T+ 系统内部标识',
|
||||
sync_batch_no VARCHAR(32) COMMENT '同步批次号(同步模块写入)',
|
||||
synced_at DATETIME COMMENT '最近同步时间',
|
||||
source_updated_at DATETIME COMMENT 'T+ 源数据更新时间(未确认时可为空)',
|
||||
CONSTRAINT pk_personnel PRIMARY KEY (id),
|
||||
CONSTRAINT fk_personnel_business_role_id FOREIGN KEY(business_role_id) REFERENCES business_roles (id) ON DELETE RESTRICT,
|
||||
CONSTRAINT fk_personnel_manager_id FOREIGN KEY(manager_id) REFERENCES personnel (id) ON DELETE RESTRICT
|
||||
@ -346,6 +351,7 @@ CREATE TABLE products (
|
||||
t_plus_id VARCHAR(64) COMMENT 'T+ 系统内部标识',
|
||||
sync_batch_no VARCHAR(32) COMMENT '同步批次号(同步模块写入)',
|
||||
synced_at DATETIME COMMENT '最近同步时间',
|
||||
source_updated_at DATETIME COMMENT 'T+ 源数据更新时间(未确认时可为空)',
|
||||
CONSTRAINT pk_products PRIMARY KEY (id),
|
||||
CONSTRAINT uk_products_name_spec UNIQUE (product_name, spec_model)
|
||||
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='产品表(类别锚定折算阈值参数;T+同步不覆盖产品类别)' COLLATE utf8mb4_general_ci;
|
||||
@ -374,6 +380,7 @@ CREATE TABLE customers (
|
||||
t_plus_id VARCHAR(64) COMMENT 'T+ 系统内部标识',
|
||||
sync_batch_no VARCHAR(32) COMMENT '同步批次号(同步模块写入)',
|
||||
synced_at DATETIME COMMENT '最近同步时间',
|
||||
source_updated_at DATETIME COMMENT 'T+ 源数据更新时间(未确认时可为空)',
|
||||
CONSTRAINT pk_customers PRIMARY KEY (id),
|
||||
CONSTRAINT uk_customers_company_owner UNIQUE (company_name, owner_name),
|
||||
CONSTRAINT fk_customers_owner_personnel_id FOREIGN KEY(owner_personnel_id) REFERENCES personnel (id) ON DELETE RESTRICT
|
||||
@ -518,6 +525,7 @@ CREATE TABLE businesses (
|
||||
t_plus_id VARCHAR(64) COMMENT 'T+ 系统内部标识',
|
||||
sync_batch_no VARCHAR(32) COMMENT '同步批次号(同步模块写入)',
|
||||
synced_at DATETIME COMMENT '最近同步时间',
|
||||
source_updated_at DATETIME COMMENT 'T+ 源数据更新时间(未确认时可为空)',
|
||||
CONSTRAINT pk_businesses PRIMARY KEY (id),
|
||||
CONSTRAINT uk_businesses_business_no UNIQUE (business_no),
|
||||
CONSTRAINT fk_businesses_customer_id FOREIGN KEY(customer_id) REFERENCES customers (id) ON DELETE RESTRICT,
|
||||
@ -602,6 +610,7 @@ CREATE TABLE shipments (
|
||||
t_plus_id VARCHAR(64) COMMENT 'T+ 系统内部标识',
|
||||
sync_batch_no VARCHAR(32) COMMENT '同步批次号(同步模块写入)',
|
||||
synced_at DATETIME COMMENT '最近同步时间',
|
||||
source_updated_at DATETIME COMMENT 'T+ 源数据更新时间(未确认时可为空)',
|
||||
CONSTRAINT pk_shipments PRIMARY KEY (id),
|
||||
CONSTRAINT uk_shipments_shipment_no UNIQUE (shipment_no),
|
||||
CONSTRAINT fk_shipments_business_id FOREIGN KEY(business_id) REFERENCES businesses (id) ON DELETE RESTRICT
|
||||
@ -656,6 +665,7 @@ CREATE TABLE receipts (
|
||||
t_plus_id VARCHAR(64) COMMENT 'T+ 系统内部标识',
|
||||
sync_batch_no VARCHAR(32) COMMENT '同步批次号(同步模块写入)',
|
||||
synced_at DATETIME COMMENT '最近同步时间',
|
||||
source_updated_at DATETIME COMMENT 'T+ 源数据更新时间(未确认时可为空)',
|
||||
CONSTRAINT pk_receipts PRIMARY KEY (id),
|
||||
CONSTRAINT uk_receipts_receipt_batch_no UNIQUE (receipt_batch_no),
|
||||
CONSTRAINT fk_receipts_business_id FOREIGN KEY(business_id) REFERENCES businesses (id) ON DELETE RESTRICT,
|
||||
@ -880,6 +890,90 @@ CREATE INDEX idx_performance_ledgers_month_personnel_type ON performance_ledgers
|
||||
|
||||
CREATE INDEX idx_performance_ledgers_personnel_id ON performance_ledgers (personnel_id);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- T+ 同步批次、错误、原始 staging 与增量水位表(V14)
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE TABLE tplus_sync_batches (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
|
||||
batch_no VARCHAR(32) NOT NULL COMMENT '同步批次号',
|
||||
sync_mode VARCHAR(16) NOT NULL COMMENT '任务方式:FULL全量 AUDIT关联核验 INCREMENTAL预留',
|
||||
status VARCHAR(16) NOT NULL COMMENT '状态:QUEUED/RUNNING/SUCCEEDED/PARTIAL/FAILED',
|
||||
source_database VARCHAR(128) COMMENT 'T+ 源数据库名',
|
||||
started_at DATETIME COMMENT '开始执行时间(排队时为空)',
|
||||
finished_at DATETIME COMMENT '结束时间',
|
||||
watermark_from DATETIME COMMENT '增量起始水位',
|
||||
watermark_to DATETIME COMMENT '本批次结束水位',
|
||||
source_record_count INT NOT NULL DEFAULT 0 COMMENT '源记录数',
|
||||
staged_record_count INT NOT NULL DEFAULT 0 COMMENT '写入 staging 的记录数',
|
||||
failed_record_count INT NOT NULL DEFAULT 0 COMMENT '失败记录数',
|
||||
error_message TEXT COMMENT '批次错误摘要',
|
||||
entity_types_json JSON COMMENT '请求读取的实体类型;历史批次可为空',
|
||||
skipped_record_count INT NOT NULL DEFAULT 0 COMMENT '重复快照跳过条数',
|
||||
parent_batch_no VARCHAR(32) COMMENT '重试来源批次号,保留原批次记录',
|
||||
request_key VARCHAR(36) COMMENT '请求幂等键,空值用于历史和直接CLI任务',
|
||||
requested_by BIGINT UNSIGNED COMMENT '请求用户ID,CLI任务为空,无物理外键',
|
||||
audit_result_json JSON COMMENT '关联核验结果,只含系统字典和汇总',
|
||||
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
CONSTRAINT pk_tplus_sync_batches PRIMARY KEY (id),
|
||||
CONSTRAINT uk_tplus_sync_batches_batch_no UNIQUE (batch_no),
|
||||
CONSTRAINT uk_tplus_sync_batches_request_key UNIQUE (request_key)
|
||||
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='T+ 同步批次表(记录读取范围、结果和对账统计)' COLLATE=utf8mb4_general_ci;
|
||||
CREATE INDEX idx_tplus_sync_batches_status ON tplus_sync_batches (status);
|
||||
CREATE INDEX idx_tplus_sync_batches_started_at ON tplus_sync_batches (started_at);
|
||||
|
||||
CREATE INDEX idx_tplus_sync_batches_parent_status ON tplus_sync_batches (parent_batch_no, status);
|
||||
|
||||
CREATE TABLE tplus_sync_errors (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
|
||||
batch_no VARCHAR(32) NOT NULL COMMENT '同步批次号',
|
||||
entity_type VARCHAR(32) NOT NULL COMMENT '业务实体类型',
|
||||
source_table VARCHAR(128) NOT NULL COMMENT 'T+ 源表',
|
||||
source_id VARCHAR(64) COMMENT 'T+ 源主键',
|
||||
source_line_no VARCHAR(64) COMMENT 'T+ 明细行号或明细主键',
|
||||
error_code VARCHAR(32) NOT NULL COMMENT '错误编码',
|
||||
error_message TEXT NOT NULL COMMENT '错误说明',
|
||||
retry_count INT NOT NULL DEFAULT 0 COMMENT '已重试次数',
|
||||
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
CONSTRAINT pk_tplus_sync_errors PRIMARY KEY (id)
|
||||
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='T+ 同步错误表(记录失败行和重试信息)' COLLATE=utf8mb4_general_ci;
|
||||
CREATE INDEX idx_tplus_sync_errors_batch_no ON tplus_sync_errors (batch_no);
|
||||
CREATE INDEX idx_tplus_sync_errors_entity_type ON tplus_sync_errors (entity_type);
|
||||
|
||||
CREATE TABLE tplus_sync_records (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
|
||||
batch_no VARCHAR(32) NOT NULL COMMENT '同步批次号',
|
||||
entity_type VARCHAR(32) NOT NULL COMMENT '业务实体类型',
|
||||
source_table VARCHAR(128) NOT NULL COMMENT 'T+ 源表',
|
||||
source_id VARCHAR(64) NOT NULL COMMENT 'T+ 源主键',
|
||||
source_no VARCHAR(64) COMMENT 'T+ 原始编号',
|
||||
source_line_no VARCHAR(64) COMMENT 'T+ 明细行号或明细主键',
|
||||
source_updated_at DATETIME COMMENT 'T+ 源数据更新时间',
|
||||
row_hash VARCHAR(64) NOT NULL COMMENT '原始行 SHA-256',
|
||||
state VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT '状态:PENDING/APPLIED/SKIPPED/ERROR',
|
||||
payload_json JSON NOT NULL COMMENT '原始行 JSON',
|
||||
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
CONSTRAINT pk_tplus_sync_records PRIMARY KEY (id),
|
||||
CONSTRAINT uk_tplus_sync_records_entity_source_hash UNIQUE (entity_type, source_id, row_hash)
|
||||
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='T+ 原始同步 staging 表(映射确认前只保存原始行,不直接改业务表)' COLLATE=utf8mb4_general_ci;
|
||||
CREATE INDEX idx_tplus_sync_records_batch_no ON tplus_sync_records (batch_no);
|
||||
CREATE INDEX idx_tplus_sync_records_source_no ON tplus_sync_records (source_no);
|
||||
CREATE INDEX idx_tplus_sync_records_state ON tplus_sync_records (state);
|
||||
|
||||
CREATE TABLE tplus_sync_watermarks (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
|
||||
entity_type VARCHAR(32) NOT NULL COMMENT '业务实体类型',
|
||||
last_source_updated_at DATETIME COMMENT '最近源更新时间',
|
||||
last_source_id VARCHAR(64) COMMENT '同一时间点的最后源主键',
|
||||
status VARCHAR(16) NOT NULL DEFAULT 'READY' COMMENT '状态:READY/BLOCKED',
|
||||
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
CONSTRAINT pk_tplus_sync_watermarks PRIMARY KEY (id),
|
||||
CONSTRAINT uk_tplus_sync_watermarks_entity_type UNIQUE (entity_type)
|
||||
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='T+ 增量同步水位表' COLLATE=utf8mb4_general_ci;
|
||||
|
||||
ALTER TABLE approval_orders MODIFY order_type TINYINT NOT NULL COMMENT '审批对象类型:1二次分配方案 2月度结算 3调整单';
|
||||
|
||||
-- 公司信息单行(system_settings 恒为一行,id=1 由迁移保底)
|
||||
@ -893,4 +987,4 @@ CREATE TABLE alembic_version (
|
||||
CONSTRAINT alembic_version_pkc PRIMARY KEY (version_num)
|
||||
)CHARSET=utf8mb4 ENGINE=InnoDB COLLATE utf8mb4_general_ci;
|
||||
|
||||
INSERT INTO alembic_version (version_num) VALUES ('c3a9e1d7b2f4');
|
||||
INSERT INTO alembic_version (version_num) VALUES ('e5f607a8b9c0');
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
-- V10: 客户评级落库(2026-10-23)
|
||||
-- V13: 客户评级落库(2026-10-23,原文件误标为 V10)
|
||||
-- 评级计算器从独立工具页并入客户管理(行内弹窗),评分保存为客户档案属性;
|
||||
-- 两列可空(null=未评分,存量客户无需回填),人工维护字段,T+ 同步不覆盖。
|
||||
-- 对应迁移:alembic/versions/c3a9e1d7b2f4_v13_客户评级落库.py
|
||||
@ -8,3 +8,5 @@ ALTER TABLE `customers`
|
||||
ADD COLUMN `rating_score` TINYINT COMMENT '评级总分(0-100,评级计算器保存;null=未评分;人工维护字段,T+ 同步不覆盖)' AFTER `remark`;
|
||||
ALTER TABLE `customers`
|
||||
ADD COLUMN `rating_time` DATETIME COMMENT '最近评级时间(评级计算器保存评分时刷新;人工维护字段)' AFTER `rating_score`;
|
||||
|
||||
UPDATE alembic_version SET version_num='c3a9e1d7b2f4' WHERE version_num='7f4f7b918bc4';
|
||||
83
db/migration/V14__tplus_sync_staging.sql
Normal file
83
db/migration/V14__tplus_sync_staging.sql
Normal file
@ -0,0 +1,83 @@
|
||||
-- V14: T+ 只读同步批次、错误、staging 和增量水位。
|
||||
-- 对应 Alembic:backend/alembic/versions/d4e5f6a7b8c9_tplus_sync_staging.py
|
||||
|
||||
ALTER TABLE personnel ADD COLUMN source_updated_at DATETIME COMMENT 'T+ 源数据更新时间(未确认时可为空)' AFTER synced_at;
|
||||
ALTER TABLE products ADD COLUMN source_updated_at DATETIME COMMENT 'T+ 源数据更新时间(未确认时可为空)' AFTER synced_at;
|
||||
ALTER TABLE customers ADD COLUMN source_updated_at DATETIME COMMENT 'T+ 源数据更新时间(未确认时可为空)' AFTER synced_at;
|
||||
ALTER TABLE businesses ADD COLUMN source_updated_at DATETIME COMMENT 'T+ 源数据更新时间(未确认时可为空)' AFTER synced_at;
|
||||
ALTER TABLE shipments ADD COLUMN source_updated_at DATETIME COMMENT 'T+ 源数据更新时间(未确认时可为空)' AFTER synced_at;
|
||||
ALTER TABLE receipts ADD COLUMN source_updated_at DATETIME COMMENT 'T+ 源数据更新时间(未确认时可为空)' AFTER synced_at;
|
||||
|
||||
CREATE TABLE tplus_sync_batches (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
||||
batch_no VARCHAR(32) NOT NULL COMMENT '同步批次号',
|
||||
sync_mode VARCHAR(16) NOT NULL COMMENT '同步模式:FULL全量 INCREMENTAL增量',
|
||||
status VARCHAR(16) NOT NULL COMMENT '状态:RUNNING/SUCCEEDED/PARTIAL/FAILED',
|
||||
source_database VARCHAR(128) COMMENT 'T+ 源数据库名',
|
||||
started_at DATETIME NOT NULL COMMENT '开始读取时间',
|
||||
finished_at DATETIME COMMENT '结束时间',
|
||||
watermark_from DATETIME COMMENT '增量起始水位',
|
||||
watermark_to DATETIME COMMENT '本批次结束水位',
|
||||
source_record_count INT NOT NULL DEFAULT 0 COMMENT '源记录数',
|
||||
staged_record_count INT NOT NULL DEFAULT 0 COMMENT '写入 staging 的记录数',
|
||||
failed_record_count INT NOT NULL DEFAULT 0 COMMENT '失败记录数',
|
||||
error_message TEXT COMMENT '批次错误摘要',
|
||||
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
CONSTRAINT pk_tplus_sync_batches PRIMARY KEY (id),
|
||||
CONSTRAINT uk_tplus_sync_batches_batch_no UNIQUE (batch_no)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='T+ 同步批次表(记录读取范围、结果和对账统计)';
|
||||
CREATE INDEX idx_tplus_sync_batches_status ON tplus_sync_batches (status);
|
||||
CREATE INDEX idx_tplus_sync_batches_started_at ON tplus_sync_batches (started_at);
|
||||
|
||||
CREATE TABLE tplus_sync_errors (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
||||
batch_no VARCHAR(32) NOT NULL COMMENT '同步批次号',
|
||||
entity_type VARCHAR(32) NOT NULL COMMENT '业务实体类型',
|
||||
source_table VARCHAR(128) NOT NULL COMMENT 'T+ 源表',
|
||||
source_id VARCHAR(64) COMMENT 'T+ 源主键',
|
||||
source_line_no VARCHAR(64) COMMENT 'T+ 明细行号或明细主键',
|
||||
error_code VARCHAR(32) NOT NULL COMMENT '错误编码',
|
||||
error_message TEXT NOT NULL COMMENT '错误说明',
|
||||
retry_count INT NOT NULL DEFAULT 0 COMMENT '已重试次数',
|
||||
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
CONSTRAINT pk_tplus_sync_errors PRIMARY KEY (id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='T+ 同步错误表(记录失败行和重试信息)';
|
||||
CREATE INDEX idx_tplus_sync_errors_batch_no ON tplus_sync_errors (batch_no);
|
||||
CREATE INDEX idx_tplus_sync_errors_entity_type ON tplus_sync_errors (entity_type);
|
||||
|
||||
CREATE TABLE tplus_sync_records (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
||||
batch_no VARCHAR(32) NOT NULL COMMENT '同步批次号',
|
||||
entity_type VARCHAR(32) NOT NULL COMMENT '业务实体类型',
|
||||
source_table VARCHAR(128) NOT NULL COMMENT 'T+ 源表',
|
||||
source_id VARCHAR(64) NOT NULL COMMENT 'T+ 源主键',
|
||||
source_no VARCHAR(64) COMMENT 'T+ 原始编号',
|
||||
source_line_no VARCHAR(64) COMMENT 'T+ 明细行号或明细主键',
|
||||
source_updated_at DATETIME COMMENT 'T+ 源数据更新时间',
|
||||
row_hash VARCHAR(64) NOT NULL COMMENT '原始行 SHA-256',
|
||||
state VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT '状态:PENDING/APPLIED/SKIPPED/ERROR',
|
||||
payload_json JSON NOT NULL COMMENT '原始行 JSON',
|
||||
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
CONSTRAINT pk_tplus_sync_records PRIMARY KEY (id),
|
||||
CONSTRAINT uk_tplus_sync_records_entity_source_hash UNIQUE (entity_type, source_id, row_hash)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='T+ 原始同步 staging 表(映射确认前只保存原始行,不直接改业务表)';
|
||||
CREATE INDEX idx_tplus_sync_records_batch_no ON tplus_sync_records (batch_no);
|
||||
CREATE INDEX idx_tplus_sync_records_source_no ON tplus_sync_records (source_no);
|
||||
CREATE INDEX idx_tplus_sync_records_state ON tplus_sync_records (state);
|
||||
|
||||
CREATE TABLE tplus_sync_watermarks (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
||||
entity_type VARCHAR(32) NOT NULL COMMENT '业务实体类型',
|
||||
last_source_updated_at DATETIME COMMENT '最近源更新时间',
|
||||
last_source_id VARCHAR(64) COMMENT '同一时间点的最后源主键',
|
||||
status VARCHAR(16) NOT NULL DEFAULT 'READY' COMMENT '状态:READY/BLOCKED',
|
||||
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
CONSTRAINT pk_tplus_sync_watermarks PRIMARY KEY (id),
|
||||
CONSTRAINT uk_tplus_sync_watermarks_entity_type UNIQUE (entity_type)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='T+ 增量同步水位表';
|
||||
|
||||
UPDATE alembic_version SET version_num='d4e5f6a7b8c9' WHERE alembic_version.version_num = 'c3a9e1d7b2f4';
|
||||
14
db/migration/V15__tplus_task_management.sql
Normal file
14
db/migration/V15__tplus_task_management.sql
Normal file
@ -0,0 +1,14 @@
|
||||
-- T+ 任务管理扩展;对应 e5f607a8b9c0,不修改工资业务数据。
|
||||
ALTER TABLE tplus_sync_batches ADD COLUMN entity_types_json JSON COMMENT '请求读取的实体类型;历史批次可为空';
|
||||
ALTER TABLE tplus_sync_batches ADD COLUMN skipped_record_count INT NOT NULL DEFAULT 0 COMMENT '重复快照跳过条数';
|
||||
ALTER TABLE tplus_sync_batches ADD COLUMN parent_batch_no VARCHAR(32) COMMENT '重试来源批次号,保留原批次记录';
|
||||
ALTER TABLE tplus_sync_batches ADD COLUMN request_key VARCHAR(36) COMMENT '请求幂等键,空值用于历史和直接CLI任务';
|
||||
ALTER TABLE tplus_sync_batches ADD COLUMN requested_by BIGINT UNSIGNED COMMENT '请求用户ID,CLI任务为空,无物理外键';
|
||||
ALTER TABLE tplus_sync_batches ADD COLUMN audit_result_json JSON COMMENT '关联核验结果,只含系统字典和汇总';
|
||||
ALTER TABLE tplus_sync_batches MODIFY COLUMN started_at DATETIME NULL COMMENT '开始执行时间(排队时为空)';
|
||||
ALTER TABLE tplus_sync_batches MODIFY COLUMN sync_mode VARCHAR(16) NOT NULL COMMENT '任务方式:FULL全量 AUDIT关联核验 INCREMENTAL预留';
|
||||
ALTER TABLE tplus_sync_batches MODIFY COLUMN status VARCHAR(16) NOT NULL COMMENT '状态:QUEUED/RUNNING/SUCCEEDED/PARTIAL/FAILED';
|
||||
ALTER TABLE tplus_sync_batches ADD CONSTRAINT uk_tplus_sync_batches_request_key UNIQUE (request_key);
|
||||
CREATE INDEX idx_tplus_sync_batches_parent_status ON tplus_sync_batches (parent_batch_no, status);
|
||||
UPDATE tplus_sync_batches SET skipped_record_count=GREATEST(0, source_record_count-staged_record_count-failed_record_count) WHERE status='SUCCEEDED' AND sync_mode='FULL';
|
||||
UPDATE alembic_version SET version_num='e5f607a8b9c0' WHERE version_num='d4e5f6a7b8c9';
|
||||
68
db/queries/tplus/audit/queries.sql
Normal file
68
db/queries/tplus/audit/queries.sql
Normal file
@ -0,0 +1,68 @@
|
||||
-- 关联核验固定查询,全部只读;源库仍按 SQL Server 2008 R2 语法编写。
|
||||
-- @query source_version
|
||||
SELECT CONVERT(varchar(80), SERVERPROPERTY('ProductVersion')) AS source_version;
|
||||
|
||||
-- @query voucher_types
|
||||
SELECT ID AS id, code, name FROM dbo.SM_VoucherType ORDER BY ID;
|
||||
|
||||
-- @query state_enums
|
||||
SELECT i.ID AS id, i.Code AS code, i.Name AS name, e.Name AS enum_name
|
||||
FROM dbo.eap_EnumItem AS i
|
||||
LEFT JOIN dbo.eap_Enum AS e ON e.id = i.idEnum
|
||||
WHERE i.ID IN (
|
||||
SELECT voucherstate FROM dbo.ARAP_ReceivePayment
|
||||
UNION SELECT cancelState FROM dbo.ARAP_ReceivePayment
|
||||
)
|
||||
ORDER BY i.ID;
|
||||
|
||||
-- @query receipt_kinds
|
||||
SELECT CASE h.isReceiveFlag WHEN 1 THEN 'RECEIVE' WHEN 0 THEN 'PAYMENT' ELSE 'UNKNOWN' END AS voucher_type_code,
|
||||
CASE h.isReceiveFlag WHEN 1 THEN N'收款' WHEN 0 THEN N'付款' ELSE N'未知方向' END AS voucher_type_name,
|
||||
h.isReceiveFlag AS receive_flag, COUNT_BIG(*) AS row_count
|
||||
FROM dbo.ARAP_ReceivePayment AS h
|
||||
GROUP BY h.isReceiveFlag;
|
||||
|
||||
-- @query receipt_line_kinds
|
||||
SELECT b.idvouchertype AS voucher_type_id, v.name AS voucher_type_name,
|
||||
h.isReceiveFlag AS receive_flag, COUNT_BIG(*) AS row_count
|
||||
FROM dbo.ARAP_ReceivePayment_b AS b
|
||||
LEFT JOIN dbo.SM_VoucherType AS v ON v.ID = b.idvouchertype
|
||||
LEFT JOIN dbo.ARAP_ReceivePayment AS h ON h.ID = b.idArapReceivePaymentDTO
|
||||
GROUP BY b.idvouchertype, v.name, h.isReceiveFlag;
|
||||
|
||||
-- @query sales_links
|
||||
SELECT COUNT_BIG(*) AS row_count,
|
||||
COALESCE(SUM(CASE WHEN h.ID IS NOT NULL THEN 1 ELSE 0 END), 0) AS delivery_matches,
|
||||
COALESCE(SUM(CASE WHEN d.ID IS NOT NULL THEN 1 ELSE 0 END), 0) AS detail_matches,
|
||||
COALESCE(SUM(CASE WHEN o.ID IS NOT NULL THEN 1 ELSE 0 END), 0) AS direct_order_matches,
|
||||
COALESCE(SUM(CASE WHEN detail_order.ID IS NOT NULL THEN 1 ELSE 0 END), 0) AS detail_order_matches,
|
||||
COALESCE(SUM(CASE WHEN (o.ID IS NOT NULL OR detail_order.ID IS NOT NULL)
|
||||
AND NOT (b.saleorderid > 0 AND d.saleOrderId > 0 AND b.saleorderid <> d.saleOrderId)
|
||||
THEN 1 ELSE 0 END), 0) AS unambiguous_order_matches,
|
||||
COALESCE(SUM(CASE WHEN b.saleorderid > 0 AND o.ID IS NULL THEN 1 ELSE 0 END), 0) AS invalid_order_ids,
|
||||
COALESCE(SUM(CASE WHEN b.saleorderid > 0 AND d.saleOrderId > 0
|
||||
AND b.saleorderid <> d.saleOrderId THEN 1 ELSE 0 END), 0) AS conflicting_order_ids
|
||||
FROM dbo.ARAP_ReceivePayment_b AS b
|
||||
JOIN dbo.ARAP_ReceivePayment AS p ON p.ID = b.idArapReceivePaymentDTO
|
||||
LEFT JOIN dbo.SA_SaleDelivery AS h ON h.ID = b.voucherID
|
||||
LEFT JOIN dbo.SA_SaleDelivery_b AS d ON d.ID = b.voucherDetailID
|
||||
AND d.idSaleDeliveryDTO = b.voucherID
|
||||
LEFT JOIN dbo.SA_SaleOrder AS o ON o.ID = b.saleorderid
|
||||
LEFT JOIN dbo.SA_SaleOrder AS detail_order ON detail_order.ID = d.saleOrderId
|
||||
WHERE b.idvouchertype = :sale_delivery_type_id AND p.isReceiveFlag = :receive_flag;
|
||||
|
||||
-- @query order_coverage
|
||||
WITH delivery_orders AS (
|
||||
SELECT idSaleDeliveryDTO, COUNT(DISTINCT saleOrderId) AS order_count
|
||||
FROM dbo.SA_SaleDelivery_b
|
||||
WHERE saleOrderId > 0
|
||||
GROUP BY idSaleDeliveryDTO
|
||||
)
|
||||
SELECT COUNT_BIG(*) AS row_count,
|
||||
COALESCE(SUM(CASE WHEN d.order_count IS NULL THEN 1 ELSE 0 END), 0) AS without_order,
|
||||
COALESCE(SUM(CASE WHEN d.order_count = 1 THEN 1 ELSE 0 END), 0) AS single_order,
|
||||
COALESCE(SUM(CASE WHEN d.order_count > 1 THEN 1 ELSE 0 END), 0) AS multiple_orders
|
||||
FROM dbo.ARAP_ReceivePayment_b AS b
|
||||
JOIN dbo.ARAP_ReceivePayment AS p ON p.ID = b.idArapReceivePaymentDTO
|
||||
LEFT JOIN delivery_orders AS d ON d.idSaleDeliveryDTO = b.voucherID
|
||||
WHERE b.idvouchertype = :sale_delivery_type_id AND p.isReceiveFlag = :receive_flag;
|
||||
21
db/queries/tplus/business_items.sql
Normal file
21
db/queries/tplus/business_items.sql
Normal file
@ -0,0 +1,21 @@
|
||||
-- 已在客户备份核对列结构;业务语义仍待确认。
|
||||
-- SQL Server 2008 R2 语法;主键游标分页,时间水位尚未启用。
|
||||
SELECT TOP (:batch_size)
|
||||
b.id AS id,
|
||||
b.code AS code,
|
||||
b.id AS line_no,
|
||||
b.idSaleOrderDTO AS source_parent_id,
|
||||
b.idinventory,
|
||||
b.quantity,
|
||||
b.baseQuantity,
|
||||
b.price,
|
||||
b.taxPrice,
|
||||
b.taxAmount,
|
||||
b.discountAmount,
|
||||
b.LatestCost,
|
||||
b.IsClose,
|
||||
b.updated AS source_updated_at,
|
||||
b.ts AS source_rowversion
|
||||
FROM dbo.SA_SaleOrder_b AS b
|
||||
WHERE b.id > :after_id
|
||||
ORDER BY b.id;
|
||||
18
db/queries/tplus/businesses.sql
Normal file
18
db/queries/tplus/businesses.sql
Normal file
@ -0,0 +1,18 @@
|
||||
-- 已在客户备份核对列结构;业务语义仍待确认。
|
||||
-- SQL Server 2008 R2 语法;主键游标分页,时间水位尚未启用。
|
||||
SELECT TOP (:batch_size)
|
||||
h.ID AS id,
|
||||
h.code AS code,
|
||||
h.voucherdate,
|
||||
h.idcustomer,
|
||||
h.idclerk,
|
||||
h.idbusinesstype,
|
||||
h.amount,
|
||||
h.taxAmount,
|
||||
h.voucherState,
|
||||
h.isCancel,
|
||||
h.updated AS source_updated_at,
|
||||
h.ts AS source_rowversion
|
||||
FROM dbo.SA_SaleOrder AS h
|
||||
WHERE h.id > :after_id
|
||||
ORDER BY h.id;
|
||||
16
db/queries/tplus/customers.sql
Normal file
16
db/queries/tplus/customers.sql
Normal file
@ -0,0 +1,16 @@
|
||||
-- 已在客户备份核对列结构;业务语义仍待确认。
|
||||
-- SQL Server 2008 R2 语法;主键游标分页,时间水位尚未启用。
|
||||
SELECT TOP (:batch_size)
|
||||
p.id AS id,
|
||||
p.code AS code,
|
||||
p.name,
|
||||
p.Contact,
|
||||
p.representative,
|
||||
p.disabled,
|
||||
p.idsaleman,
|
||||
p.partnerType,
|
||||
p.updated AS source_updated_at,
|
||||
p.ts AS source_rowversion
|
||||
FROM dbo.AA_Partner AS p
|
||||
WHERE p.id > :after_id
|
||||
ORDER BY p.id;
|
||||
16
db/queries/tplus/personnel.sql
Normal file
16
db/queries/tplus/personnel.sql
Normal file
@ -0,0 +1,16 @@
|
||||
-- 已在客户备份核对列结构;业务语义仍待确认。
|
||||
-- SQL Server 2008 R2 语法;主键游标分页,时间水位尚未启用。
|
||||
SELECT TOP (:batch_size)
|
||||
p.id AS id,
|
||||
p.code AS code,
|
||||
p.name,
|
||||
p.disabled,
|
||||
p.mobilePhoneNo,
|
||||
p.officePhoneNo,
|
||||
p.iddepartment,
|
||||
p.isSalesman,
|
||||
p.updated AS source_updated_at,
|
||||
p.ts AS source_rowversion
|
||||
FROM dbo.AA_Person AS p
|
||||
WHERE p.id > :after_id
|
||||
ORDER BY p.id;
|
||||
16
db/queries/tplus/products.sql
Normal file
16
db/queries/tplus/products.sql
Normal file
@ -0,0 +1,16 @@
|
||||
-- 已在客户备份核对列结构;业务语义仍待确认。
|
||||
-- SQL Server 2008 R2 语法;主键游标分页,时间水位尚未启用。
|
||||
SELECT TOP (:batch_size)
|
||||
i.id AS id,
|
||||
i.code AS code,
|
||||
i.name,
|
||||
i.specification,
|
||||
i.idunit,
|
||||
i.idinventoryclass,
|
||||
i.disabled,
|
||||
i.invSCost,
|
||||
i.updated AS source_updated_at,
|
||||
i.ts AS source_rowversion
|
||||
FROM dbo.AA_Inventory AS i
|
||||
WHERE i.id > :after_id
|
||||
ORDER BY i.id;
|
||||
24
db/queries/tplus/receipt_items.sql
Normal file
24
db/queries/tplus/receipt_items.sql
Normal file
@ -0,0 +1,24 @@
|
||||
-- 已在客户备份核对列结构;业务语义仍待确认。
|
||||
-- SQL Server 2008 R2 语法;主键游标分页,时间水位尚未启用。
|
||||
SELECT TOP (:batch_size)
|
||||
b.id AS id,
|
||||
b.code AS code,
|
||||
b.id AS line_no,
|
||||
b.idArapReceivePaymentDTO AS source_parent_id,
|
||||
b.voucherID,
|
||||
b.voucherDetailID,
|
||||
b.saleorderid,
|
||||
b.saleordercode,
|
||||
b.sourceorderid,
|
||||
b.idsourceordertype,
|
||||
b.idvouchertype,
|
||||
b.voucherDate,
|
||||
b.arrivalDate,
|
||||
b.currentAmount,
|
||||
b.Amount,
|
||||
b.VoucherAmount,
|
||||
CAST(NULL AS datetime) AS source_updated_at,
|
||||
b.ts AS source_rowversion
|
||||
FROM dbo.ARAP_ReceivePayment_b AS b
|
||||
WHERE b.id > :after_id
|
||||
ORDER BY b.id;
|
||||
24
db/queries/tplus/receipts.sql
Normal file
24
db/queries/tplus/receipts.sql
Normal file
@ -0,0 +1,24 @@
|
||||
-- 已在客户备份核对列结构;业务语义仍待确认。
|
||||
-- SQL Server 2008 R2 语法;主键游标分页,时间水位尚未启用。
|
||||
SELECT TOP (:batch_size)
|
||||
h.ID AS id,
|
||||
h.code AS code,
|
||||
h.voucherDate,
|
||||
h.auditedDate,
|
||||
h.createdTime,
|
||||
h.SaleOrderCode,
|
||||
h.SaleOrderID,
|
||||
h.idpartner,
|
||||
h.idperson,
|
||||
h.IdSettleStyle,
|
||||
h.idvouchertype,
|
||||
h.isReceiveFlag,
|
||||
h.cancelState,
|
||||
h.voucherstate,
|
||||
h.amount,
|
||||
h.paymentAmount,
|
||||
CAST(NULL AS datetime) AS source_updated_at,
|
||||
h.ts AS source_rowversion
|
||||
FROM dbo.ARAP_ReceivePayment AS h
|
||||
WHERE h.id > :after_id
|
||||
ORDER BY h.id;
|
||||
26
db/queries/tplus/shipment_items.sql
Normal file
26
db/queries/tplus/shipment_items.sql
Normal file
@ -0,0 +1,26 @@
|
||||
-- 已在客户备份核对列结构;业务语义仍待确认。
|
||||
-- SQL Server 2008 R2 语法;主键游标分页,时间水位尚未启用。
|
||||
SELECT TOP (:batch_size)
|
||||
b.ID AS id,
|
||||
b.code AS code,
|
||||
b.ID AS line_no,
|
||||
b.idSaleDeliveryDTO AS source_parent_id,
|
||||
b.idinventory,
|
||||
b.saleOrderId,
|
||||
b.saleOrderDetailId,
|
||||
b.quantity,
|
||||
b.baseQuantity,
|
||||
b.price,
|
||||
b.taxPrice,
|
||||
b.taxAmount,
|
||||
b.costPrice,
|
||||
b.costAmount,
|
||||
b.CostPriceGetType,
|
||||
b.returnQuntity,
|
||||
b.backSentQuantity,
|
||||
b.isCancel,
|
||||
b.updated AS source_updated_at,
|
||||
b.ts AS source_rowversion
|
||||
FROM dbo.SA_SaleDelivery_b AS b
|
||||
WHERE b.id > :after_id
|
||||
ORDER BY b.id;
|
||||
23
db/queries/tplus/shipments.sql
Normal file
23
db/queries/tplus/shipments.sql
Normal file
@ -0,0 +1,23 @@
|
||||
-- 已在客户备份核对列结构;业务语义仍待确认。
|
||||
-- SQL Server 2008 R2 语法;主键游标分页,时间水位尚未启用。
|
||||
SELECT TOP (:batch_size)
|
||||
h.ID AS id,
|
||||
h.code AS code,
|
||||
h.voucherdate,
|
||||
h.SendDate,
|
||||
h.deliveryDate,
|
||||
h.auditeddate,
|
||||
h.SourceVoucherCode,
|
||||
h.SaleOrderCode,
|
||||
h.idcustomer,
|
||||
h.idclerk,
|
||||
h.amount,
|
||||
h.taxAmount,
|
||||
h.voucherState,
|
||||
h.isCancel,
|
||||
h.isCounteractDelivery,
|
||||
h.updated AS source_updated_at,
|
||||
h.ts AS source_rowversion
|
||||
FROM dbo.SA_SaleDelivery AS h
|
||||
WHERE h.id > :after_id
|
||||
ORDER BY h.id;
|
||||
43
docker/docker-compose.tplus.dev.yml
Normal file
43
docker/docker-compose.tplus.dev.yml
Normal file
@ -0,0 +1,43 @@
|
||||
# T+ SQL Server 恢复 PoC
|
||||
#
|
||||
# 使用前:
|
||||
# 1. 复制 docker/tplus.env.example 为 D:\work\app\tplus-sqlserver\.env;
|
||||
# 2. 将客户 .bak 放入 D:\work\app\tplus-sqlserver\backup;
|
||||
# 3. SQL Server 数据、日志和备份通过绑定目录写入 D 盘;不修改 Docker Desktop 的磁盘镜像设置。
|
||||
name: ys-payroll-tplus
|
||||
|
||||
services:
|
||||
tplus-sqlserver:
|
||||
image: ${TPLUS_SQLSERVER_IMAGE:-mcr.microsoft.com/mssql/server:2022-CU16-ubuntu-22.04}
|
||||
container_name: ys-tplus-sqlserver-dev
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
ACCEPT_EULA: "Y"
|
||||
MSSQL_PID: Developer
|
||||
MSSQL_SA_PASSWORD: ${TPLUS_SA_PASSWORD:?请在 D:/work/app/tplus-sqlserver/.env 设置 TPLUS_SA_PASSWORD}
|
||||
TZ: Asia/Shanghai
|
||||
ports:
|
||||
- "1433:1433"
|
||||
volumes:
|
||||
- type: bind
|
||||
source: ${TPLUS_ROOT:-D:/work/app/tplus-sqlserver}/data
|
||||
target: /var/opt/mssql/data
|
||||
- type: bind
|
||||
source: ${TPLUS_ROOT:-D:/work/app/tplus-sqlserver}/log
|
||||
target: /var/opt/mssql/log
|
||||
- type: bind
|
||||
source: ${TPLUS_ROOT:-D:/work/app/tplus-sqlserver}/backup
|
||||
target: /var/opt/mssql/backup
|
||||
read_only: true
|
||||
- type: bind
|
||||
source: ${TPLUS_ROOT:-D:/work/app/tplus-sqlserver}/scripts
|
||||
target: /opt/tplus/scripts
|
||||
read_only: true
|
||||
healthcheck:
|
||||
test:
|
||||
- CMD-SHELL
|
||||
- /opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P "$${MSSQL_SA_PASSWORD}" -C -Q "SELECT 1" >/dev/null 2>&1
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 20
|
||||
start_period: 30s
|
||||
5
docker/tplus.env.example
Normal file
5
docker/tplus.env.example
Normal file
@ -0,0 +1,5 @@
|
||||
# 复制到 D:\work\app\tplus-sqlserver\.env 后填写。
|
||||
# 不要把真实密码写入仓库或提交 Git。
|
||||
TPLUS_ROOT=D:/work/app/tplus-sqlserver
|
||||
TPLUS_SQLSERVER_IMAGE=mcr.microsoft.com/mssql/server:2022-CU16-ubuntu-22.04
|
||||
TPLUS_SA_PASSWORD=
|
||||
106
scripts/tplus/check_compatibility.py
Normal file
106
scripts/tplus/check_compatibility.py
Normal file
@ -0,0 +1,106 @@
|
||||
"""只读核验真实 SQL Server 2008 R2;2022 PoC 不能获得兼容通过结果。"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
|
||||
import pyodbc
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "backend"))
|
||||
|
||||
from sqlalchemy import URL, create_engine
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.services.tplus.mapping import DETAIL_MAPPINGS, TPLUS_MAPPINGS
|
||||
from app.services.tplus.reader import TPlusReader
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""检查版本、中文、金额、日期和写权限,结果不包含凭据或客户记录。"""
|
||||
parser = argparse.ArgumentParser(description="SQL Server 2008 R2 兼容性验收")
|
||||
parser.add_argument("--connection-file", required=True, type=Path)
|
||||
parser.add_argument("--report", required=True, type=Path)
|
||||
args = parser.parse_args()
|
||||
config = json.loads(args.connection_file.read_text(encoding="utf-8-sig"))
|
||||
connection_string = (
|
||||
"DRIVER={"
|
||||
+ config["driver"]
|
||||
+ "};SERVER="
|
||||
+ config["host"]
|
||||
+ ","
|
||||
+ str(config["port"])
|
||||
+ ";DATABASE="
|
||||
+ config["database"]
|
||||
+ ";UID="
|
||||
+ config["user"]
|
||||
+ ";PWD={"
|
||||
+ config["password"].replace("}", "}}")
|
||||
+ "};"
|
||||
)
|
||||
with pyodbc.connect(connection_string, timeout=10) as connection:
|
||||
connection.timeout = 15
|
||||
cursor = connection.cursor()
|
||||
version = str(
|
||||
cursor.execute(
|
||||
"SELECT CAST(SERVERPROPERTY('ProductVersion') AS varchar(80))"
|
||||
).fetchone()[0]
|
||||
)
|
||||
values = cursor.execute(
|
||||
"SELECT N'中文核验', CAST('12345.6789' AS decimal(19,4)), "
|
||||
"CAST('2008-01-02T03:04:05' AS datetime)"
|
||||
).fetchone()
|
||||
permissions = cursor.execute(
|
||||
"SELECT HAS_PERMS_BY_NAME(DB_NAME(),'DATABASE','INSERT'), "
|
||||
"HAS_PERMS_BY_NAME(DB_NAME(),'DATABASE','UPDATE'), "
|
||||
"HAS_PERMS_BY_NAME(DB_NAME(),'DATABASE','DELETE')"
|
||||
).fetchone()
|
||||
checks = {
|
||||
"sqlserver_2008r2": version.startswith("10.50."),
|
||||
"unicode_roundtrip": values[0] == "中文核验",
|
||||
"decimal_exact": values[1] == Decimal("12345.6789"),
|
||||
"datetime_exact": values[2] == datetime(2008, 1, 2, 3, 4, 5),
|
||||
"no_dml_permissions": all(x == 0 for x in permissions),
|
||||
}
|
||||
report = {
|
||||
"server_version": version,
|
||||
"driver_name": connection.getinfo(pyodbc.SQL_DRIVER_NAME),
|
||||
"driver_version": connection.getinfo(pyodbc.SQL_DRIVER_VER),
|
||||
"checks": checks,
|
||||
"compatible": all(checks.values()),
|
||||
}
|
||||
url = URL.create(
|
||||
"mssql+pyodbc",
|
||||
username=config["user"],
|
||||
password=config["password"],
|
||||
host=config["host"],
|
||||
port=config["port"],
|
||||
database=config["database"],
|
||||
query={"driver": config["driver"]},
|
||||
)
|
||||
engine = create_engine(url, hide_parameters=True)
|
||||
adapter_checks = {}
|
||||
try:
|
||||
with Session(engine) as session:
|
||||
reader = TPlusReader(session)
|
||||
for mapping in (*TPLUS_MAPPINGS, *DETAIL_MAPPINGS):
|
||||
try:
|
||||
reader.fetch_entity(mapping.entity_type, max_rows=1)
|
||||
adapter_checks[mapping.entity_type] = True
|
||||
except Exception as exc:
|
||||
adapter_checks[mapping.entity_type] = False
|
||||
report.setdefault("errors", {})[mapping.entity_type] = type(exc).__name__
|
||||
finally:
|
||||
engine.dispose()
|
||||
report["adapter_queries"] = adapter_checks
|
||||
report["compatible"] = report["compatible"] and all(adapter_checks.values())
|
||||
args.report.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.report.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
print(json.dumps(report, ensure_ascii=False))
|
||||
return 0 if report["compatible"] else 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
20
scripts/tplus/container_sql.ps1
Normal file
20
scripts/tplus/container_sql.ps1
Normal file
@ -0,0 +1,20 @@
|
||||
# 共享函数:密码只通过环境传入进程,SQL 通过标准输入传入,避免出现在命令行。
|
||||
function Invoke-TPlusContainerSql {
|
||||
param(
|
||||
[string]$ContainerName,
|
||||
[string]$Sql,
|
||||
[SecureString]$SaPassword
|
||||
)
|
||||
$ptr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($SaPassword)
|
||||
$previousPassword = [Environment]::GetEnvironmentVariable("SQLCMDPASSWORD", "Process")
|
||||
try {
|
||||
$env:SQLCMDPASSWORD = [Runtime.InteropServices.Marshal]::PtrToStringBSTR($ptr)
|
||||
$OutputEncoding = [Text.UTF8Encoding]::new($false)
|
||||
$Sql | docker exec -i --env SQLCMDPASSWORD $ContainerName /opt/mssql-tools18/bin/sqlcmd `
|
||||
-S localhost -U sa -C -b -t 0 -w 65535 -W -s "|"
|
||||
if ($LASTEXITCODE -ne 0) { throw "SQL Server 命令失败,请检查已脱敏的核验结果。" }
|
||||
} finally {
|
||||
[Environment]::SetEnvironmentVariable("SQLCMDPASSWORD", $previousPassword, "Process")
|
||||
[Runtime.InteropServices.Marshal]::ZeroFreeBSTR($ptr)
|
||||
}
|
||||
}
|
||||
56
scripts/tplus/create_tplus_readonly_login.ps1
Normal file
56
scripts/tplus/create_tplus_readonly_login.ps1
Normal file
@ -0,0 +1,56 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$DatabaseName,
|
||||
[string]$LoginName = "tplus_reader",
|
||||
[string]$ContainerName = "ys-tplus-sqlserver-dev",
|
||||
[Parameter(Mandatory = $true)]
|
||||
[SecureString]$SaPassword,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[SecureString]$ReaderPassword
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = "Stop"
|
||||
. (Join-Path $PSScriptRoot "container_sql.ps1")
|
||||
|
||||
function Convert-SecureStringToPlainText([SecureString]$Value) {
|
||||
$ptr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($Value)
|
||||
try { return [Runtime.InteropServices.Marshal]::PtrToStringBSTR($ptr) }
|
||||
finally { [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($ptr) }
|
||||
}
|
||||
|
||||
if ($LoginName -notmatch '^[A-Za-z_][A-Za-z0-9_]{0,63}$') {
|
||||
throw "只读账号名只能包含字母、数字和下划线,且不能以数字开头。"
|
||||
}
|
||||
|
||||
$readerPasswordText = Convert-SecureStringToPlainText $ReaderPassword
|
||||
$safeDatabaseIdentifier = $DatabaseName.Replace("]", "]]" )
|
||||
$safeDatabaseLiteral = $DatabaseName.Replace("'", "''")
|
||||
$safeLoginIdentifier = $LoginName.Replace("]", "]]" )
|
||||
$safeReaderPassword = $readerPasswordText.Replace("'", "''")
|
||||
$sql = @"
|
||||
IF DATABASEPROPERTYEX(N'$safeDatabaseLiteral', 'Updateability') = N'READ_ONLY'
|
||||
ALTER DATABASE [$safeDatabaseIdentifier] SET READ_WRITE;
|
||||
IF NOT EXISTS (SELECT 1 FROM sys.server_principals WHERE name = N'$safeLoginIdentifier')
|
||||
BEGIN
|
||||
CREATE LOGIN [$safeLoginIdentifier] WITH PASSWORD = N'$safeReaderPassword', CHECK_POLICY = ON;
|
||||
END;
|
||||
USE [$safeDatabaseIdentifier];
|
||||
IF NOT EXISTS (SELECT 1 FROM sys.database_principals WHERE name = N'$safeLoginIdentifier')
|
||||
BEGIN
|
||||
CREATE USER [$safeLoginIdentifier] FOR LOGIN [$safeLoginIdentifier];
|
||||
END;
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM sys.database_role_members rm
|
||||
JOIN sys.database_principals r ON r.principal_id = rm.role_principal_id
|
||||
JOIN sys.database_principals u ON u.principal_id = rm.member_principal_id
|
||||
WHERE r.name=N'db_datareader' AND u.name=N'$safeLoginIdentifier'
|
||||
)
|
||||
EXEC sp_addrolemember N'db_datareader', N'$safeLoginIdentifier';
|
||||
DENY INSERT, UPDATE, DELETE TO [$safeLoginIdentifier];
|
||||
ALTER DATABASE [$safeDatabaseIdentifier] SET READ_ONLY;
|
||||
"@
|
||||
|
||||
Invoke-TPlusContainerSql -ContainerName $ContainerName -Sql $sql -SaPassword $SaPassword
|
||||
Write-Host "只读账号已配置:$LoginName(未输出密码)"
|
||||
23
scripts/tplus/inspect_sqlserver_backup.ps1
Normal file
23
scripts/tplus/inspect_sqlserver_backup.ps1
Normal file
@ -0,0 +1,23 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$BackupFileName,
|
||||
[string]$ContainerName = "ys-tplus-sqlserver-dev",
|
||||
[string]$BackupRoot = "D:\work\app\tplus-sqlserver\backup",
|
||||
[Parameter(Mandatory = $true)]
|
||||
[SecureString]$SaPassword
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
. (Join-Path $PSScriptRoot "container_sql.ps1")
|
||||
|
||||
$backupPath = Join-Path $BackupRoot $BackupFileName
|
||||
if (-not (Test-Path -LiteralPath $backupPath)) {
|
||||
throw "找不到备份文件:$backupPath"
|
||||
}
|
||||
|
||||
$containerPath = "/var/opt/mssql/backup/$BackupFileName"
|
||||
$sql = "RESTORE VERIFYONLY FROM DISK = N'$($containerPath.Replace("'", "''"))'; RESTORE FILELISTONLY FROM DISK = N'$($containerPath.Replace("'", "''"))';"
|
||||
Invoke-TPlusContainerSql -ContainerName $ContainerName -Sql $sql -SaPassword $SaPassword
|
||||
74
scripts/tplus/inventory_tplus_database.ps1
Normal file
74
scripts/tplus/inventory_tplus_database.ps1
Normal file
@ -0,0 +1,74 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$DatabaseName,
|
||||
[string]$ContainerName = "ys-tplus-sqlserver-dev",
|
||||
[string]$ReportRoot = "D:\work\app\tplus-sqlserver\reports",
|
||||
[Parameter(Mandatory = $true)]
|
||||
[SecureString]$SaPassword
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
. (Join-Path $PSScriptRoot "container_sql.ps1")
|
||||
|
||||
New-Item -ItemType Directory -Path $ReportRoot -Force | Out-Null
|
||||
$reportPath = Join-Path $ReportRoot ("Tplus-核验-{0:yyyyMMdd-HHmmss}.md" -f (Get-Date))
|
||||
$safeDatabaseIdentifier = $DatabaseName.Replace("]", "]]" )
|
||||
$safeDatabaseLiteral = $DatabaseName.Replace("'", "''")
|
||||
|
||||
$sql = @"
|
||||
USE [$safeDatabaseIdentifier];
|
||||
SET NOCOUNT ON;
|
||||
SELECT 'DATABASE' AS section_name, DB_NAME() AS item_name, CAST(SUM(size) * 8.0 / 1024 AS varchar(64)) AS detail
|
||||
FROM sys.database_files;
|
||||
SELECT 'TABLE' AS section_name, s.name + '.' + t.name AS item_name,
|
||||
CAST(SUM(p.rows) AS varchar(64)) AS detail
|
||||
FROM sys.tables t
|
||||
JOIN sys.schemas s ON s.schema_id = t.schema_id
|
||||
LEFT JOIN sys.partitions p ON p.object_id = t.object_id AND p.index_id IN (0, 1)
|
||||
GROUP BY s.name, t.name
|
||||
ORDER BY s.name, t.name;
|
||||
SELECT 'COLUMN' AS section_name,
|
||||
s.name + '.' + t.name + '.' + c.name AS item_name,
|
||||
ty.name + '(' + CAST(c.max_length AS varchar(16)) + ')' AS detail
|
||||
FROM sys.columns c
|
||||
JOIN sys.tables t ON t.object_id = c.object_id
|
||||
JOIN sys.schemas s ON s.schema_id = t.schema_id
|
||||
JOIN sys.types ty ON ty.user_type_id = c.user_type_id
|
||||
ORDER BY s.name, t.name, c.column_id;
|
||||
SELECT 'VIEW' AS section_name, s.name + '.' + v.name AS item_name, 'view' AS detail
|
||||
FROM sys.views v JOIN sys.schemas s ON s.schema_id = v.schema_id
|
||||
ORDER BY s.name, v.name;
|
||||
SELECT 'PROCEDURE' AS section_name, s.name + '.' + p.name AS item_name, 'procedure' AS detail
|
||||
FROM sys.procedures p JOIN sys.schemas s ON s.schema_id = p.schema_id
|
||||
ORDER BY s.name, p.name;
|
||||
"@
|
||||
|
||||
$output = Invoke-TPlusContainerSql -ContainerName $ContainerName -Sql $sql -SaPassword $SaPassword
|
||||
|
||||
$header = @(
|
||||
"# T+ 数据库恢复核验报告",
|
||||
"",
|
||||
"- 数据库:$DatabaseName",
|
||||
"- 容器:$ContainerName",
|
||||
"- 核验时间:$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')",
|
||||
"- 数据目录:D:\work\app\tplus-sqlserver\data",
|
||||
"- 日志目录:D:\work\app\tplus-sqlserver\log",
|
||||
"",
|
||||
"## 查询结果",
|
||||
"",
|
||||
'```text'
|
||||
)
|
||||
$footer = @(
|
||||
'```',
|
||||
"",
|
||||
"## 后续核对",
|
||||
"",
|
||||
"- 对照 T+ 12.3 字段映射草案核对六类源表。",
|
||||
"- 记录更新时间字段、关联键、负向单据和枚举值。",
|
||||
"- 未确认字段不得直接写入工资业务表。"
|
||||
)
|
||||
($header + @($output) + $footer) | Set-Content -LiteralPath $reportPath -Encoding utf8
|
||||
Write-Host "核验报告已生成:$reportPath"
|
||||
37
scripts/tplus/prepare_sql2008r2_vm.ps1
Normal file
37
scripts/tplus/prepare_sql2008r2_vm.ps1
Normal file
@ -0,0 +1,37 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$VmName = "tplus-sql2008r2-test",
|
||||
[string]$Root = "D:\work\app\tplus-vm",
|
||||
[string]$WindowsIso = ""
|
||||
)
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = "Stop"
|
||||
$rootPath = [IO.Path]::GetFullPath($Root)
|
||||
if (-not $rootPath.StartsWith("D:\work\app\", [StringComparison]::OrdinalIgnoreCase)) {
|
||||
throw "虚拟机目录必须位于 D:\work\app 下。"
|
||||
}
|
||||
if ($WindowsIso -and -not (Test-Path -LiteralPath $WindowsIso -PathType Leaf)) {
|
||||
throw "Windows 安装介质不存在。"
|
||||
}
|
||||
if (Get-VM -Name $VmName -ErrorAction SilentlyContinue) {
|
||||
throw "同名虚拟机已存在,拒绝覆盖。"
|
||||
}
|
||||
if ((Get-PSDrive D).Free -lt 40GB) { throw "D 盘剩余不足 40GB,暂不创建测试环境。" }
|
||||
New-Item -ItemType Directory -Path $rootPath -Force | Out-Null
|
||||
$disk = Join-Path $rootPath "sqlserver2008r2.vhdx"
|
||||
if (Test-Path -LiteralPath $disk) { throw "测试磁盘已存在,拒绝覆盖。" }
|
||||
New-VHD -Path $disk -SizeBytes 64GB -Dynamic | Out-Null
|
||||
$vm = New-VM -Name $VmName -Generation 1 -MemoryStartupBytes 4GB `
|
||||
-VHDPath $disk -Path $rootPath
|
||||
Set-VM -VM $vm -ProcessorCount 2 -AutomaticStartAction Nothing `
|
||||
-AutomaticStopAction ShutDown -CheckpointType Disabled `
|
||||
-SnapshotFileLocation $rootPath -SmartPagingFilePath $rootPath
|
||||
# 来宾系统尚未安装,先断开网卡,不改 Docker/WSL 的交换机。
|
||||
Get-VMNetworkAdapter -VM $vm | Disconnect-VMNetworkAdapter
|
||||
if ($WindowsIso) {
|
||||
Add-VMDvdDrive -VM $vm -Path $WindowsIso | Out-Null
|
||||
}
|
||||
Get-VM -Name $VmName | Select-Object Name, State, Generation, Path
|
||||
Get-VMHardDiskDrive -VMName $VmName | Select-Object Path
|
||||
Write-Host "仅创建了未启动的虚拟机和动态磁盘,未安装 Windows 或 SQL Server。"
|
||||
Write-Host "需授权 Windows x64 和 SQL Server 2008 R2 安装介质,不能用 Express 承载此大账套。"
|
||||
52
scripts/tplus/prepare_tplus_workspace.ps1
Normal file
52
scripts/tplus/prepare_tplus_workspace.ps1
Normal file
@ -0,0 +1,52 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$SourceBackupPath = "D:\work\code\PayrollManagementSystem\db\ceshi20261026.bak",
|
||||
[string]$TPlusRoot = "D:\work\app\tplus-sqlserver"
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
function Assert-OnDDrive([string]$Path) {
|
||||
$full = [IO.Path]::GetFullPath($Path)
|
||||
if (-not $full.StartsWith("D:\", [StringComparison]::OrdinalIgnoreCase)) {
|
||||
throw "T+ 数据目录必须位于 D 盘:$full"
|
||||
}
|
||||
}
|
||||
|
||||
Assert-OnDDrive $TPlusRoot
|
||||
if (-not (Test-Path -LiteralPath $SourceBackupPath -PathType Leaf)) {
|
||||
throw "找不到客户备份文件:$SourceBackupPath"
|
||||
}
|
||||
|
||||
$dataDir = Join-Path $TPlusRoot "data"
|
||||
$logDir = Join-Path $TPlusRoot "log"
|
||||
$backupDir = Join-Path $TPlusRoot "backup"
|
||||
$scriptsDir = Join-Path $TPlusRoot "scripts"
|
||||
foreach ($dir in @($dataDir, $logDir, $backupDir, $scriptsDir)) {
|
||||
New-Item -ItemType Directory -Path $dir -Force | Out-Null
|
||||
}
|
||||
|
||||
$destination = Join-Path $backupDir (Split-Path $SourceBackupPath -Leaf)
|
||||
$sourceHash = (Get-FileHash -LiteralPath $SourceBackupPath -Algorithm SHA256).Hash
|
||||
|
||||
if (Test-Path -LiteralPath $destination -PathType Leaf) {
|
||||
$destinationHash = (Get-FileHash -LiteralPath $destination -Algorithm SHA256).Hash
|
||||
if ($destinationHash -ne $sourceHash) {
|
||||
throw "D 盘已有同名备份,但 SHA-256 不一致:$destination"
|
||||
}
|
||||
Write-Host "备份已存在且校验一致:$destination"
|
||||
} else {
|
||||
Write-Host "正在把备份复制到 D 盘,源文件不会被修改……"
|
||||
Copy-Item -LiteralPath $SourceBackupPath -Destination $destination -Force
|
||||
$destinationHash = (Get-FileHash -LiteralPath $destination -Algorithm SHA256).Hash
|
||||
if ($destinationHash -ne $sourceHash) {
|
||||
throw "复制完成后 SHA-256 校验失败:$destination"
|
||||
}
|
||||
Write-Host "备份复制完成:$destination"
|
||||
}
|
||||
|
||||
$hashFile = "$destination.sha256"
|
||||
"$destinationHash $(Split-Path $destination -Leaf)" | Set-Content -LiteralPath $hashFile -Encoding utf8
|
||||
Write-Host "SHA-256:$destinationHash"
|
||||
Write-Host "SQL Server 数据、日志和备份目录已准备在:$TPlusRoot"
|
||||
48
scripts/tplus/profile_source.py
Normal file
48
scripts/tplus/profile_source.py
Normal file
@ -0,0 +1,48 @@
|
||||
"""只读统计主从关系、状态和负数行,不输出业务明细。"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pyodbc
|
||||
|
||||
QUERIES = {
|
||||
"personnel_dates": "SELECT COUNT_BIG(*) AS total, SUM(CASE WHEN updated IS NULL THEN 1 ELSE 0 END) AS null_updated FROM AA_Person",
|
||||
"product_dates": "SELECT COUNT_BIG(*) AS total, SUM(CASE WHEN updated IS NULL THEN 1 ELSE 0 END) AS null_updated FROM AA_Inventory",
|
||||
"order_detail_parent": "SELECT COUNT_BIG(*) AS total, SUM(CASE WHEN h.ID IS NULL THEN 1 ELSE 0 END) AS orphan_rows FROM SA_SaleOrder_b b LEFT JOIN SA_SaleOrder h ON h.ID=b.idSaleOrderDTO",
|
||||
"delivery_detail_parent": "SELECT COUNT_BIG(*) AS total, SUM(CASE WHEN h.ID IS NULL THEN 1 ELSE 0 END) AS orphan_rows FROM SA_SaleDelivery_b b LEFT JOIN SA_SaleDelivery h ON h.ID=b.idSaleDeliveryDTO",
|
||||
"receipt_detail_parent": "SELECT COUNT_BIG(*) AS total, SUM(CASE WHEN h.ID IS NULL THEN 1 ELSE 0 END) AS orphan_rows FROM ARAP_ReceivePayment_b b LEFT JOIN ARAP_ReceivePayment h ON h.ID=b.idArapReceivePaymentDTO",
|
||||
"delivery_order_link": "SELECT COUNT_BIG(*) AS total, SUM(CASE WHEN b.saleOrderId IS NULL OR b.saleOrderId=0 THEN 1 ELSE 0 END) AS missing_order_id, SUM(CASE WHEN b.saleOrderId>0 AND h.ID IS NULL THEN 1 ELSE 0 END) AS invalid_order_id FROM SA_SaleDelivery_b b LEFT JOIN SA_SaleOrder h ON h.ID=b.saleOrderId",
|
||||
"receipt_order_link": "SELECT COUNT_BIG(*) AS total, SUM(CASE WHEN p.SaleOrderID IS NULL OR p.SaleOrderID=0 THEN 1 ELSE 0 END) AS missing_order_id, SUM(CASE WHEN p.SaleOrderID>0 AND h.ID IS NULL THEN 1 ELSE 0 END) AS invalid_order_id FROM ARAP_ReceivePayment p LEFT JOIN SA_SaleOrder h ON h.ID=p.SaleOrderID",
|
||||
"receipt_direction": "SELECT isReceiveFlag, COUNT_BIG(*) AS total FROM ARAP_ReceivePayment GROUP BY isReceiveFlag",
|
||||
"receipt_status": "SELECT voucherstate, cancelState, COUNT_BIG(*) AS total FROM ARAP_ReceivePayment GROUP BY voucherstate,cancelState",
|
||||
"negative_deliveries": "SELECT SUM(CASE WHEN quantity<0 THEN 1 ELSE 0 END) AS negative_quantity, SUM(CASE WHEN costAmount<0 THEN 1 ELSE 0 END) AS negative_cost FROM SA_SaleDelivery_b",
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""执行固定白名单统计,并保存无凭据、无明细的 JSON。"""
|
||||
parser = argparse.ArgumentParser(description="T+ 只读数据质量统计")
|
||||
parser.add_argument("--connection-file", required=True, type=Path)
|
||||
parser.add_argument("--report", required=True, type=Path)
|
||||
args = parser.parse_args()
|
||||
c = json.loads(args.connection_file.read_text(encoding="utf-8-sig"))
|
||||
cs = ("DRIVER={" + c["driver"] + "};SERVER=" + c["host"] + "," + str(c["port"])
|
||||
+ ";DATABASE=" + c["database"] + ";UID=" + c["user"]
|
||||
+ ";PWD={" + c["password"].replace("}", "}}") + "};")
|
||||
result = {}
|
||||
with pyodbc.connect(cs, timeout=10) as connection:
|
||||
connection.timeout = 120
|
||||
cursor = connection.cursor()
|
||||
for name, sql in QUERIES.items():
|
||||
cursor.execute(sql)
|
||||
names = [column[0] for column in cursor.description]
|
||||
result[name] = [dict(zip(names, row, strict=True)) for row in cursor.fetchall()]
|
||||
print(name, json.dumps(result[name]), flush=True)
|
||||
args.report.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.report.write_text(json.dumps(result, indent=2), encoding="utf-8")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
87
scripts/tplus/restore_sqlserver_backup.ps1
Normal file
87
scripts/tplus/restore_sqlserver_backup.ps1
Normal file
@ -0,0 +1,87 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$BackupFileName,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$DatabaseName,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$DataLogicalName,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$LogLogicalName,
|
||||
[string]$ContainerName = "ys-tplus-sqlserver-dev",
|
||||
[string]$BackupRoot = "D:\work\app\tplus-sqlserver\backup",
|
||||
[string]$DataRoot = "D:\work\app\tplus-sqlserver\data",
|
||||
[string]$LogRoot = "D:\work\app\tplus-sqlserver\log",
|
||||
[Parameter(Mandatory = $true)]
|
||||
[SecureString]$SaPassword
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
. (Join-Path $PSScriptRoot "container_sql.ps1")
|
||||
|
||||
if ($DatabaseName -notmatch '^[A-Za-z_][A-Za-z0-9_]{0,127}$') {
|
||||
throw "恢复数据库名只能使用字母、数字和下划线。"
|
||||
}
|
||||
if ([IO.Path]::GetFileName($BackupFileName) -ne $BackupFileName) {
|
||||
throw "备份文件名不能包含目录。"
|
||||
}
|
||||
foreach ($path in @($BackupRoot, $DataRoot, $LogRoot)) {
|
||||
if (-not [IO.Path]::GetFullPath($path).StartsWith("D:\", [StringComparison]::OrdinalIgnoreCase)) {
|
||||
throw "恢复目录必须位于 D 盘。"
|
||||
}
|
||||
}
|
||||
$mounts = docker inspect $ContainerName --format '{{json .Mounts}}' | ConvertFrom-Json
|
||||
if ($LASTEXITCODE -ne 0) { throw "无法读取 SQL Server 容器挂载。" }
|
||||
foreach ($pair in @(
|
||||
@{ Target="/var/opt/mssql/data"; Source=$DataRoot },
|
||||
@{ Target="/var/opt/mssql/log"; Source=$LogRoot },
|
||||
@{ Target="/var/opt/mssql/backup"; Source=$BackupRoot }
|
||||
)) {
|
||||
$mount = @($mounts | Where-Object { $_.Destination -eq $pair.Target })
|
||||
if ($mount.Count -ne 1 -or $mount[0].Type -ne "bind") {
|
||||
throw "目标目录未明确绑定到宿主机 D 盘:$($pair.Target)"
|
||||
}
|
||||
if ([IO.Path]::GetFullPath($mount[0].Source) -ine [IO.Path]::GetFullPath($pair.Source)) {
|
||||
throw "实际挂载与恢复目录配置不一致:$($pair.Target)"
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($path in @($BackupRoot, $DataRoot, $LogRoot)) {
|
||||
if (-not (Test-Path -LiteralPath $path)) {
|
||||
New-Item -ItemType Directory -Path $path -Force | Out-Null
|
||||
}
|
||||
}
|
||||
|
||||
$backupPath = Join-Path $BackupRoot $BackupFileName
|
||||
if (-not (Test-Path -LiteralPath $backupPath)) {
|
||||
throw "找不到备份文件:$backupPath"
|
||||
}
|
||||
|
||||
$containerBackupPath = "/var/opt/mssql/backup/$BackupFileName"
|
||||
$dataPath = "/var/opt/mssql/data/$DatabaseName.mdf"
|
||||
$logPath = "/var/opt/mssql/log/$DatabaseName`_log.ldf"
|
||||
$safeDatabaseIdentifier = $DatabaseName.Replace("]", "]]" )
|
||||
$safeDatabaseLiteral = $DatabaseName.Replace("'", "''")
|
||||
$safeDataLogicalName = $DataLogicalName.Replace("'", "''")
|
||||
$safeLogLogicalName = $LogLogicalName.Replace("'", "''")
|
||||
$safeBackupPath = $containerBackupPath.Replace("'", "''")
|
||||
$sql = @"
|
||||
RESTORE VERIFYONLY FROM DISK = N'$safeBackupPath';
|
||||
IF DB_ID(N'$safeDatabaseLiteral') IS NOT NULL
|
||||
BEGIN
|
||||
THROW 51000, 'Target database exists; refusing to overwrite.', 1;
|
||||
END;
|
||||
RESTORE DATABASE [$safeDatabaseIdentifier]
|
||||
FROM DISK = N'$safeBackupPath'
|
||||
WITH MOVE N'$safeDataLogicalName' TO N'$dataPath',
|
||||
MOVE N'$safeLogLogicalName' TO N'$logPath',
|
||||
RECOVERY, STATS = 5;
|
||||
ALTER DATABASE [$safeDatabaseIdentifier] SET READ_ONLY;
|
||||
DBCC CHECKDB (N'$safeDatabaseLiteral') WITH NO_INFOMSGS;
|
||||
"@
|
||||
|
||||
Write-Host "正在校验并恢复备份(密码不会输出)……"
|
||||
Invoke-TPlusContainerSql -ContainerName $ContainerName -Sql $sql -SaPassword $SaPassword
|
||||
Write-Host "恢复完成:$DatabaseName;数据库文件位于 D 盘绑定目录。"
|
||||
71
scripts/tplus/verify_staging.py
Normal file
71
scripts/tplus/verify_staging.py
Normal file
@ -0,0 +1,71 @@
|
||||
"""使用本地只读凭据执行快照对账,不把凭据复制到项目配置。"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "backend"))
|
||||
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.core.db import dispose_engine, get_session_factory
|
||||
from app.core.tplus_db import dispose_tplus_engine, get_tplus_session_factory
|
||||
from app.models import TPlusSyncRecord
|
||||
from app.services.tplus.mapping import DETAIL_MAPPINGS, TPLUS_MAPPINGS
|
||||
from app.services.tplus.sync_service import TPlusSyncService
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""执行一次或重复快照,并将计数结果保存到指定报告。"""
|
||||
parser = argparse.ArgumentParser(description="T+ 本地只读快照与幂等对账")
|
||||
parser.add_argument("--connection-file", type=Path, required=True)
|
||||
parser.add_argument("--report", type=Path, required=True)
|
||||
parser.add_argument("--repeat", action="store_true")
|
||||
args = parser.parse_args()
|
||||
config = json.loads(args.connection_file.read_text(encoding="utf-8-sig"))
|
||||
settings = get_settings()
|
||||
settings.t_plus_enabled = True
|
||||
for key in ("host", "port", "database", "user", "password"):
|
||||
setattr(settings, "t_plus_" + key, config[key])
|
||||
settings.t_plus_odbc_driver = config["driver"]
|
||||
source_factory, target_factory = get_tplus_session_factory(), get_session_factory()
|
||||
reports, failed = [], False
|
||||
try:
|
||||
with source_factory() as source, target_factory() as target:
|
||||
for run in range(1, 3 if args.repeat else 2):
|
||||
for mapping in (*TPLUS_MAPPINGS, *DETAIL_MAPPINGS):
|
||||
batch = TPlusSyncService(source, target).run(entity_types=[mapping.entity_type])
|
||||
count = target.scalar(
|
||||
select(func.count())
|
||||
.select_from(TPlusSyncRecord)
|
||||
.where(TPlusSyncRecord.entity_type == mapping.entity_type)
|
||||
)
|
||||
row = {
|
||||
"run": run,
|
||||
"entity": mapping.entity_type,
|
||||
"batch": batch.batch_no,
|
||||
"status": batch.status,
|
||||
"source": batch.source_record_count,
|
||||
"inserted": batch.staged_record_count,
|
||||
"errors": batch.failed_record_count,
|
||||
"target_count": count,
|
||||
}
|
||||
print(json.dumps(row), flush=True)
|
||||
reports.append(row)
|
||||
failed |= batch.status != "SUCCEEDED" or (
|
||||
run == 2 and batch.staged_record_count != 0
|
||||
)
|
||||
args.report.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.report.write_text(
|
||||
json.dumps(reports, ensure_ascii=False, indent=2), encoding="utf-8"
|
||||
)
|
||||
finally:
|
||||
dispose_tplus_engine()
|
||||
dispose_engine()
|
||||
return int(failed)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Loading…
Reference in New Issue
Block a user