from __future__ import annotations

import json
import re
import secrets
import hashlib
import traceback
import tkinter as tk
from copy import deepcopy
from datetime import datetime
from pathlib import Path
from tkinter import filedialog, messagebox, ttk

from app_core.paths import DB_PATH, DEBUG_DIR
from app_core.ui_helpers import configure_striped_treeview
from app_core.server_api import delete_bom_item as delete_bom_item_via_api
from app_core.server_api import fetch_app_setting, fetch_bom_items, save_app_setting, save_bom_item as save_bom_item_via_api
from database import (
    delete_bom_item,
    get_app_setting,
    list_bom_items,
    normalize_style_identifier,
    normalize_workflow_status,
    save_bom_item,
    set_app_setting,
    list_suppliers,
    summarize_purchase_order_requirements_by_style,
)
from fds_parser import FDSParseError, parse_fds_workbook

BOM_SORT_KEY_OPTIONS = [
    ("created_at", "create_time_column"),
    ("item_code", "bom_item_code_column"),
    ("item_name", "bom_item_name_column"),
    ("item_type", "bom_item_type_column"),
    ("section", "bom_section_column"),
    ("booking_required_qty", "tree_total_qty"),
    ("production_required_qty", "tree_total_qty"),
    ("settlement_required_qty", "tree_total_qty"),
    ("ordered_qty", "total_packs_ordered"),
    ("received_qty", "tab_items"),
    ("issued_qty", "tab_notes"),
    ("on_hand_qty", "tree_total_qty"),
    ("shortage_qty", "tree_total_qty"),
    ("default_supplier", "bom_supplier_column"),
    ("status", "active_status"),
    ("updated_at", "revised_time_column"),
]

BOM_FILTER_FIELD_OPTIONS = [
    ("__all__", "samples_filter_all_option"),
    ("item_code", "bom_item_code_column"),
    ("item_name", "bom_item_name_column"),
    ("item_type", "bom_item_type_column"),
    ("section", "bom_section_column"),
    ("po_summary", "po_number"),
    ("article_ref", "bom_article_column"),
    ("color", "samples_color_column"),
    ("booking_required_qty", "tree_total_qty"),
    ("production_required_qty", "tree_total_qty"),
    ("settlement_required_qty", "tree_total_qty"),
    ("ordered_qty", "total_packs_ordered"),
    ("received_qty", "tab_items"),
    ("issued_qty", "tab_notes"),
    ("on_hand_qty", "tree_total_qty"),
    ("shortage_qty", "tree_total_qty"),
    ("default_supplier", "bom_supplier_column"),
    ("position_of_use", "bom_position_column"),
    ("status", "active_status"),
    ("created_at", "create_time_column"),
    ("updated_at", "revised_time_column"),
]

BOM_DATE_FILTER_FIELDS = {"created_at", "updated_at"}
BOM_COLUMNS = (
    "select_flag",
    "created_at",
    "item_code",
    "item_name",
    "item_type",
    "section",
    "po_summary",
    "booking_required_qty",
    "production_required_qty",
    "settlement_required_qty",
    "ordered_qty",
    "received_qty",
    "issued_qty",
    "on_hand_qty",
    "shortage_qty",
    "article_ref",
    "color",
    "spec_summary",
    "uom",
    "default_supplier",
    "status",
    "updated_at",
    "latest_user",
)
BOM_FILTER_DEBOUNCE_MS = 180

ITEM_TYPE_OPTIONS = ["Fabric", "Trim", "Label", "Packaging", "Artwork", "Other"]
ITEM_STATUS_OPTIONS = ["draft", "checking", "approved"]
MATERIAL_DOCUMENT_STATUS_OPTIONS = ["draft", "checking", "approved"]
MATERIAL_PO_STATUS_OPTIONS = ["draft", "checking", "approved"]
PIECE_UOM_OPTIONS = ["EA", "SET", "CONE"]
LENGTH_UOM_OPTIONS = ["MM", "CM", "M", "INCH", "YARD"]
WEIGHT_UOM_OPTIONS = ["G", "KG", "LB", "OZ"]
BOM_CURRENCY_OPTIONS = ["USD", "VND"]
BOM_CHARGE_UNIT_OPTIONS = ["%", "USD", "VND"]
LEAD_TIME_UNIT_OPTIONS = ["DAY", "WEEK", "MONTH"]
PURCHASE_UOM_OPTIONS = PIECE_UOM_OPTIONS + LENGTH_UOM_OPTIONS + WEIGHT_UOM_OPTIONS
BOM_UOM_OPTIONS_BY_ITEM_TYPE = {
    "fabric": LENGTH_UOM_OPTIONS + WEIGHT_UOM_OPTIONS,
    "trim": PIECE_UOM_OPTIONS + LENGTH_UOM_OPTIONS,
    "label": PIECE_UOM_OPTIONS,
    "packaging": PIECE_UOM_OPTIONS,
    "artwork": PIECE_UOM_OPTIONS,
    "other": PIECE_UOM_OPTIONS + LENGTH_UOM_OPTIONS,
}
SECTION_TYPE_MAP = {
    "MATERIAL": "Fabric",
    "TRIM": "Trim",
    "LABELS": "Label",
    "PACKAGING": "Packaging",
    "ARTWORK": "Artwork",
}
SECTION_CODE_MAP = {
    "MATERIAL": "MAT",
    "TRIM": "TRM",
    "LABELS": "LBL",
    "PACKAGING": "PKG",
    "ARTWORK": "ART",
}
BOM_APP_SETTING_KEY = "bom_items"
MATERIAL_PO_APP_SETTING_KEY = "material_po_records"
MATERIAL_RECEIPT_APP_SETTING_KEY = "material_receipt_records"
MATERIAL_ISSUE_APP_SETTING_KEY = "material_issue_records"
WAREHOUSE_APP_SETTING_KEY = "warehouse_records"
_BOM_DEBUG_LOG_PATH = DEBUG_DIR / "bom_debug.log"
try:
    if _BOM_DEBUG_LOG_PATH.exists():
        _BOM_DEBUG_LOG_PATH.unlink()
except Exception:
    pass


def _append_bom_debug_log(message: str) -> None:
    try:
        DEBUG_DIR.mkdir(parents=True, exist_ok=True)
        with _BOM_DEBUG_LOG_PATH.open("a", encoding="utf-8") as handle:
            handle.write(f"[{_bom_now_text()}] {message}\n")
    except Exception:
        pass


def _bom_now_text() -> str:
    return datetime.now().strftime("%Y-%m-%d %H:%M:%S")


def _safe_float_text(value: object) -> str:
    text = str(value or "").strip()
    if not text:
        return ""
    try:
        return str(float(text))
    except Exception:
        return text


def _safe_float_value(value: object) -> float:
    text = str(value or "").strip().replace(",", "")
    if not text:
        return 0.0
    match = re.search(r"-?\d+(?:\.\d+)?", text)
    if match:
        text = match.group(0)
    try:
        return float(text)
    except Exception:
        return 0.0


def _format_metric_text(value: float) -> str:
    normalized = round(float(value or 0.0), 4)
    if abs(normalized - round(normalized)) < 1e-9:
        return str(int(round(normalized)))
    return f"{normalized:.4f}".rstrip("0").rstrip(".")


def _format_metric_with_uom(value: float, uom: object) -> str:
    text = _format_metric_text(value)
    normalized_uom = str(uom or "").strip().upper()
    return f"{text} {normalized_uom}".strip() if normalized_uom else text


def _is_material_po_status_active(status: object) -> bool:
    normalized = str(status or "").strip().lower()
    return normalized not in {"cancelled", "canceled", "closed", "done"}


def _is_material_po_approved(status: object) -> bool:
    normalized = str(status or "").strip().lower()
    return normalized == "approved"


def _is_material_receipt_approved(status: object) -> bool:
    normalized = str(status or "").strip().lower()
    return normalized == "approved"


_LENGTH_UOM_FACTORS_TO_M = {
    "MM": 0.001,
    "CM": 0.01,
    "M": 1.0,
    "INCH": 0.0254,
    "YARD": 0.9144,
}
_WEIGHT_UOM_FACTORS_TO_KG = {
    "G": 0.001,
    "KG": 1.0,
    "LB": 0.45359237,
    "OZ": 0.028349523125,
}


def _uom_conversion_context(uom: object) -> tuple[str, str, float]:
    normalized_uom = str(uom or "").strip().upper()
    if normalized_uom in _LENGTH_UOM_FACTORS_TO_M:
        return "length", "M", _LENGTH_UOM_FACTORS_TO_M[normalized_uom]
    if normalized_uom in _WEIGHT_UOM_FACTORS_TO_KG:
        return "weight", "KG", _WEIGHT_UOM_FACTORS_TO_KG[normalized_uom]
    if normalized_uom == "EA":
        return "piece:EA", "EA", 1.0
    if normalized_uom == "SET":
        return "piece:SET", "SET", 1.0
    if normalized_uom:
        return f"raw:{normalized_uom}", normalized_uom, 1.0
    return "raw:", "", 1.0


def _convert_quantity_to_common_uom(value: float, from_uom: object, common_uom: object) -> float:
    normalized_value = float(value or 0.0)
    if abs(normalized_value) <= 1e-12:
        return 0.0
    from_family, from_common, from_factor = _uom_conversion_context(from_uom)
    target_family, target_common, target_factor = _uom_conversion_context(common_uom)
    if from_family == target_family and from_common == target_common:
        return normalized_value * (from_factor / max(target_factor, 1e-12))
    if not str(from_uom or "").strip() or not str(common_uom or "").strip():
        return normalized_value
    return normalized_value


def _normalize_material_identity_text(value: object) -> str:
    return " ".join(str(value or "").strip().upper().split())


def _build_bom_shared_material_key(row: dict) -> tuple[str, ...]:
    normalized_uom = str(row.get("uom") or row.get("purchase_uom") or "").strip().upper()
    identifier = (
        _normalize_material_identity_text(row.get("supplier_item_code"))
        or _normalize_material_identity_text(row.get("article_ref"))
        or "|".join(
            part
            for part in (
                _normalize_material_identity_text(row.get("item_name")),
                _normalize_material_identity_text(row.get("composition")),
            )
            if part
        )
        or _normalize_material_identity_text(row.get("item_code"))
    )
    uom_family, common_uom, _factor = _uom_conversion_context(normalized_uom)
    return (
        _normalize_material_identity_text(row.get("item_type")),
        identifier,
        _normalize_material_identity_text(row.get("color")),
        _normalize_material_identity_text(row.get("color_code")),
        uom_family,
        common_uom,
    )


def _parse_qty_and_uom(value: object) -> tuple[str, str]:
    text = str(value or "").strip()
    if not text:
        return "", ""
    parts = text.split()
    if not parts:
        return "", ""
    first = parts[0].replace(",", "")
    try:
        qty = str(float(first))
        if qty.endswith(".0"):
            qty = qty[:-2]
        return qty, " ".join(parts[1:]).strip()
    except Exception:
        return "", text


def _split_measurement_and_unit(value: object) -> tuple[str, str]:
    text = str(value or "").strip()
    if not text:
        return "", ""
    parts = text.split()
    if len(parts) >= 2:
        return " ".join(parts[:-1]).strip(), parts[-1].strip().upper()
    return text, ""


def _bom_uom_options_for_item_type(item_type: object, current_value: object = "") -> list[str]:
    item_type_key = str(item_type or "").strip().lower()
    values = list(BOM_UOM_OPTIONS_BY_ITEM_TYPE.get(item_type_key, PIECE_UOM_OPTIONS + LENGTH_UOM_OPTIONS))
    current_text = str(current_value or "").strip().upper()
    if current_text and current_text not in values:
        values.insert(0, current_text)
    return values


def _purchase_uom_options(current_value: object = "") -> list[str]:
    values = list(PURCHASE_UOM_OPTIONS)
    current_text = str(current_value or "").strip().upper()
    if current_text and current_text not in values:
        values.insert(0, current_text)
    return values


def _default_trim_size_options(current_value: object = "") -> list[str]:
    values = ["ALL", "XXS", "XS", "S", "M", "L", "XL", "XXL", "3XL", "4XL", "5XL"]
    current_text = _normalize_trim_size_option(current_value)
    if _is_valid_trim_size_option(current_text) and current_text not in values:
        values.insert(1, current_text)
    return values


def _normalize_trim_size_option(value: object) -> str:
    return " ".join(str(value or "").strip().upper().split())


def _is_valid_trim_size_option(value: object) -> bool:
    normalized = _normalize_trim_size_option(value)
    if not normalized:
        return False
    if normalized == "ALL":
        return True
    return re.fullmatch(r"(XXS|XS|S|M|L|XL|XXL|XXXL|XXXXL|XXXXXL|[2-9]XL|[0-9]{1,2}X|[0-9]{1,2})", normalized) is not None


def get_bom_trim_size_options(self, style_code: object, current_value: object = "") -> list[str]:
    normalized_style = normalize_style_identifier(style_code)
    collected: list[str] = []
    library_rows = list(getattr(self, "tech_pack_library", []) or [])
    for record in library_rows:
        if not isinstance(record, dict):
            continue
        record_style = normalize_style_identifier(record.get("style_code"))
        if normalized_style and record_style != normalized_style:
            continue
        for pom_entry in list(record.get("pom_data", [])):
            if not isinstance(pom_entry, dict):
                continue
            for header in list(pom_entry.get("size_headers", [])):
                normalized_header = _normalize_trim_size_option(header)
                if _is_valid_trim_size_option(normalized_header) and normalized_header not in collected:
                    collected.append(normalized_header)
    if not collected:
        return _default_trim_size_options(current_value)
    if "ALL" not in collected:
        collected.insert(0, "ALL")
    current_text = _normalize_trim_size_option(current_value)
    if _is_valid_trim_size_option(current_text) and current_text not in collected:
        collected.append(current_text)
    return collected


def _is_bom_missing_endpoint_error(exc: Exception) -> bool:
    message = str(exc or "").strip().lower()
    return "http 404" in message or "404: not found" in message or message.endswith("404 not found")


def _is_bom_recoverable_source_error(exc: Exception) -> bool:
    message = str(exc or "").strip().lower()
    if _is_bom_missing_endpoint_error(exc):
        return True
    return any(
        token in message
        for token in (
            "http 500",
            "http 502",
            "http 503",
            "http 504",
            "internal server error",
            "bad gateway",
            "service unavailable",
            "gateway timeout",
            "connection failed",
            "timed out",
        )
    )


def _normalize_bom_item_list(payload: object) -> list[dict]:
    if not isinstance(payload, list):
        return []
    normalized_rows: list[dict] = []
    for item in payload:
        normalized = normalize_bom_item_payload(item)
        if isinstance(normalized, dict):
            normalized_rows.append(normalized)
    return normalized_rows


def sync_tech_pack_consumption_with_bom_status(self, bom_rows: list[dict] | None = None) -> int:
    library_rows = [dict(item) for item in list(getattr(self, "tech_pack_library", []) or []) if isinstance(item, dict)]
    if not library_rows and hasattr(self, "load_tech_pack_library_from_source"):
        try:
            loaded_rows = self.load_tech_pack_library_from_source()
            if isinstance(loaded_rows, list):
                library_rows = [dict(item) for item in loaded_rows if isinstance(item, dict)]
                self.tech_pack_library = list(library_rows)
        except Exception as exc:
            _append_bom_debug_log(f"sync-consumption load-library warning={exc}")
    if not library_rows:
        return 0
    normalized_bom_rows = [
        item
        for item in (normalize_bom_item_payload(item) for item in list(bom_rows or getattr(self, "bom_items", []) or []))
        if isinstance(item, dict)
    ]
    bom_by_id = {
        str(item.get("item_id", "")).strip(): dict(item)
        for item in normalized_bom_rows
        if str(item.get("item_id", "")).strip()
    }
    bom_by_code: dict[str, dict] = {}
    for item in normalized_bom_rows:
        item_code = str(item.get("item_code", "")).strip()
        if item_code and item_code not in bom_by_code:
            bom_by_code[item_code] = dict(item)
    approved_by_style: dict[str, list[dict]] = {}
    approved_item_ids: set[str] = set()
    approved_item_codes: set[str] = set()
    for item in normalized_bom_rows:
        if str(item.get("status", "")).strip().lower() != "approved":
            continue
        style_code = normalize_style_identifier(item.get("style_code"))
        item_id = str(item.get("item_id", "")).strip()
        item_code = str(item.get("item_code", "")).strip()
        if item_id:
            approved_item_ids.add(item_id)
        if item_code:
            approved_item_codes.add(item_code)
        if style_code and item_code:
            approved_by_style.setdefault(style_code, []).append(dict(item))

    updated_library_by_style = {
        normalize_style_identifier(item.get("style_code")): dict(item)
        for item in library_rows
        if normalize_style_identifier(item.get("style_code"))
    }
    sync_change_count = 0
    for record in library_rows:
        style_code = normalize_style_identifier(record.get("style_code"))
        if not style_code:
            continue
        versions = record.get("consumption_versions", [])
        version_by_type = {
            str(version.get("version_type", "")).strip().lower(): dict(version)
            for version in list(versions or [])
            if isinstance(version, dict) and str(version.get("version_type", "")).strip()
        }
        approved_items = approved_by_style.get(style_code, [])
        normalized_versions: list[dict] = []
        record_changed = False
        version_templates = [
            ("costing", "Costing"),
            ("sample", "Sample"),
            ("booking", "Booking"),
            ("production", "Production"),
            ("settlement", "Settlement"),
        ]
        for version_type, version_name in version_templates:
            version = dict(version_by_type.get(version_type, {}))
            source_lines = version.get("lines", [])
            if not isinstance(source_lines, list):
                source_lines = []
            kept_lines: list[dict] = []
            for line in source_lines:
                if not isinstance(line, dict):
                    continue
                working_line = dict(line)
                bom_item_id = str(working_line.get("bom_item_id", "")).strip()
                bom_item_code = str(working_line.get("bom_item_code", "")).strip()
                matched_bom = bom_by_id.get(bom_item_id) if bom_item_id else None
                if matched_bom is None and bom_item_code:
                    matched_bom = bom_by_code.get(bom_item_code)
                if isinstance(matched_bom, dict):
                    target_uom = str(matched_bom.get("purchase_uom") or matched_bom.get("uom") or "").strip().upper()
                    if target_uom and str(working_line.get("uom", "")).strip().upper() != target_uom:
                        working_line["uom"] = target_uom
                        sync_change_count += 1
                        record_changed = True
                if version_type not in {"booking", "production", "settlement"}:
                    kept_lines.append(working_line)
                    continue
                is_approved = (bom_item_id and bom_item_id in approved_item_ids) or (bom_item_code and bom_item_code in approved_item_codes)
                if is_approved:
                    kept_lines.append(working_line)
                else:
                    sync_change_count += 1
                    record_changed = True
            normalized_version = {
                "version_id": str(version.get("version_id", "")).strip() or version_type,
                "version_type": version_type,
                "version_name": str(version.get("version_name", "")).strip() or version_name,
            }
            normalized_version["lines"] = kept_lines
            normalized_versions.append(normalized_version)
        if record_changed:
            saved_record = self.save_tech_pack_record_patch_to_source(style_code, {"consumption_versions": normalized_versions})
            updated_library_by_style[style_code] = dict(saved_record) if isinstance(saved_record, dict) and saved_record else dict(record)
            _append_bom_debug_log(
                f"sync-consumption patched style={style_code} approved_count={len(approved_items)} change_count={sync_change_count}"
            )
    if sync_change_count:
        self.tech_pack_library = list(updated_library_by_style.values())
    return sync_change_count


def default_bom_item_payload() -> dict:
    return {
        "item_id": "",
        "bom_id": "",
        "style_code": "",
        "bom_version": "",
        "bom_name": "",
        "season": "",
        "collection": "",
        "item_code": "",
        "item_name": "",
        "item_type": "Other",
        "section": "",
        "article_ref": "",
        "color": "",
        "color_code": "",
        "description": "",
        "composition": "",
        "position_of_use": "",
        "spec_summary": "",
        "uom": "",
        "qty_per_garment": "",
        "trim_size_um": "",
        "trim_size_unit": "",
        "length_value": "",
        "length_unit": "",
        "width_value": "",
        "width_unit": "",
        "gsm_value": "",
        "gsm_unit": "",
        "waste_percent": "",
        "tech_comment": "",
        "default_supplier": "",
        "supplier_item_code": "",
        "moq": "",
        "moq_unit": "",
        "moq_charge": "",
        "moq_charge_unit": "",
        "mcq": "",
        "mcq_unit": "",
        "mcq_charge": "",
        "mcq_charge_unit": "",
        "lead_time": "",
        "lead_time_unit": "",
        "sample_lead_time": "",
        "sample_lead_time_unit": "",
        "purchase_uom": "",
        "standard_price": "",
        "currency": "",
        "purchase_note": "",
        "fds_date": "",
        "image_path": "",
        "status": "draft",
        "source_style_code": "",
        "source_section": "",
        "source_section_index": "",
        "source_item_index": "",
        "source_payload_json": "",
        "created_at": "",
        "updated_at": "",
    }


def normalize_bom_item_payload(payload: object) -> dict | None:
    if not isinstance(payload, dict):
        return None
    normalized = default_bom_item_payload()
    for key in normalized:
        value = payload.get(key, normalized[key])
        normalized[key] = str(value or "").strip()
    normalized["item_id"] = normalized["item_id"] or secrets.token_hex(16)
    normalized["style_code"] = normalize_style_identifier(normalized.get("style_code"))
    normalized["source_style_code"] = normalize_style_identifier(normalized.get("source_style_code"))
    normalized["item_type"] = normalized["item_type"] or "Other"
    normalized["status"] = str(normalized["status"] or "draft").strip().lower() or "draft"
    normalized["trim_size_unit"] = str(normalized.get("trim_size_unit", "")).strip().upper()
    normalized["length_unit"] = str(normalized.get("length_unit", "")).strip().upper()
    normalized["width_unit"] = str(normalized.get("width_unit", "")).strip().upper()
    normalized["gsm_unit"] = str(normalized.get("gsm_unit", "")).strip().upper()
    normalized["uom"] = str(normalized.get("uom", "")).strip().upper()
    normalized["purchase_uom"] = str(normalized.get("purchase_uom", "")).strip().upper()
    normalized["currency"] = str(normalized.get("currency", "")).strip().upper()
    normalized["moq_unit"] = str(normalized.get("moq_unit", "")).strip().upper()
    normalized["moq_charge_unit"] = str(normalized.get("moq_charge_unit", "")).strip().upper()
    normalized["mcq_unit"] = str(normalized.get("mcq_unit", "")).strip().upper()
    normalized["mcq_charge_unit"] = str(normalized.get("mcq_charge_unit", "")).strip().upper()
    normalized["lead_time_unit"] = str(normalized.get("lead_time_unit", "")).strip().upper()
    normalized["sample_lead_time_unit"] = str(normalized.get("sample_lead_time_unit", "")).strip().upper()
    normalized["qty_per_garment"] = _safe_float_text(normalized.get("qty_per_garment"))
    normalized["waste_percent"] = _safe_float_text(normalized.get("waste_percent"))
    normalized["moq"] = _safe_float_text(normalized.get("moq"))
    normalized["moq_charge"] = _safe_float_text(normalized.get("moq_charge"))
    normalized["mcq"] = _safe_float_text(normalized.get("mcq"))
    normalized["mcq_charge"] = _safe_float_text(normalized.get("mcq_charge"))
    normalized["standard_price"] = _safe_float_text(normalized.get("standard_price"))
    for key in (
        "po_summary",
        "booking_required_qty",
        "production_required_qty",
        "settlement_required_qty",
        "ordered_qty",
        "received_qty",
        "issued_qty",
        "on_hand_qty",
        "shortage_qty",
    ):
        normalized[key] = str(payload.get(key, "") or "").strip()
    return normalized


def _load_store_rows(self, setting_key: str) -> list[dict]:
    if self.should_use_server_data_source():
        try:
            payload = fetch_app_setting(self.server_settings, setting_key, [])
        except Exception:
            payload = []
    else:
        payload = get_app_setting(DB_PATH, setting_key, [])
    return [dict(item) for item in list(payload) if isinstance(item, dict)]


def _save_store_rows(self, setting_key: str, rows: list[dict]) -> list[dict]:
    payload = [dict(item) for item in rows if isinstance(item, dict)]
    if self.should_use_server_data_source():
        saved = save_app_setting(self.server_settings, setting_key, payload)
    else:
        saved = set_app_setting(DB_PATH, setting_key, payload)
    return [dict(item) for item in list(saved) if isinstance(item, dict)]


