feat: extend boxing module to support accessory items

- Add PendingAccessory schema and pending_accessories to BoxInfoResponse
- Add item_type field to BoxItemDetail, CurrentZongpaiBox, BoxSaveRequest, BoxSaveResponse
- Query pending (unboxed) accessories in get_box_info
- Branch save_box_record to skip ERP validation for accessory items

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Misaka
2026-05-24 17:43:15 +08:00
parent 0539c4696d
commit 39b8e5de6a
2 changed files with 109 additions and 34 deletions

View File

@@ -5,45 +5,64 @@ from pydantic import BaseModel, Field, field_validator
ZONGPAI_PATTERN = re.compile(r"^(\d{2}(B|C|T)\d+|\d{2}(BW|CW)\d{4})$") ZONGPAI_PATTERN = re.compile(r"^(\d{2}(B|C|T)\d+|\d{2}(BW|CW)\d{4})$")
class PendingAccessory(BaseModel):
"""待装箱的配件"""
accessory_id: int
accessory_type: str
quantity: int
location_code: str | None = None
class BoxItemDetail(BaseModel): class BoxItemDetail(BaseModel):
"""箱号下某个总排号的明细""" """箱号下某个总排号的明细"""
box_item_id: int | None = None box_item_id: int | None = None
zongpai_no: str zongpai_no: str
work_order_no: str | None = None work_order_no: str | None = None
quantity: int quantity: int
total_quantity: int | None = None total_quantity: int | None = None
item_type: str = "product"
class CurrentZongpaiBox(BaseModel): class CurrentZongpaiBox(BaseModel):
"""当前总排号已经分配的箱号明细""" """当前总排号已经分配的箱号明细"""
box_item_id: int box_item_id: int
box_no: int box_no: int
quantity: int quantity: int
item_type: str = "product"
class BoxDetail(BaseModel): class BoxDetail(BaseModel):
"""一个箱号的完整信息""" """一个箱号的完整信息"""
box_no: int box_no: int
items: list[BoxItemDetail] items: list[BoxItemDetail]
class BoxInfoResponse(BaseModel): class BoxInfoResponse(BaseModel):
"""GET /box/info 响应""" """GET /box/info 响应"""
zongpai_no: str zongpai_no: str
paichan_no: str paichan_no: str
work_order_no: str | None = None work_order_no: str | None = None
quantity: int quantity: int
current_zongpai_boxes: list[CurrentZongpaiBox] = Field(default_factory=list) current_zongpai_boxes: list[CurrentZongpaiBox] = Field(default_factory=list)
existing_boxes: list[BoxDetail] existing_boxes: list[BoxDetail]
pending_accessories: list[PendingAccessory] = Field(default_factory=list)
max_box_no: int max_box_no: int
suggested_box_no: int suggested_box_no: int
class BoxSaveRequest(BaseModel): class BoxSaveRequest(BaseModel):
"""POST /box 请求""" """POST /box 请求"""
zongpai_no: str zongpai_no: str
box_no: int box_no: int
quantity: int quantity: int
item_type: str = "product"
accessory_id: int | None = None
@field_validator("zongpai_no") @field_validator("zongpai_no")
@classmethod @classmethod
@@ -70,16 +89,19 @@ class BoxSaveRequest(BaseModel):
class BoxSaveResponse(BaseModel): class BoxSaveResponse(BaseModel):
"""POST /box 响应""" """POST /box 响应"""
box_item_id: int box_item_id: int
paichan_no: str paichan_no: str
box_no: int box_no: int
zongpai_no: str zongpai_no: str
quantity: int quantity: int
item_type: str = "product"
created_at: str created_at: str
class BoxUpdateRequest(BaseModel): class BoxUpdateRequest(BaseModel):
"""PATCH /box/{box_item_id} 请求""" """PATCH /box/{box_item_id} 请求"""
box_no: int box_no: int
quantity: int quantity: int
@@ -100,6 +122,7 @@ class BoxUpdateRequest(BaseModel):
class BoxUpdateResponse(BaseModel): class BoxUpdateResponse(BaseModel):
"""PATCH /box/{box_item_id} 响应""" """PATCH /box/{box_item_id} 响应"""
box_item_id: int box_item_id: int
paichan_no: str paichan_no: str
box_no: int box_no: int
@@ -110,5 +133,6 @@ class BoxUpdateResponse(BaseModel):
class BoxDeleteResponse(BaseModel): class BoxDeleteResponse(BaseModel):
"""DELETE /box/{box_item_id} 响应""" """DELETE /box/{box_item_id} 响应"""
box_item_id: int box_item_id: int
deleted: bool deleted: bool

View File

