MES 制造执行系统 · 开发实战
这不是一篇概念介绍,而是一条可完整跟做的工程路径。
下面配套的源码已经编译通过、跑在 localhost:5100 上,
72 个接口全部实测过——包括被业务规则拒绝的那些。
你照着 30 课走一遍,手里会多出一套真正能用的 MES:
不只是"能记工单",而是能排产、算料、控质量、看 OEE、追责任。
技术栈 .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 课产出物总表
一眼看清整条流水线:每课你做出什么、多出哪些文件。想知道"我做到哪了",对着这张表找最后一次成功验证的位置。
四 · 终点长什么样
这是跑完 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 里 ,搜「ASP.NET Core Web API」能搜到这个模板(搜不到 = 工作负荷没勾)
- 里敲
dotnet --list-sdks,输出里有 10.x
- 菜单下能看到「程序包管理器控制台」这一项(第 6 课要用)
三条都过了,直接进第 01 课。
主线
从头建一遍 · 全程施工主线
照着做
目标:把整个 MES 从空目录建到上线,拆成 90 个连续动作。
这一页只回答"手该往哪放"——点哪个菜单、填哪个框、建哪个文件、粘哪段代码、什么时候迁移、怎么确认做对了。
"为什么这么做"全部放在 30 课里,这里不重复。
这条主线怎么用
主线与 30 课是什么关系
主线 = 手脚。90 个动作,全部是可执行的操作,不带解释。
课程 = 脑子。30 课讲清每个动作为什么这么做、选错了会怎样。
第一次跟做时,以主线为主、课为辅:主线告诉你现在该干什么,卡住了再点课号进去读。
不要一开始就把 30 课通读一遍——读完你还是不知道从哪个菜单下手。
批次 0 · 认知:先读懂模型,再动手
对应 第 01–第 03 课 · 约 60 分钟 · 不产生任何代码 · 里程碑:能说清一张工单的一生、17 张表为什么这么分
这三课不写代码,但主线的 S01 到 S90 都建立在这三课的设计结论上。跳过它们的代价是:
写到第 12 课才发现工单状态少了一个、写到第 20 课才发现表该按域拆开。
- 读 第 01 课:搞清楚 MES 站在工厂五层架构的第三层,往上接 ERP、往下接设备。
自检:能对着架构图指出「工单从哪来、成品往哪去」
- 读 第 02 课:把工单的 7 个状态和它们之间的合法流转画一遍。
自检:合上课本能默画出这张图——第 10、11 课的状态机代码就是它的翻译
- 读 第 03 课:记下 17 张表分成哪三个域、每张表的外键指向谁。
自检:随便说一个功能(比如"报工"),能说出它会动哪几张表
看到:三课的自检都能过,就可以按下面的批次顺序动手了。
三课没读完就直接进 S01 也行——但每写到一个实体,都该回来对照第 03 课的表设计
11 个施工批次总览
整条主线分 11 批。每批结束都有一个能实际验证的里程碑,过了才进下一批。
每个动作只做三件事
① 在 VS 里点(菜单路径都写全了,含快捷键) →
② 把课里的代码整份复制进新建的文件(每个动作都标了粘哪一段) →
③ 看一眼「看到」那行,对上了再往下走。
凡是要动数据库的动作,都会单独标一个 迁移 标,
看到这个标就必须跑一次迁移命令,漏了后面必炸。
批次 1 · 立项:从空目录到能跑的空服务
对应 第 04 课 · 约 20 分钟 · 里程碑:浏览器打开 /scalar/v1
- 启动 VS 2026 → 开始窗口点 (已开 VS 则 )
- 搜索框输入 web api → 选中 ASP.NET Core Web API(C# / 跨平台,作者 Microsoft)→「下一步」
- 「配置新项目」页逐栏填:
| 项目名称 | Mes.Api |
| 位置 | 一个空的文件夹,例如 D:\Code\mes(不要用中文路径、不要放在桌面) |
| 解决方案名称 | Mes |
| 将解决方案和项目放在同一目录中 | 取消勾选 |
- 「其他信息」页逐项核对:框架
.NET 10.0 (长期支持) · 身份验证类型 无 · 勾选「启用 OpenAPI 支持」· 勾选「使用控制器」
- 点「创建」,等右下角 NuGet 还原进度转完
看到:解决方案资源管理器出现 Mes 解决方案 → 下面挂着项目 Mes.Api;
末尾是 还原成功
新建完立刻核对一遍,这 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 步漏勾了「使用控制器」,删掉整个项目重来(现在还没写代码,重来成本为零)
- (快捷键 Ctrl+Alt+L)
- 右键
WeatherForecast.cs → 删除 → 确认框点「确定」
- 展开
Controllers → 右键 WeatherForecastController.cs → 删除
看到:Controllers 文件夹从资源管理器里消失了。
这是正常的——VS 不显示空文件夹,第 7 课建第一个控制器时它会自动回来
- 切到 「浏览」 选项卡,逐个搜下面三个包
- 每个包:搜索结果里选中 → 右侧展开「版本」下拉,选
10.0.x 最新一版 →
勾上项目 Mes.Api → 点「安装」→ 接受许可协议
看到:双击 Mes.Api.csproj,里面有 3 个 <PackageReference>;
版本号全是 10.0.x 而不是 11.x(装了 11.x 要卸载重装)
- 双击
Program.cs,Ctrl+A 全选 → Delete 清空
- 打开 第 04 课「第四步 · 改写 Program.cs」,把那段
C# · Program.cs 整份复制过来
- Ctrl+S 保存
看到:Scalar.AspNetCore 下有蓝色波浪线 → 这是 Scalar 版本和 .NET 10 不匹配,
回 S04 把 Scalar.AspNetCore 卸了装最新稳定版。只有警告没有红线,可以先继续
S06改 appsettings.json 加连接串第 04 课
- 双击
appsettings.json,全选替换成第 04 课那段 JSON · appsettings.json
- 保存。这段里只有
ConnectionStrings:Default 是新增的,其余照原样
看到:文件里出现 "Default": "Data Source=mes.db"。
别改成绝对路径——相对路径正是我们要的,数据库文件会落在项目根目录
- 确认工具栏中间的运行按钮旁,配置下拉选的是 http(不是 https、不是 IIS Express)
- 按 F5。首次运行会问是否信任 HTTPS 开发证书 → 选「是」
- 等状态栏显示运行中,浏览器会自己打开
- 地址栏手动改成
http://localhost:5100/scalar/v1
看到:Scalar 的 API 文档界面,接口列表是空的——这是对的,第一个接口第 7 课才有。
返回 404 说明在命令行跑且没带环境变量,VS 里按 F5 不会遇到
✅ 批次 1 里程碑达成:空服务能跑
批次 2 · 数据层落地:让实体变成真正的表
对应 第 05 · 第 06 课 · 约 30 分钟 · 里程碑:mes.db 里能看到 Materials 表
S08建 Entities/ 与 Data/ 两个文件夹第 05 课
- 右键项目 Mes.Api → → 输入
Entities → 回车
- 同样再建一个
Data
看到:项目下多出两个文件夹图标。别用「新建项目」建——那会建出一个独立类库,本教程全部放在一个项目里
- 右键
Entities → → 名称填 Material → 「添加」
- VS 生成的骨架类里已经有
namespace Mes.Api.Entities;(新版 VS 用文件范围命名空间)
- 全选 → 替换成 第 05 课那段
C# · Entities/Material.cs,保存
看到:文件里同时有 class Material 和 enum MaterialType。
枚举值从 1 开始(RawMaterial = 1),不是 0——这不是笔误
- 右键
Data → → 名称 MesDbContext
- 全选 → 替换成第 05 课那段
C# · Data/MesDbContext.cs,保存
看到:类里有 ConfigureConventions(decimal → double 转换)和
OnModelCreating。这两段一个都不能省,
少了 ConfigureConventions,第 16 课看板排序会直接崩
S11把 DbContext 接进 Program.cs第 05 课
- 打开
Program.cs,加两行 using:
using Mes.Api.Data; 和 using Microsoft.EntityFrameworkCore;
- 在
builder.Services.AddOpenApi(); 之后插入
AddDbContext<MesDbContext>(...UseSqlite(...)) 三行(第 05 课标了 ← 的那段)
看到:没有红色波浪线。报 CS0246 找不到类型 = Data/MesDbContext.cs
的命名空间不是 Mes.Api.Data,回去核对拼写
- (Ctrl+Shift+B)
- 看 ,下拉选「生成」
看到:已成功生成,0 个错误;项目根目录下没有 mes.db。
有 CS8618 警告说明某个字符串字段漏了 = string.Empty,回去补
第 4 课装的 Design 包是给命令行用的;PMC 里的 Add-Migration 要的是另一个包 Tools。
两个包长得像,缺了报错也像,但解法完全不同。
- ,VS 下方弹出 PowerShell 面板
- 确认面板顶部的「默认项目」下拉框选的是
Mes.Api(选错会在外面生成迁移文件)
- 敲
Install-Package Microsoft.EntityFrameworkCore.Tools 回车
看到:控制台输出 已成功安装。
提示"无法识别 Add-Migration" 就是这步没做
- 仍在 程序包管理器控制台(默认项目 = Mes.Api)
- 敲
Add-Migration InitialCreate 回车,等它跑完
看到:项目里多出 Migrations/ 文件夹和 3 个文件
(xxx_InitialCreate.cs · xxx_InitialCreate.Designer.cs · MesDbContextModelSnapshot.cs)。
双击第一个文件,能看到 EF 生成的 CreateTable 建表代码
- 同一个控制台里敲
Update-Database 回车
看到:输出 Applying migration '...InitialCreate' Done.;
项目根目录出现 mes.db。找不到文件就全盘搜一次 mes.db,
多半是你在解决方案根目录敲了命令,工作目录不一样
- 打开 DB Browser for SQLite → 「打开数据库」→ 选
Mes.Api/mes.db
- 「数据库结构」标签页看表清单;「浏览数据」标签页看数据
看到:三张表——Materials(你的业务表)、
__EFMigrationsHistory(迁移记录,里面有一条 InitialCreate)。
Materials 的 Code 列上带 UNIQUE 索引
- 打开
Program.cs,在 var app = builder.Build(); 之后、app.Run(); 之前
- 插入第 06 课那段
using (var scope = ...) { ... db.Database.Migrate(); }
看到:这段现在只有两行是对的。第 06 课说明了第三行
SeedData.Initialize(db); 要等第 18 课才能加——现在加会编译失败
- 右键解决方案 Mes → ,命名
.gitignore
- 内容填:
bin/ · obj/ · *.db · .vs/
看到:文件出现在解决方案根。
把 *.db 忽略掉是有意的——数据库是运行产物,不进版本库,换台机器跑一次迁移就有了
✅ 批次 2 里程碑达成:实体已变成真正的表
批次 3 · 主数据:物料 / BOM / 工艺
对应 第 07–第 09 课 · 约 50 分钟 · 里程碑:三组主数据接口能在 Scalar 里调通
这一批开始出现"每课都要做"的三个动作
① 建实体 → ② 在 MesDbContext 里加一行 DbSet → ③ 跑一次迁移。
后面每一课新增实体都是这三步,我在主线里会逐步写简,
到第 20 课之后就只写「加实体 + DbSet + 迁移」六个字,你得自己会了。
- 右键项目 Mes.Api → →
Controllers
看到:前面删空的 Controllers 回来了
S20写第一个控制器 MaterialsController第 07 课
- 右键
Controllers → ,选
「API 控制器 - 空」(不要选「使用 Entity Framework 的 API 控制器」——那会生成一堆要删的脚手架)
- 名称填
MaterialsController → 「添加」
- 全选 → 替换成第 07 课那段
C# · Controllers/MaterialsController.cs,保存
看到:F5 起来后,/scalar/v1 的接口列表里出现
Materials 分组(GET / POST / PUT / DELETE 五六个)
- 右键
Entities → → BomItem,内容照第 08 课的实体小节
- 打开
Data/MesDbContext.cs,加一行 public DbSet<BomItem> BomItems => Set<BomItem>();
- 把第 08 课
C# · Data/MesDbContext.cs(节选) 里的双重外键配置(两个 HasOne...WithMany + DeleteBehavior.Restrict)放进 OnModelCreating
- PMC 里敲
Add-Migration AddBomItem → Update-Database
看到:DB Browser 里多出 BomItems 表,
两条外键的 on delete 是 RESTRICT——
这是为了防止删父件时把子件数据一起带走
S22写 BomController(含递归展开)第 08 课
- 右键
Controllers → 添加 → 控制器 → 「API 控制器 - 空」 → 名称 BomController
- 替换成第 08 课
C# · Controllers/BomController.cs(核心片段) +
第二段 BomController.cs(片段),保存
- 重新生成(Ctrl+Shift+B),F5 起来在 Scalar 里调一次展开接口
看到:Scalar 里 Bom 分组出现;调展开接口返回树形结构。
返回 500 多半是库里还没有 BOM 数据——第 18 课的种子数据会补上,现在可以手工 POST 几条试
S23新建 WorkCenter 与 ProcessStep 实体迁移第 09 课
- 右键
Entities → 添加 → 类 → WorkCenter,粘第 09 课第一段
- 同样建
ProcessStep,粘第 09 课第二段
MesDbContext 里加两行 DbSet,并按第 09 课说明配置外键
- PMC:
Add-Migration AddProcessData → Update-Database
看到:库里多出 WorkCenters、ProcessSteps 两张表
- 右键
Controllers → 添加 → 控制器(API 控制器 - 空)→ WorkCentersController
- 再建一个
ProcessStepsController
- 两个都用第 09 课对应的代码填入(结构同 MaterialsController,改实体名即可)
看到:Scalar 里 WorkCenters、ProcessSteps 两个分组出现
- 在 Scalar 里用 POST 接口依次创建:2 个工作中心(如「装配线 A」「机加工 B」)
- 再给某个物料建 3 道工序的工艺路线
看到:GET 能查到刚才建的数据。
这批数据现在没大用,但第 11 课下达工单时要靠它把工艺"拍"成工序实例
- Scalar 里 POST 两条物料:一条原材料(如
M-001 钢板)、一条成品(如 P-001 电机)
- 试一次故意违规:再用同一个
Code 建一条,看返回什么
看到:重复编码返回 400 而不是 500 ——
说明唯一索引真的在数据库层生效了,不是前端糊弄
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 课
Entities/ 下建 WorkOrder.cs,粘第 10 课第一段
- 再建
WorkOrderOperation.cs,粘第 10 课第二段
MesDbContext 加两个 DbSet + 外键配置
- PMC:
Add-Migration AddWorkOrder → Update-Database
看到:库里多出 WorkOrders 和 WorkOrderOperations 两张表。
这是"模板 / 实例分离"的落地——ProcessStep 是模板,
WorkOrderOperation 是下达时拍出来的实例
S29建 Services/ 文件夹与 WorkOrderService第 10 课
- 右键项目 Mes.Api → →
Services
- 右键
Services → 添加 → 类 → WorkOrderService
- 粘进第 10 课那段
C# · Services/WorkOrderService.cs(片段)(含状态机定义)
看到:编译报错不要慌——这一课只给了片段,
里面引用的 DTO 和数据库方法要在第 11 课补全。如果你想要一次编译通过,直接看 S30 的完整版
S30补全服务并写 WorkOrdersController第 11 课
WorkOrderService.cs 换成第 11 课 C# · Services/WorkOrderService.cs(核心),
它包含「下达 = 状态机校验 + 工艺路线展开」的完整逻辑
- 再按第二段
WorkOrderService.cs(片段) 补上通用状态流转
Controllers/ 下建 WorkOrdersController(API 控制器 - 空),
粘第 11 课 Controllers/WorkOrdersController.cs(片段)
- 在
Program.cs 注册服务:builder.Services.AddScoped<WorkOrderService>();
看到:Scalar 里 WorkOrders 分组出现,且能查到「允许的状态流转」接口返回值。
报「无法解析服务 WorkOrderService」 = 第 4 步没做
S31新建 ProductionReport 实体迁移第 12 课
- 建
Entities/ProductionReport.cs(报工记录,第 12 课开头给的实体)
MesDbContext 加 DbSet + 外键 + decimal→double 自动生效(S10 那段全局约定在起作用)
- PMC:
Add-Migration AddProductionReport → Update-Database
看到:库里多出 ProductionReports 表
S32写 ProductionService(五道校验)第 12 课
Services/ProductionService.cs,整份粘第 12 课 C# · Services/ProductionService.cs
Controllers/ProductionController.cs(API 控制器 - 空),按第 12 课的接口清单填动作
Program.cs 注册 ProductionService
看到:报工接口能通。这一课的核心是五道校验:
工单状态、工序存在、数量合法、不能超报、报废必须带原因——故意报一次超报,应该被拒
S33新建 WipTrace 实体并接入报工迁移第 13 课
- 建
Entities/WipTrace.cs,粘第 13 课第一段
- 加
DbSet + 迁移(Add-Migration AddWipTrace → Update-Database)
- 回到
ProductionService,在报工成功的地方顺手写一条 WipTrace(第 13 课的核心要求)
WorkOrdersController 加追溯链查询接口,粘第 13 课第二段
看到:报一次工,WipTraces 表就多一行;
调追溯接口能查出这条工单的完整链。追溯记录必须和业务写在同一个事务里——这是第 27 课审计也会复用的原则
S34新建 QualityInspection 实体迁移第 14 课
- 建
Entities/QualityInspection.cs,粘第 14 课第一段
- 加
DbSet + 迁移(Add-Migration AddQualityInspection → Update-Database)
看到:库里多出 QualityInspections 表
- 把第 14 课
C# · Services/ProductionService.cs(片段) 合并进你的 ProductionService
- 在工艺路线里把某道工序标成关键工序(
IsKeyProcess = true)
看到:对关键工序直接报工会被拒绝,提示必须先检验。
这就是 MES 和普通 CRUD 的分水岭——业务规则拦在服务层,不在前端
在 Scalar 里按顺序手工走一遍,这是第 4 批的验收动作:
- POST 建一张工单(引用 S26 建的那个成品物料)
- PUT 把工单下达 → 看
WorkOrderOperations 表是否自动多出几行
- 逐道工序 POST 报工 → 看
WorkOrderOperations 的完成数量变化
- 对关键工序直接报工 → 应该被拒
- POST 一条检验记录 → 再报工 → 这次应该通过
- 把工单推到完工 → 查追溯链
看到:六步都符合预期。
有一处不符就先别往下走——第 5 批的看板会把这些数据聚合出来,前面的错会被放大
✅ 批次 4 里程碑达成:执行闭环通了
批次 5 · 看板与前端界面
对应 第 15–第 17 课 · 约 60 分钟 · 里程碑:浏览器里能点着操作
- 建
Entities/Equipment.cs、Entities/EquipmentCheck.cs,各粘第 15 课对应段落
- 加
DbSet + 迁移(Add-Migration AddEquipment → Update-Database)
- 建
Controllers/EquipmentsController.cs,粘第 15 课控制器片段
看到:库里多出 Equipments、EquipmentChecks;
Scalar 里 Equipments 分组能调通,且开动率接口对零记录的设备不抛除零异常
Controllers/DashboardController.cs(API 控制器 - 空)
- 粘第 16 课
(核心) 和 (片段) 两段
- 起服务,调一次总览接口
看到:一个接口返回五组数字(在制工单数、今日产出、合格率等)。
这里就是 S10 那个 decimal 约定要救你的地方——如果当初没加 ConfigureConventions,
带 OrderBy 的统计会在这里抛
SQLite cannot order by expressions of type 'decimal'
- 右键项目 Mes.Api → → 命名
wwwroot
- 照第 17 课
C# · Program.cs(片段) 在 Program.cs 里挂上静态文件托管
看到:wwwroot 是 ASP.NET Core 的**约定目录**,不用在代码里写路径。
文件夹名拼错(比如 wwwRoot)页面就是 404,且不报错
wwwroot/ 下新建 index.html
- 把第 17 课
JavaScript · wwwroot/index.html(片段) 的核心封装
(api() 请求封装、$() 选择器、esc() 转义)先搭起来
- 按第 17 课「界面功能一览」逐个补齐标签页
- 起服务,浏览器打开
http://localhost:5100/
看到:浏览器里出现车间操作界面,能从界面上看到 S36 造的工单和数据。
界面白屏:按 F12 开控制台看报错,多半是接口路径写错或 JSON 字段名对不上
- 在界面上新建一张工单 → 下达 → 报工 → 看板数字跟着变
- 刷新页面,确认数据还在(说明真的进了数据库,不是内存态)
看到:操作流畅、数字正确、刷新不丢。
此时你已经有一个"看得见摸得着"的 MES 了
✅ 批次 5 里程碑达成:界面可用
批次 6 · 收尾上线:第一段落完结
对应 第 18 · 第 19 课 · 约 40 分钟 · 里程碑:一套完整系统 + 线上可访问
- 把第 18 课
C# · Program.cs(片段) 的异常中间件插进 Program.cs,
位置必须在 app.MapControllers() 之前
看到:故意调一个会抛异常的接口,返回的是结构化的 JSON 错误,
不是一堆 HTML 堆栈。顺序放错中间件就不生效,且不会有任何报错提示
- 建
Data/SeedData.cs,粘第 18 课 C# · Data/SeedData.cs
- 回到
Program.cs,把 S17 那段里预留的第三行补上:
SeedData.Initialize(db);
看到:这一行就是第 6 课让你"先留空"的那一行——
现在 SeedData 类存在了,编译通过
- 把项目根目录的
mes.db 重命名成 mes.db.old(不要直接删,留个后路)
- F5 启动
- 用 DB Browser 打开新生成的
mes.db 看数据
看到:所有表自动建好(迁移生效),且物料 / 工艺 / 工单等表里已经有演示数据。
确认无误后再删掉 mes.db.old
- 右键项目 Mes.Api →
- 目标选「文件夹」→ 路径填一个干净目录(如
D:\Code\mes-publish)
- 配置选 Release,目标运行时若服务器已装 .NET 10 运行时则选「框架依赖」
- 点「发布」,等完成
看到:发布目录里出现 Mes.Api.dll、appsettings.json、wwwroot/ 等。
看不到 mes.db 是对的——数据库不进发布包
- 把发布产物传到服务器(第 19 课给了
scp / tar 两种写法)
- 装 .NET 10 运行时(若选框架依赖),或直接用自包含产物
- 建 systemd 服务单元并
systemctl enable --now
- 配 nginx 反代到本机端口
看到:systemctl status 是 active (running);
curl 你的域名能拿到首页 HTML
- 把连接串从相对路径改成服务器绝对路径(如
/var/lib/mes/mes.db)
- 确认运行服务的用户对那个目录有写权限
- 重启服务,看日志
看到:服务正常启动,日志里没有 unable to open database file。
报这个错 99% 是权限或目录不存在
✅ 批次 6 里程碑达成:第一段落完结,MES 已上线
批次 7 · 排产与物料
对应 第 20–第 22 课 · 约 60 分钟 · 里程碑:能排档期、能算缺料
从这里开始,实体三步走不再详写
下面的步骤里凡是「加实体 + DbSet + 迁移」,流程就是
建 Entities/Xxx.cs → 在 MesDbContext 加一行 DbSet → PMC 敲 Add-Migration + Update-Database。
你已经做过五遍了。
Services/SchedulingService.cs,粘第 20 课 (排产部分)
Program.cs 注册该服务
- 用第 20 课的
curl 命令触发一次排产
看到:每道工序被算出开始 / 结束时间,且同一工作中心上的工序不重叠——
这是"有限产能"的意思
- 建
Entities/OperationAssignment.cs + 迁移
- 把第 21 课
(派工校验片段) 合进 SchedulingService
- 建
Controllers/SchedulingController.cs
看到:三条硬校验生效——派工人数不为负、派工不能超工序剩余量、同一人同一时段不重复派
- 建
Entities/MaterialRequirement.cs + 迁移
Services/MrpService.cs,粘第 22 课 (递归展开部分)
Controllers/MrpController.cs(API 控制器 - 空)
- 注册服务,用第 22 课的
curl 跑一次算料
看到:BOM 逐层展开后,缺料的行被标出来(净需求 > 0)。
第 22 课列了四个坑,逐个对一遍——循环 BOM、损耗率、单位不一致、已有库存抵扣
✅ 批次 7 里程碑达成:排产与算料都通了
批次 8 · 质量闭环
对应 第 23 · 第 24 课 · 约 40 分钟 · 里程碑:不良品全流程 + SPC 控制图
- 建
Entities/DefectRecord.cs + 迁移
Services/DefectService.cs,粘第 23 课 (关闭校验)
Controllers/DefectsController.cs,注册服务
看到:登记不良品后关闭时不填根因会被拒(这是硬闸);
每条不良品登记都会在 WipTraces 里留一条(第 13 课的追溯链要求)
- 在
Services/AnalyticsService.cs(没有就新建)里加
第 24 课 (SPC 部分) 的 SpcAsync
- 挂一个控制器动作暴露它
- 用第 24 课
curl 跑一次
看到:返回每组的均值 / UCL / LCL,越界点被单独标出来
(而不是只给一个"合格率")
✅ 批次 8 里程碑达成:质量闭环完成
批次 9 · 设备效率
对应 第 25 课 · 约 25 分钟 · 里程碑:OEE 三率可查
Services/OeeService.cs,整份粘第 25 课代码
Controllers/OeeController.cs,注册服务
- 用第 25 课
curl 跑一次
看到:每个设备返回 时间开动率 × 性能开动率 × 合格率 = OEE,
并且带一句损失归因(该先改哪一项)。除零要有防护——零工时的设备不能崩
✅ 批次 9 里程碑达成:OEE 可查
批次 10 · 系统治理:登录 / 审计 / 报表
对应 第 26–第 28 课 · 约 90 分钟 · 里程碑:带权限、可追溯、能出报表
- 建
Entities/AppUser.cs(用户/账号)、Entities/Employee.cs(员工)
- 加
DbSet + 迁移(Add-Migration AddAuth → Update-Database)
- 给
AppUser.PasswordHash 加唯一索引
看到:库里多出 AppUsers、Employees。
第 26 课强调的"员工 ≠ 账号"就在这两张表的关系里——一个人可以没有账号,一个账号也只绑一个员工
Services/TokenService.cs —— 粘第 26 课「令牌」小节代码(HMAC-SHA256 自签)
Services/AuthService.cs —— 粘「密码」小节代码(PBKDF2 加盐哈希)
- 两个都在
Program.cs 注册
看到:数据库里绝不出现明文密码。
用 DB Browser 看 AppUsers 表,PasswordHash 是一串 base64
Services/AccessControl.cs —— 粘第 26 课「权限校验」小节代码
Controllers/AuthController.cs —— 登录 / 当前用户 / 登出三个动作
- 在需要权限的动作上挂权限检查(第 26 课说明了挂哪些)
- 用一个开关控制权限是否强制生效(
Mes:EnforcePermission),先留成 false
看到:先用 false 跑通——所有接口照旧能调。
这个开关是故意的:第 29 课的测试脚本要跑两种模式
- 按第 26 课
JavaScript · wwwroot/index.html 那段改前端
- 关键:不要在前端写死"后端没开权限"。改成从
/api/auth/me 返回的
enforcePermission 字段动态推导 ENFORCED 变量
- 补
restoreSession() 和 renderLoginHint()
看到:把开关改成 true 重启后,前端不用改一行代码,
右上角提示自动变成「未登录 · 只能查看」
- 建
Entities/AuditLog.cs + 迁移
Services/AuditService.cs —— 粘第 27 课代码
Controllers/AuditController.cs,注册服务
- 把审计写进各业务服务的同一个事务里(第 27 课的核心设计:审计不单独提交)
看到:做一次业务操作,AuditLogs 表同步多一行。
业务失败了审计也不该有记录——这就是"同事务"的意义,去试一次故意失败的操作用来确认
- 在
AnalyticsService 里补第 28 课三段:日期补齐、单件工时分母、CSV 工具
- 建
Controllers/ReportsController.cs,暴露六张报表 + 导出动作
- 用第 28 课
curl 下载一次 CSV
看到:用 Excel 打开下载的 CSV,中文不乱码
(因为按第 28 课写了 UTF-8 BOM)、日期没有空洞、分母为零时不出现 NaN
✅ 批次 10 里程碑达成:治理能力齐备
批次 11 · 测试与交付
对应 第 29 · 第 30 课 · 约 60 分钟 · 里程碑:回归测试全绿 + 生产加固
- 在解决方案根目录建
test-api.sh
- 整份粘第 29 课的那段脚本(双模式:先探测
enforcePermission 再决定要不要跑鉴权断言)
- 确认服务在跑,然后
bash test-api.sh
看到:false 模式 15 通过 + 1 SKIP;
把开关改成 true 重启再跑,是 19 通过。
有 FAIL 就贴出来逐条查——脚本里的断言都是真实业务规则,不是形式主义
- 解决方案根目录建
ui-test.js,粘第 29 课代码
node ui-test.js
看到:52 项全过。它用 jsdom 把 index.html 真跑一遍,
专项检查 XSS 转义和接口字段名是否对得上
- 按第 30 课「上线前必须改的四件事」逐条处理(密钥、CORS、错误详情、日志级别)
- 把
Mes:EnforcePermission 改成 true
- 重新发布并部署
看到:生产环境打开时,浏览器里看不到 API 文档界面(/scalar/v1 返回 404)。
这是对的,别去"修"它——文档界面本来就不该暴露在生产
- 线上打开首页 → 登录 → 建单 → 下达 → 报工 → 看板
- 跑一次
bash test-api.sh,把地址指向线上
- 重启一次服务,确认数据还在(验证数据库没被塞在临时目录里)
看到:全流程通过、重启不丢数据。
到这里,30 课全部走完,你手上是一套真能用的 MES。
🎉 主线终点:90 个动作全部完成
主线走完之后
走主线时最容易断的三个地方
一、漏迁移。症状是"程序不报错但一查数据就 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 的逻辑(采购、财务、成本核算)写进来,白写几百行
五层架构
制造业的信息系统按 ISA-95 标准分五层。MES 卡在正中间第三层,这个位置决定了它的全部特质:
| 层级 | 系统 | 时间尺度 | 关心什么 |
| L4 | ERP 企业资源计划 | 天 ~ 月 | 订单、采购、库存、财务 |
| L3 | MES 制造执行系统 | 秒 ~ 天 | 工单、派工、追溯、质量、设备 |
| L2 | SCADA 监控与数据采集 | 毫秒 ~ 秒 | 实时监控、报警、组态画面 |
| L1 | PLC / 传感器 | 毫秒 | 逻辑控制、信号采集 |
| 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 个模块都是往同一套骨架上挂,那时你会写得非常快。
本课任务
- 记住五层架构里 MES 的位置(L3),以及它的时间尺度(秒 ~ 天)
- 想清楚一个问题:为什么 ERP 不能直接管车间?
提示:ERP 的时间尺度是天/月,而车间的问题是按分钟爆发的
02
业务主流程与工单状态机
建模
目标:画出工单的完整生命周期,理解为什么状态机是 MES 的业务规则本体。
本课产出 · 不动手写代码
本课交付工单 7 个状态和它们之间的合法流转图
做完的标准合上课本能默画出这张图,并说清每个状态允许转到哪几个状态、不能转到哪几个
为什么不能跳第 10 课的状态机定义、第 11 课的通用流转接口,就是这张图的代码翻译。图不清楚,代码必然写歪
一张工单的一生
车间里所有动作都围着工单转。它有 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 : [];
为什么用「允许列表」而不是「禁止列表」
允许列表(白名单)默认拒绝一切未声明的流转。将来新增一个状态时,
如果忘了配置它的出口,系统会明确地拒绝——而不是悄悄放行一个不该存在的流转。
这是安全设计的基本原则:默认拒绝,显式允许。
本课任务
- 把上面这段状态机字典抄进你的
WorkOrderService
- 想一想:如果业务要求支持"完工后允许返工",应该怎么改?
答案:在 Completed 的允许列表里加上 InProgress。注意只改一处,规则就生效了——这就是集中定义的好处
03
数据模型设计
建模
目标:先设计出支撑核心业务的 12 张表,理清它们的关系。这是整个项目最关键的一步。
说明:这 12 张是"地基"。后面阶段 6–9 会再补 5 张(派工单、不良品、MRP 结果、用户、审计日志),合计 17 张——但都是在理解了这 12 张之后自然长出来的,不是一开始就要画齐。
本课产出 · 不动手写代码
本课交付17 张表的关系设计,按三个域分组
做完的标准随便说一个功能(比如"报工"),能立刻说出它会动哪几张表、外键怎么连
为什么不能跳第 5 课到第 30 课建的每一个实体都来自这张设计图。这里错一张表,后面要有十课跟着返工
分三个域组织
不要按"模块"切表,要按数据的生命周期切。基础数据长期不变,执行数据每天猛增,追溯数据只增不改:
| 域 | 表 | 特征 |
| 基础数据域 |
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 课解决。
本课任务
- 在纸上(或工具里)画出这 12 张表的 ER 图,标出所有一对多关系
- 回答:为什么
WorkOrderOperation 不能直接用
ProcessStep 的 Id 代替,非要复制一份?
提示:工艺标准改了以后,历史工单的追溯记录还能对得上吗?
- 检查每个实体的删除行为(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 个对话框
- ① 打开「创建新项目」对话框
启动 VS 2026,开始窗口左侧点蓝色链接 。
VS 已经开着的话,走菜单 ,或按 Ctrl+Shift+N。
看到:标题为「创建新项目」的窗口——中间一列模板卡片,顶部一个搜索框 + 三个下拉筛选(语言 / 平台 / 项目类型)
- ② 选对模板
顶部搜索框输入 web api(小写、带空格)。列表里选中 ASP.NET Core Web API。
认准三点:语言列写 C#、平台列写 跨平台(或 Linux/macOS/Windows)、卡片右下角作者是 Microsoft。
选中后点右下角「下一步」。
看到:卡片变蓝框选中,「下一步」按钮从灰变可用
搜不到这个模板 = 装机时漏勾了「ASP.NET 和 Web 开发」工作负荷。回 开工准备 看补救办法,不用重装 VS
- ③ 「配置新项目」页 · 四个字段
这一页只有一个「下一步」,没有多余选项。逐栏对照下表填:
| 项目名称 | Mes.Api ← 一字不差 |
| 位置 | 一个空文件夹,例如 D:\Code\mes。不要用中文路径、不要放桌面、不要放 C:\Program Files |
| 解决方案名称 | Mes ← 它默认跟着项目名变成 Mes.Api,手动改掉 |
| 将解决方案和项目放在同一目录中 | 取消勾选(勾着会让解决方案文件挤进项目文件夹,以后加 WPF 客户端时结构会乱) |
- 填完点「下一步」。
看到:对话框底部路径预览里,
...\mes\Mes.slnx 和 ...\mes\Mes.Api\ 是两条并列路径
- ④ 「其他信息」页 · 六项逐个核对
这页是所有新手翻车的重灾区,逐项对一遍再点「创建」:
| 框架 | .NET 10.0 (长期支持)。下拉里只有 .NET 8/9 = SDK 太旧,先装 .NET 10 SDK |
| 身份验证类型 | 无。第 26 课我们自己写登录;选模板的会灌一堆用不上的身份代码 |
| 启用 OpenAPI 支持 | 保持勾选。Scalar 调试界面靠它拿接口描述,取消勾选第 7 课就没界面可调 |
| 使用控制器 | 必须勾选!不勾会生成 app.MapGet("/weatherforecast", ...) 这种最小 API 写法,本教程 30 课的代码全部对不上 |
| 为 HTTPS 配置 | 保持勾选。不影响使用——第 6 步会说明开发时为什么走 http |
| 启用 Docker | 取消勾选。本教程不涉及容器 |
- 点「创建」。VS 生成项目并自动还原 NuGet 包,右下角状态栏转圈。
看到:主界面打开,右侧「解决方案资源管理器」出现 Mes 解决方案 → 里面挂着 Mes.Api 项目;底部还原进度条消失后不再出现红字
还原失败(输出窗口红字「无法还原包」)= 网络或 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 操作
- 展开项目节点:在解决方案资源管理器里点 Mes.Api 左边的小三角。
看到:展开出
Controllers、Properties、
appsettings.json、Program.cs、WeatherForecast.cs
—— 一共这几项。没有 WeatherForecast.cs 说明第一步漏勾了「使用控制器」,回上一步
- 删第一个文件:右键
WeatherForecast.cs → 删除。
看到:弹出「是否永久删除 WeatherForecast.cs?」确认框
- 确认框里看清路径再点「确定」——它显示的应该是
...\Mes.Api\WeatherForecast.cs,路径对了才点。
VS 的「删除」是扔进回收站,不是永久删除,删错了能捞回来。但别依赖这个,动手前看清路径
- 删第二个文件:展开
Controllers → 右键 WeatherForecastController.cs → 删除 → 确认。
看到:Controllers 文件夹跟着消失了——VS 默认不显示空文件夹
文件夹消失了不用管
空文件夹在解决方案资源管理器里不可见,这是 VS 的正常行为,不是删坏了。
第 7 课建第一个控制器时它会自动回来;实在想现在就看到它,点工具栏的「显示所有文件」图标即可。
删完 Controllers 文件夹可能一起消失了(空文件夹 VS 不显示)。
没关系,第 7 课新建控制器时它会自动回来。
第三步 · 装 3 个 NuGet 包
用「管理解决方案的 NuGet 程序包」能一次装完,比逐个右键项目省事——它会在一个界面里
反复切换搜索,不用来回点菜单。
VS 2026 操作
- 打开包管理界面:点菜单
。
看到:VS 主区域打开一个标签页,左侧四个选项卡「已安装 / 浏览 / 更新 / 整合」,
右侧一列是解决方案里的项目清单(目前只有 Mes.Api)
- 切到「浏览」,在搜索框里输入第一个包名(见下表)。
看到:下方列出匹配结果,右边带「版本」下拉和「安装」按钮,
搜索结果顶部还有个「包括预发行版」复选框——保持不勾
- 选版本:装之前先点开版本下拉,滚到
10.0.x 里最新的一版选中。
默认会选最新版,可能是 11.x。项目框架是 net10.0,装 11.x 会报兼容性警告甚至编译失败。
EF Core 大版本必须跟着 .NET 主版本走
- 安装:在右侧把项目 Mes.Api 前面的复选框勾上 → 点「安装」。
看到:弹出「预览更改」对话框,列出要装的包和它带的依赖;
点「确定」→ 接受许可协议 → 安装完成后「输出」窗口有
已成功安装
- 重复第 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 → 点「更新」(它会把降级也当更新处理)。
不用删项目重来。
装完怎么确认
- 在解决方案资源管理器里双击
Mes.Api.csproj。
看到:文件以 XML 打开,<ItemGroup> 里有三条
<PackageReference Include="..." Version="10.0.x" />
- 看不到变化?在解决方案资源管理器里右键项目 → ,
或按 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 操作
- 确认启动配置:看标准工具栏中间那个绿色实心三角(「开始调试」)按钮,
它右边有一个下拉框,里面应该是 http。
看到:下拉里通常有三项——
http、https、
可能还有 IIS Express。选 http
选 https 会跳到 https://localhost:7100,
而本教程后面所有 curl 命令都按 http:5100 写,端口对不上会让你怀疑人生
- 启动:按 F5(或点绿色三角)。
看到:首次运行弹出「信任 ASP.NET Core HTTPS 开发证书?」→ 点「是」
(本教程走 http,但证书装上没坏处,免得以后又弹)
- 等状态栏从「正在生成…」变成运行中,底部出现
Now listening on: http://localhost:5100。
看到:浏览器自动打开,地址是 http://localhost:5100。
多半是 404——因为还没有任何页面和接口,这是对的
- 手动访问调试界面:把地址栏改成
http://localhost:5100/scalar/v1 回车。
看到:Scalar 的 API 文档界面——左侧接口列表、右侧详情区。
此时接口列表是空的,这是对的:第一个接口第 7 课才有
- 停不下来怎么办:菜单 ,
或按 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 读的就是它
本课任务
- VS 里新建项目(ASP.NET Core Web API / .NET 10 / 勾使用控制器),解决方案名
Mes
- 删除
WeatherForecast.cs 和 Controllers/WeatherForecastController.cs
- 装 3 个包:
Sqlite · Design · Scalar.AspNetCore
- 替换
Program.cs,修改 appsettings.json
- 按 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 操作
- 在解决方案资源管理器里右键项目 Mes.Api → ,命名
Entities
- 同样方式再建一个
Data 文件夹
- 右键
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; 报红,
多半是包没装成功。回到
确认 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 只是"登记"了这个上下文,并不会去连数据库或者建文件。
真正生成数据库是下一课的事。
本课任务
- 建
Entities/ 与 Data/ 两个文件夹(右键项目 → 添加 → 新建文件夹)
- 用「添加 → 类」建出
Material.cs,内容照抄上面的代码
- 同样建出
MesDbContext.cs,务必带上 ConfigureConventions
- 回到
Program.cs,补上两处 using 和 AddDbContext
- 编译并启动,确认服务照旧能跑
验证
本课还没有接口可调,能验证的是"没写坏":
VS 里
VS 2026 操作
- 按 Ctrl+Shift+B 生成解决方案,看 ,
应该是
已成功生成,0 个错误
- 按 F5 启动,确认
/scalar/v1 照旧能打开
- 去项目根目录看,应该没有
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 · 程序包管理器控制台(推荐)
- 点菜单 ,
下方会弹出一个 PowerShell 面板
- 面板顶部有个 「默认项目」下拉框,确认它选的是
Mes.Api。
选错了会在错误的地方生成迁移文件
- PMC 的迁移命令由另一个包提供,先装它:
Install-Package Microsoft.EntityFrameworkCore.Tools
- 生成迁移(名字叫 InitialCreate,可自取):
Add-Migration InitialCreate
- 应用到数据库:
Update-Database
路径 B · 开发人员 PowerShell(等价写法)
- 点菜单
- 切到项目目录:
cd Mes.Api
- 如果提示"未找到 dotnet ef",装一次全局工具(只需一次):
dotnet tool install --global dotnet-ef
- 生成迁移、应用迁移:
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,再回来补那一行。
这就是"每课都能编译通过"的具体做法——把未来的依赖先留空。
本课任务
- 用路径 A(PMC)跑通
Add-Migration InitialCreate 和 Update-Database
- 确认项目里出现了
Migrations/ 文件夹和 mes.db
- 打开
Migrations/xxx_InitialCreate.cs,找到 CreateTable,读一遍 EF 生成的建表代码
- 在
Program.cs 里加上"启动时自动应用迁移"那段
- 用 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 查回来
控制器与服务层的分工
先明确一条纪律:控制器只做三件事——收参数、调服务、返回结果。
物料这种纯主数据没有业务规则,所以直接操作 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 错误。
本课任务
- 实现
MaterialsController 的五个方法
- 用 Scalar 或 curl 依次测试:新增、查询、修改、重复编码、停用
- 思考:如果业务要求"已被工单引用的物料不允许停用",这个校验写在哪一层?
验证
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)
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)
}));
本课任务
- 实现
BomItem 实体与 DbContext 配置
- 实现树形展开接口,务必加上
visited 防环
- 构造一条环状数据(让 A 的 BOM 里出现 A),验证接口不会挂死
- 给成品建一棵三层 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 里两组接口可调
工作中心: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 | 总装 | 总装线 A | 25 分 | ✅ |
| 50 | 成品检验 | 检验站 | 10 分 | ✅ |
本课任务
- 实现
WorkCenter、ProcessStep 实体与控制器
- 在 DbContext 里配置
(MaterialId, SequenceNo) 唯一索引
- 建 5 个工作中心,给成品电机配 5 道工序
- 标出哪些是关键工序——想想"为什么成品检验必须关键"
阶段 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 两张表
工单实体
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),三个月后就是六位数,没人记得住。
实现上要注意:必须加唯一索引。
并发创建时两个请求可能算出同一个序号,靠数据库约束拦住,
再捕获异常重试一次即可(本教程简化处理,直接暴露错误)。
本课任务
- 实现
WorkOrder 与 WorkOrderOperation 实体
- 实现
CreateAsync 和工单号生成器
- 加一个校验:产品必须已经配置工艺路线,否则不允许建工单
(提示:查 ProcessSteps 有没有该产品的记录)
11
工艺路线实例化
核心
目标:实现"下达"这个动作——一次状态流转 + 一次模板复制。
本课施工
起点实体有了,但服务还只是个片段,跑不起来
新建Controllers/WorkOrdersController.cs
改写Services/WorkOrderService.cs —— 补全下达逻辑(状态机校验 + 工艺路线展开)
迁移(无,本课不动实体)
VS 操作控制器用「添加 → 控制器(API 控制器 - 空)」;服务在 Program.cs 里 AddScoped 注册
完成标志下达一张工单后,WorkOrderOperations 表自动多出几行——工艺被"拍"成了工序实例
下达 = 状态机校验 + 工艺路线展开
这是 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 === '草稿') 显示下达按钮
这类硬编码,那就意味着规则被复制了一份,早晚会不一致。
本课任务
- 实现
ReleaseAsync 与 TransitAsync
- 实现
next-states 接口
- 测试:建一张工单 → 下达 → 检查工序是否按 10/20/30/40/50 生成
- 测试:对同一张工单再次下达,确认被幂等校验拦住
验证
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 控制器 - 空)」
完成标志报工接口能通,且超报被拒——五道校验真的在拦人
报工为什么这么重
表面上,报工就是"一线员工点一下提交"。但实际上它要:
| 动作 | 影响 |
| 通过 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 就能做完工判断。
本课任务
- 实现
ProductionService.ReportAsync,五道校验一道都不能省
- 逐条测试校验规则,确认每种非法操作都被正确拒绝
- 重点测:跳过工序报工(应被拒绝,并提示前道工序名)
- 测:一次报满数量,确认工序直接跳到"已完工"
验证
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 表多一行;调追溯接口能查出完整链条
追溯表的设计哲学
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
客户投诉时,把这张表打出来——什么时候、谁、在哪、做了多少、废了多少,一目了然。
本课任务
- 实现
WipTrace 实体与追溯查询接口
- 确保报工、检验、下达、流转四个动作都会写追溯记录
- 验证:跑一遍完整流程,看追溯链是否按时间顺序完整呈现
验证
curl -s http://localhost:5100/api/work-orders/1/trace
应看到从"下达"到各工序"报工""完工"的完整时间线,每条都带操作人和工作中心
14
质量检验与关键工序
核心
目标:实现检验记录,理解 MES 如何用"关键工序"拦住批量质量问题。
本课施工
起点报了工就算过了,可关键工序没人把关
新建Entities/QualityInspection.cs
改写ProductionService —— 加"关键工序必须先检验"的闸门
迁移Add-Migration AddQualityInspection → Update-Database
VS 操作实体用「添加 → 类」;检验相关的控制器动作加在 ProductionController.cs 里
完成标志对关键工序直接报工会被拒,先提交检验再报工才通过
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。
检验是质量事件,必须和报工一样留在履历里。
客户拿到货问"这批做过哪些检测",答案就在追溯链的"检验"条目里。
本课任务
- 实现检验实体、接口与 Service 方法
- 加一条业务规则:关键工序未检验合格,不允许下一道工序报工
实现位置:在 ReportAsync 的校验③里,检查前道关键工序是否有合格记录
- 测:送检数与合格/不合格数不等时,确认被拒绝
阶段 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 而不是崩)
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),而不是让它抛异常。
本课任务
- 实现
Equipment、EquipmentCheck 实体与接口
- 实现点检接口,验证"不合格自动停机"规则生效
- 给 4 台设备录入运行/停机分钟数,检查开动率计算是否正确
16
生产看板聚合接口
看板
目标:把散落的单据聚合成管理者一眼看懂的数字。
本课施工
起点数据散在十几张表里,看板要一次问五个问题
新建Controllers/DashboardController.cs
迁移(无,本课不动实体)
VS 操作「添加 → 控制器(API 控制器 - 空)」,名称 DashboardController
完成标志一个接口返回五组数字(在制工单 / 今日产出 / 合格率 / 设备状态 / 待检数)
看板要回答的五个问题
| 问题 | 指标 | 数据来源 |
| 今天有多少活? | 工单总数、在制数、完工数 | 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
});
}
本课任务
- 实现
summary、wip、quality 三个接口
- 给每个除法都加上除零防护
- 验证:把某个工单报废数调大,确认报废率随之变化
验证
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/ 出现车间界面,能从界面新建工单、报工
为什么前端不用框架
本教程的前端是单文件原生 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 |
本课任务
- 创建
wwwroot/index.html,实现五个页面
- 在
Program.cs 里加 UseDefaultFiles + UseStaticFiles
- 访问
http://localhost:5100/ 确认界面正常
- 在页面上实际操作一遍:下达工单 → 报工 → 看追溯链
验证
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 重启,所有表和演示数据自动生成
为什么不能到处 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;
不能省——否则每次重启服务,数据翻一倍。
本课任务
- 实现全局异常处理器,删掉控制器里所有 try-catch
- 实现
SeedData,造出完整的中文演示场景
- 删掉
mes.db 重启服务,确认数据自动重建
- 连续重启两次,确认数据没有翻倍(幂等生效)
19
部署上线与后续演进
工程化
目标:把系统部署到服务器,并规划下一步往哪走。
本课施工
起点本地跑得好好的,别人访问不到
新建发布产物 + systemd 服务单元 + nginx 配置
改写连接串改成服务器绝对路径;Mes:EnforcePermission 先留 false
迁移(本课不动实体,不用迁移)
VS 操作右键项目 → → 目标「文件夹」→ 配置选 Release → 发布
完成标志公网能打开首页,systemctl status 是 active (running),服务重启后数据还在
发布与部署
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
完成标志每道工序算出开始 / 结束时间,且同一工作中心上的工序不重叠
本课任务
- 分清三种排产策略,想清楚为什么大部分工厂只需要"有限产能顺排"
- 实现两个核心函数:
AlignToShift(对齐上下班时间)和
AddWorkingMinutes(跨天累加工作量)
- 跑一次派工,确认返回的计划时间真的落在工作时段内
排 = 决定什么时候做;派 = 决定谁来做
这两个字经常被混用,但它们是两件事:
| 动作 | 回答的问题 | 产出 |
| 排产(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;
}
}
这段代码里最重要的一个设计
起算点是"该工作中心已有任务的最晚结束时间",
不是"上一道工序的结束时间"。这一个选择决定了它是有序排产还是无序排产。
如果按工序顺序排(上一道做完才排下一道),同一道工序的多个任务会串行排开,
但不同工单之间缺乏全局视野;如果按工作中心排(本课的做法),
每个工作中心的活是连续堆叠的,更接近真实车间。
两者都能用,但你要知道自己选的是哪一种、为什么——
而不是抄完代码就算了。
三个必须注意的细节
- 不能排到过去。当某个工作中心的任务都已完成(lastEnd 是三天前),
起算点必须回落到
DateTime.Now,否则会生成一张"从上周开始干"的派工单。
- 下班时间的边界要一致。
AlignToShift 用
>= 判断下班,AddWorkingMinutes 也用
>= 判断。两个地方不一致就会出现在 17:00 整被排进今天、
然后瞬间跳到明天的诡异结果。
- 算法必须能终止。这个
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 控制器 - 空)」
完成标志派工接口能返回工作中心负荷;超剩余量派工被拒
本课任务
- 建
OperationAssignment 表,理解为什么派工不能直接写在工序上
- 实现派工的三条硬校验(超派 / 离职 / 设备故障)
- 算出每个工作中心的负荷率,并做超期预警
为什么派工必须是一张独立的表
最省事的做法是在 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)
本课任务
- 实现递归 BOM 展开,用量要逐层累计(这是 MRP 最容易错的地方)
- 加上环路保护,让配错的 BOM 不会把服务撑爆
- 把结果落库成快照,并做多工单批量齐套检查
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
对照检查:
| 物料 | 层级 | 毛需求 | 算式 |
| 内六角螺栓 | 1 | 244.8 | 30 × 8 × 1.02 |
| 电机壳体 | 1 | 30 | 30 × 1 × 1.00 |
| 冷轧钢板 | 2 | 63.0 | 37.8 + 9.45 + 15.75(三条路径相加) |
| 漆包铜线 | 2 | 2626.5 | 30 × 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 控制器 - 空)」
完成标志关闭不良品时不填根因会被拒;每次登记都在追溯链里留痕
本课任务
- 建
DefectRecord,理解它和 QualityInspection 的分工
- 实现"不填根因和对策就不允许关闭"这道硬闸
- 走一遍完整流程:登记 → 处理 → 关闭,并在追溯链里看到它
检验和不良品,是两件事
| 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,越界点被单独标出来
本课任务
- 理解中心线 ±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%)。
选几个 σ,本质上是在"漏报"和"误报"之间选一个代价更低的点。
三个实现细节
- 只取有产出的天参与计算。没开工的天合格率是 0,
如果算进去,均值会被拉低一大截,控制限完全失效。
代码里是这句
var valid = daily.Where(d => d.TotalQty > 0)。
- 用样本标准差(除以 n-1)而不是总体标准差(除以 n)。
在统计上,样本标准差是对总体标准差的无偏估计。样本量小的时候差别很明显,
而且工业界的 SPC 规范用的就是 n-1。
- 下控制限不能小于 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,并带一句损失归因(建议先改哪一项)
本课任务
- 理解 OEE 三个分项各自在回答什么问题
- 用"理论加工时间 ÷ 实际加工时间"算性能开动率(不是用台数比)
- 处理一个真实的建模陷阱:统计窗口不一致
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 后,未登录只能看、不能改;密码在库里是哈希串
本课任务
- 分清
Employee 和 AppUser,理解为什么要分开建表
- 实现 PBKDF2 加盐哈希,以及登录令牌的签发与校验
- 把权限判断集中到一处,而不是散落在 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; }
}
三条底线
- 只存哈希,永不存明文,也永不回传。
连"显示密码"这种功能都不该有——管理员也不能看到用户的密码,只能重置。
- 账号不存在和密码错误返回同一个错误信息。
如果分开提示,攻击者可以用它枚举出哪些账号真实存在。代码里就是这么写的:
if (user is null || !user.IsActive || !VerifyPassword(...))
三个条件合并成一个判断。
- 登录成功和失败都要写审计日志。
暴力破解在日志里是看得见的——同一个账号短时间内大量失败,
这是最典型的入侵信号。没有日志,你连被打了都不知道。
令牌:手写一遍,是为了以后知道该看哪一层
真实的项目请直接用 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 同步多一行;业务失败时审计不该有记录
本课任务
- 建
AuditLog,理解它和 WipTrace 的区别
- 把审计写入嵌进已有的服务,让业务数据和日志在同一个事务里提交
- 做审计汇总,学会从日志里看出异常信号
两本账,各管一半
| 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 打开中文不乱码、日期无空洞
本课任务
- 实现按天产量、工序节拍、员工绩效三种聚合
- 处理两个容易算错的细节:日期补齐、单件工时的分母
- 写出 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());
- UTF-8 BOM 必须有。没有它,中文 Excel 用系统默认编码(GBK)打开,
整张表就是一堆方块。这是中文 CSV 导出最经典的坑,几乎人人踩过一次。
- 字段要转义。报废原因里写个逗号,整行的列就全部错位——
而且错得很隐蔽,因为数据看起来"好像还在"。
- 用 \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 项全过
本课任务
- 想清楚 MES 里最该被测的是什么——不是计算,是规则
- 写一组 API 层的业务规则测试
- 写一个前端渲染测试,防止改一处样式崩掉整个页面
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(这是对的)
本课任务
- 过一遍上线前必须改的清单(数据库 / HTTPS / 备份 / 鉴权)
- 看一遍真实部署踩到的四个坑(这一节最实用)
- 理解索引和查询口径对性能的影响
- 拿到一张按优先级排序的演进路线图
上线前必须改的四件事
| # | 改什么 | 为什么 | 怎么改 |
| 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% 的慢查询是这两个原因:
- 看板接口在扫全表。首页要"按状态列工单",
如果
Status 上没索引,工单涨到 10 万条时首屏就要几秒。
- 聚合在内存里做。本项目为了演示清晰,很多统计是
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
工单 · WorkOrdersController
看板 · DashboardController
基础数据 · Materials / Bom / ProcessSteps / WorkCenters / Employees / Equipments
执行:报工与检验 · ProductionController
派工与排产 · SchedulingController
物料需求 · MrpController
不良品 · DefectsController
设备效率 · OeeController
报表 · ReportsController
type 可选且仅可为这 5 个值(写错了返回 400):
workorders(工单台账) · reports(报工明细) ·
inspections(检验记录) · output(日产量) ·
assignments(派工单)。
审计 · AuditController
抓重点:这 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 张)
长期稳定、被大量引用、几乎不做物理删除。
域二 · 执行数据(5 张)
每天猛增、有明确状态、是业务规则的战场。
域三 · 追溯与质量账本(2 张)
只增不改、不建外键、永远查得到。
域四 · 设备与效率(2 张)
域五 · 计划结果与系统支撑(3 张)
关键约定(改表前必读)
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
故障排查手册
速查
目标:按"报错原文 → 根因 → 处理"编排。卡住时先搜报错关键字,答案基本都在这里。
一 · 编译与启动
二 · 数据库与迁移
三 · 业务规则被拒(这是好事,说明规则生效了)
四 · 数据与指标异常
五 · 前端与接口调用
六 · 调试三件套
不知道怎么查的时候,按这个顺序来
- 看日志——ASP.NET Core 的控制台输出有完整异常栈,别只看 HTTP 状态码。
- 用 Scalar 单发一次——打开
/scalar/v1,直接对单个接口发请求,排除前端因素。
- 直查数据库——
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 课把"为什么这样建模"想清楚——模型错了,后面的代码写得再漂亮都是返工。