news 2026/10/26 10:33:39

Python进销存系统源码:库存流水结存双写与对账避坑

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Python进销存系统源码:库存流水结存双写与对账避坑

简介:这套Python进销存系统源码面向具备Python基础、希望完整实践Web业务系统的开发者及小型商贸企业的信息化人员,围绕采购入库、销售出库、库存盘点等流程组织代码,可用于课程设计、毕业项目或二次开发起步。实现上涉及Django、Flask等Web框架的路由映射与请求处理,SQLAlchemy一类ORM的建模查询,Jinja2模板渲染与WTForms表单校验,会话保持与基于角色的登录认证,也包含logging日志、FIFO与LIFO两种补货策略,以及pandas、matplotlib生成的销售趋势与库存报表;部署环节常配合Nginx、Gunicorn与Docker。压缩包为zip格式,约91.29MB,已有709人学习下载。读者可对照模块划分理解数据模型与业务流程的对应关系,梳理权限设计与报表生成思路,并在此基础上改造字段、补充单据类型或迁移到自建数据库环境。

1. 从 Excel 对账到实时结存:python进销存系统源码能接住的三类活

上个月帮一个做五金批发的小团队看账,他们用三张 Excel 表管进货、销售和库存,月底对账对到凌晨,最后发现一张销售单重复录了两次。这类问题不是人粗心,是工具没把单据和库存流水绑在一起。python进销存系统源码要解决的就是三件事:商品档案统一、采购销售单据留痕、库存随单据实时结存。它不是给大型 ERP 做替代,而是给会一点 Python、需要按自己业务改字段的小团队一个可二次开发的起点。如果你正找一套能本地跑起来、能改、能对账的 python 软件/插件式源码,这套东西的边界和用法得先讲清楚:它适合单仓或多仓的中小批发、零售场景,不适合上来就做多组织、多币种、复杂生产制造的企业。

2. 数据模型先立住:6 张核心表与库存流水、结存双写模型

很多 python 进销存源码包跑不起来,不是代码报错,是表关系没理清。商品和 SKU 混在一张表、仓库只有名字没有 ID、单据明细不记批次,结果一改库存就全乱。这里按常见做法拆成 6 张核心表加 2 张辅助表:商品档案、仓库、往来单位、采购单头/明细、销售单头/明细、库存流水、库存结存。先把这个模型看懂,后面改字段、加批次、接报表才有地基。

2.1 为什么这类系统常用 Django/Flask + MySQL 而不是一上来微服务

进销存的单据量通常不大,但事务边界很清楚:一张采购单确认,要同时改单据状态、写库存流水、更新结存,三步必须原子。单体应用加关系型数据库最省事,Django 自带 ORM、迁移、admin 和事务装饰器,Flask 加 SQLAlchemy 也够用。MySQL 的 InnoDB 行锁、唯一索引、DECIMAL 类型能直接兜住库存扣减和金额精度。常见做法是先把单体跑通,等单据量真的上来,再把报表查询拆出去,不要一开始就拆服务,否则一个库存事务跨三个服务,排错成本翻倍。

选型上还有一个现实原因:这类源码包多半是给二次开发用的,目录越简单越好改。Django 的 app 结构把商品、采购、销售、库存分开,改一个模块不影响其他模块;Flask 的蓝图也类似。数据库用 MySQL 8 而不是 SQLite,主要是并发写入和行锁更稳,SQLite 在本地演示可以,真上多用户收银、仓库同时操作,写锁会让人翻车。

2.2 六张核心表的字段与关系:从商品档案到库存流水

先把表按职责分清楚。商品表只放档案,不放库存;库存只放流水和结存,不放商品名称;单据头放往来单位、仓库、状态和总额,单据明细放商品、数量、单价、批次。这样拆的好处是:商品改名不影响历史单据,仓库调拨不影响流水方向,退货单可以引用原单号而不覆盖原流水。

