ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

小型超市后台管理系统设计:数据库表、库存扣减与进销存实战

小型超市后台管理系统设计:数据库表、库存扣减与进销存实战 简介一份面向小型超市后台管理场景的Java Web项目资源适合基于SSM框架做课程设计或毕业设计的开发者参考。项目聚焦订单管理与管理员权限控制并延伸出库存监控、用户管理、数据统计、安全审计等模块整体展示了一个后台管理系统从需求到功能落地的常见结构。技术路线上以SSM框架为主线涵盖Spring的依赖注入与事务管理、控制层的请求分发、持久层的数据库操作对理解Java Web分层开发很有帮助。资源包采用ZIP压缩格式大小约29.08MB文件数量和类型明细未在页面展示具体内容需下载后查看。已有384人学习下载说明该主题在中小型管理系统的教学与实践中具备一定参考价值参考其模块划分、接口设计和权限控制思路可快速搭建或二次开发自己的超市信息管理系统原型。1. 小型超市管理后台Excel 管不住货架之后该上什么系统一家三五个收银台的小型超市最怕的往往不是客流而是月底对账时库存账面和货架实际数量对不上。小店用 Excel 管商品、管进货、管库存起初没毛病一旦商品超过几百个、进货批次开始变多Excel 里的行数就变成了灾难改一个价格要翻半天表盘点差异根本追不到是哪一笔出入库造成的。小型超市信息管理系统后台就是替这几百个 SKU 把商品档案、进货入库、收银流水、库存台账和员工操作记录统一收进一套数据库里通过一个后台操作界面完成日常管理。文章后面会直接给你一套能落地的表设计和核心代码附带我踩过的坑新手按步骤搭熟手可以拿着参数和边界条件去对照自己的系统。2. 先把骨架搭对后台技术选型与数据库表设计2.1 小型超市场景的技术选型单体服务就够了小型超市信息管理系统后台要支撑的并发量很有限收银高峰期可能也就十几个操作员同时操作日常管理更是只有店长和采购员在用。这个量级下常见做法是直接用单体应用Spring Boot 或 Django 这类框架单包部署数据库用 MySQL。我一般建议如果团队里 Java 熟练就选 Spring Boot 搭配 MyBatis-Plus如果只是想快速上线、后续维护人不是专职后端用 Python Django 配 Django Admin 甚至能省掉一半后台页面的开发量。维度Spring Boot MyBatis-PlusDjango Admin上手成本需要熟悉 Java 和 Maven熟悉 Python 即可自带后台后台界面需要单独开发或引入前端模板自带的 Admin 可直接管理数据适合场景有专职开发、后续要扩展接口小店自用、开发人手少长线维护生态全招人容易小众但稳定运维简单对于小型超市后台不要上微服务不要引入 Redis 做缓存除非你已经明确遇到性能瓶颈。小店场景的核心需求是数据一致性和操作可追溯单库单服务就是最稳的边界。前端部分如果做的是给店长和收银员用的后台管理页面建议 Vue 加一个现成的后台模板或者干脆用服务端渲染的 Thymeleaf省掉前后端联调环节。2.2 先建五张表商品、库存、库存流水、进货单、订单数据库表的数量不需要多但每张表的关键字段必须一次设计到位。我接过的几个小店后台项目里最怕的是后期在已上线的表上反复加字段导致代码里到处是判空逻辑。开局先落五张表商品表 goods、库存表 inventory、库存流水表 stock_log、进货单表 purchase_order、订单表 orders。商品表管档案库存表管当前数量库存流水表管每一次变动来源进货单和订单管业务单据。少量冗余字段能换查询便利但核心数量只允许在库存表上更新。-- 商品表档案信息 CREATE TABLE goods ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT 主键, goods_no VARCHAR(32) NOT NULL UNIQUE COMMENT 商品编码手工录入或条码生成, barcode VARCHAR(64) DEFAULT COMMENT 条码, category VARCHAR(32) NOT NULL DEFAULT 其他 COMMENT 分类, name VARCHAR(128) NOT NULL COMMENT 商品名称, spec VARCHAR(64) DEFAULT COMMENT 规格如 500ml/瓶, unit VARCHAR(16) NOT NULL DEFAULT 件 COMMENT 单位, purchase_price DECIMAL(10,2) NOT NULL DEFAULT 0.00 COMMENT 进货价, retail_price DECIMAL(10,2) NOT NULL DEFAULT 0.00 COMMENT 零售价, member_price DECIMAL(10,2) NOT NULL DEFAULT 0.00 COMMENT 会员价, low_stock INTEGER NOT NULL DEFAULT 5 COMMENT 库存预警阈值, status TINYINT NOT NULL DEFAULT 1 COMMENT 1上架 0下架, created_at DATETIME NOT NULL ); -- 库存表一个商品一条记录 CREATE TABLE inventory ( id BIGINT PRIMARY KEY AUTO_INCREMENT, goods_id BIGINT NOT NULL UNIQUE COMMENT 关联 goods.id, quantity INTEGER NOT NULL DEFAULT 0 COMMENT 当前在库数量, version INT NOT NULL DEFAULT 0 COMMENT 乐观锁版本号 ); -- 库存流水表记录每一笔出入库 CREATE TABLE stock_log ( id BIGINT PRIMARY KEY AUTO_INCREMENT, goods_id BIGINT NOT NULL, change_quantity INT NOT NULL COMMENT 正数入库负数出库, biz_type VARCHAR(32) NOT NULL COMMENT PURCHASE/SALE/RETURN/CHECK, biz_no VARCHAR(64) NOT NULL COMMENT 关联单号如进货单号或订单号, before_quantity INT NOT NULL, after_quantity INT NOT NULL, remark VARCHAR(255) DEFAULT , operator_id BIGINT NOT NULL COMMENT 操作人, created_at DATETIME NOT NULL );商品表上的 goods_no 一定要做唯一约束这是后续所有单据关联的根基不要依赖 id 去对外暴露。库存表单独拆出来而不是把数量字段放进商品表原因在于库存是高频更新字段商品档案是低频更新字段放在一张表里会让更新操作锁住整行也会让历史流水查询被迫回看商品价格变化。stock_log 里的 before_quantity 和 after_quantity 是为了还原任意时间点的库存快照后面做盘点差异排查时全靠这两个字段这个设计直接决定复盘效率。库存数量建议用整数除非你卖的商品按斤称。如果确实有散称商品单独把计量方式做成 weight 类型不要把 DECIMAL 用在所有商品上否则后续库存汇总和盘点的运算都会跟着踩坑。进货价、零售价、会员价用 DECIMAL(10,2)禁止用 FLOAT 或 DOUBLE后面对账环节会单独说这个坑。3. 把核心功能逐个落地商品、库存、进货、收银流水3.1 商品档案条码、规格和三个价格怎么维护商品档案是后台系统里操作频率最高的模块新店开局要把几百个商品录入日常又不断有新品进来。商品表设计里要有条码扫码枪录入时直接定位同时保留手工搜索入口给没有条码的散装商品。一个容易忽略的点是商品规格同一种饮料有 500ml 和 2L 两个规格必须是两条商品记录不能在一个商品下做规格嵌套否则库存和价格都理不清。新增商品时后台要做三项校验goods_no 唯一、零售价不能低于进货价、必填字段不能为空。我习惯把 low_stock 预警阈值设成默认值 5但饮料、粮油这类整箱进整箱卖的商品阈值应该按箱数而不是瓶数来设所以新增和编辑商品的时候阈值字段要允许按商品单独调整。上下架状态也放在商品表里下架的商品不允许出现在收银端但后台仍然可查询历史记录。3.2 库存台账数量更新必须走乐观锁库存表的更新是后台并发最集中的环节进货入库、收银出库、退换货都在改 quantity。如果直接用UPDATE inventory SET quantity quantity - 1 WHERE goods_id ?在两个人同时操作同一个商品时会出现超卖或者负数库存小店收银高峰期两个收银台同时扫到同一款饮料是真实会发生的事。解决做法是拿 version 字段做乐观锁更新时带上查出来的版本号更新失败就重试一次。public boolean deductStock(Long goodsId, int count) { for (int retry 0; retry 3; retry) { // 先查当前库存和版本号 Inventory inv inventoryMapper.selectByGoodsId(goodsId); if (inv.getQuantity() count) { throw new BusinessException(库存不足); } // 带版本号更新影响行数为 0 说明期间已有其他人改过 int rows inventoryMapper.deductWithVersion( goodsId, count, inv.getVersion()); if (rows 1) { // 更新成功后写流水 stockLogMapper.insert(...); return true; } } throw new BusinessException(操作冲突请重试); }UPDATE inventory SET quantity quantity - #{count}, version version 1 WHERE goods_id #{goodsId} AND version #{version}这段逻辑里有两个参数细节。第一重试次数设 3 次如果小店现场并发冲突到了需要超过 3 次重试才能成功的频率说明业务设计有问题要么收银端扣库存太频繁要么该考虑单独的交易库存服务。第二stock_log 的插入必须和库存更新放在同一个事务里否则会出现库存数量变了但没有流水月底盘点的差异就无从追踪。扣减库存用正数 count 传入日志里的 change_quantity 记成负数保持流水表里“正入负出”的规则。3.3 进货入库单头加明细数量要对上进货单是小型超市后台最吃流程的业务。采购员从供应商拿货到店清点之后在后台录入进货单一张单可能有几十个商品每个商品数量各不相同。进货单设计成主表加明细表主表留存供应商信息、进货日期、总金额、操作人明细表记录每个商品的数量、进货价、生产日期因为饮料这类有保质期的商品需要用到批次信息但这个阶段不必做完整的批次库存把生产日期记录在进货明细里就够日常管理用。Transactional public void createPurchaseOrder(PurchaseOrderDTO dto) { // 1. 保存进货单主表生成进货单号 // 单号格式PO yyyyMMdd 3位流水号 String orderNo generatePurchaseOrderNo(); purchaseOrderMapper.insertMain(orderNo, dto.getSupplierId(), dto.getTotalAmount(), getCurrentUserId()); // 2. 逐条保存明细并更新库存 for (PurchaseItemDTO item : dto.getItems()) { purchaseOrderMapper.insertDetail(orderNo, item.getGoodsId(), item.getQuantity(), item.getPurchasePrice()); // 更新库存库存增加写进货类型的流水 increaseStockWithLog(item.getGoodsId(), item.getQuantity(), PURCHASE, orderNo); } }这里的事务注解是必须的因为主表插入和明细插入以及库存更新要保证原子性。进货单号不要用数据库自增 id 去拼上线之后会经常出现在进货单列表和跟供应商对账时用PO 日期 序号的格式可读性强得多也方便按时间区间查单。进货价的取值要小心进货明细里存当前这次的实际进货价而不是去读取商品档案里的 purchase_price因为供应商调价是常态单次进货价只对本次单据有效改档案价格不应该影响历史单据。3.4 收银流水后台看的是整单汇总而不是商品级明细实时推送小型超市的收银是高频操作每扫一件商品就往后端写一条明细会让数据库压力集中在订单明细表上而且后台管理界面根本不需要实时看到每一笔扫描动作。常见的做法是收银端完成整单结算后一次性写入订单主表和订单明细表同时异步扣减库存。这个设计意味着后台的订单查询、销售统计都以订单表为基准商品明细表只在看单品销售排行时才会被高频访问。-- 订单主表 CREATE TABLE orders ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(64) NOT NULL UNIQUE COMMENT 收银小票号, cashier_id BIGINT NOT NULL COMMENT 收银员, total_amount DECIMAL(10,2) NOT NULL COMMENT 应收金额, discount_amount DECIMAL(10,2) NOT NULL DEFAULT 0.00 COMMENT 优惠金额, actual_amount DECIMAL(10,2) NOT NULL COMMENT 实收金额, payment_type TINYINT NOT NULL COMMENT 1现金 2微信 3支付宝 4银行卡, created_at DATETIME NOT NULL ); -- 订单明细表 CREATE TABLE order_item ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(64) NOT NULL, goods_id BIGINT NOT NULL, goods_name VARCHAR(128) NOT NULL COMMENT 冗余商品名防止商品改名影响统计, quantity INT NOT NULL, sale_price DECIMAL(10,2) NOT NULL COMMENT 成交单价, cost_price DECIMAL(10,2) NOT NULL COMMENT 冗余进货成本用于毛利计算 );order_item 里的 goods_name 做冗余商品改名后历史小票依然显示当时的名称。cost_price 冗余的是当时商品档案里的进货价这个字段对毛利计算非常重要结算时的进货成本可能和现在档案里的进货价不一样不做冗余的话月底算毛利时所有历史订单都会变成按最新成本计算结果完全失真。同一商品不同批次进货价不同的问题在这个阶段先不做加权平均小店通常用移动加权或者简单的最新成本能保证月度毛利趋势稳定即可。3.5 供应商和门店设置基础资料不能省供应商表虽然简单但要在后台系统上线第一天建好否则进货单里供应商只能手填文本后续对账查数会非常痛苦。供应商表至少要有供应商编码、名称、联系人、电话、应付账款余额这五个字段。门店的信息如果只有一个店建一条门店记录即可但门店字段最好保留收银端以后每多一个分店订单和库存都要按门店隔离。我见过某连锁便利店项目前期没做门店字段后期加了分店之后只能回填历史数据还重新设计了所有统计 SQL代价远高于一开始多建一个 shop_id。4. 后台管理的一层保护壳员工账号与操作日志4.1 员工账号按角色分权店长、收银、采购三类账号小型超市的后台系统直接面向店员权限划分不需要做到很细的菜单级权限按角色分配是最稳妥的做法。常见的做法是分成三个角色店长能看所有报表、能做盘点调整和价格修改收银员只能查看自己的收银流水和商品基础信息采购员能维护供应商、录入进货单但不能改零售价。权限模型用最简单的 RBAC三张表员工账号表、角色表、账号角色关系表。public class Employee { private Long id; private String username; private String passwordHash; // BCrypt 加密后的密码 private String realName; private Integer roleType; // 1店长 2收银 3采购 private Integer status; // 1正常 0禁用 }注意密码不能存明文用 BCrypt 加密Spring Security 或者 Shiro 都能直接支持。小型超市的员工流动率高收银员离职后账号必须能立即冻结所以账号表里要有 status 字段禁用后该账号无法登录但不能删除因为历史订单里保存的 cashier_id 还要关联到它。角色类型用整数比用字符串更省空间但代码里要对每个数字做明确的常量定义避免魔法值散落在各处。4.2 操作日志记录谁改了什么不靠记忆后台系统里最容易被忽略但出事时最关键的是操作日志。进货单录错、价格改错、库存盘点调整错了如果没有日志事后只能翻聊天记录猜是谁改的。操作日志表只需要五个字段操作人、操作时间、操作类型、操作对象、操作详情。日志的写法不要在每个业务方法里手动调一遍日志服务用注解加切面统一处理。Target(ElementType.METHOD) Retention(RetentionPolicy.RUNTIME) public interface OpLog { String module() default ; String action() default ; }Aspect Component public class OpLogAspect { Around(annotation(opLog)) public Object around(ProceedingJoinPoint point, OpLog opLog) throws Throwable { Object result point.proceed(); String detail buildDetail(point.getArgs()); // 这里把当前登录用户 id 从登录上下文取出 logService.save(opLog.module(), opLog.action(), detail); return result; } }切面写法的好处是业务代码不会到处散落日志逻辑新增一个业务功能时只要在方法上标注注解。要注意的是日志保存必须放在业务事务之外不然业务执行失败回滚时日志也会跟着回滚那就失去审计意义了。我通常的做法是让日志保存用独立的事务传播级别即使业务失败也能记录一条“谁在什么时间尝试做了某个操作但失败了”这类失败日志反而经常是排查问题的最直接线索。5. 小型超市后台的五个高频踩坑与排查5.1 商品编码导入时重复Excel 直接覆盖了已有商品现象从 Excel 导入商品时系统提示部分商品编码重复但继续导入后新数据把原有商品记录覆盖了价格和历史库存全乱。原因导入逻辑把 Excel 里的 goods_no 作为唯一键执行了先查询存在则更新的操作但是更新时把库存、历史流水都当成了同一条商品来处理原来商品的库存被清零。解决导入前先做全量校验比对 Excel 里的 goods_no 与数据库现有编码出现重复时中止导入并把重复行号列出来。对于真正想更新的商品把更新范围限制在价格和名称字段库存数量绝不允许通过导入直接覆盖只能用调入调出或盘点功能来调整。5.2 金额字段用浮点类型月底对账差一分钱现象订单金额和后台统计的销售额总和总是差几角甚至几分单笔订单金额看起来是对的加总之后就出现偏差。原因MySQL 里 FLOAT 和 DOUBLE 是近似值存储多行累加之后浮点误差被放大。收银端计算的金额本身是两个浮点数运算的结果存进去的时候就已经不精确了。解决所有金额字段统一改成 DECIMAL(10,2)。如果已经上线了用一条 SQL 把浮点列转成 DECIMAL 并四舍五入保留两位小数。代码里计算金额一律用 BigDecimal 而不是 double尤其是优惠分摊这类逻辑double 乘法加法的精度问题在财务场景完全不能容忍。5.3 两个收银台同时卖同一款商品库存变成负数现象店内两台收银机同时结算某商品库存只剩 1 件两台机器却都成功出单库存变成 -1。原因收银端写入订单后扣减库存用的是UPDATE inventory SET quantity quantity - 1没有校验 quantity 不能小于零也没有做乐观锁或行锁两个事务同时读到旧值然后各自扣减覆盖了彼此的更新。解决按前面 3.2 的做法扣减时带上 version 条件影响行数为 0 时重试。如果代码已经大面积使用直接 UPDATE短平快方案是把 SQL 改成SET quantity quantity - 1 WHERE quantity 1影响行数同样能判断是否超卖。但要记得异常时把订单回滚不能让小票出了库存却扣减失败。5.4 盘点调整不走流水月底对账永远对不上现象发现某商品库存多了几件直接改了库存表把它调正确月底看库存流水时发现账实差异凭空消失完全无法追溯。原因系统没有强制让盘点调整写入 stock_log库存字段直接被 UPDATE流水记录出现断层。库存表里的实际数量和流水累计量对不上后面任何统计都会带着这个差异走。解决盘点的操作必须走专门的盘点单流程生成一个盘点差异入库或出库记录biz_type 记为CHECK。差异数据要展示出来单个商品差异过大时要求店长二次确认。记住一条原则库存表只能被进销存事务修改任何绕过流程直接 UPDATE 库存的操作都是安全隐患。5.5 时间字段混用 datetime 与时间戳日报统计缺数据现象后台按天查看销售报表某几天的数据总是少那么几条细看发现缺失的都是深夜 23 点以后的订单。原因订单创建时间字段存的是本地时间 datetime统计时用 Java 代码里的 UTC 时间取当天范围时区偏移导致晚上 11 点以后的订单被算到了另一天。解决数据库连接串显式指定serverTimezoneAsia/Shanghai所有时间字段统一用DATETIME统计 SQL 里开始时间和结束时间都在 Java 侧生成好再传入。尽量不要在前端拼接时间范围去查后端前端时区和服务器时区不一致会造成同一种问题。排查这个坑的方法是把订单表按小时分组看分布如果 23 点后的数据比其他时段明显稀疏大概率就是时区问题。6. 一个好使的收尾技巧用一条 SQL 生成当日经营台账给这套系统做个加分的实用功能每天早上店长打开后台能看到一张前一日的经营台账报表包含销售总额、订单数、退货数、毛利估算和库存预警商品。这个功能不用单独开发一套复杂报表系统一条聚合 SQL 就能完成。SELECT DATE(o.created_at) AS biz_date, COUNT(o.id) AS order_count, SUM(o.actual_amount) AS sale_amount, SUM(o.discount_amount) AS discount_total, SUM(oi.quantity * (oi.sale_price - oi.cost_price)) AS estimated_profit FROM orders o JOIN order_item oi ON o.order_no oi.order_no WHERE DATE(o.created_at) #{bizDate} GROUP BY DATE(o.created_at)库存预警子查询直接从 inventory 和 goods 关联筛出 quantity 小于等于 low_stock 的商品列表按剩余数量升序排列取前 10 条。把这两段 SQL 封装进一个方法里页面顶部放一张当日总览卡片下面放低库存列表。毛利率的估算值最让店长有感知但要在页面标注这是按订单冗余成本算出的估算值实际利润还要扣除房租人工水电防止店长拿估算毛利去跟真实经营利润做硬对照。这个功能上线后店长每天开电脑第一件事就是看这个页面后台系统的价值感一下子就立住了。最后留个习惯给 admin 账号不做任何修改权限它只用来登录查看日志日常操作都用带姓名的员工账号这是我这几个项目里最值钱的一条教训。希望帮到你。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进