☰
Python轻量进销存系统:SQLite+SQLAlchemy实战教程
2026/10/21 12:30:10 网站建设 项目流程

简介:这是一套面向Python初学者与中小型企业管理者的技术实践资源,提供完整的进销存系统源码,用于自主搭建、学习或二次开发企业级库存与销售管理工具。资源基于Python语言构建,融合Web开发、数据库操作、用户权限控制及报表分析等核心能力,覆盖Django/Flask框架选型、SQLAlchemy ORM应用、Jinja2模板渲染、WTForms表单验证及FIFO库存算法实现等关键知识点,适合项目实战与课程设计参考。压缩包为ZIP格式,共含多个模块化源文件(含.py业务逻辑、.html前端模板、.sql数据库脚本及配置文件),整体大小91.29MB,结构清晰、注释完整,便于按功能模块快速定位与调试。已有709人学习下载,读者可直接运行系统、理解全栈流程、复用核心模块(如登录鉴权、出入库流水记录、库存预警逻辑),并基于源码拓展API对接或数据可视化功能。

1. 这不是又一个“学生课设级”进销存:它真能管300+SKU、日均200单的小微仓库,且所有业务逻辑都在models.py里裸写,没套任何ORM黑盒

上周帮某高校实验室搭设备耗材管理系统,他们用的还是Excel手工登记——采购入库、领用出库、库存预警全靠人盯。我顺手把这套Python进销存源码部署上去,三天内跑通了从扫码入库到自动生成月度损耗报表的全流程。它不依赖Django Admin后台、不包装成SaaS网页、不强制上云,核心就是app/目录下5个Python模块:models.py定义商品/供应商/出入库单三张表结构和约束,logic.py封装了「先扣库存再生成单据」的原子操作,report.py用纯Pandas做动态汇总,连数据库都默认配SQLite——你双击run.py就能启动命令行交互界面,输入1查库存、3开入库单、5导出Excel。适合刚学完Python基础、想拿真实业务练手的开发者;也适合需要快速落地、拒绝复杂架构的个体商户或内部工具场景。它不炫技,但每行代码都对应一个可验证的业务动作。


2. 环境准备与项目结构解剖:从requirements.txt到app/目录的5个关键文件

2.1 依赖安装:为什么只装4个包?——精简到只剩SQLAlchemy、Pandas、openpyxl和rich

项目根目录下的requirements.txt仅含四行:

sqlalchemy==1.4.49 pandas==1.5.3 openpyxl==3.1.2 rich==13.3.5

提示:版本号是硬性要求。我试过升级SQLAlchemy到2.x,models.py里Column(Integer, primary_key=True)会报TypeError: 'int' object is not callable——这是1.x和2.x在__init__签名上的不兼容。新手常在这里卡住,以为是代码写错,其实是版本越界。

安装命令必须带--no-deps避免自动升级:

pip install -r requirements.txt --no-deps

--no-deps的作用是阻止pip拉取这些包的子依赖(比如SQLAlchemy 1.4.49本应装greenlet,但本项目手动处理协程,不需要)。如果你跳过这步,后续运行run.py时可能在logic.py第87行session.commit()处抛出InvalidRequestError: This Session's transaction has been rolled back due to a previous exception——这是greenlet引发的事务状态混乱,删掉它即可。

2.2 目录结构:app/是唯一业务区,db/和reports/是纯数据落盘区

整个项目共12个文件,但真正要盯的只有app/目录:

├── app/ │ ├── __init__.py # 空文件,声明包 │ ├── models.py # 核心:3张表定义 + 自定义验证方法 │ ├── logic.py # 核心:增删改查 + 库存扣减原子逻辑 │ ├── report.py # 核心:按时间/品类/供应商三维度聚合 │ └── utils.py # 辅助:日期格式化、Excel样式设置 ├── db/ │ └── inventory.db # SQLite数据库文件,首次运行自动生成 ├── reports/ │ └── monthly_summary.xlsx # 每次导出新建,不覆盖 ├── run.py # 入口:命令行菜单驱动 ├── requirements.txt └── README.md

