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

feat(s6): add production instruction mdp consumer

Phase 1A 生产指令中台消费层(实现代码,迁移待正式取号):
- S2 std 扩 6 列(specification/lot_serial/drawing_no/project/work_order_type/labor_variance),
  specification 由 ItemMaster.Descr1 按 (Domain,ItemNum) 中台 enrich(去重取一,实测同键无冲突)
- S6ProductionInstructionService 只读消费 mdp_std_work_order_schedule:page+detail,
  AidopTenantScope.ResolveOrThrow 严格租户,显式 tenant_id 过滤,真分页,不直连源库
- 前端 s6/production-record/productionInstructionList.vue(FQC 风格)+ api 模块,菜单占位改真实页
- 契约测试 8 项
- WIP-s6-prodinstr.sql 为占位迁移(非正式 AutoVersionUpdate 号),commit-2 再取正式号并落库
YY968XX 2 дней назад
Родитель
Сommit
91634d544a

+ 65 - 0
Web/src/views/aidop/s6/api/productionInstruction.ts

@@ -0,0 +1,65 @@
+import service from '/@/utils/request';
+
+export interface Paged<T> {
+	total: number;
+	page: number;
+	pageSize: number;
+	list: T[];
+}
+
+// ── S6 生产指令单(只读,数据中台标准层 mdp_std_work_order_schedule)──
+// 数据源:WorkOrdMaster → mdp_stg_schedule → mdp_std_work_order_schedule(specification 由 ItemMaster.Descr1 中台 enrich)。
+// 前端不直连任何源库,只调用 S6 消费 API。
+export interface ProductionInstructionRow {
+	id?: number;
+	/** 工单编号 work_order */
+	workOrderNo?: string;
+	/** 批次号 lot_serial */
+	lotSerial?: string;
+	/** 物料编码 item_code */
+	itemCode?: string;
+	/** 物料名称 item_name */
+	itemName?: string;
+	/** 规格型号 specification(← ItemMaster.Descr1) */
+	specification?: string;
+	/** 图纸号 drawing_no */
+	drawingNo?: string;
+	/** 项目 project */
+	project?: string;
+	/** 订单数量 qty_ordered */
+	orderQty?: number;
+	/** 报工/完成数量 qty_completed */
+	completedQty?: number;
+	/** 开工日期 order_date */
+	orderDate?: string;
+	/** 计划完成日期 due_date */
+	dueDate?: string;
+	/** 生产状态 status(可信源文本原样透出) */
+	status?: string;
+	/** 工单类型 work_order_type */
+	workOrderType?: string;
+}
+
+export interface ProductionInstructionQuery {
+	page: number;
+	pageSize: number;
+	workOrderNo?: string;
+	lotSerial?: string;
+	itemCode?: string;
+	specification?: string;
+	status?: string;
+	orderDateStart?: string;
+	orderDateEnd?: string;
+}
+
+export function fetchProductionInstructionPage(params: ProductionInstructionQuery) {
+	return service
+		.get<Paged<ProductionInstructionRow>>('/api/S6ProductionInstruction/page', { params })
+		.then((r) => r.data);
+}
+
+export function fetchProductionInstructionDetail(workOrderNo: string) {
+	return service
+		.get<ProductionInstructionRow>('/api/S6ProductionInstruction/detail', { params: { workOrderNo } })
+		.then((r) => r.data);
+}

+ 182 - 0
Web/src/views/aidop/s6/production-record/productionInstructionList.vue

