from __future__ import annotations from io import BytesIO from datetime import date, datetime from decimal import Decimal from fastapi import HTTPException from openpyxl import Workbook, load_workbook from sqlalchemy import func, select from sqlalchemy.orm import Session from app.models.master_data import Item, Warehouse from app.models.operations import StockLot, Stocktake, StocktakeAdjustment, StocktakeLine, StocktakeWarehouse, WarehouseLocation from app.services.operations import create_inventory_txn, to_decimal, upsert_stock_balance ACTIVE_STOCKTAKE_STATUSES = {"LOCKED", "EXPORTED", "IMPORTED"} STOCKTAKE_EXCEL_HEADERS = [ "盘库单号", "盘库校验码", "仓库ID", "快照行ID", "仓库", "库位", "物料编码", "物料名称", "批次号", "来源库存批次号", "系统数量", "系统重量(kg)", "系统单价", "盘点数量", "盘点重量(kg)", "盘点备注", ] STOCKTAKE_QUANTITY_HEADERS = {"系统数量", "盘点数量"} EMPTY_STOCKTAKE_MARKER_REMARK = "空库盘点校验行,请保留此行用于校验盘库单号" def build_stocktake_no(db: Session) -> str: date_part = date.today().strftime("%Y%m%d") prefix = f"PK-{date_part}" count = db.scalar(select(func.count(Stocktake.id)).where(Stocktake.stocktake_no.like(f"{prefix}-%"))) or 0 return f"{prefix}-{count + 1:03d}" def stocktake_warehouse_type_label(value: str | None) -> str: return { "RAW": "原材料库", "SEMI": "半成品库", "FINISHED": "成品库", "AUX": "辅料库", "SCRAP": "废料库", "RETURN": "退货库", }.get(str(value or "").upper(), str(value or "未知仓库")) def stocktake_validation_token(stocktake_no: str, warehouse_id: int, warehouse_type: str | None) -> str: return f"{stocktake_no}|{int(warehouse_id)}|{str(warehouse_type or '').upper()}" def stocktake_weight_only(warehouse_type: str | None, item_type: str | None) -> bool: _ = item_type return str(warehouse_type or "").upper() in {"RAW", "SCRAP"} def stocktake_excel_headers_for_type(warehouse_type: str | None) -> list[str]: if stocktake_weight_only(warehouse_type, None): return [header for header in STOCKTAKE_EXCEL_HEADERS if header not in STOCKTAKE_QUANTITY_HEADERS] return list(STOCKTAKE_EXCEL_HEADERS) def stocktake_excel_row_for_headers(payload: dict[str, object], headers: list[str]) -> list[object]: return [payload.get(header, "") for header in headers] def get_locked_warehouse_ids(db: Session, warehouse_ids: list[int] | set[int]) -> set[int]: ids = {int(item) for item in warehouse_ids if item} if not ids: return set() rows = db.scalars( select(StocktakeWarehouse.warehouse_id) .join(Stocktake, Stocktake.id == StocktakeWarehouse.stocktake_id) .where( StocktakeWarehouse.warehouse_id.in_(ids), StocktakeWarehouse.status == "LOCKED", Stocktake.status.in_(ACTIVE_STOCKTAKE_STATUSES), ) ).all() return {int(row) for row in rows} def ensure_warehouses_unlocked(db: Session, warehouse_ids: list[int] | set[int], action_label: str) -> None: locked_ids = get_locked_warehouse_ids(db, warehouse_ids) if not locked_ids: return rows = db.scalars(select(Warehouse).where(Warehouse.id.in_(locked_ids)).order_by(Warehouse.id.asc())).all() labels = "、".join(row.warehouse_name for row in rows) raise HTTPException(status_code=423, detail=f"{labels}正在盘库,暂不能执行{action_label}") def decimal_value(value: object, places: str = "0.000001") -> Decimal: return Decimal(str(value or 0)).quantize(Decimal(places)) def current_time() -> datetime: return datetime.now() def start_stocktake(db: Session, *, warehouse_ids: list[int], user_id: int | None, remark: str | None = None) -> Stocktake: ids = [int(item) for item in warehouse_ids if item] if not ids: raise HTTPException(status_code=400, detail="请选择需要盘库的仓库") if len(set(ids)) != 1: raise HTTPException(status_code=400, detail="一次只能盘一个仓库,请先在仓库栏选中单个仓库") ensure_warehouses_unlocked(db, ids, "开始盘库") warehouses = db.scalars(select(Warehouse).where(Warehouse.id.in_(ids)).order_by(Warehouse.id.asc())).all() if len(warehouses) != len(set(ids)): raise HTTPException(status_code=404, detail="存在无效仓库,无法开始盘库") now = current_time() stocktake = Stocktake( stocktake_no=build_stocktake_no(db), status="LOCKED", started_by_user_id=user_id, started_at=now, remark=remark, ) db.add(stocktake) db.flush() warehouses_by_id = {warehouse.id: warehouse for warehouse in warehouses} stocktake_warehouses_by_id: dict[int, StocktakeWarehouse] = {} for warehouse in warehouses: row = StocktakeWarehouse( stocktake_id=stocktake.id, warehouse_id=warehouse.id, warehouse_type=str(warehouse.warehouse_type or "").upper(), warehouse_name=warehouse.warehouse_name, status="LOCKED", ) db.add(row) db.flush() stocktake_warehouses_by_id[warehouse.id] = row lot_rows = db.execute( select(StockLot, Item) .join(Item, Item.id == StockLot.item_id) .where( StockLot.warehouse_id.in_(ids), StockLot.status == "AVAILABLE", StockLot.remaining_weight_kg > 0, ) .order_by(StockLot.warehouse_id.asc(), Item.item_code.asc(), StockLot.lot_no.asc(), StockLot.id.asc()) ).all() line_no = 1 for lot, item in lot_rows: warehouse = warehouses_by_id.get(lot.warehouse_id) if not warehouse: continue weight_only = stocktake_weight_only(warehouse.warehouse_type, item.item_type) location_name = None if lot.location_id: location_name = db.scalar(select(WarehouseLocation.location_name).where(WarehouseLocation.id == lot.location_id)) db.add( StocktakeLine( stocktake_id=stocktake.id, stocktake_warehouse_id=stocktake_warehouses_by_id[warehouse.id].id, line_no=line_no, warehouse_id=warehouse.id, warehouse_name=warehouse.warehouse_name, warehouse_type=str(warehouse.warehouse_type or "").upper(), location_id=lot.location_id, location_name=location_name, item_id=item.id, item_code=item.item_code, item_name=item.item_name, item_type=item.item_type, lot_id=lot.id, lot_no=lot.lot_no, source_material_sub_batch_no=lot.source_material_sub_batch_no or lot.material_sub_batch_no, snapshot_qty=Decimal("0") if weight_only else to_decimal(lot.remaining_qty), snapshot_weight_kg=to_decimal(lot.remaining_weight_kg), snapshot_unit_cost=to_decimal(lot.unit_cost, "0.0001"), counted_qty=None, counted_weight_kg=None, diff_qty=Decimal("0"), diff_weight_kg=Decimal("0"), diff_amount=Decimal("0"), diff_type="MATCH", row_status="SNAPSHOT", remark=None, ) ) line_no += 1 db.commit() db.refresh(stocktake) return stocktake def _stocktake_lines(db: Session, stocktake_id: int) -> list[StocktakeLine]: return db.scalars( select(StocktakeLine) .where(StocktakeLine.stocktake_id == stocktake_id) .order_by(StocktakeLine.warehouse_id.asc(), StocktakeLine.line_no.asc()) ).all() def _single_stocktake_warehouse(db: Session, stocktake_id: int) -> StocktakeWarehouse: rows = db.scalars( select(StocktakeWarehouse) .where(StocktakeWarehouse.stocktake_id == stocktake_id) .order_by(StocktakeWarehouse.id.asc()) ).all() if len(rows) != 1: raise HTTPException(status_code=400, detail="当前盘库单不是单仓库盘库,无法继续处理") return rows[0] def build_stocktake_workbook(db: Session, stocktake_id: int) -> Workbook: stocktake = db.get(Stocktake, stocktake_id) if not stocktake: raise HTTPException(status_code=404, detail="盘库单不存在") lines = _stocktake_lines(db, stocktake_id) stocktake_warehouses = db.scalars( select(StocktakeWarehouse).where(StocktakeWarehouse.stocktake_id == stocktake_id).order_by(StocktakeWarehouse.id.asc()) ).all() workbook = Workbook() default_sheet = workbook.active workbook.remove(default_sheet) grouped: dict[int, list[StocktakeLine]] = {} for line in lines: grouped.setdefault(int(line.warehouse_id), []).append(line) for warehouse in stocktake_warehouses: grouped.setdefault(int(warehouse.warehouse_id), []) warehouses_by_id = {int(warehouse.warehouse_id): warehouse for warehouse in stocktake_warehouses} for warehouse_id, sheet_lines in grouped.items(): warehouse = warehouses_by_id.get(int(warehouse_id)) if not warehouse: continue sheet_name = stocktake_warehouse_type_label(warehouse.warehouse_type) worksheet = workbook.create_sheet(title=sheet_name[:31]) worksheet.freeze_panes = "A2" headers = stocktake_excel_headers_for_type(warehouse.warehouse_type) worksheet.append(headers) if not sheet_lines: token = stocktake_validation_token(stocktake.stocktake_no, warehouse.warehouse_id, warehouse.warehouse_type) worksheet.append( stocktake_excel_row_for_headers( { "盘库单号": stocktake.stocktake_no, "盘库校验码": token, "仓库ID": warehouse.warehouse_id, "快照行ID": "", "仓库": sheet_name, "库位": "", "物料编码": "", "物料名称": "", "批次号": "", "来源库存批次号": "", "系统数量": 0, "系统重量(kg)": 0, "系统单价": 0, "盘点数量": 0, "盘点重量(kg)": 0, "盘点备注": EMPTY_STOCKTAKE_MARKER_REMARK, }, headers, ) ) for line in sheet_lines: worksheet.append( stocktake_excel_row_for_headers( { "盘库单号": stocktake.stocktake_no, "盘库校验码": stocktake_validation_token(stocktake.stocktake_no, line.warehouse_id, line.warehouse_type), "仓库ID": line.warehouse_id, "快照行ID": line.id, "仓库": line.warehouse_name, "库位": line.location_name or "", "物料编码": line.item_code, "物料名称": line.item_name, "批次号": line.lot_no or "", "来源库存批次号": line.source_material_sub_batch_no or "", "系统数量": float(line.snapshot_qty or 0), "系统重量(kg)": float(line.snapshot_weight_kg or 0), "系统单价": float(line.snapshot_unit_cost or 0), "盘点数量": float(line.snapshot_qty or 0), "盘点重量(kg)": float(line.snapshot_weight_kg or 0), "盘点备注": "", }, headers, ) ) widths_by_header = { "盘库单号": 18, "盘库校验码": 30, "仓库ID": 10, "快照行ID": 12, "仓库": 14, "库位": 14, "物料编码": 18, "物料名称": 28, "批次号": 22, "来源库存批次号": 24, "系统数量": 14, "系统重量(kg)": 16, "系统单价": 12, "盘点数量": 14, "盘点重量(kg)": 16, "盘点备注": 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 stocktake.exported_at = current_time() stocktake.status = "EXPORTED" db.add(stocktake) db.commit() return workbook def _clean_text(value: object) -> str: return str(value or "").strip() def _read_workbook_rows(content: bytes, warehouse_type: str | None = None) -> 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 = [_clean_text(item) for item in values[0]] required_headers = stocktake_excel_headers_for_type(warehouse_type) missing = [header for header in required_headers if header not in headers] if missing: raise ValueError(f"{worksheet.title} 缺少必要列:{'、'.join(missing)}") for row in values[1:]: if not any(_clean_text(item) for item in row): continue payload = {headers[index]: value for index, value in enumerate(row) if index < len(headers)} payload["_sheet_name"] = worksheet.title rows.append(payload) return rows def _is_empty_stocktake_marker_row(row: dict[str, object]) -> bool: remark = _clean_text(row.get("盘点备注")) if "空库" not in remark: return False if _clean_text(row.get("快照行ID")): return False if any(_clean_text(row.get(label)) for label in ["物料编码", "物料名称", "批次号", "来源库存批次号"]): return False return all( decimal_value(row.get(label)) == 0 for label in ["系统数量", "系统重量(kg)", "系统单价", "盘点数量", "盘点重量(kg)"] ) def _validate_stocktake_row_marker(row: dict[str, object], stocktake: Stocktake, warehouse: StocktakeWarehouse) -> None: row_stocktake_no = _clean_text(row.get("盘库单号")) if row_stocktake_no != stocktake.stocktake_no: raise ValueError(f"存在不属于当前盘库单的行:{row_stocktake_no or '空盘库单号'}") row_warehouse_id = _clean_text(row.get("仓库ID")) if row_warehouse_id and int(float(row_warehouse_id)) != int(warehouse.warehouse_id): raise ValueError("导入清单不属于当前仓库,请确认仓库栏选中项和盘库清单一致") row_warehouse_name = _clean_text(row.get("仓库")) if row_warehouse_name and row_warehouse_name != warehouse.warehouse_name: raise ValueError("导入清单不属于当前仓库,请确认仓库栏选中项和盘库清单一致") expected_token = stocktake_validation_token(stocktake.stocktake_no, warehouse.warehouse_id, warehouse.warehouse_type) row_token = _clean_text(row.get("盘库校验码")) if row_token != expected_token: raise ValueError("盘库校验码不匹配,请导入本次盘库导出的仓库清单") def _reset_stocktake_preview_for_reimport(db: Session, stocktake_id: int, warehouse_id: int) -> dict[int, StocktakeLine]: lines = db.scalars( select(StocktakeLine) .where(StocktakeLine.stocktake_id == stocktake_id, StocktakeLine.warehouse_id == warehouse_id) .order_by(StocktakeLine.line_no.asc()) ).all() existing_lines: dict[int, StocktakeLine] = {} for line in lines: if line.lot_id is None and line.diff_type == "NEW_LOT": db.delete(line) continue line.counted_qty = None line.counted_weight_kg = None line.diff_qty = Decimal("0") line.diff_weight_kg = Decimal("0") line.diff_amount = Decimal("0") line.diff_type = "MATCH" line.row_status = "SNAPSHOT" line.error_message = None line.remark = None db.add(line) existing_lines[line.id] = line db.flush() return existing_lines def _line_diff_type(qty_diff: Decimal, weight_diff: Decimal, *, is_new: bool = False, is_missing: bool = False) -> str: if is_new: return "NEW_LOT" if is_missing: return "MISSING" basis = weight_diff if weight_diff != 0 else qty_diff if basis > 0: return "GAIN" if basis < 0: return "LOSS" return "MATCH" def _stocktake_summary(lines: list[StocktakeLine]) -> dict[str, int | float]: summary = { "match_count": 0, "gain_count": 0, "loss_count": 0, "new_lot_count": 0, "missing_count": 0, "error_count": 0, "diff_amount": 0.0, } for line in lines: key = { "MATCH": "match_count", "GAIN": "gain_count", "LOSS": "loss_count", "NEW_LOT": "new_lot_count", "MISSING": "missing_count", }.get(line.diff_type) if key: summary[key] += 1 if line.row_status == "ERROR": summary["error_count"] += 1 summary["diff_amount"] += float(line.diff_amount or 0) return summary def import_stocktake_workbook(db: Session, stocktake_id: int, content: bytes, user_id: int | None) -> dict[str, object]: stocktake = db.get(Stocktake, stocktake_id) if not stocktake: raise HTTPException(status_code=404, detail="盘库单不存在") if stocktake.status not in {"LOCKED", "EXPORTED", "IMPORTED"}: raise HTTPException(status_code=400, detail="当前盘库单状态不允许导入") stocktake_warehouse = _single_stocktake_warehouse(db, stocktake_id) rows = _read_workbook_rows(content, stocktake_warehouse.warehouse_type) existing_lines = _reset_stocktake_preview_for_reimport(db, stocktake_id, stocktake_warehouse.warehouse_id) seen_line_ids: set[int] = set() next_line_no = max((line.line_no for line in existing_lines.values()), default=0) + 1 for row in rows: _validate_stocktake_row_marker(row, stocktake, stocktake_warehouse) if _is_empty_stocktake_marker_row(row): continue line_id_text = _clean_text(row.get("快照行ID")) counted_qty = decimal_value(row.get("盘点数量")) counted_weight = decimal_value(row.get("盘点重量(kg)")) remark = _clean_text(row.get("盘点备注")) or None if line_id_text: line_id = int(float(line_id_text)) line = existing_lines.get(line_id) if not line: raise ValueError(f"快照行ID {line_id} 不存在于当前盘库单") line.error_message = None if line_id in seen_line_ids: line.error_message = "导入清单中该快照行重复" seen_line_ids.add(line_id) snapshot_qty = decimal_value(line.snapshot_qty) snapshot_weight = decimal_value(line.snapshot_weight_kg) weight_only = stocktake_weight_only(line.warehouse_type, line.item_type) qty_diff = Decimal("0") if weight_only else counted_qty - snapshot_qty weight_diff = counted_weight - snapshot_weight line.counted_qty = Decimal("0") if weight_only else counted_qty line.counted_weight_kg = counted_weight line.diff_qty = qty_diff line.diff_weight_kg = weight_diff line.diff_amount = (weight_diff * decimal_value(line.snapshot_unit_cost, "0.0001")).quantize(Decimal("0.01")) line.diff_type = _line_diff_type(qty_diff, weight_diff) line.row_status = "ERROR" if line.error_message else "READY" line.remark = remark db.add(line) continue item_code = _clean_text(row.get("物料编码")) item = db.scalar(select(Item).where(Item.item_code == item_code).limit(1)) warehouse = db.get(Warehouse, stocktake_warehouse.warehouse_id) if not item or not warehouse: raise ValueError(f"新增批次行物料或仓库不存在:{stocktake_warehouse.warehouse_name} / {item_code}") unit_cost = decimal_value(row.get("系统单价"), "0.0001") weight_only = stocktake_weight_only(warehouse.warehouse_type, item.item_type) db.add( StocktakeLine( stocktake_id=stocktake.id, stocktake_warehouse_id=stocktake_warehouse.id, line_no=next_line_no, warehouse_id=warehouse.id, warehouse_name=warehouse.warehouse_name, warehouse_type=str(warehouse.warehouse_type or "").upper(), location_id=None, location_name=_clean_text(row.get("库位")) or None, item_id=item.id, item_code=item.item_code, item_name=item.item_name, item_type=item.item_type, lot_id=None, lot_no=_clean_text(row.get("批次号")) or None, source_material_sub_batch_no=_clean_text(row.get("来源库存批次号")) or None, snapshot_qty=Decimal("0"), snapshot_weight_kg=Decimal("0"), snapshot_unit_cost=unit_cost, counted_qty=Decimal("0") if weight_only else counted_qty, counted_weight_kg=counted_weight, diff_qty=Decimal("0") if weight_only else counted_qty, diff_weight_kg=counted_weight, diff_amount=(counted_weight * unit_cost).quantize(Decimal("0.01")), diff_type="NEW_LOT", row_status="READY", remark=remark, ) ) next_line_no += 1 for line_id, line in existing_lines.items(): if line_id in seen_line_ids: continue line.counted_qty = Decimal("0") line.counted_weight_kg = Decimal("0") line.diff_qty = -decimal_value(line.snapshot_qty) line.diff_weight_kg = -decimal_value(line.snapshot_weight_kg) line.diff_amount = (decimal_value(line.diff_weight_kg) * decimal_value(line.snapshot_unit_cost, "0.0001")).quantize(Decimal("0.01")) line.diff_type = "MISSING" line.row_status = "READY" line.error_message = None line.remark = "导入清单缺失,按盘点为0处理" db.add(line) stocktake.status = "IMPORTED" stocktake.imported_by_user_id = user_id stocktake.imported_at = current_time() db.add(stocktake) db.commit() lines = db.scalars( select(StocktakeLine) .where(StocktakeLine.stocktake_id == stocktake_id, StocktakeLine.warehouse_id == stocktake_warehouse.warehouse_id) .order_by(StocktakeLine.line_no.asc()) ).all() return {"stocktake": stocktake, "lines": lines, "summary": _stocktake_summary(lines)} def _stocktake_txn_type(diff_type: str) -> str: return { "GAIN": "STOCKTAKE_GAIN", "LOSS": "STOCKTAKE_LOSS", "MISSING": "STOCKTAKE_LOSS", "NEW_LOT": "STOCKTAKE_NEW_LOT", }.get(diff_type, "STOCKTAKE_GAIN") def confirm_stocktake( db: Session, stocktake_id: int, *, user_id: int | None, confirm_text: str | None = None, remark: str | None = None, ) -> Stocktake: _ = confirm_text stocktake = db.get(Stocktake, stocktake_id) if not stocktake: raise HTTPException(status_code=404, detail="盘库单不存在") lines = db.scalars(select(StocktakeLine).where(StocktakeLine.stocktake_id == stocktake_id).order_by(StocktakeLine.line_no.asc())).all() now = current_time() if stocktake.status != "IMPORTED": if stocktake.status in {"LOCKED", "EXPORTED"} and not lines: stocktake.status = "IMPORTED" stocktake.imported_by_user_id = user_id stocktake.imported_at = now db.add(stocktake) db.flush() else: raise HTTPException(status_code=400, detail="请先导入盘点清单并确认差异后再过账") error_lines = [line for line in lines if line.row_status == "ERROR"] if error_lines: raise HTTPException(status_code=400, detail=f"存在 {len(error_lines)} 行异常,不能确认盘库") for line in lines: if line.diff_type == "MATCH": continue weight_only = stocktake_weight_only(line.warehouse_type, line.item_type) unit_cost = decimal_value(line.snapshot_unit_cost, "0.0001") qty_change = decimal_value(line.diff_qty) weight_change = decimal_value(line.diff_weight_kg) if line.diff_type == "NEW_LOT": counted_qty = Decimal("0") if weight_only else max(Decimal("0"), decimal_value(line.counted_qty)) counted_weight = max(Decimal("0"), decimal_value(line.counted_weight_kg)) lot = StockLot( lot_no=line.lot_no or f"{stocktake.stocktake_no}-NEW-{line.line_no:04d}", lot_role="STOCKTAKE_NEW", item_id=line.item_id, warehouse_id=line.warehouse_id, location_id=line.location_id, source_doc_type="STOCKTAKE", source_doc_id=stocktake.id, source_line_id=line.id, source_material_sub_batch_no=line.source_material_sub_batch_no, source_material_summary="盘库新增批次", inbound_qty=counted_qty, inbound_weight_kg=counted_weight, remaining_qty=counted_qty, remaining_weight_kg=counted_weight, locked_qty=Decimal("0"), locked_weight_kg=Decimal("0"), unit_cost=unit_cost, production_date=now.date(), quality_status="PASS", status="AVAILABLE", remark=line.remark or "盘库新增", ) db.add(lot) db.flush() lot_id = lot.id before_qty = Decimal("0") before_weight = Decimal("0") after_qty = counted_qty after_weight = counted_weight qty_change = after_qty weight_change = after_weight else: lot = db.get(StockLot, line.lot_id) if not lot: raise HTTPException(status_code=400, detail=f"盘库行 {line.line_no} 对应批次不存在") before_qty = Decimal("0") if weight_only else decimal_value(lot.remaining_qty) before_weight = decimal_value(lot.remaining_weight_kg) after_qty = Decimal("0") if weight_only else max(Decimal("0"), decimal_value(line.counted_qty)) after_weight = max(Decimal("0"), decimal_value(line.counted_weight_kg)) qty_change = after_qty - before_qty weight_change = after_weight - before_weight lot.remaining_qty = after_qty lot.remaining_weight_kg = after_weight lot.status = "AVAILABLE" if after_qty > 0 or after_weight > 0 else "DEPLETED" db.add(lot) lot_id = lot.id upsert_stock_balance( db, item_id=line.item_id, warehouse_id=line.warehouse_id, location_id=line.location_id, qty_delta=qty_change, weight_delta=weight_change, available_qty_delta=qty_change, available_weight_delta=weight_change, unit_cost=unit_cost, ) txn = create_inventory_txn( db, txn_type=_stocktake_txn_type(line.diff_type), item_id=line.item_id, warehouse_id=line.warehouse_id, location_id=line.location_id, lot_id=lot_id, qty_change=qty_change, weight_change=weight_change, unit_cost=unit_cost, source_doc_type="STOCKTAKE", source_doc_id=stocktake.id, source_line_id=line.id, biz_time=now, operator_user_id=user_id, remark=line.remark or remark or "盘库调整", amount_basis="WEIGHT", ) db.flush() db.add( StocktakeAdjustment( stocktake_id=stocktake.id, stocktake_line_id=line.id, warehouse_id=line.warehouse_id, item_id=line.item_id, lot_id=line.lot_id, adjustment_lot_id=lot_id, inventory_txn_id=txn.id, adjustment_type=line.diff_type, before_qty=before_qty, after_qty=after_qty, before_weight_kg=before_weight, after_weight_kg=after_weight, qty_change=qty_change, weight_change_kg=weight_change, unit_cost=unit_cost, amount=(abs(weight_change) * unit_cost).quantize(Decimal("0.01")), ) ) for warehouse in db.scalars(select(StocktakeWarehouse).where(StocktakeWarehouse.stocktake_id == stocktake_id)).all(): warehouse.status = "UNLOCKED" db.add(warehouse) stocktake.status = "CONFIRMED" stocktake.confirmed_by_user_id = user_id stocktake.confirmed_at = now if remark: stocktake.remark = remark db.add(stocktake) db.commit() db.refresh(stocktake) return stocktake def cancel_stocktake(db: Session, stocktake_id: int, *, user_id: int | None, remark: str | None = None) -> Stocktake: stocktake = db.get(Stocktake, stocktake_id) if not stocktake: raise HTTPException(status_code=404, detail="盘库单不存在") if stocktake.status not in ACTIVE_STOCKTAKE_STATUSES: raise HTTPException(status_code=400, detail="当前盘库单不能取消") now = current_time() for warehouse in db.scalars(select(StocktakeWarehouse).where(StocktakeWarehouse.stocktake_id == stocktake_id)).all(): warehouse.status = "UNLOCKED" db.add(warehouse) stocktake.status = "CANCELED" stocktake.canceled_by_user_id = user_id stocktake.canceled_at = now stocktake.remark = remark or stocktake.remark db.add(stocktake) db.commit() db.refresh(stocktake) return stocktake def stocktake_lines_for_read(db: Session, stocktake_id: int) -> list[StocktakeLine]: return db.scalars( select(StocktakeLine) .where(StocktakeLine.stocktake_id == stocktake_id) .order_by(StocktakeLine.warehouse_id.asc(), StocktakeLine.line_no.asc()) ).all() def stocktake_warehouses_for_read(db: Session, stocktake_id: int) -> list[StocktakeWarehouse]: return db.scalars( select(StocktakeWarehouse) .where(StocktakeWarehouse.stocktake_id == stocktake_id) .order_by(StocktakeWarehouse.id.asc()) ).all()