feat(server): add PATCH/DELETE /attachment/box mirroring product box ops

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Misaka_Company
2026-08-12 14:15:51 +08:00
parent 0ef4f0e990
commit 4d55420958
4 changed files with 235 additions and 0 deletions

View File

@@ -4,8 +4,11 @@ from sqlalchemy.orm import Session
from app.core.database import get_db from app.core.database import get_db
from app.models.attachment import AttachmentCategory from app.models.attachment import AttachmentCategory
from app.schemas.attachment import ( from app.schemas.attachment import (
AttachmentBoxDeleteResponse,
AttachmentBoxSaveRequest, AttachmentBoxSaveRequest,
AttachmentBoxSaveResponse, AttachmentBoxSaveResponse,
AttachmentBoxUpdateRequest,
AttachmentBoxUpdateResponse,
AttachmentConfigRequest, AttachmentConfigRequest,
AttachmentLocationRequest, AttachmentLocationRequest,
AttachmentLocationResponse, AttachmentLocationResponse,
@@ -15,9 +18,11 @@ from app.schemas.attachment import (
from app.schemas.common import ErrorResponse from app.schemas.common import ErrorResponse
from app.services.attachment_service import ( from app.services.attachment_service import (
configure_attachment, configure_attachment,
delete_attachment_box,
get_attachment_status, get_attachment_status,
register_attachment_location, register_attachment_location,
save_attachment_box, save_attachment_box,
update_attachment_box,
) )
router = APIRouter(tags=["attachment"]) router = APIRouter(tags=["attachment"])
@@ -82,3 +87,29 @@ def post_attachment_box(
req: AttachmentBoxSaveRequest, db: Session = Depends(get_db) req: AttachmentBoxSaveRequest, db: Session = Depends(get_db)
): ):
return save_attachment_box(db, req) 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)

View File

@@ -34,6 +34,7 @@ from app.services.box_service import (
) )
from app.services.attachment_service import ( from app.services.attachment_service import (
AlreadyOffShelfAttachmentError, AlreadyOffShelfAttachmentError,
AttachmentItemNotFoundError,
CategoryHasBoxesError, CategoryHasBoxesError,
CategoryNotFoundError, CategoryNotFoundError,
DuplicateAttachmentBoxItemError, 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("/") @app.get("/")
async def root(): async def root():
return {"message": "Welcome to CargoTrace API"} return {"message": "Welcome to CargoTrace API"}

View File

@@ -371,3 +371,119 @@ def save_attachment_box(db: Session, req) -> dict:
"quantity": req.quantity, "quantity": req.quantity,
"created_at": record.created_at.isoformat(), "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)

View File

@@ -149,3 +149,80 @@ def test_attachment_box_completion(client: TestClient, db: Session):
"/CargoTrace/attachment/status", params={"zongpai_no": "26BW0011"} "/CargoTrace/attachment/status", params={"zongpai_no": "26BW0011"}
).json() ).json()
assert status["all_complete"] is True 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