源ERP庫(kù)存管理系統(tǒng)實(shí)戰(zhàn):從選型到Spring Boot實(shí)現(xiàn)與并發(fā)控制)
先聊聊我最近的感受。很多中小型制造企業(yè)和貿(mào)易公司在庫(kù)存管理上其實(shí)一直處于“半手工”狀態(tài)采購(gòu)靠Excel登記銷(xiāo)售開(kāi)單靠聊天記錄倉(cāng)庫(kù)盤(pán)點(diǎn)靠人肉數(shù)數(shù)。一開(kāi)始訂單量小還沒(méi)感覺(jué)等業(yè)務(wù)跑起來(lái)之后庫(kù)存賬面和實(shí)物對(duì)不上、訂單超賣(mài)、采購(gòu)重復(fù)下單等問(wèn)題就會(huì)接連出現(xiàn)。企業(yè)想直接上SAP、Oracle這類(lèi)商業(yè)ERP成本太高實(shí)施周期也長(zhǎng)。這時(shí)候開(kāi)源免費(fèi)的ERP庫(kù)存管理系統(tǒng)就成了一個(gè)性?xún)r(jià)比很高的選擇。市面上確實(shí)有不少成熟的開(kāi)源項(xiàng)目也有不少團(tuán)隊(duì)選擇基于開(kāi)源框架二次開(kāi)發(fā)一套自己的庫(kù)存管理系統(tǒng)。這篇文章我就從開(kāi)源ERP庫(kù)存管理系統(tǒng)的選型思路、核心概念、數(shù)據(jù)庫(kù)設(shè)計(jì)、接口實(shí)現(xiàn)到常見(jiàn)問(wèn)題和工程化建議做一次完整的拆解幫助你從零搭建一套可用的庫(kù)存管理系統(tǒng)或者幫助你評(píng)估現(xiàn)有開(kāi)源項(xiàng)目的落地方式。文章會(huì)包含大量可復(fù)用的SQL、Java和Vue代碼重點(diǎn)講清楚庫(kù)存系統(tǒng)的核心閉環(huán)入庫(kù)、出庫(kù)、庫(kù)存流水、庫(kù)存余額以及并發(fā)扣減庫(kù)存時(shí)的安全問(wèn)題。不管你是準(zhǔn)備學(xué)習(xí)ERP開(kāi)發(fā)還是要在企業(yè)內(nèi)部推動(dòng)開(kāi)源ERP落地這篇文章都值得收藏備用。1. 開(kāi)源ERP庫(kù)存管理系統(tǒng)是什么解決什么問(wèn)題1.1 什么是ERP庫(kù)存管理系統(tǒng)ERPEnterprise Resource Planning是企業(yè)資源計(jì)劃系統(tǒng)的簡(jiǎn)稱(chēng)它覆蓋財(cái)務(wù)、采購(gòu)、銷(xiāo)售、生產(chǎn)、庫(kù)存、人力資源等模塊。而庫(kù)存管理系統(tǒng)通常指其中負(fù)責(zé)物料管理和庫(kù)存核算的部分它要回答幾個(gè)核心問(wèn)題倉(cāng)庫(kù)里現(xiàn)在有什么物料每個(gè)物料的數(shù)量是多少這些物料分布在哪些倉(cāng)庫(kù)庫(kù)存的變化是由哪張業(yè)務(wù)單據(jù)觸發(fā)的當(dāng)前的可用庫(kù)存是否能滿(mǎn)足一張銷(xiāo)售訂單所以庫(kù)存管理系統(tǒng)并不是簡(jiǎn)單的“進(jìn)銷(xiāo)存登記表”。它必須把采購(gòu)入庫(kù)、銷(xiāo)售出庫(kù)、生產(chǎn)領(lǐng)料、退貨、盤(pán)點(diǎn)、調(diào)撥這些業(yè)務(wù)動(dòng)作統(tǒng)一建模形成一條完整的數(shù)據(jù)鏈路。1.2 開(kāi)源ERP與傳統(tǒng)商業(yè)ERP的區(qū)別對(duì)比維度開(kāi)源ERP商業(yè)ERP軟件許可費(fèi)用免費(fèi)或低成本按模塊、用戶(hù)數(shù)、年費(fèi)計(jì)費(fèi)源代碼通常開(kāi)放可二次開(kāi)發(fā)閉源定制依賴(lài)原廠實(shí)施方式自研團(tuán)隊(duì)或第三方實(shí)施原廠或授權(quán)顧問(wèn)實(shí)施擴(kuò)展能力可靈活修改業(yè)務(wù)邏輯受限于平臺(tái)能力技術(shù)門(mén)檻較高需要懂開(kāi)發(fā)和運(yùn)維較低配置為主典型代表Odoo、ERPNext、Apache OFBizSAP、Oracle EBS、用友、金蝶選擇開(kāi)源ERP庫(kù)存管理系統(tǒng)并不意味著“免費(fèi)所以湊合”。相反你需要具備一定的技術(shù)能力去掌控它。很多企業(yè)選擇開(kāi)源方案核心訴求是三點(diǎn)源代碼可控業(yè)務(wù)邏輯可以按需改造。無(wú)License授權(quán)壓力尤其是對(duì)預(yù)算敏感的成長(zhǎng)型企業(yè)。技術(shù)棧公開(kāi)透明不綁定特定廠商。1.3 常見(jiàn)的開(kāi)源ERP項(xiàng)目概覽項(xiàng)目名稱(chēng)技術(shù)棧適用場(chǎng)景許可證OdooPython PostgreSQL中小型制造、貿(mào)易、電商LGPLERPNextPython Frappe框架中小企業(yè)全模塊管理GPLApache OFBizJava Tomcat大型企業(yè)復(fù)雜業(yè)務(wù)Apache 2.0華夏ERP基于SpringBootJava Vue MySQL國(guó)內(nèi)中小企業(yè)進(jìn)銷(xiāo)存GPL若依RuoYiJava Vue適合二次開(kāi)發(fā)后臺(tái)管理系統(tǒng)MIT需要說(shuō)明的是開(kāi)源項(xiàng)目的許可證非常關(guān)鍵。如果企業(yè)計(jì)劃對(duì)系統(tǒng)做閉源二次發(fā)行那么GPL類(lèi)協(xié)議會(huì)有較嚴(yán)格的約束如果是內(nèi)部使用不對(duì)外發(fā)行通常影響較小。做技術(shù)選型時(shí)一定要去對(duì)應(yīng)開(kāi)源倉(cāng)庫(kù)確認(rèn)License原文不能只看社區(qū)傳聞。1.4 開(kāi)源庫(kù)存管理系統(tǒng)適合哪些場(chǎng)景年?duì)I收在幾千萬(wàn)到幾億之間、還在用Excel管理庫(kù)存的中小制造企業(yè)。有技術(shù)團(tuán)隊(duì)、希望自己掌控核心業(yè)務(wù)系統(tǒng)的成長(zhǎng)型公司。高校、實(shí)驗(yàn)室、非營(yíng)利組織預(yù)算有限但又需要規(guī)范流程管理。個(gè)人開(kāi)發(fā)者學(xué)習(xí)ERP業(yè)務(wù)邏輯接觸真實(shí)進(jìn)銷(xiāo)存場(chǎng)景。軟件公司希望基于成熟開(kāi)源框架快速交付客戶(hù)項(xiàng)目。2. 開(kāi)源ERP庫(kù)存管理系統(tǒng)的技術(shù)選型與架構(gòu)設(shè)計(jì)2.1 技術(shù)棧選型建議在實(shí)際項(xiàng)目中庫(kù)存管理系統(tǒng)通常作為整個(gè)ERP系統(tǒng)中的子模塊出現(xiàn)。如果從零開(kāi)始搭建一套輕量級(jí)庫(kù)存管理系統(tǒng)我比較推薦下面的技術(shù)組合技術(shù)層次推薦方案說(shuō)明前端框架Vue 3 Element Plus組件生態(tài)豐富適合中后臺(tái)系統(tǒng)后端框架Spring Boot 2.x / 3.xJava生態(tài)成熟事務(wù)與并發(fā)控制能力強(qiáng)數(shù)據(jù)庫(kù)MySQL 8.0使用廣泛運(yùn)維成本低權(quán)限框架Spring Security JWT實(shí)現(xiàn)登錄認(rèn)證與接口權(quán)限構(gòu)建工具M(jìn)aven / npm標(biāo)準(zhǔn)工程構(gòu)建方式部署方式Docker Compose便于本地環(huán)境與生產(chǎn)環(huán)境保持一致這里的版本號(hào)只是推薦具體需要結(jié)合你的項(xiàng)目要求來(lái)定。Spring Boot 3.x基于Jakarta EE與Spring Boot 2.x在某些依賴(lài)上不兼容MySQL 8.0支持窗口函數(shù)和更好的鎖機(jī)制但如果你已有的生產(chǎn)環(huán)境是5.7也不必強(qiáng)行升級(jí)。本文側(cè)重講解庫(kù)存管理系統(tǒng)的核心設(shè)計(jì)代碼示例會(huì)盡量保持通用。2.2 整體架構(gòu)圖文字版Vue 3 Element Plus | | HTTP/JSON v Spring Boot 后端服務(wù) |--- 認(rèn)證模塊 (JWT) |--- 商品管理模塊 |--- 倉(cāng)庫(kù)管理模塊 |--- 入庫(kù)單模塊 |--- 出庫(kù)單模塊 |--- 庫(kù)存流水模塊 |--- 庫(kù)存余額模塊 | | JDBC v MySQL 數(shù)據(jù)庫(kù) |--- sys_user 用戶(hù)表 |--- bas_product 商品表 |--- bas_warehouse 倉(cāng)庫(kù)表 |--- stk_inbound 入庫(kù)單表 |--- stk_inbound_item 入庫(kù)明細(xì)表 |--- stk_outbound 出庫(kù)單表 |--- stk_outbound_item 出庫(kù)明細(xì)表 |--- stk_stock_flow 庫(kù)存流水表 |--- stk_stock_balance 庫(kù)存余額表從整體架構(gòu)可以看出庫(kù)存管理系統(tǒng)的核心是“單據(jù) 流水 余額”三層模型。業(yè)務(wù)操作不直接修改庫(kù)存數(shù)量而是通過(guò)生成入庫(kù)單或出庫(kù)單同時(shí)記錄庫(kù)存流水最后更新庫(kù)存余額。2.3 核心業(yè)務(wù)閉環(huán)庫(kù)存系統(tǒng)的完整閉環(huán)可以拆成下面幾步用戶(hù)創(chuàng)建入庫(kù)單填寫(xiě)供應(yīng)商、收貨倉(cāng)庫(kù)、商品和數(shù)量。入庫(kù)單審核通過(guò)后系統(tǒng)生成入庫(kù)庫(kù)存流水。系統(tǒng)更新對(duì)應(yīng)倉(cāng)庫(kù)、對(duì)應(yīng)商品的庫(kù)存余額。用戶(hù)創(chuàng)建出庫(kù)單填寫(xiě)客戶(hù)、發(fā)貨倉(cāng)庫(kù)、商品和數(shù)量。出庫(kù)單審核時(shí)系統(tǒng)檢查可用庫(kù)存是否充足。庫(kù)存充足則扣減庫(kù)存不足則提示庫(kù)存不足。財(cái)務(wù)或管理人員通過(guò)報(bào)表查看庫(kù)存變動(dòng)、庫(kù)存價(jià)值、出入庫(kù)趨勢(shì)。這個(gè)閉環(huán)看起來(lái)簡(jiǎn)單但實(shí)際開(kāi)發(fā)中并發(fā)扣減庫(kù)存、事務(wù)一致性、冪等性等問(wèn)題非常容易出現(xiàn)。3. 數(shù)據(jù)庫(kù)表設(shè)計(jì)庫(kù)存系統(tǒng)的基石3.1 數(shù)據(jù)模型設(shè)計(jì)原則在設(shè)計(jì)庫(kù)存管理系統(tǒng)數(shù)據(jù)庫(kù)時(shí)至少要遵循以下原則單據(jù)與明細(xì)分離主表存單據(jù)頭信息明細(xì)表存商品明細(xì)避免單表字段過(guò)多。數(shù)量與金額精度控制庫(kù)存數(shù)量使用DECIMAL不使用浮點(diǎn)類(lèi)型避免精度丟失。庫(kù)存流水只增不改流水表通過(guò)流水號(hào)唯一約束一旦寫(xiě)入不允許修改和刪除只能通過(guò)沖銷(xiāo)單糾正。庫(kù)存余額必須有版本號(hào)或鎖機(jī)制用于處理并發(fā)更新防止超賣(mài)。所有業(yè)務(wù)表都要帶創(chuàng)建人、創(chuàng)建時(shí)間、更新人、更新時(shí)間和邏輯刪除標(biāo)記。3.2 商品表和倉(cāng)庫(kù)表-- 商品表 CREATE TABLE bas_product ( id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT 主鍵, product_code VARCHAR(50) NOT NULL COMMENT 商品編碼, product_name VARCHAR(100) NOT NULL COMMENT 商品名稱(chēng), category_id BIGINT COMMENT 分類(lèi)ID, unit VARCHAR(20) DEFAULT 件 COMMENT 單位, spec VARCHAR(100) COMMENT 規(guī)格型號(hào), status TINYINT DEFAULT 1 COMMENT 狀態(tài)1啟用0停用, deleted TINYINT DEFAULT 0 COMMENT 邏輯刪除0否1是, create_by VARCHAR(50), create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_by VARCHAR(50), update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_product_code (product_code) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT商品表; -- 倉(cāng)庫(kù)表 CREATE TABLE bas_warehouse ( id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT 主鍵, warehouse_code VARCHAR(50) NOT NULL COMMENT 倉(cāng)庫(kù)編碼, warehouse_name VARCHAR(100) NOT NULL COMMENT 倉(cāng)庫(kù)名稱(chēng), address VARCHAR(255) COMMENT 倉(cāng)庫(kù)地址, manager VARCHAR(50) COMMENT 負(fù)責(zé)人, status TINYINT DEFAULT 1 COMMENT 狀態(tài)1啟用0停用, deleted TINYINT DEFAULT 0 COMMENT 邏輯刪除, create_by VARCHAR(50), create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_by VARCHAR(50), update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_warehouse_code (warehouse_code) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT倉(cāng)庫(kù)表;3.3 入庫(kù)單和入庫(kù)明細(xì)表-- 入庫(kù)單主表 CREATE TABLE stk_inbound ( id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT 主鍵, inbound_no VARCHAR(50) NOT NULL COMMENT 入庫(kù)單號(hào), inbound_type TINYINT COMMENT 入庫(kù)類(lèi)型1采購(gòu)入庫(kù)2生產(chǎn)入庫(kù)3退貨入庫(kù)4盤(pán)點(diǎn)入庫(kù), supplier_id BIGINT COMMENT 供應(yīng)商ID, warehouse_id BIGINT NOT NULL COMMENT 收貨倉(cāng)庫(kù)ID, inbound_date DATE NOT NULL COMMENT 入庫(kù)日期, status TINYINT DEFAULT 0 COMMENT 狀態(tài)0草稿1已審核2已作廢, remark VARCHAR(500) COMMENT 備注, deleted TINYINT DEFAULT 0, create_by VARCHAR(50), create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_by VARCHAR(50), update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_inbound_no (inbound_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT入庫(kù)單主表; -- 入庫(kù)明細(xì)表 CREATE TABLE stk_inbound_item ( id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT 主鍵, inbound_id BIGINT NOT NULL COMMENT 入庫(kù)單主表ID, product_id BIGINT NOT NULL COMMENT 商品ID, quantity DECIMAL(18,3) NOT NULL COMMENT 入庫(kù)數(shù)量, price DECIMAL(18,2) DEFAULT 0 COMMENT 入庫(kù)單價(jià), amount DECIMAL(18,2) DEFAULT 0 COMMENT 金額, remark VARCHAR(200) COMMENT 明細(xì)備注, KEY idx_inbound_id (inbound_id), KEY idx_product_id (product_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT入庫(kù)明細(xì)表;3.4 出庫(kù)單和出庫(kù)明細(xì)表出庫(kù)表和入庫(kù)表結(jié)構(gòu)非常相似這里可以單獨(dú)建表也可以設(shè)計(jì)成一張通用庫(kù)存單據(jù)表。對(duì)于初學(xué)者來(lái)說(shuō)分表更容易理解。如果業(yè)務(wù)擴(kuò)展后單據(jù)類(lèi)型增多再考慮將公共字段抽取為通用單據(jù)表。-- 出庫(kù)單主表 CREATE TABLE stk_outbound ( id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT 主鍵, outbound_no VARCHAR(50) NOT NULL COMMENT 出庫(kù)單號(hào), outbound_type TINYINT COMMENT 出庫(kù)類(lèi)型1銷(xiāo)售出庫(kù)2生產(chǎn)領(lǐng)料3報(bào)損出庫(kù)4盤(pán)虧出庫(kù), customer_id BIGINT COMMENT 客戶(hù)ID, warehouse_id BIGINT NOT NULL COMMENT 發(fā)貨倉(cāng)庫(kù)ID, outbound_date DATE NOT NULL COMMENT 出庫(kù)日期, status TINYINT DEFAULT 0 COMMENT 狀態(tài)0草稿1已審核2已作廢, remark VARCHAR(500) COMMENT 備注, deleted TINYINT DEFAULT 0, create_by VARCHAR(50), create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_by VARCHAR(50), update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_outbound_no (outbound_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT出庫(kù)單主表; -- 出庫(kù)明細(xì)表 CREATE TABLE stk_outbound_item ( id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT 主鍵, outbound_id BIGINT NOT NULL COMMENT 出庫(kù)單主表ID, product_id BIGINT NOT NULL COMMENT 商品ID, quantity DECIMAL(18,3) NOT NULL COMMENT 出庫(kù)數(shù)量, price DECIMAL(18,2) DEFAULT 0 COMMENT 出庫(kù)單價(jià), amount DECIMAL(18,2) DEFAULT 0 COMMENT 金額, remark VARCHAR(200) COMMENT 明細(xì)備注, KEY idx_outbound_id (outbound_id), KEY idx_product_id (product_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT出庫(kù)明細(xì)表;3.5 庫(kù)存流水表和庫(kù)存余額表庫(kù)存流水表和庫(kù)存余額表是整個(gè)庫(kù)存系統(tǒng)的核心。簡(jiǎn)單來(lái)說(shuō)流水表記錄了每一次庫(kù)存變化余額表保存當(dāng)前庫(kù)存數(shù)量。查詢(xún)報(bào)表時(shí)可以先看余額表要追溯時(shí)可以查流水表。-- 庫(kù)存流水表 CREATE TABLE stk_stock_flow ( id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT 主鍵, flow_no VARCHAR(50) NOT NULL COMMENT 流水號(hào), product_id BIGINT NOT NULL COMMENT 商品ID, warehouse_id BIGINT NOT NULL COMMENT 倉(cāng)庫(kù)ID, change_type TINYINT COMMENT 變動(dòng)類(lèi)型1入庫(kù)2出庫(kù)3盤(pán)點(diǎn)調(diào)整4調(diào)撥出5調(diào)撥入, source_no VARCHAR(50) COMMENT 來(lái)源單據(jù)號(hào), source_type TINYINT COMMENT 來(lái)源單據(jù)類(lèi)型1入庫(kù)單2出庫(kù)單3盤(pán)點(diǎn)單4調(diào)撥單, quantity_change DECIMAL(18,3) NOT NULL COMMENT 變動(dòng)數(shù)量入庫(kù)為正出庫(kù)為負(fù), before_quantity DECIMAL(18,3) DEFAULT 0 COMMENT 變動(dòng)前庫(kù)存, after_quantity DECIMAL(18,3) DEFAULT 0 COMMENT 變動(dòng)后庫(kù)存, create_by VARCHAR(50), create_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_flow_no (flow_no), KEY idx_product_warehouse (product_id, warehouse_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT庫(kù)存流水表; -- 庫(kù)存余額表 CREATE TABLE stk_stock_balance ( id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT 主鍵, product_id BIGINT NOT NULL COMMENT 商品ID, warehouse_id BIGINT NOT NULL COMMENT 倉(cāng)庫(kù)ID, stock_quantity DECIMAL(18,3) NOT NULL DEFAULT 0 COMMENT 庫(kù)存數(shù)量, locked_quantity DECIMAL(18,3) NOT NULL DEFAULT 0 COMMENT 鎖定數(shù)量, available_quantity DECIMAL(18,3) NOT NULL DEFAULT 0 COMMENT 可用數(shù)量, version INT NOT NULL DEFAULT 0 COMMENT 樂(lè)觀鎖版本號(hào), create_by VARCHAR(50), create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_by VARCHAR(50), update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_product_warehouse (product_id, warehouse_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT庫(kù)存余額表;這里引入了一個(gè)“鎖定數(shù)量”字段。這個(gè)概念在電商庫(kù)存系統(tǒng)中用得非常多。當(dāng)用戶(hù)提交訂單但還沒(méi)付款時(shí)可以先占用一部分庫(kù)存避免其他訂單繼續(xù)超賣(mài)。在普通ERP庫(kù)存系統(tǒng)中如果業(yè)務(wù)流程沒(méi)有“預(yù)占”環(huán)節(jié)可以暫時(shí)不用這個(gè)字段但保留它能為以后的復(fù)雜業(yè)務(wù)留出擴(kuò)展空間。3.6 為什么需要 before_quantity 和 after_quantity在設(shè)計(jì)庫(kù)存流水表時(shí)有人會(huì)問(wèn)既然有了 quantity_change為什么還要存 before_quantity 和 after_quantity原因很簡(jiǎn)單便于對(duì)賬和排查。如果某一天發(fā)現(xiàn)庫(kù)存余額不平可以通過(guò)流水表快速看到每筆操作發(fā)生前后的庫(kù)存快照。特別是在多人同時(shí)操作的情況下光靠變動(dòng)數(shù)量無(wú)法還原當(dāng)時(shí)的庫(kù)存上下文。保留這兩個(gè)字段相當(dāng)于給每次庫(kù)存變動(dòng)記錄了系統(tǒng)日志。4. 后端核心實(shí)現(xiàn)Spring Boot 庫(kù)存服務(wù)4.1 項(xiàng)目基礎(chǔ)結(jié)構(gòu)假設(shè)我們使用Spring Boot MyBatis-Plus來(lái)構(gòu)建后端服務(wù)項(xiàng)目結(jié)構(gòu)如下erp-stock-system ├── pom.xml └── src └── main ├── java │ └── com │ └── example │ └── erp │ ├── ErpApplication.java │ ├── common │ │ ├── Result.java │ │ └── BusinessException.java │ ├── controller │ │ └── StockController.java │ ├── entity │ │ ├── StockBalance.java │ │ ├── StockFlow.java │ │ ├── Inbound.java │ │ └── Outbound.java │ ├── mapper │ │ ├── StockBalanceMapper.java │ │ ├── StockFlowMapper.java │ │ ├── InboundMapper.java │ │ └── OutboundMapper.java │ └── service │ ├── InboundService.java │ └── OutboundService.java └── resources ├── application.yml └── mapper4.2 Maven 依賴(lài)配置dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.5/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version8.0.33/version /dependency dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/optional /dependency /dependencies4.3 庫(kù)存扣減的核心邏輯庫(kù)存扣減是庫(kù)存系統(tǒng)里最容易出Bug的地方。下面用一個(gè)出庫(kù)場(chǎng)景來(lái)演示核心代碼。注意這里的示例重點(diǎn)是事務(wù)和并發(fā)控制。首先定義庫(kù)存余額實(shí)體Data TableName(stk_stock_balance) public class StockBalance { TableId(type IdType.AUTO) private Long id; private Long productId; private Long warehouseId; private BigDecimal stockQuantity; private BigDecimal lockedQuantity; private BigDecimal availableQuantity; Version private Integer version; }然后在Service中實(shí)現(xiàn)出庫(kù)審核邏輯Service public class OutboundService { Resource private StockBalanceMapper stockBalanceMapper; Resource private StockFlowMapper stockFlowMapper; Transactional(rollbackFor Exception.class) public void auditOutbound(Outbound outbound, ListOutboundItem items) { for (OutboundItem item : items) { // 1. 查詢(xún)庫(kù)存余額 StockBalance balance stockBalanceMapper.selectByProductAndWarehouse( item.getProductId(), outbound.getWarehouseId()); if (balance null) { throw new BusinessException(庫(kù)存余額不存在請(qǐng)先初始化庫(kù)存); } // 2. 校驗(yàn)可用庫(kù)存 if (balance.getAvailableQuantity().compareTo(item.getQuantity()) 0) { throw new BusinessException(商品庫(kù)存不足); } // 3. 使用樂(lè)觀鎖更新庫(kù)存 int rows stockBalanceMapper.deductStock( balance.getId(), item.getQuantity(), balance.getVersion()); if (rows 0) { throw new BusinessException(庫(kù)存扣減失敗請(qǐng)重試); } // 4. 寫(xiě)入庫(kù)存流水 StockFlow flow new StockFlow(); flow.setFlowNo(generateFlowNo()); flow.setProductId(item.getProductId()); flow.setWarehouseId(outbound.getWarehouseId()); flow.setChangeType(2); flow.setSourceNo(outbound.getOutboundNo()); flow.setSourceType(2); flow.setQuantityChange(item.getQuantity().negate()); flow.setBeforeQuantity(balance.getStockQuantity()); flow.setAfterQuantity(balance.getStockQuantity().subtract(item.getQuantity())); flow.setCreateBy(outbound.getUpdateBy()); stockFlowMapper.insert(flow); } // 5. 更新出庫(kù)單狀態(tài)為已審核 outbound.setStatus(1); outboundMapper.updateById(outbound); } }對(duì)應(yīng)的Mapper SQL如下update iddeductStock UPDATE stk_stock_balance SET stock_quantity stock_quantity - #{quantity}, available_quantity available_quantity - #{quantity}, version version 1 WHERE id #{id} AND version #{version} AND available_quantity gt; #{quantity} /update這段SQL的關(guān)鍵點(diǎn)在于UPDATE語(yǔ)句中同時(shí)帶上了version條件并且通過(guò)available_quantity大于等于待扣數(shù)量來(lái)做額外的庫(kù)存約束。在數(shù)據(jù)庫(kù)層面這種寫(xiě)法可以防止并發(fā)場(chǎng)景下兩個(gè)請(qǐng)求同時(shí)扣減同一份庫(kù)存。4.4 入庫(kù)審核的核心邏輯入庫(kù)相對(duì)簡(jiǎn)單核心是生成流水并累加庫(kù)存Transactional(rollbackFor Exception.class) public void auditInbound(Inbound inbound, ListInboundItem items) { for (InboundItem item : items) { StockBalance balance stockBalanceMapper.selectByProductAndWarehouse( item.getProductId(), inbound.getWarehouseId()); if (balance null) { // 初始化一條庫(kù)存余額記錄 balance new StockBalance(); balance.setProductId(item.getProductId()); balance.setWarehouseId(inbound.getWarehouseId()); balance.setStockQuantity(BigDecimal.ZERO); balance.setLockedQuantity(BigDecimal.ZERO); balance.setAvailableQuantity(BigDecimal.ZERO); stockBalanceMapper.insert(balance); } // 增加庫(kù)存 int rows stockBalanceMapper.addStock( balance.getId(), item.getQuantity(), balance.getVersion()); if (rows 0) { throw new BusinessException(庫(kù)存更新失敗請(qǐng)重試); } // 寫(xiě)入流水 StockFlow flow new StockFlow(); flow.setFlowNo(generateFlowNo()); flow.setProductId(item.getProductId()); flow.setWarehouseId(inbound.getWarehouseId()); flow.setChangeType(1); flow.setSourceNo(inbound.getInboundNo()); flow.setSourceType(1); flow.setQuantityChange(item.getQuantity()); flow.setBeforeQuantity(balance.getStockQuantity()); flow.setAfterQuantity(balance.getStockQuantity().add(item.getQuantity())); flow.setCreateBy(inbound.getUpdateBy()); stockFlowMapper.insert(flow); } inbound.setStatus(1); inboundMapper.updateById(inbound); }這里有幾個(gè)容易被忽略的點(diǎn)新增庫(kù)存余額記錄時(shí)要注意并發(fā)插入。如果在極短時(shí)間內(nèi)同時(shí)收到兩條入庫(kù)請(qǐng)求且?guī)齑嬗囝~記錄不存在可能會(huì)插入兩條重復(fù)記錄。解決方式有兩種一是利用數(shù)據(jù)庫(kù)唯一索引uk_product_warehouse兜底二是先查詢(xún)?nèi)绻淮嬖趧t通過(guò)INSERT IGNORE或ON DUPLICATE KEY UPDATE方式處理。流水號(hào)建議使用類(lèi)似“年月日隨機(jī)數(shù)”或“日期序列”的格式。這里可以使用Redis自增序列也可以直接用數(shù)據(jù)庫(kù)的ID生成器但一定要保證唯一。4.5 事務(wù)與并發(fā)控制方案選擇在庫(kù)存扣減場(chǎng)景中常見(jiàn)的并發(fā)控制方案有三種方案實(shí)現(xiàn)方式優(yōu)點(diǎn)缺點(diǎn)樂(lè)觀鎖使用version字段更新時(shí)校驗(yàn)版本號(hào)簡(jiǎn)單、性能好沖突時(shí)需要重試悲觀鎖SELECT ... FOR UPDATE鎖定記錄實(shí)現(xiàn)直觀不會(huì)重試并發(fā)高時(shí)鎖競(jìng)爭(zhēng)激烈數(shù)據(jù)庫(kù)約束使用條件更新 WHERE available_quantity quantity數(shù)據(jù)庫(kù)層面兜底需要配合樂(lè)觀鎖或事務(wù)使用實(shí)際項(xiàng)目中我比較推薦“樂(lè)觀鎖 條件更新”的組合方式。先用樂(lè)觀鎖保證版本一致性再用數(shù)據(jù)庫(kù)條件更新作為最終約束這樣既不會(huì)因?yàn)殒i等待導(dǎo)致系統(tǒng)卡頓又能防止超賣(mài)。5. 前端頁(yè)面Vue 3 Element Plus 快速搭建5.1 前端項(xiàng)目結(jié)構(gòu)前端部分使用Vue 3 Vite Element Plus項(xiàng)目結(jié)構(gòu)如下erp-web ├── index.html ├── package.json ├── vite.config.js └── src ├── main.js ├── App.vue ├── api │ └── stock.js └── views ├── dashboard.vue ├── inbound.vue ├── outbound.vue └── stock.vue5.2 庫(kù)存余額展示頁(yè)面下面是一個(gè)簡(jiǎn)單的庫(kù)存余額列表頁(yè)面template div classstock-page el-card el-form :inlinetrue el-form-item label商品編碼 el-input v-modelqueryParam.productCode placeholder請(qǐng)輸入商品編碼 clearable / /el-form-item el-form-item label倉(cāng)庫(kù) el-select v-modelqueryParam.warehouseId placeholder請(qǐng)選擇倉(cāng)庫(kù) clearable el-option v-foritem in warehouseList :keyitem.id :labelitem.warehouseName :valueitem.id / /el-select /el-form-item el-form-item el-button typeprimary clickloadStockList查詢(xún)/el-button el-button clickresetQuery重置/el-button /el-form-item /el-form /el-card el-card stylemargin-top: 16px el-table :datastockList border stripe el-table-column propproductCode label商品編碼 min-width120 / el-table-column propproductName label商品名稱(chēng) min-width150 / el-table-column propwarehouseName label倉(cāng)庫(kù)名稱(chēng) min-width120 / el-table-column propstockQuantity label庫(kù)存數(shù)量 min-width100 alignright / el-table-column proplockedQuantity label鎖定數(shù)量 min-width100 alignright / el-table-column propavailableQuantity label可用數(shù)量 min-width100 alignright / /el-table /el-card /div /template script setup import { ref, onMounted } from vue import { getStockList } from /api/stock const queryParam ref({ productCode: , warehouseId: null }) const stockList ref([]) const warehouseList ref([]) async function loadStockList() { const res await getStockList(queryParam.value) stockList.value res.data.records } function resetQuery() { queryParam.value { productCode: , warehouseId: null } loadStockList() } onMounted(() { loadStockList() }) /script這個(gè)頁(yè)面的作用是展示當(dāng)前庫(kù)存余額讓管理者可以快速看到某個(gè)倉(cāng)庫(kù)中所有商品的數(shù)量分布。實(shí)際系統(tǒng)中還可以加入庫(kù)存預(yù)警閾值當(dāng)可用庫(kù)存低于某個(gè)下限時(shí)在列表里用紅色標(biāo)識(shí)提示需要補(bǔ)貨。5.3 前端接口封裝import request from /utils/request export function getStockList(params) { return request({ url: /api/stock/list, method: get, params }) } export function auditInbound(data) { return request({ url: /api/inbound/audit, method: post, data }) } export function auditOutbound(data) { return request({ url: /api/outbound/audit, method: post, data }) }前端做好數(shù)據(jù)展示和交互即可真正的業(yè)務(wù)規(guī)則必須放在后端服務(wù)中。尤其是庫(kù)存扣減、事務(wù)、權(quán)限校驗(yàn)這類(lèi)邏輯如果放在前端任何人都可以通過(guò)瀏覽器控制臺(tái)繞過(guò)。6. 開(kāi)源許可證與二次開(kāi)發(fā)注意事項(xiàng)6.1 許可證怎么選很多開(kāi)發(fā)者在Gitee或GitHub上創(chuàng)建開(kāi)源項(xiàng)目時(shí)都會(huì)糾結(jié)許可證怎么選。這里給出一個(gè)簡(jiǎn)單的選擇建議許可證特點(diǎn)適合場(chǎng)景MIT寬松允許商用允許閉源個(gè)人項(xiàng)目、內(nèi)部系統(tǒng)、希望被廣泛使用Apache 2.0寬松附帶專(zhuān)利授權(quán)條款企業(yè)級(jí)開(kāi)源項(xiàng)目GPL傳染性衍生代碼需開(kāi)源希望生態(tài)保持開(kāi)源LGPL修改庫(kù)文件才需要開(kāi)源被其他項(xiàng)目以庫(kù)形式引用MPL文件級(jí)別傳染混合開(kāi)源開(kāi)發(fā)模型如果你在開(kāi)發(fā)開(kāi)源ERP庫(kù)存管理系統(tǒng)需要明確一個(gè)問(wèn)題允許別人拿去商用而不開(kāi)源嗎如果允許建議MIT或Apache 2.0如果希望商業(yè)公司在使用后把增強(qiáng)功能回饋社區(qū)可以選擇GPL。6.2 二次開(kāi)發(fā)時(shí)的注意事項(xiàng)保留上游項(xiàng)目版權(quán)聲明不要?jiǎng)h除License文件。修改代碼時(shí)盡量以模塊化方式擴(kuò)展避免在核心代碼上大面積改動(dòng)。如果是基于GPL項(xiàng)目二次開(kāi)發(fā)對(duì)外分發(fā)時(shí)需要注意源代碼開(kāi)放義務(wù)。如果只是企業(yè)內(nèi)部使用不分發(fā)給第三方GPL和MIT的影響相對(duì)可控但也要咨詢(xún)法務(wù)。7. 常見(jiàn)問(wèn)題與排查思路7.1 常見(jiàn)問(wèn)題匯總表問(wèn)題現(xiàn)象常見(jiàn)原因解決思路庫(kù)存扣減后變成負(fù)數(shù)未使用樂(lè)觀鎖或條件更新并發(fā)扣減在UPDATE語(yǔ)句中增加可用庫(kù)存判斷使用version同一商品出現(xiàn)多條庫(kù)存余額記錄并發(fā)插入庫(kù)存余額唯一索引失效創(chuàng)建唯一索引插入時(shí)使用ON DUPLICATE KEY UPDATE頁(yè)面顯示庫(kù)存和實(shí)際盤(pán)點(diǎn)不一致手工修改數(shù)據(jù)庫(kù)導(dǎo)致流水?dāng)嗔呀故止じ膸?kù)存通過(guò)盤(pán)點(diǎn)單調(diào)整超賣(mài)訂單提交和庫(kù)存扣減不是同一事務(wù)將訂單創(chuàng)建與庫(kù)存扣減放在同一事務(wù)中流水號(hào)重復(fù)使用時(shí)間戳或簡(jiǎn)單隨機(jī)數(shù)生成流水號(hào)使用Redis自增或數(shù)據(jù)庫(kù)序列報(bào)表查詢(xún)慢庫(kù)存流水表全表掃描為product_id、warehouse_id、create_time建立聯(lián)合索引Decimal類(lèi)型計(jì)算出現(xiàn)精度問(wèn)題使用double或float存儲(chǔ)金額金額和數(shù)量統(tǒng)一使用DECIMAL7.2 復(fù)盤(pán)一個(gè)典型的庫(kù)存變負(fù)事故假設(shè)系統(tǒng)上線(xiàn)后運(yùn)營(yíng)反饋某個(gè)商品庫(kù)存從“3件”變成了“-1件”。排查思路如下查詢(xún)?cè)撋唐返膸?kù)存流水表看看最近有哪些出入庫(kù)記錄。找出導(dǎo)致庫(kù)存變負(fù)的那條流水確認(rèn)來(lái)源單據(jù)。檢查該單據(jù)審核時(shí)后端是否執(zhí)行了“可用庫(kù)存是否充足”的判斷。如果判斷邏輯存在再看是否是并發(fā)導(dǎo)致的兩個(gè)請(qǐng)求同時(shí)讀到庫(kù)存為3同時(shí)扣2理論上最終結(jié)果應(yīng)該是1但由于沒(méi)有版本號(hào)控制兩條SQL都成功執(zhí)行結(jié)果變成-1。修復(fù)方式為庫(kù)存余額表增加version字段修改UPDATE語(yǔ)句增加available_quantity quantity條件。這個(gè)案例說(shuō)明庫(kù)存系統(tǒng)不是“能增能減”就行必須把并發(fā)控制放到數(shù)據(jù)庫(kù)層而不是依賴(lài)應(yīng)用層的判斷。8. 最佳實(shí)踐與工程建議8.1 單據(jù)驅(qū)動(dòng)禁止直接改庫(kù)存很多初學(xué)ERP開(kāi)發(fā)的朋友會(huì)犯一個(gè)錯(cuò)誤用戶(hù)點(diǎn)擊“庫(kù)存調(diào)整”直接寫(xiě)一條UPDATE語(yǔ)句把庫(kù)存數(shù)量改了。這樣做表面上很快實(shí)際是在給系統(tǒng)埋雷。正確的做法是引入“盤(pán)點(diǎn)單”。盤(pán)點(diǎn)的本質(zhì)是“賬面數(shù)量 vs 實(shí)盤(pán)數(shù)量”差異部分通過(guò)盤(pán)點(diǎn)單生成盤(pán)盈或盤(pán)虧流水最終調(diào)整庫(kù)存余額。這樣做的好處是所有庫(kù)存變動(dòng)都可追溯每筆變更都能找到對(duì)應(yīng)的業(yè)務(wù)單據(jù)。8.2 庫(kù)存流水只增不改庫(kù)存流水表記錄了企業(yè)所有庫(kù)存變動(dòng)的歷史是一份極其重要的審計(jì)數(shù)據(jù)。設(shè)計(jì)上應(yīng)該做到流水表不提供修改和刪除接口。即使對(duì)應(yīng)的入庫(kù)單或出庫(kù)單作廢流水表仍然保留。作廢單據(jù)時(shí)需要生成一張反向沖銷(xiāo)的流水而不是物理刪除原來(lái)的流水。這樣設(shè)計(jì)雖然會(huì)增加一些開(kāi)發(fā)量但長(zhǎng)期來(lái)看系統(tǒng)的數(shù)據(jù)可信度會(huì)高很多。8.3 永遠(yuǎn)在事務(wù)中操作庫(kù)存所有涉及庫(kù)存余額變化的操作都必須放在數(shù)據(jù)庫(kù)事務(wù)中。Spring中可以通過(guò)Transactional注解實(shí)現(xiàn)。事務(wù)的隔離級(jí)別建議設(shè)置為默認(rèn)的數(shù)據(jù)庫(kù)隔離級(jí)別不要輕易提高。另外事務(wù)中盡量縮短業(yè)務(wù)邏輯的執(zhí)行時(shí)間不要在事務(wù)內(nèi)調(diào)用第三方HTTP接口、發(fā)送短信郵件、等待MQ消息等耗時(shí)操作。否則會(huì)導(dǎo)致數(shù)據(jù)庫(kù)連接長(zhǎng)時(shí)間占用系統(tǒng)吞吐量下降。8.4 權(quán)限與安全邊界庫(kù)存管理系統(tǒng)涉及企業(yè)的核心資產(chǎn)數(shù)據(jù)安全設(shè)計(jì)不能放松接口必須做登錄認(rèn)證不能裸奔。權(quán)限控制建議做到按鈕級(jí)別不只是頁(yè)面級(jí)別。審核入庫(kù)單和創(chuàng)建入庫(kù)單建議由不同角色承擔(dān)形成簡(jiǎn)單復(fù)核機(jī)制。所有寫(xiě)操作都要記錄操作日志包括操作人、操作時(shí)間、IP、操作內(nèi)容。不要在前端頁(yè)面暴露全量庫(kù)存導(dǎo)出接口或者至少加入權(quán)限校驗(yàn)和數(shù)據(jù)量限制防止數(shù)據(jù)被批量拉取。8.5 數(shù)據(jù)備份與灰度發(fā)布生產(chǎn)環(huán)境數(shù)據(jù)庫(kù)必須配置自動(dòng)備份策略。庫(kù)存數(shù)據(jù)一旦丟失或錯(cuò)亂恢復(fù)成本極高。建議每周至少做一次全量備份每天做一次增量備份備份文件要存儲(chǔ)到獨(dú)立存儲(chǔ)空間。系統(tǒng)升級(jí)時(shí)先在測(cè)試環(huán)境完整跑一遍出入庫(kù)流程確認(rèn)無(wú)問(wèn)題后再發(fā)布到生產(chǎn)。如果條件允許可以采用灰度發(fā)布方式先讓倉(cāng)庫(kù)部門(mén)的少量賬號(hào)使用新版本驗(yàn)證穩(wěn)定后再全員切換。8.6 性能優(yōu)化要點(diǎn)庫(kù)存流水表數(shù)據(jù)量會(huì)隨著業(yè)務(wù)增長(zhǎng)快速膨脹建議按季度或按年做分區(qū)。常用查詢(xún)條件要建立聯(lián)合索引例如(product_id, warehouse_id, create_time)。庫(kù)存余額表的查詢(xún)非常頻繁盡量通過(guò)組合條件唯一命中記錄避免全表掃描。報(bào)表類(lèi)查詢(xún)不要直接在業(yè)務(wù)庫(kù)執(zhí)行可以通過(guò)定時(shí)任務(wù)把數(shù)據(jù)同步到統(tǒng)計(jì)庫(kù)或者使用讀從庫(kù)。9. 總結(jié)與學(xué)習(xí)路線(xiàn)寫(xiě)到這里開(kāi)源ERP庫(kù)存管理系統(tǒng)的核心內(nèi)容已經(jīng)拆解得比較完整了。如果你是從零開(kāi)始學(xué)習(xí)ERP開(kāi)發(fā)建議按下面的路線(xiàn)繼續(xù)深入先理解“單據(jù) 流水 余額”三層模型這是整個(gè)庫(kù)存系統(tǒng)的靈魂。自己動(dòng)手建一套MySQL表結(jié)構(gòu)手工插入幾條入庫(kù)單、出庫(kù)單數(shù)據(jù)查看庫(kù)存余額變化。學(xué)習(xí)Spring Boot事務(wù)管理和MyBatis-Plus的樂(lè)觀鎖插件寫(xiě)一個(gè)簡(jiǎn)單的庫(kù)存扣減接口。完善單據(jù)審核流程、庫(kù)存盤(pán)點(diǎn)、庫(kù)存預(yù)警、調(diào)撥單逐步擴(kuò)展系統(tǒng)能力。閱讀優(yōu)秀開(kāi)源項(xiàng)目的源碼比如Odoo庫(kù)存模塊、ERPNext的Stock模塊學(xué)習(xí)它們的業(yè)務(wù)抽象方式。最后嘗試把庫(kù)存模塊與采購(gòu)、銷(xiāo)售、財(cái)務(wù)模塊打通理解ERP系統(tǒng)各模塊之間的關(guān)聯(lián)。開(kāi)源ERP庫(kù)存管理系統(tǒng)的優(yōu)勢(shì)在于你可以邊看源碼邊學(xué)習(xí)遇到不滿(mǎn)足的業(yè)務(wù)需求還能自己改。但這也意味著你需要投入時(shí)間和精力不能把自己當(dāng)成普通的軟件使用者。如果你所在的企業(yè)正好需要一個(gè)庫(kù)存管理系統(tǒng)不妨先從本文提到的數(shù)據(jù)模型和核心接口入手搭一個(gè)最小可運(yùn)行版本再根據(jù)實(shí)際業(yè)務(wù)去迭代。我在文章里分享的SQL和Java代碼均是可以直接參考的核心片段。實(shí)際項(xiàng)目里你還需要補(bǔ)充用戶(hù)認(rèn)證、菜單權(quán)限、操作日志、報(bào)表統(tǒng)計(jì)等功能。如果遇到庫(kù)存扣減并發(fā)問(wèn)題優(yōu)先檢查你們的庫(kù)存余額表是否使用了樂(lè)觀鎖以及UPDATE語(yǔ)句是否帶有庫(kù)存數(shù)量約束條件——大部分問(wèn)題都出在這兩個(gè)地方。如果你準(zhǔn)備把某個(gè)開(kāi)源ERP項(xiàng)目引入公司也不要急著部署。先讓運(yùn)維和開(kāi)發(fā)團(tuán)隊(duì)把技術(shù)棧、部署方式、License條款、二次開(kāi)發(fā)成本都評(píng)估清楚。庫(kù)存系統(tǒng)是業(yè)務(wù)系統(tǒng)的核心一旦跑了錯(cuò)誤的數(shù)據(jù)業(yè)務(wù)方對(duì)系統(tǒng)的信任度會(huì)大打折扣。希望這篇文章能幫你少走一些彎路。