Compare commits
7 Commits
8a171a572c
...
1b074576fe
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1b074576fe | ||
|
|
013d6e7a74 | ||
|
|
0ca1734523 | ||
|
|
b0c599e513 | ||
|
|
2fda2d118b | ||
|
|
37eb51513f | ||
|
|
ee801652e3 |
@ -43,8 +43,11 @@ def resolve_tplus_settings(settings: Settings) -> Settings:
|
||||
config = _ConnectionFile.model_validate(
|
||||
json.loads(path.read_text(encoding="utf-8-sig"))
|
||||
)
|
||||
except (OSError, ValueError, ValidationError):
|
||||
raise RuntimeError("T+ 只读连接文件不可用或字段不正确") from None
|
||||
except OSError:
|
||||
# 消息只区分故障类别,不回显路径内容;路径本身可在应用配置中核对。
|
||||
raise RuntimeError("T+ 只读连接文件不存在或不可读") from None
|
||||
except (ValueError, ValidationError):
|
||||
raise RuntimeError("T+ 只读连接文件字段不正确") from None
|
||||
values = {
|
||||
"t_plus_" + key: getattr(config, key) for key in ("host", "port", "database", "user")
|
||||
}
|
||||
|
||||
@ -17,7 +17,7 @@ services:
|
||||
MSSQL_SA_PASSWORD: ${TPLUS_SA_PASSWORD:?请在 D:/work/app/tplus-sqlserver/.env 设置 TPLUS_SA_PASSWORD}
|
||||
TZ: Asia/Shanghai
|
||||
ports:
|
||||
- "1433:1433"
|
||||
- "127.0.0.1:1433:1433"
|
||||
volumes:
|
||||
- type: bind
|
||||
source: ${TPLUS_ROOT:-D:/work/app/tplus-sqlserver}/data
|
||||
|
||||
@ -7,7 +7,7 @@
|
||||
- 登录页与图形验证码交互
|
||||
- 会话状态管理与路由守卫
|
||||
- 管理员 T+ 数据同步页:`/system/tplus`,支持读取排队、关联检查、批次/异常详情和失败重试;只读配置未就绪时禁用创建。
|
||||
- T+ 页面位于 `src/views/system/tplus/`,合同在 `src/api/tplus.ts`;创建或重试失败后保留请求键,范围改变或提交成功后才换键。任务由独立读取服务执行,页面刷新查看状态,运行命令见 T+ 本地恢复手册。
|
||||
- T+ 页面位于 `src/views/system/tplus/`,合同在 `src/api/tplus.ts`;创建或重试失败后保留请求键,范围改变或提交成功后才换键。任务由独立读取服务执行,页面刷新查看状态,运行命令见 T+ 本地恢复手册。**e2e 待补(2026-10-30)**:按模块测试约定应在本模块验收节点统一编写并记录,当前仅有 type-check 验证,尚未落 Playwright 用例。
|
||||
- Vite 开发代理可通过 `DEV_API_TARGET` 指定后端,默认本地 8000;浏览器请求继续使用 `/api` 相对地址。
|
||||
- 岗位角色管理页(模板绑定)、计提参数配置页(分组目录/批次提交/版本对比/导出/计算预览)
|
||||
- 首页 Bento 仪表盘(M1 空态设计;业务上线后接入真实统计,图表用 ECharts)
|
||||
|
||||
@ -16,7 +16,6 @@ export interface AuditReport {
|
||||
order_coverage: Record<string, number> | null
|
||||
state_enums: { id: number; code: string; name: string; enum_name: string }[]
|
||||
receipt_kinds: { voucher_type_name: string; row_count: number }[]
|
||||
receipt_line_kinds: { voucher_type_name: string | null; receive_flag: number; row_count: number }[]
|
||||
pending_confirmations: { key: string; title: string; reason: string }[]
|
||||
}
|
||||
|
||||
|
||||
11
frontend/src/utils/uuid.ts
Normal file
11
frontend/src/utils/uuid.ts
Normal file
@ -0,0 +1,11 @@
|
||||
// crypto.randomUUID 仅在安全上下文(HTTPS 或 localhost)存在;客户内网常以
|
||||
// http://<内网IP> 访问,此时该 API 为 undefined。降级用 getRandomValues 拼
|
||||
// v4 格式(getRandomValues 在非安全上下文仍然可用)。
|
||||
export function newRequestKey(): string {
|
||||
if (typeof crypto.randomUUID === 'function') return crypto.randomUUID()
|
||||
const bytes = crypto.getRandomValues(new Uint8Array(16))
|
||||
bytes[6] = (bytes[6] & 0x0f) | 0x40 // version 4
|
||||
bytes[8] = (bytes[8] & 0x3f) | 0x80 // variant 10x
|
||||
const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0'))
|
||||
return `${hex.slice(0, 4).join('')}-${hex.slice(4, 6).join('')}-${hex.slice(6, 8).join('')}-${hex.slice(8, 10).join('')}-${hex.slice(10, 16).join('')}`
|
||||
}
|
||||
@ -17,7 +17,7 @@ const rows = computed(() => Object.entries(props.report.sales_links ?? {}).map((
|
||||
<template>
|
||||
<section class="audit-result" aria-label="关联核验结果">
|
||||
<p class="audit-caption">核验于 {{ formatDateTime(report.captured_at) }} · 源引擎 {{ report.source_version }}</p>
|
||||
<el-alert type="warning" :closable="false" title="SQL Server 2008 R2 同版本验收待完成;关联存在不代表业务口径已确认。" />
|
||||
<el-alert type="warning" :closable="false" show-icon title="SQL Server 2008 R2 同版本验收待完成;关联存在不代表业务口径已确认。" />
|
||||
<p v-if="report.relationship_status !== 'OBSERVED'" role="alert">单据类型未能唯一确认,关联统计未执行。</p>
|
||||
<el-table v-if="rows.length" :data="rows" size="small" class="audit-table">
|
||||
<el-table-column prop="label" label="核验项目" min-width="190" />
|
||||
|
||||
@ -12,9 +12,13 @@ const number = defineModel<string | null>({ required: true })
|
||||
const detail = ref<api.TPlusDetail | null>(null)
|
||||
const loading = ref(false)
|
||||
const failed = ref(false)
|
||||
const errorEntityType = ref<api.EntityType>()
|
||||
let sequence = 0
|
||||
const { records, total, pageNum, pageSize, loading: errorsLoading, search, onPageChange, onSizeChange } =
|
||||
usePagedList<api.TPlusError, api.ErrorQuery>((query) => api.listErrors(number.value!, query), () => ({}))
|
||||
usePagedList<api.TPlusError, api.ErrorQuery>(
|
||||
(query) => api.listErrors(number.value!, query),
|
||||
() => ({ entity_type: errorEntityType.value }),
|
||||
)
|
||||
|
||||
async function load(): Promise<void> {
|
||||
const current = ++sequence
|
||||
@ -46,7 +50,7 @@ watch(number, (value) => { if (value) void load(); else sequence++ })
|
||||
<el-descriptions-item label="状态">{{ statuses[detail.status] }}</el-descriptions-item>
|
||||
<el-descriptions-item label="范围">{{ scopeLabel(detail) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="创建时间">{{ formatDateTime(detail.create_time) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="执行时间">{{ formatDateTime(detail.started_at) }} → {{ formatDateTime(detail.finished_at) }}</el-descriptions-item>
|
||||
<el-descriptions-item v-if="detail.started_at" label="执行时间">{{ formatDateTime(detail.started_at) }} → {{ formatDateTime(detail.finished_at) }}</el-descriptions-item>
|
||||
<el-descriptions-item v-if="detail.parent_batch_no" label="原批次">{{ detail.parent_batch_no }}</el-descriptions-item>
|
||||
<el-descriptions-item label="数量">源记录 {{ detail.source_record_count.toLocaleString() }},新增 {{ detail.staged_record_count.toLocaleString() }},重复跳过 {{ detail.skipped_record_count.toLocaleString() }},异常 {{ detail.failed_record_count.toLocaleString() }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
@ -55,6 +59,9 @@ watch(number, (value) => { if (value) void load(); else sequence++ })
|
||||
<AuditResult v-if="detail.audit_result" :report="detail.audit_result" />
|
||||
<section v-if="detail.failed_record_count" class="errors-section" aria-label="异常明细">
|
||||
<h3>异常明细</h3>
|
||||
<el-select v-model="errorEntityType" clearable placeholder="全部范围" aria-label="异常范围" @change="search">
|
||||
<el-option v-for="item in entities" :key="item.value" :label="item.label" :value="item.value" />
|
||||
</el-select>
|
||||
<el-table v-loading="errorsLoading" :data="records" empty-text="暂无异常记录">
|
||||
<el-table-column label="范围" width="100"><template #default="{ row }">{{ entities.find((item) => item.value === row.entity_type)?.label ?? '任务' }}</template></el-table-column>
|
||||
<el-table-column prop="source_id" label="源 ID" width="90" />
|
||||
@ -73,6 +80,7 @@ watch(number, (value) => { if (value) void load(); else sequence++ })
|
||||
.detail-body { min-height: 120px; }
|
||||
.batch-number { overflow-wrap: anywhere; }
|
||||
.errors-section { margin-top: 24px; }
|
||||
.errors-section .el-select { width: 160px; margin-bottom: 12px; }
|
||||
h3 { font-size: 14px; }
|
||||
.pager-scroll { overflow-x: auto; }
|
||||
</style>
|
||||
|
||||
@ -3,6 +3,7 @@ import { ref, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import * as api from '@/api/tplus'
|
||||
import { handledError } from '@/utils/error'
|
||||
import { newRequestKey } from '@/utils/uuid'
|
||||
import { entities } from './labels'
|
||||
|
||||
const visible = defineModel<boolean>({ required: true })
|
||||
@ -11,9 +12,9 @@ const emit = defineEmits<{ created: [batch: api.TPlusDetail] }>()
|
||||
const taskType = ref<api.TaskType>('READ')
|
||||
const selected = ref<api.EntityType[]>(['personnel'])
|
||||
const pending = ref(false)
|
||||
let requestKey = crypto.randomUUID()
|
||||
let requestKey = newRequestKey()
|
||||
// 请求失败后保留键,只有意图改变或成功后才换键。
|
||||
watch([taskType, selected], () => { requestKey = crypto.randomUUID() }, { deep: true, flush: 'sync' })
|
||||
watch([taskType, selected], () => { requestKey = newRequestKey() }, { deep: true, flush: 'sync' })
|
||||
|
||||
async function submit(): Promise<void> {
|
||||
if (pending.value || !props.configured) return
|
||||
@ -21,7 +22,7 @@ async function submit(): Promise<void> {
|
||||
try {
|
||||
const batch = await api.createTask({ task_type: taskType.value,
|
||||
entity_types: taskType.value === 'AUDIT' ? [] : [...selected.value], request_key: requestKey })
|
||||
requestKey = crypto.randomUUID()
|
||||
requestKey = newRequestKey()
|
||||
visible.value = false
|
||||
ElMessage.success('任务已排队,处理完成后可刷新查看')
|
||||
emit('created', batch)
|
||||
|
||||
@ -30,7 +30,14 @@ $safeDatabaseLiteral = $DatabaseName.Replace("'", "''")
|
||||
$safeLoginIdentifier = $LoginName.Replace("]", "]]" )
|
||||
$safeReaderPassword = $readerPasswordText.Replace("'", "''")
|
||||
$sql = @"
|
||||
IF DATABASEPROPERTYEX(N'$safeDatabaseLiteral', 'Updateability') = N'READ_ONLY'
|
||||
IF DB_ID(N'$safeDatabaseLiteral') IS NULL
|
||||
BEGIN
|
||||
;THROW 51000, N'目标数据库不存在,已停止配置,未修改任何库。', 1;
|
||||
END;
|
||||
DECLARE @initialReadonly bit =
|
||||
CASE WHEN CAST(DATABASEPROPERTYEX(N'$safeDatabaseLiteral', 'Updateability') AS nvarchar(30)) = N'READ_ONLY'
|
||||
THEN 1 ELSE 0 END;
|
||||
IF @initialReadonly = 1
|
||||
ALTER DATABASE [$safeDatabaseIdentifier] SET READ_WRITE;
|
||||
IF NOT EXISTS (SELECT 1 FROM sys.server_principals WHERE name = N'$safeLoginIdentifier')
|
||||
BEGIN
|
||||
@ -49,8 +56,9 @@ IF NOT EXISTS (
|
||||
)
|
||||
EXEC sp_addrolemember N'db_datareader', N'$safeLoginIdentifier';
|
||||
DENY INSERT, UPDATE, DELETE TO [$safeLoginIdentifier];
|
||||
ALTER DATABASE [$safeDatabaseIdentifier] SET READ_ONLY;
|
||||
IF @initialReadonly = 1
|
||||
ALTER DATABASE [$safeDatabaseIdentifier] SET READ_ONLY;
|
||||
"@
|
||||
|
||||
Invoke-TPlusContainerSql -ContainerName $ContainerName -Sql $sql -SaPassword $SaPassword
|
||||
Write-Host "只读账号已配置:$LoginName(未输出密码)"
|
||||
Write-Host "只读账号已配置:$LoginName(未输出密码)。仅当目标库初始为 READ_ONLY 时才恢复只读;初始为可写的库保持可写。"
|
||||
|
||||
@ -13,6 +13,10 @@ $ErrorActionPreference = "Stop"
|
||||
|
||||
. (Join-Path $PSScriptRoot "container_sql.ps1")
|
||||
|
||||
if ($DatabaseName -notmatch '^[A-Za-z_][A-Za-z0-9_]{0,127}$') {
|
||||
throw "核验数据库名只能使用字母、数字和下划线。"
|
||||
}
|
||||
|
||||
New-Item -ItemType Directory -Path $ReportRoot -Force | Out-Null
|
||||
$reportPath = Join-Path $ReportRoot ("Tplus-核验-{0:yyyyMMdd-HHmmss}.md" -f (Get-Date))
|
||||
$safeDatabaseIdentifier = $DatabaseName.Replace("]", "]]" )
|
||||
|
||||
@ -2,9 +2,15 @@
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pyodbc
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "backend"))
|
||||
|
||||
from sqlalchemy import Engine, create_engine
|
||||
|
||||
from app.core.config import Settings, get_settings
|
||||
from app.core.tplus_db import resolve_tplus_settings
|
||||
|
||||
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",
|
||||
@ -20,25 +26,43 @@ QUERIES = {
|
||||
}
|
||||
|
||||
|
||||
def build_source_engine(resolved: Settings) -> Engine:
|
||||
"""复用应用的 URL 转义和 TLS 配置自建引擎,不修改全局设置。"""
|
||||
return create_engine(
|
||||
resolved.t_plus_database_url,
|
||||
connect_args={"timeout": resolved.t_plus_connect_timeout},
|
||||
hide_parameters=True,
|
||||
)
|
||||
|
||||
|
||||
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("}", "}}") + "};")
|
||||
resolved = resolve_tplus_settings(
|
||||
get_settings().model_copy(
|
||||
update={"t_plus_enabled": True, "t_plus_connection_file": str(args.connection_file)}
|
||||
)
|
||||
)
|
||||
engine = build_source_engine(resolved)
|
||||
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)
|
||||
try:
|
||||
connection = engine.raw_connection()
|
||||
try:
|
||||
connection.driver_connection.timeout = resolved.t_plus_query_timeout
|
||||
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)
|
||||
cursor.close()
|
||||
finally:
|
||||
connection.close()
|
||||
finally:
|
||||
engine.dispose()
|
||||
args.report.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.report.write_text(json.dumps(result, indent=2), encoding="utf-8")
|
||||
return 0
|
||||
|
||||
@ -7,16 +7,38 @@ from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "backend"))
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy import Engine, create_engine, event, func, select
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.core.config import Settings, 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.core.tplus_db import resolve_tplus_settings
|
||||
from app.models import TPlusSyncRecord
|
||||
from app.services.tplus.mapping import DETAIL_MAPPINGS, TPLUS_MAPPINGS
|
||||
from app.services.tplus.sync_service import TPlusSyncService
|
||||
|
||||
|
||||
def build_source_engine(resolved: Settings) -> Engine:
|
||||
"""复用应用的 URL 转义和 TLS 配置自建引擎,不修改全局设置。
|
||||
|
||||
不能走 get_tplus_session_factory():它读取全局单例配置,看不到本脚本
|
||||
显式指定的连接文件;因此这里自建引擎,用完由调用方 dispose。
|
||||
"""
|
||||
engine = create_engine(
|
||||
resolved.t_plus_database_url,
|
||||
pool_pre_ping=True,
|
||||
pool_recycle=1800,
|
||||
connect_args={"timeout": resolved.t_plus_connect_timeout},
|
||||
hide_parameters=True,
|
||||
)
|
||||
|
||||
def configure_timeout(connection, _record):
|
||||
connection.timeout = resolved.t_plus_query_timeout
|
||||
|
||||
event.listen(engine, "connect", configure_timeout)
|
||||
return engine
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""执行一次或重复快照,并将计数结果保存到指定报告。"""
|
||||
parser = argparse.ArgumentParser(description="T+ 本地只读快照与幂等对账")
|
||||
@ -24,17 +46,20 @@ def main() -> int:
|
||||
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()
|
||||
resolved = resolve_tplus_settings(
|
||||
get_settings().model_copy(
|
||||
update={"t_plus_enabled": True, "t_plus_connection_file": str(args.connection_file)}
|
||||
)
|
||||
)
|
||||
engine = build_source_engine(resolved)
|
||||
source_factory, target_factory = (
|
||||
sessionmaker(engine, expire_on_commit=False),
|
||||
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 run in range(2 if args.repeat else 1):
|
||||
for mapping in (*TPLUS_MAPPINGS, *DETAIL_MAPPINGS):
|
||||
batch = TPlusSyncService(source, target).run(entity_types=[mapping.entity_type])
|
||||
count = target.scalar(
|
||||
@ -55,14 +80,14 @@ def main() -> int:
|
||||
print(json.dumps(row), flush=True)
|
||||
reports.append(row)
|
||||
failed |= batch.status != "SUCCEEDED" or (
|
||||
run == 2 and batch.staged_record_count != 0
|
||||
run == 1 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()
|
||||
engine.dispose()
|
||||
dispose_engine()
|
||||
return int(failed)
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user