☰
小型自选商场商品管理系统:MySQL数据库设计与库存对账
2026/10/31 2:51:32 网站建设 项目流程

简介:面向计算机专业课程设计与小型零售信息化场景的商品管理系统完整工程,围绕自选商场进货、销售、库存三条主线实现闭环管理。系统支持逐笔登记进货并按月汇总统计,记录销售流水并完成日盘存、月盘存,进货与售货操作同步刷新库存;同时提供供应商信息查询,以及按商品编号和数量生成购物清单、显示收付款情况的收银台程序。数据库按库存、售货、进货、供应商四表组织,涵盖定价、折扣率、最低存量、存根号等业务字段。

压缩包共189个文件、约680KB,以60个sql脚本、54个cs源码、24个resx与resources资源文件为主,另含sln解决方案、csproj工程、exe程序及mdf/ldf数据库文件,可直接导入调试。已有1028人学习下载。读者可获得可运行的WinForms实现、建库建表脚本、类图与窗体模块划分参考,适合作数据库课程设计蓝本或二次开发基础。

1. 小型自选商场商品管理系统:先看库存对不上的那笔账

小型自选商场商品管理系统这类资源,真正要看的不是页面多漂亮,而是“数据库 商品管理系统”这条主线有没有闭环。一个三十来平的自选商场,SKU 可能不到两千,收银台却经常因为库存对不上重新盘货:前台显示还有六瓶饮料,货架只剩两瓶;采购说入了二十箱,系统库存只加了十八箱。问题往往不在收银页面,而在商品档案、入库单、销售单和库存流水没有落在同一个数据库事务里。这套系统解决的是商品分类、条码、供应商、采购入库、销售出库、库存预警和日报统计的基础闭环,适合做课程设计、小型门店数字化原型,也适合拿来练手进销存数据库设计。读者如果是刚接触商品管理系统的开发者,可以顺着建表和事务写一遍;如果是熟手,重点看库存扣减、条码唯一性和对账 SQL 这几个边界。

2. 数据库选型与商品档案建模:SKU、条码和分类怎么落表

2.1 为什么先定数据库边界,而不是先画收银页

小型自选商场的数据特征很明确:商品数量不算大,但条码入口多、库存变化频繁、收银不能停、报表要能按天和分类汇总。这个场景下,我一般优先选关系型数据库,MySQL 是常见做法,原因是事务、唯一约束和聚合查询都能直接兜住核心问题。收银扣库存必须和销售单、流水写在同一个事务里,条码重复必须靠唯一索引拦住,日报统计用一条分组 SQL 就能出结果。先把页面画出来再倒推表结构,后面很容易出现“销售明细没有价格快照”“库存只存汇总无法追溯”这类翻车点。缓存不是不能上,但缓存只适合放条码查询这类热点,库存真相仍然留在数据库。

选型时还要注意两个边界。第一,金额字段不要用浮点类型,采购价、销售价、优惠金额统一用decimal,否则日报汇总会出现几分钱尾差,对账时非常难受。第二,商品不要物理删除。自选商场的商品会下架、换包装、换条码,历史销售单还要能查到当时的商品名和价格,所以商品表用状态字段控制上下架,销售明细保存商品名称、条码、单价和成本价快照。这样即使商品档案后来改了价格,历史订单也不会被改写。数据库表引擎用 InnoDB,事务和行锁是后面库存扣减的基础。

2.2 商品主表、条码表和分类表怎么拆

商品档案最容易图省事,把所有信息塞进一张表:商品名、分类名、条码、供应商、库存数量全在一行。刚跑演示数据没问题,一旦出现“一品多码”就会卡住。比如同一瓶饮料,箱装和瓶装条码不同,促销期还可能贴临时条码;如果条码直接挂在商品主表上,要么重复建商品,要么一个字段存多个条码,扫描时只能做字符串拆分。常见做法是把条码拆成独立表,商品主表只保留内部 SKU 编码,条码表负责扫描入口。

下面是一组适合小型自选商场的核心表设计,字段不用追求大而全,但主键、状态、价格快照和唯一约束要保留。

表名关键字段作用
categoryid、parent_id、name、sort_no、status商品分类,两级分类足够小型门店使用
productid、sku_code、name、category_id、supplier_id、unit、purchase_price、sale_price、status商品主档案,保存内部编码和当前售价
product_barcodeid、product_id、barcode、is_primary、status一品多码,条码唯一
supplierid、name、contact、phone、status供应商基础信息
stockproduct_id、quantity、locked_quantity、version、update_time当前库存汇总,收银快速读取
stock_logid、product_id、biz_type、biz_no、change_qty、before_qty、after_qty、operator_id、create_time库存变化流水,对账和追溯

