baseline: 批次A-D 成果 + membership 半成品(测试红)
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class InventoryConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.inventory"
|
||||
verbose_name = "库存中心"
|
||||
@@ -0,0 +1,103 @@
|
||||
# Generated by Django 5.2.12 on 2026-09-06 17:42
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('catalog', '0001_initial'),
|
||||
('core', '0001_initial'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Warehouse',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('is_deleted', models.BooleanField(default=False)),
|
||||
('deleted_at', models.DateTimeField(blank=True, null=True)),
|
||||
('ext_data', models.JSONField(blank=True, default=dict)),
|
||||
('source_channel', models.CharField(blank=True, default='manual', max_length=32)),
|
||||
('code', models.CharField(max_length=64)),
|
||||
('name', models.CharField(max_length=128)),
|
||||
('location', models.CharField(blank=True, default='', max_length=255)),
|
||||
('is_active', models.BooleanField(default=True)),
|
||||
('is_default', models.BooleanField(default=False, help_text='默认仓库')),
|
||||
('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
|
||||
('org', models.ForeignKey(blank=True, help_text='所属组织(多机构预留)', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='core.org')),
|
||||
('tenant', models.ForeignKey(help_text='所属租户', on_delete=django.db.models.deletion.PROTECT, related_name='+', to='core.tenant')),
|
||||
('updated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'verbose_name_plural': '仓库',
|
||||
'db_table': 'inventory_warehouse',
|
||||
'ordering': ['code'],
|
||||
'unique_together': {('tenant', 'code')},
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='StockMovement',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('is_deleted', models.BooleanField(default=False)),
|
||||
('deleted_at', models.DateTimeField(blank=True, null=True)),
|
||||
('ext_data', models.JSONField(blank=True, default=dict)),
|
||||
('source_channel', models.CharField(blank=True, default='manual', max_length=32)),
|
||||
('movement_type', models.CharField(choices=[('inbound', '入库'), ('outbound', '出库'), ('lock', '锁定'), ('unlock', '释放'), ('adjust', '盘点')], max_length=16)),
|
||||
('source_type', models.CharField(choices=[('purchase', '采购入库'), ('sale', '销售出库'), ('transfer', '调拨'), ('adjust', '盘点调整'), ('manual', '手工')], default='manual', max_length=16)),
|
||||
('source_ref', models.CharField(blank=True, default='', help_text='来源单据号', max_length=64)),
|
||||
('quantity', models.DecimalField(decimal_places=4, help_text='本次变动数量(正数)', max_digits=18)),
|
||||
('unit_cost', models.DecimalField(decimal_places=4, default=0, help_text='单位成本', max_digits=18)),
|
||||
('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
|
||||
('org', models.ForeignKey(blank=True, help_text='所属组织(多机构预留)', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='core.org')),
|
||||
('product', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='movements', to='catalog.product')),
|
||||
('tenant', models.ForeignKey(help_text='所属租户', on_delete=django.db.models.deletion.PROTECT, related_name='+', to='core.tenant')),
|
||||
('updated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
|
||||
('warehouse', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='movements', to='inventory.warehouse')),
|
||||
],
|
||||
options={
|
||||
'verbose_name_plural': '库存流水',
|
||||
'db_table': 'inventory_movement',
|
||||
'ordering': ['-created_at'],
|
||||
'indexes': [models.Index(fields=['tenant', 'warehouse', '-created_at'], name='inventory_m_tenant__e8039f_idx'), models.Index(fields=['tenant', 'product', '-created_at'], name='inventory_m_tenant__bc5789_idx'), models.Index(fields=['source_type', 'source_ref'], name='inventory_m_source__9a2f80_idx')],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Stock',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('is_deleted', models.BooleanField(default=False)),
|
||||
('deleted_at', models.DateTimeField(blank=True, null=True)),
|
||||
('ext_data', models.JSONField(blank=True, default=dict)),
|
||||
('source_channel', models.CharField(blank=True, default='manual', max_length=32)),
|
||||
('on_hand', models.DecimalField(decimal_places=4, default=0, max_digits=18)),
|
||||
('locked', models.DecimalField(decimal_places=4, default=0, max_digits=18)),
|
||||
('avg_cost', models.DecimalField(decimal_places=4, default=0, max_digits=18)),
|
||||
('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
|
||||
('org', models.ForeignKey(blank=True, help_text='所属组织(多机构预留)', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='core.org')),
|
||||
('product', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='stocks', to='catalog.product')),
|
||||
('tenant', models.ForeignKey(help_text='所属租户', on_delete=django.db.models.deletion.PROTECT, related_name='+', to='core.tenant')),
|
||||
('updated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
|
||||
('warehouse', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='stocks', to='inventory.warehouse')),
|
||||
],
|
||||
options={
|
||||
'verbose_name_plural': '库存账面',
|
||||
'db_table': 'inventory_stock',
|
||||
'ordering': ['warehouse', 'product'],
|
||||
'indexes': [models.Index(fields=['tenant', 'warehouse', 'product'], name='inventory_s_tenant__80269e_idx'), models.Index(fields=['tenant', 'product'], name='inventory_s_tenant__fa0b11_idx')],
|
||||
'unique_together': {('tenant', 'warehouse', 'product')},
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,54 @@
|
||||
# Generated by Django 5.2.12 on 2026-09-08 08:39
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('catalog', '0002_product_is_batch_managed_product_min_sale_price_and_more'),
|
||||
('core', '0001_initial'),
|
||||
('inventory', '0001_initial'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='stockmovement',
|
||||
name='batch_detail',
|
||||
field=models.JSONField(blank=True, default=list, help_text='批次商品出库时的 FEFO 分摊明细 [{batch_no, quantity, expiry_date}]'),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='StockBatch',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('is_deleted', models.BooleanField(default=False)),
|
||||
('deleted_at', models.DateTimeField(blank=True, null=True)),
|
||||
('ext_data', models.JSONField(blank=True, default=dict)),
|
||||
('source_channel', models.CharField(blank=True, default='manual', max_length=32)),
|
||||
('batch_no', models.CharField(help_text='批次号', max_length=64)),
|
||||
('production_date', models.DateField(blank=True, help_text='生产日期', null=True)),
|
||||
('expiry_date', models.DateField(blank=True, help_text='到期日期', null=True)),
|
||||
('on_hand', models.DecimalField(decimal_places=4, default=0, max_digits=18)),
|
||||
('locked', models.DecimalField(decimal_places=4, default=0, max_digits=18)),
|
||||
('unit_cost', models.DecimalField(decimal_places=4, default=0, max_digits=18)),
|
||||
('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
|
||||
('org', models.ForeignKey(blank=True, help_text='所属组织(多机构预留)', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='core.org')),
|
||||
('product', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='batches', to='catalog.product')),
|
||||
('tenant', models.ForeignKey(help_text='所属租户', on_delete=django.db.models.deletion.PROTECT, related_name='+', to='core.tenant')),
|
||||
('updated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
|
||||
('warehouse', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='batches', to='inventory.warehouse')),
|
||||
],
|
||||
options={
|
||||
'verbose_name_plural': '批次库存',
|
||||
'db_table': 'inventory_stock_batch',
|
||||
'ordering': ['expiry_date', 'id'],
|
||||
'indexes': [models.Index(fields=['tenant', 'product', 'expiry_date'], name='inventory_s_tenant__813139_idx'), models.Index(fields=['tenant', 'warehouse', 'product'], name='inventory_s_tenant__5497ec_idx')],
|
||||
'unique_together': {('tenant', 'warehouse', 'product', 'batch_no')},
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,168 @@
|
||||
"""库存模型:Warehouse / Stock / StockMovement。
|
||||
|
||||
设计:
|
||||
- Warehouse 仓库(在 tenant 下唯一编码)
|
||||
- Stock 按 (tenant, warehouse, product) 唯一定义当前库存数 / 锁定数
|
||||
- StockMovement 是每次库存变化的流水(不可变),用于审计与回溯
|
||||
|
||||
锁库存语义:
|
||||
- on_hand: 在手数量(可卖/可用)
|
||||
- locked: 已锁定(销售订单已确认但未出库)
|
||||
- available = on_hand - locked
|
||||
"""
|
||||
|
||||
from django.db import models
|
||||
from apps.core.base_models import TenantScopedModel
|
||||
from decimal import Decimal
|
||||
|
||||
|
||||
class Warehouse(TenantScopedModel):
|
||||
"""仓库。"""
|
||||
|
||||
code = models.CharField(max_length=64)
|
||||
name = models.CharField(max_length=128)
|
||||
location = models.CharField(max_length=255, blank=True, default="")
|
||||
is_active = models.BooleanField(default=True)
|
||||
is_default = models.BooleanField(default=False, help_text="默认仓库")
|
||||
|
||||
class Meta:
|
||||
db_table = "inventory_warehouse"
|
||||
unique_together = [("tenant", "code")]
|
||||
verbose_name_plural = "仓库"
|
||||
ordering = ["code"]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.code} {self.name}"
|
||||
|
||||
|
||||
class Stock(TenantScopedModel):
|
||||
"""库存账面(每个 (warehouse, product) 一行)。"""
|
||||
|
||||
warehouse = models.ForeignKey(
|
||||
Warehouse,
|
||||
on_delete=models.PROTECT,
|
||||
related_name="stocks",
|
||||
)
|
||||
product = models.ForeignKey(
|
||||
"catalog.Product",
|
||||
on_delete=models.PROTECT,
|
||||
related_name="stocks",
|
||||
)
|
||||
on_hand = models.DecimalField(max_digits=18, decimal_places=4, default=0)
|
||||
locked = models.DecimalField(max_digits=18, decimal_places=4, default=0)
|
||||
avg_cost = models.DecimalField(max_digits=18, decimal_places=4, default=0)
|
||||
|
||||
class Meta:
|
||||
db_table = "inventory_stock"
|
||||
unique_together = [("tenant", "warehouse", "product")]
|
||||
verbose_name_plural = "库存账面"
|
||||
indexes = [
|
||||
models.Index(fields=["tenant", "warehouse", "product"]),
|
||||
models.Index(fields=["tenant", "product"]),
|
||||
]
|
||||
ordering = ["warehouse", "product"]
|
||||
|
||||
@property
|
||||
def available(self) -> Decimal:
|
||||
return self.on_hand - self.locked
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.warehouse.code}/{self.product.code} on_hand={self.on_hand} locked={self.locked}"
|
||||
|
||||
|
||||
class StockMovement(TenantScopedModel):
|
||||
"""库存流水(不可变)。"""
|
||||
|
||||
MOVEMENT_INBOUND = "inbound" # 入库(采购入库、调拨入库、其他入库)
|
||||
MOVEMENT_OUTBOUND = "outbound" # 出库(销售出库、调拨出库、其他出库)
|
||||
MOVEMENT_LOCK = "lock" # 锁定
|
||||
MOVEMENT_UNLOCK = "unlock" # 释放锁定
|
||||
MOVEMENT_ADJUST = "adjust" # 盘点调整
|
||||
|
||||
MOVEMENT_CHOICES = [
|
||||
(MOVEMENT_INBOUND, "入库"),
|
||||
(MOVEMENT_OUTBOUND, "出库"),
|
||||
(MOVEMENT_LOCK, "锁定"),
|
||||
(MOVEMENT_UNLOCK, "释放"),
|
||||
(MOVEMENT_ADJUST, "盘点"),
|
||||
]
|
||||
|
||||
SOURCE_CHOICES = [
|
||||
("purchase", "采购入库"),
|
||||
("sale", "销售出库"),
|
||||
("transfer", "调拨"),
|
||||
("adjust", "盘点调整"),
|
||||
("manual", "手工"),
|
||||
]
|
||||
|
||||
warehouse = models.ForeignKey(
|
||||
Warehouse, on_delete=models.PROTECT, related_name="movements"
|
||||
)
|
||||
product = models.ForeignKey(
|
||||
"catalog.Product", on_delete=models.PROTECT, related_name="movements"
|
||||
)
|
||||
|
||||
movement_type = models.CharField(max_length=16, choices=MOVEMENT_CHOICES)
|
||||
source_type = models.CharField(max_length=16, choices=SOURCE_CHOICES, default="manual")
|
||||
source_ref = models.CharField(max_length=64, blank=True, default="", help_text="来源单据号")
|
||||
|
||||
quantity = models.DecimalField(max_digits=18, decimal_places=4, help_text="本次变动数量(正数)")
|
||||
unit_cost = models.DecimalField(max_digits=18, decimal_places=4, default=0, help_text="单位成本")
|
||||
batch_detail = models.JSONField(
|
||||
default=list,
|
||||
blank=True,
|
||||
help_text="批次商品出库时的 FEFO 分摊明细 [{batch_no, quantity, expiry_date}]",
|
||||
)
|
||||
|
||||
# 流水是不可变的:不允许 delete / 修改后影响金额
|
||||
class Meta:
|
||||
db_table = "inventory_movement"
|
||||
verbose_name_plural = "库存流水"
|
||||
indexes = [
|
||||
models.Index(fields=["tenant", "warehouse", "-created_at"]),
|
||||
models.Index(fields=["tenant", "product", "-created_at"]),
|
||||
models.Index(fields=["source_type", "source_ref"]),
|
||||
]
|
||||
ordering = ["-created_at"]
|
||||
|
||||
def __str__(self):
|
||||
sign = "+" if self.movement_type in (self.MOVEMENT_INBOUND, self.MOVEMENT_UNLOCK) else "-"
|
||||
return f"[{self.created_at}] {self.warehouse.code}/{self.product.code} {sign}{self.quantity}"
|
||||
|
||||
|
||||
class StockBatch(TenantScopedModel):
|
||||
"""批次库存账(批次管理商品的 (warehouse, product, batch_no) 维度账面)。
|
||||
|
||||
与总账 Stock 并行维护:sum(StockBatch.on_hand) == Stock.on_hand。
|
||||
出库按 FEFO(近效期优先)自动分摊。
|
||||
"""
|
||||
|
||||
warehouse = models.ForeignKey(
|
||||
Warehouse, on_delete=models.PROTECT, related_name="batches"
|
||||
)
|
||||
product = models.ForeignKey(
|
||||
"catalog.Product", on_delete=models.PROTECT, related_name="batches"
|
||||
)
|
||||
batch_no = models.CharField(max_length=64, help_text="批次号")
|
||||
production_date = models.DateField(null=True, blank=True, help_text="生产日期")
|
||||
expiry_date = models.DateField(null=True, blank=True, help_text="到期日期")
|
||||
on_hand = models.DecimalField(max_digits=18, decimal_places=4, default=0)
|
||||
locked = models.DecimalField(max_digits=18, decimal_places=4, default=0)
|
||||
unit_cost = models.DecimalField(max_digits=18, decimal_places=4, default=0)
|
||||
|
||||
class Meta:
|
||||
db_table = "inventory_stock_batch"
|
||||
unique_together = [("tenant", "warehouse", "product", "batch_no")]
|
||||
verbose_name_plural = "批次库存"
|
||||
indexes = [
|
||||
models.Index(fields=["tenant", "product", "expiry_date"]),
|
||||
models.Index(fields=["tenant", "warehouse", "product"]),
|
||||
]
|
||||
ordering = ["expiry_date", "id"]
|
||||
|
||||
@property
|
||||
def available(self) -> Decimal:
|
||||
return self.on_hand - self.locked
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.warehouse.code}/{self.product.code}/{self.batch_no} on_hand={self.on_hand}"
|
||||
@@ -0,0 +1,69 @@
|
||||
"""inventory 序列化器(async 兼容)。"""
|
||||
|
||||
from rest_framework import serializers
|
||||
from apps.core.serializers import AsyncModelSerializer
|
||||
from .models import Warehouse, Stock, StockBatch, StockMovement
|
||||
|
||||
|
||||
class WarehouseSerializer(AsyncModelSerializer):
|
||||
class Meta:
|
||||
model = Warehouse
|
||||
fields = [
|
||||
"id", "code", "name", "location", "is_active", "is_default",
|
||||
"is_deleted", "created_at", "updated_at",
|
||||
]
|
||||
read_only_fields = ["id", "created_at", "updated_at"]
|
||||
|
||||
|
||||
class StockSerializer(AsyncModelSerializer):
|
||||
warehouse_code = serializers.CharField(source="warehouse.code", read_only=True)
|
||||
product_code = serializers.CharField(source="product.code", read_only=True)
|
||||
product_name = serializers.CharField(source="product.name", read_only=True)
|
||||
available = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = Stock
|
||||
fields = [
|
||||
"id", "warehouse_code", "product_code", "product_name",
|
||||
"on_hand", "locked", "available", "avg_cost",
|
||||
"is_deleted", "created_at", "updated_at",
|
||||
]
|
||||
read_only_fields = ["id", "created_at", "updated_at", "available", "on_hand", "locked", "avg_cost"]
|
||||
|
||||
def get_available(self, obj):
|
||||
return str(obj.on_hand - obj.locked)
|
||||
|
||||
|
||||
class StockMovementSerializer(AsyncModelSerializer):
|
||||
warehouse_code = serializers.CharField(source="warehouse.code", read_only=True)
|
||||
product_code = serializers.CharField(source="product.code", read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = StockMovement
|
||||
fields = [
|
||||
"id", "warehouse_code", "product_code",
|
||||
"movement_type", "source_type", "source_ref",
|
||||
"quantity", "unit_cost",
|
||||
"created_at",
|
||||
]
|
||||
read_only_fields = fields # 流水只读
|
||||
|
||||
|
||||
class StockBatchSerializer(AsyncModelSerializer):
|
||||
warehouse_code = serializers.CharField(source="warehouse.code", read_only=True)
|
||||
product_code = serializers.CharField(source="product.code", read_only=True)
|
||||
product_name = serializers.CharField(source="product.name", read_only=True)
|
||||
available = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = StockBatch
|
||||
fields = [
|
||||
"id", "warehouse_code", "product_code", "product_name",
|
||||
"batch_no", "production_date", "expiry_date",
|
||||
"on_hand", "locked", "available", "unit_cost",
|
||||
"is_deleted", "created_at", "updated_at",
|
||||
]
|
||||
read_only_fields = ["id", "created_at", "updated_at", "available"]
|
||||
|
||||
def get_available(self, obj):
|
||||
return str(obj.on_hand - obj.locked)
|
||||
@@ -0,0 +1,397 @@
|
||||
"""库存原子化服务。
|
||||
|
||||
核心规则:
|
||||
1. 所有库存变动必须走这里的函数,**禁止业务层直接 update Stock**
|
||||
2. 每个变动包 transaction.atomic + select_for_update 锁行
|
||||
3. 同步写 StockMovement 流水
|
||||
4. raise InsufficientStock 当可用库存不足
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
from django.db import IntegrityError, transaction
|
||||
from django.db.models import F
|
||||
|
||||
from .models import Stock, StockBatch, StockMovement, Warehouse
|
||||
|
||||
|
||||
class InsufficientStock(Exception):
|
||||
"""可用库存不足。"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class WarehouseNotFound(Exception):
|
||||
"""仓库不存在。"""
|
||||
|
||||
|
||||
@dataclass
|
||||
class StockChangeResult:
|
||||
stock: Stock
|
||||
movement: StockMovement
|
||||
batch_allocations: list = field(default_factory=list)
|
||||
|
||||
|
||||
def _get_locked(tenant, warehouse, product):
|
||||
"""select_for_update 取 stock;不存在则创建。返回刷新过的 stock。
|
||||
|
||||
**并发安全**:`select_for_update()` 对"不存在的行"无法上锁——两个事务同时
|
||||
发现无记录时会各自 create,撞唯一约束(PG 实测复现)。
|
||||
因此创建路径用 `get_or_create` 并捕获 IntegrityError 重取:
|
||||
竞态失败方在对手提交后重查即可拿到同一行。
|
||||
"""
|
||||
stock = (
|
||||
Stock.objects.select_for_update()
|
||||
.filter(tenant=tenant, warehouse=warehouse, product=product)
|
||||
.first()
|
||||
)
|
||||
if stock is not None:
|
||||
return stock
|
||||
|
||||
# 创建路径:允许"被并发抢先"——用保存点隔离失败,避免整个外层事务被标记 aborted
|
||||
try:
|
||||
with transaction.atomic(): # 保存点:回滚只影响本段
|
||||
stock, _created = Stock.objects.get_or_create(
|
||||
tenant=tenant,
|
||||
warehouse=warehouse,
|
||||
product=product,
|
||||
defaults={"on_hand": Decimal("0"), "locked": Decimal("0"),
|
||||
"avg_cost": Decimal("0")},
|
||||
)
|
||||
except IntegrityError:
|
||||
# 竞态:对手已插入并提交 → 重取(此时行一定存在)
|
||||
stock = (
|
||||
Stock.objects.select_for_update()
|
||||
.filter(tenant=tenant, warehouse=warehouse, product=product)
|
||||
.first()
|
||||
)
|
||||
if stock is None: # 对手回滚了 → 自己创建
|
||||
stock = Stock.objects.create(
|
||||
tenant=tenant, warehouse=warehouse, product=product,
|
||||
on_hand=Decimal("0"), locked=Decimal("0"), avg_cost=Decimal("0"),
|
||||
)
|
||||
return stock
|
||||
|
||||
|
||||
def get_or_create_stock(*, tenant, warehouse, product) -> Stock:
|
||||
"""非事务环境下的便捷版(用于初始化)。"""
|
||||
stock, _ = Stock.objects.get_or_create(
|
||||
tenant=tenant,
|
||||
warehouse=warehouse,
|
||||
product=product,
|
||||
defaults={"on_hand": Decimal("0"), "locked": Decimal("0"), "avg_cost": Decimal("0")},
|
||||
)
|
||||
return stock
|
||||
|
||||
|
||||
def _get_locked_batch(tenant, warehouse, product, batch_no, defaults: dict):
|
||||
"""select_for_update 取批次账;不存在则按 defaults 创建。
|
||||
|
||||
并发安全同 `_get_locked`:创建路径用 get_or_create + IntegrityError 重取,
|
||||
避免"首次并发入库同一批次"撞唯一约束。
|
||||
"""
|
||||
batch = (
|
||||
StockBatch.objects.select_for_update()
|
||||
.filter(tenant=tenant, warehouse=warehouse, product=product, batch_no=batch_no)
|
||||
.first()
|
||||
)
|
||||
if batch is None:
|
||||
try:
|
||||
with transaction.atomic(): # 保存点:隔离并发冲突
|
||||
batch, _created = StockBatch.objects.get_or_create(
|
||||
tenant=tenant, warehouse=warehouse, product=product,
|
||||
batch_no=batch_no,
|
||||
defaults={
|
||||
"production_date": defaults.get("production_date"),
|
||||
"expiry_date": defaults.get("expiry_date"),
|
||||
"on_hand": Decimal("0"),
|
||||
"locked": Decimal("0"),
|
||||
"unit_cost": defaults.get("unit_cost", Decimal("0")),
|
||||
},
|
||||
)
|
||||
except IntegrityError:
|
||||
batch = (
|
||||
StockBatch.objects.select_for_update()
|
||||
.filter(tenant=tenant, warehouse=warehouse,
|
||||
product=product, batch_no=batch_no)
|
||||
.first()
|
||||
)
|
||||
if batch is None:
|
||||
batch = StockBatch.objects.create(
|
||||
tenant=tenant, warehouse=warehouse, product=product,
|
||||
batch_no=batch_no,
|
||||
production_date=defaults.get("production_date"),
|
||||
expiry_date=defaults.get("expiry_date"),
|
||||
on_hand=Decimal("0"), locked=Decimal("0"),
|
||||
unit_cost=defaults.get("unit_cost", Decimal("0")),
|
||||
)
|
||||
else:
|
||||
# 批次信息允许补录(生产日期/到期日为空时回填)
|
||||
fill = {}
|
||||
if batch.production_date is None and defaults.get("production_date"):
|
||||
fill["production_date"] = defaults["production_date"]
|
||||
if batch.expiry_date is None and defaults.get("expiry_date"):
|
||||
fill["expiry_date"] = defaults["expiry_date"]
|
||||
if fill:
|
||||
StockBatch.objects.filter(pk=batch.pk).update(**fill)
|
||||
batch.refresh_from_db()
|
||||
return batch
|
||||
|
||||
|
||||
def inbound(
|
||||
*,
|
||||
tenant,
|
||||
warehouse,
|
||||
product,
|
||||
quantity: Decimal,
|
||||
unit_cost: Decimal = Decimal("0"),
|
||||
source_type: str = "manual",
|
||||
source_ref: str = "",
|
||||
batch_no: str = "",
|
||||
production_date=None,
|
||||
expiry_date=None,
|
||||
) -> StockChangeResult:
|
||||
"""入库:on_hand += quantity;写流水 + 加权平均成本。
|
||||
|
||||
批次管理商品(product.is_batch_managed)必须带 batch_no,同时维护批次账。
|
||||
"""
|
||||
if quantity <= 0:
|
||||
raise ValueError("quantity must be positive")
|
||||
is_batch = getattr(product, "is_batch_managed", False)
|
||||
if is_batch and not batch_no:
|
||||
raise ValueError(f"product {product.code} is batch-managed: batch_no required")
|
||||
with transaction.atomic():
|
||||
stock = _get_locked(tenant, warehouse, product)
|
||||
# 用 F() 表达式做增量 update,避免 race condition
|
||||
Stock.objects.filter(pk=stock.pk).update(on_hand=F("on_hand") + quantity)
|
||||
# 加权平均成本
|
||||
if unit_cost and unit_cost > 0:
|
||||
old_qty = stock.on_hand
|
||||
old_cost = stock.avg_cost
|
||||
new_cost = (
|
||||
(old_cost * old_qty + unit_cost * quantity) / (old_qty + quantity)
|
||||
if (old_qty + quantity) > 0
|
||||
else unit_cost
|
||||
)
|
||||
Stock.objects.filter(pk=stock.pk).update(avg_cost=new_cost)
|
||||
# 刷新 in-memory
|
||||
stock.refresh_from_db()
|
||||
movement = StockMovement.objects.create(
|
||||
tenant=tenant,
|
||||
warehouse=warehouse,
|
||||
product=product,
|
||||
movement_type=StockMovement.MOVEMENT_INBOUND,
|
||||
source_type=source_type,
|
||||
source_ref=source_ref,
|
||||
quantity=quantity,
|
||||
unit_cost=unit_cost,
|
||||
)
|
||||
batch_allocations = []
|
||||
if is_batch:
|
||||
if expiry_date is None and product.shelf_life_days:
|
||||
base = production_date or date.today()
|
||||
expiry_date = base + __import__("datetime").timedelta(
|
||||
days=product.shelf_life_days
|
||||
)
|
||||
batch = _get_locked_batch(
|
||||
tenant, warehouse, product, batch_no,
|
||||
defaults={
|
||||
"production_date": production_date,
|
||||
"expiry_date": expiry_date,
|
||||
"unit_cost": unit_cost,
|
||||
},
|
||||
)
|
||||
StockBatch.objects.filter(pk=batch.pk).update(
|
||||
on_hand=F("on_hand") + quantity,
|
||||
unit_cost=unit_cost if unit_cost > 0 else batch.unit_cost,
|
||||
)
|
||||
batch.refresh_from_db()
|
||||
batch_allocations.append({"batch_no": batch_no, "quantity": str(quantity)})
|
||||
return StockChangeResult(
|
||||
stock=stock, movement=movement, batch_allocations=batch_allocations
|
||||
)
|
||||
|
||||
|
||||
def _fefo_allocate(tenant, warehouse, product, quantity: Decimal) -> list:
|
||||
"""批次商品出库分摊:FEFO(近效期优先,空到期日最后,再按先入先出 id)。
|
||||
|
||||
返回 [{batch_no, quantity, expiry_date}];总量不足抛 InsufficientStock。
|
||||
调用方须处于事务中。
|
||||
"""
|
||||
batches = list(
|
||||
StockBatch.objects.select_for_update()
|
||||
.filter(tenant=tenant, warehouse=warehouse, product=product)
|
||||
.order_by(F("expiry_date").asc(nulls_last=True), "id")
|
||||
)
|
||||
remaining = quantity
|
||||
allocations = []
|
||||
for b in batches:
|
||||
if remaining <= 0:
|
||||
break
|
||||
available = b.on_hand - b.locked
|
||||
if available <= 0:
|
||||
continue
|
||||
take = min(available, remaining)
|
||||
allocations.append({"batch": b, "take": take})
|
||||
remaining -= take
|
||||
if remaining > 0:
|
||||
total_available = sum((b.on_hand - b.locked) for b in batches)
|
||||
raise InsufficientStock(
|
||||
f"insufficient batch stock for {warehouse.code}/{product.code}: "
|
||||
f"available={total_available}, requested={quantity}"
|
||||
)
|
||||
return allocations
|
||||
|
||||
|
||||
def outbound(
|
||||
*,
|
||||
tenant,
|
||||
warehouse,
|
||||
product,
|
||||
quantity: Decimal,
|
||||
source_type: str = "manual",
|
||||
source_ref: str = "",
|
||||
) -> StockChangeResult:
|
||||
"""出库:available = on_hand - locked 必须 >= quantity;on_hand -= quantity。
|
||||
|
||||
批次管理商品自动按 FEFO 分摊到批次账(近效期优先)。
|
||||
"""
|
||||
if quantity <= 0:
|
||||
raise ValueError("quantity must be positive")
|
||||
is_batch = getattr(product, "is_batch_managed", False)
|
||||
with transaction.atomic():
|
||||
stock = _get_locked(tenant, warehouse, product)
|
||||
available = stock.on_hand - stock.locked
|
||||
if available < quantity:
|
||||
raise InsufficientStock(
|
||||
f"insufficient stock for {warehouse.code}/{product.code}: "
|
||||
f"available={available}, requested={quantity}"
|
||||
)
|
||||
batch_detail = []
|
||||
if is_batch:
|
||||
for alloc in _fefo_allocate(tenant, warehouse, product, quantity):
|
||||
b = alloc["batch"]
|
||||
take = alloc["take"]
|
||||
StockBatch.objects.filter(pk=b.pk).update(on_hand=F("on_hand") - take)
|
||||
batch_detail.append(
|
||||
{
|
||||
"batch_no": b.batch_no,
|
||||
"quantity": str(take),
|
||||
"expiry_date": b.expiry_date.isoformat() if b.expiry_date else None,
|
||||
}
|
||||
)
|
||||
Stock.objects.filter(pk=stock.pk).update(on_hand=F("on_hand") - quantity)
|
||||
stock.refresh_from_db()
|
||||
movement = StockMovement.objects.create(
|
||||
tenant=tenant,
|
||||
warehouse=warehouse,
|
||||
product=product,
|
||||
movement_type=StockMovement.MOVEMENT_OUTBOUND,
|
||||
source_type=source_type,
|
||||
source_ref=source_ref,
|
||||
quantity=quantity,
|
||||
unit_cost=stock.avg_cost,
|
||||
batch_detail=batch_detail,
|
||||
)
|
||||
return StockChangeResult(
|
||||
stock=stock, movement=movement, batch_allocations=batch_detail
|
||||
)
|
||||
|
||||
|
||||
def lock(
|
||||
*,
|
||||
tenant,
|
||||
warehouse,
|
||||
product,
|
||||
quantity: Decimal,
|
||||
source_type: str = "manual",
|
||||
source_ref: str = "",
|
||||
) -> StockChangeResult:
|
||||
"""锁定:locked += quantity(不直接出库)。"""
|
||||
if quantity <= 0:
|
||||
raise ValueError("quantity must be positive")
|
||||
with transaction.atomic():
|
||||
stock = _get_locked(tenant, warehouse, product)
|
||||
available = stock.on_hand - stock.locked
|
||||
if available < quantity:
|
||||
raise InsufficientStock(
|
||||
f"insufficient available stock for {warehouse.code}/{product.code}: "
|
||||
f"available={available}, requested={quantity}"
|
||||
)
|
||||
Stock.objects.filter(pk=stock.pk).update(locked=F("locked") + quantity)
|
||||
stock.refresh_from_db()
|
||||
movement = StockMovement.objects.create(
|
||||
tenant=tenant,
|
||||
warehouse=warehouse,
|
||||
product=product,
|
||||
movement_type=StockMovement.MOVEMENT_LOCK,
|
||||
source_type=source_type,
|
||||
source_ref=source_ref,
|
||||
quantity=quantity,
|
||||
)
|
||||
return StockChangeResult(stock=stock, movement=movement)
|
||||
|
||||
|
||||
def unlock(
|
||||
*,
|
||||
tenant,
|
||||
warehouse,
|
||||
product,
|
||||
quantity: Decimal,
|
||||
source_type: str = "manual",
|
||||
source_ref: str = "",
|
||||
) -> StockChangeResult:
|
||||
"""释放锁定:locked -= quantity。"""
|
||||
if quantity <= 0:
|
||||
raise ValueError("quantity must be positive")
|
||||
with transaction.atomic():
|
||||
stock = _get_locked(tenant, warehouse, product)
|
||||
if stock.locked < quantity:
|
||||
raise InsufficientStock(
|
||||
f"locked less than unlock for {warehouse.code}/{product.code}: "
|
||||
f"locked={stock.locked}, requested={quantity}"
|
||||
)
|
||||
Stock.objects.filter(pk=stock.pk).update(locked=F("locked") - quantity)
|
||||
stock.refresh_from_db()
|
||||
movement = StockMovement.objects.create(
|
||||
tenant=tenant,
|
||||
warehouse=warehouse,
|
||||
product=product,
|
||||
movement_type=StockMovement.MOVEMENT_UNLOCK,
|
||||
source_type=source_type,
|
||||
source_ref=source_ref,
|
||||
quantity=quantity,
|
||||
)
|
||||
return StockChangeResult(stock=stock, movement=movement)
|
||||
|
||||
|
||||
def adjust(
|
||||
*,
|
||||
tenant,
|
||||
warehouse,
|
||||
product,
|
||||
new_on_hand: Decimal,
|
||||
source_type: str = "adjust",
|
||||
source_ref: str = "",
|
||||
) -> StockChangeResult:
|
||||
"""盘点调整:把 on_hand 改成 new_on_hand,差值记一条 adjust 流水。"""
|
||||
with transaction.atomic():
|
||||
stock = _get_locked(tenant, warehouse, product)
|
||||
diff = new_on_hand - stock.on_hand
|
||||
Stock.objects.filter(pk=stock.pk).update(on_hand=new_on_hand)
|
||||
stock.refresh_from_db()
|
||||
movement = StockMovement.objects.create(
|
||||
tenant=tenant,
|
||||
warehouse=warehouse,
|
||||
product=product,
|
||||
movement_type=StockMovement.MOVEMENT_ADJUST,
|
||||
source_type=source_type,
|
||||
source_ref=source_ref,
|
||||
quantity=abs(diff),
|
||||
unit_cost=stock.avg_cost,
|
||||
)
|
||||
return StockChangeResult(stock=stock, movement=movement)
|
||||
@@ -0,0 +1,17 @@
|
||||
"""inventory URL。"""
|
||||
|
||||
from django.urls import path, include
|
||||
from adrf.routers import DefaultRouter as ADRouter
|
||||
from .views import WarehouseViewSet, StockViewSet, StockMovementViewSet, StockBatchViewSet
|
||||
|
||||
|
||||
router = ADRouter()
|
||||
router.register("warehouses", WarehouseViewSet, basename="warehouse")
|
||||
router.register("stocks", StockViewSet, basename="stock")
|
||||
router.register("movements", StockMovementViewSet, basename="movement")
|
||||
router.register("batches", StockBatchViewSet, basename="stockbatch")
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
path("", include(router.urls)),
|
||||
]
|
||||
@@ -0,0 +1,70 @@
|
||||
"""inventory async ViewSets。
|
||||
|
||||
库存账面与流水是只读对外接口(写入走 services);
|
||||
仓库可增删改。
|
||||
"""
|
||||
|
||||
from adrf.viewsets import ModelViewSet, ReadOnlyModelViewSet
|
||||
from datetime import timedelta
|
||||
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.core.viewset import BaseTenantViewSet
|
||||
from apps.core.viewset import StandardAsyncPagination
|
||||
from .models import Warehouse, Stock, StockBatch, StockMovement
|
||||
from .serializers import WarehouseSerializer, StockSerializer, StockMovementSerializer, StockBatchSerializer
|
||||
|
||||
|
||||
class WarehouseViewSet(BaseTenantViewSet):
|
||||
model = Warehouse
|
||||
serializer_class = WarehouseSerializer
|
||||
search_fields = ["code", "name"]
|
||||
|
||||
|
||||
class StockViewSet(BaseTenantViewSet):
|
||||
model = Stock
|
||||
serializer_class = StockSerializer
|
||||
search_fields = ["product__code", "product__name", "warehouse__code"]
|
||||
http_method_names = ["get", "head", "options"] # 只读
|
||||
select_related_fields = ("product", "warehouse", "product__base_unit")
|
||||
|
||||
|
||||
class StockMovementViewSet(BaseTenantViewSet):
|
||||
model = StockMovement
|
||||
serializer_class = StockMovementSerializer
|
||||
search_fields = ["product__code", "product__name", "source_ref"]
|
||||
http_method_names = ["get", "head", "options"] # 只读
|
||||
select_related_fields = ("product", "warehouse")
|
||||
|
||||
|
||||
class StockBatchViewSet(BaseTenantViewSet):
|
||||
"""批次库存(只读;写入走 inbound/outbound 服务)。"""
|
||||
|
||||
model = StockBatch
|
||||
serializer_class = StockBatchSerializer
|
||||
search_fields = ["batch_no", "product__code", "product__name", "warehouse__code"]
|
||||
http_method_names = ["get", "head", "options"] # 只读
|
||||
select_related_fields = ("product", "warehouse", "product__base_unit")
|
||||
|
||||
async def get_queryset(self):
|
||||
qs = await super().get_queryset()
|
||||
# 支持按 product / warehouse 过滤,且默认只看有量批次
|
||||
product_id = self.request.query_params.get("product")
|
||||
warehouse_id = self.request.query_params.get("warehouse")
|
||||
in_stock = self.request.query_params.get("in_stock", "")
|
||||
near_expiry = self.request.query_params.get("near_expiry", "")
|
||||
expiry_days = self.request.query_params.get("expiry_days", "30")
|
||||
if product_id:
|
||||
qs = qs.filter(product_id=product_id)
|
||||
if warehouse_id:
|
||||
qs = qs.filter(warehouse_id=warehouse_id)
|
||||
if in_stock in ("1", "true", "True"):
|
||||
qs = qs.filter(on_hand__gt=0)
|
||||
if near_expiry in ("1", "true", "True"):
|
||||
try:
|
||||
days = max(0, min(int(expiry_days), 3650))
|
||||
except (TypeError, ValueError):
|
||||
days = 30
|
||||
cutoff = timezone.localdate() + timedelta(days=days)
|
||||
qs = qs.filter(expiry_date__isnull=False, expiry_date__lte=cutoff)
|
||||
return qs
|
||||
Reference in New Issue
Block a user