diff --git a/backend/app/api/pl/public_io_order.py b/backend/app/api/pl/public_io_order.py index 4c4a30b00..e930af6c3 100644 --- a/backend/app/api/pl/public_io_order.py +++ b/backend/app/api/pl/public_io_order.py @@ -93,6 +93,7 @@ def _make_handlers(kind: str): item_qty=qty_map, remark=payload.remark, images=images, + status=payload.status, ) except ValueError as exc: return error_response(400, str(exc)) diff --git a/backend/app/schemas/pl/io_order.py b/backend/app/schemas/pl/io_order.py index 129778951..28867b772 100644 --- a/backend/app/schemas/pl/io_order.py +++ b/backend/app/schemas/pl/io_order.py @@ -92,6 +92,7 @@ class PublicIoOrderItemFeedback(BaseModel): class PublicIoOrderFeedback(BaseModel): + status: str remark: Optional[str] = None images: list[str] = Field(default_factory=list) items: list[PublicIoOrderItemFeedback] = Field(default_factory=list) diff --git a/backend/app/services/pl_io_order_service.py b/backend/app/services/pl_io_order_service.py index 651653a5b..e0dedb9e2 100644 --- a/backend/app/services/pl_io_order_service.py +++ b/backend/app/services/pl_io_order_service.py @@ -304,6 +304,9 @@ def dump_feedback_images(images: list[str]) -> str: return json.dumps(images, ensure_ascii=False) +_VALID_SITE_FEEDBACK_STATUSES = frozenset({"in_progress", "completed", "voided"}) + + def apply_site_feedback( db: Session, *, @@ -312,10 +315,13 @@ def apply_site_feedback( item_qty: dict[str, Decimal], remark: Optional[str], images: list[str], + status: str, user_id: Optional[str] = None, ) -> None: if header.status == "voided": raise ValueError("单据已作废,无法提交") + if status not in _VALID_SITE_FEEDBACK_STATUSES: + raise ValueError(f"无效状态: {status}") now = datetime.now() for item in items: if item.id not in item_qty: @@ -328,8 +334,7 @@ def apply_site_feedback( header.site_feedback_time = now if not header.site_first_submitted_at: header.site_first_submitted_at = now - if header.status == "in_progress": - header.status = "completed" + header.status = status header.update_by = user_id db.flush() diff --git a/backend/tests/test_pl_io_order_site_feedback_status.py b/backend/tests/test_pl_io_order_site_feedback_status.py new file mode 100644 index 000000000..6c48854f8 --- /dev/null +++ b/backend/tests/test_pl_io_order_site_feedback_status.py @@ -0,0 +1,127 @@ +from datetime import datetime +from decimal import Decimal +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest + +from app.services.pl_io_order_service import apply_site_feedback + + +def _header(**kwargs): + defaults = dict( + status="in_progress", + site_feedback_remark=None, + site_feedback_images=None, + site_feedback_time=None, + site_first_submitted_at=None, + update_by=None, + ) + defaults.update(kwargs) + return SimpleNamespace(**defaults) + + +def _item(item_id="item-1"): + return SimpleNamespace( + id=item_id, + actual_qty=None, + qty_source=None, + update_by=None, + ) + + +def test_apply_site_feedback_sets_status_completed(): + header = _header() + item = _item() + db = MagicMock() + apply_site_feedback( + db, + header=header, + items=[item], + item_qty={"item-1": Decimal("12.5")}, + remark="ok", + images=["a.jpg"], + status="completed", + ) + assert header.status == "completed" + assert item.actual_qty == Decimal("12.5") + assert header.site_first_submitted_at is not None + assert header.site_feedback_remark == "ok" + db.flush.assert_called_once() + + +def test_apply_site_feedback_sets_status_voided(): + header = _header() + apply_site_feedback( + MagicMock(), + header=header, + items=[_item()], + item_qty={"item-1": Decimal("1")}, + remark=None, + images=[], + status="voided", + ) + assert header.status == "voided" + + +def test_apply_site_feedback_keeps_in_progress_when_requested(): + """首次提交选进行中时,不得隐式改成已完成。""" + header = _header(status="in_progress") + apply_site_feedback( + MagicMock(), + header=header, + items=[_item()], + item_qty={"item-1": Decimal("1")}, + remark=None, + images=[], + status="in_progress", + ) + assert header.status == "in_progress" + assert header.site_first_submitted_at is not None + + +def test_apply_site_feedback_rejects_invalid_status(): + header = _header() + with pytest.raises(ValueError, match="无效状态"): + apply_site_feedback( + MagicMock(), + header=header, + items=[_item()], + item_qty={}, + remark=None, + images=[], + status="done", + ) + + +def test_apply_site_feedback_rejects_already_voided(): + header = _header(status="voided") + with pytest.raises(ValueError, match="单据已作废"): + apply_site_feedback( + MagicMock(), + header=header, + items=[_item()], + item_qty={}, + remark=None, + images=[], + status="completed", + ) + + +def test_apply_site_feedback_does_not_reset_first_submitted_at(): + first = datetime(2026, 1, 1, 12, 0, 0) + header = _header( + status="completed", + site_first_submitted_at=first, + ) + apply_site_feedback( + MagicMock(), + header=header, + items=[_item()], + item_qty={"item-1": Decimal("2")}, + remark="二改", + images=[], + status="in_progress", + ) + assert header.status == "in_progress" + assert header.site_first_submitted_at == first