baseline: 批次A-D 成果 + membership 半成品(测试红)

This commit is contained in:
agent
2026-09-11 23:11:35 +08:00
commit b3f3095d53
311 changed files with 40540 additions and 0 deletions
+7
View File
@@ -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')},
},
),
]
+168
View File
@@ -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}"
+69
View File
@@ -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)
+397
View File
@@ -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)
+17
View File
@@ -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)),
]
+70
View File
@@ -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