from datetime import date, datetime, timedelta
from decimal import Decimal, ROUND_FLOOR
import re
from uuid import uuid4

from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import func, or_, select
from sqlalchemy.orm import Session, aliased

from app.db.session import get_db
from app.models.document_archive import DocumentArchive
from app.models.master_data import BomItem, Item, Warehouse
from app.models.miniapp import MiniAppPersonnel, MiniAppProductionReport, MiniAppProductionReportItem
from app.models.operations import (
    CompletionReceipt,
    CompletionReceiptItem,
    Equipment,
    InventoryTxn,
    OperationReport,
    ProcessRouteOperation,
    ProductionBatchLedger,
    ProductionBatchLedgerTxn,
    ReturnDisposition,
    ReturnItem,
    ScrapRecord,
    StockLot,
    WorkOrder,
    WorkOrderMaterial,
    WorkOrderMaterialIssue,
    WorkOrderOperation,
)
from app.models.org import Department, Employee
from app.models.sales import SalesOrderItem
from app.schemas.operations import (
    CompletionReceiptCreate,
    CompletionReceiptItemRead,
    CompletionReceiptRead,
    MiniAppOperationReportRead,
    MiniAppOperationReportSyncRead,
    MiniAppOperationReportSyncStateRead,
    MiniAppScrapRecordCreate,
    OperationReportCreate,
    OperationReportRead,
    ProductionBatchLedgerRead,
    ProductionBatchLedgerInboundCreate,
    ProductionBatchLedgerInboundPreviewRead,
    ProductionBatchLedgerInboundRead,
    ProductionWorkOrderInboundCreate,
    ProductionWorkOrderInboundPreviewRead,
    ProductionWorkOrderInboundRead,
    ScrapRecordCreate,
    ScrapRecordRead,
    WorkOrderCreate,
    WorkOrderLedgerRead,
    WorkOrderMaterialIssueRead,
    WorkOrderMaterialRead,
    WorkOrderOperationRead,
    WorkOrderRead,
)
from app.schemas.document_archives import DocumentArchiveGenerateResult
from app.services.auth import AuthContext, require_authenticated_user
from app.services.auth import next_employee_code
from app.services.document_archives import (
    ARCHIVE_STATUS_MISSING,
    DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT,
    DOCUMENT_TYPE_PRODUCTION_MATERIAL_OUT,
    FILE_FORMAT_PDF,
    generate_document_archive,
    production_document_batch_no_from_text,
)
from app.services.operations import (
    build_raw_lot_product_work_order_no,
    build_yearly_product_work_order_no,
    create_inventory_txn,
    create_work_order_materials_and_operations,
    clamp_non_negative,
    get_completion_receipt_items_query,
    get_completion_receipts_query,
    get_default_bom,
    get_default_route,
    get_location_or_default,
    get_operation_reports_query,
    get_scrap_records_query,
    get_work_order_materials_query,
    get_work_order_material_issues_query,
    get_work_order_operations_query,
    get_work_orders_query,
    issue_selected_stock_lots,
    sync_recent_work_order_reference_qty,
    sync_work_order_reference_qty,
    sync_work_order_status,
    to_decimal,
    upsert_stock_balance,
)
from app.services.system_permissions import ensure_employee_has_permission
from app.services.system_config import (
    get_miniapp_operation_report_last_sync,
    lock_miniapp_operation_report_last_sync,
    upsert_miniapp_operation_report_last_sync,
)
from app.services.production_batch_ledger import (
    calculate_production_batch_ledger_snapshot,
    lock_production_batch_ledger,
    post_finished_inbound_to_ledger,
    post_scrap_inbound_to_ledger,
    post_surplus_inbound_to_ledger,
    record_production_issue_to_ledger,
)
from app.services.production_work_order_ledger import (
    INBOUND_TOLERANCE_MULTIPLIER,
    get_work_order_ledger_rows,
    get_work_order_limit_snapshot,
    validate_inbound_with_tolerance,
)
from app.services.production_work_order_inbound import (
    create_production_work_order_inbound,
    preview_production_work_order_inbound,
)
from app.services.stocktake import ensure_warehouses_unlocked
router = APIRouter(dependencies=[Depends(require_authenticated_user)])

_MINIAPP_WORK_ORDER_SHORT_CODE_RE = re.compile(r"^JH(\d{4})$")
PRODUCTION_LEDGER_INBOUND_TXN_TYPES = {"成品入库", "生产余料入库", "生产废料入库"}


def _archive_fields_from_result(
    result: DocumentArchiveGenerateResult | None,
    *,
    document_type: str,
    business_id: int | None,
) -> dict[str, object]:
    if result is None:
        return {
            "archive_status": "未生成",
            "archive_business_id": business_id,
            "archive_document_type": document_type if business_id else None,
            "archive_error_message": None,
        }
    return {
        "archive_status": result.archive_status,
        "archive_business_id": business_id,
        "archive_document_type": document_type,
        "archive_error_message": result.archive_error_message,
    }


def _failed_archive_result(document_type: str, business_id: int, document_no: str, exc: Exception) -> DocumentArchiveGenerateResult:
    _ = exc
    return DocumentArchiveGenerateResult(
        business_id=business_id,
        document_type=document_type,
        document_no=document_no,
        archive_status="归档失败",
        archive_version=None,
        archive_error_message="归档失败：PDF归档服务异常，请稍后重新生成",
    )


def _production_document_batch_no(now: datetime) -> str:
    return f"生产结算{now.strftime('%Y%m%d%H%M%S')}{uuid4().int % 1_000_000:06d}"


def _with_document_batch_no(remark: str, batch_no: str | None) -> str:
    if not batch_no:
        return remark
    return f"{remark}；单据批次号：{batch_no}"


