C语言商品进销存管理系统实战:结构体、文件读写与DevC++调试全解析
2026/9/17 1:54:27 网站建设 项目流程

简介:这是一份面向 C 语言初学者的商品进销管理系统课程设计资源,围绕进货、销售、库存增减与销售金额计算等核心业务,设计了输入、显示、增加、查找、排序、统计六项功能,并通过菜单方式引导用户操作,适合课程设计、期末作业或基础项目练手。压缩包共 6 个文件,包含 DevC 工程文件、C 语言源码、可执行程序、Makefile 辅助配置等,整体仅 49KB,轻量易用,下载后可直接打开工程或运行 exe 查看效果。目前已有 106 人学习使用。资源在源码层面保留了完整的项目结构与核心算法,读者可借此学习结构体数组管理商品信息、日期区间统计、按数量排序等写法,也能体会从需求分析、模块划分到编码调试的软件过程,是巩固 C 语言综合能力的实用参考。

1. C语言商品进销管理系统到底在练什么

很多人在学完C语言基础后都会找带菜单的管理系统来练手。商品进销管理系统就是其中最典型的一种:它没有复杂算法,却能把结构体、函数、文件读写、循环菜单这些知识点全部串起来。更关键的是,进销存天然包含三个容易出错的业务场景——进货要加库存,销售要减库存,单价和数量的金额计算不能想当然。用DevC写这个项目的人,往往代码写完才发现中文乱码、控制台闪退、文件存不上,这不是业务逻辑问题,而是环境与工程方法的问题。这里就从实际可运行的C代码出发,从数据结构、核心业务流程、文件持久化到DevC调试技巧,把这个项目完整拆开。适合正在做课程设计,或者想用一个小项目检验指针和文件操作功底的C语言学习者。

2. 先定数据结构与代码文件划分

2.1 用结构体定义商品信息

商品进销存的核心对象是“商品”。一个商品在程序里至少需要编号、名称、进货价、售价、库存、销量这六个属性。在C语言里,这组属性必须用结构体(struct)放在一起,否则函数之间传递参数会非常痛苦。

#define MAX_GOODS 500 #define NAME_LEN 50 typedef struct { int id; // 商品编号,全局唯一 char name[NAME_LEN]; // 商品名称,不含空格 double costPrice; // 进货价 double salePrice; // 售价 int stock; // 当前库存数量 int sold; // 累计销售数量 } Goods;

这段代码里,doublefloat更稳妥,因为进销存经常涉及单价和金额,float在多次累加后会出现明显误差。NAME_LEN定义为50,是给字符串末尾的\0留出空间,商品名最长只能49个字符。stocksoldint足够,课程设计场景不太可能超过21亿,但如果做企业级原型,考虑改成long long

结构体定了之后,要决定用数组还是链表。常见做法是用全局数组Goods goods[MAX_GOODS]和计数器int total。这个方案的优势是简单:数组元素连续存放,排序用qsort直接完成,查找用循环下标。链表在删除和插入时不需要搬移元素,但这个项目里删除并不频繁,没必要为了展示指针而引入额外的内存管理负担。

2.2 按业务模块拆函数,而不是让main承载一切

不少初学者会把菜单循环和所有业务写在一个200行的main里,这样做在DevC里编译没问题,但一旦某个功能出错,调试定位就非常慢。更合理的做法是按操作类型拆成独立函数,让main只做菜单分发。

下表列出了这个项目最基础的函数模块划分:

模块函数原型职责
文件读写int saveToFile(Goods goods[], int total)把商品数组写入文件
文件读写int loadFromFile(Goods goods[], int *total)程序启动时读回数据
商品管理int addGoods(Goods goods[], int *total)新增商品,检查编号重复
商品管理int findById(Goods goods[], int total, int id)按编号查数组下标
进销存void purchaseGoods(Goods goods[], int total)进货,增加库存
进销存void sellGoods(Goods goods[], int total)销售,减库存并累计销量
查询统计void showAll(Goods goods[], int total)显示全部商品
查询统计void showRank(Goods goods[], int total)按销量排序

findById是一个辅助函数,进货和销售都要用到,单独拆出来可以避免在两个地方写相同的for循环。实际写项目时,我一般还会加一个modifyGoods用于修改价格,但那是后话,先把这个骨架跑通最重要。

2.3 数组容量与全局数据传递的边界