def default_material_po_payload() -> dict:
    return {
        "record_id": "",
        "mpo_number": "",
        "order_date": "",
        "eta_date": "",
        "supplier_name": "",
        "style_code": "",
        "item_id": "",
        "item_code": "",
        "item_name": "",
        "linked_po_numbers": "",
        "ordered_qty": "",
        "purchase_uom": "",
        "unit_price": "",
        "currency": "",
        "status": "draft",
        "note": "",
        "created_at": "",
        "updated_at": "",
    }


def normalize_material_po_row(payload: object) -> dict | None:
    if not isinstance(payload, dict):
        return None
    normalized = default_material_po_payload()
    for key in normalized:
        normalized[key] = str(payload.get(key, normalized[key]) or "").strip()
    normalized["record_id"] = normalized["record_id"] or secrets.token_hex(16)
    normalized["style_code"] = normalize_style_identifier(normalized.get("style_code"))
    normalized["status"] = str(normalized.get("status") or "draft").strip().lower() or "draft"
    if normalized["status"] not in MATERIAL_DOCUMENT_STATUS_OPTIONS:
        normalized["status"] = "draft"
    normalized["ordered_qty"] = _safe_float_text(normalized.get("ordered_qty"))
    normalized["unit_price"] = _safe_float_text(normalized.get("unit_price"))
    if not normalized["item_code"] and not normalized["item_name"]:
        return None
    return normalized


def default_material_receipt_payload() -> dict:
    return {
        "receipt_id": "",
        "receipt_number": "",
        "receipt_date": "",
        "po_record_id": "",
        "mpo_number": "",
        "supplier_name": "",
        "warehouse_code": "",
        "style_code": "",
        "item_id": "",
        "item_code": "",
        "item_name": "",
        "received_qty": "",
        "uom": "",
        "lot_no": "",
        "status": "draft",
        "note": "",
        "created_at": "",
        "updated_at": "",
    }


def normalize_material_receipt_row(payload: object) -> dict | None:
    if not isinstance(payload, dict):
        return None
    normalized = default_material_receipt_payload()
    for key in normalized:
        normalized[key] = str(payload.get(key, normalized[key]) or "").strip()
    normalized["receipt_id"] = normalized["receipt_id"] or secrets.token_hex(16)
    normalized["style_code"] = normalize_style_identifier(normalized.get("style_code"))
    normalized["received_qty"] = _safe_float_text(normalized.get("received_qty"))
    normalized["status"] = str(normalized.get("status") or "draft").strip().lower() or "draft"
    normalized["warehouse_code"] = str(normalized.get("warehouse_code", "")).strip().upper()
    if not normalized["item_code"] and not normalized["item_name"]:
        return None
    return normalized


def default_material_issue_payload() -> dict:
    return {
        "issue_id": "",
        "issue_number": "",
        "issue_date": "",
        "po_number": "",
        "warehouse_code": "",
        "style_code": "",
        "item_id": "",
        "item_code": "",
        "item_name": "",
        "issued_qty": "",
        "uom": "",
        "reason": "",
        "note": "",
        "created_at": "",
        "updated_at": "",
    }


def normalize_material_issue_row(payload: object) -> dict | None:
    if not isinstance(payload, dict):
        return None
    normalized = default_material_issue_payload()
    for key in normalized:
        normalized[key] = str(payload.get(key, normalized[key]) or "").strip()
    normalized["issue_id"] = normalized["issue_id"] or secrets.token_hex(16)
    normalized["style_code"] = normalize_style_identifier(normalized.get("style_code"))
    normalized["issued_qty"] = _safe_float_text(normalized.get("issued_qty"))
    if not normalized["item_code"] and not normalized["item_name"]:
        return None
    return normalized


def default_warehouse_payload() -> dict:
    return {
        "warehouse_id": "",
        "warehouse_code": "",
        "warehouse_name": "",
        "status": "active",
        "note": "",
        "created_at": "",
        "updated_at": "",
    }


def normalize_warehouse_row(payload: object) -> dict | None:
    if not isinstance(payload, dict):
        return None
    normalized = default_warehouse_payload()
    for key in normalized:
        normalized[key] = str(payload.get(key, normalized[key]) or "").strip()
    normalized["warehouse_id"] = normalized["warehouse_id"] or secrets.token_hex(16)
    normalized["warehouse_code"] = normalized["warehouse_code"].upper()
    normalized["status"] = str(normalized.get("status") or "active").strip().lower() or "active"
    if not normalized["warehouse_code"] or not normalized["warehouse_name"]:
        return None
    return normalized


def _find_bom_consumption_detail(self, row: dict) -> dict[str, object]:
    style_code = normalize_style_identifier(row.get("style_code"))
    item_code = str(row.get("item_code", "")).strip()
    values = {"booking": 0.0, "production": 0.0, "settlement": 0.0}
    matched_versions: set[str] = set()
    nonzero_versions: set[str] = set()
    matched_line_count = 0
    if style_code and item_code:
        for record in list(getattr(self, "tech_pack_library", []) or []):
            if normalize_style_identifier(record.get("style_code")) != style_code:
                continue
            versions = record.get("consumption_versions", [])
            if not isinstance(versions, list):
                break
            for version in [dict(item) for item in versions if isinstance(item, dict)]:
                version_type = str(version.get("version_type", "")).strip().lower()
                if version_type not in values:
                    continue
                total = 0.0
                for line in list(version.get("lines", [])):
                    if not isinstance(line, dict):
                        continue
                    if str(line.get("bom_item_code", "")).strip() != item_code:
                        continue
                    consumption_value = _safe_float_value(line.get("consumption"))
                    total += consumption_value
                    matched_versions.add(version_type)
                    if consumption_value > 0:
                        nonzero_versions.add(version_type)
                    matched_line_count += 1
                values[version_type] = total
            break
    fallback_qty = _safe_float_value(row.get("qty_per_garment"))
    fallback_used = False
    if fallback_qty <= 0:
        spec_summary = str(row.get("spec_summary", "")).strip().split()
        if spec_summary:
            try:
                fallback_qty = float(spec_summary[0].replace(",", ""))
            except Exception:
                fallback_qty = 0.0
    if fallback_qty > 0:
        for key in values:
            if values[key] <= 0 and key not in nonzero_versions:
                values[key] = fallback_qty
                fallback_used = True
    return {
        "values": values,
        "matched_versions": matched_versions,
        "nonzero_versions": nonzero_versions,
        "matched_line_count": matched_line_count,
        "has_actual_consumption_match": bool(matched_versions),
        "has_consumption_match": bool(matched_versions) or fallback_used,
        "fallback_used": fallback_used,
    }


def _find_bom_consumption_by_version(self, row: dict) -> dict[str, float]:
    detail = self._find_bom_consumption_detail(row)
    values = detail.get("values", {})
    return dict(values) if isinstance(values, dict) else {"booking": 0.0, "production": 0.0, "settlement": 0.0}


def enrich_bom_requirement_rows(self, rows: list[dict]) -> list[dict]:
    try:
        po_summary_map = self.summarize_bom_purchase_order_requirements()
    except Exception as exc:
        _append_bom_debug_log(f"po-summary build failed error={exc}")
        po_summary_map = {}
    material_po_rows = self.load_material_po_rows_from_source()
    receipt_rows = [row for row in self.load_material_receipt_rows_from_source() if _is_material_receipt_approved(row.get("status"))]
    issue_rows = self.load_material_issue_rows_from_source()
    prepared_rows: list[dict] = []
    shared_key_by_style_item: dict[tuple[str, str], tuple[str, ...]] = {}
    group_totals: dict[tuple[str, ...], dict[str, object]] = {}

    for source_row in rows:
        row = dict(source_row)
        style_code = normalize_style_identifier(row.get("style_code"))
        item_code = str(row.get("item_code", "")).strip()
        po_info = po_summary_map.get(style_code, {}) if isinstance(po_summary_map, dict) else {}
        demand_qty = _safe_float_value(po_info.get("total_qty"))
        consumption_detail = self._find_bom_consumption_detail(row)
        consumption_by_version = dict(consumption_detail.get("values", {}))
        has_consumption_match = bool(consumption_detail.get("has_consumption_match"))
        has_actual_consumption_match = bool(consumption_detail.get("has_actual_consumption_match"))
        waste_percent = _safe_float_value(row.get("waste_percent"))
        booking_required_qty = demand_qty * _safe_float_value(consumption_by_version.get("booking"))
        production_required_qty = demand_qty * _safe_float_value(consumption_by_version.get("production"))
        settlement_required_qty = demand_qty * _safe_float_value(consumption_by_version.get("settlement"))
        if waste_percent > 0:
            multiplier = 1 + (waste_percent / 100.0)
            if booking_required_qty > 0:
                booking_required_qty *= multiplier
            if production_required_qty > 0:
                production_required_qty *= multiplier
            if settlement_required_qty > 0:
                settlement_required_qty *= multiplier
        shared_key = _build_bom_shared_material_key(row)
        req_uom = str(row.get("uom") or row.get("purchase_uom") or "").strip().upper()
        shared_key_by_style_item[(style_code, item_code)] = shared_key
        row["_shared_material_key"] = shared_key
        row["_requirement_uom"] = req_uom
        row["_booking_required_qty_value"] = booking_required_qty
        row["_production_required_qty_value"] = production_required_qty
        row["_settlement_required_qty_value"] = settlement_required_qty
        row["_has_consumption_match"] = has_consumption_match
        row["_has_actual_consumption_match"] = has_actual_consumption_match
        row["_po_numbers"] = list(po_info.get("po_numbers", [])) if has_actual_consumption_match and isinstance(po_info, dict) else []
        group = group_totals.setdefault(
            shared_key,
            {
                "common_uom": _uom_conversion_context(req_uom)[1],
                "booking_required_qty": 0.0,
                "production_required_qty": 0.0,
                "settlement_required_qty": 0.0,
                "ordered_qty": 0.0,
                "received_qty": 0.0,
                "issued_qty": 0.0,
                "po_numbers": set(),
            },
        )
        common_uom = str(group.get("common_uom", "")).strip().upper()
        group["booking_required_qty"] = _safe_float_value(group.get("booking_required_qty")) + _convert_quantity_to_common_uom(
            booking_required_qty,
            req_uom,
            common_uom,
        )
        group["production_required_qty"] = _safe_float_value(group.get("production_required_qty")) + _convert_quantity_to_common_uom(
            production_required_qty,
            req_uom,
            common_uom,
        )
        group["settlement_required_qty"] = _safe_float_value(group.get("settlement_required_qty")) + _convert_quantity_to_common_uom(
            settlement_required_qty,
            req_uom,
            common_uom,
        )
        for po_number in row["_po_numbers"]:
            if str(po_number).strip():
                group["po_numbers"].add(str(po_number).strip())
        self._trace_bom_requirement_debug(
            row,
            po_info=po_info,
            consumption_detail=consumption_detail,
            demand_qty=demand_qty,
        )
        prepared_rows.append(row)

    for record in material_po_rows:
        style_code = normalize_style_identifier(record.get("style_code"))
        item_code = str(record.get("item_code", "")).strip()
        shared_key = shared_key_by_style_item.get((style_code, item_code))
        if not shared_key:
            continue
        group = group_totals.get(shared_key)
        if not isinstance(group, dict):
            continue
        common_uom = str(group.get("common_uom", "")).strip().upper()
        ordered_qty = _convert_quantity_to_common_uom(
            _safe_float_value(record.get("ordered_qty")),
            record.get("purchase_uom"),
            common_uom,
        )
        group["ordered_qty"] = _safe_float_value(group.get("ordered_qty")) + ordered_qty

    for record in receipt_rows:
        style_code = normalize_style_identifier(record.get("style_code"))
        item_code = str(record.get("item_code", "")).strip()
        shared_key = shared_key_by_style_item.get((style_code, item_code))
        if not shared_key:
            continue
        group = group_totals.get(shared_key)
        if not isinstance(group, dict):
            continue
        common_uom = str(group.get("common_uom", "")).strip().upper()
        received_qty = _convert_quantity_to_common_uom(
            _safe_float_value(record.get("received_qty")),
            record.get("uom"),
            common_uom,
        )
        group["received_qty"] = _safe_float_value(group.get("received_qty")) + received_qty

    for record in issue_rows:
        style_code = normalize_style_identifier(record.get("style_code"))
        item_code = str(record.get("item_code", "")).strip()
        shared_key = shared_key_by_style_item.get((style_code, item_code))
        if not shared_key:
            continue
        group = group_totals.get(shared_key)
        if not isinstance(group, dict):
            continue
        common_uom = str(group.get("common_uom", "")).strip().upper()
        issued_qty = _convert_quantity_to_common_uom(
            _safe_float_value(record.get("issued_qty")),
            record.get("uom"),
            common_uom,
        )
        group["issued_qty"] = _safe_float_value(group.get("issued_qty")) + issued_qty

    enriched_rows: list[dict] = []
    for row in prepared_rows:
        shared_key = row.get("_shared_material_key")
        group = group_totals.get(shared_key, {})
        common_uom = str(group.get("common_uom", row.get("_requirement_uom", "")) or "").strip().upper()
        has_consumption_match = bool(row.get("_has_consumption_match"))
        has_actual_consumption_match = bool(row.get("_has_actual_consumption_match"))
        booking_required_qty = _safe_float_value(group.get("booking_required_qty"))
        production_required_qty = _safe_float_value(group.get("production_required_qty"))
        settlement_required_qty = _safe_float_value(group.get("settlement_required_qty"))
        ordered_qty = _safe_float_value(group.get("ordered_qty"))
        received_qty = _safe_float_value(group.get("received_qty"))
        issued_qty = _safe_float_value(group.get("issued_qty"))
        on_hand_qty = received_qty - issued_qty
        shortage_qty = max(production_required_qty - (on_hand_qty + max(ordered_qty - received_qty, 0.0)), 0.0)
        po_numbers = sorted(str(item).strip() for item in group.get("po_numbers", set()) if str(item).strip())
        po_summary = ", ".join(po_numbers[:3]) + (" ..." if len(po_numbers) > 3 else "") if po_numbers else "-"
        row["po_summary"] = po_summary if has_actual_consumption_match else "-"
        row["booking_required_qty"] = _format_metric_with_uom(booking_required_qty, common_uom) if has_consumption_match else _format_metric_with_uom(0.0, common_uom)
        row["production_required_qty"] = _format_metric_with_uom(production_required_qty, common_uom) if has_consumption_match else _format_metric_with_uom(0.0, common_uom)
        row["settlement_required_qty"] = _format_metric_with_uom(settlement_required_qty, common_uom) if has_consumption_match else _format_metric_with_uom(0.0, common_uom)
        row["ordered_qty"] = _format_metric_with_uom(ordered_qty, common_uom)
        row["received_qty"] = _format_metric_with_uom(received_qty, common_uom)
        row["issued_qty"] = _format_metric_with_uom(issued_qty, common_uom)
        row["on_hand_qty"] = _format_metric_with_uom(on_hand_qty, common_uom)
        row["shortage_qty"] = _format_metric_with_uom(shortage_qty, common_uom)
        enriched_rows.append(row)
    return enriched_rows


def summarize_bom_purchase_order_requirements(self) -> dict[str, dict]:
    if self.should_use_server_data_source():
        return self.summarize_bom_purchase_order_requirements_from_source()
    return summarize_purchase_order_requirements_by_style(DB_PATH)


def summarize_bom_purchase_order_requirements_from_source(self) -> dict[str, dict]:
    summary: dict[str, dict] = {}
    source_rows = list(self.fetch_order_records_from_source() or [])
    if not source_rows:
        source_rows = list(getattr(self, "all_orders", []) or [])

    for raw_row in source_rows:
        row = dict(raw_row) if isinstance(raw_row, dict) else {}
        raw_payload = {}
        raw_json = str(row.get("raw_json") or "").strip()
        if raw_json:
            try:
                parsed_payload = json.loads(raw_json)
                if isinstance(parsed_payload, dict):
                    raw_payload = parsed_payload
            except Exception:
                raw_payload = {}
        order_status = normalize_workflow_status(row.get("status") or raw_payload.get("status"), "draft")
        if order_status != "approved":
            continue

        po_number = str(row.get("po_number") or raw_payload.get("po_number") or "").strip()
        line_items = raw_payload.get("items", [])
        if not isinstance(line_items, list):
            line_items = []
        for item in line_items:
            if not isinstance(item, dict):
                continue
            style_code = normalize_style_identifier(item.get("mfg_style") or item.get("style_code"))
            if not style_code:
                continue
            total_units = _safe_float_value(item.get("total_units"))
            if total_units <= 0:
                total_units = _safe_float_value(item.get("total_packs")) * _safe_float_value(item.get("pack_qty"))
            summary_item = summary.setdefault(
                style_code,
                {
                    "style_code": style_code,
                    "total_qty": 0.0,
                    "po_numbers": [],
                },
            )
            summary_item["total_qty"] = _safe_float_value(summary_item.get("total_qty")) + total_units
            if po_number and po_number not in summary_item["po_numbers"]:
                summary_item["po_numbers"].append(po_number)

    for item in summary.values():
        po_numbers = list(item.get("po_numbers", []))
        item["po_count"] = len(po_numbers)
        item["po_summary"] = ", ".join(po_numbers[:3]) + (" ..." if len(po_numbers) > 3 else "")
    debug_focus = summary.get("EEL2008")
    if debug_focus:
        _append_bom_debug_log(
            "po-summary style=EEL2008 "
            + json.dumps(
                {
                    "total_qty": debug_focus.get("total_qty"),
                    "po_numbers": list(debug_focus.get("po_numbers", [])),
                    "po_count": debug_focus.get("po_count"),
                    "po_summary": debug_focus.get("po_summary"),
                },
                ensure_ascii=False,
            )
        )
    else:
        _append_bom_debug_log("po-summary style=EEL2008 missing")
    return summary


def _trace_bom_requirement_debug(
    self,
    row: dict,
    *,
    po_info: dict,
    consumption_detail: dict,
    demand_qty: float,
) -> None:
    style_code = normalize_style_identifier(row.get("style_code"))
    if style_code != "EEL2008":
        return
    try:
        payload = {
            "style_code": style_code,
            "item_code": str(row.get("item_code", "")).strip(),
            "item_name": str(row.get("item_name", "")).strip(),
            "po_summary": str(row.get("po_summary", "")).strip(),
            "demand_qty": demand_qty,
            "po_numbers": list(po_info.get("po_numbers", [])) if isinstance(po_info, dict) else [],
            "matched_versions": sorted(str(item) for item in set(consumption_detail.get("matched_versions", set()) or set())),
            "matched_line_count": int(consumption_detail.get("matched_line_count", 0) or 0),
            "has_actual_consumption_match": bool(consumption_detail.get("has_actual_consumption_match")),
            "has_consumption_match": bool(consumption_detail.get("has_consumption_match")),
            "consumption_values": dict(consumption_detail.get("values", {})) if isinstance(consumption_detail.get("values", {}), dict) else {},
            "source_style_code": str(row.get("source_style_code", "")).strip(),
            "source_section": str(row.get("source_section", "")).strip(),
            "spec_summary": str(row.get("spec_summary", "")).strip(),
            "qty_per_garment": str(row.get("qty_per_garment", "")).strip(),
        }
        _append_bom_debug_log("enrich-row " + json.dumps(payload, ensure_ascii=False))
    except Exception as exc:
        _append_bom_debug_log(f"enrich-row debug-error style=EEL2008 error={exc}")


def load_material_po_rows_from_source(self) -> list[dict]:
    return [row for row in (normalize_material_po_row(item) for item in self._load_store_rows(MATERIAL_PO_APP_SETTING_KEY)) if isinstance(row, dict)]


def save_material_po_row_to_source(self, row: dict) -> dict:
    normalized = normalize_material_po_row(row)
    if normalized is None:
        return {}
    rows = self.load_material_po_rows_from_source()
    record_id = str(normalized.get("record_id", "")).strip()
    merged: list[dict] = []
    replaced = False
    for existing in rows:
        if str(existing.get("record_id", "")).strip() == record_id:
            merged.append(normalized)
            replaced = True
        else:
            merged.append(existing)
    if not replaced:
        merged.append(normalized)
    saved_rows = [row for row in (normalize_material_po_row(item) for item in self._save_store_rows(MATERIAL_PO_APP_SETTING_KEY, merged)) if isinstance(row, dict)]
    return next((item for item in saved_rows if str(item.get("record_id", "")).strip() == record_id), normalized)


def delete_material_po_row_from_source(self, record_id: str) -> None:
    normalized_record_id = str(record_id or "").strip()
    if not normalized_record_id:
        return
    rows = [row for row in self.load_material_po_rows_from_source() if str(row.get("record_id", "")).strip() != normalized_record_id]
    self._save_store_rows(MATERIAL_PO_APP_SETTING_KEY, rows)


def _generate_auto_material_po_number(self, existing_rows: list[dict]) -> str:
    prefix = f"AUTO-MPO-{datetime.now().strftime('%Y%m%d')}"
    used_numbers = {
        str(row.get("mpo_number", "")).strip().upper()
        for row in existing_rows
        if isinstance(row, dict) and str(row.get("mpo_number", "")).strip()
    }
    sequence = 1
    while True:
        candidate = f"{prefix}-{sequence:03d}"
        if candidate.upper() not in used_numbers:
            return candidate
        sequence += 1


def _generate_auto_material_receipt_number(self, existing_rows: list[dict], receipt_date_text: str) -> str:
    normalized_date_text = str(receipt_date_text or "").strip()
    try:
        date_token = datetime.strptime(normalized_date_text, "%Y-%m-%d").strftime("%Y%m%d")
    except Exception:
        date_token = datetime.now().strftime("%Y%m%d")
    prefix = f"GR-{date_token}"
    used_numbers = {
        str(row.get("receipt_number", "")).strip().upper()
        for row in existing_rows
        if isinstance(row, dict) and str(row.get("receipt_number", "")).strip()
    }
    sequence = 1
    while True:
        candidate = f"{prefix}-{sequence:03d}"
        if candidate.upper() not in used_numbers:
            return candidate
        sequence += 1


def auto_generate_material_po_from_booking_shortage(self, rows: list[dict]) -> int:
    source_rows = [dict(item) for item in list(rows or []) if isinstance(item, dict)]
    if not source_rows:
        return 0

    existing_rows = self.load_material_po_rows_from_source()
    active_rows = [row for row in existing_rows if _is_material_po_status_active(row.get("status"))]
    existing_auto_markers: set[str] = set()
    for row in active_rows:
        note_text = str(row.get("note", "")).strip().lower()
        if "auto-generated from bom booking shortage" not in note_text:
            continue
        marker_match = re.search(r"(AUTO-BOM-SHARED-[^\s]+)", str(row.get("note", "")).strip())
        if marker_match:
            existing_auto_markers.add(marker_match.group(1))

    created_count = 0
    processed_shared_keys: set[tuple[str, ...]] = set()
    all_rows = list(existing_rows)
    for row in source_rows:
        status_value = str(row.get("status", "")).strip().lower()
        if status_value != "approved":
            continue
        style_code = normalize_style_identifier(row.get("style_code"))
        item_code = str(row.get("item_code", "")).strip()
        item_name = str(row.get("item_name", "")).strip()
        shared_key = _build_bom_shared_material_key(row)
        if not style_code or not item_code or not item_name or shared_key in processed_shared_keys:
            continue
        if not row.get("_has_consumption_match"):
            continue
        processed_shared_keys.add(shared_key)
        booking_required_qty = _safe_float_value(row.get("booking_required_qty"))
        on_hand_qty = _safe_float_value(row.get("on_hand_qty"))
        ordered_qty = _safe_float_value(row.get("ordered_qty"))
        received_qty = _safe_float_value(row.get("received_qty"))
        outstanding_qty = max(ordered_qty - received_qty, 0.0)
        shortage_qty = booking_required_qty - (on_hand_qty + outstanding_qty)
        if shortage_qty <= 1e-9:
            continue
        shared_marker = "AUTO-BOM-SHARED-" + "|".join(shared_key)
        if shared_marker in existing_auto_markers:
            continue

        purchase_uom = str(row.get("purchase_uom") or row.get("uom") or "").strip().upper()
        po_numbers = str(row.get("po_summary", "")).strip()
        payload = default_material_po_payload()
        payload.update(
            {
                "mpo_number": self._generate_auto_material_po_number(all_rows),
                "order_date": datetime.now().strftime("%Y-%m-%d"),
                "supplier_name": str(row.get("default_supplier", "")).strip(),
                "style_code": style_code,
                "item_id": str(row.get("item_id", "")).strip(),
                "item_code": item_code,
                "item_name": item_name,
                "linked_po_numbers": "" if po_numbers == "-" else po_numbers,
                "ordered_qty": _format_metric_text(shortage_qty),
                "purchase_uom": purchase_uom,
                "status": "draft",
                "note": (
                    f"Auto-generated from BOM booking shortage [{shared_marker}]"
                    + (f" | Linked PO(s): {po_numbers}" if po_numbers and po_numbers != "-" else "")
                ),
            }
        )
        saved = self.save_material_po_row_to_source(payload)
        if not saved:
            continue
        all_rows.append(dict(saved))
        existing_auto_markers.add(shared_marker)
        created_count += 1
        _append_bom_debug_log(
            f"auto-material-po created style={style_code} item_code={item_code} shortage={_format_metric_text(shortage_qty)} uom={purchase_uom}"
        )
    return created_count