@@ -0,0 +1,182 @@
+<template>
+	<AidopDemoShell title="生产指令单列表" subtitle="数据中台标准层只读">
+		<el-form :inline="true" :model="query" class="mb12" @submit.prevent>
+			<el-form-item label="工单编号">
+				<el-input v-model="query.workOrderNo" clearable placeholder="工单编号" style="width: 200px" @keyup.enter="doSearch" />
+			</el-form-item>
+			<el-form-item label="批次号">
+				<el-input v-model="query.lotSerial" clearable placeholder="批次号" style="width: 180px" @keyup.enter="doSearch" />
+			</el-form-item>
+			<el-form-item label="物料编码">
+				<el-input v-model="query.itemCode" clearable placeholder="物料编码" style="width: 180px" @keyup.enter="doSearch" />
+			</el-form-item>
+			<el-form-item label="生产状态">
+				<el-input v-model="query.status" clearable placeholder="生产状态" style="width: 150px" @keyup.enter="doSearch" />
+			</el-form-item>
+			<el-form-item label="开工日期">
+				<el-date-picker
+					v-model="orderDateRange"
+					type="daterange"
+					value-format="YYYY-MM-DD"
+					range-separator="至"
+					start-placeholder="开始"
+					end-placeholder="结束"
+					style="width: 260px"
+				/>
+			</el-form-item>
+			<el-form-item>
+				<el-button type="primary" @click="doSearch">查询</el-button>
+				<el-button @click="resetQuery">重置</el-button>
+			</el-form-item>
+		</el-form>
+
+		<el-table :data="rows" row-key="id" v-loading="loading" border stripe size="default">
+			<el-table-column type="index" label="#" width="55" align="center" :index="indexMethod" />
+			<el-table-column prop="workOrderNo" label="工单编号" min-width="140" show-overflow-tooltip resizable />
+			<el-table-column prop="lotSerial" label="批次号" min-width="130" show-overflow-tooltip resizable>
+				<template #default="{ row }">{{ row.lotSerial || '—' }}</template>
+			</el-table-column>
+			<el-table-column prop="itemCode" label="物料编码" min-width="130" show-overflow-tooltip resizable />
+			<el-table-column prop="itemName" label="物料名称" min-width="200" show-overflow-tooltip resizable />
+			<el-table-column prop="specification" label="规格型号" min-width="130" show-overflow-tooltip resizable>
+				<template #default="{ row }">{{ row.specification || '—' }}</template>
+			</el-table-column>
+			<el-table-column prop="orderQty" label="订单数量" min-width="110" align="right" resizable>
+				<template #default="{ row }">{{ fmtNum(row.orderQty) }}</template>
+			</el-table-column>
+			<el-table-column prop="completedQty" label="报工数量" min-width="110" align="right" resizable>
+				<template #default="{ row }">{{ fmtNum(row.completedQty) }}</template>
+			</el-table-column>
+			<el-table-column prop="orderDate" label="开工日期" min-width="120" align="center" resizable>
+				<template #default="{ row }">{{ fmtDate(row.orderDate) }}</template>
+			</el-table-column>
+			<el-table-column prop="dueDate" label="计划完成日期" min-width="130" align="center" resizable>
+				<template #default="{ row }">{{ fmtDate(row.dueDate) }}</template>
+			</el-table-column>
+			<el-table-column prop="status" label="生产状态" min-width="110" align="center" resizable>
+				<template #default="{ row }">
+					<el-tag v-if="row.status" type="info" size="small" disable-transitions>{{ row.status }}</el-tag>
+					<span v-else>—</span>
+				</template>
+			</el-table-column>
+			<el-table-column label="操作" width="130" align="center" fixed="right">
+				<template #default="{ row }">
+					<el-tooltip content="过程检验入口将在 Phase 1B 接入检规选择" placement="top">
+						<el-button link type="primary" :disabled="true" @click="onProcessInspect(row)">过程检验</el-button>
+					</el-tooltip>
+				</template>
+			</el-table-column>
+			<template #empty>
+				<el-empty description="暂无数据" />
+			</template>
+		</el-table>
+
+		<div class="pager">
+			<el-pagination
+				v-model:current-page="query.page"
+				v-model:page-size="query.pageSize"
+				:total="total"
+				:page-sizes="[10, 20, 50]"
+				layout="total, sizes, prev, pager, next"
+				@current-change="loadList"
+				@size-change="onSizeChange"
+			/>
+		</div>
+	</AidopDemoShell>
+</template>
+
+<script setup lang="ts">
+import { reactive, ref, computed } from 'vue';
+import { ElMessage } from 'element-plus';
+import AidopDemoShell from '/@/views/aidop/components/AidopDemoShell.vue';
+import { fetchProductionInstructionPage, type ProductionInstructionRow } from '../api/productionInstruction';
+
+const loading = ref(false);
+const rows = ref<ProductionInstructionRow[]>([]);
+const total = ref(0);
+
+const query = reactive({
+	page: 1,
+	pageSize: 10,
+	workOrderNo: '',
+	lotSerial: '',
+	itemCode: '',
+	status: '',
+});
+
+const orderDateRange = ref<[string, string] | null>(null);
+
+const indexMethod = computed(() => (index: number) => (query.page - 1) * query.pageSize + index + 1);
+
+function fmtDate(v?: string) {
+	if (!v) return '—';
+	const d = String(v);
+	return d.length >= 10 ? d.slice(0, 10) : d;
+}
+
+function fmtNum(v?: number) {
+	if (v === null || v === undefined) return '—';
+	return Number(v).toLocaleString('en-US', { maximumFractionDigits: 2 });
+}
+
+async function loadList() {
+	loading.value = true;
+	try {
+		const res = await fetchProductionInstructionPage({
+			page: query.page,
+			pageSize: query.pageSize,
+			workOrderNo: query.workOrderNo?.trim() || undefined,
+			lotSerial: query.lotSerial?.trim() || undefined,
+			itemCode: query.itemCode?.trim() || undefined,
+			status: query.status?.trim() || undefined,
+			orderDateStart: orderDateRange.value?.[0] || undefined,
+			orderDateEnd: orderDateRange.value?.[1] || undefined,
+		});
+		rows.value = res?.list ?? [];
+		total.value = res?.total ?? 0;
+	} catch (e) {
+		rows.value = [];
+		total.value = 0;
+		ElMessage.error('生产指令单列表加载失败');
+	} finally {
+		loading.value = false;
+	}
+}
+
+function doSearch() {
+	query.page = 1;
+	loadList();
+}
+
+function resetQuery() {
+	query.workOrderNo = '';
+	query.lotSerial = '';
+	query.itemCode = '';
+	query.status = '';
+	orderDateRange.value = null;
+	query.page = 1;
+	loadList();
+}
+
+function onSizeChange() {
+	query.page = 1;
+	loadList();
+}
+
+function onProcessInspect(_row: ProductionInstructionRow) {
+	// Phase 1A 不生成检验单,不做写入;入口待 Phase 1B 接入检规选择后开放。
+}
+
+loadList();
+</script>
+
+<style scoped>
+.mb12 {
+	margin-bottom: 12px;
+}
+.pager {
+	margin-top: 12px;
+	display: flex;
+	justify-content: flex-end;
+}
+</style>

