From a794ff40c8354b8302d510d4cf1ad7172c300f6d Mon Sep 17 00:00:00 2001
From: lizerk
Date: Mon, 1 Jun 2026 22:13:40 +0300
Subject: [PATCH] Support multiple expected delivery dates
---
backend/api/v1/procurement/serializers.py | 91 ++++++++++++++--
backend/api/v1/procurement/views.py | 2 +-
backend/procurement/admin.py | 4 +-
.../0012_expected_delivery_dates.py | 46 ++++++++
backend/procurement/models.py | 7 +-
.../procurement/test_procurement_requests.py | 103 ++++++++++++++++--
.../test_expected_delivery_dates_migration.py | 76 +++++++++++++
.../ProcurementRequestDetailContent.tsx | 80 +++++++++++---
frontend/src/types/api.ts | 2 +-
9 files changed, 370 insertions(+), 41 deletions(-)
create mode 100644 backend/procurement/migrations/0012_expected_delivery_dates.py
create mode 100644 backend/tests/procurement/test_expected_delivery_dates_migration.py
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;