Support multiple expected delivery dates

This commit is contained in:
lizerk 2026-06-01 22:13:40 +03:00
parent 0338580bfb
commit a794ff40c8
9 changed files with 370 additions and 41 deletions

View File

@ -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):
"""Сериализатор для редактирования позиций внутри заявки."""

View File

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

View File

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

View File

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

View File

@ -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(
"Фактическая цена за единицу",

View File

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

View File

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

View File

@ -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<ItemProcessingDraft>(() => 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({
</p>
</div>
<div className="min-w-0">
<p className="app-text-muted text-[11px]">Дата поступления</p>
<p className="app-text-wrap mt-0.5 font-medium text-[var(--foreground)]">
{formatDate(item.expected_delivery_date) || "—"}
</p>
<p className="app-text-muted text-[11px]">Ожидаемые даты</p>
{expectedDeliveryDates.length > 0 ? (
<div className="mt-1 flex flex-wrap gap-1">
{expectedDeliveryDates.map((date, dateIndex) => (
<span key={`${item.id}-date-${dateIndex}`} className="app-badge px-2 py-0.5 text-[11px] font-medium">
{formatDate(date)}
</span>
))}
</div>
) : (
<p className="app-text-wrap mt-0.5 font-medium text-[var(--foreground)]">—</p>
)}
</div>
<div className="min-w-0">
<p className="app-text-muted text-[11px]">Заказано</p>
@ -448,13 +467,44 @@ function ProcurementItemCard({
</select>
</div>
<div>
<label className="app-text-muted mb-1 block text-[11px] font-medium">Ожидаемая дата</label>
<input
type="date"
value={draft.expected_delivery_date}
onChange={(event) => updateDraft({ expected_delivery_date: event.target.value })}
className="app-input w-full rounded-lg px-3 py-2 text-xs"
/>
<div className="mb-1 flex items-center justify-between gap-2">
<label className="app-text-muted block text-[11px] font-medium">Ожидаемые даты</label>
<button
type="button"
onClick={() => updateDraft({ expected_delivery_dates: ["", ...draft.expected_delivery_dates] })}
className="app-link-accent inline-flex items-center gap-1 text-[11px] font-medium"
>
<Plus size={12} /> Добавить дату
</button>
</div>
<div className="space-y-2">
{draft.expected_delivery_dates.map((date, dateIndex) => (
<div key={dateIndex} className="flex items-center gap-2">
<input
type="date"
value={date}
onChange={(event) => 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 ? (
<button
type="button"
onClick={() => updateDraft({
expected_delivery_dates: draft.expected_delivery_dates.filter((_, currentIndex) => currentIndex !== dateIndex),
})}
className="app-action-secondary inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg"
title="Удалить дату"
>
<X size={12} />
</button>
) : null}
</div>
))}
</div>
</div>
<div>
<label className="app-text-muted mb-1 block text-[11px] font-medium">Фактическая цена/ед.</label>
@ -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 ?? "",

View File

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