+ 43 - 0
server/Admin.NET.Web.Entry/UpdateScripts/WIP-s6-prodinstr.sql

@@ -0,0 +1,43 @@
+-- S6 Phase 1A · 生产指令中台消费层:mdp_std_work_order_schedule 扩列 + 生产指令单列表菜单重指(幂等)
+-- 说明:S2 转换服务用 CREATE TABLE IF NOT EXISTS 建表,不会 ALTER 既有表;此脚本为存量库补列。
+--       新列全 nullable,兼容历史行;重新跑 S2_MDP_SYNC_TRANSFORM 后由转换 SQL 回填。
+
+-- ① mdp_std_work_order_schedule 增列(逐列 information_schema 守卫,幂等)
+SET @tbl := 'mdp_std_work_order_schedule';
+
+SET @col := 'specification';
+SET @exist := (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=@tbl AND COLUMN_NAME=@col);
+SET @sql := IF(@exist=0, 'ALTER TABLE mdp_std_work_order_schedule ADD COLUMN specification VARCHAR(200) NULL AFTER prod_line', 'SELECT 1');
+PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
+SET @col := 'lot_serial';
+SET @exist := (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=@tbl AND COLUMN_NAME=@col);
+SET @sql := IF(@exist=0, 'ALTER TABLE mdp_std_work_order_schedule ADD COLUMN lot_serial VARCHAR(100) NULL AFTER specification', 'SELECT 1');
+PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
+SET @col := 'drawing_no';
+SET @exist := (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=@tbl AND COLUMN_NAME=@col);
+SET @sql := IF(@exist=0, 'ALTER TABLE mdp_std_work_order_schedule ADD COLUMN drawing_no VARCHAR(100) NULL AFTER lot_serial', 'SELECT 1');
+PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
+SET @col := 'project';
+SET @exist := (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=@tbl AND COLUMN_NAME=@col);
+SET @sql := IF(@exist=0, 'ALTER TABLE mdp_std_work_order_schedule ADD COLUMN project VARCHAR(200) NULL AFTER drawing_no', 'SELECT 1');
+PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
+SET @col := 'work_order_type';
+SET @exist := (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=@tbl AND COLUMN_NAME=@col);
+SET @sql := IF(@exist=0, 'ALTER TABLE mdp_std_work_order_schedule ADD COLUMN work_order_type VARCHAR(50) NULL AFTER project', 'SELECT 1');
+PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
+SET @col := 'labor_variance';
+SET @exist := (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=@tbl AND COLUMN_NAME=@col);
+SET @sql := IF(@exist=0, 'ALTER TABLE mdp_std_work_order_schedule ADD COLUMN labor_variance DECIMAL(18,6) NULL AFTER work_order_type', 'SELECT 1');
+PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
+-- ② 生产指令单列表菜单:占位组件改真实页(存量库既有菜单行的 Component 更新;种子仅 INSERT 不 UPDATE 既有行)
+UPDATE sys_menu
+SET Component = '/aidop/s6/production-record/productionInstructionList',
+    Remark = 'S6 生产指令单列表(数据中台标准层只读)'
+WHERE Id = 1329016010001
+  AND Component = '/aidop/planning/index';

+ 117 - 0
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/S6/Manufacturing/S6ProductionInstructionContractTests.cs

