diff --git a/backend/api/v1/procurement/serializers.py b/backend/api/v1/procurement/serializers.py index 4173ba32..123af356 100644 --- a/backend/api/v1/procurement/serializers.py +++ b/backend/api/v1/procurement/serializers.py @@ -2,6 +2,9 @@ Сериализаторы для модуля закупок. """ +import re +from datetime import date + from drf_spectacular.utils import extend_schema_field from django.db import transaction from rest_framework import serializers @@ -33,6 +36,45 @@ PROBLEM_ITEM_STATUSES = { ProcurementItemExecutionStatus.DEFECTIVE, } +DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$") + + +def _validate_expected_delivery_dates(value): + if value in (None, ""): + return [] + if not isinstance(value, list): + raise serializers.ValidationError("Ожидаемые даты должны быть списком.") + + result = [] + for raw in value: + item = str(raw or "").strip() + if not item: + continue + if not DATE_RE.fullmatch(item): + raise serializers.ValidationError( + "Каждая ожидаемая дата должна быть в формате YYYY-MM-DD." + ) + try: + parsed = date.fromisoformat(item) + except ValueError as exc: + raise serializers.ValidationError( + "Каждая ожидаемая дата должна быть корректной календарной датой." + ) from exc + result.append(parsed.isoformat()) + return result + + +def _expected_delivery_date_values(item): + dates = [] + for raw in item.expected_delivery_dates or []: + if not isinstance(raw, str) or not DATE_RE.fullmatch(raw): + continue + try: + dates.append(date.fromisoformat(raw)) + except ValueError: + continue + return dates + def _validate_item_quantities(attrs, instance=None): quantity = attrs.get("quantity", getattr(instance, "quantity", None)) @@ -69,20 +111,29 @@ class ProcurementRequestSummaryMixin(serializers.Serializer): @extend_schema_field(serializers.DateField(allow_null=True)) def get_next_expected_delivery_date(self, obj): - dated_items = [ - item for item in _request_items(obj) - if item.expected_delivery_date - ] - if not dated_items: + items = _request_items(obj) + all_dates = [] + pending_dates = [] + all_items_received = all( + item.execution_status == ProcurementItemExecutionStatus.RECEIVED + for item in items + ) + for item in items: + item_dates = _expected_delivery_date_values(item) + if not item_dates: + continue + all_dates.extend(item_dates) + if item.execution_status != ProcurementItemExecutionStatus.RECEIVED: + pending_dates.extend(item_dates) + + if not all_dates: return None - pending_dates = [ - item.expected_delivery_date for item in dated_items - if item.execution_status != ProcurementItemExecutionStatus.RECEIVED - ] if pending_dates: return min(pending_dates).isoformat() - return max(item.expected_delivery_date for item in dated_items).isoformat() + if all_items_received: + return max(all_dates).isoformat() + return None @extend_schema_field(serializers.IntegerField()) def get_items_total_count(self, obj): @@ -141,6 +192,11 @@ class ProcurementItemSerializer(serializers.ModelSerializer): allow_null=True, min_value=0, ) + expected_delivery_dates = serializers.ListField( + child=serializers.CharField(allow_blank=False, trim_whitespace=True), + required=False, + allow_empty=True, + ) initial_comment = serializers.CharField( write_only=True, required=False, @@ -181,7 +237,7 @@ class ProcurementItemSerializer(serializers.ModelSerializer): "received_quantity", "supplier_info", "links", - "expected_delivery_date", + "expected_delivery_dates", "actual_unit_price", "execution_status", "execution_status_display", @@ -202,6 +258,9 @@ class ProcurementItemSerializer(serializers.ModelSerializer): def validate(self, attrs): return _validate_item_quantities(attrs, self.instance) + def validate_expected_delivery_dates(self, value): + return _validate_expected_delivery_dates(value) + def create(self, validated_data): initial_comment = validated_data.pop("initial_comment", "").strip() item = super().create(validated_data) @@ -235,6 +294,11 @@ class ProcurementItemCreateSerializer(serializers.ModelSerializer): allow_null=True, min_value=0, ) + expected_delivery_dates = serializers.ListField( + child=serializers.CharField(allow_blank=False, trim_whitespace=True), + required=False, + allow_empty=True, + ) initial_comment = serializers.CharField( write_only=True, required=False, @@ -253,7 +317,7 @@ class ProcurementItemCreateSerializer(serializers.ModelSerializer): "received_quantity", "supplier_info", "links", - "expected_delivery_date", + "expected_delivery_dates", "actual_unit_price", "execution_status", "executor_comment", @@ -263,6 +327,9 @@ class ProcurementItemCreateSerializer(serializers.ModelSerializer): def validate(self, attrs): return _validate_item_quantities(attrs, self.instance) + def validate_expected_delivery_dates(self, value): + return _validate_expected_delivery_dates(value) + class ProcurementItemEditSerializer(ProcurementItemCreateSerializer): """Сериализатор для редактирования позиций внутри заявки.""" diff --git a/backend/api/v1/procurement/views.py b/backend/api/v1/procurement/views.py index 23c6e613..23060c99 100644 --- a/backend/api/v1/procurement/views.py +++ b/backend/api/v1/procurement/views.py @@ -836,7 +836,7 @@ class ProcurementItemViewSet(viewsets.ModelViewSet): "ordered_quantity", "received_quantity", "links", - "expected_delivery_date", + "expected_delivery_dates", "actual_unit_price", "execution_status", "executor_comment", diff --git a/backend/procurement/admin.py b/backend/procurement/admin.py index 16e6ce54..50c8000f 100644 --- a/backend/procurement/admin.py +++ b/backend/procurement/admin.py @@ -30,7 +30,7 @@ class ProcurementItemInline(admin.TabularInline): "ordered_quantity", "received_quantity", "execution_status", - "expected_delivery_date", + "expected_delivery_dates", "supplier_info", ] @@ -188,7 +188,7 @@ class ProcurementItemAdmin(admin.ModelAdmin): "ordered_quantity", "received_quantity", "execution_status", - "expected_delivery_date", + "expected_delivery_dates", "total_price", ] list_filter = ["unit", "execution_status", "created_at"] diff --git a/backend/procurement/migrations/0012_expected_delivery_dates.py b/backend/procurement/migrations/0012_expected_delivery_dates.py new file mode 100644 index 00000000..4ea188d4 --- /dev/null +++ b/backend/procurement/migrations/0012_expected_delivery_dates.py @@ -0,0 +1,46 @@ +# Generated by Codex on 2026-06-01 + +from django.db import migrations, models + + +def forwards(apps, schema_editor): + ProcurementItem = apps.get_model("procurement", "ProcurementItem") + for item in ProcurementItem.objects.all().iterator(): + if item.expected_delivery_date: + item.expected_delivery_dates = [item.expected_delivery_date.isoformat()] + else: + item.expected_delivery_dates = [] + item.save(update_fields=["expected_delivery_dates"]) + + +def backwards(apps, schema_editor): + ProcurementItem = apps.get_model("procurement", "ProcurementItem") + for item in ProcurementItem.objects.all().iterator(): + dates = item.expected_delivery_dates or [] + item.expected_delivery_date = dates[0] if dates else None + item.save(update_fields=["expected_delivery_date"]) + + +class Migration(migrations.Migration): + + dependencies = [ + ("procurement", "0011_item_quantities_defective_optional_price"), + ] + + operations = [ + migrations.AddField( + model_name="procurementitem", + name="expected_delivery_dates", + field=models.JSONField( + blank=True, + default=list, + help_text="Список ожидаемых дат поступления в формате YYYY-MM-DD", + verbose_name="Ожидаемые даты поступления", + ), + ), + migrations.RunPython(forwards, backwards), + migrations.RemoveField( + model_name="procurementitem", + name="expected_delivery_date", + ), + ] diff --git a/backend/procurement/models.py b/backend/procurement/models.py index bff21599..482df843 100644 --- a/backend/procurement/models.py +++ b/backend/procurement/models.py @@ -289,10 +289,11 @@ class ProcurementItem(models.Model): blank=True, help_text="Список ссылок на позицию", ) - expected_delivery_date = models.DateField( - "Ожидаемая дата поступления", - null=True, + expected_delivery_dates = models.JSONField( + "Ожидаемые даты поступления", + default=list, blank=True, + help_text="Список ожидаемых дат поступления в формате YYYY-MM-DD", ) actual_unit_price = models.DecimalField( "Фактическая цена за единицу", diff --git a/backend/tests/api/v1/procurement/test_procurement_requests.py b/backend/tests/api/v1/procurement/test_procurement_requests.py index d62fbce2..4a92f85f 100644 --- a/backend/tests/api/v1/procurement/test_procurement_requests.py +++ b/backend/tests/api/v1/procurement/test_procurement_requests.py @@ -294,6 +294,7 @@ class TestProcurementRequestCreate: 'unit': 'шт', 'estimated_unit_price': '25000.00', 'description': '4K, IPS', + 'expected_delivery_dates': ['2026-06-10', '2026-06-12'], }, { 'name': 'Монитор 24"', @@ -315,6 +316,10 @@ class TestProcurementRequestCreate: request = ProcurementRequest.objects.get(id=request_id) assert request.items.count() == 2 assert request.total_cost == Decimal('305000.00') # 5*25k + 10*18k + assert request.items.get(name='Монитор 27"').expected_delivery_dates == [ + '2026-06-10', + '2026-06-12', + ] def test_create_request_for_other_department( self, api_client, user, db @@ -1595,13 +1600,15 @@ class TestProcessingDepartmentWorkflow: { 'execution_status': ProcurementItemExecutionStatus.ORDERED, 'actual_unit_price': '120.00', - 'expected_delivery_date': '2026-05-25', + 'expected_delivery_dates': ['2026-05-25', '2026-05-29'], 'links': ['https://example.com/item'], }, format='json', ) assert response.status_code == status.HTTP_200_OK + assert response.data["expected_delivery_dates"] == ["2026-05-25", "2026-05-29"] + assert "expected_delivery_date" not in response.data item_notifications = [ item for item in sent if item["verb"] == "procurement_item_updated" @@ -1612,11 +1619,63 @@ class TestProcessingDepartmentWorkflow: assert sorted(item_notifications[0]["data"]["changed_fields"]) == [ "actual_unit_price", "execution_status", - "expected_delivery_date", + "expected_delivery_dates", "links", "ordered_quantity", ] + def test_item_expected_delivery_dates_must_be_valid_dates( + self, api_client, supply_user, processing_request, + procurement_item_factory + ): + item = procurement_item_factory(request=processing_request) + processing_request.executor = supply_user + processing_request.status = ProcurementStatus.IN_PROGRESS + processing_request.save() + + api_client.force_authenticate(user=supply_user) + item_url = reverse( + 'api:v1:procurement:procurementitem-detail', + kwargs={'pk': item.id}, + ) + + response = api_client.patch( + item_url, + {'expected_delivery_dates': ['2026-02-31']}, + format='json', + ) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + + def test_item_expected_delivery_dates_accepts_empty_list( + self, api_client, supply_user, processing_request, + procurement_item_factory + ): + item = procurement_item_factory( + request=processing_request, + expected_delivery_dates=["2026-05-25"], + ) + processing_request.executor = supply_user + processing_request.status = ProcurementStatus.IN_PROGRESS + processing_request.save() + + api_client.force_authenticate(user=supply_user) + item_url = reverse( + 'api:v1:procurement:procurementitem-detail', + kwargs={'pk': item.id}, + ) + + response = api_client.patch( + item_url, + {'expected_delivery_dates': []}, + format='json', + ) + + assert response.status_code == status.HTTP_200_OK + assert response.data["expected_delivery_dates"] == [] + item.refresh_from_db() + assert item.expected_delivery_dates == [] + def test_item_quantities_cannot_exceed_requested_quantity( self, api_client, supply_user, processing_request, procurement_item_factory @@ -2124,7 +2183,7 @@ class TestProcessingDepartmentWorkflow: execution_status=ProcurementItemExecutionStatus.RECEIVED, ordered_quantity=5, received_quantity=3, - expected_delivery_date="2026-05-28", + expected_delivery_dates=["2026-05-28"], ) procurement_item_factory( request=processing_request, @@ -2132,7 +2191,7 @@ class TestProcessingDepartmentWorkflow: quantity=2, execution_status=ProcurementItemExecutionStatus.DEFECTIVE, ordered_quantity=1, - expected_delivery_date="2026-05-27", + expected_delivery_dates=["2026-05-31", "2026-05-27"], ) procurement_item_factory( request=processing_request, @@ -2170,7 +2229,7 @@ class TestProcessingDepartmentWorkflow: execution_status=ProcurementItemExecutionStatus.RECEIVED, ordered_quantity=1, received_quantity=1, - expected_delivery_date="2026-05-28", + expected_delivery_dates=["2026-05-28"], ) procurement_item_factory( request=processing_request, @@ -2179,7 +2238,7 @@ class TestProcessingDepartmentWorkflow: execution_status=ProcurementItemExecutionStatus.RECEIVED, ordered_quantity=1, received_quantity=1, - expected_delivery_date="2026-05-30", + expected_delivery_dates=["2026-05-30", "2026-06-01"], ) api_client.force_authenticate(user=supply_user) url = reverse('api:v1:procurement:procurementrequest-list') @@ -2191,7 +2250,37 @@ class TestProcessingDepartmentWorkflow: item for item in response.data["results"] if item["id"] == processing_request.id ) - assert result["next_expected_delivery_date"] == "2026-05-30" + assert result["next_expected_delivery_date"] == "2026-06-01" + + def test_list_expected_date_is_null_when_pending_items_have_no_dates( + self, api_client, supply_user, processing_request, + procurement_item_factory + ): + procurement_item_factory( + request=processing_request, + quantity=1, + execution_status=ProcurementItemExecutionStatus.RECEIVED, + ordered_quantity=1, + received_quantity=1, + expected_delivery_dates=["2026-05-28"], + ) + procurement_item_factory( + request=processing_request, + name="Без ожидаемой даты", + quantity=1, + execution_status=ProcurementItemExecutionStatus.PENDING, + ) + api_client.force_authenticate(user=supply_user) + url = reverse('api:v1:procurement:procurementrequest-list') + + response = api_client.get(url) + + assert response.status_code == status.HTTP_200_OK + result = next( + item for item in response.data["results"] + if item["id"] == processing_request.id + ) + assert result["next_expected_delivery_date"] is None def test_requestor_item_update_does_not_notify_self( self, api_client, user, processing_request, diff --git a/backend/tests/procurement/test_expected_delivery_dates_migration.py b/backend/tests/procurement/test_expected_delivery_dates_migration.py new file mode 100644 index 00000000..c8967fe2 --- /dev/null +++ b/backend/tests/procurement/test_expected_delivery_dates_migration.py @@ -0,0 +1,76 @@ +from datetime import date + +import pytest +from django.db import connection +from django.db.migrations.executor import MigrationExecutor + + +pytestmark = pytest.mark.django_db(transaction=True) + + +def _migrate_to(target): + executor = MigrationExecutor(connection) + executor.loader.build_graph() + executor.migrate(target) + return executor.loader.project_state(target).apps + + +def _migrate_to_latest(): + executor = MigrationExecutor(connection) + executor.loader.build_graph() + executor.migrate(executor.loader.graph.leaf_nodes()) + + +def test_expected_delivery_date_migrates_to_dates_list(): + try: + old_apps = _migrate_to( + [("procurement", "0011_item_quantities_defective_optional_price")] + ) + Department = old_apps.get_model("employees", "Department") + Employee = old_apps.get_model("employees", "Employee") + ProcurementRequest = old_apps.get_model("procurement", "ProcurementRequest") + ProcurementItem = old_apps.get_model("procurement", "ProcurementItem") + + department = Department.objects.create(name="Migration procurement") + employee = Employee.objects.create( + email="procurement-migration@example.com", + phone_number="+79990001001", + first_name="Migration", + last_name="Procurement", + is_active=True, + email_verified=True, + ) + procurement_request = ProcurementRequest.objects.create( + title="Migration request", + description="Migration request description", + department=department, + requestor=employee, + ) + item_with_date = ProcurementItem.objects.create( + request=procurement_request, + name="With date", + quantity=1, + unit="шт", + expected_delivery_date=date(2026, 5, 25), + ) + item_without_date = ProcurementItem.objects.create( + request=procurement_request, + name="Without date", + quantity=1, + unit="шт", + ) + + new_apps = _migrate_to([("procurement", "0012_expected_delivery_dates")]) + MigratedItem = new_apps.get_model("procurement", "ProcurementItem") + + migrated_with_date = MigratedItem.objects.get(id=item_with_date.id) + migrated_without_date = MigratedItem.objects.get(id=item_without_date.id) + + assert migrated_with_date.expected_delivery_dates == ["2026-05-25"] + assert migrated_without_date.expected_delivery_dates == [] + assert not any( + field.name == "expected_delivery_date" + for field in MigratedItem._meta.get_fields() + ) + finally: + _migrate_to_latest() diff --git a/frontend/src/components/procurement/ProcurementRequestDetailContent.tsx b/frontend/src/components/procurement/ProcurementRequestDetailContent.tsx index f8e7b2bf..48971323 100644 --- a/frontend/src/components/procurement/ProcurementRequestDetailContent.tsx +++ b/frontend/src/components/procurement/ProcurementRequestDetailContent.tsx @@ -39,7 +39,7 @@ interface ProcurementRequestDetailContentProps { type ItemProcessingDraft = { execution_status: ProcurementItemExecutionStatus; - expected_delivery_date: string; + expected_delivery_dates: string[]; actual_unit_price: string; links: string[]; ordered_quantity: string; @@ -81,6 +81,16 @@ const toOptionalInteger = (value: string): number | null => { return Number.isFinite(parsed) ? Math.max(0, Math.trunc(parsed)) : null; }; +const toDateRows = (dates?: string[] | null): string[] => ( + Array.isArray(dates) + ? dates.map((date) => String(date || "").trim()).filter(Boolean) + : [] +); + +const cleanDateRows = (dates: string[]): string[] => ( + dates.map((date) => date.trim()).filter(Boolean) +); + const approvalIconByStatus = (status?: string) => { const normalized = String(status || "").toLowerCase(); if (normalized === "approved") { @@ -102,7 +112,7 @@ const initialsFromName = (name: string) => const normalizeItemDraft = (item: ProcurementItem): ItemProcessingDraft => ({ execution_status: item.execution_status || "pending", - expected_delivery_date: item.expected_delivery_date || "", + expected_delivery_dates: toDateRows(item.expected_delivery_dates), actual_unit_price: item.actual_unit_price ? String(item.actual_unit_price) : "", links: toLinkRows(item.links), ordered_quantity: item.ordered_quantity === null || item.ordered_quantity === undefined ? "" : String(item.ordered_quantity), @@ -167,6 +177,7 @@ function ProcurementItemCard({ const [draft, setDraft] = useState(() => normalizeItemDraft(item)); const [processingOpen, setProcessingOpen] = useState(false); const links = Array.isArray(item.links) ? item.links.filter(Boolean) : []; + const expectedDeliveryDates = toDateRows(item.expected_delivery_dates); const commentsTotal = item.comments_count ?? comments.length; const requestedQuantity = Math.max(0, Math.trunc(toNumber(item.quantity))); const orderedQuantity = item.ordered_quantity ?? 0; @@ -215,7 +226,7 @@ function ProcurementItemCard({ const saveItemProcessing = () => { return onUpdateItem?.(requestId, item.id, { execution_status: draft.execution_status, - expected_delivery_date: draft.expected_delivery_date || null, + expected_delivery_dates: cleanDateRows(draft.expected_delivery_dates), actual_unit_price: draft.actual_unit_price || null, ordered_quantity: toOptionalInteger(draft.ordered_quantity), received_quantity: toOptionalInteger(draft.received_quantity), @@ -251,10 +262,18 @@ function ProcurementItemCard({

-

Дата поступления

-

- {formatDate(item.expected_delivery_date) || "—"} -

+

Ожидаемые даты

+ {expectedDeliveryDates.length > 0 ? ( +
+ {expectedDeliveryDates.map((date, dateIndex) => ( + + {formatDate(date)} + + ))} +
+ ) : ( +

—

+ )}

Заказано

@@ -448,13 +467,44 @@ function ProcurementItemCard({
- - updateDraft({ expected_delivery_date: event.target.value })} - className="app-input w-full rounded-lg px-3 py-2 text-xs" - /> +
+ + +
+
+ {draft.expected_delivery_dates.map((date, dateIndex) => ( +
+ updateDraft({ + expected_delivery_dates: draft.expected_delivery_dates.map((currentDate, currentIndex) => ( + currentIndex === dateIndex ? event.target.value : currentDate + )), + })} + className="app-input min-w-0 flex-1 rounded-lg px-3 py-2 text-xs" + /> + {draft.expected_delivery_dates.length > 1 ? ( + + ) : null} +
+ ))} +
@@ -690,7 +740,7 @@ export function ProcurementRequestDetailContent({ key={[ item.id, item.execution_status || "", - item.expected_delivery_date || "", + Array.isArray(item.expected_delivery_dates) ? item.expected_delivery_dates.join("|") : "", item.actual_unit_price || "", item.ordered_quantity ?? "", item.received_quantity ?? "", diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index d00725c9..60b8bf6f 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -541,7 +541,7 @@ export interface ProcurementItem { total_price?: string | number; supplier_info?: string; links?: string[]; - expected_delivery_date?: string | null; + expected_delivery_dates?: string[]; actual_unit_price?: string | number | null; execution_status?: ProcurementItemExecutionStatus; execution_status_display?: string;