2934 lines
129 KiB
Python
2934 lines
129 KiB
Python
from datetime import date, datetime
|
||
from decimal import Decimal
|
||
from io import BytesIO
|
||
from pathlib import Path
|
||
from urllib.parse import quote
|
||
from uuid import uuid4
|
||
|
||
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile
|
||
from fastapi.responses import FileResponse, Response
|
||
from openpyxl import Workbook, load_workbook
|
||
from sqlalchemy import and_, case, func, or_, select, update
|
||
from sqlalchemy.orm import Session
|
||
|
||
from app.db.session import get_db
|
||
from app.models.document_archive import DocumentArchive
|
||
from app.models.master_data import Bom, BomItem, Item, StockBalance, Warehouse
|
||
from app.models.operations import (
|
||
InventoryTxn,
|
||
OperationReport,
|
||
ProcessRoute,
|
||
ProcessRouteOperation,
|
||
ProductionBatchLedger,
|
||
ProductionBatchLedgerTxn,
|
||
PurchaseOrder,
|
||
PurchaseOrderItem,
|
||
PurchaseReceipt,
|
||
PurchaseReceiptItem,
|
||
PurchaseReturn,
|
||
PurchaseReturnItem,
|
||
SpecialWarehouseAdjustmentLine,
|
||
StockLot,
|
||
Stocktake,
|
||
StocktakeLine,
|
||
StocktakeWarehouse,
|
||
WarehouseLocation,
|
||
WorkOrder,
|
||
)
|
||
from app.schemas.operations import (
|
||
CustomerSuppliedInboundCreate,
|
||
InventorySettlementRead,
|
||
OpeningInventoryImportResult,
|
||
InventoryTxnLedgerResponse,
|
||
InventoryTxnRead,
|
||
PurchaseReturnRead,
|
||
RawMaterialReturnOutboundCreate,
|
||
SpecialWarehouseAdjustmentCreate,
|
||
SpecialWarehouseAdjustmentRead,
|
||
StockBalanceRead,
|
||
StockLotPurchaseLinkRead,
|
||
StockLotOptionRead,
|
||
StockLotRead,
|
||
StocktakeConfirmCreate,
|
||
StocktakeImportPreviewRead,
|
||
StocktakeLineRead,
|
||
StocktakeRead,
|
||
StocktakeStartCreate,
|
||
WarehouseInboundCreate,
|
||
WarehouseOutboundCreate,
|
||
)
|
||
from app.services.auth import AuthContext, require_authenticated_user
|
||
from app.services.document_archives import (
|
||
ARCHIVE_STATUS_MISSING,
|
||
DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT,
|
||
DOCUMENT_TYPE_WAREHOUSE_OPERATION,
|
||
FILE_FORMAT_PDF,
|
||
generate_document_archive,
|
||
production_document_batch_no_from_text,
|
||
)
|
||
from app.services.logistics import normalize_logistics_fields
|
||
from app.services.operations import (
|
||
build_inventory_lot_no,
|
||
create_inventory_txn,
|
||
WAREHOUSE_LEDGER_TYPES,
|
||
get_location_or_default,
|
||
get_inventory_settlements,
|
||
get_inventory_txn_ledger_query,
|
||
get_inventory_txn_query,
|
||
get_stock_balances_query,
|
||
get_stock_lot_purchase_links,
|
||
get_stock_lot_options_query,
|
||
get_stock_lots_query,
|
||
to_decimal,
|
||
summarize_inventory_txn_ledger,
|
||
upsert_stock_balance,
|
||
)
|
||
from app.services.production_work_order_ledger import (
|
||
INBOUND_TOLERANCE_MULTIPLIER,
|
||
get_work_order_limit_snapshot,
|
||
validate_inbound_with_tolerance,
|
||
validate_work_order_source_lot,
|
||
)
|
||
from app.services.production_batch_ledger import (
|
||
lock_production_batch_ledger,
|
||
post_finished_inbound_to_ledger,
|
||
post_scrap_inbound_to_ledger,
|
||
post_surplus_inbound_to_ledger,
|
||
)
|
||
from app.services.special_inventory_adjustment import create_special_inventory_adjustment
|
||
from app.services.stocktake import (
|
||
_stocktake_summary,
|
||
build_stocktake_workbook,
|
||
cancel_stocktake,
|
||
confirm_stocktake,
|
||
ensure_warehouses_unlocked,
|
||
import_stocktake_workbook,
|
||
start_stocktake,
|
||
stocktake_lines_for_read,
|
||
stocktake_warehouses_for_read,
|
||
)
|
||
|
||
router = APIRouter(dependencies=[Depends(require_authenticated_user)])
|
||
|
||
|
||
def _stock_lot_read_with_archive(
|
||
row: dict,
|
||
*,
|
||
archive_status: str | None = None,
|
||
archive_business_id: int | None = None,
|
||
archive_document_type: str | None = None,
|
||
archive_error_message: str | None = None,
|
||
) -> StockLotRead:
|
||
response = StockLotRead.model_validate(dict(row))
|
||
response.archive_status = archive_status
|
||
response.archive_business_id = archive_business_id
|
||
response.archive_document_type = archive_document_type
|
||
response.archive_error_message = archive_error_message
|
||
return response
|
||
|
||
|
||
def _generate_inventory_archive_fields(
|
||
db: Session,
|
||
*,
|
||
document_type: str,
|
||
business_id: int | None,
|
||
created_by: int | None,
|
||
) -> dict[str, object]:
|
||
if not business_id:
|
||
return {
|
||
"archive_status": None,
|
||
"archive_business_id": None,
|
||
"archive_document_type": None,
|
||
"archive_error_message": None,
|
||
}
|
||
try:
|
||
archive_result = generate_document_archive(db, document_type, int(business_id), created_by=created_by)
|
||
return {
|
||
"archive_status": archive_result.archive_status,
|
||
"archive_business_id": int(business_id),
|
||
"archive_document_type": document_type,
|
||
"archive_error_message": archive_result.archive_error_message,
|
||
}
|
||
except Exception as exc:
|
||
_ = exc
|
||
return {
|
||
"archive_status": "归档失败",
|
||
"archive_business_id": int(business_id),
|
||
"archive_document_type": document_type,
|
||
"archive_error_message": "归档失败:PDF归档服务异常,请稍后重新生成",
|
||
}
|
||
|
||
|
||
def _generate_inventory_archives_for_txns(
|
||
db: Session,
|
||
*,
|
||
document_type: str,
|
||
business_ids: list[int],
|
||
created_by: int | None,
|
||
) -> dict[str, object]:
|
||
first_archive_fields: dict[str, object] | None = None
|
||
for business_id in business_ids:
|
||
archive_fields = _generate_inventory_archive_fields(
|
||
db,
|
||
document_type=document_type,
|
||
business_id=business_id,
|
||
created_by=created_by,
|
||
)
|
||
if first_archive_fields is None:
|
||
first_archive_fields = archive_fields
|
||
return first_archive_fields or _generate_inventory_archive_fields(
|
||
db,
|
||
document_type=document_type,
|
||
business_id=None,
|
||
created_by=created_by,
|
||
)
|
||
|
||
LOGISTICS_UPLOAD_DIR = Path(__file__).resolve().parents[3] / "uploads" / "logistics"
|
||
LOGISTICS_ALLOWED_SUFFIXES = {".jpg", ".jpeg", ".png", ".webp", ".heic", ".heif"}
|
||
LOGISTICS_MAX_BYTES = 10 * 1024 * 1024
|
||
|
||
OPENING_INVENTORY_HEADERS = [
|
||
"仓库类型",
|
||
"仓库名称",
|
||
"库位",
|
||
"物料编码",
|
||
"物料名称",
|
||
"批次号",
|
||
"数量",
|
||
"重量(kg)",
|
||
"单价",
|
||
"来源说明",
|
||
"备注",
|
||
]
|
||
WEIGHT_ONLY_WAREHOUSE_TYPES = {"RAW", "SCRAP"}
|
||
|
||
WAREHOUSE_TYPE_LABELS = {
|
||
"RAW": "原材料库",
|
||
"SEMI": "半成品库",
|
||
"FINISHED": "成品库",
|
||
"AUX": "辅料库",
|
||
"SCRAP": "废料库",
|
||
"RETURN": "退货库",
|
||
}
|
||
|
||
WAREHOUSE_LABEL_TO_TYPE = {label: key for key, label in WAREHOUSE_TYPE_LABELS.items()}
|
||
|
||
OPENING_INVENTORY_SAMPLE_ROWS = {
|
||
"RAW": [
|
||
["原材料库", "原材料仓", "原料主库位", "示例原材料001", "304不锈钢板", "示例-RAW-0001", 0, 500, 4.2, "仓库初始化清点", "示例行,导入时会自动跳过,请替换为真实数据"],
|
||
["原材料库", "原材料仓", "原料主库位", "示例原材料002", "冷轧钢板", "示例-RAW-0002", 0, 1200, 3.8, "仓库初始化清点", "示例行,导入时会自动跳过,请替换为真实数据"],
|
||
],
|
||
"SEMI": [
|
||
["半成品库", "半成品仓", "半成品主库位", "示例产品001", "五金支架半成品", "示例-SEMI-0001", 100, 250, 8.5, "仓库初始化清点", "示例行,导入时会自动跳过,请替换为真实数据"],
|
||
["半成品库", "半成品仓", "半成品主库位", "示例产品002", "冲压件半成品", "示例-SEMI-0002", 60, 180, 7.2, "仓库初始化清点", "示例行,导入时会自动跳过,请替换为真实数据"],
|
||
],
|
||
"FINISHED": [
|
||
["成品库", "成品仓", "成品主库位", "示例产品001", "五金支架", "示例-FG-0001", 100, 250, 12.5, "仓库初始化清点", "示例行,导入时会自动跳过,请替换为真实数据"],
|
||
["成品库", "成品仓", "成品主库位", "示例产品002", "苹果电脑支架", "示例-FG-0002", 80, 200, 15, "仓库初始化清点", "示例行,导入时会自动跳过,请替换为真实数据"],
|
||
],
|
||
"AUX": [
|
||
["辅料库", "辅料仓", "辅料主库位", "示例辅料001", "包装薄膜", "示例-AUX-0001", 80, 0, 6.5, "仓库初始化清点", "示例行,导入时会自动跳过,请替换为真实数据"],
|
||
["辅料库", "辅料仓", "辅料主库位", "示例辅料002", "劳保手套", "示例-AUX-0002", 25, 0, 12, "仓库初始化清点", "示例行,导入时会自动跳过,请替换为真实数据"],
|
||
],
|
||
"SCRAP": [
|
||
["废料库", "废料库", "废料暂存位", "示例原材料001", "304不锈钢边角料", "示例-SCRAP-0001", 0, 120, 1.6, "仓库初始化清点", "示例行,导入时会自动跳过,请替换为真实数据"],
|
||
["废料库", "废料库", "废料暂存位", "示例原材料002", "冲压报废件", "示例-SCRAP-0002", 0, 80, 1.2, "仓库初始化清点", "示例行,导入时会自动跳过,请替换为真实数据"],
|
||
],
|
||
"RETURN": [
|
||
["退货库", "退货库", "退货暂存位", "示例产品001", "返工待处理五金支架", "示例-RETURN-0001", 20, 50, 12.5, "客户退回待返工初始化", "示例行,导入时会自动跳过,请替换为真实数据"],
|
||
["退货库", "退货库", "退货暂存位", "示例产品002", "返工待处理苹果电脑支架", "示例-RETURN-0002", 12, 30, 15, "客户退回待返工初始化", "示例行,导入时会自动跳过,请替换为真实数据"],
|
||
],
|
||
}
|
||
|
||
|
||
def _is_weight_only_warehouse_type(warehouse_type: str | None) -> bool:
|
||
return str(warehouse_type or "").upper() in WEIGHT_ONLY_WAREHOUSE_TYPES
|
||
|
||
|
||
def _opening_inventory_headers_for_type(warehouse_type: str | None) -> list[str]:
|
||
if _is_weight_only_warehouse_type(warehouse_type):
|
||
return [header for header in OPENING_INVENTORY_HEADERS if header != "数量"]
|
||
return list(OPENING_INVENTORY_HEADERS)
|
||
|
||
|
||
def _opening_inventory_row_for_headers(row: list[object], headers: list[str]) -> list[object]:
|
||
legacy_payload = {header: row[index] if index < len(row) else "" for index, header in enumerate(OPENING_INVENTORY_HEADERS)}
|
||
return [legacy_payload.get(header, "") for header in headers]
|
||
|
||
|
||
def _excel_response(workbook, filename: str) -> Response:
|
||
output = BytesIO()
|
||
workbook.save(output)
|
||
encoded_filename = quote(filename)
|
||
return Response(
|
||
content=output.getvalue(),
|
||
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||
headers={"Content-Disposition": f"attachment; filename=\"stocktake.xlsx\"; filename*=UTF-8''{encoded_filename}"},
|
||
)
|
||
|
||
|
||
def _stocktake_read(db: Session, stocktake_id: int, warehouse_id: int | None = None) -> StocktakeRead:
|
||
stocktake = db.get(Stocktake, stocktake_id)
|
||
if not stocktake:
|
||
raise HTTPException(status_code=404, detail="盘库单不存在")
|
||
warehouses = stocktake_warehouses_for_read(db, stocktake_id)
|
||
lines = stocktake_lines_for_read(db, stocktake_id)
|
||
if warehouse_id:
|
||
lines = [line for line in lines if int(line.warehouse_id) == int(warehouse_id)]
|
||
return StocktakeRead.model_validate(
|
||
{
|
||
"id": stocktake.id,
|
||
"stocktake_no": stocktake.stocktake_no,
|
||
"status": stocktake.status,
|
||
"started_by_user_id": stocktake.started_by_user_id,
|
||
"imported_by_user_id": stocktake.imported_by_user_id,
|
||
"confirmed_by_user_id": stocktake.confirmed_by_user_id,
|
||
"canceled_by_user_id": stocktake.canceled_by_user_id,
|
||
"started_at": stocktake.started_at,
|
||
"exported_at": stocktake.exported_at,
|
||
"imported_at": stocktake.imported_at,
|
||
"confirmed_at": stocktake.confirmed_at,
|
||
"canceled_at": stocktake.canceled_at,
|
||
"remark": stocktake.remark,
|
||
"warehouses": [
|
||
{
|
||
"id": row.id,
|
||
"warehouse_id": row.warehouse_id,
|
||
"warehouse_name": row.warehouse_name,
|
||
"warehouse_type": row.warehouse_type,
|
||
"status": row.status,
|
||
}
|
||
for row in warehouses
|
||
],
|
||
"summary": _stocktake_summary(lines),
|
||
}
|
||
)
|
||
|
||
|
||
def _stocktake_line_read(line: StocktakeLine) -> dict[str, object]:
|
||
return {
|
||
"id": line.id,
|
||
"stocktake_id": line.stocktake_id,
|
||
"line_no": line.line_no,
|
||
"warehouse_id": line.warehouse_id,
|
||
"warehouse_name": line.warehouse_name,
|
||
"warehouse_type": line.warehouse_type,
|
||
"location_id": line.location_id,
|
||
"location_name": line.location_name,
|
||
"item_id": line.item_id,
|
||
"item_code": line.item_code,
|
||
"item_name": line.item_name,
|
||
"item_type": line.item_type,
|
||
"lot_id": line.lot_id,
|
||
"lot_no": line.lot_no,
|
||
"source_material_sub_batch_no": line.source_material_sub_batch_no,
|
||
"snapshot_qty": line.snapshot_qty,
|
||
"snapshot_weight_kg": line.snapshot_weight_kg,
|
||
"snapshot_unit_cost": line.snapshot_unit_cost,
|
||
"counted_qty": line.counted_qty,
|
||
"counted_weight_kg": line.counted_weight_kg,
|
||
"diff_qty": line.diff_qty,
|
||
"diff_weight_kg": line.diff_weight_kg,
|
||
"diff_amount": line.diff_amount,
|
||
"diff_type": line.diff_type,
|
||
"row_status": line.row_status,
|
||
"error_message": line.error_message,
|
||
"remark": line.remark,
|
||
}
|
||
|
||
|
||
def _stocktake_preview_read(db: Session, result: dict[str, object]) -> StocktakeImportPreviewRead:
|
||
stocktake = result["stocktake"]
|
||
lines = result["lines"]
|
||
return StocktakeImportPreviewRead.model_validate(
|
||
{
|
||
"stocktake": _stocktake_read(db, stocktake.id),
|
||
"lines": [_stocktake_line_read(line) for line in lines],
|
||
"summary": result["summary"],
|
||
}
|
||
)
|
||
|
||
|
||
def _code_token(value: str | None, fallback: str, max_length: int = 14) -> str:
|
||
token = "".join(ch for ch in str(value or "").upper() if ch.isalnum())
|
||
return (token or fallback)[-max_length:]
|
||
|
||
|
||
def _build_customer_supplied_lot_no(db: Session, material: Item) -> str:
|
||
return build_inventory_lot_no(db, material.id)
|
||
|
||
|
||
def _build_purchase_return_no(db: Session) -> str:
|
||
year = datetime.now().strftime("%Y")
|
||
prefix = f"退货{year}-"
|
||
rows = db.scalars(select(PurchaseReturn.return_no).where(PurchaseReturn.return_no.like(f"{prefix}%"))).all()
|
||
max_no = 0
|
||
for value in rows:
|
||
tail = str(value or "").replace(prefix, "", 1)
|
||
if tail.isdigit():
|
||
max_no = max(max_no, int(tail))
|
||
return f"{prefix}{max_no + 1:05d}"
|
||
|
||
|
||
def _lock_purchase_order_items_for_return(db: Session, purchase_order_item_ids: set[int]) -> None:
|
||
if not purchase_order_item_ids:
|
||
return
|
||
db.scalars(
|
||
select(PurchaseOrderItem)
|
||
.where(PurchaseOrderItem.id.in_(sorted(purchase_order_item_ids)))
|
||
.with_for_update()
|
||
).all()
|
||
|
||
|
||
def _current_returned_weight_for_purchase_line_lot(db: Session, purchase_order_item_id: int, lot_id: int) -> Decimal:
|
||
rows = db.scalars(
|
||
select(PurchaseReturnItem.return_weight_kg)
|
||
.join(PurchaseReturn, PurchaseReturn.id == PurchaseReturnItem.purchase_return_id)
|
||
.where(
|
||
PurchaseReturnItem.purchase_order_item_id == purchase_order_item_id,
|
||
PurchaseReturnItem.lot_id == lot_id,
|
||
PurchaseReturn.status != "CANCELED",
|
||
PurchaseReturnItem.status != "CANCELED",
|
||
)
|
||
.with_for_update()
|
||
).all()
|
||
return sum((to_decimal(row) for row in rows), Decimal("0"))
|
||
|
||
|
||
def _recheck_raw_material_return_purchase_line_availability(db: Session, prepared_lines: list[dict[str, object]]) -> None:
|
||
for prepared in prepared_lines:
|
||
lot_id = int(prepared["lot_id"])
|
||
purchase_order_item_id = int(prepared["purchase_order_item_id"])
|
||
return_weight = to_decimal(prepared["return_weight"])
|
||
matched_link = next(
|
||
(
|
||
link
|
||
for link in get_stock_lot_purchase_links(db, lot_id)
|
||
if int(link["purchase_order_item_id"]) == purchase_order_item_id
|
||
),
|
||
None,
|
||
)
|
||
if not matched_link:
|
||
db.rollback()
|
||
raise HTTPException(status_code=400, detail="退货重量不能超过该采购明细可退重量")
|
||
current_returned_weight = _current_returned_weight_for_purchase_line_lot(db, purchase_order_item_id, lot_id)
|
||
current_line_returnable_weight = to_decimal(matched_link["accepted_weight_kg"]) - current_returned_weight
|
||
if current_line_returnable_weight < 0:
|
||
current_line_returnable_weight = Decimal("0")
|
||
if return_weight > current_line_returnable_weight or return_weight > to_decimal(matched_link["returnable_weight_kg"]):
|
||
db.rollback()
|
||
raise HTTPException(status_code=400, detail="退货重量不能超过该采购明细可退重量")
|
||
prepared["unit_cost"] = to_decimal(matched_link["unit_cost"], "0.0001")
|
||
|
||
|
||
def _purchase_return_read(db: Session, purchase_return_id: int) -> PurchaseReturnRead:
|
||
header = db.execute(
|
||
select(
|
||
PurchaseReturn.id.label("purchase_return_id"),
|
||
PurchaseReturn.return_no.label("return_no"),
|
||
PurchaseReturn.purchase_order_id.label("purchase_order_id"),
|
||
PurchaseOrder.po_no.label("po_no"),
|
||
PurchaseReturn.warehouse_id.label("warehouse_id"),
|
||
Warehouse.warehouse_name.label("warehouse_name"),
|
||
PurchaseReturn.return_date.label("return_date"),
|
||
PurchaseReturn.logistics_waybill_no.label("logistics_waybill_no"),
|
||
PurchaseReturn.logistics_freight_amount.label("logistics_freight_amount"),
|
||
PurchaseReturn.logistics_photo_url.label("logistics_photo_url"),
|
||
PurchaseReturn.reason.label("reason"),
|
||
PurchaseReturn.status.label("status"),
|
||
PurchaseReturn.remark.label("remark"),
|
||
)
|
||
.join(PurchaseOrder, PurchaseOrder.id == PurchaseReturn.purchase_order_id)
|
||
.join(Warehouse, Warehouse.id == PurchaseReturn.warehouse_id)
|
||
.where(PurchaseReturn.id == purchase_return_id)
|
||
).mappings().first()
|
||
if not header:
|
||
raise HTTPException(status_code=404, detail="采购退货单不存在")
|
||
|
||
item_rows = db.execute(
|
||
select(
|
||
PurchaseReturnItem.id.label("purchase_return_item_id"),
|
||
PurchaseReturnItem.purchase_return_id.label("purchase_return_id"),
|
||
PurchaseReturnItem.line_no.label("line_no"),
|
||
PurchaseReturnItem.purchase_order_item_id.label("purchase_order_item_id"),
|
||
PurchaseReturnItem.material_item_id.label("material_item_id"),
|
||
Item.item_code.label("material_code"),
|
||
Item.item_name.label("material_name"),
|
||
PurchaseReturnItem.lot_id.label("lot_id"),
|
||
StockLot.lot_no.label("lot_no"),
|
||
PurchaseReturnItem.return_weight_kg.label("return_weight_kg"),
|
||
PurchaseReturnItem.unit_cost.label("unit_cost"),
|
||
PurchaseReturnItem.amount.label("amount"),
|
||
PurchaseReturnItem.reason.label("reason"),
|
||
PurchaseReturnItem.status.label("status"),
|
||
PurchaseReturnItem.remark.label("remark"),
|
||
)
|
||
.join(Item, Item.id == PurchaseReturnItem.material_item_id)
|
||
.join(StockLot, StockLot.id == PurchaseReturnItem.lot_id)
|
||
.where(PurchaseReturnItem.purchase_return_id == purchase_return_id)
|
||
.order_by(PurchaseReturnItem.line_no.asc(), PurchaseReturnItem.id.asc())
|
||
).mappings().all()
|
||
|
||
total_return_weight = sum((to_decimal(row["return_weight_kg"]) for row in item_rows), Decimal("0"))
|
||
total_amount = sum((to_decimal(row["amount"], "0.01") for row in item_rows), Decimal("0"))
|
||
return PurchaseReturnRead.model_validate(
|
||
{
|
||
**dict(header),
|
||
"total_return_weight_kg": total_return_weight,
|
||
"total_amount": total_amount,
|
||
"items": [dict(row) for row in item_rows],
|
||
}
|
||
)
|
||
|
||
|
||
@router.post("/stocktakes", response_model=StocktakeRead)
|
||
def start_inventory_stocktake(
|
||
payload: StocktakeStartCreate,
|
||
context: AuthContext = Depends(require_authenticated_user),
|
||
db: Session = Depends(get_db),
|
||
) -> StocktakeRead:
|
||
stocktake = start_stocktake(db, warehouse_ids=payload.warehouse_ids, user_id=context.user.id, remark=payload.remark)
|
||
return _stocktake_read(db, stocktake.id)
|
||
|
||
|
||
@router.get("/stocktakes/{stocktake_id}/export")
|
||
def export_inventory_stocktake(stocktake_id: int, db: Session = Depends(get_db)) -> Response:
|
||
workbook = build_stocktake_workbook(db, stocktake_id)
|
||
stocktake = db.get(Stocktake, stocktake_id)
|
||
if not stocktake:
|
||
raise HTTPException(status_code=404, detail="盘库单不存在")
|
||
return _excel_response(workbook, f"{stocktake.stocktake_no}_盘库清单.xlsx")
|
||
|
||
|
||
@router.get("/stocktakes", response_model=list[StocktakeRead])
|
||
def list_inventory_stocktakes(
|
||
limit: int = Query(default=100, ge=1, le=500),
|
||
warehouse_id: int | None = Query(default=None),
|
||
db: Session = Depends(get_db),
|
||
) -> list[StocktakeRead]:
|
||
stmt = select(Stocktake).order_by(Stocktake.started_at.desc(), Stocktake.id.desc()).limit(limit)
|
||
if warehouse_id:
|
||
stmt = (
|
||
select(Stocktake)
|
||
.join(StocktakeWarehouse, StocktakeWarehouse.stocktake_id == Stocktake.id)
|
||
.where(StocktakeWarehouse.warehouse_id == warehouse_id)
|
||
.order_by(Stocktake.started_at.desc(), Stocktake.id.desc())
|
||
.limit(limit)
|
||
)
|
||
rows = db.scalars(stmt).all()
|
||
return [_stocktake_read(db, row.id, warehouse_id=warehouse_id) for row in rows]
|
||
|
||
|
||
@router.get("/stocktakes/{stocktake_id}", response_model=StocktakeRead)
|
||
def get_inventory_stocktake(stocktake_id: int, db: Session = Depends(get_db)) -> StocktakeRead:
|
||
return _stocktake_read(db, stocktake_id)
|
||
|
||
|
||
@router.get("/stocktakes/{stocktake_id}/lines", response_model=list[StocktakeLineRead])
|
||
def list_inventory_stocktake_lines(
|
||
stocktake_id: int,
|
||
warehouse_id: int | None = Query(default=None),
|
||
db: Session = Depends(get_db),
|
||
) -> list[StocktakeLineRead]:
|
||
lines = stocktake_lines_for_read(db, stocktake_id)
|
||
if warehouse_id:
|
||
lines = [line for line in lines if int(line.warehouse_id) == int(warehouse_id)]
|
||
return [StocktakeLineRead.model_validate(_stocktake_line_read(line)) for line in lines]
|
||
|
||
|
||
@router.post("/stocktakes/{stocktake_id}/import", response_model=StocktakeImportPreviewRead)
|
||
async def import_inventory_stocktake(
|
||
stocktake_id: int,
|
||
file: UploadFile = File(...),
|
||
context: AuthContext = Depends(require_authenticated_user),
|
||
db: Session = Depends(get_db),
|
||
) -> StocktakeImportPreviewRead:
|
||
content = await file.read()
|
||
try:
|
||
result = import_stocktake_workbook(db, stocktake_id, content, user_id=context.user.id)
|
||
except ValueError as exc:
|
||
db.rollback()
|
||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||
return _stocktake_preview_read(db, result)
|
||
|
||
|
||
@router.post("/stocktakes/{stocktake_id}/confirm", response_model=StocktakeRead)
|
||
def confirm_inventory_stocktake(
|
||
stocktake_id: int,
|
||
payload: StocktakeConfirmCreate,
|
||
context: AuthContext = Depends(require_authenticated_user),
|
||
db: Session = Depends(get_db),
|
||
) -> StocktakeRead:
|
||
stocktake = confirm_stocktake(
|
||
db,
|
||
stocktake_id,
|
||
user_id=context.user.id,
|
||
confirm_text=payload.confirm_text,
|
||
remark=payload.remark,
|
||
)
|
||
return _stocktake_read(db, stocktake.id)
|
||
|
||
|
||
@router.post("/stocktakes/{stocktake_id}/cancel", response_model=StocktakeRead)
|
||
def cancel_inventory_stocktake(
|
||
stocktake_id: int,
|
||
payload: StocktakeConfirmCreate,
|
||
context: AuthContext = Depends(require_authenticated_user),
|
||
db: Session = Depends(get_db),
|
||
) -> StocktakeRead:
|
||
stocktake = cancel_stocktake(db, stocktake_id, user_id=context.user.id, remark=payload.remark)
|
||
return _stocktake_read(db, stocktake.id)
|
||
|
||
|
||
def _normalize_warehouse_type(value: str | None) -> str:
|
||
text = str(value or "").strip().upper()
|
||
if text in WAREHOUSE_TYPE_LABELS:
|
||
return text
|
||
return WAREHOUSE_LABEL_TO_TYPE.get(str(value or "").strip(), "")
|
||
|
||
|
||
def _opening_cell_text(value: object) -> str:
|
||
return str(value or "").strip()
|
||
|
||
|
||
def _opening_decimal(value: object, default: Decimal = Decimal("0"), places: str = "0.000001") -> Decimal:
|
||
if value is None or _opening_cell_text(value) == "":
|
||
return default
|
||
try:
|
||
return Decimal(str(value)).quantize(Decimal(places))
|
||
except Exception as exc:
|
||
raise ValueError(f"数值格式错误:{value}") from exc
|
||
|
||
|
||
def _opening_row_is_empty(row: dict[str, object]) -> bool:
|
||
return not any(_opening_cell_text(value) for key, value in row.items() if not str(key).startswith("_"))
|
||
|
||
|
||
def _opening_row_is_sample(row: dict[str, object]) -> bool:
|
||
lot_no = _opening_cell_text(row.get("批次号"))
|
||
item_code = _opening_cell_text(row.get("物料编码"))
|
||
remark = _opening_cell_text(row.get("备注"))
|
||
return lot_no.startswith("示例-") and item_code.startswith("示例") and "示例行" in remark
|
||
|
||
|
||
def _opening_sheet_title(warehouse_type: str) -> str:
|
||
return f"{WAREHOUSE_TYPE_LABELS.get(warehouse_type, warehouse_type)}期初导入"[:31]
|
||
|
||
|
||
def build_opening_inventory_template(db: Session, warehouse_type: str) -> Workbook:
|
||
normalized_type = _normalize_warehouse_type(warehouse_type)
|
||
if normalized_type not in WAREHOUSE_TYPE_LABELS:
|
||
raise HTTPException(status_code=400, detail="未知仓库类型,无法导出期初模版")
|
||
workbook = Workbook()
|
||
worksheet = workbook.active
|
||
worksheet.title = _opening_sheet_title(normalized_type)
|
||
worksheet.freeze_panes = "A2"
|
||
headers = _opening_inventory_headers_for_type(normalized_type)
|
||
worksheet.append(headers)
|
||
for row in OPENING_INVENTORY_SAMPLE_ROWS.get(normalized_type, []):
|
||
worksheet.append(_opening_inventory_row_for_headers(row, headers))
|
||
widths_by_header = {
|
||
"仓库类型": 14,
|
||
"仓库名称": 18,
|
||
"库位": 18,
|
||
"物料编码": 20,
|
||
"物料名称": 28,
|
||
"批次号": 24,
|
||
"数量": 12,
|
||
"重量(kg)": 14,
|
||
"单价": 12,
|
||
"来源说明": 24,
|
||
"备注": 24,
|
||
}
|
||
for index, header in enumerate(headers, start=1):
|
||
width = widths_by_header.get(header, 14)
|
||
worksheet.column_dimensions[worksheet.cell(row=1, column=index).column_letter].width = width
|
||
return workbook
|
||
|
||
|
||
def _read_opening_inventory_rows(content: bytes) -> list[dict[str, object]]:
|
||
try:
|
||
workbook = load_workbook(BytesIO(content), read_only=True, data_only=True)
|
||
except Exception as exc:
|
||
raise ValueError("Excel 文件读取失败,请确认上传的是期初导入 xlsx 文件") from exc
|
||
rows: list[dict[str, object]] = []
|
||
for worksheet in workbook.worksheets:
|
||
values = list(worksheet.iter_rows(values_only=True))
|
||
if not values:
|
||
continue
|
||
headers = [_opening_cell_text(value) for value in values[0]]
|
||
required_headers = [header for header in OPENING_INVENTORY_HEADERS if header != "数量"]
|
||
missing = [header for header in required_headers if header not in headers]
|
||
if missing:
|
||
raise ValueError(f"{worksheet.title} 缺少必要列:{'、'.join(missing)}")
|
||
for row_no, raw_row in enumerate(values[1:], start=2):
|
||
payload = {headers[index]: value for index, value in enumerate(raw_row) if index < len(headers)}
|
||
if _opening_row_is_empty(payload) or _opening_row_is_sample(payload):
|
||
continue
|
||
payload["_row_no"] = row_no
|
||
payload["_sheet_name"] = worksheet.title
|
||
rows.append(payload)
|
||
return rows
|
||
|
||
|
||
def _find_opening_warehouse(db: Session, warehouse_type: str, warehouse_name: str, row_no: int) -> Warehouse:
|
||
query = select(Warehouse).where(Warehouse.warehouse_type == warehouse_type, Warehouse.status == "ACTIVE")
|
||
if warehouse_name:
|
||
query = query.where(Warehouse.warehouse_name == warehouse_name)
|
||
warehouses = db.scalars(query.order_by(Warehouse.id.asc())).all()
|
||
if not warehouses:
|
||
raise ValueError(f"第 {row_no} 行仓库不存在或未启用:{warehouse_name or WAREHOUSE_TYPE_LABELS[warehouse_type]}")
|
||
if len(warehouses) > 1:
|
||
raise ValueError(f"第 {row_no} 行仓库名称不能为空,该类型存在多个仓库")
|
||
return warehouses[0]
|
||
|
||
|
||
def _find_opening_location(db: Session, warehouse_id: int, location_name: str, row_no: int) -> WarehouseLocation | None:
|
||
if not location_name:
|
||
return get_location_or_default(db, warehouse_id, None)
|
||
location = db.scalar(
|
||
select(WarehouseLocation).where(
|
||
WarehouseLocation.warehouse_id == warehouse_id,
|
||
WarehouseLocation.location_name == location_name,
|
||
WarehouseLocation.status == "ACTIVE",
|
||
)
|
||
)
|
||
if not location:
|
||
raise ValueError(f"第 {row_no} 行库位不存在或未启用:{location_name}")
|
||
return location
|
||
|
||
|
||
def _create_opening_inventory_lot(
|
||
db: Session,
|
||
*,
|
||
item: Item,
|
||
warehouse: Warehouse,
|
||
location: WarehouseLocation | None,
|
||
qty: Decimal,
|
||
weight: Decimal,
|
||
unit_cost: Decimal,
|
||
lot_no: str | None,
|
||
source_summary: str | None,
|
||
remark: str | None,
|
||
user_id: int | None,
|
||
) -> StockLot:
|
||
warehouse_type = str(warehouse.warehouse_type or "").upper()
|
||
raw_weight_only = warehouse_type in {"RAW", "SCRAP"}
|
||
inbound_qty = Decimal("0") if raw_weight_only else qty
|
||
if warehouse_type == "RAW":
|
||
actual_lot_no = build_inventory_lot_no(db, item.id)
|
||
else:
|
||
actual_lot_no = lot_no or _build_opening_lot_no(db, item, warehouse)
|
||
if db.scalar(select(StockLot).where(StockLot.lot_no == actual_lot_no)):
|
||
raise ValueError(f"批次号 {actual_lot_no} 已存在,请更换后重试")
|
||
now = datetime.now()
|
||
lot = StockLot(
|
||
lot_no=actual_lot_no,
|
||
parent_lot_id=None,
|
||
lot_role="OPENING_STOCK",
|
||
material_sub_batch_no=None,
|
||
item_id=item.id,
|
||
warehouse_id=warehouse.id,
|
||
location_id=location.id if location else None,
|
||
source_doc_type="OPENING_STOCK",
|
||
source_doc_id=0,
|
||
source_line_id=None,
|
||
source_material_lot_id=None,
|
||
source_material_sub_batch_no=None,
|
||
source_material_summary=source_summary or "期初导入",
|
||
inbound_qty=inbound_qty,
|
||
inbound_weight_kg=weight,
|
||
remaining_qty=inbound_qty,
|
||
remaining_weight_kg=weight,
|
||
locked_qty=Decimal("0"),
|
||
locked_weight_kg=Decimal("0"),
|
||
unit_cost=unit_cost,
|
||
production_date=now.date(),
|
||
expire_date=None,
|
||
quality_status="PASS",
|
||
status="AVAILABLE",
|
||
remark=remark or "期初导入",
|
||
)
|
||
db.add(lot)
|
||
db.flush()
|
||
lot.source_doc_id = lot.id
|
||
db.add(lot)
|
||
|
||
upsert_stock_balance(
|
||
db,
|
||
item_id=item.id,
|
||
warehouse_id=warehouse.id,
|
||
location_id=location.id if location else None,
|
||
qty_delta=inbound_qty,
|
||
weight_delta=weight,
|
||
available_qty_delta=inbound_qty,
|
||
available_weight_delta=weight,
|
||
unit_cost=unit_cost,
|
||
)
|
||
create_inventory_txn(
|
||
db,
|
||
txn_type="OPENING_IN",
|
||
item_id=item.id,
|
||
warehouse_id=warehouse.id,
|
||
location_id=location.id if location else None,
|
||
lot_id=lot.id,
|
||
qty_change=inbound_qty,
|
||
weight_change=weight,
|
||
unit_cost=unit_cost,
|
||
source_doc_type="OPENING_STOCK",
|
||
source_doc_id=lot.id,
|
||
source_line_id=None,
|
||
biz_time=now,
|
||
operator_user_id=user_id,
|
||
remark=remark or "期初导入",
|
||
amount_basis="WEIGHT" if raw_weight_only else "QTY",
|
||
)
|
||
return lot
|
||
|
||
|
||
def import_opening_inventory_workbook(db: Session, warehouse_type: str, content: bytes, user_id: int | None) -> dict[str, object]:
|
||
normalized_type = _normalize_warehouse_type(warehouse_type)
|
||
if normalized_type not in WAREHOUSE_TYPE_LABELS:
|
||
raise ValueError("未知仓库类型,无法导入期初库存")
|
||
rows = _read_opening_inventory_rows(content)
|
||
if not rows:
|
||
raise ValueError("导入文件没有可导入的期初库存明细")
|
||
|
||
imported = 0
|
||
for row in rows:
|
||
row_no = int(row["_row_no"])
|
||
row_type = _normalize_warehouse_type(_opening_cell_text(row.get("仓库类型")))
|
||
if row_type and row_type != normalized_type:
|
||
raise ValueError(f"第 {row_no} 行仓库类型与当前导入类型不一致")
|
||
warehouse = _find_opening_warehouse(db, normalized_type, _opening_cell_text(row.get("仓库名称")), row_no)
|
||
ensure_warehouses_unlocked(db, [warehouse.id], "期初导入")
|
||
item_code = _opening_cell_text(row.get("物料编码"))
|
||
item_name = _opening_cell_text(row.get("物料名称"))
|
||
if not item_code:
|
||
raise ValueError(f"第 {row_no} 行物料编码不能为空")
|
||
item = db.scalar(select(Item).where(Item.item_code == item_code).limit(1))
|
||
if not item:
|
||
raise ValueError(f"第 {row_no} 行物料不存在:{item_code}")
|
||
if item_name and item.item_name != item_name:
|
||
raise ValueError(f"第 {row_no} 行物料名称与系统不一致:{item_code} / {item_name}")
|
||
_validate_warehouse_item(warehouse, item)
|
||
location = _find_opening_location(db, warehouse.id, _opening_cell_text(row.get("库位")), row_no)
|
||
qty = _opening_decimal(row.get("数量"))
|
||
weight = _opening_decimal(row.get("重量(kg)"))
|
||
unit_cost = _opening_decimal(row.get("单价"), places="0.0001")
|
||
if normalized_type == "AUX":
|
||
if qty <= 0:
|
||
raise ValueError(f"第 {row_no} 行辅料库数量必须大于0")
|
||
weight = Decimal("0")
|
||
elif weight <= 0:
|
||
raise ValueError(f"第 {row_no} 行重量(kg)必须大于0")
|
||
if normalized_type in {"SEMI", "FINISHED", "RETURN"} and qty <= 0:
|
||
raise ValueError(f"第 {row_no} 行{WAREHOUSE_TYPE_LABELS[normalized_type]}数量必须大于0")
|
||
if unit_cost < 0:
|
||
raise ValueError(f"第 {row_no} 行单价不能小于0")
|
||
_create_opening_inventory_lot(
|
||
db,
|
||
item=item,
|
||
warehouse=warehouse,
|
||
location=location,
|
||
qty=qty,
|
||
weight=weight,
|
||
unit_cost=unit_cost,
|
||
lot_no=_opening_cell_text(row.get("批次号")) or None,
|
||
source_summary=_opening_cell_text(row.get("来源说明")) or None,
|
||
remark=_opening_cell_text(row.get("备注")) or None,
|
||
user_id=user_id,
|
||
)
|
||
imported += 1
|
||
|
||
db.commit()
|
||
return {
|
||
"imported": imported,
|
||
"warehouse_type": normalized_type,
|
||
"message": f"期初库存导入完成:新增 {imported} 条",
|
||
}
|
||
|
||
|
||
@router.get("/opening/template")
|
||
def export_opening_inventory_template(
|
||
warehouse_type: str = Query(...),
|
||
db: Session = Depends(get_db),
|
||
) -> Response:
|
||
normalized_type = _normalize_warehouse_type(warehouse_type)
|
||
workbook = build_opening_inventory_template(db, normalized_type)
|
||
filename = f"{WAREHOUSE_TYPE_LABELS[normalized_type]}期初导入模版.xlsx"
|
||
return _excel_response(workbook, filename)
|
||
|
||
|
||
@router.post("/opening/import", response_model=OpeningInventoryImportResult)
|
||
async def import_opening_inventory(
|
||
warehouse_type: str = Query(...),
|
||
file: UploadFile = File(...),
|
||
context: AuthContext = Depends(require_authenticated_user),
|
||
db: Session = Depends(get_db),
|
||
) -> OpeningInventoryImportResult:
|
||
content = await file.read()
|
||
try:
|
||
result = import_opening_inventory_workbook(db, warehouse_type, content, user_id=context.user.id)
|
||
except ValueError as exc:
|
||
db.rollback()
|
||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||
return OpeningInventoryImportResult.model_validate(result)
|
||
|
||
|
||
@router.get("/stock-lots", response_model=list[StockLotRead])
|
||
def list_stock_lots(
|
||
item_type: str | None = Query(default=None),
|
||
limit: int = Query(default=200, ge=1, le=500),
|
||
db: Session = Depends(get_db),
|
||
) -> list[StockLotRead]:
|
||
rows = db.execute(get_stock_lots_query(limit=limit, item_type=item_type)).mappings().all()
|
||
return [StockLotRead.model_validate(dict(row)) for row in rows]
|
||
|
||
|
||
@router.get("/stock-lots/{lot_id}/purchase-links", response_model=list[StockLotPurchaseLinkRead])
|
||
def list_stock_lot_purchase_links(lot_id: int, db: Session = Depends(get_db)) -> list[StockLotPurchaseLinkRead]:
|
||
return [StockLotPurchaseLinkRead.model_validate(row) for row in get_stock_lot_purchase_links(db, lot_id)]
|
||
|
||
|
||
@router.get("/stock-lot-options", response_model=list[StockLotOptionRead])
|
||
def list_stock_lot_options(
|
||
material_item_id: int | None = Query(default=None, ge=1),
|
||
limit: int = Query(default=500, ge=1, le=1000),
|
||
db: Session = Depends(get_db),
|
||
) -> list[StockLotOptionRead]:
|
||
rows = db.execute(get_stock_lot_options_query(material_item_id=material_item_id).limit(limit)).mappings().all()
|
||
return [StockLotOptionRead.model_validate(dict(row)) for row in rows]
|
||
|
||
|
||
@router.get("/stock-balances", response_model=list[StockBalanceRead])
|
||
def list_stock_balances(
|
||
limit: int = Query(default=200, ge=1, le=500),
|
||
db: Session = Depends(get_db),
|
||
) -> list[StockBalanceRead]:
|
||
rows = db.execute(get_stock_balances_query(limit=limit)).mappings().all()
|
||
return [StockBalanceRead.model_validate(dict(row)) for row in rows]
|
||
|
||
|
||
@router.get("/transactions", response_model=list[InventoryTxnRead])
|
||
def list_inventory_transactions(
|
||
limit: int = Query(default=300, ge=1, le=800),
|
||
db: Session = Depends(get_db),
|
||
) -> list[InventoryTxnRead]:
|
||
rows = _apply_production_inbound_archive_links(
|
||
db,
|
||
[dict(row) for row in db.execute(get_inventory_txn_query(limit=limit)).mappings().all()],
|
||
)
|
||
return [InventoryTxnRead.model_validate(row) for row in rows]
|
||
|
||
|
||
PRODUCTION_LEDGER_INBOUND_TXN_ORDER = {
|
||
"成品入库": 1,
|
||
"生产余料入库": 2,
|
||
"生产废料入库": 3,
|
||
}
|
||
|
||
|
||
def _latest_inventory_archive_map(db: Session, document_type: str, business_ids: list[int]) -> dict[int, DocumentArchive]:
|
||
normalized_ids = sorted({int(business_id) for business_id in business_ids if business_id})
|
||
if not normalized_ids:
|
||
return {}
|
||
latest_version_subquery = (
|
||
select(
|
||
DocumentArchive.business_id.label("business_id"),
|
||
func.max(DocumentArchive.archive_version).label("archive_version"),
|
||
)
|
||
.where(
|
||
DocumentArchive.document_type == document_type,
|
||
DocumentArchive.file_format == FILE_FORMAT_PDF,
|
||
DocumentArchive.business_id.in_(normalized_ids),
|
||
)
|
||
.group_by(DocumentArchive.business_id)
|
||
.subquery()
|
||
)
|
||
latest_archive_id_subquery = (
|
||
select(
|
||
DocumentArchive.business_id.label("business_id"),
|
||
func.max(DocumentArchive.id).label("archive_id"),
|
||
)
|
||
.join(
|
||
latest_version_subquery,
|
||
and_(
|
||
latest_version_subquery.c.business_id == DocumentArchive.business_id,
|
||
latest_version_subquery.c.archive_version == DocumentArchive.archive_version,
|
||
),
|
||
)
|
||
.where(
|
||
DocumentArchive.document_type == document_type,
|
||
DocumentArchive.file_format == FILE_FORMAT_PDF,
|
||
DocumentArchive.business_id.in_(normalized_ids),
|
||
)
|
||
.group_by(DocumentArchive.business_id)
|
||
.subquery()
|
||
)
|
||
archives = db.scalars(
|
||
select(DocumentArchive).join(
|
||
latest_archive_id_subquery,
|
||
latest_archive_id_subquery.c.archive_id == DocumentArchive.id,
|
||
)
|
||
).all()
|
||
return {int(archive.business_id): archive for archive in archives}
|
||
|
||
|
||
def _production_inbound_group_key(txn: ProductionBatchLedgerTxn) -> tuple[int, object]:
|
||
batch_no = production_document_batch_no_from_text(txn.remark)
|
||
return (
|
||
int(txn.production_ledger_id),
|
||
f"批次:{batch_no}" if batch_no else txn.biz_time,
|
||
)
|
||
|
||
|
||
def _apply_production_inbound_archive_links(db: Session, rows: list[dict[str, object]]) -> list[dict[str, object]]:
|
||
inventory_txn_ids = [
|
||
int(row["inventory_txn_id"])
|
||
for row in rows
|
||
if row.get("archive_document_type") == DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT and row.get("inventory_txn_id")
|
||
]
|
||
if not inventory_txn_ids:
|
||
return rows
|
||
|
||
page_ledger_txns = db.scalars(
|
||
select(ProductionBatchLedgerTxn).where(
|
||
ProductionBatchLedgerTxn.source_doc_type == "库存流水",
|
||
ProductionBatchLedgerTxn.source_doc_id.in_(inventory_txn_ids),
|
||
ProductionBatchLedgerTxn.txn_type.in_(PRODUCTION_LEDGER_INBOUND_TXN_ORDER.keys()),
|
||
)
|
||
).all()
|
||
ledger_ids = sorted({int(txn.production_ledger_id) for txn in page_ledger_txns})
|
||
if not ledger_ids:
|
||
return rows
|
||
|
||
all_ledger_txns = db.scalars(
|
||
select(ProductionBatchLedgerTxn).where(
|
||
ProductionBatchLedgerTxn.production_ledger_id.in_(ledger_ids),
|
||
ProductionBatchLedgerTxn.source_doc_type == "库存流水",
|
||
ProductionBatchLedgerTxn.source_doc_id.is_not(None),
|
||
ProductionBatchLedgerTxn.txn_type.in_(PRODUCTION_LEDGER_INBOUND_TXN_ORDER.keys()),
|
||
)
|
||
).all()
|
||
grouped_txns: dict[tuple[int, object], list[ProductionBatchLedgerTxn]] = {}
|
||
for txn in all_ledger_txns:
|
||
grouped_txns.setdefault(_production_inbound_group_key(txn), []).append(txn)
|
||
|
||
settlement_business_id_by_inventory_txn_id: dict[int, int] = {}
|
||
for group_txns in grouped_txns.values():
|
||
main_txn = sorted(
|
||
group_txns,
|
||
key=lambda item: (
|
||
PRODUCTION_LEDGER_INBOUND_TXN_ORDER.get(str(item.txn_type or ""), 99),
|
||
int(item.source_doc_id or 0),
|
||
),
|
||
)[0]
|
||
if not main_txn.source_doc_id:
|
||
continue
|
||
main_business_id = int(main_txn.source_doc_id)
|
||
for txn in group_txns:
|
||
if txn.source_doc_id:
|
||
settlement_business_id_by_inventory_txn_id[int(txn.source_doc_id)] = main_business_id
|
||
|
||
if not settlement_business_id_by_inventory_txn_id:
|
||
return rows
|
||
|
||
archive_map = _latest_inventory_archive_map(
|
||
db,
|
||
DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT,
|
||
list(set(settlement_business_id_by_inventory_txn_id.values())),
|
||
)
|
||
for row in rows:
|
||
inventory_txn_id = int(row.get("inventory_txn_id") or 0)
|
||
main_business_id = settlement_business_id_by_inventory_txn_id.get(inventory_txn_id)
|
||
if not main_business_id:
|
||
continue
|
||
archive = archive_map.get(main_business_id)
|
||
row["archive_document_type"] = DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT
|
||
row["archive_business_id"] = main_business_id
|
||
row["archive_status"] = archive.status if archive else ARCHIVE_STATUS_MISSING
|
||
row["archive_error_message"] = archive.error_message if archive else None
|
||
return rows
|
||
|
||
|
||
@router.get("/transaction-ledger", response_model=InventoryTxnLedgerResponse)
|
||
def list_inventory_transaction_ledger(
|
||
warehouse_type: str = Query(...),
|
||
warehouse_id: int | None = None,
|
||
keyword: str | None = None,
|
||
direction: str = "ALL",
|
||
txn_type: str | None = None,
|
||
item_id: int | None = None,
|
||
lot_no: str | None = None,
|
||
start_date: date | None = None,
|
||
end_date: date | None = None,
|
||
page: int = 1,
|
||
page_size: int = 10,
|
||
sort_key: str = "biz_time",
|
||
sort_direction: str = "desc",
|
||
db: Session = Depends(get_db),
|
||
) -> InventoryTxnLedgerResponse:
|
||
normalized_warehouse_type = str(warehouse_type or "").upper()
|
||
if normalized_warehouse_type not in WAREHOUSE_LEDGER_TYPES:
|
||
raise HTTPException(status_code=400, detail="未知仓库类型,无法查询出入库流水")
|
||
normalized_direction = str(direction or "ALL").upper()
|
||
if normalized_direction not in {"ALL", "IN", "OUT", "ADJUST"}:
|
||
raise HTTPException(status_code=400, detail="流水方向只能是 ALL、IN、OUT、ADJUST")
|
||
if page < 1:
|
||
raise HTTPException(status_code=400, detail="页码必须大于等于 1")
|
||
if page_size < 1 or page_size > 100:
|
||
raise HTTPException(status_code=400, detail="每页条数必须在 1 到 100 之间")
|
||
if warehouse_id is not None and warehouse_id < 1:
|
||
raise HTTPException(status_code=400, detail="仓库 ID 必须大于等于 1")
|
||
if item_id is not None and item_id < 1:
|
||
raise HTTPException(status_code=400, detail="物料 ID 必须大于等于 1")
|
||
|
||
stmt = get_inventory_txn_ledger_query(
|
||
warehouse_type=normalized_warehouse_type,
|
||
warehouse_id=warehouse_id,
|
||
keyword=keyword,
|
||
direction=normalized_direction,
|
||
txn_type=txn_type,
|
||
item_id=item_id,
|
||
lot_no=lot_no,
|
||
start_date=start_date,
|
||
end_date=end_date,
|
||
sort_key=sort_key,
|
||
sort_direction=sort_direction,
|
||
)
|
||
total = db.scalar(select(func.count()).select_from(stmt.subquery())) or 0
|
||
summary = summarize_inventory_txn_ledger(db, stmt)
|
||
offset = (page - 1) * page_size
|
||
rows = _apply_production_inbound_archive_links(
|
||
db,
|
||
[dict(row) for row in db.execute(stmt.offset(offset).limit(page_size)).mappings().all()],
|
||
)
|
||
return InventoryTxnLedgerResponse(
|
||
items=rows,
|
||
total=total,
|
||
summary=summary,
|
||
)
|
||
|
||
|
||
WAREHOUSE_LEDGER_EXPORT_HEADERS = [
|
||
("biz_time", "业务时间"),
|
||
("warehouse_name", "仓库"),
|
||
("location_name", "库位"),
|
||
("direction_label", "方向"),
|
||
("txn_type_label", "流水类型"),
|
||
("txn_no", "流水号"),
|
||
("item_code", "物料/产品编码"),
|
||
("item_name", "物料/产品名称"),
|
||
("lot_no", "库存批次号"),
|
||
("source_material_lot_no", "来源库存批次号"),
|
||
("qty_change", "数量变化"),
|
||
("weight_change_kg", "重量变化(kg)"),
|
||
("unit_cost", "单价"),
|
||
("amount", "金额"),
|
||
("source_doc_label", "来源单据"),
|
||
("operator_name", "经办人"),
|
||
("logistics_waybill_no", "运单号"),
|
||
("logistics_freight_amount", "运费"),
|
||
("logistics_photo_label", "辅助照片"),
|
||
("remark", "备注"),
|
||
("archive_status", "PDF归档状态"),
|
||
("archive_business_id", "PDF归档业务ID"),
|
||
]
|
||
|
||
|
||
def _ledger_direction_label(value: str | None) -> str:
|
||
return {
|
||
"IN": "入库",
|
||
"OUT": "出库",
|
||
"ADJUST": "调整",
|
||
"ALL": "全部",
|
||
}.get(str(value or "").upper(), "未知方向")
|
||
|
||
|
||
def _ledger_source_doc_type_label(value: str | None) -> str:
|
||
normalized = str(value or "").strip()
|
||
upper_value = normalized.upper()
|
||
labels = {
|
||
"OPENING_STOCK": "期初入库",
|
||
"CUSTOMER_SUPPLIED_IN": "客料入库",
|
||
"PURCHASE_RECEIPT": "到货入库单",
|
||
"PURCHASE_RETURN": "采购退货",
|
||
"WORK_ORDER": "生产工单",
|
||
"FG_RECEIPT": "成品入库单",
|
||
"DELIVERY": "发货单",
|
||
"STOCKTAKE": "盘库",
|
||
"SPECIAL_ADJUSTMENT": "特殊调整",
|
||
"PRODUCT_ROUTE_OPERATION": "产品工序",
|
||
"PRODUCTION_LEDGER": "生产台账",
|
||
}
|
||
if normalized == "生产台账":
|
||
return "生产台账"
|
||
if upper_value in labels:
|
||
return labels[upper_value]
|
||
if not normalized:
|
||
return ""
|
||
if all(char.isalnum() or char == "_" for char in normalized):
|
||
return "系统单据"
|
||
return normalized
|
||
|
||
|
||
def _ledger_export_value(row: dict[str, object], key: str) -> object:
|
||
if key == "direction_label":
|
||
return _ledger_direction_label(row.get("direction"))
|
||
if key == "txn_type_label":
|
||
return _inventory_biz_type_label(row.get("txn_type"))
|
||
if key == "source_doc_label":
|
||
source_type = _ledger_source_doc_type_label(row.get("source_doc_type"))
|
||
source_id = row.get("source_doc_id")
|
||
return f"{source_type} #{source_id}" if source_type and source_id is not None else source_type
|
||
if key == "logistics_photo_label":
|
||
return "已上传" if row.get("logistics_photo_url") else ""
|
||
value = row.get(key)
|
||
return "" if value is None else value
|
||
|
||
|
||
@router.get("/transaction-ledger/export")
|
||
def export_inventory_transaction_ledger(
|
||
warehouse_type: str = Query(...),
|
||
warehouse_id: int | None = None,
|
||
keyword: str | None = None,
|
||
direction: str = "ALL",
|
||
txn_type: str | None = None,
|
||
item_id: int | None = None,
|
||
lot_no: str | None = None,
|
||
start_date: date | None = None,
|
||
end_date: date | None = None,
|
||
sort_key: str = "biz_time",
|
||
sort_direction: str = "desc",
|
||
db: Session = Depends(get_db),
|
||
) -> Response:
|
||
normalized_warehouse_type = str(warehouse_type or "").upper()
|
||
if normalized_warehouse_type not in WAREHOUSE_LEDGER_TYPES:
|
||
raise HTTPException(status_code=400, detail="未知仓库类型,无法导出出入库流水")
|
||
normalized_direction = str(direction or "ALL").upper()
|
||
if normalized_direction not in {"ALL", "IN", "OUT", "ADJUST"}:
|
||
raise HTTPException(status_code=400, detail="流水方向只能是 ALL、IN、OUT、ADJUST")
|
||
if warehouse_id is not None and warehouse_id < 1:
|
||
raise HTTPException(status_code=400, detail="仓库 ID 必须大于等于 1")
|
||
if item_id is not None and item_id < 1:
|
||
raise HTTPException(status_code=400, detail="物料 ID 必须大于等于 1")
|
||
|
||
stmt = get_inventory_txn_ledger_query(
|
||
warehouse_type=normalized_warehouse_type,
|
||
warehouse_id=warehouse_id,
|
||
keyword=keyword,
|
||
direction=normalized_direction,
|
||
txn_type=txn_type,
|
||
item_id=item_id,
|
||
lot_no=lot_no,
|
||
start_date=start_date,
|
||
end_date=end_date,
|
||
sort_key=sort_key,
|
||
sort_direction=sort_direction,
|
||
)
|
||
rows = _apply_production_inbound_archive_links(
|
||
db,
|
||
[dict(row) for row in db.execute(stmt).mappings().all()],
|
||
)
|
||
|
||
workbook = Workbook()
|
||
sheet = workbook.active
|
||
sheet.title = "出入库流水"
|
||
sheet.append([label for _, label in WAREHOUSE_LEDGER_EXPORT_HEADERS])
|
||
for row in rows:
|
||
sheet.append([_ledger_export_value(row, key) for key, _ in WAREHOUSE_LEDGER_EXPORT_HEADERS])
|
||
sheet.freeze_panes = "A2"
|
||
sheet.auto_filter.ref = sheet.dimensions
|
||
for column_cells in sheet.columns:
|
||
header = str(column_cells[0].value or "")
|
||
max_length = max(len(str(cell.value or "")) for cell in column_cells)
|
||
sheet.column_dimensions[column_cells[0].column_letter].width = min(max(max_length + 2, len(header) + 2), 42)
|
||
|
||
date_range = f"{start_date or '全部'}_{end_date or '全部'}"
|
||
warehouse_label = WAREHOUSE_TYPE_LABELS.get(normalized_warehouse_type, normalized_warehouse_type)
|
||
return _excel_response(workbook, f"{warehouse_label}出入库流水_{date_range}.xlsx")
|
||
|
||
|
||
@router.post("/logistics-photos")
|
||
async def upload_logistics_photo(file: UploadFile = File(...)) -> dict[str, str]:
|
||
suffix = Path(file.filename or "").suffix.lower()
|
||
content_type = (file.content_type or "").lower()
|
||
if suffix not in LOGISTICS_ALLOWED_SUFFIXES or not content_type.startswith("image/"):
|
||
raise HTTPException(status_code=400, detail="辅助照片仅支持 jpg、png、webp、heic 图片")
|
||
|
||
content = await file.read()
|
||
if len(content) <= 0:
|
||
raise HTTPException(status_code=400, detail="上传的辅助照片为空")
|
||
if len(content) > LOGISTICS_MAX_BYTES:
|
||
raise HTTPException(status_code=400, detail="辅助照片不能超过10MB")
|
||
|
||
LOGISTICS_UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
|
||
stored_name = f"{datetime.now().strftime('%Y%m%d%H%M%S')}-{uuid4().hex}{suffix}"
|
||
target = LOGISTICS_UPLOAD_DIR / stored_name
|
||
target.write_bytes(content)
|
||
return {"filename": stored_name, "url": f"/inventory/logistics-photos/{stored_name}"}
|
||
|
||
|
||
@router.get("/logistics-photos/{filename}")
|
||
def get_logistics_photo(filename: str) -> FileResponse:
|
||
if Path(filename).name != filename:
|
||
raise HTTPException(status_code=400, detail="照片路径不合法")
|
||
target = LOGISTICS_UPLOAD_DIR / filename
|
||
if not target.exists() or not target.is_file():
|
||
raise HTTPException(status_code=404, detail="辅助照片不存在")
|
||
return FileResponse(target)
|
||
|
||
|
||
@router.get("/settlements", response_model=list[InventorySettlementRead])
|
||
def list_inventory_settlements(
|
||
limit: int = Query(default=200, ge=1, le=800),
|
||
db: Session = Depends(get_db),
|
||
) -> list[InventorySettlementRead]:
|
||
return [InventorySettlementRead.model_validate(row) for row in get_inventory_settlements(db, limit=limit)]
|
||
|
||
|
||
@router.post("/special-adjustments", response_model=SpecialWarehouseAdjustmentRead)
|
||
def create_special_warehouse_adjustment(
|
||
payload: SpecialWarehouseAdjustmentCreate,
|
||
context: AuthContext = Depends(require_authenticated_user),
|
||
db: Session = Depends(get_db),
|
||
) -> SpecialWarehouseAdjustmentRead:
|
||
try:
|
||
adjustment = create_special_inventory_adjustment(db, payload, user_id=context.user.id)
|
||
except ValueError as exc:
|
||
db.rollback()
|
||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||
archive_business_ids = [
|
||
int(inventory_txn_id)
|
||
for inventory_txn_id in db.scalars(
|
||
select(SpecialWarehouseAdjustmentLine.inventory_txn_id)
|
||
.where(
|
||
SpecialWarehouseAdjustmentLine.adjustment_id == adjustment.id,
|
||
SpecialWarehouseAdjustmentLine.inventory_txn_id.is_not(None),
|
||
)
|
||
.order_by(SpecialWarehouseAdjustmentLine.line_no.asc())
|
||
).all()
|
||
]
|
||
archive_fields = _generate_inventory_archives_for_txns(
|
||
db,
|
||
document_type=DOCUMENT_TYPE_WAREHOUSE_OPERATION,
|
||
business_ids=archive_business_ids,
|
||
created_by=context.user.id,
|
||
)
|
||
result = SpecialWarehouseAdjustmentRead.model_validate(adjustment)
|
||
result.archive_status = archive_fields["archive_status"]
|
||
result.archive_business_id = archive_fields["archive_business_id"]
|
||
result.archive_document_type = archive_fields["archive_document_type"]
|
||
result.archive_error_message = archive_fields["archive_error_message"]
|
||
return result
|
||
|
||
|
||
@router.post("/customer-supplied-inbound", response_model=StockLotRead)
|
||
def create_customer_supplied_inbound(
|
||
payload: CustomerSuppliedInboundCreate,
|
||
context: AuthContext = Depends(require_authenticated_user),
|
||
db: Session = Depends(get_db),
|
||
) -> StockLotRead:
|
||
material = db.get(Item, payload.material_item_id)
|
||
if not material or material.item_type != "RAW_MATERIAL":
|
||
raise HTTPException(status_code=404, detail="客供料入库只能选择原材料")
|
||
warehouse = db.get(Warehouse, payload.warehouse_id)
|
||
if not warehouse:
|
||
raise HTTPException(status_code=404, detail="仓库不存在")
|
||
ensure_warehouses_unlocked(db, [payload.warehouse_id], "客料入库")
|
||
warehouse_type = str(warehouse.warehouse_type or "").upper()
|
||
biz_type = "CUSTOMER_SUPPLIED"
|
||
if biz_type not in _ALLOWED_INBOUND_BY_WAREHOUSE_TYPE.get(warehouse_type, set()):
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail=f"{_warehouse_type_label(warehouse_type)}不允许执行该入库业务",
|
||
)
|
||
_validate_warehouse_item(warehouse, material)
|
||
|
||
inbound_weight = to_decimal(payload.inbound_weight_kg)
|
||
inbound_qty = Decimal("0")
|
||
if inbound_weight <= 0:
|
||
raise HTTPException(status_code=400, detail="客供料入库重量必须大于0")
|
||
|
||
location = get_location_or_default(db, payload.warehouse_id, payload.location_id)
|
||
now = datetime.now()
|
||
lot = StockLot(
|
||
lot_no=_build_customer_supplied_lot_no(db, material),
|
||
parent_lot_id=None,
|
||
lot_role="CUSTOMER_SUPPLIED_RAW",
|
||
material_sub_batch_no=None,
|
||
item_id=payload.material_item_id,
|
||
warehouse_id=payload.warehouse_id,
|
||
location_id=location.id if location else None,
|
||
source_doc_type="CUSTOMER_SUPPLIED_IN",
|
||
source_doc_id=0,
|
||
source_line_id=None,
|
||
source_material_lot_id=None,
|
||
source_material_sub_batch_no=None,
|
||
source_material_summary=payload.provider_name or "客供料",
|
||
inbound_qty=inbound_qty,
|
||
inbound_weight_kg=inbound_weight,
|
||
remaining_qty=inbound_qty,
|
||
remaining_weight_kg=inbound_weight,
|
||
locked_qty=Decimal("0"),
|
||
locked_weight_kg=Decimal("0"),
|
||
unit_cost=Decimal("0"),
|
||
production_date=now.date(),
|
||
expire_date=None,
|
||
quality_status="PASS",
|
||
status="AVAILABLE",
|
||
remark=payload.remark or f"客供料入库:{payload.provider_name or '未填写提供方'}",
|
||
)
|
||
db.add(lot)
|
||
db.flush()
|
||
lot.source_doc_id = lot.id
|
||
db.add(lot)
|
||
|
||
upsert_stock_balance(
|
||
db,
|
||
item_id=payload.material_item_id,
|
||
warehouse_id=payload.warehouse_id,
|
||
location_id=location.id if location else None,
|
||
qty_delta=Decimal("0"),
|
||
weight_delta=inbound_weight,
|
||
available_qty_delta=Decimal("0"),
|
||
available_weight_delta=inbound_weight,
|
||
unit_cost=Decimal("0"),
|
||
)
|
||
create_inventory_txn(
|
||
db,
|
||
txn_type="CUSTOMER_SUPPLIED_IN",
|
||
item_id=payload.material_item_id,
|
||
warehouse_id=payload.warehouse_id,
|
||
location_id=location.id if location else None,
|
||
lot_id=lot.id,
|
||
qty_change=Decimal("0"),
|
||
weight_change=inbound_weight,
|
||
unit_cost=Decimal("0"),
|
||
source_doc_type="CUSTOMER_SUPPLIED_IN",
|
||
source_doc_id=lot.id,
|
||
source_line_id=None,
|
||
biz_time=now,
|
||
operator_user_id=context.user.id,
|
||
remark=lot.remark,
|
||
amount_basis="WEIGHT",
|
||
)
|
||
db.commit()
|
||
|
||
row = db.execute(get_stock_lots_query(limit=1).where(StockLot.id == lot.id)).mappings().first()
|
||
if not row:
|
||
raise HTTPException(status_code=500, detail="客供料入库后读取失败")
|
||
return StockLotRead.model_validate(dict(row))
|
||
|
||
|
||
# ---------- unified lot_no builders ----------
|
||
|
||
def _build_opening_lot_no(db: Session, item: Item, warehouse: Warehouse) -> str:
|
||
date_part = datetime.now().strftime("%Y%m%d")
|
||
item_part = _code_token(item.item_code or item.item_name, f"IT{item.id:04d}")
|
||
wh_part = _code_token(warehouse.warehouse_name or "", "WH")[:4]
|
||
prefix = f"QC-{wh_part}-{date_part}-{item_part}"
|
||
count = db.scalar(select(func.count(StockLot.id)).where(StockLot.lot_no.like(f"{prefix}-%"))) or 0
|
||
return f"{prefix}-{count + 1:03d}"
|
||
|
||
|
||
def _build_surplus_lot_no(db: Session, item: Item, warehouse: Warehouse, prefix_tag: str) -> str:
|
||
date_part = datetime.now().strftime("%Y%m%d")
|
||
item_part = _code_token(item.item_code or item.item_name, f"IT{item.id:04d}")
|
||
prefix = f"{prefix_tag}-{date_part}-{item_part}"
|
||
count = db.scalar(select(func.count(StockLot.id)).where(StockLot.lot_no.like(f"{prefix}-%"))) or 0
|
||
return f"{prefix}-{count + 1:03d}"
|
||
|
||
|
||
def _build_wip_lot_no(db: Session, item: Item, warehouse: Warehouse) -> str:
|
||
date_part = datetime.now().strftime("%Y%m%d")
|
||
item_part = _code_token(item.item_code or item.item_name, f"IT{item.id:04d}")
|
||
prefix = f"WIP-{date_part}-{item_part}"
|
||
count = db.scalar(select(func.count(StockLot.id)).where(StockLot.lot_no.like(f"{prefix}-%"))) or 0
|
||
return f"{prefix}-{count + 1:03d}"
|
||
|
||
|
||
def _build_finished_lot_no(db: Session, item: Item, warehouse: Warehouse) -> str:
|
||
date_part = datetime.now().strftime("%Y%m%d")
|
||
item_part = _code_token(item.item_code or item.item_name, f"IT{item.id:04d}")
|
||
prefix = f"FG-{date_part}-{item_part}"
|
||
count = db.scalar(select(func.count(StockLot.id)).where(StockLot.lot_no.like(f"{prefix}-%"))) or 0
|
||
return f"{prefix}-{count + 1:03d}"
|
||
|
||
|
||
# ---------- inbound biz_type -> (lot_role, txn_type, source_doc_type, default_cost) ----------
|
||
|
||
_INBOUND_BIZ_MAP = {
|
||
"OPENING": ("OPENING_STOCK", "OPENING_IN", "OPENING_STOCK", Decimal("0")),
|
||
"CUSTOMER_SUPPLIED": ("CUSTOMER_SUPPLIED_RAW","CUSTOMER_SUPPLIED_IN","CUSTOMER_SUPPLIED_IN", Decimal("0")),
|
||
"PRODUCTION_SURPLUS": ("PRODUCTION_SURPLUS", "PRODUCTION_SURPLUS_IN","PRODUCTION_SURPLUS_IN", None),
|
||
"OUTSOURCING_SURPLUS": ("OUTSOURCING_SURPLUS", "OUTSOURCING_SURPLUS_IN","OUTSOURCING_SURPLUS_IN", None),
|
||
"PRODUCTION_SCRAP_IN": ("PRODUCTION_SCRAP", "PRODUCTION_SCRAP_IN", "PRODUCTION_SCRAP_IN", None),
|
||
"REWORK_SCRAP_IN": ("REWORK_SCRAP", "REWORK_SCRAP_IN", "REWORK_SCRAP_IN", None),
|
||
"OUTSOURCING_SCRAP_IN":("OUTSOURCING_SCRAP", "OUTSOURCING_SCRAP_IN","OUTSOURCING_SCRAP_IN", None),
|
||
"WIP_IN": ("WIP_STAGING", "WIP_IN", "WIP_IN", None),
|
||
"OUTSOURCING_IN": ("OUTSOURCING_IN", "OUTSOURCING_IN", "OUTSOURCING_IN", None),
|
||
"PRODUCTION_FINISHED": ("INBOUND_FINISHED", "PRODUCTION_FINISHED_IN","PRODUCTION_FINISHED_IN", None),
|
||
}
|
||
|
||
_ALLOWED_INBOUND_BY_WAREHOUSE_TYPE = {
|
||
"RAW": {"OPENING", "CUSTOMER_SUPPLIED", "PRODUCTION_SURPLUS", "OUTSOURCING_SURPLUS"},
|
||
"SEMI": {"WIP_IN", "OPENING", "OUTSOURCING_IN"},
|
||
"FINISHED": {"PRODUCTION_FINISHED", "OUTSOURCING_IN", "OPENING"},
|
||
"AUX": {"OPENING"},
|
||
"SCRAP": {"PRODUCTION_SCRAP_IN", "REWORK_SCRAP_IN", "OPENING", "OUTSOURCING_SCRAP_IN"},
|
||
}
|
||
|
||
_LOGISTICS_REQUIRED_INBOUND_BIZ_TYPES = {
|
||
"CUSTOMER_SUPPLIED",
|
||
"OUTSOURCING_SURPLUS",
|
||
"OUTSOURCING_IN",
|
||
"OUTSOURCING_SCRAP_IN",
|
||
}
|
||
|
||
_SOURCE_LOT_RETURN_INBOUND_BIZ_TYPES = {"PRODUCTION_SURPLUS", "OUTSOURCING_SURPLUS"}
|
||
PRODUCTION_ARCHIVE_INBOUND_BIZ_TYPES = {"PRODUCTION_FINISHED", "PRODUCTION_SURPLUS", "PRODUCTION_SCRAP_IN"}
|
||
_PRODUCTION_WORK_ORDER_INBOUND_SETTLE_MARKERS = {
|
||
"PRODUCTION_SURPLUS": "生产余料已结单",
|
||
"PRODUCTION_SCRAP_IN": "生产废料已结单",
|
||
}
|
||
_PRODUCTION_WORK_ORDER_REQUIRED_SETTLE_MARKERS = (
|
||
"生产余料已结单",
|
||
"生产废料已结单",
|
||
)
|
||
_PRODUCTION_FINISHED_SETTLE_MARKER = "生产入库已结单"
|
||
|
||
|
||
def _require_work_order_inbound_source(payload: WarehouseInboundCreate, label: str) -> int:
|
||
if str(payload.source_doc_type or "").upper() != "WORK_ORDER" or not payload.source_doc_id:
|
||
raise HTTPException(status_code=400, detail=f"{label}必须选择生产工单")
|
||
return int(payload.source_doc_id)
|
||
|
||
|
||
def _is_production_ledger_source(payload: WarehouseInboundCreate) -> bool:
|
||
source_type = str(payload.source_doc_type or "").strip()
|
||
return source_type.upper() == "PRODUCTION_LEDGER" or source_type == "生产台账"
|
||
|
||
|
||
def _require_production_ledger_inbound_source(payload: WarehouseInboundCreate, label: str) -> int:
|
||
if not _is_production_ledger_source(payload) or not payload.source_doc_id:
|
||
raise HTTPException(status_code=400, detail=f"{label}必须选择生产台账")
|
||
return int(payload.source_doc_id)
|
||
|
||
|
||
def _should_archive_production_inbound(
|
||
biz_type: str,
|
||
*,
|
||
production_ledger_id: int | None,
|
||
production_work_order_id: int | None,
|
||
) -> bool:
|
||
return (
|
||
biz_type in PRODUCTION_ARCHIVE_INBOUND_BIZ_TYPES
|
||
and (production_ledger_id is not None or production_work_order_id is not None)
|
||
)
|
||
|
||
|
||
def _get_active_work_order_for_inbound(db: Session, work_order_id: int, label: str, biz_type: str | None = None) -> WorkOrder:
|
||
work_order = db.get(WorkOrder, work_order_id)
|
||
if not work_order:
|
||
raise HTTPException(status_code=404, detail="生产工单不存在")
|
||
marker = _PRODUCTION_WORK_ORDER_INBOUND_SETTLE_MARKERS.get(str(biz_type or "").upper())
|
||
if marker and marker in set(_work_order_remark_parts(work_order)):
|
||
raise HTTPException(status_code=400, detail=f"{label}选择的生产工单已完成本项结单,不能继续使用")
|
||
if str(work_order.status or "").upper() == "SETTLED":
|
||
if marker and _PRODUCTION_FINISHED_SETTLE_MARKER in set(_work_order_remark_parts(work_order)):
|
||
return work_order
|
||
raise HTTPException(status_code=400, detail=f"{label}选择的生产工单已结单,不能继续使用")
|
||
return work_order
|
||
|
||
|
||
def _append_work_order_settle_remark(work_order: WorkOrder, settle_remark: str | None) -> None:
|
||
remark = (settle_remark or "").strip()
|
||
if not remark:
|
||
return
|
||
existing = (work_order.remark or "").strip()
|
||
addition = f"结单说明:{remark}"
|
||
next_remark = f"{existing};{addition}" if existing else addition
|
||
work_order.remark = next_remark[-255:]
|
||
|
||
|
||
def _work_order_remark_parts(work_order: WorkOrder) -> list[str]:
|
||
return [part.strip() for part in str(work_order.remark or "").replace(";", ";").split(";") if part.strip()]
|
||
|
||
|
||
def _set_work_order_remark_parts(work_order: WorkOrder, parts: list[str]) -> None:
|
||
unique_parts: list[str] = []
|
||
for part in parts:
|
||
normalized = str(part or "").strip()
|
||
if normalized and normalized not in unique_parts:
|
||
unique_parts.append(normalized)
|
||
marker_parts = [marker for marker in _PRODUCTION_WORK_ORDER_REQUIRED_SETTLE_MARKERS if marker in unique_parts]
|
||
other_parts = [part for part in unique_parts if part not in _PRODUCTION_WORK_ORDER_REQUIRED_SETTLE_MARKERS]
|
||
next_remark = ";".join(marker_parts + other_parts)
|
||
if len(next_remark) <= 255:
|
||
work_order.remark = next_remark
|
||
return
|
||
marker_text = ";".join(marker_parts)
|
||
remaining_length = max(255 - len(marker_text) - (1 if marker_text else 0), 0)
|
||
other_text = ";".join(other_parts)
|
||
if marker_text and remaining_length > 0 and other_text:
|
||
work_order.remark = f"{marker_text};{other_text[-remaining_length:]}"
|
||
else:
|
||
work_order.remark = marker_text[:255] if marker_text else other_text[-255:]
|
||
|
||
|
||
def _mark_production_work_order_inbound_settled(
|
||
work_order: WorkOrder,
|
||
*,
|
||
biz_type: str,
|
||
settle_remark: str | None,
|
||
) -> None:
|
||
marker = _PRODUCTION_WORK_ORDER_INBOUND_SETTLE_MARKERS.get(biz_type)
|
||
if not marker:
|
||
_append_work_order_settle_remark(work_order, settle_remark)
|
||
return
|
||
parts = _work_order_remark_parts(work_order)
|
||
if marker not in parts:
|
||
parts.append(marker)
|
||
remark = (settle_remark or "").strip()
|
||
if remark:
|
||
parts.append(f"{marker}说明:{remark}")
|
||
_set_work_order_remark_parts(work_order, parts)
|
||
|
||
|
||
def _production_work_order_inbound_settlements_complete(work_order: WorkOrder) -> bool:
|
||
parts = set(_work_order_remark_parts(work_order))
|
||
return all(marker in parts for marker in _PRODUCTION_WORK_ORDER_REQUIRED_SETTLE_MARKERS)
|
||
|
||
|
||
def _apply_production_work_order_inbound_settlement(
|
||
work_order: WorkOrder,
|
||
*,
|
||
biz_type: str,
|
||
settle_remark: str | None,
|
||
) -> None:
|
||
_mark_production_work_order_inbound_settled(work_order, biz_type=biz_type, settle_remark=settle_remark)
|
||
if _production_work_order_inbound_settlements_complete(work_order):
|
||
work_order.status = "SETTLED"
|
||
work_order.actual_end_time = work_order.actual_end_time or datetime.now()
|
||
work_order.updated_at = datetime.now()
|
||
|
||
|
||
def _validate_surplus_settle_request(
|
||
payload: WarehouseInboundCreate,
|
||
*,
|
||
inbound_weight: Decimal,
|
||
expected_return_weight: Decimal,
|
||
returned_weight_before: Decimal,
|
||
) -> None:
|
||
final_return_weight = returned_weight_before + inbound_weight
|
||
lower_limit = expected_return_weight * Decimal("0.85")
|
||
upper_limit = expected_return_weight * INBOUND_TOLERANCE_MULTIPLIER
|
||
if lower_limit <= final_return_weight <= upper_limit:
|
||
return
|
||
if not (payload.settle_remark or "").strip():
|
||
raise HTTPException(status_code=400, detail="该工单余料结单归还重量超出标准归还重量上下15%,请填写结单说明(余料结单说明)")
|
||
|
||
|
||
def _validate_surplus_non_settle_request(
|
||
*,
|
||
inbound_weight: Decimal,
|
||
expected_return_weight: Decimal,
|
||
returned_weight_before: Decimal,
|
||
) -> None:
|
||
if inbound_weight <= 0:
|
||
raise HTTPException(status_code=400, detail="生产余料入库重量必须大于0")
|
||
upper_limit = to_decimal(max(expected_return_weight, Decimal("0")) * INBOUND_TOLERANCE_MULTIPLIER, "0.001")
|
||
final_return_weight = returned_weight_before + inbound_weight
|
||
if final_return_weight > upper_limit:
|
||
raise HTTPException(status_code=400, detail=f"累计归还重量超过允许入库上限 {upper_limit}")
|
||
|
||
|
||
def _validate_production_scrap_settle_request(
|
||
payload: WarehouseInboundCreate,
|
||
*,
|
||
inbound_weight: Decimal,
|
||
expected_scrap_weight: Decimal,
|
||
scrap_inbound_before: Decimal,
|
||
) -> None:
|
||
final_scrap_weight = scrap_inbound_before + inbound_weight
|
||
lower_limit = expected_scrap_weight * Decimal("0.85")
|
||
upper_limit = expected_scrap_weight * INBOUND_TOLERANCE_MULTIPLIER
|
||
if lower_limit <= final_scrap_weight <= upper_limit:
|
||
return
|
||
if not (payload.settle_remark or "").strip():
|
||
raise HTTPException(status_code=400, detail="该工单废料结单入库废料重量超出标准废料重量上下15%,请填写结单说明(废料结单说明)")
|
||
|
||
|
||
def _validate_production_scrap_non_settle_request(
|
||
*,
|
||
inbound_weight: Decimal,
|
||
expected_scrap_weight: Decimal,
|
||
scrap_inbound_before: Decimal,
|
||
) -> None:
|
||
if inbound_weight <= 0:
|
||
raise HTTPException(status_code=400, detail="生产废料入库重量必须大于0")
|
||
upper_limit = to_decimal(max(expected_scrap_weight, Decimal("0")) * INBOUND_TOLERANCE_MULTIPLIER, "0.001")
|
||
final_scrap_weight = scrap_inbound_before + inbound_weight
|
||
if final_scrap_weight > upper_limit:
|
||
raise HTTPException(status_code=400, detail=f"累计入库废料重量超过允许入库上限 {upper_limit}")
|
||
|
||
|
||
def _get_rework_work_order_for_scrap_inbound(db: Session, payload: WarehouseInboundCreate) -> WorkOrder:
|
||
if str(payload.source_doc_type or "").upper() != "WORK_ORDER" or not payload.source_doc_id:
|
||
raise HTTPException(status_code=400, detail="返工废料入库请选择返工工单")
|
||
work_order = db.get(WorkOrder, int(payload.source_doc_id))
|
||
if not work_order:
|
||
raise HTTPException(status_code=404, detail="返工工单不存在")
|
||
if str(work_order.work_order_type or "").upper() != "REWORK":
|
||
raise HTTPException(status_code=400, detail="返工废料入库只能选择返工工单")
|
||
if str(work_order.status or "").upper() == "SETTLED":
|
||
raise HTTPException(status_code=400, detail="该返工工单已结单,不能继续入废料库")
|
||
return work_order
|
||
|
||
|
||
def _rework_report_scrap_qty(db: Session, work_order_id: int) -> Decimal:
|
||
return to_decimal(
|
||
db.scalar(
|
||
select(func.coalesce(func.sum(OperationReport.scrap_qty), 0)).where(OperationReport.work_order_id == work_order_id)
|
||
),
|
||
"0.000001",
|
||
)
|
||
|
||
|
||
def _rework_scrap_inbound_qty(db: Session, work_order_id: int) -> Decimal:
|
||
return to_decimal(
|
||
db.scalar(
|
||
select(func.coalesce(func.sum(StockLot.inbound_qty), 0)).where(
|
||
StockLot.lot_role == "REWORK_SCRAP",
|
||
StockLot.source_doc_type == "WORK_ORDER",
|
||
StockLot.source_doc_id == work_order_id,
|
||
)
|
||
),
|
||
"0.000001",
|
||
)
|
||
|
||
|
||
def _rework_finished_inbound_qty(db: Session, work_order_id: int) -> Decimal:
|
||
return to_decimal(
|
||
db.scalar(
|
||
select(func.coalesce(func.sum(InventoryTxn.qty_change), 0)).where(
|
||
InventoryTxn.txn_type == "REWORK_FINISHED_IN",
|
||
InventoryTxn.source_doc_type == "WORK_ORDER",
|
||
InventoryTxn.source_doc_id == work_order_id,
|
||
)
|
||
),
|
||
"0.000001",
|
||
)
|
||
|
||
|
||
def _settle_rework_work_order_if_ready(
|
||
db: Session,
|
||
work_order: WorkOrder,
|
||
*,
|
||
incoming_scrap_qty: Decimal = Decimal("0"),
|
||
settle_remark: str | None = None,
|
||
) -> None:
|
||
planned_qty = to_decimal(work_order.planned_qty)
|
||
processed_qty = _rework_finished_inbound_qty(db, work_order.id) + _rework_scrap_inbound_qty(db, work_order.id) + incoming_scrap_qty
|
||
if processed_qty < planned_qty:
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail=f"返工工单尚未闭环:返工出库 {planned_qty} 件,已回库/入废料 {processed_qty} 件",
|
||
)
|
||
work_order.status = "SETTLED"
|
||
work_order.actual_end_time = work_order.actual_end_time or datetime.now()
|
||
parts = [part.strip() for part in str(work_order.remark or "").replace(";", ";").split(";") if part.strip()]
|
||
if "返工工单已结单" not in parts:
|
||
parts.append("返工工单已结单")
|
||
remark = str(settle_remark or "").strip()
|
||
if remark:
|
||
parts.append(f"返工结单说明:{remark}")
|
||
work_order.remark = ";".join(dict.fromkeys(parts))[-255:]
|
||
db.add(work_order)
|
||
|
||
|
||
@router.post("/inbound", response_model=StockLotRead)
|
||
def create_warehouse_inbound(
|
||
payload: WarehouseInboundCreate,
|
||
context: AuthContext = Depends(require_authenticated_user),
|
||
db: Session = Depends(get_db),
|
||
) -> StockLotRead:
|
||
biz_type = (payload.biz_type or "").upper()
|
||
if biz_type not in _INBOUND_BIZ_MAP:
|
||
raise HTTPException(status_code=400, detail=f"未知的入库业务类型: {biz_type}")
|
||
|
||
item = db.get(Item, payload.item_id)
|
||
if not item:
|
||
raise HTTPException(status_code=404, detail="物料不存在")
|
||
|
||
warehouse = db.get(Warehouse, payload.warehouse_id)
|
||
if not warehouse:
|
||
raise HTTPException(status_code=404, detail="仓库不存在")
|
||
ensure_warehouses_unlocked(db, [payload.warehouse_id], "入库")
|
||
warehouse_type = str(warehouse.warehouse_type or "").upper()
|
||
if biz_type not in _ALLOWED_INBOUND_BY_WAREHOUSE_TYPE.get(warehouse_type, set()):
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail=f"{_warehouse_type_label(warehouse_type)}不允许执行该入库业务",
|
||
)
|
||
_validate_warehouse_item(warehouse, item)
|
||
logistics_waybill_no, logistics_freight_amount, logistics_photo_url = normalize_logistics_fields(
|
||
payload.waybill_no,
|
||
payload.freight_amount,
|
||
required=biz_type in _LOGISTICS_REQUIRED_INBOUND_BIZ_TYPES,
|
||
order_photo_url=payload.order_photo_url,
|
||
)
|
||
|
||
inbound_weight = to_decimal(payload.inbound_weight_kg)
|
||
inbound_qty = to_decimal(payload.inbound_qty)
|
||
is_semi_source_lot_trace_inbound = warehouse_type == "SEMI" and biz_type in {"WIP_IN", "OUTSOURCING_IN"}
|
||
is_finished_outsourcing_quantity_inbound = warehouse_type == "FINISHED" and biz_type == "OUTSOURCING_IN"
|
||
is_finished_production_quantity_inbound = (
|
||
warehouse_type == "FINISHED"
|
||
and biz_type == "PRODUCTION_FINISHED"
|
||
and (_is_production_ledger_source(payload) or str(payload.source_doc_type or "").upper() == "WORK_ORDER")
|
||
)
|
||
is_outsourcing_scrap_source_lot_trace_inbound = warehouse_type == "SCRAP" and biz_type == "OUTSOURCING_SCRAP_IN"
|
||
is_rework_scrap_inbound = warehouse_type == "SCRAP" and biz_type == "REWORK_SCRAP_IN"
|
||
is_product_raw_source_lot_trace_inbound = (
|
||
is_semi_source_lot_trace_inbound
|
||
or is_finished_outsourcing_quantity_inbound
|
||
or is_outsourcing_scrap_source_lot_trace_inbound
|
||
)
|
||
is_aux_quantity_inbound = warehouse_type == "AUX"
|
||
semi_inbound_measure_basis: str | None = None
|
||
if inbound_weight < 0 or inbound_qty < 0:
|
||
raise HTTPException(status_code=400, detail="入库重量和入库数量不能小于0")
|
||
if is_semi_source_lot_trace_inbound:
|
||
semi_inbound_measure_basis = _semi_measure_basis_from_values(inbound_weight, inbound_qty, "入库")
|
||
elif is_finished_outsourcing_quantity_inbound or is_finished_production_quantity_inbound:
|
||
if inbound_qty <= 0:
|
||
label = "成品生产入库" if is_finished_production_quantity_inbound else "成品委外入库"
|
||
raise HTTPException(status_code=400, detail=f"{label}数量必须大于0")
|
||
if is_finished_production_quantity_inbound and inbound_weight <= 0:
|
||
inbound_weight = inbound_qty * to_decimal(item.unit_weight_kg)
|
||
if is_finished_outsourcing_quantity_inbound:
|
||
inbound_weight = Decimal("0")
|
||
elif is_aux_quantity_inbound:
|
||
if inbound_qty <= 0:
|
||
raise HTTPException(status_code=400, detail="辅料入库数量必须大于0")
|
||
inbound_weight = Decimal("0")
|
||
elif is_rework_scrap_inbound:
|
||
if inbound_qty <= 0:
|
||
raise HTTPException(status_code=400, detail="返工废料入库数量必须大于0")
|
||
if inbound_weight <= 0:
|
||
inbound_weight = inbound_qty * to_decimal(item.unit_weight_kg)
|
||
if inbound_weight <= 0:
|
||
raise HTTPException(status_code=400, detail="返工废料入库重量必须大于0")
|
||
else:
|
||
if inbound_weight <= 0:
|
||
raise HTTPException(status_code=400, detail="入库重量必须大于0")
|
||
inbound_qty = Decimal("0")
|
||
|
||
lot_role, txn_type, source_doc_type, default_cost = _INBOUND_BIZ_MAP[biz_type]
|
||
production_ledger_id: int | None = None
|
||
production_work_order_id: int | None = None
|
||
production_work_order_material = None
|
||
production_work_order: WorkOrder | None = None
|
||
rework_scrap_work_order: WorkOrder | None = None
|
||
settle_production_work_order = False
|
||
if biz_type == "PRODUCTION_SURPLUS":
|
||
if _is_production_ledger_source(payload):
|
||
production_ledger_id = _require_production_ledger_inbound_source(payload, "生产余料入库")
|
||
else:
|
||
production_work_order_id = _require_work_order_inbound_source(payload, "生产余料入库")
|
||
production_work_order = _get_active_work_order_for_inbound(db, production_work_order_id, "生产余料入库", biz_type)
|
||
snapshot = get_work_order_limit_snapshot(db, production_work_order_id)
|
||
if payload.settle_work_order:
|
||
expected_return_weight = to_decimal(snapshot.issued_weight_kg) - to_decimal(snapshot.used_material_weight_kg)
|
||
_validate_surplus_settle_request(
|
||
payload,
|
||
inbound_weight=inbound_weight,
|
||
expected_return_weight=max(expected_return_weight, Decimal("0")),
|
||
returned_weight_before=to_decimal(snapshot.returned_material_weight_kg),
|
||
)
|
||
settle_production_work_order = True
|
||
else:
|
||
expected_return_weight = to_decimal(snapshot.issued_weight_kg) - to_decimal(snapshot.used_material_weight_kg)
|
||
_validate_surplus_non_settle_request(
|
||
inbound_weight=inbound_weight,
|
||
expected_return_weight=max(expected_return_weight, Decimal("0")),
|
||
returned_weight_before=to_decimal(snapshot.returned_material_weight_kg),
|
||
)
|
||
if payload.source_lot_id:
|
||
production_work_order_material = validate_work_order_source_lot(
|
||
db,
|
||
production_work_order_id,
|
||
int(payload.source_lot_id),
|
||
payload.item_id,
|
||
)
|
||
elif biz_type == "PRODUCTION_SCRAP_IN":
|
||
if _is_production_ledger_source(payload):
|
||
production_ledger_id = _require_production_ledger_inbound_source(payload, "生产废料入库")
|
||
else:
|
||
production_work_order_id = _require_work_order_inbound_source(payload, "生产废料入库")
|
||
production_work_order = _get_active_work_order_for_inbound(db, production_work_order_id, "生产废料入库", biz_type)
|
||
snapshot = get_work_order_limit_snapshot(db, production_work_order_id)
|
||
expected_scrap_weight = to_decimal(snapshot.standard_scrap_weight_kg)
|
||
if payload.settle_work_order:
|
||
_validate_production_scrap_settle_request(
|
||
payload,
|
||
inbound_weight=inbound_weight,
|
||
expected_scrap_weight=expected_scrap_weight,
|
||
scrap_inbound_before=to_decimal(snapshot.scrap_inbound_weight_kg),
|
||
)
|
||
settle_production_work_order = True
|
||
else:
|
||
_validate_production_scrap_non_settle_request(
|
||
inbound_weight=inbound_weight,
|
||
expected_scrap_weight=expected_scrap_weight,
|
||
scrap_inbound_before=to_decimal(snapshot.scrap_inbound_weight_kg),
|
||
)
|
||
elif biz_type == "PRODUCTION_FINISHED":
|
||
if _is_production_ledger_source(payload):
|
||
production_ledger_id = _require_production_ledger_inbound_source(payload, "生产入库")
|
||
production_ledger = db.get(ProductionBatchLedger, production_ledger_id)
|
||
if not production_ledger:
|
||
raise HTTPException(status_code=404, detail="生产台账不存在")
|
||
if int(production_ledger.product_item_id) != int(payload.item_id):
|
||
raise HTTPException(status_code=400, detail="生产台账产品与入库产品不一致")
|
||
elif str(payload.source_doc_type or "").upper() == "WORK_ORDER" and payload.source_doc_id:
|
||
production_work_order_id = _require_work_order_inbound_source(payload, "生产入库")
|
||
production_work_order = _get_active_work_order_for_inbound(db, production_work_order_id, "生产入库", biz_type)
|
||
if int(production_work_order.product_item_id) != int(payload.item_id):
|
||
raise HTTPException(status_code=400, detail="生产工单产品与入库产品不一致")
|
||
elif biz_type == "REWORK_SCRAP_IN":
|
||
rework_scrap_work_order = _get_rework_work_order_for_scrap_inbound(db, payload)
|
||
reported_scrap_qty = _rework_report_scrap_qty(db, rework_scrap_work_order.id)
|
||
already_scrap_inbound_qty = _rework_scrap_inbound_qty(db, rework_scrap_work_order.id)
|
||
max_rework_scrap_qty = reported_scrap_qty - already_scrap_inbound_qty
|
||
if inbound_qty > max_rework_scrap_qty:
|
||
raise HTTPException(status_code=400, detail=f"返工废料入库数量不能超过当前可入库废品数量 {max_rework_scrap_qty}")
|
||
if payload.settle_work_order:
|
||
_settle_rework_work_order_if_ready(
|
||
db,
|
||
rework_scrap_work_order,
|
||
incoming_scrap_qty=inbound_qty,
|
||
settle_remark=payload.settle_remark,
|
||
)
|
||
|
||
if biz_type in _SOURCE_LOT_RETURN_INBOUND_BIZ_TYPES:
|
||
if not payload.source_lot_id:
|
||
raise HTTPException(status_code=400, detail="余料入库请选择来源库存批次号")
|
||
source_lot = db.get(StockLot, payload.source_lot_id)
|
||
if not source_lot:
|
||
raise HTTPException(status_code=404, detail="来源库存批次号不存在")
|
||
if source_lot.item_id != payload.item_id:
|
||
raise HTTPException(status_code=400, detail="来源库存批次号与入库物料不一致")
|
||
if source_lot.warehouse_id != payload.warehouse_id:
|
||
raise HTTPException(status_code=400, detail="来源库存批次号不属于当前仓库")
|
||
if source_lot.quality_status != "PASS":
|
||
raise HTTPException(status_code=400, detail="来源库存批次号未质检放行,不能归还余料")
|
||
|
||
lot_location_id = source_lot.location_id
|
||
if lot_location_id is None:
|
||
lot_location = get_location_or_default(db, payload.warehouse_id, payload.location_id)
|
||
lot_location_id = lot_location.id if lot_location else None
|
||
source_lot.location_id = lot_location_id
|
||
|
||
unit_cost = to_decimal(source_lot.unit_cost, "0.0001")
|
||
source_lot.remaining_weight_kg = to_decimal(source_lot.remaining_weight_kg) + inbound_weight
|
||
if source_lot.status == "DEPLETED" and to_decimal(source_lot.remaining_weight_kg) > 0:
|
||
source_lot.status = "AVAILABLE"
|
||
source_lot.updated_at = datetime.now()
|
||
db.add(source_lot)
|
||
if production_work_order_material is not None:
|
||
production_work_order_material.returned_weight_kg = (
|
||
to_decimal(production_work_order_material.returned_weight_kg) + inbound_weight
|
||
)
|
||
production_work_order_material.updated_at = datetime.now()
|
||
db.add(production_work_order_material)
|
||
if settle_production_work_order and production_work_order is not None:
|
||
_apply_production_work_order_inbound_settlement(
|
||
production_work_order,
|
||
biz_type=biz_type,
|
||
settle_remark=payload.settle_remark,
|
||
)
|
||
db.add(production_work_order)
|
||
|
||
upsert_stock_balance(
|
||
db,
|
||
item_id=payload.item_id,
|
||
warehouse_id=payload.warehouse_id,
|
||
location_id=lot_location_id,
|
||
qty_delta=Decimal("0"),
|
||
weight_delta=inbound_weight,
|
||
available_qty_delta=Decimal("0"),
|
||
available_weight_delta=inbound_weight,
|
||
unit_cost=unit_cost,
|
||
)
|
||
return_remark = payload.remark or _inventory_biz_type_label(biz_type)
|
||
if payload.provider_name:
|
||
return_remark = f"来源/提供方:{payload.provider_name};{return_remark}"
|
||
|
||
return_txn = create_inventory_txn(
|
||
db,
|
||
txn_type=txn_type,
|
||
item_id=payload.item_id,
|
||
warehouse_id=payload.warehouse_id,
|
||
location_id=lot_location_id,
|
||
lot_id=source_lot.id,
|
||
qty_change=Decimal("0"),
|
||
weight_change=inbound_weight,
|
||
unit_cost=unit_cost,
|
||
source_doc_type="生产台账" if production_ledger_id else "WORK_ORDER" if production_work_order_id else source_doc_type,
|
||
source_doc_id=production_ledger_id or production_work_order_id or source_lot.id,
|
||
source_line_id=source_lot.id if (production_work_order_id or production_ledger_id) else None,
|
||
biz_time=datetime.now(),
|
||
operator_user_id=context.user.id,
|
||
remark=return_remark,
|
||
amount_basis="WEIGHT",
|
||
logistics_waybill_no=logistics_waybill_no,
|
||
logistics_freight_amount=logistics_freight_amount,
|
||
logistics_photo_url=logistics_photo_url,
|
||
)
|
||
if production_ledger_id:
|
||
post_surplus_inbound_to_ledger(
|
||
db,
|
||
production_ledger_id=production_ledger_id,
|
||
surplus_weight_kg=inbound_weight,
|
||
operator_user_id=context.user.id,
|
||
source_doc_type="库存流水",
|
||
source_doc_id=return_txn.id,
|
||
source_line_id=source_lot.id,
|
||
biz_time=datetime.now(),
|
||
remark=return_remark,
|
||
)
|
||
if payload.settle_work_order:
|
||
lock_production_batch_ledger(
|
||
db,
|
||
production_ledger_id=production_ledger_id,
|
||
operator_user_id=context.user.id,
|
||
biz_time=datetime.now(),
|
||
remark=payload.settle_remark or "生产余料入库后该批材料结单",
|
||
)
|
||
use_production_archive = _should_archive_production_inbound(
|
||
biz_type,
|
||
production_ledger_id=production_ledger_id,
|
||
production_work_order_id=production_work_order_id,
|
||
)
|
||
archive_document_type = DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT if use_production_archive else DOCUMENT_TYPE_WAREHOUSE_OPERATION
|
||
archive_business_id = int(return_txn.id)
|
||
db.commit()
|
||
|
||
archive_fields = _generate_inventory_archive_fields(
|
||
db,
|
||
document_type=archive_document_type,
|
||
business_id=archive_business_id,
|
||
created_by=context.user.id,
|
||
)
|
||
row = db.execute(get_stock_lots_query(limit=1).where(StockLot.id == source_lot.id)).mappings().first()
|
||
if not row:
|
||
raise HTTPException(status_code=500, detail="余料入库后读取失败")
|
||
return _stock_lot_read_with_archive(dict(row), **archive_fields)
|
||
|
||
unit_cost = to_decimal(payload.unit_cost, "0.0001") if payload.unit_cost is not None else default_cost
|
||
if unit_cost is None:
|
||
# try to look up from latest stock balance
|
||
latest = db.scalar(
|
||
select(StockBalance.avg_unit_cost)
|
||
.where(StockBalance.item_id == payload.item_id, StockBalance.warehouse_id == payload.warehouse_id)
|
||
.order_by(StockBalance.id.desc())
|
||
.limit(1)
|
||
)
|
||
unit_cost = to_decimal(latest, "0.0001") if latest else Decimal("0")
|
||
|
||
location = get_location_or_default(db, payload.warehouse_id, payload.location_id)
|
||
now = datetime.now()
|
||
operation_source_doc_type = source_doc_type
|
||
operation_source_doc_id = payload.source_doc_id or 0
|
||
operation_source_line_id = payload.source_line_id
|
||
if biz_type == "PRODUCTION_FINISHED" and production_ledger_id:
|
||
operation_source_doc_type = "生产台账"
|
||
operation_source_doc_id = production_ledger_id
|
||
operation_source_line_id = None
|
||
if biz_type == "PRODUCTION_FINISHED" and production_work_order_id:
|
||
operation_source_doc_type = "WORK_ORDER"
|
||
operation_source_doc_id = production_work_order_id
|
||
operation_source_line_id = None
|
||
if biz_type == "PRODUCTION_SCRAP_IN" and production_ledger_id:
|
||
operation_source_doc_type = "生产台账"
|
||
operation_source_doc_id = production_ledger_id
|
||
operation_source_line_id = None
|
||
if biz_type == "PRODUCTION_SCRAP_IN" and production_work_order_id:
|
||
operation_source_doc_type = "WORK_ORDER"
|
||
operation_source_doc_id = production_work_order_id
|
||
if biz_type == "REWORK_SCRAP_IN" and rework_scrap_work_order is not None:
|
||
operation_source_doc_type = "WORK_ORDER"
|
||
operation_source_doc_id = rework_scrap_work_order.id
|
||
if warehouse_type == "SEMI" and biz_type in {"WIP_IN", "OUTSOURCING_IN"}:
|
||
route_operation = _validate_semi_product_operation(db, payload.item_id, payload.source_line_id)
|
||
operation_source_doc_type = payload.source_doc_type or "PRODUCT_ROUTE_OPERATION"
|
||
operation_source_doc_id = payload.source_doc_id or route_operation.route_id
|
||
operation_source_line_id = route_operation.id
|
||
existing_measure_basis = _get_existing_semi_measure_basis(db, payload.item_id, payload.warehouse_id, route_operation.id)
|
||
if existing_measure_basis == "MIXED":
|
||
raise HTTPException(status_code=400, detail="该半成品已有历史混合库存,请先清理历史库存后再入库")
|
||
if existing_measure_basis and semi_inbound_measure_basis and existing_measure_basis != semi_inbound_measure_basis:
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail=f"该半成品已有库存{_semi_measure_basis_label(existing_measure_basis)}管理,后续入库必须沿用该口径",
|
||
)
|
||
product_source_lots = _resolve_product_raw_source_lots(db, payload.item_id, payload.source_lot_ids) if is_product_raw_source_lot_trace_inbound else []
|
||
product_source_lot_nos = [lot.lot_no for lot in product_source_lots]
|
||
source_material_sub_batch_no = payload.source_material_sub_batch_no
|
||
source_material_summary = payload.source_material_summary or payload.provider_name or _inventory_biz_type_label(biz_type)
|
||
if biz_type == "REWORK_SCRAP_IN" and rework_scrap_work_order is not None:
|
||
source_material_summary = payload.source_material_summary or f"返工废料入库;工单:{rework_scrap_work_order.work_order_no}"
|
||
if biz_type == "CUSTOMER_SUPPLIED":
|
||
source_material_sub_batch_no = None
|
||
source_material_summary = payload.provider_name or "客料入库"
|
||
if product_source_lot_nos:
|
||
source_material_sub_batch_no = "、".join(product_source_lot_nos)
|
||
source_lot_summary = f"来源库存批次号:{source_material_sub_batch_no}"
|
||
source_material_summary = f"{source_material_summary};{source_lot_summary}" if source_material_summary else source_lot_summary
|
||
|
||
# build lot_no
|
||
lot_no = payload.lot_no
|
||
if warehouse_type == "RAW" and biz_type in {"OPENING", "CUSTOMER_SUPPLIED"}:
|
||
lot_no = None
|
||
if not lot_no:
|
||
if warehouse_type == "RAW" and biz_type in {"OPENING", "CUSTOMER_SUPPLIED"}:
|
||
lot_no = build_inventory_lot_no(db, item.id)
|
||
elif biz_type in ("OPENING",):
|
||
lot_no = _build_opening_lot_no(db, item, warehouse)
|
||
elif biz_type in ("PRODUCTION_SURPLUS", "OUTSOURCING_SURPLUS"):
|
||
tag = "SCSY" if biz_type == "PRODUCTION_SURPLUS" else "WWSY"
|
||
lot_no = _build_surplus_lot_no(db, item, warehouse, tag)
|
||
elif biz_type in ("PRODUCTION_SCRAP_IN", "OUTSOURCING_SCRAP_IN", "REWORK_SCRAP_IN"):
|
||
tag = "SCFL" if biz_type == "PRODUCTION_SCRAP_IN" else "FWFL" if biz_type == "REWORK_SCRAP_IN" else "WWFL"
|
||
lot_no = _build_surplus_lot_no(db, item, warehouse, tag)
|
||
elif biz_type == "WIP_IN":
|
||
lot_no = _build_wip_lot_no(db, item, warehouse)
|
||
elif biz_type == "PRODUCTION_FINISHED":
|
||
lot_no = _build_finished_lot_no(db, item, warehouse)
|
||
elif biz_type == "OUTSOURCING_IN":
|
||
lot_no = _build_surplus_lot_no(db, item, warehouse, "WWJK")
|
||
else:
|
||
lot_no = _build_opening_lot_no(db, item, warehouse)
|
||
|
||
existing_lot = db.scalar(select(StockLot).where(StockLot.lot_no == lot_no))
|
||
if existing_lot:
|
||
raise HTTPException(status_code=400, detail=f"批次号 {lot_no} 已存在,请更换后重试")
|
||
|
||
lot = StockLot(
|
||
lot_no=lot_no,
|
||
parent_lot_id=None,
|
||
lot_role=lot_role,
|
||
material_sub_batch_no=None,
|
||
item_id=payload.item_id,
|
||
warehouse_id=payload.warehouse_id,
|
||
location_id=location.id if location else None,
|
||
source_doc_type=operation_source_doc_type,
|
||
source_doc_id=operation_source_doc_id,
|
||
source_line_id=operation_source_line_id,
|
||
source_material_lot_id=product_source_lots[0].id if product_source_lots else None,
|
||
source_material_sub_batch_no=source_material_sub_batch_no,
|
||
source_material_summary=source_material_summary,
|
||
logistics_waybill_no=logistics_waybill_no,
|
||
logistics_freight_amount=logistics_freight_amount,
|
||
logistics_photo_url=logistics_photo_url,
|
||
inbound_qty=inbound_qty,
|
||
inbound_weight_kg=inbound_weight,
|
||
remaining_qty=inbound_qty,
|
||
remaining_weight_kg=inbound_weight,
|
||
locked_qty=Decimal("0"),
|
||
locked_weight_kg=Decimal("0"),
|
||
unit_cost=unit_cost,
|
||
production_date=now.date(),
|
||
expire_date=None,
|
||
quality_status="PASS",
|
||
status="AVAILABLE",
|
||
remark=payload.remark or _inventory_biz_type_label(biz_type),
|
||
)
|
||
db.add(lot)
|
||
db.flush()
|
||
if not operation_source_doc_id:
|
||
lot.source_doc_id = lot.id
|
||
db.add(lot)
|
||
|
||
balance_qty_delta = Decimal("0") if warehouse_type in WEIGHT_ONLY_WAREHOUSE_TYPES else inbound_qty
|
||
upsert_stock_balance(
|
||
db,
|
||
item_id=payload.item_id,
|
||
warehouse_id=payload.warehouse_id,
|
||
location_id=location.id if location else None,
|
||
qty_delta=balance_qty_delta,
|
||
weight_delta=inbound_weight,
|
||
available_qty_delta=balance_qty_delta,
|
||
available_weight_delta=inbound_weight,
|
||
unit_cost=unit_cost,
|
||
)
|
||
inbound_txn = create_inventory_txn(
|
||
db,
|
||
txn_type=txn_type,
|
||
item_id=payload.item_id,
|
||
warehouse_id=payload.warehouse_id,
|
||
location_id=location.id if location else None,
|
||
lot_id=lot.id,
|
||
qty_change=inbound_qty,
|
||
weight_change=inbound_weight,
|
||
unit_cost=unit_cost,
|
||
source_doc_type=operation_source_doc_type,
|
||
source_doc_id=lot.source_doc_id,
|
||
source_line_id=operation_source_line_id,
|
||
biz_time=now,
|
||
operator_user_id=context.user.id,
|
||
remark=payload.remark or _inventory_biz_type_label(biz_type),
|
||
amount_basis="QTY" if (is_aux_quantity_inbound or is_finished_outsourcing_quantity_inbound or is_finished_production_quantity_inbound) else "WEIGHT",
|
||
logistics_waybill_no=logistics_waybill_no,
|
||
logistics_freight_amount=logistics_freight_amount,
|
||
logistics_photo_url=logistics_photo_url,
|
||
)
|
||
if biz_type == "PRODUCTION_SCRAP_IN" and production_ledger_id:
|
||
post_scrap_inbound_to_ledger(
|
||
db,
|
||
production_ledger_id=production_ledger_id,
|
||
scrap_weight_kg=inbound_weight,
|
||
operator_user_id=context.user.id,
|
||
source_doc_type="库存流水",
|
||
source_doc_id=inbound_txn.id,
|
||
source_line_id=lot.id,
|
||
biz_time=now,
|
||
remark=payload.remark or "生产废料入库联动生产台账",
|
||
)
|
||
if payload.settle_work_order:
|
||
lock_production_batch_ledger(
|
||
db,
|
||
production_ledger_id=production_ledger_id,
|
||
operator_user_id=context.user.id,
|
||
biz_time=now,
|
||
remark=payload.settle_remark or "生产废料入库后该批材料结单",
|
||
)
|
||
if biz_type == "PRODUCTION_FINISHED" and production_ledger_id:
|
||
post_finished_inbound_to_ledger(
|
||
db,
|
||
production_ledger_id=production_ledger_id,
|
||
finished_qty=inbound_qty,
|
||
operator_user_id=context.user.id,
|
||
source_doc_type="库存流水",
|
||
source_doc_id=inbound_txn.id,
|
||
source_line_id=lot.id,
|
||
biz_time=now,
|
||
remark=payload.remark or "生产入库联动生产台账",
|
||
)
|
||
if payload.settle_work_order:
|
||
lock_production_batch_ledger(
|
||
db,
|
||
production_ledger_id=production_ledger_id,
|
||
operator_user_id=context.user.id,
|
||
biz_time=now,
|
||
remark=payload.settle_remark or "生产入库后该批材料结单",
|
||
)
|
||
if biz_type == "PRODUCTION_FINISHED" and production_work_order is not None:
|
||
production_work_order.finished_qty = to_decimal(production_work_order.finished_qty) + inbound_qty
|
||
if payload.settle_work_order:
|
||
_apply_production_work_order_inbound_settlement(
|
||
production_work_order,
|
||
biz_type=biz_type,
|
||
settle_remark=payload.settle_remark,
|
||
)
|
||
db.add(production_work_order)
|
||
if settle_production_work_order and production_work_order is not None:
|
||
_apply_production_work_order_inbound_settlement(
|
||
production_work_order,
|
||
biz_type=biz_type,
|
||
settle_remark=payload.settle_remark,
|
||
)
|
||
db.add(production_work_order)
|
||
use_production_archive = _should_archive_production_inbound(
|
||
biz_type,
|
||
production_ledger_id=production_ledger_id,
|
||
production_work_order_id=production_work_order_id,
|
||
)
|
||
archive_document_type = DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT if use_production_archive else DOCUMENT_TYPE_WAREHOUSE_OPERATION
|
||
archive_business_id = int(inbound_txn.id)
|
||
db.commit()
|
||
|
||
archive_fields = _generate_inventory_archive_fields(
|
||
db,
|
||
document_type=archive_document_type,
|
||
business_id=archive_business_id,
|
||
created_by=context.user.id,
|
||
)
|
||
row = db.execute(get_stock_lots_query(limit=1).where(StockLot.id == lot.id)).mappings().first()
|
||
if not row:
|
||
raise HTTPException(status_code=500, detail="入库后读取失败")
|
||
return _stock_lot_read_with_archive(dict(row), **archive_fields)
|
||
|
||
|
||
# ---------- outbound biz_type -> (txn_type, source_doc_type) ----------
|
||
|
||
_OUTBOUND_BIZ_MAP = {
|
||
"PRODUCTION_OUT": ("PRODUCTION_OUT", "PRODUCTION_OUT"),
|
||
"OUTSOURCING_OUT": ("OUTSOURCING_OUT", "OUTSOURCING_OUT"),
|
||
"SCRAP_OUT": ("SCRAP_OUT", "SCRAP_OUT"),
|
||
"SCRAP_SALE_OUT": ("SCRAP_SALE_OUT", "SCRAP_SALE_OUT"),
|
||
"WIP_OUT": ("WIP_OUT", "WIP_OUT"),
|
||
"SALES_OUT": ("SALES_OUT", "SALES_OUT"),
|
||
"PURCHASE_RETURN_OUT": ("PURCHASE_RETURN_OUT", "PURCHASE_RETURN"),
|
||
}
|
||
|
||
_ALLOWED_OUTBOUND_BY_WAREHOUSE_TYPE = {
|
||
"RAW": {"OUTSOURCING_OUT", "SCRAP_OUT", "PURCHASE_RETURN_OUT"},
|
||
"SEMI": {"WIP_OUT", "OUTSOURCING_OUT"},
|
||
"FINISHED": {"SALES_OUT"},
|
||
"AUX": {"PRODUCTION_OUT"},
|
||
"SCRAP": {"SCRAP_SALE_OUT"},
|
||
}
|
||
|
||
_LOGISTICS_REQUIRED_OUTBOUND_BIZ_TYPES = {
|
||
"OUTSOURCING_OUT",
|
||
"SCRAP_SALE_OUT",
|
||
"SALES_OUT",
|
||
}
|
||
|
||
_SELECTED_SOURCE_LOT_OUTBOUND_BIZ_TYPES = {
|
||
"OUTSOURCING_OUT",
|
||
"SCRAP_OUT",
|
||
"SCRAP_SALE_OUT",
|
||
}
|
||
|
||
_INVENTORY_BIZ_TYPE_LABELS = {
|
||
"OPENING": "期初入库",
|
||
"CUSTOMER_SUPPLIED": "客料入库",
|
||
"PRODUCTION_SURPLUS": "生产余料入库",
|
||
"OUTSOURCING_SURPLUS": "委外余料入库",
|
||
"PRODUCTION_SCRAP_IN": "生产废料入库",
|
||
"REWORK_SCRAP_IN": "返工废料入库",
|
||
"OUTSOURCING_SCRAP_IN": "委外废料入库",
|
||
"WIP_IN": "产中入库",
|
||
"OUTSOURCING_IN": "委外入库",
|
||
"PRODUCTION_FINISHED": "生产入库",
|
||
"PRODUCTION_OUT": "生产出库",
|
||
"OUTSOURCING_OUT": "委外出库",
|
||
"SCRAP_OUT": "报废出库",
|
||
"SCRAP_SALE_OUT": "售卖出库",
|
||
"WIP_OUT": "产中出库",
|
||
"SALES_OUT": "销售出库",
|
||
"PURCHASE_RETURN_OUT": "退货出库",
|
||
"SPECIAL_IN": "特殊入库",
|
||
"SPECIAL_OUT": "特殊出库",
|
||
}
|
||
|
||
|
||
def _inventory_biz_type_label(value: str | None) -> str:
|
||
normalized = str(value or "").upper()
|
||
return _INVENTORY_BIZ_TYPE_LABELS.get(normalized, "库存业务")
|
||
|
||
|
||
def _warehouse_type_label(value: str | None) -> str:
|
||
return {
|
||
"RAW": "原材料库",
|
||
"SEMI": "半成品库",
|
||
"FINISHED": "成品库",
|
||
"AUX": "辅料库",
|
||
"SCRAP": "废料库",
|
||
"RETURN": "退货库",
|
||
}.get(str(value or "").upper(), str(value or "未知仓库"))
|
||
|
||
|
||
def _validate_warehouse_item(warehouse: Warehouse, item: Item) -> None:
|
||
warehouse_type = str(warehouse.warehouse_type or "").upper()
|
||
if warehouse_type in {"RAW", "AUX"} and item.item_type != "RAW_MATERIAL":
|
||
raise HTTPException(status_code=400, detail=f"{_warehouse_type_label(warehouse_type)}只能选择原材料/辅料类物料")
|
||
if warehouse_type in {"SEMI", "FINISHED"} and item.item_type not in {"PRODUCT", "FINISHED_GOOD"}:
|
||
raise HTTPException(status_code=400, detail=f"{_warehouse_type_label(warehouse_type)}只能选择产品类物料")
|
||
if warehouse_type == "SCRAP" and item.item_type not in {"RAW_MATERIAL", "PRODUCT", "FINISHED_GOOD"}:
|
||
raise HTTPException(status_code=400, detail="废料库只能选择原材料、辅料、产品或报废品类物料")
|
||
if warehouse_type == "RETURN" and item.item_type not in {"PRODUCT", "FINISHED_GOOD"}:
|
||
raise HTTPException(status_code=400, detail="退货库只能选择客户退回且可返工的产品类物料")
|
||
|
||
|
||
def _validate_semi_product_operation(db: Session, item_id: int, source_line_id: int | None) -> ProcessRouteOperation:
|
||
if not source_line_id:
|
||
raise HTTPException(status_code=400, detail="半成品入出库必须选择半成品")
|
||
operation = db.get(ProcessRouteOperation, source_line_id)
|
||
if not operation:
|
||
raise HTTPException(status_code=404, detail="半成品不存在")
|
||
route = db.get(ProcessRoute, operation.route_id)
|
||
if not route or route.product_item_id != item_id:
|
||
raise HTTPException(status_code=400, detail="半成品与所选产品不一致")
|
||
if str(route.status or "").upper() != "ACTIVE":
|
||
raise HTTPException(status_code=400, detail="停用的产品需规不能用于半成品入出库")
|
||
if str(operation.status or "").upper() != "ACTIVE":
|
||
raise HTTPException(status_code=400, detail="停用的半成品不能用于半成品入出库")
|
||
return operation
|
||
|
||
|
||
def _semi_measure_basis_label(value: str | None) -> str:
|
||
return {"PIECE": "按件", "WEIGHT": "按重", "MIXED": "历史混合"}.get(str(value or "").upper(), "未确定")
|
||
|
||
|
||
def _semi_measure_basis_from_values(weight: Decimal, qty: Decimal, action_label: str) -> str:
|
||
if weight <= 0 and qty <= 0:
|
||
raise HTTPException(status_code=400, detail=f"半成品{action_label}必须选择按件或按重一种口径,{action_label}重量和{action_label}数量至少填写一项")
|
||
if weight > 0 and qty > 0:
|
||
raise HTTPException(status_code=400, detail=f"半成品{action_label}只能选择按件或按重一种口径")
|
||
return "PIECE" if qty > 0 else "WEIGHT"
|
||
|
||
|
||
def _derive_semi_measure_basis_from_lots(lots: list[StockLot]) -> str | None:
|
||
basis_set: set[str] = set()
|
||
for lot in lots:
|
||
has_qty = to_decimal(lot.remaining_qty) > 0
|
||
has_weight = to_decimal(lot.remaining_weight_kg) > 0
|
||
if has_qty and has_weight:
|
||
return "MIXED"
|
||
if has_qty:
|
||
basis_set.add("PIECE")
|
||
if has_weight:
|
||
basis_set.add("WEIGHT")
|
||
if len(basis_set) > 1:
|
||
return "MIXED"
|
||
return next(iter(basis_set), None)
|
||
|
||
|
||
def _get_existing_semi_measure_basis(db: Session, item_id: int, warehouse_id: int, source_line_id: int) -> str | None:
|
||
lots = db.scalars(
|
||
select(StockLot).where(
|
||
StockLot.item_id == item_id,
|
||
StockLot.warehouse_id == warehouse_id,
|
||
StockLot.source_line_id == source_line_id,
|
||
or_(StockLot.remaining_qty > 0, StockLot.remaining_weight_kg > 0),
|
||
)
|
||
).all()
|
||
return _derive_semi_measure_basis_from_lots(lots)
|
||
|
||
|
||
def _get_product_material_item_ids(db: Session, product_item_id: int) -> set[int]:
|
||
bom_ids = db.scalars(
|
||
select(Bom.id)
|
||
.where(Bom.product_item_id == product_item_id, Bom.status == "ACTIVE")
|
||
.order_by(Bom.is_default.desc(), Bom.id.desc())
|
||
).all()
|
||
if not bom_ids:
|
||
return set()
|
||
rows = db.scalars(
|
||
select(BomItem.material_item_id)
|
||
.where(BomItem.bom_id.in_(bom_ids))
|
||
.order_by(BomItem.seq_no.asc(), BomItem.id.asc())
|
||
).all()
|
||
return {int(row) for row in rows if row}
|
||
|
||
|
||
def _resolve_product_raw_source_lots(db: Session, product_item_id: int, source_lot_ids: list[int]) -> list[StockLot]:
|
||
ordered_lot_ids = [int(row) for row in source_lot_ids if int(row or 0) > 0]
|
||
if not ordered_lot_ids:
|
||
return []
|
||
if len(ordered_lot_ids) != len(set(ordered_lot_ids)):
|
||
raise HTTPException(status_code=400, detail="来源库存批次号不能重复选择")
|
||
|
||
material_item_ids = _get_product_material_item_ids(db, product_item_id)
|
||
if not material_item_ids:
|
||
raise HTTPException(status_code=400, detail="该产品未维护默认用料,不能选择来源库存批次号")
|
||
|
||
source_lots = db.scalars(select(StockLot).where(StockLot.id.in_(ordered_lot_ids))).all()
|
||
source_lot_by_id = {int(lot.id): lot for lot in source_lots}
|
||
ordered_lots: list[StockLot] = []
|
||
for source_lot_id in ordered_lot_ids:
|
||
source_lot = source_lot_by_id.get(source_lot_id)
|
||
if not source_lot:
|
||
raise HTTPException(status_code=404, detail="来源库存批次号不存在")
|
||
source_warehouse = db.get(Warehouse, source_lot.warehouse_id)
|
||
if str(source_warehouse.warehouse_type if source_warehouse else "").upper() != "RAW":
|
||
raise HTTPException(status_code=400, detail=f"来源库存批次号 {source_lot.lot_no} 不属于原材料库")
|
||
if int(source_lot.item_id) not in material_item_ids:
|
||
raise HTTPException(status_code=400, detail=f"来源库存批次号 {source_lot.lot_no} 不是该产品需用清单中的默认用料")
|
||
ordered_lots.append(source_lot)
|
||
return ordered_lots
|
||
|
||
|
||
def _source_material_lot_note(db: Session, lot: StockLot) -> str | None:
|
||
if lot.source_material_lot_id:
|
||
source_lot_no = db.scalar(select(StockLot.lot_no).where(StockLot.id == lot.source_material_lot_id))
|
||
if source_lot_no:
|
||
return str(source_lot_no)
|
||
if lot.source_material_sub_batch_no:
|
||
return str(lot.source_material_sub_batch_no)
|
||
source_summary = str(lot.source_material_summary or "")
|
||
marker = "来源库存批次号:"
|
||
for part in source_summary.split(";"):
|
||
if part.strip().startswith(marker):
|
||
return part.strip()[len(marker):].strip() or None
|
||
return lot.lot_no
|
||
|
||
|
||
@router.post("/outbound", response_model=StockLotRead)
|
||
def create_warehouse_outbound(
|
||
payload: WarehouseOutboundCreate,
|
||
context: AuthContext = Depends(require_authenticated_user),
|
||
db: Session = Depends(get_db),
|
||
) -> StockLotRead:
|
||
biz_type = (payload.biz_type or "").upper()
|
||
if biz_type not in _OUTBOUND_BIZ_MAP:
|
||
raise HTTPException(status_code=400, detail=f"未知的出库业务类型: {biz_type}")
|
||
|
||
item = db.get(Item, payload.item_id)
|
||
if not item:
|
||
raise HTTPException(status_code=404, detail="物料不存在")
|
||
|
||
warehouse = db.get(Warehouse, payload.warehouse_id)
|
||
if not warehouse:
|
||
raise HTTPException(status_code=404, detail="仓库不存在")
|
||
ensure_warehouses_unlocked(db, [payload.warehouse_id], "出库")
|
||
warehouse_type = str(warehouse.warehouse_type or "").upper()
|
||
if biz_type not in _ALLOWED_OUTBOUND_BY_WAREHOUSE_TYPE.get(warehouse_type, set()):
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail=f"{_warehouse_type_label(warehouse_type)}不允许执行该出库业务",
|
||
)
|
||
_validate_warehouse_item(warehouse, item)
|
||
logistics_waybill_no, logistics_freight_amount, logistics_photo_url = normalize_logistics_fields(
|
||
payload.waybill_no,
|
||
payload.freight_amount,
|
||
required=biz_type in _LOGISTICS_REQUIRED_OUTBOUND_BIZ_TYPES,
|
||
order_photo_url=payload.order_photo_url,
|
||
)
|
||
|
||
outbound_weight = to_decimal(payload.outbound_weight_kg)
|
||
outbound_qty = to_decimal(payload.outbound_qty)
|
||
is_semi_quantity_weight_outbound = warehouse_type == "SEMI" and biz_type in {"WIP_OUT", "OUTSOURCING_OUT"}
|
||
is_aux_quantity_outbound = warehouse_type == "AUX" and biz_type == "PRODUCTION_OUT"
|
||
semi_outbound_measure_basis: str | None = None
|
||
if outbound_weight < 0 or outbound_qty < 0:
|
||
raise HTTPException(status_code=400, detail="出库重量和出库数量不能小于0")
|
||
if is_semi_quantity_weight_outbound:
|
||
semi_outbound_measure_basis = _semi_measure_basis_from_values(outbound_weight, outbound_qty, "出库")
|
||
elif is_aux_quantity_outbound:
|
||
if outbound_qty <= 0:
|
||
raise HTTPException(status_code=400, detail="辅料生产出库数量必须大于0")
|
||
if not str(payload.remark or "").strip():
|
||
raise HTTPException(status_code=400, detail="辅料生产出库必须填写用途")
|
||
outbound_weight = Decimal("0")
|
||
else:
|
||
if outbound_weight <= 0:
|
||
raise HTTPException(status_code=400, detail="出库重量必须大于0")
|
||
outbound_qty = Decimal("0")
|
||
sale_unit_price = Decimal("0")
|
||
if biz_type == "SCRAP_SALE_OUT":
|
||
sale_unit_price = to_decimal(payload.sale_unit_price, "0.0001") if payload.sale_unit_price is not None else Decimal("0")
|
||
if sale_unit_price <= 0:
|
||
raise HTTPException(status_code=400, detail="废料售卖出库必须填写大于0的售卖单价")
|
||
|
||
txn_type, source_doc_type = _OUTBOUND_BIZ_MAP[biz_type]
|
||
now = datetime.now()
|
||
if warehouse_type == "SEMI" and biz_type in {"WIP_OUT", "OUTSOURCING_OUT"}:
|
||
_validate_semi_product_operation(db, payload.item_id, payload.target_doc_line_id)
|
||
selected_source_lines = payload.source_lots or []
|
||
deduction_plan: list[tuple[StockLot, Decimal, Decimal]] = []
|
||
|
||
if selected_source_lines:
|
||
if biz_type not in _SELECTED_SOURCE_LOT_OUTBOUND_BIZ_TYPES:
|
||
raise HTTPException(status_code=400, detail="该出库业务不支持指定来源库存批次号")
|
||
|
||
requested_weights_by_lot_id: dict[int, Decimal] = {}
|
||
for source_line in selected_source_lines:
|
||
source_lot_id = int(source_line.source_lot_id)
|
||
if source_lot_id in requested_weights_by_lot_id:
|
||
raise HTTPException(status_code=400, detail="来源库存批次号不能重复选择")
|
||
source_weight = to_decimal(source_line.outbound_weight_kg)
|
||
if source_weight <= 0:
|
||
raise HTTPException(status_code=400, detail="每个来源库存批次号的出库重量必须大于0")
|
||
requested_weights_by_lot_id[source_lot_id] = source_weight
|
||
|
||
selected_total_weight = sum(requested_weights_by_lot_id.values(), Decimal("0"))
|
||
if selected_total_weight != outbound_weight:
|
||
raise HTTPException(status_code=400, detail="出库总重量必须等于各来源库存批次号出库重量之和")
|
||
|
||
source_lots = db.scalars(
|
||
select(StockLot).where(StockLot.id.in_(requested_weights_by_lot_id.keys()))
|
||
).all()
|
||
source_lot_by_id = {lot.id: lot for lot in source_lots}
|
||
for source_lot_id, selected_weight in requested_weights_by_lot_id.items():
|
||
source_lot = source_lot_by_id.get(source_lot_id)
|
||
if not source_lot:
|
||
raise HTTPException(status_code=404, detail="来源库存批次号不存在")
|
||
if source_lot.item_id != payload.item_id or source_lot.warehouse_id != payload.warehouse_id:
|
||
raise HTTPException(status_code=400, detail=f"来源库存批次号 {source_lot.lot_no} 与本次出库物料或仓库不一致")
|
||
if payload.location_id and source_lot.location_id != payload.location_id:
|
||
raise HTTPException(status_code=400, detail=f"来源库存批次号 {source_lot.lot_no} 不属于所选库位")
|
||
if source_lot.quality_status != "PASS":
|
||
raise HTTPException(status_code=400, detail=f"来源库存批次号 {source_lot.lot_no} 未质检放行")
|
||
if source_lot.status != "AVAILABLE":
|
||
raise HTTPException(status_code=400, detail=f"来源库存批次号 {source_lot.lot_no} 当前状态不可用")
|
||
usable_weight = to_decimal(source_lot.remaining_weight_kg) - to_decimal(source_lot.locked_weight_kg)
|
||
if usable_weight < selected_weight:
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail=f"来源库存批次号 {source_lot.lot_no} 可用重量不足,无法完成本次出库",
|
||
)
|
||
deduction_plan.append((source_lot, selected_weight, Decimal("0")))
|
||
else:
|
||
lot_filters = [
|
||
StockLot.item_id == payload.item_id,
|
||
StockLot.warehouse_id == payload.warehouse_id,
|
||
StockLot.status == "AVAILABLE",
|
||
or_(StockLot.remaining_weight_kg > 0, StockLot.remaining_qty > 0) if is_semi_quantity_weight_outbound else (StockLot.remaining_qty > 0 if is_aux_quantity_outbound else StockLot.remaining_weight_kg > 0),
|
||
]
|
||
if payload.location_id:
|
||
lot_filters.append(StockLot.location_id == payload.location_id)
|
||
if warehouse_type == "SEMI" and payload.target_doc_line_id:
|
||
lot_filters.append(StockLot.source_line_id == payload.target_doc_line_id)
|
||
|
||
available_lots = db.scalars(
|
||
select(StockLot)
|
||
.where(*lot_filters)
|
||
.order_by(StockLot.created_at.asc(), StockLot.id.asc())
|
||
).all()
|
||
if not available_lots:
|
||
raise HTTPException(status_code=400, detail="该物料在该仓库无可用库存批次")
|
||
|
||
existing_measure_basis = _derive_semi_measure_basis_from_lots(available_lots) if is_semi_quantity_weight_outbound else None
|
||
if existing_measure_basis == "MIXED":
|
||
raise HTTPException(status_code=400, detail="该半成品已有历史混合库存,请先清理历史库存后再出库")
|
||
if existing_measure_basis and semi_outbound_measure_basis and existing_measure_basis != semi_outbound_measure_basis:
|
||
required_field = "出库数量" if existing_measure_basis == "PIECE" else "出库重量"
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail=f"该半成品现有库存{_semi_measure_basis_label(existing_measure_basis)}管理,请填写{required_field}",
|
||
)
|
||
|
||
total_available_weight = sum((to_decimal(lot.remaining_weight_kg) for lot in available_lots), Decimal("0"))
|
||
total_available_qty = sum((to_decimal(lot.remaining_qty) for lot in available_lots), Decimal("0"))
|
||
if outbound_weight > 0 and total_available_weight < outbound_weight:
|
||
raise HTTPException(status_code=400, detail="该物料可用重量不足,无法完成本次出库")
|
||
if (is_semi_quantity_weight_outbound or is_aux_quantity_outbound) and outbound_qty > 0 and total_available_qty < outbound_qty:
|
||
shortage_label = "该辅料" if is_aux_quantity_outbound else "该产品工序"
|
||
raise HTTPException(status_code=400, detail=f"{shortage_label}可用数量不足,无法完成本次{_inventory_biz_type_label(biz_type)}")
|
||
|
||
remaining_weight = outbound_weight
|
||
remaining_qty = outbound_qty
|
||
for available_lot in available_lots:
|
||
if remaining_weight <= 0 and remaining_qty <= 0:
|
||
break
|
||
available_weight = to_decimal(available_lot.remaining_weight_kg)
|
||
available_qty = to_decimal(available_lot.remaining_qty)
|
||
if is_aux_quantity_outbound:
|
||
actual_qty = min(remaining_qty, available_qty)
|
||
actual_deduction = Decimal("0")
|
||
elif is_semi_quantity_weight_outbound and outbound_qty > 0:
|
||
actual_qty = min(remaining_qty, available_qty)
|
||
if actual_qty <= 0:
|
||
continue
|
||
if outbound_weight > 0:
|
||
actual_deduction = remaining_weight if remaining_qty == actual_qty else (outbound_weight * actual_qty / outbound_qty)
|
||
actual_deduction = min(actual_deduction, available_weight, remaining_weight)
|
||
else:
|
||
ratio = actual_qty / available_qty if available_qty > 0 else Decimal("0")
|
||
actual_deduction = min(available_weight * ratio, available_weight)
|
||
else:
|
||
actual_deduction = min(remaining_weight, available_weight)
|
||
if is_semi_quantity_weight_outbound:
|
||
ratio = actual_deduction / available_weight if available_weight > 0 else Decimal("0")
|
||
actual_qty = min(available_qty * ratio, available_qty)
|
||
else:
|
||
actual_qty = Decimal("0")
|
||
if actual_deduction <= 0 and actual_qty <= 0:
|
||
continue
|
||
deduction_plan.append((available_lot, actual_deduction, actual_qty))
|
||
remaining_weight -= actual_deduction
|
||
remaining_qty -= actual_qty
|
||
|
||
first_lot_id: int | None = None
|
||
first_inventory_txn_id: int | None = None
|
||
for available_lot, actual_deduction, actual_qty in deduction_plan:
|
||
available_weight = to_decimal(available_lot.remaining_weight_kg)
|
||
available_qty = to_decimal(available_lot.remaining_qty)
|
||
unit_cost = to_decimal(available_lot.unit_cost, "0.0001")
|
||
txn_unit_cost = sale_unit_price if biz_type == "SCRAP_SALE_OUT" else unit_cost
|
||
available_lot.remaining_weight_kg = available_weight - actual_deduction
|
||
available_lot.remaining_qty = available_qty - actual_qty if (is_semi_quantity_weight_outbound or is_aux_quantity_outbound) else Decimal("0")
|
||
if available_lot.remaining_weight_kg <= 0 and available_lot.remaining_qty <= 0:
|
||
available_lot.status = "DEPLETED"
|
||
db.add(available_lot)
|
||
first_lot_id = first_lot_id or available_lot.id
|
||
|
||
upsert_stock_balance(
|
||
db,
|
||
item_id=payload.item_id,
|
||
warehouse_id=payload.warehouse_id,
|
||
location_id=available_lot.location_id,
|
||
qty_delta=-actual_qty,
|
||
weight_delta=-actual_deduction,
|
||
available_qty_delta=-actual_qty,
|
||
available_weight_delta=-actual_deduction,
|
||
unit_cost=unit_cost,
|
||
)
|
||
|
||
if is_aux_quantity_outbound:
|
||
remark = f"用途:{str(payload.remark or '').strip()}"
|
||
else:
|
||
remark = payload.remark or _inventory_biz_type_label(biz_type)
|
||
if biz_type == "SCRAP_SALE_OUT":
|
||
sale_parts = []
|
||
if payload.buyer_name:
|
||
sale_parts.append(f"购买方 {payload.buyer_name}")
|
||
if payload.sale_remark:
|
||
sale_parts.append(payload.sale_remark)
|
||
if sale_parts:
|
||
remark = f"{remark};{';'.join(sale_parts)}"
|
||
if biz_type == "OUTSOURCING_OUT" and payload.outsourcing_party_name:
|
||
remark = f"{remark};委外方:{payload.outsourcing_party_name}"
|
||
if biz_type == "SCRAP_SALE_OUT":
|
||
source_lot_note = _source_material_lot_note(db, available_lot)
|
||
else:
|
||
source_lot_note = available_lot.lot_no if (selected_source_lines or is_semi_quantity_weight_outbound) else payload.target_material_sub_batch_no
|
||
if source_lot_note:
|
||
remark = f"{remark};来源库存批次号 {source_lot_note}"
|
||
outbound_txn = create_inventory_txn(
|
||
db,
|
||
txn_type=txn_type,
|
||
item_id=payload.item_id,
|
||
warehouse_id=payload.warehouse_id,
|
||
location_id=available_lot.location_id,
|
||
lot_id=available_lot.id,
|
||
qty_change=-actual_qty,
|
||
weight_change=-actual_deduction,
|
||
unit_cost=txn_unit_cost,
|
||
source_doc_type=source_doc_type,
|
||
source_doc_id=payload.target_doc_id or 0,
|
||
source_line_id=payload.target_doc_line_id,
|
||
biz_time=now,
|
||
operator_user_id=context.user.id,
|
||
remark=remark,
|
||
amount_basis="QTY" if is_aux_quantity_outbound else "WEIGHT",
|
||
logistics_waybill_no=logistics_waybill_no,
|
||
logistics_freight_amount=logistics_freight_amount,
|
||
logistics_photo_url=logistics_photo_url,
|
||
)
|
||
first_inventory_txn_id = first_inventory_txn_id or int(outbound_txn.id)
|
||
db.commit()
|
||
|
||
archive_fields = _generate_inventory_archive_fields(
|
||
db,
|
||
document_type=DOCUMENT_TYPE_WAREHOUSE_OPERATION,
|
||
business_id=first_inventory_txn_id,
|
||
created_by=context.user.id,
|
||
)
|
||
row = db.execute(get_stock_lots_query(limit=1).where(StockLot.id == first_lot_id)).mappings().first()
|
||
if not row:
|
||
raise HTTPException(status_code=500, detail="出库后读取失败")
|
||
return _stock_lot_read_with_archive(dict(row), **archive_fields)
|
||
|
||
|
||
@router.post("/raw-material-return-outbound", response_model=PurchaseReturnRead)
|
||
def create_raw_material_return_outbound(
|
||
payload: RawMaterialReturnOutboundCreate,
|
||
context: AuthContext = Depends(require_authenticated_user),
|
||
db: Session = Depends(get_db),
|
||
) -> PurchaseReturnRead:
|
||
if not payload.items:
|
||
raise HTTPException(status_code=400, detail="退货出库明细不能为空")
|
||
|
||
warehouse = db.get(Warehouse, payload.warehouse_id)
|
||
if not warehouse:
|
||
raise HTTPException(status_code=404, detail="仓库不存在")
|
||
if str(warehouse.warehouse_type or "").upper() != "RAW":
|
||
raise HTTPException(status_code=400, detail="退货出库只能从原材料库发起")
|
||
ensure_warehouses_unlocked(db, [payload.warehouse_id], "退货出库")
|
||
|
||
logistics_waybill_no, logistics_freight_amount, logistics_photo_url = normalize_logistics_fields(
|
||
payload.waybill_no,
|
||
payload.freight_amount,
|
||
required=True,
|
||
freight_required=False,
|
||
order_photo_url=payload.order_photo_url,
|
||
)
|
||
|
||
seen_lot_ids: set[int] = set()
|
||
purchase_order_id: int | None = None
|
||
prepared_lines: list[dict[str, object]] = []
|
||
for line_no, item_payload in enumerate(payload.items, start=1):
|
||
if item_payload.lot_id in seen_lot_ids:
|
||
raise HTTPException(status_code=400, detail="同一张退货出库单不能重复选择同一库存批次")
|
||
seen_lot_ids.add(item_payload.lot_id)
|
||
|
||
return_weight = to_decimal(item_payload.return_weight_kg)
|
||
if return_weight <= 0:
|
||
raise HTTPException(status_code=400, detail="退货重量必须大于0")
|
||
|
||
lot = db.get(StockLot, item_payload.lot_id)
|
||
if not lot:
|
||
raise HTTPException(status_code=404, detail="库存批次不存在")
|
||
if lot.warehouse_id != payload.warehouse_id:
|
||
raise HTTPException(status_code=400, detail="库存批次不属于当前原材料库")
|
||
if lot.status != "AVAILABLE" or lot.quality_status != "PASS":
|
||
raise HTTPException(status_code=400, detail="只能选择可用且质检合格的原材料批次退货")
|
||
|
||
purchase_order_item = db.get(PurchaseOrderItem, item_payload.purchase_order_item_id)
|
||
if not purchase_order_item:
|
||
raise HTTPException(status_code=404, detail="采购订单明细不存在")
|
||
if purchase_order_item.material_item_id != lot.item_id:
|
||
raise HTTPException(status_code=400, detail="采购订单明细物料与库存批次物料不一致")
|
||
if purchase_order_id is None:
|
||
purchase_order_id = purchase_order_item.purchase_order_id
|
||
elif purchase_order_id != purchase_order_item.purchase_order_id:
|
||
raise HTTPException(status_code=400, detail="一张退货出库单只能对应一个采购订单,请分开登记")
|
||
|
||
usable_lot_weight = to_decimal(lot.remaining_weight_kg) - to_decimal(lot.locked_weight_kg)
|
||
if return_weight > usable_lot_weight:
|
||
raise HTTPException(status_code=400, detail="退货重量不能超过批次可用重量")
|
||
|
||
matched_link = next(
|
||
(
|
||
link
|
||
for link in get_stock_lot_purchase_links(db, lot.id)
|
||
if int(link["purchase_order_item_id"]) == int(item_payload.purchase_order_item_id)
|
||
),
|
||
None,
|
||
)
|
||
if not matched_link:
|
||
raise HTTPException(status_code=400, detail="该库存批次未关联所选采购订单明细")
|
||
if return_weight > to_decimal(matched_link["returnable_weight_kg"]):
|
||
raise HTTPException(status_code=400, detail="退货重量不能超过采购入库可退重量")
|
||
|
||
prepared_lines.append(
|
||
{
|
||
"line_no": line_no,
|
||
"payload": item_payload,
|
||
"lot": lot,
|
||
"lot_id": lot.id,
|
||
"purchase_order_item": purchase_order_item,
|
||
"purchase_order_item_id": purchase_order_item.id,
|
||
"return_weight": return_weight,
|
||
"unit_cost": to_decimal(matched_link["unit_cost"], "0.0001"),
|
||
}
|
||
)
|
||
|
||
if purchase_order_id is None:
|
||
raise HTTPException(status_code=400, detail="退货出库明细不能为空")
|
||
|
||
_lock_purchase_order_items_for_return(
|
||
db,
|
||
{int(prepared["purchase_order_item_id"]) for prepared in prepared_lines},
|
||
)
|
||
_recheck_raw_material_return_purchase_line_availability(db, prepared_lines)
|
||
|
||
now = datetime.now()
|
||
purchase_return = PurchaseReturn(
|
||
return_no=_build_purchase_return_no(db),
|
||
purchase_order_id=purchase_order_id,
|
||
warehouse_id=payload.warehouse_id,
|
||
return_date=payload.return_date or now,
|
||
operator_user_id=context.user.id,
|
||
logistics_waybill_no=logistics_waybill_no,
|
||
logistics_freight_amount=logistics_freight_amount,
|
||
logistics_photo_url=logistics_photo_url,
|
||
reason=payload.reason,
|
||
status="CONFIRMED",
|
||
remark=payload.remark,
|
||
created_at=now,
|
||
updated_at=now,
|
||
)
|
||
db.add(purchase_return)
|
||
db.flush()
|
||
|
||
first_inventory_txn_id: int | None = None
|
||
for prepared in prepared_lines:
|
||
item_payload = prepared["payload"]
|
||
lot = prepared["lot"]
|
||
purchase_order_item = prepared["purchase_order_item"]
|
||
return_weight = prepared["return_weight"]
|
||
unit_cost = prepared["unit_cost"]
|
||
amount = to_decimal(return_weight * unit_cost, "0.01")
|
||
|
||
purchase_return_item = PurchaseReturnItem(
|
||
purchase_return_id=purchase_return.id,
|
||
line_no=prepared["line_no"],
|
||
purchase_order_item_id=purchase_order_item.id,
|
||
material_item_id=lot.item_id,
|
||
lot_id=lot.id,
|
||
return_weight_kg=return_weight,
|
||
unit_cost=unit_cost,
|
||
amount=amount,
|
||
reason=item_payload.reason,
|
||
status="CONFIRMED",
|
||
remark=item_payload.remark,
|
||
created_at=now,
|
||
updated_at=now,
|
||
)
|
||
db.add(purchase_return_item)
|
||
db.flush()
|
||
|
||
deduction_result = db.execute(
|
||
update(StockLot)
|
||
.where(
|
||
StockLot.id == lot.id,
|
||
StockLot.remaining_weight_kg - StockLot.locked_weight_kg >= return_weight,
|
||
StockLot.status == "AVAILABLE",
|
||
StockLot.quality_status == "PASS",
|
||
)
|
||
.values(
|
||
remaining_weight_kg=StockLot.remaining_weight_kg - return_weight,
|
||
remaining_qty=Decimal("0"),
|
||
status=case(
|
||
(StockLot.remaining_weight_kg - return_weight <= 0, "DEPLETED"),
|
||
else_="AVAILABLE",
|
||
),
|
||
updated_at=now,
|
||
)
|
||
.execution_options(synchronize_session=False)
|
||
)
|
||
if deduction_result.rowcount != 1:
|
||
db.rollback()
|
||
raise HTTPException(status_code=400, detail="退货重量不能超过批次可用重量")
|
||
db.refresh(lot)
|
||
|
||
upsert_stock_balance(
|
||
db,
|
||
item_id=lot.item_id,
|
||
warehouse_id=payload.warehouse_id,
|
||
location_id=lot.location_id,
|
||
qty_delta=Decimal("0"),
|
||
weight_delta=-return_weight,
|
||
available_qty_delta=Decimal("0"),
|
||
available_weight_delta=-return_weight,
|
||
unit_cost=unit_cost,
|
||
)
|
||
|
||
txn = create_inventory_txn(
|
||
db,
|
||
txn_type="PURCHASE_RETURN_OUT",
|
||
item_id=lot.item_id,
|
||
warehouse_id=payload.warehouse_id,
|
||
location_id=lot.location_id,
|
||
lot_id=lot.id,
|
||
qty_change=Decimal("0"),
|
||
weight_change=-return_weight,
|
||
unit_cost=unit_cost,
|
||
source_doc_type="PURCHASE_RETURN",
|
||
source_doc_id=purchase_return.id,
|
||
source_line_id=purchase_return_item.id,
|
||
biz_time=purchase_return.return_date,
|
||
operator_user_id=context.user.id,
|
||
remark=item_payload.remark or item_payload.reason or payload.remark,
|
||
amount_basis="WEIGHT",
|
||
logistics_waybill_no=logistics_waybill_no,
|
||
logistics_freight_amount=logistics_freight_amount,
|
||
logistics_photo_url=logistics_photo_url,
|
||
)
|
||
if first_inventory_txn_id is None:
|
||
first_inventory_txn_id = int(txn.id)
|
||
|
||
db.commit()
|
||
archive_fields = _generate_inventory_archive_fields(
|
||
db,
|
||
document_type=DOCUMENT_TYPE_WAREHOUSE_OPERATION,
|
||
business_id=first_inventory_txn_id,
|
||
created_by=context.user.id,
|
||
)
|
||
result = _purchase_return_read(db, purchase_return.id)
|
||
result.archive_status = archive_fields["archive_status"]
|
||
result.archive_business_id = archive_fields["archive_business_id"]
|
||
result.archive_document_type = archive_fields["archive_document_type"]
|
||
result.archive_error_message = archive_fields["archive_error_message"]
|
||
return result
|