使用数组方案时,MAX_GOODS就是系统的硬上限。500个商品对课程设计完全够用,但你要在每次新增前判断总数量是否越界。更完整的做法是把total封装成文件头字段,每次保存时先写总数量,读文件时先读这个数量,再循环读取商品数据。

这里有一个DevC初学者容易忽略的点:函数参数里写Goods goods[]Goods *goods是等价的,都退化成指针。所以在addGoods内部执行goods[*total] = g; (*total)++;可以直接修改外部数组和计数变量。如果只把total的值传进函数,函数内部改变total却不会影响外部,这是C语言新手最容易犯的错,也是这个项目的重要考点。

3. 把进销存核心操作写成可复现的C函数

3.1 商品录入:先查重再写入

addGoods看起来只是scanf几个值,但处理不好会留下很多坑。先看基本版本:

int addGoods(Goods goods[], int *total) { if (*total >= MAX_GOODS) { printf("当前商品数量已达上限 %d\n", MAX_GOODS); return 0; } Goods g; printf("请输入商品编号: "); scanf("%d", &g.id); for (int i = 0; i < *total; i++) { if (goods[i].id == g.id) { printf("该编号已存在,录入失败\n"); return 0; } } printf("请输入商品名称(不含空格): "); scanf("%s", g.name); printf("请输入进货价: "); scanf("%lf", &g.costPrice); printf("请输入售价: "); scanf("%lf", &g.salePrice); g.stock = 0; g.sold = 0; goods[*total] = g; (*total)++; printf("录入成功,当前共有 %d 种商品\n", *total); return 1; }

这段代码里,最容易被忽略的是goods[*total] = g后面的(*total)++。因为total是指针,*total自增必须加括号;写成*total++会先对指针移动再取值,属于隐藏的越界写操作。scanf("%lf", ...)对应double,不能写成%f,否则大数读取会变成0.000000。

scanf("%s", g.name)不会读取空格,因此商品名如果叫“牛奶 250ml”,只能读到“牛奶”。想彻底解决,需要换成fgets,但fgets会吞掉回车,要和scanf混用就得手动清缓冲。我的建议是这个项目就统一用scanf("%s"),商品名只放连续无空格的短词,把精力放在业务逻辑上。

3.2 进货入库:库存累加并更新成本价

进货模块要区分两个概念:修改商品信息里的“统一改价”和进货时的“增量入库”。

void purchaseGoods(Goods goods[], int total) { int id, qty; printf("输入进货商品编号: "); scanf("%d", &id); int idx = findById(goods, total, id); if (idx == -1) { printf("商品不存在\n"); return; } printf("输入进货数量: "); scanf("%d", &qty); if (qty <= 0) { printf("进货数量必须大于 0\n"); return; } goods[idx].stock += qty; printf("进货成功,商品 [%s] 当前库存 %d\n", goods[idx].name, goods[idx].stock); }

进货时是否需要更新costPrice?取决于你的业务口径。如果每次进货单价不同,但想让利润计算使用最新成本价,那就在进完货后用新的进货价覆盖goods[idx].costPrice。课程设计通常只要求“数量增加”,所以我这里不覆盖价格,避免产生“库存数量变了,成本均价没变”的困惑。想要更严谨,应该维护一个totalCost字段,进销存行业里叫移动加权平均,但那是大项目的事,C语言课程设计不一定要做。

这里的findById是整个系统的复用重点:

int findById(Goods goods[], int total, int id) { for (int i = 0; i < total; i++) { if (goods[i].id == id) { return i; } } return -1; }

选择返回下标而不是返回指针,是因为后续很多函数既需要读写结构体,又需要显示“第几条记录”,回到数组下标会更直观。

3.3 销售出库:库存不足必须拦截

销售是进销存的核心,也是最容易出逻辑漏洞的地方。

void sellGoods(Goods goods[], int total) { int id, qty; printf("输入销售商品编号: "); scanf("%d", &id); int idx = findById(goods, total, id); if (idx == -1) { printf("商品不存在\n"); return; } printf("输入销售数量: "); scanf("%d", &qty); if (qty <= 0) { printf("销售数量必须大于 0\n"); return; } if (qty > goods[idx].stock) { printf("库存不足,当前库存 %d,销售失败\n", goods[idx].stock); return; } goods[idx].stock -= qty; goods[idx].sold += qty; printf("销售成功,销售额 %.2f 元\n", qty * goods[idx].salePrice); }

