from __future__ import annotations from datetime import date, datetime from decimal import Decimal from fastapi import HTTPException from sqlalchemy import select from sqlalchemy.orm import Session from app.models.master_data import Item, Warehouse from app.models.operations import InventoryTxn, SpecialWarehouseAdjustment, SpecialWarehouseAdjustmentLine, StockLot from app.schemas.operations import SpecialWarehouseAdjustmentCreate from app.services.operations import ( build_inventory_lot_no, clamp_non_negative, create_inventory_txn, get_location_or_default, to_decimal, upsert_stock_balance, ) from app.services.sales_planning import next_doc_no from app.services.stocktake import ensure_warehouses_unlocked SOURCE_DOC_TYPE = "SPECIAL_ADJUSTMENT" LOT_ROLE = "SPECIAL_ADJUSTMENT" def _strip_text(value: str | None) -> str: return str(value or "").strip() def _raise_value_from_http(exc: HTTPException) -> None: detail = getattr(exc, "detail", None) raise ValueError(str(detail or exc)) from exc def _format_decimal(value: Decimal) -> str: normalized = value.quantize(Decimal("0.000001")).normalize() if normalized == normalized.to_integral(): return str(normalized.quantize(Decimal("1"))) return format(normalized, "f") def _trim_255(value: str) -> str: return value[:255] def _trim_to(value: str, limit: int) -> str: if limit <= 0: return "" return value[:limit] def _warehouse_type_label(warehouse_type: str) -> str: return { "AUX": "辅料", "FINISHED": "成品", "RETURN": "退货", "SEMI": "半成品", "SCRAP": "废品", "RAW": "原材料", }.get(warehouse_type, "库存") def _build_adjustment_no(db: Session, *, direction: str, adjusted_at: datetime) -> str: direction_label = "入库" if direction == "IN" else "出库" return next_doc_no( db, SpecialWarehouseAdjustment, "adjustment_no", f"特殊调整{adjusted_at.strftime('%Y%m%d')}{direction_label}", ) def _build_special_lot_no( db: Session, *, adjustment_id: int, item_id: int, warehouse_type: str, now: datetime, ) -> str: if warehouse_type == "RAW": return build_inventory_lot_no(db, item_id) prefix = f"{_warehouse_type_label(warehouse_type)}特殊{now.strftime('%Y%m%d')}-{adjustment_id}" candidate = next_doc_no(db, StockLot, "lot_no", prefix) sequence = 1 while db.scalar(select(StockLot.id).where(StockLot.lot_no == candidate).limit(1)): sequence += 1 candidate = f"{prefix}-{sequence:03d}" return candidate def _normalize_change( *, warehouse_type: str, raw_qty: Decimal, raw_weight: Decimal, ) -> tuple[Decimal, Decimal]: qty = to_decimal(raw_qty) weight = to_decimal(raw_weight) if qty < 0 or weight < 0: raise ValueError("特殊调整数量和重量不能小于0") if warehouse_type in {"RAW", "SCRAP"}: qty = Decimal("0") if weight <= 0: raise ValueError("特殊调整重量必须大于0") elif warehouse_type == "AUX": weight = Decimal("0") if qty <= 0: raise ValueError("特殊调整数量必须大于0") elif warehouse_type == "SEMI": if qty > 0 and weight > 0: raise ValueError("半成品特殊调整只允许选择数量或重量一种口径") if qty <= 0 and weight <= 0: raise ValueError("特殊调整数量或重量必须大于0") else: if qty <= 0: raise ValueError("特殊调整数量必须大于0") weight = max(weight, Decimal("0")) return to_decimal(qty), to_decimal(weight) def _assert_out_available(lot: StockLot, *, qty: Decimal, weight: Decimal, warehouse_type: str) -> None: available_qty = clamp_non_negative(to_decimal(lot.remaining_qty) - to_decimal(lot.locked_qty)) available_weight = clamp_non_negative(to_decimal(lot.remaining_weight_kg) - to_decimal(lot.locked_weight_kg)) if warehouse_type == "AUX" and qty > available_qty: raise ValueError("特殊出库数量不能超过当前可用数量") if warehouse_type in {"RAW", "SCRAP"} and weight > available_weight: raise ValueError("特殊出库重量不能超过当前可用重量") if warehouse_type not in {"AUX", "RAW", "SCRAP"}: if qty > available_qty: raise ValueError("特殊出库数量不能超过当前可用数量") if weight > available_weight: raise ValueError("特殊出库重量不能超过当前可用重量") def _build_remark( *, direction: str, reason: str, line_reason: str | None, before_qty: Decimal, after_qty: Decimal, before_weight: Decimal, after_weight: Decimal, ) -> str: direction_label = "特殊入库" if direction == "IN" else "特殊出库" metrics = [ f"调整前数量:{_format_decimal(before_qty)}", f"调整后数量:{_format_decimal(after_qty)}", f"调整前重量:{_format_decimal(before_weight)}", f"调整后重量:{_format_decimal(after_weight)}", ] metric_text = ";".join(metrics) reason_prefix = f"{direction_label}说明:" reason_text = _strip_text(reason) detail = _strip_text(line_reason) reserved_without_detail = f"{reason_prefix};{metric_text}" reason_limit = max(0, 255 - len(reserved_without_detail)) parts = [f"{reason_prefix}{_trim_to(reason_text, reason_limit)}", metric_text] if detail: detail_prefix = "明细说明:" detail_reserved = ";".join(parts) + f";{detail_prefix}" detail_limit = max(0, 255 - len(detail_reserved)) trimmed_detail = _trim_to(detail, detail_limit) if trimmed_detail: parts.append(f"{detail_prefix}{trimmed_detail}") return ";".join(parts) def _create_new_special_lot( db: Session, *, adjustment_id: int, item_id: int, warehouse_id: int, location_id: int | None, warehouse_type: str, qty: Decimal, weight: Decimal, unit_cost: Decimal, reason: str, now: datetime, ) -> StockLot: summary = f"特殊入库说明:{reason}" lot = StockLot( lot_no=_build_special_lot_no( db, adjustment_id=adjustment_id, item_id=item_id, warehouse_type=warehouse_type, now=now, ), parent_lot_id=None, lot_role=LOT_ROLE, material_sub_batch_no=None, item_id=item_id, warehouse_id=warehouse_id, location_id=location_id, source_doc_type=SOURCE_DOC_TYPE, source_doc_id=adjustment_id, source_line_id=None, source_material_lot_id=None, source_material_sub_batch_no=None, source_material_summary=summary, inbound_qty=qty, inbound_weight_kg=weight, remaining_qty=qty, remaining_weight_kg=weight, locked_qty=to_decimal(0), locked_weight_kg=to_decimal(0), unit_cost=unit_cost, production_date=now.date() if isinstance(now.date(), date) else None, expire_date=None, quality_status="PASS", status="AVAILABLE", remark=_trim_255(summary), created_at=now, updated_at=now, ) db.add(lot) db.flush() return lot def create_special_inventory_adjustment( db: Session, payload: SpecialWarehouseAdjustmentCreate, user_id: int | None = None, ) -> SpecialWarehouseAdjustment: now = datetime.now() direction = str(payload.direction or "").upper() reason = _strip_text(payload.reason) if direction not in {"IN", "OUT"}: raise ValueError("特殊库存调整方向只能是特殊入库或特殊出库") direction_label = "特殊入库" if direction == "IN" else "特殊出库" if len(reason) < 4: raise ValueError(f"{direction_label}必须填写说明") if len(reason) > 1000: raise ValueError(f"{direction_label}说明不能超过1000个字") try: warehouse = db.get(Warehouse, payload.warehouse_id) if not warehouse: raise ValueError("仓库不存在") warehouse_type = str(warehouse.warehouse_type or "").upper() ensure_warehouses_unlocked(db, [payload.warehouse_id], direction_label) adjustment = SpecialWarehouseAdjustment( adjustment_no=_build_adjustment_no(db, direction=direction, adjusted_at=now), warehouse_id=payload.warehouse_id, warehouse_type=warehouse_type, direction=direction, reason=reason, status="已确认", operator_user_id=user_id, adjusted_at=now, created_at=now, updated_at=now, ) db.add(adjustment) db.flush() for index, line_payload in enumerate(payload.lines, start=1): item = db.get(Item, line_payload.item_id) if not item: raise ValueError(f"物料不存在: {line_payload.item_id}") location = get_location_or_default(db, payload.warehouse_id, line_payload.location_id) if location and int(location.warehouse_id) != int(payload.warehouse_id): raise ValueError("选择的库位不属于当前仓库") change_qty, change_weight = _normalize_change( warehouse_type=warehouse_type, raw_qty=to_decimal(line_payload.adjust_qty), raw_weight=to_decimal(line_payload.adjust_weight_kg), ) unit_cost = to_decimal(line_payload.unit_cost, "0.0001") lot: StockLot | None if direction == "OUT": if not line_payload.lot_id: raise ValueError("特殊出库必须选择库存批次") lot = db.get(StockLot, line_payload.lot_id) if not lot or int(lot.item_id) != int(line_payload.item_id) or int(lot.warehouse_id) != int(payload.warehouse_id): raise ValueError("库存批次不存在或不属于当前仓库物料") if location and lot.location_id and int(lot.location_id) != int(location.id): raise ValueError("库存批次库位与选择库位不一致") _assert_out_available(lot, qty=change_qty, weight=change_weight, warehouse_type=warehouse_type) unit_cost = to_decimal(lot.unit_cost, "0.0001") if unit_cost <= 0 else unit_cost before_qty = to_decimal(lot.remaining_qty) before_weight = to_decimal(lot.remaining_weight_kg) after_qty = clamp_non_negative(before_qty - change_qty) after_weight = clamp_non_negative(before_weight - change_weight) qty_delta = -change_qty weight_delta = -change_weight else: if line_payload.lot_id: lot = db.get(StockLot, line_payload.lot_id) if not lot or int(lot.item_id) != int(line_payload.item_id) or int(lot.warehouse_id) != int(payload.warehouse_id): raise ValueError("库存批次不存在或不属于当前仓库物料") if location and lot.location_id and int(lot.location_id) != int(location.id): raise ValueError("库存批次库位与选择库位不一致") unit_cost = to_decimal(lot.unit_cost, "0.0001") if unit_cost <= 0 else unit_cost before_qty = to_decimal(lot.remaining_qty) before_weight = to_decimal(lot.remaining_weight_kg) lot.inbound_qty = to_decimal(lot.inbound_qty) + change_qty lot.inbound_weight_kg = to_decimal(lot.inbound_weight_kg) + change_weight lot.remaining_qty = before_qty + change_qty lot.remaining_weight_kg = before_weight + change_weight lot.status = "AVAILABLE" lot.updated_at = now db.add(lot) else: before_qty = to_decimal(0) before_weight = to_decimal(0) lot = _create_new_special_lot( db, adjustment_id=adjustment.id, item_id=line_payload.item_id, warehouse_id=payload.warehouse_id, location_id=location.id if location else None, warehouse_type=warehouse_type, qty=change_qty, weight=change_weight, unit_cost=unit_cost, reason=reason, now=now, ) after_qty = before_qty + change_qty after_weight = before_weight + change_weight qty_delta = change_qty weight_delta = change_weight remark = _build_remark( direction=direction, reason=reason, line_reason=line_payload.line_reason, before_qty=before_qty, after_qty=after_qty, before_weight=before_weight, after_weight=after_weight, ) if direction == "OUT": lot.remaining_qty = after_qty lot.remaining_weight_kg = after_weight if after_qty <= 0 and after_weight <= 0: lot.status = "DEPLETED" lot.updated_at = now db.add(lot) line = SpecialWarehouseAdjustmentLine( adjustment_id=adjustment.id, line_no=index, item_id=line_payload.item_id, warehouse_id=payload.warehouse_id, location_id=lot.location_id if lot else (location.id if location else None), lot_id=lot.id, inventory_txn_id=None, before_qty=before_qty, change_qty=qty_delta, after_qty=after_qty, before_weight_kg=before_weight, change_weight_kg=weight_delta, after_weight_kg=after_weight, unit_cost=unit_cost, line_reason=_strip_text(line_payload.line_reason) or reason, created_at=now, updated_at=now, ) db.add(line) db.flush() if direction == "IN" and lot.source_doc_type == SOURCE_DOC_TYPE and lot.source_line_id is None: lot.source_line_id = line.id db.add(lot) upsert_stock_balance( db, item_id=line_payload.item_id, warehouse_id=payload.warehouse_id, location_id=line.location_id, qty_delta=qty_delta, weight_delta=weight_delta, available_qty_delta=qty_delta, available_weight_delta=weight_delta, unit_cost=unit_cost, ) txn: InventoryTxn = create_inventory_txn( db, txn_type="SPECIAL_IN" if direction == "IN" else "SPECIAL_OUT", item_id=line_payload.item_id, warehouse_id=payload.warehouse_id, location_id=line.location_id, lot_id=lot.id, qty_change=qty_delta, weight_change=weight_delta, unit_cost=unit_cost, source_doc_type=SOURCE_DOC_TYPE, source_doc_id=adjustment.id, source_line_id=line.id, biz_time=now, operator_user_id=user_id, remark=remark, amount_basis="WEIGHT" if abs(weight_delta) > 0 else "QTY", ) line.inventory_txn_id = txn.id line.updated_at = now db.add(line) db.commit() db.refresh(adjustment) return adjustment except HTTPException as exc: db.rollback() _raise_value_from_http(exc) except ValueError: db.rollback() raise except Exception: db.rollback() raise