Ver Fonte

feat(s0): 库位维护内嵌货架明细及批量生成(主从事务保存)

库位新增/编辑支持货架明细子表:DTO 增 Shelves 集合 + 详情 DTO 回显;
Controller Create/Update 改主从同事务(BeginTran/Commit/Rollback),编辑 FULL Replace
(按 tenant+domain+location 删旧插新),服务端统一赋作用域/审计不信前端,货架请求内
去重+长度+≤5000 校验、唯一键冲突转业务错误;GetAsync 返回 shelves 供编辑回显。
前端弹窗内加货架明细表 + 批量生成区(padStart 两位不截断三位)+ 追加去重 + 前端校验,
openEdit 改调详情接口回显。复用现有 LocationShelfMaster 表,无表结构变更。

bump version Web 2.4.271 / server 1.0.291
YY968XX há 1 semana atrás
pai
commit
7c4c73f1d5

+ 1 - 1
Web/package.json

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

+ 16 - 1
Web/src/views/aidop/s0/api/s0WarehouseApi.ts

@@ -171,6 +171,14 @@ export interface S0LocationRow {
 	updateTime?: string | null;
 }
 
+// 库位货架明细入参(主从保存用;前端只提交编码/描述/区域,作用域由服务端赋值)
+export interface S0LocationShelfInput {
+	id?: number;
+	invShelf: string;
+	descr?: string;
+	area?: string;
+}
+
 export interface S0LocationUpsert {
 	companyRefId?: string;
 	factoryRefId?: string;
@@ -183,6 +191,13 @@ export interface S0LocationUpsert {
 	isActive: boolean;
 	createUser?: string;
 	updateUser?: string;
+	// 货架明细(FULL Replace)
+	shelves?: S0LocationShelfInput[];
+}
+
+// 库位详情(含货架明细,供编辑回显)
+export interface S0LocationDetail extends S0LocationRow {
+	shelves: S0LocationShelfInput[];
 }
 
 export interface S0LocationOptionRow {
@@ -206,7 +221,7 @@ export interface S0LocationOptionsQuery {
 const locationsBase = '/api/s0/warehouse/locations';
 export const s0LocationsApi = {
 	list: (params: Record<string, unknown>) => service.get<Paged<S0LocationRow>>(locationsBase, { params }).then(unwrap),
-	get: (id: number) => service.get<S0LocationRow>(`${locationsBase}/${id}`).then(unwrap),
+	get: (id: number) => service.get<S0LocationDetail>(`${locationsBase}/${id}`).then(unwrap),
 	create: (body: S0LocationUpsert) => service.post<S0LocationRow>(locationsBase, body).then(unwrap),
 	update: (id: number, body: S0LocationUpsert) => service.put<S0LocationRow>(`${locationsBase}/${id}`, body).then(unwrap),
 	delete: (id: number) => service.delete(`${locationsBase}/${id}`).then(unwrap),

+ 159 - 46
Web/src/views/aidop/s0/warehouse/LocationList.vue

@@ -45,26 +45,59 @@
 				@current-change="loadList" @size-change="loadList" />
 		</div>
 
-		<el-dialog v-model="dialogVisible" :title="dialogTitle" width="640px" destroy-on-close @closed="resetForm">
+		<el-dialog v-model="dialogVisible" :title="dialogTitle" width="920px" destroy-on-close @closed="resetForm">
 			<el-form ref="formRef" :model="form" :rules="rules" label-width="110px">
-				<el-form-item v-if="false" label="公司" prop="companyRefId">
-					<el-select v-model="form.companyRefId" clearable filterable placeholder="请选择公司" style="width: 100%">
-						<el-option v-for="item in companyOptions" :key="item.id" :label="item.name || item.code || `${item.id}`" :value="item.id" />
-					</el-select>
+				<el-row :gutter="16">
+					<el-col :span="12"><el-form-item label="库位编码" prop="location"><el-input v-model="form.location" :disabled="!!editingId" /></el-form-item></el-col>
+					<el-col :span="12"><el-form-item label="库位说明"><el-input v-model="form.descr" /></el-form-item></el-col>
+					<el-col :span="12"><el-form-item label="货主/保管方"><el-input v-model="form.storer" /></el-form-item></el-col>
+					<el-col :span="12"><el-form-item label="库位类型"><el-input v-model="form.typed" placeholder="如 Supp" /></el-form-item></el-col>
+					<el-col :span="12"><el-form-item label="物理地址"><el-input v-model="form.physicalAddress" /></el-form-item></el-col>
+					<el-col :span="12"><el-form-item label="域编码"><el-input v-model="form.domainCode" /></el-form-item></el-col>
+					<el-col :span="12"><el-form-item label="启用"><el-switch v-model="form.isActive" /></el-form-item></el-col>
+				</el-row>
+			</el-form>
+
+			<el-divider content-position="left">货架明细</el-divider>
+
+			<el-form :inline="true" class="gen-bar" @submit.prevent>
+				<el-form-item label="前缀">
+					<el-input v-model="gen.prefix" placeholder="可空" clearable style="width: 90px" />
+				</el-form-item>
+				<el-form-item label="货架序号">
+					<el-input-number v-model="gen.startNum" :min="1" :precision="0" :step="1" controls-position="right" placeholder="起" style="width: 110px" />
+					<span class="gen-sep">至</span>
+					<el-input-number v-model="gen.endNum" :min="1" :precision="0" :step="1" controls-position="right" placeholder="止" style="width: 110px" />
 				</el-form-item>
-				<el-form-item v-if="false" label="工厂" prop="factoryRefId">
-					<el-select v-model="form.factoryRefId" clearable filterable placeholder="请选择工厂" style="width: 100%" :disabled="!form.companyRefId">
-						<el-option v-for="item in formFactoryOptions" :key="item.id" :label="item.name || item.code || `${item.id}`" :value="item.id" />
-					</el-select>
+				<el-form-item label="层数">
+					<el-input-number v-model="gen.shelfRow" :min="1" :precision="0" :step="1" controls-position="right" style="width: 100px" />
+				</el-form-item>
+				<el-form-item label="列数">
+					<el-input-number v-model="gen.shelfCol" :min="1" :precision="0" :step="1" controls-position="right" style="width: 100px" />
+				</el-form-item>
+				<el-form-item>
+					<el-button type="primary" @click="generateShelves">生成货架明细</el-button>
+					<el-button @click="addShelfRow">新增一行</el-button>
 				</el-form-item>
-				<el-form-item label="库位编码" prop="location"><el-input v-model="form.location" :disabled="!!editingId" /></el-form-item>
-				<el-form-item label="库位说明"><el-input v-model="form.descr" /></el-form-item>
-				<el-form-item label="货主/保管方"><el-input v-model="form.storer" /></el-form-item>
-				<el-form-item label="库位类型"><el-input v-model="form.typed" placeholder="如 Supp" /></el-form-item>
-				<el-form-item label="物理地址"><el-input v-model="form.physicalAddress" /></el-form-item>
-				<el-form-item label="域编码"><el-input v-model="form.domainCode" /></el-form-item>
-				<el-form-item label="启用"><el-switch v-model="form.isActive" /></el-form-item>
 			</el-form>
+
+			<el-table :data="shelves" border stripe size="small" max-height="300px" style="width: 100%">
+				<el-table-column type="index" label="#" width="50" align="center" />
+				<el-table-column label="货架" min-width="180">
+					<template #default="{ row }"><el-input v-model="row.invShelf" placeholder="货架编码" /></template>
+				</el-table-column>
+				<el-table-column label="描述" min-width="200">
+					<template #default="{ row }"><el-input v-model="row.descr" placeholder="描述" /></template>
+				</el-table-column>
+				<el-table-column label="区域" min-width="140">
+					<template #default="{ row }"><el-input v-model="row.area" placeholder="区域" /></template>
+				</el-table-column>
+				<el-table-column label="操作" width="80" align="center">
+					<template #default="{ $index }"><el-button link type="danger" @click="removeShelfRow($index)">删除</el-button></template>
+				</el-table-column>
+			</el-table>
+			<div class="shelf-count">共 {{ shelves.length }} 条货架明细(保存上限 {{ MAX_SHELVES }} 条)</div>
+
 			<template #footer>
 				<el-button @click="dialogVisible = false">取消</el-button>
 				<el-button type="primary" :loading="saving" @click="submitForm">保存</el-button>
@@ -74,12 +107,13 @@
 </template>
 
 <script setup lang="ts" name="aidopS0WhLocation">
-import { computed, onMounted, reactive, ref, watch } from 'vue';
+import { computed, onMounted, reactive, ref } from 'vue';
 import { useRoute } from 'vue-router';
 import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus';
 import AidopDemoShell from '../../components/AidopDemoShell.vue';
-import { s0LocationsApi, type S0LocationRow, type S0LocationUpsert } from '../api/s0WarehouseApi';
-import { loadOrgList, type OrgOption } from '../api/s0SalesApi';
+import { s0LocationsApi, type S0LocationRow, type S0LocationShelfInput, type S0LocationUpsert } from '../api/s0WarehouseApi';
+
+const MAX_SHELVES = 5000;
 
 const route = useRoute();
 const pageTitle = computed(() => (route.meta?.title as string) || '库位维护');
@@ -94,6 +128,10 @@ const saving = ref(false);
 const formRef = ref<FormInstance>();
 const form = reactive<S0LocationUpsert>({ companyRefId: undefined, factoryRefId: undefined, domainCode: '', location: '', descr: '', storer: '', typed: '', physicalAddress: '', isActive: true });
 
+// 货架明细行 + 批量生成参数
+const shelves = ref<S0LocationShelfInput[]>([]);
+const gen = reactive<{ prefix: string; startNum?: number; endNum?: number; shelfRow?: number; shelfCol?: number }>({ prefix: '', startNum: undefined, endNum: undefined, shelfRow: 1, shelfCol: 1 });
+
 // 单甲方私有云口径:公司/工厂不在前端启用,统一注入全局默认组织(不依赖登录账号 tenant_id)
 const DEFAULT_COMPANY_REF_ID = '1329900200001';
 const DEFAULT_FACTORY_REF_ID = '1329900200002';
@@ -104,26 +142,11 @@ function applyDefaultOrg() {
 	if (isMissingOrLegacyOrgRef(form.companyRefId)) form.companyRefId = DEFAULT_COMPANY_REF_ID;
 	if (isMissingOrLegacyOrgRef(form.factoryRefId)) form.factoryRefId = DEFAULT_FACTORY_REF_ID;
 }
-const companyOptions = ref<OrgOption[]>([]);
-const factoryOptions = ref<OrgOption[]>([]);
-const formFactoryOptions = computed(() => {
-	if (!form.companyRefId) return factoryOptions.value;
-	return factoryOptions.value.filter((item) => item.pid === form.companyRefId);
-});
 
 const rules: FormRules = {
 	location: [{ required: true, message: '请填写库位编码', trigger: 'blur' }],
 };
 
-watch(
-	() => form.companyRefId,
-	() => {
-		if (!formFactoryOptions.value.some((item) => item.id === form.factoryRefId)) {
-			form.factoryRefId = undefined;
-		}
-	},
-);
-
 async function loadList() {
 	loading.value = true;
 	try {
@@ -132,20 +155,112 @@ async function loadList() {
 	} catch { rows.value = []; total.value = 0; } finally { loading.value = false; }
 }
 function resetQuery() { Object.assign(query, { keyword: '', domainCode: '', typed: '', isActive: undefined, page: 1 }); void loadList(); }
-function resetForm() { editingId.value = null; Object.assign(form, { companyRefId: undefined, factoryRefId: undefined, domainCode: '', location: '', descr: '', storer: '', typed: '', physicalAddress: '', isActive: true }); formRef.value?.clearValidate(); }
+function resetForm() {
+	editingId.value = null;
+	Object.assign(form, { companyRefId: undefined, factoryRefId: undefined, domainCode: '', location: '', descr: '', storer: '', typed: '', physicalAddress: '', isActive: true });
+	shelves.value = [];
+	Object.assign(gen, { prefix: '', startNum: undefined, endNum: undefined, shelfRow: 1, shelfCol: 1 });
+	formRef.value?.clearValidate();
+}
 function openCreate() { resetForm(); dialogTitle.value = '新增库位'; dialogVisible.value = true; }
-function openEdit(row: S0LocationRow) {
-	resetForm(); editingId.value = row.id; dialogTitle.value = `编辑库位 ${row.location}`;
-	Object.assign(form, { companyRefId: row.companyRefId, factoryRefId: row.factoryRefId, domainCode: row.domainCode ?? '', location: row.location, descr: row.descr ?? '', storer: row.storer ?? '', typed: row.typed ?? '', physicalAddress: row.physicalAddress ?? '', isActive: row.isActive });
+async function openEdit(row: S0LocationRow) {
+	resetForm();
+	editingId.value = row.id;
+	dialogTitle.value = `编辑库位 ${row.location}`;
+	// 编辑必须调用详情接口回显主表 + 货架明细,不再仅用列表 row 回填
+	try {
+		const detail = await s0LocationsApi.get(row.id);
+		Object.assign(form, { companyRefId: detail.companyRefId, factoryRefId: detail.factoryRefId, domainCode: detail.domainCode ?? '', location: detail.location, descr: detail.descr ?? '', storer: detail.storer ?? '', typed: detail.typed ?? '', physicalAddress: detail.physicalAddress ?? '', isActive: detail.isActive });
+		shelves.value = (detail.shelves ?? []).map((s) => ({ id: s.id, invShelf: s.invShelf, descr: s.descr ?? '', area: s.area ?? '' }));
+	} catch {
+		ElMessage.error('加载库位详情失败');
+		return;
+	}
 	dialogVisible.value = true;
 }
+
+// ===== 批量生成 =====
+const pad = (v: number) => String(v).padStart(2, '0');
+function isPositiveInt(v: unknown): boolean {
+	const n = Number(v);
+	return Number.isFinite(n) && Number.isInteger(n) && n > 0;
+}
+// 层/列:空或 0 按 1;填写后必须为正整数,否则返回 null 表示非法
+function resolveDim(v: unknown): number | null {
+	if (v === '' || v === null || v === undefined) return 1;
+	const n = Number(v);
+	if (n === 0) return 1;
+	if (!Number.isFinite(n) || !Number.isInteger(n) || n < 0) return null;
+	return n;
+}
+function generateShelves() {
+	const start = gen.startNum;
+	const end = gen.endNum;
+	if (!isPositiveInt(start)) { ElMessage.warning('请填写正整数的货架序号起始值'); return; }
+	if (!isPositiveInt(end)) { ElMessage.warning('请填写正整数的货架序号结束值'); return; }
+	if ((end as number) < (start as number)) { ElMessage.warning('货架序号结束值不能小于起始值'); return; }
+	const row = resolveDim(gen.shelfRow);
+	const col = resolveDim(gen.shelfCol);
+	if (row === null) { ElMessage.warning('层数必须为正整数'); return; }
+	if (col === null) { ElMessage.warning('列数必须为正整数'); return; }
+
+	const genCount = ((end as number) - (start as number) + 1) * row * col;
+	if (genCount > MAX_SHELVES) { ElMessage.warning(`本次将生成 ${genCount} 条,超过上限 ${MAX_SHELVES},请缩小货架序号范围/层数/列数`); return; }
+
+	const prefix = gen.prefix.trim();
+	// 已有编码集合(Trim + 不区分大小写,与后端去重口径一致)
+	const existing = new Set(shelves.value.map((s) => (s.invShelf ?? '').trim().toLowerCase()));
+	let added = 0;
+	let skipped = 0;
+	for (let i = start as number; i <= (end as number); i++) {
+		for (let r = 1; r <= row; r++) {
+			for (let c = 1; c <= col; c++) {
+				const invShelf = `${prefix}${pad(i)}-${pad(r)}-${pad(c)}`;
+				const key = invShelf.trim().toLowerCase();
+				if (existing.has(key)) { skipped++; continue; }
+				existing.add(key);
+				shelves.value.push({ invShelf, descr: `${i}#货架第${r}层第${c}格`, area: '' });
+				added++;
+			}
+		}
+	}
+	ElMessage.success(`已生成:新增 ${added} 条,跳过重复 ${skipped} 条`);
+}
+function addShelfRow() { shelves.value.push({ invShelf: '', descr: '', area: '' }); }
+function removeShelfRow(index: number) { shelves.value.splice(index, 1); }
+
+// ===== 保存前校验(体验优化;后端保留同等校验)=====
+function validateShelvesForSubmit(): S0LocationShelfInput[] | null {
+	const list = shelves.value;
+	if (list.length > MAX_SHELVES) { ElMessage.warning(`货架明细 ${list.length} 条超过上限 ${MAX_SHELVES}`); return null; }
+	const seen = new Set<string>();
+	const cleaned: S0LocationShelfInput[] = [];
+	for (const s of list) {
+		const code = (s.invShelf ?? '').trim();
+		if (code.length === 0) { ElMessage.warning('存在货架编码为空的明细行,请填写货架编码或删除该行'); return null; }
+		if (code.length > 100) { ElMessage.warning(`货架编码「${code}」超过 100 字符`); return null; }
+		const descr = (s.descr ?? '').trim();
+		if (descr.length > 255) { ElMessage.warning(`货架「${code}」描述超过 255 字符`); return null; }
+		const area = (s.area ?? '').trim();
+		if (area.length > 100) { ElMessage.warning(`货架「${code}」区域超过 100 字符`); return null; }
+		const key = code.toLowerCase();
+		if (seen.has(key)) { ElMessage.warning(`货架编码重复:${code}`); return null; }
+		seen.add(key);
+		cleaned.push({ id: s.id, invShelf: code, descr: descr || undefined, area: area || undefined });
+	}
+	return cleaned;
+}
+
 async function submitForm() {
 	await formRef.value?.validate();
+	const cleanedShelves = validateShelvesForSubmit();
+	if (cleanedShelves === null) return;
 	applyDefaultOrg();
 	saving.value = true;
 	try {
-		if (editingId.value) { await s0LocationsApi.update(editingId.value, { ...form }); ElMessage.success('已保存'); }
-		else { await s0LocationsApi.create({ ...form }); ElMessage.success('已创建'); }
+		const payload: S0LocationUpsert = { ...form, shelves: cleanedShelves };
+		if (editingId.value) { await s0LocationsApi.update(editingId.value, payload); ElMessage.success('已保存'); }
+		else { await s0LocationsApi.create(payload); ElMessage.success('已创建'); }
 		dialogVisible.value = false; await loadList();
 	} finally { saving.value = false; }
 }
@@ -153,17 +268,15 @@ function onDelete(row: S0LocationRow) {
 	ElMessageBox.confirm(`确定删除库位「${row.location}」?`, '确认', { type: 'warning' })
 		.then(async () => { await s0LocationsApi.delete(row.id); ElMessage.success('已删除'); await loadList(); }).catch(() => {});
 }
-async function loadOrgOptions() {
-	const [companies, factories] = await Promise.all([loadOrgList('201'), loadOrgList('501')]);
-	companyOptions.value = companies.filter((item) => item.id && item.id !== '0');
-	factoryOptions.value = factories.filter((item) => item.id && item.id !== '0');
-}
 
-onMounted(() => { void loadList(); void loadOrgOptions(); });
+onMounted(() => { void loadList(); });
 </script>
 
 <style scoped lang="scss">
 @import '/@/views/aidop/styles/aidop-demo.scss';
 .mb12 { margin-bottom: 12px; }
 .pager { margin-top: 12px; display: flex; justify-content: flex-end; }
+.gen-bar { margin-bottom: 8px; }
+.gen-sep { margin: 0 6px; color: var(--el-text-color-secondary); }
+.shelf-count { margin-top: 8px; font-size: 12px; color: var(--el-text-color-secondary); }
 </style>

+ 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.290</AssemblyVersion>
-    <FileVersion>1.0.290</FileVersion>
-    <Version>1.0.290</Version>
+    <AssemblyVersion>1.0.291</AssemblyVersion>
+    <FileVersion>1.0.291</FileVersion>
+    <Version>1.0.291</Version>
   </PropertyGroup>
 
   <ItemGroup>

+ 195 - 10
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S0/Warehouse/AdoS0LocationsController.cs

@@ -5,7 +5,7 @@ using Admin.NET.Plugin.AiDOP.Infrastructure;
 namespace Admin.NET.Plugin.AiDOP.Controllers.S0.Warehouse;
 
 /// <summary>
-/// S0 库位主数据(LocationMaster 语义)
+/// S0 库位主数据(LocationMaster 语义)+ 货架明细(LocationShelfMaster)主从保存
 /// </summary>
 [ApiController]
 [Route("api/s0/warehouse/locations")]
@@ -13,12 +13,19 @@ namespace Admin.NET.Plugin.AiDOP.Controllers.S0.Warehouse;
 [NonUnify]
 public class AdoS0LocationsController : ControllerBase
 {
+    private const int MaxShelves = 5000;
+
     private readonly SqlSugarRepository<AdoS0LocationMaster> _rep;
+    private readonly SqlSugarRepository<AdoS0LocationShelfMaster> _shelfRep;
     private readonly AdoS0ReferenceChecker _refChecker;
 
-    public AdoS0LocationsController(SqlSugarRepository<AdoS0LocationMaster> rep, AdoS0ReferenceChecker refChecker)
+    public AdoS0LocationsController(
+        SqlSugarRepository<AdoS0LocationMaster> rep,
+        SqlSugarRepository<AdoS0LocationShelfMaster> shelfRep,
+        AdoS0ReferenceChecker refChecker)
     {
         _rep = rep;
+        _shelfRep = shelfRep;
         _refChecker = refChecker;
     }
 
@@ -47,11 +54,35 @@ public class AdoS0LocationsController : ControllerBase
         return Ok(new { total, page = q.Page, pageSize = q.PageSize, list });
     }
 
+    /// <summary>
+    /// 库位详情(含货架明细,供编辑回显)。货架按关联口径 tenant_id + domain_code + location 拉取。
+    /// </summary>
     [HttpGet("{id:long}")]
     public async Task<IActionResult> GetAsync(long id)
     {
         var item = await _rep.GetByIdAsync(id);
-        return item == null ? NotFound() : Ok(item);
+        if (item == null) return NotFound();
+
+        var shelves = await LoadShelvesAsync(item);
+        var detail = new AdoS0LocationDetailDto
+        {
+            Id = item.Id,
+            CompanyRefId = item.CompanyRefId,
+            FactoryRefId = item.FactoryRefId,
+            DomainCode = item.DomainCode,
+            Location = item.Location,
+            Descr = item.Descr,
+            Storer = item.Storer,
+            Typed = item.Typed,
+            PhysicalAddress = item.PhysicalAddress,
+            IsActive = item.IsActive,
+            CreateUser = item.CreateUser,
+            CreateTime = item.CreateTime,
+            UpdateUser = item.UpdateUser,
+            UpdateTime = item.UpdateTime,
+            Shelves = shelves
+        };
+        return Ok(detail);
     }
 
     [HttpGet("options")]
@@ -89,12 +120,19 @@ public class AdoS0LocationsController : ControllerBase
         return Ok(list);
     }
 
+    /// <summary>
+    /// 新增库位(含货架明细,主从同事务保存)。
+    /// </summary>
     [HttpPost]
     public async Task<IActionResult> CreateAsync([FromBody] AdoS0LocationUpsertDto dto)
     {
+        var (shelfError, shelfItems) = ValidateShelves(dto.Shelves);
+        if (shelfError != null) return shelfError;
+
         if (await _rep.IsAnyAsync(x => x.FactoryRefId == dto.FactoryRefId && x.Location == dto.Location))
             return AdoS0ApiErrors.Conflict(AdoS0ErrorCodes.DuplicateCode, "库位编码已存在");
 
+        var now = DateTime.Now;
         var entity = new AdoS0LocationMaster
         {
             CompanyRefId = dto.CompanyRefId,
@@ -107,36 +145,90 @@ public class AdoS0LocationsController : ControllerBase
             PhysicalAddress = dto.PhysicalAddress,
             IsActive = dto.IsActive,
             CreateUser = dto.CreateUser,
-            CreateTime = DateTime.Now
+            CreateTime = now
         };
 
-        await _rep.AsInsertable(entity).ExecuteReturnEntityAsync();
-        return Ok(entity);
+        var db = _rep.Context;
+        try
+        {
+            await db.Ado.BeginTranAsync();
+
+            var saved = await _rep.AsInsertable(entity).ExecuteReturnEntityAsync();
+
+            if (shelfItems.Count > 0)
+            {
+                var shelfEntities = BuildShelfEntities(saved, shelfItems, dto.CreateUser, now);
+                await _shelfRep.AsInsertable(shelfEntities).ExecuteCommandAsync();
+            }
+
+            await db.Ado.CommitTranAsync();
+            return Ok(saved);
+        }
+        catch (Exception ex)
+        {
+            await db.Ado.RollbackTranAsync();
+            return MapWriteException(ex);
+        }
     }
 
+    /// <summary>
+    /// 编辑库位(含货架明细 FULL Replace,主从同事务保存)。库位编码不可修改。
+    /// </summary>
     [HttpPut("{id:long}")]
     public async Task<IActionResult> UpdateAsync(long id, [FromBody] AdoS0LocationUpsertDto dto)
     {
+        var (shelfError, shelfItems) = ValidateShelves(dto.Shelves);
+        if (shelfError != null) return shelfError;
+
         var entity = await _rep.GetByIdAsync(id);
         if (entity == null) return NotFound();
 
+        // 库位编码不可修改:以库存原值为准,前端传值须一致
+        if (!string.Equals(entity.Location, dto.Location?.Trim(), StringComparison.Ordinal))
+            return AdoS0ApiErrors.InvalidRequest("库位编码不可修改");
+
         if (await _rep.IsAnyAsync(x => x.Id != id && x.FactoryRefId == dto.FactoryRefId && x.Location == dto.Location))
             return AdoS0ApiErrors.Conflict(AdoS0ErrorCodes.DuplicateCode, "库位编码已存在");
 
+        var now = DateTime.Now;
         entity.CompanyRefId = dto.CompanyRefId;
         entity.FactoryRefId = dto.FactoryRefId;
         entity.DomainCode = dto.DomainCode ?? string.Empty;
-        entity.Location = dto.Location;
+        // entity.Location 保持不变(不可修改)
         entity.Descr = dto.Descr;
         entity.Storer = dto.Storer;
         entity.Typed = dto.Typed;
         entity.PhysicalAddress = dto.PhysicalAddress;
         entity.IsActive = dto.IsActive;
         entity.UpdateUser = dto.UpdateUser;
-        entity.UpdateTime = DateTime.Now;
+        entity.UpdateTime = now;
+
+        var db = _rep.Context;
+        try
+        {
+            await db.Ado.BeginTranAsync();
+
+            await _rep.AsUpdateable(entity).ExecuteCommandAsync();
+
+            // FULL Replace:删除本库位当前作用域(tenant + domain + location)下全部货架,再整体重插
+            await _shelfRep.AsDeleteable()
+                .Where(x => x.DomainCode == entity.DomainCode && x.Location == entity.Location)
+                .ExecuteCommandAsync();
 
-        await _rep.AsUpdateable(entity).ExecuteCommandAsync();
-        return Ok(entity);
+            if (shelfItems.Count > 0)
+            {
+                var shelfEntities = BuildShelfEntities(entity, shelfItems, dto.UpdateUser, now);
+                await _shelfRep.AsInsertable(shelfEntities).ExecuteCommandAsync();
+            }
+
+            await db.Ado.CommitTranAsync();
+            return Ok(entity);
+        }
+        catch (Exception ex)
+        {
+            await db.Ado.RollbackTranAsync();
+            return MapWriteException(ex);
+        }
     }
 
     [HttpDelete("{id:long}")]
@@ -145,6 +237,7 @@ public class AdoS0LocationsController : ControllerBase
         var item = await _rep.GetByIdAsync(id);
         if (item == null) return NotFound();
 
+        // 保持既有删除契约:存在货架(或其它引用)时拦截,不做级联删除
         var refInfo = await _refChecker.LocationReferencesAsync(item.Location);
         if (refInfo is { } r)
             return AdoS0ApiErrors.Conflict(AdoS0ErrorCodes.DeleteBlocked,
@@ -153,4 +246,96 @@ public class AdoS0LocationsController : ControllerBase
         await _rep.DeleteAsync(item);
         return Ok(new { message = "删除成功" });
     }
+
+    // ==================== 私有:货架明细主从辅助 ====================
+
+    /// <summary>
+    /// 按关联口径(tenant 自动过滤 + domain_code + location)拉取库位下货架明细。
+    /// </summary>
+    private async Task<List<AdoS0LocationShelfInputDto>> LoadShelvesAsync(AdoS0LocationMaster master)
+    {
+        return await _shelfRep.AsQueryable()
+            .Where(x => x.DomainCode == master.DomainCode && x.Location == master.Location)
+            .OrderBy(x => x.InvShelf)
+            .Select(x => new AdoS0LocationShelfInputDto
+            {
+                Id = x.Id,
+                InvShelf = x.InvShelf,
+                Descr = x.Descr,
+                Area = x.Area
+            })
+            .ToListAsync();
+    }
+
+    /// <summary>
+    /// 服务端货架明细校验 + 规范化(Trim、长度、请求内去重、数量上限)。前端校验只是体验,服务端为准。
+    /// </summary>
+    private static (IActionResult? Error, List<AdoS0LocationShelfInputDto> Items) ValidateShelves(List<AdoS0LocationShelfInputDto>? shelves)
+    {
+        var items = shelves ?? new List<AdoS0LocationShelfInputDto>();
+        if (items.Count > MaxShelves)
+            return (AdoS0ApiErrors.InvalidRequest($"货架明细数量 {items.Count} 超过上限 {MaxShelves},请缩小货架序号范围/层数/列数后重试"), new());
+
+        var normalized = new List<AdoS0LocationShelfInputDto>(items.Count);
+        // 去重按数据库不区分大小写口径(MySQL 默认 ci 排序规则),避免与唯一索引冲突
+        var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
+        foreach (var s in items)
+        {
+            var code = (s.InvShelf ?? string.Empty).Trim();
+            if (code.Length == 0)
+                return (AdoS0ApiErrors.InvalidRequest("存在货架编码为空的明细行,请填写货架编码或删除该行"), new());
+            if (code.Length > 100)
+                return (AdoS0ApiErrors.InvalidRequest($"货架编码 '{code}' 超过 100 字符上限"), new());
+
+            var descr = s.Descr?.Trim();
+            if (descr is { Length: > 255 })
+                return (AdoS0ApiErrors.InvalidRequest($"货架 '{code}' 的描述超过 255 字符上限"), new());
+
+            var area = s.Area?.Trim();
+            if (area is { Length: > 100 })
+                return (AdoS0ApiErrors.InvalidRequest($"货架 '{code}' 的区域超过 100 字符上限"), new());
+
+            if (!seen.Add(code))
+                return (AdoS0ApiErrors.Conflict(AdoS0ErrorCodes.DuplicateDetailItem, $"货架编码重复:{code}"), new());
+
+            normalized.Add(new AdoS0LocationShelfInputDto
+            {
+                InvShelf = code,
+                Descr = string.IsNullOrEmpty(descr) ? null : descr,
+                Area = string.IsNullOrEmpty(area) ? null : area
+            });
+        }
+        return (null, normalized);
+    }
+
+    /// <summary>
+    /// 由库位主表统一赋值货架作用域字段(tenant 由 ITenantIdFilter 自动注入,此处不设)。
+    /// </summary>
+    private static List<AdoS0LocationShelfMaster> BuildShelfEntities(
+        AdoS0LocationMaster master, List<AdoS0LocationShelfInputDto> items, string? actingUser, DateTime now)
+    {
+        return items.Select(s => new AdoS0LocationShelfMaster
+        {
+            CompanyRefId = master.CompanyRefId,
+            FactoryRefId = master.FactoryRefId,
+            DomainCode = master.DomainCode,
+            Location = master.Location,
+            InvShelf = s.InvShelf,
+            Descr = s.Descr,
+            Area = s.Area,
+            CreateUser = actingUser,
+            CreateTime = now
+        }).ToList();
+    }
+
+    /// <summary>
+    /// 写入异常映射:唯一键冲突 → 清晰业务错误;其余 → 500(事务已回滚)。
+    /// </summary>
+    private static IActionResult MapWriteException(Exception ex)
+    {
+        var msg = ex.Message + " " + (ex.InnerException?.Message ?? string.Empty);
+        if (msg.Contains("Duplicate entry", StringComparison.OrdinalIgnoreCase) || msg.Contains("1062"))
+            return AdoS0ApiErrors.Conflict(AdoS0ErrorCodes.DuplicateDetailItem, "货架编码在同一库位内重复(唯一约束冲突),保存已回滚");
+        return AdoS0ApiErrors.InternalServerError("库位与货架明细保存失败,已整体回滚");
+    }
 }

+ 47 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Dto/S0/Warehouse/AdoS0WarehouseBasicDtos.cs

@@ -178,6 +178,53 @@ public class AdoS0LocationUpsertDto
     public string? CreateUser { get; set; }
     [MaxLength(100)]
     public string? UpdateUser { get; set; }
+
+    /// <summary>
+    /// 货架明细(FULL Replace)。仅接受 InvShelf/Descr/Area(及可选 Id,本次不依赖);
+    /// tenant/company/factory/domain/location/审计一律由服务端从库位主表 + 上下文赋值,不信任前端传值。
+    /// </summary>
+    public List<AdoS0LocationShelfInputDto> Shelves { get; set; } = new();
+}
+
+/// <summary>
+/// 库位货架明细入参(库位主从保存用)。前端仅允许提交货架编码/描述/区域,作用域字段由服务端统一赋值。
+/// </summary>
+public class AdoS0LocationShelfInputDto
+{
+    /// <summary>回显用;FULL Replace 保存不依赖此 Id。</summary>
+    public long? Id { get; set; }
+
+    [Required(ErrorMessage = "货架编码不能为空")]
+    [MaxLength(100)]
+    public string InvShelf { get; set; } = string.Empty;
+
+    [MaxLength(255)]
+    public string? Descr { get; set; }
+
+    [MaxLength(100)]
+    public string? Area { get; set; }
+}
+
+/// <summary>
+/// 库位详情输出(含货架明细,供编辑回显)。
+/// </summary>
+public class AdoS0LocationDetailDto
+{
+    public long Id { get; set; }
+    public long CompanyRefId { get; set; }
+    public long FactoryRefId { get; set; }
+    public string DomainCode { get; set; } = string.Empty;
+    public string Location { get; set; } = string.Empty;
+    public string? Descr { get; set; }
+    public string? Storer { get; set; }
+    public string? Typed { get; set; }
+    public string? PhysicalAddress { get; set; }
+    public bool IsActive { get; set; }
+    public string? CreateUser { get; set; }
+    public DateTime? CreateTime { get; set; }
+    public string? UpdateUser { get; set; }
+    public DateTime? UpdateTime { get; set; }
+    public List<AdoS0LocationShelfInputDto> Shelves { get; set; } = new();
 }
 
 /// <summary>