feat(box): simplify boxing mode API

This commit is contained in:
Misaka_Company
2026-05-14 12:29:33 +08:00
parent bd926050c2
commit 5a7e88c3c9
5 changed files with 156 additions and 58 deletions

View File

@@ -37,7 +37,7 @@ def create_box(req: BoxSaveRequest, db: Session = Depends(get_db)):
@router.patch("/box/{box_item_id}", response_model=BoxUpdateResponse) @router.patch("/box/{box_item_id}", response_model=BoxUpdateResponse)
def update_box(box_item_id: int, req: BoxUpdateRequest, db: Session = Depends(get_db)): def update_box(box_item_id: int, req: BoxUpdateRequest, db: Session = Depends(get_db)):
"""更新一码多箱已分配装箱明细。""" """更新已分配装箱明细。"""
return update_box_item(db, box_item_id, req) return update_box_item(db, box_item_id, req)

View File

@@ -22,8 +22,8 @@ from app.services.location_service import AlreadyOffShelfError, DuplicateLocatio
from app.services.box_service import ( from app.services.box_service import (
BoxItemNotFoundError, BoxItemNotFoundError,
DuplicateBoxItemError, DuplicateBoxItemError,
DuplicateZongpaiBindError,
InvalidBoxItemError, InvalidBoxItemError,
InvalidQuantityError,
InvalidZongpaiError, InvalidZongpaiError,
ZongpaiNotFoundError, ZongpaiNotFoundError,
) )
@@ -126,20 +126,6 @@ async def duplicate_box_handler(request: Request, exc: DuplicateBoxItemError):
) )
@app.exception_handler(DuplicateZongpaiBindError)
async def duplicate_zongpai_bind_handler(request: Request, exc: DuplicateZongpaiBindError):
return JSONResponse(
status_code=409,
content={
"error_code": "DUPLICATE_ZONGPAI_BIND",
"message": f"总排号 {exc.zongpai_no} 已绑定箱号 {exc.box_no},请勿重复装箱",
"paichan_no": exc.paichan_no,
"box_no": exc.box_no,
"zongpai_no": exc.zongpai_no,
},
)
@app.exception_handler(BoxItemNotFoundError) @app.exception_handler(BoxItemNotFoundError)
async def box_item_not_found_handler(request: Request, exc: BoxItemNotFoundError): async def box_item_not_found_handler(request: Request, exc: BoxItemNotFoundError):
return JSONResponse( return JSONResponse(
@@ -151,6 +137,17 @@ async def box_item_not_found_handler(request: Request, exc: BoxItemNotFoundError
) )
@app.exception_handler(InvalidQuantityError)
async def invalid_quantity_handler(request: Request, exc: InvalidQuantityError):
return JSONResponse(
status_code=400,
content={
"error_code": "INVALID_QUANTITY",
"message": "装箱数量超出可装数量上限",
},
)
@app.exception_handler(InvalidBoxItemError) @app.exception_handler(InvalidBoxItemError)
async def invalid_box_item_handler(request: Request, exc: InvalidBoxItemError): async def invalid_box_item_handler(request: Request, exc: InvalidBoxItemError):
return JSONResponse( return JSONResponse(

View File

@@ -44,7 +44,6 @@ class BoxSaveRequest(BaseModel):
zongpai_no: str zongpai_no: str
box_no: int box_no: int
quantity: int quantity: int
box_mode: str | None = None # one-to-one | one-to-many | many-to-one
@field_validator("zongpai_no") @field_validator("zongpai_no")
@classmethod @classmethod
@@ -83,7 +82,6 @@ class BoxUpdateRequest(BaseModel):
"""PATCH /box/{box_item_id} 请求""" """PATCH /box/{box_item_id} 请求"""
box_no: int box_no: int
quantity: int quantity: int
box_mode: str = "one-to-many"
@field_validator("box_no") @field_validator("box_no")
@classmethod @classmethod

View File

@@ -23,13 +23,6 @@ class DuplicateBoxItemError(Exception):
self.box_no = box_no self.box_no = box_no
class DuplicateZongpaiBindError(Exception):
def __init__(self, paichan_no: str, box_no: int, zongpai_no: str):
self.paichan_no = paichan_no
self.box_no = box_no
self.zongpai_no = zongpai_no
class BoxItemNotFoundError(Exception): class BoxItemNotFoundError(Exception):
pass pass
@@ -38,6 +31,10 @@ class InvalidBoxItemError(Exception):
pass pass
class InvalidQuantityError(Exception):
pass
def query_erp_info(db: Session, zongpai_no: str) -> dict: def query_erp_info(db: Session, zongpai_no: str) -> dict:
"""从 ERP 视图查询总排号对应的排产号、工令号和数量。""" """从 ERP 视图查询总排号对应的排产号、工令号和数量。"""
sql = text(_erp_info_sql(db)) sql = text(_erp_info_sql(db))
@@ -172,23 +169,47 @@ def get_box_info(db: Session, zongpai_no: str) -> dict:
} }
def save_box_record(db: Session, req: BoxSaveRequest) -> dict: def _packed_quantity_for_zongpai(
"""保存装箱记录。箱已存在时仅追加明细(多码一箱场景)。""" db: Session,
erp = query_erp_info(db, req.zongpai_no) paichan_no: str,
paichan_no = erp["paichan_no"] zongpai_no: str,
exclude_box_item_id: int | None = None,
if req.box_mode == "one-to-one": ) -> int:
existing_box_no = ( query = (
db.query(FinishedGoodsBox.box_no) db.query(FinishedGoodsBoxItem)
.join(FinishedGoodsBoxItem, FinishedGoodsBox.id == FinishedGoodsBoxItem.box_id) .join(FinishedGoodsBox, FinishedGoodsBoxItem.box_id == FinishedGoodsBox.id)
.filter( .filter(
FinishedGoodsBox.paichan_no == paichan_no, FinishedGoodsBox.paichan_no == paichan_no,
FinishedGoodsBoxItem.zongpai_no == req.zongpai_no, FinishedGoodsBoxItem.zongpai_no == zongpai_no,
) )
.scalar()
) )
if existing_box_no is not None: if exclude_box_item_id is not None:
raise DuplicateZongpaiBindError(paichan_no, existing_box_no, req.zongpai_no) query = query.filter(FinishedGoodsBoxItem.id != exclude_box_item_id)
return sum(int(item.quantity or 0) for item in query.all())
def _ensure_quantity_within_erp_total(
db: Session,
paichan_no: str,
zongpai_no: str,
erp_quantity: int,
submitted_quantity: int,
exclude_box_item_id: int | None = None,
) -> None:
packed_quantity = _packed_quantity_for_zongpai(
db,
paichan_no,
zongpai_no,
exclude_box_item_id=exclude_box_item_id,
)
if packed_quantity + submitted_quantity > erp_quantity:
raise InvalidQuantityError()
def save_box_record(db: Session, req: BoxSaveRequest) -> dict:
"""保存装箱记录。同箱可凑箱,但同箱同总排不可重复。"""
erp = query_erp_info(db, req.zongpai_no)
paichan_no = erp["paichan_no"]
box = ( box = (
db.query(FinishedGoodsBox) db.query(FinishedGoodsBox)
@@ -200,8 +221,6 @@ def save_box_record(db: Session, req: BoxSaveRequest) -> dict:
) )
if box: if box:
if req.box_mode in ("one-to-one", "one-to-many"):
raise DuplicateBoxItemError(paichan_no, req.box_no)
existing_item = ( existing_item = (
db.query(FinishedGoodsBoxItem) db.query(FinishedGoodsBoxItem)
.filter( .filter(
@@ -212,7 +231,16 @@ def save_box_record(db: Session, req: BoxSaveRequest) -> dict:
) )
if existing_item: if existing_item:
raise DuplicateBoxItemError(paichan_no, req.box_no) raise DuplicateBoxItemError(paichan_no, req.box_no)
else:
_ensure_quantity_within_erp_total(
db,
paichan_no,
req.zongpai_no,
erp["quantity"],
req.quantity,
)
if box is None:
box = FinishedGoodsBox( box = FinishedGoodsBox(
paichan_no=paichan_no, paichan_no=paichan_no,
box_no=req.box_no, box_no=req.box_no,
@@ -240,7 +268,7 @@ def save_box_record(db: Session, req: BoxSaveRequest) -> dict:
def update_box_item(db: Session, box_item_id: int, req: BoxUpdateRequest) -> dict: def update_box_item(db: Session, box_item_id: int, req: BoxUpdateRequest) -> dict:
"""更新一码多箱已分配明细的箱号和数量。""" """更新已分配明细的箱号和数量。"""
item = ( item = (
db.query(FinishedGoodsBoxItem) db.query(FinishedGoodsBoxItem)
.filter(FinishedGoodsBoxItem.id == box_item_id) .filter(FinishedGoodsBoxItem.id == box_item_id)
@@ -267,13 +295,32 @@ def update_box_item(db: Session, box_item_id: int, req: BoxUpdateRequest) -> dic
.first() .first()
) )
if target_box is not None and target_box.id != current_box.id:
raise DuplicateBoxItemError(paichan_no, req.box_no)
if target_box is None: if target_box is None:
target_box = FinishedGoodsBox(paichan_no=paichan_no, box_no=req.box_no) target_box = FinishedGoodsBox(paichan_no=paichan_no, box_no=req.box_no)
db.add(target_box) db.add(target_box)
db.flush() db.flush()
else:
duplicate_item = (
db.query(FinishedGoodsBoxItem)
.filter(
FinishedGoodsBoxItem.box_id == target_box.id,
FinishedGoodsBoxItem.zongpai_no == item.zongpai_no,
FinishedGoodsBoxItem.id != item.id,
)
.first()
)
if duplicate_item is not None:
raise DuplicateBoxItemError(paichan_no, req.box_no)
erp = query_erp_info(db, item.zongpai_no)
_ensure_quantity_within_erp_total(
db,
paichan_no,
item.zongpai_no,
erp["quantity"],
req.quantity,
exclude_box_item_id=box_item_id,
)
old_box_id = item.box_id old_box_id = item.box_id
item.box_id = target_box.id item.box_id = target_box.id

View File

@@ -84,8 +84,8 @@ def test_box_save_zongpai_not_found(client: TestClient):
assert resp.json()["error_code"] == "ZONGPAI_NOT_FOUND" assert resp.json()["error_code"] == "ZONGPAI_NOT_FOUND"
def test_box_save_many_to_one_appends_different_zongpais(client: TestClient): def test_box_save_multi_code_appends_different_zongpais(client: TestClient):
"""多码箱:同一排产号同一箱号可追加不同总排号。""" """多码箱:同一排产号同一箱号可追加不同总排号。"""
box_no = 901 box_no = 901
first = client.post( first = client.post(
"/CargoTrace/box", "/CargoTrace/box",
@@ -93,7 +93,6 @@ def test_box_save_many_to_one_appends_different_zongpais(client: TestClient):
"zongpai_no": "26BW0011", "zongpai_no": "26BW0011",
"box_no": box_no, "box_no": box_no,
"quantity": 10, "quantity": 10,
"box_mode": "many-to-one",
}, },
) )
assert first.status_code == 200 assert first.status_code == 200
@@ -105,7 +104,6 @@ def test_box_save_many_to_one_appends_different_zongpais(client: TestClient):
"zongpai_no": "26BW0012", "zongpai_no": "26BW0012",
"box_no": box_no, "box_no": box_no,
"quantity": 8, "quantity": 8,
"box_mode": "many-to-one",
}, },
) )
assert second.status_code == 200 assert second.status_code == 200
@@ -126,8 +124,8 @@ def test_box_save_many_to_one_appends_different_zongpais(client: TestClient):
assert isinstance(item["total_quantity"], int) assert isinstance(item["total_quantity"], int)
def test_box_save_many_to_one_duplicate_same_zongpai(client: TestClient): def test_box_save_multi_code_duplicate_same_zongpai(client: TestClient):
"""多码箱:同箱同总排重复录入返回 DUPLICATE_BOX_NO。""" """多码箱:同箱同总排重复录入返回 DUPLICATE_BOX_NO。"""
box_no = 902 box_no = 902
first = client.post( first = client.post(
"/CargoTrace/box", "/CargoTrace/box",
@@ -135,7 +133,6 @@ def test_box_save_many_to_one_duplicate_same_zongpai(client: TestClient):
"zongpai_no": "26BW0011", "zongpai_no": "26BW0011",
"box_no": box_no, "box_no": box_no,
"quantity": 10, "quantity": 10,
"box_mode": "many-to-one",
}, },
) )
assert first.status_code == 200 assert first.status_code == 200
@@ -146,13 +143,52 @@ def test_box_save_many_to_one_duplicate_same_zongpai(client: TestClient):
"zongpai_no": "26BW0011", "zongpai_no": "26BW0011",
"box_no": box_no, "box_no": box_no,
"quantity": 5, "quantity": 5,
"box_mode": "many-to-one",
}, },
) )
assert duplicate.status_code == 409 assert duplicate.status_code == 409
assert duplicate.json()["error_code"] == "DUPLICATE_BOX_NO" assert duplicate.json()["error_code"] == "DUPLICATE_BOX_NO"
def test_box_save_split_same_zongpai_up_to_total_quantity(client: TestClient):
"""单码装箱:同一总排号可分箱提交,累计等于 ERP 数量时成功。"""
first = client.post(
"/CargoTrace/box",
json={"zongpai_no": "26BW0011", "box_no": 905, "quantity": 30},
)
assert first.status_code == 200
second = client.post(
"/CargoTrace/box",
json={"zongpai_no": "26BW0011", "box_no": 906, "quantity": 50},
)
assert second.status_code == 200
info = client.get("/CargoTrace/box/info", params={"zongpai_no": "26BW0011"})
assert info.status_code == 200
current_boxes = [
item
for item in info.json()["current_zongpai_boxes"]
if item["box_no"] in (905, 906)
]
assert sum(item["quantity"] for item in current_boxes) == 80
def test_box_save_rejects_quantity_over_remaining(client: TestClient):
"""保存后累计数量超过 ERP 总数量时返回 INVALID_QUANTITY。"""
created = client.post(
"/CargoTrace/box",
json={"zongpai_no": "26BW0011", "box_no": 907, "quantity": 70},
)
assert created.status_code == 200
over = client.post(
"/CargoTrace/box",
json={"zongpai_no": "26BW0011", "box_no": 908, "quantity": 11},
)
assert over.status_code == 400
assert over.json()["error_code"] == "INVALID_QUANTITY"
def test_box_update_same_box_no_does_not_conflict(client: TestClient): def test_box_update_same_box_no_does_not_conflict(client: TestClient):
"""PATCH 原箱号未变时,应排除当前 box_item_id避免误判重复。""" """PATCH 原箱号未变时,应排除当前 box_item_id避免误判重复。"""
box_no = 903 box_no = 903
@@ -162,7 +198,6 @@ def test_box_update_same_box_no_does_not_conflict(client: TestClient):
"zongpai_no": "26BW0011", "zongpai_no": "26BW0011",
"box_no": box_no, "box_no": box_no,
"quantity": 10, "quantity": 10,
"box_mode": "one-to-many",
}, },
) )
assert created.status_code == 200 assert created.status_code == 200
@@ -170,12 +205,35 @@ def test_box_update_same_box_no_does_not_conflict(client: TestClient):
updated = client.patch( updated = client.patch(
f"/CargoTrace/box/{box_item_id}", f"/CargoTrace/box/{box_item_id}",
json={"box_no": box_no, "quantity": 7, "box_mode": "one-to-many"}, json={"box_no": box_no, "quantity": 7},
) )
assert updated.status_code == 200 assert updated.status_code == 200
assert updated.json()["quantity"] == 7 assert updated.json()["quantity"] == 7
def test_box_update_rejects_quantity_over_remaining(client: TestClient):
"""PATCH 调整数量导致累计超过 ERP 总数量时返回 INVALID_QUANTITY。"""
first = client.post(
"/CargoTrace/box",
json={"zongpai_no": "26BW0011", "box_no": 909, "quantity": 70},
)
assert first.status_code == 200
second = client.post(
"/CargoTrace/box",
json={"zongpai_no": "26BW0011", "box_no": 910, "quantity": 5},
)
assert second.status_code == 200
box_item_id = second.json()["box_item_id"]
over = client.patch(
f"/CargoTrace/box/{box_item_id}",
json={"box_no": 910, "quantity": 11},
)
assert over.status_code == 400
assert over.json()["error_code"] == "INVALID_QUANTITY"
def test_box_delete_removes_empty_box(client: TestClient): def test_box_delete_removes_empty_box(client: TestClient):
"""DELETE 删除最后一条明细后,同步清理空箱号。""" """DELETE 删除最后一条明细后,同步清理空箱号。"""
box_no = 904 box_no = 904
@@ -185,7 +243,6 @@ def test_box_delete_removes_empty_box(client: TestClient):
"zongpai_no": "26BW0011", "zongpai_no": "26BW0011",
"box_no": box_no, "box_no": box_no,
"quantity": 10, "quantity": 10,
"box_mode": "many-to-one",
}, },
) )
assert created.status_code == 200 assert created.status_code == 200
@@ -201,7 +258,6 @@ def test_box_delete_removes_empty_box(client: TestClient):
"zongpai_no": "26BW0012", "zongpai_no": "26BW0012",
"box_no": box_no, "box_no": box_no,
"quantity": 8, "quantity": 8,
"box_mode": "one-to-many",
}, },
) )
assert recreated.status_code == 200 assert recreated.status_code == 200