Browse Source

fix(mdp): 修复 S5-S9 中立层断链与推送凭据发放 | server 1.0.584 / web 2.4.410

中立层断链修复(S5-S9 指标恢复):
- 库存流水按租户重物化正式跨租户切片,补回 approved_time(S5/S6/S7 唯一时间基准)
- approved_time 纳入中立层必填列守卫,新增 165_ASN 投影通道覆盖断言
- 修 ShipTransNeutralProjection:补 approved_time、限定 ASNBOLShipperDetail、修 OrdLine 取值、闸门日志与对账去重
- 新增 AIDOP_NATIVE 工单排程投影,销售订单行完成量/关行状态由发运实绩推导
- 出库符号收口:S3/S5/S7 出库汇总统一 ABS(5 处读取点),中立层保持「出库为负」
- MdpNeutralSourceCleanup 增加「注册来源无数据则不删」护栏,避免清理器清空整个对象
- 新增 NeutralRequiredColumnsMonitor:必填列整列为空时运行期告警并落 mdp_source_gate_log

推送凭据与租户基线:
- 发放授权时未传 AccessKey 则同事务创建 SysOpenAccess,一次性回显 accessKey/accessSecret
- 新增租户基线自检接口 AidopTenantBaselineController
- 补 InboundGrantIssuance / 租户基线守卫测试与第三方集成文档、系统部署初始化指南

测试:3167 个用例,7 个失败均为改动前既有基线,零回归。

Co-authored-by: Cursor <cursoragent@cursor.com>
YY968XX 1 day ago
parent
commit
e5c9c33dd8
41 changed files with 2570 additions and 681 deletions
  1. 1 1
      Web/package.json
  2. 1 1
      Web/src/views/aidop/data-platform/api/sources.ts
  3. 16 4
      Web/src/views/aidop/data-platform/components/SourceOnboardWizard.vue
  4. BIN
      _uat_screenshots/s0_menu_check.png
  5. BIN
      _uat_screenshots/s6_login_check.png
  6. BIN
      _uat_screenshots/s6_return002_drawer.png
  7. BIN
      _uat_screenshots/s7_02_before_save.png
  8. BIN
      _uat_screenshots/s7_start_state.png
  9. BIN
      _uat_screenshots/s8_00_initial.png
  10. 5 0
      doc/README.md
  11. 238 0
      doc/plan/推送凭据与租户基线修复执行任务书.md
  12. BIN
      doc/plan/系统部署/Ai-DOP系统初始化配置指南.docx
  13. 671 0
      doc/plan/系统部署/Ai-DOP系统初始化配置指南.md
  14. 35 0
      doc/plan/系统部署/README.md
  15. 44 0
      doc/plan/系统部署/_gen_deploy_docx.py
  16. 420 0
      doc/plan/系统部署/init_new_tenant.py
  17. BIN
      doc/plan/系统集成/Ai-DOP第三方对接业务数据详表-业务页.docx
  18. 226 547
      doc/plan/系统集成/Ai-DOP第三方对接业务数据详表-业务页.md
  19. BIN
      doc/plan/系统集成/Ai-DOP第三方对接业务数据详表-技术页.docx
  20. 19 19
      doc/plan/系统集成/Ai-DOP第三方对接业务数据详表-技术页.md
  21. BIN
      doc/plan/系统集成/Ai-DOP第三方系统集成指南.docx
  22. 19 19
      doc/plan/系统集成/Ai-DOP第三方系统集成指南.md
  23. 64 13
      doc/plan/系统集成/_gen_integration_guide_docx.py
  24. 17 4
      server/Admin.NET.Core/Service/Tenant/SysTenantService.cs
  25. 3 3
      server/Admin.NET.Web.Entry/Admin.NET.Web.Entry.csproj
  26. 81 0
      server/Plugins/Admin.NET.Plugin.AiDOP.Tests/DataPlatform/InboundGrantAndTenantBaselineGuardTests.cs
  27. 54 0
      server/Plugins/Admin.NET.Plugin.AiDOP.Tests/DataPlatform/InboundGrantIssuanceTests.cs
  28. 32 0
      server/Plugins/Admin.NET.Plugin.AiDOP.Tests/DataPlatform/NeutralRequiredColumnsGuardTests.cs
  29. 46 0
      server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/Inbound/InboundGrantIssuance.cs
  30. 62 0
      server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/MdpNeutralSourceCleanup.cs
  31. 91 14
      server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/MdpSourceMappingService.cs
  32. 113 3
      server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/NativeNeutralProjectionService.cs
  33. 18 5
      server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/NeutralRequiredColumns.cs
  34. 79 0
      server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/NeutralRequiredColumnsMonitor.cs
  35. 55 24
      server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/Wms/ShipTransNeutralProjection.cs
  36. 4 4
      server/Plugins/Admin.NET.Plugin.AiDOP/FinishedWarehouse/S7MdpSyncTransformService.cs
  37. 35 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Infrastructure/AidopTenantBaselineController.cs
  38. 69 14
      server/Plugins/Admin.NET.Plugin.AiDOP/Infrastructure/AidopTenantInitService.cs
  39. 47 4
      server/Plugins/Admin.NET.Plugin.AiDOP/MaterialWarehouse/InventoryMdpSyncService.cs
  40. 3 1
      server/Plugins/Admin.NET.Plugin.AiDOP/MaterialWarehouse/S5MdpSyncTransformService.cs
  41. 2 1
      server/Plugins/Admin.NET.Plugin.AiDOP/Supply/S3MdpSyncTransformService.cs

+ 1 - 1
Web/package.json