def _latest_archive_map(db: Session, document_type: str, business_ids: list[int]) -> dict[int, DocumentArchive]:
    if not business_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_(business_ids),
        )
        .group_by(DocumentArchive.business_id)
        .subquery()
    )
    latest_id_subquery = (
        select(
            DocumentArchive.business_id.label("business_id"),
            func.max(DocumentArchive.id).label("archive_id"),
        )
        .join(
            latest_version_subquery,
            (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,
        )
        .group_by(DocumentArchive.business_id)
        .subquery()
    )
    rows = db.scalars(
        select(DocumentArchive).join(latest_id_subquery, latest_id_subquery.c.archive_id == DocumentArchive.id)
    ).all()
    return {int(row.business_id): row for row in rows}


def _code_token(value: str | None, fallback: str, max_length: int = 16) -> str:
    token = "".join(ch for ch in str(value or "").upper() if ch.isalnum())
    return (token or fallback)[-max_length:]


def _next_doc_no(db: Session, model, column_name: str, prefix: str) -> str:
    column = getattr(model, column_name)
    count = db.scalar(select(func.count(model.id)).where(column.like(f"{prefix}-%"))) or 0
    return f"{prefix}-{count + 1:03d}"


def _next_optional_sequence_doc_no(db: Session, model, column_name: str, prefix: str, max_length: int = 80) -> str:
    normalized_prefix = " ".join(str(prefix or "").split()).strip("- ") or "未命名"
    sequence_prefix = normalized_prefix[: max(max_length - 4, 1)]
    column = getattr(model, column_name)
    if not db.scalar(select(model.id).where(column == sequence_prefix).limit(1)):
        return sequence_prefix
    count = db.scalar(select(func.count(model.id)).where(column.like(f"{sequence_prefix}-%"))) or 0
    return f"{sequence_prefix}-{count + 1:03d}"


def _next_yearly_sequence_work_order_no(db: Session, product: Item, now: datetime | None = None) -> str:
    current_time = now or datetime.now()
    year_part = current_time.strftime("%Y")
    existing_nos = db.scalars(select(WorkOrder.work_order_no).where(WorkOrder.work_order_no.like(f"{year_part}____-%"))).all()
    used_sequences = [
        int(work_order_no[4:8])
        for work_order_no in existing_nos
        if len(work_order_no) > 8 and work_order_no[:4] == year_part and work_order_no[4:8].isdigit() and work_order_no[8] == "-"
    ]
    next_sequence = max(used_sequences, default=0) + 1
    product_name = " ".join(str(product.item_name or product.item_code or f"产品{product.id}").split()).strip("- ") or f"产品{product.id}"
    max_product_name_length = max(80 - len(year_part) - 4 - 1, 1)
    return f"{year_part}{next_sequence:04d}-{product_name[:max_product_name_length]}"


def _build_work_order_no(db: Session, product: Item) -> str:
    return build_yearly_product_work_order_no(db, product)


def _miniapp_work_order_short_code_sequence(value: str | None) -> int | None:
    match = _MINIAPP_WORK_ORDER_SHORT_CODE_RE.match(str(value or "").strip())
    if not match:
        return None
    return int(match.group(1))


def _is_miniapp_work_order_short_code(value: str | None) -> bool:
    return _miniapp_work_order_short_code_sequence(value) is not None


def _miniapp_syncable_work_order_status_filter():
    return func.upper(func.coalesce(WorkOrder.status, "")).notin_(("SETTLED", "CANCELED", "CANCELLED"))


def _resolve_work_order_by_miniapp_short_code(db: Session, short_code: str, row: dict) -> WorkOrder | None:
    sequence = _miniapp_work_order_short_code_sequence(short_code)
    if sequence is None:
        return None
    product_name = str(row.get("product_name") or "").strip()
    project_no = str(row.get("project_no") or "").strip()
    product_filters = []
    if product_name:
        product_filters.append(Item.item_name == product_name)
    if project_no:
        product_filters.append(Item.item_code == project_no)
    if not product_filters:
        return None

    year_part = datetime.now().strftime("%Y")
    work_order_prefix = f"{year_part}{sequence:04d}-"
    candidates = db.scalars(
        select(WorkOrder)
        .join(Item, Item.id == WorkOrder.product_item_id)
        .where(
            WorkOrder.work_order_no.like(f"{work_order_prefix}%"),
            _miniapp_syncable_work_order_status_filter(),
            or_(*product_filters),
        )
        .order_by(WorkOrder.id.desc())
    ).all()
    if len(candidates) == 1:
        return candidates[0]
    return None



def _build_scrap_no(db: Session, work_order: WorkOrder, disposal_type: str) -> str:
    date_part = date.today().strftime("%Y%m%d")
    type_part = "RWK" if disposal_type == "REWORK" else "SCRAP"
    work_order_part = _code_token(work_order.work_order_no, f"WO{work_order.id:04d}", 18)
    return _next_doc_no(db, ScrapRecord, "scrap_no", f"DSP-{type_part}-{date_part}-{work_order_part}")


def _build_completion_receipt_no(db: Session, work_order: WorkOrder) -> str:
    date_part = date.today().strftime("%Y%m%d")
    work_order_part = _code_token(work_order.work_order_no, f"WO{work_order.id:04d}", 18)
    return _next_doc_no(db, CompletionReceipt, "receipt_no", f"FGI-NB-{date_part}-{work_order_part}")


def _build_operation_report_no(db: Session, work_order: WorkOrder, operation: WorkOrderOperation, employee: Employee) -> str:
    date_part = date.today().strftime("%Y%m%d")
    work_order_part = _code_token(work_order.work_order_no, f"WO{work_order.id:04d}", 12)
    operation_part = f"OP{int(operation.seq_no or 0):02d}"
    employee_part = _code_token(employee.employee_code or employee.employee_name, f"EMP{employee.id:04d}", 8)
    return _next_doc_no(db, OperationReport, "report_no", f"RPT-NB-{date_part}-{work_order_part}-{operation_part}-{employee_part}")


def _production_ledger_txn_read(
    txn: ProductionBatchLedgerTxn,
    archive: DocumentArchive | None = None,
    archive_business_id: int | None = None,
) -> dict:
    return {
        "production_ledger_txn_id": txn.id,
        "production_ledger_id": txn.production_ledger_id,
        "txn_type": txn.txn_type,
        "qty_delta": float(to_decimal(txn.qty_delta)),
        "weight_delta_kg": float(to_decimal(txn.weight_delta_kg)),
        "outside_weight_after_kg": float(to_decimal(txn.outside_weight_after_kg)),
        "source_doc_type": txn.source_doc_type,
        "source_doc_id": txn.source_doc_id,
        "source_line_id": txn.source_line_id,
        "biz_time": txn.biz_time,
        "operator_user_id": txn.operator_user_id,
        "remark": txn.remark,
        "archive_status": archive.status if archive else (ARCHIVE_STATUS_MISSING if archive_business_id else None),
        "archive_business_id": archive_business_id,
        "archive_document_type": DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT if archive_business_id else None,
        "archive_error_message": archive.error_message if archive else None,
    }


def _build_finished_lot_no(db: Session, work_order: WorkOrder, product: Item, receipt_time: datetime, line_no: int) -> str:
    date_part = receipt_time.strftime("%Y%m%d")
    product_part = _code_token(product.item_code or product.item_name, f"FG{product.id:04d}", 12)
    work_order_part = _code_token(work_order.work_order_no, f"WO{work_order.id:04d}", 10)
    base_lot_no = f"FGL-NB-{date_part}-{product_part}-{work_order_part}-L{line_no:02d}"
    candidate = base_lot_no
    suffix = 1
    while db.scalar(select(func.count(StockLot.id)).where(StockLot.lot_no == candidate)):
        suffix += 1
        candidate = f"{base_lot_no}-{suffix:02d}"
    return candidate


def _get_active_warehouse(db: Session, warehouse_type: str, operation_name: str) -> Warehouse:
    warehouse = db.scalar(
        select(Warehouse)
        .where(Warehouse.warehouse_type == warehouse_type, Warehouse.status == "ACTIVE")
        .order_by(Warehouse.id)
        .limit(1)
    )
    if not warehouse:
        warehouse_name = {"FINISHED": "成品库", "SCRAP": "废料库", "RAW": "原材料库"}.get(warehouse_type, "目标仓库")
        raise HTTPException(status_code=400, detail=f"{operation_name}缺少启用的{warehouse_name}")
    return warehouse


def _build_production_ledger_lot_no(db: Session, prefix: str, ledger: ProductionBatchLedger, item: Item, biz_time: datetime) -> str:
    item_part = _code_token(item.item_code or item.item_name, f"ITEM{item.id:04d}", 12)
    source_part = _code_token(ledger.material_lot_no, f"LOT{ledger.material_lot_id:04d}", 10)
    return _next_doc_no(db, StockLot, "lot_no", f"{prefix}-NB-{biz_time.strftime('%Y%m%d')}-{item_part}-{source_part}")


def _issue_raw_lot_to_production_ledger(
    db: Session,
    *,
    lot: StockLot,
    material_item_id: int,
    issued_weight: Decimal,
    production_ledger_id: int,
    operator_user_id: int | None,
    biz_time: datetime,
) -> InventoryTxn:
    if issued_weight <= 0:
        raise HTTPException(status_code=400, detail="出库重量必须大于0")
    if not lot or int(lot.item_id) != int(material_item_id):
        raise HTTPException(status_code=400, detail="库存批次不存在或物料不匹配")
    warehouse = db.get(Warehouse, lot.warehouse_id)
    if not warehouse or warehouse.warehouse_type != "RAW":
        raise HTTPException(status_code=400, detail="库存批次不属于原材料库")
    usable_weight = clamp_non_negative(to_decimal(lot.remaining_weight_kg) - to_decimal(lot.locked_weight_kg))
    if lot.quality_status != "PASS" or lot.status != "AVAILABLE" or usable_weight <= 0:
        raise HTTPException(status_code=400, detail=f"库存批次 {lot.lot_no} 不可用")
    if issued_weight > usable_weight:
        raise HTTPException(status_code=400, detail=f"库存批次 {lot.lot_no} 剩余重量不足")

    lot.remaining_qty = Decimal("0")
    lot.remaining_weight_kg = clamp_non_negative(to_decimal(lot.remaining_weight_kg) - issued_weight)
    if to_decimal(lot.remaining_weight_kg) <= 0:
        lot.status = "DEPLETED"
    db.add(lot)

    unit_cost = to_decimal(lot.unit_cost, "0.0001")
    upsert_stock_balance(
        db,
        item_id=material_item_id,
        warehouse_id=lot.warehouse_id,
        location_id=lot.location_id,
        qty_delta=Decimal("0"),
        weight_delta=-issued_weight,
        available_qty_delta=Decimal("0"),
        available_weight_delta=-issued_weight,
        unit_cost=unit_cost,
    )
    txn = create_inventory_txn(
        db,
        txn_type="MATERIAL_ISSUE",
        item_id=material_item_id,
        warehouse_id=lot.warehouse_id,
        location_id=lot.location_id,
        lot_id=lot.id,
        qty_change=Decimal("0"),
        weight_change=-issued_weight,
        unit_cost=unit_cost,
        source_doc_type="生产台账",
        source_doc_id=production_ledger_id,
        source_line_id=None,
        biz_time=biz_time,
        operator_user_id=operator_user_id,
        remark=f"生产出库：库存批次号 {lot.lot_no}",
        amount_basis="WEIGHT",
    )
    db.add(txn)
    db.flush()
    return txn


def _link_latest_issue_ledger_txn_to_inventory_txn(
    db: Session,
    *,
    production_ledger_id: int,
    inventory_txn: InventoryTxn,
) -> None:
    ledger_txn = db.scalar(
        select(ProductionBatchLedgerTxn)
        .where(
            ProductionBatchLedgerTxn.production_ledger_id == production_ledger_id,
            ProductionBatchLedgerTxn.txn_type == "生产出库",
        )
        .order_by(ProductionBatchLedgerTxn.id.desc())
        .limit(1)
    )
    if not ledger_txn:
        return
    ledger_txn.source_doc_type = "库存流水"
    ledger_txn.source_doc_id = inventory_txn.id
    ledger_txn.source_line_id = inventory_txn.id
    db.add(ledger_txn)


def _build_production_ledger_work_order_compat_read(
    *,
    production_ledger_id: int,
    material_lot_no: str,
    product: Item,
    route,
    bom,
    planned_qty: Decimal,
    payload: WorkOrderCreate,
    biz_time: datetime,
    status: str,
    operation_count: int,
) -> WorkOrderRead:
    return WorkOrderRead(
        work_order_id=production_ledger_id,
        work_order_no=material_lot_no,
        work_order_type="正常生产",
        source_sales_order_item_id=payload.source_sales_order_item_id,
        source_order_no=None,
        product_item_id=product.id,
        product_code=product.item_code,
        product_name=product.item_name,
        product_version_no=None,
        route_id=route.id,
        route_name=route.route_name,
        bom_id=bom.id,
        planned_qty=float(planned_qty),
        released_qty=float(planned_qty),
        finished_qty=0,
        scrap_qty=0,
        planned_start_time=payload.planned_start_time or biz_time,
        planned_end_time=payload.planned_end_time,
        actual_start_time=None,
        actual_end_time=None,
        priority_level=payload.priority_level or 3,
        status=status,
        remark=payload.remark,
        operation_count=operation_count,
        abnormal_operation_count=0,
        reported_good_qty=0,
        received_qty=0,
        max_receivable_qty=float(planned_qty),
        all_operations_done=False,
    )


def _calculate_actual_cycle_seconds(start_time: datetime, end_time: datetime, report_qty: Decimal) -> float | None:
    if report_qty <= 0 or end_time <= start_time:
        return None
    return round((end_time - start_time).total_seconds() / float(report_qty), 2)


def _build_operation_abnormal_reason(row: dict, actual_cycle_seconds: float | None) -> str | None:
    reasons: list[str] = []
    report_qty = to_decimal(row.get("report_qty"))
    scrap_qty = to_decimal(row.get("scrap_qty"))
    allowed_scrap_rate = to_decimal(row.get("allowed_scrap_rate"), "0.0001")
    ref_cycle_seconds = to_decimal(row.get("ref_cycle_seconds"), "0.01")

    if report_qty > 0 and allowed_scrap_rate >= 0:
        actual_scrap_rate = scrap_qty / report_qty if report_qty > 0 else Decimal("0")
        if actual_scrap_rate > allowed_scrap_rate:
            reasons.append(
                f"报废率超标 {round(float(actual_scrap_rate * 100), 2)}% > {round(float(allowed_scrap_rate * 100), 2)}%"
            )

    if actual_cycle_seconds is not None and ref_cycle_seconds > 0 and Decimal(str(actual_cycle_seconds)) > ref_cycle_seconds:
        reasons.append(f"实际节拍 {actual_cycle_seconds}s/件 慢于参考节拍 {float(ref_cycle_seconds):.2f}s/件")

    scrap_reason = row.get("scrap_reason")
    if scrap_reason:
        reasons.append(f"异常说明：{scrap_reason}")

    if not reasons:
        return None
    return "；".join(reasons)


def _build_operation_report_read(row: dict) -> OperationReportRead:
    payload = dict(row)
    actual_cycle_seconds = _calculate_actual_cycle_seconds(
        payload["start_time"],
        payload["end_time"],
        to_decimal(payload.get("report_qty")),
    )
    payload["ref_cycle_seconds"] = float(payload.get("ref_cycle_seconds") or 0)
    payload["actual_cycle_seconds"] = actual_cycle_seconds
    payload["abnormal_reason"] = _build_operation_abnormal_reason(payload, actual_cycle_seconds)
    return OperationReportRead.model_validate(payload)


def _get_average_issued_weight_cost(db: Session, work_order_id: int) -> Decimal:
    row = db.execute(
        select(
            func.coalesce(func.sum(InventoryTxn.amount), 0).label("total_amount"),
            func.coalesce(func.sum(InventoryTxn.weight_change_kg), 0).label("total_weight"),
        ).where(
            InventoryTxn.txn_type == "MATERIAL_ISSUE",
            InventoryTxn.source_doc_type == "WORK_ORDER",
            InventoryTxn.source_doc_id == work_order_id,
        )
    ).mappings().first()
    if not row:
        return Decimal("0")
    total_amount = to_decimal(row["total_amount"], "0.01")
    total_weight = abs(to_decimal(row["total_weight"]))
    if total_weight <= 0:
        return Decimal("0")
    return total_amount / total_weight


def _get_active_scrap_record(db: Session, report_id: int) -> ScrapRecord | None:
    return db.scalar(
        select(ScrapRecord)
        .where(
            ScrapRecord.report_id == report_id,
            ScrapRecord.status == "CONFIRMED",
        )
        .order_by(ScrapRecord.id.desc())
        .limit(1)
    )


def _revert_scrap_record_effect(work_order: WorkOrder, record: ScrapRecord) -> None:
    if record.disposal_type == "SCRAP":
        work_order.scrap_qty = max(Decimal("0"), to_decimal(work_order.scrap_qty) - to_decimal(record.scrap_qty))


MINIAPP_REPORT_STATUS_LABELS = {
    "pending": "待审核",
    "approved": "已审核",
    "rejected": "已驳回",
}


def _miniapp_report_no(report_id: int, item_id: int) -> str:
    return f"WRS-{report_id}-{item_id}"


def _miniapp_effective_scrap_qty(row: dict) -> Decimal:
    scrap_qty = to_decimal(row.get("miniapp_scrap_qty"))
    defect_qty = to_decimal(row.get("defect_qty"))
    return scrap_qty if scrap_qty > 0 else defect_qty


def _new_miniapp_resolve_cache() -> dict[str, dict]:
    return {
        "production_ledger": {},
        "work_order": {},
        "operation": {},
        "employee": {},
        "report": {},
    }


def _miniapp_report_item_rows(
    db: Session,
    *,
    limit: int | None = 300,
    source_item_id: int | None = None,
    start_date: date | None = None,
    end_date: date | None = None,
    updated_after: datetime | None = None,
    updated_until: datetime | None = None,
    order_by_updated_asc: bool = False,
) -> list[dict]:
    stmt = (
        select(
            MiniAppProductionReport.id.label("source_report_id"),
            MiniAppProductionReportItem.id.label("source_item_id"),
            func.coalesce(
                MiniAppProductionReportItem.attendance_point_name,
                MiniAppProductionReport.attendance_point_name,
            ).label("attendance_point_name"),
            MiniAppProductionReport.employee_phone.label("employee_phone"),
            MiniAppPersonnel.name.label("employee_name"),
            MiniAppProductionReport.report_date.label("report_date"),
            MiniAppProductionReport.start_at.label("start_time"),
            MiniAppProductionReport.end_at.label("end_time"),
            MiniAppProductionReport.duration_minutes.label("duration_minutes"),
            MiniAppProductionReport.effective_minutes.label("effective_minutes"),
            MiniAppProductionReport.status.label("status"),
            MiniAppProductionReport.reviewer_phone.label("reviewer_phone"),
            MiniAppProductionReport.reviewed_at.label("reviewed_at"),
            MiniAppProductionReport.reject_reason.label("reject_reason"),
            MiniAppProductionReportItem.device_no.label("device_no"),
            MiniAppProductionReportItem.project_no.label("project_no"),
            MiniAppProductionReportItem.product_name.label("product_name"),
            MiniAppProductionReportItem.material_code.label("material_code"),
            MiniAppProductionReportItem.material_name.label("material_name"),
            MiniAppProductionReportItem.raw_material_batch_no.label("raw_material_batch_no"),
            MiniAppProductionReportItem.process_name.label("process_name"),
            MiniAppProductionReportItem.stamping_method.label("stamping_method"),
            MiniAppProductionReportItem.standard_beat.label("standard_beat"),
            MiniAppProductionReportItem.standard_workload.label("standard_workload"),
            MiniAppProductionReportItem.good_qty.label("good_qty"),
            MiniAppProductionReportItem.defect_qty.label("defect_qty"),
            MiniAppProductionReportItem.scrap_qty.label("miniapp_scrap_qty"),
            MiniAppProductionReportItem.allocated_minutes.label("allocated_minutes"),
            MiniAppProductionReportItem.started_at.label("started_at"),
        )
        .join(MiniAppProductionReport, MiniAppProductionReport.id == MiniAppProductionReportItem.report_id)
        .outerjoin(MiniAppPersonnel, MiniAppPersonnel.phone == MiniAppProductionReport.employee_phone)
    )
    if source_item_id is not None:
        stmt = stmt.where(MiniAppProductionReportItem.id == source_item_id)
    if start_date is not None:
        stmt = stmt.where(MiniAppProductionReport.report_date >= start_date)
    if end_date is not None:
        stmt = stmt.where(MiniAppProductionReport.report_date <= end_date)
    if updated_after is not None:
        stmt = stmt.where(MiniAppProductionReport.updated_at > updated_after)
    if updated_until is not None:
        stmt = stmt.where(MiniAppProductionReport.updated_at <= updated_until)
    if order_by_updated_asc:
        stmt = stmt.order_by(MiniAppProductionReport.updated_at.asc(), MiniAppProductionReportItem.id.asc())
    else:
        stmt = stmt.order_by(MiniAppProductionReportItem.id.desc())
    if limit is not None:
        stmt = stmt.limit(limit)
    rows = db.execute(stmt).mappings().all()
    return [dict(row) for row in rows]


def _preload_miniapp_operation_reports(db: Session, report_nos: list[str], cache: dict[str, dict]) -> None:
    if not report_nos:
        return
    unique_report_nos = list(dict.fromkeys(report_nos))
    existing_reports = db.scalars(select(OperationReport).where(OperationReport.report_no.in_(unique_report_nos))).all()
    cache["report"].update({report_no: None for report_no in unique_report_nos})
    cache["report"].update({report.report_no: report for report in existing_reports})


def _get_miniapp_report_item_row(db: Session, source_item_id: int) -> dict:
    rows = _miniapp_report_item_rows(db, limit=1, source_item_id=source_item_id)
    if rows:
        return rows[0]
    raise HTTPException(status_code=404, detail="小程序报工明细不存在")


def _miniapp_report_matches_target(
    report: OperationReport | None,
    work_order: WorkOrder | None,
    operation: WorkOrderOperation | None,
) -> bool:
    return bool(
        report
        and work_order
        and operation
        and int(report.work_order_id) == int(work_order.id)
        and int(report.work_order_operation_id) == int(operation.id)
    )


def _miniapp_report_matches_production_ledger(
    report: OperationReport | None,
    production_ledger: ProductionBatchLedger | None,
) -> bool:
    return bool(
        report
        and production_ledger
        and report.production_ledger_id
        and int(report.production_ledger_id) == int(production_ledger.id)
    )


def _unlink_stale_miniapp_report(
    db: Session,
    report: OperationReport | None,
    *,
    cache: dict[str, dict] | None = None,
) -> None:
    if not report:
        return

    scrap_count = db.scalar(select(func.count(ScrapRecord.id)).where(ScrapRecord.report_id == report.id)) or 0
    if scrap_count:
        # Do not delete a report already used by disposal records. It will be hidden from miniapp sync state
        # by strict matching, and manual correction can handle the dependent disposal data if needed.
        return

    operation = db.get(WorkOrderOperation, report.work_order_operation_id) if report.work_order_operation_id else None
    work_order_id = int(report.work_order_id) if report.work_order_id else None
    if operation:
        operation.report_qty = max(Decimal("0"), to_decimal(operation.report_qty) - to_decimal(report.report_qty))
        operation.good_qty = max(Decimal("0"), to_decimal(operation.good_qty) - to_decimal(report.good_qty))
        operation.scrap_qty = max(Decimal("0"), to_decimal(operation.scrap_qty) - to_decimal(report.scrap_qty))
        operation.rework_qty = max(Decimal("0"), to_decimal(operation.rework_qty) - to_decimal(report.rework_qty))
        report_qty = to_decimal(operation.report_qty)
        planned_qty = to_decimal(operation.planned_qty)
        if planned_qty > 0 and report_qty >= planned_qty:
            operation.status = "DONE"
        elif report_qty > 0:
            operation.status = "IN_PROGRESS"
        else:
            operation.status = "PENDING"
        operation.is_abnormal = 1 if report_qty > 0 and (
            to_decimal(operation.scrap_qty) / report_qty
        ) > to_decimal(operation.allowed_scrap_rate, "0.0001") else 0
        db.add(operation)

    report_no = report.report_no
    db.delete(report)
    db.flush()
    if cache is not None:
        cache["report"][report_no] = None
    if work_order_id:
        sync_work_order_status(db, work_order_id)


def _select_miniapp_issue_candidate(
    db: Session,
    candidates: list[WorkOrderMaterialIssue],
    row: dict,
) -> WorkOrderMaterialIssue | None:
    unsettled_work_order_ids = {
        work_order_id
        for (work_order_id,) in db.execute(
            select(WorkOrder.id).where(
                WorkOrder.id.in_({issue.work_order_id for issue in candidates}),
                _miniapp_syncable_work_order_status_filter(),
            )
        ).all()
    }
    latest_by_work_order: dict[int, WorkOrderMaterialIssue] = {}
    for issue in candidates:
        if issue.work_order_id not in unsettled_work_order_ids:
            continue
        latest_by_work_order.setdefault(issue.work_order_id, issue)
    if len(latest_by_work_order) == 1:
        return next(iter(latest_by_work_order.values()))

    product_name = str(row.get("product_name") or "").strip()
    project_no = str(row.get("project_no") or "").strip()
    if not product_name and not project_no:
        return None

    matched_work_order_ids: set[int] = set()
    product_rows = db.execute(
        select(WorkOrder.id, Item.item_name, Item.item_code)
        .join(Item, Item.id == WorkOrder.product_item_id)
        .where(
            WorkOrder.id.in_(latest_by_work_order),
            _miniapp_syncable_work_order_status_filter(),
        )
    ).all()
    for work_order_id, item_name, item_code in product_rows:
        if product_name and str(item_name or "").strip() == product_name:
            matched_work_order_ids.add(work_order_id)
        if project_no and str(item_code or "").strip() == project_no:
            matched_work_order_ids.add(work_order_id)
    if len(matched_work_order_ids) == 1:
        return latest_by_work_order[next(iter(matched_work_order_ids))]
    return None


def _resolve_work_order_issue_by_inventory_lot_no(
    db: Session,
    lot_no: str,
    row: dict,
) -> tuple[WorkOrderMaterialIssue | None, bool]:
    candidates = db.scalars(
        select(WorkOrderMaterialIssue)
        .join(WorkOrder, WorkOrder.id == WorkOrderMaterialIssue.work_order_id)
        .join(StockLot, StockLot.id == WorkOrderMaterialIssue.source_lot_id)
        .where(
            StockLot.lot_no == lot_no,
            _miniapp_syncable_work_order_status_filter(),
        )
        .order_by(WorkOrderMaterialIssue.id.desc())
    ).all()
    if candidates:
        return _select_miniapp_issue_candidate(db, list(candidates), row), True
    has_filtered_source_lot_candidate = bool(db.scalar(
        select(func.count(WorkOrderMaterialIssue.id))
        .join(StockLot, StockLot.id == WorkOrderMaterialIssue.source_lot_id)
        .where(StockLot.lot_no == lot_no)
    ) or 0)
    candidates = db.scalars(
        select(WorkOrderMaterialIssue)
        .join(WorkOrder, WorkOrder.id == WorkOrderMaterialIssue.work_order_id)
        .where(
            WorkOrderMaterialIssue.material_sub_batch_no == lot_no,
            _miniapp_syncable_work_order_status_filter(),
        )
        .order_by(WorkOrderMaterialIssue.id.desc())
    ).all()
    if candidates:
        return _select_miniapp_issue_candidate(db, list(candidates), row), True
    has_filtered_sub_batch_candidate = bool(db.scalar(
        select(func.count(WorkOrderMaterialIssue.id)).where(WorkOrderMaterialIssue.material_sub_batch_no == lot_no)
    ) or 0)
    if has_filtered_source_lot_candidate or has_filtered_sub_batch_candidate:
        return None, True
    return None, False


def _find_work_order_for_miniapp_item(db: Session, row: dict, cache: dict[str, dict] | None = None) -> WorkOrder | None:
    batch_no = str(row.get("raw_material_batch_no") or "").strip()
    product_name = str(row.get("product_name") or "").strip()
    project_no = str(row.get("project_no") or "").strip()
    cache_key = ("batch", batch_no, product_name, project_no)
    if cache is not None and cache_key in cache["work_order"]:
        return cache["work_order"][cache_key]

    if not batch_no:
        if cache is not None:
            cache["work_order"][cache_key] = None
        return None

    issue, has_issue_candidates = _resolve_work_order_issue_by_inventory_lot_no(db, batch_no, row)
    work_order = db.get(WorkOrder, issue.work_order_id) if issue else None
    if not work_order and _is_miniapp_work_order_short_code(batch_no):
        work_order = _resolve_work_order_by_miniapp_short_code(db, batch_no, row)
    if not work_order and not has_issue_candidates:
        work_order = db.scalar(
            select(WorkOrder)
            .where(
                WorkOrder.work_order_no == batch_no,
                _miniapp_syncable_work_order_status_filter(),
            )
            .limit(1)
        )
    if work_order:
        if cache is not None:
            cache["work_order"][cache_key] = work_order
        return work_order
    if cache is not None:
        cache["work_order"][cache_key] = work_order
    return work_order


def _find_production_ledger_for_miniapp_item(
    db: Session,
    row: dict,
    cache: dict[str, dict] | None = None,
) -> ProductionBatchLedger | None:
    batch_no = str(row.get("raw_material_batch_no") or "").strip()
    product_name = str(row.get("product_name") or "").strip()
    project_no = str(row.get("project_no") or "").strip()
    cache_key = ("production_ledger", batch_no, product_name, project_no)
    if cache is not None and cache_key in cache["production_ledger"]:
        return cache["production_ledger"][cache_key]
    if not batch_no or (not product_name and not project_no):
        if cache is not None:
            cache["production_ledger"][cache_key] = None
        return None
    product_filters = []
    if product_name:
        product_filters.append(Item.item_name == product_name)
    if project_no:
        product_filters.append(Item.item_code == project_no)
    ledger = db.scalar(
        select(ProductionBatchLedger)
        .join(Item, Item.id == ProductionBatchLedger.product_item_id)
        .where(
            ProductionBatchLedger.material_lot_no == batch_no,
            ProductionBatchLedger.status == "在生产",
            ProductionBatchLedger.miniapp_selectable == 1,
            or_(*product_filters),
        )
        .order_by(ProductionBatchLedger.id.desc())
        .limit(1)
    )
    if cache is not None:
        cache["production_ledger"][cache_key] = ledger
    return ledger


def _find_work_order_operation_for_miniapp_item(
    db: Session,
    work_order_id: int,
    process_name: str | None,
    cache: dict[str, dict] | None = None,
    *,
    allow_fallback: bool = True,
) -> WorkOrderOperation | None:
    normalized_process = str(process_name or "").strip()
    cache_key = (work_order_id, normalized_process, allow_fallback)
    if cache is not None and cache_key in cache["operation"]:
        return cache["operation"][cache_key]
    operation = None
    if normalized_process:
        operation = db.scalar(
            select(WorkOrderOperation)
            .where(
                WorkOrderOperation.work_order_id == work_order_id,
                WorkOrderOperation.operation_name == normalized_process,
            )
            .order_by(WorkOrderOperation.seq_no)
            .limit(1)
        )
        if operation:
            if cache is not None:
                cache["operation"][cache_key] = operation
            return operation
    if not allow_fallback:
        if cache is not None:
            cache["operation"][cache_key] = None
        return None
    operation = db.scalar(
        select(WorkOrderOperation)
        .where(WorkOrderOperation.work_order_id == work_order_id)
        .order_by(WorkOrderOperation.seq_no)
        .limit(1)
    )
    if cache is not None:
        cache["operation"][cache_key] = operation
    return operation


def _get_or_create_miniapp_employee(db: Session, row: dict, cache: dict[str, dict] | None = None) -> Employee:
    phone = str(row.get("employee_phone") or "").strip()
    employee_name = str(row.get("employee_name") or "").strip() or phone or "小程序员工"
    cache_key = phone or f"name:{employee_name}"
    if cache is not None and cache_key in cache["employee"]:
        return cache["employee"][cache_key]
    if phone:
        employee = db.scalar(select(Employee).where(Employee.mobile == phone).order_by(Employee.id.desc()).limit(1))
        if employee:
            if employee.employee_name != employee_name:
                employee.employee_name = employee_name
                db.add(employee)
            if cache is not None:
                cache["employee"][cache_key] = employee
            return employee

    department = db.scalar(select(Department).order_by(Department.id).limit(1))
    if not department:
        raise HTTPException(status_code=400, detail="ERP 未配置部门，无法把小程序报工员工映射到 ERP 员工档案")
    employee = Employee(
        employee_code=next_employee_code(db),
        employee_name=employee_name,
        dept_id=department.id,
        mobile=phone or None,
        gender=None,
        hire_date=None,
        job_title="小程序报工员工",
        shift_code=None,
        manager_employee_id=None,
        is_operator=1,
        is_workshop_staff=1,
        status="ACTIVE",
        remark="由小程序报工同步生成",
    )
    db.add(employee)
    db.flush()
    if cache is not None:
        cache["employee"][cache_key] = employee
    return employee


def _sync_miniapp_item_to_erp(
    db: Session,
    source_item_id: int,
    *,
    required: bool = False,
    row: dict | None = None,
    cache: dict[str, dict] | None = None,
    sync_status: bool = True,
) -> OperationReport | None:
    if row is None:
        row = _get_miniapp_report_item_row(db, source_item_id)
    report_no = _miniapp_report_no(int(row["source_report_id"]), int(row["source_item_id"]))
    if cache is not None and report_no in cache["report"]:
        report = cache["report"][report_no]
    else:
        report = db.scalar(select(OperationReport).where(OperationReport.report_no == report_no).limit(1))
        if cache is not None:
            cache["report"][report_no] = report
    if str(row.get("status") or "") == "rejected":
        _unlink_stale_miniapp_report(db, report, cache=cache)
        if required:
            raise HTTPException(status_code=400, detail="该小程序报工已驳回，不能同步为 ERP 报工")
        return None

    production_ledger = _find_production_ledger_for_miniapp_item(db, row, cache)
    work_order = None if production_ledger else _find_work_order_for_miniapp_item(db, row, cache)
    if not work_order:
        if not production_ledger:
            _unlink_stale_miniapp_report(db, report, cache=cache)
            if required:
                raise HTTPException(status_code=400, detail="小程序报工缺少有效库存批次号，不能同步为 ERP 报工")
            return None
    operation = None
    if work_order:
        operation = _find_work_order_operation_for_miniapp_item(
            db,
            work_order.id,
            row.get("process_name"),
            cache,
            allow_fallback=not _is_miniapp_work_order_short_code(row.get("raw_material_batch_no")),
        )
        if not operation:
            _unlink_stale_miniapp_report(db, report, cache=cache)
            if required:
                raise HTTPException(status_code=400, detail="该库存批次缺少可匹配的小程序报工工序")
            return None

    employee = _get_or_create_miniapp_employee(db, row, cache)
    start_time = row.get("started_at") or row.get("start_time") or datetime.now()
    end_time = row.get("end_time") or start_time + timedelta(minutes=float(row.get("allocated_minutes") or 0) or 1)
    if end_time <= start_time:
        end_time = start_time + timedelta(minutes=1)

    good_qty = to_decimal(row.get("good_qty"))
    scrap_qty = _miniapp_effective_scrap_qty(row)
    report_qty = good_qty + scrap_qty
    if report_qty <= 0:
        if required:
            raise HTTPException(status_code=400, detail="该小程序报工没有有效产出或报废数量")
        return None

    if production_ledger:
        if report and not _miniapp_report_matches_production_ledger(report, production_ledger):
            _unlink_stale_miniapp_report(db, report, cache=cache)
            report = None
    elif report and not _miniapp_report_matches_target(report, work_order, operation):
        _unlink_stale_miniapp_report(db, report, cache=cache)
        report = None
    old_report_qty = to_decimal(report.report_qty) if report else Decimal("0")
    old_good_qty = to_decimal(report.good_qty) if report else Decimal("0")
    old_scrap_qty = to_decimal(report.scrap_qty) if report else Decimal("0")

    actual_cycle_seconds = _calculate_actual_cycle_seconds(start_time, end_time, report_qty)
    ref_cycle_seconds = to_decimal(row.get("standard_beat"), "0.01")
    abnormal = 0
    if operation and report_qty > 0 and (scrap_qty / report_qty) > to_decimal(operation.allowed_scrap_rate, "0.0001"):
        abnormal = 1
    if actual_cycle_seconds is not None and ref_cycle_seconds > 0 and Decimal(str(actual_cycle_seconds)) > ref_cycle_seconds:
        abnormal = 1

    if not report:
        report = OperationReport(
            report_no=report_no,
            work_order_id=work_order.id if work_order else None,
            work_order_operation_id=operation.id if operation else None,
            production_ledger_id=production_ledger.id if production_ledger else None,
            employee_id=employee.id,
            equipment_id=None,
            report_source="MINIAPP",
            shift_code=None,
            start_time=start_time,
            end_time=end_time,
            report_qty=report_qty,
            good_qty=good_qty,
            scrap_qty=scrap_qty,
            rework_qty=to_decimal(0),
            scrap_reason=None,
            downtime_minutes=to_decimal(0, "0.01"),
            extra_cost_amount=to_decimal(0, "0.01"),
            is_abnormal=abnormal,
            approved_by=None,
            approved_at=None,
            remark=(
                f"小程序报工同步：考勤点={row.get('attendance_point_name') or '-'} "
                f"report_id={row['source_report_id']} item_id={row['source_item_id']}"
            ),
        )
    else:
        report.work_order_id = work_order.id if work_order else None
        report.work_order_operation_id = operation.id if operation else None
        report.production_ledger_id = production_ledger.id if production_ledger else None
        report.employee_id = employee.id
        report.report_source = "MINIAPP"
        report.start_time = start_time
        report.end_time = end_time
        report.report_qty = report_qty
        report.good_qty = good_qty
        report.scrap_qty = scrap_qty
        report.rework_qty = to_decimal(0)
        report.is_abnormal = abnormal
        report.remark = (
            f"小程序报工同步：考勤点={row.get('attendance_point_name') or '-'} "
            f"report_id={row['source_report_id']} item_id={row['source_item_id']}"
        )
    db.add(report)

    if operation and work_order:
        delta_report_qty = report_qty - old_report_qty
        delta_good_qty = good_qty - old_good_qty
        delta_scrap_qty = scrap_qty - old_scrap_qty
        operation.report_qty = max(Decimal("0"), to_decimal(operation.report_qty) + delta_report_qty)
        operation.good_qty = max(Decimal("0"), to_decimal(operation.good_qty) + delta_good_qty)
        operation.scrap_qty = max(Decimal("0"), to_decimal(operation.scrap_qty) + delta_scrap_qty)
        if abnormal:
            operation.is_abnormal = 1
        if to_decimal(operation.report_qty) >= to_decimal(operation.planned_qty):
            operation.status = "DONE"
        elif to_decimal(operation.report_qty) > 0:
            operation.status = "IN_PROGRESS"
        db.add(operation)

        if work_order.actual_start_time is None:
            work_order.actual_start_time = start_time
        db.add(work_order)
        if sync_status:
            sync_work_order_status(db, work_order.id)
    db.flush()
    if cache is not None:
        cache["report"][report_no] = report
    return report


def _build_miniapp_operation_report_read(
    db: Session,
    row: dict,
    report: OperationReport | None = None,
    cache: dict[str, dict] | None = None,
    resolve_targets: bool = True,
) -> MiniAppOperationReportRead:
    if not report:
        report_no = _miniapp_report_no(int(row["source_report_id"]), int(row["source_item_id"]))
        if cache is not None and report_no in cache["report"]:
            report = cache["report"][report_no]
        else:
            report = db.scalar(select(OperationReport).where(OperationReport.report_no == report_no).limit(1))
            if cache is not None:
                cache["report"][report_no] = report
    erp_scrap_qty = _miniapp_effective_scrap_qty(row)
    status = str(row.get("status") or "pending")
    if not resolve_targets:
        return MiniAppOperationReportRead(
            source_report_id=int(row["source_report_id"]),
            source_item_id=int(row["source_item_id"]),
            report_no=_miniapp_report_no(int(row["source_report_id"]), int(row["source_item_id"])),
            attendance_point_name=str(row.get("attendance_point_name") or ""),
            employee_phone=str(row.get("employee_phone") or ""),
            employee_name=str(row.get("employee_name") or ""),
            report_date=row["report_date"],
            start_time=row["start_time"],
            end_time=row["end_time"],
            duration_minutes=float(row.get("duration_minutes") or 0),
            effective_minutes=float(row.get("effective_minutes") or 0),
            status=status,
            status_name=MINIAPP_REPORT_STATUS_LABELS.get(status, status),
            reviewer_phone=row.get("reviewer_phone"),
            reviewed_at=row.get("reviewed_at"),
            reject_reason=row.get("reject_reason"),
            device_no=str(row.get("device_no") or ""),
            project_no=str(row.get("project_no") or ""),
            product_name=str(row.get("product_name") or ""),
            material_code=row.get("material_code"),
            material_name=row.get("material_name"),
            raw_material_batch_no=row.get("raw_material_batch_no"),
            process_name=row.get("process_name"),
            standard_beat=float(row.get("standard_beat") or 0),
            standard_workload=float(row.get("standard_workload") or 0),
            good_qty=float(row.get("good_qty") or 0),
            defect_qty=float(row.get("defect_qty") or 0),
            scrap_qty=float(row.get("miniapp_scrap_qty") or 0),
            erp_scrap_qty=float(erp_scrap_qty),
            allocated_minutes=float(row.get("allocated_minutes") or 0),
            started_at=row.get("started_at"),
            work_order_id=report.work_order_id if report else None,
            work_order_no=row.get("raw_material_batch_no"),
            work_order_operation_id=report.work_order_operation_id if report else None,
            operation_name=row.get("process_name"),
            erp_report_id=report.id if report else None,
            erp_report_no=report.report_no if report else None,
            is_synced_to_erp=bool(report),
            abnormal_reason=None,
        )
    production_ledger = _find_production_ledger_for_miniapp_item(db, row, cache)
    work_order = None if production_ledger else _find_work_order_for_miniapp_item(db, row, cache)
    operation = (
        _find_work_order_operation_for_miniapp_item(
            db,
            work_order.id,
            row.get("process_name"),
            cache,
            allow_fallback=not _is_miniapp_work_order_short_code(row.get("raw_material_batch_no")),
        )
        if work_order
        else None
    )
    synced_report = (
        report
        if (
            _miniapp_report_matches_production_ledger(report, production_ledger)
            or _miniapp_report_matches_target(report, work_order, operation)
        )
        else None
    )
    report_qty = to_decimal(row.get("good_qty")) + erp_scrap_qty
    abnormal_reason = None
    if report_qty > 0 and operation:
        scrap_rate = erp_scrap_qty / report_qty
        if scrap_rate > to_decimal(operation.allowed_scrap_rate, "0.0001"):
            abnormal_reason = (
                f"报废率超标 {round(float(scrap_rate * 100), 2)}% > "
                f"{round(float(to_decimal(operation.allowed_scrap_rate, '0.0001') * 100), 2)}%"
            )
    return MiniAppOperationReportRead(
        source_report_id=int(row["source_report_id"]),
        source_item_id=int(row["source_item_id"]),
        report_no=_miniapp_report_no(int(row["source_report_id"]), int(row["source_item_id"])),
        attendance_point_name=str(row.get("attendance_point_name") or ""),
        employee_phone=str(row.get("employee_phone") or ""),
        employee_name=str(row.get("employee_name") or ""),
        report_date=row["report_date"],
        start_time=row["start_time"],
        end_time=row["end_time"],
        duration_minutes=float(row.get("duration_minutes") or 0),
        effective_minutes=float(row.get("effective_minutes") or 0),
        status=status,
        status_name=MINIAPP_REPORT_STATUS_LABELS.get(status, status),
        reviewer_phone=row.get("reviewer_phone"),
        reviewed_at=row.get("reviewed_at"),
        reject_reason=row.get("reject_reason"),
        device_no=str(row.get("device_no") or ""),
        project_no=str(row.get("project_no") or ""),
        product_name=str(row.get("product_name") or ""),
        material_code=row.get("material_code"),
        material_name=row.get("material_name"),
        raw_material_batch_no=row.get("raw_material_batch_no"),
        process_name=row.get("process_name"),
        standard_beat=float(row.get("standard_beat") or 0),
        standard_workload=float(row.get("standard_workload") or 0),
        good_qty=float(row.get("good_qty") or 0),
        defect_qty=float(row.get("defect_qty") or 0),
        scrap_qty=float(row.get("miniapp_scrap_qty") or 0),
        erp_scrap_qty=float(erp_scrap_qty),
        allocated_minutes=float(row.get("allocated_minutes") or 0),
        started_at=row.get("started_at"),
        work_order_id=work_order.id if work_order else None,
        work_order_no=work_order.work_order_no if work_order else row.get("raw_material_batch_no"),
        work_order_operation_id=operation.id if operation else None,
        operation_name=operation.operation_name if operation else row.get("process_name"),
        erp_report_id=synced_report.id if synced_report else None,
        erp_report_no=synced_report.report_no if synced_report else None,
        is_synced_to_erp=bool(synced_report),
        abnormal_reason=abnormal_reason,
    )


@router.get("/work-order-ledger", response_model=list[WorkOrderLedgerRead])
def list_work_order_ledger(
    work_order_id: int | None = Query(default=None),
    limit: int = Query(default=200, ge=1, le=500),
    db: Session = Depends(get_db),
) -> list[WorkOrderLedgerRead]:
    if work_order_id and sync_work_order_reference_qty(db, work_order_id):
        db.commit()
    rows = get_work_order_ledger_rows(db, limit=limit, work_order_id=work_order_id)
    return [WorkOrderLedgerRead.model_validate(row.model_dump()) for row in rows]


@router.get("/production-ledger", response_model=list[ProductionBatchLedgerRead])
def list_production_batch_ledger(
    production_ledger_id: int | None = Query(default=None),
    status: str | None = Query(default=None),
    limit: int = Query(default=200, ge=1, le=500),
    db: Session = Depends(get_db),
) -> list[ProductionBatchLedgerRead]:
    material_item = aliased(Item)
    product_item = aliased(Item)
    query = (
        select(
            ProductionBatchLedger,
            material_item.item_code.label("material_code"),
            material_item.item_name.label("material_name"),
            product_item.item_code.label("product_code"),
            product_item.item_name.label("product_name"),
        )
        .join(material_item, material_item.id == ProductionBatchLedger.material_item_id, isouter=True)
        .join(product_item, product_item.id == ProductionBatchLedger.product_item_id, isouter=True)
        .order_by(ProductionBatchLedger.updated_at.desc(), ProductionBatchLedger.id.desc())
        .limit(limit)
    )
    if production_ledger_id:
        query = query.where(ProductionBatchLedger.id == production_ledger_id)
    if status:
        query = query.where(ProductionBatchLedger.status == status)

    rows = db.execute(query).all()
    ledger_ids = [row.ProductionBatchLedger.id for row in rows]
    material_out_archives = _latest_archive_map(db, DOCUMENT_TYPE_PRODUCTION_MATERIAL_OUT, [int(ledger_id) for ledger_id in ledger_ids])
    txns_by_ledger_id: dict[int, list[ProductionBatchLedgerTxn]] = {ledger_id: [] for ledger_id in ledger_ids}
    settlement_archives: dict[int, DocumentArchive] = {}
    settlement_business_id_by_txn_id: dict[int, int] = {}
    if ledger_ids:
        txn_rows = db.scalars(
            select(ProductionBatchLedgerTxn)
            .where(ProductionBatchLedgerTxn.production_ledger_id.in_(ledger_ids))
            .order_by(ProductionBatchLedgerTxn.biz_time.desc(), ProductionBatchLedgerTxn.id.desc())
        ).all()
        for txn in txn_rows:
            txns_by_ledger_id.setdefault(txn.production_ledger_id, []).append(txn)
        settlement_group_order = {"成品入库": 1, "生产余料入库": 2, "生产废料入库": 3}
        settlement_grouped_txns: dict[tuple[int, object], list[ProductionBatchLedgerTxn]] = {}
        for txn in txn_rows:
            if txn.source_doc_type != "库存流水" or not txn.source_doc_id or txn.txn_type not in PRODUCTION_LEDGER_INBOUND_TXN_TYPES:
                continue
            batch_no = production_document_batch_no_from_text(txn.remark)
            group_key = (int(txn.production_ledger_id), f"批次:{batch_no}" if batch_no else txn.biz_time)
            settlement_grouped_txns.setdefault(group_key, []).append(txn)
        for grouped_txns in settlement_grouped_txns.values():
            main_txn = sorted(
                grouped_txns,
                key=lambda item: (settlement_group_order.get(str(item.txn_type or ""), 99), int(item.source_doc_id or 0)),
            )[0]
            main_business_id = int(main_txn.source_doc_id)
            for txn in grouped_txns:
                settlement_business_id_by_txn_id[int(txn.id)] = main_business_id
        settlement_business_ids = list(set(settlement_business_id_by_txn_id.values()))
        settlement_archives = _latest_archive_map(db, DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT, settlement_business_ids)

    result: list[ProductionBatchLedgerRead] = []
    for row in rows:
        ledger = row.ProductionBatchLedger
        material_archive = material_out_archives.get(int(ledger.id))
        result.append(
            ProductionBatchLedgerRead(
                production_ledger_id=ledger.id,
                material_lot_id=ledger.material_lot_id,
                material_lot_no=ledger.material_lot_no,
                material_item_id=ledger.material_item_id,
                material_code=row.material_code,
                material_name=row.material_name,
                product_item_id=ledger.product_item_id,
                product_code=row.product_code,
                product_name=row.product_name,
                status=ledger.status,
                miniapp_selectable=bool(ledger.miniapp_selectable),
                total_issued_weight_kg=float(to_decimal(ledger.total_issued_weight_kg)),
                outside_weight_kg=float(to_decimal(ledger.outside_weight_kg)),
                finished_inbound_qty=float(to_decimal(ledger.finished_inbound_qty)),
                surplus_inbound_weight_kg=float(to_decimal(ledger.surplus_inbound_weight_kg)),
                scrap_inbound_weight_kg=float(to_decimal(ledger.scrap_inbound_weight_kg)),
                writeoff_weight_kg=float(to_decimal(ledger.writeoff_weight_kg)),
                first_issue_time=ledger.first_issue_time,
                last_issue_time=ledger.last_issue_time,
                locked_at=ledger.locked_at,
                reopened_at=ledger.reopened_at,
                lock_count=int(ledger.lock_count or 0),
                remark=ledger.remark,
                material_out_archive_status=material_archive.status if material_archive else ARCHIVE_STATUS_MISSING,
                material_out_archive_business_id=ledger.id,
                material_out_archive_document_type=DOCUMENT_TYPE_PRODUCTION_MATERIAL_OUT,
                material_out_archive_error_message=material_archive.error_message if material_archive else None,
                txns=[
                    _production_ledger_txn_read(
                        txn,
                        settlement_archives.get(settlement_business_id_by_txn_id[int(txn.id)])
                        if int(txn.id) in settlement_business_id_by_txn_id
                        else None,
                        settlement_business_id_by_txn_id.get(int(txn.id)),
                    )
                    for txn in txns_by_ledger_id.get(ledger.id, [])
                ],
            )
        )
    return result


@router.get("/work-order-inbounds/preview", response_model=ProductionWorkOrderInboundPreviewRead)
def preview_work_order_inbound(
    work_order_id: int = Query(...),
    finished_qty: float | None = Query(default=None),
    db: Session = Depends(get_db),
) -> ProductionWorkOrderInboundPreviewRead:
    return preview_production_work_order_inbound(db, work_order_id=work_order_id, finished_qty=finished_qty)


@router.post("/work-order-inbounds", response_model=ProductionWorkOrderInboundRead)
def create_work_order_inbound(
    payload: ProductionWorkOrderInboundCreate,
    context: AuthContext = Depends(require_authenticated_user),
    db: Session = Depends(get_db),
) -> ProductionWorkOrderInboundRead:
    return create_production_work_order_inbound(db, payload=payload, operator_user_id=context.user.id)


@router.get("/production-ledger-inbounds/preview", response_model=ProductionBatchLedgerInboundPreviewRead)
def preview_production_batch_ledger_inbound(
    production_ledger_id: int = Query(...),
    finished_qty: float = Query(default=0),
    db: Session = Depends(get_db),
) -> ProductionBatchLedgerInboundPreviewRead:
    snapshot = calculate_production_batch_ledger_snapshot(
        db,
        production_ledger_id,
        smart_reporting_enabled=False,
        selected_finished_qty=to_decimal(finished_qty),
    )
    material = db.get(Item, snapshot.material_item_id)
    product = db.get(Item, snapshot.product_item_id)
    return ProductionBatchLedgerInboundPreviewRead(
        production_ledger_id=snapshot.production_ledger_id,
        material_lot_no=snapshot.material_lot_no,
        product_item_id=snapshot.product_item_id,
        product_code=product.item_code if product else None,
        product_name=product.item_name if product else None,
        material_item_id=snapshot.material_item_id,
        material_code=material.item_code if material else None,
        material_name=material.item_name if material else None,
        gross_weight_per_piece_kg=float(snapshot.gross_weight_per_piece_kg),
        net_weight_per_piece_kg=float(snapshot.net_weight_per_piece_kg),
        total_issued_weight_kg=float(snapshot.total_issued_weight_kg),
        outside_weight_kg=float(snapshot.outside_weight_kg),
        finished_inbound_qty=float(snapshot.finished_inbound_qty),
        surplus_inbound_weight_kg=float(snapshot.surplus_inbound_weight_kg),
        scrap_inbound_weight_kg=float(snapshot.scrap_inbound_weight_kg),
        writeoff_weight_kg=float(snapshot.writeoff_weight_kg),
        reference_finished_qty=float(snapshot.reference_finished_qty),
        used_material_weight_kg=float(snapshot.used_material_weight_kg),
        theoretical_surplus_weight_kg=float(snapshot.theoretical_surplus_weight_kg),
        theoretical_scrap_weight_kg=float(snapshot.theoretical_scrap_weight_kg),
    )


@router.post("/production-ledger-inbounds", response_model=ProductionBatchLedgerInboundRead)
def create_production_batch_ledger_inbound(
    payload: ProductionBatchLedgerInboundCreate,
    context: AuthContext = Depends(require_authenticated_user),
    db: Session = Depends(get_db),
) -> ProductionBatchLedgerInboundRead:
    finished_qty = to_decimal(payload.finished_qty)
    surplus_weight = to_decimal(payload.surplus_weight_kg)
    scrap_weight = to_decimal(payload.scrap_weight_kg)
    if finished_qty <= 0 and surplus_weight <= 0 and scrap_weight <= 0 and not payload.lock_material_batch:
        raise HTTPException(status_code=400, detail="请至少填写一种入库数量或勾选该批材料结单")

    ledger: ProductionBatchLedger | None = db.get(ProductionBatchLedger, payload.production_ledger_id)
    if not ledger:
        raise HTTPException(status_code=404, detail="生产台账不存在")
    source_lot = db.get(StockLot, ledger.material_lot_id)
    material = db.get(Item, ledger.material_item_id)
    product = db.get(Item, ledger.product_item_id)
    if not source_lot or not material or not product:
        raise HTTPException(status_code=400, detail="生产台账来源批次、原材料或产品数据不完整")
    snapshot = calculate_production_batch_ledger_snapshot(
        db,
        payload.production_ledger_id,
        smart_reporting_enabled=False,
        selected_finished_qty=finished_qty,
    )
    deviation_remark = str(payload.deviation_remark or "").strip()
    total_finished_qty = to_decimal(snapshot.finished_inbound_qty) + finished_qty
    reference_finished_qty = to_decimal(snapshot.reference_finished_qty)
    if reference_finished_qty <= 0:
        finished_exceeded = total_finished_qty > 0
    else:
        finished_exceeded = total_finished_qty > reference_finished_qty * Decimal("1.15")

    def _deviation_exceeded(actual: Decimal, theoretical: Decimal) -> bool:
        actual = to_decimal(actual)
        theoretical = to_decimal(theoretical)
        if theoretical == 0:
            return actual != 0
        return abs(actual - theoretical) / abs(theoretical) > Decimal("0.15")

    if finished_exceeded and not deviation_remark:
        raise HTTPException(status_code=400, detail="成品入库数量超过本生产台账参考生产数量上限15%，请填写偏差说明")
    if (
        _deviation_exceeded(surplus_weight, to_decimal(snapshot.theoretical_surplus_weight_kg))
        or _deviation_exceeded(scrap_weight, to_decimal(snapshot.theoretical_scrap_weight_kg))
    ) and not deviation_remark:
        raise HTTPException(status_code=400, detail="余料或废料入库重量与理论值偏差超过上下15%，请填写偏差说明")
    gross_weight = to_decimal(snapshot.gross_weight_per_piece_kg)
    net_weight = to_decimal(snapshot.net_weight_per_piece_kg)
    if net_weight <= 0:
        net_weight = to_decimal(product.unit_weight_kg)
    if net_weight <= 0:
        net_weight = gross_weight
    if gross_weight <= 0 and finished_qty > 0:
        raise HTTPException(status_code=400, detail="产品需用清单缺少单件毛重，不能进行生产台账入库")

    now = datetime.now()
    locked_check_warehouse_ids: set[int] = set()
    finished_warehouse = _get_active_warehouse(db, "FINISHED", "生产台账入库") if finished_qty > 0 else None
    scrap_warehouse = _get_active_warehouse(db, "SCRAP", "生产台账入库") if scrap_weight > 0 else None
    if finished_warehouse:
        locked_check_warehouse_ids.add(int(finished_warehouse.id))
    if surplus_weight > 0:
        locked_check_warehouse_ids.add(int(source_lot.warehouse_id))
    if scrap_warehouse:
        locked_check_warehouse_ids.add(int(scrap_warehouse.id))
    ensure_warehouses_unlocked(db, locked_check_warehouse_ids, "生产台账入库")

    finished_lot_id = surplus_lot_id = scrap_lot_id = None
    finished_txn_id = surplus_txn_id = scrap_txn_id = None
    base_remark = payload.remark or "生产台账入库"
    if payload.deviation_remark:
        base_remark = f"{base_remark}；偏差说明：{payload.deviation_remark}"
    document_batch_no = _production_document_batch_no(now) if (finished_qty > 0 or surplus_weight > 0 or scrap_weight > 0) else None

    if finished_qty > 0:
        receipt_weight = to_decimal(finished_qty * net_weight)
        unit_cost = to_decimal(to_decimal(source_lot.unit_cost) * gross_weight, "0.0001")
        lot_no = _build_production_ledger_lot_no(db, "FGL", ledger, product, now)
        source_summary = f"{material.item_name}：{ledger.material_lot_no}"
        finished_lot = StockLot(
            lot_no=lot_no,
            parent_lot_id=source_lot.id,
            lot_role="FINISHED_FROM_RAW_BATCH",
            material_sub_batch_no=None,
            item_id=product.id,
            warehouse_id=finished_warehouse.id,
            location_id=None,
            source_doc_type="PRODUCTION_LEDGER_IN",
            source_doc_id=ledger.id,
            source_line_id=None,
            source_material_lot_id=source_lot.id,
            source_material_sub_batch_no=ledger.material_lot_no,
            source_material_summary=source_summary,
            inbound_qty=finished_qty,
            inbound_weight_kg=receipt_weight,
            remaining_qty=finished_qty,
            remaining_weight_kg=receipt_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=_with_document_batch_no(f"{base_remark}；成品入库；来源库存批次已记录", document_batch_no),
        )
        db.add(finished_lot)
        db.flush()
        upsert_stock_balance(
            db,
            item_id=product.id,
            warehouse_id=finished_warehouse.id,
            location_id=None,
            qty_delta=finished_qty,
            weight_delta=receipt_weight,
            available_qty_delta=finished_qty,
            available_weight_delta=receipt_weight,
            unit_cost=unit_cost,
        )
        finished_txn = create_inventory_txn(
            db,
            txn_type="FG_IN",
            item_id=product.id,
            warehouse_id=finished_warehouse.id,
            location_id=None,
            lot_id=finished_lot.id,
            qty_change=finished_qty,
            weight_change=receipt_weight,
            unit_cost=unit_cost,
            source_doc_type="生产台账",
            source_doc_id=ledger.id,
            source_line_id=finished_lot.id,
            biz_time=now,
            operator_user_id=context.user.id,
            remark=_with_document_batch_no(f"{base_remark}；成品入库", document_batch_no),
            amount_basis="QTY",
        )
        finished_lot_id = finished_lot.id
        finished_txn_id = finished_txn.id
        ledger = post_finished_inbound_to_ledger(
            db,
            production_ledger_id=payload.production_ledger_id,
            finished_qty=finished_qty,
            operator_user_id=context.user.id,
            source_doc_type="库存流水",
            source_doc_id=finished_txn.id,
            source_line_id=finished_lot.id,
            biz_time=now,
            remark=_with_document_batch_no(f"{base_remark}；成品入库联动生产台账", document_batch_no),
        )
    if surplus_weight > 0:
        source_lot.remaining_weight_kg = to_decimal(source_lot.remaining_weight_kg) + surplus_weight
        source_lot.status = "AVAILABLE"
        db.add(source_lot)
        upsert_stock_balance(
            db,
            item_id=material.id,
            warehouse_id=source_lot.warehouse_id,
            location_id=source_lot.location_id,
            qty_delta=Decimal("0"),
            weight_delta=surplus_weight,
            available_qty_delta=Decimal("0"),
            available_weight_delta=surplus_weight,
            unit_cost=to_decimal(source_lot.unit_cost),
        )
        surplus_txn = create_inventory_txn(
            db,
            txn_type="PRODUCTION_SURPLUS_IN",
            item_id=material.id,
            warehouse_id=source_lot.warehouse_id,
            location_id=source_lot.location_id,
            lot_id=source_lot.id,
            qty_change=Decimal("0"),
            weight_change=surplus_weight,
            unit_cost=to_decimal(source_lot.unit_cost),
            source_doc_type="生产台账",
            source_doc_id=ledger.id,
            source_line_id=source_lot.id,
            biz_time=now,
            operator_user_id=context.user.id,
            remark=_with_document_batch_no(f"{base_remark}；生产余料入库", document_batch_no),
            amount_basis="WEIGHT",
        )
        surplus_lot_id = source_lot.id
        surplus_txn_id = surplus_txn.id
        ledger = post_surplus_inbound_to_ledger(
            db,
            production_ledger_id=payload.production_ledger_id,
            surplus_weight_kg=surplus_weight,
            operator_user_id=context.user.id,
            source_doc_type="库存流水",
            source_doc_id=surplus_txn.id,
            source_line_id=source_lot.id,
            biz_time=now,
            remark=_with_document_batch_no(f"{base_remark}；生产余料入库联动生产台账", document_batch_no),
        )
    if scrap_weight > 0:
        scrap_lot_no = _build_production_ledger_lot_no(db, "SCRAPI", ledger, material, now)
        scrap_lot = StockLot(
            lot_no=scrap_lot_no,
            parent_lot_id=source_lot.id,
            lot_role="PRODUCTION_SCRAP",
            material_sub_batch_no=None,
            item_id=material.id,
            warehouse_id=scrap_warehouse.id,
            location_id=None,
            source_doc_type="PRODUCTION_SCRAP_IN",
            source_doc_id=ledger.id,
            source_line_id=None,
            source_material_lot_id=source_lot.id,
            source_material_sub_batch_no=ledger.material_lot_no,
            source_material_summary=f"{material.item_name}：{ledger.material_lot_no}",
            inbound_qty=Decimal("0"),
            inbound_weight_kg=scrap_weight,
            remaining_qty=Decimal("0"),
            remaining_weight_kg=scrap_weight,
            locked_qty=Decimal("0"),
            locked_weight_kg=Decimal("0"),
            unit_cost=to_decimal(source_lot.unit_cost),
            production_date=now.date(),
            expire_date=None,
            quality_status="SCRAP",
            status="AVAILABLE",
            remark=_with_document_batch_no(f"{base_remark}；生产废料入库；来源库存批次已记录", document_batch_no),
        )
        db.add(scrap_lot)
        db.flush()
        upsert_stock_balance(
            db,
            item_id=material.id,
            warehouse_id=scrap_warehouse.id,
            location_id=None,
            qty_delta=Decimal("0"),
            weight_delta=scrap_weight,
            available_qty_delta=Decimal("0"),
            available_weight_delta=scrap_weight,
            unit_cost=to_decimal(source_lot.unit_cost),
        )
        scrap_txn = create_inventory_txn(
            db,
            txn_type="PRODUCTION_SCRAP_IN",
            item_id=material.id,
            warehouse_id=scrap_warehouse.id,
            location_id=None,
            lot_id=scrap_lot.id,
            qty_change=Decimal("0"),
            weight_change=scrap_weight,
            unit_cost=to_decimal(source_lot.unit_cost),
            source_doc_type="生产台账",
            source_doc_id=ledger.id,
            source_line_id=scrap_lot.id,
            biz_time=now,
            operator_user_id=context.user.id,
            remark=_with_document_batch_no(f"{base_remark}；生产废料入库", document_batch_no),
            amount_basis="WEIGHT",
        )
        scrap_lot_id = scrap_lot.id
        scrap_txn_id = scrap_txn.id
        ledger = post_scrap_inbound_to_ledger(
            db,
            production_ledger_id=payload.production_ledger_id,
            scrap_weight_kg=scrap_weight,
            operator_user_id=context.user.id,
            source_doc_type="库存流水",
            source_doc_id=scrap_txn.id,
            source_line_id=scrap_lot.id,
            biz_time=now,
            remark=_with_document_batch_no(f"{base_remark}；生产废料入库联动生产台账", document_batch_no),
        )
    if payload.lock_material_batch:
        if not payload.lock_confirmed:
            raise HTTPException(status_code=400, detail="请确认该批材料结单后再提交")
        ledger = lock_production_batch_ledger(
            db,
            production_ledger_id=payload.production_ledger_id,
            operator_user_id=context.user.id,
            biz_time=now,
            remark=payload.deviation_remark or payload.remark or "生产台账入库后该批材料结单",
        )
    response_ledger_id = int(ledger.id)
    response_material_lot_no = str(ledger.material_lot_no)
    response_status = str(ledger.status)
    main_txn_id = finished_txn_id or surplus_txn_id or scrap_txn_id
    db.commit()

    archive_result: DocumentArchiveGenerateResult | None = None
    if main_txn_id:
        try:
            archive_result = generate_document_archive(
                db,
                DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT,
                int(main_txn_id),
                created_by=context.user.id,
            )
        except Exception as exc:
            archive_result = _failed_archive_result(
                DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT,
                int(main_txn_id),
                f"库存流水#{main_txn_id}",
                exc,
            )

    response = ProductionBatchLedgerInboundRead(
        production_ledger_id=response_ledger_id,
        material_lot_no=response_material_lot_no,
        finished_qty=float(finished_qty),
        surplus_weight_kg=float(surplus_weight),
        scrap_weight_kg=float(scrap_weight),
        finished_lot_id=finished_lot_id,
        surplus_lot_id=surplus_lot_id,
        scrap_lot_id=scrap_lot_id,
        finished_txn_id=finished_txn_id,
        surplus_txn_id=surplus_txn_id,
        scrap_txn_id=scrap_txn_id,
        locked=bool(payload.lock_material_batch),
        status=response_status,
        remark=payload.remark,
    )
    for key, value in _archive_fields_from_result(
        archive_result,
        document_type=DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT,
        business_id=int(main_txn_id) if main_txn_id else None,
    ).items():
        setattr(response, key, value)
    return response


@router.get("/work-orders", response_model=list[WorkOrderRead])
def list_work_orders(
    limit: int = Query(default=100, ge=1, le=500),
    db: Session = Depends(get_db),
) -> list[WorkOrderRead]:
    if sync_recent_work_order_reference_qty(db, limit=limit):
        db.commit()
    rows = db.execute(get_work_orders_query(limit=limit)).mappings().all()
    return [WorkOrderRead.model_validate(dict(row)) for row in rows]


@router.get("/work-order-materials", response_model=list[WorkOrderMaterialRead])
def list_work_order_materials(
    work_order_id: int | None = Query(default=None),
    limit: int = Query(default=200, ge=1, le=500),
    db: Session = Depends(get_db),
) -> list[WorkOrderMaterialRead]:
    rows = db.execute(get_work_order_materials_query(limit=limit, work_order_id=work_order_id)).mappings().all()
    return [WorkOrderMaterialRead.model_validate(dict(row)) for row in rows]


@router.get("/work-order-material-issues", response_model=list[WorkOrderMaterialIssueRead])
def list_work_order_material_issues(
    work_order_id: int | None = Query(default=None),
    limit: int = Query(default=300, ge=1, le=800),
    db: Session = Depends(get_db),
) -> list[WorkOrderMaterialIssueRead]:
    rows = db.execute(get_work_order_material_issues_query(limit=limit, work_order_id=work_order_id)).mappings().all()
    return [WorkOrderMaterialIssueRead.model_validate(dict(row)) for row in rows]


@router.get("/work-order-operations", response_model=list[WorkOrderOperationRead])
def list_work_order_operations(
    work_order_id: int | None = Query(default=None),
    limit: int = Query(default=200, ge=1, le=500),
    db: Session = Depends(get_db),
) -> list[WorkOrderOperationRead]:
    if work_order_id and sync_work_order_reference_qty(db, work_order_id):
        db.commit()
    rows = db.execute(get_work_order_operations_query(limit=limit, work_order_id=work_order_id)).mappings().all()
    return [WorkOrderOperationRead.model_validate(dict(row)) for row in rows]


@router.post("/work-orders", response_model=WorkOrderRead)
def create_work_order(
    payload: WorkOrderCreate,
    context: AuthContext = Depends(require_authenticated_user),
    db: Session = Depends(get_db),
) -> WorkOrderRead:
    product = db.get(Item, payload.product_item_id)
    if not product:
        raise HTTPException(status_code=404, detail="产品不存在")
    if payload.source_sales_order_item_id:
        source_order_item = db.get(SalesOrderItem, payload.source_sales_order_item_id)
        if not source_order_item:
            raise HTTPException(status_code=404, detail="来源订单明细不存在")

    selected_lots = [
        {
            "source_lot_id": int(row.source_lot_id),
            "material_item_id": int(row.material_item_id) if row.material_item_id else None,
            "issued_weight_kg": to_decimal(row.issued_weight_kg),
        }
        for row in payload.selected_stock_lots
    ]
    if not selected_lots:
        raise HTTPException(status_code=400, detail="生产出库必须选择库存批次号")
    if len(selected_lots) != 1:
        raise HTTPException(status_code=400, detail="一次生产出库只能选择一个材料库存批次号")
    route = get_default_route(db, payload.product_item_id)
    bom = get_default_bom(db, payload.product_item_id)
    bom_items = db.scalars(select(BomItem).where(BomItem.bom_id == bom.id).order_by(BomItem.seq_no)).all()
    if not bom_items:
        raise HTTPException(status_code=400, detail="产品需规缺少默认用料，无法按库存批次出库")

    selected_lot_payload = selected_lots[0]
    selected_lot = db.scalar(
        select(StockLot)
        .where(StockLot.id == int(selected_lot_payload["source_lot_id"]))
        .with_for_update()
    )
    if not selected_lot:
        raise HTTPException(status_code=400, detail="库存批次不存在")
    selected_material_item_id = int(selected_lot_payload["material_item_id"] or selected_lot.item_id)
    if selected_material_item_id != int(selected_lot.item_id):
        raise HTTPException(status_code=400, detail=f"库存批次 {selected_lot.lot_no} 与选择的原材料不匹配")
    selected_lot_payload["material_item_id"] = selected_material_item_id
    bom_item_by_material_id = {int(item.material_item_id): item for item in bom_items}
    selected_bom_item = bom_item_by_material_id.get(selected_material_item_id)
    if not selected_bom_item:
        raise HTTPException(status_code=400, detail=f"库存批次 {selected_lot.lot_no} 不属于该产品需规默认用料")
    ensure_warehouses_unlocked(db, {int(selected_lot.warehouse_id)}, "生产出库")

    issue_weight = to_decimal(selected_lot_payload["issued_weight_kg"])
    if issue_weight <= 0:
        raise HTTPException(status_code=400, detail="出库重量必须大于0")

    planned_qty = to_decimal(payload.planned_qty)
    if issue_weight > 0:
        gross_weight = to_decimal(selected_bom_item.gross_weight_per_piece_kg)
        if gross_weight <= 0:
            gross_weight = to_decimal(product.unit_weight_kg)
        if gross_weight <= 0:
            raise HTTPException(status_code=400, detail="产品需用清单缺少单件毛重/净重，无法按领料重量计算参考生产数量")
        planned_qty = (issue_weight / gross_weight).to_integral_value(rounding=ROUND_FLOOR)
    if planned_qty <= 0:
        raise HTTPException(status_code=400, detail="参考生产数量必须大于0")
    biz_time = datetime.now()
    ledger = record_production_issue_to_ledger(
        db,
        material_lot_id=selected_lot.id,
        product_item_id=payload.product_item_id,
        issued_weight_kg=issue_weight,
        operator_user_id=context.user.id,
        source_doc_type="库存流水",
        source_doc_id=None,
        source_line_id=None,
        biz_time=biz_time,
        remark=payload.remark or "原材料库生产出库形成生产台账",
    )
    inventory_txn = _issue_raw_lot_to_production_ledger(
        db,
        lot=selected_lot,
        material_item_id=selected_material_item_id,
        issued_weight=issue_weight,
        production_ledger_id=ledger.id,
        operator_user_id=context.user.id,
        biz_time=biz_time,
    )
    _link_latest_issue_ledger_txn_to_inventory_txn(db, production_ledger_id=ledger.id, inventory_txn=inventory_txn)
    production_ledger_id = int(ledger.id)
    production_material_lot_no = str(ledger.material_lot_no)
    production_ledger_status = str(ledger.status)
    operation_count = db.scalar(select(func.count(ProcessRouteOperation.id)).where(ProcessRouteOperation.route_id == route.id)) or 0
    response = _build_production_ledger_work_order_compat_read(
        production_ledger_id=production_ledger_id,
        material_lot_no=production_material_lot_no,
        product=product,
        route=route,
        bom=bom,
        planned_qty=planned_qty,
        payload=payload,
        biz_time=biz_time,
        status=production_ledger_status,
        operation_count=int(operation_count),
    )
    db.commit()

    archive_result: DocumentArchiveGenerateResult | None = None
    try:
        archive_result = generate_document_archive(
            db,
            DOCUMENT_TYPE_PRODUCTION_MATERIAL_OUT,
            production_ledger_id,
            created_by=context.user.id,
        )
    except Exception as exc:
        archive_result = _failed_archive_result(
            DOCUMENT_TYPE_PRODUCTION_MATERIAL_OUT,
            production_ledger_id,
            f"生产台账#{production_ledger_id}",
            exc,
        )
    for key, value in _archive_fields_from_result(
        archive_result,
        document_type=DOCUMENT_TYPE_PRODUCTION_MATERIAL_OUT,
        business_id=production_ledger_id,
    ).items():
        setattr(response, key, value)
    return response


@router.get("/miniapp-operation-reports/sync-state", response_model=MiniAppOperationReportSyncStateRead)
def get_miniapp_operation_report_sync_state(
    db: Session = Depends(get_db),
) -> MiniAppOperationReportSyncStateRead:
    return MiniAppOperationReportSyncStateRead(last_synced_at=get_miniapp_operation_report_last_sync(db))


@router.post("/miniapp-operation-reports/sync", response_model=MiniAppOperationReportSyncRead)
def sync_miniapp_operation_reports(
    db: Session = Depends(get_db),
) -> MiniAppOperationReportSyncRead:
    lock_miniapp_operation_report_last_sync(db)
    sync_from = get_miniapp_operation_report_last_sync(db)
    sync_to = datetime.now()
    rows = _miniapp_report_item_rows(
        db,
        limit=None,
        updated_after=sync_from,
        updated_until=sync_to,
        order_by_updated_asc=True,
    )
    cache = _new_miniapp_resolve_cache()
    report_nos = [_miniapp_report_no(int(row["source_report_id"]), int(row["source_item_id"])) for row in rows]
    _preload_miniapp_operation_reports(db, report_nos, cache)

    touched_work_order_ids: set[int] = set()
    synced = 0
    skipped = 0
    for row in rows:
        synced_report = _sync_miniapp_item_to_erp(
            db,
            int(row["source_item_id"]),
            required=False,
            row=row,
            cache=cache,
            sync_status=False,
        )
        if synced_report:
            synced += 1
            if synced_report.work_order_id:
                touched_work_order_ids.add(int(synced_report.work_order_id))
        else:
            skipped += 1

    for work_order_id in touched_work_order_ids:
        sync_work_order_status(db, work_order_id)
    row = upsert_miniapp_operation_report_last_sync(db, sync_to)
    db.commit()
    db.refresh(row)
    return MiniAppOperationReportSyncRead(
        sync_from=sync_from,
        sync_to=sync_to,
        last_synced_at=sync_to,
        total=len(rows),
        synced=synced,
        skipped=skipped,
        failed=0,
    )


@router.get("/miniapp-operation-reports", response_model=list[MiniAppOperationReportRead])
def list_miniapp_operation_reports(
    limit: int = Query(default=300, ge=1, le=800),
    sync_to_erp: bool = Query(default=True),
    start_date: date | None = Query(default=None),
    end_date: date | None = Query(default=None),
    db: Session = Depends(get_db),
) -> list[MiniAppOperationReportRead]:
    if start_date and end_date and start_date > end_date:
        raise HTTPException(status_code=400, detail="开始日期不能晚于结束日期")
    rows = _miniapp_report_item_rows(db, limit=limit, start_date=start_date, end_date=end_date)
    cache = _new_miniapp_resolve_cache()
    report_nos = [_miniapp_report_no(int(row["source_report_id"]), int(row["source_item_id"])) for row in rows]
    _preload_miniapp_operation_reports(db, report_nos, cache)
    result: list[MiniAppOperationReportRead] = []
    touched_work_order_ids: set[int] = set()
    for row in rows:
        synced_report = None
        if sync_to_erp:
            synced_report = _sync_miniapp_item_to_erp(
                db,
                int(row["source_item_id"]),
                required=False,
                row=row,
                cache=cache,
                sync_status=False,
            )
            if synced_report and synced_report.work_order_id:
                touched_work_order_ids.add(int(synced_report.work_order_id))
        result.append(_build_miniapp_operation_report_read(db, row, synced_report, cache=cache, resolve_targets=sync_to_erp))
    if sync_to_erp:
        for work_order_id in touched_work_order_ids:
            sync_work_order_status(db, work_order_id)
        db.commit()
    return result


@router.get("/operation-reports", response_model=list[OperationReportRead])
def list_operation_reports(
    work_order_id: int | None = Query(default=None),
    limit: int = Query(default=200, ge=1, le=500),
    db: Session = Depends(get_db),
) -> list[OperationReportRead]:
    rows = db.execute(get_operation_reports_query(limit=limit, work_order_id=work_order_id)).mappings().all()
    return [_build_operation_report_read(dict(row)) for row in rows]


@router.post("/miniapp-scrap-records", response_model=ScrapRecordRead)
def create_miniapp_scrap_record(payload: MiniAppScrapRecordCreate, db: Session = Depends(get_db)) -> ScrapRecordRead:
    report = _sync_miniapp_item_to_erp(db, payload.source_item_id, required=True)
    if not report:
        raise HTTPException(status_code=400, detail="小程序报工无法同步为 ERP 报工")
    work_order = db.get(WorkOrder, report.work_order_id)
    if not work_order:
        raise HTTPException(status_code=404, detail="库存批次号不存在")
    source_row = _get_miniapp_report_item_row(db, payload.source_item_id)
    source_scrap_qty = _miniapp_effective_scrap_qty(source_row)
    scrap_qty = to_decimal(payload.scrap_qty) if to_decimal(payload.scrap_qty) > 0 else source_scrap_qty
    scrap_payload = ScrapRecordCreate(
        report_id=report.id,
        work_order_id=report.work_order_id,
        work_order_operation_id=report.work_order_operation_id,
        item_id=work_order.product_item_id,
        scrap_qty=float(scrap_qty),
        scrap_weight_kg=payload.scrap_weight_kg,
        scrap_reason=payload.scrap_reason,
        disposal_type="SCRAP",
        saleable_scrap_weight_kg=payload.saleable_scrap_weight_kg,
        scrap_cost_amount=payload.scrap_cost_amount,
    )
    return create_scrap_record(scrap_payload, db)


@router.post("/operation-reports", response_model=OperationReportRead)
def create_operation_report(
    payload: OperationReportCreate,
    db: Session = Depends(get_db),
) -> OperationReportRead:
    work_order = db.get(WorkOrder, payload.work_order_id)
    if not work_order:
        raise HTTPException(status_code=404, detail="工单不存在")
    operation = db.get(WorkOrderOperation, payload.work_order_operation_id)
    if not operation or operation.work_order_id != payload.work_order_id:
        raise HTTPException(status_code=400, detail="工单工序不存在或不属于当前工单")
    employee = db.get(Employee, payload.employee_id)
    if not employee:
        raise HTTPException(status_code=404, detail="员工不存在")
    if payload.equipment_id:
        equipment = db.get(Equipment, payload.equipment_id)
        if not equipment:
            raise HTTPException(status_code=404, detail="设备不存在")
    if payload.end_time <= payload.start_time:
        raise HTTPException(status_code=400, detail="报工结束时间必须晚于开始时间")

    report_qty = to_decimal(payload.report_qty)
    good_qty = to_decimal(payload.good_qty)
    scrap_qty = to_decimal(payload.scrap_qty)
    rework_qty = to_decimal(payload.rework_qty)
    if report_qty <= 0:
        raise HTTPException(status_code=400, detail="报工数量必须大于0")
    if good_qty < 0 or scrap_qty < 0 or rework_qty < 0:
        raise HTTPException(status_code=400, detail="报工数量、合格数、报废数、返工数不能小于0")
    if good_qty + scrap_qty + rework_qty > report_qty:
        raise HTTPException(status_code=400, detail="合格数、报废数、返工数合计不能超过报工数量")

    route_operation = db.get(ProcessRouteOperation, operation.route_operation_id) if operation.route_operation_id else None
    ref_cycle_seconds = to_decimal(route_operation.std_cycle_seconds if route_operation else 0, "0.01")
    actual_cycle_seconds = _calculate_actual_cycle_seconds(payload.start_time, payload.end_time, report_qty)
    abnormal_reasons: list[str] = []
    if report_qty > 0 and (scrap_qty / report_qty) > to_decimal(operation.allowed_scrap_rate, "0.0001"):
        abnormal_reasons.append("报废率超出工序允许值")
    if actual_cycle_seconds is not None and ref_cycle_seconds > 0 and Decimal(str(actual_cycle_seconds)) > ref_cycle_seconds:
        abnormal_reasons.append("实际节拍慢于参考节拍")
    abnormal = 1 if abnormal_reasons else 0

    report = OperationReport(
        report_no=_build_operation_report_no(db, work_order, operation, employee),
        work_order_id=payload.work_order_id,
        work_order_operation_id=payload.work_order_operation_id,
        employee_id=payload.employee_id,
        equipment_id=payload.equipment_id,
        report_source=payload.report_source,
        shift_code=payload.shift_code,
        start_time=payload.start_time,
        end_time=payload.end_time,
        report_qty=report_qty,
        good_qty=good_qty,
        scrap_qty=scrap_qty,
        rework_qty=rework_qty,
        scrap_reason=payload.scrap_reason,
        downtime_minutes=to_decimal(payload.downtime_minutes, "0.01"),
        extra_cost_amount=to_decimal(payload.extra_cost_amount, "0.01"),
        is_abnormal=abnormal,
        approved_by=None,
        approved_at=None,
        remark=payload.remark,
    )
    db.add(report)

    operation.report_qty = to_decimal(operation.report_qty) + report_qty
    operation.good_qty = to_decimal(operation.good_qty) + good_qty
    operation.scrap_qty = to_decimal(operation.scrap_qty) + scrap_qty
    operation.rework_qty = to_decimal(operation.rework_qty) + rework_qty
    if abnormal:
        operation.is_abnormal = 1
    if operation.report_qty > 0 and (to_decimal(operation.scrap_qty) / to_decimal(operation.report_qty)) > to_decimal(
        operation.allowed_scrap_rate, "0.0001"
    ):
        operation.is_abnormal = 1
    if to_decimal(operation.report_qty) >= to_decimal(operation.planned_qty):
        operation.status = "DONE"
    else:
        operation.status = "IN_PROGRESS"
    db.add(operation)

    if work_order.actual_start_time is None:
        work_order.actual_start_time = payload.start_time
    db.add(work_order)
    sync_work_order_status(db, work_order.id)
    db.commit()

    row = db.execute(get_operation_reports_query(limit=1).where(OperationReport.id == report.id)).mappings().first()
    if not row:
        raise HTTPException(status_code=500, detail="报工记录创建后读取失败")
    return _build_operation_report_read(dict(row))


@router.get("/scrap-records", response_model=list[ScrapRecordRead])
def list_scrap_records(
    limit: int = Query(default=200, ge=1, le=500),
    db: Session = Depends(get_db),
) -> list[ScrapRecordRead]:
    rows = db.execute(get_scrap_records_query(limit=limit)).mappings().all()
    return [ScrapRecordRead.model_validate(dict(row)) for row in rows]


@router.post("/scrap-records", response_model=ScrapRecordRead)
def create_scrap_record(payload: ScrapRecordCreate, db: Session = Depends(get_db)) -> ScrapRecordRead:
    report = db.get(OperationReport, payload.report_id)
    if not report:
        raise HTTPException(status_code=404, detail="报工记录不存在")
    if report.work_order_id != payload.work_order_id or report.work_order_operation_id != payload.work_order_operation_id:
        raise HTTPException(status_code=400, detail="处置记录与来源报工不匹配")
    work_order = db.get(WorkOrder, payload.work_order_id)
    if not work_order:
        raise HTTPException(status_code=404, detail="工单不存在")
    operation = db.get(WorkOrderOperation, payload.work_order_operation_id)
    if not operation or operation.work_order_id != payload.work_order_id:
        raise HTTPException(status_code=404, detail="工序不存在")
    item = db.get(Item, payload.item_id)
    if not item:
        raise HTTPException(status_code=404, detail="物料不存在")
    disposal_type = str(payload.disposal_type or "SCRAP").strip().upper()
    if disposal_type != "SCRAP":
        raise HTTPException(status_code=400, detail="生产过程只登记报废；客户退货返工请在发货概览模块处理")

    existing_active_record = _get_active_scrap_record(db, payload.report_id)
    if existing_active_record:
        _revert_scrap_record_effect(work_order, existing_active_record)
        existing_active_record.status = "OVERRIDDEN"
        db.add(existing_active_record)

    source_qty = to_decimal(report.rework_qty if disposal_type == "REWORK" and to_decimal(report.rework_qty) > 0 else report.scrap_qty)
    scrap_qty = to_decimal(payload.scrap_qty) if to_decimal(payload.scrap_qty) > 0 else source_qty
    if scrap_qty <= 0:
        raise HTTPException(status_code=400, detail="当前来源报工没有可处置的报废/返工数量")
    scrap_weight = to_decimal(payload.scrap_weight_kg)
    if scrap_weight <= 0:
        scrap_weight = scrap_qty * to_decimal(item.unit_weight_kg)

    saleable_scrap_weight = Decimal("0")
    scrap_cost_amount = Decimal("0")
    if disposal_type == "SCRAP":
        saleable_scrap_weight = (
            to_decimal(payload.saleable_scrap_weight_kg)
            if to_decimal(payload.saleable_scrap_weight_kg) > 0
            else scrap_weight
        )
        scrap_cost_amount = to_decimal(payload.scrap_cost_amount, "0.01")
        if scrap_cost_amount <= 0 and saleable_scrap_weight > 0:
            scrap_cost_amount = to_decimal(_get_average_issued_weight_cost(db, payload.work_order_id) * saleable_scrap_weight, "0.01")
    else:
        scrap_cost_amount = to_decimal(scrap_qty * to_decimal(operation.std_labor_cost, "0.0001"), "0.01")

    record = ScrapRecord(
        scrap_no=_build_scrap_no(db, work_order, disposal_type),
        report_id=payload.report_id,
        work_order_id=payload.work_order_id,
        work_order_operation_id=payload.work_order_operation_id,
        item_id=payload.item_id,
        scrap_qty=scrap_qty,
        scrap_weight_kg=scrap_weight,
        scrap_reason=payload.scrap_reason or report.scrap_reason,
        disposal_type=disposal_type,
        saleable_scrap_weight_kg=saleable_scrap_weight,
        scrap_cost_amount=scrap_cost_amount,
        status="CONFIRMED",
    )
    db.add(record)

    if disposal_type == "SCRAP":
        work_order.scrap_qty = to_decimal(work_order.scrap_qty) + scrap_qty
    db.add(work_order)
    sync_work_order_status(db, work_order.id)
    db.commit()

    row = db.execute(get_scrap_records_query(limit=1).where(ScrapRecord.id == record.id)).mappings().first()
    if not row:
        raise HTTPException(status_code=500, detail="报废/返工记录创建后读取失败")
    return ScrapRecordRead.model_validate(dict(row))


@router.get("/completion-receipts", response_model=list[CompletionReceiptRead])
def list_completion_receipts(
    limit: int = Query(default=100, ge=1, le=500),
    db: Session = Depends(get_db),
) -> list[CompletionReceiptRead]:
    rows = db.execute(get_completion_receipts_query(limit=limit)).mappings().all()
    return [CompletionReceiptRead.model_validate(dict(row)) for row in rows]


@router.get("/completion-items", response_model=list[CompletionReceiptItemRead])
def list_completion_receipt_items(
    receipt_id: int | None = Query(default=None),
    limit: int = Query(default=200, ge=1, le=500),
    db: Session = Depends(get_db),
) -> list[CompletionReceiptItemRead]:
    rows = db.execute(get_completion_receipt_items_query(limit=limit, receipt_id=receipt_id)).mappings().all()
    return [CompletionReceiptItemRead.model_validate(dict(row)) for row in rows]


def _build_work_order_source_material_summary(db: Session, work_order_id: int) -> dict[str, object | None]:
    rows = db.execute(get_work_order_material_issues_query(limit=500, work_order_id=work_order_id)).mappings().all()
    if not rows:
        return {
            "source_material_lot_id": None,
            "source_material_sub_batch_no": None,
            "source_material_summary": None,
            "source_material_weight_kg": Decimal("0"),
            "source_material_amount": Decimal("0"),
            "source_material_unit_cost": Decimal("0"),
        }
    first = rows[0]
    parts: list[str] = []
    total_weight = Decimal("0")
    total_amount = Decimal("0")
    for row in rows:
        weight = to_decimal(row["issued_weight_kg"])
        amount = to_decimal(row["amount"], "0.01")
        source_lot_no = row["source_lot_no"] or row["material_sub_batch_no"]
        parts.append(f"{source_lot_no} / {row['material_name']} / {weight}kg / ¥{amount}")
        if weight > 0:
            total_weight += weight
            total_amount += amount
    first_lot_no = first["source_lot_no"] or first["material_sub_batch_no"]
    material_unit_cost = (total_amount / total_weight) if total_weight > 0 else Decimal("0")
    return {
        "source_material_lot_id": first["source_lot_id"],
        "source_material_sub_batch_no": first_lot_no,
        "source_material_summary": "；".join(parts)[:1000],
        "source_material_weight_kg": to_decimal(total_weight, "0.000001"),
        "source_material_amount": to_decimal(total_amount, "0.01"),
        "source_material_unit_cost": to_decimal(material_unit_cost, "0.0001"),
    }


def _is_rework_work_order(work_order: WorkOrder) -> bool:
    return str(work_order.work_order_type or "").upper() == "REWORK"


def _build_rework_source_material_summary(db: Session, work_order_id: int) -> dict[str, object | None]:
    row = db.execute(
        select(
            ReturnDisposition.id.label("return_disposition_id"),
            ReturnItem.source_lot_id.label("source_lot_id"),
        )
        .join(ReturnItem, ReturnItem.id == ReturnDisposition.return_item_id)
        .where(ReturnDisposition.rework_work_order_id == work_order_id)
        .limit(1)
    ).mappings().first()
    if not row:
        raise HTTPException(status_code=400, detail="该返工工单缺少退货返工来源，不能返工入库")

    finished_lot = db.get(StockLot, row["source_lot_id"]) if row["source_lot_id"] else None
    raw_lot = db.get(StockLot, finished_lot.source_material_lot_id) if finished_lot and finished_lot.source_material_lot_id else None
    source_material_lot_id = finished_lot.source_material_lot_id if finished_lot else None
    source_material_sub_batch_no = (
        finished_lot.source_material_sub_batch_no
        if finished_lot and finished_lot.source_material_sub_batch_no
        else raw_lot.lot_no if raw_lot else None
    )
    source_material_summary = (
        finished_lot.source_material_summary
        if finished_lot and finished_lot.source_material_summary
        else raw_lot.lot_no if raw_lot else "退货返工来源"
    )
    source_material_unit_cost = to_decimal(raw_lot.unit_cost if raw_lot else finished_lot.unit_cost if finished_lot else 0, "0.0001")
    return {
        "source_material_lot_id": source_material_lot_id,
        "source_material_sub_batch_no": source_material_sub_batch_no or "退货返工来源批次未指定",
        "source_material_summary": f"退货返工；{source_material_summary}",
        "source_material_weight_kg": Decimal("0"),
        "source_material_amount": Decimal("0"),
        "source_material_unit_cost": source_material_unit_cost,
    }


def _get_work_order_gross_weight_per_piece(db: Session, work_order: WorkOrder, product: Item) -> Decimal:
    gross_weight = to_decimal(
        db.scalar(
            select(BomItem.gross_weight_per_piece_kg)
            .where(BomItem.bom_id == work_order.bom_id, BomItem.gross_weight_per_piece_kg > 0)
            .order_by(BomItem.seq_no, BomItem.id)
            .limit(1)
        ),
        "0.000001",
    )
    if gross_weight <= 0:
        gross_weight = to_decimal(product.unit_weight_kg, "0.000001")
    return gross_weight


def _calculate_finished_material_cost_per_piece(
    source_material: dict[str, object | None],
    gross_weight_per_piece: Decimal,
) -> Decimal:
    material_unit_cost = to_decimal(source_material.get("source_material_unit_cost"), "0.0001")
    if material_unit_cost <= 0 or gross_weight_per_piece <= 0:
        return Decimal("0")
    return to_decimal(material_unit_cost * gross_weight_per_piece, "0.0001")


def _append_finished_work_order_settle_remark(work_order: WorkOrder, settle_remark: str | None) -> None:
    existing = str(work_order.remark or "").strip()
    parts = [part.strip() for part in existing.replace(";", "；").split("；") if part.strip()]
    if "生产入库已结单" not in parts:
        parts.append("生产入库已结单")
    remark = str(settle_remark or "").strip()
    if remark:
        parts.append(f"生产入库结单说明：{remark}")
    unique_parts: list[str] = []
    for part in parts:
        if part not in unique_parts:
            unique_parts.append(part)
    work_order.remark = "；".join(unique_parts)[-255:]


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 _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 _settle_rework_work_order_if_ready(
    db: Session,
    work_order: WorkOrder,
    *,
    incoming_finished_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) + incoming_finished_qty + _rework_scrap_inbound_qty(db, work_order.id)
    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()
    work_order.updated_at = 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)


