上次接了个小活儿给一家开了七八年的体育用品商店做一套管理软件。老板的需求很朴素能管商品、能记订单、月底能看出什么卖得好最好还能在库存不足时提醒他补货。预算不高、时间也紧我直接选了Python来做整套方案。这个项目我内部起名hx2767hx取了店名里欢喜体育的拼音首字母2767是老板自己给门店编的号文章里我就一直用这个代号称呼它。今天把这套东西从选型、数据库设计、业务逻辑到部署维护的完整过程整理出来给准备用Python做小型业务系统的朋友一个参考。这套方案适合三类人一是刚学完Python基础、想找一个完整项目练手的开发者二是准备给线下小店做定制系统的程序员三是想低成本搞定店铺管理的店主也可以拿这篇文章去和你的技术对接人沟通需求。整个项目用到的技术栈很朴素Python 3加Flask加SQLite加ECharts全免费、全开源一台普通电脑就能跑后续要迁到服务器也不难。下面我按照自己真实的开发顺序一步步拆开讲。1. 为什么用Python自己做而不是买现成的进销存1.1 现成软件看着省事用起来处处受制先说我为什么没有直接推荐老板去买市面上的进销存软件。市面上的产品功能确实全扫码、库存、会员、报表都有但问题也很集中订阅费按年收想加个自定义字段要额外花钱定制周期常常以月为单位。对一家小店来说这笔钱三年累积下来不少换来的是一个自己改不动、数据也不完全在手里的系统。更重要的是数据控制权。很多SaaS进销存的数据库在云端导出的Excel字段固定、格式受限老板想知道去年卖出多少双42码的跑鞋这种问题可能要从系统里导好几张表自己拼。而店铺的进销存数据是核心资产拿不到手里心里就不踏实。用Python自己做的系统里数据库就是本地一个文件随时备份、随时迁移想要什么报表就写什么SQL完全自己说了算。1.2 这个项目里几套方案的横向对比方案开发成本定制能力数据控制运行环境适合场景现成SaaS进销存低低在云端受限浏览器完全不想折腾系统Excel手工记账零中完全自主任意电脑单品少、单店、订单量低PythonFlaskSQLite中高完全自主一台普通电脑定制需求多、预算低Java/Spring全家桶高高完全自主服务器团队开发、大型连锁结论很明确这个规模的项目用Java有点杀鸡用牛刀用Excel撑不起订单明细和库存的自动联动而Python生态恰好卡在中间。Flask轻量、上手快SQLite零配置、文件即数据库对单店每天几百笔订单的场景完全够用。开发周期上我一个人一周多点就交付了第一版这速度用Java写光环境配置和项目骨架就得折腾两天。1.3 为什么选Flask而不是Django肯定有人会问Python里做Web为什么不用Django。我的理由很简单Django自带的Admin后台、ORM、模板系统确实强大但对这个项目来说太重了。一个只有十几个页面的小系统Django那套项目结构、配置文件和中间件机制反而成了学习负担。Flask足够灵活路由自己定义ORM需要就只引Flask-SQLAlchemy这一个组件代码结构清爽维护起来心智负担小很多。另外Python做这类业务系统还有一个隐藏优势生态里的零件全是现成的。Excel导入导出有pandas报表图片有matplotlib前端图表有ECharts商品图片处理有opencv-python每个模块我都不需要从轮子开始造。这套组合的开发效率在我的体感里比Java高出一倍不止。2. 环境准备从一台新电脑到项目跑起来2.1 安装Python并确认版本这套系统我建议直接用Python 3.10及以上版本主要原因是类型注解和语法糖更好用Flask的新版本对高版本Python的支持也更积极。安装过程本身不复杂去python.org下载对应操作系统的安装包安装时有一个选项非常关键务必勾选Add Python to PATH。很多人漏掉这一步装完之后在命令行敲python提示找不到命令其实就是PATH里没有。装完在终端验证一下python --version如果你电脑里有多个Python版本强烈建议用py命令来管理比如py -3.11指定版本启动。Windows用户尤其推荐这种方式能避免我明明装的是3.11结果python指向了3.8这种经典问题。2.2 VS Code里配置Python环境的几个关键点编辑器我推荐VS Code。配置Python开发环境时有几个步骤不能省顺序也不能乱。第一步装官方Python扩展。第二步在项目目录里创建虚拟环境python -m venv venv然后按CtrlShiftP输入Python: Select Interpreter选择刚才创建的venv目录下的python.exe。这一步如果漏了后面所有依赖都装上了代码里却import不到错得莫名其妙。第三步确认终端也激活了虚拟环境Windows下命令行前面会出现(venv)前缀。这里要立一个规矩依赖永远装在虚拟环境里别图省事直接pip install到全局。项目要迁移到别的电脑时只需要一个requirements.txt几分钟就能还原环境。2.3 项目依赖清单这个系统的依赖非常精简pip install flask flask-sqlalchemy pandas matplotlib openpyxl requests beautifulsoup4逐个说用途flask是Web框架flask-sqlalchemy是数据库ORM操作SQLite时不需要自己拼原生SQLpandas处理Excel批量导入matplotlib生成月度销售报表图片openpyxl是pandas读写Excel的底层引擎requests和beautifulsoup4是初始化商品库时抓公开数据用的如果只做本地录入也可以不装。如果你需要处理商品图片缩略图建议额外装一个opencv-python也就是大家常说的cv2。用它的resize函数做图片压缩非常直接比PIL顺手命令是pip install opencv-python3. 数据库建模先想清楚业务再动写代码的念头3.1 四张表撑起整个店铺系统我反复改过几版之后最终把核心收拢成了四张表products商品表、customers会员表、orders订单主表、order_items订单明细表。整个系统所有功能说白了都是对这四张表的增删改查。为什么订单要拆成主表和明细表两张因为一笔订单可能包含多个商品而每个商品的购买数量和成交价都不一样。如果只建一张订单表要么把整单商品存成一串JSON、完全没法做统计要么一条商品一行数据、又没法区分哪些行属于同一笔订单。拆成主表加明细表是经典的一主多明细结构orders保存一次购买行为的整体信息比如时间、应收总额、实收金额、属于哪个会员order_items保存这一单里逐条商品的数量、单价和成交价。3.2 products表的设计要点商品表是整个系统使用频率最高的表设计上一开始就要踩准几个点。贴一下结构CREATE TABLE products ( id INTEGER PRIMARY KEY AUTOINCREMENT, sku TEXT UNIQUE NOT NULL, name TEXT NOT NULL, category TEXT NOT NULL, brand TEXT, size TEXT, color TEXT, cost_price INTEGER NOT NULL, sale_price INTEGER NOT NULL, stock INTEGER NOT NULL DEFAULT 0, warn_stock INTEGER NOT NULL DEFAULT 5, image_path TEXT, status INTEGER NOT NULL DEFAULT 1, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP );注意我把价格字段全部设计成了INTEGER类型单位是分而不是用FLOAT。这可能是新手最容易忽略的一个点。浮点数在计算机里有天生的精度问题0.1 0.2计算出来往往是0.30000000000000004这种数用在金额上积少成多早晚出事。用整数分存价格展示的时候除以100转成元所有账目永远精确。warn_stock是库存预警阈值比如某款跑鞋低于5双就提醒补货sku是商品编码用来对应条形码或手工录入设成UNIQUE约束防止重复建档status用来做商品的上下架默认1表示在售下架改成0而不是真的删除商品记录这样历史订单才能关联上。3.3 订单、会员和明细表的建模逻辑订单主表的设计核心是金额分账要清晰CREATE TABLE orders ( id INTEGER PRIMARY KEY AUTOINCREMENT, order_no TEXT UNIQUE NOT NULL, customer_id INTEGER, total_amount INTEGER NOT NULL, discount_amount INTEGER NOT NULL DEFAULT 0, pay_amount INTEGER NOT NULL, status TEXT NOT NULL DEFAULT paid, remark TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP );total_amount是折扣前总额discount_amount是优惠金额pay_amount是顾客实际支付的金额三者关系就是total减去discount等于pay。把优惠金额单独存一行月底对账时才能说清楚这个月送了多少钱的优惠。会员表我只保留了核心字段手机号作为唯一索引外加姓名、积分、等级、注册时间。小店不需要做复杂的权限体系手机号就是登录名店员拿一个账号就能操作。订单明细表的建表语句CREATE TABLE order_items ( id INTEGER PRIMARY KEY AUTOINCREMENT, order_id INTEGER NOT NULL, product_id INTEGER NOT NULL, product_name TEXT NOT NULL, purchase_price INTEGER NOT NULL, quantity INTEGER NOT NULL, sub_total INTEGER NOT NULL );注意我在明细表里冗余存了一份product_name和purchase_price。这看起来有点违背第三方范式但实践上是必须的商品的名称和价格会改而历史订单里的信息应该保持成交当下的样子。如果只存product_id去关联商品表商品一改名几个月前的订单报表就全变样了。4. 商品数据从哪来批量导入、爬虫初始化与手工录入的取舍4.1 老板手里的Excel怎么快速入库这家体育用品商店的老板手里有一份用了好几年的Excel库存表几百个SKU要让他在网页上一条条重新录他肯定不干所以系统必须支持批量导入。我做了个上传Excel并解析的函数import pandas as pd def import_products_from_excel(file_path): df pd.read_excel(file_path) required [sku, name, category, sale_price, stock] if not set(required).issubset(df.columns): return {ok: False, msg: 缺少必要列请检查表头} errors [] count 0 for idx, row in df.iterrows(): try: product Product( skustr(row[sku]).strip(), namestr(row[name]).strip(), categorystr(row[category]).strip(), sale_priceint(round(row[sale_price] * 100)), cost_priceint(round(row.get(cost_price, 0) * 100)), stockint(row.get(stock, 0)), ) db.session.add(product) count 1 except Exception as e: errors.append(f第{idx 2}行导入失败: {e}) db.session.commit() return {ok: True, count: count, errors: errors}这段代码有两个处理细节值得学习。一是先把Excel里带小数的价格换算成分再整型规避浮点误差二是用try/except逐行捕获错误某一行数据有问题不能影响整批导入同时把失败的行号返回给前端老板对着原始Excel改起来效率高很多。4.2 爬虫在系统里的定位初始化工具而不是日常功能体育用品的SKU动辄几百上千商品名称、品牌、分类全靠手工录入确实累。在最开始建库时我用requests和BeautifulSoup抓过公开商品信息来自动填充数据技术上完全可行但有几个边界必须讲清楚。第一只访问公开页面遵守目标网站的robots规则控制请求频率绝不能给目标站点造成压力。第二商品图片涉及版权不能直接把别人的商品图拿来当系统图商用我的方案是把爬到的数据只作为文字素材参考图片让老板自己用手机拍。第三爬来的销售价格只能当参考实际售价必须由店主自己确认系统里提供导入后人工复核的流程。最终落地的方案是分两步走初始化时用公开数据集或合规抓取的分类信息把商品库架子搭起来日常运营则完全靠Excel导入和手工录入。爬虫在这个项目里的定位是搬运工不是核心功能写的时候控制好节奏一次请求间隔设置1到2秒几万个SKU也能在半小时内跑完。4.3 手工录入页面怎么防手误单条录入页面看起来简单但几个防错细节直接决定老板用起来顺不顺手。价格输入框以元为单位展示提交时在后端转成分并且校验防止用户填成负数或者填出三位小数库存数量必须是自然数用正则^\d$校验sku必填且不能重复重复时明确提示该商品编码已存在。如果装了opencv-python还能顺手加一个图片自动压缩用户上传一张4MB的鞋子照片后端用cv2的resize缩成800乘800的缩略图再保存到本地静态目录商品列表页加载照片就不会被大图拖慢。一个小改动体感提升很明显。5. 核心业务逻辑别小看购物车到订单闭环这一段5.1 购物车用Session实现别为它建表单店系统的购物车我直接用了Flask的session实现。session本质上是签名后的Cookie数据存在客户端不需要专门建表而且购物车天然是个临时概念用户关了浏览器购物车清空也合理不需要持久化。加购的逻辑很简单从session里读出一个字典key是商品idvalue是数量如果商品已在购物车就累加数量否则新增一项。展示购物车时再根据ids去数据库批量查商品信息组装成前端数据返回。这里有一个很容易踩的坑购物车里只应该存商品id和数量价格必须每次实时从数据库查。如果贪方便把价格也存进session用户在结算前商品一调价购物车页面显示的价格和下单时后端算出来的价格就对不上这会让老板觉得系统有Bug。统一用session只存id和数量、价格每次查数据库的方案可以从根上杜绝这个问题。5.2 下单结算与库存扣减必须在一个事务里整个系统最核心的代码就是下单接口。它要做的事按顺序排检查购物车非空遍历购物车里的商品判断是否在售、库存是否充足生成订单主表和明细表扣减库存清空购物车。这几步必须放在同一个数据库事务里任何一个环节失败整个下单过程都要回滚绝不能出现订单生成了但库存没扣这种状态。from sqlalchemy.exc import SQLAlchemyError def checkout(customer_idNone, remark): cart get_cart_from_session() if not cart: return {ok: False, msg: 购物车为空} try: order_no generate_order_no() customer db.session.get(Customer, customer_id) if customer_id else None total 0 items [] for product_id, qty in cart.items(): product db.session.get(Product, product_id) if not product or product.status ! 1: return {ok: False, msg: f商品ID {product_id} 不存在或已下架} if product.stock qty: return {ok: False, msg: f{product.name} 库存不足} subtotal product.sale_price * qty total subtotal items.append({ product_id: product.id, product_name: product.name, purchase_price: product.sale_price, quantity: qty, sub_total: subtotal }) product.stock - qty discount 0 if customer: discount int(total * 0.05) pay_amount total - discount order Order( order_noorder_no, customer_idcustomer_id, total_amounttotal, discount_amountdiscount, pay_amountpay_amount, remarkremark ) db.session.add(order) db.session.flush() for it in items: db.session.add(OrderItem(order_idorder.id, **it)) clear_cart_from_session() db.session.commit() return {ok: True, order_no: order_no, pay_amount: pay_amount} except SQLAlchemyError as e: db.session.rollback() return {ok: False, msg: f下单失败: {str(e)}}这段代码里每一处检查都是真实业务教训的沉淀。商品可能被下架库存可能在别人已经下单后不足了金额必须由后端根据数据库里的实时售价重新计算而不是信任前端传来的总价。很多系统被刷单问题就出在后端没有做这些二次校验。特别说一下db.session.flush()这一行。它的作用是先把order数据写入数据库的临时状态拿到自增的order_id这样后面的明细表才能正确关联主表。如果不调用flushorder.id还是None明细表外键就断了最终写入会直接报错。5.3 订单号生成和日常的对账口子订单号我采用的是日期加随机数的组合例如20250607153012后面再接4位随机数。加随机数是为了防止同一秒内并发下单撞单号。虽然小店并发量低但设计上把order_no设成UNIQUE约束生成时如果撞了就重新生成最多重试三次。日常对账时店主会看一个日结流水页面查询某一天所有订单统计总营收、总优惠、订单数量、客单价。SQL实现很简单按created_at过滤当天数据对pay_amount求和。店主每天下班前扫一眼这个页面就知道今天卖了多少、优惠送出去多少、客单价比昨天高还是低这个习惯一旦养成他对系统的依赖度就很高了。6. 可视化销售看板数据要比老板更懂生意6.1 销售趋势的JSON接口体育用品有明显的季节性跑鞋夏天卖得好羽毛球拍常年有固定人群健身器材在年初和年末是高峰。这种趋势光看Excel表格感受不到必须画成图。我在管理端加了三个核心口径按日销售额曲线、按品类销售额Top5、按商品销量Top10。看板前端用了ECharts后端只需要提供一个JSON接口app.route(/api/sales_trend) def sales_trend(): rows db.session.execute( text(SELECT strftime(%Y-%m-%d, created_at) as day, SUM(pay_amount) as amount FROM orders WHERE statuspaid GROUP BY day ORDER BY day) ).fetchall() return jsonify({ days: [r.day for r in rows], amounts: [r.amount / 100 for r in rows] })前端ECharts折线图几行代码就能画出来。做这类聚合统计时直接用原生SQL比走ORM更直观所以我在这里用text()执行SQL这是Flask-SQLAlchemy里非常常见的做法。订单状态过滤我们要注意只统计paid状态的订单退款单不能混进销售额。6.2 自动生成月度报表图片店主不是每天都打开电脑看网页所以我加了一个功能每周定时用matplotlib画一张报表图包含品类占比饼图和销量排行柱状图保存成PNG发到店里工作群。matplotlib画中文会乱码这是必踩的坑解决办法是显式指定中文字体import matplotlib.pyplot as plt plt.rcParams[font.sans-serif] [Microsoft YaHei, SimHei] plt.rcParams[axes.unicode_minus] False第一行的列表按操作系统会不一样Windows用Microsoft YaHeiLinux服务器上可以换成WenQuanYi Zen Hei或者Noto Sans CJK。第二行是让坐标轴的负号正常显示不设置的话负号会显示成方块。这两行不做报表图片里的中文全是豆腐块老板看到一定觉得这个系统做得很业余。6.3 给店主讲清楚报表怎么看做可视化不能光画图还得帮店主看懂图。我在页面上额外加了几个业务口径说明品类销售占比反映进货结构是否合理如果这家店跑步装备一类占比超过60%说明其他品类有压货风险商品销量Top10要和库存预警联动排名靠前但库存已经低于预警线的就是下一批采购必须补的货客单价如果连续两周下降就要排查是不是优惠活动太频繁或者导购的连带推荐没有做到位。这些分析口径不是系统自动生成的是我跟老板聊了好几天业务之后总结出来的。这也是定制系统比通用软件有价值的地方通用软件给你一堆图表不知道看哪个定制系统直接把结论摆在你面前。7. 上线之后最容易踩的坑我替你踩过一遍7.1 中文编码问题这是Windows上跑Python Web项目第一个会炸的地方。从Excel导入商品时如果文件里有中文非常容易出现编码错误。我的处理办法是读Excel时用openpyxl引擎它内部按XML的UTF-8解析基本不会出问题。但如果读取的是CSV文件就一定要显式指定encodingutf-8或者encodinggbk哪个不报错用哪个。数据库层面SQLite默认UTF-8Python侧只要统一编码问题不大。最容易忽略的是Web页面本身的Content-TypeFlask返回HTML时页面上一定要有meta charsetutf-8否则浏览器按系统默认编码渲染中文就会乱。7.2 SQLite并发写入和每日备份SQLite是文件型数据库不适合高并发写入。小店一天几百笔订单其实完全没压力但如果有两个窗口同一秒提交订单可能遇到database is locked。解决办法有两个方向一是所有写操作尽量放到同一个事务里缩短数据库锁占用时间二是设置连接超时create_engine(sqlite:///shop.db, connect_args{timeout: 15})这样并发写入最多等15秒而不是直接报错小店场景足够了。备份策略也要提前定好。我写了一个最简单的每日备份脚本核心是用SQLite自带的backup API而不是直接复制文件。直接文件复制在数据库正在写入时可能复制到不一致的状态用backup API才能拿到一致性的快照import sqlite3 from datetime import datetime src sqlite3.connect(shop.db) dst sqlite3.connect(fbackup/shop_{datetime.now():%Y%m%d}.db) src.backup(dst) dst.close() src.close()备份目录保留最近7天的版本然后定期手动把备份拷到另一块硬盘或者网盘里。7.3 金额计算里几个隐蔽的Bug前面说了价格用分存储但这个约束在后端所有涉及计算的地方都要守住。我实际遇到的一个问题是会员95折的处理订单总价如果算出来是9999分打95折后是9499.05分不能直接浮点运算要决定四舍五入还是向下取整。我当时定为向下取整把零头当优惠让利给顾客同时在订单里记录discount_amount。规则一旦定下来所有会员订单入口都得一致绝不能一个入口四舍五入、另一个入口直接截断。另一个容易掉的坑是退货逻辑。退货不是删记录而是把订单状态改成refunded恢复对应商品库存同时把退款金额记录到日报里。如果图省事直接删订单记录月底对账就永远对不上。7.4 交付前的三个验收动作系统交给店主正式使用之前我建议至少走三个验收流程。第一用店主真实的Excel文件跑一遍批量导入确认几百条数据的导入时间和错误提示都合理而不是只拿自己做的小样例测。第二连续模拟十笔订单覆盖正常购买、库存不足、会员折扣、商品下架四种情况全部走通确认不会出现订单生成了库存没扣、或者库存扣了订单没生成的情况。第三把系统日期临时改到月底跑一遍月度报表生成确认没有任何日期边界问题。这几个动作做完再让老板上手他第一天使用遇到的意外就会少很多你也少半夜接电话。整套系统做完说实话技术难度不高Python加Flask加SQLite的组合有经验的人三天就能把骨架搭出来。但这个项目真正花时间的地方在业务侧跟店主确认商品导入的Excel格式、理清楚会员折扣规则、把每个报表的口径讲明白这些才是决定项目交付质量的关键。如果你照这篇文章做类似的小店系统我的建议是开工前先别急着写代码把店里现有的Excel、手写记账本、微信转账记录认认真真看一遍把可能出现的每一种数据形态都列出来。数据模型设计对了后面的开发就是一马平川数据模型没想清楚改起来才是真正的噩梦。