Bladeren bron

feat(s5): add stocktake label confirm readonly list

- 新增 api/S5StocktakeLabelConfirm list(MissedPrint IsTag=1 + ItemMaster 直读,派生 差异数量/状态/盘点结果)
- 新增 api/S5StocktakeLabelConfirm locations(库位下拉 distinct)
- stocktakeLabelConfirmList.vue 骨架接真实接口(查询/下拉/分页/排序/空态)
- 空态先行,MissedPrint=0;MissedPrint 无 tenant_id 不做租户过滤
- 无 MDP、无 SQL、无菜单变更;无确认写回/批量确认/反确认/库存事务/状态流转/导出
- 版本 server 1.0.225 / Web 2.4.220
YY968XX 1 week geleden
bovenliggende
commit
987a1c9b5d

+ 1 - 1
Web/package.json

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

+ 63 - 0
Web/src/views/aidop/s5/api/stocktakeLabelConfirm.ts

@@ -0,0 +1,63 @@
+import service from '/@/utils/request';
+import { withAidopTenantParams } from '../../api/aidopTenant';
+
+export interface Paged<T> {
+	total: number;
+	page: number;
+	pageSize: number;
+	list: T[];
+}
+
+// ── S5 盘点标签确认(只读,数据源 aidopdev.MissedPrint IsTag=1 + ItemMaster)──
+// 后端 Route api/S5StocktakeLabelConfirm;MissedPrint 无租户列,后端忽略 tenantId。
+export interface StocktakeLabelConfirmRow {
+	id?: number;
+	/** 物料编码 ItemNum */
+	materialCode?: string;
+	/** 物料名称 ItemMaster.Descr */
+	materialName?: string;
+	/** 规格型号 ItemMaster.Descr1 */
+	spec?: string;
+	/** 库位 Location */
+	location?: string;
+	/** 货架 Shelf */
+	shelf?: string;
+	/** 实盘库位 FirmString2 */
+	actualLocation?: string;
+	/** 实盘货架 FirmString4 */
+	actualShelf?: string;
+	/** 批次号 LotSerial */
+	batchNo?: string;
+	/** 单位 ItemMaster.UM */
+	unit?: string;
+	/** 标签数量 Qty */
+	labelQty?: number;
+	/** 实盘数量 CompQty */
+	actualQty?: number;
+	/** 差异数量 CompQty-Qty */
+	diffQty?: number;
+	/** 状态(Q→冻结) */
+	status?: string;
+	/** 盘点结果(有差异/正常) */
+	stocktakeResult?: string;
+	/** 盘点人 UpdateUser */
+	stocktakePerson?: string;
+	/** 盘点时间 UpdateTime */
+	stocktakeTime?: string;
+	/** 标签号/条码 BarCode(默认不展示) */
+	barCode?: string;
+}
+
+export interface StocktakeLabelConfirmLocationOption {
+	val?: string;
+}
+
+export function fetchStocktakeLabelConfirmList(params: any) {
+	return service
+		.get<Paged<StocktakeLabelConfirmRow>>('/api/S5StocktakeLabelConfirm/list', { params: withAidopTenantParams(params) })
+		.then((r) => r.data);
+}
+
+export function fetchStocktakeLabelConfirmLocations() {
+	return service.get<StocktakeLabelConfirmLocationOption[]>('/api/S5StocktakeLabelConfirm/locations').then((r) => r.data);
+}

+ 94 - 15
Web/src/views/aidop/s5/inventory/stocktakeLabelConfirmList.vue

@@ -1,8 +1,16 @@
 <template>
 	<AidopDemoShell :title="pageTitle" subtitle="只读列表">
+		<!--
+			S5 盘点标签确认 · 只读页(S5-INVENTORY-TAG-CONFIRM-READONLY-LIST-1)。
+			数据源:aidopdev.MissedPrint(IsTag=1) + ItemMaster,后端 api/S5StocktakeLabelConfirm 直读。
+			只读:无确认写回/批量确认/反确认/保存/库存事务/状态流转/导出。MissedPrint 无租户列,不做租户过滤。
+			上游 IsTag=1 为 0 行时为真实接口空态,无 mock。
+		-->
 		<el-form :inline="true" :model="query" class="mb12" @submit.prevent>
 			<el-form-item label="库位">
