Pārlūkot izejas kodu

feat(s5): 库存查询只读查询接口 + 前端接线

S5z 库存数据/库存查询:新增 StockQuery 只读查询(list/type-options/location-options),忠实复刻存储过程 pr_WMS_GetMissedLocationQty base 口径(LocationMaster⋈LocationDetail 现存量 AvailStatusQty+Assay+FreezeQty>0,派生 LocType,join ItemMaster,富集 MissedPrint 货架/版本/供应商);前端既有骨架接线(接口/类型·库位下拉/4数值筛/分页/合计/空态),未改列结构。数据源 aidopdev 只读,无 DDL/无写路径。多货架拆分因 MissedPrint 空零效果后置。

chore: bump version Web 2.4.216 / server 1.0.221
YY968XX 1 nedēļu atpakaļ
vecāks
revīzija
38d5e202c4

+ 1 - 1
Web/package.json

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

+ 68 - 0
Web/src/views/aidop/s5/api/stockQuery.ts

@@ -0,0 +1,68 @@
+import service from '/@/utils/request';
+import { withAidopTenantParams } from '../../api/aidopTenant';
+
+export interface StockQueryPaged<T> {
+	total: number;
+	page: number;
+	pageSize: number;
+	list: T[];
+	grandTotalQtyOnHand: number;
+	grandUnrestricted: number;
+	grandQc: number;
+	grandFrozen: number;
+}
+
+// ── S5 库存查询(只读,数据源 aidopdev.LocationDetail,存储过程 pr_WMS_GetMissedLocationQty 口径)──
+export interface StockQueryRow {
+	/** 类型 LocType(VMI/O库/空) */
+	type?: string;
+	/** 库位 Location */
+	location?: string;
+	/** 库位名称 LocationMaster.Descr */
+	locationName?: string;
+	/** 物料编码 ItemNum */
+	materialCode?: string;
+	/** 物料名称 ItemMaster.Descr */
+	materialName?: string;
+	/** 批次号 LotSerial */
+	batchNo?: string;
+	/** 货架 MissedPrint.Shelf */
+	shelf?: string;
+	/** 版本 MissedPrint.LevelChar */
+	version?: string;
+	/** 非限制库存 AvailStatusQty */
+	unrestrictedStock?: number;
+	/** 质检库存 Assay */
+	qcStock?: number;
+	/** 冻结库存 FreezeQty */
+	frozenStock?: number;
+	/** 库存数量 QtyOnHand(合计用,列表默认不展示) */
+	qtyOnHand?: number;
+	/** 单位 UM */
+	unit?: string;
+	/** 供应商 MissedPrint.Supply */
+	supplier?: string;
+}
+
+export interface StockQueryTypeOption {
+	val?: string;
+}
+
+export interface StockQueryLocationOption {
+	val?: string;
+	label?: string;
+}
+
+export function fetchStockQueryList(params: any) {
+	return service
+		.get<StockQueryPaged<StockQueryRow>>('/api/S5StockQuery/list', { params: withAidopTenantParams(params) })
+		.then((r) => r.data);
+}
+
+export function fetchStockQueryTypeOptions() {
+	return service.get<StockQueryTypeOption[]>('/api/S5StockQuery/type-options').then((r) => r.data);
+}
+
+export function fetchStockQueryLocationOptions() {
+	return service.get<StockQueryLocationOption[]>('/api/S5StockQuery/location-options').then((r) => r.data);
+}

+ 90 - 6
Web/src/views/aidop/s5/inventory/stockQueryList.vue

@@ -2,10 +2,14 @@
 	<AidopDemoShell :title="pageTitle" subtitle="只读列表">
 		<el-form :inline="true" :model="query" class="mb12" @submit.prevent>
 			<el-form-item label="类型">
-				<el-select v-model="query.type" placeholder="请选择" clearable style="width: 200px" />
+				<el-select v-model="query.type" placeholder="请选择" clearable style="width: 200px">
+					<el-option v-for="o in typeOptions" :key="o.val" :label="o.val" :value="o.val!" />
+				</el-select>
 			</el-form-item>
 			<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="o in locationOptions" :key="o.val" :label="o.label ? `${o.val} ${o.label}` : o.val" :value="o.val!" />