表名作用关键字段关联
product商品档案id, sku, name, unit, category_id, purchase_price, sale_pricecategory
warehouse仓库id, name, is_default
partner往来单位id, type(supplier/customer), name, tax_no
purchase_order采购单头id, doc_no, partner_id, warehouse_id, status, total_amount, created_atpartner, warehouse
purchase_item采购明细id, order_id, product_id, qty, unit_price, batch_noorder, product
sales_order销售单头id, doc_no, partner_id, warehouse_id, status, total_amountpartner, warehouse
sales_item销售明细id, order_id, product_id, qty, unit_price, batch_noorder, product
stock_flow库存流水id, doc_no, product_id, warehouse_id, direction, qty, unit_cost, occurred_atproduct, warehouse
stock_balance库存结存id, product_id, warehouse_id, qty, avg_cost, versionproduct, warehouse

库存流水表只追加、不修改,direction 用 in/out 表示方向,qty 永远存正数,unit_cost 记本次成本。结存表是冗余,用来加速列表和报表查询,qty 是当前结存,avg_cost 是移动加权平均成本,version 用来做乐观锁。单据状态常用 draft/confirmed/void,确认后不允许直接改明细,要改先冲红或反审核,这是后面对账不乱的前提。

建表时数量用 DECIMAL(18,4),金额用 DECIMAL(18,2),不要用 float。下面这段 SQL 是核心三张表的骨架,字段名可以按源码包调整,但类型和索引建议保留。

