426 lines
16 KiB
Python
426 lines
16 KiB
Python
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
|