forked from erp-dev/erp
feat: mes module
This commit is contained in:
7
mes/apps.py
Normal file
7
mes/apps.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class MesConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'mes'
|
||||
verbose_name = 'MES'
|
||||
62
mes/migrations/0001_initial.py
Normal file
62
mes/migrations/0001_initial.py
Normal file
@@ -0,0 +1,62 @@
|
||||
# Generated by Django 5.2.8 on 2026-05-08 07:40
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('basic_info', '0026_transportvehicle_and_capacity'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='DeviceCategory',
|
||||
fields=[
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
|
||||
('id', models.BigAutoField(primary_key=True, serialize=False)),
|
||||
('name', models.CharField(max_length=100, verbose_name='设备分类名称')),
|
||||
('created_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='created_mes_device_categories', to=settings.AUTH_USER_MODEL, verbose_name='创建人')),
|
||||
('merchant', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='mes_device_categories', to='basic_info.merchant', verbose_name='所属商户')),
|
||||
('operator', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='operated_mes_device_categories', to='basic_info.employee', verbose_name='操作人')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '设备分类',
|
||||
'verbose_name_plural': '设备分类',
|
||||
'ordering': ['id'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Device',
|
||||
fields=[
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
|
||||
('id', models.BigAutoField(primary_key=True, serialize=False)),
|
||||
('name', models.CharField(max_length=100, verbose_name='设备名称')),
|
||||
('extra', models.JSONField(blank=True, null=True, verbose_name='扩展参数')),
|
||||
('created_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='created_mes_devices', to=settings.AUTH_USER_MODEL, verbose_name='创建人')),
|
||||
('merchant', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='mes_devices', to='basic_info.merchant', verbose_name='所属商户')),
|
||||
('operator', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='operated_mes_devices', to='basic_info.employee', verbose_name='操作人')),
|
||||
('category', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='devices', to='mes.devicecategory', verbose_name='设备分类')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '设备',
|
||||
'verbose_name_plural': '设备',
|
||||
'ordering': ['id'],
|
||||
},
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='devicecategory',
|
||||
constraint=models.UniqueConstraint(fields=('merchant', 'name'), name='unique_mes_device_category_name_per_merchant'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='device',
|
||||
constraint=models.UniqueConstraint(fields=('merchant', 'name'), name='unique_mes_device_name_per_merchant'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,51 @@
|
||||
# Generated by Django 5.2.8 on 2026-05-08 08:43
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('basic_info', '0026_transportvehicle_and_capacity'),
|
||||
('contenttypes', '0002_remove_content_type_name'),
|
||||
('mes', '0001_initial'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='device',
|
||||
name='capacity_unit',
|
||||
field=models.IntegerField(choices=[(1, '米'), (2, '码')], default=1, verbose_name='产能单位'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='device',
|
||||
name='peak_capacity',
|
||||
field=models.PositiveIntegerField(default=100, verbose_name='峰值产能'),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ProductionAssignment',
|
||||
fields=[
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
|
||||
('id', models.BigAutoField(primary_key=True, serialize=False)),
|
||||
('object_id', models.PositiveBigIntegerField(db_index=True, verbose_name='关联对象ID')),
|
||||
('extra', models.JSONField(blank=True, null=True, verbose_name='扩展参数')),
|
||||
('assignee', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='received_mes_production_assignments', to='basic_info.employee', verbose_name='被指派人')),
|
||||
('assigner', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='assigned_mes_production_assignments', to='basic_info.employee', verbose_name='指派者')),
|
||||
('content_type', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='mes_production_assignments', to='contenttypes.contenttype', verbose_name='关联对象类型')),
|
||||
('created_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='created_mes_production_assignments', to=settings.AUTH_USER_MODEL, verbose_name='创建人')),
|
||||
('device', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='production_assignments', to='mes.device', verbose_name='设备')),
|
||||
('merchant', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='mes_production_assignments', to='basic_info.merchant', verbose_name='所属商户')),
|
||||
('operator', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='operated_mes_production_assignments', to='basic_info.employee', verbose_name='操作人')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '生产指派',
|
||||
'verbose_name_plural': '生产指派',
|
||||
'ordering': ['-created_at', '-id'],
|
||||
'indexes': [models.Index(fields=['merchant', 'content_type', 'object_id'], name='mes_product_merchan_a3ff6f_idx'), models.Index(fields=['merchant', 'device', 'created_at'], name='mes_product_merchan_ae938e_idx')],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,32 @@
|
||||
# Generated by Django 5.2.8 on 2026-05-08 09:06
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('basic_info', '0026_transportvehicle_and_capacity'),
|
||||
('contenttypes', '0002_remove_content_type_name'),
|
||||
('mes', '0002_device_capacity_unit_device_peak_capacity_and_more'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='productionassignment',
|
||||
name='production_quantity',
|
||||
field=models.PositiveIntegerField(default=100, verbose_name='生产数量'),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='productionassignment',
|
||||
name='status',
|
||||
field=models.IntegerField(choices=[(1, 'Draft'), (2, '已发布'), (3, '已接收'), (4, '已取消'), (5, '已完工')], default=1, verbose_name='状态'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='productionassignment',
|
||||
index=models.Index(fields=['merchant', 'status', 'created_at'], name='mes_product_merchan_3ae0e6_idx'),
|
||||
),
|
||||
]
|
||||
0
mes/migrations/__init__.py
Normal file
0
mes/migrations/__init__.py
Normal file
211
mes/models.py
Normal file
211
mes/models.py
Normal file
@@ -0,0 +1,211 @@
|
||||
from django.contrib.contenttypes.fields import GenericForeignKey
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
from flower.common import ModelBase
|
||||
|
||||
|
||||
class CapacityUnitEnum(models.IntegerChoices):
|
||||
METER = 1, '米'
|
||||
YARD = 2, '码'
|
||||
|
||||
|
||||
class ProductionAssignmentStatusEnum(models.IntegerChoices):
|
||||
DRAFT = 1, 'Draft'
|
||||
PUBLISHED = 2, '已发布'
|
||||
ACCEPTED = 3, '已接收'
|
||||
CANCELLED = 4, '已取消'
|
||||
COMPLETED = 5, '已完工'
|
||||
|
||||
|
||||
class DeviceCategory(ModelBase):
|
||||
id = models.BigAutoField(primary_key=True)
|
||||
merchant = models.ForeignKey(
|
||||
'basic_info.Merchant',
|
||||
on_delete=models.PROTECT,
|
||||
related_name='mes_device_categories',
|
||||
verbose_name='所属商户',
|
||||
)
|
||||
name = models.CharField(max_length=100, verbose_name='设备分类名称')
|
||||
created_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.PROTECT,
|
||||
related_name='created_mes_device_categories',
|
||||
verbose_name='创建人',
|
||||
)
|
||||
operator = models.ForeignKey(
|
||||
'basic_info.Employee',
|
||||
on_delete=models.PROTECT,
|
||||
related_name='operated_mes_device_categories',
|
||||
verbose_name='操作人',
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def clean(self):
|
||||
if self.operator_id and self.merchant_id and self.operator.merchant_id != self.merchant_id:
|
||||
raise ValidationError({'operator': '操作人不属于当前商户'})
|
||||
|
||||
class Meta:
|
||||
verbose_name = '设备分类'
|
||||
verbose_name_plural = '设备分类'
|
||||
ordering = ['id']
|
||||
constraints = [
|
||||
models.UniqueConstraint(fields=['merchant', 'name'], name='unique_mes_device_category_name_per_merchant'),
|
||||
]
|
||||
|
||||
|
||||
class Device(ModelBase):
|
||||
id = models.BigAutoField(primary_key=True)
|
||||
merchant = models.ForeignKey(
|
||||
'basic_info.Merchant',
|
||||
on_delete=models.PROTECT,
|
||||
related_name='mes_devices',
|
||||
verbose_name='所属商户',
|
||||
)
|
||||
category = models.ForeignKey(
|
||||
DeviceCategory,
|
||||
on_delete=models.PROTECT,
|
||||
related_name='devices',
|
||||
verbose_name='设备分类',
|
||||
)
|
||||
name = models.CharField(max_length=100, verbose_name='设备名称')
|
||||
peak_capacity = models.PositiveIntegerField(default=100, verbose_name='峰值产能')
|
||||
capacity_unit = models.IntegerField(
|
||||
choices=CapacityUnitEnum.choices,
|
||||
default=CapacityUnitEnum.METER,
|
||||
verbose_name='产能单位',
|
||||
)
|
||||
extra = models.JSONField(null=True, blank=True, verbose_name='扩展参数')
|
||||
created_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.PROTECT,
|
||||
related_name='created_mes_devices',
|
||||
verbose_name='创建人',
|
||||
)
|
||||
operator = models.ForeignKey(
|
||||
'basic_info.Employee',
|
||||
on_delete=models.PROTECT,
|
||||
related_name='operated_mes_devices',
|
||||
verbose_name='操作人',
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def clean(self):
|
||||
errors = {}
|
||||
if self.operator_id and self.merchant_id and self.operator.merchant_id != self.merchant_id:
|
||||
errors['operator'] = '操作人不属于当前商户'
|
||||
if self.category_id and self.merchant_id and self.category.merchant_id != self.merchant_id:
|
||||
errors['category'] = '设备分类不属于当前商户'
|
||||
if self.peak_capacity <= 0:
|
||||
errors['peak_capacity'] = '峰值产能必须大于0'
|
||||
if errors:
|
||||
raise ValidationError(errors)
|
||||
|
||||
class Meta:
|
||||
verbose_name = '设备'
|
||||
verbose_name_plural = '设备'
|
||||
ordering = ['id']
|
||||
constraints = [
|
||||
models.UniqueConstraint(fields=['merchant', 'name'], name='unique_mes_device_name_per_merchant'),
|
||||
]
|
||||
|
||||
|
||||
class ProductionAssignment(ModelBase):
|
||||
id = models.BigAutoField(primary_key=True)
|
||||
merchant = models.ForeignKey(
|
||||
'basic_info.Merchant',
|
||||
on_delete=models.PROTECT,
|
||||
related_name='mes_production_assignments',
|
||||
verbose_name='所属商户',
|
||||
)
|
||||
device = models.ForeignKey(
|
||||
Device,
|
||||
on_delete=models.PROTECT,
|
||||
related_name='production_assignments',
|
||||
verbose_name='设备',
|
||||
)
|
||||
content_type = models.ForeignKey(
|
||||
ContentType,
|
||||
on_delete=models.PROTECT,
|
||||
related_name='mes_production_assignments',
|
||||
verbose_name='关联对象类型',
|
||||
)
|
||||
object_id = models.PositiveBigIntegerField(db_index=True, verbose_name='关联对象ID')
|
||||
content_object = GenericForeignKey('content_type', 'object_id')
|
||||
assigner = models.ForeignKey(
|
||||
'basic_info.Employee',
|
||||
on_delete=models.PROTECT,
|
||||
related_name='assigned_mes_production_assignments',
|
||||
verbose_name='指派者',
|
||||
)
|
||||
assignee = models.ForeignKey(
|
||||
'basic_info.Employee',
|
||||
on_delete=models.PROTECT,
|
||||
related_name='received_mes_production_assignments',
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name='被指派人',
|
||||
)
|
||||
production_quantity = models.PositiveIntegerField(verbose_name='生产数量')
|
||||
status = models.IntegerField(
|
||||
choices=ProductionAssignmentStatusEnum.choices,
|
||||
default=ProductionAssignmentStatusEnum.DRAFT,
|
||||
verbose_name='状态',
|
||||
)
|
||||
created_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.PROTECT,
|
||||
related_name='created_mes_production_assignments',
|
||||
verbose_name='创建人',
|
||||
)
|
||||
operator = models.ForeignKey(
|
||||
'basic_info.Employee',
|
||||
on_delete=models.PROTECT,
|
||||
related_name='operated_mes_production_assignments',
|
||||
verbose_name='操作人',
|
||||
)
|
||||
extra = models.JSONField(null=True, blank=True, verbose_name='扩展参数')
|
||||
|
||||
def __str__(self):
|
||||
return f'ProductionAssignment #{self.id}'
|
||||
|
||||
def clean(self):
|
||||
errors = {}
|
||||
if self.device_id and self.merchant_id and self.device.merchant_id != self.merchant_id:
|
||||
errors['device'] = '设备不属于当前商户'
|
||||
if self.assigner_id and self.merchant_id and self.assigner.merchant_id != self.merchant_id:
|
||||
errors['assigner'] = '指派者不属于当前商户'
|
||||
if self.assignee_id and self.merchant_id and self.assignee.merchant_id != self.merchant_id:
|
||||
errors['assignee'] = '被指派人不属于当前商户'
|
||||
if self.operator_id and self.merchant_id and self.operator.merchant_id != self.merchant_id:
|
||||
errors['operator'] = '操作人不属于当前商户'
|
||||
if self.production_quantity is not None and self.production_quantity <= 0:
|
||||
errors['production_quantity'] = '生产数量必须大于0'
|
||||
if self.content_type_id and self.object_id:
|
||||
try:
|
||||
content_object = self.content_type.get_object_for_this_type(pk=self.object_id)
|
||||
except Exception as exc:
|
||||
errors['object_id'] = '关联对象不存在'
|
||||
else:
|
||||
content_object_merchant_id = getattr(content_object, 'merchant_id', None)
|
||||
if content_object_merchant_id is not None and self.merchant_id and content_object_merchant_id != self.merchant_id:
|
||||
errors['object_id'] = '关联对象不属于当前商户'
|
||||
if errors:
|
||||
raise ValidationError(errors)
|
||||
|
||||
class Meta:
|
||||
verbose_name = '生产指派'
|
||||
verbose_name_plural = '生产指派'
|
||||
ordering = ['-created_at', '-id']
|
||||
indexes = [
|
||||
models.Index(fields=['merchant', 'content_type', 'object_id']),
|
||||
models.Index(fields=['merchant', 'device', 'created_at']),
|
||||
models.Index(fields=['merchant', 'status', 'created_at']),
|
||||
]
|
||||
|
||||
303
mes/services.py
Normal file
303
mes/services.py
Normal file
@@ -0,0 +1,303 @@
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import IntegrityError, transaction
|
||||
|
||||
from mes.models import (
|
||||
CapacityUnitEnum,
|
||||
Device,
|
||||
DeviceCategory,
|
||||
ProductionAssignment,
|
||||
ProductionAssignmentStatusEnum,
|
||||
)
|
||||
|
||||
|
||||
UNSET = object()
|
||||
|
||||
ALLOWED_PRODUCTION_ASSIGNMENT_STATUS_TRANSITIONS = {
|
||||
ProductionAssignmentStatusEnum.DRAFT: {
|
||||
ProductionAssignmentStatusEnum.DRAFT,
|
||||
ProductionAssignmentStatusEnum.PUBLISHED,
|
||||
ProductionAssignmentStatusEnum.CANCELLED,
|
||||
},
|
||||
ProductionAssignmentStatusEnum.PUBLISHED: {
|
||||
ProductionAssignmentStatusEnum.PUBLISHED,
|
||||
ProductionAssignmentStatusEnum.ACCEPTED,
|
||||
ProductionAssignmentStatusEnum.CANCELLED,
|
||||
},
|
||||
ProductionAssignmentStatusEnum.ACCEPTED: {
|
||||
ProductionAssignmentStatusEnum.ACCEPTED,
|
||||
ProductionAssignmentStatusEnum.COMPLETED,
|
||||
},
|
||||
ProductionAssignmentStatusEnum.CANCELLED: {
|
||||
ProductionAssignmentStatusEnum.CANCELLED,
|
||||
},
|
||||
ProductionAssignmentStatusEnum.COMPLETED: {
|
||||
ProductionAssignmentStatusEnum.COMPLETED,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _normalize_name(name: str, field_name: str = 'name') -> str:
|
||||
normalized = (name or '').strip()
|
||||
if not normalized:
|
||||
raise ValueError(f'{field_name}不能为空')
|
||||
return normalized
|
||||
|
||||
|
||||
def _assert_operator_belongs_to_merchant(*, operator, merchant) -> None:
|
||||
if operator is None:
|
||||
raise ValueError('操作人不能为空')
|
||||
if operator.merchant_id != merchant.id:
|
||||
raise ValueError('操作人不属于当前商户')
|
||||
|
||||
|
||||
def _assert_category_belongs_to_merchant(*, category: DeviceCategory, merchant) -> None:
|
||||
if category.merchant_id != merchant.id:
|
||||
raise ValueError('设备分类不属于当前商户')
|
||||
|
||||
|
||||
def _assert_employee_belongs_to_merchant(*, employee, merchant, role: str) -> None:
|
||||
if employee is None:
|
||||
raise ValueError(f'{role}不能为空')
|
||||
if employee.merchant_id != merchant.id:
|
||||
raise ValueError(f'{role}不属于当前商户')
|
||||
|
||||
|
||||
def _validate_peak_capacity(peak_capacity: int) -> int:
|
||||
if peak_capacity is None:
|
||||
raise ValueError('峰值产能不能为空')
|
||||
if int(peak_capacity) <= 0:
|
||||
raise ValueError('峰值产能必须大于0')
|
||||
return int(peak_capacity)
|
||||
|
||||
|
||||
def _validate_capacity_unit(capacity_unit: int) -> int:
|
||||
valid_values = {choice[0] for choice in CapacityUnitEnum.choices}
|
||||
if capacity_unit not in valid_values:
|
||||
raise ValueError('产能单位不合法')
|
||||
return capacity_unit
|
||||
|
||||
|
||||
def _validate_production_quantity(production_quantity: int) -> int:
|
||||
if production_quantity is None:
|
||||
raise ValueError('生产数量不能为空')
|
||||
if int(production_quantity) <= 0:
|
||||
raise ValueError('生产数量必须大于0')
|
||||
return int(production_quantity)
|
||||
|
||||
|
||||
def _validate_production_assignment_status(status: int) -> int:
|
||||
valid_values = {choice[0] for choice in ProductionAssignmentStatusEnum.choices}
|
||||
if status not in valid_values:
|
||||
raise ValueError('生产指派状态不合法')
|
||||
return status
|
||||
|
||||
|
||||
def _validate_production_assignment_status_transition(*, current_status: int, next_status: int) -> int:
|
||||
next_status = _validate_production_assignment_status(next_status)
|
||||
allowed_statuses = ALLOWED_PRODUCTION_ASSIGNMENT_STATUS_TRANSITIONS.get(current_status, {current_status})
|
||||
if next_status not in allowed_statuses:
|
||||
raise ValueError('当前状态不允许变更为目标状态')
|
||||
return next_status
|
||||
|
||||
|
||||
def _validate_content_object_merchant(*, content_type, object_id: int, merchant) -> None:
|
||||
try:
|
||||
content_object = content_type.get_object_for_this_type(pk=object_id)
|
||||
except Exception as exc:
|
||||
raise ValueError('关联对象不存在') from exc
|
||||
content_object_merchant_id = getattr(content_object, 'merchant_id', None)
|
||||
if content_object_merchant_id is not None and content_object_merchant_id != merchant.id:
|
||||
raise ValueError('关联对象不属于当前商户')
|
||||
|
||||
|
||||
def _save_with_validation(instance):
|
||||
try:
|
||||
instance.full_clean()
|
||||
instance.save()
|
||||
except ValidationError as exc:
|
||||
if hasattr(exc, 'message_dict'):
|
||||
first_error = next(iter(exc.message_dict.values()))
|
||||
if isinstance(first_error, list) and first_error:
|
||||
raise ValueError(first_error[0]) from exc
|
||||
raise ValueError(str(exc)) from exc
|
||||
except IntegrityError as exc:
|
||||
raise ValueError('名称已存在') from exc
|
||||
return instance
|
||||
|
||||
|
||||
def list_device_categories_for_merchant(*, merchant):
|
||||
return DeviceCategory.objects.filter(merchant=merchant).select_related('merchant', 'created_by', 'operator')
|
||||
|
||||
|
||||
def get_device_category_for_merchant(*, merchant, category_id: int) -> DeviceCategory:
|
||||
return list_device_categories_for_merchant(merchant=merchant).get(id=category_id)
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def create_device_category(*, merchant, name: str, created_by, operator) -> DeviceCategory:
|
||||
_assert_operator_belongs_to_merchant(operator=operator, merchant=merchant)
|
||||
category = DeviceCategory(
|
||||
merchant=merchant,
|
||||
name=_normalize_name(name, '设备分类名称'),
|
||||
created_by=created_by,
|
||||
operator=operator,
|
||||
)
|
||||
return _save_with_validation(category)
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def update_device_category(*, category: DeviceCategory, operator, name=UNSET) -> DeviceCategory:
|
||||
_assert_operator_belongs_to_merchant(operator=operator, merchant=category.merchant)
|
||||
category = DeviceCategory.objects.select_for_update().get(pk=category.pk)
|
||||
category.operator = operator
|
||||
if name is not UNSET:
|
||||
category.name = _normalize_name(name, '设备分类名称')
|
||||
return _save_with_validation(category)
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def delete_device_category(*, category: DeviceCategory, operator) -> None:
|
||||
_assert_operator_belongs_to_merchant(operator=operator, merchant=category.merchant)
|
||||
category = DeviceCategory.objects.select_for_update().get(pk=category.pk)
|
||||
category.delete()
|
||||
|
||||
|
||||
def list_devices_for_merchant(*, merchant, category_id: int | None = None, extra_json_path: list[str] | None = None,
|
||||
extra_json_value=UNSET):
|
||||
queryset = Device.objects.filter(merchant=merchant).select_related('merchant', 'category', 'created_by', 'operator')
|
||||
if category_id is not None:
|
||||
queryset = queryset.filter(category_id=category_id)
|
||||
if extra_json_path:
|
||||
lookup = '__'.join(['extra', *extra_json_path])
|
||||
queryset = queryset.filter(**{lookup: extra_json_value})
|
||||
return queryset
|
||||
|
||||
|
||||
def get_device_for_merchant(*, merchant, device_id: int) -> Device:
|
||||
return list_devices_for_merchant(merchant=merchant).get(id=device_id)
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def create_device(*, merchant, category: DeviceCategory, name: str, created_by, operator, peak_capacity: int,
|
||||
capacity_unit: int = CapacityUnitEnum.METER, extra=None) -> Device:
|
||||
_assert_operator_belongs_to_merchant(operator=operator, merchant=merchant)
|
||||
_assert_category_belongs_to_merchant(category=category, merchant=merchant)
|
||||
device = Device(
|
||||
merchant=merchant,
|
||||
category=category,
|
||||
name=_normalize_name(name, '设备名称'),
|
||||
peak_capacity=_validate_peak_capacity(peak_capacity),
|
||||
capacity_unit=_validate_capacity_unit(capacity_unit),
|
||||
extra=extra,
|
||||
created_by=created_by,
|
||||
operator=operator,
|
||||
)
|
||||
return _save_with_validation(device)
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def update_device(*, device: Device, operator, name=UNSET, category=UNSET, peak_capacity=UNSET,
|
||||
capacity_unit=UNSET, extra=UNSET):
|
||||
device = Device.objects.select_for_update().get(pk=device.pk)
|
||||
_assert_operator_belongs_to_merchant(operator=operator, merchant=device.merchant)
|
||||
device.operator = operator
|
||||
if name is not UNSET:
|
||||
device.name = _normalize_name(name, '设备名称')
|
||||
if category is not UNSET:
|
||||
_assert_category_belongs_to_merchant(category=category, merchant=device.merchant)
|
||||
device.category = category
|
||||
if peak_capacity is not UNSET:
|
||||
device.peak_capacity = _validate_peak_capacity(peak_capacity)
|
||||
if capacity_unit is not UNSET:
|
||||
device.capacity_unit = _validate_capacity_unit(capacity_unit)
|
||||
if extra is not UNSET:
|
||||
device.extra = extra
|
||||
return _save_with_validation(device)
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def delete_device(*, device: Device, operator) -> None:
|
||||
_assert_operator_belongs_to_merchant(operator=operator, merchant=device.merchant)
|
||||
device = Device.objects.select_for_update().get(pk=device.pk)
|
||||
device.delete()
|
||||
|
||||
|
||||
def list_production_assignments_for_merchant(*, merchant, device_id: int | None = None, content_type_id: int | None = None,
|
||||
object_id: int | None = None, status: int | None = None):
|
||||
queryset = ProductionAssignment.objects.filter(merchant=merchant).select_related(
|
||||
'merchant', 'device', 'content_type', 'assigner', 'assignee', 'created_by', 'operator'
|
||||
)
|
||||
if device_id is not None:
|
||||
queryset = queryset.filter(device_id=device_id)
|
||||
if content_type_id is not None:
|
||||
queryset = queryset.filter(content_type_id=content_type_id)
|
||||
if object_id is not None:
|
||||
queryset = queryset.filter(object_id=object_id)
|
||||
if status is not None:
|
||||
queryset = queryset.filter(status=status)
|
||||
return queryset
|
||||
|
||||
|
||||
def get_production_assignment_for_merchant(*, merchant, assignment_id: int) -> ProductionAssignment:
|
||||
return list_production_assignments_for_merchant(merchant=merchant).get(id=assignment_id)
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def create_production_assignment(*, merchant, device: Device, content_type: ContentType, object_id: int,
|
||||
assigner, production_quantity: int, assignee=None, created_by=None, operator=None,
|
||||
status: int = ProductionAssignmentStatusEnum.DRAFT, extra=None) -> ProductionAssignment:
|
||||
_assert_employee_belongs_to_merchant(employee=assigner, merchant=merchant, role='指派者')
|
||||
if assignee is not None:
|
||||
_assert_employee_belongs_to_merchant(employee=assignee, merchant=merchant, role='被指派人')
|
||||
_assert_employee_belongs_to_merchant(employee=operator, merchant=merchant, role='操作人')
|
||||
if device.merchant_id != merchant.id:
|
||||
raise ValueError('设备不属于当前商户')
|
||||
_validate_content_object_merchant(content_type=content_type, object_id=object_id, merchant=merchant)
|
||||
assignment = ProductionAssignment(
|
||||
merchant=merchant,
|
||||
device=device,
|
||||
content_type=content_type,
|
||||
object_id=object_id,
|
||||
assigner=assigner,
|
||||
assignee=assignee,
|
||||
production_quantity=_validate_production_quantity(production_quantity),
|
||||
status=_validate_production_assignment_status(status),
|
||||
created_by=created_by,
|
||||
operator=operator,
|
||||
extra=extra,
|
||||
)
|
||||
return _save_with_validation(assignment)
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def update_production_assignment(*, assignment: ProductionAssignment, operator, device=UNSET, assignee=UNSET,
|
||||
production_quantity=UNSET, status=UNSET, extra=UNSET):
|
||||
assignment = ProductionAssignment.objects.select_for_update().get(pk=assignment.pk)
|
||||
_assert_employee_belongs_to_merchant(employee=operator, merchant=assignment.merchant, role='操作人')
|
||||
assignment.operator = operator
|
||||
if device is not UNSET:
|
||||
if device.merchant_id != assignment.merchant_id:
|
||||
raise ValueError('设备不属于当前商户')
|
||||
assignment.device = device
|
||||
if assignee is not UNSET:
|
||||
if assignee is not None:
|
||||
_assert_employee_belongs_to_merchant(employee=assignee, merchant=assignment.merchant, role='被指派人')
|
||||
assignment.assignee = assignee
|
||||
if production_quantity is not UNSET:
|
||||
assignment.production_quantity = _validate_production_quantity(production_quantity)
|
||||
if status is not UNSET:
|
||||
assignment.status = _validate_production_assignment_status_transition(
|
||||
current_status=assignment.status,
|
||||
next_status=status,
|
||||
)
|
||||
if extra is not UNSET:
|
||||
assignment.extra = extra
|
||||
return _save_with_validation(assignment)
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def delete_production_assignment(*, assignment: ProductionAssignment, operator) -> None:
|
||||
_assert_employee_belongs_to_merchant(employee=operator, merchant=assignment.merchant, role='操作人')
|
||||
assignment = ProductionAssignment.objects.select_for_update().get(pk=assignment.pk)
|
||||
assignment.delete()
|
||||
368
mes/tests.py
Normal file
368
mes/tests.py
Normal file
@@ -0,0 +1,368 @@
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.db.models import ProtectedError
|
||||
from django.test import TestCase
|
||||
|
||||
from basic_info.models import Customer, Employee, Merchant, MerchantTypeEnum
|
||||
from mes.models import CapacityUnitEnum, Device, DeviceCategory, ProductionAssignment, ProductionAssignmentStatusEnum
|
||||
from mes import services
|
||||
from printing.models import PrintingOrder
|
||||
|
||||
|
||||
class MesServiceTestCase(TestCase):
|
||||
def setUp(self):
|
||||
self.user = get_user_model().objects.create_user(username='mes-user', password='pass12345')
|
||||
self.other_user = get_user_model().objects.create_user(username='mes-other-user', password='pass12345')
|
||||
self.merchant = Merchant.objects.create(name='MES商户', type=MerchantTypeEnum.STORE)
|
||||
self.other_merchant = Merchant.objects.create(name='其他商户', type=MerchantTypeEnum.STORE)
|
||||
self.operator = Employee.objects.create(merchant=self.merchant, sys_user=self.user, name='操作员')
|
||||
self.other_operator = Employee.objects.create(merchant=self.other_merchant, sys_user=self.other_user, name='其他操作员')
|
||||
|
||||
def test_create_device_category(self):
|
||||
category = services.create_device_category(
|
||||
merchant=self.merchant,
|
||||
name='打印机',
|
||||
created_by=self.user,
|
||||
operator=self.operator,
|
||||
)
|
||||
|
||||
self.assertEqual(category.merchant, self.merchant)
|
||||
self.assertEqual(category.created_by, self.user)
|
||||
self.assertEqual(category.operator, self.operator)
|
||||
|
||||
def test_create_device_category_rejects_cross_merchant_operator(self):
|
||||
with self.assertRaisesMessage(ValueError, '操作人不属于当前商户'):
|
||||
services.create_device_category(
|
||||
merchant=self.merchant,
|
||||
name='打印机',
|
||||
created_by=self.user,
|
||||
operator=self.other_operator,
|
||||
)
|
||||
|
||||
def test_create_device(self):
|
||||
category = services.create_device_category(
|
||||
merchant=self.merchant,
|
||||
name='打印机',
|
||||
created_by=self.user,
|
||||
operator=self.operator,
|
||||
)
|
||||
|
||||
device = services.create_device(
|
||||
merchant=self.merchant,
|
||||
category=category,
|
||||
name='设备A',
|
||||
created_by=self.user,
|
||||
operator=self.operator,
|
||||
peak_capacity=120,
|
||||
capacity_unit=CapacityUnitEnum.METER,
|
||||
extra={'capacity': {'per_hour': 120}},
|
||||
)
|
||||
|
||||
self.assertEqual(device.category, category)
|
||||
self.assertEqual(device.peak_capacity, 120)
|
||||
self.assertEqual(device.capacity_unit, CapacityUnitEnum.METER)
|
||||
self.assertEqual(device.extra, {'capacity': {'per_hour': 120}})
|
||||
|
||||
def test_create_device_rejects_cross_merchant_category(self):
|
||||
other_category = services.create_device_category(
|
||||
merchant=self.other_merchant,
|
||||
name='其他分类',
|
||||
created_by=self.other_user,
|
||||
operator=self.other_operator,
|
||||
)
|
||||
|
||||
with self.assertRaisesMessage(ValueError, '设备分类不属于当前商户'):
|
||||
services.create_device(
|
||||
merchant=self.merchant,
|
||||
category=other_category,
|
||||
name='设备A',
|
||||
created_by=self.user,
|
||||
operator=self.operator,
|
||||
peak_capacity=120,
|
||||
)
|
||||
|
||||
def test_update_device(self):
|
||||
category = services.create_device_category(
|
||||
merchant=self.merchant,
|
||||
name='打印机',
|
||||
created_by=self.user,
|
||||
operator=self.operator,
|
||||
)
|
||||
another_category = services.create_device_category(
|
||||
merchant=self.merchant,
|
||||
name='滚筒机',
|
||||
created_by=self.user,
|
||||
operator=self.operator,
|
||||
)
|
||||
device = services.create_device(
|
||||
merchant=self.merchant,
|
||||
category=category,
|
||||
name='设备A',
|
||||
created_by=self.user,
|
||||
operator=self.operator,
|
||||
peak_capacity=120,
|
||||
)
|
||||
|
||||
updated = services.update_device(
|
||||
device=device,
|
||||
operator=self.operator,
|
||||
name='设备B',
|
||||
category=another_category,
|
||||
peak_capacity=180,
|
||||
capacity_unit=CapacityUnitEnum.YARD,
|
||||
extra={'power': 220},
|
||||
)
|
||||
|
||||
self.assertEqual(updated.name, '设备B')
|
||||
self.assertEqual(updated.category, another_category)
|
||||
self.assertEqual(updated.peak_capacity, 180)
|
||||
self.assertEqual(updated.capacity_unit, CapacityUnitEnum.YARD)
|
||||
self.assertEqual(updated.extra, {'power': 220})
|
||||
|
||||
def test_update_device_allows_clearing_extra(self):
|
||||
category = services.create_device_category(
|
||||
merchant=self.merchant,
|
||||
name='打印机',
|
||||
created_by=self.user,
|
||||
operator=self.operator,
|
||||
)
|
||||
device = services.create_device(
|
||||
merchant=self.merchant,
|
||||
category=category,
|
||||
name='设备A',
|
||||
created_by=self.user,
|
||||
operator=self.operator,
|
||||
peak_capacity=120,
|
||||
extra={'power': 220},
|
||||
)
|
||||
|
||||
updated = services.update_device(device=device, operator=self.operator, extra=None)
|
||||
|
||||
self.assertIsNone(updated.extra)
|
||||
|
||||
def test_delete_device_category_is_protected_when_devices_exist(self):
|
||||
category = services.create_device_category(
|
||||
merchant=self.merchant,
|
||||
name='打印机',
|
||||
created_by=self.user,
|
||||
operator=self.operator,
|
||||
)
|
||||
services.create_device(
|
||||
merchant=self.merchant,
|
||||
category=category,
|
||||
name='设备A',
|
||||
created_by=self.user,
|
||||
operator=self.operator,
|
||||
peak_capacity=120,
|
||||
)
|
||||
|
||||
with self.assertRaises(ProtectedError):
|
||||
services.delete_device_category(category=category, operator=self.operator)
|
||||
|
||||
def test_list_devices_for_merchant_filters_by_category(self):
|
||||
category = services.create_device_category(
|
||||
merchant=self.merchant,
|
||||
name='打印机',
|
||||
created_by=self.user,
|
||||
operator=self.operator,
|
||||
)
|
||||
another_category = services.create_device_category(
|
||||
merchant=self.merchant,
|
||||
name='滚筒机',
|
||||
created_by=self.user,
|
||||
operator=self.operator,
|
||||
)
|
||||
target = services.create_device(
|
||||
merchant=self.merchant,
|
||||
category=category,
|
||||
name='设备A',
|
||||
created_by=self.user,
|
||||
operator=self.operator,
|
||||
peak_capacity=120,
|
||||
)
|
||||
services.create_device(
|
||||
merchant=self.merchant,
|
||||
category=another_category,
|
||||
name='设备B',
|
||||
created_by=self.user,
|
||||
operator=self.operator,
|
||||
peak_capacity=80,
|
||||
)
|
||||
|
||||
devices = list(services.list_devices_for_merchant(merchant=self.merchant, category_id=category.id))
|
||||
|
||||
self.assertEqual([device.id for device in devices], [target.id])
|
||||
|
||||
def test_list_devices_for_merchant_filters_by_extra_json_path(self):
|
||||
category = services.create_device_category(
|
||||
merchant=self.merchant,
|
||||
name='打印机',
|
||||
created_by=self.user,
|
||||
operator=self.operator,
|
||||
)
|
||||
target = services.create_device(
|
||||
merchant=self.merchant,
|
||||
category=category,
|
||||
name='设备A',
|
||||
created_by=self.user,
|
||||
operator=self.operator,
|
||||
peak_capacity=120,
|
||||
extra={'capacity': {'per_hour': 120}},
|
||||
)
|
||||
services.create_device(
|
||||
merchant=self.merchant,
|
||||
category=category,
|
||||
name='设备B',
|
||||
created_by=self.user,
|
||||
operator=self.operator,
|
||||
peak_capacity=80,
|
||||
extra={'capacity': {'per_hour': 80}},
|
||||
)
|
||||
|
||||
devices = list(
|
||||
services.list_devices_for_merchant(
|
||||
merchant=self.merchant,
|
||||
extra_json_path=['capacity', 'per_hour'],
|
||||
extra_json_value=120,
|
||||
)
|
||||
)
|
||||
|
||||
self.assertEqual([device.id for device in devices], [target.id])
|
||||
|
||||
def test_create_production_assignment(self):
|
||||
customer = Customer.objects.create(
|
||||
merchant=self.merchant,
|
||||
name='测试客户',
|
||||
mobile='13800138000',
|
||||
)
|
||||
printing_order = PrintingOrder.objects.create(
|
||||
merchant=self.merchant,
|
||||
customer=customer,
|
||||
fabric='测试面料',
|
||||
width='150cm',
|
||||
created_by=self.user,
|
||||
)
|
||||
category = services.create_device_category(
|
||||
merchant=self.merchant,
|
||||
name='打印机',
|
||||
created_by=self.user,
|
||||
operator=self.operator,
|
||||
)
|
||||
device = services.create_device(
|
||||
merchant=self.merchant,
|
||||
category=category,
|
||||
name='设备A',
|
||||
created_by=self.user,
|
||||
operator=self.operator,
|
||||
peak_capacity=120,
|
||||
)
|
||||
content_type = ContentType.objects.get_for_model(PrintingOrder)
|
||||
|
||||
assignment = services.create_production_assignment(
|
||||
merchant=self.merchant,
|
||||
device=device,
|
||||
content_type=content_type,
|
||||
object_id=printing_order.id,
|
||||
assigner=self.operator,
|
||||
production_quantity=300,
|
||||
assignee=None,
|
||||
created_by=self.user,
|
||||
operator=self.operator,
|
||||
extra={'batch': 'A1'},
|
||||
)
|
||||
|
||||
self.assertEqual(assignment.device, device)
|
||||
self.assertEqual(assignment.assigner, self.operator)
|
||||
self.assertIsNone(assignment.assignee)
|
||||
self.assertEqual(assignment.production_quantity, 300)
|
||||
self.assertEqual(assignment.status, ProductionAssignmentStatusEnum.DRAFT)
|
||||
self.assertEqual(assignment.extra, {'batch': 'A1'})
|
||||
|
||||
def test_create_production_assignment_rejects_cross_merchant_assignee(self):
|
||||
customer = Customer.objects.create(
|
||||
merchant=self.merchant,
|
||||
name='测试客户',
|
||||
mobile='13800138000',
|
||||
)
|
||||
printing_order = PrintingOrder.objects.create(
|
||||
merchant=self.merchant,
|
||||
customer=customer,
|
||||
fabric='测试面料',
|
||||
width='150cm',
|
||||
created_by=self.user,
|
||||
)
|
||||
category = services.create_device_category(
|
||||
merchant=self.merchant,
|
||||
name='打印机',
|
||||
created_by=self.user,
|
||||
operator=self.operator,
|
||||
)
|
||||
device = services.create_device(
|
||||
merchant=self.merchant,
|
||||
category=category,
|
||||
name='设备A',
|
||||
created_by=self.user,
|
||||
operator=self.operator,
|
||||
peak_capacity=120,
|
||||
)
|
||||
content_type = ContentType.objects.get_for_model(PrintingOrder)
|
||||
|
||||
with self.assertRaisesMessage(ValueError, '被指派人不属于当前商户'):
|
||||
services.create_production_assignment(
|
||||
merchant=self.merchant,
|
||||
device=device,
|
||||
content_type=content_type,
|
||||
object_id=printing_order.id,
|
||||
assigner=self.operator,
|
||||
production_quantity=300,
|
||||
assignee=self.other_operator,
|
||||
created_by=self.user,
|
||||
operator=self.operator,
|
||||
)
|
||||
|
||||
def test_update_production_assignment_rejects_invalid_status_transition(self):
|
||||
customer = Customer.objects.create(
|
||||
merchant=self.merchant,
|
||||
name='测试客户',
|
||||
mobile='13800138001',
|
||||
)
|
||||
printing_order = PrintingOrder.objects.create(
|
||||
merchant=self.merchant,
|
||||
customer=customer,
|
||||
fabric='测试面料',
|
||||
width='150cm',
|
||||
created_by=self.user,
|
||||
)
|
||||
category = services.create_device_category(
|
||||
merchant=self.merchant,
|
||||
name='打印机',
|
||||
created_by=self.user,
|
||||
operator=self.operator,
|
||||
)
|
||||
device = services.create_device(
|
||||
merchant=self.merchant,
|
||||
category=category,
|
||||
name='设备A',
|
||||
created_by=self.user,
|
||||
operator=self.operator,
|
||||
peak_capacity=120,
|
||||
)
|
||||
content_type = ContentType.objects.get_for_model(PrintingOrder)
|
||||
assignment = services.create_production_assignment(
|
||||
merchant=self.merchant,
|
||||
device=device,
|
||||
content_type=content_type,
|
||||
object_id=printing_order.id,
|
||||
assigner=self.operator,
|
||||
production_quantity=300,
|
||||
created_by=self.user,
|
||||
operator=self.operator,
|
||||
status=ProductionAssignmentStatusEnum.ACCEPTED,
|
||||
)
|
||||
|
||||
with self.assertRaisesMessage(ValueError, '当前状态不允许变更为目标状态'):
|
||||
services.update_production_assignment(
|
||||
assignment=assignment,
|
||||
operator=self.operator,
|
||||
status=ProductionAssignmentStatusEnum.CANCELLED,
|
||||
)
|
||||
Reference in New Issue
Block a user