-				<el-select v-model="query.location" placeholder="请选择" clearable style="width: 200px" />
+				<el-select v-model="query.location" placeholder="请选择" clearable filterable style="width: 200px">
+					<el-option v-for="loc in locationOptions" :key="loc" :label="loc" :value="loc" />
+				</el-select>
 			</el-form-item>
 			<el-form-item label="货架">
 				<el-input v-model="query.shelf" clearable style="width: 200px" />
@@ -14,7 +22,10 @@
 				<el-input v-model="query.batchNo" clearable style="width: 200px" />
 			</el-form-item>
 			<el-form-item label="核查结果">
-				<el-select v-model="query.checkResult" placeholder="请选择" clearable style="width: 200px" />
+				<el-select v-model="query.checkResult" placeholder="请选择" clearable style="width: 200px">
+					<el-option label="有差异" value="有差异" />
+					<el-option label="正常" value="正常" />
+				</el-select>
 			</el-form-item>
 			<el-form-item>
 				<el-button type="primary" @click="doSearch">查询</el-button>
@@ -22,23 +33,25 @@
 			</el-form-item>
 		</el-form>
 
-		<el-table :data="rows" row-key="id" v-loading="loading" border stripe>
-			<el-table-column prop="materialCode" label="物料编码" min-width="140" show-overflow-tooltip resizable />
+		<el-table :data="rows" row-key="id" v-loading="loading" border stripe @sort-change="onSortChange">
+			<el-table-column prop="materialCode" label="物料编码" min-width="140" sortable="custom" show-overflow-tooltip resizable />
 			<el-table-column prop="materialName" label="物料名称" min-width="160" show-overflow-tooltip resizable />
 			<el-table-column prop="spec" label="规格型号" min-width="140" show-overflow-tooltip resizable />
-			<el-table-column prop="location" label="库位" min-width="110" show-overflow-tooltip resizable />
-			<el-table-column prop="shelf" label="货架" min-width="110" show-overflow-tooltip resizable />
+			<el-table-column prop="location" label="库位" min-width="110" sortable="custom" show-overflow-tooltip resizable />
+			<el-table-column prop="shelf" label="货架" min-width="110" sortable="custom" show-overflow-tooltip resizable />
 			<el-table-column prop="actualLocation" label="实盘库位" min-width="120" show-overflow-tooltip resizable />
 			<el-table-column prop="actualShelf" label="实盘货架" min-width="120" show-overflow-tooltip resizable />
-			<el-table-column prop="batchNo" label="批次号" min-width="140" show-overflow-tooltip resizable />
+			<el-table-column prop="batchNo" label="批次号" min-width="140" sortable="custom" show-overflow-tooltip resizable />
 			<el-table-column prop="unit" label="单位" min-width="90" resizable />
-			<el-table-column prop="labelQty" label="标签数量" min-width="110" resizable />
-			<el-table-column prop="actualQty" label="实盘数量" min-width="110" resizable />
-			<el-table-column prop="diffQty" label="差异数量" min-width="110" resizable />
+			<el-table-column prop="labelQty" label="标签数量" min-width="110" align="right" sortable="custom" resizable />
+			<el-table-column prop="actualQty" label="实盘数量" min-width="110" align="right" sortable="custom" resizable />
+			<el-table-column prop="diffQty" label="差异数量" min-width="110" align="right" sortable="custom" resizable />
 			<el-table-column prop="status" label="状态" min-width="100" resizable />
-			<el-table-column prop="stocktakeResult" label="盘点结果" min-width="120" resizable />
+			<el-table-column prop="stocktakeResult" label="盘点结果" min-width="120" sortable="custom" resizable />
 			<el-table-column prop="stocktakePerson" label="盘点人" min-width="120" show-overflow-tooltip resizable />