def load_material_receipt_rows_from_source(self) -> list[dict]:
    return [row for row in (normalize_material_receipt_row(item) for item in self._load_store_rows(MATERIAL_RECEIPT_APP_SETTING_KEY)) if isinstance(row, dict)]


def save_material_receipt_row_to_source(self, row: dict) -> dict:
    normalized = normalize_material_receipt_row(row)
    if normalized is None:
        return {}
    rows = self.load_material_receipt_rows_from_source()
    receipt_id = str(normalized.get("receipt_id", "")).strip()
    merged: list[dict] = []
    replaced = False
    for existing in rows:
        if str(existing.get("receipt_id", "")).strip() == receipt_id:
            merged.append(normalized)
            replaced = True
        else:
            merged.append(existing)
    if not replaced:
        merged.append(normalized)
    saved_rows = [row for row in (normalize_material_receipt_row(item) for item in self._save_store_rows(MATERIAL_RECEIPT_APP_SETTING_KEY, merged)) if isinstance(row, dict)]
    return next((item for item in saved_rows if str(item.get("receipt_id", "")).strip() == receipt_id), normalized)


def delete_material_receipt_row_from_source(self, receipt_id: str) -> None:
    normalized_receipt_id = str(receipt_id or "").strip()
    if not normalized_receipt_id:
        return
    rows = [row for row in self.load_material_receipt_rows_from_source() if str(row.get("receipt_id", "")).strip() != normalized_receipt_id]
    self._save_store_rows(MATERIAL_RECEIPT_APP_SETTING_KEY, rows)


def summarize_material_receipts_by_po_item(self, *, approved_only: bool = True) -> dict[tuple[str, str], float]:
    summary: dict[tuple[str, str], float] = {}
    for receipt in self.load_material_receipt_rows_from_source():
        if approved_only and not _is_material_receipt_approved(receipt.get("status")):
            continue
        key = (str(receipt.get("mpo_number", "")).strip(), str(receipt.get("item_code", "")).strip())
        if not key[0] or not key[1]:
            continue
        summary[key] = summary.get(key, 0.0) + _safe_float_value(receipt.get("received_qty"))
    return summary


def get_material_receipt_history_for_po_item(self, mpo_number: str, item_code: str) -> list[dict]:
    normalized_mpo_number = str(mpo_number or "").strip()
    normalized_item_code = str(item_code or "").strip()
    if not normalized_mpo_number or not normalized_item_code:
        return []
    rows = [
        dict(row)
        for row in self.load_material_receipt_rows_from_source()
        if str(row.get("mpo_number", "")).strip() == normalized_mpo_number
        and str(row.get("item_code", "")).strip() == normalized_item_code
    ]
    rows.sort(
        key=lambda item: (
            str(item.get("receipt_date", "")).strip().lower(),
            str(item.get("receipt_number", "")).strip().lower(),
            str(item.get("updated_at", "")).strip().lower(),
        ),
        reverse=True,
    )
    return rows


def load_material_issue_rows_from_source(self) -> list[dict]:
    return [row for row in (normalize_material_issue_row(item) for item in self._load_store_rows(MATERIAL_ISSUE_APP_SETTING_KEY)) if isinstance(row, dict)]


def save_material_issue_row_to_source(self, row: dict) -> dict:
    normalized = normalize_material_issue_row(row)
    if normalized is None:
        return {}
    rows = self.load_material_issue_rows_from_source()
    issue_id = str(normalized.get("issue_id", "")).strip()
    merged: list[dict] = []
    replaced = False
    for existing in rows:
        if str(existing.get("issue_id", "")).strip() == issue_id:
            merged.append(normalized)
            replaced = True
        else:
            merged.append(existing)
    if not replaced:
        merged.append(normalized)
    saved_rows = [row for row in (normalize_material_issue_row(item) for item in self._save_store_rows(MATERIAL_ISSUE_APP_SETTING_KEY, merged)) if isinstance(row, dict)]
    return next((item for item in saved_rows if str(item.get("issue_id", "")).strip() == issue_id), normalized)


def delete_material_issue_row_from_source(self, issue_id: str) -> None:
    normalized_issue_id = str(issue_id or "").strip()
    if not normalized_issue_id:
        return
    rows = [row for row in self.load_material_issue_rows_from_source() if str(row.get("issue_id", "")).strip() != normalized_issue_id]
    self._save_store_rows(MATERIAL_ISSUE_APP_SETTING_KEY, rows)


def load_warehouse_rows_from_source(self) -> list[dict]:
    rows = [row for row in (normalize_warehouse_row(item) for item in self._load_store_rows(WAREHOUSE_APP_SETTING_KEY)) if isinstance(row, dict)]
    if rows:
        return rows
    defaults = [
        normalize_warehouse_row({"warehouse_code": "MAIN", "warehouse_name": "Main Warehouse", "status": "active"}),
        normalize_warehouse_row({"warehouse_code": "RM", "warehouse_name": "Raw Material Warehouse", "status": "active"}),
    ]
    rows = [dict(item) for item in defaults if isinstance(item, dict)]
    self._save_store_rows(WAREHOUSE_APP_SETTING_KEY, rows)
    return rows


def save_warehouse_row_to_source(self, row: dict) -> dict:
    normalized = normalize_warehouse_row(row)
    if normalized is None:
        return {}
    rows = self.load_warehouse_rows_from_source()
    warehouse_id = str(normalized.get("warehouse_id", "")).strip()
    merged: list[dict] = []
    replaced = False
    for existing in rows:
        if str(existing.get("warehouse_id", "")).strip() == warehouse_id:
            merged.append(normalized)
            replaced = True
        else:
            merged.append(existing)
    if not replaced:
        merged.append(normalized)
    saved_rows = [row for row in (normalize_warehouse_row(item) for item in self._save_store_rows(WAREHOUSE_APP_SETTING_KEY, merged)) if isinstance(row, dict)]
    return next((item for item in saved_rows if str(item.get("warehouse_id", "")).strip() == warehouse_id), normalized)


def delete_warehouse_row_from_source(self, warehouse_id: str) -> None:
    normalized_warehouse_id = str(warehouse_id or "").strip()
    if not normalized_warehouse_id:
        return
    rows = [row for row in self.load_warehouse_rows_from_source() if str(row.get("warehouse_id", "")).strip() != normalized_warehouse_id]
    self._save_store_rows(WAREHOUSE_APP_SETTING_KEY, rows)


def load_bom_items_from_source(self) -> list[dict]:
    if self.should_use_server_data_source():
        if self.is_local_data_mode():
            self.ensure_local_server_running()
        _append_bom_debug_log(f"load start mode=server local_mode={self.is_local_data_mode()} api_base={self.server_settings.get('api_base_url', '')}")
        try:
            items = [item for item in fetch_bom_items(self.server_settings) if isinstance(item, dict)]
            _append_bom_debug_log(f"load api success count={len(items)}")
            return items
        except Exception as exc:
            if _is_bom_recoverable_source_error(exc):
                if self.is_local_data_mode():
                    _append_bom_debug_log(f"load api recoverable restart local-server error={exc}")
                    restarted = self.restart_local_server("refreshing local BOM API endpoints") if hasattr(self, "restart_local_server") else False
                    _append_bom_debug_log(f"load restart result ready={restarted}")
                    if restarted:
                        try:
                            items = [item for item in fetch_bom_items(self.server_settings) if isinstance(item, dict)]
                            _append_bom_debug_log(f"load api retry success count={len(items)}")
                            return items
                        except Exception as retry_exc:
                            _append_bom_debug_log(
                                "load api retry error="
                                + str(retry_exc)
                                + " traceback="
                                + traceback.format_exc(limit=6).replace("\n", " | ")
                            )
                            if not _is_bom_recoverable_source_error(retry_exc):
                                raise
                payload = fetch_app_setting(self.server_settings, BOM_APP_SETTING_KEY, [])
                rows = _normalize_bom_item_list(payload)
                _append_bom_debug_log(f"load app-setting fallback count={len(rows)}")
                if rows:
                    return rows
                _append_bom_debug_log("load fallback db local")
                return list_bom_items(DB_PATH)
            _append_bom_debug_log(
                "load api error="
                + str(exc)
                + " traceback="
                + traceback.format_exc(limit=6).replace("\n", " | ")
            )
            raise
    return list_bom_items(DB_PATH)


def save_bom_item_to_source(self, row: dict) -> dict:
    normalized = normalize_bom_item_payload(row)
    if normalized is None:
        return {}
    existing_row = next(
        (
            dict(item)
            for item in list(getattr(self, "bom_items", []) or [])
            if str(item.get("item_id", "")).strip() == str(normalized.get("item_id", "")).strip()
        ),
        None,
    )
    if self.should_use_server_data_source():
        if self.is_local_data_mode():
            self.ensure_local_server_running()
        _append_bom_debug_log(
            f"save start mode=server local_mode={self.is_local_data_mode()} style={normalized.get('style_code','')} item_code={normalized.get('item_code','')}"
        )
        try:
            saved = save_bom_item_via_api(self.server_settings, normalized)
            _append_bom_debug_log(
                f"save api success item_id={saved.get('item_id','')} item_code={saved.get('item_code','')}"
            )
            try:
                latest_bom_rows = [normalize_bom_item_payload(item) for item in self.load_bom_items_from_source()]
                self.sync_tech_pack_consumption_with_bom_status([item for item in latest_bom_rows if isinstance(item, dict)])
            except Exception as sync_exc:
                _append_bom_debug_log(f"save api sync-consumption warning={sync_exc}")
            self.record_entity_history(
                scope="bom",
                entity_key=saved.get("item_id", ""),
                entity_label=str(saved.get("item_code", "")).strip() or str(saved.get("item_name", "")).strip(),
                action="update" if existing_row else "create",
                before=existing_row,
                after=dict(saved),
                summary=f"Saved BOM item {saved.get('item_code', '')}",
            )
            return saved
        except Exception as exc:
            if _is_bom_recoverable_source_error(exc):
                if self.is_local_data_mode():
                    _append_bom_debug_log(f"save api recoverable restart local-server error={exc}")
                    restarted = self.restart_local_server("refreshing local BOM API endpoints") if hasattr(self, "restart_local_server") else False
                    _append_bom_debug_log(f"save restart result ready={restarted}")
                    if restarted:
                        try:
                            saved = save_bom_item_via_api(self.server_settings, normalized)
                            _append_bom_debug_log(
                                f"save api retry success item_id={saved.get('item_id','')} item_code={saved.get('item_code','')}"
                            )
                            try:
                                latest_bom_rows = [normalize_bom_item_payload(item) for item in self.load_bom_items_from_source()]
                                self.sync_tech_pack_consumption_with_bom_status([item for item in latest_bom_rows if isinstance(item, dict)])
                            except Exception as sync_exc:
                                _append_bom_debug_log(f"save api retry sync-consumption warning={sync_exc}")
                            return saved
                        except Exception as retry_exc:
                            _append_bom_debug_log(
                                "save api retry error="
                                + str(retry_exc)
                                + " traceback="
                                + traceback.format_exc(limit=6).replace("\n", " | ")
                            )
                            if not _is_bom_recoverable_source_error(retry_exc):
                                raise
                current_rows = self.load_bom_items_from_source()
                item_id = str(normalized.get("item_id", "")).strip()
                merged_rows: list[dict] = []
                replaced = False
                for existing_row in current_rows:
                    existing_id = str(existing_row.get("item_id", "")).strip()
                    if item_id and existing_id == item_id:
                        merged_rows.append(normalized)
                        replaced = True
                    else:
                        merged_rows.append(existing_row)
                if not replaced:
                    merged_rows.append(normalized)
                payload = save_app_setting(self.server_settings, BOM_APP_SETTING_KEY, merged_rows)
                saved_rows = _normalize_bom_item_list(payload)
                matched = next((item for item in saved_rows if str(item.get("item_id", "")).strip() == item_id), normalized)
                _append_bom_debug_log(
                    f"save app-setting fallback item_id={matched.get('item_id','')} item_code={matched.get('item_code','')}"
                )
                try:
                    self.sync_tech_pack_consumption_with_bom_status([item for item in saved_rows if isinstance(item, dict)])
                except Exception as sync_exc:
                    _append_bom_debug_log(f"save app-setting sync-consumption warning={sync_exc}")
                self.record_entity_history(
                    scope="bom",
                    entity_key=matched.get("item_id", ""),
                    entity_label=str(matched.get("item_code", "")).strip() or str(matched.get("item_name", "")).strip(),
                    action="update" if existing_row else "create",
                    before=existing_row,
                    after=dict(matched),
                    summary=f"Saved BOM item {matched.get('item_code', '')}",
                )
                return matched
            _append_bom_debug_log(
                "save api error="
                + str(exc)
                + " traceback="
                + traceback.format_exc(limit=6).replace("\n", " | ")
            )
            raise
    saved = save_bom_item(DB_PATH, normalized)
    try:
        latest_bom_rows = [normalize_bom_item_payload(item) for item in self.load_bom_items_from_source()]
        self.sync_tech_pack_consumption_with_bom_status([item for item in latest_bom_rows if isinstance(item, dict)])
    except Exception as sync_exc:
        _append_bom_debug_log(f"save local sync-consumption warning={sync_exc}")
    if isinstance(saved, dict):
        self.record_entity_history(
            scope="bom",
            entity_key=saved.get("item_id", ""),
            entity_label=str(saved.get("item_code", "")).strip() or str(saved.get("item_name", "")).strip(),
            action="update" if existing_row else "create",
            before=existing_row,
            after=dict(saved),
            summary=f"Saved BOM item {saved.get('item_code', '')}",
        )
    return dict(saved) if isinstance(saved, dict) else {}


def delete_bom_item_from_source(self, item_id: str) -> bool:
    normalized_item_id = str(item_id or "").strip()
    if not normalized_item_id:
        return False
    if self.should_use_server_data_source():
        if self.is_local_data_mode():
            self.ensure_local_server_running()
        _append_bom_debug_log(f"delete start mode=server local_mode={self.is_local_data_mode()} item_id={normalized_item_id}")
        try:
            result = delete_bom_item_via_api(self.server_settings, normalized_item_id)
            deleted_count = int(result.get("deleted_count", 0) or 0) if isinstance(result, dict) else 0
            _append_bom_debug_log(f"delete api success item_id={normalized_item_id} deleted_count={deleted_count}")
            if deleted_count <= 0:
                return False
            try:
                local_deleted_count = delete_bom_item(DB_PATH, normalized_item_id)
                if local_deleted_count:
                    _append_bom_debug_log(f"delete local mirror item_id={normalized_item_id} deleted_count={local_deleted_count}")
            except Exception as local_exc:
                _append_bom_debug_log(f"delete local mirror warning={local_exc}")
            try:
                payload = fetch_app_setting(self.server_settings, BOM_APP_SETTING_KEY, [])
                rows = _normalize_bom_item_list(payload)
                if rows:
                    remaining_rows = [
                        row for row in rows
                        if str(row.get("item_id", "")).strip() != normalized_item_id
                    ]
                    if len(remaining_rows) != len(rows):
                        save_app_setting(self.server_settings, BOM_APP_SETTING_KEY, remaining_rows)
                        _append_bom_debug_log(f"delete app-setting mirror item_id={normalized_item_id}")
            except Exception as mirror_exc:
                _append_bom_debug_log(f"delete app-setting mirror warning={mirror_exc}")
            return True
        except Exception as exc:
            if _is_bom_recoverable_source_error(exc):
                if self.is_local_data_mode():
                    _append_bom_debug_log(f"delete api recoverable restart local-server error={exc}")
                    restarted = self.restart_local_server("refreshing local BOM API endpoints") if hasattr(self, "restart_local_server") else False
                    _append_bom_debug_log(f"delete restart result ready={restarted}")
                    if restarted:
                        try:
                            result = delete_bom_item_via_api(self.server_settings, normalized_item_id)
                            deleted_count = int(result.get("deleted_count", 0) or 0) if isinstance(result, dict) else 0
                            _append_bom_debug_log(f"delete api retry success item_id={normalized_item_id} deleted_count={deleted_count}")
                            if deleted_count > 0:
                                try:
                                    local_deleted_count = delete_bom_item(DB_PATH, normalized_item_id)
                                    if local_deleted_count:
                                        _append_bom_debug_log(
                                            f"delete retry local mirror item_id={normalized_item_id} deleted_count={local_deleted_count}"
                                        )
                                except Exception as local_exc:
                                    _append_bom_debug_log(f"delete retry local mirror warning={local_exc}")
                            return deleted_count > 0
                        except Exception as retry_exc:
                            _append_bom_debug_log(
                                "delete api retry error="
                                + str(retry_exc)
                                + " traceback="
                                + traceback.format_exc(limit=6).replace("\n", " | ")
                            )
                            if not _is_bom_recoverable_source_error(retry_exc):
                                raise
                current_rows = self.load_bom_items_from_source()
                remaining_rows = [
                    row for row in current_rows
                    if str(row.get("item_id", "")).strip() != normalized_item_id
                ]
                save_app_setting(self.server_settings, BOM_APP_SETTING_KEY, remaining_rows)
                _append_bom_debug_log(f"delete app-setting fallback item_id={normalized_item_id}")
                return len(remaining_rows) != len(current_rows)
            else:
                _append_bom_debug_log(
                    "delete api error="
                    + str(exc)
                    + " traceback="
                    + traceback.format_exc(limit=6).replace("\n", " | ")
                )
                raise
    _append_bom_debug_log(f"delete fallback db item_id={normalized_item_id}")
    return delete_bom_item(DB_PATH, normalized_item_id) > 0


def _build_bom_module(self, parent: ttk.Frame) -> None:
    parent.columnconfigure(0, weight=1)
    parent.rowconfigure(3, weight=1)

    toolbar = ttk.Frame(parent)
    toolbar.grid(row=0, column=0, sticky="ew")
    self.widget_text["bom_new_button"] = ttk.Button(toolbar, text="New", command=self.open_new_bom_item_dialog)
    self.widget_text["bom_new_button"].pack(side="left")
    self.register_permission_widget("action.bom.new", self.widget_text["bom_new_button"])
    self.widget_text["bom_copy_button"] = ttk.Button(toolbar, text="Copy", command=self.copy_selected_bom_item)
    self.widget_text["bom_copy_button"].pack(side="left", padx=(8, 0))
    self.register_permission_widget("action.bom.copy", self.widget_text["bom_copy_button"])
    self.widget_text["bom_edit_button"] = ttk.Button(toolbar, text="Edit", command=self.edit_selected_bom_item)
    self.widget_text["bom_edit_button"].pack(side="left", padx=(8, 0))
    self.register_permission_widget("action.bom.edit", self.widget_text["bom_edit_button"])
    self.widget_text["bom_delete_button"] = ttk.Button(toolbar, text="Delete", command=self.delete_selected_bom_item)
    self.widget_text["bom_delete_button"].pack(side="left", padx=(8, 0))
    self.register_permission_widget("action.bom.delete", self.widget_text["bom_delete_button"])
    self.widget_text["bom_generate_button"] = ttk.Button(toolbar, text="Generate from TP", command=self.generate_bom_from_tech_pack)
    self.widget_text["bom_generate_button"].pack(side="left", padx=(8, 0))
    self.register_permission_widget("action.bom.generate_from_tech_pack", self.widget_text["bom_generate_button"])
    self.widget_text["bom_import_fds_button"] = ttk.Button(toolbar, text="Import FDS", command=self.open_import_fds_menu)
    self.widget_text["bom_import_fds_button"].pack(side="left", padx=(8, 0))
    self.register_permission_widget("action.bom.import_fds", self.widget_text["bom_import_fds_button"])
    self.widget_text["bom_supplier_list_button"] = ttk.Button(toolbar, text="Supplier list", command=self.open_bom_supplier_list_window)
    self.widget_text["bom_supplier_list_button"].pack(side="left", padx=(8, 0))
    self.register_permission_widget("action.bom.supplier_list", self.widget_text["bom_supplier_list_button"])
    self.widget_text["bom_materials_po_button"] = ttk.Button(toolbar, text="Materials PO", command=self.open_materials_po_window)
    self.widget_text["bom_materials_po_button"].pack(side="left", padx=(8, 0))
    self.widget_text["bom_warehouse_button"] = ttk.Button(toolbar, text="Warehouse", command=self.open_material_warehouse_window)
    self.widget_text["bom_warehouse_button"].pack(side="left", padx=(8, 0))
    self.widget_text["bom_export_button"] = ttk.Button(
        toolbar,
        text="Export report",
        command=self.open_bom_report_builder,
    )
    self.widget_text["bom_export_button"].pack(side="left", padx=(8, 0))
    self.register_permission_widget("action.bom.export_report", self.widget_text["bom_export_button"])

    meta = ttk.Frame(parent)
    meta.grid(row=1, column=0, sticky="ew", pady=(12, 14))
    meta.columnconfigure(8, weight=1)
    self.widget_text["bom_filter_field_label"] = ttk.Label(meta, text="Select")
    self.widget_text["bom_filter_field_label"].grid(row=0, column=0, sticky="w", padx=(0, 6))
    self.bom_filter_field_combo = ttk.Combobox(meta, textvariable=self.bom_filter_field_var, state="readonly", width=22)
    self.bom_filter_field_combo.grid(row=0, column=1, sticky="w")
    self.bom_filter_field_combo.bind("<<ComboboxSelected>>", self.on_bom_filter_field_changed)
    self.widget_text["bom_filter_value_label"] = ttk.Label(meta, text="Value")
    self.widget_text["bom_filter_value_label"].grid(row=0, column=2, sticky="w", padx=(12, 6))
    self.bom_search_entry = ttk.Entry(meta, textvariable=self.bom_search_var, width=36)
    self.bom_search_entry.grid(row=0, column=3, sticky="w")
    self.bom_search_entry.bind("<Return>", self.on_bom_search_submit)
    self.bom_search_entry.bind("<KP_Enter>", self.on_bom_search_submit)
    self.widget_text["bom_date_from_label"] = ttk.Label(meta, text="From")
    self.widget_text["bom_date_from_label"].grid(row=0, column=4, sticky="w", padx=(12, 6))
    self.bom_date_from_entry = ttk.Entry(meta, textvariable=self.bom_date_from_var, width=12)
    self.bom_date_from_entry.grid(row=0, column=5, sticky="w", padx=(0, 12))
    self.bind_date_entry_picker(self.bom_date_from_entry, self.bom_date_from_var)
    self.bom_date_from_var.trace_add("write", lambda *_args: self.apply_bom_filter())
    self.widget_text["bom_date_to_label"] = ttk.Label(meta, text="To")
    self.widget_text["bom_date_to_label"].grid(row=0, column=6, sticky="w", padx=(0, 6))
    self.bom_date_to_entry = ttk.Entry(meta, textvariable=self.bom_date_to_var, width=12)
    self.bom_date_to_entry.grid(row=0, column=7, sticky="w")
    self.bind_date_entry_picker(self.bom_date_to_entry, self.bom_date_to_var)
    self.bom_date_to_var.trace_add("write", lambda *_args: self.apply_bom_filter())
    self.widget_text["bom_sort_by_label"] = ttk.Label(meta, text="Sort by")
    self.widget_text["bom_sort_by_label"].grid(row=1, column=0, sticky="w", pady=(8, 0), padx=(0, 6))
    self.bom_sort_key_combo = ttk.Combobox(meta, textvariable=self.bom_sort_key_var, state="readonly", width=20)
    self.bom_sort_key_combo.grid(row=1, column=1, sticky="w", pady=(8, 0))
    self.bom_sort_key_combo.bind("<<ComboboxSelected>>", self.on_bom_sort_key_changed)
    self.widget_text["bom_sort_order_label"] = ttk.Label(meta, text="Order")
    self.widget_text["bom_sort_order_label"].grid(row=1, column=2, sticky="w", pady=(8, 0), padx=(12, 6))
    self.bom_sort_order_combo = ttk.Combobox(meta, textvariable=self.bom_sort_order_var, state="readonly", width=12)
    self.bom_sort_order_combo.grid(row=1, column=3, sticky="w", pady=(8, 0))
    self.bom_sort_order_combo.bind("<<ComboboxSelected>>", self.on_bom_sort_order_changed)
    self.update_bom_filter_input_state()

    self.widget_text["bom_styles_section"] = ttk.Label(parent, style="Section.TLabel", text="BOM Items")
    self.widget_text["bom_styles_section"].grid(row=2, column=0, sticky="nw", pady=(0, 8))

    table_frame = ttk.Frame(parent)
    table_frame.grid(row=3, column=0, sticky="nsew")
    table_frame.columnconfigure(0, weight=1)
    table_frame.rowconfigure(0, weight=1)

    self.bom_tree = ttk.Treeview(table_frame, columns=BOM_COLUMNS, show="headings", selectmode="browse", style="Costing.Treeview", height=12)
    heading_labels = {
        "select_flag": "Export",
        "created_at": "Created At",
        "item_code": "Item Code",
        "item_name": "Item Name",
        "item_type": "Type",
        "section": self.t("bom_section_column"),
        "po_summary": "Linked POs",
        "booking_required_qty": "Booking Req",
        "production_required_qty": "Production Req",
        "settlement_required_qty": "Settlement Req",
        "ordered_qty": "Ordered Qty",
        "received_qty": "Received Qty",
        "issued_qty": "Issued Qty",
        "on_hand_qty": "On Hand",
        "shortage_qty": "Shortage",
        "article_ref": "Article",
        "color": "Color",
        "spec_summary": "Spec Summary",
        "uom": "UOM",
        "default_supplier": "Supplier",
        "status": self.t("status"),
        "updated_at": self.t("revised_time_column"),
        "latest_user": self.t("latest_user_column"),
    }
    for column_key, width in {
        "select_flag": 60,
        "created_at": 130,
        "item_code": 120,
        "item_name": 190,
        "item_type": 100,
        "section": 100,
        "po_summary": 180,
        "booking_required_qty": 110,
        "production_required_qty": 110,
        "settlement_required_qty": 110,
        "ordered_qty": 110,
        "received_qty": 110,
        "issued_qty": 110,
        "on_hand_qty": 110,
        "shortage_qty": 110,
        "article_ref": 120,
        "color": 90,
        "spec_summary": 220,
        "uom": 75,
        "default_supplier": 120,
        "status": 90,
        "updated_at": 130,
        "latest_user": 140,
    }.items():
        anchor = "center" if column_key == "select_flag" else "w"
        stretch = column_key == "spec_summary"
        self.bom_tree.column(column_key, width=width, anchor=anchor, stretch=stretch)
        self.bom_tree.heading(
            column_key,
            text=heading_labels.get(column_key, column_key),
            command=lambda key=column_key: self.on_bom_heading_click(key),
        )
    configure_striped_treeview(self.bom_tree)
    bom_scroll_y = ttk.Scrollbar(table_frame, orient="vertical", command=self.bom_tree.yview)
    bom_scroll_x = ttk.Scrollbar(table_frame, orient="horizontal", command=self.bom_tree.xview)
    self.bom_tree.configure(yscrollcommand=bom_scroll_y.set, xscrollcommand=bom_scroll_x.set)
    self.bom_tree.grid(row=0, column=0, sticky="nsew")
    bom_scroll_y.grid(row=0, column=1, sticky="ns")
    bom_scroll_x.grid(row=1, column=0, sticky="ew")
    self.bom_tree.bind("<<TreeviewSelect>>", self.on_bom_item_selected)
    self.bom_tree.bind("<Button-1>", self.on_bom_tree_click)
    self.bom_tree.bind("<Double-1>", self.on_bom_item_double_click)

    self.bom_status_label = ttk.Label(
        parent,
        textvariable=self.status_var,
        relief="sunken",
        anchor="w",
        justify="left",
        padding=8,
        wraplength=1180,
    )
    self.bom_status_label.grid(row=4, column=0, sticky="ew", pady=(12, 0))

