baseline: 批次A-D 成果 + membership 半成品(测试红)
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class PurchaseConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.purchase"
|
||||
verbose_name = "采购"
|
||||
@@ -0,0 +1,139 @@
|
||||
# Generated by Django 5.2.12 on 2026-09-06 17:48
|
||||
|
||||
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'),
|
||||
('inventory', '0001_initial'),
|
||||
('partner', '0001_initial'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='PurchaseBill',
|
||||
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)),
|
||||
('bill_no', models.CharField(max_length=64)),
|
||||
('bill_date', models.DateField()),
|
||||
('total_amount', models.DecimalField(decimal_places=4, default=0, max_digits=18)),
|
||||
('state', models.CharField(choices=[('draft', '草稿'), ('confirmed', '已过账'), ('cancelled', '已取消')], default='draft', max_length=16)),
|
||||
('remark', models.TextField(blank=True, default='')),
|
||||
('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')),
|
||||
('supplier', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='purchase_bills', to='partner.supplier')),
|
||||
('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='purchase_bills', to='inventory.warehouse')),
|
||||
],
|
||||
options={
|
||||
'verbose_name_plural': '进货单',
|
||||
'db_table': 'purchase_bill',
|
||||
'ordering': ['-bill_date', '-id'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='PurchaseBillLine',
|
||||
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)),
|
||||
('quantity', models.DecimalField(decimal_places=4, max_digits=18)),
|
||||
('unit_price', models.DecimalField(decimal_places=4, max_digits=18)),
|
||||
('amount', models.DecimalField(decimal_places=4, default=0, max_digits=18)),
|
||||
('bill', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='lines', to='purchase.purchasebill')),
|
||||
('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='bill_lines', 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)),
|
||||
],
|
||||
options={
|
||||
'db_table': 'purchase_bill_line',
|
||||
'ordering': ['id'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='PurchaseOrder',
|
||||
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)),
|
||||
('bill_no', models.CharField(max_length=64)),
|
||||
('bill_date', models.DateField()),
|
||||
('expected_date', models.DateField(blank=True, null=True)),
|
||||
('total_amount', models.DecimalField(decimal_places=4, default=0, max_digits=18)),
|
||||
('state', models.CharField(choices=[('draft', '草稿'), ('confirmed', '已确认'), ('received', '已收货'), ('cancelled', '已取消')], default='draft', max_length=16)),
|
||||
('remark', models.TextField(blank=True, default='')),
|
||||
('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')),
|
||||
('supplier', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='purchase_orders', to='partner.supplier')),
|
||||
('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='purchase_orders', to='inventory.warehouse')),
|
||||
],
|
||||
options={
|
||||
'verbose_name_plural': '采购订单',
|
||||
'db_table': 'purchase_order',
|
||||
'ordering': ['-bill_date', '-id'],
|
||||
'unique_together': {('tenant', 'bill_no')},
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='purchasebill',
|
||||
name='order',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='bills', to='purchase.purchaseorder'),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='PurchaseOrderLine',
|
||||
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)),
|
||||
('quantity', models.DecimalField(decimal_places=4, max_digits=18)),
|
||||
('unit_price', models.DecimalField(decimal_places=4, max_digits=18)),
|
||||
('amount', models.DecimalField(decimal_places=4, default=0, max_digits=18)),
|
||||
('received_qty', 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)),
|
||||
('order', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='lines', to='purchase.purchaseorder')),
|
||||
('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='po_lines', 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)),
|
||||
],
|
||||
options={
|
||||
'db_table': 'purchase_order_line',
|
||||
'ordering': ['id'],
|
||||
},
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='purchasebill',
|
||||
unique_together={('tenant', 'bill_no')},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,50 @@
|
||||
# Generated by Django 5.2.12 on 2026-09-08 08:39
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('catalog', '0002_product_is_batch_managed_product_min_sale_price_and_more'),
|
||||
('purchase', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='purchasebillline',
|
||||
name='batch_no',
|
||||
field=models.CharField(blank=True, default='', help_text='批次号(批次管理商品必填)', max_length=64),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='purchasebillline',
|
||||
name='expiry_date',
|
||||
field=models.DateField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='purchasebillline',
|
||||
name='production_date',
|
||||
field=models.DateField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='purchasebillline',
|
||||
name='source_quantity',
|
||||
field=models.DecimalField(blank=True, decimal_places=4, help_text='录入数量(按录入单位;quantity 恒为基本单位数量)', max_digits=18, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='purchasebillline',
|
||||
name='source_unit',
|
||||
field=models.ForeignKey(blank=True, help_text='录入单位(空=按基本单位)', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_lines', to='catalog.unit'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='purchaseorderline',
|
||||
name='source_quantity',
|
||||
field=models.DecimalField(blank=True, decimal_places=4, help_text='录入数量(按录入单位;quantity 恒为基本单位数量)', max_digits=18, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='purchaseorderline',
|
||||
name='source_unit',
|
||||
field=models.ForeignKey(blank=True, help_text='录入单位(空=按基本单位)', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_lines', to='catalog.unit'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 5.2.12 on 2026-09-10 15:10
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('purchase', '0002_purchasebillline_batch_no_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='purchasebillline',
|
||||
name='tax_amount',
|
||||
field=models.DecimalField(decimal_places=4, default=0, help_text='行税额(从含税额中拆出)', max_digits=18),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='purchasebillline',
|
||||
name='tax_rate',
|
||||
field=models.DecimalField(decimal_places=4, default=0, help_text='行税率(0.13 = 13%);价内口径:amount 为含税额', max_digits=6),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,154 @@
|
||||
"""采购模型:PurchaseOrder(订单)+ PurchaseBill(进货单/收货入库单)。"""
|
||||
|
||||
from decimal import Decimal
|
||||
|
||||
from django.db import models
|
||||
|
||||
from apps.core.base_models import TenantScopedModel
|
||||
|
||||
|
||||
class PurchaseOrder(TenantScopedModel):
|
||||
"""采购订单(可选,预先订货流程)。"""
|
||||
|
||||
STATE_CHOICES = [
|
||||
("draft", "草稿"),
|
||||
("confirmed", "已确认"),
|
||||
("received", "已收货"),
|
||||
("cancelled", "已取消"),
|
||||
]
|
||||
|
||||
bill_no = models.CharField(max_length=64)
|
||||
supplier = models.ForeignKey(
|
||||
"partner.Supplier",
|
||||
on_delete=models.PROTECT,
|
||||
related_name="purchase_orders",
|
||||
)
|
||||
warehouse = models.ForeignKey(
|
||||
"inventory.Warehouse",
|
||||
on_delete=models.PROTECT,
|
||||
related_name="purchase_orders",
|
||||
)
|
||||
bill_date = models.DateField()
|
||||
expected_date = models.DateField(null=True, blank=True)
|
||||
total_amount = models.DecimalField(max_digits=18, decimal_places=4, default=0)
|
||||
state = models.CharField(max_length=16, choices=STATE_CHOICES, default="draft")
|
||||
remark = models.TextField(blank=True, default="")
|
||||
|
||||
class Meta:
|
||||
db_table = "purchase_order"
|
||||
unique_together = [("tenant", "bill_no")]
|
||||
ordering = ["-bill_date", "-id"]
|
||||
verbose_name_plural = "采购订单"
|
||||
|
||||
|
||||
class PurchaseOrderLine(TenantScopedModel):
|
||||
order = models.ForeignKey(
|
||||
PurchaseOrder, on_delete=models.CASCADE, related_name="lines"
|
||||
)
|
||||
product = models.ForeignKey(
|
||||
"catalog.Product", on_delete=models.PROTECT, related_name="po_lines"
|
||||
)
|
||||
source_unit = models.ForeignKey(
|
||||
"catalog.Unit",
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="%(class)s_lines",
|
||||
help_text="录入单位(空=按基本单位)",
|
||||
)
|
||||
source_quantity = models.DecimalField(
|
||||
max_digits=18, decimal_places=4, null=True, blank=True,
|
||||
help_text="录入数量(按录入单位;quantity 恒为基本单位数量)",
|
||||
)
|
||||
|
||||
quantity = models.DecimalField(max_digits=18, decimal_places=4)
|
||||
unit_price = models.DecimalField(max_digits=18, decimal_places=4)
|
||||
amount = models.DecimalField(max_digits=18, decimal_places=4, default=0)
|
||||
received_qty = models.DecimalField(max_digits=18, decimal_places=4, default=0)
|
||||
|
||||
class Meta:
|
||||
db_table = "purchase_order_line"
|
||||
ordering = ["id"]
|
||||
|
||||
|
||||
class PurchaseBill(TenantScopedModel):
|
||||
"""进货单(实际收货入库时生成)。"""
|
||||
|
||||
STATE_CHOICES = [
|
||||
("draft", "草稿"),
|
||||
("confirmed", "已过账"),
|
||||
("cancelled", "已取消"),
|
||||
]
|
||||
|
||||
bill_no = models.CharField(max_length=64)
|
||||
supplier = models.ForeignKey(
|
||||
"partner.Supplier",
|
||||
on_delete=models.PROTECT,
|
||||
related_name="purchase_bills",
|
||||
)
|
||||
warehouse = models.ForeignKey(
|
||||
"inventory.Warehouse",
|
||||
on_delete=models.PROTECT,
|
||||
related_name="purchase_bills",
|
||||
)
|
||||
order = models.ForeignKey(
|
||||
PurchaseOrder,
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="bills",
|
||||
)
|
||||
bill_date = models.DateField()
|
||||
total_amount = models.DecimalField(max_digits=18, decimal_places=4, default=0)
|
||||
state = models.CharField(max_length=16, choices=STATE_CHOICES, default="draft")
|
||||
remark = models.TextField(blank=True, default="")
|
||||
|
||||
class Meta:
|
||||
db_table = "purchase_bill"
|
||||
unique_together = [("tenant", "bill_no")]
|
||||
ordering = ["-bill_date", "-id"]
|
||||
verbose_name_plural = "进货单"
|
||||
|
||||
|
||||
class PurchaseBillLine(TenantScopedModel):
|
||||
bill = models.ForeignKey(
|
||||
PurchaseBill, on_delete=models.CASCADE, related_name="lines"
|
||||
)
|
||||
product = models.ForeignKey(
|
||||
"catalog.Product", on_delete=models.PROTECT, related_name="bill_lines"
|
||||
)
|
||||
batch_no = models.CharField(
|
||||
max_length=64, blank=True, default="", help_text="批次号(批次管理商品必填)"
|
||||
)
|
||||
production_date = models.DateField(null=True, blank=True)
|
||||
expiry_date = models.DateField(null=True, blank=True)
|
||||
|
||||
source_unit = models.ForeignKey(
|
||||
"catalog.Unit",
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="%(class)s_lines",
|
||||
help_text="录入单位(空=按基本单位)",
|
||||
)
|
||||
source_quantity = models.DecimalField(
|
||||
max_digits=18, decimal_places=4, null=True, blank=True,
|
||||
help_text="录入数量(按录入单位;quantity 恒为基本单位数量)",
|
||||
)
|
||||
|
||||
quantity = models.DecimalField(max_digits=18, decimal_places=4)
|
||||
unit_price = models.DecimalField(max_digits=18, decimal_places=4)
|
||||
amount = models.DecimalField(max_digits=18, decimal_places=4, default=0)
|
||||
|
||||
tax_rate = models.DecimalField(
|
||||
max_digits=6, decimal_places=4, default=0,
|
||||
help_text="行税率(0.13 = 13%);价内口径:amount 为含税额",
|
||||
)
|
||||
tax_amount = models.DecimalField(
|
||||
max_digits=18, decimal_places=4, default=0,
|
||||
help_text="行税额(从含税额中拆出)",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
db_table = "purchase_bill_line"
|
||||
ordering = ["id"]
|
||||
@@ -0,0 +1,97 @@
|
||||
"""purchase 序列化器(async 兼容)。"""
|
||||
|
||||
from rest_framework import serializers
|
||||
from apps.core.serializers import AsyncModelSerializer
|
||||
from .models import PurchaseOrder, PurchaseOrderLine, PurchaseBill, PurchaseBillLine
|
||||
|
||||
|
||||
class PurchaseOrderLineSerializer(AsyncModelSerializer):
|
||||
product_code = serializers.CharField(source="product.code", read_only=True)
|
||||
product_name = serializers.CharField(source="product.name", read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = PurchaseOrderLine
|
||||
fields = ["id", "product", "product_code", "product_name",
|
||||
"quantity", "unit_price", "amount", "received_qty"]
|
||||
read_only_fields = ["id", "amount", "received_qty"]
|
||||
|
||||
|
||||
class PurchaseOrderSerializer(AsyncModelSerializer):
|
||||
supplier_code = serializers.CharField(source="supplier.code", read_only=True)
|
||||
warehouse_code = serializers.CharField(source="warehouse.code", read_only=True)
|
||||
lines = PurchaseOrderLineSerializer(many=True, required=False)
|
||||
|
||||
class Meta:
|
||||
model = PurchaseOrder
|
||||
fields = [
|
||||
"id", "bill_no", "supplier", "supplier_code",
|
||||
"warehouse", "warehouse_code",
|
||||
"bill_date", "expected_date", "total_amount", "state", "remark",
|
||||
"lines",
|
||||
]
|
||||
read_only_fields = ["id", "bill_no", "total_amount", "state"]
|
||||
|
||||
def create(self, validated_data):
|
||||
lines = validated_data.pop("lines", [])
|
||||
tenant = self.context["request"]._request.tenant if hasattr(self.context.get("request", None), "_request") else None
|
||||
# 多租户注入由 acreate 处理(参考 catalog)
|
||||
from django.db import transaction
|
||||
from datetime import date
|
||||
|
||||
with transaction.atomic():
|
||||
order = PurchaseOrder.objects.create(**validated_data)
|
||||
total = Decimal = __import__("decimal").Decimal
|
||||
tot = total("0")
|
||||
for ln in lines:
|
||||
qty = total(str(ln["quantity"]))
|
||||
price = total(str(ln["unit_price"]))
|
||||
PurchaseOrderLine.objects.create(
|
||||
tenant=order.tenant,
|
||||
order=order,
|
||||
product=ln["product"],
|
||||
quantity=qty,
|
||||
unit_price=price,
|
||||
amount=qty * price,
|
||||
)
|
||||
tot += qty * price
|
||||
order.total_amount = tot
|
||||
order.save(update_fields=["total_amount"])
|
||||
return order
|
||||
|
||||
|
||||
class PurchaseBillLineSerializer(AsyncModelSerializer):
|
||||
product_code = serializers.CharField(source="product.code", read_only=True)
|
||||
product_name = serializers.CharField(source="product.name", read_only=True)
|
||||
unit_name = serializers.SerializerMethodField()
|
||||
is_batch_managed = serializers.BooleanField(
|
||||
source="product.is_batch_managed", read_only=True
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = PurchaseBillLine
|
||||
fields = ["id", "product", "product_code", "product_name",
|
||||
"quantity", "source_unit", "source_quantity", "unit_name",
|
||||
"unit_price", "amount", "tax_rate", "tax_amount",
|
||||
"batch_no", "production_date", "expiry_date", "is_batch_managed"]
|
||||
read_only_fields = ["id", "amount"]
|
||||
|
||||
def get_unit_name(self, obj):
|
||||
unit = obj.source_unit or getattr(obj.product, "base_unit", None)
|
||||
return unit.name if unit else ""
|
||||
|
||||
|
||||
class PurchaseBillSerializer(AsyncModelSerializer):
|
||||
supplier_code = serializers.CharField(source="supplier.code", read_only=True)
|
||||
supplier_name = serializers.CharField(source="supplier.name", read_only=True)
|
||||
warehouse_code = serializers.CharField(source="warehouse.code", read_only=True)
|
||||
lines = PurchaseBillLineSerializer(many=True, required=False)
|
||||
|
||||
class Meta:
|
||||
model = PurchaseBill
|
||||
fields = [
|
||||
"id", "bill_no", "supplier", "supplier_code", "supplier_name",
|
||||
"warehouse", "warehouse_code", "order",
|
||||
"bill_date", "total_amount", "state", "remark",
|
||||
"lines",
|
||||
]
|
||||
read_only_fields = ["id", "bill_no", "total_amount", "state"]
|
||||
@@ -0,0 +1,152 @@
|
||||
"""采购业务服务:确认订单 / 进货单过账(写库存 + 生成应付)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
from django.db import transaction
|
||||
|
||||
from apps.inventory import services as inv_services
|
||||
from apps.finance import services as fin_services
|
||||
from apps.core.services import (
|
||||
compute_tax,
|
||||
parse_price,
|
||||
resolve_line_quantity,
|
||||
resolve_tax_rate,
|
||||
)
|
||||
|
||||
from .models import PurchaseOrder, PurchaseOrderLine, PurchaseBill, PurchaseBillLine
|
||||
|
||||
|
||||
def _resolve_line(product, ln):
|
||||
"""解析采购行:换算基本单位数量 + 录入单位/数量;价格按录入单位。
|
||||
|
||||
返回 (qty, price, src_unit, src_qty, amount, tax_rate, tax_amount)(D4 加税)。
|
||||
"""
|
||||
parsed = resolve_line_quantity(product, ln)
|
||||
qty = parsed["quantity"]
|
||||
src_unit = parsed["source_unit"]
|
||||
src_qty = parsed["source_quantity"]
|
||||
# 单价校验(与销售侧同口径):必须 > 0,否则拒绝
|
||||
# (此前直接 Decimal(str(...)) 未校验,0 价只能靠下游"金额>0"兜住,
|
||||
# 错误码不精确;0 成本入库还会污染加权平均成本)
|
||||
price = parse_price(product, ln.get("unit_price"))
|
||||
if src_unit is not None:
|
||||
from apps.catalog.models import UnitConversion
|
||||
conv = UnitConversion.objects.filter(product=product, unit=src_unit).first()
|
||||
rate = conv.rate if conv else None
|
||||
amount = src_qty * price
|
||||
else:
|
||||
rate = None
|
||||
amount = qty * price
|
||||
|
||||
tax_rate = resolve_tax_rate(product, ln)
|
||||
tax = compute_tax(amount, tax_rate)
|
||||
return qty, price, src_unit, src_qty, amount, tax_rate, tax["tax"]
|
||||
|
||||
|
||||
def _generate_bill_no(tenant, prefix: str) -> str:
|
||||
"""取候选单号(唯一性由 create_with_unique_bill_no 的重试保证)。"""
|
||||
Model = PurchaseOrder if prefix == "PO" else PurchaseBill
|
||||
from apps.core.services import next_bill_no
|
||||
|
||||
return next_bill_no(tenant, prefix, Model)
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def create_purchase_bill(
|
||||
*, tenant, supplier, warehouse, bill_date=None, remark="", lines=None,
|
||||
order=None,
|
||||
) -> PurchaseBill:
|
||||
"""新建一张进货单(草稿态)。lines: list of dict(product, quantity, unit_price)。"""
|
||||
bill_date = bill_date or date.today()
|
||||
from apps.core.services import create_with_unique_bill_no
|
||||
|
||||
bill = create_with_unique_bill_no(
|
||||
PurchaseBill,
|
||||
tenant=tenant,
|
||||
prefix="PB",
|
||||
defaults=dict(
|
||||
supplier=supplier,
|
||||
warehouse=warehouse,
|
||||
order=order,
|
||||
bill_date=bill_date,
|
||||
total_amount=Decimal("0"),
|
||||
state="draft",
|
||||
remark=remark,
|
||||
),
|
||||
)
|
||||
total = Decimal("0")
|
||||
for ln in lines or []:
|
||||
product = ln["product"]
|
||||
qty, price, src_unit, src_qty, amount, tax_rate, tax_amount = _resolve_line(product, ln)
|
||||
PurchaseBillLine.objects.create(
|
||||
tenant=tenant,
|
||||
bill=bill,
|
||||
product=product,
|
||||
quantity=qty,
|
||||
unit_price=price,
|
||||
amount=amount,
|
||||
batch_no=str(ln.get("batch_no") or ""),
|
||||
production_date=ln.get("production_date"),
|
||||
expiry_date=ln.get("expiry_date"),
|
||||
source_unit=src_unit,
|
||||
source_quantity=src_qty,
|
||||
tax_rate=tax_rate,
|
||||
tax_amount=tax_amount,
|
||||
)
|
||||
total += amount
|
||||
if total <= 0:
|
||||
raise ValueError(f"进货单金额必须大于 0(行合计 {total});请检查数量与单价")
|
||||
|
||||
bill.total_amount = total
|
||||
bill.save(update_fields=["total_amount", "updated_at"])
|
||||
return bill
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def confirm_purchase_bill(bill: PurchaseBill, *, user=None) -> PurchaseBill:
|
||||
"""进货单过账:写库存 + 生成应付单。
|
||||
|
||||
必须状态是 draft;过账后变 confirmed。
|
||||
"""
|
||||
if bill.state != "draft":
|
||||
raise ValueError(f"purchase bill {bill.bill_no} not in draft state")
|
||||
# 1. 写库存
|
||||
for line in bill.lines.select_related("product").all():
|
||||
inv_services.inbound(
|
||||
tenant=bill.tenant,
|
||||
warehouse=bill.warehouse,
|
||||
product=line.product,
|
||||
quantity=line.quantity,
|
||||
unit_cost=line.unit_price,
|
||||
source_type="purchase",
|
||||
source_ref=bill.bill_no,
|
||||
batch_no=line.batch_no,
|
||||
production_date=line.production_date,
|
||||
expiry_date=line.expiry_date,
|
||||
)
|
||||
# 2. 生成应付(D4:把行税额合计传给凭证做进项税拆分)
|
||||
from django.db.models import Sum as _Sum
|
||||
|
||||
tax_total = bill.lines.aggregate(t=_Sum("tax_amount"))["t"] or Decimal("0")
|
||||
fin_services.create_payable_from_purchase(
|
||||
tenant=bill.tenant,
|
||||
supplier=bill.supplier,
|
||||
total_amount=bill.total_amount,
|
||||
source_ref=bill.bill_no,
|
||||
bill_date=bill.bill_date,
|
||||
tax_amount=tax_total,
|
||||
)
|
||||
# 3. 改状态
|
||||
bill.state = "confirmed"
|
||||
bill.save(update_fields=["state", "updated_at"])
|
||||
|
||||
from apps.core import audit as audit_log
|
||||
|
||||
audit_log.log_bill_posted(
|
||||
tenant=bill.tenant, bill=bill, user=user,
|
||||
extra={"lines": bill.lines.count()},
|
||||
)
|
||||
return bill
|
||||
@@ -0,0 +1,15 @@
|
||||
"""purchase URL。"""
|
||||
|
||||
from django.urls import path, include
|
||||
from adrf.routers import DefaultRouter as ADRouter
|
||||
from .views import PurchaseOrderViewSet, PurchaseBillViewSet
|
||||
|
||||
|
||||
router = ADRouter()
|
||||
router.register("orders", PurchaseOrderViewSet, basename="po")
|
||||
router.register("bills", PurchaseBillViewSet, basename="pb")
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
path("", include(router.urls)),
|
||||
]
|
||||
@@ -0,0 +1,165 @@
|
||||
"""purchase async ViewSets。
|
||||
|
||||
开单页动作接口:
|
||||
- `create_bill`(POST /purchase/bills/create-bill/):建草稿(支持批次号/生产日/到期日/录入单位)
|
||||
- `confirm_bill`(POST /purchase/bills/<id>/confirm/):过账(写库存 + 生成应付)
|
||||
"""
|
||||
|
||||
from asgiref.sync import sync_to_async
|
||||
from rest_framework import status
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.exceptions import ValidationError
|
||||
from rest_framework.response import Response
|
||||
|
||||
from apps.core.services import InvalidLinePrice, InvalidLineQuantity
|
||||
from apps.core.viewset import BaseTenantViewSet
|
||||
|
||||
from .models import PurchaseOrder, PurchaseBill
|
||||
from .serializers import PurchaseOrderSerializer, PurchaseBillSerializer
|
||||
from . import services as purchase_services
|
||||
|
||||
|
||||
class PurchaseOrderViewSet(BaseTenantViewSet):
|
||||
model = PurchaseOrder
|
||||
serializer_class = PurchaseOrderSerializer
|
||||
search_fields = ["bill_no", "supplier__code", "supplier__name"]
|
||||
select_related_fields = ("supplier", "warehouse")
|
||||
prefetch_related_fields = (
|
||||
"lines", "lines__product", "lines__source_unit", "lines__product__base_unit",
|
||||
)
|
||||
|
||||
|
||||
class PurchaseBillViewSet(BaseTenantViewSet):
|
||||
model = PurchaseBill
|
||||
serializer_class = PurchaseBillSerializer
|
||||
search_fields = ["bill_no", "supplier__code", "supplier__name"]
|
||||
select_related_fields = ("supplier", "warehouse")
|
||||
prefetch_related_fields = (
|
||||
"lines", "lines__product", "lines__source_unit", "lines__product__base_unit",
|
||||
)
|
||||
|
||||
@action(detail=False, methods=["post"], url_path="create-bill")
|
||||
async def create_bill(self, request):
|
||||
"""POST /api/v1/purchase/bills/create-bill/
|
||||
|
||||
body: {supplier, warehouse, bill_date?, remark?, lines: [
|
||||
{product, quantity, unit_price, source_unit?, batch_no?,
|
||||
production_date?, expiry_date?}]}
|
||||
"""
|
||||
tenant = await self.get_tenant()
|
||||
if tenant is None:
|
||||
raise ValidationError({"tenant": "无法识别租户"})
|
||||
payload = request.data or {}
|
||||
|
||||
# 套餐配额:月度单据量(批次 C1)
|
||||
from apps.billing import quota as billing_quota
|
||||
|
||||
def _quota_check():
|
||||
return billing_quota.check_and_count(tenant, "bills_monthly", delta=1)
|
||||
|
||||
try:
|
||||
await sync_to_async(_quota_check)()
|
||||
except billing_quota.QuotaExceeded as exc:
|
||||
return Response(exc.as_dict(), status=status.HTTP_403_FORBIDDEN)
|
||||
|
||||
supplier_id, warehouse_id = payload.get("supplier"), payload.get("warehouse")
|
||||
lines = payload.get("lines") or []
|
||||
if not supplier_id or not warehouse_id:
|
||||
raise ValidationError({"detail": "supplier 与 warehouse 必填"})
|
||||
if not lines:
|
||||
raise ValidationError({"detail": "lines 不能为空"})
|
||||
if not isinstance(lines, list):
|
||||
raise ValidationError({"detail": "lines 必须是数组"})
|
||||
# 逐行结构校验:非 dict 或缺 product 直接 400(否则下游 .get() 会 500)
|
||||
for idx, ln in enumerate(lines, start=1):
|
||||
if not isinstance(ln, dict):
|
||||
raise ValidationError({"detail": f"第 {idx} 行明细格式错误(应为对象)"})
|
||||
if not ln.get("product"):
|
||||
raise ValidationError({"detail": f"第 {idx} 行缺少 product"})
|
||||
|
||||
from apps.partner.models import Supplier
|
||||
from apps.inventory.models import Warehouse
|
||||
from apps.catalog.models import Product
|
||||
|
||||
def _load_refs():
|
||||
supplier = Supplier.objects.filter(tenant=tenant, pk=supplier_id).first()
|
||||
warehouse = Warehouse.objects.filter(tenant=tenant, pk=warehouse_id).first()
|
||||
products = {
|
||||
p.id: p for p in Product.objects.filter(
|
||||
tenant=tenant, pk__in=[ln.get("product") for ln in lines]
|
||||
)
|
||||
}
|
||||
return supplier, warehouse, products
|
||||
|
||||
supplier, warehouse, products = await sync_to_async(_load_refs)()
|
||||
if supplier is None:
|
||||
raise ValidationError({"supplier": "供应商不存在"})
|
||||
if warehouse is None:
|
||||
raise ValidationError({"warehouse": "仓库不存在"})
|
||||
|
||||
resolved_lines = []
|
||||
for ln in lines:
|
||||
product = products.get(ln.get("product"))
|
||||
if product is None:
|
||||
raise ValidationError({"lines": f"商品 {ln.get('product')} 不存在"})
|
||||
if ln.get("unit_price") in (None, ""):
|
||||
raise ValidationError({"lines": f"商品 {product.code} 缺少进货单价"})
|
||||
resolved_lines.append({
|
||||
"product": product,
|
||||
"quantity": ln.get("quantity"),
|
||||
"unit_price": ln.get("unit_price"),
|
||||
"source_unit": ln.get("source_unit"),
|
||||
"batch_no": ln.get("batch_no") or "",
|
||||
"production_date": ln.get("production_date") or None,
|
||||
"expiry_date": ln.get("expiry_date") or None,
|
||||
})
|
||||
|
||||
from datetime import date as _date
|
||||
|
||||
kwargs = {
|
||||
"tenant": tenant,
|
||||
"supplier": supplier,
|
||||
"warehouse": warehouse,
|
||||
"lines": resolved_lines,
|
||||
"remark": payload.get("remark") or "",
|
||||
}
|
||||
bill_date = payload.get("bill_date")
|
||||
if bill_date:
|
||||
kwargs["bill_date"] = _date.fromisoformat(bill_date)
|
||||
|
||||
# InvalidLine* 等由全局异常处理器统一映射
|
||||
try:
|
||||
bill = await sync_to_async(purchase_services.create_purchase_bill)(**kwargs)
|
||||
except ValueError as exc:
|
||||
return Response(
|
||||
{"code": "invalid_line", "detail": str(exc)},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
data = await sync_to_async(lambda: PurchaseBillSerializer(bill).data)()
|
||||
return Response(data, status=status.HTTP_201_CREATED)
|
||||
|
||||
@action(detail=True, methods=["post"], url_path="confirm")
|
||||
async def confirm_bill(self, request, pk=None):
|
||||
"""POST /api/v1/purchase/bills/<id>/confirm/"""
|
||||
bill = await self.aget_object()
|
||||
|
||||
def _confirm():
|
||||
return purchase_services.confirm_purchase_bill(
|
||||
bill, user=request.user if request.user.is_authenticated else None,
|
||||
)
|
||||
|
||||
try:
|
||||
await sync_to_async(_confirm)()
|
||||
except ValueError as exc:
|
||||
return Response(
|
||||
{"code": "invalid_state", "detail": str(exc)},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
def _reload():
|
||||
bill.refresh_from_db()
|
||||
return PurchaseBillSerializer(bill).data
|
||||
|
||||
data = await sync_to_async(_reload)()
|
||||
return Response(data)
|
||||
Reference in New Issue
Block a user