@@ -0,0 +1,117 @@
+using Xunit;
+
+namespace Admin.NET.Plugin.AiDOP.Tests.S6.Manufacturing;
+
+/// <summary>
+/// S6 Phase 1A · 生产指令中台消费层契约测试(源码契约断言,无需活库)。
+/// 覆盖:STD 扩列 + specification enrich(Domain+ItemNum)、消费层强租户隔离、只读中台不直连源库、真分页、筛选。
+/// </summary>
+public class S6ProductionInstructionContractTests
+{
+    private static string ReadS2Transform() => File.ReadAllText(FindFile(
+        "server", "Plugins", "Admin.NET.Plugin.AiDOP", "Production", "S2MdpSyncTransformService.cs"));
+
+    private static string ReadService() => File.ReadAllText(FindFile(
+        "server", "Plugins", "Admin.NET.Plugin.AiDOP", "Manufacturing", "S6ProductionInstructionService.cs"));
+
+    [Fact]
+    public void Std_Schedule_HasNewNullableColumns()
+    {
+        var s2 = ReadS2Transform();
+        Assert.Contains("specification VARCHAR(200) NULL", s2);
+        Assert.Contains("lot_serial VARCHAR(100) NULL", s2);
+        Assert.Contains("drawing_no VARCHAR(100) NULL", s2);
+        Assert.Contains("project VARCHAR(200) NULL", s2);
+        Assert.Contains("work_order_type VARCHAR(50) NULL", s2);
+        Assert.Contains("labor_variance DECIMAL(18,6) NULL", s2);
+        // 唯一键不变
+        Assert.Contains("uk_std_work_order_schedule (tenant_id, work_order)", s2);
+    }
+
+    [Fact]
+    public void Specification_EnrichedFromItemMaster_ByDomainAndItemNum_NotItemNumOnly()
+    {
+        var s2 = ReadS2Transform();
+        // specification 来自 ItemMaster.Descr1
+        Assert.Contains("im.Descr1", s2);
+        Assert.Contains("FROM ItemMaster", s2);
+        // 关联必须同时按 Domain + ItemNum(去重取一,实测同键 Descr1 无冲突)
+        Assert.Contains("GROUP BY `Domain`, ItemNum", s2);
+        Assert.Contains("im.`Domain` = JSON_UNQUOTE(JSON_EXTRACT(s.raw_data,'$.Domain'))", s2);
+        Assert.Contains("im.ItemNum = JSON_UNQUOTE(JSON_EXTRACT(s.raw_data,'$.ItemNum'))", s2);
+        // 新列进入 upsert
+        Assert.Contains("specification=VALUES(specification)", s2);
+        Assert.Contains("lot_serial=VALUES(lot_serial)", s2);
+    }
+
+    [Fact]
+    public void EnvelopeSourcedColumns_MappedFromRawData()
+    {
+        var s2 = ReadS2Transform();
+        Assert.Contains("JSON_EXTRACT(s.raw_data,'$.LotSerial')", s2);
+        Assert.Contains("JSON_EXTRACT(s.raw_data,'$.Drawing')", s2);
+        Assert.Contains("JSON_EXTRACT(s.raw_data,'$.Project')", s2);
+        Assert.Contains("JSON_EXTRACT(s.raw_data,'$.WoTyped')", s2);
+        Assert.Contains("JSON_EXTRACT(s.raw_data,'$.LbrVar')", s2);
+    }
+
+    [Fact]
+    public void Consumer_UsesStrictTenantScope_OnEverySql()
+    {
+        var svc = ReadService();
+        Assert.Contains("AidopTenantScope.ResolveOrThrow", svc);
+        Assert.Contains("m.tenant_id = @TenantId", svc);
+        // COUNT / PAGE / DETAIL 都带租户过滤:至少出现 2 处显式 tenant 谓词(page where + detail where)
+        var occurrences = svc.Split("m.tenant_id = @TenantId").Length - 1;
+        Assert.True(occurrences >= 2, $"expected tenant predicate on page+detail, found {occurrences}");
+    }
+
+    [Fact]
+    public void Consumer_ReadsMdpStdOnly_NeverSourceTables()
+    {
+        var svc = ReadService();
+        Assert.Contains("FROM mdp_std_work_order_schedule", svc);
+        Assert.DoesNotContain("WorkOrdMaster", svc);
+        Assert.DoesNotContain("ItemMaster", svc);
+        Assert.DoesNotContain("mes_morder", svc);
+    }
+
+    [Fact]
+    public void Consumer_HasNoTenantFallback_AndNoSelectStar()
+    {
+        var svc = ReadService();
+        Assert.DoesNotContain("tenant_id IS NULL", svc);
+        Assert.DoesNotContain("tenant_id = 0", svc);
+        Assert.DoesNotContain("SELECT *", svc);
+        // 不硬编码工厂
+        Assert.DoesNotContain("factory_id = 1", svc);
+        Assert.DoesNotContain("factory_id = 8010", svc);
+    }
+
+    [Fact]
+    public void Consumer_TruePagination_AndFilters()
+    {
+        var svc = ReadService();
+        Assert.Contains("LIMIT {pageSize} OFFSET {offset}", svc);
+        Assert.Contains("SELECT COUNT(1) FROM mdp_std_work_order_schedule", svc);
+        Assert.Contains("m.work_order LIKE @WorkOrderNo", svc);
+        Assert.Contains("m.lot_serial LIKE @LotSerial", svc);
+        Assert.Contains("m.item_code LIKE @ItemCode", svc);
+        Assert.Contains("m.status = @Status", svc);
+        Assert.Contains("m.order_date >= @OrderDateStart", svc);
+        Assert.Contains("DATE_ADD(@OrderDateEnd, INTERVAL 1 DAY)", svc);
+        Assert.Contains("ORDER BY m.order_date DESC, m.work_order DESC", svc);
+    }
+
+    private static string FindFile(params string[] parts)
+    {
+        var dir = new DirectoryInfo(AppContext.BaseDirectory);
+        while (dir != null)
+        {
+            var candidate = Path.Combine(new[] { dir.FullName }.Concat(parts).ToArray());
+            if (File.Exists(candidate)) return candidate;
+            dir = dir.Parent;
+        }
+        throw new FileNotFoundException(string.Join("/", parts));
+    }
+}

+ 85 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Manufacturing/Dto/S6ProductionInstructionDto.cs

