Django进销存系统源码拆解:从数据模型到库存事务的完整实践

发布时间:2026/10/20 10:51:38
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.11Django版本先看项目里的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 Django3.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.mysqlNAME填shop_db再补USER、PASSWORD、HOST、PORT。这里有个血泪经验老项目的SQL导出文件里可能带着SET FOREIGN_KEY_CHECKS0这类会话级操作导入时终端会刷一屏警告只要最后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_length64, uniqueTrue) name models.CharField(商品名称, max_length128) brand models.CharField(品牌, max_length64, blankTrue) category models.CharField(分类, max_length64, blankTrue) sale_price models.DecimalField(销售价, max_digits10, decimal_places2) purchase_price models.DecimalField(采购价, max_digits10, decimal_places2) is_active models.BooleanField(上架, defaultTrue) created_at models.DateTimeField(创建时间, auto_now_addTrue) class Meta: db_table product indexes [ models.Index(fields[sku]), models.Index(fields[category]), ] class Inventory(models.Model): 库存汇总表——与SKU一对一 product models.OneToOneField(Product, on_deletemodels.CASCADE, primary_keyTrue) quantity models.IntegerField(当前库存数, default0) safety_stock models.IntegerField(安全库存, default0) updated_at models.DateTimeField(最后变动时间, auto_nowTrue) class Meta: db_table inventory逻辑说明把售价、采购价放在Product表把可变的数量放进Inventory表好处是商品资料相对静态库存是高频变动字段拆开后行锁竞争小、缓存命中率高。OneToOneField用主键关联是为了保证一个SKU只有一行库存记录查询时直接走主键索引。参数说明max_digits10, decimal_places2是钱的标准配置能存到千万级金额不丢精度。为什么不用FloatField因为浮点数算钱会有0.10.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_deletemodels.PROTECT) flow_type models.CharField(流水类型, max_length10, choicesFLOW_TYPES) quantity models.IntegerField(变动数量正负号表示方向) before_quantity models.IntegerField(变动前库存) after_quantity models.IntegerField(变动后库存) order_no models.CharField(关联单据号, max_length64, blankTrue) remark models.CharField(备注, max_length255, blankTrue) operator models.CharField(操作人, max_length32, blankTrue) created_at models.DateTimeField(发生时间, auto_now_addTrue) 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_deletePROTECT是硬约束防止你把一个有过流水的商品直接删掉。实际开发中更稳的做法是on_deleteSET_NULL加nullTrue但PROTECT的好处是强迫你先处理历史数据不做“删库跑路”。参数说明created_at用auto_now_addTrue但需要注意——如果你在代码里手工造测试数据Django会忽略你赋的值一律拿当前时间。这在后面做对账、按时间维度统计时会有些麻烦所以测试数据建议直接写SQL插入或通过ORM批量创建再统一改时间。3.3 订单模型头表和明细表分离为什么是进销存的铁律采购单、销售单、退货单都遵循“一主多从”的结构。主表存单号、客户/供应商、总金额、状态、时间明细表存商品、数量、单价、小计。为什么要拆因为订单头是低频更新状态流转变更订单明细是高频新增但不修改——合在一张表里会让更新逻辑纠缠不清。class PurchaseOrder(models.Model): 采购单主表 order_no models.CharField(单号, max_length32, uniqueTrue) supplier models.CharField(供应商, max_length128) total_amount models.DecimalField(总额, max_digits12, decimal_places2, default0) STATUS_CHOICES ( (DRAFT, 草稿), (CONFIRMED, 已确认), (COMPLETED, 已完成), (CANCELLED, 已取消), ) status models.CharField(状态, max_length10, choicesSTATUS_CHOICES, defaultDRAFT) created_at models.DateTimeField(创建时间, auto_now_addTrue) confirmed_at models.DateTimeField(确认时间, nullTrue, blankTrue) class PurchaseOrderItem(models.Model): 采购单明细——每行一个商品 order models.ForeignKey(PurchaseOrder, on_deletemodels.CASCADE, related_nameitems) product models.ForeignKey(Product, on_deletemodels.PROTECT) quantity models.IntegerField(采购数量) price models.DecimalField(采购单价, max_digits10, decimal_places2) subtotal models.DecimalField(小计, max_digits12, decimal_places2) def save(self, *args, **kwargs): self.subtotal self.quantity * self.price super().save(*args, **kwargs)逻辑说明related_nameitems让你可以直接用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( productproduct, defaults{quantity: 0, safety_stock: 0} ) before_qty inventory.quantity Inventory.objects.filter(pkinventory.pk).update( quantityF(quantity) item.quantity ) StockFlow.objects.create( productproduct, flow_typeIN, quantityitem.quantity, before_quantitybefore_qty, after_quantitybefore_qty item.quantity, order_nopurchase_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(productitem.product) if inventory.quantity item.quantity: raise ValueError(f{item.product.sku} 库存不足当前{inventory.quantity}需要{item.quantity}) before_qty inventory.quantity Inventory.objects.filter(pkinventory.pk).update( quantityF(quantity) - item.quantity ) StockFlow.objects.create( productitem.product, flow_typeOUT, quantity-item.quantity, # 出库记负数 before_quantitybefore_qty, after_quantitybefore_qty - item.quantity, order_noorder.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_idproduct_id) diff actual_quantity - inventory.quantity if diff 0: return # 账实相符无事发生 before_qty inventory.quantity Inventory.objects.filter(pkinventory.pk).update(quantityactual_quantity) StockFlow.objects.create( product_idproduct_id, flow_typeADJUST, quantitydiff, before_quantitybefore_qty, after_quantityactual_quantity, remarkremark, operatoroperator, )逻辑说明差异量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_idproduct_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(days30, limit10): 最近N天销量TOP10 since datetime.now() - timedelta(daysdays) qs (StockFlow.objects .filter(flow_typeOUT, created_at__gtesince) .values(product__sku, product__name) .annotate(total_qtySum(quantity)) .order_by(total_qty)[:limit]) return list(qs)逻辑说明这里Sum(quantity)能直接用是因为出库流水的quantity是负数Sum的结果就是净出库量。不用再写Sum(quantity) * -1。.values()先按SKU和名称分组再在分组基础上累加这是ORM里固定套路。一个隐藏的坑created_at__gtesince依赖数据库当前时间如果开发机的系统时间和服务器时间不同步报表会莫名“缺最近两小时数据”或“多了明天的数据”。这是老项目常见的玄学问题排查办法是先在Django shell里跑两遍查询输出datetime.now()对比一下。参数说明timedelta(days30)表示最近30天自然日。这个口径在进销存里其实有三种自然日滚动、按月自然月、按工作日历。上线前一定要和业务方确认清楚用哪种否则报表对不上运营的口径你来挨骂。5.3 库存预警SQL低库存和负库存一次查出来报表看板上的“库存预警”是一个红黄绿灯逻辑。红灯给负库存黄灯给低于安全库存的商品from django.db.models import Q def stock_warning_list(): 库存预警低于安全库存或负库存 warn_items (Inventory.objects .select_related(product) .filter(Q(quantity__ltF(safety_stock)) | Q(quantity__lt0)) .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_stockORM里就得用F()。所有库存汇总表的查询都用select_related预加载商品表避免N1查询。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;第二查订单状态字段看报表查询条件里有没有过滤statusCOMPLETED第三查settings.py的TIME_ZONE和USE_TZ如果USE_TZTrue而TIME_ZONEUTC那所有auto_now_add存的时间都是UTC本地报表必须做时区转换。这套排查路径走下来超过一半的“数据不对”都会被识破。6.2 入库重复执行导致库存翻倍用幂等校验挡住现象同一个采购单号在库存流水里出现了两次库存翻倍了。原因采购入库的接口没有做幂等判断运营不小心点了两次“入库”按钮或者请求超时后前端重试了一次。解决在入口处先查流水表有没有同order_no的记录if StockFlow.objects.filter(order_nopurchase_order.order_no, flow_typeIN).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], nameuniq_order_flow) ]这样哪怕应用层代码漏了校验数据库也不会让你插入第二条同样单据的入库流水。6.3 SQLite文件被锁死开发环境跑着跑着就报database is locked现象开发环境突然报OperationalError: database is locked项目就崩了。原因SQLite并发写的能力很弱两个进程同时写同一个文件就会锁死。常见于你开了两个runserver窗口或某个脚本在后台跑长事务没提交。解决先拆后台进程。项目如果只在本地开发用SQLiteDjango的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_threadFalse允许跨线程复用连接这在多线程开发服务器下能缓解一部分问题。但说穿了这只是创可贴换掉SQLite才是根本解。6.4 删除商品时报外键约束错误别轻易改级联策略现象Admin后台里想删掉某个商品点击删除报ProtectedForeignKey错误。原因模型里StockFlow.product用的是on_deletePROTECT而流水表里有该商品的历史记录数据库层不允许你删父表记录。解决正确的业务动作不是删除商品而是“停用”——把Product.is_active改成False前端下单的商品选择器里不再显示它历史流水原样保留。如果必须物理删除比如测试数据清理先把关联流水删掉或转走# 只允许在确认无误的情况下执行 StockFlow.objects.filter(productproduct).update(productNone) # 前提是外键允许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_TZTrue配合TIME_ZONEAsia/Shanghai就够了Django会在ORM查询时自动做时区转换。但如果你像我一样写了原生SQL或用了extra()就必须自己处理from django.utils import timezone from datetime import timedelta # 报表查询时的本地日期范围 start_local timezone.now().replace(hour0, minute0, second0, microsecond0) end_local start_local timedelta(days1) # 转成UTC存储时间再过滤 start_utc start_local.astimezone(timezone.utc) end_utc end_local.astimezone(timezone.utc) StockFlow.objects.filter(created_at__gtestart_utc, created_at__ltend_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( skuitem[sku], defaults{ name: item[name], sale_price: item[sale_price], purchase_price: item[purchase_price], } ) inv, _ Inventory.objects.update_or_create( productproduct, 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类型。解决方式是自定义Encoderimport 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, clsCustomJSONEncoder, ensure_asciiFalse)ensure_asciiFalse保证中文不被转成\uXXXX文件更可读。这套进阶动作做完你的交付物就不再是“源码数据库文件”二件套而是“源码可重复构建的种子数据初始化脚本”三件套——这会让你的项目代码评审体验上一个档次也让别人真正敢把系统拿去改。我自己的习惯是不管接手的项目原始形态多粗糙我都会要求交付内容里必须有一份init_demo.py和一版seed.json因为复制一份数据库文件交出去对方永远不知道你数据库里藏着什么但跑一遍初始化脚本所有表结构和基础数据都会冷静地摊开在面前这才是能让人放心开始修改的起点。进销存这套逻辑无论用Django还是别的框架核心都在“库存流水是否完整、事务边界是否清晰、金额精度是否可靠”你把这些点钉死系统就立得住。希望帮到你。本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询