def refresh_bom_module(self) -> None:
    _append_bom_debug_log("refresh-bom start")
    try:
        latest_library = self.load_tech_pack_library_from_source()
        if isinstance(latest_library, list) and latest_library:
            self.tech_pack_library = latest_library
        _append_bom_debug_log(
            f"refresh-bom tech-pack-library count={len(list(getattr(self, 'tech_pack_library', []) or []))}"
        )
    except Exception:
        _append_bom_debug_log("refresh-bom tech-pack-library reload failed")
    try:
        new_rows = [normalize_bom_item_payload(item) for item in self.load_bom_items_from_source()]
        eel2008_count = sum(
            1
            for item in new_rows
            if isinstance(item, dict) and normalize_style_identifier(item.get("style_code")) == "EEL2008"
        )
        _append_bom_debug_log(f"refresh-bom bom-row count={len(new_rows)} eel2008_count={eel2008_count}")
    except Exception as exc:
        self.bom_items = []
        self.filtered_bom_items = []
        if hasattr(self, "bom_tree"):
            self.refresh_bom_tree()
        else:
            self.update_report_target_summary()
        self.set_status(f"BOM load warning: {exc}")
        return
    try:
        removed_count = self.sync_tech_pack_consumption_with_bom_status([item for item in new_rows if isinstance(item, dict)])
        if removed_count:
            _append_bom_debug_log(f"refresh-bom sync-consumption removed_count={removed_count}")
    except Exception as sync_exc:
        _append_bom_debug_log(f"refresh-bom sync-consumption warning={sync_exc}")
    enriched_rows = self.enrich_bom_requirement_rows([item for item in new_rows if isinstance(item, dict)])
    auto_created_count = 0
    if not getattr(self, "_auto_generating_material_po", False):
        try:
            self._auto_generating_material_po = True
            auto_created_count = self.auto_generate_material_po_from_booking_shortage(enriched_rows)
        finally:
            self._auto_generating_material_po = False
    if auto_created_count:
        _append_bom_debug_log(f"refresh-bom auto-material-po created_count={auto_created_count}")
        enriched_rows = self.enrich_bom_requirement_rows([item for item in new_rows if isinstance(item, dict)])
    self.bom_items = enriched_rows
    for item in self.bom_items:
        item["latest_user_display"] = self.get_entity_latest_user_display("bom", item.get("item_id", ""))
        item["latest_user"] = item["latest_user_display"]
    self.apply_bom_filter()


def update_bom_filter_input_state(self, *, clear_irrelevant: bool = False) -> None:
    filter_field = getattr(self, "bom_filter_field_internal", "__all__")
    is_date_field = filter_field in BOM_DATE_FILTER_FIELDS
    if clear_irrelevant:
        if is_date_field:
            self.sync_bom_search_query("")
        else:
            self.bom_date_from_var.set("")
            self.bom_date_to_var.set("")
    search_state = "disabled" if is_date_field else "normal"
    date_state = "normal" if is_date_field else "disabled"
    if hasattr(self, "bom_search_entry"):
        self.bom_search_entry.configure(state=search_state)
    if hasattr(self, "bom_date_from_entry"):
        self.bom_date_from_entry.configure(state=date_state)
    if hasattr(self, "bom_date_to_entry"):
        self.bom_date_to_entry.configure(state=date_state)


def apply_bom_filter(self) -> None:
    if getattr(self, "_bom_filter_update_suspended", False):
        return
    query = str(getattr(self, "bom_search_committed_value", self.bom_search_var.get()) or "").strip().lower()
    from_text = self.bom_date_from_var.get().strip()
    to_text = self.bom_date_to_var.get().strip()
    filter_field = getattr(self, "bom_filter_field_internal", "__all__")
    filtered = list(self.bom_items)
    if filter_field in BOM_DATE_FILTER_FIELDS:
        filtered = [
            row
            for row in filtered
            if self.is_within_date_range(row.get(filter_field), from_text, to_text)
        ]
    elif filter_field == "__all__" and query:
        result: list[dict] = []
        for row in filtered:
            haystack = " ".join(
                [
                    self.display_value(row.get("item_code")),
                    self.display_value(row.get("item_name")),
                    self.display_value(row.get("item_type")),
                    self.display_value(row.get("section")),
                    self.display_value(row.get("po_summary")),
                    self.display_value(row.get("article_ref")),
                    self.display_value(row.get("color")),
                    self.display_value(row.get("description")),
                    self.display_value(row.get("composition")),
                    self.display_value(row.get("booking_required_qty")),
                    self.display_value(row.get("production_required_qty")),
                    self.display_value(row.get("settlement_required_qty")),
                    self.display_value(row.get("ordered_qty")),
                    self.display_value(row.get("received_qty")),
                    self.display_value(row.get("issued_qty")),
                    self.display_value(row.get("on_hand_qty")),
                    self.display_value(row.get("shortage_qty")),
                    self.display_value(row.get("default_supplier")),
                    self.display_value(row.get("position_of_use")),
                    self.display_value(row.get("status")),
                ]
            ).lower()
            if query in haystack:
                result.append(row)
        filtered = result
    elif query:
        filtered = [
            row
            for row in filtered
            if query in self.display_value(row.get(filter_field)).strip().lower()
        ]

    sort_key = self.bom_sort_key_internal
    reverse = self.bom_sort_order_internal == "desc"

    def sort_value(row: dict) -> object:
        value = row.get(sort_key, "")
        if sort_key in {"created_at", "updated_at"}:
            return self.parse_sort_value(sort_key, str(value or ""))
        if sort_key in {"booking_required_qty", "production_required_qty", "settlement_required_qty", "ordered_qty", "received_qty", "issued_qty", "on_hand_qty", "shortage_qty"}:
            return _safe_float_value(value)
        return str(value or "").lower()

    filtered.sort(key=sort_value, reverse=reverse)
    self.filtered_bom_items = filtered
    if hasattr(self, "bom_tree"):
        self.refresh_bom_tree()
    self.update_report_target_summary()


def commit_bom_search_query(self, value: object | None = None, *, apply_filter: bool = True) -> None:
    committed_value = self.bom_search_var.get() if value is None else value
    self.bom_search_committed_value = str(committed_value or "").strip()
    if apply_filter:
        self.apply_bom_filter()


def on_bom_search_submit(self, _event: object | None = None) -> str:
    self.commit_bom_search_query()
    return "break"


def sync_bom_search_query(self, value: object) -> None:
    normalized_value = str(value or "").strip()
    self.bom_search_var.set(normalized_value)
    self.bom_search_committed_value = normalized_value


def schedule_bom_filter_apply(self) -> None:
    self.commit_bom_search_query()


def run_bom_filter_apply(self) -> None:
    self.apply_bom_filter()


def refresh_bom_tree(self) -> None:
    selected_item_id = self.get_selected_bom_item_id()
    self.bom_tree.delete(*self.bom_tree.get_children())
    self.bom_tree.tag_configure("bom_status_draft", background="#ffffff")
    self.bom_tree.tag_configure("bom_status_checking", background="#fff7cc")
    self.bom_tree.tag_configure("bom_status_approved", background="#dff5e3")
    valid_ids = {str(row.get("item_id", "")).strip() for row in self.bom_items if str(row.get("item_id", "")).strip()}
    self.checked_bom_item_ids.intersection_update(valid_ids)
    for index, row in enumerate(self.filtered_bom_items, start=1):
        item_id = str(row.get("item_id", "")).strip()
        if not item_id:
            continue
        values = []
        for key in BOM_COLUMNS:
            if key == "select_flag":
                values.append("[x]" if item_id in self.checked_bom_item_ids else "[ ]")
            else:
                values.append(self.display_value(row.get(key)))
        status_value = str(row.get("status", "")).strip().lower()
        status_tag = {
            "checking": "bom_status_checking",
            "approved": "bom_status_approved",
        }.get(status_value, "bom_status_draft")
        self.bom_tree.insert(
            "",
            "end",
            iid=item_id,
            values=tuple(values),
            tags=(status_tag,),
        )
    if selected_item_id and self.bom_tree.exists(selected_item_id):
        self.bom_tree.selection_set(selected_item_id)
        self.bom_tree.focus(selected_item_id)
    self.update_bom_module_status()


def update_bom_module_status(self) -> None:
    total = len(self.bom_items)
    shown = len(self.filtered_bom_items)
    checked = len(getattr(self, "checked_bom_item_ids", set()) or set())
    total_shortage = sum(_safe_float_value(row.get("shortage_qty")) for row in self.filtered_bom_items)
    self.set_status(f"BOM items: {shown}/{total} | Export checked: {checked} | Visible shortage: {_format_metric_text(total_shortage)}")


def get_selected_bom_item_id(self) -> str:
    if not hasattr(self, "bom_tree"):
        return ""
    selection = self.bom_tree.selection()
    return str(selection[0]).strip() if selection else ""


def get_selected_bom_item(self) -> dict | None:
    item_id = self.get_selected_bom_item_id()
    if not item_id:
        return None
    for row in self.bom_items:
        if str(row.get("item_id", "")).strip() == item_id:
            return dict(row)
    return None


def on_bom_heading_click(self, key: str) -> None:
    if key == "select_flag":
        visible_ids = [
            str(row.get("item_id", "")).strip()
            for row in self.filtered_bom_items
            if str(row.get("item_id", "")).strip()
        ]
        checked_visible_ids = {item_id for item_id in visible_ids if item_id in self.checked_bom_item_ids}
        if checked_visible_ids and len(checked_visible_ids) == len(visible_ids):
            self.checked_bom_item_ids.difference_update(visible_ids)
        else:
            self.checked_bom_item_ids.update(visible_ids)
        self.update_bom_tree_check_cell()
        selected_visible_ids = [item_id for item_id in visible_ids if item_id in self.checked_bom_item_ids]
        if selected_visible_ids:
            first_item_id = selected_visible_ids[0]
            if self.bom_tree.exists(first_item_id):
                self.bom_tree.selection_set(first_item_id)
                self.bom_tree.focus(first_item_id)
        else:
            self.bom_tree.selection_remove(self.bom_tree.selection())
        return
    if self.bom_sort_key_internal == key:
        self.bom_sort_order_internal = "asc" if self.bom_sort_order_internal == "desc" else "desc"
        self.bom_sort_order_var.set(self.translate_sort_order(self.bom_sort_order_internal))
    else:
        self.bom_sort_key_internal = key
        self.bom_sort_key_var.set(self.translate_bom_sort_key(key))
    self.apply_bom_filter()


def update_bom_tree_check_cell(self, row_id: str | None = None) -> None:
    if not hasattr(self, "bom_tree"):
        return
    target_ids = [row_id] if row_id else list(self.bom_tree.get_children())
    for item_id in target_ids:
        if not self.bom_tree.exists(item_id):
            continue
        self.bom_tree.set(item_id, "select_flag", "[x]" if item_id in self.checked_bom_item_ids else "[ ]")
    self.update_bom_module_status()


def on_bom_tree_click(self, event: tk.Event) -> str | None:
    region = self.bom_tree.identify_region(event.x, event.y)
    if region != "cell":
        return None
    column = self.bom_tree.identify_column(event.x)
    if column != "#1":
        return None
    row_id = self.bom_tree.identify_row(event.y)
    if not row_id:
        return None
    if row_id in self.checked_bom_item_ids:
        self.checked_bom_item_ids.remove(row_id)
    else:
        self.checked_bom_item_ids.add(row_id)
    self.update_bom_tree_check_cell(row_id)
    self.bom_tree.selection_set(row_id)
    self.bom_tree.focus(row_id)
    return "break"


def on_bom_item_selected(self, _event: object | None = None) -> None:
    self.update_report_target_summary()


def on_bom_item_double_click(self, _event: object | None = None) -> None:
    self.edit_selected_bom_item()


def translate_bom_sort_key(self, internal_value: str) -> str:
    for key, text_key in BOM_SORT_KEY_OPTIONS:
        if key == internal_value:
            return self.t(text_key)
    return internal_value


def translate_bom_filter_field(self, internal_value: str) -> str:
    for key, text_key in BOM_FILTER_FIELD_OPTIONS:
        if key == internal_value:
            return self.t(text_key)
    return internal_value


def on_bom_sort_key_changed(self, _event: object) -> None:
    selected = self.bom_sort_key_var.get()
    for internal, text_key in BOM_SORT_KEY_OPTIONS:
        if selected == self.t(text_key):
            self.bom_sort_key_internal = internal
            break
    self.apply_bom_filter()


def on_bom_filter_field_changed(self, _event: object) -> None:
    selected = self.bom_filter_field_var.get()
    for internal, text_key in BOM_FILTER_FIELD_OPTIONS:
        if selected == self.t(text_key):
            self.bom_filter_field_internal = internal
            break
    self.update_bom_filter_input_state(clear_irrelevant=True)
    self.apply_bom_filter()


def on_bom_sort_order_changed(self, _event: object) -> None:
    selected = self.bom_sort_order_var.get()
    for internal, text_key in (("asc", "ascending"), ("desc", "descending")):
        if selected == self.t(text_key):
            self.bom_sort_order_internal = internal
            break
    self.apply_bom_filter()


def open_new_bom_item_dialog(self) -> None:
    if not self.is_action_allowed("action.bom.new"):
        return
    self.open_bom_item_detail_window(default_bom_item_payload(), is_new=True, is_copy=False)


def edit_selected_bom_item(self) -> None:
    if not self.is_action_allowed("action.bom.edit"):
        return
    row = self.get_selected_bom_item()
    if row is None:
        messagebox.showwarning(self.t("warning_title"), "Please select one BOM item first.")
        return
    self.open_bom_item_detail_window(row, is_new=False, is_copy=False)


def copy_selected_bom_item(self) -> None:
    if not self.is_action_allowed("action.bom.copy"):
        return
    row = self.get_selected_bom_item()
    if row is None:
        messagebox.showwarning(self.t("warning_title"), "Please select one BOM item first.")
        return
    copied = deepcopy(row)
    copied["item_id"] = ""
    copied["item_code"] = ""
    copied["created_at"] = ""
    copied["updated_at"] = ""
    self.open_bom_item_detail_window(copied, is_new=True, is_copy=True)


def delete_selected_bom_item(self) -> None:
    if not self.is_action_allowed("action.bom.delete"):
        return
    checked_rows = [
        row
        for row in self.filtered_bom_items
        if str(row.get("item_id", "")).strip() in self.checked_bom_item_ids
    ]
    target_rows = checked_rows
    if not target_rows:
        selected_row = self.get_selected_bom_item()
        if selected_row is not None:
            target_rows = [selected_row]
    if not target_rows:
        messagebox.showwarning(self.t("warning_title"), "Please select one BOM item first.")
        return
    if len(target_rows) == 1:
        item_name = str(target_rows[0].get("item_name", "")).strip() or str(target_rows[0].get("item_code", "")).strip()
        confirm_message = f"Delete BOM item '{item_name}'?"
    else:
        item_name = f"{len(target_rows)} BOM items"
        confirm_message = f"Delete {len(target_rows)} BOM items?"
    confirmed = messagebox.askyesno(
        self.t("warning_title"),
        confirm_message,
    )
    if not confirmed:
        return
    deleted_names: list[str] = []
    for row in target_rows:
        item_id = str(row.get("item_id", "")).strip()
        if not item_id:
            continue
        deleted = bool(self.delete_bom_item_from_source(item_id))
        if deleted:
            deleted_names.append(str(row.get("item_name", "")).strip() or str(row.get("item_code", "")).strip() or item_id)
            self.checked_bom_item_ids.discard(item_id)
    self.refresh_bom_module()
    if not deleted_names:
        messagebox.showwarning(self.t("warning_title"), "No BOM item was deleted. Please refresh and try again.")
        self.set_status("No BOM item was deleted.")
        return
    if len(deleted_names) == 1:
        self.set_status(f"Deleted BOM item {deleted_names[0]}.")
    else:
        self.set_status(f"Deleted {len(deleted_names)} BOM items.")


def open_bom_report_builder(self) -> None:
    if not self.is_action_allowed("action.bom.export_report"):
        return
    checked_rows = [
        row
        for row in self.filtered_bom_items
        if str(row.get("item_id", "")).strip() in self.checked_bom_item_ids
    ]
    if not checked_rows:
        messagebox.showwarning(self.t("report_no_checked_title_bom"), self.t("report_no_checked_message_bom"))
        return
    self.export_report("bom")


