diff --git a/apps/enm/migrations/0024_auto_20240326_1617.py b/apps/enm/migrations/0024_auto_20240326_1617.py index 22a53ceb..9b799ae7 100644 --- a/apps/enm/migrations/0024_auto_20240326_1617.py +++ b/apps/enm/migrations/0024_auto_20240326_1617.py @@ -13,6 +13,7 @@ class Migration(migrations.Migration): sql=[ ( """ +CREATE EXTENSION IF NOT EXISTS timescaledb; CREATE TABLE public.enm_mplogx ( "timex" timestamptz NOT NULL, "mpoint_id" text NOT NULL, diff --git a/apps/enp/migrations/0002_auto_20240119_1053.py b/apps/enp/migrations/0002_auto_20240119_1053.py index 1c9039d1..62b01bbb 100644 --- a/apps/enp/migrations/0002_auto_20240119_1053.py +++ b/apps/enp/migrations/0002_auto_20240119_1053.py @@ -14,6 +14,7 @@ class Migration(migrations.Migration): sql=[ ( """ +CREATE EXTENSION IF NOT EXISTS timescaledb; CREATE TABLE public.enp_envdata ( "timex" timestamptz NOT NULL, "equipment_id" text NOT NULL, diff --git a/apps/pum/migrations/0011_pucontract_alter_puorder_belong_dept_and_more.py b/apps/pum/migrations/0011_pucontract_alter_puorder_belong_dept_and_more.py new file mode 100644 index 00000000..ba607716 --- /dev/null +++ b/apps/pum/migrations/0011_pucontract_alter_puorder_belong_dept_and_more.py @@ -0,0 +1,164 @@ +# Generated by Django 4.2.27 on 2026-04-20 06:02 + +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'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('pum', '0010_quotationapply'), + ] + + operations = [ + migrations.CreateModel( + name='PuContract', + 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='合同名称')), + ('number', models.CharField(max_length=100, unique=True, verbose_name='合同编号')), + ('contract_amount', models.DecimalField(decimal_places=2, default=0, max_digits=14, verbose_name='合同金额')), + ('sign_date', models.DateField(verbose_name='签订日期')), + ('effective_date', models.DateField(blank=True, null=True, verbose_name='生效日期')), + ('end_date', models.DateField(blank=True, null=True, verbose_name='截止日期')), + ('status', models.PositiveSmallIntegerField(choices=[(10, '草稿'), (20, '执行中'), (30, '已完成'), (40, '已终止')], default=10, help_text="((10, '草稿'), (20, '执行中'), (30, '已完成'), (40, '已终止'))", verbose_name='合同状态')), + ('settlement_status', models.PositiveSmallIntegerField(choices=[(10, '未付款'), (20, '部分付款'), (30, '全部付款')], default=10, help_text="((10, '未付款'), (20, '部分付款'), (30, '全部付款'))", verbose_name='结算状态')), + ('paid_amount', models.DecimalField(decimal_places=2, default=0, max_digits=14, verbose_name='累计已付款')), + ('unpaid_amount', models.DecimalField(decimal_places=2, default=0, max_digits=14, verbose_name='累计未付款')), + ('pay_progress', models.DecimalField(decimal_places=2, default=0, max_digits=5, verbose_name='付款进度')), + ('description', models.CharField(blank=True, max_length=200, null=True, verbose_name='描述')), + ('belong_dept', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_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='%(class)s_create_by', to=settings.AUTH_USER_MODEL, verbose_name='创建人')), + ], + options={ + 'verbose_name': '采购合同', + 'verbose_name_plural': '采购合同', + }, + ), + migrations.AlterField( + model_name='puorder', + name='belong_dept', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_belong_dept', to='system.dept', verbose_name='所属部门'), + ), + migrations.AlterField( + model_name='puorder', + name='create_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_create_by', to=settings.AUTH_USER_MODEL, verbose_name='创建人'), + ), + migrations.AlterField( + model_name='puorder', + name='update_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_update_by', to=settings.AUTH_USER_MODEL, verbose_name='最后编辑人'), + ), + migrations.AlterField( + model_name='puplan', + name='belong_dept', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_belong_dept', to='system.dept', verbose_name='所属部门'), + ), + migrations.AlterField( + model_name='puplan', + name='create_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_create_by', to=settings.AUTH_USER_MODEL, verbose_name='创建人'), + ), + migrations.AlterField( + model_name='puplan', + name='update_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_update_by', to=settings.AUTH_USER_MODEL, verbose_name='最后编辑人'), + ), + migrations.AlterField( + model_name='puplanitem', + name='belong_dept', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_belong_dept', to='system.dept', verbose_name='所属部门'), + ), + migrations.AlterField( + model_name='puplanitem', + name='create_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_create_by', to=settings.AUTH_USER_MODEL, verbose_name='创建人'), + ), + migrations.AlterField( + model_name='puplanitem', + name='update_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_update_by', to=settings.AUTH_USER_MODEL, verbose_name='最后编辑人'), + ), + migrations.AlterField( + model_name='quotationapply', + name='create_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_create_by', to=settings.AUTH_USER_MODEL, verbose_name='创建人'), + ), + migrations.AlterField( + model_name='quotationapply', + name='update_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_update_by', to=settings.AUTH_USER_MODEL, verbose_name='最后编辑人'), + ), + migrations.AlterField( + model_name='supplier', + name='belong_dept', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_belong_dept', to='system.dept', verbose_name='所属部门'), + ), + migrations.AlterField( + model_name='supplier', + name='create_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_create_by', to=settings.AUTH_USER_MODEL, verbose_name='创建人'), + ), + migrations.AlterField( + model_name='supplier', + name='update_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_update_by', to=settings.AUTH_USER_MODEL, verbose_name='最后编辑人'), + ), + migrations.AlterField( + model_name='supplieraudit', + name='create_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_create_by', to=settings.AUTH_USER_MODEL, verbose_name='创建人'), + ), + migrations.AlterField( + model_name='supplieraudit', + name='update_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_update_by', to=settings.AUTH_USER_MODEL, verbose_name='最后编辑人'), + ), + migrations.CreateModel( + name='PuContractRecord', + 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='删除标记')), + ('record_date', models.DateField(verbose_name='付款日期')), + ('amount', models.DecimalField(decimal_places=2, max_digits=14, verbose_name='付款金额')), + ('stage_type', models.PositiveSmallIntegerField(choices=[(10, '首款'), (20, '中间款'), (30, '尾款'), (40, '其他')], default=40, help_text="((10, '首款'), (20, '中间款'), (30, '尾款'), (40, '其他'))", verbose_name='阶段类型')), + ('pay_method', models.PositiveSmallIntegerField(choices=[(10, '银行转账'), (20, '现金'), (30, '承兑'), (40, '微信'), (50, '支付宝'), (60, '其他')], default=10, help_text="((10, '银行转账'), (20, '现金'), (30, '承兑'), (40, '微信'), (50, '支付宝'), (60, '其他'))", verbose_name='付款方式')), + ('voucher_no', models.CharField(blank=True, max_length=100, null=True, verbose_name='凭证号')), + ('remark', models.CharField(blank=True, max_length=200, null=True, verbose_name='备注')), + ('belong_dept', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_belong_dept', to='system.dept', verbose_name='所属部门')), + ('contract', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='records', to='pum.pucontract', verbose_name='采购合同')), + ('create_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_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='%(class)s_update_by', to=settings.AUTH_USER_MODEL, verbose_name='最后编辑人')), + ], + options={ + 'verbose_name': '采购合同付款流水', + 'verbose_name_plural': '采购合同付款流水', + 'ordering': ['-record_date', '-create_time'], + }, + ), + migrations.AddField( + model_name='pucontract', + name='supplier', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='contracts', to='pum.supplier', verbose_name='供应商'), + ), + migrations.AddField( + model_name='pucontract', + name='update_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_update_by', to=settings.AUTH_USER_MODEL, verbose_name='最后编辑人'), + ), + migrations.AddField( + model_name='puorder', + name='contract', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='orders', to='pum.pucontract', verbose_name='采购合同'), + ), + ] diff --git a/apps/pum/models.py b/apps/pum/models.py index d13754e1..0d90d914 100644 --- a/apps/pum/models.py +++ b/apps/pum/models.py @@ -1,4 +1,7 @@ +from decimal import Decimal + from django.db import models +from django.db.models import Sum from apps.utils.models import CommonBModel, BaseModel, CommonBDModel, CommonADModel from apps.mtm.models import Material from apps.wf.models import Ticket @@ -71,6 +74,9 @@ class PuOrder(CommonBModel): number = models.CharField('订单编号', max_length=20, null=True, blank=True) supplier = models.ForeignKey( Supplier, verbose_name='供应商', on_delete=models.CASCADE) + contract = models.ForeignKey( + 'pum.PuContract', verbose_name='采购合同', on_delete=models.SET_NULL, + null=True, blank=True, related_name='orders') delivery_date = models.DateField('截止到货日期', null=True, blank=True) submit_time = models.DateTimeField('提交时间', null=True, blank=True) submit_user = models.ForeignKey( @@ -125,4 +131,152 @@ class QuotationApply(CommonADModel): quoter = models.CharField(max_length=50,verbose_name="报价人", null=True, blank=True) apply_date = models.DateField(verbose_name="申请日期",auto_now_add=True) ticket = models.OneToOneField('wf.ticket', verbose_name='关联工单', - on_delete=models.CASCADE, related_name='quo_ticket', null=True, blank=True) \ No newline at end of file + on_delete=models.CASCADE, related_name='quo_ticket', null=True, blank=True) + + +class PuContract(CommonBDModel): + """ + TN:采购合同 + """ + STATUS_DRAFT = 10 + STATUS_ACTIVE = 20 + STATUS_DONE = 30 + STATUS_TERMINATED = 40 + STATUS_CHOICES = ( + (STATUS_DRAFT, '草稿'), + (STATUS_ACTIVE, '执行中'), + (STATUS_DONE, '已完成'), + (STATUS_TERMINATED, '已终止'), + ) + SETTLEMENT_UNPAID = 10 + SETTLEMENT_PARTIAL = 20 + SETTLEMENT_FULL = 30 + SETTLEMENT_CHOICES = ( + (SETTLEMENT_UNPAID, '未付款'), + (SETTLEMENT_PARTIAL, '部分付款'), + (SETTLEMENT_FULL, '全部付款'), + ) + name = models.CharField('合同名称', max_length=100) + number = models.CharField('合同编号', max_length=100, unique=True) + supplier = models.ForeignKey(Supplier, verbose_name='供应商', on_delete=models.CASCADE, related_name='contracts') + contract_amount = models.DecimalField('合同金额', max_digits=14, decimal_places=2, default=0) + sign_date = models.DateField('签订日期') + effective_date = models.DateField('生效日期', null=True, blank=True) + end_date = models.DateField('截止日期', null=True, blank=True) + status = models.PositiveSmallIntegerField( + '合同状态', choices=STATUS_CHOICES, default=STATUS_DRAFT, help_text=str(STATUS_CHOICES)) + settlement_status = models.PositiveSmallIntegerField( + '结算状态', choices=SETTLEMENT_CHOICES, default=SETTLEMENT_UNPAID, help_text=str(SETTLEMENT_CHOICES)) + paid_amount = models.DecimalField('累计已付款', max_digits=14, decimal_places=2, default=0) + unpaid_amount = models.DecimalField('累计未付款', max_digits=14, decimal_places=2, default=0) + pay_progress = models.DecimalField('付款进度', max_digits=5, decimal_places=2, default=0) + description = models.CharField('描述', max_length=200, blank=True, null=True) + + class Meta: + verbose_name = '采购合同' + verbose_name_plural = verbose_name + + def __str__(self): + return self.name + + def save(self, *args, **kwargs): + refresh_settlement = kwargs.pop('refresh_settlement', True) + super().save(*args, **kwargs) + if refresh_settlement: + self.refresh_settlement() + + def refresh_settlement(self): + paid_amount = PuContractRecord.objects.filter(contract=self).aggregate( + total=Sum('amount') + )['total'] or Decimal('0.00') + contract_amount = Decimal(str(self.contract_amount or 0)).quantize(Decimal('0.01')) + unpaid_amount = contract_amount - paid_amount + if unpaid_amount < Decimal('0.00'): + unpaid_amount = Decimal('0.00') + if contract_amount <= Decimal('0.00'): + pay_progress = Decimal('0.00') + else: + pay_progress = (paid_amount * Decimal('100.00') / contract_amount).quantize(Decimal('0.01')) + if pay_progress > Decimal('100.00'): + pay_progress = Decimal('100.00') + if paid_amount <= Decimal('0.00'): + settlement_status = self.SETTLEMENT_UNPAID + elif paid_amount >= contract_amount and contract_amount > Decimal('0.00'): + settlement_status = self.SETTLEMENT_FULL + else: + settlement_status = self.SETTLEMENT_PARTIAL + status = self.status + if status != self.STATUS_TERMINATED: + if paid_amount <= Decimal('0.00'): + status = self.STATUS_DRAFT + elif paid_amount >= contract_amount and contract_amount > Decimal('0.00'): + status = self.STATUS_DONE + else: + status = self.STATUS_ACTIVE + type(self).objects.filter(pk=self.pk).update( + paid_amount=paid_amount, + unpaid_amount=unpaid_amount, + pay_progress=pay_progress, + settlement_status=settlement_status, + status=status, + ) + self.paid_amount = paid_amount + self.unpaid_amount = unpaid_amount + self.pay_progress = pay_progress + self.settlement_status = settlement_status + self.status = status + + +class PuContractRecord(CommonBDModel): + """ + TN:采购合同付款流水 + """ + STAGE_FIRST = 10 + STAGE_MIDDLE = 20 + STAGE_FINAL = 30 + STAGE_OTHER = 40 + STAGE_CHOICES = ( + (STAGE_FIRST, '首款'), + (STAGE_MIDDLE, '中间款'), + (STAGE_FINAL, '尾款'), + (STAGE_OTHER, '其他'), + ) + PAY_BANK = 10 + PAY_CASH = 20 + PAY_ACCEPTANCE = 30 + PAY_WECHAT = 40 + PAY_ALIPAY = 50 + PAY_OTHER = 60 + PAY_METHOD_CHOICES = ( + (PAY_BANK, '银行转账'), + (PAY_CASH, '现金'), + (PAY_ACCEPTANCE, '承兑'), + (PAY_WECHAT, '微信'), + (PAY_ALIPAY, '支付宝'), + (PAY_OTHER, '其他'), + ) + contract = models.ForeignKey( + PuContract, verbose_name='采购合同', on_delete=models.CASCADE, related_name='records') + record_date = models.DateField('付款日期') + amount = models.DecimalField('付款金额', max_digits=14, decimal_places=2) + stage_type = models.PositiveSmallIntegerField( + '阶段类型', choices=STAGE_CHOICES, default=STAGE_OTHER, help_text=str(STAGE_CHOICES)) + pay_method = models.PositiveSmallIntegerField( + '付款方式', choices=PAY_METHOD_CHOICES, default=PAY_BANK, help_text=str(PAY_METHOD_CHOICES)) + voucher_no = models.CharField('凭证号', max_length=100, null=True, blank=True) + remark = models.CharField('备注', max_length=200, null=True, blank=True) + + class Meta: + verbose_name = '采购合同付款流水' + verbose_name_plural = verbose_name + ordering = ['-record_date', '-create_time'] + + def save(self, *args, **kwargs): + super().save(*args, **kwargs) + self.contract.refresh_settlement() + + def delete(self, using=None, *args, **kwargs): + contract = self.contract + result = super().delete(using=using, *args, **kwargs) + contract.refresh_settlement() + return result diff --git a/apps/pum/serializers.py b/apps/pum/serializers.py index 23b5cda5..b67ab111 100644 --- a/apps/pum/serializers.py +++ b/apps/pum/serializers.py @@ -1,9 +1,11 @@ +from decimal import Decimal + from rest_framework import serializers from apps.utils.serializers import CustomModelSerializer from apps.utils.constants import EXCLUDE_FIELDS_DEPT, EXCLUDE_FIELDS_BASE, EXCLUDE_FIELDS from rest_framework.exceptions import ValidationError, ParseError -from apps.pum.models import Supplier, PuPlan, PuPlanItem, PuOrder, PuOrderItem, SupplierAudit, QuotationApply +from apps.pum.models import Supplier, PuPlan, PuPlanItem, PuOrder, PuOrderItem, SupplierAudit, QuotationApply, PuContract, PuContractRecord from apps.mtm.serializers import MaterialSerializer, MaterialSimpleSerializer from django.db import transaction from .services import PumService @@ -99,6 +101,14 @@ class PuOrderSerializer(CustomModelSerializer): fields = '__all__' read_only_fields = EXCLUDE_FIELDS_DEPT + ['state', 'submit_time', 'total_price'] + def validate(self, attrs): + contract = attrs.get('contract', None) + if contract: + attrs['supplier'] = contract.supplier + if attrs.get('supplier', None) is None: + raise ValidationError('未选择供应商') + return attrs + def update(self, instance, validated_data): validated_data.pop('supplier') if instance.state != PuOrder.PUORDER_CREATE: @@ -164,4 +174,40 @@ class QuotationApplySerializer(CustomModelSerializer): class Meta: model = QuotationApply fields = "__all__" - read_only_fields = EXCLUDE_FIELDS \ No newline at end of file + read_only_fields = EXCLUDE_FIELDS + + +class PuContractSerializer(CustomModelSerializer): + supplier_name = serializers.CharField(source='supplier.name', read_only=True) + create_by_name = serializers.CharField(source='create_by.name', read_only=True) + update_by_name = serializers.CharField(source='update_by.name', read_only=True) + + class Meta: + model = PuContract + fields = '__all__' + read_only_fields = EXCLUDE_FIELDS + ['belong_dept', 'paid_amount', 'unpaid_amount', 'pay_progress', 'settlement_status'] + + +class PuContractRecordSerializer(CustomModelSerializer): + contract_number = serializers.CharField(source='contract.number', read_only=True) + supplier_name = serializers.CharField(source='contract.supplier.name', read_only=True) + + class Meta: + model = PuContractRecord + fields = '__all__' + read_only_fields = EXCLUDE_FIELDS + ['belong_dept'] + + def validate(self, attrs): + contract = attrs.get('contract', getattr(self.instance, 'contract', None)) + amount = attrs.get('amount', getattr(self.instance, 'amount', None)) + if contract is None or amount is None: + return attrs + if contract.status == PuContract.STATUS_TERMINATED: + raise ValidationError('合同已终止,不可操作付款流水') + qs = PuContractRecord.objects.filter(contract=contract) + if self.instance is not None: + qs = qs.exclude(id=self.instance.id) + total = sum((item.amount for item in qs), Decimal('0.00')) + amount + if total > contract.contract_amount: + raise ValidationError('累计付款金额不可超过合同金额') + return attrs diff --git a/apps/pum/tests.py b/apps/pum/tests.py index 7ce503c2..1bae6997 100644 --- a/apps/pum/tests.py +++ b/apps/pum/tests.py @@ -1,3 +1,178 @@ +from decimal import Decimal + from django.test import TestCase -# Create your tests here. +from apps.pum.models import Supplier +from apps.pum.serializers import PuOrderSerializer +from apps.pum.serializers import PuContractRecordSerializer +from rest_framework.exceptions import ParseError + + +class PuContractSettlementTests(TestCase): + def test_purchase_contract_record_updates_paid_summary(self): + supplier = Supplier.objects.create(name='供应商A') + + from apps.pum.models import PuContract, PuContractRecord + + contract = PuContract.objects.create( + name='采购合同A', + number='PC-001', + contract_amount=Decimal('2000.00'), + supplier=supplier, + sign_date='2026-04-20', + ) + + PuContractRecord.objects.create( + contract=contract, + record_date='2026-04-21', + amount=Decimal('800.00'), + stage_type=PuContractRecord.STAGE_FIRST, + ) + + contract.refresh_from_db() + self.assertEqual(contract.paid_amount, Decimal('800.00')) + self.assertEqual(contract.unpaid_amount, Decimal('1200.00')) + self.assertEqual(contract.pay_progress, Decimal('40.00')) + self.assertEqual(contract.status, contract.STATUS_ACTIVE) + + def test_purchase_contract_record_delete_refreshes_summary_and_is_physical(self): + supplier = Supplier.objects.create(name='供应商C') + + from apps.pum.models import PuContract, PuContractRecord + + contract = PuContract.objects.create( + name='采购合同C', + number='PC-003', + contract_amount=Decimal('2000.00'), + supplier=supplier, + sign_date='2026-04-20', + ) + + record = PuContractRecord.objects.create( + contract=contract, + record_date='2026-04-21', + amount=Decimal('800.00'), + stage_type=PuContractRecord.STAGE_FIRST, + ) + + record.delete() + contract.refresh_from_db() + + self.assertEqual(contract.paid_amount, Decimal('0.00')) + self.assertEqual(contract.unpaid_amount, Decimal('2000.00')) + self.assertEqual(contract.pay_progress, Decimal('0.00')) + self.assertEqual(contract.status, contract.STATUS_DRAFT) + self.assertFalse(PuContractRecord._base_manager.filter(pk=record.pk).exists()) + + def test_purchase_contract_delete_is_physical(self): + supplier = Supplier.objects.create(name='供应商D') + + from apps.pum.models import PuContract + + contract = PuContract.objects.create( + name='采购合同D', + number='PC-004', + contract_amount=Decimal('500.00'), + supplier=supplier, + sign_date='2026-04-20', + ) + + contract.delete() + + self.assertFalse(PuContract._base_manager.filter(pk=contract.pk).exists()) + + def test_purchase_contract_status_auto_transitions_by_records(self): + supplier = Supplier.objects.create(name='供应商E') + + from apps.pum.models import PuContract, PuContractRecord + + contract = PuContract.objects.create( + name='采购合同E', + number='PC-005', + contract_amount=Decimal('2000.00'), + supplier=supplier, + sign_date='2026-04-20', + ) + + self.assertEqual(contract.status, PuContract.STATUS_DRAFT) + + first_record = PuContractRecord.objects.create( + contract=contract, + record_date='2026-04-21', + amount=Decimal('800.00'), + stage_type=PuContractRecord.STAGE_FIRST, + ) + contract.refresh_from_db() + self.assertEqual(contract.status, PuContract.STATUS_ACTIVE) + + PuContractRecord.objects.create( + contract=contract, + record_date='2026-04-22', + amount=Decimal('1200.00'), + stage_type=PuContractRecord.STAGE_FINAL, + ) + contract.refresh_from_db() + self.assertEqual(contract.status, PuContract.STATUS_DONE) + + first_record.delete() + contract.refresh_from_db() + self.assertEqual(contract.status, PuContract.STATUS_ACTIVE) + + def test_purchase_terminated_contract_forbids_record_changes(self): + supplier = Supplier.objects.create(name='供应商F') + + from apps.pum.models import PuContract, PuContractRecord + + contract = PuContract.objects.create( + name='采购合同F', + number='PC-006', + contract_amount=Decimal('1000.00'), + supplier=supplier, + sign_date='2026-04-20', + status=PuContract.STATUS_TERMINATED, + ) + + serializer = PuContractRecordSerializer(data={ + 'contract': contract.id, + 'record_date': '2026-04-21', + 'amount': '100.00', + 'stage_type': 10, + 'pay_method': 10, + }) + self.assertFalse(serializer.is_valid()) + self.assertIn('合同已终止,不可操作付款流水', str(serializer.errors)) + + contract.status = PuContract.STATUS_DRAFT + contract.save(refresh_settlement=False) + + record = PuContractRecord.objects.create( + contract=contract, + record_date='2026-04-20', + amount=Decimal('100.00'), + stage_type=PuContractRecord.STAGE_OTHER, + ) + contract.status = PuContract.STATUS_TERMINATED + contract.save(refresh_settlement=False) + + with self.assertRaises(ParseError): + from apps.pum.views import PuContractRecordViewSet + viewset = PuContractRecordViewSet() + viewset.request = None + viewset.perform_destroy(record) + + def test_purchase_order_serializer_accepts_purchase_contract(self): + supplier = Supplier.objects.create(name='供应商B') + + from apps.pum.models import PuContract + + contract = PuContract.objects.create( + name='采购合同B', + number='PC-002', + contract_amount=Decimal('500.00'), + supplier=supplier, + sign_date='2026-04-20', + ) + + serializer = PuOrderSerializer(data={'supplier': supplier.id, 'contract': contract.id}) + + self.assertTrue(serializer.is_valid(), serializer.errors) diff --git a/apps/pum/urls.py b/apps/pum/urls.py index cb316589..35da7f31 100644 --- a/apps/pum/urls.py +++ b/apps/pum/urls.py @@ -1,6 +1,6 @@ from django.urls import path, include from rest_framework.routers import DefaultRouter -from apps.pum.views import (SupplierViewSet, PuPlanViewSet, PuPlanItemViewSet, PuOrderViewSet, PuOrderItemViewSet, SupplierAuditViewSet, QuotationApplyViewSet) +from apps.pum.views import (SupplierViewSet, PuPlanViewSet, PuPlanItemViewSet, PuOrderViewSet, PuOrderItemViewSet, SupplierAuditViewSet, QuotationApplyViewSet, PuContractViewSet, PuContractRecordViewSet) # from apps.pum.views import SupplierViewSet, PuPlanViewSet, PuPlanItemViewSet, PuOrderViewSet, PuOrderItemViewSet API_BASE_URL = 'api/pum/' @@ -11,9 +11,11 @@ router.register('supplier', SupplierViewSet, basename='supplier') router.register('supplieraudit', SupplierAuditViewSet, basename='supplieraudit') router.register('pu_plan', PuPlanViewSet, basename='pu_plan') router.register('pu_planitem', PuPlanItemViewSet, basename='pu_planitem') +router.register('pu_contract', PuContractViewSet, basename='pu_contract') +router.register('pu_contract_record', PuContractRecordViewSet, basename='pu_contract_record') router.register('pu_order', PuOrderViewSet, basename='pu_order') router.register('pu_orderitem', PuOrderItemViewSet, basename='pu_orderitem') router.register('quotation', QuotationApplyViewSet, basename='quotation') urlpatterns = [ path(API_BASE_URL, include(router.urls)), -] \ No newline at end of file +] diff --git a/apps/pum/views.py b/apps/pum/views.py index 9899b8b7..5674d2ad 100644 --- a/apps/pum/views.py +++ b/apps/pum/views.py @@ -1,8 +1,9 @@ from django.shortcuts import render -from apps.pum.models import Supplier, PuPlan, PuPlanItem, PuOrder, PuOrderItem, SupplierAudit, QuotationApply +from apps.pum.models import Supplier, PuPlan, PuPlanItem, PuOrder, PuOrderItem, SupplierAudit, QuotationApply, PuContract, PuContractRecord from apps.utils.viewsets import CustomGenericViewSet, CustomModelViewSet, EuModelViewSet from apps.pum.serializers import (SupplierSerializer, PuPlanSerializer, PuPlanItemSerializer, QuotationApplySerializer, - PuOrderSerializer, PuOrderItemSerializer, AddSerializer, SupplierAuditSerializer) + PuOrderSerializer, PuOrderItemSerializer, AddSerializer, SupplierAuditSerializer, + PuContractSerializer, PuContractRecordSerializer) from rest_framework.exceptions import ParseError, PermissionDenied from rest_framework.decorators import action from rest_framework import serializers @@ -222,4 +223,51 @@ class QuotationApplyViewSet(TicketMixin, CustomModelViewSet): filterset_fields = ['product_name', 'customer_name','apply_date', 'quoter'] search_fields = ['product_name', 'customer_name','contact_person'] ordering = ['create_time'] - workflow_key = "wf_quotation" \ No newline at end of file + workflow_key = "wf_quotation" + + +class PuContractViewSet(CustomModelViewSet): + """ + list: 采购合同 + + 采购合同 + """ + queryset = PuContract.objects.all() + serializer_class = PuContractSerializer + search_fields = ['name', 'number', 'supplier__name'] + select_related_fields = ['supplier', 'create_by', 'update_by'] + filterset_fields = ['supplier', 'status', 'settlement_status'] + + def perform_destroy(self, instance): + if PuOrder.objects.filter(contract=instance).exists(): + raise ParseError('该采购合同存在采购订单不可删除') + instance.delete() + + +class PuContractRecordViewSet(CustomModelViewSet): + """ + list: 采购合同付款流水 + + 采购合同付款流水 + """ + perms_map = { + 'get': '*', + 'post': 'pu_contract.update', + 'put': 'pu_contract.update', + 'patch': 'pu_contract.update', + 'delete': 'pu_contract.update', + } + queryset = PuContractRecord.objects.all() + serializer_class = PuContractRecordSerializer + search_fields = ['contract__number', 'contract__name', 'voucher_no', 'remark'] + select_related_fields = ['contract', 'contract__supplier', 'create_by', 'update_by'] + filterset_fields = { + 'contract': ['exact'], + 'stage_type': ['exact', 'in'], + 'pay_method': ['exact', 'in'], + } + + def perform_destroy(self, instance): + if instance.contract.status == PuContract.STATUS_TERMINATED: + raise ParseError('合同已终止,不可删除付款流水') + instance.delete() diff --git a/apps/sam/migrations/0009_contract_effective_date_contract_end_date_and_more.py b/apps/sam/migrations/0009_contract_effective_date_contract_end_date_and_more.py new file mode 100644 index 00000000..c29acc60 --- /dev/null +++ b/apps/sam/migrations/0009_contract_effective_date_contract_end_date_and_more.py @@ -0,0 +1,122 @@ +# Generated by Django 4.2.27 on 2026-04-20 06:02 + +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'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('sam', '0008_alter_orderitem_order'), + ] + + operations = [ + migrations.AddField( + model_name='contract', + name='effective_date', + field=models.DateField(blank=True, null=True, verbose_name='生效日期'), + ), + migrations.AddField( + model_name='contract', + name='end_date', + field=models.DateField(blank=True, null=True, verbose_name='截止日期'), + ), + migrations.AddField( + model_name='contract', + name='receive_progress', + field=models.DecimalField(decimal_places=2, default=0, max_digits=5, verbose_name='到款进度'), + ), + migrations.AddField( + model_name='contract', + name='received_amount', + field=models.DecimalField(decimal_places=2, default=0, max_digits=14, verbose_name='累计已到款'), + ), + migrations.AddField( + model_name='contract', + name='settlement_status', + field=models.PositiveSmallIntegerField(choices=[(10, '未到款'), (20, '部分到款'), (30, '全部到款')], default=10, help_text="((10, '未到款'), (20, '部分到款'), (30, '全部到款'))", verbose_name='结算状态'), + ), + migrations.AddField( + model_name='contract', + name='status', + field=models.PositiveSmallIntegerField(choices=[(10, '草稿'), (20, '执行中'), (30, '已完成'), (40, '已终止')], default=10, help_text="((10, '草稿'), (20, '执行中'), (30, '已完成'), (40, '已终止'))", verbose_name='合同状态'), + ), + migrations.AddField( + model_name='contract', + name='unreceived_amount', + field=models.DecimalField(decimal_places=2, default=0, max_digits=14, verbose_name='累计未到款'), + ), + migrations.AlterField( + model_name='contract', + name='belong_dept', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_belong_dept', to='system.dept', verbose_name='所属部门'), + ), + migrations.AlterField( + model_name='contract', + name='create_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_create_by', to=settings.AUTH_USER_MODEL, verbose_name='创建人'), + ), + migrations.AlterField( + model_name='contract', + name='update_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_update_by', to=settings.AUTH_USER_MODEL, verbose_name='最后编辑人'), + ), + migrations.AlterField( + model_name='customer', + name='belong_dept', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_belong_dept', to='system.dept', verbose_name='所属部门'), + ), + migrations.AlterField( + model_name='customer', + name='create_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_create_by', to=settings.AUTH_USER_MODEL, verbose_name='创建人'), + ), + migrations.AlterField( + model_name='customer', + name='update_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_update_by', to=settings.AUTH_USER_MODEL, verbose_name='最后编辑人'), + ), + migrations.AlterField( + model_name='order', + name='belong_dept', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_belong_dept', to='system.dept', verbose_name='所属部门'), + ), + migrations.AlterField( + model_name='order', + name='create_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_create_by', to=settings.AUTH_USER_MODEL, verbose_name='创建人'), + ), + migrations.AlterField( + model_name='order', + name='update_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_update_by', to=settings.AUTH_USER_MODEL, verbose_name='最后编辑人'), + ), + migrations.CreateModel( + name='ContractRecord', + 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='删除标记')), + ('record_date', models.DateField(verbose_name='到款日期')), + ('amount', models.DecimalField(decimal_places=2, max_digits=14, verbose_name='到款金额')), + ('stage_type', models.PositiveSmallIntegerField(choices=[(10, '首款'), (20, '中间款'), (30, '尾款'), (40, '其他')], default=40, help_text="((10, '首款'), (20, '中间款'), (30, '尾款'), (40, '其他'))", verbose_name='阶段类型')), + ('pay_method', models.PositiveSmallIntegerField(choices=[(10, '银行转账'), (20, '现金'), (30, '承兑'), (40, '微信'), (50, '支付宝'), (60, '其他')], default=10, help_text="((10, '银行转账'), (20, '现金'), (30, '承兑'), (40, '微信'), (50, '支付宝'), (60, '其他'))", verbose_name='收款方式')), + ('voucher_no', models.CharField(blank=True, max_length=100, null=True, verbose_name='凭证号')), + ('remark', models.CharField(blank=True, max_length=200, null=True, verbose_name='备注')), + ('belong_dept', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_belong_dept', to='system.dept', verbose_name='所属部门')), + ('contract', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='records', to='sam.contract', verbose_name='销售合同')), + ('create_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_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='%(class)s_update_by', to=settings.AUTH_USER_MODEL, verbose_name='最后编辑人')), + ], + options={ + 'verbose_name': '销售合同到款流水', + 'verbose_name_plural': '销售合同到款流水', + 'ordering': ['-record_date', '-create_time'], + }, + ), + ] diff --git a/apps/sam/models.py b/apps/sam/models.py index dc5c9bcb..62451adf 100644 --- a/apps/sam/models.py +++ b/apps/sam/models.py @@ -1,4 +1,7 @@ +from decimal import Decimal + from django.db import models +from django.db.models import Sum from apps.utils.models import CommonBModel, BaseModel, CommonBDModel from apps.mtm.models import Material @@ -24,16 +27,43 @@ class Customer(CommonBModel): return self.name -class Contract(CommonBModel): +class Contract(CommonBDModel): """ TN:合同信息 """ + STATUS_DRAFT = 10 + STATUS_ACTIVE = 20 + STATUS_DONE = 30 + STATUS_TERMINATED = 40 + STATUS_CHOICES = ( + (STATUS_DRAFT, '草稿'), + (STATUS_ACTIVE, '执行中'), + (STATUS_DONE, '已完成'), + (STATUS_TERMINATED, '已终止'), + ) + SETTLEMENT_UNRECEIVED = 10 + SETTLEMENT_PARTIAL = 20 + SETTLEMENT_FULL = 30 + SETTLEMENT_CHOICES = ( + (SETTLEMENT_UNRECEIVED, '未到款'), + (SETTLEMENT_PARTIAL, '部分到款'), + (SETTLEMENT_FULL, '全部到款'), + ) name = models.CharField('合同名称', max_length=100) number = models.CharField('合同编号', max_length=100, unique=True) amount = models.IntegerField('合同金额', default=0) customer = models.ForeignKey(Customer, verbose_name='关联客户', on_delete=models.CASCADE, related_name='contract_customer') sign_date = models.DateField('签订日期') + effective_date = models.DateField('生效日期', null=True, blank=True) + end_date = models.DateField('截止日期', null=True, blank=True) + status = models.PositiveSmallIntegerField( + '合同状态', choices=STATUS_CHOICES, default=STATUS_DRAFT, help_text=str(STATUS_CHOICES)) + settlement_status = models.PositiveSmallIntegerField( + '结算状态', choices=SETTLEMENT_CHOICES, default=SETTLEMENT_UNRECEIVED, help_text=str(SETTLEMENT_CHOICES)) + received_amount = models.DecimalField('累计已到款', max_digits=14, decimal_places=2, default=0) + unreceived_amount = models.DecimalField('累计未到款', max_digits=14, decimal_places=2, default=0) + receive_progress = models.DecimalField('到款进度', max_digits=5, decimal_places=2, default=0) description = models.CharField('描述', max_length=200, blank=True, null=True) class Meta: @@ -43,6 +73,53 @@ class Contract(CommonBModel): def __str__(self): return self.name + def save(self, *args, **kwargs): + refresh_settlement = kwargs.pop('refresh_settlement', True) + super().save(*args, **kwargs) + if refresh_settlement: + self.refresh_settlement() + + def refresh_settlement(self): + received_amount = ContractRecord.objects.filter(contract=self).aggregate( + total=Sum('amount') + )['total'] or Decimal('0.00') + contract_amount = Decimal(str(self.amount or 0)).quantize(Decimal('0.01')) + unreceived_amount = contract_amount - received_amount + if unreceived_amount < Decimal('0.00'): + unreceived_amount = Decimal('0.00') + if contract_amount <= Decimal('0.00'): + receive_progress = Decimal('0.00') + else: + receive_progress = (received_amount * Decimal('100.00') / contract_amount).quantize(Decimal('0.01')) + if receive_progress > Decimal('100.00'): + receive_progress = Decimal('100.00') + if received_amount <= Decimal('0.00'): + settlement_status = self.SETTLEMENT_UNRECEIVED + elif received_amount >= contract_amount and contract_amount > Decimal('0.00'): + settlement_status = self.SETTLEMENT_FULL + else: + settlement_status = self.SETTLEMENT_PARTIAL + status = self.status + if status != self.STATUS_TERMINATED: + if received_amount <= Decimal('0.00'): + status = self.STATUS_DRAFT + elif received_amount >= contract_amount and contract_amount > Decimal('0.00'): + status = self.STATUS_DONE + else: + status = self.STATUS_ACTIVE + type(self).objects.filter(pk=self.pk).update( + received_amount=received_amount, + unreceived_amount=unreceived_amount, + receive_progress=receive_progress, + settlement_status=settlement_status, + status=status, + ) + self.received_amount = received_amount + self.unreceived_amount = unreceived_amount + self.receive_progress = receive_progress + self.settlement_status = settlement_status + self.status = status + class Order(CommonBModel): """ @@ -87,3 +164,58 @@ class OrderItem(BaseModel): delivered_count = models.PositiveIntegerField('已交货数量', default=0) utask = models.ForeignKey('pm.utask', verbose_name='关联生产大任务', on_delete=models.SET_NULL, null=True, blank=True) + + +class ContractRecord(CommonBDModel): + """ + TN:销售合同到款流水 + """ + STAGE_FIRST = 10 + STAGE_MIDDLE = 20 + STAGE_FINAL = 30 + STAGE_OTHER = 40 + STAGE_CHOICES = ( + (STAGE_FIRST, '首款'), + (STAGE_MIDDLE, '中间款'), + (STAGE_FINAL, '尾款'), + (STAGE_OTHER, '其他'), + ) + PAY_BANK = 10 + PAY_CASH = 20 + PAY_ACCEPTANCE = 30 + PAY_WECHAT = 40 + PAY_ALIPAY = 50 + PAY_OTHER = 60 + PAY_METHOD_CHOICES = ( + (PAY_BANK, '银行转账'), + (PAY_CASH, '现金'), + (PAY_ACCEPTANCE, '承兑'), + (PAY_WECHAT, '微信'), + (PAY_ALIPAY, '支付宝'), + (PAY_OTHER, '其他'), + ) + contract = models.ForeignKey( + Contract, verbose_name='销售合同', on_delete=models.CASCADE, related_name='records') + record_date = models.DateField('到款日期') + amount = models.DecimalField('到款金额', max_digits=14, decimal_places=2) + stage_type = models.PositiveSmallIntegerField( + '阶段类型', choices=STAGE_CHOICES, default=STAGE_OTHER, help_text=str(STAGE_CHOICES)) + pay_method = models.PositiveSmallIntegerField( + '收款方式', choices=PAY_METHOD_CHOICES, default=PAY_BANK, help_text=str(PAY_METHOD_CHOICES)) + voucher_no = models.CharField('凭证号', max_length=100, null=True, blank=True) + remark = models.CharField('备注', max_length=200, null=True, blank=True) + + class Meta: + verbose_name = '销售合同到款流水' + verbose_name_plural = verbose_name + ordering = ['-record_date', '-create_time'] + + def save(self, *args, **kwargs): + super().save(*args, **kwargs) + self.contract.refresh_settlement() + + def delete(self, using=None, *args, **kwargs): + contract = self.contract + result = super().delete(using=using, *args, **kwargs) + contract.refresh_settlement() + return result diff --git a/apps/sam/serializers.py b/apps/sam/serializers.py index 08d8143a..0bc17f03 100644 --- a/apps/sam/serializers.py +++ b/apps/sam/serializers.py @@ -1,6 +1,8 @@ +from decimal import Decimal + from rest_framework import serializers from apps.utils.serializers import CustomModelSerializer -from apps.sam.models import Customer, Contract, Order, OrderItem +from apps.sam.models import Customer, Contract, Order, OrderItem, ContractRecord from apps.utils.constants import EXCLUDE_FIELDS, EXCLUDE_FIELDS_BASE from rest_framework.exceptions import ValidationError from apps.mtm.serializers import MaterialSerializer @@ -81,3 +83,29 @@ class OrderItemSerializer(CustomModelSerializer): validated_data.pop('product', None) validated_data.pop('order', None) return super().update(instance, validated_data) + + +class ContractRecordSerializer(CustomModelSerializer): + contract_number = serializers.CharField(source='contract.number', read_only=True) + customer_name = serializers.CharField(source='contract.customer.name', read_only=True) + + class Meta: + model = ContractRecord + fields = '__all__' + read_only_fields = EXCLUDE_FIELDS + ['belong_dept'] + + def validate(self, attrs): + contract = attrs.get('contract', getattr(self.instance, 'contract', None)) + amount = attrs.get('amount', getattr(self.instance, 'amount', None)) + if contract is None or amount is None: + return attrs + if contract.status == Contract.STATUS_TERMINATED: + raise ValidationError('合同已终止,不可操作到款流水') + qs = ContractRecord.objects.filter(contract=contract) + if self.instance is not None: + qs = qs.exclude(id=self.instance.id) + total = sum((item.amount for item in qs), Decimal('0.00')) + amount + contract_amount = Decimal(str(contract.amount or 0)) + if total > contract_amount: + raise ValidationError('累计到款金额不可超过合同金额') + return attrs diff --git a/apps/sam/tests.py b/apps/sam/tests.py index 7ce503c2..882c77ae 100644 --- a/apps/sam/tests.py +++ b/apps/sam/tests.py @@ -1,3 +1,178 @@ +from decimal import Decimal + from django.test import TestCase -# Create your tests here. +from apps.sam.models import Contract, Customer +from apps.sam.serializers import ContractRecordSerializer +from rest_framework.exceptions import ParseError + + +class ContractSettlementTests(TestCase): + def test_sales_contract_record_updates_received_summary(self): + customer = Customer.objects.create( + name='客户A', + contact='张三', + contact_phone='13800000001', + ) + contract = Contract.objects.create( + name='销售合同A', + number='SC-001', + amount=1000, + customer=customer, + sign_date='2026-04-20', + ) + + from apps.sam.models import ContractRecord + + ContractRecord.objects.create( + contract=contract, + record_date='2026-04-21', + amount=Decimal('300.00'), + stage_type=ContractRecord.STAGE_FIRST, + ) + ContractRecord.objects.create( + contract=contract, + record_date='2026-04-22', + amount=Decimal('200.00'), + stage_type=ContractRecord.STAGE_MIDDLE, + ) + + contract.refresh_from_db() + self.assertEqual(contract.received_amount, Decimal('500.00')) + self.assertEqual(contract.unreceived_amount, Decimal('500.00')) + self.assertEqual(contract.receive_progress, Decimal('50.00')) + self.assertEqual(contract.status, Contract.STATUS_ACTIVE) + + def test_sales_contract_record_delete_refreshes_summary_and_is_physical(self): + customer = Customer.objects.create( + name='客户B', + contact='李四', + contact_phone='13800000002', + ) + contract = Contract.objects.create( + name='销售合同B', + number='SC-002', + amount=1000, + customer=customer, + sign_date='2026-04-20', + ) + + from apps.sam.models import ContractRecord + + record = ContractRecord.objects.create( + contract=contract, + record_date='2026-04-21', + amount=Decimal('300.00'), + stage_type=ContractRecord.STAGE_FIRST, + ) + + record.delete() + contract.refresh_from_db() + + self.assertEqual(contract.received_amount, Decimal('0.00')) + self.assertEqual(contract.unreceived_amount, Decimal('1000.00')) + self.assertEqual(contract.receive_progress, Decimal('0.00')) + self.assertEqual(contract.status, Contract.STATUS_DRAFT) + self.assertFalse(ContractRecord._base_manager.filter(pk=record.pk).exists()) + + def test_sales_contract_delete_is_physical(self): + customer = Customer.objects.create( + name='客户C', + contact='王五', + contact_phone='13800000003', + ) + contract = Contract.objects.create( + name='销售合同C', + number='SC-003', + amount=500, + customer=customer, + sign_date='2026-04-20', + ) + + contract.delete() + + self.assertFalse(Contract._base_manager.filter(pk=contract.pk).exists()) + + def test_sales_contract_status_auto_transitions_by_records(self): + customer = Customer.objects.create( + name='客户D', + contact='赵六', + contact_phone='13800000004', + ) + contract = Contract.objects.create( + name='销售合同D', + number='SC-004', + amount=1000, + customer=customer, + sign_date='2026-04-20', + ) + + from apps.sam.models import ContractRecord + + self.assertEqual(contract.status, Contract.STATUS_DRAFT) + + first_record = ContractRecord.objects.create( + contract=contract, + record_date='2026-04-21', + amount=Decimal('300.00'), + stage_type=ContractRecord.STAGE_FIRST, + ) + contract.refresh_from_db() + self.assertEqual(contract.status, Contract.STATUS_ACTIVE) + + ContractRecord.objects.create( + contract=contract, + record_date='2026-04-22', + amount=Decimal('700.00'), + stage_type=ContractRecord.STAGE_FINAL, + ) + contract.refresh_from_db() + self.assertEqual(contract.status, Contract.STATUS_DONE) + + first_record.delete() + contract.refresh_from_db() + self.assertEqual(contract.status, Contract.STATUS_ACTIVE) + + def test_sales_terminated_contract_forbids_record_changes(self): + customer = Customer.objects.create( + name='客户E', + contact='孙七', + contact_phone='13800000005', + ) + contract = Contract.objects.create( + name='销售合同E', + number='SC-005', + amount=1000, + customer=customer, + sign_date='2026-04-20', + status=Contract.STATUS_TERMINATED, + ) + + serializer = ContractRecordSerializer(data={ + 'contract': contract.id, + 'record_date': '2026-04-21', + 'amount': '100.00', + 'stage_type': 10, + 'pay_method': 10, + }) + self.assertFalse(serializer.is_valid()) + self.assertIn('合同已终止,不可操作到款流水', str(serializer.errors)) + + contract.status = Contract.STATUS_DRAFT + contract.save(refresh_settlement=False) + + from apps.sam.models import ContractRecord + record = ContractRecord.objects.create( + contract=contract, + record_date='2026-04-20', + amount=Decimal('100.00'), + stage_type=ContractRecord.STAGE_OTHER, + ) + contract.status = Contract.STATUS_TERMINATED + contract.save(refresh_settlement=False) + + with self.assertRaises(ParseError): + from apps.sam.views import ContractRecordViewSet + viewset = ContractRecordViewSet() + viewset.request = None + viewset.perform_destroy(record) diff --git a/apps/sam/urls.py b/apps/sam/urls.py index 02c85a8f..d4636faf 100644 --- a/apps/sam/urls.py +++ b/apps/sam/urls.py @@ -1,6 +1,6 @@ from django.urls import path, include from rest_framework.routers import DefaultRouter -from apps.sam.views import (CustomerViewSet, ContractViewSet, OrderViewSet, OrderItemViewSet) +from apps.sam.views import (CustomerViewSet, ContractViewSet, OrderViewSet, OrderItemViewSet, ContractRecordViewSet) API_BASE_URL = 'api/sam/' HTML_BASE_URL = 'dhtml/sam/' @@ -8,8 +8,9 @@ HTML_BASE_URL = 'dhtml/sam/' router = DefaultRouter() router.register('customer', CustomerViewSet, basename='customer') router.register('contract', ContractViewSet, basename='contract') +router.register('contract_record', ContractRecordViewSet, basename='contract_record') router.register('order', OrderViewSet, basename='order') router.register('orderitem', OrderItemViewSet, basename='orderitem') urlpatterns = [ path(API_BASE_URL, include(router.urls)), -] \ No newline at end of file +] diff --git a/apps/sam/views.py b/apps/sam/views.py index bbd185c1..265bcd46 100644 --- a/apps/sam/views.py +++ b/apps/sam/views.py @@ -1,7 +1,7 @@ from django.shortcuts import render from apps.utils.viewsets import CustomGenericViewSet, CustomModelViewSet -from apps.sam.models import Customer, Contract, Order, OrderItem -from apps.sam.serializers import CustomerSerializer, ContractSerializer, OrderSerializer, OrderItemSerializer +from apps.sam.models import Customer, Contract, Order, OrderItem, ContractRecord +from apps.sam.serializers import CustomerSerializer, ContractSerializer, OrderSerializer, OrderItemSerializer, ContractRecordSerializer from rest_framework.exceptions import ParseError from rest_framework.mixins import ListModelMixin, CreateModelMixin, DestroyModelMixin from apps.utils.mixins import BulkCreateModelMixin @@ -46,6 +46,7 @@ class ContractViewSet(CustomModelViewSet): def perform_destroy(self, instance): if Order.objects.filter(contract=instance).exists(): raise ParseError('该合同存在订单不可删除') + instance.delete() class OrderViewSet(CustomModelViewSet): @@ -106,3 +107,32 @@ class OrderItemViewSet(ListModelMixin, CreateModelMixin, DestroyModelMixin, Cust if instance.order.state != Order.ORDER_CREATE: raise ParseError('该订单状态下不可删除') return super().perform_destroy(instance) + + +class ContractRecordViewSet(CustomModelViewSet): + """ + list: 销售合同到款流水 + + 销售合同到款流水 + """ + perms_map = { + 'get': '*', + 'post': 'contract.update', + 'put': 'contract.update', + 'patch': 'contract.update', + 'delete': 'contract.update', + } + queryset = ContractRecord.objects.all() + serializer_class = ContractRecordSerializer + search_fields = ['contract__number', 'contract__name', 'voucher_no', 'remark'] + select_related_fields = ['contract', 'contract__customer', 'create_by', 'update_by'] + filterset_fields = { + 'contract': ['exact'], + 'stage_type': ['exact', 'in'], + 'pay_method': ['exact', 'in'], + } + + def perform_destroy(self, instance): + if instance.contract.status == Contract.STATUS_TERMINATED: + raise ParseError('合同已终止,不可删除到款流水') + instance.delete()