From 5a7e88c3c920afc10516b33667d4eb265eef8c93 Mon Sep 17 00:00:00 2001 From: Misaka_Company Date: Thu, 14 May 2026 12:29:33 +0800 Subject: [PATCH] feat(box): simplify boxing mode API --- app/api/v1/box.py | 2 +- app/main.py | 27 +++++----- app/schemas/box.py | 2 - app/services/box_service.py | 103 ++++++++++++++++++++++++++---------- tests/test_box_api.py | 80 +++++++++++++++++++++++----- 5 files changed, 156 insertions(+), 58 deletions(-) diff --git a/app/api/v1/box.py b/app/api/v1/box.py index c90f222..6c2465a 100644 --- a/app/api/v1/box.py +++ b/app/api/v1/box.py @@ -37,7 +37,7 @@ def create_box(req: BoxSaveRequest, db: Session = Depends(get_db)): @router.patch("/box/{box_item_id}", response_model=BoxUpdateResponse) def update_box(box_item_id: int, req: BoxUpdateRequest, db: Session = Depends(get_db)): - """更新一码多箱已分配装箱明细。""" + """更新已分配装箱明细。""" return update_box_item(db, box_item_id, req) diff --git a/app/main.py b/app/main.py index aa41dda..c7b4b6c 100644 --- a/app/main.py +++ b/app/main.py @@ -22,8 +22,8 @@ from app.services.location_service import AlreadyOffShelfError, DuplicateLocatio from app.services.box_service import ( BoxItemNotFoundError, DuplicateBoxItemError, - DuplicateZongpaiBindError, InvalidBoxItemError, + InvalidQuantityError, InvalidZongpaiError, 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) async def box_item_not_found_handler(request: Request, exc: BoxItemNotFoundError): 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) async def invalid_box_item_handler(request: Request, exc: InvalidBoxItemError): return JSONResponse( diff --git a/app/schemas/box.py b/app/schemas/box.py index 26f1c4a..35750e6 100644 --- a/app/schemas/box.py +++ b/app/schemas/box.py @@ -44,7 +44,6 @@ class BoxSaveRequest(BaseModel): zongpai_no: str box_no: int quantity: int - box_mode: str | None = None # one-to-one | one-to-many | many-to-one @field_validator("zongpai_no") @classmethod @@ -83,7 +82,6 @@ class BoxUpdateRequest(BaseModel): """PATCH /box/{box_item_id} 请求""" box_no: int quantity: int - box_mode: str = "one-to-many" @field_validator("box_no") @classmethod diff --git a/app/services/box_service.py b/app/services/box_service.py index 70700f8..4a8db63 100644 --- a/app/services/box_service.py +++ b/app/services/box_service.py @@ -23,13 +23,6 @@ class DuplicateBoxItemError(Exception): 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): pass @@ -38,6 +31,10 @@ class InvalidBoxItemError(Exception): pass +class InvalidQuantityError(Exception): + pass + + def query_erp_info(db: Session, zongpai_no: str) -> dict: """从 ERP 视图查询总排号对应的排产号、工令号和数量。""" sql = text(_erp_info_sql(db)) @@ -172,24 +169,48 @@ def get_box_info(db: Session, zongpai_no: str) -> dict: } +def _packed_quantity_for_zongpai( + db: Session, + paichan_no: str, + zongpai_no: str, + exclude_box_item_id: int | None = None, +) -> int: + query = ( + db.query(FinishedGoodsBoxItem) + .join(FinishedGoodsBox, FinishedGoodsBoxItem.box_id == FinishedGoodsBox.id) + .filter( + FinishedGoodsBox.paichan_no == paichan_no, + FinishedGoodsBoxItem.zongpai_no == zongpai_no, + ) + ) + if exclude_box_item_id is not None: + 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"] - if req.box_mode == "one-to-one": - existing_box_no = ( - db.query(FinishedGoodsBox.box_no) - .join(FinishedGoodsBoxItem, FinishedGoodsBox.id == FinishedGoodsBoxItem.box_id) - .filter( - FinishedGoodsBox.paichan_no == paichan_no, - FinishedGoodsBoxItem.zongpai_no == req.zongpai_no, - ) - .scalar() - ) - if existing_box_no is not None: - raise DuplicateZongpaiBindError(paichan_no, existing_box_no, req.zongpai_no) - box = ( db.query(FinishedGoodsBox) .filter( @@ -200,8 +221,6 @@ def save_box_record(db: Session, req: BoxSaveRequest) -> dict: ) if box: - if req.box_mode in ("one-to-one", "one-to-many"): - raise DuplicateBoxItemError(paichan_no, req.box_no) existing_item = ( db.query(FinishedGoodsBoxItem) .filter( @@ -212,7 +231,16 @@ def save_box_record(db: Session, req: BoxSaveRequest) -> dict: ) if existing_item: 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( paichan_no=paichan_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: - """更新一码多箱已分配明细的箱号和数量。""" + """更新已分配明细的箱号和数量。""" item = ( db.query(FinishedGoodsBoxItem) .filter(FinishedGoodsBoxItem.id == box_item_id) @@ -267,13 +295,32 @@ def update_box_item(db: Session, box_item_id: int, req: BoxUpdateRequest) -> dic .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: target_box = FinishedGoodsBox(paichan_no=paichan_no, box_no=req.box_no) db.add(target_box) 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 item.box_id = target_box.id diff --git a/tests/test_box_api.py b/tests/test_box_api.py index e416e7e..a5ef7b2 100644 --- a/tests/test_box_api.py +++ b/tests/test_box_api.py @@ -84,8 +84,8 @@ def test_box_save_zongpai_not_found(client: TestClient): 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 first = client.post( "/CargoTrace/box", @@ -93,7 +93,6 @@ def test_box_save_many_to_one_appends_different_zongpais(client: TestClient): "zongpai_no": "26BW0011", "box_no": box_no, "quantity": 10, - "box_mode": "many-to-one", }, ) assert first.status_code == 200 @@ -105,7 +104,6 @@ def test_box_save_many_to_one_appends_different_zongpais(client: TestClient): "zongpai_no": "26BW0012", "box_no": box_no, "quantity": 8, - "box_mode": "many-to-one", }, ) 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) -def test_box_save_many_to_one_duplicate_same_zongpai(client: TestClient): - """多码一箱:同箱同总排重复录入返回 DUPLICATE_BOX_NO。""" +def test_box_save_multi_code_duplicate_same_zongpai(client: TestClient): + """多码凑箱:同箱同总排重复录入返回 DUPLICATE_BOX_NO。""" box_no = 902 first = client.post( "/CargoTrace/box", @@ -135,7 +133,6 @@ def test_box_save_many_to_one_duplicate_same_zongpai(client: TestClient): "zongpai_no": "26BW0011", "box_no": box_no, "quantity": 10, - "box_mode": "many-to-one", }, ) assert first.status_code == 200 @@ -146,13 +143,52 @@ def test_box_save_many_to_one_duplicate_same_zongpai(client: TestClient): "zongpai_no": "26BW0011", "box_no": box_no, "quantity": 5, - "box_mode": "many-to-one", }, ) assert duplicate.status_code == 409 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): """PATCH 原箱号未变时,应排除当前 box_item_id,避免误判重复。""" box_no = 903 @@ -162,7 +198,6 @@ def test_box_update_same_box_no_does_not_conflict(client: TestClient): "zongpai_no": "26BW0011", "box_no": box_no, "quantity": 10, - "box_mode": "one-to-many", }, ) assert created.status_code == 200 @@ -170,12 +205,35 @@ def test_box_update_same_box_no_does_not_conflict(client: TestClient): updated = client.patch( 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.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): """DELETE 删除最后一条明细后,同步清理空箱号。""" box_no = 904 @@ -185,7 +243,6 @@ def test_box_delete_removes_empty_box(client: TestClient): "zongpai_no": "26BW0011", "box_no": box_no, "quantity": 10, - "box_mode": "many-to-one", }, ) assert created.status_code == 200 @@ -201,7 +258,6 @@ def test_box_delete_removes_empty_box(client: TestClient): "zongpai_no": "26BW0012", "box_no": box_no, "quantity": 8, - "box_mode": "one-to-many", }, ) assert recreated.status_code == 200