feat(server): add POST /attachment/box with real-evidence flip + cap
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -4,6 +4,8 @@ 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 (
|
||||||
|
AttachmentBoxSaveRequest,
|
||||||
|
AttachmentBoxSaveResponse,
|
||||||
AttachmentConfigRequest,
|
AttachmentConfigRequest,
|
||||||
AttachmentLocationRequest,
|
AttachmentLocationRequest,
|
||||||
AttachmentLocationResponse,
|
AttachmentLocationResponse,
|
||||||
@@ -15,6 +17,7 @@ from app.services.attachment_service import (
|
|||||||
configure_attachment,
|
configure_attachment,
|
||||||
get_attachment_status,
|
get_attachment_status,
|
||||||
register_attachment_location,
|
register_attachment_location,
|
||||||
|
save_attachment_box,
|
||||||
)
|
)
|
||||||
|
|
||||||
router = APIRouter(tags=["attachment"])
|
router = APIRouter(tags=["attachment"])
|
||||||
@@ -64,3 +67,18 @@ def post_attachment_location(
|
|||||||
created_at=record.created_at.isoformat(),
|
created_at=record.created_at.isoformat(),
|
||||||
previous_location=getattr(record, "previous_location", None),
|
previous_location=getattr(record, "previous_location", None),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/attachment/box",
|
||||||
|
response_model=AttachmentBoxSaveResponse,
|
||||||
|
responses={
|
||||||
|
400: {"description": "参数非法或超量", "model": ErrorResponse},
|
||||||
|
404: {"description": "总排号/类型不存在", "model": ErrorResponse},
|
||||||
|
409: {"description": "同箱同类型重复", "model": ErrorResponse},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
def post_attachment_box(
|
||||||
|
req: AttachmentBoxSaveRequest, db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
return save_attachment_box(db, req)
|
||||||
|
|||||||
16
app/main.py
16
app/main.py
@@ -36,6 +36,7 @@ from app.services.attachment_service import (
|
|||||||
AlreadyOffShelfAttachmentError,
|
AlreadyOffShelfAttachmentError,
|
||||||
CategoryHasBoxesError,
|
CategoryHasBoxesError,
|
||||||
CategoryNotFoundError,
|
CategoryNotFoundError,
|
||||||
|
DuplicateAttachmentBoxItemError,
|
||||||
DuplicateAttachmentLocationError,
|
DuplicateAttachmentLocationError,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -231,6 +232,21 @@ async def already_off_shelf_attachment_handler(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.exception_handler(DuplicateAttachmentBoxItemError)
|
||||||
|
async def duplicate_attachment_box_handler(
|
||||||
|
request: Request, exc: DuplicateAttachmentBoxItemError
|
||||||
|
):
|
||||||
|
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("/")
|
@app.get("/")
|
||||||
async def root():
|
async def root():
|
||||||
return {"message": "Welcome to CargoTrace API"}
|
return {"message": "Welcome to CargoTrace API"}
|
||||||
|
|||||||
@@ -11,8 +11,13 @@ from app.models.attachment import (
|
|||||||
FinishedGoodsAttachmentLocation,
|
FinishedGoodsAttachmentLocation,
|
||||||
FinishedGoodsBoxAttachmentItem,
|
FinishedGoodsBoxAttachmentItem,
|
||||||
)
|
)
|
||||||
|
from app.models.finished_goods import FinishedGoodsBox
|
||||||
from app.schemas.attachment import AttachmentConfigRequest
|
from app.schemas.attachment import AttachmentConfigRequest
|
||||||
from app.services.box_service import InvalidZongpaiError
|
from app.services.box_service import (
|
||||||
|
InvalidQuantityError,
|
||||||
|
InvalidZongpaiError,
|
||||||
|
query_erp_info,
|
||||||
|
)
|
||||||
|
|
||||||
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})$")
|
||||||
|
|
||||||
@@ -257,3 +262,112 @@ def register_attachment_location(db: Session, req) -> FinishedGoodsAttachmentLoc
|
|||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(record)
|
db.refresh(record)
|
||||||
return record
|
return record
|
||||||
|
|
||||||
|
|
||||||
|
def _boxed_for_category(
|
||||||
|
db: Session, zongpai_no: str, category_id: int, exclude_item_id: int | None = None
|
||||||
|
) -> int:
|
||||||
|
q = db.query(FinishedGoodsBoxAttachmentItem).filter(
|
||||||
|
FinishedGoodsBoxAttachmentItem.zongpai_no == zongpai_no,
|
||||||
|
FinishedGoodsBoxAttachmentItem.category_id == category_id,
|
||||||
|
)
|
||||||
|
if exclude_item_id is not None:
|
||||||
|
q = q.filter(FinishedGoodsBoxAttachmentItem.id != exclude_item_id)
|
||||||
|
return sum(int(r.quantity or 0) for r in q.all())
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_attachment_item(db: Session, zongpai_no: str, category_id: int, erp_qty: int):
|
||||||
|
"""实物证据:不存在则建 item(expected 兜底 = ERP 数量),并保证 determination=has。"""
|
||||||
|
if not _category_name_map(db, [category_id]):
|
||||||
|
raise CategoryNotFoundError(category_id)
|
||||||
|
|
||||||
|
att = (
|
||||||
|
db.query(FinishedGoodsAttachment)
|
||||||
|
.filter(FinishedGoodsAttachment.zongpai_no == zongpai_no)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if att is None:
|
||||||
|
att = FinishedGoodsAttachment(zongpai_no=zongpai_no, determination="has")
|
||||||
|
db.add(att)
|
||||||
|
elif att.determination in ("undetermined", "none"):
|
||||||
|
att.determination = "has"
|
||||||
|
att.updated_at = datetime.now()
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
item = (
|
||||||
|
db.query(FinishedGoodsAttachmentItem)
|
||||||
|
.filter(
|
||||||
|
FinishedGoodsAttachmentItem.zongpai_no == zongpai_no,
|
||||||
|
FinishedGoodsAttachmentItem.category_id == category_id,
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if item is None:
|
||||||
|
item = FinishedGoodsAttachmentItem(
|
||||||
|
zongpai_no=zongpai_no,
|
||||||
|
category_id=category_id,
|
||||||
|
expected_qty=erp_qty,
|
||||||
|
)
|
||||||
|
db.add(item)
|
||||||
|
db.flush()
|
||||||
|
return item
|
||||||
|
|
||||||
|
|
||||||
|
def save_attachment_box(db: Session, req) -> dict:
|
||||||
|
zongpai_no = _validate_zongpai(req.zongpai_no)
|
||||||
|
|
||||||
|
erp = query_erp_info(db, zongpai_no) # raises ZongpaiNotFoundError if missing
|
||||||
|
paichan_no = erp["paichan_no"]
|
||||||
|
erp_qty = erp["quantity"]
|
||||||
|
|
||||||
|
item = _ensure_attachment_item(db, zongpai_no, req.category_id, erp_qty)
|
||||||
|
|
||||||
|
box = (
|
||||||
|
db.query(FinishedGoodsBox)
|
||||||
|
.filter(
|
||||||
|
FinishedGoodsBox.paichan_no == paichan_no,
|
||||||
|
FinishedGoodsBox.box_no == req.box_no,
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if box is not None:
|
||||||
|
existing_in_box = (
|
||||||
|
db.query(FinishedGoodsBoxAttachmentItem)
|
||||||
|
.filter(
|
||||||
|
FinishedGoodsBoxAttachmentItem.box_id == box.id,
|
||||||
|
FinishedGoodsBoxAttachmentItem.zongpai_no == zongpai_no,
|
||||||
|
FinishedGoodsBoxAttachmentItem.category_id == req.category_id,
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if existing_in_box is not None:
|
||||||
|
raise DuplicateAttachmentBoxItemError(paichan_no, req.box_no)
|
||||||
|
else:
|
||||||
|
box = FinishedGoodsBox(paichan_no=paichan_no, box_no=req.box_no)
|
||||||
|
db.add(box)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
# Cap check against expected_qty (mirrors product _ensure_quantity_within_erp_total)
|
||||||
|
already_boxed = _boxed_for_category(db, zongpai_no, req.category_id)
|
||||||
|
if already_boxed + req.quantity > item.expected_qty:
|
||||||
|
raise InvalidQuantityError()
|
||||||
|
|
||||||
|
record = FinishedGoodsBoxAttachmentItem(
|
||||||
|
box_id=box.id,
|
||||||
|
zongpai_no=zongpai_no,
|
||||||
|
category_id=req.category_id,
|
||||||
|
quantity=req.quantity,
|
||||||
|
)
|
||||||
|
db.add(record)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(record)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"box_item_id": record.id,
|
||||||
|
"paichan_no": paichan_no,
|
||||||
|
"box_no": req.box_no,
|
||||||
|
"zongpai_no": zongpai_no,
|
||||||
|
"category_id": req.category_id,
|
||||||
|
"quantity": req.quantity,
|
||||||
|
"created_at": record.created_at.isoformat(),
|
||||||
|
}
|
||||||
|
|||||||
151
tests/test_attachment_boxing.py
Normal file
151
tests/test_attachment_boxing.py
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.models.attachment import AttachmentCategory
|
||||||
|
|
||||||
|
|
||||||
|
def _seed(db: Session) -> list[int]:
|
||||||
|
cats = [
|
||||||
|
AttachmentCategory(name="TEST_CERT", sort_order=1, is_active=True),
|
||||||
|
AttachmentCategory(name="TEST_EXTRA", sort_order=2, is_active=True),
|
||||||
|
]
|
||||||
|
for c in cats:
|
||||||
|
db.add(c)
|
||||||
|
db.commit()
|
||||||
|
for c in cats:
|
||||||
|
db.refresh(c)
|
||||||
|
return [c.id for c in cats]
|
||||||
|
|
||||||
|
|
||||||
|
def test_attachment_box_basic_and_flip(client: TestClient, db: Session):
|
||||||
|
"""扫附件码即到货:未判定 → 自动 has,item 不存在则按 ERP 数量建。"""
|
||||||
|
cert_id, _ = _seed(db)
|
||||||
|
resp = client.post(
|
||||||
|
"/CargoTrace/attachment/box",
|
||||||
|
json={
|
||||||
|
"zongpai_no": "26BW0011",
|
||||||
|
"category_id": cert_id,
|
||||||
|
"box_no": 931,
|
||||||
|
"quantity": 30,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.json()
|
||||||
|
assert body["paichan_no"] == "W00009"
|
||||||
|
assert body["box_no"] == 931
|
||||||
|
assert body["quantity"] == 30
|
||||||
|
|
||||||
|
status = client.get(
|
||||||
|
"/CargoTrace/attachment/status", params={"zongpai_no": "26BW0011"}
|
||||||
|
).json()
|
||||||
|
assert status["determination"] == "has"
|
||||||
|
item = status["items"][0]
|
||||||
|
assert item["expected_qty"] == 80 # ERP 数量兜底
|
||||||
|
assert item["boxed_qty"] == 30
|
||||||
|
|
||||||
|
|
||||||
|
def test_attachment_box_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}],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
first = client.post(
|
||||||
|
"/CargoTrace/attachment/box",
|
||||||
|
json={
|
||||||
|
"zongpai_no": "26BW0011",
|
||||||
|
"category_id": cert_id,
|
||||||
|
"box_no": 932,
|
||||||
|
"quantity": 70,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert first.status_code == 200
|
||||||
|
over = client.post(
|
||||||
|
"/CargoTrace/attachment/box",
|
||||||
|
json={
|
||||||
|
"zongpai_no": "26BW0011",
|
||||||
|
"category_id": cert_id,
|
||||||
|
"box_no": 933,
|
||||||
|
"quantity": 11,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert over.status_code == 400
|
||||||
|
assert over.json()["error_code"] == "INVALID_QUANTITY"
|
||||||
|
|
||||||
|
|
||||||
|
def test_attachment_box_duplicate_in_same_box(client: TestClient, db: Session):
|
||||||
|
cert_id, _ = _seed(db)
|
||||||
|
client.post(
|
||||||
|
"/CargoTrace/attachment/box",
|
||||||
|
json={
|
||||||
|
"zongpai_no": "26BW0011",
|
||||||
|
"category_id": cert_id,
|
||||||
|
"box_no": 934,
|
||||||
|
"quantity": 10,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
dup = client.post(
|
||||||
|
"/CargoTrace/attachment/box",
|
||||||
|
json={
|
||||||
|
"zongpai_no": "26BW0011",
|
||||||
|
"category_id": cert_id,
|
||||||
|
"box_no": 934,
|
||||||
|
"quantity": 5,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert dup.status_code == 409
|
||||||
|
assert dup.json()["error_code"] == "DUPLICATE_BOX_NO"
|
||||||
|
|
||||||
|
|
||||||
|
def test_attachment_box_mixed_with_product(client: TestClient, db: Session):
|
||||||
|
"""附件与产品同箱:先装产品,再装附件,同 box_no。"""
|
||||||
|
cert_id, _ = _seed(db)
|
||||||
|
client.post(
|
||||||
|
"/CargoTrace/box",
|
||||||
|
json={"zongpai_no": "26BW0011", "box_no": 935, "quantity": 10},
|
||||||
|
)
|
||||||
|
resp = client.post(
|
||||||
|
"/CargoTrace/attachment/box",
|
||||||
|
json={
|
||||||
|
"zongpai_no": "26BW0011",
|
||||||
|
"category_id": cert_id,
|
||||||
|
"box_no": 935,
|
||||||
|
"quantity": 5,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
info = client.get(
|
||||||
|
"/CargoTrace/box/info", params={"zongpai_no": "26BW0011"}
|
||||||
|
).json()
|
||||||
|
box = next(b for b in info["existing_boxes"] if b["box_no"] == 935)
|
||||||
|
kinds = {i["kind"] for i in box["items"]}
|
||||||
|
assert kinds == {"product", "attachment"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_attachment_box_completion(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}],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
client.post(
|
||||||
|
"/CargoTrace/attachment/box",
|
||||||
|
json={
|
||||||
|
"zongpai_no": "26BW0011",
|
||||||
|
"category_id": cert_id,
|
||||||
|
"box_no": 936,
|
||||||
|
"quantity": 80,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
status = client.get(
|
||||||
|
"/CargoTrace/attachment/status", params={"zongpai_no": "26BW0011"}
|
||||||
|
).json()
|
||||||
|
assert status["all_complete"] is True
|
||||||
Reference in New Issue
Block a user