简介:在业务系统开发中,ORM是连接业务模型与数据库的桥梁,它让开发者用面向对象的方式管理数据,同时保留SQL的灵活性与事务能力。以Django框架为例,进销存系统正是一类对数据一致性要求极高的典型业务场景:商品SKU与库存分离、库存流水记录每一次变动、采购与销售通过事务保证原子性。理解这些底层设计,不仅能看懂开源项目,更能避免高并发下库存超卖、报表数据错位等工程陷阱。本文从环境搭建与数据库接入讲起,逐步拆解三大核心模型、入库出库闭环、毛利统计与低库存预警的ORM实现,最后汇总常见排错路径与种子数据交付技巧。无论你是准备二次开发还是学习Django建模,这套方法论都能帮你快速掌握可落地的进销存系统实现思路。1. 用Django做进销存:这套源码到底值不值得你花时间拆拿到一个“基于Django商品销售进销存系统源码+数据库文件”的压缩包,第一反应别急着解压。先想清楚:Django做进销存(采购、销售、库存、报表)在中小体量业务里是非常成熟的选择,ORM帮你在业务模型和数据库之间搭桥,Admin后台甚至能秒出基础管理界面。这类标着“高分项目”的交付物,往往是教学场景下的完整范例,意味着它该具备三层价值:架构能看懂、流程能跑通、数据能复现。我拆过的类似项目里,最值钱的从来不是登录注册和商品列表,而是库存流水、订单状态机、毛利统计这几块硬逻辑。本篇就把这条链路完整捋一遍——从环境跑通,到数据模型设计,再到库存与报表的SQL/ORM实现,最后落在数据库迁移和排错上,让你照着能复现,改着能落地。2. 先把项目拉起来:从zip到本地可运行的最小路径2.1 环境准备与依赖解析:Python版本为什么是第一道门槛这类Django项目通常自带requirements.txt,但它不会告诉你Python版本多重要。实测中最高频的翻车点就是Python 3.12跑Django 3.2,直接报AttributeError: module 'collections' has no attribute 'Iterable'——这是类型注解向后兼容炸了。我的建议是:优先用Python 3.10或3.11,Django版本先看项目里的django字段,老项目常见2.2和3.2,新一点的会跟4.x。虚拟环境里安装依赖,命令照抄:# 创建虚拟环境(Windows/Linux通用) python -m venv venv source venv/bin/activate # Windows下用 venv\Scripts\activate # 安装依赖 pip install -r requirements.txt # 确认版本 python -m django --version逻辑说明:venv隔离项目依赖,避免污染全局Python环境。requirements.txt里的Django版本是天花板约束,如果安装时pip自动给你拉了个更高版本导致兼容问题,可以用pip install "Django==3.2.25"锁定。参数说明:这里最关键的其实是source venv/bin/activate这一步,不少新手会跳过它直接pip install,装完才发现包进不了项目。另外,老项目可能缺mysqlclient或psycopg2-binary,如果依赖文件里没有,又使用的是SQLite,那这两个包根本不需要,别多装。2.2 数据库文件怎么接:SQLite版的和MySQL版的要区别对待项目标题里写着“数据库文件”,常见有两种:.sqlite3和.sql。.sqlite3是SQLite数据库文件,直接放到项目根目录,然后修改settings.py里的DATABASES配置:DATABASES = { 'default': { 'ENGINE': 'django.db.backends.sqlite3', 'NAME': BASE_DIR / 'db.sqlite3', # 确保文件名和实际存放的一致 } }逻辑说明:SQLite是文件型数据库,Django的ORM会直接操作这个物理文件。重点在于NAME字段的路径是BASE_DIR下的相对路径,如果你把数据库文件放错了目录,或者大小写对不上,Django会在migrate时重新生成一个空库,而不是报错——这就是坑:项目跑起来了,但所有数据都没了。如果是.sql导出文件,那属于MySQL/PostgreSQL的备份,需要先建库再导入:# 以MySQL为例 mysql -u root -p -e "CREATE DATABASE shop_db CHARACTER SET utf8mb4;" mysql -u root -p shop_db < database.sql导入后再把settings.py改成MySQL配置:ENGINE换成django.db.backends.mysql,NAME填shop_db,再补USER、PASSWORD、HOST、PORT。这里有个血泪经验:老项目的SQL导出文件里可能带着SET FOREIGN_KEY_CHECKS=0这类会话级操作,导入时终端会刷一屏警告,只要最后SELECT COUNT(*) FROM auth_user;能查出数据,就不用管警告。2.3 跑起来要过四关:迁移、静态文件、超级用户和Admin界面验证这四关是跑通任何Django项目的通用顺序,但进销存项目多了一个高频卡点:迁移文件与数据库文件版本不匹配。# 第1关:生成并执行迁移 python manage.py makemigrations python manage.py migrate # 第2关:创建管理员账号 python manage.py createsuperuser # 第3关:启动开发服务器 python manage.py runserver逻辑说明:makemigrations会扫描models.py的变化生成迁移文件,如果项目作者已经提供了migrations目录,并且数据库文件是配套的,这两条命令往往只需要执行migrate——直接makemigrations反而会因为检测到“模型和迁移不一致”而报警。注意控制台输出里有没有No changes detected,有就说明迁移状态正常。createsuperuser之后,Django Admin就是进销存系统的“免费后台”。进销存这类项目的Admin往往注册了商品、供应商、采购单、销售单这些模型,你可以直接在/admin/里看到数据表内容。这一关过了,系统才算真正“活”了。参数说明:runserver默认跑在8000端口,如果端口被占用,python manage.py runserver 0.0.0.0:8001可以换端口。另外,migrate执行前建议先备份原有数据库文件,因为如果迁移和数据库文件冲突,Django会尝试改表结构,失败时可能损坏SQLite文件。3. 进销存的骨架:三大核心模型怎么设计才不返工3.1 商品表和库存表:SKU与汇总库存分离的必要性看一个进销存项目水平高低,先看商品模型。初级项目会把“商品名称”“库存数量”直接怼在同一张表里,高级项目一定做了SKU与库存的分离。常见做法是这样设计:from django.db import models class Product(models.Model): """商品基本信息——不含库存字段的SKU表""" sku = models.CharField('SKU编码', max_length=64, unique=True) name = models.CharField('商品名称', max_length=128) brand = models.CharField('品牌', max_length=64, blank=True) category = models.CharField('分类', max_length=64, blank=True) sale_price = models.DecimalField('销售价', max_digits=10, decimal_places=2) purchase_price = models.DecimalField('采购价', max_digits=10, decimal_places=2) is_active = models.BooleanField('上架', default=True) created_at = models.DateTimeField('创建时间', auto_now_add=True) class Meta: db_table = 'product' indexes = [ models.Index(fields=['sku']), models.Index(fields=['category']), ] class Inventory(models.Model): """库存汇总表——与SKU一对一""" product = models.OneToOneField(Product, on_delete=models.CASCADE, primary_key=True) quantity = models.IntegerField('当前库存数', default=0) safety_stock = models.IntegerField('安全库存', default=0) updated_at = models.DateTimeField('最后变动时间', auto_now=True) class Meta: db_table = 'inventory'逻辑说明:把售价、采购价放在Product表,把可变的数量放进Inventory表,好处是商品资料相对静态,库存是高频变动字段,拆开后行锁竞争小、缓存命中率高。OneToOneField用主键关联,是为了保证一个SKU只有一行库存记录,查询时直接走主键索引。参数说明:max_digits=10, decimal_places=2是钱的标准配置,能存到千万级金额不丢精度。为什么不用FloatField,因为浮点数算钱会有0.1+0.2不等于0.3的经典翻车;DecimalField在Django ORM层会映射成数据库的DECIMAL或SQLite的numeric,计算更稳。索引方面,sku建唯一索引是必须的,category的索引是为了分类汇总报表。3.2 库存流水表:所有库存变动的唯一真相来源真正高阶的进销存项目,库存表上的数量只是“结果”,而“过程”全在流水表里。设计逻辑是这样的:class StockFlow(models.Model): """库存流水——所有入库、出库、退货、盘点操作的明细记录""" FLOW_TYPES = ( ('IN', '采购入库'), ('OUT', '销售出库'), ('RETURN', '退货'), ('ADJUST', '盘点调整'), ) product = models.ForeignKey(Product, on_delete=models.PROTECT) flow_type = models.CharField('流水类型', max_length=10, choices=FLOW_TYPES) quantity = models.IntegerField('变动数量(正负号表示方向)') before_quantity = models.IntegerField('变动前库存') after_quantity = models.IntegerField('变动后库存') order_no = models.CharField('关联单据号', max_length=64, blank=True) remark = models.CharField('备注', max_length=255, blank=True) operator = models.CharField('操作人', max_length=32, blank=True) created_at = models.DateTimeField('发生时间', auto_now_add=True) class Meta: db_table = 'stock_flow' ordering = ['-created_at'] indexes = [ models.Index(fields=['product', 'created_at']), models.Index(fields=['order_no']), ]逻辑说明:quantity字段存的是方向——入库为正、出库为负、盘点调整看实际情况。before_quantity和after_quantity把变动前后的快照都留下来了,这是对账、排查数据异常、写审计报表的唯一依据。有这套流水,库存表哪天盖歪了都能通过流水重算。on_delete=PROTECT是硬约束,防止你把一个有过流水的商品直接删掉。实际开发中更稳的做法是on_delete=SET_NULL加null=True,但PROTECT的好处是强迫你先处理历史数据,不做“删库跑路”。参数说明:created_at用auto_now_add=True,但需要注意——如果你在代码里手工造测试数据,Django会忽略你赋的值,一律拿当前时间。这在后面做对账、按时间维度统计时会有些麻烦,所以测试数据建议直接写SQL插入或通过ORM批量创建再统一改时间。3.3 订单模型:头表和明细表分离,为什么是进销存的铁律采购单、销售单、退货单都遵循“一主多从”的结构。主表存单号、客户/供应商、总金额、状态、时间;明细表存商品、数量、单价、小计。为什么要拆?因为订单头是低频更新(状态流转变更),订单明细是高频新增但不修改——合在一张表里会让更新逻辑纠缠不清。class PurchaseOrder(models.Model): """采购单主表""" order_no = models.CharField('单号', max_length=32, unique=True) supplier = models.CharField('供应商', max_length=128) total_amount = models.DecimalField('总额', max_digits=12, decimal_places=2, default=0) STATUS_CHOICES = ( ('DRAFT', '草稿'), ('CONFIRMED', '已确认'), ('COMPLETED', '已完成'), ('CANCELLED', '已取消'), ) status = models.CharField('状态', max_length=10, choices=STATUS_CHOICES, default='DRAFT') created_at = models.DateTimeField('创建时间', auto_now_add=True) confirmed_at = models.DateTimeField('确认时间', null=True, blank=True) class PurchaseOrderItem(models.Model): """采购单明细——每行一个商品""" order = models.ForeignKey(PurchaseOrder, on_delete=models.CASCADE, related_name='items') product = models.ForeignKey(Product, on_delete=models.PROTECT) quantity = models.IntegerField('采购数量') price = models.DecimalField('采购单价', max_digits=10, decimal_places=2) subtotal = models.DecimalField('小计', max_digits=12, decimal_places=2) def save(self, *args, **kwargs): self.subtotal = self.quantity * self.price super().save(*args, **kwargs)逻辑说明:related_name='items'让你可以直接用order.items.all()取明细。save方法里重算subtotal不是在__init__里算,因为__init__不能保证每次save都会执行这个运算。总金额total_amount不在明细里存——它在订单确认时通过聚合计算写入主表,这是典型的“汇总冗余”,用空间换查询性能。要注意的是订单明细不是用DecimalField而直接用price,因为明细行不允许出现均价这类浮动值。4. 核心业务逻辑:采购入库、销售出库、盘点调整的三个完整闭环4.1 采购入库:用事务把订单表和库存表绑在一起采购单从草稿到完成,中间至少经过确认、入库两步。一个常见的错误是在PurchaseOrderItem.save()里直接加库存,这样一单录多次就会重复加。我用的事务方案是这样:from django.db import transaction from django.db.models import F @transaction.atomic def purchase_inbound(purchase_order): """采购入库:逐条更新库存并写流水""" if purchase_order.status != 'CONFIRMED': raise ValueError('只有已确认的采购单才能入库') # 记录入库前库存 for item in purchase_order.items.select_for_update(): product = item.product inventory, created = Inventory.objects.get_or_create( product=product, defaults={'quantity': 0, 'safety_stock': 0} ) before_qty = inventory.quantity Inventory.objects.filter(pk=inventory.pk).update( quantity=F('quantity') + item.quantity ) StockFlow.objects.create( product=product, flow_type='IN', quantity=item.quantity, before_quantity=before_qty, after_quantity=before_qty + item.quantity, order_no=purchase_order.order_no, ) purchase_order.status = 'COMPLETED' purchase_order.save()逻辑说明:@transaction.atomic把整段包在一个事务里,任何一个商品更新失败,前面已改的库存和流水全部回滚。select_for_update()对商品库存行加锁,防止并发请求下出现“多次扣减同一批货”。这里用F('quantity') + item.quantity而不是读出来再加,是避免竞态条件——在高并发秒杀场景下,读改写三步分开做会丢更新。然后有两个细节:第一,get_or_create保证了即使这个商品还没有库存记录,也会自动创建一行;第二,流水里必须有before_quantity和after_quantity,这样后面任何时刻都能回溯:某个时间点哪个订单动了哪些库存。参数说明:F()表达式是在数据库端执行运算,不是在Python内存里算好赋值,这在高并发下非常重要。而且要注意update()之后,inventory.quantity在Python层面还是旧值,所以before_qty要在update之前取。4.2 销售出库:校验库存不足比扣减本身更容易踩坑销售出库逻辑和采购入库正好反向,最大的坑在“校验库存够不够”这一步。如果校验和扣减中间有人下了个单,库存已经变了,就会出现超卖。@transaction.atomic def sales_outbound(order): """销售出库:前置校验,防超卖""" for item in order.items.select_for_update(): inventory = Inventory.objects.select_for_update().get(product=item.product) if inventory.quantity < item.quantity: raise ValueError(f"{item.product.sku} 库存不足:当前{inventory.quantity},需要{item.quantity}") before_qty = inventory.quantity Inventory.objects.filter(pk=inventory.pk).update( quantity=F('quantity') - item.quantity ) StockFlow.objects.create( product=item.product, flow_type='OUT', quantity=-item.quantity, # 出库记负数 before_quantity=before_qty, after_quantity=before_qty - item.quantity, order_no=order.order_no, )逻辑说明:注意select_for_update()先后加在Inventory查询上,同一个事务里其他请求的相同行锁会被阻塞到提交或回滚,这样超卖的概率从“并发窗口期”降到“零”。quantity字段给负值,是因为流水统计时SUM(quantity)直接就是当前库存增减总量,不需要再判断方向。常见错误是把校验写成inventory.quantity >= item.quantity就放行,但如果不加锁,两个请求同时进来都校验通过,然后都更新库存,结果就超了。血泪经验是:进销存后端永远不要让“校验”和“扣减”分开在两个事务之外。参数说明:这套逻辑里没有直接操作Product表,读取的是Inventory,因为卖的是库存而不是商品档案。销售订单主表的状态变化我习惯放在整个函数末尾,和采购入库保持一致,避免中间报错后状态半更新。4.3 盘点调整:负数库存的补救与流水留痕盘点调整是进销存系统里最容易被忽略、但运营场景里最常用的功能。某商品台账对不上,或者某些老系统的负数库存要清零,必须靠“盘点单+调整流水”的方式覆盖,而不是直接改Inventory。@transaction.atomic def stocktake_adjust(product_id, actual_quantity, operator, remark=''): """盘点调整:以实际清点数为准,记录调整差异""" inventory = Inventory.objects.select_for_update().get(product_id=product_id) diff = actual_quantity - inventory.quantity if diff == 0: return # 账实相符,无事发生 before_qty = inventory.quantity Inventory.objects.filter(pk=inventory.pk).update(quantity=actual_quantity) StockFlow.objects.create( product_id=product_id, flow_type='ADJUST', quantity=diff, before_quantity=before_qty, after_quantity=actual_quantity, remark=remark, operator=operator, )逻辑说明:差异量diff可正可负,但流水类型永远是ADJUST。为什么要用“差异”而不是“调整后数量”来记流水?因为流水表里的quantity字段设计为“变动量”,这样汇总时才不需要判断类型,直接SUM就是净变动。这里还牵出一个运营管理原则:盘点的目的不是改数字,而是发现问题。如果某个SKU连续多次盘点都出现差异,说明入库、出库链路里有丢失或步骤遗漏,这时该查的是业务流程,而不是跑SQL补数据。参数说明:actual_quantity直接覆盖而不是增量更新,意味着盘点是“绝对值对齐”;而采购入库和销售出库是“相对值增加/减少”。这几种操作混在一个流水表里,靠flow_type字段做区分,所以代码里对quantity存的是变动量这一点必须统一。5. 报表与看板:进销存系统里最体现“高分”的统计逻辑5.1 毛利统计:用加权平均成本算毛利,别用最新采购价进销存的毛利率统计是个深水区。初级项目直接拿销售均价 - 商品资料里的最新采购价来算毛利,这在商品价格波动频繁的场景下误差极大。正确做法是加权平均成本——每次入库后,库存的“平均成本”重新计算:def update_avg_cost(product_id, new_quantity, new_cost): """入库后按移动加权平均法更新成本——入库时调用""" inventory = Inventory.objects.select_for_update().get(product_id=product_id) old_qty = inventory.quantity old_cost = inventory.avg_cost total_cost = old_cost * old_qty + new_cost * new_quantity new_avg_cost = total_cost / (old_qty + new_quantity) inventory.avg_cost = round(new_avg_cost, 4) inventory.save()逻辑说明:Inventory表里加了一个avg_cost字段,每来一批货,就用加权平均公式重算。这个字段不在前面的模型定义里,而这恰恰是进销存设计的精髓——库存表不是只有数量,它还是成本计算的载体。round(…, 4)不是随便选的:角分后面保留四位小数,是为了累计多笔交易后不出现“几分钱对不上”的分摊误差。用Decimal类型时这个round的参数单位是小数位数,不是百分比。如果你在报表里发现每一行毛利都对,月底总账差几块钱,十有八九是这里没保留够精度。参数说明:这个函数必须在采购入库的事务里同步调用,不能异步做。否则就会出现库存已经加了,成本还没更新的窗口期,对账时数据错位。5.2 TOP销量统计:ORM聚合的写法与原生SQL的取舍进销存报表最常见的需求就是“某段时间内卖得最好的商品TOP10”。ORM写法如下:from django.db.models import Sum, F from datetime import datetime, timedelta def top_selling_products(days=30, limit=10): """最近N天销量TOP10""" since = datetime.now() - timedelta(days=days) qs = (StockFlow.objects .filter(flow_type='OUT', created_at__gte=since) .values('product__sku', 'product__name') .annotate(total_qty=Sum('quantity')) .order_by('total_qty')[:limit]) return list(qs)逻辑说明:这里Sum('quantity')能直接用,是因为出库流水的quantity是负数,Sum的结果就是净出库量。不用再写Sum('quantity') * -1。.values()先按SKU和名称分组,再在分组基础上累加,这是ORM里固定套路。一个隐藏的坑:created_at__gte=since依赖数据库当前时间,如果开发机的系统时间和服务器时间不同步,报表会莫名“缺最近两小时数据”或“多了明天的数据”。这是老项目常见的玄学问题,排查办法是先在Django shell里跑两遍查询,输出datetime.now()对比一下。参数说明:timedelta(days=30)表示最近30天自然日。这个口径在进销存里其实有三种:自然日滚动、按月自然月、按工作日历。上线前一定要和业务方确认清楚用哪种,否则报表对不上运营的口径,你来挨骂。5.3 库存预警SQL:低库存和负库存一次查出来报表看板上的“库存预警”是一个红黄绿灯逻辑。红灯给负库存,黄灯给低于安全库存的商品:from django.db.models import Q def stock_warning_list(): """库存预警:低于安全库存或负库存""" warn_items = (Inventory.objects .select_related('product') .filter(Q(quantity__lt=F('safety_stock')) | Q(quantity__lt=0)) .order_by('quantity')) for item in warn_items: level = 'RED' if item.quantity < 0 else 'YELLOW' print(f"[{level}] {item.product.sku}: {item.quantity}")逻辑说明:F('safety_stock')允许在filter里做字段对字段的比较——SQL里要写quantity < safety_stock,ORM里就得用F()。所有库存汇总表的查询都用select_related预加载商品表,避免N+1查询。order_by('quantity')升序排列,负数排在最前,一眼看到最严重的。实际项目里这个函数经常挂在APScheduler之类的定时任务里,每天上班前跑一遍邮件发给运营。如果数据库里有一堆长时间没更新的库存记录,查一下是不是有脚本在批量刷updated_at,否则预警邮件会天天轰炸。参数说明:Q对象用|表示OR,用&表示AND。如果你想把“负库存”挂在单独的红色分组里,就拆成两个filter,不要混在一起再在Python里二次过滤。6. 避坑手册:Django进销存开发里的七个常见问题与排查路径6.1 遇到问题先别改代码:三个必查的位置现象:系统某个报表数据不对,比如“本月销售额比上月高了一倍”但业务说没这回事。原因推断:可能是流水表里混入了测试数据,也可能是订单状态还没流转到“已完成”但报表把草稿单也算进去了,还可能是时区问题——数据库存的是UTC,报表按本地时区范围查询,把“边界单”(正好在23:59创建的)算进了错误的一天。解决路径:第一查StockFlow流水的created_at范围,用SQL直接:SELECT DATE(created_at), COUNT(*), SUM(quantity) FROM stock_flow GROUP BY DATE(created_at) ORDER BY DATE(created_at) DESC LIMIT 20;第二查订单状态字段,看报表查询条件里有没有过滤status='COMPLETED';第三查settings.py的TIME_ZONE和USE_TZ,如果USE_TZ=True而TIME_ZONE='UTC',那所有auto_now_add存的时间都是UTC,本地报表必须做时区转换。这套排查路径走下来,超过一半的“数据不对”都会被识破。6.2 入库重复执行导致库存翻倍:用幂等校验挡住现象:同一个采购单号在库存流水里出现了两次,库存翻倍了。原因:采购入库的接口没有做幂等判断,运营不小心点了两次“入库”按钮,或者请求超时后前端重试了一次。解决:在入口处先查流水表有没有同order_no的记录:if StockFlow.objects.filter(order_no=purchase_order.order_no, flow_type='IN').exists(): raise ValueError(f"采购单 {purchase_order.order_no} 已入库,禁止重复操作")搭配事务后,这行校验就是最后一道闸。更稳妥的做法是给StockFlow表加一个(order_no, flow_type)的UniqueConstraint,在数据库层做死约束。SQLite支持复合唯一索引,MySQL也一样,迁移文件里直接定义:class Meta: constraints = [ models.UniqueConstraint(fields=['order_no', 'flow_type'], name='uniq_order_flow') ]这样哪怕应用层代码漏了校验,数据库也不会让你插入第二条同样单据的入库流水。6.3 SQLite文件被锁死:开发环境跑着跑着就报database is locked现象:开发环境突然报OperationalError: database is locked,项目就崩了。原因:SQLite并发写的能力很弱,两个进程同时写同一个文件就会锁死。常见于你开了两个runserver窗口,或某个脚本在后台跑长事务没提交。解决:先拆后台进程。项目如果只在本地开发用SQLite,Django的runserver是单进程的,基本不会遇到;一旦你上了gunicorn之类的多worker部署,SQLite锁的问题就会爆发。此时必须换PostgreSQL或MySQL。如果只是想临时救急,可以在settings.py里给SQLite加超时和线程选项:DATABASES = { 'default': { 'ENGINE': 'django.db.backends.sqlite3', 'NAME': BASE_DIR / 'db.sqlite3', 'OPTIONS': { 'timeout': 20, 'check_same_thread': False, } } }timeout单位是秒,意思是锁等待最多20秒;check_same_thread=False允许跨线程复用连接,这在多线程开发服务器下能缓解一部分问题。但说穿了这只是创可贴,换掉SQLite才是根本解。6.4 删除商品时报外键约束错误:别轻易改级联策略现象:Admin后台里想删掉某个商品,点击删除报ProtectedForeignKey错误。原因:模型里StockFlow.product用的是on_delete=PROTECT,而流水表里有该商品的历史记录,数据库层不允许你删父表记录。解决:正确的业务动作不是删除商品,而是“停用”——把Product.is_active改成False,前端下单的商品选择器里不再显示它,历史流水原样保留。如果必须物理删除(比如测试数据清理),先把关联流水删掉或转走:# 只允许在确认无误的情况下执行 StockFlow.objects.filter(product=product).update(product=None) # 前提是外键允许null product.delete()现实中我见过最离谱的一次事故是,某开发者为了删商品,把所有on_delete都换成了CASCADE,结果删一个商品连带删了一整年的流水,报表和财务数据全没了。这条血泪经验写在这里:数据库约束是保护伞,别乱改成级联。6.5 重跑迁移文件时数据全丢:迁移前不备份的后悔药现象:项目加了新字段,执行makemigrations后一切正常;migrate后打开Admin发现之前录入的数据全没了。原因:migrate不是只加字段,它还会执行数据库里所有未应用的迁移。如果项目里有DeleteModel或RemoveField这类破坏性迁移,数据在那一刻就被清了。更隐蔽的是,如果你把数据库文件换成了空文件再跑迁移,Django会从零建表,原数据就“蒸发”了。解决:跑migrate前备份数据库文件,这条习惯要刻进肌肉记忆:cp db.sqlite3 db.sqlite3.bak_$(date +%Y%m%d_%H%M%S)如果数据已经丢了,只剩备份文件,恢复就是把备份文件复制回去,然后重启服务。如果连备份都没有,就只能捞SQLite的WAL文件碰运气,但SQLite默认不开WAL,基本没戏。说难听点,这就是没有后悔药的事故。6.6 时区引发的日期统计偏差:所有报表按天统计都会踩的坑现象:后台报表里某天的销售数据总是少了几单,细看都是晚上11点后的订单。原因:TIME_ZONE设成了UTC,数据库存的是UTC时间,而SQLite的DATE(created_at)函数取的是字符串里的日期,相当于按UTC切天。中国时区比UTC早8小时,晚上8点之后创建的订单,在UTC日期上已经“跑到第二天”去了。解决:项目里如果只用Django ORM,不写原生SQL,那settings.py里USE_TZ=True配合TIME_ZONE='Asia/Shanghai'就够了,Django会在ORM查询时自动做时区转换。但如果你像我一样写了原生SQL或用了extra(),就必须自己处理:from django.utils import timezone from datetime import timedelta # 报表查询时的本地日期范围 start_local = timezone.now().replace(hour=0, minute=0, second=0, microsecond=0) end_local = start_local + timedelta(days=1) # 转成UTC存储时间再过滤 start_utc = start_local.astimezone(timezone.utc) end_utc = end_local.astimezone(timezone.utc) StockFlow.objects.filter(created_at__gte=start_utc, created_at__lt=end_utc)astimezone(timezone.utc)不是可选的,是按日报表的必写步骤。很多项目上线一年后才发现月度报表对不上,回查全是这个窟窿。6.7 金额精度对齐问题:DecimalField和浮点数计算的边界现象:订单总额和明细小计加起来差了0.01元,财务对账时发现。原因:Django的DecimalField在数据库层是精确的,但如果你在Python代码里不自觉地用float()做了一次转换,精度就丢在那一瞬间了。解决:所有金额计算统一用Decimal,并在中间步骤保持足够精度。我习惯在所有涉及钱的地方写这样的工具函数:from decimal import Decimal def calc_subtotal(price, quantity): """金额计算统一走Decimal,不落浮点数""" return Decimal(price) * Decimal(quantity)且项目约定:数据库里price字段一律DecimalField,传输层用字符串序列化,前端展示时再做格式化。这条约束写进团队开发规范里,比事后修bug便宜十倍。前面看到的round(new_avg_cost, 4)也是同样原则,只在小数点位做舍入,绝不中间过程用float。7. 进阶:把数据库文件变成可复用的种子数据,让项目交付物更完整7.1 用dumpdata导出授权数据,区分“开发用”和“交付用”进销存项目交付时,数据库文件里经常堆着测试数据——一堆“商品A”“测试商品B”的垃圾记录,这种交付物给到别人手上,第一印象就崩了。我习惯在交付前用Django的dumpdata导出“干净数据”,只保留基础配置和少量演示用的商品、供应商记录。# 导出核心业务数据为JSON python manage.py dumpdata product.Inventory --indent 2 > seed_inventory.json python manage.py dumpdata product.Product --indent 2 > seed_product.json python manage.py dumpdata stock.StockFlow --indent 2 > seed_flow.json逻辑说明:dumpdata的格式是app_label.ModelName,要把依赖关系理清楚——先导Product再导Inventory,不然导入时空引用会断。三个文件分开存,方便使用者只需要看业务结构时,只导入前两个。--indent 2是让JSON可读性更好,方便代码评审的人直接看内容。loaddata导入时,如果目标数据库里已有相同主键的数据,会直接覆盖。所以交付时要写清楚导入顺序,否则后导入的Product数据会把库存表的外键全部带歪。参数说明:如果交付物里包含订单和流水,dumpdata导出的数据量会很大,而且包含created_at这些时间信息。我通常只保留最近一周的流水作为示例,更多历史数据不导出——毕竟新人用学习项目,不需要三个月的历史流水撑容量。7.2 写一个初始化脚本,让新手一键跑通不管你的数据库文件做得多干净,都不如一份init_demo.py来得省事——至少能让对方把项目跑起来时,不用手工在Admin里点半天录入数据。我用Django的manage.py shell执行初始化:# init_demo.py from product.models import Product, Inventory from stock.models import StockFlow def run(): """初始化演示数据:3个商品+初始库存+一笔入库流水""" demo_products = [ {'sku': 'SKU-001', 'name': '演示商品A', 'sale_price': '99.00', 'purchase_price': '60.00'}, {'sku': 'SKU-002', 'name': '演示商品B', 'sale_price': '199.00', 'purchase_price': '120.00'}, {'sku': 'SKU-003', 'name': '演示商品C', 'sale_price': '29.90', 'purchase_price': '15.00'}, ] for item in demo_products: product, _ = Product.objects.update_or_create( sku=item['sku'], defaults={ 'name': item['name'], 'sale_price': item['sale_price'], 'purchase_price': item['purchase_price'], } ) inv, _ = Inventory.objects.update_or_create( product=product, defaults={'quantity': 100, 'safety_stock': 20} )运行时:python manage.py shell < init_demo.py逻辑说明:update_or_create是幂等写法,脚本可以重复执行而不会把库存越加越多。这个脚本的意义不在于省那三分钟手敲数据的时间,而在于它把“业务演示数据”从数据库文件里独立出来了——数据库文件只管结构,脚本管数据,交付时两边都不互相绑架。参数说明:shell < init_demo.py的写法在Windows PowerShell里可能不生效,建议Windows用户改用type init_demo.py | python manage.py shell。这是跨平台踩坑点之一,文档里最好写清楚两种平台的执行方式。7.3 自定义JSON Encoder:当dumpdata遇到Decimal和Datetime直接dumpdata导出时,DecimalField和DateTimeField会被Django自动处理成可解析的格式,这个天然的优势就是它能跨库导入。但如果你自己写json.dumps序列化ORM对象,十有八九会报TypeError: Object of type Decimal is not JSON serializable。原因就是Decimal不是原生JSON类型。解决方式是自定义Encoder:import json from decimal import Decimal from datetime import datetime, date class CustomJSONEncoder(json.JSONEncoder): """支持Decimal和datetime的JSON序列化""" def default(self, obj): if isinstance(obj, Decimal): return float(obj) if isinstance(obj, (datetime, date)): return obj.strftime('%Y-%m-%d %H:%M:%S') return super().default(obj)dumpdata内部已经帮你做了这些,所以这个Encoder更多用于你写导出脚本给自己做备份时用。给出的代码里float(obj)的转换会损失精度——但这只是导出备份用的,不是数据库运算,所以可接受。如果要精确到分,也可以转成字符串。json_str = json.dumps(queryset_data, cls=CustomJSONEncoder, ensure_ascii=False)ensure_ascii=False保证中文不被转成\uXXXX,文件更可读。这套进阶动作做完,你的交付物就不再是“源码+数据库文件”二件套,而是“源码+可重复构建的种子数据+初始化脚本”三件套——这会让你的项目代码评审体验上一个档次,也让别人真正敢把系统拿去改。我自己的习惯是:不管接手的项目原始形态多粗糙,我都会要求交付内容里必须有一份init_demo.py和一版seed.json,因为复制一份数据库文件交出去,对方永远不知道你数据库里藏着什么;但跑一遍初始化脚本,所有表结构和基础数据都会冷静地摊开在面前,这才是能让人放心开始修改的起点。进销存这套逻辑无论用Django还是别的框架,核心都在“库存流水是否完整、事务边界是否清晰、金额精度是否可靠”,你把这些点钉死,系统就立得住。希望帮到你。本文还有配套的精品资源,点击获取
简介:在业务系统开发中,ORM是连接业务模型与数据库的桥梁,它让开发者用面向对象的方式管理数据,同时保留SQL的灵活性与事务能力。以Django框架为例,进销存系统正是一类对数据一致性要求极高的典型业务场景:商品SKU与库存分离、库存流水记录每一次变动、采购与销售通过事务保证原子性。理解这些底层设计,不仅能看懂开源项目,更能避免高并发下库存超卖、报表数据错位等工程陷阱。本文从环境搭建与数据库接入讲起,逐步拆解三大核心模型、入库出库闭环、毛利统计与低库存预警的ORM实现,最后汇总常见排错路径与种子数据交付技巧。无论你是准备二次开发还是学习Django建模,这套方法论都能帮你快速掌握可落地的进销存系统实现思路。1. 用Django做进销存:这套源码到底值不值得你花时间拆拿到一个“基于Django商品销售进销存系统源码+数据库文件”的压缩包,第一反应别急着解压。先想清楚:Django做进销存(采购、销售、库存、报表)在中小体量业务里是非常成熟的选择,ORM帮你在业务模型和数据库之间搭桥,Admin后台甚至能秒出基础管理界面。这类标着“高分项目”的交付物,往往是教学场景下的完整范例,意味着它该具备三层价值:架构能看懂、流程能跑通、数据能复现。我拆过的类似项目里,最值钱的从来不是登录注册和商品列表,而是库存流水、订单状态机、毛利统计这几块硬逻辑。本篇就把这条链路完整捋一遍——从环境跑通,到数据模型设计,再到库存与报表的SQL/ORM实现,最后落在数据库迁移和排错上,让你照着能复现,改着能落地。2. 先把项目拉起来:从zip到本地可运行的最小路径2.1 环境准备与依赖解析:Python版本为什么是第一道门槛这类Django项目通常自带requirements.txt,但它不会告诉你Python版本多重要。实测中最高频的翻车点就是Python 3.12跑Django 3.2,直接报AttributeError: module 'collections' has no attribute 'Iterable'——这是类型注解向后兼容炸了。我的建议是:优先用Python 3.10或3.11,Django版本先看项目里的django字段,老项目常见2.2和3.2,新一点的会跟4.x。虚拟环境里安装依赖,命令照抄:# 创建虚拟环境(Windows/Linux通用) python -m venv venv source venv/bin/activate # Windows下用 venv\Scripts\activate # 安装依赖 pip install -r requirements.txt # 确认版本 python -m django --version逻辑说明:venv隔离项目依赖,避免污染全局Python环境。requirements.txt里的Django版本是天花板约束,如果安装时pip自动给你拉了个更高版本导致兼容问题,可以用pip install "Django==3.2.25"锁定。参数说明:这里最关键的其实是source venv/bin/activate这一步,不少新手会跳过它直接pip install,装完才发现包进不了项目。另外,老项目可能缺mysqlclient或psycopg2-binary,如果依赖文件里没有,又使用的是SQLite,那这两个包根本不需要,别多装。2.2 数据库文件怎么接:SQLite版的和MySQL版的要区别对待项目标题里写着“数据库文件”,常见有两种:.sqlite3和.sql。.sqlite3是SQLite数据库文件,直接放到项目根目录,然后修改settings.py里的DATABASES配置:DATABASES = { 'default': { 'ENGINE': 'django.db.backends.sqlite3', 'NAME': BASE_DIR / 'db.sqlite3', # 确保文件名和实际存放的一致 } }逻辑说明:SQLite是文件型数据库,Django的ORM会直接操作这个物理文件。重点在于NAME字段的路径是BASE_DIR下的相对路径,如果你把数据库文件放错了目录,或者大小写对不上,Django会在migrate时重新生成一个空库,而不是报错——这就是坑:项目跑起来了,但所有数据都没了。如果是.sql导出文件,那属于MySQL/PostgreSQL的备份,需要先建库再导入:# 以MySQL为例 mysql -u root -p -e "CREATE DATABASE shop_db CHARACTER SET utf8mb4;" mysql -u root -p shop_db < database.sql导入后再把settings.py改成MySQL配置:ENGINE换成django.db.backends.mysql,NAME填shop_db,再补USER、PASSWORD、HOST、PORT。这里有个血泪经验:老项目的SQL导出文件里可能带着SET FOREIGN_KEY_CHECKS=0这类会话级操作,导入时终端会刷一屏警告,只要最后SELECT COUNT(*) FROM auth_user;能查出数据,就不用管警告。2.3 跑起来要过四关:迁移、静态文件、超级用户和Admin界面验证这四关是跑通任何Django项目的通用顺序,但进销存项目多了一个高频卡点:迁移文件与数据库文件版本不匹配。# 第1关:生成并执行迁移 python manage.py makemigrations python manage.py migrate # 第2关:创建管理员账号 python manage.py createsuperuser # 第3关:启动开发服务器 python manage.py runserver逻辑说明:makemigrations会扫描models.py的变化生成迁移文件,如果项目作者已经提供了migrations目录,并且数据库文件是配套的,这两条命令往往只需要执行migrate——直接makemigrations反而会因为检测到“模型和迁移不一致”而报警。注意控制台输出里有没有No changes detected,有就说明迁移状态正常。createsuperuser之后,Django Admin就是进销存系统的“免费后台”。进销存这类项目的Admin往往注册了商品、供应商、采购单、销售单这些模型,你可以直接在/admin/里看到数据表内容。这一关过了,系统才算真正“活”了。参数说明:runserver默认跑在8000端口,如果端口被占用,python manage.py runserver 0.0.0.0:8001可以换端口。另外,migrate执行前建议先备份原有数据库文件,因为如果迁移和数据库文件冲突,Django会尝试改表结构,失败时可能损坏SQLite文件。3. 进销存的骨架:三大核心模型怎么设计才不返工3.1 商品表和库存表:SKU与汇总库存分离的必要性看一个进销存项目水平高低,先看商品模型。初级项目会把“商品名称”“库存数量”直接怼在同一张表里,高级项目一定做了SKU与库存的分离。常见做法是这样设计:from django.db import models class Product(models.Model): """商品基本信息——不含库存字段的SKU表""" sku = models.CharField('SKU编码', max_length=64, unique=True) name = models.CharField('商品名称', max_length=128) brand = models.CharField('品牌', max_length=64, blank=True) category = models.CharField('分类', max_length=64, blank=True) sale_price = models.DecimalField('销售价', max_digits=10, decimal_places=2) purchase_price = models.DecimalField('采购价', max_digits=10, decimal_places=2) is_active = models.BooleanField('上架', default=True) created_at = models.DateTimeField('创建时间', auto_now_add=True) class Meta: db_table = 'product' indexes = [ models.Index(fields=['sku']), models.Index(fields=['category']), ] class Inventory(models.Model): """库存汇总表——与SKU一对一""" product = models.OneToOneField(Product, on_delete=models.CASCADE, primary_key=True) quantity = models.IntegerField('当前库存数', default=0) safety_stock = models.IntegerField('安全库存', default=0) updated_at = models.DateTimeField('最后变动时间', auto_now=True) class Meta: db_table = 'inventory'逻辑说明:把售价、采购价放在Product表,把可变的数量放进Inventory表,好处是商品资料相对静态,库存是高频变动字段,拆开后行锁竞争小、缓存命中率高。OneToOneField用主键关联,是为了保证一个SKU只有一行库存记录,查询时直接走主键索引。参数说明:max_digits=10, decimal_places=2是钱的标准配置,能存到千万级金额不丢精度。为什么不用FloatField,因为浮点数算钱会有0.1+0.2不等于0.3的经典翻车;DecimalField在Django ORM层会映射成数据库的DECIMAL或SQLite的numeric,计算更稳。索引方面,sku建唯一索引是必须的,category的索引是为了分类汇总报表。3.2 库存流水表:所有库存变动的唯一真相来源真正高阶的进销存项目,库存表上的数量只是“结果”,而“过程”全在流水表里。设计逻辑是这样的:class StockFlow(models.Model): """库存流水——所有入库、出库、退货、盘点操作的明细记录""" FLOW_TYPES = ( ('IN', '采购入库'), ('OUT', '销售出库'), ('RETURN', '退货'), ('ADJUST', '盘点调整'), ) product = models.ForeignKey(Product, on_delete=models.PROTECT) flow_type = models.CharField('流水类型', max_length=10, choices=FLOW_TYPES) quantity = models.IntegerField('变动数量(正负号表示方向)') before_quantity = models.IntegerField('变动前库存') after_quantity = models.IntegerField('变动后库存') order_no = models.CharField('关联单据号', max_length=64, blank=True) remark = models.CharField('备注', max_length=255, blank=True) operator = models.CharField('操作人', max_length=32, blank=True) created_at = models.DateTimeField('发生时间', auto_now_add=True) class Meta: db_table = 'stock_flow' ordering = ['-created_at'] indexes = [ models.Index(fields=['product', 'created_at']), models.Index(fields=['order_no']), ]逻辑说明:quantity字段存的是方向——入库为正、出库为负、盘点调整看实际情况。before_quantity和after_quantity把变动前后的快照都留下来了,这是对账、排查数据异常、写审计报表的唯一依据。有这套流水,库存表哪天盖歪了都能通过流水重算。on_delete=PROTECT是硬约束,防止你把一个有过流水的商品直接删掉。实际开发中更稳的做法是on_delete=SET_NULL加null=True,但PROTECT的好处是强迫你先处理历史数据,不做“删库跑路”。参数说明:created_at用auto_now_add=True,但需要注意——如果你在代码里手工造测试数据,Django会忽略你赋的值,一律拿当前时间。这在后面做对账、按时间维度统计时会有些麻烦,所以测试数据建议直接写SQL插入或通过ORM批量创建再统一改时间。3.3 订单模型:头表和明细表分离,为什么是进销存的铁律采购单、销售单、退货单都遵循“一主多从”的结构。主表存单号、客户/供应商、总金额、状态、时间;明细表存商品、数量、单价、小计。为什么要拆?因为订单头是低频更新(状态流转变更),订单明细是高频新增但不修改——合在一张表里会让更新逻辑纠缠不清。class PurchaseOrder(models.Model): """采购单主表""" order_no = models.CharField('单号', max_length=32, unique=True) supplier = models.CharField('供应商', max_length=128) total_amount = models.DecimalField('总额', max_digits=12, decimal_places=2, default=0) STATUS_CHOICES = ( ('DRAFT', '草稿'), ('CONFIRMED', '已确认'), ('COMPLETED', '已完成'), ('CANCELLED', '已取消'), ) status = models.CharField('状态', max_length=10, choices=STATUS_CHOICES, default='DRAFT') created_at = models.DateTimeField('创建时间', auto_now_add=True) confirmed_at = models.DateTimeField('确认时间', null=True, blank=True) class PurchaseOrderItem(models.Model): """采购单明细——每行一个商品""" order = models.ForeignKey(PurchaseOrder, on_delete=models.CASCADE, related_name='items') product = models.ForeignKey(Product, on_delete=models.PROTECT) quantity = models.IntegerField('采购数量') price = models.DecimalField('采购单价', max_digits=10, decimal_places=2) subtotal = models.DecimalField('小计', max_digits=12, decimal_places=2) def save(self, *args, **kwargs): self.subtotal = self.quantity * self.price super().save(*args, **kwargs)逻辑说明:related_name='items'让你可以直接用order.items.all()取明细。save方法里重算subtotal不是在__init__里算,因为__init__不能保证每次save都会执行这个运算。总金额total_amount不在明细里存——它在订单确认时通过聚合计算写入主表,这是典型的“汇总冗余”,用空间换查询性能。要注意的是订单明细不是用DecimalField而直接用price,因为明细行不允许出现均价这类浮动值。4. 核心业务逻辑:采购入库、销售出库、盘点调整的三个完整闭环4.1 采购入库:用事务把订单表和库存表绑在一起采购单从草稿到完成,中间至少经过确认、入库两步。一个常见的错误是在PurchaseOrderItem.save()里直接加库存,这样一单录多次就会重复加。我用的事务方案是这样:from django.db import transaction from django.db.models import F @transaction.atomic def purchase_inbound(purchase_order): """采购入库:逐条更新库存并写流水""" if purchase_order.status != 'CONFIRMED': raise ValueError('只有已确认的采购单才能入库') # 记录入库前库存 for item in purchase_order.items.select_for_update(): product = item.product inventory, created = Inventory.objects.get_or_create( product=product, defaults={'quantity': 0, 'safety_stock': 0} ) before_qty = inventory.quantity Inventory.objects.filter(pk=inventory.pk).update( quantity=F('quantity') + item.quantity ) StockFlow.objects.create( product=product, flow_type='IN', quantity=item.quantity, before_quantity=before_qty, after_quantity=before_qty + item.quantity, order_no=purchase_order.order_no, ) purchase_order.status = 'COMPLETED' purchase_order.save()逻辑说明:@transaction.atomic把整段包在一个事务里,任何一个商品更新失败,前面已改的库存和流水全部回滚。select_for_update()对商品库存行加锁,防止并发请求下出现“多次扣减同一批货”。这里用F('quantity') + item.quantity而不是读出来再加,是避免竞态条件——在高并发秒杀场景下,读改写三步分开做会丢更新。然后有两个细节:第一,get_or_create保证了即使这个商品还没有库存记录,也会自动创建一行;第二,流水里必须有before_quantity和after_quantity,这样后面任何时刻都能回溯:某个时间点哪个订单动了哪些库存。参数说明:F()表达式是在数据库端执行运算,不是在Python内存里算好赋值,这在高并发下非常重要。而且要注意update()之后,inventory.quantity在Python层面还是旧值,所以before_qty要在update之前取。4.2 销售出库:校验库存不足比扣减本身更容易踩坑销售出库逻辑和采购入库正好反向,最大的坑在“校验库存够不够”这一步。如果校验和扣减中间有人下了个单,库存已经变了,就会出现超卖。@transaction.atomic def sales_outbound(order): """销售出库:前置校验,防超卖""" for item in order.items.select_for_update(): inventory = Inventory.objects.select_for_update().get(product=item.product) if inventory.quantity < item.quantity: raise ValueError(f"{item.product.sku} 库存不足:当前{inventory.quantity},需要{item.quantity}") before_qty = inventory.quantity Inventory.objects.filter(pk=inventory.pk).update( quantity=F('quantity') - item.quantity ) StockFlow.objects.create( product=item.product, flow_type='OUT', quantity=-item.quantity, # 出库记负数 before_quantity=before_qty, after_quantity=before_qty - item.quantity, order_no=order.order_no, )逻辑说明:注意select_for_update()先后加在Inventory查询上,同一个事务里其他请求的相同行锁会被阻塞到提交或回滚,这样超卖的概率从“并发窗口期”降到“零”。quantity字段给负值,是因为流水统计时SUM(quantity)直接就是当前库存增减总量,不需要再判断方向。常见错误是把校验写成inventory.quantity >= item.quantity就放行,但如果不加锁,两个请求同时进来都校验通过,然后都更新库存,结果就超了。血泪经验是:进销存后端永远不要让“校验”和“扣减”分开在两个事务之外。参数说明:这套逻辑里没有直接操作Product表,读取的是Inventory,因为卖的是库存而不是商品档案。销售订单主表的状态变化我习惯放在整个函数末尾,和采购入库保持一致,避免中间报错后状态半更新。4.3 盘点调整:负数库存的补救与流水留痕盘点调整是进销存系统里最容易被忽略、但运营场景里最常用的功能。某商品台账对不上,或者某些老系统的负数库存要清零,必须靠“盘点单+调整流水”的方式覆盖,而不是直接改Inventory。@transaction.atomic def stocktake_adjust(product_id, actual_quantity, operator, remark=''): """盘点调整:以实际清点数为准,记录调整差异""" inventory = Inventory.objects.select_for_update().get(product_id=product_id) diff = actual_quantity - inventory.quantity if diff == 0: return # 账实相符,无事发生 before_qty = inventory.quantity Inventory.objects.filter(pk=inventory.pk).update(quantity=actual_quantity) StockFlow.objects.create( product_id=product_id, flow_type='ADJUST', quantity=diff, before_quantity=before_qty, after_quantity=actual_quantity, remark=remark, operator=operator, )逻辑说明:差异量diff可正可负,但流水类型永远是ADJUST。为什么要用“差异”而不是“调整后数量”来记流水?因为流水表里的quantity字段设计为“变动量”,这样汇总时才不需要判断类型,直接SUM就是净变动。这里还牵出一个运营管理原则:盘点的目的不是改数字,而是发现问题。如果某个SKU连续多次盘点都出现差异,说明入库、出库链路里有丢失或步骤遗漏,这时该查的是业务流程,而不是跑SQL补数据。参数说明:actual_quantity直接覆盖而不是增量更新,意味着盘点是“绝对值对齐”;而采购入库和销售出库是“相对值增加/减少”。这几种操作混在一个流水表里,靠flow_type字段做区分,所以代码里对quantity存的是变动量这一点必须统一。5. 报表与看板:进销存系统里最体现“高分”的统计逻辑5.1 毛利统计:用加权平均成本算毛利,别用最新采购价进销存的毛利率统计是个深水区。初级项目直接拿销售均价 - 商品资料里的最新采购价来算毛利,这在商品价格波动频繁的场景下误差极大。正确做法是加权平均成本——每次入库后,库存的“平均成本”重新计算:def update_avg_cost(product_id, new_quantity, new_cost): """入库后按移动加权平均法更新成本——入库时调用""" inventory = Inventory.objects.select_for_update().get(product_id=product_id) old_qty = inventory.quantity old_cost = inventory.avg_cost total_cost = old_cost * old_qty + new_cost * new_quantity new_avg_cost = total_cost / (old_qty + new_quantity) inventory.avg_cost = round(new_avg_cost, 4) inventory.save()逻辑说明:Inventory表里加了一个avg_cost字段,每来一批货,就用加权平均公式重算。这个字段不在前面的模型定义里,而这恰恰是进销存设计的精髓——库存表不是只有数量,它还是成本计算的载体。round(…, 4)不是随便选的:角分后面保留四位小数,是为了累计多笔交易后不出现“几分钱对不上”的分摊误差。用Decimal类型时这个round的参数单位是小数位数,不是百分比。如果你在报表里发现每一行毛利都对,月底总账差几块钱,十有八九是这里没保留够精度。参数说明:这个函数必须在采购入库的事务里同步调用,不能异步做。否则就会出现库存已经加了,成本还没更新的窗口期,对账时数据错位。5.2 TOP销量统计:ORM聚合的写法与原生SQL的取舍进销存报表最常见的需求就是“某段时间内卖得最好的商品TOP10”。ORM写法如下:from django.db.models import Sum, F from datetime import datetime, timedelta def top_selling_products(days=30, limit=10): """最近N天销量TOP10""" since = datetime.now() - timedelta(days=days) qs = (StockFlow.objects .filter(flow_type='OUT', created_at__gte=since) .values('product__sku', 'product__name') .annotate(total_qty=Sum('quantity')) .order_by('total_qty')[:limit]) return list(qs)逻辑说明:这里Sum('quantity')能直接用,是因为出库流水的quantity是负数,Sum的结果就是净出库量。不用再写Sum('quantity') * -1。.values()先按SKU和名称分组,再在分组基础上累加,这是ORM里固定套路。一个隐藏的坑:created_at__gte=since依赖数据库当前时间,如果开发机的系统时间和服务器时间不同步,报表会莫名“缺最近两小时数据”或“多了明天的数据”。这是老项目常见的玄学问题,排查办法是先在Django shell里跑两遍查询,输出datetime.now()对比一下。参数说明:timedelta(days=30)表示最近30天自然日。这个口径在进销存里其实有三种:自然日滚动、按月自然月、按工作日历。上线前一定要和业务方确认清楚用哪种,否则报表对不上运营的口径,你来挨骂。5.3 库存预警SQL:低库存和负库存一次查出来报表看板上的“库存预警”是一个红黄绿灯逻辑。红灯给负库存,黄灯给低于安全库存的商品:from django.db.models import Q def stock_warning_list(): """库存预警:低于安全库存或负库存""" warn_items = (Inventory.objects .select_related('product') .filter(Q(quantity__lt=F('safety_stock')) | Q(quantity__lt=0)) .order_by('quantity')) for item in warn_items: level = 'RED' if item.quantity < 0 else 'YELLOW' print(f"[{level}] {item.product.sku}: {item.quantity}")逻辑说明:F('safety_stock')允许在filter里做字段对字段的比较——SQL里要写quantity < safety_stock,ORM里就得用F()。所有库存汇总表的查询都用select_related预加载商品表,避免N+1查询。order_by('quantity')升序排列,负数排在最前,一眼看到最严重的。实际项目里这个函数经常挂在APScheduler之类的定时任务里,每天上班前跑一遍邮件发给运营。如果数据库里有一堆长时间没更新的库存记录,查一下是不是有脚本在批量刷updated_at,否则预警邮件会天天轰炸。参数说明:Q对象用|表示OR,用&表示AND。如果你想把“负库存”挂在单独的红色分组里,就拆成两个filter,不要混在一起再在Python里二次过滤。6. 避坑手册:Django进销存开发里的七个常见问题与排查路径6.1 遇到问题先别改代码:三个必查的位置现象:系统某个报表数据不对,比如“本月销售额比上月高了一倍”但业务说没这回事。原因推断:可能是流水表里混入了测试数据,也可能是订单状态还没流转到“已完成”但报表把草稿单也算进去了,还可能是时区问题——数据库存的是UTC,报表按本地时区范围查询,把“边界单”(正好在23:59创建的)算进了错误的一天。解决路径:第一查StockFlow流水的created_at范围,用SQL直接:SELECT DATE(created_at), COUNT(*), SUM(quantity) FROM stock_flow GROUP BY DATE(created_at) ORDER BY DATE(created_at) DESC LIMIT 20;第二查订单状态字段,看报表查询条件里有没有过滤status='COMPLETED';第三查settings.py的TIME_ZONE和USE_TZ,如果USE_TZ=True而TIME_ZONE='UTC',那所有auto_now_add存的时间都是UTC,本地报表必须做时区转换。这套排查路径走下来,超过一半的“数据不对”都会被识破。6.2 入库重复执行导致库存翻倍:用幂等校验挡住现象:同一个采购单号在库存流水里出现了两次,库存翻倍了。原因:采购入库的接口没有做幂等判断,运营不小心点了两次“入库”按钮,或者请求超时后前端重试了一次。解决:在入口处先查流水表有没有同order_no的记录:if StockFlow.objects.filter(order_no=purchase_order.order_no, flow_type='IN').exists(): raise ValueError(f"采购单 {purchase_order.order_no} 已入库,禁止重复操作")搭配事务后,这行校验就是最后一道闸。更稳妥的做法是给StockFlow表加一个(order_no, flow_type)的UniqueConstraint,在数据库层做死约束。SQLite支持复合唯一索引,MySQL也一样,迁移文件里直接定义:class Meta: constraints = [ models.UniqueConstraint(fields=['order_no', 'flow_type'], name='uniq_order_flow') ]这样哪怕应用层代码漏了校验,数据库也不会让你插入第二条同样单据的入库流水。6.3 SQLite文件被锁死:开发环境跑着跑着就报database is locked现象:开发环境突然报OperationalError: database is locked,项目就崩了。原因:SQLite并发写的能力很弱,两个进程同时写同一个文件就会锁死。常见于你开了两个runserver窗口,或某个脚本在后台跑长事务没提交。解决:先拆后台进程。项目如果只在本地开发用SQLite,Django的runserver是单进程的,基本不会遇到;一旦你上了gunicorn之类的多worker部署,SQLite锁的问题就会爆发。此时必须换PostgreSQL或MySQL。如果只是想临时救急,可以在settings.py里给SQLite加超时和线程选项:DATABASES = { 'default': { 'ENGINE': 'django.db.backends.sqlite3', 'NAME': BASE_DIR / 'db.sqlite3', 'OPTIONS': { 'timeout': 20, 'check_same_thread': False, } } }timeout单位是秒,意思是锁等待最多20秒;check_same_thread=False允许跨线程复用连接,这在多线程开发服务器下能缓解一部分问题。但说穿了这只是创可贴,换掉SQLite才是根本解。6.4 删除商品时报外键约束错误:别轻易改级联策略现象:Admin后台里想删掉某个商品,点击删除报ProtectedForeignKey错误。原因:模型里StockFlow.product用的是on_delete=PROTECT,而流水表里有该商品的历史记录,数据库层不允许你删父表记录。解决:正确的业务动作不是删除商品,而是“停用”——把Product.is_active改成False,前端下单的商品选择器里不再显示它,历史流水原样保留。如果必须物理删除(比如测试数据清理),先把关联流水删掉或转走:# 只允许在确认无误的情况下执行 StockFlow.objects.filter(product=product).update(product=None) # 前提是外键允许null product.delete()现实中我见过最离谱的一次事故是,某开发者为了删商品,把所有on_delete都换成了CASCADE,结果删一个商品连带删了一整年的流水,报表和财务数据全没了。这条血泪经验写在这里:数据库约束是保护伞,别乱改成级联。6.5 重跑迁移文件时数据全丢:迁移前不备份的后悔药现象:项目加了新字段,执行makemigrations后一切正常;migrate后打开Admin发现之前录入的数据全没了。原因:migrate不是只加字段,它还会执行数据库里所有未应用的迁移。如果项目里有DeleteModel或RemoveField这类破坏性迁移,数据在那一刻就被清了。更隐蔽的是,如果你把数据库文件换成了空文件再跑迁移,Django会从零建表,原数据就“蒸发”了。解决:跑migrate前备份数据库文件,这条习惯要刻进肌肉记忆:cp db.sqlite3 db.sqlite3.bak_$(date +%Y%m%d_%H%M%S)如果数据已经丢了,只剩备份文件,恢复就是把备份文件复制回去,然后重启服务。如果连备份都没有,就只能捞SQLite的WAL文件碰运气,但SQLite默认不开WAL,基本没戏。说难听点,这就是没有后悔药的事故。6.6 时区引发的日期统计偏差:所有报表按天统计都会踩的坑现象:后台报表里某天的销售数据总是少了几单,细看都是晚上11点后的订单。原因:TIME_ZONE设成了UTC,数据库存的是UTC时间,而SQLite的DATE(created_at)函数取的是字符串里的日期,相当于按UTC切天。中国时区比UTC早8小时,晚上8点之后创建的订单,在UTC日期上已经“跑到第二天”去了。解决:项目里如果只用Django ORM,不写原生SQL,那settings.py里USE_TZ=True配合TIME_ZONE='Asia/Shanghai'就够了,Django会在ORM查询时自动做时区转换。但如果你像我一样写了原生SQL或用了extra(),就必须自己处理:from django.utils import timezone from datetime import timedelta # 报表查询时的本地日期范围 start_local = timezone.now().replace(hour=0, minute=0, second=0, microsecond=0) end_local = start_local + timedelta(days=1) # 转成UTC存储时间再过滤 start_utc = start_local.astimezone(timezone.utc) end_utc = end_local.astimezone(timezone.utc) StockFlow.objects.filter(created_at__gte=start_utc, created_at__lt=end_utc)astimezone(timezone.utc)不是可选的,是按日报表的必写步骤。很多项目上线一年后才发现月度报表对不上,回查全是这个窟窿。6.7 金额精度对齐问题:DecimalField和浮点数计算的边界现象:订单总额和明细小计加起来差了0.01元,财务对账时发现。原因:Django的DecimalField在数据库层是精确的,但如果你在Python代码里不自觉地用float()做了一次转换,精度就丢在那一瞬间了。解决:所有金额计算统一用Decimal,并在中间步骤保持足够精度。我习惯在所有涉及钱的地方写这样的工具函数:from decimal import Decimal def calc_subtotal(price, quantity): """金额计算统一走Decimal,不落浮点数""" return Decimal(price) * Decimal(quantity)且项目约定:数据库里price字段一律DecimalField,传输层用字符串序列化,前端展示时再做格式化。这条约束写进团队开发规范里,比事后修bug便宜十倍。前面看到的round(new_avg_cost, 4)也是同样原则,只在小数点位做舍入,绝不中间过程用float。7. 进阶:把数据库文件变成可复用的种子数据,让项目交付物更完整7.1 用dumpdata导出授权数据,区分“开发用”和“交付用”进销存项目交付时,数据库文件里经常堆着测试数据——一堆“商品A”“测试商品B”的垃圾记录,这种交付物给到别人手上,第一印象就崩了。我习惯在交付前用Django的dumpdata导出“干净数据”,只保留基础配置和少量演示用的商品、供应商记录。# 导出核心业务数据为JSON python manage.py dumpdata product.Inventory --indent 2 > seed_inventory.json python manage.py dumpdata product.Product --indent 2 > seed_product.json python manage.py dumpdata stock.StockFlow --indent 2 > seed_flow.json逻辑说明:dumpdata的格式是app_label.ModelName,要把依赖关系理清楚——先导Product再导Inventory,不然导入时空引用会断。三个文件分开存,方便使用者只需要看业务结构时,只导入前两个。--indent 2是让JSON可读性更好,方便代码评审的人直接看内容。loaddata导入时,如果目标数据库里已有相同主键的数据,会直接覆盖。所以交付时要写清楚导入顺序,否则后导入的Product数据会把库存表的外键全部带歪。参数说明:如果交付物里包含订单和流水,dumpdata导出的数据量会很大,而且包含created_at这些时间信息。我通常只保留最近一周的流水作为示例,更多历史数据不导出——毕竟新人用学习项目,不需要三个月的历史流水撑容量。7.2 写一个初始化脚本,让新手一键跑通不管你的数据库文件做得多干净,都不如一份init_demo.py来得省事——至少能让对方把项目跑起来时,不用手工在Admin里点半天录入数据。我用Django的manage.py shell执行初始化:# init_demo.py from product.models import Product, Inventory from stock.models import StockFlow def run(): """初始化演示数据:3个商品+初始库存+一笔入库流水""" demo_products = [ {'sku': 'SKU-001', 'name': '演示商品A', 'sale_price': '99.00', 'purchase_price': '60.00'}, {'sku': 'SKU-002', 'name': '演示商品B', 'sale_price': '199.00', 'purchase_price': '120.00'}, {'sku': 'SKU-003', 'name': '演示商品C', 'sale_price': '29.90', 'purchase_price': '15.00'}, ] for item in demo_products: product, _ = Product.objects.update_or_create( sku=item['sku'], defaults={ 'name': item['name'], 'sale_price': item['sale_price'], 'purchase_price': item['purchase_price'], } ) inv, _ = Inventory.objects.update_or_create( product=product, defaults={'quantity': 100, 'safety_stock': 20} )运行时:python manage.py shell < init_demo.py逻辑说明:update_or_create是幂等写法,脚本可以重复执行而不会把库存越加越多。这个脚本的意义不在于省那三分钟手敲数据的时间,而在于它把“业务演示数据”从数据库文件里独立出来了——数据库文件只管结构,脚本管数据,交付时两边都不互相绑架。参数说明:shell < init_demo.py的写法在Windows PowerShell里可能不生效,建议Windows用户改用type init_demo.py | python manage.py shell。这是跨平台踩坑点之一,文档里最好写清楚两种平台的执行方式。7.3 自定义JSON Encoder:当dumpdata遇到Decimal和Datetime直接dumpdata导出时,DecimalField和DateTimeField会被Django自动处理成可解析的格式,这个天然的优势就是它能跨库导入。但如果你自己写json.dumps序列化ORM对象,十有八九会报TypeError: Object of type Decimal is not JSON serializable。原因就是Decimal不是原生JSON类型。解决方式是自定义Encoder:import json from decimal import Decimal from datetime import datetime, date class CustomJSONEncoder(json.JSONEncoder): """支持Decimal和datetime的JSON序列化""" def default(self, obj): if isinstance(obj, Decimal): return float(obj) if isinstance(obj, (datetime, date)): return obj.strftime('%Y-%m-%d %H:%M:%S') return super().default(obj)dumpdata内部已经帮你做了这些,所以这个Encoder更多用于你写导出脚本给自己做备份时用。给出的代码里float(obj)的转换会损失精度——但这只是导出备份用的,不是数据库运算,所以可接受。如果要精确到分,也可以转成字符串。json_str = json.dumps(queryset_data, cls=CustomJSONEncoder, ensure_ascii=False)ensure_ascii=False保证中文不被转成\uXXXX,文件更可读。这套进阶动作做完,你的交付物就不再是“源码+数据库文件”二件套,而是“源码+可重复构建的种子数据+初始化脚本”三件套——这会让你的项目代码评审体验上一个档次,也让别人真正敢把系统拿去改。我自己的习惯是:不管接手的项目原始形态多粗糙,我都会要求交付内容里必须有一份init_demo.py和一版seed.json,因为复制一份数据库文件交出去,对方永远不知道你数据库里藏着什么;但跑一遍初始化脚本,所有表结构和基础数据都会冷静地摊开在面前,这才是能让人放心开始修改的起点。进销存这套逻辑无论用Django还是别的框架,核心都在“库存流水是否完整、事务边界是否清晰、金额精度是否可靠”,你把这些点钉死,系统就立得住。希望帮到你。本文还有配套的精品资源,点击获取