def _validate_finished_settle_request(
    payload: CompletionReceiptCreate,
    *,
    receipt_qty: Decimal,
    expected_receipt_qty: Decimal,
) -> None:
    if not payload.settle_work_order:
        return
    lower_limit = expected_receipt_qty * Decimal("0.85")
    upper_limit = expected_receipt_qty * INBOUND_TOLERANCE_MULTIPLIER
    if lower_limit <= receipt_qty <= upper_limit:
        return
    if not str(payload.settle_remark or "").strip():
        raise HTTPException(status_code=400, detail="该工单结单入库成品数量超出标准成品数量上下15%，请填写结单说明")


@router.post("/completion-receipts", response_model=CompletionReceiptRead)
def create_completion_receipt(
    payload: CompletionReceiptCreate,
    context: AuthContext = Depends(require_authenticated_user),
    db: Session = Depends(get_db),
) -> CompletionReceiptRead:
    work_order = db.get(WorkOrder, payload.work_order_id)
    if not work_order:
        raise HTTPException(status_code=404, detail="工单不存在")
    if str(work_order.status or "").upper() == "SETTLED":
        raise HTTPException(status_code=400, detail="该生产工单已结单，不能继续成品入库")
    warehouse = db.get(Warehouse, payload.warehouse_id)
    if not warehouse:
        raise HTTPException(status_code=404, detail="仓库不存在")
    ensure_warehouses_unlocked(db, [payload.warehouse_id], "成品入库")
    if not payload.items:
        raise HTTPException(status_code=400, detail="成品入库至少需要一条明细")

    is_rework = _is_rework_work_order(work_order)
    limit_snapshot = get_work_order_limit_snapshot(db, payload.work_order_id)
    max_receivable_qty = to_decimal(limit_snapshot.max_finished_inbound_qty)
    if max_receivable_qty <= 0:
        detail = "返工工单当前没有可返工入库数量" if is_rework else "该工单当前没有可入库的完工数量"
        raise HTTPException(status_code=400, detail=detail)

    total_receipt_qty = Decimal("0")
    for item_payload in payload.items:
        product = db.get(Item, item_payload.product_item_id)
        if not product:
            raise HTTPException(status_code=404, detail=f"产品不存在: {item_payload.product_item_id}")
        if item_payload.product_item_id != work_order.product_item_id:
            raise HTTPException(status_code=400, detail="成品入库产品必须与工单产品一致")
        receipt_qty = to_decimal(item_payload.receipt_qty)
        if receipt_qty <= 0:
            raise HTTPException(status_code=400, detail="入库数量必须大于0")
        total_receipt_qty += receipt_qty
    if is_rework:
        if total_receipt_qty > max_receivable_qty:
            raise HTTPException(status_code=400, detail=f"返工入库数量不能超过当前可返工入库数量 {max_receivable_qty}")
    else:
        validate_inbound_with_tolerance("成品入库数量", total_receipt_qty, max_receivable_qty)
    if not is_rework:
        _validate_finished_settle_request(
            payload,
            receipt_qty=total_receipt_qty,
            expected_receipt_qty=max_receivable_qty,
        )
    elif payload.settle_work_order:
        _settle_rework_work_order_if_ready(
            db,
            work_order,
            incoming_finished_qty=total_receipt_qty,
            settle_remark=payload.settle_remark,
        )

    if is_rework:
        source_material = _build_rework_source_material_summary(db, payload.work_order_id)
    else:
        source_material = _build_work_order_source_material_summary(db, payload.work_order_id)
        if not source_material["source_material_sub_batch_no"]:
            raise HTTPException(status_code=400, detail="该工单没有原材料库存批次领用记录，不能进行成品入库")
    ensure_employee_has_permission(
        db,
        payload.receiver_employee_id,
        "MENU_INVENTORY_LEDGER",
        "接收人",
        required=False,
    )
    completion = CompletionReceipt(
        receipt_no=_build_completion_receipt_no(db, work_order),
        work_order_id=payload.work_order_id,
        warehouse_id=payload.warehouse_id,
        receipt_time=payload.receipt_time or datetime.now(),
        receiver_employee_id=payload.receiver_employee_id,
        status="POSTED",
        remark=payload.remark,
    )
    db.add(completion)
    db.flush()

    remaining_receivable_qty = max_receivable_qty
    for index, item_payload in enumerate(payload.items, start=1):
        product = db.get(Item, item_payload.product_item_id)
        if not product:
            raise HTTPException(status_code=404, detail=f"产品不存在: {item_payload.product_item_id}")
        if item_payload.product_item_id != work_order.product_item_id:
            raise HTTPException(status_code=400, detail="成品入库产品必须与工单产品一致")
        location = get_location_or_default(db, payload.warehouse_id, item_payload.location_id)
        receipt_qty = to_decimal(item_payload.receipt_qty)
        if receipt_qty <= 0:
            raise HTTPException(status_code=400, detail="入库数量必须大于0")
        gross_weight = _get_work_order_gross_weight_per_piece(db, work_order, product)
        if gross_weight <= 0:
            raise HTTPException(status_code=400, detail="产品需用清单缺少单件毛重，无法计算成品入库材料成本")
        receipt_weight = receipt_qty * to_decimal(product.unit_weight_kg)
        unit_cost = _calculate_finished_material_cost_per_piece(source_material, gross_weight)
        lot_no = _build_finished_lot_no(db, work_order, product, completion.receipt_time, index)

        receipt_item = CompletionReceiptItem(
            completion_receipt_id=completion.id,
            line_no=index,
            product_item_id=item_payload.product_item_id,
            location_id=location.id if location else None,
            lot_no=lot_no,
            source_material_lot_id=source_material["source_material_lot_id"],
            source_material_sub_batch_no=source_material["source_material_sub_batch_no"],
            source_material_summary=source_material["source_material_summary"],
            receipt_qty=receipt_qty,
            receipt_weight_kg=receipt_weight,
            unit_cost=unit_cost,
            status="POSTED",
            remark=item_payload.remark,
        )
        db.add(receipt_item)
        db.flush()

        lot = StockLot(
            lot_no=lot_no,
            parent_lot_id=source_material["source_material_lot_id"],
            lot_role="FINISHED_FROM_RAW_BATCH",
            material_sub_batch_no=None,
            item_id=item_payload.product_item_id,
            warehouse_id=payload.warehouse_id,
            location_id=location.id if location else None,
            source_doc_type="REWORK_RECEIPT" if is_rework else "FG_RECEIPT",
            source_doc_id=completion.id,
            source_line_id=receipt_item.id,
            source_material_lot_id=source_material["source_material_lot_id"],
            source_material_sub_batch_no=source_material["source_material_sub_batch_no"],
            source_material_summary=source_material["source_material_summary"],
            inbound_qty=receipt_qty,
            inbound_weight_kg=receipt_weight,
            remaining_qty=receipt_qty,
            remaining_weight_kg=receipt_weight,
            locked_qty=to_decimal(0),
            locked_weight_kg=to_decimal(0),
            unit_cost=unit_cost,
            production_date=completion.receipt_time.date(),
            expire_date=None,
            quality_status="PASS",
            status="AVAILABLE",
            remark="返工入库；来源库存批次已记录" if is_rework else "完工入库；来源库存批次已记录",
        )
        db.add(lot)
        db.flush()

        upsert_stock_balance(
            db,
            item_id=item_payload.product_item_id,
            warehouse_id=payload.warehouse_id,
            location_id=location.id if location else None,
            qty_delta=receipt_qty,
            weight_delta=receipt_weight,
            available_qty_delta=receipt_qty,
            available_weight_delta=receipt_weight,
            unit_cost=unit_cost,
        )
        create_inventory_txn(
            db,
            txn_type="REWORK_FINISHED_IN" if is_rework else "FG_IN",
            item_id=item_payload.product_item_id,
            warehouse_id=payload.warehouse_id,
            location_id=location.id if location else None,
            lot_id=lot.id,
            qty_change=receipt_qty,
            weight_change=receipt_weight,
            unit_cost=unit_cost,
            source_doc_type="WORK_ORDER" if is_rework else "FG_RECEIPT",
            source_doc_id=work_order.id if is_rework else completion.id,
            source_line_id=receipt_item.id,
            biz_time=completion.receipt_time,
            operator_user_id=context.user.id,
            remark="返工入库" if is_rework else "完工入库",
            amount_basis="QTY",
        )

        work_order.finished_qty = to_decimal(work_order.finished_qty) + receipt_qty
        db.add(work_order)
        remaining_receivable_qty -= receipt_qty

    if payload.settle_work_order:
        if not is_rework:
            work_order.status = "SETTLED"
            work_order.actual_end_time = work_order.actual_end_time or datetime.now()
            work_order.updated_at = datetime.now()
            _append_finished_work_order_settle_remark(work_order, payload.settle_remark)
            db.add(work_order)
    else:
        sync_work_order_status(db, work_order.id)
    db.commit()

    row = db.execute(get_completion_receipts_query(limit=1).where(CompletionReceipt.id == completion.id)).mappings().first()
    if not row:
        raise HTTPException(status_code=500, detail="完工入库单创建后读取失败")
    return CompletionReceiptRead.model_validate(dict(row))
