feat(server): add PATCH/DELETE /attachment/box mirroring product box ops
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||||
|
|||||||
11
app/main.py
11
app/main.py
@@ -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"}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user