def open_bom_item_detail_window(self, row: dict, *, is_new: bool, is_copy: bool) -> None:
    initial_row = normalize_bom_item_payload(row) or default_bom_item_payload()
    dialog = tk.Toplevel(self.root)
    dialog.title("New BOM Item" if is_new and not is_copy else ("Copy BOM Item" if is_copy else "Edit BOM Item"))
    dialog.resizable(True, True)
    dialog.geometry("1360x860")
    dialog.minsize(1180, 760)

    frame = ttk.Frame(dialog, padding=16)
    frame.pack(fill="both", expand=True)
    frame.columnconfigure(0, weight=1)
    frame.rowconfigure(1, weight=1)

    header = ttk.Frame(frame)
    header.grid(row=0, column=0, sticky="ew")
    ttk.Label(header, text=dialog.title(), style="Section.TLabel").pack(side="left")

    notebook = ttk.Notebook(frame)
    notebook.grid(row=1, column=0, sticky="nsew", pady=(12, 0))
    basic_tab = ttk.Frame(notebook, padding=14)
    spec_tab = ttk.Frame(notebook, padding=14)
    purchase_tab = ttk.Frame(notebook, padding=14)
    notebook.add(basic_tab, text="Basic Information")
    notebook.add(spec_tab, text="Spec")
    notebook.add(purchase_tab, text="Purchase")

    initial_payload = dict(initial_row or {})
    trim_size_value, trim_size_unit = _split_measurement_and_unit(initial_payload.get("trim_size_um", ""))
    length_value, length_unit = _split_measurement_and_unit(initial_payload.get("length_value", ""))
    width_value, width_unit = _split_measurement_and_unit(initial_payload.get("width_value", ""))
    gsm_value, gsm_unit = _split_measurement_and_unit(initial_payload.get("gsm_value", ""))
    initial_payload["trim_size_um"] = trim_size_value or str(initial_payload.get("trim_size_um", "")).strip()
    initial_payload["trim_size_unit"] = str(initial_payload.get("trim_size_unit", "")).strip() or trim_size_unit
    initial_payload["length_value"] = length_value or str(initial_payload.get("length_value", "")).strip()
    initial_payload["length_unit"] = str(initial_payload.get("length_unit", "")).strip() or length_unit
    initial_payload["width_value"] = width_value or str(initial_payload.get("width_value", "")).strip()
    initial_payload["width_unit"] = str(initial_payload.get("width_unit", "")).strip() or width_unit
    initial_payload["gsm_value"] = gsm_value or str(initial_payload.get("gsm_value", "")).strip()
    initial_payload["gsm_unit"] = str(initial_payload.get("gsm_unit", "")).strip() or gsm_unit

    vars_map = {key: tk.StringVar(value=str(initial_payload.get(key, "")).strip()) for key in default_bom_item_payload()}
    text_widgets: dict[str, tk.Text] = {}
    image_preview_state: dict[str, object] = {"photo": None}
    unit_combos: dict[str, ttk.Combobox] = {}
    numeric_vcmd = (dialog.register(lambda proposed: proposed == "" or re.fullmatch(r"\d*(?:\.\d*)?", proposed) is not None), "%P")

    def add_labeled_entry(
        parent_frame: ttk.Frame,
        row_index: int,
        column_index: int,
        label: str,
        key: str,
        *,
        columnspan: int = 1,
        readonly: bool = False,
        validatecommand: tuple[str, str] | None = None,
    ) -> ttk.Entry:
        field = ttk.Frame(parent_frame)
        field.grid(row=row_index, column=column_index, columnspan=columnspan, sticky="ew", padx=8, pady=(0, 12))
        field.columnconfigure(0, weight=1)
        ttk.Label(field, text=label).grid(row=0, column=0, sticky="w", pady=(0, 4))
        entry = ttk.Entry(field, textvariable=vars_map[key])
        if validatecommand is not None:
            entry.configure(validate="key", validatecommand=validatecommand)
        if readonly:
            entry.configure(state="readonly")
        entry.grid(row=1, column=0, sticky="ew")
        return entry

    def add_labeled_combo(
        parent_frame: ttk.Frame,
        row_index: int,
        column_index: int,
        label: str,
        key: str,
        values: list[str],
    ) -> ttk.Combobox:
        field = ttk.Frame(parent_frame)
        field.grid(row=row_index, column=column_index, sticky="ew", padx=8, pady=(0, 12))
        field.columnconfigure(0, weight=1)
        ttk.Label(field, text=label).grid(row=0, column=0, sticky="w", pady=(0, 4))
        combo = ttk.Combobox(field, textvariable=vars_map[key], values=values, state="readonly")
        combo.grid(row=1, column=0, sticky="ew")
        return combo

    def add_labeled_supplier_search_combo(
        parent_frame: ttk.Frame,
        row_index: int,
        column_index: int,
        label: str,
        key: str,
        values: list[str],
    ) -> ttk.Combobox:
        field = ttk.Frame(parent_frame)
        field.grid(row=row_index, column=column_index, sticky="ew", padx=8, pady=(0, 12))
        field.columnconfigure(0, weight=1)
        ttk.Label(field, text=label).grid(row=0, column=0, sticky="w", pady=(0, 4))
        combo = ttk.Combobox(field, textvariable=vars_map[key], values=values, state="normal")
        combo.grid(row=1, column=0, sticky="ew")

        def refresh_matches(_event: object | None = None) -> None:
            query = vars_map[key].get().strip().lower()
            if query:
                matched_values = [value for value in values if query in value.lower()]
            else:
                matched_values = list(values)
            combo.configure(values=matched_values)
            if matched_values:
                try:
                    combo.event_generate("<Down>")
                except tk.TclError:
                    pass

        combo.bind("<KeyRelease>", refresh_matches)
        combo.bind("<FocusIn>", lambda _event: combo.configure(values=list(values)))
        return combo

    def combo_values_with_current(values: list[str], current: object) -> list[str]:
        normalized_values = [str(value).strip().upper() for value in values if str(value).strip()]
        current_value = str(current or "").strip().upper()
        if current_value and current_value not in normalized_values:
            normalized_values.append(current_value)
        return normalized_values

    def get_active_supplier_names_for_purchase() -> list[str]:
        rows: list[dict] = []
        if hasattr(self, "load_supplier_rows_from_source"):
            try:
                rows = [row for row in self.load_supplier_rows_from_source() if isinstance(row, dict)]
            except Exception:
                rows = []
        if not rows:
            try:
                rows = [row for row in list_suppliers(DB_PATH) if isinstance(row, dict)]
            except Exception:
                rows = []
        names: list[str] = []
        seen: set[str] = set()
        for supplier_row in rows:
            status = str(supplier_row.get("status") or "").strip().lower()
            if status != "active":
                continue
            name = str(supplier_row.get("supplier_name") or supplier_row.get("short_name") or supplier_row.get("supplier_code") or "").strip()
            if not name:
                continue
            key_name = name.lower()
            if key_name in seen:
                continue
            seen.add(key_name)
            names.append(name)
        names.sort(key=str.lower)
        current_supplier = vars_map["default_supplier"].get().strip()
        if current_supplier and current_supplier.lower() not in seen:
            names.insert(0, current_supplier)
        return names

    def add_labeled_entry_with_unit(
        parent_frame: ttk.Frame,
        row_index: int,
        column_index: int,
        label: str,
        value_key: str,
        unit_key: str,
        unit_values: list[str],
        *,
        validatecommand: tuple[str, str] | None = None,
    ) -> tuple[ttk.Entry, ttk.Combobox]:
        field = ttk.Frame(parent_frame)
        field.grid(row=row_index, column=column_index, sticky="ew", padx=8, pady=(0, 12))
        field.columnconfigure(0, weight=1)
        field.columnconfigure(1, weight=0)
        ttk.Label(field, text=label).grid(row=0, column=0, columnspan=2, sticky="w", pady=(0, 4))
        entry = ttk.Entry(field, textvariable=vars_map[value_key])
        if validatecommand is not None:
            entry.configure(validate="key", validatecommand=validatecommand)
        entry.grid(row=1, column=0, sticky="ew", padx=(0, 6))
        combo = ttk.Combobox(field, textvariable=vars_map[unit_key], values=unit_values, state="readonly", width=10)
        combo.grid(row=1, column=1, sticky="ew")
        return entry, combo

    def add_labeled_text(
        parent_frame: ttk.Frame,
        row_index: int,
        label: str,
        key: str,
        *,
        height: int = 5,
        columnspan: int = 1,
    ) -> tk.Text:
        host = ttk.LabelFrame(parent_frame, text=label)
        host.grid(row=row_index, column=0, columnspan=columnspan, sticky="nsew", padx=8, pady=(0, 12))
        host.columnconfigure(0, weight=1)
        host.rowconfigure(0, weight=1)
        text = tk.Text(host, height=height, wrap="word", relief="solid", bd=1, font=("Segoe UI", 10))
        text.grid(row=0, column=0, sticky="nsew", padx=8, pady=8)
        value = vars_map[key].get().strip()
        if value:
            text.insert("1.0", value)
        text_widgets[key] = text
        return text

    for tab in (basic_tab, spec_tab, purchase_tab):
        tab.columnconfigure(0, weight=1)
        tab.rowconfigure(0, weight=1)

    basic_host = ttk.Frame(basic_tab)
    basic_host.grid(row=0, column=0, sticky="nsew")
    basic_host.columnconfigure(0, weight=1)
    basic_host.columnconfigure(1, weight=0)
    basic_host.rowconfigure(1, weight=1)

    basic_form = ttk.Frame(basic_host)
    basic_form.grid(row=0, column=0, sticky="nsew", padx=(0, 12))
    basic_form.columnconfigure(0, weight=1)
    basic_form.columnconfigure(1, weight=1)

    add_labeled_entry(basic_form, 0, 0, "Style Code", "style_code")
    add_labeled_entry(basic_form, 0, 1, "BOM Version", "bom_version")
    add_labeled_entry(basic_form, 1, 0, "Item Code", "item_code")
    add_labeled_entry(basic_form, 1, 1, "Item Name", "item_name")
    item_type_combo = add_labeled_combo(basic_form, 2, 0, "Item Type", "item_type", list(ITEM_TYPE_OPTIONS))
    add_labeled_entry(basic_form, 2, 1, "Section", "section")
    add_labeled_entry(basic_form, 3, 0, "Article / Part No", "article_ref")
    add_labeled_combo(basic_form, 3, 1, "Status", "status", list(ITEM_STATUS_OPTIONS))
    add_labeled_entry(basic_form, 4, 0, "Main Color", "color")
    add_labeled_entry(basic_form, 4, 1, "Color Code", "color_code")

    preview_frame = ttk.LabelFrame(basic_host, text="Image Preview")
    preview_frame.grid(row=0, column=1, sticky="ne", padx=(8, 0))
    preview_frame.columnconfigure(0, weight=1)
    preview_label = ttk.Label(preview_frame, anchor="center", relief="solid")
    preview_label.grid(row=0, column=0, sticky="nsew", padx=10, pady=10)

    description_host = ttk.Frame(basic_host)
    description_host.grid(row=1, column=0, columnspan=2, sticky="nsew", pady=(4, 0))
    description_host.columnconfigure(0, weight=1)
    description_host.rowconfigure(0, weight=1)
    add_labeled_text(description_host, 0, "Description", "description", height=10)

    spec_host = ttk.Frame(spec_tab)
    spec_host.grid(row=0, column=0, sticky="nsew")
    spec_host.columnconfigure(0, weight=1)
    spec_host.columnconfigure(1, weight=1)
    spec_host.rowconfigure(5, weight=1)
    add_labeled_entry(spec_host, 0, 0, "Composition", "composition")
    add_labeled_entry(spec_host, 0, 1, "Position of Use", "position_of_use")
    unit_combos["trim_size_um"] = add_labeled_combo(
        spec_host,
        1,
        0,
        "Trim Size",
        "trim_size_um",
        self.get_bom_trim_size_options(vars_map["style_code"].get(), vars_map["trim_size_um"].get()),
    )
    _length_entry, unit_combos["length_unit"] = add_labeled_entry_with_unit(
        spec_host,
        1,
        1,
        "Length",
        "length_value",
        "length_unit",
        list(LENGTH_UOM_OPTIONS),
        validatecommand=numeric_vcmd,
    )
    _width_entry, unit_combos["width_unit"] = add_labeled_entry_with_unit(
        spec_host,
        2,
        0,
        "Width",
        "width_value",
        "width_unit",
        list(LENGTH_UOM_OPTIONS),
        validatecommand=numeric_vcmd,
    )
    _gsm_entry, unit_combos["gsm_unit"] = add_labeled_entry_with_unit(
        spec_host,
        2,
        1,
        "GSM / Weight",
        "gsm_value",
        "gsm_unit",
        list(WEIGHT_UOM_OPTIONS),
        validatecommand=numeric_vcmd,
    )
    add_labeled_entry(spec_host, 3, 0, "Waste (%)", "waste_percent", validatecommand=numeric_vcmd)
    spec_notes = ttk.Frame(spec_host)
    spec_notes.grid(row=4, column=0, columnspan=2, sticky="nsew")
    spec_notes.columnconfigure(0, weight=1)
    spec_notes.columnconfigure(1, weight=1)
    add_labeled_text(spec_notes, 0, "Spec Summary", "spec_summary", height=6)
    add_labeled_text(spec_notes, 1, "Tech Comment", "tech_comment", height=6)

    purchase_host = ttk.Frame(purchase_tab)
    purchase_host.grid(row=0, column=0, sticky="nsew")
    purchase_host.columnconfigure(0, weight=1)
    purchase_host.columnconfigure(1, weight=1)
    purchase_host.rowconfigure(6, weight=1)
    active_supplier_names = get_active_supplier_names_for_purchase()
    add_labeled_supplier_search_combo(purchase_host, 0, 0, "Default Supplier", "default_supplier", active_supplier_names)
    add_labeled_entry(purchase_host, 0, 1, "Supplier Item Code", "supplier_item_code")
    _moq_entry, unit_combos["moq_unit"] = add_labeled_entry_with_unit(
        purchase_host,
        1,
        0,
        "MOQ",
        "moq",
        "moq_unit",
        _purchase_uom_options(vars_map["moq_unit"].get() or vars_map["purchase_uom"].get()),
        validatecommand=numeric_vcmd,
    )
    _moq_charge_entry, unit_combos["moq_charge_unit"] = add_labeled_entry_with_unit(
        purchase_host,
        1,
        1,
        "Charge MOQ",
        "moq_charge",
        "moq_charge_unit",
        combo_values_with_current(BOM_CHARGE_UNIT_OPTIONS, vars_map["moq_charge_unit"].get()),
        validatecommand=numeric_vcmd,
    )
    _mcq_entry, unit_combos["mcq_unit"] = add_labeled_entry_with_unit(
        purchase_host,
        2,
        0,
        "MCQ",
        "mcq",
        "mcq_unit",
        _purchase_uom_options(vars_map["mcq_unit"].get() or vars_map["purchase_uom"].get()),
        validatecommand=numeric_vcmd,
    )
    _mcq_charge_entry, unit_combos["mcq_charge_unit"] = add_labeled_entry_with_unit(
        purchase_host,
        2,
        1,
        "Charge MCQ",
        "mcq_charge",
        "mcq_charge_unit",
        combo_values_with_current(BOM_CHARGE_UNIT_OPTIONS, vars_map["mcq_charge_unit"].get()),
        validatecommand=numeric_vcmd,
    )
    unit_combos["purchase_uom"] = add_labeled_combo(
        purchase_host,
        3,
        0,
        "Purchase UOM",
        "purchase_uom",
        _purchase_uom_options(vars_map["purchase_uom"].get() or vars_map["uom"].get()),
    )
    _lead_time_entry, unit_combos["lead_time_unit"] = add_labeled_entry_with_unit(
        purchase_host,
        3,
        1,
        "Lead Time",
        "lead_time",
        "lead_time_unit",
        combo_values_with_current(LEAD_TIME_UNIT_OPTIONS, vars_map["lead_time_unit"].get()),
        validatecommand=numeric_vcmd,
    )
    _sample_lead_time_entry, unit_combos["sample_lead_time_unit"] = add_labeled_entry_with_unit(
        purchase_host,
        4,
        0,
        "Sample Lead Time",
        "sample_lead_time",
        "sample_lead_time_unit",
        combo_values_with_current(LEAD_TIME_UNIT_OPTIONS, vars_map["sample_lead_time_unit"].get()),
        validatecommand=numeric_vcmd,
    )
    _standard_price_entry, unit_combos["currency"] = add_labeled_entry_with_unit(
        purchase_host,
        4,
        1,
        "Standard Price",
        "standard_price",
        "currency",
        combo_values_with_current(BOM_CURRENCY_OPTIONS, vars_map["currency"].get()),
        validatecommand=numeric_vcmd,
    )
    add_labeled_entry(purchase_host, 5, 0, "FDS Date", "fds_date")
    purchase_note_host = ttk.Frame(purchase_host)
    purchase_note_host.grid(row=6, column=0, columnspan=2, sticky="nsew")
    purchase_note_host.columnconfigure(0, weight=1)
    purchase_note_host.rowconfigure(0, weight=1)
    add_labeled_text(purchase_note_host, 0, "Purchase Note", "purchase_note", height=8, columnspan=2)

    def refresh_image_preview(*_args: object) -> None:
        image_path = vars_map["image_path"].get().strip()
        photo = None
        if image_path:
            if hasattr(self, "create_tech_pack_bom_preview_photo"):
                photo = self.create_tech_pack_bom_preview_photo(image_path, target_width=220, target_height=220)
            elif hasattr(self, "create_tech_pack_thumbnail"):
                photo = self.create_tech_pack_thumbnail({"image_path": image_path}, target_width=220, target_height=220)
        if photo is not None:
            preview_label.configure(image=photo, text="")
            preview_label.image = photo
            image_preview_state["photo"] = photo
        else:
            preview_label.configure(image="", text="No preview image", anchor="center")
            preview_label.image = None
            image_preview_state["photo"] = None

    vars_map["image_path"].trace_add("write", refresh_image_preview)
    refresh_image_preview()

    def refresh_uom_combo_values(*_args: object) -> None:
        item_type_value = vars_map["item_type"].get().strip()
        if "purchase_uom" in unit_combos:
            purchase_current = vars_map["purchase_uom"].get().strip()
            unit_combos["purchase_uom"].configure(values=_purchase_uom_options(purchase_current))
        if "moq_unit" in unit_combos:
            unit_combos["moq_unit"].configure(values=_purchase_uom_options(vars_map["moq_unit"].get().strip()))
        if "mcq_unit" in unit_combos:
            unit_combos["mcq_unit"].configure(values=_purchase_uom_options(vars_map["mcq_unit"].get().strip()))

    def refresh_trim_size_combo_values(*_args: object) -> None:
        if "trim_size_um" not in unit_combos:
            return
        unit_combos["trim_size_um"].configure(
            values=self.get_bom_trim_size_options(vars_map["style_code"].get(), vars_map["trim_size_um"].get())
        )

    vars_map["item_type"].trace_add("write", refresh_uom_combo_values)
    vars_map["style_code"].trace_add("write", refresh_trim_size_combo_values)
    refresh_uom_combo_values()
    refresh_trim_size_combo_values()

    actions = ttk.Frame(frame)
    actions.grid(row=2, column=0, sticky="e", pady=(12, 0))

    def save_action() -> None:
        required_action = "action.bom.copy" if is_copy else ("action.bom.new" if is_new else "action.bom.edit")
        if not self.is_action_allowed(required_action):
            return
        payload = default_bom_item_payload()
        for key in payload:
            payload[key] = vars_map[key].get().strip()
        payload["currency"] = payload["currency"].upper()
        payload["moq_unit"] = payload["moq_unit"].upper()
        payload["moq_charge_unit"] = payload["moq_charge_unit"].upper()
        payload["mcq_unit"] = payload["mcq_unit"].upper()
        payload["mcq_charge_unit"] = payload["mcq_charge_unit"].upper()
        payload["lead_time_unit"] = payload["lead_time_unit"].upper()
        payload["sample_lead_time_unit"] = payload["sample_lead_time_unit"].upper()
        payload["purchase_uom"] = payload["purchase_uom"].upper()
        payload["trim_size_um"] = _normalize_trim_size_option(payload["trim_size_um"])
        payload["trim_size_unit"] = ""
        payload["length_value"] = " ".join(part for part in (payload["length_value"], payload["length_unit"]) if part).strip()
        payload["width_value"] = " ".join(part for part in (payload["width_value"], payload["width_unit"]) if part).strip()
        payload["gsm_value"] = " ".join(part for part in (payload["gsm_value"], payload["gsm_unit"]) if part).strip()
        for key, widget in text_widgets.items():
            payload[key] = widget.get("1.0", "end-1c").strip()
        payload["status"] = str(payload.get("status", "draft")).strip().lower() or "draft"
        payload["uom"] = payload["uom"] or payload["purchase_uom"]
        payload["qty_per_garment"] = ""
        if not payload["style_code"]:
            messagebox.showwarning(self.t("warning_title"), "Style Code is required.")
            notebook.select(basic_tab)
            return
        if not payload["bom_version"]:
            messagebox.showwarning(self.t("warning_title"), "BOM Version is required.")
            notebook.select(basic_tab)
            return
        if not payload["item_name"]:
            messagebox.showwarning(self.t("warning_title"), "Item Name is required.")
            notebook.select(basic_tab)
            return
        if not payload["item_code"]:
            payload["item_code"] = f"{payload['style_code']}-ITEM"
        if payload["status"] == "approved" and not self.is_action_allowed("action.bom.approve"):
            if hasattr(self, "deny_permission_action"):
                self.deny_permission_action("action.bom.approve")
            return
        existing = normalize_bom_item_payload(initial_row) or default_bom_item_payload()
        payload["item_id"] = payload["item_id"] or str(existing.get("item_id", "")).strip() or secrets.token_hex(16)
        payload["created_at"] = str(existing.get("created_at", "")).strip() or _bom_now_text()
        payload["updated_at"] = _bom_now_text()
        try:
            saved = self.save_bom_item_to_source(payload)
        except Exception as exc:
            messagebox.showerror(self.t("warning_title"), str(exc))
            return
        if not saved:
            messagebox.showerror(self.t("warning_title"), "Could not save BOM item.")
            return
        self.refresh_bom_module()
        saved_item_id = str(saved.get("item_id", "")).strip()
        if saved_item_id and hasattr(self, "bom_tree") and self.bom_tree.exists(saved_item_id):
            self.bom_tree.selection_set(saved_item_id)
            self.bom_tree.focus(saved_item_id)
        self.set_status(f"Saved BOM item {saved.get('item_code', '')}.")
        dialog.destroy()

    ttk.Button(actions, text=self.t("close"), command=dialog.destroy).pack(side="right")
    ttk.Button(actions, text="Save", command=save_action).pack(side="right", padx=(0, 8))


def _build_bom_item_code(style_code: str, section_name: str, item_index: int) -> str:
    section_upper = str(section_name or "").strip().upper()
    short_code = SECTION_CODE_MAP.get(section_upper, "OTH")
    return f"{style_code}-{short_code}-{item_index:03d}"


def _build_bom_spec_summary(item: dict, qty_um_text: str) -> str:
    parts = [
        str(item.get("description", "")).strip(),
        str(item.get("composition", "")).strip(),
        str(item.get("trim_size_um", "")).strip(),
        str(qty_um_text or "").strip(),
    ]
    return " | ".join(part for part in parts if part)


def _build_bom_variant_spec_summary(item: dict, qty_um_text: str, color_value: str, color_code: str) -> str:
    parts = [
        _build_bom_spec_summary(item, qty_um_text),
        str(color_value or "").strip(),
        str(color_code or "").strip(),
    ]
    return " | ".join(part for part in parts if part)


def _tech_pack_bom_item_needs_color_split(self, section_name: str, item: dict, colorways: list[str]) -> bool:
    normalized_colorways = [str(item).strip() for item in colorways if str(item).strip()]
    if len(normalized_colorways) <= 1:
        return False
    section_upper = str(section_name or "").strip().upper()
    if section_upper == "MATERIAL":
        return True
    item_text = " ".join(
        str(item.get(key, "")).upper()
        for key in ("component", "article_ref", "description", "composition", "position", "comments")
    )
    if "DTM" in item_text:
        return True
    if hasattr(self, "tech_pack_bom_item_has_colorway_data") and self.tech_pack_bom_item_has_colorway_data(item):
        return True
    for colorway in normalized_colorways:
        tokens = [token for token in colorway.upper().split() if token]
        if tokens and any(token in item_text for token in tokens):
            return True
    return False


def _build_tech_pack_bom_item_variants(
    self,
    tech_pack_row: dict,
    section_name: str,
    item: dict,
    display_values: dict[str, str],
    colorways: list[str],
) -> list[dict[str, str]]:
    normalized_colorways = [str(entry).strip() for entry in colorways if str(entry).strip()]
    if not self._tech_pack_bom_item_needs_color_split(section_name, item, normalized_colorways):
        return [{"color": "", "color_code": "", "variant_label": ""}]

    color_lookup = self.get_tech_pack_color_lookup(tech_pack_row) if hasattr(self, "get_tech_pack_color_lookup") else {}
    variants: list[dict[str, str]] = []
    for color_index, colorway in enumerate(normalized_colorways):
        color_meta = color_lookup.get(colorway.upper(), {}) if isinstance(color_lookup, dict) else {}
        default_color_name = str(color_meta.get("color_name", "")).strip()
        primary = default_color_name
        secondary = ""
        if hasattr(self, "get_tech_pack_bom_color_primary_line"):
            primary = self.get_tech_pack_bom_color_primary_line(section_name, item, default_color_name, color_index)
        if hasattr(self, "get_tech_pack_bom_color_secondary_line"):
            secondary = self.get_tech_pack_bom_color_secondary_line(item, color_index)
        if hasattr(self, "get_tech_pack_bom_color_override"):
            primary = self.get_tech_pack_bom_color_override(item, colorway, "primary", primary)
            secondary = self.get_tech_pack_bom_color_override(item, colorway, "secondary", secondary)
        color_value = primary or colorway
        color_code = secondary
        variant_label = " | ".join(part for part in (color_value, color_code) if part)
        variants.append(
            {
                "color": color_value,
                "color_code": color_code,
                "variant_label": variant_label,
            }
        )
    return variants or [{"color": "", "color_code": "", "variant_label": ""}]


def get_selected_or_prompt_bom_tech_pack_styles(self) -> list[str]:
    selected_styles = sorted(
        {
            normalize_style_identifier(style_code)
            for style_code in getattr(self, "checked_tech_pack_styles", set())
            if normalize_style_identifier(style_code)
        }
    )
    if selected_styles:
        return selected_styles
    selected_row = self.get_selected_tech_pack_row() if hasattr(self, "get_selected_tech_pack_row") else None
    if isinstance(selected_row, dict):
        style_code = normalize_style_identifier(selected_row.get("style_code"))
        if style_code:
            return [style_code]
    if not getattr(self, "tech_pack_rows", []):
        try:
            self.refresh_tech_pack_module(silent=True)
        except Exception:
            pass
    available_styles = sorted(
        {
            normalize_style_identifier(row.get("style_code"))
            for row in getattr(self, "tech_pack_rows", [])
            if isinstance(row, dict) and normalize_style_identifier(row.get("style_code"))
        }
    )
    if not available_styles:
        return []

    dialog = tk.Toplevel(self.root)
    dialog.title("Select Tech Pack Style")
    dialog.transient(self.root)
    dialog.grab_set()
    dialog.resizable(False, False)
    frame = ttk.Frame(dialog, padding=16)
    frame.pack(fill="both", expand=True)
    frame.columnconfigure(0, weight=1)
    frame.rowconfigure(2, weight=1)
    ttk.Label(frame, text="Search one Tech Pack style to generate BOM items from.").grid(row=0, column=0, sticky="w")
    selected_var = tk.StringVar(value="")
    search_var = tk.StringVar(value="")
    search_entry = ttk.Entry(frame, textvariable=search_var, width=36)
    search_entry.grid(row=1, column=0, sticky="ew", pady=(8, 0))
    result_frame = ttk.Frame(frame)
    result_frame.grid(row=2, column=0, sticky="nsew", pady=(8, 0))
    result_frame.columnconfigure(0, weight=1)
    result_frame.rowconfigure(0, weight=1)
    result_list = tk.Listbox(result_frame, height=8, exportselection=False, font=("Segoe UI", 10))
    result_list.grid(row=0, column=0, sticky="nsew")
    result_scroll = ttk.Scrollbar(result_frame, orient="vertical", command=result_list.yview)
    result_scroll.grid(row=0, column=1, sticky="ns")
    result_list.configure(yscrollcommand=result_scroll.set)
    result: dict[str, list[str]] = {"items": []}

    def get_filtered_candidates() -> list[str]:
        query = search_var.get().strip().lower()
        if not query:
            return list(available_styles)
        starts_with = [candidate for candidate in available_styles if candidate.lower().startswith(query)]
        contains = [candidate for candidate in available_styles if query in candidate.lower() and candidate not in starts_with]
        return starts_with + contains

    def refresh_candidate_list(*_args: object) -> None:
        filtered = get_filtered_candidates()
        result_list.delete(0, "end")
        for candidate in filtered:
            result_list.insert("end", candidate)
        if filtered:
            result_list.selection_clear(0, "end")
            result_list.selection_set(0)
            result_list.activate(0)
            selected_var.set(filtered[0])
        else:
            selected_var.set("")

    def sync_selected_from_list(_event: object | None = None) -> None:
        selection = result_list.curselection()
        if not selection:
            selected_var.set("")
            return
        selected_var.set(str(result_list.get(selection[0])).strip())

    def choose() -> None:
        style_code = normalize_style_identifier(selected_var.get())
        if style_code:
            result["items"] = [style_code]
        dialog.destroy()

    actions = ttk.Frame(frame)
    actions.grid(row=3, column=0, sticky="e", pady=(14, 0))
    ttk.Button(actions, text=self.t("close"), command=dialog.destroy).pack(side="right")
    ttk.Button(actions, text="Use Style", command=choose).pack(side="right", padx=(0, 8))
    search_var.trace_add("write", refresh_candidate_list)
    result_list.bind("<<ListboxSelect>>", sync_selected_from_list)
    result_list.bind("<Double-Button-1>", lambda _event: choose())
    refresh_candidate_list()
    search_entry.focus_set()
    self.center_dialog(dialog, min_width=440, min_height=300)
    dialog.wait_window()
    return list(result.get("items", []))