注意销售额的计算发生在库存扣减之后,用qty * salePrice临时算,不需要单独加一个字段。还需要留意执行顺序:先校验,再修改数据。如果先做了stock -= qty发现库存变负数再想恢复,就会引入更多状态位。另一个容易错的地方是如果销售后要记录“一条销售流水”,你得再准备一个SaleRecord结构体数组,这里不展开,但对于想提高评分的人来说是一个很好的加分项。

3.4 用qsort快速实现销量排行

查询商品时最常见的需求是按销量降序排列。C标准库的qsort能在O(n log n)内完成排序,比自己写冒泡更专业。

int cmpBySold(const void *a, const void *b) { const Goods *ga = (const Goods *)a; const Goods *gb = (const Goods *)b; return gb->sold - ga->sold; } void showRank(Goods goods[], int total) { Goods tmp[MAX_GOODS]; for (int i = 0; i < total; i++) { tmp[i] = goods[i]; } qsort(tmp, total, sizeof(Goods), cmpBySold); printf("%-5s %-12s %-8s %-8s\n", "编号", "名称", "销量", "销售额"); for (int i = 0; i < total; i++) { printf("%-5d %-12s %-8d %.2f\n", tmp[i].id, tmp[i].name, tmp[i].sold, tmp[i].sold * tmp[i].salePrice); } }

这里的技巧是复制一份数组再排序,避免原始数组顺序被破坏。你可能会问为什么不用goods直接排?如果后面还要按编号精确查找,排序后的数组会让二分查找失效;而课程设计往往在菜单中先显示再操作,顺序错乱会让用户以为程序丢了数据。复制数组在500个商品规模下毫无性能压力,换取的是逻辑简单。

4. 文件持久化:把数据写进txt并在启动时加载

4.1 文本文件比二进制文件更适合DevC课程设计

进销存系统关闭程序后,数据不能每次都从main里重新录入。C语言持久化有两种常见选择:文本文件和二进制文件。

对比项文本文件(.txt/.dat)二进制文件(.bin)
可读性用记事本能直接看到商品内容打开是乱码,需要程序读回
跨编译器兼容格式统一,DevC和VS都能读受结构体字节对齐影响
调试难度出错时容易定位第几行需要借助hexdump等工具
推荐程度课程设计首选企业级原型更合适

文本文件还有一个额外优势:写试卷或者答辩时,老师可以直接打开你的数据文件,检查价格和库存是否正确。这是课程设计评分的一个重要加分细节。

4.2 saveToFile与loadFromFile的成对实现

保存数据时要先写总条数,再逐条写商品信息。读取时严格按相同顺序读回。

#define DATA_FILE "goods.txt" int saveToFile(Goods goods[], int total) { FILE *fp = fopen(DATA_FILE, "w"); if (fp == NULL) { perror("保存文件失败"); return 0; } fprintf(fp, "%d\n", total); for (int i = 0; i < total; i++) { fprintf(fp, "%d %s %.2f %.2f %d %d\n", goods[i].id, goods[i].name, goods[i].costPrice, goods[i].salePrice, goods[i].stock, goods[i].sold); } fclose(fp); return 1; } int loadFromFile(Goods goods[], int *total) { FILE *fp = fopen(DATA_FILE, "r"); if (fp == NULL) { printf("找不到数据文件,将按空数据启动\n"); *total = 0; return 0; } if (fscanf(fp, "%d", total) != 1) { printf("数据文件格式错误\n"); fclose(fp); return 0; } for (int i = 0; i < *total; i++) { int r = fscanf(fp, "%d %s %lf %lf %d %d", &goods[i].id, goods[i].name, &goods[i].costPrice, &goods[i].salePrice, &goods[i].stock, &goods[i].sold); if (r != 6) { printf("数据损坏,读取到第 %d 条时中断\n", i + 1); *total = i; break; } } fclose(fp); return 1; }

注意fopen时选择"w"会直接截断原文件,所以保存操作必须有明确的用户确认。fprintf%.2f限制了单价只保留两位小数。读取时检查fscanf的返回值是否为6,这是防止用户手动改坏文件后程序不崩溃的关键;没这个检查,文件最后一行缺字段时会读到goods[i].name的随机值,后续扣库存会越界。

