Просмотр исходного кода

feat(s6): add tooling ledger CRUD with tenant-scoped writes

FUNC-S6-004 模工具台账管理由只读列表扩为完整 CRUD V1(详情/新增/编辑/删除),
既有 list 与 type-options 的路由、签名、返回结构保持不变。

写字段严格限于 9 个白名单业务字段(lx / MoldName / MoldCount / MoldTypeCode /
gnyymc / product_material_code / product_drawing_number / product_name / process)。
语义未确认的 13 列(gys / gysmc / MoldUsingDepartment / zrr / zt / sm / bypl / fj /
xh / bh / gnyyid / MoldMaintenanceReleaseTime / ActiveMaintenanceMoldCount)
不进 Upsert DTO、不被写路径触碰;「使用部门」查询条件与「供应商名」列保持原样。

租户隔离为硬边界:租户只取 JWT(AidopTenantScope.ResolveOrThrow),
DTO 不含 TenantId,创建由服务端盖章,详情/编辑/删除一律 tenant-scoped 加载后
再以 id + tenant_id 双条件写库,跨租户一律 404 且不泄露记录存在性。

MoldTypeCode 为租户内唯一业务编码:编辑入参类型上即不含该字段(不只靠前端 disabled),
应用层做重复预检,并新增唯一索引 uk_MoldToolList_tenant_type_code 兜底并发竞态。

删除前经 MoldToolReferenceChecker 检查 ProdLineDetail.MoldTypeCode 与
ScheduleResultOpMaster.MoldTypeCode,命中即 409 拒删,不级联、不清空引用。

新增实体 AdoS6MoldToolList 标记 [IgnoreTable],禁止 CodeFirst 改动该 legacy 表
(重启前后已实测 schema/索引/行数无变化)。

类型下拉改用固定枚举 模具/工装/检具,解决空租户下 DISTINCT lx 返回空、
无法创建首条数据的问题。

chore: bump version Web 2.4.347 / server 1.0.438
YY968XX 9 часов назад
Родитель
Сommit
71a63cef2d

+ 1 - 1
Web/package.json

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

+ 72 - 0
Web/src/views/aidop/s6/api/toolingLedger.ts

@@ -37,6 +37,62 @@ export interface ToolingLedgerTypeOption {
 	val?: string;
 }
 