sku_code是内部编码,可以按分类加流水号生成,不直接暴露给顾客;barcode才是扫描入口。条码表上要建唯一索引,并且只对有效条码生效。如果数据库不支持部分唯一索引,就把停用条码改成带后缀的失效值,或者用status加唯一索引的组合方案。分类表用parent_id表达层级,一级分类parent_id=0,二级分类挂在一级下面,不建议在小型系统里做无限层级,页面选择和报表汇总都会变复杂。供应商可以先只存名称和联系方式,采购单里保存供应商快照,避免供应商改名后历史单据跟着变。

2.3 库存表和库存流水表为什么必须分开

库存表只存当前数量,查询快、收银扣减直接;库存流水表存每一次变化的原因和前后数量,排错和对账靠它。只保留库存表,出现“系统库存 18,实际 16”时只能看到结果,不知道是哪一笔入库少加了、哪一笔销售多扣了,也没有后悔药。把流水表加上后,任何一次库存变化都要写一条记录:采购入库写正数,销售出库写负数,退货写正数,盘点盈亏按差异写正负。流水里的biz_type用固定枚举,比如PURCHASE_IN、SALE_OUT、SALE_RETURN、CHECK_ADJUST,biz_no存采购单号或销售单号,方便按单据反查。

库存表里我一般会保留version字段,用来做乐观锁;小型门店虽然并发不高,但两台收银机同时扫同一件商品的情况并不少见。locked_quantity可以留给预留库存场景,比如线上下单未提货,普通课程设计可以先置 0。库存数量不要允许为负,数据库层用条件更新兜底,应用层再给出“库存不足”的提示。流水表和库存表必须在同一个事务里写,先更新库存,再插入流水,任何一步失败都回滚。这样库存汇总和流水明细才有机会对得上。血泪经验是:只写库存表不写流水,前面演示很顺,后面一补对账功能就得全表猜账。

3. 建库建表与初始化:一份能跑起来的 SQL 脚本

3.1 建库与核心表脚本

拿到这类源码包,第一步不是急着启动前端,而是先把数据库跑通。我一般会新建一个独立库,字符集用utf8mb4,然后按“分类、供应商、商品、条码、库存、流水、销售单、销售明细”的顺序建表。下面这份脚本可以直接在 MySQL 里执行,表名和字段名按常见商品管理系统习惯命名,方便和资源包里的实体类对照。

