
简介GreaterWMS是一套面向企业仓库管理场景的开源式WMS解决方案脱胎于福特亚太区售后物流仓储供应链实践后独立演进为可扩展的企业级系统。资源提供完整前后端源码与工程配置覆盖库存控制、库位管理、订单处理、出入库管理、退货处理、条码扫描等核心业务模块适合企业应用系统开发者、供应链从业者以及希望深入理解WMS业务逻辑的技术人员。压缩包共2000个文件以JavaScript1529个、Python179个、Vue105个等前端脚本和后端逻辑为主辅以44个Markdown说明文档、33个CSS样式、29个JSON配置以及C/Java等扩展代码整体约85.15MB目录结构清晰适合按模块研读。目前已有42人学习浏览借助这份资料可以学习真实WMS的架构拆分、接口设计、库位与批次管理逻辑也能为二次开发或课程设计提供可直接参考的代码基线。1. GreaterWMS 仓库管理系统一套被售后物流逼出来的实战方案值得照着做GreaterWMS 这套仓库管理系统的底子来自某大型汽车厂商亚太区的售后物流仓储供应链流程设计它的人最初要解决的不是电商发货而是售后件怎么追溯、库龄怎么管控、质检区怎么流转这一连串没人愿意碰的脏活。和市面上把入库、出库、库存三张表拼起来的通用 WMS 不同这套系统把序列号、批次、质检状态、库龄预警当成了第一公民从数据模型到出库策略都按售后仓的规则在走。适合三类人正在给自有仓库选型 WMS 的物流负责人想基于开源系统做二次开发但被现有框架锁死的开发者以及刚入行、想搞懂仓库管理系统真实数据流和状态机怎么落地的工程师。接下来我按它解决了什么问题 → 怎么部署跑通 → 核心流程怎么设计 → 哪些坑最容易翻车 → 值得投入的进阶方向这条线把这套精简化方案讲透。2. 售后物流仓库和电商仓的根本区别先搞懂它在解决哪一类问题2.1 售后件为什么比新品难管序列号、批次与追溯链电商仓管的是SKU × 数量 × 库位售后物流仓管的是序列号 × 批次 × 来源 × 状态。同一个零件从供应商进来时是新品发给经销商后因为质量问题退回回到仓库就变成了待检品检测完可能翻新、报废或者退回供应商。同一个序列号在这些状态之间流转每一次流转都必须留痕因为一旦出现批次性质量问题仓库要在几小时内圈定这批货发给了谁、还剩多少在库里。这套 WMS 里最核心的设计不是库存表而是库存流水Inventory Transaction。每一笔入库、出库、移库、盘点差异都对应一条流水库存余额只是流水的投影。售后场景下改了库存没留流水等于没改这也是它和很多通用系统最本质的分歧点。我见过不少团队把流水当成可有可无的审计功能结果追溯链条一断问题件只能靠人工翻 Excel。2.2 一个售后 WMS 的最小数据模型五张核心表怎么设计不看完整表结构只看构成追溯链的最小集合。这套系统的数据模型可以压缩成五张核心表表职责关键字段售后场景下的注意点product物料主数据sku_code, name, unit, is_serial_tracked必须区分按序列号管理和按数量管理两类物料location库位主数据location_code, zone, type质检区、退货暂存区、待报废区都是独立 zone不能混在架位上lot批次lot_no, product_id, supplier_id, production_date批次号全局唯一生产日期直接参与库龄计算serial序列号serial_no, lot_id, status状态至少要有在库、已出库、锁定、待报废四种stock实时库存product_id, location_id, lot_id, quantity唯一约束由 productlocationlot 组成serial 管理的物料还要加 serial 维度这张表的顺序就是你的建库顺序先有物料和库位再有批次接着序列号挂在批次下最后库存永远指到某个库位、某个批次、某个序列号。实际开发时常见错误的顺序是先建库存表再补批次导致无数历史单据没法回填批次追溯链从第一天就断了。2.3 售后物流最看重的能力清单选型或自研前先对着打分在汽车售后仓里下面这些能力每一项都是硬需求缺一个都会在后期用人工来补序列号级追溯从整车召回反查到零件序列号、批次、供应商、入库日期、出库去向。库龄预警与先进先出超过 90 天、180 天、365 天的库存自动亮灯出库时强制按库龄升序分配。质检状态机待检、合格、不合格、待报废四个状态状态切换必须走流程不能直接改库存。多包装单位同一个 SKU 要同时支持按件、按箱、按托出入库且换算关系可配置。移库与盘点差异流盘点差异不是改一下数量而是生成盘盈、盘亏单据和财务流程联动。拿这套清单去对照市面上的通用 WMS你会发现很多系统在库存管理上很强但序列号追溯和库龄预警往往做成有但是个摆设。这套系统把这两项从辅助功能提到了主流程这是它值得借鉴的地方。3. 用 Docker Compose 把整套 WMS 跑起来从零到扫码收货3.1 部署前的环境准备与目录规划常见做法是把三个服务拆开PostgreSQL 存业务数据、后端 API 处理业务逻辑、前端负责操作界面。我一般建议至少给这套系统准备一台 4 核 8G 的服务器磁盘根据库存流水量估算建议预留 100G 起步因为库存流水表膨胀速度比想象中快得多。目录规划上不要把所有东西堆在/opt下乱放按下面的结构组织/opt/wms/ ├── backend/ # 后端项目的 Dockerfile 与代码挂载 ├── frontend/ # 前端静态文件与 Nginx 配置 ├── postgres/ # 数据库数据目录用命名卷或 bind mount 都行 └── deploy/ ├── docker-compose.yml └── .env.env里放环境差异比如端口、数据库密码、时区。不要在任何源码文件里硬编码数据库密码这是我见过最普遍的安全翻车点尤其是这套系统的代码常被拿到内网之外演示密码一旦打成明文等于把整个仓库的数据权限送出去。3.2 编写 docker-compose.yml三个服务的最小可跑配置后端我用 Django 风格的配置来举例因为这套 WMS 的后端思路是典型的 Python 业务框架ORM 管数据模型、迁移管表结构、API 管前端交互。下面是完整的最小编排文件version: 3.8 services: db: image: postgres:16-alpine restart: unless-stopped environment: POSTGRES_DB: wms POSTGRES_USER: wms_user POSTGRES_PASSWORD: ${DB_PASSWORD} volumes: - postgres_data:/var/lib/postgresql/data healthcheck: test: [CMD-SHELL, pg_isready -U wms_user] interval: 10s timeout: 5s retries: 5 backend: build: ../backend restart: unless-stopped depends_on: db: condition: service_healthy environment: DB_HOST: db DB_PORT: 5432 DB_NAME: wms DB_USER: wms_user DB_PASSWORD: ${DB_PASSWORD} TZ: Asia/Shanghai volumes: - ../backend:/app ports: - 8000:8000 frontend: build: ../frontend restart: unless-stopped depends_on: - backend ports: - 80:80 volumes: - ../frontend/nginx.conf:/etc/nginx/conf.d/default.conf:ro volumes: postgres_data:这里db服务显式加了健康检查backend用depends_on.condition: service_healthy等数据库就绪再启动。不这么配置的话前三次启动大概率出现后端连不上数据库直接崩溃退出这是容器编排里最经典的玄学问题本质不是玄学是启动顺序没控制。TZ: Asia/Shanghai一定要设不然库龄预警按 UTC 算时间储存在日期边界上会整体差 8 小时库存账面上整天差出库龄满 90 天的判断偏差。3.3 首次启动与初始化创建管理员、初始化基础数据配置文件就位后需要做一次完整的初始化。以下是我每次部署都会走的命令序列cd /opt/wms/deploy # 1. 首次构建镜像拉依赖会比较慢建议设好代理或提前构建缓存 docker compose up -d --build # 2. 确认三个服务的状态 docker compose ps # 3. 初始化数据库表结构这个命令只在第一次运行时执行 docker compose exec backend python manage.py migrate # 4. 创建超级管理员 docker compose exec backend python manage.py createsuperuser # 5. 初始化基础数据仓库、库区、默认库位 docker compose exec backend python manage.py init_demo_datamigrate执行的是建表语句会按数据模型的迁移历史把所有表建出来之后代码里改模型也会生成新的迁移文件这个命令要反复跑。createsuperuser创建管理后台的登录账号。init_demo_data是我习惯保留的一个自定义命令它会创建默认仓库、几个基础库区收货区、质检区、存储区、发货区以及一组演示用的物料和供应商。忘记执行migrate是最常见的初始化翻车点后端起来后任何接口都报relation does not exist。看到这个报错不要怀疑代码先检查有没有跑迁移。3.4 用命令行模拟一次扫码收货验证整条链路通不通不急着接 PDA 硬件先用 API 模拟扫码枪的动作。扫码收货的业务逻辑是拿着收货单号找到预期到货的明细扫一个物料条码/序列号校验后把库存加到收货暂存区。用 curl 模拟如下# 按收货单号查询待收货明细 curl -X GET \ http://127.0.0.1:8000/api/inbound/order/ASN2024001/details \ -H Authorization: Token 你的_token | python -m json.tool # 提交一次扫码收货参数是明细ID和扫描到的序列号 curl -X POST \ http://127.0.0.1:8000/api/inbound/receive \ -H Authorization: Token 你的_token \ -H Content-Type: application/json \ -d {detail_id: 101, serial_no: SN202411001001}正常响应里会返回这条序列号分配到的目标库位同时库存流水里生成一条收货记录。到这一步说明数据库、后端 API、权限校验、库存流转整条链路已经通了。Authorization: Token是 Django REST Framework 常见的认证方式二开时如果换成 JWT把请求头里的 Token 换成Bearer即可注意别在文档里把两者混着写。4. 核心业务流程落库入库、出库、盘点与库龄的接口设计4.1 收货到上架的完整状态机四个状态怎么转换售后仓的收货不能直接入储存位至少要经过收货、待检、合格/不合格、上架/隔离这四个阶段。我把这套系统的状态机简化成一条链待收货(OPEN) → 已收货(RECEIVED) → 质检通过(QUALIFIED) → 已上架(IN_STOCK) ↓ 质检不通过(REJECTED) → 已隔离(IN_QUARANTINE)实现上不要用 if-else 到处散落判断状态定义一张状态转换表控制合法路径。后端伪代码如下# 定义状态转换的合法路径 STATE_FLOW { OPEN: [RECEIVED, CANCELLED], RECEIVED: [QUALIFIED, REJECTED], QUALIFIED: [IN_STOCK], REJECTED: [IN_QUARANTINE, RETURNED], IN_QUARANTINE: [SCRAPPED, RETURNED], IN_STOCK: [ALLOCATED, PICKED], } def transition(record, target_state, operator, **payload): 执行状态转换不合法直接抛异常合法则写入流水 if target_state not in STATE_FLOW.get(record.state, []): raise IllegalTransition( f当前状态 {record.state} 不能转到 {target_state} ) # 在事务里更新状态并写流水 with transaction.atomic(): record.state target_state record.operator operator record.save() InventoryTransaction.objects.create( record_idrecord.id, from_staterecord.state, # 注意这里要先取旧值 to_statetarget_state, operatoroperator, payloadpayload, )注意from_state一定要在赋值给record.state之前取旧值这个 bug 我见人踩过不止一次先改了状态再写流水结果流水的 from_state 和 to_state 永远一样追溯链信息直接失效。状态表的另一个价值是给每个状态绑定权限比如只有质检角色能执行QUALIFIED转换这个在后面抗审计的时候非常有用。4.2 出库波次与拣选策略按库龄、批次、序列号三道过滤售后出库有个硬性要求先进先出按库龄走同批次优先序列号精确匹配。波次分配的逻辑就是在这三个维度上逐层过滤顺序不能反。先按库龄升序排再在相同库龄内按批次聚拢最后按需求里的序列号清单精确锁定。我用 Python 代码描述这个分配器方便你理解它为什么这样排顺序def allocate_stock(order_lines): 按库龄优先、批次聚拢、序列号精确匹配分配库存 allocation [] for line in order_lines: # 第1层按库龄升序排序库龄 现在 - 入库时间 candidates Stock.objects.filter( productline.product, location__zoneSTORAGE, # 只从存储区分配 quantity__gt0, ).order_by(inbound_date) # 第2层如果订单指定了批次直接过滤批次 if line.lot_no: candidates candidates.filter(lot__lot_noline.lot_no) # 第3层如果订单指定了序列号精确匹配 if line.serial_no: candidates candidates.filter(serial__serial_noline.serial_no) for stock in candidates: take min(line.remaining_qty, stock.quantity) allocation.append((stock, take)) line.remaining_qty - take if line.remaining_qty 0: break if line.remaining_qty 0: # 分配不足不等于失败但要返回缺量原因 raise Shortage(line.line_no, line.remaining_qty) return allocation这段的核心在order_by(inbound_date)它让库龄天然成为第一排序条件。不要尝试用库存表的更新时间代替入库时间那是两个概念混用会让库龄报表从第一天就是错的。分配成功的库存要立刻标记为ALLOCATED状态并做预占预占数量单独记在库存表的allocated_qty字段上可分配数量是quantity - allocated_qty这样波次不会把同一批库存分给两个订单。4.3 盘点差异的闭环从盘盈盘亏到财务调整盘点模块最常见的错误是直接把账面数量改成实盘数量这会让问题线索被抹掉。这套系统的正确处理方式是生成差异单写入流水再用差异单驱动财务调整。盘点差异生成的接口逻辑我一般这样写def create_count_diff(zone, count_lines): 生成盘点差异单不直接改库存 diff_lines [] for item in count_lines: book_qty Stock.objects.filter( productitem.product, locationitem.location, ).aggregate(totalSum(quantity))[total] diff item.actual_qty - (book_qty or 0) if diff 0: continue diff_lines.append({ product: item.product, location: item.location, book_qty: book_qty or 0, actual_qty: item.actual_qty, diff: diff, # 正数盘盈负数盘亏 }) if not diff_lines: return {created_count: 0} # 生成一张盘点差异单状态为待审核 diff_order CountDiffOrder.objects.create( zonezone, statusPENDING, linesdiff_lines, ) return {created_count: len(diff_lines), order_no: diff_order.order_no}差异单审核通过后才会执行库存调整。调整时正差异走盘盈流水入账负差异走盘亏流水同时触发财务凭证。这套设计把盘点和调账分成两步给财务留了审批节点哪怕是内网小团队我也建议保留这个审批动作因为盘亏单后面是要扣 KPI 的直接改库存会让责任追踪变成一笔烂账。5. 售后 WMS 最容易翻车的 6 个坑从数据迁移到并发扣减5.1 现象库存显示负数但系统没报错出库单还在继续打原因库存扣减逻辑没有加锁两个并发请求同时读到同一行库存为 5各自扣完 3 后都写出 2最终账面变成 -1。售后仓对负库存容忍度为零因为序列号追溯不允许出现凭空多出的负数出库。解决扣减库存时必须使用数据库行锁并校验扣减后不为负。不要试图用 Python 层的标志位控制并发两个进程之间根本不共享内存。with transaction.atomic(): stock Stock.objects.select_for_update().get(pkstock_id) if stock.quantity - stock.allocated_qty need_qty: raise InsufficientStock(f库存不足可分配 {stock.quantity - stock.allocated_qty}) stock.quantity - need_qty stock.save()select_for_update()会把对应行锁住直到事务结束这是解决扣减并发最通用的做法。代价是会阻塞同行的其他事务所以务必保证事务体尽量短不要在锁里做报表统计或调用外部接口。5.2 现象同一个序列号被两次扫码入库两张收货单都显示成功原因序列号全局唯一约束只建在序列号表上但收货接口没有在事务开始时就检查序列号是否已经存在导致先到的一单还没提交后到的一单在检查时没看到新记录双双通过。解决把唯一性检查合并进插入操作本身直接依赖数据库唯一索引的冲突报错而不是先查再插。try: Serial.objects.create( serial_noserial_no, lotlot, productproduct, statusIN_STOCK, ) except IntegrityError: # 捕获唯一索引冲突返回序列号重复请核对来源 raise DuplicateSerial(serial_no)这个现象最隐蔽的地方在于单机单库时出现的概率很低一旦开启多实例部署检查再插的窗口期被放大重复入库基本必然出现。所以我在二开时项目里所有涉及唯一编号生成的接口一律不做先查后插只依赖数据库约束兜底。5.3 现象波次已经分配完库存拣货时发现库存被别人挪走原因波次分配只写了库存表的allocated_qty但移库、盘点调整的代码没有检查这个字段。比如一个库存被波次预占 10 件同时有人做移库把这 10 件挪到了别区预占记录还挂在原库存上拣货时当然找不到货。解决所有会改变quantity的操作必须同时校验allocated_qty挪库时不允许挪走已预占部分确需挪走要先把预占释放重新分配。releaseable stock.quantity - stock.allocated_qty if request_qty releaseable: raise AllocatedLockedError( f当前可移库数量为 {releaseable}含预占库存不可移动 )这个坑在售后仓尤其严重因为质检区、隔离区、存储区之间的移库非常频繁任何一条移库路径漏掉预占校验波次完整性就崩了。5.4 现象数据迁移完成后盘点怎么都对不上差异单满天飞原因迁移时只搬了库存余额没有搬库存流水。更严重的是迁移脚本把历史订单的收货完成时间当成了库存入库时间导致库龄全部错误。解决迁移顺序必须是单据先于流水流水先于余额。库存余额在迁移时反推生成绝不直接复制旧表里的数字。# 迁移逻辑按三阶段走 def migrate_wms_data(raw_snapshot): # 阶段1迁移物料、库位、批次、序列号主数据 migrate_master_data(raw_snapshot) # 阶段2迁移入库单/出库单/移库单及明细 move_ids migrate_orders(raw_snapshot) # 阶段3按单据流水重放生成库存余额 replay_transactions(move_ids)凡是迁移后库存账实不符的九成都是阶段顺序错了。宁可在迁移时多花三天打通单据流也不要图省事只搬余额否则后面每个月盘点都在还这笔技术债。5.5 现象前端报表加载超过 10 秒数据库 CPU 飙升原因典型的是 N1 查询和缺索引。前端要显示一行库存记录后端却循环查了 20 次明细表而库存流水表的record_id、created_at没有索引按时间段查流水直接全表扫描。解决报表接口一律用聚合查询一次 SQL 出结果给流水表建组合索引。-- 流水表必备组合索引按时间范围查询和按单据维表关联同时覆盖 CREATE INDEX idx_inv_txn_record_time ON inventory_transaction (record_id, created_at); -- 按仓库物料库位的实时库存汇总避免逐行暴查 SELECT product_id, location_id, SUM(quantity) AS total_qty, SUM(allocated_qty) AS alloc_qty FROM stock WHERE warehouse_id WH01 GROUP BY product_id, location_id;优化之后如果还慢下一个排查点是序列化层确认没有在 Python 循环里逐条访问 ORM 的关联属性。这套系统的报表慢基本逃不出这三个原因缺索引、N1、循环内查询。5.6 现象多仓并发修改同一个物料主数据时数据库死锁原因两个事务各自更新了不同的子表之后又同时去更新父表锁的获取顺序不一致导致死锁。这种问题在单仓部署时几乎不出现一旦做成多仓或总部-分仓两级模式各仓同时改物料主数据死锁就变成高概率事件。解决锁定主数据的顺序必须全局一致比如所有事务都先锁物料主表再锁子表且事务保持短小。BEGIN; -- 所有涉及主数据更新的业务统一先锁父行 SELECT * FROM product WHERE id 101 FOR UPDATE; -- 再更新关联子表 UPDATE product_supplier SET is_active 0 WHERE product_id 101; COMMIT;死锁出现时数据库一般会主动牺牲一个事务表现为某个请求突然报deadlock detected。不要靠重试硬扛要从锁顺序上根治。把先父后子写进代码评审清单比事后查 pg 日志省事得多。6. 把 WMS 从能用调到好用三个值得投入的二次开发方向6.1 对外暴露幂等 API让过账、订单同步不再重复记账仓库系统和其他系统对接时最痛的点是对方超时重试同一个入库单被推了三次。给核心写操作加上幂等键是最值钱的改造。做法是每个请求头带一个Idempotency-Key服务端用数据库唯一约束记录这个键的处理结果重复请求直接返回第一次的结果不再执行扣减。6.2 用 ABC 分类重排库位把热销件和呆滞件分离售后仓的库位分配经常靠人手工记。可以按出库频次把物料分成 A、B、C 三类A 类占出库量 80% 的物料放在离拣货口最近的库位C 类呆滞件挪到高层或远端这个改动不需要改接口只要定期跑一次重排任务生成的库位调整建议让仓管确认后执行。库内行走距离缩短之后拣货效率的提升会直接体现在人均单量上这是投入产出比最高的优化项。6.3 给库龄预警加一个低代码定时任务异常库存不再无人管库龄超过 180 天没有动销的库存在售后仓里意味着潜在的呆滞风险。设计一个每天凌晨执行的定时任务扫描所有在库批次把超过阈值天数的批次写入预警表同时按预警级别生成不同的处理任务90 天触发提醒180 天强制要求提交处理意见365 天自动封存并停止出库分配。这样库龄从报表指标变成了主动任务而不是等人去看报表才发现问题。做这套系统二开时我吃过最大的亏就是一开始太迷信复杂设计把状态机、权限模型全部推到极致结果团队内没人跑得动。后来我把精力集中到三件事上库存扣减并发控制、序列号唯一约束、库龄计算口径统一。这三件事稳住之后其他模块哪怕糙一点仓库也能正常运转。希望这些经验对你帮到你少走我当年绕的弯路。本文还有配套的精品资源点击获取