@@ -1,7 +1,7 @@
 {
 	"name": "admin.net",
 	"type": "module",
-	"version": "2.4.409",
+	"version": "2.4.410",
 	"packageManager": "pnpm@10.32.1",
 	"lastBuildTime": "2026.03.15",
 	"description": "Admin.NET 站在巨人肩膀上的 .NET 通用权限开发框架",

+ 1 - 1
Web/src/views/aidop/data-platform/api/sources.ts

@@ -24,7 +24,7 @@ export function fetchSemanticCodes() {
 	return service.get('/api/DataPlatform/semantic-codes');
 }
 
-export function createInboundGrant(body: { tenantId: number; sourceCode: string; entityCode: string; rateLimitPerMin?: number }) {
+export function createInboundGrant(body: { tenantId: number; sourceCode: string; entityCode: string; rateLimitPerMin?: number; accessKey?: string }) {
 	return service.post('/api/DataPlatform/inbound-grants', body);
 }
 

+ 16 - 4
Web/src/views/aidop/data-platform/components/SourceOnboardWizard.vue

@@ -36,11 +36,15 @@
 		</div>
 
 		<div v-else>
-			<el-form label-width="100px">
+			<el-form label-width="110px">
 				<el-form-item label="实体编码"><el-input v-model="grantEntity" /></el-form-item>
 				<el-form-item label="每分钟限流"><el-input-number v-model="rate" :min="1" /></el-form-item>
+				<el-form-item label="已有访问标识">
+					<el-input v-model="existingKey" placeholder="留空则新建一套凭据;填写则复用,且必须属于本租户" />
+				</el-form-item>
 			</el-form>
-			<el-alert v-if="shownKey" type="warning" :closable="false" :title="'密钥只显示这一次:' + shownKey" />
+			<el-alert v-if="shownKey" type="warning" :closable="false" :title="'访问标识:' + shownKey" />
+			<el-alert v-if="shownSecret" class="mt" type="error" :closable="false" :title="'签名密钥只显示这一次:' + shownSecret" />
 		</div>
 
 		<template #footer>
@@ -69,6 +73,8 @@ const scope = ref('');
 const grantEntity = ref('');
 const rate = ref(60);
 const shownKey = ref('');
+const shownSecret = ref('');
+const existingKey = ref('');
 
 const lastStep = computed(() => (props.sourceType === 'API_INBOUND' ? step.value === 3 : step.value === 2));
 
@@ -76,6 +82,8 @@ watch(open, async (v) => {
 	if (!v) return;
 	step.value = 0;
 	shownKey.value = '';
+	shownSecret.value = '';
+	existingKey.value = '';
 	const res: any = await service.get('/api/DataPlatform/std-objects');
 	const data = res?.data ?? res;
 	objects.value = Array.isArray(data) ? data : data?.list ?? [];
@@ -184,9 +192,12 @@ async function next() {
 			sourceCode: props.sourceCode,
 			entityCode: grantEntity.value,
 			rateLimitPerMin: rate.value,
+			accessKey: existingKey.value.trim() || undefined,
 		});
-		shownKey.value = created?.data?.accessKey ?? created?.accessKey ?? '';
-		ElMessage.success('授权已生成,请立刻抄下密钥');
+		const body = created?.data ?? created ?? {};
+		shownKey.value = body.accessKey ?? '';
+		shownSecret.value = body.accessSecret ?? '';
+		ElMessage.success(shownSecret.value ? '授权已生成,请立刻抄下签名密钥' : '已用现有访问标识完成授权');
 	} finally {
 		saving.value = false;
 	}
@@ -196,6 +207,7 @@ async function next() {
 <style scoped>
 .mb { margin-bottom: 16px; }
 .hint { color: var(--el-text-color-secondary); margin-bottom: 12px; }
+.mt { margin-top: 8px; }
 .row { display: flex; gap: 8px; align-items: center; margin-bottom: 8px; }
 .code { width: 220px; }
 </style>

BIN
_uat_screenshots/s0_menu_check.png


BIN
_uat_screenshots/s6_login_check.png


BIN
_uat_screenshots/s6_return002_drawer.png


BIN
_uat_screenshots/s7_02_before_save.png


BIN
_uat_screenshots/s7_start_state.png


BIN
_uat_screenshots/s8_00_initial.png


+ 5 - 0
doc/README.md

@@ -21,6 +21,7 @@
 |----------------|----------|
 | **`doc/plan/`** | 各模块/专题的**实施方案、设计说明**(如 `plan/S1/`) |
 | **`doc/plan/系统集成/`** | **第三方系统对接**指南与业务/技术详表(含 Word) |
+| **`doc/plan/系统部署/`** | **首次部署与新租户初始化**指南、初始化脚本(含 Word) |
 | **`doc/meeting/`** | 会议纪要、评审记录、过程性附件 |
 | **`doc/db/`** | 与主库相关的**表结构导出、生成脚本**(如 Excel、Python) |
 | **`doc/` 根下** | 跨模块的**功能说明**等独立 Markdown;第三方对接资料已归入 `plan/系统集成/` |
@@ -49,6 +50,9 @@
 | [plan/移动端APP-UI页面设计方案.md](./plan/移动端APP-UI页面设计方案.md) | **移动端 APP UI 设计方案(评审稿)**:设计 Tokens(品牌色 #0F59A4、字号/间距规范)、Element Plus→wot-design-uni 组件映射、4 Tab 信息架构与页面树、14 组页面详细设计(布局/交互/数据来源/状态设计)、通用交互模式、与 M1~M5 里程碑对应关系及设计验收方式 |
 | [审批流功能说明.md](./审批流功能说明.md) | 审批流菜单、审批中心、设计器、通知与权限 |
 | [审批流集成开发指南.md](./审批流集成开发指南.md) | `IFlowBizHandler`、`ApprovalPanel`、配置与 API |
+| [plan/系统部署/README.md](./plan/系统部署/README.md) | **部署与初始化入口**:首次云端部署 + 新租户工厂从零开通 |
+| [plan/推送凭据与租户基线修复执行任务书.md](./plan/推送凭据与租户基线修复执行任务书.md) | **可派发任务书**(纯后端,无 DDL):编写初始化指南时取证发现的两个缺陷。**包 A(P0)** 发放推送授权只写 `mdp_inbound_grant` 不写 `sys_open_access`,界面发出的 accessKey 必然验签失败(`InboundSignatureHandler.cs:74-90` 取的是 `SysOpenAccess.AccessSecret`),方式丙在生产路径上不可用;含 `BindTenantId` 决定入站数据租户归属这一关键点与跨租户串数防线。**包 B(P1)** 租户基线反射解析失败时完全静默(`SysTenantService.cs:311-325` 的 null 分支无日志)且无重跑入口,方法本身已幂等故加入口安全 |
+| [plan/系统部署/Ai-DOP系统初始化配置指南.md](./plan/系统部署/Ai-DOP系统初始化配置指南.md) | 按环境部署、新增租户工厂、运营建模主数据、数据服务层对接、启用运行五阶段编写,每阶段附验证项,末尾含 30 项检查表与问题排查表;配套初始化脚本 `init_new_tenant.py` |
 | [plan/系统集成/README.md](./plan/系统集成/README.md) | **第三方系统对接资料入口**:集成指南 + 业务/技术详表(Markdown 与 Word) |
 | [plan/系统集成/Ai-DOP第三方系统集成指南.md](./plan/系统集成/Ai-DOP第三方系统集成指南.md) | 给第三方系统看的对接指南:入站对象、DB 同步与 API 约定、回写与联调验收 |
 | [plan/系统集成/Ai-DOP第三方对接业务数据详表-业务页.md](./plan/系统集成/Ai-DOP第三方对接业务数据详表-业务页.md) | 对接详表(业务):九宫格/详情看板/诊断/ChatBI 需要哪些业务数据,不含库表名 |
@@ -121,6 +125,7 @@
 
 ## 变更记录
 
+- **2026-09-28**:新增 [plan/系统部署/](./plan/系统部署/README.md) 目录,含《Ai-DOP 系统初始化配置指南》与新租户初始化脚本 `init_new_tenant.py`;同日新增 [plan/推送凭据与租户基线修复执行任务书.md](./plan/推送凭据与租户基线修复执行任务书.md),记录编写指南期间取证发现的两个缺陷。
 - **2026-09-25**:新增 [plan/165-aidopdev连接耗尽根治执行任务书.md](./plan/165-aidopdev连接耗尽根治执行任务书.md),供其他模型按包 A–G 执行 1040 根治。同日修订:更正根因权重(多实例为次要因素,定时全量设计量级才是主因);包 B 扩写为执行机注册/人工指派/领取过滤;新增包 G 超管跨租户 ETL 管理。
 - **2026-09-06**:重写 [plan/第三方标准API推数入站方案.md](./plan/第三方标准API推数入站方案.md) 为五层终态(否定只贴源/另案回灌);UAT-INT-004 待确认立项。
 - **2026-09-06**:新增 [plan/AIDOP标准与非标双模BOM升级方案.md](./plan/AIDOP标准与非标双模BOM升级方案.md)(标准/非标双模 + 三轨收口,非最小改动)。

+ 238 - 0
doc/plan/推送凭据与租户基线修复执行任务书.md

@@ -0,0 +1,238 @@
+# 推送凭据与租户基线修复执行任务书
+
+| 项目 | 内容 |
+|------|------|
+| 文档版本 | V1.0(2026-09-28) |
+| 状态 | **已落地**(2026-09-28,server 1.0.584 / Web 2.4.410)。守卫测试 9 项通过。A-V2、A-V3、A-V6 需连真实环境用一笔推送验证,代码侧未跑 |
+| 来源 | 编写《Ai-DOP 系统初始化配置指南》期间,对新租户开通链路做三层取证时发现 |
+| 基线版本 | server `1.0.583` |
+| 工作包 | **包 A** 推送凭据发放(P0,功能实际不可用)、**包 B** 租户基线静默跳过(P1,健壮性) |
+
+---
+
+## 0. 执行须知
+
+1. 两个包互相独立,可分别派发、分别验收,无先后依赖。
+2. **包 A 改的是对外契约的一部分**(推送授权的返回体),落地前须同步更新《Ai-DOP 第三方系统集成指南》与《Ai-DOP 第三方对接业务数据详表(技术页)》中关于凭据发放的描述。
+3. 全程**不得**把任何 `AccessSecret`、数据库口令写入代码、迁移脚本、任务书或提交。联调用的密钥一律运行时生成、一次性回显。
+4. 本任务书不含 DDL。`mdp_inbound_grant` 与 `sys_open_access` 两张表结构均已存在,不需要新建或改列。
+5. 改动落在 `Admin.NET.Core` 与 `Admin.NET.Plugin.AiDOP`,按版本规则须递增后端 `.csproj` 三处版本号。
+
+---
+
+## 1. 包 A:推送授权发放的凭据无法通过验签
+
+### 1.1 现象
+
+在「来源映射维护」页对某业务对象点击发放推送授权,界面返回一个 `accessKey`。把它交给第三方后,第三方无论怎么签名,推送一律被拒,返回 `401 unknown AccessKey`。
+
+### 1.2 三层取证
+
+**代码层**:验签取的是 `sys_open_access` 里的密钥,与 `mdp_inbound_grant` 是两张表。
+
+```72:90:server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/Inbound/InboundSignatureHandler.cs
+        SysOpenAccess openAccess;
+        try
+        {
+            openAccess = await _openAccess.GetByKey(accessKey);
+        }
+        // ...
+        if (openAccess == null || string.IsNullOrWhiteSpace(openAccess.AccessSecret))
+            return Fail("unknown AccessKey");
+        // ...
+        var expected = Sign(openAccess.AccessSecret, message);
+```
+
+而发放授权的接口现场随机生成一个 key,只写 `mdp_inbound_grant`,从不接触 `sys_open_access`:
+
+```298:311:server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/MdpSourceMappingService.cs
+        var key = Convert.ToHexString(System.Security.Cryptography.RandomNumberGenerator.GetBytes(16)).ToLowerInvariant();
+        var now = DateTime.Now;
+        await _db.Insertable(new MdpInboundGrant
+        {
+            TenantId = tenant,
+            AccessKey = key,
+            EntityCode = input.EntityCode.Trim(),
+            // ...
+```
+
+入参也没有绑定已有身份的字段:
+
+```375:381:server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/MdpSourceMappingService.cs
+public sealed class GrantCreateInput
+{
+    public long TenantId { get; set; }
+    public string SourceCode { get; set; } = "";
+    public string EntityCode { get; set; } = "";
+    public int RateLimitPerMin { get; set; } = 60;
+}
+```
+
+**配置层**:`1.0.536.sql:153-156` 与 `:186-194` 是把 `SysOpenAccess` 与 `mdp_inbound_grant` 两张表**一起**插的,说明设计意图本就是两者成对存在,只是 API 路径漏掉了前者。
+
+**数据层**:`mdp_inbound_request` 为 0 行,推送通道从未被真实调用过,因此该缺陷至今未被暴露。
+
+### 1.3 根因
+
+发放授权只完成了「这个 key 可以推这个实体」的授权登记,没有完成「这个 key 是谁、密钥是什么」的身份登记。两者分属不同模块,API 只做了后半段。
+
+### 1.4 一个必须一并处理的关键字段
+
+验签成功后,**推进来的数据归属哪个租户,取自 `SysOpenAccess.BindTenantId`**,不是取自 grant:
+
+```102:105:server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/Inbound/InboundSignatureHandler.cs
+        var identity = new ClaimsIdentity(InboundSignatureDefaults.AuthenticationScheme);
+        identity.AddClaim(new Claim(ClaimConst.TenantId, openAccess.BindTenantId.ToString()));
+        identity.AddClaim(new Claim(ClaimConst.UserId, openAccess.BindUserId.ToString()));
+        identity.AddClaim(new Claim(InboundSignatureDefaults.AccessKeyClaim, accessKey));
+```
+
+因此创建身份时 `BindTenantId` 必须写成发放授权时的目标租户。写错或留 0,会导致数据落到错误租户或 0 租户——这正是 [租户隔离收口任务书](租户隔离收口与造数前置任务书.md) 要根除的那类污染,必须在本次一并杜绝。
+
+`BindUserId` 在本条链路中只进 Claim,不解引用导航属性,填 0 不会抛异常;但平台自带的 Signature 认证事件(`SysOpenAccessService.cs:182-189`)会取 `BindUser.Account`,留 0 将来会踩空。建议填该租户的租管用户 Id。
+
+### 1.5 方案
+
+保留「一套凭据可推多个实体」的能力——`mdp_inbound_grant` 的唯一键是 `(access_key, entity_code)`,本就允许同一个 key 对应多个实体,这也是一个第三方系统推多类数据时的常态。据此分两条路径:
+
+| 入参 | 行为 | 返回 |
+|------|------|------|
+| 传入 `AccessKey` | 校验该身份存在且 `BindTenantId` 等于目标租户,不匹配则拒绝;只插 grant | `accessKey`、`entityCode` |
+| 未传 `AccessKey` | 同一事务内新建 `SysOpenAccess`(随机 key 与 secret,绑定目标租户与租管用户)+ 插 grant | `accessKey`、`accessSecret`、`entityCode`,并标注密钥仅此一次可见 |
+
+校验 `BindTenantId` 是必需的,否则可以拿 A 租户的身份为 B 租户的实体发授权,数据会按身份归到 A,形成跨租户串数。
+
+### 1.6 改动清单
+
+| 文件 | 改动 |
+|------|------|
+| `server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/MdpSourceMappingService.cs` | `GrantCreateInput` 增加可选 `AccessKey`;`CreateGrant`(`:287-313`)按 1.5 分两条路径,新建身份时写入 `BindTenantId` 与 `BindUserId`;两次写入置于同一事务 |
+| 同上 | 新建身份后清一次缓存键 `CacheConst.KeyOpenAccess + accessKey` |
+| `doc/plan/系统集成/Ai-DOP第三方系统集成指南.md` | 更新凭据发放段落:说明一次拿到标识与密钥,密钥仅回显一次 |
+| `doc/plan/系统集成/Ai-DOP第三方对接业务数据详表-技术页.md` | 同步凭据发放描述 |
+| `doc/plan/系统部署/Ai-DOP系统初始化配置指南.md` | §6.4 由「两处分别配置」改为「一次发放」,并删去临时变通说明 |
+
+**关于清缓存**:`GetByKey` 走 `GetOrAdd` 缓存(`SysOpenAccessService.cs:138-149`),而 `AddOpenAccess`(`:79-86`)与 `UpdateOpenAccess`、`DeleteOpenAccess` 不同,**没有**清缓存动作。若第三方在身份建好之前先推过一次,该 key 是否已被以空值缓存,须实测确认。无论实测结果如何,新建后主动清一次都是无害且正确的,与 Update/Delete 的既有做法一致。
+
+### 1.7 验收
+
+| 编号 | 验收项 | 判定 |
+|------|--------|------|
+| A-V1 | 不传 AccessKey 发授权 | 返回体同时包含 `accessKey` 与 `accessSecret` |
+| A-V2 | 用 A-V1 的凭据按签名规则推一笔 | 返回成功,`mdp_inbound_request` 新增一行。**改前此项必然返回 `unknown AccessKey`**,须留改前截图作对照 |
+| A-V3 | 租户归属 | 推入的数据 `tenant_id` 等于发放授权时的目标租户,不为 0 |
+| A-V4 | 复用凭据 | 用 A-V1 的 accessKey 为同租户另一实体发授权,成功且不新建身份 |
+| A-V5 | 跨租户拒绝 | 用 A 租户的 accessKey 为 B 租户实体发授权,被拒 |
+| A-V6 | 先推后建 | 先用未登记的 key 推一次(预期拒绝),再建身份,再推一次,应成功(验证缓存未被污染) |
+
+**守卫测试**(新增,缺一不可):不传 AccessKey 时两张表成对写入;传入他租户 AccessKey 时抛错;传入本租户 AccessKey 时不重复建身份。
+
+---
+
+## 2. 包 B:租户基线静默跳过且无重跑入口
+
+### 2.1 现象
+
+创建租户成功,界面无任何提示,但该租户下没有 S8 业务角色、没有指标计算配置,看板取不到数。服务日志里也可能什么都没有。
+
+### 2.2 三层取证
+
+**代码层**:
+
+```311:325:server/Admin.NET.Core/Service/Tenant/SysTenantService.cs
+        try
+        {
+            var initType = Type.GetType("Admin.NET.Plugin.AiDOP.Infrastructure.AidopTenantInitService, Admin.NET.Plugin.AiDOP");
+            if (initType != null)
+            {
+                var initSvc = App.GetService(initType);
+                var method = initType.GetMethod("InitTenantBaselineAsync");
+                if (initSvc != null && method != null)
+                    await (Task)method.Invoke(initSvc, new object[] { tenantId })!;
+            }
+        }
+        catch (Exception ex)
+        {
+            Log.Error("AidopTenantInitService.InitTenantBaselineAsync failed for tenant {TenantId}: {Message}", tenantId, ex.Message);
+        }
+```
+
+这里有两级失败,性质不同:
+
+| 路径 | 后果 | 评价 |
+|------|------|------|
+| 抛异常 | 被 catch,租户仍创建,**有** Error 日志 | 取舍合理——插件故障不该拖垮核心建租户 |
+| `initType` / `initSvc` / `method` 任一为 null | 整段基线被跳过,**一行日志都没有** | 缺陷。比异常路径更难查 |
+
+**重跑入口**:全仓检索 `InitTenantBaseline` 仅 2 处匹配——方法定义本身(`AidopTenantInitService.cs:30`)与上述唯一调用点(`SysTenantService.cs:317`)。**没有任何 HTTP 入口**,一旦跳过只能删租户重建。
+
+**幂等性已具备**:方法注释明写「幂等种入…允许重复调用」(`AidopTenantInitService.cs:29`),五个子步骤各自带存在性检查后再插入(`:60-65`、`:85-88`、`:118-121` 等)。因此**加重跑入口是安全的,不需要额外做幂等改造**。
+
+### 2.3 方案
+
+| 项 | 做法 |
+|----|------|
+| B-1 补日志 | 三个 null 分支各打一条 Error,写明 tenantId 与具体哪一步解析失败 |
+| B-2 重跑入口 | 新增超管接口,对指定租户重跑基线。因方法本身幂等,直接调用即可 |
+| B-3 结果可见 | 重跑接口返回各子步骤的落地条数,便于确认到底补了什么 |
+
+B-2 建议挂 `api/aidop/tenant-baseline/reinit`,鉴权照 `MdpMonitorService` 中 `admin/*` 接口族的 `RequireSuperAdmin()` 写法。`AidopTenantInitService` 是 `ITransient`,可直接注入。
+
+**不做**改 `AddTenant` 的返回体来回传基线结果——那会改动 Core 的对外契约并牵连前端租户管理页。加日志与重跑入口已足以定位和补救,收益不抵影响面。如需界面提示,另案评估。
+
+### 2.4 改动清单
+
+| 文件 | 改动 |
+|------|------|
+| `server/Admin.NET.Core/Service/Tenant/SysTenantService.cs` | `:311-325` 三个 null 分支补 Error 日志 |
+| `server/Plugins/Admin.NET.Plugin.AiDOP/Infrastructure/AidopTenantInitService.cs` | 五个子步骤各返回落地条数,供 B-3 汇总(方法签名由 `Task` 改 `Task<结果>`,唯一调用点同步调整) |
+| 新增控制器 | 超管重跑接口,注入 `AidopTenantInitService` |
+| `doc/plan/系统部署/Ai-DOP系统初始化配置指南.md` | §4.3 删去「平台没有提供重跑入口」的说明;§8.4 从待人工事项中移除该条;`init_new_tenant.py` 的基线步骤由「只核对」改为「可重跑」 |
+
+### 2.5 验收
+
+| 编号 | 验收项 | 判定 |
+|------|--------|------|
+| B-V1 | 解析失败可见 | 构造插件未加载场景,日志出现 Error 并含 tenantId |
+| B-V2 | 重跑有效 | 对一个缺基线的租户调重跑,S8 业务角色与指标计算配置补齐 |
+| B-V3 | 重跑幂等 | 连续调用两次,相关表行数不变 |
+| B-V4 | 鉴权 | 非超管调用被拒 |
+| B-V5 | 模板租户自身 | 对模板租户调用不产生自我复制(`:43`、`:107` 已有短路,回归确认) |
+
+**守卫测试**(新增):连续两次重跑后行数不变;非超管调用被拒。
+
+---
+
+## 3. 反向影响推演
+
+| 面 | 结论 |
+|----|------|
+| **代码调用面** | 包 A:`CreateGrant` 全仓仅「来源映射维护」页一个调用方,新增可选入参向后兼容。包 B:`InitTenantBaselineAsync` 全仓仅 `SysTenantService.cs:317` 一处调用,改签名须同步该处——**需同步改** |
+| **数据契约面** | 包 A 新写 `sys_open_access` 行。该表被平台自带 Signature 认证与 MDP 入站两条链路共用,新增行不影响既有行;但 `BindUserId` 留 0 会使平台自带认证在该 key 上取空,故要求填租管用户 Id——**需同步改** |
+| **多数据源面** | 包 A 只影响方式丙(接口推送)。方式甲(数据库同步)与方式乙(接口拉取)不经过验签,**无影响** |
+| **多租户 / Domain 面** | 包 A 的 `BindTenantId` 校验是本次的核心防线,必须有守卫测试(A-V5)。包 B 的重跑接口须限超管并显式传 tenantId,不得取当前上下文租户,否则超管切换租户后会补错对象——**需同步改** |
+| **运行面** | 无 DDL、无迁移脚本、无种子改动、无定时任务改动。包 A 涉及一处缓存键失效 |
+| **验证面** | 现有 `MdpConfigApiTenantIsolationTests`、`AidopTenantInitContractTests` 覆盖不到本次两条路径,两个包各自的守卫测试**必须新增**,不得以「手工验过」替代 |
+
+---
+
+## 4. 明确不做的事
+
+| 事项 | 原因 |
+|------|------|
+| 改执行机为自荐 | `is_runner` 人工指派是设计,见 `AidopJobGate.cs:27`、`EtlInstanceRegistrar.cs:111`,是 [165-aidopdev 连接耗尽根治任务书](165-aidopdev连接耗尽根治执行任务书.md) 包 B 的成果,不得回退 |
+| 给 KPI 维度配置与目标值加模板复制 | 各客户考核口径不同,复制模板会带进错误目标值。属设计取舍,非缺陷 |
+| 改 `AddTenant` 返回体 | 见 2.3,影响面不抵收益 |
+| 给 `AddOpenAccess` 补缓存清理 | 属 Admin.NET 上游代码,本次只在新建路径上自行清理,不改上游通用方法 |
+| 清理历史数据 | `mdp_inbound_request` 为 0 行,无存量可清 |
+
+---
+
+## 5. 交付总表
+
+| 包 | 优先级 | 改动文件数 | DDL | 守卫测试 | 验收项 |
+|----|--------|------------|-----|----------|--------|
+| A 推送凭据发放 | P0 | 2 代码 + 3 文档 | 无 | 3 条 | A-V1 至 A-V6 |
+| B 租户基线 | P1 | 3 代码 + 1 文档 | 无 | 2 条 | B-V1 至 B-V5 |
+
+包 A 的定级依据:该功能当前**在生产路径上完全不可用**,界面发出的凭据必然验签失败,且三份对外交付文档都已按「可用」描述。包 B 为健壮性问题,有人工绕过手段(删租户重建)。

BIN
doc/plan/系统部署/Ai-DOP系统初始化配置指南.docx


+ 671 - 0
doc/plan/系统部署/Ai-DOP系统初始化配置指南.md

@@ -0,0 +1,671 @@
+# Ai-DOP 系统初始化配置指南
+
+| 项目 | 内容 |
+|------|------|
+| 文档名称 | Ai-DOP 系统初始化配置指南 |
+| 文档版本 | V1.0 |
+| 发布日期 | 2026-09-28 |
+| 适用读者 | 实施工程师、系统管理员、运维人员 |
+
+## 修订记录
+
+| 版本 | 日期 | 修订内容 |
+|------|------|----------|
+| V1.0 | 2026-09-28 | 初版发布。覆盖首次云端部署、新增租户工厂、运营建模主数据、数据服务层对接配置、启用运行与验证五个阶段,并提供初始化脚本 |
+
+## 目录
+
+| 章节 | 标题 |
+|------|------|
+| 1 | 引言 |
+| 1.1 | 编写目的与适用读者 |
+| 1.2 | 名词与约定 |
+| 1.3 | 五个阶段与责任分工 |
+| 2 | 部署前置条件 |
+| 2.1 | 服务器与中间件 |
+| 2.2 | 网络与端口 |
+| 2.3 | 交付物与账号准备 |
+| 3 | 环境部署与数据库初始化 |
+| 3.1 | 部署方式 |
+| 3.2 | 配置文件与生效顺序 |
+| 3.3 | 数据库建库与表结构 |
+| 3.4 | 版本迁移脚本 |
+| 3.5 | 种子数据 |
+| 3.6 | 本阶段验证 |
+| 4 | 新增租户工厂 |
+| 4.1 | 隔离模式的选择 |
+| 4.2 | 创建租户 |
+| 4.3 | 创建时自动生成的内容 |
+| 4.4 | 菜单授权 |
+| 4.5 | 首次登录 |
+| 4.6 | 本阶段验证 |
+| 5 | 组织机构与运营建模主数据 |
+| 5.1 | 组织机构 |
+| 5.2 | 主数据录入顺序 |
+| 5.3 | 各业务域主数据清单 |
+| 5.4 | 排程与齐套的硬性前提 |
+| 5.5 | 录入与导入方式 |
+| 5.6 | 本阶段验证 |
+| 6 | 数据服务层对接配置 |
+| 6.1 | 配置次序 |
+| 6.2 | 数据源登记 |
+| 6.3 | 租户业务对象来源 |
+| 6.4 | 接口推送授权 |
+| 6.5 | 同步任务 |
+| 6.6 | 本阶段验证 |
+| 7 | 启用运行与验证 |
+| 7.1 | 指派 ETL 执行机 |
+| 7.2 | 触发同步与模块重算 |
+| 7.3 | 指标配置 |
+| 7.4 | 本阶段验证 |
+| 8 | 初始化脚本 |
+| 8.1 | 脚本覆盖范围 |
+| 8.2 | 运行环境与参数 |
+| 8.3 | 使用方式 |
+| 8.4 | 脚本不能代办的事项 |
+| 9 | 初始化检查表与问题排查 |
+| 9.1 | 初始化检查表 |
+| 9.2 | 常见问题排查 |
+
+## 1. 引言
+
+### 1.1 编写目的与适用读者
+
+本文档用于指导实施人员在一套全新的云端环境上完成 Ai-DOP 智能运营平台的部署与初始化配置,并在此基础上接入一个新的租户工厂,直至各模块看板能够正常取数。
+
+适用读者为负责环境搭建的运维人员、负责租户开通与主数据建档的实施工程师,以及负责与第三方业务系统对接的技术窗口。
+
+本文档不涉及业务指标口径与对接数据范围的说明。第三方业务系统需要提供哪些业务数据,以及各功能对数据的依赖关系,见《Ai-DOP 第三方对接业务数据详表(业务页)》;字段级对接规格见《Ai-DOP 第三方对接业务数据详表(技术页)》与《Ai-DOP 第三方系统集成指南》。
+
+### 1.2 名词与约定
+
+| 名词 | 含义 |
+|------|------|
+| 租户 | 平台的数据隔离单元。一家客户企业对应一个租户,其全部业务数据与配置互不可见 |
+| 工厂 | 租户内的生产实体,对应组织机构中的工厂节点。一个租户可包含多个工厂 |
+| 数据服务层 | 平台统一接收、清洗并换算外部业务数据的中间层。各功能只读取该层的结果,不直连第三方业务系统 |
+| 贴源层 | 数据服务层中按原样留存外部数据的第一层,用于追溯与比对 |
+| 标准层 | 数据服务层中完成口径统一与单位换算后的一层,是指标计算的直接输入 |
+| 业务对象 | 数据服务层中一类可独立对接的业务数据,例如采购订单、入库单、报工记录 |
+| ETL 执行机 | 被指定负责运行定时数据处理作业的后端实例。多实例部署时同一时刻只能有一台 |
+| 模块重算 | 按模块重新执行一遍从贴源到指标的完整链路,用于让新接入的数据立即体现在看板上 |
+| 超管 | 平台级超级管理员,可跨租户操作 |
+| 租管 | 租户管理员,权限范围限于本租户 |
+
+平台业务模块及其编号如下,本文档统一采用该编号与名称。
+
+| 编号 | 模块名称 | 编号 | 模块名称 |
+|------|----------|------|----------|
+| S0 | 运营建模 | S5 | 物料仓储 |
+| S1 | 产销协同 | S6 | 生产执行 |
+| S2 | 制造协同 | S7 | 成品仓储 |
+| S3 | 供应协同 | S8 | 异常监控 |
+| S4 | 采购执行 | S9 | 运营指标 |
+
+### 1.3 五个阶段与责任分工
+
+初始化按下列五个阶段顺序进行,前一阶段未验证通过不得进入下一阶段。
+
+| 阶段 | 内容 | 频次 | 责任方 |
+|------|------|------|--------|
+| 一 | 环境部署与数据库初始化 | 每套环境一次 | 运维人员 |
+| 二 | 新增租户工厂 | 每个客户一次 | 实施工程师(需超管权限) |
+| 三 | 组织机构与运营建模主数据 | 每个工厂一次 | 实施工程师会同客户业务部门 |
+| 四 | 数据服务层对接配置 | 每个数据来源一次 | 技术窗口会同第三方系统人员 |
+| 五 | 启用运行与验证 | 每套环境与每个租户各一次 | 运维人员与实施工程师 |
+
+阶段一为环境级,与租户无关;阶段二至四为租户级,新增第二个客户时从阶段二重新开始;阶段五中的执行机指派为环境级,模块重算为租户级。
+
+## 2. 部署前置条件
+
+### 2.1 服务器与中间件
+
+| 组件 | 说明 |
+|------|------|
+| 应用服务器 | 承载后端服务与前端静态站点,需安装容器运行环境 |
+| MySQL | 平台主库。字符集需为 utf8mb4,事务隔离级别为 READ-COMMITTED,表名大小写不敏感 |
+| Redis | 缓存与事件总线。单实例部署时可使用内存缓存,多实例部署时必须使用 Redis |
+| 对象存储 | 存放上传文件与图片,可使用 MinIO |
+| 第三方业务系统数据库 | 若采用数据库同步方式对接,需开通只读账号与网络可达 |
+
+多实例部署时有两项额外要求:缓存必须切换为 Redis,否则登录态与实时消息不能在实例间共享;后端实例中只能有一台承担数据库版本迁移与定时数据处理,具体见 3.4 与 7.1。
+
+### 2.2 网络与端口
+
+下表为标准部署的端口映射,实际可按现场调整,调整后需同步修改反向代理配置。
+
+| 组件 | 对外端口 | 容器内端口 | 用途 |
+|------|----------|------------|------|
+| 站点(HTTP) | 9100 | 80 | 前端页面与接口入口 |
+| 站点(HTTPS) | 9103 | 443 | 同上,启用证书后使用 |
+| 后端服务 | 9102 | 5005 | 接口服务,通常不直接对外暴露 |
+| MySQL | 9101 | 3306 | 数据库,建议仅对内网开放 |
+| Redis | 9137 | 6379 | 缓存,建议仅对内网开放 |
+| 对象存储 | 9104 / 9105 | 9000 / 9001 | 文件服务与管理控制台 |
+
+前端通过站点端口访问,接口统一走 `/prod-api` 前缀由反向代理转发至后端服务。实时消息使用 WebSocket,反向代理需对 `/prod-api/hubs` 开启协议升级,否则页面右上角的消息提醒与看板实时刷新不可用。
+
+### 2.3 交付物与账号准备
+
+开始部署前需准备齐下列内容。
+
+| 项目 | 说明 |
+|------|------|
+| 前端构建产物 | 前端工程的发布目录 |
+| 后端发布产物 | 后端服务的发布目录,其中须包含版本迁移脚本目录 |
+| 数据库连接信息 | 主库地址、端口、库名、账号、口令 |
+| 第三方系统连接信息 | 若采用数据库同步,需只读账号;若采用接口推送或拉取,需接口地址与联系人 |
+| 客户企业信息 | 企业名称、工厂名称与数量、租管账号名 |
+
+数据库口令、接口密钥等敏感信息不得写入任何文档、脚本或代码仓库,应通过环境变量或运维密钥管理方式下发。
+
+## 3. 环境部署与数据库初始化
+
+### 3.1 部署方式
+
+平台提供两种部署方式,二者选其一,不可混用。
+
+第一种为挂载部署:在构建机上分别完成前端与后端的构建,将产物目录挂载进容器运行。适用于需要频繁更新、且构建环境与运行环境分离的场景。
+
+第二种为镜像部署:由前端与后端各自的镜像描述文件在镜像内完成构建与发布。适用于交付固定版本、运行环境不具备构建条件的场景。
+
+两种方式在数据库初始化行为上完全一致,区别仅在于产物从何而来,以及反向代理指向的后端服务名不同,需与实际编排文件中的服务名保持一致。
+
+### 3.2 配置文件与生效顺序
+
+后端的全部配置以 JSON 文件形式存放于发布目录下的配置目录中,服务启动时扫描该目录并合并加载。容器部署时通常再以外部挂载的方式覆盖其中部分文件,挂载文件优先级更高。
+
+下列配置项直接影响初始化行为,部署前必须逐项确认。
+
+| 配置文件 | 关键项 | 说明 |
+|----------|--------|------|
+| 数据库配置 | 连接串 | 主库地址、库名与账号。库名决定平台建在哪个库中 |
+| 数据库配置 | 自动建库开关 | 开启时,目标库不存在则自动创建 |
+| 数据库配置 | 自动建表开关 | 开启时,按实体定义自动创建与补齐表结构 |
+| 数据库配置 | 自动建视图开关 | 开启时,自动创建平台所需视图 |
+| 数据库配置 | 种子数据开关 | 控制是否写入基础数据,详见 3.5 |
+| 应用配置 | 服务监听地址 | 后端监听端口 |
+| 应用配置 | 实例编号 | 决定本实例是否执行版本迁移脚本,详见 3.4 |
+| 应用配置 | 作业调度开关 | 定时作业总开关,关闭后全部定时任务不运行 |
+| 缓存配置 | 缓存类型 | 单实例可用内存缓存,多实例必须使用 Redis |
+| 上传配置 | 对象存储参数 | 文件与图片的存放位置 |
+
+需特别注意:随产物发布的默认数据库配置与容器挂载用的数据库配置,在种子数据开关上取值可能不同。部署前必须明确本次以哪一份为准,并确认该文件中种子数据开关的取值符合预期,否则会出现基础菜单与字典缺失,或反复被基线数据覆盖的情况。
+
+若在编排中设置了跳过数据库初始化的环境变量,则自动建库、自动建表与种子数据三项将全部不执行。该变量仅用于已完成初始化后的重复启动,首次部署时不得设置。
+
+### 3.3 数据库建库与表结构
+
+后端服务启动时按下列顺序完成数据库准备,全过程为同步执行,未完成则服务不对外提供接口。
+
+| 顺序 | 动作 | 触发条件 |
+|------|------|----------|
+| 1 | 创建数据库 | 自动建库开关开启且目标库不存在 |
+| 2 | 创建与补齐表结构 | 自动建表开关开启 |
+| 3 | 创建视图 | 自动建视图开关开启 |
+| 4 | 写入种子数据 | 种子数据开关开启 |
+| 5 | 执行版本迁移脚本 | 见 3.4 |
+
+容器编排中由数据库容器自行创建的库,与连接串中指定的库不一定是同一个。若连接串指向的库名与编排中创建的库名不一致,必须依赖自动建库开关,或在部署前手工建好该库,否则服务启动即失败。
+
+### 3.4 版本迁移脚本
+
+表结构之外的数据变更、索引、存量数据修正等,通过版本迁移脚本完成。脚本随后端产物一同发布,存放于发布目录下的迁移脚本目录中。
+
+执行规则如下。
+
+| 规则 | 说明 |
+|------|------|
+| 纳入范围 | 迁移脚本目录下的顶层脚本文件,按文件名中的版本号排序执行 |
+| 命名要求 | 文件名必须是可解析的版本号形式,不符合该形式的文件不会被执行 |
+| 校验脚本 | 与迁移脚本同名并带校验后缀的文件不作为迁移执行,仅用于结果断言 |
+| 执行上限 | 只执行版本号不高于当前程序集版本的脚本 |
+| 执行记录 | 已执行的脚本记录在迁移日志表中,重复启动不会重复执行 |
+| 失败处理 | 默认在脚本失败时终止服务启动,避免带着不完整的结构对外提供服务 |
+| 换行符 | 脚本文件必须使用 LF 换行,CRLF 会导致解析异常并阻断启动 |
+
+**多实例部署的关键约束**:只有实例编号为 1 的后端实例会执行迁移脚本。多实例部署时必须确保恰有一台配置为该编号,否则全部实例都跳过迁移,表结构停留在旧版本,且该现象在启动日志中不表现为错误。
+
+首次部署完成后,应确认迁移日志表中的记录数与产物中的脚本数量一致。
+
+### 3.5 种子数据
+
+种子数据是平台运行所必需的基础数据,包括系统菜单、数据字典、系统参数、默认角色与默认租户,以及各业务模块的基线配置,例如异常类型清单、大屏卡片配置、字段语义字典等。
+
+种子数据由种子数据开关控制,开关开启时每次启动都会执行,不是仅在空库时执行一次。其写入策略为:有主键的数据按主键比对并更新,无主键的数据仅在表为空时整批写入。因此在已上线环境中开启该开关,会把被手工修改过的基线数据改回默认值。
+
+建议的做法是:首次部署时开启,完成初始化并验证后关闭;后续版本升级若需要补充基线数据,由版本迁移脚本承担,不再依赖该开关。
+
+除上述基础种子外,部分业务基线数据由版本迁移脚本写入,不在种子数据范围内。这意味着即使种子数据开关关闭,只要迁移脚本执行完整,业务基线仍然完备。
+
+### 3.6 本阶段验证
+
+| 序号 | 验证项 | 判定标准 |
+|------|--------|----------|
+| 1 | 服务启动 | 后端服务正常监听,启动日志无异常终止 |
+| 2 | 数据库建立 | 目标库存在,平台表结构完整 |
+| 3 | 迁移执行 | 迁移日志表中的记录数与产物脚本数量一致 |
+| 4 | 站点访问 | 通过站点端口可打开登录页 |
+| 5 | 接口连通 | 登录动作可正常完成,未出现接口地址错误 |
+| 6 | 实时消息 | 登录后实时消息通道建立成功 |
+
+## 4. 新增租户工厂
+
+本阶段的全部操作需以超管身份执行。
+
+### 4.1 隔离模式的选择
+
+平台支持两种租户隔离模式,创建时选定后不建议更改。
+
+| 模式 | 数据存放 | 适用场景 | 附加操作 |
+|------|----------|----------|----------|
+| 标识隔离 | 与平台同库,按租户标识自动过滤 | 单客户部署,或客户数量少、数据量适中 | 无 |
+| 库隔离 | 客户业务数据存放于独立数据库 | 多客户共用一套环境,且要求物理隔离 | 创建租户后须另行执行建库动作 |
+
+采用标识隔离时,数据库类型与连接串强制与平台主库保持一致,无需填写。采用库隔离时必须提供独立的连接串,且创建租户的动作本身不会建库,须在创建完成后再执行一次建库动作,由平台在新库中建立业务表结构。
+
+### 4.2 创建租户
+
+在系统管理的租户管理中新增租户,需填写的关键信息如下。
+
+| 字段 | 必填 | 说明 |
+|------|------|------|
+| 租户名称 | 是 | 客户企业名称,不少于两个字符,且不能与已有租户重名 |
+| 租管账号 | 是 | 租户管理员登录账号,不少于三个字符,且在全平台范围内唯一 |
+| 隔离模式 | 是 | 见 4.1 |
+| 连接串 | 库隔离时必填 | 独立数据库的连接信息 |
+| 域名 | 否 | 按域名区分租户时填写,填写后需同时填写站点标题、副标题、描述与版权说明 |
+
+租管账号的唯一性是在全平台范围内校验的,不同租户之间也不允许重名,取名时建议带上客户标识。
+
+### 4.3 创建时自动生成的内容
+
+租户创建成功后,平台自动完成下列初始化,无需手工操作。
+
+| 序号 | 内容 | 说明 |
+|------|------|------|
+| 1 | 根机构 | 以租户名称建立的顶层组织节点 |
+| 2 | 默认角色 | 一个基础角色,包含工作台与系统管理的只读类菜单 |
+| 3 | 管理员职位 | 挂在根机构下 |
+| 4 | 租管用户 | 使用填写的租管账号,归属根机构与管理员职位 |
+| 5 | 租户菜单 | 从平台菜单中按目录取三层子树授予本租户 |
+| 6 | 业务角色基线 | 异常监控的业务角色与改善审批基线角色 |
+| 7 | 场景与指标基线 | 异常监控场景配置、指标主数据与布局、指标计算配置 |
+
+第 6、7 项由平台在创建租户时一并写入,其中指标计算配置从平台模板租户按指标编码与版本号复制而来。
+
+**需要关注的例外**:第 6、7 项如果执行失败,租户仍然创建成功,失败信息记录在服务日志中,界面上不会提示。解析不到基线服务时同样会留下错误日志。创建租户后必须按 4.6 逐项验证;若验证不通过,由超管调用基线重跑接口补齐,该接口可重复调用,已有配置不会被覆盖。
+
+### 4.4 菜单授权
+
+租户能看到哪些菜单,由三层关系共同决定,缺一不可。
+
+| 层级 | 作用 | 维护位置 |
+|------|------|----------|
+| 租户菜单 | 本租户可见菜单的上限 | 租户管理中的菜单授权 |
+| 角色菜单 | 角色拥有的菜单,取值范围不能超出租户菜单 | 角色管理中的菜单授权 |
+| 用户角色 | 用户与角色的对应关系 | 用户管理中的角色分配 |
+
+创建租户时自动授予的租户菜单,取的是各目录下三层子树。若业务菜单层级更深,部分末级页面可能不在其中。后端服务在每次启动时会把业务菜单补入全部租户的租户菜单,因此常规情况下无需手工补授;若确认仍有缺失,可在租户管理中重新执行菜单授权。
+
+平台版本升级引入新菜单后,需由超管对各租户执行一次菜单同步,否则租户看不到新功能。
+
+### 4.5 首次登录
+
+租管用户的初始密码取自系统参数中的默认密码项,不随租户单独设置。若不清楚当前取值,可在用户管理中对该账号执行密码重置,重置后即为该系统参数的当前值。
+
+租管账号的账号类型为租户管理员。该类型不通过角色获得权限,而是直接拥有本租户范围内的菜单,因此创建时不会为其分配角色,这是正常现象。
+
+租户内的其它业务用户则必须分配角色,并确保角色已被授予相应菜单,否则登录后看不到任何业务页面。
+
+### 4.6 本阶段验证
+
+| 序号 | 验证项 | 判定标准 |
+|------|--------|----------|
+| 1 | 租户创建 | 租户列表中可见新租户,状态为启用 |
+| 2 | 独立库 | 库隔离模式下,独立库已创建且业务表结构完整 |
+| 3 | 租管登录 | 使用租管账号可成功登录并进入工作台 |
+| 4 | 菜单可见 | 侧栏可见 S0 至 S9 各业务模块目录及其末级页面 |
+| 5 | 业务角色 | 角色管理中可见异常监控业务角色与改善审批基线角色 |
+| 6 | 指标基线 | 指标配置页中可见指标主数据与计算配置,且计算配置非空 |
+
+第 5、6 项是 4.3 中提到的例外的验证点,必须实际打开页面确认,不能因为创建过程未报错就认为已完成。
+
+## 5. 组织机构与运营建模主数据
+
+本阶段起改由租管账号操作。
+
+### 5.1 组织机构
+
+运营建模的全部主数据都需要归属到具体的公司与工厂。平台通过组织机构的节点类型识别二者:类型为公司的节点代表企业法人实体,类型为工厂的节点代表生产实体。
+
+创建租户时自动建立的只有一个根机构,其节点类型并非公司,因此**在录入任何运营建模主数据之前,必须先在组织机构中补齐公司与工厂节点**,否则新增主数据时会被拒绝,并提示当前租户未配置机构、无法确定公司与工厂归属。
+
+建议建成如下形状。
+
+| 层级 | 节点类型 | 数量 | 说明 |
+|------|----------|------|------|
+| 第一层 | 根机构 | 1 | 租户创建时自动生成 |
+| 第二层 | 公司 | 1 | 企业法人实体 |
+| 第三层 | 工厂 | 按实际 | 挂在公司节点下,每个生产基地一个 |
+
+平台在识别当前公司与工厂时,优先取约定编码的节点,其次取类型匹配的节点,最后回落到根机构。单工厂场景下即使只建了公司未建工厂,也能继续使用,此时工厂等同于公司;但多工厂场景必须显式建立工厂节点,否则各工厂的数据会混在一起。
+
+### 5.2 主数据录入顺序
+
+运营建模主数据之间存在引用关系,录入顺序错误会导致保存失败。下表为按依赖关系排定的顺序,同一批次内的对象可并行录入。
+
+| 批次 | 录入对象 | 硬性前置 |
+|------|----------|----------|
+| 0 | 组织机构(公司、工厂) | 无 |
+| 1 | 部门 | 组织机构 |
+| 1 | 工作中心 | 组织机构 |
+| 1 | 库位(含货架) | 组织机构 |
+| 1 | 物料 | 组织机构 |
+| 1 | 客户、供应商 | 组织机构 |
+| 1 | 标准工序 | 组织机构 |
+| 1 | 人员技能字典、质量基础字典 | 组织机构 |
+| 2 | 雇员 | 部门 |
+| 2 | 生产线 | 库位 |
+| 2 | 标准 BOM | 物料 |
+| 2 | 标准工艺路线 | 物料、工作中心 |
+| 2 | 货源清单 | 物料、供应商 |
+| 2 | 物料职责 | 雇员、库位 |
+| 3 | 生产线物料 | 物料、生产线、标准工序 |
+| 3 | 产线岗位与岗位技能 | 生产线、人员技能字典 |
+| 3 | 物料替代关系、工艺要素 | 物料 |
+
+其中生产线的保存会实际校验所填库位是否存在,标准 BOM 会校验父件与子件物料是否存在,标准工艺路线会校验物料与工作中心是否存在,货源清单会校验物料与供应商是否存在,物料职责会校验雇员与库位是否存在。生产线物料在保存时不做存在性校验,但若填入了不存在的物料或工序,后续排程会取不到产能。
+
+### 5.3 各业务域主数据清单
+
+运营建模的页面按业务域分组,位于 S0 运营建模下的数据建模目录中。
+
+| 业务域 | 主要页面 |
+|--------|----------|
+| 公共组织 | 部门维护、雇员列表 |
+| 产销建模 | 客户信息维护、物料维护、订单优先级配置、合同评审周期、产品设计周期、订单评审周期、齐套检查排除规则 |
+| 制造建模 | 标准 BOM、标准工序、工序流转卡、生产线维护、标准工艺路线、物料替代关系、人员技能维护、产线岗位维护、工作中心、生产线物料维护、订单排程周期、工单控制参数、生产要素参数、物料工艺要素、前处理要素、SOP 维护 |
+| 供应建模 | 供应商维护、货源清单、品类采购前置期、物料计划周期 |
+| 质量建模 | 质量基础、原材料白名单、抽样方案、检验仪器、计量器具、检验方法、检验依据、检验标准、检验项目、检验频率、检验方案,以及原材料、过程、成品、出货四类检验规范 |
+| 仓储建模 | 成本中心、库位维护、货架列表、条码规则、标签格式、单号类型、单号规则、零件包装规格、物料职责、物料状态任务指派 |
+
+并非全部页面都必须在初始化阶段录入。应按客户本期启用的功能范围确定录入清单,未启用的业务域可暂不录入,后续按需补充。
+
+### 5.4 排程与齐套的硬性前提
+
+生产排程与齐套检查对主数据的完整性要求最高,下列各项缺一即会出现排程结果为空、或排出的计划不符合实际产能的现象。
+
+| 前提 | 缺失时的表现 |
+|------|--------------|
+| 工艺路线 | 工单没有关键工序,直接跳过排程与齐套 |
+| 生产线产能速率 | 不按产能排期,退化为按订单日期与交期线性摊派工序日期 |
+| 产线工作日历 | 该产线没有可用工作日,对应工序不参与产能占用 |
+| 物料主数据 | 工艺路线与产能按物料编码关联,缺失则取不到 |
+| BOM | 齐套检查无法展开物料需求 |
+
+其中**产线工作日历不在运营建模中维护,而在 S2 制造协同的产线工作日历管理中维护**,是初始化阶段最容易遗漏的一项。工作日历必须实际标注出工作日,若整段日历的工作日标记全部为否,排程会因为找不到可用工作日而空转,且该情况不会在界面上报错。
+
+齐套检查展开物料需求时读取的 BOM 数据,与运营建模中维护的标准 BOM 属于不同的数据存放位置。若客户的 BOM 由第三方系统提供,需在第四阶段通过数据服务层接入;若由平台内维护,需确认两处数据一致,否则会出现标准 BOM 已维护但齐套仍提示缺料的情况。
+
+### 5.5 录入与导入方式
+
+| 方式 | 适用范围 | 说明 |
+|------|----------|------|
+| 页面手工录入 | 全部主数据 | 数据量小或首次建档时使用 |
+| 模板导入 | 四类检验规范 | 在对应页面下载模板,填写后上传 |
+| 接口批量创建 | 全部主数据 | 各主数据均提供创建接口,可由脚本循环调用,契约与页面一致 |
+| 数据服务层接入 | 部分主数据 | 物料、BOM、工艺路线、生产线、库位、部门、雇员、供应商、工作中心等已在数据服务层中登记为可对接的业务对象,可由第三方系统直接提供 |
+
+若客户的主数据已存在于现有业务系统中,优先采用第四种方式由数据服务层接入,避免重复建档与后续的双边维护。仅当某类主数据在现有系统中不存在、或平台需要扩展额外属性时,才采用手工录入。
+
+主数据变更后,维度数据需要刷新才会体现到看板。平台设有定时刷新作业,也可由运维人员手工触发一次刷新。
+
+### 5.6 本阶段验证
+
+| 序号 | 验证项 | 判定标准 |
+|------|--------|----------|
+| 1 | 组织机构 | 公司节点与工厂节点均已建立,工厂挂在公司之下 |
+| 2 | 主数据保存 | 各业务域主数据可正常保存,无机构未配置类提示 |
+| 3 | 依赖完整 | 生产线、标准 BOM、工艺路线、货源清单均能保存成功 |
+| 4 | 工作日历 | S2 产线工作日历中,各产线存在标记为工作日的日期 |
+| 5 | 产能速率 | 生产线物料中已填写产能速率 |
+| 6 | 维度刷新 | 触发一次维度刷新后无异常 |
+
+## 6. 数据服务层对接配置
+
+本阶段配置页面位于数据服务层目录下,需超管或具备相应权限的账号操作。
+
+### 6.1 配置次序
+
+| 顺序 | 配置项 | 说明 |
+|------|--------|------|
+| 1 | 数据源 | 登记一个外部数据来源及其连接方式 |
+| 2 | 租户业务对象来源 | 指定本租户的某类业务数据由哪个数据源提供 |
+| 3 | 来源映射 | 外部字段与平台标准字段的对应关系 |
+| 4 | 接口推送授权 | 采用接口推送方式时,为第三方发放访问凭据 |
+| 5 | 同步任务 | 确认业务对象已挂在同步任务下 |
+
+三种对接方式对应的配置项不同:数据库同步需要 1、2、3、5;接口推送需要 1、2、3、4;接口拉取需要 1、2、3、5。
+
+### 6.2 数据源登记
+
+在数据源管理中新增数据源,按对接方式填写。
+
+| 对接方式 | 来源类型 | 需填写内容 |
+|----------|----------|------------|
+| 数据库同步 | 数据库 | 数据库类型、地址、端口、库名、只读账号与口令 |
+| 接口推送 | 对方推送 | 仅需来源标识,无需连接信息 |
+| 接口拉取 | 接口 | 接口地址与鉴权方式 |
+
+数据库口令在保存时加密存放。修改数据源时若口令留空,表示保持原值不变,不会被清空。保存后应使用页面提供的连接测试功能确认可连通,再进入下一步。
+
+若平台随环境预置了某个数据源但连接信息为空,后端在启动时会尝试从后端配置中的对应连接补齐,无需手工填写。
+
+### 6.3 租户业务对象来源
+
+同一类业务数据在不同租户可能来自不同系统,因此需要为本租户逐项指定来源。此处未配置的业务对象,即使数据源已登记,数据也不会进入本租户。
+
+多个系统提供同一类业务数据时,可分别登记为不同数据源并各自配置,平台在数据服务层按业务键合并,第三方无需预先整合。
+
+### 6.4 接口推送授权
+
+采用接口推送方式时,第三方需要访问标识与签名密钥。在对接向导的推送授权步骤发放即可,一次同时得到两者,并自动绑定到本租户。签名密钥只在发放成功时显示一次,关闭页面后无法再查看,须当场交付给第三方。
+
+同一套凭据可以授权给多个业务对象。再次发放时填写已有访问标识,系统只追加授权、不再生成新密钥。填入的访问标识必须属于本租户,属于其他租户的标识会被拒绝。
+
+| 步骤 | 位置 | 产出 |
+|------|------|------|
+| 1 | 对接向导的推送授权 | 访问标识、签名密钥(仅此一次),以及该业务对象的授权 |
+| 2 | 业务对象配置 | 确认该业务对象已允许推送 |
+
+推送的字段契约存在版本区分,部分业务对象在新版本契约中才包含完整字段。第三方推送时需在请求头中声明契约版本,未声明时按旧版本处理。具体字段清单与版本要求见《Ai-DOP 第三方对接业务数据详表(技术页)》。
+
+### 6.5 同步任务
+
+平台已内置各模块的同步任务,新租户默认继承这些任务,通常无需新建。需要确认的是本租户启用的业务对象已挂在对应任务下,且任务的调度配置符合预期。
+
+同步任务的实际执行依赖 ETL 执行机,未指派执行机时任务不会运行,详见 7.1。
+
+### 6.6 本阶段验证
+
+| 序号 | 验证项 | 判定标准 |
+|------|--------|----------|
+| 1 | 数据源连通 | 连接测试通过 |
+| 2 | 业务对象来源 | 本期对接的业务对象均已指定来源 |
+| 3 | 字段映射 | 映射关系完整,无未映射的必需字段 |
+| 4 | 推送凭据 | 采用推送方式的对象,访问标识与签名密钥均已配置且一致 |
+| 5 | 推送联调 | 第三方推送一笔测试数据,返回成功且可在贴源数据中查到 |
+| 6 | 同步任务 | 本期业务对象均已挂在同步任务下 |
+
+## 7. 启用运行与验证
+
+### 7.1 指派 ETL 执行机
+
+平台的定时数据处理作业由一台被指派的后端实例负责,其余实例不参与。这一指派是**人工动作**,后端实例启动后只会向平台报到,不会自行承担该职责。
+
+**未指派执行机时,全部定时数据处理作业都不会运行**。表现为数据源已配好、第三方也推了数,但贴源数据不更新、看板长期为空,且没有任何报错。这是新环境部署后最常见的问题。
+
+指派方式为:在数据服务层的运行监控页中查看已报到的实例列表,选择一台指派为执行机。平台保证同一时刻只有一台实例处于该状态,指派新实例时会自动取消原实例,无需重启服务。
+
+多实例部署时应选择一台资源充足且不承担高并发页面请求的实例。单实例部署时也必须执行一次指派,不能省略。
+
+需要注意的是,即使未指派执行机,用户在看板上手工点击的重算仍然可以由任意实例执行。因此"手工重算有数、但第二天自动不更新"这一现象,基本可以断定是执行机未指派。
+
+### 7.2 触发同步与模块重算
+
+首次接入数据后,不必等待定时作业,可直接触发一次模块重算,让数据立即走完从贴源到指标的完整链路。
+
+| 触发方式 | 适用场景 | 操作位置 |
+|----------|----------|----------|
+| 看板重算 | 租户自助刷新本模块 | 各模块看板页的重算按钮 |
+| 超管重算 | 实施人员为指定租户重算 | 数据服务层运行监控页 |
+| 定时作业 | 日常自动运行 | 无需操作,由执行机按计划执行 |
+
+模块重算按模块逐个排队执行,同一模块短时间内重复触发会被去重。重算任务的状态可在运行监控页查看,全部模块完成后再进行看板验证。
+
+指标数据按日重算,看板呈现的是截至上一自然日的业务结果。当日发生的业务需在次日的重算周期后才会体现。
+
+### 7.3 指标配置
+
+租户创建时已自动复制指标主数据、布局与指标计算配置,但下列两项**不随租户自动生成,需逐项配置**,否则相关指标取不到数或无法下钻。
+
+| 配置项 | 说明 | 维护位置 |
+|--------|------|----------|
+| 指标维度配置 | 指标的下钻维度定义 | S9 运营指标的维度配置 |
+| 指标目标值 | 各指标的考核目标 | S9 运营指标的目标值配置 |
+
+目标值未配置时,指标能取到数值但无法判定达成与否,看板上不会显示达成状态。维度配置缺失时,指标卡片可以显示总值但点击无法下钻。
+
+### 7.4 本阶段验证
+
+| 序号 | 验证项 | 判定标准 |
+|------|--------|----------|
+| 1 | 执行机 | 运行监控页显示有且仅有一台实例处于执行机状态 |
+| 2 | 贴源数据 | 本期对接的业务对象,贴源数据中可查到记录且业务日期为近期 |
+| 3 | 重算完成 | 各模块重算任务状态为成功 |
+| 4 | 九宫格看板 | 各模块格子显示具体数值,而非未取数标记 |
+| 5 | 模块详情看板 | 可正常打开并显示明细 |
+| 6 | 指标下钻 | 指标卡片可点击下钻 |
+| 7 | 达成状态 | 已配置目标值的指标显示达成状态 |
+| 8 | 次日自动更新 | 次日不做任何操作,看板数据自动推进一日 |
+
+第 8 项需隔日验证,是判断整套链路是否真正自动运行的唯一可靠依据,不可省略。
+
+## 8. 初始化脚本
+
+### 8.1 脚本覆盖范围
+
+随本文档提供初始化脚本 `init_new_tenant.py`,用于把第二阶段与第五阶段中可自动化的步骤串成一次执行,减少手工操作与遗漏。
+
+| 脚本步骤 | 对应章节 | 说明 |
+|----------|----------|------|
+| 超管登录 | — | 取得操作凭据 |
+| 创建租户 | 4.2 | 已存在同名租户时跳过,不重复创建 |
+| 创建租户库 | 4.1 | 仅库隔离模式执行 |
+| 菜单授权 | 4.4 | 按平台菜单重新授权本租户 |
+| 基线重跑 | 4.3 | 调用基线重跑接口补齐业务角色与指标计算配置,已有配置不覆盖 |
+| 指派执行机 | 7.1 | 列出已报到实例并指派,已有执行机时跳过 |
+| 模块重算 | 7.2 | 按模块入队并等待完成 |
+| 输出检查表 | 9.1 | 打印各步骤结果与待人工处理事项 |
+
+脚本为幂等设计,重复执行不会产生重复数据,中途失败后可直接重跑。
+
+### 8.2 运行环境与参数
+
+脚本运行需要 Python 3.10 及以上版本,以及用于登录加密的 `gmssl` 依赖。
+
+超管口令通过环境变量 `AIDOP_SUPER_PASSWORD` 传入,**不接受命令行参数形式,也不得写入脚本或任何配置文件**,以避免出现在命令历史与进程列表中。
+
+| 参数 | 必填 | 说明 |
+|------|------|------|
+| `--base` | 是 | 后端服务地址 |
+| `--super-account` | 是 | 超管账号 |
+| `--tenant-name` | 是 | 租户名称 |
+| `--tenant-admin` | 是 | 租管账号 |
+| `--isolation` | 否 | 隔离模式,取 `id` 或 `db`,默认 `id` |
+| `--db-connection` | 库隔离必填 | 独立库连接串 |
+| `--modules` | 否 | 需重算的模块,默认全部 |
+| `--assign-runner` | 否 | 指定要设为执行机的实例,留空表示自动选择 |
+| `--skip` | 否 | 跳过指定步骤,多个以逗号分隔 |
+| `--dry-run` | 否 | 只打印将要执行的动作,不实际调用 |
+
+### 8.3 使用方式
+
+建议首次执行时加 `--dry-run` 确认动作无误,再去掉该参数正式执行。执行完成后脚本会打印一张检查表,逐项标明已完成、已跳过与需人工处理。
+
+脚本退出码为 0 表示全部自动步骤成功,非 0 表示存在失败项,具体原因见输出。
+
+### 8.4 脚本不能代办的事项
+
+下列事项无法通过脚本完成,必须由人工在界面中操作,脚本会在检查表中列出提醒。
+
+| 事项 | 原因 | 对应章节 |
+|------|------|----------|
+| 环境部署与数据库初始化 | 由服务启动过程完成,脚本无法介入 | 第 3 章 |
+| 组织机构的公司与工厂节点 | 节点名称与层级需按客户实际确定 | 5.1 |
+| 运营建模主数据 | 属业务数据,需客户业务部门确认 | 5.2 至 5.5 |
+| 产线工作日历 | 需按客户实际排班确定 | 5.4 |
+| 数据源连接信息 | 含口令,不得由脚本携带 | 6.2 |
+| 开放接口身份与签名密钥 | 在对接向导发放,密钥只回显一次,脚本不携带 | 6.4 |
+| 指标维度配置与目标值 | 需按客户考核口径确定 | 7.3 |
+
+## 9. 初始化检查表与问题排查
+
+### 9.1 初始化检查表
+
+下表可直接用于交付前的逐项核对。
+
+| 序号 | 阶段 | 检查项 | 完成 |
+|------|------|--------|------|
+| 1 | 环境 | 后端服务正常启动,无异常终止 | □ |
+| 2 | 环境 | 数据库已建立,表结构完整 | □ |
+| 3 | 环境 | 迁移记录数与产物脚本数量一致 | □ |
+| 4 | 环境 | 站点可访问,登录正常 | □ |
+| 5 | 环境 | 多实例部署时,迁移执行实例唯一 | □ |
+| 6 | 环境 | 多实例部署时,缓存已切换为 Redis | □ |
+| 7 | 租户 | 租户已创建且状态启用 | □ |
+| 8 | 租户 | 库隔离模式下独立库已建立 | □ |
+| 9 | 租户 | 租管账号可登录 | □ |
+| 10 | 租户 | 业务模块菜单完整可见 | □ |
+| 11 | 租户 | 业务角色基线已生成 | □ |
+| 12 | 租户 | 指标计算配置非空 | □ |
+| 13 | 主数据 | 公司与工厂节点已建立 | □ |
+| 14 | 主数据 | 本期业务域主数据已录入 | □ |
+| 15 | 主数据 | 工艺路线已维护 | □ |
+| 16 | 主数据 | 生产线产能速率已填写 | □ |
+| 17 | 主数据 | 产线工作日历已标注工作日 | □ |
+| 18 | 主数据 | BOM 数据已就位 | □ |
+| 19 | 对接 | 数据源连接测试通过 | □ |
+| 20 | 对接 | 业务对象来源已逐项指定 | □ |
+| 21 | 对接 | 字段映射完整 | □ |
+| 22 | 对接 | 推送方式的访问标识与签名密钥已配置且一致 | □ |
+| 23 | 对接 | 推送联调通过,贴源可查到数据 | □ |
+| 24 | 运行 | ETL 执行机已指派且唯一 | □ |
+| 25 | 运行 | 各模块重算成功 | □ |
+| 26 | 运行 | 指标维度配置已维护 | □ |
+| 27 | 运行 | 指标目标值已维护 | □ |
+| 28 | 运行 | 九宫格看板显示具体数值 | □ |
+| 29 | 运行 | 指标可下钻 | □ |
+| 30 | 运行 | 次日数据自动推进一日 | □ |
+
+### 9.2 常见问题排查
+
+| 现象 | 优先排查 | 处理方式 |
+|------|----------|----------|
+| 看板全部为未取数标记 | 是否已指派 ETL 执行机 | 在运行监控页指派,见 7.1 |
+| 手工重算有数,次日不更新 | 执行机未指派或已失联 | 同上 |
+| 某模块看板为空,其余正常 | 该模块的业务对象来源未配置 | 补配来源,见 6.3 |
+| 第三方推送被拒 | 只拿到了访问标识,签名密钥未交付或已丢失 | 重新发放一套凭据,见 6.4 |
+| 推送成功但字段缺失 | 契约版本未声明 | 在请求头中声明契约版本 |
+| 主数据保存提示机构未配置 | 公司或工厂节点缺失 | 补建组织机构,见 5.1 |
+| 排程结果为空 | 工艺路线缺失 | 补维护工艺路线,见 5.4 |
+| 排程不按产能,日期均匀摊派 | 产能速率未填或工作日历无工作日 | 补填产能速率与工作日历,见 5.4 |
+| 标准 BOM 已维护但齐套提示缺料 | 齐套读取的 BOM 数据未就位 | 见 5.4 |
+| 指标有数值但无达成状态 | 目标值未配置 | 补配目标值,见 7.3 |
+| 指标卡片无法下钻 | 维度配置未维护 | 补配维度配置,见 7.3 |
+| 升级后租户看不到新功能菜单 | 未执行菜单同步 | 由超管执行菜单同步,见 4.4 |
+| 表结构停留在旧版本 | 迁移执行实例编号不为 1 | 调整实例编号后重启,见 3.4 |
+| 服务启动即失败 | 目标库不存在且未开启自动建库 | 手工建库或开启自动建库,见 3.3 |
+| 基础菜单或字典缺失 | 种子数据开关未开启 | 见 3.5 |
+| 手工改过的基线被改回默认 | 种子数据开关仍处于开启状态 | 完成初始化后关闭,见 3.5 |

+ 35 - 0
doc/plan/系统部署/README.md

@@ -0,0 +1,35 @@
+# 系统部署与初始化
+
+本目录存放**首次部署一套 Ai-DOP 环境、并接入新租户工厂**所需的指南与脚本。
+
+面向第三方业务系统的对接资料不在此处,见 [`../系统集成/README.md`](../系统集成/README.md)。
+
+## 文件
+
+| 文件 | 说明 |
+|------|------|
+| [`Ai-DOP系统初始化配置指南.md`](Ai-DOP系统初始化配置指南.md) | 正文。按环境部署、新增租户工厂、运营建模主数据、数据服务层对接、启用运行五个阶段编写,每阶段附验证项,末尾有 30 项初始化检查表与问题排查表 |
+| `Ai-DOP系统初始化配置指南.docx` | 上述 Markdown 生成的交付版 Word,**不要直接编辑**,改 Markdown 后重新生成 |
+| [`init_new_tenant.py`](init_new_tenant.py) | 新租户初始化脚本,覆盖指南第 4、7 章中可自动化的步骤 |
+| [`_gen_deploy_docx.py`](_gen_deploy_docx.py) | Markdown 转 Word,复用 `../系统集成/_gen_integration_guide_docx.py` 的排版逻辑 |
+
+## 生成 Word
+
+```bash
+cd doc/plan/系统部署
+python _gen_deploy_docx.py
+```
+
+Word 的目录是自动目录域,**首次打开需全选后按 F9 更新**才会出现条目与页码。
+
+## 脚本使用
+
+超管口令只从环境变量读,不接受命令行参数。先用 `--dry-run` 确认动作:
+
+```bash
+set AIDOP_SUPER_PASSWORD=...
+python init_new_tenant.py --base http://127.0.0.1:5005 --super-account superadmin \
+    --tenant-name 某某制造 --tenant-admin AdminMouMou --dry-run
+```
+
+脚本覆盖范围与其代办不了的事项见指南 §8。

+ 44 - 0
doc/plan/系统部署/_gen_deploy_docx.py

@@ -0,0 +1,44 @@
+# -*- coding: utf-8 -*-
+"""把系统初始化配置指南 Markdown 转成 Word。
+
+排版逻辑不在这里重复一遍,直接复用系统集成那套生成器:横向版式、黑体标题套内置
+Heading 样式(Word 导航窗格与自动目录才认得层级)、表格 Table Grid 加表头底色、
+一级标题前分页、「目录」一节换成 Word 自动目录域。
+"""
+
+from __future__ import annotations
+
+import importlib.util
+import sys
+from pathlib import Path
+
+BASE = Path(__file__).resolve().parent
+GENERATOR = BASE.parent / "系统集成" / "_gen_integration_guide_docx.py"
+
+JOBS = [
+    ("Ai-DOP系统初始化配置指南.md", "Ai-DOP系统初始化配置指南.docx", True, True),
+]
+
+
+def load_generator():
+    spec = importlib.util.spec_from_file_location("_aidop_docx_generator", GENERATOR)
+    if spec is None or spec.loader is None:
+        raise SystemExit(f"找不到排版生成器:{GENERATOR}")
+    module = importlib.util.module_from_spec(spec)
+    sys.modules[spec.name] = module
+    spec.loader.exec_module(module)
+    return module
+
+
+def main():
+    gen = load_generator()
+    for src_name, out_name, landscape, page_break_before_h1 in JOBS:
+        src = BASE / src_name
+        out = BASE / out_name
+        doc = gen.build_docx(src.read_text(encoding="utf-8"), landscape, page_break_before_h1)
+        doc.save(out)
+        print(f"OK: {out}")
+
+
+if __name__ == "__main__":
+    main()

+ 420 - 0
doc/plan/系统部署/init_new_tenant.py

@@ -0,0 +1,420 @@
+#!/usr/bin/env python3
+"""新租户工厂初始化:把《Ai-DOP 系统初始化配置指南》第 4、7 章中可自动化的步骤串成一次执行。
+
+覆盖:创建租户 → 建租户库(库隔离)→ 菜单授权 → 基线重跑 → 指派 ETL 执行机 → 模块重算。
+环境部署、组织机构、运营建模主数据、数据源连接、开放接口密钥、指标维度与目标值均不在范围内,
+原因见指南 §8.4;脚本会在收尾检查表里把这些列为待人工处理。
+
+口令只从环境变量 AIDOP_SUPER_PASSWORD 读,不接受命令行参数——命令行会进 shell 历史和进程列表。
+
+用法:
+    set AIDOP_SUPER_PASSWORD=...
+    python init_new_tenant.py --base http://127.0.0.1:5005 --super-account superadmin \
+        --tenant-name 某某制造 --tenant-admin AdminMouMou --dry-run
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import os
+import sys
+import time
+import urllib.error
+import urllib.request
+from typing import Any
+
+# 登录口令用 SM2 加密后提交,公钥与前端登录页内置的一致。
+SM2_PUBLIC_KEY = (
+    "84C7466D950E120E5ECE5DD85D0C90EAA85081A3A2BD7C57AE6DC822EFCCBD66"
+    "620C67B0103FC8DD280E36C3B282977B722AAEC3C56518EDCEBAFB72C5A05312"
+)
+
+ALL_MODULES = ("S1", "S2", "S3", "S4", "S5", "S6", "S7")
+TERMINAL_STATUS = {"SUCCESS", "FAILED", "CANCELLED"}
+ALL_STEPS = (
+    "tenant", "tenant-db", "menu", "baseline", "runner", "rebuild",
+)
+
+# 指南 §8.4:这些事项脚本代办不了,收尾时原样打出来。
+MANUAL_ITEMS = (
+    ("环境部署与数据库初始化", "由服务启动过程完成", "§3"),
+    ("组织机构的公司与工厂节点", "名称与层级需按客户实际确定", "§5.1"),
+    ("运营建模主数据", "属业务数据,需客户业务部门确认", "§5.2–§5.5"),
+    ("产线工作日历", "需按客户实际排班确定", "§5.4"),
+    ("数据源连接信息", "含口令,不得由脚本携带", "§6.2"),
+    ("开放接口身份与签名密钥", "在对接向导发放,密钥只回显一次,脚本不携带", "§6.4"),
+    ("指标维度配置与目标值", "需按客户考核口径确定", "§7.3"),
+)
+
+
+class ApiError(RuntimeError):
+    pass
+
+
+class Client:
+    def __init__(self, base: str, dry_run: bool):
+        self.base = base.rstrip("/")
+        self.dry_run = dry_run
+        self.token: str | None = None
+
+    def request(self, method: str, path: str, payload: Any = None,
+                timeout: int = 120) -> tuple[int, Any]:
+        if self.dry_run and method != "GET":
+            print(f"  [dry-run] {method} {path} {json.dumps(payload, ensure_ascii=False) if payload else ''}")
+            return 200, {"result": {"dryRun": True}}
+
+        headers = {"Accept": "application/json"}
+        data = None
+        if payload is not None:
+            headers["Content-Type"] = "application/json"
+            data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
+        if self.token:
+            headers["Authorization"] = "Bearer " + self.token
+        req = urllib.request.Request(self.base + path, data=data, headers=headers, method=method)
+        try:
+            with urllib.request.urlopen(req, timeout=timeout) as resp:
+                raw = resp.read()
+                return resp.status, json.loads(raw) if raw else {}
+        except urllib.error.HTTPError as exc:
+            raw = exc.read()
+            try:
+                body = json.loads(raw) if raw else {}
+            except json.JSONDecodeError:
+                body = {"raw": raw.decode("utf-8", errors="replace")}
+            return exc.code, body
+        except urllib.error.URLError as exc:
+            raise ApiError(f"无法连接 {self.base}:{exc.reason}") from exc
+
+    def call(self, method: str, path: str, payload: Any = None,
+             timeout: int = 120, ok: tuple[int, ...] = (200, 201, 202)) -> Any:
+        status, body = self.request(method, path, payload, timeout)
+        if status not in ok:
+            raise ApiError(f"{method} {path} → HTTP {status}:{json.dumps(body, ensure_ascii=False)}")
+        return unwrap(body)
+
+
+def unwrap(body: Any) -> Any:
+    if isinstance(body, dict) and "result" in body:
+        return body["result"]
+    return body
+
+
+def pick(obj: Any, *names: str, default: Any = None) -> Any:
+    """按多种大小写取字段——后端序列化风格在不同版本里出现过驼峰与帕斯卡两种。"""
+    if not isinstance(obj, dict):
+        return default
+    for name in names:
+        if obj.get(name) is not None:
+            return obj[name]
+    lowered = {str(k).lower(): v for k, v in obj.items()}
+    for name in names:
+        if lowered.get(name.lower()) is not None:
+            return lowered[name.lower()]
+    return default
+
+
+def encrypt_password(plain: str) -> str:
+    try:
+        from gmssl import sm2
+    except ImportError as exc:
+        raise ApiError("缺少依赖 gmssl,请先执行:pip install gmssl") from exc
+    return sm2.CryptSM2(public_key=SM2_PUBLIC_KEY, private_key=None, mode=1) \
+        .encrypt(plain.encode()).hex()
+
+
+class Report:
+    """收尾检查表。每一步只记三种结局:完成 / 跳过 / 需人工。"""
+
+    def __init__(self) -> None:
+        self.rows: list[tuple[str, str, str]] = []
+        self.failed = False
+
+    def done(self, step: str, detail: str = "") -> None:
+        self.rows.append(("完成", step, detail))
+
+    def skipped(self, step: str, detail: str = "") -> None:
+        self.rows.append(("跳过", step, detail))
+
+    def manual(self, step: str, detail: str = "") -> None:
+        self.rows.append(("需人工", step, detail))
+
+    def failure(self, step: str, detail: str = "") -> None:
+        self.rows.append(("失败", step, detail))
+        self.failed = True
+
+    def render(self) -> None:
+        print()
+        print("=" * 78)
+        print("初始化检查表")
+        print("=" * 78)
+        width = max((len(s) for _, s, _ in self.rows), default=10)
+        for state, step, detail in self.rows:
+            print(f"  [{state:<3}] {step:<{width}}  {detail}")
+        print()
+        print("以下事项脚本无法代办,须在界面中人工完成(指南 §8.4):")
+        for item, reason, ref in MANUAL_ITEMS:
+            print(f"  - {item}({reason},见指南 {ref})")
+
+
+def login_super(client: Client, account: str, password: str) -> None:
+    cipher = encrypt_password(password)
+    status, body = client.request("POST", "/api/sysAuth/login",
+                                  {"account": account, "password": cipher})
+    result = unwrap(body)
+    token = pick(result, "accessToken") if isinstance(result, dict) else None
+    if status != 200 or not token:
+        raise ApiError(f"超管登录失败:HTTP {status} {json.dumps(body, ensure_ascii=False)}")
+    client.token = token
+
+
+def find_tenant(client: Client, name: str) -> dict[str, Any] | None:
+    result = client.call("POST", "/api/sysTenant/page", {"page": 1, "pageSize": 200, "name": name})
+    items = pick(result, "items", "rows", default=[]) if isinstance(result, dict) else []
+    for item in items or []:
+        if str(pick(item, "name", default="")).strip() == name.strip():
+            return item
+    return None
+
+
+def step_tenant(client: Client, args, report: Report) -> dict[str, Any] | None:
+    print("[1/6] 创建租户")
+    existing = None if client.dry_run else find_tenant(client, args.tenant_name)
+    if existing:
+        tenant_id = pick(existing, "id")
+        report.skipped("创建租户", f"已存在,租户 Id {tenant_id}")
+        print(f"  已存在同名租户,跳过创建(Id {tenant_id})")
+        return existing
+
+    payload: dict[str, Any] = {
+        "name": args.tenant_name,
+        "adminAccount": args.tenant_admin,
+        "tenantType": 1 if args.isolation == "db" else 0,
+    }
+    if args.isolation == "db":
+        payload["connection"] = args.db_connection
+    client.call("POST", "/api/sysTenant/add", payload)
+    report.done("创建租户", args.tenant_name)
+    print(f"  已创建:{args.tenant_name} / 租管 {args.tenant_admin}")
+
+    if client.dry_run:
+        return None
+    created = find_tenant(client, args.tenant_name)
+    if not created:
+        raise ApiError("租户创建后未能查回,请在租户管理中确认")
+    return created
+
+
+def step_tenant_db(client: Client, args, tenant: dict[str, Any] | None, report: Report) -> None:
+    print("[2/6] 创建租户库")
+    if args.isolation != "db":
+        report.skipped("创建租户库", "标识隔离模式无需建库")
+        print("  标识隔离模式,跳过")
+        return
+    tenant_id = pick(tenant or {}, "id")
+    if not tenant_id and not client.dry_run:
+        report.failure("创建租户库", "未取得租户 Id")
+        return
+    client.call("POST", "/api/sysTenant/createDb", {"id": tenant_id})
+    report.done("创建租户库", f"租户 Id {tenant_id}")
+    print("  已建库并初始化业务表结构")
+
+
+def step_menu(client: Client, tenant: dict[str, Any] | None, report: Report) -> None:
+    print("[3/6] 菜单授权")
+    tenant_id = pick(tenant or {}, "id")
+    if not tenant_id and not client.dry_run:
+        report.failure("菜单授权", "未取得租户 Id")
+        return
+    # syncGrantMenu 按平台菜单种子重新对齐本租户,比手工挑 MenuId 稳妥,
+    # 且与版本升级后补发新菜单是同一个入口。
+    status, body = client.request("POST", "/api/sysTenant/syncGrantMenu", {"id": tenant_id})
+    if status in (200, 201, 202):
+        report.done("菜单授权", "dry-run" if client.dry_run else f"租户 Id {tenant_id}")
+        print("  已按平台菜单同步授权")
+    else:
+        report.failure("菜单授权", f"HTTP {status} {json.dumps(body, ensure_ascii=False)}")
+        print(f"  失败:HTTP {status}")
+
+
+def step_baseline(client: Client, tenant: dict[str, Any] | None, report: Report) -> None:
+    """重跑租户基线。接口幂等,已有角色与指标配置不会被覆盖。"""
+    print("[4/6] 基线重跑")
+    tenant_id = pick(tenant or {}, "id")
+    if not tenant_id and not client.dry_run:
+        report.failure("基线重跑", "未取得租户 Id")
+        return
+    result = client.call("POST", "/api/aidop/tenant-baseline/reinit", {"tenantId": int(tenant_id or 0)})
+    if client.dry_run:
+        report.skipped("基线重跑", "dry-run")
+        return
+    detail = " ".join(
+        f"{name}={pick(result, name, default=0)}"
+        for name in ("s8Roles", "approvalRoles", "scenes", "kpiMasters", "kpiCalcConfigs")
+    )
+    report.done("基线重跑", detail)
+    print(f"  已重跑:{detail}")
+
+
+def step_runner(client: Client, args, report: Report) -> None:
+    print("[5/6] 指派 ETL 执行机")
+    if client.dry_run:
+        report.skipped("指派执行机", "dry-run")
+        print("  跳过")
+        return
+
+    result = client.call("GET", "/api/DataPlatform/mdp-monitor/etl-runner")
+    instances = pick(result, "instances", default=[]) if isinstance(result, dict) else []
+    if not instances:
+        report.failure("指派执行机", "没有实例向平台报到,请确认后端服务已启动")
+        print("  未发现任何已报到的实例")
+        return
+
+    current = [x for x in instances if pick(x, "isRunner", default=False)]
+    if current and not args.assign_runner:
+        who = pick(current[0], "instanceId", default="")
+        report.skipped("指派执行机", f"已有执行机 {who}")
+        print(f"  已有执行机 {who},跳过")
+        return
+
+    target = args.assign_runner
+    if not target:
+        target = pick(instances[0], "instanceId", default="")
+        print(f"  未指定实例,自动选择第一台已报到实例:{target}")
+    client.call("POST", "/api/DataPlatform/mdp-monitor/admin/assign-runner", {"instanceId": target})
+    report.done("指派执行机", target)
+    print(f"  已指派 {target}")
+
+
+def step_rebuild(client: Client, args, tenant: dict[str, Any] | None, report: Report) -> None:
+    print("[6/6] 模块重算")
+    tenant_id = pick(tenant or {}, "id")
+    factory_id = pick(tenant or {}, "orgId", default=0)
+    if client.dry_run or not tenant_id:
+        report.skipped("模块重算", "dry-run 或未取得租户 Id")
+        print("  跳过")
+        return
+
+    queued: list[tuple[str, int]] = []
+    for module in args.modules:
+        status, body = client.request(
+            "POST", "/api/DataPlatform/mdp-monitor/admin/rebuild",
+            {"moduleCode": module, "tenantId": int(tenant_id), "factoryId": int(factory_id or 0)},
+        )
+        if status not in (200, 201, 202):
+            report.failure(f"重算 {module}", f"HTTP {status}")
+            continue
+        inner = unwrap(body)
+        job_id = int(pick(unwrap(pick(inner, "body", default={})), "jobId", default=0) or 0)
+        queued.append((module, job_id))
+        print(f"  {module} 已入队(任务 {job_id or '去重'})")
+
+    if not queued:
+        return
+    print(f"  等待重算完成,最长 {args.timeout_seconds} 秒…")
+    deadline = time.monotonic() + args.timeout_seconds
+    pending = list(queued)
+    while pending and time.monotonic() < deadline:
+        still: list[tuple[str, int]] = []
+        for module, job_id in pending:
+            if not job_id:
+                report.done(f"重算 {module}", "已在队列中,未重复入队")
+                continue
+            result = client.call(
+                "GET", f"/api/AidopKanban/{module}/rebuild-jobs/{job_id}?factoryId={factory_id}")
+            state = str(pick(result, "status", default="")).upper()
+            if state in TERMINAL_STATUS:
+                if state == "SUCCESS":
+                    report.done(f"重算 {module}", "成功")
+                else:
+                    report.failure(f"重算 {module}", f"{state} {pick(result, 'errorMessage', default='')}")
+            else:
+                still.append((module, job_id))
+        pending = still
+        if pending:
+            time.sleep(5)
+    for module, job_id in pending:
+        report.failure(f"重算 {module}", f"等待超时(任务 {job_id})")
+
+
+def parse_args(argv: list[str]):
+    parser = argparse.ArgumentParser(
+        description="新租户工厂初始化(配套《Ai-DOP 系统初始化配置指南》)")
+    parser.add_argument("--base", required=True, help="后端服务地址,例如 http://127.0.0.1:5005")
+    parser.add_argument("--super-account", required=True, help="超管账号")
+    parser.add_argument("--tenant-name", required=True, help="租户名称")
+    parser.add_argument("--tenant-admin", required=True, help="租管账号")
+    parser.add_argument("--isolation", choices=("id", "db"), default="id", help="隔离模式,默认 id")
+    parser.add_argument("--db-connection", default="", help="库隔离模式下的独立库连接串")
+    parser.add_argument("--modules", default=",".join(ALL_MODULES), help="需重算的模块")
+    parser.add_argument("--assign-runner", default="", help="指定作为执行机的实例,留空自动选择")
+    parser.add_argument("--skip", default="", help=f"跳过的步骤,可选 {','.join(ALL_STEPS)}")
+    parser.add_argument("--timeout-seconds", type=int, default=1800, help="等待重算的最长秒数")
+    parser.add_argument("--dry-run", action="store_true", help="只打印将要执行的动作")
+    args = parser.parse_args(argv)
+
+    args.modules = tuple(m.strip().upper() for m in args.modules.split(",") if m.strip())
+    invalid = sorted(set(args.modules) - set(ALL_MODULES))
+    if invalid:
+        parser.error(f"不支持的模块:{','.join(invalid)}")
+    args.skip = {s.strip().lower() for s in args.skip.split(",") if s.strip()}
+    invalid_steps = sorted(args.skip - set(ALL_STEPS))
+    if invalid_steps:
+        parser.error(f"不支持的步骤:{','.join(invalid_steps)}")
+    if args.isolation == "db" and not args.db_connection and not args.dry_run:
+        parser.error("库隔离模式必须提供 --db-connection")
+    return args
+
+
+def main(argv: list[str]) -> int:
+    args = parse_args(argv)
+
+    password = os.environ.get("AIDOP_SUPER_PASSWORD", "")
+    if not password and not args.dry_run:
+        print("未设置环境变量 AIDOP_SUPER_PASSWORD,无法登录。", file=sys.stderr)
+        return 2
+
+    client = Client(args.base, args.dry_run)
+    report = Report()
+
+    print(f"目标环境:{client.base}")
+    if args.dry_run:
+        print("模式:dry-run,只打印动作,不实际调用")
+    print()
+
+    try:
+        if not args.dry_run:
+            login_super(client, args.super_account, password)
+            print(f"已以 {args.super_account} 登录")
+            print()
+
+        tenant = None
+        if "tenant" in args.skip:
+            report.skipped("创建租户", "按 --skip 跳过")
+            tenant = None if args.dry_run else find_tenant(client, args.tenant_name)
+        else:
+            tenant = step_tenant(client, args, report)
+
+        for name, fn in (
+            ("tenant-db", lambda: step_tenant_db(client, args, tenant, report)),
+            ("menu", lambda: step_menu(client, tenant, report)),
+            ("baseline", lambda: step_baseline(client, tenant, report)),
+            ("runner", lambda: step_runner(client, args, report)),
+            ("rebuild", lambda: step_rebuild(client, args, tenant, report)),
+        ):
+            if name in args.skip:
+                report.skipped(name, "按 --skip 跳过")
+                continue
+            fn()
+    except ApiError as exc:
+        print(f"\n执行中断:{exc}", file=sys.stderr)
+        report.failure("执行", str(exc))
+        report.render()
+        return 1
+
+    report.render()
+    return 1 if report.failed else 0
+
+
+if __name__ == "__main__":
+    raise SystemExit(main(sys.argv[1:]))

BIN
doc/plan/系统集成/Ai-DOP第三方对接业务数据详表-业务页.docx


+ 226 - 547
doc/plan/系统集成/Ai-DOP第三方对接业务数据详表-业务页.md

@@ -1,599 +1,278 @@
-# Ai-DOP 第三方对接功能与数据详表(业务页)
+# Ai-DOP 第三方对接业务数据详表(业务页)
 
-| 项 | 内容 |
-|----|------|
-| 读者 | 业务负责人,以及计划 / 采购 / 仓储 / 质量 / 生产各环节的对接窗口 |
-| 配套 | 同目录另有技术页与系统集成指南,供技术同事使用 |
-| 目的 | 讲清楚:**每个功能要贵方提供哪些业务数据、用哪种方式提供、这些数据归在哪个功能名下、不提供会怎样** |
-| 日期 | 2026-09-27 |
-
-本页全程使用业务语言,不出现表名、栏位名与接口编码。
-
----
-
-## 1. 一页看懂
-
-Ai-DOP 不直接连贵方系统出报表。所有对外数据先进入我方数据中台,由中台统一换算成指标,各功能再读指标。这样做的原因是:贵方系统的改动不会影响看板,我方也不会在贵方生产库上跑查询。
-
-由此带来两个结果,请业务先建立预期:
-
-**一,功能是分层的。** 指标算出来了,九宫格与 ChatBI 就能用;要下钻到具体单据,还得把单据明细也送进来。
-
-**二,看板是 T+1 的。** 多数指标按天重算,当天的业务当天不一定看得到。
-
-系统共五类功能,本页第 6 至第 10 章逐个说明:
-
-| 功能 | 业务上看到什么 | 对贵方数据的要求 |
-|---|---|---|
-| **九宫格看板** | 每个业务模块几张核心指标(周期、满足率、人效、周转) | 必须有**能算出指标的日期与数量** |
-| **模块详情看板** | 趋势、分布、明细列表、按条件筛选 | 在九宫格基础上加**单号、物料、客户或供应商、状态** |
-| **智慧诊断** | 指标逐层下钻,定位问题出在哪一环 | 指标同看板;要点开「业务依据」还需**具体单据行** |
-| **异常监控** | 逾期、缺料等异常自动列出并派人处理 | **不需要贵方送异常单**,由我方规则读已入站数据自动生成 |
-| **ChatBI** | 用日常语言提问指标 | **不另要一套数**,指标算出来就能问 |
-
-### 1.1 一个会影响验收的表现
-
-**指标没有数据时,格子显示为「—」,同时状态标记为黄色「风险」。** 也就是说**没接数的格子,和业务真的有风险的格子,看起来是一样的**。
-
-区分方法:数值为「—」即未取到数据,属对接范围问题;数值有数字而标黄,才是业务指标未达目标。首次验收时请按此判读,不要把大面积黄色误认为业务告警。
-
----
-
-## 2. 三种对接方式
-
-同一笔业务数据,三种方式要的**业务内容相同**,区别只是谁主动、走什么通道。一个数据包只需选一种,不同数据包可以各选各的。
-
-| 方式 | 谁主动 | 适用情形 |
-|---|---|---|
-| **甲 · 数据库同步** | 我方定时从贵方**只读库**取数 | 贵方能开只读账号。**覆盖全部模块,是目前最完整的通道** |
-| **丙 · 标准接口推送** | 贵方按我方标准接口**主动推送** | 贵方不便开库,或希望即时送数。按业务对象逐个开通 |
-| **乙 · 接口拉取** | 我方定时调用贵方**查询接口**取数 | 贵方只提供查询接口,需按对象逐一约定 |
-
-### 2.1 方式丙当前已开通的业务对象
-
-推送按对象开通。**已开通**=当前即可联调;**未开通**=我方尚未登记该对象并签发授权,此时推送会被拒绝,请在排期前提出。
-
-| 归属 | 已开通的业务对象 |
-|---|---|
-| 前置主数据 | 物料、雇员与在岗人数 |
-| 产销协同 | 销售订单行 |
-| 制造协同 | 生产工单行、排程工单 |
-| 供应协同 | 采购订单 |
-| 采购执行 | 采购收货、供应商发货、来料检验、采购退货、缺料 |
-| 来料仓储 | 出入库流水、库存期初、月度库存金额、工单用料 |
-| 制造执行 | 车间报工 |
-| 成品仓储 | 成品结存、成品期初、成品检验、销售订单行(成品仓储口径) |
-
-**已有标准契约但尚未开通**:客户、供应商、库位、货源清单、齐套核验结果。需要走推送的请提出,由我方登记后开通。
-
-**目前只能走方式甲或乙的业务数据**(没有推送对象):销售订单头、合同评审环节、发货计划与发货单、工序级日计划与排产结果、物料需求计划、采购申请、交货计划、现存量快照、过程检验、成品入库。
-
-### 2.2 出入库流水可以走推送
-
-周转类与仓储制造类指标依赖**出入库业务类型**(要能区分采购入库、生产领料、生产入库、销售出库、销售退货)与**审核状态**(只算已审核通过的单据)。
-
-这两项**三种方式都能提供**:走方式甲随单据一并读取;走方式丙时,我方已备好带业务类型的标准推送约定,其中业务类型为**必填**、审核状态与审核时间为可选,贵方按该约定推送即可,**不需要我方另行扩展**。
-
-需要留意的是,推送流水另有一套早期约定不含业务类型,按早期约定推送则上述五张格子仍然算不出。选用哪一套请在联调前与我方技术窗口确认。
-
-因此依赖业务类型的五张格子——物料库存周转、物料上线、订单制造、订单发货、质量退货率——**走甲或走丙都可以交付**。真正的前提是贵方系统里这两项本身存在且可取,而不是走了哪种方式。
-
-### 2.3 按情况选方式
-
-**情况 A:贵方能提供只读数据库账号。** 首选方式甲。全部模块都能覆盖,我方按约定周期抽取,贵方无需开发。需要贵方提供表结构说明,以便我方建立字段对应关系。
-
-**情况 B:贵方不能开库,但能按我方标准推送。** 走方式丙。§2.1 已开通的对象现在就能联调;未开通的对象请在排期前提出。若本次范围包含只能走甲/乙的数据(见 §2.1 末段),这部分仍需另行安排通道。
-
-**情况 C:贵方只能提供查询接口。** 走方式乙。本页第 3 章总表列出的「所需业务数据」即我方要从贵方接口取得的内容,接口地址、分页方式与返回结构另行逐对象约定。
-
-**情况 D:同一数据包在贵方分散于多套系统。** 按系统拆分别对接,我方在中台按物料编码、单号等业务键合并,不需要贵方先合。请在签收表上注明每个数据包由哪套系统出。
-
----
-
-## 3. 总表:模块 · 功能 · 所需业务数据 · 对接方式
-
-本章一行一个功能,是全页的索引。**「数据归属」列标明这笔数据挂在哪个功能名下**——凡不是「本模块」的,都意味着该格子要靠别的模块先接进来,跨功能依赖详见第 4 章。
-
-### 3.1 前置(不产生格子,但被多处依赖)
-
-| 模块 | 功能 | 所需业务数据 | 对接方式 | 数据归属 |
-|---|---|---|---|---|
-| 前置 | 物料主数据 | 物料编码、名称、单位、规格型号、物料类型、是否启用 | 甲 / 丙(已开通)/ 乙 | 本项 |
-| 前置 | 客户主数据 | 客户编码、名称、是否启用 | 甲 / 丙(未开通)/ 乙 | 本项 |
-| 前置 | 供应商主数据 | 供应商编码、名称、是否启用 | 甲 / 丙(未开通)/ 乙 | 本项;**同时是物料采购人效的分母** |
-| 前置 | 货源清单 | 物料、供应商、供货比例、采购周期 | 甲 / 丙(未开通)/ 乙 | 本项 |
-| 前置 | 库位 | 库位编码 | 甲 / 丙(未开通)/ 乙 | 本项 |
-| 前置 | 员工与在岗人数 | 在职人员名单、**岗位分类**(计划 / 仓管 / 生产)、在职状态 | 甲 / 丙(已开通)/ 乙 | 本项;**四张人效格子的分母** |
-
-### 3.2 九宫格看板
-
-| 模块 | 功能(格子) | 所需业务数据 | 对接方式 | 数据归属 |
-|---|---|---|---|---|
-| 产销协同 | 订单评审周期 | 订单头的签订日期;订单行的承诺交货日期(缺则依次取产能建议交期、齐套日、计划交货日期) | 甲 / 丙(订单行已开通,**订单头无推送对象**)/ 乙 | 本模块 |
-| 产销协同 | 订单评审满足率 | 同上 | 同上 | 本模块 |
-| 产销协同 | 主计划人效 | 订单行数;**订单行上的计划员** | 甲 / 丙(已开通)/ 乙 | 本模块。**与员工人数表无关** |
-| 产销协同 | 成品库存周转 | 订单行未发数量;累计发货数量 | 甲 / 乙 | 发货记录归**成品仓储 · 销售出库** |
-| 制造协同 | 订单排程周期 | 工单下达日期(缺则取开单日期);工序计划日期 | 甲 / 丙(工单已开通,**工序级日计划无推送对象**)/ 乙 | 本模块 |
-| 制造协同 | 订单排程满足率 | 工序计划日期;工单需求完工日 | 同上 | 本模块 |
-| 制造协同 | 订单排程人效 | 工单数;**日计划或排产结果上的作业人数** | 甲 / 乙 | 本模块。**与员工人数表无关** |
-| 制造协同 | 在制库存周转 | 在制数量;完工数量 | 甲 / 丙(已开通)/ 乙 | 本模块 |
-| 供应协同 | 物料计划周期 | 交货计划的需求日期与提交日期 | 甲 / 乙(**交货计划无推送对象**) | 本模块 |
-| 供应协同 | 物料计划满足率 | 交货计划数量;采购订单订货数量 | 甲 / 丙(采购订单已开通)/ 乙 | 本模块 |
-| 供应协同 | 物料计划人效 | 计划岗在职人数 | 甲 / 丙(已开通)/ 乙 | **前置 · 员工与在岗人数** |
-| 供应协同 | 物料库存周转 | 采购入库量、结存量、生产领料量(取上一完整月) | 甲 / 丙(已开通) | **来料仓储 · 出入库流水与库存期初** |
-| 采购执行 | 物料交货周期 | 实际收货日期(正式收货与来料检验取最晚一次);**交货计划的需求日期** | 甲 / 丙(收货与检验已开通)/ 乙 | 起点归**供应协同 · 交货计划** |
-| 采购执行 | 物料交货满足率 | 收货数量;订货数量或完成状态 | 同上 | 订货数量归**供应协同 · 采购订单** |
-| 采购执行 | 采购在途周转 | 未收数量;订货数量 | 同上 | 同上 |
-| 采购执行 | 物料采购人效 | **供应商数量**(不是采购人数) | 甲 / 乙 | **前置 · 供应商主数据** |
-| 来料仓储 | 物料上线周期 | 采购入库日期、生产领料日期,各取最早一次;含**业务类型**与**审核状态** | 甲 / 丙(已开通) | 本模块 |
-| 来料仓储 | 物料上线满足率 | 已领到的物料种类数;**工单用料种类数**;**工单开工日** | 甲 / 丙(工单用料已开通)/ 乙 | 用料与开工日归**制造执行 · 生产任务与用料** |
-| 来料仓储 | 物料仓储人效 | 仓管岗在职人数 | 甲 / 丙(已开通)/ 乙 | **前置 · 员工与在岗人数** |
-| 来料仓储 | 品类物料库存周转 | 月均库存金额;月出库成本(取上一完整月) | 甲 / 丙(已开通) | 本模块。**全系统唯一按成本计算的周转** |
-| 制造执行 | 订单制造周期 | 工单最早投产、最晚完工(**仅统计已完工订单**) | 甲 / 丙(报工与工单行已开通)/ 乙 | 本模块 |
-| 制造执行 | 订单制造满足率 | 计划完工日;生产入库数量与入库日期 | 甲 / 丙(已开通) | 入库流水归**来料仓储 · 出入库流水**(同一套流水) |
-| 制造执行 | 订单制造人效 | 生产岗在职人数 | 甲 / 丙(已开通)/ 乙 | **前置 · 员工与在岗人数** |
-| 成品仓储 | 订单发货周期 | 成品报检日期(缺则取建单日期);实际出库日期(**仅统计已发完订单**) | 甲 / 丙(已开通)/ 乙 | 本模块 |
-| 成品仓储 | 订单发货满足率 | 出库数量与出库日期;**订单的计划交货日** | 甲 / 丙(已开通)/ 乙 | 计划交货日归**产销协同 · 销售订单行** |
-| 成品仓储 | 成品仓储人效 | 仓管岗在职人数 | 甲 / 丙(已开通)/ 乙 | **前置 · 员工与在岗人数**,与物料仓储同一分母 |
-| 异常监控 | 各类异常事件数量 | 不需贵方送异常单 | — | 读三条规则各自依赖的数据,见第 4 章与第 9 章 |
-| 异常监控 | 各类异常事件处理时效 | 同上 | — | 同上 |
-| 运营指标 | 质量退货率 | 质量退货数量与退货日期;对应出库数量;含业务类型与审核状态 | 甲 / 丙(已开通) | 归**来料仓储 · 出入库流水**(同一套流水) |
-
-**九宫格另有四张跨模块格子**:订单交付周期、订单交付满足率、生产效率、全库存周转。它们由平台根据已接入的订单、发货、报工与库存汇总,**不需要单独再交一套格子数据**;上述任一类缺失时,对应格子显示无数据而不是 0。
-
-### 3.3 其余四类功能
-
-| 功能 | 所需业务数据 | 对接方式 | 数据归属 |
-|---|---|---|---|
-| 模块详情看板 | 与该模块九宫格同源,另需单号、物料名称、客户或供应商名称、状态、负责人等可读字段,以及**单据行级明细**(筛选靠明细逐条算) | 随各模块 | 各模块本身 |
-| 智慧诊断 · 指标树 | 与九宫格完全同源,不需额外数据 | — | 各模块本身 |
-| 智慧诊断 · 点开业务依据 | **单据行级明细**,不能只给汇总数;且该指标须已登记可追溯范围(见 §8.2) | 随各模块 | 各模块本身 |
-| 异常监控 | 见 §3.2 两行与第 9 章 | — | 跨模块,见第 4 章 |
-| ChatBI | 不另要数据,读的就是九宫格与详情看板那套指标 | — | 各模块本身 |
-
----
-
-## 4. 跨功能数据依赖
-
-以下格子**要靠别的功能先把数据接进来**。分批上线时若漏了依赖方,这些格子即使本模块数据齐了也算不出。
-
-| 需要数据的功能 | 依赖什么数据 | 数据归在哪个功能 |
-|---|---|---|
-| 产销协同 · 成品库存周转 | 销售发货记录 | 成品仓储 · 销售出库 |
-| 制造协同 · 全部四格 | 销售订单号(串联订单到工单) | 产销协同 · 销售订单 |
-| 供应协同 · 物料计划人效 | 计划岗在职人数 | 前置 · 员工与在岗人数 |
-| 供应协同 · 物料库存周转 | 出入库流水与库存期初 | 来料仓储 · 出入库流水 |
-| 采购执行 · 物料交货周期 | 交货计划的需求日期(周期起点) | 供应协同 · 交货计划 |
-| 采购执行 · 物料交货满足率、采购在途周转 | 采购订单的订货数量 | 供应协同 · 采购订单 |
-| 采购执行 · 物料采购人效 | 供应商清单(按**供应商数量**算,不是采购人数) | 前置 · 供应商主数据 |
-| 来料仓储 · 物料上线满足率 | 工单用料种类、工单开工日 | 制造执行 · 生产任务与用料 |
-| 来料仓储 · 物料仓储人效 | 仓管岗在职人数 | 前置 · 员工与在岗人数 |
-| 制造执行 · 订单制造满足率 | 生产入库流水 | 来料仓储 · 出入库流水(同一套流水) |
-| 制造执行 · 订单制造人效 | 生产岗在职人数 | 前置 · 员工与在岗人数 |
-| 成品仓储 · 订单发货满足率 | 订单的计划交货日 | 产销协同 · 销售订单行 |
-| 成品仓储 · 成品仓储人效 | 仓管岗在职人数 | 前置 · 员工与在岗人数 |
-| 运营指标 · 质量退货率 | 质量退货流水与出库数量 | 来料仓储 · 出入库流水(同一套流水) |
-| 异常监控 · 订单交付延期预警 | 销售订单与评审产出的交期确认 | 产销协同 |
-| 异常监控 · 制造工单缺料 | **物料齐套检查数据** | **产销协同链路,不是制造模块** |
-| 异常监控 · 采购交期延误 | **交货计划与采购订单** | **供应协同,不是采购执行** |
-| 九宫格跨模块四格 | 订单、发货、报工、库存 | 各自模块,由平台汇总 |
-
-**两条监控规则的数据来源与它们的名字不一致**,是最常被误判的两处:「制造工单缺料」靠的是产销协同的齐套检查数据;「采购交期延误」靠的是供应协同的交货计划。**只提供采购执行模块的收货数据,不足以让「采购交期延误」工作。**
-
-**两张人效格子不用人数表**:主计划人效的分母是订单行上的计划员,订单排程人效的分母是日计划上的作业人数。提供再完整的岗位人数表也点不亮这两张格子。
-
----
-
-## 5. 前置:主数据与人效分母
-
-### 5.1 主数据
-
-主数据本身不产生九宫格指标,但订单、工单、收货要对上物料名、客户名、供应商名。不接的话,看板与明细里只能看到编码。
-
-| 业务数据包 | 必须提供 | 建议再提供 | 可用方式 |
-|---|---|---|---|
-| **物料** | 物料编码、物料名称、单位、规格型号、物料类型、是否启用 | 图号、默认库位 | 甲 / 丙(已开通)/ 乙 |
-| **客户** | 客户编码、客户名称、是否启用 | 简称、国家、业务员 | 甲 / 丙(未开通)/ 乙 |
-| **供应商** | 供应商编码、供应商名称、是否启用 | 联系人、地址、币种、类型 | 甲 / 丙(未开通)/ 乙 |
-| **货源清单**(某物料向谁采购) | 物料编码、供应商编码、供货比例、采购周期 | 最小订量、包装量、价格、生效失效日 | 甲 / 丙(未开通)/ 乙 |
-| **库位** | 库位编码 | 库位名称、所属仓库 | 甲 / 丙(未开通)/ 乙 |
-| **员工与在岗人数** | 在职人员名单、**岗位分类**、在职状态 | 工号、所属部门 | 甲 / 丙(已开通)/ 乙 |
-
-物料主数据走方式丙推送时,会同时回灌我方运营建模页面;页面上手工建立的同编码记录不会被覆盖。
-
-### 5.2 人效指标的分母不是同一个来源
-
-人效共六张格子,**分母各不相同**。只提供一张岗位人数表,并不能把六张格子都算出来。
+| 项目 | 内容 |
+|------|------|
+| 文档名称 | Ai-DOP 第三方对接业务数据详表(业务页) |
+| 文档版本 | V2.0 |
+| 发布日期 | 2026-09-25 |
+| 适用读者 | 业务负责人,计划、采购、仓储、质量、生产各环节的对接窗口 |
 
-| 人效格子 | 分母来自哪里 | 对贵方的要求 |
-|---|---|---|
-| 主计划人效(产销协同) | **销售订单行上的计划员**去重计数 | 订单行必须带计划员,见 §6.1。与人数表无关 |
-| 订单排程人效(制造协同) | **排产或日计划上的作业人数** | 日计划要带作业人员数或班组人数,见 §6.2 |
-| 物料计划人效(供应协同) | 在职人员中岗位为**计划**的人数 | 员工名单带岗位分类与在职状态 |
-| 物料仓储人效(来料仓储) | 在职人员中岗位为**仓管**的人数 | 同上 |
-| 订单制造人效(制造执行) | 在职人员中岗位为**生产**的人数 | 同上 |
-| 成品仓储人效(成品仓储) | 在职人员中岗位为**仓管**的人数(与来料仓储同一口径) | 同上 |
+## 修订记录
 
-两点请业务先知晓,避免验收时对不上:
+| 版本 | 日期 | 修订内容 |
+|------|------|----------|
+| V1.0 | 2026-09-04 | 初版发布,按看板、诊断与 ChatBI 分列业务数据需求 |
+| V1.1 | 2026-09-11 | 增补对接方式对照说明 |
+| V1.2 | 2026-09-22 | 按现行实现校正评审周期口径、人效分母与周转口径 |
+| V2.0 | 2026-09-25 | 统一采用系统模块编号;合并为业务数据需求总表,新增依赖项与缺失影响列;新增业务确认表 |
 
-- **没有「主计划 / 生产计划 / 采购 / 成品仓」四个独立岗位口径。** 员工名单只需区分「计划 / 仓管 / 生产」三类;来料仓储与成品仓储共用仓管人数,两张格子的分母相同。
-- **「物料采购人效」按供应商数量计算,不是按采购人数。** 提供采购岗人数不会改变这张格子。
+## 目录
 
-缺人数时的表现:物料仓储、订单制造、成品仓储三张人效格子**留空**;主计划人效与订单排程人效会**按 1 人计算,数值明显偏大**,请不要当真实人效使用。
+| 章节 | 标题 |
+|------|------|
+| 第 1 章 | 引言 |
+| 1.1 | 编写目的与适用读者 |
+| 1.2 | 名词与约定 |
+| 第 2 章 | 对接总体说明 |
+| 2.1 | 数据流转原则与更新时效 |
+| 2.2 | 功能层次与数据要求 |
+| 2.3 | 指标取数状态的展示规则 |
+| 第 3 章 | 对接方式 |
+| 3.1 | 三种方式与适用性对照 |
+| 3.2 | 出入库流水专项约定 |
+| 3.3 | 常见情形与建议方案 |
+| 第 4 章 | 业务数据需求总表 |
+| 4.1 | 本表阅读说明 |
+| 4.2 | 前置基础数据 |
+| 4.3 | 各模块看板指标数据 |
+| 4.4 | 异常监控与其他功能 |
+| 第 5 章 | 业务确认表 |
+| 5.1 | 前置基础数据 |
+| 5.2 | 业务单据数据 |
+| 5.3 | 库存与出入库数据 |
 
 ---
 
-## 6. 功能一:九宫格看板
-
-九宫格是首页九个模块入口,每个模块显示三到四张核心指标。以下逐模块列出口径、需要的业务数据与缺数表现;总表见第 3 章。
-
-### 6.1 产销协同
-
-| 格子 | 业务口径 |
-|---|---|
-| 订单评审周期(天) | 从**订单签订日期**到**承诺交货日期**的平均天数。行上没有承诺交货日期时,依次取产能建议交期、齐套日、计划交货日期 |
-| 订单评审满足率(%) | 上述时长在 **72 小时**以内的订单行占比 |
-| 主计划人效(行/人) | 订单行数 ÷ 订单行上计划员去重人数 |
-| 成品库存周转(天) | 平均未发数量 ÷ 累计发货数量 × 30,**按数量计算**,非财务成本口径 |
-
-**销售订单**(必接,方式甲 / 丙 / 乙)
-
-| 数据项 | 必接 / 建议 | 说明 |
-|---|---|---|
-| 销售订单号、订单行号 | 必接 | 人效按「行」计算 |
-| 订单签订日期(在订单头上) | 必接 | 评审周期的起点 |
-| 承诺交货日期(在订单行上) | 必接 | 评审周期的终点,首选 |
-| 产能建议交期、齐套日、计划交货日期 | 必接(至少一项) | 没有承诺交货日期时按此顺序回退 |
-| 订单数量 | 必接 | 交付进度与周转 |
-| 计划员(工号或姓名) | 必接 | 主计划人效的分母,缺了这张格子算不出来 |
-| 客户要求日期 | 建议 | **不参与**评审周期,仅用于展示与诊断 |
-| 客户编码与名称、物料编码与名称、规格、单位 | 建议 | 明细可读 |
-| 已通知发货数量、已发货数量 | 建议 | 交付进度 |
-| 订单状态、关闭标记 | 建议 | 排除作废单 |
-
-**订单头与订单行必须一起提供**:周期起点在头上、终点在行上,只给行会导致评审周期与满足率算不出来。**订单行可走推送,订单头目前只能走方式甲或乙。**
-
-**销售发货**(成品库存周转必接,方式甲 / 乙):订单行、应发与实发数量、实际发货日期。这笔数据归成品仓储,见 §6.7。
-
-**缺数表现**:缺签订日期或四个交期全缺,评审周期与满足率留空;缺计划员,主计划人效按 1 人计算;缺发货记录,成品库存周转留空。
-
-### 6.2 制造协同
-
-| 格子 | 业务口径 |
-|---|---|
-| 订单排程周期(天) | 从**工单下达日期**(没有则取开单日期)到**工序计划日期**的平均天数 |
-| 订单排程满足率(%) | 工序计划日期**不晚于工单需求完工日**的工单占比 |
-| 订单排程人效(行/人) | 工单数 ÷ 作业人数合计 |
-| 在制库存周转(天) | 在制数量 ÷ 完工数量 × 30,**按数量计算** |
-
-**生产工单**(必接,方式甲 / 丙 / 乙)
-
-| 数据项 | 必接 / 建议 | 说明 |
-|---|---|---|
-| 工单号 | 必接 | 排程与在制的主键 |
-| 销售订单号 | 必接 | 串联订单到工单,数据归产销协同 |
-| 工单数量、完工数量 | 必接 | 在制量与周转 |
-| 开单日期、下达日期、需求完工日 | 必接 | 周期起点与是否逾期 |
-| 物料编码与名称、工单状态、加急标记、产线 | 建议 | 详情与分布 |
-
-**日计划或排产结果**(工序级,必接,方式甲 / 乙)
-
-| 数据项 | 必接 / 建议 | 说明 |
-|---|---|---|
-| 工单号、工序号 | 必接 | 哪道工序哪天做 |
-| 计划日期 | 必接 | 排程周期的终点 |
-| 计划数量、完工数量 | 必接 | 排程满足与在制 |
-| 作业人数或班组人数 | 必接 | 订单排程人效的分母,缺了按 1 人计算 |
-| 工作中心、作业人员 | 建议 | 分布图与详情 |
-
-**工序级日计划与排产结果目前没有推送对象**,只能走方式甲或乙;工单本身可走推送。
-
-**缺数表现**:缺工序计划日期,排程周期与满足率留空;缺需求完工日,满足率按「未满足」计入,数值偏低。
-
-### 6.3 供应协同
-
-| 格子 | 业务口径 |
-|---|---|
-| 物料计划周期(天) | 从**交货计划的需求日期**到**交货计划提交日期**的平均天数 |
-| 物料计划满足率(%) | **交货计划数量不少于采购订单数量**的采购订单行占比(按数量比较,不是按时间) |
-| 物料计划人效(行/人) | 计划岗在职人数为分母 |
-| 物料库存周转(天) | (采购入库量 + 结存量)÷ 2 ÷ 生产领料量 × 30,取**上一个完整月**,按数量计算 |
+## 1. 引言
 
-| 业务数据包 | 必须提供 | 建议再提供 | 可用方式 |
-|---|---|---|---|
-| **交货计划** | 交货计划号、对应采购订单行、物料、计划数量、**需求日期**、**提交日期**、已发数量、状态 | 剩余数量、退货数量、最近发货日 | 甲 / 乙 |
-| **采购订单** | 采购单号与行号、供应商、物料、订货数量、已收数量、订单日期、要货日期、状态 | 退货数量、采购员、关联工单 | 甲 / 丙(已开通)/ 乙 |
-| **物料需求计划** | 需求单号、物料、需求数量、需求日期 | 供应商、状态 | 甲 / 乙 |
-| **采购申请** | 申请单号、物料、申请数量、申请日期 | 发出日、到货日、状态 | 甲 / 乙 |
-| **齐套与现存量** | 物料、可用库存或库位存量 | 工单缺料数量、风险标记 | 甲 / 乙 |
-| **出入库流水与期初** | 见 §6.5,数据归来料仓储 | — | 甲 / 丙(已开通) |
+### 1.1 编写目的与适用读者
 
-**交货计划目前没有推送对象**,而它同时是本模块两张格子与采购执行三张格子的起点,只能走方式甲或乙。
+本文档用于在项目实施前,向第三方业务部门说明 Ai-DOP 智能运营平台各项功能所需的业务数据范围、来源归属与可选的对接方式,作为双方确定对接清单与实施范围的依据。
 
-**缺数表现**:缺交货计划的需求日期或提交日期,物料计划周期留空;缺交货计划数量,满足率留空;缺出入库流水或领料记录,物料库存周转留空(不会显示 0)。
+本文档内容限于业务数据范围、来源归属与对接方式,不涉及数据库表名、字段名与接口规格。上述技术实现内容由配套的技术页与系统集成指南承载,供双方技术人员使用。
 
-### 6.4 采购执行
+### 1.2 名词与约定
 
-| 格子 | 业务口径 |
-|---|---|
-| 物料交货周期(天) | 从**交货计划需求日期**到**实际收货日期**的平均天数,收货取正式收货与来料检验收货中最晚一次 |
-| 物料交货满足率(%) | **收货数量不少于订货数量,或状态已完成**的采购订单行占比(按数量与状态,不是按时间) |
-| 物料采购人效(颗/人) | 供应商数量为分母,见 §5.2 |
-| 采购在途周转(天) | 未收数量 ÷ 订货数量 × 30 的平均值 |
-
-| 业务数据包 | 必须提供 | 建议再提供 | 可用方式 |
-|---|---|---|---|
-| **收货记录** | 收货日期、物料、收货数量、对应采购订单行 | 供应商、批次 | 甲 / 丙(已开通)/ 乙 |
-| **供应商发货执行** | 送货单号、物料、实发数量、发货日期 | 对应采购订单行 | 甲 / 丙(已开通)/ 乙 |
-| **来料检验收货行** | 收货数量、不合格数量、收货日期、物料 | 抽检数量、检验结论 | 甲 / 丙(已开通)/ 乙 |
-| **采购退货** | 退货数量、物料、对应采购订单 | 原因、状态 | 甲 / 丙(已开通)/ 乙 |
-| **欠料** | 工单、缺料物料、欠数 | 供应商、预计到货 | 甲 / 丙(已开通)/ 乙 |
-
-**本模块三张周期与满足率格子的起点数据不在本模块**:物料交货周期的起点是供应协同的交货计划需求日期,满足率与在途周转的分母是供应协同的采购订单订货数量。**只接本模块的收货与发货数据,这三张格子仍然算不出来。**
-
-### 6.5 来料仓储
-
-| 格子 | 业务口径 |
-|---|---|
-| 物料上线周期(天) | 按物料计算,从**采购入库日期**到**生产领料日期**的平均天数,各取最早一次 |
-| 物料上线满足率(%) | 工单**开工日之前已领到的物料种类数 ÷ 工单用料种类数**的平均值 |
-| 物料仓储人效(个/人) | 仓管岗在职人数为分母 |
-| 品类物料库存周转(天) | **月均库存金额 ÷ 月出库成本 × 30**,取上一个完整月。这是本系统唯一按**成本**计算的周转指标 |
-
-| 业务数据包 | 必须提供 | 建议再提供 | 可用方式 |
-|---|---|---|---|
-| **出入库流水** | 物料、数量、出入库日期、**出入库业务类型**、**审核状态** | 库位、批次、单价或金额 | 甲 / 丙(已开通) |
-| **库存期初余额** | 物料、期初数量、起算日期 | 库位、批次 | 甲 / 丙(已开通) |
-| **月均库存金额** | 期间、月均库存金额 | — | 甲 / 丙(已开通) |
-| **生产领料与上线** | 领料单、工单、物料、实发数量、发到产线日期 | — | 甲 / 丙(属出入库流水的一种业务类型) |
-| **工单用料与开工日** | 工单、用料物料、需求数量、实际开工日期 | — | 甲 / 丙(已开通)/ 乙;数据归制造执行 |
-| **采购收货** | 收货单号与行号、物料、实收数量、收货日期 | 供应商、采购订单、批次、库位 | 甲 / 丙(已开通)/ 乙 |
-| **来料检验结论** | 检验单号、结论、检验日期、物料 | 不合格数量 | 甲 / 丙(已开通)/ 乙 |
-| **委外发料、生产退料** | 单据号、物料、数量、日期 | — | 甲 / 丙(属出入库流水的一种业务类型) |
-
-**出入库流水是全系统共用的一套数据**:供应协同的物料库存周转、制造执行的订单制造满足率、成品仓储的订单发货、运营指标的质量退货率,读的都是这一套流水,靠业务类型区分。接一次,四处都受益。
-
-**关于单价**:周转类指标提供**物料单价或出库成本**时按成本口径计算,不提供时按数量口径计算。两种口径的周转天数可能相差数倍,且看板上不作区分。若贵方需要与财务口径对账,请务必提供单价或成本。
-
-**缺数表现**:缺业务类型,物料上线与周转全部留空;缺工单开工日,上线满足率留空;缺单价,周转改按数量口径且不提示。
-
-### 6.6 制造执行
-
-| 格子 | 业务口径 |
-|---|---|
-| 订单制造周期(天) | 从工单**最早投产**到**最晚完工**的平均天数,**仅统计已完工的订单** |
-| 订单制造满足率(%) | 在**计划完工日之前入库**的数量占比 |
-| 订单制造人效(个/人) | 生产岗在职人数为分母 |
-
-| 业务数据包 | 必须提供 | 建议再提供 | 可用方式 |
-|---|---|---|---|
-| **生产任务与用料** | 工单号、物料、计划数量、计划完工日、完工标记与完工时间、实际开工日期 | 产线、班组 | 甲 / 丙(已开通)/ 乙 |
-| **生产入库流水** | 工单、物料、入库数量、入库日期、审核状态 | 批次 | 甲 / 丙(已开通);数据归来料仓储的同一套流水 |
-| **车间报工** | 工单或报工单号、开工与报工日期、报工数量 | 人员 | 甲 / 丙(已开通)/ 乙 |
-| **过程检验** | 检验单号、工单、工序、结论、检验日期 | 物料、检验员、抽样数 | 甲 / 乙 |
-
-**本模块的生产任务与用料同时被来料仓储的物料上线满足率依赖**(提供工单用料种类与开工日),漏接会同时影响两个模块。
-
-**缺数表现**:未完工的订单不进入制造周期统计,因此若贵方只提供在制工单而无完工记录,这张格子会留空。
-
-### 6.7 成品仓储
-
-| 格子 | 业务口径 |
-|---|---|
-| 订单发货周期(天) | 从**成品报检日期**(没有则取建单日期)到**实际出库日期**的平均天数,**仅统计已发完的订单** |
-| 订单发货满足率(%) | 在**计划交货日之前出库且数量齐备**的行占比 |
-| 成品仓储人效(个/人) | 仓管岗在职人数为分母 |
-
-| 业务数据包 | 必须提供 | 建议再提供 | 可用方式 |
-|---|---|---|---|
-| **销售订单与用料** | 订单号、物料、计划数量、计划交货日、完工标记 | — | 甲 / 丙(已开通)/ 乙 |
-| **销售出库流水** | 订单、物料、出库数量、出库日期、审核状态 | 批次、客户 | 甲 / 丙(已开通);数据归来料仓储的同一套流水 |
-| **成品检验报检** | 报检日期(取订单行最早一次)、检验结论 | 检验单号、明细 | 甲 / 丙(已开通)/ 乙 |
-| **成品入库** | 入库单号、工单或订单、物料、入库数量、入库日期 | 部门、状态 | 甲 / 乙 |
-| **成品结存与期初** | 物料、数量、日期 | 库位 | 甲 / 丙(已开通) |
-
-**订单发货满足率的计划交货日来自产销协同的销售订单行**,不接销售订单则这张格子算不出。本模块的销售出库流水同时是产销协同成品库存周转的来源。
-
-### 6.8 异常监控
-
-两张格子:**各类异常事件数量(个)**、**各类异常事件处理时效(小时)**。
-
-**不需要贵方提供异常单**,异常由我方监控规则读已入站数据自动生成,详见第 9 章。
-
-两张格子的空值表现不同:**异常数量在没有异常时显示 0**;**处理时效在没有已关闭异常时留空**。这是设计如此,不是故障。
-
-### 6.9 运营指标(全场)
-
-本期纳入一张格子:
-
-| 格子 | 业务口径 |
-|---|---|
-| 质量退货率(PPM) | 质量退货数量 ÷ 出库数量 × 1,000,000。单位是百万分之一,**不是百分比** |
-
-需要提供:**质量退货流水**(因质量原因退回的数量、退货日期、对应物料,含业务类型与审核状态),以及对应的出库数量。可用方式:甲 / 丙(已开通)。这两项都来自来料仓储的同一套出入库流水,靠业务类型区分。
-
-**九宫格另有四张跨模块格子**:订单交付周期、订单交付满足率、生产效率、全库存周转。它们由平台根据已接入的订单、发货、报工与库存汇总,不需要单独再交一套格子数据。订单、发货、报工或库存缺了,对应格子会显示无数据,而不是 0。
+| 名词 | 含义 |
+|------|------|
+| 数据服务层 | Ai-DOP 统一接收、清洗并换算外部业务数据的中间层,各功能只读取数据服务层产出的指标 |
+| 九宫格看板 | 平台首页的模块入口,每个业务模块展示三至四项核心指标 |
+| 格子 | 九宫格看板上的单项指标 |
+| 数据包 | 一组业务含义相近、通常来自同一张单据或同一套台账的业务数据 |
+| 业务键 | 能够唯一定位一条业务记录的编号组合,如销售订单号加订单行号 |
+| 必须提供 | 缺少该项数据时,对应指标无法计算 |
+| 建议提供 | 不影响指标计算,但影响明细展示、筛选或问题定位 |
+
+平台业务模块及其编号如下,本文档统一采用该编号与名称:
+
+| 编号 | 模块名称 | 编号 | 模块名称 |
+|------|----------|------|----------|
+| S1 | 产销协同 | S6 | 生产执行 |
+| S2 | 制造协同 | S7 | 成品仓储 |
+| S3 | 供应协同 | S8 | 异常监控 |
+| S4 | 采购执行 | S9 | 运营指标 |
+| S5 | 物料仓储 | | |
 
 ---
 
-## 7. 功能二:模块详情看板
-
-每个模块的详情看板与该模块的九宫格**读同一套指标**,不额外要求新的数据包。区别在三处:
+## 2. 对接总体说明
 
-**一,需要分层指标。** 详情看板展示指标的逐层分解(如评审周期按部门拆、交货周期按供应商拆)。这些分层由我方按已入站的单据明细自动算出,所以**贵方要提供的是单据明细,而不是分层汇总数**。
+### 2.1 数据流转原则与更新时效
 
-**二,要用筛选必须有明细。** 详情看板支持按产品、订单、产线、客户、供应商、物料、仓库、工单等条件筛选。**筛选结果由单据明细逐条算出**;若贵方只提供了汇总数据,筛选后会显示为空。
+Ai-DOP 不直接连接第三方业务系统生成报表。所有外部业务数据先进入 Ai-DOP 数据服务层,由数据服务层统一换算为指标,各功能模块读取指标对外呈现。
 
-**三,明细列表要可读字段。** 单号、物料名称、客户或供应商名称、状态、负责人,这些在九宫格里不需要,在详情列表里缺了就只能看到编码。
+指标数据按日重算,看板呈现的是截至上一自然日的业务结果。当日发生的业务需在次日的重算周期后方可在看板上体现。
 
-按模块额外建议提供的明细,已在第 6 章各表的「建议再提供」列中标出。
+### 2.2 功能层次与数据要求
 
----
+平台功能对数据的依赖程度不同,请业务部门在确定对接范围时一并考虑。
 
-## 8. 功能三:智慧诊断
+| 功能 | 呈现内容 | 数据要求 |
+|------|----------|----------|
+| 九宫格看板 | 各模块的核心指标,包含周期、满足率、人效与周转四类 | 需提供可用于计算指标的日期与数量 |
+| 模块详情看板 | 指标趋势、分布、明细列表与条件筛选 | 在指标数据基础上,另需单据编号、物料名称、客户或供应商名称、状态等可读信息,以及单据行级明细 |
+| 智慧诊断 | 指标逐层下钻,定位问题所在环节 | 指标数据与看板一致;查看业务依据时需单据行级明细 |
+| 异常监控 | 自动识别逾期、缺料等异常并分派处理 | 无需第三方提供异常台账,由平台监控规则读取已接入数据自动生成 |
+| ChatBI | 以自然语言查询指标 | 无需额外数据,指标计算完成即可使用 |
 
-诊断分两层能力,对数据的要求不同,请分别确认。
+### 2.3 指标取数状态的展示规则
 
-### 8.1 指标树逐层下钻
+当某项指标未取到数据时,该格子显示为「—」,同时状态标记为黄色「风险」。指标已取到数据但未达成目标时,格子显示具体数值,状态同样标记为黄色。
 
-指标树的取数与九宫格完全同源,**哪个模块的九宫格有数,该模块的指标树就能亮**,不需要额外数据。
-
-### 8.2 点开「业务依据」
-
-点开某个指标看它由哪些具体单据构成。这一层**只对已登记的指标开放**,未登记的指标点开会提示「尚未登记可追溯业务依据」。当前覆盖情况如下,请业务按此设定预期:
-
-| 模块 | 可点开业务依据的范围 |
-|---|---|
-| 产销协同 | 合同评审各环节(覆盖最全) |
-| 制造协同 | 排程类指标与工序类指标 |
-| 供应协同 | 交货计划、供应商交付、齐套、采购申请、采购订单五类 |
-| 采购执行 | 供应商交付、交货周期、采购执行三类 |
-| 来料仓储 | 三个指标 |
-| 制造执行 | 两个指标 |
-| 成品仓储 | 三个指标 |
-| 运营指标 | 仅质量退货率 |
-
-点开依据要求提供**单据行级明细**,不能只给汇总数。哪些明细对应哪个模块,见第 6 章各表。
+因此判读时应以数值为准:显示为「—」表示该指标所需数据尚未接入,属对接范围事项;显示为具体数值且标记为黄色,才表示业务指标未达成目标。首次验收时请按此规则判读。
 
 ---
 
-## 9. 功能四:异常监控
-
-### 9.1 不需要贵方送异常单
-
-异常由我方监控规则定时扫描已入站数据自动生成,贵方不需要提供异常台账。当前内置三条规则:
-
-| 监控规则 | 判定什么 | 实际读取的数据 |
-|---|---|---|
-| **订单交付延期预警** | 产能确认的交期晚于计划交货日期,提前预警 | 销售订单 + 评审产出的交期确认 |
-| **制造工单缺料** | 工单用料中存在欠料 | **物料齐套检查数据**(属产销协同链路,不是制造模块数据) |
-| **采购交期延误** | 采购交期已过且未收货完成 | **供应链交货计划与采购订单**(属供应协同链路,不是采购执行模块数据) |
-
-**请特别注意后两条规则的数据来源与它们的名字不一致。** 「制造工单缺料」依赖产销协同的齐套检查数据;「采购交期延误」依赖供应协同的交货计划,**只提供采购执行模块的收货数据不足以让它工作**。
-
-供应协同、来料仓储、制造执行、成品仓储四个模块目前没有内置监控规则。
-
-### 9.2 部分对接时的表现
-
-规则能否工作取决于**它实际依赖的数据**,不取决于模块编号。常见情况如下:
-
-| 已对接范围 | 订单交付延期预警 | 制造工单缺料 | 采购交期延误 |
-|---|---|---|---|
-| 主数据 + 销售订单 + 评审 | **可用** | 不可用 | 不可用 |
-| 上述 + 物料齐套检查 | 可用 | **可用** | 不可用 |
-| 上述 + 交货计划与采购订单 | 可用 | 可用 | **可用** |
-| 只有采购与收货数据 | 不可用 | 不可用 | **不可用**(缺交货计划) |
-
-### 9.3 两个必须知晓的行为
-
-**一,缺数据是静默的。** 某条规则的来源数据为空时,规则照常运行、判定为「无异常」、**不报错也不告警**。因此「异常数为 0」有两种可能:真的没有异常,或者这条规则的数据没接进来。请在验收时逐条确认数据来源,不要仅凭 0 判断。
+## 3. 对接方式
 
-**二,规则需要先配置责任人。** 每条规则要指定默认责任部门与处理人,未配置时即使判定出异常也不会生成待办。这属我方实施配置项,会在上线前完成,此处列出供贵方了解。
+### 3.1 三种方式与适用性对照
 
-### 9.4 订单全链路视图
+同一笔业务数据,三种对接方式所需的业务内容完全相同,区别在于由哪一方发起、通过何种通道传输。每个数据包可独立选择对接方式,不同数据包可采用不同方式。
 
-异常监控模块另有一张订单全链路视图,按阶段展示一张订单从接单到交付的进展。当前**订单接收、设计核验、物料采购三个阶段有数据**,本体生产与总装两个阶段为预留,本期不展示实际数据。
+| 对接方式 | 发起方 | 说明 | 适用情形 |
+|----------|--------|------|----------|
+| 数据库同步 | Ai-DOP | 平台按约定周期从第三方只读数据库抽取数据 | 第三方可开放只读账号。该方式覆盖全部业务对象,第三方无需开发 |
+| 接口推送 | 第三方 | 第三方按 Ai-DOP 标准接口主动推送数据 | 第三方不便开放数据库,或希望数据即时送达 |
+| 接口拉取 | Ai-DOP | 平台按约定周期调用第三方查询接口取数 | 第三方仅能提供查询接口,需按业务对象逐一约定接口规格 |
 
----
-
-## 10. 功能五:ChatBI
+下列业务数据目前仅支持数据库同步与接口拉取两种方式,采用接口推送需另行评估开发:销售订单头、合同评审环节、发货计划与发货单、工序级日计划与排产结果、物料需求计划、采购申请、交货计划、现存量快照、过程检验、成品入库。
 
-用日常语言提问指标,例如「上个月订单评审周期多少天」「哪个模块满足率最低」。
+其中交货计划的影响范围最广。S3 供应协同的物料计划周期与物料计划满足率、S4 采购执行的物料交货周期,以及 S8 异常监控的采购交期延误规则,均以交货计划为起点。若本期范围包含上述功能,须为交货计划安排数据库同步或接口拉取通道。
 
-**不需要额外对接数据。** ChatBI 读的就是九宫格与详情看板那套指标,哪个模块有指标就能问哪个模块。
+### 3.2 出入库流水专项约定
 
-两个边界请业务知晓:
+平台的周转类指标与仓储制造类指标,依赖出入库流水中的两项信息:**出入库业务类型**,用于区分采购入库、生产领料、生产入库、销售出库与销售退货;**审核状态**,用于排除未审核通过的单据。
 
-- **只能问指标,不能问原始单据。** 问「哪几张订单逾期了」这类明细问题,系统会引导去模块详情看板查看。
-- **指标全无数据时**给固定提示,不会编造数值。
+这两项信息在三种对接方式下均可提供。采用数据库同步时随单据一并读取;采用接口推送时,业务类型为推送报文的必填项,审核状态与审核时间为可选项。
 
----
+受此影响的指标共五项:S3 供应协同的物料库存周转、S5 物料仓储的物料上线周期与物料上线满足率、S6 生产执行的订单制造满足率、S7 成品仓储的订单发货满足率,以及 S9 运营指标的质量退货率。前提是第三方业务系统中确实记录了这两项信息。
 
-## 11. 缺数影响对照表
-
-按数据包查它缺失会影响什么。用于评估分批上线的取舍。
-
-| 业务数据包 | 缺失影响的功能 |
-|---|---|
-| **物料主数据** | 各模块明细与诊断依据只显示编码,指标本身仍可算 |
-| **客户、供应商主数据** | 详情列表与诊断标题只显示编码;供应商缺失还会影响物料采购人效的分母 |
-| **员工与岗位人数** | 物料计划、物料仓储、订单制造、成品仓储四张人效格子留空 |
-| **销售订单头** | 订单评审周期与满足率留空(周期起点在头上) |
-| **销售订单行** | 产销协同四张格子全部留空;订单交付延期预警失效;制造协同无法串联订单;成品仓储发货满足率留空 |
-| **订单行上的计划员** | 主计划人效按 1 人计算,数值明显偏大 |
-| **合同评审环节** | 九宫格不受影响;诊断「点开依据」在产销协同基本不可用 |
-| **销售发货记录** | 成品库存周转留空;成品仓储发货周期与满足率留空 |
-| **生产工单** | 制造协同四张格子全部留空 |
-| **日计划或排产结果** | 订单排程周期与满足率留空;排程人效按 1 人计算 |
-| **交货计划** | 物料计划周期与满足率留空;**采购执行的物料交货周期留空**;**采购交期延误规则失效** |
-| **采购订单** | 物料计划满足率、物料交货满足率、采购在途周转留空 |
-| **收货记录** | 物料交货周期与满足率留空 |
-| **物料齐套检查** | **制造工单缺料规则失效**;供应协同齐套类诊断依据不可用 |
-| **出入库流水的业务类型** | 物料库存周转、物料上线、订单制造、订单发货、质量退货率全部留空 |
-| **库存期初余额** | 全部周转类格子留空(仅有结存快照算不出周转) |
-| **物料单价或出库成本** | 周转类改按数量口径计算,与财务口径无法对账,且界面不提示 |
-| **工单用料与开工日** | 物料上线满足率留空 |
-| **工单完工记录** | 订单制造周期留空(仅统计已完工订单) |
-| **成品报检记录** | 订单发货周期起点缺失,改取建单日期,周期偏长 |
-| **质量退货流水** | 质量退货率留空 |
+生产领料、生产入库、销售出库与质量退货不需要单独对接,它们属于出入库流水的不同业务类型,通过同一套流水数据提供即可。
 
----
+### 3.3 常见情形与建议方案
 
-## 12. 全部接通后的功能可用性
+**情形一:第三方可开放只读数据库账号。** 建议全部采用数据库同步。该方式覆盖全部业务对象,第三方无需开发,仅需提供表结构说明以便平台建立字段对应关系。
 
-假设第 5 至第 6 章列出的「必须提供」全部到位:
+**情形二:第三方不便开放数据库,可按标准接口推送。** 除 3.1 节列出的十类业务数据外,其余均可采用接口推送。上述十类需另行安排数据库同步或接口拉取通道,否则相关指标不纳入本期范围。
 
-| 功能 | 状态 | 说明 |
-|---|---|---|
-| 九宫格看板 | **可用** | 八个模块的指标可出数;运营指标模块本期仅质量退货率 |
-| 模块详情看板 | **可用** | 含趋势、分层与按条件筛选 |
-| 智慧诊断指标树 | **可用** | 全模块逐层下钻 |
-| 智慧诊断点开依据 | **部分可用** | 覆盖范围见 §8.2,来料仓储、制造执行、成品仓储各仅两三个指标 |
-| 异常监控 | **可用** | 三条内置规则均可工作 |
-| 订单全链路视图 | **部分可用** | 前三个阶段有数据,后两个阶段本期预留 |
-| ChatBI | **可用** | 随指标可用范围 |
+**情形三:第三方仅能提供查询接口。** 采用接口拉取,第 4 章所列「所需业务数据」即平台需从第三方接口获取的内容,接口地址、鉴权方式、分页规则与返回结构由双方技术人员另行约定,实施排期中需预留接口联调时间。
 
-**跨模块四格**:订单交付周期、订单交付满足率、生产效率、全库存周转随订单、发货、报工、库存数据到位后由平台计算。
+**情形四:同一数据包分散在多套系统中。** 按系统分别对接,平台在数据服务层按物料编码、单据编号等业务键合并,第三方无需预先整合。请在第 5 章业务确认表中注明每个数据包由哪套系统提供。
 
 ---
 
-## 13. 分批上线建议
-
-分批时请连同第 4 章的跨功能依赖一起看,避免某一批只接了格子所属模块、却漏了它依赖的上游数据。
-
-**第一批 · 主数据与订单。** 物料、客户、供应商、员工岗位人数、销售订单头与行、合同评审环节。上线后可点亮产销协同的评审周期、满足率与主计划人效,诊断可点开评审依据,ChatBI 可问这些指标,订单交付延期预警可启用。
-
-**第二批 · 计划与采购。** 生产工单、日计划、交货计划、采购订单、收货记录、物料齐套检查。上线后可点亮制造协同与供应协同、采购执行的周期与满足率,另外两条监控规则可启用。**交货计划是这一批的关键**:供应协同两张格子与采购执行三张格子都以它为起点。
+## 4. 业务数据需求总表
 
-**第三批 · 库存与仓储制造。** 出入库流水(含业务类型与审核状态)、库存期初、月均库存金额、领料上线、工单用料与开工、工单完工、成品报检与出库、质量退货。上线后可点亮周转类格子与来料仓储、制造执行、成品仓储、质量退货率。若同时提供单价,周转类可按成本口径计算。
+### 4.1 本表阅读说明
 
-每批上线后可独立验收,不必等全部数据到齐。
+本章为全文档核心,逐项列出各功能所需的业务数据。各列含义如下。
 
----
-
-## 14. 业务签收表
-
-请业务对每个数据包逐项确认:
-
-| 确认项 | 说明 |
-|---|---|
-| 贵方有这笔业务 | 没有这笔业务的数据包直接标注「无此业务」,相关格子从验收范围移除 |
-| 能提供「必须提供」列 | 逐项核对第 5、6 章 |
-| 由哪套系统出 | ERP / MES / WMS / QMS / SRM,可多套,我方按业务键合并 |
-| 用哪种方式提供 | 数据库同步 / 标准接口推送 / 接口拉取,见第 2 章 |
-| 本批是否已覆盖依赖项 | 对照第 4 章,确认本批格子依赖的上游数据也在范围内 |
-| 出入库流水能否带业务类型与审核状态 | 决定周转类与仓储制造类格子能否交付,见 §2.2 |
-| 能否提供物料单价或出库成本 | 决定周转按成本口径还是数量口径,见 §6.5 |
-| 能否提供库存期初余额 | 周转类必须「期初 + 流水」,只给结存快照算不出 |
+| 列名 | 含义 |
+|------|------|
+| 模块 | 该功能所属的业务模块 |
+| 功能 | 具体指标或功能项 |
+| 所需业务数据 | 计算该功能所需的业务数据,逐项标注「必须」或「建议」 |
+| 对接方式 | 该数据包可采用的对接方式 |
+| 依赖项 | 所需数据的归属。标注为「本模块」表示由该模块自身数据满足;标注为其他模块时,表示需先接入该模块的数据,本功能才能计算 |
+| 缺失影响 | 数据未提供时,该功能的实际表现 |
+
+**依赖项一列需重点关注。** 分批实施时若仅接入某模块自身数据而遗漏其依赖的上游数据,该模块的指标仍无法计算。其中两项尤易误判:S8 异常监控的制造工单缺料规则,依赖 S1 产销协同产出的齐套检查数据,而非 S6 生产执行的数据;S8 异常监控的采购交期延误规则,依赖 S3 供应协同的交货计划,而非 S4 采购执行的收货数据。
+
+### 4.2 前置基础数据
+
+前置基础数据本身不产生看板指标,但订单、工单与收货记录需据此显示物料名称、客户名称与供应商名称。此外,四项人效指标的分母取自在职人员数据。
+
+| 模块 | 功能 | 所需业务数据 | 对接方式 | 依赖项 | 缺失影响 |
+|------|------|--------------|----------|--------|----------|
+| 前置 | 物料基础信息 | 物料编码(必须)、物料名称(必须)、计量单位(必须)、规格型号(必须)、物料类型(必须)、启用状态(必须)、图号(建议)、默认库位(建议) | 数据库同步/接口推送/接口拉取 | 本项 | 指标仍可计算,但各模块明细与诊断依据中只显示物料编码,业务人员难以辨识 |
+| 前置 | 客户基础信息 | 客户编码(必须)、客户名称(必须)、启用状态(必须)、客户简称(建议)、所属国家(建议)、业务员(建议) | 数据库同步/接口推送/接口拉取 | 本项 | 详情列表与诊断标题中只显示客户编码 |
+| 前置 | 供应商基础信息 | 供应商编码(必须)、供应商名称(必须)、启用状态(必须)、联系人(建议)、地址(建议)、结算币种(建议)、供应商类型(建议) | 数据库同步/接口推送/接口拉取 | 本项 | 详情列表只显示供应商编码;同时导致 S4 采购执行的物料采购人效缺少分母而无法计算 |
+| 前置 | 货源清单 | 物料编码(必须)、供应商编码(必须)、供货比例(必须)、采购周期(必须)、最小订量(建议)、包装量(建议)、价格(建议)、生效与失效日期(建议) | 数据库同步/接口推送/接口拉取 | 本项 | 供应商与物料的对应关系缺失,影响采购类诊断的问题定位 |
+| 前置 | 库位信息 | 库位编码(必须)、库位名称(建议)、所属仓库(建议) | 数据库同步/接口推送/接口拉取 | 本项 | 库存明细中只显示库位编码 |
+| 前置 | 在职人员与岗位 | 在职人员名单(必须)、岗位分类(必须,需区分计划、仓管、生产三类)、在职状态(必须)、工号(建议)、所属部门(建议) | 数据库同步/接口推送/接口拉取 | 本项 | S3 物料计划人效、S5 物料仓储人效、S6 订单制造人效、S7 成品仓储人效四项指标显示为「—」 |
+
+### 4.3 各模块看板指标数据
+
+| 模块 | 功能 | 所需业务数据 | 对接方式 | 依赖项 | 缺失影响 |
+|------|------|--------------|----------|--------|----------|
+| S1 产销协同 | 订单评审周期 | 订单签订日期(必须,位于订单头)、订单行承诺交货日期(必须,位于订单行)、产能建议交期/齐套日/计划交货日期(三者至少提供一项,用于承诺交货日期缺失时按序回退)、销售订单号与订单行号(必须) | 数据库同步/接口拉取;订单行另支持接口推送 | 本模块 | 订单签订日期缺失,或四类交期全部缺失时,该指标显示为「—」。订单头与订单行须一并提供,仅提供订单行无法计算 |
+| S1 产销协同 | 订单评审满足率 | 同订单评审周期 | 数据库同步/接口拉取;订单行另支持接口推送 | 本模块 | 同上 |
+| S1 产销协同 | 主计划人效 | 销售订单行数(必须)、订单行上的计划员工号或姓名(必须) | 数据库同步/接口推送/接口拉取 | 本模块 | 计划员缺失时按一人计算,数值显著偏高 |
+| S1 产销协同 | 成品库存周转 | 订单行未发数量(必须)、累计发货数量(必须)、实际发货日期(必须) | 数据库同步/接口拉取 | S7 成品仓储的销售出库记录 | 发货记录缺失时该指标显示为「—」。本指标按数量计算,非财务成本口径 |
+| S2 制造协同 | 订单排程周期 | 工单号(必须)、工单下达日期(必须,缺失时取开单日期)、工序计划日期(必须)、关联销售订单号(必须) | 工单支持三种方式;工序计划日期仅支持数据库同步/接口拉取 | 本模块;销售订单号依赖 S1 产销协同 | 工序计划日期缺失时该指标显示为「—」 |
+| S2 制造协同 | 订单排程满足率 | 工序计划日期(必须)、工单需求完工日(必须) | 工单支持三种方式;工序计划日期仅支持数据库同步/接口拉取 | 本模块 | 需求完工日缺失时,该工单按未满足计入,指标数值偏低 |
+| S2 制造协同 | 订单排程人效 | 工单数量(必须)、日计划或排产结果上的作业人数或班组人数(必须) | 数据库同步/接口拉取 | 本模块 | 作业人数缺失时按一人计算,数值显著偏高 |
+| S2 制造协同 | 在制库存周转 | 在制数量(必须)、完工数量(必须) | 数据库同步/接口推送/接口拉取 | 本模块 | 完工数量缺失时该指标显示为「—」。本指标按数量计算 |
+| S3 供应协同 | 物料计划周期 | 交货计划号(必须)、需求日期(必须)、提交日期(必须)、对应采购订单行(必须) | 数据库同步/接口拉取 | 本模块 | 需求日期或提交日期缺失时该指标显示为「—」 |
+| S3 供应协同 | 物料计划满足率 | 交货计划数量(必须)、采购订单订货数量(必须) | 交货计划仅支持数据库同步/接口拉取;采购订单支持三种方式 | 本模块 | 交货计划数量缺失时该指标显示为「—」。本指标按数量比较,不按时间比较 |
+| S3 供应协同 | 物料计划人效 | 计划岗在职人数(必须) | 数据库同步/接口推送/接口拉取 | 前置基础数据的在职人员与岗位 | 在职人员数据缺失时该指标显示为「—」 |
+| S3 供应协同 | 物料库存周转 | 采购入库数量(必须)、期末结存数量(必须)、生产领料数量(必须)、出入库业务类型(必须)、审核状态(必须) | 数据库同步/接口推送 | S5 物料仓储的出入库流水与库存期初 | 出入库流水或库存期初缺失时该指标显示为「—」,不会显示为 0。取上一完整月数据,按数量计算 |
+| S4 采购执行 | 物料交货周期 | 实际收货日期(必须)、来料检验收货日期(必须)、交货计划需求日期(必须)、物料编码(必须) | 收货与检验支持三种方式;交货计划仅支持数据库同步/接口拉取 | S3 供应协同的交货计划提供周期起点 | 交货计划缺失时该指标显示为「—」,仅接入本模块收货数据无法计算。收货日期取正式收货与来料检验收货中较晚的一次 |
+| S4 采购执行 | 物料交货满足率 | 收货数量(必须)、采购订单订货数量或完成状态(必须) | 数据库同步/接口推送/接口拉取 | S3 供应协同的采购订单提供订货数量 | 采购订单缺失时该指标显示为「—」。本指标按数量与状态判定,不按时间判定 |
+| S4 采购执行 | 采购在途周转 | 未收数量(必须)、采购订单订货数量(必须) | 数据库同步/接口推送/接口拉取 | S3 供应协同的采购订单 | 采购订单缺失时该指标显示为「—」 |
+| S4 采购执行 | 物料采购人效 | 供应商清单(必须) | 数据库同步/接口推送/接口拉取 | 前置基础数据的供应商基础信息 | 供应商数据缺失时该指标显示为「—」。本指标以供应商数量为分母,不以采购岗人数为分母 |
+| S5 物料仓储 | 物料上线周期 | 采购入库日期(必须)、生产领料日期(必须)、出入库业务类型(必须)、审核状态(必须)、物料编码(必须) | 数据库同步/接口推送 | 本模块 | 出入库业务类型缺失时该指标显示为「—」。采购入库与生产领料均取该物料最早的一次 |
+| S5 物料仓储 | 物料上线满足率 | 工单用料种类与需求数量(必须)、工单实际开工日期(必须)、已领物料记录(必须) | 数据库同步/接口推送/接口拉取 | S6 生产执行的生产任务与用料 | 工单开工日或工单用料缺失时该指标显示为「—」 |
+| S5 物料仓储 | 物料仓储人效 | 仓管岗在职人数(必须) | 数据库同步/接口推送/接口拉取 | 前置基础数据的在职人员与岗位 | 在职人员数据缺失时该指标显示为「—」 |
+| S5 物料仓储 | 品类物料库存周转 | 月均库存金额(必须)、月出库成本(必须)、统计期间(必须)、物料品类(建议) | 数据库同步/接口推送 | 本模块 | 月出库成本缺失时该指标显示为「—」。本指标是平台唯一按成本计算的周转指标,取上一完整月数据 |
+| S6 生产执行 | 订单制造周期 | 工单号(必须)、最早投产日期(必须)、最晚完工日期(必须)、完工标记(必须) | 数据库同步/接口推送/接口拉取 | 本模块 | 本指标仅统计已完工订单。仅提供在制工单而无完工记录时,该指标显示为「—」 |
+| S6 生产执行 | 订单制造满足率 | 工单计划完工日(必须)、生产入库数量与入库日期(必须)、出入库业务类型(必须)、审核状态(必须) | 数据库同步/接口推送 | S5 物料仓储的出入库流水 | 出入库业务类型缺失时该指标显示为「—」 |
+| S6 生产执行 | 订单制造人效 | 生产岗在职人数(必须) | 数据库同步/接口推送/接口拉取 | 前置基础数据的在职人员与岗位 | 在职人员数据缺失时该指标显示为「—」 |
+| S7 成品仓储 | 订单发货周期 | 成品报检日期(必须,缺失时取建单日期)、实际出库日期(必须)、出入库业务类型(必须)、审核状态(必须) | 数据库同步/接口推送 | 本模块 | 本指标仅统计已发完订单。成品报检记录缺失时改取建单日期,周期数值偏长 |
+| S7 成品仓储 | 订单发货满足率 | 出库数量与出库日期(必须)、销售订单计划交货日(必须) | 数据库同步/接口推送/接口拉取 | S1 产销协同的销售订单行提供计划交货日 | 销售订单缺失时该指标显示为「—」 |
+| S7 成品仓储 | 成品仓储人效 | 仓管岗在职人数(必须) | 数据库同步/接口推送/接口拉取 | 前置基础数据的在职人员与岗位 | 在职人员数据缺失时该指标显示为「—」。与 S5 物料仓储人效采用同一分母 |
+| S9 运营指标 | 质量退货率 | 质量退货数量与退货日期(必须)、对应出库数量(必须)、出入库业务类型(必须)、审核状态(必须) | 数据库同步/接口推送 | S5 物料仓储的出入库流水 | 质量退货流水缺失时该指标显示为「—」。本指标单位为 PPM(百万分之一),非百分比 |
+| 跨模块 | 订单交付周期、订单交付满足率、生产效率、全库存周转 | 由平台依据已接入的销售订单、发货记录、车间报工与库存数据汇总 | 随各来源模块 | S1 产销协同、S6 生产执行、S7 成品仓储与 S5 物料仓储 | 无需单独提供数据。任一来源缺失时对应指标显示为「—」,不会显示为 0 |
+
+### 4.4 异常监控与其他功能
+
+| 模块 | 功能 | 所需业务数据 | 对接方式 | 依赖项 | 缺失影响 |
+|------|------|--------------|----------|--------|----------|
+| S8 异常监控 | 订单交付延期预警 | 销售订单(必须)、评审产出的产能确认交期(必须)、计划交货日期(必须) | 数据库同步/接口拉取;订单行另支持接口推送 | S1 产销协同 | 数据缺失时规则照常运行并判定为无异常,不报错也不告警 |
+| S8 异常监控 | 制造工单缺料 | 物料齐套检查结果,含工单、缺料物料与欠料数量(必须) | 数据库同步/接口推送/接口拉取 | **S1 产销协同产出的齐套检查数据,非 S6 生产执行数据** | 齐套检查数据缺失时该规则不产生任何异常。提供缺料台账或库存现存量无法替代 |
+| S8 异常监控 | 采购交期延误 | 交货计划需求日期(必须)、采购订单要货日期与状态(必须) | 交货计划仅支持数据库同步/接口拉取;采购订单支持三种方式 | **S3 供应协同的交货计划与采购订单,非 S4 采购执行数据** | 交货计划缺失时该规则不产生任何异常。仅提供 S4 采购执行的收货数据无法驱动该规则 |
+| S8 异常监控 | 各类异常事件数量 | 无需第三方提供异常台账 | 不适用 | 上述三条规则各自的依赖数据 | 无异常时显示为 0。数值为 0 存在两种可能:确无异常,或该规则的数据尚未接入 |
+| S8 异常监控 | 各类异常事件处理时效 | 无需第三方提供异常台账 | 不适用 | 上述三条规则各自的依赖数据 | 无已关闭异常时显示为「—」,此为设计行为 |
+| S8 异常监控 | 订单全链路视图 | 销售订单、设计核验记录、物料采购记录(必须) | 随各来源模块 | S1 产销协同、S3 供应协同 | 当前订单接收、设计核验、物料采购三个阶段可展示数据,本体生产与总装两个阶段为后续规划,本期不展示 |
+| 全平台 | 模块详情看板 | 与该模块看板指标同源,另需单据编号、物料名称、客户或供应商名称、状态、负责人(建议),以及单据行级明细(必须) | 随各来源模块 | 各模块自身 | 仅提供汇总数据时,趋势、分布与条件筛选结果为空。筛选功能依据单据明细逐条计算 |
+| 全平台 | 智慧诊断指标树 | 与模块看板指标完全同源 | 随各来源模块 | 各模块自身 | 模块看板有数据,该模块指标树即可使用 |
+| 全平台 | 智慧诊断业务依据 | 单据行级明细(必须) | 随各来源模块 | 各模块自身 | 仅提供汇总数据时无法展开业务依据。该功能按指标逐项开放,当前 S1 产销协同覆盖最全,S5 物料仓储、S6 生产执行与 S7 成品仓储各覆盖两至三项指标,S9 运营指标仅覆盖质量退货率 |
+| 全平台 | ChatBI | 无需额外数据 | 不适用 | 各模块指标 | 指标可用范围即 ChatBI 可查询范围。ChatBI 仅支持查询指标,不支持查询原始单据明细 |
 
 ---
 
-## 变更记录
-
-| 日期 | 说明 |
-|------|------|
-| 2026-09-04 | 初版:按九宫格、详情看板、诊断、ChatBI 列业务数据包与必接列 |
-| 2026-09-11 | 增补方式丙对接编码对照 |
-| 2026-09-22 | 按现行实现校正评审周期口径、人效分母、周转口径与开通状态 |
-| 2026-09-22 | **改版为按功能组织**:新增逐模块九宫格口径与可用方式、缺数影响对照表、功能可用性总表、分批上线建议、监控规则部分对接表现、三种方式的分情况应对;对接编码全部移至技术页,本页不再出现编码;明确本期不纳入的四张格子 |
-| 2026-09-27 | 新增第 3 章「模块 · 功能 · 所需业务数据 · 对接方式」总表与第 4 章「跨功能数据依赖」;方式丙已开通对象由 7 个更新为 20 个并改以业务名列出,补「仅能走甲/乙」的数据清单;**更正「出入库业务类型无推送通道」的旧结论**(现行推送约定中业务类型为必填、审核状态可选,无需我方扩展),相应删去「依赖业务类型的指标建议走方式甲」的建议;各模块章节补注数据归属;全页去除指向技术页的字段与编码说明 |
+## 5. 业务确认表
+
+请与各业务系统对接人员,按下表逐项确认。标注为「无此业务」的数据包将从本期验收范围中移除,对应指标不纳入考核。
+
+**填写说明**:「第三方有此业务」指第三方业务系统中是否记录该类数据;「能否提供」指能否按第 4 章所列内容对外提供;「提供系统」可多选,平台按业务键自动合并,第三方无需预先整合;「对接方式」每个数据包只需选择一种。
+
+### 5.1 前置基础数据
+
+| 序号 | 业务数据 | 第三方有此业务 | 能否提供 | 提供系统 | 对接方式 | 备注 |
+|------|----------|--------------|----------|----------|----------|------|
+| 1 | 物料基础信息 | □ 有 □ 无 | □ 能 □ 部分 □ 否 | □ ERP □ MES □ WMS □ 其他 ______ | □ 数据库同步 □ 接口推送 □ 接口拉取 | |
+| 2 | 客户基础信息 | □ 有 □ 无 | □ 能 □ 部分 □ 否 | □ ERP □ MES □ WMS □ 其他 ______ | □ 数据库同步 □ 接口推送 □ 接口拉取 | |
+| 3 | 供应商基础信息 | □ 有 □ 无 | □ 能 □ 部分 □ 否 | □ ERP □ MES □ WMS □ 其他 ______ | □ 数据库同步 □ 接口推送 □ 接口拉取 | 同时决定物料采购人效能否计算 |
+| 4 | 货源清单 | □ 有 □ 无 | □ 能 □ 部分 □ 否 | □ ERP □ SRM □ 其他 ______ | □ 数据库同步 □ 接口推送 □ 接口拉取 | |
+| 5 | 库位信息 | □ 有 □ 无 | □ 能 □ 部分 □ 否 | □ ERP □ WMS □ 其他 ______ | □ 数据库同步 □ 接口推送 □ 接口拉取 | |
+| 6 | 在职人员与岗位 | □ 有 □ 无 | □ 能 □ 部分 □ 否 | □ ERP □ HR □ 其他 ______ | □ 数据库同步 □ 接口推送 □ 接口拉取 | 须区分计划、仓管、生产三类岗位 |
+
+### 5.2 业务单据数据
+
+| 序号 | 业务数据 | 第三方有此业务 | 能否提供 | 提供系统 | 对接方式 | 备注 |
+|------|----------|--------------|----------|----------|----------|------|
+| 7 | 销售订单(订单头与订单行) | □ 有 □ 无 | □ 能 □ 部分 □ 否 | □ ERP □ 其他 ______ | □ 数据库同步 □ 接口拉取 | 订单头与订单行须一并提供;订单行可另选接口推送 |
+| 8 | 订单行上的计划员 | □ 有 □ 无 | □ 能 □ 部分 □ 否 | □ ERP □ 其他 ______ | 随销售订单 | 主计划人效的分母 |
+| 9 | 合同评审环节 | □ 有 □ 无 | □ 能 □ 部分 □ 否 | □ ERP □ 其他 ______ | □ 数据库同步 □ 接口拉取 | 影响诊断业务依据,不影响看板指标 |
+| 10 | 物料齐套检查结果 | □ 有 □ 无 | □ 能 □ 部分 □ 否 | □ ERP □ MES □ 其他 ______ | □ 数据库同步 □ 接口推送 □ 接口拉取 | 制造工单缺料规则的唯一数据源 |
+| 11 | 生产工单 | □ 有 □ 无 | □ 能 □ 部分 □ 否 | □ ERP □ MES □ 其他 ______ | □ 数据库同步 □ 接口推送 □ 接口拉取 | 须含关联销售订单号 |
+| 12 | 工序级日计划或排产结果 | □ 有 □ 无 | □ 能 □ 部分 □ 否 | □ MES □ APS □ 其他 ______ | □ 数据库同步 □ 接口拉取 | 须含作业人数或班组人数 |
+| 13 | 交货计划 | □ 有 □ 无 | □ 能 □ 部分 □ 否 | □ ERP □ SRM □ 其他 ______ | □ 数据库同步 □ 接口拉取 | 影响 S3、S4 共五项指标与采购交期延误规则 |
+| 14 | 采购订单 | □ 有 □ 无 | □ 能 □ 部分 □ 否 | □ ERP □ SRM □ 其他 ______ | □ 数据库同步 □ 接口推送 □ 接口拉取 | |
+| 15 | 物料需求计划 | □ 有 □ 无 | □ 能 □ 部分 □ 否 | □ ERP □ 其他 ______ | □ 数据库同步 □ 接口拉取 | |
+| 16 | 采购申请 | □ 有 □ 无 | □ 能 □ 部分 □ 否 | □ ERP □ SRM □ 其他 ______ | □ 数据库同步 □ 接口拉取 | |
+| 17 | 采购收货记录 | □ 有 □ 无 | □ 能 □ 部分 □ 否 | □ ERP □ WMS □ 其他 ______ | □ 数据库同步 □ 接口推送 □ 接口拉取 | |
+| 18 | 供应商发货记录 | □ 有 □ 无 | □ 能 □ 部分 □ 否 | □ SRM □ 其他 ______ | □ 数据库同步 □ 接口推送 □ 接口拉取 | |
+| 19 | 来料检验记录 | □ 有 □ 无 | □ 能 □ 部分 □ 否 | □ QMS □ WMS □ 其他 ______ | □ 数据库同步 □ 接口推送 □ 接口拉取 | |
+| 20 | 采购退货记录 | □ 有 □ 无 | □ 能 □ 部分 □ 否 | □ ERP □ WMS □ 其他 ______ | □ 数据库同步 □ 接口推送 □ 接口拉取 | |
+| 21 | 欠料记录 | □ 有 □ 无 | □ 能 □ 部分 □ 否 | □ ERP □ MES □ 其他 ______ | □ 数据库同步 □ 接口推送 □ 接口拉取 | |
+| 22 | 工单用料与开工日期 | □ 有 □ 无 | □ 能 □ 部分 □ 否 | □ ERP □ MES □ 其他 ______ | □ 数据库同步 □ 接口推送 □ 接口拉取 | 物料上线满足率的分母 |
+| 23 | 车间报工记录 | □ 有 □ 无 | □ 能 □ 部分 □ 否 | □ MES □ 其他 ______ | □ 数据库同步 □ 接口推送 □ 接口拉取 | |
+| 24 | 过程检验记录 | □ 有 □ 无 | □ 能 □ 部分 □ 否 | □ QMS □ MES □ 其他 ______ | □ 数据库同步 □ 接口拉取 | |
+| 25 | 成品检验报检记录 | □ 有 □ 无 | □ 能 □ 部分 □ 否 | □ QMS □ 其他 ______ | □ 数据库同步 □ 接口推送 □ 接口拉取 | 订单发货周期的起点 |
+| 26 | 成品入库记录 | □ 有 □ 无 | □ 能 □ 部分 □ 否 | □ ERP □ WMS □ 其他 ______ | □ 数据库同步 □ 接口拉取 | |
+
+### 5.3 库存与出入库数据
+
+| 序号 | 业务数据 | 第三方有此业务 | 能否提供 | 提供系统 | 对接方式 | 备注 |
+|------|----------|--------------|----------|----------|----------|------|
+| 27 | 出入库流水 | □ 有 □ 无 | □ 能 □ 部分 □ 否 | □ ERP □ WMS □ 其他 ______ | □ 数据库同步 □ 接口推送 | 含采购入库、生产领料、生产入库、销售出库、销售退货 |
+| 28 | 出入库业务类型 | □ 有 □ 无 | □ 能 □ 部分 □ 否 | 随出入库流水 | 随出入库流水 | 决定五项周转与仓储制造类指标能否计算 |
+| 29 | 单据审核状态 | □ 有 □ 无 | □ 能 □ 部分 □ 否 | 随出入库流水 | 随出入库流水 | 用于排除未审核通过的单据 |
+| 30 | 库存期初余额 | □ 有 □ 无 | □ 能 □ 部分 □ 否 | □ ERP □ WMS □ 其他 ______ | □ 数据库同步 □ 接口推送 | 周转类指标须由期初加流水计算,仅提供结存快照无法计算 |
+| 31 | 月均库存金额与月出库成本 | □ 有 □ 无 | □ 能 □ 部分 □ 否 | □ ERP □ 财务系统 □ 其他 ______ | □ 数据库同步 □ 接口推送 | 品类物料库存周转的唯一成本口径来源 |
+| 32 | 物料单价或出库成本 | □ 有 □ 无 | □ 能 □ 部分 □ 否 | □ ERP □ 财务系统 □ 其他 ______ | 随出入库流水 | 提供时周转类按成本口径计算,不提供时按数量口径计算,两者结果可能相差数倍 |
+| 33 | 成品结存与期初 | □ 有 □ 无 | □ 能 □ 部分 □ 否 | □ ERP □ WMS □ 其他 ______ | □ 数据库同步 □ 接口推送 | |
+| 34 | 现存量快照 | □ 有 □ 无 | □ 能 □ 部分 □ 否 | □ ERP □ WMS □ 其他 ______ | □ 数据库同步 □ 接口拉取 | 用于校验,不能替代流水计算周转 |
+| 35 | 质量退货流水 | □ 有 □ 无 | □ 能 □ 部分 □ 否 | □ ERP □ WMS □ QMS □ 其他 ______ | □ 数据库同步 □ 接口推送 | 质量退货率的数据源 |

BIN
doc/plan/系统集成/Ai-DOP第三方对接业务数据详表-技术页.docx


+ 19 - 19
doc/plan/系统集成/Ai-DOP第三方对接业务数据详表-技术页.md

@@ -2,21 +2,21 @@
 
 | 项 | 内容 |
 |----|------|
-| 读者 | 第三方开发与实施、Ai-DOP 数据中台配置 |
+| 读者 | 第三方开发与实施、Ai-DOP 数据服务层配置 |
 | 配套 | 业务含义见 [`Ai-DOP第三方对接业务数据详表-业务页.md`](./Ai-DOP第三方对接业务数据详表-业务页.md);接口协议见 [`Ai-DOP第三方系统集成指南.md`](./Ai-DOP第三方系统集成指南.md) |
 | 范围 | 三种入站方式的技术契约、各对象落表栏位、九宫格逐格计算口径、监控规则数据源、联调验收项 |
 | 日期 | 2026-09-27(开通清单与契约字段按当日 `mdp_entity` 与代码契约核对) |
 
 ## 阅读约定
 
-- **源字段**:贵方库列名、拉取接口的 JSON 键,或推送接口的**对外契约字段**。
+- **源字段**:第三方库列名、拉取接口的 JSON 键,或推送接口的**对外契约字段**。
 - **贴源**:先入 `mdp_stg_*`(整行 JSON 留存);三种方式落同一张表。
 - **标准层 / 宽表**:转换写入的栏位,看板与诊断读这里。
 - **必 / 建**:与业务页「必接 / 建议」同一套口径。**指标口径以业务页为准,字段清单与开通状态以本页及 `GET .../schema` 为准**;业务页不出现表名与栏位名,不要拿它核对字段。
 
 ### 分层约束(不可绕过)
 
-看板、诊断、ChatBI **只读中台的指标表与标准层**,不直连贵方系统。第三方数据必须先入贴源、再跑模块转换,指标才会出现。
+看板、诊断、ChatBI **只读数据服务层的指标表与标准层**,不直连第三方系统。第三方数据必须先入贴源、再跑模块转换,指标才会出现。
 
 ### 「协议必填」与「指标必需」是两件事
 
@@ -40,9 +40,9 @@
 | 业务键 | 唯一定位一行的列组合,用于幂等 |
 | 批次大小 | 默认 2000 |
 
-贵方需提供:只读账号、表结构说明(列名、类型、含义)、增量列、业务键。**若贵方表名或列名与本页「源字段」不一致**,由我方按对象登记字段映射,不修改转换 SQL;映射工作量按对象计,请在排期时纳入。
+第三方需提供:只读账号、表结构说明(列名、类型、含义)、增量列、业务键。**若第三方表名或列名与本页「源字段」不一致**,由我方按对象登记字段映射,不修改转换 SQL;映射工作量按对象计,请在排期时纳入。
 
-只能提供部分列时,建议贵方侧建视图对齐本页栏位,比逐列映射更易维护。
+只能提供部分列时,建议第三方侧建视图对齐本页栏位,比逐列映射更易维护。
 
 ### 1.2 方式丙 · 标准接口推送
 
@@ -52,7 +52,7 @@
 
 #### 1.2.1 对象开通清单
 
-开通需三件事同时成立,缺一都会 403:代码层有字段契约、`mdp_entity.inbound_enabled=1`、该租户在 `mdp_inbound_grant` 上有授权行。
+开通需三件事同时成立,缺一都会 403:代码层有字段契约、`mdp_entity.inbound_enabled=1`、该租户在 `mdp_inbound_grant` 上有授权行。发放授权时同时写入开放接口身份,`AccessSecret` 只回显一次;入站数据的租户取自身份上的绑定租户,不取授权行上的租户。同一 `AccessKey` 可对多个 `entityCode` 各有一行授权。
 **`GET /api/mdp/inbound/{entityCode}/schema` 是字段清单与开通状态的权威来源**,本页表格与它不一致时以接口为准。
 
 下表「贴源表」「业务键」取自 `mdp_entity` 的实际登记值(核对日期见页首)。
@@ -103,7 +103,7 @@
 | `sourceUpdatedAt` | 必 | ISO 8601 带时区,源系统的更新时间 |
 | `sourceVersion` | 建 | 源版本号,用于乱序判定 |
 | `op` | 建 | 操作类型,删除场景使用 |
-| `bizKey` | 建 | 业务键,服务端按配置自算,贵方可附带用于校验 |
+| `bizKey` | 建 | 业务键,服务端按配置自算,第三方可附带用于校验 |
 
 **物料主数据** `MDM_ITEM` → `mdp_stg_item`
 
@@ -288,7 +288,7 @@
 
 ### 1.3 方式乙 · 接口拉取
 
-我方按对象配置 `GET` 路径、返回数据路径、去重键、增量列,定时调用贵方接口。
+我方按对象配置 `GET` 路径、返回数据路径、去重键、增量列,定时调用第三方接口。
 
 需按对象约定:接口地址、鉴权方式、分页或增量参数、返回结构中列表所在路径、单行的唯一键。要取的字段内容与方式甲的「源字段」一致,见第 2 至第 9 章各表。
 
@@ -303,9 +303,9 @@
 
 ### 1.5 按情况选择方式
 
-**情况 A · 贵方可提供只读库。** 全部对象走方式甲。表名列名与本页一致则直接配置;不一致则由我方按对象登记字段映射,或请贵方建视图对齐。这是覆盖最全、约束最少的方案。
+**情况 A · 第三方可提供只读库。** 全部对象走方式甲。表名列名与本页一致则直接配置;不一致则由我方按对象登记字段映射,或请第三方建视图对齐。这是覆盖最全、约束最少的方案。
 
-**情况 B · 贵方只能主动推送。** §1.2.1 中标「已开通」的 20 个对象可直接联调,**含周转类与仓储制造类指标所需的出入库流水**——推送时声明 `X-Mdp-Contract-Version: v2` 并填 `BizDocType` 即可,不需要我方扩展契约(见 §1.2.2)。
+**情况 B · 第三方只能主动推送。** §1.2.1 中标「已开通」的 20 个对象可直接联调,**含周转类与仓储制造类指标所需的出入库流水**——推送时声明 `X-Mdp-Contract-Version: v2` 并填 `BizDocType` 即可,不需要我方扩展契约(见 §1.2.2)。
 
 本情况下真正需要另行安排的是两类:
 
@@ -314,11 +314,11 @@
 
 第 2 类中**销售订单头与交货计划的影响面最大**:前者缺失则产销协同的评审周期与满足率算不出,后者缺失则供应协同两个指标、采购执行物料交货周期与「采购交期延误」监控规则同时失效。
 
-**情况 C · 贵方只能提供查询接口。** 走方式乙,按 §1.3 逐对象约定,并预留联调时间。
+**情况 C · 第三方只能提供查询接口。** 走方式乙,按 §1.3 逐对象约定,并预留联调时间。
 
-**情况 D · 数据分散在多套系统。** 按系统分别对接,我方在中台按业务键合并。请在联调清单上注明每个对象由哪套系统出、走哪种方式。
+**情况 D · 数据分散在多套系统。** 按系统分别对接,我方在数据服务层按业务键合并。请在联调清单上注明每个对象由哪套系统出、走哪种方式。
 
-**情况 E · 某对象贵方完全没有。** 在联调清单上标注「无此业务」,对应指标从验收范围移除,见业务页第 11 章的缺数影响对照。
+**情况 E · 某对象第三方完全没有。** 在联调清单上标注「无此业务」,对应指标从验收范围移除,见业务页第 11 章的缺数影响对照。
 
 ---
 
@@ -608,7 +608,7 @@
 
 **物料交货周期的收货日期**取 `mdp_std_delivery_result.receipt_date` 与 `mdp_std_s4_iqc.receipt_date` 两者按采购订单行分组后的**最晚一次**,见 §10.4。两个来源提供其一即可,都提供则取较晚者。
 
-**注意**:本模块当前没有现成的外部对接模板,需按贵方实际情况一次性约定,见业务页 §6.4 与本页 §1.5。
+**注意**:本模块当前没有现成的外部对接模板,需按第三方实际情况一次性约定,见业务页 §6.4 与本页 §1.5。
 
 ---
 
@@ -642,7 +642,7 @@
 
 **审核与作废标记要求**:出入库类算式统一过滤 `hzyn=0 AND zfyn=0 AND shyn=1`(未汇总、未作废、已审核);任务与订单头过滤 `zf=0 AND shyn=1`。**未审核的单据不参与任何指标计算**。
 
-贵方走方式甲时,上述语义随单据一并读取。走方式丙时,业务类型与标记位于单据头而当前无推送对象,处理方式见 §1.5 情况 B。
+第三方走方式甲时,上述语义随单据一并读取。走方式丙时,业务类型与标记位于单据头而当前无推送对象,处理方式见 §1.5 情况 B。
 
 ### 7.2 来料仓储业务明细
 
@@ -678,7 +678,7 @@
 
 ### 7.5 产线运行(本期不纳入)
 
-`LineStatusDet` / `LineRunRestDet`:产线、日期、运行时长、停机时长。入站实体已登记但处于停用状态,贴源表已建,**尚无转换与指标算式**。对应的「生产效率」指标本期不纳入,贵方无需为此准备数据。
+`LineStatusDet` / `LineRunRestDet`:产线、日期、运行时长、停机时长。入站实体已登记但处于停用状态,贴源表已建,**尚无转换与指标算式**。对应的「生产效率」指标本期不纳入,第三方无需为此准备数据。
 
 ---
 
@@ -718,7 +718,7 @@
 
 该指标不走贴源表,而是直接调用源端的存货总账报表函数取「月均库存金额」与「月出库成本」,按上一个完整月计算。**这是唯一按成本口径计算的周转指标**,也是唯一没有贴源表的指标。
 
-若贵方源端无等价的存货总账报表,该指标回落到按 `mdp_std_inv_trans` 的「结存 × 单价 ÷ 出库量 × 单价」计算;单价缺失时退化为数量口径。
+若第三方源端无等价的存货总账报表,该指标回落到按 `mdp_std_inv_trans` 的「结存 × 单价 ÷ 出库量 × 单价」计算;单价缺失时退化为数量口径。
 
 ---
 
@@ -873,7 +873,7 @@
 
 **二,业务依据按指标逐个登记。** 未登记的指标点开会提示尚未登记,覆盖范围见业务页 §8.2。新增可下钻指标需在我方登记,不是配置项。
 
-**禁止**让看板直连贵方接口或数据库。数据必须先入贴源、跑转换、生成指标。
+**禁止**让看板直连第三方接口或数据库。数据必须先入贴源、跑转换、生成指标。
 
 ---
 
@@ -882,7 +882,7 @@
 每个对象请技术同时交付并逐项确认:
 
 1. **入站方式确定**:方式甲的源表或视图名 / 方式乙的接口路径与返回结构 / 方式丙的 `entityCode`。走方式丙前先确认该对象已开通(未开通时 `POST` 与 `GET .../schema` 均返回 403)。
-2. **源字段可取**:本页各表「源字段」列逐项核对。方式甲缺列时贵方建视图补齐;方式丙列名不同时由我方登记字段映射,先调 `GET .../schema` 取准确清单。
+2. **源字段可取**:本页各表「源字段」列逐项核对。方式甲缺列时第三方建视图补齐;方式丙列名不同时由我方登记字段映射,先调 `GET .../schema` 取准确清单。
 3. **样例数据**:方式甲与乙提供增量列与业务键的样例行;方式丙提供带时区的 `sourceUpdatedAt` 样例,并核对服务端计算出的业务键。
 4. **贴源与标准层非空**:入站后确认 `mdp_stg_*` 有行、对应 `mdp_std_*` 与 `dwd_*` 的关键栏位非空。
 5. **指标有值**:跑完模块转换后,确认 `ado_s9_kpi_value_l1_day` 中该模块当日有值。**这一项是真正的验收依据**——第 1 至 4 项全过但本项不过,说明字段虽然进来了但不满足算式要求(最常见的是缺日期、缺业务类型、缺审核标记)。

BIN
doc/plan/系统集成/Ai-DOP第三方系统集成指南.docx


+ 19 - 19
doc/plan/系统集成/Ai-DOP第三方系统集成指南.md

@@ -4,14 +4,14 @@
 |----|------|
 | 读者 | ERP / MES / WMS / QMS / SRM 等第三方系统厂商与实施人员 |
 | 目的 | 说明要对哪些**业务数据**对接;入站三种方式(库同步 / 我方拉取 / **对方推送我方标准 API**)与回写分别怎么做 |
-| 版本 | 2026-09-27(§4.6 按代码契约与中台登记全量核对 `entityCode`,更正 6 个错误编码;前版 2026-09-22) |
+| 版本 | 2026-09-27(§4.6 按代码契约与数据服务层登记全量核对 `entityCode`,更正 6 个错误编码;前版 2026-09-22) |
 | 范围 | 入站(第三方 → Ai-DOP)与回写(Ai-DOP → 第三方) |
 
 本文只讲对接约定,不包含数据库密码、Token、连接串等凭据。凭据须走现场带外方式交付。
 
 ## 0. 三种业务模式,没有模式开关
 
-同一套数据中台按租户登记“每个标准对象由哪个来源提供”。不存在“对接模式”配置项。
+同一套数据服务层按租户登记“每个标准对象由哪个来源提供”。不存在“对接模式”配置项。
 
 | 模式 | 谁提供 S5–S7 | 其余模块 |
 |---|---|---|
@@ -28,7 +28,7 @@
 ### 1.1 Ai-DOP 不在页面打开时直连你们的系统
 
 九宫格看板、各模块详情看板、智慧诊断、ChatBI **都只读 Ai-DOP 本库已经同步并转换好的数据**。  
-第三方系统的职责是:按本指南把业务数据送到 Ai-DOP 数据中台。中台刷新完成后,上述功能即可使用。
+第三方系统的职责是:按本指南把业务数据送到 Ai-DOP 数据服务层。该层刷新完成后,上述功能即可使用。
 
 ```text
 第三方系统(ERP / MES / WMS / QMS / SRM)
@@ -84,11 +84,11 @@ Ai-DOP 贴源层 → 标准层 → 宽表 / 日度指标
    - **业务主键**(见第 5 节「业务键」列,可用视图拼出)
    - **增量时间字段**(建议 `UpdateTime` / `update_time`,类型 `datetime`)
 4. 建议提供 `tenant_id` / `TenantId`(或工厂/账套字段),便于多租户隔离。
-5. 不要求与 Ai-DOP 表名完全一致:可提供**视图**,把贵司字段映射成下表「建议源表/视图列」。
+5. 不要求与 Ai-DOP 表名完全一致:可提供**视图**,把第三方字段映射成下表「建议源表/视图列」。
 
 ### 2.2 Ai-DOP 侧如何对接(实施配置,不改业务代码)
 
-在数据中台登记:
+在数据服务层登记:
 
 1. **数据源** `mdp_source`
    - `source_type = DB`
@@ -237,7 +237,7 @@ Authorization: Bearer {约定 Token}
 ```
 
 响应形如 `{ "code": 0, "data": { "list": [ ... ] } }`。  
-切真源时,只需把数据源上的 `api_base_url` / Token 改成贵司地址,**业务对象编码不用改**。
+切真源时,只需把数据源上的 `api_base_url` / Token 改成第三方地址,**业务对象编码不用改**。
 
 **当前状态**:拉取引擎与各业务对象的配置均已具备,但现网已登记的接口地址指向上述内置 Mock,**尚无对接真实第三方接口的现场案例**。选用方式乙需在项目排期中预留接口联调时间;对象覆盖面最广、约束最少的仍是方式甲。
 
@@ -245,10 +245,10 @@ Authorization: Bearer {约定 Token}
 
 ## 4. 方式丙:调用我方标准 API 推送(`API_INBOUND`)
 
-适合:**全部业务数据由贵司系统主动推送**,不开放只读库、也不提供给我方拉取的查询接口。  
+适合:**全部业务数据由第三方系统主动推送**,不开放只读库、也不提供给我方拉取的查询接口。  
 这是全量推送模式下的**标准主入站方式**。路径不带 `/v1/`:`/api/mdp/inbound/{entityCode}`。
 
-我方为每个「对接方 × 系统」签发 `AccessKey` / `AccessSecret`(只带外交付一次),凭证绑定租户。贵司用签名头调用;未开通的对象返回 403。
+我方在对接向导的推送授权步骤,为每个「对接方 × 系统」一次签发 `AccessKey` 与 `AccessSecret`。`AccessSecret` 只在签发成功时回显一次,须当场带外交付,关闭页面后无法再查看。凭证绑定签发时的租户,推进来的数据归入该租户。同一套凭据可继续授权给更多业务对象,此时只追加授权、不再生成新密钥。未开通的对象返回 403。
 
 ### 4.1 统一入口与配套接口
 
@@ -322,8 +322,8 @@ X-Signature:        Base64( HMAC-SHA256(secret, 签名串) )
 |----|------|
 | 列表 | `data.list`,或根 JSON 数组 |
 | 契约版本 | 请求头 `X-Mdp-Contract-Version`。不传或非 `v2` 时按 **v1** 收数,字段名与改造前一致。显式 `v2` 时 `S5_INVENTORY_TXN` 要求 `BizDocType`,`MDM_EMPLOYEE_HEADCOUNT` 要求岗位与在职状态。v1 仍是默认,弃用时间另行通知对接方,不会静默改必填 |
-| `S5_INVENTORY_TXN` 阶段码 | v2 可选 `TransType`(12 个中立阶段码之一,非法值整批 **400**)、`SrcTransType`(贵司原始事务码,未映射时进隔离表)、`Location`(库位)。不传 `TransType` 时由 `BizDocType` 映射阶段码,**不做库位角色推导**。`BizDocType` 必须是已登记枚举,非法值整批 **400** |
-| 字段名 | 对外用规范字段;可用 `GET .../schema` 取清单。贵司列名不同时由我方登记映射,**转换层不改** |
+| `S5_INVENTORY_TXN` 阶段码 | v2 可选 `TransType`(12 个中立阶段码之一,非法值整批 **400**)、`SrcTransType`(第三方原始事务码,未映射时进隔离表)、`Location`(库位)。不传 `TransType` 时由 `BizDocType` 映射阶段码,**不做库位角色推导**。`BizDocType` 必须是已登记枚举,非法值整批 **400** |
+| 字段名 | 对外用规范字段;可用 `GET .../schema` 取清单。第三方列名不同时由我方登记映射,**转换层不改** |
 | `bizKey` | **我方按业务键字段计算**(多字段用 `#` 连接,规则同第 5 节)。对方可附带作校验,不一致则该行拒绝 |
 | `sourceUpdatedAt` | **必填**,ISO 8601 **必须带时区**(`+08:00` 或 `Z`);无偏移则该行拒绝 |
 | `sourceVersion` | 可选;提供时优先于时间戳做乱序裁决 |
@@ -385,7 +385,7 @@ HTTP/1.1 202 Accepted
 
 现场按白名单开通。未开通 → 403。规范字段以 `GET .../schema` 为准。目录 BOM(`MDM_BOM_*`)首期不开。
 
-**状态列先看**:`entityCode` 逐个开通。「**已开通**」=当前即可推送与取 schema;「**待登记**」=代码层已有字段契约,但我方尚未在数据中台登记该对象或签发授权,现在 `POST` 与 `GET .../schema` **都会返回 403**。排联调计划前请先与我方确认本次要开通哪些对象;待登记对象在开通前可先走第 2、3 章的甲/乙入站,业务内容不变。
+**状态列先看**:`entityCode` 逐个开通。「**已开通**」=当前即可推送与取 schema;「**待登记**」=代码层已有字段契约,但我方尚未在数据服务层登记该对象或签发授权,现在 `POST` 与 `GET .../schema` **都会返回 403**。排联调计划前请先与我方确认本次要开通哪些对象;待登记对象在开通前可先走第 2、3 章的甲/乙入站,业务内容不变。
 
 下表按 2026-09-27 的代码契约与 `mdp_entity` 登记状态核对。**编码请严格照抄,不要按业务名自行推断**——本节此前列过若干拼法相近但并不存在的编码,已在下方「已废弃编码」中列明。
 
@@ -434,14 +434,14 @@ HTTP/1.1 202 Accepted
 | `S4_IQC_RECEIPT` | 应为 `S4_IQC`(已开通)。同名编码仅作为方式甲的抽取实体存在 |
 | `S6_REPORT` | 应为 `S6_REPORT_TXN`(已开通)。同名编码仅作为方式甲的抽取实体存在 |
 | `S7_FQC_RESULT` | 应为 `S7_FQC_TASK_TXN`(已开通)。同名编码仅作为方式甲的抽取实体存在 |
-| `S4_RECEIPT_CONFIRM` | 不是入站编码,只是第 6 章的**回写动作编码**(我方推给贵司) |
-| `S1_SALES_ORDER`、`S1_CONTRACT_REVIEW`、`S1_SHIPPING_PLAN`、`S1_ASN_SHIPPER`、`S2_OPERATION_SCHEDULE`、`S3_SUPPLY_DEMAND`、`S3_PURCHASE_REQUEST`、`S3_DELIVERY_SCHEDULE`、`S3_INVENTORY_ONHAND`、`S5_PURCHASE_RECEIPT`、`S5_PRODUCTION_ISSUE`、`S5_PRODUCTION_RETURN`、`S5_OUTSOURCE_ISSUE`、`S5_IQC_RESULT`、`S6_IPQC_INSPECTION`、`S6_LINE_RUNTIME`、`S7_PRODUCTION_RECEIPT`、`S7_QUALITY_RETURN` | 这些业务对象**没有推送契约**,只能走方式甲或乙。其中部分编码在中台存在同名的方式甲抽取实体,但不接受推送 |
+| `S4_RECEIPT_CONFIRM` | 不是入站编码,只是第 6 章的**回写动作编码**(我方推给第三方) |
+| `S1_SALES_ORDER`、`S1_CONTRACT_REVIEW`、`S1_SHIPPING_PLAN`、`S1_ASN_SHIPPER`、`S2_OPERATION_SCHEDULE`、`S3_SUPPLY_DEMAND`、`S3_PURCHASE_REQUEST`、`S3_DELIVERY_SCHEDULE`、`S3_INVENTORY_ONHAND`、`S5_PURCHASE_RECEIPT`、`S5_PRODUCTION_ISSUE`、`S5_PRODUCTION_RETURN`、`S5_OUTSOURCE_ISSUE`、`S5_IQC_RESULT`、`S6_IPQC_INSPECTION`、`S6_LINE_RUNTIME`、`S7_PRODUCTION_RECEIPT`、`S7_QUALITY_RETURN` | 这些业务对象**没有推送契约**,只能走方式甲或乙。其中部分编码在数据服务层存在同名的方式甲抽取实体,但不接受推送 |
 
 > **无推送通道的影响面**:销售订单头缺失则产销协同评审周期与满足率算不出;交货计划(`S3_DELIVERY_SCHEDULE`)缺失则供应协同两个指标、采购执行物料交货周期与「采购交期延误」监控规则同时失效;工序级日计划缺失则订单排程周期、满足率与排程人效算不出。这三项若在本期范围内,务必安排方式甲或乙。
 
 > **生产领料、生产入库、销售出库、质量退货不需要单独编码**:它们是出入库流水的业务类型,统一走 `S5_INVENTORY_TXN` 的 v2 契约,用 `BizDocType` 区分。
 
-**周转类指标的前置条件**:`S1_L1_004` / `S2_L1_004` / `S3_L1_004` / `S4_L1_004` / `S9_L1_005` 等周转格子由「期初 + 流水」在中台自算结存,结存快照(`S7_FINISHED_ONHAND` 等)只作校验。只推结存不推流水与期初时,这些格子算不出来。
+**周转类指标的前置条件**:`S1_L1_004` / `S2_L1_004` / `S3_L1_004` / `S4_L1_004` / `S9_L1_005` 等周转格子由「期初 + 流水」在数据服务层自算结存,结存快照(`S7_FINISHED_ONHAND` 等)只作校验。只推结存不推流水与期初时,这些格子算不出来。
 
 ### 4.7 联调脚本
 
@@ -451,7 +451,7 @@ HTTP/1.1 202 Accepted
 python tools/mock/inbound/push_demo.py --base-url https://{aidop-host} --access-key {key} --secret {secret} --entity S1_SALES_ORDER_ENTRY
 ```
 
-内置:正常推送、缺幂等键、改一字节 body、过期时间戳、nonce 重放、同 Key 重放、同 Key 不同体、partial、stale、快照 open→两批→commit。贵司对接验收至少覆盖 **partial** 与 **同 Key 重放**。
+内置:正常推送、缺幂等键、改一字节 body、过期时间戳、nonce 重放、同 Key 重放、同 Key 不同体、partial、stale、快照 open→两批→commit。第三方对接验收至少覆盖 **partial** 与 **同 Key 重放**。
 
 ---
 
@@ -476,7 +476,7 @@ python tools/mock/inbound/push_demo.py --base-url https://{aidop-host} --access-
 
 图例:
 
-- **入站**:甲/乙由 Ai-DOP 来拉;丙由贵司按第 4 节 `entityCode` 推送
+- **入站**:甲/乙由 Ai-DOP 来拉;丙由第三方按第 4 节 `entityCode` 推送
 - **回写**:见第 6 节,不在本表重复展开
 
 ### 5.1 主数据(S0 / 供应共用)
@@ -595,7 +595,7 @@ python tools/mock/inbound/push_demo.py --base-url https://{aidop-host} --access-
 
 ### 5.9 ERP 库存 / 报工账(T8 等价;优先推送)
 
-若现场 ERP 类似 T8(库存单据 + 报工),**优先按第 4 节推送**等价实体:`S5_INVENTORY_TXN`(流水)、`S5_INVENTORY_OPENING_BALANCE`(期初)、`S7_FINISHED_ONHAND`(结存校验)、`S7_FINISHED_OPENING_BALANCE`(成品期初)。结存由「期初 + 流水」在中台自算,结存快照作校验。  
+若现场 ERP 类似 T8(库存单据 + 报工),**优先按第 4 节推送**等价实体:`S5_INVENTORY_TXN`(流水)、`S5_INVENTORY_OPENING_BALANCE`(期初)、`S7_FINISHED_ONHAND`(结存校验)、`S7_FINISHED_OPENING_BALANCE`(成品期初)。结存由「期初 + 流水」在数据服务层自算,结存快照作校验。  
 无推送条件时,仍可用 **DB 只读** 对接下列对象,主要用于 **S5–S7 看板指标**,不替代上面的业务明细。
 
 | 业务名称 | 建议来源 | DB 源表 | 业务键 |
@@ -664,7 +664,7 @@ python tools/mock/inbound/push_demo.py --base-url https://{aidop-host} --access-
 6. **S6**:报工、过程检验  
 7. **S7**:成品入库、成品检验  
 
-主对象入站并完成中台刷新后,九宫格、模块详情看板、智慧诊断 KPI 树、ChatBI 指标问答即可消费。  
+主对象入站并完成数据服务层刷新后,九宫格、模块详情看板、智慧诊断 KPI 树、ChatBI 指标问答即可消费。  
 智慧诊断的「业务依据」还依赖对应明细是否同步;只接指标、不接明细时,诊断能出红黄绿,但可能点不开依据。
 
 ### 7.3 联调检查清单
@@ -700,7 +700,7 @@ python tools/mock/inbound/push_demo.py --base-url https://{aidop-host} --access-
 
 **业务结果**
 
-- [ ] 中台刷新后,对应模块详情看板有数  
+- [ ] 数据服务层刷新后,对应模块详情看板有数  
 - [ ] 九宫格对应格子有指标  
 - [ ] 智慧诊断能展开该模块 KPI 树  
 

+ 64 - 13
doc/plan/系统集成/_gen_integration_guide_docx.py

@@ -8,16 +8,18 @@ from pathlib import Path
 
 from docx import Document
 from docx.enum.section import WD_ORIENT
-from docx.enum.text import WD_ALIGN_PARAGRAPH, WD_LINE_SPACING
+from docx.enum.text import WD_ALIGN_PARAGRAPH, WD_BREAK, WD_LINE_SPACING
+from docx.oxml import OxmlElement
 from docx.oxml.ns import qn
-from docx.shared import Cm, Pt
+from docx.shared import Cm, Pt, RGBColor
 
 BASE = Path(__file__).resolve().parent
 
+# (Markdown, Word, 横向排版, 一级标题前分页)
 JOBS = [
-    ("Ai-DOP第三方系统集成指南.md", "Ai-DOP第三方系统集成指南.docx", False),
-    ("Ai-DOP第三方对接业务数据详表-业务页.md", "Ai-DOP第三方对接业务数据详表-业务页.docx", False),
-    ("Ai-DOP第三方对接业务数据详表-技术页.md", "Ai-DOP第三方对接业务数据详表-技术页.docx", True),
+    ("Ai-DOP第三方系统集成指南.md", "Ai-DOP第三方系统集成指南.docx", False, False),
+    ("Ai-DOP第三方对接业务数据详表-业务页.md", "Ai-DOP第三方对接业务数据详表-业务页.docx", True, True),
+    ("Ai-DOP第三方对接业务数据详表-技术页.md", "Ai-DOP第三方对接业务数据详表-技术页.docx", True, False),
 ]
 
 
@@ -53,12 +55,16 @@ def add_title(doc: Document, text: str):
     set_run_font(run, "黑体", "SimHei", 18, True)
 
 
-def add_heading(doc: Document, text: str, size: float):
-    p = doc.add_paragraph()
+def add_heading(doc: Document, text: str, size: float, level: int = 1):
+    # 套内置 Heading 样式再覆盖字体:大纲级别由样式带来,Word 的导航窗格与
+    # 自动目录才认得这些标题;只手工设字号的话它们仍是正文级。
+    p = doc.add_paragraph(style=f"Heading {level}")
     p.paragraph_format.space_before = Pt(12)
     p.paragraph_format.space_after = Pt(6)
+    p.paragraph_format.first_line_indent = Pt(0)
     run = p.add_run(strip_md(text))
     set_run_font(run, "黑体", "SimHei", size, True)
+    run.font.color.rgb = RGBColor(0, 0, 0)
 
 
 def add_rich_paragraph(doc: Document, text: str, *, indent=True, left_indent_cm=0.0, size=12):
@@ -141,7 +147,40 @@ def set_landscape(doc: Document):
     sec.page_height = Cm(21.0)
 
 
-def build_docx(md_text: str, landscape: bool) -> Document:
+def add_page_break(doc: Document):
+    p = doc.add_paragraph()
+    p.paragraph_format.first_line_indent = Pt(0)
+    p.paragraph_format.space_after = Pt(0)
+    p.add_run().add_break(WD_BREAK.PAGE)
+
+
+def add_toc_field(doc: Document):
+    """插入 Word 自动目录域,取代正文里的静态目录表。
+
+    域首次打开时是空的,需在 Word 中按 Ctrl+A、F9 更新一次;更新后条目与页码
+    由标题的大纲级别自动生成,改章节不必再回来手工对目录。
+    """
+    p = doc.add_paragraph()
+    p.paragraph_format.first_line_indent = Pt(0)
+    run = p.add_run()
+
+    begin = OxmlElement("w:fldChar")
+    begin.set(qn("w:fldCharType"), "begin")
+    instr = OxmlElement("w:instrText")
+    instr.set(qn("xml:space"), "preserve")
+    instr.text = r' TOC \o "1-3" \h \z \u '
+    separate = OxmlElement("w:fldChar")
+    separate.set(qn("w:fldCharType"), "separate")
+    placeholder = OxmlElement("w:t")
+    placeholder.text = "请在 Word 中全选后按 F9 更新目录。"
+    end = OxmlElement("w:fldChar")
+    end.set(qn("w:fldCharType"), "end")
+
+    for node in (begin, instr, separate, placeholder, end):
+        run._r.append(node)
+
+
+def build_docx(md_text: str, landscape: bool, page_break_before_h1: bool = False) -> Document:
     doc = Document()
     sec = doc.sections[0]
     if landscape:
@@ -186,15 +225,27 @@ def build_docx(md_text: str, landscape: bool) -> Document:
             i += 1
             continue
         if stripped.startswith("## "):
-            add_heading(doc, stripped[3:].strip(), 14)
+            title = stripped[3:].strip()
+            # 「目录」整节换成 Word 自动目录域:域自己带「目录」标题,正文里不再
+            # 出条标题,也不分页,让它跟在修订记录后面同页。Markdown 里的静态
+            # 目录表保留给直接读 .md 的人看,转 Word 时跳过。
+            if title == "目录":
+                add_toc_field(doc)
+                i += 1
+                while i < len(lines) and not lines[i].strip().startswith("## "):
+                    i += 1
+                continue
+            if page_break_before_h1:
+                add_page_break(doc)
+            add_heading(doc, title, 14, level=1)
             i += 1
             continue
         if stripped.startswith("### "):
-            add_heading(doc, stripped[4:].strip(), 12)
+            add_heading(doc, stripped[4:].strip(), 12, level=2)
             i += 1
             continue
         if stripped.startswith("#### "):
-            add_heading(doc, stripped[5:].strip(), 11)
+            add_heading(doc, stripped[5:].strip(), 11, level=3)
             i += 1
             continue
 
@@ -222,11 +273,11 @@ def build_docx(md_text: str, landscape: bool) -> Document:
 
 
 def main():
-    for src_name, out_name, landscape in JOBS:
+    for src_name, out_name, landscape, page_break_before_h1 in JOBS:
         src = BASE / src_name
         out = BASE / out_name
         md = src.read_text(encoding="utf-8")
-        doc = build_docx(md, landscape)
+        doc = build_docx(md, landscape, page_break_before_h1)
         doc.save(out)
         print(f"OK: {out}")
 

+ 17 - 4
server/Admin.NET.Core/Service/Tenant/SysTenantService.cs

@@ -311,12 +311,25 @@ public class SysTenantService : IDynamicApiController, ITransient
         try
         {
             var initType = Type.GetType("Admin.NET.Plugin.AiDOP.Infrastructure.AidopTenantInitService, Admin.NET.Plugin.AiDOP");
-            if (initType != null)
+            if (initType == null)
+            {
+                Log.Error("AidopTenantInitService 类型未解析,租户 {TenantId} 的基线未执行", tenantId);
+            }
+            else
             {
                 var initSvc = App.GetService(initType);
-                var method = initType.GetMethod("InitTenantBaselineAsync");
-                if (initSvc != null && method != null)
-                    await (Task)method.Invoke(initSvc, new object[] { tenantId })!;
+                if (initSvc == null)
+                {
+                    Log.Error("AidopTenantInitService 未注册到容器,租户 {TenantId} 的基线未执行", tenantId);
+                }
+                else
+                {
+                    var method = initType.GetMethod("InitTenantBaselineAsync");
+                    if (method == null)
+                        Log.Error("InitTenantBaselineAsync 方法未找到,租户 {TenantId} 的基线未执行", tenantId);
+                    else
+                        await (Task)method.Invoke(initSvc, new object[] { tenantId })!;
+                }
             }
         }
         catch (Exception ex)

+ 3 - 3
server/Admin.NET.Web.Entry/Admin.NET.Web.Entry.csproj

@@ -11,9 +11,9 @@
     <GenerateSatelliteAssembliesForCore>true</GenerateSatelliteAssembliesForCore>
     <Copyright>Admin.NET</Copyright>
     <Description>Admin.NET 通用权限开发平台</Description>
-    <AssemblyVersion>1.0.583</AssemblyVersion>
-    <FileVersion>1.0.583</FileVersion>
-    <Version>1.0.583</Version>
+    <AssemblyVersion>1.0.584</AssemblyVersion>
+    <FileVersion>1.0.584</FileVersion>
+    <Version>1.0.584</Version>
   </PropertyGroup>
 
   <ItemGroup>

+ 81 - 0
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/DataPlatform/InboundGrantAndTenantBaselineGuardTests.cs

@@ -0,0 +1,81 @@
+using Xunit;
+
+namespace Admin.NET.Plugin.AiDOP.Tests.DataPlatform;
+
+/// <summary>
+/// 推送凭据与租户基线的源码守卫。测试项目不连库,行为判断见
+/// <see cref="InboundGrantIssuanceTests"/>;这里钉住调用侧不会把两张表拆开、
+/// 也不会在解析失败时静默跳过。
+/// </summary>
+public class InboundGrantAndTenantBaselineGuardTests
+{
+    private static string RepoFile(params string[] parts)
+    {
+        var dir = new DirectoryInfo(AppContext.BaseDirectory);
+        while (dir != null && !File.Exists(Path.Combine(dir.FullName, "AGENTS.md")))
+            dir = dir.Parent;
+        Assert.NotNull(dir);
+        return File.ReadAllText(Path.Combine(new[] { dir!.FullName }.Concat(parts).ToArray()));
+    }
+
+    [Fact]
+    public void CreateGrant_WritesIdentityAndGrantInOneTransaction_AndClearsCache()
+    {
+        var source = RepoFile("server", "Plugins", "Admin.NET.Plugin.AiDOP", "DataPlatform", "MdpSourceMappingService.cs");
+        var method = SliceMethod(source, "CreateGrant");
+
+        var begin = method.IndexOf("BeginTran()", StringComparison.Ordinal);
+        var commit = method.IndexOf("CommitTran()", StringComparison.Ordinal);
+        Assert.True(begin >= 0 && commit > begin, "新建身份与授权行必须在同一事务里");
+
+        var body = method[begin..commit];
+        Assert.Contains("new SysOpenAccess", body);
+        Assert.Contains("BindTenantId = tenant", body);
+        Assert.Contains("new MdpInboundGrant", body);
+        Assert.Contains("CacheConst.KeyOpenAccess", method);
+        Assert.Contains("RejectCrossTenant", method);
+    }
+
+    [Fact]
+    public void TenantCreate_LogsEveryFailedBaselineResolution()
+    {
+        var source = RepoFile("server", "Admin.NET.Core", "Service", "Tenant", "SysTenantService.cs");
+        Assert.Contains("类型未解析", source);
+        Assert.Contains("未注册到容器", source);
+        Assert.Contains("方法未找到", source);
+    }
+
+    [Fact]
+    public void ReinitEndpoint_RequiresSuperAdmin_AndExplicitTenant()
+    {
+        var source = RepoFile("server", "Plugins", "Admin.NET.Plugin.AiDOP", "Infrastructure", "AidopTenantBaselineController.cs");
+        var method = SliceMethod(source, "Reinit");
+        Assert.Contains("RequireSuperAdmin".Replace("RequireSuperAdmin", "SuperAdmin"), method);
+        Assert.Contains("ErrorCodeEnum.SA001", method);
+        Assert.Contains("input.TenantId", method);
+        Assert.DoesNotContain("_user.TenantId", method);
+    }
+
+    [Fact]
+    public void BaselineSeed_SkipsRowsThatAlreadyExist()
+    {
+        var source = RepoFile("server", "Plugins", "Admin.NET.Plugin.AiDOP", "Infrastructure", "AidopTenantInitService.cs");
+        var guards = source.Split("if (exists) continue").Length - 1;
+        Assert.True(guards >= 3, $"存在性检查不足,重跑可能重复插入:{guards}");
+    }
+
+    private static string SliceMethod(string source, string name)
+    {
+        var at = source.IndexOf(name + "(", StringComparison.Ordinal);
+        Assert.True(at >= 0, $"找不到方法 {name}");
+        var open = source.IndexOf('{', at);
+        var depth = 0;
+        for (var end = open; end < source.Length; end++)
+        {
+            if (source[end] == '{') depth++;
+            else if (source[end] == '}' && --depth == 0)
+                return source[open..(end + 1)];
+        }
+        throw new InvalidOperationException($"方法 {name} 没有闭合");
+    }
+}

+ 54 - 0
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/DataPlatform/InboundGrantIssuanceTests.cs

@@ -0,0 +1,54 @@
+using Admin.NET.Plugin.AiDOP.DataPlatform.Inbound;
+using Xunit;
+
+namespace Admin.NET.Plugin.AiDOP.Tests.DataPlatform;
+
+public class InboundGrantIssuanceTests
+{
+    private static string NeverCalled() => throw new InvalidOperationException("不应签发新凭据");
+
+    [Fact]
+    public void EmptyKey_CreatesIdentity_AndBothTokens()
+    {
+        var plan = InboundGrantIssuance.Plan("", 11, 0, false, () => "key-1", () => "secret-1");
+
+        Assert.Equal(InboundGrantIssuanceKind.CreateIdentity, plan.Kind);
+        Assert.Equal("key-1", plan.AccessKey);
+        Assert.Equal("secret-1", plan.AccessSecret);
+    }
+
+    [Fact]
+    public void SameTenantKey_ReusesIdentity_WithoutNewSecret()
+    {
+        var plan = InboundGrantIssuance.Plan("existing", 11, boundTenantId: 11, identityExists: true, NeverCalled, NeverCalled);
+
+        Assert.Equal(InboundGrantIssuanceKind.ReuseIdentity, plan.Kind);
+        Assert.Equal("existing", plan.AccessKey);
+        Assert.Equal("", plan.AccessSecret);
+    }
+
+    [Fact]
+    public void OtherTenantKey_IsRejected()
+    {
+        var plan = InboundGrantIssuance.Plan("existing", 11, boundTenantId: 22, identityExists: true, NeverCalled, NeverCalled);
+
+        Assert.Equal(InboundGrantIssuanceKind.RejectCrossTenant, plan.Kind);
+    }
+
+    [Fact]
+    public void UnknownKey_IsRejected()
+    {
+        var plan = InboundGrantIssuance.Plan("missing", 11, boundTenantId: 0, identityExists: false, NeverCalled, NeverCalled);
+
+        Assert.Equal(InboundGrantIssuanceKind.RejectUnknownKey, plan.Kind);
+    }
+
+    [Fact]
+    public void Reuse_IsIdempotent_AcrossRepeatedPlans()
+    {
+        var first = InboundGrantIssuance.Plan("existing", 11, 11, true, NeverCalled, NeverCalled);
+        var second = InboundGrantIssuance.Plan("existing", 11, 11, true, NeverCalled, NeverCalled);
+
+        Assert.Equal(first, second);
+    }
+}

+ 32 - 0
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/DataPlatform/NeutralRequiredColumnsGuardTests.cs

@@ -29,6 +29,38 @@ public class NeutralRequiredColumnsGuardTests
         }
     }
 
+    /// <summary>
+    /// approved_time 是 S5_L1_001、S7_L1_001 唯一的时间基准。2026-09 指标 SQL 改用它时没登记成必填列,
+    /// 于是整列为空也没人报错,S5/S7 全租户静默掉数。此断言锁住这条契约。
+    /// </summary>
+    [Fact]
+    public void ApprovedTime_IsRequiredColumn()
+    {
+        Assert.Contains("approved_time", NeutralRequiredColumns.InvTrans);
+        Assert.Contains(NeutralRequiredColumns.For("INV_TRANS"), c => c.Name == "approved_time");
+    }
+
+    /// <summary>
+    /// 每个写 mdp_std_inv_trans 的地方都必须在 Channels 里登记。
+    /// 漏登记的写入口不受必填列守卫约束:ASN 发运投影就是这么带着空 approved_time 上线的。
+    /// </summary>
+    [Fact]
+    public void EveryInvTransWriter_IsRegisteredAsChannel()
+    {
+        var plugin = Path.Combine(RepoRoot(), "server", "Plugins", "Admin.NET.Plugin.AiDOP");
+        var registered = NeutralRequiredColumns.Channels
+            .Select(c => Path.Combine(plugin, c.RelativePath))
+            .ToHashSet(StringComparer.OrdinalIgnoreCase);
+
+        var unregistered = Directory
+            .EnumerateFiles(plugin, "*.cs", SearchOption.AllDirectories)
+            .Where(f => File.ReadAllText(f).Contains("INSERT INTO mdp_std_inv_trans", StringComparison.Ordinal))
+            .Where(f => !registered.Contains(f))
+            .ToList();
+
+        Assert.True(unregistered.Count == 0, "未登记为必填列通道: " + string.Join(", ", unregistered));
+    }
+
     private static string RepoRoot()
     {
         var dir = new DirectoryInfo(AppContext.BaseDirectory);

+ 46 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/Inbound/InboundGrantIssuance.cs

@@ -0,0 +1,46 @@
+namespace Admin.NET.Plugin.AiDOP.DataPlatform.Inbound;
+
+/// <summary>
+/// 推送授权该走哪条路。纯判断,不碰库:调用方按结果决定建身份还是只插授权行。
+/// </summary>
+public enum InboundGrantIssuanceKind
+{
+    /// <summary>未带访问标识,新建身份并签发密钥。</summary>
+    CreateIdentity,
+
+    /// <summary>访问标识属于目标租户,只为新实体加授权行。</summary>
+    ReuseIdentity,
+
+    /// <summary>访问标识在开放接口身份中不存在。</summary>
+    RejectUnknownKey,
+
+    /// <summary>访问标识属于别的租户。用它发授权会把数据写进那个租户。</summary>
+    RejectCrossTenant,
+}
+
+public sealed record InboundGrantIssuancePlan(
+    InboundGrantIssuanceKind Kind,
+    string AccessKey,
+    string AccessSecret);
+
+public static class InboundGrantIssuance
+{
+    public static InboundGrantIssuancePlan Plan(
+        string requestedAccessKey,
+        long tenantId,
+        long boundTenantId,
+        bool identityExists,
+        Func<string> newKey,
+        Func<string> newSecret)
+    {
+        if (string.IsNullOrWhiteSpace(requestedAccessKey))
+            return new InboundGrantIssuancePlan(InboundGrantIssuanceKind.CreateIdentity, newKey(), newSecret());
+
+        var key = requestedAccessKey.Trim();
+        if (!identityExists)
+            return new InboundGrantIssuancePlan(InboundGrantIssuanceKind.RejectUnknownKey, key, "");
+        if (boundTenantId != tenantId)
+            return new InboundGrantIssuancePlan(InboundGrantIssuanceKind.RejectCrossTenant, key, "");
+        return new InboundGrantIssuancePlan(InboundGrantIssuanceKind.ReuseIdentity, key, "");
+    }
+}

+ 62 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/MdpNeutralSourceCleanup.cs

@@ -41,6 +41,10 @@ public sealed class MdpNeutralSourceCleanup : ITransient
             var docFilter = string.IsNullOrEmpty(def.DocType) || !await ColumnExistsAsync(table, "doc_type")
                 ? ""
                 : " AND t.doc_type=@doc";
+
+            if (!await RegisteredSourceHasRowsAsync(tenantId, def.Code, table, docFilter, cancellationToken))
+                continue;
+
             var sql = $"""
                 DELETE t FROM `{table}` t
                 INNER JOIN mdp_tenant_std_source s
@@ -59,6 +63,64 @@ public sealed class MdpNeutralSourceCleanup : ITransient
         return total;
     }
 
+    /// <summary>
+    /// 登记来源在该表一行都没有时,不执行清理。
+    /// <para>
+    /// 清理是「按登记收口」,不是「清空对象」。若登记来源尚未产出任何行,删掉非登记来源就等于
+    /// 把该对象整片清空:指标随即算出 NO_DATA 且全程不报错,看板一夜之间没数(S6/S7 就是这么掉的)。
+    /// 这种情况下真正的问题是登记与数据不一致,须人工核对登记,不能靠重算去「修正」。
+    /// 故此处保留存量并按 <c>PURGE_SKIPPED_EMPTY_SOURCE</c> 告警,宁可留旧数据也不静默清空。
+    /// </para>
+    /// </summary>
+    private async Task<bool> RegisteredSourceHasRowsAsync(
+        long tenantId, string stdObject, string table, string docFilter, CancellationToken cancellationToken)
+    {
+        cancellationToken.ThrowIfCancellationRequested();
+        var rows = await _db.Ado.SqlQueryAsync<SourceRowCount>(
+            $"""
+            SELECT MAX(s.source_system) AS RegisteredSource,
+                   SUM(t.source_system=s.source_system) AS RegisteredRows,
+                   SUM(t.source_system<>s.source_system) AS ForeignRows
+            FROM `{table}` t
+            INNER JOIN mdp_tenant_std_source s
+              ON s.tenant_id=t.tenant_id AND s.std_object=@obj
+            WHERE t.tenant_id=@tenant
+              {docFilter}
+            """,
+            new { tenant = tenantId, obj = stdObject, doc = MdpStdObjectCatalog.Find(stdObject)?.DocType });
+        var stat = rows.FirstOrDefault();
+        if (stat == null || stat.ForeignRows <= 0)
+            return true;
+        if (stat.RegisteredRows > 0)
+            return true;
+
+        _logger.LogError(
+            "中立层来源清理已跳过:登记来源无数据 tenant={Tenant} object={Object} table={Table} 登记来源={Source} 待清理行={Foreign}",
+            tenantId, stdObject, table, stat.RegisteredSource, stat.ForeignRows);
+        await _db.Ado.ExecuteCommandAsync(
+            """
+            INSERT INTO mdp_source_gate_log
+              (tenant_id, std_object, source_system, gate_reason, row_count, sample_keys, sync_batch_id)
+            VALUES (@tenant, @obj, @src, 'PURGE_SKIPPED_EMPTY_SOURCE', @rows, @sample, '')
+            """,
+            new
+            {
+                tenant = tenantId,
+                obj = stdObject,
+                src = stat.RegisteredSource ?? "",
+                rows = stat.ForeignRows,
+                sample = table
+            });
+        return false;
+    }
+
+    private sealed class SourceRowCount
+    {
+        public string? RegisteredSource { get; set; }
+        public long RegisteredRows { get; set; }
+        public long ForeignRows { get; set; }
+    }
+
     private async Task<bool> TableReadyAsync(string table)
     {
         var n = await _db.Ado.SqlQueryAsync<int>(

+ 91 - 14
server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/MdpSourceMappingService.cs

@@ -1,5 +1,8 @@
 using Admin.NET.Core;
+using Admin.NET.Core.Service;
+using Admin.NET.Plugin.AiDOP.DataPlatform.Inbound;
 using Admin.NET.Plugin.AiDOP.Entity.DataPlatform;
+using Yitter.IdGenerator;
 
 namespace Admin.NET.Plugin.AiDOP.DataPlatform;
 
@@ -14,11 +17,13 @@ public class MdpSourceMappingService : IDynamicApiController, ITransient
 {
     private readonly ISqlSugarClient _db;
     private readonly UserManager _user;
+    private readonly SysCacheService _cache;
 
-    public MdpSourceMappingService(ISqlSugarClient db, UserManager user)
+    public MdpSourceMappingService(ISqlSugarClient db, UserManager user, SysCacheService cache)
     {
         _db = db;
         _user = user;
+        _cache = cache;
     }
 
     private long ScopeTenant(long? requested)
@@ -295,22 +300,91 @@ public class MdpSourceMappingService : IDynamicApiController, ITransient
             .FirstAsync() ?? throw Oops.Oh("来源不存在");
         if (!string.Equals(source.SourceType, "API_INBOUND", StringComparison.OrdinalIgnoreCase))
             throw Oops.Oh("只有对方推送来源可以发授权");
-        var key = Convert.ToHexString(System.Security.Cryptography.RandomNumberGenerator.GetBytes(16)).ToLowerInvariant();
+
+        var requestedKey = input.AccessKey?.Trim() ?? "";
+        var identity = string.IsNullOrEmpty(requestedKey)
+            ? null
+            : await _db.Queryable<SysOpenAccess>().FirstAsync(x => x.AccessKey == requestedKey);
+        var plan = InboundGrantIssuance.Plan(
+            requestedKey,
+            tenant,
+            identity?.BindTenantId ?? 0,
+            identity != null,
+            NewToken,
+            NewToken);
+        var entityCode = input.EntityCode.Trim();
+        switch (plan.Kind)
+        {
+            case InboundGrantIssuanceKind.RejectUnknownKey:
+                throw Oops.Oh("访问标识不存在。留空将新建一套凭据,或先在开放接口身份中建立该标识");
+            case InboundGrantIssuanceKind.RejectCrossTenant:
+                throw Oops.Oh("该访问标识属于其他租户,不能用于本租户的推送授权");
+        }
+
+        var duplicated = await _db.Queryable<MdpInboundGrant>()
+            .AnyAsync(x => x.AccessKey == plan.AccessKey && x.EntityCode == entityCode);
+        if (duplicated)
+            throw Oops.Oh("该访问标识已经授权过这个业务对象");
+
+        long bindUserId = 0;
+        if (plan.Kind == InboundGrantIssuanceKind.CreateIdentity)
+        {
+            bindUserId = await _db.Queryable<SysTenant>()
+                .Where(t => t.Id == tenant)
+                .Select(t => t.UserId)
+                .FirstAsync();
+            if (bindUserId <= 0)
+                throw Oops.Oh("租户尚未生成租管用户,无法签发推送身份");
+        }
+
         var now = DateTime.Now;
-        await _db.Insertable(new MdpInboundGrant
+        try
         {
-            TenantId = tenant,
-            AccessKey = key,
-            EntityCode = input.EntityCode.Trim(),
-            SourceCode = source.SourceCode,
-            RateLimitPerMin = input.RateLimitPerMin <= 0 ? 60 : input.RateLimitPerMin,
-            BatchRowLimit = 500,
-            Status = 1,
-            CreateTime = now,
-            UpdateTime = now
-        }).ExecuteCommandAsync();
-        return new { accessKey = key, entityCode = input.EntityCode.Trim() };
+            _db.Ado.BeginTran();
+            if (plan.Kind == InboundGrantIssuanceKind.CreateIdentity)
+            {
+                await _db.Insertable(new SysOpenAccess
+                {
+                    Id = YitIdHelper.NextId(),
+                    AccessKey = plan.AccessKey,
+                    AccessSecret = plan.AccessSecret,
+                    BindTenantId = tenant,
+                    BindUserId = bindUserId,
+                    CreateTime = now,
+                }).ExecuteCommandAsync();
+            }
+
+            await _db.Insertable(new MdpInboundGrant
+            {
+                TenantId = tenant,
+                AccessKey = plan.AccessKey,
+                EntityCode = entityCode,
+                SourceCode = source.SourceCode,
+                RateLimitPerMin = input.RateLimitPerMin <= 0 ? 60 : input.RateLimitPerMin,
+                BatchRowLimit = 500,
+                Status = 1,
+                CreateTime = now,
+                UpdateTime = now
+            }).ExecuteCommandAsync();
+            _db.Ado.CommitTran();
+        }
+        catch
+        {
+            _db.Ado.RollbackTran();
+            throw;
+        }
+
+        if (plan.Kind == InboundGrantIssuanceKind.CreateIdentity)
+            _cache.Remove(CacheConst.KeyOpenAccess + plan.AccessKey);
+
+        // 密钥只在新建时回显一次。复用已有标识时调用方本就持有密钥,不再返回。
+        return plan.Kind == InboundGrantIssuanceKind.CreateIdentity
+            ? new { accessKey = plan.AccessKey, accessSecret = plan.AccessSecret, entityCode, secretShownOnce = true }
+            : (object)new { accessKey = plan.AccessKey, entityCode, secretShownOnce = false };
     }
+
+    private static string NewToken() =>
+        Convert.ToHexString(System.Security.Cryptography.RandomNumberGenerator.GetBytes(16)).ToLowerInvariant();
 }
 
 public sealed class DomainMapInput
@@ -378,4 +452,7 @@ public sealed class GrantCreateInput
     public string SourceCode { get; set; } = "";
     public string EntityCode { get; set; } = "";
     public int RateLimitPerMin { get; set; } = 60;
+
+    /// <summary>留空则新建开放接口身份并回显密钥;传入则复用该身份,且必须属于目标租户。</summary>
+    public string AccessKey { get; set; }
 }

+ 113 - 3
server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/NativeNeutralProjectionService.cs

@@ -29,11 +29,33 @@ public sealed class NativeNeutralProjectionService : ITransient
         _logger = loggerFactory.CreateLogger(nameof(NativeNeutralProjectionService));
     }
 
+    /// <summary>
+    /// 销售发货实绩(登记来源的 <c>SALES_SHIP</c>)按「订单号 + 物料」汇总。
+    /// <para>
+    /// 模式一下销售订单的权威在自有 S1,执行事实由外部系统提供,故订单行的已交货量与关行状态
+    /// 要用发货流水推导,不能只看 <c>mdp_std_so.delivered_qty</c>(自建单常年为空)。
+    /// 来源不写死:内联 <c>mdp_tenant_std_source</c>,只读该租户登记为 INV_TRANS 权威的那一个来源。
+    /// 粒度与 S7 指标 SQL 的 JOIN 一致(中立流水没有订单行号,最细只能到订单号 + 物料)。
+    /// </para>
+    /// </summary>
+    private const string ShipmentActualsSql = """
+        SELECT t.tenant_id, t.ref_task_no, t.item_num,
+               SUM(ABS(IFNULL(t.qty_change,0))) AS shipped_qty,
+               MAX(t.approved_time) AS last_ship_time
+        FROM mdp_std_inv_trans t
+        INNER JOIN mdp_tenant_std_source ts
+          ON ts.tenant_id=t.tenant_id AND ts.std_object='INV_TRANS' AND ts.source_system=t.source_system
+        WHERE t.tenant_id=@tid AND t.biz_doc_type='SALES_SHIP'
+          AND t.summary_flag=0 AND t.void_flag=0 AND t.approved_flag=1
+        GROUP BY t.tenant_id, t.ref_task_no, t.item_num
+        """;
+
     public async Task ProjectAsync(long tenantId, string batchId, CancellationToken cancellationToken = default)
     {
         if (tenantId <= 0) return;
         var failed = new List<string>();
         await Try(failed, "WO_LINE_PROD", () => ProjectWorkOrdersAsync(tenantId, batchId, cancellationToken));
+        await Try(failed, "WO_SCHEDULE", () => ProjectWorkOrderScheduleAsync(tenantId, batchId, cancellationToken));
         await Try(failed, "WO_BOM", () => ProjectBomAsync(tenantId, batchId, cancellationToken));
         await Try(failed, "EMPLOYEE", () => ProjectEmployeesAsync(tenantId, batchId, cancellationToken));
         await Try(failed, "WO_LINE_SALES", () => ProjectSalesLinesAsync(tenantId, batchId, cancellationToken));
@@ -81,6 +103,60 @@ public sealed class NativeNeutralProjectionService : ITransient
             new { tid = tenantId, batch = batchId });
     }
 
