离散制造(Make-to-Order,MTO)场景对采购的要求很高——订单触发采购、交期紧、零件多、供应商管理复杂、退货与替换频繁。
采购管理不是“买东西”的流程,而是制造计划与供应商生态的接口,是控制成本、保证交期与质量、降低库存占用的关键模块。
因此做一个专门针对 MTO 的采购管理板块,不只是把振铃串起来,更要把审批、需求预测、入库/退货、执行看板、统计分析结合起来,形成闭环。
本文你将了解
注:本文示例所用方案模板:简道云ERP系统,给大家示例的是一些通用的功能和模块,都是支持自定义修改的,你可以根据自己的需求修改里面的功能。
简单说:ERP 是一套把企业各类业务(销售、生产、采购、库存、财务、人力)联动起来的软件。
离散制造-MTO 强调“订单驱动生产”——每个生产/采购动作通常始于客户订单或工程变更。
对采购模块的要求包括快速响应订单、按序列号追踪、支持回写到 WIP(在制品)和质量检验、以及动态供应商切换与交期预测。
非功能要求
下面给一个简洁的微服务/模块化架构图(ASCII),便于项目初期沟通:
+---------------------------+ +---------------------+
| 前端应用(React/PC) | <--> | API Gateway / BFF |
+---------------------------+ +---------------------+
| |
v v
+--------------------------------------------------------------+
| 采购管理微服务 / 模块 (Procurement) |
| - REST API |
| - 审批引擎(可插拔) |
| - 统计与报表服务 |
| - 看板(WebSocket / Push) |
+--------------------------------------------------------------+
| | | | |
v v v v v
+--------+ +--------+ +--------+ +--------------+ +-------------+
| DB (订单) | | DB(库存)| | SRM/供应商| | MQ (异步流程)| | Search/BI |
+--------+ +--------+ +--------+ +--------------+ +-------------+
| | | |
v v v v
MES/WMS 财务系统 供应商门户 第三方物流/邮件/ERP
说明:
核心实体:
在 ER 图中 PurchaseRequest → PurchaseRequestLine;PR 转化为 PO 的过程会创建 PurchaseOrder 与 PurchaseOrderLine 并保留引用关系(traceability)。
流程图:
[需求触发] --> [创建 PR] --> [PR 审批?]
|yes
v
[合并/生成 PO] --> [下发供应商] --> [供应商发货]
|
v
[到货/验收] --合格--> [入库更新库存/财务]
|
不合格
v
[发起退货/索赔] --> [退货/补发/结算]
支持多联系人、多价格表(按物料/数量折扣)、资质文档(文件存储和过期提醒)
供应商评分(按准时率、质量、不良率、沟通响应) 接口:新增/查询/上传资质/评分接口
来源:销售单/工单/手动 支持物料需求合并、优先级与到货期
自动生成建议供应商(基于历史价格/供应商能力) 审批链:支持多级审批、金额阈值、并行审批
PO 版本控制(变更单) 支持部分到货、暂扣(质检未通过) 支持价格、到货分批、交货地点
支持 PO 与供应商回签
收货时支持批次/序列号、质检计划、不良记录 与 WMS 联动:收货上架或直接发往生产线(JIT)
触发库存事务:可配置为延迟更新或即时事务
支持退货单、退货审批、费用分担、运费处理 与供应商结算 / 折扣 / 补料
采购需求统计:按物料/工厂/项目汇总需求(支持导出)
PO 统计:按周期/供应商/物料 成交率/到货率/逾期率 看板:未交明细、逾期 PO、到货预测
实时数据:未交数量、逾期 PO、预期7天到货、供应商热力图
支持钻取:点某个供应商查看其 PO 列表、未交明细、历史绩效
建议:使用事件驱动(MQ)+幂等消费者。
下面给出一个可直接跑的后端参考,采用 FastAPI + SQLAlchemy(SQLite)展示关键 API。
# procurement_app.py
from fastapi import FastAPI, HTTPException, WebSocket, Depends
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
from typing import List, Optional
from datetime import datetime, date
from sqlalchemy import (
create_engine, Column, Integer, String, DateTime, ForeignKey,
Float, Enum, Boolean, Date
)
from sqlalchemy.orm import sessionmaker, declarative_base, relationship, Session
from sqlalchemy.exc import IntegrityError
import enum
import uuid
# DB setup (SQLite for demo)
SQLALCHEMY_DATABASE_URL = "sqlite:///./procurement_demo.db"
engine = create_engine(SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False})
SessionLocal = sessionmaker(bind=engine, autocommit=False, autoflush=False)
Base = declarative_base()
# Enums
class PRStatus(str, enum.Enum):
DRAFT = "DRAFT"
SUBMITTED = "SUBMITTED"
APPROVED = "APPROVED"
REJECTED = "REJECTED"
class POStatus(str, enum.Enum):
DRAFT = "DRAFT"
SENT = "SENT"
PART_RECEIVED = "PART_RECEIVED"
RECEIVED = "RECEIVED"
CANCELLED = "CANCELLED"
# Models
class Supplier(Base):
__tablename__ = "suppliers"
id = Column(Integer, primary_key=True, index=True)
code = Column(String, unique=True, index=True)
name = Column(String, index=True)
contact = Column(String, nullable=True)
lead_time_mean = Column(Integer, default=7) # days
rating = Column(Float, default=5.0)
class Material(Base):
__tablename__ = "materials"
id = Column(Integer, primary_key=True, index=True)
sku = Column(String, unique=True)
description = Column(String)
class PurchaseRequest(Base):
__tablename__ = "purchase_requests"
id = Column(Integer, primary_key=True, index=True)
pr_no = Column(String, unique=True, default=lambda: "PR-" + uuid.uuid4().hex[:8])
requester = Column(String)
created_at = Column(DateTime, default=datetime.utcnow)
status = Column(Enum(PRStatus), default=PRStatus.DRAFT)
lines = relationship("PurchaseRequestLine", back_populates="pr")
class PurchaseRequestLine(Base):
__tablename__ = "pr_lines"
id = Column(Integer, primary_key=True, index=True)
pr_id = Column(Integer, ForeignKey("purchase_requests.id"))
material_id = Column(Integer, ForeignKey("materials.id"))
qty = Column(Float)
required_date = Column(Date)
priority = Column(Integer, default=3)
pr = relationship("PurchaseRequest", back_populates="lines")
material = relationship("Material")
class PurchaseOrder(Base):
__tablename__ = "purchase_orders"
id = Column(Integer, primary_key=True, index=True)
po_no = Column(String, unique=True, default=lambda: "PO-" + uuid.uuid4().hex[:8])
supplier_id = Column(Integer, ForeignKey("suppliers.id"))
created_by = Column(String)
status = Column(Enum(POStatus), default=POStatus.DRAFT)
created_at = Column(DateTime, default=datetime.utcnow)
lines = relationship("PurchaseOrderLine", back_populates="po")
supplier = relationship("Supplier")
class PurchaseOrderLine(Base):
__tablename__ = "po_lines"
id = Column(Integer, primary_key=True, index=True)
po_id = Column(Integer, ForeignKey("purchase_orders.id"))
material_id = Column(Integer, ForeignKey("materials.id"))
qty_ordered = Column(Float)
qty_received = Column(Float, default=0.0)
unit_price = Column(Float, default=0.0)
promised_date = Column(Date)
po = relationship("PurchaseOrder", back_populates="lines")
material = relationship("Material")
class Receipt(Base):
__tablename__ = "receipts"
id = Column(Integer, primary_key=True, index=True)
receipt_no = Column(String, unique=True, default=lambda: "RC-" + uuid.uuid4().hex[:8])
po_id = Column(Integer, ForeignKey("purchase_orders.id"))
created_at = Column(DateTime, default=datetime.utcnow)
warehouse = Column(String, default="MAIN")
lines = Column(String) # for demo: JSON string of received lines
Base.metadata.create_all(bind=engine)
# Pydantic schemas
class PRLineIn(BaseModel):
material_sku: str
qty: float
required_date: date
priority: int = 3
class PRIn(BaseModel):
requester: str
lines: List[PRLineIn]
class POCreateIn(BaseModel):
supplier_code: str
created_by: str
items: List[dict] # each: {material_sku, qty, unit_price, promised_date}
# FastAPI app
app = FastAPI(title="Procurement Demo API (MTO)")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Dependency
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
# Helper: get or create material
def get_or_create_material(db: Session, sku: str, description: Optional[str] = None):
m = db.query(Material).filter(Material.sku == sku).first()
if m:
return m
m = Material(sku=sku, description=description or sku)
db.add(m)
db.commit()
db.refresh(m)
return m
# API: create PR
@app.post("/pr", summary="创建采购申请 PR")
def create_pr(pr_in: PRIn, db: Session = Depends(get_db)):
pr = PurchaseRequest(requester=pr_in.requester)
db.add(pr)
db.commit()
db.refresh(pr)
for l in pr_in.lines:
mat = get_or_create_material(db, l.material_sku)
pr_line = PurchaseRequestLine(pr_id=pr.id, material_id=mat.id, qty=l.qty,
required_date=l.required_date, priority=l.priority)
db.add(pr_line)
db.commit()
db.refresh(pr)
return {"pr_no": pr.pr_no, "id": pr.id, "status": pr.status}
# API: submit PR (simple approval simulation)
@app.post("/pr/{pr_id}/submit", summary="提交 PR 审批")
def submit_pr(pr_id: int, db: Session = Depends(get_db)):
pr = db.query(PurchaseRequest).filter(PurchaseRequest.id == pr_id).first()
if not pr:
raise HTTPException(status_code=404, detail="PR not found")
pr.status = PRStatus.SUBMITTED
db.commit()
return {"pr_no": pr.pr_no, "status": pr.status}
# API: approve PR and create PO (very simplified logic: one PO per PR, using supplier heuristic)
@app.post("/pr/{pr_id}/approve", summary="审批 PR 并生成 PO(示例)")
def approve_pr_and_create_po(pr_id: int, supplier_code: str, db: Session = Depends(get_db)):
pr = db.query(PurchaseRequest).filter(PurchaseRequest.id == pr_id).first()
if not pr:
raise HTTPException(status_code=404, detail="PR not found")
supplier = db.query(Supplier).filter(Supplier.code == supplier_code).first()
if not supplier:
raise HTTPException(status_code=404, detail="Supplier not found")
# mark PR approved
pr.status = PRStatus.APPROVED
db.commit()
# create PO
po = PurchaseOrder(supplier_id=supplier.id, created_by="system-batch", status=POStatus.SENT)
db.add(po); db.commit(); db.refresh(po)
for line in pr.lines:
pol = PurchaseOrderLine(po_id=po.id, material_id=line.material_id,
qty_ordered=line.qty, qty_received=0.0,
unit_price=0.0, promised_date=line.required_date)
db.add(pol)
db.commit()
return {"po_no": po.po_no, "po_id": po.id}
# API: manually create PO
@app.post("/po", summary="创建 PO(手工)")
def create_po(payload: POCreateIn, db: Session = Depends(get_db)):
supplier = db.query(Supplier).filter(Supplier.code == payload.supplier_code).first()
if not supplier:
raise HTTPException(status_code=404, detail="Supplier not found")
po = PurchaseOrder(supplier_id=supplier.id, created_by=payload.created_by, status=POStatus.SENT)
db.add(po); db.commit(); db.refresh(po)
for it in payload.items:
mat = get_or_create_material(db, it["material_sku"])
pol = PurchaseOrderLine(po_id=po.id, material_id=mat.id, qty_ordered=it["qty"],
unit_price=it.get("unit_price", 0.0), promised_date=it.get("promised_date"))
db.add(pol)
db.commit()
return {"po_no": po.po_no, "po_id": po.id}
# API: receive goods (partial allowed)
@app.post("/po/{po_id}/receive", summary="入库/收货(简化)")
def receive_po(po_id: int, lines: List[dict], warehouse: str = "MAIN", db: Session = Depends(get_db)):
po = db.query(PurchaseOrder).filter(PurchaseOrder.id == po_id).first()
if not po:
raise HTTPException(status_code=404, detail="PO not found")
# update quantities
for l in lines:
pol = db.query(PurchaseOrderLine).filter(PurchaseOrderLine.po_id == po_id,
PurchaseOrderLine.material_id == db.query(Material).filter(Material.sku == l["material_sku"]).first().id).first()
if not pol:
raise HTTPException(status_code=400, detail=f"PO line for {l['material_sku']} not found")
pol.qty_received = (pol.qty_received or 0) + l["qty"]
# set status
all_received = all([ln.qty_received >= ln.qty_ordered for ln in po.lines])
po.status = POStatus.RECEIVED if all_received else POStatus.PART_RECEIVED
db.add(po)
# create receipt (simple)
r = Receipt(po_id=po.id, warehouse=warehouse, lines=str(lines))
db.add(r)
db.commit()
return {"po_no": po.po_no, "status": po.status}
# API: create supplier (demo)
@app.post("/supplier", summary="新增供应商")
def create_supplier(code: str, name: str, db: Session = Depends(get_db)):
s = Supplier(code=code, name=name)
db.add(s)
try:
db.commit()
except IntegrityError:
db.rollback(); raise HTTPException(status_code=400, detail="Supplier code already exists")
db.refresh(s)
return {"supplier_id": s.id, "code": s.code}
# API: stats: get pending/overdue POs
@app.get("/stats/po_summary", summary="采购统计")
def po_summary(db: Session = Depends(get_db)):
total_po = db.query(PurchaseOrder).count()
pending_po = db.query(PurchaseOrder).filter(PurchaseOrder.status != POStatus.RECEIVED).count()
# overdue: promised_date < today and qty_received < qty_ordered
from sqlalchemy import func
sub = db.query(PurchaseOrderLine).filter(PurchaseOrderLine.qty_received < PurchaseOrderLine.qty_ordered,
PurchaseOrderLine.promised_date < date.today()).count()
return {"total_po": total_po, "pending_po": pending_po, "overdue_lines": sub}
# WebSocket for simple tracking board (push minimal updates)
connected = []
@app.websocket("/ws/board")
async def ws_board(ws: WebSocket):
await ws.accept()
connected.append(ws)
try:
while True:
# ping/pong or wait for client messages; in demo simply echo
data = await ws.receive_text()
await ws.send_text(f"echo: {data}")
except Exception:
connected.remove(ws)
说明与扩展建议
需求要点
技术实现建议
示意交互
短期(上线 1~3 个月)
中期(3~9 个月)
长期
做好 MTO 场景下的采购管理,不只是一个模块的开发,而是把采购、生产、库存与供应商紧密联动,形成可追溯、可度量的闭环。技术上推荐微服务化、事件驱动、审计与补偿机制并重;
产品上要把审批/合并/供应商选择规则做到可配置化,减少对代码的频繁改动。
FAQ 1:MTO 场景中 PR 如何自动转 PO?什么时候应该自动合并 PR?
在 MTO 场景下,PR 转 PO 的决策需要平衡响应速度与成本。一般有两种策略:
建议做成可配置规则:若 PR 优先级高或交期短于供应商平均交期阈值,则立即生成 PO;否则进入一个短窗口(例如 12–24 小时)做合并。
实现时可使用“合并引擎”:把待处理 PR 放入队列或缓存,按供应商/物料/交期分桶,按规则触发合并或立即下发;并做好幂等和分布式锁,防止重复下单。
质量检验在 MTO 特别重要:收货时应先做初检并记录判定(合格/待检/不合格)。
系统应支持:按 PO Line 创建检验计划、记录检验结果、生成批次/序列号并与物料关联。若质检不合格,自动触发退货或返修工单,并把该批次标记为隔离(Quarantine)。
所有操作都要写审计日志,批次/序列号信息必须可追溯到 PO、供应商与源订单。
PO 变更是采购实施中常见且风险较高的操作。最佳实践包括:
所有变更必须有变更单(PO Revision),并记录变更原因、变更人、时间、审批人和变更前后对比。
系统要保证变更与发票、入库、在制品(WIP)和计划的联动:
例如,若在 PO 已部分收货后变更价格,需判断是否影响已到货物料的成本分摊;
若变更交期,应触发对下游生产计划(MES/MRP)的通知。
实现上,建议使用版本号(po.version)并在每次变更时生成一条新版本记录,保持旧版本可回溯;
并在接口层面做权限校验——例如超过某金额或关键字段(交期/数量)变更时需额外审批。
对于财务,变更需触发财务凭证调整流程(或给出待处理项),并由财务审核最终账务影响。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。