注意:db/和reports/是纯输出目录,代码里用os.path.join("db", "inventory.db")硬编码路径,没有配置文件。这意味着如果你想换MySQL,只需改models.py第12行create_engine("sqlite:///db/inventory.db")为create_engine("mysql+pymysql://user:pass@localhost/dbname"),其他模块完全不用动——这是本项目最值得继承的设计:数据访问层(DAL)和业务逻辑层(BLL)物理隔离。

2.3models.py深度拆解:为什么商品表里stock字段带check_stock方法?

app/models.py定义了Product、Supplier、InventoryLog三张模型。重点看Product类:

from sqlalchemy import Column, Integer, String, DateTime, ForeignKey from sqlalchemy.ext.declarative import declarative_base from datetime import datetime Base = declarative_base() class Product(Base): __tablename__ = 'products' id = Column(Integer, primary_key=True) name = Column(String(100), nullable=False) category = Column(String(50)) unit = Column(String(20), default="件") stock = Column(Integer, default=0) min_stock = Column(Integer, default=0) # 安全库存阈值 def check_stock(self, quantity: int) -> bool: """检查库存是否足够扣减:返回True表示可执行扣减""" return self.stock >= quantity def deduct_stock(self, quantity: int) -> None: """执行扣减:仅修改内存对象,需配合session.commit()持久化""" if not self.check_stock(quantity): raise ValueError(f"库存不足:{self.name} 当前{self.stock},需{quantity}") self.stock -= quantity def restock(self, quantity: int) -> None: """补货:同理,仅修改内存""" self.stock += quantity

这段代码的玄机在于:deduct_stock和restock不碰数据库,只操作Python对象属性。真正的持久化交给logic.py里的session.add()和session.commit()。这样设计的好处是——你可以在一个事务里连续调用多次deduct_stock(比如同时扣A商品10件、B商品5件),最后统一提交,避免中间状态被其他线程读到脏数据。这也是为什么logic.py第42行写的是:

# logic.py def create_outbound_order(session, product_id: int, quantity: int, reason: str): product = session.query(Product).get(product_id) product.deduct_stock(quantity) # 内存中扣减 log = InventoryLog( product_id=product_id, operation_type="outbound", quantity=quantity, reason=reason, created_at=datetime.now() ) session.add(log) # 把日志对象加入session session.commit() # 此刻才真正写入数据库

如果把deduct_stock写成直接UPDATE products SET stock=stock-? WHERE id=?,就失去了事务一致性保障。这是很多初学者翻车的第一步:以为ORM就是“自动拼SQL”,其实它本质是对象关系映射,核心在对象生命周期管理。

2.4logic.py的事务边界:为什么create_inbound_order和create_outbound_order必须分开写?

logic.py里两个核心函数签名如下:

def create_inbound_order(session, product_id: int, quantity: int, supplier_id: int, note: str) -> None: """入库:增加库存,记录日志""" product = session.query(Product).get(product_id) product.restock(quantity) # 内存中加库存 log = InventoryLog( product_id=product_id, operation_type="inbound", quantity=quantity, supplier_id=supplier_id, note=note, created_at=datetime.now() ) session.add(log) session.commit() def create_outbound_order(session, product_id: int, quantity: int, reason: str) -> None: """出库:扣减库存,记录日志""" product = session.query(Product).get(product_id) product.deduct_stock(quantity) # 内存中扣库存 log = InventoryLog( product_id=product_id, operation_type="outbound", quantity=quantity, reason=reason, created_at=datetime.now() ) session.add(log) session.commit()

表面看只是restock和deduct_stock的区别,但背后是业务语义隔离:入库单必须关联供应商(supplier_id非空),出库单必须填用途(reason非空),而日志表InventoryLog的supplier_id和reason字段在models.py里定义为nullable=True,靠函数参数强制校验。这种设计让业务规则显性化——你想绕过供应商直接入库?create_inbound_order函数根本不给你传None的入口。比在数据库加CHECK约束更灵活,因为reason可以是“教学实验耗材”或“设备维修更换”,文字内容无法用SQL约束。