@@ -0,0 +1,85 @@
+namespace Admin.NET.Plugin.AiDOP.Manufacturing.Dto;
+
+/// <summary>
+/// S6 生产指令单列表 查询入参(只读)。
+/// 数据源:数据中台标准层 mdp_std_work_order_schedule(S2 从 WorkOrdMaster 贴源标准化,Specification 由 ItemMaster.Descr1 enrich)。
+/// 租户只来自认证后 JWT,忽略前端传入的任何 tenantId(见 AidopTenantScope.ResolveOrThrow)。
+/// </summary>
+public class S6ProductionInstructionInput
+{
+    /// <summary>页码(从 1 开始)</summary>
+    public int Page { get; set; } = 1;
+
+    /// <summary>每页条数</summary>
+    public int PageSize { get; set; } = 10;
+
+    /// <summary>工单编号(work_order,模糊匹配)</summary>
+    public string? WorkOrderNo { get; set; }
+
+    /// <summary>批次号(lot_serial,模糊匹配)</summary>
+    public string? LotSerial { get; set; }
+
+    /// <summary>物料编码(item_code,模糊匹配)</summary>
+    public string? ItemCode { get; set; }
+
+    /// <summary>规格型号(specification,模糊匹配)</summary>
+    public string? Specification { get; set; }
+
+    /// <summary>生产状态(status,精确匹配)</summary>
+    public string? Status { get; set; }
+
+    /// <summary>开工日期起(order_date &gt;=,含当日)</summary>
+    public string? OrderDateStart { get; set; }
+
+    /// <summary>开工日期止(order_date &lt;=,含当日)</summary>
+    public string? OrderDateEnd { get; set; }
+}
+
+/// <summary>
+/// S6 生产指令单列表 行(只读)。字段对齐前端列 prop(camelCase)。
+/// 全部来自 mdp_std_work_order_schedule;不暴露 tenant_id / sync 元数据 / 内部技术字段。
+/// </summary>
+public class S6ProductionInstructionRow
+{
+    /// <summary>主键 id(mdp_std_work_order_schedule.id,仅作前端 row-key)</summary>
+    public long Id { get; set; }
+
+    /// <summary>工单编号(work_order ← WorkOrdMaster.WorkOrd)</summary>
+    public string? WorkOrderNo { get; set; }
+
+    /// <summary>批次号(lot_serial ← WorkOrdMaster.LotSerial)</summary>
+    public string? LotSerial { get; set; }
+
+    /// <summary>物料编码(item_code ← WorkOrdMaster.ItemNum)</summary>
+    public string? ItemCode { get; set; }
+
+    /// <summary>物料名称(item_name ← WorkOrdMaster.ItemName)</summary>
+    public string? ItemName { get; set; }
+
+    /// <summary>规格型号(specification ← ItemMaster.Descr1,S2 转换层 enrich)</summary>
+    public string? Specification { get; set; }
+
+    /// <summary>图纸号(drawing_no ← WorkOrdMaster.Drawing)</summary>
+    public string? DrawingNo { get; set; }
+
+    /// <summary>项目(project ← WorkOrdMaster.Project)</summary>
+    public string? Project { get; set; }
+
+    /// <summary>订单数量(qty_ordered ← WorkOrdMaster.QtyOrded)</summary>
+    public decimal? OrderQty { get; set; }
+
+    /// <summary>报工/完成数量(qty_completed ← WorkOrdMaster.QtyCompleted)</summary>
+    public decimal? CompletedQty { get; set; }
+
+    /// <summary>开工日期(order_date ← WorkOrdMaster.OrdDate)</summary>
+    public DateTime? OrderDate { get; set; }
+
+    /// <summary>计划完成日期(due_date ← WorkOrdMaster.DueDate)</summary>
+    public DateTime? DueDate { get; set; }
+
+    /// <summary>生产状态(status ← WorkOrdMaster.Status,可信源文本原样透出)</summary>
+    public string? Status { get; set; }
+
+    /// <summary>工单类型(work_order_type ← WorkOrdMaster.WoTyped)</summary>
+    public string? WorkOrderType { get; set; }
+}

+ 154 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Manufacturing/S6ProductionInstructionService.cs

