Przeglądaj źródła

fix(s0): repair routing create org scope and BOM per-row factory isolation

ISSUE B · FUNC-S0-002 标准工艺路线新增 100% 返回 400
- RoutingList.vue 新增时 companyRefId/factoryRefId 恒为 undefined,序列化丢键,
  后端非空 long 绑定成 0/0;改为复用既有 applyDefaultOrgScopeForCreate 补当前租户
  默认公司/工厂(不另造组织解析算法),编辑路径保持原值回填不变。
- Create 侧原本要求 ItemMaster 的 company/factory 与 DTO 组织归属数值完全相等,
  但两者分属 SysOrg 雪花域与 legacy 数值域(1000/8010),跨 namespace 强比较
  恒不成立;对齐标准 BOM 既有策略改为"租户内物料真实存在即可"。
  租户隔离与存在性校验保持严格,共享的 AdoS0ReferenceChecker 未改动,
  Update 的 D-013 / D-03 兼容语义原样保留。

ISSUE A · FUNC-S0-001 标准 BOM 多工厂作用域计算错误
- 原用 list[0].FactoryRefId 决定整页的虚拟件集合与工序聚合范围;
  同一租户可存在多组 (公司, 工厂),跨组分页会把 A 工厂数据算到 B 工厂的行上。
- 改为按本页出现的 scope 组合批量取数一次并分桶,每行只用自己 (公司, 工厂)
  的虚拟件集合与 (工厂, 父件, 子件) 的工序覆盖,无 per-row 查询、无 N+1。
- 单工厂租户结果完全不变;keyword / 排序 / 分页 / BFS 成品候选 / D-016 均未触碰。

同批修正两处与代码或数据现实不符的注释(仅注释,无行为变化)。

chore: bump version Web 2.4.362 / server 1.0.463
YY968XX 2 dni temu
rodzic
commit
74003343d5

+ 1 - 1
Web/package.json

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

+ 15 - 4
Web/src/views/aidop/s0/manufacturing/RoutingList.vue

@@ -190,14 +190,19 @@ import { computed, onMounted, reactive, ref } from 'vue';
 import { useRoute } from 'vue-router';
 import { useRoute } from 'vue-router';
 import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus';
 import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus';
 import AidopDemoShell from '../../components/AidopDemoShell.vue';
 import AidopDemoShell from '../../components/AidopDemoShell.vue';
+import { applyDefaultOrgScopeForCreate, useS0MfgOrgScope } from '../composables/useS0MfgOrgScope';
 import { s0MfgRoutingOpDetailsApi, type S0MfgRoutingOpDetailRow, type S0MfgRoutingOpDetailUpsert } from '../api/s0ManufacturingApi';
 import { s0MfgRoutingOpDetailsApi, type S0MfgRoutingOpDetailRow, type S0MfgRoutingOpDetailUpsert } from '../api/s0ManufacturingApi';
 
 
 const route = useRoute();
 const route = useRoute();
 const pageTitle = computed(() => (route.meta?.title as string) || '标准工艺路线列表');
 const pageTitle = computed(() => (route.meta?.title as string) || '标准工艺路线列表');
 type Row = Record<string, any>;
 type Row = Record<string, any>;
 
 
