离线超市收银系统源码:Electron+SQLite全链路实战
简介超市收银系统源码是一套面向小型超市收银场景、兼顾毕业设计与课程实践的完整项目参考围绕商品录入、条码结账、商品查询和计算器等基础功能展开并延伸至库存预警、会员积分、销售报表、支付接口、退货退款与权限管理等辅助模块。读者可据此梳理收银业务的流程拆解、数据库表关系、前端界面组织与后端逻辑处理了解从需求到系统集成的实现路径适合计算机相关专业学生和初级开发者对照学习。对于希望理解中小型零售软件从商品建档、扫码销售到售后与权限控制的读者这些模块能提供较清晰的业务分层和功能边界参考。压缩包为rar格式大小约3.3MB文件总数为0、类型明细暂未提供具体内容以实际解压为准。该资源已有3120人学习可作为小型商业系统设计与毕业项目复盘的参考资料。1. 从一台收银机说起这套超市收银系统源码到底解决什么问题早上六点半生鲜区还在卸货收银台已经开工先把昨天没结完的挂单捞出来核对再试一遍扫码枪和小票机。这种场景里一套超市收银系统源码的价值不在界面多漂亮而在于断网、断电、扫码枪掉线、打印机卡纸这几件事同时发生时它还能把一笔账算对。这套源码给出的是一套能落地的单店收银骨架商品条码建档与批量导入、扫码加购、改价抹零、会员价与整单折扣、现金或扫码支付、挂单取单、小票打印、日结交班对账外加一个后台做商品和库存维护。它面向三类人想给自家小店做独立收银工具的小团队、接了商超外包要快速出原型的开发者、以及拿它当桌面端加本地数据库实战教材的学生。不适合直接拿去做连锁总部级的多仓调拨系统——重心在收银台不在供应链。2. 技术栈与目录结构一套离线可用的超市收银系统怎么搭起来收银台的首要约束在于断网时也得继续卖货而界面好不好看无关紧要。这一条基本决定了技术选型凡是把结算逻辑放在云端的方案网络一抖队伍就堵在门口。2.1 技术选型桌面壳 本地库 外设直连市面上常见的四条路摆在一起看就很清楚方案优势代价适合谁纯网页 云服务免升级、多端同步断网即停业外设要靠本地代理网络稳定的连锁门店桌面壳 本地库离线可用能直连 USB/串口包体大要处理签名和升级单店、加盟店原生桌面WinForms、Qt外设支持最扎实开发慢跨平台差存量老收银机移动端 App便携扫码方便小票机和钱箱是硬伤极小门店这套源码走的是第二条Electron 主进程负责窗口、本地数据库和外设Vue 3 渲染进程负责收银界面两边通过 IPC 通信。好处是收银员看到的界面能用前端那套东西快速改脏活累活串口、打印、文件落盘全留在主进程里。这里有个容易忽略的设计点结算逻辑必须放在主进程或独立服务层不能写在页面组件里。因为挂单、断电恢复、批量导入这些操作都要复用同一套金额计算拽在.vue文件里迟早会被复制出三份不同的算法然后三份算法在小数点上打架。2.2 目录结构主进程、渲染进程、外设服务怎么分层pos-supermarket/ ├── electron/ │ ├── main.js # 主进程入口窗口、开机自启、单实例锁 │ ├── ipc/ │ │ ├── product.js # 商品查询、条码建档、拼音检索 │ │ ├── order.js # 下单、结算、挂单、退款 │ │ └── printer.js # 小票与价签打印 │ ├── db/ │ │ ├── schema.sql # 建表脚本 │ │ ├── migrate.js # 版本化迁移按 user_version 递增 │ │ └── seed.js # 演示商品与初始库存 │ └── device/ │ ├── scanner.js # 扫码枪缓冲键盘模拟设备 │ ├── escpos.js # ESC/POS 指令拼装 │ └── scale.js # 电子秤串口读数 ├── src/ │ ├── views/ │ │ ├── Cashier.vue # 收银主界面 │ │ ├── Goods.vue # 商品管理 │ │ ├── Stock.vue # 库存与盘点 │ │ └── Report.vue # 日结交班 │ ├── store/cart.js # 购物车状态 │ └── utils/money.js # 以“分”为单位的金额运算 ├── config/ │ └── store.json # 门店信息、打印机、小票模板 └── package.json分层的关键是electron/ipc这一层页面永远不直接碰数据库只发{ action, payload }给主进程主进程校验参数、开事务、返回结果。这样做的好处是后面想换成 C/S 架构或者加一个远程同步服务时只改 IPC 层界面一行不用动。device/单独拆出来也有道理。扫码枪、打印机、电子秤这些东西的调试周期比业务代码长得多混在业务里改起来会互相牵连。我一般会把它们做成可替换的适配器开发机上没接打印机就切到mock模式把指令打到控制台不至于为了调一个购物车逻辑专门搬一台机器过来。2.3 依赖与运行环境装完就能扫第一件商品跑起来之前先确认 Node 版本。better-sqlite3是原生模块Node 18 和 20 都能用但 Windows 上第一次装需要编译工具链Python 加 VS Build Tools这是新手最容易卡住的一关。配置里把 Electron 的二进制源单独指一下会顺很多不然npm install会在下载那一步停很久。# 1. 装依赖 npm install # 2. 初始化本地库建表 写入演示商品和初始库存 npm run db:init # 3. 开发模式Vite 起渲染进程Electron 起主进程 npm run dev # 4. 打包成免安装版 npm run buildpackage.json里几个脚本值得单独看一眼尤其是asarUnpack。原生模块被打进 asar 归档后会加载失败必须解出来{ name: pos-supermarket, main: electron/main.js, scripts: { dev: concurrently \vite\ \wait-on tcp:5173 electron .\, db:init: node electron/db/migrate.js node electron/db/seed.js, build: vite build electron-builder --win --x64 }, build: { asarUnpack: [**/node_modules/better-sqlite3/**] } }门店参数全部集中在config/store.json改门店名、小票页脚、抹零规则不用翻代码{ storeName: 示例生活超市, storeCode: S001, printer: { type: usb, codePage: gb18030 }, receipt: { width: 32, footer: 小票请留存七日内可退换 }, rounding: { mode: floor, unit: 10 } }storeCode不只是个标识它参与订单号生成多台收银机同时开单时靠它区分来源rounding.unit是 10意思是抹零到角改成立 1 就是抹到分。这两个参数是后面二次开发时最常被改动的。3. 收银主流程拆解从扫码枪到小票打印的完整链路一条完整链路是扫码枪输入 → 条码查商品 → 进购物车 → 算钱抹零 → 落库扣库存 → 打小票开钱箱。这五步里任何一步出错收银台前的队伍就会停下来所以每一步都得有兜底。3.1 扫码枪输入它不是串口设备是键盘很多人第一次接扫码枪会去找串口驱动其实绝大多数 USB 扫码枪出厂就是键盘模拟模式扫一次码等于飞快地敲一串字符再补一个回车。听起来省事实际坑不少——输入法开着会转成中文焦点跑到别的输入框字符就丢了回车还可能误触页面上的按钮。合格的做法是自己攒缓冲靠时间间隔判断是不是同一枪// electron/device/scanner.js // 扫码枪在系统眼里就是键盘字符到达间隔通常小于 30ms let buffer ; let lastTime 0; const GAP 35; // 相邻两次按键超过 35ms判定为新的一枪 const MIN_LEN 6; // 短于 6 位当作人工输入不拦截 export function createScanner(onScan) { return function handleKey(e) { const now Date.now(); if (now - lastTime GAP) buffer ; // 间隔太久清空重来 lastTime now; if (e.key Enter) { if (buffer.length MIN_LEN) { onScan(buffer); // 交给购物车处理 e.preventDefault(); // 拦掉回车避免触发按钮或换行 } buffer ; return; } if (e.key.length 1) buffer e.key; // 过滤 Shift/Ctrl 这类键 }; }GAP是可调参数老式扫码枪慢一些调到 50 更稳但会让收银员手输条码后的回车也被吞掉通常 35 是平衡点。MIN_LEN用来把人工键盘输入和扫码区分开因为收银员在搜不到商品时会手动敲条码这时得走商品检索而不是直接加购。购物车模型要注意两点同一个条码重复扫要合并成一行而不是加两行金额全部用整数“分”存储。// src/store/cart.js // price / amount / discount 单位统一为“分” export function addItem(cart, goods, qty 1) { const exist cart.lines.find((l) l.barcode goods.barcode); if (exist) { exist.qty qty; exist.amount exist.qty * exist.price - exist.discount; return cart; } cart.lines.push({ barcode: goods.barcode, name: goods.name, price: goods.sale_price, qty, discount: 0, amount: goods.sale_price * qty, weighed: goods.is_weighed 1 // 称重商品标一下后面要重算 }); return cart; }weighed这个字段看起来多余实际很关键称重商品扫的是固定条码重量得从电子秤读或者手工填行金额是单价 × 重量不能按件数走。少了这个标记后面做改价和退货时会错得莫名其妙。3.2 结算抹零规则和支付幂等结算要做三件事算小计、叠会员折扣、抹零然后接受支付、算找零。看着简单但抹零规则每个门店都不一样有的四舍五入到角有的直接抹掉分有的只抹现金单、扫码单不抹。所以规则得做成配置项而不是写死。// src/utils/money.js export const toFen (yuan) Math.round(Number(yuan) * 100); export const toYuan (fen) (fen / 100).toFixed(2); // 结账subtotal 小计、memberOff 会员优惠、payable 应收、change 找零单位全是分 export function settle(cart, member, payType, payAmount) { const subtotal cart.lines.reduce((s, l) s l.amount, 0); const rate member ? member.rate : 1; // 会员折扣率0.95 表示九五折 const memberOff subtotal - Math.floor(subtotal * rate); let payable subtotal - memberOff; if (payType cash) { payable Math.floor(payable / 10) * 10; // 只有现金单抹零到角 } const change payType cash ? payAmount - payable : 0; return { subtotal, memberOff, payable, change }; }先乘后取整是这里最容易翻车的地方。Math.floor(subtotal * rate)保证优惠金额不会被算多反过来写成subtotal - Math.round(subtotal * (1 - rate))就会在四舍五入上丢掉几分钱。一天几百单下来财务对账时能差出一顿饭钱。支付幂等指的是“同一笔交易不能记两次”。收银员手忙脚乱连点两下确认按钮或者扫码支付回调重试都可能造成重复落单。做法是订单在落库前先按门店码 营业日 当日流水号生成唯一订单号数据库加唯一索引插入冲突就说明重复直接返回已有订单不再扣库存也不再打印。3.3 小票打印ESC/POS 指令和中文编码小票机基本都是 ESC/POS 指令集主进程拼一个Buffer直接丢给 USB 端口。麻烦的地方在中文默认代码页是英文不显式指定就会打出问号或者乱码。// electron/device/escpos.js const ESC 0x1b; const GS 0x1d; export function buildReceipt(order, cfg) { const buf []; buf.push(ESC, 0x40); // 初始化打印机清空上一条残留 buf.push(ESC, 0x61, 0x01); // 居中对齐 buf.push(...Buffer.from(cfg.storeName \n, gb18030)); buf.push(ESC, 0x61, 0x00); // 恢复左对齐 buf.push(...Buffer.from(-.repeat(cfg.receipt.width) \n, gb18030)); for (const line of order.lines) { const name line.name.slice(0, 14).padEnd(14, ); const qty String(line.qty).padStart(3, ); const amt (line.amount / 100).toFixed(2).padStart(9, ); buf.push(...Buffer.from(${name}${qty}${amt}\n, gb18030)); } buf.push(...Buffer.from(-.repeat(cfg.receipt.width) \n, gb18030)); buf.push(ESC, 0x61, 0x02); // 右对齐打合计 buf.push(...Buffer.from(合计 ${(order.payable / 100).toFixed(2)}\n, gb18030)); buf.push(ESC, 0x64, 0x03); // 走纸 3 行保证切纸位置正确 buf.push(GS, 0x56, 0x01); // 半切兼容不支持全切的机型 return Buffer.from(buf); }三个参数值得记住0x64, 0x03是走纸行数设大了每单多吐十厘米纸一年下来是笔钱GS 0x56 0x01是切纸指令老机型只支持全切那就改成0x00gb18030必须和打印机内部代码页一致不一致就是满纸问号。打印顺序也有讲究先落库再打印。反过来的话纸打出来了但数据库写失败磁盘满、断电系统里就没有这笔单日报少钱且无据可查这是真踩过的坑。4. 数据库设计商品、库存、订单、会员四张核心表的落地细节单店收银的数据量不大一天几百到几千单SQLite 完全够用还能省掉装数据库服务器的麻烦。但表结构一旦留了坑后面做报表和对账会天天加班。4.1 表结构与索引金额用整数订单号带门店码-- electron/db/schema.sql PRAGMA journal_mode WAL; -- 断电少丢数据收银台的保命设置 CREATE TABLE IF NOT EXISTS goods ( id INTEGER PRIMARY KEY, barcode TEXT NOT NULL UNIQUE, name TEXT NOT NULL, pinyin TEXT, -- 拼音首字母收银员键盘检索用 category_id INTEGER, purchase_price INTEGER NOT NULL DEFAULT 0, -- 进货价单位分 sale_price INTEGER NOT NULL, -- 零售价单位分 stock INTEGER NOT NULL DEFAULT 0, is_weighed INTEGER NOT NULL DEFAULT 0, -- 1 表示称重商品 status INTEGER NOT NULL DEFAULT 1 ); CREATE INDEX IF NOT EXISTS idx_goods_pinyin ON goods(pinyin); CREATE TABLE IF NOT EXISTS orders ( id INTEGER PRIMARY KEY, order_no TEXT NOT NULL UNIQUE, -- 门店码 营业日 流水号 biz_date TEXT NOT NULL, -- 营业日跨零点仍算前一天 shift_id INTEGER NOT NULL, -- 班次交班对账按它分组 member_id INTEGER, subtotal INTEGER NOT NULL, discount INTEGER NOT NULL DEFAULT 0, payable INTEGER NOT NULL, paid INTEGER NOT NULL, change_amt INTEGER NOT NULL DEFAULT 0, pay_type TEXT NOT NULL, -- cash / scan / stored status TEXT NOT NULL DEFAULT paid, -- paid / hang / refund created_at TEXT NOT NULL DEFAULT (datetime(now, localtime)) ); CREATE INDEX IF NOT EXISTS idx_orders_biz ON orders(biz_date, shift_id);order_no上加唯一索引不是为了好看它是防重复提交的最后一道闸。biz_date和created_at分开存是因为超市普遍有凌晨补货、夜间跨零点结账的情况财务要按营业日统计而不是自然日。这两个字段分开日报口径才不会在两个部门之间吵起来。另外两张表order_items存明细冗余商品名和单价商品改价后历史订单不能跟着变stock_flow存每一次出入库流水是盘点差异的唯一追溯依据。会员表按需只有做储值和积分才需要。4.2 库存扣减一个事务加一条带条件的 UPDATE库存超卖在收银场景里不算高频但一旦出现就是负库存、盘点对不上、店长找人问话。稳妥的写法是把下单、扣库存、写明细塞进同一个事务并且让扣减语句自己带条件// electron/ipc/order.js const db require(better-sqlite3)(pos.db); // db.transaction 是 better-sqlite3 提供的同步事务包装抛异常自动回滚 const settleTx db.transaction((order) { const insOrder db.prepare( INSERT INTO orders(order_no, biz_date, shift_id, subtotal, discount, payable, paid, change_amt, pay_type) VALUES (orderNo, bizDate, shiftId, subtotal, discount, payable, paid, changeAmt, payType)); const insItem db.prepare( INSERT INTO order_items(order_id, barcode, name, price, qty, amount) VALUES (?, ?, ?, ?, ?, ?)); // 关键WHERE 里带 stock ?库存不足时 changes 为 0不依赖先查后改 const decStock db.prepare( UPDATE goods SET stock stock - ? WHERE barcode ? AND stock ?); const info insOrder.run(order); for (const line of order.lines) { const r decStock.run(line.qty, line.barcode, line.qty); if (r.changes 0) { throw new Error(库存不足${line.name}); // 整个事务回滚 } insItem.run(info.lastInsertRowid, line.barcode, line.name, line.qty ? line.price : 0, line.qty, line.amount); } return info.lastInsertRowid; });WHERE stock ?这一句是重点。很多实现习惯先SELECT stock判断再UPDATE中间这段空隙在单机单线程下看似没事但只要开了第二个窗口比如后台在做批量导入就会出问题。把判断塞进 UPDATE 的 WHERE 里靠数据库自己的行锁保证原子性是成本最低的写法。真正的坑在业务层库存不足到底要不要拦单超市的实际做法往往是允许卖货在架上但系统没入库事后补一张手工入库单。所以这个throw更适合做成告警而不是硬阻断具体走哪条路由门店管理习惯决定。4.3 日结交班一条 GROUP BY 和三种对不上的情况交班对账是整个系统里被查得最勤的功能店员下班前要打印一张纸写清本班次收了多少现金、多少扫码、退了多少。-- 按营业日和班次统计? 依次传 bizDate、shiftId SELECT pay_type AS 支付方式, COUNT(*) AS 笔数, SUM(payable) / 100.0 AS 应收, SUM(paid - change_amt) / 100.0 AS 实收 FROM orders WHERE biz_date ? AND shift_id ? AND status paid GROUP BY pay_type;对不上的情况通常有三种。一是抹零没进统计口径实收比应收费少几毛属于正常报表里要单列一行“抹零金额”。二是挂单没排除status hang的单子还没付钱混进去就会虚高。三是退款用了负数记录但状态没标refund导致重复计入。这三种在第一次上线时基本都会遇到与其事后解释不如在 SQL 里就把条件写全。5. 避坑与排查门店上线第一天最容易翻车的五件事代码在开发机上跑通和在小票机旁边跑通是两回事。下面这几条都是收银台现场才会暴露的问题。5.1 外设类扫码枪和小票机不听话现象一扫码枪扫出来少一位字符或者混进一串中文。原因多半是收银员切到了中文输入法或者页面焦点被弹窗抢走了。扫码枪发的是键盘事件输入法一介入就变成拼音候选。 解决在全局keydown上做缓冲就是前面scanner.js那段同时在收银界面挂载时强制切英文输入弹窗打开期间暂停缓冲。别指望扫码枪自己解决它只是个键盘。现象二小票中文全是问号或方块。原因是Buffer.from(text)用了默认 UTF-8而打印机内部代码页还是英文。ESC/POS 机器不认 Unicode。 解决所有文本按gb18030编码并在打印前发一次设置代码页的指令。不同品牌指令略有差异先用厂商自带的测试页确认代码页编号再写死到配置里。现象三打完小票吐出一大段空白切纸位置对不上。走纸行数设太大或者用了机型不支持的切纸指令。 解决走纸固定 3 行切纸改成GS 0x56 0x00兼容全切。纸宽也要核对58mm 机器一行 32 字符80mm 是 48 字符模板里写死 48 在 58mm 机器上会折行。5.2 数据类金额和断电现象四日结差几毛到几块怎么都对不平。根源是浮点数参与金额计算0.1 0.2那套问题在几百单叠加后放大。 解决全链路整数分。数据库存分、内存算分、只在显示和打印时除以 100。这一条要在项目第一天就定死后面改起来要翻遍所有金额字段。现象五正在结账时断电重启后订单没了但货已经拿走了。这是顺序问题先打印后落库或者落库了但没开 WAL 导致写入丢失。 解决PRAGMA journal_mode WAL放在建库脚本第一行订单事务提交成功后才调打印。宁可让小票晚两秒出来也不能让系统里少一笔钱。5.3 环境类打包之后就跑不动现象六开发机能跑装到门店机器上双击没反应。常见原因是原生模块没从 asar 里解出来或者安装路径带中文和空格导致数据库文件创建失败。 解决build.asarUnpack里放better-sqlite3数据库文件路径用app.getPath(userData)拼不要用相对路径。装完先在目标机器上手动跑一次db:init确认能建表再交付。6. 二次开发把通用超市收银系统改成你门店自己的那套拿到源码之后最先改的通常不是界面是钱的计算方式。每个门店的促销玩法都不一样满减、第二件半价、会员日折上折、临期商品自动降价。把这些规则硬编码进settle()函数三个月后没人敢动那段代码。我的做法是留一个促销钩子规则按顺序跑每个规则拿到购物车返回新的购物车// src/promo/index.js const rules [ require(./member-price), // 会员价覆盖原价 require(./full-reduce), // 满 100 减 10 require(./second-half), // 同款第二件半价 require(./clearance) // 临期商品按时间段降价 ]; // ctx 里带会员信息、当前时间、门店配置规则内部不读全局状态 export function applyPromotions(cart, ctx) { return rules.reduce((c, rule) rule(c, ctx), cart); }这么拆的好处是加一条新规则只加一个文件不用碰结算主流程坏处是规则之间有顺序依赖满减和会员价谁先算结果不一样所以rules的数组顺序要写注释说明别让下一个人随手调换。常见的改造点和落点整理成一张表改之前先对号入座改造需求主要改动位置风险点会员储值与积分electron/ipc/order.js、新增member_account表储值扣款必须和订单同一事务称重商品接电子秤electron/device/scale.js串口波特率与稳定读数判断对接聚合扫码支付新增electron/ipc/pay.js回调重试要幂等用订单号去重小票加二维码electron/device/escpos.js位图指令因机型而异需实机验证多收银机共用数据换掉 SQLite改主从或局域网服务订单号必须全局唯一改完怎么验证别只点几下界面。我一般会造一批数据跑对账脚本随机生成 200 单混合现金、扫码、退款、挂单然后拿日结 SQL 的结果和脚本独立算出的期望值逐项比对差一分都算不通过。再补两个演练拔网线看能不能继续收银掐电源看重启后订单是否完整。这两关过了才敢往门店的机器上装。这套系统我前后改过几轮最值钱的经验不是哪个功能写得多巧而是每次改动前都先把日结对账跑一遍基线。有回加了个“满减可与会员价叠加”的活动界面看着没问题结果日结差了三十多块追了两个小时才发现是叠加顺序反了。从那以后我每次动金额相关的代码都强制走一遍“造单 → 对账 → 差值为零”这条线再小改动也不跳过。希望帮到你。本文还有配套的精品资源点击获取