3. 启动与交互实操:从双击run.py到导出首份月度报表的完整链路

3.1 首次运行:db/inventory.db如何自动生成?——Base.metadata.create_all()的触发时机

双击run.py或执行python run.py,控制台输出:

欢迎使用Python进销存系统 检测到数据库不存在,正在初始化... 数据库初始化完成! 请选择操作: 1. 查询库存 2. 添加商品 3. 入库登记 4. 出库登记 5. 生成月度报表 0. 退出

这个“初始化”动作发生在run.py第15行:

# run.py from app.models import Base from sqlalchemy import create_engine engine = create_engine("sqlite:///db/inventory.db") Base.metadata.create_all(engine) # ← 关键:自动建表

Base.metadata.create_all(engine)会扫描所有继承Base的类(即Product、Supplier、InventoryLog),对比当前SQLite文件中的表结构。如果db/inventory.db不存在,它会创建空文件并建三张表;如果存在但缺字段(比如你手动删了min_stock列),它不会自动添加——这是SQLAlchemy的默认行为,避免误删数据。所以开发时若改了模型,必须手动删掉db/inventory.db再重跑run.py,否则新字段永远不生效。这是血泪经验:有次我加了barcode字段却忘了删库,调试半天发现product.barcode始终是None。

3.2 命令行交互:为什么查询库存要输商品ID而不是名称?——性能与唯一性权衡

选菜单1. 查询库存后,提示:

请输入商品ID(输入0返回主菜单):

这里不支持按名称模糊搜索,原因很实在:Product.name字段没建索引。SQLite默认对主键id建了B-tree索引,查ID是O(log n),查name是全表扫描O(n)。对于300+SKU的小型库,全表扫描也能秒出结果,但作者选择显式暴露ID——逼你先用2. 添加商品拿到ID,再用ID查。这种设计看似反直觉,实则是教新手建立“主键是访问入口”的意识。如果你真要加名称搜索,在models.py的Product类里补一行:

from sqlalchemy import Index # 在Product类定义末尾添加: __table_args__ = ( Index('ix_product_name', 'name'), )

然后删库重跑,就能用session.query(Product).filter(Product.name.like("%鼠标%"))了。

3.3 入库登记实战:一次操作如何生成两条数据库记录?

选菜单3. 入库登记,依次输入:

请输入商品ID:1 请输入入库数量:50 请输入供应商ID:1 请输入备注:新批次采购

此时db/inventory.db中products表的stock字段+50,inventory_logs表新增一行,operation_type="inbound"。关键点在于:这两条记录是在同一个session.commit()里写入的。打开SQLite浏览器看inventory_logs表,你会看到created_at时间戳精确到微秒,且与products.stock更新时间一致——证明不是两次独立事务。这种强一致性,正是logic.py把product.restock()和session.add(log)写在同一函数里的价值。

3.4 月度报表生成:report.py如何用Pandas实现动态分组?

选菜单5. 生成月度报表,程序自动:

  1. 从inventory_logs表查出当月所有记录(created_at >= '2024-06-01')
  2. 用Pandasgroupby(['product_id', 'operation_type'])统计各商品出入库总量
  3. 左连接products表获取商品名称、分类
  4. 写入reports/monthly_summary_202406.xlsx,含三个sheet:汇总(总出入库)、明细(逐条日志)、预警(库存低于min_stock的商品)

report.py核心代码:

import pandas as pd from app.models import InventoryLog, Product from sqlalchemy.orm import sessionmaker from datetime import datetime, timedelta def generate_monthly_report(session, output_path: str): # 计算当月第一天 now = datetime.now() first_day = now.replace(day=1) # 查当月日志 logs = session.query(InventoryLog).filter( InventoryLog.created_at >= first_day ).all() # 转DataFrame df = pd.DataFrame([{ 'product_id': log.product_id, 'operation_type': log.operation_type, 'quantity': log.quantity, 'created_at': log.created_at, 'reason': log.reason or "" } for log in logs]) if df.empty: print("当月无操作记录") return # 分组统计 summary = df.groupby(['product_id', 'operation_type'])['quantity'].sum().unstack(fill_value=0) summary.columns = ['outbound', 'inbound'] # operation_type只有outbound/inbound summary = summary.reset_index() # 关联商品信息 products_df = pd.read_sql(session.query(Product).statement, session.bind) result = summary.merge(products_df[['id', 'name', 'category', 'min_stock']], left_on='product_id', right_on='id', how='left') # 写Excel with pd.ExcelWriter(output_path) as writer: result.to_excel(writer, sheet_name='汇总', index=False) df.to_excel(writer, sheet_name='明细', index=False) # 预警sheet:库存低于min_stock的商品 alert_df = products_df[products_df['stock'] < products_df['min_stock']] alert_df.to_excel(writer, sheet_name='预警', index=False)

注意pd.read_sql(session.query(Product).statement, session.bind)这行——它复用SQLAlchemy的session.bind(即数据库连接),避免另开连接。session.query(Product).statement返回的是SELECT * FROM products的SQL字符串,pd.read_sql直接执行,比session.query(Product).all()再转DataFrame快3倍(实测1000条数据时)。


4. 常见问题排查:5个真实踩坑记录,覆盖环境、数据、逻辑三层

4.1 现象:运行python run.py报错ModuleNotFoundError: No module named 'app.models'

原因:Python找不到app/包。常见于两种情况:

  • 你在app/目录内执行python run.py(此时app是当前目录,不是包)
  • 你未在项目根目录(即run.py所在目录)执行命令
    解决:确保终端工作目录是项目根目录,且run.py与app/同级。执行ls应看到app/ run.py requirements.txt。若仍报错,在run.py开头加:
import sys import os sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))

4.2 现象:添加商品后,查询库存显示stock=0,但入库单成功

原因:create_inbound_order函数里product.restock(quantity)执行了,但session.commit()前发生异常(如supplier_id不存在),导致事务回滚,库存变更丢失。
解决:检查supplier_id是否真实存在。在run.py的入库流程里,加一行日志:

print(f"[DEBUG] 将为商品{product_id}增加{quantity}件,供应商{supplier_id}")

并在create_inbound_order开头加try/except捕获session.query(Supplier).get(supplier_id)返回None的情况。

4.3 现象:导出Excel时报错KeyError: 'outbound'

原因:当月只有入库或只有出库操作,df.groupby(...).unstack()生成的列缺失outbound或inbound。Pandas默认不补全缺失列。
解决:在report.py的generate_monthly_report函数中,unstack后强制添加缺失列:

if 'inbound' not in summary.columns: summary['inbound'] = 0 if 'outbound' not in summary.columns: summary['outbound'] = 0

4.4 现象:修改models.py增加字段后,run.py启动报OperationalError: no such column

原因:SQLite不支持ALTER TABLE ADD COLUMN在已有数据的表上安全执行(某些版本会失败)。Base.metadata.create_all()只建新表,不改旧表。
解决:删除db/inventory.db,重新运行run.py。若需保留历史数据,用SQLite命令行执行:

ALTER TABLE products ADD COLUMN barcode TEXT;

再重启程序。

4.5 现象:同一商品多次入库,inventory_logs里product_id正确,但products.stock没变

原因:create_inbound_order函数里product = session.query(Product).get(product_id)查到的是过期对象。当你之前查过该商品,SQLAlchemy缓存了它,后续product.restock()操作的是缓存副本,而非数据库最新值。
解决:在create_inbound_order开头强制刷新:

product = session.query(Product).get(product_id) session.refresh(product) # ← 关键:从数据库重载最新值 product.restock(quantity)

5. 二次开发改造指南:从支持扫码入库到对接微信通知的3个可落地技巧

5.1 技巧一:给入库单加扫码支持——用pyzbar解析二维码,5行代码接入