def build_bom_items_from_tech_pack_style(self, style_code: str) -> tuple[list[dict], int]:
    _append_bom_debug_log(f"generate style start style={style_code}")
    tech_pack_row = self.get_tech_pack_row_by_style_code(style_code) if hasattr(self, "get_tech_pack_row_by_style_code") else None
    if not isinstance(tech_pack_row, dict):
        _append_bom_debug_log(f"generate style missing tech-pack row style={style_code}")
        return [], 0
    bom_sections = [section for section in list(tech_pack_row.get("bom_data", [])) if isinstance(section, dict)]
    if not bom_sections:
        _append_bom_debug_log(f"generate style no bom_data style={style_code}")
        return [], 0
    existing_items = {
        (
            normalize_style_identifier(item.get("style_code")),
            str(item.get("bom_version", "")).strip(),
            str(item.get("item_code", "")).strip(),
        ): dict(item)
        for item in self.bom_items
        if isinstance(item, dict)
    }
    overview_data = tech_pack_row.get("overview_data", {}) if isinstance(tech_pack_row.get("overview_data", {}), dict) else {}
    color_tokens = self.build_tech_pack_bom_color_token_set(tech_pack_row) if hasattr(self, "build_tech_pack_bom_color_token_set") else set()
    bom_version = "TP"
    saved_rows: list[dict] = []
    generated_count = 0
    section_counters: dict[str, int] = {}
    for section_index, section in enumerate(bom_sections):
        section_name = str(section.get("section", "")).strip()
        items = [item for item in list(section.get("items", [])) if isinstance(item, dict)]
        colorways = [str(entry).strip() for entry in list(section.get("colorways", [])) if str(entry).strip()]
        for item_index, item in enumerate(items, start=1):
            display_values = (
                self.get_tech_pack_bom_display_values(section_name, item, color_tokens)
                if hasattr(self, "get_tech_pack_bom_display_values")
                else {
                    "component": str(item.get("component", "")).strip(),
                    "description": str(item.get("description", "")).strip(),
                    "composition": str(item.get("composition", "")).strip(),
                    "comments": str(item.get("comments", "")).strip(),
                    "article": str(item.get("article_ref", "")).strip(),
                }
            )
            qty_um_text = str(item.get("qty_um", "")).strip()
            qty_per_garment, parsed_uom = _parse_qty_and_uom(qty_um_text)
            thumbnail_path = (
                self.resolve_tech_pack_bom_item_thumbnail_path(tech_pack_row, section, item, item_index)
                if hasattr(self, "resolve_tech_pack_bom_item_thumbnail_path")
                else str(item.get("thumbnail_path", "")).strip()
            )
            variants = self._build_tech_pack_bom_item_variants(
                tech_pack_row,
                section_name,
                item,
                display_values,
                colorways,
            )
            for variant in variants:
                section_counter = section_counters.get(section_name.upper(), 0) + 1
                section_counters[section_name.upper()] = section_counter
                item_code = _build_bom_item_code(style_code, section_name, section_counter)
                existing = existing_items.get((style_code, bom_version, item_code), {})
                item_name = str(display_values.get("component", "")).strip() or str(display_values.get("description", "")).strip()
                if str(variant.get("variant_label", "")).strip():
                    item_name = f"{item_name} {variant.get('variant_label', '')}".strip()
                payload = default_bom_item_payload()
                payload.update(
                    {
                        "item_id": str(existing.get("item_id", "")).strip(),
                        "bom_id": str(existing.get("bom_id", "")).strip(),
                        "style_code": style_code,
                        "bom_version": bom_version,
                        "bom_name": "Tech Pack Imported BOM",
                        "season": str(tech_pack_row.get("season", "")).strip() or str(overview_data.get("season", "")).strip(),
                        "collection": str(tech_pack_row.get("collection", "")).strip() or str(overview_data.get("brand", "")).strip(),
                        "item_code": item_code,
                        "item_name": item_name,
                        "item_type": SECTION_TYPE_MAP.get(section_name.upper(), "Other"),
                        "section": section_name,
                        "article_ref": str(item.get("article_ref", "")).strip(),
                        "color": str(variant.get("color", "")).strip(),
                        "color_code": str(variant.get("color_code", "")).strip(),
                        "description": str(display_values.get("description", "")).strip(),
                        "composition": str(display_values.get("composition", "")).strip(),
                        "position_of_use": str(item.get("position", "")).strip(),
                        "spec_summary": _build_bom_variant_spec_summary(
                            item,
                            qty_um_text,
                            str(variant.get("color", "")).strip(),
                            str(variant.get("color_code", "")).strip(),
                        ),
                        "uom": parsed_uom,
                        "qty_per_garment": qty_per_garment,
                        "trim_size_um": str(item.get("trim_size_um", "")).strip(),
                        "width_value": "",
                        "gsm_value": "",
                        "waste_percent": str(existing.get("waste_percent", "")).strip(),
                        "tech_comment": str(display_values.get("comments", "")).strip(),
                        "default_supplier": str(item.get("supplier", "")).strip(),
                        "supplier_item_code": str(existing.get("supplier_item_code", "")).strip(),
                        "moq": str(existing.get("moq", "")).strip(),
                        "moq_unit": str(existing.get("moq_unit", "")).strip(),
                        "moq_charge": str(existing.get("moq_charge", "")).strip(),
                        "moq_charge_unit": str(existing.get("moq_charge_unit", "")).strip(),
                        "mcq": str(existing.get("mcq", "")).strip(),
                        "mcq_unit": str(existing.get("mcq_unit", "")).strip(),
                        "mcq_charge": str(existing.get("mcq_charge", "")).strip(),
                        "mcq_charge_unit": str(existing.get("mcq_charge_unit", "")).strip(),
                        "lead_time": str(existing.get("lead_time", "")).strip(),
                        "lead_time_unit": str(existing.get("lead_time_unit", "")).strip(),
                        "purchase_uom": parsed_uom or str(existing.get("purchase_uom", "")).strip(),
                        "standard_price": str(existing.get("standard_price", "")).strip(),
                        "currency": str(existing.get("currency", "")).strip(),
                        "purchase_note": str(existing.get("purchase_note", "")).strip(),
                        "image_path": str(thumbnail_path or "").strip(),
                        "status": "draft",
                        "source_style_code": style_code,
                        "source_section": section_name,
                        "source_section_index": str(section_index),
                        "source_item_index": str(item_index - 1),
                        "source_payload_json": json.dumps(item, ensure_ascii=False),
                        "created_at": _bom_now_text(),
                    }
                )
                saved = self.save_bom_item_to_source(payload)
                if isinstance(saved, dict) and saved:
                    saved_rows.append(saved)
                    generated_count += 1
    _append_bom_debug_log(f"generate style done style={style_code} generated_count={generated_count}")
    return saved_rows, generated_count


def generate_bom_from_tech_pack(self) -> None:
    if not self.is_action_allowed("action.bom.generate_from_tech_pack"):
        return
    selected_styles = self.get_selected_or_prompt_bom_tech_pack_styles()
    if not selected_styles:
        messagebox.showwarning(self.t("warning_title"), "Please choose at least one Tech Pack style first.")
        return
    generated_total = 0
    failed: list[str] = []
    for style_code in selected_styles:
        try:
            _saved_rows, generated_count = self.build_bom_items_from_tech_pack_style(style_code)
            if generated_count <= 0:
                failed.append(f"{style_code}: no BOM item found in Tech Pack.")
            else:
                generated_total += generated_count
        except Exception as exc:
            _append_bom_debug_log(
                "generate style error="
                + style_code
                + " message="
                + str(exc)
                + " traceback="
                + traceback.format_exc(limit=8).replace("\n", " | ")
            )
            failed.append(f"{style_code}: {exc}")
    self.refresh_bom_module()
    if failed:
        messagebox.showwarning(self.t("warning_title"), "\n".join(failed[:10]))
    if generated_total > 0:
        messagebox.showinfo(self.t("import_done_title"), f"Generated {generated_total} BOM item(s) from Tech Pack.")
        self.set_status(f"Generated {generated_total} BOM item(s) from Tech Pack.")


def _stable_fds_bom_item_id(style_code: str, bom_version: str, item_code: str) -> str:
    source = "|".join(["FDS", str(bom_version or "").strip().upper(), str(item_code or "").strip().upper()])
    return hashlib.sha1(source.encode("utf-8")).hexdigest()[:32]


def _format_fds_spec_summary(fds_data: dict, variant: dict) -> str:
    parts = [
        str(fds_data.get("commodity", "")).strip(),
        str(fds_data.get("description", "")).strip(),
        str(fds_data.get("content", "")).strip(),
        str(fds_data.get("construction", "")).strip(),
    ]
    gsm_text = " ".join(
        token
        for token in [str(fds_data.get("gsm_value", "")).strip(), str(fds_data.get("gsm_unit", "")).strip()]
        if token
    )
    width_text = " ".join(
        token
        for token in [str(fds_data.get("width_value", "")).strip(), str(fds_data.get("width_unit", "")).strip()]
        if token
    )
    if gsm_text:
        parts.append(gsm_text)
    if width_text:
        parts.append(width_text)
    color = str(variant.get("color", "")).strip()
    if color:
        parts.append(f"Color: {color}")
    print_type = str(variant.get("print_type", "")).strip()
    if print_type:
        parts.append(print_type)
    return " | ".join(part for part in parts if part and part != "/")


def _merge_fds_purchase_note(existing_note: object, fds_data: dict) -> str:
    existing_text = str(existing_note or "").strip()
    surcharge_note = str(fds_data.get("surcharge_note", "") or "").strip()
    if not surcharge_note:
        return existing_text
    note_line = f"SURCHARGE FOR UNDER MOQ/MCQ: {surcharge_note}"
    if not existing_text:
        return note_line
    if note_line in existing_text or surcharge_note in existing_text:
        return existing_text
    return existing_text + "\n" + note_line


def build_bom_items_from_fds_data(self, fds_data: dict, *, style_code: str, bom_version: str, fds_date: str) -> tuple[list[dict], int]:
    normalized_style = normalize_style_identifier(style_code)
    normalized_version = str(bom_version or "").strip() or "FDS"
    normalized_date = str(fds_date or "").strip() or str(fds_data.get("fds_date", "")).strip()

    try:
        current_items = [normalize_bom_item_payload(item) for item in self.load_bom_items_from_source()]
    except Exception:
        current_items = [normalize_bom_item_payload(item) for item in getattr(self, "bom_items", []) or []]
    existing_items = {
        (
            normalize_style_identifier(item.get("style_code")),
            str(item.get("bom_version", "")).strip(),
            str(item.get("item_code", "")).strip(),
        ): item
        for item in current_items
        if isinstance(item, dict) and str(item.get("item_code", "")).strip()
    }

    saved_rows: list[dict] = []
    for index, variant in enumerate(list(fds_data.get("variants") or []), start=1):
        if not isinstance(variant, dict):
            continue
        item_code = str(variant.get("item_code", "")).strip().upper()
        if not item_code:
            continue
        existing = existing_items.get((normalized_style, normalized_version, item_code), {})
        print_type = str(variant.get("print_type", "")).strip()
        color = str(variant.get("color", "")).strip()
        item_name = " ".join(
            token
            for token in [
                str(fds_data.get("description", "")).strip() or str(fds_data.get("article", "")).strip(),
                print_type,
                color,
            ]
            if token
        )
        payload = default_bom_item_payload()
        payload.update(
            {
                "item_id": str(existing.get("item_id", "")).strip() or _stable_fds_bom_item_id(normalized_style, normalized_version, item_code),
                "bom_id": str(existing.get("bom_id", "")).strip(),
                "style_code": normalized_style,
                "bom_version": normalized_version,
                "bom_name": "FDS Imported BOM",
                "item_code": item_code,
                "item_name": item_name,
                "item_type": "Fabric",
                "section": str(fds_data.get("section", "")).strip() or "Fabric",
                "article_ref": str(fds_data.get("article", "")).strip(),
                "color": color,
                "color_code": str(variant.get("color_code", "")).strip(),
                "description": str(fds_data.get("description", "")).strip(),
                "composition": str(fds_data.get("content", "")).strip(),
                "position_of_use": str(existing.get("position_of_use", "")).strip(),
                "spec_summary": _format_fds_spec_summary(fds_data, variant),
                "uom": str(variant.get("purchase_uom", "")).strip() or str(existing.get("uom", "")).strip(),
                "trim_size_um": str(existing.get("trim_size_um", "")).strip(),
                "width_value": str(fds_data.get("width_value", "")).strip(),
                "width_unit": str(fds_data.get("width_unit", "")).strip(),
                "gsm_value": str(fds_data.get("gsm_value", "")).strip(),
                "gsm_unit": str(fds_data.get("gsm_unit", "")).strip(),
                "waste_percent": str(existing.get("waste_percent", "")).strip(),
                "tech_comment": str(fds_data.get("construction", "")).strip(),
                "default_supplier": str(existing.get("default_supplier", "")).strip(),
                "supplier_item_code": str(existing.get("supplier_item_code", "")).strip(),
                "moq": str(fds_data.get("moq", "")).strip(),
                "moq_unit": str(fds_data.get("moq_unit", "")).strip(),
                "moq_charge": str(existing.get("moq_charge", "")).strip(),
                "moq_charge_unit": str(existing.get("moq_charge_unit", "")).strip(),
                "mcq": str(fds_data.get("mcq", "")).strip(),
                "mcq_unit": str(fds_data.get("mcq_unit", "")).strip(),
                "mcq_charge": str(existing.get("mcq_charge", "")).strip(),
                "mcq_charge_unit": str(existing.get("mcq_charge_unit", "")).strip(),
                "lead_time": str(fds_data.get("lead_time", "")).strip(),
                "lead_time_unit": str(fds_data.get("lead_time_unit", "")).strip(),
                "sample_lead_time": str(fds_data.get("sample_lead_time", "")).strip(),
                "sample_lead_time_unit": str(fds_data.get("sample_lead_time_unit", "")).strip(),
                "purchase_uom": str(variant.get("purchase_uom", "")).strip(),
                "standard_price": str(variant.get("price", "")).strip(),
                "currency": str(variant.get("currency", "")).strip(),
                "purchase_note": _merge_fds_purchase_note(existing.get("purchase_note", ""), fds_data),
                "fds_date": normalized_date,
                "status": str(existing.get("status", "")).strip() or "draft",
                "source_style_code": normalized_style,
                "source_section": "FDS",
                "source_section_index": "0",
                "source_item_index": str(index - 1),
                "source_payload_json": json.dumps({"fds": fds_data, "variant": variant}, ensure_ascii=False),
                "created_at": str(existing.get("created_at", "")).strip() or _bom_now_text(),
            }
        )
        saved = self.save_bom_item_to_source(payload)
        if isinstance(saved, dict) and saved:
            saved_rows.append(saved)
    return saved_rows, len(saved_rows)


def open_import_fds_menu(self) -> None:
    if not self.is_action_allowed("action.bom.import_fds"):
        return
    button = self.widget_text.get("bom_import_fds_button") if hasattr(self, "widget_text") else None
    menu = tk.Menu(self.root, tearoff=False)
    menu.add_command(label="Single file", command=self.import_fds_single_file)
    menu.add_command(label="Folder", command=self.import_fds_folder)
    try:
        if button is not None:
            x = button.winfo_rootx()
            y = button.winfo_rooty() + button.winfo_height()
        else:
            x = self.root.winfo_pointerx()
            y = self.root.winfo_pointery()
        menu.tk_popup(x, y)
    finally:
        menu.grab_release()


def import_fds_single_file(self) -> None:
    if not self.is_action_allowed("action.bom.import_fds"):
        return
    file_path = filedialog.askopenfilename(
        parent=self.root,
        title="Import FDS - Single file",
        filetypes=[
            ("Excel files", "*.xls *.xlsx *.xlsm"),
            ("All files", "*.*"),
        ],
    )
    if not file_path:
        return
    self.import_fds_files([file_path])


def import_fds_folder(self) -> None:
    if not self.is_action_allowed("action.bom.import_fds"):
        return
    folder_path = filedialog.askdirectory(parent=self.root, title="Import FDS - Folder")
    if not folder_path:
        return
    folder = Path(folder_path)
    file_paths = [
        str(path)
        for path in sorted(folder.iterdir(), key=lambda item: item.name.lower())
        if path.is_file()
        and path.suffix.lower() in {".xls", ".xlsx", ".xlsm"}
        and not path.name.startswith("~$")
    ]
    if not file_paths:
        messagebox.showwarning(self.t("warning_title"), "No Excel FDS file found in the selected folder.")
        return
    self.import_fds_files(file_paths)


def import_fds_files(self, file_paths: list[str]) -> None:
    saved_total = 0
    imported_files = 0
    failed: list[str] = []
    skipped_duplicates: list[str] = []
    try:
        existing_rows = [normalize_bom_item_payload(item) for item in self.load_bom_items_from_source()]
    except Exception as exc:
        existing_rows = [normalize_bom_item_payload(item) for item in getattr(self, "bom_items", []) or []]
        _append_bom_debug_log(f"import-fds duplicate preload warning={exc}")
    imported_fds_keys = {
        (
            str(row.get("article_ref", "")).strip().upper(),
            str(row.get("fds_date", "")).strip(),
        )
        for row in existing_rows
        if isinstance(row, dict)
        and str(row.get("article_ref", "")).strip()
        and str(row.get("fds_date", "")).strip()
    }
    for file_path in file_paths:
        try:
            fds_data = parse_fds_workbook(file_path)
            fds_key = (
                str(fds_data.get("article", "")).strip().upper(),
                str(fds_data.get("fds_date", "")).strip(),
            )
            if fds_key[0] and fds_key[1] and fds_key in imported_fds_keys:
                skipped_duplicates.append(
                    f"{Path(file_path).name}: duplicate Article / Part No {fds_key[0]} and FDS Date {fds_key[1]}"
                )
                _append_bom_debug_log(
                    f"import-fds duplicate skipped file={file_path} article={fds_key[0]} fds_date={fds_key[1]}"
                )
                continue
            _saved_rows, saved_count = self.build_bom_items_from_fds_data(
                fds_data,
                style_code="",
                bom_version="FDS",
                fds_date=str(fds_data.get("fds_date", "")).strip(),
            )
            saved_total += saved_count
            imported_files += 1
            if fds_key[0] and fds_key[1] and saved_count > 0:
                imported_fds_keys.add(fds_key)
        except (FDSParseError, Exception) as exc:
            _append_bom_debug_log(
                "import-fds file error="
                + str(file_path)
                + " message="
                + str(exc)
                + " traceback="
                + traceback.format_exc(limit=8).replace("\n", " | ")
            )
            failed.append(f"{Path(file_path).name}: {exc}")
    try:
        self.refresh_bom_module()
    finally:
        self.set_status(f"Imported {saved_total} FDS BOM item(s) from {imported_files} file(s).")
    if failed:
        messagebox.showwarning(
            self.t("warning_title"),
            f"Imported {saved_total} FDS BOM item(s) from {imported_files} file(s).\n\nIssues:\n"
            + "\n".join((skipped_duplicates + failed)[:12]),
        )
    elif skipped_duplicates:
        messagebox.showwarning(
            self.t("warning_title"),
            f"Imported {saved_total} FDS BOM item(s) from {imported_files} file(s).\n\nSkipped duplicates:\n"
            + "\n".join(skipped_duplicates[:12]),
        )
    elif imported_files:
        messagebox.showinfo(self.t("import_done_title"), f"Imported {saved_total} FDS BOM item(s) from {imported_files} file(s).")


def open_material_po_detail_window(self, row: dict | None = None) -> None:
    source_row = dict(row) if isinstance(row, dict) else default_material_po_payload()
    dialog = tk.Toplevel(self.root)
    dialog.title("Material PO")
    dialog.geometry("760x560")
    dialog.minsize(680, 520)

    frame = ttk.Frame(dialog, padding=16)
    frame.pack(fill="both", expand=True)
    frame.columnconfigure(0, weight=1)
    frame.rowconfigure(1, weight=1)

    ttk.Label(frame, text=dialog.title(), style="Section.TLabel").grid(row=0, column=0, sticky="w")
    form = ttk.Frame(frame)
    form.grid(row=1, column=0, sticky="nsew", pady=(12, 0))
    form.columnconfigure(0, weight=1)
    form.columnconfigure(1, weight=1)

    vars_map = {key: tk.StringVar(value=str(source_row.get(key, "")).strip()) for key in default_material_po_payload()}
    text_note = tk.Text(form, height=6, wrap="word")
    text_note.insert("1.0", str(source_row.get("note", "")).strip())

    def add_entry(row_index: int, column_index: int, label: str, key: str) -> None:
        host = ttk.Frame(form)
        host.grid(row=row_index, column=column_index, sticky="ew", padx=8, pady=(0, 12))
        host.columnconfigure(0, weight=1)
        ttk.Label(host, text=label).grid(row=0, column=0, sticky="w", pady=(0, 4))
        ttk.Entry(host, textvariable=vars_map[key]).grid(row=1, column=0, sticky="ew")

    add_entry(0, 0, "MPO No", "mpo_number")
    add_entry(0, 1, "Order Date", "order_date")
    add_entry(1, 0, "ETA Date", "eta_date")
    add_entry(1, 1, "Supplier", "supplier_name")
    add_entry(2, 0, "Style Code", "style_code")
    add_entry(2, 1, "Item Code", "item_code")
    add_entry(3, 0, "Item Name", "item_name")
    add_entry(3, 1, "Linked PO(s)", "linked_po_numbers")
    add_entry(4, 0, "Ordered Qty", "ordered_qty")
    add_entry(4, 1, "Purchase UOM", "purchase_uom")
    add_entry(5, 0, "Unit Price", "unit_price")
    add_entry(5, 1, "Currency", "currency")
    status_host = ttk.Frame(form)
    status_host.grid(row=6, column=0, sticky="ew", padx=8, pady=(0, 12))
    status_host.columnconfigure(0, weight=1)
    ttk.Label(status_host, text="Status").grid(row=0, column=0, sticky="w", pady=(0, 4))
    status_combo = ttk.Combobox(status_host, textvariable=vars_map["status"], values=MATERIAL_PO_STATUS_OPTIONS, state="readonly")
    status_combo.grid(row=1, column=0, sticky="ew")

    note_host = ttk.Frame(form)
    note_host.grid(row=6, column=1, sticky="nsew", padx=8, pady=(0, 12))
    note_host.columnconfigure(0, weight=1)
    ttk.Label(note_host, text="Note").grid(row=0, column=0, sticky="w", pady=(0, 4))
    text_note.grid(in_=note_host, row=1, column=0, sticky="nsew")

    action_row = ttk.Frame(frame)
    action_row.grid(row=2, column=0, sticky="e", pady=(12, 0))

    def save_and_close() -> None:
        payload = {key: variable.get().strip() for key, variable in vars_map.items()}
        payload["note"] = text_note.get("1.0", "end").strip()
        saved = self.save_material_po_row_to_source(payload)
        if not saved:
            messagebox.showwarning(self.t("warning_title"), "Please fill at least item code or item name.")
            return
        dialog.destroy()
        self.refresh_bom_module()
        self.refresh_materials_po_window()

    ttk.Button(action_row, text="Save", command=save_and_close).pack(side="left")
    ttk.Button(action_row, text="Close", command=dialog.destroy).pack(side="left", padx=(8, 0))