@@ -0,0 +1,154 @@
+using Admin.NET.Plugin.AiDOP.Manufacturing.Dto;
+
+namespace Admin.NET.Plugin.AiDOP.Manufacturing;
+
+/// <summary>
+/// S6 生产指令单 只读消费服务(Phase 1A)。
+///
+/// 数据源:数据中台标准层 mdp_std_work_order_schedule(S2/Production 从 aidopdev.WorkOrdMaster
+/// 贴源 → mdp_stg_schedule → 标准化而来;specification 由 S2 转换层 LEFT JOIN ItemMaster.Descr1 enrich)。
+///
+/// 只读铁律:本服务只 SELECT mdp_std_*,绝不直连 WorkOrdMaster / ItemMaster / mes_morder,绝不回退源库、绝不写库。
+///
+/// 租户隔离(P0):租户只来自认证后 JWT(AidopTenantScope.ResolveOrThrow),忽略前端传入的任何 tenantId;
+/// COUNT / PAGE / DETAIL 三处 SQL 都显式带 tenant_id = @TenantId(mdp_std 实体非 ITenantIdFilter,不依赖框架自动过滤)。
+/// 工厂隔离:当前 S6 体系只可靠解析租户、无稳定工厂作用域 → FACTORY_SCOPE = PARTIAL(tenant-only,不硬编码 factory_id)。
+/// </summary>
+[ApiDescriptionSettings(Order = 305, Description = "生产指令单")]
+[Route("api/S6ProductionInstruction")]
+[NonUnify]
+public class S6ProductionInstructionService : IDynamicApiController, ITransient
+{
+    private readonly ISqlSugarClient _db;
+    private readonly UserManager _userManager;
+
+    public S6ProductionInstructionService(ISqlSugarClient db, UserManager userManager)
+    {
+        _db = db;
+        _userManager = userManager;
+    }
+
+    /// <summary>严格可信租户解析,见 <see cref="Infrastructure.AidopTenantScope.ResolveOrThrow"/>。</summary>
+    private long ResolveTenantOrThrow() => Infrastructure.AidopTenantScope.ResolveOrThrow(_userManager);
+
+    private const string SelectColumns = """
+        m.id                AS Id,
+        m.work_order        AS WorkOrderNo,
+        m.lot_serial        AS LotSerial,
+        m.item_code         AS ItemCode,
+        m.item_name         AS ItemName,
+        m.specification     AS Specification,
+        m.drawing_no        AS DrawingNo,
+        m.project           AS Project,
+        m.qty_ordered       AS OrderQty,
+        m.qty_completed     AS CompletedQty,
+        m.order_date        AS OrderDate,
+        m.due_date          AS DueDate,
+        m.status            AS Status,
+        m.work_order_type   AS WorkOrderType
+        """;
+
+    /// <summary>
+    /// 生产指令单列表(只读真分页)。
+    /// </summary>
+    [DisplayName("生产指令单列表")]
+    [HttpGet("page")]
+    public async Task<object> GetPage([FromQuery] S6ProductionInstructionInput 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> { "1=1" };
+        var pars = new List<SugarParameter>();
+
+        // 强制租户过滤:mdp_std_work_order_schedule 非 ITenantIdFilter,隔离全靠显式 SQL 谓词。
+        var tid = ResolveTenantOrThrow();
+        where.Add("m.tenant_id = @TenantId");
+        pars.Add(new SugarParameter("@TenantId", tid));
+
+        if (!string.IsNullOrWhiteSpace(input.WorkOrderNo))
+        {
+            where.Add("m.work_order LIKE @WorkOrderNo");
+            pars.Add(new SugarParameter("@WorkOrderNo", $"%{input.WorkOrderNo.Trim()}%"));
+        }
+        if (!string.IsNullOrWhiteSpace(input.LotSerial))
+        {
+            where.Add("m.lot_serial LIKE @LotSerial");
+            pars.Add(new SugarParameter("@LotSerial", $"%{input.LotSerial.Trim()}%"));
+        }
+        if (!string.IsNullOrWhiteSpace(input.ItemCode))
+        {
+            where.Add("m.item_code LIKE @ItemCode");
+            pars.Add(new SugarParameter("@ItemCode", $"%{input.ItemCode.Trim()}%"));
+        }
+        if (!string.IsNullOrWhiteSpace(input.Specification))
+        {
+            where.Add("m.specification LIKE @Specification");
+            pars.Add(new SugarParameter("@Specification", $"%{input.Specification.Trim()}%"));
+        }
+        if (!string.IsNullOrWhiteSpace(input.Status))
+        {
+            where.Add("m.status = @Status");
+            pars.Add(new SugarParameter("@Status", input.Status.Trim()));
+        }
+        if (!string.IsNullOrWhiteSpace(input.OrderDateStart))
+        {
+            where.Add("m.order_date >= @OrderDateStart");
+            pars.Add(new SugarParameter("@OrderDateStart", input.OrderDateStart.Trim()));
+        }
+        if (!string.IsNullOrWhiteSpace(input.OrderDateEnd))
+        {
+            // 含止日当天:order_date < 止日 + 1 天
+            where.Add("m.order_date < DATE_ADD(@OrderDateEnd, INTERVAL 1 DAY)");
+            pars.Add(new SugarParameter("@OrderDateEnd", input.OrderDateEnd.Trim()));
+        }
+
+        var whereSql = string.Join(" AND ", where);
+
+        var total = await _db.Ado.GetIntAsync(
+            $"SELECT COUNT(1) FROM mdp_std_work_order_schedule m WHERE {whereSql}", pars);
+
+        var list = total == 0
+            ? new List<S6ProductionInstructionRow>()
+            : await _db.Ado.SqlQueryAsync<S6ProductionInstructionRow>(
+                $"""
+                SELECT
+                {SelectColumns}
+                FROM mdp_std_work_order_schedule m
+                WHERE {whereSql}
+                ORDER BY m.order_date DESC, m.work_order DESC
+                LIMIT {pageSize} OFFSET {offset}
+                """,
+                pars);
+
+        return new { total, page, pageSize, list };
+    }
+
+    /// <summary>
+    /// 生产指令单详情(只读,按工单编号)。查不到 / 跨租户 → 返回 null(不泄漏“无权限”信息)。
+    /// </summary>
+    [DisplayName("生产指令单详情")]
+    [HttpGet("detail")]
+    public async Task<S6ProductionInstructionRow?> GetDetail([FromQuery] string workOrderNo)
+    {
+        if (string.IsNullOrWhiteSpace(workOrderNo)) return null;
+
+        var tid = ResolveTenantOrThrow();
+        var pars = new List<SugarParameter>
+        {
+            new("@TenantId", tid),
+            new("@WorkOrderNo", workOrderNo.Trim())
+        };
+
+        return await _db.Ado.SqlQuerySingleAsync<S6ProductionInstructionRow>(
+            $"""
+            SELECT
+            {SelectColumns}
+            FROM mdp_std_work_order_schedule m
+            WHERE m.tenant_id = @TenantId AND m.work_order = @WorkOrderNo
+            LIMIT 1
+            """,
+            pars);
+    }
+}

