diff --git a/apps/mpr/__init__.py b/apps/mpr/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/mpr/admin.py b/apps/mpr/admin.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/mpr/apps.py b/apps/mpr/apps.py new file mode 100644 index 00000000..1b441df0 --- /dev/null +++ b/apps/mpr/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class MprConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'apps.mpr' diff --git a/apps/mpr/filters.py b/apps/mpr/filters.py new file mode 100644 index 00000000..629991f2 --- /dev/null +++ b/apps/mpr/filters.py @@ -0,0 +1,39 @@ +from django_filters import rest_framework as filters +from apps.mpr.models import PurchaseRequisition, WarehouseEntry, WarehouseStock, MaterialRequisition + + +class PurchaseRequisitionFilter(filters.FilterSet): + req_date_after = filters.DateFilter(field_name='req_date', lookup_expr='gte') + req_date_before = filters.DateFilter(field_name='req_date', lookup_expr='lte') + + class Meta: + model = PurchaseRequisition + fields = ['belong_dept', 'req_date_after', 'req_date_before'] + + +class WarehouseEntryFilter(filters.FilterSet): + entry_date_after = filters.DateFilter(field_name='entry_date', lookup_expr='gte') + entry_date_before = filters.DateFilter(field_name='entry_date', lookup_expr='lte') + + class Meta: + model = WarehouseEntry + fields = ['warehouse', 'entry_type', 'entry_method', 'entry_date_after', 'entry_date_before'] + + +class WarehouseStockFilter(filters.FilterSet): + entry_date_after = filters.DateFilter(field_name='entry_date', lookup_expr='gte') + entry_date_before = filters.DateFilter(field_name='entry_date', lookup_expr='lte') + + class Meta: + model = WarehouseStock + fields = ['warehouse', 'entry_type', 'entry_method', 'supplier_name', + 'invoice_received', 'status', 'entry_date_after', 'entry_date_before'] + + +class MaterialRequisitionFilter(filters.FilterSet): + req_date_after = filters.DateFilter(field_name='req_date', lookup_expr='gte') + req_date_before = filters.DateFilter(field_name='req_date', lookup_expr='lte') + + class Meta: + model = MaterialRequisition + fields = ['belong_dept', 'req_date_after', 'req_date_before'] diff --git a/apps/mpr/migrations/0001_initial.py b/apps/mpr/migrations/0001_initial.py new file mode 100644 index 00000000..c729e014 --- /dev/null +++ b/apps/mpr/migrations/0001_initial.py @@ -0,0 +1,64 @@ +# Generated by Django 3.2.12 on 2026-03-12 03:26 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('system', '0007_alter_dept_create_by_alter_dept_third_info_and_more'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('wf', '0006_auto_20251215_1645'), + ] + + operations = [ + migrations.CreateModel( + name='PurchaseRequisition', + fields=[ + ('id', models.CharField(editable=False, help_text='主键ID', max_length=20, primary_key=True, serialize=False, verbose_name='主键ID')), + ('create_time', models.DateTimeField(default=django.utils.timezone.now, help_text='创建时间', verbose_name='创建时间')), + ('update_time', models.DateTimeField(auto_now=True, help_text='修改时间', verbose_name='修改时间')), + ('is_deleted', models.BooleanField(default=False, help_text='删除标记', verbose_name='删除标记')), + ('number', models.CharField(max_length=20, unique=True, verbose_name='编号')), + ('phone', models.CharField(blank=True, max_length=20, null=True, verbose_name='联系电话')), + ('req_date', models.DateField(blank=True, null=True, verbose_name='申购日期')), + ('total_amount', models.DecimalField(decimal_places=2, default=0, max_digits=14, verbose_name='合计金额')), + ('note', models.TextField(blank=True, null=True, verbose_name='备注')), + ('belong_dept', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='purchaserequisition_belong_dept', to='system.dept', verbose_name='所属部门')), + ('create_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='purchaserequisition_create_by', to=settings.AUTH_USER_MODEL, verbose_name='创建人')), + ('ticket', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='mpr_ticket', to='wf.ticket', verbose_name='关联工单')), + ('update_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='purchaserequisition_update_by', to=settings.AUTH_USER_MODEL, verbose_name='最后编辑人')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='PurchaseRequisitionItem', + fields=[ + ('id', models.CharField(editable=False, help_text='主键ID', max_length=20, primary_key=True, serialize=False, verbose_name='主键ID')), + ('create_time', models.DateTimeField(default=django.utils.timezone.now, help_text='创建时间', verbose_name='创建时间')), + ('update_time', models.DateTimeField(auto_now=True, help_text='修改时间', verbose_name='修改时间')), + ('is_deleted', models.BooleanField(default=False, help_text='删除标记', verbose_name='删除标记')), + ('item_name', models.CharField(max_length=100, verbose_name='物品名称')), + ('spec', models.CharField(blank=True, max_length=200, null=True, verbose_name='规格及型号')), + ('unit', models.CharField(blank=True, max_length=20, null=True, verbose_name='单位')), + ('req_quantity', models.DecimalField(decimal_places=3, default=0, max_digits=12, verbose_name='申购数量')), + ('current_stock', models.DecimalField(decimal_places=3, default=0, max_digits=12, verbose_name='现库存量')), + ('need_date', models.DateField(blank=True, null=True, verbose_name='需用日期')), + ('purchase_quantity', models.DecimalField(decimal_places=3, default=0, max_digits=12, verbose_name='需采购数量')), + ('unit_price', models.DecimalField(decimal_places=2, default=0, max_digits=14, verbose_name='单价')), + ('total_price', models.DecimalField(decimal_places=2, default=0, max_digits=14, verbose_name='总价')), + ('note', models.TextField(blank=True, null=True, verbose_name='备注')), + ('requisition', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='mpr.purchaserequisition', verbose_name='关联申购单')), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/apps/mpr/migrations/0002_warehouseentry_warehouseentryitem.py b/apps/mpr/migrations/0002_warehouseentry_warehouseentryitem.py new file mode 100644 index 00000000..aca57078 --- /dev/null +++ b/apps/mpr/migrations/0002_warehouseentry_warehouseentryitem.py @@ -0,0 +1,65 @@ +# Generated by Django 3.2.12 on 2026-03-12 06:33 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('system', '0007_alter_dept_create_by_alter_dept_third_info_and_more'), + ('wf', '0006_auto_20251215_1645'), + ('inm', '0038_mioitem_count_send'), + ('mpr', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='WarehouseEntry', + fields=[ + ('id', models.CharField(editable=False, help_text='主键ID', max_length=20, primary_key=True, serialize=False, verbose_name='主键ID')), + ('create_time', models.DateTimeField(default=django.utils.timezone.now, help_text='创建时间', verbose_name='创建时间')), + ('update_time', models.DateTimeField(auto_now=True, help_text='修改时间', verbose_name='修改时间')), + ('is_deleted', models.BooleanField(default=False, help_text='删除标记', verbose_name='删除标记')), + ('number', models.CharField(max_length=20, unique=True, verbose_name='编号')), + ('entry_date', models.DateField(blank=True, null=True, verbose_name='入库日期')), + ('entry_type', models.CharField(choices=[('raw_normal', '原材料正常入库'), ('raw_estimated', '原材料暂估入库'), ('product', '产品入库'), ('other', '其他')], default='raw_normal', max_length=20, verbose_name='入库类型')), + ('entry_method', models.CharField(choices=[('purchase', '采购'), ('self_made', '自制'), ('other', '其他')], default='purchase', max_length=20, verbose_name='入库方式')), + ('total_amount', models.DecimalField(decimal_places=2, default=0, max_digits=14, verbose_name='合计金额')), + ('note', models.TextField(blank=True, null=True, verbose_name='备注')), + ('belong_dept', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='warehouseentry_belong_dept', to='system.dept', verbose_name='所属部门')), + ('create_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='warehouseentry_create_by', to=settings.AUTH_USER_MODEL, verbose_name='创建人')), + ('ticket', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='warehouse_entry_ticket', to='wf.ticket', verbose_name='关联工单')), + ('update_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='warehouseentry_update_by', to=settings.AUTH_USER_MODEL, verbose_name='最后编辑人')), + ('warehouse', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='entries', to='inm.warehouse', verbose_name='仓库')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='WarehouseEntryItem', + fields=[ + ('id', models.CharField(editable=False, help_text='主键ID', max_length=20, primary_key=True, serialize=False, verbose_name='主键ID')), + ('create_time', models.DateTimeField(default=django.utils.timezone.now, help_text='创建时间', verbose_name='创建时间')), + ('update_time', models.DateTimeField(auto_now=True, help_text='修改时间', verbose_name='修改时间')), + ('is_deleted', models.BooleanField(default=False, help_text='删除标记', verbose_name='删除标记')), + ('name', models.CharField(max_length=100, verbose_name='名称')), + ('spec', models.CharField(blank=True, max_length=200, null=True, verbose_name='规格')), + ('unit', models.CharField(blank=True, max_length=20, null=True, verbose_name='单位')), + ('quantity', models.DecimalField(decimal_places=3, default=0, max_digits=12, verbose_name='数量')), + ('unit_price', models.DecimalField(decimal_places=2, default=0, max_digits=14, verbose_name='单价')), + ('amount', models.DecimalField(decimal_places=2, default=0, max_digits=14, verbose_name='金额')), + ('supplier_name', models.CharField(blank=True, max_length=100, null=True, verbose_name='供应商名称')), + ('invoice_received', models.BooleanField(default=False, verbose_name='账单是否收到')), + ('note', models.TextField(blank=True, null=True, verbose_name='备注')), + ('entry', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='mpr.warehouseentry', verbose_name='关联入库单')), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/apps/mpr/migrations/0003_warehousestock.py b/apps/mpr/migrations/0003_warehousestock.py new file mode 100644 index 00000000..fc5ea555 --- /dev/null +++ b/apps/mpr/migrations/0003_warehousestock.py @@ -0,0 +1,42 @@ +# Generated by Django 3.2.12 on 2026-03-12 07:26 + +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone + + +class Migration(migrations.Migration): + + dependencies = [ + ('inm', '0038_mioitem_count_send'), + ('mpr', '0002_warehouseentry_warehouseentryitem'), + ] + + operations = [ + migrations.CreateModel( + name='WarehouseStock', + fields=[ + ('id', models.CharField(editable=False, help_text='主键ID', max_length=20, primary_key=True, serialize=False, verbose_name='主键ID')), + ('create_time', models.DateTimeField(default=django.utils.timezone.now, help_text='创建时间', verbose_name='创建时间')), + ('update_time', models.DateTimeField(auto_now=True, help_text='修改时间', verbose_name='修改时间')), + ('is_deleted', models.BooleanField(default=False, help_text='删除标记', verbose_name='删除标记')), + ('entry_number', models.CharField(max_length=20, verbose_name='入库单号')), + ('entry_date', models.DateField(blank=True, null=True, verbose_name='入库日期')), + ('entry_type', models.CharField(choices=[('raw_normal', '原材料正常入库'), ('raw_estimated', '原材料暂估入库'), ('product', '产品入库'), ('other', '其他')], max_length=20, verbose_name='入库类型')), + ('entry_method', models.CharField(choices=[('purchase', '采购'), ('self_made', '自制'), ('other', '其他')], max_length=20, verbose_name='入库方式')), + ('name', models.CharField(max_length=100, verbose_name='名称')), + ('spec', models.CharField(blank=True, max_length=200, null=True, verbose_name='规格')), + ('unit', models.CharField(blank=True, max_length=20, null=True, verbose_name='单位')), + ('quantity', models.DecimalField(decimal_places=3, default=0, max_digits=12, verbose_name='数量')), + ('unit_price', models.DecimalField(decimal_places=2, default=0, max_digits=14, verbose_name='单价')), + ('amount', models.DecimalField(decimal_places=2, default=0, max_digits=14, verbose_name='金额')), + ('supplier_name', models.CharField(blank=True, max_length=100, null=True, verbose_name='供应商名称')), + ('invoice_received', models.BooleanField(default=False, verbose_name='账单是否收到')), + ('entry', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='stocks', to='mpr.warehouseentry', verbose_name='来源入库单')), + ('warehouse', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='mpr_stocks', to='inm.warehouse', verbose_name='仓库')), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/apps/mpr/migrations/0004_materialrequisition_materialrequisitionitem.py b/apps/mpr/migrations/0004_materialrequisition_materialrequisitionitem.py new file mode 100644 index 00000000..13f2ec0d --- /dev/null +++ b/apps/mpr/migrations/0004_materialrequisition_materialrequisitionitem.py @@ -0,0 +1,60 @@ +# Generated by Django 3.2.12 on 2026-03-12 08:06 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone + + +class Migration(migrations.Migration): + + dependencies = [ + ('system', '0007_alter_dept_create_by_alter_dept_third_info_and_more'), + ('wf', '0006_auto_20251215_1645'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('mpr', '0003_warehousestock'), + ] + + operations = [ + migrations.CreateModel( + name='MaterialRequisition', + fields=[ + ('id', models.CharField(editable=False, help_text='主键ID', max_length=20, primary_key=True, serialize=False, verbose_name='主键ID')), + ('create_time', models.DateTimeField(default=django.utils.timezone.now, help_text='创建时间', verbose_name='创建时间')), + ('update_time', models.DateTimeField(auto_now=True, help_text='修改时间', verbose_name='修改时间')), + ('is_deleted', models.BooleanField(default=False, help_text='删除标记', verbose_name='删除标记')), + ('number', models.CharField(max_length=20, unique=True, verbose_name='编号')), + ('req_date', models.DateField(blank=True, null=True, verbose_name='填报时间')), + ('collector', models.CharField(blank=True, max_length=50, null=True, verbose_name='领取人')), + ('note', models.TextField(blank=True, null=True, verbose_name='备注')), + ('belong_dept', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='materialrequisition_belong_dept', to='system.dept', verbose_name='所属部门')), + ('create_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='materialrequisition_create_by', to=settings.AUTH_USER_MODEL, verbose_name='创建人')), + ('ticket', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='material_requisition_ticket', to='wf.ticket', verbose_name='关联工单')), + ('update_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='materialrequisition_update_by', to=settings.AUTH_USER_MODEL, verbose_name='最后编辑人')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='MaterialRequisitionItem', + fields=[ + ('id', models.CharField(editable=False, help_text='主键ID', max_length=20, primary_key=True, serialize=False, verbose_name='主键ID')), + ('create_time', models.DateTimeField(default=django.utils.timezone.now, help_text='创建时间', verbose_name='创建时间')), + ('update_time', models.DateTimeField(auto_now=True, help_text='修改时间', verbose_name='修改时间')), + ('is_deleted', models.BooleanField(default=False, help_text='删除标记', verbose_name='删除标记')), + ('is_stock_item', models.BooleanField(default=True, verbose_name='是否库存物品')), + ('req_type', models.CharField(blank=True, max_length=50, null=True, verbose_name='领用类型')), + ('name', models.CharField(max_length=100, verbose_name='物资名称')), + ('spec', models.CharField(blank=True, max_length=200, null=True, verbose_name='规格型号')), + ('unit', models.CharField(blank=True, max_length=20, null=True, verbose_name='单位')), + ('quantity', models.DecimalField(decimal_places=3, default=0, max_digits=12, verbose_name='领用量')), + ('note', models.TextField(blank=True, null=True, verbose_name='备注')), + ('requisition', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='mpr.materialrequisition', verbose_name='关联领用单')), + ('stock', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='requisition_items', to='mpr.warehousestock', verbose_name='关联库存')), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/apps/mpr/migrations/0005_warehousestock_status.py b/apps/mpr/migrations/0005_warehousestock_status.py new file mode 100644 index 00000000..6c4c998a --- /dev/null +++ b/apps/mpr/migrations/0005_warehousestock_status.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.12 on 2026-03-12 08:41 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('mpr', '0004_materialrequisition_materialrequisitionitem'), + ] + + operations = [ + migrations.AddField( + model_name='warehousestock', + name='status', + field=models.CharField(choices=[('idle', '闲置'), ('in_requisition', '领用中'), ('requisitioned', '已领用')], default='idle', max_length=20, verbose_name='状态'), + ), + ] diff --git a/apps/mpr/migrations/__init__.py b/apps/mpr/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/mpr/models.py b/apps/mpr/models.py new file mode 100644 index 00000000..785bf4cb --- /dev/null +++ b/apps/mpr/models.py @@ -0,0 +1,182 @@ +from django.db import models +from apps.utils.models import BaseModel, CommonBDModel +from datetime import datetime +from django.db.models import Max, Sum + + +def _get_number(model_cls): + today_str = datetime.now().strftime('%Y%m%d') + prefix = model_cls.PREFIX + last_record = model_cls.objects.filter( + number__startswith=f"{prefix}-{today_str}" + ).aggregate(Max('number'))['number__max'] + if last_record: + last_number = int(last_record.split('-')[-1]) + 1 + else: + last_number = 1 + return f"{prefix}-{today_str}-{last_number:04d}" + + +class PurchaseRequisition(CommonBDModel): + """ + TN:物资申购单 + """ + PREFIX = 'WZSG' + + number = models.CharField('编号', max_length=20, unique=True) + phone = models.CharField('联系电话', max_length=20, null=True, blank=True) + req_date = models.DateField('申购日期', null=True, blank=True) + total_amount = models.DecimalField('合计金额', max_digits=14, decimal_places=2, default=0) + note = models.TextField('备注', null=True, blank=True) + ticket = models.OneToOneField( + 'wf.ticket', verbose_name='关联工单', + on_delete=models.SET_NULL, null=True, blank=True, + related_name='mpr_ticket') + + @classmethod + def get_a_number(cls): + return _get_number(cls) + + +class PurchaseRequisitionItem(BaseModel): + """ + TN:物资申购明细 + """ + requisition = models.ForeignKey( + PurchaseRequisition, verbose_name='关联申购单', + on_delete=models.CASCADE, related_name='items') + item_name = models.CharField('物品名称', max_length=100) + spec = models.CharField('规格及型号', max_length=200, null=True, blank=True) + unit = models.CharField('单位', max_length=20, null=True, blank=True) + req_quantity = models.DecimalField('申购数量', max_digits=12, decimal_places=3, default=0) + current_stock = models.DecimalField('现库存量', max_digits=12, decimal_places=3, default=0) + need_date = models.DateField('需用日期', null=True, blank=True) + purchase_quantity = models.DecimalField('需采购数量', max_digits=12, decimal_places=3, default=0) + unit_price = models.DecimalField('单价', max_digits=14, decimal_places=2, default=0) + total_price = models.DecimalField('总价', max_digits=14, decimal_places=2, default=0) + note = models.TextField('备注', null=True, blank=True) + + +class WarehouseEntry(CommonBDModel): + """ + TN:仓库入库单 + """ + PREFIX = 'CKRK' + + ENTRY_TYPE_CHOICES = ( + ('raw_normal', '原材料正常入库'), + ('raw_estimated', '原材料暂估入库'), + ('product', '产品入库'), + ('other', '其他'), + ) + ENTRY_METHOD_CHOICES = ( + ('purchase', '采购'), + ('self_made', '自制'), + ('other', '其他'), + ) + + number = models.CharField('编号', max_length=20, unique=True) + warehouse = models.ForeignKey( + 'inm.WareHouse', verbose_name='仓库', + on_delete=models.CASCADE, related_name='entries') + entry_date = models.DateField('入库日期', null=True, blank=True) + entry_type = models.CharField('入库类型', max_length=20, choices=ENTRY_TYPE_CHOICES, default='raw_normal') + entry_method = models.CharField('入库方式', max_length=20, choices=ENTRY_METHOD_CHOICES, default='purchase') + total_amount = models.DecimalField('合计金额', max_digits=14, decimal_places=2, default=0) + note = models.TextField('备注', null=True, blank=True) + ticket = models.OneToOneField( + 'wf.ticket', verbose_name='关联工单', + on_delete=models.SET_NULL, null=True, blank=True, + related_name='warehouse_entry_ticket') + + @classmethod + def get_a_number(cls): + return _get_number(cls) + + +class WarehouseEntryItem(BaseModel): + """ + TN:入库明细 + """ + entry = models.ForeignKey( + WarehouseEntry, verbose_name='关联入库单', + on_delete=models.CASCADE, related_name='items') + name = models.CharField('名称', max_length=100) + spec = models.CharField('规格', max_length=200, null=True, blank=True) + unit = models.CharField('单位', max_length=20, null=True, blank=True) + quantity = models.DecimalField('数量', max_digits=12, decimal_places=3, default=0) + unit_price = models.DecimalField('单价', max_digits=14, decimal_places=2, default=0) + amount = models.DecimalField('金额', max_digits=14, decimal_places=2, default=0) + supplier_name = models.CharField('供应商名称', max_length=100, null=True, blank=True) + invoice_received = models.BooleanField('账单是否收到', default=False) + note = models.TextField('备注', null=True, blank=True) + + +class WarehouseStock(BaseModel): + """ + TN:物料库存(审批通过后入库) + """ + STATUS_CHOICES = ( + ('idle', '闲置'), + ('in_requisition', '领用中'), + ('requisitioned', '已领用'), + ) + + warehouse = models.ForeignKey( + 'inm.WareHouse', verbose_name='仓库', + on_delete=models.CASCADE, related_name='mpr_stocks') + entry = models.ForeignKey( + WarehouseEntry, verbose_name='来源入库单', + on_delete=models.SET_NULL, null=True, blank=True, related_name='stocks') + entry_number = models.CharField('入库单号', max_length=20) + entry_date = models.DateField('入库日期', null=True, blank=True) + entry_type = models.CharField('入库类型', max_length=20, choices=WarehouseEntry.ENTRY_TYPE_CHOICES) + entry_method = models.CharField('入库方式', max_length=20, choices=WarehouseEntry.ENTRY_METHOD_CHOICES) + name = models.CharField('名称', max_length=100) + spec = models.CharField('规格', max_length=200, null=True, blank=True) + unit = models.CharField('单位', max_length=20, null=True, blank=True) + quantity = models.DecimalField('数量', max_digits=12, decimal_places=3, default=0) + unit_price = models.DecimalField('单价', max_digits=14, decimal_places=2, default=0) + amount = models.DecimalField('金额', max_digits=14, decimal_places=2, default=0) + supplier_name = models.CharField('供应商名称', max_length=100, null=True, blank=True) + invoice_received = models.BooleanField('账单是否收到', default=False) + status = models.CharField('状态', max_length=20, choices=STATUS_CHOICES, default='idle') + + +class MaterialRequisition(CommonBDModel): + """ + TN:物资领用单 + """ + PREFIX = 'WZLY' + + number = models.CharField('编号', max_length=20, unique=True) + req_date = models.DateField('填报时间', null=True, blank=True) + collector = models.CharField('领取人', max_length=50, null=True, blank=True) + note = models.TextField('备注', null=True, blank=True) + ticket = models.OneToOneField( + 'wf.ticket', verbose_name='关联工单', + on_delete=models.SET_NULL, null=True, blank=True, + related_name='material_requisition_ticket') + + @classmethod + def get_a_number(cls): + return _get_number(cls) + + +class MaterialRequisitionItem(BaseModel): + """ + TN:物资领用明细 + """ + requisition = models.ForeignKey( + MaterialRequisition, verbose_name='关联领用单', + on_delete=models.CASCADE, related_name='items') + is_stock_item = models.BooleanField('是否库存物品', default=True) + stock = models.ForeignKey( + WarehouseStock, verbose_name='关联库存', + on_delete=models.SET_NULL, null=True, blank=True, related_name='requisition_items') + req_type = models.CharField('领用类型', max_length=50, null=True, blank=True) + name = models.CharField('物资名称', max_length=100) + spec = models.CharField('规格型号', max_length=200, null=True, blank=True) + unit = models.CharField('单位', max_length=20, null=True, blank=True) + quantity = models.DecimalField('领用量', max_digits=12, decimal_places=3, default=0) + note = models.TextField('备注', null=True, blank=True) diff --git a/apps/mpr/serializers.py b/apps/mpr/serializers.py new file mode 100644 index 00000000..8f49cc8a --- /dev/null +++ b/apps/mpr/serializers.py @@ -0,0 +1,271 @@ +from decimal import Decimal +from rest_framework import serializers +from rest_framework.exceptions import ParseError +from apps.utils.serializers import CustomModelSerializer +from apps.utils.constants import EXCLUDE_FIELDS +from apps.mpr.models import ( + PurchaseRequisition, PurchaseRequisitionItem, + WarehouseEntry, WarehouseEntryItem, WarehouseStock, + MaterialRequisition, MaterialRequisitionItem, +) +from apps.wf.serializers import TicketSimpleSerializer + + +# ========== 物资申购单 ========== + +class PurchaseRequisitionItemSerializer(CustomModelSerializer): + class Meta: + model = PurchaseRequisitionItem + fields = '__all__' + read_only_fields = ['create_time', 'update_time', 'is_deleted'] + + +class PurchaseRequisitionListSerializer(CustomModelSerializer): + create_by_name = serializers.CharField(source='create_by.name', read_only=True) + belong_dept_name = serializers.CharField(source='belong_dept.name', read_only=True) + ticket_ = TicketSimpleSerializer(source='ticket', read_only=True) + + class Meta: + model = PurchaseRequisition + fields = '__all__' + read_only_fields = EXCLUDE_FIELDS + + +class PurchaseRequisitionDetailSerializer(CustomModelSerializer): + create_by_name = serializers.CharField(source='create_by.name', read_only=True) + belong_dept_name = serializers.CharField(source='belong_dept.name', read_only=True) + ticket_ = TicketSimpleSerializer(source='ticket', read_only=True) + items_ = PurchaseRequisitionItemSerializer(source='items', many=True, read_only=True) + + class Meta: + model = PurchaseRequisition + fields = '__all__' + read_only_fields = EXCLUDE_FIELDS + + +class PurchaseRequisitionCreateSerializer(CustomModelSerializer): + items = serializers.ListField(child=serializers.DictField(), write_only=True, required=False, default=[]) + + class Meta: + model = PurchaseRequisition + fields = '__all__' + read_only_fields = EXCLUDE_FIELDS + ['belong_dept', 'number', 'total_amount'] + + def create(self, validated_data): + items_data = validated_data.pop('items', []) + validated_data['number'] = PurchaseRequisition.get_a_number() + instance = super().create(validated_data) + self._save_items(instance, items_data) + return instance + + def update(self, instance, validated_data): + items_data = validated_data.pop('items', None) + instance = super().update(instance, validated_data) + if items_data is not None: + instance.items.all().delete() + self._save_items(instance, items_data) + return instance + + def _save_items(self, instance, items_data): + total = 0 + for item in items_data: + item.pop('id', None) + unit_price = float(item.get('unit_price', 0) or 0) + purchase_quantity = float(item.get('purchase_quantity', 0) or 0) + item['total_price'] = round(unit_price * purchase_quantity, 2) + total += item['total_price'] + PurchaseRequisitionItem.objects.create(requisition=instance, **item) + instance.total_amount = total + instance.save(update_fields=['total_amount']) + + +# ========== 仓库入库单 ========== + +class WarehouseEntryItemSerializer(CustomModelSerializer): + class Meta: + model = WarehouseEntryItem + fields = '__all__' + read_only_fields = ['create_time', 'update_time', 'is_deleted'] + + +class WarehouseEntryListSerializer(CustomModelSerializer): + create_by_name = serializers.CharField(source='create_by.name', read_only=True) + belong_dept_name = serializers.CharField(source='belong_dept.name', read_only=True) + warehouse_name = serializers.CharField(source='warehouse.name', read_only=True) + ticket_ = TicketSimpleSerializer(source='ticket', read_only=True) + entry_type_display = serializers.CharField(source='get_entry_type_display', read_only=True) + entry_method_display = serializers.CharField(source='get_entry_method_display', read_only=True) + + class Meta: + model = WarehouseEntry + fields = '__all__' + read_only_fields = EXCLUDE_FIELDS + + +class WarehouseEntryDetailSerializer(CustomModelSerializer): + create_by_name = serializers.CharField(source='create_by.name', read_only=True) + belong_dept_name = serializers.CharField(source='belong_dept.name', read_only=True) + warehouse_name = serializers.CharField(source='warehouse.name', read_only=True) + ticket_ = TicketSimpleSerializer(source='ticket', read_only=True) + items_ = WarehouseEntryItemSerializer(source='items', many=True, read_only=True) + entry_type_display = serializers.CharField(source='get_entry_type_display', read_only=True) + entry_method_display = serializers.CharField(source='get_entry_method_display', read_only=True) + + class Meta: + model = WarehouseEntry + fields = '__all__' + read_only_fields = EXCLUDE_FIELDS + + +class WarehouseEntryCreateSerializer(CustomModelSerializer): + items = serializers.ListField(child=serializers.DictField(), write_only=True, required=False, default=[]) + + class Meta: + model = WarehouseEntry + fields = '__all__' + read_only_fields = EXCLUDE_FIELDS + ['belong_dept', 'number', 'total_amount'] + + def create(self, validated_data): + items_data = validated_data.pop('items', []) + validated_data['number'] = WarehouseEntry.get_a_number() + instance = super().create(validated_data) + self._save_items(instance, items_data) + return instance + + def update(self, instance, validated_data): + items_data = validated_data.pop('items', None) + instance = super().update(instance, validated_data) + if items_data is not None: + instance.items.all().delete() + self._save_items(instance, items_data) + return instance + + def _save_items(self, instance, items_data): + total = 0 + for item in items_data: + item.pop('id', None) + unit_price = float(item.get('unit_price', 0) or 0) + quantity = float(item.get('quantity', 0) or 0) + item['amount'] = round(unit_price * quantity, 2) + total += item['amount'] + WarehouseEntryItem.objects.create(entry=instance, **item) + instance.total_amount = total + instance.save(update_fields=['total_amount']) + + +# ========== 物料库存 ========== + +class WarehouseStockSerializer(CustomModelSerializer): + warehouse_name = serializers.CharField(source='warehouse.name', read_only=True) + entry_type_display = serializers.CharField(source='get_entry_type_display', read_only=True) + entry_method_display = serializers.CharField(source='get_entry_method_display', read_only=True) + status_display = serializers.SerializerMethodField() + + class Meta: + model = WarehouseStock + fields = '__all__' + read_only_fields = ['create_time', 'update_time', 'is_deleted'] + + def get_status_display(self, obj): + if obj.quantity <= 0: + return '已领完' + return obj.get_status_display() + + +# ========== 物资领用单 ========== + +class MaterialRequisitionItemSerializer(CustomModelSerializer): + stock_name = serializers.CharField(source='stock.name', read_only=True, default='') + + class Meta: + model = MaterialRequisitionItem + fields = '__all__' + read_only_fields = ['create_time', 'update_time', 'is_deleted'] + + +class MaterialRequisitionListSerializer(CustomModelSerializer): + create_by_name = serializers.CharField(source='create_by.name', read_only=True) + belong_dept_name = serializers.CharField(source='belong_dept.name', read_only=True) + ticket_ = TicketSimpleSerializer(source='ticket', read_only=True) + + class Meta: + model = MaterialRequisition + fields = '__all__' + read_only_fields = EXCLUDE_FIELDS + + +class MaterialRequisitionDetailSerializer(CustomModelSerializer): + create_by_name = serializers.CharField(source='create_by.name', read_only=True) + belong_dept_name = serializers.CharField(source='belong_dept.name', read_only=True) + ticket_ = TicketSimpleSerializer(source='ticket', read_only=True) + items_ = MaterialRequisitionItemSerializer(source='items', many=True, read_only=True) + + class Meta: + model = MaterialRequisition + fields = '__all__' + read_only_fields = EXCLUDE_FIELDS + + +class MaterialRequisitionCreateSerializer(CustomModelSerializer): + items = serializers.ListField(child=serializers.DictField(), write_only=True, required=False, default=[]) + + class Meta: + model = MaterialRequisition + fields = '__all__' + read_only_fields = EXCLUDE_FIELDS + ['belong_dept', 'number'] + + def create(self, validated_data): + items_data = validated_data.pop('items', []) + validated_data['number'] = MaterialRequisition.get_a_number() + instance = super().create(validated_data) + self._save_items(instance, items_data) + return instance + + def update(self, instance, validated_data): + items_data = validated_data.pop('items', None) + instance = super().update(instance, validated_data) + if items_data is not None: + self._restore_stock(instance) + instance.items.all().delete() + self._save_items(instance, items_data) + return instance + + def _save_items(self, instance, items_data): + for item in items_data: + item.pop('id', None) + is_stock_item = item.get('is_stock_item', True) + stock_id = item.pop('stock', None) or item.pop('stock_id', None) + quantity = Decimal(str(item.get('quantity', 0) or 0)) + + stock_obj = None + if is_stock_item and stock_id: + try: + stock_obj = WarehouseStock.objects.select_for_update().get(id=stock_id) + except WarehouseStock.DoesNotExist: + raise ParseError(f"库存记录不存在: {stock_id}") + if stock_obj.quantity < quantity: + raise ParseError(f"库存不足: {stock_obj.name} 库存{stock_obj.quantity} < 领用{quantity}") + stock_obj.quantity -= quantity + stock_obj.status = 'in_requisition' + stock_obj.save(update_fields=['quantity', 'status']) + + MaterialRequisitionItem.objects.create( + requisition=instance, + is_stock_item=is_stock_item, + stock=stock_obj, + req_type=item.get('req_type', ''), + name=item.get('name', ''), + spec=item.get('spec', ''), + unit=item.get('unit', ''), + quantity=quantity, + note=item.get('note', ''), + ) + + @staticmethod + def _restore_stock(instance): + """恢复库存(用于编辑或拒绝时)""" + for item in instance.items.filter(is_stock_item=True, stock__isnull=False): + stock = WarehouseStock.objects.select_for_update().get(id=item.stock_id) + stock.quantity += item.quantity + stock.status = 'idle' + stock.save(update_fields=['quantity', 'status']) diff --git a/apps/mpr/tests.py b/apps/mpr/tests.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/mpr/urls.py b/apps/mpr/urls.py new file mode 100644 index 00000000..f6191e71 --- /dev/null +++ b/apps/mpr/urls.py @@ -0,0 +1,23 @@ +from django.urls import path, include +from rest_framework.routers import DefaultRouter +from apps.mpr.views import ( + PurchaseRequisitionViewSet, PurchaseRequisitionItemViewSet, + WarehouseEntryViewSet, WarehouseEntryItemViewSet, + WarehouseStockViewSet, + MaterialRequisitionViewSet, MaterialRequisitionItemViewSet, +) + +API_BASE_URL = 'api/mpr/' + +router = DefaultRouter() +router.register('requisition', PurchaseRequisitionViewSet, basename='requisition') +router.register('requisition_item', PurchaseRequisitionItemViewSet, basename='requisition_item') +router.register('warehouse_entry', WarehouseEntryViewSet, basename='warehouse_entry') +router.register('warehouse_entry_item', WarehouseEntryItemViewSet, basename='warehouse_entry_item') +router.register('warehouse_stock', WarehouseStockViewSet, basename='warehouse_stock') +router.register('material_requisition', MaterialRequisitionViewSet, basename='material_requisition') +router.register('material_requisition_item', MaterialRequisitionItemViewSet, basename='material_requisition_item') + +urlpatterns = [ + path(API_BASE_URL, include(router.urls)), +] diff --git a/apps/mpr/views.py b/apps/mpr/views.py new file mode 100644 index 00000000..c00848f1 --- /dev/null +++ b/apps/mpr/views.py @@ -0,0 +1,173 @@ +from rest_framework.exceptions import ParseError +from django.db import transaction + +from apps.utils.viewsets import CustomModelViewSet +from apps.wf.mixins import TicketMixin +from apps.wf.models import Ticket +from apps.mpr.models import ( + PurchaseRequisition, PurchaseRequisitionItem, + WarehouseEntry, WarehouseEntryItem, WarehouseStock, + MaterialRequisition, MaterialRequisitionItem, +) +from apps.mpr.serializers import ( + PurchaseRequisitionListSerializer, + PurchaseRequisitionDetailSerializer, + PurchaseRequisitionCreateSerializer, + PurchaseRequisitionItemSerializer, + WarehouseEntryListSerializer, + WarehouseEntryDetailSerializer, + WarehouseEntryCreateSerializer, + WarehouseEntryItemSerializer, + WarehouseStockSerializer, + MaterialRequisitionListSerializer, + MaterialRequisitionDetailSerializer, + MaterialRequisitionCreateSerializer, + MaterialRequisitionItemSerializer, +) +from apps.mpr.filters import ( + PurchaseRequisitionFilter, WarehouseEntryFilter, + WarehouseStockFilter, MaterialRequisitionFilter, +) + + +class PurchaseRequisitionViewSet(TicketMixin, CustomModelViewSet): + """ + 物资申购单 + """ + queryset = PurchaseRequisition.objects.all() + serializer_class = PurchaseRequisitionListSerializer + retrieve_serializer_class = PurchaseRequisitionDetailSerializer + create_serializer_class = PurchaseRequisitionCreateSerializer + update_serializer_class = PurchaseRequisitionCreateSerializer + select_related_fields = ['create_by', 'belong_dept', 'ticket', 'ticket__state'] + search_fields = ['number', 'create_by__name'] + filterset_class = PurchaseRequisitionFilter + ordering = '-create_time' + workflow_key = 'wf_mpr' + + def gen_other_ticket_data(self, instance): + dept_name = instance.belong_dept.name if instance.belong_dept else '' + return {"dept_name": dept_name} + + +class PurchaseRequisitionItemViewSet(CustomModelViewSet): + """ + 物资申购明细 + """ + queryset = PurchaseRequisitionItem.objects.all() + serializer_class = PurchaseRequisitionItemSerializer + filterset_fields = ['requisition'] + ordering = 'create_time' + + +class WarehouseEntryViewSet(TicketMixin, CustomModelViewSet): + """ + 仓库入库单 + """ + queryset = WarehouseEntry.objects.all() + serializer_class = WarehouseEntryListSerializer + retrieve_serializer_class = WarehouseEntryDetailSerializer + create_serializer_class = WarehouseEntryCreateSerializer + update_serializer_class = WarehouseEntryCreateSerializer + select_related_fields = ['create_by', 'belong_dept', 'warehouse', 'ticket', 'ticket__state'] + search_fields = ['number', 'create_by__name', 'warehouse__name'] + filterset_class = WarehouseEntryFilter + ordering = '-create_time' + workflow_key = 'wf_warehouse_entry' + + def gen_other_ticket_data(self, instance): + return {"warehouse_name": instance.warehouse.name if instance.warehouse else ''} + + @staticmethod + def approve_entry(ticket: Ticket, transition, new_ticket_data: dict): + """审批通过后,将入库明细写入物料库存""" + entry: WarehouseEntry = WarehouseEntry.objects.get(ticket=ticket) + if WarehouseStock.objects.filter(entry=entry).exists(): + raise ParseError('该入库单已入库,不可重复操作') + for item in entry.items.all(): + WarehouseStock.objects.create( + warehouse=entry.warehouse, + entry=entry, + entry_number=entry.number, + entry_date=entry.entry_date, + entry_type=entry.entry_type, + entry_method=entry.entry_method, + name=item.name, + spec=item.spec, + unit=item.unit, + quantity=item.quantity, + unit_price=item.unit_price, + amount=item.amount, + supplier_name=item.supplier_name, + invoice_received=item.invoice_received, + ) + + +class WarehouseEntryItemViewSet(CustomModelViewSet): + """ + 入库明细 + """ + queryset = WarehouseEntryItem.objects.all() + serializer_class = WarehouseEntryItemSerializer + filterset_fields = ['entry'] + ordering = 'create_time' + + +class WarehouseStockViewSet(CustomModelViewSet): + """ + 物料库存 + """ + queryset = WarehouseStock.objects.all() + serializer_class = WarehouseStockSerializer + select_related_fields = ['warehouse', 'entry'] + search_fields = ['name', 'spec', 'supplier_name', 'entry_number'] + filterset_class = WarehouseStockFilter + ordering = '-create_time' + perms_map = {'get': '*', 'post': 'warehouse_stock.create', + 'put': 'warehouse_stock.update', 'delete': 'warehouse_stock.delete'} + + +class MaterialRequisitionViewSet(TicketMixin, CustomModelViewSet): + """ + 物资领用单 + """ + queryset = MaterialRequisition.objects.all() + serializer_class = MaterialRequisitionListSerializer + retrieve_serializer_class = MaterialRequisitionDetailSerializer + create_serializer_class = MaterialRequisitionCreateSerializer + update_serializer_class = MaterialRequisitionCreateSerializer + select_related_fields = ['create_by', 'belong_dept', 'ticket', 'ticket__state'] + search_fields = ['number', 'create_by__name', 'collector'] + filterset_class = MaterialRequisitionFilter + ordering = '-create_time' + workflow_key = 'wf_material_requis' + + def gen_other_ticket_data(self, instance): + dept_name = instance.belong_dept.name if instance.belong_dept else '' + return {"dept_name": dept_name, "collector": instance.collector or ''} + + @staticmethod + def approve_requisition(ticket: Ticket, transition, new_ticket_data: dict): + """审批通过后,将库存物品状态改为已领用""" + req = MaterialRequisition.objects.get(ticket=ticket) + for item in req.items.filter(is_stock_item=True, stock__isnull=False): + stock = WarehouseStock.objects.select_for_update().get(id=item.stock_id) + stock.status = 'requisitioned' + stock.save(update_fields=['status']) + + @staticmethod + def reject_requisition(ticket: Ticket, transition, new_ticket_data: dict): + """审批拒绝后,恢复库存数量和状态""" + from apps.mpr.serializers import MaterialRequisitionCreateSerializer + req = MaterialRequisition.objects.get(ticket=ticket) + MaterialRequisitionCreateSerializer._restore_stock(req) + + +class MaterialRequisitionItemViewSet(CustomModelViewSet): + """ + 物资领用明细 + """ + queryset = MaterialRequisitionItem.objects.all() + serializer_class = MaterialRequisitionItemSerializer + filterset_fields = ['requisition'] + ordering = 'create_time' diff --git a/server/settings.py b/server/settings.py index d1112fb5..0797700d 100755 --- a/server/settings.py +++ b/server/settings.py @@ -88,7 +88,8 @@ INSTALLED_APPS = [ 'apps.ofm', 'apps.srm', 'apps.asm', - 'apps.rem' + 'apps.rem', + 'apps.mpr' ] MIDDLEWARE = [ diff --git a/server/urls.py b/server/urls.py index 431852a8..da981a42 100755 --- a/server/urls.py +++ b/server/urls.py @@ -79,6 +79,7 @@ urlpatterns = [ path('', include('apps.srm.urls')), path('', include('apps.asm.urls')), path('', include('apps.rem.urls')), + path('', include('apps.mpr.urls')), # 前端页面入口 path('', TemplateView.as_view(template_name="index.html")),