def refresh_materials_po_window(self) -> None:
    tree = getattr(self, "materials_po_tree", None)
    if tree is None:
        return
    try:
        if int(tree.winfo_exists()) != 1:
            self.materials_po_tree = None
            return
    except tk.TclError:
        self.materials_po_tree = None
        return
    rows = self.load_material_po_rows_from_source()
    search_query = str(getattr(self, "materials_po_search_var", tk.StringVar(value="")).get() or "").strip().lower()
    if search_query:
        rows = [
            row for row in rows
            if search_query in " ".join(
                [
                    self.display_value(row.get("mpo_number")),
                    self.display_value(row.get("supplier_name")),
                    self.display_value(row.get("style_code")),
                    self.display_value(row.get("item_code")),
                    self.display_value(row.get("item_name")),
                    self.display_value(row.get("linked_po_numbers")),
                    self.display_value(row.get("status")),
                ]
            ).lower()
        ]
    rows.sort(key=lambda item: (str(item.get("order_date", "")).lower(), str(item.get("mpo_number", "")).lower()), reverse=True)
    self.material_po_rows = rows
    try:
        existing_children = tree.get_children()
        if existing_children:
            tree.delete(*existing_children)
    except tk.TclError:
        self.materials_po_tree = None
        return
    receipt_by_mpo_item = self.summarize_material_receipts_by_po_item(approved_only=True)
    for index, row in enumerate(rows, start=1):
        record_id = str(row.get("record_id", "")).strip()
        received_qty = receipt_by_mpo_item.get((str(row.get("mpo_number", "")).strip(), str(row.get("item_code", "")).strip()), 0.0)
        tree.insert(
            "",
            "end",
            iid=record_id,
            values=(
                self.display_value(row.get("mpo_number")),
                self.display_value(row.get("order_date")),
                self.display_value(row.get("eta_date")),
                self.display_value(row.get("supplier_name")),
                self.display_value(row.get("style_code")),
                self.display_value(row.get("item_code")),
                self.display_value(row.get("item_name")),
                self.display_value(row.get("linked_po_numbers")),
                self.display_value(row.get("ordered_qty")),
                _format_metric_text(received_qty),
                self.display_value(row.get("purchase_uom")),
                self.display_value(row.get("status")),
            ),
            tags=("odd_row" if index % 2 == 1 else "even_row",),
        )
    status_var = getattr(self, "materials_po_status_var", None)
    if isinstance(status_var, tk.StringVar):
        status_var.set(f"Materials PO: {len(rows)}")


def open_materials_po_window(self) -> None:
    existing = getattr(self, "materials_po_dialog", None)
    if existing is not None and existing.winfo_exists():
        existing.lift()
        existing.focus_force()
        self.refresh_materials_po_window()
        return
    dialog = tk.Toplevel(self.root)
    dialog.title("Materials PO")
    dialog.geometry("1320x700")
    dialog.minsize(1180, 620)
    self.materials_po_dialog = dialog

    def close_materials_po_window() -> None:
        self.materials_po_tree = None
        self.material_po_rows = []
        self.materials_po_dialog = None
        self.materials_po_status_var = None
        try:
            self.materials_po_search_var = None
        except Exception:
            pass
        if dialog.winfo_exists():
            dialog.destroy()

    dialog.protocol("WM_DELETE_WINDOW", close_materials_po_window)

    frame = ttk.Frame(dialog, padding=16)
    frame.pack(fill="both", expand=True)
    frame.columnconfigure(0, weight=1)
    frame.rowconfigure(2, weight=1)

    toolbar = ttk.Frame(frame)
    toolbar.grid(row=0, column=0, sticky="ew")
    ttk.Button(toolbar, text="New", command=lambda: self.open_material_po_detail_window()).pack(side="left")

    def edit_selected() -> None:
        selection = self.materials_po_tree.selection()
        if not selection:
            messagebox.showwarning(self.t("warning_title"), "Please select one Materials PO line first.")
            return
        record_id = str(selection[0]).strip()
        row = next((dict(item) for item in getattr(self, "material_po_rows", []) if str(item.get("record_id", "")).strip() == record_id), None)
        if row is None:
            return
        self.open_material_po_detail_window(row)

    def receive_selected() -> None:
        selection = self.materials_po_tree.selection()
        if not selection:
            messagebox.showwarning(self.t("warning_title"), "Please select one Materials PO line first.")
            return
        record_id = str(selection[0]).strip()
        row = next((dict(item) for item in getattr(self, "material_po_rows", []) if str(item.get("record_id", "")).strip() == record_id), None)
        if row is None:
            return
        if not _is_material_po_approved(row.get("status")):
            messagebox.showwarning(self.t("warning_title"), "Only approved Materials PO can be received.")
            return
        self.open_material_receipt_detail_window(material_po_row=row)

    def delete_selected() -> None:
        selection = self.materials_po_tree.selection()
        if not selection:
            messagebox.showwarning(self.t("warning_title"), "Please select one Materials PO line first.")
            return
        record_id = str(selection[0]).strip()
        if not messagebox.askyesno(self.t("warning_title"), "Delete selected Materials PO line?"):
            return
        self.delete_material_po_row_from_source(record_id)
        self.refresh_materials_po_window()
        self.refresh_bom_module()

    ttk.Button(toolbar, text="Edit", command=edit_selected).pack(side="left", padx=(8, 0))
    ttk.Button(toolbar, text="Receive", command=receive_selected).pack(side="left", padx=(8, 0))
    ttk.Button(toolbar, text="Delete", command=delete_selected).pack(side="left", padx=(8, 0))
    ttk.Button(toolbar, text="Refresh", command=self.refresh_materials_po_window).pack(side="left", padx=(8, 0))

    search_row = ttk.Frame(frame)
    search_row.grid(row=1, column=0, sticky="ew", pady=(12, 10))
    ttk.Label(search_row, text="Search").pack(side="left")
    self.materials_po_search_var = tk.StringVar()
    search_entry = ttk.Entry(search_row, textvariable=self.materials_po_search_var, width=42)
    search_entry.pack(side="left", padx=(8, 0))
    search_entry.bind("<Return>", lambda _event: self.refresh_materials_po_window())

    table_frame = ttk.Frame(frame)
    table_frame.grid(row=2, column=0, sticky="nsew")
    table_frame.columnconfigure(0, weight=1)
    table_frame.rowconfigure(0, weight=1)

    columns = ("mpo_number", "order_date", "eta_date", "supplier_name", "style_code", "item_code", "item_name", "linked_po_numbers", "ordered_qty", "received_qty", "purchase_uom", "status")
    tree = ttk.Treeview(table_frame, columns=columns, show="headings", selectmode="browse", style="Costing.Treeview")
    self.materials_po_tree = tree
    configure_striped_treeview(tree)
    for key, title, width in (
        ("mpo_number", "MPO No", 110),
        ("order_date", "Order Date", 100),
        ("eta_date", "ETA", 100),
        ("supplier_name", "Supplier", 160),
        ("style_code", "Style", 90),
        ("item_code", "Item Code", 110),
        ("item_name", "Item Name", 180),
        ("linked_po_numbers", "Linked PO(s)", 180),
        ("ordered_qty", "Ordered", 90),
        ("received_qty", "Received", 90),
        ("purchase_uom", "UOM", 70),
        ("status", "Status", 90),
    ):
        tree.heading(key, text=title)
        tree.column(key, width=width, anchor="w", stretch=key in {"supplier_name", "item_name", "linked_po_numbers"})
    tree.grid(row=0, column=0, sticky="nsew")
    scroll_y = ttk.Scrollbar(table_frame, orient="vertical", command=tree.yview)
    scroll_x = ttk.Scrollbar(table_frame, orient="horizontal", command=tree.xview)
    scroll_y.grid(row=0, column=1, sticky="ns")
    scroll_x.grid(row=1, column=0, sticky="ew")
    tree.configure(yscrollcommand=scroll_y.set, xscrollcommand=scroll_x.set)
    self.materials_po_status_var = tk.StringVar(value="Materials PO: 0")
    ttk.Label(frame, textvariable=self.materials_po_status_var, relief="sunken", anchor="w", padding=8).grid(row=3, column=0, sticky="ew", pady=(12, 0))
    tree.bind("<Double-1>", lambda _event: edit_selected())
    self.refresh_materials_po_window()


def open_material_receipt_detail_window(self, row: dict | None = None, material_po_row: dict | None = None) -> None:
    source_row = dict(row) if isinstance(row, dict) else default_material_receipt_payload()
    selected_material_po = dict(material_po_row) if isinstance(material_po_row, dict) else None
    if selected_material_po is None and isinstance(row, dict) and "record_id" in row and "receipt_id" not in row:
        selected_material_po = dict(row)
        source_row = default_material_receipt_payload()
    if selected_material_po is not None:
        source_row.update(
            {
                "po_record_id": str(selected_material_po.get("record_id", "")).strip(),
                "mpo_number": str(selected_material_po.get("mpo_number", "")).strip(),
                "supplier_name": str(selected_material_po.get("supplier_name", "")).strip(),
                "style_code": normalize_style_identifier(selected_material_po.get("style_code")),
                "item_id": str(selected_material_po.get("item_id", "")).strip(),
                "item_code": str(selected_material_po.get("item_code", "")).strip(),
                "item_name": str(selected_material_po.get("item_name", "")).strip(),
                "uom": str(selected_material_po.get("purchase_uom", "")).strip(),
                "warehouse_code": str(source_row.get("warehouse_code", "")).strip() or "RM",
                "receipt_date": str(source_row.get("receipt_date", "")).strip() or datetime.now().strftime("%Y-%m-%d"),
            }
        )
    dialog = tk.Toplevel(self.root)
    dialog.title("Goods Receipt")
    dialog.geometry("980x760")
    dialog.minsize(860, 680)

    frame = ttk.Frame(dialog, padding=16)
    frame.pack(fill="both", expand=True)
    frame.columnconfigure(0, weight=1)
    frame.rowconfigure(1, weight=1)
    ttk.Label(frame, text=dialog.title(), style="Section.TLabel").grid(row=0, column=0, sticky="w")

    form = ttk.Frame(frame)
    form.grid(row=1, column=0, sticky="nsew", pady=(12, 0))
    form.columnconfigure(0, weight=1)
    form.columnconfigure(1, weight=1)
    vars_map = {key: tk.StringVar(value=str(source_row.get(key, "")).strip()) for key in default_material_receipt_payload()}
    note_text = tk.Text(form, height=6, wrap="word")
    note_text.insert("1.0", str(source_row.get("note", "")).strip())
    warehouse_codes = [str(item.get("warehouse_code", "")).strip() for item in self.load_warehouse_rows_from_source() if str(item.get("warehouse_code", "")).strip()]
    material_po_rows = [row for row in self.load_material_po_rows_from_source() if _is_material_po_approved(row.get("status"))]
    po_choices: list[tuple[str, dict]] = []
    for po_row in material_po_rows:
        choice_value = " | ".join(
            [
                str(po_row.get("mpo_number", "")).strip() or "-",
                normalize_style_identifier(po_row.get("style_code")) or "-",
                str(po_row.get("item_code", "")).strip() or "-",
                str(po_row.get("item_name", "")).strip() or "-",
            ]
        )
        po_choices.append((choice_value, dict(po_row)))
    po_choice_map = {label: payload for label, payload in po_choices}
    current_po_choice = next(
        (
            label
            for label, payload in po_choices
            if str(payload.get("record_id", "")).strip() == str(vars_map["po_record_id"].get()).strip()
        ),
        "",
    )
    selected_po_var = tk.StringVar(value=current_po_choice)
    receipt_summary_var = tk.StringVar(value="")
    if selected_material_po is not None and not _is_material_po_approved(selected_material_po.get("status")):
        messagebox.showwarning(self.t("warning_title"), "Only approved Materials PO can be received.")
        dialog.destroy()
        return

    def add_entry(row_index: int, column_index: int, label: str, key: str, *, state: str | None = None) -> None:
        host = ttk.Frame(form)
        host.grid(row=row_index, column=column_index, sticky="ew", padx=8, pady=(0, 12))
        host.columnconfigure(0, weight=1)
        ttk.Label(host, text=label).grid(row=0, column=0, sticky="w", pady=(0, 4))
        entry = ttk.Entry(host, textvariable=vars_map[key])
        if state:
            entry.configure(state=state)
        entry.grid(row=1, column=0, sticky="ew")
        return host, entry

    def refresh_receipt_number() -> None:
        current_receipt_id = str(vars_map["receipt_id"].get()).strip()
        if current_receipt_id and str(source_row.get("receipt_id", "")).strip() == current_receipt_id and str(source_row.get("receipt_number", "")).strip():
            vars_map["receipt_number"].set(str(source_row.get("receipt_number", "")).strip())
            return
        existing_rows = [
            dict(item)
            for item in self.load_material_receipt_rows_from_source()
            if str(item.get("receipt_id", "")).strip() != current_receipt_id
        ]
        vars_map["receipt_number"].set(
            self._generate_auto_material_receipt_number(existing_rows, vars_map["receipt_date"].get().strip())
        )

    _receipt_host, _receipt_entry = add_entry(0, 0, "Receipt No", "receipt_number", state="readonly")
    receipt_date_host = ttk.Frame(form)
    receipt_date_host.grid(row=0, column=1, sticky="ew", padx=8, pady=(0, 12))
    receipt_date_host.columnconfigure(0, weight=1)
    ttk.Label(receipt_date_host, text="Receipt Date").grid(row=0, column=0, sticky="w", pady=(0, 4))
    receipt_date_entry = ttk.Entry(receipt_date_host, textvariable=vars_map["receipt_date"], state="readonly")
    receipt_date_entry.grid(row=1, column=0, sticky="ew")
    self.bind_date_entry_picker(receipt_date_entry, vars_map["receipt_date"])
    mpo_host = ttk.Frame(form)
    mpo_host.grid(row=1, column=0, sticky="ew", padx=8, pady=(0, 12))
    mpo_host.columnconfigure(0, weight=1)
    ttk.Label(mpo_host, text="Materials PO Line").grid(row=0, column=0, sticky="w", pady=(0, 4))
    mpo_combo = ttk.Combobox(mpo_host, textvariable=selected_po_var, values=[label for label, _payload in po_choices], state="readonly")
    mpo_combo.grid(row=1, column=0, sticky="ew")
    warehouse_host = ttk.Frame(form)
    warehouse_host.grid(row=1, column=1, sticky="ew", padx=8, pady=(0, 12))
    warehouse_host.columnconfigure(0, weight=1)
    ttk.Label(warehouse_host, text="Warehouse").grid(row=0, column=0, sticky="w", pady=(0, 4))
    warehouse_combo = ttk.Combobox(warehouse_host, textvariable=vars_map["warehouse_code"], values=warehouse_codes, state="readonly")
    warehouse_combo.grid(row=1, column=0, sticky="ew")
    add_entry(2, 0, "MPO No", "mpo_number")
    add_entry(2, 1, "Supplier", "supplier_name")
    add_entry(3, 0, "Style Code", "style_code")
    add_entry(3, 1, "Item Code", "item_code")
    add_entry(4, 0, "Item Name", "item_name")
    add_entry(4, 1, "Received Qty", "received_qty")
    add_entry(5, 0, "UOM", "uom")
    add_entry(5, 1, "Lot No", "lot_no")
    status_host = ttk.Frame(form)
    status_host.grid(row=6, column=0, sticky="ew", padx=8, pady=(0, 12))
    status_host.columnconfigure(0, weight=1)
    ttk.Label(status_host, text="Status").grid(row=0, column=0, sticky="w", pady=(0, 4))
    status_combo = ttk.Combobox(status_host, textvariable=vars_map["status"], values=MATERIAL_DOCUMENT_STATUS_OPTIONS, state="readonly")
    status_combo.grid(row=1, column=0, sticky="ew")

    note_host = ttk.Frame(form)
    note_host.grid(row=7, column=0, columnspan=2, sticky="nsew", padx=8, pady=(0, 12))
    note_host.columnconfigure(0, weight=1)
    ttk.Label(note_host, text="Note").grid(row=0, column=0, sticky="w", pady=(0, 4))
    note_text.grid(in_=note_host, row=1, column=0, sticky="nsew")

    action_row = ttk.Frame(form)
    action_row.grid(row=8, column=0, columnspan=2, sticky="e", padx=8, pady=(0, 12))

    history_frame = ttk.LabelFrame(form, text="Receipt History For This PO Item")
    history_frame.grid(row=9, column=0, columnspan=2, sticky="nsew", padx=8, pady=(8, 0))
    history_frame.columnconfigure(0, weight=1)
    history_frame.rowconfigure(1, weight=1)
    ttk.Label(history_frame, textvariable=receipt_summary_var, foreground="#5f6b7a").grid(row=0, column=0, sticky="w", pady=(0, 8))
    history_table_frame = ttk.Frame(history_frame)
    history_table_frame.grid(row=1, column=0, sticky="nsew")
    history_table_frame.columnconfigure(0, weight=1)
    history_table_frame.rowconfigure(0, weight=1)
    history_columns = ("receipt_number", "receipt_date", "warehouse_code", "received_qty", "uom", "status")
    history_tree = ttk.Treeview(history_table_frame, columns=history_columns, show="headings", height=8, selectmode="browse", style="Costing.Treeview")
    configure_striped_treeview(history_tree)
    for key, title, width in (
        ("receipt_number", "Receipt No", 140),
        ("receipt_date", "Receipt Date", 120),
        ("warehouse_code", "Warehouse", 110),
        ("received_qty", "Qty", 90),
        ("uom", "UOM", 80),
        ("status", "Status", 90),
    ):
        history_tree.heading(key, text=title)
        history_tree.column(key, width=width, anchor="w", stretch=False)
    history_tree.grid(row=0, column=0, sticky="nsew")
    history_scroll_y = ttk.Scrollbar(history_table_frame, orient="vertical", command=history_tree.yview)
    history_scroll_y.grid(row=0, column=1, sticky="ns")
    history_tree.configure(yscrollcommand=history_scroll_y.set)

    def refresh_receipt_history() -> None:
        history_tree.delete(*history_tree.get_children())
        mpo_number = vars_map["mpo_number"].get().strip()
        item_code = vars_map["item_code"].get().strip()
        history_rows = self.get_material_receipt_history_for_po_item(mpo_number, item_code)
        approved_total = 0.0
        all_total = 0.0
        current_receipt_id = str(vars_map["receipt_id"].get()).strip()
        for index, history_row in enumerate(history_rows, start=1):
            qty_value = _safe_float_value(history_row.get("received_qty"))
            all_total += qty_value
            if _is_material_receipt_approved(history_row.get("status")):
                approved_total += qty_value
            row_values = (
                str(history_row.get("receipt_number", "")).strip(),
                str(history_row.get("receipt_date", "")).strip(),
                str(history_row.get("warehouse_code", "")).strip(),
                _format_metric_text(qty_value),
                str(history_row.get("uom", "")).strip(),
                str(history_row.get("status", "")).strip(),
            )
            history_tree.insert(
                "",
                "end",
                iid=str(history_row.get("receipt_id", "")).strip() or f"history-{index}",
                values=row_values,
                tags=("odd_row" if index % 2 == 1 else "even_row",),
            )
        selected_po = po_choice_map.get(selected_po_var.get().strip(), {})
        ordered_qty = _safe_float_value(selected_po.get("ordered_qty"))
        approved_before_current = approved_total
        if current_receipt_id:
            current_history_row = next((item for item in history_rows if str(item.get("receipt_id", "")).strip() == current_receipt_id), None)
            if current_history_row and _is_material_receipt_approved(current_history_row.get("status")):
                approved_before_current = max(approved_total - _safe_float_value(current_history_row.get("received_qty")), 0.0)
        remaining_qty = max(ordered_qty - approved_before_current, 0.0)
        receipt_summary_var.set(
            " | ".join(
                [
                    f"Ordered: {_format_metric_text(ordered_qty)} {str(selected_po.get('purchase_uom', vars_map['uom'].get())).strip()}".strip(),
                    f"Approved received before this receipt: {_format_metric_text(approved_before_current)}",
                    f"Remaining to receive: {_format_metric_text(remaining_qty)}",
                    f"History rows: {len(history_rows)}",
                ]
            )
        )

    def apply_material_po_selection(_event: object | None = None) -> None:
        selected_po = po_choice_map.get(selected_po_var.get().strip())
        if not isinstance(selected_po, dict):
            refresh_receipt_history()
            return
        vars_map["po_record_id"].set(str(selected_po.get("record_id", "")).strip())
        vars_map["mpo_number"].set(str(selected_po.get("mpo_number", "")).strip())
        vars_map["supplier_name"].set(str(selected_po.get("supplier_name", "")).strip())
        vars_map["style_code"].set(normalize_style_identifier(selected_po.get("style_code")))
        vars_map["item_id"].set(str(selected_po.get("item_id", "")).strip())
        vars_map["item_code"].set(str(selected_po.get("item_code", "")).strip())
        vars_map["item_name"].set(str(selected_po.get("item_name", "")).strip())
        vars_map["uom"].set(str(selected_po.get("purchase_uom", "")).strip())
        if not vars_map["warehouse_code"].get().strip():
            vars_map["warehouse_code"].set("RM")
        refresh_receipt_history()

    mpo_combo.bind("<<ComboboxSelected>>", apply_material_po_selection)
    vars_map["receipt_date"].trace_add("write", lambda *_args: refresh_receipt_number())
    if selected_po_var.get().strip():
        apply_material_po_selection()
    else:
        refresh_receipt_history()
    refresh_receipt_number()

    def save_and_close() -> None:
        payload = {key: variable.get().strip() for key, variable in vars_map.items()}
        payload["note"] = note_text.get("1.0", "end").strip()
        if not payload.get("receipt_date"):
            payload["receipt_date"] = datetime.now().strftime("%Y-%m-%d")
        selected_po = po_choice_map.get(selected_po_var.get().strip(), {})
        current_receipt_id = str(payload.get("receipt_id", "")).strip()
        existing_history = self.get_material_receipt_history_for_po_item(payload.get("mpo_number", ""), payload.get("item_code", ""))
        approved_before_current = 0.0
        for history_row in existing_history:
            if str(history_row.get("receipt_id", "")).strip() == current_receipt_id:
                continue
            if _is_material_receipt_approved(history_row.get("status")):
                approved_before_current += _safe_float_value(history_row.get("received_qty"))
        receipt_qty = _safe_float_value(payload.get("received_qty"))
        ordered_qty = _safe_float_value(selected_po.get("ordered_qty"))
        if ordered_qty > 0 and approved_before_current + receipt_qty > ordered_qty + 1e-9:
            should_continue = messagebox.askyesno(
                self.t("warning_title"),
                "Approved received quantity will exceed ordered quantity for this Materials PO line. Continue saving?",
            )
            if not should_continue:
                return
        saved = self.save_material_receipt_row_to_source(payload)
        if not saved:
            messagebox.showwarning(self.t("warning_title"), "Please fill at least item code or item name.")
            return
        dialog.destroy()
        self.refresh_material_warehouse_window()
        self.refresh_materials_po_window()
        self.refresh_bom_module()

    ttk.Button(action_row, text="Save", command=save_and_close).pack(side="left")
    ttk.Button(action_row, text="Close", command=dialog.destroy).pack(side="left", padx=(8, 0))


