简介:这份文档是辽宁工业大学软件学院SQL Server数据库技术课程设计报告,题目为超市进销存管理系统,面向软件工程专业学生及数据库初学者,帮助读者理解从需求分析到程序实现的完整数据库系统设计流程。资源包内含1个doc文件,约420KB,内容涵盖需求分析、数据流图、数据字典、E-R图、关系模型、逻辑与物理设计、数据库实现及概要设计等章节,并配有C#连接SQL Server 2005的简单调用示例。读者可借此掌握关系数据库设计方法、SQL Server 2005技术应用以及C/S模式结构,获得一份规范的课程设计报告模板与可参考的超市进销存系统设计思路。目前已有418人学习,适合需要完成数据库课程设计或学习管理信息系统开发的学生参考借鉴。
1. 超市进销存系统为什么总在库存对不上时才发现表结构有问题
做过超市零售系统的人大多有过这种经历:白天收银正常,晚上盘点时发现某款矿泉水的系统库存比货架实际少了三十多瓶,查来查去不是收银漏扫,而是进货单和销售单在两张表里各记各的,中间没有一张表把「批次」这件事说清楚。SQL-SERVER 数据库技术配上超市进销存管理系统,要解决的核心就是这件事——用关系型数据库把商品、供应商、进货、销售、库存这几条线用主外键和事务串起来,让每一次入库出库都能追溯到具体单据,而不是靠事后对账去猜。
这套东西适合谁?一是数据库课程设计阶段的学生,需要一份能跑通、能演示、能讲清楚范式的完整方案;二是中小超市或便利店的技术负责人,想用一台普通服务器把进销存管起来,不想上昂贵的 ERP;三是刚接触 SQL-SERVER 的开发者,想找一个业务闭环完整、表关系不复杂的练手项目。它不追求高并发秒杀,追求的是数据一致性和可追溯性,这两点恰恰是进销存系统的命根子。
2. 从业务单据倒推表结构:七张核心表怎么定主键和外键
2.1 先画单据流,再落表,别一上来就建表
很多人做课程设计或小系统,习惯打开 SSMS 就开始建表,建到一半发现销售表里不知道该不该存商品名称,于是又加冗余字段,最后数据一改就乱。正确的顺序是先把业务单据流画出来:供应商供货产生进货单,进货单审核后入库产生库存批次,收银台卖出产生销售单,销售单扣减对应批次库存,退货则反向操作。这条流里每一个「单」就是一张主表,每一个「动作」就是一张明细表或流水表。
按这个思路,超市进销存最少需要七张核心表:商品分类表、商品信息表、供应商表、进货单主表、进货单明细表、销售单主表、销售单明细表。库存不单独建一张「当前库存表」去频繁更新,而是用进货明细和销售明细的汇总来算,或者建一张库存批次表记录每次入库的剩余量。前者查询稍慢但绝对准,后者查询快但需要事务保证扣减不出错。中小超市数据量不大,我一般推荐批次表方案,因为盘点时能直接看到每一批货进了多少、剩多少、卖了多少。
2.2 主键用自增还是业务编号,这里有个坑
商品表的主键用自增整数还是商品条码?条码看起来天然唯一,但超市里存在散称商品没有条码、条码重复贴错、供应商换包装改条码的情况。所以商品表主键用自增 ID,条码单独建唯一索引,允许为空但非空时必须唯一。进货单和销售单的主键同样用自增,单号用单独字段存业务编号,格式类似 JH20250101001,方便人看,但不做主键。
外键关系这样定:进货单明细表里存进货单 ID 和商品 ID,销售单明细表里存销售单 ID 和商品 ID,库存批次表里存商品 ID 和对应的进货单明细 ID。这样从任何一条销售记录都能反查到它扣的是哪一批进货,从任何一批进货都能查到它卖给了哪些销售单。下面这段建表语句是核心几张表的骨架,可以直接在 SSMS 里跑。
-- 商品分类表 CREATE TABLE Category ( CategoryID INT IDENTITY(1,1) PRIMARY KEY, CategoryName NVARCHAR(50) NOT NULL, ParentID INT NULL -- 支持二级分类,NULL 表示顶级 ); -- 商品信息表 CREATE TABLE Product ( ProductID INT IDENTITY(1,1) PRIMARY KEY, Barcode VARCHAR(30) NULL, ProductName NVARCHAR(100) NOT NULL, CategoryID INT NOT NULL, Unit NVARCHAR(10) NOT NULL DEFAULT '瓶', PurchasePrice DECIMAL(10,2) NOT NULL DEFAULT 0, SalePrice DECIMAL(10,2) NOT NULL DEFAULT 0, IsActive BIT NOT NULL DEFAULT 1, CONSTRAINT UQ_Product_Barcode UNIQUE (Barcode), CONSTRAINT FK_Product_Category FOREIGN KEY (CategoryID) REFERENCES Category(CategoryID) ); -- 供应商表 CREATE TABLE Supplier ( SupplierID INT IDENTITY(1,1) PRIMARY KEY, SupplierName NVARCHAR(100) NOT NULL, Contact NVARCHAR(50) NULL, Phone VARCHAR(20) NULL, Address NVARCHAR(200) NULL ); -- 进货单主表 CREATE TABLE PurchaseOrder ( PurchaseID INT IDENTITY(1,1) PRIMARY KEY, PurchaseNo VARCHAR(30) NOT NULL UNIQUE, SupplierID INT NOT NULL, PurchaseDate DATETIME NOT NULL DEFAULT GETDATE(), TotalAmount DECIMAL(12,2) NOT NULL DEFAULT 0, Status TINYINT NOT NULL DEFAULT 0, -- 0待审核 1已入库 2已作废 CONSTRAINT FK_Purchase_Supplier FOREIGN KEY (SupplierID) REFERENCES Supplier(SupplierID) ); -- 进货单明细表 CREATE TABLE PurchaseDetail ( DetailID INT IDENTITY(1,1) PRIMARY KEY, PurchaseID INT NOT NULL, ProductID INT NOT NULL, Quantity INT NOT NULL, UnitPrice DECIMAL(10,2) NOT NULL, BatchNo VARCHAR(30) NULL, CONSTRAINT FK_PD_Purchase FOREIGN KEY (PurchaseID) REFERENCES PurchaseOrder(PurchaseID), CONSTRAINT FK_PD_Product FOREIGN KEY (ProductID) REFERENCES Product(ProductID) );这段代码里几个参数值得说清楚。IDENTITY(1,1)是自增种子和步长,SQL-SERVER 里建表时最常用。NVARCHAR而不是VARCHAR,因为商品名和供应商名可能含中文,用VARCHAR在某些排序规则下会丢字符。DECIMAL(10,2)存金额,不要用FLOAT,浮点数算钱会出现 0.30000000000000004 这种结果,对账时能把你逼疯。Status用TINYINT而不是字符串,省空间且索引快。外键约束FK_PD_Purchase保证明细不会挂到不存在的进货单上,这是数据一致性的第一道闸。
2.3 库存批次表是进销存的枢纽,设计时多留一个字段
库存批次表连接进货和销售,设计时除了商品 ID、进货明细 ID、入库数量、剩余数量,我建议多留一个「入库时间」和「成本单价」。入库时间用于先进先出扣减,成本单价用于计算销售毛利。没有这两个字段,后面做毛利报表时只能取商品表里的当前进价,而进价是变动的,算出来的毛利不准。
CREATE TABLE StockBatch ( BatchID INT IDENTITY(1,1) PRIMARY KEY, ProductID INT NOT NULL, PurchaseDetailID INT NOT NULL, InQuantity INT NOT NULL, RemainQuantity INT NOT NULL, CostPrice DECIMAL(10,2) NOT NULL, InDate DATETIME NOT NULL DEFAULT GETDATE(), CONSTRAINT FK_SB_Product FOREIGN KEY (ProductID) REFERENCES Product(ProductID), CONSTRAINT FK_SB_PD FOREIGN KEY (PurchaseDetailID) REFERENCES PurchaseDetail(DetailID) ); -- 销售单主表和明细表结构类似进货单,此处省略主表,给出明细表 CREATE TABLE SaleDetail ( SaleDetailID INT IDENTITY(1,1) PRIMARY KEY, SaleID INT NOT NULL, ProductID INT NOT NULL, Quantity INT NOT NULL, SalePrice DECIMAL(10,2) NOT NULL, BatchID INT NULL, -- 记录扣的哪一批,便于追溯 CONSTRAINT FK_SD_Sale FOREIGN KEY (SaleID) REFERENCES SaleOrder(SaleID), CONSTRAINT FK_SD_Product FOREIGN KEY (ProductID) REFERENCES Product(ProductID), CONSTRAINT FK_SD_Batch FOREIGN KEY (BatchID) REFERENCES StockBatch(BatchID) );RemainQuantity这个字段是并发扣减的关键。收银台同时卖同一款商品时,两个事务如果都先读后写,会出现超卖。解决办法是在扣减时用UPDATE StockBatch SET RemainQuantity = RemainQuantity - @Qty WHERE BatchID = @BatchID AND RemainQuantity >= @Qty,然后检查@@ROWCOUNT是否为 1,为 0 说明库存不足,回滚事务。这个写法比先SELECT再UPDATE可靠得多,是血泪经验换来的。
3. 进货入库和销售出库的存储过程怎么写才不出错
3.1 入库存储过程:审核和写批次必须在一个事务里
进货单审核这个动作,业务上要同时做三件事:把进货单状态改成已入库、往库存批次表插入记录、更新商品表的最近进价。这三件事必须在一个事务里完成,任何一步失败都回滚,否则会出现单子审核了但库存没加,或者库存加了但单子还是待审核状态。下面这个存储过程是核心逻辑。
CREATE PROCEDURE sp_ApprovePurchase @PurchaseID INT AS BEGIN SET NOCOUNT ON; BEGIN TRY BEGIN TRANSACTION; -- 检查单据状态,防止重复审核 IF NOT EXISTS (SELECT 1 FROM PurchaseOrder WHERE PurchaseID = @PurchaseID AND Status = 0) BEGIN RAISERROR('单据不存在或已审核', 16, 1); ROLLBACK TRANSACTION; RETURN; END -- 逐条明细写入库存批次 INSERT INTO StockBatch (ProductID, PurchaseDetailID, InQuantity, RemainQuantity, CostPrice, InDate) SELECT ProductID, DetailID, Quantity, Quantity, UnitPrice, GETDATE() FROM PurchaseDetail WHERE PurchaseID = @PurchaseID; -- 更新商品最近进价 UPDATE P SET P.PurchasePrice = D.UnitPrice FROM Product P INNER JOIN PurchaseDetail D ON P.ProductID = D.ProductID WHERE D.PurchaseID = @PurchaseID; -- 更新单据状态 UPDATE PurchaseOrder SET Status = 1 WHERE PurchaseID = @PurchaseID; COMMIT TRANSACTION; END TRY BEGIN CATCH IF @@TRANCOUNT > 0 ROLLBACK TRANSACTION; THROW; END CATCH ENDSET NOCOUNT ON减少网络往返,存储过程里几乎必加。BEGIN TRY...BEGIN CATCH是 SQL-SERVER 里处理事务错误的标配,THROW把原始错误抛给调用方,方便上层定位。注意INSERT INTO StockBatch用的是SELECT批量插入,比游标逐条插快很多,而且天然保持原子性。RAISERROR那一段是防重复审核的后悔药,没有它,操作员手抖点两次审核,库存就翻倍了。
3.2 出库存储过程:先进先出扣减和事务隔离级别
销售出库比入库复杂,因为要按先进先出原则扣减批次。假设某商品有三批库存,入库时间分别是 1 月 1 日、1 月 5 日、1 月 10 日,现在卖 5 个,应该先扣 1 月 1 日那批,不够再扣 1 月 5 日。这个逻辑用游标或者WHILE循环实现,但必须放在事务里,并且把隔离级别设为REPEATABLE READ或更高,防止扣减过程中别的会话插进来。
CREATE PROCEDURE sp_SaleOut @ProductID INT, @Quantity INT, @SaleDetailID INT AS BEGIN SET NOCOUNT ON; SET TRANSACTION ISOLATION LEVEL REPEATABLE READ; BEGIN TRY BEGIN TRANSACTION; DECLARE @NeedQty INT = @Quantity; DECLARE @BatchID INT, @Remain INT, @Cost DECIMAL(10,2); -- 按入库时间升序取批次 DECLARE cur CURSOR LOCAL FAST_FORWARD FOR SELECT BatchID, RemainQuantity, CostPrice FROM StockBatch WHERE ProductID = @ProductID AND RemainQuantity > 0 ORDER BY InDate ASC; OPEN cur; FETCH NEXT FROM cur INTO @BatchID, @Remain, @Cost; WHILE @@FETCH_STATUS = 0 AND @NeedQty > 0 BEGIN IF @Remain >= @NeedQty BEGIN UPDATE StockBatch SET RemainQuantity = RemainQuantity - @NeedQty WHERE BatchID = @BatchID AND RemainQuantity >= @NeedQty; SET @NeedQty = 0; END ELSE BEGIN UPDATE StockBatch SET RemainQuantity = 0 WHERE BatchID = @BatchID AND RemainQuantity = @Remain; SET @NeedQty = @NeedQty - @Remain; END FETCH NEXT FROM cur INTO @BatchID, @Remain, @Cost; END CLOSE cur; DEALLOCATE cur; IF @NeedQty > 0 BEGIN RAISERROR('库存不足,扣减失败', 16, 1); ROLLBACK TRANSACTION; RETURN; END -- 记录扣减的批次到销售明细 UPDATE SaleDetail SET BatchID = @BatchID WHERE SaleDetailID = @SaleDetailID; COMMIT TRANSACTION; END TRY BEGIN CATCH IF @@TRANCOUNT > 0 ROLLBACK TRANSACTION; THROW; END CATCH ENDREPEATABLE READ保证在事务内多次读同一批数据结果一致,防止幻读导致扣减错乱。游标用LOCAL FAST_FORWARD,只进不退,性能最好。每次UPDATE都带AND RemainQuantity >= @NeedQty或= @Remain条件,这是乐观锁思路,即使隔离级别不够,也能靠条件更新避免扣成负数。最后那个UPDATE SaleDetail SET BatchID只记录了最后一批,严格来说应该记录所有扣减批次,实际项目里可以再建一张销售批次关联表,这里为了简洁只做示意。
3.3 库存查询视图:把批次汇总成一眼能看懂的报表
日常运营最常问的一句话是「这个商品还有多少库存」。直接查StockBatch汇总RemainQuantity就行,但还要带上商品名、分类、最近进价,所以建一个视图最方便。
CREATE VIEW v_StockSummary AS SELECT P.ProductID, P.ProductName, C.CategoryName, P.Unit, ISNULL(SUM(SB.RemainQuantity), 0) AS TotalRemain, P.PurchasePrice, P.SalePrice FROM Product P LEFT JOIN Category C ON P.CategoryID = C.CategoryID LEFT JOIN StockBatch SB ON P.ProductID = SB.ProductID AND SB.RemainQuantity > 0 WHERE P.IsActive = 1 GROUP BY P.ProductID, P.ProductName, C.CategoryName, P.Unit, P.PurchasePrice, P.SalePrice;ISNULL处理没有批次的商品,显示 0 而不是 NULL。LEFT JOIN保证即使某商品从没进过货也能列出来,方便发现「建了商品档案但一直没进货」的情况。WHERE P.IsActive = 1过滤掉停用商品,避免报表里出现一堆不卖的货。
4. 避坑:超市进销存数据库最容易翻车的五个地方
4.1 现象:盘点时库存对不上,差几个到几十个不等
原因通常有三种。一是销售出库时没有用事务,扣了库存但销售单没写成功,或者反过来。二是并发扣减时用了先查后改的写法,两个收银台同时卖同一商品,都读到剩余 10,各扣 3,最后剩 7 而不是 4。三是退货没有反向加回批次,或者加回到了错误的批次。解决方法是所有库存变动必须走存储过程,存储过程里必须用事务,扣减必须用条件更新WHERE RemainQuantity >= @Qty并检查@@ROWCOUNT。
4.2 现象:进货单审核后,商品最近进价没更新
原因多半是UPDATE Product那一步写在了事务外面,或者JOIN条件写错导致更新了 0 行。排查时先看PurchaseDetail里有没有对应记录,再看Product.PurchasePrice是否真的变了。解决方法是把更新进价放进审核存储过程的事务里,并且用@@ROWCOUNT打印受影响行数,为 0 就抛错回滚。
4.3 现象:销售明细里的 BatchID 是 NULL,追溯不到批次
原因是出库存储过程里UPDATE SaleDetail SET BatchID只更新了最后一批,如果一次销售跨多个批次,前面的批次没记录。解决方法是建一张SaleBatch关联表,每扣一个批次插一条记录,字段包括SaleDetailID、BatchID、Quantity、CostPrice。这样毛利计算和批次追溯都准确。
4.4 现象:数据库文件越来越大,查询越来越慢
原因是没有定期清理作废单据和长期无变动的批次记录,或者缺失索引。StockBatch表上ProductID和RemainQuantity应该建复合索引,SaleDetail上SaleID和ProductID建索引。作废的进货单和销售单不要物理删除,用Status标记,但可以定期归档到历史表。索引不是越多越好,写多的表索引多了拖慢插入,一般三到五个关键索引足够。
4.5 现象:SSMS 里执行存储过程报「事务计数不匹配」
原因是存储过程里开了事务但某条分支没有提交或回滚,常见于IF判断后直接RETURN而忘了ROLLBACK。解决方法是每个RETURN前都检查@@TRANCOUNT,大于 0 就回滚。更稳妥的写法是统一用BEGIN TRY...BEGIN CATCH包裹,CATCH里无条件回滚,TRY里最后提交。
5. 用 SQL-SERVER 代理作业做每日库存快照和毛利报表
5.1 为什么需要快照,而不是每次实时算
实时算库存汇总在数据量小的时候没问题,但超市经营一年后StockBatch和SaleDetail动辄几十万行,每次打开报表都全表扫描,体验很差。更好的做法是每天凌晨用 SQL-SERVER 代理作业跑一次快照,把当前库存和当日销售毛利存到汇总表里,报表直接查汇总表。这样既快又能保留历史,月底看趋势时不用重新算。
-- 每日库存快照表 CREATE TABLE DailyStockSnapshot ( SnapshotDate DATE NOT NULL, ProductID INT NOT NULL, TotalRemain INT NOT NULL, TotalCost DECIMAL(12,2) NOT NULL, PRIMARY KEY (SnapshotDate, ProductID) ); -- 快照存储过程 CREATE PROCEDURE sp_DailyStockSnapshot AS BEGIN SET NOCOUNT ON; DECLARE @Today DATE = CAST(GETDATE() AS DATE); MERGE DailyStockSnapshot AS target USING ( SELECT @Today AS SnapshotDate, SB.ProductID, SUM(SB.RemainQuantity) AS TotalRemain, SUM(SB.RemainQuantity * SB.CostPrice) AS TotalCost FROM StockBatch SB WHERE SB.RemainQuantity > 0 GROUP BY SB.ProductID ) AS source ON target.SnapshotDate = source.SnapshotDate AND target.ProductID = source.ProductID WHEN MATCHED THEN UPDATE SET TotalRemain = source.TotalRemain, TotalCost = source.TotalCost WHEN NOT MATCHED THEN INSERT (SnapshotDate, ProductID, TotalRemain, TotalCost) VALUES (source.SnapshotDate, source.ProductID, source.TotalRemain, source.TotalCost); ENDMERGE语句在 SQL-SERVER 里做「存在则更新,不存在则插入」很方便,但要注意并发场景下MERGE有已知的锁问题,代理作业是单线程跑,所以安全。@Today用CAST(GETDATE() AS DATE)取日期部分,避免时间戳干扰主键。快照表主键是日期加商品 ID,同一天重复跑会更新而不是重复插入。
5.2 代理作业怎么配,以及跑失败了看哪里
在 SSMS 里展开「SQL Server 代理」,右键「作业」新建,步骤里填EXEC sp_DailyStockSnapshot,计划设每天凌晨 2 点执行。配好后手动右键作业点「作业开始步骤」测试一次,看历史记录里有没有报错。常见失败原因是代理服务没启动,或者执行账户没有存储过程所在数据库的权限。跑失败先看作业历史里的错误消息,再到 Windows 事件查看器里看 SQL-SERVER 相关日志,多数是权限或磁盘空间问题。
5.3 毛利报表的 SQL 怎么写才不把成本算错
毛利等于销售收入减去销售成本,收入从SaleDetail的SalePrice * Quantity来,成本从扣减批次的CostPrice * Quantity来。如果前面建了SaleBatch关联表,这里直接 JOIN 就行。没有关联表的话,只能用商品当前进价近似,误差在进价波动大时很明显。下面这个查询假设有SaleBatch表。
SELECT CAST(SO.SaleDate AS DATE) AS SaleDate, P.ProductName, SUM(SD.Quantity) AS SaleQty, SUM(SD.Quantity * SD.SalePrice) AS Revenue, SUM(SB.Quantity * SB.CostPrice) AS Cost, SUM(SD.Quantity * SD.SalePrice) - SUM(SB.Quantity * SB.CostPrice) AS GrossProfit FROM SaleOrder SO INNER JOIN SaleDetail SD ON SO.SaleID = SD.SaleID INNER JOIN SaleBatch SB ON SD.SaleDetailID = SB.SaleDetailID INNER JOIN Product P ON SD.ProductID = P.ProductID WHERE SO.SaleDate >= @StartDate AND SO.SaleDate < DATEADD(DAY, 1, @EndDate) GROUP BY CAST(SO.SaleDate AS DATE), P.ProductName ORDER BY SaleDate, GrossProfit DESC;DATEADD(DAY, 1, @EndDate)处理结束日期,避免漏掉当天最后几秒的单子。GROUP BY里用CAST把时间截断到天,否则同一天不同时刻会分成多行。这个查询在SaleBatch有索引的情况下,几十万行数据也能秒出。
5.4 一个我踩过的坑:快照表主键顺序影响查询速度
DailyStockSnapshot主键是(SnapshotDate, ProductID),查某一天全部商品很快,查某个商品的历史趋势就慢,因为ProductID在第二列。后来我把主键改成(ProductID, SnapshotDate),趋势查询快了,但按天查全部商品又慢了。最终方案是保留(SnapshotDate, ProductID)主键,额外建一个(ProductID, SnapshotDate)的非聚集索引。索引不是免费的,插入时会慢一点,但快照表每天只插几千行,完全值得。这个取舍我纠结了一下午,最后用执行计划对比才定下来,希望帮到你。
本文还有配套的精品资源,点击获取