From 4d55420958ae0b3ded4a73aba1460ebde5a5c2d1 Mon Sep 17 00:00:00 2001 From: Misaka_Company Date: Wed, 12 Aug 2026 14:15:51 +0800 Subject: [PATCH] feat(server): add PATCH/DELETE /attachment/box mirroring product box ops Co-Authored-By: Claude --- app/api/v1/attachment.py | 31 ++++++++ app/main.py | 11 +++ app/services/attachment_service.py | 116 +++++++++++++++++++++++++++++ tests/test_attachment_boxing.py | 77 +++++++++++++++++++ 4 files changed, 235 insertions(+) diff --git a/app/api/v1/attachment.py b/app/api/v1/attachment.py index 6f2ab94..8442056 100644 --- a/app/api/v1/attachment.py +++ b/app/api/v1/attachment.py @@ -4,8 +4,11 @@ from sqlalchemy.orm import Session from app.core.database import get_db from app.models.attachment import AttachmentCategory from app.schemas.attachment import ( + AttachmentBoxDeleteResponse, AttachmentBoxSaveRequest, AttachmentBoxSaveResponse, + AttachmentBoxUpdateRequest, + AttachmentBoxUpdateResponse, AttachmentConfigRequest, AttachmentLocationRequest, AttachmentLocationResponse, @@ -15,9 +18,11 @@ from app.schemas.attachment import ( from app.schemas.common import ErrorResponse from app.services.attachment_service import ( configure_attachment, + delete_attachment_box, get_attachment_status, register_attachment_location, save_attachment_box, + update_attachment_box, ) router = APIRouter(tags=["attachment"]) @@ -82,3 +87,29 @@ def post_attachment_box( req: AttachmentBoxSaveRequest, db: Session = Depends(get_db) ): return save_attachment_box(db, req) + + +@router.patch( + "/attachment/box/{item_id}", + response_model=AttachmentBoxUpdateResponse, + responses={ + 400: {"description": "参数非法或超量", "model": ErrorResponse}, + 404: {"description": "明细不存在", "model": ErrorResponse}, + 409: {"description": "同箱同类型重复", "model": ErrorResponse}, + }, +) +def patch_attachment_box( + item_id: int, req: AttachmentBoxUpdateRequest, db: Session = Depends(get_db) +): + return update_attachment_box(db, item_id, req) + + +@router.delete( + "/attachment/box/{item_id}", + response_model=AttachmentBoxDeleteResponse, + responses={404: {"description": "明细不存在", "model": ErrorResponse}}, +) +def delete_attachment_box_endpoint( + item_id: int, db: Session = Depends(get_db) +): + return delete_attachment_box(db, item_id) diff --git a/app/main.py b/app/main.py index 0ebca65..06bb4c4 100644 --- a/app/main.py +++ b/app/main.py @@ -34,6 +34,7 @@ from app.services.box_service import ( ) from app.services.attachment_service import ( AlreadyOffShelfAttachmentError, + AttachmentItemNotFoundError, CategoryHasBoxesError, CategoryNotFoundError, DuplicateAttachmentBoxItemError, @@ -247,6 +248,16 @@ async def duplicate_attachment_box_handler( ) +@app.exception_handler(AttachmentItemNotFoundError) +async def attachment_item_not_found_handler( + request: Request, exc: AttachmentItemNotFoundError +): + return JSONResponse( + status_code=404, + content={"error_code": "BOX_ITEM_NOT_FOUND", "message": "指定装箱明细不存在"}, + ) + + @app.get("/") async def root(): return {"message": "Welcome to CargoTrace API"} diff --git a/app/services/attachment_service.py b/app/services/attachment_service.py index 6e27ca7..75895ed 100644 --- a/app/services/attachment_service.py +++ b/app/services/attachment_service.py @@ -371,3 +371,119 @@ def save_attachment_box(db: Session, req) -> dict: "quantity": req.quantity, "created_at": record.created_at.isoformat(), } + + +def update_attachment_box(db: Session, item_id: int, req) -> dict: + record = ( + db.query(FinishedGoodsBoxAttachmentItem) + .filter(FinishedGoodsBoxAttachmentItem.id == item_id) + .first() + ) + if record is None: + raise AttachmentItemNotFoundError() + + current_box = ( + db.query(FinishedGoodsBox) + .filter(FinishedGoodsBox.id == record.box_id) + .first() + ) + if current_box is None: + raise AttachmentItemNotFoundError() + paichan_no = current_box.paichan_no + + target_box = ( + db.query(FinishedGoodsBox) + .filter( + FinishedGoodsBox.paichan_no == paichan_no, + FinishedGoodsBox.box_no == req.box_no, + ) + .first() + ) + if target_box is None: + target_box = FinishedGoodsBox(paichan_no=paichan_no, box_no=req.box_no) + db.add(target_box) + db.flush() + else: + dup = ( + db.query(FinishedGoodsBoxAttachmentItem) + .filter( + FinishedGoodsBoxAttachmentItem.box_id == target_box.id, + FinishedGoodsBoxAttachmentItem.zongpai_no == record.zongpai_no, + FinishedGoodsBoxAttachmentItem.category_id == record.category_id, + FinishedGoodsBoxAttachmentItem.id != record.id, + ) + .first() + ) + if dup is not None: + raise DuplicateAttachmentBoxItemError(paichan_no, req.box_no) + + item = ( + db.query(FinishedGoodsAttachmentItem) + .filter( + FinishedGoodsAttachmentItem.zongpai_no == record.zongpai_no, + FinishedGoodsAttachmentItem.category_id == record.category_id, + ) + .first() + ) + expected_qty = item.expected_qty if item is not None else 0 + already_boxed = _boxed_for_category( + db, record.zongpai_no, record.category_id, exclude_item_id=record.id + ) + if already_boxed + req.quantity > expected_qty: + raise InvalidQuantityError() + + old_box_id = record.box_id + record.box_id = target_box.id + record.quantity = req.quantity + + if old_box_id != target_box.id: + _cleanup_empty_box(db, old_box_id, exclude_item_id=record.id) + + db.commit() + db.refresh(record) + return { + "box_item_id": record.id, + "paichan_no": paichan_no, + "box_no": req.box_no, + "zongpai_no": record.zongpai_no, + "category_id": record.category_id, + "quantity": int(record.quantity or 0), + "updated_at": record.created_at.isoformat(), + } + + +def delete_attachment_box(db: Session, item_id: int) -> dict: + record = ( + db.query(FinishedGoodsBoxAttachmentItem) + .filter(FinishedGoodsBoxAttachmentItem.id == item_id) + .first() + ) + if record is None: + raise AttachmentItemNotFoundError() + box_id = record.box_id + db.delete(record) + db.flush() + _cleanup_empty_box(db, box_id) + db.commit() + return {"box_item_id": item_id, "deleted": True} + + +def _cleanup_empty_box(db: Session, box_id: int, exclude_item_id: int | None = None) -> None: + """If a box has no product items and no attachment items, delete it.""" + from app.models.finished_goods import FinishedGoodsBoxItem + + product_q = db.query(FinishedGoodsBoxItem.id).filter( + FinishedGoodsBoxItem.box_id == box_id + ) + if product_q.first() is not None: + return + att_q = db.query(FinishedGoodsBoxAttachmentItem.id).filter( + FinishedGoodsBoxAttachmentItem.box_id == box_id + ) + if exclude_item_id is not None: + att_q = att_q.filter(FinishedGoodsBoxAttachmentItem.id != exclude_item_id) + if att_q.first() is not None: + return + box = db.query(FinishedGoodsBox).filter(FinishedGoodsBox.id == box_id).first() + if box is not None: + db.delete(box) diff --git a/tests/test_attachment_boxing.py b/tests/test_attachment_boxing.py index 268ad18..f22679e 100644 --- a/tests/test_attachment_boxing.py +++ b/tests/test_attachment_boxing.py @@ -149,3 +149,80 @@ def test_attachment_box_completion(client: TestClient, db: Session): "/CargoTrace/attachment/status", params={"zongpai_no": "26BW0011"} ).json() assert status["all_complete"] is True + + +def test_attachment_box_update_quantity(client: TestClient, db: Session): + cert_id, _ = _seed(db) + created = client.post( + "/CargoTrace/attachment/box", + json={ + "zongpai_no": "26BW0011", + "category_id": cert_id, + "box_no": 941, + "quantity": 30, + }, + ).json() + item_id = created["box_item_id"] + updated = client.patch( + f"/CargoTrace/attachment/box/{item_id}", + json={"box_no": 941, "quantity": 25}, + ) + assert updated.status_code == 200 + assert updated.json()["quantity"] == 25 + + +def test_attachment_box_update_rejects_over_expected(client: TestClient, db: Session): + cert_id, _ = _seed(db) + client.put( + "/CargoTrace/attachment/config", + json={ + "zongpai_no": "26BW0011", + "determination": "has", + "items": [{"category_id": cert_id, "expected_qty": 80}], + }, + ) + created = client.post( + "/CargoTrace/attachment/box", + json={ + "zongpai_no": "26BW0011", + "category_id": cert_id, + "box_no": 942, + "quantity": 70, + }, + ).json() + over = client.patch( + f"/CargoTrace/attachment/box/{created['box_item_id']}", + json={"box_no": 942, "quantity": 81}, + ) + assert over.status_code == 400 + assert over.json()["error_code"] == "INVALID_QUANTITY" + + +def test_attachment_box_delete_cleans_empty_box(client: TestClient, db: Session): + cert_id, _ = _seed(db) + created = client.post( + "/CargoTrace/attachment/box", + json={ + "zongpai_no": "26BW0011", + "category_id": cert_id, + "box_no": 943, + "quantity": 10, + }, + ).json() + item_id = created["box_item_id"] + deleted = client.delete(f"/CargoTrace/attachment/box/{item_id}") + assert deleted.status_code == 200 + assert deleted.json() == {"box_item_id": item_id, "deleted": True} + + # Box was attachment-only; deleting it should also remove the empty box + # so the same box_no can be recreated for a different总排号. + recreated = client.post( + "/CargoTrace/attachment/box", + json={ + "zongpai_no": "26BW0012", + "category_id": cert_id, + "box_no": 943, + "quantity": 5, + }, + ) + assert recreated.status_code == 200