Преглед на файлове

选择客户和选择物料

Pengxy преди 4 дни
родител
ревизия
a3527e66b3

+ 1 - 1
Web/package.json

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

+ 57 - 0
Web/src/views/aidop/api/universalSelector.ts

@@ -0,0 +1,57 @@
+import service from '/@/utils/request';
+
+// ──────────────── 数据类型 ────────────────
+
+export interface CustomerRow {
+	cust: string | null;
+	sortName: string | null;
+	id: string | null;
+}
+
+export interface ItemRow {
+	recId: string | null;
+	itemNum: string | null;
+	descr: string | null;
+	descr1: string | null;
+	um: string | null;
+	location: string | null;
+	rev: string | null;
+	drawing: string | null;
+}
+
+export interface PageResult<T> {
+	total: number;
+	page: number;
+	pageSize: number;
+	list: T[];
+}
+
+// ──────────────── 请求参数 ────────────────
+
+export interface CustomerPageParams {
+	custNo?: string;
+	sortName?: string;
+	page: number;
+	pageSize: number;
+	sortField?: string;
+	sortOrder?: string;
+}
+
+export interface ItemPageParams {
+	itemNum?: string;
+	descr?: string;
+	page: number;
+	pageSize: number;
+	sortField?: string;
+	sortOrder?: string;
+}
+
+// ──────────────── API 调用 ────────────────
+
+export function fetchCustomerPage(params: CustomerPageParams) {
+	return service.get<PageResult<CustomerRow>>('/api/Universal/customer/page', { params }).then((r) => r.data);
+}
+
+export function fetchItemPage(params: ItemPageParams) {
+	return service.get<PageResult<ItemRow>>('/api/Universal/item/page', { params }).then((r) => r.data);
+}

+ 10 - 11
Web/src/views/aidop/business/salesOrderForm.vue

@@ -139,6 +139,7 @@ import { ElMessage, FormInstance, FormRules } from 'element-plus';
 import SelectCustomer from './selectCustomer.vue';
 import SelectItem from './selectItem.vue';
 import { fetchSeOrderDetail, saveSeOrder, type SeOrderEntry, type SeOrderUpsert } from '../api/seOrderReview';