+    /// <summary>
+    /// 自有生产工单 → <c>mdp_std_work_order_schedule</c>(<c>doc_type='PROD_TASK'</c>)。
+    /// <para>
+    /// S5_L1_002 物料齐套满足率的分母以工单排程头为驱动,且 BOM 按 <c>source_system</c> 关联,
+    /// 故排程必须与 <see cref="ProjectBomAsync"/> 写同一个 <c>AIDOP_NATIVE</c>,否则分母恒为空、
+    /// 指标只会算出 NO_DATA。
+    /// </para>
+    /// <para>
+    /// 唯一键 <c>uk_std_wo_sched_type</c> 不含 <c>source_system</c>,同一工单号若已被别的来源占用,
+    /// 直接 upsert 会把对方的行改成自有来源,故与 T8 投影一样加 NOT EXISTS 反向占用保护。
+    /// </para>
+    /// </summary>
+    private async Task ProjectWorkOrderScheduleAsync(long tenantId, string batchId, CancellationToken cancellationToken)
+    {
+        if (!await _neutralGate.AllowsAsync(tenantId, "WO_SCHEDULE", MdpSourceIdentity.Native, syncBatchId: batchId, cancellationToken: cancellationToken))
+            return;
+        await MdpSchemaAligner.EnsureWrittenByColumnAsync(_db, "mdp_std_work_order_schedule");
+        cancellationToken.ThrowIfCancellationRequested();
+        await _db.Ado.ExecuteCommandAsync(
+            """
+            INSERT INTO mdp_std_work_order_schedule
+              (tenant_id, factory_id, source_system, written_by, work_order, doc_type,
+               item_code, item_name, site_code, status, priority, urgent_flag,
+               qty_ordered, qty_completed, order_date, due_date, release_date,
+               prod_line, lot_serial, drawing_no, project, work_order_type, labor_variance,
+               approved_flag, void_flag, source_biz_key, sync_batch_id, sync_time)
+            SELECT
+              w.tenant_id, 1, 'AIDOP_NATIVE', 'PLATFORM_FORM', w.WorkOrd, 'PROD_TASK',
+              w.ItemNum, w.ItemName, w.Site, w.Status, w.Priority, IF(IFNULL(w.Urgent,0)<>0, 1, 0),
+              w.QtyOrded, w.QtyCompleted, w.OrdDate, w.DueDate, w.ReleaseDate,
+              w.ProdLine, w.Batch, w.Drawing, w.Project, w.Typed, w.LbrVar,
+              1, IF(IFNULL(w.IsActive,1)=0, 1, 0),
+              LEFT(CONCAT(IFNULL(w.Domain,''), ':', w.WorkOrd), 200), @batch, NOW()
+            FROM WorkOrdMaster w
+            WHERE w.tenant_id=@tid AND w.WorkOrd IS NOT NULL AND w.WorkOrd<>''
+              AND NOT EXISTS (
+                SELECT 1 FROM mdp_std_work_order_schedule x
+                WHERE x.tenant_id=w.tenant_id AND x.doc_type='PROD_TASK' AND x.work_order=w.WorkOrd
+                  AND x.source_system<>'AIDOP_NATIVE')
+            ON DUPLICATE KEY UPDATE
+              item_code=VALUES(item_code), item_name=VALUES(item_name), site_code=VALUES(site_code),
+              status=VALUES(status), priority=VALUES(priority), urgent_flag=VALUES(urgent_flag),
+              qty_ordered=VALUES(qty_ordered), qty_completed=VALUES(qty_completed),
+              order_date=VALUES(order_date), due_date=VALUES(due_date), release_date=VALUES(release_date),
+              prod_line=VALUES(prod_line), lot_serial=VALUES(lot_serial), drawing_no=VALUES(drawing_no),
+              project=VALUES(project), work_order_type=VALUES(work_order_type),
+              labor_variance=VALUES(labor_variance),
+              approved_flag=VALUES(approved_flag), void_flag=VALUES(void_flag),
+              written_by=VALUES(written_by), source_biz_key=VALUES(source_biz_key),
+              sync_batch_id=VALUES(sync_batch_id), sync_time=VALUES(sync_time)
+            """,
+            new { tid = tenantId, batch = batchId });
+    }
+
     private async Task ProjectBomAsync(long tenantId, string batchId, CancellationToken cancellationToken)
     {
         if (!await _neutralGate.AllowsAsync(tenantId, "WO_BOM", MdpSourceIdentity.Native, syncBatchId: batchId, cancellationToken: cancellationToken))
@@ -155,20 +231,25 @@ public sealed class NativeNeutralProjectionService : ITransient
         await MdpSchemaAligner.EnsureWrittenByColumnAsync(_db, "mdp_std_work_order_line");
         cancellationToken.ThrowIfCancellationRequested();
         await _db.Ado.ExecuteCommandAsync(
-            """
+            $"""
             INSERT INTO mdp_std_work_order_line
               (tenant_id, factory_id, source_system, written_by, domain, doc_type, src_doc_type_raw,
                order_no, line_no, task_no, item_code, qty_planned, qty_completed, plan_finish_date, release_time,
                closed_flag, closed_time, approved_flag, void_flag, source_row_id, source_biz_key, sync_batch_id, sync_time)
             SELECT
               s.tenant_id, IFNULL(s.factory_id, 1), 'AIDOP_NATIVE', 'PLATFORM_FORM', @domain, 'SALES_ORDER', IFNULL(s.source_table,'mdp_std_so'),
-              s.order_no, s.order_line, s.order_no, s.item_code, s.order_qty, s.delivered_qty,
+              s.order_no, s.order_line, s.order_no, s.item_code, s.order_qty,
+              COALESCE(d.shipped_qty, s.delivered_qty),
               COALESCE(s.promised_delivery_date, s.plan_delivery_date, s.customer_request_date), s.order_date,
-              IF(IFNULL(s.closed,0)=1 OR IFNULL(s.line_closed_flag,0)=1, 1, 0), s.line_closed_time,
+              IF(IFNULL(s.closed,0)=1 OR IFNULL(s.line_closed_flag,0)=1
+                 OR d.shipped_qty>=s.order_qty, 1, 0),
+              COALESCE(s.line_closed_time, IF(d.shipped_qty>=s.order_qty, d.last_ship_time, NULL)),
               1, IFNULL(s.deleted_flag, 0),
               IFNULL(s.source_row_id, CAST(s.id AS CHAR)),
               LEFT(CONCAT('SO:', IFNULL(s.source_biz_key, s.order_no)), 200), @batch, NOW()
             FROM mdp_std_so s
+            LEFT JOIN ({ShipmentActualsSql}) d
+              ON d.tenant_id=s.tenant_id AND d.ref_task_no=s.order_no AND d.item_num=s.item_code
             WHERE s.tenant_id=@tid AND s.order_no IS NOT NULL AND s.order_no<>''
               AND s.source_system IN ('AIDOP_NATIVE','AIDOP')
             ON DUPLICATE KEY UPDATE
@@ -179,4 +260,33 @@ public sealed class NativeNeutralProjectionService : ITransient
             """,
             new { tid = tenantId, batch = batchId, domain });
     }
+
+    /// <summary>
+    /// 用销售发货实绩刷新已有 <c>SALES_ORDER</c> 中立行的已交货量与关行状态。
+    /// <para>
+    /// 发货流水在 S5/S7 重算时才产出,而销售订单行投影只在 S1/S2 重算时跑;
+    /// 若不在产出发货流水的同一次跑批里回写,S7 订单发货周期要等下一次 S1 重算才有值。
+    /// 故本方法由 <c>InventoryMdpSyncService</c> 在发货投影之后调用,只更新既有行、不新增行。
+    /// </para>
+    /// </summary>
+    public async Task RefreshSalesLineCompletionAsync(
+        long tenantId, string? batchId, CancellationToken cancellationToken = default)
+    {
+        if (tenantId <= 0) return;
+        if (!await _neutralGate.AllowsAsync(tenantId, "WO_LINE_SALES", MdpSourceIdentity.Native, syncBatchId: batchId, cancellationToken: cancellationToken))
+            return;
+        cancellationToken.ThrowIfCancellationRequested();
+        await _db.Ado.ExecuteCommandAsync(
+            $"""
+            UPDATE mdp_std_work_order_line l
+            INNER JOIN ({ShipmentActualsSql}) d
+              ON d.tenant_id=l.tenant_id AND d.ref_task_no=l.task_no AND d.item_num=l.item_code
+            SET l.qty_completed=d.shipped_qty,
+                l.closed_flag=IF(l.closed_flag=1 OR d.shipped_qty>=l.qty_planned, 1, 0),
+                l.closed_time=COALESCE(l.closed_time, IF(d.shipped_qty>=l.qty_planned, d.last_ship_time, NULL)),
+                l.sync_time=NOW()
+            WHERE l.tenant_id=@tid AND l.doc_type='SALES_ORDER' AND l.source_system='AIDOP_NATIVE'
+            """,
+            new { tid = tenantId });
+    }
 }

+ 18 - 5
server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/NeutralRequiredColumns.cs

@@ -17,6 +17,9 @@ public static class NeutralRequiredColumns
                 new("end_balance", "INV_END_BALANCE"),
                 new("dimension1", null),
                 new("eff_date", null),
+                // S5_L1_001 物料上线周期、S7_L1_001 订单发货周期都以 approved_time 为唯一时间基准:
+                // 该列整列为空时两个 KPI 只会算出 NO_DATA,且不会报错,故必须当必填列守住。
+                new("approved_time", null),
                 new("doc_qty", "DOC_QTY_CLOSE"),
                 new("line_closed_flag", "DOC_QTY_CLOSE")
             ]
@@ -24,7 +27,7 @@ public static class NeutralRequiredColumns
 
     public static readonly string[] InvTrans =
     [
-        "location", "lot_serial", "create_user", "end_balance", "dimension1", "eff_date", "doc_qty", "line_closed_flag"
+        "location", "lot_serial", "create_user", "end_balance", "dimension1", "eff_date", "approved_time", "doc_qty", "line_closed_flag"
     ];
 
     public sealed record Projection(string Channel, string RelativePath, string Marker, string[] Present, string[] Missing);
@@ -34,23 +37,33 @@ public static class NeutralRequiredColumns
         new("T8",
             Path.Combine("DataPlatform", "T8BaseInboundMdpSyncService.NeutralProjection.cs"),
             "ProjectInvTransSql",
-            ["doc_qty", "eff_date", "dimension1", "line_closed_flag", "location", "lot_serial", "create_user", "end_balance"],
+            ["doc_qty", "eff_date", "approved_time", "dimension1", "line_closed_flag", "location", "lot_serial", "create_user", "end_balance"],
             []),
         new("PUSH",
             Path.Combine("DataPlatform", "T8BaseInboundMdpSyncService.NeutralProjection.cs"),
             "BuildProjectApiInvTransSql",
-            ["location", "lot_serial", "create_user", "end_balance", "doc_qty", "line_closed_flag", "dimension1", "eff_date"],
+            ["location", "lot_serial", "create_user", "end_balance", "doc_qty", "line_closed_flag", "dimension1", "eff_date", "approved_time"],
             []),
         new("PULL",
             Path.Combine("DataPlatform", "T8BaseInboundMdpSyncService.NeutralProjection.cs"),
             "BuildProjectApiInvTransSql",
-            ["location", "lot_serial", "create_user", "end_balance", "doc_qty", "line_closed_flag", "dimension1", "eff_date"],
+            ["location", "lot_serial", "create_user", "end_balance", "doc_qty", "line_closed_flag", "dimension1", "eff_date", "approved_time"],
             []),
         new("165",
             Path.Combine("MaterialWarehouse", "InventoryMdpSyncService.cs"),
             "MaterializeInvTransStdAsync",
-            ["location", "lot_serial", "create_user", "end_balance", "doc_qty", "dimension1", "eff_date"],
+            ["location", "lot_serial", "create_user", "end_balance", "doc_qty", "dimension1", "eff_date", "approved_time"],
             ["line_closed_flag"]),
+        // 165 的销售发运走 ASN 明细单独一段投影(任务 A-3),是 mdp_std_inv_trans 的第二个写入口。
+        // 它漏掉 approved_time 时 S7 三个指标会一起静默变成 NO_DATA,故必须单列为一个通道守住。
+        new("165_ASN",
+            Path.Combine("DataPlatform", "Wms", "ShipTransNeutralProjection.cs"),
+            // 标记只写表名:带上 INSERT INTO 会被「中立层写入必须调用来源闸门」的守卫当成写入点。
+            "mdp_std_inv_trans",
+            ["location", "lot_serial", "create_user", "dimension1", "eff_date", "approved_time", "doc_qty"],
+            ["end_balance", "line_closed_flag"]),
+        // NATIVE 不写 mdp_std_inv_trans(自有数据只进工单、BOM、员工、销售订单行),
+        // 故不参与库存流水必填列的覆盖断言,这里只记它自己的写入方标记。
         new("NATIVE",
             Path.Combine("DataPlatform", "NativeNeutralProjectionService.cs"),
             "AIDOP_NATIVE",

+ 79 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/NeutralRequiredColumnsMonitor.cs

@@ -0,0 +1,79 @@
+using Microsoft.Extensions.Logging;
+
+namespace Admin.NET.Plugin.AiDOP.DataPlatform;
+
+/// <summary>
+/// 运行期核对中立层必填列是否整列为空。
+/// <para>
+/// <see cref="NeutralRequiredColumns"/> 是「投影 SQL 有没有写这一列」的静态守卫,守不住
+/// 「写了但取到的全是 NULL」和「这批存量是加列之前物化的」两种情况。而整列为空不会报错,
+/// 只会让依赖它的 KPI 一起算出 NO_DATA —— 2026-09 S5/S7 全租户掉数就是这么发生的。
+/// 故物化之后必须实测一次,把整列为空变成一条可查询的告警。
+/// </para>
+/// </summary>
+public sealed class NeutralRequiredColumnsMonitor : ITransient
+{
+    private readonly ISqlSugarClient _db;
+    private readonly ILogger _logger;
+
+    public NeutralRequiredColumnsMonitor(ISqlSugarClient db, ILoggerFactory loggerFactory)
+    {
+        _db = db;
+        _logger = loggerFactory.CreateLogger(nameof(NeutralRequiredColumnsMonitor));
+    }
+
+    /// <summary>返回该租户该表里「有行但整列为空」的必填列名。顺带落 mdp_source_gate_log 与错误日志。</summary>
+    public async Task<IReadOnlyList<string>> AssertAsync(
+        long tenantId, string stdObject, string table, string? batchId, CancellationToken cancellationToken = default)
+    {
+        if (tenantId <= 0) return [];
+        var required = NeutralRequiredColumns.For(stdObject).Select(c => c.Name).ToList();
+        if (required.Count == 0) return [];
+
+        cancellationToken.ThrowIfCancellationRequested();
+        var present = await _db.Ado.SqlQueryAsync<string>(
+            """
+            SELECT COLUMN_NAME FROM information_schema.COLUMNS
+            WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=@table
+            """,
+            new { table });
+        var columns = required
+            .Where(c => present.Contains(c, StringComparer.OrdinalIgnoreCase))
+            .ToList();
+        if (columns.Count == 0) return [];
+
+        // 列名取自 NeutralRequiredColumns 常量,不来自外部输入,故可直接拼进 SQL。
+        var counters = string.Join(", ", columns.Select(c => $"SUM(`{c}` IS NOT NULL) AS `{c}`"));
+        var stats = await _db.Ado.GetDataTableAsync(
+            $"SELECT COUNT(*) AS `__total`, {counters} FROM `{table}` WHERE tenant_id=@tenant",
+            new { tenant = tenantId });
+        if (stats.Rows.Count == 0) return [];
+
+        var row = stats.Rows[0];
+        if (Convert.ToInt64(row["__total"]) <= 0) return [];
+
+        var empty = columns
+            .Where(c => row[c] == DBNull.Value || Convert.ToInt64(row[c]) == 0)
+            .ToList();
+        if (empty.Count == 0) return empty;
+
+        _logger.LogError(
+            "中立层必填列整列为空 tenant={Tenant} object={Object} table={Table} columns={Columns}",
+            tenantId, stdObject, table, string.Join(",", empty));
+        await _db.Ado.ExecuteCommandAsync(
+            """
+            INSERT INTO mdp_source_gate_log
+              (tenant_id, std_object, source_system, gate_reason, row_count, sample_keys, sync_batch_id)
+            VALUES (@tenant, @obj, '', 'REQUIRED_COLUMN_EMPTY', @rows, @sample, @batch)
+            """,
+            new
+            {
+                tenant = tenantId,
+                obj = stdObject,
+                rows = empty.Count,
+                sample = string.Join(",", empty),
+                batch = batchId ?? ""
+            });
+        return empty;
+    }
+}

+ 55 - 24
server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/Wms/ShipTransNeutralProjection.cs

@@ -8,6 +8,29 @@ namespace Admin.NET.Plugin.AiDOP.DataPlatform.Wms;
 /// </summary>
 public sealed class ShipTransNeutralProjection : ITransient
 {
+    /// <summary>
+    /// 贴源筛选。<c>mdp_stg_ship_trans</c> 一张表承载 ASN 明细与发运计划(LinkagePlan / ShippingPlanDetail),
+    /// 只有 ASN 明细是发货实绩;计划行没有 ShipDate / QtyShipped,放进来会变成一批 0 数量的 SALES_SHIP。
+    /// 同一张 ASN 还会因 Q13 的 <c>AIDOPDEV_MYSQL → AIDOP_NATIVE</c> 迁移在贴源留两份,
+    /// 故聚合口径一律先按业务字段去重,不按贴源行数算。
+    /// </summary>
+    private const string AsnStagingFilter = """
+        s.tenant_id=@tid
+          AND s.source_table='ASNBOLShipperDetail'
+          AND NULLIF(JSON_UNQUOTE(JSON_EXTRACT(s.raw_data,'$.ShipDate')),'null') IS NOT NULL
+        """;
+
+    /// <summary>ASN 发货时间。eff_date / trans_time / approved_time 同源,避免三处各写一遍。</summary>
+    private const string ShipDateExpr = """
+        STR_TO_DATE(SUBSTRING_INDEX(REPLACE(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(s.raw_data,'$.ShipDate')),'null'),'T',' '),'.',1),'%Y-%m-%d %H:%i:%s')
+        """;
+
+    /// <summary>ASN 明细行号。165 推 OrdLine,历史样例里也出现过 Line,两者都取不到才退 0。</summary>
+    private const string OrdLineExpr = """
+        COALESCE(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(s.raw_data,'$.OrdLine')),'null'),
+                 NULLIF(JSON_UNQUOTE(JSON_EXTRACT(s.raw_data,'$.Line')),'null'), '0')
+        """;
+
     private readonly ISqlSugarClient _db;
     private readonly MdpNeutralSourceGate _gate;
 
@@ -42,17 +65,17 @@ public sealed class ShipTransNeutralProjection : ITransient
             """);
 
         var inserted = await _db.Ado.ExecuteCommandAsync(
-            """
+            $"""
             INSERT INTO mdp_std_inv_trans
               (tenant_id, source_system, written_by, domain, src_rec_id, trans_type, src_trans_type_raw,
                biz_doc_type, src_biz_doc_type_raw, approved_flag, void_flag, summary_flag,
                item_num, lot_serial, location, dimension1, dimension2, site, qty_change, doc_qty,
-               eff_date, trans_time, ord_nbr, ref_task_no, shipper_num, create_user,
+               eff_date, trans_time, approved_time, ord_nbr, ref_task_no, shipper_num, create_user,
                history_from, as_of, sync_batch_id)
             SELECT
               @tid, 'DOPDEMORQ_SQLSERVER', 'DB_SYNC',
               IFNULL(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(s.raw_data,'$.Domain')),'null'),''),
