ForgeFlow-ERP/backend/app/services/special_inventory_adjustment.py
2026-06-12 16:00:56 +08:00

426 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.

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