-// 行级工艺路线(legacy RoutingOpDetail 复刻)不建模组织作用域:全表 company/factory 多为 0,
-// 故页面不展示公司/工厂筛选与列;company/factory 仅在写回 DTO 时原样透传,保留既有 scope。
+// 公司/工厂是**系统后台归属字段**,前台不展示筛选与列,但必须有值:
+//   新增 → openCreate 补当前租户可用的默认公司/工厂(与标准 BOM 同一机制,不另造算法);
+//   编辑 → rowToForm 原样回填记录自身归属(legacy 1000/1000、0/0 一律保留,不转雪花)。
+// 原实现新增时两个字段恒为 undefined,序列化后整键丢失,后端非空 long 绑定成 0/0,
+// 再被 Create 侧的物料作用域校验判为 ScopeMiss,导致新增 100% 返回 400。
+const { companyOptions, factoryOptions, loadOrgs } = useS0MfgOrgScope();
 
 
 const query = reactive({
 const query = reactive({
 	keyword: '',
 	keyword: '',
@@ -217,8 +222,9 @@ const formRef = ref<FormInstance>();
 
 
 function emptyForm() {
 function emptyForm() {
 	return {
 	return {
-		companyRefId: undefined,
-		factoryRefId: undefined,
+		// 组织归属为 string 语义(雪花 id 超 JS number 精度,禁止转 number)
+		companyRefId: undefined as string | undefined,
+		factoryRefId: undefined as string | undefined,
 		routeCode: '',
 		routeCode: '',
 		routeName: '',
 		routeName: '',
 		materialCode: '',
 		materialCode: '',
@@ -331,6 +337,9 @@ function rowToForm(row: S0MfgRoutingOpDetailRow) {
 
 
 function openCreate() {
 function openCreate() {
 	resetForm();
 	resetForm();
+	// 新增:补当前租户可用的默认公司/工厂归属,用户无需选择。
+	// 只补空值、不覆盖,且只作用于新增;编辑走 rowToForm 的原值回填,不受影响。
+	applyDefaultOrgScopeForCreate(form, companyOptions.value, factoryOptions.value);
 	dialogTitle.value = '新增工艺路线明细';
 	dialogTitle.value = '新增工艺路线明细';
 	dialogVisible.value = true;
 	dialogVisible.value = true;
 }
 }
@@ -426,6 +435,8 @@ function toggleEnabled(row: Row) {
 }
 }
 
 
 onMounted(async () => {
 onMounted(async () => {
+	// 组织下拉只用于解析新增时的默认归属,不进入查询条件,取数失败不阻断列表。
+	await loadOrgs().catch(() => {});
 	await loadList();
 	await loadList();
 });
 });
 </script>
 </script>

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

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

+ 17 - 11
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S0/Manufacturing/AdoS0MfgRoutingOpDetailsController.cs

@@ -71,17 +71,23 @@ public class AdoS0MfgRoutingOpDetailsController : ControllerBase
         var err = ValidateUpsert(dto);
         var err = ValidateUpsert(dto);
         if (err != null) return BadRequest(new { message = err });
         if (err != null) return BadRequest(new { message = err });
 
 
-        // B1 + B2:Create 一律严格校验,禁止降级
-        var matResult = await _refChecker.MaterialExistsInScopeAsync(tenantId, dto.MaterialCode, dto.CompanyRefId, dto.FactoryRefId);
-        switch (matResult)
-        {
-            case MaterialScopeCheck.NotFound:
-                return AdoS0ApiErrors.InvalidReference(AdoS0ErrorCodes.ReferenceNotFound,
-                    $"物料编码 '{dto.MaterialCode}' 不存在于物料主数据");
-            case MaterialScopeCheck.ScopeMiss:
-                return AdoS0ApiErrors.InvalidReference(AdoS0ErrorCodes.InvalidReferenceScope,
-                    $"物料编码 '{dto.MaterialCode}' 不属于当前公司/工厂 (CompanyRefId={dto.CompanyRefId}, FactoryRefId={dto.FactoryRefId})");
-        }
+        // B1:Create 的物料引用严格校验 = 租户隔离 + 物料真实存在。
+        //
+        // 原实现在此额外要求 ItemMaster.Company/FactoryRefId 与本 DTO 的组织归属**数值完全相等**,
+        // 但这两个值分属两套没有映射关系的 namespace:
+        //   Routing/表单组织归属 → SysOrg 雪花 id(如 797403761152069)
+        //   ItemMaster 历史归属   → legacy 数值域(797/824 租户实测全部为 1000/8010)
+        // 跨 namespace 强比较没有语义基础、恒不成立,实测该租户 16150 条物料无一能通过,
+        // 新增因此 100% 被判 ScopeMiss 返回 400(历史 32309 行 RoutingOpDetail 同样一条都过不了)。
+        // 同域的标准 BOM(AdoS0ProductStructuresController.ValidateMaterialRefsAsync)早已因同一
+        // 原因改为"引用真实存在即可",此处对齐该既有策略。
+        //
+        // 注意这不是取消校验:租户隔离与物料存在性仍然严格,移除的只是那条无语义基础的数值比较。
+        // 也不改动共享的 AdoS0ReferenceChecker —— 只把本调用点换成同类中已有的 tenant 内存在性方法,
+        // 其他仍依赖严格 scope 的功能不受影响。legacy↔SysOrg 组织域映射治理(I-007)不在本轮范围。
+        if (!await _refChecker.MaterialExistsAsync(tenantId, dto.MaterialCode))
+            return AdoS0ApiErrors.InvalidReference(AdoS0ErrorCodes.ReferenceNotFound,
+                $"物料编码 '{dto.MaterialCode}' 不存在于物料主数据");
 
 
         if (!await _refChecker.WorkCenterExistsAsync(tenantId, dto.WorkCenterCode))
         if (!await _refChecker.WorkCenterExistsAsync(tenantId, dto.WorkCenterCode))
             return AdoS0ApiErrors.InvalidReference(AdoS0ErrorCodes.ReferenceNotFound,
             return AdoS0ApiErrors.InvalidReference(AdoS0ErrorCodes.ReferenceNotFound,

+ 39 - 18
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S0/Manufacturing/AdoS0ProductStructuresController.cs

@@ -45,10 +45,11 @@ public class AdoS0ProductStructuresController : ControllerBase
         {
         {
             var kw = q.Keyword!.Trim();
             var kw = q.Keyword!.Trim();
             // 直接匹配 BOM 主表存储的父/子项物料编码文本(ParentItem/ComponentItem)。
             // 直接匹配 BOM 主表存储的父/子项物料编码文本(ParentItem/ComponentItem)。
-            // legacy BOM 行的 ParentMaterialId/ComponentMaterialId 多为 NULL(未解析到 ItemMaster.Id),
-            // 原"keyword→ItemMaster.Id→Contains(ParentMaterialId)"关联会漏掉几乎全部历史数据,
-            // 且依赖 CompanyRefId/FactoryRefId(查询区已不再提供)导致整段过滤被跳过。
-            // 改为按父/子项编码文本模糊匹配,对 NULL-FK 行同样生效,对应"父级物料编码"查询语义。
+            // 原"keyword→ItemMaster.Id→Contains(ParentMaterialId)"关联依赖 CompanyRefId/FactoryRefId
+            // (查询区已不再提供)导致整段过滤被跳过,且对未解析到 ItemMaster.Id 的行直接漏掉。
+            // 改为按父/子项编码文本模糊匹配:不依赖 FK 是否解析成功,对应"父级物料编码"查询语义。
+            // 注:当初写下本注释时 legacy 行的 Parent/ComponentMaterialId 大量为 NULL,该描述已过期
+            // ——2026-09-02 实测全表 0 条 NULL/0(797 租户 57199 行全部已解析)。文本匹配的选型仍然成立。
             query = query.Where(m => m.ParentItem.Contains(kw) || m.ComponentItem.Contains(kw));
             query = query.Where(m => m.ParentItem.Contains(kw) || m.ComponentItem.Contains(kw));
         }
         }
 
 
@@ -57,16 +58,29 @@ public class AdoS0ProductStructuresController : ControllerBase
         if (list.Count == 0)
         if (list.Count == 0)
             return Ok(new { total, page, pageSize, list });
             return Ok(new { total, page, pageSize, list });
 
 
-        var factoryId = q.FactoryRefId ?? list[0].FactoryRefId;
-        var companyId = q.CompanyRefId ?? list[0].CompanyRefId;
-
-        var phantomComponents = await _masterRep.ScopedTo(tenantId)
-            .Where(x => x.FactoryRefId == factoryId && x.CompanyRefId == companyId
+        // 作用域必须按行隔离,**不得**用 list[0] 的公司/工厂代表整页。
+        // 一个租户可以同时存在多组 (公司, 工厂):实测 tenant 838257186181189 的 BOM 同时有
+        // 雪花 scope 与 legacy 1000/8010 两组。原实现用首行 scope 计算整页的虚拟件集合与工序聚合,
+        // 跨组分页时会把 A 工厂的数据算到 B 工厂的行上(前结构 / 工序两列出错)。
+        // 这里改为:先取本页出现的 scope 组合,批量取数一次,再按行各取自己的桶——不做 per-row 查询,无 N+1。
+        var scopeCompanyIds = list.Select(x => x.CompanyRefId).Distinct().ToList();
+        var scopeFactoryIds = list.Select(x => x.FactoryRefId).Distinct().ToList();
+
+        // 两个 IN 是 scope 组合的**超集**(可能多取到本页不存在的公司×工厂交叉组合);
+        // 下面按行真实的 (公司, 工厂) 分桶,多余的桶不会被任何行命中,结果不受影响。
+        var phantomRows = await _masterRep.ScopedTo(tenantId)
+            .Where(x => scopeCompanyIds.Contains(x.CompanyRefId) && scopeFactoryIds.Contains(x.FactoryRefId)
                 && x.StructureType != null && x.StructureType.ToUpper() == "X")
                 && x.StructureType != null && x.StructureType.ToUpper() == "X")
-            .Select(x => x.ComponentMaterialId)
-            .Distinct()
+            .Select(x => new { x.CompanyRefId, x.FactoryRefId, x.ComponentMaterialId })
             .ToListAsync();
             .ToListAsync();
-        var phantomSet = phantomComponents.ToHashSet();
+        var phantomByScope = new Dictionary<(long CompanyRefId, long FactoryRefId), HashSet<long>>();
+        foreach (var p in phantomRows)
+        {
+            var key = (p.CompanyRefId, p.FactoryRefId);
+            if (!phantomByScope.TryGetValue(key, out var set))
+                phantomByScope[key] = set = new HashSet<long>();
+            set.Add(p.ComponentMaterialId);
+        }
 
 
         var materialIds = list.SelectMany(m => new[] { m.ParentMaterialId, m.ComponentMaterialId }).Distinct().ToList();
         var materialIds = list.SelectMany(m => new[] { m.ParentMaterialId, m.ComponentMaterialId }).Distinct().ToList();
         var materials = await _materialRep.ScopedTo(tenantId)
         var materials = await _materialRep.ScopedTo(tenantId)
@@ -76,12 +90,15 @@ public class AdoS0ProductStructuresController : ControllerBase
 
 
         var pids = list.Select(x => x.ParentMaterialId).Distinct().ToList();
         var pids = list.Select(x => x.ParentMaterialId).Distinct().ToList();
         var cids = list.Select(x => x.ComponentMaterialId).Distinct().ToList();
         var cids = list.Select(x => x.ComponentMaterialId).Distinct().ToList();
+        // 工序覆盖同样按工厂隔离:聚合键带上 FactoryRefId,避免不同工厂下同一 (父件, 子件)
+        // 的工序号被串到一起(原实现只按首行 factory 过滤,跨工厂分页时必然串用)。
         var allOps = await _opRep.ScopedTo(tenantId)
         var allOps = await _opRep.ScopedTo(tenantId)
-            .Where(o => o.FactoryRefId == factoryId && pids.Contains(o.ParentMaterialId) && cids.Contains(o.ComponentMaterialId))
+            .Where(o => scopeFactoryIds.Contains(o.FactoryRefId)
+                && pids.Contains(o.ParentMaterialId) && cids.Contains(o.ComponentMaterialId))
             .OrderBy(o => o.Op)
             .OrderBy(o => o.Op)
             .ToListAsync();
             .ToListAsync();
         var aggMap = allOps
         var aggMap = allOps
-            .GroupBy(o => (o.ParentMaterialId, o.ComponentMaterialId))
+            .GroupBy(o => (o.FactoryRefId, o.ParentMaterialId, o.ComponentMaterialId))
             .ToDictionary(
             .ToDictionary(
                 g => g.Key,
                 g => g.Key,
                 g => string.Join(",", g.Select(x => x.Op).Distinct().OrderBy(x => x)));
                 g => string.Join(",", g.Select(x => x.Op).Distinct().OrderBy(x => x)));
@@ -90,12 +107,15 @@ public class AdoS0ProductStructuresController : ControllerBase
         {
         {
             matMap.TryGetValue(m.ParentMaterialId, out var pm);
             matMap.TryGetValue(m.ParentMaterialId, out var pm);
             matMap.TryGetValue(m.ComponentMaterialId, out var cm);
             matMap.TryGetValue(m.ComponentMaterialId, out var cm);
-            var preStructureType = phantomSet.Contains(m.ParentMaterialId) ? "X" : "";
+            // 每行只看**自己所属工厂**的虚拟件集合与工序覆盖,不跨 scope 取数。
+            var parentIsPhantom = phantomByScope.TryGetValue((m.CompanyRefId, m.FactoryRefId), out var rowPhantoms)
+                && rowPhantoms.Contains(m.ParentMaterialId);
+            var preStructureType = parentIsPhantom ? "X" : "";
             var structureTypeLabel = string.Equals(m.StructureType, "X", StringComparison.OrdinalIgnoreCase) ? "是" : "";
             var structureTypeLabel = string.Equals(m.StructureType, "X", StringComparison.OrdinalIgnoreCase) ? "是" : "";
-            var useAgg = phantomSet.Contains(m.ParentMaterialId)
+            var useAgg = parentIsPhantom
                 && !string.Equals(m.StructureType, "X", StringComparison.OrdinalIgnoreCase);
                 && !string.Equals(m.StructureType, "X", StringComparison.OrdinalIgnoreCase);
             var opDisplay = useAgg
             var opDisplay = useAgg
-                ? (aggMap.TryGetValue((m.ParentMaterialId, m.ComponentMaterialId), out var s) ? s : "")
+                ? (aggMap.TryGetValue((m.FactoryRefId, m.ParentMaterialId, m.ComponentMaterialId), out var s) ? s : "")
                 : (m.LineOp.HasValue ? m.LineOp.Value.ToString() : "");
                 : (m.LineOp.HasValue ? m.LineOp.Value.ToString() : "");
 
 
             return new
             return new
@@ -381,7 +401,8 @@ public class AdoS0ProductStructuresController : ControllerBase
     /// 依据已证实的业务关系:ProductStructureOp 是「某顶层成品展开 BOM 时,对某条
     /// 依据已证实的业务关系:ProductStructureOp 是「某顶层成品展开 BOM 时,对某条
     /// (ParentItem → ComponentItem) 结构行的工序号覆盖」,其 ProductItem 恒为该父件在
     /// (ParentItem → ComponentItem) 结构行的工序号覆盖」,其 ProductItem 恒为该父件在
     /// BOM 中的祖先根件(实测 808/808 命中)。这里沿 ComponentItem→ParentItem 逐层上溯,
     /// BOM 中的祖先根件(实测 808/808 命中)。这里沿 ComponentItem→ParentItem 逐层上溯,
-    /// 取「不再作为任何行 ComponentItem 出现」的顶层件作为候选。
+    /// 把**沿途每一层的祖先**都收进候选集(不是只取「不再作为任何行 ComponentItem 出现」的顶层件)——
+    /// 这与下面「候选集是上限而非实际值」的定位一致:服务端只做合法性收敛,不替用户挑成品。
     ///
     ///
     /// 注意:候选集是**上限**而非实际值——历史数据里同一父件的 7 个祖先只有 4 个有覆盖行,
     /// 注意:候选集是**上限**而非实际值——历史数据里同一父件的 7 个祖先只有 4 个有覆盖行,
     /// 说明具体选哪些是人工决定,服务端只做合法性收敛,不自动派生。
     /// 说明具体选哪些是人工决定,服务端只做合法性收敛,不自动派生。