-              LEFT(CONCAT('ASN:', IFNULL(JSON_UNQUOTE(JSON_EXTRACT(s.raw_data,'$.Id')), s.source_row_id), ':', IFNULL(JSON_UNQUOTE(JSON_EXTRACT(s.raw_data,'$.Line')),'0')), 200),
+              LEFT(CONCAT('ASN:', IFNULL(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(s.raw_data,'$.Id')),''), s.source_row_id), ':', {OrdLineExpr}), 200),
               'FG_SHIP',
               NULLIF(JSON_UNQUOTE(JSON_EXTRACT(s.raw_data,'$.ShType')),'null'),
               'SALES_SHIP', 'ASNBOLShipperDetail',
@@ -70,8 +93,9 @@ public sealed class ShipTransNeutralProjection : ITransient
               NULLIF(JSON_UNQUOTE(JSON_EXTRACT(s.raw_data,'$.Site')),'null'),
               -ABS(IFNULL(CAST(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(s.raw_data,'$.QtyShipped')),'null') AS DECIMAL(18,6)), 0)),
               CAST(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(s.raw_data,'$.QtyToShip')),'null') AS DECIMAL(18,6)),
-              STR_TO_DATE(SUBSTRING_INDEX(REPLACE(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(s.raw_data,'$.ShipDate')),'null'),'T',' '),'.',1),'%Y-%m-%d %H:%i:%s'),
-              STR_TO_DATE(SUBSTRING_INDEX(REPLACE(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(s.raw_data,'$.ShipDate')),'null'),'T',' '),'.',1),'%Y-%m-%d %H:%i:%s'),
+              {ShipDateExpr},
+              {ShipDateExpr},
+              {ShipDateExpr},
               NULLIF(JSON_UNQUOTE(JSON_EXTRACT(s.raw_data,'$.OrdNbr')),'null'),
               NULLIF(JSON_UNQUOTE(JSON_EXTRACT(s.raw_data,'$.OrdNbr')),'null'),
               NULLIF(JSON_UNQUOTE(JSON_EXTRACT(s.raw_data,'$.Id')),'null'),
