feat(server): add PUT /attachment/config with removal guard
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -3,8 +3,15 @@ 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 AttachmentStatusResponse, CategoryResponse
|
from app.schemas.attachment import (
|
||||||
from app.services.attachment_service import get_attachment_status
|
AttachmentConfigRequest,
|
||||||
|
AttachmentStatusResponse,
|
||||||
|
CategoryResponse,
|
||||||
|
)
|
||||||
|
from app.services.attachment_service import (
|
||||||
|
configure_attachment,
|
||||||
|
get_attachment_status,
|
||||||
|
)
|
||||||
|
|
||||||
router = APIRouter(tags=["attachment"])
|
router = APIRouter(tags=["attachment"])
|
||||||
|
|
||||||
@@ -26,3 +33,8 @@ def list_categories(db: Session = Depends(get_db)):
|
|||||||
@router.get("/attachment/status", response_model=AttachmentStatusResponse)
|
@router.get("/attachment/status", response_model=AttachmentStatusResponse)
|
||||||
def attachment_status(zongpai_no: str, db: Session = Depends(get_db)):
|
def attachment_status(zongpai_no: str, db: Session = Depends(get_db)):
|
||||||
return get_attachment_status(db, zongpai_no)
|
return get_attachment_status(db, zongpai_no)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/attachment/config", response_model=AttachmentStatusResponse)
|
||||||
|
def put_attachment_config(req: AttachmentConfigRequest, db: Session = Depends(get_db)):
|
||||||
|
return configure_attachment(db, req)
|
||||||
|
|||||||
55
app/main.py
55
app/main.py
@@ -32,6 +32,12 @@ from app.services.box_service import (
|
|||||||
InvalidZongpaiError,
|
InvalidZongpaiError,
|
||||||
ZongpaiNotFoundError,
|
ZongpaiNotFoundError,
|
||||||
)
|
)
|
||||||
|
from app.services.attachment_service import (
|
||||||
|
AlreadyOffShelfAttachmentError,
|
||||||
|
CategoryHasBoxesError,
|
||||||
|
CategoryNotFoundError,
|
||||||
|
DuplicateAttachmentLocationError,
|
||||||
|
)
|
||||||
|
|
||||||
app = FastAPI(title="CargoTrace API", version="0.1.0")
|
app = FastAPI(title="CargoTrace API", version="0.1.0")
|
||||||
|
|
||||||
@@ -176,6 +182,55 @@ async def invalid_box_item_handler(request: Request, exc: InvalidBoxItemError):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.exception_handler(CategoryNotFoundError)
|
||||||
|
async def category_not_found_handler(request: Request, exc: CategoryNotFoundError):
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=404,
|
||||||
|
content={"error_code": "CATEGORY_NOT_FOUND", "message": "附件类型不存在"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.exception_handler(CategoryHasBoxesError)
|
||||||
|
async def category_has_boxes_handler(request: Request, exc: CategoryHasBoxesError):
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=409,
|
||||||
|
content={
|
||||||
|
"error_code": "CATEGORY_HAS_BOXES",
|
||||||
|
"message": f"类型 {exc.category_name} 已有装箱记录,不可删除,可改应到数量",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.exception_handler(DuplicateAttachmentLocationError)
|
||||||
|
async def duplicate_attachment_location_handler(
|
||||||
|
request: Request, exc: DuplicateAttachmentLocationError
|
||||||
|
):
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=409,
|
||||||
|
content={
|
||||||
|
"error_code": "DUPLICATE_LOCATION",
|
||||||
|
"message": "该总排号该类型已存在货位记录",
|
||||||
|
"location_code": exc.location_code,
|
||||||
|
"registered_at": exc.registered_at,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.exception_handler(AlreadyOffShelfAttachmentError)
|
||||||
|
async def already_off_shelf_attachment_handler(
|
||||||
|
request: Request, exc: AlreadyOffShelfAttachmentError
|
||||||
|
):
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=409,
|
||||||
|
content={
|
||||||
|
"error_code": "ALREADY_OFF_SHELF",
|
||||||
|
"message": "该总排号该类型已下架至转运区域,不可重新上架",
|
||||||
|
"location_code": exc.location_code,
|
||||||
|
"registered_at": exc.registered_at,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
async def root():
|
async def root():
|
||||||
return {"message": "Welcome to CargoTrace API"}
|
return {"message": "Welcome to CargoTrace API"}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import re
|
import re
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
@@ -10,6 +11,7 @@ from app.models.attachment import (
|
|||||||
FinishedGoodsAttachmentLocation,
|
FinishedGoodsAttachmentLocation,
|
||||||
FinishedGoodsBoxAttachmentItem,
|
FinishedGoodsBoxAttachmentItem,
|
||||||
)
|
)
|
||||||
|
from app.schemas.attachment import AttachmentConfigRequest
|
||||||
from app.services.box_service import InvalidZongpaiError
|
from app.services.box_service import InvalidZongpaiError
|
||||||
|
|
||||||
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})$")
|
||||||
@@ -130,3 +132,69 @@ def get_attachment_status(db: Session, zongpai_no: str) -> dict:
|
|||||||
"all_complete": all_complete,
|
"all_complete": all_complete,
|
||||||
"items": items,
|
"items": items,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_categories_exist(db: Session, category_ids: list[int]) -> dict[int, str]:
|
||||||
|
names = _category_name_map(db, category_ids)
|
||||||
|
missing = [c for c in category_ids if c not in names]
|
||||||
|
if missing:
|
||||||
|
raise CategoryNotFoundError(missing[0])
|
||||||
|
return names
|
||||||
|
|
||||||
|
|
||||||
|
def configure_attachment(db: Session, req: AttachmentConfigRequest) -> dict:
|
||||||
|
zongpai_no = _validate_zongpai(req.zongpai_no)
|
||||||
|
|
||||||
|
# Validate categories referenced in the submitted list
|
||||||
|
submitted_ids = [it.category_id for it in req.items]
|
||||||
|
_ensure_categories_exist(db, submitted_ids)
|
||||||
|
|
||||||
|
# Upsert order-level determination row
|
||||||
|
att = (
|
||||||
|
db.query(FinishedGoodsAttachment)
|
||||||
|
.filter(FinishedGoodsAttachment.zongpai_no == zongpai_no)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if att is None:
|
||||||
|
att = FinishedGoodsAttachment(zongpai_no=zongpai_no, determination=req.determination)
|
||||||
|
db.add(att)
|
||||||
|
else:
|
||||||
|
att.determination = req.determination
|
||||||
|
att.updated_at = datetime.now()
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
# Load existing items
|
||||||
|
existing = (
|
||||||
|
db.query(FinishedGoodsAttachmentItem)
|
||||||
|
.filter(FinishedGoodsAttachmentItem.zongpai_no == zongpai_no)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
existing_by_cat = {it.category_id: it for it in existing}
|
||||||
|
submitted_set = set(submitted_ids)
|
||||||
|
boxed = _boxed_qty_map(db, zongpai_no)
|
||||||
|
names = _category_name_map(db, list(existing_by_cat.keys()) + submitted_ids)
|
||||||
|
|
||||||
|
# Remove items not in the submitted list; block those with boxed > 0
|
||||||
|
for cat_id, it in existing_by_cat.items():
|
||||||
|
if cat_id not in submitted_set:
|
||||||
|
if boxed.get(cat_id, 0) > 0:
|
||||||
|
raise CategoryHasBoxesError(names.get(cat_id, str(cat_id)))
|
||||||
|
db.delete(it)
|
||||||
|
|
||||||
|
# Upsert submitted items
|
||||||
|
for submitted in req.items:
|
||||||
|
it = existing_by_cat.get(submitted.category_id)
|
||||||
|
if it is None:
|
||||||
|
db.add(
|
||||||
|
FinishedGoodsAttachmentItem(
|
||||||
|
zongpai_no=zongpai_no,
|
||||||
|
category_id=submitted.category_id,
|
||||||
|
expected_qty=submitted.expected_qty,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
it.expected_qty = submitted.expected_qty
|
||||||
|
it.updated_at = datetime.now()
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
return get_attachment_status(db, zongpai_no)
|
||||||
|
|||||||
@@ -84,3 +84,113 @@ def test_status_reflects_config_and_boxed(client: TestClient, db: Session):
|
|||||||
assert item["expected_qty"] == 80
|
assert item["expected_qty"] == 80
|
||||||
assert item["boxed_qty"] == 30
|
assert item["boxed_qty"] == 30
|
||||||
assert item["complete"] is False
|
assert item["complete"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_config_sets_none(client: TestClient):
|
||||||
|
resp = client.put(
|
||||||
|
"/CargoTrace/attachment/config",
|
||||||
|
json={"zongpai_no": "26BW0011", "determination": "none", "items": []},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["determination"] == "none"
|
||||||
|
assert resp.json()["items"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_config_creates_and_updates_items(client: TestClient, db: Session):
|
||||||
|
cert_id, extra_id, _ = _seed_categories(db)
|
||||||
|
client.put(
|
||||||
|
"/CargoTrace/attachment/config",
|
||||||
|
json={
|
||||||
|
"zongpai_no": "26BW0011",
|
||||||
|
"determination": "has",
|
||||||
|
"items": [
|
||||||
|
{"category_id": cert_id, "expected_qty": 80},
|
||||||
|
{"category_id": extra_id, "expected_qty": 5},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
# Update: change extra expected, drop nothing
|
||||||
|
resp = client.put(
|
||||||
|
"/CargoTrace/attachment/config",
|
||||||
|
json={
|
||||||
|
"zongpai_no": "26BW0011",
|
||||||
|
"determination": "has",
|
||||||
|
"items": [
|
||||||
|
{"category_id": cert_id, "expected_qty": 80},
|
||||||
|
{"category_id": extra_id, "expected_qty": 8},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
extra = next(i for i in resp.json()["items"] if i["category_id"] == extra_id)
|
||||||
|
assert extra["expected_qty"] == 8
|
||||||
|
|
||||||
|
|
||||||
|
def test_config_rejects_removing_type_with_boxes(client: TestClient, db: Session):
|
||||||
|
cert_id, _, _ = _seed_categories(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": 922,
|
||||||
|
"quantity": 10,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
# Try to drop the type that now has boxes
|
||||||
|
resp = client.put(
|
||||||
|
"/CargoTrace/attachment/config",
|
||||||
|
json={
|
||||||
|
"zongpai_no": "26BW0011",
|
||||||
|
"determination": "has",
|
||||||
|
"items": [],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 409
|
||||||
|
assert resp.json()["error_code"] == "CATEGORY_HAS_BOXES"
|
||||||
|
|
||||||
|
|
||||||
|
def test_config_allows_removing_type_with_zero_boxes(client: TestClient, db: Session):
|
||||||
|
cert_id, extra_id, _ = _seed_categories(db)
|
||||||
|
client.put(
|
||||||
|
"/CargoTrace/attachment/config",
|
||||||
|
json={
|
||||||
|
"zongpai_no": "26BW0011",
|
||||||
|
"determination": "has",
|
||||||
|
"items": [
|
||||||
|
{"category_id": cert_id, "expected_qty": 80},
|
||||||
|
{"category_id": extra_id, "expected_qty": 5},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
resp = client.put(
|
||||||
|
"/CargoTrace/attachment/config",
|
||||||
|
json={
|
||||||
|
"zongpai_no": "26BW0011",
|
||||||
|
"determination": "has",
|
||||||
|
"items": [{"category_id": cert_id, "expected_qty": 80}],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert {i["category_id"] for i in resp.json()["items"]} == {cert_id}
|
||||||
|
|
||||||
|
|
||||||
|
def test_config_rejects_unknown_category(client: TestClient):
|
||||||
|
resp = client.put(
|
||||||
|
"/CargoTrace/attachment/config",
|
||||||
|
json={
|
||||||
|
"zongpai_no": "26BW0011",
|
||||||
|
"determination": "has",
|
||||||
|
"items": [{"category_id": 9999999, "expected_qty": 1}],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 404
|
||||||
|
assert resp.json()["error_code"] == "CATEGORY_NOT_FOUND"
|
||||||
|
|||||||
Reference in New Issue
Block a user