CREATE TABLE product ( id BIGINT PRIMARY KEY AUTO_INCREMENT, sku VARCHAR(64) NOT NULL, name VARCHAR(128) NOT NULL, unit VARCHAR(16) NOT NULL DEFAULT '件', purchase_price DECIMAL(18,2) NOT NULL DEFAULT 0, sale_price DECIMAL(18,2) NOT NULL DEFAULT 0, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_sku (sku) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE stock_flow ( id BIGINT PRIMARY KEY AUTO_INCREMENT, doc_no VARCHAR(64) NOT NULL, product_id BIGINT NOT NULL, warehouse_id BIGINT NOT NULL, direction ENUM('in','out') NOT NULL, qty DECIMAL(18,4) NOT NULL, unit_cost DECIMAL(18,4) NOT NULL DEFAULT 0, occurred_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_doc_product_wh_dir (doc_no, product_id, warehouse_id, direction), KEY idx_product_wh (product_id, warehouse_id), KEY idx_occurred (occurred_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE stock_balance ( id BIGINT PRIMARY KEY AUTO_INCREMENT, product_id BIGINT NOT NULL, warehouse_id BIGINT NOT NULL, qty DECIMAL(18,4) NOT NULL DEFAULT 0, avg_cost DECIMAL(18,4) NOT NULL DEFAULT 0, version INT NOT NULL DEFAULT 0, UNIQUE KEY uk_product_wh (product_id, warehouse_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

这段 SQL 里最关键是 stock_flow 的唯一索引 uk_doc_product_wh_dir。同一个单据号、同一个商品、同一个仓库、同一个方向只允许一条流水,这就是幂等的最后一道闸。 occurred_at 加索引是为了按日统计报表;product_id + warehouse_id 联合索引是为了查单个商品结存。stock_balance 的 uk_product_wh 保证一个商品在一个仓库只有一条结存记录,后面 get_or_create 才不会插出两条。

2.3 库存流水与结存双写:选型理由和 ORM 落地

库存模型常见有三种:只记流水,结存用 SUM 实时算;只记结存,流水不落地;流水加结存双写。第一种查报表慢,数据量大时按日汇总会拖垮页面;第二种对账没有依据,改错一笔就说不清;双写最稳,流水是唯一事实,结存是缓存。双写必须在一个事务里,顺序建议先写流水、再更新结存、最后改单据状态。如果结存更新失败,整个事务回滚,流水也不会留下半条。

用 Django ORM 落地时,流水模型和结存模型可以这样写。注意 Meta 里的 unique_together 要和数据库唯一索引对齐,不然 ORM 层不会报错,数据库会报重复键。

from django.db import models class StockFlow(models.Model): doc_no = models.CharField(max_length=64) product_id = models.BigIntegerField() warehouse_id = models.BigIntegerField() direction = models.CharField(max_length=3, choices=[('in', '入库'), ('out', '出库')]) qty = models.DecimalField(max_digits=18, decimal_places=4) unit_cost = models.DecimalField(max_digits=18, decimal_places=4, default=0) occurred_at = models.DateTimeField(auto_now_add=True) class Meta: db_table = 'stock_flow' unique_together = ('doc_no', 'product_id', 'warehouse_id', 'direction') indexes = [ models.Index(fields=['product_id', 'warehouse_id']), models.Index(fields=['occurred_at']), ] class StockBalance(models.Model): product_id = models.BigIntegerField() warehouse_id = models.BigIntegerField() qty = models.DecimalField(max_digits=18, decimal_places=4, default=0) avg_cost = models.DecimalField(max_digits=18, decimal_places=4, default=0) version = models.IntegerField(default=0) class Meta: db_table = 'stock_balance' unique_together = ('product_id', 'warehouse_id')

这两个模型没有外键到 product 和 warehouse,是为了让库存模块可以独立迁移,也避免商品删除时被外键卡住。实际项目里如果源码包用了 ForeignKey,也不用改,只要保证 product_id 和 warehouse_id 有索引即可。迁移命令放到下一章一起跑,先确认表结构能和源码里的 models.py 对上,再往下走。

3. 本地跑通:Python 3.11 + MySQL 8 的依赖、配置与初始化

拿到源码包先别急着改业务,第一步是让它在本地跑起来。版本选择上,Python 3.11 比 3.12 稳,很多依赖轮子齐全;MySQL 8 支持窗口函数和 CTE,后面做报表方便;Django 4.2 LTS 是常见搭配。如果你的源码包是 Flask + SQLAlchemy,命令换成 flask run,数据库配置思路一样。下面按 Django 项目写,其他框架对照改配置即可。

3.1 依赖安装与数据库配置:先锁 Python 与 MySQL 版本

先建虚拟环境,再装依赖。不要用系统 Python 直接 pip install,依赖冲突时后悔药很难吃。requirements.txt 里通常会有 Django、PyMySQL、python-dotenv、djangorestframework,版本按源码包里的锁文件来,不要随手升级大版本。

# 创建虚拟环境,Python 3.11 python3.11 -m venv venv source venv/bin/activate # 安装依赖,按源码包里的 requirements.txt pip install -r requirements.txt # 如果源码包没有锁版本,至少锁这几个大件 pip install "Django==4.2.*" "PyMySQL==1.1.*" "python-dotenv==1.0.*"

安装完成后先跑 pip check,看有没有依赖冲突。接着建数据库和账号,字符集用 utf8mb4,排序规则用 utf8mb4_0900_ai_ci 或 utf8mb4_general_ci 都行,但要和源码包保持一致,否则中文排序和大小写行为可能不同。

CREATE DATABASE erp_demo DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci; CREATE USER 'erp_user'@'localhost' IDENTIFIED BY 'change_me_123'; GRANT ALL PRIVILEGES ON erp_demo.* TO 'erp_user'@'localhost'; FLUSH PRIVILEGES;

数据库配置建议走环境变量,不要硬编码在 settings.py 里。下面这段配置的关键参数是 HOST、PORT、OPTIONS 里的 charset,以及 TIME_ZONE。USE_TZ=True 时数据库存 UTC,展示层再转本地,后面第 5 章会讲时区坑。

# settings.py 片段 import os from dotenv import load_dotenv load_dotenv() DATABASES = { 'default': { 'ENGINE': 'django.db.backends.mysql', 'NAME': os.getenv('DB_NAME', 'erp_demo'), 'USER': os.getenv('DB_USER', 'erp_user'), 'PASSWORD': os.getenv('DB_PASSWORD', 'change_me_123'), 'HOST': os.getenv('DB_HOST', '127.0.0.1'), 'PORT': os.getenv('DB_PORT', '3306'), 'OPTIONS': { 'charset': 'utf8mb4', 'init_command': "SET sql_mode='STRICT_TRANS_TABLES'", }, 'TIME_ZONE': '+00:00', } } USE_TZ = True TIME_ZONE = 'Asia/Shanghai'

参数说明:NAME 是库名,USER/PASSWORD 换成你刚建的账号;HOST 用 127.0.0.1 比 localhost 更少走 socket 歧义;OPTIONS 里的 init_command 把 sql_mode 设严格,避免插入超长字段被静默截断;TIME_ZONE 给数据库连接用 UTC,Django 的 TIME_ZONE 给展示层用上海时区。这两者不要混。

3.2 迁移、建管理员与种子数据:让首页有数可看

表结构确认后跑迁移。先 makemigrations 再 migrate,如果源码包已经带了 migrations 目录,直接 migrate。迁移前把数据库备份一遍,空库无所谓,但后面改字段时这个习惯能救命。

python manage.py makemigrations python manage.py migrate # 创建后台管理员 python manage.py createsuperuser

创建管理员时用户名、邮箱、密码按提示填,密码别用 123456。接着灌种子数据,至少建一个默认仓库、两个商品、一个供应商和一个客户,不然首页全是空表,接口自测也没数据。下面这个脚本用 Django shell 跑,字段名按你的 models.py 调整。

# python manage.py shell < seed.py from decimal import Decimal from app.models import Warehouse, Product, Partner wh, _ = Warehouse.objects.get_or_create(name='主仓', defaults={'is_default': True}) p1, _ = Product.objects.get_or_create( sku='SKU-001', defaults={'name': '六角螺栓 M8', 'unit': '盒', 'purchase_price': Decimal('12.50'), 'sale_price': Decimal('18.00')} ) p2, _ = Product.objects.get_or_create( sku='SKU-002', defaults={'name': '平垫片 M8', 'unit': '包', 'purchase_price': Decimal('3.20'), 'sale_price': Decimal('5.00')} ) Partner.objects.get_or_create(name='模拟供应商A', defaults={'type': 'supplier'}) Partner.objects.get_or_create(name='模拟客户B', defaults={'type': 'customer'}) print('seed done:', wh.name, p1.sku, p2.sku)

这段脚本用 get_or_create 而不是 create,重复执行不会报唯一键冲突。金额用 Decimal 字符串传入,不要写 12.5 这种浮点字面量。种子数据只用于跑通,不要把它当正式档案,正式上线前清掉或改 sku。

3.3 启动与第一个接口自测:用 curl 验证登录和商品列表

启动开发服务器,先看首页和 admin 能不能打开。然后用 curl 走一遍登录和商品列表,确认数据库连接、序列化、权限都通。如果源码包用了 DRF 的 Token 或 JWT,登录接口返回的 token 要带上。

python manage.py runserver 0.0.0.0:8000 # 登录取 token,字段名按源码包调整 curl -X POST http://127.0.0.1:8000/api/login/ \ -H "Content-Type: application/json" \ -d '{"username":"admin","password":"your_password"}' # 带 token 查商品列表 curl -H "Authorization: Bearer <token>" \ "http://127.0.0.1:8000/api/products/?page=1&page_size=20"

自测时重点看三个返回:登录是否返回 token、商品列表是否有 total 和 results、分页参数 page_size 是否生效。如果返回 401,先查认证头格式;如果返回 500,看控制台堆栈,多半是数据库字段和 models.py 不一致。走到这一步,环境就算跑通了,后面再动业务代码。

4. 采购入库与销售出库:库存扣减的事务边界和 4 个接口参数

进销存最核心的两条链路是采购入库和销售出库。入库加库存,出库减库存,退货反向,盘点调整。四条链路都必须走同一个库存服务,不能这里直接改结存、那里写流水。事务边界要包住三件事:单据状态变更、库存流水写入、结存更新。少包一件,月底对账就会出现“单据已确认但库存没动”的黑匣子。

4.1 入库事务:select for update 与流水号防重

采购入库先锁单据,防止两个人同时点确认。然后逐行写流水、更新结存、算移动加权平均成本,最后把单据状态改成 confirmed。下面这段代码是 Django ORM 写法,关键点是 select_for_update 锁单据行,get_or_create 配合唯一索引防并发插入,F 表达式更新 version 防丢失更新。

from decimal import Decimal from django.db import transaction from django.db.models import F from .models import PurchaseOrder, PurchaseItem, StockFlow, StockBalance @transaction.atomic def confirm_purchase(order_id: int): order = PurchaseOrder.objects.select_for_update().get(id=order_id) if order.status != 'draft': raise ValueError('单据状态不允许入库') for item in PurchaseItem.objects.filter(order_id=order.id): # 1. 写流水,唯一索引兜底防重 StockFlow.objects.create( doc_no=order.doc_no, product_id=item.product_id, warehouse_id=order.warehouse_id, direction='in', qty=item.qty, unit_cost=item.unit_price, ) # 2. 锁结存行,没有则建 balance, _ = StockBalance.objects.select_for_update().get_or_create( product_id=item.product_id, warehouse_id=order.warehouse_id, defaults={'qty': Decimal('0'), 'avg_cost': Decimal('0'), 'version': 0}, ) new_qty = balance.qty + item.qty total_cost = balance.qty * balance.avg_cost + item.qty * item.unit_price balance.avg_cost = (total_cost / new_qty) if new_qty else Decimal('0') balance.qty = new_qty balance.version = F('version') + 1 balance.save(update_fields=['qty', 'avg_cost', 'version']) order.status = 'confirmed' order.save(update_fields=['status'])

逻辑说明:select_for_update 锁住采购单头,同一张单并发确认时第二个事务会等锁,拿到锁后 status 已经不是 draft,直接抛错。流水写入放在结存更新之前,保证“有结存必有流水”。get_or_create 在并发下可能两个事务同时判断不存在,但 stock_balance 的 uk_product_wh 唯一索引会让其中一个失败,外层捕获 IntegrityError 重试即可。F('version') + 1 是数据库端自增,避免读改写覆盖。

参数说明:order_id 是采购单主键;doc_no 是单据号,必须全局唯一;warehouse_id 决定入哪个仓;qty 用 Decimal,单位要和商品单位一致;unit_price 是采购单价,入库成本按移动加权平均摊。如果源码包支持批次,batch_no 要一起写进流水,结存维度也要加批次,否则批次成本会混。

4.2 出库事务:库存校验、扣减与负库存拦截

销售出库比入库多一步库存校验。正确顺序是先锁结存行,再判断 qty 够不够,够才写流水、扣结存。常见错误是先查库存再扣,中间没有锁,两个销售单同时出库就会超卖。下面代码把校验和扣减放在同一个事务里,出库成本用当前 avg_cost,方便后面算毛利。

@transaction.atomic def confirm_sales(order_id: int): order = SalesOrder.objects.select_for_update().get(id=order_id) if order.status != 'draft': raise ValueError('单据状态不允许出库') for item in SalesItem.objects.filter(order_id=order.id): balance = StockBalance.objects.select_for_update().get( product_id=item.product_id, warehouse_id=order.warehouse_id, ) if balance.qty < item.qty: raise ValueError(f'库存不足: product={item.product_id}, need={item.qty}, have={balance.qty}') StockFlow.objects.create( doc_no=order.doc_no, product_id=item.product_id, warehouse_id=order.warehouse_id, direction='out', qty=item.qty, unit_cost=balance.avg_cost, ) balance.qty = balance.qty - item.qty balance.version = F('version') + 1 balance.save(update_fields=['qty', 'version']) order.status = 'confirmed' order.save(update_fields=['status'])

这段代码里 select_for_update().get() 如果结存行不存在会抛 DoesNotExist,说明这个商品在这个仓库从没入过库,直接按库存不足处理更友好。出库流水的 unit_cost 记当前平均成本,不要记销售单价,否则毛利报表会把收入当成本。负库存拦截放在锁内,判断和扣减之间没有其他事务能插进来。

接口参数建议统一成四个核心字段:doc_no、warehouse_id、partner_id、items。items 里每行带 sku_id、qty、unit_price、batch_no。下面是一个销售出库请求示例,字段名按源码包的序列化器调整。

{ "doc_no": "SO-20240520-0001", "warehouse_id": 1, "partner_id": 2, "items": [ {"sku_id": 1, "qty": "2.0000", "unit_price": "18.00", "batch_no": ""}, {"sku_id": 2, "qty": "5.0000", "unit_price": "5.00", "batch_no": ""} ] }

qty 和 unit_price 用字符串传,后端反序列化成 Decimal,避免 JSON 浮点精度问题。doc_no 由前端生成还是后端生成要定死,建议后端按“前缀+日期+序列”生成,前端只传业务类型,减少重复单据号。

4.3 退货与盘点:反向流水怎么写才不把账搅乱

销售退货是入库,采购退货是出库,方向反过来,但 doc_no 不能和原单一样,否则唯一索引冲突。常见做法是原单号加后缀,比如 SO-20240520-0001-R,R 表示 return。退货流水的 unit_cost 用原单成本还是当前平均成本,要看财务口径;简单做法是销售退货按原单出库成本回冲,采购退货按当前结存成本出库。

盘点不要直接改 stock_balance.qty,必须生成调整流水。盘盈写 direction='in',盘亏写 direction='out',qty 存差异绝对值,doc_no 用 PD-日期-序号,unit_cost 用当前 avg_cost。这样流水汇总永远等于结存,后面对账脚本才能跑出零差异。盘点单审核后不允许再改,要改就反审核生成反向流水,这是血泪经验:直接改结存一时爽,月底对账火葬场。

5. 避坑排查:库存对不上、并发超卖、时区错乱的 5 条血泪记录

这套源码在本地跑通不难,难的是上线后前两周。下面五条是这类系统最容易翻车的地方,每条按现象、原因、解决写,照着排查能少熬几个晚上。

5.1 现象:结存和流水汇总对不上

现象是库存列表显示 100,按流水 SUM 出来 98,差了两笔。原因通常有三个:一是先更新结存后写流水,中间异常回滚不完整;二是有人直接连数据库改 stock_balance;三是退货单 doc_no 和原单重复,流水插入失败但结存已经加了。解决的第一步是把流水当唯一事实,写一个对账 SQL 把差异找出来。

SELECT b.product_id, b.warehouse_id, b.qty AS balance_qty, COALESCE(f.flow_qty, 0) AS flow_qty, b.qty - COALESCE(f.flow_qty, 0) AS diff FROM stock_balance b LEFT JOIN ( SELECT product_id, warehouse_id, SUM(CASE WHEN direction = 'in' THEN qty ELSE -qty END) AS flow_qty FROM stock_flow GROUP BY product_id, warehouse_id ) f ON f.product_id = b.product_id AND f.warehouse_id = b.warehouse_id WHERE ABS(b.qty - COALESCE(f.flow_qty, 0)) > 0.0001;

查出来有 diff 的记录,先别急着改结存,去流水表按 product_id + warehouse_id 倒序看最近几笔,确认是漏写还是多写。修复时补一条调整流水,不要 update 结存。长期做法是所有库存变更走同一个 service 函数,禁止业务代码直接碰 StockBalance。

5.2 现象:两个销售单同时出库出现负库存

现象是并发压测时库存扣成负数,或者两个单都成功但实际只够一个单。原因是先 get 再 save,中间没有行锁,两个事务都读到 qty=10,都判断够,都扣 8,最后 -6。解决是用 select_for_update 锁结存行,或者在 SQL 里做条件更新:UPDATE stock_balance SET qty=qty-8 WHERE product_id=? AND warehouse_id=? AND qty>=8,看 affected rows 是否为 1。

注意 select_for_update 必须走索引,where 条件用 product_id + warehouse_id 联合唯一索引,否则 InnoDB 可能锁全表,并发直接掉底。还有一点,事务里不要做 HTTP 调用或发消息,锁持有时间越长,排队越严重。

5.3 现象:按日统计少一天或多一天

现象是报表按 created_at 的日期分组,白天单据跑到前一天,或者晚上 23 点后的单子算到第二天。原因是数据库存 UTC,Python 用了 naive datetime,前端按本地时区展示,三处时区没对齐。解决是 Django 设 USE_TZ=True,数据库连接 TIME_ZONE='+00:00',所有 datetime 用 timezone.now(),展示层统一转 Asia/Shanghai。

from django.utils import timezone from zoneinfo import ZoneInfo # 存 UTC,展示转本地 now_utc = timezone.now() local = now_utc.astimezone(ZoneInfo('Asia/Shanghai'))

如果源码包已经用了 naive datetime,不要一刀切改,先把数据库里的历史数据按业务时区标注清楚,再逐步迁移。报表 SQL 里用 CONVERT_TZ(created_at, '+00:00', '+08:00') 只能救急,长期还是要统一存储口径。

5.4 现象:单据号重复导致主键冲突

现象是保存单据时报 Duplicate entry,或者流水唯一索引冲突。原因是单据号用“时间戳+随机数”生成,并发下撞号;或者前端重复提交,后端没做幂等。解决是 doc_no 加唯一索引,生成规则改成数据库序列或 Redis INCR,提交接口用 doc_no 做幂等键,重复请求直接返回已存在的单据。

简单做法是建一张序列表,按业务类型和日期取号,取号用行锁。不要用 count+1,删过单之后 count 会回退,一定撞号。前端按钮加防抖只能减少重复提交,不能替代后端唯一索引。

5.5 现象:金额尾差和浮点误差

现象是采购单 0.1+0.2 算出 0.30000000000000004,或者毛利报表差几分钱。原因是 Python float 和数据库 FLOAT/DOUBLE。解决是金额一律 Decimal,数据库用 DECIMAL(18,2),Python 里用 Decimal(str(x)) 构造,不要用 Decimal(0.1)。平均成本可以保留 4 位,最终金额四舍五入到 2 位。

from decimal import Decimal, ROUND_HALF_UP price = Decimal('12.50') qty = Decimal('3') amount = (price * qty).quantize(Decimal('0.01'), rounding=ROUND_HALF_UP)

序列化返回给前端时也转字符串,不要转 float。前端展示再格式化,后端不丢精度。这条坑不显眼,但对账时差一分钱能查一下午。

6. 二次开发技巧:用对账脚本守住结存,兼谈仓库级权限

改库存逻辑之前,先准备一个每天能跑的对账脚本。下面这段用 Django ORM 汇总流水和结存,输出差异行,跑完再决定要不要发版。把它挂到定时任务里,早上上班前看结果,比等业务喊“库存不对”再查强得多。

from django.db.models import Sum, Case, When, DecimalField from django.core.management.base import BaseCommand from app.models import StockFlow, StockBalance class Command(BaseCommand): def handle(self, *args, **options): flow = StockFlow.objects.values('product_id', 'warehouse_id').annotate( qty=Sum(Case( When(direction='in', then='qty'), When(direction='out', then=-1 * Sum('qty')), output_field=DecimalField(max_digits=18, decimal_places=4), )) ) flow_map = {(r['product_id'], r['warehouse_id']): r['qty'] for r in flow} for b in StockBalance.objects.all(): diff = b.qty - flow_map.get((b.product_id, b.warehouse_id), 0) if abs(diff) > 0.0001: self.stdout.write(f'差异 product={b.product_id} wh={b.warehouse_id} diff={diff}')

这段脚本的 Case/When 写法在 Django 里需要调整 Sum 的位置,实际跑的时候如果报错,改成用 raw SQL 更直接。重点不是代码多优雅,是每天跑一遍,差异超过阈值就告警。仓库级权限则用装饰器卡数据范围:用户表加 warehouse_ids 字段,列表查询统一过滤,详情接口校验仓库归属,导出接口也要过同一个装饰器,别只卡页面不卡接口。

from functools import wraps from django.http import JsonResponse def warehouse_scope(view_func): @wraps(view_func) def wrapper(request, *args, **kwargs): allowed = request.user.warehouse_ids or [] wh_id = int(kwargs.get('warehouse_id', 0)) if wh_id and wh_id not in allowed: return JsonResponse({'code': 403, 'msg': '无权访问该仓库'}, status=403) return view_func(request, *args, **kwargs) return wrapper

从那以后我每次改库存扣减逻辑,都先跑对账脚本,再用两个并发请求压一遍出库接口,最后才看页面。这个习惯不花哨,但能挡住大部分“月底对不上”的破事。希望帮到你。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/26 10:32:37

WPF DataGrid 实战:MVVM绑定、虚拟化与五万行不卡避坑

简介&#xff1a;面向WPF开发者及希望进阶桌面表格开发的初学者&#xff0c;这份DataGrid实例围绕数据绑定、列定义、样式模板、增删改交互、排序分页与数据验证等常见需求展开&#xff0c;帮助读者理解如何在真实项目中搭建清晰、可编辑且易维护的数据视图。压缩包共347个文件…

作者头像 李华
网站建设 2026/10/26 10:31:49

Cocos Creator抓娃娃机物理实战:刚体、关节与碰撞响应详解

简介&#xff1a;这是一份基于Cocos Creator引擎与TypeScript开发的抓娃娃小游戏完整项目源码&#xff0c;面向游戏开发初学者及Cocos Creator进阶学习者&#xff0c;旨在帮助读者掌握2D物理交互、动画控制、UI事件响应与跨平台打包等核心实践能力。资源包共992个文件&#xff…

作者头像 李华
网站建设 2026/10/26 10:31:49

Cocos Creator抓娃娃机物理交互实现指南

简介&#xff1a;这是一份基于Cocos Creator引擎与TypeScript开发的抓娃娃小游戏完整示例&#xff0c;面向游戏开发初学者及Cocos Creator进阶学习者&#xff0c;帮助理解2D物理交互、动画控制、UI事件响应与单机逻辑架构等核心实践问题。资源包共992个文件&#xff0c;涵盖225…

作者头像 李华
网站建设 2026/10/26 10:31:42

电光调制器仿真:从物理链路到高频设计的MATLAB实践

简介&#xff1a;这份资源面向光学与通信方向的学习者及工程实践者&#xff0c;提供一套基于MATLAB的电光调制器性能仿真工具&#xff0c;用于解决调制特性曲线难以直观验证的问题。输入半波电压、偏置电压与调制电压后&#xff0c;即可输出电光调制特性曲线&#xff0c;并通过…

作者头像 李华
网站建设 2026/10/26 10:31:35

MATLAB卷积神经网络手写数字识别例程拆解与避坑

简介&#xff1a;面向深度学习入门者与MATLAB用户的卷积神经网络手写数字识别例程包&#xff0c;围绕手写数字任务给出从数据加载、网络搭建到训练测试的完整实现&#xff0c;帮助读者在MATLAB环境中理解卷积、池化、Softmax分类及反向传播等关键环节&#xff0c;适合课程实验、…

作者头像 李华
网站建设 2026/10/26 10:30:55

SamsungBIOS固件更新实战:SMBIOS校验、BitLocker挂起与批量运维

简介&#xff1a;面向需要排查三星笔记本 BIOS/UEFI 故障、寻找旧版固件或研究官方更新机制的用户&#xff0c;整理了一套围绕三星 BIOS 下载与恢复的工具和资料合集。三星长期隐藏 BIOS 下载入口&#xff0c;SW Update 自动更新也存在失败报告&#xff0c;旧版本更难获取&…

作者头像 李华