From 4538e51ad57ed6ecc3f589a0d799f5b9747c4595 Mon Sep 17 00:00:00 2001 From: colaftc Date: Thu, 2 Jul 2026 18:26:09 +0800 Subject: [PATCH] beta --- api_v2/views/mes.py | 12 +- business/amounts.py | 59 ++++++++ business/external_finance_sync.py | 21 ++- business/models.py | 37 +++-- business/services.py | 5 +- business/tests/test_amounts.py | 101 ++++++++++++++ business/tests/test_payment_receipt.py | 11 +- .../business_amount_mode_design_2026-07-01.md | 131 ++++++++++++++++++ env.example | 8 +- flower/settings.py | 5 + ...4_production_assignment_device_nullable.py | 19 +++ mes/models.py | 2 + mes/services.py | 7 +- mes/tests.py | 79 +++++++++++ 14 files changed, 471 insertions(+), 26 deletions(-) create mode 100644 business/amounts.py create mode 100644 business/tests/test_amounts.py create mode 100644 docs/business_amount_mode_design_2026-07-01.md create mode 100644 mes/migrations/0004_production_assignment_device_nullable.py diff --git a/api_v2/views/mes.py b/api_v2/views/mes.py index 9d40873..9f1a5aa 100644 --- a/api_v2/views/mes.py +++ b/api_v2/views/mes.py @@ -147,7 +147,7 @@ class DeviceSerializer(serializers.ModelSerializer): class ProductionAssignmentWriteSerializer(serializers.Serializer): - device = serializers.IntegerField(required=False, min_value=1) + device = serializers.IntegerField(required=False, allow_null=True, min_value=1) content_type = serializers.IntegerField(required=False, min_value=1) object_id = serializers.IntegerField(required=False, min_value=1) assigner = serializers.IntegerField(required=False, min_value=1) @@ -160,13 +160,14 @@ class ProductionAssignmentWriteSerializer(serializers.Serializer): self.is_create = kwargs.pop("is_create", False) super().__init__(*args, **kwargs) if self.is_create: - self.fields["device"].required = True self.fields["content_type"].required = True self.fields["object_id"].required = True self.fields["assigner"].required = True self.fields["production_quantity"].required = True def validate_device(self, value): + if value is None: + return None employee = self.context["employee"] try: return Device.objects.get(id=value, merchant=employee.merchant) @@ -197,7 +198,7 @@ class ProductionAssignmentWriteSerializer(serializers.Serializer): class ProductionAssignmentSerializer(serializers.ModelSerializer): - device_name = serializers.CharField(source='device.name', read_only=True) + device_name = serializers.SerializerMethodField(read_only=True) assigner = serializers.SerializerMethodField() assignee = serializers.SerializerMethodField() created_by = serializers.SerializerMethodField() @@ -226,6 +227,11 @@ class ProductionAssignmentSerializer(serializers.ModelSerializer): ] read_only_fields = fields + def get_device_name(self, obj): + if obj.device is None: + return None + return obj.device.name + def get_assigner(self, obj): return _employee_payload(obj.assigner) diff --git a/business/amounts.py b/business/amounts.py new file mode 100644 index 0000000..3966d81 --- /dev/null +++ b/business/amounts.py @@ -0,0 +1,59 @@ +from decimal import Decimal, InvalidOperation, ROUND_HALF_UP + +from django.conf import settings + + +AMOUNT_MODE_HAOBUYE_INTEGER = 'haobuye_integer_round_half_up' +AMOUNT_MODE_STANDARD_DECIMAL_2 = 'standard_decimal_2' +AMOUNT_MODES = { + AMOUNT_MODE_HAOBUYE_INTEGER, + AMOUNT_MODE_STANDARD_DECIMAL_2, +} +INTEGER_QUANT = Decimal('1') +DECIMAL_2_QUANT = Decimal('0.01') + + +def to_decimal(value, *, field_name: str = 'value') -> Decimal: + try: + return Decimal(str(value)) + except (InvalidOperation, TypeError, ValueError) as exc: + raise ValueError(f'{field_name} 必须是数字') from exc + + +def normalize_money_amount(value, *, field_name: str = 'amount') -> Decimal: + decimal_value = to_decimal(value, field_name=field_name) + return decimal_value.quantize(_money_quant(), rounding=ROUND_HALF_UP) + + +def calculate_line_amount(*, quantity, price, standard_quantity=None) -> Decimal: + raw_quantity = quantity + if _amount_mode() == AMOUNT_MODE_STANDARD_DECIMAL_2 and standard_quantity is not None: + raw_quantity = standard_quantity + + quantity_value = to_decimal( + raw_quantity, + field_name='quantity', + ) + price_value = to_decimal(price, field_name='price') + return (quantity_value * price_value).quantize(_money_quant(), rounding=ROUND_HALF_UP) + + +def calculate_settlement_amount(*, amount, discount_amount=Decimal('0')) -> Decimal: + return normalize_money_amount( + normalize_money_amount(amount, field_name='amount') + + normalize_money_amount(discount_amount or Decimal('0'), field_name='discount_amount'), + field_name='settlement_amount', + ) + + +def _amount_mode() -> str: + mode = getattr(settings, 'BUSINESS_AMOUNT_MODE', AMOUNT_MODE_HAOBUYE_INTEGER) + if mode not in AMOUNT_MODES: + raise ValueError(f'BUSINESS_AMOUNT_MODE 不合法: {mode}') + return mode + + +def _money_quant() -> Decimal: + if _amount_mode() == AMOUNT_MODE_STANDARD_DECIMAL_2: + return DECIMAL_2_QUANT + return INTEGER_QUANT diff --git a/business/external_finance_sync.py b/business/external_finance_sync.py index 7f99c9b..c0ed72e 100644 --- a/business/external_finance_sync.py +++ b/business/external_finance_sync.py @@ -14,6 +14,7 @@ from django.db import transaction from basic_info import models as basic_models from . import models as business_models from . import services as business_services +from .amounts import calculate_settlement_amount, normalize_money_amount logger = logging.getLogger(__name__) @@ -524,10 +525,19 @@ def _normalize_external_receipt_record(*, record: dict[str, Any], record_kind: s markup = str(record.get('JieSunFS') or '').strip() or None receipt_date = _parse_external_date(record.get('RiQi') or record.get('KdRiQi')) if record_kind == 'receipt': - amount = _to_decimal(record.get('FkJinE'), field_name='FkJinE') - discount_amount = _to_decimal(record.get('ZkJinE'), field_name='ZkJinE', default=Decimal('0')) + amount = normalize_money_amount( + _to_decimal(record.get('FkJinE'), field_name='FkJinE'), + field_name='FkJinE', + ) + discount_amount = normalize_money_amount( + _to_decimal(record.get('ZkJinE'), field_name='ZkJinE', default=Decimal('0')), + field_name='ZkJinE', + ) elif record_kind == 'refund': - amount = _to_decimal(record.get('YfJinE'), field_name='YfJinE') + amount = normalize_money_amount( + _to_decimal(record.get('YfJinE'), field_name='YfJinE'), + field_name='YfJinE', + ) discount_amount = Decimal('0') else: raise ExternalFinanceSyncError(f'不支持的 record_kind: {record_kind}') @@ -537,7 +547,10 @@ def _normalize_external_receipt_record(*, record: dict[str, Any], record_kind: s 'receipt_date': receipt_date, 'amount': amount, 'discount_amount': discount_amount, - 'settlement_amount': amount + discount_amount, + 'settlement_amount': calculate_settlement_amount( + amount=amount, + discount_amount=discount_amount, + ), 'customer_name': customer_name, 'markup': markup, 'remarks': _build_external_remarks(record=record, record_kind=record_kind), diff --git a/business/models.py b/business/models.py index 121b3e8..a54e714 100644 --- a/business/models.py +++ b/business/models.py @@ -7,6 +7,7 @@ from django.conf import settings from django.core.exceptions import ValidationError from flower.common import ModelBase from basic_info import models as basic_info_models +from .amounts import calculate_line_amount, calculate_settlement_amount def build_business_human_id(prefix: str, created_at, object_id) -> str: @@ -222,7 +223,11 @@ class PurchaseOrderItem(ModelBase): return round(self.quantity * (self.empty_diff_percent / 100), 2) def total_amount(self): - return round(self.price * self.real_quantity(), 2) + return calculate_line_amount( + quantity=self.quantity, + price=self.price, + standard_quantity=self.real_quantity(), + ) def split_quantity_of_rolls(self) -> List[int]: if self.quantity_of_rolls: @@ -393,7 +398,11 @@ class SalesOrderItem(ModelBase): return round(self.quantity * (self.empty_diff_percent / 100), 2) def total_amount(self): - return round(self.price * self.real_quantity(), 2) + return calculate_line_amount( + quantity=self.quantity, + price=self.price, + standard_quantity=self.real_quantity(), + ) class PreSalesOrder(ModelBase): @@ -878,7 +887,11 @@ class PurchaseReturnOrderItem(ModelBase): return round(self.quantity * (self.empty_diff_percent / 100), 2) def total_amount(self): - return round(self.price * self.real_quantity(), 2) + return calculate_line_amount( + quantity=self.quantity, + price=self.price, + standard_quantity=self.real_quantity(), + ) class SalesReturnStatusEnum(models.IntegerChoices): @@ -1008,7 +1021,11 @@ class SalesReturnOrderItem(ModelBase): return round(self.quantity * (self.empty_diff_percent / 100), 2) def total_amount(self): - return round(self.price * self.real_quantity(), 2) + return calculate_line_amount( + quantity=self.quantity, + price=self.price, + standard_quantity=self.real_quantity(), + ) class ExternalCustomerStatementCategoryEnum(models.TextChoices): @@ -1151,8 +1168,10 @@ class PaymentOrder(OrderDirectionMixin, OrderCounterpartyMixin, ModelBase): @property def settlement_amount(self) -> Decimal: - discount = self.discount_amount or Decimal('0') - return self.amount + discount + return calculate_settlement_amount( + amount=self.amount, + discount_amount=self.discount_amount or Decimal('0'), + ) def get_total_amount(self) -> Decimal: return self.settlement_amount @@ -1242,8 +1261,10 @@ class ReceiptOrder(OrderDirectionMixin, OrderCounterpartyMixin, ModelBase): @property def settlement_amount(self) -> Decimal: - discount = self.discount_amount or Decimal('0') - return self.amount + discount + return calculate_settlement_amount( + amount=self.amount, + discount_amount=self.discount_amount or Decimal('0'), + ) def get_total_amount(self) -> Decimal: return self.settlement_amount diff --git a/business/services.py b/business/services.py index 899f69f..a6fef50 100644 --- a/business/services.py +++ b/business/services.py @@ -16,6 +16,7 @@ from stock import models as stock_models from stock.services import StockFlowService from basic_info.services import MerchantSettingService +from .amounts import normalize_money_amount from . import models from .tasks import ( create_purchase_order_stock_entries, @@ -2272,7 +2273,7 @@ def _to_decimal(value, field_name: str) -> Decimal: def _ensure_non_zero_amount(value, field_name: str) -> Decimal: - amount = _to_decimal(value, field_name) + amount = normalize_money_amount(_to_decimal(value, field_name), field_name=field_name) # 金额类字段要求非零(允许负数用于处理退款场景) # 付款单负金额 = 供应商退款,收款单负金额 = 退款给客户 if amount == 0: @@ -2288,7 +2289,7 @@ def _ensure_order_pending(order, pending_status, entity_name: str): def _ensure_non_negative_amount(value, field_name: str) -> Decimal: if value in (None, ''): return Decimal('0') - amount = _to_decimal(value, field_name) + amount = normalize_money_amount(_to_decimal(value, field_name), field_name=field_name) if amount < 0: raise ValueError(f'{field_name} 不能小于 0') return amount diff --git a/business/tests/test_amounts.py b/business/tests/test_amounts.py new file mode 100644 index 0000000..2871964 --- /dev/null +++ b/business/tests/test_amounts.py @@ -0,0 +1,101 @@ +from decimal import Decimal + +from django.test import TestCase, override_settings +from django.utils import timezone + +from basic_info import models as basic_models +from business import services +from business.amounts import calculate_line_amount, normalize_money_amount + +from .fixtures import create_basic_fixtures + + +class BusinessAmountRulesTestCase(TestCase): + def setUp(self): + ( + self.merchant, + self.supplier, + self.warehouse_strict, + self.warehouse_relaxed, + self.product, + self.operator, + ) = create_basic_fixtures() + + def test_line_amount_rounds_half_up_to_integer(self): + self.assertEqual(calculate_line_amount(quantity='1', price='1.49'), Decimal('1')) + self.assertEqual(calculate_line_amount(quantity='1', price='1.50'), Decimal('2')) + self.assertEqual(calculate_line_amount(quantity='1.5', price='1.5'), Decimal('2')) + self.assertEqual(calculate_line_amount(quantity='1', price='2.5'), Decimal('3')) + + def test_order_total_sums_rounded_line_amounts(self): + order = services.create_purchase_order( + merchant=self.merchant, + supplier=self.supplier, + order_date=timezone.now().date(), + warehouse=self.warehouse_relaxed, + operator=self.operator, + items=[ + {'product_id': self.product.id, 'quantity': '1', 'num_of_rolls': 1, 'price': '1.5', 'unit': '米'}, + {'product_id': self.product.id, 'quantity': '1', 'num_of_rolls': 1, 'price': '1.5', 'unit': '米'}, + ], + ) + + self.assertEqual([item.total_amount() for item in order.items.order_by('id')], [Decimal('2'), Decimal('2')]) + self.assertEqual(order.get_total_amount(), Decimal('4')) + + def test_payment_and_receipt_amounts_are_normalized_on_create(self): + payment = services.create_payment_order( + merchant=self.merchant, + supplier=self.supplier, + payment_date=timezone.now().date(), + amount='120.50', + discount_amount='20.49', + operator=self.operator, + ) + self.assertEqual(payment.amount, Decimal('121')) + self.assertEqual(payment.discount_amount, Decimal('20')) + self.assertEqual(payment.settlement_amount, Decimal('141')) + + customer = basic_models.Customer.objects.create( + merchant=self.merchant, + name='金额测试客户', + created_by=None, + ) + receipt = services.create_receipt_order( + merchant=self.merchant, + customer=customer, + receipt_date=timezone.now().date(), + amount='80.49', + discount_amount='5.50', + operator=self.operator, + ) + self.assertEqual(receipt.amount, Decimal('80')) + self.assertEqual(receipt.discount_amount, Decimal('6')) + self.assertEqual(receipt.settlement_amount, Decimal('86')) + + def test_normalize_money_amount_rounds_negative_half_away_from_zero(self): + self.assertEqual(normalize_money_amount('-25.50'), Decimal('-26')) + + @override_settings(BUSINESS_AMOUNT_MODE='standard_decimal_2') + def test_standard_decimal_mode_preserves_original_precision(self): + self.assertEqual( + calculate_line_amount(quantity='10', standard_quantity='9.50', price='12.345'), + Decimal('117.28'), + ) + + payment = services.create_payment_order( + merchant=self.merchant, + supplier=self.supplier, + payment_date=timezone.now().date(), + amount='120.50', + discount_amount='20.49', + operator=self.operator, + ) + self.assertEqual(payment.amount, Decimal('120.50')) + self.assertEqual(payment.discount_amount, Decimal('20.49')) + self.assertEqual(payment.settlement_amount, Decimal('140.99')) + + @override_settings(BUSINESS_AMOUNT_MODE='unknown_mode') + def test_invalid_amount_mode_fails_fast(self): + with self.assertRaisesMessage(ValueError, 'BUSINESS_AMOUNT_MODE 不合法'): + calculate_line_amount(quantity='1', price='1') diff --git a/business/tests/test_payment_receipt.py b/business/tests/test_payment_receipt.py index 3cbf133..1fffe14 100644 --- a/business/tests/test_payment_receipt.py +++ b/business/tests/test_payment_receipt.py @@ -48,8 +48,9 @@ class PaymentReceiptServiceTestCase(TestCase): self.assertEqual(order.status, business_models.PaymentOrderStatusEnum.PENDING) self.assertEqual(order.bank_account, self.bank_account) self.assertEqual(order.markup, '服务层附言') - self.assertEqual(order.discount_amount, Decimal('20.50')) - self.assertEqual(order.settlement_amount, Decimal('141.00')) + self.assertEqual(order.amount, Decimal('121')) + self.assertEqual(order.discount_amount, Decimal('21')) + self.assertEqual(order.settlement_amount, Decimal('142')) reviewed = services.review_payment_order( payment_order=order, target_status=business_models.PaymentOrderStatusEnum.APPROVED, @@ -60,14 +61,14 @@ class PaymentReceiptServiceTestCase(TestCase): merchant=self.merchant, supplier=self.supplier, ) - self.assertEqual(balance.balance, Decimal('-141.00')) + self.assertEqual(balance.balance, Decimal('-142')) record = business_models.BalanceChangeRecord.objects.get( merchant=self.merchant, source_type=business_models.BalanceChangeSourceEnum.PAYMENT_ORDER, source_id=order.id, ) self.assertEqual(record.direction, business_models.BalanceChangeDirectionEnum.DECREASE) - self.assertEqual(record.delta, Decimal('-141.00')) + self.assertEqual(record.delta, Decimal('-142')) self.assertEqual(record.balance_after, balance.balance) with self.assertRaises(ValueError): services.review_payment_order( @@ -113,7 +114,7 @@ class PaymentReceiptServiceTestCase(TestCase): amount='-25.50', operator=self.operator, ) - self.assertEqual(order.amount, Decimal('-25.50')) + self.assertEqual(order.amount, Decimal('-26')) def test_payment_discount_can_exceed_amount(self): order = services.create_payment_order( diff --git a/docs/business_amount_mode_design_2026-07-01.md b/docs/business_amount_mode_design_2026-07-01.md new file mode 100644 index 0000000..b773275 --- /dev/null +++ b/docs/business_amount_mode_design_2026-07-01.md @@ -0,0 +1,131 @@ +# Business 金额计算口径设计说明 + +日期:2026-07-01 + +## 背景 + +Business 模块中存在采购、销售、退货、收款、付款等单据。当前项目原本按较高精度计算;为兼容好布业遗留系统,需要支持其整数元金额口径。 + +本次实现不保存两套金额,不新增字段,不迁移历史数据。系统在任意时刻只启用一种计算口径,口径由配置项控制。 + +## 目标 + +- 将金额规则集中在一个入口,后续如果会议调整规则,修改范围尽量收窄。 +- 默认启用好布业整数元口径。 +- 保留回退到原两位小数口径的能力。 +- 采购、销售、采购退货、销售退货、收款、付款保持一致。 +- 覆盖外部财务同步直接创建收款单的路径。 + +## 配置项 + +配置项名称: + +```env +BUSINESS_AMOUNT_MODE=haobuye_integer_round_half_up +``` + +可选值: + +- `haobuye_integer_round_half_up`:好布业整数元口径,默认值。 +- `standard_decimal_2`:原系统两位小数口径。 + +配置读取位置: + +- `flower/settings.py` +- 示例配置:`env.example` + +配置变更后需要重启会执行金额计算的进程,至少包括 Web 容器和 worker 容器。 + +## 好布业整数元口径 + +单据明细行: + +```text +行金额 = ROUND_HALF_UP(quantity * price, 0) +``` + +单据总额: + +```text +单据总额 = SUM(每一行取整后的行金额) +``` + +这里的“多行”对应 DataGrid 形式的一张单据内有多条明细:先逐行计算并取整,再对取整后的行金额求和。 + +收款单、付款单没有明细行,按同一口径归一化金额: + +```text +amount = ROUND_HALF_UP(amount, 0) +discount_amount = ROUND_HALF_UP(discount_amount, 0) +settlement_amount = ROUND_HALF_UP(amount, 0) + ROUND_HALF_UP(discount_amount, 0) +``` + +## 原两位小数口径 + +单据明细行: + +```text +行金额 = ROUND_HALF_UP(real_quantity * price, 2) +``` + +单据总额: + +```text +单据总额 = SUM(每一行两位小数行金额) +``` + +收款单、付款单: + +```text +amount = ROUND_HALF_UP(amount, 2) +discount_amount = ROUND_HALF_UP(discount_amount, 2) +settlement_amount = amount + discount_amount +``` + +## 涉及范围 + +业务单据: + +- `PurchaseOrder` +- `SalesOrder` +- `PurchaseReturnOrder` +- `SalesReturnOrder` +- `PaymentOrder` +- `ReceiptOrder` + +创建入口: + +- Business 服务层创建收款单、付款单时会归一化金额。 +- 好布业外部财务同步创建收款单时会归一化金额。 +- 明细类单据通过模型明细行的统一计算入口计算行金额和单据总额。 + +## 不做的事 + +- 不新增数据库字段。 +- 不新增 migration。 +- 不回算历史数据。 +- 不自动修复已经生成的资金流水。 +- 不同时保存整数元金额和两位小数金额。 + +## 运行影响 + +该变更主要影响未来创建或后续计算的业务金额。已经审批并生成的余额流水不会自动变化。 + +如果存在未审批单据,审批时会按当前配置计算并写入流水。配置切换前后,需要避免同一批未审批单据跨口径处理。 + +## 回退方式 + +如需恢复原计算模式: + +```env +BUSINESS_AMOUNT_MODE=standard_decimal_2 +``` + +修改配置后重启 Web 和 worker 进程即可。由于本次没有数据库结构变更,也不回算历史数据,回退动作可以较快完成。 + +## 风险与注意事项 + +- 口径切换会改变未审批单据后续审批时的金额。 +- 对于没有保存快照、运行时重新计算总额的展示接口,切换配置后展示金额可能按新口径变化。 +- 收款、付款没有明细,已按同一配置归一化金额,以保证和明细单据一致。 +- 如果以后好布业规则再次调整,应优先修改集中计算入口,而不是分散修改各类单据。 diff --git a/env.example b/env.example index b3070f5..bde9bc8 100644 --- a/env.example +++ b/env.example @@ -90,6 +90,13 @@ HAOBUYE_API_TIMEOUT_SECONDS=30 # 可选:作为外部财务同步默认经办人(Employee.id) HAOBUYE_FINANCE_SYNC_OPERATOR_ID=0 +############################ +# Business 金额计算口径 +############################ +# haobuye_integer_round_half_up: 好布业整数元口径(默认) +# standard_decimal_2: 原系统两位小数口径 +BUSINESS_AMOUNT_MODE=haobuye_integer_round_half_up + # PrintingJob 状态推进通知的“跟进地址”模板;为空则消息里省略“跟进地址”字段 PRINTING_JOB_STATE_ADVANCED_FOLLOWUP_URL_TEMPLATE=https://app.yuwen.cloud/workstation/production/batch-advance?orderId={order_id} @@ -126,4 +133,3 @@ CELERY_RESULT_DB=1 ############################ # 逗号分隔 CORS_ALLOWED_ORIGINS=http://localhost:8000,http://127.0.0.1:5173,http://127.0.0.1:5174,http://127.0.0.1:5179,https://yuwenerp.yuwen.cloud - diff --git a/flower/settings.py b/flower/settings.py index c017f25..ad8ba71 100644 --- a/flower/settings.py +++ b/flower/settings.py @@ -106,6 +106,11 @@ HAOBUYE_API_AUTHORIZATION = env('HAOBUYE_API_AUTHORIZATION', default='your-fixed HAOBUYE_API_TIMEOUT_SECONDS = env.float('HAOBUYE_API_TIMEOUT_SECONDS', default=30.0) HAOBUYE_FINANCE_SYNC_OPERATOR_ID = env.int('HAOBUYE_FINANCE_SYNC_OPERATOR_ID', default=6) +# Business 金额计算口径 +# - haobuye_integer_round_half_up: 行金额=ROUND_HALF_UP(quantity * price, 0),收/付款金额取整数元 +# - standard_decimal_2: 原系统口径,行金额=ROUND_HALF_UP(real_quantity * price, 2),收/付款金额保留两位 +BUSINESS_AMOUNT_MODE = env('BUSINESS_AMOUNT_MODE', default='haobuye_integer_round_half_up') + # 定时财务同步客户列表(临时需求,直接写死不走 env) FINANCE_SYNC_CUSTOMER_NAMES: list[str] = [ '曾念', '紫琪', '胡肖宇', '歌斯拉-胜利星厂', '胡鼎', diff --git a/mes/migrations/0004_production_assignment_device_nullable.py b/mes/migrations/0004_production_assignment_device_nullable.py new file mode 100644 index 0000000..a2e5c67 --- /dev/null +++ b/mes/migrations/0004_production_assignment_device_nullable.py @@ -0,0 +1,19 @@ +# Generated by Django 5.2.8 on 2026-07-01 06:55 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('mes', '0003_productionassignment_production_quantity_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='productionassignment', + name='device', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='production_assignments', to='mes.device', verbose_name='设备'), + ), + ] diff --git a/mes/models.py b/mes/models.py index 13a3efb..18236f7 100644 --- a/mes/models.py +++ b/mes/models.py @@ -128,6 +128,8 @@ class ProductionAssignment(ModelBase): Device, on_delete=models.PROTECT, related_name='production_assignments', + null=True, + blank=True, verbose_name='设备', ) content_type = models.ForeignKey( diff --git a/mes/services.py b/mes/services.py index f8517a4..ff52b31 100644 --- a/mes/services.py +++ b/mes/services.py @@ -244,14 +244,15 @@ def get_production_assignment_for_merchant(*, merchant, assignment_id: int) -> P @transaction.atomic -def create_production_assignment(*, merchant, device: Device, content_type: ContentType, object_id: int, +def create_production_assignment(*, merchant, device: Device | None = None, + 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: + if device is not None and device.merchant_id != merchant.id: raise ValueError('设备不属于当前商户') _validate_content_object_merchant(content_type=content_type, object_id=object_id, merchant=merchant) assignment = ProductionAssignment( @@ -277,7 +278,7 @@ def update_production_assignment(*, assignment: ProductionAssignment, operator, _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: + if device is not None and device.merchant_id != assignment.merchant_id: raise ValueError('设备不属于当前商户') assignment.device = device if assignee is not UNSET: diff --git a/mes/tests.py b/mes/tests.py index 400753a..c6ebd53 100644 --- a/mes/tests.py +++ b/mes/tests.py @@ -366,3 +366,82 @@ class MesServiceTestCase(TestCase): operator=self.operator, status=ProductionAssignmentStatusEnum.CANCELLED, ) + + def test_create_production_assignment_without_device(self): + customer = Customer.objects.create( + merchant=self.merchant, + name='无设备客户', + mobile='13800138002', + ) + printing_order = PrintingOrder.objects.create( + merchant=self.merchant, + customer=customer, + fabric='无设备面料', + width='150cm', + created_by=self.user, + ) + content_type = ContentType.objects.get_for_model(PrintingOrder) + + assignment = services.create_production_assignment( + merchant=self.merchant, + device=None, + content_type=content_type, + object_id=printing_order.id, + assigner=self.operator, + production_quantity=100, + created_by=self.user, + operator=self.operator, + ) + + self.assertIsNone(assignment.device) + self.assertIsNone(assignment.device_id) + self.assertEqual(assignment.status, ProductionAssignmentStatusEnum.DRAFT) + self.assertEqual(assignment.production_quantity, 100) + + def test_update_production_assignment_clear_device(self): + customer = Customer.objects.create( + merchant=self.merchant, + name='清空设备客户', + mobile='13800138003', + ) + 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=200, + created_by=self.user, + operator=self.operator, + ) + self.assertIsNotNone(assignment.device) + + updated = services.update_production_assignment( + assignment=assignment, + operator=self.operator, + device=None, + ) + self.assertIsNone(updated.device) + self.assertIsNone(updated.device_id)