@@ -81,40 +105,43 @@ public sealed class ShipTransNeutralProjection : ITransient
             LEFT JOIN mdp_std_work_order_line wol
               ON wol.tenant_id=@tid AND wol.source_system='AIDOP_NATIVE' AND wol.doc_type='SALES_ORDER'
              AND wol.order_no=NULLIF(JSON_UNQUOTE(JSON_EXTRACT(s.raw_data,'$.OrdNbr')),'null')
-             AND wol.line_no=CAST(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(s.raw_data,'$.OrdLine')),'null') AS SIGNED)
-            WHERE s.tenant_id=@tid
+             AND wol.line_no=CAST({OrdLineExpr} AS SIGNED)
+            WHERE {AsnStagingFilter}
             ON DUPLICATE KEY UPDATE
               qty_change=VALUES(qty_change), doc_qty=VALUES(doc_qty), item_num=VALUES(item_num),
               location=VALUES(location), lot_serial=VALUES(lot_serial), trans_type=VALUES(trans_type),
-              biz_doc_type=VALUES(biz_doc_type), eff_date=VALUES(eff_date), as_of=VALUES(as_of),
-              sync_batch_id=VALUES(sync_batch_id)
+              biz_doc_type=VALUES(biz_doc_type), eff_date=VALUES(eff_date),
+              trans_time=VALUES(trans_time), approved_time=VALUES(approved_time),
+              approved_flag=VALUES(approved_flag), void_flag=VALUES(void_flag),
+              as_of=VALUES(as_of), sync_batch_id=VALUES(sync_batch_id)
             """,
             new SugarParameter("@tid", tenantId),
             new SugarParameter("@batch", batchId ?? ""));
 
         await _db.Ado.ExecuteCommandAsync(
-            """
+            $"""
             INSERT INTO mdp_source_gate_log
               (tenant_id, std_object, source_system, gate_reason, row_count, sample_keys, sync_batch_id)
-            SELECT @tid, 'INV_TRANS', 'DOPDEMORQ_SQLSERVER', 'SO_LINE_NOT_FOUND', COUNT(*),
-                   LEFT(GROUP_CONCAT(DISTINCT CONCAT(IFNULL(JSON_UNQUOTE(JSON_EXTRACT(s.raw_data,'$.OrdNbr')),''), ':', IFNULL(JSON_UNQUOTE(JSON_EXTRACT(s.raw_data,'$.OrdLine')),''))), 500),
+            SELECT @tid, 'INV_TRANS', 'DOPDEMORQ_SQLSERVER', 'SO_LINE_NOT_FOUND',
+                   COUNT(DISTINCT CONCAT(IFNULL(JSON_UNQUOTE(JSON_EXTRACT(s.raw_data,'$.Id')),''), ':', {OrdLineExpr})) AS asn_rows,
+                   LEFT(GROUP_CONCAT(DISTINCT CONCAT(IFNULL(JSON_UNQUOTE(JSON_EXTRACT(s.raw_data,'$.OrdNbr')),''), ':', {OrdLineExpr})), 500),
                    @batch
             FROM mdp_stg_ship_trans s
             LEFT JOIN mdp_std_work_order_line wol
               ON wol.tenant_id=@tid AND wol.source_system='AIDOP_NATIVE' AND wol.doc_type='SALES_ORDER'
              AND wol.order_no=NULLIF(JSON_UNQUOTE(JSON_EXTRACT(s.raw_data,'$.OrdNbr')),'null')
-             AND wol.line_no=CAST(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(s.raw_data,'$.OrdLine')),'null') AS SIGNED)
-            WHERE s.tenant_id=@tid
+             AND wol.line_no=CAST({OrdLineExpr} AS SIGNED)
+            WHERE {AsnStagingFilter}
               AND wol.id IS NULL
               AND NULLIF(JSON_UNQUOTE(JSON_EXTRACT(s.raw_data,'$.CustItem')),'null') IS NULL
               AND NULLIF(JSON_UNQUOTE(JSON_EXTRACT(s.raw_data,'$.ContainerItem')),'null') IS NULL
-            HAVING COUNT(*)>0
+            HAVING asn_rows>0
             """,
             new SugarParameter("@tid", tenantId),
             new SugarParameter("@batch", batchId ?? ""));
 
         await _db.Ado.ExecuteCommandAsync(
-            """
+            $"""
             INSERT INTO mdp_ship_recon_diff
               (tenant_id, source_system, domain, ord_nbr, ord_line, biz_date, asn_qty, inv_iss_so_qty, diff_qty)
             SELECT @tid, 'DOPDEMORQ_SQLSERVER', d.domain, d.ord_nbr, d.ord_line, d.biz_date,
@@ -123,14 +150,18 @@ public sealed class ShipTransNeutralProjection : ITransient
               SELECT domain, ord_nbr, IFNULL(ord_line,'') ord_line, biz_date,
                      SUM(asn_qty) asn_qty, SUM(iss_qty) iss_qty
               FROM (
-                SELECT IFNULL(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(s.raw_data,'$.Domain')),'null'),'') domain,
-                       IFNULL(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(s.raw_data,'$.OrdNbr')),'null'),'') ord_nbr,
-                       IFNULL(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(s.raw_data,'$.OrdLine')),'null'),'') ord_line,
-                       DATE(STR_TO_DATE(SUBSTRING_INDEX(REPLACE(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(s.raw_data,'$.ShipDate')),'null'),'T',' '),'.',1),'%Y-%m-%d %H:%i:%s')) biz_date,
-                       ABS(IFNULL(CAST(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(s.raw_data,'$.QtyShipped')),'null') AS DECIMAL(18,6)),0)) asn_qty,
-                       0 iss_qty
-                FROM mdp_stg_ship_trans s
-                WHERE s.tenant_id=@tid
+                SELECT a.domain, a.ord_nbr, a.ord_line, a.biz_date, a.asn_qty, 0 iss_qty
+                FROM (
+                  SELECT IFNULL(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(s.raw_data,'$.Domain')),'null'),'') domain,
+                         IFNULL(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(s.raw_data,'$.OrdNbr')),'null'),'') ord_nbr,
+                         IFNULL(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(s.raw_data,'$.OrdLine')),'null'),'') ord_line,
+                         DATE({ShipDateExpr}) biz_date,
+                         IFNULL(JSON_UNQUOTE(JSON_EXTRACT(s.raw_data,'$.Id')),'') asn_id,
+                         MAX(ABS(IFNULL(CAST(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(s.raw_data,'$.QtyShipped')),'null') AS DECIMAL(18,6)),0))) asn_qty
+                  FROM mdp_stg_ship_trans s
+                  WHERE {AsnStagingFilter}
+                  GROUP BY domain, ord_nbr, ord_line, biz_date, asn_id
+                ) a
                 UNION ALL
                 SELECT IFNULL(t.domain,''), IFNULL(t.ord_nbr,''), '', DATE(t.eff_date), 0, ABS(IFNULL(t.qty_change,0))
                 FROM mdp_std_inv_trans t

