This commit is contained in:
TianyangZhang 2026-04-21 16:05:08 +08:00
commit 48305ed6fb
15 changed files with 1105 additions and 18 deletions

View File

@ -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,

View File

@ -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,

View File

@ -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='采购合同'),
),
]

View File

@ -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)
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

View File

@ -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
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

View File

@ -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)

View File

@ -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)),
]
]

View File

@ -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"
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()

View File

@ -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'],
},
),
]

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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)),
]
]

View File

@ -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()

View File

@ -1,6 +1,6 @@
from apps.wpm.models import BatchSt
import logging
from apps.wpm.models import Mlogb, Mlogbw, MlogbDefect
from apps.wpm.models import Mlogb, Mlogbw, MlogbDefect, MlogUser
from apps.mtm.models import Mgroup
import decimal
from django.db.models import Sum
@ -27,7 +27,7 @@ def main(batch: str, mgroup_obj:Mgroup=None):
mgroup_name = mgroup.name
mlogb1_qs = Mlogb.objects.filter(mlog__submit_time__isnull=False,
material_out__isnull=False, mlog__mgroup=mgroup,
mlog__is_fix=False, batch=batch, need_inout=True)
mlog__is_fix=False, batch=batch, need_inout=True).order_by("mlog__submit_time")
if mlogb1_qs.exists():
data[f"{mgroup_name}_日期"] = []
data[f"{mgroup_name}_操作人"] = []
@ -38,6 +38,7 @@ def main(batch: str, mgroup_obj:Mgroup=None):
data[f"{mgroup_name}_count_ok_full"] = 0
data[f"{mgroup_name}_count_pn_jgqbl"] = 0
mlogb_q_ids = []
cal_mlog = []
for item in mlogb1_qs:
# 找到对应的输入
mlogb_from:Mlogb = item.mlogb_from
@ -51,6 +52,13 @@ def main(batch: str, mgroup_obj:Mgroup=None):
data[f"{mgroup_name}_count_pn_jgqbl"] += 0
if item.mlog.handle_user:
data[f"{mgroup_name}_操作人"].append(item.mlog.handle_user)
# 子工序操作人
if item.mlog not in cal_mlog:
mlog_users_qs = MlogUser.objects.filter(mlog=item.mlog)
if mlog_users_qs.exists():
for mlog_user in mlog_users_qs:
data[f"{mgroup_name}_{mlog_user.process.name}_操作人"] = mlog_user.handle_user.name
cal_mlog.append(item.mlog)
if item.mlog.handle_date:
data[f"{mgroup_name}_日期"].append(item.mlog.handle_date)
data[f"{mgroup_name}_count_real"] += item.count_real