- Add PendingAccessory schema and pending_accessories to BoxInfoResponse - Add item_type field to BoxItemDetail, CurrentZongpaiBox, BoxSaveRequest, BoxSaveResponse - Query pending (unboxed) accessories in get_box_info - Branch save_box_record to skip ERP validation for accessory items Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
437 lines
13 KiB
Python
437 lines
13 KiB
Python
import re
|
|
|
|
from sqlalchemy import bindparam, text
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.models.accessory import FinishedGoodsAccessory
|
|
from app.models.finished_goods import FinishedGoodsBox, FinishedGoodsBoxItem
|
|
from app.schemas.box import BoxSaveRequest, BoxUpdateRequest
|
|
|
|
ZONGPAI_PATTERN = re.compile(r"^(\d{2}(B|C|T)\d+|\d{2}(BW|CW)\d{4})$")
|
|
|
|
|
|
class InvalidZongpaiError(Exception):
|
|
pass
|
|
|
|
|
|
class ZongpaiNotFoundError(Exception):
|
|
pass
|
|
|
|
|
|
class DuplicateBoxItemError(Exception):
|
|
def __init__(self, paichan_no: str, box_no: int):
|
|
self.paichan_no = paichan_no
|
|
self.box_no = box_no
|
|
|
|
|
|
class BoxItemNotFoundError(Exception):
|
|
pass
|
|
|
|
|
|
class InvalidBoxItemError(Exception):
|
|
pass
|
|
|
|
|
|
class InvalidQuantityError(Exception):
|
|
pass
|
|
|
|
|
|
def query_erp_info(db: Session, zongpai_no: str) -> dict:
|
|
"""从 ERP 视图查询总排号对应的排产号、工令号和数量。"""
|
|
sql = text(_erp_info_sql(db))
|
|
row = db.execute(sql, {"zongpai_no": zongpai_no}).fetchone()
|
|
if not row:
|
|
raise ZongpaiNotFoundError()
|
|
return {
|
|
"zongpai_no": row[0],
|
|
"paichan_no": row[1],
|
|
"quantity": int(row[2]),
|
|
"work_order_no": row[3],
|
|
}
|
|
|
|
|
|
def _erp_info_sql(db: Session) -> str:
|
|
dialect_name = db.bind.dialect.name if db.bind is not None else ""
|
|
if dialect_name == "postgresql":
|
|
return (
|
|
'SELECT "总排号", "排产号", "数量", "工令号" '
|
|
'FROM "ERPAuto"."vw_productionContractData" '
|
|
'WHERE "总排号" = :zongpai_no '
|
|
"LIMIT 1"
|
|
)
|
|
return (
|
|
"SELECT TOP 1 [总排号], [排产号], [数量], [工令号] "
|
|
"FROM [ERPAuto].[vw_productionContractData] "
|
|
"WHERE [总排号] = :zongpai_no"
|
|
)
|
|
|
|
|
|
def query_erp_item_info_map(db: Session, zongpai_nos: list[str]) -> dict[str, dict]:
|
|
"""批量查询总排号对应的工令号和 ERP 总数量。"""
|
|
if not zongpai_nos:
|
|
return {}
|
|
|
|
dialect_name = db.bind.dialect.name if db.bind is not None else ""
|
|
if dialect_name == "postgresql":
|
|
sql = text(
|
|
'SELECT "总排号", "工令号", "数量" '
|
|
'FROM "ERPAuto"."vw_productionContractData" '
|
|
'WHERE "总排号" IN :zongpai_nos'
|
|
)
|
|
else:
|
|
sql = text(
|
|
"SELECT [总排号], [工令号], [数量] "
|
|
"FROM [ERPAuto].[vw_productionContractData] "
|
|
"WHERE [总排号] IN :zongpai_nos"
|
|
)
|
|
rows = db.execute(
|
|
sql.bindparams(bindparam("zongpai_nos", expanding=True)),
|
|
{"zongpai_nos": sorted(set(zongpai_nos))},
|
|
).fetchall()
|
|
return {
|
|
row[0]: {
|
|
"work_order_no": row[1],
|
|
"total_quantity": int(row[2]) if row[2] is not None else None,
|
|
}
|
|
for row in rows
|
|
}
|
|
|
|
|
|
def get_box_info(db: Session, zongpai_no: str) -> dict:
|
|
"""获取装箱信息:排产号、数量、已有箱号明细。"""
|
|
zongpai_no = zongpai_no.strip().upper()
|
|
if not ZONGPAI_PATTERN.match(zongpai_no):
|
|
raise InvalidZongpaiError()
|
|
erp = query_erp_info(db, zongpai_no)
|
|
paichan_no = erp["paichan_no"]
|
|
quantity = erp["quantity"]
|
|
|
|
boxes = (
|
|
db.query(FinishedGoodsBox)
|
|
.filter(FinishedGoodsBox.paichan_no == paichan_no)
|
|
.order_by(FinishedGoodsBox.box_no)
|
|
.all()
|
|
)
|
|
|
|
box_items = {}
|
|
item_zongpai_nos = []
|
|
for box in boxes:
|
|
items = (
|
|
db.query(FinishedGoodsBoxItem)
|
|
.filter(FinishedGoodsBoxItem.box_id == box.id)
|
|
.all()
|
|
)
|
|
box_items[box.id] = items
|
|
item_zongpai_nos.extend(item.zongpai_no for item in items)
|
|
|
|
erp_item_info_map = query_erp_item_info_map(db, item_zongpai_nos)
|
|
|
|
existing_boxes = []
|
|
current_zongpai_boxes = []
|
|
max_box_no = 0
|
|
for box in boxes:
|
|
items = box_items[box.id]
|
|
for item in items:
|
|
if item.zongpai_no == zongpai_no:
|
|
current_zongpai_boxes.append(
|
|
{
|
|
"box_item_id": item.id,
|
|
"box_no": box.box_no,
|
|
"quantity": int(item.quantity or 0),
|
|
"item_type": item.item_type or "product",
|
|
}
|
|
)
|
|
existing_boxes.append(
|
|
{
|
|
"box_no": box.box_no,
|
|
"items": [
|
|
{
|
|
"box_item_id": item.id,
|
|
"zongpai_no": item.zongpai_no,
|
|
"work_order_no": erp_item_info_map.get(item.zongpai_no, {}).get(
|
|
"work_order_no"
|
|
),
|
|
"quantity": int(item.quantity or 0),
|
|
"total_quantity": erp_item_info_map.get(
|
|
item.zongpai_no, {}
|
|
).get("total_quantity"),
|
|
"item_type": item.item_type or "product",
|
|
}
|
|
for item in items
|
|
],
|
|
}
|
|
)
|
|
if box.box_no > max_box_no:
|
|
max_box_no = box.box_no
|
|
|
|
# Query pending accessories for this zongpai_no
|
|
pending_accessories = []
|
|
accessory_records = (
|
|
db.query(FinishedGoodsAccessory)
|
|
.filter(
|
|
FinishedGoodsAccessory.zongpai_no == zongpai_no,
|
|
FinishedGoodsAccessory.location_code.isnot(None),
|
|
)
|
|
.all()
|
|
)
|
|
for acc in accessory_records:
|
|
is_boxed = (
|
|
db.query(FinishedGoodsBoxItem)
|
|
.join(FinishedGoodsBox, FinishedGoodsBoxItem.box_id == FinishedGoodsBox.id)
|
|
.filter(
|
|
FinishedGoodsBoxItem.zongpai_no == acc.zongpai_no,
|
|
FinishedGoodsBox.paichan_no == acc.paichan_no,
|
|
FinishedGoodsBoxItem.item_type == "accessory",
|
|
)
|
|
.first()
|
|
) is not None
|
|
if not is_boxed:
|
|
pending_accessories.append(
|
|
{
|
|
"accessory_id": acc.id,
|
|
"accessory_type": acc.accessory_type,
|
|
"quantity": acc.quantity,
|
|
"location_code": acc.location_code,
|
|
}
|
|
)
|
|
|
|
return {
|
|
"zongpai_no": zongpai_no,
|
|
"paichan_no": paichan_no,
|
|
"work_order_no": erp["work_order_no"],
|
|
"quantity": quantity,
|
|
"current_zongpai_boxes": current_zongpai_boxes,
|
|
"existing_boxes": existing_boxes,
|
|
"pending_accessories": pending_accessories,
|
|
"max_box_no": max_box_no,
|
|
"suggested_box_no": max_box_no + 1,
|
|
}
|
|
|
|
|
|
def _packed_quantity_for_zongpai(
|
|
db: Session,
|
|
paichan_no: str,
|
|
zongpai_no: str,
|
|
exclude_box_item_id: int | None = None,
|
|
) -> int:
|
|
query = (
|
|
db.query(FinishedGoodsBoxItem)
|
|
.join(FinishedGoodsBox, FinishedGoodsBoxItem.box_id == FinishedGoodsBox.id)
|
|
.filter(
|
|
FinishedGoodsBox.paichan_no == paichan_no,
|
|
FinishedGoodsBoxItem.zongpai_no == zongpai_no,
|
|
)
|
|
)
|
|
if exclude_box_item_id is not None:
|
|
query = query.filter(FinishedGoodsBoxItem.id != exclude_box_item_id)
|
|
return sum(int(item.quantity or 0) for item in query.all())
|
|
|
|
|
|
def _ensure_quantity_within_erp_total(
|
|
db: Session,
|
|
paichan_no: str,
|
|
zongpai_no: str,
|
|
erp_quantity: int,
|
|
submitted_quantity: int,
|
|
exclude_box_item_id: int | None = None,
|
|
) -> None:
|
|
packed_quantity = _packed_quantity_for_zongpai(
|
|
db,
|
|
paichan_no,
|
|
zongpai_no,
|
|
exclude_box_item_id=exclude_box_item_id,
|
|
)
|
|
if packed_quantity + submitted_quantity > erp_quantity:
|
|
raise InvalidQuantityError()
|
|
|
|
|
|
def save_box_record(db: Session, req: BoxSaveRequest) -> dict:
|
|
"""保存装箱记录。同箱可凑箱,但同箱同总排不可重复。"""
|
|
if req.item_type == "accessory":
|
|
# For accessories, skip ERP quantity validation
|
|
accessory = (
|
|
db.query(FinishedGoodsAccessory)
|
|
.filter(FinishedGoodsAccessory.id == req.accessory_id)
|
|
.first()
|
|
)
|
|
if accessory is None:
|
|
raise BoxItemNotFoundError()
|
|
paichan_no = accessory.paichan_no
|
|
else:
|
|
erp = query_erp_info(db, req.zongpai_no)
|
|
paichan_no = erp["paichan_no"]
|
|
|
|
box = (
|
|
db.query(FinishedGoodsBox)
|
|
.filter(
|
|
FinishedGoodsBox.paichan_no == paichan_no,
|
|
FinishedGoodsBox.box_no == req.box_no,
|
|
)
|
|
.first()
|
|
)
|
|
|
|
if box:
|
|
existing_item = (
|
|
db.query(FinishedGoodsBoxItem)
|
|
.filter(
|
|
FinishedGoodsBoxItem.box_id == box.id,
|
|
FinishedGoodsBoxItem.zongpai_no == req.zongpai_no,
|
|
)
|
|
.first()
|
|
)
|
|
if existing_item:
|
|
raise DuplicateBoxItemError(paichan_no, req.box_no)
|
|
|
|
if req.item_type != "accessory":
|
|
_ensure_quantity_within_erp_total(
|
|
db,
|
|
paichan_no,
|
|
req.zongpai_no,
|
|
erp["quantity"],
|
|
req.quantity,
|
|
)
|
|
|
|
if box is None:
|
|
box = FinishedGoodsBox(
|
|
paichan_no=paichan_no,
|
|
box_no=req.box_no,
|
|
)
|
|
db.add(box)
|
|
db.flush()
|
|
|
|
item = FinishedGoodsBoxItem(
|
|
box_id=box.id,
|
|
zongpai_no=req.zongpai_no,
|
|
quantity=req.quantity,
|
|
item_type=req.item_type,
|
|
)
|
|
db.add(item)
|
|
db.commit()
|
|
db.refresh(item)
|
|
|
|
return {
|
|
"box_item_id": item.id,
|
|
"paichan_no": paichan_no,
|
|
"box_no": req.box_no,
|
|
"zongpai_no": req.zongpai_no,
|
|
"quantity": req.quantity,
|
|
"item_type": req.item_type,
|
|
"created_at": item.created_at.isoformat(),
|
|
}
|
|
|
|
|
|
def update_box_item(db: Session, box_item_id: int, req: BoxUpdateRequest) -> dict:
|
|
"""更新已分配明细的箱号和数量。"""
|
|
item = (
|
|
db.query(FinishedGoodsBoxItem)
|
|
.filter(FinishedGoodsBoxItem.id == box_item_id)
|
|
.first()
|
|
)
|
|
if item is None:
|
|
raise BoxItemNotFoundError()
|
|
|
|
current_box = (
|
|
db.query(FinishedGoodsBox).filter(FinishedGoodsBox.id == item.box_id).first()
|
|
)
|
|
if current_box is None:
|
|
raise InvalidBoxItemError()
|
|
|
|
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:
|
|
duplicate_item = (
|
|
db.query(FinishedGoodsBoxItem)
|
|
.filter(
|
|
FinishedGoodsBoxItem.box_id == target_box.id,
|
|
FinishedGoodsBoxItem.zongpai_no == item.zongpai_no,
|
|
FinishedGoodsBoxItem.id != item.id,
|
|
)
|
|
.first()
|
|
)
|
|
if duplicate_item is not None:
|
|
raise DuplicateBoxItemError(paichan_no, req.box_no)
|
|
|
|
erp = query_erp_info(db, item.zongpai_no)
|
|
_ensure_quantity_within_erp_total(
|
|
db,
|
|
paichan_no,
|
|
item.zongpai_no,
|
|
erp["quantity"],
|
|
req.quantity,
|
|
exclude_box_item_id=box_item_id,
|
|
)
|
|
|
|
old_box_id = item.box_id
|
|
item.box_id = target_box.id
|
|
item.quantity = req.quantity
|
|
|
|
if old_box_id != target_box.id:
|
|
old_box_has_items = (
|
|
db.query(FinishedGoodsBoxItem.id)
|
|
.filter(
|
|
FinishedGoodsBoxItem.box_id == old_box_id,
|
|
FinishedGoodsBoxItem.id != item.id,
|
|
)
|
|
.first()
|
|
)
|
|
if old_box_has_items is None:
|
|
old_box = (
|
|
db.query(FinishedGoodsBox)
|
|
.filter(FinishedGoodsBox.id == old_box_id)
|
|
.first()
|
|
)
|
|
if old_box is not None:
|
|
db.delete(old_box)
|
|
|
|
db.commit()
|
|
db.refresh(item)
|
|
|
|
return {
|
|
"box_item_id": item.id,
|
|
"paichan_no": paichan_no,
|
|
"box_no": req.box_no,
|
|
"zongpai_no": item.zongpai_no,
|
|
"quantity": int(item.quantity or 0),
|
|
"updated_at": item.created_at.isoformat(),
|
|
}
|
|
|
|
|
|
def delete_box_item(db: Session, box_item_id: int) -> dict:
|
|
"""删除误录的装箱明细;若箱号下无其他明细,同步删除空箱号。"""
|
|
item = (
|
|
db.query(FinishedGoodsBoxItem)
|
|
.filter(FinishedGoodsBoxItem.id == box_item_id)
|
|
.first()
|
|
)
|
|
if item is None:
|
|
raise BoxItemNotFoundError()
|
|
|
|
box_id = item.box_id
|
|
db.delete(item)
|
|
db.flush()
|
|
|
|
remaining = (
|
|
db.query(FinishedGoodsBoxItem.id)
|
|
.filter(FinishedGoodsBoxItem.box_id == box_id)
|
|
.first()
|
|
)
|
|
if remaining is None:
|
|
box = db.query(FinishedGoodsBox).filter(FinishedGoodsBox.id == box_id).first()
|
|
if box is not None:
|
|
db.delete(box)
|
|
|
|
db.commit()
|
|
return {"box_item_id": box_item_id, "deleted": True}
|