+ 4 - 4
server/Plugins/Admin.NET.Plugin.AiDOP/FinishedWarehouse/S7MdpSyncTransformService.cs

@@ -164,7 +164,7 @@ select order_no as order_no,
     from mdp_std_work_order_line b
     left join (
       select tenant_id as tenant_id, ref_task_no, item_num,
-             max(approved_time) as approved_time, sum(qty_change) as qty_change
+             max(approved_time) as approved_time, sum(abs(qty_change)) as qty_change
        from mdp_std_inv_trans
        where tenant_id=@tenantId and biz_doc_type='SALES_SHIP'
          and summary_flag=0 and void_flag=0 and approved_flag=1 AND source_system=@sourceSystem
@@ -254,7 +254,7 @@ select order_no as order_no,
        sum(completed) as in_window_rows
  from (
    select b.order_no as order_no, b.task_no as task_no, b.item_code as item_code,
-          (case when sum(d.qty_change)>=b.qty_planned then 1 else 0 end) as completed
+          (case when sum(abs(d.qty_change))>=b.qty_planned then 1 else 0 end) as completed
     from mdp_std_work_order_line b
     left join (
       select tenant_id as tenant_id, ref_task_no, item_num,
@@ -350,7 +350,7 @@ ON DUPLICATE KEY UPDATE
 
         const string sqlNumer = @"
 select ref_task_no as task_no, item_num as item_code,
-       date(approved_time) as approved_date, qty_change as qty_change
+       date(approved_time) as approved_date, abs(qty_change) as qty_change
  from mdp_std_inv_trans
  where tenant_id=@tenantId and biz_doc_type='SALES_SHIP'
    and summary_flag=0 and void_flag=0 and approved_flag=1 AND source_system=@sourceSystem
@@ -508,7 +508,7 @@ ON DUPLICATE KEY UPDATE
                        COUNT(*) AS RowCount
                 FROM (
                     SELECT b.order_no, b.task_no, b.item_code,
-                           CASE WHEN SUM(IFNULL(d.qty_change,0))>=b.qty_planned THEN 1 ELSE 0 END AS completed
+                           CASE WHEN SUM(ABS(IFNULL(d.qty_change,0)))>=b.qty_planned THEN 1 ELSE 0 END AS completed
                     FROM mdp_std_work_order_line b
                     LEFT JOIN (
                         SELECT tenant_id, ref_task_no, item_num, qty_change, DATE(approved_time) AS ship_date

+ 35 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Infrastructure/AidopTenantBaselineController.cs

@@ -0,0 +1,35 @@
+namespace Admin.NET.Plugin.AiDOP.Infrastructure;
+
+/// <summary>
+/// 超管重跑租户基线。方法本身幂等,重复调用只补缺、不覆盖已有配置。
+/// 租户号必须由调用方显式传入,不取当前登录上下文,避免超管切了租户之后补错对象。
+/// </summary>
+[ApiDescriptionSettings(Order = 327, Description = "租户基线")]
+[Route("api/aidop/tenant-baseline")]
+public class AidopTenantBaselineController : IDynamicApiController, ITransient
+{
+    private readonly AidopTenantInitService _init;
+    private readonly UserManager _user;
+
+    public AidopTenantBaselineController(AidopTenantInitService init, UserManager user)
+    {
+        _init = init;
+        _user = user;
+    }
+
+    [DisplayName("超管:重跑租户基线")]
+    [HttpPost("reinit")]
+    public Task<AidopTenantBaselineResult> Reinit([FromBody] TenantBaselineReinitInput input)
+    {
+        if (!_user.SuperAdmin)
+            throw Oops.Oh(ErrorCodeEnum.SA001);
+        if (input == null || input.TenantId <= 0)
+            throw Oops.Oh("必须指定租户");
+        return _init.InitTenantBaselineAsync(input.TenantId);
+    }
+}
+
+public sealed class TenantBaselineReinitInput
+{
+    public long TenantId { get; set; }
+}

+ 69 - 14
server/Plugins/Admin.NET.Plugin.AiDOP/Infrastructure/AidopTenantInitService.cs

@@ -1,4 +1,5 @@
 using Admin.NET.Plugin.AiDOP.Entity;
+using Furion.Logging;
 using Admin.NET.Plugin.AiDOP.Entity.S8;
 using Admin.NET.Plugin.AiDOP.Service.S8.OrderFlow;
 using Yitter.IdGenerator;
@@ -27,27 +28,53 @@ public sealed class AidopTenantInitService : ITransient
     public AidopTenantInitService(ISqlSugarClient db) => _db = db;
 
     /// <summary>幂等种入目标租户的 S8 业务角色、KPI 主数据/布局与 calc config;允许重复调用。</summary>
-    public async Task InitTenantBaselineAsync(long tenantId)
+    public async Task<AidopTenantBaselineResult> InitTenantBaselineAsync(long tenantId)
     {
-        if (tenantId <= 0) return;
+        if (tenantId <= 0)
+            return new AidopTenantBaselineResult();
+
+        var s8Roles = await SeedS8BusinessRolesAsync(tenantId);
+        var approvalRoles = await CountInsertedAsync(
+            () => CountRolesAsync(tenantId, SmartOpsImprovementApprovalRules.BaselineRoles.Select(x => x.Code)),
+            () => AidopImprovementApprovalSeed.EnsureBaselineRoles(_db, tenantId));
+        var scenes = await SeedS8ScenesFromTemplateAsync(tenantId);
+        var kpiMasters = await CountInsertedAsync(
+            () => _db.Queryable<AdoSmartOpsKpiMaster>().ClearFilter<ITenantIdFilter>().CountAsync(x => x.TenantId == tenantId),
+            () => AidopKpiMasterSeed.EnsureSeed(_db, tenantId, includeDemoValues: false));
+        var kpiCalc = await SeedKpiCalcConfigFromTemplateAsync(tenantId);
+
+        var result = new AidopTenantBaselineResult
+        {
+            S8Roles = s8Roles,
+            ApprovalRoles = approvalRoles,
+            Scenes = scenes,
+            KpiMasters = kpiMasters,
+            KpiCalcConfigs = kpiCalc,
+        };
+        Log.Information(
+            "租户基线 tenant={TenantId} s8Roles={S8Roles} approvalRoles={ApprovalRoles} scenes={Scenes} kpiMasters={KpiMasters} kpiCalc={KpiCalc}",
+            tenantId, result.S8Roles, result.ApprovalRoles, result.Scenes, result.KpiMasters, result.KpiCalcConfigs);
+        return result;
+    }
 
-        await SeedS8BusinessRolesAsync(tenantId);
-        AidopImprovementApprovalSeed.EnsureBaselineRoles(_db, tenantId);
-        await SeedS8ScenesFromTemplateAsync(tenantId);
-        AidopKpiMasterSeed.EnsureSeed(_db, tenantId, includeDemoValues: false);
-        await SeedKpiCalcConfigFromTemplateAsync(tenantId);
+    private async Task<int> CountInsertedAsync(Func<Task<int>> count, Action seed)
+    {
+        var before = await count();
+        seed();
+        var after = await count();
+        return Math.Max(0, after - before);
     }
 
-    private async Task SeedS8ScenesFromTemplateAsync(long tenantId)
+    private async Task<int> SeedS8ScenesFromTemplateAsync(long tenantId)
     {
-        if (tenantId == S8SceneTemplateTenantId) return;
+        if (tenantId == S8SceneTemplateTenantId) return 0;
 
         var factoryId = await _db.Queryable<SysTenant>()
             .ClearFilter<ITenantIdFilter>()
             .Where(x => x.Id == tenantId)
             .Select(x => x.OrgId)
             .FirstAsync();
-        if (factoryId <= 0) return;
+        if (factoryId <= 0) return 0;
 
         var templates = await _db.Queryable<AdoS8SceneConfig>()
             .ClearFilter<ITenantIdFilter>()
@@ -55,6 +82,7 @@ public sealed class AidopTenantInitService : ITransient
             .OrderBy(x => x.SortNo)
             .ToListAsync();
         var now = DateTime.Now;
+        var inserted = 0;
         foreach (var template in templates)
         {
             var exists = await _db.Queryable<AdoS8SceneConfig>()
@@ -64,6 +92,7 @@ public sealed class AidopTenantInitService : ITransient
                             && x.SceneCode == template.SceneCode);
             if (exists) continue;
 
+            inserted++;
             await _db.Insertable(new AdoS8SceneConfig
             {
                 TenantId = tenantId,
@@ -75,11 +104,13 @@ public sealed class AidopTenantInitService : ITransient
                 CreatedAt = now,
             }).ExecuteCommandAsync();
         }
+        return inserted;
     }
 
-    private async Task SeedS8BusinessRolesAsync(long tenantId)
+    private async Task<int> SeedS8BusinessRolesAsync(long tenantId)
     {
         var ct = DateTime.Now;
+        var inserted = 0;
         foreach (var (code, name, orderNo) in S8BusinessRoles)
         {
             var exists = await _db.Queryable<SysRole>()
@@ -87,6 +118,7 @@ public sealed class AidopTenantInitService : ITransient
                 .AnyAsync(r => r.TenantId == tenantId && r.Code == code);
             if (exists) continue;
 
+            inserted++;
             await _db.Insertable(new SysRole
             {
                 Id = YitIdHelper.NextId(),
@@ -100,19 +132,21 @@ public sealed class AidopTenantInitService : ITransient
                 CreateTime = ct,
             }).ExecuteCommandAsync();
         }
+        return inserted;
     }
 
-    private async Task SeedKpiCalcConfigFromTemplateAsync(long tenantId)
+    private async Task<int> SeedKpiCalcConfigFromTemplateAsync(long tenantId)
     {
-        if (tenantId == KpiTemplateTenantId) return;
+        if (tenantId == KpiTemplateTenantId) return 0;
 
         var templates = await _db.Queryable<AdoSmartOpsKpiCalcConfig>()
             .ClearFilter<ITenantIdFilter>()
             .Where(c => c.TenantId == KpiTemplateTenantId)
             .ToListAsync();
-        if (templates.Count == 0) return;
+        if (templates.Count == 0) return 0;
 
         var now = DateTime.Now;
+        var inserted = 0;
         foreach (var tpl in templates)
         {
             var exists = await _db.Queryable<AdoSmartOpsKpiCalcConfig>()
@@ -120,6 +154,7 @@ public sealed class AidopTenantInitService : ITransient
                 .AnyAsync(c => c.TenantId == tenantId && c.MetricCode == tpl.MetricCode && c.VersionNo == tpl.VersionNo);
             if (exists) continue;
 
+            inserted++;
             await _db.Insertable(new AdoSmartOpsKpiCalcConfig
             {
                 TenantId = tenantId,
@@ -138,5 +173,25 @@ public sealed class AidopTenantInitService : ITransient
                 CreatedAt = now,
             }).ExecuteCommandAsync();
         }
+        return inserted;
+    }
+
+    private Task<int> CountRolesAsync(long tenantId, IEnumerable<string> codes)
+    {
+        var list = codes.Where(c => !string.IsNullOrWhiteSpace(c)).Distinct().ToList();
+        if (list.Count == 0) return Task.FromResult(0);
+        return _db.Queryable<SysRole>()
+            .ClearFilter<ITenantIdFilter>()
+            .CountAsync(r => r.TenantId == tenantId && list.Contains(r.Code));
     }
 }
+
+/// <summary>一次基线执行实际新写入的条数。重复调用时各项为 0。</summary>
+public sealed class AidopTenantBaselineResult
+{
+    public int S8Roles { get; init; }
+    public int ApprovalRoles { get; init; }
+    public int Scenes { get; init; }
+    public int KpiMasters { get; init; }
+    public int KpiCalcConfigs { get; init; }
+}

+ 47 - 4
server/Plugins/Admin.NET.Plugin.AiDOP/MaterialWarehouse/InventoryMdpSyncService.cs

@@ -29,6 +29,8 @@ public sealed class InventoryMdpSyncService : ITransient
     private readonly ILogger _logger;
     private readonly MdpNeutralSourceGate _neutralGate;
     private readonly ShipTransNeutralProjection _ship;
+    private readonly NativeNeutralProjectionService _native;
+    private readonly NeutralRequiredColumnsMonitor _requiredColumns;
 
     public InventoryMdpSyncService(
         ISqlSugarClient db,
@@ -38,7 +40,9 @@ public sealed class InventoryMdpSyncService : ITransient
         IOptions<AidopInventoryOptions> opt,
         ILoggerFactory loggerFactory,
         MdpNeutralSourceGate neutralGate,
-        ShipTransNeutralProjection ship)
+        ShipTransNeutralProjection ship,
+        NativeNeutralProjectionService native,
+        NeutralRequiredColumnsMonitor requiredColumns)
     {
         _db = db;
         _pullDispatcher = pullDispatcher;
@@ -48,6 +52,8 @@ public sealed class InventoryMdpSyncService : ITransient
         _logger = loggerFactory.CreateLogger(nameof(InventoryMdpSyncService));
         _neutralGate = neutralGate;
         _ship = ship;
+        _native = native;
+        _requiredColumns = requiredColumns;
     }
 
     public Task<InventorySyncResult> RunBootstrapAsync(CancellationToken cancellationToken = default)
@@ -237,6 +243,16 @@ public sealed class InventoryMdpSyncService : ITransient
     /// <summary>
     /// 将指定租户已存在的全部库存交易贴源批次转换到标准层。
     /// 供租户级模块重算使用,不拉外部源,也不依赖默认 Domain→Tenant 映射。
+    /// <para>
+    /// 贴源层归属租户与业务租户不是一回事:正式切片的 stg 挂在
+    /// <c>ado_source_domain_tenant_map</c> 解析出的<b>源租户</b>名下,业务归属才按各租户
+    /// 库位范围投影(见 <see cref="MaterializeInvTransStdAsync"/>)。因此只按
+    /// <c>tenant_id=@TenantId</c> 找贴源会整片漏掉正式切片,该租户的标准层会永远停在
+    /// 上一次全量物化的那一刻:之后新增的中立层列(如 <c>approved_time</c>)一直是 NULL,
+    /// 依赖它的 KPI 只会算出 NO_DATA 且全程不报错。故这里把正式切片按
+    /// 「源租户 stg → 本租户 std」补进来。
+    /// </para>
+    /// <para>语义为 UPSERT(batchId 传 null 覆盖全部已贴源行),不删任何既有切片。</para>
     /// </summary>
     public async Task<int> TransformTransStdFromStgAsync(
         long tenantId, CancellationToken cancellationToken = default)
@@ -255,14 +271,36 @@ public sealed class InventoryMdpSyncService : ITransient
         var asOf = DateTime.Now;
         var months = _opt.TransBootstrapMonths <= 0 ? 12 : _opt.TransBootstrapMonths;
         var historyFrom = asOf.Date.AddMonths(-months);
+
+        var slices = sources
+            .Where(s => !string.IsNullOrWhiteSpace(s.SourceSystem))
+            .Select(s => (SourceTenantId: tenantId, SourceSystem: s.SourceSystem!.Trim()))
+            .ToList();
+
+        var sourceCode = string.IsNullOrWhiteSpace(_opt.SourceCode) ? SourceCodeDefault : _opt.SourceCode.Trim();
+        var domain = string.IsNullOrWhiteSpace(_opt.DefaultDomain) ? "8010" : _opt.DefaultDomain.Trim();
+        var formalSourceTenantId = await _domainTenant.ResolveTenantIdAsync(sourceCode, domain, cancellationToken);
+        if (formalSourceTenantId > 0
+            && formalSourceTenantId != tenantId
+            && !slices.Any(s => string.Equals(s.SourceSystem, sourceCode, StringComparison.OrdinalIgnoreCase)))
+        {
+            // fail closed:只给「在该 domain 下配了合法库位」的租户补正式切片,
+            // 绝不因为解析到了源租户就替无库位范围的租户凭空物化一份。
+            var scoped = await ListInventoryScopedTenantsAsync(domain, cancellationToken);
+            if (scoped.Contains(tenantId))
+                slices.Add((formalSourceTenantId, sourceCode));
+        }
+
+        using var xformTimeout = WithLongCommandTimeout();
         var affected = 0;
-        foreach (var source in sources)
+        foreach (var (sourceTenantId, sourceSystem) in slices)
         {
             cancellationToken.ThrowIfCancellationRequested();
-            if (string.IsNullOrWhiteSpace(source.SourceSystem)) continue;
             affected += await MaterializeInvTransStdAsync(
-                tenantId, tenantId, batchId: null, asOf, historyFrom, source.SourceSystem);
+                sourceTenantId, tenantId, batchId: null, asOf, historyFrom, sourceSystem);
         }
+        await _requiredColumns.AssertAsync(
+            tenantId, "INV_TRANS", "mdp_std_inv_trans", batchId: null, cancellationToken);
         return affected;
     }
 
