feat: add boxing module API — GET /box/info and POST /box

- Add box schemas (BoxInfoResponse, BoxSaveRequest, BoxSaveResponse)
- Add box service with ERP view query and save logic (supports many2one append)
- Add box router with GET /box/info and POST /box endpoints
- Register exception handlers for InvalidZongpaiError, ZongpaiNotFoundError, DuplicateBoxItemError
- Update RequestValidationError handler to cover INVALID_BOX_NO and INVALID_QUANTITY
- Add 7 integration tests for box API

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Misaka_Company
2026-05-11 18:04:13 +08:00
parent 6e0e9d9584
commit c370ca36bc
6 changed files with 847 additions and 1 deletions

23
app/api/v1/box.py Normal file
View File

@@ -0,0 +1,23 @@
from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.schemas.box import BoxInfoResponse, BoxSaveRequest, BoxSaveResponse
from app.services.box_service import get_box_info, save_box_record
router = APIRouter(tags=["boxing"])
@router.get("/box/info", response_model=BoxInfoResponse)
def box_info(
zongpai_no: str = Query(..., description="总排号"),
db: Session = Depends(get_db),
):
"""查询装箱信息:根据总排号查询排产号、数量和已有箱号明细。"""
return get_box_info(db, zongpai_no)
@router.post("/box", response_model=BoxSaveResponse)
def create_box(req: BoxSaveRequest, db: Session = Depends(get_db)):
"""保存装箱记录:将总排号记录到指定箱号中。"""
return save_box_record(db, req)

View File