-			<el-table-column prop="stocktakeTime" label="盘点时间" min-width="170" resizable />
+			<el-table-column prop="stocktakeTime" label="盘点时间" min-width="170" sortable="custom" resizable>
+				<template #default="{ row }">{{ fmtDateTime(row.stocktakeTime) }}</template>
+			</el-table-column>
 			<template #empty>
 				<el-empty description="暂无数据" />
 			</template>
@@ -51,15 +64,23 @@
 				:total="total"
 				:page-sizes="[10, 20, 50]"
 				layout="total, sizes, prev, pager, next"
+				@current-change="loadList"
+				@size-change="loadList"
 			/>
 		</div>
 	</AidopDemoShell>
 </template>
 
 <script setup lang="ts" name="aidopS5InventoryStocktakeLabel">
-import { computed, reactive, ref } from 'vue';
+import { computed, onActivated, onMounted, reactive, ref } from 'vue';
 import { useRoute } from 'vue-router';
+import { ElMessage } from 'element-plus';
 import AidopDemoShell from '/@/views/aidop/components/AidopDemoShell.vue';
+import {
+	fetchStocktakeLabelConfirmList,
+	fetchStocktakeLabelConfirmLocations,
+	type StocktakeLabelConfirmRow,
+} from '../api/stocktakeLabelConfirm';
 
 const route = useRoute();
 const pageTitle = computed(() => (route.meta?.title as string) || '盘点标签确认');
@@ -72,15 +93,64 @@ const query = reactive({
 	checkResult: '',
 	page: 1,
 	pageSize: 10,
+	orderBy: '',
+	orderDir: '',
 });
 
-// 本批次为只读 UI 骨架,尚无真实盘点标签接口:渲染空表,不发请求、不造数据。
 const loading = ref(false);
-const rows = ref<Record<string, unknown>[]>([]);
+const rows = ref<StocktakeLabelConfirmRow[]>([]);
 const total = ref(0);