+ 42 - 19
server/Plugins/Admin.NET.Plugin.AiDOP/Production/S2MdpSyncTransformService.cs

@@ -262,32 +262,49 @@ public class S2MdpSyncTransformService : ITransient
             INSERT INTO mdp_std_work_order_schedule
             (tenant_id, factory_id, source_system, work_order, sales_order_no, item_code, item_name, site_code, status,
              priority, urgent_flag, qty_ordered, qty_completed, order_date, due_date, release_date, prod_line,
+             specification, lot_serial, drawing_no, project, work_order_type, labor_variance,
              source_biz_key, sync_batch_id, sync_time)
-            SELECT tenant_id,
-                   CASE WHEN factory_id REGEXP '^[0-9]+$' THEN CAST(factory_id AS UNSIGNED) ELSE 1 END,
+            SELECT s.tenant_id,
+                   CASE WHEN s.factory_id REGEXP '^[0-9]+$' THEN CAST(s.factory_id AS UNSIGNED) ELSE 1 END,
                    'AIDOP',
-                   JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.WorkOrd')),
-                   JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.SalesJob')),
-                   JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.ItemNum')),
-                   JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.ItemName')),
-                   JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.Site')),
-                   JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.Status')),
-                   CASE WHEN JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.Priority')) REGEXP '^-?[0-9]+(\\.[0-9]+)?$' THEN CAST(JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.Priority')) AS DECIMAL(18,6)) END,
-                   CASE WHEN JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.Urgent')) IN ('1','true','True') THEN 1 ELSE 0 END,
-                   CASE WHEN JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.QtyOrded')) REGEXP '^-?[0-9]+(\\.[0-9]+)?$' THEN CAST(JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.QtyOrded')) AS DECIMAL(18,6)) ELSE 0 END,
-                   CASE WHEN JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.QtyCompleted')) REGEXP '^-?[0-9]+(\\.[0-9]+)?$' THEN CAST(JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.QtyCompleted')) AS DECIMAL(18,6)) ELSE 0 END,
-                   NULLIF(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.OrdDate')), 'null'), ''),
-                   NULLIF(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.DueDate')), 'null'), ''),
-                   NULLIF(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.ReleaseDate')), 'null'), ''),
-                   JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.ProdLine')),
-                   source_biz_key, @BatchId, @Now
-            FROM mdp_stg_schedule
-            WHERE source_table='WorkOrdMaster' AND IFNULL(JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.WorkOrd')), '') <> ''
+                   JSON_UNQUOTE(JSON_EXTRACT(s.raw_data,'$.WorkOrd')),
+                   JSON_UNQUOTE(JSON_EXTRACT(s.raw_data,'$.SalesJob')),
+                   JSON_UNQUOTE(JSON_EXTRACT(s.raw_data,'$.ItemNum')),
+                   JSON_UNQUOTE(JSON_EXTRACT(s.raw_data,'$.ItemName')),
+                   JSON_UNQUOTE(JSON_EXTRACT(s.raw_data,'$.Site')),
+                   JSON_UNQUOTE(JSON_EXTRACT(s.raw_data,'$.Status')),
+                   CASE WHEN JSON_UNQUOTE(JSON_EXTRACT(s.raw_data,'$.Priority')) REGEXP '^-?[0-9]+(\\.[0-9]+)?$' THEN CAST(JSON_UNQUOTE(JSON_EXTRACT(s.raw_data,'$.Priority')) AS DECIMAL(18,6)) END,
+                   CASE WHEN JSON_UNQUOTE(JSON_EXTRACT(s.raw_data,'$.Urgent')) IN ('1','true','True') THEN 1 ELSE 0 END,
+                   CASE WHEN JSON_UNQUOTE(JSON_EXTRACT(s.raw_data,'$.QtyOrded')) REGEXP '^-?[0-9]+(\\.[0-9]+)?$' THEN CAST(JSON_UNQUOTE(JSON_EXTRACT(s.raw_data,'$.QtyOrded')) AS DECIMAL(18,6)) ELSE 0 END,
+                   CASE WHEN JSON_UNQUOTE(JSON_EXTRACT(s.raw_data,'$.QtyCompleted')) REGEXP '^-?[0-9]+(\\.[0-9]+)?$' THEN CAST(JSON_UNQUOTE(JSON_EXTRACT(s.raw_data,'$.QtyCompleted')) AS DECIMAL(18,6)) ELSE 0 END,
+                   NULLIF(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(s.raw_data,'$.OrdDate')), 'null'), ''),
+                   NULLIF(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(s.raw_data,'$.DueDate')), 'null'), ''),
+                   NULLIF(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(s.raw_data,'$.ReleaseDate')), 'null'), ''),
+                   JSON_UNQUOTE(JSON_EXTRACT(s.raw_data,'$.ProdLine')),
+                   -- Specification(型号)= ItemMaster.Descr1 中台 enrich((Domain,ItemNum) 去重取一,实测同键 Descr1 无冲突;
+                   -- 严格按 Domain+ItemNum 关联,不做 ItemNum-only 去重,避免跨工厂串型号):
+                   im.Descr1,
+                   NULLIF(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(s.raw_data,'$.LotSerial')), 'null'), ''),
+                   NULLIF(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(s.raw_data,'$.Drawing')), 'null'), ''),
+                   NULLIF(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(s.raw_data,'$.Project')), 'null'), ''),
+                   NULLIF(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(s.raw_data,'$.WoTyped')), 'null'), ''),
+                   CASE WHEN JSON_UNQUOTE(JSON_EXTRACT(s.raw_data,'$.LbrVar')) REGEXP '^-?[0-9]+(\\.[0-9]+)?$' THEN CAST(JSON_UNQUOTE(JSON_EXTRACT(s.raw_data,'$.LbrVar')) AS DECIMAL(18,6)) END,
+                   s.source_biz_key, @BatchId, @Now
+            FROM mdp_stg_schedule s
+            LEFT JOIN (
+                SELECT `Domain`, ItemNum, MIN(Descr1) AS Descr1
+                FROM ItemMaster
+                GROUP BY `Domain`, ItemNum
+            ) im ON im.`Domain` = JSON_UNQUOTE(JSON_EXTRACT(s.raw_data,'$.Domain'))
+                AND im.ItemNum = JSON_UNQUOTE(JSON_EXTRACT(s.raw_data,'$.ItemNum'))
+            WHERE s.source_table='WorkOrdMaster' AND IFNULL(JSON_UNQUOTE(JSON_EXTRACT(s.raw_data,'$.WorkOrd')), '') <> ''
             ON DUPLICATE KEY UPDATE
                 sales_order_no=VALUES(sales_order_no), item_code=VALUES(item_code), item_name=VALUES(item_name),
                 site_code=VALUES(site_code), status=VALUES(status), priority=VALUES(priority), urgent_flag=VALUES(urgent_flag),
                 qty_ordered=VALUES(qty_ordered), qty_completed=VALUES(qty_completed), order_date=VALUES(order_date),
                 due_date=VALUES(due_date), release_date=VALUES(release_date), prod_line=VALUES(prod_line),
