
简介超市进销存管理系统源码包适合Java初、中级学习者以及正在开展课程设计或毕业设计的开发者覆盖商品采购、销售、库存管理等核心业务。系统登录界面包含用户验证机制销售管理模块支持订单创建、修改、查询、删除与库存更新商品统计模块可生成畅销品、销售额、库存周转率等报表有助于理解零售管理系统的典型开发思路。压缩包共239个文件内含49个Java源文件、146个编译后的class文件另附31张图片、数据库文件db/mdf/ldf以及项目配置与说明文档整体仅1.24MB轻量便于下载学习。已有48人学习下载。源码中可看到从数据库设计、Hibernate持久化配置到DAO层、业务层和界面面板的完整代码结构同时包含入库、退货、查询等业务子模块配合项目文件和class文件可快速导入开发环境调试既适合用于教学演示也可作为二次开发或毕业设计的参考项目。1. 超市进销存管理系统源码为什么我劝你自己搭一套而不是买成品一个常见的翻车场景从源码站下了一套超市进销存管理系统源码数据库导入、后端启动看着都正常录第一张销售单库存数字就和你货架上的对不上了。这不是源码烂而是你还没理解“进销存”的业务边界。这套源码的本质是把进货、销售、库存、盘点、往来单位串成一条闭环数据流让每件商品的流入、流出、损耗都有据可查最终让账面库存逼近真实库存。适合谁想落地一家便利店、社区超市的个体老板或者想从零理解真实业务系统怎么设计的开发者。比起买成品收银软件拿源码自己改好处是每个字段、每段逻辑你都动得了。2. 拆解超市进销存的数据骨架六张核心表与单据状态机很多免费源码最大的问题不是功能少而是表结构设计得“刚刚够跑通”。等你要加一个批次、加一个门店维度就要动表结构牵一发动全身。所以我先讲清楚一套能持续改的超市进销存系统底表应该有哪些字段怎么定才不会被业务往后拖。2.1 商品档案、库存台账、往来单位三张底表怎么建商品档案是所有业务的主数据。最小集合是product_id、barcode、name、category、purchase_price、sale_price、min_stock、is_active。这里有两个容易忽视的点barcode必须加唯一索引因为扫码枪扫出来的就是它重复条码会让销售单挂到错误的商品上min_stock是安全库存阈值后面做补货预警全靠它没有这个字段预警功能就得另外造一张表。库存不能只存一个数字。常见做法是products表保留stock_qty作为余额同时维护一张stock_movements流水表记录product_id、change_qty入库为正、出库为负、balance_after、type、ref_order_id、created_at。为什么要有两份数据因为数字是可以算错的流水是不容抵赖的。每次进货、销售、盘点、报损都写一条流水就能在月底用一行 SQL 把“当前余额”和“流水累计”做交叉验证哪件商品对不上立刻能查出来。这是个很朴素的会计思路但源码站上很多打着“免费 Python 源码大全”旗号的进销存项目十个有九个没做这层台账。往来单位表存supplier_id、name、contact、phone、credit_amount。散客销售不需要建客户档案但进货的供应商必须建否则你的应付账款就是一笔糊涂账。credit_amount是给供应商的授信额度小超市一般用不到但留一个字段成本很低。字段类型上新手很容易直接用 REAL 存金额。SQLite 里 REAL 是浮点0.1 加 0.2 会变成 0.30000000000000004月底汇总对不上钱。这套最小方案为了可读性用了 REAL真要跑生产金额字段建议换成整数分存储或者用 DECIMAL(10,2)显示时再除以 100。我把这套最小表结构整理成清单照着建不会漏表名用途关键字段products商品主数据product_id, barcode, name, category, purchase_price, sale_price, min_stock, stock_qtysuppliers往来单位supplier_id, name, contact, phone, credit_amountstock_movements库存流水台账movement_id, product_id, change_qty, balance_after, type, ref_order_idpurchase_orders进货单主表po_id, supplier_id, status, total_amountpurchase_order_items进货单明细item_id, po_id, product_id, qty, pricesales_orders销售单主表so_id, cashier_id, status, total_amountsales_order_items销售单明细item_id, so_id, product_id, qty, pricestocktake_items盘点明细stocktake_id, product_id, book_qty, actual_qty, diff_qty商品档案和往来单位是主数据进货单、销售单、盘点单是业务单据库存流水是它们共同的结果。理解了这个分层后面改源码时就不会在stock_qty上直接乱加减而会先问一句这笔变动要落在什么单据上。2.2 进货单与销售单的状态机设计单据是进销存的骨架状态决定这个单据能不能被编辑、能不能触发库存动作。进货单常见状态draft(草稿)→confirmed(已审核)→completed(已入库)/cancelled(作废)。销售单常见状态pending(挂单)→paid(已付款)→refunded(已退货)。核心原则一句话单据一旦离开草稿态就不允许再编辑明细要改只能作废重开或者红冲。为什么这么苛刻因为库存流水已经根据明细写进stock_movements你回头改明细库存余额不会自动跟着改账面立刻对不上。这是进销存源码最常翻车的点后面避坑章节会重点展开。进货单的“入库”动作必须落在同一个事务里更新进货单状态、逐条更新商品库存、逐条写库存流水。销售单的“支付”同理但多一步“先校验库存够不够”。如果这些动作分散在两个接口、两次提交里中途断电或者接口超时重试就会出现“单据已支付但库存没扣”或者“库存扣了但单据还是待支付”的脏数据。单据编号建议独立于自增主键用PO20250101-001这种带日期和序号的格式。原因很实际门店里对单、退货、和供应商对账喊“单号 12”没人知道是哪天的喊PO20250108-001一目了然。自增主键保留给外键引用单据编号字段单独加唯一索引。2.3 盘点与报损让账面库存对得上货架盘点单的明细表里要有product_id、book_qty、actual_qty、diff_qty、status。盘点时系统在单据生成那一刻冻结账面库存快照再允许录入实际货架数量。差异diff_qty审核后生成一张损益调整单正数代表盘盈负数代表盘亏最后通过一条typeADJUST的流水把库存调平。这里有个设计细节最容易踩坑盘点期间如果还在卖货你录的实际数量永远和账面数量对不上。常见做法有两种。一种是盘点单生成后把相关商品标记为“盘点中”禁止销售另一种是盘点差异的计算基准放在“盘点开始时点库存”而不是“审核时点库存”盘点期间的出库单独记账审核时一并纳入计算。小超市通常选第一种操作简单人工封货架十几分钟就完事。报损单和盘点的区别在于盘点是“不知道少了”报损是“知道为什么少了”。所以报损单必须录入原因分类比如过期、破损、失窃方便月底统计损耗率。报表里把WASTE类型流水的数量除以同期进货量就是损耗率这是店长最关心的经营指标之一。很多源码只有进货和销售两张表损耗全靠盘点差异兜着结果月底损耗率永远算不准。月损耗率统计可以直接对流水表聚合SELECT product_id, SUM(change_qty) AS waste_qty FROM stock_movements WHERE type WASTE AND created_at 2025-01-01 AND created_at 2025-02-01 GROUP BY product_id;把结果除以当月进货量就是单品的损耗率。对生鲜区损耗率超过 5% 就该查供应链了。3. 用 Flask SQLite 跑通最小可用的超市进销存进货、销售、库存联动选 Flask SQLite 做最小版本理由是零安装、单文件、新手能一眼看穿全部代码。你把源码站上常见的 PHP 版、Java 课设版打开展一眼会发现很多版本把逻辑堆在页面脚本里库存直接减一个字段就完事。真实超市上线我一般会把这个架子平移成 FastAPI PostgreSQL但核心的事务逻辑和表结构是一样的。先把逻辑跑通再换数据库比一开始就上重框架要稳也别把这一套当黑匣子。3.1 项目目录与数据库初始化脚本一个能跑的最小工程目录长这样supermarket_ims/ ├── app.py # Flask 路由与业务接口 ├── db.py # SQLite 连接与事务管理 ├── schema.sql # 建表脚本 ├── requirements.txt # flask └── templates/ ├── dashboard.html ├── purchase.html └── sale.htmlschema.sql是最先要建立的我把建表脚本贴在下面。注意开头两行 PRAGMA它们是并发的命根子-- schema.sql最小可用进销存表结构 PRAGMA journal_mode WAL; -- 读写不互斥收银高峰期少报错 PRAGMA busy_timeout 5000; -- 拿不到锁时等待5秒而不是立刻失败 CREATE TABLE products ( product_id INTEGER PRIMARY KEY AUTOINCREMENT, barcode TEXT UNIQUE NOT NULL, name TEXT NOT NULL, category TEXT, purchase_price INTEGER NOT NULL DEFAULT 0, -- 建议存分避免浮点误差 sale_price INTEGER NOT NULL DEFAULT 0, min_stock INTEGER NOT NULL DEFAULT 0, stock_qty INTEGER NOT NULL DEFAULT 0, is_active INTEGER NOT NULL DEFAULT 1 ); CREATE TABLE stock_movements ( movement_id INTEGER PRIMARY KEY AUTOINCREMENT, product_id INTEGER NOT NULL REFERENCES products(product_id), change_qty INTEGER NOT NULL, -- 入库为正出库为负 balance_after INTEGER NOT NULL, type TEXT NOT NULL, -- PURCHASE/SALE/ADJUST/WASTE/RETURN ref_order_id INTEGER, created_at TEXT NOT NULL DEFAULT (datetime(now,localtime)) ); CREATE TABLE purchase_orders ( po_id INTEGER PRIMARY KEY AUTOINCREMENT, supplier_id INTEGER, status TEXT NOT NULL DEFAULT draft, total_amount INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL DEFAULT (datetime(now,localtime)) ); CREATE TABLE purchase_order_items ( item_id INTEGER PRIMARY KEY AUTOINCREMENT, po_id INTEGER NOT NULL REFERENCES purchase_orders(po_id), product_id INTEGER NOT NULL, qty INTEGER NOT NULL, price INTEGER NOT NULL, -- 单价单位也是分 subtotal INTEGER NOT NULL ); -- sales_orders、sales_order_items 结构与进货单对称字段略 CREATE TABLE sales_orders ( so_id INTEGER PRIMARY KEY AUTOINCREMENT, cashier_id TEXT, status TEXT NOT NULL DEFAULT pending, total_amount INTEGER NOT NULL DEFAULT 0, paid_at TEXT );逻辑说明我把金额字段全部改成 INTEGER 存“分”不为炫技只为躲浮点误差。比如一瓶水进价 1.10 元用 REAL 存 1.1累计 100 瓶后总额会变成 109.99999999999999报表上差一分钱对账的人会疯。用分存显示时除以 100所有加法都是整数运算永远不会出这种“玄学”误差。参数说明PRAGMA journal_modeWAL让读操作不阻塞写操作busy_timeout5000让连接在遇到写锁时等待 5 秒而不是直接抛database is locked。这两个参数在小超市收银并发场景下够用但要注意 WAL 模式下不要用网络文件系统NFS/SMB放数据库文件锁机制在远程盘上会失效。db.py里的连接函数是事务控制的入口# db.py连接管理 import sqlite3 DB_PATH supermarket.db def get_conn(): conn sqlite3.connect(DB_PATH) conn.row_factory sqlite3.Row conn.isolation_level None # 关闭自动提交事务改由代码显式控制 conn.execute(PRAGMA busy_timeout 5000) return conn这里isolation_level None是必须的。默认情况下 Python sqlite3 模块会自动开启隐式事务你很难判断 COMMIT 发生在哪一行关掉自动提交后所有写操作都等你显式BEGIN/COMMIT事务边界完全可控。这是后面库存扣减不出脏数据的前提。3.2 进货入库一个事务完成“单据确认 库存增加 流水写入”进货入库的接口逻辑是整套系统的模板。它要做三件事确认单据状态、更新商品库存、写库存流水。三件事缺一不可所以要锁在同一事务里# app.py进货单确认入库 from flask import Flask, request, jsonify import db app Flask(__name__) app.route(/api/purchase/confirm, methods[POST]) def confirm_purchase(): data request.get_json() po_id data[po_id] conn db.get_conn() try: conn.execute(BEGIN IMMEDIATE) # 立刻拿写锁避免两条入库单互相踩 # 1. 校验状态只有草稿单允许入库防止重复提交 po conn.execute( SELECT status FROM purchase_orders WHERE po_id ?, (po_id,) ).fetchone() if po is None or po[status] ! draft: return jsonify({code: 400, msg: 单据不存在或已确认}), 400 # 2. 逐条加库存、写流水 items conn.execute( SELECT product_id, qty, price FROM purchase_order_items WHERE po_id ?, (po_id,) ).fetchall() total 0 for it in items: total it[qty] * it[price] conn.execute( UPDATE products SET stock_qty stock_qty ? WHERE product_id ?, (it[qty], it[product_id]) ) bal conn.execute( SELECT stock_qty FROM products WHERE product_id ?, (it[product_id],) ).fetchone()[stock_qty] conn.execute( INSERT INTO stock_movements(product_id, change_qty, balance_after, type, ref_order_id) VALUES (?, ?, ?, PURCHASE, ?), (it[product_id], it[qty], bal, po_id) ) # 3. 更新主表状态 conn.execute( UPDATE purchase_orders SET statuscompleted, total_amount? WHERE po_id?, (total, po_id) ) conn.execute(COMMIT) return jsonify({code: 0, msg: 入库成功}) except Exception as e: conn.execute(ROLLBACK) return jsonify({code: 500, msg: str(e)}), 500 finally: conn.close()这里有几个细节值得说。BEGIN IMMEDIATE和普通BEGIN的区别是它在事务开头就申请写锁而不是等到第一条 UPDATE 才申请避免两个连接同时进入事务后互相等待造成死锁。balance_after不是简单加一个变量而是从库里重新查出来的这样即使之前有脏数据流水也能如实记录“变动后的真实余额”。失败路径的处理同样重要任何一步抛错ROLLBACK会把前面所有 UPDATE 和 INSERT 全部撤销单据状态、库存、流水三者保持一致。如果不做事务接口在第二步超时重试库存加了两次账永远对不上。这正是很多免费源码“平时好好的、一忙就乱”的根本原因。3.3 销售出库先校验库存扣减失败要整体回滚销售支付的逻辑比进货多一道关卡库存校验。前端可能同时开两张单都扫了同一件只剩 5 件的商品如果不在事务里校验和扣减两个请求可以同时读到 5、同时通过校验最后库存变成 3 而不是 1多卖出去 2 件。这个并发竞争在 SQLite 里要靠写锁来挡app.route(/api/sale/pay, methods[POST]) def pay_sale(): data request.get_json() so_id data[so_id] conn db.get_conn() try: conn.execute(BEGIN IMMEDIATE) # 写锁串行化并发支付 so conn.execute( SELECT status FROM sales_orders WHERE so_id ?, (so_id,) ).fetchone() if so is None or so[status] ! pending: return jsonify({code: 400, msg: 订单不存在或已支付}), 400 items conn.execute( SELECT product_id, qty FROM sales_order_items WHERE so_id ?, (so_id,) ).fetchall() total 0 for it in items: p conn.execute( SELECT sale_price, stock_qty FROM products WHERE product_id ?, (it[product_id],) ).fetchone() if p is None or p[stock_qty] it[qty]: raise Exception(f商品 {it[product_id]} 库存不足当前只剩 {p[stock_qty]}) total p[sale_price] * it[qty] conn.execute( UPDATE products SET stock_qty stock_qty - ? WHERE product_id ?, (it[qty], it[product_id]) ) new_bal p[stock_qty] - it[qty] conn.execute( INSERT INTO stock_movements(product_id, change_qty, balance_after, type, ref_order_id) VALUES (?, ?, ?, SALE, ?), (it[product_id], -it[qty], new_bal, so_id) ) conn.execute( UPDATE sales_orders SET statuspaid, total_amount?, paid_atdatetime(now,localtime) WHERE so_id?, (total, so_id) ) conn.execute(COMMIT) return jsonify({code: 0, msg: 支付成功, total: total}) except Exception as e: conn.execute(ROLLBACK) return jsonify({code: 500, msg: str(e)}), 500 finally: conn.close()注意销售金额total不是把前端传的明细价格加起来而是从products.sale_price重新读出来计算。前端传的商品价格一律不信这样收银员在前端把 2.5 元的水改成 0.01 元后端也不会认。价格权限控制会在下一章展开。跑通这两个接口后可以用一条 SQL 验证系统是否自洽-- 找出库存余额和流水累计对不上的商品 SELECT p.product_id, p.name, p.stock_qty, COALESCE((SELECT SUM(m.change_qty) FROM stock_movements m WHERE m.product_id p.product_id), 0) AS calc_qty FROM products p WHERE p.stock_qty ! COALESCE((SELECT SUM(m.change_qty) FROM stock_movements m WHERE m.product_id p.product_id), 0);这条 SQL 有结果说明某个操作的库存更新和流水写入没有同时发生没有结果说明当前所有进出库都留了痕迹。我一般把它写成一个check_consistency()函数每天日结后自动跑一次比任何监控都实在。4. 源码落地必调的四个参数从演示数据到能真跑的收银系统源码跑起来只是第一步能不能放进店里用取决于一堆参数和边界。这一章按落地的顺序讲四个最影响日常使用的设置。4.1 安全库存阈值补货预警的数学和踩坑products.min_stock不是拍脑袋填的。常见的经验公式是安全库存 日均销量 × 采购提前期 × 1.5。假设某款牛奶每天卖 20 盒供货商从下单到送货要 3 天那安全库存就是 20×3×1.590 盒。乘 1.5 是给促销、天气、突发大单留的余量生鲜类要再乘大一些因为保质期短。没有历史数据时用最近 7 天销售流水估算日均SELECT product_id, SUM(qty) / 7.0 AS daily_avg FROM sales_order_items WHERE so_id IN (SELECT so_id FROM sales_orders WHERE paid_at datetime(now,-7 days)) GROUP BY product_id;把结果回填到min_stock。预警查询是SELECT name, stock_qty, min_stock FROM products WHERE is_active 1 AND stock_qty min_stock ORDER BY (stock_qty - min_stock) ASC;店铺首页只需要展示这条 SQL 的前 20 行补货单就出来了。要避免的误区是min_stock定了就再也不动。夏季饮料日均销量是冬季的 3 倍安全库存必须按月重算否则夏天永远“没货可补”。4.2 权限与操作日志小店也要有审计进销存系统最怕的不是黑客是“内部人随手一改”。收银员改价格、店员删单、老板想查却查不到操作记录这三个问题靠两样东西解决角色权限和审计日志。角色至少分管理员和收银员两档。管理员能改商品、改价格、作废单据、看报表收银员只能开销售单、挂单、结账。权限校验要在后端做不能只在前端隐藏按钮。前端隐藏只是好看的皮直接调 API 一样能改。审计日志表我一般建得很简单CREATE TABLE audit_log ( log_id INTEGER PRIMARY KEY AUTOINCREMENT, user_id TEXT, action TEXT, -- UPDATE_PRICE / VOID_ORDER / CONFIRM_PURCHASE target_type TEXT, -- product / sales_order / purchase_order target_id INTEGER, old_value TEXT, new_value TEXT, created_at TEXT NOT NULL DEFAULT (datetime(now,localtime)) );任何涉及价格、库存、单据状态的写操作在业务事务里同时插一条 audit_log。比如改价接口conn.execute( INSERT INTO audit_log(user_id, action, target_type, target_id, old_value, new_value) VALUES (?, UPDATE_PRICE, product, ?, ?, ?), (current_user, product_id, old_price, new_price) )有人改价改库存一查日志就知道是谁、什么时候、从什么值改到什么值。这个表在平时没用出事的时候是唯一后悔药。4.3 批次与有效期生鲜超市绕不开的先进先出不带批次的进销存只能卖卖日化。只要店里卖牛奶、面包、冻品就必须回答“哪批先到期先卖”。做法是在库存流水的基础上加批次维度CREATE TABLE product_batches ( batch_id INTEGER PRIMARY KEY AUTOINCREMENT, product_id INTEGER NOT NULL, batch_no TEXT, -- 生产批次号 qty INTEGER NOT NULL, -- 该批次剩余数量 expiry_date TEXT, -- 有效期 created_at TEXT NOT NULL DEFAULT (datetime(now,localtime)) );进货接口在完成原有逻辑的同时每个品种插入 product_batches 行销售扣库存时按expiry_date ASC先扣最早的批次这就是先进先出的落法# 扣减某个商品的库存按到期日从早到晚逐批扣 batches conn.execute( SELECT batch_id, qty FROM product_batches WHERE product_id ? AND qty 0 ORDER BY expiry_date ASC, (product_id,) ).fetchall() remaining need_qty for b in batches: if remaining 0: break take min(b[qty], remaining) conn.execute( UPDATE product_batches SET qty qty - ? WHERE batch_id ?, (take, b[batch_id]) ) remaining - take # remaining 仍大于 0说明批次库存不够抛异常回滚参数说明ORDER BY expiry_date ASC保证最早过期的最先扣remaining min(b[qty], remaining)保证不会把某一批扣成负数。如果循环完 remaining 还大于 0直接抛异常让整个事务回滚而不是硬扣。这里有个很隐蔽的坑批次表的qty和products.stock_qty是两套数字如果销售扣库存时只更新了批次表忘了更新商品表或者反过来两边就对不上。把批次扣减和商品余额扣减放在同一个事务里并在日结对账时同时校验products.stock_qty SUM(product_batches.qty)。4.4 单据编号与日结时间口径对账时才知道钱是哪天的日结报表最容易被忽略的是时间口径。销售金额必须按paid_at支付时间统计而不是下单时间。挂单十几分钟甚至隔夜的情况在超市太常见了按下单时间统计会把昨天的钱算到今天日结永远对不上。日结 SQL 这么写SELECT strftime(%Y-%m-%d, paid_at) AS biz_date, SUM(total_amount) AS revenue FROM sales_orders WHERE status paid GROUP BY biz_date ORDER BY biz_date DESC;报表页面默认展示biz_date分组的结果这就是门店每天日结的“天”的定义。至于单据编号我习惯在创建时用应用层生成格式SO20250108001日期加三位流水号。订单号带日期客人退货、对账、调监控都有据可查不用再翻数据库看自增主键。5. 超市进销存源码落地避坑5条血泪经验与每日对账脚本下面每一条都是我在不同项目里真金白银换来的。表现形式不一样但根子上的原因就那么几个。5.1 库存对不上账先查并发竞争和漏网操作现象系统跑了两周后台显示某个 SKU 库存 -3货架上一瓶都没有。负库存说明卖出去的数量比进的多一定是某个出库动作没被完整记录。原因通常两种。第一种是退货没回补客人退了一瓶水收银台只退了钱没有写一条RETURN正向流水。第二种是并发竞争两个收银台同时读到库存 5都判断可以卖两件A 扣完写回 3B 基于自己读到的旧值 5 也写回 3最后一次写把 A 的扣减覆盖了。系统只扣了一次的量但两张销售单都支付成功流水累计 -4库存余额 3从此越卖越乱。解决所有扣减必须走 3.3 节BEGIN IMMEDIATE的写锁事务把“读库存-判断-扣减”串行化退货单独写一条RETURN正向流水。再配合 3.3 节末尾的对账 SQL当天不一致当天补。5.2 已确认单据不能编辑用红冲而不是“改了之”现象进了一批货发现数量录错直接在进货单上把 100 改成 90。保存很成功但库存没变账面和实物差 10 件。原因进货单确认时已经写死了库存流水你改明细老流水还挂在旧数量上系统不会自动生成一条“修正”流水。解决代码里加一条硬约束——status ! draft的单据UPDATE 接口直接返回 400。要纠正就分两步先把原单作废状态置为cancelled并写一条负向流水冲掉原影响再开一张新单走正常入库。这就是会计上的红冲进销存系统必须保留这个动作而不是允许“反悔式编辑”。5.3 盘点期间还在卖货盘盈盘亏永远是乱的现象晚上打烊后盘点一切正常差异为 0。第二天白天又盘了一次账面上比实物多出 30 件。晚上盘就对白天盘就错。原因盘点单生成后系统记录了“此刻账面库存”但盘点期间销售还在继续出库盘点单里的账面数字已经过时。等盘点单审核时用这个过时数字去算差异差异当然虚高。解决给盘点单加状态盘点中的商品禁止销售。实现不复杂products加一个stocktaking_flag字段盘点开始置 1销售接口查到这个字段就拒绝下单。生鲜超市可以按区域分批盘点每批冻结十几分钟影响可控。如果实在不能封货架就把差异计算基准改为“盘点开始库存 盘点期间出库量”但这样逻辑复杂容易再引入别的坑。5.4 前端改价后端必须重新读档案价现象收银员在结账界面把一瓶标价 3 元的水改成 0.01 元订单正常支付。月底对账老板亏得莫名其妙。原因销售接口的金额是前端传上来的price后端直接信任并写入订单明细。前端页面是谁都能打开的改个参数毫无成本。解决销售单明的单价一律从products.sale_price读前端传的 price 只作展示不作计价依据。真要搞促销后端单独提供“改价”接口要求管理员权限并把原价、新价写进 audit_log。记得在销售接口里也加一层校验如果前端传的单价和档案价不一致直接拒绝而不是静默使用档案价。5.5 SQLite 的 database is locked并发收银高峰的翻车现场现象晚上 6 点到 8 点的收银高峰支付接口偶发 500日志里躺着一行sqlite3.OperationalError: database is locked。原因多个连接同时写 SQLite后到的写操作默认立刻失败。并不是 SQLite 坏是它本来就只适合“单写多读”的场景。解决分三步走。第一步建表时开 WAL写PRAGMA journal_modeWAL;读和写不再互相阻塞。第二步连接串加PRAGMA busy_timeout5000;让写操作排队等锁而不是立刻报错。第三步把整个应用的所有请求收敛到同一个连接池控制并发写连接数不超过 5。做完这三步单店收银基本够用。如果还是锁说明业务量已经不适合 SQLite把 schema 迁移到 PostgreSQL事务代码几乎不用改这是这套写法的好处。5.6 每日对账脚本拿流水反查余额把对账 SQL 固化成一个独立脚本每天日结后执行# check_stock.py每日库存一致性检查 import db conn db.get_conn() rows conn.execute( SELECT p.product_id, p.name, p.stock_qty, COALESCE((SELECT SUM(m.change_qty) FROM stock_movements m WHERE m.product_id p.product_id), 0) AS calc_qty FROM products p WHERE p.stock_qty ! COALESCE((SELECT SUM(m.change_qty) FROM stock_movements m WHERE m.product_id p.product_id), 0) ).fetchall() if not rows: print(库存一致全部对平) else: for r in rows: print(f[不一致] {r[name]}: 账面 {r[stock_qty]}, 流水累计 {r[calc_qty]})脚本逻辑说明stock_qty是当前余额calc_qty是所有流水增量的累计两者理论上必须相等。跑出来的不一致项就是当天必须人工处理的清单。这条脚本不花多少时间但能救很多次月底盘点。6. 从单店到连锁的源码进阶路线调拨单和多门店库存怎么加这套源码跑稳之后下一个最常见的诉求是开第二家店。这时候单店表结构会碰到一个硬边界products和stock_movements没有门店维度两店的库存混在一个数里没法算各店损耗和业绩。加门店维度第一步给products加store_id或者更规范地拆一张store_stock(product_id, store_id, qty)第二步在stock_movements加store_id字段所有流水按店记录第三步新增调拨单——transfer_orders主表加from_store_id、to_store_id明细里是product_id、qty。调拨单确认时在一个事务里做“出库店库存减、入库店库存加、两条流水分别写”逻辑和第 3 章的进货入库完全同构只是多了一个物流方向。总部采购则可以复用进货单只是把supplier_id换成“总部代采”标记再按门店拆分成收货单。这一步设计的关键是让门店之间不共享库存余额只共享商品档案和供应商数据。商品档案是主数据可以全局统一库存是事实数据必须按门店隔离。这个边界划清楚后面加门店就是加一行store_id的事而不是重构。进阶之后验证方法不要变。我习惯让系统与手工台账双轨跑一个月每天晚上对一次日结金额、库存金额、损耗率三个数。等连续 30 天三个数都对得上手工台账就可以停了。我曾接手过一套库存差异 300 件的烂摊子最后发现是供应商赠品直接上了货架没走入库流程——不是系统 bug是流程没覆盖到赠品。后来我在进货单里加了“赠品行”标记价格记 0库存照常入库这个口子才算堵上。希望这套从表结构到接口再到对账的思路能帮你也少踩几个坑。本文还有配套的精品资源点击获取