import re from datetime import datetime from sqlalchemy import func, select from sqlalchemy.orm import Session from app.models.attachment import ( AttachmentCategory, FinishedGoodsAttachment, FinishedGoodsAttachmentItem, FinishedGoodsAttachmentLocation, FinishedGoodsBoxAttachmentItem, ) from app.schemas.attachment import AttachmentConfigRequest from app.services.box_service import InvalidZongpaiError ZONGPAI_PATTERN = re.compile(r"^(\d{2}(B|C|T)\d+|\d{2}(BW|CW)\d{4})$") class CategoryNotFoundError(Exception): pass class AttachmentItemNotFoundError(Exception): pass class DuplicateAttachmentLocationError(Exception): def __init__(self, location_code: str, registered_at: str): self.location_code = location_code self.registered_at = registered_at class AlreadyOffShelfAttachmentError(Exception): def __init__(self, location_code: str, registered_at: str): self.location_code = location_code self.registered_at = registered_at class DuplicateAttachmentBoxItemError(Exception): def __init__(self, paichan_no: str, box_no: int): self.paichan_no = paichan_no self.box_no = box_no class CategoryHasBoxesError(Exception): """配置中删除已有装箱记录的类型时抛出。""" def __init__(self, category_name: str): self.category_name = category_name def _validate_zongpai(zongpai_no: str) -> str: zongpai_no = zongpai_no.strip().upper() if not ZONGPAI_PATTERN.match(zongpai_no): raise InvalidZongpaiError() return zongpai_no def _boxed_qty_map(db: Session, zongpai_no: str) -> dict[int, int]: """按 category_id 汇总已装数量。""" rows = db.execute( select( FinishedGoodsBoxAttachmentItem.category_id, func.sum(FinishedGoodsBoxAttachmentItem.quantity), ) .where(FinishedGoodsBoxAttachmentItem.zongpai_no == zongpai_no) .group_by(FinishedGoodsBoxAttachmentItem.category_id) ).all() return {row[0]: int(row[1] or 0) for row in rows} def _location_map(db: Session, zongpai_no: str) -> dict[int, str]: rows = ( db.query(FinishedGoodsAttachmentLocation) .filter(FinishedGoodsAttachmentLocation.zongpai_no == zongpai_no) .all() ) return {r.category_id: r.location_code for r in rows} def _category_name_map(db: Session, category_ids: list[int]) -> dict[int, str]: if not category_ids: return {} rows = ( db.query(AttachmentCategory) .filter(AttachmentCategory.id.in_(category_ids)) .all() ) return {r.id: r.name for r in rows} def get_attachment_status(db: Session, zongpai_no: str) -> dict: zongpai_no = _validate_zongpai(zongpai_no) att = ( db.query(FinishedGoodsAttachment) .filter(FinishedGoodsAttachment.zongpai_no == zongpai_no) .first() ) determination = att.determination if att else "undetermined" items_db = ( db.query(FinishedGoodsAttachmentItem) .filter(FinishedGoodsAttachmentItem.zongpai_no == zongpai_no) .all() ) category_ids = [i.category_id for i in items_db] names = _category_name_map(db, category_ids) boxed = _boxed_qty_map(db, zongpai_no) locations = _location_map(db, zongpai_no) items = [] for it in items_db: boxed_qty = boxed.get(it.category_id, 0) items.append( { "category_id": it.category_id, "name": names.get(it.category_id, "(已删除类型)"), "expected_qty": it.expected_qty, "boxed_qty": boxed_qty, "location_code": locations.get(it.category_id), "complete": boxed_qty >= it.expected_qty, } ) items.sort(key=lambda x: x["category_id"]) all_complete = determination == "has" and bool(items) and all(i["complete"] for i in items) return { "zongpai_no": zongpai_no, "determination": determination, "all_complete": all_complete, "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) def _is_transit_location(location_code: str) -> bool: return location_code.startswith("TRANS-") def _is_temp_storage_location(location_code: str) -> bool: return location_code.startswith("B") def register_attachment_location(db: Session, req) -> FinishedGoodsAttachmentLocation: zongpai_no = _validate_zongpai(req.zongpai_no) # Category must exist if not _category_name_map(db, [req.category_id]): raise CategoryNotFoundError(req.category_id) existing = ( db.query(FinishedGoodsAttachmentLocation) .filter( FinishedGoodsAttachmentLocation.zongpai_no == zongpai_no, FinishedGoodsAttachmentLocation.category_id == req.category_id, ) .first() ) if existing: if _is_transit_location(existing.location_code): raise AlreadyOffShelfAttachmentError( location_code=existing.location_code, registered_at=existing.created_at.isoformat(), ) target_is_transit = _is_transit_location(req.location_code) target_is_temp = _is_temp_storage_location(req.location_code) current_is_temp = _is_temp_storage_location(existing.location_code) if target_is_transit or (not current_is_temp and target_is_temp): previous_location = existing.location_code existing.location_code = req.location_code existing.created_at = datetime.now() db.commit() db.refresh(existing) existing.previous_location = previous_location return existing raise DuplicateAttachmentLocationError( location_code=existing.location_code, registered_at=existing.created_at.isoformat(), ) record = FinishedGoodsAttachmentLocation( zongpai_no=zongpai_no, category_id=req.category_id, location_code=req.location_code, ) db.add(record) db.commit() db.refresh(record) return record