☰
小型自选商场商品管理系统:从数据表设计到收银结算落地指南
2026/10/15 15:23:46 网站建设 项目流程

简介:这是一个面向数据库课程设计与商场管理场景的小型自选商场商品管理系统,适合计算机相关专业学生或初学C#与SQL的开发者用来学习进销存业务流程。系统覆盖进货、销售、库存动态刷新、供应商查询、收银台结账等功能,包含库存、售货、进货、供应商四张核心数据表设计,能够支撑从录入到统计的完整闭环。压缩包共189个文件,以60个SQL脚本和54个C#源文件为主,另有窗体设计文件、配置文件、可执行程序及数据库备份文件,整体约680KB,适合作为课程设计或项目起步参考。目前已有1025人学习下载,其中SQL脚本可直接用于建库与数据初始化,C#代码则展示了商品管理、查询统计与收银界面的实现思路,便于按模块拆解和二次开发。

1. 小型自选商场商品管理系统:不是做个增删改查那么简单

一台收银机、几百个SKU、三个服务员,这就是大多数小型自选商场的真实状态。商品管理系统听起来就是个标准的增删改查,但真到落地时会发现:商品条码怎么维护、售价和进价怎么联动、库存扣减怎么防止超卖、盘点差异怎么处理,这些都是需要具体设计的。本文要梳理的是一套适合小团队、能一次性跑通的小型自选商场商品管理系统实现路径,覆盖数据模型、后端接口、前端页面和演示验收四个环节。

我见过太多半途而废的同类项目,多数不是败在技术难度上,而是败在表结构设计不合理、库存逻辑没想清楚、对账时发现金额对不上。这套系统的核心价值在于快速支撑日常收银、入库、盘点和会员管理,面向的是几百个商品规模的门店,不需要复杂的分布式架构,但必须把每一笔流水记录清楚。

如果你正在做课程设计、毕业设计,或者给亲戚的小店做一套真正能用的管理后台,这篇文章的落地方案可以直接照着改。我会把表结构、核心接口、前端页面、典型坑位都展开讲,包含完整可复现的代码片段和参数说明。

2. 从商品到流水:先把数据模型设计明白,后面才能少返工

2.1 核心表的职责划分:商品、库存、流水、会员各管一摊

小型自选商场的业务模型可以拆成四类数据:商品档案、库存变动、销售流水、会员信息。很多新手会把库存数量和商品放在一张表里,省事,但后续做盘点、做历史追溯时会非常痛苦。我推荐的表结构是:商品表只管商品属性,库存变动表记录每一笔出入库,销售流水表记录每一笔卖出,会员表管积分和余额。

商品表的核心字段包括:商品ID、条码、名称、类目、进价、售价、单位、供应商、状态、创建时间和更新时间。进价和售价必须分开存,因为定价策略经常调整,但历史进价要能追溯。条码字段要加唯一索引,这是商品管理系统的核心标识,后面扫码录入全靠它。

库存变动表的字段包括:变动ID、商品ID、变动类型(入库、出库、盘点调整、销售扣减)、变动数量、变动前库存、变动后库存、操作人、订单号、备注、创建时间。这个表是库存对账的依据,任何库存变化都必须落一条记录。销售流水表的字段包括:流水ID、商品ID、数量、单价、总价、收银员、结账方式、订单号、创建时间。

会员表要根据实际需要设计,如果商场有储值卡,就要有余额字段和储值流水;如果只是积分,那一个累计积分字段就够。我给亲戚小店做的时候,发现最实用的字段是会员等级、积分和手机号,储值作为可选扩展。

2.2 建表SQL与三个必须注意的字段设计

下面是核心表的建表SQL,MySQL语法,线上环境建议加utf8mb4字符集和InnoDB引擎。

