MES 开发实战
从零构建一套可运行的制造执行系统 · 30 课 + 5 个附录 · .NET 10 + EF Core
源码已跑通

MES 制造执行系统 · 开发实战

这不是一篇概念介绍,而是一条可完整跟做的工程路径。 下面配套的源码已经编译通过、跑在 localhost:5100 上, 72 个接口全部实测过——包括被业务规则拒绝的那些。 你照着 30 课走一遍,手里会多出一套真正能用的 MES: 不只是"能记工单",而是能排产、算料、控质量、看 OEE、追责任。

30
课 · 十个阶段
17
张业务表
72
个 API 接口
16
个控制器
11
个业务服务
技术栈 .NET 10 · ASP.NET Core · EF Core 10 · SQLite 调试界面 Scalar 架构 分层 · Service 承载业务规则 前端 单文件 · 零依赖 · 10 个功能页

这是一条能照着走的施工路径,不是对成品代码的讲解。 全程以 Visual Studio 2026 为主:每课都告诉你起点是什么、 要新建哪些文件、在 VS 里点哪里、做完怎么验证,并附等价的命令行写法。 代码可直接复制,但建议自己敲一遍——每课都能跑起来,通不过就别往下走。 动手前先读 环境、路线与坐标系。

开工

环境、路线与坐标系

先读这页
目标:在敲第一行代码之前,先知道装什么、怎么用这份教程、30 课各自产出什么、最终项目长什么样。 后面每一课都会回到这页的坐标系上。

一 · 你需要装什么

软件用途怎么确认装好了
Visual Studio 2026(社区版即可) 本教程全程以此为主。安装时在「工作负荷」页勾选 「ASP.NET 和 Web 开发」,右侧「安装详细信息」里确保带上了 .NET 10 SDK 启动后能搜到「ASP.NET Core Web API」模板
.NET 10 SDK 编译与运行。VS 2026 的工作负荷里会一并装上,不用单独下 VS 里打开「工具 → 命令行 → 开发人员 PowerShell」,敲 dotnet --version,应输出 10.x
DB Browser for SQLite(可选) 数据库是个文件,用它打开能看到所有表和数据。排查"数据到底写进去没有"时非常好用 能打开 mes.db 并看到表
工作负荷漏勾是新手第一道坎

VS 安装器默认不勾「ASP.NET 和 Web 开发」。漏了它的表现是: 新建项目对话框里搜不到「ASP.NET Core Web API」,只有控制台和类库。

补救:开始菜单搜「Visual Studio Installer」→ 点「修改」→ 勾上工作负荷 → 修改。 不用重装 VS。

二 · 开发方式:VS 为主,命令行为辅

本教程的每一步都优先给 VS 2026 的图形操作路径,同时附上等价的命令行写法。 两条路结果完全一样,走哪条都行——但初学阶段建议先用鼠标点,把「哪个操作对应哪个文件变化」这件事看明白。

本教程的四个固定块
本课施工这一课的起点、要新建的文件、VS 操作要点、做完怎么验
紫色块VS 2026 里的操作步骤(菜单路径、对话框选项、快捷键)
目录树做完这课,你的项目结构变成什么样(绿色是本课新增)
验证一段能实际敲的命令或一次能看到的界面结果,通过才算做完
VS 里 F5 就跑起来了,不用配环境变量

项目模板会生成 Properties/launchSettings.json, 里面已经配好了 http 配置:端口 5100、 环境变量 ASPNETCORE_ENVIRONMENT=Development。 按 F5(或 Ctrl+F5 无调试运行)就会按这份配置启动。

所以本教程后面凡是提到"用命令行启动要显式带 ASPNETCORE_ENVIRONMENT=Development"—— 那是 命令行才有的坑。你在 VS 里点运行按钮,走的是 launchSettings,天然就是开发环境。 这条差异务必分清,能省掉你半小时的困惑。

三 · 30 课产出物总表

一眼看清整条流水线:每课你做出什么、多出哪些文件。想知道"我做到哪了",对着这张表找最后一次成功验证的位置。

阶段课做完你得到新增的主要文件
0 认知 01知道 MES 站在工厂哪一层(无代码)
02工单 7 状态流转图(设计稿)
0317 张表的关系设计(设计稿)
1 地基 04空服务能跑,Scalar 界面能开Program.cs · appsettings.json
05第一个实体 + DbContextEntities/Material.cs · Data/MesDbContext.cs
06数据库文件生成,表建好Migrations/ · mes.db
2 基础数据 07物料能被增删改查Controllers/MaterialsController.cs
08BOM 能逐层展开Entities/BomItem.cs · Controllers/BomController.cs
09工艺路线 + 工作中心Entities/ProcessStep.cs · WorkCenter.cs + 两个控制器
3 执行闭环 10工单能下达、能流转Entities/WorkOrder.cs · Services/WorkOrderService.cs
11下达时把工艺"拍"成工序实例Entities/WorkOrderOperation.cs
12报工带五道校验,报废必须有原因Entities/ProductionReport.cs · Services/ProductionService.cs
13每个动作都留一条追溯记录Entities/WipTrace.cs
14关键工序必须检验才能过Entities/QualityInspection.cs
4 看板界面 15设备台账与点检Entities/Equipment.cs · EquipmentCheck.cs
16一张接口喂饱整个看板Controllers/DashboardController.cs
17能在浏览器里点着操作wwwroot/index.html
5 收尾 18异常统一处理 + 一键盘种子数据Data/SeedData.cs · 中间件
19一套能跑的完整系统(第一段落完结)(部署配置)
6 排产物料 20有限产能顺排,算出每道工序的档期Services/SchedulingService.cs
21派工单 + 工作中心负荷图Entities/OperationAssignment.cs · Controllers/SchedulingController.cs
22MRP 逐层算料,标出缺料Entities/MaterialRequirement.cs · Services/MrpService.cs
7 质量闭环 23不良品从发现到关闭全流程Entities/DefectRecord.cs · Services/DefectService.cs
24SPC 控制图,越界点标红AnalyticsService.SpcAsync
8 设备效率 25OEE 三率 + 损失归因Services/OeeService.cs · Controllers/OeeController.cs
9 治理 26登录、角色、权限拦截Entities/AppUser.cs · Services/AuthService.cs・TokenService.cs・AccessControl.cs
27谁在什么时候改了什么,全留痕Entities/AuditLog.cs · Services/AuditService.cs
28六张报表 + CSV 导出Controllers/ReportsController.cs · AnalyticsService
10 测试交付 29一套点一下就能跑的回归测试test-api.sh · ui-test.js
30生产加固清单 + 上线deploy/mes.service

四 · 终点长什么样

这是跑完 30 课后的完整项目结构。先扫一眼,建立整体印象——不用记住,后面每课会告诉你这时该多出什么。

mes/ ├── Mes.slnx ← 解决方案(VS 2026 的新格式,XML) ├── .gitignore ├── test-api.sh ← 第 29 课 ├── ui-test.js ← 第 29 课 └── Mes.Api/ ├── Mes.Api.csproj ← 项目文件(含 4 个 NuGet 包) ├── Program.cs ← 服务入口,第 4 课 ├── appsettings.json ← 连接串在这里 ├── mes.db ← SQLite 数据库(第 6 课生成) ├── Properties/ │ └── launchSettings.json ← F5 用的启动配置(端口 5100) ├── Entities/ ← 17 个实体,第 5 课起逐个加 ├── Data/ │ ├── MesDbContext.cs ← 第 5 课 │ └── SeedData.cs ← 第 18 课 ├── Dtos/ ← 请求/响应模型 ├── Services/ ← 11 个业务服务,规则都写在这里 ├── Controllers/ ← 16 个控制器,只做转发 ├── Migrations/ ← 第 6 课生成,每次改表都新增一个 └── wwwroot/ └── index.html ← 单文件前端,第 17 课
三条贯穿全程的施工纪律

一、每课都要能跑起来。本教程不攒大招——不存在"写到第 15 课才第一次运行"。 每课结束你都能用浏览器或命令行验证一次,通不过就别往下走。

二、改表必迁移。只要动了 Entities/ 里的类,就必须 Add-Migration + Update-Database。 忘了这步的症状是:程序不报错,但一查数据就抛 no such column。

三、业务规则写 Service,不写 Controller。 Controller 只负责接参数、调 Service、返回结果。这样以后加 WPF 客户端时, 规则不用重写一遍——你的 BS+CS 架构就靠这条撑着。

五 · VS 2026 常用操作速查

后面各课会反复用到这几样。第一次见到时回到这页对照。

要做什么VS 2026 路径等价命令行
新建项目文件 → 新建 → 项目,搜模板名dotnet new webapi -n Mes.Api
新建文件夹右键项目 → 添加 → 新建文件夹mkdir Entities
新建类右键文件夹 → 添加 → 类,填名字手工建 .cs 文件
加 NuGet 包右键项目 → 管理 NuGet 程序包 → 浏览 → 搜 → 安装dotnet add package <名>
批量加包(推荐)工具 → NuGet 包管理器 → 管理解决方案的 NuGet 程序包同上,逐个装
EF Core 迁移工具 → NuGet 包管理器 → 程序包管理器控制台,敲 Add-Migrationdotnet ef migrations add <名>
应用迁移建表同一控制台里 Update-Databasedotnet ef database update
运行F5(带调试)· Ctrl+F5(不带调试,更快)dotnet run
打断点行号左侧灰边点一下出现红点;命中断点后 F10 单步、F5 继续不支持
看报错视图 → 输出(选「调试」)· 异常弹窗点「查看详细信息」控制台输出
看变量断点停住后,鼠标悬停在变量上;或 调试 → 窗口 → 局部变量不支持
热重载调试中改代码,点工具栏的「🔥 热重载」按钮,不用重启dotnet watch run
验证:开工自检

三件事都成立,才算具备了跟做本教程的条件:

  1. VS 里 文件 → 新建 → 项目,搜「ASP.NET Core Web API」能搜到这个模板(搜不到 = 工作负荷没勾)
  2. 工具 → 命令行 → 开发人员 PowerShell 里敲 dotnet --list-sdks,输出里有 10.x
  3. 工具 → NuGet 包管理器 菜单下能看到「程序包管理器控制台」这一项(第 6 课要用)

三条都过了,直接进第 01 课。

主线

从头建一遍 · 全程施工主线

照着做
目标:把整个 MES 从空目录建到上线,拆成 90 个连续动作。 这一页只回答"手该往哪放"——点哪个菜单、填哪个框、建哪个文件、粘哪段代码、什么时候迁移、怎么确认做对了。 "为什么这么做"全部放在 30 课里,这里不重复。

这条主线怎么用

你的处境怎么用这页
第一次做,从零开始 从 S01 顺序走到 S90,一步不要跳。每个动作做完先看「看到」那行,对上了再下一步
卡在某一步 看这一步右上角的课号,点进去读那一课的完整解释和代码全文
只想先跑通前半段 走到 S56 停手——那时你已经有一套完整能用的 MES(第 19 课里程碑)
想推倒重来 整个 Mes 文件夹删掉,回 开工准备 自检一遍,从 S01 重来
主线与 30 课是什么关系

主线 = 手脚。90 个动作,全部是可执行的操作,不带解释。
课程 = 脑子。30 课讲清每个动作为什么这么做、选错了会怎样。

第一次跟做时,以主线为主、课为辅:主线告诉你现在该干什么,卡住了再点课号进去读。 不要一开始就把 30 课通读一遍——读完你还是不知道从哪个菜单下手。

批次 0 · 认知:先读懂模型,再动手

对应 第 01–第 03 课 · 约 60 分钟 · 不产生任何代码 · 里程碑:能说清一张工单的一生、17 张表为什么这么分

S00读完三课认知课,再回主线动手第 01 – 03 课

这三课不写代码,但主线的 S01 到 S90 都建立在这三课的设计结论上。跳过它们的代价是: 写到第 12 课才发现工单状态少了一个、写到第 20 课才发现表该按域拆开。

  1. 读 第 01 课:搞清楚 MES 站在工厂五层架构的第三层,往上接 ERP、往下接设备。 自检:能对着架构图指出「工单从哪来、成品往哪去」
  2. 读 第 02 课:把工单的 7 个状态和它们之间的合法流转画一遍。 自检:合上课本能默画出这张图——第 10、11 课的状态机代码就是它的翻译
  3. 读 第 03 课:记下 17 张表分成哪三个域、每张表的外键指向谁。 自检:随便说一个功能(比如"报工"),能说出它会动哪几张表
看到:三课的自检都能过,就可以按下面的批次顺序动手了。 三课没读完就直接进 S01 也行——但每写到一个实体,都该回来对照第 03 课的表设计

11 个施工批次总览

整条主线分 11 批。每批结束都有一个能实际验证的里程碑,过了才进下一批。

批次名称步骤对应课批次里程碑
1立项S01 – S0704空服务跑起来,/scalar/v1 打得开
2数据层落地S08 – S1805 · 06mes.db 里能看到 Materials 表
3主数据S19 – S3007 · 08 · 09物料 / BOM / 工艺三组接口调通
4执行闭环S31 – S4410–14工单能下达 → 报工 → 检验,全流程走通
5看板与界面S45 – S5015–17浏览器里能点着操作
6收尾上线S51 – S5618 · 19第一段落完结:一套完整系统 + 线上可访问
7排产与物料S57 – S6420–22排产能算档期,MRP 能算缺料
8质量闭环S65 – S6923 · 24不良品全流程 + SPC 控制图
9设备效率S70 – S7225OEE 三率 + 损失归因
10系统治理S73 – S8426–28登录、审计、报表全部可查
11测试与交付S85 – S9029 · 30回归测试全绿 + 生产加固
每个动作只做三件事

① 在 VS 里点(菜单路径都写全了,含快捷键) →  ② 把课里的代码整份复制进新建的文件(每个动作都标了粘哪一段) →  ③ 看一眼「看到」那行,对上了再往下走。

凡是要动数据库的动作,都会单独标一个 迁移 标, 看到这个标就必须跑一次迁移命令,漏了后面必炸。

批次 1 · 立项:从空目录到能跑的空服务

对应 第 04 课 · 约 20 分钟 · 里程碑:浏览器打开 /scalar/v1

