feat: add accessory service layer with CRUD and shelving logic

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Misaka
2026-05-24 17:38:17 +08:00
parent b9f76b03fa
commit 35bea2a81e

View File

@@ -0,0 +1,339 @@
import re
from collections import defaultdict
from datetime import datetime
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.models.accessory import AccessoryType, FinishedGoodsAccessory
from app.models.finished_goods import FinishedGoodsBox, FinishedGoodsBoxItem
from app.schemas.accessory import (
AccessoryCreateRequest,
AccessoryTypeCreateRequest,
AccessoryTypeUpdateRequest,
AccessoryUpdateRequest,
)
PAICHAN_PATTERN = re.compile(r"^[A-Z]{1,2}\d{5}(-J)?$")
# ── Error classes ──
class InvalidPaichanError(Exception):
pass
class PaichanNotFoundError(Exception):
pass
class AccessoryNotFoundError(Exception):
pass
class AccessoryAlreadyBoxedError(Exception):
pass
class AccessoryDuplicateLocationError(Exception):
def __init__(self, location_code: str, registered_at: str):
self.location_code = location_code
self.registered_at = registered_at
class AccessoryAlreadyOffShelfError(Exception):
def __init__(self, location_code: str, registered_at: str):
self.location_code = location_code
self.registered_at = registered_at
class AccessoryTypeNotFoundError(Exception):
pass
class DuplicateAccessoryTypeNameError(Exception):
pass
# ── Helpers ──
def _is_transit_location(location_code: str) -> bool:
return location_code.startswith("TRANS-")
def _erp_paicha_items_sql(db: Session) -> str:
"""Reuse the same ERP query pattern as location_service."""
dialect_name = db.bind.dialect.name if db.bind is not None else ""
if dialect_name == "postgresql":
return (
'SELECT "总排号", "工令号", "数量" '
'FROM "ERPAuto"."vw_productionContractData" '
'WHERE "排产号" = :paicha_no'
)
return (
"SELECT [总排号], [工令号], [数量] "
"FROM [ERPAuto].[vw_productionContractData] "
"WHERE [排产号] = :paicha_no"
)
def _is_accessory_boxed(db: Session, accessory: FinishedGoodsAccessory) -> bool:
"""Check whether the accessory is already boxed (EXISTS in box_item as 'accessory')."""
boxed = (
db.query(FinishedGoodsBoxItem)
.join(FinishedGoodsBox, FinishedGoodsBoxItem.box_id == FinishedGoodsBox.id)
.filter(
FinishedGoodsBoxItem.zongpai_no == accessory.zongpai_no,
FinishedGoodsBox.paichan_no == accessory.paichan_no,
FinishedGoodsBoxItem.item_type == "accessory",
)
.first()
)
return boxed is not None
def _accessory_to_dict(
accessory: FinishedGoodsAccessory, is_boxed: bool = False
) -> dict:
return {
"id": accessory.id,
"paichan_no": accessory.paichan_no,
"zongpai_no": accessory.zongpai_no,
"accessory_type": accessory.accessory_type,
"quantity": accessory.quantity,
"location_code": accessory.location_code,
"is_boxed": is_boxed,
"created_at": accessory.created_at.isoformat(),
}
# ── Work order query ──
def query_work_orders(db: Session, paichan_no: str) -> dict:
"""Query ERP for work orders grouped by work_order_no for a given paichan_no."""
paichan_no = paichan_no.strip().upper()
if not PAICHAN_PATTERN.match(paichan_no):
raise InvalidPaichanError()
rows = db.execute(
text(_erp_paicha_items_sql(db)), {"paicha_no": paichan_no}
).fetchall()
if not rows:
raise PaichanNotFoundError()
grouped: dict[str, list[str]] = defaultdict(list)
for row in rows:
zongpai_no = row[0]
work_order_no = row[1]
grouped[work_order_no].append(zongpai_no)
work_orders = [
{"work_order_no": wo_no, "zongpai_nos": zongpai_nos}
for wo_no, zongpai_nos in grouped.items()
]
return {
"paichan_no": paichan_no,
"work_orders": work_orders,
}
# ── Accessory CRUD ──
def list_accessories(db: Session, paichan_no: str) -> dict:
"""List all accessories for a given paichan_no, including is_boxed status."""
accessories = (
db.query(FinishedGoodsAccessory)
.filter(FinishedGoodsAccessory.paichan_no == paichan_no)
.order_by(FinishedGoodsAccessory.id)
.all()
)
items = []
for acc in accessories:
boxed = _is_accessory_boxed(db, acc)
items.append(_accessory_to_dict(acc, is_boxed=boxed))
return {
"paichan_no": paichan_no,
"items": items,
}
def create_accessory(
db: Session, req: AccessoryCreateRequest
) -> FinishedGoodsAccessory:
"""Create a new accessory record after validating against ERP data."""
# 1. Validate paichan_no exists in ERP
rows = db.execute(
text(_erp_paicha_items_sql(db)), {"paicha_no": req.paichan_no}
).fetchall()
if not rows:
raise PaichanNotFoundError()
# 2. Validate zongpai_no belongs to this paichan_no in ERP
valid_zongpai_nos = {row[0] for row in rows}
if req.zongpai_no not in valid_zongpai_nos:
raise ValueError(
f"zongpai_no {req.zongpai_no} does not belong to paichan_no {req.paichan_no}"
)
# 3. Create the record
record = FinishedGoodsAccessory(
paichan_no=req.paichan_no,
zongpai_no=req.zongpai_no,
accessory_type=req.accessory_type,
quantity=req.quantity,
location_code=req.location_code,
)
db.add(record)
db.commit()
db.refresh(record)
return record
def update_accessory(
db: Session, accessory_id: int, req: AccessoryUpdateRequest
) -> dict:
"""Partially update an accessory record with shelving logic."""
accessory = (
db.query(FinishedGoodsAccessory)
.filter(FinishedGoodsAccessory.id == accessory_id)
.first()
)
if accessory is None:
raise AccessoryNotFoundError()
# Check if boxed
if _is_accessory_boxed(db, accessory):
raise AccessoryAlreadyBoxedError()
# Shelving logic when location_code changes
if req.location_code is not None and accessory.location_code is not None:
existing_loc = accessory.location_code
new_loc = req.location_code
if existing_loc == new_loc:
# No change needed for location
pass
elif _is_transit_location(existing_loc):
raise AccessoryAlreadyOffShelfError(
location_code=existing_loc,
registered_at=accessory.created_at.isoformat(),
)
elif _is_transit_location(new_loc):
# Off-shelf/down-shelf: normal operation
accessory.location_code = new_loc
accessory.created_at = datetime.now()
else:
# Both are normal locations → duplicate
raise AccessoryDuplicateLocationError(
location_code=existing_loc,
registered_at=accessory.created_at.isoformat(),
)
elif req.location_code is not None and accessory.location_code is None:
# Not yet shelved, just set the location
accessory.location_code = req.location_code
# Update other fields if provided
if req.accessory_type is not None:
accessory.accessory_type = req.accessory_type
if req.quantity is not None:
accessory.quantity = req.quantity
db.commit()
db.refresh(accessory)
return _accessory_to_dict(accessory, is_boxed=_is_accessory_boxed(db, accessory))
def delete_accessory(db: Session, accessory_id: int) -> dict:
"""Delete an accessory record if it is not boxed."""
accessory = (
db.query(FinishedGoodsAccessory)
.filter(FinishedGoodsAccessory.id == accessory_id)
.first()
)
if accessory is None:
raise AccessoryNotFoundError()
if _is_accessory_boxed(db, accessory):
raise AccessoryAlreadyBoxedError()
db.delete(accessory)
db.commit()
return {"id": accessory_id, "deleted": True}
# ── Accessory type management ──
def list_accessory_types(db: Session) -> dict:
"""List all accessory types ordered by sort_order."""
types = (
db.query(AccessoryType)
.order_by(AccessoryType.sort_order, AccessoryType.id)
.all()
)
return {
"types": [
{"id": t.id, "name": t.name, "sort_order": t.sort_order} for t in types
]
}
def create_accessory_type(db: Session, req: AccessoryTypeCreateRequest) -> dict:
"""Create a new accessory type. Name must be unique."""
existing = db.query(AccessoryType).filter(AccessoryType.name == req.name).first()
if existing is not None:
raise DuplicateAccessoryTypeNameError()
record = AccessoryType(
name=req.name,
sort_order=req.sort_order,
)
db.add(record)
db.commit()
db.refresh(record)
return {"id": record.id, "name": record.name, "sort_order": record.sort_order}
def update_accessory_type(
db: Session, type_id: int, req: AccessoryTypeUpdateRequest
) -> dict:
"""Partially update an accessory type."""
record = db.query(AccessoryType).filter(AccessoryType.id == type_id).first()
if record is None:
raise AccessoryTypeNotFoundError()
# If name is being changed, check for duplicate
if req.name is not None and req.name != record.name:
duplicate = (
db.query(AccessoryType).filter(AccessoryType.name == req.name).first()
)
if duplicate is not None:
raise DuplicateAccessoryTypeNameError()
record.name = req.name
if req.sort_order is not None:
record.sort_order = req.sort_order
db.commit()
db.refresh(record)
return {"id": record.id, "name": record.name, "sort_order": record.sort_order}
def delete_accessory_type(db: Session, type_id: int) -> dict:
"""Delete an accessory type."""
record = db.query(AccessoryType).filter(AccessoryType.id == type_id).first()
if record is None:
raise AccessoryTypeNotFoundError()
db.delete(record)
db.commit()
return {"id": type_id, "deleted": True}