+				</el-select>
 			</el-form-item>
 			<el-form-item label="物料编码">
 				<el-input v-model="query.materialCode" clearable style="width: 200px" />
@@ -31,7 +35,7 @@
 			</el-form-item>
 		</el-form>
 
-		<el-table :data="rows" row-key="id" v-loading="loading" border stripe>
+		<el-table :data="rows" v-loading="loading" border stripe show-summary :summary-method="summaryMethod">
 			<el-table-column prop="type" label="类型" min-width="100" resizable />
 			<el-table-column prop="location" label="库位" min-width="110" show-overflow-tooltip resizable />
 			<el-table-column prop="locationName" label="库位名称" min-width="130" show-overflow-tooltip resizable />
@@ -57,15 +61,26 @@
 				: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="aidopS5InventoryStockQuery">
-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 {
+	fetchStockQueryList,
+	fetchStockQueryTypeOptions,
+	fetchStockQueryLocationOptions,
+	type StockQueryRow,
+	type StockQueryTypeOption,
+	type StockQueryLocationOption,
+} from '../api/stockQuery';
 
 const route = useRoute();
 const pageTitle = computed(() => (route.meta?.title as string) || '库存查询');
@@ -83,13 +98,75 @@ const query = reactive({
 	pageSize: 10,
 });
 
-// 本批次为只读 UI 骨架,尚无真实库存查询接口:渲染空表,不发请求、不造数据。
 const loading = ref(false);
-const rows = ref<Record<string, unknown>[]>([]);
+const rows = ref<StockQueryRow[]>([]);
 const total = ref(0);
