简介:这是一套面向Java Web初学者与课程设计者的超市管理系统源码包,基于JSP与Java技术栈实现,覆盖商品出入库、价格策略、销售分析、库存预警及客户关系管理等核心业务模块,适合用于毕业设计、课程实训或二次开发参考。压缩包共49个文件,约1.34MB,包含10个xml配置、8个jsp页面、6个java源文件与6个class编译文件,另有jpg图片、css样式、properties配置及jar依赖等,构成完整的Web工程目录结构,便于直接导入NetBeans运行调试。目前已有58人学习下载。通过研读源码,读者可掌握商品编码、库存自动更新、促销与会员价设定、销售报表生成等实现思路,理解从入库核对到出库减记、库存监控预警的完整工作流程,并借鉴其数据决策与规范化运营的设计理念,为后续开发同类信息管理系统积累可复用的代码与架构经验。
1. 从一份“luyao.rar”说起:超市管理系统到底在管什么
你拿到一个叫luyao.rar的压缩包,解压后大概率是一个带数据库脚本的桌面程序或者 Web 工程,名字里写着“超市管理”。很多人第一反应是“这不就是个进销存 CRUD 吗”,但真把它跑起来、把数据灌进去,你会发现它管的远不止商品和库存——它管的是一次收银动作背后,库存、价格、会员、供应商、日结这几条数据链怎么同时不出错。
超市管理系统的核心矛盾只有一个:前台要快,后台要准。收银员扫一个条码,系统要在几百毫秒内完成查价、扣库存、算会员折扣、记流水;而到了晚上,老板要看的是今天到底卖了多少、哪个货快没了、哪个供应商该补货了。这两件事对数据一致性的要求完全不同,前者怕慢,后者怕错。标题里的“超市管理系统”如果只做成一个能增删改查的表格程序,那它连最小可用都算不上。
这篇文章适合两类人:一类是手里正拿着类似luyao.rar这种课程设计或小项目,想把它从“能跑”改到“敢用”的开发者;另一类是想自己搭一套小型超市收银后台,但不确定该从哪张表、哪个事务开始下手的工程师。我会按“先立数据模型,再跑最小收银链路,最后补日结和避坑”的顺序讲,代码用 Python + SQLite 演示,换成 MySQL 或 Java 只是驱动和语法差异,逻辑是通的。
2. 先把数据模型立住:五张表撑起收银和库存
2.1 为什么商品表和库存表必须分开
新手最容易犯的错,是把stock字段直接塞进product表,觉得“一个商品一个库存,多简单”。真跑起来就翻车:同一个商品在不同门店、不同批次、不同进货价下,库存是分开算的;而且每次收银扣减库存都要锁这一行,如果商品信息也在这张表里,改个商品名都可能和收银事务抢锁。
常见做法是拆成product(商品档案:条码、名称、分类、售价)和inventory(库存:商品 ID、当前数量、预警阈值、最后更新时间)。售价变动不碰库存,库存扣减不碰商品描述,锁的粒度小,收银链路才快。
-- 商品档案表:只放相对稳定的信息 CREATE TABLE product ( id INTEGER PRIMARY KEY AUTOINCREMENT, barcode TEXT NOT NULL UNIQUE, -- 条码,收银扫描的入口 name TEXT NOT NULL, category TEXT, price REAL NOT NULL, -- 当前售价,单位元 status INTEGER DEFAULT 1 -- 1 上架,0 下架 ); -- 库存表:和商品一对一,但独立成表 CREATE TABLE inventory ( product_id INTEGER PRIMARY KEY, quantity INTEGER NOT NULL DEFAULT 0, warn_level INTEGER NOT NULL DEFAULT 10, -- 低于这个值触发补货提醒 updated_at TEXT NOT NULL, FOREIGN KEY (product_id) REFERENCES product(id) );逻辑说明:barcode加唯一索引,是因为收银扫描必须能唯一定位到一个商品,重复条码在真实超市里是灾难。inventory用product_id做主键而不是自增 ID,保证一个商品只有一条库存记录,避免出现“同一商品两条库存”的脏数据。warn_level是补货预警线,后面日结报表会用到。
参数说明:price用 REAL 只是演示方便,生产环境建议用整数存“分”,避免浮点误差;updated_at存 ISO 格式字符串,方便排序和比对。
2.2 订单主表和明细表:一笔收银拆成两条记录
收银动作在数据库里必须是一次事务,写两张表:sale_order(这一单的头部信息:时间、收银员、总金额、会员)和sale_item(这一单里每个商品买了几件、单价多少)。只写一张宽表看起来省事,但退货、改价、按商品统计销量时你会痛不欲生。
CREATE TABLE sale_order ( id INTEGER PRIMARY KEY AUTOINCREMENT, order_no TEXT NOT NULL UNIQUE, -- 业务单号,对外展示 member_id INTEGER, -- 会员 ID,散客为 NULL total_amount REAL NOT NULL, created_at TEXT NOT NULL ); CREATE TABLE sale_item ( id INTEGER PRIMARY KEY AUTOINCREMENT, order_id INTEGER NOT NULL, product_id INTEGER NOT NULL, quantity INTEGER NOT NULL, unit_price REAL NOT NULL, -- 成交单价,可能被改价 FOREIGN KEY (order_id) REFERENCES sale_order(id), FOREIGN KEY (product_id) REFERENCES product(id) );逻辑说明:unit_price必须冗余存一份,不能只靠product.price反查。因为商品明天可能涨价,但今天这笔订单的成交价必须固定,否则历史报表全乱。order_no用业务单号而不是自增 ID 对外,是为了避免暴露单量,也方便按日期生成可读单号。
参数说明:member_id允许为空,散客不强制注册;quantity用整数,超市按件卖,不涉及小数。
2.3 会员表和供应商表:别急着做,但结构要留好
会员和供应商不是收银链路的必需项,但标题里既然叫“超市管理系统”,这两块迟早要加。我的建议是:第一版先把表建好,逻辑留空,别一上来就做积分和账期,否则主链路还没跑通就陷进业务细节。
CREATE TABLE member ( id INTEGER PRIMARY KEY AUTOINCREMENT, phone TEXT NOT NULL UNIQUE, name TEXT, points INTEGER DEFAULT 0, created_at TEXT NOT NULL ); CREATE TABLE supplier ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, contact TEXT, settle_days INTEGER DEFAULT 0 -- 账期天数,0 表示现结 );逻辑说明:phone做唯一键,因为手机号是会员最自然的标识;points先留着,积分规则后面再定。settle_days是给采购对账用的,现结为 0,月结为 30,先有字段,逻辑可以后补。
参数说明:contact存联系人信息,不拆太细,小超市够用;settle_days用整数天,避免日期计算复杂度。
3. 跑通最小收银链路:一次扫码到扣库存的完整事务
3.1 收银主流程的代码骨架
收银的核心就一件事:查价、扣库存、写订单,三步在一个事务里完成。任何一步失败,整单回滚,不能出现“库存扣了但订单没写”的情况。下面是最小可跑的 Python 实现,用 SQLite 演示。
import sqlite3 import datetime def checkout(conn, barcode, quantity, member_id=None): """ 一次收银动作:查价 -> 扣库存 -> 写订单 -> 写明细 返回订单号,失败抛异常并回滚 """ cur = conn.cursor() try: conn.execute("BEGIN") # 显式开启事务 # 1. 查商品和库存,加行锁(SQLite 用 IMMEDIATE 事务模拟) cur.execute(""" SELECT p.id, p.price, i.quantity FROM product p JOIN inventory i ON p.id = i.product_id WHERE p.barcode = ? AND p.status = 1 """, (barcode,)) row = cur.fetchone() if not row: raise ValueError(f"条码 {barcode} 不存在或已下架") product_id, price, stock = row # 2. 库存校验,不足直接拒绝 if stock < quantity: raise ValueError(f"库存不足:当前 {stock},需要 {quantity}") # 3. 扣库存 cur.execute(""" UPDATE inventory SET quantity = quantity - ?, updated_at = ? WHERE product_id = ? """, (quantity, datetime.datetime.now().isoformat(), product_id)) # 4. 写订单头 order_no = "S" + datetime.datetime.now().strftime("%Y%m%d%H%M%S%f") total = price * quantity cur.execute(""" INSERT INTO sale_order (order_no, member_id, total_amount, created_at) VALUES (?, ?, ?, ?) """, (order_no, member_id, total, datetime.datetime.now().isoformat())) order_id = cur.lastrowid # 5. 写订单明细 cur.execute(""" INSERT INTO sale_item (order_id, product_id, quantity, unit_price) VALUES (?, ?, ?, ?) """, (order_id, product_id, quantity, price)) conn.commit() return order_no except Exception as e: conn.rollback() raise e逻辑说明:BEGIN显式开启事务,保证五步要么全成要么全败。查库存时用JOIN一次拿到价格和库存,减少往返。扣库存用quantity = quantity - ?而不是先查再算再写,避免并发下的丢失更新。订单号用时间戳加微秒,小超市并发低够用,高并发要换成序列或雪花算法。
参数说明:barcode是扫描枪输入,quantity默认 1,member_id可选。conn建议用isolation_level=None创建,手动控制事务边界。
3.2 并发扣库存:为什么“先查再改”一定会超卖
上面代码里扣库存是UPDATE ... SET quantity = quantity - ?,这是原子操作。如果你写成先SELECT quantity,在 Python 里减完再UPDATE,两个收银台同时卖同一件商品时就会超卖——这就是典型的“先查再改”翻车现场。
# 错误示范:并发下会超卖 cur.execute("SELECT quantity FROM inventory WHERE product_id = ?", (pid,)) qty = cur.fetchone()[0] if qty >= n: cur.execute("UPDATE inventory SET quantity = ? WHERE product_id = ?", (qty - n, pid))逻辑说明:两条收银线程可能同时读到qty=1,都判断通过,都写0,结果卖了两件但库存只扣了一件。正确做法是把判断和扣减合并到一条 SQL 里,或者用UPDATE ... WHERE quantity >= ?加影响行数判断。
# 正确做法:带条件的原子扣减 cur.execute(""" UPDATE inventory SET quantity = quantity - ? WHERE product_id = ? AND quantity >= ? """, (n, pid, n)) if cur.rowcount == 0: raise ValueError("库存不足")参数说明:rowcount为 0 说明条件不满足,直接抛异常回滚。这种方式在 MySQL、PostgreSQL 里同样适用,是收银扣库存的标准写法。
3.3 退货和改价:别让历史数据被覆盖
退货不是把sale_item删掉,而是写一条负数量的记录或者单独的退货表。改价同理,成交价已经写进sale_item.unit_price,后续商品调价不影响历史订单。我一般会加一张stock_log表,记录每一次库存变动的来源(销售、退货、采购、盘点),这样对账时能追到每一件商品的去向。
CREATE TABLE stock_log ( id INTEGER PRIMARY KEY AUTOINCREMENT, product_id INTEGER NOT NULL, change_qty INTEGER NOT NULL, -- 正数入库,负数出库 reason TEXT NOT NULL, -- sale / return / purchase / check ref_no TEXT, -- 关联单号 created_at TEXT NOT NULL );逻辑说明:change_qty用正负区分方向,reason标记来源,ref_no关联订单号或采购单号。每次扣库存时同步插一条日志,日结时用SUM(change_qty)就能核对库存对不对。
参数说明:reason建议用枚举值约束,避免写错;ref_no可空,盘点和初始化时没有关联单号。
4. 日结报表和补货预警:把收银数据变成经营决策
4.1 日结报表的三条核心 SQL
日结不是把订单列表打印出来,而是回答三个问题:今天卖了多少钱、哪些商品卖得好、哪些货该补了。下面三条 SQL 直接可用。
-- 1. 今日销售额和单量 SELECT COUNT(*) AS order_count, SUM(total_amount) AS revenue FROM sale_order WHERE created_at >= date('now') AND created_at < date('now', '+1 day'); -- 2. 今日商品销量排行 SELECT p.name, SUM(si.quantity) AS sold, SUM(si.quantity * si.unit_price) AS amount FROM sale_item si JOIN sale_order so ON si.order_id = so.id JOIN product p ON si.product_id = p.id WHERE so.created_at >= date('now') AND so.created_at < date('now', '+1 day') GROUP BY si.product_id ORDER BY sold DESC LIMIT 10; -- 3. 补货预警:库存低于预警线且未下架 SELECT p.barcode, p.name, i.quantity, i.warn_level FROM inventory i JOIN product p ON i.product_id = p.id WHERE i.quantity <= i.warn_level AND p.status = 1 ORDER BY i.quantity ASC;逻辑说明:第一条用date('now')和date('now','+1 day')圈出今天,避免用LIKE匹配日期字符串导致索引失效。第二条按商品聚合销量和金额,LIMIT 10给老板看爆款。第三条是补货清单,quantity <= warn_level触发,按库存升序排,最急的排最前。
参数说明:SQLite 的date('now')是 UTC 时间,如果服务器时区不是 UTC,要改成date('now','localtime')。MySQL 对应CURDATE()和DATE_ADD(CURDATE(), INTERVAL 1 DAY)。
4.2 库存对账:用 stock_log 反查差异
库存对不上是超市系统最常见的投诉。我的习惯是每天日结时跑一次对账:用stock_log的累计变动和inventory.quantity比对,差异超过阈值就报警。
SELECT i.product_id, p.name, i.quantity AS current_qty, COALESCE(SUM(sl.change_qty), 0) AS logged_qty, i.quantity - COALESCE(SUM(sl.change_qty), 0) AS diff FROM inventory i JOIN product p ON i.product_id = p.id LEFT JOIN stock_log sl ON i.product_id = sl.product_id GROUP BY i.product_id HAVING diff != 0;逻辑说明:LEFT JOIN保证没有日志的商品也能查出来,COALESCE把 NULL 转成 0。HAVING diff != 0只输出有差异的行。如果diff不为零,说明有库存变动没写日志,或者日志被误删,需要人工排查。
参数说明:diff的绝对值建议设一个容忍度,比如小于 2 忽略,大于 2 报警,避免盘点时的微小误差天天报警。
4.3 把报表做成定时任务
日结不需要人盯着,用cron或 Windows 计划任务每天凌晨跑一次,把结果写进daily_report表或者发邮件。下面是一个最小定时脚本。
import sqlite3 import datetime def daily_report(db_path): conn = sqlite3.connect(db_path) cur = conn.cursor() today = datetime.date.today().isoformat() cur.execute(""" INSERT INTO daily_report (report_date, order_count, revenue, created_at) SELECT ?, COUNT(*), COALESCE(SUM(total_amount), 0), ? FROM sale_order WHERE created_at >= ? AND created_at < date(?, '+1 day') """, (today, datetime.datetime.now().isoformat(), today, today)) conn.commit() conn.close()逻辑说明:把统计结果落成一张日报表,方便历史查询和趋势分析。COALESCE处理当天没有订单的情况,避免插入 NULL。created_at用参数传入,方便补跑历史日期。
参数说明:db_path是数据库文件路径,定时任务里写绝对路径,避免工作目录变化导致找不到库。
5. 避坑与排查:超市管理系统最容易翻车的五个地方
5.1 条码重复导致收银扫出两个商品
现象:扫同一个条码,有时出 A 商品,有时出 B 商品,收银员以为扫描枪坏了。原因:product.barcode没加唯一约束,或者导入数据时重复插入。SQLite 里UNIQUE约束对 NULL 不生效,如果条码允许为空,多条 NULL 不会冲突,但空条码商品扫不出来。解决:建表时barcode TEXT NOT NULL UNIQUE,导入前用SELECT barcode, COUNT(*) FROM product GROUP BY barcode HAVING COUNT(*) > 1查重,发现重复先合并或下架。
5.2 浮点金额算出 0.30000000000000004
现象:订单总金额出现一长串小数,打印小票时很难看。原因:REAL类型存金额,0.1 + 0.2在二进制浮点里不等于0.3。解决:金额统一用整数存“分”,显示时除以 100。如果已经用了 REAL,在 Python 里用round(total, 2)做最终展示,但数据库里还是建议改成整数。
5.3 收银时数据库被锁,提示 database is locked
现象:两个收银台同时结账,其中一个报database is locked。原因:SQLite 默认写操作会锁整个库,并发写就排队超时。解决:小超市单收银台用 SQLite 没问题;多收银台必须换 MySQL 或 PostgreSQL。如果暂时不能换,把connect(timeout=10)调大,并确保事务尽量短,不要在事务里做网络请求或打印。
5.4 日结报表少了一天数据
现象:凌晨跑日结,发现前一天的订单没统计进去。原因:created_at存的是 UTC 时间,而date('now')也是 UTC,如果服务器时区是东八区,凌晨 0 点到 8 点的订单会被算到前一天。解决:统一用本地时间存created_at,查询时用date('now','localtime')。或者存 UTC,查询时手动加时区偏移。关键是存和查用同一套时区规则。
5.5 库存扣成负数
现象:盘点时发现某个商品库存是 -3。原因:扣库存的 SQL 没加AND quantity >= ?条件,或者并发下先查再改。解决:用第 3.2 节的原子扣减写法,并在inventory表上加CHECK (quantity >= 0)约束,数据库层面兜底。已经负数的,用stock_log反查是哪几笔单子出的问题,手动调平并补日志。
6. 进阶技巧:用触发器兜住库存日志,别再靠人肉补
前面说每次扣库存要同步写stock_log,但靠应用层代码写日志,迟早有人漏写——新来的同事加了个退货接口,忘了插日志,对账就对不上。我的习惯是把日志下沉到数据库触发器,只要inventory.quantity变了,自动记一笔,应用层只管改库存,日志永远不会丢。
CREATE TRIGGER trg_inventory_update AFTER UPDATE OF quantity ON inventory FOR EACH ROW WHEN OLD.quantity != NEW.quantity BEGIN INSERT INTO stock_log (product_id, change_qty, reason, ref_no, created_at) VALUES ( NEW.product_id, NEW.quantity - OLD.quantity, 'auto', NULL, datetime('now', 'localtime') ); END;逻辑说明:AFTER UPDATE OF quantity只在库存字段变化时触发,WHEN OLD.quantity != NEW.quantity过滤掉没实际变动的更新。change_qty用NEW - OLD自动算正负,入库为正,出库为负。reason先写'auto',应用层如果有更具体的来源,可以在业务表里补,但日志本身不会丢。
参数说明:SQLite 的触发器语法和 MySQL 略有差异,MySQL 里需要DELIMITER包裹,且NEW/OLD用法一致。datetime('now','localtime')保证日志时间本地化,和日结报表时区一致。
触发器带来的一个副作用是:批量盘点时如果直接UPDATE inventory SET quantity = ?,会一次性生成大量日志,reason全是'auto',不好区分是销售还是盘点。我的做法是盘点走单独的存储过程,先关触发器或者用reason字段在应用层覆盖。另一个坑是触发器里的异常会导致主更新失败,所以触发器逻辑要尽量简单,别在里面做复杂查询。
验证触发器是否生效,可以手动改一条库存,然后查stock_log最后几条:
UPDATE inventory SET quantity = quantity - 1 WHERE product_id = 1; SELECT * FROM stock_log WHERE product_id = 1 ORDER BY id DESC LIMIT 3;如果最后一条的change_qty是 -1,reason是'auto',说明触发器工作正常。如果没记录,检查触发器是否被删除,或者WHEN条件是否写错。
我踩过最深的一个坑,是早期版本里触发器和应用层日志同时写,结果每笔出库记了两条日志,对账时库存差异翻倍。后来统一成“要么应用层写,要么触发器写,绝不两边都写”。现在我的习惯是:只要库存变动逻辑超过两个入口,就上触发器;只有一个入口,应用层写日志更灵活。这个判断标准帮我省了很多对账的夜晚。
希望帮到你。
本文还有配套的精品资源,点击获取