+/**
+ * 类型(lx)固定业务枚举。与后端 ToolingLedgerConst.Types 一一对应。
+ * 依据:MoldToolList 全表 lx 仅这 3 值,且系统无对应字典。
+ * 不再用 type-options(SELECT DISTINCT lx)驱动新增表单——空租户下它返回 [],会导致无法创建首条数据。
+ * type-options 仍供查询区使用,保持不变。
+ */
+export const TOOLING_LEDGER_TYPES = ['模具', '工装', '检具'] as const;
+
+/** 详情 / 新增 / 编辑共用的 V1 白名单字段(9 个业务字段 + 主键)。 */
+export interface ToolingLedgerDetail {
+	id: number;
+	/** 类型 lx */
+	type?: string;
+	/** 名称 MoldName */
+	name?: string;
+	/** 数量 MoldCount */
+	qty: number;
+	/** 模具类型编码 MoldTypeCode(创建后不可修改) */
+	moldTypeCode?: string;
+	/** 工作组 / 功能应用名称 gnyymc */
+	workGroup?: string;
+	/** 对应产品物料编码 product_material_code */
+	productMaterialCode?: string;
+	/** 对应产品图纸号 product_drawing_number */
+	productDrawingNo?: string;
+	/** 对应产品名称 product_name */
+	productName?: string;
+	/** 对应工序 process */
+	process?: string;
+}
+
+/** 新增入参:9 个白名单字段。禁止出现 id / tenantId / 任何后置字段。 */
+export interface ToolingLedgerCreateBody {
+	type: string;
+	name: string;
+	qty: number;
+	moldTypeCode: string;
+	workGroup?: string;
+	productMaterialCode?: string;
+	productDrawingNo?: string;
+	productName?: string;
+	process?: string;
+}
+
+/** 编辑入参:刻意不含 moldTypeCode —— 从类型层面消除误改业务编码的路径。 */
+export interface ToolingLedgerUpdateBody {
+	type: string;
+	name: string;
+	qty: number;
+	workGroup?: string;
+	productMaterialCode?: string;
+	productDrawingNo?: string;
+	productName?: string;
+	process?: string;
+}
+
 export function fetchToolingLedgerList(params: any) {
 	return service
 		.get<ToolingLedgerPaged<ToolingLedgerRow>>('/api/S6ToolingLedger/list', { params: withAidopTenantParams(params) })
@@ -46,3 +102,19 @@ export function fetchToolingLedgerList(params: any) {
 export function fetchToolingLedgerTypeOptions() {
 	return service.get<ToolingLedgerTypeOption[]>('/api/S6ToolingLedger/type-options').then((r) => r.data);
 }
+
+export function fetchToolingLedgerDetail(id: number) {
+	return service.get<ToolingLedgerDetail>(`/api/S6ToolingLedger/detail/${id}`).then((r) => r.data);
+}
+
+export function createToolingLedger(body: ToolingLedgerCreateBody) {
+	return service.post<ToolingLedgerDetail>('/api/S6ToolingLedger/create', body).then((r) => r.data);
+}
+
+export function updateToolingLedger(id: number, body: ToolingLedgerUpdateBody) {
+	return service.put<ToolingLedgerDetail>(`/api/S6ToolingLedger/update/${id}`, body).then((r) => r.data);
+}
+
+export function deleteToolingLedger(id: number) {
+	return service.delete<{ message?: string }>(`/api/S6ToolingLedger/delete/${id}`).then((r) => r.data);
+}

+ 212 - 3
Web/src/views/aidop/s6/equipment-tooling/toolingLedgerList.vue

@@ -1,5 +1,9 @@
 <template>
-	<AidopDemoShell :title="pageTitle" subtitle="只读列表">
+	<AidopDemoShell :title="pageTitle" subtitle="模工具台账维护">
+		<template #bar-right>
+			<el-button type="primary" @click="openCreate">新增</el-button>
+		</template>
+
 		<el-form :inline="true" :model="query" class="mb12" @submit.prevent>
 			<el-form-item label="名称">
 				<el-input v-model="query.name" clearable style="width: 200px" />
@@ -35,6 +39,12 @@
 			<el-table-column prop="productDrawingNo" label="对应产品图纸号" min-width="150" show-overflow-tooltip resizable />
 			<el-table-column prop="productName" label="对应产品名称" min-width="150" show-overflow-tooltip resizable />
 			<el-table-column prop="process" label="对应工序" min-width="130" show-overflow-tooltip resizable />
+			<el-table-column label="操作" width="140" fixed="right" align="center">
+				<template #default="{ row }">
+					<el-button link type="primary" @click="openEdit(row)">编辑</el-button>
+					<el-button link type="danger" @click="onDelete(row)">删除</el-button>
+				</template>
+			</el-table-column>
 			<template #empty>
 				<el-empty description="暂无数据" />
 			</template>
@@ -51,15 +61,84 @@
 				@size-change="loadList"
 			/>
 		</div>
+
+		<el-dialog v-model="dialogVisible" :title="dialogTitle" width="720px" destroy-on-close @closed="resetForm">
+			<el-form ref="formRef" :model="form" :rules="rules" label-width="130px">
+				<el-row :gutter="12">
+					<el-col :span="12">
+						<el-form-item label="类型" prop="type">
+							<el-select v-model="form.type" placeholder="请选择" style="width: 100%">
+								<el-option v-for="t in TOOLING_LEDGER_TYPES" :key="t" :label="t" :value="t" />
+							</el-select>
+						</el-form-item>
+					</el-col>
+					<el-col :span="12">
+						<el-form-item label="名称" prop="name">
+							<el-input v-model="form.name" maxlength="255" />
+						</el-form-item>
+					</el-col>
+					<el-col :span="12">
+						<el-form-item label="数量" prop="qty">
+							<el-input-number v-model="form.qty" :min="0" :precision="0" controls-position="right" style="width: 100%" />
+						</el-form-item>
+					</el-col>
+					<el-col :span="12">
+						<!-- 模具类型编码是被 ProdLineDetail 按值引用的业务编码,系统无级联更新 → 创建后锁定 -->
+						<el-form-item label="模具类型编码" prop="moldTypeCode">
+							<el-input v-model="form.moldTypeCode" :disabled="isEdit" maxlength="255" />
+						</el-form-item>
+					</el-col>
+					<el-col :span="12">
+						<el-form-item label="工作组">
+							<el-input v-model="form.workGroup" maxlength="255" />
+						</el-form-item>
+					</el-col>
+					<el-col :span="12">
+						<el-form-item label="对应产品物料编码">
+							<el-input v-model="form.productMaterialCode" maxlength="255" />
+						</el-form-item>
+					</el-col>
+					<el-col :span="12">
+						<el-form-item label="对应产品图纸号">
+							<el-input v-model="form.productDrawingNo" maxlength="255" />
+						</el-form-item>
+					</el-col>
+					<el-col :span="12">
+						<el-form-item label="对应产品名称">
+							<el-input v-model="form.productName" maxlength="255" />
+						</el-form-item>
+					</el-col>
+					<el-col :span="12">
+						<el-form-item label="对应工序">
+							<el-input v-model="form.process" maxlength="255" />
+						</el-form-item>
+					</el-col>
+				</el-row>
+			</el-form>
+			<template #footer>
+				<el-button :disabled="saving" @click="dialogVisible = false">取消</el-button>
+				<el-button type="primary" :loading="saving" :disabled="saving" @click="submitForm">保存</el-button>
+			</template>
+		</el-dialog>
 	</AidopDemoShell>
 </template>
 
 <script setup lang="ts" name="aidopS6EquipmentToolingToolingLedger">
 import { computed, onActivated, onMounted, reactive, ref } from 'vue';
 import { useRoute } from 'vue-router';
-import { ElMessage } from 'element-plus';
+import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus';
 import AidopDemoShell from '/@/views/aidop/components/AidopDemoShell.vue';
-import { fetchToolingLedgerList, fetchToolingLedgerTypeOptions, type ToolingLedgerRow, type ToolingLedgerTypeOption } from '../api/toolingLedger';
+import {
+	TOOLING_LEDGER_TYPES,
+	createToolingLedger,
+	deleteToolingLedger,
+	fetchToolingLedgerDetail,
+	fetchToolingLedgerList,
+	fetchToolingLedgerTypeOptions,
+	updateToolingLedger,
+	type ToolingLedgerRow,
+	type ToolingLedgerTypeOption,
+} from '../api/toolingLedger';
 
 const route = useRoute();
 const pageTitle = computed(() => (route.meta?.title as string) || '模工具台账管理');
@@ -79,6 +158,42 @@ const rows = ref<ToolingLedgerRow[]>([]);
 const total = ref(0);
 const typeOptions = ref<ToolingLedgerTypeOption[]>([]);
 
+const dialogVisible = ref(false);
+const saving = ref(false);
+const editingId = ref<number | null>(null);
+const formRef = ref<FormInstance>();
+const isEdit = computed(() => editingId.value !== null);
+const dialogTitle = computed(() => (isEdit.value ? '编辑模工具' : '新增模工具'));
+
+const form = reactive({
+	type: '',
+	name: '',
+	qty: 1 as number | null,
+	moldTypeCode: '',
+	workGroup: '',
+	productMaterialCode: '',
+	productDrawingNo: '',
+	productName: '',
+	process: '',
+});
+
+const rules: FormRules = {
+	type: [{ required: true, message: '请选择类型', trigger: 'change' }],
+	name: [{ required: true, message: '请填写名称', trigger: 'blur' }],
+	qty: [
+		{
+			// 显式判空:0 是合法数量,不得被 required 当成空值拦下
+			validator: (_rule: any, value: any, callback: (e?: Error) => void) => {
+				if (value === null || value === undefined || value === '') return callback(new Error('请填写数量'));
+				if (Number(value) < 0) return callback(new Error('数量不能为负数'));
+				callback();
+			},
+			trigger: 'blur',
+		},
+	],
+	moldTypeCode: [{ required: true, message: '请填写模具类型编码', trigger: 'blur' }],
+};
+
 async function loadList() {
 	loading.value = true;
 	try {
@@ -125,6 +240,100 @@ function resetQuery() {
 	loadList();
 }
 
+function resetForm() {
+	editingId.value = null;
+	Object.assign(form, {
+		type: '',
+		name: '',
+		qty: 1,
+		moldTypeCode: '',
+		workGroup: '',
+		productMaterialCode: '',
+		productDrawingNo: '',
+		productName: '',
+		process: '',
+	});
+	formRef.value?.clearValidate();
+}
+
+function openCreate() {
+	resetForm();
+	dialogVisible.value = true;
+}
+
+async function openEdit(row: ToolingLedgerRow) {
+	if (row.id == null) return;
+	resetForm();
+	try {
+		const detail = await fetchToolingLedgerDetail(row.id);
+		editingId.value = detail.id;
+		Object.assign(form, {
+			type: detail.type || '',
+			name: detail.name || '',
+			qty: detail.qty ?? 0,
+			moldTypeCode: detail.moldTypeCode || '',
+			workGroup: detail.workGroup || '',
+			productMaterialCode: detail.productMaterialCode || '',
+			productDrawingNo: detail.productDrawingNo || '',
+			productName: detail.productName || '',
+			process: detail.process || '',
+		});
+		dialogVisible.value = true;
+	} catch {
+		// 后端业务错误已由请求拦截器统一提示,此处不重复弹窗、不打开弹窗
+	}
+}
+
+async function submitForm() {
+	const valid = await formRef.value?.validate().catch(() => false);
+	if (!valid) return;
+
+	saving.value = true;
+	try {
+		const shared = {
+			type: form.type.trim(),
+			name: form.name.trim(),
+			qty: Number(form.qty ?? 0),
+			workGroup: form.workGroup.trim() || undefined,
+			productMaterialCode: form.productMaterialCode.trim() || undefined,
+			productDrawingNo: form.productDrawingNo.trim() || undefined,
+			productName: form.productName.trim() || undefined,
+			process: form.process.trim() || undefined,
+		};
+		if (editingId.value !== null) {
+			// 编辑不提交 moldTypeCode:类型层面即无该字段,后端也不接受
+			await updateToolingLedger(editingId.value, shared);
+			ElMessage.success('已保存');
+		} else {
+			await createToolingLedger({ ...shared, moldTypeCode: form.moldTypeCode.trim() });
+			ElMessage.success('已创建');
+		}
+		dialogVisible.value = false;
+		await loadList();
+	} catch {
+		// 保留弹窗与已填内容;后端业务错误(编码重复等)由请求拦截器统一中文提示
+	} finally {
+		saving.value = false;
+	}
+}
+
+function onDelete(row: ToolingLedgerRow) {
+	if (row.id == null) return;
+	ElMessageBox.confirm(`确定删除模工具「${row.name || row.moldTypeCode || row.id}」?`, '确认', { type: 'warning' })
+		.then(async () => {
+			try {
+				await deleteToolingLedger(row.id!);
+				ElMessage.success('已删除');
+				// 删掉当前页最后一条时回退一页,避免停在空页
+				if (rows.value.length === 1 && query.page > 1) query.page -= 1;
+				await loadList();
+			} catch {
+				// 被引用拦截(409)等业务错误由请求拦截器统一提示
+			}
+		})
+		.catch(() => {});
+}
+
 onMounted(() => {
 	loadOptions();
 	loadList();

+ 9 - 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.437</AssemblyVersion>
-    <FileVersion>1.0.437</FileVersion>
-    <Version>1.0.437</Version>
+    <AssemblyVersion>1.0.438</AssemblyVersion>
+    <FileVersion>1.0.438</FileVersion>
+    <Version>1.0.438</Version>
   </PropertyGroup>
 
   <ItemGroup>
@@ -619,6 +619,12 @@
     <None Update="UpdateScripts\1.0.436.verify.sql">
       <CopyToOutputDirectory>Always</CopyToOutputDirectory>
     </None>
+    <None Update="UpdateScripts\1.0.438.sql">
+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+    </None>
+    <None Update="UpdateScripts\1.0.438.verify.sql">
+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+    </None>
     <None Update="UpdateScripts\UAT-PLACEHOLDER-MENU-HIDE.ops.sql">
       <CopyToOutputDirectory>Always</CopyToOutputDirectory>
     </None>

+ 63 - 0
server/Admin.NET.Web.Entry/UpdateScripts/1.0.438.sql

@@ -0,0 +1,63 @@
+-- ===========================================================================
+-- S6-FUNC-004-TOOLING-LEDGER-CRUD-1
+-- MoldToolList(模工具台账)补租户内业务编码唯一索引
+--
+-- 目的:MoldTypeCode 是模工具台账的业务编码,且被下游按值引用
+--       (ProdLineDetail.MoldTypeCode 同租户命中 896 行)。
+--       本批为 FUNC-S6-004 增加 CRUD 后,必须由 DB 约束保证租户内唯一 ——
+--       仅靠应用层「先查后插」存在 TOCTOU 竞态,并发下会插入重复编码。
+--
+-- 范围:**只新增一个唯一索引**。
+--       不修改 tenant_id 可空性、不改任何列定义、不改字符集/collation、
+--       不清洗历史数据、不删除任何行。
+--
+-- 前置实测(2026-09-01,aidopdev,只读):
+--       MoldToolList 共 290 行,全部 tenant_id = 824585161322565
+--       tenant_id IS NULL              = 0
+--       MoldTypeCode IS NULL           = 0
+--       TRIM(MoldTypeCode) = ''        = 0
+--       MoldTypeCode <> TRIM(...)      = 0
+--       GROUP BY tenant_id,MoldTypeCode HAVING COUNT(*)>1 = 0 组
+--       现有索引:仅 PRIMARY(id)
+--
+-- ⚠️ tenant_id 可空的已知边界:MySQL 的 UNIQUE 不对 NULL 做相等判定,
+--     因此若将来出现 tenant_id IS NULL 的行,唯一性对该行不生效。
+--     当前 0 行,且应用层写入一律由服务端从 JWT 盖章 tenant_id,不会产生 NULL。
+--     本批**不**把 tenant_id 改成 NOT NULL(属独立的 schema 变更,且实体侧
+--     ITenantIdFilter.TenantId 为 long?,硬化会被 CodeFirst 还原)。
+--
+-- ⚠️ 事务性:MySQL DDL 隐式提交,AutoVersionUpdate 不开显式事务。
+--     故所有语句写成可安全重入:索引已存在则整段 no-op。
+-- ===========================================================================
+
+
+-- ---------------------------------------------------------------------------
+-- 01 · 前置守卫:新唯一键定义下不得存在重复,否则中止,不带病建索引、不清洗数据
+-- ---------------------------------------------------------------------------
+SET @dup := (SELECT COUNT(*) FROM (
+    SELECT tenant_id, MoldTypeCode
+    FROM MoldToolList
+    GROUP BY tenant_id, MoldTypeCode
+    HAVING COUNT(*) > 1) x);
+
+SET @g := IF(@dup = 0, 'DO 0', 'SELECT RAISE_MOLDTOOLLIST_DUPLICATE_TYPECODE_FOUND');
+PREPARE gstmt FROM @g;
+EXECUTE gstmt;
+DEALLOCATE PREPARE gstmt;
+
+
+-- ---------------------------------------------------------------------------
+-- 02 · 建唯一索引 uk_MoldToolList_tenant_type_code(tenant_id, MoldTypeCode)
+--      条件:同名索引不存在才建;已存在则整段 no-op(可重入)
+-- ---------------------------------------------------------------------------
+SET @idx_exists := (SELECT COUNT(DISTINCT INDEX_NAME) FROM information_schema.STATISTICS
+    WHERE TABLE_SCHEMA = DATABASE()
+      AND TABLE_NAME = 'MoldToolList'
+      AND INDEX_NAME = 'uk_MoldToolList_tenant_type_code');
+
+SET @s := IF(@idx_exists = 0,
+    'CREATE UNIQUE INDEX uk_MoldToolList_tenant_type_code ON MoldToolList (tenant_id, MoldTypeCode)',
+    'DO 0');
+PREPARE stmt FROM @s;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;

+ 45 - 0
server/Admin.NET.Web.Entry/UpdateScripts/1.0.438.verify.sql

@@ -0,0 +1,45 @@
+-- ===========================================================================
+-- S6-FUNC-004-TOOLING-LEDGER-CRUD-1 · verify
+--
+-- 契约(AutoVersionUpdate.RunVerifyScriptIfExists):逐条执行、取首行首列、
+-- 交 IsTruthy 判定,不真即抛。故:禁止 SET @var;每条语句自包含;
+-- 每条返回「通过为真」的标量(用 = 期望值 得 1/0),不得返回错误条数;
+-- 一条语句一个断言;文件以最后一条 SQL 的分号结束。
+-- ===========================================================================
+
+-- 01 · 唯一索引存在,且恰好由 (tenant_id, MoldTypeCode) 两列组成
+SELECT COUNT(*) = 2
+FROM information_schema.STATISTICS
+WHERE TABLE_SCHEMA = DATABASE()
+  AND TABLE_NAME = 'MoldToolList'
+  AND INDEX_NAME = 'uk_MoldToolList_tenant_type_code'
+  AND COLUMN_NAME IN ('tenant_id', 'MoldTypeCode');
+
+-- 02 · 该索引是 UNIQUE(NON_UNIQUE = 0)
+SELECT COUNT(*) = 2
+FROM information_schema.STATISTICS
+WHERE TABLE_SCHEMA = DATABASE()
+  AND TABLE_NAME = 'MoldToolList'
+  AND INDEX_NAME = 'uk_MoldToolList_tenant_type_code'
+  AND NON_UNIQUE = 0;
+
+-- 03 · 租户内业务编码无重复
+SELECT (SELECT COUNT(*) FROM (
+          SELECT tenant_id, MoldTypeCode
+          FROM MoldToolList
+          GROUP BY tenant_id, MoldTypeCode
+          HAVING COUNT(*) > 1) x) = 0;
+
+-- 04 · 未改动 tenant_id 可空性(本批只加索引,不做列变更)
+SELECT COUNT(*) = 1
+FROM information_schema.COLUMNS
+WHERE TABLE_SCHEMA = DATABASE()
+  AND TABLE_NAME = 'MoldToolList'
+  AND COLUMN_NAME = 'tenant_id'
+  AND IS_NULLABLE = 'YES';
+
+-- 05 · 表列数仍为 24(未新增/删除任何列)
+SELECT COUNT(*) = 24
+FROM information_schema.COLUMNS
+WHERE TABLE_SCHEMA = DATABASE()
+  AND TABLE_NAME = 'MoldToolList';

+ 70 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Entity/AdoS6MoldToolList.cs

@@ -0,0 +1,70 @@
+using Admin.NET.Core;
+
+namespace Admin.NET.Plugin.AiDOP.Entity;
+
+/// <summary>
+/// S6 模工具台账(复刻 legacy 表 MoldToolList,FUNC-S6-004)。
+///
+/// 只映射 CRUD V1 白名单字段 + 主键 + 租户列。其余 legacy 列(gys / gysmc / MoldUsingDepartment /
+/// zrr / zt / sm / bypl / fj / xh / bh / gnyyid / MoldMaintenanceReleaseTime /
+/// ActiveMaintenanceMoldCount)语义未确认,本批一律不映射、不写入。
+/// 写路径全部使用「显式 SetColumns + 显式 WHERE」或 Insertable,不做 delete+reinsert,
+/// 故未映射列在更新时天然保留(SAFE-BY-PARTIAL-UPDATE)。
+///
+/// [IgnoreTable]:MoldToolList 是外部导入的 legacy 表,表结构由 UpdateScripts 管理。
+/// 该特性使实体被 SqlSugarSetup.GetEntityTypesForInit 排除,禁止 CodeFirst 对其
+/// ALTER 列 / 改可空性 / 建索引。同理本实体**不得**登记进 Startup.cs 的显式
+/// db.CodeFirst.InitTables(...) 列表——那条路径不受 [IgnoreTable] 保护。
+///
+/// 租户:tenant_id 为唯一租户边界;实现 ITenantIdFilter 以获得全局查询过滤(defense-in-depth),
+/// 但业务代码仍必须在每条读写语句显式带 tenant 条件(全局过滤器对超管不生效)。
+/// 唯一键 uk_MoldToolList_tenant_type_code(tenant_id, MoldTypeCode) 由 UpdateScripts 维护。
+/// </summary>
+[IgnoreTable]
+[SugarTable("MoldToolList", "模工具台账(复刻 MoldToolList)")]
+public class AdoS6MoldToolList : ITenantIdFilter
+{
+    /// <summary>主键(legacy 自增 int)</summary>
+    [SugarColumn(ColumnName = "id", ColumnDescription = "主键", IsPrimaryKey = true, IsIdentity = true)]
+    public int Id { get; set; }
+
+    /// <summary>类型(模具 / 工装 / 检具)</summary>
+    [SugarColumn(ColumnName = "lx", ColumnDescription = "类型", Length = 255, IsNullable = true)]
+    public string? Lx { get; set; }
+
+    /// <summary>名称(DB NOT NULL)</summary>
+    [SugarColumn(ColumnName = "MoldName", ColumnDescription = "名称", Length = 255)]
+    public string MoldName { get; set; } = string.Empty;
+
+    /// <summary>数量(DB NOT NULL)</summary>
+    [SugarColumn(ColumnName = "MoldCount", ColumnDescription = "数量")]
+    public int MoldCount { get; set; }
+
+    /// <summary>模具类型编码(DB NOT NULL;租户内唯一业务编码,创建后不可修改)</summary>
+    [SugarColumn(ColumnName = "MoldTypeCode", ColumnDescription = "模具类型编码", Length = 255)]
+    public string MoldTypeCode { get; set; } = string.Empty;
+
+    /// <summary>工作组 / 功能应用名称(自由文本,源系统同列两名,忠实复刻)</summary>
+    [SugarColumn(ColumnName = "gnyymc", ColumnDescription = "工作组/功能应用名称", Length = 255, IsNullable = true)]
+    public string? Gnyymc { get; set; }
+
+    /// <summary>对应产品物料编码(自由文本,存量含 "/" 占位值)</summary>
+    [SugarColumn(ColumnName = "product_material_code", ColumnDescription = "对应产品物料编码", Length = 255, IsNullable = true)]
+    public string? ProductMaterialCode { get; set; }
+
+    /// <summary>对应产品图纸号</summary>
+    [SugarColumn(ColumnName = "product_drawing_number", ColumnDescription = "对应产品图纸号", Length = 255, IsNullable = true)]
+    public string? ProductDrawingNumber { get; set; }
+
+    /// <summary>对应产品名称</summary>
+    [SugarColumn(ColumnName = "product_name", ColumnDescription = "对应产品名称", Length = 255, IsNullable = true)]
+    public string? ProductName { get; set; }
+
+    /// <summary>对应工序</summary>
+    [SugarColumn(ColumnName = "process", ColumnDescription = "对应工序", Length = 255, IsNullable = true)]
+    public string? Process { get; set; }
+
+    /// <summary>租户(唯一租户边界;仅服务端从 JWT 盖章,不接受客户端传入)</summary>
+    [SugarColumn(ColumnName = "tenant_id", ColumnDescription = "租户", IsNullable = true)]
+    public long? TenantId { get; set; }
+}

+ 147 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Manufacturing/Dto/ToolingLedgerDto.cs

@@ -70,6 +70,153 @@ public class ToolingLedgerListRow
     public string? Process { get; set; }
 }
 
+/// <summary>
+/// S6 模工具台账 CRUD V1 常量(FUNC-S6-004)。
+/// </summary>
+public static class ToolingLedgerConst
+{
+    /// <summary>
+    /// 类型(lx)固定业务枚举。
+    /// 依据:MoldToolList 全表 290 行 lx 仅 模具 / 工装 / 检具 三值(HEX 逐值核对无异体、无 NULL、无空串),
+    /// 且系统内无对应字典。改为固定枚举是为了让**空租户也能创建首条数据**——
+    /// 原 type-options 走 SELECT DISTINCT lx,空租户返回 [] 会使新增无选项可选。
+    /// type-options 端点保持不变,仅供既有查询区继续使用。
+    /// </summary>
+    public static readonly string[] Types = ["模具", "工装", "检具"];
+}
+
+/// <summary>
+/// S6 模工具台账 详情(只含 CRUD V1 白名单字段 + 主键)。
+/// 不含 tenant_id,也不含任何本批 DEFERRED 字段。
+/// </summary>
+public class ToolingLedgerDetailDto
+{
+    /// <summary>主键(MoldToolList.id)</summary>
+    public int Id { get; set; }
+
+    /// <summary>类型(lx)</summary>
+    public string? Type { get; set; }
+
+    /// <summary>名称(MoldName)</summary>
+    public string? Name { get; set; }
+
+    /// <summary>数量(MoldCount)</summary>
+    public int Qty { get; set; }
+
+    /// <summary>模具类型编码(MoldTypeCode,创建后不可修改)</summary>
+    public string? MoldTypeCode { get; set; }
+
+    /// <summary>工作组 / 功能应用名称(gnyymc)</summary>
+    public string? WorkGroup { get; set; }
+
+    /// <summary>对应产品物料编码(product_material_code)</summary>
+    public string? ProductMaterialCode { get; set; }
+
+    /// <summary>对应产品图纸号(product_drawing_number)</summary>
+    public string? ProductDrawingNo { get; set; }
+
+    /// <summary>对应产品名称(product_name)</summary>
+    public string? ProductName { get; set; }
+
+    /// <summary>对应工序(process)</summary>
+    public string? Process { get; set; }
+}
+
+/// <summary>
+/// S6 模工具台账 新增入参。
+/// 白名单 9 字段;**禁止**出现 TenantId / Id / 任何 DEFERRED 字段
+/// (gys / gysmc / MoldUsingDepartment / zrr / zt / sm / bypl / fj /
+///  xh / bh / gnyyid / MoldMaintenanceReleaseTime / ActiveMaintenanceMoldCount)。
+/// MaxLength 一律对齐 DB 实际列长(全部 varchar(255)),不额外发明长度上限。
+/// </summary>
+public class ToolingLedgerCreateInput
+{
+    /// <summary>类型(lx,必填,限 模具 / 工装 / 检具)</summary>
+    [Required(ErrorMessage = "类型不能为空")]
+    [MaxLength(255)]
+    public string Type { get; set; } = string.Empty;
+
+    /// <summary>名称(MoldName,必填,DB NOT NULL)</summary>
+    [Required(ErrorMessage = "名称不能为空")]
+    [MaxLength(255)]
+    public string Name { get; set; } = string.Empty;
+
+    /// <summary>数量(MoldCount,必填,DB NOT NULL,不允许负数)</summary>
+    [Required(ErrorMessage = "数量不能为空")]
+    [Range(0, int.MaxValue, ErrorMessage = "数量不能为负数")]
+    public int? Qty { get; set; }
+
+    /// <summary>模具类型编码(MoldTypeCode,必填,DB NOT NULL,租户内唯一)</summary>
+    [Required(ErrorMessage = "模具类型编码不能为空")]
+    [MaxLength(255)]
+    public string MoldTypeCode { get; set; } = string.Empty;
+
+    /// <summary>工作组 / 功能应用名称(gnyymc,可选,自由文本)</summary>
+    [MaxLength(255)]
+    public string? WorkGroup { get; set; }
+
+    /// <summary>对应产品物料编码(product_material_code,可选)</summary>
+    [MaxLength(255)]
+    public string? ProductMaterialCode { get; set; }
+
+    /// <summary>对应产品图纸号(product_drawing_number,可选)</summary>
+    [MaxLength(255)]
+    public string? ProductDrawingNo { get; set; }
+
+    /// <summary>对应产品名称(product_name,可选)</summary>
+    [MaxLength(255)]
+    public string? ProductName { get; set; }
+
+    /// <summary>对应工序(process,可选)</summary>
+    [MaxLength(255)]
+    public string? Process { get; set; }
+}
+
+/// <summary>
+/// S6 模工具台账 编辑入参。
+/// **刻意不含 MoldTypeCode** —— 从类型层面消除「编辑期改业务编码」的路径,
+/// 而不是只靠前端 disabled。理由:ProdLineDetail 已有 896 条按编码引用且系统无级联更新,
+/// 改编码等于静默断链。
+/// 同样禁止出现 TenantId / Id / 任何 DEFERRED 字段。
+/// </summary>
+public class ToolingLedgerUpdateInput
+{
+    /// <summary>类型(lx,必填,限 模具 / 工装 / 检具)</summary>
+    [Required(ErrorMessage = "类型不能为空")]
+    [MaxLength(255)]
+    public string Type { get; set; } = string.Empty;
+
+    /// <summary>名称(MoldName,必填)</summary>
+    [Required(ErrorMessage = "名称不能为空")]
+    [MaxLength(255)]
+    public string Name { get; set; } = string.Empty;
+
+    /// <summary>数量(MoldCount,必填,不允许负数)</summary>
+    [Required(ErrorMessage = "数量不能为空")]
+    [Range(0, int.MaxValue, ErrorMessage = "数量不能为负数")]
+    public int? Qty { get; set; }
+
+    /// <summary>工作组 / 功能应用名称(gnyymc,可选)</summary>
+    [MaxLength(255)]
+    public string? WorkGroup { get; set; }
+
+    /// <summary>对应产品物料编码(product_material_code,可选)</summary>
+    [MaxLength(255)]
+    public string? ProductMaterialCode { get; set; }
+
+    /// <summary>对应产品图纸号(product_drawing_number,可选)</summary>
+    [MaxLength(255)]
+    public string? ProductDrawingNo { get; set; }
+
+    /// <summary>对应产品名称(product_name,可选)</summary>
+    [MaxLength(255)]
+    public string? ProductName { get; set; }
+
+    /// <summary>对应工序(process,可选)</summary>
+    [MaxLength(255)]
+    public string? Process { get; set; }
+}
+
 /// <summary>类型下拉选项(MoldToolList.lx distinct:模具 / 工装 / 检具)。</summary>
 public class ToolingLedgerTypeOption
 {

+ 58 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Manufacturing/MoldToolReferenceChecker.cs

@@ -0,0 +1,58 @@
+namespace Admin.NET.Plugin.AiDOP.Manufacturing;
+
+/// <summary>
+/// S6 模工具台账(MoldToolList)删除前的下游引用检查(FUNC-S6-004)。
+///
+/// 刻意**不复用** Infrastructure/AdoS0ReferenceChecker:那是 S0 共享基础设施,
+/// 改它需抢提交锁且会把 S6 逻辑混入 S0 模块边界(CLAUDE.md 第九节)。
+///
+/// 引用口径一律「租户内 + 按业务编码 MoldTypeCode 匹配」,与两张下游表的实际存储一致
+/// (下游存的是编码字符串,不是 MoldToolList.id)。
+///
+/// 覆盖范围(2026-09-01 实测):
+///   MUST   ProdLineDetail.MoldTypeCode         —— S0 线边物料/产线物料在用,活跃写入路径,
+///                                                 同租户命中 896 行(另有 369 行历史孤儿,非本批修复范围)
+///   SHOULD ScheduleResultOpMaster.MoldTypeCode —— 排产结果;当前生成逻辑未写该列(命中 0),
+///                                                 但 ExecutableDailyPlanService 已按该列做筛选消费,
+///                                                 属已声明且已被消费的引用位,一并纳入以免将来漏检
+/// 不纳入:LineMaterial.MoldTypeCode —— 空表、无任何 controller/service 读写路径,仅 CodeFirst 注册
+///
+/// 只读:本类只做 SELECT,不修改、不清空、不级联删除任何下游数据。
+/// </summary>
+public sealed class MoldToolReferenceChecker : ITransient
+{
+    private readonly ISqlSugarClient _db;
+
+    public MoldToolReferenceChecker(ISqlSugarClient db)
+    {
+        _db = db;
+    }
+
+    /// <summary>
+    /// 租户内检查该模具类型编码是否被下游引用。命中返回(条数, 中文表述),未命中返回 null。
+    /// </summary>
+    public async Task<(int Count, string Table)?> ReferencesAsync(long tenantId, string? moldTypeCode)
+    {
+        if (string.IsNullOrWhiteSpace(moldTypeCode)) return null;
+        var code = moldTypeCode.Trim();
+
+        var prodLine = await CountAsync(
+            "SELECT COUNT(1) FROM ProdLineDetail WHERE tenant_id = @TenantId AND MoldTypeCode = @Code",
+            tenantId, code);
+        if (prodLine > 0) return (prodLine, "线边物料/产线物料配置 (ProdLineDetail.MoldTypeCode)");
+
+        var schedule = await CountAsync(
+            "SELECT COUNT(1) FROM ScheduleResultOpMaster WHERE tenant_id = @TenantId AND MoldTypeCode = @Code",
+            tenantId, code);
+        if (schedule > 0) return (schedule, "排产结果工序 (ScheduleResultOpMaster.MoldTypeCode)");
+
+        return null;
+    }
+
+    private Task<int> CountAsync(string sql, long tenantId, string code)
+        => _db.Ado.GetIntAsync(sql, new List<SugarParameter>
+        {
+            new("@TenantId", tenantId),
+            new("@Code", code)
+        });
+}

+ 230 - 3
server/Plugins/Admin.NET.Plugin.AiDOP/Manufacturing/ToolingLedgerService.cs

@@ -1,16 +1,23 @@
+using Admin.NET.Plugin.AiDOP.Entity;
 using Admin.NET.Plugin.AiDOP.Manufacturing.Dto;
 
 namespace Admin.NET.Plugin.AiDOP.Manufacturing;
 
 /// <summary>
-/// S6 模工具台账管理 只读 list 服务。
+/// S6 模工具台账管理 服务(FUNC-S6-004)
 ///
 /// 数据源:aidopdev.MoldToolList(模工具台账),忠实复刻证据 sql.txt(select * from MoldToolList),
 /// 列映射见字段配置图:类型=lx / 名称=MoldName / 供应商名=gysmc / 数量=MoldCount / 功能应用名称=gnyymc /
 /// 模具类型编码=MoldTypeCode / 对应产品物料编码=product_material_code / 图纸号=product_drawing_number /
 /// 产品名称=product_name / 工序=process。lx 含 模具 / 工装 / 检具(同表同页同源,类型下拉可筛选)。
 ///
-/// 本服务仅 SELECT:无新增/编辑/删除/写入;所有查询严格按当前登录用户的可信租户过滤。
+/// list / type-options 为既有只读查询,本批不改其签名与返回结构。
+/// detail / create / update / delete 为 CRUD V1 新增能力,写字段严格限于 9 个白名单业务字段;
+/// gys / gysmc / MoldUsingDepartment / zrr / zt / sm / bypl / fj / xh / bh / gnyyid /
+/// MoldMaintenanceReleaseTime / ActiveMaintenanceMoldCount 语义未确认,本批不写。
+///
+/// 租户:所有读写一律经 AidopTenantScope.ResolveOrThrow 取 JWT 可信租户,并在每条语句显式带
+/// tenant 条件;不信任前端传入的 tenantId,不依赖全局 QueryFilter(其对超管不生效)。
 /// </summary>
 [ApiDescriptionSettings(Order = 320, Description = "模工具台账管理")]
 [Route("api/S6ToolingLedger")]
@@ -19,11 +26,13 @@ public class ToolingLedgerService : IDynamicApiController, ITransient
 {
     private readonly ISqlSugarClient _db;
     private readonly UserManager _userManager;
+    private readonly MoldToolReferenceChecker _refChecker;
 
-    public ToolingLedgerService(ISqlSugarClient db, UserManager userManager)
+    public ToolingLedgerService(ISqlSugarClient db, UserManager userManager, MoldToolReferenceChecker refChecker)
     {
         _db = db;
         _userManager = userManager;
+        _refChecker = refChecker;
     }
 
     /// <summary>
@@ -114,4 +123,222 @@ public class ToolingLedgerService : IDynamicApiController, ITransient
             new SugarParameter("@TenantId", tenantId));
         return list;
     }
+
+    // ─────────────────────────── CRUD V1(FUNC-S6-004) ───────────────────────────
+
+    /// <summary>
+    /// 模工具台账详情(租户内)。不存在或不属当前租户一律 404,不泄露记录存在性。
+    /// </summary>
+    [DisplayName("模工具台账详情")]
+    [HttpGet("detail/{id:int}")]
+    public async Task<IActionResult> GetDetail(int id)
+    {
+        var tenantId = Infrastructure.AidopTenantScope.ResolveOrThrow(_userManager);
+        var entity = await LoadScopedAsync(id, tenantId);
+        return entity == null ? NotFound() : new OkObjectResult(ToDetail(entity));
+    }
+
+    /// <summary>
+    /// 新增模工具台账。tenant_id 只由服务端从 JWT 盖章,不接受任何客户端租户入参。
+    /// </summary>
+    [DisplayName("新增模工具台账")]
+    [HttpPost("create")]
+    public async Task<IActionResult> Create([FromBody] ToolingLedgerCreateInput input)
+    {
+        var tenantId = Infrastructure.AidopTenantScope.ResolveOrThrow(_userManager);
+
+        var type = NormalizeType(input.Type);
+        var name = RequireText(input.Name, "名称");
+        var qty = RequireQty(input.Qty);
+        var code = RequireText(input.MoldTypeCode, "模具类型编码");
+
+        // 友好预检;真正的并发唯一性由 DB 唯一索引 uk_MoldToolList_tenant_type_code 保证(见下方 catch)。
+        var duplicated = await _db.Queryable<AdoS6MoldToolList>()
+            .Where(x => x.TenantId == tenantId && x.MoldTypeCode == code)
+            .AnyAsync();
+        if (duplicated) return DuplicateCode(code);
+
+        var entity = new AdoS6MoldToolList
+        {
+            TenantId = tenantId, // 服务端盖章,不取 DTO / query
+            Lx = type,
+            MoldName = name,
+            MoldCount = qty,
+            MoldTypeCode = code,
+            Gnyymc = Trim(input.WorkGroup),
+            ProductMaterialCode = Trim(input.ProductMaterialCode),
+            ProductDrawingNumber = Trim(input.ProductDrawingNo),
+            ProductName = Trim(input.ProductName),
+            Process = Trim(input.Process)
+        };
+
+        try
+        {
+            entity.Id = await _db.Insertable(entity).ExecuteReturnIdentityAsync();
+        }
+        catch (Exception ex) when (IsDuplicateKeyViolation(ex))
+        {
+            // 并发下预检与插入之间存在 TOCTOU 窗口,由唯一索引兜底,转成同一业务错误,不外泄 SQL 异常。
+            return DuplicateCode(code);
+        }
+
+        return new OkObjectResult(ToDetail(entity));
+    }
+
+    /// <summary>
+    /// 编辑模工具台账(租户内)。MoldTypeCode 与 tenant_id 均不可修改(入参类型上即不接受)。
+    /// </summary>
+    [DisplayName("编辑模工具台账")]
+    [HttpPut("update/{id:int}")]
+    public async Task<IActionResult> Update(int id, [FromBody] ToolingLedgerUpdateInput input)
+    {
+        var tenantId = Infrastructure.AidopTenantScope.ResolveOrThrow(_userManager);
+        var entity = await LoadScopedAsync(id, tenantId);
+        if (entity == null) return NotFound();
+
+        var type = NormalizeType(input.Type);
+        var name = RequireText(input.Name, "名称");
+        var qty = RequireQty(input.Qty);
+        var workGroup = Trim(input.WorkGroup);
+        var materialCode = Trim(input.ProductMaterialCode);
+        var drawingNo = Trim(input.ProductDrawingNo);
+        var productName = Trim(input.ProductName);
+        var process = Trim(input.Process);
+
+        // 显式 SET + 显式 WHERE(id + tenant_id):
+        // ① 杜绝裸 UPDATE ... WHERE id=@id;② 只写 8 个白名单列,
+        // MoldTypeCode / tenant_id / 全部 DEFERRED 列不出现在 SET 子句中。
+        var affected = await _db.Updateable<AdoS6MoldToolList>()
+            .SetColumns(x => new AdoS6MoldToolList
+            {
+                Lx = type,
+                MoldName = name,
+                MoldCount = qty,
+                Gnyymc = workGroup,
+                ProductMaterialCode = materialCode,
+                ProductDrawingNumber = drawingNo,
+                ProductName = productName,
+                Process = process
+            })
+            .Where(x => x.Id == id && x.TenantId == tenantId)
+            .ExecuteCommandAsync();
+        if (affected <= 0) return NotFound();
+
+        entity.Lx = type;
+        entity.MoldName = name;
+        entity.MoldCount = qty;
+        entity.Gnyymc = workGroup;
+        entity.ProductMaterialCode = materialCode;
+        entity.ProductDrawingNumber = drawingNo;
+        entity.ProductName = productName;
+        entity.Process = process;
+        return new OkObjectResult(ToDetail(entity));
+    }
+
+    /// <summary>
+    /// 删除模工具台账(租户内物理删除;表无软删列)。被下游引用时拒绝删除,不级联、不清空引用。
+    /// </summary>
+    [DisplayName("删除模工具台账")]
+    [HttpDelete("delete/{id:int}")]
+    public async Task<IActionResult> Delete(int id)
+    {
+        var tenantId = Infrastructure.AidopTenantScope.ResolveOrThrow(_userManager);
+        var entity = await LoadScopedAsync(id, tenantId);
+        if (entity == null) return NotFound();
+
+        var reference = await _refChecker.ReferencesAsync(tenantId, entity.MoldTypeCode);
+        if (reference is { } r)
+            return Problem(409, $"该模工具已被 {r.Count} 条{r.Table}引用,无法删除");
+
+        var affected = await _db.Deleteable<AdoS6MoldToolList>()
+            .Where(x => x.Id == id && x.TenantId == tenantId)
+            .ExecuteCommandAsync();
+        if (affected <= 0) return NotFound();
+
+        return new OkObjectResult(new { message = "删除成功" });
+    }
+
+    // ─────────────────────────── 内部辅助 ───────────────────────────
+
+    /// <summary>租户内按主键取单条;不存在或属于其他租户一律返回 null(调用方统一转 404,不泄露存在性)。</summary>
+    private Task<AdoS6MoldToolList> LoadScopedAsync(int id, long tenantId)
+        => _db.Queryable<AdoS6MoldToolList>()
+            .Where(x => x.Id == id && x.TenantId == tenantId)
+            .FirstAsync();
+
+    /// <summary>
+    /// 业务错误结果。刻意用 ObjectResult 显式给状态码,而不是 Oops.Oh(...).StatusCode(n):
+    /// 本服务是 [NonUnify] 的 IDynamicApiController,实测该路径下 Furion 友好异常一律落 400,
+    /// StatusCode(409/404) 不生效。校验类错误仍走 Oops.Oh(400 语义正确)。
+    /// body 为纯中文字符串,与既有 S6 错误响应形态一致,前端 request 拦截器可直接提取展示。
+    /// </summary>
+    private static IActionResult Problem(int statusCode, string message)
+        => new ObjectResult(message) { StatusCode = statusCode };
+
+    private static IActionResult NotFound() => Problem(404, "模工具台账记录不存在");
+
+    private static IActionResult DuplicateCode(string code) =>
+        Problem(409, $"模具类型编码「{code}」已存在,请更换后重试");
+
+    /// <summary>类型固定枚举校验(不依赖存量 DISTINCT,空租户同样可创建首条)。</summary>
+    private static string NormalizeType(string? value)
+    {
+        var type = value?.Trim();
+        if (string.IsNullOrEmpty(type)) throw Oops.Oh("类型不能为空");
+        if (!ToolingLedgerConst.Types.Contains(type))
+            throw Oops.Oh($"类型只能是 {string.Join(" / ", ToolingLedgerConst.Types)}");
+        return type;
+    }
+
+    private static string RequireText(string? value, string label)
+    {
+        var text = value?.Trim();
+        if (string.IsNullOrEmpty(text)) throw Oops.Oh($"{label}不能为空");
+        return text;
+    }
+
+    /// <summary>数量必填且非负;注意 0 是合法值,不得当成空值。</summary>
+    private static int RequireQty(int? value)
+    {
+        if (value == null) throw Oops.Oh("数量不能为空");
+        if (value < 0) throw Oops.Oh("数量不能为负数");
+        return value.Value;
+    }
+
+    /// <summary>空白串归一为 null,避免把历史 NULL 污染成空串。</summary>
+    private static string? Trim(string? value)
+    {
+        var text = value?.Trim();
+        return string.IsNullOrEmpty(text) ? null : text;
+    }
+
+    /// <summary>
+    /// 识别唯一键冲突。按驱动异常文本判定(MySQL 1062 / "Duplicate entry"),
+    /// 不额外引入 MySqlConnector 包引用,且对被 SqlSugar 包装的异常链同样有效。
+    /// </summary>
+    private static bool IsDuplicateKeyViolation(Exception ex)
+    {
+        for (var e = ex; e != null; e = e.InnerException)
+        {
+            var message = e.Message;
+            if (string.IsNullOrEmpty(message)) continue;
+            if (message.Contains("Duplicate entry", StringComparison.OrdinalIgnoreCase)) return true;
+            if (message.Contains("uk_MoldToolList_tenant_type_code", StringComparison.OrdinalIgnoreCase)) return true;
+        }
+        return false;
+    }
+
+    private static ToolingLedgerDetailDto ToDetail(AdoS6MoldToolList e) => new()
+    {
+        Id = e.Id,
+        Type = e.Lx,
+        Name = e.MoldName,
+        Qty = e.MoldCount,
+        MoldTypeCode = e.MoldTypeCode,
+        WorkGroup = e.Gnyymc,
+        ProductMaterialCode = e.ProductMaterialCode,
+        ProductDrawingNo = e.ProductDrawingNumber,
+        ProductName = e.ProductName,
+        Process = e.Process
+    };
 }