CREATE TABLE `product` ( `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '商品ID', `barcode` VARCHAR(32) NOT NULL COMMENT '条码', `name` VARCHAR(100) NOT NULL COMMENT '商品名称', `category` VARCHAR(50) DEFAULT NULL COMMENT '类目', `cost_price` DECIMAL(10,2) NOT NULL COMMENT '进价', `sale_price` DECIMAL(10,2) NOT NULL COMMENT '售价', `unit` VARCHAR(10) DEFAULT '件' COMMENT '单位', `supplier` VARCHAR(100) DEFAULT NULL COMMENT '供应商', `status` TINYINT DEFAULT 1 COMMENT '状态:1上架 0下架', `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_barcode` (`barcode`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `stock_log` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `product_id` BIGINT NOT NULL, `change_type` TINYINT NOT NULL COMMENT '1入库 2销售 3盘点调整 4退货', `change_qty` INT NOT NULL COMMENT '变动数量,负数表示减少', `before_stock` INT NOT NULL, `after_stock` INT NOT NULL, `operator` VARCHAR(50) DEFAULT NULL, `order_no` VARCHAR(32) DEFAULT NULL COMMENT '关联订单号', `remark` VARCHAR(200) DEFAULT NULL, `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_product_id` (`product_id`), KEY `idx_order_no` (`order_no`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `sale_order_item` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `order_no` VARCHAR(32) NOT NULL COMMENT '订单号', `product_id` BIGINT NOT NULL, `product_name` VARCHAR(100) NOT NULL COMMENT '冗余商品名,防商品改名影响历史流水', `qty` INT NOT NULL, `price` DECIMAL(10,2) NOT NULL COMMENT '成交单价', `subtotal` DECIMAL(10,2) NOT NULL, `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_order_no` (`order_no`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

字段设计里最重要的三个点:第一,金额一律用DECIMAL而不是FLOAT或DOUBLE,二进制浮点数在小数计算上存在精度损失,钱算错一分都麻烦。第二,库存变动表一定要记录变动前和变动后的库存值,这样盘点对账时可以直接比对,不用靠推算。第三,销售流水里冗余一个product_name字段,因为商品改名后历史订单不能被影响。

商品状态用TINYINT来标记上架和下架,而不是直接物理删除。物理删商品会导致历史流水和库存日志找不到关联对象,后面的报表统计可能直接因为外键关联失败而报错。

2.3 库存扣减时机:销售成功后才记账,还是下单时就预占

小型自选商场的业务是即时结账的,不存在电商那种下单后延迟支付的情况,所以不需要预占库存。标准流程是收银台选择商品、确认金额、收款成功、扣减库存、生成订单流水。如果做到一半放弃收款,不能让用户觉得库存已经被扣了。

所以我推荐的库存扣减时机是支付成功回调里统一处理。前端点击结账后,后端先算总价,收银员确认金额后,调用支付接口生成订单,然后在一个事务里完成扣库存和写流水两张表的操作。扣库存用乐观锁方式:UPDATE product SET stock = stock - #{qty} WHERE id = #{id} AND stock >= #{qty},如果影响行数为0,说明库存不足,整个事务回滚抛出提示。

后台手工入库和盘点调整则直接写positive数量或负数数量,同时落一条stock_log。这里要强调:库存绝不能直接UPDATE,必须走stock_log。没有日志的库存变动就是一笔糊涂账,后面出问题了完全无法追溯。

3. 后端业务接口:把进销存逻辑封装成可靠服务

3.1 Spring Boot项目结构与核心依赖

后端我习惯用Spring Boot 2.7 + MyBatis Plus + MySQL。不用太新的版本,稳定优先。以下是pom.xml里最核心的几个依赖:

<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.3.1</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency>

MyBatis Plus的好处是单表CRUD不用写XML,内置的LambdaQueryWrapper能省下大量样板代码。小型系统没有复杂关联查询,用这个框架开发速度最快。项目结构按controller、service、mapper、entity四层分包,简单直接,不要引入DDD那套,这个规模撑不起来。

3.2 商品管理接口:新增、编辑、上下架和分页查询

商品模块的后端接口是基础,整个系统都围绕商品档案来转。下面给出新增和分页查询的Service实现片段。

@Service @RequiredArgsConstructor public class ProductServiceImpl extends ServiceImpl<ProductMapper, Product> implements ProductService { private final StockLogService stockLogService; @Override @Transactional(rollbackFor = Exception.class) public boolean createProduct(ProductDTO dto) { // 检查条码是否重复,重复直接抛业务异常 long count = this.lambdaQuery() .eq(Product::getBarcode, dto.getBarcode()) .count(); if (count > 0) { throw new BizException("条码已存在:" + dto.getBarcode()); } Product product = new Product(); BeanUtils.copyProperties(dto, product); product.setStatus(1); // 默认上架 this.save(product); // 新商品初始库存为0,但需要落一条盘点调整记录,标记初始库存为0 stockLogService.recordChange(product.getId(), 3, 0, 0, 0, "system", null, "新建商品初始化"); return true; } @Override public PageResult<ProductVO> pageQuery(ProductQuery query) { LambdaQueryWrapper<Product> wrapper = new LambdaQueryWrapper<>(); wrapper.like(StringUtils.hasText(query.getKeyword()), Product::getName, query.getKeyword()) .eq(StringUtils.hasText(query.getBarcode()), Product::getBarcode, query.getBarcode()) .eq(query.getStatus() != null, Product::getStatus, query.getStatus()) .orderByDesc(Product::getUpdatedAt); Page<Product> page = this.page(new Page<>(query.getPageNum(), query.getPageSize()), wrapper); // 将查询到的商品ID集合批量查库存变动表和最近流水,填充到VO return PageResult.of(page); } }

这段代码里有几个关键设计:新增商品时校验条码唯一性,不通过就在service层抛出业务异常。初始库存为0但不能不写日志,这样stock_log表里记录了每个商品的初始状态,今后做全量库存追溯时可以从时间倒推。

分页查询时,keyword可以同时匹配商品名和条码,因为收银员经常扫不出码直接手输条码或者名称模糊搜索。分页用MyBatis Plus的Page对象,底层会自动拼接LIMIT语句,无需手写分页SQL。

3.3 收银结算接口:事务里完成计算、扣库存和写流水

收银接口是系统中并发压力最大的地方,多台收银机同时结账必须保证库存扣减不出错。以下是一段收银逻辑的Service实现,采用事务加乐观锁。

@Override @Transactional(rollbackFor = Exception.class) public OrderVO settle(SettleRequest request) { String orderNo = generateOrderNo(); // 生成规则:yyyyMMddHHmmss + 4位随机数 List<OrderItemVO> items = new ArrayList<>(); BigDecimal totalAmount = BigDecimal.ZERO; for (SettleItem item : request.getItems()) { Product product = this.getById(item.getProductId()); if (product == null || product.getStatus() == 0) { throw new BizException("商品不存在或已下架:" + item.getProductId()); } // 核心:乐观锁扣库存,条件stock >= qty,防止超卖 boolean update = this.lambdaUpdate() .setSql("stock = stock - " + item.getQty()) .eq(Product::getId, item.getProductId()) .ge(Product::getStock, item.getQty()) .update(); if (!update) { throw new BizException("商品库存不足:" + product.getName()); } // 写库存变动日志 stockLogService.recordChange(product.getId(), 2, -item.getQty(), product.getStock() - item.getQty(), product.getStock(), request.getOperator(), orderNo, "销售出库"); // 计算小计,保留两位小数 BigDecimal subtotal = product.getSalePrice().multiply(BigDecimal.valueOf(item.getQty())); totalAmount = totalAmount.add(subtotal); items.add(new OrderItemVO(product, item.getQty(), subtotal)); } // 批量插入销售流水明细 orderItemService.saveBatch(items); return new OrderVO(orderNo, totalAmount); }

商品ID和购买数量都放在SettleRequest里一次传入,后端循环逐条处理。需要注意的一点:循环内有两个数据库操作,一是扣库存,二是写stock_log,这两步必须在一个事务里。如果不用事务,中途一旦失败就会出现扣了库存但没写流水的情况,账目不平。

乐观锁在代码里的体现是靠setSql拼接减法和ge条件同时生效。MyBatis Plus的setSql是直接拼接SQL片段,不经过参数预编译,qty在进入前已经做了正整数校验,不会注入。至于商品售卖价为什么不取客户端传的金额,而是后端查库拿sale_price,这个设计必须养成习惯,前端传的价格不可信。

3.4 库存盘点接口:盘盈盘亏自动生成调整单

盘点是小商店每月必做的操作。正确的处理方式是先用扫码枪把所有商品实盘数量录入系统,形成盘点草稿,再确认生成盘盈盘亏结果。确认时一次性更新库存并记录调整日志。

@Override @Transactional(rollbackFor = Exception.class) public void confirmStocktake(StocktakeRequest request) { for (StocktakeItem item : request.getItems()) { Product product = this.getById(item.getProductId()); int diff = item.getActualQty() - product.getStock(); if (diff == 0) { continue; // 无差异跳过 } // 更新库存为实盘数量 this.lambdaUpdate() .eq(Product::getId, item.getProductId()) .set(Product::getStock, item.getActualQty()) .update(); // 记录调整日志,负数代表盘亏,正数代表盘盈 stockLogService.recordChange(product.getId(), 3, diff, product.getStock(), item.getActualQty(), request.getOperator(), null, "盘点调整"); } }

盘点确认的核心是计算Diff值,同时更新库存和写日志都在同一个事务里,不会出现只改库存没记账的情况。另外,这里的库存更新不像收银那样走乐观锁,因为盘点是单人在确认时间段内进行的,冲突概率极低,不需要加复杂条件。

盘点单建议保留一个独立的盘点记录表和明细表,这样月底可以对历史盘点差异做分析,看哪些商品亏损严重,辅助调整采购策略。如果项目时间紧张,也可以用stock_log加备注字段模糊查询代替,但效果会差一些。

4. 前端页面怎么组织:收银台、商品管理、库存和报表

4.1 收银台页面:核心是扫码录入与快捷结算

收银台是整个系统被使用频率最高的页面,设计目标只有一句话:收银员在3秒内完成一笔商品的录入。我用Vue 3 + Element Plus实现,页面布局为左侧商品列表,右侧结算面板。

录入方式有两种:扫码枪输入条码后回车,系统自动根据条码匹配商品;或者手动在搜索框输入名称关键字,从结果中选择。扫码枪本质是键盘输入设备,识别到条码后会加一个回车键,所以监听keyup事件时判断Enter即可。录入的商品进入结算列表,显示名称、单价、数量,默认数量为1,可以加减。结算时选择支付方式(现金、微信、支付宝、会员储值),点击结账按钮提交。

这里有一个易踩的坑:收银台页面必须做到操作成功后清空结算列表但要保留商品搜索框内容,同时自动聚焦到搜索框,方便下一件商品扫码。很多系统忽略了这一步,收银员每次都要用鼠标点一下搜索框,使用体验很差,一天几百个订单下来效率差距巨大。

4.2 商品管理页面:表格加弹窗,批量导入也要有

商品管理页面对应的功能是商品基础信息维护、上下架、批量导入导出。表格用el-table展示,分页请求后端接口。新增和编辑用el-dialog弹出表单,遇到条码重复时后端会返错误提示,前端直接message展示即可。

批量导入是小店从旧系统切换过来最需要的功能。Excel模板不需要太复杂,只需四列:条码、名称、进价、售价。后端用EasyExcel解析上传的文件,逐条校验条码唯一性。我一般会提供下载模板的功能,并限制上传文件大小不超过5MB。解析完成后的导入可以采用批量插入,每100条提交一次,速度比逐条插入快得多。

导出功能反而没太大必要做复杂配置。直接查询全部或按条件查询,然后用EasyExcel写出即可。这个模块最需要注意的是导入时条码格式,Excel里如果条码列是科学计数法,长条码会被截断或显示成分段,解决方法是模板里设置该列为文本格式。

4.3 库存与报表页面:让老板看明白赚了多少、哪些好卖

库存页面展示每个商品的当前库存、安全库存、累计入库、累计出库。安全库存值可以在商品编辑里设置,低于该值的商品在页面顶部用红色块置顶提醒,方便老板及时进货。

报表页面其实不用写复杂的图表库,ECharts引入折线图和柱状图就够了。按日展示销售额趋势,按商品展示销量排行Top10,按月展示利润,利润等于销售额减去对应进价成本。利润计算的SQL比较关键,要用join把sale_order_item和product关联起来,按日期分组聚合。对于小店,一张销售日汇总表和一张商品销量排行表基本够用。

后端实现可以参考如下查询逻辑:先按订单时间范围过滤流水明细,然后按product_id分组,SUM数量得到销量,SUM小计得到销售额,再用商品表的cost_price乘总数量得到成本,差价就是利润。这些查询存在一点性能顾虑,但这个规模的数据量,加好索引的情况下毫秒级返回完全没问题。

5. 避坑手册:从数据精度到并发扣库存的现场记录

5.1 金额计算出现尾差,账目对不上

现象:某天营业额统计和订单明细合计差了几分钱,查了很久查不出来。 原因:使用了FLOAT或DOUBLE类型存储金额,多次运算后精度丢失。尤其在计算折扣和按比例分摊时,浮点数误差会累积。 解决:金额一律改用DECIMAL(10,2),所有计算在Java侧用BigDecimal,避免double参与运算。写代码时严禁直接将前端传的数量乘单价得到金额,必须后端重新计算。

5.2 销售出库时库存被扣成负数

现象:前台显示商品还有库存,但结账的瞬间提示库存不足,或者下单成功但库存变成了负数。 原因:多个收银台同时扣同一件商品的库存,后一个更新覆盖了前一个;或者没有在SQL层面加库存大于等于数量的条件,代码只做了查询判断,存在并发间隙。 解决:库存扣减必须使用UPDATE加条件的方式,例如SET stock = stock - 1 WHERE id = ? AND stock >= 1,用数据库行锁和条件保证原子性。不要先SELECT再UPDATE。

5.3 商品条码重复或格式不统一,扫码匹配错乱

现象:不同商品用同一个条码,或者一条码前后有空格,扫码枪扫出来的不是对应商品。 原因:条码是由厂商决定的,部分产品条码位数不标准;另一方面,导入Excel时条码列格式被转成了科学计数法,精度丢失。 解决:条码字段设置为唯一索引,应用层导入时先把条码转字符串并trim去除首尾空格。如果有特殊商品(散装称重食品)没条形码,建议用自定义规则生成内部条码,如四位分类码加五位序号,并在商品编辑页允许修改。

5.4 订单删除商品导致库存日志错乱

现象:后台想删掉一条录错的订单,但删除后库存变了,stock_log里却看不到对应记录。 原因:物理删除订单时没有同步补偿库存或写入调整日志。 解决:不要物理删除订单,加一个状态字段改为作废。作废时必须恢复库存,并写入一条类型为退货的stock_log,操作人标记为system,备注写明作废原因。这个过程也要放在同一事务里,避免恢复库存成功但日志写入失败。

5.5 盘点时把在途商品也算进去,导致盘亏爆增

现象:刚采购的商品已经打单但还没入库,盘点当天把仓库实物数录入系统,结果盘亏几千。 原因:在途的采购订单占用库存,但库存表里没有体现,实物数量含在途商品。 解决:采购订单流程上要严格区分在途、已入库两个状态。盘点时把在途采购单商品排除在外,或者在盘点草稿里把已打单未入库商品列表弹出来让操作人确认,从实盘数中扣除后再提交。

6. 验收和演示时的几个实用技巧

系统做完了,最后一步是完整验收或向老师、客户演示。这个环节有技巧,我就按自己的一套方法来。准备当天,我会先跑一遍全流程模拟:建三个类目的商品、扫条码录入、开一张含会员折扣的销售单、手工入库一笔、盘点调整一笔,最后导出当日报表。全程录屏,避免演示时断网或者数据库连接失败,白屏一秒钟就会让前面的努力大打折扣。

验证库存和流水是否补偿的SQL是日常复盘的重点。我会用一个左关联查询,把product表的stock和stock_log里最近一次变动的after_stock做对比,如果不一致就说明有库存没有被日志覆盖。这个SQL还可以判断是不是有其他入口直接改了库存。给项目做自查时,这条查询能快速定位绝大多数库存问题。

生产环境的部署我习惯采用Docker Compose一键拉起MySQL和Java应用,配置文件外置,避免打包后改乱。数据库初始化脚本单独维护,加入建表、初始化类目和默认管理员数据的语句,方便在新机器上一条命令还原整个环境。商品数据量500条以内时,这种单体部署完全足够,没有必要拆微服务。

最后验收时我还会准备一份简洁的用户手册,重点写清楚三件事:每日关店时怎么核对营业额与现金、每周怎么盘库存、条码扫码无响应时排查什么。做过这个方向之后我的体会是:管理系统不是功能越多越好,而是数据闭环越完整越好。系统开发的难点很多时候不在写代码,在于每一个看起来不起眼的口子都留了日志。希望这篇笔记能帮你在自己的项目里少踩几个坑。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询