From 78bef43c06a76866901d2ef1117394ac10b4548c Mon Sep 17 00:00:00 2001 From: liuwenxin <1009266660@qq.com> Date: Wed, 28 Oct 2026 11:41:38 +0800 Subject: [PATCH] =?UTF-8?q?feat(tplus):=20=E6=96=B0=E5=A2=9E=E5=90=8C?= =?UTF-8?q?=E6=AD=A5=E6=9A=82=E5=AD=98=E4=B8=8E=E4=BB=BB=E5=8A=A1=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E8=BF=81=E7=A7=BB=E5=92=8C=E7=8E=AF=E5=A2=83=E9=85=8D?= =?UTF-8?q?=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/.env.example | 20 ++- .../d4e5f6a7b8c9_tplus_sync_staging.py | 122 ++++++++++++++++++ .../e5f607a8b9c0_tplus_task_management.py | 118 +++++++++++++++++ db/init/init-dev.sql | 96 +++++++++++++- ...ating.sql => V13__add_customer_rating.sql} | 4 +- db/migration/V14__tplus_sync_staging.sql | 83 ++++++++++++ db/migration/V15__tplus_task_management.sql | 14 ++ db/queries/tplus/audit/queries.sql | 68 ++++++++++ db/queries/tplus/business_items.sql | 21 +++ db/queries/tplus/businesses.sql | 18 +++ db/queries/tplus/customers.sql | 16 +++ db/queries/tplus/personnel.sql | 16 +++ db/queries/tplus/products.sql | 16 +++ db/queries/tplus/receipt_items.sql | 24 ++++ db/queries/tplus/receipts.sql | 24 ++++ db/queries/tplus/shipment_items.sql | 26 ++++ db/queries/tplus/shipments.sql | 23 ++++ docker/docker-compose.tplus.dev.yml | 43 ++++++ docker/tplus.env.example | 5 + scripts/tplus/check_compatibility.py | 106 +++++++++++++++ scripts/tplus/container_sql.ps1 | 20 +++ scripts/tplus/create_tplus_readonly_login.ps1 | 56 ++++++++ scripts/tplus/inspect_sqlserver_backup.ps1 | 23 ++++ scripts/tplus/inventory_tplus_database.ps1 | 74 +++++++++++ scripts/tplus/prepare_sql2008r2_vm.ps1 | 37 ++++++ scripts/tplus/prepare_tplus_workspace.ps1 | 52 ++++++++ scripts/tplus/profile_source.py | 48 +++++++ scripts/tplus/restore_sqlserver_backup.ps1 | 87 +++++++++++++ scripts/tplus/verify_staging.py | 71 ++++++++++ 29 files changed, 1326 insertions(+), 5 deletions(-) create mode 100644 backend/alembic/versions/d4e5f6a7b8c9_tplus_sync_staging.py create mode 100644 backend/alembic/versions/e5f607a8b9c0_tplus_task_management.py rename db/migration/{V10__add_customer_rating.sql => V13__add_customer_rating.sql} (84%) create mode 100644 db/migration/V14__tplus_sync_staging.sql create mode 100644 db/migration/V15__tplus_task_management.sql create mode 100644 db/queries/tplus/audit/queries.sql create mode 100644 db/queries/tplus/business_items.sql create mode 100644 db/queries/tplus/businesses.sql create mode 100644 db/queries/tplus/customers.sql create mode 100644 db/queries/tplus/personnel.sql create mode 100644 db/queries/tplus/products.sql create mode 100644 db/queries/tplus/receipt_items.sql create mode 100644 db/queries/tplus/receipts.sql create mode 100644 db/queries/tplus/shipment_items.sql create mode 100644 db/queries/tplus/shipments.sql create mode 100644 docker/docker-compose.tplus.dev.yml create mode 100644 docker/tplus.env.example create mode 100644 scripts/tplus/check_compatibility.py create mode 100644 scripts/tplus/container_sql.ps1 create mode 100644 scripts/tplus/create_tplus_readonly_login.ps1 create mode 100644 scripts/tplus/inspect_sqlserver_backup.ps1 create mode 100644 scripts/tplus/inventory_tplus_database.ps1 create mode 100644 scripts/tplus/prepare_sql2008r2_vm.ps1 create mode 100644 scripts/tplus/prepare_tplus_workspace.ps1 create mode 100644 scripts/tplus/profile_source.py create mode 100644 scripts/tplus/restore_sqlserver_backup.ps1 create mode 100644 scripts/tplus/verify_staging.py diff --git a/backend/.env.example b/backend/.env.example index 863150f..7f6474d 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -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= diff --git a/backend/alembic/versions/d4e5f6a7b8c9_tplus_sync_staging.py b/backend/alembic/versions/d4e5f6a7b8c9_tplus_sync_staging.py new file mode 100644 index 0000000..1b7131b --- /dev/null +++ b/backend/alembic/versions/d4e5f6a7b8c9_tplus_sync_staging.py @@ -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") diff --git a/backend/alembic/versions/e5f607a8b9c0_tplus_task_management.py b/backend/alembic/versions/e5f607a8b9c0_tplus_task_management.py new file mode 100644 index 0000000..a083479 --- /dev/null +++ b/backend/alembic/versions/e5f607a8b9c0_tplus_task_management.py @@ -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", + ) diff --git a/db/init/init-dev.sql b/db/init/init-dev.sql index 404f5aa..78108e2 100644 --- a/db/init/init-dev.sql +++ b/db/init/init-dev.sql @@ -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'); diff --git a/db/migration/V10__add_customer_rating.sql b/db/migration/V13__add_customer_rating.sql similarity index 84% rename from db/migration/V10__add_customer_rating.sql rename to db/migration/V13__add_customer_rating.sql index 0b70042..fd61ccd 100644 --- a/db/migration/V10__add_customer_rating.sql +++ b/db/migration/V13__add_customer_rating.sql @@ -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'; diff --git a/db/migration/V14__tplus_sync_staging.sql b/db/migration/V14__tplus_sync_staging.sql new file mode 100644 index 0000000..e9caa9a --- /dev/null +++ b/db/migration/V14__tplus_sync_staging.sql @@ -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'; diff --git a/db/migration/V15__tplus_task_management.sql b/db/migration/V15__tplus_task_management.sql new file mode 100644 index 0000000..3aacb5f --- /dev/null +++ b/db/migration/V15__tplus_task_management.sql @@ -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'; diff --git a/db/queries/tplus/audit/queries.sql b/db/queries/tplus/audit/queries.sql new file mode 100644 index 0000000..a8d9daf --- /dev/null +++ b/db/queries/tplus/audit/queries.sql @@ -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; diff --git a/db/queries/tplus/business_items.sql b/db/queries/tplus/business_items.sql new file mode 100644 index 0000000..7306f59 --- /dev/null +++ b/db/queries/tplus/business_items.sql @@ -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; diff --git a/db/queries/tplus/businesses.sql b/db/queries/tplus/businesses.sql new file mode 100644 index 0000000..d47da1c --- /dev/null +++ b/db/queries/tplus/businesses.sql @@ -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; diff --git a/db/queries/tplus/customers.sql b/db/queries/tplus/customers.sql new file mode 100644 index 0000000..8e02165 --- /dev/null +++ b/db/queries/tplus/customers.sql @@ -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; diff --git a/db/queries/tplus/personnel.sql b/db/queries/tplus/personnel.sql new file mode 100644 index 0000000..4b92b59 --- /dev/null +++ b/db/queries/tplus/personnel.sql @@ -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; diff --git a/db/queries/tplus/products.sql b/db/queries/tplus/products.sql new file mode 100644 index 0000000..6683689 --- /dev/null +++ b/db/queries/tplus/products.sql @@ -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; diff --git a/db/queries/tplus/receipt_items.sql b/db/queries/tplus/receipt_items.sql new file mode 100644 index 0000000..2385f67 --- /dev/null +++ b/db/queries/tplus/receipt_items.sql @@ -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; diff --git a/db/queries/tplus/receipts.sql b/db/queries/tplus/receipts.sql new file mode 100644 index 0000000..f38bfcf --- /dev/null +++ b/db/queries/tplus/receipts.sql @@ -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; diff --git a/db/queries/tplus/shipment_items.sql b/db/queries/tplus/shipment_items.sql new file mode 100644 index 0000000..ae9841e --- /dev/null +++ b/db/queries/tplus/shipment_items.sql @@ -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; diff --git a/db/queries/tplus/shipments.sql b/db/queries/tplus/shipments.sql new file mode 100644 index 0000000..3639399 --- /dev/null +++ b/db/queries/tplus/shipments.sql @@ -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; diff --git a/docker/docker-compose.tplus.dev.yml b/docker/docker-compose.tplus.dev.yml new file mode 100644 index 0000000..816723a --- /dev/null +++ b/docker/docker-compose.tplus.dev.yml @@ -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 diff --git a/docker/tplus.env.example b/docker/tplus.env.example new file mode 100644 index 0000000..d970b9b --- /dev/null +++ b/docker/tplus.env.example @@ -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= diff --git a/scripts/tplus/check_compatibility.py b/scripts/tplus/check_compatibility.py new file mode 100644 index 0000000..df7c29c --- /dev/null +++ b/scripts/tplus/check_compatibility.py @@ -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()) diff --git a/scripts/tplus/container_sql.ps1 b/scripts/tplus/container_sql.ps1 new file mode 100644 index 0000000..5771309 --- /dev/null +++ b/scripts/tplus/container_sql.ps1 @@ -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) + } +} diff --git a/scripts/tplus/create_tplus_readonly_login.ps1 b/scripts/tplus/create_tplus_readonly_login.ps1 new file mode 100644 index 0000000..2562fc9 --- /dev/null +++ b/scripts/tplus/create_tplus_readonly_login.ps1 @@ -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(未输出密码)" diff --git a/scripts/tplus/inspect_sqlserver_backup.ps1 b/scripts/tplus/inspect_sqlserver_backup.ps1 new file mode 100644 index 0000000..03b26e2 --- /dev/null +++ b/scripts/tplus/inspect_sqlserver_backup.ps1 @@ -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 diff --git a/scripts/tplus/inventory_tplus_database.ps1 b/scripts/tplus/inventory_tplus_database.ps1 new file mode 100644 index 0000000..1931ef0 --- /dev/null +++ b/scripts/tplus/inventory_tplus_database.ps1 @@ -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" diff --git a/scripts/tplus/prepare_sql2008r2_vm.ps1 b/scripts/tplus/prepare_sql2008r2_vm.ps1 new file mode 100644 index 0000000..3acbf16 --- /dev/null +++ b/scripts/tplus/prepare_sql2008r2_vm.ps1 @@ -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 承载此大账套。" diff --git a/scripts/tplus/prepare_tplus_workspace.ps1 b/scripts/tplus/prepare_tplus_workspace.ps1 new file mode 100644 index 0000000..99746fe --- /dev/null +++ b/scripts/tplus/prepare_tplus_workspace.ps1 @@ -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" diff --git a/scripts/tplus/profile_source.py b/scripts/tplus/profile_source.py new file mode 100644 index 0000000..1699e5e --- /dev/null +++ b/scripts/tplus/profile_source.py @@ -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()) diff --git a/scripts/tplus/restore_sqlserver_backup.ps1 b/scripts/tplus/restore_sqlserver_backup.ps1 new file mode 100644 index 0000000..69bb8ae --- /dev/null +++ b/scripts/tplus/restore_sqlserver_backup.ps1 @@ -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 盘绑定目录。" diff --git a/scripts/tplus/verify_staging.py b/scripts/tplus/verify_staging.py new file mode 100644 index 0000000..16a2f62 --- /dev/null +++ b/scripts/tplus/verify_staging.py @@ -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())