+import { type CustomerRow, type ItemRow } from '../api/universalSelector';
 
 const props = defineProps<{
 	mode: 'create' | 'edit' | 'view';
@@ -210,10 +211,10 @@ function removeRow(idx: number) {
 }
 
 const customerDlg = ref(false);
-function onPickCustomer(v: { value: string; label: string; id?: number | null }) {
-	form.customId = v.id ?? null;
-	form.customNo = v.value;
-	form.customName = v.label;
+function onPickCustomer(v: CustomerRow) {
+	form.customId = null;
+	form.customNo = v.cust ?? '';
+	form.customName = v.sortName ?? '';
 	customerDlg.value = false;
 }
 
@@ -223,14 +224,12 @@ function openItemDlg(row: SeOrderEntry) {
 	itemTargetRow = row;
 	itemDlgVisible.value = true;
 }
-function onPickItem(v: { value: string; label: string; extra?: string | null }) {
+function onPickItem(v: ItemRow) {
 	if (!itemTargetRow) return;
-	itemTargetRow.itemNumber = v.value;
-	itemTargetRow.itemName = v.label;
-	if (v.extra) {
-		const [descr1] = v.extra.split('|');
-		itemTargetRow.specification = descr1 || itemTargetRow.specification;
-	}
+	itemTargetRow.itemNumber = v.itemNum ?? '';
+	itemTargetRow.itemName = v.descr ?? '';
+	itemTargetRow.specification = v.descr1 ?? '';
+	itemTargetRow.unit = v.um ?? '';
 	itemDlgVisible.value = false;
 }
 

+ 46 - 15
Web/src/views/aidop/business/selectCustomer.vue

@@ -1,18 +1,30 @@
 <template>
 	<div>
 		<el-form :inline="true" :model="query" class="mb12" @submit.prevent>
-			<el-form-item label="关键词">
-				<el-input v-model="query.keyword" placeholder="客户编号/名称" clearable style="width: 240px" />
+			<el-form-item label="客户编号">
+				<el-input v-model="query.custNo" placeholder="输入客户编号" clearable style="width: 180px" />
+			</el-form-item>
+			<el-form-item label="客户名称">
+				<el-input v-model="query.sortName" placeholder="输入客户名称" clearable style="width: 180px" />
 			</el-form-item>
 			<el-form-item>
-				<el-button type="primary" @click="loadList">查询</el-button>
+				<el-button type="primary" @click="doSearch">查询</el-button>
 				<el-button @click="reset">重置</el-button>
 			</el-form-item>
 		</el-form>
 
-		<el-table :data="rows" v-loading="loading" border stripe style="width: 100%" height="420" @row-dblclick="onDblClick">
-			<el-table-column prop="value" label="客户编号" width="160" />
-			<el-table-column prop="label" label="客户名称" min-width="220" show-overflow-tooltip />
+		<el-table
+			:data="rows"
+			v-loading="loading"
+			border
+			stripe
+			style="width: 100%"
+			height="420"
+			@row-dblclick="onDblClick"
+			@sort-change="onSortChange"
+		>
+			<el-table-column prop="cust" label="客户编号" width="200" sortable="custom" />
+			<el-table-column prop="sortName" label="客户名称" min-width="220" show-overflow-tooltip sortable="custom" />
 		</el-table>
 
 		<div class="pager">
@@ -31,27 +43,33 @@
 
 <script setup lang="ts" name="aidopBusinessSelectCustomer">
 import { onMounted, reactive, ref } from 'vue';
-import { fetchCustomers, type SimpleKv } from '../api/seOrderReview';
+import { fetchCustomerPage, type CustomerRow } from '../api/universalSelector';
 
-const emit = defineEmits<{ (e: 'picked', v: SimpleKv): void }>();
+const emit = defineEmits<{ (e: 'picked', v: CustomerRow): void }>();
 
 const query = reactive({
-	keyword: '',
+	custNo: '',
+	sortName: '',
 	page: 1,
 	pageSize: 10,
+	sortField: '',
+	sortOrder: '',
 });
 
 const loading = ref(false);
-const rows = ref<SimpleKv[]>([]);
+const rows = ref<CustomerRow[]>([]);
 const total = ref(0);
 
 async function loadList() {
 	loading.value = true;
 	try {
-		const data = await fetchCustomers({
-			keyword: query.keyword || undefined,
+		const data = await fetchCustomerPage({
+			custNo: query.custNo || undefined,
+			sortName: query.sortName || undefined,
 			page: query.page,
 			pageSize: query.pageSize,
+			sortField: query.sortField || undefined,
+			sortOrder: query.sortOrder || undefined,
 		});
 		rows.value = data.list || [];
 		total.value = data.total || 0;
@@ -60,13 +78,27 @@ async function loadList() {
 	}
 }
 
+function doSearch() {
+	query.page = 1;
+	loadList();
+}
+
 function reset() {
-	query.keyword = '';
+	query.custNo = '';
+	query.sortName = '';
 	query.page = 1;
+	query.sortField = '';
+	query.sortOrder = '';
 	loadList();
 }
 
-function onDblClick(row: SimpleKv) {
+function onSortChange({ prop, order }: { prop: string; order: string | null }) {
+	query.sortField = prop || '';
+	query.sortOrder = order === 'ascending' ? 'asc' : order === 'descending' ? 'desc' : '';
+	loadList();
+}
+
+function onDblClick(row: CustomerRow) {
 	emit('picked', row);
 }
 
@@ -83,4 +115,3 @@ onMounted(loadList);
 	justify-content: flex-end;
 }
 </style>
-

+ 51 - 16
Web/src/views/aidop/business/selectItem.vue

@@ -1,19 +1,35 @@
 <template>
 	<div>
 		<el-form :inline="true" :model="query" class="mb12" @submit.prevent>
-			<el-form-item label="关键词">
-				<el-input v-model="query.keyword" placeholder="物料编号/名称" clearable style="width: 260px" />
+			<el-form-item label="物料编号">
+				<el-input v-model="query.itemNum" placeholder="输入物料编号" clearable style="width: 180px" />
+			</el-form-item>
+			<el-form-item label="物料名称">
+				<el-input v-model="query.descr" placeholder="输入物料名称" clearable style="width: 180px" />
 			</el-form-item>
 			<el-form-item>
-				<el-button type="primary" @click="loadList">查询</el-button>
+				<el-button type="primary" @click="doSearch">查询</el-button>
 				<el-button @click="reset">重置</el-button>
 			</el-form-item>
 		</el-form>
 
-		<el-table :data="rows" v-loading="loading" border stripe style="width: 100%" height="420" @row-dblclick="onDblClick">
-			<el-table-column prop="value" label="物料编号" width="160" />
-			<el-table-column prop="label" label="物料名称" min-width="180" show-overflow-tooltip />
-			<el-table-column prop="extra" label="规格/单位" min-width="180" show-overflow-tooltip />
+		<el-table
+			:data="rows"
+			v-loading="loading"
+			border
+			stripe
+			style="width: 100%"
+			height="420"
+			@row-dblclick="onDblClick"
+			@sort-change="onSortChange"
+		>
+			<el-table-column prop="itemNum" label="物料编号" width="150" sortable="custom" />
+			<el-table-column prop="descr" label="物料名称" min-width="160" show-overflow-tooltip sortable="custom" />
+			<el-table-column prop="descr1" label="规格型号" min-width="140" show-overflow-tooltip sortable="custom" />
+			<el-table-column prop="um" label="单位" width="80" sortable="custom" />
+			<el-table-column prop="location" label="库位" width="100" sortable="custom" />
+			<el-table-column prop="rev" label="版本" width="80" sortable="custom" />
+			<el-table-column prop="drawing" label="图纸号" width="130" show-overflow-tooltip sortable="custom" />
 		</el-table>
 
 		<div class="pager">
@@ -32,27 +48,33 @@
 
 <script setup lang="ts" name="aidopBusinessSelectItem">
 import { onMounted, reactive, ref } from 'vue';
-import { fetchItems, type SimpleKv } from '../api/seOrderReview';
+import { fetchItemPage, type ItemRow } from '../api/universalSelector';
 
-const emit = defineEmits<{ (e: 'picked', v: SimpleKv): void }>();
+const emit = defineEmits<{ (e: 'picked', v: ItemRow): void }>();
 
 const query = reactive({
-	keyword: '',
+	itemNum: '',
+	descr: '',
 	page: 1,
 	pageSize: 10,
+	sortField: '',
+	sortOrder: '',
 });
 
 const loading = ref(false);
-const rows = ref<SimpleKv[]>([]);
+const rows = ref<ItemRow[]>([]);
 const total = ref(0);
 
 async function loadList() {
 	loading.value = true;
 	try {
-		const data = await fetchItems({
-			keyword: query.keyword || undefined,
+		const data = await fetchItemPage({
+			itemNum: query.itemNum || undefined,
+			descr: query.descr || undefined,
 			page: query.page,
 			pageSize: query.pageSize,
+			sortField: query.sortField || undefined,
+			sortOrder: query.sortOrder || undefined,
 		});
 		rows.value = data.list || [];
 		total.value = data.total || 0;
@@ -61,13 +83,27 @@ async function loadList() {
 	}
 }
 
+function doSearch() {
+	query.page = 1;
+	loadList();
+}
+
 function reset() {
-	query.keyword = '';
+	query.itemNum = '';
+	query.descr = '';
 	query.page = 1;
+	query.sortField = '';
+	query.sortOrder = '';
 	loadList();
 }
 
-function onDblClick(row: SimpleKv) {
+function onSortChange({ prop, order }: { prop: string; order: string | null }) {
+	query.sortField = prop || '';
+	query.sortOrder = order === 'ascending' ? 'asc' : order === 'descending' ? 'desc' : '';
+	loadList();
+}
+
+function onDblClick(row: ItemRow) {
 	emit('picked', row);
 }
 
@@ -84,4 +120,3 @@ onMounted(loadList);
 	justify-content: flex-end;
 }
 </style>
-

+ 40 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Entity/AdoLegacyEntities.cs

@@ -0,0 +1,40 @@
+namespace Admin.NET.Plugin.AiDOP.Entity;
+
+/// <summary>订单(看板基础查询用,对应旧版 ado_order 表)</summary>
+[SugarTable("ado_order")]
+public class AdoOrder
+{
+    [SugarColumn(IsPrimaryKey = true, IsIdentity = true)]
+    public long Id { get; set; }
+
+    [SugarColumn(IsNullable = true, Length = 200)]
+    public string? Product { get; set; }
+
+    [SugarColumn(IsNullable = true, Length = 100)]
+    public string? OrderNo { get; set; }
+}
+
+/// <summary>工单(看板基础查询用,对应旧版 ado_work_order 表)</summary>
+[SugarTable("ado_work_order")]
+public class AdoWorkOrder
+{
+    [SugarColumn(IsPrimaryKey = true, IsIdentity = true)]
+    public long Id { get; set; }
+
+    [SugarColumn(IsNullable = true, Length = 200)]
+    public string? Product { get; set; }
+
+    [SugarColumn(IsNullable = true, Length = 100)]
+    public string? WorkCenter { get; set; }
+}
+
+/// <summary>计划(看板基础查询用,对应旧版 ado_plan 表)</summary>
+[SugarTable("ado_plan")]
+public class AdoPlan
+{
+    [SugarColumn(IsPrimaryKey = true, IsIdentity = true)]
+    public long Id { get; set; }
+
+    [SugarColumn(IsNullable = true, Length = 200)]
+    public string? ProductName { get; set; }
+}

+ 1 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Order/Dto/SeOrderDto.cs

@@ -40,6 +40,7 @@ public class SeOrderEntryInput
     public string? ItemNumber { get; set; }
     public string? ItemName { get; set; }
     public string? Specification { get; set; }
+    public string? Unit { get; set; }
     public decimal? Qty { get; set; }
     public string? PlanDate { get; set; }
     public string? SysCapacityDate { get; set; }

+ 3 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Order/Entity/SeOrderEntry.cs

@@ -56,6 +56,9 @@ public class SeOrderEntry
     [SugarColumn(ColumnName = "deliver_count")]
     public decimal? DeliverCount { get; set; }
 
+    [SugarColumn(ColumnName = "create_by", Length = 64)]
+    public string? CreateBy { get; set; }
+
     [SugarColumn(ColumnName = "IsDeleted")]
     public int IsDeleted { get; set; } = 0;
 

+ 11 - 5
server/Plugins/Admin.NET.Plugin.AiDOP/Order/SeOrderService.cs

@@ -16,17 +16,20 @@ public class SeOrderService : IDynamicApiController, ITransient
     private readonly SqlSugarRepository<SeOrder> _seOrderRep;
     private readonly SqlSugarRepository<SeOrderEntry> _seOrderEntryRep;
     private readonly SqlSugarRepository<SeOrderChange> _seOrderChangeRep;
+    private readonly UserManager _userManager;
 
     public SeOrderService(
         ISqlSugarClient db,
         SqlSugarRepository<SeOrder> seOrderRep,
         SqlSugarRepository<SeOrderEntry> seOrderEntryRep,
-        SqlSugarRepository<SeOrderChange> seOrderChangeRep)
+        SqlSugarRepository<SeOrderChange> seOrderChangeRep,
+        UserManager userManager)
     {
         _db = db;
         _seOrderRep = seOrderRep;
         _seOrderEntryRep = seOrderEntryRep;
         _seOrderChangeRep = seOrderChangeRep;
+        _userManager = userManager;
     }
 
     // ══════════════════════════════════════════════════════════════
@@ -241,7 +244,7 @@ public class SeOrderService : IDynamicApiController, ITransient
             entity.IsDeleted = 0;
             entity.CreateTime = DateTime.Now;
             await _seOrderRep.InsertAsync(entity);
-            await SaveEntriesAsync(entity.Id, input.Entries);
+            await SaveEntriesAsync(entity.Id, input.BillNo, input.Entries);
             return new { id = entity.Id, message = "新增成功" };
         }
         else
@@ -254,7 +257,7 @@ public class SeOrderService : IDynamicApiController, ITransient
             entity.UpdateTime = DateTime.Now;
             await _seOrderRep.UpdateAsync(entity);
 
-            await SaveEntriesAsync(input.Id.Value, input.Entries);
+            await SaveEntriesAsync(input.Id.Value, input.BillNo, input.Entries);
             return new { id = input.Id, message = "编辑成功" };
         }
     }
@@ -262,10 +265,10 @@ public class SeOrderService : IDynamicApiController, ITransient
     /// <summary>
     /// 明细三路合并:
     /// ① DB有且入参有(按 Id 匹配)→ 更新
-    /// ② DB无但入参有              → 新增
+    /// ② DB无但入参有              → 新增(补填 bill_no / progress=0 / create_by)
     /// ③ DB有但入参无              → 删除
     /// </summary>
-    private async Task SaveEntriesAsync(long orderId, List<SeOrderEntryInput> entries)
+    private async Task SaveEntriesAsync(long orderId, string billNo, List<SeOrderEntryInput> entries)
     {
         // 取 DB 现有明细,以 Id 为 key 建索引
         var dbEntries = await _seOrderEntryRep.GetListAsync(u => u.SeOrderId == orderId && u.IsDeleted == 0);
@@ -293,7 +296,10 @@ public class SeOrderService : IDynamicApiController, ITransient
                 var entry = e.Adapt<SeOrderEntry>();
                 entry.Id = YitIdHelper.NextId();
                 entry.SeOrderId = orderId;
+                entry.BillNo = billNo;
                 entry.EntrySeq = seq;
+                entry.Progress ??= 0;
+                entry.CreateBy = _userManager.Account;
                 entry.IsDeleted = 0;
                 entry.CreateTime = DateTime.Now;
                 await _seOrderEntryRep.InsertAsync(entry);

+ 89 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Universal/UniversalCustomerService.cs

@@ -0,0 +1,89 @@
+namespace Admin.NET.Plugin.AiDOP.Universal;
+
+/// <summary>
+/// 通用客户选择服务 🏢
+/// 路由前缀:/api/Universal
+/// </summary>
+[ApiDescriptionSettings(Order = 280, Description = "通用-客户选择")]
+[Route("api/Universal")]
+[AllowAnonymous]
+[NonUnify]
+public class UniversalCustomerService : IDynamicApiController, ITransient
+{
+    private readonly ISqlSugarClient _db;
+
+    public UniversalCustomerService(ISqlSugarClient db)
+    {
+        _db = db;
+    }
+
+    // 允许排序的字段白名单(key=前端 prop,value=数据库列名)
+    private static readonly Dictionary<string, string> _sortFieldMap =
+        new(StringComparer.OrdinalIgnoreCase)
+        {
+            ["cust"]     = "Cust",
+            ["sortname"] = "SortName",
+        };
+
+    /// <summary>获取客户选择分页列表 🏢</summary>
+    [DisplayName("获取客户选择列表")]
+    [HttpGet("customer/page")]
+    public async Task<object> GetCustomerPage([FromQuery] CustomerPageInput input)
+    {
+        var conditions = new List<string>();
+        var pars = new List<SugarParameter>();
+
+        if (!string.IsNullOrWhiteSpace(input.CustNo))
+        {
+            conditions.Add("Cust LIKE @CustNo");
+            pars.Add(new SugarParameter("@CustNo", $"%{input.CustNo.Trim()}%"));
+        }
+        if (!string.IsNullOrWhiteSpace(input.SortName))
+        {
+            conditions.Add("SortName LIKE @SortName");
+            pars.Add(new SugarParameter("@SortName", $"%{input.SortName.Trim()}%"));
+        }
+
+        var where = conditions.Count > 0 ? string.Join(" AND ", conditions) : "1=1";
+
+        var dbSortField = _sortFieldMap.TryGetValue(input.SortField ?? "", out var mapped) ? mapped : "Cust";
+        var sortOrder = string.Equals(input.SortOrder, "desc", StringComparison.OrdinalIgnoreCase) ? "DESC" : "ASC";
+
+        var offset = (input.Page - 1) * input.PageSize;
+        var total = await _db.Ado.GetIntAsync(
+            $"SELECT COUNT(*) FROM CustMaster WHERE {where}", pars);
+        var list = await _db.Ado.SqlQueryAsync<CustomerRow>(
+            $"SELECT Cust, SortName, recid AS Id FROM CustMaster WHERE {where} ORDER BY {dbSortField} {sortOrder} LIMIT {input.PageSize} OFFSET {offset}",
+            pars);
+
+        return new { total, page = input.Page, pageSize = input.PageSize, list };
+    }
+
+    // ──────────────── 内部结果映射 ────────────────
+
+    private sealed class CustomerRow
+    {
+        public string? Cust { get; set; }
+        public string? SortName { get; set; }
+        public string? Id { get; set; }
+    }
+}
+
+/// <summary>客户选择分页查询入参</summary>
+public class CustomerPageInput
+{
+    public int Page { get; set; } = 1;
+    public int PageSize { get; set; } = 10;
+
+    /// <summary>客户编号(模糊)</summary>
+    public string? CustNo { get; set; }
+
+    /// <summary>客户名称(模糊)</summary>
+    public string? SortName { get; set; }
+
+    /// <summary>排序字段(前端 prop 名)</summary>
+    public string? SortField { get; set; }
+
+    /// <summary>排序方向:asc / desc</summary>
+    public string? SortOrder { get; set; }
+}

+ 99 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Universal/UniversalItemService.cs

@@ -0,0 +1,99 @@
+namespace Admin.NET.Plugin.AiDOP.Universal;
+
+/// <summary>
+/// 通用物料选择服务 📦
+/// 路由前缀:/api/Universal
+/// </summary>
+[ApiDescriptionSettings(Order = 279, Description = "通用-物料选择")]
+[Route("api/Universal")]
+[AllowAnonymous]
+[NonUnify]
+public class UniversalItemService : IDynamicApiController, ITransient
+{
+    private readonly ISqlSugarClient _db;
+
+    public UniversalItemService(ISqlSugarClient db)
+    {
+        _db = db;
+    }
+
+    // 允许排序的字段白名单(key=前端 prop,value=数据库列名)
+    private static readonly Dictionary<string, string> _sortFieldMap =
+        new(StringComparer.OrdinalIgnoreCase)
+        {
+            ["itemnum"]  = "ItemNum",
+            ["descr"]    = "Descr",
+            ["descr1"]   = "Descr1",
+            ["um"]       = "UM",
+            ["location"] = "Location",
+            ["rev"]      = "Rev",
+            ["drawing"]  = "Drawing",
+        };
+
+    /// <summary>获取物料选择分页列表 📦</summary>
+    [DisplayName("获取物料选择列表")]
+    [HttpGet("item/page")]
+    public async Task<object> GetItemPage([FromQuery] ItemPageInput input)
+    {
+        var conditions = new List<string>();
+        var pars = new List<SugarParameter>();
+
+        if (!string.IsNullOrWhiteSpace(input.ItemNum))
+        {
+            conditions.Add("ItemNum LIKE @ItemNum");
+            pars.Add(new SugarParameter("@ItemNum", $"%{input.ItemNum.Trim()}%"));
+        }
+        if (!string.IsNullOrWhiteSpace(input.Descr))
+        {
+            conditions.Add("Descr LIKE @Descr");
+            pars.Add(new SugarParameter("@Descr", $"%{input.Descr.Trim()}%"));
+        }
+
+        var where = conditions.Count > 0 ? string.Join(" AND ", conditions) : "1=1";
+
+        var dbSortField = _sortFieldMap.TryGetValue(input.SortField ?? "", out var mapped) ? mapped : "ItemNum";
+        var sortOrder = string.Equals(input.SortOrder, "desc", StringComparison.OrdinalIgnoreCase) ? "DESC" : "ASC";
+
+        var offset = (input.Page - 1) * input.PageSize;
+        var total = await _db.Ado.GetIntAsync(
+            $"SELECT COUNT(*) FROM ItemMaster WHERE {where}", pars);
+        var list = await _db.Ado.SqlQueryAsync<ItemRow>(
+            $"SELECT RecID AS RecId, ItemNum, Descr, Descr1, UM AS Um, Location, Rev, Drawing FROM ItemMaster WHERE {where} ORDER BY {dbSortField} {sortOrder} LIMIT {input.PageSize} OFFSET {offset}",
+            pars);
+
+        return new { total, page = input.Page, pageSize = input.PageSize, list };
+    }
+
+    // ──────────────── 内部结果映射 ────────────────
+
+    private sealed class ItemRow
+    {
+        public string? RecId { get; set; }
+        public string? ItemNum { get; set; }
+        public string? Descr { get; set; }
+        public string? Descr1 { get; set; }
+        public string? Um { get; set; }
+        public string? Location { get; set; }
+        public string? Rev { get; set; }
+        public string? Drawing { get; set; }
+    }
+}
+
+/// <summary>物料选择分页查询入参</summary>
+public class ItemPageInput
+{
+    public int Page { get; set; } = 1;
+    public int PageSize { get; set; } = 10;
+
+    /// <summary>物料编号(模糊)</summary>
+    public string? ItemNum { get; set; }
+
+    /// <summary>物料名称(模糊)</summary>
+    public string? Descr { get; set; }
+
+    /// <summary>排序字段(前端 prop 名)</summary>
+    public string? SortField { get; set; }
+
+    /// <summary>排序方向:asc / desc</summary>
+    public string? SortOrder { get; set; }
+}