@@ -3,6 +3,7 @@ import re
from sqlalchemy import bindparam, text from sqlalchemy import bindparam, text
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.models.accessory import FinishedGoodsAccessory
from app.models.finished_goods import FinishedGoodsBox, FinishedGoodsBoxItem from app.models.finished_goods import FinishedGoodsBox, FinishedGoodsBoxItem
from app.schemas.box import BoxSaveRequest, BoxUpdateRequest from app.schemas.box import BoxSaveRequest, BoxUpdateRequest
@@ -132,31 +133,68 @@ def get_box_info(db: Session, zongpai_no: str) -> dict:
items = box_items[box.id] items = box_items[box.id]
for item in items: for item in items:
if item.zongpai_no == zongpai_no: if item.zongpai_no == zongpai_no:
current_zongpai_boxes.append({ current_zongpai_boxes.append(
{
"box_item_id": item.id, "box_item_id": item.id,
"box_no": box.box_no, "box_no": box.box_no,
"quantity": int(item.quantity or 0), "quantity": int(item.quantity or 0),
}) "item_type": item.item_type or "product",
existing_boxes.append({ }
)
existing_boxes.append(
{
"box_no": box.box_no, "box_no": box.box_no,
"items": [ "items": [
{ {
"box_item_id": item.id, "box_item_id": item.id,
"zongpai_no": item.zongpai_no, "zongpai_no": item.zongpai_no,
"work_order_no": erp_item_info_map.get( "work_order_no": erp_item_info_map.get(item.zongpai_no, {}).get(
item.zongpai_no, {} "work_order_no"
).get("work_order_no"), ),
"quantity": int(item.quantity or 0), "quantity": int(item.quantity or 0),
"total_quantity": erp_item_info_map.get( "total_quantity": erp_item_info_map.get(
item.zongpai_no, {} item.zongpai_no, {}
).get("total_quantity"), ).get("total_quantity"),
"item_type": item.item_type or "product",
} }
for item in items for item in items
], ],
}) }
)
if box.box_no > max_box_no: if box.box_no > max_box_no:
max_box_no = box.box_no max_box_no = box.box_no
# Query pending accessories for this zongpai_no
pending_accessories = []
accessory_records = (
db.query(FinishedGoodsAccessory)
.filter(
FinishedGoodsAccessory.zongpai_no == zongpai_no,
FinishedGoodsAccessory.location_code.isnot(None),
)
.all()
)
for acc in accessory_records:
is_boxed = (
db.query(FinishedGoodsBoxItem)
.join(FinishedGoodsBox, FinishedGoodsBoxItem.box_id == FinishedGoodsBox.id)
.filter(
FinishedGoodsBoxItem.zongpai_no == acc.zongpai_no,
FinishedGoodsBox.paichan_no == acc.paichan_no,
FinishedGoodsBoxItem.item_type == "accessory",
)
.first()
) is not None
if not is_boxed:
pending_accessories.append(
{
"accessory_id": acc.id,
"accessory_type": acc.accessory_type,
"quantity": acc.quantity,
"location_code": acc.location_code,
}
)
return { return {
"zongpai_no": zongpai_no, "zongpai_no": zongpai_no,
"paichan_no": paichan_no, "paichan_no": paichan_no,
@@ -164,6 +202,7 @@ def get_box_info(db: Session, zongpai_no: str) -> dict:
"quantity": quantity, "quantity": quantity,
"current_zongpai_boxes": current_zongpai_boxes, "current_zongpai_boxes": current_zongpai_boxes,
"existing_boxes": existing_boxes, "existing_boxes": existing_boxes,
"pending_accessories": pending_accessories,
"max_box_no": max_box_no, "max_box_no": max_box_no,
"suggested_box_no": max_box_no + 1, "suggested_box_no": max_box_no + 1,
} }
@@ -208,6 +247,17 @@ def _ensure_quantity_within_erp_total(
def save_box_record(db: Session, req: BoxSaveRequest) -> dict: def save_box_record(db: Session, req: BoxSaveRequest) -> dict:
"""保存装箱记录。同箱可凑箱,但同箱同总排不可重复。""" """保存装箱记录。同箱可凑箱,但同箱同总排不可重复。"""
if req.item_type == "accessory":
# For accessories, skip ERP quantity validation
accessory = (
db.query(FinishedGoodsAccessory)
.filter(FinishedGoodsAccessory.id == req.accessory_id)
.first()
)
if accessory is None:
raise BoxItemNotFoundError()
paichan_no = accessory.paichan_no
else:
erp = query_erp_info(db, req.zongpai_no) erp = query_erp_info(db, req.zongpai_no)
paichan_no = erp["paichan_no"] paichan_no = erp["paichan_no"]
@@ -232,6 +282,7 @@ 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)
if req.item_type != "accessory":
_ensure_quantity_within_erp_total( _ensure_quantity_within_erp_total(
db, db,
paichan_no, paichan_no,
@@ -252,6 +303,7 @@ def save_box_record(db: Session, req: BoxSaveRequest) -> dict:
box_id=box.id, box_id=box.id,
zongpai_no=req.zongpai_no, zongpai_no=req.zongpai_no,
quantity=req.quantity, quantity=req.quantity,
item_type=req.item_type,
) )
db.add(item) db.add(item)
db.commit() db.commit()
@@ -263,6 +315,7 @@ def save_box_record(db: Session, req: BoxSaveRequest) -> dict:
"box_no": req.box_no, "box_no": req.box_no,
"zongpai_no": req.zongpai_no, "zongpai_no": req.zongpai_no,
"quantity": req.quantity, "quantity": req.quantity,
"item_type": req.item_type,
"created_at": item.created_at.isoformat(), "created_at": item.created_at.isoformat(),
} }
@@ -278,9 +331,7 @@ def update_box_item(db: Session, box_item_id: int, req: BoxUpdateRequest) -> dic
raise BoxItemNotFoundError() raise BoxItemNotFoundError()
current_box = ( current_box = (
db.query(FinishedGoodsBox) db.query(FinishedGoodsBox).filter(FinishedGoodsBox.id == item.box_id).first()
.filter(FinishedGoodsBox.id == item.box_id)
.first()
) )
if current_box is None: if current_box is None:
raise InvalidBoxItemError() raise InvalidBoxItemError()