+const grand = reactive({ unrestricted: 0, qc: 0, frozen: 0 });
+const typeOptions = ref<StockQueryTypeOption[]>([]);
+const locationOptions = ref<StockQueryLocationOption[]>([]);
+
+function numOrUndef(v: string): number | undefined {
+	if (v === '' || v === null || v === undefined) return undefined;
+	const n = Number(v);
+	return Number.isFinite(n) ? n : undefined;
+}
+
+async function loadList() {
+	loading.value = true;
+	try {
+		const data = await fetchStockQueryList({
+			type: query.type,
+			location: query.location,
+			materialCode: query.materialCode,
+			batchNo: query.batchNo,
+			stockGt: numOrUndef(query.stockGt),
+			unrestrictedGt: numOrUndef(query.unrestrictedGt),
+			qcStockGt: numOrUndef(query.qcStockGt),
+			frozenGt: numOrUndef(query.frozenGt),
+			page: query.page,
+			pageSize: query.pageSize,
+		});
+		rows.value = data.list || [];
+		total.value = data.total || 0;
+		grand.unrestricted = data.grandUnrestricted || 0;
+		grand.qc = data.grandQc || 0;
+		grand.frozen = data.grandFrozen || 0;
+	} catch (e: any) {
+		rows.value = [];
+		total.value = 0;
+		grand.unrestricted = 0;
+		grand.qc = 0;
+		grand.frozen = 0;
+		ElMessage.error(e?.message || '加载库存查询列表失败');
+	} finally {
+		loading.value = false;
+	}
+}
+
+async function loadOptions() {
+	try {
+		const [ty, loc] = await Promise.all([fetchStockQueryTypeOptions(), fetchStockQueryLocationOptions()]);
+		typeOptions.value = ty || [];
+		locationOptions.value = loc || [];
+	} catch {
+		typeOptions.value = [];
+		locationOptions.value = [];
+	}
+}
+
+function summaryMethod({ columns }: { columns: { property?: string }[] }) {
+	return columns.map((col, idx) => {
+		if (idx === 0) return '合计';
+		if (col.property === 'unrestrictedStock') return String(grand.unrestricted ?? 0);
+		if (col.property === 'qcStock') return String(grand.qc ?? 0);
+		if (col.property === 'frozenStock') return String(grand.frozen ?? 0);
+		return '';
+	});
+}
 
 function doSearch() {
 	query.page = 1;
+	loadList();
 }
 
 function resetQuery() {
@@ -102,7 +179,14 @@ function resetQuery() {
 	query.qcStockGt = '';
 	query.frozenGt = '';
 	query.page = 1;
+	loadList();
 }
+
+onMounted(() => {
+	loadOptions();
+	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.220</AssemblyVersion>
-    <FileVersion>1.0.220</FileVersion>
-    <Version>1.0.220</Version>
+    <AssemblyVersion>1.0.221</AssemblyVersion>
+    <FileVersion>1.0.221</FileVersion>
+    <Version>1.0.221</Version>
   </PropertyGroup>
 
   <ItemGroup>

+ 110 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/MaterialWarehouse/Dto/StockQueryDto.cs

@@ -0,0 +1,110 @@
+namespace Admin.NET.Plugin.AiDOP.MaterialWarehouse.Dto;
+
+/// <summary>
+/// S5 库存查询 列表查询入参(只读)。
+///
+/// 数据源:aidopdev.LocationMaster ⋈ LocationDetail(现存量),按存储过程 pr_WMS_GetMissedLocationQty
+/// 口径过滤 (AvailStatusQty+Assay+FreezeQty)&gt;0,join ItemMaster(名称/单位)、富集 MissedPrint(货架/版本/供应商)。
+/// Domain 由 GeneralizedCodeMaster(SystemConfig/CompanyCode) 解析,默认 8010。
+/// LocationDetail 无 tenant_id 列;TenantId 仅为前端 withAidopTenantParams 注入的透传参数,本查询不据其过滤。
+/// </summary>
+public class StockQueryListInput
+{
+    /// <summary>页码(从 1 开始)</summary>
+    public int Page { get; set; } = 1;
+
+    /// <summary>每页条数</summary>
+    public int PageSize { get; set; } = 10;
+
+    /// <summary>类型(LocType:VMI / O库,精确匹配,下拉选择)</summary>
+    public string? Type { get; set; }
+
+    /// <summary>库位(LocationDetail.Location,精确匹配,下拉选择)</summary>
+    public string? Location { get; set; }
+
+    /// <summary>物料编码(LocationDetail.ItemNum,模糊匹配)</summary>
+    public string? MaterialCode { get; set; }
+
+    /// <summary>批次号(LocationDetail.LotSerial,模糊匹配)</summary>
+    public string? BatchNo { get; set; }
+
+    /// <summary>库存数大于(LocationDetail.QtyOnHand &gt; 值)</summary>
+    public decimal? StockGt { get; set; }
+
+    /// <summary>非限制库存大于(LocationDetail.AvailStatusQty &gt; 值)</summary>
+    public decimal? UnrestrictedGt { get; set; }
+
+    /// <summary>质检库存大于(LocationDetail.Assay &gt; 值)</summary>
+    public decimal? QcStockGt { get; set; }
+
+    /// <summary>冻结库存大于(LocationDetail.FreezeQty &gt; 值)</summary>
+    public decimal? FrozenGt { get; set; }
+
+    /// <summary>租户 ID(前端 withAidopTenantParams 注入;LocationDetail 无租户列,本查询忽略)</summary>
+    public long? TenantId { get; set; }
+}
+
+/// <summary>
+/// S5 库存查询 列表行(只读)。字段对齐前端列 prop(camelCase),源列见注释(存储过程 pr_WMS_GetMissedLocationQty)。
+/// </summary>
+public class StockQueryListRow
+{
+    /// <summary>类型(LocType:VMI / O库 / 空)</summary>
+    public string? Type { get; set; }
+
+    /// <summary>库位(LocationDetail.Location)</summary>
+    public string? Location { get; set; }
+
+    /// <summary>库位名称(LocationMaster.Descr)</summary>
+    public string? LocationName { get; set; }
+
+    /// <summary>物料编码(LocationDetail.ItemNum)</summary>
+    public string? MaterialCode { get; set; }
+
+    /// <summary>物料名称(ItemMaster.Descr)</summary>
+    public string? MaterialName { get; set; }
+
+    /// <summary>批次号(LocationDetail.LotSerial)</summary>
+    public string? BatchNo { get; set; }
+
+    /// <summary>货架(MissedPrint.Shelf 富集,当前 MissedPrint 空则为空)</summary>
+    public string? Shelf { get; set; }
+
+    /// <summary>版本(MissedPrint.LevelChar 富集)</summary>
+    public string? Version { get; set; }
+
+    /// <summary>非限制库存(LocationDetail.AvailStatusQty)</summary>
+    public decimal? UnrestrictedStock { get; set; }
+
+    /// <summary>质检库存(LocationDetail.Assay)</summary>
+    public decimal? QcStock { get; set; }
+
+    /// <summary>冻结库存(LocationDetail.FreezeQty)</summary>
+    public decimal? FrozenStock { get; set; }
+
+    /// <summary>库存数量(LocationDetail.QtyOnHand,用于合计,列表默认不展示)</summary>
+    public decimal? QtyOnHand { get; set; }
+
+    /// <summary>单位(ItemMaster.UM)</summary>
+    public string? Unit { get; set; }
+
+    /// <summary>供应商(MissedPrint.Supply 富集)</summary>
+    public string? Supplier { get; set; }
+}
+
+/// <summary>类型下拉选项(派生 LocType:VMI / O库)。</summary>
+public class StockQueryTypeOption
+{
+    /// <summary>类型值</summary>
+    public string? Val { get; set; }
+}
+
+/// <summary>库位下拉选项(LocationMaster.Location + Descr)。</summary>
+public class StockQueryLocationOption
+{
+    /// <summary>库位(LocationMaster.Location)</summary>
+    public string? Val { get; set; }
+
+    /// <summary>库位名称(LocationMaster.Descr)</summary>
+    public string? Label { get; set; }
+}

+ 221 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/MaterialWarehouse/StockQueryService.cs

@@ -0,0 +1,221 @@
+using Admin.NET.Plugin.AiDOP.MaterialWarehouse.Dto;
+
+namespace Admin.NET.Plugin.AiDOP.MaterialWarehouse;
+
+/// <summary>
+/// S5 库存查询 只读 list 服务。
+///
+/// 数据源:aidopdev.LocationMaster ⋈ LocationDetail(现存量),忠实复刻旧系统存储过程
+/// pr_WMS_GetMissedLocationQty 主口径:Domain 过滤 + (AvailStatusQty+Assay+FreezeQty)&gt;0,
+/// 类型 LocType 由 LocationMaster.Typed 派生(VMI / Supp→O库),join ItemMaster(名称/单位)、
+/// 富集 MissedPrint(货架/版本/供应商,Status not in U/P/C、Location 非空、Qty&gt;0,按库位·物料·批次聚合)。
+/// Domain 由 GeneralizedCodeMaster(SystemConfig/CompanyCode) 解析,默认 8010。
+///
+/// 忠实度说明:存储过程「多货架行拆分 + 按 MissedPrint 状态重算量」(原过程第 99-121 行) 仅在同一
+/// 库位·物料·批次存在多货架时生效;当前 MissedPrint 为 0 行,其效果为零且无数据可验,本服务不复刻该拆分
+/// (LocCount≤1 场景即 base + 富集,与现数据一致)。MissedPrint 回补后如需多货架拆分,另起批次复验。
+///
+/// 本服务仅 SELECT:无新增/编辑/删除/库存事务/状态流转;LocationDetail 无 tenant_id,不做租户过滤。
+/// </summary>
+[ApiDescriptionSettings(Order = 300, Description = "库存查询")]
+[Route("api/S5StockQuery")]
+[AllowAnonymous]
+[NonUnify]
+public class StockQueryService : IDynamicApiController, ITransient
+{
+    private readonly ISqlSugarClient _db;
+
+    public StockQueryService(ISqlSugarClient db)
+    {
+        _db = db;
+    }
+
+    private sealed class GrandTotals
+    {
+        public decimal QtyOnHand { get; set; }
+        public decimal Unrestricted { get; set; }
+        public decimal Qc { get; set; }
+        public decimal Frozen { get; set; }
+    }
+
+    /// <summary>解析公司域(GeneralizedCodeMaster SystemConfig/CompanyCode,默认 8010)。</summary>
+    private async Task<string> ResolveDomainAsync()
+    {
+        var list = await _db.Ado.SqlQueryAsync<string>(
+            "SELECT Domain FROM GeneralizedCodeMaster WHERE FldName='SystemConfig' AND Val='CompanyCode' ORDER BY RecID LIMIT 1");
+        var domain = list.FirstOrDefault();
+        return string.IsNullOrWhiteSpace(domain) ? "8010" : domain;
+    }
+
+    // 派生 LocType 表达式(与存储过程一致)
+    private const string LocTypeExpr = "CASE WHEN t.typed='VMI' THEN 'VMI' WHEN t.typed='Supp' THEN 'O库' ELSE '' END";
+
+    /// <summary>
+    /// 库存查询列表(只读分页查询)。
+    /// </summary>
+    [DisplayName("库存查询列表")]
+    [HttpGet("list")]
+    public async Task<object> GetList([FromQuery] StockQueryListInput input)
+    {
+        var page = input.Page <= 0 ? 1 : input.Page;
+        var pageSize = input.PageSize <= 0 ? 10 : input.PageSize;
+        var offset = (page - 1) * pageSize;
+
+        var domain = await ResolveDomainAsync();
+
+        var where = new List<string>
+        {
+            "t.Domain = @Domain",
+            "(IFNULL(d.AvailStatusQty,0)+IFNULL(d.Assay,0)+IFNULL(d.FreezeQty,0)) > 0",
+        };
+        var pars = new List<SugarParameter> { new("@Domain", domain) };
+
+        if (!string.IsNullOrWhiteSpace(input.Type))
+        {
+            where.Add($"({LocTypeExpr}) = @Type");
+            pars.Add(new SugarParameter("@Type", input.Type.Trim()));
+        }
+        if (!string.IsNullOrWhiteSpace(input.Location))
+        {
+            where.Add("d.Location = @Location");
+            pars.Add(new SugarParameter("@Location", input.Location.Trim()));
+        }
+        if (!string.IsNullOrWhiteSpace(input.MaterialCode))
+        {
+            where.Add("d.ItemNum LIKE @MaterialCode");
+            pars.Add(new SugarParameter("@MaterialCode", $"%{input.MaterialCode.Trim()}%"));
+        }
+        if (!string.IsNullOrWhiteSpace(input.BatchNo))
+        {
+            where.Add("d.LotSerial LIKE @BatchNo");
+            pars.Add(new SugarParameter("@BatchNo", $"%{input.BatchNo.Trim()}%"));
+        }
+        if (input.StockGt.HasValue)
+        {
+            where.Add("d.QtyOnHand > @StockGt");
+            pars.Add(new SugarParameter("@StockGt", input.StockGt.Value));
+        }
+        if (input.UnrestrictedGt.HasValue)
+        {
+            where.Add("d.AvailStatusQty > @UnrestrictedGt");
+            pars.Add(new SugarParameter("@UnrestrictedGt", input.UnrestrictedGt.Value));
+        }
+        if (input.QcStockGt.HasValue)
+        {
+            where.Add("d.Assay > @QcStockGt");
+            pars.Add(new SugarParameter("@QcStockGt", input.QcStockGt.Value));
+        }
+        if (input.FrozenGt.HasValue)
+        {
+            where.Add("d.FreezeQty > @FrozenGt");
+            pars.Add(new SugarParameter("@FrozenGt", input.FrozenGt.Value));
+        }
+
+        var whereSql = string.Join(" AND ", where);
+
+        // 复刻存储过程 @tbLocDet 基础层(现存量,含派生 LocType 与库位名)
+        var baseQuery =
+            $"""
+            SELECT d.Location, d.ItemNum, IFNULL(d.LotSerial,'') AS LotSerial,
+                   d.QtyOnHand, d.AvailStatusQty, d.Assay, d.FreezeQty,
+                   {LocTypeExpr} AS LocType, t.descr AS LocationName
+            FROM LocationMaster t
+            INNER JOIN LocationDetail d ON t.Domain = d.Domain AND t.location = d.Location
+            WHERE {whereSql}
+            """;
+
+        // MissedPrint 富集(货架/版本/供应商),按库位·物料·批次聚合,1:1
+        const string mpQuery =
+            """
+            SELECT Location, ItemNum, IFNULL(LotSerial,'') AS LotSerial,
+                   MAX(Shelf) AS Shelf, MAX(LevelChar) AS LevelChar, MAX(Supply) AS Supply
+            FROM MissedPrint
+            WHERE Domain = @Domain AND IFNULL(Status,'') NOT IN ('U','P','C') AND IFNULL(Location,'') <> '' AND Qty > 0
+            GROUP BY Location, ItemNum, IFNULL(LotSerial,'')
+            """;
+
+        var total = await _db.Ado.GetIntAsync($"SELECT COUNT(1) FROM ({baseQuery}) n", pars);
+
+        var grand = (await _db.Ado.SqlQueryAsync<GrandTotals>(
+            $"""
+            SELECT IFNULL(SUM(n.QtyOnHand),0) AS QtyOnHand, IFNULL(SUM(n.AvailStatusQty),0) AS Unrestricted,
+                   IFNULL(SUM(n.Assay),0) AS Qc, IFNULL(SUM(n.FreezeQty),0) AS Frozen
+            FROM ({baseQuery}) n
+            """, pars)).FirstOrDefault() ?? new GrandTotals();
+
+        var list = await _db.Ado.SqlQueryAsync<StockQueryListRow>(
+            $"""
+            SELECT
+                n.LocType         AS Type,
+                n.Location        AS Location,
+                n.LocationName    AS LocationName,
+                n.ItemNum         AS MaterialCode,
+                i.Descr           AS MaterialName,
+                n.LotSerial       AS BatchNo,
+                IFNULL(mp.Shelf,'')     AS Shelf,
+                IFNULL(mp.LevelChar,'') AS Version,
+                n.AvailStatusQty  AS UnrestrictedStock,
+                n.Assay           AS QcStock,
+                n.FreezeQty       AS FrozenStock,
+                n.QtyOnHand       AS QtyOnHand,
+                i.UM              AS Unit,
+                IFNULL(mp.Supply,'')    AS Supplier
+            FROM ({baseQuery}) n
+            LEFT JOIN ItemMaster i ON i.Domain = @Domain AND n.ItemNum = i.ItemNum
+            LEFT JOIN ({mpQuery}) mp ON mp.Location = n.Location AND mp.ItemNum = n.ItemNum AND mp.LotSerial = n.LotSerial
+            ORDER BY n.Location, n.ItemNum, n.LotSerial
+            LIMIT {pageSize} OFFSET {offset}
+            """,
+            pars);
+
+        return new
+        {
+            total,
+            page,
+            pageSize,
+            list,
+            grandTotalQtyOnHand = grand.QtyOnHand,
+            grandUnrestricted = grand.Unrestricted,
+            grandQc = grand.Qc,
+            grandFrozen = grand.Frozen,
+        };
+    }
+
+    /// <summary>
+    /// 类型下拉选项(派生 LocType:VMI / O库,只读)。
+    /// </summary>
+    [DisplayName("库存查询类型选项")]
+    [HttpGet("type-options")]
+    public async Task<object> GetTypeOptions()
+    {
+        var domain = await ResolveDomainAsync();
+        var list = await _db.Ado.SqlQueryAsync<StockQueryTypeOption>(
+            $"""
+            SELECT DISTINCT {LocTypeExpr.Replace("t.typed", "typed")} AS Val
+            FROM LocationMaster
+            WHERE Domain = @Domain AND {LocTypeExpr.Replace("t.typed", "typed")} <> ''
+            ORDER BY Val
+            """,
+            new List<SugarParameter> { new("@Domain", domain) });
+        return list;
+    }
+
+    /// <summary>
+    /// 库位下拉选项(LocationMaster.Location + Descr,只读)。
+    /// </summary>
+    [DisplayName("库存查询库位选项")]
+    [HttpGet("location-options")]
+    public async Task<object> GetLocationOptions()
+    {
+        var domain = await ResolveDomainAsync();
+        var list = await _db.Ado.SqlQueryAsync<StockQueryLocationOption>(
+            """
+            SELECT location AS Val, descr AS Label
+            FROM LocationMaster
+            WHERE Domain = @Domain AND IFNULL(location,'') <> ''
+            ORDER BY location
+            """,
+            new List<SugarParameter> { new("@Domain", domain) });
+        return list;
+    }
+}