还有一个藏在细节里的坑:fscanf("%s")读取同样不能含空格,所以文件名中尽量避免空格。如果坚持要带空格的名称,格式要改成用|分隔,再用fgets按行解析,代码复杂度会明显上升。

4.3 DevC在调试时找不到文件:工作目录问题

很多人写完读写逻辑后,在DevC里按F11运行发现fopen返回NULL,但到资源管理器里双击exe就能运行。原因是DevC运行时,程序的工作目录通常是项目里的.exe所在目录,或者DevC的默认临时目录,不一定是你的代码文件目录。

用相对路径"goods.txt"时,文件会被创建在当前工作目录下。最简单的排查办法是在程序启动时打印当前工作目录:

#include <direct.h> void showWorkDir() { char buffer[256]; if (getcwd(buffer, sizeof(buffer)) != NULL) { printf("当前工作目录: %s\n", buffer); } }

main最开始调用showWorkDir(),然后去这个目录找goods.txt。如果文件确实生成在这里,说明程序本身没有错,是之前找错了地方。另一种做法是把DATA_FILE改成绝对路径,比如"C:\\temp\\goods.txt",但这会让交作业时路径失效,所以更推荐在代码里维持相对路径,运行时按打印的目录去操作系统里核对。

5. DevC++调试桌面,用 5 个技巧收住边界

5.1 中文乱码不是逻辑bug,是执行字符集问题

DevC的控制台窗口在中文Windows下默认使用GBK编码,而新版MinGW编译器默认把源码按UTF-8处理,导致printf("商品")输出乱码。解决方法是打开DevC的“工具 - 编译器选项 - 编译器”,在编译器命令行加入-fexec-charset=GBK,然后重新编译。这条参数指示编译器在生成的可执行文件里把窄字符串转成GBK编码,能解决中文乱码却不影响代码里的UTF-8注释。

如果你把源码文件另存为ANSI编码(GBK),不加强制参数也能正常输出,但换到Linux服务器上用gcc编译就会乱,所以学习阶段最好理解这两种路径的区别。建议保留UTF-8源码,只加编译参数,这样跨平台时改动最小。

5.2 用fopen返回值与assert夹住输入边界

开发阶段,可以在容易越界的位置加断言,让问题在发生时立刻暴露。比如addGoods写完后加一行:

assert(*total <= MAX_GOODS);

这行代码在发布版里可以用#define NDEBUG关掉。assert比单纯printf强的地方是,它同时打印出错的源文件名和行号,便于从DevC的调试输出窗口直接定位。对指针的判断也可以用断言:assert(goods != NULL);。要注意断言失败时程序会中断,这不是崩溃,是在提醒你上游逻辑越界。

5.3 用一张测试表验证库存和销售额是否正确

系统写完,别急着交。我习惯手工设计一组覆盖边界的数据,按顺序执行:

操作输入期望结果
新增商品A编号1,名称cola,进价2.0,售价3.0库存0,销量0
进货商品1,数量10库存10
销售商品1,数量12提示库存不足,销量不变
销售商品1,数量4库存6,销量4
再次销售商品1,数量6库存0,销量10
保存退出-goods.txt中库存0,销量10
重新打开-读取后库存0,销量10

写这样一份清单放在项目文档里,能让答辩演示更有说服力。尤其要测试“库存不足”这个分支,它证明你考虑了业务异常,而不仅仅是正常路径。

5.4 用scanf缓冲残留的教训

scanf混合输入数字和字符串时,回车键会留在缓冲区,导致下一次scanf("%s")读到空串。常见修法是在每次scanf后加while (getchar() != '\n');。但注意这个循环不适用于文件末尾没有换行的情况。在这个项目里,菜单通常用数字选择,输入非法字符时scanf会失败并陷入死循环,可以这样兜底:

if (scanf("%d", &choice) != 1) { while (getchar() != '\n'); printf("请输入数字\n"); continue; }

choice读取成功后,continue会回到主菜单顶部,不会因为非数字输入把程序拖进死循环。这个项目里所有涉及scanf的地方,都应该按同样的方式检查返回值,而不是假设用户一定会老实输入。之后再结合5.3的测试表,把每一组数据从新增到销售再到文件落盘完整走一遍,这套C语言商品进销管理系统就算真正能在DevC里稳定运行了。

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

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

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

立即咨询