@@ -3,11 +3,14 @@ from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from app.api.v1.location import router as location_router
from app.api.v1.box import router as box_router
from app.services.location_service import DuplicateLocationError
from app.services.box_service import ZongpaiNotFoundError, DuplicateBoxItemError, InvalidZongpaiError
app = FastAPI(title="CargoTrace API", version="0.1.0")
app.include_router(location_router, prefix="/CargoTrace")
app.include_router(box_router, prefix="/CargoTrace")
@app.exception_handler(DuplicateLocationError)
@@ -27,7 +30,7 @@ async def duplicate_location_handler(request: Request, exc: DuplicateLocationErr
async def validation_error_handler(request: Request, exc: RequestValidationError):
for err in exc.errors():
msg = err.get("msg", "")
if "INVALID_ZONGPAI" in msg or "INVALID_LOCATION" in msg:
if "INVALID_ZONGPAI" in msg or "INVALID_LOCATION" in msg or "INVALID_BOX_NO" in msg or "INVALID_QUANTITY" in msg:
error_code = msg.replace("Value error, ", "")
return JSONResponse(
status_code=400,
@@ -54,6 +57,41 @@ async def value_error_handler(request: Request, exc: ValueError):
)
@app.exception_handler(InvalidZongpaiError)
async def invalid_zongpai_handler(request: Request, exc: InvalidZongpaiError):
return JSONResponse(
status_code=400,
content={
"error_code": "INVALID_ZONGPAI",
"message": "总排号格式不合法",
},
)
@app.exception_handler(ZongpaiNotFoundError)
async def zongpai_not_found_handler(request: Request, exc: ZongpaiNotFoundError):
return JSONResponse(
status_code=404,
content={
"error_code": "ZONGPAI_NOT_FOUND",
"message": "未找到该总排号对应的排产号信息",
},
)
@app.exception_handler(DuplicateBoxItemError)
async def duplicate_box_handler(request: Request, exc: DuplicateBoxItemError):
return JSONResponse(
status_code=409,
content={
"error_code": "DUPLICATE_BOX_NO",
"message": f"排产号 {exc.paichan_no} 下箱号 {exc.box_no} 已存在",
"paichan_no": exc.paichan_no,
"box_no": exc.box_no,
},
)
@app.get("/")
async def root():
return {"message": "Welcome to CargoTrace API"}

65
app/schemas/box.py Normal file
View File

@@ -0,0 +1,65 @@
import re
from pydantic import BaseModel, field_validator
ZONGPAI_PATTERN = re.compile(r"^(\d{2}(B|C|T)\d+|\d{2}(BW|CW)\d{4})$")
class BoxItemDetail(BaseModel):
"""箱号下某个总排号的明细"""
zongpai_no: str
quantity: int
class BoxDetail(BaseModel):
"""一个箱号的完整信息"""
box_no: int
items: list[BoxItemDetail]
class BoxInfoResponse(BaseModel):
"""GET /box/info 响应"""
zongpai_no: str
paichan_no: str
quantity: int
existing_boxes: list[BoxDetail]
max_box_no: int
suggested_box_no: int
class BoxSaveRequest(BaseModel):
"""POST /box 请求"""
zongpai_no: str
box_no: int
quantity: int
@field_validator("zongpai_no")
@classmethod
def validate_zongpai_no(cls, v: str) -> str:
v = v.strip().upper()
if not ZONGPAI_PATTERN.match(v):
raise ValueError("INVALID_ZONGPAI")
return v
@field_validator("box_no")
@classmethod
def validate_box_no(cls, v: int) -> int:
if v <= 0:
raise ValueError("INVALID_BOX_NO")
return v
@field_validator("quantity")
@classmethod
def validate_quantity(cls, v: int) -> int:
if v <= 0:
raise ValueError("INVALID_QUANTITY")
return v
class BoxSaveResponse(BaseModel):
"""POST /box 响应"""
paichan_no: str
box_no: int
zongpai_no: str
quantity: int
created_at: str

135
app/services/box_service.py Normal file
View File

@@ -0,0 +1,135 @@
import re
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.models.finished_goods import FinishedGoodsBox, FinishedGoodsBoxItem
from app.schemas.box import BoxSaveRequest
ZONGPAI_PATTERN = re.compile(r"^(\d{2}(B|C|T)\d+|\d{2}(BW|CW)\d{4})$")
class InvalidZongpaiError(Exception):
pass
class ZongpaiNotFoundError(Exception):
pass
class DuplicateBoxItemError(Exception):
def __init__(self, paichan_no: str, box_no: int):
self.paichan_no = paichan_no
self.box_no = box_no
def query_erp_info(db: Session, zongpai_no: str) -> dict:
"""从 ERP 视图查询总排号对应的排产号和数量。"""
sql = text(
"SELECT TOP 1 [总排号], [排产号], [数量] "
"FROM [ERPAuto].[vw_productionContractData] "
"WHERE [总排号] = :zongpai_no"
)
row = db.execute(sql, {"zongpai_no": zongpai_no}).fetchone()
if not row:
raise ZongpaiNotFoundError()
return {
"zongpai_no": row[0],
"paichan_no": row[1],
"quantity": int(row[2]),
}
def get_box_info(db: Session, zongpai_no: str) -> dict:
"""获取装箱信息:排产号、数量、已有箱号明细。"""
zongpai_no = zongpai_no.strip().upper()
if not ZONGPAI_PATTERN.match(zongpai_no):
raise InvalidZongpaiError()
erp = query_erp_info(db, zongpai_no)
paichan_no = erp["paichan_no"]
quantity = erp["quantity"]
boxes = (
db.query(FinishedGoodsBox)
.filter(FinishedGoodsBox.paichan_no == paichan_no)
.order_by(FinishedGoodsBox.box_no)
.all()
)
existing_boxes = []
max_box_no = 0
for box in boxes:
items = (
db.query(FinishedGoodsBoxItem)
.filter(FinishedGoodsBoxItem.box_id == box.id)
.all()
)
existing_boxes.append({
"box_no": box.box_no,
"items": [
{"zongpai_no": item.zongpai_no, "quantity": int(item.quantity or 0)}
for item in items
],
})
if box.box_no > max_box_no:
max_box_no = box.box_no
return {
"zongpai_no": zongpai_no,
"paichan_no": paichan_no,
"quantity": quantity,
"existing_boxes": existing_boxes,
"max_box_no": max_box_no,
"suggested_box_no": max_box_no + 1,
}
def save_box_record(db: Session, req: BoxSaveRequest) -> dict:
"""保存装箱记录。箱已存在时仅追加明细(多码一箱场景)。"""
erp = query_erp_info(db, req.zongpai_no)
paichan_no = erp["paichan_no"]
box = (
db.query(FinishedGoodsBox)
.filter(
FinishedGoodsBox.paichan_no == paichan_no,
FinishedGoodsBox.box_no == req.box_no,
)
.first()
)
if box:
existing_item = (
db.query(FinishedGoodsBoxItem)
.filter(
FinishedGoodsBoxItem.box_id == box.id,
FinishedGoodsBoxItem.zongpai_no == req.zongpai_no,
)
.first()
)
if existing_item:
raise DuplicateBoxItemError(paichan_no, req.box_no)
else:
box = FinishedGoodsBox(
paichan_no=paichan_no,
box_no=req.box_no,
)
db.add(box)
db.flush()
item = FinishedGoodsBoxItem(
box_id=box.id,
zongpai_no=req.zongpai_no,
quantity=req.quantity,
)
db.add(item)
db.commit()
db.refresh(item)
return {
"paichan_no": paichan_no,
"box_no": req.box_no,
"zongpai_no": req.zongpai_no,
"quantity": req.quantity,
"created_at": item.created_at.isoformat(),
}