资讯动态

小型自选商场商品管理系统:库存快照、流水与并发扣减设计

发布时间:2026/10/27 19:14:39 来源:尧图企业网站定制
简介小型自选商场商品管理系统是一套面向数据库课程设计与零售管理入门实践的完整项目包适合高校计算机与信息管理相关专业学生及初级开发者围绕商品进货、销售、库存与供应商信息管理展开重点解决进货与售货记录、库存动态刷新、按月进货统计、日盘存与月盘存以及收银台清单输出等业务问题。资源包共189个文件约680KB包含60个SQL脚本、54个C#源码文件及24组resx资源文件并附数据库文件、配置与可执行程序便于直接查看表结构、业务逻辑和运行效果。已有1028人学习下载。读者可参考库存、售货、进货、供应商等表的设计思路理解进货/销售金额计算、库存联动更新、供应商查询和收银台收付款显示的实现方式形成从建表到业务查询的完整闭环也可据此完成课程设计、实验报告或二次开发。1. 从一次月底盘亏说起小型自选商场为什么需要一个独立的商品管理系统一家八百平米的自选商场三千个 SKU六台收银机日均八百单。之前用 Excel 记库存月底盘下来亏三千多块谁也说不清是漏扫、报损没记还是被顺手拿走。店长真正想要的不是一套能打通供应链的 ERP而是商品档案、库存流水、收银小票、日报这四样东西能互相对上。这套小型自选商场商品管理系统就围着这条底线做单体后端加一张商品主数据表库存单独存快照、流水只追加不改写前端只做收银、理货、店长三个界面。它适合拿来当数据库课程设计或毕设的完整底稿也适合真要给社区超市装一套的人。2. 需求拆解与技术选型三个角色、五个闭环与一张不能省的主数据表2.1 三个角色的真实动作决定了系统的边界很多同学做商品管理系统第一步就画一张大而全的功能树商品管理、库存管理、订单管理、会员管理、报表管理看上去很完整答辩时被问一句“收银员结账的时候后台到底动了几张表”就答不上来。我一般的做法是先坐收银台边上观察半天把真实动作记下来再反推表结构。收银员的动作只有六个扫码或手输条码、改数量、整单折扣需要权限、选支付方式、挂单与取单、退货。这里有个容易被忽略的点——改价。散称商品称重后金额是收银员手输的临时促销价也要临时改如果系统只允许店长改价高峰期就会堵在收银台。所以权限要设计成“折扣率上限”而不是“能不能改”超过 10% 才需要店长刷卡。理货员和库管负责进货入库、报损、盘点、查临期。他们的操作频率比收银低但对数据准确性的影响更大因为入库和盘点是唯一能让库存变大的入口。店长管三件事商品建档与调价、员工账号与权限、看日报和库存预警。他是唯一有权限物理意义上看全表的人所以操作日志必须写全谁在什么时候把某个商品进价从 4.2 改成 4.8要能查出来。把这三种角色串起来就是五个业务闭环后面的表结构全部从这五个闭环长出来建档 → 上架 → 扫码销售 → 扣库存 → 日结对账采购入库 → 库存增加 → 进价更新 → 应付累计盘点 → 差异计算 → 生成调整流水 → 库存回写退货 → 库存回库 → 生成红字流水 → 冲减当日销售额预警 → 补货建议 → 采购单 → 入库闭环之外的会员、优惠券、线上商城这份方案一律不做。毕设最容易翻车的地方不是功能少而是功能多到每个都半成品验收时连一条完整的链路都跑不通。2.2 技术选型为什么是单体加关系库而不是微服务和文档库选型这块我见过两种极端。一种是上来就 Spring Cloud 全家桶注册中心、网关、配置中心配了三个服务最后跑在一台 2 核 4G 的云主机上光启动就吃掉一半内存另一种是图省事商品和订单全塞进一个 JSON 字段后面想按分类统计销量写了三十行代码还跑不动。这套系统的选型理由很直白单体 Spring Boot 3 MyBatis-Plus MySQL 8.0前端 Vue 3 Element Plus收银端做成浏览器全屏页面。单店、单库、日单量八百微服务带来的服务发现、链路追踪、分布式事务没有一个是这个场景的痛点。真要做扩展把库存预警和日结这两块拆成独立的定时任务进程就已经够了没必要上注册中心。数据库必须用关系型。商品、订单、订单明细、库存流水之间是典型的多对多和强一致关系一次结账要同时写主单、明细、库存、流水这是本地事务MySQL 的 InnoDB 一行 BEGIN 就解决。报表要按分类、按供应商、按日期做多维聚合SQL 的 GROUP BY 比在应用层遍历十万条记录快一个数量级。库存这里有个关键取舍为什么不每次扫码都去 SUM 一遍流水因为流水表一年会长到接近两百万行收银高峰每秒三次扫码每次都做一次全表聚合数据库会先撑不住。所以库存要单独存一张快照表stock流水表stock_flow当账本用两者在同一个事务里写允许冗余但不允许不一致。这个“快照 账本”的结构是整套设计的地基后面所有并发问题都从这儿解释。2.3 数据规模估算与容量底线毕设答辩很爱问“你这套系统能撑多大数据量”答不上来会显得没做过容量思考。按一家店的实际经营数据估一遍心里就有底了表名单行大小估算日增行数一年行数说明product约 400B2约 3000只在建档和调价时写stock约 60B0约 3000与商品一对一只更新不新增stock_flow约 120B约 5000约 180 万只追加不 UPDATEsale_order约 200B约 800约 29 万主单sale_order_item约 180B约 4000约 146 万明细行数最多stock_batch约 100B约 300约 11 万只给生鲜、乳品等保质期商品用一年不到两百万行的规模单机 MySQL 完全够用sale_order_item和stock_flow两张表各建两个联合索引查询都能压到毫秒级。真正需要提前想的是归档策略销售明细保留两年两年以上的按biz_date迁到历史表主表只留最近数据。这件事不用在毕设里实现但答辩时能说出来说明你想过系统跑三年之后的样子。服务器参数上MySQL 8.0 InnoDB utf8mb4 字符集是标配innodb_buffer_pool_size给到物理内存的 50% 到 60%innodb_flush_log_at_trx_commit 1保证事务不丢transaction_isolation用默认的 REPEATABLE READ 就够。这几项写进部署说明验收时能对着配置文件逐条核对。提示不要为了“看起来高级”去调隔离级别或者关掉双一设置innodb_flush_log_at_trx_commit和sync_binlog收银数据丢一笔比系统慢一点严重得多。3. 数据库设计从商品主数据到库存流水十张表怎么切3.1 主键与编码策略为什么不能拿条码当主键新手最容易踩的坑是把商品条码设成主键。听起来很合理——条码唯一、天然有序、还能省一个字段。但真跑起来会连着遇到三个问题。第一散称商品没有条码。散装米面、生鲜称重收银员是先在电子秤上称好、贴一张店内码或者直接在收银台输入 PLU 码。这类商品的“条码”是店里自己生成的格式和 69 码完全不同混在一列里做唯一索引迟早撞车。第二条码会变。同一个商品换包装、换规格、供应商换批次条码跟着换。如果条码是主键改一次条码等于换一个商品历史订单全部指向错误对象。第三条码是字符串但不是所有条码都等长而且前导零会被某些前端框架当数字吞掉。0001234存成1234这种血泪经验做零售的都遇到过。所以最终采用三层编码idBIGINT 自增内部主键所有外键都关联它跟业务无关永不变product_code店内自编编码如SP000123用于报表和人工核对唯一barcode国际条码或店内码唯一但允许为 NULL散称商品可以留空这里用到一个 MySQL 的特性唯一索引允许多个 NULL 值。UNIQUE KEY uk_barcode (barcode)建上去之后一百个散称商品都能存 NULL不会互相冲突但真实的条码依然保证唯一。这个细节写进设计文档答辩时是个加分项。3.2 商品与库存三张表字段取舍和建表语句先看商品主数据。这张表的字段不是越多越好每多一个字段就多一处要维护的地方。真正必要的只有能唯一标识、能扫码查到、能算毛利、能判断是否在售。CREATE TABLE product ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT 内部主键所有外键关联此列, product_code VARCHAR(32) NOT NULL COMMENT 店内编码如 SP000123, barcode VARCHAR(32) DEFAULT NULL COMMENT 69码或店内码散称为NULL, name VARCHAR(128) NOT NULL COMMENT 商品名称, category_id BIGINT NOT NULL COMMENT 分类ID, supplier_id BIGINT DEFAULT NULL COMMENT 默认供应商, spec VARCHAR(64) DEFAULT NULL COMMENT 规格如 500ml/袋, unit VARCHAR(16) NOT NULL DEFAULT 个 COMMENT 销售单位, purchase_price DECIMAL(10,2) NOT NULL DEFAULT 0.00 COMMENT 最近进价入库时更新, sale_price DECIMAL(10,2) NOT NULL DEFAULT 0.00 COMMENT 当前售价, status TINYINT NOT NULL DEFAULT 1 COMMENT 1在售 0停售 2淘汰, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_product_code (product_code), UNIQUE KEY uk_barcode (barcode), KEY idx_category_status (category_id, status), KEY idx_name (name) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT商品主数据;几个字段的取舍说明一下。价格一律用DECIMAL(10,2)绝不用FLOAT或DOUBLE浮点数存 19.99 会变成 19.989999一千条明细累加能差出一块钱。status用整数而不是布尔是因为要区分“停售”暂时没货不上架和“淘汰”彻底不卖了但历史数据要留两个状态的处理逻辑不一样停售的商品在收银端扫码要提示“该商品暂停销售”淘汰的商品直接提示“商品不存在”。再看库存快照表。它和商品是一对一主键直接用product_id省一个自增列也省一次索引查找。CREATE TABLE stock ( product_id BIGINT NOT NULL COMMENT 与product.id一对一, quantity INT NOT NULL DEFAULT 0 COMMENT 当前可用库存, safety_stock INT NOT NULL DEFAULT 0 COMMENT 安全库存低于此值触发预警, max_stock INT NOT NULL DEFAULT 0 COMMENT 库存上限0表示不限, version INT NOT NULL DEFAULT 0 COMMENT 乐观锁版本号便于排查, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (product_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT库存快照与流水表配合使用;version这一列在实际扣减里其实用不上——条件更新本身就能保证原子性但留着他有两个好处一是排查问题时能看出这条记录被改过多少次二是将来要换成 MyBatis-Plus 的乐观锁插件不用改表结构。这种“现在不用但以后可能要”的字段我一般会留但只留一个绝不留一堆预留字段。流水表是整套系统的黑匣子任何库存变动都必须在这里留痕且只允许 INSERT不允许 UPDATE 和 DELETE。CREATE TABLE stock_flow ( id BIGINT NOT NULL AUTO_INCREMENT, product_id BIGINT NOT NULL, change_type TINYINT NOT NULL COMMENT 1采购入库 2销售出库 3退货入库 4报损 5盘点调整, change_qty INT NOT NULL COMMENT 带符号入库为正、出库为负, before_qty INT NOT NULL COMMENT 变动前库存, after_qty INT NOT NULL COMMENT 变动后库存, ref_no VARCHAR(40) DEFAULT NULL COMMENT 关联单号销售单号/采购单号/盘点单号, operator_id BIGINT NOT NULL COMMENT 操作人, remark VARCHAR(255) DEFAULT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_product_time (product_id, created_at), KEY idx_ref_no (ref_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT库存流水只追加;before_qty和after_qty两列看着冗余其实是排查问题的后悔药。库存对不上时直接按时间顺序把某商品的所有流水拉出来一眼就能定位是哪一笔出了问题不用再去重算。ref_no上建索引是为了“按销售单号反查库存变动”这种售后场景。3.3 订单两张表与库存扣减的事务边界订单分主单和明细主单存“这一单整体是什么情况”明细存“买了哪几样”。这里有个设计原则明细表必须做快照把下单那一刻的商品名、条码、售价、成本价全部冗余进去。CREATE TABLE sale_order ( id BIGINT NOT NULL AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL COMMENT yyyyMMdd收银机号4位流水, cashier_id BIGINT NOT NULL, total_amount DECIMAL(10,2) NOT NULL DEFAULT 0.00 COMMENT 应收金额, discount_amount DECIMAL(10,2) NOT NULL DEFAULT 0.00 COMMENT 整单折扣, pay_amount DECIMAL(10,2) NOT NULL DEFAULT 0.00 COMMENT 实收金额, pay_type TINYINT NOT NULL COMMENT 1现金 2扫码 3银行卡 4混合, status TINYINT NOT NULL DEFAULT 1 COMMENT 1已完成 2已退 3挂单, biz_date DATE NOT NULL COMMENT 业务日期跨零点归属前一天, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_order_no (order_no), KEY idx_biz_date_status (biz_date, status), KEY idx_cashier_time (cashier_id, created_at) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT销售主单; CREATE TABLE sale_order_item ( id BIGINT NOT NULL AUTO_INCREMENT, order_id BIGINT NOT NULL, product_id BIGINT NOT NULL, barcode VARCHAR(32) DEFAULT NULL COMMENT 下单时条码快照, product_name VARCHAR(128) NOT NULL COMMENT 下单时名称快照, sale_price DECIMAL(10,2) NOT NULL COMMENT 成交单价快照, cost_price DECIMAL(10,2) NOT NULL COMMENT 成本单价快照用于毛利, quantity INT NOT NULL, subtotal DECIMAL(10,2) NOT NULL COMMENT 服务端计算不采信前端, PRIMARY KEY (id), KEY idx_order_id (order_id), KEY idx_product_id (product_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT销售明细;biz_date单独存一列而不是用created_at现算是为了处理零点交接。晚上十一点五十开始的单结完账可能已经过了零点按营业日它属于前一天。收银端在开班时设置营业日所有单子统一写这个日期日结才不会漏。真正的硬骨头在扣减。最朴素的写法是先查再改// 错误示范先查后改并发下必然超卖 Stock stock stockMapper.selectById(productId); if (stock.getQuantity() qty) { stock.setQuantity(stock.getQuantity() - qty); stockMapper.updateById(stock); }两个收银台同时卖最后三瓶水都读到 3都判断通过都扣 3库存变成 -3。这个场景在两家店同时做促销的时候百分百复现。正确做法是把判断交给数据库用一条带条件的 UPDATEMapper public interface StockMapper { /** * 条件扣减只有库存足够时才更新返回 0 表示库存不足 * 判断和更新在一条 SQL 里完成由 InnoDB 的行锁保证原子性 */ Update(UPDATE stock SET quantity quantity - #{qty}, version version 1 WHERE product_id #{productId} AND quantity #{qty}) int deduct(Param(productId) Long productId, Param(qty) int qty); Select(SELECT quantity FROM stock WHERE product_id #{productId}) int selectQuantity(Param(productId) Long productId); }调用方根据返回的影响行数判断成败0 行就是库存不足直接抛业务异常让整个事务回滚Service public class CheckoutService { Transactional(rollbackFor Exception.class) public String checkout(CheckoutDTO dto, Long cashierId) { String orderNo orderNoGenerator.next(dto.getPosNo()); // 第一次写主单 orderMapper.insert(buildOrder(orderNo, dto, cashierId)); for (CheckoutItem item : dto.getItems()) { // 第二次写扣库存条件更新失败即回滚整单 int affected stockMapper.deduct(item.getProductId(), item.getQuantity()); if (affected 0) { throw new BizException(库存不足 item.getProductName()); } // 第三次写库存流水after 从库里读before after qty int after stockMapper.selectQuantity(item.getProductId()); flowMapper.insert(buildFlow(item, after item.getQuantity(), after, orderNo, cashierId)); // 第四次写销售明细 itemMapper.insert(buildItem(orderNo, item)); } return orderNo; } }一段结账事务里其实写了六次库主单、每次扣库存、每次写流水、每次写明细再加上可能的挂单删除和积分变更。六次写入必须在同一个Transactional里任何一次失败全部回滚。rollbackFor Exception.class这个参数别省默认只回滚RuntimeException抛受检异常时事务不回滚这个坑我在第一次上线时就踩过。3.4 索引设计让收银扫码走唯一索引而不是全表扫描索引不是建得越多越好每多一个索引写入就多一次 B 树维护。这套系统里真正高频的查询只有四条索引围着它们建第一条扫码查商品SELECT * FROM product WHERE barcode ?。走uk_barcode等值查询EXPLAIN里 type 应该是constrows 为 1。如果显示ALL或者ref说明索引没命中八成是查询时对字段做了函数处理比如WHERE TRIM(barcode) ?函数一上索引就失效。第二条按分类列在售商品WHERE category_id ? AND status 1。走联合索引idx_category_status注意字段顺序不能反。status只有三个值选择性极低放在联合索引第一位等于没建category_id选择性高必须放前面。这条规律叫最左前缀答辩常问。第三条查某天的销售流水WHERE biz_date ? AND status 1。走idx_biz_date_status日结任务每天扫一次扫的是索引区间不是全表。第四条按商品统计销量WHERE product_id ? AND created_at BETWEEN ? AND ?。这条在stock_flow上走idx_product_time在sale_order_item上目前只有idx_product_id做月度销量榜时会稍慢但月度报表是离线任务几秒钟的延迟可以接受不值得为它再加一个联合索引。验证索引有没有生效别靠感觉跑EXPLAIN看三列type要出现const、ref或range出现ALL就是全表扫key要显示实际用的索引名Extra里如果出现Using filesort或Using temporary说明排序或分组没走索引数据量一大就会明显卡顿。这三列抄进验收文档比写十页设计说明更有说服力。4. 后端模块实现商品、收银、预警、对账四条链路的落地4.1 商品档案条码规范化与唯一性冲突的处理商品建档看着是最简单的增删改查实际上手会发现问题全在边界上。前端传过来的条码可能带空格、可能是全角字符、可能前导零被吃掉还可能扫到一半重复提交。第一道防线放在入库前统一做规范化public class BarcodeNormalizer { /** * 条码规范化去掉空白与不可见字符全角转半角 * 注意不做去前导零0001234 和 1234 是两个不同的店内码 */ public static String normalize(String raw) { if (raw null) { return null; } String s raw.trim() .replace(\u3000, ) // 全角空格 .replace(\r, ) .replace(\n, ); if (s.isEmpty()) { return null; } // 全角数字转半角收银员手输时容易切换输入法 StringBuilder sb new StringBuilder(s.length()); for (char c : s.toCharArray()) { if (c \uFF10 c \uFF19) { sb.append((char) (c - \uFF10 0)); } else { sb.append(c); } } return sb.toString(); } }第二道防线是数据库的唯一索引。这里不要用“先查再插”的方式判断重复同样是并发问题两个理货员同时录入同一个新品都查不到都插入最后靠唯一索引报错。正确做法是直接插入捕获DuplicateKeyException再根据异常里带的索引名判断是product_code冲突还是barcode冲突返回对应的提示。try { productMapper.insert(product); } catch (DuplicateKeyException e) { String msg e.getMessage() null ? : e.getMessage(); if (msg.contains(uk_barcode)) { throw new BizException(条码已被商品【 findNameByBarcode(product.getBarcode()) 】占用); } throw new BizException(商品编码重复请更换); }第三点是删除。商品一律不物理删除只改status。原因很简单历史订单明细里存着product_id商品真删了报表里就会出现一堆孤儿数据。要做“彻底删除”先检查该商品有没有关联的流水没有流水才允许真删有流水只允许停售。4.2 收银结算一次结账里到底动了哪几张表收银是整套系统里唯一有强实时要求的模块高峰期每秒三次扫码任何一次超过 200 毫秒的操作都会被收银员感知到。所以这条链路的设计原则是能在一次事务里写完的绝不拆成两次能不查的绝不查。具体流程拆成五步前端把购物车整单提交包含每行的productId、quantity、前端算的subtotal服务端按productId批量查出商品当前售价逐行重算subtotal和前端传的值比对不一致以服务端为准并记一条告警日志整单折扣按比例分摊到每行只在最后一行做尾差修正保证明细之和等于整单金额生成单号写主单循环扣库存、写流水、写明细单号生成也有讲究。不能用数据库自增当单号因为挂单、退单会占号收银员看到的单号会跳。也不能用 UUID太长且无序索引会碎片化。通常是yyyyMMdd 收银机号(2位) 当日流水(4位)收银机号保证六台机器不撞流水在本地内存里自增每天开班清零机器重启时从库里查当天最大号回填。金额计算全部用BigDecimal并且明确规定舍入时机单价保留两位折扣率保留四位明细小计按单价乘数量再保留两位整单金额等于各行小计之和不再二次舍入。这套规则写进接口文档前端和后端都用同一套才不会出现小票和系统差一分钱的情况。退货走的是反向流程但不是把原单的库存加回去那么简单。正确的做法是生成一张status 2的销售单明细里数量为负库存流水也写负数这样日结时正负相抵报表口径不用特殊处理。原单不做任何修改保留完整审计链。4.3 库存预警与临期批次两个定时任务的写法预警任务每天开门前跑一次把低于安全库存的商品拉出来生成补货建议店长上班第一眼就能看到。-- 库存预警在售商品中库存低于安全库存的按紧缺程度排序 SELECT p.id, p.product_code, p.name, s.quantity, s.safety_stock, s.max_stock, (s.max_stock - s.quantity) AS suggest_qty FROM stock s JOIN product p ON p.id s.product_id WHERE p.status 1 AND s.safety_stock 0 AND s.quantity s.safety_stock ORDER BY (s.quantity * 1.0 / s.safety_stock) ASC, s.quantity ASC;排序用“当前库存除以安全库存”的比例比单纯按数量排更合理。同样差 10 件安全库存 100 的商品还剩 5 件已经很危险安全库存 15 的商品还剩 5 件只是略微偏低比例能把这个差别体现出来。suggest_qty建议补到上限上限为 0 表示不限前端要做兜底提示不能让补货建议变成负数。临期任务只针对保质期商品用批次表stock_batch管理字段包括product_id、batch_no、production_date、expire_date、quantity。销售出库按先进先出扣批次账面上先扣stock再按expire_date升序扣批次批次扣减同样用条件更新。Scheduled(cron 0 30 7 * * ?) public void expireAlert() { // 临期阈值乳品 3 天生鲜 1 天其他 15 天按分类配置 LocalDate today LocalDate.now(); ListStockBatch list batchMapper.selectExpiring(today, today.plusDays(15)); for (StockBatch b : list) { int days (int) ChronoUnit.DAYS.between(today, b.getExpireDate()); if (days thresholdOf(b.getCategoryId())) { alertService.push(b, days); // 写入预警表前端轮询展示 } } }这两个任务的共同坑是幂等。任务重启、手动触发、定时重叠都可能让同一天跑两遍预警表里就会出现两条重复记录。解决办法是预警表建UNIQUE KEY uk_product_date (product_id, alert_date, alert_type)插入用INSERT IGNORE或者ON DUPLICATE KEY UPDATE跑一百遍结果都一样。4.4 日结与对账把聚合口径钉死在一个地方日结是整套系统里最容易出争议的部分。收银员说今天卖了八千店长看报表是七千九差的这一百块能吵一下午。根因通常是口径不统一有人按created_at算有人按biz_date算有人统计已完成的单有人把挂单也算进去了有人用应收有人用实收。解决办法是把口径写进一条 SQL所有报表都从这张日结表读不允许各页面自己写聚合。INSERT INTO daily_report (biz_date, order_count, sale_amount, cost_amount, gross_amount) SELECT o.biz_date, COUNT(DISTINCT o.id) AS order_count, SUM(o.pay_amount) AS sale_amount, SUM(i.cost_price * i.quantity) AS cost_amount, SUM(o.pay_amount) - SUM(i.cost_price * i.quantity) AS gross_amount FROM sale_order o JOIN sale_order_item i ON i.order_id o.id WHERE o.biz_date #{bizDate} AND o.status 1 GROUP BY o.biz_date ON DUPLICATE KEY UPDATE order_count VALUES(order_count), sale_amount VALUES(sale_amount), cost_amount VALUES(cost_amount), gross_amount VALUES(gross_amount);三个参数要说明清楚。status 1排除挂单和已退单退单在明细里是负数如果退单本身也是status 2那它根本不会进这个聚合所以要保证退单生成时status是 1、明细数量为负这一点在前面 4.2 已经定死了。pay_amount用实收不用应收因为现金抹零是真实发生的用应收会虚高。cost_price来自明细快照不是商品的当前进价否则商品一调价上个月的毛利全变了。ON DUPLICATE KEY UPDATE保证重跑安全日结任务补跑、重跑都不会产生重复行。表上建UNIQUE KEY uk_biz_date (biz_date)这条语句才有意义。5. 避坑与排查这套系统上线后最容易翻车的五个点5.1 扫码枪扫得快报商品不存在现象收银高峰期连续扫码偶尔弹“商品不存在”重扫一次又好了。原因通常有两个。一是前端把扫码当成键盘输入每次keydown回车就发请求上一次请求还没回来就把输入框清空了请求乱序到达后发的先回前面的结果被覆盖。二是条码脏数据扫码枪末尾可能带回车符或者商品建档时条码前后带了空格WHERE barcode ?用等值比较查不到。解决前端做请求队列同一个输入框的扫码请求串行发送返回后再允许下一次输入服务端入库和查询两个入口都调用同一个normalize方法把空白和全角字符统一掉barcode列定长不超过 32超长的直接拒收。这三条做完扫码报错的概率能降到接近零。5.2 库存变成负数现象月底盘点发现某商品stock.quantity -3。原因基本可以锁定为“先查后改”。两个收银台并发扣同一件商品都读到库存 5都判断 5 3 通过各扣 3最终变成 -1。另一种可能是扣减 SQL 漏了AND quantity #{qty}这个条件或者代码里捕获了异常但没回滚事务。解决扣减一律用单条条件 UPDATE判断结果只看影响行数Transactional显式写上rollbackFor Exception.class上线前跑一遍并发测试用两个线程各扣 5 次看最终库存是不是刚好减了 10任何一次出现负数就是没修干净。对必须严格串行的场景比如盘点调整用SELECT ... FOR UPDATE锁住那一行但别在收银链路上用会拖慢并发。5.3 金額差一分钱现象小票合计 19.99日报汇总 20.00怎么查都对不上。原因有三种用了FLOAT或DOUBLE存金额前端算完小计传给后端后端直接采信舍入时机不一致有人按明细行四舍五入再求和有人先求和再舍入两种算法差一分钱很正常。解决金额列全部DECIMAL(10,2)Java 侧全部BigDecimal禁止用double参与任何金额运算服务端重算subtotal前端传的值只用于比对不一致记日志并以服务端为准舍入规则统一为“明细行按setScale(2, RoundingMode.HALF_UP)整单金额等于各行小计之和不再二次舍入”。规则写进接口文档前端照着实现。5.4 商品改名以后历史订单跟着变现象上个月的销售明细里某个商品的名字变成了新名字或者显示为空白。原因明细表只存了product_id查询时 JOIN 商品表取名字。商品一旦改名或下架历史单据全部跟着变如果商品被物理删除JOIN 出来就是空。解决明细表冗余product_name、barcode、sale_price、cost_price四个快照字段查询历史单据时不 JOIN 商品表商品禁止物理删除只改status。这条做与不做在答辩现场被追问“历史数据可追溯性”时高下立判。5.5 盘点期间照常收银盘完库存全乱现象盘点单提交后某些商品的库存比实际少了十几件。原因盘点开始时把当时的库存记为账面数盘点过程中收银继续扣库存最后用“实盘数”直接覆盖stock.quantity把这个期间卖掉的量一起冲掉了。解决盘点单必须记录三个数——期初账面数book_qty、盘点期间的变动合计period_change从stock_flow按时间区间汇总、实盘数check_qty。调整量等于check_qty - (book_qty period_change)而不是check_qty - book_qty。数据量大或者差异敏感的品类干脆在盘点时段锁定该分类商品收银端扫码提示“该商品正在盘点”代价是几分钟不能卖换来的是一本清楚的账。6. 验收与答辩用一条对账 SQL 和一份清单把系统讲清楚验收这件事我的习惯是先把最容易崩的地方测一遍而不是从登录页开始一个个点。商品管理系统里最容易崩的是库存一致性所以验收第一天就跑对账-- 库存快照与流水期末余额比对结果集必须为空 SELECT s.product_id, s.quantity AS snapshot_qty, IFNULL(SUM(f.change_qty), 0) AS flow_sum FROM stock s LEFT JOIN stock_flow f ON f.product_id s.product_id GROUP BY s.product_id, s.quantity HAVING s.quantity IFNULL(SUM(f.change_qty), 0);这条 SQL 的含义是任何商品的当前库存必须等于它所有流水的变动量之和。跑出来只要有一行就说明某次操作绕过了流水表直接改了库存或者事务中途失败留下了半截数据。这个问题不解决后面所有报表都是错的验收也没必要往下做。跑通对账之后按下面这份清单逐项验收每一项都要有明确的操作方法和通过标准不能写“功能正常”这种没法判断的话序号验收项操作方法通过标准1并发送货两个线程同时对库存 10 的商品各下单 6 件一单成功一单失败库存为 4无负数2金额精度下单 3 件单价 6.666 的商品小票金额 20.00与日结一致3条码脏数据建档时条码前后加空格、全角数字保存后查询能命中无重复记录4历史快照改商品名后再查上月订单明细显示下单时的旧名称5退单冲减完成一单后整单退货库存回库流水有负数记录日结金额为 06日结重跑同一天连续触发三次日结任务daily_report只有一行数值不变7盘点差异盘点期间产生 5 件销售后提交实盘数调整量 实盘 - 期初 - 期间变动8索引命中对四条核心查询跑EXPLAINtype 不为 ALLExtra 无 filesort答辩讲解的顺序也有讲究。很多人上来就讲技术栈讲用了什么框架、什么设计模式评委听三句就走神了。我的建议是先讲一个具体场景拿一瓶矿泉水从它在系统里建档开始讲到被扫码、扣库存、写流水、进日结一条链路走完中间自然带出表结构、索引、事务和并发控制。评委听到“这一瓶水在数据库里留下了四条记录”比听十页架构图更有画面感。讲完链路再讲取舍。为什么用单体不用微服务为什么库存要单独存快照为什么明细要冗余商品名为什么条码不能当主键。每一个“为什么”背后都要有一个具体的坑比如“如果明细不冗余商品一改名历史订单就变了”这种回答比背概念有分量得多。最后展示验收数据把那条对账 SQL 的结果、并发测试的日志、EXPLAIN的执行计划三样东西投出来基本就没什么可追问的了。从那以后我每次交方案前都会先把这条库存与流水的对账 SQL 跑一遍跑不通就不往下讲因为后面讲得再漂亮数据对不上就是白讲。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