Files
fastAPI/app/services/attachment_service.py
2026-08-12 14:15:51 +08:00

490 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.models.finished_goods import FinishedGoodsBox
from app.schemas.attachment import AttachmentConfigRequest
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})$")
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
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):
"""实物证据:不存在则建 itemexpected 兜底 = 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(),
}
def update_attachment_box(db: Session, item_id: int, req) -> dict:
record = (
db.query(FinishedGoodsBoxAttachmentItem)
.filter(FinishedGoodsBoxAttachmentItem.id == item_id)
.first()
)
if record is None:
raise AttachmentItemNotFoundError()
current_box = (
db.query(FinishedGoodsBox)
.filter(FinishedGoodsBox.id == record.box_id)
.first()
)
if current_box is None:
raise AttachmentItemNotFoundError()
paichan_no = current_box.paichan_no
target_box = (
db.query(FinishedGoodsBox)
.filter(
FinishedGoodsBox.paichan_no == paichan_no,
FinishedGoodsBox.box_no == req.box_no,
)
.first()
)
if target_box is None:
target_box = FinishedGoodsBox(paichan_no=paichan_no, box_no=req.box_no)
db.add(target_box)
db.flush()
else:
dup = (
db.query(FinishedGoodsBoxAttachmentItem)
.filter(
FinishedGoodsBoxAttachmentItem.box_id == target_box.id,
FinishedGoodsBoxAttachmentItem.zongpai_no == record.zongpai_no,
FinishedGoodsBoxAttachmentItem.category_id == record.category_id,
FinishedGoodsBoxAttachmentItem.id != record.id,
)
.first()
)
if dup is not None:
raise DuplicateAttachmentBoxItemError(paichan_no, req.box_no)
item = (
db.query(FinishedGoodsAttachmentItem)
.filter(
FinishedGoodsAttachmentItem.zongpai_no == record.zongpai_no,
FinishedGoodsAttachmentItem.category_id == record.category_id,
)
.first()
)
expected_qty = item.expected_qty if item is not None else 0
already_boxed = _boxed_for_category(
db, record.zongpai_no, record.category_id, exclude_item_id=record.id
)
if already_boxed + req.quantity > expected_qty:
raise InvalidQuantityError()
old_box_id = record.box_id
record.box_id = target_box.id
record.quantity = req.quantity
if old_box_id != target_box.id:
_cleanup_empty_box(db, old_box_id, exclude_item_id=record.id)
db.commit()
db.refresh(record)
return {
"box_item_id": record.id,
"paichan_no": paichan_no,
"box_no": req.box_no,
"zongpai_no": record.zongpai_no,
"category_id": record.category_id,
"quantity": int(record.quantity or 0),
"updated_at": record.created_at.isoformat(),
}
def delete_attachment_box(db: Session, item_id: int) -> dict:
record = (
db.query(FinishedGoodsBoxAttachmentItem)
.filter(FinishedGoodsBoxAttachmentItem.id == item_id)
.first()
)
if record is None:
raise AttachmentItemNotFoundError()
box_id = record.box_id
db.delete(record)
db.flush()
_cleanup_empty_box(db, box_id)
db.commit()
return {"box_item_id": item_id, "deleted": True}
def _cleanup_empty_box(db: Session, box_id: int, exclude_item_id: int | None = None) -> None:
"""If a box has no product items and no attachment items, delete it."""
from app.models.finished_goods import FinishedGoodsBoxItem
product_q = db.query(FinishedGoodsBoxItem.id).filter(
FinishedGoodsBoxItem.box_id == box_id
)
if product_q.first() is not None:
return
att_q = db.query(FinishedGoodsBoxAttachmentItem.id).filter(
FinishedGoodsBoxAttachmentItem.box_id == box_id
)
if exclude_item_id is not None:
att_q = att_q.filter(FinishedGoodsBoxAttachmentItem.id != exclude_item_id)
if att_q.first() is not None:
return
box = db.query(FinishedGoodsBox).filter(FinishedGoodsBox.id == box_id).first()
if box is not None:
db.delete(box)