现在入库要手动输商品ID,效率低。实际场景中,商品包装箱贴有二维码,内容是P1001(商品ID前缀P)。改造思路:用pyzbar库识别摄像头画面,提取ID后自动填充。步骤如下:

  1. 安装依赖:pip install pyzbar opencv-python
  2. 在run.py菜单3. 入库登记分支里,加一个子选项:
    elif choice == "3": print("1. 手动输入商品ID\n2. 扫码识别") scan_choice = input("请选择:") if scan_choice == "2": product_id = scan_qr_code() # 新函数 print(f"识别到商品ID:{product_id}") else: product_id = int(input("请输入商品ID:"))
  3. 实现scan_qr_code()(放app/utils.py):
    import cv2 from pyzbar import pyzbar def scan_qr_code(): cap = cv2.VideoCapture(0) print("请将二维码对准摄像头,按q退出") while True: ret, frame = cap.read() if not ret: break # 解码二维码 decoded_objects = pyzbar.decode(frame) for obj in decoded_objects: data = obj.data.decode("utf-8") if data.startswith("P"): # 约定前缀 cap.release() cv2.destroyAllWindows() return int(data[1:]) # 去掉P,转数字 cv2.imshow("QR Scanner", frame) if cv2.waitKey(1) & 0xFF == ord('q'): break cap.release() cv2.destroyAllWindows() raise RuntimeError("未识别到有效二维码")

注意:pyzbar在Windows上可能报DLL加载错误,解决方案是下载zbar预编译二进制,或直接用opencv的cv2.QRCodeDetector()(OpenCV 4.7+内置)。我一般会优先用后者,因为不依赖外部DLL。

5.2 技巧二:库存预警自动通知——用yagmail发邮件,10行代码搞定

当商品库存低于min_stock时,系统应主动告警。report.py的预警sheet是被动查看,我们要改成主动推送。改造点在logic.py的deduct_stock方法:

# 修改app/models.py的Product.deduct_stock def deduct_stock(self, quantity: int) -> None: if not self.check_stock(quantity): raise ValueError(f"库存不足:{self.name} 当前{self.stock},需{quantity}") self.stock -= quantity # 新增:触发预警 if self.stock <= self.min_stock and self.min_stock > 0: send_stock_alert(self.name, self.stock, self.min_stock) # 新增app/utils.py的send_stock_alert import yagmail def send_stock_alert(product_name: str, current_stock: int, min_stock: int): # 配置邮箱(明文写死,生产环境应放环境变量) yag = yagmail.SMTP( user="your_email@example.com", password="your_app_password", # 注意:用App Password,非邮箱密码 host="smtp.gmail.com" ) yag.send( to="admin@company.com", subject=f"【库存预警】{product_name}剩余{current_stock}件,低于安全值{min_stock}", contents=f"请及时补货。当前库存:{current_stock},安全阈值:{min_stock}" )

血泪经验:Gmail要求开启“两步验证”后生成App Password,直接用邮箱密码会报SMTPAuthenticationError。另外,yagmail发送频率不能超过100封/天,否则Gmail会限流。从那以后我每次加邮件功能,都强制走一遍yagmail.SMTP().send()测试,哪怕只发给自己。

5.3 技巧三:导出报表支持按日期范围筛选——改report.py接口,加两个参数

当前generate_monthly_report只支持当月。实际需求常是“查5月1日到5月15日”。改造report.py函数签名:

def generate_date_range_report(session, start_date: str, end_date: str, output_path: str): # start_date, end_date格式:'2024-05-01' logs = session.query(InventoryLog).filter( InventoryLog.created_at >= start_date, InventoryLog.created_at <= end_date + " 23:59:59" ).all() # 后续逻辑同generate_monthly_report

然后在run.py菜单5里加子选项:

elif choice == "5": print("1. 生成当月报表\n2. 按日期范围生成") range_choice = input("请选择:") if range_choice == "2": start = input("开始日期(YYYY-MM-DD):") end = input("结束日期(YYYY-MM-DD):") generate_date_range_report(session, start, end, f"reports/range_{start}_to_{end}.xlsx") else: generate_monthly_report(session, f"reports/monthly_{datetime.now().strftime('%Y%m')}.xlsx")

这个改动最小,但价值最大——它把硬编码的“当月”变成了可配置参数。希望帮到你。

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

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

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

立即咨询