From 416aa50a678506eff087bfaecda03ec6ed220fa1 Mon Sep 17 00:00:00 2001 From: TianyangZhang Date: Fri, 6 Mar 2026 11:14:16 +0800 Subject: [PATCH 1/4] =?UTF-8?q?feat:hrm-migration=20=E4=BF=AE=E6=94=B9?= =?UTF-8?q?=E8=BF=81=E7=A7=BB=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/hrm/migrations/0030_probation.py | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/hrm/migrations/0030_probation.py b/apps/hrm/migrations/0030_probation.py index 9b957b7d..90ad30a1 100644 --- a/apps/hrm/migrations/0030_probation.py +++ b/apps/hrm/migrations/0030_probation.py @@ -9,7 +9,6 @@ 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), ('hrm', '0029_auto_20260205_1337'), From 7e052b7b71991914f523e71c924006a0fe72b56e Mon Sep 17 00:00:00 2001 From: TianyangZhang Date: Thu, 12 Mar 2026 08:48:52 +0800 Subject: [PATCH 2/4] =?UTF-8?q?feat:hrm-=E4=BA=BA=E5=91=98=E5=90=88?= =?UTF-8?q?=E5=90=8C=E5=8F=98=E6=9B=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/hrm/models.py | 2 +- apps/hrm/serializers.py | 4 ++-- apps/hrm/urls.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/hrm/models.py b/apps/hrm/models.py index 8ec6dc1b..19b52f0c 100755 --- a/apps/hrm/models.py +++ b/apps/hrm/models.py @@ -357,7 +357,7 @@ class EmpContract(CommonAModel): employee = models.OneToOneField(Employee, verbose_name='人员信息', on_delete=models.SET_NULL, null=True, blank=True) ticket = models.OneToOneField('wf.ticket', verbose_name='关联工单', on_delete=models.CASCADE, related_name='contract_ticket', null=True, blank=True) - counts = models.PositiveSmallIntegerField('合同变更次数', default=1) + counts = models.PositiveSmallIntegerField('合同变更次数', default=0) plan_renewal = models.DateField('应续签', null=True, blank=True) normal_renewal = models.DateField('正常续签', null=True, blank=True) change_date = models.IntegerField('续签/变更(年)', null=True, blank=True) diff --git a/apps/hrm/serializers.py b/apps/hrm/serializers.py index a3b1ca1d..03bc1153 100755 --- a/apps/hrm/serializers.py +++ b/apps/hrm/serializers.py @@ -421,8 +421,8 @@ class EmpContractSerializer(CustomModelSerializer): post_name = serializers.CharField(source="employee.post.name", read_only=True) dept_name = serializers.CharField(source='employee.belong_dept.name', read_only=True) gender = serializers.CharField(source="employee.gender", read_only=True) - join_date = serializers.CharField(source="employee.join_date", read_only=True) - end_contract = serializers.CharField(source="employee.end_contract_date", read_only=True) + join_date = serializers.CharField(source="employee.start_date", read_only=True) + end_contract = serializers.CharField(source="employee.contract_end_date", read_only=True) class Meta: model = EmpContract fields = '__all__' \ No newline at end of file diff --git a/apps/hrm/urls.py b/apps/hrm/urls.py index 368bcf62..b62f25f5 100755 --- a/apps/hrm/urls.py +++ b/apps/hrm/urls.py @@ -20,7 +20,7 @@ router.register('empperson', EmpPersonInfoViewSet, basename='empperson') router.register('leave', LeaveViewSet, basename='leave') router.register('transfer', TransferViewSet, basename='transfer') router.register('probation', ProbationViewSet, basename='probation') -router.register('contract', EmpContractViewSet, basename='emp_contract') +router.register('contract', EmpContractViewSet, basename='empcontract') urlpatterns = [ path(API_BASE_URL, include(router.urls)), ] From 8541905a5cf55bce96e8b5f8fc9609f3f79a3413 Mon Sep 17 00:00:00 2001 From: TianyangZhang Date: Thu, 12 Mar 2026 16:56:12 +0800 Subject: [PATCH 3/4] =?UTF-8?q?feat:mpr-=E7=89=A9=E8=B5=84=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E6=A8=A1=E5=9D=97=EF=BC=88=E7=94=B3=E8=B4=AD=E5=8D=95?= =?UTF-8?q?=E3=80=81=E5=85=A5=E5=BA=93=E5=8D=95=E3=80=81=E7=89=A9=E6=96=99?= =?UTF-8?q?=E5=BA=93=E5=AD=98=E3=80=81=E9=A2=86=E7=94=A8=E8=AE=B0=E5=BD=95?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 mpr app,包含物资申购单、仓库入库单、物料库存、物资领用单四个业务模型 - 物资申购单/入库单支持 TicketMixin 审批工作流,明细随主表一次性提交 - 入库单审批通过后自动生成物料库存记录 - 物料库存增加状态字段(闲置/领用中/已领用/已领完),数量为0时显示已领完 - 物资领用单提交时自动扣减库存并标记领用中,审批通过改为已领用,拒绝时恢复库存 - 包含 models、serializers、views、filters、urls 完整后端代码 Made-with: Cursor --- apps/mpr/__init__.py | 0 apps/mpr/admin.py | 0 apps/mpr/apps.py | 6 + apps/mpr/filters.py | 39 +++ apps/mpr/migrations/0001_initial.py | 64 +++++ .../0002_warehouseentry_warehouseentryitem.py | 65 +++++ apps/mpr/migrations/0003_warehousestock.py | 42 +++ ...rialrequisition_materialrequisitionitem.py | 60 ++++ .../migrations/0005_warehousestock_status.py | 18 ++ apps/mpr/migrations/__init__.py | 0 apps/mpr/models.py | 182 ++++++++++++ apps/mpr/serializers.py | 271 ++++++++++++++++++ apps/mpr/tests.py | 0 apps/mpr/urls.py | 23 ++ apps/mpr/views.py | 173 +++++++++++ server/settings.py | 3 +- server/urls.py | 1 + 17 files changed, 946 insertions(+), 1 deletion(-) create mode 100644 apps/mpr/__init__.py create mode 100644 apps/mpr/admin.py create mode 100644 apps/mpr/apps.py create mode 100644 apps/mpr/filters.py create mode 100644 apps/mpr/migrations/0001_initial.py create mode 100644 apps/mpr/migrations/0002_warehouseentry_warehouseentryitem.py create mode 100644 apps/mpr/migrations/0003_warehousestock.py create mode 100644 apps/mpr/migrations/0004_materialrequisition_materialrequisitionitem.py create mode 100644 apps/mpr/migrations/0005_warehousestock_status.py create mode 100644 apps/mpr/migrations/__init__.py create mode 100644 apps/mpr/models.py create mode 100644 apps/mpr/serializers.py create mode 100644 apps/mpr/tests.py create mode 100644 apps/mpr/urls.py create mode 100644 apps/mpr/views.py 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")), From d657c9fd266b777651396a272a7a2e744e972d77 Mon Sep 17 00:00:00 2001 From: TianyangZhang Date: Fri, 13 Mar 2026 10:01:05 +0800 Subject: [PATCH 4/4] =?UTF-8?q?feat:=20=E5=88=A0=E9=99=A4-ichat?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/ichat/__init__.py | 0 apps/ichat/admin.py | 3 - apps/ichat/apps.py | 6 - apps/ichat/migrations/0001_initial.py | 48 -------- apps/ichat/migrations/__init__.py | 0 apps/ichat/models.py | 17 --- apps/ichat/promot/w_ana.md | 14 --- apps/ichat/promot/w_sql.md | 53 --------- apps/ichat/script.py | 22 ---- apps/ichat/serializers.py | 18 --- apps/ichat/tests.py | 3 - apps/ichat/urls.py | 16 --- apps/ichat/utils.py | 88 --------------- apps/ichat/view_bak.py | 87 --------------- apps/ichat/views.py | 155 -------------------------- apps/ichat/views2.py | 129 --------------------- server/urls.py | 1 - 17 files changed, 660 deletions(-) delete mode 100644 apps/ichat/__init__.py delete mode 100644 apps/ichat/admin.py delete mode 100644 apps/ichat/apps.py delete mode 100644 apps/ichat/migrations/0001_initial.py delete mode 100644 apps/ichat/migrations/__init__.py delete mode 100644 apps/ichat/models.py delete mode 100644 apps/ichat/promot/w_ana.md delete mode 100644 apps/ichat/promot/w_sql.md delete mode 100644 apps/ichat/script.py delete mode 100644 apps/ichat/serializers.py delete mode 100644 apps/ichat/tests.py delete mode 100644 apps/ichat/urls.py delete mode 100644 apps/ichat/utils.py delete mode 100644 apps/ichat/view_bak.py delete mode 100644 apps/ichat/views.py delete mode 100644 apps/ichat/views2.py diff --git a/apps/ichat/__init__.py b/apps/ichat/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/apps/ichat/admin.py b/apps/ichat/admin.py deleted file mode 100644 index 8c38f3f3..00000000 --- a/apps/ichat/admin.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.contrib import admin - -# Register your models here. diff --git a/apps/ichat/apps.py b/apps/ichat/apps.py deleted file mode 100644 index c7bf0cf9..00000000 --- a/apps/ichat/apps.py +++ /dev/null @@ -1,6 +0,0 @@ -from django.apps import AppConfig - - -class ChatConfig(AppConfig): - default_auto_field = 'django.db.models.BigAutoField' - name = 'apps.ichat' diff --git a/apps/ichat/migrations/0001_initial.py b/apps/ichat/migrations/0001_initial.py deleted file mode 100644 index 290c1cff..00000000 --- a/apps/ichat/migrations/0001_initial.py +++ /dev/null @@ -1,48 +0,0 @@ -# Generated by Django 3.2.12 on 2025-05-21 05:59 - -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 = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.CreateModel( - name='Conversation', - 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='删除标记')), - ('title', models.CharField(default='新对话', max_length=200, verbose_name='对话标题')), - ('create_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='conversation_create_by', to=settings.AUTH_USER_MODEL, verbose_name='创建人')), - ('update_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='conversation_update_by', to=settings.AUTH_USER_MODEL, verbose_name='最后编辑人')), - ], - options={ - 'abstract': False, - }, - ), - migrations.CreateModel( - name='Message', - 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='删除标记')), - ('content', models.TextField(verbose_name='消息内容')), - ('role', models.CharField(default='user', help_text='system/user', max_length=10, verbose_name='角色')), - ('conversation', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='messages', to='ichat.conversation', verbose_name='对话')), - ], - options={ - 'abstract': False, - }, - ), - ] diff --git a/apps/ichat/migrations/__init__.py b/apps/ichat/migrations/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/apps/ichat/models.py b/apps/ichat/models.py deleted file mode 100644 index e8e79fad..00000000 --- a/apps/ichat/models.py +++ /dev/null @@ -1,17 +0,0 @@ -from django.db import models -from apps.system.models import CommonADModel, BaseModel - -# Create your models here. -class Conversation(CommonADModel): - """ - TN: 对话 - """ - title = models.CharField(max_length=200, default='新对话',verbose_name='对话标题') - -class Message(BaseModel): - """ - TN: 消息 - """ - conversation = models.ForeignKey(Conversation, on_delete=models.CASCADE, related_name='messages', verbose_name='对话') - content = models.TextField(verbose_name='消息内容') - role = models.CharField("角色", max_length=10, default='user', help_text="system/user") diff --git a/apps/ichat/promot/w_ana.md b/apps/ichat/promot/w_ana.md deleted file mode 100644 index 0751e9a1..00000000 --- a/apps/ichat/promot/w_ana.md +++ /dev/null @@ -1,14 +0,0 @@ -# 角色 -你是一位数据分析专家和前端程序员,具备深厚的专业知识和丰富的实践经验。你能够精准理解用户的文本描述, 并形成报告。 -# 技能 -1. 仔细分析用户提供的JSON格式数据,分析用户需求。 -2. 依据得到的需求, 分别获取JSON数据中的关键信息。 -3. 根据2中的关键信息最优化选择表格/饼图/柱状图/折线图等格式绘制报告。 -# 回答要求 -1. 仅生成完整的HTML代码,所有功能都需要实现,支持响应式,不要输出任何解释或说明。 -2. 代码中如需要Echarts等js库,请直接使用中国大陆的CDN链接例如bootcdn的链接。 -3. 标题为 数据分析报告。 -3. 在开始部分,请以表格形式简略展示获取的JSON数据。 -4. 之后选择最合适的图表方式生成相应的图。 -5. 在最后提供可下载该报告的完整PDF的按钮和功能。 -6. 在最后提供可下载含有JSON数据的EXCEL文件的按钮和功能。 \ No newline at end of file diff --git a/apps/ichat/promot/w_sql.md b/apps/ichat/promot/w_sql.md deleted file mode 100644 index c987e161..00000000 --- a/apps/ichat/promot/w_sql.md +++ /dev/null @@ -1,53 +0,0 @@ -# 角色 -你是一位资深的Postgresql数据库SQL专家,具备深厚的专业知识和丰富的实践经验。你能够精准理解用户的文本描述,并生成准确可执行的SQL语句。 -# 技能 -1. 仔细分析用户提供的文本描述,明确用户需求。 -2. 根据对用户需求的理解,生成符合Postgresql数据库语法的准确可执行的SQL语句。 -# 回答要求 -1. 如果用户的询问未以 查询 开头,请直接回复 "请以 查询 开头,重新描述你的需求"。 -2. 生成的SQL语句必须符合Postgresql数据库的语法规范。 -3. 不要使用 Markerdown 和 SQL 语法格式输出,禁止添加语法标准、备注、说明等信息。 -4. 直接输出符合Postgresql标准的SQL语句,用txt纯文本格式展示即可。 -5. 如果无法生成符合要求的SQL语句,请直接回复 "无法生成"。 -# 示例 -1. 问:查询 外协白片抛 工段在2025年6月1日到2025年6月15日之间的生产合格数以及合格率等 - 答:select - sum(mlog.count_use) as 领用数, - sum(mlog.count_real) as 生产数, - sum(mlog.count_ok) as 合格数, - sum(mlog.count_notok) as 不合格数, - CAST ( SUM ( mlog.count_ok ) AS FLOAT ) / NULLIF ( SUM ( mlog.count_real ), 0 ) * 100 AS 合格率 - from wpm_mlog mlog - left join mtm_mgroup mgroup on mgroup.id = mlog.mgroup_id - where mlog.submit_time is not null - and mgroup.name = '外协白片抛' - and mlog.handle_date >= '2025-06-01' - and mlog.handle_date <= '2025-06-15' -2. 问:查询 黑化 工段在2025年6月的生产合格数以及合格率等 - 答: select - sum(mlog.count_use) as 领用数, - sum(mlog.count_real) as 生产数, - sum(mlog.count_ok) as 合格数, - sum(mlog.count_notok) as 不合格数, - CAST ( SUM ( mlog.count_ok ) AS FLOAT ) / NULLIF ( SUM ( mlog.count_real ), 0 ) * 100 AS 合格率 - from wpm_mlog mlog - left join mtm_mgroup mgroup on mgroup.id = mlog.mgroup_id - where mlog.submit_time is not null - and mgroup.name = '黑化' - and mlog.handle_date >= '2025-06-01' - and mlog.handle_date <= '2025-06-30' -3. 问:查询 各工段 在2025年6月的生产合格数以及合格率等 - 答: select - mgroup.name as 工段, - sum(mlog.count_use) as 领用数, - sum(mlog.count_real) as 生产数, - sum(mlog.count_ok) as 合格数, - sum(mlog.count_notok) as 不合格数, - CAST ( SUM ( mlog.count_ok ) AS FLOAT ) / NULLIF ( SUM ( mlog.count_real ), 0 ) * 100 AS 合格率 - from wpm_mlog mlog - left join mtm_mgroup mgroup on mgroup.id = mlog.mgroup_id - where mlog.submit_time is not null - and mlog.handle_date >= '2025-06-01' - and mlog.handle_date <= '2025-06-30' - group by mgroup.id - order by mgroup.sort \ No newline at end of file diff --git a/apps/ichat/script.py b/apps/ichat/script.py deleted file mode 100644 index 705ce0d0..00000000 --- a/apps/ichat/script.py +++ /dev/null @@ -1,22 +0,0 @@ -import json -from .models import Message -from django.http import StreamingHttpResponse - -def stream_generator(stream_response: bytes, conversation_id: str): - full_content = '' - for chunk in stream_response.iter_content(chunk_size=1024): - if chunk: - full_content += chunk.decode('utf-8') - try: - data = json.loads(full_content) - content = data.get("choices", [{}])[0].get("delta", {}).get("content", "") - Message.objects.create( - conversation_id=conversation_id, - content=content - ) - yield f" data:{content}\n\n" - full_content = '' - except json.JSONDecodeError: - continue - return StreamingHttpResponse(stream_generator(stream_response, conversation_id), content_type='text/event-stream') - diff --git a/apps/ichat/serializers.py b/apps/ichat/serializers.py deleted file mode 100644 index 43102545..00000000 --- a/apps/ichat/serializers.py +++ /dev/null @@ -1,18 +0,0 @@ -from rest_framework import serializers -from .models import Conversation, Message -from apps.utils.constants import EXCLUDE_FIELDS - - -class MessageSerializer(serializers.ModelSerializer): - class Meta: - model = Message - fields = ['id', 'conversation', 'content', 'role'] - read_only_fields = EXCLUDE_FIELDS - - -class ConversationSerializer(serializers.ModelSerializer): - messages = MessageSerializer(many=True, read_only=True) - class Meta: - model = Conversation - fields = ['id', 'title', 'messages'] - read_only_fields = EXCLUDE_FIELDS \ No newline at end of file diff --git a/apps/ichat/tests.py b/apps/ichat/tests.py deleted file mode 100644 index 7ce503c2..00000000 --- a/apps/ichat/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/apps/ichat/urls.py b/apps/ichat/urls.py deleted file mode 100644 index 88a41b81..00000000 --- a/apps/ichat/urls.py +++ /dev/null @@ -1,16 +0,0 @@ - -from django.urls import path, include -from rest_framework.routers import DefaultRouter -from apps.ichat.views import QueryLLMviewSet, ConversationViewSet -from apps.ichat.views2 import WorkChain - -API_BASE_URL = 'api/ichat/' - -router = DefaultRouter() - -router.register('conversation', ConversationViewSet, basename='conversation') -router.register('message', QueryLLMviewSet, basename='message') -urlpatterns = [ - path(API_BASE_URL, include(router.urls)), - path(API_BASE_URL + 'workchain/ask/', WorkChain.as_view(), name='workchain') -] diff --git a/apps/ichat/utils.py b/apps/ichat/utils.py deleted file mode 100644 index e055a672..00000000 --- a/apps/ichat/utils.py +++ /dev/null @@ -1,88 +0,0 @@ -import re -import psycopg2 -import threading -from django.db import transaction -from .models import Message - -# 数据库连接 -def connect_db(): - from server.conf import DATABASES - db_conf = DATABASES['default'] - conn = psycopg2.connect( - host=db_conf['HOST'], - port=db_conf['PORT'], - user=db_conf['USER'], - password=db_conf['PASSWORD'], - database=db_conf['NAME'] - ) - return conn - -def extract_sql_code(text): - # 优先尝试 ```sql 包裹的语句 - match = re.search(r"```sql\s*(.+?)```", text, re.DOTALL | re.IGNORECASE) - if match: - return match.group(1).strip() - - # fallback: 寻找首个 select 语句 - match = re.search(r"(SELECT\s.+?;)", text, re.IGNORECASE | re.DOTALL) - if match: - return match.group(1).strip() - - return None - - -def get_schema_text(conn, table_names:list): - cur = conn.cursor() - query = """ - SELECT - table_name, column_name, data_type - FROM - information_schema.columns - WHERE - table_schema = 'public' - and table_name in %s; - """ - cur.execute(query, (tuple(table_names), )) - - schema = {} - for table_name, column_name, data_type in cur.fetchall(): - if table_name not in schema: - schema[table_name] = [] - schema[table_name].append(f"{column_name} ({data_type})") - cur.close() - schema_text = "" - for table_name, columns in schema.items(): - schema_text += f"表{table_name} 包含列:{', '.join(columns)}\n" - return schema_text - - -def is_safe_sql(sql:str) -> bool: - sql = sql.strip().lower() - return sql.startswith("select") or sql.startswith("show") and not re.search(r"delete|update|insert|drop|create|alter", sql) - -def execute_sql(conn, sql_query): - cur = conn.cursor() - cur.execute(sql_query) - try: - rows = cur.fetchall() - columns = [desc[0] for desc in cur.description] - result = [dict(zip(columns, row)) for row in rows] - except psycopg2.ProgrammingError: - result = cur.statusmessage - cur.close() - return result - -def strip_sql_markdown(content: str) -> str: - # 去掉包裹在 ```sql 或 ``` 中的内容 - match = re.search(r"```sql\s*(.*?)```", content, re.DOTALL | re.IGNORECASE) - if match: - return match.group(1).strip() - else: - return None - -# ORM 写入包装函数 -def save_message_thread_safe(**kwargs): - def _save(): - with transaction.atomic(): - Message.objects.create(**kwargs) - threading.Thread(target=_save).start() diff --git a/apps/ichat/view_bak.py b/apps/ichat/view_bak.py deleted file mode 100644 index 4a5570c7..00000000 --- a/apps/ichat/view_bak.py +++ /dev/null @@ -1,87 +0,0 @@ -import requests -from langchain_core.language_models import LLM -from langchain_core.outputs import LLMResult, Generation -from langchain_experimental.sql import SQLDatabaseChain -from langchain_community.utilities import SQLDatabase -from server.conf import DATABASES -from apps.ichat.serializers import CustomLLMrequestSerializer -from rest_framework.views import APIView -from urllib.parse import quote_plus -from rest_framework.response import Response - - -db_conf = DATABASES['default'] -# 密码需要 URL 编码(因为有特殊字符如 @) -password_encodeed = quote_plus(db_conf['PASSWORD']) - -db = SQLDatabase.from_uri(f"postgresql+psycopg2://{db_conf['USER']}:{password_encodeed}@{db_conf['HOST']}/{db_conf['NAME']}", include_tables=["enm_mpoint", "enm_mpointstat"]) -# model_url = "http://14.22.88.72:11025/v1/chat/completions" -model_url = "http://139.159.180.64:11434/v1/chat/completions" - -class CustomLLM(LLM): - model_url: str - mode: str = 'chat' - def _call(self, prompt: str, stop: list = None) -> str: - data = { - "model":"glm4", - "messages": self.build_message(prompt), - "stream": False, - } - response = requests.post(self.model_url, json=data, timeout=600) - response.raise_for_status() - content = response.json()["choices"][0]["message"]["content"] - print('content---', content) - clean_sql = self.strip_sql_markdown(content) if self.mode == 'sql' else content.strip() - return clean_sql - - def _generate(self, prompts: list, stop: list = None) -> LLMResult: - generations = [] - for prompt in prompts: - text = self._call(prompt, stop) - generations.append([Generation(text=text)]) - return LLMResult(generations=generations) - - def strip_sql_markdown(self, content: str) -> str: - import re - # 去掉包裹在 ```sql 或 ``` 中的内容 - match = re.search(r"```sql\s*(.*?)```", content, re.DOTALL | re.IGNORECASE) - if match: - return match.group(1).strip() - else: - return content.strip() - - def build_message(self, prompt: str) -> list: - if self.mode == 'sql': - system_prompt = ( - "你是一个 SQL 助手,严格遵循以下规则:\n" - "1. 只返回 PostgreSQL 语法 SQL 语句。\n" - "2. 严格禁止添加任何解释、注释、Markdown 代码块标记(包括 ```sql 和 ```)。\n" - "3. 输出必须是纯 SQL,且可直接执行,无需任何额外处理。\n" - "4. 在 SQL 中如有多个表,请始终使用表名前缀引用字段,避免字段歧义。" - ) - else: - system_prompt = "你是一个聊天助手,请根据用户的问题,提供简洁明了的答案。" - return [ - {"role": "system", "content": system_prompt}, - {"role": "user", "content": prompt}, - ] - - @property - def _llm_type(self) -> str: - return "custom_llm" - - -class QueryLLMview(APIView): - def post(self, request): - serializer = CustomLLMrequestSerializer(data=request.data) - serializer.is_valid(raise_exception=True) - prompt = serializer.validated_data['prompt'] - mode = serializer.validated_data.get('mode', 'chat') - llm = CustomLLM(model_url=model_url, mode=mode) - print('prompt---', prompt, mode) - if mode == 'sql': - chain = SQLDatabaseChain.from_llm(llm, db, verbose=True) - result = chain.invoke(prompt) - else: - result = llm._call(prompt) - return Response({"result": result}) \ No newline at end of file diff --git a/apps/ichat/views.py b/apps/ichat/views.py deleted file mode 100644 index 922d0fee..00000000 --- a/apps/ichat/views.py +++ /dev/null @@ -1,155 +0,0 @@ -import requests -import json -from rest_framework.views import APIView -from apps.ichat.serializers import MessageSerializer, ConversationSerializer -from rest_framework.response import Response -from apps.ichat.models import Conversation, Message -from apps.ichat.utils import connect_db, extract_sql_code, execute_sql, get_schema_text, is_safe_sql, save_message_thread_safe -from django.http import StreamingHttpResponse, JsonResponse -from rest_framework.decorators import action -from apps.utils.viewsets import CustomGenericViewSet, CustomModelViewSet - -# API_KEY = "sk-5644e2d6077b46b9a04a8a2b12d6b693" -# API_BASE = "https://dashscope.aliyuncs.com/compatible-mode/v1" -# MODEL = "qwen-plus" - -# #本地部署的模式 -API_KEY = "JJVAide0hw3eaugGmxecyYYFw45FX2LfhnYJtC+W2rw" -API_BASE = "http://106.0.4.200:9000/v1" -MODEL = "qwen14b" - -# google gemini -# API_KEY = "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" -# API_BASE = "https://openrouter.ai/api/v1" -# MODEL="google/gemini-2.0-flash-exp:free" - -# deepseek v3 -# API_KEY = "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" -# API_BASE = "https://openrouter.ai/api/v1" -# MODEL="deepseek/deepseek-chat-v3-0324:free" - -TABLES = ["enm_mpoint", "enm_mpointstat", "enm_mplogx"] # 如果整个数据库全都给模型,准确率下降,所以只给模型部分表 - - -class QueryLLMviewSet(CustomModelViewSet): - queryset = Message.objects.all() - serializer_class = MessageSerializer - ordering = ['create_time'] - perms_map = {'get':'*', 'post':'*', 'put':'*'} - - @action(methods=['post'], detail=False, perms_map={'post':'*'} ,serializer_class=MessageSerializer) - def completion(self, request): - serializer = self.get_serializer(data=request.data) - serializer.is_valid(raise_exception=True) - serializer.save() - prompt = serializer.validated_data['content'] - conversation = serializer.validated_data['conversation'] - if not prompt or not conversation: - return JsonResponse({"error": "缺少 prompt 或 conversation"}, status=400) - save_message_thread_safe(content=prompt, conversation=conversation, role="user") - url = f"{API_BASE}/chat/completions" - headers = { - "Content-Type": "application/json", - "Authorization": f"Bearer {API_KEY}" - } - - user_prompt = f""" -我提问的问题是:{prompt}请判断我的问题是否与数据库查询或操作相关。如果是,回答"database";如果不是,回答"general"。 - -注意: -只需回答"database"或"general"即可,不要有其他内容。 -""" - _payload = { - "model": MODEL, - "messages": [{"role": "user", "content": user_prompt}, {"role":"system" , "content": "只返回一个结果'database'或'general'"}], - "temperature": 0, - "max_tokens": 10 - } - try: - class_response = requests.post(url, headers=headers, json=_payload) - class_response.raise_for_status() - class_result = class_response.json() - question_type = class_result.get('choices', [{}])[0].get('message', {}).get('content', '').strip().lower() - print("question_type", question_type) - if question_type == "database": - conn = connect_db() - schema_text = get_schema_text(conn, TABLES) - print("schema_text----------------------", schema_text) - user_prompt = f"""你是一个专业的数据库工程师,根据以下数据库结构: -{schema_text} -请根据我的需求生成一条标准的PostgreSQL SQL语句,直接返回SQL,不要额外解释。 -需求是:{prompt} -""" - else: - user_prompt = f""" -回答以下问题,不需要涉及数据库查询: - -问题: {prompt} - -请直接回答问题,不要提及数据库或SQL。 -""" - # TODO 是否应该拿到conservastion的id,然后根据id去数据库查询所以的messages, 然后赋值给messages - history = Message.objects.filter(conversation=conversation).order_by('create_time') - # chat_history = [{"role": msg.role, "content": msg.content} for msg in history] - # chat_history.append({"role": "user", "content": prompt}) - chat_history = [{"role":"user", "content":prompt}] - print("chat_history", chat_history) - payload = { - "model": MODEL, - "messages": chat_history, - "temperature": 0, - "stream": True - } - response = requests.post(url, headers=headers, json=payload) - response.raise_for_status() - except requests.exceptions.RequestException as e: - return JsonResponse({"error":f"LLM API调用失败: {e}"}, status=500) - def stream_generator(): - accumulated_content = "" - for line in response.iter_lines(): - if line: - decoded_line = line.decode('utf-8') - if decoded_line.startswith('data:'): - if decoded_line.strip() == "data: [DONE]": - break # OpenAI-style标志结束 - try: - data = json.loads(decoded_line[6:]) - content = data.get('choices', [{}])[0].get('delta', {}).get('content', '') - if content: - accumulated_content += content - yield f"data: {content}\n\n" - - except Exception as e: - yield f"data: [解析失败]: {str(e)}\n\n" - print("accumulated_content", accumulated_content) - save_message_thread_safe(content=accumulated_content, conversation=conversation, role="system") - - if question_type == "database": - sql = extract_sql_code(accumulated_content) - if sql: - try: - conn = connect_db() - if is_safe_sql(sql): - result = execute_sql(conn, sql) - save_message_thread_safe(content=f"SQL结果: {result}", conversation=conversation, role="system") - yield f"data: SQL执行结果: {result}\n\n" - else: - yield f"data: 拒绝执行非查询类 SQL:{sql}\n\n" - except Exception as e: - yield f"data: SQL执行失败: {str(e)}\n\n" - finally: - if conn: - conn.close() - else: - yield "data: \\n[文本结束]\n\n" - return StreamingHttpResponse(stream_generator(), content_type='text/event-stream') - - -# 先新建对话 生成对话session_id -class ConversationViewSet(CustomModelViewSet): - queryset = Conversation.objects.all() - serializer_class = ConversationSerializer - ordering = ['create_time'] - perms_map = {'get':'*', 'post':'*', 'put':'*'} - - diff --git a/apps/ichat/views2.py b/apps/ichat/views2.py deleted file mode 100644 index 9b2766bb..00000000 --- a/apps/ichat/views2.py +++ /dev/null @@ -1,129 +0,0 @@ -import requests -import os -from apps.utils.sql import execute_raw_sql -import json -from apps.utils.tools import MyJSONEncoder -from .utils import is_safe_sql -from rest_framework.views import APIView -from drf_yasg.utils import swagger_auto_schema -from rest_framework import serializers -from rest_framework.exceptions import ParseError -from rest_framework.response import Response -from django.conf import settings -from apps.utils.mixins import MyLoggingMixin -from django.core.cache import cache -import uuid -from apps.utils.thread import MyThread - -LLM_URL = getattr(settings, "LLM_URL", "") -API_KEY = getattr(settings, "LLM_API_KEY", "") -MODEL = "qwen14b" -HEADERS = { - "Authorization": f"Bearer {API_KEY}", - "Content-Type": "application/json" -} -CUR_DIR = os.path.dirname(os.path.abspath(__file__)) - -def load_promot(name): - with open(os.path.join(CUR_DIR, f'promot/{name}.md'), 'r') as f: - return f.read() - - -def ask(input:str, p_name:str, stream=False): - his = [{"role":"system", "content": load_promot(p_name)}] - his.append({"role":"user", "content": input}) - payload = { - "model": MODEL, - "messages": his, - "temperature": 0, - "stream": stream - } - response = requests.post(LLM_URL, headers=HEADERS, json=payload, stream=stream) - if not stream: - return response.json()["choices"][0]["message"]["content"] - else: - # 处理流式响应 - full_content = "" - for chunk in response.iter_lines(): - if chunk: - # 通常流式响应是SSE格式(data: {...}) - decoded_chunk = chunk.decode('utf-8') - if decoded_chunk.startswith("data:"): - json_str = decoded_chunk[5:].strip() - if json_str == "[DONE]": - break - try: - chunk_data = json.loads(json_str) - if "choices" in chunk_data and chunk_data["choices"]: - delta = chunk_data["choices"][0].get("delta", {}) - if "content" in delta: - print(delta["content"]) - full_content += delta["content"] - except json.JSONDecodeError: - continue - return full_content - -def work_chain(input:str, t_key:str): - pdict = {"state": "progress", "steps": [{"state":"ok", "msg":"正在生成查询语句"}]} - cache.set(t_key, pdict) - res_text = ask(input, 'w_sql') - if res_text == '请以 查询 开头,重新描述你的需求': - pdict["state"] = "error" - pdict["steps"].append({"state":"error", "msg":res_text}) - cache.set(t_key, pdict) - return - else: - pdict["steps"].append({"state":"ok", "msg":"查询语句生成成功", "content":res_text}) - cache.set(t_key, pdict) - if not is_safe_sql(res_text): - pdict["state"] = "error" - pdict["steps"].append({"state":"error", "msg":"当前查询存在风险,请重新描述你的需求"}) - cache.set(t_key, pdict) - return - pdict["steps"].append({"state":"ok", "msg":"正在执行查询语句"}) - cache.set(t_key, pdict) - res = execute_raw_sql(res_text) - pdict["steps"].append({"state":"ok", "msg":"查询语句执行成功", "content":res}) - cache.set(t_key, pdict) - pdict["steps"].append({"state":"ok", "msg":"正在生成报告"}) - cache.set(t_key, pdict) - res2 = ask(json.dumps(res, cls=MyJSONEncoder, ensure_ascii=False), 'w_ana') - content = res2.lstrip('```html ').rstrip('```') - pdict["state"] = "done" - pdict["content"] = content - pdict["steps"].append({"state":"ok", "msg":"报告生成成功", "content": content}) - cache.set(t_key, pdict) - return - -class InputSerializer(serializers.Serializer): - input = serializers.CharField(label="查询需求") - -class WorkChain(MyLoggingMixin, APIView): - - @swagger_auto_schema( - operation_summary="提交查询需求", - request_body=InputSerializer) - def post(self, request): - llm_enabled = getattr(settings, "LLM_ENABLED", False) - if not llm_enabled: - raise ParseError('LLM功能未启用') - input = request.data.get('input') - t_key = f'ichat_{uuid.uuid4()}' - MyThread(target=work_chain, args=(input, t_key)).start() - return Response({'ichat_tid': t_key}) - - @swagger_auto_schema( - operation_summary="获取查询进度") - def get(self, request): - llm_enabled = getattr(settings, "LLM_ENABLED", False) - if not llm_enabled: - raise ParseError('LLM功能未启用') - ichat_tid = request.GET.get('ichat_tid') - if ichat_tid: - return Response(cache.get(ichat_tid)) - -if __name__ == "__main__": - print(work_chain("查询 一次超洗 工段在2025年6月的生产合格数等并形成报告")) - - from apps.ichat.views2 import work_chain - print(work_chain('查询外观检验工段在2025年6月的生产合格数等并形成报告')) \ No newline at end of file diff --git a/server/urls.py b/server/urls.py index da981a42..ac46779f 100755 --- a/server/urls.py +++ b/server/urls.py @@ -45,7 +45,6 @@ urlpatterns = [ # api path('', include('apps.auth1.urls')), - path('', include('apps.ichat.urls')), path('', include('apps.system.urls')), path('', include('apps.monitor.urls')), path('', include('apps.wf.urls')),