@@ -834,7 +872,12 @@ public sealed class InventoryMdpSyncService : ITransient
         if (await _neutralGate.AllowsAsync(targetTenantId, "INV_BAL_MONTHLY", sourceSystem, syncBatchId: batchId))
             await Materialize165InventoryBalanceMonthlyAsync(sourceTenantId, targetTenantId, sourceSystem, batchId);
         if (await _neutralGate.AllowsAsync(targetTenantId, "INV_TRANS", sourceSystem, syncBatchId: batchId))
+        {
             await _ship.ProjectAsync(targetTenantId, batchId);
+            // 发货流水刚落地,顺手把销售订单行的已交货量与关行状态刷成最新,
+            // 否则 S7 订单发货周期要等下一次 S1 重算才看得到本次发货。
+            await _native.RefreshSalesLineCompletionAsync(targetTenantId, batchId);
+        }
         return affected;
     }
 

+ 3 - 1
server/Plugins/Admin.NET.Plugin.AiDOP/MaterialWarehouse/S5MdpSyncTransformService.cs

@@ -410,8 +410,10 @@ ON DUPLICATE KEY UPDATE
     {
         var sub = new KpiBuildSubResult();
 
+        // 中立层 qty_change 是带方向的库存变化量,PROD_ISSUE 这类出库行为负值;
+        // 领料数量取绝对值,与本文件 L2/L3 的 ABS(qty_change) 口径一致。
         const string sqlNumer = @"
-select sum(qty_change) as qty_change
+select sum(abs(qty_change)) as qty_change
  from mdp_std_inv_trans
  where tenant_id=@tenantId and biz_doc_type='PROD_ISSUE'
    and summary_flag=0 and void_flag=0 and approved_flag=1 AND source_system=@sourceSystem

+ 2 - 1
server/Plugins/Admin.NET.Plugin.AiDOP/Supply/S3MdpSyncTransformService.cs

@@ -557,7 +557,8 @@ public class S3MdpSyncTransformService : ITransient
             FROM (
                 SELECT item_num,
                        SUM(CASE WHEN biz_doc_type='PUR_RECEIPT' THEN IFNULL(qty_change,0) ELSE 0 END) AS receipt_qty,
-                       SUM(CASE WHEN biz_doc_type='PROD_ISSUE' THEN IFNULL(qty_change,0) ELSE 0 END) AS issue_qty
+                       -- 出库在中立层为负,取绝对值后 HAVING SUM(issue_qty)>0 才成立,周转天数公式也才按字面生效
+                       SUM(CASE WHEN biz_doc_type='PROD_ISSUE' THEN ABS(IFNULL(qty_change,0)) ELSE 0 END) AS issue_qty
                 FROM mdp_std_inv_trans
                 WHERE tenant_id=@TenantId
                   AND summary_flag=0 AND void_flag=0 AND approved_flag=1 AND source_system=@sourceSystem