-- 建库:小型自选商场商品管理系统 CREATE DATABASE IF NOT EXISTS mini_market DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE mini_market; -- 分类表:两级分类足够,parent_id=0 表示一级 CREATE TABLE category ( id BIGINT PRIMARY KEY AUTO_INCREMENT, parent_id BIGINT NOT NULL DEFAULT 0, name VARCHAR(64) NOT NULL, sort_no INT NOT NULL DEFAULT 0, status TINYINT NOT NULL DEFAULT 1 COMMENT '1启用 0停用', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_parent (parent_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 供应商表 CREATE TABLE supplier ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(128) NOT NULL, contact VARCHAR(32), phone VARCHAR(32), status TINYINT NOT NULL DEFAULT 1, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 商品主表:价格用 decimal,停用不物理删除 CREATE TABLE product ( id BIGINT PRIMARY KEY AUTO_INCREMENT, sku_code VARCHAR(64) NOT NULL, name VARCHAR(128) NOT NULL, category_id BIGINT NOT NULL, supplier_id BIGINT, unit VARCHAR(16) NOT NULL DEFAULT '件', purchase_price DECIMAL(10,2) NOT NULL DEFAULT 0.00, sale_price DECIMAL(10,2) NOT NULL DEFAULT 0.00, status TINYINT NOT NULL DEFAULT 1 COMMENT '1上架 0下架', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_sku (sku_code), KEY idx_category (category_id), KEY idx_name (name) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 条码表:一品多码,条码唯一 CREATE TABLE product_barcode ( id BIGINT PRIMARY KEY AUTO_INCREMENT, product_id BIGINT NOT NULL, barcode VARCHAR(64) NOT NULL, is_primary TINYINT NOT NULL DEFAULT 0, status TINYINT NOT NULL DEFAULT 1, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_barcode (barcode), KEY idx_product (product_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 库存汇总表:version 做乐观锁 CREATE TABLE stock ( product_id BIGINT PRIMARY KEY, quantity INT NOT NULL DEFAULT 0, locked_quantity INT NOT NULL DEFAULT 0, version INT NOT NULL DEFAULT 0, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 库存流水:每一次变化都要落一条 CREATE TABLE stock_log ( id BIGINT PRIMARY KEY AUTO_INCREMENT, product_id BIGINT NOT NULL, biz_type VARCHAR(32) NOT NULL COMMENT 'PURCHASE_IN/SALE_OUT/SALE_RETURN/CHECK_ADJUST', biz_no VARCHAR(64) NOT NULL, change_qty INT NOT NULL COMMENT '正数增加,负数减少', before_qty INT NOT NULL, after_qty INT NOT NULL, operator_id BIGINT, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_product_time (product_id, create_time), KEY idx_biz_no (biz_no) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 销售单 CREATE TABLE sale_order ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(64) NOT NULL, total_amount DECIMAL(10,2) NOT NULL DEFAULT 0.00, pay_amount DECIMAL(10,2) NOT NULL DEFAULT 0.00, pay_type VARCHAR(16) NOT NULL DEFAULT 'CASH', status TINYINT NOT NULL DEFAULT 1 COMMENT '1已完成 2已退货', cashier_id BIGINT, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_order_no (order_no), KEY idx_create_time (create_time) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 销售明细:保存商品名称和价格快照 CREATE TABLE sale_order_item ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_id BIGINT NOT NULL, product_id BIGINT NOT NULL, barcode VARCHAR(64) NOT NULL, product_name VARCHAR(128) NOT NULL, sale_price DECIMAL(10,2) NOT NULL, quantity INT NOT NULL, amount DECIMAL(10,2) NOT NULL, KEY idx_order (order_id), KEY idx_product (product_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

这份脚本里,product和product_barcode是我最不建议合并的两张表。sku_code唯一约束保证内部编码不重复,barcode唯一约束保证扫描入口不歧义。stock用product_id做主键,一个商品一条库存记录,避免同商品出现多条库存行。sale_order_item故意冗余了product_name和sale_price,这是价格快照,后面商品改价不会影响历史订单。执行完脚本后,用SHOW TABLES确认八张表都在,再用SHOW CREATE TABLE product_barcode看一眼唯一索引是否生效。

3.2 初始化分类、供应商和测试商品

建完表直接启动系统,页面多半是空的,先插一组最小数据:一个一级分类、一个二级分类、一个供应商、两个商品、三个条码。初始化 SQL 不要依赖界面录入,方便反复重建测试库。

-- 初始化基础数据 INSERT INTO category (id, parent_id, name, sort_no, status) VALUES (1, 0, '饮料', 1, 1), (2, 1, '瓶装水', 1, 1), (3, 0, '零食', 2, 1); INSERT INTO supplier (id, name, contact, phone, status) VALUES (1, '模拟供应商A', 'A同学', '00000000000', 1); INSERT INTO product (id, sku_code, name, category_id, supplier_id, unit, purchase_price, sale_price, status) VALUES (1, 'SKU-WATER-001', '模拟矿泉水550ml', 2, 1, '瓶', 1.10, 2.00, 1), (2, 'SKU-SNACK-001', '模拟薯片原味', 3, 1, '袋', 3.20, 5.50, 1); INSERT INTO product_barcode (product_id, barcode, is_primary, status) VALUES (1, '6900000000011', 1, 1), (1, '6900000000012', 0, 1), (2, '6900000000021', 1, 1); -- 初始库存:商品1入20瓶,商品2入10袋 INSERT INTO stock (product_id, quantity, locked_quantity, version) VALUES (1, 20, 0, 0), (2, 10, 0, 0); INSERT INTO stock_log (product_id, biz_type, biz_no, change_qty, before_qty, after_qty, operator_id) VALUES (1, 'PURCHASE_IN', 'INIT-001', 20, 0, 20, 1), (2, 'PURCHASE_IN', 'INIT-001', 10, 0, 10, 1);

这里商品 1 挂了两个条码,用来验证“一品多码”扫描是否正常。初始化库存时同步写一条PURCHASE_IN流水,保证库存汇总和流水从一开始就一致。如果只插stock不插stock_log,后面跑对账 SQL 会立刻暴露差异。日期和操作员字段可以先给默认值,正式环境从登录用户和系统时间取。测试数据里的条码用虚构号段,不要拿真实商品条码做批量测试。

3.3 索引和约束验证

表建好后不要急着导全量商品,先用几条查询看执行计划。商品名模糊查询、条码精确查询、销售日报按时间范围查询,是这个小系统里最常见的三类访问。条码查询必须走uk_barcode,商品名查询至少不要全表扫,销售日报依赖sale_order.create_time索引。

-- 条码扫描查询,应该命中 uk_barcode EXPLAIN SELECT p.id, p.name, p.sale_price FROM product_barcode b JOIN product p ON p.id = b.product_id WHERE b.barcode = '6900000000011' AND b.status = 1; -- 检查重复条码,正常应返回空 SELECT barcode, COUNT(*) AS cnt FROM product_barcode GROUP BY barcode HAVING cnt > 1; -- 检查库存为负的商品,正常应返回空 SELECT product_id, quantity FROM stock WHERE quantity < 0;

EXPLAIN里重点看type和key,条码查询出现ALL就说明唯一索引没命中,先检查字段类型和字符集是否一致。重复条码查询返回空不代表以后不会重复,唯一索引才是最后一道门。库存为负检查建议放在每日对账任务里,不要只靠人工点页面。参数上,barcode长度不要超过 64,太长会影响索引体积;quantity用INT足够小型门店使用,如果做批发再换DECIMAL或大整数。

4. 入库、销售与库存扣减:事务边界和并发控制怎么写

4.1 入库单先落库,再动库存

采购入库最常见的错误顺序是:页面先调“增加库存”,再保存入库单。中间如果网络断开或者保存单据失败,库存已经加上去了,单据却没留下,后面只能人工猜。正确顺序是先把采购单和明细落库,状态置为“草稿”或“待入库”;审核时在一个事务里做三件事:更新单据状态、增加库存、写库存流水。库存变化以审核动作为准,草稿单不碰库存。

@Transactional(rollbackFor = Exception.class) public void confirmPurchase(Long purchaseId, Long operatorId) { // 1. 锁定采购单,防止重复审核 PurchaseOrder order = purchaseOrderMapper.selectForUpdate(purchaseId); if (order == null || order.getStatus() != 0) { throw new BizException("采购单不存在或已审核"); } // 2. 按商品ID排序,统一锁顺序,降低死锁概率 List<PurchaseItem> items = purchaseItemMapper.listByOrderId(purchaseId); items.sort(Comparator.comparing(PurchaseItem::getProductId)); for (PurchaseItem item : items) { // 3. 增加库存,返回影响行数 int updated = stockMapper.increaseStock(item.getProductId(), item.getQuantity()); if (updated == 0) { throw new BizException("库存记录不存在,商品ID=" + item.getProductId()); } // 4. 查询变更后库存,写流水 Stock stock = stockMapper.selectByProductId(item.getProductId()); stockLogMapper.insertLog(item.getProductId(), "PURCHASE_IN", order.getOrderNo(), item.getQuantity(), stock.getQuantity() - item.getQuantity(), stock.getQuantity(), operatorId); } // 5. 单据状态改为已入库 purchaseOrderMapper.updateStatus(purchaseId, 1); }

这段代码的关键不是注解本身,而是事务内的顺序:锁单据、按商品排序、增加库存、写流水、改状态。selectForUpdate用来防止同一个采购单被两个操作员同时审核;items.sort是血泪经验,两个事务如果按不同顺序锁商品库存,容易互相等待。increaseStock的 SQL 可以写成UPDATE stock SET quantity = quantity + #{qty}, version = version + 1 WHERE product_id = #{productId},影响行数为 0 说明库存记录缺失,直接抛异常回滚。流水里的before_qty用变更后库存减本次数量算出来,必须和实际更新在同一事务内,否则并发下会记错。

4.2 销售扣库存:事务内三步与乐观锁

收银销售比入库更敏感,因为收银员就在顾客面前等结果。销售事务里我一般按“创建销售单、写销售明细、扣库存、写流水”的顺序走,所有商品按product_id排序后扣减。库存表带version字段时,可以用乐观锁:先查当前库存和版本号,再用条件更新quantity >= qty AND version = oldVersion。更新失败说明库存被其他收银台改过,重试一次或者提示“库存已变化,请重新扫码”。

@Transactional(rollbackFor = Exception.class) public SaleResult createSale(SaleCreateDTO dto, Long cashierId) { // 1. 生成销售单号,保存主单 String orderNo = "S" + System.currentTimeMillis(); SaleOrder order = new SaleOrder(); order.setOrderNo(orderNo); order.setPayType(dto.getPayType()); order.setCashierId(cashierId); order.setStatus(1); saleOrderMapper.insert(order); BigDecimal total = BigDecimal.ZERO; List<SaleItemDTO> items = dto.getItems(); items.sort(Comparator.comparing(SaleItemDTO::getProductId)); for (SaleItemDTO item : items) { // 2. 按条码查商品,必须校验上架状态 Product product = productMapper.selectByBarcode(item.getBarcode()); if (product == null || product.getStatus() != 1) { throw new BizException("商品不存在或已下架:" + item.getBarcode()); } // 3. 扣库存:带库存数量和版本号条件 Stock stock = stockMapper.selectByProductId(product.getId()); int updated = stockMapper.deduct(product.getId(), item.getQuantity(), stock.getVersion()); if (updated == 0) { throw new BizException("库存不足或已被其他收银台修改:" + product.getName()); } // 4. 写销售明细,保存价格快照 BigDecimal amount = product.getSalePrice().multiply(BigDecimal.valueOf(item.getQuantity())); saleOrderItemMapper.insert(order.getId(), product.getId(), item.getBarcode(), product.getName(), product.getSalePrice(), item.getQuantity(), amount); total = total.add(amount); // 5. 写库存流水 Stock after = stockMapper.selectByProductId(product.getId()); stockLogMapper.insertLog(product.getId(), "SALE_OUT", orderNo, -item.getQuantity(), after.getQuantity() + item.getQuantity(), after.getQuantity(), cashierId); } // 6. 回写主单金额 saleOrderMapper.updateAmount(order.getId(), total, total); return new SaleResult(orderNo, total); }

对应的扣减 SQL 要带两个条件,缺一不可:

UPDATE stock SET quantity = quantity - #{qty}, version = version + 1, update_time = NOW() WHERE product_id = #{productId} AND quantity >= #{qty} AND version = #{version};

quantity >= #{qty}防止库存扣成负数,version = #{version}防止两个收银台基于同一个旧版本同时扣减。影响行数为 0 时不要静默返回,直接抛业务异常让事务回滚。参数qty必须做正整数校验,条码查询要限制只查status=1的条码。金额计算用BigDecimal,不要用double;销售明细里的价格是扫描当时的快照,后面商品调价不影响这张单。收银台如果同时扫多个商品,按product_id排序再扣减,和入库保持同一套锁顺序。

4.3 退货和盘点怎么走反向流水

退货不要直接改原销售单金额,也不要把原销售明细删掉。常见做法是生成一张退货单,关联原订单号,库存加回来,流水写SALE_RETURN,原销售单状态改成“部分退货”或“已退货”。这样历史账还在,退货原因也能查。盘点则生成CHECK_ADJUST流水:盘点数量大于系统数量记正数,小于系统数量记负数,库存表和流水表在同一个事务里更新,盘点单保存盘点人和盘点时间。退货和盘点都不要绕过流水直接UPDATE stock,否则对账时会出现库存和流水对不上的黑匣子。

5. 避坑与排查:库存为负、条码重复和慢查询的五个现场

5.1 五个高频翻车现场

第一个现场:库存扣成负数。现象是收银小票已经打出来,库存表出现-1。原因通常是先SELECT查库存,再在 Java 里判断quantity >= qty,最后UPDATE stock SET quantity = quantity - qty,两条收银台并发时都读到了旧库存。解决是把判断放进更新条件:WHERE quantity >= #{qty},影响行数为 0 就回滚并提示库存不足。数据库层再加一个CHECK (quantity >= 0)约束更稳,但 MySQL 老版本对 CHECK 支持不完整,所以条件更新仍是主方案。

第二个现场:条码扫出两个商品。现象是同一个条码在收银台弹出两个候选商品,收银员随手点一个,月底统计串账。原因多半是product_barcode没有唯一索引,或者停用条码和启用条码共用了同一个值。解决是给barcode建唯一索引,停用条码时把status置 0 并同步改成一个不会再次启用的失效值;查询时只查启用条码。初始化数据时就要跑重复条码检查 SQL,不要等门店反馈。

第三个现场:金额差几分钱。现象是销售明细逐行相加和主单总额对不上,日报和支付流水差 0.01。原因是价格字段用了double,多次累加产生浮点误差。解决是建表时统一DECIMAL(10,2),Java 里用BigDecimal,乘法后按业务规则保留两位小数。优惠分摊也要落到明细上,不要只改主单总额,否则退货时无法按行退。

第四个现场:日报查询越来越慢。现象是开业前两周日报秒开,两个月后要等十几秒。原因常见于WHERE DATE(create_time) = '2026-01-01'这种写法,函数包住索引列导致全表扫;或者销售明细没有order_id索引。解决是把条件改成时间范围create_time >= '2026-01-01 00:00:00' AND create_time < '2026-01-02 00:00:00',并给sale_order.create_time、sale_order_item.order_id建索引。报表 SQL 上线前用EXPLAIN看一眼,别等数据量上来才补。

第五个现场:退货重复加库存。现象是同一个退货单被点了两次,库存多加一次。原因是退货接口没有幂等控制,页面重复提交或者网络重试都会再执行一遍。解决是给退货单号加唯一索引,服务层先查退货单状态,已完成的直接返回原结果;库存流水用biz_type + biz_no + product_id做业务唯一约束,重复写入直接失败回滚。这个坑在演示环境不容易出现,一到真实收银场景就会翻车。

5.2 排查顺序和对账 SQL

出现库存对不上,我一般按“先看流水、再看库存、最后看单据”的顺序排查。先拿商品 ID 查最近流水,看before_qty和after_qty能不能首尾相接;再用流水汇总和库存表对比,差异就是漏写流水或者手工改过库存;最后按biz_no反查采购单、销售单、退货单,确认业务动作是否真的发生。不要一上来就改库存表,手工改完没有流水,下一次对账更乱。

-- 库存汇总与流水汇总对账,差异不为0就要查 SELECT s.product_id, s.quantity AS stock_qty, COALESCE(SUM(l.change_qty), 0) AS log_qty, s.quantity - COALESCE(SUM(l.change_qty), 0) AS diff FROM stock s LEFT JOIN stock_log l ON l.product_id = s.product_id GROUP BY s.product_id, s.quantity HAVING diff <> 0; -- 查某个商品最近20条流水,检查前后数量是否连续 SELECT id, biz_type, biz_no, change_qty, before_qty, after_qty, create_time FROM stock_log WHERE product_id = 1 ORDER BY id DESC LIMIT 20;

第一条 SQL 是每天跑一次的对账底线,diff不为 0 就说明库存表和流水表已经分叉。第二条 SQL 用来人工追溯,重点看before_qty + change_qty = after_qty是否成立,以及相邻两条流水的after_qty和before_qty是否接得上。排查时还要看事务有没有回滚、有没有人直接在数据库里改库存。把这两条 SQL 放进定时任务,比在页面上加十个提示都管用。

6. 对账与报表进阶:用一条校验 SQL 抓住账实不符

库存对账不要等到月底才做,小型自选商场每天闭店后跑一次最合适。除了库存和流水对账,还可以加一条销售单金额校验:主单总额和明细汇总不一致的单据,大概率是并发回写或者优惠分摊出了问题。下面这条 SQL 可以直接放进日报任务,结果为空表示当天销售单金额自洽。

-- 销售主单金额与明细汇总校验 SELECT o.id, o.order_no, o.total_amount, COALESCE(SUM(i.amount), 0) AS item_amount, o.total_amount - COALESCE(SUM(i.amount), 0) AS diff FROM sale_order o LEFT JOIN sale_order_item i ON i.order_id = o.id WHERE o.create_time >= CURDATE() AND o.create_time < DATE_ADD(CURDATE(), INTERVAL 1 DAY) GROUP BY o.id, o.order_no, o.total_amount HAVING diff <> 0;

跑这条 SQL 时,total_amount是主单应付金额,item_amount是明细行金额汇总,diff不为 0 就要查当天有没有改价、抹零、退货或者事务回写失败。抹零属于正常业务,但要把抹零金额单独存字段,不要让它悄悄吃掉明细差。定时任务建议每天闭店后执行,结果写进一张对账日志表,连续两天有差异再告警。从那以后我每次接这类商品管理系统,都先跑一遍库存流水对账和销售金额对账,再谈页面和报表;这两个数对得上,系统才算真正落地。需要完整建表脚本和示例代码的,可以按资源入口获取,先拿建表脚本跑通,再对照服务层改库存扣减。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询