很多公司做 ERP 时把注意力放在 BOM、生产排产、财务凭证上,结果把“销售”当成一个简单的单据模块:开单 —> 发货。
对于离散制造 + MTO(按订单生产) 企业来说,销售管理是整个业务的入口和“决策中心”:
因此,做得好能减少沟通、降低交付延期、提高现金回收;
做不好,则频繁改单、加急、库存错配、利润受损。
本文你将了解
注:本文示例所用方案模板:简道云ERP系统,给大家示例的是一些通用的功能和模块,都是支持自定义修改的,你可以根据自己的需求修改里面的功能。
离散制造:制造对象是可区分、可拆解的件(设备、机床、零部件)。MTO(Make To Order)意味着大多数生产在接到订单后才启动,库存低、交期关键。销售模块既要做前端的报价和订单管理,还得和物料、BOM、工艺、供应商交期紧耦合:当销售承诺一个交期,需要立刻知道关键件能否按时到位并反馈风险;报价需要结合成本和可变费用来估算毛利,从而指导商务策略。
+------------------------------------------------------------+
| 客户/销售前端 (Web/移动/自助门户) |
+-----------------------------+------------------------------+
|
API 网关(鉴权、限流、统一日志、API 版本)
|
+-----------+--------+-------+---------+---------------------+
| Sales Svc | Pricing| Inv Svc| Scheduler | Integration Svc |
| (quotes, | Svc | (stock,| (MRP/APS) | (MES/财务/CRM) |
| orders) | rules | ATP) | | |
+-----------+--------+-------+---------+---------------------+
| | | |
+---------------+---------+-----------+
|
事件总线(Kafka / RabbitMQ / Redis Stream)
|
支撑服务:采购、仓库、排产、质检、财务
要点:
报价 -> 转订单 -> ATP -> 备料排产 -> 出库 -> 售后(退换货)
示例字段:
索引建议:
原则:用户交互(报价创建、订单创建)为同步体验;库存锁定/硬 ATP/排产可以是异步,前端通过看板与通知追踪结果。
后端:server.js(简化)
// server.js
const express = require('express');
const bodyParser = require('body-parser');
const { Pool } = require('pg');
const EventEmitter = require('events');
const bus = new EventEmitter();
const pool = new Pool({
host: process.env.PG_HOST || 'localhost',
user: process.env.PG_USER || 'postgres',
password: process.env.PG_PASS || 'password',
database: process.env.PG_DB || 'erp',
port: process.env.PG_PORT || 5432,
});
const app = express();
app.use(bodyParser.json());
// 创建报价
app.post('/api/quotes', async (req, res) => {
const { customer_id, items, valid_until } = req.body;
const client = await pool.connect();
try {
await client.query('BEGIN');
const r = await client.query(
`INSERT INTO quotes (customer_id, status, total_amount, valid_until) VALUES ($1,$2,$3,$4) RETURNING id`,
[customer_id, 'DRAFT', 0, valid_until]
);
const quoteId = r.rows[0].id;
// items 是 [{product_id, qty, unit_price}]
for (const it of items) {
await client.query(
`INSERT INTO quote_lines (quote_id, product_id, qty, unit_price, line_amount) VALUES ($1,$2,$3,$4,$5)`,
[quoteId, it.product_id, it.qty, it.unit_price, it.qty * it.unit_price]
);
}
// 简单计算 total
const t = await client.query(`SELECT SUM(line_amount) as total FROM quote_lines WHERE quote_id=$1`, [quoteId]);
await client.query(`UPDATE quotes SET total_amount=$1 WHERE id=$2`, [t.rows[0].total || 0, quoteId]);
await client.query('COMMIT');
res.status(201).json({ id: quoteId });
} catch (err) {
await client.query('ROLLBACK');
console.error(err);
res.status(500).send('error');
} finally {
client.release();
}
});
// 报价确认 -> 生成订单并发布事件
app.post('/api/quotes/:id/confirm', async (req, res) => {
const quoteId = req.params.id;
const client = await pool.connect();
try {
await client.query('BEGIN');
const q = await client.query(`SELECT * FROM quotes WHERE id=$1 FOR UPDATE`, [quoteId]);
if (q.rowCount === 0) return res.status(404).send('quote not found');
const quote = q.rows[0];
if (quote.status !== 'DRAFT') return res.status(400).send('invalid status');
const ord = await client.query(
`INSERT INTO sales_orders (quote_id, customer_id, status, order_date, total_amount) VALUES ($1,$2,$3,$4,$5) RETURNING id`,
[quoteId, quote.customer_id, 'NEW', new Date(), quote.total_amount]
);
const orderId = ord.rows[0].id;
// copy lines
const lines = await client.query(`SELECT product_id, qty, unit_price FROM quote_lines WHERE quote_id=$1`, [quoteId]);
for (const ln of lines.rows) {
await client.query(
`INSERT INTO order_lines (order_id, product_id, qty, unit_price, status) VALUES ($1,$2,$3,$4,$5)`,
[orderId, ln.product_id, ln.qty, ln.unit_price, 'NEW']
);
}
await client.query(`UPDATE quotes SET status='CONFIRMED' WHERE id=$1`, [quoteId]);
await client.query('COMMIT');
// 发布事件(事务外)
bus.emit('order.created', { orderId });
res.json({ ok: true, orderId });
} catch (err) {
await client.query('ROLLBACK');
console.error(err);
res.status(500).send('error');
} finally {
client.release();
}
});
// 监听 order.created 并尝试锁库存(示例)
bus.on('order.created', async (payload) => {
console.log('event order.created', payload);
// 这里实际应调用库存服务 API 或发送消息到队列
// 简单示例:读取订单行并标记 reserve(伪操作)
const client = await pool.connect();
try {
const lines = await client.query(`SELECT product_id, qty FROM order_lines WHERE order_id=$1`, [payload.orderId]);
for (const ln of lines.rows) {
// 伪逻辑:插入 stock_moves 表当作锁定请求
await client.query(
`INSERT INTO stock_moves (type, ref_id, product_id, qty, from_loc, to_loc, status) VALUES ($1,$2,$3,$4,$5,$6,$7)`,
['reserve', payload.orderId, ln.product_id, ln.qty, 'WH:MAIN', 'RESERVE', 'PENDING']
);
}
// 这里可触发异步任务处理实际库存扣减
} catch (err) {
console.error(err);
} finally {
client.release();
}
});
app.listen(3000, () => console.log('listening 3000'));
CREATE TABLE customers (id serial PRIMARY KEY, code text UNIQUE, name text);
CREATE TABLE products (id serial PRIMARY KEY, sku text UNIQUE, name text, lead_time_days int);
CREATE TABLE quotes (id serial PRIMARY KEY, quote_no text, customer_id int references customers(id), status text, total_amount numeric, valid_until date);
CREATE TABLE quote_lines (id serial PRIMARY KEY, quote_id int references quotes(id), product_id int references products(id), qty numeric, unit_price numeric, line_amount numeric);
CREATE TABLE sales_orders (id serial PRIMARY KEY, order_no text, quote_id int references quotes(id), customer_id int references customers(id), status text, order_date timestamp, total_amount numeric);
CREATE TABLE order_lines (id serial PRIMARY KEY, order_id int references sales_orders(id), product_id int references products(id), qty numeric, unit_price numeric, status text);
CREATE TABLE stock_moves (id serial PRIMARY KEY, type text, ref_id int, product_id int references products(id), qty numeric, from_loc text, to_loc text, status text);
// QuoteForm.jsx
import React, { useState } from 'react';
import axios from 'axios';
export default function QuoteForm(){
const [items, setItems] = useState([{ product_id:1, qty:1, unit_price:100 }]);
async function createQuote(){
const res = await axios.post('/api/quotes', { customer_id:1, items, valid_until: '2025-12-31' });
alert('报价创建 ID: ' + res.data.id);
}
async function confirmQuote(id){
const res = await axios.post(`/api/quotes/${id}/confirm`);
alert('已确认,订单ID: ' + res.data.orderId);
}
return (
<div>
<h3>报价(示例)</h3>
<button onClick={createQuote}>创建报价</button>
<button onClick={() => confirmQuote(1)}>确认报价ID=1(示例)</button>
</div>
);
}
建议分阶段交付:
关键交付件:API 文档(OpenAPI)、ER 图、事件契约文档、运维/备份方案、单元/集成测试报告。
ATP(Available To Promise)应做到“快 + 准”。建议分层:
硬检查可在低峰时段或通过队列批量执行,避免阻塞用户。关键是把软检作为用户交互的默认体验,硬检作为风险管理与自动化触发的依据,同时在页面上把“软检结果”和“硬检结果”分开展示,便于销售做判断与沟通。
退货流程必须把“质检”作为决定库存动作前的必须步骤:
所有步骤都要有审计日志(谁、何时、结果、附件),以便处理争议。技术上,退货入库应是事务性写入,质检结论驱动库存与会计凭证的生成,确保库存与账务并行一致或有可回溯的补偿流程。
在 MTO 场景下,销售订单往往直接触发生产与采购。最佳实践是通过事件驱动将订单信息以标准事件(如 order.created、order.updated)广播给库存、采购和排产服务:
这样可以避免同步阻塞、提高系统鲁棒性。关键点是设计清晰的事件契约(payload 结构、幂等键、重试策略),并保证事件处理的幂等性与可追踪性。
同时,建立反馈循环:当采购或排产发现无法达成承诺,应立刻把信息回写给销售看板并触发人工或自动化的补救(如调整交期或更换供应商),从而把交期风险在最早阶段暴露并处理。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。