一、先说结论:分岔发生在第一张表
做过流程行业系统的人转到离散制造,最容易踩的坑是「以为只是字段不同」。
真正不同的是主对象。
流程 ERP 的主对象是凭证(voucher)与物料(material):业务发生 → 凭证;物料移动 → 库存事务。生产执行被压缩成「生产订单 + 投料/产出」两个动作。
离散车间的主对象是工单与工单工序:一张工单 = 一个产品 + 一串工段 + 一个数量;执行落在「某道工序谁在什么时候做完了几件」。
这不是字段差异,是两种记账单位。记账单位不同,后面所有索引、约束、状态机、统计口径全都跟着不同。
下面按表逐层拆,最后给三条迁移踩坑和一组性能注意。
二、工单:数量必须是「件」,且必须来自订单
流程系统的数量几乎都是 numeric(18,3) 这样的浮点,因为料是可称的。
离散的数量要绑件:
create table work_orders (
id uuid primary key,
tenant_id uuid not null,
order_id uuid not null, — 必须来自客户订单
order_number varchar(50) not null, — 工单号(业务主键)
part_no varchar(100), — 件号
product_name varchar(200),
quantity numeric(12,2) not null check (quantity > 0),
material varchar(100),
length numeric(12,3),
width numeric(12,3),
height numeric(12,3),
status varchar(20) not null default 'pending',
created_at timestamptz not null default now(),
unique (tenant_id, order_number)
);
create index idx_wo_tenant_status on work_orders (tenant_id, status);
create index idx_wo_order on work_orders (tenant_id, order_id);
两个约束值得单独讲。
order_id not null:工单不能凭空建。这条在数据库层就要卡住,因为一旦允许自由建工单,订单→工单的追溯链在第一环就断了,之后的报工、外协、对账全部悬空。
unique (tenant_id, order_number):单号在租户内唯一。跨租户可以重复(A 厂和 B 厂都可能有 WO20261027001),所以唯一键必须带 tenant_id 前缀——这是多租户系统最常见的一处疏漏。
数量为什么不用浮点?因为下游一整套计算都挂在「件」上:
- 报工完成数量累加,必须是件
- 工序计划工时是「小时/件」,乘数量才是总工时
- 成本里的加工费按件数口径放大
一旦允许 0.5 件,报工记录会出现半件完工,库存和成本全部带零头,对账时谁都解释不清。
三、工序:不是工艺路线模板,是这张单实际要走的路径
流程系统的工序建模是「工艺路线模板(routing)+ 参数段」:一个产品绑一条路线,路线里的段是抽象的。
离散必须落到具体工单:
create table work_order_process (
id uuid primary key,
tenant_id uuid not null,
work_order_id uuid not null references work_orders(id) on delete cascade,
seq int not null, — 工段顺序,从 1 开始
process_id uuid not null, — 工段定义(含小时费率)
process_name varchar(100),
planned_hours numeric(10,3), — 计划工时(小时/件)
status varchar(20) not null default 'pending',
unique (work_order_id, seq)
);
create index idx_wop_wo on work_order_process (tenant_id, work_order_id, seq);
unique (work_order_id, seq) 这条约束表达的是「件内串行」:同一张工单的工序顺序号不能重复。
但它只能防重号,防不了乱序执行。真正的串行校验在应用层:
# 报工前置校验(伪代码)
def assert_process_order(wop, prev_wops):
if wop.status == 'pending' and any(p.status not in ('done', 'skipped') for p in prev_wops):
raise ConflictError("前道工序未完工,不能开工当前工序")
注意这里用 skipped 而不是直接删除:工序被跳过也是一条历史,删掉会让后续追溯断档(客户改需求不做热处理,是常态)。
件内串行、件间并行,是这个模型的第二个隐含约束。
不同工单(或同工单不同件)可以同时在不同工段上跑:A 单在磨、B 单在车、C 单在外协。流程系统的「工序段」模型表达不了这一层——它只有一条流水,没有「多张单同时卡在不同段」的概念。
四、报工:一行业务事实,四个字段各有脾气
离散的核心执行记录是报工。
create table work_report (
id uuid primary key,
tenant_id uuid not null,
work_order_process_id uuid not null references work_order_process(id) on delete cascade,
worker_id uuid,
equipment_id uuid,
started_at timestamptz,
completed_at timestamptz,
quantity numeric(12,2), — 本次完成数量
duration_hours numeric(10,3), — 完工时按 started/completed 自动算
allocated_hours numeric(10,3), — 多人工段的分配工时
source varchar(20) not null default 'manual', — manual / outsourcing
client_uuid uuid, — 客户端幂等键(离线补传用)
created_at timestamptz not null default now()
);
create unique index uq_report_client on work_report (tenant_id, client_uuid) where client_uuid is not null;
create index idx_report_wop on work_report (tenant_id, work_order_process_id);
create index idx_report_time on work_report (tenant_id, started_at);
四个字段的脾气:
duration_hours 是跨度,不是净加工时间。 它是 completed_at – started_at,中途停机、午休、去帮别的忙都算在里面。这是有意的定义:一台机床被某张单占用的时间,本身就是成本的一部分。要统计净加工时间得靠设备联网或分段报工。
allocated_hours 解决多人工段。 一道工序三个人干,工时总量要在人头上分。分配是独立动作,改一个人的分配数要校验其他人总和不超过这道工序的实际工时。
source 区分人工与外协自动驱动。 多入口写同一张工单时,来源标识是排查的第一把钥匙——数量对不上,先按 source 分堆,人工录入错误和外协对接错误,排查方向完全不同。
client_uuid 是幂等键。 车间弱网、PDA 离线补传是常态,同一条报工可能被重传三次。没有幂等键,补传就等于重复报工,数量翻倍。部分唯一索引(where client_uuid is not null)让历史数据不受影响,同时保证新数据不重复。
五、成本:只有两个字段,算法是确定的
最常见的误解是「系统会自动把料、工、外协三摊都归集」。
离散实现的成本表只有两摊:
create table work_order_costs (
id uuid primary key,
tenant_id uuid not null,
work_order_id uuid not null unique,
material_cost numeric(12,2) default 0,
processing_cost numeric(12,2) default 0,
loss_factor double precision not null default 1.0,
total_cost numeric(12,2) default 0,
note text,
updated_at timestamptz
);
算法:
material_cost = length × width × height(mm³) × density(g/cm³) × unit_price(元/kg) / 1e6 × loss_factor
processing_cost = Σ( planned_hours × process.hourly_rate × qty_factor )
qty_factor = work_order.quantity (per_piece 模式)
= 1 (total 模式)
total_cost = material_cost + processing_cost
实现片段(Python 风格,语义与真实实现一致):
def recalc(wo, wop_rows, material, mode, loss_factor):
# 料:体积 × 密度 × 单价 / 1e6 × 损耗系数
material_cost = Decimal("0")
if material and all([wo.length, wo.width, wo.height,
material.density, material.unit_price]):
vol_mm3 = float(wo.length) * float(wo.width) * float(wo.height)
kg = vol_mm3 * float(material.density) * float(material.unit_price) / 1_000_000.0
material_cost = Decimal(f"{kg * loss_factor:.2f}")
# 工:计划工时 × 工段费率 × 数量系数
qty_factor = wo.quantity if mode == "per_piece" else 1
processing_cost = Decimal("0")
for wop, rate in wop_rows: # rate 来自 process.hourly_rate
processing_cost += Decimal(
f"{float(wop.planned_hours or 0) * float(rate or 0) * qty_factor:.2f}")
return material_cost, processing_cost, material_cost + processing_cost
三点必须写进文档,否则三个月后一定有人问:
还有一个坑:重算通常是覆盖式的。
update work_order_costs
set material_cost = ?, processing_cost = ?, total_cost = ?, updated_at = now()
where work_order_id = ? and tenant_id = ?;
覆盖意味着历史成本不保留。要算「变更改掉了多少钱」,必须在变更前后各留一次数(导出、截图或写进变更单备注)。指望系统给你差额,是没有的——系统提供的是计算器,不是损益表。
六、变更:独立单据 + 双快照,客户的改动不能直接改字段
流程系统的变更通常是「修改单据字段 + 操作日志」。离散不行,因为变更会影响数量、工序、成本口径。
离散的做法是两种变更单分治:
— 客户订单变更(单号 OC{YYMM}{4位})
create table customer_order_change (
id uuid primary key,
tenant_id uuid not null,
change_no varchar(50) not null,
order_id uuid not null,
change_type varchar(20) not null, — delivery_date / cancel / force_cancel
status varchar(20) not null default 'draft', — draft / executed / void
original_data text, — 变更前 JSON 快照
new_data text, — 变更后 JSON 快照
change_detail text, — 人类可读说明
reason text not null, — 原因必填
executed_by varchar(36),
executed_at timestamptz,
unique (tenant_id, change_no)
);
— 工单变更(单号 CH{YYMM}{4位})
create table work_order_changes (
id uuid primary key,
tenant_id uuid not null,
change_no varchar(50) not null,
work_order_id uuid not null,
change_type varchar(20) not null, — delivery_date / quantity / process / cancel
status varchar(20) not null default 'draft',
old_value text,
new_value text,
reason text not null,
unique (tenant_id, change_no)
);
— 工单变更明细:与工段结构对齐
create table work_order_change_items (
id uuid primary key,
tenant_id uuid not null,
change_id uuid not null references work_order_changes(id) on delete cascade,
seq int not null,
process_id uuid not null,
planned_hours numeric(10,3),
action varchar(20), — add / delete / modify
old_planned_hours numeric(10,3),
old_remark text
);
两个设计决定值得解释。
客户订单变更只开放三类。 改交期、取消、强制取消。改数量属于危险变更,被挡在这个层级之外——一张订单的数量变化会连锁影响料、工时、外协和报价。这是有意收窄的入口,不是功能缺失。
工单变更明细按工段一对一排。 action 三值 + old_planned_hours,这样成本才可能算出「差多少」。如果明细只记一句「工序有调整」,这张变更单等于废纸——车间不知道改哪道,成本算不出差额。
另外,force_cancel(强制取消)要级联取消其下 pending / in_progress 的工单,但外协、发货、对账不自动动。这条边界必须写死在文档里:级联做多了会连带毁掉下游的真实业务记录。
七、迁移:从流程系统搬数据,三个必踩的坑
做过一次流程 ERP → 离散 MES 的数据迁移,会踩三个坑。
坑一:数量带小数。 流程系统里的数量是浮点,搬过来之后 quantity 出现 0.5、1.25。清洗规则要提前定:要么向上取整,要么单独立一条「非法数据」台账人工核。悄悄 round 是最坏的选择。
坑二:工序没有 seq。 流程系统的工艺段是模板,搬到工单上要生成实际的工单工序。如果模板里段的顺序不明确,生成的 seq 会乱,串行校验全部失效。迁移前要人工确认一遍段序。
坑三:料号一对多。 流程系统一个料号对应一个规格;离散厂的历史数据里,同一个料号常常被用在多个件号上(因为当年没料号就随手填了)。迁移映射表要么按「料号 + 件号」建,要么承认这部分成本只能按估算。
这三个坑的共同点:都不是技术难点,是数据质量。 清洗脚本好写,判断标准难定,所以必须业务方参与确认,不能全交给开发。
八、多租户与性能:两条容易漏的
多租户隔离与业务模型正交,但有两处容易漏:
成本统计这类聚合查询尤其要注意:
— 租户前置 + 时间范围 + 只统计已完工
select count(*), coalesce(sum(c.total_cost), 0)
from work_order_costs c
join work_orders w
on w.id = c.work_order_id and w.tenant_id = c.tenant_id
where c.tenant_id = :tid
and w.status = 'completed'
and w.completed_at >= :from
and w.completed_at < :to;
性能上离散系统有两个热点:
报工写入。 车间集中报工(午休、下班)会形成写入尖峰,索引不要建太多,work_report 上两三个足够(按工序查、按时间查)。
看板查询。 看板是高频只读聚合,最好做一层物化视图或缓存,别每次都跑全表 count。
九、模型对不对口,看四个约束
给做选型评审或代码评审的人四条硬判据:
四条里任一条不成立,这套模型就不是离散模型,用它管车间必然多一层翻译。
上面这些约束不是纸上推演——它们来自一套真实在跑的离散 MES 的数据结构(摩杰斯的 PMS5),字段名、默认值、约束都能逐条对上,包括「客户订单变更只开放三类」这种看起来别扭、实际是护栏的设计。
FAQ
Q:为什么不用「批次」做主轴,把料和活串起来?
A:批次适合同质料的质量追溯(流程行业)。离散的非标件没有稳定批次谱系:一件可能来自三批料,一批料可能做五个图号。按批次管,追溯不出有用的信息。真要做批次级追溯,得引入批次号、先进先出、有效期这一整套,成本和维护量是另一个量级。
Q:工时能不能直接用工时卡/设备采集替代报工?
A:能补,但替代不了。报工记录的是「这张单的这道工序完成了多少件」,设备采集通常只有运行时长,没有件数与工序归属。两者互补:设备采集校准时长,报工提供件数与归属。
Q:成本口径为什么不能两套并存(按单 + 按批)?
A:可以并存,但必须先明确哪一套是决策口径。两套并行时最常见的失败是报价用按单、复盘用按批,然后两边数永远对不上。技术上只是 qty_factor 一个开关,管理上是一个态度问题。
Q:这类模型能承载多租户 SaaS 吗?
A:能。多租户的行级隔离(tenant_id)与业务模型正交,只要所有查询都带租户上下文、唯一键都带租户前缀,模型本身不受影响。真正要小心的是聚合查询和导出——这两处最容易漏掉租户条件。
Q:离线报工的数据怎么保证不重复?
A:客户端生成 client_uuid,服务端用它建部分唯一索引。补传时先查后插,或者直接依赖唯一索引冲突并返回已有记录。没有幂等键的离线补传,迟早会出现重复报工。
网硕互联帮助中心







评论前必须登录!
注册