+                specification=VALUES(specification), lot_serial=VALUES(lot_serial), drawing_no=VALUES(drawing_no),
+                project=VALUES(project), work_order_type=VALUES(work_order_type), labor_variance=VALUES(labor_variance),
                 sync_batch_id=VALUES(sync_batch_id), sync_time=VALUES(sync_time), update_time=CURRENT_TIMESTAMP
             """, batchId, now);
 
@@ -921,6 +938,12 @@ internal static class S2MdpDdl
             due_date DATETIME NULL,
             release_date DATETIME NULL,
             prod_line VARCHAR(100) NULL,
+            specification VARCHAR(200) NULL,
+            lot_serial VARCHAR(100) NULL,
+            drawing_no VARCHAR(100) NULL,
+            project VARCHAR(200) NULL,
+            work_order_type VARCHAR(50) NULL,
+            labor_variance DECIMAL(18,6) NULL,
             source_biz_key VARCHAR(200) NULL,
             sync_batch_id VARCHAR(100) NOT NULL,
             sync_time DATETIME NOT NULL,

+ 1 - 1
server/Plugins/Admin.NET.Plugin.AiDOP/SeedData/SysMenuSeedData.cs

@@ -584,7 +584,7 @@ public class SysMenuSeedData : ISqlSugarEntitySeedData<SysMenu>
         // 生产记录管理 (1322000000019) 下 1 个三级
         const long productionRecordDirId = 1322000000019L;
         const long productionRecordBase = 1329016010000L;
-        yield return new SysMenu { Id = productionRecordBase + 1, Pid = productionRecordDirId, Title = "生产指令单列表", Path = "/aidop/s6/production-record/order-list", Name = "aidopS6ProductionRecordOrderList", Component = placeholderComponent, Icon = "ele-Document", Type = MenuTypeEnum.Menu, CreateTime = ct, OrderNo = 10, Remark = "S6 生产指令单列表(真实页面待交付)" };
+        yield return new SysMenu { Id = productionRecordBase + 1, Pid = productionRecordDirId, Title = "生产指令单列表", Path = "/aidop/s6/production-record/order-list", Name = "aidopS6ProductionRecordOrderList", Component = "/aidop/s6/production-record/productionInstructionList", Icon = "ele-Document", Type = MenuTypeEnum.Menu, CreateTime = ct, OrderNo = 10, Remark = "S6 生产指令单列表(数据中台标准层只读)" };
 
         // 过程质量管理 (1322000000020) 下 1 个三级
         const long processQualityDirId = 1322000000020L;