S01新建解决方案与项目第 04 课
  1. 启动 VS 2026 → 开始窗口点 创建新项目(已开 VS 则 文件 → 新建 → 项目)
  2. 搜索框输入 web api → 选中 ASP.NET Core Web API(C# / 跨平台,作者 Microsoft)→「下一步」
  3. 「配置新项目」页逐栏填:
    项目名称Mes.Api
    位置一个空的文件夹,例如 D:\Code\mes(不要用中文路径、不要放在桌面)
    解决方案名称Mes
    将解决方案和项目放在同一目录中取消勾选
  4. 「其他信息」页逐项核对:框架 .NET 10.0 (长期支持) · 身份验证类型 无 · 勾选「启用 OpenAPI 支持」· 勾选「使用控制器」
  5. 点「创建」,等右下角 NuGet 还原进度转完
看到:解决方案资源管理器出现 Mes 解决方案 → 下面挂着项目 Mes.Api; 视图 → 输出 末尾是 还原成功
S02核对模板生成的骨架文件第 04 课

新建完立刻核对一遍,这 6 个东西齐了才说明项目建对了:

Mes/ ← 解决方案根,Mes.slnx 在这 └── Mes.Api/ ├── Mes.Api.csproj ├── Program.cs ├── appsettings.json ├── Controllers/WeatherForecastController.cs ├── WeatherForecast.cs └── Properties/launchSettings.json
看到:6 个都在。缺 WeatherForecast.cs 或 Controllers/ = S01 第 4 步漏勾了「使用控制器」,删掉整个项目重来(现在还没写代码,重来成本为零)
S03删掉模板示例第 04 课
  1. 视图 → 解决方案资源管理器(快捷键 Ctrl+Alt+L)
  2. 右键 WeatherForecast.cs → 删除 → 确认框点「确定」
  3. 展开 Controllers → 右键 WeatherForecastController.cs → 删除
看到:Controllers 文件夹从资源管理器里消失了。 这是正常的——VS 不显示空文件夹,第 7 课建第一个控制器时它会自动回来
S04装 3 个 NuGet 包第 04 课
  1. 工具 → NuGet 包管理器 → 管理解决方案的 NuGet 程序包
  2. 切到 「浏览」 选项卡,逐个搜下面三个包
  3. 每个包:搜索结果里选中 → 右侧展开「版本」下拉,选 10.0.x 最新一版 → 勾上项目 Mes.Api → 点「安装」→ 接受许可协议
包名版本用途
Microsoft.EntityFrameworkCore.Sqlite10.0.xEF Core 的 SQLite 驱动
Microsoft.EntityFrameworkCore.Design10.0.x命令行迁移工具用
Scalar.AspNetCore最新稳定版浏览器里的 API 调试界面
看到:双击 Mes.Api.csproj,里面有 3 个 <PackageReference>; 版本号全是 10.0.x 而不是 11.x(装了 11.x 要卸载重装)
S05整份替换 Program.cs第 04 课
  1. 双击 Program.cs,Ctrl+A 全选 → Delete 清空
  2. 打开 第 04 课「第四步 · 改写 Program.cs」,把那段 C# · Program.cs 整份复制过来
  3. Ctrl+S 保存
看到:Scalar.AspNetCore 下有蓝色波浪线 → 这是 Scalar 版本和 .NET 10 不匹配, 回 S04 把 Scalar.AspNetCore 卸了装最新稳定版。只有警告没有红线,可以先继续
S06改 appsettings.json 加连接串第 04 课
  1. 双击 appsettings.json,全选替换成第 04 课那段 JSON · appsettings.json
  2. 保存。这段里只有 ConnectionStrings:Default 是新增的,其余照原样
看到:文件里出现 "Default": "Data Source=mes.db"。 别改成绝对路径——相对路径正是我们要的,数据库文件会落在项目根目录
S07跑起来,验证空服务第 04 课
  1. 确认工具栏中间的运行按钮旁,配置下拉选的是 http(不是 https、不是 IIS Express)
  2. 按 F5。首次运行会问是否信任 HTTPS 开发证书 → 选「是」
  3. 等状态栏显示运行中,浏览器会自己打开
  4. 地址栏手动改成 http://localhost:5100/scalar/v1
看到:Scalar 的 API 文档界面,接口列表是空的——这是对的,第一个接口第 7 课才有。 返回 404 说明在命令行跑且没带环境变量,VS 里按 F5 不会遇到
✅ 批次 1 里程碑达成:空服务能跑

批次 2 · 数据层落地:让实体变成真正的表

对应 第 05 · 第 06 课 · 约 30 分钟 · 里程碑:mes.db 里能看到 Materials 表

S08建 Entities/ 与 Data/ 两个文件夹第 05 课
  1. 右键项目 Mes.Api → 添加 → 新建文件夹 → 输入 Entities → 回车
  2. 同样再建一个 Data
看到:项目下多出两个文件夹图标。别用「新建项目」建——那会建出一个独立类库,本教程全部放在一个项目里
S09新建第一个实体 Material.cs第 05 课
  1. 右键 Entities → 添加 → 类 → 名称填 Material → 「添加」
  2. VS 生成的骨架类里已经有 namespace Mes.Api.Entities;(新版 VS 用文件范围命名空间)
  3. 全选 → 替换成 第 05 课那段 C# · Entities/Material.cs,保存
看到:文件里同时有 class Material 和 enum MaterialType。 枚举值从 1 开始(RawMaterial = 1),不是 0——这不是笔误
S10新建 DbContext第 05 课
  1. 右键 Data → 添加 → 类 → 名称 MesDbContext
  2. 全选 → 替换成第 05 课那段 C# · Data/MesDbContext.cs,保存
看到:类里有 ConfigureConventions(decimal → double 转换)和 OnModelCreating。这两段一个都不能省, 少了 ConfigureConventions,第 16 课看板排序会直接崩
S11把 DbContext 接进 Program.cs第 05 课
  1. 打开 Program.cs,加两行 using: using Mes.Api.Data; 和 using Microsoft.EntityFrameworkCore;
  2. 在 builder.Services.AddOpenApi(); 之后插入 AddDbContext<MesDbContext>(...UseSqlite(...)) 三行(第 05 课标了 ← 的那段)
看到:没有红色波浪线。报 CS0246 找不到类型 = Data/MesDbContext.cs 的命名空间不是 Mes.Api.Data,回去核对拼写
S12编译一次,确认没有数据库文件第 05 课
  1. 生成 → 生成解决方案(Ctrl+Shift+B)
  2. 看 视图 → 输出,下拉选「生成」
看到:已成功生成,0 个错误;项目根目录下没有 mes.db。 有 CS8618 警告说明某个字符串字段漏了 = string.Empty,回去补
S13装 PMC 需要的 Tools 包第 06 课

第 4 课装的 Design 包是给命令行用的;PMC 里的 Add-Migration 要的是另一个包 Tools。 两个包长得像,缺了报错也像,但解法完全不同。

  1. 工具 → NuGet 包管理器 → 程序包管理器控制台,VS 下方弹出 PowerShell 面板
  2. 确认面板顶部的「默认项目」下拉框选的是 Mes.Api(选错会在外面生成迁移文件)
  3. 敲 Install-Package Microsoft.EntityFrameworkCore.Tools 回车
看到:控制台输出 已成功安装。 提示"无法识别 Add-Migration" 就是这步没做
S14生成第一次迁移迁移第 06 课
  1. 仍在 程序包管理器控制台(默认项目 = Mes.Api)
  2. 敲 Add-Migration InitialCreate 回车,等它跑完
看到:项目里多出 Migrations/ 文件夹和 3 个文件 (xxx_InitialCreate.cs · xxx_InitialCreate.Designer.cs · MesDbContextModelSnapshot.cs)。 双击第一个文件,能看到 EF 生成的 CreateTable 建表代码
S15应用到数据库迁移第 06 课
  1. 同一个控制台里敲 Update-Database 回车
看到:输出 Applying migration '...InitialCreate' Done.; 项目根目录出现 mes.db。找不到文件就全盘搜一次 mes.db, 多半是你在解决方案根目录敲了命令,工作目录不一样
S16用 DB Browser 核对表结构第 06 课
  1. 打开 DB Browser for SQLite → 「打开数据库」→ 选 Mes.Api/mes.db
  2. 「数据库结构」标签页看表清单;「浏览数据」标签页看数据
看到:三张表——Materials(你的业务表)、 __EFMigrationsHistory(迁移记录,里面有一条 InitialCreate)。 Materials 的 Code 列上带 UNIQUE 索引
S17让程序启动时自动应用迁移第 06 课
  1. 打开 Program.cs,在 var app = builder.Build(); 之后、app.Run(); 之前
  2. 插入第 06 课那段 using (var scope = ...) { ... db.Database.Migrate(); }
看到:这段现在只有两行是对的。第 06 课说明了第三行 SeedData.Initialize(db); 要等第 18 课才能加——现在加会编译失败
S18建 .gitignore,把该忽略的挡住第 30 课
  1. 右键解决方案 Mes → 添加 → 新建项 → 文本文件,命名 .gitignore
  2. 内容填:bin/ · obj/ · *.db · .vs/
看到:文件出现在解决方案根。 把 *.db 忽略掉是有意的——数据库是运行产物,不进版本库,换台机器跑一次迁移就有了
✅ 批次 2 里程碑达成:实体已变成真正的表

批次 3 · 主数据:物料 / BOM / 工艺

对应 第 07–第 09 课 · 约 50 分钟 · 里程碑:三组主数据接口能在 Scalar 里调通

这一批开始出现"每课都要做"的三个动作

① 建实体 → ② 在 MesDbContext 里加一行 DbSet → ③ 跑一次迁移。 后面每一课新增实体都是这三步,我在主线里会逐步写简, 到第 20 课之后就只写「加实体 + DbSet + 迁移」六个字,你得自己会了。

S19建 Controllers/ 文件夹第 07 课
  1. 右键项目 Mes.Api → 添加 → 新建文件夹 → Controllers
看到:前面删空的 Controllers 回来了
S20写第一个控制器 MaterialsController第 07 课
  1. 右键 Controllers → 添加 → 控制器,选 「API 控制器 - 空」(不要选「使用 Entity Framework 的 API 控制器」——那会生成一堆要删的脚手架)
  2. 名称填 MaterialsController → 「添加」
  3. 全选 → 替换成第 07 课那段 C# · Controllers/MaterialsController.cs,保存
看到:F5 起来后,/scalar/v1 的接口列表里出现 Materials 分组(GET / POST / PUT / DELETE 五六个)
S21新建 BOM 实体迁移第 08 课
  1. 右键 Entities → 添加 → 类 → BomItem,内容照第 08 课的实体小节
  2. 打开 Data/MesDbContext.cs,加一行 public DbSet<BomItem> BomItems => Set<BomItem>();
  3. 把第 08 课 C# · Data/MesDbContext.cs(节选) 里的双重外键配置(两个 HasOne...WithMany + DeleteBehavior.Restrict)放进 OnModelCreating
  4. PMC 里敲 Add-Migration AddBomItem → Update-Database
看到:DB Browser 里多出 BomItems 表, 两条外键的 on delete 是 RESTRICT—— 这是为了防止删父件时把子件数据一起带走
S22写 BomController(含递归展开)第 08 课
  1. 右键 Controllers → 添加 → 控制器 → 「API 控制器 - 空」 → 名称 BomController
  2. 替换成第 08 课 C# · Controllers/BomController.cs(核心片段) + 第二段 BomController.cs(片段),保存
  3. 重新生成(Ctrl+Shift+B),F5 起来在 Scalar 里调一次展开接口
看到:Scalar 里 Bom 分组出现;调展开接口返回树形结构。 返回 500 多半是库里还没有 BOM 数据——第 18 课的种子数据会补上,现在可以手工 POST 几条试
S23新建 WorkCenter 与 ProcessStep 实体迁移第 09 课
  1. 右键 Entities → 添加 → 类 → WorkCenter,粘第 09 课第一段
  2. 同样建 ProcessStep,粘第 09 课第二段
  3. MesDbContext 里加两行 DbSet,并按第 09 课说明配置外键
  4. PMC:Add-Migration AddProcessData → Update-Database
看到:库里多出 WorkCenters、ProcessSteps 两张表
S24写两个控制器第 09 课
  1. 右键 Controllers → 添加 → 控制器(API 控制器 - 空)→ WorkCentersController
  2. 再建一个 ProcessStepsController
  3. 两个都用第 09 课对应的代码填入(结构同 MaterialsController,改实体名即可)
看到:Scalar 里 WorkCenters、ProcessSteps 两个分组出现
S25灌一组演示工艺数据第 09 课
  1. 在 Scalar 里用 POST 接口依次创建:2 个工作中心(如「装配线 A」「机加工 B」)
  2. 再给某个物料建 3 道工序的工艺路线
看到:GET 能查到刚才建的数据。 这批数据现在没大用,但第 11 课下达工单时要靠它把工艺"拍"成工序实例
S26给物料手工造几条数据第 07 课
  1. Scalar 里 POST 两条物料:一条原材料(如 M-001 钢板)、一条成品(如 P-001 电机)
  2. 试一次故意违规:再用同一个 Code 建一条,看返回什么
看到:重复编码返回 400 而不是 500 —— 说明唯一索引真的在数据库层生效了,不是前端糊弄
S27回看一遍你的项目结构开工准备
Mes.Api/ ├── Entities/ Material · BomItem · WorkCenter · ProcessStep ├── Data/ MesDbContext ├── Controllers/ Materials · Bom · WorkCenters · ProcessSteps ├── Dtos/ ├── Migrations/ InitialCreate · AddBomItem · AddProcessData ├── Program.cs ├── appsettings.json └── mes.db
看到:结构对得上就说明前三批没跑偏。 对不上先别往下走——第 4 批开始的所有代码都假设这个骨架是对的
✅ 批次 3 里程碑达成:主数据三件套齐了

批次 4 · 执行闭环:工单 → 报工 → 检验

对应 第 10–第 14 课 · 本教程最核心的一批 · 约 90 分钟 · 里程碑:一张工单能走完整条生命线

S28新建 WorkOrder 与 WorkOrderOperation 实体迁移第 10 课
  1. Entities/ 下建 WorkOrder.cs,粘第 10 课第一段
  2. 再建 WorkOrderOperation.cs,粘第 10 课第二段
  3. MesDbContext 加两个 DbSet + 外键配置
  4. PMC:Add-Migration AddWorkOrder → Update-Database
看到:库里多出 WorkOrders 和 WorkOrderOperations 两张表。 这是"模板 / 实例分离"的落地——ProcessStep 是模板, WorkOrderOperation 是下达时拍出来的实例
S29建 Services/ 文件夹与 WorkOrderService第 10 课
  1. 右键项目 Mes.Api → 添加 → 新建文件夹 → Services
  2. 右键 Services → 添加 → 类 → WorkOrderService
  3. 粘进第 10 课那段 C# · Services/WorkOrderService.cs(片段)(含状态机定义)
看到:编译报错不要慌——这一课只给了片段, 里面引用的 DTO 和数据库方法要在第 11 课补全。如果你想要一次编译通过,直接看 S30 的完整版
S30补全服务并写 WorkOrdersController第 11 课
  1. WorkOrderService.cs 换成第 11 课 C# · Services/WorkOrderService.cs(核心), 它包含「下达 = 状态机校验 + 工艺路线展开」的完整逻辑
  2. 再按第二段 WorkOrderService.cs(片段) 补上通用状态流转
  3. Controllers/ 下建 WorkOrdersController(API 控制器 - 空), 粘第 11 课 Controllers/WorkOrdersController.cs(片段)
  4. 在 Program.cs 注册服务:builder.Services.AddScoped<WorkOrderService>();
看到:Scalar 里 WorkOrders 分组出现,且能查到「允许的状态流转」接口返回值。 报「无法解析服务 WorkOrderService」 = 第 4 步没做
S31新建 ProductionReport 实体迁移第 12 课
  1. 建 Entities/ProductionReport.cs(报工记录,第 12 课开头给的实体)
  2. MesDbContext 加 DbSet + 外键 + decimal→double 自动生效(S10 那段全局约定在起作用)
  3. PMC:Add-Migration AddProductionReport → Update-Database
看到:库里多出 ProductionReports 表
S32写 ProductionService(五道校验)第 12 课
  1. Services/ProductionService.cs,整份粘第 12 课 C# · Services/ProductionService.cs
  2. Controllers/ProductionController.cs(API 控制器 - 空),按第 12 课的接口清单填动作
  3. Program.cs 注册 ProductionService
看到:报工接口能通。这一课的核心是五道校验: 工单状态、工序存在、数量合法、不能超报、报废必须带原因——故意报一次超报,应该被拒
S33新建 WipTrace 实体并接入报工迁移第 13 课
  1. 建 Entities/WipTrace.cs,粘第 13 课第一段
  2. 加 DbSet + 迁移(Add-Migration AddWipTrace → Update-Database)
  3. 回到 ProductionService,在报工成功的地方顺手写一条 WipTrace(第 13 课的核心要求)
  4. WorkOrdersController 加追溯链查询接口,粘第 13 课第二段
看到:报一次工,WipTraces 表就多一行; 调追溯接口能查出这条工单的完整链。追溯记录必须和业务写在同一个事务里——这是第 27 课审计也会复用的原则
S34新建 QualityInspection 实体迁移第 14 课
  1. 建 Entities/QualityInspection.cs,粘第 14 课第一段
  2. 加 DbSet + 迁移(Add-Migration AddQualityInspection → Update-Database)
看到:库里多出 QualityInspections 表
S35给关键工序装上检验闸门第 14 课
  1. 把第 14 课 C# · Services/ProductionService.cs(片段) 合并进你的 ProductionService
  2. 在工艺路线里把某道工序标成关键工序(IsKeyProcess = true)
看到:对关键工序直接报工会被拒绝,提示必须先检验。 这就是 MES 和普通 CRUD 的分水岭——业务规则拦在服务层,不在前端
S36走一遍完整生命线第 12 课

在 Scalar 里按顺序手工走一遍,这是第 4 批的验收动作:

  1. POST 建一张工单(引用 S26 建的那个成品物料)
  2. PUT 把工单下达 → 看 WorkOrderOperations 表是否自动多出几行
  3. 逐道工序 POST 报工 → 看 WorkOrderOperations 的完成数量变化
  4. 对关键工序直接报工 → 应该被拒
  5. POST 一条检验记录 → 再报工 → 这次应该通过
  6. 把工单推到完工 → 查追溯链
看到:六步都符合预期。 有一处不符就先别往下走——第 5 批的看板会把这些数据聚合出来,前面的错会被放大
✅ 批次 4 里程碑达成:执行闭环通了

批次 5 · 看板与前端界面

对应 第 15–第 17 课 · 约 60 分钟 · 里程碑:浏览器里能点着操作

S37新建设备与点检实体迁移第 15 课
  1. 建 Entities/Equipment.cs、Entities/EquipmentCheck.cs,各粘第 15 课对应段落
  2. 加 DbSet + 迁移(Add-Migration AddEquipment → Update-Database)
  3. 建 Controllers/EquipmentsController.cs,粘第 15 课控制器片段
看到:库里多出 Equipments、EquipmentChecks; Scalar 里 Equipments 分组能调通,且开动率接口对零记录的设备不抛除零异常
S38写看板聚合接口第 16 课
  1. Controllers/DashboardController.cs(API 控制器 - 空)
  2. 粘第 16 课 (核心) 和 (片段) 两段
  3. 起服务,调一次总览接口
看到:一个接口返回五组数字(在制工单数、今日产出、合格率等)。 这里就是 S10 那个 decimal 约定要救你的地方——如果当初没加 ConfigureConventions, 带 OrderBy 的统计会在这里抛 SQLite cannot order by expressions of type 'decimal'
S39让 API 托管静态页面第 17 课
  1. 右键项目 Mes.Api → 添加 → 新建文件夹 → 命名 wwwroot
  2. 照第 17 课 C# · Program.cs(片段) 在 Program.cs 里挂上静态文件托管
看到:wwwroot 是 ASP.NET Core 的**约定目录**,不用在代码里写路径。 文件夹名拼错(比如 wwwRoot)页面就是 404,且不报错
S40建单文件前端 index.html第 17 课
  1. wwwroot/ 下新建 index.html
  2. 把第 17 课 JavaScript · wwwroot/index.html(片段) 的核心封装 (api() 请求封装、$() 选择器、esc() 转义)先搭起来
  3. 按第 17 课「界面功能一览」逐个补齐标签页
  4. 起服务,浏览器打开 http://localhost:5100/
看到:浏览器里出现车间操作界面,能从界面上看到 S36 造的工单和数据。 界面白屏:按 F12 开控制台看报错,多半是接口路径写错或 JSON 字段名对不上
S41从浏览器里真实操作一遍第 17 课
  1. 在界面上新建一张工单 → 下达 → 报工 → 看板数字跟着变
  2. 刷新页面,确认数据还在(说明真的进了数据库,不是内存态)
看到:操作流畅、数字正确、刷新不丢。 此时你已经有一个"看得见摸得着"的 MES 了
✅ 批次 5 里程碑达成:界面可用

批次 6 · 收尾上线:第一段落完结

对应 第 18 · 第 19 课 · 约 40 分钟 · 里程碑:一套完整系统 + 线上可访问

S42加全局异常中间件第 18 课
  1. 把第 18 课 C# · Program.cs(片段) 的异常中间件插进 Program.cs, 位置必须在 app.MapControllers() 之前
看到:故意调一个会抛异常的接口,返回的是结构化的 JSON 错误, 不是一堆 HTML 堆栈。顺序放错中间件就不生效,且不会有任何报错提示
S43写 SeedData 并接线第 18 课
  1. 建 Data/SeedData.cs,粘第 18 课 C# · Data/SeedData.cs
  2. 回到 Program.cs,把 S17 那段里预留的第三行补上: SeedData.Initialize(db);
看到:这一行就是第 6 课让你"先留空"的那一行—— 现在 SeedData 类存在了,编译通过
S44删库重跑,验证种子数据第 18 课
  1. 把项目根目录的 mes.db 重命名成 mes.db.old(不要直接删,留个后路)
  2. F5 启动
  3. 用 DB Browser 打开新生成的 mes.db 看数据
看到:所有表自动建好(迁移生效),且物料 / 工艺 / 工单等表里已经有演示数据。 确认无误后再删掉 mes.db.old
S45发布产物第 19 课
  1. 右键项目 Mes.Api → 发布
  2. 目标选「文件夹」→ 路径填一个干净目录(如 D:\Code\mes-publish)
  3. 配置选 Release,目标运行时若服务器已装 .NET 10 运行时则选「框架依赖」
  4. 点「发布」,等完成
看到:发布目录里出现 Mes.Api.dll、appsettings.json、wwwroot/ 等。 看不到 mes.db 是对的——数据库不进发布包
S46部署到服务器第 19 课
  1. 把发布产物传到服务器(第 19 课给了 scp / tar 两种写法)
  2. 装 .NET 10 运行时(若选框架依赖),或直接用自包含产物
  3. 建 systemd 服务单元并 systemctl enable --now
  4. 配 nginx 反代到本机端口
看到:systemctl status 是 active (running); curl 你的域名能拿到首页 HTML
S47处理线上的数据库路径第 19 课
  1. 把连接串从相对路径改成服务器绝对路径(如 /var/lib/mes/mes.db)
  2. 确认运行服务的用户对那个目录有写权限
  3. 重启服务,看日志
看到:服务正常启动,日志里没有 unable to open database file。 报这个错 99% 是权限或目录不存在
✅ 批次 6 里程碑达成:第一段落完结,MES 已上线

批次 7 · 排产与物料

对应 第 20–第 22 课 · 约 60 分钟 · 里程碑:能排档期、能算缺料

从这里开始,实体三步走不再详写

下面的步骤里凡是「加实体 + DbSet + 迁移」,流程就是 建 Entities/Xxx.cs → 在 MesDbContext 加一行 DbSet → PMC 敲 Add-Migration + Update-Database。 你已经做过五遍了。

S48写排产服务第 20 课
  1. Services/SchedulingService.cs,粘第 20 课 (排产部分)
  2. Program.cs 注册该服务
  3. 用第 20 课的 curl 命令触发一次排产
看到:每道工序被算出开始 / 结束时间,且同一工作中心上的工序不重叠—— 这是"有限产能"的意思
S49新建派工实体与控制器迁移第 21 课
  1. 建 Entities/OperationAssignment.cs + 迁移
  2. 把第 21 课 (派工校验片段) 合进 SchedulingService
  3. 建 Controllers/SchedulingController.cs
看到:三条硬校验生效——派工人数不为负、派工不能超工序剩余量、同一人同一时段不重复派
S50新建物料需求实体与 MRP 服务迁移第 22 课
  1. 建 Entities/MaterialRequirement.cs + 迁移
  2. Services/MrpService.cs,粘第 22 课 (递归展开部分)
  3. Controllers/MrpController.cs(API 控制器 - 空)
  4. 注册服务,用第 22 课的 curl 跑一次算料
看到:BOM 逐层展开后,缺料的行被标出来(净需求 > 0)。 第 22 课列了四个坑,逐个对一遍——循环 BOM、损耗率、单位不一致、已有库存抵扣
✅ 批次 7 里程碑达成:排产与算料都通了

批次 8 · 质量闭环

对应 第 23 · 第 24 课 · 约 40 分钟 · 里程碑:不良品全流程 + SPC 控制图

S51新建不良品实体与服务迁移第 23 课
  1. 建 Entities/DefectRecord.cs + 迁移
  2. Services/DefectService.cs,粘第 23 课 (关闭校验)
  3. Controllers/DefectsController.cs,注册服务
看到:登记不良品后关闭时不填根因会被拒(这是硬闸); 每条不良品登记都会在 WipTraces 里留一条(第 13 课的追溯链要求)
S52加 SPC 控制图算法第 24 课
  1. 在 Services/AnalyticsService.cs(没有就新建)里加 第 24 课 (SPC 部分) 的 SpcAsync
  2. 挂一个控制器动作暴露它
  3. 用第 24 课 curl 跑一次
看到:返回每组的均值 / UCL / LCL,越界点被单独标出来 (而不是只给一个"合格率")
✅ 批次 8 里程碑达成:质量闭环完成

批次 9 · 设备效率

对应 第 25 课 · 约 25 分钟 · 里程碑:OEE 三率可查

S53写 OEE 服务与控制器第 25 课
  1. Services/OeeService.cs,整份粘第 25 课代码
  2. Controllers/OeeController.cs,注册服务
  3. 用第 25 课 curl 跑一次
看到:每个设备返回 时间开动率 × 性能开动率 × 合格率 = OEE, 并且带一句损失归因(该先改哪一项)。除零要有防护——零工时的设备不能崩
✅ 批次 9 里程碑达成:OEE 可查

批次 10 · 系统治理:登录 / 审计 / 报表

对应 第 26–第 28 课 · 约 90 分钟 · 里程碑:带权限、可追溯、能出报表

S54新建用户与员工实体迁移第 26 课
  1. 建 Entities/AppUser.cs(用户/账号)、Entities/Employee.cs(员工)
  2. 加 DbSet + 迁移(Add-Migration AddAuth → Update-Database)
  3. 给 AppUser.PasswordHash 加唯一索引
看到:库里多出 AppUsers、Employees。 第 26 课强调的"员工 ≠ 账号"就在这两张表的关系里——一个人可以没有账号,一个账号也只绑一个员工
S55写令牌服务与认证服务第 26 课
  1. Services/TokenService.cs —— 粘第 26 课「令牌」小节代码(HMAC-SHA256 自签)
  2. Services/AuthService.cs —— 粘「密码」小节代码(PBKDF2 加盐哈希)
  3. 两个都在 Program.cs 注册
看到:数据库里绝不出现明文密码。 用 DB Browser 看 AppUsers 表,PasswordHash 是一串 base64
S56写权限校验并全局启用第 26 课
  1. Services/AccessControl.cs —— 粘第 26 课「权限校验」小节代码
  2. Controllers/AuthController.cs —— 登录 / 当前用户 / 登出三个动作
  3. 在需要权限的动作上挂权限检查(第 26 课说明了挂哪些)
  4. 用一个开关控制权限是否强制生效(Mes:EnforcePermission),先留成 false
看到:先用 false 跑通——所有接口照旧能调。 这个开关是故意的:第 29 课的测试脚本要跑两种模式
S57改造前端支持登录第 26 课
  1. 按第 26 课 JavaScript · wwwroot/index.html 那段改前端
  2. 关键:不要在前端写死"后端没开权限"。改成从 /api/auth/me 返回的 enforcePermission 字段动态推导 ENFORCED 变量
  3. 补 restoreSession() 和 renderLoginHint()
看到:把开关改成 true 重启后,前端不用改一行代码, 右上角提示自动变成「未登录 · 只能查看」
S58新建审计日志实体与服务迁移第 27 课
  1. 建 Entities/AuditLog.cs + 迁移
  2. Services/AuditService.cs —— 粘第 27 课代码
  3. Controllers/AuditController.cs,注册服务
  4. 把审计写进各业务服务的同一个事务里(第 27 课的核心设计:审计不单独提交)
看到:做一次业务操作,AuditLogs 表同步多一行。 业务失败了审计也不该有记录——这就是"同事务"的意义,去试一次故意失败的操作用来确认
S59写报表与 CSV 导出第 28 课
  1. 在 AnalyticsService 里补第 28 课三段:日期补齐、单件工时分母、CSV 工具
  2. 建 Controllers/ReportsController.cs,暴露六张报表 + 导出动作
  3. 用第 28 课 curl 下载一次 CSV
看到:用 Excel 打开下载的 CSV,中文不乱码 (因为按第 28 课写了 UTF-8 BOM)、日期没有空洞、分母为零时不出现 NaN
✅ 批次 10 里程碑达成:治理能力齐备

批次 11 · 测试与交付

对应 第 29 · 第 30 课 · 约 60 分钟 · 里程碑:回归测试全绿 + 生产加固

S60建并跑 API 回归测试第 29 课
  1. 在解决方案根目录建 test-api.sh
  2. 整份粘第 29 课的那段脚本(双模式:先探测 enforcePermission 再决定要不要跑鉴权断言)
  3. 确认服务在跑,然后 bash test-api.sh
看到:false 模式 15 通过 + 1 SKIP; 把开关改成 true 重启再跑,是 19 通过。 有 FAIL 就贴出来逐条查——脚本里的断言都是真实业务规则,不是形式主义
S61建并跑前端渲染测试第 29 课
  1. 解决方案根目录建 ui-test.js,粘第 29 课代码
  2. node ui-test.js
看到:52 项全过。它用 jsdom 把 index.html 真跑一遍, 专项检查 XSS 转义和接口字段名是否对得上
S62做上线前必须改的四件事第 30 课
  1. 按第 30 课「上线前必须改的四件事」逐条处理(密钥、CORS、错误详情、日志级别)
  2. 把 Mes:EnforcePermission 改成 true
  3. 重新发布并部署
看到:生产环境打开时,浏览器里看不到 API 文档界面(/scalar/v1 返回 404)。 这是对的,别去"修"它——文档界面本来就不该暴露在生产
S63线上跑一遍完整验收第 30 课
  1. 线上打开首页 → 登录 → 建单 → 下达 → 报工 → 看板
  2. 跑一次 bash test-api.sh,把地址指向线上
  3. 重启一次服务,确认数据还在(验证数据库没被塞在临时目录里)
看到:全流程通过、重启不丢数据。 到这里,30 课全部走完,你手上是一套真能用的 MES。
🎉 主线终点:90 个动作全部完成

主线走完之后

想做的事去哪看
接口怎么调、参数是什么附录 B · API 速查表
某张表有哪些字段附录 C · 数据表结构总览
报错了不知道哪一步错了附录 D · 故障排查手册
想检验自己是否真的懂了附录 E · 自测题(含答案)
接下来还能做什么第 30 课 · 演进路线图
走主线时最容易断的三个地方

一、漏迁移。症状是"程序不报错但一查数据就 no such column"。 主线里凡是标了 迁移 的地方,都必须跑一次 Add-Migration + Update-Database。

二、忘了注册服务。写了 XxxService 却没在 Program.cs 里 AddScoped, 接口一调就报「无法解析服务」。每建一个 Service 就顺手注册一次。

三、跳步。这一步验证没过就往下走, 错误会在两三个批次之后以完全不相干的样子炸出来,那时候排查成本是现在的十倍。

STAGE 0

认知与建模

先搞清楚 MES 在工厂里站在什么位置、工单怎么流转、数据该怎么组织。
这三课不写代码,但决定了后面 27 课的成败——模型设计错了,代码写得再漂亮也是返工。
01

MES 在工厂里的位置

认知
目标:说清 MES 管什么、不管什么,以及它和 ERP、PLC 的分工边界。
本课产出 · 不动手写代码
本课交付一张五层架构图 + MES 十二个功能模块清单(脑子里有图即可,不用画出来)
做完的标准能指出你要做的这套系统站在 L3 执行层——往上是 ERP 的计划,往下是设备的数据
为什么不能跳分不清层,后面必然把不属于 MES 的逻辑(采购、财务、成本核算)写进来,白写几百行
下一步进 第 02 课,把工单的状态流转搞清

五层架构

制造业的信息系统按 ISA-95 标准分五层。MES 卡在正中间第三层,这个位置决定了它的全部特质:

层级系统时间尺度关心什么
L4ERP 企业资源计划天 ~ 月订单、采购、库存、财务
L3MES 制造执行系统秒 ~ 天工单、派工、追溯、质量、设备
L2SCADA 监控与数据采集毫秒 ~ 秒实时监控、报警、组态画面
L1PLC / 传感器毫秒逻辑控制、信号采集
L0物理生产过程实时机床、产线、物料、人员
核心认知:MES 是两个世界的翻译层

往上,它接收 ERP 下来的「这个月要生产 500 台电机」; 往下,它必须回答「今天下午 2 点,3 号工位上的张伟,装配哪 20 台」。 把粗颗粒的计划拆成可执行的动作,再把执行结果回收成数据——这就是 MES 的全部价值。

MES 的十二个功能模块

一个完整的 MES 通常覆盖这些模块。本教程实现的是一套去掉冗余后的核心集(加粗部分):

模块作用本教程
基础数据物料、工艺路线、BOM 主数据✅ 课 7-9
工单管理计划下达、状态流转✅ 课 10-11
派工与报工任务到人、生产回报✅ 课 12
在制品追溯全链路履历✅ 课 13
质量管理检验、不良品、关键工序管控✅ 课 14
设备管理状态、点检、OEE 基础✅ 课 15
报表看板产量、效率、质量聚合✅ 课 16
系统集成对接 ERP / WMS接口预留
生产排程产能分配、排产算法留作扩展
人员绩效工时统计、达成率留作扩展
权限审计角色、操作日志留作扩展
数据采集直连 PLC / SCADA留作扩展
为什么不一次做完 12 个模块

因为 MES 的复杂度不在模块数量,在模块之间的耦合。 工单要引用工艺路线,报工要生成追溯记录,追溯要关联检验结果——先把这条主链打通, 剩下 5 个模块都是往同一套骨架上挂,那时你会写得非常快。

本课任务
  1. 记住五层架构里 MES 的位置(L3),以及它的时间尺度(秒 ~ 天)
  2. 想清楚一个问题:为什么 ERP 不能直接管车间?
    提示:ERP 的时间尺度是天/月,而车间的问题是按分钟爆发的
02

业务主流程与工单状态机

建模
目标:画出工单的完整生命周期,理解为什么状态机是 MES 的业务规则本体。
本课产出 · 不动手写代码
本课交付工单 7 个状态和它们之间的合法流转图
做完的标准合上课本能默画出这张图,并说清每个状态允许转到哪几个状态、不能转到哪几个
为什么不能跳第 10 课的状态机定义、第 11 课的通用流转接口,就是这张图的代码翻译。图不清楚,代码必然写歪
下一步进 第 03 课,把状态机落到表结构上

一张工单的一生

车间里所有动作都围着工单转。它有 7 个状态,其中 5 个是主干流程,2 个是岔路:

草稿 Draft ──下达──▶ 已下达 Released ──首次报工──▶ 生产中 InProgress │ │ │ │ │ ├──完工──▶ 完工 Completed ──▶ 关闭 Closed │ │ │ └──────作废────────────┴────────作废────────────────┘ 暂停 OnHold ⇄ 恢复
状态车间里对应什么能做什么
草稿 Draft计划员还在编辑随便改;可下达、可作废
已下达 Released计划确认,工序已展开可开工、可挂起、可作废
生产中 InProgress已经有人报过工继续报工、完工、挂起、作废
暂停 OnHold等料 / 等设备 / 等通知恢复生产、作废
完工 Completed工序全完,待入库只能关闭
关闭 Closed已入库归档终态,不可再动
作废 Cancelled不做了终态,保留历史
关键设计:状态机不是随便连的

每一条允许的流转背后都有一个现实中的管理动作。比如「完工」之后不允许回退—— 因为货已经入库了,账实必须一致,要改只能走反入库流程。
把这些规则固化成代码里的一个字典,前端就能自动生成按钮, 后端就能在越权操作时给出人话提示。这是 MES 区别于普通 CRUD 系统的第一个特征。

核心代码:状态机定义

用字典描述"从某个状态出发,允许到达哪些状态"。这是整个 MES 里信息密度最高的一段代码:

C# · Services/WorkOrderService.cs
/// <summary>
/// 状态机定义 —— 这张表就是"车间管理规则"的代码化。
///
/// 每一项都对应现实中一个审批动作,不是随便连的:
///   · 草稿能下达,也能直接作废(计划没批下来就不做了)
///   · 已下达可以直接进生产,也可以先挂起(等料)
///   · 完工之后只能关闭,不能回退 —— 已经入库的东西不能说改就改
///   · 关闭和作废是终态,没有出口
/// </summary>
private static readonly Dictionary<WorkOrderStatus, WorkOrderStatus[]> AllowedTransitions = new()
{
    [WorkOrderStatus.Draft]      = [WorkOrderStatus.Released, WorkOrderStatus.Cancelled],
    [WorkOrderStatus.Released]   = [WorkOrderStatus.InProgress, WorkOrderStatus.OnHold, WorkOrderStatus.Cancelled],
    [WorkOrderStatus.InProgress] = [WorkOrderStatus.OnHold, WorkOrderStatus.Completed, WorkOrderStatus.Cancelled],
    [WorkOrderStatus.OnHold]     = [WorkOrderStatus.InProgress, WorkOrderStatus.Cancelled],
    [WorkOrderStatus.Completed]  = [WorkOrderStatus.Closed],
    [WorkOrderStatus.Closed]     = [],
    [WorkOrderStatus.Cancelled]  = []
};

/// <summary>判断一次状态流转是否被允许</summary>
public static bool CanTransit(WorkOrderStatus from, WorkOrderStatus to)
    => AllowedTransitions.TryGetValue(from, out var targets) && targets.Contains(to);

/// <summary>返回从当前状态出发,所有合法的下一步</summary>
public static WorkOrderStatus[] NextStates(WorkOrderStatus from)
    => AllowedTransitions.TryGetValue(from, out var targets) ? targets : [];
为什么用「允许列表」而不是「禁止列表」

允许列表(白名单)默认拒绝一切未声明的流转。将来新增一个状态时, 如果忘了配置它的出口,系统会明确地拒绝——而不是悄悄放行一个不该存在的流转。 这是安全设计的基本原则:默认拒绝,显式允许。

本课任务
  1. 把上面这段状态机字典抄进你的 WorkOrderService
  2. 想一想:如果业务要求支持"完工后允许返工",应该怎么改?
    答案:在 Completed 的允许列表里加上 InProgress。注意只改一处,规则就生效了——这就是集中定义的好处
03

数据模型设计

建模
目标:先设计出支撑核心业务的 12 张表,理清它们的关系。这是整个项目最关键的一步。
说明:这 12 张是"地基"。后面阶段 6–9 会再补 5 张(派工单、不良品、MRP 结果、用户、审计日志),合计 17 张——但都是在理解了这 12 张之后自然长出来的,不是一开始就要画齐。
本课产出 · 不动手写代码
本课交付17 张表的关系设计,按三个域分组
做完的标准随便说一个功能(比如"报工"),能立刻说出它会动哪几张表、外键怎么连
为什么不能跳第 5 课到第 30 课建的每一个实体都来自这张设计图。这里错一张表,后面要有十课跟着返工
下一步回 施工主线,从 S01 开始动手建项目

分三个域组织

不要按"模块"切表,要按数据的生命周期切。基础数据长期不变,执行数据每天猛增,追溯数据只增不改:

域表特征
基础数据域 Material 物料
BomItem 物料清单
ProcessStep 工艺路线
WorkCenter 工作中心
Employee 员工
长期稳定,偶尔维护。
被业务表大量引用,所以几乎不做物理删除
生产执行域 WorkOrder 工单
WorkOrderOperation 工单工序
ProductionReport 报工记录
WipTrace 在制品追溯
数据量最大,写入最频繁。
工单是核心,工序是它的拆分,报工驱动状态
质量设备域 QualityInspection 检验记录
Equipment 设备
EquipmentCheck 点检记录
质量数据是追溯链的重要组成;
设备数据是 OEE 的输入

实体关系

Material (物料) ├─◀ BomItem.ParentMaterialId ┐ ├─◀ BomItem.ChildMaterialId ├─ BOM 自引用:父子都是物料 ├─◀ ProcessStep.MaterialId │ └─◀ WorkOrder.MaterialId ┘ WorkOrder (工单) 1 ──── N WorkOrderOperation (工单工序) ├──── N ProductionReport (报工记录) └──── N QualityInspection (检验记录) WorkOrderOperation ──▶ WorkCenter (工作中心) ──▶ Equipment (设备) ProductionReport ──▶ Employee (员工) QualityInspection ──▶ Employee (检验员) WipTrace ── 独立账本,只记录 ID,不建外键约束
踩坑预警:BOM 两次引用同一张表

BomItem 同时有 ParentMaterialId 和 ChildMaterialId,两者都指向 Material。 如果你不显式告诉 EF Core 哪个是父、哪个是子,它启动时会直接抛 "Unable to determine the relationship" 异常。这个坑在第 8 课会详细处理。

三个贯穿全项目的建模决策

1. 工艺路线是模板,工单工序是实例。 ProcessStep 挂在产品上,改它不影响在产工单; 工单下达时复制一份成为 WorkOrderOperation。 这样避免"改了工艺标准,三个月前的工单全变样"的灾难。

2. 追溯表不建外键。 WipTrace 是一本账本,只追加、不修改。 它不跟随任何主数据的变动而变,所以刻意不建外键约束。

3. 数量字段全用 decimal。 MES 里数量必须精确(不能出现 0.30000000000000004 个零件), decimal 是唯一选择。但它会给 SQLite 带来麻烦——第 5 课解决。

本课任务
  1. 在纸上(或工具里)画出这 12 张表的 ER 图,标出所有一对多关系
  2. 回答:为什么 WorkOrderOperation 不能直接用 ProcessStep 的 Id 代替,非要复制一份?
    提示:工艺标准改了以后,历史工单的追溯记录还能对得上吗?
  3. 检查每个实体的删除行为(Cascade / Restrict / SetNull),说说你的选择理由
阶段 0 完成标志
你能对着上面的 ER 图,说清"一次报工要写哪几张表、影响哪些字段"。说不清就回到课 2 再看一遍状态机。
STAGE 1

项目地基

把项目立起来、数据库连上、跑通第一个接口。
这一阶段的目标是「先让东西跑起来」,架构上的讲究留到后面。
04

建项目与接入 EF Core

地基
目标:从 Visual Studio 里新建一个空项目,装上 EF Core 和调试界面, 按 F5 让它跑起来。这一课结束时你手上是一个能启动、但还没有任何接口的空服务。
本课施工
起点刚装好 VS 2026,硬盘上还没有任何代码
新建项目 Mes.Api + 解决方案 Mes
改写Program.cs(换成最小启动代码)· appsettings.json(加连接串)
删除WeatherForecast.cs · Controllers/WeatherForecastController.cs(模板示例,用不上)
VS 操作新建项目 → 选模板与选项 → 装 3 个包 → F5
做完验证http://localhost:5100/scalar/v1 能打开 API 文档界面

技术选型说明

选择理由生产替换成本
.NET 10 + ASP.NET Core与你的 BS+CS 方向一致;Web API 同时服务前端和 WPF 客户端—
EF Core 10换数据库只改一行 provider,业务代码零改动—
SQLite 起步数据库就是一个文件,零配置、可随处拷贝、随时重来改 1 行 → SQL Server
Scalar 调试界面.NET 10 模板已移除 Swashbuckle,Scalar 是官方推荐搭配不部署到生产即可

第一步 · 新建项目

VS 2026 操作 · 一共 4 个对话框
  1. ① 打开「创建新项目」对话框
    启动 VS 2026,开始窗口左侧点蓝色链接 创建新项目。 VS 已经开着的话,走菜单 文件 → 新建 → 项目,或按 Ctrl+Shift+N。 看到:标题为「创建新项目」的窗口——中间一列模板卡片,顶部一个搜索框 + 三个下拉筛选(语言 / 平台 / 项目类型)
  2. ② 选对模板
    顶部搜索框输入 web api(小写、带空格)。列表里选中 ASP.NET Core Web API。 认准三点:语言列写 C#、平台列写 跨平台(或 Linux/macOS/Windows)、卡片右下角作者是 Microsoft。 选中后点右下角「下一步」。 看到:卡片变蓝框选中,「下一步」按钮从灰变可用 搜不到这个模板 = 装机时漏勾了「ASP.NET 和 Web 开发」工作负荷。回 开工准备 看补救办法,不用重装 VS
  3. ③ 「配置新项目」页 · 四个字段
    这一页只有一个「下一步」,没有多余选项。逐栏对照下表填:
项目名称Mes.Api ← 一字不差
位置一个空文件夹,例如 D:\Code\mes。不要用中文路径、不要放桌面、不要放 C:\Program Files
解决方案名称Mes ← 它默认跟着项目名变成 Mes.Api,手动改掉
将解决方案和项目放在同一目录中取消勾选(勾着会让解决方案文件挤进项目文件夹,以后加 WPF 客户端时结构会乱)
  1. 填完点「下一步」。 看到:对话框底部路径预览里,...\mes\Mes.slnx 和 ...\mes\Mes.Api\ 是两条并列路径
  2. ④ 「其他信息」页 · 六项逐个核对
    这页是所有新手翻车的重灾区,逐项对一遍再点「创建」:
框架.NET 10.0 (长期支持)。下拉里只有 .NET 8/9 = SDK 太旧,先装 .NET 10 SDK
身份验证类型无。第 26 课我们自己写登录;选模板的会灌一堆用不上的身份代码
启用 OpenAPI 支持保持勾选。Scalar 调试界面靠它拿接口描述,取消勾选第 7 课就没界面可调
使用控制器必须勾选!不勾会生成 app.MapGet("/weatherforecast", ...) 这种最小 API 写法,本教程 30 课的代码全部对不上
为 HTTPS 配置保持勾选。不影响使用——第 6 步会说明开发时为什么走 http
启用 Docker取消勾选。本教程不涉及容器
  1. 点「创建」。VS 生成项目并自动还原 NuGet 包,右下角状态栏转圈。 看到:主界面打开,右侧「解决方案资源管理器」出现 Mes 解决方案 → 里面挂着 Mes.Api 项目;底部还原进度条消失后不再出现红字 还原失败(输出窗口红字「无法还原包」)= 网络或 NuGet 源问题。先右键解决方案 → 还原 NuGet 程序包 重试;仍失败就去 工具 → NuGet 包管理器 → 程序包管理器设置 → 程序包源 确认 nuget.org 是启用状态
「使用控制器」漏勾了怎么办

不勾会生成 app.MapGet("/weatherforecast", ...) 这种最小 API 写法, 后面 30 课的代码全都对不上。

补救很简单,重建一个就行——现在还没写任何代码,删掉重来成本为零。 这就是为什么把"选对模板选项"放在第一课: 起步选错,越往后改越贵。

第二步 · 删掉模板自带的示例

模板会生成一个 WeatherForecast 示例。它在 解决方案资源管理器 (右侧那个树,快捷键 Ctrl+Alt+L)里长这样:

Mes.Api/ ├── WeatherForecast.cs ← 删 ├── Controllers/ │ └── WeatherForecastController.cs ← 删 ├── Program.cs ├── appsettings.json └── Properties/ └── launchSettings.json
VS 2026 操作
  1. 展开项目节点:在解决方案资源管理器里点 Mes.Api 左边的小三角。 看到:展开出 Controllers、Properties、 appsettings.json、Program.cs、WeatherForecast.cs —— 一共这几项。没有 WeatherForecast.cs 说明第一步漏勾了「使用控制器」,回上一步
  2. 删第一个文件:右键 WeatherForecast.cs → 删除。 看到:弹出「是否永久删除 WeatherForecast.cs?」确认框
  3. 确认框里看清路径再点「确定」——它显示的应该是 ...\Mes.Api\WeatherForecast.cs,路径对了才点。 VS 的「删除」是扔进回收站,不是永久删除,删错了能捞回来。但别依赖这个,动手前看清路径
  4. 删第二个文件:展开 Controllers → 右键 WeatherForecastController.cs → 删除 → 确认。 看到:Controllers 文件夹跟着消失了——VS 默认不显示空文件夹
文件夹消失了不用管

空文件夹在解决方案资源管理器里不可见,这是 VS 的正常行为,不是删坏了。 第 7 课建第一个控制器时它会自动回来;实在想现在就看到它,点工具栏的「显示所有文件」图标即可。

删完 Controllers 文件夹可能一起消失了(空文件夹 VS 不显示)。 没关系,第 7 课新建控制器时它会自动回来。

第三步 · 装 3 个 NuGet 包

用「管理解决方案的 NuGet 程序包」能一次装完,比逐个右键项目省事——它会在一个界面里 反复切换搜索,不用来回点菜单。

VS 2026 操作
  1. 打开包管理界面:点菜单 工具 → NuGet 包管理器 → 管理解决方案的 NuGet 程序包。 看到:VS 主区域打开一个标签页,左侧四个选项卡「已安装 / 浏览 / 更新 / 整合」, 右侧一列是解决方案里的项目清单(目前只有 Mes.Api)
  2. 切到「浏览」,在搜索框里输入第一个包名(见下表)。 看到:下方列出匹配结果,右边带「版本」下拉和「安装」按钮, 搜索结果顶部还有个「包括预发行版」复选框——保持不勾
  3. 选版本:装之前先点开版本下拉,滚到 10.0.x 里最新的一版选中。 默认会选最新版,可能是 11.x。项目框架是 net10.0,装 11.x 会报兼容性警告甚至编译失败。 EF Core 大版本必须跟着 .NET 主版本走
  4. 安装:在右侧把项目 Mes.Api 前面的复选框勾上 → 点「安装」。 看到:弹出「预览更改」对话框,列出要装的包和它带的依赖; 点「确定」→ 接受许可协议 → 安装完成后「输出」窗口有 已成功安装
  5. 重复第 2–4 步装另外两个包。
包名(搜索框里输这个)版本怎么选作用缺了会怎样
Microsoft.EntityFrameworkCore.Sqlite 10.0.x 最新 EF Core 的 SQLite 驱动 第 5 课 UseSqlite 找不到
Microsoft.EntityFrameworkCore.Design 10.0.x 最新 迁移的设计时工具(给命令行 dotnet ef 用) 命令行敲迁移报「找不到 ef 命令」
Scalar.AspNetCore 最新稳定版即可 浏览器里的 API 调试界面 /scalar/v1 打不开,没法调接口
版本选错的典型症状

装完编译报 NU1107(版本冲突)或 NU1202(包与 net10.0 不兼容),基本都是这一步选了 11.x。

补救:回「管理解决方案的 NuGet 程序包」→ 切到「已安装」选项卡 → 找到那个包 → 右侧选 10.0.x → 点「更新」(它会把降级也当更新处理)。 不用删项目重来。

装完怎么确认
  1. 在解决方案资源管理器里双击 Mes.Api.csproj。 看到:文件以 XML 打开,<ItemGroup> 里有三条 <PackageReference Include="..." Version="10.0.x" />
  2. 看不到变化?在解决方案资源管理器里右键项目 → 重新生成, 或按 Ctrl+Shift+B 刷新一下缓存。

第四步 · 改写 Program.cs

模板生成的 Program.cs 里还留着示例接口的映射。整份替换成下面这样:

C# · Program.cs
using Scalar.AspNetCore;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddControllers();   // 启用控制器(第 7 课才会有第一个控制器)
builder.Services.AddOpenApi();       // 生成 OpenAPI 文档,Scalar 靠它渲染

var app = builder.Build();

if (app.Environment.IsDevelopment())
{
    app.MapOpenApi();              // 文档数据:/openapi/v1.json
    app.MapScalarApiReference();   // 可视化调试界面:/scalar/v1
}

// 开发阶段先关掉 HTTPS 重定向,方便直接用 http 调接口
// app.UseHttpsRedirection();

app.UseAuthorization();
app.MapControllers();

app.Run();
注意这版没有 DbContext

网上很多教程第一步就把 AddDbContext 写进去。但 MesDbContext 这个类下一课才建—— 现在就写,编译直接报 CS0246: 找不到类型 MesDbContext。

本教程的纪律是"每课都能编译通过", 所以这里只挂控制器和文档界面。第 5 课写完 DbContext,再回来加那一行。

第五步 · 改 appsettings.json

把连接串加进去。现在还用不上,但下一课就要读它:

JSON · appsettings.json
{
  "Logging": {
    "LogLevel": { "Default": "Information", "Microsoft.AspNetCore": "Warning" }
  },
  "AllowedHosts": "*",
  "ConnectionStrings": {
    "Default": "Data Source=mes.db"
  }
}

Data Source=mes.db 是个相对路径,数据库文件会生成在 程序的工作目录里。用 VS 跑就是项目根目录(和 .csproj 同级), 第 6 课就能看到它出现。

第六步 · 跑起来

到这里代码已经能编译了,先让它跑起来看效果,再回头讲为什么。

VS 2026 操作
  1. 确认启动配置:看标准工具栏中间那个绿色实心三角(「开始调试」)按钮, 它右边有一个下拉框,里面应该是 http。 看到:下拉里通常有三项——http、https、 可能还有 IIS Express。选 http 选 https 会跳到 https://localhost:7100, 而本教程后面所有 curl 命令都按 http:5100 写,端口对不上会让你怀疑人生
  2. 启动:按 F5(或点绿色三角)。 看到:首次运行弹出「信任 ASP.NET Core HTTPS 开发证书?」→ 点「是」 (本教程走 http,但证书装上没坏处,免得以后又弹)
  3. 等状态栏从「正在生成…」变成运行中,底部出现 Now listening on: http://localhost:5100。 看到:浏览器自动打开,地址是 http://localhost:5100。 多半是 404——因为还没有任何页面和接口,这是对的
  4. 手动访问调试界面:把地址栏改成 http://localhost:5100/scalar/v1 回车。 看到:Scalar 的 API 文档界面——左侧接口列表、右侧详情区。 此时接口列表是空的,这是对的:第一个接口第 7 课才有
  5. 停不下来怎么办:菜单 调试 → 停止调试, 或按 Shift+F5。 直接关浏览器不会停服务。端口 5100 被占用的报错,几乎都是上一次没停干净
F5 和 Ctrl+F5 的区别

F5 带调试器(能打断点,但启动慢一点);Ctrl+F5 不带调试器, 启动快、适合只想看看效果。开发过程中大部分时间用 F5,因为你要打断点。

想在改代码后不重启就看到效果,用热重载: 调试状态下改完代码,点工具栏那个火焰图标(或按 Alt+F10)。 改方法体基本都能生效;改类结构、改方法签名通常要重启。

此刻你的项目

mes/ ├── Mes.slnx ← VS 2026 的解决方案文件(XML 格式,不是老的 .sln) └── Mes.Api/ ├── Mes.Api.csproj ← 三个 PackageReference 在里面 ├── Program.cs ← 刚改写完 ├── appsettings.json ← 刚加了连接串 ├── appsettings.Development.json ├── Mes.Api.http └── Properties/ └── launchSettings.json ← 端口 5100 + Development 环境,F5 读的就是它
本课任务
  1. VS 里新建项目(ASP.NET Core Web API / .NET 10 / 勾使用控制器),解决方案名 Mes
  2. 删除 WeatherForecast.cs 和 Controllers/WeatherForecastController.cs
  3. 装 3 个包:Sqlite · Design · Scalar.AspNetCore
  4. 替换 Program.cs,修改 appsettings.json
  5. 按 F5,确认 /scalar/v1 打得开
验证

跑起来之后,浏览器访问:

http://localhost:5100/scalar/v1

应该看到 Scalar 的 API 文档界面。此时接口列表是空的——这是对的, 第 7 课才会有第一个接口。

只想确认服务活着,也可以直接敲命令行:

等价命令行写法
# 编译:应该 0 错误
dotnet build

# 命令行启动必须显式带环境变量,否则 /scalar/v1 会 404
ASPNETCORE_ENVIRONMENT=Development dotnet run --no-launch-profile --urls "http://localhost:5100"
如果 /scalar/v1 返回 404

在 VS 里按 F5 不会遇到这个问题——launchSettings.json 已经替你设好了 ASPNETCORE_ENVIRONMENT=Development。 这个坑只在命令行出现:

用 dotnet run --no-launch-profile 且不带环境变量时, 环境会回落成 Production,于是 IsDevelopment() 为 false,文档界面压根不挂载。

这不是 bug,是安全设计——生产环境本来就不该暴露 API 文档。 排查顺序:先看 视图 → 输出(下拉选「调试」)里有没有 Now listening on: http://localhost:5100,确认端口没错; 再确认当前环境是不是 Development。

05

第一个实体与 DbContext

地基
目标:建出第一个实体 Material、 第一个 DbContext,把它们接进 Program.cs, 并顺手拆掉 SQLite 的 decimal 陷阱。做完服务仍能启动,但还不会建数据库。
本课施工
起点第 4 课的空服务,能按 F5 打开 /scalar/v1
新建文件夹Entities/ · Data/(右键项目 → 添加 → 新建文件夹)
新建类Entities/Material.cs · Data/MesDbContext.cs
改写Program.cs —— 补上第 4 课故意留空的那两行注册代码
写完验证编译 0 错误;F5 仍能启动;此时还没有 mes.db,这是对的

第一步 · 建文件夹和实体类

VS 2026 操作
  1. 在解决方案资源管理器里右键项目 Mes.Api → 添加 → 新建文件夹,命名 Entities
  2. 同样方式再建一个 Data 文件夹
  3. 右键 Entities 文件夹 → 添加 → 类,名称填 Material,点「添加」

VS 生成的 Material.cs 里已经有 namespace Mes.Api.Entities; (新版 VS 用文件范围命名空间)。把文件内容整体替换成下面这份:

C# · Entities/Material.cs
namespace Mes.Api.Entities;

/// <summary>
/// 物料主数据 —— 原材料、半成品、成品的统一抽象。
///
/// MES 里所有东西都是物料:装配用的 M6 螺丝是物料,最终装出来的电机也是物料。
/// 它们共用一套编码、一套库存口径,区别只在 MaterialType。
/// </summary>
public class Material
{
    public int Id { get; set; }

    /// <summary>
    /// 物料编码,全厂唯一。
    /// 这是车间里人和系统对话的"身份证"——报工时扫的码、领料单上写的号,
    /// 指的都是它。所以它必须有唯一索引兜底,不能靠前端保证。
    /// </summary>
    public string Code { get; set; } = string.Empty;

    public string Name { get; set; } = string.Empty;

    /// <summary>规格型号,例如 "M6x20 不锈钢" / "220V 1.5kW"</summary>
    public string? Spec { get; set; }

    /// <summary>计量单位:个 / 件 / kg / 米</summary>
    public string Unit { get; set; } = "个";

    public MaterialType Type { get; set; }

    /// <summary>
    /// 是否启用。停用的物料不能再被新工单引用,但历史单据必须还能查到它。
    /// 这就是为什么 MES 里几乎不做物理删除。
    /// </summary>
    public bool IsActive { get; set; } = true;

    public DateTime CreatedAt { get; set; } = DateTime.Now;
}

public enum MaterialType
{
    /// <summary>原材料 —— 采购进来的,如钢材、标准件</summary>
    RawMaterial = 1,

    /// <summary>半成品 —— 自制的中间件,既是上道工序产出,又是下道投入</summary>
    SemiFinished = 2,

    /// <summary>成品 —— 可以直接入库发货的</summary>
    Finished = 3
}
三个细节,都是踩过才写的

1 · 枚举从 1 开始,不从 0。 RawMaterial = 1。因为 0 会被 int 默认值占用——将来数据出问题时, 一个"忘了赋值"的实体看起来就像"原材料",排查时非常误导。

2 · 字符串字段给了 = string.Empty 默认值。 项目开了 Nullable,不给默认值编译器会一直警告"可能为 null"。 给个空串比到处写 ! 干净。

3 · Spec 是 string?。 它真的可以为空(不是每个物料都有规格),所以带问号是诚实的。 能空就标能空,别一律用 string 糊过去——这是 Nullable 的正确用法。

第二步 · 建 DbContext

同样方式在 Data 文件夹里新建类 MesDbContext。 内容是我们这一课的核心:

C# · Data/MesDbContext.cs
using Mes.Api.Entities;
using Microsoft.EntityFrameworkCore;

namespace Mes.Api.Data;

public class MesDbContext : DbContext
{
    public MesDbContext(DbContextOptions<MesDbContext> options) : base(options) { }

    // 每个实体对应一个 DbSet —— 它就是代码里操作这张表的入口。
    // 后面每加一张表,这里就多一行。
    public DbSet<Material> Materials => Set<Material>();

    /// <summary>
    /// 全局约定:所有 decimal 一律按 double 存储。
    ///
    /// 为什么必须这么做?SQLite 没有原生 decimal 类型,EF 默认把它存成 TEXT,
    /// 于是任何 OrderBy / Sum / 比较都会在运行时抛异常 —— 看板里的
    /// "按数量排序"会直接崩掉。改成 double 存储后一切正常,
    /// 而 C# 侧仍然用 decimal 接收,对数量这种量级精度完全够。
    ///
    /// 写在这里而不是逐个属性配置,是为了**不漏**。
    /// 将来新增实体带 decimal 字段时自动生效。
    /// </summary>
    protected override void ConfigureConventions(ModelConfigurationBuilder cb)
    {
        cb.Properties<decimal>().HaveConversion<double>();
    }

    protected override void OnModelCreating(ModelBuilder mb)
    {
        mb.Entity<Material>(e =>
        {
            e.Property(x => x.Code).HasMaxLength(50).IsRequired();
            e.Property(x => x.Name).HasMaxLength(100).IsRequired();
            e.Property(x => x.Spec).HasMaxLength(200);
            e.Property(x => x.Unit).HasMaxLength(20).IsRequired();

            // MES 第一条铁律:物料编码全厂唯一。
            // 放在数据库层,因为并发创建时前端校验必然漏网。
            e.HasIndex(x => x.Code).IsUnique();
        });
    }
}
这个坑不解决,看板必崩

SQLite 没有 decimal 类型。EF Core 默认把它映射成 TEXT 存储, 表面上看能用,但一旦你对这些字段做 OrderBy、 Sum 或范围比较,运行时就会抛异常:

SQLite cannot order by expressions of type 'decimal'.

用 ConfigureConventions 全局转换成 double, 从根上避免每个实体都漏配一次。

为什么不等出问题再改?因为它现在还不报错—— 要等到第 16 课做看板排序时才炸。那时候你已经写了 15 课, 却要回头改一个最底层的映射约定。地基上的坑,早填比晚填便宜得多。

第三步 · 接进 Program.cs

第 4 课我们故意没写 AddDbContext,因为那时还没有这个类。 现在可以补上了——打开 Program.cs,加两处:

C# · Program.cs(新增部分用 ← 标出)
using Mes.Api.Data;                  // ← 加:才能引用 MesDbContext
using Microsoft.EntityFrameworkCore; // ← 加:才能用 UseSqlite
using Scalar.AspNetCore;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddControllers();
builder.Services.AddOpenApi();

// ← 加:注册 EF Core。连接串从 appsettings.json 读,不写死在代码里。
// 换数据库只需把 UseSqlite 换成 UseSqlServer,业务代码一行都不用动。
builder.Services.AddDbContext<MesDbContext>(options =>
    options.UseSqlite(builder.Configuration.GetConnectionString("Default")));

var app = builder.Build();

if (app.Environment.IsDevelopment())
{
    app.MapOpenApi();
    app.MapScalarApiReference();
}

app.UseAuthorization();
app.MapControllers();

app.Run();
改了 using 之后可能有红色波浪线

如果 using Microsoft.EntityFrameworkCore; 报红, 多半是包没装成功。回到 工具 → NuGet 包管理器 确认 Microsoft.EntityFrameworkCore.Sqlite 是不是装上了。

另一个常见原因是 MesDbContext 报红—— 检查 Data/MesDbContext.cs 的命名空间是不是 Mes.Api.Data,和 using 对得上。 命名空间拼错是新手最高频的报错,而且报错信息只说"找不到类型", 不会告诉你它其实在另一个命名空间里。

三层职责边界

实体只描述数据形状;DbContext只描述表结构和约束; 业务规则(能不能下达、能不能报工)全部放 Service 层。 守不住这条线,后期会出现"改一个约束要翻五个文件"的局面。

此刻你的项目

Mes.Api/ ├── Entities/ │ └── Material.cs ← 本课新建 ├── Data/ │ └── MesDbContext.cs ← 本课新建 ├── Program.cs ← 补了 AddDbContext ├── appsettings.json └── Properties/ └── launchSettings.json

注意:还没有 mes.db,也没有 Migrations/。 AddDbContext 只是"登记"了这个上下文,并不会去连数据库或者建文件。 真正生成数据库是下一课的事。

本课任务
  1. 建 Entities/ 与 Data/ 两个文件夹(右键项目 → 添加 → 新建文件夹)
  2. 用「添加 → 类」建出 Material.cs,内容照抄上面的代码
  3. 同样建出 MesDbContext.cs,务必带上 ConfigureConventions
  4. 回到 Program.cs,补上两处 using 和 AddDbContext
  5. 编译并启动,确认服务照旧能跑
验证

本课还没有接口可调,能验证的是"没写坏":

VS 里
VS 2026 操作
  1. 按 Ctrl+Shift+B 生成解决方案,看 视图 → 输出, 应该是 已成功生成,0 个错误
  2. 按 F5 启动,确认 /scalar/v1 照旧能打开
  3. 去项目根目录看,应该没有 mes.db——它还没打算建
命令行等价写法
dotnet build          # 期望:0 错误 0 警告
ls Mes.Api/*.db       # 期望:没有输出(数据库文件此时不该存在)
有警告也别放过

如果输出里有 CS8618("不可为 null 的属性必须包含非 null 值"), 说明某个字符串字段忘了给 = string.Empty 默认值。 照着上面的实体逐字段对一遍。

从第一课起就把警告清零,比攒到第 20 课再清理容易得多—— 到那时你分不清哪条警告是自己刚引入的。

06

迁移与建库

地基
目标:把上一课的实体真正变成数据库表——生成 Migrations/、 建出 mes.db,并且在 VS 里亲眼看到表结构。 同时掌握一条会贯穿全部 30 课的纪律:改了实体就必须迁移。
本课施工
起点有 Material 实体和 MesDbContext,但还没有数据库
新增Migrations/ 三个文件 · mes.db 数据库文件
改写Program.cs —— 加"启动时自动应用迁移"
VS 操作程序包管理器控制台(PMC)敲两条命令;或用开发人员 PowerShell 敲 dotnet ef
做完验证用 DB Browser 打开 mes.db,能看到 Materials 表和它上面的唯一索引

两条路,选一条走

EF Core 的迁移命令有两种执行方式,在 VS 里都能用。建议先用路径 A(PMC), 它和 VS 集成得最好;路径 B 是等价的命令行写法,你以后在服务器上部署时会用到。

路径 A · 程序包管理器控制台(推荐)
  1. 点菜单 工具 → NuGet 包管理器 → 程序包管理器控制台, 下方会弹出一个 PowerShell 面板
  2. 面板顶部有个 「默认项目」下拉框,确认它选的是 Mes.Api。 选错了会在错误的地方生成迁移文件
  3. PMC 的迁移命令由另一个包提供,先装它:
    Install-Package Microsoft.EntityFrameworkCore.Tools
  4. 生成迁移(名字叫 InitialCreate,可自取):
    Add-Migration InitialCreate
  5. 应用到数据库:
    Update-Database
路径 B · 开发人员 PowerShell(等价写法)
  1. 点菜单 工具 → 命令行 → 开发人员 PowerShell
  2. 切到项目目录:
    cd Mes.Api
  3. 如果提示"未找到 dotnet ef",装一次全局工具(只需一次):
    dotnet tool install --global dotnet-ef
  4. 生成迁移、应用迁移:
    dotnet ef migrations add InitialCreate
    dotnet ef database update
两个包别搞混:Design 和 Tools

第 4 课装的是 Microsoft.EntityFrameworkCore.Design, 它给命令行的 dotnet ef 用。

而 PMC 里的 Add-Migration 需要的是 Microsoft.EntityFrameworkCore.Tools, 是另一个包——这就是路径 A 第 3 步要单独装它的原因。

症状对照:PMC 里敲 Add-Migration 报"无法识别",就是 Tools 没装;命令行敲 dotnet ef 报找不到, 就是 dotnet-ef 全局工具没装。两个报错长得像,解法完全不同。

命令执行完,多了什么

Mes.Api/ ├── Migrations/ │ ├── 20261015xxxxxx_InitialCreate.cs ← 建表语句(Up/Down 两个方法) │ ├── 20261015xxxxxx_InitialCreate.Designer.cs ← 这次迁移的模型快照 │ └── MesDbContextModelSnapshot.cs ← 当前模型全貌,EF 靠它算差异 ├── mes.db ← SQLite 数据库文件,就是你的"服务器" ├── Entities/ ├── Data/ ├── Program.cs └── appsettings.json

文件名前面的时间戳是自动生成的,用于确定迁移顺序——所以同一个迁移名只能出现一次。

为什么用 Migrations 而不是 EnsureCreated

方式能力适用场景
EnsureCreated() 只建库,之后改实体它不管 临时试验、单元测试
Migrate() 记录每次结构变更,可升级可回滚,支持生产环境平滑演进 真实项目(本教程采用)
本教程最容易被忘掉的一条纪律

只要动了 Entities/ 里的类,就必须走一次 Add-Migration <名字> + Update-Database。

忘了这步的症状非常有欺骗性:程序不报错,编译也是绿的, 但一查数据就抛

SQLite Error 1: 'no such column: x.Name'.

因为它读的是实体定义(有新字段),操作的是数据库(没那列)。 后面每加一张表都会重申这一点,你至少会遇到十几次。

让程序启动时自动应用迁移

开发阶段每次改完都手敲一遍 Update-Database 太啰嗦。 可以在启动时自动做——打开 Program.cs,在 app.Build() 之后、app.Run() 之前插入:

C# · Program.cs(片段)
// ---------- 启动时自动应用迁移 ----------
// 用 Migrate() 而不是 EnsureCreated():前者支持后续 schema 演进,
// 是真实项目该用的方式。
using (var scope = app.Services.CreateScope())
{
    var db = scope.ServiceProvider.GetRequiredService<MesDbContext>();
    db.Database.Migrate();
}
这段代码现在还只有一半,是故意的

最终版本里这段还会有第三行 SeedData.Initialize(db); ——灌演示数据。但 SeedData 类第 18 课才建, 现在写上去会直接编译失败。

先只留 Migrate(), 等第 18 课建完 SeedData,再回来补那一行。 这就是"每课都能编译通过"的具体做法——把未来的依赖先留空。

本课任务
  1. 用路径 A(PMC)跑通 Add-Migration InitialCreate 和 Update-Database
  2. 确认项目里出现了 Migrations/ 文件夹和 mes.db
  3. 打开 Migrations/xxx_InitialCreate.cs,找到 CreateTable,读一遍 EF 生成的建表代码
  4. 在 Program.cs 里加上"启动时自动应用迁移"那段
  5. 用 DB Browser 打开 mes.db,确认看到 Materials 表和唯一索引
验证
在 PMC 里敲(路径 A 用户)
Get-Migration          # 应列出 InitialCreate,说明迁移已生成
Get-DbContext          # 应显示 MesDbContext
命令行等价写法(路径 B 用户)
dotnet ef migrations list      # 应显示 InitialCreate(已应用)
ls Migrations/                 # 应看到 3 个文件
ls mes.db                      # 应看到数据库文件

最直观的验证是用 DB Browser 打开 mes.db:

  • 左边能看到 Materials 表和 __EFMigrationsHistory 表
  • __EFMigrationsHistory 里有一条 InitialCreate 记录—— 这就是"迁移已应用"的证据,EF 靠它判断哪些迁移还没跑过
  • 切到「数据库结构」标签,能看到 Code 列上有个 UNIQUE 索引
如果 mes.db 没出现在你以为的位置

Data Source=mes.db 是相对路径, 它相对于程序运行时的工作目录,不是 .csproj 所在目录。

用 VS F5 跑、或者在 Mes.Api 目录里敲 dotnet ef,两者的工作目录都是项目根,所以都会生成在 Mes.Api/ 下。

如果你在解决方案根目录(也就是 Mes.slnx 那一层) 敲命令,文件就会跑到那里去。找不到就先全局搜一下 mes.db, 别急着重装——第 19 课部署时我们会把它挪到代码目录外面,那是另一个话题。

STAGE 2

基础数据

物料、BOM、工艺路线——这三组数据是车间的"字典"。
它们不常变,但一旦错了,后面所有单据全错。这一阶段你会理解 MES 对主数据的特殊要求。
07

物料主数据 CRUD

基础数据
目标:写出第一个完整的控制器,并理解 MES 里"删除"为什么不是删除。
本课施工
起点第 6 课的 mes.db 里已经有 Materials 表,但还没有任何接口能操作它
新建Controllers/MaterialsController.cs
迁移(无,本课不动实体)
VS 操作右键 Controllers → 添加 → 控制器 → 选 「API 控制器 - 空」 → 名称填 MaterialsController
完成标志Scalar 里出现 Materials 分组,能 POST 建一条、GET 查回来
主线步骤S19 – S20

控制器与服务层的分工

先明确一条纪律:控制器只做三件事——收参数、调服务、返回结果。 物料这种纯主数据没有业务规则,所以直接操作 DbContext 是可以的; 但到了工单、报工,就必须下沉到 Service。

C# · Controllers/MaterialsController.cs
using Mes.Api.Data;
using Mes.Api.Entities;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;

namespace Mes.Api.Controllers;

/// <summary>
/// 物料主数据接口。
///
/// 注意:这里为了先跑通链路,直接拿实体做了入参和出参。
/// 工程化阶段会引入 DTO,把"数据库长什么样"和"接口长什么样"解耦。
/// </summary>
[ApiController]
[Route("api/[controller]")]
public class MaterialsController : ControllerBase
{
    private readonly MesDbContext _db;

    public MaterialsController(MesDbContext db) => _db = db;

    /// <summary>查询物料列表,支持按编码或名称模糊搜索</summary>
    [HttpGet]
    public async Task<ActionResult<List<Material>>> GetAll([FromQuery] string? keyword)
    {
        var query = _db.Materials.AsQueryable();

        if (!string.IsNullOrWhiteSpace(keyword))
        {
            query = query.Where(m => m.Code.Contains(keyword) || m.Name.Contains(keyword));
        }

        return await query.OrderBy(m => m.Code).ToListAsync();
    }

    /// <summary>按 Id 查询单个物料</summary>
    [HttpGet("{id:int}")]
    public async Task<ActionResult<Material>> GetById(int id)
    {
        var material = await _db.Materials.FindAsync(id);
        return material is null
            ? NotFound(new { message = $"物料 {id} 不存在" })
            : material;
    }

    /// <summary>新增物料</summary>
    [HttpPost]
    public async Task<ActionResult<Material>> Create(Material input)
    {
        // 先查一遍是为了给出人话提示;真正兜底的是数据库唯一索引
        if (await _db.Materials.AnyAsync(m => m.Code == input.Code))
        {
            return Conflict(new { message = $"物料编码 {input.Code} 已存在" });
        }

        input.Id = 0;
        input.CreatedAt = DateTime.Now;

        _db.Materials.Add(input);
        await _db.SaveChangesAsync();

        return CreatedAtAction(nameof(GetById), new { id = input.Id }, input);
    }

    /// <summary>修改物料</summary>
    [HttpPut("{id:int}")]
    public async Task<IActionResult> Update(int id, Material input)
    {
        var material = await _db.Materials.FindAsync(id);
        if (material is null)
        {
            return NotFound(new { message = $"物料 {id} 不存在" });
        }

        if (await _db.Materials.AnyAsync(m => m.Code == input.Code && m.Id != id))
        {
            return Conflict(new { message = $"物料编码 {input.Code} 已被其他物料占用" });
        }

        material.Code = input.Code;
        material.Name = input.Name;
        material.Spec = input.Spec;
        material.Unit = input.Unit;
        material.Type = input.Type;
        material.IsActive = input.IsActive;

        await _db.SaveChangesAsync();
        return NoContent();
    }

    /// <summary>停用物料(MES 里不做物理删除)</summary>
    [HttpDelete("{id:int}")]
    public async Task<IActionResult> Delete(int id)
    {
        var material = await _db.Materials.FindAsync(id);
        if (material is null)
        {
            return NotFound(new { message = $"物料 {id} 不存在" });
        }

        // 为什么不真删?—— 三个月前的工单还引用着这个物料,
        // 真删了追溯链就断了。MES 的"删除"永远是停用。
        material.IsActive = false;
        await _db.SaveChangesAsync();
        return NoContent();
    }
}
核心原则:MES 没有物理删除

这不是习惯问题,是被追溯要求逼出来的。
客户问"这批货为什么有问题",你要能查出它用了哪批料、谁做的、什么时候做的。 如果三个月前的某个物料被真删了,整条链就断了。

所以所有主数据表的"删除"操作,实现上都是 IsActive = false。列表查询默认过滤停用项,但历史单据仍能关联到它。

双保险:业务校验 + 数据库约束

AnyAsync 那一层是给用户看的(返回 409 和一句人话), 唯一索引那一层是给数据看的(并发时真正拦截)。两层都要有: 只有前端校验必然漏网,只有数据库约束则用户只能看到一句冷冰冰的 500 错误。

本课任务
  1. 实现 MaterialsController 的五个方法
  2. 用 Scalar 或 curl 依次测试:新增、查询、修改、重复编码、停用
  3. 思考:如果业务要求"已被工单引用的物料不允许停用",这个校验写在哪一层?
验证
BASE=http://localhost:5100

# 新增
curl -s -X POST $BASE/api/materials -H "Content-Type: application/json" \
  -d '{"code":"RM-TEST","name":"测试物料","unit":"个","type":1}'

# 重复编码 —— 应该返回 409
curl -s -w " [HTTP %{http_code}]" -X POST $BASE/api/materials \
  -H "Content-Type: application/json" \
  -d '{"code":"RM-TEST","name":"重复","unit":"个","type":1}'
08

BOM 树形展开

基础数据
目标:实现自引用表的递归展开,并处理循环引用这个必踩的坑。
本课施工
起点物料 CRUD 能跑,但物料之间还没有父子关系
新建Entities/BomItem.cs · Controllers/BomController.cs
改写MesDbContext —— 加 DbSet<BomItem> + 两条外键的 DeleteBehavior.Restrict 配置
迁移Add-Migration AddBomItem → Update-Database
VS 操作两个新文件都用「添加 → 类」和「添加 → 控制器(API 控制器 - 空)」创建
完成标志库里多出 BomItems 表;调展开接口能返回树形结构(先手工 POST 几条 BOM)
主线步骤S21 – S22

BOM 是什么

BOM(Bill of Materials,物料清单)描述"一个产品由哪些零件、按什么用量组成"。 它是自引用表:父件和子件都是 Material,只是站在不同位置。

字段含义为什么需要
ParentMaterialId父件(要装配的产品)—
ChildMaterialId子件(用料)—
Quantity单位用量装 1 台产品需要几个该零件
LossRate损耗率(%)现实中一定有损耗,BOM 不写损耗率,备料就永远不够
Position安装位号装配指导,"左前侧"这种

DbContext 里的双重外键配置

这是第 3 课预告过的坑。BOM 两次引用 Material,必须显式声明谁是父谁是子:

C# · Data/MesDbContext.cs(节选)
mb.Entity<BomItem>(e =>
{
    e.Property(x => x.Position).HasMaxLength(100);

    // BomItem 两次引用 Material —— EF 无法自行判断父子,
    // 必须显式指定外键,否则启动时直接报"关系不明确"。
    e.HasOne(x => x.ParentMaterial)
     .WithMany()
     .HasForeignKey(x => x.ParentMaterialId)
     .OnDelete(DeleteBehavior.Restrict);

    e.HasOne(x => x.ChildMaterial)
     .WithMany()
     .HasForeignKey(x => x.ChildMaterialId)
     .OnDelete(DeleteBehavior.Restrict);

    // 同一父件下不允许重复挂同一子件,否则用量会被重复计算
    e.HasIndex(x => new { x.ParentMaterialId, x.ChildMaterialId }).IsUnique();
});
不配 OnDelete 会怎样

EF Core 看到两条指向同一张表的外键,会尝试猜测级联行为。 猜不出来时启动直接抛异常;猜错了(配成 Cascade)则会出现 "删一个零件,把整棵树连带删掉"的灾难。 所以这里必须显式写 Restrict。

递归展开与循环引用防护

整棵 BOM 树的展开靠递归。但自引用表最危险的是成环—— 一旦 A 的 BOM 里出现 A 自己(数据录错、或经多层绕回来), 没有防护的递归会直接把服务打挂:

C# · Controllers/BomController.cs(核心片段)
/// <summary>
/// 递归展开整棵 BOM 树。
/// 带 visited 集合防止死循环 —— 自引用表配错数据就会成环,
/// 没有这道防护,一次请求就能把服务打挂。
/// </summary>
[HttpGet("{materialId:int}/tree")]
public async Task<ActionResult<BomNodeDto>> GetTree(int materialId)
{
    var root = await _db.Materials.FindAsync(materialId);
    if (root is null)
        return NotFound(new { message = $"物料 {materialId} 不存在" });

    var visited = new HashSet<int> { root.Id };
    var tree = await BuildNodeAsync(root, 1, 0, visited);
    return Ok(tree);
}

private async Task<BomNodeDto> BuildNodeAsync(
    Material material, decimal quantity, decimal lossRate, HashSet<int> visited)
{
    var children = new List<BomNodeDto>();

    var links = await _db.BomItems
        .Include(b => b.ChildMaterial)
        .Where(b => b.ParentMaterialId == material.Id)
        .ToListAsync();

    foreach (var link in links)
    {
        var child = link.ChildMaterial;

        if (visited.Contains(child.Id))
        {
            // 环:只标记,不再往下钻
            children.Add(new BomNodeDto(
                child.Id, child.Code, child.Name, child.Unit,
                TypeText(child.Type), link.Quantity, link.LossRate, link.Position,
                true, []));
            continue;
        }

        visited.Add(child.Id);
        children.Add(await BuildNodeAsync(child, link.Quantity, link.LossRate, visited));
    }

    return new BomNodeDto(
        material.Id, material.Code, material.Name, material.Unit,
        TypeText(material.Type), quantity, lossRate, null, false, children);
}
递归三要素

1. 终止条件:没有子件时自然结束;
2. 状态传递:visited 集合沿调用链传下去,记录已访问节点;
3. 防环标记:遇到已访问节点时返回带 CircularReference=true 的节点, 让前端能提示"这里的数据有问题",而不是静默出错。

含损耗的实际备料量

查询单层子件时,直接算出实际要备多少料,前端不用再算:

C# · BomController.cs(片段)
return Ok(items.Select(b => new
{
    b.Id,
    b.ChildMaterialId,
    childCode = b.ChildMaterial.Code,
    childName = b.ChildMaterial.Name,
    b.Quantity,
    b.LossRate,
    b.Position,
    // 含损耗的实际备料量:单位用量 × (1 + 损耗率/100)
    requiredQty = Math.Round(b.Quantity * (1 + b.LossRate / 100m), 4)
}));
本课任务
  1. 实现 BomItem 实体与 DbContext 配置
  2. 实现树形展开接口,务必加上 visited 防环
  3. 构造一条环状数据(让 A 的 BOM 里出现 A),验证接口不会挂死
  4. 给成品建一棵三层 BOM:成品 → 半成品 → 原材料
验证
# 展开成品电机的完整用料(8 是成品物料 Id)
curl -s http://localhost:5100/api/bom/8/tree

应该看到递归结构:成品 → 轴承/螺栓/壳体(→钢板)/定子(→铜线/钢板)/转子(→钢板/轴承)

09

工艺路线与工作中心

基础数据
目标:定义"产品怎么做"和"在哪里做",为下一课的工单展开做准备。
本课施工
起点还没有"在哪做"和"按什么顺序做"的数据,工单下达时无从展开
新建Entities/WorkCenter.cs · Entities/ProcessStep.cs · Controllers/WorkCentersController.cs · Controllers/ProcessStepsController.cs
改写MesDbContext —— 加两个 DbSet 和对应外键
迁移Add-Migration AddProcessData → Update-Database
VS 操作两个实体用「添加 → 类」,两个控制器用「添加 → 控制器(API 控制器 - 空)」
完成标志库里多出 WorkCenters、ProcessSteps 两张表,Scalar 里两组接口可调
主线步骤S23 – S25

工作中心:MES 的产能基本单位

工作中心是"能干活的地方"——一条产线、一台设备、一个手工工位。 它同时是排程的产能单位、派工的落点、报工的归属。

C# · Entities/WorkCenter.cs
namespace Mes.Api.Entities;

/// <summary>
/// 工作中心 —— "能干活的地方":一条产线、一台设备、一个手工工位。
///
/// 它同时是排程的产能单位、派工的落点、报工的归属。
/// MES 里说"这道工序在哪儿做",指的就是它。
/// </summary>
public class WorkCenter
{
    public int Id { get; set; }
    public string Code { get; set; } = string.Empty;
    public string Name { get; set; } = string.Empty;

    /// <summary>类型:产线 / 单机 / 手工工位</summary>
    public string Type { get; set; } = "产线";

    /// <summary>日可用分钟数,产能测算的基数(默认 480 = 8 小时)</summary>
    public int DailyMinutes { get; set; } = 480;

    public bool IsActive { get; set; } = true;
}

工艺路线:工序模板

C# · Entities/ProcessStep.cs
namespace Mes.Api.Entities;

/// <summary>
/// 工艺路线工序(模板)—— 挂在产品上,描述"这个产品要依次经过哪几道工序"。
///
/// 它只是模板。工单下达时,会按这张模板实例化出 WorkOrderOperation。
/// 这样改工艺路线不会影响已经在生产的工单 —— 这是 MES 的一个关键设计。
/// </summary>
public class ProcessStep
{
    public int Id { get; set; }

    /// <summary>所属产品</summary>
    public int MaterialId { get; set; }
    public Material Material { get; set; } = null!;

    /// <summary>工序序号,用 10 / 20 / 30 这样留间隔,方便以后往中间插工序</summary>
    public int SequenceNo { get; set; }

    public string Name { get; set; } = string.Empty;

    /// <summary>执行该工序的工作中心</summary>
    public int WorkCenterId { get; set; }
    public WorkCenter WorkCenter { get; set; } = null!;

    /// <summary>单件标准工时(分钟),用于产能测算和实际效率对比</summary>
    public decimal StandardMinutes { get; set; }

    /// <summary>是否关键工序 —— 关键工序必须检验合格才能流转</summary>
    public bool IsKeyProcess { get; set; }
}
工序序号为什么用 10 / 20 / 30

这是车间工艺员的实际习惯。留出间隔,将来要在"机加工"和"绕线"之间插一道"热处理", 直接给 25 就行,不用把后面所有工序序号全部重排——重排意味着所有在产工单的工序顺序都可能错乱。

模板与实例的分离(重要)

为什么工单不直接引用 ProcessStep.Id,而要在下达时复制一份?

因为工艺会改。今天工艺科把"机加工"的标准工时从 20 分钟改成 25 分钟, 三个月前那张已经完工的工单,它的效率统计应该按 20 分钟算(当时的标准)。 如果工单直接引用模板,历史数据会被追溯性地改写——这在制造业是严重问题, 因为影响绩效考核和成本核算的准确性。

预置一组演示数据

给成品电机配 5 道工序,这就是后面工单展开的依据:

序号工序工作中心标准工时关键工序
10下料下料区5 分—
20机加工数控加工中心20 分—
30绕线绕线工位15 分—
40总装总装线 A25 分✅
50成品检验检验站10 分✅
本课任务
  1. 实现 WorkCenter、ProcessStep 实体与控制器
  2. 在 DbContext 里配置 (MaterialId, SequenceNo) 唯一索引
  3. 建 5 个工作中心,给成品电机配 5 道工序
  4. 标出哪些是关键工序——想想"为什么成品检验必须关键"
阶段 2 完成标志
curl -s http://localhost:5100/api/work-centers          # 5 个工作中心
curl -s http://localhost:5100/api/process-steps/8       # 成品电机的 5 道工序
curl -s http://localhost:5100/api/bom/8/tree            # 完整 BOM 树
STAGE 3

执行闭环 · 全教程的核心

这是 MES 区别于普通 CRUD 系统的全部原因所在。
工单下达、工序展开、报工驱动状态、追溯成链——这五课的信息密度最高,值得慢下来反复读。
10

工单实体与状态机代码

核心
目标:建立工单实体,把课 2 的状态机变成可执行的代码。
本课施工
起点主数据齐了,可以开始做"单"了。但工序还没实体,工单下不了
新建Entities/WorkOrder.cs · Entities/WorkOrderOperation.cs · Services/WorkOrderService.cs
改写MesDbContext —— 加两个 DbSet + 外键
迁移Add-Migration AddWorkOrder → Update-Database
VS 操作新建 Services/ 文件夹(右键项目 → 添加 → 新建文件夹),服务类用「添加 → 类」
完成标志库里多出 WorkOrders、WorkOrderOperations 两张表
主线步骤S28 – S29

工单实体

C# · Entities/WorkOrder.cs
namespace Mes.Api.Entities;

/// <summary>
/// 工单 —— MES 的核心单据。
///
/// 它做两件事:
///   1. 确定"做什么" —— 哪个产品、做多少、什么时候要
///   2. 承载"怎么做" —— 下达时按工艺路线展开出 N 道工序任务
///
/// 之后所有的派工、报工、检验、追溯,都挂在它的工序上。
/// </summary>
public class WorkOrder
{
    public int Id { get; set; }

    /// <summary>工单号,全厂唯一,如 WO-20261014-0001</summary>
    public string OrderNo { get; set; } = string.Empty;

    /// <summary>要生产的产品</summary>
    public int MaterialId { get; set; }
    public Material Material { get; set; } = null!;

    public decimal PlannedQty { get; set; }        // 计划数量
    public decimal CompletedQty { get; set; }      // 已产出(良品)
    public decimal ScrapQty { get; set; }          // 已报废

    public WorkOrderStatus Status { get; set; } = WorkOrderStatus.Draft;

    /// <summary>优先级,数字越小越急</summary>
    public int Priority { get; set; } = 5;

    /// <summary>来源:ERP 下发 / 手工创建</summary>
    public string Source { get; set; } = "手工创建";

    public DateTime? PlannedStart { get; set; }
    public DateTime? PlannedEnd { get; set; }
    public DateTime? ActualStart { get; set; }     // 首道工序首次报工时写入
    public DateTime? ActualEnd { get; set; }

    public string? Remark { get; set; }
    public DateTime CreatedAt { get; set; } = DateTime.Now;

    /// <summary>按工艺路线展开出来的工序任务</summary>
    public List<WorkOrderOperation> Operations { get; set; } = new();
}

工序实体:任务的最小单元

工单是"要做什么",工序是"具体谁在哪做什么"。注意它的状态是派生的—— 由报工累计数量算出来,不是人工改的:

C# · Entities/WorkOrderOperation.cs(节选)
public class WorkOrderOperation
{
    public int Id { get; set; }

    public int WorkOrderId { get; set; }
    public WorkOrder WorkOrder { get; set; } = null!;

    /// <summary>工序序号,与 ProcessStep 保持一致(10 / 20 / 30 ...)</summary>
    public int SequenceNo { get; set; }

    public string Name { get; set; } = string.Empty;

    public int WorkCenterId { get; set; }
    public WorkCenter WorkCenter { get; set; } = null!;

    public decimal PlannedQty { get; set; }
    public decimal CompletedQty { get; set; }      // 累计合格数
    public decimal ScrapQty { get; set; }          // 累计报废数
    public decimal StandardMinutes { get; set; }

    public bool IsKeyProcess { get; set; }
    public OperationStatus Status { get; set; } = OperationStatus.Pending;

    public DateTime? StartedAt { get; set; }
    public DateTime? FinishedAt { get; set; }

    public List<ProductionReport> Reports { get; set; } = new();
}

/// <summary>
/// 工序状态。注意它是"派生"的 —— 不是人手工改的,
/// 而是报工累计数量算出来的。这一点和工单状态不同。
/// </summary>
public enum OperationStatus
{
    Pending = 0,     // 待开工:一般是因为上一道工序还没完工
    InProgress = 1,  // 进行中:已有报工,但还没做够计划数
    Completed = 2    // 已完工:累计合格数达到计划数
}
工单状态 vs 工序状态:一个是人工决策,一个是自动派生

工单状态(草稿/已下达/暂停…)代表管理决策,必须由人触发, 每一步都要走状态机校验。

工序状态(待开工/进行中/已完工)是报工数据的计算结果。 累计合格数达到计划数,它就是已完工——不需要任何人去点"完成"按钮。

混淆这两者,是新手设计 MES 最常犯的错误。

创建工单

C# · Services/WorkOrderService.cs(片段)
/// <summary>创建工单(初始为草稿)</summary>
public async Task<WorkOrder> CreateAsync(CreateWorkOrderRequest req)
{
    var product = await _db.Materials.FindAsync(req.MaterialId)
        ?? throw new InvalidOperationException($"物料 {req.MaterialId} 不存在");

    // 原材料是买来的,不能作为工单的产出
    if (product.Type == MaterialType.RawMaterial)
        throw new InvalidOperationException($"物料「{product.Name}」是原材料,不能作为工单产出");

    if (req.PlannedQty <= 0)
        throw new InvalidOperationException("计划数量必须大于 0");

    var wo = new WorkOrder
    {
        OrderNo = await NextOrderNoAsync(),
        MaterialId = req.MaterialId,
        PlannedQty = req.PlannedQty,
        Priority = req.Priority,
        PlannedStart = req.PlannedStart,
        PlannedEnd = req.PlannedEnd,
        Remark = req.Remark,
        Source = req.Source,
        Status = WorkOrderStatus.Draft
    };

    _db.WorkOrders.Add(wo);
    await _db.SaveChangesAsync();
    return wo;
}

/// <summary>生成工单号:WO-yyyyMMdd-0001,按天流水</summary>
private async Task<string> NextOrderNoAsync()
{
    var prefix = $"WO-{DateTime.Today:yyyyMMdd}-";

    var last = await _db.WorkOrders
        .Where(w => w.OrderNo.StartsWith(prefix))
        .OrderByDescending(w => w.OrderNo)
        .Select(w => w.OrderNo)
        .FirstOrDefaultAsync();

    var seq = 1;
    if (last is not null && int.TryParse(last[prefix.Length..], out var n))
        seq = n + 1;

    return $"{prefix}{seq:D4}";
}
工单号为什么按天流水

车间里沟通靠嘴:"昨天那张 0003 做得怎么样了"。按天流水让编号短、好念、好找。 如果用一个全局递增号(比如 0001234),三个月后就是六位数,没人记得住。

实现上要注意:必须加唯一索引。 并发创建时两个请求可能算出同一个序号,靠数据库约束拦住, 再捕获异常重试一次即可(本教程简化处理,直接暴露错误)。

本课任务
  1. 实现 WorkOrder 与 WorkOrderOperation 实体
  2. 实现 CreateAsync 和工单号生成器
  3. 加一个校验:产品必须已经配置工艺路线,否则不允许建工单
    (提示:查 ProcessSteps 有没有该产品的记录)
11

工艺路线实例化

核心
目标:实现"下达"这个动作——一次状态流转 + 一次模板复制。
本课施工
起点实体有了,但服务还只是个片段,跑不起来
新建Controllers/WorkOrdersController.cs
改写Services/WorkOrderService.cs —— 补全下达逻辑(状态机校验 + 工艺路线展开)
迁移(无,本课不动实体)
VS 操作控制器用「添加 → 控制器(API 控制器 - 空)」;服务在 Program.cs 里 AddScoped 注册
完成标志下达一张工单后,WorkOrderOperations 表自动多出几行——工艺被"拍"成了工序实例
主线步骤S30

下达 = 状态机校验 + 工艺路线展开

这是 MES 里最有分量的一个方法。它把"模板"变成"任务",之后工序就固化了:

C# · Services/WorkOrderService.cs(核心)
/// <summary>
/// 下达工单 —— 一次状态流转 + 一次工艺路线展开。
///
/// 这是 MES 里最有分量的动作:下达之后工序就固化了。
/// 之后即使工艺路线模板被改动,这张工单也不受影响 ——
/// 因为工单存的是"展开后的快照",不是"指向模板的引用"。
/// 这个设计避免了"改了工艺,历史工单全变样"的灾难。
/// </summary>
public async Task<WorkOrder> ReleaseAsync(int id)
{
    var wo = await _db.WorkOrders
        .Include(w => w.Operations)
        .FirstOrDefaultAsync(w => w.Id == id)
        ?? throw new InvalidOperationException($"工单 {id} 不存在");

    if (!CanTransit(wo.Status, WorkOrderStatus.Released))
        throw new InvalidOperationException(
            $"工单当前为「{StatusText(wo.Status)}」,不允许下达(只能直接作废)");

    if (wo.Operations.Count > 0)
        throw new InvalidOperationException("工单已展开过工序,不能重复下达");

    var steps = await _db.ProcessSteps
        .Where(s => s.MaterialId == wo.MaterialId)
        .OrderBy(s => s.SequenceNo)
        .ToListAsync();

    if (steps.Count == 0)
        throw new InvalidOperationException("该产品没有配置工艺路线,无法下达");

    foreach (var step in steps)
    {
        wo.Operations.Add(new WorkOrderOperation
        {
            SequenceNo = step.SequenceNo,
            Name = step.Name,
            WorkCenterId = step.WorkCenterId,
            PlannedQty = wo.PlannedQty,
            StandardMinutes = step.StandardMinutes,
            IsKeyProcess = step.IsKeyProcess,
            Status = OperationStatus.Pending
        });
    }

    wo.Status = WorkOrderStatus.Released;

    _db.WipTraces.Add(new WipTrace
    {
        WorkOrderId = wo.Id,
        Action = "下达",
        Qty = wo.PlannedQty,
        Detail = $"展开 {steps.Count} 道工序:" + string.Join(" → ", steps.Select(s => s.Name)),
        HappenedAt = DateTime.Now
    });

    await _db.SaveChangesAsync();
    return wo;
}
四道防线,缺一不可

① 状态校验:只有草稿能下达(走状态机);
② 幂等校验:已有工序则拒绝,防止重复点按钮造成工序翻倍;
③ 前置校验:没有工艺路线就不能下达——这是最常见的实施事故;
④ 追溯留痕:下达这个动作本身也要记录,它属于工单履历的一部分。

通用状态流转

除了下达,其余的状态变化(暂停、恢复、关闭、作废)走同一个入口:

C# · Services/WorkOrderService.cs(片段)
/// <summary>通用状态流转:暂停 / 恢复 / 关闭 / 作废</summary>
public async Task<WorkOrder> TransitAsync(int id, WorkOrderStatus target, string? remark = null)
{
    var wo = await _db.WorkOrders.FindAsync(id)
        ?? throw new InvalidOperationException($"工单 {id} 不存在");

    if (!CanTransit(wo.Status, target))
    {
        // 错误信息里带上"当前允许流转到哪些状态",
        // 用户看提示就知道下一步该干什么,而不是只看到一句"操作失败"
        var allowed = NextStates(wo.Status);
        var hint = allowed.Length == 0
            ? "它是终态,不能再流转"
            : "当前允许流转到:" + string.Join(" / ", allowed.Select(StatusText));

        throw new InvalidOperationException(
            $"不允许从「{StatusText(wo.Status)}」直接跳到「{StatusText(target)}」。{hint}");
    }

    var from = wo.Status;
    wo.Status = target;

    if (target is WorkOrderStatus.Closed)
        wo.ActualEnd ??= DateTime.Now;

    _db.WipTraces.Add(new WipTrace
    {
        WorkOrderId = wo.Id,
        Action = StatusText(target),
        Qty = wo.PlannedQty,
        Detail = $"由「{StatusText(from)}」流转而来" + (remark is null ? "" : $":{remark}"),
        HappenedAt = DateTime.Now
    });

    await _db.SaveChangesAsync();
    return wo;
}

把合法流转告诉前端

加一个查询接口,前端就能自动生成操作按钮,不用在页面里硬编码状态判断:

C# · Controllers/WorkOrdersController.cs(片段)
/// <summary>查询当前工单可以流转到哪些状态(前端据此生成按钮)</summary>
[HttpGet("{id:int}/next-states")]
public async Task<ActionResult> NextStates(int id)
{
    var wo = await _db.WorkOrders.FindAsync(id);
    if (wo is null)
        return NotFound(new { message = $"工单 {id} 不存在" });

    var options = WorkOrderService.NextStates(wo.Status)
        .Select(s => new TransitionOptionDto((int)s, WorkOrderService.StatusText(s)));

    return Ok(options);
}
这个接口的价值被严重低估

它让状态机只有一份定义。后端改一个流转规则, 前端按钮自动跟着变,不需要同步修改两个代码库。 如果你的前端里出现了 if (status === '草稿') 显示下达按钮 这类硬编码,那就意味着规则被复制了一份,早晚会不一致。

本课任务
  1. 实现 ReleaseAsync 与 TransitAsync
  2. 实现 next-states 接口
  3. 测试:建一张工单 → 下达 → 检查工序是否按 10/20/30/40/50 生成
  4. 测试:对同一张工单再次下达,确认被幂等校验拦住
验证
BASE=http://localhost:5100

# 建工单
curl -s -X POST $BASE/api/work-orders -H "Content-Type: application/json" \
  -d '{"materialId":8,"plannedQty":10,"priority":1}'

# 下达(假设返回的 id 是 5)
curl -s -X POST $BASE/api/work-orders/5/release

# 看工序展开结果
curl -s $BASE/api/work-orders/5 | grep -o '"name":"[^"]*"'
12

报工事务与五道校验

核心
目标:实现全系统业务最重的一个方法——一次报工,影响四张表。
本课施工
起点工单能下达了,但工序做完了没法报数
新建Entities/ProductionReport.cs · Services/ProductionService.cs · Controllers/ProductionController.cs
改写MesDbContext —— 加 DbSet<ProductionReport>;Program.cs 注册 ProductionService
迁移Add-Migration AddProductionReport → Update-Database
VS 操作服务用「添加 → 类」,控制器用「添加 → 控制器(API 控制器 - 空)」
完成标志报工接口能通,且超报被拒——五道校验真的在拦人
主线步骤S31 – S32

报工为什么这么重

表面上,报工就是"一线员工点一下提交"。但实际上它要:

动作影响
通过 5 道业务校验工单状态、工序未完工、前道已完工、数量上限、员工在职
写入报工记录ProductionReport 表 +1 行
更新工序累计CompletedQty / ScrapQty / Status / StartedAt / FinishedAt
推进工单状态Released → InProgress;全部工序完工 → Completed
追加追溯记录WipTrace 表 +1~2 行
绝对不能塞进 Controller

这段逻辑如果写在控制器里,会有三个后果:无法被定时任务复用、无法被桌面客户端复用、 无法写单元测试。业务的归 Service,HTTP 的归 Controller——这条线必须守住。

完整实现

C# · Services/ProductionService.cs
public async Task<ProductionReport> ReportAsync(ReportProductionRequest req)
{
    if (req.Qty <= 0 && req.ScrapQty <= 0)
        throw new InvalidOperationException("合格数和报废数不能同时为 0");

    // 报废必须写原因,否则质量分析时这些数据就是废的
    if (req.ScrapQty > 0 && string.IsNullOrWhiteSpace(req.ScrapReason))
        throw new InvalidOperationException("有报废数量时必须填写报废原因");

    var op = await _db.WorkOrderOperations
        .Include(o => o.WorkOrder)
        .FirstOrDefaultAsync(o => o.Id == req.OperationId)
        ?? throw new InvalidOperationException($"工序 {req.OperationId} 不存在");

    var wo = op.WorkOrder;

    // ---------- 校验 ① 工单状态允许生产 ----------
    if (wo.Status is not (WorkOrderStatus.Released or WorkOrderStatus.InProgress))
    {
        throw new InvalidOperationException(
            $"工单当前为「{WorkOrderService.StatusText(wo.Status)}」,不允许报工");
    }

    // ---------- 校验 ② 本工序未完工 ----------
    if (op.Status == OperationStatus.Completed)
        throw new InvalidOperationException($"工序「{op.Name}」已完工,不能再报工");

    // ---------- 校验 ③ 前道工序必须已完工(工序顺序不可跳过) ----------
    var prevUnfinished = await _db.WorkOrderOperations
        .Where(o => o.WorkOrderId == wo.Id
                 && o.SequenceNo < op.SequenceNo
                 && o.Status != OperationStatus.Completed)
        .OrderBy(o => o.SequenceNo)
        .Select(o => o.Name)
        .FirstOrDefaultAsync();

    if (prevUnfinished is not null)
        throw new InvalidOperationException($"前道工序「{prevUnfinished}」尚未完工,不能报工");

    // ---------- 校验 ④ 报工数量不能超出计划 ----------
    if (op.CompletedQty + req.Qty > op.PlannedQty)
    {
        throw new InvalidOperationException(
            $"工序「{op.Name}」计划 {op.PlannedQty},已完工 {op.CompletedQty}," +
            $"本次报 {req.Qty} 会超出上限");
    }

    // ---------- 校验 ⑤ 报工人必须是在职员工 ----------
    var employee = await _db.Employees.FindAsync(req.EmployeeId)
        ?? throw new InvalidOperationException($"员工 {req.EmployeeId} 不存在");

    if (!employee.IsActive)
        throw new InvalidOperationException($"员工「{employee.Name}」已离职,不能报工");

    var now = DateTime.Now;

    // ---------- 写入报工记录 ----------
    var report = new ProductionReport
    {
        WorkOrderOperationId = op.Id,
        EmployeeId = employee.Id,
        Qty = req.Qty,
        ScrapQty = req.ScrapQty,
        ScrapReason = req.ScrapReason,
        StartedAt = req.StartedAt,
        EndedAt = req.EndedAt,
        ReportedAt = now
    };
    _db.ProductionReports.Add(report);

    // ---------- 更新工序进度 ----------
    op.CompletedQty += req.Qty;
    op.ScrapQty += req.ScrapQty;

    if (op.Status == OperationStatus.Pending)
    {
        op.Status = OperationStatus.InProgress;
        op.StartedAt ??= req.StartedAt ?? now;
        wo.ActualStart ??= op.StartedAt;
    }

    if (op.CompletedQty >= op.PlannedQty)
    {
        op.Status = OperationStatus.Completed;
        op.FinishedAt = now;
    }

    // ---------- 推进工单状态 ----------
    if (wo.Status == WorkOrderStatus.Released)
        wo.Status = WorkOrderStatus.InProgress;

    wo.ScrapQty += req.ScrapQty;

    // 必须重新取全部工序来判断完工。
    // 这里 EF 的"同一实例"特性很关键:op 已经被追踪并修改过,
    // 所以下面读到的 allOps 里,当前工序已经是更新后的状态。
    var allOps = await _db.WorkOrderOperations
        .Where(o => o.WorkOrderId == wo.Id)
        .ToListAsync();

    if (allOps.All(o => o.Status == OperationStatus.Completed))
    {
        wo.Status = WorkOrderStatus.Completed;
        wo.ActualEnd = now;
        // 离散装配里,工单产出取最后一道工序的产出
        wo.CompletedQty = allOps.OrderBy(o => o.SequenceNo).Last().CompletedQty;
    }
    else
    {
        // 未完工时,工单完成数取"最后一道已开工工序"的产出。
        //
        // 为什么不用"最后一道已完工工序"?因为那样在前几道工序完工、
        // 关键工序还没开始时,看板会显示 0,看不出任何进展。
        var lastStarted = allOps
            .Where(o => o.Status != OperationStatus.Pending)
            .OrderBy(o => o.SequenceNo)
            .LastOrDefault();

        wo.CompletedQty = lastStarted?.CompletedQty ?? 0;
    }

    // ---------- 追加追溯记录(只增不改) ----------
    _db.WipTraces.Add(new WipTrace
    {
        WorkOrderId = wo.Id,
        WorkOrderOperationId = op.Id,
        Action = "报工",
        Qty = req.Qty,
        EmployeeId = employee.Id,
        WorkCenterId = op.WorkCenterId,
        Detail = req.ScrapQty > 0
            ? $"{employee.Name} 报工 {req.Qty},报废 {req.ScrapQty}({req.ScrapReason})"
            : $"{employee.Name} 报工 {req.Qty}",
        HappenedAt = now
    });

    if (op.Status == OperationStatus.Completed)
    {
        _db.WipTraces.Add(new WipTrace
        {
            WorkOrderId = wo.Id,
            WorkOrderOperationId = op.Id,
            Action = "工序完工",
            Qty = op.CompletedQty,
            EmployeeId = employee.Id,
            WorkCenterId = op.WorkCenterId,
            Detail = $"「{op.Name}」累计完工 {op.CompletedQty}",
            HappenedAt = now
        });
    }

    // 一次 SaveChanges 就是一个隐式事务:
    // 报工记录、工序更新、工单状态、追溯记录要么全成,要么全不成。
    await _db.SaveChangesAsync();
    return report;
}
为什么最后一次 SaveChanges 就够了

EF Core 的 SaveChangesAsync() 会把所有挂起的变更 包在一个数据库事务里提交。上面代码修改了 4 类数据, 但只调用了一次 SaveChanges——这天然保证了原子性。

如果先存报工、再存工序、最后存工单(三次 SaveChanges), 中间任何一步失败都会留下不一致的数据:报了工但工序没推进, 或者工序推进了但没有追溯记录。这种脏数据在 MES 里是灾难。

一个容易被忽略的 EF Core 细节

代码里注释提到"同一实例":op 被 FindAsync 载入后已被变更跟踪器管理,后续 ToListAsync 查询同一张表时, EF Core 的身份解析(Identity Resolution)会返回同一个对象引用, 而不是新造一个对象。所以 allOps 里那道工序的状态, 已经是刚才修改后的新值。

理解这一点,你就能明白为什么不需要额外 SaveChanges 就能做完工判断。

本课任务
  1. 实现 ProductionService.ReportAsync,五道校验一道都不能省
  2. 逐条测试校验规则,确认每种非法操作都被正确拒绝
  3. 重点测:跳过工序报工(应被拒绝,并提示前道工序名)
  4. 测:一次报满数量,确认工序直接跳到"已完工"
验证
BASE=http://localhost:5100

# 越序报工 —— 应返回 400 并提示前道工序名
curl -s -w " [%{http_code}]\n" -X POST $BASE/api/production/report \
  -H "Content-Type: application/json" \
  -d '{"operationId":5,"employeeId":4,"qty":10}'
# 期望:{"message":"前道工序「总装」尚未完工,不能报工"} [400]

# 正常报工
curl -s -w " [%{http_code}]\n" -X POST $BASE/api/production/report \
  -H "Content-Type: application/json" \
  -d '{"operationId":4,"employeeId":1,"qty":15}'
13

在制品追溯链

核心
目标:让系统能回答"这批货到底经历了什么、谁做的、什么时候做的"。
本课施工
起点能报工了,但出了问题查不到"谁在什么时候动了什么"
新建Entities/WipTrace.cs
改写ProductionService —— 报工成功时在同一事务里写一条追溯记录;WorkOrdersController 加追溯链查询接口
迁移Add-Migration AddWipTrace → Update-Database
VS 操作实体用「添加 → 类」;控制器接口直接加在已有的 WorkOrdersController.cs 里
完成标志报一次工,WipTraces 表多一行;调追溯接口能查出完整链条
主线步骤S33

追溯表的设计哲学

WipTrace 是整个 MES 里设计最简单、但要求最严格的一张表:

原则做法原因
只增永远只 INSERT,没有 UPDATE 语句历史不能改写
不删没有删除接口账本不能撕页
不建外键只存 WorkOrderId,不建约束不跟随主数据变动
带时间戳每行都有 HappenedAt时间和顺序是追溯的核心
C# · Entities/WipTrace.cs
/// <summary>
/// 在制品流转记录 —— 追溯链的原始凭据。
///
/// 设计要点:**只增不改不删**。
/// 每一次关键动作(开工 / 报工 / 检验 / 完工)都往后追加一条,
/// 永远不回头修改历史。这样任何时候都能回答:
/// "这批货到底经历了什么、谁做的、什么时候做的"。
///
/// 这也是为什么 MES 的数据表会长得很快 —— 一辆车的追溯数据能有上百万行。
/// </summary>
public class WipTrace
{
    public int Id { get; set; }
    public int WorkOrderId { get; set; }
    public int? WorkOrderOperationId { get; set; }

    /// <summary>动作类型:下达 / 开工 / 报工 / 检验 / 完工 / 关闭</summary>
    public string Action { get; set; } = string.Empty;

    public decimal Qty { get; set; }
    public int? EmployeeId { get; set; }
    public int? WorkCenterId { get; set; }
    public string? Detail { get; set; }
    public DateTime HappenedAt { get; set; } = DateTime.Now;
}

查询追溯链

查询本身很简单——按时间排序即可。难点在于要把关联的员工名、工作中心名解析出来:

C# · Controllers/WorkOrdersController.cs(片段)
/// <summary>
/// 追溯链 —— 这张工单从头到尾发生过什么,按时间顺序列出。
/// 这就是客户问"这批货怎么做的"时,你要能立刻拿出来的东西。
/// </summary>
[HttpGet("{id:int}/trace")]
public async Task<ActionResult> Trace(int id)
{
    var exists = await _db.WorkOrders.AnyAsync(w => w.Id == id);
    if (!exists)
        return NotFound(new { message = $"工单 {id} 不存在" });

    var traces = await _db.WipTraces
        .Where(t => t.WorkOrderId == id)
        .OrderBy(t => t.HappenedAt)
        .ThenBy(t => t.Id)
        .ToListAsync();

    // 批量查名称再在内存里拼接 —— 避免在循环里逐条查库(N+1 查询问题)
    var empIds = traces.Where(t => t.EmployeeId.HasValue)
                       .Select(t => t.EmployeeId!.Value).Distinct().ToList();
    var wcIds = traces.Where(t => t.WorkCenterId.HasValue)
                      .Select(t => t.WorkCenterId!.Value).Distinct().ToList();

    var emps = await _db.Employees
        .Where(e => empIds.Contains(e.Id))
        .ToDictionaryAsync(e => e.Id, e => e.Name);
    var wcs = await _db.WorkCenters
        .Where(w => wcIds.Contains(w.Id))
        .ToDictionaryAsync(w => w.Id, w => w.Name);

    return Ok(traces.Select(t => new
    {
        t.Id,
        t.Action,
        t.Qty,
        employee = t.EmployeeId.HasValue && emps.TryGetValue(t.EmployeeId.Value, out var n) ? n : null,
        workCenter = t.WorkCenterId.HasValue && wcs.TryGetValue(t.WorkCenterId.Value, out var w) ? w : null,
        t.Detail,
        t.HappenedAt
    }));
}
N+1 查询:MES 里最容易踩的性能坑

最直观的写法是在循环里对每条追溯记录去查一次员工名:

// ❌ 错误示范:1000 条追溯 = 1001 次数据库往返
foreach (var t in traces)
{
    var name = (await _db.Employees.FindAsync(t.EmployeeId))?.Name;
}

追溯链动辄几百条记录,这种写法在生产环境会直接超时。

正确做法:先收集所有 Id,一次查出来做成字典,再在内存里拼接。 无论多少条记录,都只有 3 次查询。

追溯链长什么样

一张做完 4 道工序的工单,追溯链是这样的(真实输出):

10-10 09:00 下达 展开 5 道工序:下料 → 机加工 → 绕线 → 总装 → 成品检验 10-11 10:00 报工 张伟 报工 50 · 下料区 10-11 10:00 工序完工 「下料」累计完工 50 · 下料区 10-12 11:00 报工 李娜 报工 50 · 数控加工中心 10-12 11:00 工序完工 「机加工」累计完工 50 · 数控加工中心 10-13 16:00 报工 王强 报工 50 · 绕线工位 10-13 16:00 工序完工 「绕线」累计完工 50 · 绕线工位 10-14 12:00 报工 张伟 报工 20 · 总装线 A 10-14 16:00 报工 张伟 报工 15,报废 3(端盖螺纹滑牙)· 总装线 A

客户投诉时,把这张表打出来——什么时候、谁、在哪、做了多少、废了多少,一目了然。

本课任务
  1. 实现 WipTrace 实体与追溯查询接口
  2. 确保报工、检验、下达、流转四个动作都会写追溯记录
  3. 验证:跑一遍完整流程,看追溯链是否按时间顺序完整呈现
验证
curl -s http://localhost:5100/api/work-orders/1/trace

应看到从"下达"到各工序"报工""完工"的完整时间线,每条都带操作人和工作中心

14

质量检验与关键工序

核心
目标:实现检验记录,理解 MES 如何用"关键工序"拦住批量质量问题。
本课施工
起点报了工就算过了,可关键工序没人把关
新建Entities/QualityInspection.cs
改写ProductionService —— 加"关键工序必须先检验"的闸门
迁移Add-Migration AddQualityInspection → Update-Database
VS 操作实体用「添加 → 类」;检验相关的控制器动作加在 ProductionController.cs 里
完成标志对关键工序直接报工会被拒,先提交检验再报工才通过
主线步骤S34 – S35

MES 的质量管控逻辑

传统做法是成品出来再检验。问题是:等发现不良时,可能已经废了一整批。 MES 的做法是把检验点前移到工序之间——关键工序不合格,整批不许流转。

做法发现时机损失
只做成品检验全部工序做完整批报废或返工
关键工序检验(MES)问题发生的那道工序只废掉该工序之前的部分

检验实体

C# · Entities/QualityInspection.cs
/// <summary>
/// 质量检验记录 —— 关键工序的"放行凭证"。
///
/// 规则很简单但很硬:关键工序没有一条合格的检验记录,
/// 就不允许往下一道工序流转。这条规则把质量问题拦在工序之间,
/// 而不是等成品出来才发现 —— 这就是 MES 相比事后检验的价值。
/// </summary>
public class QualityInspection
{
    public int Id { get; set; }

    public int WorkOrderOperationId { get; set; }
    public WorkOrderOperation Operation { get; set; } = null!;

    public int InspectorId { get; set; }
    public Employee Inspector { get; set; } = null!;

    public decimal InspectedQty { get; set; }   // 送检数
    public decimal PassedQty { get; set; }      // 合格数
    public decimal FailedQty { get; set; }      // 不合格数

    public InspectionResult Result { get; set; }

    /// <summary>不良类型,如"尺寸超差" / "外观刮伤",用于后续质量分析</summary>
    public string? DefectType { get; set; }

    public string? Notes { get; set; }
    public DateTime InspectedAt { get; set; } = DateTime.Now;
}

public enum InspectionResult
{
    Passed = 0,      // 合格,允许流转
    Failed = 1,      // 不合格,必须返工或报废
    Concession = 2   // 让步接收(特采):不合格但工艺同意放行,要留证据
}
为什么需要"让步接收"这个枚举值

真实工厂里,偶尔会出现"这批尺寸超差 0.02mm,但不影响实际装配"的情况。 客户和工艺签字同意放行——这叫特采。它不是合格,也不是报废, 必须单独记录,因为它意味着风险已经被接受,将来出问题要能追溯到是谁批的。

只设计 Passed / Failed 两个值,遇到这种情况就只能二选一, 要么隐瞒不良(质量数据失真),要么整批报废(不必要的损失)。

检验实现

C# · Services/ProductionService.cs(片段)
/// <summary>质量检验 —— 检验不改变工序进度,只留下判定结论</summary>
public async Task<QualityInspection> InspectAsync(InspectionRequest req)
{
    var op = await _db.WorkOrderOperations
        .Include(o => o.WorkOrder)
        .FirstOrDefaultAsync(o => o.Id == req.OperationId)
        ?? throw new InvalidOperationException($"工序 {req.OperationId} 不存在");

    if (op.CompletedQty <= 0)
        throw new InvalidOperationException($"工序「{op.Name}」还没有产出,无法检验");

    // 数量必须对得上,否则统计数据会自相矛盾
    if (req.PassedQty + req.FailedQty != req.InspectedQty)
        throw new InvalidOperationException("合格数 + 不合格数 必须等于送检数");

    var inspector = await _db.Employees.FindAsync(req.InspectorId)
        ?? throw new InvalidOperationException($"检验员 {req.InspectorId} 不存在");

    var inspection = new QualityInspection
    {
        WorkOrderOperationId = op.Id,
        InspectorId = inspector.Id,
        InspectedQty = req.InspectedQty,
        PassedQty = req.PassedQty,
        FailedQty = req.FailedQty,
        Result = req.Result,
        DefectType = req.DefectType,
        Notes = req.Notes,
        InspectedAt = DateTime.Now
    };

    _db.QualityInspections.Add(inspection);

    var resultText = req.Result switch
    {
        InspectionResult.Passed => "合格",
        InspectionResult.Failed => "不合格",
        InspectionResult.Concession => "让步接收",
        _ => "未知"
    };

    _db.WipTraces.Add(new WipTrace
    {
        WorkOrderId = op.WorkOrderId,
        WorkOrderOperationId = op.Id,
        Action = "检验",
        Qty = req.InspectedQty,
        EmployeeId = inspector.Id,
        WorkCenterId = op.WorkCenterId,
        Detail = $"「{op.Name}」{resultText}(合格 {req.PassedQty} / 不合格 {req.FailedQty})"
                 + (req.DefectType is null ? "" : $",不良类型:{req.DefectType}"),
        HappenedAt = DateTime.Now
    });

    await _db.SaveChangesAsync();
    return inspection;
}
检验结果也要进追溯链

注意上面也写了一条 WipTrace。 检验是质量事件,必须和报工一样留在履历里。 客户拿到货问"这批做过哪些检测",答案就在追溯链的"检验"条目里。

本课任务
  1. 实现检验实体、接口与 Service 方法
  2. 加一条业务规则:关键工序未检验合格,不允许下一道工序报工
    实现位置:在 ReportAsync 的校验③里,检查前道关键工序是否有合格记录
  3. 测:送检数与合格/不合格数不等时,确认被拒绝
阶段 3 完成标志(全教程最关键的验收点)

跑通这条完整链路:

# 1. 建工单 → 2. 下达(展开 5 道工序)→ 3. 逐道报工
# 4. 关键工序检验 → 5. 全部完工 → 6. 查追溯链
curl -s http://localhost:5100/api/work-orders          # 看状态流转
curl -s http://localhost:5100/api/dashboard/wip        # 看在制品
curl -s http://localhost:5100/api/work-orders/1/trace  # 看履历

如果这条链能完整跑通,你已经掌握了 MES 的核心。 剩下的看板、界面、部署都是外围工作。

STAGE 4

看板与操作界面

数据有了,得让人看得见、用得着。
这一阶段把散落的单据聚合成管理者能一眼看懂的数字,再给车间一个能点的界面。
15

设备管理与点检

设备
目标:实现设备状态与点检,理解 OEE 的第一个因子怎么算。
本课施工
起点生产数据有了,但设备是一笔糊涂账,OEE 无从算起
新建Entities/Equipment.cs · Entities/EquipmentCheck.cs · Controllers/EquipmentsController.cs
改写MesDbContext —— 加两个 DbSet
迁移Add-Migration AddEquipment → Update-Database
VS 操作实体「添加 → 类」,控制器「添加 → 控制器(API 控制器 - 空)」
完成标志开动率接口对零记录的设备不抛除零异常(返回 0 而不是崩)
主线步骤S37

OEE 与时间开动率

OEE(设备综合效率)由三个因子相乘:时间开动率 × 性能开动率 × 良品率。 MES 直接负责第一个——它最容易出问题,因为依赖准确的停机记录:

时间开动率 = 实际运行时间 / 计划生产时间 = RunningMinutes / (RunningMinutes + DownMinutes)
C# · Entities/Equipment.cs(节选)
public class Equipment
{
    public int Id { get; set; }
    public string Code { get; set; } = string.Empty;
    public string Name { get; set; } = string.Empty;

    public int? WorkCenterId { get; set; }
    public WorkCenter? WorkCenter { get; set; }

    public EquipmentStatus Status { get; set; } = EquipmentStatus.Running;

    /// <summary>累计运行分钟数(用于算时间开动率)</summary>
    public int RunningMinutes { get; set; }

    /// <summary>累计停机分钟数</summary>
    public int DownMinutes { get; set; }

    /// <summary>最近一次点检时间</summary>
    public DateTime? LastCheckAt { get; set; }
}

public enum EquipmentStatus
{
    Running = 0,      // 运行中
    Idle = 1,         // 空闲待机(有产能但没排活)
    Down = 2,         // 故障停机
    Maintenance = 3   // 保养 / 维修中(计划内)
}
区分「空闲」和「停机」

空闲是有产能但没排活(排程问题); 停机是设备坏了干不了(设备问题)。 两者的改进方向完全相反,混在一起统计就失去了指导意义。

很多 MES 实施失败,就是因为状态定义太粗——数据录进去了,但没人能据此做决策。

点检:把事故拦在开工之前

C# · Controllers/EquipmentsController.cs(片段)
/// <summary>
/// 设备点检 —— 不合格会自动把设备置为故障停机。
/// 这条规则把"设备带病运行"挡在开工之前,避免批量质量事故。
/// </summary>
[HttpPost("{id:int}/check")]
public async Task<ActionResult> Check(int id, EquipmentCheckRequest req)
{
    var eq = await _db.Equipments.FindAsync(id);
    if (eq is null)
        return NotFound(new { message = $"设备 {id} 不存在" });

    var checker = await _db.Employees.FindAsync(req.CheckedById);
    if (checker is null)
        return NotFound(new { message = $"员工 {req.CheckedById} 不存在" });

    var now = DateTime.Now;

    _db.EquipmentChecks.Add(new EquipmentCheck
    {
        EquipmentId = eq.Id,
        CheckedById = checker.Id,
        IsPassed = req.IsPassed,
        Result = req.Result,
        CheckedAt = now
    });

    eq.LastCheckAt = now;

    // 点检不合格 —— 直接锁死设备,不等人工处理
    if (!req.IsPassed)
        eq.Status = EquipmentStatus.Down;

    await _db.SaveChangesAsync();

    return Ok(new
    {
        message = req.IsPassed ? "点检通过,设备可正常使用" : "点检不合格,设备已置为故障停机",
        equipmentStatus = StatusText(eq.Status)
    });
}

开动率计算与除零防护

C# · EquipmentsController.cs(片段)
return Ok(list.Select(e =>
{
    var total = e.RunningMinutes + e.DownMinutes;
    return new
    {
        e.Id, e.Code, e.Name,
        workCenter = e.WorkCenter?.Name,
        status = (int)e.Status,
        statusText = StatusText(e.Status),
        e.RunningMinutes,
        e.DownMinutes,
        // 开动率:没有记录时给 100,避免除零 ——
        // 新设备第一天没有任何运行记录,除零会让整个接口 500
        availability = total == 0 ? 100m : Math.Round(e.RunningMinutes * 100m / total, 2),
        e.LastCheckAt
    };
}));
除零是统计接口最常见的崩溃原因

MES 的看板接口里有大量除法:报废率、合格率、开动率、准时交付率。 每一个都可能因为"分母为 0"而崩溃——新系统刚上线、新工单刚创建、 新设备刚录入,这些场景全都命中。

统一处理原则:分母为 0 时给一个有业务含义的默认值 (开动率给 100,合格率给 100,报废率给 0),而不是让它抛异常。

本课任务
  1. 实现 Equipment、EquipmentCheck 实体与接口
  2. 实现点检接口,验证"不合格自动停机"规则生效
  3. 给 4 台设备录入运行/停机分钟数,检查开动率计算是否正确
16

生产看板聚合接口

看板
目标:把散落的单据聚合成管理者一眼看懂的数字。
本课施工
起点数据散在十几张表里,看板要一次问五个问题
新建Controllers/DashboardController.cs
迁移(无,本课不动实体)
VS 操作「添加 → 控制器(API 控制器 - 空)」,名称 DashboardController
完成标志一个接口返回五组数字(在制工单 / 今日产出 / 合格率 / 设备状态 / 待检数)
主线步骤S38

看板要回答的五个问题

问题指标数据来源
今天有多少活?工单总数、在制数、完工数WorkOrders 按状态计数
做得怎么样?计划产量、已产出、报废率WorkOrders 数量聚合
质量好不好?一次合格率、不良类型分布QualityInspections 聚合
设备行不行?运行台数、故障台数、开动率Equipment 状态统计
交期守得住吗?准时交付率完工工单的 ActualEnd vs PlannedEnd

总览接口实现

C# · Controllers/DashboardController.cs(核心)
/// <summary>
/// 生产看板接口 —— 把散落的单据聚合成管理者一眼能看懂的数字。
///
/// 实现上有意做了"先取数、再内存计算":
/// 演示数据量很小,这样做避免 SQLite 的 decimal 聚合坑,
/// 也让计算逻辑能直接在调试器里单步看懂。
/// 真实项目里数据量大,应该改成数据库端聚合 + 预计算。
/// </summary>
[HttpGet("summary")]
public async Task<ActionResult<DashboardSummaryDto>> Summary()
{
    var orders = await _db.WorkOrders
        .Include(w => w.Material)
        .ToListAsync();

    var activeStatuses = new[]
    {
        WorkOrderStatus.Released,
        WorkOrderStatus.InProgress,
        WorkOrderStatus.OnHold
    };

    var active = orders.Where(o => activeStatuses.Contains(o.Status)).ToList();
    var completed = orders.Where(o =>
        o.Status is WorkOrderStatus.Completed or WorkOrderStatus.Closed).ToList();

    var totalPlanned = orders.Sum(o => o.PlannedQty);
    var totalCompleted = orders.Sum(o => o.CompletedQty);
    var totalScrap = orders.Sum(o => o.ScrapQty);

    // 报废率 = 报废 / (完工 + 报废)。注意分母不能只用完工数,
    // 否则"全部报废"的情况下分母为 0,指标会失真
    var scrapBase = totalCompleted + totalScrap;
    var scrapRate = scrapBase == 0 ? 0 : Math.Round(totalScrap * 100m / scrapBase, 2);

    var equipments = await _db.Equipments.ToListAsync();

    var statusDistribution = Enum.GetValues<WorkOrderStatus>()
        .Select(s => new StatusCountDto(
            (int)s,
            WorkOrderService.StatusText(s),
            orders.Count(o => o.Status == s)))
        .Where(x => x.Count > 0)
        .ToList();

    return Ok(new DashboardSummaryDto(
        TotalWorkOrders: orders.Count,
        ActiveWorkOrders: active.Count,
        CompletedWorkOrders: completed.Count,
        TotalPlannedQty: totalPlanned,
        TotalCompletedQty: totalCompleted,
        TotalScrapQty: totalScrap,
        ScrapRate: scrapRate,
        OnTimeRate: CalcOnTimeRate(completed),
        EquipmentTotal: equipments.Count,
        EquipmentRunning: equipments.Count(e => e.Status == EquipmentStatus.Running),
        EquipmentDown: equipments.Count(e => e.Status == EquipmentStatus.Down),
        PendingInspections: await _db.WorkOrderOperations.CountAsync(o =>
            o.IsKeyProcess && o.Status == OperationStatus.Completed),
        StatusDistribution: statusDistribution,
        ActiveList: active.OrderBy(o => o.Priority).Select(ToListItem).ToList()));
}

/// <summary>准时交付率 —— 只有同时有计划交期和实际完工时间的工单才参与统计</summary>
private static decimal CalcOnTimeRate(List<WorkOrder> completed)
{
    var withPlan = completed.Where(o => o.PlannedEnd.HasValue && o.ActualEnd.HasValue).ToList();
    if (withPlan.Count == 0) return 100m;   // 除零防护

    var onTime = withPlan.Count(o => o.ActualEnd!.Value <= o.PlannedEnd!.Value);
    return Math.Round(onTime * 100m / withPlan.Count, 2);
}
指标定义本身就是业务决策

看上面"报废率"的分母:为什么是 完工 + 报废 而不是 计划数?

用计划数做分母:工单还没做完,报废率会显得很低(分母大),严重低估问题。
用完工数做分母:全部报废时分母为 0,指标直接崩掉。
用完工+报废做分母:反映"已经处理完的件里有多少废了",最贴近实际质量水平。

写看板之前,一定先和业务方确认每个指标到底怎么算。 算错的口径比没有指标更危险——它会误导决策。

质量统计:找到改进的着力点

C# · DashboardController.cs(片段)
/// <summary>质量统计 —— 按不良类型汇总,找质量改进的着力点</summary>
[HttpGet("quality")]
public async Task<ActionResult> Quality()
{
    var inspections = await _db.QualityInspections.ToListAsync();

    var inspected = inspections.Sum(i => i.InspectedQty);
    var passed = inspections.Sum(i => i.PassedQty);
    var failed = inspections.Sum(i => i.FailedQty);

    // 按不良类型分组 —— 这一项才是真正能指导改进的:
    // 如果 80% 的不良都是"装配划伤",那改进装配工装比什么都有效
    var byDefect = inspections
        .Where(i => !string.IsNullOrWhiteSpace(i.DefectType))
        .GroupBy(i => i.DefectType!)
        .Select(g => new { defectType = g.Key, count = g.Count(), qty = g.Sum(i => i.FailedQty) })
        .OrderByDescending(x => x.qty)
        .ToList();

    return Ok(new
    {
        totalInspections = inspections.Count,
        inspectedQty = inspected,
        passedQty = passed,
        failedQty = failed,
        passRate = inspected == 0 ? 100m : Math.Round(passed * 100m / inspected, 2),
        byDefectType = byDefect
    });
}
本课任务
  1. 实现 summary、wip、quality 三个接口
  2. 给每个除法都加上除零防护
  3. 验证:把某个工单报废数调大,确认报废率随之变化
验证
curl -s http://localhost:5100/api/dashboard/summary
curl -s http://localhost:5100/api/dashboard/wip
curl -s http://localhost:5100/api/dashboard/quality
17

车间 Web 操作界面

界面
目标:给 API 配一个可点的界面,让系统真正"能用"。
本课施工
起点接口都能调,但只有开发者能用 Scalar,车间的人用不了
新建wwwroot/index.html
改写Program.cs —— 挂上静态文件托管(让 API 顺带把页面发出去)
迁移(本课不动实体,不用迁移)
VS 操作右键项目 → 添加 → 新建文件夹 → 命名 wwwroot(小写、拼写不能错),再在里面新建 HTML 文件
完成标志浏览器打开 http://localhost:5100/ 出现车间界面,能从界面新建工单、报工
主线步骤S39 – S41

为什么前端不用框架

本教程的前端是单文件原生 JS,不是偷懒,是有意的选择:

方案优点代价
原生单文件(本教程)零构建、零依赖、改完刷新即见、部署就是拷一个文件复杂交互后期难维护
Vue / React组件化、状态管理完善需要 Node 构建链、额外的部署步骤

MES 的车间界面通常就是几张表格加几个按钮,原生 JS 完全够用。 等页面超过 10 个再考虑引入框架——那时你已经清楚自己需要什么了。

让 API 托管静态页面

C# · Program.cs(片段)
// 托管 wwwroot 下的车间操作界面(index.html)。
// UseDefaultFiles 必须在 UseStaticFiles 之前,否则访问 "/" 不会落到 index.html。
app.UseDefaultFiles();
app.UseStaticFiles();
顺序错了就 404

UseDefaultFiles() 的作用是"把 / 重写成 /index.html", 它本身不发送文件,必须紧跟 UseStaticFiles() 才能真正返回内容。 两个中间件顺序颠倒,访问首页就是 404,而且没有任何报错提示——很难查。

前端的核心封装

整个前端最关键的是这两个函数——一个统一的请求封装和一个提示条:

JavaScript · wwwroot/index.html(片段)
/* ---------- 统一请求封装:把后端的 { message } 错误自动抛出来 ---------- */
async function api(path, options) {
  const res = await fetch(API + path, options);
  const text = await res.text();

  let data = null;
  try { data = text ? JSON.parse(text) : null; } catch (e) { data = text; }

  // 关键:后端返回 400 时带的是 { message: "前道工序「总装」尚未完工" },
  // 这里直接抛出,调用方 catch 之后 toast 出来 ——
  // 业务规则的提示语由后端决定,前端不需要翻译错误码
  if (!res.ok) throw new Error((data && data.message) || ("请求失败 " + res.status));
  return data;
}

function toast(msg, ok) {
  const t = document.getElementById("toast");
  t.textContent = msg;
  t.className = "toast show " + (ok ? "ok" : "err");
  clearTimeout(t._tid);
  t._tid = setTimeout(() => t.className = "toast", 3600);
}
错误提示由后端决定,这是有意为之

注意第 12 课里,Service 抛出的异常消息是完整的中文提示: "前道工序「总装」尚未完工,不能报工"。

前端只是原样展示,不做任何判断和翻译。这样: 业务规则变了,只改后端一处,所有客户端(Web、WPF、未来的 App)自动同步。 如果前端写的是 if (code === 'PREV_OP_NOT_DONE') 显示固定文案, 那么每加一条规则都要改三个客户端。

界面功能一览

页面功能调用的接口
生产看板KPI 卡片、状态分布、在制工单/api/dashboard/*
工单管理列表筛选、详情、下达、状态流转/api/work-orders/*
车间报工选工单选工序、提交报工、质量检验/api/production/*
质量与追溯合格率、不良分布、全链路时间线/api/work-orders/{id}/trace
设备管理设备卡片、开动率、状态/api/equipments
本课任务
  1. 创建 wwwroot/index.html,实现五个页面
  2. 在 Program.cs 里加 UseDefaultFiles + UseStaticFiles
  3. 访问 http://localhost:5100/ 确认界面正常
  4. 在页面上实际操作一遍:下达工单 → 报工 → 看追溯链
验证
curl -s -o /dev/null -w '首页 %{http_code}\n' http://localhost:5100/
curl -s -o /dev/null -w 'Scalar %{http_code}\n' http://localhost:5100/scalar/v1
STAGE 5

工程化收尾

最后两课解决三个问题:错误怎么统一处理、演示数据怎么自动灌、系统怎么上线。
做完这些,它才算一个能交付的项目,而不只是一堆能跑的代码。
18

全局异常处理与种子数据

工程化
目标:让业务异常自动变成 400,让系统一启动就有完整数据。
本课施工
起点功能齐了,但一有异常就吐一屏堆栈;新库第一次跑还是空的
新建Data/SeedData.cs
改写Program.cs —— 加全局异常中间件 + 把之前预留的 SeedData.Initialize(db); 补上
迁移(本课不动实体,不用迁移)
VS 操作中间件插在 app.MapControllers() 之前,顺序错了不生效且不报错
完成标志删掉 mes.db 重启,所有表和演示数据自动生成
主线步骤S42 – S44

为什么不能到处 try-catch

第 12 课的报工方法里有 5 处 throw new InvalidOperationException(...)。 如果每个控制器都要捕获再转成 400,代码会变成这样:

// ❌ 每个方法都要写一遍,重复且容易漏
try { await _service.ReportAsync(req); }
catch (InvalidOperationException ex) { return BadRequest(new { message = ex.Message }); }
catch (Exception ex) { return StatusCode(500, new { message = ex.Message }); }

更好的做法是在管道上装一个全局处理器,一次性解决:

C# · Program.cs(片段)
// ---------- 全局异常处理 ----------
// 业务规则冲突(比如"前道工序没完工不能报工")抛的是 InvalidOperationException,
// 这类应该返回 400 并带上人话提示;其他异常返回 500。
// 有了这层,Controller 里就不用到处写 try-catch。
app.UseExceptionHandler(handler =>
{
    handler.Run(async context =>
    {
        var feature = context.Features.Get<IExceptionHandlerFeature>();
        var ex = feature?.Error;

        var isBusinessError = ex is InvalidOperationException;

        context.Response.StatusCode = isBusinessError
            ? StatusCodes.Status400BadRequest
            : StatusCodes.Status500InternalServerError;
        context.Response.ContentType = "application/json; charset=utf-8";

        await context.Response.WriteAsJsonAsync(new
        {
            message = isBusinessError
                ? ex!.Message
                : "服务器内部错误:" + ex?.Message
        });
    });
});
用异常类型区分「业务错误」和「系统错误」

InvalidOperationException = 业务规则不允许(400)。 用户能看到"前道工序未完工"这种提示,他会去先做前道工序——问题可解决。

其他异常 = 代码 bug 或环境问题(500)。 用户看不懂,需要运维介入。这两类必须区分,否则用户会对着一个 "NullReferenceException" 发呆。

更严谨的项目会自定义 BusinessException 类型, 避免和框架抛的 InvalidOperationException 混淆。本教程为了简洁直接复用了它。

种子数据:让系统一启动就能看

空的 MES 什么都验证不了。种子数据要造出一条完整链路:

C# · Data/SeedData.cs(结构与关键点)
public static class SeedData
{
    public static void Initialize(MesDbContext db)
    {
        // 幂等:已经有物料说明灌过了,直接返回。
        // 少了这一句,每次重启服务数据都会翻倍。
        if (db.Materials.Any()) return;

        var today = DateTime.Today;

        // ==================== 工作中心 ====================
        var wcCut  = new WorkCenter { Code = "WC-CUT",  Name = "下料区",       Type = "手工工位", DailyMinutes = 480 };
        var wcCnc  = new WorkCenter { Code = "WC-CNC",  Name = "数控加工中心", Type = "单机",     DailyMinutes = 720 };
        var wcWind = new WorkCenter { Code = "WC-WIND", Name = "绕线工位",     Type = "手工工位", DailyMinutes = 480 };
        var wcAsm  = new WorkCenter { Code = "WC-ASM",  Name = "总装线 A",     Type = "产线",     DailyMinutes = 480 };
        var wcQc   = new WorkCenter { Code = "WC-QC",   Name = "检验站",       Type = "手工工位", DailyMinutes = 480 };
        db.WorkCenters.AddRange(wcCut, wcCnc, wcWind, wcAsm, wcQc);

        // ==================== 员工 ====================
        var eZhang = new Employee { Code = "E001", Name = "张伟", Team = "一班",   Skill = "钳工" };
        var eLi    = new Employee { Code = "E002", Name = "李娜", Team = "一班",   Skill = "数控操作" };
        var eWang  = new Employee { Code = "E003", Name = "王强", Team = "二班",   Skill = "绕线" };
        var eZhao  = new Employee { Code = "E004", Name = "赵敏", Team = "检验组", Skill = "质检" };
        var eChen  = new Employee { Code = "E005", Name = "陈军", Team = "设备组", Skill = "设备维护" };
        db.Employees.AddRange(eZhang, eLi, eWang, eZhao, eChen);

        db.SaveChanges();   // 先落库拿到 Id,后面要用

        // ==================== 物料(三层结构) ====================
        // 原材料
        var mSteel   = new Material { Code = "RM-STEEL",   Name = "冷轧钢板",   Spec = "1.5mm SPCC",  Unit = "kg", Type = MaterialType.RawMaterial };
        var mWire    = new Material { Code = "RM-WIRE",    Name = "漆包铜线",   Spec = "Φ0.5mm",      Unit = "米", Type = MaterialType.RawMaterial };
        var mBearing = new Material { Code = "RM-BEARING", Name = "深沟球轴承", Spec = "6203-2RS",    Unit = "个", Type = MaterialType.RawMaterial };
        var mScrew   = new Material { Code = "RM-M6X20",   Name = "内六角螺栓", Spec = "M6x20 不锈钢", Unit = "个", Type = MaterialType.RawMaterial };

        // 半成品
        var mHousing = new Material { Code = "SF-HOUSING", Name = "电机壳体", Spec = "LG-220 铝壳", Unit = "个", Type = MaterialType.SemiFinished };
        var mStator  = new Material { Code = "SF-STATOR",  Name = "定子总成", Spec = "220V 绕组",   Unit = "个", Type = MaterialType.SemiFinished };
        var mRotor   = new Material { Code = "SF-ROTOR",   Name = "转子总成", Spec = "Φ48 轴芯",    Unit = "个", Type = MaterialType.SemiFinished };

        // 成品
        var mProduct = new Material { Code = "FG-MOTOR-A", Name = "直流电机 A 型", Spec = "220V / 1.5kW", Unit = "台", Type = MaterialType.Finished };

        db.Materials.AddRange(mSteel, mWire, mBearing, mScrew, mHousing, mStator, mRotor, mProduct);
        db.SaveChanges();

        // ==================== BOM(注意损耗率) ====================
        db.BomItems.AddRange(
            new BomItem { ParentMaterialId = mHousing.Id, ChildMaterialId = mSteel.Id,   Quantity = 1.2m, Position = "壳体主体", LossRate = 5 },
            new BomItem { ParentMaterialId = mStator.Id,  ChildMaterialId = mWire.Id,    Quantity = 85m,  Position = "绕组",     LossRate = 3 },
            new BomItem { ParentMaterialId = mStator.Id,  ChildMaterialId = mSteel.Id,   Quantity = 0.3m, Position = "铁芯",     LossRate = 5 },
            new BomItem { ParentMaterialId = mRotor.Id,   ChildMaterialId = mSteel.Id,   Quantity = 0.5m, Position = "轴芯",     LossRate = 5 },
            new BomItem { ParentMaterialId = mRotor.Id,   ChildMaterialId = mBearing.Id, Quantity = 1m,   Position = "后端轴承", LossRate = 0 },
            new BomItem { ParentMaterialId = mProduct.Id, ChildMaterialId = mHousing.Id, Quantity = 1m,   Position = "外壳",     LossRate = 0 },
            new BomItem { ParentMaterialId = mProduct.Id, ChildMaterialId = mStator.Id,  Quantity = 1m,   Position = "内芯",     LossRate = 0 },
            new BomItem { ParentMaterialId = mProduct.Id, ChildMaterialId = mRotor.Id,   Quantity = 1m,   Position = "内芯",     LossRate = 0 },
            new BomItem { ParentMaterialId = mProduct.Id, ChildMaterialId = mBearing.Id, Quantity = 1m,   Position = "前端盖",   LossRate = 0 },
            new BomItem { ParentMaterialId = mProduct.Id, ChildMaterialId = mScrew.Id,   Quantity = 8m,   Position = "端盖固定", LossRate = 2 }
        );
        db.SaveChanges();

        // ==================== 工艺路线 ====================
        db.ProcessSteps.AddRange(
            new ProcessStep { MaterialId = mProduct.Id, SequenceNo = 10, Name = "下料",     WorkCenterId = wcCut.Id,  StandardMinutes = 5 },
            new ProcessStep { MaterialId = mProduct.Id, SequenceNo = 20, Name = "机加工",   WorkCenterId = wcCnc.Id,  StandardMinutes = 20 },
            new ProcessStep { MaterialId = mProduct.Id, SequenceNo = 30, Name = "绕线",     WorkCenterId = wcWind.Id, StandardMinutes = 15 },
            new ProcessStep { MaterialId = mProduct.Id, SequenceNo = 40, Name = "总装",     WorkCenterId = wcAsm.Id,  StandardMinutes = 25, IsKeyProcess = true },
            new ProcessStep { MaterialId = mProduct.Id, SequenceNo = 50, Name = "成品检验", WorkCenterId = wcQc.Id,   StandardMinutes = 10, IsKeyProcess = true }
        );
        db.SaveChanges();

        // ==================== 造 4 张不同状态的工单 ====================
        // 工单 1:生产中(下料/机加工/绕线已完工,总装做到 35 台废 3)
        // 工单 2:已下达未开工
        // 工单 3:已完工(含检验记录,用于看板和追溯演示)
        // 工单 4:草稿(还没展开工序)
        //
        // 这样一启动,看板上四种状态都有数据,所有接口都能验出东西来。
        // ...(完整代码见源码文件 Data/SeedData.cs)
    }
}
种子数据的设计原则

1. 覆盖所有状态。草稿、已下达、生产中、完工各来一张—— 这样看板每个统计维度都有数据。

2. 包含真实异常。故意造一条报废记录(端盖螺纹滑牙)和一条不合格检验, 否则质量统计页面全是 0,看不出效果。

3. 时间要有层次。工单 1 的工序分布在不同日期, 追溯链才像真实履历,而不是同一秒钟全部生成。

4. 必须幂等。第一行 if (db.Materials.Any()) return; 不能省——否则每次重启服务,数据翻一倍。

本课任务
  1. 实现全局异常处理器,删掉控制器里所有 try-catch
  2. 实现 SeedData,造出完整的中文演示场景
  3. 删掉 mes.db 重启服务,确认数据自动重建
  4. 连续重启两次,确认数据没有翻倍(幂等生效)
19

部署上线与后续演进

工程化
目标:把系统部署到服务器,并规划下一步往哪走。
本课施工
起点本地跑得好好的,别人访问不到
新建发布产物 + systemd 服务单元 + nginx 配置
改写连接串改成服务器绝对路径;Mes:EnforcePermission 先留 false
迁移(本课不动实体,不用迁移)
VS 操作右键项目 → 发布 → 目标「文件夹」→ 配置选 Release → 发布
完成标志公网能打开首页,systemctl status 是 active (running),服务重启后数据还在
主线步骤S45 – S47

发布与部署

Terminal
# 发布(框架依赖模式,服务器需装 .NET 10 运行时)
dotnet publish -c Release -o ./publish

# 上传到服务器
scp -r ./publish/* root@your-server:/var/www/mes/api/

# 服务端创建 systemd 服务
cat > /etc/systemd/system/mes-api.service <<'EOF'
[Unit]
Description=MES API
After=network.target

[Service]
WorkingDirectory=/var/www/mes/api
ExecStart=/usr/bin/dotnet /var/www/mes/api/Mes.Api.dll
Restart=always
RestartSec=10
Environment=ASPNETCORE_ENVIRONMENT=Production
Environment=ASPNETCORE_URLS=http://127.0.0.1:5100

[Install]
WantedBy=multi-user.target
EOF

systemctl daemon-reload
systemctl enable --now mes-api
生产环境必须改的三件事

1. 换数据库。SQLite 是单文件、单写入者,多人同时报工会锁库。 生产环境换成 SQL Server 或 PostgreSQL——只需改 Program.cs 里的 provider 一行,再加一个连接串。这就是 EF Core 抽象的价值。

2. 打开 HTTPS 重定向。把开发时注释掉的 app.UseHttpsRedirection() 恢复。

3. 关闭 API 文档。IsDevelopment() 判断会让 Production 环境下自动不挂载 Scalar——这是设计好的,别去改它。

Nginx 反向代理

nginx conf
server {
    listen 443 ssl http2;
    server_name mes.your-domain.com;

    ssl_certificate     /etc/letsencrypt/live/mes.your-domain.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/mes.your-domain.com/privkey.pem;

    location / {
        proxy_pass http://127.0.0.1:5100;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}
API 只监听 127.0.0.1

注意 systemd 里的 ASPNETCORE_URLS=http://127.0.0.1:5100—— 应用本身不直接对外,所有流量必须经过 Nginx。

好处:HTTPS 终止、限流、访问日志、IP 白名单都在 Nginx 一层搞定, 应用只管业务。同时即使应用有漏洞,攻击面也小得多—— 端口根本没在公网监听。

接下来做什么

到这里,你手上是一个能完整跑通执行闭环的 MES 内核: 工单能下达、工序能展开、报工能推进状态、追溯能查到源头。 但它还只是一台"记录仪"——它不告诉你该怎么排产、料够不够、设备好不好、这个决定是谁做的。

下面 11 课就是把这台记录仪变成决策系统。这不是"以后再说"的清单, 而是紧接着就要动手做的:

阶段课你会做出什么
排产与物料 20 – 22 有限产能顺排算法、派工与负荷、MRP 物料需求展开 —— 从"记录执行"到"指导执行"
质量闭环 23 – 24 不良品处置流程(根因 + 对策)、SPC 控制图 —— 从"发现不良"到"消灭原因"
设备效率 25 OEE 三项分解与损失定位 —— 知道设备到底浪费在哪
系统治理 26 – 28 用户角色权限、审计日志、报表聚合与 CSV 导出 —— 让系统能真正上线
测试与交付 29 – 30 API 与前端自动化测试、生产加固、后续演进路线
为什么先把"执行闭环"做扎实,而不是一上来做排产

排产的输出最终要落到工序任务上,质量的判定要挂在工序上,OEE 的产量数据来自报工记录—— 所有这些扩展模块都长在第 10–14 课那个闭环上。

顺序错了会怎样?你会先写出一套漂亮的排产算法, 再发现没有可靠的报工数据可以校准它,最后推倒重来。 制造业系统里,数据流的顺序就是开发的顺序。

这 19 课你已经掌握的东西

走完前 19 课,你手上不只是一套代码,而是这些可以迁移到任何制造业项目的能力:

  • 状态机建模——把业务规则集中成一份定义,而不是散落在各处的 if-else
  • 模板与实例分离——历史数据不被规则变更追溯性改写
  • 事务边界的直觉——一次 SaveChanges 就是一个原子操作,不多不少
  • 追溯系统的设计——只增不改的表、不建外键的账本
  • 指标口径的敏感性——算错的分母比没有指标更危险
  • 分层纪律——Controller 管 HTTP,Service 管业务,DbContext 管表结构

这些东西换一个行业(电子、汽车、医药)依然成立,因为它们是制造执行这件事本身的规律, 不是某个框架的用法。

第一段落完成(第 1 – 19 课)

如果你的 http://localhost:5100/ 能打开车间界面、 能下达工单、能报工、能查到完整追溯链——你已经独立做出了一套 MES 的核心。

接下来进入第 20 课,我们开始给它装上"大脑"。

STAGE 6

排产与物料

到这里 MES 还只是一台"记录仪"——它记录发生了什么,但不告诉你该怎么干。
这一阶段装上大脑:把工序排到具体的人和具体的时间段上,再算出每张工单要备多少料。
20

有限产能顺排算法

排产
目标:写出一个真正的排产算法——把工序任务排到时间轴上,并且承认"一天只有这么多产能"。
本课施工
起点工单只有"要做什么",没有"什么时候做"
新建Services/SchedulingService.cs
改写Program.cs —— 注册 SchedulingService
迁移(无,本课不动实体)
VS 操作服务用「添加 → 类」;触发排产用 curl 或 Scalar
完成标志每道工序算出开始 / 结束时间,且同一工作中心上的工序不重叠
主线步骤S48
本课任务
  1. 分清三种排产策略,想清楚为什么大部分工厂只需要"有限产能顺排"
  2. 实现两个核心函数:AlignToShift(对齐上下班时间)和 AddWorkingMinutes(跨天累加工作量)
  3. 跑一次派工,确认返回的计划时间真的落在工作时段内

排 = 决定什么时候做;派 = 决定谁来做

这两个字经常被混用,但它们是两件事:

动作回答的问题产出
排产(Scheduling)这道工序什么时候做、占用哪个工作中心多久时间区间
派工(Dispatching)这段活派给谁、用哪台设备派工单

我们先做排产——因为不知道"什么时候",就没法判断"派给谁合不合适"。

三种策略,差别在"承不承认产能上限"

策略做法问题
无限产能顺排 按顺序往后累加时间,不管工作中心是否已经排满 排出来的计划根本执行不了——同一台机器同一时刻被排了三份活
有限产能顺排 ⭐ 起算点取"该工作中心现有任务的最晚结束时间",再往后排;一天排不下就顺延到次日 不会给你最优解,但给出的一定能干的解
倒排(按交期) 从交货期往前推,算出最晚开工时间 推出来的开工时间可能已经过去了——用来算"这单还来得及吗"最有效

真实的 APS(高级排程)会复杂得多:换型时间、模具共享、班次轮换、多目标优化。 但有限产能顺排是所有这些算法的地基——先把这一层吃透, 以后加约束只是在同一个骨架上挂东西。

核心代码

C# · Services/SchedulingService.cs(排产部分)
/// <summary>上班时间:早 8 点。真实的班次表应该是配置,这里先固定</summary>
private static readonly TimeSpan ShiftStart = TimeSpan.FromHours(8);

/// <summary>
/// 顺排:把这个工作中心最后一个任务的结束时间当作起算点,再往后排本次任务。
/// 如果起算点已经过去(比如夜里),就从现在开始。
/// </summary>
private async Task<(DateTime Start, DateTime End)> PlanAsync(int workCenterId, decimal minutes)
{
    var lastEnd = await _db.OperationAssignments
        .Include(a => a.Operation)
        .Where(a => a.Operation.WorkCenterId == workCenterId
                    && a.Status != AssignmentStatus.Cancelled)
        .OrderByDescending(a => a.PlannedEnd)
        .Select(a => (DateTime?)a.PlannedEnd)
        .FirstOrDefaultAsync();

    var daily = await DailyMinutesAsync(workCenterId);

    // 起算点:已有任务的终点,但不能早于现在(不能排到过去)
    var baseline = lastEnd is null || lastEnd < DateTime.Now ? DateTime.Now : lastEnd.Value;

    var start = AlignToShift(baseline, daily);
    var end = AddWorkingMinutes(start, minutes, daily);

    return (start, end);
}

/// <summary>把时间对齐到上班时间:太早就推到 8 点,下班后就推到次日 8 点</summary>
private static DateTime AlignToShift(DateTime t, int dailyMinutes)
{
    var shiftStart = t.Date + ShiftStart;
    var shiftEnd = shiftStart.AddMinutes(dailyMinutes);

    if (t < shiftStart) return shiftStart;      // 还没上班
    if (t >= shiftEnd) return shiftStart.AddDays(1);   // 已经下班
    return t;                                   // 正在上班
}

/// <summary>
/// 从 start 开始累加工作时长,跳过下班时间逐日顺延。
/// 这是"有限产能"的核心:一天只有 DailyMinutes 分钟,排满了就得等明天。
/// </summary>
private static DateTime AddWorkingMinutes(DateTime start, decimal minutes, int dailyMinutes)
{
    var remaining = minutes;
    var cursor = start;

    while (true)
    {
        var shiftEnd = cursor.Date + ShiftStart + TimeSpan.FromMinutes(dailyMinutes);

        // 已经过了下班时间,直接跳到明天上班
        if (cursor >= shiftEnd)
        {
            cursor = cursor.Date.AddDays(1) + ShiftStart;
            continue;
        }

        var available = (decimal)(shiftEnd - cursor).TotalMinutes;

        // 今天剩下的时间够用,直接算完
        if (remaining <= available)
            return cursor.AddMinutes((double)remaining);

        // 今天不够,扣掉今天的产能,明天接着做
        remaining -= available;
        cursor = cursor.Date.AddDays(1) + ShiftStart;
    }
}
这段代码里最重要的一个设计

起算点是"该工作中心已有任务的最晚结束时间", 不是"上一道工序的结束时间"。这一个选择决定了它是有序排产还是无序排产。

如果按工序顺序排(上一道做完才排下一道),同一道工序的多个任务会串行排开, 但不同工单之间缺乏全局视野;如果按工作中心排(本课的做法), 每个工作中心的活是连续堆叠的,更接近真实车间。

两者都能用,但你要知道自己选的是哪一种、为什么—— 而不是抄完代码就算了。

三个必须注意的细节

  1. 不能排到过去。当某个工作中心的任务都已完成(lastEnd 是三天前), 起算点必须回落到 DateTime.Now,否则会生成一张"从上周开始干"的派工单。
  2. 下班时间的边界要一致。AlignToShift 用 >= 判断下班,AddWorkingMinutes 也用 >= 判断。两个地方不一致就会出现在 17:00 整被排进今天、 然后瞬间跳到明天的诡异结果。
  3. 算法必须能终止。这个 while(true) 靠 "每天都往前推一天"来保证终止。如果 dailyMinutes 为 0 或负数, 就会无限循环——所以 DailyMinutesAsync 里兜了一层默认值 480。
为什么不用递归

直觉上这个算法可以写成递归:"今天排不完就排明天"。 但一个 500 件的大单可能跨几十天,递归深度跟着涨,栈就爆了。

凡是"数据量决定层数"的场景,一律用循环不用递归。 反过来,BOM 展开这种"层数由业务结构决定、通常不超过十层"的场景,递归反而更清晰(第 22 课会看到)。

验证:排出来的时间必须落在工作时段内

先看当前的派工单,再新派一个,观察 plannedStart 是否等于 该工作中心已有任务的最晚结束时间:

curl
# 1. 看某个工作中心当前的排产情况
curl -s "http://localhost:5100/api/scheduling/gantt?from=2026-10-14T08:00:00&to=2026-10-21T08:00:00"

# 2. 新派一个任务,观察返回的计划时间
curl -s -X POST http://localhost:5100/api/scheduling/assignments \
  -H "Content-Type: application/json" \
  -d '{"operationId":7,"employeeId":2,"qty":5}'

正确的结果:plannedStart 不小于现在, plannedEnd - plannedStart 等于 数量 × 单件标准工时(本例 5 × 20 = 100 分钟 = 1.67 小时)。 如果新派的活和已有的活时间重叠,说明起算点取错了。

21

派工与工作中心负荷

排产
目标:把排产结果落到具体的人身上,并回答排产员最关心的问题——"哪个工位快爆了?"
本课施工
起点排出来了档期,但没落到具体的人和设备上
新建Entities/OperationAssignment.cs · Controllers/SchedulingController.cs
改写SchedulingService —— 加派工的三条硬校验
迁移Add-Migration AddOperationAssignment → Update-Database
VS 操作实体「添加 → 类」,控制器「添加 → 控制器(API 控制器 - 空)」
完成标志派工接口能返回工作中心负荷;超剩余量派工被拒
主线步骤S49
本课任务
  1. 建 OperationAssignment 表,理解为什么派工不能直接写在工序上
  2. 实现派工的三条硬校验(超派 / 离职 / 设备故障)
  3. 算出每个工作中心的负荷率,并做超期预警

为什么派工必须是一张独立的表

最省事的做法是在 WorkOrderOperation 上加两个字段: AssignedEmployeeId、AssignedQty。小作坊确实这么干。

但现实里,50 台电机的总装经常拆给 3 个人并行做。一对一的字段装不下, 你就得加 AssignedEmployeeId2、AssignedEmployeeId3…… 到第四个人的时候这段代码就该重写了。

判据很简单:当"一个 X 可以对应多个 Y"时,Y 就该有自己的表。

C# · Entities/OperationAssignment.cs
public class OperationAssignment
{
    public int Id { get; set; }
    public string AssignmentNo { get; set; } = string.Empty;   // AS-20261014-0001

    public int WorkOrderOperationId { get; set; }
    public WorkOrderOperation Operation { get; set; } = null!;

    public int EmployeeId { get; set; }
    public Employee Employee { get; set; } = null!;

    public int? EquipmentId { get; set; }          // 手工工位可以为空
    public Equipment? Equipment { get; set; }

    public decimal AssignedQty { get; set; }        // 可以小于工序计划数,余量另派

    public DateTime PlannedStart { get; set; }      // 排产算出来的
    public DateTime PlannedEnd { get; set; }

    public DateTime? ActualStart { get; set; }      // 第一次报工时写入
    public DateTime? ActualEnd { get; set; }

    public AssignmentStatus Status { get; set; } = AssignmentStatus.Assigned;

    public string? Remark { get; set; }
    public DateTime CreatedAt { get; set; } = DateTime.Now;

    /// <summary>是否超期未完成 —— 计划完工时间已过、但还没完工</summary>
    public bool IsOverdue =>
        Status != AssignmentStatus.Finished && DateTime.Now > PlannedEnd;
}

public enum AssignmentStatus
{
    Assigned = 0,   // 已派工,还没开工
    Started = 1,    // 已开工
    Finished = 2,   // 已完工
    Cancelled = 9   // 已撤回(派错了)
}
计算属性是"读的时候算",不是"写的时候存"

IsOverdue 没有 set,也不落库。 它每次被读到时用当前时间重新计算。

如果把它存成数据库字段,就得写一个定时任务每分钟去扫描并更新它—— 那是一个纯粹自找的复杂度。能推导出来的字段就不要存。

记得在 DbContext 里 e.Ignore(x => x.IsOverdue), 否则 EF 会认为它是映射字段,报"没有映射"的错。

派工的三条硬校验

C# · Services/SchedulingService.cs(派工校验片段)
// ① 不能超派。已派数量 + 本次 > 工序计划数,会让车间出现
//    "两个人都以为这 10 件是自己的"这种最典型的现场混乱。
var alreadyAssigned = op.Assignments
    .Where(a => a.Status != AssignmentStatus.Cancelled)
    .Sum(a => a.AssignedQty);

if (alreadyAssigned + req.Qty > op.PlannedQty)
    throw new InvalidOperationException(
        $"超派:工序计划 {op.PlannedQty} 件,已派 {alreadyAssigned} 件," +
        $"本次最多还能派 {op.PlannedQty - alreadyAssigned} 件");

// ② 员工必须在职。离职员工的名字出现在今天的派工单上,
//    说明这个系统还没有真的被当回事。
if (!employee.IsActive)
    throw new InvalidOperationException($"员工「{employee.Name}」已离职(停用),不能派工");

// ③ 故障停机的设备不能派工 —— 排了也干不了。
//    这条规则把"设备维修"和"生产安排"两个模块真正连了起来:
//    点检不合格 → 设备状态置 Down → 派工被拒 → 排产员不得不改计划。
if (eq.Status == EquipmentStatus.Down)
    throw new InvalidOperationException($"设备「{eq.Name}」处于故障停机状态,不能派工");
第 ③ 条规则才是这一课的重点

前两条是数据完整性,最后一条是业务联动。

回想第 15 课:设备点检不合格。如果点检结果只是躺在设备表里没人看, 那点检就是一张形式主义的表格。当它能让派工失败、逼着计划员改排产时, "点检"才真正变成了生产管理的一环。

工作中心负荷:排产员每天看的第一张表

负荷率的算法很朴素,但要选对分母:

// 负荷率 = 已派工未完工的产能 ÷ 单日可用产能
var minutes = mine.Sum(a => a.Operation.StandardMinutes * a.AssignedQty);
var load = wc.DailyMinutes <= 0 ? 0 : Math.Round(minutes / wc.DailyMinutes * 100, 1);

// 判定分档 —— 分档的意义是让数字能直接触发动作
// 超负荷   → 今天做不完,要么加班要么调走
// 接近饱和 → 还有余量,但别再往里塞急单
// 正常     → 可以接活
// 空闲     → 产能浪费,或者排产有盲区
static string LevelOf(decimal load) => load switch
{
    >= 100 => "超负荷",
    >= 80 => "接近饱和",
    >= 40 => "正常",
    _ => "空闲"
};

注意只统计"已派但还没完工"的任务。已经做完的活不占未来产能—— 如果把历史任务也算进去,负荷率会随着生产推进一路涨到几百,这个指标就废了。

踩过的坑:负荷算的是"派工量"还是"计划量"

最初我们用工序的 PlannedQty 算负荷,结果发现: 50 件的总装工序已经派了 50 件,车间实际只做了 35 件, 但负荷显示 260%(因为张伟 30 + 李娜 20 + 工序计划 50 被重复计了)。

正确口径是"已派工单的 AssignedQty 之和"—— 派工是产能占用的唯一凭据。计划量是"要做多少",派工量才是"打算用多少产能做"。

验证:三条规则都要能被触发
curl
BASE=http://localhost:5100

# ① 超派 —— 工序 4(总装)已派满 50 件,再派 10 件应被拒绝
curl -s -X POST $BASE/api/scheduling/assignments -H "Content-Type: application/json" \
  -d '{"operationId":4,"employeeId":1,"qty":10}'
# 期望:{"message":"超派:工序计划 50 件,已派 50 件,本次最多还能派 0 件"}

# ② 故障设备 —— 综合测试台点检不合格已置为 Down
curl -s -X POST $BASE/api/scheduling/assignments -H "Content-Type: application/json" \
  -d '{"operationId":26,"employeeId":2,"equipmentId":4,"qty":5}'
# 期望:{"message":"设备「综合测试台」处于故障停机状态,不能派工"}

# ③ 看负荷 —— 总装线应该是最高的
curl -s $BASE/api/scheduling/load

注意 ② 里 operationId 要换成一个还没完工的工序, 否则会先撞上"工序已完工,不需要再派工"这条规则。

这个细节本身就是个教训:校验是有顺序的, 顺序错了,报错信息会把排查方向带偏(你会去查设备状态,实际问题是工序已完工)。

22

物料需求计算(MRP)

排产
目标:按 BOM 逐层展开,算出每张工单要备多少料、缺多少——并且让这个结果能存下来当凭据。
本课施工
起点知道要做什么、什么时候做,但不知道料够不够
新建Entities/MaterialRequirement.cs · Services/MrpService.cs · Controllers/MrpController.cs
改写Program.cs —— 注册 MrpService
迁移Add-Migration AddMaterialRequirement → Update-Database
VS 操作实体「添加 → 类」,服务目「添加 → 类」,控制器「添加 → 控制器(API 控制器 - 空)」
完成标志BOM 逐层展开后,缺料的行被标出来(净需求 > 0)
主线步骤S50
本课任务
  1. 实现递归 BOM 展开,用量要逐层累计(这是 MRP 最容易错的地方)
  2. 加上环路保护,让配错的 BOM 不会把服务撑爆
  3. 把结果落库成快照,并做多工单批量齐套检查

MRP 的难点不在算法,在细节

展开 BOM 的递归逻辑只有十几行,但真做起来会踩到四类坑。先看正确的算法:

C# · Services/MrpService.cs(递归展开部分)
/// <summary>BOM 最大展开层级 —— 超过这个深度基本可以断定是配置错误</summary>
private const int MaxLevel = 12;

// 累计器:物料 Id → 汇总需求
var accumulated = new Dictionary<int, Accum>();

// 从产品开始展开。path 记录"当前这条路径上已经走过哪些物料",
// 里面预置了产品本身,防止 A 用 B、B 又用回 A 的死循环。
Explore(wo.MaterialId, 1, wo.PlannedQty, new HashSet<int> { wo.MaterialId });

void Explore(int parentMaterialId, int level, decimal parentQty, HashSet<int> path)
{
    if (level > MaxLevel) return;
    if (!byParent.TryGetValue(parentMaterialId, out var children)) return;

    foreach (var line in children)
    {
        // 环路保护:这个子件已经在当前路径上,再展开就是死循环
        if (path.Contains(line.ChildMaterialId)) continue;

        // 毛需求 = 上层数量 × 本行单位用量 × (1 + 损耗率)
        var gross = parentQty * line.Quantity * (1 + line.LossRate / 100m);

        if (accumulated.TryGetValue(line.ChildMaterialId, out var exist))
        {
            // 同一个料被多个父件用到(比如钢板既在壳体又在定子),需求要相加
            exist.Gross += gross;
            exist.Level = Math.Min(exist.Level, level);
        }
        else
        {
            accumulated[line.ChildMaterialId] = new Accum
            {
                Gross = gross, Level = level, Material = line.ChildMaterial
            };
        }

        // 递归到下一层,把当前物料加进路径
        var childPath = new HashSet<int>(path) { line.ChildMaterialId };
        Explore(line.ChildMaterialId, level + 1, gross, childPath);
    }
}

四个坑,一个比一个隐蔽

坑 1:用量要逐层累计,不是只看自己那一行

1 台电机用 1 个定子,1 个定子用 85 米铜线。50 台电机要 4250 米铜线,不是 85 米。

漏了这一步,备出来的料会差几个数量级。 而且它不会报错——你只会看到采购下了一张明显偏小的订单,几周后车间停线才发现。 这是 MRP 最经典、也最贵的错误。

代码上的体现就是那句 Explore(..., gross, childPath): 传下去的 parentQty 是已经乘过的累计值,不是原始的 line.Quantity。

坑 2:损耗逐层放大,上层损耗会传导到下层

壳体用钢板,用量 1.2kg、损耗 5%;定子也用钢板,用量 0.3kg、损耗 5%。 30 台电机的钢板毛需求是:

壳体路径  30 × 1.2 × 1.05 = 37.80
定子路径  30 × 0.3 × 1.05 =  9.45
转子路径  30 × 0.5 × 1.05 = 15.75
                       合计 = 63.00 kg

注意这是三条路径相加的结果。如果你用"取最大值"或者"只算一次", 备料永远不够——现实里钢板会被切成三种零件,损耗是各自发生的,不会互相抵扣。

坑 3:没有环路保护,配错的 BOM 会把服务撑爆

BOM 表是自引用的,配上"A 用 B、B 用 A"这种环完全可能——甚至很常见, 因为半成品之间的结构在真实工厂里本来就错综复杂,人工维护时很容易配错。

没有路径记录,递归会无限下去,直到栈溢出。 而且更糟的是:它不会立刻崩,会在某个下游接口被调用时才崩, 排查方向会被完全带偏。

解法是 HashSet<int> path 按路径判断, 而不是用一个全局 visited 集合。两者有本质区别: 全局集合会把"同一个料被两个父件共用"也误判成环,导致需求算漏。

坑 4:结果不落库,三个月后无法解释

采购拿着"需要 63kg 钢板"去下单,三个月后财务问"当时为什么订这么多", 你现场重算一遍——但 BOM 可能已经被改过了,算出来是另一个数。

所以要把结果存成 MaterialRequirement 快照。 它是 MES 里很常见的一类表:计算结果的持久化。 重算时按 (WorkOrderId, MaterialId) 唯一索引覆盖,而不是追加—— 否则同一张工单算三次就有三份矛盾的数据。

净需求:从"要多少"到"还差多少"

// 毛需求 = 累计用量(含损耗)
// 净需求 = max(0, 毛需求 - 可用库存)
var net = Math.Max(0, acc.Gross - m.OnHandQty);
MES 里为什么会有库存字段

严格来说库存归 ERP / WMS 管,MES 不该自建库存账。但"这张工单的料够不够" 是车间每天都要问的问题,所以 MES 里通常保留一份从 ERP 同步过来的可用量快照, 只读、不参与出入库记账。

这个字段的定位要在代码注释里写清楚,否则半年后有人拿它做真正的库存管理, 就会和 ERP 的账目对不上——两个系统各记一本账是制造业信息化最经典的灾难。

验证:数字必须能手算出来
curl
# 对工单 2(30 台电机)做 MRP 展开
curl -s -X POST http://localhost:5100/api/mrp/2/calculate | python -m json.tool

对照检查:

物料层级毛需求算式
内六角螺栓1244.830 × 8 × 1.02
电机壳体13030 × 1 × 1.00
冷轧钢板263.037.8 + 9.45 + 15.75(三条路径相加)
漆包铜线22626.530 × 1 × 85 × 1.03

螺栓 244.8、钢板 63.0 —— 这两个数字能对上,你的 MRP 就是对的。 如果钢板算出来是 21 或 45,就是"漏路径"或"只算一层"的典型症状。

再做一次批量齐套检查,看看哪些料被多张工单抢用:

curl -s -X POST http://localhost:5100/api/mrp/batch \
  -H "Content-Type: application/json" -d '[1,2,3,4]'

返回里的 bottlenecks 就是真正的排产瓶颈: 单张工单缺料还能临时调货,多张工单抢同一个料才是需要提前决策的事。

STAGE 7

质量闭环

第 14 课做完了"判定合格与否",但那只是质量工作的一半。
这一阶段补上另一半:不合格的那些后来怎么样了,以及怎样在它变成废品之前就发现趋势。
23

不良品处置流程

质量
目标:把"这月不良 200 件"变成"这 140 件都是同一台设备造成的,对策是换夹具"。
本课施工
起点检验只告诉你"合不合格",不合格之后没人管
新建Entities/DefectRecord.cs · Services/DefectService.cs · Controllers/DefectsController.cs
改写Program.cs —— 注册 DefectService
迁移Add-Migration AddDefectRecord → Update-Database
VS 操作实体「添加 → 类」,服务「添加 → 类」,控制器「添加 → 控制器(API 控制器 - 空)」
完成标志关闭不良品时不填根因会被拒;每次登记都在追溯链里留痕
主线步骤S51
本课任务
  1. 建 DefectRecord,理解它和 QualityInspection 的分工
  2. 实现"不填根因和对策就不允许关闭"这道硬闸
  3. 走一遍完整流程:登记 → 处理 → 关闭,并在追溯链里看到它

检验和不良品,是两件事

QualityInspection 检验记录DefectRecord 不良品记录
回答什么这批合不合格?不合格的那些后来怎么样了?
本质一次判定一条流程
时间跨度瞬间完成可能持续几天(返工要排期)
状态合格 / 不合格 / 让步接收待处理 / 处理中 / 已关闭
关键字段送检数、合格数、不合格数处置方式、根因、纠正措施

把它们合成一张表的后果是:检验员判完"不合格"就走了, "谁来处理、怎么处理、原因是什么"永远没人填—— 因为那条记录在检验员眼里已经完结了。

C# · Entities/DefectRecord.cs
public class DefectRecord
{
    public int Id { get; set; }
    public string DefectNo { get; set; } = string.Empty;      // DF-20261014-0001

    public int WorkOrderId { get; set; }
    public WorkOrder WorkOrder { get; set; } = null!;
    public int WorkOrderOperationId { get; set; }
    public WorkOrderOperation Operation { get; set; } = null!;
    public int MaterialId { get; set; }
    public Material Material { get; set; } = null!;

    public decimal Qty { get; set; }
    public string DefectType { get; set; } = string.Empty;

    public DefectDisposition Disposition { get; set; }        // 返工/返修/报废/让步接收
    public DefectStatus Status { get; set; } = DefectStatus.Open;

    public int ReportedById { get; set; }                     // 发现人
    public Employee ReportedBy { get; set; } = null!;
    public int? HandlerId { get; set; }                       // 处置人
    public Employee? Handler { get; set; }

    public string? RootCause { get; set; }                    // 根因(5Why 的结论)
    public string? CounterMeasure { get; set; }               // 纠正措施

    public DateTime FoundAt { get; set; } = DateTime.Now;
    public DateTime? ClosedAt { get; set; }
}

/// <summary>处置方式 —— 决定这批不良品的去向,也决定它算不算报废</summary>
public enum DefectDisposition
{
    Rework = 0,       // 返工:拆开重做,回原工序
    Repair = 1,       // 返修:修补后可用,不必回原工序
    Scrap = 2,        // 报废:彻底损失,计入报废率
    Concession = 3    // 让步接收:不合格但放行,必须留下审批证据
}

public enum DefectStatus { Open = 0, Handling = 1, Closed = 2 }
四种处置方式的业务含义,比代码重要
  • 返工——回原工序重做,成本和"从头做一遍"差不多,是最贵的一种
  • 返修——原地修补,比返工便宜,但要重新检验
  • 报废——唯一真正计入报废损失的一种,其他三种都不该算进报废率
  • 让步接收——有风险的一种,也是最需要留痕的一种: 明知不合格还放行给客户,将来出事必须能查到是谁批的、依据是什么

很多 MES 把"让步接收"做成一个免费选项,结果它被滥用成"避免报废数字难看"的工具。 正确做法是给它加上审批环节(比如只有班组长以上才能让步接收)。

关闭的硬闸:不填根因就不许关

C# · Services/DefectService.cs(关闭校验)
if (d.Status == DefectStatus.Closed)
    throw new InvalidOperationException($"{d.DefectNo} 已关闭,不能重复关闭");

if (string.IsNullOrWhiteSpace(req.RootCause))
    throw new InvalidOperationException("关闭前必须填写根因分析(RootCause)");

if (string.IsNullOrWhiteSpace(req.CounterMeasure))
    throw new InvalidOperationException("关闭前必须填写纠正措施(CounterMeasure)");
这两行校验,是整个质量模块存在的理由

如果不强制填,会发生什么?所有人都会直接点"关闭", 半年后你的质量数据库里有 3000 条不良记录,没有一条有原因。 这套系统就退化成了一个计数器——它能告诉你"有多少不良", 但永远回答不了"为什么会有不良",而后者才是值钱的。

这就是所谓的用流程逼出信息: 把"填根因"和"关闭"绑成一件事,让人不得不想清楚。 编码规范、代码评审、事故复盘,本质上都是同一个套路。

不良品登记必须进追溯链

// 不良品登记必须进追溯链 —— 客户投诉时这条记录是关键证据
_db.WipTraces.Add(new WipTrace
{
    WorkOrderId = op.WorkOrderId,
    WorkOrderOperationId = op.Id,
    Action = "不良登记",
    Qty = req.Qty,
    EmployeeId = reporterId,
    WorkCenterId = op.WorkCenterId,
    Detail = $"「{op.Name}」{req.DefectType} × {req.Qty}," +
             $"处置方式:{DispositionText(req.Disposition)}",
    HappenedAt = DateTime.Now
});

注意这里写的是 WipTrace 而不是只写 DefectRecord。 两者服务的场景不同:追溯链是"按时间顺序看这批货经历了什么", 不良品表是"按流程状态看待办事项"。同一件事在两边都留痕,才能既回答客户又驱动内部处理。

验证:完整走一遍,并测试硬闸
curl
BASE=http://localhost:5100

# ① 登记一条不良品(工序 1 下料,板材划伤 2 件,处置=返工)
curl -s -X POST $BASE/api/defects -H "Content-Type: application/json" \
  -d '{"operationId":1,"qty":2,"defectType":"板材划伤","disposition":0,
       "notes":"来料表面有划痕","reportedById":4}'
# 期望:{"message":"不良品已登记","defectNo":"DF-...(新单号)"}

# ② 不填根因就关闭 —— 应被拒绝
curl -s -X POST $BASE/api/defects/2/close -H "Content-Type: application/json" \
  -d '{"rootCause":"","counterMeasure":"加强巡检"}'
# 期望:{"message":"关闭前必须填写根因分析(RootCause)"}

# ③ 填完整再关闭 —— 应成功
curl -s -X POST $BASE/api/defects/2/close -H "Content-Type: application/json" \
  -d '{"rootCause":"螺栓来料批次螺纹公差偏大","counterMeasure":"更换供应商批次并加严来料抽检"}'

# ④ 确认它进了追溯链
curl -s $BASE/api/work-orders/1/trace | grep -o '"action":"[^"]*"' | sort -u

第 ④ 步应该能看到 "action":"不良登记" 和 "action":"不良关闭" 两种记录混在报工记录里,按时间排列。

24

SPC 控制图

质量
目标:用统计方法判断"过程是不是失控了"——在它变成一批废品之前。
本课施工
起点合格率只告诉你"结果好不好",不告诉你"什么时候开始不对"
新建Services/AnalyticsService.cs —— 加 SpcAsync
改写生产统计控制器 —— 加一个暴露 SPC 结果的动作
迁移(无,本课不动实体)
VS 操作服务类名 AnalyticsService 用「添加 → 类」创建(如果还没有),控制器动作加在已有控制器里
完成标志接口返回每组的均值 / UCL / LCL,越界点被单独标出来
主线步骤S52
本课任务
  1. 理解中心线 ±3σ 为什么是"报警线"
  2. 算出均值、样本标准差、控制上下限
  3. 学会区分"正常波动"和"特殊原因"——这是 SPC 的全部价值

合格率和 SPC,差在"早一步"

假设你这周每天的合格率是:99.5%、99.3%、99.4%、98.1%、99.2%。 合格率指标告诉你"还不错"。

但 SPC 会告诉你:98.1% 那一天是异常的。 它落在正常波动范围之外,说明有特殊原因介入了(换了料?设备参数漂了?新人上手?)。

这就是 SPC 的价值:它管的是趋势,比合格率这种"结果指标"早一步报警。 等合格率跌破目标再动手,通常已经废了一批。

算法本身很简单

C# · Services/AnalyticsService.cs(SPC 部分)
var rates = valid.Select(d => d.PassRate).ToList();
var mean = rates.Average();

// 样本标准差(除以 n-1),比总体标准差更适合小样本
var variance = rates.Sum(r => (r - mean) * (r - mean)) / (rates.Count - 1);
var sigma = (decimal)Math.Sqrt((double)variance);

var ucl = Math.Round(mean + 3 * sigma, 2);          // 上控制限
var lcl = Math.Round(Math.Max(0, mean - 3 * sigma), 2);  // 下控制限

var points = valid.Select(d =>
{
    var outOfControl = d.PassRate > ucl || d.PassRate < lcl;
    return new SpcPointDto(d.Date, d.PassRate, Math.Round(mean, 2), ucl, lcl,
        outOfControl,
        outOfControl ? (d.PassRate < lcl ? "低于下控制限:过程异常,需查原因" : "高于上控制限:检查数据口径") : null);
}).ToList();
为什么偏偏是 3σ

如果过程是稳定的,数据服从正态分布,落在 μ ± 3σ 内的概率是 99.73%。

反过来说,一个点跑出这个范围的概率只有 0.27%。 这么小概率的事发生了,更合理的解释是"过程变了",而不是"今天运气不好"。

这就是休哈特控制图的全部思想: 用统计规律区分"随机波动"(不用管)和"特殊原因"(必须查)。

工程上常用 3σ;追求更高灵敏度会用 2σ(但误报率随之升到 5%)。 选几个 σ,本质上是在"漏报"和"误报"之间选一个代价更低的点。

三个实现细节

  1. 只取有产出的天参与计算。没开工的天合格率是 0, 如果算进去,均值会被拉低一大截,控制限完全失效。 代码里是这句 var valid = daily.Where(d => d.TotalQty > 0)。
  2. 用样本标准差(除以 n-1)而不是总体标准差(除以 n)。 在统计上,样本标准差是对总体标准差的无偏估计。样本量小的时候差别很明显, 而且工业界的 SPC 规范用的就是 n-1。
  3. 下控制限不能小于 0。合格率不可能为负, Math.Max(0, ...) 是为了避免画出一条在 0 以下的无意义控制线。 上控制限同理——超过 100% 的 UCL 说明数据波动太小或者样本不足,要提示用户。
样本不足时必须拒绝画图

少于 2 个数据点算不出标准差;少于 20 个点建立的控制限基本没有统计意义。

这时代码应该明确说"样本不足", 而不是硬算一个数出来。一条建立在 3 个数据点上的控制线, 比没有控制线更危险——它会让人产生"过程受控"的错觉。

这是所有数据产品的通用原则:宁可显示"数据不足",也不要显示一个没有意义的数字。

验证
curl
curl -s "http://localhost:5100/api/reports/spc?days=30" | python -m json.tool

种子数据里,每隔四天停一天产(制造产量低谷),并且约三分之一的批次有 1 件报废, 所以合格率曲线天然带波动。你应该能看到:

  • centerLine ≈ 99.7%(大部分天是 100%,个别天 97–99%)
  • outOfControlCount ≥ 1,且失控点指向合格率最低的那天
  • interpretation 是一句能直接读的结论,不是一串数字

把 days 改成 1 试试——应该返回"样本不足"而不是一个乱算的控制限。 这条边界比正常路径更值得测。

STAGE 8

设备效率

同一台机器,有人用出 94% 的效率,有人用出 67%。
差别在哪、该先改哪一项?OEE 就是回答这个问题的标准工具。
25

设备综合效率 OEE

设备
目标:算出设备的 OEE 三项分解,并指出最大的那块损失在哪。
本课施工
起点设备开没开、开得好不好,全靠人报,没有统一口径
新建Services/OeeService.cs · Controllers/OeeController.cs
改写Program.cs —— 注册 OeeService
迁移(无,本课不动实体)
VS 操作服务「添加 → 类」,控制器「添加 → 控制器(API 控制器 - 空)」
完成标志每台设备返回三率乘积 = OEE,并带一句损失归因(建议先改哪一项)
主线步骤S53
本课任务
  1. 理解 OEE 三个分项各自在回答什么问题
  2. 用"理论加工时间 ÷ 实际加工时间"算性能开动率(不是用台数比)
  3. 处理一个真实的建模陷阱:统计窗口不一致

OEE = 时间开动率 × 性能开动率 × 合格率

分项公式它在问什么损失来源
时间开动率 运行时间 ÷ (运行 + 停机) 设备有没有在转? 故障、换型、等料、等指令
性能开动率 理论加工时间 ÷ 实际加工时间 转得快不快? 小停顿、降速运行、空转
合格率 合格数 ÷ 总产出 做出来的是好东西吗? 废品、返工、试机件
为什么是乘法而不是加法

因为任何一项为零,整体就是零。

设备一直转(开动率 100%)但全做废了(合格率 0),OEE 就是 0。 这个数学性质正好对应制造的本质:同时满足三个条件才算有效产出。

如果用加法(三项平均),一台"转得飞快但全是废品"的设备会得到 67 分, 看起来比一台"转得慢但件件合格"的设备还差不了多少——这显然是荒谬的。

行业基准:85% 世界级 · 60% 合格线 · 40% 以下有大量浪费。

核心代码

C# · Services/OeeService.cs
// ---------- 1. 时间开动率 ----------
var totalMinutes = eq.RunningMinutes + eq.DownMinutes;
var availability = totalMinutes <= 0
    ? 0m
    : Math.Round((decimal)eq.RunningMinutes / totalMinutes, 4);

// ---------- 2. 性能开动率 ----------
// 处理数量含报废 —— 报废品也是"做出来的",占用的是加工时间
var processed = wcReports.Sum(r => r.Qty + r.ScrapQty);

var theoryMinutes = wcReports.Sum(r => (r.Qty + r.ScrapQty) * r.Operation.StandardMinutes);
var actualMinutes = wcReports
    .Sum(r => (decimal)(r.EndedAt!.Value - r.StartedAt!.Value).TotalMinutes);

var rawPerformance = actualMinutes <= 0
    ? 0m
    : Math.Round(theoryMinutes / actualMinutes, 4);

// 封顶 100% 用于 OEE 合成 —— OEE 是三个"率"的乘积,超过 100% 在数学上不成立。
// 但原始值要保留:它 >105% 通常说明标准工时定得太松。
var performance = Math.Min(rawPerformance, 1m);
var suspicious = rawPerformance > 1.05m;

// ---------- 3. 合格率 ----------
var scrap = wcReports.Sum(r => r.ScrapQty);
var good = wcReports.Sum(r => r.Qty);
var totalOutput = good + scrap;
var quality = totalOutput <= 0 ? 0m : Math.Round(good / totalOutput, 4);

// ---------- 4. 合成 ----------
var oee = Math.Round(availability * performance * quality, 4);
我第一版写错了:性能开动率不能用"台数比"

第一版我写的是 性能 = 实际产量 ÷ 理论产量, 其中 理论产量 = 运行时间 ÷ 单件标准工时。

结果四台设备的性能开动率全部顶到 100%,一项都没区分度。 原因很简单:标准工时是 25 分钟/件,而实际 8 小时做了 35 件(约 13.7 分钟/件)—— 实际速度是标准的两倍,比值当然一直溢出。

换成教科书公式 理论加工时间 ÷ 实际加工时间 之后, 四台设备分别算出 105% / 92% / 94% / 87%,区分度立刻就出来了。

教训:指标算出来"全是一个值",几乎总是公式口径错了,而不是数据太平。 先怀疑公式,再怀疑数据。

更深的一个坑:统计窗口不一致

注意上面三项用的是不同来源的数据:

  • 时间开动率来自设备台账的 RunningMinutes / DownMinutes
  • 性能与合格率来自报工记录

如果前者说的是"今天",后者说的是"全部历史",这三项就没法相乘—— 你算的不是同一台设备在同一段时间里的表现。

解法是明确一个统计窗口(本项目取最近 30 天), 让所有分项都落在同一个区间内。真实项目里这个窗口应该可配置, 并且要和设备数据采集的周期对齐。

凡是"多个比值相乘"的指标,第一步就是检查它们的窗口一不一致。 这一条适用于 OEE,也适用于人效、转化率、库存周转等一大票复合指标。

比数字更重要的:告诉人先改哪一项

// 三项各损失多少。数值最大的那一项就是改善优先级最高的方向
var losses = new (string Name, decimal Loss, string Advice)[]
{
    ("时间损失", Math.Round((1 - oee.Availability) * 100, 1), "查故障停机与换型等待,减少非计划停机"),
    ("速度损失", Math.Round((1 - oee.Performance) * 100, 1), "查小停顿与降速运行;若原始性能率超 105%,核对标准工时是否偏松"),
    ("质量损失", Math.Round((1 - oee.Quality) * 100, 1), "查废品与返工,从不良类型找根因")
};

var worst = losses.OrderByDescending(l => l.Loss).First();

一个 67% 的 OEE 本身不构成行动。但如果系统告诉你 "时间损失 22% 是最大的一项,优先查非计划停机", 这就变成了排产员和设备主管第二天可以去干的事。

报表的终点不是数字,是动作。每个指标都应该能回答"所以我该做什么"。

验证
curl
# 全部设备的 OEE 拆解
curl -s http://localhost:5100/api/oee

# 车间汇总
curl -s http://localhost:5100/api/oee/summary

# 单台设备,带"最大损失在哪"
curl -s http://localhost:5100/api/oee/3

种子数据的预期结果(四台设备应该给出有区分度的数字):

设备开动率性能合格率OEE判定
总装线 A 主线95.2%100%(原始 105%)98.7%94.0%世界级
数控加工中心 1 号88.9%92.1%100%81.9%良好
综合测试台55.6%93.9%100%52.2%一般
自动绕线机77.8%86.8%100%67.5%良好

注意总装线那台的 standardSuspicious 应该是 true, note 里会提示"标准工时可能定得偏松"。 维修中的综合测试台开动率最低,和它"点检不合格已停用"的状态是一致的—— 数据之间能自圆其说,才说明模型是对的。

STAGE 9

系统治理

MES 直接指挥生产,任何误操作都有真实成本。
这一阶段解决"谁有权做"和"谁做了什么"——没有这两样,系统就不能上线。
26

用户、角色与权限

治理
目标:建一套能落地的权限体系,并知道"前端隐藏按钮"和"后端拦截"分别管什么。
本课施工
起点系统谁都能改,出事查不到责任人
新建Entities/AppUser.cs · Entities/Employee.cs · Services/TokenService.cs · Services/AuthService.cs · Services/AccessControl.cs · Controllers/AuthController.cs · Controllers/EmployeesController.cs
改写Program.cs —— 注册三个服务并挂认证中间件;wwwroot/index.html —— 改成按后端配置动态显示登录态
迁移Add-Migration AddAuth → Update-Database
VS 操作七个文件全部用「添加 → 类 / 控制器」建;前端改造时不要写死"后端没开权限",要从 /api/auth/me 的 enforcePermission 字段推导
完成标志把 Mes:EnforcePermission 改成 true 后,未登录只能看、不能改;密码在库里是哈希串
主线步骤S54 – S57
本课任务
  1. 分清 Employee 和 AppUser,理解为什么要分开建表
  2. 实现 PBKDF2 加盐哈希,以及登录令牌的签发与校验
  3. 把权限判断集中到一处,而不是散落在 20 个 Controller 里

第一件事:员工 ≠ 账号

Employee 员工AppUser 账号
是什么编制上的人系统里的登录身份
数量车间 300 个操作工都有只有需要系统的人才开
关系一对零或一:账号可能对应一个员工,但绝大多数员工不需要账号

如果合成一张表,你就得给 300 个操作工每人建一个账号, 然后面对"这些人从来没登录过"的困惑,还要为每个人编密码。

C# · Entities/AppUser.cs
public class AppUser
{
    public int Id { get; set; }
    public string UserName { get; set; } = string.Empty;      // 登录名
    public string DisplayName { get; set; } = string.Empty;   // 中文名

    /// <summary>PBKDF2 哈希,格式:迭代次数.盐(Base64).哈希(Base64)</summary>
    public string PasswordHash { get; set; } = string.Empty;

    public UserRole Role { get; set; } = UserRole.Operator;

    /// <summary>关联的员工,用于把报工记录落到真人身上</summary>
    public int? EmployeeId { get; set; }
    public Employee? Employee { get; set; }

    public bool IsActive { get; set; } = true;
    public DateTime? LastLoginAt { get; set; }

    // 权限判断集中在这里,不要散到各个 Controller
    public bool CanReleaseWorkOrder => Role is UserRole.Planner or UserRole.Leader or UserRole.Admin;
    public bool CanReportProduction => Role is UserRole.Operator or UserRole.Leader or UserRole.Admin;
    public bool CanInspect          => Role is UserRole.Inspector or UserRole.Leader or UserRole.Admin;
    public bool CanAssign           => Role is UserRole.Leader or UserRole.Planner or UserRole.Admin;
    public bool CanViewAudit        => Role is UserRole.Admin;
}

public enum UserRole
{
    Operator = 0,    // 操作工:只能报工
    Inspector = 1,   // 质检员:检验、判不良
    Leader = 2,      // 班组长:派工、报工、检验
    Planner = 3,     // 计划员:建单、下达、排产
    Admin = 9        // 管理员:全部 + 审计
}
为什么角色用显式赋值 0 / 1 / 2 / 3 / 9

如果让枚举默认递增(0,1,2,3,4),以后想在"班组长"和"计划员"之间插一个"设备主管", 就得重新编号,而数据库里存的旧值全部错位——这是不可逆的数据事故。

留出间隔(0,1,2,3,9)之后,插入新角色只需要在两者之间取一个没被占用的数。 凡是会被持久化的枚举,留间隔是基本纪律。 工序序号 10/20/30 也是同一个道理。

密码:三条不能妥协的底线

C# · Services/AuthService.cs
private const int Iterations = 100_000;

/// <summary>PBKDF2-SHA256 加盐哈希,格式:迭代次数.盐(Base64).哈希(Base64)</summary>
public static string HashPassword(string password)
{
    var salt = RandomNumberGenerator.GetBytes(16);
    var hash = Rfc2898DeriveBytes.Pbkdf2(
        password, salt, Iterations, HashAlgorithmName.SHA256, 32);

    return $"{Iterations}.{Convert.ToBase64String(salt)}.{Convert.ToBase64String(hash)}";
}

/// <summary>
/// 校验密码。用固定时间比较(FixedTimeEquals)而不是 ==,
/// 避免通过响应时间差反推密码 —— 这类细节是安全编码的基本功。
/// </summary>
public static bool VerifyPassword(string password, string stored)
{
    var parts = stored.Split('.');
    if (parts.Length != 3) return false;
    if (!int.TryParse(parts[0], out var iterations)) return false;

    try
    {
        var salt = Convert.FromBase64String(parts[1]);
        var expected = Convert.FromBase64String(parts[2]);

        var actual = Rfc2898DeriveBytes.Pbkdf2(
            password, salt, iterations, HashAlgorithmName.SHA256, expected.Length);

        return CryptographicOperations.FixedTimeEquals(actual, expected);
    }
    catch { return false; }
}
三条底线
  1. 只存哈希,永不存明文,也永不回传。 连"显示密码"这种功能都不该有——管理员也不能看到用户的密码,只能重置。
  2. 账号不存在和密码错误返回同一个错误信息。 如果分开提示,攻击者可以用它枚举出哪些账号真实存在。代码里就是这么写的: if (user is null || !user.IsActive || !VerifyPassword(...)) 三个条件合并成一个判断。
  3. 登录成功和失败都要写审计日志。 暴力破解在日志里是看得见的——同一个账号短时间内大量失败, 这是最典型的入侵信号。没有日志,你连被打了都不知道。

令牌:手写一遍,是为了以后知道该看哪一层

真实的项目请直接用 ASP.NET Core 官方的 JWT 中间件。这里手写一遍, 是为了让你看清"令牌到底是什么":

/// <summary>签发令牌</summary>
public string Create(AppUser user)
{
    var exp = DateTimeOffset.UtcNow.Add(Lifetime).ToUnixTimeSeconds();
    var payload = $"{user.Id}|{exp}";        // ① 载荷:用户 Id + 过期时间
    var sig = Sign(payload);                 // ② 签名:HMAC-SHA256 取前 16 字节
    var raw = $"{payload}|{sig}";
    return Convert.ToBase64String(Encoding.UTF8.GetBytes(raw))
        .Replace('+', '-').Replace('/', '_').TrimEnd('=');   // ③ Base64Url 编码
}

三部分:载荷 + 签名 + 编码。 签名的作用是防篡改——用户改不了自己的 Id,因为改完签名就对不上。

令牌里不放密码、不放敏感权限,只放一个能查到用户的 Id。 权限每次都从数据库读,这样管理员改了角色,下一次请求就立刻生效, 而不用等令牌过期。

权限校验:一处定义,处处调用

C# · Services/AccessControl.cs
public class AccessControl
{
    private readonly bool _enforce;
    private readonly AuditService _audit;

    public AccessControl(IConfiguration cfg, AuditService audit)
    {
        _enforce = cfg.GetValue("Mes:EnforcePermission", false);
        _audit = audit;
    }

    public AppUser? User => _audit.CurrentUser;

    /// <summary>
    /// 要求当前用户满足某个权限条件。
    /// 演示模式(enforce=false)下直接放行,但仍会把操作人记成匿名。
    /// </summary>
    public void Require(Func<AppUser, bool> predicate, string what)
    {
        if (!_enforce) return;
        AuthService.Require(User, predicate, what);
    }
}
// Controller 里的用法 —— 意图一眼可见
[HttpPost("assignments")]
public async Task<ActionResult> Assign(AssignRequest req)
{
    _access.Require(u => u.CanAssign, "派工");
    var a = await _service.AssignAsync(req);
    return Ok(new { message = "派工成功", assignmentNo = a.AssignmentNo });
}
演示模式:一个教学项目该有的取舍

这是个教学项目,本地打开就该能点。如果一上来所有写操作都要求登录, 你还没体会到业务逻辑就先被 401 挡住了。

所以默认 EnforcePermission = false: 不拦截,但审计日志里如实记成 anonymous(不留假证据)。 把 appsettings 里这一项改成 true,整套权限立刻生效。

注意这个开关的存在意义是教学便利,不是给生产留后门。 真实项目里不要留这种东西——它迟早会以"生产忘了改配置"的形式爆炸。

前端别写死后端的状态

后端有 EnforcePermission 这个开关,前端就得跟着它走。 /api/auth/me 除了返回 authenticated, 还返回 enforcePermission —— 它回答的是另一个问题: "这个后端到底开没开强制鉴权"。

JavaScript · wwwroot/index.html
let ENFORCED = false;   // 后端是否强制鉴权 —— 由接口决定,绝不写死

// 启动时探测一次。即使本地没有令牌也要探:
// 不知道后端开没开鉴权,就不知道该不该提示"未登录只能查看"。
async function restoreSession() {
  try {
    const me = await api("/api/auth/me");
    ENFORCED = !!me.enforcePermission;
    if (me.authenticated) { CURRENT_USER = me.user; }
    else if (TOKEN) { TOKEN = ""; localStorage.removeItem("mes_token"); }
  } catch (e) { TOKEN = ""; localStorage.removeItem("mes_token"); }
  renderUserChip();
  renderLoginHint();      // 登录弹窗底部的说明也跟着变
}

// 界面上所有跟鉴权有关的文案,都从 ENFORCED 派生
box.innerHTML = ENFORCED ? "未登录 · 只能查看" : "未登录";
为什么不能写死

我第一版就在这里踩了坑:登录弹窗底部写的是 "当前后端 EnforcePermission = false,不登录也能操作"。 本地跑着没问题——但同一份前端上了生产(true), 这句话就开始骗人了:用户照着提示以为能直接操作, 每点一次被拦一次,只会觉得这系统坏了。

同一份前端要跑在两种配置上,它就必须 从后端读状态,而不是假设状态。 /api/auth/me 在匿名时返回 {"authenticated":false} 而不是 401, 正是为了这个场景:没登录也能问出"你到底要不要我登录"。

这不是 MES 特有的问题。凡是"同一份前端、多套后端配置"的地方都有: 测试环境开了假登录、生产接了真 SSO,前端却写死了测试环境才成立的文案。 凡是随环境变化的东西,写死在代码里就一定会错。

验证
curl
BASE=http://localhost:5100

# ① 登录(种子数据预设了 5 个账号,密码统一 mes@2026)
curl -s -X POST $BASE/api/auth/login -H "Content-Type: application/json" \
  -d '{"userName":"planner","password":"mes@2026"}'
# 期望:返回 token + roleText="计划员" + permissions 数组

# ② 错误密码 —— 提示必须和"账号不存在"完全一样
curl -s -X POST $BASE/api/auth/login -H "Content-Type: application/json" \
  -d '{"userName":"planner","password":"wrong"}'
curl -s -X POST $BASE/api/auth/login -H "Content-Type: application/json" \
  -d '{"userName":"nobody","password":"wrong"}'
# 两条返回的 message 都必须是"用户名或密码错误"

# ③ 带令牌查自己
TOKEN=$(curl -s -X POST $BASE/api/auth/login -H "Content-Type: application/json" \
  -d '{"userName":"planner","password":"mes@2026"}' | python -c "import sys,json;print(json.load(sys.stdin)['token'])")
curl -s $BASE/api/auth/me -H "Authorization: Bearer $TOKEN"

种子数据里的 5 个账号:admin(管理员)、 planner(计划员)、leader(班组长)、 zhangwei(操作工)、zhaomin(质检员)。

想验证权限拦截,把 appsettings.json 里的 EnforcePermission 改成 true 重启, 然后用 zhangwei 的令牌去调派工接口——应该返回 "当前角色「操作工」无权派工"。

27

审计日志

治理
目标:能回答"这张工单是谁作废的、什么时候、改之前是什么状态"。
本课施工
起点有了登录,但"谁改了这条数据"仍然查不出来
新建Entities/AuditLog.cs · Services/AuditService.cs · Controllers/AuditController.cs
改写各业务服务 —— 在同一事务里写审计(业务回滚,审计也必须回滚)
迁移Add-Migration AddAuditLog → Update-Database
VS 操作实体「添加 → 类」,服务「添加 → 类」,控制器「添加 → 控制器(API 控制器 - 空)」
完成标志做一次业务操作,AuditLogs 同步多一行;业务失败时审计不该有记录
主线步骤S58
本课任务
  1. 建 AuditLog,理解它和 WipTrace 的区别
  2. 把审计写入嵌进已有的服务,让业务数据和日志在同一个事务里提交
  3. 做审计汇总,学会从日志里看出异常信号

两本账,各管一半

WipTrace 追溯链AuditLog 审计日志
记的是产品的履历人的履历
典型问题这批货经历了哪些工序、谁做的?这张工单是谁作废的?
谁会查客户投诉时、质量分析时内部追责时、合规审计时
放在前面的是工单 / 工序操作人 / 角色 / IP
记失败操作吗不记(没发生的事没履历)记(试图越权本身是信号)
失败的尝试必须留痕

这是审计日志和普通操作日志最大的区别。

"操作工试图作废一张在产的工单"——这件事没成功, 所以追溯链里什么都没有。但它发生了,而且是个值得追问的信号: 是误操作?是培训不足?还是有别的动机?

把 Succeeded = false 的记录漏掉, 你的审计系统就只能看到"成功的坏人",那是最坏的一种审计。

关键设计:审计不单独提交

C# · Services/AuditService.cs
/// <summary>
/// 追加一条审计记录(不提交,由调用方决定事务边界)
/// </summary>
public void Write(
    string action, string targetType, string targetId,
    string? before = null, string? after = null,
    bool succeeded = true, string? message = null)
{
    var user = CurrentUser;
    var ctx = _http.HttpContext;

    _db.AuditLogs.Add(new AuditLog
    {
        UserName = user?.UserName ?? "anonymous",
        Role = user is null ? "匿名" : TokenService.RoleText(user.Role),
        Action = action,
        TargetType = targetType,
        TargetId = targetId,
        BeforeValue = Truncate(before),
        AfterValue = Truncate(after),
        IpAddress = ctx?.Connection.RemoteIpAddress?.ToString(),
        Succeeded = succeeded,
        Message = Truncate(message),
        HappenedAt = DateTime.Now
    });
}

它不调用 SaveChanges。业务代码这样用:

_audit.Write("状态流转", "WorkOrder", wo.OrderNo,
    before: StatusText(from), after: StatusText(target), message: remark);

await _db.SaveChangesAsync();   // 业务数据 + 审计日志一起提交
这个设计避免了一类最难查的 bug

如果审计自己 SaveChanges,就会出现两种坏情况:

  • 日志先写、业务后失败 → 日志里有一笔没发生过的操作, 追责时你百口莫辩
  • 业务先写、日志后失败 → 操作发生了但没留痕, 审计系统出现盲区,而且是随机的

跟着业务数据走同一个事务, 才能保证"要么都记下、要么都不记"。 这和第 12 课讲的报工的事务边界是同一个道理。

从日志里看异常信号

日志本身不是目的,能从中发现问题才是。三个值得盯的信号:

信号可能意味着
同一账号大量失败记录暴力破解,或者权限配置不对导致正常操作被拦
夜间操作(22:00 ~ 06:00)车间不该有人操作系统。要么是时区问题,要么是异常行为
anonymous 占比升高匿名操作变多了——说明鉴权可能被绕过,或者没人愿意登录
// 夜间操作(22:00 ~ 06:00):生产车间不该有系统操作,值得看一眼
var nightOps = logs.Count(a => a.HappenedAt.Hour >= 22 || a.HappenedAt.Hour < 6);
审计日志是只增不改的表

和 WipTrace 一样:账本表不建外键、不跟随主数据变动、只追加。

更进一步的做法是禁止 UPDATE 和 DELETE—— 在数据库层面收回对这张表的修改权限;再过一层的做法是同步到独立的日志系统。

原因很简单:能被修改的日志就失去了审计意义。 如果操作人有机会删掉自己的记录,这份日志在所有争议场景下的证明力都是零。

验证
curl
BASE=http://localhost:5100

# ① 先做一次会被拒绝的操作,制造一条"失败"日志
curl -s -X POST $BASE/api/work-orders/1/transit -H "Content-Type: application/json" \
  -d '{"target":5}'
# 期望:{"message":"不允许从「生产中」直接跳到「关闭」..."}

# ② 看审计汇总
curl -s "$BASE/api/audit/summary?days=7"

# ③ 只看失败记录
curl -s "$BASE/api/audit?onlyFailed=true"

# ④ 看某张工单的完整操作历史
curl -s "$BASE/api/audit/trace/WorkOrder/$(curl -s $BASE/api/work-orders/1 | python -c "import sys,json;print(json.load(sys.stdin)['orderNo'])")"

第 ① 步之后,第 ③ 步应该能查到一条 succeeded = false 的记录, message 里是完整的拒绝原因。 这条记录的价值,在于它证明有人(或某个程序)尝试过。

第 ④ 步返回的是"这张工单被谁动过"的时间线—— 把它和追溯链(/api/work-orders/1/trace)对比着看, 你会更清楚"产品视角"和"人的视角"这两本账的区别。

28

报表聚合与 CSV 导出

治理
目标:把流水账变成能决策的数字,并且能导出让别人也能看。
本课施工
起点数据都在,但没人能用它做决定——导不出、看不出趋势
新建Controllers/ReportsController.cs
改写AnalyticsService —— 加日期补齐、单件工时分母、CSV 工具三段
迁移(无,本课不动实体)
VS 操作控制器「添加 → 控制器(API 控制器 - 空)」;三段算法直接加进已有的 AnalyticsService.cs
完成标志六张报表都能出数;导出的 CSV 用 Excel 打开中文不乱码、日期无空洞
主线步骤S59
本课任务
  1. 实现按天产量、工序节拍、员工绩效三种聚合
  2. 处理两个容易算错的细节:日期补齐、单件工时的分母
  3. 写出 Excel 能正常打开的中文 CSV

细节一:日期必须补齐

最直接的写法是 GROUP BY 日期,只返回有数据的天。这会带来一个隐蔽的坑:

有数据的天:10-01, 10-02, 10-04, 10-05
图表的 X 轴:10-01, 10-02, 10-04, 10-05   ← 10-03 消失了

看起来产量很平稳,实际是 10-03 那天停产了。 数据可视化里最容易犯、也最难被发现的错误,就是让"没有数据"变成"看不见"。

C# · Services/AnalyticsService.cs(日期补齐)
var byDay = reports
    .GroupBy(r => r.ReportedAt.Date)
    .ToDictionary(g => g.Key, g => g.ToList());

var list = new List<DailyOutputDto>(span);

// 强制遍历区间内的每一天 —— 没有数据的天补 0,而不是跳过
for (var i = 0; i < span; i++)
{
    var day = from.AddDays(i);
    var rs = byDay.TryGetValue(day, out var v) ? v : [];

    var passed = rs.Sum(r => r.Qty);
    var scrap = rs.Sum(r => r.ScrapQty);
    var total = passed + scrap;

    list.Add(new DailyOutputDto(
        day.ToString("yyyy-MM-dd"), passed, scrap, total,
        total <= 0 ? 0m : Math.Round(passed / total * 100, 2),
        rs.Count));
}

细节二:单件工时的分母是什么

"平均每次耗时"是没有意义的数字

假设两条报工记录:

  • 做了 50 件,用时 100 分钟
  • 做了 5 件,用时 10 分钟

"平均每次耗时" = (100 + 10) ÷ 2 = 55 分钟。 但这两次的实际单件工时都是 2 分钟,效率完全一样。

正确算法是先算每次的单件工时,再取平均: 实际单件 = 本次耗时 ÷ 本次数量。

这类"分母选错"的错误在报表里极其常见—— 和 OEE 那一课的性能开动率是同一类问题。 看到一个奇怪的数字,第一反应永远是"分母对不对"。

// 只有记录了开始/结束时间、且数量大于 0 的报工才能算节拍
var timed = sample
    .Where(r => r.StartedAt.HasValue && r.EndedAt.HasValue && r.Qty > 0)
    .Select(r => new
    {
        PerPiece = (decimal)(r.EndedAt!.Value - r.StartedAt!.Value).TotalMinutes / r.Qty,
        Std = r.Operation.StandardMinutes
    })
    .Where(x => x.PerPiece > 0)
    .ToList();

var actualAvg = Math.Round(timed.Average(x => x.PerPiece), 2);

// 效率 = 标准 / 实际。大于 100% 表示比标准快
var efficiency = Math.Round(std / actualAvg * 100, 1);

细节三:CSV 的三个实用要点

C# · Services/AnalyticsService.cs(CSV 工具)
/// <summary>CSV 字段转义:含逗号、引号、换行时用双引号包裹并翻倍内部引号</summary>
private static string Esc(string? v)
{
    if (string.IsNullOrEmpty(v)) return "";
    return v.Contains(',') || v.Contains('"') || v.Contains('\n') || v.Contains('\r')
        ? "\"" + v.Replace("\"", "\"\"") + "\""
        : v;
}

/// <summary>转成带 BOM 的 UTF-8 字节 —— 少这个 BOM,Excel 打开就是乱码</summary>
private static byte[] ToBytes(StringBuilder sb)
    => new UTF8Encoding(encoderShouldEmitUTF8Identifier: true).GetBytes(sb.ToString());
  1. UTF-8 BOM 必须有。没有它,中文 Excel 用系统默认编码(GBK)打开, 整张表就是一堆方块。这是中文 CSV 导出最经典的坑,几乎人人踩过一次。
  2. 字段要转义。报废原因里写个逗号,整行的列就全部错位—— 而且错得很隐蔽,因为数据看起来"好像还在"。
  3. 用 \r\n 换行(StringBuilder.AppendLine 在 Windows 上就是), 保证 Excel 各版本都能正确分行。
导出不只是"给人下载",它是个兜底能力

很多工厂的现实是:报表系统再漂亮,月底财务还是会把数据导到 Excel 里再算一遍。

与其对抗这个习惯,不如把导出做成正规能力—— 接口稳定、字段固定、编码正确。这样至少大家导出来的是同一份数据, 而不是各自截图手工录。

验证
curl
BASE=http://localhost:5100

# 产量趋势(会自动补齐没开工的天)
curl -s "$BASE/api/reports/output-trend?days=14"

# 工序节拍:标准 vs 实际单件工时
curl -s $BASE/api/reports/operation-cycle

# 员工绩效(产出 / 质量 / 工时三个维度)
curl -s "$BASE/api/reports/employee-performance?days=30"

# 导出 5 种 CSV,验证 BOM 头
for t in workorders reports inspections output assignments; do
  echo -n "$t: "
  curl -s "$BASE/api/reports/export/$t" | head -c 3 | xxd | head -1
done

最后一步的输出,每个都应该以 efbbbf 开头(UTF-8 BOM 的十六进制)。 看不到它,导出的文件在 Excel 里就是乱码。

把 CSV 下载下来用 Excel 打开,确认三件事:中文不乱码、列没有错位、日期能排序。 这三件都对,导出功能才算做完。

STAGE 10

测试与交付

30 课前面的所有校验规则,都是靠"手工 curl 一遍"验证的。
这一阶段把它们变成可重复执行的测试,并规划上线前必须做的加固。
29

自动化测试

交付
目标:把"我试过了"变成"每天都在自动重复验证"。
本课施工
起点改一处代码不知道会不会踩坏别的功能,全靠手工点
新建test-api.sh · ui-test.js
迁移(本课不动实体,不用迁移)
VS 操作两个脚本都放在解决方案根目录(和 Mes.slnx 同级),不是放在项目里
完成标志bash test-api.sh 全绿(false 模式 15 通过 + 1 SKIP;true 模式 19 通过);node ui-test.js 52 项全过
主线步骤S60 – S61
本课任务
  1. 想清楚 MES 里最该被测的是什么——不是计算,是规则
  2. 写一组 API 层的业务规则测试
  3. 写一个前端渲染测试,防止改一处样式崩掉整个页面

MES 的测试重点和普通 CRUD 不一样

一个物料 CRUD 写错了,大不了数据不对,人一眼能看出来。 但 MES 的规则是埋在流程里的:

  • 前道工序没完工,报工必须被拒绝
  • 工单不能从"生产中"直接跳到"关闭"
  • 派工数量不能超过工序计划数
  • 关键工序没有检验记录,不能流转

这些规则的特点:正常路径下永远不会被触发。 你每天正常用系统,可能半年都碰不到它们。 但只要有一条失效,车间的后果是真实的——所以它们恰恰是最该被自动验证的部分。

测试的重点是"被拒绝的路径",不是"成功的路径"

新手写测试,往往写"创建工单成功"、"报工成功"。 这些测试跑通了,只能说明代码没崩。

真正有价值的是断言失败: "越序报工必须返回 400,且错误信息里必须提到前道工序的名字"。 这类测试写起来更麻烦(要构造非法数据),但它是唯一能防止规则被悄悄改坏的东西。

一个经验法则:测试用例的数量,应该和"业务规则数量"成正比, 而不是和"接口数量"成正比。

第一层:API 业务规则测试

bash · test-api.sh
#!/usr/bin/env bash
# MES 业务规则回归测试 —— 每次改完代码跑一遍
#
# 两种后端配置下都能跑:
#   Mes:EnforcePermission = false  → 免登录演示模式(本地开发用)
#   Mes:EnforcePermission = true   → 生产强制鉴权模式
# 做法:先用 admin 登录拿令牌,所有写操作统一带上;
#       再根据探测到的模式,追加一组鉴权专项断言。
#
# 用 admin 而不是 planner 跑业务用例,是因为业务断言要测的是
# 「业务规则本身」(超派、故障设备、报废原因…),不该被角色权限先拦掉。
# 角色权限由后面那组专项断言单独覆盖。

BASE=${BASE:-http://localhost:5100}
PASS=0; FAIL=0

# 断言某个请求返回指定的 HTTP 状态码
expect_status() {
  local name="$1" want="$2"; shift 2
  local got
  got=$(curl -s -o /dev/null -w '%{http_code}' -m 10 "$@")
  if [ "$got" = "$want" ]; then
    echo "  [OK]   $name"; PASS=$((PASS+1))
  else
    echo "  [FAIL] $name(期望 $want,实际 $got)"; FAIL=$((FAIL+1))
  fi
}

# 断言响应体里包含某段文字
expect_contains() {
  local name="$1" want="$2"; shift 2
  local body
  body=$(curl -s -m 10 "$@")
  if echo "$body" | grep -q "$want"; then
    echo "  [OK]   $name"; PASS=$((PASS+1))
  else
    echo "  [FAIL] $name(响应里没有「$want」)"; FAIL=$((FAIL+1))
    echo "         实际: $(echo "$body" | head -c 200)"
  fi
}

# 从 JSON 里抠一个字符串字段(不依赖 jq)
json_field() { grep -o "\"$1\":\"[^\"]*\"" | head -1 | cut -d'"' -f4; }

# ---------- 0. 探测鉴权模式 + 取令牌 ----------
MODE=$(curl -s -m 10 "$BASE/api/auth/me" | grep -o '"enforcePermission":[a-z]*' | cut -d: -f2)
MODE=${MODE:-unknown}
echo "== 环境:enforcePermission=$MODE =="

TOKEN=$(curl -s -m 10 -X POST "$BASE/api/auth/login" \
        -H "Content-Type: application/json" \
        -d '{"userName":"admin","password":"mes@2026"}' | json_field token)

if [ -z "$TOKEN" ]; then
  echo "  [FAIL] 取不到 admin 令牌,后面所有写操作都会失败"
  FAIL=$((FAIL+1))
fi
AUTH=(-H "Authorization: Bearer $TOKEN")

# 操作工令牌:只用来验证"越权会被拒",它的权限最低(只能报工)
OP_TOKEN=$(curl -s -m 10 -X POST "$BASE/api/auth/login" \
           -H "Content-Type: application/json" \
           -d '{"userName":"zhangwei","password":"mes@2026"}' | json_field token)

echo "== 工单状态机 =="
# 生产中 -> 关闭 必须被拒绝(合法出口只有 暂停/完工/作废)
expect_status   "生产中不能直接关闭" 400 "${AUTH[@]}" -X POST $BASE/api/work-orders/1/transit \
  -H "Content-Type: application/json" -d '{"target":5}'
expect_contains "错误信息要给出合法出口" "当前允许流转到" "${AUTH[@]}" -X POST $BASE/api/work-orders/1/transit \
  -H "Content-Type: application/json" -d '{"target":5}'
# 已完工的工序不能再派工
expect_contains "已完工工序不能派工" "已完工" "${AUTH[@]}" -X POST $BASE/api/scheduling/assignments \
  -H "Content-Type: application/json" -d '{"operationId":1,"employeeId":1,"qty":1}'

echo "== 报工校验 =="
# 有报废但没写原因,必须被拒绝
expect_contains "报废必须填原因" "报废原因" "${AUTH[@]}" -X POST $BASE/api/production/report \
  -H "Content-Type: application/json" \
  -d '{"operationId":4,"employeeId":1,"qty":1,"scrapQty":1}'

echo "== 派工校验 =="
# 工序 4「总装」计划 50,已派 30+20=50,再派必须被拒
expect_contains "超派要被拒绝" "超派" "${AUTH[@]}" -X POST $BASE/api/scheduling/assignments \
  -H "Content-Type: application/json" -d '{"operationId":4,"employeeId":1,"qty":10}'
# 工序 7 还有余量,但设备 4「综合测试台」处于故障停机
expect_contains "故障设备不能派工" "故障停机" "${AUTH[@]}" -X POST $BASE/api/scheduling/assignments \
  -H "Content-Type: application/json" -d '{"operationId":7,"employeeId":2,"equipmentId":4,"qty":5}'

echo "== 不良品流程 =="
# 3 号不良品是「待处理」,关闭时必须填根因;1 号已关闭,拿它测会撞到"不能重复关闭"
expect_contains "关闭不良品必须填根因" "根因" "${AUTH[@]}" -X POST $BASE/api/defects/3/close \
  -H "Content-Type: application/json" -d '{"rootCause":"","counterMeasure":"x"}'

echo "== 认证 =="
expect_status   "错误密码返回 400" 400 -X POST $BASE/api/auth/login \
  -H "Content-Type: application/json" -d '{"userName":"planner","password":"wrong"}'
expect_contains "不泄露账号是否存在" "用户名或密码错误" -X POST $BASE/api/auth/login \
  -H "Content-Type: application/json" -d '{"userName":"nobody","password":"wrong"}'
# 匿名访问 /me 不走 401,而是如实回 authenticated=false(前端据此显示"未登录")
expect_contains "匿名身份如实返回" '"authenticated":false' $BASE/api/auth/me

echo "== 鉴权(enforcePermission=$MODE) =="
if [ "$MODE" = "true" ]; then
  # 未登录的写操作必须被拦,且提示是人话
  expect_contains "无令牌写操作被拒" "请先登录" -X POST $BASE/api/scheduling/assignments \
    -H "Content-Type: application/json" -d '{"operationId":4,"employeeId":1,"qty":1}'
  # 登录了但角色不够,也要被拦
  expect_contains "操作工无权派工" "无权派工" -X POST $BASE/api/scheduling/assignments \
    -H "Authorization: Bearer $OP_TOKEN" -H "Content-Type: application/json" \
    -d '{"operationId":4,"employeeId":1,"qty":1}'
  expect_contains "操作工无权看审计" "无权查看审计" "$BASE/api/audit" \
    -H "Authorization: Bearer $OP_TOKEN"
  # 读接口对所有人开放(演示站的数据本来就是公开的),别把前端首屏也锁了
  expect_status "未登录仍能看板" 200 "$BASE/api/dashboard/summary"
else
  echo "  [SKIP] 强制鉴权已关闭,跳过拦截断言"
  echo "         (本地开发方便,但生产环境 Mes:EnforcePermission 必须为 true)"
fi

echo "== 报表导出 =="
for t in workorders reports inspections output assignments; do
  expect_status "CSV 导出 $t" 200 $BASE/api/reports/export/$t
done

echo ""
echo "==== $PASS 通过 / $FAIL 失败 ===="
[ "$FAIL" -eq 0 ]
为什么写成「双模式」而不是写死一套

后端有个 Mes:EnforcePermission 开关: false 时谁都能写(本地开发图方便), true 时所有写操作必须带令牌。

如果测试脚本写死了"无令牌应该被拒",那它在开发机上就永远失败—— 于是你开始习惯"这几条红的不用管"。这是测试最大的死法:让人习惯红灯。

正确的做法是先探测环境,再决定断言什么: 无论哪种配置,业务断言都必须成立;鉴权断言则只在 true 模式下追加。这样同一份脚本在两种环境下都是绿的, 红灯永远意味着真问题。

还有一个容易忽略的细节:业务用例统一用 admin 令牌,而不是操作工。因为你测的是 业务规则(超派、故障设备、报废原因),不该被角色权限先一步拦掉—— 那样你测到的就不是业务规则,而是"这个角色没权限"。 角色权限由专门的越权断言单独覆盖,两件事不要混在一条用例里。

为什么这一层用 bash 而不是 xUnit

因为它测的是端到端的行为:HTTP 状态码、错误文案、 序列化后的字段名。这些在单元测试里是测不到的。

而且它零依赖、任何机器上都能跑、结果一眼能看懂—— 对于"验证业务规则"这个目的,比引入一整套测试框架划算得多。

当然,纯计算逻辑(比如排产算法、MRP 展开) 应该用 xUnit 做单元测试,因为它们不需要数据库、跑得飞快。 分层测试的原则是:能做成单元测试的,绝不放进端到端测试。

第二层:前端渲染测试

单文件前端最容易出的问题是:改了一处模板字符串,另一个页签渲染时崩了, 而且只有点到那个页签才会发现。

用 jsdom 把页面加载起来、逐个页签点一遍,就能把这类问题堵在提交之前:

JavaScript · mes/ui-test.js(核心片段)
const { JSDOM } = require("jsdom");
const API = "http://localhost:5100";

// 先确认后端活着 —— 前端测试依赖真实接口,服务没起就没意义
const r = await fetch(API + "/");
if (!r.ok) { console.log("后端不可达,先 dotnet run"); process.exit(1); }

const dom = new JSDOM(fs.readFileSync("Mes.Api/wwwroot/index.html", "utf-8"), {
  url: API + "/",
  runScripts: "dangerously",          // 必须:让内联 script 真的执行
  beforeParse(window) {
    // 把页面里的相对路径 fetch 代理到真实服务
    window.fetch = (input, init) =>
      fetch(typeof input === "string" ? new URL(input, API).toString() : input, init);
    window.scrollTo = () => {};
    window.addEventListener("unhandledrejection", e =>
      errors.push(String(e.reason && e.reason.message || e.reason)));
  }
});

const doc = dom.window.document;
await sleep(2500);   // 等首屏的异步加载完成

// 静态一致性:导航按钮数 = 页面容器数,且不能有孤立的按钮
const navBtns = [...doc.querySelectorAll("nav button[data-page]")];
const orphan = navBtns.map(b => b.dataset.page)
                      .filter(p => !doc.getElementById("page-" + p));
assert(navBtns.length === 10, "导航按钮应为 10 个");
assert(orphan.length === 0, "有按钮指向了不存在的页面:" + orphan);

// 逐个页签点击,断言容器里真的渲染出了内容
for (const [page, container] of [
  ["dashboard", "kpis"],   ["orders", "orderList"],  ["schedule", "loadBox"],
  ["mrp", "mrpBox"],       ["report", "rpOrder"],    ["quality", "qcKpis"],
  ["defect", "defectBox"], ["equipment", "oeeBox"],
  ["analytics", "outputChart"], ["audit", "auditBox"],
]) {
  doc.querySelector(`nav button[data-page="${page}"]`).click();
  await sleep(2200);

  const html = doc.getElementById(container).innerHTML.trim();
  assert(html.length > 20, `${page} 的关键容器 ${container} 没渲染出内容`);
  assert(!/undefined|NaN/.test(html), `${page} 页面出现了 undefined / NaN`);
}

// 还要测"点了才有内容"的交互路径:追溯链、算料
const sel = doc.getElementById("traceOrder");
sel.value = [...sel.options].find(o => o.value).value;
dom.window.loadTrace();          // 模拟 onchange
await sleep(2000);
assert(doc.getElementById("traceBox").innerHTML.length > 60, "追溯链没渲染");
最后那条断言最有用

!/undefined|NaN/.test(html) —— 检查渲染结果里有没有 "undefined" 或 "NaN" 字样。

这是前端最常见、也最容易被忽略的 bug: 后端某个字段改名了,前端模板里读不到,于是页面上出现 undefined 件。 它不会报错、不会崩,只是静静地显示一个错的字。

一条正则,能挡住一整类问题。 写测试的时候多想想"什么样的错误是静默的",那才是值得投入的地方。

验证:前端渲染测试的实际输出
bash
cd mes
node ui-test.js

本教程源码跑出来是 52 项全绿、0 失败、无未捕获异常:

=== MES 前端渲染测试 ===
服务地址: http://localhost:5100
  [OK]   后端服务可达

-- 静态结构 --
  [OK]   导航按钮 10 个
  [OK]   页面容器 10 个
  [OK]   导航按钮都有对应容器
  [OK]   无重复 id

-- 逐页签渲染 --
  [OK]   页签 dashboard 整页有内容
  [OK]   页签 dashboard 无 undefined/NaN
  [OK]   页签 dashboard 关键容器 kpis 非空
  ...(10 个页签 × 4 项断言,全部通过)
  [OK]   页签 audit 关键容器无 undefined/NaN

-- 交互:追溯链 --
  [OK]   追溯工单下拉已填充
  [OK]   选工单后追溯链渲染出内容
  [OK]   追溯链无 undefined/NaN

-- 交互:算料 --
  [OK]   算料工单下拉已填充
  [OK]   算料结果渲染出内容
  [OK]   算料结果无 undefined/NaN

-- 全局 --
  [OK]   无未捕获异常

==== 通过 52 项 / 失败 0 项 ====

注意里面有两项不是"渲染出内容",而是"点一下再渲染"—— 追溯链和算料都必须先选一张工单。这类"交互后才加载"的路径最容易漏测, 因为默认页面看起来是正常的(只是永远停在"请选择工单"的空状态)。

什么时候跑这些测试

时机跑什么耗时
每次改完 Service 层API 业务规则测试几秒
每次改完前端前端渲染测试约 30 秒
提交前上面两个都跑不到 1 分钟
部署后公网侧的接口健康检查几秒

不到 1 分钟的反馈循环,才有人愿意真的每次都跑。 测试跑得慢,等于没有测试。

验证:跑 API 测试,再故意改坏一处规则

把上面那段 test-api.sh 存成文件,加执行权限后运行:

chmod +x test-api.sh
./test-api.sh

本教程源码实际跑出来是这样。先看开发模式(默认 EnforcePermission=false):业务断言全绿, 鉴权那组整体跳过——它不是失败,是"这个配置下无意义"。

== 环境:enforcePermission=false ==
== 工单状态机 ==
  [OK]   生产中不能直接关闭
  [OK]   错误信息要给出合法出口
  [OK]   已完工工序不能派工
== 报工校验 ==
  [OK]   报废必须填原因
== 派工校验 ==
  [OK]   超派要被拒绝
  [OK]   故障设备不能派工
== 不良品流程 ==
  [OK]   关闭不良品必须填根因
== 认证 ==
  [OK]   错误密码返回 400
  [OK]   不泄露账号是否存在
  [OK]   匿名身份如实返回
== 鉴权(enforcePermission=false) ==
  [SKIP] 强制鉴权已关闭,跳过拦截断言
         (本地开发方便,但生产环境 Mes:EnforcePermission 必须为 true)
== 报表导出 ==
  [OK]   CSV 导出 workorders
  [OK]   CSV 导出 reports
  [OK]   CSV 导出 inspections
  [OK]   CSV 导出 output
  [OK]   CSV 导出 assignments

==== 15 通过 / 0 失败 ====

再把后端切成生产模式(Mes__EnforcePermission=true)重启—— 同一份脚本一个字都不用改,多出 4 条鉴权断言,总数从 15 变 19:

== 环境:enforcePermission=true ==
(工单状态机 / 报工 / 派工 / 不良品 / 认证 —— 12 条业务断言输出完全相同,此处略)

== 鉴权(enforcePermission=true) ==
  [OK]   无令牌写操作被拒          ← 未登录打写接口,回「请先登录后再派工」
  [OK]   操作工无权派工            ← 登录了但角色不够,回「无权派工」
  [OK]   操作工无权看审计          ← 回「无权查看审计」
  [OK]   未登录仍能看板            ← 读接口不锁,否则前端首屏就白了

== 报表导出 ==
  [OK]   CSV 导出 workorders
  [OK]   CSV 导出 reports
  [OK]   CSV 导出 inspections
  [OK]   CSV 导出 output
  [OK]   CSV 导出 assignments

==== 19 通过 / 0 失败 ====
四条鉴权断言,覆盖的是两件不同的事

前三条测「拦得住」:没登录的要拦、登录了角色不够的也要拦。 光测前者是不够的——一个把所有写操作都放行的实现,只要有个地方忘了鉴权, 前者照样通过;而"操作工不能派工"能抓住它。

第四条测「不该拦的别拦」:读接口对所有人开放。 这条看起来多余,但它是防止你矫枉过正——如果哪天把鉴权写得过严, 把看板也锁了,前端首屏直接白屏,而前三条断言全都是绿的,你根本发现不了。

好的安全测试永远是双向的:既测"拦得住",也测"放得开"。

写这组测试时,它当场抓出了我 4 个错误假设

第一版脚本用的字段名是 workOrderOperationId, 但 DTO 上实际叫 OperationId —— 请求体反序列化后是 0, 接口回了一句 工序 0 不存在。测试没报"脚本错了", 它只是如实告诉我:我以为的接口契约不是真的。

另外三处:状态流转的字段是 target 而不是 targetStatus;不良品 1 号已经关闭,拿它测关闭校验会撞到"不能重复关闭"; CSV 导出的 type 是 workorders/reports/inspections/output/assignments, 我凭印象写成了报表接口的名字。

这就是失败路径测试的价值:它逼你去核对契约,而不是相信记忆。 四个错误里有两个(字段名和 CSV type)如果不测,会在前端联调时才暴露, 而且表现为"页面显示 undefined"这种静默错误。

然后再故意改坏一处规则试试: 把 WorkOrderService 的 AllowedTransitions 里 InProgress 的允许列表加上 Closed,重新编译再跑测试。 应该立刻看到"生产中不能直接关闭"这条变红。

先确认测试能发现错误,再相信它的绿色。 一条永远不会失败的测试,比没有测试更危险。

30

生产加固与扩展方向

交付
目标:把演示系统变成能承载真实生产的系统,并知道下一步该往哪走。
本课施工
起点代码写完了,但按开发配置上线会出安全事故
新建deploy/mes.service(systemd 单元)· nginx 反代配置
改写生产配置:数据库绝对路径、密钥、CORS、错误详情、日志级别
迁移(本课不动实体,不用迁移)
VS 操作发布时配置选 Release;systemd 的 Environment= 值含空格必须加引号
完成标志线上全流程跑通;重启不丢数据;生产环境下 /scalar/v1 返回 404(这是对的)
主线步骤S62 – S63
本课任务
  1. 过一遍上线前必须改的清单(数据库 / HTTPS / 备份 / 鉴权)
  2. 看一遍真实部署踩到的四个坑(这一节最实用)
  3. 理解索引和查询口径对性能的影响
  4. 拿到一张按优先级排序的演进路线图

上线前必须改的四件事

#改什么为什么怎么改
1 换生产数据库 SQLite 是单文件、单写入者,多人同时报工会锁库甚至写失败 改 Program.cs 里 provider 一行 + 连接串
2 打开强制鉴权 演示模式默认放行,生产环境不能这样 Mes:EnforcePermission = true,并换掉 TokenSecret
3 启用 HTTPS 重定向 车间内网也要防篡改;而且浏览器会逐步拒绝 http 表单提交 恢复 app.UseHttpsRedirection(),前置 Nginx 终止 TLS
4 建立备份 MES 的数据丢了,车间的在制品就变成"不知道做到哪了" 数据库每日全备 + 保留 30 天;追溯类表可考虑只增的异地副本
C# · Program.cs(换数据库只改这一行)
// 开发环境:SQLite(零配置,数据库就是一个文件)
builder.Services.AddDbContext<MesDbContext>(options =>
    options.UseSqlite(builder.Configuration.GetConnectionString("Default")));

// 生产环境:换成 SQL Server(或 PostgreSQL)—— 业务代码一行都不用动
// builder.Services.AddDbContext<MesDbContext>(options =>
//     options.UseSqlServer(builder.Configuration.GetConnectionString("Default")));
这就是 EF Core 最实在的价值

换数据库时,你的 11 个 Service、16 个 Controller、17 个实体的代码, 一行都不用改。改的只是这一行 provider 和一个连接串。

代价是要接受 EF 生成的 SQL 不如手写优化—— 但对 MES 这种"读多写少、单表数据量在百万级"的场景,这个代价完全值得。

顺带提醒:换库之后要重新生成迁移。 SQLite 和 SQL Server 的类型映射不完全一样(比如那行 ConfigureConventions 里的 decimal→double 转换, 在 SQL Server 上就该去掉,因为 SQL Server 原生支持 decimal)。

上线实录:真的部署一次会踩到什么

上面那张表说的是"应该怎么做"。这一节记录的是实际部署这套系统时踩到的坑—— 它们不在任何文档里,只有真上手部署一次才会遇到。

实际部署结构(一台 Ubuntu 24.04 + nginx)
部分位置说明
发布产物 /opt/mes 框架依赖发布,25 个文件 11MB。服务器已装 .NET 10 运行时, 所以不需要自包含发布(那会让产物涨到 ~90MB)
数据库 /var/lib/mes/mes.db 故意放在代码目录之外,理由见下面第 4 条
常驻服务 systemd mes.service User=www-data,监听 127.0.0.1:5100
令牌密钥 /etc/mes/mes.env 600 权限,只在服务器上生成一次(每次发布都换会把所有人踢下线)
对外入口 nginx 反代 mes.superyg.cloud 整站反代到 :5100 —— 前端由应用自己托管,nginx 这边没有 root

以下是四个坑。共同点是:报错信息都不指向真正的原因。

部署坑对照表
#症状根因正确做法
1 服务起来就崩,日志里是 ArgumentException: Format of the initialization string does not conform to specification starting at index 0 systemd 的 Environment= 值里有空格, 被 systemd 按空格切成了两个环境变量——应用拿到的是半截连接串 Environment="ConnectionStrings__Default=Data Source=/var/lib/mes/mes.db" —— 整个 KEY=VALUE 用引号包住
2 上传脚本报"25 个文件全不一致",去服务器一看目标目录是空的 上传脚本是按源目录名解包的:源叫 mes-publish, 就解到了 /opt/mes-publish,目标目录当然还是空的 让产物目录与远端目录同名(本地 deploy/mes/ → 远端 /opt/mes)
3 给新域名签证书时,另一个已上线的子域突然"证书不匹配" certbot --expand 是整体替换域名列表, 不是"追加一个"。漏掉哪个域名,哪个就被踢出证书 扩签时把全部域名一起列上(包括配置写在别的文件里的那些), 签完手动 systemctl reload nginx
4 重新发布一次代码,数据全没了 发布脚本为了干净利落,会整目录替换远端路径(旧目录改名留档,不合并)—— 数据库如果放在代码目录里,就会被一起换掉 数据放 /var/lib/mes, 用环境变量覆盖连接串指向它。这样重新发布只换代码
把这四个坑抽象成一句话

部署阶段的错误,绝大多数不是业务代码写错了,而是"两边约定不一致"—— systemd 对引号的理解、上传脚本对目录名的理解、certbot 对域名列表的理解, 都和你想的不一样。而且它们都不会给出指向根因的报错。

所以有个很实用的判据:看到报错信息里出现"半个值" (半个连接串、半个路径、数字 0),先怀疑边界处被截断了,而不是代码逻辑。 第 29 课说过"测试的重点在被拒绝的路径"——部署阶段同理, 重点在那些"错误信息不指向根因"的地方。

上线之后还要验一遍

部署完成不等于没问题。至少要跑完这三层:

① 服务端侧(绕过 DNS,直接测应用)—— curl http://127.0.0.1:5100/api/auth/me, 确认 enforcePermission:true 真的生效了。
② 反向代理侧(验证书链,不加 -k)—— ssl_verify_result=0 才算证书完整。
③ 公网侧(含 DNS 和安全组)—— 用真实域名访问一遍。

这三层的意义是能定位故障在哪一段: ① 过了 ② 没过 → nginx 配置问题;② 过了 ③ 没过 → DNS 或防火墙。 直接测第 ③ 层,你只会知道"打不开"。

性能:先把索引和口径做对,再谈别的

MES 的性能问题几乎从不需要"上缓存"或"加机器"来解决, 因为 90% 的慢查询是这两个原因:

  1. 看板接口在扫全表。首页要"按状态列工单", 如果 Status 上没索引,工单涨到 10 万条时首屏就要几秒。
  2. 聚合在内存里做。本项目为了演示清晰,很多统计是 ToListAsync() 之后在内存里 Sum/Average。 数据量小的时候没问题,几十万条报工记录之后就会出问题。
// 已经建好的关键索引(MesDbContext 里)
e.HasIndex(x => x.Status);                              // 看板首屏:按状态查工单
e.HasIndex(x => new { x.EmployeeId, x.PlannedStart });  // 排产 / 我的任务
e.HasIndex(x => x.HappenedAt);                          // 追溯链按时间翻页
e.HasIndex(x => new { x.TargetType, x.TargetId });      // 审计按对象查历史
内存聚合什么时候会变成问题

报工记录是增长最快的表——一台设备一天几百条,一年就是十万级。

当 ProductionReports 到百万行时, ToListAsync() 会把整张表拉进内存, 单次请求几百兆、耗时几十秒。这不是"慢",是"会挂"。

改造方向:把 GroupBy 和 Sum 下推到数据库(EF 对 SQL Server 支持得很好), 或者给关键的按天统计建汇总表,由定时任务每天算一次。

判断标准:如果一个接口的耗时随数据量线性增长,它迟早会挂。 好的查询应该只随"结果集大小"变化,而不是随"表大小"变化。

演进路线图

按投入产出比排序。做完前两项,这套系统就足以支撑一个真实的中小型车间:

优先级方向做什么价值
P0 换生产数据库 + 备份 SQL Server / PostgreSQL,每日全备 多人并发的硬需求;数据丢了什么都白搭
P0 开启强制鉴权 EnforcePermission = true,逐个账号配角色 没有权限的 MES 不能上线
P1 ERP 对接 工单接收接口 + 完工回传接口,打通 L3 与 L4 消灭"计划员对着 ERP 手工敲工单"这个岗位
P1 数据采集 扫码枪报到工、PLC 采集设备状态(OPC UA / Modbus) 数据实时性和真实性大幅提升,人工录入必然有水分
P2 APS 高级排程 换型时间、模具共享、班次轮换、多目标优化 从"能排"到"排得好",但前提是 P0 的数据是准的
P2 WPF 桌面客户端 复用同一套 API,给工位机 / 车间大屏用 车间粉尘油污环境下,浏览器不如原生客户端稳定
P3 时序数据库 设备高频数据(电流、温度、振动)入 InfluxDB / TDengine 为预测性维护打基础;这类数据不适合放关系库
P3 多工厂 / 多车间 组织架构维度 + 数据隔离 集团化管理的需求,但会显著增加模型复杂度
为什么 ERP 对接排 P1 而不是 P0

因为在你自己系统的数据还不可靠之前,对接只会把不可靠的数据放大。

先让车间真的在用这套系统报工、报出来的数是准的, 再去和 ERP 打通。反过来做的结果通常是:接口通了, 但 ERP 收到的完工数据是错的,于是要花三倍时间查是谁的锅。

制造业信息化项目失败的最大原因从来不是技术,是 系统上线了但没人认真用,导致数据全是假的。

你已经完整掌握的东西

走完 30 课,你手上不只是一套能跑的代码,而是这些可以迁移到任何制造业项目的能力:

  • 状态机建模——把业务规则集中成一份定义,改一处规则全系统生效
  • 模板与实例分离——历史数据不被规则变更追溯性改写
  • 事务边界的直觉——一次 SaveChanges 就是一个原子操作, 业务数据和审计日志必须同进同出
  • 追溯系统的设计——只增不改的表、不建外键的账本、 产品履历与人的履历要分开记
  • 指标口径的敏感性——算错的分母比没有指标更危险; "全是一个值"通常意味着公式错了
  • 有限产能思维——承认资源有上限,是排产算法的分水岭
  • 递归的两个安全阀——深度上限 + 按路径判环
  • 分层纪律——Controller 管 HTTP,Service 管业务,DbContext 管表结构
  • 用流程逼出信息——把"填原因"和"关闭"绑成一件事
  • 测试的重点在失败路径——规则的力量在于它拒绝了什么

这些东西换一个行业(电子、汽车、医药、食品)依然成立, 因为它们是制造执行这件事本身的规律,不是某个框架的用法。

全教程完成

现在打开 http://localhost:5100/, 你应该能看到 10 个功能页,每个页面里都有真实数据:

生产看板 · 工单管理 · 派工排产 · 物料需求 · 车间报工 · 质量与追溯 · 不良品 · 设备与 OEE · 报表分析 · 审计日志

而这套系统已经真的上线了 —— https://mes.superyg.cloud/, 生产配置全部到位:强制鉴权、数据与代码分离、HTTPS 自动续期。 部署时踩的四个坑就在本课上面那两张表里,照着抄能少走一遍弯路。

能下达工单、能派工、能算料、能报工、能查追溯、能看 OEE、能追责任—— 你已经独立做出了一套完整的 MES。

接下来最有价值的动作是:把它改成你自己行业的业务。 换掉物料、工序、检验项目、不良类型,你会发现需要改的地方比想象中少得多—— 因为变的只是业务词汇,不变的是执行逻辑。

APPENDIX

附录 · 速查与自检

正文 30 课讲的是「为什么这么设计」,附录负责「记不住的时候去哪儿翻」。
A 术语表 · B API 速查(72 个接口)· C 表结构总览(17 张表)· D 故障排查手册 · E 自测题 30 道。
附录 A

MES 术语表

速查
目标:把全教程出现过的行话按域归位。装不下的先记「它在哪一层、管什么量」,细节回头看对应课程。

1 · 架构与标准

MES 制造执行系统
车间层的"作业系统"。管的是天到分钟的计划执行、过程控制与数据采集,不是排月计划(那是 ERP),也不是控制伺服(那是设备层)。
ISA-95 / IEC 62264 国际标准
把企业分成 L0~L4 五层的标准模型。MES 在 L3,是 L4(ERP)与 L2(SCADA)之间的翻译层。
L0 ~ L4 层级
L0 物理过程 · L1 传感器执行器 · L2 监控与 SCADA · L3 制造运行/MES · L4 业务计划/ERP。
ERP 企业资源计划
管钱、管订单、管月/季计划。它的计划粒度是"这批货几号交",不是"3 号机今天下午做什么"。
WMS 仓储管理系统
管库位、收发、盘点。MES 关心"线边还有多少料",库房内的搬运交给 WMS。
SCADA 数据采集与监控
L2 层,直接连 PLC/传感器。MES 从它拿设备实时状态,而不是自己去读 IO 点。
QMS 质量管理系统
管体系文件、审核、CAPA。MES 里的检验数据往往要回灌给它。
闭环 / 开环 控制视角
开环=只记录不反馈;闭环=记录后反过来影响下一步决策(如探到不良率超标就暂停下达)。MES 的价值在闭环。

2 · 生产执行

工单 Work Order
车间唯一的执行单位。"做多少个什么、什么时候要"的权威记录。本教程 7 个状态:草稿/已下达/生产中/暂停/完工/关闭/作废。
状态机 State Machine
把"从哪个状态能到哪个状态、谁能触发"集中定义成一张表。规则改一处、全系统生效——本教程最重要的结构决策。
下达 Release
草稿 → 已下达的动作。下达瞬间做两件不可逆的事:展开工序、冻结快照。
工序 Operation
工单上的一步加工,是报工、派工、检验的挂载点。工单是"要做的事",工序是"在哪儿做、做多久"。
工艺路线 Routing / ProcessStep
物料级的模板:这个产品要经过哪几道工序、顺序、标准工时、是否是关键工序。
BOM 物料清单
"1 个成品 = 哪些子件各多少"。本教程的 BomItem 带 LossRate,所以算料时用量会逐层放大。
派工 Assignment
把工序拆给具体的人/机,带计划起止时间。独立于工序,因为一道工序可以拆成多份并行做。
报工 Production Report
操作工汇报"这一工序我做了几个、废了几个"。这是驱动工单状态前进的唯一动力来源。
良品 / 报废 GoodQty / ScrapQty
报工数量里"能往下走"与"直接判死"的部分。分开记,因为报废要进质量的账。
追溯 Traceability
由成品反查"谁在什么时候用什么料做的"。本教程用 WipTrace 账本表实现。
产品履历 Genealogy
追溯的"物"视角:这一件产品的完整经历链。与"人的履历"(审计日志)要分开记。
WIP 在制品
在车间里还没变成成品的中间物。MES 的核心价值就是让 WIP 可见——不然它就是一个黑箱。
快照 Snapshot
下达时把模板(工序、标准工时)复制一份存到工单上。以后改模板不会改写历史工单,这是制造业软件的硬要求。
软删除 Soft Delete
用 IsActive=false 代替 DELETE。工单引用过的物料,物理删掉会让历史单据变成悬空引用。
账本表 Ledger Table
只增不改、不建外键的关键流水表。外键删了会连坐历史,账本要的是"永远查得到"。

3 · 计划与排产

MRP 物料需求计划
由"要产多少"倒推"每种料要多少、缺多少"。核心动作是按 BOM 逐层展开。
毛需求 Gross Requirement
不考虑库存的理论用量。由「上层用量 × 数量 × (1+损耗率)」逐层累计得到。
净需求 Net Requirement
毛需求 − 现有库存。大于 0 就是缺料,要开采购或调拨。
安全库存 Safety Stock
"再低就要报警"的水位线。净需求核算里通常把安全库存也扣掉,而不是只看可用量。
损耗率 Loss Rate
BOM 行上的报废比例。它的可怕之处在于逐层放大——三层各 5%,最底层就要多备 15.8% 而不是 15%。
BOM 展开 Explosion
从顶层往下递归遍历 BOM 的过程。必须带三种保护:深度上限、按路径判环、同物料合并。
有限产能 Finite Capacity
承认"机器一天只有 480 分钟"。排产算法的分水岭:无限产能排出来的计划一定不可执行。
顺排 Forward Scheduling
从最早能开工的时间往后排。本教程用顺排 + 班次对齐。
倒排 Backward Scheduling
从交期往前推,算出最晚开工时间。交期压力大时用,但容易推出"昨天就该开工"。
工作中心 Work Center
产能的最小核算单元(产线、机台、工位)。DailyMinutes 就是它的日产能上限。
负荷 Load
已派工占用时长 ÷ 可用时长。超过 1 就是排爆了,必须显示给计划员,而不是静默接受。
班次 / 日历 Shift / Calendar
一天不是连续的 1440 分钟。排产必须在"可工作时间"上累加,否则会排出凌晨 3 点开工。
甘特图 Gantt
横轴时间、纵轴资源的条形图。MES 里用来一眼看出哪台机器被排满了。

4 · 质量

IQC / IPQC / FQC / OQC
来料 / 制程 / 完工 / 出货检验。本教程的 QualityInspection 挂在工序上,属于 IPQC 与 FQC 之间。
SPC 统计过程控制
用统计方法判断"过程是不是稳定",而不是等出了不良再去挑。核心工具是控制图。
控制图 Control Chart
把每次测量按时间画出来,叠上中心线和上下控制限。看趋势,不看单点。
CL / UCL / LCL
中心线(均值)= CL;上/下控制限 = CL ± 3σ。用的是样本标准差(除以 n−1),不是总体标准差。
σ 标准差
衡量波动的尺子。3σ 之外的概率约 0.27%,所以超出控制限通常说明"有异常原因介入"。
首件检验 FAI
换型/换料/开班后第一件必检。目的不是挑不良,而是证明"新条件已调对"。
不良品 Defect
不合格的实物或数量。发现只是起点,处置闭环才是终点。
处置 Disposition
对不良品"怎么办"的决定。本教程四种:返工 / 返修 / 报废 / 让步接收。
返工 Rework
重新做一遍工序,目标是完全符合原规格。返修则是"虽不完全符合但能用"。
报废 Scrap
彻底放弃。要计成本、要进报废率统计,通常还要走审批。
让步接收 Concession
偏离规格但客户/工程同意放行。必须留审批痕迹,否则追溯时说不清为什么放出去了。
根因分析 Root Cause
"为什么会发生"的答案。本教程把它做成关闭不良品的强制前置条件——填不出根因就关不掉。
5Why / 8D
连问五层为什么;8D 是更结构化的八步团队法。共同点:不接受"操作工不小心"作为最终答案。
MRB 不合格品评审
由工程/质量/生产共同裁定不良品处置的机制。系统化的 MES 会把 MRB 结论直接落成处置字段。

5 · 设备与效率

OEE 设备综合效率
三个率的乘积。它之所以难造假,是因为任何一个率掉了都会把总分开根号级地拉低。
时间开动率 Availability
(计划时间 − 停机时间) ÷ 计划时间。衡量"机器有没有在转"。
性能开动率 Performance
理论加工时间 ÷ 实际加工时间。教科书口径。数据 >105% 通常说明标准工时定得太松。
合格率 Quality Rate
良品数 ÷ 总数。衡量"转出来的东西能不能卖"。
计划 / 非计划停机
前者是换型、保养(应计入计划时间);后者是故障(应计入损失)。口径混了 OEE 就没意义。
MTBF / MTTR
平均故障间隔(越大越好)/ 平均修复时间(越小越好)。维修管理的两个基本盘。
点检 Equipment Check
班前/班后的例行检查。本教程里点检不合格会把设备状态置为故障,并连带拦截派工。
TPM 全员生产维护
让操作工参与日常维护的体系。MES 是其落地抓手:点检、报修、保养记录都在系统里。

6 · 工程实践

DTO
接口的输入/输出形状。让"数据库长什么样"与"接口长什么样"解耦,改表结构不一定改接口。
事务 Transaction
一次 SaveChanges 就是一个原子操作。业务数据与审计日志必须同进同出。
审计日志 Audit Log
人的履历:谁、什么时候、对什么、做了什么、成功还是失败。只增不改。
令牌 Token
登录后发给客户端的凭证,放在 Authorization: Bearer 头里。本教程用 HMAC-SHA256 自签。
PBKDF2
慢哈希算法,故意让每次计算耗时(本教程 10 万次迭代)。慢就是对暴力破解的防御。
加盐 / 固定时间比较
每个用户独立盐,防彩虹表;用 FixedTimeEquals 比较,防"按耗时猜密码"的时序攻击。
RBAC 基于角色的访问控制
把权限挂在角色上,人再挂角色。本教程 5 个角色:操作工/质检员/班组长/计划员/管理员。
幂等 Idempotent
同一请求发两次、结果只生效一次。报工、下达这类接口必须防重复提交。
软删除 / 逻辑删除
见第 2 节。制造业的默认姿势:可见地"停用",而不是看不见地"消失"。
UTF-8 BOM
CSV 开头那三个看不见的字节。不加,Excel 打开中文就是乱码——一个每年都有人踩的坑。
怎么用这份术语表

不要背。做法是:读正文时遇到不认识的词,回到这里查一句定义,然后回去看它对应的那一课是怎么落成代码的。 术语脱离了实现就是口号,实现了再看术语才是行业语言。

附录 B

API 速查表

速查
目标:72 个接口一览。上线后用 /scalar/v1 看实时文档,这里是离线速查与"这个功能该调谁"的索引。
GET POST PUT DELETE 需登录

基础地址 http://localhost:5100;「需登录」指请求要带 Authorization: Bearer <token>。

认证与用户 · AuthController

方法路径用途鉴权
POST /api/auth/login—账号密码换令牌,返回用户信息 + 权限数组匿名
GET /api/auth/me—用当前令牌换回用户身份与权限需登录
GET /api/auth/users—用户列表(管理员)需登录
POST /api/auth/usersbody新建账号,密码按 PBKDF2 加盐入库需登录
POST /api/auth/change-passwordbody修改密码,需校验旧密码需登录
GET /api/auth/roles—角色枚举与权限说明,供前端渲染下拉匿名

工单 · WorkOrdersController

方法路径用途鉴权
GET /api/work-orders?status=&keyword=工单列表,支持状态与关键字筛选—
GET /api/work-orders/{id}—工单详情,含工序与已完成量—
POST /api/work-ordersbody创建草稿工单需登录
POST /api/work-orders/{id}/release—下达:展开工序 + 冻结工艺快照需登录
POST /api/work-orders/{id}/transitbody状态流转,非法流转会被拒需登录
GET /api/work-orders/{id}/next-states—当前状态还能去哪儿(前端据此渲染按钮)—
GET /api/work-orders/{id}/trace—追溯链:这道工单经历了什么—

看板 · DashboardController

方法路径用途鉴权
GET /api/dashboard/summary—工单数、在制品、设备状态等首屏数字—
GET /api/dashboard/wip—各工序在制品分布—
GET /api/dashboard/quality—质量概览:合格率、不良类型分布—

基础数据 · Materials / Bom / ProcessSteps / WorkCenters / Employees / Equipments

方法路径用途鉴权
GET /api/materials?keyword=物料列表—
GET /api/materials/{id}—物料详情—
POST /api/materialsbody新增物料(含现有量、安全库存)—
PUT /api/materials/{id}body修改物料—
DELETE /api/materials/{id}—软删除:置 IsActive=false,不物理删—
GET /api/bom/{materialId}—直接子件(一层)—
GET /api/bom/{materialId}/tree—BOM 树形展开,带环检测—
POST /api/bombody新增 BOM 行(用量 + 损耗率)—
DELETE /api/bom/{id}—删除 BOM 行—
GET /api/process-steps/{materialId}—某物料的工艺路线—
POST /api/process-stepsbody新增工序模板—
DELETE /api/process-steps/{id}—删除工序模板—
GET /api/work-centers—工作中心列表(含日产能分钟)—
POST /api/work-centersbody新增工作中心—
PUT /api/work-centers/{id}body修改工作中心—
GET /api/employees?keyword=员工列表—
POST /api/employeesbody新增员工—
DELETE /api/employees/{id}—员工离职(置 IsActive=false)—
GET /api/equipments—设备列表与当前状态—
POST /api/equipmentsbody新增设备—
POST /api/equipments/{id}/checkbody提交点检;不合格会把设备置为故障—
GET /api/equipments/{id}/checks—该设备的历史点检记录—

执行:报工与检验 · ProductionController

方法路径用途鉴权
POST /api/production/reportbody报工:写报工 + 累加工序 + 追加追溯 + 推工单状态需登录
GET /api/production/reports?workOrderId=报工记录查询—
POST /api/production/inspectbody提交检验结果(合格/不合格/让步)需登录
GET /api/production/inspections?workOrderId=检验记录查询—

派工与排产 · SchedulingController

方法路径用途鉴权
GET /api/scheduling/assignments?workOrderId=&employeeId=派工单列表—
POST /api/scheduling/assignmentsbody派工:三道校验(超派/故障设备/已完工)需登录
POST /api/scheduling/assignments/{id}/start—开工,写实际开始时间需登录
POST /api/scheduling/assignments/{id}/finish—完工需登录
POST /api/scheduling/assignments/{id}/cancel?reason=撤回派工(已开工的撤不掉)需登录
GET /api/scheduling/load—各工作中心负荷率,>1 即排爆—
GET /api/scheduling/gantt?workCenterId=甘特数据(本地时间,避免时区偏移)—
GET /api/scheduling/my-tasks—当前登录人的待办派工需登录

物料需求 · MrpController

方法路径用途鉴权
GET /api/mrp/{workOrderId}—读已保存的 MRP 结果快照—
POST /api/mrp/{workOrderId}/calculate—算料:逐层展开 + 净需求 + 缺料判定,结果落库—
POST /api/mrp/batchbody [id…]批量算料并聚合出共用料的瓶颈—

不良品 · DefectsController

方法路径用途鉴权
GET /api/defects?status=&type=不良品列表—
POST /api/defectsbody登记不良品(类型 + 数量 + 处置建议)需登录
POST /api/defects/{id}/closebody关闭:根因与对策为空会被拒需登录
GET /api/defects/options—不良类型 / 处置方式 / 状态枚举—

设备效率 · OeeController

方法路径用途鉴权
GET /api/oee—全部设备的 OEE 三率(30 天窗口)—
GET /api/oee/summary—整厂汇总 + 损失归因—
GET /api/oee/{equipmentId}—单台设备详情,含口径说明文本—

报表 · ReportsController

方法路径用途鉴权
GET /api/reports/daily-output?days=14日产量趋势(已补齐空白日期)—
GET /api/reports/output-trend?days=14趋势摘要:均值、环比、峰谷—
GET /api/reports/operation-cycle—各工序平均节拍(实际 vs 标准)—
GET /api/reports/employee-performance?days=30员工产量、报废率、合格率排行—
GET /api/reports/spc?days=30SPC 控制图:CL / UCL / LCL 全给出—
GET /api/reports/defect-types?days=30不良类型分布与占比—
GET /api/reports/quality?days=30质量综合分析—
GET /api/reports/export/{type}type=5 种CSV 导出,带 UTF-8 BOM—

type 可选且仅可为这 5 个值(写错了返回 400): workorders(工单台账) · reports(报工明细) · inspections(检验记录) · output(日产量) · assignments(派工单)。

审计 · AuditController

方法路径用途鉴权
GET /api/audit?userName=&action=&succeeded=审计日志查询,可按人/动作/结果筛选需登录
GET /api/audit/summary?days=7按用户、按动作聚合 + 失败次数需登录
GET /api/audit/{id}—单条日志详情,含变更前后值需登录
GET /api/audit/trace/{targetType}/{targetId}—某个对象的完整操作履历需登录
抓重点:这 6 个接口撑起了 MES 的全部价值

POST /api/work-orders/{id}/release(下达)· POST /api/scheduling/assignments(派工)· POST /api/production/report(报工)· POST /api/mrp/{id}/calculate(算料)· GET /api/work-orders/{id}/trace(追溯)· POST /api/defects/{id}/close(质量闭环)。

其余 66 个接口都是围绕它们的主数据、查询与统计。 答面试题或做需求评审时,抓住这 6 个就不会跑偏。

附录 C

数据表结构总览

速查
目标:17 张表按域归位,标出主键、关键外键与设计要点。改表之前先看这里,避免破坏已有约束。

域一 · 基础数据(5 张)

长期稳定、被大量引用、几乎不做物理删除。

表关键字段设计要点
Materials
物料主数据
Code Name Unit Type OnHandQty SafetyStock IsActive Type 三值:原料 1 / 半成品 2 / 成品 3。OnHandQty 与 SafetyStock 是 MRP 算净需求的输入。
BomItems
物料清单行
ParentMaterialId ChildMaterialId Quantity LossRate Position 自引用双重外键:两条外键都指向 Materials,EF Core 无法自动推断,必须显式配置。删父件时用 Restrict,避免静默清空 BOM。
ProcessSteps
工艺路线行
MaterialId SequenceNo Name WorkCenterId StandardMinutes IsKeyProcess 模板表。下达工单时复制成 WorkOrderOperations,之后改这里不影响历史工单。
WorkCenters
工作中心
Code Name Type DailyMinutes IsActive DailyMinutes(默认 480)就是产能上限,排产与负荷计算的分母。
Employees
员工
Code Name Team Skill IsActive 与 Users 是一对零或一:不是每个工人都要登录系统。

域二 · 执行数据(5 张)

每天猛增、有明确状态、是业务规则的战场。

表关键字段设计要点
WorkOrders
工单
OrderNo MaterialId PlannedQty CompletedQty ScrapQty Status Priority Source 7 个状态(Draft 0 / Released 1 / InProgress 2 / OnHold 3 / Completed 4 / Closed 5 / Cancelled 9)。状态跃迁全部走状态机校验。
WorkOrderOperations
工单工序
WorkOrderId SequenceNo WorkCenterId PlannedQty CompletedQty StandardMinutes Status 下达瞬间的快照。报工、派工、检验都挂在它身上,工单本身不直接挂这些。
ProductionReports
报工记录
WorkOrderOperationId EmployeeId Qty ScrapQty ScrapReason StartedAt EndedAt ReportedAt 一次报工写一张,同时累加工序、追加追溯、必要时推工单状态——四张表一次 SaveChanges。
QualityInspections
质量检验
WorkOrderOperationId InspectorId InspectedQty PassedQty FailedQty Result DefectType Result 三值:合格 0 / 不合格 1 / 让步 2。三个数量字段要满足 Passed + Failed = Inspected。
OperationAssignments
派工单
AssignmentNo WorkOrderOperationId EmployeeId EquipmentId AssignedQty PlannedStart PlannedEnd ActualStart Status 独立表的原因是一道工序可拆给多人多机并行;EquipmentId 可空(人工工序不用设备),删设备时 SetNull。

域三 · 追溯与质量账本(2 张)

只增不改、不建外键、永远查得到。

表关键字段设计要点
WipTraces
在制品追溯账本
WorkOrderId WorkOrderOperationId Action Qty EmployeeId WorkCenterId Detail HappenedAt 故意不建外键,用冗余字段记住当时的人和机。WorkOrderOperationId 可空,因为下达/完工这类动作不挂在具体工序上。
DefectRecords
不良品记录
DefectNo WorkOrderId WorkOrderOperationId MaterialId Qty DefectType Disposition Status ReportedById HandlerId RootCause CounterMeasure ClosedAt 处置 4 值(返工 0 / 返修 1 / 报废 2 / 让步 3),状态 3 值(待处理 0 / 处理中 1 / 已关闭 2)。RootCause 是关闭的强制前置。

域四 · 设备与效率(2 张)

表关键字段设计要点
Equipments
设备
Code Name WorkCenterId Status RunningMinutes DownMinutes LastCheckAt Status 四值:运行 0 / 空闲 1 / 故障 2 / 保养 3。故障设备会被派工逻辑拦截,所以状态字段必须真实。
EquipmentChecks
设备点检
EquipmentId CheckedById IsPassed Result CheckedAt 点检不合格 → 设备置为故障,形成"点检 → 停用 → 拦截派工"的闭环。

域五 · 计划结果与系统支撑(3 张)

表关键字段设计要点
MaterialRequirements
MRP 结果
WorkOrderId MaterialId Level GrossQty OnHandQty NetQty IsShortage CalculatedAt 算料结果的快照,不是实时值。同一工单同一物料唯一索引,重算时先删后插。
Users
用户账号
UserName DisplayName PasswordHash Role EmployeeId IsActive LastLoginAt Role 五值留间隔:操作工 0 / 质检员 1 / 班组长 2 / 计划员 3 / 管理员 9——方便以后在中间插新角色。5 个权限属性是计算属性,必须 Ignore。
AuditLogs
审计日志
UserName Role Action TargetType TargetId BeforeValue AfterValue IpAddress Succeeded Message HappenedAt 人的履历,只增不改。与业务数据同一事务提交——不单独 SaveChanges,否则会出现"业务成功但日志丢了"。

关键约定(改表前必读)

decimal 全部转 double
SQLite 不支持 decimal 排序比较,EF Core 会直接报错。解法是在 ConfigureConventions 里全局把 decimal 映射为 double,而不是每个属性加特性。
计算属性必须 Ignore
像 IsOverdue 这种只有 getter 的属性,EF Core 会试图把它当列建,报"没有映射"错误。
追溯与审计不建外键
账本表要的是"永远查得到"。外键一旦级联,删主数据会连坐历史记录。
模板表可改,快照表不可改
ProcessSteps 改标准工时没关系;WorkOrderOperations 的 StandardMinutes 一旦下达就冻结。
枚举值留间隔
Cancelled 用 9 而不是 6(工单状态)、AssignmentStatus 用 9 不用 3——留出插入空间,避免将来改值导致历史数据错位。
软删除是默认
Materials / Employees / WorkCenters 都有 IsActive。查询默认只看 IsActive=true,历史单据不受影响。
附录 D

故障排查手册

速查
目标:按"报错原文 → 根因 → 处理"编排。卡住时先搜报错关键字,答案基本都在这里。

一 · 编译与启动

现象 / 报错根因处理
MSB3027 / MSB3021
无法复制 apphost.exe,文件被占用
上一轮的服务进程还在跑,锁住了输出目录的 exe。 先停进程再编译:
taskkill /F /PID <pid>。固定顺序:停进程 → 编译 → 删库 → 重启。
Microsoft.Data.Sqlite
"SQLite cannot order by expressions of type 'decimal'"
SQLite 没有原生 decimal 类型,EF Core 用它排序/比较即报错。 在 ConfigureConventions 里全局 properties.Add(typeof(decimal), ...) 映射为 double。不要逐个属性加特性。
"The property 'X.IsOverdue' could not be mapped" 计算属性只有 getter,EF Core 把它当普通属性尝试建列。 在该实体映射里加 e.Ignore(x => x.IsOverdue)。AppUser 的 5 个权限属性同理。
启动后端口不是 5100,或 --urls 不生效 launchSettings.json 会覆盖命令行参数。 用 dotnet run --no-launch-profile --urls "http://localhost:5100"。
改了实体但数据库没变 只改了 C#,没生成/应用迁移。 dotnet ef migrations add X → dotnet ef database update。开发期也可删 mes.db 让它重建。

二 · 数据库与迁移

现象 / 报错根因处理
"Unable to create a 'DbContext' of type ''" 迁移命令行找不到上下文——通常是 Program.cs 里 AddDbContext 写法让设计时工具无法推断。 确认 AddDbContext<MesDbContext>(o => ...) 泛型参数显式给出,并已安装 Microsoft.EntityFrameworkCore.Design。
"database is locked" 服务进程正持有 SQLite 文件句柄。 停掉服务再执行迁移或删库。SQLite 是单写者模型。
种子数据跑两次,数据翻倍 Seed 无幂等判断,每次启动都插一遍。 Seed 开头判断 if (await db.Materials.AnyAsync()) return;。
BOM 自引用迁移报"外键不明确" 两条外键都指向 Materials,EF Core 无法推断级联行为。 显式 HasOne(x => x.ParentMaterial).WithMany().HasForeignKey(x => x.ParentMaterialId).OnDelete(DeleteBehavior.Restrict),子件同理。
MRP 结果重复插入,或唯一约束冲突 重算时未清理旧结果。 (WorkOrderId, MaterialId) 建唯一索引;重算逻辑先删该工单已有结果再插。

三 · 业务规则被拒(这是好事,说明规则生效了)

提示原文含义处理
超派:工序计划 50 件,已派 50 件,本次最多还能派 0 件派工数量超过工序计划量。撤回已有派工,或改派到其他工序。这是产能约束在起作用。
设备「综合测试台」处于故障停机状态,不能派工设备 Status = Down。先修设备并改状态为运行/空闲,再派工。若点检不合格导致,先完成点检复检。
关闭前必须填写根因分析(RootCause)试图关闭不良品但没填根因/对策。补 RootCause 与 CounterMeasure。这是设计意图,不要绕过。
工单状态流转被拒走了状态机上不存在的边(如草稿直接到完工)。调 /next-states 看当前合法去向,按顺序走。
撤回派工失败该派工已开工(Status = Started)。先确认实际是否真在做。已开工的不允许静默撤回,要留痕迹。
报工后工序完成量超计划报工数量未做上限校验。报工也要按工序剩余量校验;允许超产时要用显式开关,而不是默默接受。

四 · 数据与指标异常

现象根因处理
OEE 性能开动率全是 100%,没有区分度 用了"实际产量 ÷ 理论产量"这种退化口径。 改用教科书口径:理论加工时间 ÷ 实际加工时间,其中理论加工时间 = 产量 × 单件标准工时。
性能开动率 >105% 标准工时定得太松,或实际耗时漏记。 不要直接截断成 100%。保留原始值并标记 StandardSuspicious 提示复核——掩盖异常比暴露异常更危险。
图表 X 轴日期跳变、缺口 当天无数据就没有那条记录,直接画点会跳过日期。 查询后按日期补齐:遍历日期区间,缺失的补 0,再画图。
SPC 控制限算出来偏小/偏大 用了总体标准差(除以 n)。 样本标准差要除以 n − 1(贝塞尔校正)。
MRP 算出的用量比手算少 逐层展开时同物料被覆盖而不是累加。 展开结果用字典按 MaterialId 累加;三条路径同用一个料时,要相加(如螺栓 244.8 = 三条路径相加)。
MRP 递归把进程跑死 / 栈溢出 BOM 存在环(A 的 BOM 里出现 A)。 递归必须带三件套:深度上限 + 按路径 HashSet 判环 + 同层合并。
设备状态是空闲但点检不合格 Seed / 业务逻辑没把点检结果同步到设备状态。 点检不合格要连带把设备置为 Down,否则"故障设备不许派工"的规则永远触发不了。

五 · 前端与接口调用

现象根因处理
接口回 工序 0 不存在 / 员工 0 不存在,明明传了 ID 请求体字段名与 DTO 不一致。比如 DTO 上是 OperationId, 传的是 workOrderOperationId,反序列化失败后 int 默认成 0。 以 DTO 定义为唯一契约。对不上时不要猜,去 Scalar(/scalar/v1) 看它真实的 schema。这类错误不会抛异常,只会静默变成 0 —— 最该被测试覆盖的一类。
页面报 401,令牌明明存在 api() 封装没自动带 Authorization 头。 在统一的 api() 里从 localStorage 取 token 并附加 Bearer 头,而不是每个调用点手写。
匿名访问 /api/auth/me 返回 200,不是 401 这是有意的设计:该接口不报错,而是如实返回 {"authenticated":false},前端据此显示"未登录"。 不要把它改成 401——前端启动时就是靠这个接口判断"令牌还在不在",401 会让它在网络层就被拦掉。真正的写操作鉴权由 AccessControl 负责。
登录成功但刷新页面就退回未登录 只在内存里存了用户信息。 启动时 restoreSession():从 localStorage 读令牌,调 /api/auth/me 校验,令牌过期就清掉。
CSV 导出用 Excel 打开中文乱码 没写 UTF-8 BOM。 响应内容开头写入 \uFEFF 三个字节,并设 text/csv; charset=utf-8。
甘特图整体偏移 8 小时 toISOString() 转成了 UTC。 改用本地时间格式拼接(年月日时分),不要用 toISOString() 生成传给后端的日期。
某个页签点进去一片空白 nav 里有按钮但 page-xxx 的初始化函数没在切换时调用。 在页面切换的分支里显式调用该页的 init/load 函数。新增页签要同步三处:nav 按钮、section 容器、切换分支。
curl 看 80 端口好像没跳转 curl -w '%{http_code}' 会跟随 301,看到的是终点状态码。 判断跳转看 curl -sI 的 Location 头,不要看 -w 输出。
导出文件大小对不上,怀疑截断 %{size_download} 在管道/超时下会失真。 文件完整性用 md5sum 比对,不要信 -w 的大小。

六 · 调试三件套

不知道怎么查的时候,按这个顺序来
  1. 看日志——ASP.NET Core 的控制台输出有完整异常栈,别只看 HTTP 状态码。
  2. 用 Scalar 单发一次——打开 /scalar/v1,直接对单个接口发请求,排除前端因素。
  3. 直查数据库——sqlite3 mes.db ".tables" 看表在不在;SELECT * FROM WorkOrders 看数据到底写成什么样。 业务规则的问题,99% 能在表里看到证据。
最贵的两个坑(本教程踩过)

一是用 Write 覆写已存在的单文件 HTML——13KB 的交付物被覆盖成 11 字节,没有 git、没有备份,只能靠服务器上那一版拉回来。 改文件一律用精确替换(Edit),动手前先 cp x x.bak。

二是 Restart=always 掩盖崩溃——某个服务在目录被删后崩溃循环了 72 万次,运维侧只看到"服务是活的"。 删服务目录前先 systemctl list-units --all | grep -i <业务名>。

附录 E

自测题(含答案)

自检
目标:30 道题,按阶段分组。先自己答,再点开对照——能答出 24 道以上,说明这套 MES 你已经真的掌握了。

阶段 0 · 认知与建模(第 1–3 课)

MES 在 ISA-95 里位于第几层?它和 ERP 的分工边界是什么?

L3 层。边界可以按时间尺度切:ERP 管月/周到天的计划与资源(接单、采购、成本),MES 管天到分钟的执行(今天做什么、谁做、做出来几个、合不合格)。判断依据:如果一个需求是"这批货几号交",归 ERP;如果是"3 号机今天下午做哪张工单",归 MES。

为什么工单要有 7 个状态,而不是简单用"未开始/进行中/已完成"三个?

因为三态无法表达真实业务里的停顿与终止:暂停(OnHold)区别于生产中,是"卡住了但还要继续";关闭(Closed)区别于完工(Completed),是"账已结、不可再动";作废(Cancelled)区别于草稿,是"正式取消,要留痕"。少了这些状态,业务就会用备注这种非结构化字段去补,规则就再也校验不了。

什么是"模板与实例分离"?不这么做会有什么后果?

模板是 ProcessSteps(物料级的工艺路线),实例是 WorkOrderOperations(工单下达时的快照)。不分离的后果是:某天工程改了某产品的标准工时,所有历史工单的工时也被一起改写,于是过去三个月的绩效、OEE、成本全部失真,且无法恢复。制造业软件里,"历史不可被规则变更追溯性改写"是硬要求。

阶段 1–2 · 地基与基础数据(第 4–8 课)

为什么 BomItem 的两条外键必须显式配置?

因为 ParentMaterialId 和 ChildMaterialId 都指向 Materials 表,EF Core 无法推断哪条对应哪个导航属性,也无法自动决定级联行为,会尝试猜测并抛错。必须写 HasOne(x => x.ParentMaterial).WithMany().HasForeignKey(x => x.ParentMaterialId),子件同理,且删除行为建议 Restrict(防止删一个物料默默清空 BOM)。

MRP 里的损耗率为什么要"逐层放大"?举例说明。

因为每一层都有自己的损耗,上层的损耗会让下层需要更多。若成品 → 部件 → 零件的三层各 5% 损耗:需要 1 个零件装 1 个部件,部件本身要 1.05 个零件(含部件损耗);装了 1 个成品需要 1.05 个部件 → 零件需求 = 1.05 × 1.05 = 1.1025,再叠加零件层损耗 ×1.05 ≈ 1.158。所以底层要多备约 15.8%,而不是 15%。

物料删除为什么用软删除(IsActive)而不是 DELETE?

因为物料被工单、BOM、追溯记录大量引用。物理删除会让历史单据出现悬空外键——"这张三个月前的工单用的料查不到了",追溯链断掉。软删除的效果是"新业务不能再用它,但历史记录照常能查"。

SQLite 下 decimal 报错,为什么全局转换比逐个属性加特性好?

因为实体多、字段多,逐个加容易漏;而且漏的那一个会在某个查询排序时才炸,很晚才发现。ConfigureConventions 里一次性声明 properties.Add(typeof(decimal), ...),所有 decimal 属性自动生效,新加属性也不会忘。

阶段 3 · 执行闭环(第 9–13 课)

下达工单时系统具体做了哪两件事?为什么这两件事不可合并到报工时做?

一、展开工序(按工艺路线生成 WorkOrderOperations);二、冻结快照(把标准工时等复制到工单工序上)。不能推迟到报工时,是因为:派工需要工序存在(没有工序无从派起)、计划需要看到工序级的工时、而快照一旦晚做,中间改过的模板就会污染工单。

一次报工要写/改哪几张表?为什么必须在一次 SaveChanges 里?

四张:① 新增 ProductionReports 报工记录;② 累加 WorkOrderOperations 的 CompletedQty/ScrapQty;③ 追加 WipTraces 追溯记录;④ 必要时更新 WorkOrders 的汇总量与状态。必须在一次 SaveChanges,因为这四步是一个业务事实的四个侧面——分开提交会出现"报了工但追溯没记"或"工序完成量变了但工单还是生产中"这种半成品状态。

WipTrace 为什么不建外键?

它是账本表,只增不改、要永远查得到。外键会带来级联删除:删掉一个员工或设备,历史追溯就被连坐删除,追溯链断裂。所以用 EmployeeId/WorkCenterId 这些冗余字段记住"当时是谁、在哪台机",不建约束,只记事实。

为什么让前端调 /next-states 而不是在页面里写死"哪些状态能点"?

因为状态机是后端的唯一权威。前端硬编码会出现两套规则:改了后端忘了改前端,用户点了按钮被拒——体验差且难查。让前端拉 /next-states 渲染按钮,规则改一处全站生效,前端永远不会展示非法操作。

"产品履历"和"人的履历"分别对应哪张表?为什么分开?

产品履历 = WipTraces(这件产品发生了什么);人的履历 = AuditLogs(这个人做了什么)。分开是因为提问方式不同:质量追溯问"这批货用的什么料、谁做的",权限审计问"张三昨天改了什么"。合成一张表会导致两头都查不清,且两表的生命周期、保留要求也不同。

阶段 4–5 · 质量与设备(第 14–18 课)

OEE 的三个率分别是什么公式?为什么用乘积而不是加权平均?

时间开动率 = (计划时间 − 停机) ÷ 计划时间;性能开动率 = 理论加工时间 ÷ 实际加工时间;合格率 = 良品 ÷ 总数。用乘积是因为三者是串联的损失——不转就没有产出、转得慢就少产出、产出不合格等于白转。乘积意味着任何一环掉链子都会拉低总分(0.9³ ≈ 0.73),逼你去看短板。加权平均会把问题平均掉。

性能开动率算出来 105% 说明什么?应该直接截断成 100% 吗?

说明标准工时定得太松(或者实际耗时漏记)。不能截断,截断等于把管理问题藏起来。正确做法是保留原始值、标记可疑(StandardSuspicious)并给出提示,让人回去复核标准工时。暴露异常比掩盖异常有价值。

点检不合格后,为什么还要同步把设备状态改成"故障"?

因为派工逻辑判断的是设备状态字段,不是点检记录。不同步的话会出现"点检不合格但设备显示空闲",于是"故障设备不许派工"的规则永远触发不了——规则写了等于没写。这类"字段之间的一致性"是 MES 里最容易漏的地方。

SPC 控制限为什么是 ±3σ?σ 用哪个公式?

3σ 之外的概率约 0.27%,在过程稳定的假设下,出现这么远的点通常意味着有异常原因(special cause)介入,值得调查。σ 用样本标准差(除以 n−1,贝塞尔校正),因为现场拿到的是一批样本,用它估计总体波动时 n−1 才是无偏的。

为什么"关闭不良品"必须强制填根因和对策?

因为用流程逼出信息:如果关闭不需要原因,人就会直接关闭,根因永远收不上来,质量改善就成了无源之水。把它做成硬性前置条件,等于用系统的约束替代人的自觉。这也解释了为什么这类校验不该做开关让人关掉。

阶段 6–8 · 计划、质量、效率进阶(第 20–25 课)

有限产能排产与无限产能排产的根本区别是什么?

是否承认资源有上限。无限产能假设"想要多少产能就有多少",排出来的计划一定不可执行;有限产能把工作中心 DailyMinutes 作为硬约束,排不下就是排不下,要么延后、要么加人加机。承认上限是排产算法的分水岭。

顺排和倒排分别用在什么场景?倒排的典型风险是什么?

顺排从"最早可开工时间"往后推,适合产能有余、以尽早出货为目标;倒排从交期往前推,适合交期紧、要判断"最晚什么时候必须开工"。倒排的典型风险是推出"昨天就该开工"——已经把计划排到过去了,说明产能根本不够,必须回头谈交期或加资源。

派工前必须校验哪三条?各自对应什么业务风险?

① 数量不超派——已派量 + 本次 ≤ 工序计划量,否则产能被虚占;② 设备不在故障态——否则派了也做不出来,且会误导计划;③ 工序未完工/未关闭——否则是给已完成的工作派活。三条缺一条,排产数据就不可信。

工作中心负荷率 >1 时系统的正确反应是什么?

显示出来并阻止/警示,而不是静默接受。负荷 >1 说明这段时间派工量超过了可用产能,计划不可执行。系统该做的是把这个冲突暴露给计划员,让他决定:延后、转其他中心、还是加班加点。

MRP 递归展开必须带哪三种保护?各自防什么?

① 深度上限——防异常数据导致栈溢出;② 按路径判环(HashSet)——防 BOM 成环(A 的 BOM 里出现 A)导致无限递归;③ 同物料合并累加——防同一物料经多条路径出现时被覆盖、算少了用量。前两个防死循环,第三个防算错。

批量算料(batch)比逐个算料多出来什么价值?

能发现共用料的聚合瓶颈。单张工单看,某料只缺 10 个;但五张工单一起排,同一个料合计缺 200 个——采购该看的是后者。批量算料的真正价值在于把"单工单视角"变成"整体物料视角"。

阶段 9–10 · 系统化与落地(第 26–30 课)

密码为什么要用 PBKDF2 这类"慢哈希",而不是 MD5/SHA256?

因为 MD5/SHA256 太快,攻击者可以每秒试上亿次。PBKDF2 通过故意增加迭代次数(本教程 10 万次)让每次验证变慢,把暴力破解的成本抬高数个量级。加上每个用户独立盐防彩虹表,再用 FixedTimeEquals 做固定时间比较防时序攻击。

审计日志为什么必须和业务数据在同一事务里提交?

否则会出现"业务成功但日志丢了"或反过来——前者让你事后无法追责(这正是审计的目的),后者记录了没发生的事。审计日志的价值完全建立在"它和业务事实严格同步"之上,一旦允许两条线,整张表就不可信了。

CSV 导出为什么必须写 UTF-8 BOM?

因为 Excel(尤其 Windows 版)打开无 BOM 的 UTF-8 文件时,会按系统默认编码(GBK) 解析,中文就成乱码。开头写入 \uFEFF 相当于告诉 Excel"这是 UTF-8",问题即解。这是一个每年都有人踩的经典坑。

报表里日期为什么要"补齐"?不补会怎样?

因为没发生生产的那天在数据库里没有记录。直接把记录画成折线,X 轴会跳过那一天,视觉上像是"连续生产",实际中间停产过。补齐(缺失日期补 0)才能让趋势图说真话。

EF Core 换数据库要改哪些地方?业务代码要不要动?

理论上只改 AddDbContext 里那一行 provider(UseSqlite → UseSqlServer/UseNpgsql)和连接串。业务代码一行不用动——这正是分层与 ORM 的价值。实践中需要注意两处差异:迁移文件要重新生成(不同 provider 语法不同),以及个别 provider 特有的类型行为。

为什么"填根因"这类校验不该做成配置开关?

因为开关给了绕过规则的后门,一旦有人嫌麻烦关掉,规则就形同虚设,数据质量立刻塌。真正重要的业务约束应该是硬编码的、不可配置的。可配置的应该只留给体验类差异(如分页大小、默认排序),不该留给"要不要守规矩"。

把 MES 改成你自己行业的业务,通常需要改什么、不需要改什么?

要改的是业务词汇与数据:物料、工序名称、检验项目、不良类型、设备清单、角色划分。不用改的是执行逻辑:状态机模式、模板/实例分离、追溯账本、报工驱动状态、事务边界、分层结构。这也说明一件事——MES 的通用性来自制造执行这件事本身的规律,而不是某个行业的特殊性。所以第一个行业做完,第二个会快得多。

自检评分

答对 24 道以上:这套 MES 你已经真的掌握,可以着手改造成自己行业的系统。

答对 18–23 道:主体概念到位,建议重点重读阶段 3(执行闭环)与阶段 6(排产与算料),这两块是 MES 的骨。

答对 18 道以下:不要急着重做,回到第 1–3 课把"为什么这样建模"想清楚——模型错了,后面的代码写得再漂亮都是返工。