def open_material_issue_detail_window(self, row: dict | None = None) -> None:
    source_row = dict(row) if isinstance(row, dict) else default_material_issue_payload()
    dialog = tk.Toplevel(self.root)
    dialog.title("Material Issue")
    dialog.geometry("760x560")
    dialog.minsize(680, 520)

    frame = ttk.Frame(dialog, padding=16)
    frame.pack(fill="both", expand=True)
    frame.columnconfigure(0, weight=1)
    frame.rowconfigure(1, weight=1)
    ttk.Label(frame, text=dialog.title(), style="Section.TLabel").grid(row=0, column=0, sticky="w")

    form = ttk.Frame(frame)
    form.grid(row=1, column=0, sticky="nsew", pady=(12, 0))
    form.columnconfigure(0, weight=1)
    form.columnconfigure(1, weight=1)
    vars_map = {key: tk.StringVar(value=str(source_row.get(key, "")).strip()) for key in default_material_issue_payload()}
    note_text = tk.Text(form, height=6, wrap="word")
    note_text.insert("1.0", str(source_row.get("note", "")).strip())

    def add_entry(row_index: int, column_index: int, label: str, key: str) -> None:
        host = ttk.Frame(form)
        host.grid(row=row_index, column=column_index, sticky="ew", padx=8, pady=(0, 12))
        host.columnconfigure(0, weight=1)
        ttk.Label(host, text=label).grid(row=0, column=0, sticky="w", pady=(0, 4))
        ttk.Entry(host, textvariable=vars_map[key]).grid(row=1, column=0, sticky="ew")

    add_entry(0, 0, "Issue No", "issue_number")
    add_entry(0, 1, "Issue Date", "issue_date")
    add_entry(1, 0, "PO No", "po_number")
    add_entry(1, 1, "Warehouse", "warehouse_code")
    add_entry(2, 0, "Style Code", "style_code")
    add_entry(2, 1, "Item Code", "item_code")
    add_entry(3, 0, "Item Name", "item_name")
    add_entry(3, 1, "Issued Qty", "issued_qty")
    add_entry(4, 0, "UOM", "uom")
    add_entry(4, 1, "Reason", "reason")

    note_host = ttk.Frame(form)
    note_host.grid(row=5, column=0, columnspan=2, sticky="nsew", padx=8, pady=(0, 12))
    note_host.columnconfigure(0, weight=1)
    ttk.Label(note_host, text="Note").grid(row=0, column=0, sticky="w", pady=(0, 4))
    note_text.grid(in_=note_host, row=1, column=0, sticky="nsew")

    action_row = ttk.Frame(frame)
    action_row.grid(row=2, column=0, sticky="e", pady=(12, 0))

    def save_and_close() -> None:
        payload = {key: variable.get().strip() for key, variable in vars_map.items()}
        payload["note"] = note_text.get("1.0", "end").strip()
        saved = self.save_material_issue_row_to_source(payload)
        if not saved:
            messagebox.showwarning(self.t("warning_title"), "Please fill at least item code or item name.")
            return
        dialog.destroy()
        self.refresh_material_warehouse_window()
        self.refresh_bom_module()

    ttk.Button(action_row, text="Save", command=save_and_close).pack(side="left")
    ttk.Button(action_row, text="Close", command=dialog.destroy).pack(side="left", padx=(8, 0))


def open_warehouse_detail_window(self, row: dict | None = None) -> None:
    source_row = dict(row) if isinstance(row, dict) else default_warehouse_payload()
    dialog = tk.Toplevel(self.root)
    dialog.title("Warehouse")
    dialog.geometry("620x360")
    dialog.minsize(560, 320)

    frame = ttk.Frame(dialog, padding=16)
    frame.pack(fill="both", expand=True)
    frame.columnconfigure(0, weight=1)
    frame.columnconfigure(1, weight=1)
    vars_map = {key: tk.StringVar(value=str(source_row.get(key, "")).strip()) for key in default_warehouse_payload()}

    def add_entry(row_index: int, column_index: int, label: str, key: str) -> None:
        host = ttk.Frame(frame)
        host.grid(row=row_index, column=column_index, sticky="ew", padx=8, pady=(0, 12))
        host.columnconfigure(0, weight=1)
        ttk.Label(host, text=label).grid(row=0, column=0, sticky="w", pady=(0, 4))
        ttk.Entry(host, textvariable=vars_map[key]).grid(row=1, column=0, sticky="ew")

    add_entry(0, 0, "Warehouse Code", "warehouse_code")
    add_entry(0, 1, "Warehouse Name", "warehouse_name")
    add_entry(1, 0, "Status", "status")
    add_entry(1, 1, "Note", "note")

    action_row = ttk.Frame(frame)
    action_row.grid(row=2, column=0, columnspan=2, sticky="e", pady=(12, 0))

    def save_and_close() -> None:
        payload = {key: variable.get().strip() for key, variable in vars_map.items()}
        saved = self.save_warehouse_row_to_source(payload)
        if not saved:
            messagebox.showwarning(self.t("warning_title"), "Warehouse Code and Warehouse Name are required.")
            return
        dialog.destroy()
        self.refresh_material_warehouse_window()

    ttk.Button(action_row, text="Save", command=save_and_close).pack(side="left")
    ttk.Button(action_row, text="Close", command=dialog.destroy).pack(side="left", padx=(8, 0))


def refresh_material_warehouse_window(self) -> None:
    warehouse_tree = getattr(self, "warehouse_tree", None)
    receipt_tree = getattr(self, "receipt_tree", None)
    issue_tree = getattr(self, "issue_tree", None)
    inventory_tree = getattr(self, "inventory_tree", None)

    warehouse_rows = self.load_warehouse_rows_from_source()
    receipt_rows = self.load_material_receipt_rows_from_source()
    issue_rows = self.load_material_issue_rows_from_source()
    material_po_rows = self.load_material_po_rows_from_source()

    if warehouse_tree is not None:
        warehouse_tree.delete(*warehouse_tree.get_children())
        for index, row in enumerate(warehouse_rows, start=1):
            warehouse_tree.insert(
                "",
                "end",
                iid=str(row.get("warehouse_id", "")),
                values=(row.get("warehouse_code", ""), row.get("warehouse_name", ""), row.get("status", ""), row.get("updated_at", "")),
                tags=("odd_row" if index % 2 == 1 else "even_row",),
            )

    if receipt_tree is not None:
        receipt_tree.delete(*receipt_tree.get_children())
        for index, row in enumerate(sorted(receipt_rows, key=lambda item: (str(item.get("receipt_date", "")).lower(), str(item.get("receipt_number", "")).lower()), reverse=True), start=1):
            receipt_tree.insert(
                "",
                "end",
                iid=str(row.get("receipt_id", "")),
                values=(
                    row.get("receipt_number", ""),
                    row.get("receipt_date", ""),
                    row.get("mpo_number", ""),
                    row.get("warehouse_code", ""),
                    row.get("style_code", ""),
                    row.get("item_code", ""),
                    row.get("item_name", ""),
                    row.get("received_qty", ""),
                    row.get("uom", ""),
                    row.get("lot_no", ""),
                    row.get("status", ""),
                ),
                tags=("odd_row" if index % 2 == 1 else "even_row",),
            )

    if issue_tree is not None:
        issue_tree.delete(*issue_tree.get_children())
        for index, row in enumerate(sorted(issue_rows, key=lambda item: str(item.get("issue_date", "")).lower(), reverse=True), start=1):
            issue_tree.insert(
                "",
                "end",
                iid=str(row.get("issue_id", "")),
                values=(
                    row.get("issue_number", ""),
                    row.get("issue_date", ""),
                    row.get("po_number", ""),
                    row.get("warehouse_code", ""),
                    row.get("style_code", ""),
                    row.get("item_code", ""),
                    row.get("item_name", ""),
                    row.get("issued_qty", ""),
                    row.get("uom", ""),
                    row.get("reason", ""),
                ),
                tags=("odd_row" if index % 2 == 1 else "even_row",),
            )

    if inventory_tree is not None:
        inventory_tree.delete(*inventory_tree.get_children())
        ordered_by_item: dict[tuple[str, str], float] = {}
        received_by_item: dict[tuple[str, str, str], float] = {}
        issued_by_item: dict[tuple[str, str, str], float] = {}
        for row in material_po_rows:
            key = (normalize_style_identifier(row.get("style_code")), str(row.get("item_code", "")).strip())
            ordered_by_item[key] = ordered_by_item.get(key, 0.0) + _safe_float_value(row.get("ordered_qty"))
        approved_receipt_rows = [row for row in receipt_rows if _is_material_receipt_approved(row.get("status"))]
        for row in approved_receipt_rows:
            key = (str(row.get("warehouse_code", "")).strip(), normalize_style_identifier(row.get("style_code")), str(row.get("item_code", "")).strip())
            received_by_item[key] = received_by_item.get(key, 0.0) + _safe_float_value(row.get("received_qty"))
        for row in issue_rows:
            key = (str(row.get("warehouse_code", "")).strip(), normalize_style_identifier(row.get("style_code")), str(row.get("item_code", "")).strip())
            issued_by_item[key] = issued_by_item.get(key, 0.0) + _safe_float_value(row.get("issued_qty"))
        summary_rows: list[dict] = []
        all_keys = sorted(set(received_by_item) | set(issued_by_item))
        for warehouse_code, style_code, item_code in all_keys:
            received_qty = received_by_item.get((warehouse_code, style_code, item_code), 0.0)
            issued_qty = issued_by_item.get((warehouse_code, style_code, item_code), 0.0)
            on_hand_qty = received_qty - issued_qty
            ordered_qty = ordered_by_item.get((style_code, item_code), 0.0)
            summary_rows.append(
                {
                    "warehouse_code": warehouse_code,
                    "style_code": style_code,
                    "item_code": item_code,
                    "ordered_qty": ordered_qty,
                    "received_qty": received_qty,
                    "issued_qty": issued_qty,
                    "on_hand_qty": on_hand_qty,
                }
            )
        for index, row in enumerate(summary_rows, start=1):
            inventory_tree.insert(
                "",
                "end",
                values=(
                    row["warehouse_code"],
                    row["style_code"],
                    row["item_code"],
                    _format_metric_text(row["ordered_qty"]),
                    _format_metric_text(row["received_qty"]),
                    _format_metric_text(row["issued_qty"]),
                    _format_metric_text(row["on_hand_qty"]),
                ),
                tags=("odd_row" if index % 2 == 1 else "even_row",),
            )

    status_var = getattr(self, "material_warehouse_status_var", None)
    if isinstance(status_var, tk.StringVar):
        status_var.set(
            f"Warehouses: {len(warehouse_rows)} | Receipts: {len(receipt_rows)} | Issues: {len(issue_rows)}"
        )


def _build_simple_table(parent: ttk.Frame, columns: list[tuple[str, str, int]]) -> ttk.Treeview:
    parent.columnconfigure(0, weight=1)
    parent.rowconfigure(0, weight=1)
    tree = ttk.Treeview(parent, columns=[key for key, _title, _width in columns], show="headings", selectmode="browse", style="Costing.Treeview")
    configure_striped_treeview(tree)
    for key, title, width in columns:
        tree.heading(key, text=title)
        tree.column(key, width=width, anchor="w", stretch=True)
    tree.grid(row=0, column=0, sticky="nsew")
    scroll_y = ttk.Scrollbar(parent, orient="vertical", command=tree.yview)
    scroll_x = ttk.Scrollbar(parent, orient="horizontal", command=tree.xview)
    tree.configure(yscrollcommand=scroll_y.set, xscrollcommand=scroll_x.set)
    scroll_y.grid(row=0, column=1, sticky="ns")
    scroll_x.grid(row=1, column=0, sticky="ew")
    return tree


def open_material_warehouse_window(self) -> None:
    existing = getattr(self, "material_warehouse_dialog", None)
    if existing is not None and existing.winfo_exists():
        existing.lift()
        existing.focus_force()
        self.refresh_material_warehouse_window()
        return
    dialog = tk.Toplevel(self.root)
    dialog.title("Warehouse")
    dialog.geometry("1320x760")
    dialog.minsize(1180, 680)
    self.material_warehouse_dialog = dialog

    frame = ttk.Frame(dialog, padding=16)
    frame.pack(fill="both", expand=True)
    frame.columnconfigure(0, weight=1)
    frame.rowconfigure(1, weight=1)

    toolbar = ttk.Frame(frame)
    toolbar.grid(row=0, column=0, sticky="ew")
    ttk.Button(toolbar, text="Refresh", command=self.refresh_material_warehouse_window).pack(side="left")

    notebook = ttk.Notebook(frame)
    notebook.grid(row=1, column=0, sticky="nsew", pady=(12, 0))
    warehouse_tab = ttk.Frame(notebook, padding=10)
    receipt_tab = ttk.Frame(notebook, padding=10)
    issue_tab = ttk.Frame(notebook, padding=10)
    inventory_tab = ttk.Frame(notebook, padding=10)
    notebook.add(warehouse_tab, text="Warehouses")
    notebook.add(receipt_tab, text="Goods Receipt")
    notebook.add(issue_tab, text="Material Issue")
    notebook.add(inventory_tab, text="Inventory")

    warehouse_actions = ttk.Frame(warehouse_tab)
    warehouse_actions.grid(row=0, column=0, sticky="ew", pady=(0, 8))
    ttk.Button(warehouse_actions, text="New Warehouse", command=lambda: self.open_warehouse_detail_window()).pack(side="left")
    ttk.Button(
        warehouse_actions,
        text="Edit",
        command=lambda: self.open_warehouse_detail_window(
            next(
                (
                    dict(item)
                    for item in self.load_warehouse_rows_from_source()
                    if str(item.get("warehouse_id", "")).strip() == str(self.warehouse_tree.selection()[0]).strip()
                ),
                None,
            )
            if self.warehouse_tree.selection()
            else None
        ),
    ).pack(side="left", padx=(8, 0))
    ttk.Button(
        warehouse_actions,
        text="Delete",
        command=lambda: (
            self.delete_warehouse_row_from_source(str(self.warehouse_tree.selection()[0]).strip()),
            self.refresh_material_warehouse_window(),
        )
        if self.warehouse_tree.selection() and messagebox.askyesno(self.t("warning_title"), "Delete selected warehouse?")
        else None,
    ).pack(side="left", padx=(8, 0))
    warehouse_table_frame = ttk.Frame(warehouse_tab)
    warehouse_table_frame.grid(row=1, column=0, sticky="nsew")
    warehouse_tab.columnconfigure(0, weight=1)
    warehouse_tab.rowconfigure(1, weight=1)
    self.warehouse_tree = _build_simple_table(
        warehouse_table_frame,
        [
            ("warehouse_code", "Warehouse Code", 120),
            ("warehouse_name", "Warehouse Name", 220),
            ("status", self.t("status"), 100),
            ("updated_at", self.t("revised_time_column"), 140),
        ],
    )

    receipt_actions = ttk.Frame(receipt_tab)
    receipt_actions.grid(row=0, column=0, sticky="ew", pady=(0, 8))
    ttk.Button(receipt_actions, text="New Receipt", command=lambda: self.open_material_receipt_detail_window()).pack(side="left")
    ttk.Button(
        receipt_actions,
        text="Edit",
        command=lambda: self.open_material_receipt_detail_window(
            next(
                (
                    dict(item)
                    for item in self.load_material_receipt_rows_from_source()
                    if str(item.get("receipt_id", "")).strip() == str(self.receipt_tree.selection()[0]).strip()
                ),
                None,
            )
            if self.receipt_tree.selection()
            else None
        ),
    ).pack(side="left", padx=(8, 0))
    ttk.Button(
        receipt_actions,
        text="Delete",
        command=lambda: (
            self.delete_material_receipt_row_from_source(str(self.receipt_tree.selection()[0]).strip()),
            self.refresh_material_warehouse_window(),
            self.refresh_materials_po_window(),
            self.refresh_bom_module(),
        )
        if self.receipt_tree.selection() and messagebox.askyesno(self.t("warning_title"), "Delete selected receipt?")
        else None,
    ).pack(side="left", padx=(8, 0))
    receipt_table_frame = ttk.Frame(receipt_tab)
    receipt_table_frame.grid(row=1, column=0, sticky="nsew")
    receipt_tab.columnconfigure(0, weight=1)
    receipt_tab.rowconfigure(1, weight=1)
    self.receipt_tree = _build_simple_table(
        receipt_table_frame,
        [("receipt_number", "Receipt No", 120), ("receipt_date", "Receipt Date", 110), ("mpo_number", "MPO No", 110), ("warehouse_code", "Warehouse", 100), ("style_code", "Style", 90), ("item_code", "Item Code", 110), ("item_name", "Item Name", 180), ("received_qty", "Received Qty", 100), ("uom", "UOM", 70), ("lot_no", "Lot No", 110), ("status", "Status", 90)],
    )

    issue_actions = ttk.Frame(issue_tab)
    issue_actions.grid(row=0, column=0, sticky="ew", pady=(0, 8))
    ttk.Button(issue_actions, text="New Issue", command=lambda: self.open_material_issue_detail_window()).pack(side="left")
    ttk.Button(
        issue_actions,
        text="Edit",
        command=lambda: self.open_material_issue_detail_window(
            next(
                (
                    dict(item)
                    for item in self.load_material_issue_rows_from_source()
                    if str(item.get("issue_id", "")).strip() == str(self.issue_tree.selection()[0]).strip()
                ),
                None,
            )
            if self.issue_tree.selection()
            else None
        ),
    ).pack(side="left", padx=(8, 0))
    ttk.Button(
        issue_actions,
        text="Delete",
        command=lambda: (
            self.delete_material_issue_row_from_source(str(self.issue_tree.selection()[0]).strip()),
            self.refresh_material_warehouse_window(),
            self.refresh_bom_module(),
        )
        if self.issue_tree.selection() and messagebox.askyesno(self.t("warning_title"), "Delete selected issue?")
        else None,
    ).pack(side="left", padx=(8, 0))
    issue_table_frame = ttk.Frame(issue_tab)
    issue_table_frame.grid(row=1, column=0, sticky="nsew")
    issue_tab.columnconfigure(0, weight=1)
    issue_tab.rowconfigure(1, weight=1)
    self.issue_tree = _build_simple_table(
        issue_table_frame,
        [("issue_number", "Issue No", 120), ("issue_date", "Issue Date", 110), ("po_number", "PO No", 110), ("warehouse_code", "Warehouse", 100), ("style_code", "Style", 90), ("item_code", "Item Code", 110), ("item_name", "Item Name", 180), ("issued_qty", "Issued Qty", 100), ("uom", "UOM", 70), ("reason", "Reason", 140)],
    )

    inventory_table_frame = ttk.Frame(inventory_tab)
    inventory_table_frame.grid(row=0, column=0, sticky="nsew")
    inventory_tab.columnconfigure(0, weight=1)
    inventory_tab.rowconfigure(0, weight=1)
    self.inventory_tree = _build_simple_table(
        inventory_table_frame,
        [("warehouse_code", "Warehouse", 100), ("style_code", "Style", 90), ("item_code", "Item Code", 110), ("ordered_qty", "Ordered", 100), ("received_qty", "Received", 100), ("issued_qty", "Issued", 100), ("on_hand_qty", "On Hand", 100)],
    )

    self.material_warehouse_status_var = tk.StringVar(value="Warehouses: 0 | Receipts: 0 | Issues: 0")
    ttk.Label(frame, textvariable=self.material_warehouse_status_var, relief="sunken", anchor="w", padding=8).grid(row=2, column=0, sticky="ew", pady=(12, 0))
    self.refresh_material_warehouse_window()


def bind_bom_module(app_cls: type) -> None:
    method_map = {
        "default_bom_item_payload": staticmethod(default_bom_item_payload),
        "normalize_bom_item_payload": staticmethod(normalize_bom_item_payload),
        "sync_tech_pack_consumption_with_bom_status": sync_tech_pack_consumption_with_bom_status,
        "_load_store_rows": _load_store_rows,
        "_save_store_rows": _save_store_rows,
        "default_material_po_payload": staticmethod(default_material_po_payload),
        "normalize_material_po_row": staticmethod(normalize_material_po_row),
        "default_material_receipt_payload": staticmethod(default_material_receipt_payload),
        "normalize_material_receipt_row": staticmethod(normalize_material_receipt_row),
        "default_material_issue_payload": staticmethod(default_material_issue_payload),
        "normalize_material_issue_row": staticmethod(normalize_material_issue_row),
        "default_warehouse_payload": staticmethod(default_warehouse_payload),
        "normalize_warehouse_row": staticmethod(normalize_warehouse_row),
        "get_bom_trim_size_options": get_bom_trim_size_options,
        "_tech_pack_bom_item_needs_color_split": _tech_pack_bom_item_needs_color_split,
        "_build_tech_pack_bom_item_variants": _build_tech_pack_bom_item_variants,
        "_find_bom_consumption_detail": _find_bom_consumption_detail,
        "_find_bom_consumption_by_version": _find_bom_consumption_by_version,
        "summarize_bom_purchase_order_requirements": summarize_bom_purchase_order_requirements,
        "summarize_bom_purchase_order_requirements_from_source": summarize_bom_purchase_order_requirements_from_source,
        "_trace_bom_requirement_debug": _trace_bom_requirement_debug,
        "enrich_bom_requirement_rows": enrich_bom_requirement_rows,
        "load_material_po_rows_from_source": load_material_po_rows_from_source,
        "save_material_po_row_to_source": save_material_po_row_to_source,
        "delete_material_po_row_from_source": delete_material_po_row_from_source,
        "_generate_auto_material_po_number": _generate_auto_material_po_number,
        "_generate_auto_material_receipt_number": _generate_auto_material_receipt_number,
        "auto_generate_material_po_from_booking_shortage": auto_generate_material_po_from_booking_shortage,
        "load_material_receipt_rows_from_source": load_material_receipt_rows_from_source,
        "save_material_receipt_row_to_source": save_material_receipt_row_to_source,
        "delete_material_receipt_row_from_source": delete_material_receipt_row_from_source,
        "summarize_material_receipts_by_po_item": summarize_material_receipts_by_po_item,
        "get_material_receipt_history_for_po_item": get_material_receipt_history_for_po_item,
        "load_material_issue_rows_from_source": load_material_issue_rows_from_source,
        "save_material_issue_row_to_source": save_material_issue_row_to_source,
        "delete_material_issue_row_from_source": delete_material_issue_row_from_source,
        "load_warehouse_rows_from_source": load_warehouse_rows_from_source,
        "save_warehouse_row_to_source": save_warehouse_row_to_source,
        "delete_warehouse_row_from_source": delete_warehouse_row_from_source,
        "load_bom_items_from_source": load_bom_items_from_source,
        "save_bom_item_to_source": save_bom_item_to_source,
        "delete_bom_item_from_source": delete_bom_item_from_source,
        "_build_bom_module": _build_bom_module,
        "refresh_bom_module": refresh_bom_module,
        "update_bom_filter_input_state": update_bom_filter_input_state,
        "apply_bom_filter": apply_bom_filter,
        "commit_bom_search_query": commit_bom_search_query,
        "on_bom_search_submit": on_bom_search_submit,
        "schedule_bom_filter_apply": schedule_bom_filter_apply,
        "run_bom_filter_apply": run_bom_filter_apply,
        "sync_bom_search_query": sync_bom_search_query,
        "refresh_bom_tree": refresh_bom_tree,
        "update_bom_module_status": update_bom_module_status,
        "get_selected_bom_item_id": get_selected_bom_item_id,
        "get_selected_bom_item": get_selected_bom_item,
        "on_bom_heading_click": on_bom_heading_click,
        "update_bom_tree_check_cell": update_bom_tree_check_cell,
        "on_bom_tree_click": on_bom_tree_click,
        "on_bom_item_selected": on_bom_item_selected,
        "on_bom_item_double_click": on_bom_item_double_click,
        "translate_bom_sort_key": translate_bom_sort_key,
        "translate_bom_filter_field": translate_bom_filter_field,
        "on_bom_sort_key_changed": on_bom_sort_key_changed,
        "on_bom_filter_field_changed": on_bom_filter_field_changed,
        "on_bom_sort_order_changed": on_bom_sort_order_changed,
        "open_new_bom_item_dialog": open_new_bom_item_dialog,
        "edit_selected_bom_item": edit_selected_bom_item,
        "copy_selected_bom_item": copy_selected_bom_item,
        "delete_selected_bom_item": delete_selected_bom_item,
        "open_bom_report_builder": open_bom_report_builder,
        "open_bom_item_detail_window": open_bom_item_detail_window,
        "get_selected_or_prompt_bom_tech_pack_styles": get_selected_or_prompt_bom_tech_pack_styles,
        "build_bom_items_from_tech_pack_style": build_bom_items_from_tech_pack_style,
        "generate_bom_from_tech_pack": generate_bom_from_tech_pack,
        "build_bom_items_from_fds_data": build_bom_items_from_fds_data,
        "open_import_fds_menu": open_import_fds_menu,
        "import_fds_single_file": import_fds_single_file,
        "import_fds_folder": import_fds_folder,
        "import_fds_files": import_fds_files,
        "open_material_po_detail_window": open_material_po_detail_window,
        "refresh_materials_po_window": refresh_materials_po_window,
        "open_materials_po_window": open_materials_po_window,
        "open_material_receipt_detail_window": open_material_receipt_detail_window,
        "open_material_issue_detail_window": open_material_issue_detail_window,
        "open_warehouse_detail_window": open_warehouse_detail_window,
        "refresh_material_warehouse_window": refresh_material_warehouse_window,
        "open_material_warehouse_window": open_material_warehouse_window,
    }
    for method_name, function in method_map.items():
        setattr(app_cls, method_name, function)