+const locationOptions = ref<string[]>([]);
+
+function fmtDateTime(v?: string | null) {
+	if (!v) return '';
+	const s = String(v);
+	return s.length > 19 ? s.slice(0, 19).replace('T', ' ') : s.replace('T', ' ');
+}
+
+function onSortChange({ prop, order }: { prop: string; order: string | null }) {
+	query.orderBy = prop || '';
+	query.orderDir = order === 'ascending' ? 'asc' : order === 'descending' ? 'desc' : '';
+	loadList();
+}
+
+async function loadList() {
+	loading.value = true;
+	try {
+		const data = await fetchStocktakeLabelConfirmList({
+			location: query.location,
+			shelf: query.shelf,
+			itemNum: query.materialCode,
+			lotSerial: query.batchNo,
+			invStatus: query.checkResult,
+			page: query.page,
+			pageSize: query.pageSize,
+			orderBy: query.orderBy,
+			orderDir: query.orderDir,
+		});
+		rows.value = data.list || [];
+		total.value = data.total || 0;
+	} catch (e: any) {
+		rows.value = [];
+		total.value = 0;
+		ElMessage.error(e?.message || '加载盘点标签确认列表失败');
+	} finally {
+		loading.value = false;
+	}
+}
+
+async function loadLocations() {
+	try {
+		const data = await fetchStocktakeLabelConfirmLocations();
+		locationOptions.value = (data || []).map((o) => o.val || '').filter((v) => v);
+	} catch {
+		locationOptions.value = [];
+	}
+}
 
 function doSearch() {
 	query.page = 1;
+	loadList();
 }
 
 function resetQuery() {
@@ -90,7 +160,16 @@ function resetQuery() {
 	query.batchNo = '';
 	query.checkResult = '';
 	query.page = 1;
+	query.orderBy = '';
+	query.orderDir = '';
+	loadList();
 }
+
+onMounted(() => {
+	loadLocations();
+	loadList();
+});
+onActivated(() => loadList());
 </script>
 
 <style scoped lang="scss">

+ 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.224</AssemblyVersion>
-    <FileVersion>1.0.224</FileVersion>
-    <Version>1.0.224</Version>
+    <AssemblyVersion>1.0.225</AssemblyVersion>
+    <FileVersion>1.0.225</FileVersion>
+    <Version>1.0.225</Version>
   </PropertyGroup>
 
   <ItemGroup>

+ 106 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/MaterialWarehouse/Dto/StocktakeLabelConfirmDto.cs

@@ -0,0 +1,106 @@
+namespace Admin.NET.Plugin.AiDOP.MaterialWarehouse.Dto;
+
+/// <summary>
+/// 盘点标签确认列表 查询入参(只读)。读 aidopdev.MissedPrint(IsTag=1) + ItemMaster。
+/// </summary>
+public class StocktakeLabelConfirmListInput
+{
+    /// <summary>页码(从 1 开始)</summary>
+    public int Page { get; set; } = 1;
+
+    /// <summary>每页条数</summary>
+    public int PageSize { get; set; } = 10;
+
+    /// <summary>库位(MissedPrint.Location,模糊匹配)</summary>
+    public string? Location { get; set; }
+
+    /// <summary>货架(MissedPrint.Shelf,模糊匹配)</summary>
+    public string? Shelf { get; set; }
+
+    /// <summary>物料编码(MissedPrint.ItemNum,模糊匹配)</summary>
+    public string? ItemNum { get; set; }
+
+    /// <summary>批次号(MissedPrint.LotSerial,模糊匹配)</summary>
+    public string? LotSerial { get; set; }
+
+    /// <summary>核查结果(有差异 / 正常;按 CompQty&lt;&gt;Qty 过滤)</summary>
+    public string? InvStatus { get; set; }
+
+    /// <summary>排序字段(前端列 prop:itemNum / location / shelf / lotSerial / qty / compQty / qtyDiff / invStatus / updateTime)</summary>
+    public string? OrderBy { get; set; }
+
+    /// <summary>排序方向(asc / desc)</summary>
+    public string? OrderDir { get; set; }
+
+    /// <summary>租户 ID(前端 withAidopTenantParams 注入;MissedPrint 无租户列,本查询忽略,不据此过滤)</summary>
+    public long? TenantId { get; set; }
+}
+
+/// <summary>
+/// 盘点标签确认列表 行(只读,扁平)。字段对齐现有前端骨架页 stocktakeLabelConfirmList.vue 的列 prop(camelCase)。
+/// </summary>
+public class StocktakeLabelConfirmListRow
+{
+    /// <summary>主键 id(MissedPrint.RecID)</summary>
+    public long Id { get; set; }
+
+    /// <summary>物料编码(MissedPrint.ItemNum)</summary>
+    public string? MaterialCode { get; set; }
+
+    /// <summary>物料名称(ItemMaster.Descr)</summary>
+    public string? MaterialName { get; set; }
+
+    /// <summary>规格型号(ItemMaster.Descr1)</summary>
+    public string? Spec { get; set; }
+
+    /// <summary>库位(MissedPrint.Location)</summary>
+    public string? Location { get; set; }
+
+    /// <summary>货架(MissedPrint.Shelf)</summary>
+    public string? Shelf { get; set; }
+
+    /// <summary>实盘库位(MissedPrint.FirmString2)</summary>
+    public string? ActualLocation { get; set; }
+
+    /// <summary>实盘货架(MissedPrint.FirmString4)</summary>
+    public string? ActualShelf { get; set; }
+
+    /// <summary>批次号(MissedPrint.LotSerial)</summary>
+    public string? BatchNo { get; set; }
+
+    /// <summary>单位(ItemMaster.UM)</summary>
+    public string? Unit { get; set; }
+
+    /// <summary>标签数量(MissedPrint.Qty)</summary>
+    public decimal? LabelQty { get; set; }
+
+    /// <summary>实盘数量(MissedPrint.CompQty)</summary>
+    public decimal? ActualQty { get; set; }
+
+    /// <summary>差异数量(CompQty - Qty)</summary>
+    public decimal? DiffQty { get; set; }
+
+    /// <summary>状态(Status='Q'→冻结,其余 raw)</summary>
+    public string? Status { get; set; }
+
+    /// <summary>盘点结果(CompQty&lt;&gt;Qty→有差异,否则正常)</summary>
+    public string? StocktakeResult { get; set; }
+
+    /// <summary>盘点人(MissedPrint.UpdateUser)</summary>
+    public string? StocktakePerson { get; set; }
+
+    /// <summary>盘点时间(MissedPrint.UpdateTime)</summary>
+    public DateTime? StocktakeTime { get; set; }
+
+    /// <summary>标签号/条码(MissedPrint.BarCode;DTO 保留,前端默认不展示)</summary>
+    public string? BarCode { get; set; }
+}
+
+/// <summary>
+/// 盘点标签确认 库位下拉选项(MissedPrint IsTag=1 的 Location distinct,只读)。
+/// </summary>
+public class StocktakeLabelConfirmLocationOption
+{
+    /// <summary>库位(MissedPrint.Location)</summary>
+    public string? Val { get; set; }
+}

+ 146 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/MaterialWarehouse/StocktakeLabelConfirmService.cs

@@ -0,0 +1,146 @@
+using Admin.NET.Plugin.AiDOP.MaterialWarehouse.Dto;
+
+namespace Admin.NET.Plugin.AiDOP.MaterialWarehouse;
+
+/// <summary>
+/// S5 盘点标签确认 只读 list 服务。
+///
+/// 数据源:aidopdev.MissedPrint(IsTag=1) LEFT JOIN ItemMaster(物料名称/规格/单位)。
+/// 参考本仓既有 MissedPrint 直读只读投影先例:LabelQueryService / StockQueryService / PendingInspectionService。
+///
+/// 本服务仅 SELECT:无新增/编辑/删除/确认写回/批量确认/反确认/库存事务/状态流转;扁平列表,无 detail。
+/// MissedPrint 无 tenant_id 列,不做租户/工厂域过滤(对齐既有 MissedPrint 只读投影先例);不加 Domain 占位过滤。
+/// 派生列:diffQty=CompQty-Qty;status(Status='Q'→冻结);stocktakeResult(CompQty&lt;&gt;Qty→有差异/正常)。
+/// </summary>
+[ApiDescriptionSettings(Order = 308, Description = "盘点标签确认")]
+[Route("api/S5StocktakeLabelConfirm")]
+[AllowAnonymous]
+[NonUnify]
+public class StocktakeLabelConfirmService : IDynamicApiController, ITransient
+{
+    private readonly ISqlSugarClient _db;
+
+    public StocktakeLabelConfirmService(ISqlSugarClient db)
+    {
+        _db = db;
+    }
+
+    /// <summary>
+    /// 盘点标签确认列表(只读分页查询)。
+    /// </summary>
+    [DisplayName("盘点标签确认列表")]
+    [HttpGet("list")]
+    public async Task<object> GetList([FromQuery] StocktakeLabelConfirmListInput input)
+    {
+        var page = input.Page <= 0 ? 1 : input.Page;
+        var pageSize = input.PageSize <= 0 ? 10 : input.PageSize;
+        var offset = (page - 1) * pageSize;
+
+        var where = new List<string> { "m.IsTag = 1" };
+        var pars = new List<SugarParameter>();
+
+        if (!string.IsNullOrWhiteSpace(input.Location))
+        {
+            where.Add("m.Location LIKE @Location");
+            pars.Add(new SugarParameter("@Location", $"%{input.Location.Trim()}%"));
+        }
+        if (!string.IsNullOrWhiteSpace(input.Shelf))
+        {
+            where.Add("m.Shelf LIKE @Shelf");
+            pars.Add(new SugarParameter("@Shelf", $"%{input.Shelf.Trim()}%"));
+        }
+        if (!string.IsNullOrWhiteSpace(input.ItemNum))
+        {
+            where.Add("m.ItemNum LIKE @ItemNum");
+            pars.Add(new SugarParameter("@ItemNum", $"%{input.ItemNum.Trim()}%"));
+        }
+        if (!string.IsNullOrWhiteSpace(input.LotSerial))
+        {
+            where.Add("m.LotSerial LIKE @LotSerial");
+            pars.Add(new SugarParameter("@LotSerial", $"%{input.LotSerial.Trim()}%"));
+        }
+        if (string.Equals(input.InvStatus, "有差异", StringComparison.Ordinal))
+        {
+            where.Add("IFNULL(m.CompQty,0) <> IFNULL(m.Qty,0)");
+        }
+        else if (string.Equals(input.InvStatus, "正常", StringComparison.Ordinal))
+        {
+            where.Add("IFNULL(m.CompQty,0) = IFNULL(m.Qty,0)");
+        }
+
+        var whereSql = string.Join(" AND ", where);
+
+        var total = await _db.Ado.GetIntAsync(
+            $"SELECT COUNT(1) FROM MissedPrint m WHERE {whereSql}", pars);
+
+        var list = await _db.Ado.SqlQueryAsync<StocktakeLabelConfirmListRow>(
+            $"""
+            SELECT
+                m.RecID       AS Id,
+                m.ItemNum     AS MaterialCode,
+                i.Descr       AS MaterialName,
+                i.Descr1      AS Spec,
+                m.Location    AS Location,
+                m.Shelf       AS Shelf,
+                m.FirmString2 AS ActualLocation,
+                m.FirmString4 AS ActualShelf,
+                m.LotSerial   AS BatchNo,
+                i.UM          AS Unit,
+                m.Qty         AS LabelQty,
+                m.CompQty     AS ActualQty,
+                IFNULL(m.CompQty,0) - IFNULL(m.Qty,0) AS DiffQty,
+                CASE WHEN m.Status='Q' THEN '冻结' ELSE IFNULL(m.Status,'') END AS Status,
+                CASE WHEN IFNULL(m.CompQty,0) <> IFNULL(m.Qty,0) THEN '有差异' ELSE '正常' END AS StocktakeResult,
+                m.UpdateUser  AS StocktakePerson,
+                m.UpdateTime  AS StocktakeTime,
+                m.BarCode     AS BarCode
+            FROM MissedPrint m
+            LEFT JOIN ItemMaster i ON i.Domain = m.Domain AND i.ItemNum = m.ItemNum
+            WHERE {whereSql}
+            ORDER BY {BuildOrderBy(input.OrderBy, input.OrderDir)}
+            LIMIT {pageSize} OFFSET {offset}
+            """,
+            pars);
+
+        return new { total, page, pageSize, list };
+    }
+
+    /// <summary>
+    /// 库位下拉选项(MissedPrint IsTag=1 的 Location distinct,只读)。
+    /// </summary>
+    [DisplayName("盘点标签确认库位选项")]
+    [HttpGet("locations")]
+    public async Task<object> GetLocations()
+    {
+        var list = await _db.Ado.SqlQueryAsync<StocktakeLabelConfirmLocationOption>(
+            """
+            SELECT DISTINCT Location AS Val
+            FROM MissedPrint
+            WHERE IsTag = 1 AND Location IS NOT NULL AND Location <> ''
+            ORDER BY Location
+            """);
+        return list;
+    }
+
+    /// <summary>
+    /// 排序白名单:仅允许按已展示列排序,杜绝 SQL 注入;非法字段回落默认。
+    /// </summary>
+    private static string BuildOrderBy(string? orderBy, string? orderDir)
+    {
+        var column = orderBy switch
+        {
+            "itemNum" => "m.ItemNum",
+            "location" => "m.Location",
+            "shelf" => "m.Shelf",
+            "lotSerial" => "m.LotSerial",
+            "qty" => "m.Qty",
+            "compQty" => "m.CompQty",
+            "qtyDiff" => "(IFNULL(m.CompQty,0) - IFNULL(m.Qty,0))",
+            "invStatus" => "(CASE WHEN IFNULL(m.CompQty,0) <> IFNULL(m.Qty,0) THEN 1 ELSE 0 END)",
+            "updateTime" => "m.UpdateTime",
+            _ => "m.UpdateTime",
+        };
+        var direction = string.Equals(orderDir, "asc", StringComparison.OrdinalIgnoreCase) ? "ASC" : "DESC";
+        return $"{column} {direction}, m.RecID DESC";
+    }
+}