import os
import math
from collections import defaultdict
from functools import wraps
from time import time
import hmac
import json
import threading
import uuid
import shutil
import tempfile
import zipfile
import subprocess
import difflib
import fcntl
import csv
import re
try:
    import openpyxl
except Exception:
    openpyxl = None
try:
    from odf.opendocument import load as load_ods  # type: ignore
    from odf.table import Table as OdsTable, TableRow as OdsRow, TableCell as OdsCell  # type: ignore
    from odf.text import P as OdsP  # type: ignore
except Exception:
    load_ods = None
    OdsTable = None
    OdsRow = None
    OdsCell = None
    OdsP = None
from concurrent.futures import ThreadPoolExecutor
from typing import Callable, Iterable, Optional, Set

from flask import Flask, g, jsonify, request, current_app, send_file, send_from_directory
from flask_cors import CORS
from flask_socketio import SocketIO
from flask_security import Security, SQLAlchemyUserDatastore
from flask_principal import Principal, Permission, RoleNeed, UserNeed, Identity, AnonymousIdentity, identity_changed

# App metadata
APP_VERSION = "1.0"

# Pagination helper
def paginate_query(query):
    try:
        page = max(int(request.args.get("page", 1)), 1)
    except Exception:
        page = 1
    try:
        per_page = int(request.args.get("per_page", 25))
    except Exception:
        per_page = 25
    per_page = max(1, min(per_page, 200))
    total = query.order_by(None).count()
    items = query.limit(per_page).offset((page - 1) * per_page).all()
    pages = (total + per_page - 1) // per_page
    return items, {"page": page, "per_page": per_page, "total": total, "pages": pages}

# Pagination helper for in-memory lists
def paginate_list(items):
    try:
        page = max(int(request.args.get("page", 1)), 1)
    except Exception:
        page = 1
    try:
        per_page = int(request.args.get("per_page", 25))
    except Exception:
        per_page = 25
    per_page = max(1, min(per_page, 200))
    total = len(items)
    pages = (total + per_page - 1) // per_page
    start = (page - 1) * per_page
    end = start + per_page
    return items[start:end], {"page": page, "per_page": per_page, "total": total, "pages": pages}


def submit_background_job(app: Flask, job_type: str, target: Callable[[], dict | list | str | int | float | None]) -> str:
    job_id = uuid.uuid4().hex
    now = datetime.now(timezone.utc).isoformat()
    with BACKGROUND_JOBS_LOCK:
        BACKGROUND_JOBS[job_id] = {
            "id": job_id,
            "type": job_type,
            "status": "queued",
            "created_at": now,
            "started_at": None,
            "finished_at": None,
            "result": None,
            "error": None,
        }

    def _run():
        with BACKGROUND_JOBS_LOCK:
            row = BACKGROUND_JOBS.get(job_id)
            if row:
                row["status"] = "running"
                row["started_at"] = datetime.now(timezone.utc).isoformat()
        try:
            with app.app_context():
                result = target()
            with BACKGROUND_JOBS_LOCK:
                row = BACKGROUND_JOBS.get(job_id)
                if row:
                    row["status"] = "completed"
                    row["finished_at"] = datetime.now(timezone.utc).isoformat()
                    row["result"] = result
        except Exception as exc:
            with BACKGROUND_JOBS_LOCK:
                row = BACKGROUND_JOBS.get(job_id)
                if row:
                    row["status"] = "failed"
                    row["finished_at"] = datetime.now(timezone.utc).isoformat()
                    row["error"] = str(exc)

    BACKGROUND_EXECUTOR.submit(_run)
    return job_id

# Flask-Principal Permission Objects
# Dashboard permissions
dashboard_read = Permission(RoleNeed("dashboard_read"))

# Product permissions
products_read = Permission(RoleNeed("products_read"))
products_create = Permission(RoleNeed("products_create"))
products_edit = Permission(RoleNeed("products_edit"))
products_delete = Permission(RoleNeed("products_delete"))

# Sales permissions
sales_read = Permission(RoleNeed("sales_read"))
sales_create = Permission(RoleNeed("sales_create"))
sales_edit = Permission(RoleNeed("sales_edit"))
sales_delete = Permission(RoleNeed("sales_delete"))

# Inventory permissions
inventory_read = Permission(RoleNeed("inventory_read"))
inventory_edit = Permission(RoleNeed("inventory_edit"))

# Settings permissions
settings_read = Permission(RoleNeed("settings_read"))
settings_create = Permission(RoleNeed("settings_create"))
settings_edit = Permission(RoleNeed("settings_edit"))
settings_delete = Permission(RoleNeed("settings_delete"))
reports_read_perm = Permission(RoleNeed("reports_read"))

# Company permissions
company_admin = Permission(RoleNeed("company_admin"))
company_read = Permission(RoleNeed("company_read"))

# User management permissions
user_management = Permission(RoleNeed("user_management"))

# Admin permissions (all access)
admin_access = Permission(RoleNeed("admin_access"))
from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer
from sqlalchemy import and_, func, or_, text
import re
import calendar
from datetime import date, datetime, timedelta, timezone
import base64
import io
import smtplib
import imaplib
import email
import hashlib
import requests
from email import header as email_header
from email import utils as email_utils
from email.message import EmailMessage
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding
from webauthn import (
    generate_registration_options,
    generate_authentication_options,
    verify_registration_response,
    verify_authentication_response,
)
from webauthn.helpers import base64url_to_bytes, bytes_to_base64url, options_to_json
from webauthn.helpers.structs import (
    AttestationConveyancePreference,
    AuthenticatorSelectionCriteria,
    PublicKeyCredentialDescriptor,
    RegistrationCredential,
    AuthenticationCredential,
    UserVerificationRequirement,
)

from models import (
    Company,
    Account,
    AccountEntry,
    AccessRight,
    ActivityLog,
    MailMessage,
    MailOutbox,
    Currency,
    Unit,
    UnitCategory,
    Customer,
    InventoryLog,
    InventoryBatch,
    StockAdjustment,
    PurchaseBill,
    PurchaseBillItem,
    PurchaseBillPayment,
    PurchaseOrder,
    PurchaseOrderItem,
    PurchaseOrderReceiptLine,
    Supplier,
    Product,
    Sale,
    SaleItem,
    SaleImage,
    SalePayment,
    SaleReturn,
    SaleReturnItem,
    ExpiryReturn,
    ExpiryReturnLine,
    User,
    UserCompany,
    WebAuthnCredential,
    WebAuthnChallenge,
    UserPermission,
    UserPermissionGrant,
    PaymentMode,
    ChequeTemplate,
    Attendance,
    PayrollPayment,
    GalleryImage,
    BulkUploadFile,
    GoogleDriveBackupSetting,
    ServerSyncSetting,
    ServerSyncLog,
    ClinicPatient,
    ClinicAppointment,
    ClinicEncounter,
    db,
    seed_data,
    upgrade_schema,
    Role,
    RolePermission,
    roles_users,
)

# rudimentary in-memory tracking for login throttling (per-process)
FAILED_LOGINS = {}
IP_LOCKS = {}
INVENTORY_RECONCILE_AT: dict[int, float] = {}
BACKUP_SCHEDULER_STARTED = False
BACKUP_SCHEDULER_LOCK = threading.Lock()
SERVER_SYNC_SCHEDULER_STARTED = False
SERVER_SYNC_SCHEDULER_LOCK = threading.Lock()
SERVER_SYNC_LOCK_PATH = "/tmp/flask-pharmacy-server-sync.lock"
BACKGROUND_EXECUTOR = ThreadPoolExecutor(max_workers=max(2, int(os.getenv("BACKGROUND_WORKERS", "4"))))
BACKGROUND_JOBS: dict[str, dict] = {}
BACKGROUND_JOBS_LOCK = threading.Lock()
socketio = SocketIO(
    cors_allowed_origins="*",
    async_mode="threading",
    allow_upgrades=False,  # disable websocket upgrades to avoid Werkzeug assertion
    transports=["polling"],
)
security = None
user_datastore = None
principals = Principal()


def _load_env_defaults_from_backend_env() -> None:
    """
    Load backend/.env into process env only for missing keys.
    This keeps `python app.py` behavior consistent with start.sh/systemd.
    """
    env_path = os.path.join(os.path.dirname(__file__), ".env")
    if not os.path.isfile(env_path):
        return
    try:
        with open(env_path, "r", encoding="utf-8") as fh:
            for raw in fh:
                line = raw.strip()
                if not line or line.startswith("#") or "=" not in line:
                    continue
                key, value = line.split("=", 1)
                key = key.strip()
                value = value.strip()
                if not key or key in os.environ:
                    continue
                if (value.startswith('"') and value.endswith('"')) or (value.startswith("'") and value.endswith("'")):
                    value = value[1:-1]
                os.environ[key] = value
    except Exception:
        # Do not crash app startup due to env parsing issues.
        pass


def _renumber_purchase_bills(company_id: int | None = None, only_missing: bool = False) -> int:
    """
    Rebuild purchase bill numbers in chronological order per company and purchase date.
    Number format rule: YY-dayOfYear-sequenceWithinThatDate.
    Returns the number of rows changed.
    """
    query = PurchaseBill.query
    if company_id:
        query = query.filter(PurchaseBill.company_id == company_id)
    bills = (
        query.order_by(
            PurchaseBill.company_id.asc(),
            PurchaseBill.purchase_date.asc().nullslast(),
            PurchaseBill.created_at.asc(),
            PurchaseBill.id.asc(),
        ).all()
    )
    if not bills:
        return 0

    counters: dict[tuple[int, date], int] = {}
    changed = 0
    for bill in bills:
        pd = bill.purchase_date
        if not pd:
            pd = bill.created_at.date() if bill.created_at else _today_ad()
            bill.purchase_date = pd
        key = (int(bill.company_id), pd)
        seq = counters.get(key, 0) + 1
        counters[key] = seq
        yy = pd.strftime("%y")
        doy = pd.timetuple().tm_yday
        next_number = f"{yy}-{doy}-{seq}"
        current_number = str(getattr(bill, "bill_number", "") or "").strip()
        if only_missing and current_number:
            continue
        if current_number != next_number:
            bill.bill_number = next_number
            changed += 1
    return changed


def _backfill_purchase_bill_numbers():
    """Assign bill_number to existing purchase bills that are missing one."""
    try:
        changed = _renumber_purchase_bills(only_missing=True)
        if changed <= 0:
            return
        db.session.commit()
    except Exception:
        db.session.rollback()
        return


def _backfill_sale_numbers():
    """Assign sale_number to existing sales that are missing one."""
    try:
        missing = (
            Sale.query.filter((Sale.sale_number.is_(None)) | (Sale.sale_number == ""))
            .order_by(Sale.company_id.asc(), Sale.sale_date.asc().nullslast(), Sale.created_at.asc(), Sale.id.asc())
            .all()
        )
        if not missing:
            return
        counters: dict[tuple[int, date], int] = {}
        for sale in missing:
            sd = sale.sale_date
            if not sd:
                sd = sale.created_at.date() if sale.created_at else _today_ad()
                sale.sale_date = sd
            key = (sale.company_id, sd)
            seq = counters.get(key, 0) + 1
            counters[key] = seq
            yy = sd.strftime("%y")
            doy = sd.timetuple().tm_yday
            sale.sale_number = f"{yy}-{doy}-{seq}"
        db.session.commit()
    except Exception:
        db.session.rollback()
        return


def _backfill_sale_return_numbers():
    """Assign return_number to existing sales returns that are missing one."""
    try:
        missing = (
            SaleReturn.query.filter((SaleReturn.return_number.is_(None)) | (SaleReturn.return_number == ""))
            .order_by(SaleReturn.company_id.asc(), SaleReturn.return_date.asc(), SaleReturn.created_at.asc(), SaleReturn.id.asc())
            .all()
        )
        if not missing:
            return
        counters: dict[tuple[int, date], int] = {}
        for ret in missing:
            rd = ret.return_date
            if not rd:
                rd = ret.created_at.date() if ret.created_at else _today_ad()
                ret.return_date = rd
            key = (ret.company_id, rd)
            seq = counters.get(key, 0) + 1
            counters[key] = seq
            yy = rd.strftime("%y")
            doy = rd.timetuple().tm_yday
            ret.return_number = f"{yy}-{doy}-{seq}"
        db.session.commit()
    except Exception:
        db.session.rollback()
        return


def has_role(user: User, role_name: str) -> bool:
    if not user:
        return False
    if user.role == role_name:
        return True
    return any(r.name == role_name for r in getattr(user, "roles", []))

PRODUCT_SCHEMA = [
    {"key": "alias_name", "label": "Alias name", "type": "string", "default": ""},
    {"key": "manufacturer", "label": "Manufacturer", "type": "string", "default": ""},
    {"key": "composition", "label": "Composition", "type": "string", "default": ""},
    {"key": "hscode", "label": "HS Code", "type": "string", "default": ""},
    {"key": "uom_category", "label": "UoM category", "type": "string", "default": ""},
    {"key": "prescription_required", "label": "Prescription required?", "type": "boolean", "default": False},
    {"key": "recurrent", "label": "Recurrent medicine", "type": "boolean", "default": False},
    {"key": "shelf_removal", "label": "Shelf removal/return before expiry", "type": "boolean", "default": False},
    {"key": "shelf_removal_offset_days", "label": "Shelf removal lead time (days)", "type": "number", "default": 30},
    {"key": "lot_tracking", "label": "Lot/serial tracking", "type": "boolean", "default": False},
    {"key": "lot_number", "label": "Lot/serial number", "type": "string", "default": ""},
    {"key": "vat_item", "label": "VAT item", "type": "boolean", "default": False},
    {"key": "charge_cc_free_items", "label": "Charge CC for free items", "type": "boolean", "default": False},
    {"key": "low_threshold", "label": "Low threshold warning (units)", "type": "number", "default": 0},
]

# Platform-level admins (can see SuperUser Settings / manage companies across tenants)
ROLE_PLATFORM_ADMINS: Set[str] = {"superuser", "superadmin"}
ROLE_COMPANY_ADMINS: Set[str] = {"admin", "manager", "superuser", "superadmin"}
ROLE_SALES: Set[str] = {"admin", "manager", "staff", "salesman", "superuser", "superadmin"}
PAYMENT_METHODS = ["cash", "card", "transfer"]


def require_env(name: str, min_length: int = 0, default: str | None = None) -> str:
    """Fetch a required environment variable and enforce basic strength. Falls back to default if provided."""
    value = os.getenv(name) or default
    if not value:
        raise RuntimeError(f"{name} must be set")
    if min_length and len(value) < min_length:
        raise RuntimeError(f"{name} must be at least {min_length} characters long")
    return value


def _decode_company_logo_bytes(company: Company) -> bytes | None:
    raw = (getattr(company, "logo_data", None) or "").strip()
    if not raw:
        return None
    if raw.startswith("data:"):
        try:
            _, b64 = raw.split(",", 1)
        except ValueError:
            return None
        raw = b64
    try:
        return base64.b64decode(raw, validate=False)
    except Exception:
        return None


def _decode_user_signature_bytes(user: User | None) -> bytes | None:
    raw = (getattr(user, "signature_url", None) or "").strip()
    if not raw or raw.startswith("http"):
        return None
    if raw.startswith("data:"):
        try:
            _, b64 = raw.split(",", 1)
        except ValueError:
            return None
        raw = b64
    try:
        return base64.b64decode(raw, validate=False)
    except Exception:
        return None


def _format_date_short(value) -> str:
    if not value:
        return "-"
    if isinstance(value, str):
        try:
            value = datetime.fromisoformat(value.replace("Z", "+00:00"))
        except Exception:
            return value
    if hasattr(value, "strftime"):
        return value.strftime("%Y-%m-%d")
    return str(value)


def _format_datetime_short(value) -> str:
    if not value:
        return "-"
    if isinstance(value, str):
        try:
            value = datetime.fromisoformat(value.replace("Z", "+00:00"))
        except Exception:
            return value
    if hasattr(value, "strftime"):
        return value.strftime("%Y-%m-%d %H:%M")
    return str(value)


def _format_dt(value) -> str:
    # Mail endpoints use this shared formatter for thread/message timestamps.
    return _format_datetime_short(value)


def _amount_in_words(amount: float) -> str:
    try:
        val = float(amount or 0.0)
    except Exception:
        val = 0.0
    try:
        from num2words import num2words  # type: ignore
        import math

        val = round(val, 2)
        rupees = int(math.floor(val))
        paise = int(round((val - rupees) * 100))
        if paise >= 100:
            rupees += paise // 100
            paise = paise % 100
        words = num2words(rupees, lang="en_IN")
        words = words.replace("-", " ").replace(",", " ").title()
        if paise > 0:
            paise_words = num2words(paise, lang="en_IN").replace("-", " ").replace(",", " ").title()
            return f"{words} Rupees And {paise_words} Paise Only"
        return f"{words} Rupees Only"
    except Exception:
        # Fallback manual converter (crores/lakhs)
        import math

        units = [
            "",
            "One",
            "Two",
            "Three",
            "Four",
            "Five",
            "Six",
            "Seven",
            "Eight",
            "Nine",
            "Ten",
            "Eleven",
            "Twelve",
            "Thirteen",
            "Fourteen",
            "Fifteen",
            "Sixteen",
            "Seventeen",
            "Eighteen",
            "Nineteen",
        ]
        tens = ["", "", "Twenty", "Thirty", "Forty", "Fifty", "Sixty", "Seventy", "Eighty", "Ninety"]

        def two_digit(n: int) -> str:
            if n < 20:
                return units[n]
            return (tens[n // 10] + (" " + units[n % 10] if n % 10 else "")).strip()

        def three_digit(n: int) -> str:
            hundred = n // 100
            rest = n % 100
            parts = []
            if hundred:
                parts.append(units[hundred] + " Hundred")
            if rest:
                parts.append(two_digit(rest))
            return " ".join(parts).strip()

        val = round(val, 2)
        num = int(math.floor(val))
        paise = int(round((val - num) * 100))
        if paise >= 100:
            num += paise // 100
            paise = paise % 100
        parts = []
        if num == 0:
            parts.append("Zero")
        else:
            crore = num // 10000000
            num %= 10000000
            lakh = num // 100000
            num %= 100000
            thousand = num // 1000
            num %= 1000
            if crore:
                parts.append(three_digit(crore) + " Crore")
            if lakh:
                parts.append(three_digit(lakh) + " Lakh")
            if thousand:
                parts.append(three_digit(thousand) + " Thousand")
            if num > 0:
                parts.append(three_digit(num))
        words = " ".join(parts).strip()
        if paise:
            return f"{words} Rupees And {two_digit(paise)} Paise Only"
        return f"{words} Rupees Only"


def _format_expiry_month_year(iso: str | None) -> str:
    if not iso:
        return "-"
    try:
        dt = datetime.fromisoformat(str(iso)[:10])
    except Exception:
        return str(iso)
    return dt.strftime("%b %Y")


def _build_pdf_response(pdf_bytes: bytes, filename: str):
    resp = current_app.response_class(pdf_bytes, mimetype="application/pdf")
    resp.headers["Content-Disposition"] = f'inline; filename="{filename}"'
    # Prevent stale PDF content from browser/proxy cache after cheque template edits.
    resp.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
    resp.headers["Pragma"] = "no-cache"
    resp.headers["Expires"] = "0"
    return resp


def _qr_link(path: str) -> str:
    try:
        base = request.host_url.rstrip("/")
        return f"{base}{path}"
    except Exception:
        return path


def _postgres_dump_path(tmp_dir: str, timestamp: str) -> tuple[str, dict]:
    try:
        url = db.engine.url
    except Exception:
        raise RuntimeError("Database engine is not ready")
    if not str(url.drivername).startswith("postgresql"):
        raise RuntimeError("PostgreSQL backup requires a PostgreSQL database")

    dbname = url.database
    if not dbname:
        raise RuntimeError("PostgreSQL database name is missing")
    username = url.username or ""
    password = url.password or ""
    host = url.host or ""
    port = int(url.port or 5432)

    backup_name = f"pharmacy-backup-{timestamp}.sql"
    backup_path = os.path.join(tmp_dir, backup_name)

    cmd = ["pg_dump", "--format=plain", "--no-owner", "--no-privileges", "-f", backup_path]
    if host:
        cmd += ["-h", host]
    if port:
        cmd += ["-p", str(port)]
    if username:
        cmd += ["-U", username]
    cmd.append(dbname)

    env = os.environ.copy()
    if password:
        env["PGPASSWORD"] = password

    try:
        subprocess.run(cmd, check=True, env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
    except FileNotFoundError:
        raise RuntimeError("pg_dump not found. Install PostgreSQL client tools.")
    except subprocess.CalledProcessError as exc:
        msg = exc.stderr.decode("utf-8", "ignore").strip() if exc.stderr else "pg_dump failed"
        raise RuntimeError(msg)

    meta = {
        "db_engine": "postgresql",
        "db_name": dbname,
        "db_host": host or "localhost",
        "db_port": port,
        "db_user": username or None,
        "db_filename": backup_name,
    }
    return backup_path, meta


def _mysql_url_parts() -> tuple[str, str, str, int, str]:
    try:
        url = db.engine.url
    except Exception:
        raise RuntimeError("Database engine is not ready")
    if not str(url.drivername).startswith("mysql"):
        raise RuntimeError("MySQL operation requires a MySQL database")
    dbname = url.database
    if not dbname:
        raise RuntimeError("MySQL database name is missing")
    return (
        dbname,
        url.username or "",
        url.password or "",
        url.host or "localhost",
        int(url.port or 3306),
    )


def _mysql_dump_path(tmp_dir: str, timestamp: str) -> tuple[str, dict]:
    dbname, username, password, host, port = _mysql_url_parts()
    backup_name = f"pharmacy-backup-{timestamp}.sql"
    backup_path = os.path.join(tmp_dir, backup_name)
    cmd = [
        "mysqldump",
        "--single-transaction",
        "--quick",
        "--routines",
        "--triggers",
        "--add-drop-table",
        "--result-file",
        backup_path,
        "-h",
        host,
        "-P",
        str(port),
    ]
    if username:
        cmd += ["-u", username]
    cmd.append(dbname)
    env = os.environ.copy()
    if password:
        env["MYSQL_PWD"] = password
    try:
        subprocess.run(cmd, check=True, env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
    except FileNotFoundError:
        raise RuntimeError("mysqldump not found. Install MySQL client tools.")
    except subprocess.CalledProcessError as exc:
        msg = exc.stderr.decode("utf-8", "ignore").strip() if exc.stderr else "mysqldump failed"
        raise RuntimeError(msg)
    return backup_path, {
        "db_engine": "mysql",
        "db_name": dbname,
        "db_host": host,
        "db_port": port,
        "db_user": username or None,
        "db_filename": backup_name,
    }


def _b64url(data: bytes) -> str:
    return base64.urlsafe_b64encode(data).decode("ascii").rstrip("=")


def _google_sa_token(service_account: dict, scope: str = "https://www.googleapis.com/auth/drive.file") -> str:
    private_key = service_account.get("private_key")
    client_email = service_account.get("client_email")
    if not private_key or not client_email:
        raise RuntimeError("Service account JSON must include private_key and client_email")

    now_ts = int(datetime.now(timezone.utc).timestamp())
    header = {"alg": "RS256", "typ": "JWT"}
    payload = {
        "iss": client_email,
        "scope": scope,
        "aud": "https://oauth2.googleapis.com/token",
        "iat": now_ts - 30,
        "exp": now_ts + 3600,
    }
    header_b64 = _b64url(json.dumps(header, separators=(",", ":")).encode("utf-8"))
    payload_b64 = _b64url(json.dumps(payload, separators=(",", ":")).encode("utf-8"))
    signing_input = f"{header_b64}.{payload_b64}".encode("utf-8")

    key = serialization.load_pem_private_key(private_key.encode("utf-8"), password=None)
    signature = key.sign(signing_input, padding.PKCS1v15(), hashes.SHA256())
    assertion = f"{header_b64}.{payload_b64}.{_b64url(signature)}"

    resp = requests.post(
        "https://oauth2.googleapis.com/token",
        data={
            "grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer",
            "assertion": assertion,
        },
        timeout=30,
    )
    if resp.status_code >= 300:
        raise RuntimeError(f"Google token error: {resp.text[:300]}")
    token = (resp.json() or {}).get("access_token")
    if not token:
        raise RuntimeError("Google token response did not include access_token")
    return token


def _create_backup_zip_bytes() -> tuple[bytes, str, str]:
    timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
    tmp_dir = tempfile.mkdtemp(prefix="pharmacy-backup-")
    try:
        engine = str(getattr(db.engine.url, "drivername", "") or "")
        meta_extra = {}
        if engine.startswith("postgresql"):
            backup_path, meta_extra = _postgres_dump_path(tmp_dir, timestamp)
            backup_name = meta_extra.get("db_filename", os.path.basename(backup_path))
        elif engine.startswith("mysql"):
            backup_path, meta_extra = _mysql_dump_path(tmp_dir, timestamp)
            backup_name = meta_extra.get("db_filename", os.path.basename(backup_path))
        else:
            raise RuntimeError(f"Backup is not supported for engine: {engine or 'unknown'}")
        zip_buf = io.BytesIO()
        with zipfile.ZipFile(zip_buf, "w", zipfile.ZIP_DEFLATED) as zf:
            zf.write(backup_path, arcname=backup_name)
            meta = {
                "backup_at": datetime.now(timezone.utc).isoformat(),
                **meta_extra,
                "app_version": APP_VERSION,
            }
            zf.writestr("backup_meta.json", json.dumps(meta, indent=2))
        zip_buf.seek(0)
        download_name = f"pharmacy-backup-{timestamp}.zip"
        return zip_buf.getvalue(), download_name, meta["backup_at"]
    finally:
        shutil.rmtree(tmp_dir, ignore_errors=True)


def _postgres_psql_env() -> tuple[list[str], dict]:
    try:
        url = db.engine.url
    except Exception:
        raise RuntimeError("Database engine is not ready")
    if not str(url.drivername).startswith("postgresql"):
        raise RuntimeError("PostgreSQL restore requires a PostgreSQL database")
    dbname = url.database
    if not dbname:
        raise RuntimeError("PostgreSQL database name is missing")
    username = url.username or ""
    password = url.password or ""
    host = url.host or ""
    port = int(url.port or 5432)

    cmd = ["psql", "-v", "ON_ERROR_STOP=1"]
    if host:
        cmd += ["-h", host]
    if port:
        cmd += ["-p", str(port)]
    if username:
        cmd += ["-U", username]
    cmd += [dbname]

    env = os.environ.copy()
    if password:
        env["PGPASSWORD"] = password
    return cmd, env


def _mysql_cmd_env() -> tuple[list[str], dict]:
    dbname, username, password, host, port = _mysql_url_parts()
    cmd = ["mysql", "-h", host, "-P", str(port)]
    if username:
        cmd += ["-u", username]
    cmd += [dbname]
    env = os.environ.copy()
    if password:
        env["MYSQL_PWD"] = password
    return cmd, env


def _upload_backup_to_google_drive(
    zip_bytes: bytes,
    filename: str,
    folder_id: str | None,
    service_account_json: str,
) -> dict:
    try:
        service_account = json.loads(service_account_json or "{}")
    except Exception:
        raise RuntimeError("Invalid service account JSON")
    token = _google_sa_token(service_account)
    metadata = {"name": filename}
    if folder_id:
        metadata["parents"] = [folder_id]
    files = {
        "metadata": ("metadata", json.dumps(metadata), "application/json; charset=UTF-8"),
        "file": (filename, zip_bytes, "application/zip"),
    }
    resp = requests.post(
        "https://www.googleapis.com/upload/drive/v3/files?uploadType=multipart&fields=id,name,createdTime,webViewLink",
        headers={"Authorization": f"Bearer {token}"},
        files=files,
        timeout=60,
    )
    if resp.status_code >= 300:
        raise RuntimeError(f"Google Drive upload failed: {resp.text[:300]}")
    return resp.json() or {}


def _google_drive_request(
    method: str,
    url: str,
    *,
    service_account_json: str,
    params: dict | None = None,
    json_payload: dict | None = None,
    timeout: int = 30,
) -> dict:
    try:
        service_account = json.loads(service_account_json or "{}")
    except Exception:
        raise RuntimeError("Invalid service account JSON")
    token = _google_sa_token(service_account)
    resp = requests.request(
        method.upper(),
        url,
        headers={"Authorization": f"Bearer {token}"},
        params=params,
        json=json_payload,
        timeout=timeout,
    )
    if resp.status_code >= 300:
        raise RuntimeError(f"Google Drive request failed: {resp.text[:300]}")
    if not resp.text:
        return {}
    return resp.json() or {}


def _google_drive_folder_info(folder_id: str, service_account_json: str) -> dict:
    if not str(folder_id or "").strip():
        return {}
    return _google_drive_request(
        "GET",
        f"https://www.googleapis.com/drive/v3/files/{folder_id}",
        service_account_json=service_account_json,
        params={"fields": "id,name,mimeType,webViewLink"},
    )


def _list_google_drive_backup_files(
    *,
    service_account_json: str,
    folder_id: str | None = None,
    limit: int = 20,
) -> list[dict]:
    q_parts = [
        "trashed = false",
        "mimeType != 'application/vnd.google-apps.folder'",
        "name contains 'pharmacy-backup-'",
    ]
    if folder_id:
        q_parts.append(f"'{folder_id}' in parents")
    data = _google_drive_request(
        "GET",
        "https://www.googleapis.com/drive/v3/files",
        service_account_json=service_account_json,
        params={
            "q": " and ".join(q_parts),
            "pageSize": max(1, min(int(limit or 20), 100)),
            "orderBy": "createdTime desc",
            "fields": "files(id,name,createdTime,size,webViewLink)",
        },
    )
    return list(data.get("files") or [])


def _backup_interval_seconds(interval_value: int, interval_unit: str) -> int:
    value = max(1, int(interval_value or 1))
    unit = str(interval_unit or "minutes").strip().lower()
    if unit == "hours":
        return value * 3600
    return value * 60


def _execute_google_drive_backup(*, from_scheduler: bool = False) -> dict:
    setting = GoogleDriveBackupSetting.query.order_by(GoogleDriveBackupSetting.id.asc()).first()
    if not setting:
        raise RuntimeError("Google Drive backup settings not configured")
    if not setting.enabled:
        raise RuntimeError("Google Drive backup is disabled")
    if not str(setting.service_account_json or "").strip():
        raise RuntimeError("Service account JSON is required")

    zip_bytes, filename, _backup_at = _create_backup_zip_bytes()
    upload_data = _upload_backup_to_google_drive(
        zip_bytes=zip_bytes,
        filename=filename,
        folder_id=setting.folder_id or None,
        service_account_json=setting.service_account_json or "",
    )
    setting.last_backup_at = datetime.now(timezone.utc)
    setting.last_backup_filename = filename
    setting.last_error = None
    db.session.commit()
    return {
        "backup_filename": filename,
        "uploaded_file_id": upload_data.get("id"),
        "uploaded_name": upload_data.get("name"),
        "uploaded_link": upload_data.get("webViewLink"),
        "from_scheduler": bool(from_scheduler),
    }


def _start_backup_scheduler(app: Flask) -> None:
    global BACKUP_SCHEDULER_STARTED
    with BACKUP_SCHEDULER_LOCK:
        if BACKUP_SCHEDULER_STARTED:
            return
        BACKUP_SCHEDULER_STARTED = True

    def _loop():
        while True:
            try:
                with app.app_context():
                    setting = GoogleDriveBackupSetting.query.order_by(GoogleDriveBackupSetting.id.asc()).first()
                    if setting and setting.enabled and str(setting.service_account_json or "").strip():
                        now_utc = datetime.now(timezone.utc)
                        interval_seconds = _backup_interval_seconds(
                            int(setting.interval_value or 60),
                            str(setting.interval_unit or "minutes"),
                        )
                        last_run = setting.last_backup_at
                        due = (last_run is None) or ((now_utc - last_run).total_seconds() >= interval_seconds)
                        if due:
                            try:
                                _execute_google_drive_backup(from_scheduler=True)
                            except Exception as exc:
                                setting.last_error = str(exc)
                                db.session.commit()
            except Exception:
                # keep scheduler alive regardless of errors
                pass
            threading.Event().wait(20)

    t = threading.Thread(target=_loop, name="google-drive-backup-scheduler", daemon=True)
    t.start()


def _get_server_sync_setting() -> ServerSyncSetting:
    row = ServerSyncSetting.query.order_by(ServerSyncSetting.id.asc()).first()
    if not row:
        row = ServerSyncSetting(auto_sync_enabled=False, sync_interval_seconds=60)
        db.session.add(row)
        db.session.commit()
    return row


def _log_server_sync(
    event_type: str,
    status: str,
    message: str,
    *,
    remote_base_url: str | None = None,
    details: dict | None = None,
    triggered_by_user_id: int | None = None,
) -> None:
    try:
        row = ServerSyncLog(
            event_type=event_type,
            status=status,
            message=message,
            remote_base_url=(remote_base_url or "").strip() or None,
            details=details or {},
            triggered_by_user_id=triggered_by_user_id,
        )
        db.session.add(row)
        db.session.commit()
        log_action(
            f"server_sync_{event_type}",
            {
                "status": status,
                "message": message,
                "remote_base_url": (remote_base_url or "").strip() or None,
                **(details or {}),
            },
        )
    except Exception:
        db.session.rollback()


def _normalize_server_sync_base_url(value: str | None) -> str:
    raw = str(value or "").strip()
    if not raw:
        return ""
    raw = raw.rstrip("/")
    if not raw.startswith(("http://", "https://")):
        raw = f"http://{raw}"
    return raw.rstrip("/")


def _server_sync_verify_tls(base_url: str) -> bool:
    return not str(base_url or "").lower().startswith("https://")


def _server_sync_endpoint_candidates(base_url: str, path: str) -> list[str]:
    base = _normalize_server_sync_base_url(base_url)
    if not base:
        return []
    clean_path = "/" + str(path or "").lstrip("/")
    if base.endswith("/api"):
        return [f"{base}{clean_path}"]
    return [f"{base}{clean_path}", f"{base}/api{clean_path}"]


def _server_sync_secret(app: Flask) -> bytes:
    secret = os.getenv("SERVER_SYNC_SHARED_KEY") or app.config.get("SECRET_KEY") or "server-sync-default"
    return str(secret).encode("utf-8")


def _server_sync_payload_hash(payload: bytes) -> str:
    return hashlib.sha256(payload or b"").hexdigest()


def _server_sync_signature(app: Flask, timestamp: str, nonce: str, payload_hash: str) -> str:
    signing_input = f"{timestamp}\n{nonce}\n{payload_hash}".encode("utf-8")
    return hmac.new(_server_sync_secret(app), signing_input, hashlib.sha256).hexdigest()


def _server_sync_signed_headers(app: Flask, payload: bytes) -> dict[str, str]:
    timestamp = str(int(time()))
    nonce = uuid.uuid4().hex
    payload_hash = _server_sync_payload_hash(payload)
    return {
        "X-Sync-Timestamp": timestamp,
        "X-Sync-Nonce": nonce,
        "X-Sync-Payload-Hash": payload_hash,
        "X-Sync-Signature": _server_sync_signature(app, timestamp, nonce, payload_hash),
    }


def _verify_server_sync_request(app: Flask, payload: bytes) -> tuple[bool, str | None]:
    timestamp = str(request.headers.get("X-Sync-Timestamp") or "").strip()
    nonce = str(request.headers.get("X-Sync-Nonce") or "").strip()
    payload_hash = str(request.headers.get("X-Sync-Payload-Hash") or "").strip().lower()
    signature = str(request.headers.get("X-Sync-Signature") or "").strip().lower()
    if not timestamp or not nonce or not payload_hash or not signature:
        return False, "Missing sync authentication headers"
    try:
        ts = int(timestamp)
    except Exception:
        return False, "Invalid sync timestamp"
    if abs(int(time()) - ts) > 300:
        return False, "Sync request timestamp expired"
    actual_hash = _server_sync_payload_hash(payload)
    if actual_hash.lower() != payload_hash:
        return False, "Sync payload hash mismatch"
    expected = _server_sync_signature(app, timestamp, nonce, payload_hash).lower()
    if not hmac.compare_digest(expected, signature):
        return False, "Invalid sync signature"
    return True, None


def _server_sync_lock_fd():
    fd = os.open(SERVER_SYNC_LOCK_PATH, os.O_CREAT | os.O_RDWR, 0o600)
    try:
        fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
        return fd
    except BlockingIOError:
        os.close(fd)
        return None


def _server_sync_unlock_fd(fd) -> None:
    if fd is None:
        return
    try:
        fcntl.flock(fd, fcntl.LOCK_UN)
    except Exception:
        pass
    try:
        os.close(fd)
    except Exception:
        pass


def _restore_backup_zip(zip_path: str, mode: str = "replace") -> dict:
    engine = str(getattr(db.engine.url, "drivername", "") or "")
    if not engine.startswith("postgresql") and not engine.startswith("mysql"):
        raise RuntimeError(f"Restore is not supported for engine: {engine or 'unknown'}")
    mode = (mode or "replace").strip().lower()
    if mode not in ["replace", "merge"]:
        raise RuntimeError("Invalid restore mode")

    tmp_dir = tempfile.mkdtemp(prefix="pharmacy-restore-")
    extracted_db = None
    meta = None
    try:
        with zipfile.ZipFile(zip_path) as zf:
            candidates = [n for n in zf.namelist() if n.lower().endswith(".sql")]
            if "backup_meta.json" in zf.namelist():
                meta = json.loads(zf.read("backup_meta.json").decode("utf-8") or "{}")
            if not candidates:
                raise RuntimeError("No SQL backup file found in backup zip")
            target_name = candidates[0]
            extracted_db = zf.extract(target_name, path=tmp_dir)
    except RuntimeError:
        raise
    except Exception as exc:
        raise RuntimeError(f"Invalid backup zip: {exc}")

    backup_version = str(meta.get("app_version")) if meta and meta.get("app_version") else None
    if backup_version:
        comparison = _compare_versions(backup_version, APP_VERSION)
        if comparison > 0:
            raise RuntimeError(f"Backup version {backup_version} is newer than current version {APP_VERSION}.")
        needs_upgrade = comparison < 0
    else:
        needs_upgrade = True

    if mode != "replace":
        raise RuntimeError("Merge restore is not supported for SQL databases")

    if engine.startswith("postgresql"):
        expected_engine = "postgresql"
    elif engine.startswith("mysql"):
        expected_engine = "mysql"
    else:
        expected_engine = ""
    if meta and meta.get("db_engine") and meta.get("db_engine") != expected_engine:
        raise RuntimeError(f"Backup zip is not a {expected_engine} backup")

    db.session.remove()
    db.engine.dispose()
    if engine.startswith("postgresql"):
        cmd, env = _postgres_psql_env()
        try:
            drop_cmd = cmd + ["-c", "DROP SCHEMA public CASCADE; CREATE SCHEMA public;"]
            subprocess.run(drop_cmd, check=True, env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
            restore_cmd = cmd + ["-f", extracted_db]
            subprocess.run(restore_cmd, check=True, env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
            if needs_upgrade:
                upgrade_schema(db.engine)
        except FileNotFoundError:
            raise RuntimeError("psql not found. Install PostgreSQL client tools.")
        except subprocess.CalledProcessError as exc:
            msg = exc.stderr.decode("utf-8", "ignore").strip() if exc.stderr else "psql restore failed"
            raise RuntimeError(msg)
    else:
        cmd, env = _mysql_cmd_env()
        try:
            with db.engine.begin() as conn:
                conn.execute(text("SET FOREIGN_KEY_CHECKS=0"))
                tables = [row[0] for row in conn.execute(text("SHOW FULL TABLES WHERE Table_type = 'BASE TABLE'"))]
                for table in tables:
                    safe_table = str(table).replace("`", "``")
                    conn.execute(text(f"DROP TABLE IF EXISTS `{safe_table}`"))
                conn.execute(text("SET FOREIGN_KEY_CHECKS=1"))
            with open(extracted_db, "rb") as handle:
                subprocess.run(cmd, check=True, env=env, stdin=handle, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
            if needs_upgrade:
                upgrade_schema(db.engine)
        except FileNotFoundError:
            raise RuntimeError("mysql not found. Install MySQL client tools.")
        except subprocess.CalledProcessError as exc:
            msg = exc.stderr.decode("utf-8", "ignore").strip() if exc.stderr else "mysql restore failed"
            raise RuntimeError(msg)

    return {
        "restored": True,
        "backup_version": backup_version or "unknown",
        "upgraded": bool(needs_upgrade),
        "note": f"{expected_engine.title()} restore completed. Restart the server if data does not refresh immediately.",
    }


def _is_csv_backup_zip(zip_path: str) -> bool:
    try:
        with zipfile.ZipFile(zip_path) as zf:
            manifest_name = next((name for name in zf.namelist() if name.rstrip("/").endswith("manifest.json")), None)
            if not manifest_name:
                return False
            manifest = json.loads(zf.read(manifest_name).decode("utf-8") or "{}")
            return isinstance(manifest.get("tables"), list) and any(
                str((row or {}).get("file") or "").lower().endswith(".csv")
                for row in manifest.get("tables") or []
                if isinstance(row, dict)
            )
    except Exception:
        return False


def _coerce_csv_restore_value(raw, column_info: dict):
    if raw is None:
        return None
    value = str(raw)
    col_type = str(column_info.get("type") or "").lower()
    nullable = bool(column_info.get("nullable", True))
    if value == "":
        if not nullable and any(token in col_type for token in ["char", "text", "varchar", "string"]):
            return ""
        return None
    if "json" in col_type:
        try:
            return json.loads(value)
        except Exception:
            return value
    if "bool" in col_type:
        return value.strip().lower() in {"1", "true", "t", "yes", "y"}
    if any(token in col_type for token in ["int", "serial", "bigint", "smallint"]):
        try:
            return int(float(value))
        except Exception:
            return None if nullable else 0
    if any(token in col_type for token in ["float", "double", "numeric", "decimal", "real"]):
        try:
            return float(value)
        except Exception:
            return None if nullable else 0.0
    if "date" in col_type or "time" in col_type:
        return value
    return value


def _reset_sql_table_sequences(conn, inspector, tables: list[str]) -> None:
    engine = str(getattr(db.engine.url, "drivername", "") or "")
    if engine.startswith("postgresql"):
        for table in tables:
            pk_cols = inspector.get_pk_constraint(table).get("constrained_columns") or []
            if len(pk_cols) != 1:
                continue
            table_q = table.replace('"', '""')
            col_q = pk_cols[0].replace('"', '""')
            conn.execute(
                text(
                    f"SELECT setval(pg_get_serial_sequence('\"{table_q}\"', '{col_q}'), "
                    f"COALESCE((SELECT MAX(\"{col_q}\") FROM \"{table_q}\"), 1), true)"
                )
            )
    elif engine.startswith("mysql"):
        for table in tables:
            pk_cols = inspector.get_pk_constraint(table).get("constrained_columns") or []
            if len(pk_cols) != 1:
                continue
            safe_table = table.replace("`", "``")
            safe_col = pk_cols[0].replace("`", "``")
            max_id = conn.execute(text(f"SELECT COALESCE(MAX(`{safe_col}`), 0) FROM `{safe_table}`")).scalar() or 0
            conn.execute(text(f"ALTER TABLE `{safe_table}` AUTO_INCREMENT = {int(max_id) + 1}"))


def _restore_csv_backup_zip(zip_path: str, mode: str = "replace") -> dict:
    mode = (mode or "replace").strip().lower()
    if mode != "replace":
        raise RuntimeError("CSV restore is replace-only")
    engine = str(getattr(db.engine.url, "drivername", "") or "")
    if not engine.startswith("postgresql") and not engine.startswith("mysql"):
        raise RuntimeError(f"CSV restore is not supported for engine: {engine or 'unknown'}")

    tmp_dir = tempfile.mkdtemp(prefix="pharmacy-csv-restore-")
    try:
        with zipfile.ZipFile(zip_path) as zf:
            manifest_name = next((name for name in zf.namelist() if name.rstrip("/").endswith("manifest.json")), None)
            if not manifest_name:
                raise RuntimeError("CSV backup zip must contain manifest.json")
            manifest = json.loads(zf.read(manifest_name).decode("utf-8") or "{}")
            manifest_dir = os.path.dirname(manifest_name)
            table_entries = [row for row in (manifest.get("tables") or []) if isinstance(row, dict)]
            if not table_entries:
                raise RuntimeError("CSV backup manifest does not contain table entries")
            zf.extractall(tmp_dir)

        inspector = inspect(db.engine)
        existing_tables = set(inspector.get_table_names())
        restore_tables = [
            str(entry.get("name") or "").strip()
            for entry in table_entries
            if str(entry.get("name") or "").strip() in existing_tables
        ]
        if not restore_tables:
            raise RuntimeError("No matching database tables found in CSV backup")

        db.session.remove()
        with db.engine.begin() as conn:
            if engine.startswith("postgresql"):
                quoted = ", ".join(f'"{table.replace(chr(34), chr(34) + chr(34))}"' for table in restore_tables)
                if quoted:
                    conn.execute(text(f"TRUNCATE TABLE {quoted} RESTART IDENTITY CASCADE"))
            else:
                conn.execute(text("SET FOREIGN_KEY_CHECKS=0"))
                for table in restore_tables:
                    safe_table = table.replace("`", "``")
                    conn.execute(text(f"DELETE FROM `{safe_table}`"))

            inserted_counts: dict[str, int] = {}
            for entry in table_entries:
                table = str(entry.get("name") or "").strip()
                filename = str(entry.get("file") or "").strip()
                if not table or table not in existing_tables or not filename:
                    continue
                csv_path = os.path.join(tmp_dir, manifest_dir, filename) if manifest_dir else os.path.join(tmp_dir, filename)
                if not os.path.isfile(csv_path):
                    raise RuntimeError(f"CSV file missing for table {table}: {filename}")
                columns_info = {col["name"]: col for col in inspector.get_columns(table)}
                with open(csv_path, "r", encoding="utf-8", newline="") as handle:
                    reader = csv.DictReader(handle)
                    if not reader.fieldnames:
                        inserted_counts[table] = 0
                        continue
                    cols = [col for col in reader.fieldnames if col in columns_info]
                    if not cols:
                        inserted_counts[table] = 0
                        continue
                    if engine.startswith("mysql"):
                        table_sql = f"`{table.replace('`', '``')}`"
                        cols_sql = ", ".join(f"`{col.replace('`', '``')}`" for col in cols)
                    else:
                        table_sql = f"\"{table.replace(chr(34), chr(34) + chr(34))}\""
                        cols_sql = ", ".join(f"\"{col.replace(chr(34), chr(34) + chr(34))}\"" for col in cols)
                    placeholders = ", ".join(f":{col}" for col in cols)
                    stmt = text(f"INSERT INTO {table_sql} ({cols_sql}) VALUES ({placeholders})")
                    batch = []
                    count = 0
                    for row in reader:
                        batch.append({col: _coerce_csv_restore_value(row.get(col), columns_info[col]) for col in cols})
                        if len(batch) >= 500:
                            conn.execute(stmt, batch)
                            count += len(batch)
                            batch = []
                    if batch:
                        conn.execute(stmt, batch)
                        count += len(batch)
                    inserted_counts[table] = count

            if engine.startswith("mysql"):
                conn.execute(text("SET FOREIGN_KEY_CHECKS=1"))
            _reset_sql_table_sequences(conn, inspector, restore_tables)

        upgrade_schema(db.engine)
        return {
            "restored": True,
            "backup_type": "csv",
            "tables": len(restore_tables),
            "rows": sum(inserted_counts.values()),
            "inserted": inserted_counts,
            "note": "CSV restore completed. Restart the server if data does not refresh immediately.",
        }
    finally:
        shutil.rmtree(tmp_dir, ignore_errors=True)


def _ping_remote_server_sync(app: Flask, remote_base_url: str) -> dict:
    last_error = None
    headers = _server_sync_signed_headers(app, b"")
    for url in _server_sync_endpoint_candidates(remote_base_url, "/server-sync/ping"):
        try:
            resp = requests.post(url, headers=headers, timeout=20, verify=_server_sync_verify_tls(url))
            if resp.status_code < 300:
                data = resp.json() if resp.text else {}
                data["resolved_url"] = url.rsplit("/server-sync/ping", 1)[0]
                return data
            last_error = f"{resp.status_code}: {resp.text[:200]}"
        except Exception as exc:
            last_error = str(exc)
    raise RuntimeError(last_error or "Failed to reach remote sync server")


def _execute_server_sync(app: Flask, *, event_type: str = "manual_sync", triggered_by_user_id: int | None = None) -> dict:
    lock_fd = _server_sync_lock_fd()
    if lock_fd is None:
        raise RuntimeError("Another sync is already running")
    try:
        setting = _get_server_sync_setting()
        remote_base_url = _normalize_server_sync_base_url(setting.remote_base_url)
        if not remote_base_url:
            raise RuntimeError("Remote server IP/URL is not configured")

        ping_info = _ping_remote_server_sync(app, remote_base_url)
        zip_bytes, filename, _backup_at = _create_backup_zip_bytes()
        files = {"file": (filename, zip_bytes, "application/zip")}
        headers = _server_sync_signed_headers(app, zip_bytes)
        last_error = None
        response_data = None
        resolved_base = ping_info.get("resolved_url") or remote_base_url
        for url in _server_sync_endpoint_candidates(resolved_base, "/server-sync/import"):
            try:
                resp = requests.post(
                    url,
                    headers=headers,
                    files=files,
                    data={"mode": "replace"},
                    timeout=600,
                    verify=_server_sync_verify_tls(url),
                )
                if resp.status_code < 300:
                    response_data = resp.json() if resp.text else {}
                    resolved_base = url.rsplit("/server-sync/import", 1)[0]
                    break
                last_error = f"{resp.status_code}: {resp.text[:300]}"
            except Exception as exc:
                last_error = str(exc)
        if response_data is None:
            raise RuntimeError(last_error or "Remote import failed")

        setting.last_sync_at = datetime.now(timezone.utc)
        setting.last_error = None
        db.session.commit()
        _log_server_sync(
            event_type,
            "success",
            f"Sync completed to {resolved_base}",
            remote_base_url=resolved_base,
            details={
                "remote_response": response_data,
                "backup_filename": filename,
                "remote_ping": ping_info,
            },
            triggered_by_user_id=triggered_by_user_id,
        )
        return {
            "ok": True,
            "remote_base_url": resolved_base,
            "backup_filename": filename,
            "remote_response": response_data,
            "remote_ping": ping_info,
        }
    except Exception as exc:
        try:
            setting = _get_server_sync_setting()
            setting.last_error = str(exc)
            db.session.commit()
        except Exception:
            db.session.rollback()
        _log_server_sync(
            event_type,
            "failed",
            str(exc),
            remote_base_url=getattr(_get_server_sync_setting(), "remote_base_url", None),
            triggered_by_user_id=triggered_by_user_id,
        )
        raise
    finally:
        _server_sync_unlock_fd(lock_fd)


def _start_server_sync_scheduler(app: Flask) -> None:
    global SERVER_SYNC_SCHEDULER_STARTED
    with SERVER_SYNC_SCHEDULER_LOCK:
        if SERVER_SYNC_SCHEDULER_STARTED:
            return
        SERVER_SYNC_SCHEDULER_STARTED = True

    def _loop():
        while True:
            try:
                with app.app_context():
                    setting = ServerSyncSetting.query.order_by(ServerSyncSetting.id.asc()).first()
                    if setting and setting.auto_sync_enabled and str(setting.remote_base_url or "").strip():
                        interval_seconds = max(30, int(setting.sync_interval_seconds or 60))
                        last_run = setting.last_sync_at
                        now_utc = datetime.now(timezone.utc)
                        due = (last_run is None) or ((now_utc - last_run).total_seconds() >= interval_seconds)
                        if due:
                            try:
                                _execute_server_sync(app, event_type="auto_sync", triggered_by_user_id=setting.updated_by_user_id)
                            except Exception as exc:
                                setting.last_error = str(exc)
                                db.session.commit()
            except Exception:
                pass
            threading.Event().wait(20)

    t = threading.Thread(target=_loop, name="server-sync-scheduler", daemon=True)
    t.start()


def _pdf_scale_for_width(page_width: float) -> float:
    # A4 width in points is ~595. Keep small-page scale readable but compact.
    return max(0.62, min(1.0, float(page_width) / 595.0))


def _user_display_name(user=None, user_id: int | None = None) -> str:
    target = user
    if target is None and user_id:
        try:
            target = db.session.get(User, int(user_id))
        except Exception:
            target = None
    if not target:
        return "-"
    first_name = str(getattr(target, "first_name", "") or "").strip()
    last_name = str(getattr(target, "last_name", "") or "").strip()
    full_name = " ".join(part for part in [first_name, last_name] if part).strip()
    return full_name or "-"


def _render_purchase_order_pdf(company: Company, po: PurchaseOrder, page_size: str | None = None) -> bytes:
    try:
        from reportlab.lib import colors
        from reportlab.lib.pagesizes import A4, A5, A6, letter
        from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
        from reportlab.lib.units import mm
        from reportlab.platypus import Image, Paragraph, SimpleDocTemplate, Spacer, Table, TableStyle
        from reportlab.graphics.barcode import qr
        from reportlab.graphics.shapes import Drawing
    except Exception as exc:
        raise RuntimeError("PDF printing requires reportlab. Install backend requirements.txt.") from exc

    size_map = {"A4": A4, "A5": A5, "A6": A6, "LETTER": letter}
    target_size = size_map.get(str(page_size or "A4").upper(), A4)
    page_width = float(target_size[0])
    scale = _pdf_scale_for_width(page_width)
    dynamic_margin = min(12 * mm, max(5 * mm, page_width * 0.05))
    w = lambda value_mm: value_mm * mm * scale

    buf = io.BytesIO()
    doc = SimpleDocTemplate(
        buf,
        pagesize=target_size,
        rightMargin=dynamic_margin,
        leftMargin=dynamic_margin,
        topMargin=max(8 * mm, 14 * mm * scale),
        bottomMargin=max(8 * mm, 14 * mm * scale),
    )
    styles = getSampleStyleSheet()
    story = []

    logo_bytes = _decode_company_logo_bytes(company)
    header_cells = []
    if logo_bytes:
        try:
            header_cells.append(Image(io.BytesIO(logo_bytes), width=28 * mm, height=28 * mm))
        except Exception:
            header_cells.append(Paragraph("", styles["Normal"]))
    else:
        header_cells.append(Paragraph("", styles["Normal"]))

    company_lines = (
        f"<font size='{max(10, int(round(13 * scale)))}'><b>{company.name}</b></font>"
        f"<br/>{company.address or ''}<br/>{company.city or ''}<br/>Phone: {company.phone or ''}"
    )
    header_text_style = ParagraphStyle("POHeaderText", parent=styles["Normal"], fontSize=max(7, 10 * scale), leading=max(9, 12 * scale))
    header_cells.append(Paragraph(company_lines, header_text_style))
    title_style = ParagraphStyle("POTitle", parent=styles["Normal"], fontSize=max(7, 10 * scale), leading=max(9, 12 * scale), alignment=2)
    title_cell = Paragraph(f"<b>Purchase Order</b><br/>No: {po.number or f'#{po.id}'}", title_style)
    right_cell = title_cell
    usable_header_width = page_width - (2 * dynamic_margin)
    col_widths = [usable_header_width * 0.22, usable_header_width * 0.53, usable_header_width * 0.25]
    try:
        qr_data = _qr_link(f"/purchase-orders/{po.id}/print")
        qr_code = qr.QrCodeWidget(qr_data)
        bounds = qr_code.getBounds()
        size = w(22)
        qr_w = float(bounds[2] - bounds[0]) or 1.0
        qr_h = float(bounds[3] - bounds[1]) or 1.0
        d = Drawing(size, size, transform=[size / qr_w, 0, 0, size / qr_h, 0, 0])
        d.add(qr_code)
        d.hAlign = "RIGHT"
        right_cell = Table([[title_cell], [d]], colWidths=[col_widths[2]])
        right_cell.setStyle(
            TableStyle(
                [
                    ("ALIGN", (0, 0), (-1, -1), "RIGHT"),
                    ("BOTTOMPADDING", (0, 0), (-1, -1), 2),
                    ("TOPPADDING", (0, 0), (-1, -1), 2),
                ]
            )
        )
    except Exception:
        right_cell = title_cell
    header_cells.append(right_cell)

    header = Table([header_cells], colWidths=col_widths)
    header.setStyle(TableStyle([("VALIGN", (0, 0), (-1, -1), "TOP")]))
    story.append(header)
    story.append(Spacer(1, max(3 * mm, 6 * mm * scale)))

    supplier_name = po.supplier.name if po.supplier else (po.supplier_name or "-")
    def to_nepali_date(dt_val):
        try:
            import nepali_datetime  # type: ignore
        except Exception:
            return ""
        if not dt_val:
            return ""
        try:
            if isinstance(dt_val, str):
                dt_val = datetime.fromisoformat(dt_val[:10])
            nd = nepali_datetime.date.from_datetime_date(dt_val if isinstance(dt_val, date) else dt_val.date())
            return nd.strftime("%Y-%m-%d")
        except Exception:
            return ""

    created_bs = to_nepali_date(po.created_at)
    receipt_date = po.receipt_date or po.expected_arrival
    receipt_bs = to_nepali_date(receipt_date)
    created_by_name = _user_display_name(po.created_by)
    meta_rows = [
        ["Supplier", supplier_name, "Status", str(po.status or "").title()],
        [
            "Created",
            f"{_format_date_short(po.created_at)}" + (f" ({created_bs})" if created_bs else ""),
            "Created By",
            created_by_name,
        ],
    ]
    if str(po.status or "").lower() == "received" and receipt_date:
        meta_rows.append(
            [
                "Receipt Date",
                f"{_format_date_short(receipt_date)}" + (f" ({receipt_bs})" if receipt_bs else ""),
                "",
                "",
            ]
        )
    meta = Table(meta_rows, colWidths=[usable_header_width * 0.16, usable_header_width * 0.40, usable_header_width * 0.16, usable_header_width * 0.28])
    meta.setStyle(
        TableStyle(
            [
                ("GRID", (0, 0), (-1, -1), 0.25, colors.lightgrey),
                ("BACKGROUND", (0, 0), (0, -1), colors.whitesmoke),
                ("BACKGROUND", (2, 0), (2, -1), colors.whitesmoke),
                ("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
                ("FONTSIZE", (0, 0), (-1, -1), max(6, int(round(9 * scale)))),
            ]
        )
    )
    story.append(meta)
    story.append(Spacer(1, max(3 * mm, 6 * mm * scale)))

    rows = [["HS Code", "Item", "Qty", "UoM"]]
    for it in po.items or []:
        product = it.product
        rows.append(
            [
                getattr(product, "hscode", None) or "-",
                product.name if product else (it.product_name if hasattr(it, "product_name") else "-"),
                str(int(it.qty or 0)),
                it.uom or "-",
            ]
        )

    table = Table(
        rows,
        colWidths=[usable_header_width * 0.16, usable_header_width * 0.48, usable_header_width * 0.14, usable_header_width * 0.22],
        repeatRows=1,
    )
    table.setStyle(
        TableStyle(
            [
                ("GRID", (0, 0), (-1, -1), 0.25, colors.lightgrey),
                ("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#f3f4f6")),
                ("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
                ("ALIGN", (2, 1), (2, -1), "RIGHT"),
                ("FONTSIZE", (0, 0), (-1, -1), max(6, int(round(9 * scale)))),
            ]
        )
    )
    story.append(table)
    instructions = (company.print_instructions_purchase_order or company.print_instructions or "").strip()
    if instructions:
        instruction_text = instructions.replace("\n", "<br/>")
        story.append(Spacer(1, 4 * mm))
        instruction_style = ParagraphStyle("PrintInstructions", parent=styles["Normal"], fontSize=8)
        instructions_table = Table(
            [[Paragraph(f"<b>**</b> {instruction_text}", instruction_style)]],
            colWidths=[doc.width],
        )
        instructions_table.setStyle(
            TableStyle(
                [
                    ("GRID", (0, 0), (-1, -1), 0.25, colors.lightgrey),
                    ("VALIGN", (0, 0), (-1, -1), "TOP"),
                    ("LEFTPADDING", (0, 0), (-1, -1), 6),
                    ("RIGHTPADDING", (0, 0), (-1, -1), 6),
                    ("TOPPADDING", (0, 0), (-1, -1), 4),
                    ("BOTTOMPADDING", (0, 0), (-1, -1), 4),
                ]
            )
        )
        story.append(instructions_table)

    user = getattr(g, "current_user", None)
    creator = user or po.created_by
    signature_bytes = _decode_user_signature_bytes(creator)
    signature_img = None
    if signature_bytes:
        try:
            signature_img = Image(io.BytesIO(signature_bytes), width=40 * mm, height=12 * mm)
        except Exception:
            signature_img = None
    created_name = _user_display_name(creator)
    signature_rows = [["", signature_img or ""]]
    signature_rows.append(["", "Authorised Signatory"])
    signature_rows.append(["", created_name])
    signature_rows.append(["", f"for {company.name}"])
    signature = Table(signature_rows, colWidths=[doc.width - w(60), w(60)])
    signature.setStyle(
        TableStyle(
            [
                ("ALIGN", (1, 0), (1, -1), "RIGHT"),
                ("FONTNAME", (1, 0), (1, -1), "Helvetica"),
                ("FONTSIZE", (0, 0), (-1, -1), max(6, int(round(9 * scale)))),
            ]
        )
    )
    story.append(Spacer(1, max(4 * mm, 8 * mm * scale)))
    story.append(signature)

    printed_by = _user_display_name(user)
    printed_at = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M")

    def add_footer(canvas, doc):
        canvas.saveState()
        footer_y = max(6 * mm, 12 * mm * scale)
        canvas.setFont("Helvetica", max(6, int(round(9 * scale))))
        canvas.drawString(doc.leftMargin, footer_y + 4, f"Printed by: {printed_by}")
        canvas.drawRightString(doc.pagesize[0] - doc.rightMargin, footer_y + 4, f"Printed at: {printed_at}")
        canvas.restoreState()

    doc.build(story, onFirstPage=add_footer, onLaterPages=add_footer)
    return buf.getvalue()


def _render_purchase_bill_pdf(
    company: Company,
    bill: PurchaseBill,
    title_override: str | None = None,
    instruction_override: str | None = None,
    meta_right: tuple[str, str] | None = None,
    meta_right2: tuple[str, str] | None = None,
    signature_mode: str | None = None,
    page_size: str | None = None,
) -> bytes:
    try:
        from reportlab.lib import colors
        from reportlab.lib.pagesizes import A4, A5, A6, letter
        from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
        from reportlab.lib.units import mm
        from reportlab.platypus import Image, Paragraph, SimpleDocTemplate, Spacer, Table, TableStyle
        from reportlab.graphics.barcode import qr
        from reportlab.graphics.shapes import Drawing
    except Exception as exc:
        raise RuntimeError("PDF printing requires reportlab. Install backend requirements.txt.") from exc

    size_map = {"A4": A4, "A5": A5, "A6": A6, "LETTER": letter}
    target_size = size_map.get(str(page_size or "A4").upper(), A4)
    page_width = float(target_size[0])
    scale = _pdf_scale_for_width(page_width)
    dynamic_margin = min(12 * mm, max(5 * mm, page_width * 0.05))
    mmv = lambda value_mm: value_mm * mm * scale

    buf = io.BytesIO()
    doc = SimpleDocTemplate(
        buf,
        pagesize=target_size,
        rightMargin=dynamic_margin,
        leftMargin=dynamic_margin,
        topMargin=max(8 * mm, 18 * mm * scale),
        bottomMargin=max(8 * mm, 18 * mm * scale),
    )
    styles = getSampleStyleSheet()
    story = []

    logo_bytes = _decode_company_logo_bytes(company)
    header_cells = []
    if logo_bytes:
        try:
            header_cells.append(Image(io.BytesIO(logo_bytes), width=28 * mm, height=28 * mm))
        except Exception:
            header_cells.append(Paragraph("", styles["Normal"]))
    else:
        header_cells.append(Paragraph("", styles["Normal"]))

    company_lines = (
        f"<font size='{max(10, int(round(13 * scale)))}'><b>{company.name}</b></font>"
        f"<br/>{company.address or ''}<br/>{company.city or ''}<br/>Phone: {company.phone or ''}"
    )
    header_text_style = ParagraphStyle("PBHeaderText", parent=styles["Normal"], fontSize=max(7, 10 * scale), leading=max(9, 12 * scale))
    title_style = ParagraphStyle("PBTitle", parent=styles["Normal"], fontSize=max(7, 10 * scale), leading=max(9, 12 * scale), alignment=2)
    header_cells.append(Paragraph(company_lines, header_text_style))
    title_cell = Paragraph(title_override or f"<b>Purchase Bill</b><br/>Bill: {bill.bill_number or f'#{bill.id}'}", title_style)
    right_cell = title_cell
    usable_header_width = page_width - (2 * dynamic_margin)
    col_widths = [usable_header_width * 0.20, usable_header_width * 0.55, usable_header_width * 0.25]
    try:
        qr_data = _qr_link(f"/purchase-bills/{bill.id}/print")
        qr_code = qr.QrCodeWidget(qr_data)
        bounds = qr_code.getBounds()
        size = mmv(22)
        qr_w = float(bounds[2] - bounds[0]) or 1.0
        qr_h = float(bounds[3] - bounds[1]) or 1.0
        d = Drawing(size, size, transform=[size / qr_w, 0, 0, size / qr_h, 0, 0])
        d.add(qr_code)
        d.hAlign = "RIGHT"
        right_cell = Table([[title_cell], [d]], colWidths=[col_widths[2]])
        right_cell.setStyle(
            TableStyle(
                [
                    ("ALIGN", (0, 0), (-1, -1), "RIGHT"),
                    ("BOTTOMPADDING", (0, 0), (-1, -1), 2),
                    ("TOPPADDING", (0, 0), (-1, -1), 2),
                ]
            )
        )
    except Exception:
        right_cell = title_cell
    header_cells.append(right_cell)

    header = Table([header_cells], colWidths=col_widths)
    header.setStyle(TableStyle([("VALIGN", (0, 0), (-1, -1), "TOP")]))
    story.append(header)
    story.append(Spacer(1, max(3 * mm, 6 * mm * scale)))

    supplier_name = bill.supplier.name if bill.supplier else (bill.supplier_name or "-")
    supplier_addr = ""
    if bill.supplier:
        parts = []
        if bill.supplier.address:
            parts.append(bill.supplier.address)
        if bill.supplier.city:
            parts.append(bill.supplier.city)
        supplier_addr = ", ".join(parts)
    supplier_ddan = getattr(bill.supplier, "dda_number", None) or ""
    supplier_pan = getattr(bill.supplier, "pan_vat_number", None) or ""
    def to_nepali_date(dt_val):
        try:
            import nepali_datetime  # type: ignore
        except Exception:
            return ""
        if not dt_val:
            return ""
        try:
            if isinstance(dt_val, str):
                dt_val = datetime.fromisoformat(dt_val[:10])
            nd = nepali_datetime.date.from_datetime_date(dt_val if isinstance(dt_val, date) else dt_val.date())
            return nd.strftime("%Y-%m-%d")
        except Exception:
            return ""

    purchase_date = bill.purchase_date
    purchase_bs = to_nepali_date(purchase_date)
    right_label, right_value = meta_right or (
        "Purchase Date",
        f"{_format_date_short(purchase_date)}" + (f" ({purchase_bs})" if purchase_bs else ""),
    )
    right2_label, right2_value = meta_right2 or ("", "")
    meta = Table(
        [
            [
                "Supplier",
                supplier_name,
                right_label,
                right_value,
            ],
            [
                "Address",
                supplier_addr or "-",
                right2_label,
                right2_value,
            ],
            [
                "DDA",
                supplier_ddan or "-",
                "",
                "",
            ],
            [
                "PAN",
                supplier_pan or "-",
                "",
                "",
            ],
        ],
        colWidths=[usable_header_width * 0.16, usable_header_width * 0.44, usable_header_width * 0.16, usable_header_width * 0.24],
    )
    meta.setStyle(
        TableStyle(
            [
                ("GRID", (0, 0), (-1, -1), 0.25, colors.lightgrey),
                ("BACKGROUND", (0, 0), (0, -1), colors.whitesmoke),
                ("BACKGROUND", (2, 0), (2, -1), colors.whitesmoke),
                ("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
                ("FONTSIZE", (0, 0), (-1, -1), max(6, int(round(9 * scale)))),
            ]
        )
    )
    story.append(meta)
    story.append(Spacer(1, max(3 * mm, 6 * mm * scale)))

    normal_style = styles["Normal"]
    def _fmt_qty(value):
        try:
            n = float(value or 0)
        except (TypeError, ValueError):
            return "0"
        if abs(n - round(n)) < 1e-9:
            return str(int(round(n)))
        return f"{n:.4f}".rstrip("0").rstrip(".")
    rows = [["HS Code", "Item", "UoM", "Ord", "Free", "Cost", "Discount", "Tax", "Line Total"]]
    spans = []
    align_rules = []
    ordered_total = 0.0
    free_total = 0.0
    for it in bill.items or []:
        ordered = float(it.ordered_qty or 0.0)
        free = float(it.free_qty or 0.0)
        ordered_total += ordered
        free_total += free
        base_row_idx = len(rows)
        product = it.product
        product_name = product.name if product else (it.product_name if hasattr(it, "product_name") else str(it.product_id))
        hscode = getattr(product, "hscode", None) or "-"
        rows.append(
            [
                Paragraph(hscode, normal_style),
                Paragraph(f"<b>{product_name}</b>", normal_style),
                Paragraph(it.uom or "-", normal_style),
                _fmt_qty(ordered),
                _fmt_qty(free),
                f"{float(it.cost_price or 0.0):.2f}",
                f"{float(getattr(it, 'discount', 0.0) or 0.0):.2f}",
                f"{float(getattr(it, 'tax_subtotal', 0.0) or 0.0):.2f}",
                f"{float(it.line_total or 0.0):.2f}",
            ]
        )
        rows.append(
            [
                Paragraph(
                    f"<i>Batch: {it.batch_number or '-'}    Expiry: {_format_expiry_month_year(it.expiry_date.isoformat() if getattr(it, 'expiry_date', None) else None)}    MRP {float(getattr(it, 'mrp', 0.0) or getattr(it, 'price', 0.0) or 0.0):.2f} ({it.uom or '-'})</i>",
                    normal_style,
                ),
                "",
                "",
                "",
                "",
                "",
                "",
                "",
                "",
            ]
        )
        spans.append(("SPAN", (0, base_row_idx + 1), (-1, base_row_idx + 1)))

    table = Table(
        rows,
        colWidths=[
            usable_header_width * 0.10,
            usable_header_width * 0.24,
            usable_header_width * 0.12,
            usable_header_width * 0.08,
            usable_header_width * 0.08,
            usable_header_width * 0.09,
            usable_header_width * 0.10,
            usable_header_width * 0.09,
            usable_header_width * 0.10,
        ],
        repeatRows=1,
    )
    table.setStyle(
        TableStyle(
            [
                ("GRID", (0, 0), (-1, -1), 0.25, colors.lightgrey),
                ("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#f3f4f6")),
                ("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
                ("ALIGN", (3, 1), (-1, -1), "RIGHT"),
                ("VALIGN", (0, 0), (-1, -1), "TOP"),
                ("FONTSIZE", (0, 0), (-1, -1), max(6, int(round(8.5 * scale)))),
                *spans,
                *align_rules,
            ]
        )
    )
    story.append(table)
    story.append(Spacer(1, max(3 * mm, 6 * mm * scale)))

    gross = float(bill.gross_total or 0.0)
    totals = Table(
        [
            ["Gross Total", f"{gross:.2f}"],
        ],
        colWidths=[usable_header_width * 0.5, usable_header_width * 0.5],
    )
    totals.setStyle(
        TableStyle(
            [
                ("GRID", (0, 0), (-1, -1), 0.25, colors.lightgrey),
                ("BACKGROUND", (0, 0), (-1, -1), colors.whitesmoke),
                ("FONTNAME", (0, 0), (-1, -1), "Helvetica-Bold"),
            ]
        )
    )
    story.append(totals)

    story.append(Spacer(1, max(3 * mm, 6 * mm * scale)))
    amount_style = ParagraphStyle(
        "AmountLeft",
        parent=styles["Normal"],
        alignment=0,
        leftIndent=mmv(2),
        fontSize=max(7, 10 * scale),
    )
    story.append(Paragraph(f"<b>Amount in words:</b> {_amount_in_words(gross)}", amount_style))
    instructions_override = (instruction_override or "").strip()
    if instructions_override:
        instructions = instructions_override
    else:
        instructions = (company.print_instructions_purchase_bill or company.print_instructions or "").strip()
    if instructions:
        instruction_text = instructions.replace("\n", "<br/>")
        story.append(Spacer(1, 3 * mm))
        instruction_style = ParagraphStyle("PrintInstructions", parent=styles["Normal"], fontSize=max(6, 8 * scale), leading=max(8, 10 * scale))
        instructions_table = Table(
            [[Paragraph(f"<b>**</b> {instruction_text}", instruction_style)]],
            colWidths=[doc.width],
        )
        instructions_table.setStyle(
            TableStyle(
                [
                    ("GRID", (0, 0), (-1, -1), 0.25, colors.lightgrey),
                    ("VALIGN", (0, 0), (-1, -1), "TOP"),
                    ("LEFTPADDING", (0, 0), (-1, -1), 6),
                    ("RIGHTPADDING", (0, 0), (-1, -1), 6),
                    ("TOPPADDING", (0, 0), (-1, -1), 4),
                    ("BOTTOMPADDING", (0, 0), (-1, -1), 4),
                ]
            )
        )
        story.append(instructions_table)

    user = getattr(g, "current_user", None)
    printed_by = _user_display_name(user)
    company_name = (getattr(company, "name", None) or "").strip() or "-"
    printed_at = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M")

    if str(signature_mode or "").strip().lower() == "write":
        sig_style = ParagraphStyle(
            "SigBlock",
            parent=styles["Normal"],
            alignment=2,
            fontSize=max(6, int(round(9 * scale))),
            leading=max(8, int(round(11 * scale))),
        )
        sig_date = datetime.now(timezone.utc).strftime("%Y-%m-%d")
        sig = Paragraph(
            "<br/>".join(
                [
                    "..................................................",
                    "Authorised signature",
                    f"For {company.name}",
                    f"Date: {sig_date}",
                ]
            ),
            sig_style,
        )
        story.append(Spacer(1, max(8 * mm, 14 * mm * scale)))
        story.append(sig)
    else:
        # Authorised signature block below amount (right-indented)
        signature = Table(
            [
                ["", "Authorised Signature"],
                ["", "______________________"],
                ["", printed_by],
                ["", company_name],
            ],
            colWidths=[doc.width - mmv(60), mmv(60)],
        )
        signature.setStyle(
            TableStyle(
                [
                    ("ALIGN", (1, 0), (1, -1), "RIGHT"),
                    ("FONTNAME", (1, 0), (1, -1), "Helvetica"),
                    ("FONTSIZE", (0, 0), (-1, -1), max(6, int(round(9 * scale)))),
                    ("LINEBELOW", (1, 1), (1, 1), 0.25, colors.black),
                ]
            )
        )
        story.append(Spacer(1, max(8 * mm, 14 * mm * scale)))
        story.append(signature)

    def add_footer(canvas, doc):
        canvas.saveState()
        footer_y = max(6 * mm, 12 * mm * scale)
        canvas.setFont("Helvetica", max(6, int(round(9 * scale))))
        canvas.drawString(doc.leftMargin, footer_y + 4, f"Printed by: {printed_by}")
        canvas.drawRightString(doc.pagesize[0] - doc.rightMargin, footer_y + 4, f"Printed at: {printed_at}")
        canvas.restoreState()

    doc.build(story, onFirstPage=add_footer, onLaterPages=add_footer)
    return buf.getvalue()


def _render_purchase_bill_cheque_pdf(
    company: Company,
    bill: PurchaseBill,
    amount: float,
    payment_date: date | None,
    payment_mode: PaymentMode | None,
    template: ChequeTemplate | None,
    ac_payee: bool,
    cheque_date: date | None,
) -> bytes:
    try:
        from reportlab.lib.units import mm
        from reportlab.pdfgen import canvas
    except Exception as exc:
        raise RuntimeError("PDF printing requires reportlab. Install backend requirements.txt.") from exc

    width = (template.width_mm if template else 210.0) * mm
    height = (template.height_mm if template else 95.0) * mm
    rotation = int(getattr(template, "print_rotation_deg", 0) or 0)
    rotation = rotation % 360
    if rotation not in {0, 90, 180, 270}:
        rotation = 0

    page_width = height if rotation in {90, 270} else width
    page_height = width if rotation in {90, 270} else height
    buf = io.BytesIO()
    c = canvas.Canvas(buf, pagesize=(page_width, page_height))

    # Rotate the whole cheque coordinate space (not glyphs-only metadata rotation).
    # This keeps all fields aligned together and rotates paper dimensions as expected.
    if rotation == 90:
        c.transform(0, -1, 1, 0, 0, width)
    elif rotation == 180:
        c.transform(-1, 0, 0, -1, width, height)
    elif rotation == 270:
        c.transform(0, 1, -1, 0, height, 0)

    supplier_name = bill.supplier.name if bill.supplier else (bill.supplier_name or "-")
    date_value = cheque_date or payment_date or date.today()
    date_label = date_value.strftime("%d/%m/%Y")
    amount_value = float(amount or 0.0)
    amount_words = f"**** {_amount_in_words(amount_value)} ****"

    if template:
        def _y(mm_val: float) -> float:
            return height - (mm_val * mm)

        def _baseline_from_top(top_mm: float, line_height_mm: float | None, font_size_pt: int) -> float:
            top = _y(top_mm)
            font_mm = font_size_pt * 0.352777
            line_h_mm = max(float(line_height_mm or 0), font_mm * 1.3)
            return top - (line_h_mm * mm) + (font_mm * mm * 0.8)

        def _centered_baseline_from_top(top_mm: float, box_height_mm: float | None, font_size_pt: int) -> float:
            top = _y(top_mm)
            font_mm = font_size_pt * 0.352777
            box_h_mm = max(float(box_height_mm or 0), 1.0)
            center_y = top - (box_h_mm * mm / 2.0)
            # Approximate baseline shift for Helvetica so glyphs look centered in each date box.
            return center_y - (font_mm * mm * 0.35)

        def _wrap_text_lines(text: str, max_width_pt: float, font_name: str, font_size_pt: int, max_lines: int) -> list[str]:
            words = text.split()
            lines: list[str] = []
            current = ""
            for word in words:
                test = word if not current else f"{current} {word}"
                if c.stringWidth(test, font_name, font_size_pt) <= max_width_pt or not current:
                    current = test
                else:
                    lines.append(current)
                    current = word
                    if len(lines) >= max_lines - 1:
                        break
            if current:
                lines.append(current)
            return lines[:max_lines]

        def _split_words_two_lines(text: str, max_width_first: float, max_width_second: float) -> tuple[str, str]:
            words = text.split()
            if not words:
                return "", ""

            first = ""
            idx = 0
            for i, word in enumerate(words):
                test = word if not first else f"{first} {word}"
                if c.stringWidth(test, "Helvetica", 9) <= max_width_first or not first:
                    first = test
                    idx = i + 1
                else:
                    break

            second = ""
            for word in words[idx:]:
                test = word if not second else f"{second} {word}"
                if c.stringWidth(test, "Helvetica", 9) <= max_width_second or not second:
                    second = test
                else:
                    break
            return first, second

        def _parse_date_layout_tokens(layout_value: str | None) -> list[str]:
            raw = (layout_value or "").upper().strip()
            cleaned = "".join((ch if ch in {"D", "M", "Y"} else "|") for ch in raw)
            parts = [p for p in cleaned.split("|") if p]
            tokens: list[str] = []
            for part in parts:
                if "D" in part and "DD" not in tokens:
                    tokens.append("DD")
                elif "M" in part and "MM" not in tokens:
                    tokens.append("MM")
                elif "Y" in part and "YYYY" not in tokens:
                    tokens.append("YYYY")
            return tokens if len(tokens) == 3 else ["MM", "YYYY", "DD"]

        date_layout_tokens = _parse_date_layout_tokens(getattr(template, "date_layout", "MM | YYYY | DD"))
        date_segments = {
            "DD": date_value.strftime("%d"),
            "MM": date_value.strftime("%m"),
            "YYYY": date_value.strftime("%Y"),
        }
        date_box_w = max(template.date_box_w_mm or 0, 1.0)
        date_box_h = max(template.date_box_h_mm or 0, 1.0)
        date_group_gap = max(template.date_char_spacing_mm or 0, 0.0)
        date_inner_gap = max(getattr(template, "date_inner_spacing_mm", 0.8) or 0.0, 0.0)
        date_font_size = int(round((date_box_h * 0.72) / 0.352777))
        date_font_size = max(6, min(date_font_size, 14))
        c.setFont("Helvetica", date_font_size)
        date_base_y = _centered_baseline_from_top(template.date_y_mm, date_box_h, date_font_size)
        if date_box_w > 0:
            cursor_x_mm = float(template.date_x_mm or 0.0)
            for group_idx, token in enumerate(date_layout_tokens):
                value = date_segments.get(token, "")
                for box_idx, char in enumerate(value):
                    c.drawCentredString(cursor_x_mm * mm + (date_box_w * mm / 2), date_base_y, char)
                    cursor_x_mm += date_box_w
                    if box_idx < len(value) - 1:
                        cursor_x_mm += date_inner_gap
                if group_idx < len(date_layout_tokens) - 1:
                    cursor_x_mm += date_group_gap
        else:
            c.drawString(template.date_x_mm * mm, date_base_y, date_label)

        c.setFont("Helvetica", 10)
        payee_base_y = _baseline_from_top(template.payee_y_mm, template.payee_line_height_mm, 10)
        c.drawString(template.payee_x_mm * mm, payee_base_y, supplier_name)

        c.setFont("Helvetica", 9)
        words_line1_width = max(float(template.amount_words_line_width_mm or 0), 1.0) * mm
        words_line2_width = max(float(getattr(template, "amount_words_2_line_width_mm", 0) or 0), 1.0) * mm
        words_line1_h_mm = max(float(template.amount_words_line_height_mm or 0), 1.0)
        words_line2_h_mm = max(float(getattr(template, "amount_words_2_line_height_mm", 0) or 0), 1.0)
        line1_text, line2_text = _split_words_two_lines(amount_words, words_line1_width, words_line2_width)

        if line1_text:
            line1_base_y = _baseline_from_top(template.amount_words_y_mm, words_line1_h_mm, 9)
            c.drawString(template.amount_words_x_mm * mm, line1_base_y, line1_text)
        if line2_text:
            line2_base_y = _baseline_from_top(float(getattr(template, "amount_words_2_y_mm", 0) or 0), words_line2_h_mm, 9)
            c.drawString(float(getattr(template, "amount_words_2_x_mm", 0) or 0) * mm, line2_base_y, line2_text)

        box_x = template.amount_number_x_mm * mm
        box_top = _y(template.amount_number_y_mm)
        box_w = template.amount_number_box_w_mm * mm
        box_h = template.amount_number_box_h_mm * mm
        c.setFont("Helvetica-Bold", 10)
        c.drawRightString(box_x + box_w - 2 * mm, box_top - box_h / 2, f"{amount_value:.2f}")

        if ac_payee:
            ac_x = template.ac_payee_x_mm * mm
            ac_top = _y(template.ac_payee_y_mm)
            ac_w = template.ac_payee_box_w_mm * mm
            ac_h = template.ac_payee_box_h_mm * mm
            c.line(ac_x, ac_top, ac_x + ac_w, ac_top)
            c.line(ac_x, ac_top - ac_h, ac_x + ac_w, ac_top - ac_h)
            c.setFont("Helvetica", 7)
            c.drawString(ac_x + 1.5 * mm, ac_top - ac_h + 2.0 * mm, "A/C PAYEE")

    else:
        c.setFont("Helvetica", 10)
        c.drawRightString(width - 8 * mm, height - 8 * mm, date_label)
        c.setFont("Helvetica", 11)
        c.drawString(8 * mm, height - 22 * mm, f"Pay: {supplier_name}")
        c.setFont("Helvetica", 9)
        c.drawString(8 * mm, height - 34 * mm, amount_words)
        box_w = 48 * mm
        box_h = 12 * mm
        box_x = width - box_w - 8 * mm
        box_y = height - 32 * mm
        c.setFont("Helvetica-Bold", 11)
        c.drawRightString(width - 10 * mm, box_y + 4 * mm, f"{amount_value:.2f}")

    c.showPage()
    c.save()
    return buf.getvalue()


def _render_sale_pdf(company: Company, sale: Sale, page_size: str | None = None) -> bytes:
    try:
        from reportlab.lib import colors
        from reportlab.lib.pagesizes import A4, A5, A6, letter
        from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
        from reportlab.lib.units import mm
        from reportlab.platypus import Image, Paragraph, SimpleDocTemplate, Spacer, Table, TableStyle
        from reportlab.graphics.barcode import qr
        from reportlab.graphics.shapes import Drawing
    except Exception as exc:
        raise RuntimeError("PDF printing requires reportlab. Install backend requirements.txt.") from exc

    try:
        import nepali_datetime  # type: ignore
    except Exception:
        nepali_datetime = None

    size_map = {"A4": A4, "A5": A5, "A6": A6, "LETTER": letter}
    target_size = size_map.get(str(page_size or "A4").upper(), A4)
    page_width = float(target_size[0])
    scale = _pdf_scale_for_width(page_width)
    dynamic_margin = min(12 * mm, max(5 * mm, page_width * 0.05))
    mmv = lambda value_mm: value_mm * mm * scale

    buf = io.BytesIO()
    doc = SimpleDocTemplate(
        buf,
        pagesize=target_size,
        rightMargin=dynamic_margin,
        leftMargin=dynamic_margin,
        topMargin=max(8 * mm, 14 * mm * scale),
        bottomMargin=max(8 * mm, 14 * mm * scale),
    )
    styles = getSampleStyleSheet()
    story = []

    logo_bytes = _decode_company_logo_bytes(company)
    header_cells = []
    if logo_bytes:
        try:
            header_cells.append(Image(io.BytesIO(logo_bytes), width=28 * mm, height=28 * mm))
        except Exception:
            header_cells.append(Paragraph("", styles["Normal"]))
    else:
        header_cells.append(Paragraph("", styles["Normal"]))

    company_lines = (
        f"<font size='{max(10, int(round(15 * scale)))}'><b>{company.name}</b></font>"
        f"<br/>{company.address or ''}<br/>{company.city or ''}<br/>Phone: {company.phone or ''}"
    )
    header_style = ParagraphStyle("SaleHeader", parent=styles["Normal"], fontSize=max(7, 10 * scale), leading=max(9, 12 * scale))
    header_cells.append(Paragraph(company_lines, header_style))
    src_lower = (sale.source or "").strip().lower()
    is_planned_delivery = src_lower == "sales_order"
    if src_lower == "sales_order":
        doc_title = "Sales Order"
    elif src_lower in {"daily_sales", "backdated_daily_sales"}:
        doc_title = "POS Receipt"
    else:
        doc_title = "Sales Receipt"
    display_number = sale.sale_number or sale.id
    title_style = ParagraphStyle("SaleTitle", parent=styles["Normal"], fontSize=max(7, 10 * scale), leading=max(9, 12 * scale), alignment=2)
    title_cell = Paragraph(f"<b>{doc_title}</b><br/>Sale: {display_number}", title_style)
    right_cell = title_cell
    usable_header_width = page_width - (2 * dynamic_margin)
    col_widths = [usable_header_width * 0.22, usable_header_width * 0.53, usable_header_width * 0.25]

    # QR code (below sale number)
    try:
        qr_data = _qr_link(f"/sales/{sale.id}/print")
        qr_code = qr.QrCodeWidget(qr_data)
        bounds = qr_code.getBounds()
        size = mmv(24)
        qr_w = float(bounds[2] - bounds[0]) or 1.0
        qr_h = float(bounds[3] - bounds[1]) or 1.0
        d = Drawing(size, size, transform=[size / qr_w, 0, 0, size / qr_h, 0, 0])
        d.add(qr_code)
        d.hAlign = "RIGHT"
        right_cell = Table([[title_cell], [d]], colWidths=[col_widths[2]])
        right_cell.setStyle(
            TableStyle(
                [
                    ("ALIGN", (0, 0), (-1, -1), "RIGHT"),
                    ("BOTTOMPADDING", (0, 0), (-1, -1), 2),
                    ("TOPPADDING", (0, 0), (-1, -1), 2),
                ]
            )
        )
    except Exception:
        right_cell = title_cell
    header_cells.append(right_cell)

    header = Table([header_cells], colWidths=col_widths)
    header.setStyle(TableStyle([("VALIGN", (0, 0), (-1, -1), "TOP")]))
    story.append(header)
    story.append(Spacer(1, max(3 * mm, 6 * mm * scale)))

    customer_name = sale.customer.name if sale.customer else "Walk-in"
    created_by = _user_display_name(getattr(sale, "created_by", None))
    # helpers for Nepali date display
    def to_nepali_date(dt_val):
        if not dt_val:
            return ""
        if nepali_datetime is None:
            return ""
        try:
            if isinstance(dt_val, str):
                dt_val = datetime.fromisoformat(dt_val[:10])
            nd = nepali_datetime.date.from_datetime_date(dt_val if isinstance(dt_val, date) else dt_val.date())
            return nd.strftime("%Y-%m-%d")
        except Exception:
            return ""
    src_label = (sale.source or "").strip().lower()
    approval_state = (sale.approval_status or "").strip().lower()
    approval_label = "Approved"
    if approval_state == "pending":
        approval_label = "Pending"
    elif approval_state == "denied":
        approval_label = "Denied"
    if src_label == "sales_order":
        status_line = "Sales Order - Undelivered"
    elif src_label == "sales_order_delivered":
        status_line = "Sales Order - Delivered"
    else:
        if approval_state == "pending":
            status_line = "Pending Approval"
        elif approval_state == "denied":
            status_line = "Denied"
        else:
            status_line = "Posted"
    latest_return = None
    for r in sale.returns or []:
        if not r.return_date:
            continue
        if not latest_return or r.return_date > latest_return:
            latest_return = r.return_date
    meta_rows = [
        ["Customer", customer_name, "", ""],
        [
            "Created",
            f"{_format_date_short(sale.created_at)}" + (f" ({to_nepali_date(sale.created_at)})" if sale.created_at and to_nepali_date(sale.created_at) else ""),
            "By",
            created_by,
        ],
        ["Status", status_line, "Approval", approval_label],
    ]
    if latest_return:
        meta_rows.append(
            [
                "Sales Return",
                f"{latest_return.isoformat()}" + (f" ({to_nepali_date(latest_return)})" if to_nepali_date(latest_return) else ""),
                "",
                "",
            ]
        )
    meta = Table(meta_rows, colWidths=[usable_header_width * 0.16, usable_header_width * 0.45, usable_header_width * 0.12, usable_header_width * 0.27])
    meta.setStyle(
        TableStyle(
            [
                ("GRID", (0, 0), (-1, -1), 0.25, colors.lightgrey),
                ("BACKGROUND", (0, 0), (0, -1), colors.whitesmoke),
                ("BACKGROUND", (2, 0), (2, -1), colors.whitesmoke),
                ("FONTSIZE", (0, 0), (-1, -1), max(6, int(round(9 * scale)))),
            ]
        )
    )
    story.append(meta)
    story.append(Spacer(1, max(3 * mm, 6 * mm * scale)))

    def _fmt_qty(value):
        try:
            n = float(value or 0)
        except (TypeError, ValueError):
            return "0"
        if abs(n - round(n)) < 1e-9:
            return str(int(round(n)))
        return f"{n:.4f}".rstrip("0").rstrip(".")

    has_bonus_qty = any(float(getattr(it, "bonus_quantity_uom", 0) or 0) > 0 for it in (sale.items or []))
    if is_planned_delivery:
        rows = [["HS Code", "Item", "Ordered", "Bonus", "Qty", "UoM"]] if has_bonus_qty else [["HS Code", "Item", "Ordered", "Qty", "UoM"]]
    else:
        rows = [["HS Code", "Item", "Batch", "UoM", "Qty", "MRP", "Disc %", "Tax %", "Total"]]
    subtotal = float(getattr(sale, "subtotal_amount", sale.total_amount) or 0.0)
    discount = float(getattr(sale, "discount_amount", 0.0) or 0.0)
    cell_style = ParagraphStyle(
        "SaleCell",
        parent=styles["Normal"],
        fontSize=max(6, int(round(8.0 * scale))),
        leading=max(7, int(round(9.0 * scale))),
        wordWrap="CJK",
    )

    for it in sale.items or []:
        qty = int(getattr(it, "quantity_uom", None) or it.quantity or 0)
        ordered_qty = float(getattr(it, "ordered_quantity_uom", None) or qty or 0)
        bonus_qty = float(getattr(it, "bonus_quantity_uom", 0) or 0)
        uom = getattr(it, "uom", None) or (it.product.uom_category if it.product else None) or ""
        mrp_uom = (
            getattr(getattr(it, "inventory_batch", None), "uom", None)
            or uom
            or ""
        )
        mrp = float(getattr(it, "unit_price_uom", None) or 0.0) or float(it.unit_price or 0.0)
        discount_pct = float(getattr(it, "line_discount_percent", 0.0) or 0.0)
        tax_pct = float(getattr(it, "line_tax_percent", 0.0) or 0.0)
        gross = float(qty) * float(mrp)
        line_after_discount = max(0.0, gross - (gross * (max(0.0, discount_pct) / 100.0)))
        line_total = line_after_discount + (line_after_discount * (max(0.0, tax_pct) / 100.0))
        product = it.product
        if is_planned_delivery:
            planned_row = [
                getattr(product, "hscode", None) or "-",
                product.name if product else str(it.product_id),
                _fmt_qty(ordered_qty),
            ]
            if has_bonus_qty:
                planned_row.append(_fmt_qty(bonus_qty))
            planned_row.extend([_fmt_qty(qty), uom or "-"])
            rows.append(planned_row)
        else:
            mrp_text = f"{mrp:.2f}" + (f" ({mrp_uom})" if mrp_uom else "")
            rows.append(
                [
                    getattr(product, "hscode", None) or "-",
                    Paragraph((product.name if product else str(it.product_id)), cell_style),
                    getattr(it, "batch_number", None) or "-",
                    uom or "-",
                    str(qty),
                    Paragraph(mrp_text, cell_style),
                    f"{discount_pct:.2f}",
                    f"{tax_pct:.2f}",
                    f"{line_total:.2f}",
                ]
            )

    table = Table(
        rows,
        colWidths=(
            (
                [
                    usable_header_width * 0.10,
                    usable_header_width * 0.38,
                    usable_header_width * 0.12,
                    usable_header_width * 0.12,
                    usable_header_width * 0.12,
                    usable_header_width * 0.16,
                ]
                if has_bonus_qty
                else [
                    usable_header_width * 0.12,
                    usable_header_width * 0.44,
                    usable_header_width * 0.14,
                    usable_header_width * 0.14,
                    usable_header_width * 0.16,
                ]
            )
            if is_planned_delivery
            else [
                usable_header_width * 0.08,
                usable_header_width * 0.22,
                usable_header_width * 0.13,
                usable_header_width * 0.09,
                usable_header_width * 0.07,
                usable_header_width * 0.14,
                usable_header_width * 0.08,
                usable_header_width * 0.08,
                usable_header_width * 0.11,
            ]
        ),
        repeatRows=1,
    )
    table.setStyle(
        TableStyle(
            [
                ("GRID", (0, 0), (-1, -1), 0.25, colors.lightgrey),
                ("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#f3f4f6")),
                ("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
                ("ALIGN", ((2 if is_planned_delivery else 4), 1), ((-2 if is_planned_delivery else -1), -1), "RIGHT"),
                ("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
                ("FONTSIZE", (0, 0), (-1, -1), max(6, int(round(8.5 * scale)))),
                ("LEFTPADDING", (0, 0), (-1, -1), 4),
                ("RIGHTPADDING", (0, 0), (-1, -1), 4),
                ("TOPPADDING", (0, 0), (-1, -1), 3),
                ("BOTTOMPADDING", (0, 0), (-1, -1), 3),
            ]
        )
    )
    story.append(table)
    if not is_planned_delivery:
        story.append(Spacer(1, max(3 * mm, 6 * mm * scale)))

        total = float(sale.total_amount or 0.0)
        totals = Table(
            [["Subtotal", f"{subtotal:.2f}", "Discount", f"{min(discount, subtotal):.2f}", "Total", f"{total:.2f}"]],
            colWidths=[
                usable_header_width * 0.18,
                usable_header_width * 0.16,
                usable_header_width * 0.16,
                usable_header_width * 0.16,
                usable_header_width * 0.14,
                usable_header_width * 0.20,
            ],
        )
        totals.setStyle(
            TableStyle(
                [
                    ("GRID", (0, 0), (-1, -1), 0.25, colors.lightgrey),
                    ("BACKGROUND", (0, 0), (-1, -1), colors.whitesmoke),
                    ("FONTNAME", (0, 0), (-1, -1), "Helvetica-Bold"),
                ]
            )
        )
        story.append(totals)
        story.append(Spacer(1, max(2 * mm, 4 * mm * scale)))
        amount_style = ParagraphStyle("AmountWords", parent=styles["Normal"], fontSize=max(7, 10 * scale))
        story.append(Paragraph(f"<b>Amount in words:</b> {_amount_in_words(total)}", amount_style))
    instructions = (company.print_instructions_sales or company.print_instructions or "").strip()
    if instructions:
        instruction_text = instructions.replace("\n", "<br/>")
        story.append(Spacer(1, 3 * mm))
        instruction_style = ParagraphStyle("PrintInstructions", parent=styles["Normal"], fontSize=max(6, 8 * scale), leading=max(8, 10 * scale))
        instructions_table = Table(
            [[Paragraph(f"<b>**</b> {instruction_text}", instruction_style)]],
            colWidths=[doc.width],
        )
        instructions_table.setStyle(
            TableStyle(
                [
                    ("GRID", (0, 0), (-1, -1), 0.25, colors.lightgrey),
                    ("VALIGN", (0, 0), (-1, -1), "TOP"),
                    ("LEFTPADDING", (0, 0), (-1, -1), 6),
                    ("RIGHTPADDING", (0, 0), (-1, -1), 6),
                    ("TOPPADDING", (0, 0), (-1, -1), 4),
                    ("BOTTOMPADDING", (0, 0), (-1, -1), 4),
                ]
            )
        )
        story.append(instructions_table)

    user = getattr(g, "current_user", None)
    printed_by = _user_display_name(user)
    printed_at = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M")
    signature_bytes = _decode_user_signature_bytes(user)
    signature_img = None
    if signature_bytes:
        try:
            signature_img = Image(io.BytesIO(signature_bytes), width=40 * mm, height=12 * mm)
        except Exception:
            signature_img = None

    signature_rows = [["", signature_img or ""]]
    signature_rows.append(["", "Authorised Signatory"])
    signature_rows.append(["", f"for {company.name}"])
    signature = Table(signature_rows, colWidths=[doc.width - mmv(60), mmv(60)])
    signature.setStyle(
        TableStyle(
            [
                ("ALIGN", (1, 0), (1, -1), "RIGHT"),
                ("FONTNAME", (1, 0), (1, -1), "Helvetica"),
                ("FONTSIZE", (0, 0), (-1, -1), max(6, int(round(9 * scale)))),
            ]
        )
    )
    story.append(Spacer(1, max(4 * mm, 8 * mm * scale)))
    story.append(signature)

    def add_footer(canvas, doc):
        canvas.saveState()
        footer_y = max(6 * mm, 12 * mm * scale)
        canvas.setFont("Helvetica", max(6, int(round(9 * scale))))
        canvas.drawString(doc.leftMargin, footer_y + 4, f"Printed by: {printed_by}")
        canvas.drawRightString(doc.pagesize[0] - doc.rightMargin, footer_y + 4, f"Printed at: {printed_at}")
        canvas.restoreState()

    doc.build(story, onFirstPage=add_footer, onLaterPages=add_footer)
    return buf.getvalue()


def _render_sale_return_pdf(company: Company, sale_return: SaleReturn, page_size: str | None = None) -> bytes:
    try:
        from reportlab.lib import colors
        from reportlab.lib.pagesizes import A4, A5, A6, letter
        from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
        from reportlab.lib.units import mm
        from reportlab.platypus import Image, Paragraph, SimpleDocTemplate, Spacer, Table, TableStyle
        from reportlab.graphics.barcode import qr
        from reportlab.graphics.shapes import Drawing
    except Exception as exc:
        raise RuntimeError("PDF printing requires reportlab. Install backend requirements.txt.") from exc

    try:
        import nepali_datetime  # type: ignore
    except Exception:
        nepali_datetime = None

    sale = sale_return.sale
    size_map = {
        "A4": A4,
        "A5": A5,
        "A6": A6,
        "LETTER": letter,
    }
    target_size = size_map.get(str(page_size or "A4").upper(), A4)
    page_width = float(target_size[0])
    scale = _pdf_scale_for_width(page_width)
    dynamic_margin = min(10 * mm, max(5 * mm, page_width * 0.05))

    buf = io.BytesIO()
    doc = SimpleDocTemplate(
        buf,
        pagesize=target_size,
        rightMargin=dynamic_margin,
        leftMargin=dynamic_margin,
        topMargin=dynamic_margin,
        bottomMargin=dynamic_margin,
    )
    styles = getSampleStyleSheet()
    story = []

    logo_bytes = _decode_company_logo_bytes(company)
    header_cells = []
    if logo_bytes:
        try:
            header_cells.append(Image(io.BytesIO(logo_bytes), width=28 * mm, height=28 * mm))
        except Exception:
            header_cells.append(Paragraph("", styles["Normal"]))
    else:
        header_cells.append(Paragraph("", styles["Normal"]))

    company_style = ParagraphStyle(
        "CompanyHeader",
        parent=styles["Normal"],
        fontSize=max(7, 11 * scale),
        leading=max(9, 13 * scale),
    )
    company_lines = (
        f"<b><font size='{max(10, int(round(14 * scale)))}'>{company.name}</font></b><br/>"
        f"{company.address or ''}<br/>{company.city or ''}<br/>Phone: {company.phone or ''}"
    )
    header_cells.append(Paragraph(company_lines, company_style))
    display_number = sale_return.return_number or sale_return.id
    title_cell = Paragraph(f"<b>Sales Return</b><br/>Return: {display_number}", styles["Normal"])
    right_cell = title_cell
    usable_header_width = page_width - (2 * dynamic_margin)
    col_widths = [usable_header_width * 0.22, usable_header_width * 0.53, usable_header_width * 0.25]

    try:
        qr_target = f"/sales/returns/{sale_return.id}/print"
        if sale:
            qr_target = f"/sales/{sale.id}/print"
        qr_data = _qr_link(qr_target)
        qr_code = qr.QrCodeWidget(qr_data)
        bounds = qr_code.getBounds()
        size = max(14 * mm, 26 * mm * scale)
        w = float(bounds[2] - bounds[0]) or 1.0
        h = float(bounds[3] - bounds[1]) or 1.0
        d = Drawing(size, size, transform=[size / w, 0, 0, size / h, 0, 0])
        d.add(qr_code)
        d.hAlign = "RIGHT"
        right_cell = Table([[title_cell], [d]], colWidths=[col_widths[2]])
        right_cell.setStyle(
            TableStyle(
                [
                    ("ALIGN", (0, 0), (-1, -1), "RIGHT"),
                    ("BOTTOMPADDING", (0, 0), (-1, -1), 2),
                    ("TOPPADDING", (0, 0), (-1, -1), 2),
                ]
            )
        )
    except Exception:
        right_cell = title_cell
    header_cells.append(right_cell)
    header = Table([header_cells], colWidths=col_widths)
    header.setStyle(TableStyle([("VALIGN", (0, 0), (-1, -1), "TOP")]))
    story.append(header)
    story.append(Spacer(1, 6 * mm))

    def to_nepali_date(dt_val):
        if not dt_val or nepali_datetime is None:
            return ""
        try:
            if isinstance(dt_val, str):
                dt_val = datetime.fromisoformat(dt_val[:10])
            nd = nepali_datetime.date.from_datetime_date(dt_val if isinstance(dt_val, date) else dt_val.date())
            return nd.strftime("%Y-%m-%d")
        except Exception:
            return ""

    customer = sale.customer if sale and sale.customer else sale_return.customer
    customer_name = customer.name if customer else "Walk-in"
    created_by = _user_display_name(getattr(sale_return, "created_by", None))
    payment_label = sale.payment_method if sale else "Cash"
    sale_date = sale.sale_date if sale else None
    return_date = sale_return.return_date
    meta_rows = [
        ["Customer", customer_name, "Payment", payment_label or "Cash"],
        [
            "Return Date",
            f"{return_date.isoformat() if return_date else ''}"
            + (f" ({to_nepali_date(return_date)})" if return_date and to_nepali_date(return_date) else ""),
            "By",
            created_by,
        ],
        [
            "Sale",
            f"{sale.sale_number or sale.id}" if sale else "-",
            "Sale Date",
            f"{sale_date.isoformat() if sale_date else ''}"
            + (f" ({to_nepali_date(sale_date)})" if sale_date and to_nepali_date(sale_date) else ""),
        ],
    ]
    meta = Table(meta_rows, colWidths=[usable_header_width * 0.16, usable_header_width * 0.45, usable_header_width * 0.12, usable_header_width * 0.27])
    meta.setStyle(
        TableStyle(
            [
                ("GRID", (0, 0), (-1, -1), 0.25, colors.lightgrey),
                ("BACKGROUND", (0, 0), (0, -1), colors.whitesmoke),
                ("BACKGROUND", (2, 0), (2, -1), colors.whitesmoke),
                ("FONTSIZE", (0, 0), (-1, -1), max(6, int(round(9 * scale)))),
            ]
        )
    )
    story.append(meta)
    story.append(Spacer(1, 6 * mm))

    rows = [["HS Code", "Item", "Batch", "UoM", "Qty", "MRP", "Total"]]
    for it in sale_return.items or []:
        qty = int(getattr(it, "quantity_uom", None) or it.qty_base or 0)
        uom = getattr(it, "uom", None) or (it.product.uom_category if it.product else None) or ""
        mrp = float(getattr(it, "unit_price", None) or 0.0)
        rows.append(
            [
                getattr(it.product, "hscode", None) or "-",
                it.product.name if it.product else str(it.product_id),
                getattr(it, "batch_number", None) or "-",
                uom or "-",
                str(qty),
                f"{mrp:.2f}",
                f"{(qty * mrp):.2f}",
            ]
        )

    table = Table(
        rows,
        colWidths=[
            usable_header_width * 0.10,
            usable_header_width * 0.29,
            usable_header_width * 0.16,
            usable_header_width * 0.10,
            usable_header_width * 0.08,
            usable_header_width * 0.12,
            usable_header_width * 0.15,
        ],
        repeatRows=1,
    )
    table.setStyle(
        TableStyle(
            [
                ("GRID", (0, 0), (-1, -1), 0.25, colors.lightgrey),
                ("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#f3f4f6")),
                ("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
                ("ALIGN", (4, 1), (-1, -1), "RIGHT"),
                ("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
                ("FONTSIZE", (0, 0), (-1, -1), max(6, int(round(8.5 * scale)))),
            ]
        )
    )
    story.append(table)
    story.append(Spacer(1, 6 * mm))

    total = float(sale_return.total_amount or 0.0)
    totals = Table([["Total Refund", f"{total:.2f}"]], colWidths=[usable_header_width * 0.6, usable_header_width * 0.4])
    totals.setStyle(
        TableStyle(
            [
                ("GRID", (0, 0), (-1, -1), 0.25, colors.lightgrey),
                ("BACKGROUND", (0, 0), (-1, -1), colors.whitesmoke),
                ("FONTNAME", (0, 0), (-1, -1), "Helvetica-Bold"),
            ]
        )
    )
    story.append(totals)
    story.append(Spacer(1, 4 * mm))
    amount_style = ParagraphStyle("AmountWords", parent=styles["Normal"], fontSize=max(7, 10 * scale))
    story.append(Paragraph(f"<b>Amount in words:</b> {_amount_in_words(total)}", amount_style))
    instructions = (company.print_instructions_sales_return or company.print_instructions or "").strip()
    if instructions:
        instruction_text = instructions.replace("\n", "<br/>")
        story.append(Spacer(1, 3 * mm))
        instruction_style = ParagraphStyle("PrintInstructions", parent=styles["Normal"], fontSize=max(6, 8 * scale), leading=max(8, 10 * scale))
        instructions_table = Table(
            [[Paragraph(f"<b>**</b> {instruction_text}", instruction_style)]],
            colWidths=[doc.width],
        )
        instructions_table.setStyle(
            TableStyle(
                [
                    ("GRID", (0, 0), (-1, -1), 0.25, colors.lightgrey),
                    ("VALIGN", (0, 0), (-1, -1), "TOP"),
                    ("LEFTPADDING", (0, 0), (-1, -1), 6),
                    ("RIGHTPADDING", (0, 0), (-1, -1), 6),
                    ("TOPPADDING", (0, 0), (-1, -1), 4),
                    ("BOTTOMPADDING", (0, 0), (-1, -1), 4),
                ]
            )
        )
        story.append(instructions_table)
    user = getattr(g, "current_user", None)
    printed_by = _user_display_name(user)
    printed_at = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M")
    signature_bytes = _decode_user_signature_bytes(user)
    signature_img = None
    if signature_bytes:
        try:
            signature_img = Image(io.BytesIO(signature_bytes), width=40 * mm, height=12 * mm)
        except Exception:
            signature_img = None

    signature_rows = [["", signature_img or ""]]
    signature_rows.append(["", "Authorised Signatory"])
    signature_rows.append(["", f"for {company.name}"])
    signature = Table(signature_rows, colWidths=[doc.width - max(35 * mm, 60 * mm * scale), max(35 * mm, 60 * mm * scale)])
    signature.setStyle(
        TableStyle(
            [
                ("ALIGN", (1, 0), (1, -1), "RIGHT"),
                ("FONTNAME", (1, 0), (1, -1), "Helvetica"),
                ("FONTSIZE", (0, 0), (-1, -1), max(6, int(round(9 * scale)))),
            ]
        )
    )
    story.append(Spacer(1, 8 * mm))
    story.append(signature)

    def add_footer(canvas, doc):
        canvas.saveState()
        footer_y = max(6 * mm, 12 * mm * scale)
        canvas.setFont("Helvetica", max(6, int(round(9 * scale))))
        canvas.drawString(doc.leftMargin, footer_y + 4, f"Printed by: {printed_by}")
        canvas.drawRightString(doc.pagesize[0] - doc.rightMargin, footer_y + 4, f"Printed at: {printed_at}")
        canvas.restoreState()

    doc.build(story, onFirstPage=add_footer, onLaterPages=add_footer)
    return buf.getvalue()


def _render_expiry_returns_pdf(
    company: Company,
    title: str,
    supplier_sections: list[dict],
    printed_by: str | None = None,
    page_size=None,
) -> bytes:
    try:
        from reportlab.lib import colors
        from reportlab.lib.pagesizes import A4, A5, A6, letter
        from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
        from reportlab.lib.units import mm
        from reportlab.platypus import Image, Paragraph, SimpleDocTemplate, Spacer, Table, TableStyle
        from reportlab.graphics.barcode import qr
        from reportlab.graphics.shapes import Drawing
    except Exception as exc:
        raise RuntimeError("PDF printing requires reportlab. Install backend requirements.txt.") from exc

    size_map = {
        "A4": A4,
        "A5": A5,
        "A6": A6,
        "LETTER": letter,
    }
    target_size = size_map.get(str(page_size or "A4").upper(), A4)
    page_width = float(target_size[0])
    scale = _pdf_scale_for_width(page_width)

    # Tighter margins on smaller paper sizes to avoid clipping content.
    dynamic_margin = min(10 * mm, max(5 * mm, page_width * 0.05))

    buf = io.BytesIO()
    doc = SimpleDocTemplate(
        buf,
        pagesize=target_size,
        rightMargin=dynamic_margin,
        leftMargin=dynamic_margin,
        topMargin=dynamic_margin,
        bottomMargin=dynamic_margin,
    )
    styles = getSampleStyleSheet()
    story = []

    logo_bytes = _decode_company_logo_bytes(company)
    header_cells = []
    if logo_bytes:
        try:
            logo_size = max(12 * mm, 18 * mm * scale)
            header_cells.append(Image(io.BytesIO(logo_bytes), width=logo_size, height=logo_size))
        except Exception:
            header_cells.append(Paragraph("", styles["Normal"]))
    else:
        header_cells.append(Paragraph("", styles["Normal"]))

    company_lines = (
        f"<font size='{max(11, int(round(14 * scale)))}'><b>{company.name}</b></font>"
        f"<br/>{company.address or ''}<br/>{company.city or ''}<br/>Phone: {company.phone or ''}"
    )
    header_text_style = ParagraphStyle("ERHeaderText", parent=styles["Normal"], fontSize=max(7, 10 * scale), leading=max(9, 12 * scale))
    title_style = ParagraphStyle("ERTitle", parent=styles["Normal"], fontSize=max(7, 10 * scale), leading=max(9, 12 * scale), alignment=2)
    header_cells.append(Paragraph(company_lines, header_text_style))
    title_cell = Paragraph(f"<b>Expiry Returns</b><br/>{title}", title_style)
    right_cell = title_cell
    try:
        qr_data = _qr_link("/expiry-returns")
        qr_code = qr.QrCodeWidget(qr_data)
        bounds = qr_code.getBounds()
        size = max(12 * mm, 20 * mm * scale)
        w = float(bounds[2] - bounds[0]) or 1.0
        h = float(bounds[3] - bounds[1]) or 1.0
        d = Drawing(size, size, transform=[size / w, 0, 0, size / h, 0, 0])
        d.add(qr_code)
        d.hAlign = "RIGHT"
        right_cell = Table([[title_cell], [d]], colWidths=[max(24 * mm, 40 * mm * scale)])
        right_cell.setStyle(
            TableStyle(
                [
                    ("ALIGN", (0, 0), (-1, -1), "RIGHT"),
                    ("BOTTOMPADDING", (0, 0), (-1, -1), 2),
                    ("TOPPADDING", (0, 0), (-1, -1), 2),
                ]
            )
        )
    except Exception:
        right_cell = title_cell
    header_cells.append(right_cell)

    usable_header_width = page_width - 2 * dynamic_margin
    col1 = usable_header_width * 0.2
    col2 = usable_header_width * 0.52
    col3 = max(usable_header_width - (col1 + col2), usable_header_width * 0.18)
    header = Table([header_cells], colWidths=[col1, col2, col3], hAlign="LEFT")
    header.setStyle(TableStyle([("VALIGN", (0, 0), (-1, -1), "TOP")]))
    story.append(header)
    story.append(Spacer(1, max(3 * mm, 6 * mm * scale)))

    now_ts = datetime.now(timezone.utc)
    printed_by_label = printed_by or "System"

    small_style = ParagraphStyle("Small", parent=styles["Normal"], fontSize=max(6, 8 * scale), leading=max(8, 10 * scale))

    for section in supplier_sections:
        supplier_name = section.get("supplier_name") or "Unknown supplier"
        supplier_details = section.get("supplier_details") or "-"
        pb_numbers: list[str] = []
        for line in section.get("lines") or []:
            raw = str(line.get("purchase_bill_numbers") or "").strip()
            if not raw or raw == "-":
                continue
            parts = [p.strip() for p in raw.split(",") if p.strip()]
            pb_numbers.extend(parts)
        pb_numbers = list(dict.fromkeys(pb_numbers))
        pb_label = ", ".join(pb_numbers) if pb_numbers else "-"

        supplier_meta_width = usable_header_width
        col_meta_left = max(22 * mm, supplier_meta_width * 0.22)
        col_meta_right = supplier_meta_width - col_meta_left
        label_style = ParagraphStyle(
            "ERMetaLabel",
            parent=small_style,
            fontName="Helvetica-Bold",
        )
        supplier_meta = Table(
            [
                [Paragraph("Company", label_style), Paragraph(str(supplier_name), small_style)],
                [Paragraph("Details", label_style), Paragraph(str(supplier_details), small_style)],
                [Paragraph("Purchase Bills", label_style), Paragraph(pb_label, small_style)],
            ],
            colWidths=[col_meta_left, col_meta_right],
            hAlign="LEFT",
        )
        supplier_meta.setStyle(
            TableStyle(
                [
                    ("GRID", (0, 0), (-1, -1), 0.25, colors.lightgrey),
                    ("BACKGROUND", (0, 0), (0, -1), colors.whitesmoke),
                    ("VALIGN", (0, 0), (-1, -1), "TOP"),
                    ("FONTSIZE", (0, 0), (-1, -1), max(6, int(round(8 * scale)))),
                    ("LEFTPADDING", (0, 0), (-1, -1), 4),
                    ("RIGHTPADDING", (0, 0), (-1, -1), 4),
                    ("TOPPADDING", (0, 0), (-1, -1), 3),
                    ("BOTTOMPADDING", (0, 0), (-1, -1), 3),
                ]
            )
        )
        story.append(supplier_meta)
        story.append(Spacer(1, max(1.5 * mm, 2 * mm * scale)))

        rows = [["S.N.", "HS Code", "Product (Serial/Batch)", "Expiry", "Qty"]]
        for idx, line in enumerate(section.get("lines") or [], start=1):
            product_name = str(line.get("product_name") or "-")
            batch_number = str(line.get("batch_number") or "-")
            product_label = f"{product_name} ({batch_number})"
            rows.append(
                [
                    str(idx),
                    Paragraph(str(line.get("hscode") or "-"), small_style),
                    Paragraph(product_label, small_style),
                    Paragraph(_format_expiry_month_year(line.get("expiry_date")), small_style),
                    Paragraph(str(line.get("qty") or "-"), small_style),
                ]
            )

        usable_width = page_width - 2 * dynamic_margin
        col_sn = max(7 * mm, usable_width * 0.07)
        col_hs = max(14 * mm, usable_width * 0.14)
        col_exp = max(16 * mm, usable_width * 0.16)
        col_qty = max(16 * mm, usable_width * 0.18)
        col_prod = usable_width - (col_sn + col_hs + col_exp + col_qty)
        if col_prod < 40 * mm:
            col_prod = 40 * mm
            col_qty = max(14 * mm, usable_width - (col_sn + col_hs + col_exp + col_prod))
        table = Table(
            rows,
            colWidths=[col_sn, col_hs, col_prod, col_exp, col_qty],
            repeatRows=1,
            hAlign="LEFT",
        )
        table.setStyle(
            TableStyle(
                [
                    ("GRID", (0, 0), (-1, -1), 0.25, colors.lightgrey),
                    ("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#f3f4f6")),
                    ("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
                    ("ALIGN", (0, 1), (0, -1), "RIGHT"),
                    ("ALIGN", (4, 1), (4, -1), "RIGHT"),
                    ("VALIGN", (0, 0), (-1, -1), "TOP"),
                    ("FONTSIZE", (0, 0), (-1, -1), 8),
                ]
            )
        )
        story.append(table)
        story.append(Spacer(1, max(3 * mm, 6 * mm * scale)))

    instructions = (company.print_instructions_expiry_returns or company.print_instructions or "").strip()
    if instructions:
        instruction_text = instructions.replace("\n", "<br/>")
        story.append(Spacer(1, 3 * mm))
        instruction_style = ParagraphStyle("PrintInstructions", parent=styles["Normal"], fontSize=max(6, 8 * scale), leading=max(8, 10 * scale))
        instructions_table = Table(
            [[Paragraph(f"<b>**</b> {instruction_text}", instruction_style)]],
            colWidths=[doc.width],
        )
        instructions_table.setStyle(
            TableStyle(
                [
                    ("GRID", (0, 0), (-1, -1), 0.25, colors.lightgrey),
                    ("VALIGN", (0, 0), (-1, -1), "TOP"),
                    ("LEFTPADDING", (0, 0), (-1, -1), 6),
                    ("RIGHTPADDING", (0, 0), (-1, -1), 6),
                    ("TOPPADDING", (0, 0), (-1, -1), 4),
                    ("BOTTOMPADDING", (0, 0), (-1, -1), 4),
                ]
            )
        )
        story.append(instructions_table)

    # Signature block
    story.append(Spacer(1, max(4 * mm, 8 * mm * scale)))
    signature_style = ParagraphStyle(
        "SignatureSmall",
        parent=styles["Normal"],
        fontSize=max(6, 8 * scale),
        leading=max(8, 10 * scale),
    )
    story.append(Paragraph("Authorised Signatory:", signature_style))
    story.append(Paragraph(f"For {company.name}", signature_style))
    story.append(Spacer(1, max(2 * mm, 4 * mm * scale)))

    def _draw_footer(canvas, doc_obj):
        canvas.saveState()
        canvas.setFont("Helvetica", max(6, int(round(8 * scale))))
        footer_text = f"Printed by: {printed_by_label} at {_format_datetime_short(now_ts)}"
        canvas.drawString(doc_obj.leftMargin, 8 * mm, footer_text)
        canvas.restoreState()

    doc.build(story, onFirstPage=_draw_footer, onLaterPages=_draw_footer)
    return buf.getvalue()


def create_app():
    _load_env_defaults_from_backend_env()
    static_dir = os.path.join(os.path.dirname(__file__), "static")
    app = Flask(__name__, static_folder=static_dir, static_url_path="/static")
    db_url = os.getenv("DATABASE_URL")
    if not db_url:
        raise RuntimeError("DATABASE_URL is required. SQLite fallback has been removed.")
    if str(db_url).startswith("sqlite"):
        raise RuntimeError("SQLite DATABASE_URL is not supported. Use PostgreSQL or MySQL.")
    app.config["SQLALCHEMY_DATABASE_URI"] = db_url
    app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
    if str(db_url).startswith("postgresql") or str(db_url).startswith("mysql"):
        app.config["SQLALCHEMY_ENGINE_OPTIONS"] = {
            "pool_size": int(os.getenv("DB_POOL_SIZE", "20")),
            "max_overflow": int(os.getenv("DB_MAX_OVERFLOW", "40")),
            "pool_timeout": int(os.getenv("DB_POOL_TIMEOUT", "30")),
            "pool_recycle": int(os.getenv("DB_POOL_RECYCLE", "1800")),
            "pool_pre_ping": True,
        }
    app.config["SECRET_KEY"] = require_env("SECRET_KEY", min_length=16, default="dev-secret-key-please-change")
    app.config["TOKEN_MAX_AGE"] = int(os.getenv("TOKEN_MAX_AGE", 60 * 60 * 8))
    app.config["MAX_LOGIN_ATTEMPTS"] = int(os.getenv("MAX_LOGIN_ATTEMPTS", 5))
    app.config["LOGIN_WINDOW_SECONDS"] = int(os.getenv("LOGIN_WINDOW_SECONDS", 15 * 60))
    app.config["MAX_USER_ATTEMPTS"] = int(os.getenv("MAX_USER_ATTEMPTS", 5))
    app.config["LOCKOUT_SECONDS"] = int(os.getenv("LOCKOUT_SECONDS", 15 * 60))
    app.config["RESET_TOKEN_AGE"] = int(os.getenv("RESET_TOKEN_AGE", 15 * 60))
    app.config["SECURITY_TOKEN_AUTHENTICATION_HEADER"] = "Authorization"
    app.config["SECURITY_TOKEN_MAX_AGE"] = app.config["TOKEN_MAX_AGE"]
    app.config["SECURITY_PASSWORD_SALT"] = require_env(
        "SECURITY_PASSWORD_SALT", min_length=16, default="dev-salt-please-change"
    )
    app.config["SECURITY_PASSWORD_HASH"] = "argon2"

    dev_origins = [
        # Local frontend dev servers
        "http://localhost:5173",
        "http://localhost:5176",
        "http://localhost:5175",
        "http://localhost:5184",
        "http://localhost:5187",
        "http://localhost:8080",
        "http://127.0.0.1:5173",
        "http://127.0.0.1:5176",
        "http://127.0.0.1:5175",
        "http://127.0.0.1:5184",
        "http://127.0.0.1:5187",
        "http://127.0.0.1:8080",
        "http://[::1]:8080",
        # Common for Android/iOS webviews
        "http://localhost",
        "capacitor://localhost",
    ]
    cors_origins = os.getenv("CORS_ORIGINS")
    if cors_origins:
        env_origins = [o.strip() for o in cors_origins.split(",") if o.strip()]
        if "*" in env_origins:
            allowed_origins = "*"
        else:
            # Keep explicit env control but always include local dev/webview origins.
            allowed_origins = list(dict.fromkeys(env_origins + dev_origins))
    else:
        allowed_origins = dev_origins
    if allowed_origins:
        CORS(app, origins=allowed_origins, supports_credentials=True)
    else:
        CORS(app, supports_credentials=True)
    socketio.init_app(app, cors_allowed_origins=allowed_origins or "*")
    principals.init_app(app)
    db.init_app(app)
    global user_datastore, security
    user_datastore = SQLAlchemyUserDatastore(db, User, Role)
    security = Security(app, user_datastore)

    with app.app_context():
        schema_lock_path = os.path.join(tempfile.gettempdir(), "flask_pharmacy_schema.lock")
        with open(schema_lock_path, "w") as schema_lock:
            fcntl.flock(schema_lock, fcntl.LOCK_EX)
            try:
                db.create_all()
                upgrade_schema()
                ensure_base_roles()
                ensure_default_role_permissions()
                default_company = ensure_default_company()
                ensure_default_admin(default_company)
                normalize_user_flags()
                backfill_company_ids(default_company)
                _backfill_purchase_bill_numbers()
                _backfill_sale_numbers()
                _backfill_sale_return_numbers()
            finally:
                fcntl.flock(schema_lock, fcntl.LOCK_UN)

    register_routes(app)
    _start_backup_scheduler(app)
    _start_server_sync_scheduler(app)
    _start_reconcile_purchase_orders(app)

    @app.route("/")
    def serve_index():
        index_path = os.path.join(app.static_folder, "index.html")
        if os.path.exists(index_path):
            return app.send_static_file("index.html")
        return jsonify({"message": "Frontend not built"}), 404

    @app.route("/dashboard")
    def serve_dashboard():
        dashboard_path = os.path.join(app.static_folder, "dashboard.html")
        if os.path.exists(dashboard_path):
            return app.send_static_file("dashboard.html")
        return jsonify({"message": "Frontend not built"}), 404

    @app.route("/assets/<path:filename>")
    def serve_frontend_assets(filename: str):
        assets_dir = os.path.join(app.static_folder, "assets")
        asset_path = os.path.join(assets_dir, filename)
        if os.path.exists(asset_path):
            return send_from_directory(assets_dir, filename)
        return jsonify({"error": "Not found"}), 404

    @app.route("/<path:path>")
    def serve_spa(path: str):
        if path.startswith(("api/", "static/", "socket.io")) or "." in path:
            return jsonify({"error": "Not found"}), 404
        index_path = os.path.join(app.static_folder, "index.html")
        if os.path.exists(index_path):
            return app.send_static_file("index.html")
        return jsonify({"message": "Frontend not built"}), 404

    @app.errorhandler(500)
    def handle_500(error):
        return jsonify({"error": "Server error", "details": str(error)}), 500

    @app.errorhandler(404)
    def handle_404(error):
        return jsonify({"error": "Not found"}), 404

    return app


def _start_reconcile_purchase_orders(app: Flask) -> None:
    """Run PO reconciliation in the background so startup stays responsive."""
    def _worker():
        with app.app_context():
            try:
                for company in Company.query.all():
                    reconcile_purchase_orders(company.id)
            except Exception:
                db.session.rollback()

    t = threading.Thread(target=_worker, name="po-reconcile", daemon=True)
    t.start()


def ensure_default_company() -> Company:
    company = Company.query.first()
    if company:
        return company
    company = Company(name=os.getenv("DEFAULT_COMPANY_NAME", "Main Pharmacy"))
    db.session.add(company)
    db.session.commit()
    return company


COMPANY_TYPES = {"pharmacy", "clinic", "laboratory"}


def normalize_company_type(value) -> str:
    normalized = str(value or "pharmacy").strip().lower()
    return normalized if normalized in COMPANY_TYPES else "pharmacy"


def ensure_base_roles():
    role_names = {"admin", "superuser", "superadmin", "manager", "salesman", "staff"}
    role_names.update({str(name or "").strip().lower() for name in DEFAULT_ROLE_PERMISSION_KEYS.keys()})
    for name in sorted({n for n in role_names if n}):
        role = Role.query.filter_by(name=name).first()
        if not role:
            db.session.add(Role(name=name, description=f"{name} role"))
        db.session.commit()


DEFAULT_PERMISSION_CATALOG: list[dict] = []
DEFAULT_ROLE_PERMISSION_KEYS: dict[str, list[str]] = {}
SECTION_PERMISSION_BY_PATH_PREFIX: list[tuple[str, str]] = []
SPECIAL_ACTION_PERMISSION_BY_PATH_METHOD: list[tuple[str, str, str]] = []
CRUD_ACTION_PERMISSION_BY_PATH_PREFIX: list[tuple[str, str]] = []


def _build_catalog_from_registry(section_rows: list[dict], special_rows: list[dict]) -> list[dict]:
    catalog: dict[str, dict] = {}
    for row in section_rows:
        if not isinstance(row, dict):
            continue
        group = str(row.get("group") or "Sections").strip() or "Sections"
        label = str(row.get("label") or row.get("id") or "").strip()
        permissions = row.get("permissions") or {}
        if not isinstance(permissions, dict):
            continue
        for action, key in permissions.items():
            key_str = str(key or "").strip()
            if not key_str:
                continue
            action_label = str(action or "").strip().capitalize()
            merged_label = f"{label} {action_label}".strip() if action_label else label
            catalog[key_str] = {
                "key": key_str,
                "group": group,
                "label": merged_label if merged_label else key_str,
            }
    for row in special_rows:
        if not isinstance(row, dict):
            continue
        key_str = str(row.get("key") or "").strip()
        if not key_str:
            continue
        catalog[key_str] = {
            "key": key_str,
            "group": str(row.get("group") or "Operations").strip() or "Operations",
            "label": str(row.get("label") or key_str).strip() or key_str,
        }
    return sorted(catalog.values(), key=lambda item: str(item.get("key", "")))


def _load_permission_registry():
    global DEFAULT_PERMISSION_CATALOG
    global DEFAULT_ROLE_PERMISSION_KEYS
    global SECTION_PERMISSION_BY_PATH_PREFIX
    global SPECIAL_ACTION_PERMISSION_BY_PATH_METHOD
    global CRUD_ACTION_PERMISSION_BY_PATH_PREFIX

    registry_path = os.path.join(
        os.path.dirname(__file__),
        "..",
        "frontend",
        "src",
        "config",
        "permissions.catalog.json",
    )
    if not os.path.isfile(registry_path):
        raise RuntimeError(f"Permission registry file not found: {registry_path}")
    with open(registry_path, "r", encoding="utf-8") as fh:
        raw = json.load(fh)
    if not isinstance(raw, dict):
        raise RuntimeError("Permission registry JSON must be an object")

    section_rows = raw.get("sectionPermissionRows")
    special_rows = raw.get("specialPermissions")
    if isinstance(section_rows, list) and isinstance(special_rows, list):
        computed_catalog = _build_catalog_from_registry(section_rows, special_rows)
        if computed_catalog:
            DEFAULT_PERMISSION_CATALOG = computed_catalog
    elif isinstance(raw.get("catalog"), list):
        normalized_catalog = []
        for item in raw["catalog"]:
            if not isinstance(item, dict):
                continue
            key = str(item.get("key") or "").strip()
            if not key:
                continue
            normalized_catalog.append(
                {
                    "key": key,
                    "group": str(item.get("group") or "Other").strip() or "Other",
                    "label": str(item.get("label") or key).strip() or key,
                }
            )
        if normalized_catalog:
            DEFAULT_PERMISSION_CATALOG = normalized_catalog
    if not DEFAULT_PERMISSION_CATALOG:
        raise RuntimeError("Permission registry resolved an empty permission catalog")

    role_map = raw.get("defaultRolePermissions")
    if isinstance(role_map, dict):
        normalized_map: dict[str, list[str]] = {}
        for role_name, permissions in role_map.items():
            role_key = str(role_name or "").strip().lower()
            if not role_key:
                continue
            if not isinstance(permissions, list):
                continue
            normalized_map[role_key] = sorted(
                {
                    str(permission or "").strip()
                    for permission in permissions
                    if str(permission or "").strip()
                }
            )
        all_keys = sorted({str(item.get("key") or "").strip() for item in DEFAULT_PERMISSION_CATALOG if str(item.get("key") or "").strip()})
        normalized_map["superuser"] = all_keys
        normalized_map["superadmin"] = all_keys
        DEFAULT_ROLE_PERMISSION_KEYS = normalized_map
    if not DEFAULT_ROLE_PERMISSION_KEYS:
        raise RuntimeError("Permission registry resolved an empty defaultRolePermissions map")

    section_map = raw.get("sectionPermissionByPathPrefix")
    if isinstance(section_map, list):
        normalized_section_map: list[tuple[str, str]] = []
        for item in section_map:
            if isinstance(item, (list, tuple)) and len(item) >= 2:
                prefix = str(item[0] or "").strip()
                permission = str(item[1] or "").strip()
                if prefix and permission:
                    normalized_section_map.append((prefix, permission))
        SECTION_PERMISSION_BY_PATH_PREFIX = normalized_section_map

    action_map = raw.get("specialActionPermissionByPathMethod")
    if isinstance(action_map, list):
        normalized_action_map: list[tuple[str, str, str]] = []
        for item in action_map:
            if isinstance(item, (list, tuple)) and len(item) >= 3:
                prefix = str(item[0] or "").strip()
                method = str(item[1] or "").strip().upper()
                permission = str(item[2] or "").strip()
                if prefix and method and permission:
                    normalized_action_map.append((prefix, method, permission))
        SPECIAL_ACTION_PERMISSION_BY_PATH_METHOD = normalized_action_map

    crud_map = raw.get("crudActionPermissionByPathPrefix")
    if isinstance(crud_map, list):
        normalized_crud_map: list[tuple[str, str]] = []
        for item in crud_map:
            if isinstance(item, (list, tuple)) and len(item) >= 2:
                prefix = str(item[0] or "").strip()
                section = str(item[1] or "").strip()
                if prefix and section:
                    normalized_crud_map.append((prefix, section))
        CRUD_ACTION_PERMISSION_BY_PATH_PREFIX = normalized_crud_map


_load_permission_registry()


def _required_section_permission_for_path(path: str) -> str | None:
    path = (path or "").strip() or "/"
    if path == "/purchase-orders/suppliers" or path.startswith("/purchase-orders/suppliers/"):
        return None
    for prefix, permission_key in SECTION_PERMISSION_BY_PATH_PREFIX:
        if path == prefix or path.startswith(f"{prefix}/"):
            return permission_key
    return None


def _required_action_permission_for_request(path: str, method: str) -> str | None:
    path = (path or "").strip() or "/"
    method = (method or "GET").upper()
    if method in {"GET", "HEAD", "OPTIONS"}:
        return None

    for prefix, action_method, permission_key in SPECIAL_ACTION_PERMISSION_BY_PATH_METHOD:
        if method == action_method and (path == prefix or path.startswith(f"{prefix}/")):
            return permission_key

    action = None
    if method == "POST":
        action = "create"
    elif method in {"PUT", "PATCH"}:
        action = "edit"
    elif method == "DELETE":
        action = "delete"
    if not action:
        return None

    for prefix, section_key in CRUD_ACTION_PERMISSION_BY_PATH_PREFIX:
        if path == prefix or path.startswith(f"{prefix}/"):
            return f"{section_key}_{action}"
    return None


def ensure_default_role_permissions():
    """
    Seed default role -> permission keys.
    This only adds missing default keys (never removes existing keys), so superusers can freely customize.
    """
    for role_name, keys in DEFAULT_ROLE_PERMISSION_KEYS.items():
        role = Role.query.filter_by(name=role_name).first()
        if not role:
            continue
        existing_keys = {
            (p.section or "").strip()
            for p in RolePermission.query.filter_by(role_id=role.id).all()
            if (p.section or "").strip()
        }
        missing = [k for k in keys if k not in existing_keys]
        if not missing:
            continue
        for key in missing:
            db.session.add(RolePermission(role_id=role.id, section=key, can_create=True))
        db.session.commit()


def _role_permission_keys(role_name: str) -> list[str]:
    role = Role.query.filter_by(name=role_name).first()
    if not role:
        return []
    perms = RolePermission.query.filter_by(role_id=role.id).all()
    return sorted({(p.section or "").strip() for p in perms if (p.section or "").strip()})


def _user_permission_grants(user_id: int, company_id: int | None) -> tuple[set[str], set[str]]:
    query = UserPermissionGrant.query.filter_by(user_id=user_id)
    # include global grants as well as company-scoped grants
    if company_id is not None:
        query = query.filter(or_(UserPermissionGrant.company_id.is_(None), UserPermissionGrant.company_id == company_id))
    allowed: set[str] = set()
    denied: set[str] = set()
    for row in query.all():
        key = (row.permission or "").strip()
        if not key:
            continue
        if bool(row.allowed):
            allowed.add(key)
        else:
            denied.add(key)
    return allowed, denied


def _effective_permission_keys(user: User, company_id: int, company_role: str) -> list[str]:
    role_keys = set(_role_permission_keys(company_role))
    grant_keys, denied_keys = _user_permission_grants(user.id, company_id)
    # Platform admins inherit full superuser permission catalog across companies.
    if user.role in ROLE_PLATFORM_ADMINS:
        role_keys |= set(_role_permission_keys("superuser"))
        role_keys.add("superuser_settings_read")
    effective = (set(role_keys) | set(grant_keys)) - set(denied_keys)
    return sorted(effective)


def ensure_default_admin(company: Company):
    role_name = os.getenv("ADMIN_ROLE", "superuser")
    admin_user = (
        User.query.filter_by(role=role_name).first()
        or User.query.filter_by(username=os.getenv("ADMIN_USERNAME", "admin")).first()
        or User.query.filter(User.role.in_(tuple(ROLE_PLATFORM_ADMINS))).first()
    )
    if not admin_user:
        username = os.getenv("ADMIN_USERNAME", "admin")
        password = os.getenv("ADMIN_PASSWORD")
        if not password:
            # Keep app bootable for existing installs where ADMIN_PASSWORD is not set.
            # This path is only used when there is no admin user yet.
            fallback_user = User.query.order_by(User.id.asc()).first()
            if fallback_user:
                admin_user = fallback_user
            else:
                password = "ChangeMe-Admin-12345"
        if not admin_user and password and len(password) < 12:
            raise RuntimeError("ADMIN_PASSWORD must be at least 12 characters")
    if not admin_user:
        admin_user = User(username=username, role=role_name, is_active=True)
        admin_user.set_password(password)
        db.session.add(admin_user)
        db.session.commit()

    # ensure admin/superuser role exists and linked
    platform_role = Role.query.filter_by(name=role_name).first()
    if not platform_role:
        platform_role = Role(name=role_name, description="Platform administrator")
        db.session.add(platform_role)
        db.session.commit()
    if platform_role not in admin_user.roles:
        admin_user.roles.append(platform_role)
        db.session.commit()

    link = UserCompany.query.filter_by(user_id=admin_user.id, company_id=company.id).first()
    if not link:
        db.session.add(UserCompany(user=admin_user, company=company, role=role_name))
        db.session.commit()


def normalize_user_flags():
    # ensure legacy users have active flag and counters
    updated = False
    for user in User.query.filter((User.is_active.is_(None)) | (User.failed_attempts.is_(None))).all():
        if user.is_active is None:
            user.is_active = True
        if user.failed_attempts is None:
            user.failed_attempts = 0
        updated = True
    if updated:
        db.session.commit()


def unit_used(unit: Unit) -> bool:
    if unit.is_base:
        return True
    if unit.category_id:
        cat = db.session.get(UnitCategory, unit.category_id)
        if cat and cat.base_unit_id == unit.id:
            return True
    if Unit.query.filter(Unit.relative_unit_id == unit.id, Unit.id != unit.id).first():
        return True
    return False


def _normalized_category_name(category_name: str | None) -> str | None:
    """Trim and normalize a unit category name."""
    if not category_name:
        return None
    cleaned = category_name.strip()
    return cleaned or None


def _parse_flexible_date(value):
    if not value:
        return None
    if isinstance(value, (datetime, date)):
        return value.date() if isinstance(value, datetime) else value
    s = str(value).strip()
    if not s:
        return None
    # Normalize separators to split on non-digits.
    parts = [p for p in re.split(r"[^0-9]", s) if p]
    if len(parts) < 3:
        return None
    a, b, c = parts[0], parts[1], parts[2]
    try:
        if len(a) == 4:
            y, m, d = int(a), int(b), int(c)
        elif len(c) == 4:
            y = int(c)
            p1 = int(a)
            p2 = int(b)
            # Heuristic: if one part > 12 it must be day.
            if p1 > 12 and p2 <= 12:
                d, m = p1, p2
            elif p2 > 12 and p1 <= 12:
                m, d = p1, p2
            else:
                # Ambiguous (both <= 12): default to day/month.
                d, m = p1, p2
        else:
            return None
        if m < 1 or m > 12 or d < 1 or d > 31:
            return None
        return date(y, m, d)
    except Exception:
        return None


def _get_unit_categories(company_id: int, category_name: str | None) -> list[UnitCategory]:
    """
    Fetch all categories that match the provided name (case-insensitive) for the company.
    We keep duplicates sorted to prefer active, earlier categories.
    """
    cleaned = _normalized_category_name(category_name)
    if not cleaned:
        return []
    return (
        UnitCategory.query.filter(
            UnitCategory.company_id == company_id,
            func.lower(UnitCategory.name) == func.lower(cleaned),
        )
        .order_by(UnitCategory.is_archived.asc(), UnitCategory.id.asc())
        .all()
    )


def _get_unit_category(company_id: int, category_name: str | None) -> UnitCategory | None:
    categories = _get_unit_categories(company_id, category_name)
    return categories[0] if categories else None


def _get_or_create_unit_category(company_id: int, category_name: str | None) -> UnitCategory | None:
    cleaned = _normalized_category_name(category_name)
    if not cleaned:
        return None
    cat = _get_unit_category(company_id, cleaned)
    if cat:
        return cat
    cat = UnitCategory(company_id=company_id, name=cleaned)
    db.session.add(cat)
    db.session.commit()
    return cat


def _validate_uom_for_product(product: Product, uom: str | None):
    """
    Ensure the provided unit of measure belongs to the product's unit category.
    Falls back to product.uom_category if no uom provided.
    """
    if not product.uom_category:
        return uom or None

    categories = _get_unit_categories(product.company_id, product.uom_category)
    # If there are no categories/units configured, don't block the transaction; accept provided value
    if not categories:
        return uom or None

    category_ids = {c.id for c in categories}
    # Use base unit if none provided
    if not uom:
        return _base_uom_name(product)

    unit = (
        Unit.query.filter(
            Unit.company_id == product.company_id,
            func.lower(Unit.name) == func.lower(uom),
        ).first()
    )
    if unit and category_ids and unit.category_id in category_ids:
        return unit.name

    # If category exists but provided UoM is invalid, fall back to base for that category
    if category_ids:
        base = _base_uom_name(product)
        if base:
            return base

    return None


def _base_uom_name(product: Product) -> str | None:
    if not product.uom_category:
        return None
    categories = _get_unit_categories(product.company_id, product.uom_category)
    for cat in categories:
        base_unit = None
        if cat.base_unit_id:
            base_unit = Unit.query.filter_by(company_id=product.company_id, id=cat.base_unit_id).first()
        if not base_unit:
            base_unit = (
                Unit.query.filter_by(company_id=product.company_id, category_id=cat.id)
                .order_by(Unit.is_base.desc(), Unit.id.asc())
                .first()
            )
        if base_unit:
            return base_unit.name
    return None


def _infer_uom_factor(name: str | None) -> float | None:
    if not name:
        return None
    n = name.strip().lower()
    if n == "dozen":
        return 12.0
    m = re.match(r"^(\d+)\s*'?s\s*$", n)
    if m:
        try:
            val = float(m.group(1))
            return val if val > 0 else None
        except Exception:
            return None
    m = re.match(
        r"^(\d+)\s*(?:x\s*)?(?:per\s*)?(strip|strips|pcs|pc|piece|pieces|tablet|tablets|tab|tabs|cap|caps|capsule|capsules)\b",
        n,
    )
    if m:
        try:
            val = float(m.group(1))
            return val if val > 0 else None
        except Exception:
            return None
    return None


def _qty_to_base(company_id: int, product: Product, qty: float, uom_name: str | None) -> int:
    """
    Convert a quantity in `uom_name` to base units (int) for the product's unit category.
    If conversion cannot be resolved, returns qty as-is (assumed base).
    """
    if qty is None:
        return 0
    try:
        qty_float = float(qty)
    except (TypeError, ValueError):
        return 0
    if qty_float == 0:
        return 0
    if not uom_name:
        return int(qty_float)

    unit = (
        Unit.query.filter(
            Unit.company_id == company_id,
            func.lower(Unit.name) == func.lower(uom_name),
        ).first()
    )
    if not unit:
        inferred = _infer_uom_factor(uom_name)
        if inferred:
            base = qty_float * inferred
            return int(base + (1e-6 if base >= 0 else -1e-6))
        return int(qty_float)

    # If the product declares a unit category and we can resolve it, enforce the unit belongs to it.
    if product.uom_category:
        categories = _get_unit_categories(company_id, product.uom_category)
        if categories and unit.category_id not in {c.id for c in categories}:
            return int(qty_float)

    factor = float(unit.conversion_to_base or 0.0) or 0.0
    if factor <= 0:
        inferred = _infer_uom_factor(unit.name or "")
        if inferred:
            factor = inferred

    # If this isn't a base unit and conversion looks unset (often left at 1), try inferring.
    if (not unit.is_base) and (factor == 1.0):
        inferred = _infer_uom_factor(unit.name or "")
        if inferred and inferred != 1.0:
            factor = inferred

    if factor <= 0:
        return int(qty_float)

    base = qty_float * factor
    return int(base + (1e-6 if base >= 0 else -1e-6))


def _uom_factor_to_base(company_id: int, product: Product, uom_name: str | None) -> float:
    """
    Return the factor to convert `uom_name` to base units.
    Uses configured conversion_to_base and falls back to name inference (e.g., Dozen -> 12, 10's -> 10).
    """
    if not uom_name:
        return 1.0
    unit = (
        Unit.query.filter(
            Unit.company_id == company_id,
            func.lower(Unit.name) == func.lower(uom_name),
        ).first()
    )
    if not unit:
        inferred = _infer_uom_factor(uom_name)
        return float(inferred or 1.0)
    if product.uom_category:
        categories = _get_unit_categories(company_id, product.uom_category)
        if categories and unit.category_id not in {c.id for c in categories}:
            return 1.0

    factor = float(unit.conversion_to_base or 0.0) or 0.0
    if factor <= 0:
        factor = 1.0

    # reuse inference rules used in _qty_to_base
    name = (unit.name or "").strip().lower()
    implied = None
    if name == "dozen":
        implied = 12.0
    else:
        m = re.match(r"^(\d+)\s*'?s\s*$", name)
        if m:
            try:
                implied = float(m.group(1))
            except Exception:
                implied = None
        if implied is None:
            m = re.match(
                r"^(\d+)\s*(?:x\s*)?(?:per\s*)?(strip|strips|pcs|pc|piece|pieces|tablet|tablets|tab|tabs|cap|caps|capsule|capsules)\b",
                name,
            )
            if m:
                try:
                    implied = float(m.group(1))
                except Exception:
                    implied = None
    if (not unit.is_base) and (factor == 1.0) and implied and implied > 1.0:
        factor = implied
    return float(factor or 1.0)


def _allocate_sale_from_batches(
    company_id: int,
    product: Product,
    qty_base: int,
    *,
    inventory_batch_id: int | None,
    batch_number: str | None,
    preferred_uom: str | None,
    allow_negative: bool = False,
) -> None:
    """
    Deduct qty_base from InventoryBatch rows.
    - If product.lot_tracking: batch_number is required and deduction is limited to that batch_number.
    - Prefer batches whose InventoryBatch.uom matches preferred_uom first, then FEFO/FIFO.
    """
    if qty_base <= 0:
        return
    if product.lot_tracking and not batch_number and not inventory_batch_id:
        raise ValueError(f"Batch/Lot is required for {product.name}")

    base_query = InventoryBatch.query.filter(
        InventoryBatch.company_id == company_id,
        InventoryBatch.product_id == product.id,
    )
    if not allow_negative:
        base_query = base_query.filter(InventoryBatch.qty_base > 0)
    if inventory_batch_id:
        base_query = base_query.filter(InventoryBatch.id == int(inventory_batch_id))
    if batch_number:
        base_query = base_query.filter(InventoryBatch.batch_number == batch_number)

    order_by = (
        InventoryBatch.expiry_date.is_(None).asc(),
        InventoryBatch.expiry_date.asc(),
        InventoryBatch.created_at.asc(),
    )

    def load_batches(match_uom: bool) -> list[InventoryBatch]:
        q = base_query
        if preferred_uom:
            if match_uom:
                q = q.filter(InventoryBatch.uom == preferred_uom)
            else:
                q = q.filter((InventoryBatch.uom != preferred_uom) | (InventoryBatch.uom.is_(None)))
        return q.order_by(*order_by).all()

    batches: list[InventoryBatch] = []
    if preferred_uom:
        batches.extend(load_batches(True))
        batches.extend(load_batches(False))
    else:
        batches = base_query.order_by(*order_by).all()

    if not batches:
        if inventory_batch_id:
            raise ValueError(f"Selected batch has no stock for {product.name}")
        if product.lot_tracking:
            raise ValueError(f"No stock found for batch {batch_number} of {product.name}")
        raise ValueError(f"No inventory batches found for {product.name}")

    remaining = int(qty_base)
    for batch in batches:
        if remaining <= 0:
            break
        available = int(batch.qty_base or 0)
        if available <= 0 and not allow_negative:
            continue
        if available <= 0 and allow_negative:
            take = remaining
        else:
            take = min(available, remaining)
        batch.qty_base = available - take
        remaining -= take

    if remaining > 0:
        if allow_negative and batches:
            # Deduct any remaining quantity from the first batch to allow negative stock.
            batch = batches[0]
            available = int(batch.qty_base or 0)
            batch.qty_base = available - remaining
            remaining = 0
        if remaining > 0:
            if product.lot_tracking:
                raise ValueError(f"Insufficient stock in batch {batch_number} for {product.name}")
            raise ValueError(f"Insufficient batch stock for {product.name}")


def _recompute_product_stock_from_batches(company_id: int, product: Product) -> int:
    total = (
        db.session.query(func.coalesce(func.sum(InventoryBatch.qty_base), 0))
        .filter(InventoryBatch.company_id == company_id, InventoryBatch.product_id == product.id)
        .scalar()
        or 0
    )
    product.stock = int(total)
    return int(product.stock or 0)


def compute_purchase_order_status(company_id: int, supplier_id: int | None, items_payload: list[dict]) -> str:
    """
    Draft means the PO can be received later (but is not yet received).
    Receipt date is optional for drafts; receiving enforces batch/expiry/MRP.
    """
    has_supplier = bool(supplier_id)

    any_valid_line = False
    all_lines_valid = True
    for line in items_payload or []:
        product_id = line.get("product_id")
        name_raw = (line.get("product_name") or line.get("name") or "").strip()
        qty_raw = line.get("qty")
        uom_raw = (line.get("uom") or "").strip() or None

        try:
            qty_float = float(qty_raw or 0)
        except (TypeError, ValueError):
            qty_float = 0.0

        # Treat a fully empty row as ignorable.
        if not product_id and not name_raw and qty_float <= 0 and not uom_raw:
            continue
        # If the user typed a name but did not provide qty, mark incomplete.
        if name_raw and (not product_id or qty_float <= 0):
            all_lines_valid = False
            continue

        if not product_id or qty_float <= 0:
            all_lines_valid = False
            continue

        # Purchase orders use whole-number quantities (same as purchase bills).
        if abs(qty_float - round(qty_float)) > 1e-9:
            all_lines_valid = False
            continue
        qty = int(round(qty_float))
        if qty <= 0:
            all_lines_valid = False
            continue

        product = db.session.get(Product, product_id)
        if not product or product.company_id != company_id:
            all_lines_valid = False
            continue

        uom = _validate_uom_for_product(product, uom_raw)
        if product.uom_category and not uom:
            uom = _base_uom_name(product) or product.uom_category

        if product.uom_category and not uom:
            all_lines_valid = False
            continue

        any_valid_line = True

    if has_supplier and any_valid_line and all_lines_valid:
        return "draft"
    return "incomplete"


def reconcile_purchase_orders(company_id: int) -> dict:
    """
    Make stored PO statuses consistent with current rules:
    - If purchase_bill_id is set => received
    - Otherwise => draft/incomplete based on supplier + PO lines
    """
    orders = PurchaseOrder.query.filter_by(company_id=company_id).all()
    updated = []
    for po in orders:
        before = str(po.status or "")
        desired = None
        if getattr(po, "purchase_bill_id", None):
            bill = db.session.get(PurchaseBill, po.purchase_bill_id)
            if bill and bill.company_id == company_id and bool(getattr(bill, "posted", False)):
                desired = "received"

        if not desired:
            has_pricing = bool(getattr(po, "pricing_received_at", None) or getattr(po, "pricing_received_by_user_id", None))
            if not has_pricing:
                for item in po.items or []:
                    if any(
                        value is not None
                        for value in [
                            getattr(item, "received_ordered_qty", None),
                            getattr(item, "received_free_qty", None),
                            getattr(item, "received_cost_price", None),
                            getattr(item, "received_mrp", None),
                        ]
                    ):
                        has_pricing = True
                        break
            has_receipt_lines = bool(po.receipt_lines)
            if has_pricing or has_receipt_lines:
                desired = "receiving"
                if has_receipt_lines:
                    totals_by_item: dict[int, float] = {}
                    for line in po.receipt_lines or []:
                        if line.purchase_order_item_id is None:
                            continue
                        totals_by_item.setdefault(line.purchase_order_item_id, 0.0)
                        totals_by_item[line.purchase_order_item_id] += float(line.ordered_qty or 0.0) + float(line.free_qty or 0.0)
                    all_complete = True
                    for poi in po.items or []:
                        po_qty = float(poi.qty or 0.0)
                        if po_qty <= 0:
                            continue
                        received_total = totals_by_item.get(poi.id, 0.0)
                        if received_total + 1e-6 < po_qty:
                            all_complete = False
                            break
                    if all_complete:
                        desired = "received"
            else:
                items_payload = [{"product_id": it.product_id, "qty": it.qty, "uom": it.uom} for it in (po.items or [])]
                desired = compute_purchase_order_status(company_id, po.supplier_id, items_payload)
        if desired != before:
            po.status = desired
            updated.append({"id": po.id, "from": before, "to": desired})

    if updated:
        db.session.commit()
    return {"updated": updated, "count": len(updated)}


def _parse_expiry_date_allow_month_year(raw: str | None):
    if not raw:
        return None
    s = str(raw).strip()
    if not s:
        return None
    try:
        return datetime.fromisoformat(s).date()
    except ValueError:
        norm = re.sub(r"[\\._]", " ", s)
        norm = re.sub(r"[/\\-]+", " ", norm)
        norm = re.sub(r"\\s+", " ", norm).strip().lower()
        months = {
            "jan": 1,
            "january": 1,
            "feb": 2,
            "february": 2,
            "mar": 3,
            "march": 3,
            "apr": 4,
            "april": 4,
            "may": 5,
            "jun": 6,
            "june": 6,
            "jul": 7,
            "july": 7,
            "aug": 8,
            "august": 8,
            "sep": 9,
            "sept": 9,
            "september": 9,
            "oct": 10,
            "october": 10,
            "nov": 11,
            "november": 11,
            "dec": 12,
            "december": 12,
        }

        tokens = norm.split(" ")
        month = None
        year = None

        for t in tokens:
            if t in months:
                month = months[t]
            elif re.fullmatch(r"\\d{4}", t):
                year = int(t)

        nums = [int(t) for t in tokens if re.fullmatch(r"\\d{1,2}", t)]
        if month is None and nums:
            if year is not None:
                for n in nums:
                    if 1 <= n <= 12:
                        month = n
                        break
            else:
                if len(nums) >= 2:
                    a, b = nums[0], nums[1]
                    if a > 12 and b <= 12:
                        year = 2000 + a if a < 100 else a
                        month = b
                    elif b > 12 and a <= 12:
                        year = 2000 + b if b < 100 else b
                        month = a
                    else:
                        month = a if 1 <= a <= 12 else None
                        year = 2000 + b if b < 100 else b
                else:
                    n = nums[0]
                    if 1 <= n <= 12:
                        month = n

        if year is None:
            for t in tokens:
                if re.fullmatch(r"\\d{2}", t):
                    year = 2000 + int(t)
                    break

        if not year or not month or month < 1 or month > 12:
            raise ValueError("Invalid expiry_date")

        return datetime(year, month, 1).date()


def _apply_purchase_bill_to_inventory(company_id: int, bill: PurchaseBill, reason: str = "purchase_bill"):
    """
    Apply a purchase bill's stock changes into Products + InventoryBatch rows.
    Does not modify bill.posted flags; caller manages bill metadata.
    """
    def _merge_inventory_batches(batches: list[InventoryBatch]) -> InventoryBatch:
        """
        Consolidate duplicate InventoryBatch rows in-place by moving all qty_base
        into a single canonical row and zeroing the rest. Keeps DB integrity for
        historical references (sale_items.inventory_batch_id).
        """
        if not batches:
            raise ValueError("No batches to merge")
        canonical = batches[0]
        total = 0
        for b in batches:
            total += int(b.qty_base or 0)
        canonical.qty_base = int(total)
        for b in batches[1:]:
            b.qty_base = 0
        return canonical

    def _get_or_create_batch_for_posting(
        *,
        product: Product,
        batch_number: str | None,
        expiry_date,
        uom: str | None,
        mrp_per_uom: float,
        factor_to_base: float,
        arrival_at: datetime | None,
    ) -> InventoryBatch:
        """
        Posting rule:
        - Lot-tracked products: batch_number must map to a single InventoryBatch row.
          If duplicates exist, merge them and update metadata to the incoming values.
        - Non-lot-tracked: keep separate rows by (expiry_date, uom, mrp) but merge accidental duplicates.
        """
        safe_uom = (uom or "").strip() or None
        safe_batch = (batch_number or "").strip() or None
        safe_factor = float(factor_to_base or 1.0) if float(factor_to_base or 0.0) > 0 else 1.0
        safe_mrp = float(mrp_per_uom or 0.0) or 0.0
        safe_arrival_at = arrival_at

        def _cmp_dt(dt: datetime | None) -> datetime | None:
            if not dt:
                return None
            out = dt
            if getattr(out, "tzinfo", None) is not None:
                out = out.astimezone(timezone.utc).replace(tzinfo=None)
            return out

        def _min_dt(a: datetime | None, b: datetime | None) -> datetime | None:
            if not a:
                return b
            if not b:
                return a
            a_cmp = _cmp_dt(a)
            b_cmp = _cmp_dt(b)
            if not a_cmp:
                return b
            if not b_cmp:
                return a
            return a if a_cmp <= b_cmp else b

        if product.lot_tracking:
            batches = (
                InventoryBatch.query.filter_by(company_id=company_id, product_id=product.id, batch_number=safe_batch)
                .order_by(InventoryBatch.created_at.asc(), InventoryBatch.id.asc())
                .all()
            )
            if not batches:
                batch = InventoryBatch(
                    company_id=company_id,
                    product=product,
                    batch_number=safe_batch,
                    expiry_date=expiry_date,
                    uom=safe_uom,
                    factor_to_base=safe_factor,
                    mrp_per_uom=safe_mrp,
                    mrp=safe_mrp,
                    qty_base=0,
                    arrival_at=safe_arrival_at,
                )
                db.session.add(batch)
                return batch

            # Prefer an existing row that already matches the incoming metadata.
            def _match_score(b: InventoryBatch) -> tuple[int, int]:
                score = 0
                if safe_uom and (b.uom or "").strip().lower() == safe_uom.lower():
                    score += 4
                if expiry_date and b.expiry_date == expiry_date:
                    score += 3
                if safe_mrp and abs(float(b.mrp or 0.0) - safe_mrp) < 1e-6:
                    score += 2
                if int(b.qty_base or 0) > 0:
                    score += 1
                # higher is better, break ties by lowest id
                return (-score, int(b.id or 0))

            batches_sorted = sorted(batches, key=_match_score)
            canonical = _merge_inventory_batches(batches_sorted)

            # Enforce canonical metadata from the posting line (batch_number defines the batch).
            canonical.batch_number = safe_batch
            canonical.expiry_date = expiry_date
            canonical.uom = safe_uom
            canonical.factor_to_base = safe_factor
            canonical.mrp_per_uom = safe_mrp
            canonical.mrp = safe_mrp
            canonical.arrival_at = _min_dt(getattr(canonical, "arrival_at", None), safe_arrival_at)
            return canonical

        # Non-lot-tracked: keep separate lots by expiry+uom+mrp for FEFO.
        batches = (
            InventoryBatch.query.filter_by(
                company_id=company_id,
                product_id=product.id,
                batch_number=None,
                expiry_date=expiry_date,
                mrp=safe_mrp,
                uom=safe_uom,
            )
            .order_by(InventoryBatch.created_at.asc(), InventoryBatch.id.asc())
            .all()
        )
        if not batches:
            batch = InventoryBatch(
                company_id=company_id,
                product=product,
                batch_number=None,
                expiry_date=expiry_date,
                uom=safe_uom,
                factor_to_base=safe_factor,
                mrp_per_uom=safe_mrp,
                mrp=safe_mrp,
                qty_base=0,
                arrival_at=safe_arrival_at,
            )
            db.session.add(batch)
            return batch

        canonical = _merge_inventory_batches(batches)
        if not canonical.uom:
            canonical.uom = safe_uom
        if not canonical.factor_to_base or float(canonical.factor_to_base or 0.0) <= 0.0:
            canonical.factor_to_base = safe_factor
        if float(canonical.mrp_per_uom or 0.0) <= 0.0 and safe_mrp > 0:
            canonical.mrp_per_uom = safe_mrp
        if float(canonical.mrp or 0.0) <= 0.0 and safe_mrp > 0:
            canonical.mrp = safe_mrp
        canonical.arrival_at = _min_dt(getattr(canonical, "arrival_at", None), safe_arrival_at)
        return canonical

    for idx, item in enumerate(bill.items or []):
        product = Product.query.get(item.product_id)
        if not product or product.company_id != company_id:
            raise ValueError(f"Line {idx + 1}: Product missing")

        if (product.expiry_tracking or product.shelf_removal) and not item.expiry_date:
            raise ValueError(f"Line {idx + 1}: Expiry date required for {product.name}")
        if product.lot_tracking and not item.batch_number:
            raise ValueError(f"Line {idx + 1}: Batch/serial required for {product.name}")

        qty_units = float((item.ordered_qty or 0) + (item.free_qty or 0))
        factor_to_base = _uom_factor_to_base(company_id, product, item.uom)
        stock_change = _qty_to_base(company_id, product, qty_units, item.uom)
        if stock_change <= 0:
            raise ValueError(f"Line {idx + 1}: Invalid converted quantity")

        product.stock = int(product.stock or 0) + stock_change
        db.session.add(InventoryLog(company_id=company_id, product=product, change=stock_change, reason=reason))

        mrp_per_uom = float(item.mrp or 0.0) or float(getattr(item, "price", 0.0) or 0.0)
        mrp_per_uom = float(mrp_per_uom or 0.0)
        if mrp_per_uom > 0:
            product.price = round(mrp_per_uom / max(1.0, factor_to_base), 4)

        expiry = item.expiry_date if (product.expiry_tracking or product.shelf_removal) else None
        # Batch "arrival into inventory" timestamp:
        # Prefer bill.posted_at (if set by caller) and fall back to now.
        arrival_at = getattr(bill, "posted_at", None) or datetime.now(timezone.utc)
        batch = _get_or_create_batch_for_posting(
            product=product,
            batch_number=item.batch_number if product.lot_tracking else None,
            expiry_date=expiry,
            uom=item.uom,
            mrp_per_uom=mrp_per_uom,
            factor_to_base=factor_to_base,
            arrival_at=arrival_at,
        )
        batch.qty_base = int(batch.qty_base or 0) + stock_change

        # If this purchase line matches an opening stock-adjustment line, mark that adjustment as locked.
        match_batch = (item.batch_number or "").strip() or None
        match_uom = (item.uom or "").strip() or None
        adj_query = StockAdjustment.query.filter(
            StockAdjustment.company_id == company_id,
            StockAdjustment.product_id == product.id,
        )
        if product.lot_tracking:
            adj_query = adj_query.filter(func.coalesce(StockAdjustment.batch_number, "") == (match_batch or ""))
        else:
            adj_query = adj_query.filter(
                func.coalesce(StockAdjustment.batch_number, "") == "",
                func.coalesce(StockAdjustment.uom, "") == (match_uom or ""),
            )
        for adj in adj_query.order_by(StockAdjustment.adjustment_date.asc(), StockAdjustment.id.asc()).all():
            if adj.locked_by_purchase and adj.inventory_batch_id:
                continue
            adj.locked_by_purchase = True
            adj.inventory_batch_id = batch.id
            if item.expiry_date:
                adj.expiry_date = item.expiry_date
            mrp_for_adj = float(item.mrp or 0.0) or float(getattr(item, "price", 0.0) or 0.0)
            if mrp_for_adj > 0:
                adj.mrp = round(float(mrp_for_adj), 4)
            if item.uom:
                adj.uom = item.uom


def _revert_purchase_bill_from_inventory(company_id: int, bill: PurchaseBill, reason: str = "purchase_bill_repair_revert"):
    """
    Reverse an already-posted purchase bill from inventory.
    Caller must ensure stock from this bill has not been consumed downstream.
    """
    for idx, item in enumerate(bill.items or []):
        product = Product.query.get(item.product_id)
        if not product or product.company_id != company_id:
            raise ValueError(f"Line {idx + 1}: Product missing")

        qty_units = float((item.ordered_qty or 0) + (item.free_qty or 0))
        stock_change = _qty_to_base(company_id, product, qty_units, item.uom)
        if stock_change <= 0:
            raise ValueError(f"Line {idx + 1}: Invalid converted quantity")
        if int(product.stock or 0) < stock_change:
            raise ValueError(f"Line {idx + 1}: Insufficient product stock to reverse posted bill")

        product.stock = int(product.stock or 0) - stock_change
        db.session.add(InventoryLog(company_id=company_id, product=product, change=-stock_change, reason=reason))

        if product.lot_tracking:
            batch_key = (item.batch_number or "").strip() or None
            batches = (
                InventoryBatch.query.filter_by(
                    company_id=company_id,
                    product_id=product.id,
                    batch_number=batch_key,
                )
                .order_by(InventoryBatch.id.asc())
                .all()
            )
        else:
            expiry = item.expiry_date if (product.expiry_tracking or product.shelf_removal) else None
            mrp_per_uom = float(item.mrp or 0.0) or float(getattr(item, "price", 0.0) or 0.0)
            safe_uom = (item.uom or "").strip() or None
            batches = (
                InventoryBatch.query.filter_by(
                    company_id=company_id,
                    product_id=product.id,
                    batch_number=None,
                    expiry_date=expiry,
                    mrp=float(mrp_per_uom or 0.0),
                    uom=safe_uom,
                )
                .order_by(InventoryBatch.id.asc())
                .all()
            )

        total_available = sum(int(b.qty_base or 0) for b in batches)
        if total_available < stock_change:
            raise ValueError(f"Line {idx + 1}: Inventory already moved; bill cannot be repaired")

        remaining = int(stock_change)
        for b in batches:
            if remaining <= 0:
                break
            have = int(b.qty_base or 0)
            if have <= 0:
                continue
            take = min(have, remaining)
            b.qty_base = have - take
        remaining -= take


def _round_half_up(value: float) -> float:
    if value is None:
        return 0.0
    sign = 1.0 if value >= 0 else -1.0
    abs_val = abs(float(value))
    base = math.floor(abs_val)
    frac = abs_val - base
    rounded_abs = base if frac < 0.5 else (base + 1)
    return rounded_abs * sign


def _compute_purchase_bill_totals(company: Company, items: list[dict]) -> dict:
    """
    Compute bill totals from item snapshots using company tax settings.
    Returns subtotal_total, discount_total, cc_free_item_amount, vat_total,
    round_off, grand_total (rounded).
    """
    subtotal_total = 0.0
    discount_total = 0.0
    free_goods_base = 0.0
    ordered_tax_total = 0.0
    free_vat_total = 0.0
    for row in items:
        product = row.get("product")
        ordered_qty = float(row.get("ordered_qty") or 0.0)
        free_qty = float(row.get("free_qty") or 0.0)
        cost_price = float(row.get("cost_price") or 0.0)
        discount = float(row.get("discount") or 0.0)
        tax_subtotal = float(row.get("tax_subtotal") or 0.0)
        free_vat_percent = float(row.get("free_vat_percent") or 0.0)
        subtotal = ordered_qty * cost_price
        subtotal_total += subtotal
        discount_total += discount
        if tax_subtotal > 0:
            ordered_tax_total += tax_subtotal
        if bool(getattr(product, "charge_cc_free_items", False)):
            free_goods_base += free_qty * cost_price
        if free_qty > 0 and free_vat_percent > 0:
            free_vat_total += (free_qty * cost_price * max(0.0, free_vat_percent)) / 100.0

    cc_percent = float(getattr(company, "cc_free_item_percent", 0.0) or 0.0)
    cc_free_item_amount = (free_goods_base * cc_percent) / 100.0
    vat_total = ordered_tax_total + free_vat_total
    grand_total_raw = subtotal_total - discount_total + cc_free_item_amount + vat_total
    grand_total_rounded = _round_half_up(grand_total_raw)
    round_off = grand_total_rounded - grand_total_raw
    return {
        "subtotal_total": round(subtotal_total, 2),
        "discount_total": round(discount_total, 2),
        "cc_free_item_amount": round(cc_free_item_amount, 2),
        "vat_total": round(vat_total, 2),
        "round_off": round(round_off, 2),
        "grand_total": round(grand_total_rounded, 2),
    }


def _purchase_bill_totals_items_from_bill(bill: PurchaseBill) -> list[dict]:
    rows: list[dict] = []
    for item in (bill.items or []):
        rows.append(
            {
                "product": item.product,
                "ordered_qty": item.ordered_qty,
                "free_qty": item.free_qty,
                "cost_price": item.cost_price,
                "discount": item.discount,
                "tax_subtotal": item.tax_subtotal,
                "free_vat_percent": item.free_vat_percent,
            }
        )
    return rows


def _sync_purchase_bill_totals(company: Company, bill: PurchaseBill) -> bool:
    if not company or not bill:
        return False
    totals = _compute_purchase_bill_totals(company, _purchase_bill_totals_items_from_bill(bill))
    next_subtotal = float(totals.get("subtotal_total", 0.0) or 0.0)
    next_discount = float(totals.get("discount_total", 0.0) or 0.0)
    next_cc = float(totals.get("cc_free_item_amount", 0.0) or 0.0)
    next_vat = float(totals.get("vat_total", 0.0) or 0.0)
    next_round = float(totals.get("round_off", 0.0) or 0.0)
    next_gross = float(totals.get("grand_total", 0.0) or 0.0)

    changed = any(
        abs(float(current or 0.0) - expected) > 0.009
        for current, expected in [
            (bill.subtotal_total, next_subtotal),
            (bill.discount_total, next_discount),
            (bill.cc_free_item_amount, next_cc),
            (bill.vat_total, next_vat),
            (bill.round_off, next_round),
            (bill.gross_total, next_gross),
        ]
    )
    if changed:
        bill.subtotal_total = next_subtotal
        bill.discount_total = next_discount
        bill.cc_free_item_amount = next_cc
        bill.vat_total = next_vat
        bill.round_off = next_round
        bill.gross_total = next_gross
    return changed


def _compute_purchase_order_totals(company: Company, po: PurchaseOrder) -> dict:
    subtotal_total = 0.0
    discount_total = 0.0
    free_goods_base = 0.0
    vat_base = 0.0
    free_vat_total = 0.0
    has_any = False
    for it in (po.items or []):
        try:
            ordered = float(it.received_ordered_qty or 0.0)
        except (TypeError, ValueError):
            ordered = 0.0
        try:
            free = float(it.received_free_qty or 0.0)
        except (TypeError, ValueError):
            free = 0.0
        try:
            cost = float(it.received_cost_price or 0.0)
        except (TypeError, ValueError):
            cost = 0.0
        if ordered <= 0 and free <= 0 and cost <= 0:
            continue
        has_any = True
        subtotal = ordered * cost
        try:
            disc_total = float(it.received_discount_total or 0.0)
        except (TypeError, ValueError):
            disc_total = 0.0
        try:
            disc_pct = float(it.received_discount_percent or 0.0)
        except (TypeError, ValueError):
            disc_pct = 0.0
        if disc_total <= 0 and disc_pct > 0 and subtotal > 0:
            disc_total = (subtotal * min(disc_pct, 100.0)) / 100.0
        subtotal_total += subtotal
        discount_total += max(0.0, disc_total)
        if it.product and bool(getattr(it.product, "charge_cc_free_items", False)):
            free_goods_base += free * cost
        try:
            free_vat_percent = float(getattr(it, "received_free_vat_percent", 0.0) or 0.0)
        except (TypeError, ValueError):
            free_vat_percent = 0.0
        if free > 0 and free_vat_percent > 0:
            free_vat_total += (free * cost * max(0.0, free_vat_percent)) / 100.0
        if it.product and bool(getattr(it.product, "vat_item", False)):
            vat_base += max(0.0, subtotal - max(0.0, disc_total))
    if not has_any:
        return {
            "subtotal_total": 0.0,
            "discount_total": 0.0,
            "cc_free_item_amount": 0.0,
            "vat_total": 0.0,
            "round_off": 0.0,
            "grand_total": 0.0,
        }
    cc_percent = float(getattr(company, "cc_free_item_percent", 0.0) or 0.0)
    vat_percent = float(getattr(company, "vat_purchase_percent", 0.0) or 0.0)
    cc_free_item_amount = (free_goods_base * max(0.0, cc_percent)) / 100.0
    vat_total = (vat_base * max(0.0, vat_percent)) / 100.0 + free_vat_total
    raw_total = subtotal_total - discount_total + cc_free_item_amount + vat_total
    rounded_total = _round_half_up(raw_total)
    round_off = rounded_total - raw_total
    return {
        "subtotal_total": round(subtotal_total, 2),
        "discount_total": round(discount_total, 2),
        "cc_free_item_amount": round(cc_free_item_amount, 2),
        "vat_total": round(vat_total, 2),
        "round_off": round(round_off, 2),
        "grand_total": round(rounded_total, 2),
    }


def _maybe_backfill_purchase_order_totals(company: Company, po: PurchaseOrder) -> bool:
    try:
        has_existing = any(
            float(getattr(po, field, 0.0) or 0.0) > 0.0
            for field in ["subtotal_total", "discount_total", "cc_free_item_amount", "vat_total"]
        )
    except Exception:
        has_existing = False
    totals = _compute_purchase_order_totals(company, po)
    if not has_existing and totals.get("grand_total", 0.0) <= 0:
        return False
    po.subtotal_total = totals.get("subtotal_total", 0.0)
    po.discount_total = totals.get("discount_total", 0.0)
    po.cc_free_item_amount = totals.get("cc_free_item_amount", 0.0)
    po.vat_total = totals.get("vat_total", 0.0)
    po.round_off = totals.get("round_off", float(po.round_off or 0.0))
    po.total_amount = totals.get("grand_total", float(po.total_amount or 0.0))
    return True


def _sale_inventory_outbound_reasons() -> list[str]:
    return [
        "sale",
        "daily_sale_approved",
        "backdated_sale_approved",
        "sales_order_delivered",
        "expiry_return",
    ]


def _purchase_bill_repair_lock_reason(company_id: int, bill: PurchaseBill) -> str | None:
    # Only posted bills are repair candidates.
    if not (bool(getattr(bill, "posted", False)) or getattr(bill, "posted_at", None)):
        return "Not posted to inventory"
    if not (bill.items or []):
        return "No bill items found"

    posted_at = getattr(bill, "posted_at", None) or getattr(bill, "created_at", None)
    product_ids = sorted({int(it.product_id) for it in (bill.items or []) if getattr(it, "product_id", None)})
    if not product_ids:
        return "No valid products in bill"

    # Safety lock: if any outbound inventory movement happened after bill posting, do not allow repair.
    if posted_at:
        outbound = (
            InventoryLog.query.filter(
                InventoryLog.company_id == company_id,
                InventoryLog.product_id.in_(product_ids),
                InventoryLog.change < 0,
                InventoryLog.created_at >= posted_at,
                InventoryLog.reason.in_(_sale_inventory_outbound_reasons()),
            )
            .order_by(InventoryLog.created_at.asc())
            .first()
        )
        if outbound:
            return "Items already moved from inventory after posting"

    # Also verify current matching batch stock still contains this bill quantity.
    for item in bill.items or []:
        product = Product.query.get(item.product_id)
        if not product or product.company_id != company_id:
            return "Product record missing"
        qty_units = float((item.ordered_qty or 0) + (item.free_qty or 0))
        needed = int(_qty_to_base(company_id, product, qty_units, item.uom) or 0)
        if needed <= 0:
            return "Invalid item quantity conversion"

        if product.lot_tracking:
            batch_key = (item.batch_number or "").strip() or None
            available = (
                db.session.query(func.coalesce(func.sum(InventoryBatch.qty_base), 0))
                .filter(
                    InventoryBatch.company_id == company_id,
                    InventoryBatch.product_id == product.id,
                    InventoryBatch.batch_number == batch_key,
                )
                .scalar()
                or 0
            )
        else:
            expiry = item.expiry_date if (product.expiry_tracking or product.shelf_removal) else None
            mrp_per_uom = float(item.mrp or 0.0) or float(getattr(item, "price", 0.0) or 0.0)
            safe_uom = (item.uom or "").strip() or None
            available = (
                db.session.query(func.coalesce(func.sum(InventoryBatch.qty_base), 0))
                .filter(
                    InventoryBatch.company_id == company_id,
                    InventoryBatch.product_id == product.id,
                    InventoryBatch.batch_number.is_(None),
                    InventoryBatch.expiry_date == expiry,
                    InventoryBatch.mrp == float(mrp_per_uom or 0.0),
                    InventoryBatch.uom == safe_uom,
                )
                .scalar()
                or 0
            )
        if int(available or 0) < needed:
            return "Items already moved from inventory"
    return None


def category_used(cat: UnitCategory) -> bool:
    return Unit.query.filter_by(category_id=cat.id).first() is not None


def currency_used(currency: Currency) -> bool:
    referenced = (
        Currency.query.filter(
            Currency.company_id == currency.company_id, Currency.base_code == currency.code, Currency.id != currency.id
        ).count()
        > 0
    )
    is_base = currency.base_code == currency.code
    return referenced or is_base


def product_used(product: Product) -> bool:
    from models import SaleItem, InventoryLog, PurchaseBillItem  # local import to avoid circular

    if SaleItem.query.filter_by(product_id=product.id).count() > 0:
        return True
    if InventoryLog.query.filter_by(product_id=product.id).count() > 0:
        return True
    if PurchaseBillItem.query.filter_by(product_id=product.id).count() > 0:
        return True
    return False


def backfill_company_ids(default_company: Company):
    # assign missing company_id values to default company for legacy records
    for model in [Product, Customer, Sale, InventoryLog]:
        missing = model.query.filter((model.company_id.is_(None)) | (model.company_id == 0)).all()
        for record in missing:
            record.company_id = default_company.id
    db.session.commit()

    # ensure existing users have at least one membership (default to staff)
    users = User.query.all()
    for user in users:
        has_any_membership = UserCompany.query.filter_by(user_id=user.id).first()
        if not has_any_membership:
            role = "admin" if user.role == "admin" else "staff"
            db.session.add(UserCompany(user=user, company=default_company, role=role))
    db.session.commit()


def serializer(app: Flask) -> URLSafeTimedSerializer:
    return URLSafeTimedSerializer(app.config["SECRET_KEY"], salt="auth-token")


def reset_serializer(app: Flask) -> URLSafeTimedSerializer:
    return URLSafeTimedSerializer(app.config["SECRET_KEY"], salt="reset-token")


def auth_required(app: Flask):
    def decorator(fn: Callable):
        @wraps(fn)
        def wrapper(*args, **kwargs):
            # Allow CORS preflight to pass without auth headers
            if request.method == "OPTIONS":
                return ("", 204)
            
            token = None
            auth_header = request.headers.get("Authorization", "")
            if auth_header.startswith("Bearer "):
                token = auth_header.split(" ", 1)[1].strip()
            if not token:
                return jsonify({"error": "Unauthorized"}), 401

            try:
                data = serializer(app).loads(token, max_age=app.config["TOKEN_MAX_AGE"])
            except SignatureExpired:
                return jsonify({"error": "Token expired"}), 401
            except BadSignature:
                return jsonify({"error": "Invalid token"}), 401

            user = db.session.get(User, data.get("sub"))
            if not user:
                return jsonify({"error": "Unauthorized"}), 401

            g.current_user = user
            # Sync Flask-Principal identity for downstream permissions
            _set_identity(app, user, getattr(g, "current_company", None), getattr(g, "company_role", None))
            return fn(*args, **kwargs)

        return wrapper

    return decorator


def _tzinfo_from_utc_value(value: str | None):
    raw = str(value or "UTC").strip()
    if not raw:
        return timezone.utc
    match = re.fullmatch(r"UTC(?:([+-])(\d{1,2})(?::?(\d{2}))?)?", raw, flags=re.IGNORECASE)
    if match:
        sign, hours_str, mins_str = match.groups()
        if not sign:
            return timezone.utc
        try:
            hours = int(hours_str or "0")
            minutes = int(mins_str or "0")
        except ValueError:
            return timezone.utc
        if hours > 14 or minutes > 59:
            return timezone.utc
        delta = timedelta(hours=hours, minutes=minutes)
        if sign == "-":
            delta = -delta
        return timezone(delta)
    # Backward-compatible support for plain offsets like +05:45
    match = re.fullmatch(r"([+-])(\d{1,2})(?::?(\d{2}))?", raw)
    if match:
        sign, hours_str, mins_str = match.groups()
        try:
            hours = int(hours_str or "0")
            minutes = int(mins_str or "0")
        except ValueError:
            return timezone.utc
        if hours > 14 or minutes > 59:
            return timezone.utc
        delta = timedelta(hours=hours, minutes=minutes)
        if sign == "-":
            delta = -delta
        return timezone(delta)
    return timezone.utc


def _current_user_timezone():
    try:
        user = getattr(g, "current_user", None)
    except RuntimeError:
        user = None
    return _tzinfo_from_utc_value(getattr(user, "time_zone", None))


def _now_for_user_timezone() -> datetime:
    return datetime.now(timezone.utc).astimezone(_current_user_timezone())


def _today_ad() -> date:
    return _now_for_user_timezone().date()


def _company_shop_is_open(company: Company, today: date) -> bool:
    return bool(getattr(company, "shop_is_open", False)) and getattr(company, "shop_open_date", None) == today


def _normalize_shop_state(company: Company, today: date) -> bool:
    """
    Treat shop state as day-scoped:
    - If shop was opened on a previous day, auto-close it.
    Returns the effective open state for today.
    """
    if bool(getattr(company, "shop_is_open", False)) and getattr(company, "shop_open_date", None) != today:
        company.shop_is_open = False
        db.session.commit()
        return False
    return _company_shop_is_open(company, today)


def _user_is_present(company_id: int, user_id: int, today: date) -> bool:
    row = Attendance.query.filter_by(company_id=company_id, user_id=user_id, date_ad=today).first()
    return bool(row and getattr(row, "is_present", False))


def _company_requires_shop_open(company: Company) -> bool:
    flag = getattr(company, "staff_login_requires_shop_open", True)
    return True if flag is None else bool(flag)


def _next_company_account_code(company_id: int) -> str:
    max_numeric = 0
    existing_codes = (
        db.session.query(Account.code)
        .filter(Account.company_id == company_id)
        .all()
    )
    for (raw_code,) in existing_codes:
        code = str(raw_code or "").strip()
        if code.isdigit():
            max_numeric = max(max_numeric, int(code))

    candidate = max_numeric + 1
    while True:
        generated = f"{candidate:03d}"
        exists = Account.query.filter_by(company_id=company_id, code=generated).first()
        if not exists:
            return generated
        candidate += 1


def company_required(roles: Optional[Iterable[str]] = None):
    def decorator(fn: Callable):
        @wraps(fn)
        def wrapper(*args, **kwargs):
            # Allow CORS preflight to pass without company context
            if request.method == "OPTIONS":
                return ("", 204)
            
            company_id = request.headers.get("X-Company-ID") or request.args.get("company_id")
            if not company_id:
                data = request.get_json(silent=True) or {}
                company_id = data.get("company_id")
            if not company_id:
                return jsonify({"error": "Company context required (X-Company-ID)"}), 400

            company = db.session.get(Company, company_id)
            if not company or not company.is_active:
                return jsonify({"error": "Company not found or inactive"}), 404

            membership = UserCompany.query.filter_by(user_id=g.current_user.id, company_id=company.id).first()
            if not membership and g.current_user.role not in ROLE_PLATFORM_ADMINS:
                return jsonify({"error": "Forbidden for this company"}), 403

            g.current_company = company
            if g.current_user.role in ROLE_PLATFORM_ADMINS:
                # Platform admins are global; do not downgrade by legacy per-company membership role.
                g.company_role = "superuser"
            elif membership and membership.role:
                g.company_role = membership.role
            else:
                g.company_role = g.current_user.role

            if roles:
                role = g.company_role
                allowed_roles = set(roles)
                # Platform admins bypass role checks
                if g.current_user.role not in ROLE_PLATFORM_ADMINS and role not in allowed_roles:
                    required_action_permission = _required_action_permission_for_request(request.path or "", request.method or "GET")
                    effective_permissions = set(_effective_permission_keys(g.current_user, company.id, g.company_role))
                    if not required_action_permission or required_action_permission not in effective_permissions:
                        return jsonify({"error": "Forbidden"}), 403

            # Section-level permission gate (read permission controls access to that section).
            required_permission = _required_section_permission_for_path(request.path or "")
            if required_permission and g.current_user.role not in ROLE_PLATFORM_ADMINS:
                effective_permissions = set(_effective_permission_keys(g.current_user, company.id, g.company_role))
                if required_permission not in effective_permissions:
                    return jsonify({"error": "Insufficient permissions", "required": required_permission}), 403

            # If shop is closed, block staff/salesman from accessing company-scoped routes (auto-logoff behavior).
            role_lc = (g.company_role or "").lower()
            if role_lc in {"staff", "salesman"} and _company_requires_shop_open(company):
                today = _today_ad()
                is_open = _normalize_shop_state(company, today)
                if not is_open:
                    return jsonify({"error": "Shop is closed"}), 403
                # Also require the user to be marked present
                if not _user_is_present(company.id, g.current_user.id, today):
                    return jsonify({"error": "Shop is closed"}), 403

            # update identity with company context
            _set_identity(current_app._get_current_object(), g.current_user, company, g.company_role)
            return fn(*args, **kwargs)

        return wrapper

    return decorator


def require_permission(permission_obj):
    """
    Decorator that checks if the current user has the required permission.
    Works with Flask-Principal and falls back to role-based checks.
    """
    def decorator(fn: Callable):
        @wraps(fn)
        def wrapper(*args, **kwargs):
            # Allow CORS preflight to pass without permission checks
            if request.method == "OPTIONS":
                return ("", 204)
            
            # Check if user is authenticated (should be called after @auth_required)
            if not hasattr(g, 'current_user') or not g.current_user:
                return jsonify({"error": "Authentication required"}), 401
            
            permission_name = next(
                (
                    getattr(need, "value", None)
                    for need in getattr(permission_obj, "needs", set())
                    if getattr(need, "value", None)
                ),
                None,
            )

            # Check Flask-Principal permissions
            if hasattr(g, 'identity') and g.identity:
                if permission_obj.can():
                    return fn(*args, **kwargs)
            
            # Fallback: Check effective role + user-grant permissions in selected company.
            if permission_name and hasattr(g, "current_company") and getattr(g, "company_role", None):
                effective = set(_effective_permission_keys(g.current_user, g.current_company.id, g.company_role))
                if permission_name in effective:
                    return fn(*args, **kwargs)

            # Fallback: platform admins
            if g.current_user.role in ROLE_PLATFORM_ADMINS:
                return fn(*args, **kwargs)
            
            # Final fallback: resolve from default role map loaded from registry.
            if permission_name and hasattr(g, "company_role") and g.company_role:
                role_name = str(g.company_role or "").strip().lower()
                fallback_permissions = set(DEFAULT_ROLE_PERMISSION_KEYS.get(role_name, []))
                if permission_name in fallback_permissions:
                    return fn(*args, **kwargs)
            
            return jsonify({"error": "Insufficient permissions"}), 403
        
        return wrapper
    return decorator


def issue_token(app: Flask, user: User) -> str:
    return serializer(app).dumps({"sub": user.id, "role": user.role})


def _client_ip() -> str:
    forwarded = request.headers.get("X-Forwarded-For", "")
    if forwarded:
        return forwarded.split(",")[0].strip()
    return request.remote_addr or "unknown"


def _set_identity(app: Flask, user: User, company: Company | None = None, company_role: str | None = None):
    """
    Broadcast a Flask-Principal identity for the authenticated user with role needs.
    This keeps existing auth intact but lets the permission system consume roles.
    """
    identity = Identity(user.id)
    identity.provides.add(UserNeed(user.id))
    if user.role:
        identity.provides.add(RoleNeed(user.role))
    for r in getattr(user, "roles", []) or []:
        if r and r.name:
            identity.provides.add(RoleNeed(r.name))
    if company:
        identity.provides.add(RoleNeed(f"company:{company.id}:{company_role or user.role}"))
    
    # Assign permissions based on user role
    _assign_permissions(identity, user, company_role)
    
    g.identity = identity
    identity_changed.send(app, identity=identity)


def _assign_permissions(identity, user: User, company_role: str | None = None):
    """
    Assign Flask-Principal permissions based on user role.
    """
    role_name = str(company_role or user.role or "").strip().lower()

    # Add role-default permissions from the registry-backed role map.
    for perm_name in DEFAULT_ROLE_PERMISSION_KEYS.get(role_name, []):
        identity.provides.add(RoleNeed(perm_name))

    # Platform admins always inherit superuser defaults from registry.
    if str(getattr(user, "role", "") or "").strip().lower() in ROLE_PLATFORM_ADMINS:
        for perm_name in DEFAULT_ROLE_PERMISSION_KEYS.get("superuser", []):
            identity.provides.add(RoleNeed(perm_name))


def _is_strong_password(password: str) -> bool:
    import re

    if len(password) < 10:
        return False
    patterns = [r"[A-Z]", r"[a-z]", r"[0-9]", r"[^A-Za-z0-9]"]
    return all(re.search(p, password) for p in patterns)


def _is_rate_limited(app: Flask, ip: str) -> bool:
    lock = _ip_locked(ip)
    if lock:
        return True
    window = app.config["LOGIN_WINDOW_SECONDS"]
    max_attempts = app.config["MAX_LOGIN_ATTEMPTS"]
    now = time()
    attempts = FAILED_LOGINS.get(ip, [])
    attempts = [t for t in attempts if now - t <= window]
    FAILED_LOGINS[ip] = attempts
    return len(attempts) >= max_attempts


def _record_failed_login(ip: str):
    FAILED_LOGINS.setdefault(ip, []).append(time())


def _reset_login_attempts(ip: str):
    FAILED_LOGINS.pop(ip, None)
    if hasattr(g, "current_user") and g.current_user:
        g.current_user.failed_attempts = 0
        g.current_user.locked_until = None
        db.session.commit()


def _ip_locked(ip: str):
    now = time()
    info = IP_LOCKS.get(ip)
    if not info:
        return None
    if info["until"] <= now:
        IP_LOCKS.pop(ip, None)
        return None
    return info


def _record_ip_lock(app: Flask, ip: str):
    now = time()
    info = IP_LOCKS.get(ip, {"factor": 1})
    factor = min(info.get("factor", 1) * 2, 16)
    lock_seconds = app.config["LOCKOUT_SECONDS"] * factor
    IP_LOCKS[ip] = {"until": now + lock_seconds, "factor": factor}
    FAILED_LOGINS[ip] = []
    return lock_seconds


def log_action(action: str, details: dict | None = None, company_id: int | None = None):
    try:
        entry = ActivityLog(
            action=action,
            details=details or {},
            company_id=company_id or getattr(g, "current_company", None).id if getattr(g, "current_company", None) else None,
            user_id=getattr(g, "current_user", None).id if getattr(g, "current_user", None) else None,
        )
        db.session.add(entry)
        db.session.commit()
    except Exception:
        db.session.rollback()


def send_company_email(
    company: Company,
    to_email: str,
    subject: str,
    body: str,
    attachments: list[dict] | None = None,
    log_outbox: bool = True,
):
    if not (company.email_host and company.email_port and company.email_username and company.email_password):
        raise RuntimeError("Mail settings missing for company")
    port = int(company.email_port or 0)
    outbox_entry: MailOutbox | None = None
    if log_outbox:
        outbox_entry = MailOutbox(
            company_id=company.id,
            to_email=to_email,
            subject=subject,
            body=body,
            status="queued",
            created_by_user_id=getattr(g, "current_user", None).id if getattr(g, "current_user", None) else None,
        )
        db.session.add(outbox_entry)
        db.session.commit()
    msg = EmailMessage()
    msg["Subject"] = subject
    msg["From"] = company.email_username
    msg["To"] = to_email
    msg.set_content(body)
    if attachments:
        for attachment in attachments:
            filename = attachment.get("filename") or "attachment"
            data = attachment.get("data") or b""
            maintype = attachment.get("maintype") or "application"
            subtype = attachment.get("subtype") or "octet-stream"
            msg.add_attachment(data, maintype=maintype, subtype=subtype, filename=filename)

    try:
        if company.email_use_ssl or port == 465:
            smtp_client: smtplib.SMTP = smtplib.SMTP_SSL(company.email_host, port, timeout=20)
        else:
            smtp_client = smtplib.SMTP(company.email_host, port, timeout=20)
        with smtp_client as smtp:
            smtp.ehlo()
            if company.email_use_tls and not company.email_use_ssl and port != 465:
                smtp.starttls()
                smtp.ehlo()
            smtp.login(company.email_username, company.email_password)
            smtp.send_message(msg)
        if outbox_entry:
            outbox_entry.status = "sent"
            outbox_entry.sent_at = datetime.utcnow()
            db.session.commit()
    except Exception as exc:
        if outbox_entry:
            outbox_entry.status = "failed"
            outbox_entry.error = str(exc)
            db.session.commit()
        raise


def _mail_provider_defaults(host: str | None, username: str | None) -> dict[str, str | int | bool] | None:
    host_lc = (host or "").lower()
    user_lc = (username or "").lower()
    if "gmail" in host_lc or user_lc.endswith("@gmail.com"):
        return {"imap_host": "imap.gmail.com", "imap_port": 993, "imap_use_ssl": True}
    if "yahoo" in host_lc or user_lc.endswith("@yahoo.com"):
        return {"imap_host": "imap.mail.yahoo.com", "imap_port": 993, "imap_use_ssl": True}
    return None


def _imap_connect(company: Company) -> imaplib.IMAP4:
    defaults = _mail_provider_defaults(company.email_host, company.email_username) or {}
    host = company.imap_host or defaults.get("imap_host")
    port = int(company.imap_port or defaults.get("imap_port") or 993)
    use_ssl = bool(company.imap_use_ssl) if company.imap_use_ssl is not None else bool(defaults.get("imap_use_ssl", True))
    if not host:
        raise RuntimeError("IMAP host is not configured")
    if use_ssl:
        imap = imaplib.IMAP4_SSL(host, port)
    else:
        imap = imaplib.IMAP4(host, port)
    imap.login(company.email_username or "", company.email_password or "")
    return imap


def _decode_header_value(value: str | None) -> str:
    if not value:
        return ""
    decoded = email_header.decode_header(value)
    parts: list[str] = []
    for item, charset in decoded:
        if isinstance(item, bytes):
            try:
                parts.append(item.decode(charset or "utf-8", errors="ignore"))
            except Exception:
                parts.append(item.decode("utf-8", errors="ignore"))
        else:
            parts.append(str(item))
    return "".join(parts).strip()


def _thread_id_from_message(msg) -> str:
    in_reply = (msg.get("In-Reply-To") or "").strip()
    if in_reply:
        return in_reply
    refs = (msg.get("References") or "").strip()
    if refs:
        return refs.split()[-1]
    subject = _decode_header_value(msg.get("Subject") or "")
    if subject:
        return hashlib.md5(subject.lower().encode("utf-8")).hexdigest()
    message_id = (msg.get("Message-ID") or "").strip()
    return message_id or hashlib.md5(str(msg).encode("utf-8")).hexdigest()


def _sync_mail_folder(company: Company, imap: imaplib.IMAP4, folder: str, imap_box: str, limit: int = 200) -> int:
    status, _ = imap.select(imap_box, readonly=True)
    if status != "OK":
        return 0
    status, data = imap.search(None, "ALL")
    if status != "OK":
        return 0
    message_ids = data[0].split()
    recent_ids = message_ids[-limit:] if len(message_ids) > limit else message_ids
    synced = 0
    for uid in reversed(recent_ids):
        status, msg_data = imap.fetch(uid, "(BODY.PEEK[HEADER] FLAGS)")
        if status != "OK" or not msg_data:
            continue
        raw_headers = None
        flags_text = ""
        for part in msg_data:
            if isinstance(part, tuple):
                raw_headers = part[1]
                flags_text = part[0].decode("utf-8", errors="ignore") if isinstance(part[0], bytes) else str(part[0])
        if not raw_headers:
            continue
        msg = email.message_from_bytes(raw_headers)
        message_id = (msg.get("Message-ID") or "").strip()
        if not message_id:
            continue
        subject = _decode_header_value(msg.get("Subject"))
        sender = _decode_header_value(msg.get("From"))
        recipients = _decode_header_value(msg.get("To"))
        is_read = "\\Seen" in flags_text
        try:
            received_at = email_utils.parsedate_to_datetime(msg.get("Date")) if msg.get("Date") else None
        except Exception:
            received_at = None
        thread_id = _thread_id_from_message(msg)
        snippet = subject or sender
        existing = MailMessage.query.filter_by(company_id=company.id, message_id=message_id).first()
        if existing:
            existing.folder = folder
            existing.thread_id = thread_id
            existing.subject = subject
            existing.sender = sender
            existing.recipients = recipients
            existing.received_at = received_at
            existing.snippet = snippet
            existing.is_read = is_read
        else:
            db.session.add(
                MailMessage(
                    company_id=company.id,
                    folder=folder,
                    message_id=message_id,
                    thread_id=thread_id,
                    subject=subject,
                    sender=sender,
                    recipients=recipients,
                    received_at=received_at,
                    snippet=snippet,
                    is_read=is_read,
                )
            )
        synced += 1
    db.session.commit()
    return synced


def sync_company_mail(company: Company, limit: int = 200) -> dict[str, int]:
    if not (company.email_username and company.email_password):
        raise RuntimeError("Mail credentials missing for company")
    imap = _imap_connect(company)
    try:
        totals = {"inbox": 0, "sent": 0}
        totals["inbox"] = _sync_mail_folder(company, imap, "inbox", "INBOX", limit)
        sent_box = "[Gmail]/Sent Mail" if "gmail" in (company.email_username or "").lower() else "Sent"
        totals["sent"] = _sync_mail_folder(company, imap, "sent", sent_box, limit)
        return totals
    finally:
        try:
            imap.logout()
        except Exception:
            pass



def register_routes(app: Flask) -> None:
    require_auth = auth_required(app)

    def _version_tuple(value: str | None) -> tuple[int, ...]:
        if not value:
            return (0,)
        parts = re.findall(r"\d+", str(value))
        if not parts:
            return (0,)
        return tuple(int(p) for p in parts)

    def _compare_versions(left: str, right: str) -> int:
        l = _version_tuple(left)
        r = _version_tuple(right)
        max_len = max(len(l), len(r))
        l = l + (0,) * (max_len - len(l))
        r = r + (0,) * (max_len - len(r))
        if l == r:
            return 0
        return 1 if l > r else -1

    def _db_is_vacant() -> tuple[bool, list[str]]:
        checks = [
            ("products", Product),
            ("customers", Customer),
            ("suppliers", Supplier),
            ("sales", Sale),
            ("sales_returns", SaleReturn),
            ("purchase_bills", PurchaseBill),
            ("purchase_orders", PurchaseOrder),
            ("inventory_batches", InventoryBatch),
            ("inventory_logs", InventoryLog),
            ("account_entries", AccountEntry),
        ]
        populated: list[str] = []
        for name, model in checks:
            try:
                has_row = db.session.query(model.id).limit(1).first() is not None
            except Exception:
                has_row = False
            if has_row:
                populated.append(name)
        return len(populated) == 0, populated

    @app.route("/health", methods=["GET"])
    def health():
        return jsonify({"status": "ok", "version": APP_VERSION})

    @app.route("/server/time", methods=["GET"])
    @require_auth
    def server_time():
        now = _now_for_user_timezone()
        return jsonify(
            {
                "iso": now.isoformat(),
                "date": now.strftime("%Y-%m-%d"),
                "time": now.strftime("%H:%M:%S"),
                "time_zone": str(getattr(g.current_user, "time_zone", None) or "UTC"),
            }
        )

    @app.route("/seed", methods=["POST"])
    @require_auth
    @company_required(["admin"])
    def seed():
        seed_data(g.current_company)
        return jsonify({"seeded": True}), 201

    # Payment modes
    @app.route("/payment-modes", methods=["GET"])
    @require_auth
    @company_required()
    def list_payment_modes():
        category = (request.args.get("category") or "").strip().lower()
        query = PaymentMode.query.filter_by(company_id=g.current_company.id, is_archived=False)
        if category in {"sales", "party"}:
            if category == "sales":
                query = query.filter(
                    or_(
                        PaymentMode.allow_sales.is_(True),
                        and_(PaymentMode.allow_sales.is_(None), db.func.lower(db.func.coalesce(PaymentMode.category, "sales")) == "sales"),
                    )
                )
            else:
                query = query.filter(
                    or_(
                        PaymentMode.allow_party.is_(True),
                        and_(PaymentMode.allow_party.is_(None), db.func.lower(db.func.coalesce(PaymentMode.category, "sales")) == "party"),
                    )
                )
        modes = query.order_by(PaymentMode.name.asc()).all()
        return jsonify([m.to_dict() for m in modes])

    @app.route("/payment-modes/default", methods=["GET"])
    @require_auth
    @company_required()
    def list_payment_methods():
        return jsonify({"methods": PAYMENT_METHODS})

    @app.route("/payment-modes", methods=["POST"])
    @require_auth
    @company_required(["admin", "manager"])
    def create_payment_mode():
        data = request.get_json() or {}
        name = data.get("name", "").strip()
        description = data.get("description", "")
        category = str(data.get("category", "sales") or "sales").strip().lower()
        if category not in ["sales", "party"]:
            return jsonify({"error": "Invalid category"}), 400
        allow_sales_in = data.get("allow_sales")
        allow_party_in = data.get("allow_party")
        if allow_sales_in is None and allow_party_in is None:
            allow_sales = category == "sales"
            allow_party = category == "party"
        else:
            allow_sales = bool(allow_sales_in)
            allow_party = bool(allow_party_in)
            if not allow_sales and not allow_party:
                return jsonify({"error": "Select at least one usage: Sales Payment or Party Payment"}), 400
            category = "party" if allow_party and not allow_sales else "sales"
        account_id = data.get("account_id")
        if not name:
            return jsonify({"error": "Name required"}), 400
        if account_id:
            try:
                account_id = int(account_id)
            except (TypeError, ValueError):
                return jsonify({"error": "Invalid account_id"}), 400
            account = Account.query.get(account_id)
            if not account or account.company_id != g.current_company.id:
                return jsonify({"error": "Account not found"}), 404
        exists = PaymentMode.query.filter_by(company_id=g.current_company.id, name=name).first()
        if exists:
            return jsonify({"error": "Payment mode exists"}), 400
        mode = PaymentMode(
            company_id=g.current_company.id,
            account_id=account_id,
            name=name,
            description=description,
            category=category,
            allow_sales=allow_sales,
            allow_party=allow_party,
            is_active=True,
        )
        db.session.add(mode)
        db.session.commit()
        log_action("payment_mode_created", {"id": mode.id, "name": mode.name}, g.current_company.id)
        return jsonify(mode.to_dict()), 201

    @app.route("/payment-modes/<int:mode_id>", methods=["PUT"])
    @require_auth
    @company_required(["admin", "manager"])
    def update_payment_mode(mode_id: int):
        mode = PaymentMode.query.get_or_404(mode_id)
        if mode.company_id != g.current_company.id:
            return jsonify({"error": "Forbidden"}), 403
        data = request.get_json() or {}
        if "name" in data:
            name = data.get("name", "").strip()
            if not name:
                return jsonify({"error": "Name required"}), 400
            exists = PaymentMode.query.filter_by(company_id=g.current_company.id, name=name).first()
            if exists and exists.id != mode.id:
                return jsonify({"error": "Name already used"}), 400
            mode.name = name
        if "description" in data:
            mode.description = data.get("description", "")
        if "category" in data and ("allow_sales" not in data and "allow_party" not in data):
            category = str(data.get("category") or "").lower()
            if category not in ["sales", "party"]:
                return jsonify({"error": "Invalid category"}), 400
            mode.category = category
            mode.allow_sales = category == "sales"
            mode.allow_party = category == "party"
        if "allow_sales" in data or "allow_party" in data:
            allow_sales = bool(data.get("allow_sales", mode.allow_sales))
            allow_party = bool(data.get("allow_party", mode.allow_party))
            if not allow_sales and not allow_party:
                return jsonify({"error": "Select at least one usage: Sales Payment or Party Payment"}), 400
            mode.allow_sales = allow_sales
            mode.allow_party = allow_party
            mode.category = "party" if allow_party and not allow_sales else "sales"
        if "account_id" in data:
            account_id = data.get("account_id")
            if account_id in [None, ""]:
                mode.account_id = None
            else:
                try:
                    account_id = int(account_id)
                except (TypeError, ValueError):
                    return jsonify({"error": "Invalid account_id"}), 400
                account = Account.query.get(account_id)
                if not account or account.company_id != g.current_company.id:
                    return jsonify({"error": "Account not found"}), 404
                mode.account_id = account_id
        if "is_active" in data:
            mode.is_active = bool(data.get("is_active"))
        if "is_archived" in data:
            mode.is_archived = bool(data.get("is_archived"))
        db.session.commit()
        log_action("payment_mode_updated", {"id": mode.id}, g.current_company.id)
        return jsonify(mode.to_dict())

    @app.route("/payment-modes/<int:mode_id>/archive", methods=["POST"])
    @require_auth
    @company_required(["admin", "manager"])
    def archive_payment_mode(mode_id: int):
        mode = PaymentMode.query.get_or_404(mode_id)
        if mode.company_id != g.current_company.id:
            return jsonify({"error": "Forbidden"}), 403
        mode.is_archived = True
        db.session.commit()
        log_action("payment_mode_archived", {"id": mode.id}, g.current_company.id)
        return jsonify(mode.to_dict())

    def _handle_login_prechecks(app: Flask, username: str):
        """Helper to handle rate limiting and basic user checks for login routes."""
        client_ip = _client_ip()

        lock = _ip_locked(client_ip)
        if lock:
            wait = int(lock["until"] - time())
            return jsonify({"error": f"Too many attempts. Try again in {wait} seconds."}), 429, None, client_ip

        if _is_rate_limited(app, client_ip):
            seconds = _record_ip_lock(app, client_ip)
            return jsonify({"error": f"Too many attempts. Locked for {int(seconds)} seconds."}), 429, None, client_ip

        user = User.query.filter_by(username=username).first()
        if not user or user.is_active is False:
            _record_failed_login(client_ip)
            return jsonify({"error": "Invalid credentials"}), 401, None, client_ip

        if user.locked_until and user.locked_until > datetime.now(timezone.utc):
            return jsonify({"error": "Account locked. Try again later."}), 403, user, client_ip

        return None, None, user, client_ip

    def _today_ad() -> date:
        return _now_for_user_timezone().date()

    def _company_shop_is_open(company: Company, today: date) -> bool:
        return bool(getattr(company, "shop_is_open", False)) and getattr(company, "shop_open_date", None) == today

    def _normalize_shop_state(company: Company, today: date) -> bool:
        """
        Treat shop state as day-scoped:
        - If shop was opened on a previous day, auto-close it.
        Returns the effective open state for today.
        """
        if bool(getattr(company, "shop_is_open", False)) and getattr(company, "shop_open_date", None) != today:
            company.shop_is_open = False
            db.session.commit()
            return False
        return _company_shop_is_open(company, today)

    def _user_is_present(company_id: int, user_id: int, today: date) -> bool:
        row = Attendance.query.filter_by(company_id=company_id, user_id=user_id, date_ad=today).first()
        return bool(row and getattr(row, "is_present", False))

    def _company_requires_shop_open(company: Company) -> bool:
        flag = getattr(company, "staff_login_requires_shop_open", True)
        return True if flag is None else bool(flag)

    def _handle_login_prechecks_user(app: Flask, user: User | None):
        """Rate limiting + lock checks for login flows that already resolved the user."""
        client_ip = _client_ip()
        lock = _ip_locked(client_ip)
        if lock:
            wait = int(lock["until"] - time())
            return jsonify({"error": f"Too many attempts. Try again in {wait} seconds."}), 429, None, client_ip

        if _is_rate_limited(app, client_ip):
            seconds = _record_ip_lock(app, client_ip)
            return jsonify({"error": f"Too many attempts. Locked for {int(seconds)} seconds."}), 429, None, client_ip

        if not user or user.is_active is False:
            _record_failed_login(client_ip)
            return jsonify({"error": "Invalid credentials"}), 401, None, client_ip

        if user.locked_until and user.locked_until > datetime.now(timezone.utc):
            return jsonify({"error": "Account locked. Try again later."}), 403, user, client_ip

        return None, None, user, client_ip

    def _build_login_response(app: Flask, user: User, client_ip: str):
        user.failed_attempts = 0
        user.locked_until = None
        db.session.commit()
        _reset_login_attempts(client_ip)

        # Gate staff/salesman login by shop open + attendance
        user_companies = UserCompany.query.filter_by(user_id=user.id).all()
        today = _today_ad()
        has_manager_membership = any(
            (uc.role or "").lower() == "manager" and uc.company and uc.company.is_active is not False for uc in user_companies
        )
        is_privileged = user.role in ROLE_PLATFORM_ADMINS or user.role == "manager" or has_manager_membership
        if not is_privileged:
            active_memberships = [uc for uc in user_companies if uc.company and uc.company.is_active is not False]
            any_shop_open = False
            allowed_memberships: list[UserCompany] = []
            for uc in active_memberships:
                company = uc.company
                if not company:
                    continue
                if not _company_requires_shop_open(company):
                    allowed_memberships.append(uc)
                    any_shop_open = True
                    continue
                is_open_today = _normalize_shop_state(company, today)
                if not is_open_today:
                    continue
                any_shop_open = True
                if _user_is_present(company.id, user.id, today):
                    allowed_memberships.append(uc)

            if not allowed_memberships:
                if not any_shop_open:
                    return jsonify({"error": "Shop is not open yet. Ask the manager to open the shop."}), 403
                return jsonify({"error": "You are marked absent today. Ask the manager to mark you present."}), 403

            user_companies = allowed_memberships

        token = issue_token(app, user)

        # Get user companies for frontend compatibility
        companies = []
        if user.role in ROLE_PLATFORM_ADMINS:
            # Platform admins can switch across all active companies (treat NULL as active for legacy rows)
            all_active = (
                Company.query.filter(or_(Company.is_active.is_(True), Company.is_active.is_(None)))
                .order_by(Company.name.asc())
                .all()
            )
            for c in all_active:
                company_role = "superuser"
                perms = _effective_permission_keys(user, c.id, company_role)
                companies.append(
                    {
                        "id": str(c.id),
                        "name": c.name,
                        "company_id": str(c.id),
                        "company_type": getattr(c, "company_type", None) or "pharmacy",
                        "role": company_role,
                        "permissions": perms,
                    }
                )
            primary_company = all_active[0] if all_active else None
        else:
            for uc in user_companies:
                if not uc.company or uc.company.is_active is False:
                    continue
                company_role = uc.role
                perms = _effective_permission_keys(user, uc.company.id, company_role)
                companies.append(
                    {
                        "id": str(uc.company.id),
                        "name": uc.company.name,
                        "company_id": str(uc.company.id),
                        "company_type": getattr(uc.company, "company_type", None) or "pharmacy",
                        "role": company_role,
                        "permissions": perms,
                    }
                )
            # If the user's company role is restricted to a single company, keep only one even if legacy data has more.
            restricted_roles = {"manager", "salesman"}
            restricted = [c for c in companies if str(c.get("role") or "").lower() in restricted_roles]
            if restricted:
                companies = restricted[:1]
                selected_company_id = int(companies[0]["company_id"])
                primary_company = next(
                    (uc.company for uc in user_companies if uc.company and uc.company.id == selected_company_id),
                    None,
                )
            else:
                primary_company = next((uc.company for uc in user_companies if uc.company and uc.company.is_active is not False), None)

        # Get role and company information
        role = Role.query.filter_by(name=user.role).first()

        # Format user data for frontend compatibility
        user_data = {
            "id": str(user.id),
            "username": user.username,
            "email": user.email or "",
            "role": user.role,
            "company_id": str(primary_company.id) if primary_company else None,
            "company_name": primary_company.name if primary_company else None,
            "company_type": getattr(primary_company, "company_type", None) or "pharmacy" if primary_company else "pharmacy",
            "is_active": user.is_active,
            "created_at": user.created_at.isoformat() if user.created_at else None,
            "last_login": datetime.now(timezone.utc).isoformat(),
            "roles": [r.name for r in user.roles] if user.roles else [],
            "role_name": role.name if role else None,
            "role_description": role.description if role else None,
            "company_name": primary_company.name if primary_company else None,
        }

        return jsonify({"token": token, "user": user_data, "companies": companies})

    def _webauthn_origin() -> str:
        origin = (request.headers.get("Origin") or "").strip()
        if origin:
            return origin
        return request.host_url.rstrip("/")

    def _webauthn_rp_id() -> str:
        host = request.host.split(":", 1)[0]
        return host

    def _record_webauthn_challenge(challenge: bytes, challenge_type: str, user_id: int | None = None) -> WebAuthnChallenge:
        row = WebAuthnChallenge(
            user_id=user_id,
            challenge=bytes_to_base64url(challenge),
            challenge_type=challenge_type,
        )
        db.session.add(row)
        db.session.commit()
        return row

    def _consume_webauthn_challenge(challenge_id: int, challenge_type: str, user_id: int | None = None) -> WebAuthnChallenge | None:
        row = WebAuthnChallenge.query.get(challenge_id)
        if not row or row.challenge_type != challenge_type:
            return None
        if user_id is not None and row.user_id is not None and row.user_id != user_id:
            return None
        # expire after 10 minutes
        if row.created_at and row.created_at < datetime.now(timezone.utc) - timedelta(minutes=10):
            db.session.delete(row)
            db.session.commit()
            return None
        db.session.delete(row)
        db.session.commit()
        return row

    # Auth
    @app.route("/auth/login", methods=["POST"])
    def login():
        data = request.get_json() or {}
        username = data.get("username", "").strip()
        password = data.get("password", "")
        challenge_id = data.get("challenge_id")
        credential = data.get("credential")
        require_passkey = bool(data.get("require_passkey"))
        client_ip = _client_ip()
        if not username or not password:
            return jsonify({"error": "Username and password required"}), 400

        error_response, status_code, user, client_ip = _handle_login_prechecks(app, username)
        if error_response:
            return error_response, status_code

        if not user.check_password(password):
            _record_failed_login(client_ip)
            user.failed_attempts = (user.failed_attempts or 0) + 1
            max_user_attempts = app.config["MAX_USER_ATTEMPTS"]
            if user.failed_attempts >= max_user_attempts:
                lock_seconds = app.config["LOCKOUT_SECONDS"]
                user.locked_until = datetime.now(timezone.utc) + timedelta(seconds=lock_seconds)
                db.session.commit()
                _record_ip_lock(app, client_ip)
                return jsonify({"error": "Account locked due to failures."}), 403
            db.session.commit()
            return jsonify({"error": "Invalid credentials"}), 401

        # Optional step-up auth: require passkey/fingerprint along with password.
        if require_passkey or challenge_id or credential:
            if not challenge_id or not credential:
                return jsonify({"error": "Passkey challenge and credential required"}), 400

            try:
                challenge_row = _consume_webauthn_challenge(int(challenge_id), "login", user.id)
            except Exception:
                challenge_row = None
            if not challenge_row:
                return jsonify({"error": "Passkey challenge expired or invalid"}), 400

            cred_id = credential.get("id") or credential.get("rawId")
            if not cred_id:
                return jsonify({"error": "Credential id missing"}), 400

            stored = WebAuthnCredential.query.filter_by(credential_id=cred_id).first()
            if not stored or stored.user_id != user.id:
                _record_failed_login(client_ip)
                return jsonify({"error": "Passkey not registered for this username"}), 401

            try:
                verification = verify_authentication_response(
                    credential=AuthenticationCredential.parse_raw(json.dumps(credential)),
                    expected_challenge=base64url_to_bytes(challenge_row.challenge),
                    expected_origin=_webauthn_origin(),
                    expected_rp_id=_webauthn_rp_id(),
                    credential_public_key=base64url_to_bytes(stored.public_key),
                    credential_current_sign_count=int(stored.sign_count or 0),
                    require_user_verification=True,
                )
            except Exception as exc:
                _record_failed_login(client_ip)
                return jsonify({"error": f"Passkey authentication failed: {exc}"}), 401

            stored.sign_count = int(verification.new_sign_count or 0)
            stored.last_used_at = datetime.now(timezone.utc)
            db.session.commit()

        return _build_login_response(app, user, client_ip)

    @app.route("/auth/webauthn/register/options", methods=["POST"])
    @require_auth
    def webauthn_register_options():
        user = getattr(g, "current_user", None)
        if not user:
            return jsonify({"error": "Authentication required"}), 401

        rp_id = _webauthn_rp_id()
        creds = WebAuthnCredential.query.filter_by(user_id=user.id).all()
        exclude = [
            PublicKeyCredentialDescriptor(id=base64url_to_bytes(c.credential_id), type="public-key")
            for c in creds
        ]
        options = generate_registration_options(
            rp_id=rp_id,
            rp_name="Pharmacy Management System",
            user_id=str(user.id).encode(),
            user_name=user.username,
            user_display_name=user.username,
            attestation=AttestationConveyancePreference.NONE,
            authenticator_selection=AuthenticatorSelectionCriteria(
                authenticator_attachment="platform",
                resident_key="preferred",
                user_verification=UserVerificationRequirement.REQUIRED,
            ),
            exclude_credentials=exclude,
            timeout=60000,
        )
        challenge_row = _record_webauthn_challenge(options.challenge, "register", user.id)
        return jsonify({"publicKey": json.loads(options_to_json(options)), "challenge_id": challenge_row.id})

    @app.route("/auth/webauthn/register/verify", methods=["POST"])
    @require_auth
    def webauthn_register_verify():
        user = getattr(g, "current_user", None)
        if not user:
            return jsonify({"error": "Authentication required"}), 401
        data = request.get_json() or {}
        challenge_id = data.get("challenge_id")
        credential = data.get("credential")
        nickname = str(data.get("nickname") or "").strip() or None
        device_platform = str(data.get("device_platform") or "").strip().lower() or None
        device_name = str(data.get("device_name") or "").strip() or None
        if not challenge_id or not credential:
            return jsonify({"error": "Missing challenge or credential"}), 400

        challenge_row = _consume_webauthn_challenge(int(challenge_id), "register", user.id)
        if not challenge_row:
            return jsonify({"error": "Challenge expired or invalid"}), 400

        try:
            expected_origin = _webauthn_origin()
            rp_id = _webauthn_rp_id()
            verification = verify_registration_response(
                credential=RegistrationCredential.parse_raw(json.dumps(credential)),
                expected_challenge=base64url_to_bytes(challenge_row.challenge),
                expected_origin=expected_origin,
                expected_rp_id=rp_id,
                require_user_verification=True,
            )
        except Exception as exc:
            return jsonify({"error": f"Registration failed: {exc}"}), 400

        cred_id = bytes_to_base64url(verification.credential_id)
        existing = WebAuthnCredential.query.filter_by(credential_id=cred_id).first()
        if existing:
            return jsonify({"error": "Credential already registered"}), 409

        new_cred = WebAuthnCredential(
            user_id=user.id,
            credential_id=cred_id,
            public_key=bytes_to_base64url(verification.credential_public_key),
            sign_count=int(verification.sign_count or 0),
            transports=",".join(credential.get("transports", []) or []),
            nickname=nickname,
            device_platform=device_platform,
            device_name=device_name,
        )
        db.session.add(new_cred)
        db.session.commit()
        return jsonify({"status": "ok", "credential": new_cred.to_dict()})

    @app.route("/auth/webauthn/login/options", methods=["POST"])
    def webauthn_login_options():
        data = request.get_json(silent=True) or {}
        username = (data.get("username") or "").strip()
        user_for_challenge = None
        allow_credentials = []
        if username:
            user_for_challenge = User.query.filter_by(username=username).first()
            if not user_for_challenge or user_for_challenge.is_active is False:
                return jsonify({"error": "Fingerprint login is not available for this username"}), 400
            user_creds = WebAuthnCredential.query.filter_by(user_id=user_for_challenge.id).all()
            if not user_creds:
                return jsonify({"error": "Fingerprint login is not available for this username"}), 400
            allow_credentials = [
                PublicKeyCredentialDescriptor(id=base64url_to_bytes(c.credential_id), type="public-key")
                for c in user_creds
            ]

        option_kwargs = {
            "rp_id": _webauthn_rp_id(),
            "user_verification": UserVerificationRequirement.REQUIRED,
            "timeout": 60000,
        }
        if allow_credentials:
            option_kwargs["allow_credentials"] = allow_credentials

        options = generate_authentication_options(**option_kwargs)
        challenge_row = _record_webauthn_challenge(
            options.challenge, "login", user_for_challenge.id if user_for_challenge else None
        )
        return jsonify({"publicKey": json.loads(options_to_json(options)), "challenge_id": challenge_row.id})

    @app.route("/auth/webauthn/login/verify", methods=["POST"])
    def webauthn_login_verify():
        data = request.get_json() or {}
        challenge_id = data.get("challenge_id")
        credential = data.get("credential")
        username = (data.get("username") or "").strip()
        if not challenge_id or not credential:
            return jsonify({"error": "Missing challenge or credential"}), 400

        challenge_row = _consume_webauthn_challenge(int(challenge_id), "login")
        if not challenge_row:
            return jsonify({"error": "Challenge expired or invalid"}), 400

        expected_user = None
        if username:
            expected_user = User.query.filter_by(username=username).first()
            if not expected_user or expected_user.is_active is False:
                return jsonify({"error": "Invalid credentials"}), 401

        cred_id = credential.get("id") or credential.get("rawId")
        if not cred_id:
            return jsonify({"error": "Credential id missing"}), 400
        stored = WebAuthnCredential.query.filter_by(credential_id=cred_id).first()
        if not stored:
            return jsonify({"error": "Credential not registered"}), 401

        if challenge_row.user_id and challenge_row.user_id != stored.user_id:
            return jsonify({"error": "Passkey does not match this login request"}), 401
        if expected_user and expected_user.id != stored.user_id:
            return jsonify({"error": "Passkey not registered for this username"}), 401

        user = expected_user or User.query.get(stored.user_id)
        error_response, status_code, user, client_ip = _handle_login_prechecks_user(app, user)
        if error_response:
            return error_response, status_code

        try:
            verification = verify_authentication_response(
                credential=AuthenticationCredential.parse_raw(json.dumps(credential)),
                expected_challenge=base64url_to_bytes(challenge_row.challenge),
                expected_origin=_webauthn_origin(),
                expected_rp_id=_webauthn_rp_id(),
                credential_public_key=base64url_to_bytes(stored.public_key),
                credential_current_sign_count=int(stored.sign_count or 0),
                require_user_verification=True,
            )
        except Exception as exc:
            _record_failed_login(client_ip)
            return jsonify({"error": f"Authentication failed: {exc}"}), 401

        stored.sign_count = int(verification.new_sign_count or 0)
        stored.last_used_at = datetime.now(timezone.utc)
        db.session.commit()
        return _build_login_response(app, user, client_ip)

    @app.route("/auth/webauthn/credentials", methods=["GET"])
    @require_auth
    def webauthn_credentials_list():
        user = getattr(g, "current_user", None)
        if not user:
            return jsonify({"error": "Authentication required"}), 401
        credentials = (
            WebAuthnCredential.query.filter_by(user_id=user.id)
            .order_by(WebAuthnCredential.created_at.desc().nullslast(), WebAuthnCredential.id.desc())
            .all()
        )
        return jsonify([cred.to_dict() for cred in credentials])

    @app.route("/auth/webauthn/credentials/<int:credential_id>", methods=["DELETE"])
    @require_auth
    def webauthn_delete_credential(credential_id: int):
        user = getattr(g, "current_user", None)
        if not user:
            return jsonify({"error": "Authentication required"}), 401
        cred = WebAuthnCredential.query.get_or_404(credential_id)
        if cred.user_id != user.id:
            return jsonify({"error": "Forbidden"}), 403
        db.session.delete(cred)
        db.session.commit()
        return jsonify({"status": "ok"})

    @app.route("/auth/thumbprint", methods=["POST"])
    def thumbprint_login():
        data = request.get_json() or {}
        username = data.get("username", "").strip()
        fingerprint_token = data.get("fingerprint_token", "")
        if not username or not fingerprint_token:
            return jsonify({"error": "Username and thumbprint token required"}), 400

        error_response, status_code, user, client_ip = _handle_login_prechecks(app, username)
        if error_response:
            return error_response, status_code

        if not user.fingerprint_template:
            return jsonify({"error": "No thumbprint registered for this user"}), 400

        if not hmac.compare_digest(str(user.fingerprint_template), str(fingerprint_token)):
            _record_failed_login(client_ip)
            return jsonify({"error": "Thumbprint mismatch"}), 401

        user.failed_attempts = 0
        user.locked_until = None
        db.session.commit()
        _reset_login_attempts(client_ip)
        token = issue_token(app, user)
        if user.role in ROLE_PLATFORM_ADMINS:
            companies = []
            all_active = (
                Company.query.filter(or_(Company.is_active.is_(True), Company.is_active.is_(None)))
                .order_by(Company.name.asc())
                .all()
            )
            for c in all_active:
                companies.append(
                    {
                        "id": str(c.id),
                        "name": c.name,
                        "company_id": str(c.id),
                        "role": "superuser",
                        "permissions": _effective_permission_keys(user, c.id, "superuser"),
                    }
                )
        else:
            companies = [uc.to_dict() for uc in UserCompany.query.filter_by(user_id=user.id).all()]
        return jsonify({"token": token, "user": user.to_dict(), "companies": companies})

    @app.route("/auth/signup", methods=["POST"])
    def signup():
        data = request.get_json() or {}
        username = data.get("username", "").strip()
        password = data.get("password", "")
        email = data.get("email", "").strip() or None
        company_name = data.get("company_name", "").strip()
        company_address = data.get("company_address", "").strip()
        time_zone = data.get("time_zone", "UTC")

        if not username or not password or not company_name:
            return jsonify({"error": "Username, password, and company name are required"}), 400
        if User.query.filter_by(username=username).first():
            return jsonify({"error": "Username already exists"}), 400
        if not _is_strong_password(password):
            return jsonify({"error": "Weak password. Use 10+ chars with upper, lower, digit, and symbol."}), 400

        company = Company.query.filter_by(name=company_name).first()
        if not company:
            company = Company(
                name=company_name,
                address=company_address,
                company_type=normalize_company_type(data.get("company_type")),
            )
            db.session.add(company)
            db.session.commit()
        _ensure_default_company_ledgers(company.id)
        db.session.commit()

        user = User(username=username, role="superuser", email=email, time_zone=time_zone or "UTC")
        user.set_password(password)
        db.session.add(user)
        db.session.commit()

        staff_role = Role.query.filter_by(name="superuser").first()
        if not staff_role:
            staff_role = Role(name="superuser", description="Platform admin")
            db.session.add(staff_role)
            db.session.commit()
        if staff_role not in user.roles:
            user.roles.append(staff_role)
            db.session.commit()

        membership = UserCompany.query.filter_by(user_id=user.id, company_id=company.id).first()
        if not membership:
            db.session.add(UserCompany(user=user, company=company, role="superuser"))
            db.session.commit()

        token = issue_token(app, user)
        if user.role in ROLE_PLATFORM_ADMINS:
            companies = []
            all_active = (
                Company.query.filter(or_(Company.is_active.is_(True), Company.is_active.is_(None)))
                .order_by(Company.name.asc())
                .all()
            )
            for c in all_active:
                companies.append(
                    {
                        "id": str(c.id),
                        "name": c.name,
                        "company_id": str(c.id),
                        "role": "superuser",
                        "permissions": _effective_permission_keys(user, c.id, "superuser"),
                    }
                )
        else:
            companies = [uc.to_dict() for uc in UserCompany.query.filter_by(user_id=user.id).all()]
        log_action("user_signed_up", {"id": user.id, "username": user.username}, company.id)
        return jsonify({"token": token, "user": user.to_dict(), "companies": companies}), 201

    @app.route("/auth/forgot", methods=["POST"])
    def forgot_password():
        data = request.get_json() or {}
        username = (data.get("username") or data.get("identifier") or "").strip()
        email = (data.get("email") or "").strip()
        if not username and not email:
            return jsonify({"error": "Username or email required"}), 400
        user = User.query.filter_by(username=username).first() if username else None
        if not user and email:
            user = User.query.filter_by(email=email).first()
        if not user or not user.email:
            return jsonify({"error": "User not found or missing email"}), 404
        # pick first company with email settings
        memberships = UserCompany.query.filter_by(user_id=user.id).all()
        company = None
        for m in memberships:
            if m.company and m.company.email_host:
                company = m.company
                break
        if not company:
            return jsonify({"error": "No company mail configured for this user"}), 400

        token = reset_serializer(app).dumps({"sub": user.id, "company_id": company.id})
        reset_url = data.get("reset_url") or os.getenv("FRONTEND_RESET_URL", "http://localhost:5173/reset")
        link = f"{reset_url}?token={token}"
        try:
            send_company_email(
                company,
                user.email,
                "Password reset link",
                f"Hello {user.username},\n\nUse this link to reset your password (one-time, expires in 15 minutes):\n{link}\n\nIf you did not request this, ignore this email.",
            )
        except Exception as exc:
            return jsonify({"error": f"Failed to send email: {exc}"}), 500
        return jsonify({"sent": True})

    @app.route("/auth/reset", methods=["POST"])
    def reset_password():
        data = request.get_json() or {}
        token = data.get("token", "")
        new_password = data.get("password", "")
        if not token or not new_password:
            return jsonify({"error": "Token and password required"}), 400
        if not _is_strong_password(new_password):
            return jsonify({"error": "Weak password. Use 10+ chars with upper, lower, digit, and symbol."}), 400
        try:
            payload = reset_serializer(app).loads(token, max_age=app.config["RESET_TOKEN_AGE"])
        except SignatureExpired:
            return jsonify({"error": "Token expired"}), 400
        except BadSignature:
            return jsonify({"error": "Invalid token"}), 400
        user = db.session.get(User, payload.get("sub"))
        if not user:
            return jsonify({"error": "User not found"}), 404
        user.set_password(new_password)
        user.failed_attempts = 0
        user.locked_until = None
        db.session.commit()
        return jsonify({"reset": True})

    @app.route("/me/profile", methods=["GET"])
    @require_auth
    def get_profile():
        profile = g.current_user.to_dict()
        company = getattr(g, "current_company", None)
        company_role = None
        if company:
            if g.current_user.role in ROLE_PLATFORM_ADMINS:
                company_role = "superuser"
            else:
                membership = UserCompany.query.filter_by(user_id=g.current_user.id, company_id=company.id).first()
                company_role = membership.role if membership else None
        else:
            if g.current_user.role in ROLE_PLATFORM_ADMINS:
                requested_company_id = request.headers.get("X-Company-ID") or request.args.get("company_id")
                if requested_company_id:
                    company = db.session.get(Company, requested_company_id)
                if not company:
                    company = (
                        Company.query.filter(or_(Company.is_active.is_(True), Company.is_active.is_(None)))
                        .order_by(Company.name.asc())
                        .first()
                    )
                company_role = "superuser" if company else None
            else:
                membership = UserCompany.query.filter_by(user_id=g.current_user.id).first()
                company = membership.company if membership else None
                company_role = membership.role if membership else None
        if company:
            profile["company_id"] = company.id
            profile["company_name"] = company.name
            profile["company_role"] = company_role
        return jsonify(profile)

    @app.route("/me/profile", methods=["PUT"])
    @require_auth
    def update_profile():
        data = request.get_json() or {}
        if "username" in data:
            new_username = str(data.get("username") or "").strip()
            if not new_username:
                return jsonify({"error": "Username is required"}), 400
            if len(new_username) < 3:
                return jsonify({"error": "Username must be at least 3 characters"}), 400
            if new_username != g.current_user.username:
                exists = User.query.filter(User.username == new_username, User.id != g.current_user.id).first()
                if exists:
                    return jsonify({"error": "Username is already taken"}), 400
                g.current_user.username = new_username
        allowed_fields = [
            "first_name",
            "last_name",
            "address",
            "phone",
            "whatsapp",
            "email",
            "avatar_url",
            "signature_url",
            "time_zone",
            "fingerprint_template",
            "role",
        ]
        for field in allowed_fields:
            if field == "fingerprint_template" and field in data:
                fingerprint_template = str(data.get("fingerprint_template") or "").strip()
                if len(fingerprint_template) > 1024:
                    return jsonify({"error": "Fingerprint token is too long"}), 400
                setattr(g.current_user, field, fingerprint_template)
            elif field in data:
                setattr(g.current_user, field, data[field])
        db.session.commit()
        return jsonify(g.current_user.to_dict())

    @app.route("/me/password", methods=["POST"])
    @require_auth
    def change_password():
        data = request.get_json() or {}
        current = data.get("current_password", "")
        new_password = data.get("new_password", "")
        if not current or not new_password:
            return jsonify({"error": "Current and new password required"}), 400
        if not g.current_user.check_password(current):
            return jsonify({"error": "Current password incorrect"}), 400
        if not _is_strong_password(new_password):
            return jsonify({"error": "Weak password. Use 10+ chars with upper, lower, digit, and symbol."}), 400
        g.current_user.set_password(new_password)
        g.current_user.failed_attempts = 0
        g.current_user.locked_until = None
        db.session.commit()
        return jsonify({"changed": True})

    @app.route("/auth/register", methods=["POST"])
    @require_auth
    def register_user():
        data = request.get_json() or {}
        username = data.get("username", "").strip()
        password = data.get("password", "")
        role = (data.get("role") or "staff").strip().lower() or "staff"
        company_id = data.get("company_id")
        email = data.get("email")
        time_zone = data.get("time_zone", "UTC")
        fingerprint_template = data.get("fingerprint_template", "")

        if not username or not password:
            return jsonify({"error": "Username and password required"}), 400
        if not _is_strong_password(password):
            return jsonify(
                {"error": "Weak password. Use 10+ chars with upper, lower, digit, and symbol."}
            ), 400

        creator_role = g.current_user.role
        if creator_role not in ROLE_COMPANY_ADMINS:
            return jsonify({"error": "Only admins or managers can create users"}), 403

        role_obj = Role.query.filter_by(name=role).first()
        if not role_obj:
            return jsonify({"error": "Unknown role"}), 400

        is_platform_admin = creator_role in ROLE_PLATFORM_ADMINS
        if not is_platform_admin and role in {"superuser", "admin", "manager"}:
            return jsonify({"error": "Only platform admin can create admins/managers/superusers"}), 403

        if role in {"manager", "salesman"} and not company_id:
            return jsonify({"error": "company_id is required for manager/salesman"}), 400

        company = None
        if company_id:
            company = db.session.get(Company, company_id)
            if not company or company.is_active is False:
                return jsonify({"error": "Company not found or inactive"}), 404
            if not is_platform_admin:
                membership = UserCompany.query.filter_by(user_id=g.current_user.id, company_id=company.id).first()
                if not membership or membership.role not in {"admin", "manager"}:
                    return jsonify({"error": "Only company admins can create users"}), 403

        if User.query.filter_by(username=username).first():
            return jsonify({"error": "Username already exists"}), 400

        user = User(
            username=username,
            role=role,
            email=email,
            time_zone=time_zone or "UTC",
            fingerprint_template=fingerprint_template,
        )
        user.set_password(password)
        db.session.add(user)
        db.session.commit()
        if role_obj not in user.roles:
            user.roles.append(role_obj)
            db.session.commit()

        if company:
            db.session.add(UserCompany(user=user, company=company, role=role))
            db.session.commit()

        log_action("user_created", {"id": user.id, "username": user.username, "role": user.role}, company_id)
        return jsonify(user.to_dict()), 201

    @app.route("/auth/me", methods=["GET"])
    @require_auth
    def me():
        companies = [uc.to_dict() for uc in UserCompany.query.filter_by(user_id=g.current_user.id).all()]
        return jsonify({"user": g.current_user.to_dict(), "companies": companies})

    @app.route("/companies/<int:company_id>", methods=["GET"])
    @require_auth
    def get_company(company_id: int):
        company = Company.query.get_or_404(company_id)
        # allow platform admins, or members of the company
        if g.current_user.role not in ROLE_PLATFORM_ADMINS:
            membership = UserCompany.query.filter_by(user_id=g.current_user.id, company_id=company_id).first()
            if not membership:
                return jsonify({"error": "Forbidden"}), 403
        data = company.to_dict()
        data["logo_data"] = company.logo_data or ""
        return jsonify(data)

    @app.route("/companies", methods=["GET"])
    @require_auth
    def list_companies_for_user():
        """
        Returns companies accessible to the logged-in user.
        - Platform admins: all companies (active only for context switching)
        - Others: only linked active companies via UserCompany
        Matches frontend expectations (id, name, company_id, role).
        """
        if g.current_user.role in ROLE_PLATFORM_ADMINS:
            rows = (
                Company.query.filter(or_(Company.is_active.is_(True), Company.is_active.is_(None)))
                .order_by(Company.name.asc())
                .all()
            )
            payload = []
            for c in rows:
                item = c.to_dict()
                item["company_id"] = c.id
                company_role = "superuser"
                item["role"] = company_role
                item["permissions"] = _effective_permission_keys(g.current_user, c.id, company_role)
                payload.append(item)
            return jsonify(payload)

        memberships = (
            UserCompany.query.filter_by(user_id=g.current_user.id)
            .join(Company, Company.id == UserCompany.company_id)
            .filter(or_(Company.is_active.is_(True), Company.is_active.is_(None)))
            .order_by(Company.name.asc())
            .all()
        )
        payload = []
        restricted_roles = {"manager", "salesman"}
        has_restricted = any((m.role or "").lower() in restricted_roles for m in memberships)
        for m in memberships:
            if not m.company:
                continue
            if has_restricted and (m.role or "").lower() not in restricted_roles:
                continue
            item = m.company.to_dict()
            item["company_id"] = m.company.id
            company_role = m.role
            item["role"] = company_role
            item["permissions"] = _effective_permission_keys(g.current_user, m.company.id, company_role)
            payload.append(item)
            if has_restricted and len(payload) >= 1:
                break
        return jsonify(payload)

    @app.route("/api/companies", methods=["GET"])
    def api_list_companies():
        companies = Company.query.filter(or_(Company.is_active.is_(True), Company.is_active.is_(None))).all()
        return jsonify([{"id": c.id, "name": c.name} for c in companies])

    @app.route("/admin/companies", methods=["GET"])
    @require_auth
    def list_all_companies():
        if g.current_user.role not in ROLE_PLATFORM_ADMINS:
            return jsonify({"error": "Only superadmin"}), 403
        companies = Company.query.order_by(Company.name.asc()).all()
        return jsonify([c.to_dict() for c in companies])

    # Roles management (superadmin)
    @app.route("/admin/roles", methods=["GET"])
    @require_auth
    def list_roles():
        if g.current_user.role not in ROLE_PLATFORM_ADMINS:
            role = (getattr(g, "company_role", None) or getattr(g.current_user, "role", "") or "").lower()
            if role not in {"admin", "manager"}:
                return jsonify({"error": "Only superadmin"}), 403
        roles = Role.query.order_by(Role.name.asc()).all()
        data = []
        for r in roles:
            user_count = User.query.filter(User.roles.any(Role.id == r.id)).count() + User.query.filter_by(role=r.name).count()
            perms = RolePermission.query.filter_by(role_id=r.id).all()
            perm_names = [p.section for p in perms]
            data.append({**r.to_dict(), "permissions": perm_names, "user_count": user_count, "is_active": True})
        return jsonify(data)

    @app.route("/admin/roles", methods=["POST"])
    @require_auth
    def create_role():
        if g.current_user.role not in ROLE_PLATFORM_ADMINS:
            return jsonify({"error": "Only superadmin"}), 403
        data = request.get_json() or {}
        name = (data.get("name") or "").strip()
        if not name:
            return jsonify({"error": "Name required"}), 400
        if Role.query.filter_by(name=name).first():
            return jsonify({"error": "Role exists"}), 400
        role = Role(name=name, description=data.get("description", ""))
        db.session.add(role)
        db.session.commit()
        perms = data.get("permissions") or []
        if isinstance(perms, list):
            for section in perms:
                section_name = (section or "").strip()
                if not section_name:
                    continue
                db.session.add(RolePermission(role_id=role.id, section=section_name, can_create=True))
            db.session.commit()
        response = role.to_dict()
        response["permissions"] = perms if isinstance(perms, list) else []
        response["is_active"] = True
        return jsonify(response), 201

    @app.route("/admin/roles/<int:role_id>", methods=["DELETE"])
    @require_auth
    def delete_role(role_id: int):
        if g.current_user.role not in ROLE_PLATFORM_ADMINS:
            return jsonify({"error": "Only superadmin"}), 403
        role = Role.query.get_or_404(role_id)
        user_count = User.query.filter(User.roles.any(Role.id == role.id)).count() + User.query.filter_by(role=role.name).count()
        if user_count > 0:
            return jsonify({"error": "Role in use; cannot delete"}), 400
        RolePermission.query.filter_by(role_id=role.id).delete()
        db.session.delete(role)
        db.session.commit()
        return jsonify({"deleted": True})

    @app.route("/admin/roles/<int:role_id>", methods=["PUT"])
    @require_auth
    def update_role(role_id: int):
        if g.current_user.role not in ROLE_PLATFORM_ADMINS:
            return jsonify({"error": "Only superadmin"}), 403
        role = Role.query.get_or_404(role_id)
        data = request.get_json() or {}
        name = (data.get("name") or role.name).strip()
        if not name:
            return jsonify({"error": "Name required"}), 400
        existing = Role.query.filter(Role.id != role.id, Role.name == name).first()
        if existing:
            return jsonify({"error": "Role exists"}), 400
        role.name = name
        if "description" in data:
            role.description = data.get("description", "") or ""
        if "permissions" in data:
            RolePermission.query.filter_by(role_id=role.id).delete()
            perms = data.get("permissions") or []
            if isinstance(perms, list):
                for section in perms:
                    section_name = (section or "").strip()
                    if not section_name:
                        continue
                    db.session.add(RolePermission(role_id=role.id, section=section_name, can_create=True))
        db.session.commit()
        response = role.to_dict()
        response["permissions"] = data.get("permissions") or []
        response["is_active"] = True
        return jsonify(response)

    @app.route("/admin/roles/<int:role_id>/permissions", methods=["GET"])
    @require_auth
    def role_permissions(role_id: int):
        if g.current_user.role not in ROLE_PLATFORM_ADMINS:
            return jsonify({"error": "Only superadmin"}), 403
        role = Role.query.get_or_404(role_id)
        perms = RolePermission.query.filter_by(role_id=role.id).all()
        return jsonify([p.to_dict() for p in perms])

    @app.route("/admin/roles/<int:role_id>/permissions", methods=["POST"])
    @require_auth
    def save_role_permission(role_id: int):
        if g.current_user.role not in ROLE_PLATFORM_ADMINS:
            return jsonify({"error": "Only superadmin"}), 403
        role = Role.query.get_or_404(role_id)
        data = request.get_json() or {}
        section = (data.get("section") or "").strip()
        if not section:
            return jsonify({"error": "Section required"}), 400
        perm = RolePermission.query.filter_by(role_id=role.id, section=section).first()
        if not perm:
            perm = RolePermission(role_id=role.id, section=section)
            db.session.add(perm)
        for field in ["can_create", "can_edit", "can_delete", "can_archive"]:
            if field in data:
                setattr(perm, field, bool(data[field]))
        db.session.commit()
        return jsonify(perm.to_dict())

    @app.route("/admin/roles/permissions/available", methods=["GET"])
    @require_auth
    def available_role_permissions():
        if g.current_user.role not in ROLE_PLATFORM_ADMINS:
            return jsonify({"error": "Only superadmin"}), 403
        sections = [p.section for p in UserPermission.query.distinct(UserPermission.section)]
        return jsonify(sorted(set(sections)))

    @app.route("/admin/permissions/catalog", methods=["GET"])
    @require_auth
    def admin_permissions_catalog():
        if g.current_user.role not in ROLE_PLATFORM_ADMINS:
            return jsonify({"error": "Only superadmin"}), 403
        return jsonify(DEFAULT_PERMISSION_CATALOG)

    @app.route("/admin/users/permissions", methods=["GET"])
    @require_auth
    @company_required()
    def admin_list_user_permission_grants():
        is_platform_admin = g.current_user.role in ROLE_PLATFORM_ADMINS
        if not is_platform_admin:
            role = (getattr(g, "company_role", None) or getattr(g.current_user, "role", "") or "").lower()
            if role not in {"admin", "manager"}:
                return jsonify({"error": "Only superadmin"}), 403
        user_id = request.args.get("user_id")
        query = UserPermissionGrant.query
        if user_id:
            query = query.filter_by(user_id=int(user_id))
        else:
            company_user_ids = [m.user_id for m in UserCompany.query.filter_by(company_id=g.current_company.id).all()]
            if company_user_ids:
                query = query.filter(UserPermissionGrant.user_id.in_(company_user_ids))
            else:
                return jsonify([])
        if is_platform_admin:
            query = query.filter(
                or_(UserPermissionGrant.company_id.is_(None), UserPermissionGrant.company_id == g.current_company.id)
            )
        else:
            query = query.filter(UserPermissionGrant.company_id == g.current_company.id)
        rows = query.order_by(UserPermissionGrant.user_id.asc(), UserPermissionGrant.permission.asc()).all()
        return jsonify([r.to_dict() for r in rows])

    @app.route("/admin/users/permissions/effective", methods=["GET"])
    @require_auth
    @company_required()
    def admin_effective_user_permissions():
        is_platform_admin = g.current_user.role in ROLE_PLATFORM_ADMINS
        if not is_platform_admin:
            role = (getattr(g, "company_role", None) or getattr(g.current_user, "role", "") or "").lower()
            if role not in {"admin", "manager"}:
                return jsonify({"error": "Only superadmin"}), 403
        user_id = request.args.get("user_id")
        if not user_id:
            return jsonify({"error": "user_id required"}), 400
        target = db.session.get(User, int(user_id))
        if not target:
            return jsonify({"error": "User not found"}), 404
        membership = UserCompany.query.filter_by(company_id=g.current_company.id, user_id=target.id).first()
        company_role = membership.role if membership else target.role
        return jsonify(
            {
                "user_id": target.id,
                "company_id": g.current_company.id,
                "company_role": company_role,
                "permissions": _effective_permission_keys(target, g.current_company.id, company_role),
            }
        )

    @app.route("/admin/users/permissions", methods=["POST"])
    @require_auth
    @company_required()
    def admin_upsert_user_permission_grant():
        is_platform_admin = g.current_user.role in ROLE_PLATFORM_ADMINS
        if not is_platform_admin:
            role = (getattr(g, "company_role", None) or getattr(g.current_user, "role", "") or "").lower()
            if role not in {"admin", "manager"}:
                return jsonify({"error": "Only superadmin"}), 403
        data = request.get_json() or {}
        user_id = data.get("user_id")
        permission = (data.get("permission") or "").strip()
        if not user_id or not permission:
            return jsonify({"error": "user_id and permission required"}), 400

        target = db.session.get(User, int(user_id))
        if not target:
            return jsonify({"error": "User not found"}), 404

        company_id = data.get("company_id", g.current_company.id)
        if company_id is None:
            scope_company_id = None
        else:
            scope_company_id = int(company_id)
            membership = UserCompany.query.filter_by(company_id=scope_company_id, user_id=target.id).first()
            if not membership and target.role not in ROLE_PLATFORM_ADMINS:
                return jsonify({"error": "User is not a member of this company"}), 400
        if not is_platform_admin:
            if scope_company_id is None or scope_company_id != g.current_company.id:
                return jsonify({"error": "Only superadmin can create global grants"}), 403
            effective = set(_effective_permission_keys(g.current_user, g.current_company.id, g.company_role))
            if permission not in effective:
                return jsonify({"error": "Cannot grant permissions you do not have"}), 403
            if permission in {"purchase_bills_approve"}:
                return jsonify({"error": "Only superadmin can grant approval permissions"}), 403

        allowed = bool(data.get("allowed", True))
        row = UserPermissionGrant.query.filter_by(
            user_id=target.id, company_id=scope_company_id, permission=permission
        ).first()
        if not row:
            row = UserPermissionGrant(user_id=target.id, company_id=scope_company_id, permission=permission, allowed=allowed)
            db.session.add(row)
        else:
            row.allowed = allowed
        db.session.commit()
        return jsonify(row.to_dict())

    @app.route("/admin/users/permissions/<int:grant_id>", methods=["DELETE"])
    @require_auth
    @company_required()
    def admin_delete_user_permission_grant(grant_id: int):
        is_platform_admin = g.current_user.role in ROLE_PLATFORM_ADMINS
        if not is_platform_admin:
            role = (getattr(g, "company_role", None) or getattr(g.current_user, "role", "") or "").lower()
            if role not in {"admin", "manager"}:
                return jsonify({"error": "Only superadmin"}), 403
        row = UserPermissionGrant.query.get_or_404(grant_id)
        if not is_platform_admin and row.company_id != g.current_company.id:
            return jsonify({"error": "Forbidden"}), 403
        if not is_platform_admin:
            effective = set(_effective_permission_keys(g.current_user, g.current_company.id, g.company_role))
            if row.permission not in effective:
                return jsonify({"error": "Cannot revoke permissions you do not have"}), 403
        db.session.delete(row)
        db.session.commit()
        return jsonify({"deleted": True})

    @app.route("/superuser/settings", methods=["GET"])
    @require_auth
    def superuser_settings():
        if g.current_user.role not in ROLE_PLATFORM_ADMINS:
            return jsonify({"error": "Only superadmin"}), 403
        roles = Role.query.order_by(Role.name.asc()).all()
        roles_payload = []
        for r in roles:
            perms = RolePermission.query.filter_by(role_id=r.id).all()
            roles_payload.append(
                {
                    **r.to_dict(),
                    "permissions": [p.section for p in perms],
                }
            )
        companies = Company.query.order_by(Company.name.asc()).all()
        sections = [p.section for p in UserPermission.query.distinct(UserPermission.section)]
        return jsonify(
            {
                "roles": roles_payload,
                "companies": [c.to_dict() for c in companies],
                "available_permissions": sorted(set(sections)),
                "permission_catalog": DEFAULT_PERMISSION_CATALOG,
            }
        )

    @app.route("/admin/repair/purchase-bills", methods=["GET"])
    @require_auth
    @company_required()
    def admin_repair_list_purchase_bills():
        if g.current_user.role not in ROLE_PLATFORM_ADMINS:
            return jsonify({"error": "Only superadmin"}), 403
        _normalize_purchase_bill_posting_flags(g.current_company.id)
        base_query = (
            PurchaseBill.query.filter(
                PurchaseBill.company_id == g.current_company.id,
                or_(PurchaseBill.posted.is_(True), PurchaseBill.posted_at.isnot(None)),
            )
            .order_by(PurchaseBill.purchase_date.desc(), PurchaseBill.created_at.desc())
        )
        bills, meta = paginate_query(base_query)
        payload = []
        updated_any = False
        for bill in bills:
            lock_reason = _purchase_bill_repair_lock_reason(g.current_company.id, bill)
            if bill.items and _sync_purchase_bill_totals(g.current_company, bill):
                updated_any = True
            row = bill.to_dict(include_items=False, include_payments=True)
            row["can_repair_edit"] = lock_reason is None
            row["repair_lock_reason"] = lock_reason
            payload.append(row)
        if updated_any:
            db.session.commit()
        return jsonify({"data": payload, "pagination": meta})

    @app.route("/admin/repair/purchase-bills/<int:bill_id>", methods=["PUT"])
    @require_auth
    @company_required()
    def admin_repair_update_posted_purchase_bill(bill_id: int):
        if g.current_user.role not in ROLE_PLATFORM_ADMINS:
            return jsonify({"error": "Only superadmin"}), 403

        bill = PurchaseBill.query.get_or_404(bill_id)
        if bill.company_id != g.current_company.id:
            return jsonify({"error": "Forbidden"}), 403

        lock_reason = _purchase_bill_repair_lock_reason(g.current_company.id, bill)
        if lock_reason:
            return jsonify({"error": lock_reason}), 400

        data = request.get_json() or {}
        supplier_id = data.get("supplier_id")
        if not supplier_id:
            return jsonify({"error": "Supplier is required"}), 400
        supplier = Supplier.query.get(supplier_id)
        if not supplier or supplier.company_id != g.current_company.id:
            return jsonify({"error": "Supplier not found"}), 404

        purchase_date_raw = data.get("purchase_date") or data.get("date_of_purchase") or data.get("date")
        if purchase_date_raw:
            try:
                purchase_date = datetime.fromisoformat(str(purchase_date_raw)).date()
            except ValueError:
                return jsonify({"error": "Invalid purchase_date (expected YYYY-MM-DD)"}), 400
        else:
            purchase_date = bill.purchase_date or _today_ad()

        items = data.get("items") or []
        if not isinstance(items, list) or not items:
            return jsonify({"error": "Purchase bill items are required"}), 400

        vat_percent = float(getattr(g.current_company, "vat_purchase_percent", 0.0) or 0.0)
        vat_rate = max(0.0, vat_percent)

        original_posted_at = bill.posted_at or datetime.now(timezone.utc)
        try:
            # Reverse old posted inventory impact first.
            _revert_purchase_bill_from_inventory(g.current_company.id, bill)

            # Replace bill core fields.
            bill.supplier = supplier
            bill.purchase_date = purchase_date
            bill.bill_number = _make_purchase_bill_number(g.current_company.id, purchase_date)

            # Replace items.
            PurchaseBillItem.query.filter_by(purchase_bill_id=bill.id).delete(synchronize_session=False)

            gross_total = 0.0
            totals_items: list[dict] = []
            added_any = False
            for idx, item in enumerate(items):
                product_id = item.get("product_id")
                if not product_id:
                    raise ValueError(f"Line {idx + 1}: product_id is required")

                product = Product.query.get(product_id)
                if not product or product.company_id != g.current_company.id:
                    raise ValueError(f"Line {idx + 1}: Invalid product")

                try:
                    ordered_qty = float(item.get("ordered_qty", item.get("qty", 0)) or 0)
                    free_qty = float(item.get("free_qty", 0) or 0)
                except (TypeError, ValueError):
                    raise ValueError(f"Line {idx + 1}: Invalid quantities")
                if ordered_qty < 0 or free_qty < 0 or (ordered_qty + free_qty) <= 0:
                    raise ValueError(f"Line {idx + 1}: Quantity must be greater than 0")

                uom_raw = (item.get("uom") or "").strip() or None
                uom = _validate_uom_for_product(product, uom_raw)
                if product.uom_category and not uom:
                    uom = product.uom_category

                batch_number = (
                    (item.get("batch_number") or item.get("lot_number") or item.get("serial_number") or "").strip()
                    or None
                )
                if product.lot_tracking and not batch_number:
                    raise ValueError(f"Line {idx + 1}: Batch/serial number required for {product.name}")

                try:
                    cost_price = float(item.get("cost_price", 0.0) or 0.0)
                    mrp = float(item.get("mrp", item.get("price", 0.0)) or 0.0)
                    discount = float(item.get("discount", 0.0) or 0.0)
                    tax_subtotal = float(item.get("tax_subtotal", item.get("tax", 0.0)) or 0.0)
                    free_vat_percent = float(item.get("free_vat_percent", 0.0) or 0.0)
                except (TypeError, ValueError):
                    raise ValueError(f"Line {idx + 1}: Invalid price/discount/tax values")
                if cost_price < 0 or mrp < 0 or discount < 0 or tax_subtotal < 0 or free_vat_percent < 0:
                    raise ValueError(f"Line {idx + 1}: Amounts must be >= 0")

                expiry_date = None
                if item.get("expiry_date"):
                    expiry_date = _parse_expiry_date_allow_month_year(str(item.get("expiry_date")))
                if (product.expiry_tracking or product.shelf_removal) and not expiry_date:
                    raise ValueError(f"Line {idx + 1}: Expiry date required for {product.name}")

                subtotal = ordered_qty * cost_price
                if product.vat_item:
                    tax_subtotal = round(max(0.0, subtotal - discount) * vat_rate / 100.0, 2)
                line_total = subtotal - discount + tax_subtotal
                if line_total < 0:
                    raise ValueError(f"Line {idx + 1}: Total cannot be negative")

                bill_item = PurchaseBillItem(
                    purchase_bill=bill,
                    product=product,
                    uom=uom,
                    batch_number=batch_number,
                    expiry_date=expiry_date,
                    ordered_qty=ordered_qty,
                    free_qty=free_qty,
                    cost_price=cost_price,
                    price=mrp,
                    mrp=mrp,
                    discount=discount,
                    tax_subtotal=tax_subtotal,
                    free_vat_percent=free_vat_percent,
                    line_total=round(line_total, 2),
                )
                db.session.add(bill_item)
                added_any = True
                gross_total += line_total
                totals_items.append(
                    {
                        "product": product,
                        "ordered_qty": ordered_qty,
                        "free_qty": free_qty,
                        "cost_price": cost_price,
                        "discount": discount,
                        "free_vat_percent": free_vat_percent,
                    }
                )

            if not added_any:
                raise ValueError("At least one valid item line is required")

            _sync_purchase_bill_totals(g.current_company, bill)
            bill.posted = True
            bill.posted_at = original_posted_at
            _apply_purchase_bill_to_inventory(g.current_company.id, bill, reason="purchase_bill_repair")
            product_ids = {int(it.product_id) for it in (bill.items or []) if getattr(it, "product_id", None)}
            if product_ids:
                _relink_pending_backdated_sales(company_id=g.current_company.id, product_ids=product_ids)
            _renumber_purchase_bills(company_id=g.current_company.id)
            db.session.commit()
        except ValueError as exc:
            db.session.rollback()
            return jsonify({"error": str(exc)}), 400

        socketio.emit(
            "inventory:update",
            {"type": "purchase_bill_repaired", "purchase_bill": bill.to_dict(include_items=True), "company_id": g.current_company.id},
        )
        log_action(
            "purchase_bill_repaired",
            {"purchase_bill_id": bill.id, "supplier_id": bill.supplier_id, "gross_total": bill.gross_total},
            g.current_company.id,
        )
        return jsonify(bill.to_dict(include_items=True, include_payments=True))

    @app.route("/superuser/db-info", methods=["GET"])
    @require_auth
    def superuser_db_info():
        if g.current_user.role not in ROLE_PLATFORM_ADMINS:
            return jsonify({"error": "Only superadmin"}), 403
        engine = str(getattr(db.engine.url, "drivername", "") or "")
        info = {"engine": engine}
        if engine.startswith("postgresql"):
            info["path"] = db.engine.url.database or ""
            info["host"] = db.engine.url.host or ""
            info["port"] = db.engine.url.port or 5432
        elif engine.startswith("mysql"):
            info["path"] = db.engine.url.database or ""
            info["host"] = db.engine.url.host or ""
            info["port"] = db.engine.url.port or 3306
        is_vacant, populated = _db_is_vacant()
        info["is_vacant"] = is_vacant
        info["populated_tables"] = populated
        try:
            info["company_count"] = Company.query.count()
        except Exception:
            info["company_count"] = 0
        return jsonify(info)

    @app.route("/superuser/db", methods=["DELETE"])
    @require_auth
    def superuser_delete_db():
        if g.current_user.role not in ROLE_PLATFORM_ADMINS:
            return jsonify({"error": "Only superadmin"}), 403
        return jsonify({"error": "Database file deletion is disabled for SQL databases"}), 400

    @app.route("/superuser/backup", methods=["GET"])
    @require_auth
    def superuser_backup():
        if g.current_user.role not in ROLE_PLATFORM_ADMINS:
            return jsonify({"error": "Only superadmin"}), 403
        try:
            zip_bytes, download_name, _ = _create_backup_zip_bytes()
        except Exception as exc:
            return jsonify({"error": f"Backup failed: {exc}"}), 500

        zip_buf = io.BytesIO(zip_bytes)
        zip_buf.seek(0)
        return send_file(
            zip_buf,
            mimetype="application/zip",
            as_attachment=True,
            download_name=download_name,
        )

    @app.route("/superuser/backup/google-drive", methods=["GET"])
    @require_auth
    def get_google_drive_backup_settings():
        if g.current_user.role not in ROLE_PLATFORM_ADMINS:
            return jsonify({"error": "Only superadmin"}), 403
        row = GoogleDriveBackupSetting.query.order_by(GoogleDriveBackupSetting.id.asc()).first()
        if not row:
            row = GoogleDriveBackupSetting(enabled=False, interval_value=60, interval_unit="minutes")
            db.session.add(row)
            db.session.commit()
        payload = row.to_dict(include_secret=True)
        next_backup_due_at = None
        if row.enabled:
            interval_seconds = _backup_interval_seconds(int(row.interval_value or 60), str(row.interval_unit or "minutes"))
            base_dt = row.last_backup_at or datetime.now(timezone.utc)
            next_backup_due_at = (base_dt + timedelta(seconds=interval_seconds)).isoformat()
        payload["next_backup_due_at"] = next_backup_due_at
        return jsonify(payload)

    @app.route("/superuser/backup/google-drive", methods=["PUT"])
    @require_auth
    def update_google_drive_backup_settings():
        if g.current_user.role not in ROLE_PLATFORM_ADMINS:
            return jsonify({"error": "Only superadmin"}), 403
        data = request.get_json() or {}
        row = GoogleDriveBackupSetting.query.order_by(GoogleDriveBackupSetting.id.asc()).first()
        if not row:
            row = GoogleDriveBackupSetting()
            db.session.add(row)
        interval_value = int(data.get("interval_value") or row.interval_value or 60)
        if interval_value <= 0:
            return jsonify({"error": "interval_value must be greater than 0"}), 400
        interval_unit = str(data.get("interval_unit") or row.interval_unit or "minutes").strip().lower()
        if interval_unit not in {"minutes", "hours"}:
            return jsonify({"error": "interval_unit must be 'minutes' or 'hours'"}), 400

        raw_sa = data.get("service_account_json")
        if raw_sa is not None and str(raw_sa).strip():
            try:
                parsed = json.loads(str(raw_sa))
            except Exception:
                return jsonify({"error": "service_account_json is not valid JSON"}), 400
            if not parsed.get("client_email") or not parsed.get("private_key"):
                return jsonify({"error": "service_account_json must include client_email and private_key"}), 400
            row.service_account_json = json.dumps(parsed)
        elif raw_sa is not None and not str(raw_sa).strip():
            row.service_account_json = ""

        row.enabled = bool(data.get("enabled", row.enabled))
        row.interval_value = interval_value
        row.interval_unit = interval_unit
        row.folder_id = (str(data.get("folder_id") or row.folder_id or "") or "").strip()
        row.updated_by_user_id = g.current_user.id
        db.session.commit()
        return jsonify(row.to_dict(include_secret=True))

    @app.route("/superuser/backup/google-drive/run", methods=["POST"])
    @require_auth
    def run_google_drive_backup_now():
        if g.current_user.role not in ROLE_PLATFORM_ADMINS:
            return jsonify({"error": "Only superadmin"}), 403
        row = GoogleDriveBackupSetting.query.order_by(GoogleDriveBackupSetting.id.asc()).first()
        if not row:
            return jsonify({"error": "Google Drive backup settings not configured"}), 400
        row.updated_by_user_id = g.current_user.id
        db.session.commit()
        try:
            result = _execute_google_drive_backup(from_scheduler=False)
            return jsonify({"ok": True, **result, "settings": row.to_dict(include_secret=False)})
        except Exception as exc:
            row.last_error = str(exc)
            db.session.commit()
            return jsonify({"error": str(exc)}), 400

    @app.route("/superuser/backup/google-drive/test", methods=["POST"])
    @require_auth
    def test_google_drive_backup_settings():
        if g.current_user.role not in ROLE_PLATFORM_ADMINS:
            return jsonify({"error": "Only superadmin"}), 403
        row = GoogleDriveBackupSetting.query.order_by(GoogleDriveBackupSetting.id.asc()).first()
        if not row or not str(row.service_account_json or "").strip():
            return jsonify({"error": "Google Drive service account JSON is not configured"}), 400
        try:
            service_account = json.loads(row.service_account_json or "{}")
        except Exception:
            return jsonify({"error": "Stored service account JSON is invalid"}), 400
        try:
            folder = _google_drive_folder_info(row.folder_id or "", row.service_account_json or "")
            files = _list_google_drive_backup_files(
                service_account_json=row.service_account_json or "",
                folder_id=row.folder_id or None,
                limit=5,
            )
            return jsonify(
                {
                    "ok": True,
                    "client_email": service_account.get("client_email"),
                    "folder": folder or None,
                    "recent_files": files,
                }
            )
        except Exception as exc:
            row.last_error = str(exc)
            db.session.commit()
            return jsonify({"error": str(exc)}), 400

    @app.route("/superuser/backup/google-drive/files", methods=["GET"])
    @require_auth
    def list_google_drive_backup_files():
        if g.current_user.role not in ROLE_PLATFORM_ADMINS:
            return jsonify({"error": "Only superadmin"}), 403
        row = GoogleDriveBackupSetting.query.order_by(GoogleDriveBackupSetting.id.asc()).first()
        if not row or not str(row.service_account_json or "").strip():
            return jsonify({"error": "Google Drive service account JSON is not configured"}), 400
        try:
            files = _list_google_drive_backup_files(
                service_account_json=row.service_account_json or "",
                folder_id=row.folder_id or None,
                limit=int(request.args.get("limit") or 20),
            )
            return jsonify({"files": files})
        except Exception as exc:
            row.last_error = str(exc)
            db.session.commit()
            return jsonify({"error": str(exc)}), 400

    @app.route("/superuser/restore", methods=["POST", "OPTIONS"])
    @require_auth
    def superuser_restore():
        if g.current_user.role not in ROLE_PLATFORM_ADMINS:
            return jsonify({"error": "Only superadmin"}), 403
        mode = (request.form.get("mode") or "replace").strip().lower()
        restore_format = (request.form.get("format") or "auto").strip().lower()
        uploaded = request.files.get("file")
        if not uploaded or not uploaded.filename:
            return jsonify({"error": "Backup zip file is required"}), 400

        tmp_dir = tempfile.mkdtemp(prefix="pharmacy-restore-")
        zip_path = os.path.join(tmp_dir, "backup.zip")
        uploaded.save(zip_path)
        try:
            if restore_format == "csv" or (restore_format == "auto" and _is_csv_backup_zip(zip_path)):
                return jsonify(_restore_csv_backup_zip(zip_path, mode=mode))
            return jsonify(_restore_backup_zip(zip_path, mode=mode))
        except RuntimeError as exc:
            message = str(exc)
            lower = message.lower()
            code = 400 if any(
                part in lower
                for part in [
                    "invalid",
                    "not supported",
                    "no ",
                    "newer than current version",
                    "must",
                    "merge restore",
                    "psql not found",
                    "pg_dump",
                    "csv",
                    "failed",
                ]
            ) else 500
            return jsonify({"error": message}), code
        finally:
            shutil.rmtree(tmp_dir, ignore_errors=True)

    @app.route("/server-sync/settings", methods=["GET"])
    @require_auth
    @company_required(["admin", "manager"])
    def get_server_sync_settings():
        row = _get_server_sync_setting()
        payload = row.to_dict()
        payload["live_sync_enabled"] = payload["auto_sync_enabled"]
        return jsonify(payload)

    @app.route("/server-sync/settings", methods=["PUT"])
    @require_auth
    @company_required(["admin", "manager"])
    def update_server_sync_settings():
        data = request.get_json() or {}
        row = _get_server_sync_setting()
        if "remote_base_url" in data:
            row.remote_base_url = _normalize_server_sync_base_url(data.get("remote_base_url"))
        if "auto_sync_enabled" in data or "live_sync_enabled" in data:
            row.auto_sync_enabled = bool(data.get("auto_sync_enabled", data.get("live_sync_enabled", row.auto_sync_enabled)))
        if "sync_interval_seconds" in data:
            try:
                row.sync_interval_seconds = max(30, int(data.get("sync_interval_seconds") or row.sync_interval_seconds or 60))
            except Exception:
                return jsonify({"error": "sync_interval_seconds must be a number"}), 400
        row.updated_by_user_id = g.current_user.id
        db.session.commit()
        log_action(
            "server_sync_settings_updated",
            {
                "remote_base_url": row.remote_base_url or "",
                "auto_sync_enabled": bool(row.auto_sync_enabled),
                "sync_interval_seconds": int(row.sync_interval_seconds or 60),
            },
            g.current_company.id,
        )
        payload = row.to_dict()
        payload["live_sync_enabled"] = payload["auto_sync_enabled"]
        return jsonify(payload)

    @app.route("/server-sync/test", methods=["POST"])
    @require_auth
    @company_required(["admin", "manager"])
    def test_server_sync():
        data = request.get_json() or {}
        row = _get_server_sync_setting()
        remote_base_url = _normalize_server_sync_base_url(data.get("remote_base_url") or row.remote_base_url)
        if not remote_base_url:
            return jsonify({"error": "Remote server IP/URL is required"}), 400
        row.remote_base_url = remote_base_url
        row.updated_by_user_id = g.current_user.id
        try:
            info = _ping_remote_server_sync(app, remote_base_url)
            row.last_test_at = datetime.now(timezone.utc)
            row.last_error = None
            db.session.commit()
            _log_server_sync(
                "test",
                "success",
                f"Connection test passed for {remote_base_url}",
                remote_base_url=info.get("resolved_url") or remote_base_url,
                details={"remote_ping": info},
                triggered_by_user_id=g.current_user.id,
            )
            return jsonify({"ok": True, "remote": info, "settings": row.to_dict()})
        except Exception as exc:
            row.last_error = str(exc)
            db.session.commit()
            _log_server_sync(
                "test",
                "failed",
                str(exc),
                remote_base_url=remote_base_url,
                triggered_by_user_id=g.current_user.id,
            )
            return jsonify({"error": str(exc)}), 400

    @app.route("/server-sync/start", methods=["POST"])
    @require_auth
    @company_required(["admin", "manager"])
    def start_server_sync():
        data = request.get_json() or {}
        row = _get_server_sync_setting()
        remote_base_url = data.get("remote_base_url")
        if remote_base_url is not None:
            row.remote_base_url = _normalize_server_sync_base_url(remote_base_url)
        if "auto_sync_enabled" in data or "live_sync_enabled" in data:
            row.auto_sync_enabled = bool(data.get("auto_sync_enabled", data.get("live_sync_enabled", row.auto_sync_enabled)))
        if "sync_interval_seconds" in data:
            try:
                row.sync_interval_seconds = max(30, int(data.get("sync_interval_seconds") or row.sync_interval_seconds or 60))
            except Exception:
                return jsonify({"error": "sync_interval_seconds must be a number"}), 400
        row.updated_by_user_id = g.current_user.id
        db.session.commit()
        try:
            result = _execute_server_sync(app, event_type="manual_sync", triggered_by_user_id=g.current_user.id)
            return jsonify({"ok": True, "result": result, "settings": row.to_dict()})
        except Exception as exc:
            return jsonify({"error": str(exc)}), 400

    @app.route("/server-sync/logs", methods=["GET"])
    @require_auth
    @company_required(["admin", "manager"])
    def list_server_sync_logs():
        limit = min(max(int(request.args.get("limit", 20) or 20), 1), 100)
        items = ServerSyncLog.query.order_by(ServerSyncLog.created_at.desc()).limit(limit).all()
        user_ids = {item.triggered_by_user_id for item in items if item.triggered_by_user_id}
        usernames = {}
        if user_ids:
            usernames = {u.id: u.username for u in User.query.filter(User.id.in_(user_ids)).all()}
        payload = []
        for item in items:
            row = item.to_dict()
            row["triggered_by_username"] = usernames.get(item.triggered_by_user_id)
            payload.append(row)
        return jsonify({"logs": payload})

    @app.route("/server-sync/ping", methods=["POST"])
    def remote_server_sync_ping():
        payload = request.get_data() or b""
        ok, error_message = _verify_server_sync_request(app, payload)
        if not ok:
            return jsonify({"error": error_message}), 401
        return jsonify(
            {
                "ok": True,
                "app": "flask_pharmacy_app",
                "version": APP_VERSION,
                "server_time": datetime.now(timezone.utc).isoformat(),
                "db_engine": str(getattr(db.engine.url, "drivername", "") or ""),
                "companies": Company.query.count(),
            }
        )

    @app.route("/server-sync/import", methods=["POST"])
    def remote_server_sync_import():
        uploaded = request.files.get("file")
        if not uploaded or not uploaded.filename:
            return jsonify({"error": "Backup zip file is required"}), 400
        uploaded_bytes = uploaded.read() or b""
        uploaded.stream.seek(0)
        ok, error_message = _verify_server_sync_request(app, uploaded_bytes)
        if not ok:
            return jsonify({"error": error_message}), 401
        mode = (request.form.get("mode") or "replace").strip().lower()
        tmp_dir = tempfile.mkdtemp(prefix="server-sync-import-")
        zip_path = os.path.join(tmp_dir, "incoming-backup.zip")
        uploaded.save(zip_path)
        try:
            result = _restore_backup_zip(zip_path, mode=mode)
            _log_server_sync(
                "remote_import",
                "success",
                "Remote server sync import completed",
                remote_base_url=(request.remote_addr or "").strip() or None,
                details={"mode": mode, **(result or {})},
            )
            return jsonify({"ok": True, **result})
        except RuntimeError as exc:
            _log_server_sync(
                "remote_import",
                "failed",
                str(exc),
                remote_base_url=(request.remote_addr or "").strip() or None,
                details={"mode": mode},
            )
            return jsonify({"error": str(exc)}), 400
        finally:
            shutil.rmtree(tmp_dir, ignore_errors=True)

    @app.route("/companies", methods=["POST"])
    @require_auth
    def create_company():
        data = request.get_json() or {}
        name = data.get("name", "").strip() or None
        if not name:
            return jsonify({"error": "Company name is required"}), 400
        # allow platform admin to create companies
        if g.current_user.role not in ROLE_PLATFORM_ADMINS:
            return jsonify({"error": "Only platform admin can create companies"}), 403

        company = Company(
            name=name,
            address=data.get("address", "") or "",
            city=data.get("city", "") or "",
            phone=data.get("phone", "") or "",
            contact_name=data.get("contact_name", "") or "",
            contact_phone=data.get("contact_phone", "") or "",
            dda_number=(data.get("dda_number") or data.get("license_number") or "") or "",
            pan_vat_number=(data.get("pan_vat_number") or data.get("pan_vat") or "") or "",
            company_type=normalize_company_type(data.get("company_type")),
            location_url=data.get("location_url", "") or "",
            logo_data=data.get("logo_data") or None,
            print_instructions=data.get("print_instructions") or "",
            print_instructions_sales=data.get("print_instructions_sales") or "",
            print_instructions_sales_return=data.get("print_instructions_sales_return") or "",
            print_instructions_purchase_bill=data.get("print_instructions_purchase_bill") or "",
            print_instructions_purchase_order=data.get("print_instructions_purchase_order") or "",
            print_instructions_expiry_returns=data.get("print_instructions_expiry_returns") or "",
            is_active=True,
            cc_free_item_percent=float(data.get("cc_free_item_percent", 7.5) or 7.5),
            vat_purchase_percent=float(data.get("vat_purchase_percent", 13.0) or 13.0),
        )
        db.session.add(company)
        db.session.commit()
        _ensure_default_company_ledgers(company.id)
        db.session.commit()
        db.session.add(UserCompany(user=g.current_user, company=company, role=g.current_user.role))
        db.session.commit()
        log_action("company_created", {"id": company.id, "name": company.name}, company.id)
        return jsonify(company.to_dict()), 201

    @app.route("/companies/<int:company_id>", methods=["PUT"])
    @require_auth
    def update_company(company_id: int):
        company = Company.query.get_or_404(company_id)
        data = request.get_json() or {}
        # Frontend compatibility field aliases
        if "license_number" in data and "dda_number" not in data:
            data["dda_number"] = data.get("license_number")
        if "pan_vat" in data and "pan_vat_number" not in data:
            data["pan_vat_number"] = data.get("pan_vat")
        allowed_fields = [
            "name",
            "address",
            "city",
            "phone",
            "contact_name",
            "contact_phone",
            "dda_number",
            "pan_vat_number",
            "company_type",
            "location_url",
            "logo_data",
            "print_instructions",
            "print_instructions_sales",
            "print_instructions_sales_return",
            "print_instructions_purchase_bill",
            "print_instructions_purchase_order",
            "print_instructions_expiry_returns",
            "is_active",
            "cc_free_item_percent",
            "vat_purchase_percent",
        ]
        # allow platform admins or company admins/managers to edit
        if g.current_user.role not in ROLE_PLATFORM_ADMINS:
            membership = UserCompany.query.filter_by(user_id=g.current_user.id, company_id=company_id).first()
            if not membership or membership.role not in ["admin", "manager"]:
                return jsonify({"error": "Forbidden"}), 403
        for field in allowed_fields:
            if field in data:
                if field == "is_active":
                    setattr(company, field, bool(data[field]))
                elif field == "company_type":
                    setattr(company, field, normalize_company_type(data[field]))
                else:
                    setattr(company, field, data[field] if data[field] is not None else "")
        db.session.commit()
        log_action("company_updated", {"id": company.id}, company.id)
        return jsonify(company.to_dict())

    def _company_delete_blockers(company_id: int) -> dict[str, int]:
        checks = {
            "sales": Sale.query.filter_by(company_id=company_id).count(),
            "purchase_bills": PurchaseBill.query.filter_by(company_id=company_id).count(),
            "purchase_orders": PurchaseOrder.query.filter_by(company_id=company_id).count(),
            "products": Product.query.filter_by(company_id=company_id).count(),
            "customers": Customer.query.filter_by(company_id=company_id).count(),
            "suppliers": Supplier.query.filter_by(company_id=company_id).count(),
            "inventory_logs": InventoryLog.query.filter_by(company_id=company_id).count(),
            "account_entries": AccountEntry.query.filter_by(company_id=company_id).count(),
            "clinic_patients": ClinicPatient.query.filter_by(company_id=company_id).count(),
            "clinic_appointments": ClinicAppointment.query.filter_by(company_id=company_id).count(),
            "attendance": Attendance.query.filter_by(company_id=company_id).count(),
            "payroll_payments": PayrollPayment.query.filter_by(company_id=company_id).count(),
        }
        return {key: value for key, value in checks.items() if value > 0}

    def _cleanup_empty_company_dependencies(company_id: int) -> None:
        # Keep audit records but detach them before the company row is removed.
        ActivityLog.query.filter_by(company_id=company_id).update(
            {ActivityLog.company_id: None},
            synchronize_session=False,
        )
        UserCompany.query.filter_by(company_id=company_id).delete(synchronize_session=False)
        ChequeTemplate.query.filter_by(company_id=company_id).delete(synchronize_session=False)
        PaymentMode.query.filter_by(company_id=company_id).delete(synchronize_session=False)
        Account.query.filter_by(company_id=company_id).update(
            {Account.parent_id: None},
            synchronize_session=False,
        )
        Account.query.filter_by(company_id=company_id).delete(synchronize_session=False)

    @app.route("/companies/<int:company_id>", methods=["DELETE"])
    @require_auth
    def delete_company_alias(company_id: int):
        """
        Frontend uses DELETE /companies/:id. Restrict to superuser and require no transactions.
        """
        if g.current_user.role not in ROLE_PLATFORM_ADMINS:
            return jsonify({"error": "Only superadmin"}), 403
        company = Company.query.get_or_404(company_id)
        if company.is_active:
            return jsonify({"error": "Deactivate company before deleting"}), 400
        blockers = _company_delete_blockers(company.id)
        if blockers:
            return jsonify({"error": "Company has data; delete blocked", "details": blockers}), 400
        old_company = {"id": company.id, "name": company.name, "company_type": getattr(company, "company_type", None) or "pharmacy"}
        try:
            _cleanup_empty_company_dependencies(company.id)
            db.session.delete(company)
            db.session.commit()
        except Exception as exc:
            db.session.rollback()
            current_app.logger.exception("Company delete failed for company_id=%s", company_id)
            return jsonify({"error": "Failed to delete company", "details": str(exc)}), 500
        log_action("company_deleted", old_company, None)
        return jsonify({"deleted": True})

    @app.route("/admin/companies/<int:company_id>/archive", methods=["POST"])
    @require_auth
    def archive_company(company_id: int):
        if g.current_user.role not in ROLE_PLATFORM_ADMINS:
            return jsonify({"error": "Only superadmin"}), 403
        company = Company.query.get_or_404(company_id)
        company.is_active = False
        db.session.commit()
        log_action("company_archived", {"id": company.id}, company.id)
        return jsonify(company.to_dict())

    @app.route("/admin/companies/<int:company_id>", methods=["DELETE"])
    @require_auth
    def delete_company(company_id: int):
        if g.current_user.role not in ROLE_PLATFORM_ADMINS:
            return jsonify({"error": "Only superadmin"}), 403
        company = Company.query.get_or_404(company_id)
        if company.is_active:
            return jsonify({"error": "Deactivate company before deleting"}), 400
        blockers = _company_delete_blockers(company.id)
        if blockers:
            return jsonify({"error": "Company has data; delete blocked", "details": blockers}), 400
        old_company = {"id": company.id, "name": company.name, "company_type": getattr(company, "company_type", None) or "pharmacy"}
        try:
            _cleanup_empty_company_dependencies(company.id)
            db.session.delete(company)
            db.session.commit()
        except Exception as exc:
            db.session.rollback()
            current_app.logger.exception("Company delete failed for company_id=%s", company_id)
            return jsonify({"error": "Failed to delete company", "details": str(exc)}), 500
        log_action("company_deleted", old_company, None)
        return jsonify({"deleted": True})

    @app.route("/companies/<int:company_id>/users", methods=["POST"])
    @require_auth
    def add_user_to_company(company_id: int):
        data = request.get_json() or {}
        username = data.get("username", "").strip()
        role = (data.get("role") or "staff").strip().lower() or "staff"
        password = data.get("password", "")
        email = (data.get("email") or "").strip() or None
        first_name = (data.get("first_name") or "").strip()
        last_name = (data.get("last_name") or "").strip()
        if not username:
            return jsonify({"error": "Username required"}), 400
        role_obj = Role.query.filter_by(name=role).first()
        if not role_obj:
            return jsonify({"error": "Unknown role"}), 400

        company = Company.query.get(company_id)
        if not company:
            return jsonify({"error": "Company not found"}), 404

        requester_membership = UserCompany.query.filter_by(user_id=g.current_user.id, company_id=company_id).first()
        is_platform_admin = g.current_user.role in ROLE_PLATFORM_ADMINS
        if not is_platform_admin:
            if not requester_membership or requester_membership.role not in ["admin", "manager"]:
                return jsonify({"error": "Only company admins can manage access"}), 403
            if role in ["superuser", "admin", "manager"]:
                return jsonify({"error": "Only platform admin can assign admin/manager/superuser roles"}), 403

        user = User.query.filter_by(username=username).first()
        if not user:
            # Company admins/managers can create any non-platform role; platform admins can create any role.
            if not is_platform_admin:
                if role in ["superuser", "admin", "manager"]:
                    return jsonify({"error": "Only platform admin can create manager/admin/superuser users"}), 403
            if not password or len(password) < 6:
                return jsonify({"error": "Password required (min 6 characters)"}), 400
            user = User(username=username, email=email, role=role)
            if first_name:
                user.first_name = first_name
            if last_name:
                user.last_name = last_name
            user.set_password(password)
            db.session.add(user)
            db.session.commit()
        else:
            # Platform admins may align platform role when assigning
            if is_platform_admin and user.role not in ROLE_PLATFORM_ADMINS:
                user.role = role
                if email is not None:
                    user.email = email
                db.session.commit()

        # Manager/Salesman users can only be assigned to ONE company total.
        other_links = (
            UserCompany.query.filter(UserCompany.user_id == user.id, UserCompany.company_id != company.id).count()
        )
        has_manager_elsewhere = (
            UserCompany.query.filter(
                UserCompany.user_id == user.id,
                UserCompany.company_id != company.id,
                UserCompany.role == "manager",
            ).count()
            > 0
        )
        has_salesman_elsewhere = (
            UserCompany.query.filter(
                UserCompany.user_id == user.id,
                UserCompany.company_id != company.id,
                UserCompany.role == "salesman",
            ).count()
            > 0
        )
        if role in {"manager", "salesman"} and other_links > 0:
            return (
                jsonify({"error": f"{role.title()} can access only one company. Downgrade existing assignments first."}),
                400,
            )
        if role != "manager" and has_manager_elsewhere:
            return jsonify({"error": "User is a manager in another company. Downgrade manager role before adding to a different company."}), 400
        if role != "salesman" and has_salesman_elsewhere:
            return jsonify({"error": "User is a salesman in another company. Downgrade salesman role before adding to a different company."}), 400

        link = UserCompany.query.filter_by(user_id=user.id, company_id=company.id).first()
        if link:
            link.role = role
        else:
            db.session.add(UserCompany(user=user, company=company, role=role))
        db.session.commit()
        return jsonify({"ok": True, "user": user.to_dict()})

    # Company settings
    def _normalize_expiry_rule_entries(raw_rules) -> list[dict]:
        if not isinstance(raw_rules, list):
            return []
        normalized: list[dict] = []
        for row in raw_rules:
            if not isinstance(row, dict):
                continue
            name = str(row.get("name") or "").strip()
            if not name:
                continue
            alert_unit = str(row.get("alert_unit") or "days").strip().lower()
            shelf_unit = str(row.get("shelf_removal_unit") or "days").strip().lower()
            if alert_unit not in {"days", "months"}:
                alert_unit = "days"
            if shelf_unit not in {"days", "months"}:
                shelf_unit = "days"
            try:
                alert_value = max(0, int(float(row.get("alert_value") or 0)))
            except Exception:
                alert_value = 0
            try:
                shelf_value = max(0, int(float(row.get("shelf_removal_value") or 0)))
            except Exception:
                shelf_value = 0
            normalized.append(
                {
                    "name": name,
                    "alert_value": alert_value,
                    "alert_unit": alert_unit,
                    "shelf_removal_value": shelf_value,
                    "shelf_removal_unit": shelf_unit,
                }
            )
        normalized.sort(key=lambda row: str(row.get("name") or "").lower())
        return normalized

    def _expiry_rule_days(value, unit: str) -> int:
        try:
            base = max(0, int(float(value or 0)))
        except Exception:
            base = 0
        unit_lc = str(unit or "days").strip().lower()
        if unit_lc == "months":
            return base * 30
        return base

    def _normalize_rule_key(value: str | None) -> str:
        return " ".join(str(value or "").strip().lower().split())

    def _effective_expiry_policy(
        company: Company,
        *,
        supplier_name: str | None = None,
        manufacturer_name: str | None = None,
        product: Product | None = None,
    ) -> dict:
        supplier_rules = {
            _normalize_rule_key(row.get("name")): row
            for row in _normalize_expiry_rule_entries(getattr(company, "supplier_expiry_rules", None) or [])
        }
        manufacturer_rules = {
            _normalize_rule_key(row.get("name")): row
            for row in _normalize_expiry_rule_entries(getattr(company, "manufacturer_expiry_rules", None) or [])
        }

        product_shelf_days = max(0, int(getattr(product, "shelf_removal_offset_days", 0) or 0)) if product else 0
        default_alert_days = product_shelf_days if product_shelf_days > 0 else 60
        default_shelf_days = product_shelf_days
        source = "product"

        supplier_rule = supplier_rules.get(_normalize_rule_key(supplier_name))
        manufacturer_rule = manufacturer_rules.get(_normalize_rule_key(manufacturer_name))
        active_rule = supplier_rule or manufacturer_rule
        if active_rule:
            source = "supplier" if supplier_rule else "manufacturer"
            return {
                "source": source,
                "alert_days": _expiry_rule_days(active_rule.get("alert_value"), active_rule.get("alert_unit", "days")),
                "shelf_removal_days": _expiry_rule_days(
                    active_rule.get("shelf_removal_value"),
                    active_rule.get("shelf_removal_unit", "days"),
                ),
            }
        return {
            "source": source,
            "alert_days": default_alert_days,
            "shelf_removal_days": default_shelf_days,
        }

    DEFAULT_CLINIC_ENCOUNTER_PARAMETERS = [
        {"key": "blood_pressure", "label": "Blood Pressure", "unit": "mm Hg", "value_type": "bp", "enabled": True},
        {"key": "pulse", "label": "Pulse", "unit": "per minute", "value_type": "number", "enabled": True},
        {"key": "temperature", "label": "Temperature", "unit": "deg F", "value_type": "number", "enabled": True},
        {"key": "respiratory_rate", "label": "Respiratory Rate", "unit": "per minute", "value_type": "number", "enabled": True},
        {"key": "weight", "label": "Weight", "unit": "kg", "value_type": "number", "enabled": True},
        {"key": "height", "label": "Height", "unit": "cm", "value_type": "number", "enabled": True},
    ]

    def _encounter_key(label: str, fallback_index: int) -> str:
        key = re.sub(r"[^a-z0-9]+", "_", str(label or "").strip().lower()).strip("_")
        return key or f"parameter_{fallback_index + 1}"

    def _normalize_clinic_encounter_parameters(raw_entries):
        entries = raw_entries if isinstance(raw_entries, list) else []
        if not entries:
            entries = DEFAULT_CLINIC_ENCOUNTER_PARAMETERS
        normalized = []
        seen = set()
        for idx, entry in enumerate(entries):
            if not isinstance(entry, dict):
                continue
            label = str(entry.get("label") or "").strip()
            if not label:
                continue
            key = _encounter_key(entry.get("key") or label, idx)
            base_key = key
            suffix = 2
            while key in seen:
                key = f"{base_key}_{suffix}"
                suffix += 1
            seen.add(key)
            value_type = str(entry.get("value_type") or "text").strip().lower()
            if value_type not in {"text", "number", "bp"}:
                value_type = "text"
            normalized.append(
                {
                    "key": key,
                    "label": label[:80],
                    "unit": str(entry.get("unit") or "").strip()[:40],
                    "value_type": value_type,
                    "enabled": bool(entry.get("enabled", True)),
                }
            )
        return normalized or DEFAULT_CLINIC_ENCOUNTER_PARAMETERS

    def _clinic_encounter_parameters_for_company(company):
        return _normalize_clinic_encounter_parameters(getattr(company, "clinic_encounter_parameters", None))

    @app.route("/company/settings", methods=["GET"])
    @require_auth
    @company_required(["admin", "manager", "superuser", "superadmin"])
    def get_company_settings():
        company = g.current_company
        return jsonify(
            {
                "company_id": company.id,
                "company_type": getattr(company, "company_type", None) or "pharmacy",
                "staff_login_requires_shop_open": _company_requires_shop_open(company),
                "clinic_fee": float(getattr(company, "clinic_fee", None) or 0),
                "clinic_fee_duration_value": int(getattr(company, "clinic_fee_duration_value", None) or 1),
                "clinic_fee_duration": getattr(company, "clinic_fee_duration", None) or "Day",
                "clinic_encounter_parameters": _clinic_encounter_parameters_for_company(company),
                "supplier_expiry_rules": _normalize_expiry_rule_entries(getattr(company, "supplier_expiry_rules", None) or []),
                "manufacturer_expiry_rules": _normalize_expiry_rule_entries(getattr(company, "manufacturer_expiry_rules", None) or []),
            }
        )

    @app.route("/company/settings", methods=["PUT"])
    @require_auth
    @company_required(["admin", "manager", "superuser", "superadmin"])
    def update_company_settings():
        data = request.get_json() or {}
        company = g.current_company
        settings_changed: dict[str, object] = {"company_id": company.id}

        if "staff_login_requires_shop_open" in data:
            raw_flag = data.get("staff_login_requires_shop_open")
            if isinstance(raw_flag, str):
                value = raw_flag.strip().lower()
                if value in {"1", "true", "yes", "on"}:
                    parsed_flag = True
                elif value in {"0", "false", "no", "off"}:
                    parsed_flag = False
                else:
                    return jsonify({"error": "Invalid staff_login_requires_shop_open"}), 400
            else:
                parsed_flag = bool(raw_flag)
            company.staff_login_requires_shop_open = parsed_flag
            settings_changed["staff_login_requires_shop_open"] = bool(company.staff_login_requires_shop_open)

        if "company_type" in data:
            company.company_type = normalize_company_type(data.get("company_type"))
            settings_changed["company_type"] = company.company_type

        if "clinic_fee" in data:
            raw_fee = data.get("clinic_fee")
            if raw_fee in [None, ""]:
                company.clinic_fee = None
                settings_changed["clinic_fee"] = None
            else:
                try:
                    company.clinic_fee = max(0, round(float(raw_fee), 2))
                except Exception:
                    return jsonify({"error": "Invalid clinic fee"}), 400
                settings_changed["clinic_fee"] = float(company.clinic_fee or 0)

        if "clinic_fee_duration_value" in data:
            raw_duration_value = data.get("clinic_fee_duration_value")
            try:
                duration_value = int(float(raw_duration_value))
            except Exception:
                return jsonify({"error": "Invalid clinic fee duration value"}), 400
            if duration_value < 1:
                return jsonify({"error": "Clinic fee duration value must be at least 1"}), 400
            company.clinic_fee_duration_value = duration_value
            settings_changed["clinic_fee_duration_value"] = duration_value

        if "clinic_fee_duration" in data:
            fee_duration = str(data.get("clinic_fee_duration") or "Day").strip().title()
            if fee_duration not in {"Day", "Month", "Year"}:
                return jsonify({"error": "Invalid clinic fee duration"}), 400
            company.clinic_fee_duration = fee_duration
            settings_changed["clinic_fee_duration"] = fee_duration

        if "clinic_encounter_parameters" in data:
            company.clinic_encounter_parameters = _normalize_clinic_encounter_parameters(data.get("clinic_encounter_parameters"))
            settings_changed["clinic_encounter_parameters"] = company.clinic_encounter_parameters

        if "supplier_expiry_rules" in data:
            company.supplier_expiry_rules = _normalize_expiry_rule_entries(data.get("supplier_expiry_rules"))
            settings_changed["supplier_expiry_rules"] = company.supplier_expiry_rules

        if "manufacturer_expiry_rules" in data:
            company.manufacturer_expiry_rules = _normalize_expiry_rule_entries(data.get("manufacturer_expiry_rules"))
            settings_changed["manufacturer_expiry_rules"] = company.manufacturer_expiry_rules

        if len(settings_changed) <= 1:
            return jsonify({"error": "No supported company settings were provided"}), 400
        db.session.commit()
        log_action(
            "company_settings_updated",
            settings_changed,
            company.id,
        )
        return jsonify(
            {
                "company_id": company.id,
                "company_type": getattr(company, "company_type", None) or "pharmacy",
                "staff_login_requires_shop_open": _company_requires_shop_open(company),
                "clinic_fee": float(getattr(company, "clinic_fee", None) or 0),
                "clinic_fee_duration_value": int(getattr(company, "clinic_fee_duration_value", None) or 1),
                "clinic_fee_duration": getattr(company, "clinic_fee_duration", None) or "Day",
                "clinic_encounter_parameters": _clinic_encounter_parameters_for_company(company),
                "supplier_expiry_rules": _normalize_expiry_rule_entries(getattr(company, "supplier_expiry_rules", None) or []),
                "manufacturer_expiry_rules": _normalize_expiry_rule_entries(getattr(company, "manufacturer_expiry_rules", None) or []),
            }
        )

    def _clinic_company_required():
        if normalize_company_type(getattr(g.current_company, "company_type", "pharmacy")) != "clinic":
            return jsonify({"error": "Selected company is not configured as Clinic"}), 400
        return None

    def _parse_clinic_date(raw_value, *, field_name: str = "scheduled_date"):
        value = str(raw_value or "").strip()
        if not value:
            return None, jsonify({"error": f"{field_name} is required"}), 400
        try:
            return date.fromisoformat(value), None, None
        except Exception:
            return None, jsonify({"error": f"Invalid {field_name}. Use YYYY-MM-DD."}), 400

    def _parse_optional_clinic_date(raw_value, *, field_name: str):
        value = str(raw_value or "").strip()
        if not value:
            return None, None, None
        try:
            return date.fromisoformat(value), None, None
        except Exception:
            return None, jsonify({"error": f"Invalid {field_name}. Use YYYY-MM-DD."}), 400

    def _normalize_clinic_payment_status(raw_value):
        value = str(raw_value or "unpaid").strip().lower().replace("-", "_").replace(" ", "_")
        if value in {"repayment", "repayment_required_duration_exceeded"}:
            value = "repayment_required"
        if value not in {"paid", "unpaid", "repayment_required"}:
            return None
        return value

    def _normalize_clinic_visit_type(raw_value):
        value = str(raw_value or "first_visit").strip().lower().replace("-", "_").replace(" ", "_")
        if value in {"followup", "follow_up_visit"}:
            value = "follow_up"
        if value not in {"first_visit", "follow_up"}:
            return None
        return value

    def _age_from_dob(dob_value: date | None) -> int | None:
        if not dob_value:
            return None
        today = _today_ad()
        years = today.year - dob_value.year - ((today.month, today.day) < (dob_value.month, dob_value.day))
        return max(0, years)

    @app.route("/clinic/patients", methods=["GET"])
    @require_auth
    @company_required()
    def list_clinic_patients():
        clinic_error = _clinic_company_required()
        if clinic_error:
            return clinic_error
        query = ClinicPatient.query.filter_by(company_id=g.current_company.id).order_by(
            ClinicPatient.created_at.desc(),
            ClinicPatient.id.desc(),
        )
        search = str(request.args.get("search") or "").strip()
        if search:
            like = f"%{search}%"
            query = query.filter(or_(ClinicPatient.full_name.ilike(like), ClinicPatient.phone.ilike(like), ClinicPatient.patient_code.ilike(like)))
        patients, meta = paginate_query(query)
        return jsonify({"items": [patient.to_dict() for patient in patients], "meta": meta})

    @app.route("/clinic/patients", methods=["POST"])
    @require_auth
    @company_required(["admin", "manager", "staff", "salesman", "superuser", "superadmin"])
    def create_clinic_patient():
        clinic_error = _clinic_company_required()
        if clinic_error:
            return clinic_error
        data = request.get_json() or {}
        full_name = str(data.get("full_name") or "").strip()
        if not full_name:
            return jsonify({"error": "Patient name is required"}), 400
        age = data.get("age")
        if age in [None, ""]:
            age_value = None
        else:
            try:
                age_value = max(0, int(float(age)))
            except Exception:
                return jsonify({"error": "Invalid age"}), 400
        dob_ad, dob_error, dob_status = _parse_optional_clinic_date(data.get("dob_ad"), field_name="dob_ad")
        if dob_error:
            return dob_error, dob_status
        if dob_ad and age_value is None:
            age_value = _age_from_dob(dob_ad)
        patient = ClinicPatient(
            company_id=g.current_company.id,
            full_name=full_name,
            phone=str(data.get("phone") or "").strip(),
            age=age_value,
            dob_ad=dob_ad,
            dob_bs=str(data.get("dob_bs") or "").strip(),
            gender=str(data.get("gender") or "").strip(),
            address=str(data.get("address") or "").strip(),
            notes=str(data.get("notes") or "").strip(),
            image_data=str(data.get("image_data") or "").strip(),
            registered_by_user_id=g.current_user.id,
        )
        db.session.add(patient)
        db.session.flush()
        patient.patient_code = f"PT-{patient.company_id}-{patient.id:06d}"
        db.session.commit()
        log_action(
            "clinic_patient_created",
            {"id": patient.id, "patient_code": patient.patient_code, "full_name": patient.full_name},
            g.current_company.id,
        )
        return jsonify(patient.to_dict()), 201

    @app.route("/clinic/appointments/today", methods=["GET"])
    @require_auth
    @company_required()
    def list_clinic_appointments_today():
        clinic_error = _clinic_company_required()
        if clinic_error:
            return clinic_error
        today = _today_ad()
        appointments = (
            ClinicAppointment.query.filter_by(company_id=g.current_company.id, scheduled_date=today)
            .order_by(ClinicAppointment.scheduled_time.asc(), ClinicAppointment.id.asc())
            .all()
        )
        return jsonify([appointment.to_dict() for appointment in appointments])

    @app.route("/clinic/appointments", methods=["GET"])
    @require_auth
    @company_required()
    def list_clinic_appointments():
        clinic_error = _clinic_company_required()
        if clinic_error:
            return clinic_error
        query = ClinicAppointment.query.filter_by(company_id=g.current_company.id)
        raw_date = request.args.get("date")
        if raw_date:
            scheduled_date, error_response, status_code = _parse_clinic_date(raw_date)
            if error_response:
                return error_response, status_code
            query = query.filter(ClinicAppointment.scheduled_date == scheduled_date)
        else:
            raw_date_from = request.args.get("date_from")
            raw_date_to = request.args.get("date_to")
            if raw_date_from:
                date_from, error_response, status_code = _parse_clinic_date(raw_date_from, field_name="date_from")
                if error_response:
                    return error_response, status_code
                query = query.filter(ClinicAppointment.scheduled_date >= date_from)
            if raw_date_to:
                date_to, error_response, status_code = _parse_clinic_date(raw_date_to, field_name="date_to")
                if error_response:
                    return error_response, status_code
                query = query.filter(ClinicAppointment.scheduled_date <= date_to)
        raw_patient_id = request.args.get("patient_id")
        if raw_patient_id:
            try:
                patient_id = int(raw_patient_id)
            except Exception:
                return jsonify({"error": "Invalid patient_id"}), 400
            query = query.filter(ClinicAppointment.patient_id == patient_id)
        query = query.order_by(ClinicAppointment.scheduled_date.desc(), ClinicAppointment.scheduled_time.asc())
        appointments, meta = paginate_query(query)
        return jsonify({"items": [appointment.to_dict() for appointment in appointments], "meta": meta})

    @app.route("/clinic/appointments", methods=["POST"])
    @require_auth
    @company_required(["admin", "manager", "staff", "salesman", "superuser", "superadmin"])
    def create_clinic_appointment():
        clinic_error = _clinic_company_required()
        if clinic_error:
            return clinic_error
        data = request.get_json() or {}
        try:
            patient_id = int(data.get("patient_id"))
        except Exception:
            return jsonify({"error": "patient_id is required"}), 400
        patient = ClinicPatient.query.filter_by(id=patient_id, company_id=g.current_company.id).first()
        if not patient:
            return jsonify({"error": "Patient not found"}), 404
        scheduled_date, error_response, status_code = _parse_clinic_date(data.get("scheduled_date"))
        if error_response:
            return error_response, status_code
        status = str(data.get("status") or "scheduled").strip().lower()
        if status not in {"scheduled", "arrived", "completed", "cancelled"}:
            status = "scheduled"
        payment_status = _normalize_clinic_payment_status(data.get("payment_status"))
        if payment_status is None:
            return jsonify({"error": "Invalid appointment payment status"}), 400
        visit_type = _normalize_clinic_visit_type(data.get("visit_type"))
        if visit_type is None:
            return jsonify({"error": "Invalid appointment visit type"}), 400
        appointment = ClinicAppointment(
            company_id=g.current_company.id,
            patient_id=patient.id,
            scheduled_date=scheduled_date,
            scheduled_time=str(data.get("scheduled_time") or "").strip(),
            reason=str(data.get("reason") or "").strip(),
            payment_status=payment_status,
            visit_type=visit_type,
            status=status,
            created_by_user_id=g.current_user.id,
        )
        db.session.add(appointment)
        db.session.commit()
        log_action(
            "clinic_appointment_created",
            {"id": appointment.id, "patient_id": patient.id, "scheduled_date": appointment.scheduled_date.isoformat()},
            g.current_company.id,
        )
        return jsonify(appointment.to_dict()), 201

    @app.route("/clinic/appointments/<int:appointment_id>", methods=["PUT"])
    @require_auth
    @company_required(["admin", "manager", "staff", "salesman", "superuser", "superadmin"])
    def update_clinic_appointment(appointment_id: int):
        clinic_error = _clinic_company_required()
        if clinic_error:
            return clinic_error
        appointment = ClinicAppointment.query.filter_by(id=appointment_id, company_id=g.current_company.id).first()
        if not appointment:
            return jsonify({"error": "Appointment not found"}), 404
        data = request.get_json() or {}
        if data.get("scheduled_date") is not None:
            scheduled_date, error_response, status_code = _parse_clinic_date(data.get("scheduled_date"))
            if error_response:
                return error_response, status_code
            appointment.scheduled_date = scheduled_date
        if data.get("scheduled_time") is not None:
            appointment.scheduled_time = str(data.get("scheduled_time") or "").strip()
        if data.get("reason") is not None:
            appointment.reason = str(data.get("reason") or "").strip()
        if data.get("payment_status") is not None:
            payment_status = _normalize_clinic_payment_status(data.get("payment_status"))
            if payment_status is None:
                return jsonify({"error": "Invalid appointment payment status"}), 400
            appointment.payment_status = payment_status
        if data.get("visit_type") is not None:
            visit_type = _normalize_clinic_visit_type(data.get("visit_type"))
            if visit_type is None:
                return jsonify({"error": "Invalid appointment visit type"}), 400
            appointment.visit_type = visit_type
        if data.get("status") is not None:
            status = str(data.get("status") or "scheduled").strip().lower()
            if status not in {"scheduled", "arrived", "completed", "cancelled"}:
                return jsonify({"error": "Invalid appointment status"}), 400
            appointment.status = status
        db.session.commit()
        log_action(
            "clinic_appointment_updated",
            {"id": appointment.id, "patient_id": appointment.patient_id, "scheduled_date": appointment.scheduled_date.isoformat()},
            g.current_company.id,
        )
        return jsonify(appointment.to_dict())

    def _normalize_clinic_encounter_values(company, raw_values):
        values = raw_values if isinstance(raw_values, dict) else {}
        normalized = {}
        parameters = _clinic_encounter_parameters_for_company(company)
        allowed = {param["key"]: param for param in parameters if param.get("enabled", True)}
        for key, param in allowed.items():
            raw_value = values.get(key)
            value = str(raw_value or "").strip()
            if not value:
                continue
            if param.get("value_type") == "number":
                try:
                    numeric_value = float(value)
                except Exception:
                    raise ValueError(f"Invalid numeric value for {param.get('label') or key}")
                value = str(int(numeric_value)) if numeric_value.is_integer() else str(round(numeric_value, 2))
            elif param.get("value_type") == "bp":
                if not re.match(r"^\d{2,3}\s*/\s*\d{2,3}$", value):
                    raise ValueError(f"Invalid BP value for {param.get('label') or key}. Use format 120/80.")
                value = re.sub(r"\s+", "", value)
            normalized[key] = value[:80]
        return normalized

    @app.route("/clinic/encounters", methods=["GET"])
    @require_auth
    @company_required()
    def list_clinic_encounters():
        clinic_error = _clinic_company_required()
        if clinic_error:
            return clinic_error
        query = ClinicEncounter.query.filter_by(company_id=g.current_company.id)
        raw_patient_id = request.args.get("patient_id")
        if raw_patient_id:
            try:
                patient_id = int(raw_patient_id)
            except Exception:
                return jsonify({"error": "Invalid patient_id"}), 400
            query = query.filter(ClinicEncounter.patient_id == patient_id)
        query = query.order_by(ClinicEncounter.encounter_date.desc(), ClinicEncounter.id.desc())
        encounters, meta = paginate_query(query)
        return jsonify({"items": [encounter.to_dict() for encounter in encounters], "meta": meta})

    @app.route("/clinic/encounters", methods=["POST"])
    @require_auth
    @company_required(["admin", "manager", "staff", "salesman", "superuser", "superadmin"])
    def create_clinic_encounter():
        clinic_error = _clinic_company_required()
        if clinic_error:
            return clinic_error
        data = request.get_json() or {}
        try:
            patient_id = int(data.get("patient_id"))
        except Exception:
            return jsonify({"error": "patient_id is required"}), 400
        patient = ClinicPatient.query.filter_by(id=patient_id, company_id=g.current_company.id).first()
        if not patient:
            return jsonify({"error": "Patient not found"}), 404
        encounter_date, error_response, status_code = _parse_clinic_date(data.get("encounter_date") or _today_ad().isoformat(), field_name="encounter_date")
        if error_response:
            return error_response, status_code
        appointment_id = None
        if data.get("appointment_id") not in [None, ""]:
            try:
                appointment_id = int(data.get("appointment_id"))
            except Exception:
                return jsonify({"error": "Invalid appointment_id"}), 400
            appointment = ClinicAppointment.query.filter_by(
                id=appointment_id,
                patient_id=patient.id,
                company_id=g.current_company.id,
            ).first()
            if not appointment:
                return jsonify({"error": "Appointment not found"}), 404
        try:
            values = _normalize_clinic_encounter_values(g.current_company, data.get("values") or {})
        except ValueError as exc:
            return jsonify({"error": str(exc)}), 400
        if not values:
            return jsonify({"error": "At least one encounter value is required"}), 400
        encounter = ClinicEncounter(
            company_id=g.current_company.id,
            patient_id=patient.id,
            appointment_id=appointment_id,
            encounter_date=encounter_date,
            values=values,
            created_by_user_id=g.current_user.id,
        )
        db.session.add(encounter)
        db.session.commit()
        log_action(
            "clinic_encounter_created",
            {"id": encounter.id, "patient_id": patient.id, "encounter_date": encounter.encounter_date.isoformat()},
            g.current_company.id,
        )
        return jsonify(encounter.to_dict()), 201

    # Company mail configuration
    @app.route("/company/mail", methods=["GET"])
    @require_auth
    @company_required(["admin", "manager"])
    def get_company_mail():
        company = g.current_company
        return jsonify(
            {
                "email_host": company.email_host or "",
                "email_port": company.email_port or 587,
                "email_username": company.email_username or "",
                "email_use_tls": True if company.email_use_tls is None else bool(company.email_use_tls),
                "email_use_ssl": bool(company.email_use_ssl),
                "imap_host": company.imap_host or "",
                "imap_port": company.imap_port or 993,
                "imap_use_ssl": True if company.imap_use_ssl is None else bool(company.imap_use_ssl),
                "mail_sync_enabled": bool(company.mail_sync_enabled),
                "has_password": bool(company.email_password),
            }
        )

    @app.route("/company/mail", methods=["POST"])
    @require_auth
    @company_required(["admin", "manager"])
    def update_company_mail():
        data = request.get_json() or {}
        host = (data.get("email_host") or "").strip()
        port = data.get("email_port")
        username = (data.get("email_username") or "").strip()
        password = data.get("email_password")
        use_tls = data.get("email_use_tls", True)
        use_ssl = data.get("email_use_ssl", False)
        imap_host = (data.get("imap_host") or "").strip()
        imap_port = data.get("imap_port")
        imap_use_ssl = data.get("imap_use_ssl", True)
        mail_sync_enabled = data.get("mail_sync_enabled", False)

        # require essentials if attempting to set mail
        if host and not port:
            return jsonify({"error": "SMTP port is required"}), 400
        if host and not username:
            return jsonify({"error": "SMTP username is required"}), 400
        if port:
            try:
                port = int(port)
            except Exception:
                return jsonify({"error": "Invalid port"}), 400
        if imap_port:
            try:
                imap_port = int(imap_port)
            except Exception:
                return jsonify({"error": "Invalid IMAP port"}), 400
        company = g.current_company
        company.email_host = host or None
        company.email_port = port or None
        company.email_username = username or None
        if password not in (None, ""):
            company.email_password = password
        company.email_use_tls = bool(use_tls)
        company.email_use_ssl = bool(use_ssl)
        if imap_host:
            company.imap_host = imap_host
        if imap_port:
            company.imap_port = imap_port
        company.imap_use_ssl = bool(imap_use_ssl)
        company.mail_sync_enabled = bool(mail_sync_enabled)
        db.session.commit()
        log_action("company_mail_updated", {"company_id": company.id, "email_host": company.email_host}, company.id)
        return jsonify(
            {
                "email_host": company.email_host or "",
                "email_port": company.email_port or 587,
                "email_username": company.email_username or "",
                "email_use_tls": bool(company.email_use_tls),
                "email_use_ssl": bool(company.email_use_ssl),
                "imap_host": company.imap_host or "",
                "imap_port": company.imap_port or 993,
                "imap_use_ssl": bool(company.imap_use_ssl),
                "mail_sync_enabled": bool(company.mail_sync_enabled),
                "has_password": bool(company.email_password),
            }
        )

    @app.route("/company/mail/test", methods=["POST"])
    @require_auth
    @company_required(["admin", "manager"])
    def test_company_mail():
        company = g.current_company
        data = request.get_json() or {}
        to_email = (data.get("to_email") or getattr(g.current_user, "email", "") or "").strip()
        if not to_email:
            return jsonify({"error": "Recipient email is required"}), 400
        if not (company.email_host and company.email_port and company.email_username and company.email_password):
            return jsonify({"error": "Mail settings incomplete"}), 400
        try:
            send_company_email(
                company,
                to_email,
                "Pharmacy mail test",
                (
                    f"Hello,\n\n"
                    f"This is a test email from {company.name}.\n"
                    f"SMTP host: {company.email_host}\n"
                    f"Sent at: {datetime.now(timezone.utc).isoformat()}\n\n"
                    f"If you received this, outgoing mail is working."
                ),
                log_outbox=False,
            )
            log_action("company_mail_test_sent", {"company_id": company.id, "to_email": to_email}, company.id)
            return jsonify({"sent": True, "to_email": to_email})
        except smtplib.SMTPAuthenticationError:
            return jsonify({"error": "SMTP authentication failed. Check username/app password."}), 400
        except smtplib.SMTPConnectError:
            return jsonify({"error": "SMTP connection failed. Check host, port, TLS/SSL, and network access."}), 400
        except TimeoutError:
            return jsonify({"error": "SMTP connection timed out. Check host, port, and firewall/network access."}), 400
        except Exception as exc:
            app.logger.exception("Company mail test failed for company_id=%s", company.id)
            return jsonify({"error": f"Failed to send test email: {exc}"}), 400

    @app.route("/company/cheque-settings", methods=["GET"])
    @require_auth
    @company_required(["admin", "manager"])
    def get_company_cheque_settings():
        company = g.current_company
        return jsonify(
            {
                "cheque_bank_name": company.cheque_bank_name or "",
                "cheque_account_name": company.cheque_account_name or "",
                "cheque_account_number": company.cheque_account_number or "",
                "cheque_branch": company.cheque_branch or "",
            }
        )

    @app.route("/company/cheque-settings", methods=["POST"])
    @require_auth
    @company_required(["admin", "manager"])
    def update_company_cheque_settings():
        data = request.get_json() or {}
        company = g.current_company
        company.cheque_bank_name = (data.get("cheque_bank_name") or "").strip() or None
        company.cheque_account_name = (data.get("cheque_account_name") or "").strip() or None
        company.cheque_account_number = (data.get("cheque_account_number") or "").strip() or None
        company.cheque_branch = (data.get("cheque_branch") or "").strip() or None
        db.session.commit()
        log_action("company_cheque_settings_updated", {"company_id": company.id}, company.id)
        return jsonify(
            {
                "cheque_bank_name": company.cheque_bank_name or "",
                "cheque_account_name": company.cheque_account_name or "",
                "cheque_account_number": company.cheque_account_number or "",
                "cheque_branch": company.cheque_branch or "",
            }
        )

    def _default_cheque_template():
        return {
            "width_mm": 210.0,
            "height_mm": 95.0,
            "date_x_mm": 150.0,
            "date_y_mm": 85.0,
            "date_box_w_mm": 6.0,
            "date_box_h_mm": 8.0,
            "date_char_spacing_mm": 5.0,
            "date_inner_spacing_mm": 0.8,
            "date_layout": "MM | YYYY | DD",
            "payee_x_mm": 12.0,
            "payee_y_mm": 72.0,
            "payee_line_width_mm": 140.0,
            "payee_line_height_mm": 6.0,
            "amount_words_x_mm": 12.0,
            "amount_words_y_mm": 60.0,
            "amount_words_line_width_mm": 186.0,
            "amount_words_line_height_mm": 6.0,
            "amount_words_2_x_mm": 12.0,
            "amount_words_2_y_mm": 66.0,
            "amount_words_2_line_width_mm": 186.0,
            "amount_words_2_line_height_mm": 6.0,
            "amount_number_x_mm": 160.0,
            "amount_number_y_mm": 63.0,
            "amount_number_box_w_mm": 40.0,
            "amount_number_box_h_mm": 10.0,
            "ac_payee_x_mm": 12.0,
            "ac_payee_y_mm": 15.0,
            "ac_payee_box_w_mm": 24.0,
            "ac_payee_box_h_mm": 8.0,
            "print_rotation_deg": 0,
            "image_data": "",
        }

    def _coerce_mm(value, fallback):
        try:
            return float(value)
        except Exception:
            return float(fallback)

    def _coerce_rotation(value, fallback=0):
        try:
            parsed = int(value)
        except Exception:
            parsed = int(fallback or 0)
        allowed = {0, 90, 180, 270}
        return parsed if parsed in allowed else int(fallback or 0)

    def _normalize_date_layout(value, fallback="MM | YYYY | DD"):
        def _tokens(raw_value: str):
            raw = (raw_value or "").upper().strip()
            cleaned = "".join((ch if ch in {"D", "M", "Y"} else "|") for ch in raw)
            parts = [p for p in cleaned.split("|") if p]
            out = []
            for part in parts:
                if "D" in part and "DD" not in out:
                    out.append("DD")
                elif "M" in part and "MM" not in out:
                    out.append("MM")
                elif "Y" in part and "YYYY" not in out:
                    out.append("YYYY")
            return out if len(out) == 3 else None

        parsed = _tokens(str(value or ""))
        if not parsed:
            parsed = _tokens(str(fallback or "")) or ["MM", "YYYY", "DD"]
        return " | ".join(parsed)

    @app.route("/company/cheque-templates", methods=["GET"])
    @require_auth
    @company_required(["admin", "manager"])
    def list_company_cheque_templates():
        company_id = g.current_company.id
        templates = ChequeTemplate.query.filter_by(company_id=company_id).all()
        data = []
        for tmpl in templates:
            payment_mode = PaymentMode.query.get(tmpl.payment_mode_id)
            data.append(
                {
                    **tmpl.to_dict(),
                    "payment_mode_name": payment_mode.name if payment_mode else None,
                }
            )
        return jsonify(data)

    @app.route("/company/cheque-templates/<int:payment_mode_id>", methods=["GET"])
    @require_auth
    @company_required(["admin", "manager"])
    def get_company_cheque_template(payment_mode_id: int):
        company_id = g.current_company.id
        payment_mode = PaymentMode.query.get(payment_mode_id)
        if not payment_mode or payment_mode.company_id != company_id:
            return jsonify({"error": "Invalid payment mode"}), 400
        tmpl = ChequeTemplate.query.filter_by(company_id=company_id, payment_mode_id=payment_mode_id).first()
        if tmpl:
            return jsonify(tmpl.to_dict())
        defaults = _default_cheque_template()
        defaults.update({"payment_mode_id": payment_mode_id, "company_id": company_id})
        return jsonify(defaults)

    @app.route("/company/cheque-templates/<int:payment_mode_id>", methods=["POST"])
    @require_auth
    @company_required(["admin", "manager"])
    def update_company_cheque_template(payment_mode_id: int):
        data = request.get_json() or {}
        company_id = g.current_company.id
        payment_mode = PaymentMode.query.get(payment_mode_id)
        if not payment_mode or payment_mode.company_id != company_id:
            return jsonify({"error": "Invalid payment mode"}), 400
        tmpl = ChequeTemplate.query.filter_by(company_id=company_id, payment_mode_id=payment_mode_id).first()
        if not tmpl:
            tmpl = ChequeTemplate(company_id=company_id, payment_mode_id=payment_mode_id)
            db.session.add(tmpl)
        defaults = _default_cheque_template()
        tmpl.width_mm = _coerce_mm(data.get("width_mm"), defaults["width_mm"])
        tmpl.height_mm = _coerce_mm(data.get("height_mm"), defaults["height_mm"])
        tmpl.date_x_mm = _coerce_mm(data.get("date_x_mm"), defaults["date_x_mm"])
        tmpl.date_y_mm = _coerce_mm(data.get("date_y_mm"), defaults["date_y_mm"])
        tmpl.date_box_w_mm = _coerce_mm(data.get("date_box_w_mm"), defaults["date_box_w_mm"])
        tmpl.date_box_h_mm = _coerce_mm(data.get("date_box_h_mm"), defaults["date_box_h_mm"])
        tmpl.date_char_spacing_mm = _coerce_mm(data.get("date_char_spacing_mm"), defaults["date_char_spacing_mm"])
        tmpl.date_inner_spacing_mm = _coerce_mm(data.get("date_inner_spacing_mm"), defaults["date_inner_spacing_mm"])
        tmpl.date_layout = _normalize_date_layout(data.get("date_layout"), defaults["date_layout"])
        tmpl.payee_x_mm = _coerce_mm(data.get("payee_x_mm"), defaults["payee_x_mm"])
        tmpl.payee_y_mm = _coerce_mm(data.get("payee_y_mm"), defaults["payee_y_mm"])
        tmpl.payee_line_width_mm = _coerce_mm(data.get("payee_line_width_mm"), defaults["payee_line_width_mm"])
        tmpl.payee_line_height_mm = _coerce_mm(data.get("payee_line_height_mm"), defaults["payee_line_height_mm"])
        tmpl.amount_words_x_mm = _coerce_mm(data.get("amount_words_x_mm"), defaults["amount_words_x_mm"])
        tmpl.amount_words_y_mm = _coerce_mm(data.get("amount_words_y_mm"), defaults["amount_words_y_mm"])
        tmpl.amount_words_line_width_mm = _coerce_mm(
            data.get("amount_words_line_width_mm"), defaults["amount_words_line_width_mm"]
        )
        tmpl.amount_words_line_height_mm = _coerce_mm(
            data.get("amount_words_line_height_mm"), defaults["amount_words_line_height_mm"]
        )
        tmpl.amount_words_2_x_mm = _coerce_mm(data.get("amount_words_2_x_mm"), defaults["amount_words_2_x_mm"])
        tmpl.amount_words_2_y_mm = _coerce_mm(data.get("amount_words_2_y_mm"), defaults["amount_words_2_y_mm"])
        tmpl.amount_words_2_line_width_mm = _coerce_mm(
            data.get("amount_words_2_line_width_mm"), defaults["amount_words_2_line_width_mm"]
        )
        tmpl.amount_words_2_line_height_mm = _coerce_mm(
            data.get("amount_words_2_line_height_mm"), defaults["amount_words_2_line_height_mm"]
        )
        tmpl.amount_number_x_mm = _coerce_mm(data.get("amount_number_x_mm"), defaults["amount_number_x_mm"])
        tmpl.amount_number_y_mm = _coerce_mm(data.get("amount_number_y_mm"), defaults["amount_number_y_mm"])
        tmpl.amount_number_box_w_mm = _coerce_mm(
            data.get("amount_number_box_w_mm"), defaults["amount_number_box_w_mm"]
        )
        tmpl.amount_number_box_h_mm = _coerce_mm(
            data.get("amount_number_box_h_mm"), defaults["amount_number_box_h_mm"]
        )
        tmpl.ac_payee_x_mm = _coerce_mm(data.get("ac_payee_x_mm"), defaults["ac_payee_x_mm"])
        tmpl.ac_payee_y_mm = _coerce_mm(data.get("ac_payee_y_mm"), defaults["ac_payee_y_mm"])
        tmpl.ac_payee_box_w_mm = _coerce_mm(data.get("ac_payee_box_w_mm"), defaults["ac_payee_box_w_mm"])
        tmpl.ac_payee_box_h_mm = _coerce_mm(data.get("ac_payee_box_h_mm"), defaults["ac_payee_box_h_mm"])
        tmpl.print_rotation_deg = _coerce_rotation(data.get("print_rotation_deg"), defaults["print_rotation_deg"])
        if data.get("image_data") is not None:
            tmpl.image_data = data.get("image_data") or None
        db.session.commit()
        log_action("company_cheque_template_updated", {"company_id": company_id, "payment_mode_id": payment_mode_id}, company_id)
        return jsonify(tmpl.to_dict())

    @app.route("/company/shop/status", methods=["GET"])
    @require_auth
    @company_required()
    def company_shop_status():
        today = _today_ad()
        company = g.current_company
        is_open = _normalize_shop_state(company, today)
        return jsonify(
            {
                "company_id": company.id,
                "date_ad": today.isoformat(),
                "shop_is_open": bool(is_open),
                "staff_login_requires_shop_open": _company_requires_shop_open(company),
                "shop_open_date": company.shop_open_date.isoformat() if company.shop_open_date else None,
                "shop_opened_at": company.shop_opened_at.isoformat() if company.shop_opened_at else None,
                "shop_opened_by_user_id": company.shop_opened_by_user_id,
            }
        )

    @app.route("/company/shop/open", methods=["POST"])
    @require_auth
    @company_required(["manager"])
    def company_shop_open():
        today = _today_ad()
        company = g.current_company
        company.shop_is_open = True
        company.shop_open_date = today
        company.shop_opened_at = datetime.utcnow()
        company.shop_opened_by_user_id = g.current_user.id
        db.session.commit()
        log_action("shop_opened", {"company_id": company.id, "date_ad": today.isoformat()}, company.id)
        return jsonify({"ok": True, "shop_is_open": True, "date_ad": today.isoformat()})

    @app.route("/company/shop/close", methods=["POST"])
    @require_auth
    @company_required(["manager"])
    def company_shop_close():
        today = _today_ad()
        company = g.current_company
        now_time = datetime.now().strftime("%H:%M")
        try:
            import nepali_datetime  # type: ignore
        except Exception:
            nepali_datetime = None
        # mark all staff/salesman as departed when shop closes
        memberships = (
            UserCompany.query.join(User, User.id == UserCompany.user_id)
            .filter(
                UserCompany.company_id == company.id,
                UserCompany.role.in_(["staff", "salesman"]),
                User.is_active.is_(True),
            )
            .all()
        )
        attendance_by_user_id = {
            a.user_id: a
            for a in Attendance.query.filter_by(company_id=company.id, date_ad=today).all()
        }
        for membership in memberships:
            row = attendance_by_user_id.get(membership.user_id)
            if not row:
                row = Attendance(company_id=company.id, user_id=membership.user_id, date_ad=today)
                db.session.add(row)
            row.is_present = False
            row.departure_time = now_time
            if not row.date_bs and nepali_datetime is not None:
                try:
                    row.date_bs = nepali_datetime.date.from_datetime_date(today).strftime("%Y-%m-%d")
                except Exception:
                    pass
        company.shop_is_open = False
        db.session.commit()
        log_action("shop_closed", {"company_id": company.id, "date_ad": today.isoformat()}, company.id)
        # notify clients to logout staff
        socketio.emit("shop:closed", {"company_id": company.id})
        return jsonify({"ok": True, "shop_is_open": False, "date_ad": today.isoformat()})

    @app.route("/company/shop/history", methods=["GET"])
    @require_auth
    @company_required(["admin", "manager", "superuser"])
    def company_shop_history():
        company = g.current_company
        limit = min(int(request.args.get("limit", 100)), 500)
        from_date = request.args.get("from")
        to_date = request.args.get("to")
        user_id = request.args.get("user_id")
        action = (request.args.get("action") or "").strip().lower()

        query = ActivityLog.query.filter(
            ActivityLog.company_id == company.id,
            ActivityLog.action.in_(["shop_opened", "shop_closed"]),
        )
        if user_id:
            try:
                query = query.filter(ActivityLog.user_id == int(user_id))
            except (TypeError, ValueError):
                pass
        if action in {"shop_opened", "shop_closed"}:
            query = query.filter(ActivityLog.action == action)
        if from_date:
            try:
                start = datetime.fromisoformat(str(from_date)).date()
                query = query.filter(ActivityLog.created_at >= datetime.combine(start, datetime.min.time()))
            except ValueError:
                pass
        if to_date:
            try:
                end = datetime.fromisoformat(str(to_date)).date()
                query = query.filter(ActivityLog.created_at <= datetime.combine(end, datetime.max.time()))
            except ValueError:
                pass

        logs = query.order_by(ActivityLog.created_at.asc()).limit(limit).all()
        user_ids = {l.user_id for l in logs if l.user_id}
        users = {}
        if user_ids:
            users = {u.id: u.username for u in User.query.filter(User.id.in_(user_ids)).all()}

        try:
            import nepali_datetime  # type: ignore
        except Exception:
            nepali_datetime = None

        def _bs_from_ad(ad_value: str | date | None) -> str | None:
            if not ad_value or nepali_datetime is None:
                return None
            try:
                if isinstance(ad_value, str):
                    ad_date = datetime.fromisoformat(ad_value).date()
                elif isinstance(ad_value, date):
                    ad_date = ad_value
                else:
                    return None
                return nepali_datetime.date.from_datetime_date(ad_date).strftime("%Y-%m-%d")
            except Exception:
                return None

        events = []
        for l in logs:
            details = l.details or {}
            date_ad = details.get("date_ad") or (l.created_at.date().isoformat() if l.created_at else None)
            events.append(
                {
                    "action": l.action,
                    "created_at": l.created_at,
                    "date_ad": date_ad,
                    "user_id": l.user_id,
                    "username": users.get(l.user_id) if l.user_id else None,
                }
            )

        rows = []
        open_stack = []
        for ev in events:
            if ev["action"] == "shop_opened":
                open_stack.append(ev)
                continue
            if ev["action"] != "shop_closed":
                continue
            opened = open_stack.pop() if open_stack else None
            date_ad = (opened or ev).get("date_ad")
            rows.append(
                {
                    "date_ad": date_ad,
                    "date_bs": _bs_from_ad(date_ad),
                    "opened_at": opened.get("created_at").isoformat() if opened and opened.get("created_at") else None,
                    "closed_at": ev.get("created_at").isoformat() if ev.get("created_at") else None,
                    "opened_by": opened.get("username") if opened else None,
                    "closed_by": ev.get("username"),
                }
            )

        for opened in open_stack:
            date_ad = opened.get("date_ad")
            rows.append(
                {
                    "date_ad": date_ad,
                    "date_bs": _bs_from_ad(date_ad),
                    "opened_at": opened.get("created_at").isoformat() if opened.get("created_at") else None,
                    "closed_at": None,
                    "opened_by": opened.get("username"),
                    "closed_by": None,
                }
            )

        if action == "shop_opened":
            rows = [r for r in rows if r.get("opened_at")]
        if action == "shop_closed":
            rows = [r for r in rows if r.get("closed_at")]

        rows.sort(
            key=lambda r: r.get("closed_at") or r.get("opened_at") or "",
            reverse=True,
        )

        return jsonify({"items": rows[:limit]})

    @app.route("/company/attendance/today", methods=["GET"])
    @require_auth
    @company_required(["manager"])
    def get_staff_attendance_today():
        today = _today_ad()
        company = g.current_company
        company_id = company.id
        shop_is_open = _normalize_shop_state(company, today)

        memberships = (
            UserCompany.query.join(User, User.id == UserCompany.user_id)
            .filter(
                UserCompany.company_id == company_id,
                UserCompany.role.in_(["staff", "salesman"]),
                User.is_active.is_(True),
            )
            .order_by(User.username.asc())
            .all()
        )
        attendances = Attendance.query.filter_by(company_id=company_id, date_ad=today).all()
        present_by_user_id = {a.user_id: bool(getattr(a, "is_present", False)) for a in attendances}

        rows = []
        for m in memberships:
            if not m.user:
                continue
            rows.append(
                {
                    "user_id": m.user_id,
                    "username": m.user.username,
                    "company_role": (m.role or "").lower(),
                    "is_present": bool(present_by_user_id.get(m.user_id, False)) if shop_is_open else False,
                }
            )

        return jsonify(
            {
                "company_id": company_id,
                "date_ad": today.isoformat(),
                "shop_is_open": bool(shop_is_open),
                "items": rows,
            }
        )

    @app.route("/company/attendance/today", methods=["PUT"])
    @require_auth
    @company_required(["manager"])
    def set_staff_attendance_today():
        data = request.get_json() or {}
        user_id = data.get("user_id")
        is_present = data.get("is_present")
        if user_id is None or is_present is None:
            return jsonify({"error": "user_id and is_present required"}), 400

        today = _today_ad()
        company = g.current_company
        company_id = company.id
        if not _normalize_shop_state(company, today):
            return jsonify({"error": "Shop is closed. Open shop first to manage attendance."}), 400
        now_time = datetime.now().strftime("%H:%M")
        try:
            import nepali_datetime  # type: ignore
        except Exception:
            nepali_datetime = None
        membership = UserCompany.query.filter_by(company_id=company_id, user_id=int(user_id)).first()
        if not membership or not membership.user:
            return jsonify({"error": "User not in this company"}), 404
        role = (membership.role or "").lower()
        if role not in {"staff", "salesman"}:
            return jsonify({"error": "Only staff/salesman attendance can be set here"}), 400

        row = Attendance.query.filter_by(company_id=company_id, user_id=int(user_id), date_ad=today).first()
        if not row:
            row = Attendance(company_id=company_id, user_id=int(user_id), date_ad=today)
            db.session.add(row)
        if not row.date_bs and nepali_datetime is not None:
            try:
                row.date_bs = nepali_datetime.date.from_datetime_date(today).strftime("%Y-%m-%d")
            except Exception:
                pass
        row.is_present = bool(is_present)
        if row.is_present:
            if not row.arrival_time:
                row.arrival_time = now_time
        else:
            row.departure_time = now_time
        db.session.commit()
        log_action(
            "attendance_marked",
            {"user_id": int(user_id), "date_ad": today.isoformat(), "is_present": bool(is_present)},
            company_id,
        )
        return jsonify(row.to_dict())

    def _gallery_file_url(file_path: str) -> str:
        base = request.url_root.rstrip("/")
        rel = str(file_path or "").lstrip("/")
        return f"{base}/static/{rel}"

    @app.route("/gallery/images", methods=["GET"])
    @require_auth
    @company_required()
    def list_gallery_images():
        supplier_id = request.args.get("supplier_id")
        q = GalleryImage.query.filter_by(company_id=g.current_company.id)
        if supplier_id:
            try:
                supplier_id_int = int(supplier_id)
            except (TypeError, ValueError):
                return jsonify({"error": "Invalid supplier_id"}), 400
            q = q.filter(GalleryImage.supplier_id == supplier_id_int)
        rows = q.order_by(GalleryImage.created_at.desc(), GalleryImage.id.desc()).all()
        user_ids = {int(r.uploaded_by_user_id) for r in rows if r.uploaded_by_user_id}
        users = {}
        if user_ids:
            users = {u.id: u for u in User.query.filter(User.id.in_(user_ids)).all()}
        items = []
        for row in rows:
            payload = row.to_dict()
            uploader = users.get(row.uploaded_by_user_id) if row.uploaded_by_user_id else None
            full_name = " ".join(
                p for p in [str(getattr(uploader, "first_name", "") or "").strip(), str(getattr(uploader, "last_name", "") or "").strip()] if p
            ).strip()
            payload["uploaded_by_name"] = full_name or (uploader.username if uploader else None)
            payload["image_url"] = _gallery_file_url(row.file_path)
            items.append(payload)
        return jsonify({"items": items})

    @app.route("/gallery/images", methods=["POST"])
    @require_auth
    @company_required(["admin", "manager", "staff", "salesman", "superuser", "superadmin"])
    def upload_gallery_image():
        uploads = [f for f in (request.files.getlist("images") or []) if f and f.filename]
        single = request.files.get("image")
        if single and single.filename:
            uploads.append(single)
        if not uploads:
            return jsonify({"error": "Image file is required"}), 400

        supplier_id = request.form.get("supplier_id") or ""
        supplier = None
        if str(supplier_id).strip():
            try:
                supplier_id = int(supplier_id)
            except (TypeError, ValueError):
                return jsonify({"error": "Invalid supplier_id"}), 400
            supplier = Supplier.query.get(supplier_id)
            if not supplier or supplier.company_id != g.current_company.id:
                return jsonify({"error": "Supplier not found"}), 404
        else:
            supplier_id = None

        company_id = g.current_company.id
        rel_dir = f"gallery/company_{company_id}"
        abs_dir = os.path.join(app.static_folder, rel_dir)
        os.makedirs(abs_dir, exist_ok=True)

        created_rows = []
        for uploaded in uploads:
            filename = str(uploaded.filename or "").strip()
            ext = os.path.splitext(filename)[1].lower()
            allowed_ext = {".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp"}
            if ext not in allowed_ext:
                return jsonify({"error": "Unsupported image type"}), 400

            mime_type = str(getattr(uploaded, "mimetype", "") or "").strip().lower()
            if mime_type and not mime_type.startswith("image/"):
                return jsonify({"error": "Only image files are allowed"}), 400

            stored_name = f"{uuid.uuid4().hex}{ext}"
            abs_path = os.path.join(abs_dir, stored_name)
            uploaded.save(abs_path)

            try:
                size_bytes = int(os.path.getsize(abs_path) or 0)
            except Exception:
                size_bytes = 0
            max_size = 10 * 1024 * 1024
            if size_bytes > max_size:
                try:
                    os.remove(abs_path)
                except Exception:
                    pass
                return jsonify({"error": "Image is too large (max 10 MB)"}), 400

            rel_path = f"{rel_dir}/{stored_name}"
            row = GalleryImage(
                company_id=company_id,
                supplier_id=supplier_id,
                uploaded_by_user_id=g.current_user.id,
                file_path=rel_path,
                original_name=filename[:255],
                mime_type=mime_type[:120] if mime_type else "",
                size_bytes=size_bytes,
            )
            db.session.add(row)
            created_rows.append(row)

        db.session.commit()
        payloads = []
        uploader_name = " ".join(
            p
            for p in [
                str(getattr(g.current_user, "first_name", "") or "").strip(),
                str(getattr(g.current_user, "last_name", "") or "").strip(),
            ]
            if p
        ).strip() or g.current_user.username
        for row in created_rows:
            payload = row.to_dict()
            payload["uploaded_by_name"] = uploader_name
            payload["image_url"] = _gallery_file_url(row.file_path)
            payloads.append(payload)
            log_action("gallery_image_uploaded", {"image_id": row.id, "name": row.original_name}, company_id)

        if len(payloads) == 1:
            return jsonify(payloads[0]), 201
        return jsonify({"items": payloads, "count": len(payloads)}), 201

    @app.route("/gallery/images/<int:image_id>", methods=["DELETE"])
    @require_auth
    @company_required(["admin", "manager", "superuser", "superadmin"])
    def delete_gallery_image(image_id: int):
        row = GalleryImage.query.get_or_404(image_id)
        if row.company_id != g.current_company.id:
            return jsonify({"error": "Forbidden"}), 403
        abs_path = os.path.join(app.static_folder, str(row.file_path or "").lstrip("/"))
        db.session.delete(row)
        db.session.commit()
        try:
            if os.path.isfile(abs_path):
                os.remove(abs_path)
        except Exception:
            pass
        log_action("gallery_image_deleted", {"image_id": image_id}, g.current_company.id)
        return jsonify({"deleted": True})

    def _read_bulk_upload_sheets(abs_path: str, ext: str):
        if ext in {".xlsx", ".xls"}:
            if not openpyxl:
                raise ValueError("openpyxl is not installed on the server")
            wb = openpyxl.load_workbook(abs_path, data_only=True)
            return [(ws.title, list(ws.iter_rows(values_only=True))) for ws in wb.worksheets]
        if ext == ".csv":
            import csv

            rows = []
            with open(abs_path, "r", encoding="utf-8-sig", newline="") as handle:
                sample = handle.read(4096)
                handle.seek(0)
                try:
                    dialect = csv.Sniffer().sniff(sample)
                except Exception:
                    dialect = csv.excel
                reader = csv.reader(handle, dialect)
                for row in reader:
                    rows.append([cell if cell is not None else "" for cell in row])
            return [("Sheet1", rows)]
        if ext == ".ods":
            if not load_ods:
                raise ValueError("odfpy is not installed on the server")
            doc = load_ods(abs_path)
            sheets = []
            for table in doc.spreadsheet.getElementsByType(OdsTable):
                sheet_name = table.getAttribute("name") or "Sheet"
                rows = []
                for row in table.getElementsByType(OdsRow):
                    row_repeat = int(row.getAttribute("numberrowsrepeated") or 1)
                    cells = []
                    for cell in row.getElementsByType(OdsCell):
                        col_repeat = int(cell.getAttribute("numbercolumnsrepeated") or 1)
                        text_parts = []
                        for p in cell.getElementsByType(OdsP):
                            for node in p.childNodes:
                                if hasattr(node, "data"):
                                    text_parts.append(node.data)
                        cell_val = "".join(text_parts).strip()
                        for _ in range(col_repeat):
                            cells.append(cell_val)
                    if cells:
                        for _ in range(row_repeat):
                            rows.append(cells)
                sheets.append((sheet_name, rows))
            return sheets
        raise ValueError("Unsupported file type")

    @app.route("/bulk-upload/purchase-orders/excel/preview", methods=["POST"])
    @require_auth
    @company_required(["admin", "manager", "superuser", "superadmin"])
    def preview_bulk_upload_excel():
        data = request.get_json(silent=True) or {}
        supplier_id = request.form.get("supplier_id") or data.get("supplier_id") or ""
        supplier_id_int = None
        if str(supplier_id).strip():
            try:
                supplier_id_int = int(supplier_id)
            except (TypeError, ValueError):
                return jsonify({"error": "Invalid supplier_id"}), 400
            supplier = Supplier.query.get(supplier_id_int)
            if not supplier or supplier.company_id != g.current_company.id:
                return jsonify({"error": "Supplier not found"}), 404

        uploaded = request.files.get("file")
        if not uploaded or not uploaded.filename:
            return jsonify({"error": "Excel file is required"}), 400

        filename = str(uploaded.filename or "").strip()
        ext = os.path.splitext(filename)[1].lower()
        allowed_ext = {".xlsx", ".xls", ".ods", ".csv"}
        if ext not in allowed_ext:
            return jsonify({"error": "Only .xlsx, .xls, .ods, or .csv files are supported"}), 400

        company_id = g.current_company.id
        rel_dir = f"bulk_uploads/company_{company_id}"
        abs_dir = os.path.join(app.static_folder, rel_dir)
        os.makedirs(abs_dir, exist_ok=True)
        stored_name = f"{uuid.uuid4().hex}{ext}"
        abs_path = os.path.join(abs_dir, stored_name)
        uploaded.save(abs_path)

        try:
            size_bytes = int(os.path.getsize(abs_path) or 0)
        except Exception:
            size_bytes = 0

        rel_path = f"{rel_dir}/{stored_name}"
        row = BulkUploadFile(
            company_id=company_id,
            supplier_id=supplier_id_int,
            uploaded_by_user_id=g.current_user.id,
            file_path=rel_path,
            original_name=filename[:255],
            mime_type=str(getattr(uploaded, "mimetype", "") or "")[:120],
            size_bytes=size_bytes,
        )
        db.session.add(row)
        db.session.commit()

        try:
            sheets = _read_bulk_upload_sheets(abs_path, ext)
        except Exception as exc:
            return jsonify({"error": str(exc)}), 400

        header_map = {
            "supplier": {"supplier", "suppliername", "vendor", "vendorname", "party", "partyname"},
            "product": {"product", "productname", "item", "itemname", "name"},
            "uom": {"uom", "unit", "unitofmeasure", "unitofmeasurement", "unitname", "packing"},
            "qty": {"qty", "quantity", "totalqty", "totalquantity"},
            "ordered_qty": {"orderedqty", "orderedquantity", "ordered", "orderqty", "orderquantity"},
            "free_qty": {"free", "freeqty", "freequantity"},
            "mrp": {"mrp", "price", "retailprice", "mrpprice"},
            "cost_price": {"costprice", "cost", "rate", "costrate"},
            "discount_percent": {"discountpercent", "discountpct", "discpercent", "discountpercentages"},
            "discount_total": {"discount", "discounttotal", "discamt", "discountamount"},
            "tax": {"tax", "vat", "taxamount", "vatamount"},
            "batch_number": {"batch", "batchnumber", "lot", "lotnumber", "serial", "batchserial"},
            "expiry_date": {"expiry", "expirydate", "expdate", "exp"},
            "serial_number": {"serialnumber", "serialno", "serial", "sn", "sno"},
            "order_date": {"orderdate", "date", "podate", "purchaseorderdate"},
        }

        def normalize_header(val: str) -> str:
            return "".join(ch for ch in str(val or "").lower() if ch.isalnum())

        def suggest_mapping(headers: list[str]) -> dict:
            normalized = [(raw, normalize_header(raw)) for raw in headers]
            mapping: dict[str, str] = {}
            for field, aliases in header_map.items():
                for raw, norm in normalized:
                    if norm and norm in aliases:
                        mapping[field] = raw
                        break
            return mapping

        def first_header_row(rows):
            for idx, row_vals in enumerate(rows):
                if row_vals is None:
                    continue
                if any(str(v).strip() for v in row_vals if v is not None):
                    return idx, row_vals
            return 0, []

        preview_sheets = []
        for name, rows in sheets:
            if not rows:
                preview_sheets.append({"name": name, "headers": [], "sample_rows": []})
                continue
            header_idx, header_row = first_header_row(rows)
            sample_rows = []
            for row_vals in rows[header_idx + 1 : header_idx + 6]:
                if row_vals is None:
                    continue
                sample_rows.append([str(v) if v is not None else "" for v in row_vals])
            headers = [str(v) if v is not None else "" for v in (header_row or [])]
            preview_sheets.append(
                {
                    "name": name,
                    "headers": headers,
                    "sample_rows": sample_rows,
                    "suggested_mapping": suggest_mapping(headers),
                }
            )

        return jsonify({"file_id": row.id, "filename": filename, "sheets": preview_sheets})

    BULK_PO_HEADER_MAP = {
        "supplier": {"supplier", "suppliername", "vendor", "vendorname", "party", "partyname"},
        "product": {"product", "productname", "item", "itemname", "name"},
        "uom": {"uom", "unit", "unitofmeasure", "unitofmeasurement", "unitname", "packing"},
        "qty": {"qty", "quantity", "totalqty", "totalquantity"},
        "ordered_qty": {"orderedqty", "orderedquantity", "ordered", "orderqty", "orderquantity"},
        "free_qty": {"free", "freeqty", "freequantity"},
        "mrp": {"mrp", "price", "retailprice", "mrpprice"},
        "cost_price": {"costprice", "cost", "rate", "costrate"},
        "discount_percent": {"discountpercent", "discountpct", "discpercent", "discountpercentages"},
        "discount_total": {"discount", "discounttotal", "discamt", "discountamount"},
        "tax": {"tax", "vat", "taxamount", "vatamount"},
        "batch_number": {"batch", "batchnumber", "lot", "lotnumber", "serial", "batchserial"},
        "expiry_date": {"expiry", "expirydate", "expdate", "exp"},
        "serial_number": {"serialnumber", "serialno", "serial", "sn", "sno"},
        "order_date": {"orderdate", "date", "podate", "purchaseorderdate"},
    }

    def _bulk_po_normalize_header(val: str) -> str:
        return "".join(ch for ch in str(val or "").lower() if ch.isalnum())

    def _bulk_po_parse_float(val):
        if val is None:
            return None
        if isinstance(val, (int, float)):
            return float(val)
        raw = str(val).strip()
        if raw == "":
            return None
        raw = raw.replace(",", "").replace("%", "")
        try:
            return float(raw)
        except Exception:
            return None

    def _bulk_po_safe_cell(val):
        if isinstance(val, (datetime, date)):
            return val.isoformat()
        if val is None:
            return None
        return str(val)

    def _bulk_po_parse_expiry(val):
        if not val:
            return None
        if isinstance(val, (datetime, date)):
            return val.date().isoformat() if isinstance(val, datetime) else val.isoformat()
        try:
            parsed = _parse_expiry_date_allow_month_year(str(val).strip())
            return parsed.isoformat() if parsed else None
        except Exception:
            return None

    def _bulk_po_parse_date(val):
        parsed = _parse_flexible_date(val)
        if isinstance(parsed, datetime):
            return parsed.date().isoformat()
        if isinstance(parsed, date):
            return parsed.isoformat()
        return None

    def _bulk_po_first_header_row(rows):
        for idx, row_vals in enumerate(rows):
            if row_vals is None:
                continue
            if any(str(v).strip() for v in row_vals if v is not None):
                return idx, row_vals
        return 0, []

    def _bulk_po_validate_upload_row(file_id, supplier_id):
        supplier_id_int = None
        supplier_fallback = None
        if str(supplier_id or "").strip():
            try:
                supplier_id_int = int(supplier_id)
            except (TypeError, ValueError):
                raise ValueError("Invalid supplier_id")
            supplier_fallback = Supplier.query.get(supplier_id_int)
            if not supplier_fallback or supplier_fallback.company_id != g.current_company.id:
                raise LookupError("Supplier not found")

        if not file_id:
            raise ValueError("file_id is required")
        try:
            file_id_int = int(file_id)
        except (TypeError, ValueError):
            raise ValueError("Invalid file_id")
        upload_row = BulkUploadFile.query.get(file_id_int)
        if not upload_row or upload_row.company_id != g.current_company.id:
            raise LookupError("Upload file not found")
        if upload_row.supplier_id and supplier_id_int and upload_row.supplier_id != supplier_id_int:
            raise ValueError("Supplier mismatch for uploaded file")
        rel_path = str(upload_row.file_path or "").lstrip("/")
        ext = os.path.splitext(rel_path)[1].lower()
        if ext not in {".xlsx", ".xls", ".ods", ".csv"}:
            raise ValueError("Unsupported file type for import")
        if ext in {".xlsx", ".xls"} and not openpyxl:
            raise RuntimeError("openpyxl is not installed on the server")
        if ext == ".ods" and not load_ods:
            raise RuntimeError("odfpy is not installed on the server")
        return upload_row, supplier_id_int, supplier_fallback, rel_path, ext

    def _prepare_bulk_purchase_order_import(upload_row: BulkUploadFile, supplier_fallback, supplier_id_int, sheets_payload):
        company_id = g.current_company.id
        rel_path = str(upload_row.file_path or "").lstrip("/")
        abs_path = os.path.join(app.static_folder, rel_path)
        ext = os.path.splitext(rel_path)[1].lower()
        sheet_rows = _read_bulk_upload_sheets(abs_path, ext)
        sheet_map = {name: rows for name, rows in sheet_rows}
        errors: list[dict] = []

        def _normalize_product_name(value: str) -> tuple[list[str], str]:
            raw = str(value or "").lower()
            tokens = [t for t in re.split(r"[^a-z0-9]+", raw) if t]
            compact = "".join(tokens)
            return tokens, compact

        def _normalize_uom(value: str | None) -> str:
            return "".join(ch for ch in str(value or "").lower() if ch.isalnum())

        def _match_uom_for_product(product: Product | None, raw_uom: str | None) -> str | None:
            cleaned = (raw_uom or "").strip()
            if not product:
                return cleaned or None
            validated = _validate_uom_for_product(product, cleaned or None)
            if validated:
                return validated
            if not product.uom_category:
                return cleaned or None
            categories = _get_unit_categories(product.company_id, product.uom_category)
            if not categories:
                return cleaned or None
            units: list[Unit] = []
            for cat in categories:
                for u in (cat.units or []):
                    if u.company_id == product.company_id and not bool(u.is_archived):
                        units.append(u)
            if not units:
                return _base_uom_name(product) or product.uom_category or (cleaned or None)
            norm_in = _normalize_uom(cleaned)
            best = None
            best_score = 0.0
            for u in units:
                for cand in (u.name, u.abbreviation):
                    if not cand:
                        continue
                    norm_cand = _normalize_uom(cand)
                    if norm_in and norm_in == norm_cand:
                        return u.name
                    if norm_in:
                        score = difflib.SequenceMatcher(None, norm_in, norm_cand).ratio()
                        if score > best_score:
                            best_score = score
                            best = u
            if best and best_score >= 0.7:
                return best.name
            return _base_uom_name(product) or product.uom_category or (cleaned or None)

        products_cache = []
        for prod in (
            Product.query.filter(Product.company_id == company_id)
            .filter(or_(Product.is_active.is_(True), Product.is_active.is_(None)))
            .filter(Product.merged_into_product_id.is_(None))
            .all()
        ):
            name_tokens, name_compact = _normalize_product_name(prod.name)
            alias_tokens, alias_compact = _normalize_product_name(getattr(prod, "alias_name", None) or "")
            products_cache.append(
                {
                    "id": prod.id,
                    "name_tokens": name_tokens,
                    "name_compact": name_compact,
                    "alias_tokens": alias_tokens,
                    "alias_compact": alias_compact,
                }
            )

        def _normalize_supplier_name(value: str) -> str:
            return " ".join(str(value or "").lower().split())

        suppliers_cache = {
            _normalize_supplier_name(s.name): s
            for s in Supplier.query.filter(Supplier.company_id == company_id).all()
        }
        suppliers_list = list(suppliers_cache.items())

        def _similarity(tokens_a, compact_a, tokens_b, compact_b) -> float:
            if not compact_a or not compact_b:
                return 0.0
            ratio1 = difflib.SequenceMatcher(None, compact_a, compact_b).ratio()
            ratio2 = difflib.SequenceMatcher(None, " ".join(tokens_a), " ".join(tokens_b)).ratio() if tokens_a and tokens_b else 0.0
            set_a = set(tokens_a)
            set_b = set(tokens_b)
            jaccard = (len(set_a & set_b) / len(set_a | set_b)) if set_a and set_b else 0.0
            return max(ratio1, ratio2, jaccard)

        def _match_product(product_name: str):
            if not product_name:
                return None, 0.0
            tokens_in, compact_in = _normalize_product_name(product_name)
            exact_norm = compact_in
            if exact_norm:
                for row in products_cache:
                    if exact_norm == row["name_compact"] or exact_norm == row["alias_compact"]:
                        return row, 1.0
            best = None
            best_score = 0.0
            for row in products_cache:
                score_name = _similarity(tokens_in, compact_in, row["name_tokens"], row["name_compact"])
                score_alias = _similarity(tokens_in, compact_in, row["alias_tokens"], row["alias_compact"])
                score = max(score_name, score_alias)
                if score > best_score:
                    best_score = score
                    best = row
            return best, best_score

        if not isinstance(sheets_payload, list):
            raise ValueError("sheets mapping must be a list")
        if not sheets_payload:
            sheets_payload = [{"name": name, "mapping": {}} for name, _ in sheet_rows]

        queued_groups: list[dict] = []
        for sheet_payload in sheets_payload:
            sheet_name = str(sheet_payload.get("name") or "").strip()
            if not sheet_name:
                continue
            rows = sheet_map.get(sheet_name)
            if not rows:
                errors.append({"sheet": sheet_name, "error": "Sheet not found or empty"})
                continue

            header_idx, header_row = _bulk_po_first_header_row(rows)
            header_norm = [_bulk_po_normalize_header(col) for col in header_row]
            header_index = {key: idx for idx, key in enumerate(header_norm) if key}
            mapping = sheet_payload.get("mapping") or {}
            if not isinstance(mapping, dict):
                errors.append({"sheet": sheet_name, "error": "Invalid mapping format"})
                continue

            for field, aliases in BULK_PO_HEADER_MAP.items():
                if mapping.get(field):
                    continue
                for idx, norm in enumerate(header_norm):
                    if norm in aliases:
                        mapping[field] = header_row[idx]
                        break

            def idx_for(field_key: str) -> int | None:
                header_name = mapping.get(field_key)
                if not header_name:
                    return None
                return header_index.get(_bulk_po_normalize_header(header_name))

            product_idx = idx_for("product")
            supplier_idx = idx_for("supplier")
            ordered_idx = idx_for("ordered_qty")
            free_idx = idx_for("free_qty")
            qty_idx = idx_for("qty")
            uom_idx = idx_for("uom")
            date_idx = idx_for("order_date")
            serial_idx = idx_for("serial_number")
            if serial_idx is None:
                for idx, norm in enumerate(header_norm):
                    if norm in BULK_PO_HEADER_MAP.get("serial_number", set()):
                        serial_idx = idx
                        break
            mrp_idx = idx_for("mrp")
            cost_idx = idx_for("cost_price")
            disc_pct_idx = idx_for("discount_percent")
            disc_total_idx = idx_for("discount_total")
            tax_idx = idx_for("tax")
            batch_idx = idx_for("batch_number")
            expiry_idx = idx_for("expiry_date")

            if supplier_idx is None and not supplier_fallback:
                errors.append({"sheet": sheet_name, "error": "Supplier mapping required"})
                continue
            if serial_idx is None:
                errors.append({"sheet": sheet_name, "error": "Serial column mapping required"})
                continue
            if uom_idx is None:
                errors.append({"sheet": sheet_name, "error": "UoM mapping required"})
                continue
            if product_idx is None or (qty_idx is None and ordered_idx is None):
                errors.append({"sheet": sheet_name, "error": "Product + Ordered Qty (or Qty) mapping required"})
                continue

            groups: dict[str, dict] = {}
            for row_idx, row in enumerate(rows[header_idx + 1 :], start=header_idx + 2):
                if row is None or all(v is None or str(v).strip() == "" for v in row):
                    continue
                supplier_name_val = None
                if supplier_idx is not None and supplier_idx < len(row):
                    supplier_name_val = str(row[supplier_idx] or "").strip()
                supplier_obj = None
                if supplier_name_val:
                    supplier_norm = _normalize_supplier_name(supplier_name_val)
                    supplier_obj = suppliers_cache.get(supplier_norm)
                    if not supplier_obj and supplier_norm:
                        best_supplier = None
                        best_score = -1.0
                        for norm_name, sup in suppliers_list:
                            score = difflib.SequenceMatcher(None, supplier_norm, norm_name).ratio()
                            if supplier_norm in norm_name or norm_name in supplier_norm:
                                score = max(score, 1.0)
                            if score > best_score:
                                best_score = score
                                best_supplier = sup
                        if best_supplier:
                            supplier_obj = best_supplier
                if not supplier_obj and supplier_fallback:
                    supplier_obj = supplier_fallback
                if not supplier_obj:
                    errors.append({"sheet": sheet_name, "row": row_idx, "error": "Supplier not found"})
                    continue

                product_val = row[product_idx] if product_idx is not None else None
                if not product_val:
                    errors.append({"sheet": sheet_name, "row": row_idx, "error": "Product is required"})
                product_name = str(product_val or "").strip()
                ordered_val = _bulk_po_parse_float(row[ordered_idx]) if ordered_idx is not None else None
                free_val = _bulk_po_parse_float(row[free_idx]) if free_idx is not None else None
                qty_val = _bulk_po_parse_float(row[qty_idx]) if qty_idx is not None else None
                qty_total = float(ordered_val or 0.0) + float(free_val or 0.0) if (ordered_val is not None or free_val is not None) else float(qty_val or 0.0)
                if qty_total <= 0:
                    errors.append({"sheet": sheet_name, "row": row_idx, "error": "Qty must be > 0"})
                    continue

                product = (
                    Product.query.filter(Product.company_id == company_id)
                    .filter(db.func.lower(Product.name) == product_name.lower())
                    .first()
                )
                if not product:
                    product = (
                        Product.query.filter(Product.company_id == company_id)
                        .filter(db.func.lower(Product.alias_name) == product_name.lower())
                        .first()
                    )
                raw_product_name = None
                if not product:
                    best, best_score = _match_product(product_name)
                    if best:
                        product = Product.query.get(best["id"])
                        if best_score < 0.72:
                            raw_product_name = product_name
                    else:
                        raw_product_name = product_name

                uom_raw = str(row[uom_idx]).strip() if uom_idx is not None and row[uom_idx] is not None else ""
                uom = _match_uom_for_product(product, uom_raw)
                if not uom:
                    raw_product_name = raw_product_name or product_name
                    errors.append({"sheet": sheet_name, "row": row_idx, "error": "UoM is required"})
                    continue

                mrp_val = _bulk_po_parse_float(row[mrp_idx]) if mrp_idx is not None else None
                cost_val = _bulk_po_parse_float(row[cost_idx]) if cost_idx is not None else None
                if mrp_val is not None and cost_val is not None and mrp_val < cost_val and mrp_val > 0:
                    mrp_val = round(float(mrp_val) * 1.6, 2)
                disc_pct_val = _bulk_po_parse_float(row[disc_pct_idx]) if disc_pct_idx is not None else None
                disc_total_val = _bulk_po_parse_float(row[disc_total_idx]) if disc_total_idx is not None else None
                tax_val = _bulk_po_parse_float(row[tax_idx]) if tax_idx is not None else None
                batch_val = str(row[batch_idx]).strip() if batch_idx is not None and row[batch_idx] is not None else ""
                expiry_val = _bulk_po_parse_expiry(row[expiry_idx]) if expiry_idx is not None else None
                serial_val = str(row[serial_idx] or "").strip() if serial_idx is not None and serial_idx < len(row) else ""
                if not serial_val:
                    serial_val = f"ROW-{row_idx}"

                group_key = f"{supplier_obj.id}:{serial_val or sheet_name}"
                group = groups.setdefault(
                    group_key,
                    {
                        "sheet": sheet_name,
                        "serial_number": serial_val,
                        "supplier_id": supplier_obj.id,
                        "supplier_name": supplier_obj.name,
                        "raw_supplier_name": supplier_name_val or supplier_obj.name,
                        "raw_order_date": None,
                        "order_date": None,
                        "mapping": mapping,
                        "items": [],
                    },
                )
                if date_idx is not None and not group.get("raw_order_date") and date_idx < len(row):
                    group["raw_order_date"] = _bulk_po_safe_cell(row[date_idx])
                if date_idx is not None and not group.get("order_date"):
                    group["order_date"] = _bulk_po_parse_date(row[date_idx])

                raw_row_data = {}
                for col_idx, header in enumerate(header_row):
                    header_key = str(header).strip() if header is not None else ""
                    if not header_key:
                        continue
                    raw_row_data[header_key] = _bulk_po_safe_cell(row[col_idx]) if col_idx < len(row) else None
                if uom_raw and "UoM (raw)" not in raw_row_data:
                    raw_row_data["UoM (raw)"] = uom_raw

                item_payload = {
                    "product_id": product.id if product else None,
                    "raw_product_name": raw_product_name if raw_product_name else None,
                    "raw_row_data": raw_row_data,
                    "qty": float(qty_total),
                    "uom": uom or None,
                    "received_cost_price": cost_val,
                    "received_mrp": mrp_val,
                    "received_discount_percent": disc_pct_val,
                    "received_discount_total": disc_total_val,
                    "received_tax_subtotal": tax_val,
                    "batch_number": batch_val or None,
                    "expiry_date": expiry_val,
                    "received_ordered_qty": float(ordered_val) if ordered_val is not None else None,
                    "received_free_qty": float(free_val) if free_val is not None else None,
                }
                group["items"].append(item_payload)

            queued_groups.extend([group for group in groups.values() if group.get("items")])

        return queued_groups, errors

    def _create_bulk_purchase_order_from_group(upload_row: BulkUploadFile, group: dict, supplier_id_int):
        company_id = g.current_company.id
        order_date_raw = group.get("order_date")
        order_dt = _parse_flexible_date(order_date_raw) if order_date_raw else None
        if isinstance(order_dt, date) and not isinstance(order_dt, datetime):
            order_dt = datetime.combine(order_dt, datetime.min.time())
        po = PurchaseOrder(
            company_id=company_id,
            supplier_id=group.get("supplier_id"),
            supplier_name=group.get("supplier_name"),
            status="draft",
            order_date=order_dt or datetime.utcnow(),
            created_by_user_id=getattr(g.current_user, "id", None),
        )
        po.history = {
            "source": "bulk_upload_excel",
            "file_id": upload_row.id,
            "file_name": upload_row.original_name or "",
            "sheet": group.get("sheet"),
            "serial_number": group.get("serial_number"),
            "raw_supplier_name": group.get("raw_supplier_name"),
            "raw_order_date": group.get("raw_order_date"),
            "mapping": group.get("mapping") or {},
        }
        db.session.add(po)
        db.session.flush()

        for idx, item_payload in enumerate(group.get("items") or [], start=1):
            item = PurchaseOrderItem(
                purchase_order_id=po.id,
                product_id=item_payload.get("product_id"),
                raw_product_name=item_payload.get("raw_product_name"),
                raw_row_data=item_payload.get("raw_row_data"),
                qty=item_payload.get("qty") or 0.0,
                uom=item_payload.get("uom"),
                line_order=idx,
                received_ordered_qty=item_payload.get("received_ordered_qty"),
                received_free_qty=item_payload.get("received_free_qty"),
                received_cost_price=item_payload.get("received_cost_price"),
                received_mrp=item_payload.get("received_mrp"),
                received_discount_percent=item_payload.get("received_discount_percent"),
                received_discount_total=item_payload.get("received_discount_total"),
                received_tax_subtotal=item_payload.get("received_tax_subtotal"),
            )
            db.session.add(item)
            db.session.flush()

            if item_payload.get("batch_number") or item_payload.get("expiry_date") or item_payload.get("received_mrp"):
                ordered_val = item_payload.get("received_ordered_qty")
                free_val = item_payload.get("received_free_qty")
                if ordered_val is None and free_val is None:
                    ordered_val = float(item_payload.get("qty") or 0.0)
                    free_val = 0.0
                expiry_date = item_payload.get("expiry_date")
                if expiry_date:
                    try:
                        expiry_date = _parse_expiry_date_allow_month_year(str(expiry_date))
                    except Exception:
                        expiry_date = None
                db.session.add(
                    PurchaseOrderReceiptLine(
                        company_id=company_id,
                        purchase_order_id=po.id,
                        purchase_order_item_id=item.id,
                        product_id=item.product_id,
                        uom=item.uom,
                        ordered_qty=ordered_val,
                        free_qty=free_val,
                        batch_number=item_payload.get("batch_number"),
                        expiry_date=expiry_date,
                        mrp=item_payload.get("received_mrp"),
                        received_by_user_id=g.current_user.id,
                        received_at=datetime.now(timezone.utc),
                    )
                )

        log_action(
            "purchase_order_excel_uploaded",
            {"supplier_id": supplier_id_int, "purchase_order_id": po.id, "file_id": upload_row.id},
            company_id,
        )
        return {
            "purchase_order_id": po.id,
            "purchase_order_number": po.number,
            "sheet": group.get("sheet"),
            "serial_number": group.get("serial_number"),
            "created_items": len(group.get("items") or []),
        }

    def _bulk_po_status_payload(upload_row: BulkUploadFile):
        payload = upload_row.to_dict()
        start_dt = upload_row.started_at or upload_row.created_at
        elapsed_seconds = 0
        if start_dt:
            elapsed_seconds = max(int((datetime.utcnow() - start_dt.replace(tzinfo=None) if getattr(start_dt, "tzinfo", None) else datetime.utcnow() - start_dt).total_seconds()), 0)
        payload["elapsed_seconds"] = elapsed_seconds
        return payload

    @app.route("/bulk-upload/purchase-orders/excel/start", methods=["POST"])
    @require_auth
    @company_required(["admin", "manager", "superuser", "superadmin"])
    def start_bulk_upload_purchase_orders_excel():
        data = request.get_json(silent=True) or {}
        try:
            upload_row, supplier_id_int, supplier_fallback, _rel_path, _ext = _bulk_po_validate_upload_row(
                data.get("file_id"), request.form.get("supplier_id") or data.get("supplier_id") or ""
            )
            queued_groups, errors = _prepare_bulk_purchase_order_import(
                upload_row,
                supplier_fallback,
                supplier_id_int,
                data.get("sheets") or [],
            )
        except LookupError as exc:
            return jsonify({"error": str(exc)}), 404
        except ValueError as exc:
            return jsonify({"error": str(exc)}), 400
        except RuntimeError as exc:
            return jsonify({"error": str(exc)}), 500
        except Exception as exc:
            return jsonify({"error": str(exc)}), 400

        if not queued_groups:
            upload_row.import_kind = "purchase_orders"
            upload_row.import_status = "failed"
            upload_row.total_groups = 0
            upload_row.processed_groups = 0
            upload_row.created_count = 0
            upload_row.error_count = len(errors)
            upload_row.import_errors = errors
            upload_row.import_payload = {"groups": []}
            upload_row.last_error = "No valid rows found"
            upload_row.completed_at = datetime.utcnow()
            db.session.commit()
            return jsonify({"error": "No valid rows found", "errors": errors}), 400

        upload_row.import_kind = "purchase_orders"
        upload_row.import_status = "queued"
        upload_row.total_groups = len(queued_groups)
        upload_row.processed_groups = 0
        upload_row.created_count = 0
        upload_row.error_count = len(errors)
        upload_row.current_sheet = queued_groups[0].get("sheet") or None
        upload_row.current_serial_number = queued_groups[0].get("serial_number") or None
        upload_row.import_payload = {
            "supplier_id": supplier_id_int,
            "groups": queued_groups,
        }
        upload_row.import_errors = errors
        upload_row.started_at = datetime.utcnow()
        upload_row.completed_at = None
        upload_row.last_error = ""
        db.session.commit()
        return jsonify(
            {
                "job": _bulk_po_status_payload(upload_row),
                "errors": errors,
            }
        ), 201

    @app.route("/bulk-upload/purchase-orders/excel/<int:file_id>/status", methods=["GET"])
    @require_auth
    @company_required(["admin", "manager", "superuser", "superadmin"])
    def get_bulk_upload_purchase_orders_excel_status(file_id: int):
        upload_row = BulkUploadFile.query.get_or_404(file_id)
        if upload_row.company_id != g.current_company.id:
            return jsonify({"error": "Forbidden"}), 403
        return jsonify({"job": _bulk_po_status_payload(upload_row)})

    @app.route("/bulk-upload/purchase-orders/import-jobs", methods=["GET"])
    @require_auth
    @company_required(["admin", "manager", "superuser", "superadmin"])
    def list_bulk_purchase_order_import_jobs():
        rows = (
            BulkUploadFile.query.filter(BulkUploadFile.company_id == g.current_company.id)
            .filter(BulkUploadFile.import_kind == "purchase_orders")
            .filter(BulkUploadFile.import_status.in_(["queued", "processing", "completed", "failed"]))
            .order_by(BulkUploadFile.updated_at.desc(), BulkUploadFile.created_at.desc())
            .limit(20)
            .all()
        )
        return jsonify({"items": [_bulk_po_status_payload(row) for row in rows]})

    @app.route("/bulk-upload/purchase-orders/excel/<int:file_id>/process-next", methods=["POST"])
    @require_auth
    @company_required(["admin", "manager", "superuser", "superadmin"])
    def process_next_bulk_upload_purchase_orders_excel(file_id: int):
        upload_row = BulkUploadFile.query.get_or_404(file_id)
        if upload_row.company_id != g.current_company.id:
            return jsonify({"error": "Forbidden"}), 403
        payload = upload_row.import_payload or {}
        groups = list(payload.get("groups") or [])
        supplier_id_int = payload.get("supplier_id")

        if upload_row.import_status in {"completed", "failed"}:
            return jsonify({"job": _bulk_po_status_payload(upload_row), "done": True})
        if not groups:
            upload_row.import_status = "completed"
            upload_row.completed_at = datetime.utcnow()
            upload_row.current_sheet = None
            upload_row.current_serial_number = None
            db.session.commit()
            return jsonify({"job": _bulk_po_status_payload(upload_row), "done": True})

        group = groups.pop(0)
        upload_row.import_status = "processing"
        upload_row.current_sheet = group.get("sheet") or None
        upload_row.current_serial_number = group.get("serial_number") or None
        upload_row.import_payload = {**payload, "groups": groups}
        db.session.flush()

        created_item = None
        try:
            created_item = _create_bulk_purchase_order_from_group(upload_row, group, supplier_id_int)
            upload_row.processed_groups = int(upload_row.processed_groups or 0) + 1
            upload_row.created_count = int(upload_row.created_count or 0) + 1
            if groups:
                next_group = groups[0]
                upload_row.import_status = "queued"
                upload_row.current_sheet = next_group.get("sheet") or None
                upload_row.current_serial_number = next_group.get("serial_number") or None
            else:
                upload_row.import_status = "completed"
                upload_row.current_sheet = None
                upload_row.current_serial_number = None
                upload_row.completed_at = datetime.utcnow()
            db.session.commit()
        except Exception as exc:
            db.session.rollback()
            upload_row = BulkUploadFile.query.get(file_id)
            payload = upload_row.import_payload or {}
            upload_row.processed_groups = int(upload_row.processed_groups or 0) + 1
            upload_row.error_count = int(upload_row.error_count or 0) + 1
            current_errors = list(upload_row.import_errors or [])
            current_errors.append(
                {
                    "sheet": group.get("sheet"),
                    "serial_number": group.get("serial_number"),
                    "error": str(exc),
                }
            )
            upload_row.import_errors = current_errors
            upload_row.last_error = str(exc)
            upload_row.import_payload = {**payload, "groups": groups}
            if groups:
                next_group = groups[0]
                upload_row.import_status = "queued"
                upload_row.current_sheet = next_group.get("sheet") or None
                upload_row.current_serial_number = next_group.get("serial_number") or None
            else:
                upload_row.import_status = "completed" if int(upload_row.created_count or 0) > 0 else "failed"
                upload_row.current_sheet = None
                upload_row.current_serial_number = None
                upload_row.completed_at = datetime.utcnow()
            db.session.commit()
            return jsonify(
                {
                    "job": _bulk_po_status_payload(upload_row),
                    "created_item": None,
                    "step_error": str(exc),
                    "done": upload_row.import_status in {"completed", "failed"},
                }
            ), 200

        return jsonify(
            {
                "job": _bulk_po_status_payload(upload_row),
                "created_item": created_item,
                "done": upload_row.import_status in {"completed", "failed"},
            }
        )

    @app.route("/bulk-upload/purchase-orders/excel", methods=["POST"])
    @require_auth
    @company_required(["admin", "manager", "superuser", "superadmin"])
    def bulk_upload_purchase_orders_excel():
        data = request.get_json(silent=True) or {}
        start_resp = start_bulk_upload_purchase_orders_excel()
        if getattr(start_resp, "status_code", 200) >= 400:
            return start_resp
        start_payload = start_resp.get_json(silent=True) or {}
        job = start_payload.get("job") or {}
        file_id = job.get("id")
        created_items = []
        while True:
            next_resp = process_next_bulk_upload_purchase_orders_excel(int(file_id))
            next_payload = next_resp.get_json(silent=True) or {}
            created_item = next_payload.get("created_item")
            if created_item:
                created_items.append(created_item)
            if next_payload.get("done"):
                final_job = next_payload.get("job") or {}
                if not created_items:
                    return jsonify(
                        {
                            "error": final_job.get("last_error") or "No valid rows found",
                            "errors": final_job.get("import_errors") or start_payload.get("errors") or [],
                        }
                    ), 400
                return jsonify(
                    {
                        "count": len(created_items),
                        "items": created_items,
                        "errors": final_job.get("import_errors") or start_payload.get("errors") or [],
                        "job": final_job,
                    }
                ), 201

    @app.route("/bulk-upload/sales/excel/preview", methods=["POST"])
    @require_auth
    @company_required(["admin", "manager", "superuser", "superadmin"])
    def preview_bulk_sales_excel():
        uploaded = request.files.get("file")
        if not uploaded or not uploaded.filename:
            return jsonify({"error": "Excel file is required"}), 400

        filename = str(uploaded.filename or "").strip()
        ext = os.path.splitext(filename)[1].lower()
        allowed_ext = {".xlsx", ".xls", ".ods", ".csv"}
        if ext not in allowed_ext:
            return jsonify({"error": "Only .xlsx, .xls, .ods, or .csv files are supported"}), 400

        company_id = g.current_company.id
        rel_dir = f"bulk_uploads/company_{company_id}"
        abs_dir = os.path.join(app.static_folder, rel_dir)
        os.makedirs(abs_dir, exist_ok=True)
        stored_name = f"{uuid.uuid4().hex}{ext}"
        abs_path = os.path.join(abs_dir, stored_name)
        uploaded.save(abs_path)

        try:
            size_bytes = int(os.path.getsize(abs_path) or 0)
        except Exception:
            size_bytes = 0

        rel_path = f"{rel_dir}/{stored_name}"
        row = BulkUploadFile(
            company_id=company_id,
            uploaded_by_user_id=g.current_user.id,
            file_path=rel_path,
            original_name=filename[:255],
            mime_type=str(getattr(uploaded, "mimetype", "") or "")[:120],
            size_bytes=size_bytes,
        )
        db.session.add(row)
        db.session.commit()

        try:
            sheets = _read_bulk_upload_sheets(abs_path, ext)
        except Exception as exc:
            return jsonify({"error": str(exc)}), 400

        sales_header_map = {
            "serial_number": {"serialnumber", "serialno", "serial", "sn", "sno", "billno", "billnumber", "invoice", "invoiceno"},
            "sale_date": {"date", "saledate", "transactiondate", "billdate", "entrydate"},
            "total_amount": {"totalamount", "total", "grandtotal", "amount", "billamount", "netamount"},
            "product": {"product", "productname", "item", "itemname", "name"},
            "base_qty": {"baseqty", "basequantity", "qty", "quantity", "totalqty", "totalquantity"},
        }

        def normalize_header(val: str) -> str:
            return "".join(ch for ch in str(val or "").lower() if ch.isalnum())

        def suggest_mapping(headers: list[str]) -> dict:
            normalized = [(raw, normalize_header(raw)) for raw in headers]
            mapping: dict[str, str] = {}
            for field, aliases in sales_header_map.items():
                for raw, norm in normalized:
                    if norm and norm in aliases:
                        mapping[field] = raw
                        break
            return mapping

        def first_header_row(rows):
            for idx, row_vals in enumerate(rows):
                if row_vals is None:
                    continue
                if any(str(v).strip() for v in row_vals if v is not None):
                    return idx, row_vals
            return 0, []

        preview_sheets = []
        for name, rows in sheets:
            if not rows:
                preview_sheets.append({"name": name, "headers": [], "sample_rows": []})
                continue
            header_idx, header_row = first_header_row(rows)
            sample_rows = []
            for row_vals in rows[header_idx + 1 : header_idx + 6]:
                if row_vals is None:
                    continue
                sample_rows.append([str(v) if v is not None else "" for v in row_vals])
            headers = [str(v) if v is not None else "" for v in (header_row or [])]
            preview_sheets.append(
                {
                    "name": name,
                    "headers": headers,
                    "sample_rows": sample_rows,
                    "suggested_mapping": suggest_mapping(headers),
                }
            )

        return jsonify({"file_id": row.id, "filename": filename, "sheets": preview_sheets})

    @app.route("/bulk-upload/sales/excel", methods=["POST"])
    @require_auth
    @company_required(["admin", "manager", "superuser", "superadmin"])
    def bulk_upload_sales_excel():
        data = request.get_json(silent=True) or {}
        file_id = data.get("file_id")
        if not file_id:
            return jsonify({"error": "file_id is required"}), 400
        try:
            file_id_int = int(file_id)
        except (TypeError, ValueError):
            return jsonify({"error": "Invalid file_id"}), 400

        upload_row = BulkUploadFile.query.get(file_id_int)
        if not upload_row or upload_row.company_id != g.current_company.id:
            return jsonify({"error": "Upload file not found"}), 404

        rel_path = str(upload_row.file_path or "").lstrip("/")
        abs_path = os.path.join(app.static_folder, rel_path)
        ext = os.path.splitext(rel_path)[1].lower()
        allowed_ext = {".xlsx", ".xls", ".ods", ".csv"}
        if ext not in allowed_ext:
            return jsonify({"error": "Unsupported file type for import"}), 400

        def normalize_header(val: str) -> str:
            return "".join(ch for ch in str(val or "").lower() if ch.isalnum())

        def parse_float(val):
            if val is None:
                return None
            if isinstance(val, (int, float)):
                return float(val)
            raw = str(val).strip()
            if raw == "":
                return None
            raw = raw.replace(",", "")
            try:
                return float(raw)
            except Exception:
                return None

        def parse_date(val):
            parsed = _parse_flexible_date(val)
            if isinstance(parsed, datetime):
                return parsed.date()
            return parsed if isinstance(parsed, date) else None

        sales_header_map = {
            "serial_number": {"serialnumber", "serialno", "serial", "sn", "sno", "billno", "billnumber", "invoice", "invoiceno"},
            "sale_date": {"date", "saledate", "transactiondate", "billdate", "entrydate"},
            "total_amount": {"totalamount", "total", "grandtotal", "amount", "billamount", "netamount"},
            "product": {"product", "productname", "item", "itemname", "name"},
            "base_qty": {"baseqty", "basequantity", "qty", "quantity", "totalqty", "totalquantity"},
        }

        try:
            sheet_rows = _read_bulk_upload_sheets(abs_path, ext)
        except Exception as exc:
            return jsonify({"error": str(exc)}), 400

        def _normalize_product_name(value: str) -> tuple[list[str], str]:
            raw = str(value or "").lower()
            tokens = [t for t in re.split(r"[^a-z0-9]+", raw) if t]
            compact = "".join(tokens)
            return tokens, compact

        def _similarity(tokens_a, compact_a, tokens_b, compact_b) -> float:
            if not compact_a or not compact_b:
                return 0.0
            ratio1 = difflib.SequenceMatcher(None, compact_a, compact_b).ratio()
            ratio2 = difflib.SequenceMatcher(None, " ".join(tokens_a), " ".join(tokens_b)).ratio() if tokens_a and tokens_b else 0.0
            set_a = set(tokens_a)
            set_b = set(tokens_b)
            jaccard = (len(set_a & set_b) / len(set_a | set_b)) if set_a and set_b else 0.0
            return max(ratio1, ratio2, jaccard)

        products_cache = []
        for prod in Product.query.filter(Product.company_id == g.current_company.id).all():
            name_tokens, name_compact = _normalize_product_name(prod.name)
            alias_tokens, alias_compact = _normalize_product_name(getattr(prod, "alias_name", None) or "")
            products_cache.append(
                {
                    "product": prod,
                    "name_tokens": name_tokens,
                    "name_compact": name_compact,
                    "alias_tokens": alias_tokens,
                    "alias_compact": alias_compact,
                }
            )

        def _match_product(product_name: str):
            if not product_name:
                return None, 0.0
            tokens_in, compact_in = _normalize_product_name(product_name)
            exact_lower = product_name.strip().lower()
            for row in products_cache:
                product = row["product"]
                if str(product.name or "").strip().lower() == exact_lower:
                    return product, 1.0
                if str(getattr(product, "alias_name", None) or "").strip().lower() == exact_lower:
                    return product, 1.0
            best = None
            best_score = 0.0
            for row in products_cache:
                score_name = _similarity(tokens_in, compact_in, row["name_tokens"], row["name_compact"])
                score_alias = _similarity(tokens_in, compact_in, row["alias_tokens"], row["alias_compact"])
                score = max(score_name, score_alias)
                if score > best_score:
                    best_score = score
                    best = row["product"]
            return best, best_score

        sheets_payload = data.get("sheets") or []
        if not isinstance(sheets_payload, list):
            return jsonify({"error": "sheets mapping must be a list"}), 400
        if not sheets_payload:
            sheets_payload = [{"name": name, "mapping": {}} for name, _ in sheet_rows]

        def first_header_row(rows):
            for idx, row_vals in enumerate(rows):
                if row_vals is None:
                    continue
                if any(str(v).strip() for v in row_vals if v is not None):
                    return idx, row_vals
            return 0, []

        created_sales: list[dict] = []
        errors: list[dict] = []
        sheet_map = {name: rows for name, rows in sheet_rows}

        for sheet_payload in sheets_payload:
            sheet_name = str(sheet_payload.get("name") or "").strip()
            if not sheet_name:
                continue
            rows = sheet_map.get(sheet_name)
            if not rows:
                errors.append({"sheet": sheet_name, "error": "Sheet not found or empty"})
                continue

            header_idx, header_row = first_header_row(rows)
            header_norm = [normalize_header(col) for col in header_row]
            header_index = {key: idx for idx, key in enumerate(header_norm) if key}

            mapping = sheet_payload.get("mapping") or {}
            if not isinstance(mapping, dict):
                errors.append({"sheet": sheet_name, "error": "Invalid mapping format"})
                continue

            for field, aliases in sales_header_map.items():
                if mapping.get(field):
                    continue
                for idx, norm in enumerate(header_norm):
                    if norm in aliases:
                        mapping[field] = header_row[idx]
                        break

            def idx_for(field_key: str) -> int | None:
                header_name = mapping.get(field_key)
                if not header_name:
                    return None
                return header_index.get(normalize_header(header_name))

            serial_idx = idx_for("serial_number")
            sale_date_idx = idx_for("sale_date")
            total_amount_idx = idx_for("total_amount")
            product_idx = idx_for("product")
            qty_idx = idx_for("base_qty")

            if serial_idx is None or sale_date_idx is None or total_amount_idx is None or product_idx is None or qty_idx is None:
                errors.append({"sheet": sheet_name, "error": "S No, Date, Total Amount, Product, and Base Qty mappings are required"})
                continue

            grouped_sales: dict[str, dict] = {}
            for row_idx, row in enumerate(rows[header_idx + 1 :], start=header_idx + 2):
                if row is None or all(v is None or str(v).strip() == "" for v in row):
                    continue

                serial_val = str(row[serial_idx] or "").strip() if serial_idx < len(row) else ""
                product_name = str(row[product_idx] or "").strip() if product_idx < len(row) else ""
                sale_date_val = parse_date(row[sale_date_idx] if sale_date_idx < len(row) else None)
                total_amount_val = parse_float(row[total_amount_idx] if total_amount_idx < len(row) else None)
                qty_val = parse_float(row[qty_idx] if qty_idx < len(row) else None)

                if not serial_val:
                    errors.append({"sheet": sheet_name, "row": row_idx, "error": "S No is required"})
                    continue
                if not sale_date_val:
                    errors.append({"sheet": sheet_name, "row": row_idx, "error": "Date is required"})
                    continue
                if total_amount_val is None or total_amount_val <= 0:
                    errors.append({"sheet": sheet_name, "row": row_idx, "error": "Total Amount must be > 0"})
                    continue
                if not product_name:
                    errors.append({"sheet": sheet_name, "row": row_idx, "error": "Product is required"})
                    continue
                if qty_val is None or qty_val <= 0 or not float(qty_val).is_integer():
                    errors.append({"sheet": sheet_name, "row": row_idx, "error": "Base Qty must be a whole number > 0"})
                    continue

                product, match_score = _match_product(product_name)
                if not product or product.company_id != g.current_company.id:
                    errors.append({"sheet": sheet_name, "row": row_idx, "error": f"Product not found: {product_name}"})
                    continue

                group_key = serial_val
                group = grouped_sales.setdefault(
                    group_key,
                    {
                        "serial_number": serial_val,
                        "sale_date": sale_date_val,
                        "total_amount": total_amount_val,
                        "items": [],
                    },
                )
                if group["sale_date"] != sale_date_val:
                    errors.append(
                        {
                            "sheet": sheet_name,
                            "row": row_idx,
                            "error": f"Conflicting Date for S No {serial_val}. One serial must belong to one transaction date.",
                        }
                    )
                    continue
                if abs(float(group["total_amount"]) - float(total_amount_val)) > 0.009:
                    errors.append(
                        {
                            "sheet": sheet_name,
                            "row": row_idx,
                            "error": f"Conflicting Total Amount for S No {serial_val}. One serial must belong to one transaction total.",
                        }
                    )
                    continue

                group["items"].append(
                    {
                        "product_id": int(product.id),
                        "quantity": int(qty_val),
                        "uom": _base_uom_name(product) or product.uom_category or None,
                        "raw_product_name": product_name if match_score < 0.72 else None,
                    }
                )

            for group in grouped_sales.values():
                if not group["items"]:
                    continue
                payload = {
                    "sale_date": group["sale_date"].isoformat(),
                    "total_amount": round(float(group["total_amount"] or 0.0), 2),
                    "items": group["items"],
                }
                result = _create_sale_record(payload, source="backdated_daily_sales", extra_fields={"approval_status": "pending"})
                response_obj = result[0] if isinstance(result, tuple) else result
                status_code = result[1] if isinstance(result, tuple) else getattr(response_obj, "status_code", 200)
                result_data = response_obj.get_json(silent=True) if hasattr(response_obj, "get_json") else None
                if status_code >= 400:
                    errors.append(
                        {
                            "sheet": sheet_name,
                            "serial_number": group["serial_number"],
                            "sale_date": group["sale_date"].isoformat(),
                            "error": (result_data or {}).get("error") or "Failed to create sale",
                        }
                    )
                    continue
                created_sales.append(
                    {
                        "sale_id": result_data.get("id"),
                        "sale_number": result_data.get("sale_number"),
                        "sheet": sheet_name,
                        "serial_number": group["serial_number"],
                        "sale_date": group["sale_date"].isoformat(),
                        "created_items": len(group["items"]),
                        "total_amount": round(float(group["total_amount"] or 0.0), 2),
                    }
                )
                log_action(
                    "bulk_sales_excel_uploaded",
                    {
                        "sale_id": result_data.get("id"),
                        "sale_number": result_data.get("sale_number"),
                        "file_id": upload_row.id,
                        "sheet": sheet_name,
                        "serial_number": group["serial_number"],
                    },
                    g.current_company.id,
                )

        if not created_sales:
            return jsonify({"error": "No valid sales rows found", "errors": errors}), 400

        return jsonify({"count": len(created_sales), "items": created_sales, "errors": errors}), 201

    @app.route("/bulk-upload/purchase-orders/<int:order_id>", methods=["PATCH"])
    @require_auth
    @company_required(["admin", "manager", "superuser", "superadmin"])
    def update_bulk_purchase_order(order_id: int):
        def _history_source_value(history) -> str:
            if isinstance(history, dict):
                return str(history.get("source") or "")
            if isinstance(history, str):
                try:
                    parsed = json.loads(history)
                    if isinstance(parsed, dict):
                        return str(parsed.get("source") or "")
                except Exception:
                    return ""
            return ""

        po = PurchaseOrder.query.get_or_404(order_id)
        if po.company_id != g.current_company.id:
            return jsonify({"error": "Forbidden"}), 403
        history_source = _history_source_value(getattr(po, "history", None))
        if history_source != "bulk_upload_excel":
            return jsonify({"error": "Only bulk-upload purchase orders can be edited here"}), 400
        if po.purchase_bill_id:
            return jsonify({"error": "Purchase order already posted to inventory"}), 400

        data = request.get_json() or {}
        supplier_id = data.get("supplier_id", None)
        if supplier_id is not None:
            try:
                supplier_id_int = int(supplier_id)
            except (TypeError, ValueError):
                return jsonify({"error": "Invalid supplier_id"}), 400
            supplier = Supplier.query.get(supplier_id_int)
            if not supplier or supplier.company_id != g.current_company.id:
                return jsonify({"error": "Supplier not found"}), 404
            po.supplier_id = supplier.id
            po.supplier_name = supplier.name

        order_date_raw = data.get("order_date", None)
        if order_date_raw is not None:
            parsed = _parse_flexible_date(order_date_raw)
            if not parsed:
                return jsonify({"error": "Invalid order_date"}), 400
            if isinstance(parsed, date) and not isinstance(parsed, datetime):
                po.order_date = datetime.combine(parsed, datetime.min.time())
            else:
                po.order_date = parsed

        items = data.get("items") or []
        if not isinstance(items, list):
            return jsonify({"error": "items must be a list"}), 400

        item_ids = [it.get("id") for it in items if it.get("id")]
        existing_items = {
            int(it.id): it
            for it in PurchaseOrderItem.query.filter(
                PurchaseOrderItem.purchase_order_id == po.id,
                PurchaseOrderItem.id.in_(item_ids),
            ).all()
        }
        for idx, line in enumerate(items):
            item_id = line.get("id")
            if not item_id:
                continue
            item = existing_items.get(int(item_id))
            if not item:
                continue

            product_id = line.get("product_id")
            product = None
            if product_id:
                product = Product.query.get(product_id)
                if not product or product.company_id != g.current_company.id:
                    product = None

            qty = 0.0
            try:
                qty = float(line.get("qty", 0) or 0)
            except (TypeError, ValueError):
                qty = 0.0

            uom_raw = (line.get("uom") or "").strip() or None
            uom = None
            if product:
                uom = _validate_uom_for_product(product, uom_raw)
                if product.uom_category and not uom:
                    uom = _base_uom_name(product) or product.uom_category
            else:
                uom = uom_raw

            line_order = None
            try:
                line_order = int(line.get("line_order")) if line.get("line_order") is not None else None
            except (TypeError, ValueError):
                line_order = None
            if line_order is None:
                line_order = idx + 1

            item.product = product
            item.product_id = product.id if product else None
            if product:
                item.raw_product_name = None
            else:
                raw_name = (line.get("raw_product_name") or "").strip()
                if raw_name:
                    item.raw_product_name = raw_name
            item.qty = qty
            item.uom = uom
            item.line_order = line_order

        po.status = "draft"
        db.session.commit()
        log_action("bulk_purchase_order_updated", {"purchase_order_id": po.id}, g.current_company.id)
        return jsonify(po.to_dict(include_items=True))

    @app.route("/bulk-upload/purchase-orders/<int:order_id>/rematch", methods=["POST"])
    @require_auth
    @company_required(["admin", "manager", "superuser", "superadmin"])
    def rematch_bulk_purchase_order(order_id: int):
        def _history_source_value(history) -> str:
            if isinstance(history, dict):
                return str(history.get("source") or "")
            if isinstance(history, str):
                try:
                    parsed = json.loads(history)
                    if isinstance(parsed, dict):
                        return str(parsed.get("source") or "")
                except Exception:
                    return ""
            return ""

        def _history_mapping(history) -> dict:
            if isinstance(history, dict):
                return history.get("mapping") or {}
            if isinstance(history, str):
                try:
                    parsed = json.loads(history)
                    if isinstance(parsed, dict):
                        return parsed.get("mapping") or {}
                except Exception:
                    return {}
            return {}

        po = PurchaseOrder.query.get_or_404(order_id)
        if po.company_id != g.current_company.id:
            return jsonify({"error": "Forbidden"}), 403
        history_source = _history_source_value(getattr(po, "history", None))
        if history_source != "bulk_upload_excel":
            return jsonify({"error": "Only bulk-upload purchase orders can be rematched here"}), 400
        if po.purchase_bill_id:
            return jsonify({"error": "Purchase order already posted to inventory"}), 400

        mapping = _history_mapping(getattr(po, "history", None))
        company_id = g.current_company.id

        def _normalize_product_name(value: str) -> tuple[list[str], str]:
            raw = str(value or "").lower()
            tokens = [t for t in re.split(r"[^a-z0-9]+", raw) if t]
            compact = "".join(tokens)
            return tokens, compact

        def normalize_header(val: str) -> str:
            return "".join(ch for ch in str(val or "").lower() if ch.isalnum())

        header_map = {
            "product": {"product", "productname", "item", "itemname", "name"},
            "uom": {"uom", "unit", "unitofmeasure", "unitofmeasurement", "unitname", "packing"},
            "qty": {"qty", "quantity", "totalqty", "totalquantity"},
            "ordered_qty": {"orderedqty", "orderedquantity", "ordered", "orderqty", "orderquantity"},
            "free_qty": {"free", "freeqty", "freequantity"},
            "mrp": {"mrp", "price", "retailprice", "mrpprice"},
            "cost_price": {"costprice", "cost", "rate", "costrate"},
            "discount_percent": {"discountpercent", "discountpct", "discpercent", "discountpercentages"},
            "discount_total": {"discount", "discounttotal", "discamt", "discountamount"},
            "tax": {"tax", "vat", "taxamount", "vatamount"},
            "batch_number": {"batch", "batchnumber", "lot", "lotnumber", "serial", "batchserial"},
            "expiry_date": {"expiry", "expirydate", "expdate", "exp"},
        }

        def _raw_value(row: dict, key: str):
            if not isinstance(row, dict):
                return None
            header = mapping.get(key)
            if header and header in row:
                return row.get(header)
            norm_to_val = {normalize_header(k): v for k, v in row.items()}
            for alias in header_map.get(key, set()):
                if alias in norm_to_val:
                    return norm_to_val[alias]
            return None

        def parse_float(val):
            if val is None:
                return None
            if isinstance(val, (int, float)):
                return float(val)
            raw = str(val).strip()
            if raw == "":
                return None
            raw = raw.replace(",", "").replace("%", "")
            try:
                return float(raw)
            except Exception:
                return None

        def parse_expiry(val):
            if not val:
                return None
            if isinstance(val, (datetime, date)):
                return val.date() if isinstance(val, datetime) else val
            try:
                return _parse_expiry_date_allow_month_year(str(val).strip())
            except Exception:
                return None

        def _normalize_uom(value: str | None) -> str:
            return "".join(ch for ch in str(value or "").lower() if ch.isalnum())

        def _match_uom_for_product(product: Product | None, raw_uom: str | None) -> str | None:
            cleaned = (raw_uom or "").strip()
            if not product:
                return cleaned or None
            validated = _validate_uom_for_product(product, cleaned or None)
            if validated:
                return validated
            if not product.uom_category:
                return cleaned or None
            categories = _get_unit_categories(product.company_id, product.uom_category)
            if not categories:
                return cleaned or None
            units: list[Unit] = []
            for cat in categories:
                for u in (cat.units or []):
                    if u.company_id == product.company_id and not bool(u.is_archived):
                        units.append(u)
            if not units:
                return _base_uom_name(product) or product.uom_category or (cleaned or None)
            norm_in = _normalize_uom(cleaned)
            best = None
            best_score = 0.0
            for u in units:
                for cand in (u.name, u.abbreviation):
                    if not cand:
                        continue
                    norm_cand = _normalize_uom(cand)
                    if norm_in and norm_in == norm_cand:
                        return u.name
                    if norm_in:
                        score = difflib.SequenceMatcher(None, norm_in, norm_cand).ratio()
                        if score > best_score:
                            best_score = score
                            best = u
            if best and best_score >= 0.7:
                return best.name
            return _base_uom_name(product) or product.uom_category or (cleaned or None)

        products_cache = []
        for prod in Product.query.filter(Product.company_id == company_id).all():
            name_tokens, name_compact = _normalize_product_name(prod.name)
            alias_tokens, alias_compact = _normalize_product_name(getattr(prod, "alias_name", None) or "")
            products_cache.append(
                {
                    "id": prod.id,
                    "name": prod.name,
                    "alias": getattr(prod, "alias_name", None),
                    "name_tokens": name_tokens,
                    "name_compact": name_compact,
                    "alias_tokens": alias_tokens,
                    "alias_compact": alias_compact,
                }
            )

        def _similarity(tokens_a, compact_a, tokens_b, compact_b) -> float:
            if not compact_a or not compact_b:
                return 0.0
            ratio1 = difflib.SequenceMatcher(None, compact_a, compact_b).ratio()
            ratio2 = difflib.SequenceMatcher(None, " ".join(tokens_a), " ".join(tokens_b)).ratio() if tokens_a and tokens_b else 0.0
            set_a = set(tokens_a)
            set_b = set(tokens_b)
            jaccard = (len(set_a & set_b) / len(set_a | set_b)) if set_a and set_b else 0.0
            return max(ratio1, ratio2, jaccard)

        def _match_product(product_name: str):
            if not product_name:
                return None, 0.0
            tokens_in, compact_in = _normalize_product_name(product_name)
            best = None
            best_score = 0.0
            for row in products_cache:
                score_name = _similarity(tokens_in, compact_in, row["name_tokens"], row["name_compact"])
                score_alias = _similarity(tokens_in, compact_in, row["alias_tokens"], row["alias_compact"])
                score = max(score_name, score_alias)
                if score > best_score:
                    best_score = score
                    best = row
            return best, best_score

        items = PurchaseOrderItem.query.filter_by(purchase_order_id=po.id).all()
        for item in items:
            raw_row = item.raw_row_data or {}
            raw_product = None
            if mapping.get("product"):
                raw_product = raw_row.get(mapping.get("product"))
            if not raw_product:
                raw_product = item.raw_product_name or raw_row.get("Product") or raw_row.get("Item") or ""
            product_name = str(raw_product or "").strip()
            product = None
            if product_name:
                best, best_score = _match_product(product_name)
                if best and best_score >= 0.72:
                    product = Product.query.get(best["id"])
                    item.product_id = product.id if product else None
                    item.raw_product_name = None
                else:
                    item.product_id = None
                    item.raw_product_name = product_name
            elif item.product_id:
                product = Product.query.get(item.product_id)

            raw_uom = raw_row.get("UoM (raw)")
            if not raw_uom and mapping.get("uom"):
                raw_uom = raw_row.get(mapping.get("uom"))
            uom = _match_uom_for_product(product, raw_uom)
            if uom:
                item.uom = uom

            ordered_val = parse_float(_raw_value(raw_row, "ordered_qty"))
            free_val = parse_float(_raw_value(raw_row, "free_qty"))
            qty_val = parse_float(_raw_value(raw_row, "qty"))
            if ordered_val is not None or free_val is not None:
                item.received_ordered_qty = float(ordered_val or 0.0)
                item.received_free_qty = float(free_val or 0.0)
                item.qty = float(ordered_val or 0.0) + float(free_val or 0.0)
            elif qty_val is not None:
                item.qty = float(qty_val)
                item.received_ordered_qty = float(qty_val)
                item.received_free_qty = 0.0

            cost_val = parse_float(_raw_value(raw_row, "cost_price"))
            if cost_val is not None:
                item.received_cost_price = float(cost_val)

            mrp_val = parse_float(_raw_value(raw_row, "mrp"))
            if mrp_val is not None:
                item.received_mrp = float(mrp_val)

            disc_pct_val = parse_float(_raw_value(raw_row, "discount_percent"))
            if disc_pct_val is not None:
                item.received_discount_percent = float(disc_pct_val)

            disc_total_val = parse_float(_raw_value(raw_row, "discount_total"))
            if disc_total_val is not None:
                item.received_discount_total = float(disc_total_val)
            elif (
                disc_pct_val is not None
                and item.received_cost_price is not None
                and item.received_ordered_qty is not None
            ):
                subtotal = float(item.received_ordered_qty or 0.0) * float(item.received_cost_price or 0.0)
                item.received_discount_total = round(subtotal * float(disc_pct_val) / 100.0, 2)

            tax_val = parse_float(_raw_value(raw_row, "tax"))
            if tax_val is not None:
                item.received_tax_subtotal = float(tax_val)

            batch_val = _raw_value(raw_row, "batch_number")
            expiry_val = parse_expiry(_raw_value(raw_row, "expiry_date"))

            receipt_line = (
                PurchaseOrderReceiptLine.query.filter_by(purchase_order_item_id=item.id)
                .order_by(PurchaseOrderReceiptLine.id.asc())
                .first()
            )
            has_receipt_values = any(
                value is not None
                for value in (
                    item.received_ordered_qty,
                    item.received_free_qty,
                    item.received_mrp,
                    batch_val,
                    expiry_val,
                )
            )
            if receipt_line:
                receipt_line.product_id = item.product_id
                receipt_line.uom = item.uom
                if item.received_ordered_qty is not None:
                    receipt_line.ordered_qty = float(item.received_ordered_qty or 0.0)
                if item.received_free_qty is not None:
                    receipt_line.free_qty = float(item.received_free_qty or 0.0)
                if batch_val is not None:
                    receipt_line.batch_number = str(batch_val).strip() or None
                if expiry_val is not None:
                    receipt_line.expiry_date = expiry_val
                if item.received_mrp is not None:
                    receipt_line.mrp = float(item.received_mrp)
            elif has_receipt_values:
                db.session.add(
                    PurchaseOrderReceiptLine(
                        company_id=company_id,
                        purchase_order_id=po.id,
                        purchase_order_item_id=item.id,
                        product_id=item.product_id,
                        uom=item.uom,
                        ordered_qty=float(item.received_ordered_qty or 0.0) if item.received_ordered_qty is not None else None,
                        free_qty=float(item.received_free_qty or 0.0) if item.received_free_qty is not None else None,
                        batch_number=str(batch_val).strip() or None if batch_val is not None else None,
                        expiry_date=expiry_val,
                        mrp=float(item.received_mrp) if item.received_mrp is not None else None,
                        received_by_user_id=getattr(g.current_user, "id", None),
                        received_at=datetime.now(timezone.utc),
                    )
                )

        po.status = "draft"
        db.session.commit()
        log_action("bulk_purchase_order_rematched", {"purchase_order_id": po.id}, g.current_company.id)
        return jsonify(po.to_dict(include_items=True))

    @app.route("/bulk-upload/purchase-orders/<int:order_id>", methods=["DELETE"])
    @require_auth
    @company_required(["admin", "manager", "superuser", "superadmin"])
    def delete_bulk_purchase_order(order_id: int):
        def _history_source_value(history) -> str:
            if isinstance(history, dict):
                return str(history.get("source") or "")
            if isinstance(history, str):
                try:
                    parsed = json.loads(history)
                    if isinstance(parsed, dict):
                        return str(parsed.get("source") or "")
                except Exception:
                    return ""
            return ""

        po = PurchaseOrder.query.get_or_404(order_id)
        if po.company_id != g.current_company.id:
            return jsonify({"error": "Forbidden"}), 403
        history_source = _history_source_value(getattr(po, "history", None))
        if history_source != "bulk_upload_excel":
            return jsonify({"error": "Only bulk-upload purchase orders can be deleted here"}), 400
        if po.purchase_bill_id:
            return jsonify({"error": "Purchase order already posted to inventory"}), 400

        db.session.delete(po)
        db.session.commit()
        log_action("bulk_purchase_order_deleted", {"purchase_order_id": po.id}, g.current_company.id)
        return jsonify({"status": "ok"})

    @app.route("/bulk-upload/purchase-orders/<int:order_id>/approve", methods=["POST"])
    @require_auth
    @company_required(["admin", "manager", "superuser", "superadmin"])
    def approve_bulk_purchase_order(order_id: int):
        def _history_source_value(history) -> str:
            if isinstance(history, dict):
                return str(history.get("source") or "")
            if isinstance(history, str):
                try:
                    parsed = json.loads(history)
                    if isinstance(parsed, dict):
                        return str(parsed.get("source") or "")
                except Exception:
                    return ""
            return ""

        po = PurchaseOrder.query.get_or_404(order_id)
        if po.company_id != g.current_company.id:
            return jsonify({"error": "Forbidden"}), 403
        history_source = _history_source_value(getattr(po, "history", None))
        if history_source != "bulk_upload_excel":
            return jsonify({"error": "Only bulk-upload purchase orders can be approved here"}), 400
        if po.purchase_bill_id:
            return jsonify({"error": "Purchase order already posted to inventory"}), 400

        invalid_lines: list[dict] = []
        for item in po.items or []:
            product = db.session.get(Product, item.product_id) if item.product_id else None
            if not product or product.company_id != g.current_company.id:
                invalid_lines.append({"id": item.id, "reason": "product"})
                continue
            qty_val = float(item.qty or 0.0)
            if qty_val <= 0:
                invalid_lines.append({"id": item.id, "reason": "qty"})
                continue
            uom_val = (item.uom or "").strip()
            validated = _validate_uom_for_product(product, uom_val)
            if product.uom_category and not validated:
                invalid_lines.append({"id": item.id, "reason": "uom"})
                continue

        if invalid_lines:
            return jsonify({"error": "Bulk PO has invalid lines", "invalid_lines": invalid_lines}), 400

        if not po.supplier_id:
            return jsonify({"error": "Supplier is required before approval"}), 400
        if po.purchase_bill_id:
            return jsonify({"error": "Bulk PO already has a purchase bill"}), 400

        purchase_date = None
        if po.order_date:
            purchase_date = po.order_date.date() if isinstance(po.order_date, datetime) else po.order_date
        if not purchase_date:
            purchase_date = date.today()

        bill = PurchaseBill(
            company_id=g.current_company.id,
            supplier_id=po.supplier_id,
            purchase_date=purchase_date,
            created_by_user_id=getattr(g.current_user, "id", None),
            approval_status="pending",
            approved_at=None,
            approved_by_user_id=None,
        )
        db.session.add(bill)
        db.session.flush()

        mapping = {}
        if isinstance(po.history, dict):
            mapping = po.history.get("mapping") or {}

        def _raw_value(row: dict, key: str) -> str | None:
            header = mapping.get(key)
            if not header:
                return None
            return row.get(header)

        totals_items = []
        for item in po.items or []:
            ordered_qty = float(item.received_ordered_qty) if item.received_ordered_qty is not None else float(item.qty or 0.0)
            free_qty = float(item.received_free_qty) if item.received_free_qty is not None else 0.0
            cost_price = float(item.received_cost_price or 0.0)
            mrp = float(item.received_mrp or 0.0)
            discount = float(item.received_discount_total or 0.0)
            tax_subtotal = float(item.received_tax_subtotal or 0.0)
            free_vat_percent = float(item.received_free_vat_percent or 0.0)

            raw_row = item.raw_row_data or {}
            batch_number = _raw_value(raw_row, "batch_number") or None
            expiry_raw = _raw_value(raw_row, "expiry_date")
            expiry_date = None
            if expiry_raw:
                try:
                    expiry_date = _parse_expiry_date_allow_month_year(expiry_raw)
                except Exception:
                    expiry_date = None

            if not batch_number or not expiry_date:
                line = (
                    PurchaseOrderReceiptLine.query.filter_by(purchase_order_item_id=item.id)
                    .order_by(PurchaseOrderReceiptLine.id.asc())
                    .first()
                )
                if line:
                    batch_number = batch_number or line.batch_number
                    expiry_date = expiry_date or line.expiry_date

            line_total = ordered_qty * cost_price - discount + tax_subtotal
            bill_item = PurchaseBillItem(
                purchase_bill=bill,
                product_id=item.product_id,
                raw_row_data=raw_row or None,
                uom=item.uom,
                batch_number=batch_number,
                expiry_date=expiry_date,
                ordered_qty=ordered_qty,
                free_qty=free_qty,
                cost_price=cost_price,
                price=mrp,
                mrp=mrp,
                discount=discount,
                tax_subtotal=tax_subtotal,
                free_vat_percent=free_vat_percent,
                line_total=round(line_total, 2),
            )
            db.session.add(bill_item)
            totals_items.append(
                {
                    "product": db.session.get(Product, item.product_id) if item.product_id else None,
                    "ordered_qty": ordered_qty,
                    "free_qty": free_qty,
                    "cost_price": cost_price,
                    "discount": discount,
                    "tax_subtotal": tax_subtotal,
                    "free_vat_percent": free_vat_percent,
                }
            )

        _sync_purchase_bill_totals(g.current_company, bill)

        history = po.history if isinstance(po.history, dict) else {}
        history = dict(history or {})
        history["bulk_approved"] = True
        history["bulk_approved_at"] = _format_dt(datetime.utcnow())
        history["bulk_approved_by_user_id"] = g.current_user.id
        history["bulk_approved_by"] = _user_display_name(g.current_user, g.current_user.id)
        history["bulk_purchase_bill_id"] = bill.id
        po.history = history
        po.purchase_bill_id = bill.id
        po.status = "received"

        db.session.commit()
        log_action("bulk_purchase_order_approved", {"purchase_order_id": po.id}, g.current_company.id)
        return jsonify(po.to_dict(include_items=True))

    @app.route("/settings/sales-returns", methods=["GET"])
    @require_auth
    @company_required(["admin", "manager"])
    def get_sales_return_settings():
        company = g.current_company
        value, unit = _sales_return_window(company)
        return jsonify({"limit_value": value, "limit_unit": unit})

    @app.route("/settings/sales-returns", methods=["PUT"])
    @require_auth
    @company_required(["admin", "manager"])
    def update_sales_return_settings():
        data = request.get_json() or {}
        try:
            value = int(data.get("limit_value"))
        except (TypeError, ValueError):
            return jsonify({"error": "Invalid limit_value"}), 400
        unit = str(data.get("limit_unit") or "").lower()
        if value < 0:
            return jsonify({"error": "limit_value must be >= 0"}), 400
        if unit not in {"days", "months"}:
            return jsonify({"error": "Invalid limit_unit"}), 400
        company = g.current_company
        company.sales_return_limit_value = value
        company.sales_return_limit_unit = unit
        db.session.commit()
        log_action(
            "sales_return_settings_updated",
            {"company_id": company.id, "limit_value": value, "limit_unit": unit},
            company.id,
        )
        return jsonify({"limit_value": value, "limit_unit": unit})

    @app.route("/settings/sections", methods=["GET", "OPTIONS"])
    @require_auth
    @company_required()
    def settings_sections():
        # Sections mirror the frontend defaults so tabs render even if not customized server-side.
        sections = [
            {
                "id": "user-management",
                "name": "User Management",
                "icon": "people",
                "description": "Manage users, roles, and permissions",
                "order": 0.5,
                "permissions": ["manage_users", "manage_roles", "admin"],
            },
            {
                "id": "unit-categories",
                "name": "Unit Categories",
                "icon": "category",
                "description": "Manage unit categories",
                "order": 1,
                "permissions": ["manage_units"],
            },
            {
                "id": "units",
                "name": "Units",
                "icon": "local_pharmacy",
                "description": "Manage units of measure",
                "order": 2,
                "permissions": ["manage_units"],
            },
            {
                "id": "roles",
                "name": "Roles",
                "icon": "security",
                "description": "Manage user roles and permissions",
                "order": 3,
                "permissions": ["manage_roles"],
            },
            {
                "id": "user-permissions",
                "name": "User Permissions",
                "icon": "people",
                "description": "Manage user permissions",
                "order": 4,
                "permissions": ["manage_users"],
            },
            {
                "id": "companies",
                "name": "Companies",
                "icon": "business",
                "description": "Manage company settings",
                "order": 5,
                "permissions": ["manage_companies"],
            },
            {
                "id": "company-settings",
                "name": "Company Settings",
                "icon": "store",
                "description": "Configure company-level login behavior",
                "order": 5.5,
                "permissions": ["manage_companies", "admin", "manager", "superuser", "superadmin"],
            },
            {
                "id": "payment-modes",
                "name": "Payment Modes",
                "icon": "payments",
                "description": "Configure allowed payment options",
                "order": 7,
                "permissions": ["manage_companies", "admin"],
            },
            {
                "id": "sales-returns",
                "name": "Sales Returns",
                "icon": "assignment_return",
                "description": "Configure sales return rules",
                "order": 8,
                "permissions": ["manage_companies", "admin"],
            },
            {
                "id": "stock-management-adjustment",
                "name": "Stock Management and Adjustment",
                "icon": "inventory",
                "description": "Manage stock controls and adjustment workflows",
                "order": 8.5,
                "permissions": ["manage_companies", "admin", "manager"],
            },
            {
                "id": "print-settings",
                "name": "Print Settings",
                "icon": "print",
                "description": "List all printable documents across the app",
                "order": 9,
                "permissions": ["manage_companies", "admin", "manager", "staff", "salesman", "superuser"],
            },
            {
                "id": "accounts",
                "name": "Accounts",
                "icon": "account_balance",
                "description": "Manage chart of accounts",
                "order": 10,
                "permissions": ["manage_companies", "admin"],
            },
        ]
        return jsonify(sections)

    # Company users (team)
    @app.route("/company/users", methods=["GET"])
    @require_auth
    @company_required()
    def list_company_users():
        memberships = UserCompany.query.filter_by(company_id=g.current_company.id).all()
        users = []
        for m in memberships:
            if not m.user:
                continue
            data = m.user.to_dict()
            data["company_id"] = m.company_id
            data["company_name"] = m.company.name if m.company else None
            data["company_role"] = m.role
            data["monthly_salary"] = m.monthly_salary or 0
            data["joined_date"] = (
                m.joined_date.isoformat()
                if m.joined_date
                else (m.created_at.date().isoformat() if m.created_at else None)
            )
            users.append(data)
        return jsonify(users)

    @app.route("/company/users/<int:user_id>", methods=["PUT"])
    @require_auth
    @company_required(["admin", "manager"])
    def update_company_user(user_id: int):
        membership = UserCompany.query.filter_by(company_id=g.current_company.id, user_id=user_id).first()
        if not membership or not membership.user:
            return jsonify({"error": "User not found in this company"}), 404
        user = membership.user
        caller_role = (g.company_role or g.current_user.role or "").strip().lower()
        target_role = (membership.role or user.role or "").strip().lower()
        # managers cannot modify platform superusers
        if user.role in ROLE_PLATFORM_ADMINS and g.current_user.role not in ROLE_PLATFORM_ADMINS:
            return jsonify({"error": "Forbidden"}), 403
        # managers can edit only staff or themselves
        if caller_role == "manager" and user.id != g.current_user.id and target_role != "staff":
            return jsonify({"error": "Managers can edit only staff users"}), 403
        data = request.get_json() or {}
        for field in [
            "first_name",
            "last_name",
            "email",
            "phone",
            "whatsapp",
            "address",
            "fingerprint_template",
            "time_zone",
            "avatar_url",
            "signature_url",
        ]:
            if field in data:
                setattr(user, field, data.get(field) or "")
        if "username" in data:
            if g.current_user.role not in ROLE_PLATFORM_ADMINS:
                return jsonify({"error": "Only superadmin can change usernames"}), 403
            new_username = str(data.get("username") or "").strip()
            if not new_username:
                return jsonify({"error": "Username is required"}), 400
            if new_username != user.username:
                existing = User.query.filter_by(username=new_username).first()
                if existing and existing.id != user.id:
                    return jsonify({"error": "Username already exists"}), 400
                user.username = new_username
        if "is_active" in data:
            user.is_active = bool(data.get("is_active"))
        if "company_role" in data:
            if caller_role == "manager":
                return jsonify({"error": "Managers cannot change roles"}), 403
            new_role = (data.get("company_role") or membership.role or "").strip().lower()
            role_obj = Role.query.filter_by(name=new_role).first()
            if not role_obj:
                return jsonify({"error": "Unknown role"}), 400

            is_platform_admin = g.current_user.role in ROLE_PLATFORM_ADMINS
            if not is_platform_admin and new_role in {"superuser", "admin", "manager"}:
                return jsonify({"error": "Only platform admin can assign admin/manager/superuser roles"}), 403

            if new_role in {"manager", "salesman"}:
                other_links = (
                    UserCompany.query.filter(
                        UserCompany.user_id == user.id,
                        UserCompany.company_id != g.current_company.id,
                    ).count()
                )
                if other_links > 0:
                    return jsonify({"error": f"{new_role.title()} can access only one company. Remove/downgrade other company links first."}), 400
            membership.role = new_role
            # Keep a single-company user's platform role aligned with their company role,
            # but don't override platform admins or multi-company users.
            if user.role not in ROLE_PLATFORM_ADMINS:
                memberships_count = UserCompany.query.filter_by(user_id=user.id).count()
                if memberships_count <= 1:
                    user.role = new_role
        if "monthly_salary" in data:
            try:
                membership.monthly_salary = float(data.get("monthly_salary") or 0)
            except Exception:
                return jsonify({"error": "Invalid salary"}), 400
        if "joined_date" in data:
            caller_platform_role = str(getattr(g.current_user, "role", "") or "").strip().lower()
            can_edit_joined_date = False
            if caller_platform_role in ROLE_PLATFORM_ADMINS or caller_role in {"superuser", "superadmin"}:
                can_edit_joined_date = True
            elif caller_role == "manager" and target_role == "staff":
                can_edit_joined_date = True
            if not can_edit_joined_date:
                return jsonify({"error": "Not allowed to edit joined date for this user"}), 403
            raw_joined_date = str(data.get("joined_date") or "").strip()
            if raw_joined_date:
                try:
                    membership.joined_date = date.fromisoformat(raw_joined_date)
                except ValueError:
                    return jsonify({"error": "Invalid joined_date (expected YYYY-MM-DD)"}), 400
            else:
                membership.joined_date = None
        new_password = data.get("new_password")
        if new_password:
            user.set_password(new_password)
        db.session.commit()
        result = user.to_dict()
        result["company_role"] = membership.role
        result["monthly_salary"] = membership.monthly_salary
        result["joined_date"] = (
            membership.joined_date.isoformat()
            if membership.joined_date
            else (membership.created_at.date().isoformat() if membership.created_at else None)
        )
        log_action("user_updated", {"id": user.id, "company_role": membership.role}, g.current_company.id)
        return jsonify(result)

    @app.route("/company/users/<int:user_id>", methods=["DELETE"])
    @require_auth
    @company_required(["admin", "manager"])
    def delete_company_user(user_id: int):
        membership = UserCompany.query.filter_by(company_id=g.current_company.id, user_id=user_id).first()
        if not membership or not membership.user:
            return jsonify({"error": "User not found in this company"}), 404
        user = membership.user
        # prevent deleting superusers unless caller is superuser
        if user.role in ROLE_PLATFORM_ADMINS and g.current_user.role not in ROLE_PLATFORM_ADMINS:
            return jsonify({"error": "Cannot delete superuser"}), 403
        try:
            # Always allow removing the company membership.
            # If the user has no other memberships and no activity, remove user row too.
            # If there is activity, keep user row (inactive) to preserve audit history.
            other_links = UserCompany.query.filter(
                UserCompany.user_id == user_id,
                UserCompany.id != membership.id,
            ).count()
            has_any_activity = ActivityLog.query.filter_by(user_id=user_id).first() is not None
            db.session.delete(membership)

            # cleanup permission grants (prevents FK constraint issues and removes access)
            if other_links == 0:
                UserPermissionGrant.query.filter_by(user_id=user_id).delete(synchronize_session=False)
                UserPermission.query.filter_by(user_id=user_id).delete(synchronize_session=False)
            else:
                UserPermissionGrant.query.filter_by(user_id=user_id, company_id=g.current_company.id).delete(
                    synchronize_session=False
                )

            deleted_user_row = False
            if other_links == 0 and not has_any_activity:
                db.session.delete(user)
                deleted_user_row = True
            elif other_links == 0 and has_any_activity:
                # Keep historical actor but prevent future access.
                user.is_active = False
            db.session.commit()
        except Exception as exc:
            db.session.rollback()
            return jsonify({"error": f"Delete failed: {exc}"}), 400
        log_action("user_deleted", {"id": user_id}, g.current_company.id)
        return jsonify(
            {
                "deleted": True,
                "user_row_deleted": deleted_user_row,
                "membership_removed": True,
            }
        )

    def _build_payroll_month_statement(
        *,
        company_id: int,
        user_id: int,
        salary_year: int,
        salary_month: int,
        monthly_salary: float,
        paid_leave_days: int = 0,
    ) -> dict:
        if salary_month < 1 or salary_month > 12:
            raise ValueError("Invalid salary month")
        month_days = int(calendar.monthrange(int(salary_year), int(salary_month))[1])
        month_start = date(int(salary_year), int(salary_month), 1)
        month_end = date(int(salary_year), int(salary_month), month_days)
        today = _today_ad()
        if month_start > today:
            considered_days = 0
            present_days = 0
        else:
            considered_to = min(month_end, today)
            considered_days = int((considered_to - month_start).days + 1)
            present_days = (
                Attendance.query.filter(
                    Attendance.company_id == company_id,
                    Attendance.user_id == user_id,
                    Attendance.date_ad >= month_start,
                    Attendance.date_ad <= considered_to,
                    Attendance.is_present.is_(True),
                ).count()
            )
        absent_days = max(0, considered_days - int(present_days))
        normalized_leave = max(0, min(int(paid_leave_days or 0), month_days))
        salary_amount = float(monthly_salary or 0.0)
        day_salary = (salary_amount / month_days) if month_days > 0 else 0.0
        payable_days = min(month_days, int(present_days) + normalized_leave)
        payable_amount = float(round(day_salary * payable_days, 2))
        return {
            "salary_year": int(salary_year),
            "salary_month": int(salary_month),
            "days_in_month": month_days,
            "considered_days": int(considered_days),
            "present_days": int(present_days),
            "absent_days": int(absent_days),
            "paid_leave_days": int(normalized_leave),
            "payable_days": int(payable_days),
            "monthly_salary": float(round(salary_amount, 2)),
            "day_salary": float(round(day_salary, 6)),
            "payable_amount": payable_amount,
        }

    @app.route("/payroll/month-statement", methods=["GET"])
    @require_auth
    @company_required(["manager", "superuser", "superadmin"])
    def payroll_month_statement():
        user_id = request.args.get("user_id", type=int)
        salary_year = request.args.get("year", type=int)
        salary_month = request.args.get("month", type=int)
        paid_leave_days = request.args.get("paid_leave_days", type=int) or 0
        if not user_id or not salary_year or not salary_month:
            return jsonify({"error": "user_id, year and month are required"}), 400
        if salary_month < 1 or salary_month > 12:
            return jsonify({"error": "Invalid month"}), 400
        membership = UserCompany.query.filter_by(company_id=g.current_company.id, user_id=user_id).first()
        if not membership or not membership.user:
            return jsonify({"error": "User not found in this company"}), 404
        statement = _build_payroll_month_statement(
            company_id=g.current_company.id,
            user_id=int(user_id),
            salary_year=int(salary_year),
            salary_month=int(salary_month),
            monthly_salary=float(membership.monthly_salary or 0.0),
            paid_leave_days=int(paid_leave_days or 0),
        )
        payment = PayrollPayment.query.filter_by(
            company_id=g.current_company.id,
            user_id=int(user_id),
            salary_year=int(salary_year),
            salary_month=int(salary_month),
        ).first()
        return jsonify(
            {
                "statement": statement,
                "already_paid": bool(payment),
                "payment": payment.to_dict() if payment else None,
            }
        )

    @app.route("/payroll/payments", methods=["POST"])
    @require_auth
    @company_required(["manager", "superuser", "superadmin"])
    def create_payroll_payment():
        data = request.get_json() or {}
        try:
            user_id = int(data.get("user_id") or 0)
            salary_year = int(data.get("year") or 0)
            salary_month = int(data.get("month") or 0)
        except (TypeError, ValueError):
            return jsonify({"error": "Invalid user_id/year/month"}), 400
        if user_id <= 0 or salary_year <= 0 or salary_month < 1 or salary_month > 12:
            return jsonify({"error": "Invalid user_id/year/month"}), 400

        membership = UserCompany.query.filter_by(company_id=g.current_company.id, user_id=user_id).first()
        if not membership or not membership.user:
            return jsonify({"error": "User not found in this company"}), 404

        existing = PayrollPayment.query.filter_by(
            company_id=g.current_company.id,
            user_id=user_id,
            salary_year=salary_year,
            salary_month=salary_month,
        ).first()
        if existing:
            return jsonify({"error": "Salary already paid for this month", "payment": existing.to_dict()}), 400

        paid_leave_days = data.get("paid_leave_days", 0)
        try:
            paid_leave_days = int(paid_leave_days or 0)
        except (TypeError, ValueError):
            return jsonify({"error": "Invalid paid_leave_days"}), 400

        statement = _build_payroll_month_statement(
            company_id=g.current_company.id,
            user_id=user_id,
            salary_year=salary_year,
            salary_month=salary_month,
            monthly_salary=float(membership.monthly_salary or 0.0),
            paid_leave_days=paid_leave_days,
        )
        default_amount = float(statement["payable_amount"] or 0.0)
        try:
            amount = float(data.get("amount", default_amount) or 0.0)
        except (TypeError, ValueError):
            return jsonify({"error": "Invalid amount"}), 400
        if amount <= 0:
            return jsonify({"error": "Payable amount must be greater than 0"}), 400

        mode = None
        payment_mode_id = data.get("payment_mode_id")
        payment_mode_name = "Cash"
        if payment_mode_id not in (None, "", 0, "0"):
            try:
                payment_mode_id = int(payment_mode_id)
            except (TypeError, ValueError):
                return jsonify({"error": "Invalid payment mode"}), 400
            mode = PaymentMode.query.get(payment_mode_id)
            if not mode or mode.company_id != g.current_company.id:
                return jsonify({"error": "Invalid payment mode"}), 400
            if mode.is_archived or not mode.is_active:
                return jsonify({"error": "Payment mode is inactive"}), 400
            payment_mode_name = mode.name or "Cash"
        elif str(data.get("payment_mode_name") or "").strip():
            payment_mode_name = str(data.get("payment_mode_name") or "").strip()

        payment_account_name = _resolve_payment_account_name(g.current_company.id, payment_mode_name, "Cash")
        is_bank_mode = ("bank" in str(payment_mode_name or "").lower()) or ("bank" in str(payment_account_name or "").lower())
        issue_cheque = bool(data.get("issue_cheque")) or is_bank_mode
        reference_number = str(data.get("reference_number") or "").strip()
        if issue_cheque and not reference_number:
            return jsonify({"error": "Cheque/reference number is required for bank payment"}), 400

        paid_on_raw = str(data.get("paid_on") or "").strip()
        if paid_on_raw:
            try:
                paid_on = date.fromisoformat(paid_on_raw)
            except ValueError:
                return jsonify({"error": "Invalid paid_on date"}), 400
        else:
            paid_on = _today_ad()

        row = PayrollPayment(
            company_id=g.current_company.id,
            user_id=user_id,
            salary_year=salary_year,
            salary_month=salary_month,
            paid_on=paid_on,
            amount=float(round(amount, 2)),
            monthly_salary=float(statement["monthly_salary"]),
            day_salary=float(statement["day_salary"]),
            days_in_month=int(statement["days_in_month"]),
            considered_days=int(statement["considered_days"]),
            present_days=int(statement["present_days"]),
            absent_days=int(statement["absent_days"]),
            paid_leave_days=int(statement["paid_leave_days"]),
            payable_days=int(statement["payable_days"]),
            payment_mode_id=mode.id if mode else None,
            payment_mode_name=payment_mode_name,
            issue_cheque=bool(issue_cheque),
            reference_number=reference_number or None,
            notes=str(data.get("notes") or "").strip() or None,
            created_by_user_id=int(getattr(g.current_user, "id", 0) or 0) or None,
        )
        db.session.add(row)
        db.session.flush()

        employee_name = " ".join(
            p for p in [str(getattr(membership.user, "first_name", "") or "").strip(), str(getattr(membership.user, "last_name", "") or "").strip()] if p
        ).strip() or membership.user.username
        description = f"Salary paid for {salary_year}-{salary_month:02d} to {employee_name}"
        if issue_cheque and reference_number:
            description = f"{description} (Cheque #{reference_number})"
        _post_double_entry(
            g.current_company.id,
            "Salary Expense",
            "expense",
            payment_account_name,
            "asset",
            float(round(amount, 2)),
            "payroll_payment",
            row.id,
            description,
        )

        db.session.commit()
        log_action(
            "payroll_payment_created",
            {
                "id": row.id,
                "user_id": user_id,
                "year": salary_year,
                "month": salary_month,
                "amount": float(row.amount or 0.0),
                "payment_mode": payment_mode_name,
                "issue_cheque": bool(issue_cheque),
            },
            g.current_company.id,
        )
        return jsonify({"payment": row.to_dict(), "statement": statement}), 201

    # Attendance
    @app.route("/attendance", methods=["GET"])
    @require_auth
    @company_required()
    def list_attendance():
        user_id = request.args.get("user_id", type=int)
        date_from_raw = (request.args.get("date_from") or "").strip()
        date_to_raw = (request.args.get("date_to") or "").strip()
        limit = request.args.get("limit", type=int) or 200
        limit = max(1, min(limit, 2000))
        query = Attendance.query.filter_by(company_id=g.current_company.id)
        if user_id:
            query = query.filter_by(user_id=user_id)
        if date_from_raw:
            try:
                date_from = datetime.fromisoformat(date_from_raw).date()
                query = query.filter(Attendance.date_ad >= date_from)
            except Exception:
                pass
        if date_to_raw:
            try:
                date_to = datetime.fromisoformat(date_to_raw).date()
                query = query.filter(Attendance.date_ad <= date_to)
            except Exception:
                pass
        records = query.order_by(Attendance.date_ad.desc()).limit(limit).all()
        return jsonify([a.to_dict() for a in records])

    @app.route("/attendance", methods=["POST"])
    @require_auth
    @company_required(["admin", "manager", "superuser", "superadmin"])
    def upsert_attendance():
        data = request.get_json() or {}
        user_id = data.get("user_id")
        date_ad_raw = data.get("date_ad")
        if not user_id or not date_ad_raw:
            return jsonify({"error": "user_id and date_ad required"}), 400
        try:
            date_ad = datetime.fromisoformat(date_ad_raw).date()
        except Exception:
            return jsonify({"error": "Invalid AD date"}), 400
        today = _today_ad()
        if date_ad > today:
            return jsonify({"error": "Future attendance cannot be updated"}), 400

        role_lc = str(getattr(g, "company_role", None) or getattr(g.current_user, "role", "") or "").strip().lower()
        if date_ad < today and role_lc not in {"manager", "superuser", "superadmin"}:
            return jsonify({"error": "Only manager/superadmin can update previous-date attendance"}), 403

        membership = UserCompany.query.filter_by(company_id=g.current_company.id, user_id=user_id).first()
        if not membership:
            return jsonify({"error": "User not in this company"}), 404

        is_present_raw = data.get("is_present", None)
        is_present = None
        if is_present_raw is not None:
            if isinstance(is_present_raw, bool):
                is_present = is_present_raw
            elif isinstance(is_present_raw, (int, float)):
                is_present = bool(int(is_present_raw))
            else:
                normalized = str(is_present_raw).strip().lower()
                if normalized in {"1", "true", "yes", "present"}:
                    is_present = True
                elif normalized in {"0", "false", "no", "absent"}:
                    is_present = False
                else:
                    return jsonify({"error": "Invalid is_present value"}), 400

        attendance = (
            Attendance.query.filter_by(company_id=g.current_company.id, user_id=user_id, date_ad=date_ad).first()
            or Attendance(company_id=g.current_company.id, user_id=user_id, date_ad=date_ad)
        )
        attendance.date_bs = data.get("date_bs") or attendance.date_bs
        now_time = datetime.now().strftime("%H:%M")
        if is_present is not None:
            attendance.is_present = bool(is_present)

        if "arrival_time" in data:
            attendance.arrival_time = data.get("arrival_time") or None
        elif is_present is True:
            attendance.arrival_time = attendance.arrival_time or now_time
            attendance.departure_time = None
        elif is_present is False:
            attendance.arrival_time = None

        if "departure_time" in data:
            attendance.departure_time = data.get("departure_time") or None
        elif is_present is False:
            attendance.departure_time = attendance.departure_time or now_time

        db.session.add(attendance)
        db.session.commit()
        log_action(
            "attendance_saved",
            {"user_id": user_id, "date_ad": attendance.date_ad.isoformat(), "is_present": bool(attendance.is_present)},
            g.current_company.id,
        )
        return jsonify(attendance.to_dict())

    # Suppliers
    @app.route("/suppliers", methods=["GET"])
    @require_auth
    @company_required()
    def list_suppliers():
        include_archived = request.args.get("include_archived") == "1"
        query = Supplier.query.filter_by(company_id=g.current_company.id)
        if not include_archived:
            query = query.filter(or_(Supplier.is_archived.is_(False), Supplier.is_archived.is_(None)))
        suppliers, meta = paginate_query(query.order_by(Supplier.name.asc()))
        return jsonify({"data": [s.to_dict() for s in suppliers], "pagination": meta})

    @app.route("/suppliers", methods=["POST"])
    @require_auth
    @company_required(["admin", "manager"])
    def create_supplier():
        data = request.get_json() or {}
        name = data.get("name", "").strip()
        if not name:
            return jsonify({"error": "Supplier name is required"}), 400
        exists = Supplier.query.filter_by(company_id=g.current_company.id, name=name).first()
        if exists:
            return jsonify({"error": "Supplier already exists"}), 400
        supplier = Supplier(
            company_id=g.current_company.id,
            name=name,
            address=data.get("address", "") or "",
            city=data.get("city", "") or "",
            phone=data.get("phone", "") or "",
            contact_name=data.get("contact_name", "") or "",
            contact_phone=data.get("contact_phone", "") or "",
            dda_number=data.get("dda_number", "") or "",
            pan_vat_number=data.get("pan_vat_number", "") or "",
            location_url=data.get("location_url", "") or "",
            email=data.get("email", "") or "",
        )
        db.session.add(supplier)
        db.session.flush()
        _get_or_create_supplier_account(g.current_company.id, supplier)
        db.session.commit()
        log_action("supplier_created", {"id": supplier.id}, g.current_company.id)
        return jsonify(supplier.to_dict()), 201

    @app.route("/suppliers/<int:supplier_id>", methods=["PUT"])
    @require_auth
    @company_required(["admin", "manager"])
    def update_supplier(supplier_id: int):
        supplier = Supplier.query.get_or_404(supplier_id)
        if supplier.company_id != g.current_company.id:
            return jsonify({"error": "Forbidden"}), 403
        data = request.get_json() or {}
        old_name = supplier.name
        for field in [
            "name",
            "address",
            "city",
            "phone",
            "contact_name",
            "contact_phone",
            "dda_number",
            "pan_vat_number",
            "location_url",
            "email",
            "is_archived",
        ]:
            if field in data:
                setattr(supplier, field, data[field] if data[field] is not None else "")
        if supplier.name != old_name:
            _rename_party_ledger(
                g.current_company.id,
                f"Supplier #{supplier.id}: {old_name}",
                _supplier_ledger_name(supplier),
                "liability",
                "Accounts Payable",
            )
        db.session.commit()
        log_action("supplier_updated", {"id": supplier.id}, g.current_company.id)
        return jsonify(supplier.to_dict())

    @app.route("/suppliers/<int:supplier_id>/archive", methods=["POST"])
    @require_auth
    @company_required(["admin", "manager"])
    def archive_supplier(supplier_id: int):
        supplier = Supplier.query.get_or_404(supplier_id)
        if supplier.company_id != g.current_company.id:
            return jsonify({"error": "Forbidden"}), 403
        supplier.is_archived = True
        db.session.commit()
        log_action("supplier_archived", {"id": supplier.id}, g.current_company.id)
        return jsonify(supplier.to_dict())

    @app.route("/suppliers/<int:supplier_id>/history", methods=["GET"])
    @require_auth
    @company_required()
    def supplier_history(supplier_id: int):
        supplier = Supplier.query.get_or_404(supplier_id)
        if supplier.company_id != g.current_company.id:
            return jsonify({"error": "Forbidden"}), 403

        bills = (
            PurchaseBill.query.filter_by(company_id=g.current_company.id, supplier_id=supplier_id)
            .order_by(PurchaseBill.created_at.desc())
            .all()
        )

        history = []
        for bill in bills:
            gross_total = round(float(bill.gross_total or 0.0), 2)
            paid_amount = round(sum(float(p.amount or 0.0) for p in (bill.payments or [])), 2)
            due_amount = round(max(gross_total - paid_amount, 0.0), 2)
            payment_state = "paid" if due_amount <= 0 else ("partially_paid" if paid_amount > 0 else "unpaid")
            item_names = []
            for it in bill.items or []:
                nm = (it.product.name if getattr(it, "product", None) else None) or None
                if nm:
                    item_names.append(nm)
            history.append(
                {
                    "id": bill.id,
                    "bill_number": bill.bill_number or str(bill.id),
                    "created_at": bill.created_at.isoformat() if bill.created_at else None,
                    "purchase_date": bill.purchase_date.isoformat() if bill.purchase_date else None,
                    "total_amount": gross_total,
                    "paid_amount": paid_amount,
                    "due_amount": due_amount,
                    "payment_state": payment_state,
                    "item_names": item_names,
                }
            )
        return jsonify(history)

    # Stock management and adjustment
    def _stock_adjustment_defaults(
        *,
        company_id: int,
        product: Product,
        batch_number: str | None,
        uom: str | None,
        as_of_date: date | None = None,
    ) -> dict:
        safe_batch = (batch_number or "").strip() or None
        safe_uom = (uom or "").strip() or None

        inv_q = InventoryBatch.query.filter(
            InventoryBatch.company_id == company_id,
            InventoryBatch.product_id == product.id,
        )
        if product.lot_tracking:
            inv_q = inv_q.filter(func.coalesce(InventoryBatch.batch_number, "") == (safe_batch or ""))
        else:
            inv_q = inv_q.filter(func.coalesce(InventoryBatch.batch_number, "") == "")
            if safe_uom:
                inv_q = inv_q.filter(func.coalesce(InventoryBatch.uom, "") == safe_uom)
        inv_row = (
            inv_q.order_by(
                InventoryBatch.created_at.desc().nullslast(),
                InventoryBatch.id.desc(),
            ).first()
        )
        if inv_row:
            mrp_val = float(getattr(inv_row, "mrp_per_uom", 0.0) or 0.0) or float(inv_row.mrp or 0.0)
            return {
                "uom": (inv_row.uom or safe_uom or None),
                "expiry_date": inv_row.expiry_date,
                "mrp": mrp_val,
            }

        pb_q = (
            db.session.query(PurchaseBillItem, PurchaseBill)
            .join(PurchaseBill, PurchaseBillItem.purchase_bill_id == PurchaseBill.id)
            .filter(
                PurchaseBill.company_id == company_id,
                PurchaseBill.posted.is_(True),
                PurchaseBillItem.product_id == product.id,
            )
        )
        if as_of_date:
            pb_q = pb_q.filter(PurchaseBill.purchase_date <= as_of_date)
        if product.lot_tracking:
            pb_q = pb_q.filter(func.coalesce(PurchaseBillItem.batch_number, "") == (safe_batch or ""))
        else:
            pb_q = pb_q.filter(func.coalesce(PurchaseBillItem.batch_number, "") == "")
            if safe_uom:
                pb_q = pb_q.filter(func.coalesce(PurchaseBillItem.uom, "") == safe_uom)
        pb_row = (
            pb_q.order_by(
                PurchaseBill.purchase_date.desc().nullslast(),
                PurchaseBill.created_at.desc().nullslast(),
                PurchaseBillItem.id.desc(),
            ).first()
        )
        if pb_row:
            it, _bill = pb_row
            mrp_val = float(it.mrp or 0.0) or float(getattr(it, "price", 0.0) or 0.0)
            return {
                "uom": (it.uom or safe_uom or None),
                "expiry_date": it.expiry_date,
                "mrp": mrp_val,
            }

        return {"uom": safe_uom, "expiry_date": None, "mrp": 0.0}

    def _parse_stock_adjustment_datetime(value: str | None) -> datetime:
        raw = (value or "").strip()
        if not raw:
            return datetime.now(timezone.utc).replace(tzinfo=None)
        for parser in (
            lambda v: datetime.fromisoformat(v),
            lambda v: datetime.strptime(v, "%m/%d/%Y %I:%M %p"),
            lambda v: datetime.strptime(v, "%Y-%m-%d %H:%M"),
            lambda v: datetime.strptime(v, "%Y-%m-%d"),
        ):
            try:
                parsed = parser(raw)
                if parsed.tzinfo is None:
                    return parsed
                return parsed.astimezone(timezone.utc).replace(tzinfo=None)
            except Exception:
                continue
        raise ValueError("Invalid adjustment_date")

    @app.route("/stock-adjustments", methods=["GET"])
    @require_auth
    @company_required()
    def list_stock_adjustments():
        query = StockAdjustment.query.filter_by(company_id=g.current_company.id).order_by(
            StockAdjustment.adjustment_date.desc(),
            StockAdjustment.created_at.desc(),
            StockAdjustment.id.desc(),
        )
        rows = query.all()
        return jsonify([r.to_dict() for r in rows])

    @app.route("/stock-adjustments/autofill", methods=["GET"])
    @require_auth
    @company_required()
    def stock_adjustment_autofill():
        product_id = int(request.args.get("product_id") or 0)
        batch_number = (request.args.get("batch_number") or "").strip() or None
        uom = (request.args.get("uom") or "").strip() or None
        date_raw = (request.args.get("adjustment_date") or "").strip()
        if not product_id:
            return jsonify({"error": "product_id is required"}), 400
        product = Product.query.get_or_404(product_id)
        if product.company_id != g.current_company.id:
            return jsonify({"error": "Forbidden"}), 403
        as_of_date = None
        if date_raw:
            try:
                as_of_date = datetime.fromisoformat(date_raw).date()
            except Exception:
                as_of_date = None
        defaults = _stock_adjustment_defaults(
            company_id=g.current_company.id,
            product=product,
            batch_number=batch_number,
            uom=uom,
            as_of_date=as_of_date,
        )
        return jsonify(
            {
                "product_id": product.id,
                "batch_number": batch_number,
                "uom": defaults.get("uom"),
                "expiry_date": defaults.get("expiry_date").isoformat() if defaults.get("expiry_date") else None,
                "mrp": round(float(defaults.get("mrp") or 0.0), 4),
            }
        )

    @app.route("/stock-adjustments", methods=["POST"])
    @require_auth
    @company_required(["admin", "manager"])
    def create_stock_adjustment():
        data = request.get_json() or {}
        lines = data.get("lines")
        if not isinstance(lines, list) or not lines:
            lines = [data]

        created: list[StockAdjustment] = []
        for idx, row in enumerate(lines):
            row_data = row or {}
            product_id = int(row_data.get("product_id") or data.get("product_id") or 0)
            qty = int(row_data.get("qty") or 0)
            uom = (row_data.get("uom") or data.get("uom") or "").strip() or None
            batch_number = (
                row_data.get("batch_number")
                or data.get("batch_number")
                or data.get("lot_number")
                or ""
            ).strip() or None
            date_raw = (row_data.get("adjustment_date") or data.get("adjustment_date") or data.get("date") or "").strip()

            # Allow extra blank rows from the UI; skip them.
            row_has_any_value = bool(
                row_data.get("product_id")
                or row_data.get("qty")
                or row_data.get("uom")
                or row_data.get("batch_number")
                or row_data.get("lot_number")
                or row_data.get("adjustment_date")
            )
            if not row_has_any_value:
                continue

            if not product_id:
                return jsonify({"error": f"Line {idx + 1}: product_id is required"}), 400
            if qty <= 0:
                return jsonify({"error": f"Line {idx + 1}: qty must be > 0"}), 400
            try:
                dt_val = _parse_stock_adjustment_datetime(date_raw)
                adjustment_date = dt_val.date()
            except Exception:
                return jsonify({"error": f"Line {idx + 1}: Invalid adjustment_date"}), 400

            product = Product.query.get_or_404(product_id)
            if product.company_id != g.current_company.id:
                return jsonify({"error": "Forbidden"}), 403
            if product.lot_tracking and not batch_number:
                return jsonify({"error": f"Line {idx + 1}: Batch/Lot number is required for this product"}), 400

            factor_to_base = _uom_factor_to_base(g.current_company.id, product, uom)
            qty_base = _qty_to_base(g.current_company.id, product, qty, uom)
            if qty_base <= 0:
                return jsonify({"error": f"Line {idx + 1}: Converted quantity must be > 0"}), 400

            defaults = _stock_adjustment_defaults(
                company_id=g.current_company.id,
                product=product,
                batch_number=batch_number if product.lot_tracking else None,
                uom=uom,
                as_of_date=adjustment_date,
            )
            if not uom and defaults.get("uom"):
                uom = str(defaults.get("uom"))
                factor_to_base = _uom_factor_to_base(g.current_company.id, product, uom)
                qty_base = _qty_to_base(g.current_company.id, product, qty, uom)
            expiry_raw = (row_data.get("expiry_date") or data.get("expiry_date") or "").strip()
            expiry_val = None
            if expiry_raw:
                try:
                    expiry_val = datetime.fromisoformat(expiry_raw).date()
                except Exception:
                    return jsonify({"error": f"Line {idx + 1}: Invalid expiry_date (expected YYYY-MM-DD)"}), 400
            if not expiry_val:
                expiry_val = defaults.get("expiry_date")
            try:
                mrp_val = float(row_data.get("mrp") or data.get("mrp") or 0.0)
            except Exception:
                return jsonify({"error": f"Line {idx + 1}: Invalid mrp"}), 400
            if mrp_val <= 0:
                mrp_val = float(defaults.get("mrp") or 0.0)

            q = InventoryBatch.query.filter(
                InventoryBatch.company_id == g.current_company.id,
                InventoryBatch.product_id == product.id,
            )
            if product.lot_tracking:
                q = q.filter(func.coalesce(InventoryBatch.batch_number, "") == ((batch_number or "").strip()))
            else:
                q = q.filter(func.coalesce(InventoryBatch.batch_number, "") == "")
                if uom:
                    q = q.filter(func.coalesce(InventoryBatch.uom, "") == uom)
            q = q.order_by(InventoryBatch.qty_base.desc(), InventoryBatch.id.asc())
            batch = q.first()
            is_locked_now = bool(batch and int(batch.qty_base or 0) >= int(qty_base or 0))

            adj = StockAdjustment(
                company_id=g.current_company.id,
                product_id=product.id,
                inventory_batch_id=batch.id if is_locked_now else None,
                adjustment_date=adjustment_date,
                batch_number=batch_number if product.lot_tracking else None,
                qty=int(qty or 0),
                uom=uom,
                factor_to_base=float(factor_to_base or 1.0),
                qty_base=int(qty_base or 0),
                expiry_date=expiry_val,
                mrp=round(float(mrp_val or 0.0), 4),
                locked_by_purchase=is_locked_now,
                created_by_user_id=getattr(g.current_user, "id", None),
            )
            # Preserve lock timestamp precision (date + time) from input/current time.
            adj.created_at = dt_val
            db.session.add(adj)
            created.append(adj)

        if not created:
            return jsonify({"error": "At least one valid line is required"}), 400

        db.session.commit()
        for adj in created:
            log_action(
                "stock_adjustment_created",
                {"id": adj.id, "product_id": adj.product_id, "qty": int(adj.qty or 0), "uom": adj.uom},
                g.current_company.id,
            )
        socketio.emit(
            "inventory:update",
            {"type": "stock_adjustment_created", "company_id": g.current_company.id},
            namespace="/events",
        )
        if len(created) == 1:
            return jsonify(created[0].to_dict()), 201
        return jsonify({"created": [a.to_dict() for a in created]}), 201

    @app.route("/stock-adjustments/<int:adjustment_id>", methods=["PUT"])
    @require_auth
    @company_required(["admin", "manager"])
    def update_stock_adjustment(adjustment_id: int):
        adj = StockAdjustment.query.get_or_404(adjustment_id)
        if adj.company_id != g.current_company.id:
            return jsonify({"error": "Forbidden"}), 403

        data = request.get_json() or {}
        product_id = int(data.get("product_id") or adj.product_id or 0)
        qty = int(data.get("qty") or 0)
        uom = (data.get("uom") or "").strip() or None
        batch_number = (data.get("batch_number") or data.get("lot_number") or "").strip() or None
        date_raw = (data.get("adjustment_date") or data.get("date") or "").strip()
        if not product_id:
            return jsonify({"error": "product_id is required"}), 400
        if qty <= 0:
            return jsonify({"error": "qty must be > 0"}), 400
        try:
            dt_val = _parse_stock_adjustment_datetime(date_raw)
            adjustment_date = dt_val.date()
        except Exception:
            return jsonify({"error": "Invalid adjustment_date"}), 400

        product = Product.query.get_or_404(product_id)
        if product.company_id != g.current_company.id:
            return jsonify({"error": "Forbidden"}), 403
        if product.lot_tracking and not batch_number:
            return jsonify({"error": "Batch/Lot number is required for this product"}), 400

        factor_to_base = _uom_factor_to_base(g.current_company.id, product, uom)
        qty_base = _qty_to_base(g.current_company.id, product, qty, uom)
        if qty_base <= 0:
            return jsonify({"error": "Converted quantity must be > 0"}), 400

        defaults = _stock_adjustment_defaults(
            company_id=g.current_company.id,
            product=product,
            batch_number=batch_number if product.lot_tracking else None,
            uom=uom,
            as_of_date=adjustment_date,
        )
        if not uom and defaults.get("uom"):
            uom = str(defaults.get("uom"))
            factor_to_base = _uom_factor_to_base(g.current_company.id, product, uom)
            qty_base = _qty_to_base(g.current_company.id, product, qty, uom)
        expiry_raw = (data.get("expiry_date") or "").strip()
        expiry_val = None
        if expiry_raw:
            try:
                expiry_val = datetime.fromisoformat(expiry_raw).date()
            except Exception:
                return jsonify({"error": "Invalid expiry_date (expected YYYY-MM-DD)"}), 400
        if not expiry_val:
            expiry_val = defaults.get("expiry_date")
        try:
            mrp_val = float(data.get("mrp") or 0.0)
        except Exception:
            return jsonify({"error": "Invalid mrp"}), 400
        if mrp_val <= 0:
            mrp_val = float(defaults.get("mrp") or 0.0)

        q = InventoryBatch.query.filter(
            InventoryBatch.company_id == g.current_company.id,
            InventoryBatch.product_id == product.id,
        )
        if product.lot_tracking:
            q = q.filter(func.coalesce(InventoryBatch.batch_number, "") == ((batch_number or "").strip()))
        else:
            q = q.filter(func.coalesce(InventoryBatch.batch_number, "") == "")
            if uom:
                q = q.filter(func.coalesce(InventoryBatch.uom, "") == uom)
        q = q.order_by(InventoryBatch.qty_base.desc(), InventoryBatch.id.asc())
        batch = q.first()
        is_locked_now = bool(batch and int(batch.qty_base or 0) >= int(qty_base or 0))

        adj.product_id = product.id
        adj.inventory_batch_id = batch.id if is_locked_now else None
        adj.adjustment_date = adjustment_date
        adj.batch_number = batch_number if product.lot_tracking else None
        adj.qty = int(qty)
        adj.uom = uom
        adj.factor_to_base = float(factor_to_base or 1.0)
        adj.qty_base = int(qty_base or 0)
        adj.expiry_date = expiry_val
        adj.mrp = round(float(mrp_val or 0.0), 4)
        adj.locked_by_purchase = is_locked_now
        adj.created_at = dt_val

        db.session.commit()
        return jsonify(adj.to_dict())

    @app.route("/stock-adjustments/<int:adjustment_id>", methods=["DELETE"])
    @require_auth
    @company_required(["admin", "manager"])
    def delete_stock_adjustment(adjustment_id: int):
        adj = StockAdjustment.query.get_or_404(adjustment_id)
        if adj.company_id != g.current_company.id:
            return jsonify({"error": "Forbidden"}), 403
        db.session.delete(adj)
        db.session.commit()
        return jsonify({"deleted": True})

    # Inventory summary (batch-aware)
    @app.route("/inventory", methods=["GET"])
    @require_auth
    @company_required()
    def inventory_summary():
        inventory_only = request.args.get("inventory_only") == "1"
        strict_raw = (request.args.get("strict") or "0").strip().lower()
        strict_reconcile = strict_raw not in {"0", "false", "no"}
        search_term = (request.args.get("q") or "").strip()
        use_server_paging = request.args.get("page") is not None or request.args.get("per_page") is not None
        if strict_reconcile:
            _maybe_reconcile_inventory(g.current_company.id)
        query = Product.query.filter_by(company_id=g.current_company.id)
        if search_term:
            like = f"%{search_term}%"
            query = query.filter(
                or_(
                    Product.name.ilike(like),
                    Product.sku.ilike(like),
                    Product.composition.ilike(like),
                    Product.manufacturer.ilike(like),
                )
            )
        if inventory_only:
            batch_product_ids = (
                db.session.query(InventoryBatch.product_id)
                .filter(
                    InventoryBatch.company_id == g.current_company.id,
                    InventoryBatch.qty_base > 0,
                )
                .distinct()
            )
            query = query.filter(or_(Product.id.in_(batch_product_ids), Product.stock > 0))
        if use_server_paging:
            products, pagination = paginate_query(query.order_by(Product.name.asc()))
        else:
            products = query.order_by(Product.name.asc()).all()
            pagination = None
        product_ids = [int(p.id) for p in products]

        def format_qty(qty_base: int, purchase_uom: str | None, factor: float, base_uom: str) -> str:
            q = int(qty_base or 0)
            if not purchase_uom or not factor or factor <= 1:
                return f"{q} x {base_uom}".strip()
            f = int(round(factor))
            if f <= 1:
                return f"{q} x {base_uom}".strip()
            major = q // f
            rem = q % f
            if major > 0 and rem > 0:
                return f"{major} x {purchase_uom} and {rem} x {base_uom}".strip()
            if major > 0:
                return f"{major} x {purchase_uom}".strip()
            return f"{rem} x {base_uom}".strip()

        batches_by_product: dict[int, list[InventoryBatch]] = defaultdict(list)
        sold_by_product: dict[int, int] = {}
        posted_items_by_product: dict[int, list[tuple[PurchaseBillItem, PurchaseBill]]] = defaultdict(list)
        if product_ids:
            batch_rows = (
                InventoryBatch.query.filter(
                    InventoryBatch.company_id == g.current_company.id,
                    InventoryBatch.product_id.in_(product_ids),
                )
                .order_by(
                    InventoryBatch.product_id.asc(),
                    InventoryBatch.expiry_date.is_(None).asc(),
                    InventoryBatch.expiry_date.asc(),
                    InventoryBatch.created_at.asc(),
                )
                .all()
            )
            for batch in batch_rows:
                batches_by_product[int(batch.product_id)].append(batch)

            sold_rows = (
                db.session.query(
                    SaleItem.product_id,
                    db.func.coalesce(db.func.sum(SaleItem.quantity), 0),
                )
                .join(Sale, SaleItem.sale_id == Sale.id)
                .filter(
                    Sale.company_id == g.current_company.id,
                    SaleItem.product_id.in_(product_ids),
                )
                .group_by(SaleItem.product_id)
                .all()
            )
            sold_by_product = {int(product_id): int(qty or 0) for product_id, qty in sold_rows}

            posted_rows = (
                db.session.query(PurchaseBillItem, PurchaseBill)
                .join(PurchaseBill, PurchaseBillItem.purchase_bill_id == PurchaseBill.id)
                .filter(
                    PurchaseBill.company_id == g.current_company.id,
                    PurchaseBill.posted.is_(True),
                    PurchaseBillItem.product_id.in_(product_ids),
                )
                .order_by(
                    PurchaseBillItem.product_id.asc(),
                    PurchaseBill.posted_at.asc(),
                    PurchaseBill.id.asc(),
                    PurchaseBillItem.id.asc(),
                )
                .all()
            )
            for item, bill in posted_rows:
                posted_items_by_product[int(item.product_id)].append((item, bill))

        payload = []
        for p in products:
            base_uom = _base_uom_name(p) or p.uom_category or ""
            batches = batches_by_product.get(int(p.id), [])
            has_any_batches = len(batches) > 0
            batch_payload = []
            for b in batches:
                if int(b.qty_base or 0) <= 0:
                    continue
                bd = b.to_dict()
                uom_name = getattr(b, "uom", None) or None
                factor = float(getattr(b, "factor_to_base", 1.0) or 1.0)
                bd["qty_display"] = format_qty(int(b.qty_base or 0), uom_name, factor, base_uom)
                mrp_per_uom = float(getattr(b, "mrp_per_uom", 0.0) or 0.0) or float(b.mrp or 0.0)
                bd["mrp_display"] = f"{round(mrp_per_uom, 2)} ({uom_name or base_uom})".strip()
                bd["mrp_per_base"] = round(mrp_per_uom / max(1.0, factor), 4) if mrp_per_uom else 0.0
                batch_payload.append(bd)

            # If no batch rows were persisted (legacy postings), derive lines from posted purchase bills.
            # For lot-tracked products with existing batches, do not fall back to purchase lines.
            if not batch_payload and (not p.lot_tracking or not has_any_batches):
                posted_items = posted_items_by_product.get(int(p.id), [])
                for it, bill in posted_items:
                    qty_units = float((it.ordered_qty or 0) + (it.free_qty or 0))
                    if qty_units <= 0:
                        continue
                    factor = _uom_factor_to_base(g.current_company.id, p, it.uom)
                    qty_base = _qty_to_base(g.current_company.id, p, qty_units, it.uom)
                    mrp_per_uom = float(it.mrp or 0.0) or float(getattr(it, "price", 0.0) or 0.0)
                    uom_name = it.uom or base_uom
                    batch_payload.append(
                        {
                            "id": it.id,
                            "company_id": g.current_company.id,
                            "product_id": p.id,
                            "batch_number": it.batch_number,
                            "expiry_date": it.expiry_date.isoformat() if it.expiry_date else None,
                            "uom": it.uom,
                            "factor_to_base": float(factor or 1.0),
                            "mrp_per_uom": round(float(mrp_per_uom or 0.0), 4),
                            "mrp": round(float(mrp_per_uom or 0.0), 4),
                            "qty_base": int(qty_base or 0),
                            "qty_display": format_qty(int(qty_base or 0), uom_name, factor, base_uom),
                            "mrp_display": f"{round(float(mrp_per_uom or 0.0), 2)} ({uom_name or base_uom})".strip(),
                            "mrp_per_base": round(float(mrp_per_uom or 0.0) / max(1.0, float(factor or 1.0)), 4)
                            if mrp_per_uom
                            else 0.0,
                            "purchase_bill_id": bill.id,
                            "purchase_bill_item_id": it.id,
                            "purchase_date": bill.purchase_date.isoformat() if bill.purchase_date else None,
                            "posted_at": bill.posted_at.isoformat() if bill.posted_at else None,
                        }
                    )

                # Deplete by recorded sales (best-effort FEFO) to make derived lines reflect current state.
                sold = sold_by_product.get(int(p.id), 0)
                remaining = int(sold or 0)
                if remaining > 0 and batch_payload:
                    # order by expiry (None last), then posted_at
                    def _sort_key(row: dict):
                        exp = row.get("expiry_date")
                        exp_key = "9999-12-31" if not exp else exp
                        posted_at = row.get("posted_at") or "9999-12-31"
                        return (exp_key, posted_at)

                    batch_payload.sort(key=_sort_key)
                    for row in batch_payload:
                        if remaining <= 0:
                            break
                        avail = int(row.get("qty_base") or 0)
                        if avail <= 0:
                            continue
                        take = min(avail, remaining)
                        row["qty_base"] = avail - take
                        remaining -= take
                        factor = float(row.get("factor_to_base") or 1.0)
                        uom_name = row.get("uom") or base_uom
                        row["qty_display"] = format_qty(int(row["qty_base"]), uom_name, factor, base_uom)
                    batch_payload = [r for r in batch_payload if int(r.get("qty_base") or 0) > 0]

            if not batch_payload and int(p.stock or 0) > 0:
                # Backward compatible display for legacy stock that predates batch tracking.
                batch_payload = [
                    {
                        "id": 0,
                        "company_id": g.current_company.id,
                        "product_id": p.id,
                        "batch_number": p.lot_number,
                        "expiry_date": p.expiry_date.isoformat() if p.expiry_date else None,
                        "mrp": round(float(p.price or 0.0), 4),
                        "mrp_per_uom": round(float(p.price or 0.0), 4),
                        "uom": base_uom,
                        "factor_to_base": 1.0,
                        "qty_base": int(p.stock or 0),
                        "qty_display": f"{int(p.stock or 0)} {base_uom}".strip(),
                        "mrp_display": f"{round(float(p.price or 0.0), 2)} ({base_uom})".strip(),
                        "mrp_per_base": round(float(p.price or 0.0), 4),
                        "created_at": p.created_at.isoformat() if p.created_at else None,
                    }
                ]

            computed_stock = (
                sum(int(bp.get("qty_base") or 0) for bp in batch_payload)
                if batch_payload
                else int(p.stock or 0)
            )

            # Smart aggregate: convert total stock to the largest available UoM, remainder in base UoM.
            qty_display = ""
            if batch_payload:
                grouped: dict[tuple[str, float], int] = {}
                for bp in batch_payload:
                    uom = (bp.get("uom") or base_uom or "").strip() or (base_uom or "")
                    try:
                        factor = float(bp.get("factor_to_base") or 1.0)
                    except Exception:
                        factor = 1.0
                    key = (uom, factor)
                    grouped[key] = grouped.get(key, 0) + int(bp.get("qty_base") or 0)

                total_base_qty = int(computed_stock or 0)
                best_uom = None
                best_factor = 1
                for uom, factor in grouped.keys():
                    try:
                        f_int = int(round(float(factor or 1.0)))
                    except Exception:
                        f_int = 1
                    if f_int > best_factor and f_int > 1:
                        best_factor = f_int
                        best_uom = uom
                if best_uom and best_factor > 1:
                    major = total_base_qty // best_factor
                    rem = total_base_qty % best_factor
                    smart_parts = []
                    if major > 0:
                        smart_parts.append(f"{major} x {best_uom}")
                    if rem > 0 or not smart_parts:
                        smart_parts.append(f"{rem} x {base_uom}")
                    qty_display = " and ".join(smart_parts)
                else:
                    qty_display = format_qty(int(computed_stock or 0), base_uom, 1.0, base_uom)
            else:
                qty_display = format_qty(int(computed_stock or 0), base_uom, 1.0, base_uom)

            mrp_labels = list({bp.get("mrp_display") for bp in batch_payload if bp.get("mrp_display")})
            mrp_display = mrp_labels[0] if len(mrp_labels) == 1 else ("Multiple" if mrp_labels else "-")
            if inventory_only and int(computed_stock or 0) <= 0:
                continue

            payload.append(
                {
                    "id": p.id,
                    "name": p.name,
                    "sku": p.sku,
                    "stock_base": int(p.stock or 0),
                    "stock_computed": int(computed_stock or 0),
                    "base_uom": base_uom,
                    "reorder_level": int(p.reorder_level or 0),
                    "mrp_default": round(float(p.price or 0.0), 4),
                    "qty_display": qty_display,
                    "mrp_display": mrp_display,
                    "lot_tracking": bool(p.lot_tracking),
                    "expiry_tracking": bool(p.expiry_tracking),
                    "shelf_removal": bool(p.shelf_removal),
                    "batches": batch_payload,
                }
            )
        if use_server_paging:
            return jsonify({"data": payload, "pagination": pagination})
        return jsonify(payload)

    @app.route("/dashboard/<path:_path>", methods=["OPTIONS"])
    def dashboard_options(_path: str):
        return ("", 204)

    def _format_qty_display(qty_base: int, purchase_uom: str | None, factor: float, base_uom: str) -> str:
        q = int(qty_base or 0)
        if not purchase_uom or not factor or factor <= 1:
            return f"{q} x {base_uom}".strip()
        f = int(round(float(factor)))
        if f <= 1:
            return f"{q} x {base_uom}".strip()
        major = q // f
        rem = q % f
        if major > 0 and rem > 0:
            return f"{major} x {purchase_uom} and {rem} x {base_uom}".strip()
        if major > 0:
            return f"{major} x {purchase_uom}".strip()
        return f"{rem} x {base_uom}".strip()


    DEFAULT_LEDGER_SPECS = [
        ("Cash", "asset"),
        ("Bank", "asset"),
        ("Inventory", "asset"),
        ("Accounts Receivable", "asset"),
        ("Accounts Payable", "liability"),
        ("Sales Revenue", "income"),
        ("Sales Returns", "expense"),
        ("Expiry Returns Expense", "expense"),
    ]

    def _get_or_create_account(
        company_id: int,
        name: str,
        acc_type: str,
        *,
        parent_id: int | None = None,
        description: str = "",
    ) -> Account:
        account = Account.query.filter_by(company_id=company_id, name=name).first()
        if account:
            if parent_id is not None and account.parent_id != parent_id:
                account.parent_id = parent_id
            if description and not (account.description or "").strip():
                account.description = description
            return account
        account = Account(
            company_id=company_id,
            name=name,
            type=acc_type,
            balance=0.0,
            parent_id=parent_id,
            description=description,
        )
        db.session.add(account)
        db.session.flush()
        return account

    def _ensure_default_company_ledgers(company_id: int) -> dict[str, Account]:
        accounts: dict[str, Account] = {}
        for name, acc_type in DEFAULT_LEDGER_SPECS:
            accounts[name] = _get_or_create_account(company_id, name, acc_type)
        return accounts

    def _customer_ledger_name(customer: Customer | None) -> str:
        if not customer:
            return "Accounts Receivable"
        number = getattr(customer, "local_number", None) or getattr(customer, "id", None) or "?"
        name = (getattr(customer, "name", None) or f"Customer {number}").strip()
        return f"Customer #{number}: {name}"

    def _supplier_ledger_name(supplier: Supplier | None) -> str:
        if not supplier:
            return "Accounts Payable"
        number = getattr(supplier, "id", None) or "?"
        name = (getattr(supplier, "name", None) or f"Supplier {number}").strip()
        return f"Supplier #{number}: {name}"

    def _get_or_create_customer_account(company_id: int, customer: Customer | None) -> Account:
        controls = _ensure_default_company_ledgers(company_id)
        if not customer:
            return controls["Accounts Receivable"]
        return _get_or_create_account(
            company_id,
            _customer_ledger_name(customer),
            "asset",
            parent_id=controls["Accounts Receivable"].id,
        )

    def _get_or_create_supplier_account(company_id: int, supplier: Supplier | None) -> Account:
        controls = _ensure_default_company_ledgers(company_id)
        if not supplier:
            return controls["Accounts Payable"]
        return _get_or_create_account(
            company_id,
            _supplier_ledger_name(supplier),
            "liability",
            parent_id=controls["Accounts Payable"].id,
        )

    def _rename_party_ledger(
        company_id: int,
        old_name: str | None,
        new_name: str | None,
        acc_type: str,
        parent_control_name: str,
    ) -> None:
        old_name = (old_name or "").strip()
        new_name = (new_name or "").strip()
        if not old_name or not new_name or old_name == new_name:
            return
        account = Account.query.filter_by(company_id=company_id, name=old_name).first()
        if not account:
            parent = _ensure_default_company_ledgers(company_id).get(parent_control_name)
            _get_or_create_account(
                company_id,
                new_name,
                acc_type,
                parent_id=parent.id if parent else None,
            )
            return
        existing = Account.query.filter_by(company_id=company_id, name=new_name).first()
        if existing and existing.id != account.id:
            return
        parent = _ensure_default_company_ledgers(company_id).get(parent_control_name)
        account.name = new_name
        account.type = acc_type
        if parent:
            account.parent_id = parent.id
    
    
    def _apply_account_balance(account: Account, entry_type: str, amount: float) -> None:
        amount = float(amount or 0.0)
        if amount <= 0:
            return
        entry_type = (entry_type or "").lower()
        if entry_type not in {"debit", "credit"}:
            raise ValueError("Invalid entry type")
        natural_credit = account.type in {"liability", "equity", "income"}
        delta = amount if entry_type == "debit" else -amount
        if natural_credit:
            delta = -delta
        account.balance = float(account.balance or 0.0) + delta
    
    
    def _post_account_entry(
        company_id: int,
        account_name: str,
        account_type: str,
        entry_type: str,
        amount: float,
        reference_type: str | None,
        reference_id: int | None,
        description: str,
    ) -> None:
        amount = float(amount or 0.0)
        if amount <= 0:
            return
        account = _get_or_create_account(company_id, account_name, account_type)
        _apply_account_balance(account, entry_type, amount)
        entry = AccountEntry(
            company_id=company_id,
            account_id=account.id,
            entry_type=entry_type,
            amount=amount,
            reference_type=reference_type,
            reference_id=reference_id,
            description=description,
            created_by_user_id=int(getattr(g.current_user, "id", 0) or 0) or None,
        )
        db.session.add(entry)
    
    
    def _post_double_entry(
        company_id: int,
        debit_name: str,
        debit_type: str,
        credit_name: str,
        credit_type: str,
        amount: float,
        reference_type: str | None,
        reference_id: int | None,
        description: str,
    ) -> None:
        amount = float(amount or 0.0)
        if amount <= 0:
            return
        _post_account_entry(company_id, debit_name, debit_type, "debit", amount, reference_type, reference_id, description)
        _post_account_entry(company_id, credit_name, credit_type, "credit", amount, reference_type, reference_id, description)

    def _resolve_payment_account_name(company_id: int, payment_method: str | None, fallback: str = "Cash") -> str:
        _ensure_default_company_ledgers(company_id)
        label = (payment_method or "").strip()
        if not label:
            return fallback
        mode = PaymentMode.query.filter(
            PaymentMode.company_id == company_id,
            func.lower(PaymentMode.name) == func.lower(label),
        ).first()
        if mode and mode.account_id:
            account = Account.query.get(mode.account_id)
            if account and account.company_id == company_id:
                return account.name
        account = Account.query.filter(
            Account.company_id == company_id,
            func.lower(Account.name) == func.lower(label),
        ).first()
        if account:
            return account.name
        return label or fallback

    def _sale_receivable_account_name(company_id: int, customer: Customer | None) -> str:
        return _get_or_create_customer_account(company_id, customer).name

    def _purchase_payable_account_name(company_id: int, supplier: Supplier | None) -> str:
        return _get_or_create_supplier_account(company_id, supplier).name

    def _sale_origin_source(sale: Sale | None) -> str:
        if not sale:
            return "sales"
        return (
            str(getattr(sale, "origin_source", None) or getattr(sale, "source", None) or "sales")
            .strip()
            .lower()
            or "sales"
        )

    def _sale_accounting_reference_type(sale: Sale | None) -> str:
        return "backdated_sale" if _sale_origin_source(sale) == "backdated_daily_sales" else "sale"

    def _sale_accounting_reference_types(sale: Sale | None) -> list[str]:
        ref_type = _sale_accounting_reference_type(sale)
        if ref_type == "backdated_sale":
            return ["backdated_sale", "sale"]
        return ["sale"]

    def _sale_inventory_reference_type(sale: Sale | None) -> str:
        return "backdated_sale" if _sale_origin_source(sale) == "backdated_daily_sales" else "sale"

    def _sale_inventory_reason(sale: Sale | None) -> str:
        if not sale:
            return "sale"
        src = (sale.source or "").strip().lower()
        origin_src = _sale_origin_source(sale)
        approval_status = (sale.approval_status or "").strip().lower()
        if origin_src == "backdated_daily_sales" and approval_status == "approved":
            return "backdated_sale_approved"
        if src == "daily_sales" and approval_status == "approved":
            return "daily_sale_approved"
        if src == "sales_order_delivered":
            return "sales_order_delivered"
        return "sale"

    def _sale_inventory_outbound_reasons() -> list[str]:
        return [
            "sale",
            "daily_sale_approved",
            "backdated_sale_approved",
            "sales_order_delivered",
            "expiry_return",
        ]

    def _sale_accounting_has_ref(sale: Sale | None) -> bool:
        if not sale or not sale.id or not sale.company_id:
            return False
        for ref_type in _sale_accounting_reference_types(sale):
            if _accounting_has_ref(int(sale.company_id), ref_type, int(sale.id)):
                return True
        return False

    def _sale_inventory_should_be_posted(sale: Sale | None) -> bool:
        if not sale:
            return False
        src = (sale.source or "").strip().lower()
        approval_status = (sale.approval_status or "").strip().lower()
        if src in {"sales_order", ""}:
            return False
        if src in {"daily_sales", "backdated_daily_sales"} and approval_status != "approved":
            return False
        return src in {"sales", "daily_sales", "backdated_daily_sales", "sales_order_delivered"}

    def _sale_inventory_is_posted(sale: Sale | None) -> bool:
        return bool(getattr(sale, "inventory_posted", False)) if sale else False

    def _post_sale_inventory_if_ready(sale: Sale | None) -> bool:
        if not sale or not sale.id or not sale.company_id:
            return False
        if not _sale_inventory_should_be_posted(sale):
            return False
        if _sale_inventory_is_posted(sale):
            return False

        allow_negative = _sale_origin_source(sale) == "backdated_daily_sales"
        company_id = int(sale.company_id or 0)
        if company_id <= 0:
            return False

        for idx, item in enumerate(sale.items or []):
            product = item.product or (db.session.get(Product, item.product_id) if getattr(item, "product_id", None) else None)
            if not product or product.company_id != company_id:
                raise ValueError(f"Line {idx + 1}: Product missing")
            try:
                qty_base = int(item.quantity or 0)
            except (TypeError, ValueError):
                qty_base = 0
            if qty_base <= 0:
                continue

            batch_id = int(item.inventory_batch_id or 0) if getattr(item, "inventory_batch_id", None) else None
            if not batch_id:
                raise ValueError(f"Line {idx + 1}: Inventory batch missing")

            batch = db.session.get(InventoryBatch, batch_id)
            if not batch or batch.company_id != company_id or batch.product_id != product.id:
                raise ValueError(f"Line {idx + 1}: Invalid inventory batch")

            raw_uom = (item.uom or "").strip() or None
            uom = _validate_uom_for_product(product, raw_uom)
            if product.uom_category and not uom:
                uom = _base_uom_name(product) or product.uom_category

            batch_number = (item.batch_number or "").strip() or None
            if not batch_number and product.lot_tracking:
                batch_number = (batch.batch_number or "").strip() or None

            _allocate_sale_from_batches(
                company_id,
                product,
                qty_base,
                inventory_batch_id=batch_id,
                batch_number=batch_number if product.lot_tracking else None,
                preferred_uom=uom,
                allow_negative=allow_negative,
            )
            _recompute_product_stock_from_batches(company_id, product)
            db.session.add(
                InventoryLog(
                    company_id=company_id,
                    product=product,
                    change=-qty_base,
                    reason=_sale_inventory_reason(sale),
                    reference_type=_sale_inventory_reference_type(sale),
                    reference_id=int(sale.id),
                )
            )

        sale.inventory_posted = True
        return True

    def _ensure_sale_accounting_posted(sale: Sale | None) -> bool:
        if not sale or not sale.id or float(sale.total_amount or 0.0) <= 0:
            return False
        company_id = int(sale.company_id or 0)
        if company_id <= 0:
            return False
        src = (sale.source or "").strip().lower()
        approval_status = (sale.approval_status or "").strip().lower()
        if src in {"daily_sales", "backdated_daily_sales"} and approval_status != "approved":
            return False
        if _sale_accounting_has_ref(sale):
            return False
        ref_type = _sale_accounting_reference_type(sale)
        description_prefix = "Backdated Sale" if ref_type == "backdated_sale" else "Sale"
        label = sale.sale_number or f"#{sale.id}"
        if (sale.payment_status or "").strip().lower() == "due":
            receivable_name = _sale_receivable_account_name(company_id, sale.customer)
            _post_double_entry(
                company_id,
                receivable_name,
                "asset",
                "Sales Revenue",
                "income",
                float(sale.total_amount or 0.0),
                ref_type,
                sale.id,
                f"{description_prefix} {label} (due)",
            )
        else:
            payment_account = _resolve_payment_account_name(company_id, sale.payment_method, "Cash")
            _post_double_entry(
                company_id,
                payment_account,
                "asset",
                "Sales Revenue",
                "income",
                float(sale.total_amount or 0.0),
                ref_type,
                sale.id,
                f"{description_prefix} {label} (paid)",
            )
        return True

    def _post_purchase_bill_if_ready(bill: PurchaseBill | None) -> bool:
        if not bill:
            return False
        if not bill.id:
            db.session.flush()
        if not bill.id:
            return False
        if (bill.approval_status or "approved").lower() != "approved":
            return False
        if bill.posted:
            return False
        company = db.session.get(Company, bill.company_id)
        if not company:
            return False
        post_ts = datetime.now(timezone.utc)
        try:
            _sync_purchase_bill_totals(company, bill)
        except Exception:
            pass
        bill.posted_at = post_ts
        _get_or_create_supplier_account(bill.company_id, bill.supplier)
        _apply_purchase_bill_to_inventory(bill.company_id, bill, reason="purchase_bill")
        bill.posted = True
        bill.posted_at = bill.posted_at or post_ts
        product_ids = {int(it.product_id) for it in (bill.items or []) if getattr(it, "product_id", None)}
        if product_ids:
            _relink_pending_backdated_sales(company_id=bill.company_id, product_ids=product_ids)
        total_cost = float(bill.gross_total or 0.0)
        if total_cost > 0 and not _accounting_has_ref(bill.company_id, "purchase_bill", bill.id):
            payable_name = _purchase_payable_account_name(bill.company_id, bill.supplier)
            _post_double_entry(
                bill.company_id,
                "Inventory",
                "asset",
                payable_name,
                "liability",
                total_cost,
                "purchase_bill",
                bill.id,
                f"Purchase bill #{bill.id}",
            )
        return True

    def _infer_payment_status(payment_method: str | None, fallback: str = "paid") -> str:
        """
        Infer payment status from payment method label when client does not send explicit status.
        Only a small set of keywords imply credit/due; otherwise default to the provided fallback.
        """
        label = (payment_method or "").strip().lower()
        due_labels = {
            "due",
            "credit",
            "credit sale",
            "on account",
            "receivable",
            "accounts receivable",
        }
        if label in due_labels or " due" in f" {label}" or label.startswith("due "):
            return "due"
        return fallback

    def _inventory_sale_filters(company_id: int) -> list:
        """
        Inventory-affecting sales filters:
        - Include legacy sales with no source.
        - Include sales, daily sales (except denied), and delivered sales orders.
        - Exclude sales orders that are not delivered and denied daily sales.
        """
        allowed_sources = {"sales", "daily_sales", "backdated_daily_sales", "sales_order_delivered"}
        return [
            Sale.company_id == company_id,
            or_(Sale.source.is_(None), Sale.source.in_(allowed_sources)),
            or_(Sale.approval_status.is_(None), Sale.approval_status != "denied"),
        ]

    def _maybe_reconcile_inventory(
        company_id: int,
        *,
        product_id: int | None = None,
        min_interval_seconds: int = 300,
    ) -> None:
        """
        Periodic guard to rebuild inventory batches from purchases/sales.
        This prevents drift when legacy data or partial postings skipped batch updates.
        """
        now_ts = time()
        last_ts = INVENTORY_RECONCILE_AT.get(company_id, 0)
        if now_ts - last_ts < min_interval_seconds:
            return
        _reconcile_inventory_from_purchase_bills_and_sales(company_id, product_id=product_id)
        db.session.commit()
        INVENTORY_RECONCILE_AT[company_id] = now_ts
    
    def _eligible_available_base_for_batch(
        *,
        company_id: int,
        product: Product,
        batch: InventoryBatch,
        sale_date: date,
        sale_at: datetime | None = None,
        cap_with_current: bool = True,
    ) -> int:
        """
        Strict eligibility:
        - Only purchases (posted purchase bills) with purchase_date <= sale_date are eligible.
        - Subtract all sales already recorded against this inventory batch.
        - Eligible availability is capped by current batch.qty_base.
        """
        if not sale_date:
            return int(batch.qty_base or 0)
        # Do not link a backdated sale to stock that had already expired on the sale date.
        # Example: expiry 2026-03-01 cannot satisfy a backdated sale dated 2026-03-02.
        if batch.expiry_date and sale_date > batch.expiry_date:
            return 0
        # Hard arrival rule: a batch cannot be sold (or used for delivery) before it arrives into inventory.
        # IMPORTANT: Do NOT rely on created_at alone because inventory repair/rebuild can recreate rows.
        # Prefer InventoryBatch.arrival_at, fall back to created_at for legacy rows.
        batch_arrival_at = getattr(batch, "arrival_at", None) or getattr(batch, "created_at", None)
        if batch_arrival_at:
            batch_created_cmp = batch_arrival_at
            if getattr(batch_created_cmp, "tzinfo", None) is not None:
                batch_created_cmp = batch_created_cmp.astimezone(timezone.utc).replace(tzinfo=None)
            sale_at_cmp = sale_at
            if sale_at_cmp is not None and getattr(sale_at_cmp, "tzinfo", None) is not None:
                sale_at_cmp = sale_at_cmp.astimezone(timezone.utc).replace(tzinfo=None)
            # If a specific sale timestamp is provided, enforce it; otherwise enforce by date.
            if sale_at_cmp is not None:
                if sale_at_cmp < batch_created_cmp:
                    return 0
            else:
                if batch_created_cmp.date() > sale_date:
                    return 0

        # Backdated-sale lock: reserve stock-count quantity until its creation timestamp.
        lock_q = StockAdjustment.query.filter(
            StockAdjustment.company_id == company_id,
            StockAdjustment.product_id == product.id,
            StockAdjustment.locked_by_purchase.is_(True),
        )
        if product.lot_tracking:
            lock_q = lock_q.filter(func.coalesce(StockAdjustment.batch_number, "") == (batch.batch_number or ""))
        else:
            lock_q = lock_q.filter(
                func.coalesce(StockAdjustment.batch_number, "") == "",
                func.coalesce(StockAdjustment.uom, "") == (batch.uom or ""),
            )
        reserved_before_unlock = 0
        for lock_row in lock_q.all():
            lock_dt = lock_row.created_at or (
                datetime.combine(lock_row.adjustment_date, datetime.min.time()) if lock_row.adjustment_date else None
            )
            if not lock_dt:
                continue
            lock_dt_cmp = lock_dt
            if getattr(lock_dt_cmp, "tzinfo", None) is not None:
                lock_dt_cmp = lock_dt_cmp.astimezone(timezone.utc).replace(tzinfo=None)
            sale_at_cmp = sale_at
            if sale_at_cmp is not None and getattr(sale_at_cmp, "tzinfo", None) is not None:
                sale_at_cmp = sale_at_cmp.astimezone(timezone.utc).replace(tzinfo=None)
            locked_for_this_sale = (sale_at_cmp < lock_dt_cmp) if sale_at_cmp is not None else (sale_date < lock_dt_cmp.date())
            if locked_for_this_sale:
                reserved_before_unlock += int(lock_row.qty_base or 0)

        pq = (
            db.session.query(PurchaseBillItem, PurchaseBill)
            .join(PurchaseBill, PurchaseBillItem.purchase_bill_id == PurchaseBill.id)
            .filter(
                PurchaseBill.company_id == company_id,
                PurchaseBill.posted.is_(True),
                PurchaseBill.purchase_date <= sale_date,
                PurchaseBillItem.product_id == product.id,
            )
        )
        if product.lot_tracking:
            if not batch.batch_number:
                return 0
            pq = pq.filter(PurchaseBillItem.batch_number == batch.batch_number)
        else:
            pq = pq.filter(PurchaseBillItem.batch_number.is_(None))
            if batch.expiry_date:
                pq = pq.filter(PurchaseBillItem.expiry_date == batch.expiry_date)
            if batch.uom:
                pq = pq.filter(PurchaseBillItem.uom == batch.uom)
            mrp_match = float(getattr(batch, "mrp_per_uom", 0.0) or 0.0) or float(batch.mrp or 0.0)
            if mrp_match:
                pq = pq.filter((PurchaseBillItem.mrp == mrp_match) | (PurchaseBillItem.price == mrp_match))

        purchased_base = 0
        for it, _bill in pq.all():
            qty_units = float((it.ordered_qty or 0) + (it.free_qty or 0))
            if qty_units <= 0:
                continue
            purchased_base += int(_qty_to_base(company_id, product, qty_units, it.uom) or 0)

        sold_until_sale_date_filter = or_(
            and_(Sale.sale_date.isnot(None), Sale.sale_date <= sale_date),
            and_(Sale.sale_date.is_(None), func.date(Sale.created_at) <= sale_date),
        )

        sold_base = (
            db.session.query(db.func.coalesce(db.func.sum(SaleItem.quantity), 0))
            .join(Sale, SaleItem.sale_id == Sale.id)
            .filter(
                SaleItem.product_id == product.id,
                SaleItem.inventory_batch_id == batch.id,
                *_inventory_sale_filters(company_id),
                sold_until_sale_date_filter,
            )
            .scalar()
            or 0
        )

        if product.lot_tracking and batch.batch_number:
            sold_fallback = (
                db.session.query(db.func.coalesce(db.func.sum(SaleItem.quantity), 0))
                .join(Sale, SaleItem.sale_id == Sale.id)
                .filter(
                    SaleItem.product_id == product.id,
                    SaleItem.inventory_batch_id.is_(None),
                    SaleItem.batch_number == batch.batch_number,
                    *_inventory_sale_filters(company_id),
                    sold_until_sale_date_filter,
                )
                .scalar()
                or 0
            )
            sold_base = int(sold_base or 0) + int(sold_fallback or 0)

        if purchased_base <= 0:
            # No purchase existed on/before sale_date for this batch.
            # Allow fallback only when the batch itself already existed by that date
            # (e.g. opening/manual stock), otherwise treat as unavailable for backdated sale.
            batch_created_date = None
            if getattr(batch, "created_at", None):
                batch_created_date = batch.created_at.date()
            if batch_created_date and batch_created_date > sale_date:
                return 0
            fallback_available = int(batch.qty_base or 0) - int(sold_base or 0) - int(reserved_before_unlock or 0)
            return max(0, fallback_available)

        eligible = int(purchased_base) - int(sold_base or 0)
        if eligible <= 0:
            return 0
        allowed = int(eligible) - int(reserved_before_unlock or 0)
        if cap_with_current:
            allowed = min(int(batch.qty_base or 0), int(eligible)) - int(reserved_before_unlock or 0)
        return max(0, int(allowed))

    def _backdated_batch_block_reason(
        *,
        batch: InventoryBatch | None,
        sale_date: date | None,
    ) -> str | None:
        if not batch or not sale_date:
            return None
        if batch.expiry_date and sale_date > batch.expiry_date:
            return (
                f"inventory batch expired on {batch.expiry_date.isoformat()} "
                f"before sale date {sale_date.isoformat()}"
            )
        return None

    def _batch_supplier_lookup(company_id: int, product: Product, batch: InventoryBatch) -> tuple[int | None, str | None]:
        """
        Best-effort mapping of an inventory batch to the supplier who last posted it,
        based on posted purchase bills.
        """
        q = (
            db.session.query(PurchaseBillItem, PurchaseBill)
            .join(PurchaseBill, PurchaseBillItem.purchase_bill_id == PurchaseBill.id)
            .filter(
                PurchaseBill.company_id == company_id,
                PurchaseBill.posted.is_(True),
                PurchaseBillItem.product_id == product.id,
            )
        )
        if product.lot_tracking and batch.batch_number:
            q = q.filter(PurchaseBillItem.batch_number == batch.batch_number)
        if batch.expiry_date:
            q = q.filter(PurchaseBillItem.expiry_date == batch.expiry_date)
        q = q.order_by(PurchaseBill.posted_at.desc().nullslast(), PurchaseBill.id.desc(), PurchaseBillItem.id.desc())
        row = q.first()
        if not row:
            return None, None
        _, bill = row
        if not bill or not bill.supplier:
            return None, None
        return int(bill.supplier.id), str(bill.supplier.name)

    def _relink_pending_backdated_sales(
        *,
        company_id: int,
        pending_sales: list[Sale] | None = None,
        product_ids: set[int] | None = None,
    ) -> bool:
        """
        Bind pending backdated sale items to inventory batches strictly by inventory arrival order.
        Purchase bills establish inventory chronology; already-recorded sales are subtracted through
        _eligible_available_base_for_batch, and unassigned backdated rows inherit batch/MRP once stock arrives.
        """
        sales_to_process = list(pending_sales or [])
        if not sales_to_process:
            sales_to_process = (
                Sale.query.filter(
                    Sale.company_id == company_id,
                    Sale.source == "backdated_daily_sales",
                    or_(Sale.approval_status.is_(None), Sale.approval_status == "pending"),
                )
                .order_by(Sale.sale_date.asc().nullslast(), Sale.created_at.asc().nullslast(), Sale.id.asc())
                .all()
            )
        if not sales_to_process:
            return False

        filter_product_ids = {int(pid) for pid in (product_ids or set()) if int(pid or 0) > 0}
        sales_to_process.sort(
            key=lambda s: (
                s.sale_date or (s.created_at.date() if s.created_at else _today_ad()),
                s.created_at or datetime.min,
                s.id,
            )
        )

        relevant_product_ids: set[int] = set()
        for sale in sales_to_process:
            for item in sale.items or []:
                try:
                    pid = int(item.product_id or 0)
                except (TypeError, ValueError):
                    pid = 0
                if pid <= 0:
                    continue
                if filter_product_ids and pid not in filter_product_ids:
                    continue
                relevant_product_ids.add(pid)
        if not relevant_product_ids:
            return False

        products = {
            p.id: p
            for p in Product.query.filter(
                Product.company_id == company_id,
                Product.id.in_(list(relevant_product_ids)),
            ).all()
        }
        batches_by_product: dict[int, list[InventoryBatch]] = {}
        for pid in relevant_product_ids:
            batches_by_product[pid] = (
                InventoryBatch.query.filter(
                    InventoryBatch.company_id == company_id,
                    InventoryBatch.product_id == pid,
                )
                .order_by(
                    InventoryBatch.arrival_at.asc().nullslast(),
                    InventoryBatch.created_at.asc().nullslast(),
                    InventoryBatch.expiry_date.asc().nullslast(),
                    InventoryBatch.id.asc(),
                )
                .all()
            )

        allocated_by_batch: dict[int, int] = {}
        touched = False

        def _set_backdated_totals(sale_obj: Sale) -> None:
            total_mrp = 0.0
            for it in sale_obj.items or []:
                try:
                    qty_uom = float(it.quantity_uom or it.quantity or 0.0)
                except (TypeError, ValueError):
                    qty_uom = 0.0
                try:
                    unit_mrp = float(it.unit_price_uom or 0.0)
                except (TypeError, ValueError):
                    unit_mrp = 0.0
                if qty_uom > 0 and unit_mrp > 0:
                    total_mrp += qty_uom * unit_mrp
            if total_mrp <= 0:
                return
            try:
                target_total = float(sale_obj.total_amount or 0.0)
            except (TypeError, ValueError):
                target_total = 0.0
            discount_pct = 0.0
            if target_total > 0 and target_total < total_mrp:
                discount_pct = max(0.0, min(100.0, ((total_mrp - target_total) / total_mrp) * 100.0))
            for it in sale_obj.items or []:
                if abs(float(it.line_discount_percent or 0.0) - float(discount_pct)) > 1e-6:
                    it.line_discount_percent = discount_pct
            sale_obj.subtotal_amount = round(total_mrp, 2)
            sale_obj.discount_amount = round(max(0.0, total_mrp - target_total), 2) if target_total > 0 else 0.0

        for sale in sales_to_process:
            sale_changed = False
            sale_date = sale.sale_date or (sale.created_at.date() if sale.created_at else _today_ad())
            for item in sale.items or []:
                try:
                    pid = int(item.product_id or 0)
                except (TypeError, ValueError):
                    pid = 0
                if pid <= 0 or (filter_product_ids and pid not in filter_product_ids):
                    continue
                product = products.get(pid)
                if not product:
                    continue

                raw_uom = (item.uom or "").strip() or None
                uom = _validate_uom_for_product(product, raw_uom)
                if product.uom_category and not uom:
                    uom = _base_uom_name(product) or product.uom_category
                if not item.uom and uom:
                    item.uom = uom
                    sale_changed = True

                try:
                    qty_uom = float(item.quantity_uom or item.quantity or 0.0)
                except (TypeError, ValueError):
                    qty_uom = 0.0
                if qty_uom <= 0:
                    continue
                qty_base = int(_qty_to_base(company_id, product, qty_uom, uom) or 0)
                if qty_base <= 0:
                    continue

                def _assign_batch(batch: InventoryBatch) -> None:
                    nonlocal sale_changed
                    next_batch_number = batch.batch_number or None if product.lot_tracking else None
                    mrp_per_uom = float(getattr(batch, "mrp_per_uom", 0.0) or 0.0) or float(getattr(batch, "mrp", 0.0) or 0.0)
                    next_unit_price_uom = float(item.unit_price_uom or 0.0)
                    next_unit_price = float(item.unit_price or 0.0)
                    if mrp_per_uom > 0:
                        batch_factor = float(getattr(batch, "factor_to_base", 1.0) or 1.0) or 1.0
                        mrp_per_base = mrp_per_uom / max(1.0, batch_factor)
                        item_factor = float(_uom_factor_to_base(company_id, product, uom) or 1.0)
                        next_unit_price_uom = mrp_per_base * max(1.0, item_factor)
                        next_unit_price = mrp_per_base
                    if int(item.inventory_batch_id or 0) != int(batch.id):
                        item.inventory_batch_id = batch.id
                        sale_changed = True
                    if product.lot_tracking and (item.batch_number or None) != next_batch_number:
                        item.batch_number = next_batch_number
                        sale_changed = True
                    if abs(float(item.unit_price_uom or 0.0) - float(next_unit_price_uom or 0.0)) > 1e-6:
                        item.unit_price_uom = next_unit_price_uom
                        sale_changed = True
                    if abs(float(item.unit_price or 0.0) - float(next_unit_price or 0.0)) > 1e-6:
                        item.unit_price = next_unit_price
                        sale_changed = True

                existing_batch = None
                if item.inventory_batch_id:
                    existing_batch = next(
                        (b for b in batches_by_product.get(product.id, []) if int(b.id) == int(item.inventory_batch_id)),
                        None,
                    )
                    if existing_batch:
                        try:
                            eligible = _eligible_available_base_for_batch(
                                company_id=company_id,
                                product=product,
                                batch=existing_batch,
                                sale_date=sale_date,
                                cap_with_current=False,
                            )
                        except Exception:
                            eligible = 0
                        remaining = int(eligible or 0) - int(allocated_by_batch.get(existing_batch.id, 0))
                        if remaining >= qty_base:
                            allocated_by_batch[existing_batch.id] = int(allocated_by_batch.get(existing_batch.id, 0)) + qty_base
                            _assign_batch(existing_batch)
                            continue

                matched = False
                for batch in batches_by_product.get(product.id, []):
                    try:
                        eligible = _eligible_available_base_for_batch(
                            company_id=company_id,
                            product=product,
                            batch=batch,
                            sale_date=sale_date,
                            cap_with_current=False,
                        )
                    except Exception:
                        eligible = 0
                    remaining = int(eligible or 0) - int(allocated_by_batch.get(batch.id, 0))
                    if remaining >= qty_base:
                        allocated_by_batch[batch.id] = int(allocated_by_batch.get(batch.id, 0)) + qty_base
                        _assign_batch(batch)
                        matched = True
                        break

                if not matched:
                    if item.inventory_batch_id is not None:
                        item.inventory_batch_id = None
                        sale_changed = True
                    if product.lot_tracking and item.batch_number is not None:
                        item.batch_number = None
                        sale_changed = True

            if sale_changed:
                _set_backdated_totals(sale)
                touched = True

        if touched:
            db.session.flush()
        return touched

    def _repair_backdated_sale_totals(
        *,
        company_id: int,
        sale_ids: list[int] | None = None,
    ) -> dict:
        query = Sale.query.filter(
            Sale.company_id == company_id,
            Sale.source == "backdated_daily_sales",
        )
        if sale_ids:
            query = query.filter(Sale.id.in_(sale_ids))
        sales = query.order_by(Sale.id.asc()).all()
        if not sales:
            return {"changed": [], "unrecoverable": []}

        pending_sales = [s for s in sales if str(s.approval_status or "").lower() in {"", "pending"}]
        if pending_sales:
            _relink_pending_backdated_sales(company_id=company_id, pending_sales=pending_sales)

        changed: list[dict] = []
        unrecoverable: list[dict] = []

        for sale in sales:
            subtotal_amount = 0.0
            line_discount_total = 0.0
            line_tax_total = 0.0
            has_priced_lines = False

            for item in sale.items or []:
                try:
                    ordered_qty = float(
                        item.ordered_quantity_uom
                        if item.ordered_quantity_uom is not None
                        else (item.quantity_uom if item.quantity_uom is not None else (item.quantity or 0.0))
                    )
                except (TypeError, ValueError):
                    ordered_qty = 0.0
                try:
                    unit_price_uom = float(item.unit_price_uom or 0.0)
                except (TypeError, ValueError):
                    unit_price_uom = 0.0
                if ordered_qty <= 0 or unit_price_uom <= 0:
                    continue
                has_priced_lines = True
                gross_line = ordered_qty * unit_price_uom
                discount_pct = max(0.0, min(100.0, float(item.line_discount_percent or 0.0)))
                discount_amt = gross_line * (discount_pct / 100.0)
                taxable_line = max(0.0, gross_line - discount_amt)
                tax_pct = max(0.0, float(item.line_tax_percent or 0.0))
                tax_amt = taxable_line * (tax_pct / 100.0)
                subtotal_amount += gross_line
                line_discount_total += discount_amt
                line_tax_total += tax_amt

            if not has_priced_lines or subtotal_amount <= 0:
                unrecoverable.append(
                    {
                        "sale_id": sale.id,
                        "approval_status": sale.approval_status,
                        "reason": "No priced line items were stored for this backdated sale",
                    }
                )
                continue

            computed_total = max(0.0, subtotal_amount - line_discount_total + line_tax_total + float(sale.extra_charges_amount or 0.0))
            before = (
                round(float(sale.subtotal_amount or 0.0), 2),
                round(float(sale.discount_amount or 0.0), 2),
                round(float(sale.total_amount or 0.0), 2),
            )
            after = (
                round(subtotal_amount, 2),
                round(line_discount_total, 2),
                round(computed_total, 2),
            )
            if before != after:
                sale.subtotal_amount = after[0]
                sale.discount_amount = after[1]
                sale.total_amount = after[2]
                if (sale.payment_status or "").strip().lower() == "due":
                    sale.paid_amount = 0.0
                    sale.due_amount = after[2]
                else:
                    sale.paid_amount = after[2]
                    sale.due_amount = 0.0
                changed.append(
                    {
                        "sale_id": sale.id,
                        "subtotal_amount": after[0],
                        "discount_amount": after[1],
                        "total_amount": after[2],
                    }
                )

        if changed:
            db.session.flush()
        return {"changed": changed, "unrecoverable": unrecoverable}

    def _expiry_return_supplier_allocations(company_id: int, product: Product, batch: InventoryBatch) -> list[dict]:
        """
        Allocate remaining batch quantities by supplier based on posted purchase bills,
        then subtract sales for this batch (FIFO by purchase_date).
        Returns a list of {supplier_id, supplier_name, qty_base}.
        """
        batch_qty = int(batch.qty_base or 0)
        if batch_qty <= 0:
            return []

        if product.lot_tracking and not batch.batch_number:
            return [{"supplier_id": None, "supplier_name": "Unknown supplier", "qty_base": batch_qty}]

        q = (
            db.session.query(PurchaseBillItem, PurchaseBill, Supplier)
            .join(PurchaseBill, PurchaseBillItem.purchase_bill_id == PurchaseBill.id)
            .join(Supplier, PurchaseBill.supplier_id == Supplier.id)
            .filter(
                PurchaseBill.company_id == company_id,
                PurchaseBill.posted.is_(True),
                PurchaseBillItem.product_id == product.id,
            )
        )
        if product.lot_tracking:
            q = q.filter(PurchaseBillItem.batch_number == batch.batch_number)
        else:
            q = q.filter(PurchaseBillItem.batch_number.is_(None))
            if batch.expiry_date:
                q = q.filter(PurchaseBillItem.expiry_date == batch.expiry_date)
            if batch.uom:
                q = q.filter(PurchaseBillItem.uom == batch.uom)
            mrp_match = float(getattr(batch, "mrp_per_uom", 0.0) or 0.0) or float(batch.mrp or 0.0)
            if mrp_match:
                q = q.filter((PurchaseBillItem.mrp == mrp_match) | (PurchaseBillItem.price == mrp_match))

        rows = (
            q.order_by(PurchaseBill.purchase_date.asc(), PurchaseBill.id.asc(), PurchaseBillItem.id.asc())
            .all()
        )
        entries = []
        for item, _bill, supplier in rows:
            qty_units = float((item.ordered_qty or 0) + (item.free_qty or 0))
            if qty_units <= 0:
                continue
            qty_base = int(_qty_to_base(company_id, product, qty_units, item.uom) or 0)
            if qty_base <= 0:
                continue
            entries.append(
                {
                    "supplier_id": int(supplier.id) if supplier else None,
                    "supplier_name": supplier.name if supplier else "Unknown supplier",
                    "qty_base": qty_base,
                }
            )

        if not entries:
            return [{"supplier_id": None, "supplier_name": "Unknown supplier", "qty_base": batch_qty}]

        sold_base = (
            db.session.query(db.func.coalesce(db.func.sum(SaleItem.quantity), 0))
            .join(Sale, SaleItem.sale_id == Sale.id)
            .filter(
                SaleItem.product_id == product.id,
                SaleItem.inventory_batch_id == batch.id,
                *_inventory_sale_filters(company_id),
            )
            .scalar()
            or 0
        )
        if product.lot_tracking and batch.batch_number:
            sold_fallback = (
                db.session.query(db.func.coalesce(db.func.sum(SaleItem.quantity), 0))
                .join(Sale, SaleItem.sale_id == Sale.id)
                .filter(
                    SaleItem.product_id == product.id,
                    SaleItem.inventory_batch_id.is_(None),
                    SaleItem.batch_number == batch.batch_number,
                    *_inventory_sale_filters(company_id),
                )
                .scalar()
                or 0
            )
            sold_base = int(sold_base or 0) + int(sold_fallback or 0)

        remaining_entries = []
        remaining_sales = int(sold_base or 0)
        for entry in entries:
            used = min(int(entry["qty_base"]), remaining_sales)
            remaining_sales -= used
            remaining = int(entry["qty_base"]) - used
            if remaining > 0:
                remaining_entries.append({**entry, "remaining": remaining})

        total_remaining = sum(int(e["remaining"]) for e in remaining_entries)
        if total_remaining <= 0:
            return [{"supplier_id": None, "supplier_name": "Unknown supplier", "qty_base": batch_qty}]

        if total_remaining > batch_qty:
            excess = total_remaining - batch_qty
            for entry in reversed(remaining_entries):
                if excess <= 0:
                    break
                take = min(int(entry["remaining"]), excess)
                entry["remaining"] -= take
                excess -= take
            remaining_entries = [e for e in remaining_entries if int(e["remaining"]) > 0]
            total_remaining = sum(int(e["remaining"]) for e in remaining_entries)

        allocations: dict[int | None, dict] = {}
        for entry in remaining_entries:
            key = entry["supplier_id"]
            if key not in allocations:
                allocations[key] = {
                    "supplier_id": entry["supplier_id"],
                    "supplier_name": entry["supplier_name"],
                    "qty_base": 0,
                }
            allocations[key]["qty_base"] += int(entry["remaining"])

        if batch_qty > total_remaining:
            allocations.setdefault(None, {"supplier_id": None, "supplier_name": "Unknown supplier", "qty_base": 0})
            allocations[None]["qty_base"] += int(batch_qty - total_remaining)

        return [v for v in allocations.values() if int(v["qty_base"]) > 0]

    def _purchase_bill_numbers_for_batch_supplier(
            company_id: int,
            product: Product,
            batch: InventoryBatch,
            supplier_id: int | None,
            cutoff_date: date | None = None,
        ) -> str:
            if not supplier_id:
                return "-"
            q = (
                db.session.query(PurchaseBill.id, PurchaseBill.bill_number)
                .join(PurchaseBillItem, PurchaseBillItem.purchase_bill_id == PurchaseBill.id)
                .filter(
                    PurchaseBill.company_id == company_id,
                    PurchaseBill.posted.is_(True),
                    PurchaseBill.supplier_id == supplier_id,
                    PurchaseBillItem.product_id == product.id,
                )
            )
            if cutoff_date:
                q = q.filter(PurchaseBill.purchase_date <= cutoff_date)
            if product.lot_tracking:
                q = q.filter(PurchaseBillItem.batch_number == batch.batch_number)
            else:
                q = q.filter(PurchaseBillItem.batch_number.is_(None))
                if batch.expiry_date:
                    q = q.filter(PurchaseBillItem.expiry_date == batch.expiry_date)
                if batch.uom:
                    q = q.filter(PurchaseBillItem.uom == batch.uom)
                mrp_match = float(getattr(batch, "mrp_per_uom", 0.0) or 0.0) or float(batch.mrp or 0.0)
                if mrp_match:
                    q = q.filter((PurchaseBillItem.mrp == mrp_match) | (PurchaseBillItem.price == mrp_match))

            refs = []
            for bill_id, bill_number in q.order_by(PurchaseBill.purchase_date.asc(), PurchaseBill.id.asc()).all():
                refs.append(str(bill_number or f"#{bill_id}"))
            if not refs:
                return "-"
            # Deduplicate while preserving order
            seen: set[str] = set()
            ordered = []
            for val in refs:
                if val in seen:
                    continue
                seen.add(val)
                ordered.append(val)
            return ", ".join(ordered)

    def _cost_per_base_for_batch(company_id: int, product: Product, batch: InventoryBatch) -> float:
        q = (
            db.session.query(PurchaseBillItem, PurchaseBill)
            .join(PurchaseBill, PurchaseBillItem.purchase_bill_id == PurchaseBill.id)
            .filter(
                PurchaseBill.company_id == company_id,
                PurchaseBill.posted.is_(True),
                PurchaseBillItem.product_id == product.id,
            )
        )
        if product.lot_tracking:
            q = q.filter(PurchaseBillItem.batch_number == batch.batch_number)
        else:
            q = q.filter(PurchaseBillItem.batch_number.is_(None))
            if batch.expiry_date:
                q = q.filter(PurchaseBillItem.expiry_date == batch.expiry_date)
            if batch.uom:
                q = q.filter(PurchaseBillItem.uom == batch.uom)
            mrp_match = float(getattr(batch, "mrp_per_uom", 0.0) or 0.0) or float(batch.mrp or 0.0)
            if mrp_match:
                q = q.filter((PurchaseBillItem.mrp == mrp_match) | (PurchaseBillItem.price == mrp_match))
        row = (
            q.order_by(PurchaseBill.purchase_date.desc(), PurchaseBill.id.desc(), PurchaseBillItem.id.desc())
            .first()
        )
        if not row:
            return 0.0
        item, _bill = row
        factor = _uom_factor_to_base(company_id, product, item.uom)
        if factor <= 0:
            return 0.0
        return float(item.cost_price or 0.0) / float(factor)

    def _purchase_bill_item_for_batch(
        company_id: int,
        product: Product,
        batch: InventoryBatch,
        cutoff_date: date | None = None,
        supplier_id: int | None = None,
    ) -> tuple[PurchaseBillItem | None, PurchaseBill | None]:
        q = (
            db.session.query(PurchaseBillItem, PurchaseBill)
            .join(PurchaseBill, PurchaseBillItem.purchase_bill_id == PurchaseBill.id)
            .filter(
                PurchaseBill.company_id == company_id,
                PurchaseBill.posted.is_(True),
                PurchaseBillItem.product_id == product.id,
            )
        )
        if cutoff_date:
            q = q.filter(PurchaseBill.purchase_date <= cutoff_date)
        if supplier_id:
            q = q.filter(PurchaseBill.supplier_id == supplier_id)
        if product.lot_tracking:
            if batch.batch_number:
                q = q.filter(PurchaseBillItem.batch_number == batch.batch_number)
        else:
            q = q.filter(PurchaseBillItem.batch_number.is_(None))
            if batch.expiry_date:
                q = q.filter(PurchaseBillItem.expiry_date == batch.expiry_date)
            if batch.uom:
                q = q.filter(PurchaseBillItem.uom == batch.uom)
            mrp_match = float(getattr(batch, "mrp_per_uom", 0.0) or 0.0) or float(batch.mrp or 0.0)
            if mrp_match:
                q = q.filter((PurchaseBillItem.mrp == mrp_match) | (PurchaseBillItem.price == mrp_match))
        row = (
            q.order_by(PurchaseBill.purchase_date.desc(), PurchaseBill.id.desc(), PurchaseBillItem.id.desc())
            .first()
        )
        if not row:
            return None, None
        return row

    @app.route("/expiry-returns", methods=["GET"])
    @require_auth
    @company_required()
    def expiry_returns_list():
        today = _today_ad()
        company_id = g.current_company.id

        batches = (
            InventoryBatch.query.filter(
                InventoryBatch.company_id == company_id,
                InventoryBatch.qty_base > 0,
                InventoryBatch.expiry_date.isnot(None),
            )
            .order_by(InventoryBatch.expiry_date.asc(), InventoryBatch.product_id.asc(), InventoryBatch.id.asc())
            .all()
        )

        month_groups: dict[str, dict] = {}
        expired_products: dict[int, dict] = {}

        for b in batches:
            product = db.session.get(Product, b.product_id)
            if not product or product.company_id != company_id:
                continue

            base_uom = _base_uom_name(product) or product.uom_category or ""
            qty_display = _format_qty_display(int(b.qty_base or 0), b.uom, float(b.factor_to_base or 1.0), base_uom)
            allocations = _expiry_return_supplier_allocations(company_id, product, b)
            if not allocations:
                continue
            for alloc in allocations:
                qty_base = int(alloc.get("qty_base") or 0)
                if qty_base <= 0:
                    continue
                qty_display = _format_qty_display(
                    int(qty_base), b.uom, float(b.factor_to_base or 1.0), base_uom
                )
                line = {
                    "inventory_batch_id": b.id,
                    "product_id": product.id,
                    "product_name": product.name,
                    "batch_number": b.batch_number,
                    "expiry_date": b.expiry_date.isoformat() if b.expiry_date else None,
                    "qty_base": int(qty_base),
                    "qty_display": qty_display,
                    "uom": b.uom or base_uom,
                    "supplier_id": alloc.get("supplier_id"),
                    "supplier_name": alloc.get("supplier_name"),
                }

                if b.expiry_date and b.expiry_date < today:
                    if product.id not in expired_products:
                        expired_products[product.id] = {"product_id": product.id, "product_name": product.name, "lines": []}
                    expired_products[product.id]["lines"].append(line)
                    continue

                if not b.expiry_date:
                    continue
                month_key = f"{b.expiry_date.year:04d}-{b.expiry_date.month:02d}"
                if month_key not in month_groups:
                    month_groups[month_key] = {"month": month_key, "label": b.expiry_date.strftime("%B %Y"), "products": {}}
                prod_map = month_groups[month_key]["products"]
                if product.id not in prod_map:
                    prod_map[product.id] = {"product_id": product.id, "product_name": product.name, "lines": []}
                prod_map[product.id]["lines"].append(line)

        months_out = []
        for mkey in sorted(month_groups.keys()):
            prod_map = month_groups[mkey]["products"]
            products_out = sorted(prod_map.values(), key=lambda x: (x.get("product_name") or "").lower())
            months_out.append({"month": mkey, "label": month_groups[mkey]["label"], "products": products_out})

        expired_out = sorted(expired_products.values(), key=lambda x: (x.get("product_name") or "").lower())
        return jsonify({"expired": expired_out, "months": months_out, "today": today.isoformat()})

    def _expiry_returns_query(company_id: int, month: str | None, expired: bool):
        today = _today_ad()
        if expired:
            title = "Expired Items"
            q = InventoryBatch.query.filter(
                InventoryBatch.company_id == company_id,
                InventoryBatch.qty_base > 0,
                InventoryBatch.expiry_date.isnot(None),
                InventoryBatch.expiry_date < today,
            )
            return q, title, None

        m = re.match(r"^(\d{4})-(\d{2})$", month or "")
        if not m:
            return None, "Invalid month (expected YYYY-MM)", None
        year = int(m.group(1))
        mon = int(m.group(2))
        if mon < 1 or mon > 12:
            return None, "Invalid month", None
        start = datetime(year, mon, 1).date()
        if mon == 12:
            end = datetime(year + 1, 1, 1).date()
        else:
            end = datetime(year, mon + 1, 1).date()
        title = f"Items expiring in {start.strftime('%B %Y')}"
        q = InventoryBatch.query.filter(
            InventoryBatch.company_id == company_id,
            InventoryBatch.qty_base > 0,
            InventoryBatch.expiry_date >= start,
            InventoryBatch.expiry_date < end,
        )
        return q, title, start

    @app.route("/expiry-returns/print", methods=["GET"])
    @require_auth
    @company_required()
    def expiry_returns_print():
        company_id = g.current_company.id

        month = (request.args.get("month") or "").strip() or None
        expired = request.args.get("expired") == "1"
        paper_size = (request.args.get("paper_size") or "A4").strip().upper()
        raw_batch_ids = (request.args.get("batch_id") or "").strip()
        supplier_param = (request.args.get("supplier_id") or "").strip()
        supplier_id_filter: int | str | None = None
        if supplier_param:
            if supplier_param.lower() == "none":
                supplier_id_filter = "none"
            elif supplier_param.isdigit():
                supplier_id_filter = int(supplier_param)
            else:
                return jsonify({"error": "Invalid supplier_id"}), 400
        batch_ids = None
        if raw_batch_ids:
            try:
                batch_ids = [int(x) for x in raw_batch_ids.split(",") if x.strip().isdigit()]
            except Exception:
                return jsonify({"error": "Invalid batch_id"}), 400
        if not month and not expired:
            return jsonify({"error": "Provide month=YYYY-MM or expired=1"}), 400

        q, title, _start = _expiry_returns_query(company_id, month, expired)
        if q is None:
            return jsonify({"error": title}), 400

        if batch_ids:
            q = q.filter(InventoryBatch.id.in_(batch_ids))

        batches = q.order_by(InventoryBatch.expiry_date.asc(), InventoryBatch.product_id.asc(), InventoryBatch.id.asc()).all()
        if not batches:
            return jsonify({"error": "No items found for the selected period"}), 404

        supplier_map: dict[int | None, dict] = {}
        for b in batches:
            product = db.session.get(Product, b.product_id)
            if not product or product.company_id != company_id:
                continue
            base_uom = _base_uom_name(product) or product.uom_category or ""
            allocations = _expiry_return_supplier_allocations(company_id, product, b)
            for alloc in allocations:
                qty_base = int(alloc.get("qty_base") or 0)
                if qty_base <= 0:
                    continue
                if supplier_id_filter is not None:
                    if supplier_id_filter == "none":
                        if alloc.get("supplier_id") not in (None, 0):
                            continue
                    else:
                        if alloc.get("supplier_id") != supplier_id_filter:
                            continue
                supplier_id = alloc.get("supplier_id")
                supplier_name = alloc.get("supplier_name") or "Unknown supplier"
                key = supplier_id
                if key not in supplier_map:
                    supplier_details = None
                    if supplier_id:
                        supplier = db.session.get(Supplier, supplier_id)
                        if supplier and supplier.company_id == company_id:
                            supplier_details = "<br/>".join(
                                [
                                    supplier.address or "-",
                                    f"Phone: {supplier.phone or '-'}",
                                    f"VAT/PAN: {supplier.pan_vat_number or '-'}",
                                    f"DDA: {supplier.dda_number or '-'}",
                                ]
                            )
                    supplier_map[key] = {
                        "supplier_id": supplier_id,
                        "supplier_name": supplier_name,
                        "supplier_details": supplier_details,
                        "lines": [],
                    }
                qty_display = _format_qty_display(
                    int(qty_base), b.uom, float(b.factor_to_base or 1.0), base_uom
                )
                pb_numbers = _purchase_bill_numbers_for_batch_supplier(
                    company_id, product, b, supplier_id
                )
                supplier_map[key]["lines"].append(
                    {
                        "product_id": product.id,
                        "product_name": product.name,
                        "hscode": product.hscode or "-",
                        "batch_number": b.batch_number,
                        "supplier_name": supplier_name,
                        "expiry_date": b.expiry_date.isoformat() if b.expiry_date else None,
                        "qty": qty_display,
                        "purchase_bill_numbers": pb_numbers,
                    }
                )

        supplier_sections = sorted(supplier_map.values(), key=lambda x: (x.get("supplier_name") or "").lower())
        try:
            printed_by = _user_display_name(getattr(g, "current_user", None))
            pdf = _render_expiry_returns_pdf(g.current_company, title, supplier_sections, printed_by, paper_size)
            filename = f"expiry-returns-{(month or 'expired')}.pdf"
            return _build_pdf_response(pdf, filename)
        except RuntimeError as exc:
            return jsonify({"error": str(exc)}), 501

    @app.route("/expiry-returns/confirm", methods=["POST"])
    @require_auth
    @company_required(["admin", "manager"])
    def expiry_returns_confirm():
        company_id = g.current_company.id
        payload = request.get_json() or {}
        month = (payload.get("month") or "").strip() or None
        expired = bool(payload.get("expired"))
        if not month and not expired:
            return jsonify({"error": "Provide month=YYYY-MM or expired=true"}), 400

        q, title, _start = _expiry_returns_query(company_id, month, expired)
        if q is None:
            return jsonify({"error": title}), 400

        batches = q.order_by(InventoryBatch.expiry_date.asc(), InventoryBatch.product_id.asc(), InventoryBatch.id.asc()).all()
        if not batches:
            return jsonify({"error": "No items found for the selected period"}), 404

        max_local = (
            db.session.query(db.func.max(ExpiryReturn.local_number))
            .filter(ExpiryReturn.company_id == company_id)
            .scalar()
            or 0
        )
        expiry_return = ExpiryReturn(
            company_id=company_id,
            local_number=int(max_local) + 1,
            period_type="expired" if expired else "month",
            period_key=month or "expired",
            title=title,
            created_by_user_id=int(getattr(g.current_user, "id", 0) or 0) or None,
        )
        db.session.add(expiry_return)
        db.session.flush()

        removed_batches = 0
        removed_qty = 0
        expense_total = 0.0
        receivable_total = 0.0
        for b in batches:
            qty_base = int(b.qty_base or 0)
            if qty_base <= 0:
                continue
            product = db.session.get(Product, b.product_id)
            if not product or product.company_id != company_id:
                continue
            base_uom = _base_uom_name(product) or product.uom_category or ""
            allocations = _expiry_return_supplier_allocations(company_id, product, b)
            if not allocations:
                allocations = [{"supplier_id": None, "supplier_name": "Unknown supplier", "qty_base": qty_base}]
            allocated_total = 0
            cost_per_base = _cost_per_base_for_batch(company_id, product, b)
            for alloc in allocations:
                alloc_qty = int(alloc.get("qty_base") or 0)
                if alloc_qty <= 0:
                    continue
                line_cost = float(cost_per_base) * float(alloc_qty)
                if alloc.get("supplier_id"):
                    receivable_total += line_cost
                else:
                    expense_total += line_cost
                allocated_total += alloc_qty
                db.session.add(
                    ExpiryReturnLine(
                        expiry_return_id=expiry_return.id,
                        product_id=product.id,
                        product_name=product.name,
                        batch_number=b.batch_number,
                        expiry_date=b.expiry_date,
                        qty_base=alloc_qty,
                        uom=b.uom or base_uom,
                        factor_to_base=float(b.factor_to_base or 1.0),
                        base_uom=base_uom,
                        supplier_id=alloc.get("supplier_id"),
                        supplier_name=alloc.get("supplier_name") or "Unknown supplier",
                    )
                )
            if allocated_total <= 0:
                continue
            b.qty_base = 0
            _recompute_product_stock_from_batches(company_id, product)
            removed_batches += 1
            removed_qty += qty_base
            db.session.add(
                InventoryLog(
                    company_id=company_id,
                    product=product,
                    change=-qty_base,
                    reason="expiry_return",
                )
            )

        if receivable_total > 0:
            _post_double_entry(
                company_id,
                "Accounts Receivable",
                "asset",
                "Inventory",
                "asset",
                receivable_total,
                "expiry_return",
                expiry_return.id,
                f"Expiry return #{expiry_return.local_number}",
            )
        if expense_total > 0:
            _post_double_entry(
                company_id,
                "Expiry Returns Expense",
                "expense",
                "Inventory",
                "asset",
                expense_total,
                "expiry_return",
                expiry_return.id,
                f"Expiry return #{expiry_return.local_number}",
            )

        db.session.commit()
        return jsonify(
            {
                "message": "Inventory removal confirmed",
                "expiry_return_id": expiry_return.id,
                "expiry_return_number": expiry_return.local_number,
                "removed_batches": removed_batches,
                "removed_qty_base": removed_qty,
                "period": month or "expired",
            }
        )

    @app.route("/expiry-returns/confirm-line", methods=["POST"])
    @require_auth
    @company_required(["admin", "manager"])
    def expiry_returns_confirm_line():
        company_id = g.current_company.id
        payload = request.get_json() or {}
        raw_batch_id = payload.get("inventory_batch_id") or payload.get("batch_id")
        raw_supplier_id = payload.get("supplier_id")
        if raw_batch_id is None:
            return jsonify({"error": "inventory_batch_id is required"}), 400
        try:
            batch_id = int(raw_batch_id)
        except (TypeError, ValueError):
            return jsonify({"error": "Invalid inventory_batch_id"}), 400

        supplier_id: int | None = None
        if raw_supplier_id not in (None, "", "none"):
            try:
                supplier_id = int(raw_supplier_id)
            except (TypeError, ValueError):
                return jsonify({"error": "Invalid supplier_id"}), 400

        batch = InventoryBatch.query.get_or_404(batch_id)
        if batch.company_id != company_id:
            return jsonify({"error": "Forbidden"}), 403
        if int(batch.qty_base or 0) <= 0:
            return jsonify({"error": "Batch is already empty"}), 400
        if not batch.expiry_date:
            return jsonify({"error": "Batch has no expiry date"}), 400

        product = db.session.get(Product, batch.product_id)
        if not product or product.company_id != company_id:
            return jsonify({"error": "Product not found"}), 404

        allocations = _expiry_return_supplier_allocations(company_id, product, batch)
        if not allocations:
            return jsonify({"error": "No allocations found for this batch"}), 404
        alloc_match = None
        for alloc in allocations:
            if (alloc.get("supplier_id") or None) == (supplier_id or None):
                alloc_match = alloc
                break
        if not alloc_match:
            return jsonify({"error": "Allocation not found for the selected supplier"}), 404

        qty_base = int(alloc_match.get("qty_base") or 0)
        if qty_base <= 0:
            return jsonify({"error": "Allocation quantity is zero"}), 400
        available = int(batch.qty_base or 0)
        if qty_base > available:
            qty_base = available

        max_local = (
            db.session.query(db.func.max(ExpiryReturn.local_number))
            .filter(ExpiryReturn.company_id == company_id)
            .scalar()
            or 0
        )
        today = _today_ad()
        if batch.expiry_date and batch.expiry_date < today:
            period_type = "expired"
            period_key = "expired"
            title = "Expired Items"
        else:
            period_type = "month"
            period_key = f"{batch.expiry_date.year:04d}-{batch.expiry_date.month:02d}"
            title = f"Items expiring in {batch.expiry_date.strftime('%B %Y')}"

        expiry_return = ExpiryReturn(
            company_id=company_id,
            local_number=int(max_local) + 1,
            period_type=period_type,
            period_key=period_key,
            title=title,
            created_by_user_id=int(getattr(g.current_user, "id", 0) or 0) or None,
        )
        db.session.add(expiry_return)
        db.session.flush()

        base_uom = _base_uom_name(product) or product.uom_category or ""
        db.session.add(
            ExpiryReturnLine(
                expiry_return_id=expiry_return.id,
                product_id=product.id,
                product_name=product.name,
                batch_number=batch.batch_number,
                expiry_date=batch.expiry_date,
                qty_base=qty_base,
                uom=batch.uom or base_uom,
                factor_to_base=float(batch.factor_to_base or 1.0),
                base_uom=base_uom,
                supplier_id=alloc_match.get("supplier_id"),
                supplier_name=alloc_match.get("supplier_name") or "Unknown supplier",
            )
        )

        batch.qty_base = max(0, int(batch.qty_base or 0) - qty_base)
        _recompute_product_stock_from_batches(company_id, product)
        db.session.add(
            InventoryLog(
                company_id=company_id,
                product=product,
                change=-qty_base,
                reason="expiry_return",
            )
        )

        cost_per_base = _cost_per_base_for_batch(company_id, product, batch)
        total_cost = float(cost_per_base) * float(qty_base)
        if total_cost > 0:
            if alloc_match.get("supplier_id"):
                debit_name = "Accounts Receivable"
                debit_type = "asset"
            else:
                debit_name = "Expiry Returns Expense"
                debit_type = "expense"
            _post_double_entry(
                company_id,
                debit_name,
                debit_type,
                "Inventory",
                "asset",
                total_cost,
                "expiry_return",
                expiry_return.id,
                f"Expiry return #{expiry_return.local_number}",
            )

        db.session.commit()
        return jsonify(
            {
                "message": "Inventory removal confirmed for selected line",
                "expiry_return_id": expiry_return.id,
                "expiry_return_number": expiry_return.local_number,
                "removed_qty_base": qty_base,
                "period": period_key,
            }
        )

    @app.route("/expiry-returns/history", methods=["GET"])
    @require_auth
    @company_required()
    def expiry_returns_history():
        company_id = g.current_company.id
        returns = (
            ExpiryReturn.query.filter(ExpiryReturn.company_id == company_id)
            .order_by(ExpiryReturn.created_at.desc())
            .all()
        )
        payload = []
        for r in returns:
            cutoff_date = r.created_at.date() if r.created_at else None
            lines_out = []
            for line in r.lines:
                qty_display = _format_qty_display(
                    int(line.qty_base or 0),
                    line.uom,
                    float(line.factor_to_base or 1.0),
                    line.base_uom or "",
                )
                product = db.session.get(Product, line.product_id) if line.product_id else None
                batch_like = type("BatchLike", (), {})()
                batch_like.batch_number = line.batch_number
                batch_like.expiry_date = line.expiry_date
                batch_like.uom = line.uom
                batch_like.mrp_per_uom = 0.0
                batch_like.mrp = 0.0
                pb_numbers = "-"
                if product and product.company_id == company_id:
                    pb_numbers = _purchase_bill_numbers_for_batch_supplier(
                        company_id, product, batch_like, line.supplier_id, cutoff_date
                    )
                lines_out.append(
                    {
                        "id": line.id,
                        "product_name": line.product_name,
                        "batch_number": line.batch_number,
                        "expiry_date": line.expiry_date.isoformat() if line.expiry_date else None,
                        "qty_display": qty_display,
                        "uom": line.uom or line.base_uom or "",
                        "supplier_name": line.supplier_name,
                        "purchase_bill_numbers": pb_numbers,
                    }
                )
            payload.append(
                {
                    "id": r.id,
                    "number": r.local_number,
                    "title": r.title,
                    "period_type": r.period_type,
                    "period_key": r.period_key,
                    "created_at": r.created_at.isoformat() if r.created_at else None,
                    "created_by": _user_display_name(r.created_by),
                    "lines": lines_out,
                }
            )
        return jsonify(payload)

    @app.route("/expiry-returns/history/<int:return_id>/print", methods=["GET"])
    @require_auth
    @company_required()
    def expiry_returns_history_print(return_id: int):
        company_id = g.current_company.id
        ret = ExpiryReturn.query.get_or_404(return_id)
        if ret.company_id != company_id:
            return jsonify({"error": "Forbidden"}), 403
        paper_size = (request.args.get("paper_size") or "A4").strip().upper()

        supplier_map: dict[int | None, dict] = {}
        cutoff_date = ret.created_at.date() if ret.created_at else None
        for line in ret.lines:
            key = line.supplier_id
            if key not in supplier_map:
                supplier_details = None
                if line.supplier_id:
                    supplier = db.session.get(Supplier, line.supplier_id)
                    if supplier and supplier.company_id == company_id:
                        supplier_details = "<br/>".join(
                            [
                                supplier.address or "-",
                                f"Phone: {supplier.phone or '-'}",
                                f"VAT/PAN: {supplier.pan_vat_number or '-'}",
                                f"DDA: {supplier.dda_number or '-'}",
                            ]
                        )
                supplier_map[key] = {
                    "supplier_id": line.supplier_id,
                    "supplier_name": line.supplier_name or "Unknown supplier",
                    "supplier_details": supplier_details,
                    "lines": [],
                }
            qty_display = _format_qty_display(
                int(line.qty_base or 0),
                line.uom,
                float(line.factor_to_base or 1.0),
                line.base_uom or "",
            )
            product = db.session.get(Product, line.product_id) if line.product_id else None
            batch_like = type("BatchLike", (), {})()
            batch_like.batch_number = line.batch_number
            batch_like.expiry_date = line.expiry_date
            batch_like.uom = line.uom
            batch_like.mrp_per_uom = 0.0
            batch_like.mrp = 0.0
            pb_numbers = "-"
            if product and product.company_id == company_id:
                pb_numbers = _purchase_bill_numbers_for_batch_supplier(
                    company_id, product, batch_like, line.supplier_id, cutoff_date
                )
            supplier_map[key]["lines"].append(
                {
                    "product_name": line.product_name or "-",
                    "hscode": (product.hscode or "-") if product else "-",
                    "batch_number": line.batch_number,
                    "expiry_date": line.expiry_date.isoformat() if line.expiry_date else None,
                    "qty": qty_display,
                    "purchase_bill_numbers": pb_numbers,
                }
            )

        supplier_sections = sorted(supplier_map.values(), key=lambda x: (x.get("supplier_name") or "").lower())
        number = f"ER-{int(ret.local_number or 0):04d}"
        title = f"{ret.title or 'Expiry Returns'} ({number})"
        try:
            printed_by = _user_display_name(getattr(g, "current_user", None))
            pdf = _render_expiry_returns_pdf(g.current_company, title, supplier_sections, printed_by, paper_size)
            filename = f"expiry-return-{number}.pdf"
            return _build_pdf_response(pdf, filename)
        except RuntimeError as exc:
            return jsonify({"error": str(exc)}), 501

    @app.route("/inventory/products/<int:product_id>/batches", methods=["GET"])
    @require_auth
    @company_required()
    def inventory_product_batches(product_id: int):
        product = Product.query.get_or_404(product_id)
        if product.company_id != g.current_company.id:
            return jsonify({"error": "Forbidden"}), 403
        sale_date_raw = (request.args.get("sale_date") or "").strip() or None
        sale_date = None
        if sale_date_raw:
            try:
                sale_date = datetime.fromisoformat(sale_date_raw).date()
            except ValueError:
                return jsonify({"error": "Invalid sale_date (expected YYYY-MM-DD)"}), 400
        include_empty_raw = (request.args.get("include_empty") or "").strip().lower()
        include_empty = include_empty_raw in {"1", "true", "yes"}
        ignore_current_raw = (request.args.get("ignore_current_qty") or "").strip().lower()
        ignore_current_qty = ignore_current_raw in {"1", "true", "yes"}

        strict_raw = (request.args.get("strict") or "1").strip().lower()
        strict_reconcile = strict_raw not in {"0", "false", "no"}
        if strict_reconcile:
            _maybe_reconcile_inventory(g.current_company.id, product_id=product.id)

        base_uom = _base_uom_name(product) or product.uom_category or ""

        batch_query = InventoryBatch.query.filter(
            InventoryBatch.company_id == g.current_company.id,
            InventoryBatch.product_id == product.id,
        )
        if not include_empty:
            batch_query = batch_query.filter(InventoryBatch.qty_base > 0)
        batches = (
            batch_query
            .order_by(
                InventoryBatch.expiry_date.is_(None).asc(),
                InventoryBatch.expiry_date.asc(),
                InventoryBatch.created_at.asc(),
            )
            .all()
        )

        out_batches = []
        for b in batches:
            bd = b.to_dict()
            if sale_date:
                eligible_base = _eligible_available_base_for_batch(
                    company_id=g.current_company.id,
                    product=product,
                    batch=b,
                    sale_date=sale_date,
                    cap_with_current=not bool(ignore_current_qty),
                )
                if eligible_base <= 0:
                    continue
                bd["eligible_qty_base"] = int(eligible_base)
                bd["eligible_qty_display"] = _format_qty_display(
                    int(eligible_base), b.uom, float(b.factor_to_base or 1.0), base_uom
                )
            out_batches.append(bd)

        return jsonify(
            {
                "product_id": product.id,
                "product_name": product.name,
                "base_uom": base_uom,
                "sale_date": sale_date.isoformat() if sale_date else None,
                "batches": out_batches,
            }
        )

    @app.route("/inventory/pricelist", methods=["GET"])
    @require_auth
    @company_required(["admin", "manager", "superuser", "superadmin"])
    def inventory_pricelist():
        company_id = g.current_company.id
        _maybe_reconcile_inventory(company_id)
        rows = (
            db.session.query(InventoryBatch, Product)
            .join(Product, Product.id == InventoryBatch.product_id)
            .filter(
                InventoryBatch.company_id == company_id,
                Product.company_id == company_id,
                InventoryBatch.qty_base > 0,
            )
            .order_by(Product.name.asc(), InventoryBatch.expiry_date.asc().nullslast(), InventoryBatch.id.asc())
            .all()
        )
        batch_keys = {
            (
                int(batch.product_id),
                (batch.batch_number or "").strip() or None,
                batch.expiry_date.isoformat() if batch.expiry_date else None,
            )
            for batch, _product in rows
        }
        purchase_meta: dict[tuple[int, str | None, str | None], dict[str, any]] = {}
        if batch_keys:
            product_ids = sorted({key[0] for key in batch_keys})
            pb_rows = (
                db.session.query(PurchaseBillItem, PurchaseBill, Supplier, Product)
                .join(PurchaseBill, PurchaseBillItem.purchase_bill_id == PurchaseBill.id)
                .outerjoin(Supplier, PurchaseBill.supplier_id == Supplier.id)
                .join(Product, Product.id == PurchaseBillItem.product_id)
                .filter(
                    PurchaseBill.company_id == company_id,
                    PurchaseBill.posted.is_(True),
                    PurchaseBillItem.product_id.in_(product_ids),
                )
                .order_by(PurchaseBill.posted_at.asc(), PurchaseBill.id.asc(), PurchaseBillItem.id.asc())
                .all()
            )
            for item, _bill, supplier, product in pb_rows:
                key = (
                    int(item.product_id),
                    (item.batch_number or "").strip() or None,
                    item.expiry_date.isoformat() if item.expiry_date else None,
                )
                if key not in batch_keys:
                    continue
                qty_uom = float(item.ordered_qty or 0.0) + float(item.free_qty or 0.0)
                qty_base = int(_qty_to_base(company_id, product, qty_uom, item.uom) or 0)
                if qty_uom > 0 and qty_base <= 0:
                    factor = float(_uom_factor_to_base(company_id, product, item.uom) or 1.0)
                    qty_base = max(int(round(qty_uom * factor)), 1)
                meta = purchase_meta.setdefault(
                    key,
                    {
                        "qty_purchased": 0,
                        "supplier_name": supplier.name if supplier else None,
                    },
                )
                meta["qty_purchased"] = int(meta.get("qty_purchased") or 0) + int(qty_base or 0)
                if not meta.get("supplier_name") and supplier and supplier.name:
                    meta["supplier_name"] = supplier.name

        sold_by_batch_id = {
            int(batch_id): float(qty or 0.0)
            for batch_id, qty in (
                db.session.query(SaleItem.inventory_batch_id, func.coalesce(func.sum(SaleItem.quantity), 0))
                .join(Sale, SaleItem.sale_id == Sale.id)
                .filter(
                    Sale.company_id == company_id,
                    SaleItem.inventory_batch_id.isnot(None),
                    or_(Sale.approval_status.is_(None), Sale.approval_status != "denied"),
                )
                .group_by(SaleItem.inventory_batch_id)
                .all()
            )
        }
        returned_by_batch_id = {
            int(batch_id): float(qty or 0.0)
            for batch_id, qty in (
                db.session.query(SaleItem.inventory_batch_id, func.coalesce(func.sum(SaleReturnItem.qty_base), 0))
                .join(SaleItem, SaleReturnItem.sale_item_id == SaleItem.id)
                .join(Sale, SaleItem.sale_id == Sale.id)
                .filter(
                    Sale.company_id == company_id,
                    SaleItem.inventory_batch_id.isnot(None),
                )
                .group_by(SaleItem.inventory_batch_id)
                .all()
            )
        }
        payload = []
        for batch, product in rows:
            mrp_value = float(getattr(batch, "mrp_per_uom", 0.0) or 0.0) or float(batch.mrp or 0.0)
            key = (
                int(batch.product_id),
                (batch.batch_number or "").strip() or None,
                batch.expiry_date.isoformat() if batch.expiry_date else None,
            )
            purchased_meta = purchase_meta.get(key) or {}
            qty_sold = max(
                0,
                int(round(float(sold_by_batch_id.get(int(batch.id), 0.0) or 0.0) - float(returned_by_batch_id.get(int(batch.id), 0.0) or 0.0)))
            )
            qty_current = int(batch.qty_base or 0)
            qty_purchased = max(
                int(purchased_meta.get("qty_purchased") or 0),
                qty_current + qty_sold,
            )
            qty_display = f"{qty_current} {batch.uom or (_base_uom_name(product) or product.uom_category or '')}".strip()
            payload.append(
                {
                    "inventory_batch_id": int(batch.id),
                    "product_id": int(product.id),
                    "product_name": product.name,
                    "batch_number": batch.batch_number or "-",
                    "expiry_date": batch.expiry_date.isoformat() if batch.expiry_date else None,
                    "uom": batch.uom or (_base_uom_name(product) or product.uom_category or ""),
                    "mrp": round(mrp_value, 4),
                    "qty_base": qty_current,
                    "qty_display": qty_display,
                    "qty_purchased": qty_purchased,
                    "qty_sold": qty_sold,
                    "supplier_name": purchased_meta.get("supplier_name"),
                    "arrival_at": _format_dt(getattr(batch, "arrival_at", None)) if getattr(batch, "arrival_at", None) else None,
                    "updated_at": _format_dt(getattr(batch, "updated_at", None)),
                }
            )
        return jsonify({"data": payload})

    @app.route("/inventory/batches/<int:batch_id>/mrp", methods=["PUT"])
    @require_auth
    @company_required(["admin", "manager", "superuser", "superadmin"])
    def inventory_batch_update_mrp(batch_id: int):
        batch = InventoryBatch.query.get_or_404(batch_id)
        if batch.company_id != g.current_company.id:
            return jsonify({"error": "Forbidden"}), 403
        product = Product.query.get(batch.product_id)
        if not product or product.company_id != g.current_company.id:
            return jsonify({"error": "Product not found"}), 404

        data = request.get_json(silent=True) or {}
        try:
            mrp = float(data.get("mrp") or 0.0)
        except (TypeError, ValueError):
            return jsonify({"error": "Invalid mrp"}), 400
        if mrp <= 0:
            return jsonify({"error": "MRP must be greater than 0"}), 400

        previous_mrp = round(float(getattr(batch, "mrp_per_uom", 0.0) or batch.mrp or 0.0), 4)
        batch.mrp = round(mrp, 4)
        batch.mrp_per_uom = round(mrp, 4)
        db.session.commit()
        log_action(
            "inventory_batch_mrp_updated",
            {
                "inventory_batch_id": batch.id,
                "product_id": product.id,
                "product_name": product.name,
                "batch_number": batch.batch_number,
                "previous_mrp": previous_mrp,
                "mrp": batch.mrp,
            },
            g.current_company.id,
        )
        socketio.emit(
            "inventory:update",
            {"type": "inventory_batch_mrp_updated", "inventory_batch_id": batch.id, "company_id": g.current_company.id},
        )
        return jsonify(
            {
                "inventory_batch_id": batch.id,
                "product_id": product.id,
                "product_name": product.name,
                "batch_number": batch.batch_number,
                "uom": batch.uom,
                "mrp": round(float(batch.mrp or 0.0), 4),
                "updated_at": _format_dt(getattr(batch, "updated_at", None)),
                "message": "MRP updated for this batch. Historical sales remain unchanged.",
            }
        )

    @app.route("/purchase-returns/eligible-products", methods=["GET"])
    @require_auth
    @company_required()
    def purchase_returns_eligible_products():
        return_date_raw = (request.args.get("return_date") or "").strip()
        if not return_date_raw:
            return jsonify({"error": "return_date is required"}), 400
        try:
            return_date = datetime.fromisoformat(return_date_raw).date()
        except ValueError:
            return jsonify({"error": "Invalid return_date (expected YYYY-MM-DD)"}), 400

        supplier_id = None
        supplier_raw = (request.args.get("supplier_id") or "").strip()
        if supplier_raw:
            try:
                supplier_id = int(supplier_raw)
            except (TypeError, ValueError):
                return jsonify({"error": "Invalid supplier_id"}), 400

        company_id = g.current_company.id
        strict_raw = (request.args.get("strict") or "1").strip().lower()
        strict_reconcile = strict_raw not in {"0", "false", "no"}
        if strict_reconcile:
            _maybe_reconcile_inventory(company_id)

        batches = (
            InventoryBatch.query.filter(
                InventoryBatch.company_id == company_id,
                InventoryBatch.qty_base > 0,
            )
            .order_by(InventoryBatch.product_id.asc(), InventoryBatch.id.asc())
            .all()
        )
        if not batches:
            return jsonify([])

        product_ids = sorted({int(b.product_id) for b in batches if b.product_id})
        if not product_ids:
            return jsonify([])

        products = {p.id: p for p in Product.query.filter(Product.company_id == company_id, Product.id.in_(product_ids)).all()}
        eligible_products: dict[int, dict] = {}
        for b in batches:
            product = products.get(int(b.product_id))
            if not product:
                continue
            eligible_base = _eligible_available_base_for_batch(
                company_id=company_id,
                product=product,
                batch=b,
                sale_date=return_date,
            )
            if eligible_base <= 0:
                continue
            if supplier_id:
                item, _bill = _purchase_bill_item_for_batch(company_id, product, b, return_date, supplier_id)
                if not item:
                    continue
            if product.id not in eligible_products:
                eligible_products[product.id] = {
                    "id": product.id,
                    "name": product.name,
                    "manufacturer": product.manufacturer,
                }
        out = sorted(eligible_products.values(), key=lambda x: (x.get("name") or "").lower())
        return jsonify(out)

    @app.route("/purchase-returns/batches", methods=["GET"])
    @require_auth
    @company_required()
    def purchase_returns_batches():
        product_id = int(request.args.get("product_id") or 0)
        if not product_id:
            return jsonify({"error": "product_id is required"}), 400
        return_date_raw = (request.args.get("return_date") or "").strip()
        if not return_date_raw:
            return jsonify({"error": "return_date is required"}), 400
        try:
            return_date = datetime.fromisoformat(return_date_raw).date()
        except ValueError:
            return jsonify({"error": "Invalid return_date (expected YYYY-MM-DD)"}), 400

        supplier_id = None
        supplier_raw = (request.args.get("supplier_id") or "").strip()
        if supplier_raw:
            try:
                supplier_id = int(supplier_raw)
            except (TypeError, ValueError):
                return jsonify({"error": "Invalid supplier_id"}), 400

        product = Product.query.get_or_404(product_id)
        if product.company_id != g.current_company.id:
            return jsonify({"error": "Forbidden"}), 403

        strict_raw = (request.args.get("strict") or "1").strip().lower()
        strict_reconcile = strict_raw not in {"0", "false", "no"}
        if strict_reconcile:
            _maybe_reconcile_inventory(g.current_company.id, product_id=product.id)

        base_uom = _base_uom_name(product) or product.uom_category or ""
        batches = (
            InventoryBatch.query.filter(
                InventoryBatch.company_id == g.current_company.id,
                InventoryBatch.product_id == product.id,
                InventoryBatch.qty_base > 0,
            )
            .order_by(
                InventoryBatch.expiry_date.is_(None).asc(),
                InventoryBatch.expiry_date.asc(),
                InventoryBatch.created_at.asc(),
            )
            .all()
        )
        out_batches = []
        for b in batches:
            eligible_base = _eligible_available_base_for_batch(
                company_id=g.current_company.id,
                product=product,
                batch=b,
                sale_date=return_date,
            )
            if eligible_base <= 0:
                continue
            item, bill = _purchase_bill_item_for_batch(g.current_company.id, product, b, return_date, supplier_id)
            if not item:
                # Fallback: allow unposted bills to populate details (inventory may have been posted by legacy flows)
                q = (
                    db.session.query(PurchaseBillItem, PurchaseBill)
                    .join(PurchaseBill, PurchaseBillItem.purchase_bill_id == PurchaseBill.id)
                    .filter(
                        PurchaseBill.company_id == g.current_company.id,
                        PurchaseBillItem.product_id == product.id,
                    )
                )
                if return_date:
                    q = q.filter(PurchaseBill.purchase_date <= return_date)
                if supplier_id:
                    q = q.filter(PurchaseBill.supplier_id == supplier_id)
                if product.lot_tracking:
                    if b.batch_number:
                        q = q.filter(PurchaseBillItem.batch_number == b.batch_number)
                else:
                    q = q.filter(PurchaseBillItem.batch_number.is_(None))
                    if b.expiry_date:
                        q = q.filter(PurchaseBillItem.expiry_date == b.expiry_date)
                    if b.uom:
                        q = q.filter(PurchaseBillItem.uom == b.uom)
                    mrp_match = float(getattr(b, "mrp_per_uom", 0.0) or 0.0) or float(b.mrp or 0.0)
                    if mrp_match:
                        q = q.filter((PurchaseBillItem.mrp == mrp_match) | (PurchaseBillItem.price == mrp_match))
                row = (
                    q.order_by(PurchaseBill.purchase_date.desc(), PurchaseBill.id.desc(), PurchaseBillItem.id.desc())
                    .first()
                )
                if row:
                    item, bill = row
            if supplier_id and not item:
                continue

            uom = (item.uom if item else None) or b.uom or base_uom
            factor = _uom_factor_to_base(g.current_company.id, product, uom)
            if factor <= 0:
                factor = float(b.factor_to_base or 1.0) or 1.0
            qty_uom = float(eligible_base) / float(factor)
            discount_total = float(item.discount or 0.0) if item else 0.0
            ordered_qty = float(item.ordered_qty or 0.0) if item else 0.0
            discount_per_uom = (discount_total / ordered_qty) if ordered_qty > 0 else 0.0
            cost_price = float(item.cost_price or 0.0) if item else round(float(_cost_per_base_for_batch(g.current_company.id, product, b)) * float(factor), 4)
            mrp_val = float(item.mrp or 0.0) if item else 0.0
            if mrp_val <= 0:
                mrp_val = float(item.price or 0.0) if item else 0.0
            if mrp_val <= 0:
                mrp_val = float(getattr(b, "mrp_per_uom", 0.0) or 0.0) or float(b.mrp or 0.0)
            line_subtotal = qty_uom * cost_price
            line_discount = discount_per_uom * qty_uom
            line_total = max(line_subtotal - line_discount, 0.0)
            ordered_qty = float(item.ordered_qty or 0.0) if item else 0.0
            free_qty = float(item.free_qty or 0.0) if item else 0.0
            purchase_line_total = float(item.line_total or 0.0) if item else 0.0
            denom_qty = ordered_qty + free_qty
            effective_unit_cost = 0.0
            if purchase_line_total > 0 and denom_qty > 0:
                effective_unit_cost = purchase_line_total / denom_qty
            else:
                effective_unit_cost = max(cost_price - discount_per_uom, 0.0)
            bill_number = ""
            bill_id = None
            if bill:
                bill_id = int(getattr(bill, "id", 0) or 0) or None
                bill_number = str(getattr(bill, "bill_number", "") or "") or (f"#{bill_id}" if bill_id else "")

            out_batches.append(
                {
                    "inventory_batch_id": b.id,
                    "batch_number": b.batch_number,
                    "expiry_date": b.expiry_date.isoformat() if b.expiry_date else None,
                    "uom": uom,
                    "factor_to_base": float(factor),
                    "eligible_qty_base": int(eligible_base),
                    "eligible_qty_uom": round(qty_uom, 4),
                    "eligible_qty_display": _format_qty_display(
                        int(eligible_base), uom, float(factor), base_uom
                    ),
                    "purchase_bill_id": bill_id,
                    "purchase_bill_number": bill_number,
                    "ordered_qty": round(ordered_qty, 4),
                    "free_qty": round(free_qty, 4),
                    "purchase_bill_line_total": round(purchase_line_total, 2),
                    "effective_unit_cost": round(effective_unit_cost, 4),
                    "mrp": round(float(mrp_val), 4),
                    "cost_price": round(float(cost_price), 4),
                    "discount_per_uom": round(float(discount_per_uom), 4),
                    "line_discount": round(float(line_discount), 2),
                    "line_total": round(float(line_total), 2),
                }
            )

        return jsonify(
            {
                "product_id": product.id,
                "product_name": product.name,
                "return_date": return_date.isoformat(),
                "batches": out_batches,
            }
        )

    @app.route("/purchase-returns", methods=["POST"])
    @require_auth
    @company_required()
    def create_purchase_return():
        effective_permissions = set(_effective_permission_keys(g.current_user, g.current_company.id, g.company_role))
        if "purchase_returns_create" not in effective_permissions:
            return jsonify({"error": "Insufficient permissions to create purchase returns"}), 403

        payload = request.get_json() or {}
        return_date_raw = (payload.get("return_date") or "").strip()
        if not return_date_raw:
            return jsonify({"error": "return_date is required"}), 400
        try:
            return_date = datetime.fromisoformat(return_date_raw).date()
        except ValueError:
            return jsonify({"error": "Invalid return_date (expected YYYY-MM-DD)"}), 400

        supplier_id = payload.get("supplier_id")
        supplier = None
        if supplier_id not in (None, "", 0, "0"):
            try:
                supplier_id = int(supplier_id)
            except (TypeError, ValueError):
                return jsonify({"error": "Invalid supplier_id"}), 400
            supplier = Supplier.query.get(supplier_id)
            if not supplier or supplier.company_id != g.current_company.id:
                return jsonify({"error": "Supplier not found"}), 404
        else:
            supplier_id = None

        items = payload.get("items") or []
        if not isinstance(items, list) or not items:
            return jsonify({"error": "items are required"}), 400

        max_local = (
            db.session.query(db.func.max(ExpiryReturn.local_number))
            .filter(ExpiryReturn.company_id == g.current_company.id)
            .scalar()
            or 0
        )
        title_supplier = supplier.name if supplier else "Unknown supplier"
        expiry_return = ExpiryReturn(
            company_id=g.current_company.id,
            local_number=int(max_local) + 1,
            period_type="manual",
            period_key=return_date.isoformat(),
            title=f"Purchase Return - {title_supplier}",
            created_by_user_id=int(getattr(g.current_user, "id", 0) or 0) or None,
        )
        db.session.add(expiry_return)
        db.session.flush()

        receivable_total = 0.0
        expense_total = 0.0

        for idx, row in enumerate(items):
            raw_batch_id = row.get("inventory_batch_id")
            if raw_batch_id is None:
                db.session.rollback()
                return jsonify({"error": f"Line {idx + 1}: inventory_batch_id is required"}), 400
            try:
                batch_id = int(raw_batch_id)
            except (TypeError, ValueError):
                db.session.rollback()
                return jsonify({"error": f"Line {idx + 1}: Invalid inventory_batch_id"}), 400

            batch = InventoryBatch.query.get_or_404(batch_id)
            if batch.company_id != g.current_company.id:
                db.session.rollback()
                return jsonify({"error": f"Line {idx + 1}: Forbidden batch"}), 403

            product = db.session.get(Product, batch.product_id)
            if not product or product.company_id != g.current_company.id:
                db.session.rollback()
                return jsonify({"error": f"Line {idx + 1}: Product not found"}), 404

            eligible_base = _eligible_available_base_for_batch(
                company_id=g.current_company.id,
                product=product,
                batch=batch,
                sale_date=return_date,
            )
            if eligible_base <= 0:
                db.session.rollback()
                return jsonify({"error": f"Line {idx + 1}: Item not available on return date"}), 400

            qty_base_raw = row.get("qty_base")
            qty_base = int(eligible_base) if qty_base_raw in (None, "", 0, "0") else int(float(qty_base_raw))
            if qty_base <= 0:
                db.session.rollback()
                return jsonify({"error": f"Line {idx + 1}: Quantity must be > 0"}), 400
            if qty_base > int(eligible_base):
                db.session.rollback()
                return jsonify({"error": f"Line {idx + 1}: Quantity exceeds available on return date"}), 400

            uom = batch.uom or _base_uom_name(product) or product.uom_category or ""
            factor = float(batch.factor_to_base or 1.0)
            base_uom = _base_uom_name(product) or product.uom_category or ""

            db.session.add(
                ExpiryReturnLine(
                    expiry_return_id=expiry_return.id,
                    product_id=product.id,
                    product_name=product.name,
                    batch_number=batch.batch_number,
                    expiry_date=batch.expiry_date,
                    qty_base=qty_base,
                    uom=uom,
                    factor_to_base=factor,
                    base_uom=base_uom,
                    supplier_id=supplier_id,
                    supplier_name=title_supplier,
                )
            )

            batch.qty_base = max(int(batch.qty_base or 0) - qty_base, 0)
            _recompute_product_stock_from_batches(g.current_company.id, product)
            db.session.add(
                InventoryLog(
                    company_id=g.current_company.id,
                    product=product,
                    change=-qty_base,
                    reason="expiry_return",
                )
            )

            item, _bill = _purchase_bill_item_for_batch(g.current_company.id, product, batch, return_date, supplier_id)
            cost_per_base = 0.0
            if item:
                factor_item = _uom_factor_to_base(g.current_company.id, product, item.uom)
                if factor_item > 0:
                    cost_per_base = float(item.cost_price or 0.0) / float(factor_item)
            if cost_per_base <= 0:
                cost_per_base = _cost_per_base_for_batch(g.current_company.id, product, batch)
            line_cost = float(cost_per_base) * float(qty_base)
            if supplier_id:
                receivable_total += line_cost
            else:
                expense_total += line_cost

        if receivable_total > 0:
            _post_double_entry(
                g.current_company.id,
                "Accounts Receivable",
                "asset",
                "Inventory",
                "asset",
                receivable_total,
                "expiry_return",
                expiry_return.id,
                f"Purchase return #{expiry_return.local_number}",
            )
        if expense_total > 0:
            _post_double_entry(
                g.current_company.id,
                "Expiry Returns Expense",
                "expense",
                "Inventory",
                "asset",
                expense_total,
                "expiry_return",
                expiry_return.id,
                f"Purchase return #{expiry_return.local_number}",
            )

        db.session.commit()
        return jsonify(
            {
                "message": "Purchase return saved",
                "expiry_return_id": expiry_return.id,
                "expiry_return_number": expiry_return.local_number,
            }
        )

    @app.route("/sales/eligible-products", methods=["GET"])
    @require_auth
    @company_required()
    def sales_eligible_products():
        sale_date_raw = (request.args.get("sale_date") or "").strip()
        if not sale_date_raw:
            return jsonify({"error": "sale_date is required"}), 400
        try:
            sale_date = datetime.fromisoformat(sale_date_raw).date()
        except ValueError:
            return jsonify({"error": "Invalid sale_date (expected YYYY-MM-DD)"}), 400

        company_id = g.current_company.id
        strict_raw = (request.args.get("strict") or "1").strip().lower()
        strict_reconcile = strict_raw not in {"0", "false", "no"}
        if strict_reconcile:
            _maybe_reconcile_inventory(company_id)
        # Start from current in-stock batches; eligibility is <= current.
        batches = (
            InventoryBatch.query.filter(
                InventoryBatch.company_id == company_id,
                InventoryBatch.qty_base > 0,
            )
            .order_by(InventoryBatch.product_id.asc(), InventoryBatch.id.asc())
            .all()
        )
        product_ids = sorted({int(b.product_id) for b in batches if b.product_id})
        if not product_ids:
            return jsonify([])

        products = {p.id: p for p in Product.query.filter(Product.company_id == company_id, Product.id.in_(product_ids)).all()}

        eligible_products: dict[int, dict] = {}
        for b in batches:
            product = products.get(int(b.product_id))
            if not product:
                continue
            eligible_base = _eligible_available_base_for_batch(company_id=company_id, product=product, batch=b, sale_date=sale_date)
            if eligible_base <= 0:
                continue
            if product.id not in eligible_products:
                eligible_products[product.id] = {
                    "id": product.id,
                    "name": product.name,
                    "uom_category": product.uom_category,
                    "lot_tracking": bool(product.lot_tracking),
                    "base_uom": _base_uom_name(product) or product.uom_category or "",
                }

        return jsonify(sorted(eligible_products.values(), key=lambda x: (x.get("name") or "").lower()))

    def _rebuild_inventory_from_purchase_bills(company_id: int, product_id: int | None = None) -> dict:
        """
        Repair utility for legacy data:
        - Backfills InventoryBatch rows from posted purchase bills.
        - Recomputes Product.stock from batch totals.
        - Logs a single InventoryLog with the adjustment delta.

        This is intended to fix cases where stock was incorrectly adjusted before "post to inventory"
        existed or before inventory_batches were persisted.
        """
        product_query = Product.query.filter_by(company_id=company_id)
        if product_id:
            product_query = product_query.filter_by(id=product_id)
        products = product_query.all()

        updated = []
        for product in products:
            posted_items = (
                db.session.query(PurchaseBillItem, PurchaseBill)
                .join(PurchaseBill, PurchaseBillItem.purchase_bill_id == PurchaseBill.id)
                .filter(
                    PurchaseBill.company_id == company_id,
                    PurchaseBill.posted.is_(True),
                    PurchaseBillItem.product_id == product.id,
                )
                .order_by(PurchaseBill.posted_at.asc(), PurchaseBill.id.asc(), PurchaseBillItem.id.asc())
                .all()
            )
            if not posted_items:
                continue

            old_stock = int(product.stock or 0)

            # Rebuild deterministically without deleting rows that may still be referenced
            # by historical sale_items.inventory_batch_id.
            existing_batches = (
                InventoryBatch.query.filter_by(company_id=company_id, product_id=product.id)
                .order_by(InventoryBatch.created_at.asc(), InventoryBatch.id.asc())
                .all()
            )
            for batch in existing_batches:
                batch.qty_base = 0

            def _batch_key(batch: InventoryBatch) -> tuple:
                return (
                    (batch.batch_number or "").strip() or None,
                    batch.expiry_date.isoformat() if batch.expiry_date else None,
                    (batch.uom or "").strip() or None,
                    round(float(batch.mrp_per_uom or batch.mrp or 0.0), 4),
                )

            def _pick_existing_batch(row: dict) -> InventoryBatch | None:
                target_batch_number = (row.get("batch_number") or "").strip() or None
                target_expiry = row.get("expiry_date")
                target_uom = (row.get("uom") or "").strip() or None
                target_mrp = round(float(row.get("mrp_per_uom") or row.get("mrp") or 0.0), 4)

                if product.lot_tracking and target_batch_number:
                    matches = [
                        b for b in existing_batches
                        if ((b.batch_number or "").strip() or None) == target_batch_number
                    ]
                    return matches[0] if matches else None

                exact_key = (
                    target_batch_number,
                    target_expiry.isoformat() if target_expiry else None,
                    target_uom,
                    target_mrp,
                )
                for batch in existing_batches:
                    if _batch_key(batch) == exact_key:
                        return batch

                for batch in existing_batches:
                    if (
                        ((batch.batch_number or "").strip() or None) == target_batch_number
                        and batch.expiry_date == target_expiry
                        and ((batch.uom or "").strip() or None) == target_uom
                    ):
                        return batch
                return None

            grouped: dict[tuple, dict] = {}
            latest_price_per_base = None
            for it, bill in posted_items:
                qty_units = float((it.ordered_qty or 0) + (it.free_qty or 0))
                if qty_units <= 0:
                    continue

                uom = (it.uom or "").strip() or None
                factor = float(_uom_factor_to_base(company_id, product, uom) or 1.0)
                qty_base = int(_qty_to_base(company_id, product, qty_units, uom) or 0)
                if qty_base <= 0:
                    continue

                mrp_per_uom = float(it.mrp or 0.0) or float(getattr(it, "price", 0.0) or 0.0)
                mrp_per_uom = float(mrp_per_uom or 0.0)
                if mrp_per_uom > 0:
                    latest_price_per_base = round(mrp_per_uom / max(1.0, factor), 4)

                batch_number = it.batch_number if product.lot_tracking else None
                expiry = it.expiry_date if (product.expiry_tracking or product.shelf_removal) else None
                key = (
                    batch_number,
                    expiry.isoformat() if expiry else None,
                    uom,
                    round(mrp_per_uom, 4),
                )
                if key not in grouped:
                    grouped[key] = {
                        "batch_number": batch_number,
                        "expiry_date": expiry,
                        "uom": uom,
                        "factor_to_base": factor,
                        "mrp_per_uom": mrp_per_uom,
                        "mrp": mrp_per_uom,
                        "qty_base": 0,
                        # Stable arrival time into inventory for this logical batch line.
                        "arrival_at": bill.posted_at,
                    }
                grouped[key]["qty_base"] += qty_base
                # Use earliest posted_at for deterministic "arrival into inventory".
                if grouped[key].get("arrival_at") is None:
                    grouped[key]["arrival_at"] = bill.posted_at
                elif bill.posted_at:
                    a = bill.posted_at
                    b = grouped[key].get("arrival_at")
                    if getattr(a, "tzinfo", None) is not None:
                        a = a.astimezone(timezone.utc).replace(tzinfo=None)
                    if b is not None and getattr(b, "tzinfo", None) is not None:
                        b = b.astimezone(timezone.utc).replace(tzinfo=None)
                    if b is None or a < b:
                        grouped[key]["arrival_at"] = bill.posted_at

            new_stock = 0
            for row in grouped.values():
                if int(row["qty_base"] or 0) <= 0:
                    continue
                batch = _pick_existing_batch(row)
                if batch is None:
                    batch = InventoryBatch(company_id=company_id, product=product, **row)
                    db.session.add(batch)
                else:
                    batch.batch_number = row.get("batch_number")
                    batch.expiry_date = row.get("expiry_date")
                    batch.uom = row.get("uom")
                    batch.factor_to_base = float(row.get("factor_to_base") or 1.0)
                    batch.mrp_per_uom = float(row.get("mrp_per_uom") or 0.0)
                    batch.mrp = float(row.get("mrp") or 0.0)
                    batch.arrival_at = row.get("arrival_at")
                    batch.qty_base = int(row.get("qty_base") or 0)
                new_stock += int(row["qty_base"] or 0)

            product.stock = int(new_stock)
            if latest_price_per_base is not None:
                product.price = latest_price_per_base

            delta = int(product.stock or 0) - old_stock
            if delta != 0:
                db.session.add(
                    InventoryLog(company_id=company_id, product=product, change=delta, reason="inventory_rebuild")
                )
            updated.append({"product_id": product.id, "old_stock": old_stock, "new_stock": int(product.stock or 0), "delta": delta})

        return {"updated": updated, "count": len(updated)}

    def _reconcile_inventory_from_purchase_bills_and_sales(company_id: int, product_id: int | None = None) -> dict:
        """
        Reconcile current InventoryBatch + Product.stock state from authoritative movements:
        posted Purchase Bills (in) minus Sales (out).

        Key goals:
        - Lot-tracked products: enforce a single InventoryBatch row per (product_id, batch_number).
        - Prevent "double lines" like "5 x 10's" + "2 x 10's" for the same batch due to duplicates.
        - Keep historical references intact by zeroing duplicates instead of deleting.
        """

        def _cmp_dt(dt: datetime | None) -> datetime | None:
            if not dt:
                return None
            out = dt
            if getattr(out, "tzinfo", None) is not None:
                out = out.astimezone(timezone.utc).replace(tzinfo=None)
            return out

        def _min_dt(a: datetime | None, b: datetime | None) -> datetime | None:
            if not a:
                return b
            if not b:
                return a
            a_cmp = _cmp_dt(a)
            b_cmp = _cmp_dt(b)
            if not a_cmp:
                return b
            if not b_cmp:
                return a
            return a if a_cmp <= b_cmp else b

        def _merge_keep_one(batches: list[InventoryBatch]) -> InventoryBatch:
            if not batches:
                raise ValueError("No batches to merge")
            # Keep the earliest arrival/created row as canonical.
            batches_sorted = sorted(
                batches,
                key=lambda b: (
                    _cmp_dt(getattr(b, "arrival_at", None)) or _cmp_dt(getattr(b, "created_at", None)) or datetime.min,
                    b.id or 0,
                ),
            )
            canonical = batches_sorted[0]
            for b in batches_sorted[1:]:
                b.qty_base = 0
            return canonical

        product_query = Product.query.filter_by(company_id=company_id)
        if product_id:
            product_query = product_query.filter_by(id=product_id)
        products = product_query.all()

        updated: list[dict] = []
        issues: list[dict] = []

        for product in products:
            posted_items = (
                db.session.query(PurchaseBillItem, PurchaseBill)
                .join(PurchaseBill, PurchaseBillItem.purchase_bill_id == PurchaseBill.id)
                .filter(
                    PurchaseBill.company_id == company_id,
                    PurchaseBill.posted.is_(True),
                    PurchaseBillItem.product_id == product.id,
                )
                .order_by(PurchaseBill.posted_at.asc(), PurchaseBill.id.asc(), PurchaseBillItem.id.asc())
                .all()
            )

            # If a product has no posted purchases, don't rewrite it; it could be legacy/manual stock.
            if not posted_items:
                continue

            old_stock = int(product.stock or 0)

            # Build expected "remaining qty_base" per logical inventory line derived from purchases, then subtract sales.
            base_uom = _base_uom_name(product) or product.uom_category or ""

            if product.lot_tracking:
                purchased_by_batch: dict[str, int] = {}
                meta_by_batch: dict[str, dict] = {}
                for it, bill in posted_items:
                    bn = (it.batch_number or "").strip()
                    if not bn:
                        continue
                    qty_units = float((it.ordered_qty or 0) + (it.free_qty or 0))
                    if qty_units <= 0:
                        continue
                    uom = (it.uom or "").strip() or base_uom or None
                    factor = float(_uom_factor_to_base(company_id, product, uom) or 1.0)
                    qty_base = int(_qty_to_base(company_id, product, qty_units, uom) or 0)
                    if qty_base <= 0:
                        continue
                    mrp_per_uom = float(it.mrp or 0.0) or float(getattr(it, "price", 0.0) or 0.0)
                    mrp_per_uom = float(mrp_per_uom or 0.0)
                    expiry = it.expiry_date if (product.expiry_tracking or product.shelf_removal) else None

                    purchased_by_batch[bn] = purchased_by_batch.get(bn, 0) + qty_base
                    # last purchase wins for metadata, but arrival_at must remain the earliest posted_at
                    existing_meta = meta_by_batch.get(bn) or {}
                    earliest_arrival = _min_dt(existing_meta.get("arrival_at"), bill.posted_at)
                    meta_by_batch[bn] = {
                        "batch_number": bn,
                        "expiry_date": expiry,
                        "uom": uom,
                        "factor_to_base": factor if factor > 0 else 1.0,
                        "mrp_per_uom": mrp_per_uom,
                        "mrp": mrp_per_uom,
                        "posted_at": bill.posted_at,
                        "arrival_at": earliest_arrival,
                    }

                sold_by_batch: dict[str, int] = {}
                unassigned_sold_total = 0
                sale_rows = (
                    db.session.query(SaleItem)
                    .join(Sale, SaleItem.sale_id == Sale.id)
                    .filter(SaleItem.product_id == product.id, *_inventory_sale_filters(company_id))
                    .all()
                )
                for si in sale_rows:
                    bn = (si.batch_number or "").strip() or None
                    if not bn and si.inventory_batch_id:
                        b = InventoryBatch.query.get(int(si.inventory_batch_id))
                        if b and b.company_id == company_id and b.product_id == product.id:
                            bn = (b.batch_number or "").strip() or None
                    if not bn:
                        unassigned_sold_total += int(si.quantity or 0)
                        continue
                    sold_by_batch[bn] = sold_by_batch.get(bn, 0) + int(si.quantity or 0)

                returned_by_batch: dict[str, int] = {}
                return_rows = (
                    db.session.query(SaleReturnItem)
                    .join(SaleReturn, SaleReturnItem.sale_return_id == SaleReturn.id)
                    .filter(SaleReturn.company_id == company_id, SaleReturnItem.product_id == product.id)
                    .all()
                )
                for ri in return_rows:
                    bn = (ri.batch_number or "").strip() or None
                    if not bn and ri.inventory_batch_id:
                        b = InventoryBatch.query.get(int(ri.inventory_batch_id))
                        if b and b.company_id == company_id and b.product_id == product.id:
                            bn = (b.batch_number or "").strip() or None
                    if not bn:
                        continue
                    returned_by_batch[bn] = returned_by_batch.get(bn, 0) + int(ri.qty_base or 0)

                expired_by_batch: dict[str, int] = {}
                expiry_rows = (
                    db.session.query(ExpiryReturnLine)
                    .join(ExpiryReturn, ExpiryReturnLine.expiry_return_id == ExpiryReturn.id)
                    .filter(ExpiryReturn.company_id == company_id, ExpiryReturnLine.product_id == product.id)
                    .all()
                )
                for ei in expiry_rows:
                    bn = (ei.batch_number or "").strip() or None
                    if not bn:
                        continue
                    expired_by_batch[bn] = expired_by_batch.get(bn, 0) + int(ei.qty_base or 0)

                # Apply expected remaining to persisted inventory batches.
                expected_batch_numbers = set(purchased_by_batch.keys())
                existing_batches = (
                    InventoryBatch.query.filter_by(company_id=company_id, product_id=product.id)
                    .order_by(InventoryBatch.created_at.asc(), InventoryBatch.id.asc())
                    .all()
                )
                existing_by_bn: dict[str | None, list[InventoryBatch]] = {}
                for b in existing_batches:
                    key = (b.batch_number or "").strip() or None
                    existing_by_bn.setdefault(key, []).append(b)

                remaining_by_batch: dict[str, int] = {}
                for bn, purchased_base in purchased_by_batch.items():
                    sold_base = int(sold_by_batch.get(bn, 0) or 0)
                    returned_base = int(returned_by_batch.get(bn, 0) or 0)
                    expired_base = int(expired_by_batch.get(bn, 0) or 0)
                    remaining = int(purchased_base) - sold_base - expired_base + returned_base
                    if remaining < 0:
                        issues.append(
                            {
                                "product_id": product.id,
                                "product_name": product.name,
                                "batch_number": bn,
                                "issue": "sold_exceeds_purchased",
                                "purchased_base": int(purchased_base),
                                "sold_base": int(sold_base),
                                "expired_base": int(expired_base),
                                "returned_base": int(returned_base),
                            }
                        )
                        remaining = 0

                    remaining_by_batch[bn] = int(remaining)

                if unassigned_sold_total > 0 and remaining_by_batch:
                    def _batch_sort_key(bn: str):
                        meta = meta_by_batch.get(bn) or {}
                        exp = meta.get("expiry_date")
                        exp_key = exp.isoformat() if exp else date.max.isoformat()
                        arrival = meta.get("arrival_at") or meta.get("posted_at")
                        arrival_key = _cmp_dt(arrival) or datetime.max
                        return (exp_key, arrival_key)

                    for bn in sorted(remaining_by_batch.keys(), key=_batch_sort_key):
                        if unassigned_sold_total <= 0:
                            break
                        avail = int(remaining_by_batch.get(bn) or 0)
                        if avail <= 0:
                            continue
                        take = min(avail, unassigned_sold_total)
                        remaining_by_batch[bn] = avail - take
                        unassigned_sold_total -= take

                    if unassigned_sold_total > 0:
                        issues.append(
                            {
                                "product_id": product.id,
                                "product_name": product.name,
                                "issue": "unassigned_sales_exceed_purchased",
                                "unassigned_sold_base": int(unassigned_sold_total),
                            }
                        )

                for bn, remaining in remaining_by_batch.items():
                    meta = meta_by_batch.get(bn) or {}
                    candidates = existing_by_bn.get(bn) or []
                    meta_arrival_at = meta.get("arrival_at") or meta.get("posted_at")
                    if not candidates:
                        canonical = InventoryBatch(
                            company_id=company_id,
                            product=product,
                            batch_number=bn,
                            expiry_date=meta.get("expiry_date"),
                            uom=meta.get("uom"),
                            factor_to_base=float(meta.get("factor_to_base") or 1.0),
                            mrp_per_uom=float(meta.get("mrp_per_uom") or 0.0),
                            mrp=float(meta.get("mrp") or 0.0),
                            qty_base=0,
                            arrival_at=meta_arrival_at,
                        )
                        db.session.add(canonical)
                    else:
                        canonical = _merge_keep_one(candidates)

                    canonical.batch_number = bn
                    canonical.expiry_date = meta.get("expiry_date")
                    canonical.uom = meta.get("uom")
                    canonical.factor_to_base = float(meta.get("factor_to_base") or 1.0) or 1.0
                    canonical.mrp_per_uom = float(meta.get("mrp_per_uom") or 0.0)
                    canonical.mrp = float(meta.get("mrp") or 0.0)
                    canonical.qty_base = int(remaining)
                    if meta_arrival_at:
                        canonical.arrival_at = _min_dt(getattr(canonical, "arrival_at", None), meta_arrival_at)

                # Zero orphans (batches not backed by purchases).
                for bn, batches in existing_by_bn.items():
                    if not bn:
                        # lot-tracked products should not have null batch_number; treat as orphan.
                        for b in batches:
                            b.qty_base = 0
                        continue
                    if bn not in expected_batch_numbers:
                        for b in batches:
                            b.qty_base = 0

            else:
                # Non-lot-tracked: allocate sales FEFO across derived purchase lines.
                purchased_lines: dict[tuple, dict] = {}
                for it, bill in posted_items:
                    qty_units = float((it.ordered_qty or 0) + (it.free_qty or 0))
                    if qty_units <= 0:
                        continue
                    uom = (it.uom or "").strip() or base_uom or None
                    factor = float(_uom_factor_to_base(company_id, product, uom) or 1.0)
                    qty_base = int(_qty_to_base(company_id, product, qty_units, uom) or 0)
                    if qty_base <= 0:
                        continue
                    mrp_per_uom = float(it.mrp or 0.0) or float(getattr(it, "price", 0.0) or 0.0)
                    mrp_per_uom = float(mrp_per_uom or 0.0)
                    expiry = it.expiry_date if (product.expiry_tracking or product.shelf_removal) else None

                    key = (
                        expiry.isoformat() if expiry else None,
                        (uom or "").strip() or None,
                        round(float(mrp_per_uom or 0.0), 4),
                    )
                    if key not in purchased_lines:
                        purchased_lines[key] = {
                            "expiry_date": expiry,
                            "uom": uom,
                            "factor_to_base": factor if factor > 0 else 1.0,
                            "mrp_per_uom": mrp_per_uom,
                            "mrp": mrp_per_uom,
                            "qty_base": 0,
                            "posted_at": bill.posted_at,
                        }
                    purchased_lines[key]["qty_base"] += qty_base
                    # Use earliest posted_at for FEFO tie-break
                    purchased_lines[key]["posted_at"] = _min_dt(purchased_lines[key].get("posted_at"), bill.posted_at)

                sold_total = (
                    db.session.query(db.func.coalesce(db.func.sum(SaleItem.quantity), 0))
                    .join(Sale, SaleItem.sale_id == Sale.id)
                    .filter(SaleItem.product_id == product.id, *_inventory_sale_filters(company_id))
                    .scalar()
                    or 0
                )
                returned_total = (
                    db.session.query(db.func.coalesce(db.func.sum(SaleReturnItem.qty_base), 0))
                    .join(SaleReturn, SaleReturnItem.sale_return_id == SaleReturn.id)
                    .filter(SaleReturn.company_id == company_id, SaleReturnItem.product_id == product.id)
                    .scalar()
                    or 0
                )
                expired_total = (
                    db.session.query(db.func.coalesce(db.func.sum(ExpiryReturnLine.qty_base), 0))
                    .join(ExpiryReturn, ExpiryReturnLine.expiry_return_id == ExpiryReturn.id)
                    .filter(ExpiryReturn.company_id == company_id, ExpiryReturnLine.product_id == product.id)
                    .scalar()
                    or 0
                )
                remaining_sold = int(sold_total or 0) + int(expired_total or 0) - int(returned_total or 0)
                if remaining_sold < 0:
                    issues.append(
                        {
                            "product_id": product.id,
                            "product_name": product.name,
                            "issue": "returns_exceed_sold",
                            "sold_base": int(sold_total or 0),
                            "expired_base": int(expired_total or 0),
                            "returned_base": int(returned_total or 0),
                        }
                    )
                    remaining_sold = 0

                def _sort_key(row: dict):
                    exp = row.get("expiry_date")
                    exp_key = datetime.max.date().isoformat() if not exp else exp.isoformat()
                    posted_at = row.get("posted_at")
                    posted_key = datetime.max.isoformat() if not posted_at else posted_at.isoformat()
                    return (exp_key, posted_key)

                ordered_keys = sorted(purchased_lines.keys(), key=lambda k: _sort_key(purchased_lines[k]))
                for k in ordered_keys:
                    if remaining_sold <= 0:
                        break
                    avail = int(purchased_lines[k]["qty_base"] or 0)
                    if avail <= 0:
                        continue
                    take = min(avail, remaining_sold)
                    purchased_lines[k]["qty_base"] = avail - take
                    remaining_sold -= take

                # Apply remaining per key to InventoryBatch rows; zero extras.
                existing_batches = (
                    InventoryBatch.query.filter_by(company_id=company_id, product_id=product.id, batch_number=None)
                    .order_by(InventoryBatch.created_at.asc(), InventoryBatch.id.asc())
                    .all()
                )
                existing_by_key: dict[tuple, list[InventoryBatch]] = {}
                for b in existing_batches:
                    key = (
                        b.expiry_date.isoformat() if b.expiry_date else None,
                        (b.uom or "").strip() or None,
                        round(float(b.mrp or 0.0), 4),
                    )
                    existing_by_key.setdefault(key, []).append(b)

                expected_keys = set(purchased_lines.keys())
                for key, row in purchased_lines.items():
                    qty_base = int(row.get("qty_base") or 0)
                    batches = existing_by_key.get(key) or []
                    if qty_base <= 0:
                        # No remaining, zero any existing rows for this key.
                        for b in batches:
                            b.qty_base = 0
                        continue

                    if not batches:
                        b = InventoryBatch(
                            company_id=company_id,
                            product=product,
                            batch_number=None,
                            expiry_date=row.get("expiry_date"),
                            uom=row.get("uom"),
                            factor_to_base=float(row.get("factor_to_base") or 1.0),
                            mrp_per_uom=float(row.get("mrp_per_uom") or 0.0),
                            mrp=float(row.get("mrp") or 0.0),
                            qty_base=qty_base,
                            arrival_at=row.get("posted_at"),
                        )
                        db.session.add(b)
                        continue

                    canonical = _merge_keep_one(batches)
                    canonical.expiry_date = row.get("expiry_date")
                    canonical.uom = row.get("uom")
                    canonical.factor_to_base = float(row.get("factor_to_base") or 1.0) or 1.0
                    canonical.mrp_per_uom = float(row.get("mrp_per_uom") or 0.0)
                    canonical.mrp = float(row.get("mrp") or 0.0)
                    canonical.qty_base = qty_base
                    if row.get("posted_at"):
                        canonical.arrival_at = _min_dt(getattr(canonical, "arrival_at", None), row.get("posted_at"))

                # Zero orphan non-lot batches
                for key, batches in existing_by_key.items():
                    if key not in expected_keys:
                        for b in batches:
                            b.qty_base = 0

            new_stock = (
                db.session.query(db.func.coalesce(db.func.sum(InventoryBatch.qty_base), 0))
                .filter(InventoryBatch.company_id == company_id, InventoryBatch.product_id == product.id)
                .scalar()
                or 0
            )
            new_stock = int(new_stock or 0)
            product.stock = new_stock

            delta = new_stock - old_stock
            if delta != 0:
                db.session.add(InventoryLog(company_id=company_id, product=product, change=delta, reason="inventory_reconcile"))

            updated.append({"product_id": product.id, "old_stock": old_stock, "new_stock": new_stock, "delta": delta})

        return {"updated": updated, "count": len(updated), "issues": issues}

    @app.route("/inventory/rebuild", methods=["POST"])
    @require_auth
    @company_required(["superuser", "superadmin"])
    def rebuild_inventory_from_purchase_bills():
        data = request.get_json(silent=True) or {}
        product_id = data.get("product_id")
        try:
            product_id_int = int(product_id) if product_id else None
        except (TypeError, ValueError):
            return jsonify({"error": "Invalid product_id"}), 400

        result = _rebuild_inventory_from_purchase_bills(g.current_company.id, product_id_int)
        db.session.commit()
        socketio.emit(
            "inventory:update",
            {"type": "inventory_rebuilt", "company_id": g.current_company.id, "result": result},
        )
        return jsonify(result)

    @app.route("/inventory/reconcile", methods=["POST"])
    @require_auth
    @company_required(["superuser", "superadmin"])
    def reconcile_inventory_from_movements():
        data = request.get_json(silent=True) or {}
        product_id = data.get("product_id")
        try:
            product_id_int = int(product_id) if product_id else None
        except (TypeError, ValueError):
            return jsonify({"error": "Invalid product_id"}), 400

        result = _reconcile_inventory_from_purchase_bills_and_sales(g.current_company.id, product_id_int)
        db.session.commit()
        socketio.emit(
            "inventory:update",
            {"type": "inventory_reconciled", "company_id": g.current_company.id, "result": result},
        )
        return jsonify(result)

    @app.route("/inventory/repair", methods=["POST"])
    @require_auth
    @company_required(["superuser", "superadmin"])
    def repair_inventory_from_purchases():
        data = request.get_json(silent=True) or {}
        product_id = data.get("product_id")
        try:
            product_id_int = int(product_id) if product_id else None
        except (TypeError, ValueError):
            return jsonify({"error": "Invalid product_id"}), 400

        rebuild_result = _rebuild_inventory_from_purchase_bills(g.current_company.id, product_id_int)
        reconcile_result = _reconcile_inventory_from_purchase_bills_and_sales(g.current_company.id, product_id_int)
        db.session.commit()

        result = {"rebuild": rebuild_result, "reconcile": reconcile_result}
        socketio.emit(
            "inventory:update",
            {"type": "inventory_repaired", "company_id": g.current_company.id, "result": result},
        )
        return jsonify(result)

    @app.route("/jobs/inventory/repair", methods=["POST"])
    @require_auth
    @company_required(["superuser", "superadmin"])
    def enqueue_inventory_repair():
        app_obj = current_app._get_current_object()
        data = request.get_json(silent=True) or {}
        product_id = data.get("product_id")
        try:
            product_id_int = int(product_id) if product_id else None
        except (TypeError, ValueError):
            return jsonify({"error": "Invalid product_id"}), 400
        company_id = g.current_company.id

        def _task():
            rebuild_result = _rebuild_inventory_from_purchase_bills(company_id, product_id_int)
            reconcile_result = _reconcile_inventory_from_purchase_bills_and_sales(company_id, product_id_int)
            db.session.commit()
            result = {"rebuild": rebuild_result, "reconcile": reconcile_result}
            socketio.emit(
                "inventory:update",
                {"type": "inventory_repaired", "company_id": company_id, "result": result},
            )
            return result

        job_id = submit_background_job(app_obj, "inventory_repair", _task)
        return jsonify({"job_id": job_id, "status": "queued"}), 202

    @app.route("/jobs/reports/sales-summary", methods=["POST"])
    @require_auth
    @company_required(["admin", "manager", "superuser"])
    def enqueue_sales_summary_report():
        app_obj = current_app._get_current_object()
        data = request.get_json(silent=True) or {}
        company_id = g.current_company.id
        date_from_raw = data.get("date_from")
        date_to_raw = data.get("date_to")

        def _parse_date(value):
            if not value:
                return None
            return date.fromisoformat(str(value)[:10])

        try:
            date_from = _parse_date(date_from_raw)
            date_to = _parse_date(date_to_raw)
        except Exception:
            return jsonify({"error": "Invalid date range"}), 400

        def _task():
            q = Sale.query.filter(Sale.company_id == company_id)
            if date_from:
                q = q.filter(Sale.sale_date >= date_from)
            if date_to:
                q = q.filter(Sale.sale_date <= date_to)
            rows = q.all()
            total_sales = float(sum(float(r.total_amount or 0.0) for r in rows))
            paid_sales = float(sum(float(r.paid_amount or 0.0) for r in rows))
            due_sales = float(sum(float(r.due_amount or 0.0) for r in rows))
            return {
                "company_id": company_id,
                "date_from": date_from.isoformat() if date_from else None,
                "date_to": date_to.isoformat() if date_to else None,
                "sale_count": len(rows),
                "total_sales": round(total_sales, 2),
                "paid_sales": round(paid_sales, 2),
                "due_sales": round(due_sales, 2),
                "generated_at": datetime.now(timezone.utc).isoformat(),
            }

        job_id = submit_background_job(app_obj, "sales_summary_report", _task)
        return jsonify({"job_id": job_id, "status": "queued"}), 202

    @app.route("/jobs/<job_id>", methods=["GET"])
    @require_auth
    @company_required(["admin", "manager", "superuser"])
    def get_background_job(job_id: str):
        with BACKGROUND_JOBS_LOCK:
            row = BACKGROUND_JOBS.get(job_id)
            if not row:
                return jsonify({"error": "Job not found"}), 404
            return jsonify(row)

    # Purchase Bills
    def _normalize_purchase_bill_posting_flags(company_id: int) -> None:
        """
        Repair legacy posting-state drift:
        - If posted_at is set, posted must be True.
        - If posted is True but posted_at is empty, backfill posted_at.
        """
        changed = False
        rows_mark_posted = PurchaseBill.query.filter(
            PurchaseBill.company_id == company_id,
            or_(PurchaseBill.posted.is_(False), PurchaseBill.posted.is_(None)),
            PurchaseBill.posted_at.isnot(None),
        ).all()
        for bill in rows_mark_posted:
            bill.posted = True
            changed = True

        rows_backfill_posted_at = PurchaseBill.query.filter(
            PurchaseBill.company_id == company_id,
            PurchaseBill.posted.is_(True),
            PurchaseBill.posted_at.is_(None),
        ).all()
        for bill in rows_backfill_posted_at:
            bill.posted_at = bill.created_at or datetime.now(timezone.utc)
            changed = True

        if changed:
            db.session.commit()
    @app.route("/purchase-bills", methods=["GET"])
    @require_auth
    @company_required()
    def list_purchase_bills():
        _normalize_purchase_bill_posting_flags(g.current_company.id)
        include_payments = request.args.get("include_payments") == "1"
        include_bulk = request.args.get("include_bulk") == "1"
        supplier_term = (request.args.get("supplier") or request.args.get("supplier_name") or "").strip()
        number_term = (request.args.get("number") or request.args.get("bill_number") or "").strip()
        product_term = (request.args.get("product") or request.args.get("product_name") or "").strip()
        status_term = (request.args.get("status") or request.args.get("payment_status") or "").strip().lower()
        approval_term = (request.args.get("approval_status") or "").strip().lower()
        date_from_raw = request.args.get("date_from") or request.args.get("from")
        date_to_raw = request.args.get("date_to") or request.args.get("to")
        po_source = (request.args.get("po_source") or request.args.get("purchase_order_source") or "").strip()

        def _history_source_value(history) -> str:
            if isinstance(history, dict):
                return str(history.get("source") or "")
            if isinstance(history, str):
                try:
                    parsed = json.loads(history)
                    if isinstance(parsed, dict):
                        return str(parsed.get("source") or "")
                except Exception:
                    return ""
            return ""

        def _parse_date(value: str | None, label: str):
            if not value:
                return None
            try:
                return date.fromisoformat(value[:10])
            except Exception:
                raise ValueError(f"Invalid {label} date")

        try:
            date_from = _parse_date(date_from_raw, "from")
            date_to = _parse_date(date_to_raw, "to")
        except ValueError as exc:
            return jsonify({"error": str(exc)}), 400

        base_query = PurchaseBill.query.filter_by(company_id=g.current_company.id)
        if po_source:
            po_candidates = PurchaseOrder.query.filter_by(company_id=g.current_company.id).all()
            bill_ids = [
                po.purchase_bill_id
                for po in po_candidates
                if po.purchase_bill_id
                and _history_source_value(getattr(po, "history", None)) == po_source
            ]
            if bill_ids:
                base_query = base_query.filter(PurchaseBill.id.in_(bill_ids))
            else:
                base_query = base_query.filter(text("1=0"))
        if approval_term:
            base_query = base_query.filter(func.lower(PurchaseBill.approval_status) == approval_term)
        if supplier_term:
            base_query = base_query.join(Supplier, PurchaseBill.supplier_id == Supplier.id).filter(
                Supplier.name.ilike(f"%{supplier_term}%")
            )
        if number_term:
            base_query = base_query.filter(PurchaseBill.bill_number.ilike(f"%{number_term}%"))
        if product_term:
            base_query = (
                base_query.join(PurchaseBillItem, PurchaseBill.id == PurchaseBillItem.purchase_bill_id)
                .join(Product, PurchaseBillItem.product_id == Product.id)
                .filter(Product.name.ilike(f"%{product_term}%"))
                .distinct()
            )
        if date_from:
            base_query = base_query.filter(PurchaseBill.purchase_date >= date_from)
        if date_to:
            base_query = base_query.filter(PurchaseBill.purchase_date <= date_to)
        if status_term:
            posted_expr = or_(PurchaseBill.posted.is_(True), PurchaseBill.posted_at.isnot(None))
            if status_term in {"posted", "posted_to_inventory", "inventory_posted"}:
                base_query = base_query.filter(posted_expr)
            elif status_term in {"unposted", "not_posted", "inventory_unposted"}:
                base_query = base_query.filter(~posted_expr)
            elif status_term in {"all"}:
                pass
            elif status_term in {"paid", "fully_paid", "partial", "partially_paid", "partially-paid", "partial_paid", "unpaid", "not_paid"}:
                paid_subq = (
                    db.session.query(
                        PurchaseBillPayment.purchase_bill_id.label("bill_id"),
                        func.coalesce(func.sum(PurchaseBillPayment.amount), 0).label("paid_sum"),
                    )
                    .filter(PurchaseBillPayment.company_id == g.current_company.id)
                    .group_by(PurchaseBillPayment.purchase_bill_id)
                    .subquery()
                )
                paid_expr = func.coalesce(paid_subq.c.paid_sum, 0)
                base_query = base_query.outerjoin(paid_subq, PurchaseBill.id == paid_subq.c.bill_id)
                if status_term in {"paid", "fully_paid"}:
                    base_query = base_query.filter(paid_expr >= PurchaseBill.gross_total)
                elif status_term in {"partial", "partially_paid", "partially-paid", "partial_paid"}:
                    base_query = base_query.filter(paid_expr > 0, paid_expr < PurchaseBill.gross_total)
                elif status_term in {"unpaid", "not_paid"}:
                    base_query = base_query.filter(paid_expr <= 0)
            else:
                return jsonify({"error": "Invalid status filter"}), 400

        # By default, hide pending bulk-upload purchase bills from the regular list
        # (they should appear only in the Bulk Upload section until approved).
        if not include_bulk and not po_source:
            po_candidates = PurchaseOrder.query.filter_by(company_id=g.current_company.id).all()
            bulk_bill_ids = [
                po.purchase_bill_id
                for po in po_candidates
                if po.purchase_bill_id
                and _history_source_value(getattr(po, "history", None)) == "bulk_upload_excel"
            ]
            if bulk_bill_ids:
                base_query = base_query.filter(
                    or_(
                        ~PurchaseBill.id.in_(bulk_bill_ids),
                        func.lower(PurchaseBill.approval_status) == "approved",
                    )
                )

        base_query = base_query.order_by(PurchaseBill.purchase_date.desc(), PurchaseBill.created_at.desc())
        bills, meta = paginate_query(base_query)
        payload = []
        totals_changed = False
        for bill in bills:
            if bill.items and _sync_purchase_bill_totals(g.current_company, bill):
                totals_changed = True
            data = bill.to_dict(include_items=False, include_payments=include_payments)
            data["item_count"] = len(bill.items or [])
            payload.append(data)
        if totals_changed:
            db.session.commit()
        return jsonify({"data": payload, "pagination": meta})

    @app.route("/sales-order-due", methods=["GET", "OPTIONS"])
    @app.route("/sales-order-due/", methods=["GET", "OPTIONS"])
    @require_auth
    @company_required()
    def list_sales_order_due():
        if request.method == "OPTIONS":
            return ("", 204)
        customer_term = (request.args.get("customer") or request.args.get("customer_name") or "").strip()
        number_term = (request.args.get("number") or request.args.get("bill_number") or "").strip()
        status_term = (request.args.get("status") or request.args.get("payment_status") or "").strip().lower()
        date_from_raw = request.args.get("date_from") or request.args.get("from")
        date_to_raw = request.args.get("date_to") or request.args.get("to")

        def _parse_date(value: str | None, label: str):
            if not value:
                return None
            try:
                return date.fromisoformat(value[:10])
            except Exception:
                raise ValueError(f"Invalid {label} date")

        try:
            date_from = _parse_date(date_from_raw, "from")
            date_to = _parse_date(date_to_raw, "to")
        except ValueError as exc:
            return jsonify({"error": str(exc)}), 400

        base_query = Sale.query.filter(
            Sale.company_id == g.current_company.id,
            Sale.source == "sales_order_delivered",
        )
        if customer_term:
            base_query = base_query.join(Customer, Sale.customer_id == Customer.id).filter(
                Customer.name.ilike(f"%{customer_term}%")
            )
        if number_term:
            base_query = base_query.filter(Sale.sale_number.ilike(f"%{number_term}%"))
        if date_from:
            base_query = base_query.filter(Sale.sale_date >= date_from)
        if date_to:
            base_query = base_query.filter(Sale.sale_date <= date_to)

        due_expr = func.coalesce(Sale.due_amount, 0.0)
        paid_expr = func.coalesce(Sale.paid_amount, 0.0)
        total_expr = func.coalesce(Sale.total_amount, 0.0)
        if status_term in {"paid", "fully_paid"}:
            base_query = base_query.filter(due_expr <= 0.0001)
        elif status_term in {"partial", "partially_paid", "partially-paid", "partial_paid"}:
            base_query = base_query.filter(paid_expr > 0.0, due_expr > 0.0001)
        elif status_term in {"unpaid", "not_paid"}:
            base_query = base_query.filter(paid_expr <= 0.0, due_expr > 0.0001)
        elif status_term in {"due", ""}:
            base_query = base_query.filter(due_expr > 0.0001)
        elif status_term in {"all"}:
            pass
        else:
            return jsonify({"error": "Invalid status filter"}), 400

        base_query = base_query.order_by(Sale.sale_date.desc(), Sale.created_at.desc())
        sales, meta = paginate_query(base_query)
        rows = []
        for sale in sales:
            paid_amount = round(float(sale.paid_amount or 0.0), 2)
            total_amount = round(float(sale.total_amount or 0.0), 2)
            due_amount = round(float(sale.due_amount or max(0.0, total_amount - paid_amount)), 2)
            if due_amount <= 0:
                payment_state = "paid"
            elif paid_amount > 0:
                payment_state = "partial"
            else:
                payment_state = "unpaid"
            rows.append(
                {
                    "id": sale.id,
                    "sale_number": sale.sale_number,
                    "customer_id": sale.customer_id,
                    "customer_name": sale.customer.name if sale.customer else "Walk-in",
                    "sale_date": sale.sale_date.isoformat() if sale.sale_date else None,
                    "total_amount": total_amount,
                    "paid_amount": paid_amount,
                    "due_amount": due_amount,
                    "payment_status": payment_state,
                    "source": sale.source,
                }
            )
        return jsonify({"data": rows, "pagination": meta})

    @app.route("/purchase-bills/unposted/count", methods=["GET"])
    @require_auth
    @company_required()
    def purchase_bills_unposted_count():
        _normalize_purchase_bill_posting_flags(g.current_company.id)
        count = PurchaseBill.query.filter(
            PurchaseBill.company_id == g.current_company.id,
            or_(PurchaseBill.posted.is_(False), PurchaseBill.posted.is_(None)),
            PurchaseBill.posted_at.is_(None),
        ).count()
        return jsonify({"count": int(count)})

    @app.route("/purchase-bills/<int:bill_id>/rematch", methods=["POST", "PUT"])
    @require_auth
    @company_required(["admin", "manager", "superuser", "superadmin"])
    def rematch_purchase_bill(bill_id: int):
        bill = PurchaseBill.query.get_or_404(bill_id)
        if bill.company_id != g.current_company.id:
            return jsonify({"error": "Forbidden"}), 403
        if bill.posted:
            return jsonify({"error": "Posted purchase bills cannot be rematched"}), 400

        po = PurchaseOrder.query.filter_by(company_id=g.current_company.id, purchase_bill_id=bill.id).first()
        mapping = {}
        if po and isinstance(po.history, dict):
            mapping = po.history.get("mapping") or {}

        def normalize_header(val: str) -> str:
            return "".join(ch for ch in str(val or "").lower() if ch.isalnum())

        header_map = {
            "product": {"product", "productname", "item", "itemname", "name"},
            "uom": {"uom", "unit", "unitofmeasure", "unitofmeasurement", "unitname", "packing"},
            "qty": {"qty", "quantity", "totalqty", "totalquantity"},
            "ordered_qty": {"orderedqty", "orderedquantity", "ordered", "orderqty", "orderquantity"},
            "free_qty": {"free", "freeqty", "freequantity"},
            "mrp": {"mrp", "price", "retailprice", "mrpprice"},
            "cost_price": {"costprice", "cost", "rate", "costrate"},
            "discount_percent": {"discountpercent", "discountpct", "discpercent", "discountpercentages"},
            "discount_total": {"discount", "discounttotal", "discamt", "discountamount"},
            "tax": {"tax", "vat", "taxamount", "vatamount"},
            "batch_number": {"batch", "batchnumber", "lot", "lotnumber", "serial", "batchserial"},
            "expiry_date": {"expiry", "expirydate", "expdate", "exp"},
        }

        def _raw_value(row: dict, key: str):
            header = mapping.get(key)
            if header and header in row:
                return row.get(header)
            norm_to_val = {normalize_header(k): v for k, v in (row or {}).items()}
            for alias in header_map.get(key, set()):
                if alias in norm_to_val:
                    return norm_to_val[alias]
            return None

        def parse_float(val):
            if val is None:
                return None
            if isinstance(val, (int, float)):
                return float(val)
            raw = str(val).strip()
            if raw == "":
                return None
            raw = raw.replace(",", "").replace("%", "")
            try:
                return float(raw)
            except Exception:
                return None

        def parse_expiry(val):
            if not val:
                return None
            if isinstance(val, (datetime, date)):
                return val.date() if isinstance(val, datetime) else val
            s = str(val).strip()
            for fmt in ("%Y-%m-%d", "%d/%m/%Y", "%m/%d/%Y"):
                try:
                    return datetime.strptime(s, fmt).date()
                except Exception:
                    continue
            for fmt in ("%m/%Y", "%m-%Y"):
                try:
                    dt = datetime.strptime(s, fmt)
                    return date(dt.year, dt.month, 1)
                except Exception:
                    continue
            try:
                return _parse_expiry_date_allow_month_year(s)
            except Exception:
                return None

        totals_items = []
        for item in bill.items or []:
            raw_row = item.raw_row_data or {}

            ordered_val = parse_float(_raw_value(raw_row, "ordered_qty"))
            free_val = parse_float(_raw_value(raw_row, "free_qty"))
            qty_val = parse_float(_raw_value(raw_row, "qty"))
            if ordered_val is not None or free_val is not None:
                item.ordered_qty = float(ordered_val or 0.0)
                item.free_qty = float(free_val or 0.0)
            elif qty_val is not None:
                item.ordered_qty = float(qty_val)
                item.free_qty = float(item.free_qty or 0.0)

            cost_val = parse_float(_raw_value(raw_row, "cost_price"))
            if cost_val is not None:
                item.cost_price = float(cost_val)

            mrp_val = parse_float(_raw_value(raw_row, "mrp"))
            if mrp_val is not None:
                item.mrp = float(mrp_val)
                item.price = float(mrp_val)

            disc_pct_val = parse_float(_raw_value(raw_row, "discount_percent"))
            disc_total_val = parse_float(_raw_value(raw_row, "discount_total"))
            if disc_total_val is not None:
                item.discount = float(disc_total_val)
            elif disc_pct_val is not None and item.cost_price and item.ordered_qty:
                subtotal = float(item.ordered_qty or 0.0) * float(item.cost_price or 0.0)
                item.discount = round(subtotal * float(disc_pct_val) / 100.0, 2)

            tax_val = parse_float(_raw_value(raw_row, "tax"))
            if tax_val is not None:
                item.tax_subtotal = float(tax_val)

            batch_val = _raw_value(raw_row, "batch_number")
            if batch_val:
                item.batch_number = str(batch_val).strip()
            expiry_val = parse_expiry(_raw_value(raw_row, "expiry_date"))
            if expiry_val:
                item.expiry_date = expiry_val

            ordered_qty = float(item.ordered_qty or 0.0)
            cost_price = float(item.cost_price or 0.0)
            discount = float(item.discount or 0.0)
            tax_subtotal = float(item.tax_subtotal or 0.0)
            item.line_total = round(ordered_qty * cost_price - discount + tax_subtotal, 2)

            totals_items.append(
                {
                    "product": db.session.get(Product, item.product_id) if item.product_id else None,
                    "ordered_qty": ordered_qty,
                    "free_qty": float(item.free_qty or 0.0),
                    "cost_price": cost_price,
                    "discount": discount,
                    "tax_subtotal": tax_subtotal,
                    "free_vat_percent": float(item.free_vat_percent or 0.0),
                }
            )

        _sync_purchase_bill_totals(g.current_company, bill)

        db.session.commit()
        log_action("purchase_bill_rematched", {"purchase_bill_id": bill.id}, g.current_company.id)
        return jsonify(bill.to_dict(include_items=True))

    @app.route("/purchase-bills/audit/unit-conversion", methods=["GET"])
    @require_auth
    @company_required(["admin", "manager"])
    def audit_purchase_bill_unit_conversion():
        """
        Heuristic audit: compares each posted bill line's expected base quantity (based on unit config/name)
        against the inventory log change recorded at posting time.
        """
        company_id = g.current_company.id
        bills = PurchaseBill.query.filter_by(company_id=company_id, posted=True).order_by(PurchaseBill.id.asc()).all()

        def _implied_factor(name: str | None) -> float | None:
            if not name:
                return None
            n = name.strip().lower()
            if n == "dozen":
                return 12.0
            m = re.match(r"^(\\d+)\\s*'?s\\s*$", n)
            if m:
                try:
                    v = float(m.group(1))
                    return v if v > 0 else None
                except Exception:
                    return None
            return None

        findings = []
        for bill in bills:
            if not bill.posted_at:
                continue
            start = bill.posted_at - timedelta(seconds=2)
            end = bill.posted_at + timedelta(seconds=2)
            for it in bill.items:
                qty_units = float((it.ordered_qty or 0) + (it.free_qty or 0))
                if qty_units <= 0:
                    continue
                if not it.uom:
                    continue
                unit = Unit.query.filter(Unit.company_id == company_id, func.lower(Unit.name) == func.lower(it.uom)).first()
                implied = _implied_factor(unit.name if unit else None)
                stored = float(unit.conversion_to_base or 0.0) if unit else 0.0
                expected_base = qty_units
                if implied and implied > 0:
                    expected_base = int(round(qty_units * implied))
                elif stored and stored > 0:
                    expected_base = int(round(qty_units * stored))

                log = (
                    InventoryLog.query.filter(
                        InventoryLog.company_id == company_id,
                        InventoryLog.product_id == it.product_id,
                        InventoryLog.reason == "purchase_bill",
                        InventoryLog.created_at >= start,
                        InventoryLog.created_at <= end,
                    )
                    .order_by(InventoryLog.created_at.asc())
                    .first()
                )
                if not log:
                    continue
                actual = int(log.change or 0)
                if actual != expected_base:
                    findings.append(
                        {
                            "bill_id": bill.id,
                            "posted_at": bill.posted_at.isoformat(),
                            "product_id": it.product_id,
                            "product_name": it.product.name if it.product else None,
                            "uom": it.uom,
                            "qty": qty_units,
                            "unit_id": unit.id if unit else None,
                            "unit_conversion_to_base": stored,
                            "unit_implied_factor": implied,
                            "expected_base_qty": expected_base,
                            "logged_change": actual,
                            "delta_needed": expected_base - actual,
                            "log_id": log.id,
                        }
                    )

        return jsonify({"checked_bills": len(bills), "mismatches": findings})

    @app.route("/purchase-bills/<int:bill_id>", methods=["GET"])
    @require_auth
    @company_required()
    def get_purchase_bill(bill_id: int):
        _normalize_purchase_bill_posting_flags(g.current_company.id)
        bill = PurchaseBill.query.get_or_404(bill_id)
        if bill.company_id != g.current_company.id:
            return jsonify({"error": "Forbidden"}), 403
        if bill.items and _sync_purchase_bill_totals(g.current_company, bill):
            db.session.commit()
        return jsonify(bill.to_dict(include_items=True, include_payments=True))

    @app.route("/purchase-bills/<int:bill_id>/chatter", methods=["GET"])
    @require_auth
    @company_required()
    def get_purchase_bill_chatter(bill_id: int):
        bill = PurchaseBill.query.get_or_404(bill_id)
        if bill.company_id != g.current_company.id:
            return jsonify({"error": "Forbidden"}), 403

        def _history_source_value(history) -> str:
            if isinstance(history, dict):
                return str(history.get("source") or "")
            if isinstance(history, str):
                try:
                    parsed = json.loads(history)
                    if isinstance(parsed, dict):
                        return str(parsed.get("source") or "")
                except Exception:
                    return ""
            return ""

        po = (
            PurchaseOrder.query.filter_by(company_id=g.current_company.id, purchase_bill_id=bill.id)
            .order_by(PurchaseOrder.id.desc())
            .first()
        )
        po_number = po.number if po else None
        bulk_po_number = None
        if po and _history_source_value(getattr(po, "history", None)) == "bulk_upload_excel":
            bulk_po_number = po.number

        item_payload = []
        for item in bill.items or []:
            inv_query = InventoryBatch.query.filter(
                InventoryBatch.company_id == g.current_company.id,
                InventoryBatch.product_id == item.product_id,
            )
            if item.batch_number:
                inv_query = inv_query.filter(InventoryBatch.batch_number == item.batch_number)
            if item.expiry_date:
                inv_query = inv_query.filter(InventoryBatch.expiry_date == item.expiry_date)
            inv = (
                inv_query.order_by(
                    InventoryBatch.arrival_at.desc().nullslast(), InventoryBatch.created_at.desc()
                ).first()
            )
            item_payload.append(
                {
                    "product_id": item.product_id,
                    "product_name": item.product.name if item.product else None,
                    "batch_number": item.batch_number,
                    "expiry_date": item.expiry_date.isoformat() if item.expiry_date else None,
                    "ordered_qty": float(item.ordered_qty or 0.0),
                    "free_qty": float(item.free_qty or 0.0),
                    "in_inventory": bool(inv),
                    "inventory_batch_id": inv.id if inv else None,
                    "inventory_qty_base": int(inv.qty_base or 0) if inv else 0,
                }
            )

        return jsonify(
            {
                "purchase_bill_id": bill.id,
                "purchase_bill_number": bill.bill_number,
                "purchase_bill_created_on": _format_dt(bill.created_at),
                "purchase_bill_created_by": _user_display_name(bill.created_by, bill.created_by_user_id),
                "purchase_bill_date": bill.purchase_date.isoformat() if bill.purchase_date else None,
                "purchase_bill_posted": bool(bill.posted) or bill.posted_at is not None,
                "po_number": po_number,
                "bulk_po_number": bulk_po_number,
                "po_created_on": _format_dt(po.created_at) if po else None,
                "po_created_by": _user_display_name(po.created_by, po.created_by_user_id) if po else None,
                "items_in_inventory": item_payload,
            }
        )

    @app.route("/purchase-bills/<int:bill_id>/approve", methods=["POST"])
    @require_auth
    @company_required(["admin", "manager", "superuser", "superadmin"])
    def approve_purchase_bill(bill_id: int):
        def _purchase_bill_mrp_below_cost_lines(pb: PurchaseBill) -> list[dict]:
            issues: list[dict] = []
            for idx, item in enumerate(pb.items or []):
                try:
                    cost_price = float(item.cost_price or 0.0)
                except (TypeError, ValueError):
                    cost_price = 0.0
                try:
                    mrp = float(item.mrp or item.price or 0.0)
                except (TypeError, ValueError):
                    mrp = 0.0
                if cost_price > 0 and mrp > 0 and mrp < cost_price:
                    issues.append(
                        {
                            "line": idx + 1,
                            "product_id": int(item.product_id or 0) if item.product_id else None,
                            "product_name": item.product.name if item.product else None,
                            "batch_number": item.batch_number,
                            "uom": item.uom,
                            "cost_price": round(cost_price, 4),
                            "mrp": round(mrp, 4),
                        }
                    )
            return issues

        bill = PurchaseBill.query.get_or_404(bill_id)
        if bill.company_id != g.current_company.id:
            return jsonify({"error": "Forbidden"}), 403
        role_lc = (g.company_role or "").strip().lower()
        if g.current_user.role not in ROLE_PLATFORM_ADMINS and role_lc not in {"admin", "manager", "superuser", "superadmin"}:
            return jsonify({"error": "Forbidden"}), 403
        approval_status = (bill.approval_status or "approved").lower()
        if approval_status == "approved":
            return jsonify(bill.to_dict(include_items=True))
        warning_lines = _purchase_bill_mrp_below_cost_lines(bill)
        if warning_lines:
            return jsonify(
                {
                    "error": "Purchase bill cannot be approved because some lines have MRP lower than cost price.",
                    "mrp_below_cost_lines": warning_lines,
                }
            ), 400
        bill.approval_status = "approved"
        bill.approved_at = datetime.now(timezone.utc)
        bill.approved_by_user_id = int(getattr(g.current_user, "id", 0) or 0) or None
        posted_now = _post_purchase_bill_if_ready(bill)
        db.session.commit()
        if posted_now:
            socketio.emit(
                "inventory:update",
                {"type": "purchase_bill_posted", "purchase_bill": bill.to_dict(include_items=True), "company_id": g.current_company.id},
            )
            log_action(
                "purchase_bill_posted",
                {"purchase_bill_id": bill.id, "supplier_id": bill.supplier_id, "gross_total": bill.gross_total},
                g.current_company.id,
            )
        log_action(
            "purchase_bill_approved",
            {"purchase_bill_id": bill.id, "supplier_id": bill.supplier_id},
            g.current_company.id,
        )
        return jsonify(bill.to_dict(include_items=True))

    @app.route("/purchase-bills/<int:bill_id>/payments", methods=["POST"])
    @require_auth
    @company_required(["admin", "manager"])
    def pay_purchase_bill(bill_id: int):
        bill = PurchaseBill.query.get_or_404(bill_id)
        if bill.company_id != g.current_company.id:
            return jsonify({"error": "Forbidden"}), 403
        role_lc = (g.company_role or "").strip().lower()
        if g.current_user.role not in ROLE_PLATFORM_ADMINS and role_lc not in {"admin", "manager"}:
            return jsonify({"error": "Forbidden"}), 403
        if (bill.approval_status or "approved").lower() != "approved":
            return jsonify({"error": "Purchase bill is pending approval"}), 400
        data = request.get_json() or {}
        try:
            amount = float(data.get("amount") or 0.0)
        except (TypeError, ValueError):
            return jsonify({"error": "Invalid amount"}), 400
        if amount <= 0:
            return jsonify({"error": "Amount must be greater than 0"}), 400

        raw_date = data.get("payment_date")
        if raw_date:
            try:
                payment_date = datetime.fromisoformat(str(raw_date)).date()
            except ValueError:
                return jsonify({"error": "Invalid payment_date (expected YYYY-MM-DD)"}), 400
        else:
            payment_date = _today_ad()

        payment_mode_id = data.get("payment_mode_id")
        payment_mode = None
        if payment_mode_id:
            try:
                payment_mode_id = int(payment_mode_id)
            except (TypeError, ValueError):
                return jsonify({"error": "Invalid payment_mode_id"}), 400
            payment_mode = PaymentMode.query.get(payment_mode_id)
            if not payment_mode or payment_mode.company_id != g.current_company.id:
                return jsonify({"error": "Payment mode not found"}), 404
            supports_party = (
                bool(payment_mode.allow_party)
                if payment_mode.allow_party is not None
                else (str(payment_mode.category or "sales").strip().lower() == "party")
            )
            if not supports_party:
                return jsonify({"error": "Payment mode must be a party payment mode"}), 400

        paid_amount = sum(float(p.amount or 0.0) for p in (bill.payments or []))
        gross_total = float(bill.gross_total or 0.0)
        if paid_amount + amount > gross_total + 0.0001:
            return jsonify({"error": "Amount exceeds bill due"}), 400

        payment = PurchaseBillPayment(
            company_id=g.current_company.id,
            purchase_bill_id=bill.id,
            supplier_id=bill.supplier_id,
            payment_date=payment_date,
            amount=amount,
            payment_mode_id=payment_mode.id if payment_mode else None,
            payment_mode_name=payment_mode.name if payment_mode else str(data.get("payment_mode_name") or ""),
            reference_no=str(data.get("reference_no") or ""),
            notes=str(data.get("notes") or ""),
            created_by_user_id=int(getattr(g.current_user, "id", 0) or 0) or None,
        )
        db.session.add(payment)
        db.session.flush()

        credit_name = "Cash"
        if payment_mode:
            if payment_mode.account_id:
                account = Account.query.get(payment_mode.account_id)
                if account and account.company_id == g.current_company.id:
                    credit_name = account.name
            if credit_name == "Cash":
                credit_name = payment_mode.name or credit_name
        else:
            credit_name = _resolve_payment_account_name(g.current_company.id, payment.payment_mode_name, "Cash")
        bill_label = bill.bill_number or f"#{bill.id}"
        payable_name = _purchase_payable_account_name(g.current_company.id, bill.supplier)
        _post_double_entry(
            g.current_company.id,
            payable_name,
            "liability",
            credit_name,
            "asset",
            amount,
            "purchase_bill_payment",
            payment.id,
            f"Payment for Purchase Bill {bill_label}",
        )
        db.session.commit()
        log_action(
            "purchase_bill_payment_created",
            {"bill_id": bill.id, "amount": amount, "payment_mode": payment.payment_mode_name},
            g.current_company.id,
        )
        return jsonify(bill.to_dict(include_items=True, include_payments=True))

    @app.route("/sales/<int:sale_id>/payments", methods=["GET", "POST"])
    @require_auth
    @company_required()
    def receive_sales_order_payment(sale_id: int):
        sale = Sale.query.get_or_404(sale_id)
        if sale.company_id != g.current_company.id:
            return jsonify({"error": "Forbidden"}), 403

        if request.method == "GET":
            payments = (
                SalePayment.query.filter_by(company_id=g.current_company.id, sale_id=sale.id)
                .order_by(SalePayment.payment_date.desc(), SalePayment.created_at.desc())
                .all()
            )
            # For regular sales we may not have explicit SalePayment rows (older records).
            # If the sale is fully/partially paid, return a synthetic payment row so UI can display the payment mode.
            if not payments and float(sale.paid_amount or 0.0) > 0.0001:
                sale_dt = None
                if sale.sale_date:
                    sale_dt = sale.sale_date
                elif sale.created_at:
                    try:
                        sale_dt = sale.created_at.date()
                    except Exception:
                        sale_dt = None
                payments_payload = [
                    {
                        "id": None,
                        "payment_date": sale_dt.isoformat() if sale_dt else None,
                        "amount": round(float(sale.paid_amount or 0.0), 2),
                        "payment_mode_name": str(sale.payment_method or "") or "",
                        "reference_no": "",
                        "notes": "synthetic",
                        "created_by": _user_display_name(sale.created_by),
                        "created_at": _format_dt(sale.created_at),
                    }
                ]
            else:
                payments_payload = [p.to_dict() for p in payments]
            return jsonify(
                {
                    "sale_id": sale.id,
                    "sale_number": sale.sale_number,
                    "customer_name": sale.customer.name if sale.customer else "Walk-in",
                    "total_amount": round(float(sale.total_amount or 0.0), 2),
                    "paid_amount": round(float(sale.paid_amount or 0.0), 2),
                    "due_amount": round(float(sale.due_amount or 0.0), 2),
                    "payments": payments_payload,
                }
            )

        # POST is only allowed for delivered sales orders (due collections), and only for admin/manager/superuser.
        if str(getattr(g.current_user, "role", "") or "").lower() not in {"admin", "manager", "superuser", "superadmin"}:
            return jsonify({"error": "Forbidden"}), 403
        if (sale.source or "").strip().lower() != "sales_order_delivered":
            return jsonify({"error": "Payments are allowed only for delivered sales orders"}), 400

        data = request.get_json() or {}
        try:
            amount = float(data.get("amount") or 0.0)
        except (TypeError, ValueError):
            return jsonify({"error": "Invalid amount"}), 400
        if amount <= 0:
            return jsonify({"error": "Amount must be greater than 0"}), 400

        due_amount = float(sale.due_amount or 0.0)
        if due_amount <= 0:
            return jsonify({"error": "This sales order has no due amount"}), 400
        if amount > due_amount + 0.0001:
            return jsonify({"error": "Amount exceeds due amount"}), 400

        raw_date = data.get("payment_date")
        if raw_date:
            try:
                payment_date = datetime.fromisoformat(str(raw_date)).date()
            except ValueError:
                return jsonify({"error": "Invalid payment_date (expected YYYY-MM-DD)"}), 400
        else:
            payment_date = _today_ad()

        payment_mode_id = data.get("payment_mode_id")
        payment_mode = None
        if payment_mode_id:
            try:
                payment_mode_id = int(payment_mode_id)
            except (TypeError, ValueError):
                return jsonify({"error": "Invalid payment_mode_id"}), 400
            payment_mode = PaymentMode.query.get(payment_mode_id)
            if not payment_mode or payment_mode.company_id != g.current_company.id:
                return jsonify({"error": "Payment mode not found"}), 404

        payment = SalePayment(
            company_id=g.current_company.id,
            sale_id=sale.id,
            customer_id=sale.customer_id,
            payment_date=payment_date,
            amount=amount,
            payment_mode_id=payment_mode.id if payment_mode else None,
            payment_mode_name=payment_mode.name if payment_mode else str(data.get("payment_mode_name") or ""),
            reference_no=str(data.get("reference_no") or ""),
            notes=str(data.get("notes") or ""),
            created_by_user_id=int(getattr(g.current_user, "id", 0) or 0) or None,
        )
        db.session.add(payment)

        sale.paid_amount = round(float(sale.paid_amount or 0.0) + amount, 2)
        sale.due_amount = round(max(0.0, float(sale.total_amount or 0.0) - float(sale.paid_amount or 0.0)), 2)
        sale.payment_status = "paid" if float(sale.due_amount or 0.0) <= 0.0001 else "due"

        debit_name = "Cash"
        if payment_mode:
            if payment_mode.account_id:
                account = Account.query.get(payment_mode.account_id)
                if account and account.company_id == g.current_company.id:
                    debit_name = account.name
            if debit_name == "Cash":
                debit_name = payment_mode.name or debit_name
        else:
            debit_name = _resolve_payment_account_name(g.current_company.id, payment.payment_mode_name, "Cash")

        db.session.flush()
        sale_label = sale.sale_number or f"#{sale.id}"
        receivable_name = _sale_receivable_account_name(g.current_company.id, sale.customer)
        _post_double_entry(
            g.current_company.id,
            debit_name,
            "asset",
            receivable_name,
            "asset",
            amount,
            "sale_payment",
            payment.id,
            f"Receipt for Sales Order {sale_label}",
        )

        db.session.commit()
        log_action(
            "sale_payment_received",
            {"sale_id": sale.id, "payment_id": payment.id, "amount": amount, "payment_mode": payment.payment_mode_name},
            g.current_company.id,
        )
        return jsonify(
            {
                "sale": _sale_payload_with_image_urls(sale),
                "payment": payment.to_dict(),
            }
        )

    @app.route("/purchase-bills/<int:bill_id>/print", methods=["GET"])
    @require_auth
    @company_required()
    def print_purchase_bill(bill_id: int):
        bill = PurchaseBill.query.get_or_404(bill_id)
        if bill.company_id != g.current_company.id:
            return jsonify({"error": "Forbidden"}), 403
        totals_changed = False
        if bill.items and _sync_purchase_bill_totals(g.current_company, bill):
            totals_changed = True
        paper_size = (request.args.get("paper_size") or "").strip() or None
        if not bill.bill_number:
            try:
                bill.bill_number = _make_purchase_bill_number(
                    g.current_company.id, bill.purchase_date or _today_ad()
                )
                totals_changed = True
            except Exception:
                db.session.rollback()
        if totals_changed:
            try:
                db.session.commit()
            except Exception:
                db.session.rollback()
        try:
            pdf = _render_purchase_bill_pdf(g.current_company, bill, page_size=paper_size)
            filename = f"purchase-bill-{bill.bill_number or bill.id}.pdf"
            return _build_pdf_response(pdf, filename)
        except RuntimeError as exc:
            return jsonify({"error": str(exc)}), 501

    @app.route("/purchase-bills/<int:bill_id>/cheque-print", methods=["GET"])
    @require_auth
    @company_required(["admin", "manager", "superuser"])
    def print_purchase_bill_cheque(bill_id: int):
        bill = PurchaseBill.query.get_or_404(bill_id)
        if bill.company_id != g.current_company.id:
            return jsonify({"error": "Forbidden"}), 403

        amount_raw = (request.args.get("amount") or "").strip()
        payment_date_raw = (request.args.get("payment_date") or "").strip()
        cheque_date_raw = (request.args.get("cheque_date") or "").strip()
        payment_mode_id = (request.args.get("payment_mode_id") or "").strip()
        ac_payee_raw = (request.args.get("ac_payee") or "").strip().lower()
        ac_payee = ac_payee_raw in {"1", "true", "yes", "y"}

        amount_value = 0.0
        if amount_raw:
            try:
                amount_value = float(amount_raw)
            except Exception:
                return jsonify({"error": "Invalid amount"}), 400
        elif bill.due_amount:
            amount_value = float(bill.due_amount or 0.0)
        else:
            amount_value = float(bill.gross_total or 0.0)

        payment_date = None
        if payment_date_raw:
            try:
                payment_date = datetime.fromisoformat(payment_date_raw).date()
            except Exception:
                payment_date = None

        cheque_date = None
        if cheque_date_raw:
            try:
                cheque_date = datetime.fromisoformat(cheque_date_raw).date()
            except Exception:
                cheque_date = None

        payment_mode = None
        if payment_mode_id:
            try:
                payment_mode = PaymentMode.query.get(int(payment_mode_id))
            except Exception:
                payment_mode = None
            if payment_mode and payment_mode.company_id != g.current_company.id:
                payment_mode = None
        else:
            return jsonify({"error": "Payment mode is required for cheque printing"}), 400
        template = None
        if payment_mode:
            template = ChequeTemplate.query.filter_by(
                company_id=g.current_company.id, payment_mode_id=payment_mode.id
            ).first()
        if payment_mode and not template:
            return jsonify({"error": "No cheque template set for the selected payment mode"}), 400

        try:
            pdf = _render_purchase_bill_cheque_pdf(
                g.current_company,
                bill,
                amount_value,
                payment_date,
                payment_mode,
                template,
                ac_payee,
                cheque_date,
            )
            filename = f"purchase-bill-{bill.bill_number or bill.id}-cheque.pdf"
            return _build_pdf_response(pdf, filename)
        except RuntimeError as exc:
            return jsonify({"error": str(exc)}), 501

    @app.route("/purchase-bills/<int:bill_id>/post", methods=["POST"])
    @require_auth
    @company_required(["admin", "manager"])
    def post_purchase_bill(bill_id: int):
        bill = PurchaseBill.query.get_or_404(bill_id)
        if bill.company_id != g.current_company.id:
            return jsonify({"error": "Forbidden"}), 403
        role_lc = (g.company_role or "").strip().lower()
        if g.current_user.role not in ROLE_PLATFORM_ADMINS and role_lc not in {"admin", "manager"}:
            return jsonify({"error": "Forbidden"}), 403
        if (bill.approval_status or "approved").lower() != "approved":
            return jsonify({"error": "Purchase bill is pending approval"}), 400
        if bill.posted:
            return jsonify({"error": "Purchase bill already posted"}), 400
        post_ts = datetime.now(timezone.utc)
        try:
            _sync_purchase_bill_totals(g.current_company, bill)
        except Exception:
            pass
        # Set posted_at before applying inventory so batches get a stable arrival timestamp.
        bill.posted_at = post_ts
        try:
            _apply_purchase_bill_to_inventory(g.current_company.id, bill, reason="purchase_bill")
        except ValueError as exc:
            db.session.rollback()
            return jsonify({"error": str(exc)}), 400

        total_cost = float(bill.gross_total or 0.0)

        bill.posted = True
        bill.posted_at = bill.posted_at or post_ts
        product_ids = {int(it.product_id) for it in (bill.items or []) if getattr(it, "product_id", None)}
        if product_ids:
            _relink_pending_backdated_sales(company_id=g.current_company.id, product_ids=product_ids)
        if total_cost > 0:
            payable_name = _purchase_payable_account_name(g.current_company.id, bill.supplier)
            _post_double_entry(
                g.current_company.id,
                "Inventory",
                "asset",
                payable_name,
                "liability",
                total_cost,
                "purchase_bill",
                bill.id,
                f"Purchase bill #{bill.id}",
            )
        db.session.commit()
        socketio.emit(
            "inventory:update",
            {"type": "purchase_bill_posted", "purchase_bill": bill.to_dict(include_items=True), "company_id": g.current_company.id},
        )
        log_action(
            "purchase_bill_posted",
            {"purchase_bill_id": bill.id, "supplier_id": bill.supplier_id, "gross_total": bill.gross_total},
            g.current_company.id,
        )
        return jsonify(bill.to_dict(include_items=True))

    @app.route("/purchase-bills/<int:bill_id>", methods=["PUT"])
    @require_auth
    @company_required(["admin", "manager"])
    def update_purchase_bill(bill_id: int):
        bill = PurchaseBill.query.get_or_404(bill_id)
        if bill.company_id != g.current_company.id:
            return jsonify({"error": "Forbidden"}), 403
        if bill.posted:
            return jsonify({"error": "Posted purchase bills cannot be edited"}), 400

        data = request.get_json() or {}
        supplier_id = data.get("supplier_id")
        if not supplier_id:
            return jsonify({"error": "Supplier is required"}), 400
        supplier = Supplier.query.get(supplier_id)
        if not supplier or supplier.company_id != g.current_company.id:
            return jsonify({"error": "Supplier not found"}), 404

        purchase_date_raw = data.get("purchase_date") or data.get("date_of_purchase") or data.get("date")
        if purchase_date_raw:
            try:
                purchase_date = datetime.fromisoformat(str(purchase_date_raw)).date()
            except ValueError:
                return jsonify({"error": "Invalid purchase_date (expected YYYY-MM-DD)"}), 400
        else:
            purchase_date = bill.purchase_date or _today_ad()

        items = data.get("items") or []
        if not isinstance(items, list) or not items:
            return jsonify({"error": "Purchase bill items are required"}), 400

        vat_percent = float(getattr(g.current_company, "vat_purchase_percent", 0.0) or 0.0)
        vat_rate = max(0.0, vat_percent)

        # Replace items (unposted only)
        PurchaseBillItem.query.filter_by(purchase_bill_id=bill.id).delete(synchronize_session=False)
        bill.supplier = supplier
        bill.purchase_date = purchase_date
        bill.bill_number = _make_purchase_bill_number(g.current_company.id, purchase_date)

        gross_total = 0.0
        totals_items: list[dict] = []
        vat_percent = float(getattr(g.current_company, "vat_purchase_percent", 0.0) or 0.0)
        vat_rate = max(0.0, vat_percent)
        totals_items: list[dict] = []
        vat_percent = float(getattr(g.current_company, "vat_purchase_percent", 0.0) or 0.0)
        vat_rate = max(0.0, vat_percent)
        added_any = False
        for idx, item in enumerate(items):
            product_id = item.get("product_id")
            if not product_id:
                db.session.rollback()
                return jsonify({"error": f"Line {idx + 1}: product_id is required"}), 400

            product = Product.query.get(product_id)
            if not product or product.company_id != g.current_company.id:
                db.session.rollback()
                return jsonify({"error": f"Line {idx + 1}: Invalid product"}), 400

            try:
                ordered_qty = float(item.get("ordered_qty", item.get("qty", 0)) or 0)
                free_qty = float(item.get("free_qty", 0) or 0)
            except (TypeError, ValueError):
                db.session.rollback()
                return jsonify({"error": f"Line {idx + 1}: Invalid quantities"}), 400

            if ordered_qty < 0 or free_qty < 0 or (ordered_qty + free_qty) <= 0:
                db.session.rollback()
                return jsonify({"error": f"Line {idx + 1}: Quantity must be greater than 0"}), 400

            uom_raw = (item.get("uom") or "").strip() or None
            uom = _validate_uom_for_product(product, uom_raw)
            if product.uom_category and not uom:
                uom = product.uom_category

            batch_number = (
                (item.get("batch_number") or item.get("lot_number") or item.get("serial_number") or "").strip()
                or None
            )
            if product.lot_tracking and not batch_number:
                db.session.rollback()
                return jsonify({"error": f"Line {idx + 1}: Batch/serial number required for {product.name}"}), 400

            try:
                cost_price = float(item.get("cost_price", 0.0) or 0.0)
                mrp = float(item.get("mrp", item.get("price", 0.0)) or 0.0)
                discount = float(item.get("discount", 0.0) or 0.0)
                tax_subtotal = float(item.get("tax_subtotal", item.get("tax", 0.0)) or 0.0)
                free_vat_percent = float(item.get("free_vat_percent", 0.0) or 0.0)
            except (TypeError, ValueError):
                db.session.rollback()
                return jsonify({"error": f"Line {idx + 1}: Invalid price/discount/tax values"}), 400

            if cost_price < 0 or mrp < 0 or discount < 0 or tax_subtotal < 0 or free_vat_percent < 0:
                db.session.rollback()
                return jsonify({"error": f"Line {idx + 1}: Amounts must be >= 0"}), 400

            expiry_date = None
            if item.get("expiry_date"):
                raw = str(item.get("expiry_date")).strip()
                try:
                    expiry_date = datetime.fromisoformat(raw).date()
                except ValueError:
                    m = re.match(r"^(\\d{2})/(\\d{4})$", raw)
                    if m:
                        month = int(m.group(1))
                        year = int(m.group(2))
                        if month < 1 or month > 12:
                            db.session.rollback()
                            return jsonify({"error": f"Line {idx + 1}: Invalid expiry_date"}), 400
                        expiry_date = datetime(year, month, 1).date()
                    else:
                        db.session.rollback()
                        return jsonify({"error": f"Line {idx + 1}: Invalid expiry_date"}), 400
            if (product.expiry_tracking or product.shelf_removal) and not expiry_date:
                db.session.rollback()
                return jsonify({"error": f"Line {idx + 1}: Expiry date required for {product.name}"}), 400

            subtotal = ordered_qty * cost_price
            if product.vat_item:
                tax_subtotal = round(max(0.0, subtotal - discount) * vat_rate / 100.0, 2)
            line_total = subtotal - discount + tax_subtotal
            if line_total < 0:
                db.session.rollback()
                return jsonify({"error": f"Line {idx + 1}: Total cannot be negative"}), 400

            bill_item = PurchaseBillItem(
                purchase_bill=bill,
                product=product,
                uom=uom,
                batch_number=batch_number,
                expiry_date=expiry_date,
                ordered_qty=ordered_qty,
                free_qty=free_qty,
                cost_price=cost_price,
                price=mrp,
                mrp=mrp,
                discount=discount,
                tax_subtotal=tax_subtotal,
                free_vat_percent=free_vat_percent,
                line_total=round(line_total, 2),
            )
            db.session.add(bill_item)
            added_any = True
            gross_total += line_total
            totals_items.append(
                {
                    "product": product,
                    "ordered_qty": ordered_qty,
                    "free_qty": free_qty,
                    "cost_price": cost_price,
                    "discount": discount,
                    "tax_subtotal": tax_subtotal,
                    "free_vat_percent": free_vat_percent,
                }
            )

        if not added_any:
            db.session.rollback()
            return jsonify({"error": "At least one valid item line is required"}), 400

        totals = _compute_purchase_bill_totals(g.current_company, totals_items)
        bill.subtotal_total = totals.get("subtotal_total", 0.0)
        bill.discount_total = totals.get("discount_total", 0.0)
        bill.cc_free_item_amount = totals.get("cc_free_item_amount", 0.0)
        bill.vat_total = totals.get("vat_total", 0.0)
        bill.round_off = totals.get("round_off", float(bill.round_off or 0.0))
        bill.gross_total = totals.get("grand_total", round(gross_total, 2))
        _renumber_purchase_bills(company_id=g.current_company.id)
        db.session.commit()
        socketio.emit(
            "inventory:update",
            {"type": "purchase_bill_updated", "purchase_bill": bill.to_dict(include_items=True), "company_id": g.current_company.id},
        )
        log_action("purchase_bill_updated", {"purchase_bill_id": bill.id}, g.current_company.id)
        return jsonify(bill.to_dict(include_items=True))

    # Purchase Orders

    @app.route("/purchase-orders", methods=["GET"])
    @require_auth
    @company_required()
    def list_purchase_orders():
        def _history_source_value(history) -> str:
            if isinstance(history, dict):
                return str(history.get("source") or "")
            if isinstance(history, str):
                try:
                    parsed = json.loads(history)
                    if isinstance(parsed, dict):
                        return str(parsed.get("source") or "")
                except Exception:
                    return ""
            return ""

        base_query = (
            PurchaseOrder.query.filter_by(company_id=g.current_company.id)
            .order_by(PurchaseOrder.created_at.desc())
        )
        if g.current_user.role not in ROLE_PLATFORM_ADMINS:
            effective_permissions = set(_effective_permission_keys(g.current_user, g.current_company.id, g.company_role))
            if "purchase_orders_read" not in effective_permissions and "purchase_orders_create" in effective_permissions:
                base_query = base_query.filter(PurchaseOrder.created_by_user_id == g.current_user.id)
        source_param = (request.args.get("source") or "").strip()
        include_bulk = request.args.get("include_bulk") == "1"
        if source_param or not include_bulk:
            ordered = base_query.all()
            filtered = []
            for po in ordered:
                src = _history_source_value(getattr(po, "history", None))
                if source_param:
                    if src != source_param:
                        continue
                else:
                    if src == "bulk_upload_excel":
                        continue
                filtered.append(po)
            orders, meta = paginate_list(filtered)
        else:
            orders, meta = paginate_query(base_query)
        payload = []
        updated_any = False
        for po in orders:
            if _maybe_backfill_purchase_order_totals(g.current_company, po):
                updated_any = True
            data = po.to_dict(include_items=True)
            data["item_count"] = len(po.items or [])
            history = getattr(po, "history", None)
            history_obj = None
            if isinstance(history, dict):
                history_obj = history
            elif isinstance(history, str):
                try:
                    parsed = json.loads(history)
                    if isinstance(parsed, dict):
                        history_obj = parsed
                except Exception:
                    history_obj = None
            if history_obj and str(history_obj.get("source") or "") == "bulk_upload_excel":
                data["source_file_name"] = history_obj.get("file_name") or None
                data["source_file_id"] = history_obj.get("file_id") or None
                data["source_sheet"] = history_obj.get("sheet") or None
            if po.purchase_bill_id:
                bill = PurchaseBill.query.get(po.purchase_bill_id)
                if bill and bill.company_id == g.current_company.id:
                    data["purchase_bill_number"] = bill.bill_number or str(bill.id)
                    data["purchase_bill_gross_total"] = round(float(bill.gross_total or 0.0), 2)
            payload.append(data)
        if updated_any:
            db.session.commit()
        return jsonify({"data": payload, "pagination": meta})

    @app.route("/purchase-orders/suppliers", methods=["GET"])
    @require_auth
    @company_required()
    def purchase_order_suppliers():
        if g.current_user.role not in ROLE_PLATFORM_ADMINS:
            effective_permissions = set(_effective_permission_keys(g.current_user, g.current_company.id, g.company_role))
            if "purchase_orders_read" not in effective_permissions and "purchase_orders_create" not in effective_permissions:
                return jsonify({"error": "Forbidden"}), 403
        include_archived = request.args.get("include_archived") == "1"
        query = Supplier.query.filter_by(company_id=g.current_company.id)
        if not include_archived:
            query = query.filter(or_(Supplier.is_archived.is_(False), Supplier.is_archived.is_(None)))
        rows = query.order_by(Supplier.name.asc()).all()
        return jsonify({"data": [s.to_dict() for s in rows]})

    @app.route("/purchase-orders/reconcile", methods=["POST"])
    @require_auth
    @company_required(["admin", "manager"])
    def reconcile_purchase_orders_route():
        result = reconcile_purchase_orders(g.current_company.id)
        log_action("purchase_orders_reconciled", result, g.current_company.id)
        return jsonify(result)

    @app.route("/purchase-orders/<int:order_id>", methods=["GET"])
    @require_auth
    @company_required()
    def get_purchase_order(order_id: int):
        def _history_source_value(history) -> str:
            if isinstance(history, dict):
                return str(history.get("source") or "")
            if isinstance(history, str):
                try:
                    parsed = json.loads(history)
                    if isinstance(parsed, dict):
                        return str(parsed.get("source") or "")
                except Exception:
                    return ""
            return ""

        po = PurchaseOrder.query.get_or_404(order_id)
        if po.company_id != g.current_company.id:
            return jsonify({"error": "Forbidden"}), 403
        history_source = _history_source_value(getattr(po, "history", None))
        if history_source == "bulk_upload_excel" and request.args.get("include_bulk") != "1":
            return jsonify({"error": "Not found"}), 404
        updated = _maybe_backfill_purchase_order_totals(g.current_company, po)
        data = po.to_dict(include_items=True)
        if po.purchase_bill_id:
            bill = PurchaseBill.query.get(po.purchase_bill_id)
            if bill and bill.company_id == g.current_company.id:
                data["purchase_bill"] = bill.to_dict(include_items=True)
        if updated:
            db.session.commit()
        return jsonify(data)

    @app.route("/purchase-orders/<int:order_id>", methods=["DELETE"])
    @require_auth
    @company_required(["superuser", "superadmin", "admin", "manager"])
    def delete_purchase_order(order_id: int):
        def _history_source_value(history) -> str:
            if isinstance(history, dict):
                return str(history.get("source") or "")
            if isinstance(history, str):
                try:
                    parsed = json.loads(history)
                    if isinstance(parsed, dict):
                        return str(parsed.get("source") or "")
                except Exception:
                    return ""
            return ""

        po = PurchaseOrder.query.get_or_404(order_id)
        if po.company_id != g.current_company.id:
            return jsonify({"error": "Forbidden"}), 403
        history_source = _history_source_value(getattr(po, "history", None))
        status_lc = str(po.status or "").strip().lower()
        if history_source == "bulk_upload_excel":
            return jsonify({"error": "Bulk-upload purchase orders must be deleted from the bulk upload screen"}), 400
        if status_lc != "draft":
            return jsonify({"error": "Only draft purchase orders can be deleted"}), 400
        if po.purchase_bill_id:
            return jsonify({"error": "Cannot delete: purchase bill already created"}), 400
        snapshot = {
            "purchase_order_id": po.id,
            "purchase_order_number": po.number,
            "status": po.status,
            "source": history_source or "manual",
            "supplier_id": po.supplier_id,
            "supplier_name": po.supplier.name if po.supplier else (po.supplier_name or None),
            "created_by_user_id": po.created_by_user_id,
            "created_by": po.created_by.username if getattr(po, "created_by", None) else None,
            "receipt_date": po.receipt_date.isoformat() if po.receipt_date else None,
            "item_count": len(po.items or []),
            "items": [
                {
                    "purchase_order_item_id": item.id,
                    "product_id": item.product_id,
                    "product_name": item.product.name if getattr(item, "product", None) else item.raw_product_name,
                    "qty": item.qty,
                    "uom": item.uom,
                    "line_order": item.line_order,
                }
                for item in (po.items or [])
            ],
        }
        db.session.delete(po)
        db.session.commit()
        log_action("purchase_order_deleted", snapshot, g.current_company.id)
        return jsonify({"success": True})

    @app.route("/purchase-orders/<int:order_id>/print", methods=["GET"])
    @require_auth
    @company_required()
    def print_purchase_order(order_id: int):
        po = PurchaseOrder.query.get_or_404(order_id)
        if po.company_id != g.current_company.id:
            return jsonify({"error": "Forbidden"}), 403
        paper_size = (request.args.get("paper_size") or "").strip() or None
        company = g.current_company
        try:
            if po.purchase_bill_id:
                bill = PurchaseBill.query.get(po.purchase_bill_id)
                if not bill or bill.company_id != company.id:
                    return jsonify({"error": "Linked purchase bill not found"}), 404
                title = (
                    f"<b>Purchase Order</b><br/>(Received)"
                    f"<br/>PO: {po.number or f'#{po.id}'}"
                    f"<br/>Bill: {bill.bill_number or f'#{bill.id}'}"
                )
                po_created = None
                try:
                    po_created = po.created_at.date() if po.created_at else None
                except Exception:
                    po_created = None
                created_label = po_created.isoformat() if po_created else (po.created_at.isoformat()[:10] if po.created_at else "")
                received_by = None
                try:
                    received_by = _user_display_name(po.received_by) if po.received_by else None
                except Exception:
                    received_by = None
                if (not received_by or received_by == "-") and po.received_by_user_id:
                    received_by = f"User #{po.received_by_user_id}"
                pdf = _render_purchase_bill_pdf(
                    company,
                    bill,
                    title_override=title,
                    instruction_override=company.print_instructions_purchase_order,
                    meta_right=("Created", created_label),
                    meta_right2=("Items Received by", received_by or "-"),
                    signature_mode="write",
                    page_size=paper_size,
                )
                filename = f"purchase-order-{po.number or po.id}-received.pdf"
                return _build_pdf_response(pdf, filename)
            pdf = _render_purchase_order_pdf(company, po, page_size=paper_size)
            filename = f"purchase-order-{po.number or po.id}.pdf"
            return _build_pdf_response(pdf, filename)
        except RuntimeError as exc:
            return jsonify({"error": str(exc)}), 501

    @app.route("/purchase-orders", methods=["POST"])
    @require_auth
    @company_required(["admin", "manager"])
    def create_purchase_order():
        data = request.get_json() or {}
        supplier_id = data.get("supplier_id")
        supplier = None
        if supplier_id:
            supplier = Supplier.query.get(supplier_id)
            if not supplier or supplier.company_id != g.current_company.id:
                return jsonify({"error": "Supplier not found"}), 404

        receipt_date = None
        if data.get("receipt_date"):
            try:
                receipt_date = datetime.fromisoformat(str(data.get("receipt_date"))).date()
            except ValueError:
                return jsonify({"error": "Invalid receipt_date (expected YYYY-MM-DD)"}), 400

        items = data.get("items") or []
        if not isinstance(items, list):
            return jsonify({"error": "items must be a list"}), 400

        status = compute_purchase_order_status(g.current_company.id, supplier.id if supplier else None, items)

        po = PurchaseOrder(
            company_id=g.current_company.id,
            supplier=supplier,
            supplier_name=supplier.name if supplier else "",
            receipt_date=receipt_date,
            expected_arrival=receipt_date,
            order_date=datetime.now(timezone.utc),
            status=status,
            created_by_user_id=int(getattr(g.current_user, "id", 0) or 0) or None,
        )
        db.session.add(po)

        for idx, line in enumerate(items):
            product_id = line.get("product_id")
            product = None
            if product_id:
                product = Product.query.get(product_id)
                if not product or product.company_id != g.current_company.id:
                    product = None

            qty = 0
            try:
                qty = int(line.get("qty", 0) or 0)
            except (TypeError, ValueError):
                qty = 0

            uom_raw = (line.get("uom") or "").strip() or None
            uom = None
            if product:
                uom = _validate_uom_for_product(product, uom_raw)
                if product.uom_category and not uom:
                    uom = _base_uom_name(product) or product.uom_category
            else:
                uom = uom_raw

            line_order = None
            try:
                line_order = int(line.get("line_order")) if line.get("line_order") is not None else None
            except (TypeError, ValueError):
                line_order = None
            if line_order is None:
                line_order = idx + 1

            db.session.add(
                PurchaseOrderItem(
                    purchase_order=po,
                    product=product,
                    qty=qty,
                    uom=uom,
                    line_order=line_order,
                )
            )

        db.session.commit()
        log_action("purchase_order_created", {"purchase_order_id": po.id, "status": po.status}, g.current_company.id)
        return jsonify(po.to_dict(include_items=True)), 201

    @app.route("/purchase-orders/<int:order_id>", methods=["PUT"])
    @require_auth
    @company_required(["admin", "manager"])
    def update_purchase_order(order_id: int):
        def _history_source_value(history) -> str:
            if isinstance(history, dict):
                return str(history.get("source") or "")
            if isinstance(history, str):
                try:
                    parsed = json.loads(history)
                    if isinstance(parsed, dict):
                        return str(parsed.get("source") or "")
                except Exception:
                    return ""
            return ""

        po = PurchaseOrder.query.get_or_404(order_id)
        if po.company_id != g.current_company.id:
            return jsonify({"error": "Forbidden"}), 403
        history_source = _history_source_value(getattr(po, "history", None))
        if history_source == "bulk_upload_excel" and request.args.get("include_bulk") != "1":
            return jsonify({"error": "Not found"}), 404
        if po.purchase_bill_id:
            return jsonify({"error": "Purchase order already posted to inventory"}), 400
        was_received = str(po.status or "").lower() == "received"
        role_lc = (g.company_role or "").strip().lower()
        is_platform_admin = g.current_user.role in ROLE_PLATFORM_ADMINS
        if was_received and not is_platform_admin and role_lc != "manager":
            return jsonify({"error": "Only manager or superadmin can edit received purchase orders"}), 403
        if (po.receipt_lines or []) and len(po.receipt_lines) > 0:
            # Allow edits for received-but-not-posted orders by clearing receipt lines.
            cleared_count = PurchaseOrderReceiptLine.query.filter_by(purchase_order_id=po.id).delete(synchronize_session=False)
            po.received_at = None
            po.received_by_user_id = None
            po.pricing_received_at = None
            po.pricing_received_by_user_id = None
            if was_received:
                po.status = "draft"
            log_action(
                "purchase_order_received_edit_reset",
                {"purchase_order_id": po.id, "cleared_receipt_lines": int(cleared_count or 0), "was_received": was_received},
                g.current_company.id,
            )

        data = request.get_json() or {}
        supplier_id = data.get("supplier_id")
        supplier = None
        if supplier_id:
            supplier = Supplier.query.get(supplier_id)
            if not supplier or supplier.company_id != g.current_company.id:
                return jsonify({"error": "Supplier not found"}), 404

        receipt_date = None
        if data.get("receipt_date"):
            try:
                receipt_date = datetime.fromisoformat(str(data.get("receipt_date"))).date()
            except ValueError:
                return jsonify({"error": "Invalid receipt_date (expected YYYY-MM-DD)"}), 400

        items = data.get("items") or []
        if not isinstance(items, list):
            return jsonify({"error": "items must be a list"}), 400

        po.supplier = supplier
        po.supplier_name = supplier.name if supplier else ""
        po.receipt_date = receipt_date
        po.expected_arrival = receipt_date
        if not po.order_date:
            po.order_date = po.created_at or datetime.now(timezone.utc)
        po.status = compute_purchase_order_status(g.current_company.id, supplier.id if supplier else None, items)
        po.pricing_received_at = None
        po.pricing_received_by_user_id = None

        PurchaseOrderItem.query.filter_by(purchase_order_id=po.id).delete(synchronize_session=False)
        for idx, line in enumerate(items):
            product_id = line.get("product_id")
            product = None
            if product_id:
                product = Product.query.get(product_id)
                if not product or product.company_id != g.current_company.id:
                    product = None

            qty = 0
            try:
                qty = int(line.get("qty", 0) or 0)
            except (TypeError, ValueError):
                qty = 0

            uom_raw = (line.get("uom") or "").strip() or None
            uom = None
            if product:
                uom = _validate_uom_for_product(product, uom_raw)
                if product.uom_category and not uom:
                    uom = _base_uom_name(product) or product.uom_category
            else:
                uom = uom_raw

            line_order = None
            try:
                line_order = int(line.get("line_order")) if line.get("line_order") is not None else None
            except (TypeError, ValueError):
                line_order = None
            if line_order is None:
                line_order = idx + 1

            db.session.add(
                PurchaseOrderItem(
                    purchase_order=po,
                    product=product,
                    qty=qty,
                    uom=uom,
                    line_order=line_order,
                )
            )

        db.session.commit()
        log_action("purchase_order_updated", {"purchase_order_id": po.id, "status": po.status}, g.current_company.id)
        return jsonify(po.to_dict(include_items=True))

    @app.route("/purchase-orders/<int:order_id>/receive-pricing", methods=["POST"])
    @require_auth
    @company_required(["admin", "manager", "staff", "salesman", "superuser", "superadmin"])
    def receive_purchase_order_pricing(order_id: int):
        po = PurchaseOrder.query.get_or_404(order_id)
        if po.company_id != g.current_company.id:
            return jsonify({"error": "Forbidden"}), 403

        status_lc = str(po.status or "").lower()
        pricing_exists = bool(getattr(po, "pricing_received_at", None) or getattr(po, "pricing_received_by_user_id", None))
        if not pricing_exists:
            for item in po.items or []:
                if any(
                    value is not None and float(value or 0) > 0
                    for value in [
                        getattr(item, "received_cost_price", None),
                        getattr(item, "received_discount_total", None),
                        getattr(item, "received_discount_percent", None),
                        getattr(item, "received_tax_subtotal", None),
                        getattr(item, "received_free_vat_percent", None),
                    ]
                ):
                    pricing_exists = True
                    break
        if po.purchase_bill_id:
            existing_bill = PurchaseBill.query.get(po.purchase_bill_id)
            if existing_bill and existing_bill.company_id == g.current_company.id:
                if not pricing_exists:
                    # Backfill PO pricing from the linked bill so UI stops showing "Pricing due".
                    totals_by_item: dict[tuple[int, str | None], dict] = {}
                    for bill_item in existing_bill.items or []:
                        key = (int(bill_item.product_id or 0), (bill_item.uom or None))
                        row = totals_by_item.setdefault(
                            key,
                            {
                                "ordered_sum": 0.0,
                                "free_sum": 0.0,
                                "subtotal_sum": 0.0,
                                "discount_sum": 0.0,
                                "tax_sum": 0.0,
                                "mrp_max": 0.0,
                                "free_vat_amount": 0.0,
                            },
                        )
                        ordered_qty = float(bill_item.ordered_qty or 0.0)
                        free_qty = float(bill_item.free_qty or 0.0)
                        cost = float(bill_item.cost_price or 0.0)
                        mrp_val = float(bill_item.mrp or 0.0)
                        discount_val = float(bill_item.discount or 0.0)
                        tax_val = float(bill_item.tax_subtotal or 0.0)
                        free_vat_pct = float(getattr(bill_item, "free_vat_percent", 0.0) or 0.0)
                        row["ordered_sum"] += ordered_qty
                        row["free_sum"] += free_qty
                        row["subtotal_sum"] += ordered_qty * cost
                        row["discount_sum"] += discount_val
                        row["tax_sum"] += tax_val
                        row["mrp_max"] = max(row["mrp_max"], mrp_val)
                        if free_qty > 0 and cost > 0 and free_vat_pct > 0:
                            row["free_vat_amount"] += (free_qty * cost * free_vat_pct) / 100.0

                    for poi in po.items or []:
                        key = (int(poi.product_id or 0), (poi.uom or None))
                        data = totals_by_item.get(key)
                        if not data:
                            continue
                        ordered_sum = float(data.get("ordered_sum") or 0.0)
                        free_sum = float(data.get("free_sum") or 0.0)
                        subtotal_sum = float(data.get("subtotal_sum") or 0.0)
                        discount_sum = float(data.get("discount_sum") or 0.0)
                        tax_sum = float(data.get("tax_sum") or 0.0)
                        mrp_val = float(data.get("mrp_max") or 0.0)
                        avg_cost = subtotal_sum / ordered_sum if ordered_sum > 0 else 0.0
                        discount_pct = (discount_sum / subtotal_sum * 100.0) if subtotal_sum > 0 else 0.0
                        free_vat_amount = float(data.get("free_vat_amount") or 0.0)
                        free_vat_pct = 0.0
                        if free_sum > 0 and avg_cost > 0:
                            free_vat_pct = (free_vat_amount / (free_sum * avg_cost)) * 100.0
                        poi.received_ordered_qty = round(ordered_sum, 4)
                        poi.received_free_qty = round(free_sum, 4)
                        if avg_cost > 0:
                            poi.received_cost_price = round(avg_cost, 4)
                        if mrp_val > 0:
                            poi.received_mrp = round(mrp_val, 2)
                        poi.received_discount_total = round(discount_sum, 2)
                        poi.received_discount_percent = round(discount_pct, 2)
                        poi.received_tax_subtotal = round(tax_sum, 2)
                        if free_vat_pct > 0:
                            poi.received_free_vat_percent = round(free_vat_pct, 4)

                    po.pricing_received_at = po.pricing_received_at or datetime.now(timezone.utc)
                    po.pricing_received_by_user_id = po.pricing_received_by_user_id or int(getattr(g.current_user, "id", 0) or 0) or None
                    db.session.commit()
                    return jsonify(po.to_dict(include_items=True))

                # Pricing already exists and the bill is linked: return the current PO (idempotent).
                return jsonify(po.to_dict(include_items=True))
            # Orphaned/missing bill reference: allow re-creation on receive/post.
            po.purchase_bill_id = None
        if status_lc == "received" and pricing_exists:
            return jsonify({"error": "This purchase order pricing is already received"}), 400
        if not po.supplier_id:
            return jsonify({"error": "Supplier is required to receive a purchase order"}), 400

        role_lc = (g.company_role or "").lower()
        if role_lc in {"staff", "salesman"}:
            effective_permissions = set(_effective_permission_keys(g.current_user, g.current_company.id, g.company_role))
            if "purchase_orders_edit" not in effective_permissions:
                return jsonify({"error": "Insufficient permissions to receive pricing"}), 403

        data = request.get_json(silent=True) or {}
        pricing_items = data.get("items") or []
        if not isinstance(pricing_items, list) or not pricing_items:
            return jsonify({"error": "items are required to receive pricing"}), 400
        pricing_date_raw = (data.get("pricing_date") or "").strip()
        pricing_date_value = None
        if pricing_date_raw:
            try:
                pricing_date_value = date.fromisoformat(pricing_date_raw[:10])
            except Exception:
                return jsonify({"error": "Invalid pricing date"}), 400
        if pricing_date_value is None:
            pricing_date_value = po.receipt_date or po.expected_arrival or _today_ad()

        received_by_id: dict[int, list[dict]] = {}
        line_order_by_item: dict[int, int] = {}
        for row in pricing_items:
            try:
                poi_id = int(row.get("purchase_order_item_id"))
            except (TypeError, ValueError):
                return jsonify({"error": "Each pricing line must include purchase_order_item_id"}), 400
            received_by_id.setdefault(poi_id, []).append(row)
            if row.get("line_order") is not None:
                try:
                    line_order_by_item.setdefault(poi_id, int(row.get("line_order")))
                except (TypeError, ValueError):
                    pass

        if line_order_by_item:
            for poi in po.items or []:
                if poi.id in line_order_by_item:
                    poi.line_order = line_order_by_item[poi.id]

        def _num(value) -> float:
            try:
                return float(value or 0.0)
            except (TypeError, ValueError):
                return 0.0

        def _row_is_blank(row: dict) -> bool:
            fields = [
                _num(row.get("ordered_qty")),
                _num(row.get("free_qty")),
                _num(row.get("cost_price")),
                _num(row.get("mrp")),
                _num(row.get("discount_percent")),
                _num(row.get("discount_total") or row.get("discount")),
                _num(row.get("tax_subtotal") or row.get("tax")),
                _num(row.get("free_vat_percent")),
            ]
            return all(v <= 0 for v in fields)

        any_pricing_updates = False

        vat_percent = float(getattr(g.current_company, "vat_purchase_percent", 0.0) or 0.0)
        vat_rate = max(0.0, vat_percent)

        pricing_marked = False
        receipt_mrp_by_item: dict[int, float] = {}
        for rline in po.receipt_lines or []:
            if rline.purchase_order_item_id is None:
                continue
            mrp_val = float(rline.mrp or 0.0)
            if mrp_val <= 0:
                continue
            current = receipt_mrp_by_item.get(rline.purchase_order_item_id, 0.0)
            if mrp_val > current:
                receipt_mrp_by_item[rline.purchase_order_item_id] = mrp_val
        for idx, poi in enumerate(po.items or []):
            if not poi.product_id:
                db.session.rollback()
                return jsonify({"error": f"Line {idx + 1}: Product is required"}), 400

            product = Product.query.get(poi.product_id)
            if not product or product.company_id != g.current_company.id:
                db.session.rollback()
                return jsonify({"error": f"Line {idx + 1}: Invalid product"}), 400

            try:
                qty_float = float(poi.qty or 0)
            except (TypeError, ValueError):
                qty_float = 0.0
            if qty_float <= 0 or abs(qty_float - round(qty_float)) > 1e-9:
                db.session.rollback()
                return jsonify({"error": f"Line {idx + 1}: PO quantity must be a whole number > 0"}), 400
            po_qty = int(round(qty_float))

            uom = (poi.uom or "").strip() or None
            uom = _validate_uom_for_product(product, uom)
            if product.uom_category and not uom:
                uom = _base_uom_name(product) or product.uom_category
            if product.uom_category and not uom:
                db.session.rollback()
                return jsonify({"error": f"Line {idx + 1}: UoM is required"}), 400

            recv_lines = received_by_id.get(poi.id) or []
            if not recv_lines:
                continue
            recv_lines = [r for r in recv_lines if not _row_is_blank(r)]
            if not recv_lines:
                continue

            ordered_sum = 0.0
            free_sum = 0.0
            cost_price = None
            mrp = None
            discount_percent = None
            free_vat_percent = None
            discount_total = 0.0
            tax_total = 0.0
            subtotal_total = 0.0
            for ridx, recv in enumerate(recv_lines):
                try:
                    ordered_qty = float(recv.get("ordered_qty") or 0.0)
                    free_qty = float(recv.get("free_qty") or 0.0)
                except (TypeError, ValueError):
                    db.session.rollback()
                    return jsonify({"error": f"Line {idx + 1}.{ridx + 1}: Invalid ordered/free quantities"}), 400
                if ordered_qty < 0 or free_qty < 0 or (ordered_qty + free_qty) <= 0:
                    db.session.rollback()
                    return jsonify({"error": f"Line {idx + 1}.{ridx + 1}: Quantities must be >= 0 and total > 0"}), 400
                ordered_sum += ordered_qty
                free_sum += free_qty

                try:
                    row_cost = float(recv.get("cost_price") or 0.0)
                except (TypeError, ValueError):
                    row_cost = 0.0
                if row_cost <= 0:
                    db.session.rollback()
                    return jsonify({"error": f"Line {idx + 1}.{ridx + 1}: Cost price is required"}), 400
                if cost_price is None:
                    cost_price = row_cost
                elif abs(cost_price - row_cost) > 1e-6:
                    db.session.rollback()
                    return jsonify({"error": f"Line {idx + 1}.{ridx + 1}: Cost price must match within a line"}), 400
                subtotal_total += ordered_qty * row_cost

                try:
                    row_mrp = float(recv.get("mrp") or 0.0)
                except (TypeError, ValueError):
                    row_mrp = 0.0
                if row_mrp <= 0:
                    fallback_mrp = float(getattr(poi, "received_mrp", 0.0) or 0.0)
                    if fallback_mrp <= 0:
                        fallback_mrp = receipt_mrp_by_item.get(poi.id, 0.0)
                    if fallback_mrp > 0:
                        row_mrp = fallback_mrp
                if row_mrp <= 0:
                    db.session.rollback()
                    return jsonify({"error": f"Line {idx + 1}.{ridx + 1}: MRP is required"}), 400
                if mrp is None:
                    mrp = row_mrp
                elif abs(mrp - row_mrp) > 1e-6:
                    db.session.rollback()
                    return jsonify({"error": f"Line {idx + 1}.{ridx + 1}: MRP must match within a line"}), 400
                if row_cost > 0 and row_mrp > 0 and row_mrp < row_cost:
                    db.session.rollback()
                    return jsonify(
                        {
                            "error": f"Line {idx + 1}.{ridx + 1}: MRP cannot be lower than cost price",
                            "mrp_below_cost_lines": [
                                {
                                    "line": idx + 1,
                                    "subline": ridx + 1,
                                    "product_id": int(poi.product_id or 0) if poi.product_id else None,
                                    "product_name": product.name,
                                    "cost_price": round(float(row_cost or 0.0), 4),
                                    "mrp": round(float(row_mrp or 0.0), 4),
                                    "uom": uom,
                                }
                            ],
                        }
                    ), 400

                try:
                    row_discount_percent = float(recv.get("discount_percent", 0.0) or 0.0)
                except (TypeError, ValueError):
                    row_discount_percent = -1.0
                if row_discount_percent < 0:
                    db.session.rollback()
                    return jsonify({"error": f"Line {idx + 1}.{ridx + 1}: Discount % must be >= 0"}), 400
                if discount_percent is None:
                    discount_percent = row_discount_percent
                elif abs(discount_percent - row_discount_percent) > 1e-6:
                    db.session.rollback()
                    return jsonify({"error": f"Line {idx + 1}.{ridx + 1}: Discount % must match within a line"}), 400

                try:
                    row_free_vat_percent = float(recv.get("free_vat_percent", 0.0) or 0.0)
                except (TypeError, ValueError):
                    row_free_vat_percent = -1.0
                if row_free_vat_percent < 0:
                    db.session.rollback()
                    return jsonify({"error": f"Line {idx + 1}.{ridx + 1}: Free VAT % must be >= 0"}), 400
                if free_vat_percent is None:
                    free_vat_percent = row_free_vat_percent
                elif abs(free_vat_percent - row_free_vat_percent) > 1e-6:
                    db.session.rollback()
                    return jsonify({"error": f"Line {idx + 1}.{ridx + 1}: Free VAT % must match within a line"}), 400

                raw_discount_total = recv.get("discount_total")
                if raw_discount_total in (None, ""):
                    raw_discount_total = recv.get("discount")
                if raw_discount_total in (None, ""):
                    row_discount_total = round((ordered_qty * row_cost) * (row_discount_percent / 100.0), 2)
                else:
                    try:
                        row_discount_total = float(raw_discount_total or 0.0)
                    except (TypeError, ValueError):
                        row_discount_total = -1.0
                    if row_discount_total < 0:
                        db.session.rollback()
                        return jsonify({"error": f"Line {idx + 1}.{ridx + 1}: Discount total must be >= 0"}), 400
                discount_total += row_discount_total

                try:
                    row_tax = float(recv.get("tax_subtotal", recv.get("tax", 0.0)) or 0.0)
                except (TypeError, ValueError):
                    row_tax = -1.0
                if row_tax < 0:
                    db.session.rollback()
                    return jsonify({"error": f"Line {idx + 1}.{ridx + 1}: Tax must be >= 0"}), 400
                if product.vat_item:
                    row_tax = round(max(0.0, (ordered_qty * row_cost) - row_discount_total) * vat_rate / 100.0, 2)
                tax_total += row_tax

            total_received = ordered_sum + free_sum
            rounded_total = round(total_received, 4)
            eps = 1e-3
            if abs(rounded_total - round(rounded_total)) > eps:
                db.session.rollback()
                return jsonify(
                    {
                        "error": f"Line {idx + 1}: Ordered + Free must sum to a whole number"
                    }
                ), 400
            if rounded_total + eps < po_qty:
                db.session.rollback()
                return jsonify(
                    {
                        "error": f"Line {idx + 1}: Ordered + Free cannot be less than PO quantity ({po_qty})"
                    }
                ), 400

            if discount_total - subtotal_total > 1e-6:
                db.session.rollback()
                return jsonify({"error": f"Line {idx + 1}: Discount total cannot exceed subtotal"}), 400
            line_net = subtotal_total - discount_total + tax_total
            if line_net < -1e-6:
                db.session.rollback()
                return jsonify({"error": f"Line {idx + 1}: Net total cannot be negative"}), 400

            poi.received_ordered_qty = round(float(ordered_sum or 0.0), 4)
            poi.received_free_qty = round(float(free_sum or 0.0), 4)
            poi.received_cost_price = float(cost_price or 0.0)
            poi.received_mrp = float(mrp or 0.0)
            poi.received_discount_percent = float(discount_percent or 0.0)
            poi.received_discount_total = round(float(discount_total or 0.0), 2)
            poi.received_tax_subtotal = round(float(tax_total or 0.0), 2)
            poi.received_free_vat_percent = float(free_vat_percent or 0.0)
            any_pricing_updates = True

        if not any_pricing_updates:
            db.session.rollback()
            return jsonify({"error": "No pricing lines to save"}), 400

        totals = _compute_purchase_order_totals(g.current_company, po)
        po.subtotal_total = totals.get("subtotal_total", 0.0)
        po.discount_total = totals.get("discount_total", 0.0)
        po.cc_free_item_amount = totals.get("cc_free_item_amount", 0.0)
        po.vat_total = totals.get("vat_total", 0.0)
        po.round_off = totals.get("round_off", float(po.round_off or 0.0))
        po.total_amount = totals.get("grand_total", float(po.total_amount or 0.0))

        po.pricing_received_at = datetime.combine(pricing_date_value, datetime.min.time(), tzinfo=timezone.utc)
        po.pricing_received_by_user_id = int(getattr(g.current_user, "id", 0) or 0) or None
        po.status = "received" if status_lc == "received" else "receiving"

        db.session.commit()
        log_action(
            "purchase_order_pricing_received",
            {
                "purchase_order_id": po.id,
                "lines": len(po.items or []),
                "pricing_date": pricing_date_value.isoformat() if pricing_date_value else None,
                "user_id": po.pricing_received_by_user_id,
            },
            g.current_company.id,
        )
        return jsonify(po.to_dict(include_items=True))

    @app.route("/purchase-orders/<int:order_id>/receive", methods=["POST"])
    @require_auth
    @company_required(["admin", "manager", "staff", "salesman"])
    def receive_purchase_order(order_id: int):
        po = PurchaseOrder.query.get_or_404(order_id)
        if po.company_id != g.current_company.id:
            return jsonify({"error": "Forbidden"}), 403

        current_status = str(po.status or "").lower()
        if current_status not in {"draft", "receiving", "received"}:
            return jsonify({"error": "Only draft or receiving purchase orders can be received"}), 400
        if not po.supplier_id:
            return jsonify({"error": "Supplier is required to receive a purchase order"}), 400
        if po.purchase_bill_id:
            return jsonify({"error": "This purchase order is already received"}), 400

        data = request.get_json(silent=True) or {}
        received_items = data.get("items") or []
        if not isinstance(received_items, list) or not received_items:
            return jsonify({"error": "items are required to receive a purchase order"}), 400
        receipt_date_raw = (data.get("receipt_date") or "").strip() if isinstance(data.get("receipt_date"), str) else data.get("receipt_date")
        receipt_date_override = None
        if receipt_date_raw:
            try:
                receipt_date_override = datetime.fromisoformat(str(receipt_date_raw)).date()
            except Exception:
                return jsonify({"error": "Invalid receipt_date (expected YYYY-MM-DD)"}), 400
        receipt_context_date = receipt_date_override or po.receipt_date or po.expected_arrival or _today_ad()

        role_lc = (g.company_role or "").lower()
        vat_percent = float(getattr(g.current_company, "vat_purchase_percent", 0.0) or 0.0)
        vat_rate = max(0.0, vat_percent)
        pricing_ready = bool(getattr(po, "pricing_received_at", None) or getattr(po, "pricing_received_by_user_id", None))
        if not pricing_ready:
            for item in po.items or []:
                if any(
                    value is not None
                    for value in [
                        getattr(item, "received_ordered_qty", None),
                        getattr(item, "received_free_qty", None),
                        getattr(item, "received_cost_price", None),
                        getattr(item, "received_mrp", None),
                    ]
                ):
                    pricing_ready = True
                    break

        # Group received lines by purchase_order_item_id (supports batch splits per PO item)
        received_by_id: dict[int, list[dict]] = {}
        line_order_by_item: dict[int, int] = {}
        for row in received_items:
            try:
                poi_id = int(row.get("purchase_order_item_id"))
            except (TypeError, ValueError):
                return jsonify({"error": "Each received line must include purchase_order_item_id"}), 400
            received_by_id.setdefault(poi_id, []).append(row)
            if row.get("line_order") is not None:
                try:
                    line_order_by_item.setdefault(poi_id, int(row.get("line_order")))
                except (TypeError, ValueError):
                    pass

        if role_lc in {"staff", "salesman"}:
            if current_status == "received":
                return jsonify({"error": "Manager approval required to post this purchase order"}), 403
            # Staff can save batch/serial + expiry + MRP before pricing is finalized.
            receipt_ts = datetime.now(timezone.utc)
            if receipt_date_override:
                po.receipt_date = receipt_date_override
                po.expected_arrival = receipt_date_override
            if line_order_by_item:
                for poi in po.items or []:
                    if poi.id in line_order_by_item:
                        poi.line_order = line_order_by_item[poi.id]

            line_item_ids = list(received_by_id.keys())
            if line_item_ids:
                PurchaseOrderReceiptLine.query.filter(
                    PurchaseOrderReceiptLine.purchase_order_id == po.id,
                    PurchaseOrderReceiptLine.purchase_order_item_id.in_(line_item_ids),
                ).delete(synchronize_session=False)

            def _maybe_float(value):
                if value is None:
                    return None
                if isinstance(value, str) and not value.strip():
                    return None
                try:
                    return float(value)
                except (TypeError, ValueError):
                    return None

            for idx, poi in enumerate(po.items or []):
                if poi.id not in received_by_id:
                    continue
                if not poi.product_id:
                    db.session.rollback()
                    return jsonify({"error": f"Line {idx + 1}: Product is required"}), 400

                product = Product.query.get(poi.product_id)
                if not product or product.company_id != g.current_company.id:
                    db.session.rollback()
                    return jsonify({"error": f"Line {idx + 1}: Invalid product"}), 400

                uom = (poi.uom or "").strip() or None
                uom = _validate_uom_for_product(product, uom)
                if product.uom_category and not uom:
                    uom = _base_uom_name(product) or product.uom_category
                if product.uom_category and not uom:
                    db.session.rollback()
                    return jsonify({"error": f"Line {idx + 1}: UoM is required"}), 400

                for ridx, recv in enumerate(received_by_id.get(poi.id) or []):
                    ordered_qty = _maybe_float(recv.get("ordered_qty"))
                    free_qty = _maybe_float(recv.get("free_qty"))
                    if ordered_qty is not None and ordered_qty < 0:
                        db.session.rollback()
                        return jsonify({"error": f"Line {idx + 1}.{ridx + 1}: Ordered qty must be >= 0"}), 400
                    if free_qty is not None and free_qty < 0:
                        db.session.rollback()
                        return jsonify({"error": f"Line {idx + 1}.{ridx + 1}: Free qty must be >= 0"}), 400

                    batch_number = (recv.get("batch_number") or "").strip() or None
                    if product.lot_tracking and not batch_number:
                        db.session.rollback()
                        return jsonify({"error": f"Line {idx + 1}.{ridx + 1}: Batch/serial number required for {product.name}"}), 400

                    expiry_date = None
                    if product.expiry_tracking or product.shelf_removal:
                        raw_expiry = recv.get("expiry_date")
                        if raw_expiry not in (None, ""):
                            try:
                                expiry_date = _parse_expiry_date_allow_month_year(raw_expiry)
                            except ValueError:
                                db.session.rollback()
                                return jsonify({"error": f"Line {idx + 1}.{ridx + 1}: Invalid expiry_date"}), 400
                        if not expiry_date and batch_number:
                            defaults = _stock_adjustment_defaults(
                                company_id=g.current_company.id,
                                product=product,
                                batch_number=batch_number,
                                uom=uom,
                                as_of_date=receipt_context_date,
                            )
                            expiry_date = defaults.get("expiry_date")
                        if not expiry_date:
                            db.session.rollback()
                            return jsonify({"error": f"Line {idx + 1}.{ridx + 1}: Expiry date required for {product.name}"}), 400

                    mrp_value = _maybe_float(recv.get("mrp"))
                    if mrp_value is not None and mrp_value < 0:
                        db.session.rollback()
                        return jsonify({"error": f"Line {idx + 1}.{ridx + 1}: MRP must be >= 0"}), 400
                    if mrp_value is not None and mrp_value <= 0:
                        mrp_value = None

                    db.session.add(
                        PurchaseOrderReceiptLine(
                            company_id=g.current_company.id,
                            purchase_order=po,
                            purchase_order_item=poi,
                            product_id=poi.product_id,
                            uom=uom,
                            ordered_qty=ordered_qty,
                            free_qty=free_qty,
                            batch_number=batch_number,
                            expiry_date=expiry_date,
                            mrp=mrp_value,
                            received_by_user_id=int(getattr(g.current_user, "id", 0) or 0) or None,
                            received_at=receipt_ts,
                        )
                    )

            db.session.flush()

            totals_by_item: dict[int, float] = {}
            for line in po.receipt_lines or []:
                if line.purchase_order_item_id is None:
                    continue
                totals_by_item.setdefault(line.purchase_order_item_id, 0.0)
                totals_by_item[line.purchase_order_item_id] += float(line.ordered_qty or 0.0) + float(line.free_qty or 0.0)

            all_complete = True
            for idx, poi in enumerate(po.items or []):
                po_qty = _maybe_float(getattr(poi, "qty", None)) or 0.0
                if po_qty <= 0:
                    continue
                received_total = totals_by_item.get(poi.id, 0.0)
                rounded_total = round(received_total, 4)
                eps = 1e-3
                if abs(rounded_total - round(rounded_total)) > eps:
                    db.session.rollback()
                    return jsonify(
                        {"error": f"Line {idx + 1}: Ordered + Free must sum to a whole number"}
                    ), 400
                if rounded_total + eps < po_qty:
                    all_complete = False
                    break

            po.received_at = po.received_at or receipt_ts
            po.received_by_user_id = int(getattr(g.current_user, "id", 0) or 0) or None
            po.status = "received" if all_complete else "receiving"

            db.session.commit()
            log_action(
                "purchase_order_receipt_saved",
                {"purchase_order_id": po.id, "lines": len(received_items)},
                g.current_company.id,
            )
            return jsonify(po.to_dict(include_items=True))

        # Build a posted Purchase Bill directly from this PO
        supplier = Supplier.query.get(po.supplier_id)
        if not supplier or supplier.company_id != g.current_company.id:
            return jsonify({"error": "Supplier not found"}), 404
        if line_order_by_item:
            for poi in po.items or []:
                if poi.id in line_order_by_item:
                    poi.line_order = line_order_by_item[poi.id]

        bill_date = receipt_date_override or po.receipt_date or po.expected_arrival or _today_ad()
        bill = PurchaseBill(
            company_id=g.current_company.id,
            supplier=supplier,
            purchase_date=bill_date,
            posted=False,
            created_by_user_id=int(getattr(g.current_user, "id", 0) or 0) or None,
        )
        bill.bill_number = _make_purchase_bill_number(g.current_company.id, bill_date)
        bill.approval_status = "approved"
        bill.approved_at = datetime.now(timezone.utc)
        bill.approved_by_user_id = int(getattr(g.current_user, "id", 0) or 0) or None
        db.session.add(bill)

        gross_total = 0.0
        totals_items: list[dict] = []
        # Validate every PO line (tight rule)
        receipt_lines_by_item: dict[int, list[PurchaseOrderReceiptLine]] = {}
        for line in po.receipt_lines or []:
            receipt_lines_by_item.setdefault(line.purchase_order_item_id, []).append(line)

        def _receipt_mrp(poi_id: int, batch: str | None) -> float:
            rows = receipt_lines_by_item.get(poi_id) or []
            if not rows:
                return 0.0
            if batch:
                for row in rows:
                    if (row.batch_number or "") == batch and row.mrp:
                        return float(row.mrp or 0.0)
            for row in rows:
                if row.mrp:
                    return float(row.mrp or 0.0)
            return 0.0

        for idx, poi in enumerate(po.items or []):
            if not poi.product_id:
                db.session.rollback()
                return jsonify({"error": f"Line {idx + 1}: Product is required"}), 400

            product = Product.query.get(poi.product_id)
            if not product or product.company_id != g.current_company.id:
                db.session.rollback()
                return jsonify({"error": f"Line {idx + 1}: Invalid product"}), 400

            qty_raw = poi.qty
            try:
                qty_float = float(qty_raw or 0)
            except (TypeError, ValueError):
                qty_float = 0.0
            if qty_float <= 0 or abs(qty_float - round(qty_float)) > 1e-9:
                db.session.rollback()
                return jsonify({"error": f"Line {idx + 1}: PO quantity must be a whole number > 0"}), 400
            po_qty = int(round(qty_float))

            uom = (poi.uom or "").strip() or None
            uom = _validate_uom_for_product(product, uom)
            if product.uom_category and not uom:
                uom = _base_uom_name(product) or product.uom_category
            if product.uom_category and not uom:
                db.session.rollback()
                return jsonify({"error": f"Line {idx + 1}: UoM is required"}), 400

            recv_lines = received_by_id.get(poi.id) or []
            if not recv_lines:
                db.session.rollback()
                return jsonify({"error": f"Line {idx + 1}: Missing receive info for item"}), 400

            ordered_sum = 0.0
            free_sum = 0.0
            stored_cost_price = float(poi.received_cost_price or 0.0) if poi.received_cost_price is not None else 0.0
            stored_mrp = float(poi.received_mrp or 0.0) if poi.received_mrp is not None else 0.0
            stored_discount_percent = float(getattr(poi, "received_discount_percent", 0.0) or 0.0)
            stored_discount_total = float(getattr(poi, "received_discount_total", 0.0) or 0.0)
            stored_tax_subtotal = float(getattr(poi, "received_tax_subtotal", 0.0) or 0.0)
            stored_free_vat_percent = float(getattr(poi, "received_free_vat_percent", 0.0) or 0.0)
            if stored_discount_percent < 0 or stored_discount_total < 0 or stored_tax_subtotal < 0 or stored_free_vat_percent < 0:
                db.session.rollback()
                return jsonify({"error": f"Line {idx + 1}: Saved pricing values cannot be negative"}), 400

            line_buffers: list[dict] = []
            for ridx, recv in enumerate(recv_lines):
                try:
                    ordered_qty = float(recv.get("ordered_qty") or 0)
                    free_qty = float(recv.get("free_qty") or 0)
                except (TypeError, ValueError):
                    db.session.rollback()
                    return jsonify({"error": f"Line {idx + 1}.{ridx + 1}: Invalid ordered/free quantities"}), 400
                if ordered_qty < 0 or free_qty < 0 or (ordered_qty + free_qty) <= 0:
                    db.session.rollback()
                    return jsonify({"error": f"Line {idx + 1}.{ridx + 1}: Quantities must be >= 0 and total > 0"}), 400
                ordered_sum += ordered_qty
                free_sum += free_qty

                batch_number = (recv.get("batch_number") or "").strip() or None
                if product.lot_tracking and not batch_number:
                    db.session.rollback()
                    return jsonify({"error": f"Line {idx + 1}.{ridx + 1}: Batch/serial number required for {product.name}"}), 400

                expiry_date = None
                if product.expiry_tracking or product.shelf_removal:
                    raw_expiry = recv.get("expiry_date")
                    if raw_expiry not in (None, ""):
                        try:
                            expiry_date = _parse_expiry_date_allow_month_year(raw_expiry)
                        except ValueError:
                            db.session.rollback()
                            return jsonify({"error": f"Line {idx + 1}.{ridx + 1}: Invalid expiry_date"}), 400
                    if not expiry_date and batch_number:
                        defaults = _stock_adjustment_defaults(
                            company_id=g.current_company.id,
                            product=product,
                            batch_number=batch_number,
                            uom=uom,
                            as_of_date=receipt_context_date,
                        )
                        expiry_date = defaults.get("expiry_date")
                    if not expiry_date:
                        db.session.rollback()
                        return jsonify({"error": f"Line {idx + 1}.{ridx + 1}: Expiry date required for {product.name}"}), 400

                effective_mrp = stored_mrp
                if effective_mrp <= 0:
                    try:
                        effective_mrp = float(recv.get("mrp") or 0.0)
                    except (TypeError, ValueError):
                        effective_mrp = 0.0
                if effective_mrp <= 0:
                    effective_mrp = _receipt_mrp(poi.id, batch_number)
                if effective_mrp <= 0:
                    db.session.rollback()
                    return jsonify({"error": f"Line {idx + 1}.{ridx + 1}: MRP is required"}), 400

                effective_cost = stored_cost_price
                if effective_cost <= 0:
                    try:
                        effective_cost = float(recv.get("cost_price") or 0.0)
                    except (TypeError, ValueError):
                        effective_cost = -1.0
                if effective_cost <= 0:
                    db.session.rollback()
                    return jsonify({"error": f"Line {idx + 1}.{ridx + 1}: Cost price is required"}), 400

                subtotal = round(ordered_qty * effective_cost, 2)
                line_buffers.append(
                    {
                        "ordered_qty": float(ordered_qty or 0.0),
                        "free_qty": float(free_qty or 0.0),
                        "batch_number": batch_number if product.lot_tracking else None,
                        "expiry_date": expiry_date if (product.expiry_tracking or product.shelf_removal) else None,
                        "effective_cost": effective_cost,
                        "effective_mrp": effective_mrp,
                        "subtotal": subtotal,
                    }
                )

            total_received = ordered_sum + free_sum
            rounded_total = round(total_received, 4)
            eps = 1e-3
            if abs(rounded_total - round(rounded_total)) > eps:
                db.session.rollback()
                return jsonify(
                    {"error": f"Line {idx + 1}: Ordered + Free must sum to a whole number"}
                ), 400
            if role_lc in {"staff", "salesman"}:
                if abs(rounded_total - po_qty) > eps:
                    db.session.rollback()
                    return jsonify(
                        {"error": f"Line {idx + 1}: Ordered + Free must match PO quantity ({po_qty})"}
                    ), 400
            else:
                if rounded_total + eps < po_qty:
                    db.session.rollback()
                    return jsonify(
                        {"error": f"Line {idx + 1}: Ordered + Free cannot be less than PO quantity ({po_qty})"}
                    ), 400

            subtotal_sum = round(sum(float(r.get("subtotal") or 0.0) for r in line_buffers), 2)
            if stored_discount_total <= 0 and stored_discount_percent > 0 and subtotal_sum > 0:
                stored_discount_total = round(subtotal_sum * (stored_discount_percent / 100.0), 2)
            if stored_discount_total - subtotal_sum > 1e-6:
                db.session.rollback()
                return jsonify({"error": f"Line {idx + 1}: Saved pricing discount exceeds subtotal"}), 400
            if subtotal_sum <= 0 and (stored_discount_total > 0 or stored_tax_subtotal > 0):
                db.session.rollback()
                return jsonify({"error": f"Line {idx + 1}: Cannot apply discount/tax when ordered subtotal is zero"}), 400

            if product.vat_item:
                stored_tax_subtotal = round(max(0.0, subtotal_sum - stored_discount_total) * vat_rate / 100.0, 2)

            # Persist pricing info onto the PO item when posting from receipt
            if stored_cost_price > 0:
                poi.received_cost_price = stored_cost_price
                pricing_marked = True
            if stored_discount_percent >= 0:
                poi.received_discount_percent = stored_discount_percent
                if stored_discount_percent > 0:
                    pricing_marked = True
            if stored_discount_total >= 0:
                poi.received_discount_total = stored_discount_total
                if stored_discount_total > 0:
                    pricing_marked = True
            if stored_tax_subtotal >= 0:
                poi.received_tax_subtotal = stored_tax_subtotal
                if stored_tax_subtotal > 0:
                    pricing_marked = True
            if stored_free_vat_percent >= 0:
                poi.received_free_vat_percent = stored_free_vat_percent
                if stored_free_vat_percent > 0:
                    pricing_marked = True
            if stored_mrp > 0:
                poi.received_mrp = stored_mrp
                pricing_marked = True
            else:
                fallback_mrp = max((float(r.get("effective_mrp") or 0.0) for r in line_buffers), default=0.0)
                if fallback_mrp > 0:
                    poi.received_mrp = fallback_mrp
                    pricing_marked = True

            poi.received_ordered_qty = round(float(ordered_sum or 0.0), 4)
            poi.received_free_qty = round(float(free_sum or 0.0), 4)

            remaining_discount = round(max(stored_discount_total, 0.0), 2)
            remaining_tax = round(max(stored_tax_subtotal, 0.0), 2)
            line_count = len(line_buffers)

            for line_idx, row in enumerate(line_buffers):
                subtotal = round(float(row.get("subtotal") or 0.0), 2)
                if subtotal_sum > 0 and line_idx < line_count - 1:
                    share = subtotal / subtotal_sum
                    discount_value = round(max(stored_discount_total, 0.0) * share, 2)
                    tax_value = round(max(stored_tax_subtotal, 0.0) * share, 2)
                    remaining_discount = round(remaining_discount - discount_value, 2)
                    remaining_tax = round(remaining_tax - tax_value, 2)
                else:
                    discount_value = remaining_discount
                    tax_value = remaining_tax

                line_total = round(subtotal - discount_value + tax_value, 2)
                if line_total < -1e-6:
                    db.session.rollback()
                    return jsonify({"error": f"Line {idx + 1}: Net line total cannot be negative"}), 400

                gross_total += line_total
                totals_items.append(
                    {
                        "product": product,
                        "ordered_qty": float(row.get("ordered_qty") or 0.0),
                        "free_qty": float(row.get("free_qty") or 0.0),
                        "cost_price": float(row.get("effective_cost") or 0.0),
                        "discount": round(discount_value, 2),
                        "tax_subtotal": round(tax_value, 2),
                        "free_vat_percent": stored_free_vat_percent,
                    }
                )
                db.session.add(
                    PurchaseBillItem(
                        purchase_bill=bill,
                        product=product,
                        uom=uom,
                        batch_number=row.get("batch_number"),
                        expiry_date=row.get("expiry_date"),
                        ordered_qty=float(row.get("ordered_qty") or 0.0),
                        free_qty=float(row.get("free_qty") or 0.0),
                        cost_price=float(row.get("effective_cost") or 0.0),
                        price=float(row.get("effective_mrp") or 0.0),  # legacy field compatibility
                        mrp=float(row.get("effective_mrp") or 0.0),
                        discount=round(discount_value, 2),
                        tax_subtotal=round(tax_value, 2),
                        free_vat_percent=stored_free_vat_percent,
                        line_total=round(line_total, 2),
                    )
                )

        totals = _compute_purchase_bill_totals(g.current_company, totals_items)
        bill.subtotal_total = totals.get("subtotal_total", 0.0)
        bill.discount_total = totals.get("discount_total", 0.0)
        bill.cc_free_item_amount = totals.get("cc_free_item_amount", 0.0)
        bill.vat_total = totals.get("vat_total", 0.0)
        bill.round_off = totals.get("round_off", float(bill.round_off or 0.0))
        bill.gross_total = totals.get("grand_total", round(gross_total, 2))
        db.session.flush()

        post_ts = datetime.now(timezone.utc)
        # Set posted_at before applying inventory so batches get a stable arrival timestamp.
        bill.posted_at = post_ts

        # Post the bill to inventory immediately.
        try:
            _apply_purchase_bill_to_inventory(g.current_company.id, bill, reason="purchase_bill")
        except ValueError as exc:
            db.session.rollback()
            return jsonify({"error": str(exc)}), 400

        bill.posted = True
        bill.posted_at = bill.posted_at or post_ts
        product_ids = {int(it.product_id) for it in (bill.items or []) if getattr(it, "product_id", None)}
        if product_ids:
            _relink_pending_backdated_sales(company_id=g.current_company.id, product_ids=product_ids)

        total_cost = float(bill.gross_total or 0.0)
        if total_cost > 0:
            payable_name = _purchase_payable_account_name(g.current_company.id, bill.supplier)
            _post_double_entry(
                g.current_company.id,
                "Inventory",
                "asset",
                payable_name,
                "liability",
                total_cost,
                "purchase_bill",
                bill.id,
                f"Purchase bill #{bill.id}",
            )

        po.receipt_date = bill_date
        po.expected_arrival = po.receipt_date
        po.purchase_bill_id = bill.id
        po.received_at = post_ts
        po.status = "received"
        po.received_by_user_id = int(getattr(g.current_user, "id", 0) or 0) or None
        if pricing_marked:
            po.pricing_received_at = po.pricing_received_at or post_ts
            po.pricing_received_by_user_id = po.pricing_received_by_user_id or int(getattr(g.current_user, "id", 0) or 0) or None

        db.session.commit()
        socketio.emit(
            "inventory:update",
            {"type": "purchase_bill_created", "purchase_bill": bill.to_dict(include_items=True), "company_id": g.current_company.id},
        )
        socketio.emit(
            "inventory:update",
            {"type": "purchase_bill_posted", "purchase_bill": bill.to_dict(include_items=True), "company_id": g.current_company.id},
        )
        log_action(
            "purchase_order_received",
            {"purchase_order_id": po.id, "purchase_bill_id": bill.id, "lines": len(bill.items or [])},
            g.current_company.id,
        )
        return jsonify(po.to_dict(include_items=True))

    @app.route("/purchase-orders/audit/received", methods=["GET"])
    @require_auth
    @company_required(["admin", "manager"])
    def audit_received_purchase_orders():
        """
        Checks received POs (linked to a purchase bill) for missing cost_price / mrp / qty integrity.
        This is a diagnostic endpoint; it does not change data.
        """
        company_id = g.current_company.id
        received = PurchaseOrder.query.filter(
            PurchaseOrder.company_id == company_id,
            PurchaseOrder.purchase_bill_id.isnot(None),
        ).all()

        issues = []
        for po in received:
            bill = PurchaseBill.query.get(po.purchase_bill_id)
            if not bill or bill.company_id != company_id:
                issues.append({"purchase_order_id": po.id, "error": "missing_purchase_bill", "purchase_bill_id": po.purchase_bill_id})
                continue

            for idx, it in enumerate(bill.items or []):
                if int(it.ordered_qty or 0) <= 0:
                    issues.append({"purchase_order_id": po.id, "purchase_bill_id": bill.id, "line": idx + 1, "error": "ordered_qty_missing"})
                if float(it.cost_price or 0.0) <= 0:
                    issues.append({"purchase_order_id": po.id, "purchase_bill_id": bill.id, "line": idx + 1, "error": "cost_price_missing"})
                if float(it.mrp or 0.0) <= 0 and float(getattr(it, "price", 0.0) or 0.0) <= 0:
                    issues.append({"purchase_order_id": po.id, "purchase_bill_id": bill.id, "line": idx + 1, "error": "mrp_missing"})

        return jsonify({"received_purchase_orders": len(received), "issues": issues})

    @app.route("/purchase-bills", methods=["POST"])
    @require_auth
    @company_required(["admin", "manager"])
    def create_purchase_bill():
        data = request.get_json() or {}
        supplier_id = data.get("supplier_id")
        if not supplier_id:
            return jsonify({"error": "Supplier is required"}), 400
        supplier = Supplier.query.get(supplier_id)
        if not supplier or supplier.company_id != g.current_company.id:
            return jsonify({"error": "Supplier not found"}), 404

        purchase_date_raw = (
            data.get("purchase_date")
            or data.get("date_of_purchase")
            or data.get("date")
        )
        if purchase_date_raw:
            try:
                purchase_date = datetime.fromisoformat(str(purchase_date_raw)).date()
            except ValueError:
                return jsonify({"error": "Invalid purchase_date (expected YYYY-MM-DD)"}), 400
        else:
            purchase_date = _today_ad()

        items = data.get("items") or []
        if not isinstance(items, list) or not items:
            return jsonify({"error": "Purchase bill items are required"}), 400

        vat_percent = float(getattr(g.current_company, "vat_purchase_percent", 0.0) or 0.0)
        vat_rate = max(0.0, vat_percent)

        bill = PurchaseBill(
            company_id=g.current_company.id,
            supplier=supplier,
            purchase_date=purchase_date,
            posted=False,
            created_by_user_id=int(getattr(g.current_user, "id", 0) or 0) or None,
        )
        bill.bill_number = _make_purchase_bill_number(g.current_company.id, purchase_date)
        creator_role = (g.company_role or g.current_user.role or "").strip().lower()
        effective_permissions = set(_effective_permission_keys(g.current_user, g.current_company.id, g.company_role))
        auto_approve = creator_role not in {"staff", "salesman"} or "purchase_bills_approve" in effective_permissions
        bill.approval_status = "approved" if auto_approve else "pending"
        if auto_approve:
            bill.approved_at = datetime.now(timezone.utc)
            bill.approved_by_user_id = int(getattr(g.current_user, "id", 0) or 0) or None
        db.session.add(bill)

        gross_total = 0.0
        totals_items: list[dict] = []
        added_any = False
        for idx, item in enumerate(items):
            product_id = item.get("product_id")
            if not product_id:
                db.session.rollback()
                return jsonify({"error": f"Line {idx + 1}: product_id is required"}), 400

            product = Product.query.get(product_id)
            if not product or product.company_id != g.current_company.id:
                db.session.rollback()
                return jsonify({"error": f"Line {idx + 1}: Invalid product"}), 400

            try:
                ordered_qty = float(item.get("ordered_qty", item.get("qty", 0)) or 0)
                free_qty = float(item.get("free_qty", 0) or 0)
            except (TypeError, ValueError):
                db.session.rollback()
                return jsonify({"error": f"Line {idx + 1}: Invalid quantities"}), 400

            if ordered_qty < 0 or free_qty < 0 or (ordered_qty + free_qty) <= 0:
                db.session.rollback()
                return jsonify({"error": f"Line {idx + 1}: Quantity must be greater than 0"}), 400

            uom_raw = (item.get("uom") or "").strip() or None
            uom = _validate_uom_for_product(product, uom_raw)
            if product.uom_category and not uom:
                # If there is no unit setup for the category, keep the category label as a fallback UoM.
                uom = product.uom_category

            batch_number = (
                (item.get("batch_number") or item.get("lot_number") or item.get("serial_number") or "").strip()
                or None
            )
            if product.lot_tracking and not batch_number:
                db.session.rollback()
                return jsonify({"error": f"Line {idx + 1}: Batch/serial number required for {product.name}"}), 400

            try:
                cost_price = float(item.get("cost_price", 0.0) or 0.0)
                mrp = float(item.get("mrp", item.get("price", 0.0)) or 0.0)
                discount = float(item.get("discount", 0.0) or 0.0)
                tax_subtotal = float(item.get("tax_subtotal", item.get("tax", 0.0)) or 0.0)
                free_vat_percent = float(item.get("free_vat_percent", 0.0) or 0.0)
            except (TypeError, ValueError):
                db.session.rollback()
                return jsonify({"error": f"Line {idx + 1}: Invalid price/discount/tax values"}), 400

            if cost_price < 0 or mrp < 0 or discount < 0 or tax_subtotal < 0 or free_vat_percent < 0:
                db.session.rollback()
                return jsonify({"error": f"Line {idx + 1}: Amounts must be >= 0"}), 400

            expiry_date = None
            if item.get("expiry_date"):
                raw = str(item.get("expiry_date")).strip()
                try:
                    expiry_date = datetime.fromisoformat(raw).date()
                except ValueError:
                    m = re.match(r"^(\\d{2})/(\\d{4})$", raw)
                    if m:
                        month = int(m.group(1))
                        year = int(m.group(2))
                        if month < 1 or month > 12:
                            db.session.rollback()
                            return jsonify({"error": f"Line {idx + 1}: Invalid expiry_date"}), 400
                        expiry_date = datetime(year, month, 1).date()
                    else:
                        db.session.rollback()
                        return jsonify({"error": f"Line {idx + 1}: Invalid expiry_date"}), 400
            if (product.expiry_tracking or product.shelf_removal) and not expiry_date:
                db.session.rollback()
                return jsonify({"error": f"Line {idx + 1}: Expiry date required for {product.name}"}), 400

            subtotal = ordered_qty * cost_price
            if product.vat_item:
                tax_subtotal = round(max(0.0, subtotal - discount) * vat_rate / 100.0, 2)
            line_total = subtotal - discount + tax_subtotal
            if line_total < 0:
                db.session.rollback()
                return jsonify({"error": f"Line {idx + 1}: Total cannot be negative"}), 400

            bill_item = PurchaseBillItem(
                purchase_bill=bill,
                product=product,
                uom=uom,
                batch_number=batch_number,
                expiry_date=expiry_date,
                ordered_qty=ordered_qty,
                free_qty=free_qty,
                cost_price=cost_price,
                price=mrp,
                mrp=mrp,
                discount=discount,
                tax_subtotal=tax_subtotal,
                free_vat_percent=free_vat_percent,
                line_total=round(line_total, 2),
            )
            db.session.add(bill_item)
            added_any = True

            gross_total += line_total
            totals_items.append(
                {
                    "product": product,
                    "ordered_qty": ordered_qty,
                    "free_qty": free_qty,
                    "cost_price": cost_price,
                    "discount": discount,
                    "tax_subtotal": tax_subtotal,
                    "free_vat_percent": free_vat_percent,
                }
            )

        if not added_any:
            db.session.rollback()
            return jsonify({"error": "At least one valid item line is required"}), 400

        totals = _compute_purchase_bill_totals(g.current_company, totals_items)
        bill.subtotal_total = totals.get("subtotal_total", 0.0)
        bill.discount_total = totals.get("discount_total", 0.0)
        bill.cc_free_item_amount = totals.get("cc_free_item_amount", 0.0)
        bill.vat_total = totals.get("vat_total", 0.0)
        bill.round_off = totals.get("round_off", float(bill.round_off or 0.0))
        bill.gross_total = totals.get("grand_total", round(gross_total, 2))
        posted_now = False
        if auto_approve:
            posted_now = _post_purchase_bill_if_ready(bill)
        db.session.commit()
        socketio.emit(
            "inventory:update",
            {"type": "purchase_bill_created", "purchase_bill": bill.to_dict(include_items=True), "company_id": g.current_company.id},
        )
        if posted_now:
            socketio.emit(
                "inventory:update",
                {"type": "purchase_bill_posted", "purchase_bill": bill.to_dict(include_items=True), "company_id": g.current_company.id},
            )
        log_action(
            "purchase_bill_created",
            {"purchase_bill_id": bill.id, "supplier_id": supplier.id, "gross_total": bill.gross_total},
            g.current_company.id,
        )
        if posted_now:
            log_action(
                "purchase_bill_posted",
                {"purchase_bill_id": bill.id, "supplier_id": bill.supplier_id, "gross_total": bill.gross_total},
                g.current_company.id,
            )
        return jsonify(bill.to_dict(include_items=True)), 201

    @app.route("/purchase-bills/repair-numbers", methods=["POST"])
    @require_auth
    @company_required(["admin", "manager", "superuser"])
    def repair_purchase_bill_numbers():
        changed = _renumber_purchase_bills(company_id=g.current_company.id)
        db.session.commit()
        log_action(
            "purchase_bill_numbers_repaired",
            {"changed": changed},
            g.current_company.id,
        )
        return jsonify({"repaired": True, "changed": changed})

    # Products
    def _normalize_product_name(raw: str) -> str:
        """
        Normalize product names for duplicate detection.
        Rules:
        - lowercase
        - strip extra whitespace
        - normalize common unit separators (e.g., '100 ml' -> '100ml', '10 x 10' -> '10x10')
        - remove non-alphanumeric characters (keep digits/letters)
        """
        if not raw:
            return ""
        text = str(raw).strip().lower()
        # unify common separators
        text = re.sub(r"\s+x\s+", "x", text)
        text = re.sub(r"(\d)\s*(mg|ml|g|kg|l|lt|ltr|mcg|ug|iu|uom|tab|tabs|tablet|tablets|cap|caps|capsule|capsules|pcs|pc|piece|pieces)\b",
                      r"\1\2", text)
        # normalize repeated whitespace and punctuation to nothing
        text = re.sub(r"[\s\-\_/().,]+", "", text)
        # drop any remaining non-alphanumeric chars
        text = re.sub(r"[^a-z0-9]", "", text)
        return text

    def _is_duplicate_product_name(company_id: int, name: str, exclude_id: int | None = None) -> bool:
        normalized = _normalize_product_name(name)
        if not normalized:
            return False
        query = db.session.query(Product.id, Product.name).filter_by(company_id=company_id)
        if exclude_id:
            query = query.filter(Product.id != exclude_id)
        for _pid, existing_name in query.all():
            if _normalize_product_name(existing_name) == normalized:
                return True
        return False

    def _backfill_merged_products(company_id: int) -> int:
        changed = 0
        rows = (
            ActivityLog.query.filter(
                ActivityLog.company_id == company_id,
                ActivityLog.action == "products_merged",
            )
            .order_by(ActivityLog.id.asc())
            .all()
        )
        for row in rows:
            details = row.details or {}
            try:
                target_id = int(details.get("target_product_id") or 0)
            except (TypeError, ValueError):
                target_id = 0
            source_ids = details.get("source_product_ids") or []
            if target_id <= 0 or not isinstance(source_ids, list):
                continue
            for raw in source_ids:
                try:
                    source_id = int(raw)
                except (TypeError, ValueError):
                    continue
                if source_id <= 0 or source_id == target_id:
                    continue
                product = Product.query.filter_by(company_id=company_id, id=source_id).first()
                if not product:
                    continue
                if int(product.merged_into_product_id or 0) == target_id and product.is_active is False:
                    continue
                product.merged_into_product_id = target_id
                product.is_active = False
                product.stock = 0
                changed += 1
        if changed:
            db.session.flush()
        return changed

    @app.route("/products", methods=["GET"])
    @require_auth
    @company_required()
    def get_products():
        _backfill_merged_products(g.current_company.id)
        query = Product.query.filter_by(company_id=g.current_company.id)
        include_merged = request.args.get("include_merged") == "1"
        if not include_merged:
            query = query.filter(Product.merged_into_product_id.is_(None))
        inventory_only = request.args.get("inventory_only") == "1"
        strict_raw = (request.args.get("strict") or "1").strip().lower()
        strict_reconcile = strict_raw not in {"0", "false", "no"}
        if inventory_only and strict_reconcile:
            _maybe_reconcile_inventory(g.current_company.id)
        search = request.args.get("q")
        if search:
            like = f"%{search.strip()}%"
            query = query.filter(
                or_(
                    Product.name.ilike(like),
                    Product.sku.ilike(like),
                    Product.composition.ilike(like),
                    Product.manufacturer.ilike(like),
                    Product.hscode.ilike(like),
                )
            )
        if inventory_only:
            batch_product_ids = (
                db.session.query(InventoryBatch.product_id)
                .filter(
                    InventoryBatch.company_id == g.current_company.id,
                    InventoryBatch.qty_base > 0,
                )
                .distinct()
            )
            query = query.filter(
                or_(
                    Product.id.in_(batch_product_ids),
                    Product.stock > 0,
                )
            )
        products, meta = paginate_query(query.order_by(Product.name.asc()))
        return jsonify({"data": [p.to_dict() for p in products], "pagination": meta})

    @app.route("/products/<int:product_id>/uoms", methods=["GET"])
    @require_auth
    @company_required()
    def product_uoms(product_id: int):
        product = Product.query.get_or_404(product_id)
        if product.company_id != g.current_company.id:
            return jsonify({"error": "Forbidden"}), 403
        if not product.uom_category:
            return jsonify({"units": []})
        categories = _get_unit_categories(g.current_company.id, product.uom_category)
        if not categories:
            return jsonify({"units": []})
        usage_mode = (request.args.get("usage") or "").strip().lower()
        category_ids = [c.id for c in categories]
        units = (
            Unit.query.filter(
                Unit.category_id.in_(category_ids),
                Unit.company_id == g.current_company.id,
                or_(Unit.is_archived.is_(False), Unit.is_archived.is_(None)),
            )
            .all()
        )
        usage_counts: dict[str, float] = {}
        if usage_mode in {"purchase", "purchases", "purchase_bills", "purchase_orders"}:
            po_counts = (
                db.session.query(PurchaseOrderItem.uom, func.count(PurchaseOrderItem.id))
                .join(PurchaseOrder, PurchaseOrderItem.purchase_order_id == PurchaseOrder.id)
                .filter(
                    PurchaseOrder.company_id == g.current_company.id,
                    PurchaseOrderItem.product_id == product.id,
                )
                .group_by(PurchaseOrderItem.uom)
                .all()
            )
            pb_counts = (
                db.session.query(PurchaseBillItem.uom, func.count(PurchaseBillItem.id))
                .join(PurchaseBill, PurchaseBillItem.purchase_bill_id == PurchaseBill.id)
                .filter(
                    PurchaseBill.company_id == g.current_company.id,
                    PurchaseBillItem.product_id == product.id,
                )
                .group_by(PurchaseBillItem.uom)
                .all()
            )
            for uom, count in (po_counts or []):
                if uom:
                    usage_counts[str(uom)] = usage_counts.get(str(uom), 0) + float(count or 0)
            for uom, count in (pb_counts or []):
                if uom:
                    usage_counts[str(uom)] = usage_counts.get(str(uom), 0) + float(count or 0)
        elif usage_mode in {"sales", "sale"}:
            sale_counts = (
                db.session.query(SaleItem.uom, func.count(SaleItem.id))
                .join(Sale, SaleItem.sale_id == Sale.id)
                .filter(
                    Sale.company_id == g.current_company.id,
                    SaleItem.product_id == product.id,
                )
                .group_by(SaleItem.uom)
                .all()
            )
            for uom, count in (sale_counts or []):
                if uom:
                    usage_counts[str(uom)] = usage_counts.get(str(uom), 0) + float(count or 0)

        if usage_counts:
            units = sorted(
                units,
                key=lambda u: (
                    -usage_counts.get(str(u.name), 0),
                    0 if u.is_base else 1,
                    str(u.name or "").lower(),
                ),
            )
        else:
            units = sorted(
                units,
                key=lambda u: (0 if u.is_base else 1, str(u.name or "").lower()),
            )

        return jsonify({"units": [u.to_dict() for u in units]})

    @app.route("/products/menu", methods=["GET"])
    @require_auth
    @company_required()
    def product_menu():
        # Menu options provided by backend so frontend simply renders them
        items = [
            {"id": "create", "label": "Create new product"},
            {"id": "list", "label": "Products list"},
            {"id": "archived", "label": "Archived products"},
            {"id": "returns", "label": "Returns history"},
            {"id": "expired", "label": "Expired products"},
        ]
        return jsonify(items)


    @app.route("/products/schema", methods=["GET"])
    @require_auth
    @company_required()
    def product_schema():
        schema = [
            {"key": "name", "label": "Product Name", "type": "string", "required": True, "default": ""},
            {"key": "sku", "label": "SKU (Auto)", "type": "string", "required": False, "default": ""},
            {"key": "composition", "label": "Composition", "type": "string", "required": False, "default": ""},
            {"key": "reorder_level", "label": "Reorder Level", "type": "number", "required": False, "default": 5},
            {"key": "is_active", "label": "Is Active", "type": "boolean", "required": False, "default": True},
            {"key": "alias_name", "label": "Alias Name", "type": "string", "required": False, "default": ""},
            {"key": "uom_category", "label": "UoM Category", "type": "string", "required": False, "default": ""},
            {"key": "prescription_required", "label": "Prescription Required", "type": "boolean", "required": False, "default": False},
            {"key": "recurrent", "label": "Recurrent Medicine", "type": "boolean", "required": False, "default": False},
            {"key": "shelf_removal", "label": "Shelf Removal", "type": "boolean", "required": False, "default": False},
            {"key": "shelf_removal_offset_days", "label": "Shelf Removal Offset (Days)", "type": "number", "required": False, "default": 30},
            {"key": "lot_tracking", "label": "Lot/Serial Tracking", "type": "boolean", "required": False, "default": False},
            {"key": "lot_number", "label": "Lot/Serial Number", "type": "string", "required": False, "default": ""},
            {"key": "vat_item", "label": "VAT Item", "type": "boolean", "required": False, "default": False},
            {"key": "low_threshold", "label": "Low Threshold Warning", "type": "number", "required": False, "default": 0},
        ]
        return jsonify(schema)

    @app.route("/products/unit-categories", methods=["GET"])
    @require_auth
    @company_required()
    def product_unit_categories():
        include_archived = request.args.get("include_archived") == "1"
        query = UnitCategory.query.filter_by(company_id=g.current_company.id)
        if not include_archived:
            query = query.filter(or_(UnitCategory.is_archived.is_(False), UnitCategory.is_archived.is_(None)))
        cats = query.order_by(UnitCategory.name.asc()).all()
        return jsonify([c.to_dict(include_units=True) for c in cats])

    @app.route("/suppliers/schema", methods=["GET"])
    @require_auth
    @company_required()
    def supplier_schema():
        schema = [
            {"key": "name", "label": "Supplier Name", "type": "string", "required": True, "default": ""},
            {"key": "address", "label": "Address", "type": "string", "required": False, "default": ""},
            {"key": "city", "label": "City", "type": "string", "required": False, "default": ""},
            {"key": "phone", "label": "Phone", "type": "tel", "required": False, "default": ""},
            {"key": "contact_name", "label": "Contact Name", "type": "string", "required": False, "default": ""},
            {"key": "contact_phone", "label": "Contact Phone", "type": "tel", "required": False, "default": ""},
            {"key": "dda_number", "label": "DDA Number", "type": "string", "required": False, "default": ""},
            {"key": "pan_vat_number", "label": "PAN/VAT Number", "type": "string", "required": False, "default": ""},
            {"key": "location_url", "label": "Location URL", "type": "string", "required": False, "default": ""},
            {"key": "email", "label": "Email", "type": "email", "required": False, "default": ""},
            {"key": "is_archived", "label": "Is Archived", "type": "boolean", "required": False, "default": False},
        ]
        return jsonify(schema)

    @app.route("/products", methods=["POST"])
    @require_auth
    @company_required()
    @require_permission(products_create)
    def create_product():
        data = request.get_json() or {}
        name = (data.get("name") or "").strip()
        if not name:
            return jsonify({"error": "Missing required fields"}), 400
        if _is_duplicate_product_name(g.current_company.id, name):
            return jsonify({"error": "Duplicate product name (same type/name/quantity)."}), 400

        def _generate_sku(company_id: int) -> str:
            prefix = "SKU"
            # attempt simple incremental SKU per company
            existing_count = db.session.query(db.func.count(Product.id)).filter_by(company_id=company_id).scalar() or 0
            candidate = f"{prefix}-{existing_count + 1:04d}"
            # ensure uniqueness
            while Product.query.filter_by(company_id=company_id, sku=candidate).first():
                existing_count += 1
                candidate = f"{prefix}-{existing_count + 1:04d}"
            return candidate

        # SKU is system-generated; ignore incoming value from clients.
        sku_value = _generate_sku(g.current_company.id)

        if data.get("uom_category"):
            _get_or_create_unit_category(g.current_company.id, data.get("uom_category"))

        def coerce_value(field_key, value):
            field = next((f for f in PRODUCT_SCHEMA if f["key"] == field_key), None)
            if not field:
                return value
            if field["type"] == "boolean":
                return bool(value)
            if field["type"] == "number":
                return int(value) if value not in (None, "") else 0
            return value or ""

        def _to_int(val, default=0):
            try:
                return int(val)
            except Exception:
                return default

        product = Product(
            company_id=g.current_company.id,
            name=name,
            sku=sku_value,
            composition=data.get("composition", "") or "",
            price=0.0,
            stock=0,
            reorder_level=_to_int(data.get("reorder_level", 5), 0),
            is_active=bool(data.get("is_active", True) if data.get("is_active") is not None else (data.get("status", "") != "inactive")),
        )
        try:
            for f in PRODUCT_SCHEMA:
                if f["key"] in data:
                    setattr(product, f["key"], coerce_value(f["key"], data[f["key"]]))
                else:
                    setattr(product, f["key"], coerce_value(f["key"], f.get("default")))
            if product.shelf_removal:
                product.expiry_tracking = True
            db.session.add(product)
            db.session.add(
                InventoryLog(
                    company_id=g.current_company.id, product=product, change=product.stock, reason="initial_stock"
                )
            )
            db.session.commit()
        except Exception as exc:
            db.session.rollback()
            return jsonify({"error": f"Failed to save product: {exc}"}), 500
        log_action("product_created", {"id": product.id, "sku": product.sku, "name": product.name}, g.current_company.id)
        socketio.emit("inventory:update", {"type": "product_created", "product": product.to_dict()},)
        return jsonify(product.to_dict()), 201

    @app.route("/products/<int:product_id>", methods=["PUT"])
    @require_auth
    @company_required()
    @require_permission(products_edit)
    def update_product(product_id):
        product = Product.query.get_or_404(product_id)
        if product.company_id != g.current_company.id:
            return jsonify({"error": "Forbidden"}), 403
        data = request.get_json() or {}
        allowed_fields = ["name", "composition", "reorder_level", "is_active"]
        schema_fields = [f["key"] for f in PRODUCT_SCHEMA]
        for field in allowed_fields + schema_fields:
            if field in data:
                val = data[field]
                if field == "uom_category" and val:
                    _get_or_create_unit_category(g.current_company.id, val)
                if field in schema_fields:
                    field_type = next((f for f in PRODUCT_SCHEMA if f["key"] == field), {}).get("type")
                    if field_type == "boolean":
                        val = bool(val)
                    elif field_type == "number":
                        val = int(val or 0)
                    else:
                        val = val or ""
                if field == "name":
                    val = (val or "").strip()
                    if not val:
                        return jsonify({"error": "Name required"}), 400
                    if _is_duplicate_product_name(g.current_company.id, val, exclude_id=product.id):
                        return jsonify({"error": "Duplicate product name (same type/name/quantity)."}), 400
                setattr(product, field, val)
        if product.shelf_removal:
            product.expiry_tracking = True
        db.session.commit()
        log_action("product_updated", {"id": product.id}, g.current_company.id)
        socketio.emit("inventory:update", {"type": "inventory_adjusted", "product": product.to_dict()},)
        return jsonify(product.to_dict())

    @app.route("/products/merge", methods=["POST"])
    @require_auth
    @company_required()
    def merge_products():
        role_lc = (g.company_role or "").strip().lower()
        user_role = str(getattr(g.current_user, "role", "") or "").strip().lower()
        is_superadmin = user_role == "superadmin"
        if not is_superadmin and role_lc not in {"manager", "admin", "superuser"}:
            return jsonify({"error": "Only manager/admin/superuser/superadmin can merge products"}), 403
        effective_permissions = set(_effective_permission_keys(g.current_user, g.current_company.id, g.company_role))
        if "products_edit" not in effective_permissions and not is_superadmin:
            return jsonify({"error": "Insufficient permissions to merge products"}), 403

        data = request.get_json(silent=True) or {}
        target_id = data.get("target_product_id")
        source_ids = data.get("source_product_ids") or []
        try:
            target_id = int(target_id)
        except (TypeError, ValueError):
            return jsonify({"error": "target_product_id is required"}), 400
        if not isinstance(source_ids, list) or not source_ids:
            return jsonify({"error": "source_product_ids must be a non-empty list"}), 400

        source_ids_clean: list[int] = []
        for raw in source_ids:
            try:
                pid = int(raw)
            except (TypeError, ValueError):
                continue
            if pid == target_id:
                continue
            source_ids_clean.append(pid)
        if not source_ids_clean:
            return jsonify({"error": "No valid source_product_ids provided"}), 400

        company_id = g.current_company.id
        target = Product.query.get_or_404(target_id)
        if target.company_id != company_id:
            return jsonify({"error": "Target product not found"}), 404

        sources = Product.query.filter(Product.company_id == company_id, Product.id.in_(source_ids_clean)).all()
        if len(sources) != len(set(source_ids_clean)):
            return jsonify({"error": "One or more source products not found"}), 404

        # Enforce merge compatibility: same unit category, VAT flag, and CC flag
        mismatches = []
        target_uom = (target.uom_category or "").strip()
        target_vat = bool(getattr(target, "vat_item", False))
        target_cc = bool(getattr(target, "charge_cc_free_items", False))
        for src in sources:
            src_uom = (src.uom_category or "").strip()
            src_vat = bool(getattr(src, "vat_item", False))
            src_cc = bool(getattr(src, "charge_cc_free_items", False))
            if src_uom != target_uom or src_vat != target_vat or src_cc != target_cc:
                mismatches.append(
                    {
                        "id": src.id,
                        "name": src.name,
                        "uom_category": src.uom_category,
                        "vat_item": src_vat,
                        "charge_cc_free_items": src_cc,
                    }
                )
        if mismatches:
            return (
                jsonify(
                    {
                        "error": "Merge requires identical Unit Category, VAT Item, and CC settings.",
                        "mismatches": mismatches,
                        "target": {
                            "id": target.id,
                            "name": target.name,
                            "uom_category": target.uom_category,
                            "vat_item": target_vat,
                            "charge_cc_free_items": target_cc,
                        },
                    }
                ),
                400,
            )

        merged_batches = 0
        moved_batches = 0
        updated_refs = 0

        for src in sources:
            src_id = src.id
            batches = InventoryBatch.query.filter_by(company_id=company_id, product_id=src_id).all()
            for batch in batches:
                match = InventoryBatch.query.filter_by(
                    company_id=company_id,
                    product_id=target_id,
                    batch_number=batch.batch_number,
                    expiry_date=batch.expiry_date,
                    mrp=batch.mrp,
                    uom=batch.uom,
                    factor_to_base=batch.factor_to_base,
                    mrp_per_uom=batch.mrp_per_uom,
                ).first()
                if match:
                    SaleItem.query.filter_by(inventory_batch_id=batch.id).update(
                        {SaleItem.inventory_batch_id: match.id},
                        synchronize_session=False,
                    )
                    StockAdjustment.query.filter_by(inventory_batch_id=batch.id).update(
                        {StockAdjustment.inventory_batch_id: match.id},
                        synchronize_session=False,
                    )
                    match.qty_base = int(match.qty_base or 0) + int(batch.qty_base or 0)
                    if batch.arrival_at and (not match.arrival_at or batch.arrival_at < match.arrival_at):
                        match.arrival_at = batch.arrival_at
                    db.session.delete(batch)
                    merged_batches += 1
                else:
                    batch.product_id = target_id
                    moved_batches += 1

            updated_refs += PurchaseBillItem.query.filter_by(product_id=src_id).update(
                {PurchaseBillItem.product_id: target_id}, synchronize_session=False
            )
            updated_refs += PurchaseOrderItem.query.filter_by(product_id=src_id).update(
                {PurchaseOrderItem.product_id: target_id}, synchronize_session=False
            )
            updated_refs += PurchaseOrderReceiptLine.query.filter_by(product_id=src_id).update(
                {PurchaseOrderReceiptLine.product_id: target_id}, synchronize_session=False
            )
            updated_refs += SaleItem.query.filter_by(product_id=src_id).update(
                {SaleItem.product_id: target_id}, synchronize_session=False
            )
            updated_refs += SaleReturnItem.query.filter_by(product_id=src_id).update(
                {SaleReturnItem.product_id: target_id}, synchronize_session=False
            )
            updated_refs += InventoryLog.query.filter_by(product_id=src_id).update(
                {InventoryLog.product_id: target_id}, synchronize_session=False
            )
            updated_refs += StockAdjustment.query.filter_by(product_id=src_id).update(
                {StockAdjustment.product_id: target_id}, synchronize_session=False
            )
            updated_refs += ExpiryReturnLine.query.filter_by(product_id=src_id).update(
                {
                    ExpiryReturnLine.product_id: target_id,
                    ExpiryReturnLine.product_name: target.name,
                },
                synchronize_session=False,
            )
            # Also update legacy expiry-return rows that only stored the product name.
            updated_refs += ExpiryReturnLine.query.filter(
                ExpiryReturnLine.product_id.is_(None),
                ExpiryReturnLine.product_name == src.name,
            ).update(
                {
                    ExpiryReturnLine.product_id: target_id,
                    ExpiryReturnLine.product_name: target.name,
                },
                synchronize_session=False,
            )

            src.is_active = False
            src.stock = 0
            src.merged_into_product_id = target_id

        _recompute_product_stock_from_batches(company_id, target)

        db.session.commit()
        log_action(
            "products_merged",
            {
                "target_product_id": target_id,
                "target_product_name": target.name,
                "source_product_ids": source_ids_clean,
                "source_product_names": [src.name for src in sources],
                "merged_batches": merged_batches,
                "moved_batches": moved_batches,
                "updated_refs": updated_refs,
            },
            company_id,
        )
        socketio.emit(
            "inventory:update",
            {
                "type": "products_merged",
                "company_id": company_id,
                "target_product_id": target_id,
                "source_product_ids": source_ids_clean,
            },
        )
        return jsonify(
            {
                "message": "Products merged",
                "target_product_id": target_id,
                "source_product_ids": source_ids_clean,
                "merged_batches": merged_batches,
                "moved_batches": moved_batches,
                "updated_refs": updated_refs,
            }
        )

    @app.route("/products/<int:product_id>", methods=["DELETE"])
    @require_auth
    @company_required()
    @require_permission(products_delete)
    def delete_product(product_id):
        product = Product.query.get_or_404(product_id)
        if product.company_id != g.current_company.id:
            return jsonify({"error": "Forbidden"}), 403
        if product_used(product):
            return jsonify({"error": "Product in use; cannot delete"}), 400
        db.session.delete(product)
        db.session.commit()
        log_action("product_deleted", {"id": product.id}, g.current_company.id)
        socketio.emit("inventory:update", {"type": "product_deleted", "product_id": product_id},)
        return jsonify({"deleted": True})

    @app.route("/products/<int:product_id>/sales-summary", methods=["GET"])
    @require_auth
    @company_required()
    def product_sales_summary(product_id: int):
        """Return last 12 months of sales quantity for a product (per company)."""
        product = Product.query.get_or_404(product_id)
        if product.company_id != g.current_company.id:
            return jsonify({"error": "Forbidden"}), 403

        today = date.today()
        start_month = today.replace(day=1)

        def month_back(dt: date, months: int) -> date:
            year = dt.year
            month = dt.month - months
            while month <= 0:
                month += 12
                year -= 1
            return date(year, month, 1)

        cutoff = month_back(start_month, 11)

        rows = (
            db.session.query(Sale.sale_date, Sale.created_at, SaleItem.quantity)
            .join(Sale, Sale.id == SaleItem.sale_id)
            .filter(Sale.company_id == g.current_company.id, SaleItem.product_id == product_id)
            .all()
        )

        monthly: dict[str, float] = {}
        for sale_date, created_at, qty in rows:
            dt = sale_date or (created_at.date() if created_at else None)
            if not dt or dt < cutoff:
                continue
            key = dt.strftime("%Y-%m")
            monthly[key] = monthly.get(key, 0) + float(qty or 0)

        months_out = []
        for i in range(12):
            mdate = month_back(start_month, 11 - i)
            key = mdate.strftime("%Y-%m")
            months_out.append(
                {
                    "month": key,
                    "label": mdate.strftime("%b %Y"),
                    "qty": monthly.get(key, 0),
                }
            )

        return jsonify({"product_id": product.id, "months": months_out})

    @app.route("/products/<int:product_id>/purchase-history", methods=["GET"])
    @require_auth
    @company_required()
    def product_purchase_history(product_id: int):
        product = Product.query.get_or_404(product_id)
        if product.company_id != g.current_company.id:
            return jsonify({"error": "Forbidden"}), 403

        rows = (
            db.session.query(PurchaseBillItem, PurchaseBill, Supplier)
            .join(PurchaseBill, PurchaseBillItem.purchase_bill_id == PurchaseBill.id)
            .outerjoin(Supplier, PurchaseBill.supplier_id == Supplier.id)
            .filter(
                PurchaseBill.company_id == g.current_company.id,
                PurchaseBillItem.product_id == product.id,
            )
            .order_by(PurchaseBill.purchase_date.desc(), PurchaseBill.id.desc(), PurchaseBillItem.id.desc())
            .all()
        )

        items: list[dict] = []
        total_qty = 0.0
        total_amount = 0.0
        for item, bill, supplier in rows:
            ordered_qty = float(item.ordered_qty or 0.0)
            free_qty = float(item.free_qty or 0.0)
            bought_qty = ordered_qty + free_qty
            line_total = float(item.line_total or 0.0)
            total_qty += bought_qty
            total_amount += line_total
            items.append(
                {
                    "purchase_bill_id": int(bill.id),
                    "bill_number": bill.bill_number or str(bill.id),
                    "purchase_date": bill.purchase_date.isoformat() if bill.purchase_date else None,
                    "supplier_name": supplier.name if supplier else None,
                    "ordered_qty": round(ordered_qty, 2),
                    "free_qty": round(free_qty, 2),
                    "bought_qty": round(bought_qty, 2),
                    "uom": item.uom or (_base_uom_name(product) or product.uom_category or "-"),
                    "line_total": round(line_total, 2),
                }
            )

        return jsonify(
            {
                "product_id": int(product.id),
                "product_name": product.name,
                "base_uom": _base_uom_name(product) or product.uom_category or "-",
                "total_bought_qty": round(total_qty, 2),
                "total_bought_amount": round(total_amount, 2),
                "items": items,
            }
        )

    @app.route("/products/<int:product_id>/adjust", methods=["POST"])
    @require_auth
    @company_required(["admin", "manager", "superuser"])
    def adjust_inventory(product_id):
        product = Product.query.get_or_404(product_id)
        if product.company_id != g.current_company.id:
            return jsonify({"error": "Forbidden"}), 403
        data = request.get_json() or {}
        try:
            change = int(data.get("change", 0))
        except Exception:
            return jsonify({"error": "Invalid change amount"}), 400
        if change == 0:
            return jsonify({"error": "Change must be non-zero"}), 400
        if change < 0 and product.stock + change < 0:
            return jsonify({"error": "Insufficient stock for reduction"}), 400

        reason = data.get("reason", "adjustment")

        # Apply stock change
        product.stock += change
        db.session.add(InventoryLog(company_id=g.current_company.id, product=product, change=change, reason=reason))

        db.session.commit()
        log_action(
            "inventory_adjusted",
            {
                "id": product.id,
                "change": change,
            },
            g.current_company.id,
        )
        socketio.emit("inventory:update", {"type": "inventory_adjusted", "product": product.to_dict()},)
        return jsonify(product.to_dict())

    def _find_walkin_customer(company_id: int) -> Customer | None:
        rows = Customer.query.filter_by(company_id=company_id).order_by(Customer.id.asc()).all()
        for row in rows:
            if row.is_walkin_default():
                return row
        return None

    def _get_or_create_walkin_customer(company_id: int, *, auto_commit: bool = False) -> Customer:
        existing = _find_walkin_customer(company_id)
        if existing:
            _get_or_create_customer_account(company_id, existing)
            return existing
        max_local = (
            db.session.query(db.func.max(Customer.local_number))
            .filter(Customer.company_id == company_id)
            .scalar()
            or 0
        )
        row = Customer(
            company_id=company_id,
            local_number=max_local + 1,
            name="Walk-in Customer",
            first_name="Walk-in",
            last_name="Customer",
            country="Nepal",
        )
        db.session.add(row)
        db.session.flush()
        _get_or_create_customer_account(company_id, row)
        if auto_commit:
            db.session.commit()
        return row

    # Customers
    @app.route("/customers", methods=["GET"])
    @require_auth
    @company_required()
    def get_customers():
        _get_or_create_walkin_customer(g.current_company.id, auto_commit=True)
        search_term = (request.args.get("q") or "").strip()
        use_server_paging = (
            request.args.get("page") is not None
            or request.args.get("per_page") is not None
            or bool(search_term)
        )
        query = Customer.query.filter_by(company_id=g.current_company.id)
        if search_term:
            like = f"%{search_term}%"
            query = query.filter(
                or_(
                    Customer.name.ilike(like),
                    Customer.phone.ilike(like),
                    Customer.mobile.ilike(like),
                    Customer.email.ilike(like),
                )
            )
        query = query.order_by(Customer.created_at.desc())
        if use_server_paging:
            customers, meta = paginate_query(query)
            return jsonify({"data": [c.to_dict() for c in customers], "pagination": meta})
        customers = query.all()
        return jsonify([c.to_dict() for c in customers])

    @app.route("/customers", methods=["POST"])
    @require_auth
    @company_required(["admin", "manager", "superuser", "salesman", "staff"])
    def create_customer():
        data = request.get_json() or {}
        first_name = data.get("first_name", "").strip()
        last_name = data.get("last_name", "").strip()
        if not first_name:
            return jsonify({"error": "First name is required"}), 400
        country = data.get("country") or "Nepal"
        isd_map = {"Nepal": "+977", "India": "+91", "USA": "+1"}
        isd_code = data.get("isd_code") or isd_map.get(country, "+977")
        name = data.get("name") or f"{first_name} {last_name}".strip()
        # assign company-scoped customer number
        max_local = (
            db.session.query(db.func.max(Customer.local_number))
            .filter(Customer.company_id == g.current_company.id)
            .scalar()
            or 0
        )
        local_number = max_local + 1
        customer = Customer(
            company_id=g.current_company.id,
            local_number=local_number,
            name=name,
            first_name=first_name,
            last_name=last_name or None,
            phone=data.get("phone"),
            mobile=data.get("mobile"),
            isd_code=isd_code,
            city=data.get("city"),
            country=country,
            email=data.get("email"),
            address=data.get("address", ""),
        )
        db.session.add(customer)
        db.session.flush()
        _get_or_create_customer_account(g.current_company.id, customer)
        db.session.commit()
        socketio.emit(
            "inventory:update",
            {"type": "customer_created", "customer": customer.to_dict(), "company_id": g.current_company.id},
        )
        return jsonify(customer.to_dict()), 201

    @app.route("/customers/<int:customer_id>", methods=["GET", "PUT", "DELETE", "OPTIONS"])
    @app.route("/customers/<int:customer_id>/", methods=["GET", "PUT", "DELETE", "OPTIONS"])
    @require_auth
    @company_required(["admin", "manager", "superuser"])
    def customer_detail(customer_id: int):
        cust = Customer.query.get_or_404(customer_id)
        if cust.company_id != g.current_company.id:
            return jsonify({"error": "Forbidden"}), 403

        if request.method == "GET":
            return jsonify(cust.to_dict())

        if request.method == "PUT":
            if cust.is_walkin_default():
                return jsonify({"error": "Default walk-in customer cannot be edited"}), 400
            data = request.get_json() or {}
            old_name = cust.name
            # Apply fields, keeping existing values when blanks are sent
            incoming_first = (data.get("first_name") or "").strip()
            incoming_last = (data.get("last_name") or "").strip()
            cust.first_name = incoming_first or cust.first_name or ""
            cust.last_name = incoming_last or cust.last_name or None
            for field in ["phone", "mobile", "isd_code", "city", "country", "email", "address"]:
                if field in data:
                    setattr(cust, field, data.get(field))
            incoming_name = (data.get("name") or "").strip()
            if incoming_name:
                cust.name = incoming_name
            else:
                # build from first/last; if empty, fallback to existing name
                full_name = f"{cust.first_name or ''} {cust.last_name or ''}".strip()
                cust.name = full_name or cust.name
            if not cust.name.strip():
                return jsonify({"error": "Name is required"}), 400
            db.session.commit()
            if cust.name != old_name:
                # cascade to sales records that store customer name inline if any exist
                Customer.query.filter_by(company_id=g.current_company.id, id=cust.id).update({"name": cust.name})
                if hasattr(Sale, "customer_name"):
                    Sale.query.filter_by(company_id=g.current_company.id, customer_id=cust.id).update(
                        {"customer_name": cust.name}
                    )
                _rename_party_ledger(
                    g.current_company.id,
                    f"Customer #{cust.local_number or cust.id}: {old_name}",
                    _customer_ledger_name(cust),
                    "asset",
                    "Accounts Receivable",
                )
                db.session.commit()
            socketio.emit(
                "inventory:update",
                {"type": "customer_updated", "customer": cust.to_dict(), "company_id": g.current_company.id},
            )
            return jsonify(cust.to_dict())

        # DELETE
        if cust.is_walkin_default():
            return jsonify({"error": "Default walk-in customer cannot be deleted"}), 400
        has_sales = Sale.query.filter_by(company_id=g.current_company.id, customer_id=cust.id).first()
        if has_sales:
            return jsonify({"error": "Cannot delete customer with existing sales"}), 400
        try:
            db.session.delete(cust)
            db.session.commit()
        except Exception as exc:
            db.session.rollback()
            return jsonify({"error": f"Failed to delete customer: {exc}"}), 500
        socketio.emit(
            "inventory:update",
            {"type": "customer_deleted", "customer_id": customer_id, "company_id": g.current_company.id},
        )
        return jsonify({"status": "deleted"})

    @app.route("/customers/<int:customer_id>/history", methods=["GET"])
    @require_auth
    @company_required()
    def customer_history(customer_id: int):
        cust = Customer.query.get_or_404(customer_id)
        if cust.company_id != g.current_company.id:
            return jsonify({"error": "Forbidden"}), 403
        sales = (
            Sale.query.filter_by(company_id=g.current_company.id, customer_id=customer_id)
            .order_by(Sale.created_at.desc())
            .all()
        )
        history = []
        for sale in sales:
            total_amount = round(float(sale.total_amount or 0.0), 2)
            paid_amount = round(float(sale.paid_amount or 0.0), 2)
            due_amount = round(float(sale.due_amount or max(0.0, total_amount - paid_amount)), 2)
            payment_state = "paid" if due_amount <= 0 else ("partially_paid" if paid_amount > 0 else "unpaid")
            item_rows = [
                {
                    "product_id": item.product_id,
                    "product_name": item.product.name if item.product else None,
                    "uom": item.product.uom_category if item.product else None,
                    "quantity": item.quantity,
                    "unit_price": item.unit_price,
                    "line_total": item.quantity * item.unit_price,
                }
                for item in sale.items
            ]
            history.append(
                {
                    "id": sale.id,
                    "bill_number": sale.sale_number or str(sale.id),
                    "created_at": sale.created_at.isoformat() if sale.created_at else None,
                    "total_amount": total_amount,
                    "paid_amount": paid_amount,
                    "due_amount": due_amount,
                    "payment_state": payment_state,
                    "item_names": [it.get("product_name") for it in item_rows if it.get("product_name")],
                    "items": item_rows,
                }
            )
        return jsonify(history)

    def _static_file_url(file_path: str) -> str:
        base = request.url_root.rstrip("/")
        rel = str(file_path or "").lstrip("/")
        return f"{base}/static/{rel}"

    def _sale_payload_with_image_urls(sale: Sale) -> dict:
        payload = sale.to_dict()
        for row in payload.get("images") or []:
            row["image_url"] = _static_file_url(str(row.get("file_path") or ""))
        return payload

    def _create_sale_record(data: dict, *, source: str = "sales", extra_fields: dict | None = None):
        items = data.get("items", [])
        if not items:
            return jsonify({"error": "Sale items are required"}), 400
        src = (source or data.get("source") or "sales").strip() or "sales"
        # sales_order_delivered must only be created via /sales/<id>/deliver
        # so that delivery always validates inventory availability.
        if src not in {"sales", "daily_sales", "backdated_daily_sales", "sales_order"}:
            return jsonify({"error": "Invalid sale source"}), 400
        is_order = src == "sales_order"
        # Use logical sale_date for eligibility checks. We do not have an explicit time-of-day for backdated sales,
        # so we let _eligible_available_base_for_batch enforce by date unless a timestamp is passed.
        sale_at_now = None
        def _to_non_negative_whole(value, field_label: str) -> int:
            if value is None or value == "":
                return 0
            try:
                parsed = float(value)
            except (TypeError, ValueError):
                raise ValueError(f"{field_label} must be a whole number")
            if parsed < 0:
                raise ValueError(f"{field_label} must be >= 0")
            if not parsed.is_integer():
                raise ValueError(f"{field_label} must be a whole number")
            return int(parsed)

        def _pick_expected_batch_for_backdated(product: Product, qty_base: int, sale_date_val: date | None) -> InventoryBatch | None:
            if not product or not product.lot_tracking:
                return None
            if not sale_date_val:
                return None
            candidates = (
                InventoryBatch.query.filter(
                    InventoryBatch.company_id == g.current_company.id,
                    InventoryBatch.product_id == product.id,
                )
                .order_by(InventoryBatch.expiry_date.asc().nullslast(), InventoryBatch.id.asc())
                .all()
            )
            best_partial = None
            for batch in candidates:
                try:
                    eligible = _eligible_available_base_for_batch(
                        company_id=g.current_company.id,
                        product=product,
                        batch=batch,
                        sale_date=sale_date_val,
                        sale_at=sale_at_now,
                        cap_with_current=True,
                    )
                except Exception:
                    eligible = 0
                if eligible >= int(qty_base or 0):
                    return batch
                if eligible > 0 and best_partial is None:
                    best_partial = batch
            return best_partial

        def _pick_backdated_batch_match(
            product: Product,
            qty_base: int,
            sale_date_val: date | None,
            batch_id_val: int | None = None,
            batch_number_val: str | None = None,
        ) -> InventoryBatch | None:
            if not product or qty_base <= 0:
                return None
            candidates_query = InventoryBatch.query.filter(
                InventoryBatch.company_id == g.current_company.id,
                InventoryBatch.product_id == product.id,
            )
            preferred_batch = None
            if batch_id_val:
                preferred_batch = db.session.get(InventoryBatch, batch_id_val)
                if preferred_batch and (
                    preferred_batch.company_id != g.current_company.id or preferred_batch.product_id != product.id
                ):
                    preferred_batch = None
            if not preferred_batch and product.lot_tracking and batch_number_val:
                preferred_batch = (
                    candidates_query.filter(InventoryBatch.batch_number == batch_number_val)
                    .order_by(InventoryBatch.arrival_at.asc().nullslast(), InventoryBatch.created_at.asc().nullslast(), InventoryBatch.id.asc())
                    .first()
                )
            if preferred_batch and sale_date_val:
                try:
                    eligible = _eligible_available_base_for_batch(
                        company_id=g.current_company.id,
                        product=product,
                        batch=preferred_batch,
                        sale_date=sale_date_val,
                        sale_at=sale_at_now,
                        cap_with_current=False,
                    )
                except Exception:
                    eligible = 0
                if int(eligible or 0) >= int(qty_base or 0):
                    return preferred_batch
            if sale_date_val:
                return _pick_expected_batch_for_backdated(product, qty_base, sale_date_val)
            return None

        # sale_date controls the logical accounting date for this sale.
        # - daily_sales: locked to today
        # - backdated_daily_sales: selectable date (defaults to today)
        # - sales: user selectable
        # - sales_order: user selectable, does not impact inventory
        sale_date = None
        raw_sale_date = data.get("sale_date") or data.get("date")
        if src == "daily_sales":
            if raw_sale_date:
                try:
                    sale_date = datetime.fromisoformat(str(raw_sale_date)).date()
                except ValueError:
                    return jsonify({"error": "Invalid sale_date (expected YYYY-MM-DD)"}), 400
            else:
                sale_date = _today_ad()
        elif src == "backdated_daily_sales":
            if raw_sale_date:
                try:
                    sale_date = datetime.fromisoformat(str(raw_sale_date)).date()
                except ValueError:
                    return jsonify({"error": "Invalid sale_date (expected YYYY-MM-DD)"}), 400
            else:
                sale_date = _today_ad()
        else:
            if raw_sale_date:
                try:
                    sale_date = datetime.fromisoformat(str(raw_sale_date)).date()
                except ValueError:
                    return jsonify({"error": "Invalid sale_date (expected YYYY-MM-DD)"}), 400
            else:
                sale_date = _today_ad()

        # Strict availability rule for normal sales:
        # If inventory is not eligible on the selected sale date, reject the sale.
        if src == "sales":
            def _line_available_for_sale(it: dict) -> bool:
                try:
                    product_id = int(it.get("product_id") or 0)
                except (TypeError, ValueError):
                    product_id = 0
                product = db.session.get(Product, product_id) if product_id else None
                try:
                    ordered_input = _to_non_negative_whole(
                        it.get("ordered_qty", it.get("ordered_quantity", it.get("quantity", 0))),
                        "Line quantity",
                    )
                    bonus_input = _to_non_negative_whole(
                        it.get("bonus_qty", it.get("bonus_quantity", 0)),
                        "Line bonus quantity",
                    )
                except ValueError:
                    return False
                quantity_input = max(0, ordered_input) + max(0, bonus_input)
                if not product or quantity_input <= 0 or product.company_id != g.current_company.id:
                    return False

                raw_uom = (it.get("uom") or "").strip() or None
                uom = _validate_uom_for_product(product, raw_uom)
                if product.uom_category and not uom:
                    uom = _base_uom_name(product) or product.uom_category
                qty_base = _qty_to_base(g.current_company.id, product, quantity_input, uom)
                if qty_base <= 0:
                    return False

                try:
                    batch_id = int(it.get("inventory_batch_id") or 0) or None
                except (TypeError, ValueError):
                    batch_id = None
                batch_number = (it.get("lot_number") or it.get("batch_number") or "").strip() or None

                batch = None
                if batch_id:
                    batch = db.session.get(InventoryBatch, batch_id)
                    if not batch or batch.company_id != g.current_company.id or batch.product_id != product.id:
                        return False
                else:
                    if product.lot_tracking and batch_number:
                        batch = (
                            InventoryBatch.query.filter(
                                InventoryBatch.company_id == g.current_company.id,
                                InventoryBatch.product_id == product.id,
                                InventoryBatch.batch_number == batch_number,
                                InventoryBatch.qty_base >= qty_base,
                            )
                            .order_by(InventoryBatch.expiry_date.asc().nullslast(), InventoryBatch.id.asc())
                            .first()
                        )
                    else:
                        batch = (
                            InventoryBatch.query.filter(
                                InventoryBatch.company_id == g.current_company.id,
                                InventoryBatch.product_id == product.id,
                                InventoryBatch.qty_base >= qty_base,
                            )
                            .order_by(InventoryBatch.expiry_date.asc().nullslast(), InventoryBatch.id.asc())
                            .first()
                        )
                    if not batch:
                        return False

                if int(batch.qty_base or 0) < int(qty_base):
                    return False
                eligible_base = _eligible_available_base_for_batch(
                    company_id=g.current_company.id,
                    product=product,
                    batch=batch,
                    sale_date=sale_date,
                    sale_at=sale_at_now,
                )
                return int(qty_base) <= int(eligible_base)

            any_unavailable = any(not _line_available_for_sale(it) for it in (items or []))
            if any_unavailable:
                return jsonify({
                    "error": "One or more items are not available in inventory on the selected sale date"
                }), 400

        expected_delivery_date = None
        if is_order:
            raw_expected = data.get("expected_delivery_date") or data.get("expected_date")
            if raw_expected:
                try:
                    expected_delivery_date = datetime.fromisoformat(str(raw_expected)).date()
                except ValueError:
                    return jsonify({"error": "Invalid expected_delivery_date (expected YYYY-MM-DD)"}), 400

        raw_payment_status = (data.get("payment_status") or "").strip().lower()
        payment_method_label = data.get("payment_method", "")
        if src in {"daily_sales", "backdated_daily_sales"}:
            payment_status = "paid"
        elif is_order:
            payment_status = raw_payment_status or "due"
        else:
            payment_status = raw_payment_status or _infer_payment_status(payment_method_label, "paid")
        if payment_status not in {"paid", "due"}:
            return jsonify({"error": "Invalid payment_status"}), 400

        discount_amount = 0.0
        if "discount_amount" in data and data.get("discount_amount") is not None:
            try:
                discount_amount = float(data.get("discount_amount") or 0.0)
            except (TypeError, ValueError):
                return jsonify({"error": "Invalid discount_amount"}), 400
            if discount_amount < 0:
                return jsonify({"error": "discount_amount must be >= 0"}), 400
            # Only admins/managers can apply discount
            if discount_amount > 0 and str(getattr(g, "company_role", "") or "").lower() not in {"admin", "manager"}:
                return jsonify({"error": "Only manager/admin can apply discount"}), 403
        extra_charges_amount = 0.0
        if "extra_charges_amount" in data and data.get("extra_charges_amount") is not None:
            try:
                extra_charges_amount = float(data.get("extra_charges_amount") or 0.0)
            except (TypeError, ValueError):
                return jsonify({"error": "Invalid extra_charges_amount"}), 400
            if extra_charges_amount < 0:
                return jsonify({"error": "extra_charges_amount must be >= 0"}), 400
        customer = None
        customer_id = data.get("customer_id")
        if customer_id:
            try:
                customer_id_int = int(customer_id)
            except (TypeError, ValueError):
                customer_id_int = 0
            customer = db.session.get(Customer, customer_id_int) if customer_id_int else None
            if not customer or customer.company_id != g.current_company.id:
                return jsonify({"error": "Customer not found"}), 404
        else:
            customer = _get_or_create_walkin_customer(g.current_company.id)

        sale_kwargs = dict(
            company_id=g.current_company.id,
            customer=customer,
            payment_method=("N/A" if is_order else data.get("payment_method", "cash")),
            created_by_user_id=int(getattr(g.current_user, "id", 0) or 0) or None,
            source=src,
            origin_source=src,
            sale_date=sale_date,
            payment_status=payment_status,
            expected_delivery_date=expected_delivery_date,
        )
        sale_kwargs["sale_number"] = _make_sale_number(g.current_company.id, sale_date)
        if extra_fields:
            sale_kwargs.update(extra_fields)
        sale_kwargs["origin_source"] = (sale_kwargs.get("origin_source") or src)
        sale_kwargs["inventory_posted"] = False
        # Track approver metadata for auto-approved daily/backdated sales.
        if src in {"daily_sales", "backdated_daily_sales"} and str(sale_kwargs.get("approval_status") or "").lower() == "approved":
            sale_kwargs.setdefault("approved_by_user_id", int(getattr(g.current_user, "id", 0) or 0) or None)
            sale_kwargs.setdefault("approved_at", datetime.now(timezone.utc))
        sale = Sale(**sale_kwargs)
        db.session.add(sale)

        post_sale_now = (not is_order) and (
            src == "sales" or str(sale_kwargs.get("approval_status") or "").lower() == "approved"
        )

        backdated_product_ids: set[int] = set()

        backdated_target_total = None
        backdated_discount_pct = None
        if src == "backdated_daily_sales":
            try:
                backdated_target_total = float(data.get("total_amount") or data.get("overall_total") or 0.0)
            except (TypeError, ValueError):
                backdated_target_total = None
            if backdated_target_total is not None and backdated_target_total > 0:
                sum_mrp_total = 0.0
                mrp_complete = True
                for idx, item in enumerate(items):
                    try:
                        product_id = int(item.get("product_id") or 0)
                    except (TypeError, ValueError):
                        product_id = 0
                    product = db.session.get(Product, product_id) if product_id else None
                    try:
                        ordered_input = _to_non_negative_whole(
                            item.get("ordered_qty", item.get("ordered_quantity", item.get("quantity", 0))),
                            f"Line {idx + 1}: quantity",
                        )
                        bonus_input = _to_non_negative_whole(
                            item.get("bonus_qty", item.get("bonus_quantity", 0)),
                            f"Line {idx + 1}: bonus quantity",
                        )
                    except ValueError:
                        mrp_complete = False
                        break
                    quantity_input = max(0, ordered_input) + max(0, bonus_input)
                    if not product or quantity_input <= 0 or product.company_id != g.current_company.id:
                        mrp_complete = False
                        break
                    raw_uom = (item.get("uom") or "").strip() or None
                    uom = _validate_uom_for_product(product, raw_uom)
                    if product.uom_category and not uom:
                        uom = _base_uom_name(product) or product.uom_category
                    factor = _uom_factor_to_base(g.current_company.id, product, uom) if uom else 1.0
                    qty_base = _qty_to_base(g.current_company.id, product, quantity_input, uom)
                    if qty_base <= 0:
                        mrp_complete = False
                        break
                    try:
                        batch_id = int(item.get("inventory_batch_id") or 0) or None
                    except (TypeError, ValueError):
                        batch_id = None
                    batch_number = (item.get("lot_number") or item.get("batch_number") or "").strip() or None
                    expected_batch = _pick_backdated_batch_match(
                        product,
                        qty_base,
                        sale_date,
                        batch_id_val=batch_id,
                        batch_number_val=batch_number,
                    )
                    if expected_batch and expected_batch.batch_number:
                        batch_number = expected_batch.batch_number
                    batch = None
                    if batch_id:
                        batch = db.session.get(InventoryBatch, batch_id)
                    if not batch and batch_number:
                        batch = (
                            InventoryBatch.query.filter(
                                InventoryBatch.company_id == g.current_company.id,
                                InventoryBatch.product_id == product.id,
                                InventoryBatch.batch_number == batch_number,
                            )
                            .order_by(InventoryBatch.expiry_date.asc().nullslast(), InventoryBatch.id.asc())
                            .first()
                        )
                    if not batch and expected_batch:
                        batch = expected_batch
                    if not batch:
                        mrp_complete = False
                        break
                    mrp_per_uom = float(getattr(batch, "mrp_per_uom", 0.0) or 0.0) or float(
                        getattr(batch, "mrp", 0.0) or 0.0
                    )
                    if mrp_per_uom <= 0:
                        mrp_complete = False
                        break
                    batch_factor = float(getattr(batch, "factor_to_base", 1.0) or 1.0) or 1.0
                    mrp_per_base = mrp_per_uom / max(1.0, batch_factor)
                    unit_price_input = mrp_per_base * max(1.0, factor)
                    sum_mrp_total += float(quantity_input) * float(unit_price_input)
                if mrp_complete and sum_mrp_total > 0 and backdated_target_total < sum_mrp_total:
                    backdated_discount_pct = max(
                        0.0, min(100.0, ((sum_mrp_total - backdated_target_total) / sum_mrp_total) * 100.0)
                    )

        subtotal_amount = 0.0
        line_discount_total = 0.0
        line_tax_total = 0.0
        for idx, item in enumerate(items):
            try:
                product_id = int(item.get("product_id") or 0)
            except (TypeError, ValueError):
                product_id = 0
            product = db.session.get(Product, product_id) if product_id else None
            if src == "backdated_daily_sales" and product_id > 0:
                backdated_product_ids.add(product_id)
            try:
                ordered_input = _to_non_negative_whole(
                    item.get("ordered_qty", item.get("ordered_quantity", item.get("quantity", 0))),
                    f"Line {idx + 1}: quantity",
                )
                bonus_input = _to_non_negative_whole(
                    item.get("bonus_qty", item.get("bonus_quantity", 0)),
                    f"Line {idx + 1}: bonus quantity",
                )
            except ValueError as exc:
                db.session.rollback()
                return jsonify({"error": str(exc)}), 400
            quantity_input = max(0, ordered_input) + max(0, bonus_input)
            if not product or quantity_input <= 0 or product.company_id != g.current_company.id:
                db.session.rollback()
                return jsonify({"error": f"Line {idx + 1}: Invalid product or quantity"}), 400

            raw_uom = (item.get("uom") or "").strip() or None
            uom = _validate_uom_for_product(product, raw_uom)
            if product.uom_category and not uom:
                uom = _base_uom_name(product) or product.uom_category
            factor = _uom_factor_to_base(g.current_company.id, product, uom) if uom else 1.0
            qty_base = _qty_to_base(g.current_company.id, product, quantity_input, uom)
            if qty_base <= 0:
                db.session.rollback()
                return jsonify({"error": f"Line {idx + 1}: Invalid product or quantity"}), 400

            batch_id = None
            try:
                batch_id = int(item.get("inventory_batch_id") or 0) or None
            except (TypeError, ValueError):
                batch_id = None

            batch_number = (item.get("lot_number") or item.get("batch_number") or "").strip() or None
            expected_batch = None
            if src == "backdated_daily_sales":
                expected_batch = _pick_backdated_batch_match(
                    product,
                    qty_base,
                    sale_date,
                    batch_id_val=batch_id,
                    batch_number_val=batch_number,
                )
                if expected_batch:
                    batch = expected_batch
                    batch_id = int(expected_batch.id)
                    if expected_batch.batch_number:
                        batch_number = expected_batch.batch_number
            batch = expected_batch if (src == "backdated_daily_sales" and expected_batch) else None
            if batch_id:
                batch = db.session.get(InventoryBatch, batch_id)
                if not batch or batch.company_id != g.current_company.id or batch.product_id != product.id:
                    db.session.rollback()
                    return jsonify({"error": f"Line {idx + 1}: Invalid batch selection"}), 400
                if (not is_order) and src != "backdated_daily_sales" and int(batch.qty_base or 0) < int(qty_base):
                    db.session.rollback()
                    return jsonify({"error": f"Line {idx + 1}: Insufficient stock in selected batch"}), 400
                # Ensure batch_number matches selected batch for lot-tracked products
                batch_number = batch.batch_number or batch_number

                if not is_order:
                    # Tight rule: do not allow selling stock from purchases after the selected sale_date.
                    # For POS (daily_sales/backdated), skip historical eligibility and trust current batch qty.
                    if src not in {"daily_sales", "backdated_daily_sales"}:
                        eligible_base = _eligible_available_base_for_batch(
                            company_id=g.current_company.id,
                            product=product,
                            batch=batch,
                            sale_date=sale_date,
                            sale_at=sale_at_now,
                        )
                        if int(qty_base) > int(eligible_base):
                            db.session.rollback()
                            return jsonify(
                                {
                                    "error": (
                                        f"Line {idx + 1}: Selected batch is not eligible for sale_date {sale_date.isoformat()} "
                                        f"(eligible {eligible_base} base units)"
                                    )
                                }
                            ), 400
                    elif src == "backdated_daily_sales":
                        eligible_base = _eligible_available_base_for_batch(
                            company_id=g.current_company.id,
                            product=product,
                            batch=batch,
                            sale_date=sale_date,
                            sale_at=sale_at_now,
                            cap_with_current=False,
                        )
                        if int(qty_base) > int(eligible_base):
                            db.session.rollback()
                            return jsonify(
                                {
                                    "error": (
                                        f"Line {idx + 1}: Insufficient inventory on {sale_date.isoformat()} "
                                        f"(eligible {eligible_base} base units)"
                                    )
                                }
                            ), 400
            elif not is_order:
                if src == "backdated_daily_sales":
                    # Allow backdated sales without a batch only when no eligible inventory exists yet.
                    batch = expected_batch
                    batch_id = int(expected_batch.id) if expected_batch else None
                    if expected_batch and expected_batch.batch_number:
                        batch_number = expected_batch.batch_number
                else:
                    db.session.rollback()
                    return jsonify({"error": f"Line {idx + 1}: Batch selection is required"}), 400

            unit_price_input = float(item.get("unit_price", 0 if is_order else product.price) or 0)
            if src == "backdated_daily_sales":
                price_batch = batch or expected_batch
                if price_batch:
                    mrp_per_uom = float(getattr(price_batch, "mrp_per_uom", 0.0) or 0.0) or float(
                        getattr(price_batch, "mrp", 0.0) or 0.0
                    )
                    if mrp_per_uom > 0:
                        batch_factor = float(getattr(price_batch, "factor_to_base", 1.0) or 1.0) or 1.0
                        mrp_per_base = mrp_per_uom / max(1.0, batch_factor)
                        unit_price_input = mrp_per_base * max(1.0, factor)
            # If a UoM is provided, treat unit_price as per that UoM and convert to base-unit price.
            base_unit_price = unit_price_input / max(1.0, factor)
            gross_line = float(max(0, ordered_input)) * float(unit_price_input)
            try:
                line_discount_pct = float(item.get("discount_percent", 0.0) or 0.0)
            except (TypeError, ValueError):
                line_discount_pct = 0.0
            line_discount_pct = max(0.0, min(100.0, line_discount_pct))
            if line_discount_pct <= 0 and backdated_discount_pct is not None:
                line_discount_pct = backdated_discount_pct
            if line_discount_pct <= 0:
                try:
                    line_discount_amt_input = float(item.get("discount_amount", 0.0) or 0.0)
                except (TypeError, ValueError):
                    line_discount_amt_input = 0.0
                if gross_line > 0 and line_discount_amt_input > 0:
                    line_discount_pct = max(0.0, min(100.0, (line_discount_amt_input / gross_line) * 100.0))
            discount_amt = gross_line * (line_discount_pct / 100.0)
            taxable_line = max(0.0, gross_line - discount_amt)
            try:
                line_tax_pct = float(item.get("tax_percent", 0.0) or 0.0)
            except (TypeError, ValueError):
                line_tax_pct = 0.0
            line_tax_pct = max(0.0, line_tax_pct)
            if line_tax_pct <= 0:
                try:
                    line_tax_amt_input = float(item.get("tax_amount", 0.0) or 0.0)
                except (TypeError, ValueError):
                    line_tax_amt_input = 0.0
                if taxable_line > 0 and line_tax_amt_input > 0:
                    line_tax_pct = max(0.0, (line_tax_amt_input / taxable_line) * 100.0)
            tax_amt = taxable_line * (line_tax_pct / 100.0)

            sale_item = SaleItem(
                sale=sale,
                product=product,
                quantity=qty_base,
                quantity_uom=quantity_input,
                ordered_quantity_uom=max(0, ordered_input),
                bonus_quantity_uom=max(0, bonus_input),
                uom=uom,
                inventory_batch_id=batch_id,
                batch_number=batch_number if product.lot_tracking else None,
                unit_price_uom=unit_price_input,
                unit_price=base_unit_price,
                line_discount_percent=line_discount_pct,
                line_tax_percent=line_tax_pct,
            )
            db.session.add(sale_item)
            subtotal_amount += gross_line
            line_discount_total += discount_amt
            line_tax_total += tax_amt

        discount_amount = min(discount_amount, max(0.0, subtotal_amount - line_discount_total))
        effective_discount_total = min(subtotal_amount, line_discount_total + discount_amount)
        total_amount = max(0.0, subtotal_amount - effective_discount_total + line_tax_total + extra_charges_amount)
        if src == "backdated_daily_sales" and backdated_target_total is not None and backdated_target_total > 0:
            # Backdated sales store the entered transaction total even before MRP/batch relinking is available.
            sale.subtotal_amount = round(subtotal_amount, 2) if subtotal_amount > 0 else round(backdated_target_total, 2)
            sale.discount_amount = round(effective_discount_total, 2) if subtotal_amount > 0 else 0.0
            sale.extra_charges_amount = round(extra_charges_amount, 2)
            sale.total_amount = round(backdated_target_total, 2)
        else:
            sale.subtotal_amount = round(subtotal_amount, 2)
            sale.discount_amount = round(effective_discount_total, 2)
            sale.extra_charges_amount = round(extra_charges_amount, 2)
            sale.total_amount = round(total_amount, 2)
        if sale.payment_status == "due":
            sale.paid_amount = 0.0
            sale.due_amount = float(sale.total_amount or 0.0)
        else:
            sale.paid_amount = float(sale.total_amount or 0.0)
            sale.due_amount = 0.0

        if src == "backdated_daily_sales":
            _relink_pending_backdated_sales(
                company_id=g.current_company.id,
                product_ids=backdated_product_ids,
            )

        db.session.flush()
        if post_sale_now:
            try:
                _post_sale_inventory_if_ready(sale)
            except ValueError as exc:
                db.session.rollback()
                return jsonify({"error": str(exc)}), 400
            _ensure_sale_accounting_posted(sale)
        db.session.commit()
        socketio.emit(
            "inventory:update",
            {"type": "sale_created", "sale": _sale_payload_with_image_urls(sale), "company_id": g.current_company.id},
        )
        log_action("sale_created", {"sale_id": sale.id, "total": sale.total_amount}, g.current_company.id)
        if src == "backdated_daily_sales":
            log_action(
                "backdated_daily_sale_created",
                {
                    "sale_id": sale.id,
                    "total": sale.total_amount,
                    "sale_date": sale.sale_date.isoformat() if sale.sale_date else None,
                },
                g.current_company.id,
            )
        return jsonify(_sale_payload_with_image_urls(sale)), 201

    def _sales_return_window(company: Company) -> tuple[int, str]:
        try:
            value = int(company.sales_return_limit_value or 0)
        except (TypeError, ValueError):
            value = 0
        unit = str(company.sales_return_limit_unit or "days").lower()
        if unit not in {"days", "months"}:
            unit = "days"
        if value < 0:
            value = 0
        return value, unit

    def _subtract_months(base_date: date, months: int) -> date:
        if months <= 0:
            return base_date
        year = base_date.year
        month = base_date.month - months
        while month <= 0:
            month += 12
            year -= 1
        last_day = calendar.monthrange(year, month)[1]
        day = min(base_date.day, last_day)
        return date(year, month, day)

    def _sales_return_cutoff(company: Company, reference_date: date) -> date | None:
        value, unit = _sales_return_window(company)
        if value <= 0:
            return None
        if unit == "months":
            return _subtract_months(reference_date, value)
        return reference_date - timedelta(days=value)

    def _sale_item_base_qty(company_id: int, item: SaleItem, product: Product | None) -> int:
        try:
            base_qty = int(item.quantity or 0)
        except TypeError:
            base_qty = 0
        if not product:
            return max(base_qty, 0)
        try:
            qty_uom_val = int(item.quantity_uom) if item.quantity_uom is not None else None
        except TypeError:
            qty_uom_val = None
        uom = item.uom or _base_uom_name(product) or product.uom_category
        if qty_uom_val and uom:
            factor = float(_uom_factor_to_base(company_id, product, uom) or 0.0) or 0.0
            if factor <= 0:
                factor = float(_infer_uom_factor(uom) or 1.0)
            derived = int(round(qty_uom_val * factor))
            if derived > base_qty:
                base_qty = derived
        return max(int(base_qty or 0), 0)

    def _sale_return_as_sale_dict(sale_return: SaleReturn) -> dict:
        sale = sale_return.sale
        customer = sale.customer if sale and sale.customer else sale_return.customer
        created_by = _user_display_name(getattr(sale_return, "created_by", None))
        items = []
        for it in sale_return.items or []:
            qty_display = int(it.quantity_uom) if it.quantity_uom is not None else int(it.qty_base or 0)
            items.append(
                {
                    "id": it.id,
                    "product_id": it.product_id,
                    "product_name": it.product.name if it.product else None,
                    "uom": it.uom,
                    "quantity": qty_display,
                    "quantity_base": int(it.qty_base or 0),
                    "unit_price": round(float(it.unit_price or 0.0), 4),
                    "line_total": round(float(qty_display or 0) * float(it.unit_price or 0.0), 2),
                }
            )
        total_amount = float(sale_return.total_amount or 0.0)
        payment_method = sale.payment_method if sale else "Sales Return"
        return {
            "id": -int(sale_return.id),
            "sale_return_id": sale_return.id,
            "return_number": sale_return.return_number,
            "sale_number": sale.sale_number if sale else None,
            "sale_id": sale_return.sale_id,
            "company_id": sale_return.company_id,
            "customer": customer.to_dict() if customer else None,
            "created_by": created_by,
            "created_by_user_id": sale_return.created_by_user_id,
            "subtotal_amount": round(-total_amount, 2),
            "discount_amount": 0.0,
            "total_amount": round(-total_amount, 2),
            "payment_method": payment_method,
            "source": "sales_return",
            "status": "Sales Return",
            "sale_date": sale_return.return_date.isoformat() if sale_return.return_date else None,
            "created_at": sale_return.created_at.isoformat() if sale_return.created_at else None,
            "updated_at": sale_return.updated_at.isoformat() if sale_return.updated_at else None,
            "payment_status": "paid",
            "paid_amount": 0.0,
            "due_amount": 0.0,
            "items": items,
        }

    def _make_sale_number(company_id: int, sale_date: date | None) -> str:
        if not sale_date:
            sale_date = _today_ad()
        yy = sale_date.strftime("%y")
        day_of_year = sale_date.timetuple().tm_yday
        existing = (
            db.session.query(Sale)
            .filter(Sale.company_id == company_id, Sale.sale_date == sale_date)
            .count()
        )
        seq = existing + 1
        return f"{yy}-{day_of_year}-{seq}"

    def _make_sale_return_number(company_id: int, return_date: date | None) -> str:
        if not return_date:
            return_date = _today_ad()
        yy = return_date.strftime("%y")
        day_of_year = return_date.timetuple().tm_yday
        existing = (
            db.session.query(SaleReturn)
            .filter(SaleReturn.company_id == company_id, SaleReturn.return_date == return_date)
            .count()
        )
        seq = existing + 1
        return f"{yy}-{day_of_year}-{seq}"

    def _make_purchase_bill_number(company_id: int, purchase_date: date | None) -> str:
        if not purchase_date:
            purchase_date = _today_ad()
        yy = purchase_date.strftime("%y")
        day_of_year = purchase_date.timetuple().tm_yday
        existing = (
            db.session.query(PurchaseBill)
            .filter(PurchaseBill.company_id == company_id, PurchaseBill.purchase_date == purchase_date)
            .count()
        )
        seq = existing + 1
        return f"{yy}-{day_of_year}-{seq}"

    # Sales
    @app.route("/sales", methods=["GET"])
    @require_auth
    @company_required()
    def get_sales():
        sales = (
            Sale.query.filter_by(company_id=g.current_company.id)
            .order_by(Sale.sale_date.desc().nullslast(), Sale.created_at.desc())
            .all()
        )
        returns = (
            SaleReturn.query.filter_by(company_id=g.current_company.id)
            .order_by(SaleReturn.return_date.desc(), SaleReturn.created_at.desc())
            .all()
        )
        payload = [_sale_payload_with_image_urls(sale) for sale in sales]
        payload.extend([_sale_return_as_sale_dict(sr) for sr in returns])

        def sort_key(row: dict) -> tuple:
            raw = row.get("sale_date") or row.get("created_at") or ""
            return (raw or "",)

        payload.sort(key=sort_key, reverse=True)
        paged, meta = paginate_list(payload)
        return jsonify({"data": paged, "pagination": meta})

    @app.route("/sales/<int:sale_id>", methods=["GET"])
    @require_auth
    @company_required()
    def get_sale(sale_id: int):
        sale = Sale.query.get_or_404(sale_id)
        visibility_error = _enforce_backdated_sale_visibility(sale)
        if visibility_error:
            return visibility_error
        payload = _sale_payload_with_image_urls(sale)
        payload["returns"] = [
            {
                "id": r.id,
                "return_number": r.return_number,
                "return_date": r.return_date.isoformat() if r.return_date else None,
                "total_amount": float(r.total_amount or 0.0),
                "created_at": r.created_at.isoformat() if r.created_at else None,
            }
            for r in (sale.returns or [])
        ]
        return jsonify(payload)

    @app.route("/sales/<int:sale_id>/print", methods=["GET"])
    @require_auth
    @company_required()
    def print_sale(sale_id: int):
        sale = Sale.query.get_or_404(sale_id)
        visibility_error = _enforce_backdated_sale_visibility(sale)
        if visibility_error:
            return visibility_error
        paper_size = (request.args.get("paper_size") or "").strip() or None
        try:
            pdf = _render_sale_pdf(g.current_company, sale, page_size=paper_size)
            return _build_pdf_response(pdf, f"sale-{sale.sale_number or sale.id}.pdf")
        except RuntimeError as exc:
            return jsonify({"error": str(exc)}), 501

    @app.route("/sales/<int:sale_id>/approve", methods=["POST"])
    @require_auth
    @company_required(["manager", "superuser"])
    def approve_sale(sale_id: int):
        sale = Sale.query.get_or_404(sale_id)
        if sale.company_id != g.current_company.id:
            return jsonify({"error": "Forbidden"}), 403
        src = (sale.source or "").strip().lower()
        if src == "backdated_daily_sales":
            return jsonify({"error": "Backdated sales can only be approved from the backdated sales screen"}), 400
        if src not in {"sales", "daily_sales"}:
            return jsonify({"error": "Only sales and daily sales can be approved"}), 400
        if (sale.approval_status or "").lower() == "denied":
            return jsonify({"error": "Denied sales cannot be approved"}), 400
        if (sale.approval_status or "").lower() == "approved":
            return jsonify(_sale_payload_with_image_urls(sale))
        sale.approval_status = "approved"
        sale.approved_by_user_id = int(getattr(g.current_user, "id", 0) or 0) or None
        sale.approved_at = datetime.now(timezone.utc)
        try:
            _post_sale_inventory_if_ready(sale)
        except ValueError as exc:
            db.session.rollback()
            return jsonify({"error": str(exc)}), 400
        _ensure_sale_accounting_posted(sale)
        db.session.commit()
        socketio.emit(
            "inventory:update",
            {"type": "sale_updated", "sale": _sale_payload_with_image_urls(sale), "company_id": g.current_company.id},
        )
        log_action("sale_approved", {"sale_id": sale.id}, g.current_company.id)
        return jsonify(_sale_payload_with_image_urls(sale))

    @app.route("/purchase-orders/<int:order_id>/email", methods=["POST"])
    @require_auth
    @company_required(["admin", "manager"])
    def email_purchase_order(order_id: int):
        po = PurchaseOrder.query.get_or_404(order_id)
        if po.company_id != g.current_company.id:
            return jsonify({"error": "Forbidden"}), 403

        supplier_email = (po.supplier.email if po.supplier and po.supplier.email else "") or ""
        if not supplier_email:
            return jsonify({"error": "Supplier email is missing"}), 400

        company = g.current_company
        subject = f"Purchase Order {po.number or f'#{po.id}'}"
        body = (
            "Respected Sir,\n\n"
            f"Please send these items to our {company.name} as soon as possible. "
            "Please find the attachment with this mail.\n\n"
            "Thanking you."
        )
        filename = f"purchase-order-{po.number or po.id}.pdf"
        try:
            pdf = _render_purchase_order_pdf(company, po)
            send_company_email(
                company,
                supplier_email,
                subject,
                body,
                attachments=[
                    {
                        "filename": filename,
                        "data": pdf,
                        "maintype": "application",
                        "subtype": "pdf",
                    }
                ],
            )
        except Exception as exc:
            return jsonify({"error": f"Failed to send email: {exc}"}), 500

        log_action(
            "purchase_order_emailed",
            {"purchase_order_id": po.id, "to": supplier_email},
            g.current_company.id,
        )
        return jsonify({"sent": True, "to": supplier_email})

    @app.route("/sales", methods=["POST"])
    @require_auth
    @company_required(["admin", "manager"])
    def create_sale():
        data = request.get_json() or {}
        # Allow creating both regular sales and sales orders based on payload
        requested_source = (data.get("source") or "sales").strip() or "sales"
        return _create_sale_record(data, source=requested_source)

    @app.route("/sales/<int:sale_id>", methods=["PUT"])
    @require_auth
    @company_required(["admin", "manager", "salesman", "superuser"])
    def update_sale_order(sale_id: int):
        sale = Sale.query.get_or_404(sale_id)
        if sale.company_id != g.current_company.id:
            return jsonify({"error": "Forbidden"}), 403
        src = (sale.source or "").strip().lower()
        if src != "sales_order":
            return jsonify({"error": "Only undelivered sales orders can be edited"}), 400

        data = request.get_json() or {}
        items = data.get("items", [])
        if not items:
            return jsonify({"error": "Sale items are required"}), 400

        raw_sale_date = data.get("sale_date") or data.get("date")
        if raw_sale_date:
            try:
                sale_date = datetime.fromisoformat(str(raw_sale_date)).date()
            except ValueError:
                return jsonify({"error": "Invalid sale_date (expected YYYY-MM-DD)"}), 400
        else:
            sale_date = sale.sale_date or _today_ad()

        raw_expected = data.get("expected_delivery_date") or data.get("expected_date")
        if raw_expected:
            try:
                expected_delivery_date = datetime.fromisoformat(str(raw_expected)).date()
            except ValueError:
                return jsonify({"error": "Invalid expected_delivery_date (expected YYYY-MM-DD)"}), 400
        else:
            expected_delivery_date = sale.expected_delivery_date

        customer = None
        customer_id = data.get("customer_id")
        if customer_id:
            try:
                customer_id_int = int(customer_id)
            except (TypeError, ValueError):
                customer_id_int = 0
            customer = db.session.get(Customer, customer_id_int) if customer_id_int else None
            if not customer or customer.company_id != g.current_company.id:
                return jsonify({"error": "Customer not found"}), 404
        else:
            customer = _get_or_create_walkin_customer(g.current_company.id)

        # Replace item lines
        SaleItem.query.filter_by(sale_id=sale.id).delete(synchronize_session=False)
        def _to_non_negative_whole(value, field_label: str) -> int:
            if value is None or value == "":
                return 0
            try:
                parsed = float(value)
            except (TypeError, ValueError):
                raise ValueError(f"{field_label} must be a whole number")
            if parsed < 0:
                raise ValueError(f"{field_label} must be >= 0")
            if not parsed.is_integer():
                raise ValueError(f"{field_label} must be a whole number")
            return int(parsed)
        extra_charges_amount = 0.0
        if "extra_charges_amount" in data and data.get("extra_charges_amount") is not None:
            try:
                extra_charges_amount = float(data.get("extra_charges_amount") or 0.0)
            except (TypeError, ValueError):
                return jsonify({"error": "Invalid extra_charges_amount"}), 400
            if extra_charges_amount < 0:
                return jsonify({"error": "extra_charges_amount must be >= 0"}), 400

        subtotal_amount = 0.0
        line_discount_total = 0.0
        line_tax_total = 0.0
        for idx, item in enumerate(items):
            try:
                product_id = int(item.get("product_id") or 0)
            except (TypeError, ValueError):
                product_id = 0
            product = db.session.get(Product, product_id) if product_id else None
            try:
                ordered_input = _to_non_negative_whole(
                    item.get("ordered_qty", item.get("ordered_quantity", item.get("quantity", 0))),
                    f"Line {idx + 1}: quantity",
                )
                bonus_input = _to_non_negative_whole(
                    item.get("bonus_qty", item.get("bonus_quantity", 0)),
                    f"Line {idx + 1}: bonus quantity",
                )
            except ValueError as exc:
                db.session.rollback()
                return jsonify({"error": str(exc)}), 400
            quantity_input = max(0, ordered_input) + max(0, bonus_input)
            if not product or quantity_input <= 0 or product.company_id != g.current_company.id:
                db.session.rollback()
                return jsonify({"error": f"Line {idx + 1}: Invalid product or quantity"}), 400

            raw_uom = (item.get("uom") or "").strip() or None
            uom = _validate_uom_for_product(product, raw_uom)
            if product.uom_category and not uom:
                uom = _base_uom_name(product) or product.uom_category
            factor = _uom_factor_to_base(g.current_company.id, product, uom) if uom else 1.0
            qty_base = _qty_to_base(g.current_company.id, product, quantity_input, uom)
            if qty_base <= 0:
                db.session.rollback()
                return jsonify({"error": f"Line {idx + 1}: Invalid product or quantity"}), 400

            batch_id = None
            try:
                batch_id = int(item.get("inventory_batch_id") or 0) or None
            except (TypeError, ValueError):
                batch_id = None
            batch_number = (item.get("lot_number") or item.get("batch_number") or "").strip() or None

            unit_price_input = float(item.get("unit_price", 0.0) or 0.0)
            base_unit_price = unit_price_input / max(1.0, factor)
            gross_line = float(max(0, ordered_input)) * float(unit_price_input)
            try:
                line_discount_pct = float(item.get("discount_percent", 0.0) or 0.0)
            except (TypeError, ValueError):
                line_discount_pct = 0.0
            line_discount_pct = max(0.0, min(100.0, line_discount_pct))
            if line_discount_pct <= 0:
                try:
                    line_discount_amt_input = float(item.get("discount_amount", 0.0) or 0.0)
                except (TypeError, ValueError):
                    line_discount_amt_input = 0.0
                if gross_line > 0 and line_discount_amt_input > 0:
                    line_discount_pct = max(0.0, min(100.0, (line_discount_amt_input / gross_line) * 100.0))
            discount_amt = gross_line * (line_discount_pct / 100.0)
            taxable_line = max(0.0, gross_line - discount_amt)
            try:
                line_tax_pct = float(item.get("tax_percent", 0.0) or 0.0)
            except (TypeError, ValueError):
                line_tax_pct = 0.0
            line_tax_pct = max(0.0, line_tax_pct)
            if line_tax_pct <= 0:
                try:
                    line_tax_amt_input = float(item.get("tax_amount", 0.0) or 0.0)
                except (TypeError, ValueError):
                    line_tax_amt_input = 0.0
                if taxable_line > 0 and line_tax_amt_input > 0:
                    line_tax_pct = max(0.0, (line_tax_amt_input / taxable_line) * 100.0)
            tax_amt = taxable_line * (line_tax_pct / 100.0)

            sale_item = SaleItem(
                sale=sale,
                product=product,
                quantity=qty_base,
                quantity_uom=quantity_input,
                ordered_quantity_uom=max(0, ordered_input),
                bonus_quantity_uom=max(0, bonus_input),
                uom=uom,
                inventory_batch_id=batch_id,
                batch_number=batch_number if product.lot_tracking else None,
                unit_price_uom=unit_price_input,
                unit_price=base_unit_price,
                line_discount_percent=line_discount_pct,
                line_tax_percent=line_tax_pct,
            )
            db.session.add(sale_item)
            subtotal_amount += gross_line
            line_discount_total += discount_amt
            line_tax_total += tax_amt

        effective_discount_total = min(subtotal_amount, line_discount_total)
        total_amount = max(0.0, subtotal_amount - effective_discount_total + line_tax_total + extra_charges_amount)
        sale.customer = customer
        sale.payment_method = "N/A"
        sale.payment_status = "due"
        sale.paid_amount = 0.0
        sale.due_amount = round(total_amount, 2)
        sale.sale_date = sale_date
        sale.expected_delivery_date = expected_delivery_date
        sale.subtotal_amount = round(subtotal_amount, 2)
        sale.discount_amount = round(effective_discount_total, 2)
        sale.extra_charges_amount = round(extra_charges_amount, 2)
        sale.total_amount = round(total_amount, 2)

        db.session.commit()
        socketio.emit(
            "inventory:update",
            {"type": "sale_updated", "sale": _sale_payload_with_image_urls(sale), "company_id": g.current_company.id},
        )
        log_action("sale_order_updated", {"sale_id": sale.id}, g.current_company.id)
        return jsonify(_sale_payload_with_image_urls(sale))

    @app.route("/sales/<int:sale_id>", methods=["DELETE"])
    @require_auth
    @company_required(["admin", "manager", "superuser"])
    def delete_sale(sale_id: int):
        sale = Sale.query.get_or_404(sale_id)
        if sale.company_id != g.current_company.id:
            return jsonify({"error": "Forbidden"}), 403
        src = (sale.source or "").strip().lower()
        if src != "sales_order":
            return jsonify({"error": "Only undelivered sales orders can be deleted"}), 400
        if sale.returns:
            return jsonify({"error": "Cannot delete sales order with returns"}), 400

        db.session.delete(sale)
        db.session.commit()
        socketio.emit(
            "inventory:update",
            {"type": "sale_deleted", "sale_id": sale_id, "company_id": g.current_company.id},
        )
        log_action("sale_deleted", {"sale_id": sale_id, "source": src}, g.current_company.id)
        return jsonify({"status": "deleted", "id": sale_id})

    @app.route("/sales/<int:sale_id>/deliver", methods=["POST"])
    @require_auth
    @company_required(["admin", "manager", "salesman"])
    def deliver_sale_order(sale_id: int):
        sale = Sale.query.get_or_404(sale_id)
        if sale.company_id != g.current_company.id:
            return jsonify({"error": "Forbidden"}), 403
        if (sale.source or "").strip().lower() != "sales_order":
            return jsonify({"error": "Only sales orders can be delivered"}), 400

        data = request.get_json() or {}
        items = data.get("items", [])
        if not items:
            return jsonify({"error": "Sale items are required"}), 400

        raw_sale_date = data.get("sale_date") or data.get("date")
        if raw_sale_date:
            try:
                sale_date = datetime.fromisoformat(str(raw_sale_date)).date()
            except ValueError:
                return jsonify({"error": "Invalid sale_date (expected YYYY-MM-DD)"}), 400
        else:
            # When delivering, post the sale on the delivery day
            sale_date = _today_ad()
        # Use logical sale_date for eligibility checks; enforce arrival/locks by date.
        sale_at_now = None

        payment_method = data.get("payment_method", "cash")
        raw_payment_status = (data.get("payment_status") or "").strip().lower()
        payment_status = raw_payment_status or _infer_payment_status(payment_method, "paid")
        if payment_status not in {"paid", "due"}:
            return jsonify({"error": "Invalid payment_status"}), 400

        discount_amount = 0.0
        if "discount_amount" in data and data.get("discount_amount") is not None:
            try:
                discount_amount = float(data.get("discount_amount") or 0.0)
            except (TypeError, ValueError):
                return jsonify({"error": "Invalid discount_amount"}), 400
            if discount_amount < 0:
                return jsonify({"error": "discount_amount must be >= 0"}), 400
            if discount_amount > 0 and str(getattr(g, "company_role", "") or "").lower() not in {"admin", "manager"}:
                return jsonify({"error": "Only manager/admin can apply discount"}), 403
        extra_charges_amount = 0.0
        if "extra_charges_amount" in data and data.get("extra_charges_amount") is not None:
            try:
                extra_charges_amount = float(data.get("extra_charges_amount") or 0.0)
            except (TypeError, ValueError):
                return jsonify({"error": "Invalid extra_charges_amount"}), 400
            if extra_charges_amount < 0:
                return jsonify({"error": "extra_charges_amount must be >= 0"}), 400

        customer = None
        customer_id = data.get("customer_id") or sale.customer_id
        if customer_id:
            try:
                customer_id_int = int(customer_id)
            except (TypeError, ValueError):
                customer_id_int = 0
            customer = db.session.get(Customer, customer_id_int) if customer_id_int else None
            if not customer or customer.company_id != g.current_company.id:
                return jsonify({"error": "Customer not found"}), 404
        else:
            customer = _get_or_create_walkin_customer(g.current_company.id)

        def _to_non_negative_number(value, field_label: str) -> float:
            if value is None or value == "":
                return 0.0
            try:
                num = float(value)
            except (TypeError, ValueError):
                raise ValueError(f"{field_label} must be a valid number")
            if num < 0:
                raise ValueError(f"{field_label} must be >= 0")
            return float(num)

        # Clear existing items before re-adding with delivery batches.
        SaleItem.query.filter_by(sale_id=sale.id).delete(synchronize_session=False)

        subtotal_amount = 0.0
        line_discount_total = 0.0
        line_tax_total = 0.0
        for idx, item in enumerate(items):
            product = Product.query.get(item.get("product_id"))
            try:
                ordered_input = _to_non_negative_number(
                    item.get("ordered_qty", item.get("ordered_quantity", item.get("quantity", 0))),
                    f"Line {idx + 1}: ordered quantity",
                )
                bonus_input = _to_non_negative_number(
                    item.get("bonus_qty", item.get("bonus_quantity", 0)),
                    f"Line {idx + 1}: bonus quantity",
                )
            except ValueError as exc:
                db.session.rollback()
                return jsonify({"error": str(exc)}), 400
            quantity_input_raw = max(0.0, ordered_input) + max(0.0, bonus_input)
            if abs(quantity_input_raw - round(quantity_input_raw)) > 1e-9:
                db.session.rollback()
                return jsonify({"error": f"Line {idx + 1}: ordered + bonus must result in a whole total quantity"}), 400
            quantity_input = int(round(quantity_input_raw))
            if not product or quantity_input <= 0 or product.company_id != g.current_company.id:
                db.session.rollback()
                return jsonify({"error": f"Line {idx + 1}: Invalid product or quantity"}), 400

            raw_uom = (item.get("uom") or "").strip() or None
            uom = _validate_uom_for_product(product, raw_uom)
            if product.uom_category and not uom:
                uom = _base_uom_name(product) or product.uom_category
            factor = _uom_factor_to_base(g.current_company.id, product, uom) if uom else 1.0
            qty_base = _qty_to_base(g.current_company.id, product, quantity_input, uom)
            if qty_base <= 0:
                db.session.rollback()
                return jsonify({"error": f"Line {idx + 1}: Invalid product or quantity"}), 400

            try:
                batch_id = int(item.get("inventory_batch_id") or 0) or None
            except (TypeError, ValueError):
                batch_id = None

            batch_number = (item.get("lot_number") or item.get("batch_number") or "").strip() or None
            if product.lot_tracking:
                if not batch_id:
                    if batch_number:
                        candidate = (
                            InventoryBatch.query.filter(
                                InventoryBatch.company_id == g.current_company.id,
                                InventoryBatch.product_id == product.id,
                                InventoryBatch.batch_number == batch_number,
                                InventoryBatch.qty_base >= qty_base,
                            )
                            .order_by(InventoryBatch.expiry_date.asc().nullslast(), InventoryBatch.id.asc())
                            .first()
                        )
                        if candidate:
                            batch_id = candidate.id
                            batch_number = candidate.batch_number or batch_number
                        else:
                            db.session.rollback()
                            return jsonify({"error": f"Line {idx + 1}: Batch selection is required"}), 400
                    else:
                        db.session.rollback()
                        return jsonify({"error": f"Line {idx + 1}: Batch selection is required"}), 400
            elif not batch_id:
                # Auto-pick the earliest eligible batch with enough stock to fulfill this line
                candidate = (
                    InventoryBatch.query.filter(
                        InventoryBatch.company_id == g.current_company.id,
                        InventoryBatch.product_id == product.id,
                        InventoryBatch.qty_base >= qty_base,
                    )
                    .order_by(InventoryBatch.expiry_date.asc().nullslast(), InventoryBatch.id.asc())
                    .first()
                )
                if candidate:
                    batch_id = candidate.id
                    batch_number = candidate.batch_number or batch_number
                else:
                    db.session.rollback()
                    return jsonify({"error": f"Line {idx + 1}: Batch selection is required"}), 400

            batch = InventoryBatch.query.get(batch_id)
            if not batch or batch.company_id != g.current_company.id or batch.product_id != product.id:
                db.session.rollback()
                return jsonify({"error": f"Line {idx + 1}: Invalid batch selection"}), 400
            if int(batch.qty_base or 0) < int(qty_base):
                db.session.rollback()
                return jsonify({"error": f"Line {idx + 1}: Insufficient stock in selected batch"}), 400

            eligible_base = _eligible_available_base_for_batch(
                company_id=g.current_company.id,
                product=product,
                batch=batch,
                sale_date=sale_date,
                sale_at=sale_at_now,
            )
            if int(qty_base) > int(eligible_base):
                db.session.rollback()
                return jsonify(
                    {
                        "error": (
                            f"Line {idx + 1}: Selected batch does not have enough stock "
                            f"for delivery date {sale_date.isoformat()} "
                            f"(eligible {eligible_base} base units)"
                        )
                    }
                ), 400

            batch_number = batch.batch_number or batch_number
            unit_price_input = float(item.get("unit_price", product.price))
            base_unit_price = unit_price_input / max(1.0, factor)
            gross_line = float(max(0, ordered_input)) * float(unit_price_input)
            try:
                line_discount_pct = float(item.get("discount_percent", 0.0) or 0.0)
            except (TypeError, ValueError):
                line_discount_pct = 0.0
            line_discount_pct = max(0.0, min(100.0, line_discount_pct))
            if line_discount_pct <= 0:
                try:
                    line_discount_amt_input = float(item.get("discount_amount", 0.0) or 0.0)
                except (TypeError, ValueError):
                    line_discount_amt_input = 0.0
                if gross_line > 0 and line_discount_amt_input > 0:
                    line_discount_pct = max(0.0, min(100.0, (line_discount_amt_input / gross_line) * 100.0))
            discount_amt = gross_line * (line_discount_pct / 100.0)
            taxable_line = max(0.0, gross_line - discount_amt)
            try:
                line_tax_pct = float(item.get("tax_percent", 0.0) or 0.0)
            except (TypeError, ValueError):
                line_tax_pct = 0.0
            line_tax_pct = max(0.0, line_tax_pct)
            if line_tax_pct <= 0:
                try:
                    line_tax_amt_input = float(item.get("tax_amount", 0.0) or 0.0)
                except (TypeError, ValueError):
                    line_tax_amt_input = 0.0
                if taxable_line > 0 and line_tax_amt_input > 0:
                    line_tax_pct = max(0.0, (line_tax_amt_input / taxable_line) * 100.0)
            tax_amt = taxable_line * (line_tax_pct / 100.0)

            sale_item = SaleItem(
                sale=sale,
                product=product,
                quantity=qty_base,
                quantity_uom=quantity_input,
                ordered_quantity_uom=max(0.0, ordered_input),
                bonus_quantity_uom=max(0.0, bonus_input),
                uom=uom,
                inventory_batch_id=batch_id,
                batch_number=batch_number if product.lot_tracking else None,
                unit_price_uom=unit_price_input,
                unit_price=base_unit_price,
                line_discount_percent=line_discount_pct,
                line_tax_percent=line_tax_pct,
            )
            try:
                _allocate_sale_from_batches(
                    g.current_company.id,
                    product,
                    qty_base,
                    inventory_batch_id=batch_id,
                    batch_number=batch_number if product.lot_tracking else None,
                    preferred_uom=uom,
                )
            except ValueError as exc:
                db.session.rollback()
                return jsonify({"error": f"Line {idx + 1}: {str(exc)}"}), 400
            _recompute_product_stock_from_batches(g.current_company.id, product)
            db.session.add(sale_item)
            db.session.add(
                InventoryLog(
                    company_id=g.current_company.id,
                    product=product,
                    change=-qty_base,
                    reason=_sale_inventory_reason(sale),
                    reference_type=_sale_inventory_reference_type(sale),
                    reference_id=int(sale.id),
                )
            )
            subtotal_amount += gross_line
            line_discount_total += discount_amt
            line_tax_total += tax_amt

        discount_amount = min(discount_amount, max(0.0, subtotal_amount - line_discount_total))
        effective_discount_total = min(subtotal_amount, line_discount_total + discount_amount)
        total_amount = max(0.0, subtotal_amount - effective_discount_total + line_tax_total + extra_charges_amount)
        sale.subtotal_amount = round(subtotal_amount, 2)
        sale.discount_amount = round(effective_discount_total, 2)
        sale.extra_charges_amount = round(extra_charges_amount, 2)
        sale.total_amount = round(total_amount, 2)
        # Delivery date becomes the expected/delivered date
        expected_delivery_date = sale_date
        sale.payment_status = payment_status
        sale.payment_method = payment_method
        sale.sale_date = sale_date
        sale.expected_delivery_date = expected_delivery_date
        sale.source = "sales_order_delivered"
        sale.inventory_posted = True
        if payment_status == "due":
            sale.paid_amount = 0.0
            sale.due_amount = float(sale.total_amount or 0.0)
        else:
            sale.paid_amount = float(sale.total_amount or 0.0)
            sale.due_amount = 0.0

        if float(sale.total_amount or 0.0) > 0:
            if payment_status == "due":
                receivable_name = _sale_receivable_account_name(g.current_company.id, sale.customer)
                _post_double_entry(
                    g.current_company.id,
                    receivable_name,
                    "asset",
                    "Sales Revenue",
                    "income",
                    float(sale.total_amount or 0.0),
                    "sale",
                    sale.id,
                    f"Sale #{sale.id} (due)",
                )
            else:
                payment_account = _resolve_payment_account_name(g.current_company.id, payment_method, "Cash")
                _post_double_entry(
                    g.current_company.id,
                    payment_account,
                    "asset",
                    "Sales Revenue",
                    "income",
                    float(sale.total_amount or 0.0),
                    "sale",
                    sale.id,
                    f"Sale #{sale.id} (paid)",
                )

        db.session.commit()
        socketio.emit(
            "inventory:update",
            {"type": "sale_delivered", "sale": _sale_payload_with_image_urls(sale), "company_id": g.current_company.id},
        )
        log_action("sale_order_delivered", {"sale_id": sale.id, "total": sale.total_amount}, g.current_company.id)
        return jsonify(_sale_payload_with_image_urls(sale))

    @app.route("/sales/daily", methods=["GET"])
    @require_auth
    @company_required()
    def get_daily_sales():
        today = _today_ad()
        start_of_day = datetime.combine(today, datetime.min.time())
        end_of_day = start_of_day + timedelta(days=1)
        role = str(getattr(g, "company_role", None) or getattr(g.current_user, "role", "") or "").lower()
        include_denied = (
            (request.args.get("include_denied") or "").strip().lower() in {"1", "true", "yes"}
            and role in {"superuser", "superadmin"}
        )
        base_query = Sale.query.filter(
            Sale.company_id == g.current_company.id,
            Sale.source == "daily_sales",
        )
        if include_denied:
            base_query = base_query.filter(
                or_(Sale.approval_status.is_(None), Sale.approval_status.in_(["pending", "denied"]))
            )
        else:
            # Hide denied transactions from the daily sales list.
            base_query = base_query.filter(
                or_(Sale.approval_status.is_(None), Sale.approval_status != "denied")
            )
            # Only show unapproved daily sales (approved are posted to Sales).
            base_query = base_query.filter(
                or_(Sale.approval_status.is_(None), Sale.approval_status == "pending")
            )
        if role in {"staff", "salesman"}:
            # Staff/salesman: only show their pending/unapproved transactions, even across days.
            query = base_query.filter(
                Sale.created_by_user_id == getattr(g.current_user, "id", None),
            )
        else:
            # Managers/admins: show all pending transactions across days.
            query = base_query
        sales = query.order_by(Sale.created_at.desc()).all()

        return_query = SaleReturn.query.filter(
            SaleReturn.company_id == g.current_company.id,
            SaleReturn.return_date == today,
        )
        if role not in {"admin", "superuser", "superadmin"}:
            return_query = return_query.filter(SaleReturn.created_by_user_id == getattr(g.current_user, "id", None))
        returns = return_query.order_by(SaleReturn.created_at.desc()).all()

        payload = [_sale_payload_with_image_urls(sale) for sale in sales]
        payload.extend([_sale_return_as_sale_dict(sr) for sr in returns])
        payload.sort(key=lambda row: row.get("created_at") or "", reverse=True)
        return jsonify(payload)

    def _can_view_company_backdated_sales() -> bool:
        role = str(getattr(g, "company_role", None) or getattr(g.current_user, "role", "") or "").lower()
        return role in {"manager", "superuser", "superadmin"}

    def _enforce_backdated_sale_visibility(sale: Sale):
        if sale.company_id != g.current_company.id:
            return jsonify({"error": "Forbidden"}), 403
        if (sale.source or "").strip().lower() != "backdated_daily_sales":
            return None
        if _can_view_company_backdated_sales():
            return None
        if int(sale.created_by_user_id or 0) == int(getattr(g.current_user, "id", 0) or 0):
            return None
        return jsonify({"error": "Forbidden"}), 403

    def _hard_delete_denied_sale(sale: Sale, *, action_name: str, event_type: str):
        if sale.returns:
            return jsonify({"error": "Cannot delete denied sale with returns"}), 400
        sale_payload = _sale_payload_with_image_urls(sale)
        sale_id = int(sale.id)
        sale_label = sale.sale_number or f"#{sale_id}"
        source = (sale.source or "").strip().lower()
        AccountEntry.query.filter(
            AccountEntry.company_id == g.current_company.id,
            AccountEntry.reference_id == sale_id,
            AccountEntry.reference_type.in_(_sale_accounting_reference_types(sale) + ["sale_denied"]),
        ).delete(synchronize_session=False)
        db.session.delete(sale)
        db.session.commit()
        socketio.emit(
            "inventory:update",
            {"type": event_type, "sale_id": sale_id, "company_id": g.current_company.id, "source": source},
        )
        log_action(
            action_name,
            {"sale_id": sale_id, "sale_number": sale_label, "source": source},
            g.current_company.id,
        )
        return jsonify({"status": "deleted", "id": sale_id, "sale": sale_payload})

    @app.route("/sales/daily/<int:sale_id>/approve", methods=["POST"])
    @require_auth
    @company_required(["admin", "manager", "superuser"])
    def approve_daily_sale(sale_id: int):
        sale = Sale.query.get_or_404(sale_id)
        if sale.company_id != g.current_company.id or sale.source != "daily_sales":
            return jsonify({"error": "Not a daily sale for this company"}), 403
        sale.approval_status = "approved"
        sale.approved_by_user_id = getattr(g.current_user, "id", None)
        sale.approved_at = datetime.now(timezone.utc)
        try:
            _post_sale_inventory_if_ready(sale)
        except ValueError as exc:
            db.session.rollback()
            return jsonify({"error": str(exc)}), 400
        _ensure_sale_accounting_posted(sale)
        db.session.commit()
        socketio.emit(
            "inventory:update",
            {"type": "sale_updated", "sale": _sale_payload_with_image_urls(sale), "company_id": g.current_company.id},
        )
        log_action("daily_sale_approved", {"sale_id": sale.id}, g.current_company.id)
        return jsonify(_sale_payload_with_image_urls(sale))

    @app.route("/sales/daily/<int:sale_id>/deny", methods=["POST"])
    @require_auth
    @company_required(["admin", "manager", "superuser"])
    def deny_daily_sale(sale_id: int):
        sale = Sale.query.get_or_404(sale_id)
        if sale.company_id != g.current_company.id or sale.source != "daily_sales":
            return jsonify({"error": "Not a daily sale for this company"}), 403
        if (sale.approval_status or "").lower() == "approved":
            return jsonify({"error": "Approved daily sales cannot be denied"}), 400
        if (sale.approval_status or "").lower() == "denied":
            return jsonify(_sale_payload_with_image_urls(sale))

        if sale.inventory_posted:
            for item in sale.items or []:
                product = item.product
                if not product or product.company_id != g.current_company.id:
                    continue
                try:
                    qty_base = int(item.quantity or 0)
                except (TypeError, ValueError):
                    qty_base = 0
                if qty_base <= 0:
                    continue
                batch = None
                if item.inventory_batch_id:
                    batch = InventoryBatch.query.get(item.inventory_batch_id)
                    if not batch or batch.company_id != g.current_company.id:
                        batch = None
                if not batch and item.batch_number:
                    batch = (
                        InventoryBatch.query.filter_by(
                            company_id=g.current_company.id,
                            product_id=product.id,
                            batch_number=item.batch_number,
                        )
                        .order_by(InventoryBatch.created_at.desc())
                        .first()
                    )
                if not batch:
                    batch = (
                        InventoryBatch.query.filter_by(company_id=g.current_company.id, product_id=product.id)
                        .order_by(InventoryBatch.expiry_date.asc().nullslast(), InventoryBatch.id.asc())
                        .first()
                    )
                if not batch:
                    continue
                batch.qty_base = int(batch.qty_base or 0) + qty_base
                _recompute_product_stock_from_batches(g.current_company.id, product)
                db.session.add(
                    InventoryLog(
                        company_id=g.current_company.id,
                        product=product,
                        change=qty_base,
                        reason="sale_denied",
                        reference_type=_sale_inventory_reference_type(sale),
                        reference_id=int(sale.id),
                    )
                )

        # Reverse the accounting impact (sales revenue and cash/receivable) for denied daily sales.
        # Daily sales are already posted to accounting at creation time; denial is a void/cancel.
        try:
            total_amount = float(sale.total_amount or 0.0)
        except (TypeError, ValueError):
            total_amount = 0.0
        if total_amount > 0:
            # Only reverse once.
            already_reversed = (
                db.session.query(AccountEntry.id)
                .filter(
                    AccountEntry.company_id == g.current_company.id,
                    AccountEntry.reference_type == "sale_denied",
                    AccountEntry.reference_id == sale.id,
                )
                .first()
                is not None
            )
            had_sale_entries = _sale_accounting_has_ref(sale)
            if (not already_reversed) and had_sale_entries:
                if (sale.payment_status or "").strip().lower() == "due":
                    credit_name = _sale_receivable_account_name(g.current_company.id, sale.customer)
                    credit_type = "asset"
                else:
                    credit_name = _resolve_payment_account_name(
                        g.current_company.id, sale.payment_method, "Cash"
                    )
                    credit_type = "asset"
                    existing_acc = Account.query.filter(
                        Account.company_id == g.current_company.id,
                        func.lower(Account.name) == func.lower(credit_name),
                    ).first()
                    if existing_acc:
                        credit_type = existing_acc.type or credit_type
                sale_label = sale.sale_number or f"#{sale.id}"
                _post_double_entry(
                    g.current_company.id,
                    "Sales Revenue",
                    "income",
                    credit_name,
                    credit_type,
                    total_amount,
                    "sale_denied",
                    sale.id,
                    f"Denied daily sale reversal {sale_label}",
                )

        sale.approval_status = "denied"
        sale.inventory_posted = False
        db.session.commit()
        socketio.emit(
            "inventory:update",
            {"type": "daily_sale_denied", "sale": _sale_payload_with_image_urls(sale), "company_id": g.current_company.id},
        )
        log_action("daily_sale_denied", {"sale_id": sale.id}, g.current_company.id)
        return jsonify(_sale_payload_with_image_urls(sale))

    @app.route("/sales/daily/<int:sale_id>", methods=["DELETE"])
    @require_auth
    @company_required(["superuser", "superadmin"])
    def delete_denied_daily_sale(sale_id: int):
        sale = Sale.query.get_or_404(sale_id)
        if sale.company_id != g.current_company.id or sale.source != "daily_sales":
            return jsonify({"error": "Not a daily sale for this company"}), 403
        if (sale.approval_status or "").lower() != "denied":
            return jsonify({"error": "Only denied daily sales can be deleted"}), 400
        return _hard_delete_denied_sale(
            sale,
            action_name="daily_sale_deleted",
            event_type="daily_sale_deleted",
        )

    @app.route("/sales/daily", methods=["POST"])
    @require_auth
    @company_required(["admin", "manager", "salesman", "staff"])
    def create_daily_sale():
        data = request.get_json() or {}
        role = str(getattr(g, "company_role", None) or getattr(g.current_user, "role", "") or "").lower()
        extra_fields = {}
        source = "daily_sales"
        if role in {"staff", "salesman"}:
            extra_fields["approval_status"] = "pending"
        else:
            # Managers/admins create POS sales that should appear in Sales immediately.
            extra_fields["approval_status"] = "approved"
            source = "sales"
        return _create_sale_record(data, source=source, extra_fields=extra_fields)

    @app.route("/sales/backdated", methods=["GET"])
    @require_auth
    @company_required()
    def get_backdated_daily_sales():
        def _backdated_approval_state(sale_obj: Sale) -> tuple[list[str], bool]:
            missing_local: list[str] = []
            for item in sale_obj.items or []:
                product_name = getattr(item.product, "name", None) or f"#{item.product_id}"
                try:
                    qty_val = int(item.quantity or 0)
                except (TypeError, ValueError):
                    qty_val = 0
                if qty_val <= 0:
                    missing_local.append(f"{product_name}: quantity missing")
                if not (item.uom or (item.product and item.product.uom_category)):
                    missing_local.append(f"{product_name}: UoM missing")
                if not item.inventory_batch_id:
                    missing_local.append(f"{product_name}: inventory batch missing")
                unit_price_val = float(item.unit_price_uom or item.unit_price or 0.0)
                if unit_price_val <= 0:
                    missing_local.append(f"{product_name}: MRP missing")
                if item.inventory_batch_id:
                    batch = InventoryBatch.query.get(item.inventory_batch_id)
                    if not batch or batch.company_id != sale_obj.company_id or batch.product_id != item.product_id:
                        missing_local.append(f"{product_name}: inventory batch missing")
                    else:
                        try:
                            qty_uom = float(item.quantity_uom or item.quantity or 0.0)
                        except (TypeError, ValueError):
                            qty_uom = 0.0
                        raw_uom = (item.uom or "").strip() or None
                        uom = _validate_uom_for_product(item.product, raw_uom) if item.product else raw_uom
                        if item.product and item.product.uom_category and not uom:
                            uom = _base_uom_name(item.product) or item.product.uom_category
                        qty_base = int(_qty_to_base(g.current_company.id, item.product, qty_uom, uom) or 0) if item.product else 0
                        if qty_base > 0:
                            block_reason = _backdated_batch_block_reason(
                                batch=batch,
                                sale_date=sale_obj.sale_date or (sale_obj.created_at.date() if sale_obj.created_at else _today_ad()),
                            )
                            if block_reason:
                                missing_local.append(f"{product_name}: {block_reason}")
                                continue
                            try:
                                eligible = _eligible_available_base_for_batch(
                                    company_id=g.current_company.id,
                                    product=item.product,
                                    batch=batch,
                                    sale_date=sale_obj.sale_date or (sale_obj.created_at.date() if sale_obj.created_at else _today_ad()),
                                    cap_with_current=False,
                                )
                            except Exception:
                                eligible = 0
                            if int(eligible or 0) < qty_base:
                                missing_local.append(f"{product_name}: inventory not arrived")
            return missing_local, len(missing_local) == 0

        role = str(getattr(g, "company_role", None) or getattr(g.current_user, "role", "") or "").lower()
        include_denied = (
            (request.args.get("include_denied") or "").strip().lower() in {"1", "true", "yes"}
            and role in {"superuser", "superadmin"}
        )
        base_query = Sale.query.filter(
            Sale.company_id == g.current_company.id,
            Sale.source == "backdated_daily_sales",
        )
        if not include_denied:
            # Hide denied transactions from the backdated list.
            base_query = base_query.filter(
                or_(Sale.approval_status.is_(None), Sale.approval_status != "denied")
            )
        if _can_view_company_backdated_sales():
            query = base_query
        else:
            query = base_query.filter(Sale.created_by_user_id == getattr(g.current_user, "id", None))
        sales = query.order_by(Sale.created_at.desc()).all()
        pending = [s for s in sales if (s.approval_status or "pending").lower() == "pending"]
        if pending:
            _relink_pending_backdated_sales(company_id=g.current_company.id, pending_sales=pending)
        payload = []
        for sale in sales:
            data = _sale_payload_with_image_urls(sale)
            missing, ready = _backdated_approval_state(sale)
            data["approval_ready"] = bool(ready)
            data["approval_missing"] = missing
            payload.append(data)
        payload.sort(key=lambda row: row.get("created_at") or "", reverse=True)
        return jsonify(payload)

    @app.route("/sales/backdated/daywise", methods=["GET"])
    @require_auth
    @company_required(["manager", "superuser", "superadmin"])
    def get_backdated_daily_sales_daywise():
        role = str(getattr(g, "company_role", None) or getattr(g.current_user, "role", "") or "").lower()
        include_denied = (
            (request.args.get("include_denied") or "").strip().lower() in {"1", "true", "yes"}
            and role in {"superuser", "superadmin"}
        )
        base_query = Sale.query.filter(
            Sale.company_id == g.current_company.id,
            Sale.source == "backdated_daily_sales",
        )
        if not include_denied:
            base_query = base_query.filter(or_(Sale.approval_status.is_(None), Sale.approval_status != "denied"))
        sales = base_query.all()
        pending = [s for s in sales if (s.approval_status or "pending").lower() == "pending"]
        if pending:
            _relink_pending_backdated_sales(company_id=g.current_company.id, pending_sales=pending)

        try:
            import nepali_datetime  # type: ignore
        except Exception:
            nepali_datetime = None

        def _bs_from_ad(ad_value: date | None) -> str | None:
            if not ad_value or nepali_datetime is None:
                return None
            try:
                return nepali_datetime.date.from_datetime_date(ad_value).strftime("%Y-%m-%d")
            except Exception:
                return None

        grouped = {}
        for sale in sales:
            sale_date = sale.sale_date or (sale.created_at.date() if sale.created_at else None)
            date_key = sale_date.isoformat() if sale_date else "unknown"
            row = grouped.get(date_key)
            if not row:
                grouped[date_key] = {
                    "date_ad": date_key,
                    "date_bs": _bs_from_ad(sale_date) if sale_date else None,
                    "total_amount": 0.0,
                    "count": 0,
                }
                row = grouped[date_key]
            row["total_amount"] += float(sale.total_amount or 0.0)
            row["count"] += 1

        rows = list(grouped.values())
        rows.sort(key=lambda r: r.get("date_ad") or "", reverse=True)
        for r in rows:
            r["total_amount"] = round(float(r.get("total_amount") or 0.0), 2)
        return jsonify(rows)

    @app.route("/sales/backdated/repair-totals", methods=["POST"])
    @require_auth
    @company_required(["admin", "manager", "superuser", "superadmin"])
    def repair_backdated_daily_sale_totals():
        data = request.get_json(silent=True) or {}
        raw_ids = data.get("sale_ids") or data.get("ids") or []
        sale_ids: list[int] | None = None
        if isinstance(raw_ids, list) and raw_ids:
            cleaned: list[int] = []
            for raw in raw_ids:
                try:
                    cleaned.append(int(raw))
                except (TypeError, ValueError):
                    continue
            sale_ids = cleaned or None
        result = _repair_backdated_sale_totals(company_id=g.current_company.id, sale_ids=sale_ids)
        db.session.commit()
        log_action(
            "backdated_daily_sale_totals_repaired",
            {
                "sale_ids": sale_ids or "all",
                "changed_count": len(result.get("changed") or []),
                "unrecoverable_count": len(result.get("unrecoverable") or []),
            },
            g.current_company.id,
        )
        return jsonify(result)

    @app.route("/sales/backdated/<int:sale_id>/approve", methods=["POST"])
    @require_auth
    @company_required(["admin", "manager", "superuser", "superadmin"])
    def approve_backdated_daily_sale(sale_id: int):
        sale = Sale.query.get_or_404(sale_id)
        if sale.company_id != g.current_company.id or sale.source != "backdated_daily_sales":
            return jsonify({"error": "Not a backdated daily sale for this company"}), 403
        _relink_pending_backdated_sales(company_id=g.current_company.id, pending_sales=[sale])
        missing = []
        for item in sale.items or []:
            product_name = getattr(item.product, "name", None) or f"#{item.product_id}"
            try:
                qty_val = int(item.quantity or 0)
            except (TypeError, ValueError):
                qty_val = 0
            if qty_val <= 0:
                missing.append(f"{product_name}: quantity missing")
            if not (item.uom or (item.product and item.product.uom_category)):
                missing.append(f"{product_name}: UoM missing")
            if not item.inventory_batch_id:
                missing.append(f"{product_name}: inventory batch missing")
            unit_price_val = float(item.unit_price_uom or item.unit_price or 0.0)
            if unit_price_val <= 0:
                missing.append(f"{product_name}: MRP missing")
            if item.inventory_batch_id:
                batch = InventoryBatch.query.get(item.inventory_batch_id)
                if not batch or batch.company_id != sale.company_id or batch.product_id != item.product_id:
                    missing.append(f"{product_name}: inventory batch missing")
                else:
                    try:
                        qty_uom = float(item.quantity_uom or item.quantity or 0.0)
                    except (TypeError, ValueError):
                        qty_uom = 0.0
                    raw_uom = (item.uom or "").strip() or None
                    uom = _validate_uom_for_product(item.product, raw_uom) if item.product else raw_uom
                    if item.product and item.product.uom_category and not uom:
                        uom = _base_uom_name(item.product) or item.product.uom_category
                    qty_base = int(_qty_to_base(g.current_company.id, item.product, qty_uom, uom) or 0) if item.product else 0
                    if qty_base > 0:
                        block_reason = _backdated_batch_block_reason(
                            batch=batch,
                            sale_date=sale.sale_date or (sale.created_at.date() if sale.created_at else _today_ad()),
                        )
                        if block_reason:
                            missing.append(f"{product_name}: {block_reason}")
                            continue
                        try:
                            eligible = _eligible_available_base_for_batch(
                                company_id=g.current_company.id,
                                product=item.product,
                                batch=batch,
                                sale_date=sale.sale_date or (sale.created_at.date() if sale.created_at else _today_ad()),
                                cap_with_current=False,
                            )
                        except Exception:
                            eligible = 0
                        if int(eligible or 0) < qty_base:
                            missing.append(f"{product_name}: inventory not arrived")
        if missing:
            return jsonify(
                {
                    "error": "Backdated sale cannot be approved until all fields are available.",
                    "missing": missing,
                }
            ), 400
        sale.origin_source = sale.origin_source or "backdated_daily_sales"
        sale.source = "sales"
        sale.approval_status = "approved"
        sale.approved_by_user_id = getattr(g.current_user, "id", None)
        sale.approved_at = datetime.now(timezone.utc)
        try:
            _post_sale_inventory_if_ready(sale)
        except ValueError as exc:
            db.session.rollback()
            return jsonify({"error": str(exc)}), 400
        _ensure_sale_accounting_posted(sale)
        db.session.commit()
        socketio.emit(
            "inventory:update",
            {"type": "sale_updated", "sale": _sale_payload_with_image_urls(sale), "company_id": g.current_company.id},
        )
        log_action("backdated_daily_sale_approved", {"sale_id": sale.id}, g.current_company.id)
        return jsonify(_sale_payload_with_image_urls(sale))

    @app.route("/sales/backdated/<int:sale_id>", methods=["PUT"])
    @require_auth
    @company_required(["admin", "manager", "superuser", "superadmin"])
    def update_backdated_daily_sale(sale_id: int):
        sale = Sale.query.get_or_404(sale_id)
        if sale.company_id != g.current_company.id or sale.source != "backdated_daily_sales":
            return jsonify({"error": "Not a backdated daily sale for this company"}), 403
        if not _can_view_company_backdated_sales() and int(sale.created_by_user_id or 0) != int(getattr(g.current_user, "id", 0) or 0):
            return jsonify({"error": "Forbidden"}), 403
        if (sale.approval_status or "").lower() == "approved":
            return jsonify({"error": "Approved backdated sales cannot be edited"}), 400
        sale.origin_source = sale.origin_source or "backdated_daily_sales"
        affected_product_ids: set[int] = {
            int(item.product_id)
            for item in (sale.items or [])
            if getattr(item, "product_id", None)
        }

        data = request.get_json() or {}
        items = data.get("items") or []
        if not items:
            return jsonify({"error": "Sale items are required"}), 400

        raw_sale_date = data.get("sale_date") or data.get("date")
        if raw_sale_date:
            try:
                sale.sale_date = datetime.fromisoformat(str(raw_sale_date)).date()
            except ValueError:
                return jsonify({"error": "Invalid sale_date (expected YYYY-MM-DD)"}), 400

        # Remove previous items + accounting entries
        SaleItem.query.filter_by(sale_id=sale.id).delete()
        AccountEntry.query.filter(
            AccountEntry.company_id == g.current_company.id,
            AccountEntry.reference_id == sale.id,
            AccountEntry.reference_type.in_(_sale_accounting_reference_types(sale)),
        ).delete(synchronize_session=False)

        backdated_target_total = None
        try:
            backdated_target_total = float(data.get("overall_total") or data.get("total_amount") or 0.0)
        except (TypeError, ValueError):
            backdated_target_total = None

        subtotal_amount = 0.0
        line_discount_total = 0.0
        line_tax_total = 0.0
        sum_mrp_total = 0.0

        for item in items:
            try:
                product_id = int(item.get("product_id") or 0)
            except (TypeError, ValueError):
                product_id = 0
            product = db.session.get(Product, product_id) if product_id else None
            if product_id > 0:
                affected_product_ids.add(product_id)
            if not product or product.company_id != g.current_company.id:
                return jsonify({"error": "Invalid product in sale items"}), 400

            ordered_input = float(item.get("ordered_qty", item.get("ordered_quantity", item.get("quantity", 0))) or 0.0)
            bonus_input = float(item.get("bonus_qty", item.get("bonus_quantity", 0)) or 0.0)
            quantity_input = max(0.0, ordered_input) + max(0.0, bonus_input)
            if quantity_input <= 0:
                return jsonify({"error": "Line quantity must be greater than 0"}), 400

            raw_uom = (item.get("uom") or "").strip() or None
            uom = _validate_uom_for_product(product, raw_uom)
            if product.uom_category and not uom:
                uom = _base_uom_name(product) or product.uom_category

            qty_base = _qty_to_base(g.current_company.id, product, quantity_input, uom)
            if qty_base <= 0:
                return jsonify({"error": "Invalid quantity for selected UoM"}), 400

            unit_price_input = float(item.get("unit_price", 0.0) or 0.0)
            base_unit_price = unit_price_input / max(1.0, float(_uom_factor_to_base(g.current_company.id, product, uom) or 1.0))
            gross_line = float(max(0.0, ordered_input)) * float(unit_price_input)
            sum_mrp_total += gross_line

            try:
                line_discount_pct = float(item.get("discount_percent", 0.0) or 0.0)
            except (TypeError, ValueError):
                line_discount_pct = 0.0
            line_discount_pct = max(0.0, min(100.0, line_discount_pct))

            if line_discount_pct <= 0 and backdated_target_total is not None and sum_mrp_total > 0:
                backdated_discount_pct = max(
                    0.0, min(100.0, ((sum_mrp_total - backdated_target_total) / sum_mrp_total) * 100.0)
                )
                line_discount_pct = backdated_discount_pct

            discount_amt = gross_line * (line_discount_pct / 100.0)
            taxable_line = max(0.0, gross_line - discount_amt)

            try:
                line_tax_pct = float(item.get("tax_percent", 0.0) or 0.0)
            except (TypeError, ValueError):
                line_tax_pct = 0.0
            line_tax_pct = max(0.0, line_tax_pct)
            tax_amt = taxable_line * (line_tax_pct / 100.0)

            sale_item = SaleItem(
                sale=sale,
                product=product,
                quantity=int(qty_base),
                quantity_uom=quantity_input,
                ordered_quantity_uom=max(0.0, ordered_input),
                bonus_quantity_uom=max(0.0, bonus_input),
                uom=uom,
                batch_number=(item.get("batch_number") or item.get("lot_number") or None),
                unit_price_uom=unit_price_input,
                unit_price=base_unit_price,
                line_discount_percent=line_discount_pct,
                line_tax_percent=line_tax_pct,
            )
            db.session.add(sale_item)
            subtotal_amount += gross_line
            line_discount_total += discount_amt
            line_tax_total += tax_amt

        computed_total_amount = max(0.0, subtotal_amount - line_discount_total + line_tax_total)
        if backdated_target_total is not None and backdated_target_total > 0:
            sale.subtotal_amount = round(subtotal_amount, 2) if subtotal_amount > 0 else round(backdated_target_total, 2)
            sale.discount_amount = round(line_discount_total, 2) if subtotal_amount > 0 else 0.0
            sale.extra_charges_amount = 0.0
            sale.total_amount = round(backdated_target_total, 2)
        else:
            sale.subtotal_amount = round(subtotal_amount, 2)
            sale.discount_amount = round(line_discount_total, 2)
            sale.extra_charges_amount = 0.0
            sale.total_amount = round(computed_total_amount, 2)
        if (sale.payment_status or "").strip().lower() == "due":
            sale.paid_amount = 0.0
            sale.due_amount = float(sale.total_amount or 0.0)
        else:
            sale.paid_amount = float(sale.total_amount or 0.0)
            sale.due_amount = 0.0
        _relink_pending_backdated_sales(
            company_id=g.current_company.id,
            product_ids=affected_product_ids,
        )
        sale.updated_at = datetime.now(timezone.utc)

        db.session.commit()
        log_action("backdated_daily_sale_updated", {"sale_id": sale.id}, g.current_company.id)
        return jsonify(_sale_payload_with_image_urls(sale))

    @app.route("/sales/backdated/bulk-approve", methods=["POST"])
    @require_auth
    @company_required(["admin", "manager", "superuser", "superadmin"])
    def bulk_approve_backdated_daily_sales():
        data = request.get_json() or {}
        sale_ids = data.get("sale_ids") or data.get("ids") or []
        if not isinstance(sale_ids, list) or not sale_ids:
            return jsonify({"error": "sale_ids is required"}), 400
        cleaned_ids = []
        for raw in sale_ids:
            try:
                cleaned_ids.append(int(raw))
            except (TypeError, ValueError):
                continue
        if not cleaned_ids:
            return jsonify({"error": "No valid sale_ids provided"}), 400
        sales = (
            Sale.query.filter(
                Sale.company_id == g.current_company.id,
                Sale.source == "backdated_daily_sales",
                Sale.id.in_(cleaned_ids),
            )
            .all()
        )
        if sales:
            _relink_pending_backdated_sales(company_id=g.current_company.id, pending_sales=sales)
        updated = []
        skipped = {}
        for sale in sales:
            if (sale.approval_status or "").lower() in {"approved", "denied"}:
                continue
            missing = []
            for item in sale.items or []:
                product_name = getattr(item.product, "name", None) or f"#{item.product_id}"
                try:
                    qty_val = int(item.quantity or 0)
                except (TypeError, ValueError):
                    qty_val = 0
                if qty_val <= 0:
                    missing.append(f"{product_name}: quantity missing")
                if not (item.uom or (item.product and item.product.uom_category)):
                    missing.append(f"{product_name}: UoM missing")
                if not item.inventory_batch_id:
                    missing.append(f"{product_name}: inventory batch missing")
                unit_price_val = float(item.unit_price_uom or item.unit_price or 0.0)
                if unit_price_val <= 0:
                    missing.append(f"{product_name}: MRP missing")
            if item.inventory_batch_id:
                batch = InventoryBatch.query.get(item.inventory_batch_id)
                if not batch or batch.company_id != sale.company_id or batch.product_id != item.product_id:
                    missing.append(f"{product_name}: inventory batch missing")
                else:
                    try:
                        qty_uom = float(item.quantity_uom or item.quantity or 0.0)
                    except (TypeError, ValueError):
                        qty_uom = 0.0
                    raw_uom = (item.uom or "").strip() or None
                    uom = _validate_uom_for_product(item.product, raw_uom) if item.product else raw_uom
                    if item.product and item.product.uom_category and not uom:
                        uom = _base_uom_name(item.product) or item.product.uom_category
                    qty_base = int(_qty_to_base(g.current_company.id, item.product, qty_uom, uom) or 0) if item.product else 0
                    if qty_base > 0:
                        block_reason = _backdated_batch_block_reason(
                            batch=batch,
                            sale_date=sale.sale_date or (sale.created_at.date() if sale.created_at else _today_ad()),
                        )
                        if block_reason:
                            missing.append(f"{product_name}: {block_reason}")
                            continue
                        try:
                            eligible = _eligible_available_base_for_batch(
                                company_id=g.current_company.id,
                                product=item.product,
                                batch=batch,
                                sale_date=sale.sale_date or (sale.created_at.date() if sale.created_at else _today_ad()),
                                cap_with_current=False,
                            )
                        except Exception:
                            eligible = 0
                        if int(eligible or 0) < qty_base:
                            missing.append(f"{product_name}: inventory not arrived")
            if missing:
                skipped[sale.id] = missing
                continue
            sale.origin_source = sale.origin_source or "backdated_daily_sales"
            sale.source = "sales"
            sale.approval_status = "approved"
            sale.approved_by_user_id = getattr(g.current_user, "id", None)
            sale.approved_at = datetime.now(timezone.utc)
            try:
                _post_sale_inventory_if_ready(sale)
            except ValueError as exc:
                skipped[sale.id] = [str(exc)]
                db.session.rollback()
                return jsonify({"error": str(exc), "sale_id": sale.id}), 400
            _ensure_sale_accounting_posted(sale)
            updated.append(sale)
        db.session.commit()
        for sale in updated:
            socketio.emit(
                "inventory:update",
                {"type": "sale_updated", "sale": _sale_payload_with_image_urls(sale), "company_id": g.current_company.id},
            )
            log_action("backdated_daily_sale_approved", {"sale_id": sale.id}, g.current_company.id)
        return jsonify(
            {
                "approved_ids": [s.id for s in updated],
                "count": len(updated),
                "skipped": skipped,
            }
        )

    @app.route("/sales/backdated/<int:sale_id>", methods=["DELETE"])
    @require_auth
    @company_required(["manager", "superuser", "superadmin"])
    def delete_backdated_daily_sale(sale_id: int):
        sale = Sale.query.get_or_404(sale_id)
        if sale.company_id != g.current_company.id or sale.source != "backdated_daily_sales":
            return jsonify({"error": "Not a backdated daily sale for this company"}), 403
        if (sale.approval_status or "").lower() == "denied":
            role = str(getattr(g, "company_role", None) or getattr(g.current_user, "role", "") or "").lower()
            if role not in {"manager", "superuser", "superadmin"}:
                return jsonify({"error": "Only manager/superuser can delete denied backdated sales"}), 403
            return _hard_delete_denied_sale(
                sale,
                action_name="backdated_daily_sale_deleted",
                event_type="backdated_daily_sale_deleted",
            )

        # Re-add inventory quantities only for backdated rows that actually allocated inventory.
        # Unapproved backdated rows may exist without inventory_batch_id; those must not create stock
        # on delete/deny by guessing a batch from batch_number or product.
        if sale.inventory_posted:
            for item in sale.items or []:
                product = item.product
                if not product or product.company_id != g.current_company.id:
                    continue
                try:
                    qty_base = int(item.quantity or 0)
                except (TypeError, ValueError):
                    qty_base = 0
                if qty_base <= 0:
                    continue
                if not item.inventory_batch_id:
                    continue
                batch = InventoryBatch.query.get(item.inventory_batch_id)
                if not batch or batch.company_id != g.current_company.id or batch.product_id != product.id:
                    continue
                batch.qty_base = int(batch.qty_base or 0) + qty_base
                _recompute_product_stock_from_batches(g.current_company.id, product)
                db.session.add(
                    InventoryLog(
                        company_id=g.current_company.id,
                        product=product,
                        change=qty_base,
                        reason="sale_deleted",
                        reference_type=_sale_inventory_reference_type(sale),
                        reference_id=int(sale.id),
                    )
                )

        # Reverse accounting impact if needed.
        try:
            total_amount = float(sale.total_amount or 0.0)
        except (TypeError, ValueError):
            total_amount = 0.0
        if total_amount > 0:
            already_reversed = (
                db.session.query(AccountEntry.id)
                .filter(
                    AccountEntry.company_id == g.current_company.id,
                    AccountEntry.reference_type == "sale_denied",
                    AccountEntry.reference_id == sale.id,
                )
                .first()
                is not None
            )
            had_sale_entries = _sale_accounting_has_ref(sale)
            if (not already_reversed) and had_sale_entries:
                if (sale.payment_status or "").strip().lower() == "due":
                    credit_name = _sale_receivable_account_name(g.current_company.id, sale.customer)
                    credit_type = "asset"
                else:
                    credit_name = _resolve_payment_account_name(
                        g.current_company.id, sale.payment_method, "Cash"
                    )
                    credit_type = "asset"
                    existing_acc = Account.query.filter(
                        Account.company_id == g.current_company.id,
                        func.lower(Account.name) == func.lower(credit_name),
                    ).first()
                    if existing_acc:
                        credit_type = existing_acc.type or credit_type
                sale_label = sale.sale_number or f"#{sale.id}"
                _post_double_entry(
                    g.current_company.id,
                    "Sales Revenue",
                    "income",
                    credit_name,
                    credit_type,
                    total_amount,
                    "sale_denied",
                    sale.id,
                    f"Deleted backdated sale reversal {sale_label}",
                )

        sale.approval_status = "denied"
        sale.inventory_posted = False
        db.session.commit()
        socketio.emit(
            "inventory:update",
            {
                "type": "backdated_daily_sale_deleted",
                "sale": _sale_payload_with_image_urls(sale),
                "company_id": g.current_company.id,
            },
        )
        log_action("backdated_daily_sale_deleted", {"sale_id": sale.id}, g.current_company.id)
        return jsonify({"status": "deleted", "id": sale.id})

    @app.route("/sales/backdated/<int:sale_id>/deny", methods=["POST"])
    @require_auth
    @company_required(["admin", "manager", "superuser", "superadmin"])
    def deny_backdated_daily_sale(sale_id: int):
        sale = Sale.query.get_or_404(sale_id)
        if sale.company_id != g.current_company.id or sale.source != "backdated_daily_sales":
            return jsonify({"error": "Not a backdated daily sale for this company"}), 403
        if (sale.approval_status or "").lower() == "approved":
            return jsonify({"error": "Approved backdated sales cannot be denied"}), 400
        if (sale.approval_status or "").lower() == "denied":
            return jsonify(_sale_payload_with_image_urls(sale))

        if sale.inventory_posted:
            for item in sale.items or []:
                product = item.product
                if not product or product.company_id != g.current_company.id:
                    continue
                try:
                    qty_base = int(item.quantity or 0)
                except (TypeError, ValueError):
                    qty_base = 0
                if qty_base <= 0:
                    continue
                if not item.inventory_batch_id:
                    continue
                batch = InventoryBatch.query.get(item.inventory_batch_id)
                if not batch or batch.company_id != g.current_company.id or batch.product_id != product.id:
                    continue
                batch.qty_base = int(batch.qty_base or 0) + qty_base
                _recompute_product_stock_from_batches(g.current_company.id, product)
                db.session.add(
                    InventoryLog(
                        company_id=g.current_company.id,
                        product=product,
                        change=qty_base,
                        reason="sale_denied",
                        reference_type=_sale_inventory_reference_type(sale),
                        reference_id=int(sale.id),
                    )
                )

        # Reverse the accounting impact for denied backdated sales.
        try:
            total_amount = float(sale.total_amount or 0.0)
        except (TypeError, ValueError):
            total_amount = 0.0
        if total_amount > 0:
            already_reversed = (
                db.session.query(AccountEntry.id)
                .filter(
                    AccountEntry.company_id == g.current_company.id,
                    AccountEntry.reference_type == "sale_denied",
                    AccountEntry.reference_id == sale.id,
                )
                .first()
                is not None
            )
            had_sale_entries = _sale_accounting_has_ref(sale)
            if (not already_reversed) and had_sale_entries:
                if (sale.payment_status or "").strip().lower() == "due":
                    credit_name = _sale_receivable_account_name(g.current_company.id, sale.customer)
                    credit_type = "asset"
                else:
                    credit_name = _resolve_payment_account_name(
                        g.current_company.id, sale.payment_method, "Cash"
                    )
                    credit_type = "asset"
                    existing_acc = Account.query.filter(
                        Account.company_id == g.current_company.id,
                        func.lower(Account.name) == func.lower(credit_name),
                    ).first()
                    if existing_acc:
                        credit_type = existing_acc.type or credit_type
                sale_label = sale.sale_number or f"#{sale.id}"
                _post_double_entry(
                    g.current_company.id,
                    "Sales Revenue",
                    "income",
                    credit_name,
                    credit_type,
                    total_amount,
                    "sale_denied",
                    sale.id,
                    f"Denied backdated sale reversal {sale_label}",
                )

        sale.approval_status = "denied"
        sale.inventory_posted = False
        db.session.commit()
        socketio.emit(
            "inventory:update",
            {
                "type": "backdated_daily_sale_denied",
                "sale": _sale_payload_with_image_urls(sale),
                "company_id": g.current_company.id,
            },
        )
        log_action("backdated_daily_sale_denied", {"sale_id": sale.id}, g.current_company.id)
        return jsonify(_sale_payload_with_image_urls(sale))

    @app.route("/sales/backdated", methods=["POST"])
    @require_auth
    @company_required(["admin", "manager", "salesman", "staff", "superuser", "superadmin"])
    def create_backdated_daily_sale():
        data = request.get_json() or {}
        extra_fields = {"approval_status": "pending"}
        return _create_sale_record(data, source="backdated_daily_sales", extra_fields=extra_fields)

    @app.route("/sales/<int:sale_id>/images", methods=["POST"])
    @require_auth
    @company_required(["admin", "manager", "salesman", "staff", "superuser", "superadmin"])
    def upload_sale_images(sale_id: int):
        sale = Sale.query.get_or_404(sale_id)
        if sale.company_id != g.current_company.id:
            return jsonify({"error": "Forbidden"}), 403
        if (sale.source or "").strip().lower() not in {"daily_sales", "backdated_daily_sales", "sales"}:
            return jsonify({"error": "Images are supported only for sales/daily sales"}), 400

        role = str(getattr(g, "company_role", None) or getattr(g.current_user, "role", "") or "").lower()
        if role in {"staff", "salesman"} and int(sale.created_by_user_id or 0) != int(g.current_user.id or 0):
            return jsonify({"error": "You can upload images only to your own sales"}), 403

        uploads = [f for f in (request.files.getlist("images") or []) if f and f.filename]
        single = request.files.get("image")
        if single and single.filename:
            uploads.append(single)
        if not uploads:
            return jsonify({"error": "At least one image is required"}), 400
        if len(uploads) > 10:
            return jsonify({"error": "Maximum 10 images can be uploaded at once"}), 400

        allowed_ext = {".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp"}
        max_size = 10 * 1024 * 1024
        company_id = g.current_company.id
        rel_dir = f"sales/company_{company_id}/sale_{sale.id}"
        abs_dir = os.path.join(app.static_folder, rel_dir)
        os.makedirs(abs_dir, exist_ok=True)

        created_rows = []
        saved_paths = []
        try:
            for uploaded in uploads:
                filename = str(uploaded.filename or "").strip()
                ext = os.path.splitext(filename)[1].lower()
                if ext not in allowed_ext:
                    raise ValueError(f"Unsupported image type: {filename}")
                mime_type = str(getattr(uploaded, "mimetype", "") or "").strip().lower()
                if mime_type and not mime_type.startswith("image/"):
                    raise ValueError(f"Only image files are allowed: {filename}")

                stored_name = f"{uuid.uuid4().hex}{ext}"
                abs_path = os.path.join(abs_dir, stored_name)
                uploaded.save(abs_path)
                saved_paths.append(abs_path)
                try:
                    size_bytes = int(os.path.getsize(abs_path) or 0)
                except Exception:
                    size_bytes = 0
                if size_bytes > max_size:
                    raise ValueError(f"Image is too large (max 10 MB): {filename}")

                row = SaleImage(
                    company_id=company_id,
                    sale_id=sale.id,
                    uploaded_by_user_id=g.current_user.id,
                    file_path=f"{rel_dir}/{stored_name}",
                    original_name=filename[:255],
                    mime_type=mime_type[:120] if mime_type else "",
                    size_bytes=size_bytes,
                )
                db.session.add(row)
                db.session.flush()
                payload = row.to_dict()
                payload["image_url"] = _static_file_url(row.file_path)
                created_rows.append(payload)
            db.session.commit()
        except ValueError as exc:
            db.session.rollback()
            for path in saved_paths:
                try:
                    if os.path.isfile(path):
                        os.remove(path)
                except Exception:
                    pass
            return jsonify({"error": str(exc)}), 400
        except Exception:
            db.session.rollback()
            for path in saved_paths:
                try:
                    if os.path.isfile(path):
                        os.remove(path)
                except Exception:
                    pass
            raise

        log_action("sale_images_uploaded", {"sale_id": sale.id, "count": len(created_rows)}, company_id)
        return jsonify({"items": created_rows}), 201

    @app.route("/sales/returns/eligible", methods=["GET"])
    @require_auth
    @company_required()
    def eligible_sales_returns():
        company = g.current_company
        value, unit = _sales_return_window(company)
        if value <= 0:
            return jsonify({"allowed": False, "window": {"value": value, "unit": unit}, "sales": []})
        today = _today_ad()
        cutoff = _sales_return_cutoff(company, today)

        # Optional UI filters (for large datasets)
        raw_days = request.args.get("days")
        if raw_days and unit == "days":
            try:
                override_days = int(raw_days)
            except (TypeError, ValueError):
                override_days = None
            if override_days is not None:
                # constrain to configured window
                override_days = max(0, min(int(override_days), int(value)))
                cutoff = today - timedelta(days=override_days)

        q = (request.args.get("q") or "").strip()
        if not cutoff:
            return jsonify({"allowed": False, "window": {"value": value, "unit": unit}, "sales": []})

        source = (request.args.get("source") or "").strip() or None
        query = Sale.query.filter(Sale.company_id == company.id)
        if source:
            query = query.filter(Sale.source == source)
        cutoff_dt = datetime.combine(cutoff, datetime.min.time())
        query = query.filter(
            (Sale.sale_date >= cutoff)
            | ((Sale.sale_date.is_(None)) & (Sale.created_at >= cutoff_dt))
        )

        # Search by bill # (sale id), customer, product (name/composition)
        if q:
            pattern = f"%{q}%"
            query = (
                query.outerjoin(Customer, Customer.id == Sale.customer_id)
                .outerjoin(SaleItem, SaleItem.sale_id == Sale.id)
                .outerjoin(Product, Product.id == SaleItem.product_id)
            )
            conds = [
                Customer.name.ilike(pattern),
                Product.name.ilike(pattern),
                Product.composition.ilike(pattern),
            ]
            # If numeric, treat as Sale ID exact match
            if q.isdigit():
                try:
                    conds.append(Sale.id == int(q))
                except Exception:
                    pass
            query = query.filter(or_(*conds)).distinct()
        role = str(getattr(g, "company_role", None) or getattr(g.current_user, "role", "") or "").lower()
        if role not in {"admin", "superuser", "superadmin"}:
            # allow records created by the user or without a creator (legacy POS)
            query = query.filter(
                (Sale.created_by_user_id == getattr(g.current_user, "id", None))
                | (Sale.created_by_user_id.is_(None))
            )
        sales = query.order_by(Sale.sale_date.desc().nullslast(), Sale.created_at.desc()).all()
        # Fallback: if filtered by source yields nothing, retry without source filter so user can still return.
        if not sales and source:
            query = Sale.query.filter(Sale.company_id == company.id)
            query = query.filter(
                (Sale.sale_date >= cutoff)
                | ((Sale.sale_date.is_(None)) & (Sale.created_at >= cutoff_dt))
            )
            if role not in {"admin", "superuser", "superadmin"}:
                query = query.filter(
                    (Sale.created_by_user_id == getattr(g.current_user, "id", None))
                    | (Sale.created_by_user_id.is_(None))
                )
            sales = query.order_by(Sale.sale_date.desc().nullslast(), Sale.created_at.desc()).all()
        if not sales:
            return jsonify({"allowed": True, "window": {"value": value, "unit": unit}, "sales": []})

        sale_ids = [s.id for s in sales]
        returned_rows = (
            db.session.query(SaleReturnItem.sale_item_id, db.func.coalesce(db.func.sum(SaleReturnItem.qty_base), 0))
            .filter(SaleReturnItem.sale_id.in_(sale_ids))
            .group_by(SaleReturnItem.sale_item_id)
            .all()
        )
        returned_map = {row[0]: int(row[1] or 0) for row in returned_rows}

        def _uom_options_for_product(product: Product) -> list[dict]:
            if not product or not product.uom_category:
                return []
            out: list[dict] = []
            seen: set[str] = set()
            cats = _get_unit_categories(company.id, product.uom_category)
            for cat in cats:
                for u in (cat.units or []):
                    if u.company_id != company.id or u.is_archived:
                        continue
                    key = (u.name or "").strip().lower()
                    if not key or key in seen:
                        continue
                    seen.add(key)
                    out.append(
                        {
                            "id": u.id,
                            "name": u.name,
                            "is_base": bool(u.is_base),
                            "conversion_to_base": float(_uom_factor_to_base(company.id, product, u.name) or 1.0),
                        }
                    )
            out.sort(key=lambda r: (not bool(r.get("is_base")), str(r.get("name") or "").lower()))
            return out

        payload = []
        for sale in sales:
            sale_items = []
            for item in sale.items or []:
                returned_qty = int(returned_map.get(item.id, 0))
                product = item.product
                base_qty = _sale_item_base_qty(company.id, item, product)
                remaining_base = max(base_qty - returned_qty, 0)
                if remaining_base <= 0:
                    continue
                try:
                    qty_uom_val = int(item.quantity_uom) if item.quantity_uom is not None else None
                except TypeError:
                    qty_uom_val = None

                sale_uom = item.uom or (_base_uom_name(product) if product else None) or (product.uom_category if product else None)
                factor = float(_uom_factor_to_base(company.id, product, sale_uom) if (product and sale_uom) else 1.0) or 1.0
                # Display sold qty in the sale's UoM when available; fallback to base.
                if qty_uom_val is not None and qty_uom_val > 0:
                    display_qty = qty_uom_val
                else:
                    display_qty = round(float(base_qty) / max(factor, 1e-9), 4) if factor else float(base_qty)
                returnable_qty = round(float(remaining_base) / max(factor, 1e-9), 4) if factor else float(remaining_base)

                uom_options = _uom_options_for_product(product) if product else []
                # Ensure the sale uom is present (legacy rows might have missing unit config)
                if sale_uom and not any(str(o.get("name", "")).lower() == sale_uom.lower() for o in uom_options):
                    uom_options.append(
                        {
                            "id": None,
                            "name": sale_uom,
                            "is_base": bool(sale_uom and product and sale_uom == (_base_uom_name(product) or "")),
                            "conversion_to_base": float(_uom_factor_to_base(company.id, product, sale_uom) or 1.0) if product else 1.0,
                        }
                    )
                    uom_options.sort(key=lambda r: (not bool(r.get("is_base")), str(r.get("name") or "").lower()))
                sale_items.append(
                    {
                        "id": item.id,
                        "product_id": item.product_id,
                        "product_name": product.name if product else None,
                        "uom": sale_uom,
                        "quantity": display_qty,
                        "quantity_base": int(base_qty),
                        "returnable_quantity": returnable_qty,
                        "returnable_quantity_base": int(remaining_base),
                        "inventory_batch_id": item.inventory_batch_id,
                        "batch_number": item.batch_number,
                        "unit_price": float(item.unit_price_uom or item.unit_price or 0.0),
                        "uom_options": uom_options,
                    }
                )
            if not sale_items:
                continue
            payload.append(
                {
                    "id": sale.id,
                    "sale_number": sale.sale_number,
                    "customer": sale.customer.to_dict() if sale.customer else None,
                    "sale_date": sale.sale_date.isoformat() if sale.sale_date else None,
                    "created_at": sale.created_at.isoformat() if sale.created_at else None,
                    "total_amount": float(sale.total_amount or 0.0),
                    "items": sale_items,
                }
            )
        return jsonify({"allowed": True, "window": {"value": value, "unit": unit}, "sales": payload})

    @app.route("/sales/returns", methods=["POST"])
    @require_auth
    @company_required(["admin", "manager", "salesman"])
    def create_sales_return():
        data = request.get_json() or {}
        sale_id = data.get("sale_id")
        items_payload = data.get("items") or []
        if not sale_id:
            return jsonify({"error": "sale_id is required"}), 400
        if not items_payload:
            return jsonify({"error": "Return items are required"}), 400

        sale = Sale.query.get_or_404(int(sale_id))
        if sale.company_id != g.current_company.id:
            return jsonify({"error": "Forbidden"}), 403

        today = _today_ad()
        cutoff = _sales_return_cutoff(g.current_company, today)
        if not cutoff:
            return jsonify({"error": "Sales returns are disabled"}), 400
        sale_effective_date = sale.sale_date or (sale.created_at.date() if sale.created_at else today)
        if sale_effective_date < cutoff:
            return jsonify({"error": "Sale is outside the allowed return window"}), 400

        returned_rows = (
            db.session.query(SaleReturnItem.sale_item_id, db.func.coalesce(db.func.sum(SaleReturnItem.qty_base), 0))
            .filter(SaleReturnItem.sale_id == sale.id)
            .group_by(SaleReturnItem.sale_item_id)
            .all()
        )
        returned_map = {row[0]: int(row[1] or 0) for row in returned_rows}

        sale_item_map = {item.id: item for item in (sale.items or [])}
        return_date_raw = data.get("return_date")
        if return_date_raw:
            try:
                return_date = datetime.fromisoformat(str(return_date_raw)).date()
            except ValueError:
                return jsonify({"error": "Invalid return_date (expected YYYY-MM-DD)"}), 400
        else:
            return_date = today

        sale_return = SaleReturn(
            company_id=g.current_company.id,
            return_number=_make_sale_return_number(g.current_company.id, return_date),
            sale_id=sale.id,
            customer_id=sale.customer_id,
            return_date=return_date,
            created_by_user_id=int(getattr(g.current_user, "id", 0) or 0) or None,
        )
        db.session.add(sale_return)

        total_amount = 0.0
        for idx, payload in enumerate(items_payload):
            sale_item_id = payload.get("sale_item_id")
            raw_uom = (payload.get("uom") or "").strip() or None
            try:
                quantity_input = float(payload.get("quantity") or 0)
            except (TypeError, ValueError):
                quantity_input = 0.0
            if abs(quantity_input - round(quantity_input)) > 1e-9:
                db.session.rollback()
                return jsonify({"error": f"Line {idx + 1}: Quantity must be a whole number"}), 400
            quantity_input = int(round(quantity_input))
            if not sale_item_id or quantity_input <= 0:
                db.session.rollback()
                return jsonify({"error": f"Line {idx + 1}: Invalid item or quantity"}), 400
            sale_item = sale_item_map.get(int(sale_item_id))
            if not sale_item:
                db.session.rollback()
                return jsonify({"error": f"Line {idx + 1}: Sale item not found"}), 404

            product = sale_item.product
            if not product or product.company_id != g.current_company.id:
                db.session.rollback()
                return jsonify({"error": f"Line {idx + 1}: Invalid product"}), 400

            # Allow returns in a different UoM than sold (e.g., sold in Dozen, return in Pcs).
            # Validate UoM belongs to the product category when configured.
            if raw_uom:
                validated = _validate_uom_for_product(product, raw_uom)
                if validated:
                    uom = validated
                else:
                    uom = raw_uom
            else:
                uom = sale_item.uom or (_base_uom_name(product) or (product.uom_category if product else None))
            qty_base = _qty_to_base(g.current_company.id, product, int(quantity_input), uom)
            if qty_base <= 0:
                db.session.rollback()
                return jsonify({"error": f"Line {idx + 1}: Invalid quantity"}), 400

            returned_qty = int(returned_map.get(sale_item.id, 0))
            remaining_base = _sale_item_base_qty(g.current_company.id, sale_item, product) - returned_qty
            if qty_base > remaining_base:
                db.session.rollback()
                return jsonify({"error": f"Line {idx + 1}: Quantity exceeds returnable amount"}), 400

            batch = None
            if product.lot_tracking:
                if sale_item.inventory_batch_id:
                    batch = InventoryBatch.query.get(sale_item.inventory_batch_id)
                    if not batch or batch.company_id != g.current_company.id:
                        batch = None
                if not batch and sale_item.batch_number:
                    batch = (
                        InventoryBatch.query.filter_by(
                            company_id=g.current_company.id,
                            product_id=product.id,
                            batch_number=sale_item.batch_number,
                        )
                        .order_by(InventoryBatch.created_at.desc())
                        .first()
                    )
                if not batch:
                    db.session.rollback()
                    return jsonify({"error": f"Line {idx + 1}: Batch/Lot is required for {product.name}"}), 400
                batch.qty_base = int(batch.qty_base or 0) + int(qty_base)
            else:
                if sale_item.inventory_batch_id:
                    batch = InventoryBatch.query.get(sale_item.inventory_batch_id)
                    if not batch or batch.company_id != g.current_company.id:
                        batch = None
                if not batch and sale_item.batch_number:
                    batch = (
                        InventoryBatch.query.filter_by(
                            company_id=g.current_company.id,
                            product_id=product.id,
                            batch_number=sale_item.batch_number,
                        )
                        .order_by(InventoryBatch.created_at.desc())
                        .first()
                    )
                if not batch:
                    batch = (
                        InventoryBatch.query.filter_by(company_id=g.current_company.id, product_id=product.id)
                        .order_by(InventoryBatch.expiry_date.asc().nullslast(), InventoryBatch.id.asc())
                        .first()
                    )
                if not batch:
                    # Do not create phantom batches for returns; inventory must originate from PB/PO/adjustment.
                    continue
                batch.qty_base = int(batch.qty_base or 0) + int(qty_base)
            _recompute_product_stock_from_batches(g.current_company.id, product)

            db.session.add(
                InventoryLog(company_id=g.current_company.id, product=product, change=qty_base, reason="sale_return")
            )

            # Always compute returns using base-unit price so it stays consistent across UoMs.
            unit_price = float(sale_item.unit_price or 0.0)
            line_total = float(qty_base) * unit_price
            total_amount += line_total

            return_item = SaleReturnItem(
                sale_return=sale_return,
                sale_id=sale.id,
                sale_item_id=sale_item.id,
                product_id=product.id,
                inventory_batch_id=sale_item.inventory_batch_id,
                batch_number=sale_item.batch_number,
                qty_base=int(qty_base),
                quantity_uom=int(quantity_input),
                uom=uom,
                unit_price=unit_price,
            )
            db.session.add(return_item)

        sale_return.total_amount = round(total_amount, 2)
        if float(total_amount or 0.0) > 0:
            if sale.payment_status == "paid":
                refund_method = (data.get("payment_method") or sale.payment_method or "Cash").strip()
                credit_name = _resolve_payment_account_name(g.current_company.id, refund_method, "Cash")
                description = f"Sales return for Sale #{sale.id} ({refund_method or 'Cash'})"
            else:
                credit_name = _sale_receivable_account_name(g.current_company.id, sale.customer)
                description = f"Sales return for Sale #{sale.id} (due)"
            _post_double_entry(
                g.current_company.id,
                "Sales Returns",
                "expense",
                credit_name,
                "asset",
                float(total_amount or 0.0),
                "sale_return",
                sale_return.id,
                description,
            )

        db.session.commit()
        log_action(
            "sale_return_created",
            {"sale_id": sale.id, "return_id": sale_return.id, "total": sale_return.total_amount},
            g.current_company.id,
        )
        return jsonify(sale_return.to_dict()), 201

    @app.route("/sales/returns/<int:return_id>/print", methods=["GET"])
    @require_auth
    @company_required()
    def print_sales_return(return_id: int):
        sale_return = SaleReturn.query.get_or_404(return_id)
        if sale_return.company_id != g.current_company.id:
            return jsonify({"error": "Forbidden"}), 403
        paper_size = (request.args.get("paper_size") or "").strip() or None
        try:
            pdf = _render_sale_return_pdf(g.current_company, sale_return, paper_size)
            return _build_pdf_response(pdf, f"sales-return-{sale_return.return_number or sale_return.id}.pdf")
        except RuntimeError as exc:
            return jsonify({"error": str(exc)}), 501

    @app.route("/dashboard/summary", methods=["GET"])
    @require_auth
    @company_required()
    def summary():
        try:
            _maybe_reconcile_inventory(g.current_company.id)
            product_count = Product.query.filter_by(company_id=g.current_company.id).count()
            customer_count = Customer.query.filter_by(company_id=g.current_company.id).count()
            sale_count = Sale.query.filter_by(company_id=g.current_company.id).count()
            revenue = (
                db.session.query(db.func.sum(Sale.total_amount))
                .filter(Sale.company_id == g.current_company.id)
                .scalar()
                or 0.0
            )
            stock_subq = (
                db.session.query(
                    InventoryBatch.product_id.label("product_id"),
                    func.coalesce(func.sum(InventoryBatch.qty_base), 0).label("batch_stock"),
                )
                .filter(InventoryBatch.company_id == g.current_company.id)
                .group_by(InventoryBatch.product_id)
                .subquery()
            )
            computed_stock = func.coalesce(stock_subq.c.batch_stock, Product.stock)
            low_stock = (
                db.session.query(func.count(Product.id))
                .outerjoin(stock_subq, stock_subq.c.product_id == Product.id)
                .filter(Product.company_id == g.current_company.id, computed_stock <= Product.reorder_level)
                .scalar()
                or 0
            )
            return jsonify(
                {
                    "products": product_count,
                    "customers": customer_count,
                    "sales": sale_count,
                    "revenue": round(revenue, 2),
                    "low_stock": low_stock,
                }
            )
        except Exception as e:
            print(f"Error in summary: {e}")
            return jsonify({"error": "Error getting summary"}), 500

    @app.route("/dashboard/overview", methods=["GET", "OPTIONS"])
    @require_auth
    @company_required()
    @require_permission(dashboard_read)
    def dashboard_overview():
        """
        Lightweight summary used by the React dashboard page.
        Matches the fields expected by frontend/src/pages/Dashboard.tsx.
        """
        company_id = g.current_company.id
        _maybe_reconcile_inventory(company_id)
        today = _today_ad()

        total_products = Product.query.filter_by(company_id=company_id).count()
        total_customers = Customer.query.filter_by(company_id=company_id).count()
        total_suppliers = Supplier.query.filter_by(company_id=company_id, is_archived=False).count()
        total_sales = (
            db.session.query(db.func.sum(Sale.total_amount))
            .filter(Sale.company_id == company_id)
            .scalar()
            or 0.0
        )
        stock_subq = (
            db.session.query(
                InventoryBatch.product_id.label("product_id"),
                func.coalesce(func.sum(InventoryBatch.qty_base), 0).label("batch_stock"),
            )
            .filter(InventoryBatch.company_id == company_id)
            .group_by(InventoryBatch.product_id)
            .subquery()
        )
        computed_stock = func.coalesce(stock_subq.c.batch_stock, Product.stock)
        low_stock_count = (
            db.session.query(func.count(Product.id))
            .outerjoin(stock_subq, stock_subq.c.product_id == Product.id)
            .filter(Product.company_id == company_id, computed_stock <= Product.reorder_level)
            .scalar()
            or 0
        )
        expiring_soon_count = 0
        expiry_batches = (
            db.session.query(InventoryBatch, Product)
            .join(Product, Product.id == InventoryBatch.product_id)
            .filter(
                InventoryBatch.company_id == company_id,
                InventoryBatch.qty_base > 0,
                InventoryBatch.expiry_date.isnot(None),
                Product.company_id == company_id,
            )
            .all()
        )
        batch_keys = {
            (int(b.product_id), (b.batch_number or "").strip() or None, b.expiry_date.isoformat() if b.expiry_date else None)
            for b, _p in expiry_batches
        }
        purchase_sources: dict[tuple[int, str | None, str | None], str | None] = {}
        if batch_keys:
            pb_rows = (
                db.session.query(PurchaseBillItem, PurchaseBill, Supplier)
                .join(PurchaseBill, PurchaseBillItem.purchase_bill_id == PurchaseBill.id)
                .join(Supplier, PurchaseBill.supplier_id == Supplier.id)
                .filter(
                    PurchaseBill.company_id == company_id,
                    PurchaseBill.posted.is_(True),
                    PurchaseBillItem.product_id.in_({key[0] for key in batch_keys}),
                )
                .order_by(PurchaseBill.posted_at.asc(), PurchaseBill.id.asc(), PurchaseBillItem.id.asc())
                .all()
            )
            for item, _bill, supplier in pb_rows:
                key = (
                    int(item.product_id),
                    (item.batch_number or "").strip() or None,
                    item.expiry_date.isoformat() if item.expiry_date else None,
                )
                purchase_sources.setdefault(key, supplier.name if supplier else None)
        expiring_product_ids: set[int] = set()
        for batch, product in expiry_batches:
            supplier_name = purchase_sources.get(
                (int(batch.product_id), (batch.batch_number or "").strip() or None, batch.expiry_date.isoformat() if batch.expiry_date else None)
            )
            policy = _effective_expiry_policy(
                g.current_company,
                supplier_name=supplier_name,
                manufacturer_name=getattr(product, "manufacturer", None),
                product=product,
            )
            alert_days = max(0, int(policy.get("alert_days") or 0))
            if batch.expiry_date and batch.expiry_date >= today and batch.expiry_date <= (today + timedelta(days=alert_days)):
                expiring_product_ids.add(int(product.id))
        expiring_soon_count = len(expiring_product_ids)
        currency = (
            Currency.query.filter_by(company_id=company_id)
            .order_by(
                Currency.is_archived.asc(),
                Currency.updated_at.desc().nullslast(),
                Currency.created_at.desc().nullslast(),
                Currency.id.desc(),
            )
            .first()
        )
        currency_code = currency.code if currency and getattr(currency, "code", None) else "USD"
        currency_symbol = None
        currency_position = "prefix"
        if currency:
            def _has_value(value) -> bool:
                return bool(str(value or "").strip())

            symbol_prefix = currency.symbol_prefix if _has_value(currency.symbol_prefix) else None
            symbol_suffix = currency.symbol_suffix if _has_value(currency.symbol_suffix) else None
            symbol = currency.symbol if _has_value(currency.symbol) else None
            # Prefer explicit prefix/suffix, then symbol, finally code
            currency_symbol = symbol_prefix or symbol_suffix or symbol or currency.code
            currency_position = "suffix" if _has_value(currency.symbol_suffix) else "prefix"

        backup_row = GoogleDriveBackupSetting.query.order_by(GoogleDriveBackupSetting.id.asc()).first()
        backup_done_at = _format_dt(backup_row.last_backup_at) if backup_row and backup_row.last_backup_at else None

        return jsonify(
            {
                "total_products": total_products,
                "total_sales": round(total_sales, 2),
                "total_customers": total_customers,
                "total_suppliers": total_suppliers,
                "low_stock_count": low_stock_count,
                "expiring_soon_count": expiring_soon_count,
                "currency_code": currency_code,
                "currency_symbol": currency_symbol,
                "currency_position": currency_position,
                "backup_done_at": backup_done_at,
            }
        )

    @app.route("/dashboard/alerts/low-stock", methods=["GET", "OPTIONS"])
    @require_auth
    @company_required()
    @require_permission(dashboard_read)
    def dashboard_alerts_low_stock():
        _maybe_reconcile_inventory(g.current_company.id)
        stock_subq = (
            db.session.query(
                InventoryBatch.product_id.label("product_id"),
                func.coalesce(func.sum(InventoryBatch.qty_base), 0).label("batch_stock"),
            )
            .filter(InventoryBatch.company_id == g.current_company.id)
            .group_by(InventoryBatch.product_id)
            .subquery()
        )
        computed_stock = func.coalesce(stock_subq.c.batch_stock, Product.stock).label("computed_stock")
        products = (
            db.session.query(Product, computed_stock)
            .outerjoin(stock_subq, stock_subq.c.product_id == Product.id)
            .filter(
                Product.company_id == g.current_company.id,
                computed_stock <= Product.reorder_level,
            )
            .order_by((computed_stock - Product.reorder_level).asc(), Product.name.asc(), Product.id.asc())
            .all()
        )
        return jsonify(
            {
                "items": [
                    {
                        "id": p.id,
                        "name": p.name,
                        "composition": p.composition,
                        "stock_base": int(stock_value or 0),
                        "reorder_level": int(p.reorder_level or 0),
                    }
                    for p, stock_value in products
                ]
            }
        )

    @app.route("/dashboard/alerts/expiry", methods=["GET", "OPTIONS"])
    @require_auth
    @company_required()
    @require_permission(dashboard_read)
    def dashboard_alerts_expiry():
        company_id = g.current_company.id
        today = _today_ad()
        rows = (
            db.session.query(InventoryBatch, Product)
            .join(Product, Product.id == InventoryBatch.product_id)
            .filter(
                InventoryBatch.company_id == company_id,
                InventoryBatch.qty_base > 0,
                InventoryBatch.expiry_date.isnot(None),
                Product.company_id == company_id,
            )
            .order_by(InventoryBatch.expiry_date.asc(), Product.name.asc(), InventoryBatch.id.asc())
            .all()
        )

        batch_keys = {
            (int(b.product_id), (b.batch_number or "").strip() or None, b.expiry_date.isoformat() if b.expiry_date else None)
            for b, _p in rows
        }
        purchase_sources: dict[tuple[int, str | None, str | None], str | None] = {}
        if batch_keys:
            pb_rows = (
                db.session.query(PurchaseBillItem, PurchaseBill, Supplier)
                .join(PurchaseBill, PurchaseBillItem.purchase_bill_id == PurchaseBill.id)
                .join(Supplier, PurchaseBill.supplier_id == Supplier.id)
                .filter(
                    PurchaseBill.company_id == company_id,
                    PurchaseBill.posted.is_(True),
                    PurchaseBillItem.product_id.in_({key[0] for key in batch_keys}),
                )
                .order_by(PurchaseBill.posted_at.asc(), PurchaseBill.id.asc(), PurchaseBillItem.id.asc())
                .all()
            )
            for item, _bill, supplier in pb_rows:
                key = (
                    int(item.product_id),
                    (item.batch_number or "").strip() or None,
                    item.expiry_date.isoformat() if item.expiry_date else None,
                )
                purchase_sources.setdefault(key, supplier.name if supplier else None)

        expired_rows = []
        expiring_alert_rows = []
        shelf_removal_rows = []
        for batch, product in rows:
            supplier_name = purchase_sources.get(
                (int(batch.product_id), (batch.batch_number or "").strip() or None, batch.expiry_date.isoformat() if batch.expiry_date else None)
            )
            policy = _effective_expiry_policy(
                g.current_company,
                supplier_name=supplier_name,
                manufacturer_name=getattr(product, "manufacturer", None),
                product=product,
            )
            alert_days = max(0, int(policy.get("alert_days") or 0))
            shelf_days = max(0, int(policy.get("shelf_removal_days") or 0))
            out = {
                "batch_id": batch.id,
                "product_id": product.id,
                "product_name": product.name,
                "composition": product.composition,
                "manufacturer": product.manufacturer,
                "supplier_name": supplier_name,
                "batch_number": batch.batch_number,
                "expiry_date": batch.expiry_date.isoformat() if batch.expiry_date else None,
                "qty_base": int(batch.qty_base or 0),
                "mrp": round(float(batch.mrp or 0.0), 4),
                "uom": batch.uom,
                "policy_source": policy.get("source"),
                "alert_days": alert_days,
                "shelf_removal_days": shelf_days,
            }
            if batch.expiry_date < today:
                expired_rows.append(out)
                continue
            if alert_days > 0 and batch.expiry_date <= (today + timedelta(days=alert_days)):
                expiring_alert_rows.append(out)
            if shelf_days > 0 and batch.expiry_date <= (today + timedelta(days=shelf_days)):
                shelf_removal_rows.append(out)

        return jsonify(
            {
                "expired": expired_rows,
                "expiring_this_month": expiring_alert_rows,
                "expiring_next_month": shelf_removal_rows,
            }
        )

    @app.route("/dashboard/sales", methods=["GET", "OPTIONS"])
    @require_auth
    @company_required()
    def dashboard_sales():
        try:
            days_raw = request.args.get("days", "7")
            try:
                days = max(1, min(int(days_raw), 365))
            except (TypeError, ValueError):
                days = 7

            start_date = _today_ad() - timedelta(days=days - 1)
            start_dt = datetime.combine(start_date, datetime.min.time())
            rows = (
                db.session.query(db.func.date(Sale.created_at), db.func.sum(Sale.total_amount))
                .filter(Sale.company_id == g.current_company.id, Sale.created_at >= start_dt)
                .group_by(db.func.date(Sale.created_at))
                .order_by(db.func.date(Sale.created_at))
                .all()
            )
            amounts_by_date = {str(row[0]): float(row[1] or 0) for row in rows}
            sales_data = []
            for offset in range(days):
                day = start_date + timedelta(days=offset)
                iso_day = day.isoformat()
                sales_data.append({"date": iso_day, "amount": round(amounts_by_date.get(iso_day, 0.0), 2)})
            return jsonify({"sales_data": sales_data})
        except Exception as exc:
            print(f"Error in dashboard sales stats: {exc}")
            return jsonify({"error": "Error getting sales stats"}), 500

    @app.route("/reports/sales", methods=["GET"])
    @require_auth
    @company_required(["admin", "manager", "superuser", "superadmin"])
    @require_permission(reports_read_perm)
    def reports_sales():
        company_id = g.current_company.id
        raw_from = (request.args.get("from") or "").strip()
        raw_to = (request.args.get("to") or "").strip()
        brand = (request.args.get("brand") or "").strip()
        brand_normalized = " ".join(brand.split()).lower() if brand else ""
        query_text = (request.args.get("q") or "").strip()

        date_from = None
        date_to = None
        if raw_from:
            try:
                date_from = datetime.fromisoformat(raw_from).date()
            except ValueError:
                return jsonify({"error": "Invalid from date (expected YYYY-MM-DD)"}), 400
        if raw_to:
            try:
                date_to = datetime.fromisoformat(raw_to).date()
            except ValueError:
                return jsonify({"error": "Invalid to date (expected YYYY-MM-DD)"}), 400

        qty_expr = db.func.coalesce(SaleItem.quantity_uom, SaleItem.quantity)
        price_expr = db.func.coalesce(SaleItem.unit_price_uom, SaleItem.unit_price)
        line_total_expr = qty_expr * price_expr

        report_query = (
            db.session.query(
                Product.id.label("product_id"),
                Product.name.label("product_name"),
                Product.manufacturer.label("manufacturer"),
                Product.uom_category.label("uom"),
                db.func.coalesce(db.func.sum(qty_expr), 0).label("qty"),
                db.func.coalesce(db.func.sum(line_total_expr), 0.0).label("total_amount"),
            )
            .join(Sale, SaleItem.sale_id == Sale.id)
            .join(Product, SaleItem.product_id == Product.id)
            .filter(
                Sale.company_id == company_id,
                Sale.source.in_(["sales", "daily_sales", "backdated_daily_sales", "sales_order_delivered"]),
                or_(Sale.approval_status.is_(None), Sale.approval_status != "denied"),
                or_(~Sale.source.in_(["daily_sales", "backdated_daily_sales"]), Sale.approval_status == "approved"),
            )
        )

        if date_from or date_to:
            start = date_from or date.min
            end = date_to or date.max
            start_dt = datetime.combine(start, datetime.min.time())
            end_dt = datetime.combine(end + timedelta(days=1), datetime.min.time())
            report_query = report_query.filter(
                or_(
                    and_(Sale.sale_date.isnot(None), Sale.sale_date >= start, Sale.sale_date <= end),
                    and_(Sale.sale_date.is_(None), Sale.created_at >= start_dt, Sale.created_at < end_dt),
                )
            )

        if brand_normalized:
            report_query = report_query.filter(
                db.func.lower(db.func.trim(db.func.coalesce(Product.manufacturer, ""))).like(f"%{brand_normalized}%")
            )

        if query_text:
            pattern = f"%{query_text}%"
            report_query = report_query.outerjoin(Customer, Customer.id == Sale.customer_id)
            conds = [
                Customer.name.ilike(pattern),
                Product.name.ilike(pattern),
                Sale.sale_number.ilike(pattern),
            ]
            if query_text.isdigit():
                try:
                    conds.append(Sale.id == int(query_text))
                except Exception:
                    pass
            report_query = report_query.filter(or_(*conds))

        rows = (
            report_query.group_by(Product.id, Product.name, Product.manufacturer)
            .order_by(db.func.sum(line_total_expr).desc())
            .all()
        )

        items = [
            {
                "product_id": int(r.product_id),
                "product_name": r.product_name,
                "brand": r.manufacturer or "-",
                "uom": r.uom or "-",
                "qty": float(r.qty or 0),
                "total_amount": round(float(r.total_amount or 0.0), 2),
            }
            for r in rows
        ]
        total_amount = round(sum(float(r.get("total_amount") or 0.0) for r in items), 2)
        return jsonify(
            {
                "brand": brand or None,
                "from": date_from.isoformat() if date_from else None,
                "to": date_to.isoformat() if date_to else None,
                "total_amount": total_amount,
                "items": items,
            }
        )

    @app.route("/reports/sales/product-trend", methods=["GET"])
    @require_auth
    @company_required(["admin", "manager", "superuser", "superadmin"])
    @require_permission(reports_read_perm)
    def reports_sales_product_trend():
        company_id = g.current_company.id
        product_id_raw = (request.args.get("product_id") or "").strip()
        try:
            product_id = int(product_id_raw)
        except (TypeError, ValueError):
            return jsonify({"error": "product_id is required"}), 400

        product = Product.query.filter(Product.company_id == company_id, Product.id == product_id).first()
        if not product:
            return jsonify({"error": "Product not found"}), 404

        qty_expr = db.func.coalesce(SaleItem.quantity_uom, SaleItem.quantity)
        price_expr = db.func.coalesce(SaleItem.unit_price_uom, SaleItem.unit_price)
        line_total_expr = qty_expr * price_expr

        rows = (
            db.session.query(
                Sale.sale_date.label("sale_date"),
                Sale.created_at.label("created_at"),
                qty_expr.label("qty"),
                line_total_expr.label("line_total"),
            )
            .join(Sale, SaleItem.sale_id == Sale.id)
            .filter(
                Sale.company_id == company_id,
                SaleItem.product_id == product_id,
                Sale.source.in_(["sales", "daily_sales", "backdated_daily_sales", "sales_order_delivered"]),
                or_(Sale.approval_status.is_(None), Sale.approval_status != "denied"),
                or_(~Sale.source.in_(["daily_sales", "backdated_daily_sales"]), Sale.approval_status == "approved"),
            )
            .all()
        )

        monthly_by_year: dict[int, dict[int, dict[str, float]]] = {}
        for row in rows:
            logical_date = row.sale_date or (row.created_at.date() if row.created_at else None)
            if not logical_date:
                continue
            year = int(logical_date.year)
            month = int(logical_date.month)
            if year not in monthly_by_year:
                monthly_by_year[year] = {m: {"qty": 0.0, "amount": 0.0} for m in range(1, 13)}
            monthly_by_year[year][month]["qty"] += float(row.qty or 0.0)
            monthly_by_year[year][month]["amount"] += float(row.line_total or 0.0)

        years = sorted(monthly_by_year.keys())
        series = []
        for year in years:
            months_qty = []
            months_amount = []
            year_total_qty = 0.0
            year_total_amount = 0.0
            for month in range(1, 13):
                qty_value = float(monthly_by_year[year][month]["qty"] or 0.0)
                amount_value = float(monthly_by_year[year][month]["amount"] or 0.0)
                months_qty.append(round(qty_value, 2))
                months_amount.append(round(amount_value, 2))
                year_total_qty += qty_value
                year_total_amount += amount_value
            series.append(
                {
                    "year": year,
                    "monthly_qty": months_qty,
                    "monthly_amount": months_amount,
                    "year_total_qty": round(year_total_qty, 2),
                    "year_total_amount": round(year_total_amount, 2),
                }
            )

        return jsonify(
            {
                "product_id": product.id,
                "product_name": product.name,
                "brand": product.manufacturer or "-",
                "uom": product.uom_category or "-",
                "months": [calendar.month_abbr[m] for m in range(1, 13)],
                "years": years,
                "series": series,
            }
        )

    @app.route("/reports/purchases", methods=["GET"])
    @require_auth
    @company_required(["admin", "manager", "superuser", "superadmin"])
    @require_permission(reports_read_perm)
    def reports_purchases():
        company_id = g.current_company.id
        raw_from = (request.args.get("from") or "").strip()
        raw_to = (request.args.get("to") or "").strip()
        query_text = (request.args.get("q") or "").strip()
        item_query = (request.args.get("item") or "").strip()
        brand = (request.args.get("brand") or "").strip()
        brand_normalized = " ".join(brand.split()).lower() if brand else ""

        date_from = None
        date_to = None
        if raw_from:
            try:
                date_from = datetime.fromisoformat(raw_from).date()
            except ValueError:
                return jsonify({"error": "Invalid from date (expected YYYY-MM-DD)"}), 400
        if raw_to:
            try:
                date_to = datetime.fromisoformat(raw_to).date()
            except ValueError:
                return jsonify({"error": "Invalid to date (expected YYYY-MM-DD)"}), 400

        qty_expr = db.func.coalesce(PurchaseBillItem.ordered_qty, 0) + db.func.coalesce(PurchaseBillItem.free_qty, 0)
        total_expr = db.func.coalesce(PurchaseBillItem.line_total, 0.0)

        report_query = (
            db.session.query(
                Product.id.label("product_id"),
                Product.name.label("product_name"),
                Product.manufacturer.label("manufacturer"),
                Supplier.id.label("supplier_id"),
                Supplier.name.label("supplier_name"),
                db.func.coalesce(db.func.sum(qty_expr), 0).label("qty"),
                db.func.coalesce(db.func.sum(total_expr), 0.0).label("total_amount"),
            )
            .join(PurchaseBill, PurchaseBillItem.purchase_bill_id == PurchaseBill.id)
            .join(Product, PurchaseBillItem.product_id == Product.id)
            .outerjoin(Supplier, PurchaseBill.supplier_id == Supplier.id)
            .filter(PurchaseBill.company_id == company_id)
        )

        if date_from or date_to:
            start = date_from or date.min
            end = date_to or date.max
            report_query = report_query.filter(PurchaseBill.purchase_date >= start, PurchaseBill.purchase_date <= end)

        if query_text:
            pattern = f"%{query_text}%"
            conds = [
                Supplier.name.ilike(pattern),
                PurchaseBill.bill_number.ilike(pattern),
            ]
            if query_text.isdigit():
                try:
                    conds.append(PurchaseBill.id == int(query_text))
                except Exception:
                    pass
            report_query = report_query.filter(or_(*conds))

        if item_query:
            item_pattern = f"%{item_query}%"
            report_query = report_query.filter(Product.name.ilike(item_pattern))

        if brand_normalized:
            report_query = report_query.filter(
                db.func.lower(db.func.trim(db.func.coalesce(Product.manufacturer, ""))).like(f"%{brand_normalized}%")
            )

        rows = (
            report_query.group_by(Product.id, Product.name, Product.manufacturer, Supplier.id, Supplier.name)
            .order_by(db.func.sum(total_expr).desc())
            .all()
        )

        items = [
            {
                "product_id": int(r.product_id),
                "product_name": r.product_name,
                "brand": r.manufacturer or "-",
                "supplier_id": r.supplier_id,
                "supplier_name": r.supplier_name,
                "qty": float(r.qty or 0),
                "total_amount": round(float(r.total_amount or 0.0), 2),
            }
            for r in rows
        ]
        total_amount = round(sum(float(r.get("total_amount") or 0.0) for r in items), 2)
        return jsonify(
            {
                "brand": brand or None,
                "from": date_from.isoformat() if date_from else None,
                "to": date_to.isoformat() if date_to else None,
                "total_amount": total_amount,
                "items": items,
            }
        )

    @app.route("/reports/purchases/product-details", methods=["GET"])
    @require_auth
    @company_required(["admin", "manager", "superuser", "superadmin"])
    @require_permission(reports_read_perm)
    def reports_purchase_product_details():
        company_id = g.current_company.id
        product_id_raw = (request.args.get("product_id") or "").strip()
        supplier_id_raw = (request.args.get("supplier_id") or "").strip()
        raw_from = (request.args.get("from") or "").strip()
        raw_to = (request.args.get("to") or "").strip()
        query_text = (request.args.get("q") or "").strip()
        item_query = (request.args.get("item") or "").strip()
        brand = (request.args.get("brand") or "").strip()
        brand_normalized = " ".join(brand.split()).lower() if brand else ""

        try:
            product_id = int(product_id_raw)
        except (TypeError, ValueError):
            return jsonify({"error": "product_id is required"}), 400

        supplier_id = None
        if supplier_id_raw:
            try:
                supplier_id = int(supplier_id_raw)
            except (TypeError, ValueError):
                return jsonify({"error": "Invalid supplier_id"}), 400

        date_from = None
        date_to = None
        if raw_from:
            try:
                date_from = datetime.fromisoformat(raw_from).date()
            except ValueError:
                return jsonify({"error": "Invalid from date (expected YYYY-MM-DD)"}), 400
        if raw_to:
            try:
                date_to = datetime.fromisoformat(raw_to).date()
            except ValueError:
                return jsonify({"error": "Invalid to date (expected YYYY-MM-DD)"}), 400

        detail_query = (
            db.session.query(PurchaseBillItem, PurchaseBill, Product, Supplier)
            .join(PurchaseBill, PurchaseBillItem.purchase_bill_id == PurchaseBill.id)
            .join(Product, PurchaseBillItem.product_id == Product.id)
            .outerjoin(Supplier, PurchaseBill.supplier_id == Supplier.id)
            .filter(
                PurchaseBill.company_id == company_id,
                PurchaseBillItem.product_id == product_id,
            )
        )

        if supplier_id is not None:
            detail_query = detail_query.filter(PurchaseBill.supplier_id == supplier_id)

        if date_from or date_to:
            start = date_from or date.min
            end = date_to or date.max
            detail_query = detail_query.filter(PurchaseBill.purchase_date >= start, PurchaseBill.purchase_date <= end)

        if query_text:
            pattern = f"%{query_text}%"
            detail_query = detail_query.filter(
                or_(
                    Supplier.name.ilike(pattern),
                    PurchaseBill.bill_number.ilike(pattern),
                )
            )

        if item_query:
            detail_query = detail_query.filter(Product.name.ilike(f"%{item_query}%"))

        if brand_normalized:
            detail_query = detail_query.filter(
                db.func.lower(db.func.trim(db.func.coalesce(Product.manufacturer, ""))).like(f"%{brand_normalized}%")
            )

        rows = (
            detail_query.order_by(PurchaseBill.purchase_date.desc(), PurchaseBill.id.desc(), PurchaseBillItem.id.desc()).all()
        )

        if not rows:
            product = (
                Product.query.filter(
                    Product.company_id == company_id,
                    Product.id == product_id,
                )
                .first()
            )
            return jsonify(
                {
                    "product_id": product_id,
                    "product_name": product.name if product else None,
                    "manufacturer": (product.manufacturer if product else None),
                    "supplier_id": supplier_id,
                    "supplier_name": None,
                    "base_uom": (_base_uom_name(product) or product.uom_category) if product else None,
                    "total_qty_base": 0.0,
                    "total_cost": 0.0,
                    "effective_cost_per_base_uom": 0.0,
                    "items": [],
                }
            )

        product = rows[0][2]
        supplier_names = sorted({(row[3].name or "").strip() for row in rows if row[3] and (row[3].name or "").strip()})
        if len(supplier_names) == 1:
            supplier_name = supplier_names[0]
        elif len(supplier_names) > 1:
            supplier_name = "Multiple"
        else:
            supplier_name = None
        base_uom = _base_uom_name(product) or product.uom_category or "-"
        detail_items = []
        total_qty_base = 0.0
        total_cost = 0.0

        for item, bill, prod, supplier in rows:
            ordered_qty = float(item.ordered_qty or 0.0)
            free_qty = float(item.free_qty or 0.0)
            qty_uom = max(0.0, ordered_qty + free_qty)
            qty_base = int(_qty_to_base(company_id, prod, qty_uom, item.uom) or 0)
            if qty_uom > 0 and qty_base <= 0:
                factor = float(_uom_factor_to_base(company_id, prod, item.uom) or 1.0)
                qty_base = max(int(round(qty_uom * factor)), 1)
            line_total = float(item.line_total or 0.0)
            effective_cost_per_base = (line_total / qty_base) if qty_base > 0 else 0.0

            total_qty_base += float(qty_base)
            total_cost += float(line_total)

            detail_items.append(
                {
                    "purchase_bill_id": bill.id,
                    "bill_number": bill.bill_number or str(bill.id),
                    "purchase_date": bill.purchase_date.isoformat() if bill.purchase_date else None,
                    "supplier_name": supplier.name if supplier else None,
                    "batch_lot_number": item.batch_number or "",
                    "uom": item.uom or "-",
                    "ordered_qty": ordered_qty,
                    "free_qty": free_qty,
                    "qty_uom": qty_uom,
                    "qty_base": float(qty_base),
                    "line_total": round(line_total, 2),
                    "discount": round(float(item.discount or 0.0), 2),
                    "tax_subtotal": round(float(item.tax_subtotal or 0.0), 2),
                    "effective_cost_per_base_uom": round(effective_cost_per_base, 6),
                }
            )

        weighted_effective = (total_cost / total_qty_base) if total_qty_base > 0 else 0.0
        return jsonify(
            {
                "product_id": product.id,
                "product_name": product.name,
                "manufacturer": product.manufacturer,
                "supplier_id": supplier_id,
                "supplier_name": supplier_name,
                "suppliers": supplier_names,
                "base_uom": base_uom,
                "total_qty_base": round(total_qty_base, 2),
                "total_cost": round(total_cost, 2),
                "effective_cost_per_base_uom": round(weighted_effective, 6),
                "items": detail_items,
            }
        )

    @app.route("/reports/product-activity", methods=["GET"])
    @require_auth
    @company_required(["admin", "manager", "superuser", "superadmin"])
    def reports_product_activity():
        company_id = g.current_company.id
        product_id_raw = (request.args.get("product_id") or "").strip()
        try:
            product_id = int(product_id_raw)
        except (TypeError, ValueError):
            return jsonify({"error": "product_id is required"}), 400

        product = Product.query.filter(Product.company_id == company_id, Product.id == product_id).first()
        if not product:
            return jsonify({"error": "Product not found"}), 404

        posted_expr = or_(PurchaseBill.posted.is_(True), PurchaseBill.posted_at.isnot(None))
        purchase_rows = (
            db.session.query(PurchaseBillItem, PurchaseBill)
            .join(PurchaseBill, PurchaseBillItem.purchase_bill_id == PurchaseBill.id)
            .filter(
                PurchaseBill.company_id == company_id,
                PurchaseBillItem.product_id == product_id,
                posted_expr,
            )
            .order_by(PurchaseBill.purchase_date.desc(), PurchaseBill.id.desc(), PurchaseBillItem.id.desc())
            .all()
        )

        purchases = []
        purchase_for_expected = []
        purchase_records = []
        for item, bill in purchase_rows:
            qty = float(item.ordered_qty or 0.0) + float(item.free_qty or 0.0)
            receipt_date_obj = bill.posted_at.date() if bill.posted_at else (bill.purchase_date if bill.purchase_date else None)
            purchase_date_obj = bill.purchase_date if bill.purchase_date else receipt_date_obj
            receipt_date = receipt_date_obj.isoformat() if receipt_date_obj else None
            mrp_val = round(float(item.mrp or 0.0), 2)
            purchases.append(
                {
                    "purchase_bill_id": bill.id,
                    "bill_number": bill.bill_number or str(bill.id),
                    "receipt_date": receipt_date,
                    "purchase_date": purchase_date_obj.isoformat() if purchase_date_obj else None,
                    "qty_purchased": round(qty, 2),
                    "mrp": mrp_val,
                    "cost_price": round(float(item.cost_price or 0.0), 2),
                    "batch_number": item.batch_number or "",
                    "expiry_date": item.expiry_date.isoformat() if item.expiry_date else None,
                }
            )
            if purchase_date_obj:
                purchase_for_expected.append((purchase_date_obj, item.batch_number or "", mrp_val))
            purchase_records.append(
                {
                    "date": purchase_date_obj,
                    "batch_number": item.batch_number or "",
                    "mrp": mrp_val,
                }
            )

        def _pick_purchase_mrp(
            sale_date_obj: date | None,
            batch_number: str | None,
        ) -> tuple[float, str]:
            candidates = [p for p in purchase_records if float(p.get("mrp") or 0.0) > 0.0]
            if batch_number:
                candidates = [p for p in candidates if p.get("batch_number") == batch_number]
            if sale_date_obj:
                candidates = [
                    p for p in candidates if p.get("date") and p.get("date") <= sale_date_obj
                ]
            if not candidates:
                return 0.0, "awaited"
            candidates.sort(key=lambda p: p.get("date") or date.min)
            return float(candidates[-1].get("mrp") or 0.0), "purchase_bill"

        qty_expr = db.func.coalesce(SaleItem.quantity_uom, SaleItem.quantity)
        price_expr = db.func.coalesce(SaleItem.unit_price_uom, SaleItem.unit_price)
        line_total_expr = qty_expr * price_expr

        sale_rows = (
            db.session.query(SaleItem, Sale, qty_expr.label("qty_uom"), price_expr.label("unit_price"), line_total_expr.label("line_total"))
            .join(Sale, SaleItem.sale_id == Sale.id)
            .filter(
                Sale.company_id == company_id,
                SaleItem.product_id == product_id,
                Sale.source.in_(["sales", "daily_sales", "backdated_daily_sales", "sales_order_delivered"]),
                or_(Sale.approval_status.is_(None), Sale.approval_status != "denied"),
            )
            .order_by(Sale.created_at.desc(), Sale.id.desc(), SaleItem.id.desc())
            .all()
        )

        purchase_for_expected.sort(key=lambda row: row[0])
        sales_with_dates = []
        for item, sale, qty_uom, unit_price, line_total in sale_rows:
            sale_date_obj = sale.sale_date or (sale.created_at.date() if sale.created_at else None)
            sale_date = sale_date_obj.isoformat() if sale_date_obj else None
            mrp_from_purchase, mrp_source = _pick_purchase_mrp(sale_date_obj, item.batch_number or None)
            origin_source = _sale_origin_source(sale)
            is_backdated_origin = origin_source == "backdated_daily_sales"
            sales_with_dates.append(
                {
                    "sale_id": sale.id,
                    "sale_number": sale.sale_number,
                    "sale_date": sale_date,
                    "sale_date_obj": sale_date_obj,
                    "created_at": _format_dt(sale.created_at),
                    "source": sale.source,
                    "origin_source": origin_source,
                    "is_backdated_origin": is_backdated_origin,
                    "approval_status": (sale.approval_status or "approved").lower(),
                    "qty": float(qty_uom or 0.0),
                    "uom": item.uom or "",
                    "unit_price": round(float(unit_price or 0.0), 2),
                    "mrp": round(float(mrp_from_purchase or 0.0), 2),
                    "mrp_source": mrp_source,
                    "line_total": round(float(line_total or 0.0), 2),
                    "batch_number": item.batch_number or "",
                }
            )

        sales_with_dates.sort(key=lambda row: row.get("sale_date_obj") or date.max)
        last_batch = ""
        p_idx = 0
        for row in sales_with_dates:
            sale_date_obj = row.get("sale_date_obj")
            if sale_date_obj:
                while p_idx < len(purchase_for_expected) and purchase_for_expected[p_idx][0] <= sale_date_obj:
                    if purchase_for_expected[p_idx][1]:
                        last_batch = purchase_for_expected[p_idx][1]
                    p_idx += 1
            source = str(row.get("source") or "").lower()
            is_backdated_origin = bool(row.get("is_backdated_origin"))
            if is_backdated_origin or source == "backdated_daily_sales":
                if not row.get("batch_number") and last_batch:
                    row["batch_number"] = last_batch
                    row["batch_source"] = "expected"
                else:
                    row["batch_source"] = "expected" if row.get("batch_number") else "missing"
                if not row.get("mrp") and last_batch:
                    mrp_from_purchase, mrp_source = _pick_purchase_mrp(sale_date_obj, last_batch)
                    if mrp_from_purchase:
                        row["mrp"] = round(float(mrp_from_purchase), 2)
                        row["mrp_source"] = mrp_source
            else:
                row["batch_source"] = "confirmed" if row.get("batch_number") else "missing"
            row.pop("sale_date_obj", None)

        sales = sales_with_dates

        timeline = []
        for p in purchases:
            date_obj = None
            if p.get("receipt_date"):
                try:
                    date_obj = datetime.fromisoformat(str(p.get("receipt_date"))).date()
                except Exception:
                    date_obj = None
            timeline.append(
                {
                    "type": "purchase",
                    "date": p.get("purchase_date") or p.get("receipt_date"),
                    "purchase_date": p.get("purchase_date"),
                    "receipt_date": p.get("receipt_date"),
                    "qty": p.get("qty_purchased"),
                    "mrp": p.get("mrp"),
                    "cost_price": p.get("cost_price"),
                    "batch_number": p.get("batch_number"),
                    "bill_number": p.get("bill_number"),
                    "_date": date_obj,
                }
            )
        for s in sales:
            date_obj = None
            if s.get("sale_date"):
                try:
                    date_obj = datetime.fromisoformat(str(s.get("sale_date"))).date()
                except Exception:
                    date_obj = None
            timeline.append(
                {
                    "type": "sale",
                    "date": s.get("sale_date"),
                    "qty": s.get("qty"),
                    "mrp": s.get("mrp"),
                    "line_total": s.get("line_total"),
                    "batch_number": s.get("batch_number"),
                    "batch_source": s.get("batch_source"),
                    "source": s.get("source"),
                    "origin_source": s.get("origin_source"),
                    "is_backdated_origin": s.get("is_backdated_origin"),
                    "_date": date_obj,
                }
            )

        timeline.sort(key=lambda row: ((row.get("_date") or date.max), 0 if row.get("type") == "purchase" else 1))
        for row in timeline:
            row.pop("_date", None)

        return jsonify(
            {
                "product_id": product.id,
                "product_name": product.name,
                "manufacturer": product.manufacturer,
                "purchases": purchases,
                "sales": sales,
                "timeline": timeline,
            }
        )

    @app.route("/dashboard/inventory", methods=["GET", "OPTIONS"])
    @require_auth
    @company_required()
    def dashboard_inventory():
        try:
            out_of_stock = (
                Product.query.filter(Product.company_id == g.current_company.id, Product.stock <= 0).count()
            )
            low_stock = (
                Product.query.filter(
                    Product.company_id == g.current_company.id,
                    Product.stock > 0,
                    Product.stock <= Product.reorder_level,
                ).count()
            )
            healthy = (
                Product.query.filter(
                    Product.company_id == g.current_company.id,
                    Product.stock > Product.reorder_level,
                ).count()
            )
            inventory_data = [
                {"category": "In Stock", "count": healthy},
                {"category": "Low Stock", "count": low_stock},
                {"category": "Out of Stock", "count": out_of_stock},
            ]
            return jsonify({"inventory_data": inventory_data})
        except Exception as exc:
            print(f"Error in inventory stats: {exc}")
            return jsonify({"error": "Error getting inventory stats"}), 500

    @app.route("/dashboard/top-products", methods=["GET", "OPTIONS"])
    @require_auth
    @company_required()
    def dashboard_top_products():
        try:
            try:
                limit = max(1, min(int(request.args.get("limit", 5) or 5), 20))
            except (TypeError, ValueError):
                limit = 5

            rows = (
                db.session.query(Product.name, db.func.sum(SaleItem.quantity * SaleItem.unit_price).label("sales"))
                .join(SaleItem, SaleItem.product_id == Product.id)
                .join(Sale, SaleItem.sale_id == Sale.id)
                .filter(Sale.company_id == g.current_company.id, Product.company_id == g.current_company.id)
                .group_by(Product.id, Product.name)
                .order_by(db.func.sum(SaleItem.quantity * SaleItem.unit_price).desc())
                .limit(limit)
                .all()
            )
            top_products = [{"name": name, "sales": round(sales or 0, 2)} for name, sales in rows]
            return jsonify({"top_products": top_products})
        except Exception as exc:
            print(f"Error in top products stats: {exc}")
            return jsonify({"error": "Error getting top products"}), 500

    # Accounts (Accounting)
    def _parse_date_arg(value: str | None) -> date | None:
        if not value:
            return None
        try:
            return datetime.fromisoformat(value).date()
        except ValueError:
            return None

    def _entry_date_bounds(from_date: date | None, to_date: date | None) -> tuple[datetime | None, datetime | None]:
        from_dt = datetime(from_date.year, from_date.month, from_date.day) if from_date else None
        to_dt = None
        if to_date:
            to_dt = datetime(to_date.year, to_date.month, to_date.day, 23, 59, 59, 999999)
        return from_dt, to_dt

    def _account_entry_sums(
        company_id: int, from_date: date | None = None, to_date: date | None = None
    ) -> dict[int, dict[str, float]]:
        from_dt, to_dt = _entry_date_bounds(from_date, to_date)
        debit_case = db.case((AccountEntry.entry_type == "debit", AccountEntry.amount), else_=0.0)
        credit_case = db.case((AccountEntry.entry_type == "credit", AccountEntry.amount), else_=0.0)
        query = (
            db.session.query(
                AccountEntry.account_id,
                db.func.sum(debit_case).label("debit"),
                db.func.sum(credit_case).label("credit"),
            )
            .filter(AccountEntry.company_id == company_id)
        )
        if from_dt:
            query = query.filter(AccountEntry.created_at >= from_dt)
        if to_dt:
            query = query.filter(AccountEntry.created_at <= to_dt)
        rows = query.group_by(AccountEntry.account_id).all()
        out: dict[int, dict[str, float]] = {}
        for account_id, debit, credit in rows:
            out[int(account_id)] = {"debit": float(debit or 0.0), "credit": float(credit or 0.0)}
        return out

    def _normalize_balance(account_type: str, debit: float, credit: float) -> float:
        natural_credit = account_type in {"liability", "equity", "income"}
        return (credit - debit) if natural_credit else (debit - credit)

    def _trial_balance_entries(
        company_id: int, from_date: date | None = None, to_date: date | None = None
    ) -> list[dict[str, object]]:
        sums = _account_entry_sums(company_id, from_date, to_date)
        accounts = (
            Account.query.filter_by(company_id=company_id)
            .order_by(db.func.coalesce(Account.code, ""), Account.name.asc())
            .all()
        )
        entries: list[dict[str, object]] = []
        for account in accounts:
            totals = sums.get(account.id, {"debit": 0.0, "credit": 0.0})
            debit = float(totals.get("debit") or 0.0)
            credit = float(totals.get("credit") or 0.0)
            net = _normalize_balance(account.type, debit, credit)
            debit_balance = 0.0
            credit_balance = 0.0
            if account.type in {"liability", "equity", "income"}:
                if net >= 0:
                    credit_balance = net
                else:
                    debit_balance = abs(net)
            else:
                if net >= 0:
                    debit_balance = net
                else:
                    credit_balance = abs(net)
            entries.append(
                {
                    "account_id": account.id,
                    "account_code": account.code or "",
                    "account_name": account.name,
                    "account_type": account.type,
                    "debit": round(debit_balance, 2),
                    "credit": round(credit_balance, 2),
                    "net": round(net, 2),
                    "raw_debit": round(debit, 2),
                    "raw_credit": round(credit, 2),
                }
            )
        return entries

    @app.route("/accounts", methods=["GET"])
    @require_auth
    @company_required()
    def list_accounts():
        accounts = (
            Account.query.filter_by(company_id=g.current_company.id)
            .order_by(db.func.coalesce(Account.code, ""), Account.name.asc())
            .all()
        )
        return jsonify([a.to_dict() for a in accounts])

    @app.route("/accounts/journal", methods=["GET"])
    @require_auth
    @company_required()
    def journal_entries():
        try:
            try:
                limit = max(1, min(int(request.args.get("limit", 200) or 200), 500))
            except (TypeError, ValueError):
                limit = 200
            bill_number_cache: dict[int, str] = {}

            def resolve_ref_display(ref_type: str | None, ref_id: int | None) -> str | None:
                if (ref_type or "").lower() == "purchase_bill" and ref_id:
                    if ref_id in bill_number_cache:
                        return bill_number_cache[ref_id]
                    bill = PurchaseBill.query.get(ref_id)
                    if bill and bill.company_id == g.current_company.id:
                        label = bill.bill_number or f"#{bill.id}"
                        bill_number_cache[ref_id] = label
                        return label
                if (ref_type or "").lower() == "purchase_bill_payment" and ref_id:
                    payment = PurchaseBillPayment.query.get(ref_id)
                    if payment and payment.company_id == g.current_company.id:
                        bill = PurchaseBill.query.get(payment.purchase_bill_id)
                        if bill and bill.company_id == g.current_company.id:
                            return bill.bill_number or f"#{bill.id}"
                    # legacy: reference_id stored as purchase_bill_id
                    bill = PurchaseBill.query.get(ref_id)
                    if bill and bill.company_id == g.current_company.id:
                        return bill.bill_number or f"#{bill.id}"
                if (ref_type or "").lower() in {"sale", "backdated_sale"} and ref_id:
                    sale = Sale.query.get(ref_id)
                    if sale and sale.company_id == g.current_company.id:
                        return sale.sale_number or f"#{sale.id}"
                if (ref_type or "").lower() == "sale_payment" and ref_id:
                    sp = SalePayment.query.get(ref_id)
                    if sp and sp.company_id == g.current_company.id:
                        sale = Sale.query.get(sp.sale_id)
                        if sale and sale.company_id == g.current_company.id:
                            return sale.sale_number or f"#{sale.id}"
                return None

            entries = (
                db.session.query(AccountEntry, Account)
                .join(Account, Account.id == AccountEntry.account_id)
                .filter(AccountEntry.company_id == g.current_company.id, Account.company_id == g.current_company.id)
                .order_by(AccountEntry.created_at.desc())
                .limit(limit)
                .all()
            )
            payload = []
            for entry, account in entries:
                data = entry.to_dict()
                data["account_name"] = account.name
                data["account_code"] = account.code
                data["account_type"] = account.type
                data["reference_display"] = resolve_ref_display(entry.reference_type, entry.reference_id)
                payload.append(data)
            return jsonify(payload)
        except Exception as exc:
            print(f"Error in journal_entries: {exc}")
            return jsonify({"error": "Failed to load journal entries"}), 500

    @app.route("/accounts/<int:account_id>/ledger", methods=["GET"])
    @require_auth
    @company_required()
    def account_ledger(account_id: int):
        account = Account.query.get_or_404(account_id)
        if account.company_id != g.current_company.id:
            return jsonify({"error": "Forbidden"}), 403

        bill_number_cache: dict[int, str] = {}

        def resolve_ref_display(ref_type: str | None, ref_id: int | None) -> str | None:
            if (ref_type or "").lower() == "purchase_bill" and ref_id:
                if ref_id in bill_number_cache:
                    return bill_number_cache[ref_id]
                bill = PurchaseBill.query.get(ref_id)
                if bill and bill.company_id == g.current_company.id:
                    label = bill.bill_number or f"#{bill.id}"
                    bill_number_cache[ref_id] = label
                    return label
            if (ref_type or "").lower() in {"sale", "backdated_sale"} and ref_id:
                sale = Sale.query.get(ref_id)
                if sale and sale.company_id == g.current_company.id:
                    return sale.sale_number or f"#{sale.id}"
            return None

        entries = (
            AccountEntry.query.filter_by(company_id=g.current_company.id, account_id=account.id)
            .order_by(AccountEntry.created_at.desc())
            .all()
        )
        total_debit = sum(float(e.amount or 0.0) for e in entries if (e.entry_type or "").lower() == "debit")
        total_credit = sum(float(e.amount or 0.0) for e in entries if (e.entry_type or "").lower() == "credit")
        return jsonify(
            {
                "account": account.to_dict(),
                "totals": {"debit": round(total_debit, 2), "credit": round(total_credit, 2)},
                "entries": [
                    dict(e.to_dict(), reference_display=resolve_ref_display(e.reference_type, e.reference_id))
                    for e in entries
                ],
            }
        )

    @app.route("/accounts/trial-balance", methods=["GET"])
    @require_auth
    @company_required()
    def trial_balance():
        from_date = _parse_date_arg(request.args.get("from_date"))
        to_date = _parse_date_arg(request.args.get("to_date"))
        entries = _trial_balance_entries(g.current_company.id, from_date, to_date)
        total_debit = sum(float(e.get("debit") or 0.0) for e in entries)
        total_credit = sum(float(e.get("credit") or 0.0) for e in entries)
        return jsonify(
            {
                "from_date": from_date.isoformat() if from_date else None,
                "to_date": to_date.isoformat() if to_date else None,
                "entries": entries,
                "totals": {"debit": round(total_debit, 2), "credit": round(total_credit, 2)},
            }
        )

    @app.route("/accounts/profit-loss", methods=["GET"])
    @require_auth
    @company_required()
    def profit_loss_statement():
        from_date = _parse_date_arg(request.args.get("from_date"))
        to_date = _parse_date_arg(request.args.get("to_date"))
        sums = _account_entry_sums(g.current_company.id, from_date, to_date)
        accounts = (
            Account.query.filter_by(company_id=g.current_company.id)
            .order_by(db.func.coalesce(Account.code, ""), Account.name.asc())
            .all()
        )
        income: list[dict[str, object]] = []
        expenses: list[dict[str, object]] = []
        for account in accounts:
            if account.type not in {"income", "expense"}:
                continue
            totals = sums.get(account.id, {"debit": 0.0, "credit": 0.0})
            debit = float(totals.get("debit") or 0.0)
            credit = float(totals.get("credit") or 0.0)
            balance = _normalize_balance(account.type, debit, credit)
            payload = {
                "account_id": account.id,
                "account_code": account.code or "",
                "account_name": account.name,
                "account_type": account.type,
                "balance": round(balance, 2),
            }
            if account.type == "income":
                income.append(payload)
            else:
                expenses.append(payload)
        total_income = sum(float(i.get("balance") or 0.0) for i in income)
        total_expense = sum(float(e.get("balance") or 0.0) for e in expenses)
        net_profit = total_income - total_expense
        return jsonify(
            {
                "from_date": from_date.isoformat() if from_date else None,
                "to_date": to_date.isoformat() if to_date else None,
                "income": income,
                "expenses": expenses,
                "totals": {
                    "income": round(total_income, 2),
                    "expenses": round(total_expense, 2),
                    "net_profit": round(net_profit, 2),
                },
            }
        )

    @app.route("/accounts/balance-sheet", methods=["GET"])
    @require_auth
    @company_required()
    def balance_sheet():
        from_date = _parse_date_arg(request.args.get("from_date"))
        to_date = _parse_date_arg(request.args.get("to_date"))
        sums = _account_entry_sums(g.current_company.id, from_date, to_date)
        accounts = (
            Account.query.filter_by(company_id=g.current_company.id)
            .order_by(db.func.coalesce(Account.code, ""), Account.name.asc())
            .all()
        )

        assets: list[dict[str, object]] = []
        liabilities: list[dict[str, object]] = []
        equity: list[dict[str, object]] = []

        for account in accounts:
            if account.type not in {"asset", "liability", "equity"}:
                continue
            totals = sums.get(account.id, {"debit": 0.0, "credit": 0.0})
            debit = float(totals.get("debit") or 0.0)
            credit = float(totals.get("credit") or 0.0)
            balance = _normalize_balance(account.type, debit, credit)
            payload = {
                "account_id": account.id,
                "account_code": account.code or "",
                "account_name": account.name,
                "account_type": account.type,
                "balance": round(balance, 2),
            }
            if account.type == "asset":
                assets.append(payload)
            elif account.type == "liability":
                liabilities.append(payload)
            else:
                equity.append(payload)

        # Combine Cash/Cash Pay into a single Cash line in assets.
        cash_bucket = None
        merged_assets: list[dict[str, object]] = []
        for item in assets:
            name = str(item.get("account_name") or "").strip()
            name_lc = name.lower()
            if name_lc in {"cash", "cash pay"}:
                if not cash_bucket:
                    cash_bucket = dict(item)
                    cash_bucket["account_name"] = "Cash"
                    cash_bucket["balance"] = float(cash_bucket.get("balance") or 0.0)
                else:
                    cash_bucket["balance"] = float(cash_bucket.get("balance") or 0.0) + float(item.get("balance") or 0.0)
            else:
                merged_assets.append(item)
        if cash_bucket:
            cash_bucket["balance"] = round(float(cash_bucket.get("balance") or 0.0), 2)
            merged_assets.insert(0, cash_bucket)
        assets = merged_assets

        # If Inventory balance is missing/zero, derive from on-hand stock using cost-per-base.
        inventory_total = 0.0
        batches = (
            InventoryBatch.query.filter(
                InventoryBatch.company_id == g.current_company.id,
                InventoryBatch.qty_base > 0,
            )
            .order_by(InventoryBatch.product_id.asc(), InventoryBatch.id.asc())
            .all()
        )
        for b in batches:
            product = b.product
            if not product:
                continue
            cost_per_base = _cost_per_base_for_batch(g.current_company.id, product, b)
            if cost_per_base > 0:
                inventory_total += float(b.qty_base or 0) * float(cost_per_base)
        inventory_total = round(inventory_total, 2)
        if inventory_total > 0:
            inventory_idx = None
            for idx, item in enumerate(assets):
                if str(item.get("account_name") or "").strip().lower() == "inventory":
                    inventory_idx = idx
                    break
            if inventory_idx is not None:
                if float(assets[inventory_idx].get("balance") or 0.0) <= 0.0:
                    assets[inventory_idx]["balance"] = inventory_total
            else:
                assets.append(
                    {
                        "account_id": None,
                        "account_code": "",
                        "account_name": "Inventory",
                        "account_type": "asset",
                        "balance": inventory_total,
                    }
                )

        total_assets = sum(float(i.get("balance") or 0.0) for i in assets)
        total_liabilities = sum(float(i.get("balance") or 0.0) for i in liabilities)
        total_equity = sum(float(i.get("balance") or 0.0) for i in equity)

        income_total = 0.0
        expense_total = 0.0
        for account in accounts:
            if account.type not in {"income", "expense"}:
                continue
            totals = sums.get(account.id, {"debit": 0.0, "credit": 0.0})
            debit = float(totals.get("debit") or 0.0)
            credit = float(totals.get("credit") or 0.0)
            balance = _normalize_balance(account.type, debit, credit)
            if account.type == "income":
                income_total += balance
            else:
                expense_total += balance
        net_profit = income_total - expense_total

        return jsonify(
            {
                "from_date": from_date.isoformat() if from_date else None,
                "to_date": to_date.isoformat() if to_date else None,
                "assets": assets,
                "liabilities": liabilities,
                "equity": equity,
                "totals": {
                    "assets": round(total_assets, 2),
                    "liabilities": round(total_liabilities, 2),
                    "equity": round(total_equity, 2),
                    "net_profit": round(net_profit, 2),
                    "liabilities_equity": round(total_liabilities + total_equity + net_profit, 2),
                },
            }
        )

    @app.route("/payments/recent", methods=["GET"])
    @require_auth
    @company_required()
    def recent_payments():
        try:
            try:
                limit = max(1, min(int(request.args.get("limit", 100) or 100), 300))
            except (TypeError, ValueError):
                limit = 100
            purchase_payments = (
                PurchaseBillPayment.query.join(PurchaseBill, PurchaseBillPayment.purchase_bill_id == PurchaseBill.id)
                .filter(PurchaseBill.company_id == g.current_company.id)
                .order_by(PurchaseBillPayment.created_at.desc())
                .limit(limit)
                .all()
            )
            sale_payments = (
                SalePayment.query.join(Sale, SalePayment.sale_id == Sale.id)
                .filter(Sale.company_id == g.current_company.id)
                .order_by(SalePayment.created_at.desc())
                .limit(limit)
                .all()
            )
            rows = []
            for p in purchase_payments:
                row = p.to_dict()
                row["source"] = "purchase_bill"
                rows.append(row)
            for p in sale_payments:
                row = p.to_dict()
                row["source"] = "sale_order_due"
                rows.append(row)
            rows.sort(key=lambda r: r.get("created_at") or "", reverse=True)
            return jsonify(rows[:limit])
        except Exception as exc:
            print(f"Error loading payments: {exc}")
            return jsonify({"error": "Failed to load payments"}), 500

    @app.route("/accounting/summary", methods=["GET"])
    @require_auth
    @company_required()
    def accounting_summary():
        company_id = g.current_company.id
        accounts = Account.query.filter_by(company_id=company_id).all()
        by_name = {a.name: a for a in accounts}
        children_by_parent: dict[int, list[Account]] = {}
        for account in accounts:
            if account.parent_id:
                children_by_parent.setdefault(int(account.parent_id), []).append(account)

        def balance(name: str) -> float:
            acc = by_name.get(name)
            if not acc:
                return 0.0
            total = float(acc.balance or 0.0)
            for child in children_by_parent.get(int(acc.id), []):
                total += float(child.balance or 0.0)
            return total

        due_total = (
            db.session.query(db.func.coalesce(db.func.sum(Sale.due_amount), 0.0))
            .filter(Sale.company_id == company_id)
            .scalar()
            or 0.0
        )
        due_count = (
            Sale.query.filter(Sale.company_id == company_id, Sale.payment_status == "due").count()
        )
        posted_purchases = (
            db.session.query(db.func.coalesce(db.func.sum(PurchaseBill.gross_total), 0.0))
            .filter(PurchaseBill.company_id == company_id, PurchaseBill.posted.is_(True))
            .scalar()
            or 0.0
        )
        expiry_returns_total = balance("Expiry Returns Expense")
        currency = (
            Currency.query.filter_by(company_id=company_id)
            .order_by(
                Currency.is_archived.asc(),
                Currency.updated_at.desc().nullslast(),
                Currency.created_at.desc().nullslast(),
                Currency.id.desc(),
            )
            .first()
        )
        currency_code = currency.code if currency and getattr(currency, "code", None) else "USD"
        currency_symbol = None
        currency_position = "prefix"
        if currency:
            symbol_prefix = str(currency.symbol_prefix or "").strip()
            symbol_suffix = str(currency.symbol_suffix or "").strip()
            symbol = str(currency.symbol or "").strip()
            currency_symbol = symbol_prefix or symbol_suffix or symbol or currency.code
            currency_position = "suffix" if symbol_suffix else "prefix"

        return jsonify(
            {
                "cash": round(balance("Cash"), 2),
                "receivables": round(balance("Accounts Receivable"), 2),
                "payables": round(balance("Accounts Payable"), 2),
                "sales_revenue": round(balance("Sales Revenue"), 2),
                "inventory_value": round(balance("Inventory"), 2),
                "expiry_returns": round(expiry_returns_total, 2),
                "due_total": round(float(due_total or 0.0), 2),
                "due_count": int(due_count or 0),
                "purchase_bills_total": round(float(posted_purchases or 0.0), 2),
                "currency_code": currency_code,
                "currency_symbol": currency_symbol,
                "currency_position": currency_position,
            }
        )

    def _accounting_has_ref(company_id: int, ref_type: str, ref_id: int) -> bool:
        return (
            db.session.query(AccountEntry.id)
            .filter(
                AccountEntry.company_id == company_id,
                AccountEntry.reference_type == ref_type,
                AccountEntry.reference_id == ref_id,
            )
            .first()
            is not None
        )

    def _recompute_company_account_balances(company_id: int) -> None:
        """
        Ensure Account.balance matches the sum of AccountEntry rows.
        This is useful after a reconciliation/backfill and keeps the Overview consistent.
        """
        sums = (
            db.session.query(
                AccountEntry.account_id,
                db.func.coalesce(db.func.sum(db.case((AccountEntry.entry_type == "debit", AccountEntry.amount), else_=0.0)), 0.0).label("debit"),
                db.func.coalesce(db.func.sum(db.case((AccountEntry.entry_type == "credit", AccountEntry.amount), else_=0.0)), 0.0).label("credit"),
            )
            .filter(AccountEntry.company_id == company_id)
            .group_by(AccountEntry.account_id)
            .all()
        )
        by_acc = {int(r[0]): {"debit": float(r[1] or 0.0), "credit": float(r[2] or 0.0)} for r in sums}
        accounts = Account.query.filter_by(company_id=company_id).all()
        for acc in accounts:
            totals = by_acc.get(int(acc.id), {"debit": 0.0, "credit": 0.0})
            debit = float(totals.get("debit") or 0.0)
            credit = float(totals.get("credit") or 0.0)
            natural_credit = (acc.type or "").lower() in {"liability", "equity", "income"}
            acc.balance = (credit - debit) if natural_credit else (debit - credit)

    def _repair_party_ledgers(company_id: int, *, move_entries: bool = True) -> dict[str, int]:
        """
        Backfill per-party ledgers for existing companies and migrate legacy
        receivable/payable postings from the control account to the correct party ledger.
        """
        controls = _ensure_default_company_ledgers(company_id)
        results = {
            "customer_ledgers_created": 0,
            "supplier_ledgers_created": 0,
            "ar_entries_reassigned": 0,
            "ap_entries_reassigned": 0,
        }

        for customer in Customer.query.filter_by(company_id=company_id).all():
            existed = (
                Account.query.filter_by(company_id=company_id, name=_customer_ledger_name(customer)).first()
                is not None
            )
            _get_or_create_customer_account(company_id, customer)
            if not existed:
                results["customer_ledgers_created"] += 1

        for supplier in Supplier.query.filter_by(company_id=company_id).all():
            existed = (
                Account.query.filter_by(company_id=company_id, name=_supplier_ledger_name(supplier)).first()
                is not None
            )
            _get_or_create_supplier_account(company_id, supplier)
            if not existed:
                results["supplier_ledgers_created"] += 1

        if not move_entries:
            return results

        ar_control = controls.get("Accounts Receivable")
        ap_control = controls.get("Accounts Payable")

        if ar_control:
            ar_entries = (
                AccountEntry.query.filter(
                    AccountEntry.company_id == company_id,
                    AccountEntry.account_id == ar_control.id,
                    AccountEntry.reference_type.in_(["sale", "backdated_sale", "sale_payment", "sale_return"]),
                )
                .order_by(AccountEntry.id.asc())
                .all()
            )
            for entry in ar_entries:
                customer = None
                ref_type = (entry.reference_type or "").strip().lower()
                if ref_type in {"sale", "backdated_sale"} and entry.reference_id:
                    sale = Sale.query.get(entry.reference_id)
                    if sale and sale.company_id == company_id:
                        customer = sale.customer
                elif ref_type == "sale_payment" and entry.reference_id:
                    payment = SalePayment.query.get(entry.reference_id)
                    sale = Sale.query.get(payment.sale_id) if payment and payment.company_id == company_id else None
                    if not sale:
                        legacy_sale = Sale.query.get(entry.reference_id)
                        if legacy_sale and legacy_sale.company_id == company_id:
                            sale = legacy_sale
                    if sale and sale.company_id == company_id:
                        customer = sale.customer
                elif ref_type == "sale_return" and entry.reference_id:
                    sale_return = SaleReturn.query.get(entry.reference_id)
                    sale = Sale.query.get(sale_return.sale_id) if sale_return and sale_return.company_id == company_id and sale_return.sale_id else None
                    if sale and sale.company_id == company_id:
                        customer = sale.customer
                if not customer:
                    continue
                customer_account = _get_or_create_customer_account(company_id, customer)
                if entry.account_id != customer_account.id:
                    entry.account_id = customer_account.id
                    results["ar_entries_reassigned"] += 1

        if ap_control:
            ap_entries = (
                AccountEntry.query.filter(
                    AccountEntry.company_id == company_id,
                    AccountEntry.account_id == ap_control.id,
                    AccountEntry.reference_type.in_(["purchase_bill", "purchase_bill_payment"]),
                )
                .order_by(AccountEntry.id.asc())
                .all()
            )
            for entry in ap_entries:
                supplier = None
                ref_type = (entry.reference_type or "").strip().lower()
                if ref_type == "purchase_bill" and entry.reference_id:
                    bill = PurchaseBill.query.get(entry.reference_id)
                    if bill and bill.company_id == company_id:
                        supplier = bill.supplier
                elif ref_type == "purchase_bill_payment" and entry.reference_id:
                    payment = PurchaseBillPayment.query.get(entry.reference_id)
                    bill = PurchaseBill.query.get(payment.purchase_bill_id) if payment and payment.company_id == company_id else None
                    if not bill:
                        legacy_bill = PurchaseBill.query.get(entry.reference_id)
                        if legacy_bill and legacy_bill.company_id == company_id:
                            bill = legacy_bill
                    if bill and bill.company_id == company_id:
                        supplier = bill.supplier
                if not supplier:
                    continue
                supplier_account = _get_or_create_supplier_account(company_id, supplier)
                if entry.account_id != supplier_account.id:
                    entry.account_id = supplier_account.id
                    results["ap_entries_reassigned"] += 1

        _recompute_company_account_balances(company_id)
        return results

    def _purchase_bill_total_cost(bill: PurchaseBill) -> float:
        if float(bill.gross_total or 0.0) > 0:
            return float(bill.gross_total or 0.0)
        total_cost = 0.0
        for item in bill.items or []:
            qty_units = float((item.ordered_qty or 0) + (item.free_qty or 0))
            if qty_units <= 0:
                continue
            total_cost += float(item.cost_price or 0.0) * float(qty_units)
        return float(total_cost or 0.0)

    @app.route("/accounting/audit", methods=["GET"])
    @require_auth
    @company_required(["admin", "manager", "superuser"])
    def accounting_audit():
        """
        Finds common financial records that should have accounting entries, but don't.
        This is a diagnostic endpoint (does not mutate data).
        """
        company_id = g.current_company.id
        audit = {
            "missing": {
                "purchase_bills": [],
                "sales": [],
                "sale_returns": [],
                "expiry_returns": [],
                "sale_payments": [],
                "purchase_bill_payments": [],
                "daily_sales_denied_without_reversal": [],
            }
        }

        # Posted purchase bills should debit Inventory and credit Accounts Payable.
        posted_bills = PurchaseBill.query.filter(
            PurchaseBill.company_id == company_id,
            PurchaseBill.posted.is_(True),
        ).all()
        for bill in posted_bills:
            if float(bill.gross_total or 0.0) <= 0:
                continue
            if not _accounting_has_ref(company_id, "purchase_bill", bill.id):
                audit["missing"]["purchase_bills"].append(
                    {"id": bill.id, "bill_number": bill.bill_number, "purchase_date": bill.purchase_date.isoformat() if bill.purchase_date else None}
                )

        # Sales that affect inventory should post to accounting (exclude undelivered sales orders and denied daily sales).
        sales = Sale.query.filter(
            Sale.company_id == company_id,
            Sale.source != "sales_order",
            or_(Sale.approval_status.is_(None), Sale.approval_status != "denied"),
        ).all()
        for sale in sales:
            if float(sale.total_amount or 0.0) <= 0:
                continue
            if not _sale_accounting_has_ref(sale):
                audit["missing"]["sales"].append(
                    {
                        "id": sale.id,
                        "sale_number": sale.sale_number,
                        "sale_date": sale.sale_date.isoformat() if sale.sale_date else None,
                        "source": sale.source,
                        "origin_source": _sale_origin_source(sale),
                        "reference_type": _sale_accounting_reference_type(sale),
                    }
                )

        # Sale returns should post.
        sale_returns = SaleReturn.query.filter(SaleReturn.company_id == company_id).all()
        for sr in sale_returns:
            if float(sr.total_amount or 0.0) <= 0:
                continue
            if not _accounting_has_ref(company_id, "sale_return", sr.id):
                audit["missing"]["sale_returns"].append({"id": sr.id, "return_number": sr.return_number, "sale_id": sr.sale_id})

        # Expiry returns confirmations should post.
        expiry_returns = ExpiryReturn.query.filter(ExpiryReturn.company_id == company_id).all()
        for er in expiry_returns:
            if not _accounting_has_ref(company_id, "expiry_return", er.id):
                audit["missing"]["expiry_returns"].append({"id": er.id, "number": er.local_number, "period": er.period_key})

        # Sale payments (receipts for due sales) should post.
        sale_payments = SalePayment.query.filter(SalePayment.company_id == company_id).all()
        for p in sale_payments:
            if float(p.amount or 0.0) <= 0:
                continue
            if not _accounting_has_ref(company_id, "sale_payment", p.id):
                audit["missing"]["sale_payments"].append({"id": p.id, "sale_id": p.sale_id, "amount": float(p.amount or 0.0)})

        # Purchase bill payments should post (best effort: if we can't match per-payment, we still report).
        pb_payments = PurchaseBillPayment.query.filter(PurchaseBillPayment.company_id == company_id).all()
        for p in pb_payments:
            if float(p.amount or 0.0) <= 0:
                continue
            # current implementation posts entries keyed by bill id (legacy); treat either as "present".
            has_per_payment = _accounting_has_ref(company_id, "purchase_bill_payment", p.id)
            has_bill_level = _accounting_has_ref(company_id, "purchase_bill_payment", p.purchase_bill_id)
            if not (has_per_payment or has_bill_level):
                audit["missing"]["purchase_bill_payments"].append(
                    {"id": p.id, "purchase_bill_id": p.purchase_bill_id, "amount": float(p.amount or 0.0), "payment_date": p.payment_date.isoformat() if p.payment_date else None}
                )

        # Denied daily sales should have a reversal entry if the original sale was posted.
        denied = Sale.query.filter(
            Sale.company_id == company_id,
            Sale.source.in_(["daily_sales", "backdated_daily_sales"]),
            Sale.approval_status == "denied",
        ).all()
        for sale in denied:
            if float(sale.total_amount or 0.0) <= 0:
                continue
            if _sale_accounting_has_ref(sale) and (not _accounting_has_ref(company_id, "sale_denied", sale.id)):
                audit["missing"]["daily_sales_denied_without_reversal"].append(
                    {"id": sale.id, "sale_number": sale.sale_number, "total_amount": float(sale.total_amount or 0.0)}
                )

        return jsonify(audit)

    @app.route("/accounting/reconcile", methods=["POST"])
    @require_auth
    @company_required(["admin", "manager", "superuser"])
    def accounting_reconcile():
        """
        Best-effort reconciliation:
        - Backfills missing core entries for posted purchase bills, sales, sale returns, expiry returns, and sale payments.
        - Adds reversal entries for denied daily sales that were already posted.
        - Recomputes Account balances from AccountEntry rows.
        """
        company_id = g.current_company.id
        results = {
            "posted": {
                "purchase_bills": 0,
                "purchase_bill_payments": 0,
                "sales": 0,
                "sale_returns": 0,
                "expiry_returns": 0,
                "sale_payments": 0,
                "daily_sales_denied_reversals": 0,
            },
            "skipped": {"purchase_bill_payments_ambiguous": 0},
            "notes": [],
        }

        # Purchase bills (posted)
        posted_bills = PurchaseBill.query.filter(
            PurchaseBill.company_id == company_id,
            PurchaseBill.posted.is_(True),
        ).all()
        for bill in posted_bills:
            if float(bill.gross_total or 0.0) <= 0:
                continue
            if _accounting_has_ref(company_id, "purchase_bill", bill.id):
                continue
            total_cost = _purchase_bill_total_cost(bill)
            if total_cost <= 0:
                continue
            label = bill.bill_number or f"#{bill.id}"
            payable_name = _purchase_payable_account_name(company_id, bill.supplier)
            _post_double_entry(
                company_id,
                "Inventory",
                "asset",
                payable_name,
                "liability",
                total_cost,
                "purchase_bill",
                bill.id,
                f"Purchase Bill {label}",
            )
            results["posted"]["purchase_bills"] += 1

        # Sales (exclude undelivered orders + denied daily/backdated sales)
        sales = Sale.query.filter(
            Sale.company_id == company_id,
            Sale.source != "sales_order",
            or_(Sale.approval_status.is_(None), Sale.approval_status != "denied"),
        ).all()
        for sale in sales:
            if float(sale.total_amount or 0.0) <= 0:
                continue
            if _sale_accounting_has_ref(sale):
                continue
            if _ensure_sale_accounting_posted(sale):
                results["posted"]["sales"] += 1

        # Sale returns
        sale_returns = SaleReturn.query.filter(SaleReturn.company_id == company_id).all()
        for sr in sale_returns:
            if float(sr.total_amount or 0.0) <= 0:
                continue
            if _accounting_has_ref(company_id, "sale_return", sr.id):
                continue
            sale = Sale.query.get(sr.sale_id) if sr.sale_id else None
            if sale and sale.company_id != company_id:
                sale = None
            if sale and (sale.payment_status or "").strip().lower() == "paid":
                refund_method = (sr.payment_method or sale.payment_method or "Cash").strip() if hasattr(sr, "payment_method") else (sale.payment_method or "Cash")
                credit_name = _resolve_payment_account_name(company_id, refund_method, "Cash")
                description = f"Sales return for Sale {sale.sale_number or f'#{sale.id}'}"
            else:
                credit_name = _sale_receivable_account_name(company_id, sale.customer if sale else None)
                description = f"Sales return (due) for Sale #{sr.sale_id}"
            _post_double_entry(
                company_id,
                "Sales Returns",
                "expense",
                credit_name,
                "asset",
                float(sr.total_amount or 0.0),
                "sale_return",
                sr.id,
                description,
            )
            results["posted"]["sale_returns"] += 1

        # Purchase bill payments (party payments)
        pb_payments = PurchaseBillPayment.query.filter(PurchaseBillPayment.company_id == company_id).all()
        for p in pb_payments:
            if float(p.amount or 0.0) <= 0:
                continue
            # If already posted per-payment, skip.
            if _accounting_has_ref(company_id, "purchase_bill_payment", p.id):
                continue
            # Legacy case: postings keyed by bill_id.
            if _accounting_has_ref(company_id, "purchase_bill_payment", p.purchase_bill_id):
                results["skipped"]["purchase_bill_payments_ambiguous"] += 1
                continue
            bill = PurchaseBill.query.get(p.purchase_bill_id)
            if not bill or bill.company_id != company_id:
                continue
            credit_name = "Cash"
            payment_mode = None
            if p.payment_mode_id:
                payment_mode = PaymentMode.query.get(p.payment_mode_id)
                if not payment_mode or payment_mode.company_id != company_id:
                    payment_mode = None
            if payment_mode:
                if payment_mode.account_id:
                    account = Account.query.get(payment_mode.account_id)
                    if account and account.company_id == company_id:
                        credit_name = account.name
                if credit_name == "Cash":
                    credit_name = payment_mode.name or credit_name
            else:
                credit_name = _resolve_payment_account_name(company_id, p.payment_mode_name, "Cash")
            bill_label = bill.bill_number or f"#{bill.id}"
            payable_name = _purchase_payable_account_name(company_id, bill.supplier)
            _post_double_entry(
                company_id,
                payable_name,
                "liability",
                credit_name,
                "asset",
                float(p.amount or 0.0),
                "purchase_bill_payment",
                p.id,
                f"Payment for Purchase Bill {bill_label}",
            )
            results["posted"]["purchase_bill_payments"] += 1

        # Expiry returns
        expiry_returns = ExpiryReturn.query.filter(ExpiryReturn.company_id == company_id).all()
        for er in expiry_returns:
            if _accounting_has_ref(company_id, "expiry_return", er.id):
                continue
            expense_total = 0.0
            receivable_total = 0.0
            for line in er.lines or []:
                try:
                    qty_base = int(line.qty_base or 0)
                except (TypeError, ValueError):
                    qty_base = 0
                if qty_base <= 0 or not line.product_id:
                    continue
                product = db.session.get(Product, line.product_id)
                if not product or product.company_id != company_id:
                    continue
                # Prefer an existing batch row (even if qty is now 0 after removal) to derive cost.
                batch = (
                    InventoryBatch.query.filter(
                        InventoryBatch.company_id == company_id,
                        InventoryBatch.product_id == product.id,
                        InventoryBatch.batch_number == line.batch_number,
                        InventoryBatch.expiry_date == line.expiry_date,
                    )
                    .order_by(InventoryBatch.id.desc())
                    .first()
                )
                if not batch:
                    continue
                cost_per_base = _cost_per_base_for_batch(company_id, product, batch)
                if cost_per_base > 0:
                    line_cost = float(qty_base) * float(cost_per_base)
                    if line.supplier_id:
                        receivable_total += line_cost
                    else:
                        expense_total += line_cost
            if receivable_total > 0:
                _post_double_entry(
                    company_id,
                    "Accounts Receivable",
                    "asset",
                    "Inventory",
                    "asset",
                    float(receivable_total),
                    "expiry_return",
                    er.id,
                    f"Expiry return #{er.local_number or er.id}",
                )
            if expense_total > 0:
                _post_double_entry(
                    company_id,
                    "Expiry Returns Expense",
                    "expense",
                    "Inventory",
                    "asset",
                    float(expense_total),
                    "expiry_return",
                    er.id,
                    f"Expiry return #{er.local_number or er.id}",
                )
            if receivable_total <= 0 and expense_total <= 0:
                continue
            results["posted"]["expiry_returns"] += 1

        # Sale payments (due receipts)
        sale_payments = SalePayment.query.filter(SalePayment.company_id == company_id).all()
        for p in sale_payments:
            if float(p.amount or 0.0) <= 0:
                continue
            if _accounting_has_ref(company_id, "sale_payment", p.id):
                continue
            debit_name = _resolve_payment_account_name(company_id, p.payment_mode_name, "Cash")
            if p.payment_mode_id:
                mode = PaymentMode.query.get(p.payment_mode_id)
                if mode and mode.company_id == company_id:
                    if mode.account_id:
                        acc = Account.query.get(mode.account_id)
                        if acc and acc.company_id == company_id:
                            debit_name = acc.name
                    elif mode.name:
                        debit_name = mode.name
            sale = Sale.query.get(p.sale_id) if p.sale_id else None
            if sale and sale.company_id != company_id:
                sale = None
            label = (sale.sale_number if sale else None) or f"#{p.sale_id}"
            receivable_name = _sale_receivable_account_name(company_id, sale.customer if sale else None)
            _post_double_entry(
                company_id,
                debit_name,
                "asset",
                receivable_name,
                "asset",
                float(p.amount or 0.0),
                "sale_payment",
                p.id,
                f"Receipt for Sales Order {label}",
            )
            results["posted"]["sale_payments"] += 1

        # Denied daily sales reversal (if they were already posted)
        denied = Sale.query.filter(
            Sale.company_id == company_id,
            Sale.source.in_(["daily_sales", "backdated_daily_sales"]),
            Sale.approval_status == "denied",
        ).all()
        for sale in denied:
            if float(sale.total_amount or 0.0) <= 0:
                continue
            if (not _sale_accounting_has_ref(sale)) or _accounting_has_ref(company_id, "sale_denied", sale.id):
                continue
            if (sale.payment_status or "").strip().lower() == "due":
                credit_name = _sale_receivable_account_name(company_id, sale.customer)
                credit_type = "asset"
            else:
                credit_name = _resolve_payment_account_name(company_id, sale.payment_method, "Cash")
                credit_type = "asset"
                existing_acc = Account.query.filter(
                    Account.company_id == company_id,
                    func.lower(Account.name) == func.lower(credit_name),
                ).first()
                if existing_acc:
                    credit_type = existing_acc.type or credit_type
            label = sale.sale_number or f"#{sale.id}"
            _post_double_entry(
                company_id,
                "Sales Revenue",
                "income",
                credit_name,
                credit_type,
                float(sale.total_amount or 0.0),
                "sale_denied",
                sale.id,
                f"Denied daily sale reversal {label}",
            )
            results["posted"]["daily_sales_denied_reversals"] += 1

        _recompute_company_account_balances(company_id)
        db.session.commit()
        log_action(
            "accounting_reconciled",
            {"posted": results["posted"], "skipped": results["skipped"]},
            company_id,
        )
        return jsonify(results)

    @app.route("/accounting/party-ledgers/repair", methods=["POST"])
    @require_auth
    @company_required(["admin", "manager", "superuser"])
    def repair_party_ledgers():
        results = _repair_party_ledgers(g.current_company.id, move_entries=True)
        db.session.commit()
        log_action("accounting_party_ledgers_repaired", results, g.current_company.id)
        return jsonify({"ok": True, **results})

    @app.route("/accounting/approved-transactions/repair", methods=["POST"])
    @require_auth
    @company_required(["admin", "manager", "superuser", "superadmin"])
    def repair_approved_transaction_postings():
        company_id = g.current_company.id
        results = {
            "purchase_bills_posted": 0,
            "sale_entries_created": 0,
            "purchase_bill_failures": [],
            "sale_failures": [],
        }

        bills = (
            PurchaseBill.query.filter(
                PurchaseBill.company_id == company_id,
                func.lower(func.coalesce(PurchaseBill.approval_status, "approved")) == "approved",
                or_(PurchaseBill.posted.is_(False), PurchaseBill.posted.is_(None)),
            )
            .order_by(PurchaseBill.id.asc())
            .all()
        )
        for bill in bills:
            try:
                if _post_purchase_bill_if_ready(bill):
                    db.session.commit()
                    results["purchase_bills_posted"] += 1
                else:
                    db.session.rollback()
            except Exception as exc:
                db.session.rollback()
                results["purchase_bill_failures"].append(
                    {
                        "purchase_bill_id": int(bill.id),
                        "bill_number": bill.bill_number,
                        "error": str(exc),
                    }
                )

        sales = (
            Sale.query.filter(
                Sale.company_id == company_id,
                func.lower(func.coalesce(Sale.approval_status, "approved")) == "approved",
                func.lower(func.coalesce(Sale.source, "")).in_(["sales", "daily_sales", "backdated_daily_sales"]),
            )
            .order_by(Sale.id.asc())
            .all()
        )
        for sale in sales:
            try:
                inventory_posted = _post_sale_inventory_if_ready(sale)
                if _ensure_sale_accounting_posted(sale):
                    db.session.commit()
                    results["sale_entries_created"] += 1
                elif inventory_posted:
                    db.session.commit()
                    results["sale_entries_created"] += 1
                else:
                    db.session.rollback()
            except Exception as exc:
                db.session.rollback()
                results["sale_failures"].append(
                    {
                        "sale_id": int(sale.id),
                        "sale_number": sale.sale_number,
                        "error": str(exc),
                    }
                )

        log_action("approved_transaction_postings_repaired", results, company_id)
        return jsonify({"ok": True, **results})

    @app.route("/accounting/expiry-returns/rebuild", methods=["POST"])
    @require_auth
    @company_required(["admin", "manager", "superuser"])
    def accounting_rebuild_expiry_returns():
        company_id = g.current_company.id
        results = {"processed": 0, "rebuilt": 0, "skipped_no_cost": 0}

        expiry_returns = ExpiryReturn.query.filter(ExpiryReturn.company_id == company_id).all()
        for er in expiry_returns:
            results["processed"] += 1
            AccountEntry.query.filter(
                AccountEntry.company_id == company_id,
                AccountEntry.reference_type == "expiry_return",
                AccountEntry.reference_id == er.id,
            ).delete(synchronize_session=False)

            expense_total = 0.0
            receivable_total = 0.0
            for line in er.lines or []:
                try:
                    qty_base = int(line.qty_base or 0)
                except (TypeError, ValueError):
                    qty_base = 0
                if qty_base <= 0 or not line.product_id:
                    continue
                product = db.session.get(Product, line.product_id)
                if not product or product.company_id != company_id:
                    continue
                batch = (
                    InventoryBatch.query.filter(
                        InventoryBatch.company_id == company_id,
                        InventoryBatch.product_id == product.id,
                        InventoryBatch.batch_number == line.batch_number,
                        InventoryBatch.expiry_date == line.expiry_date,
                    )
                    .order_by(InventoryBatch.id.desc())
                    .first()
                )
                if not batch:
                    continue
                cost_per_base = _cost_per_base_for_batch(company_id, product, batch)
                if cost_per_base <= 0:
                    continue
                line_cost = float(qty_base) * float(cost_per_base)
                if line.supplier_id:
                    receivable_total += line_cost
                else:
                    expense_total += line_cost

            if receivable_total > 0:
                _post_double_entry(
                    company_id,
                    "Accounts Receivable",
                    "asset",
                    "Inventory",
                    "asset",
                    float(receivable_total),
                    "expiry_return",
                    er.id,
                    f"Expiry return #{er.local_number or er.id}",
                )
            if expense_total > 0:
                _post_double_entry(
                    company_id,
                    "Expiry Returns Expense",
                    "expense",
                    "Inventory",
                    "asset",
                    float(expense_total),
                    "expiry_return",
                    er.id,
                    f"Expiry return #{er.local_number or er.id}",
                )
            if receivable_total <= 0 and expense_total <= 0:
                results["skipped_no_cost"] += 1
                continue
            results["rebuilt"] += 1

        _recompute_company_account_balances(company_id)
        db.session.commit()
        log_action(
            "expiry_returns_ledger_rebuilt",
            {"processed": results["processed"], "rebuilt": results["rebuilt"]},
            company_id,
        )
        return jsonify(results)

    @app.route("/accounts", methods=["POST"])
    @require_auth
    @company_required(["admin"])
    def create_account():
        data = request.get_json() or {}
        code = _next_company_account_code(g.current_company.id)
        name = data.get("name", "").strip()
        acc_type = str(data.get("type", "asset") or "asset").lower()
        parent_id = data.get("parent_id")
        is_active = bool(data.get("is_active", True))
        description = data.get("description", "")
        balance = float(data.get("balance", 0.0))
        if not name:
            return jsonify({"error": "Account name required"}), 400
        if acc_type not in ["asset", "liability", "income", "expense", "equity"]:
            return jsonify({"error": "Invalid account type"}), 400
        if parent_id is not None:
            try:
                parent_id = int(parent_id)
            except (TypeError, ValueError):
                return jsonify({"error": "Invalid parent account"}), 400
            parent = Account.query.get(parent_id)
            if not parent or parent.company_id != g.current_company.id:
                return jsonify({"error": "Parent account not found"}), 404
        exists = Account.query.filter_by(company_id=g.current_company.id, name=name).first()
        if exists:
            return jsonify({"error": "Account name already exists"}), 400
        acc = Account(
            company_id=g.current_company.id,
            code=code,
            name=name,
            type=acc_type,
            parent_id=parent_id,
            is_active=is_active,
            description=description,
            balance=balance,
        )
        db.session.add(acc)
        db.session.commit()
        socketio.emit(
            "accounting:update",
            {"type": "account_created", "account": acc.to_dict(), "company_id": g.current_company.id},
        )
        log_action("account_created", {"id": acc.id, "name": acc.name, "type": acc.type}, g.current_company.id)
        return jsonify(acc.to_dict()), 201

    @app.route("/accounts/transfer", methods=["POST"])
    @require_auth
    @company_required(["admin", "manager"])
    def transfer_account_balance():
        data = request.get_json() or {}
        from_account_id = data.get("from_account_id")
        to_account_id = data.get("to_account_id")
        try:
            amount = float(data.get("amount") or 0.0)
        except (TypeError, ValueError):
            amount = 0.0
        if amount <= 0:
            return jsonify({"error": "Amount must be greater than 0"}), 400
        try:
            from_account_id = int(from_account_id)
            to_account_id = int(to_account_id)
        except (TypeError, ValueError):
            return jsonify({"error": "Invalid account selection"}), 400
        if from_account_id == to_account_id:
            return jsonify({"error": "Accounts must be different"}), 400
        from_account = Account.query.get_or_404(from_account_id)
        to_account = Account.query.get_or_404(to_account_id)
        if from_account.company_id != g.current_company.id or to_account.company_id != g.current_company.id:
            return jsonify({"error": "Forbidden"}), 403

        _post_double_entry(
            g.current_company.id,
            to_account.name,
            to_account.type,
            from_account.name,
            from_account.type,
            amount,
            "account_transfer",
            None,
            f"Transfer from {from_account.name} to {to_account.name}",
        )
        db.session.commit()
        log_action(
            "account_transfer",
            {"from": from_account.id, "to": to_account.id, "amount": amount},
            g.current_company.id,
        )
        return jsonify({"ok": True})

    @app.route("/accounts/<int:account_id>", methods=["PUT"])
    @require_auth
    @company_required(["admin", "manager", "superuser"])
    def update_account(account_id: int):
        acc = Account.query.get_or_404(account_id)
        if acc.company_id != g.current_company.id:
            return jsonify({"error": "Forbidden"}), 403
        data = request.get_json() or {}

        if "name" in data:
            name = (data.get("name") or "").strip()
            if not name:
                return jsonify({"error": "Account name required"}), 400
            exists = Account.query.filter_by(company_id=g.current_company.id, name=name).first()
            if exists and exists.id != acc.id:
                return jsonify({"error": "Account name already exists"}), 400
            acc.name = name

        if "type" in data:
            acc_type = str(data.get("type", "") or "").lower()
            if acc_type not in ["asset", "liability", "income", "expense", "equity"]:
                return jsonify({"error": "Invalid account type"}), 400
            acc.type = acc_type

        if "parent_id" in data:
            parent_id = data.get("parent_id")
            if parent_id in [None, ""]:
                acc.parent_id = None
            else:
                try:
                    parent_id = int(parent_id)
                except (TypeError, ValueError):
                    return jsonify({"error": "Invalid parent account"}), 400
                if parent_id == acc.id:
                    return jsonify({"error": "Parent account cannot be the same account"}), 400
                parent = Account.query.get(parent_id)
                if not parent or parent.company_id != g.current_company.id:
                    return jsonify({"error": "Parent account not found"}), 404
                acc.parent_id = parent_id

        if "is_active" in data:
            acc.is_active = bool(data.get("is_active"))

        if "description" in data:
            acc.description = data.get("description", "")

        db.session.commit()
        socketio.emit(
            "accounting:update",
            {"type": "account_updated", "account": acc.to_dict(), "company_id": g.current_company.id},
        )
        log_action("account_updated", {"id": acc.id, "name": acc.name, "type": acc.type}, g.current_company.id)
        return jsonify(acc.to_dict())

    # Currencies
    @app.route("/currencies", methods=["GET"])
    @require_auth
    @company_required()
    def list_currencies():
        include_archived = request.args.get("include_archived") == "1"
        query = Currency.query.filter_by(company_id=g.current_company.id)
        if not include_archived:
            query = query.filter(or_(Currency.is_archived.is_(False), Currency.is_archived.is_(None)))
        currencies = query.order_by(Currency.created_at.asc().nullslast(), Currency.id.asc()).all()
        return jsonify([c.to_dict() for c in currencies])

    @app.route("/currencies", methods=["POST"])
    @require_auth
    @company_required(["admin", "manager"])
    def create_currency():
        data = request.get_json() or {}
        code = (data.get("code") or "").strip().upper()
        name = (data.get("name") or "").strip()
        symbol_raw = data.get("symbol")
        symbol = symbol_raw if symbol_raw is not None else ""
        position = (data.get("position") or "").strip().lower() or ""
        symbol_prefix_raw = data.get("symbol_prefix")
        symbol_suffix_raw = data.get("symbol_suffix")
        symbol_prefix = symbol_prefix_raw if symbol_prefix_raw is not None else ""
        symbol_suffix = symbol_suffix_raw if symbol_suffix_raw is not None else ""
        if not code:
            code = (name[:3] or "CUR").upper()
        if not name or not str(symbol).strip():
            return jsonify({"error": "Currency name and symbol are required"}), 400
        exists = Currency.query.filter_by(company_id=g.current_company.id, code=code).first()
        if exists:
            return jsonify({"error": "Currency code already exists"}), 400
        if not position:
            position = "prefix" if str(symbol_prefix).strip() or (not str(symbol_suffix).strip()) else "suffix"
        currency = Currency(
            company_id=g.current_company.id,
            code=code,
            name=name,
            symbol=symbol,
            base_code=code,
            exchange_rate=float(data.get("exchange_rate") or 1.0),
            symbol_prefix=symbol_prefix if str(symbol_prefix).strip() else (symbol if position == "prefix" else None),
            symbol_suffix=symbol_suffix if str(symbol_suffix).strip() else (symbol if position == "suffix" else None),
        )
        db.session.add(currency)
        db.session.commit()
        log_action("currency_created", {"id": currency.id, "code": currency.code}, g.current_company.id)
        return jsonify(currency.to_dict()), 201

    @app.route("/currencies/<int:currency_id>/archive", methods=["POST"])
    @require_auth
    @company_required(["admin", "manager"])
    def archive_currency(currency_id: int):
        currency = Currency.query.get_or_404(currency_id)
        if currency.company_id != g.current_company.id:
            return jsonify({"error": "Forbidden"}), 403
        if currency_used(currency):
            return jsonify({"error": "Currency is in use and cannot be archived"}), 400
        currency.is_archived = True
        db.session.commit()
        log_action("currency_archived", {"id": currency.id, "code": currency.code}, g.current_company.id)
        return jsonify(currency.to_dict())

    @app.route("/currencies/<int:currency_id>", methods=["PUT"])
    @require_auth
    @company_required(["admin", "manager"])
    def update_currency(currency_id: int):
        currency = Currency.query.get_or_404(currency_id)
        if currency.company_id != g.current_company.id:
            return jsonify({"error": "Forbidden"}), 403
        data = request.get_json() or {}
        if "code" in data:
            code = (data.get("code") or "").strip().upper()
            if not code:
                return jsonify({"error": "Currency code is required"}), 400
            exists = (
                Currency.query.filter(
                    Currency.company_id == g.current_company.id, Currency.code == code, Currency.id != currency.id
                ).first()
                is not None
            )
            if exists:
                return jsonify({"error": "Currency code already exists"}), 400
            currency.code = code
            currency.base_code = code if currency.base_code == currency.code else currency.base_code
        if "name" in data:
            name = (data.get("name") or "").strip()
            if not name:
                return jsonify({"error": "Currency name is required"}), 400
            currency.name = name
        if "symbol" in data:
            symbol_raw = data.get("symbol")
            symbol = symbol_raw if symbol_raw is not None else ""
            if not str(symbol).strip():
                return jsonify({"error": "Currency symbol is required"}), 400
            currency.symbol = symbol
            position = (data.get("position") or "").strip().lower()
            symbol_prefix_raw = data.get("symbol_prefix")
            symbol_suffix_raw = data.get("symbol_suffix")
            symbol_prefix = symbol_prefix_raw if symbol_prefix_raw is not None else ""
            symbol_suffix = symbol_suffix_raw if symbol_suffix_raw is not None else ""
            if not position:
                position = "prefix" if str(symbol_prefix).strip() or (not str(symbol_suffix).strip()) else "suffix"
            currency.symbol_prefix = (
                symbol_prefix if str(symbol_prefix).strip() else (symbol if position == "prefix" else None)
            )
            currency.symbol_suffix = (
                symbol_suffix if str(symbol_suffix).strip() else (symbol if position == "suffix" else None)
            )
        elif ("symbol_prefix" in data) or ("symbol_suffix" in data) or ("position" in data):
            position = (data.get("position") or "").strip().lower()
            symbol_prefix_raw = data.get("symbol_prefix")
            symbol_suffix_raw = data.get("symbol_suffix")
            symbol_prefix = symbol_prefix_raw if symbol_prefix_raw is not None else ""
            symbol_suffix = symbol_suffix_raw if symbol_suffix_raw is not None else ""
            if not position:
                position = "prefix" if str(symbol_prefix).strip() or (not str(symbol_suffix).strip()) else "suffix"
            if str(symbol_prefix).strip() or str(symbol_suffix).strip():
                currency.symbol_prefix = symbol_prefix if str(symbol_prefix).strip() else None
                currency.symbol_suffix = symbol_suffix if str(symbol_suffix).strip() else None
            else:
                sym = currency.symbol or currency.symbol_prefix or currency.symbol_suffix or ""
                currency.symbol_prefix = sym if position == "prefix" else None
                currency.symbol_suffix = sym if position == "suffix" else None
        if "exchange_rate" in data and data.get("exchange_rate") is not None:
            try:
                currency.exchange_rate = float(data.get("exchange_rate"))
            except (TypeError, ValueError):
                return jsonify({"error": "Invalid exchange_rate"}), 400
        if "is_archived" in data:
            if data.get("is_archived"):
                if currency_used(currency):
                    return jsonify({"error": "Currency is in use and cannot be archived"}), 400
                currency.is_archived = True
            else:
                currency.is_archived = False
        db.session.commit()
        log_action("currency_updated", {"id": currency.id, "code": currency.code}, g.current_company.id)
        return jsonify(currency.to_dict())

    @app.route("/currencies/<int:currency_id>", methods=["DELETE"])
    @require_auth
    @company_required(["admin", "manager"])
    def delete_currency(currency_id: int):
        currency = Currency.query.get_or_404(currency_id)
        if currency.company_id != g.current_company.id:
            return jsonify({"error": "Forbidden"}), 403
        if currency_used(currency):
            return jsonify({"error": "Currency is in use and cannot be deleted"}), 400
        try:
            db.session.delete(currency)
            db.session.commit()
        except Exception as exc:
            db.session.rollback()
            return jsonify({"error": f"Failed to delete currency: {exc}"}), 500
        log_action("currency_deleted", {"id": currency_id}, g.current_company.id)
        return jsonify({"status": "deleted"})

    @app.route("/settings/dashboard", methods=["GET"])
    @require_auth
    @company_required()
    def settings_dashboard():
        company_id = g.current_company.id
        payment_modes = PaymentMode.query.filter_by(company_id=company_id).count()
        units = Unit.query.filter_by(company_id=company_id).count()
        unit_categories = UnitCategory.query.filter_by(company_id=company_id).count()
        roles = Role.query.count()
        currencies = Currency.query.filter_by(company_id=company_id).count()
        return jsonify(
            {
                "payment_modes": payment_modes,
                "units": units,
                "unit_categories": unit_categories,
                "roles": roles,
                "currencies": currencies,
            }
        )

    # Units / Unit categories
    @app.route("/unit-categories", methods=["GET"])
    @require_auth
    @company_required()  # reading categories should be allowed for any company member
    def list_unit_categories():
        include_archived = request.args.get("include_archived") == "1"
        query = UnitCategory.query.filter_by(company_id=g.current_company.id)
        if not include_archived:
            query = query.filter(or_(UnitCategory.is_archived.is_(False), UnitCategory.is_archived.is_(None)))
        cats = query.order_by(UnitCategory.name.asc()).all()
        return jsonify([c.to_dict(include_units=True) for c in cats])

    @app.route("/unit-categories", methods=["POST"])
    @require_auth
    @company_required(["admin"])
    def create_unit_category():
        data = request.get_json() or {}
        name = data.get("name", "").strip()
        if not name:
            return jsonify({"error": "Category name required"}), 400
        exists = UnitCategory.query.filter_by(company_id=g.current_company.id, name=name).first()
        if exists:
            return jsonify({"error": "Category exists"}), 400
        cat = UnitCategory(company_id=g.current_company.id, name=name)
        db.session.add(cat)
        db.session.commit()
        log_action("unit_category_created", {"id": cat.id, "name": cat.name}, g.current_company.id)
        return jsonify(cat.to_dict(include_units=True)), 201

    @app.route("/unit-categories/<int:cat_id>", methods=["PUT"])
    @require_auth
    @company_required(["admin"])
    def update_unit_category(cat_id):
        cat = UnitCategory.query.get_or_404(cat_id)
        if cat.company_id != g.current_company.id:
            return jsonify({"error": "Forbidden"}), 403
        data = request.get_json() or {}
        if "name" in data:
            new_name = data.get("name", "").strip()
            if not new_name:
                return jsonify({"error": "Category name required"}), 400
            exists = UnitCategory.query.filter_by(company_id=g.current_company.id, name=new_name).first()
            if exists and exists.id != cat.id:
                return jsonify({"error": "Category exists"}), 400
            cat.name = new_name
        db.session.commit()
        log_action("unit_category_updated", {"id": cat.id}, g.current_company.id)
        return jsonify(cat.to_dict(include_units=True))

    @app.route("/unit-categories/<int:cat_id>/archive", methods=["POST"])
    @require_auth
    @company_required(["admin"])
    def archive_unit_category(cat_id):
        cat = UnitCategory.query.get_or_404(cat_id)
        if cat.company_id != g.current_company.id:
            return jsonify({"error": "Forbidden"}), 403
        has_units = Unit.query.filter_by(category_id=cat.id).first()
        if has_units:
            return jsonify({"error": "Category in use. Cannot archive while units reference it."}), 400
        cat.is_archived = True
        db.session.commit()
        return jsonify(cat.to_dict(include_units=True))

    @app.route("/unit-categories/<int:cat_id>/restore", methods=["POST"])
    @require_auth
    @company_required(["admin"])
    def restore_unit_category(cat_id):
        cat = UnitCategory.query.get_or_404(cat_id)
        if cat.company_id != g.current_company.id:
            return jsonify({"error": "Forbidden"}), 403
        cat.is_archived = False
        db.session.commit()
        return jsonify(cat.to_dict(include_units=True))

    @app.route("/unit-categories/<int:cat_id>", methods=["DELETE"])
    @require_auth
    @company_required(["admin"])
    def delete_unit_category(cat_id):
        cat = UnitCategory.query.get_or_404(cat_id)
        if cat.company_id != g.current_company.id:
            return jsonify({"error": "Forbidden"}), 403
        has_units = Unit.query.filter_by(category_id=cat.id).first()
        if has_units:
            return jsonify({"error": "Category in use. Archive instead of delete."}), 400
        db.session.delete(cat)
        db.session.commit()
        return jsonify({"deleted": True})

    @app.route("/units", methods=["GET"])
    @require_auth
    @company_required()
    def list_units():
        # repair any orphan units on the fly to enforce category integrity
        missing_units = Unit.query.filter(
            (Unit.company_id == g.current_company.id)
            & (
                (Unit.category_id.is_(None))
                | (
                    ~Unit.category_id.in_(
                        db.session.query(UnitCategory.id).filter_by(company_id=g.current_company.id)
                    )
                )
            )
        ).all()
        if missing_units:
            fallback = UnitCategory.query.filter_by(company_id=g.current_company.id, name="Recovered Units").first()
            if not fallback:
                fallback = UnitCategory(company_id=g.current_company.id, name="Recovered Units")
                db.session.add(fallback)
                db.session.flush()
            for u in missing_units:
                u.category_id = fallback.id
            db.session.commit()

        include_archived = request.args.get("include_archived") == "1"
        query = Unit.query.filter_by(company_id=g.current_company.id)
        if not include_archived:
            query = query.filter(or_(Unit.is_archived.is_(False), Unit.is_archived.is_(None)))
        items = query.order_by(Unit.name.asc()).all()
        return jsonify([u.to_dict() for u in items])

    @app.route("/units", methods=["POST"])
    @require_auth
    @company_required(["admin"])
    def create_unit():
        data = request.get_json() or {}
        name = data.get("name", "").strip()
        abbreviation = data.get("abbreviation", "").strip()
        category_id = data.get("category_id")
        is_base = bool(data.get("is_base", False))
        conversion_factor = float(data.get("conversion_to_base") or data.get("conversion_factor") or 0.0)
        relative_unit_id = data.get("relative_unit_id")
        relative_factor = float(data.get("relative_factor") or 0.0)

        if not name:
            return jsonify({"error": "Unit name required"}), 400
        if not category_id:
            return jsonify({"error": "Category is required"}), 400

        category = UnitCategory.query.filter_by(id=category_id, company_id=g.current_company.id).first()
        if not category:
            return jsonify({"error": "Category not found"}), 404
        if category.is_archived:
            return jsonify({"error": "Category is archived"}), 400

        exists = Unit.query.filter_by(company_id=g.current_company.id, name=name).first()
        if exists:
            return jsonify({"error": "Unit exists"}), 400

        # Handle base unit logic
        if is_base:
            # Ensure no other base unit exists in this category
            other_base = Unit.query.filter_by(company_id=g.current_company.id, category_id=category.id, is_base=True).first()
            if other_base or category.base_unit_id:
                return jsonify({"error": "Base unit already set for this category"}), 400
            # For base units, conversion to base is always 1.0
            conversion_to_base = 1.0
            relative_unit = None
            relative_factor_val = 0.0
        else:
            # For non-base units, use the provided conversion factor
            relative_unit = None
            conversion_to_base = conversion_factor
            relative_factor_val = relative_factor
            
            # If a relative unit is specified, calculate conversion to base
            if relative_unit_id:
                relative_unit = Unit.query.filter_by(id=relative_unit_id, company_id=g.current_company.id).first()
                if not relative_unit or relative_unit.category_id != category.id:
                    return jsonify({"error": "Relative unit not in same category"}), 400
                base_factor = relative_unit.conversion_to_base or 1.0
                conversion_to_base = base_factor * (relative_factor or 1.0)
                relative_factor_val = relative_factor or 1.0
            
            # Validate conversion factor for non-base units
            if conversion_to_base <= 0:
                return jsonify({"error": "Conversion factor must be > 0"}), 400

        unit = Unit(
            company_id=g.current_company.id,
            name=name,
            abbreviation=abbreviation,
            category_id=category.id,
            is_base=is_base,
            conversion_to_base=conversion_to_base,
            relative_unit_id=relative_unit.id if relative_unit else None,
            relative_factor=relative_factor_val,
        )
        db.session.add(unit)
        
        # Update category's base_unit_id if this is a base unit
        if is_base:
            category.base_unit_id = unit.id
        
        db.session.commit()
        log_action("unit_created", {"id": unit.id, "name": unit.name, "category_id": category.id}, g.current_company.id)
        return jsonify(unit.to_dict()), 201

    @app.route("/units/<int:unit_id>", methods=["PUT"])
    @require_auth
    @company_required(["admin"])
    def update_unit(unit_id):
        unit = Unit.query.get_or_404(unit_id)
        if unit.company_id != g.current_company.id:
            return jsonify({"error": "Forbidden"}), 403
        data = request.get_json() or {}
        used = unit_used(unit)
        old_name = unit.name
        old_abbreviation = unit.abbreviation
        name_changed = False
        abbr_changed = False
        if "name" in data:
            new_name = (data.get("name") or unit.name or "").strip()
            if new_name:
                name_changed = new_name != unit.name
                unit.name = new_name
        if "abbreviation" in data:
            new_abbr = (data.get("abbreviation") or "").strip() or None
            abbr_changed = new_abbr != unit.abbreviation
            unit.abbreviation = new_abbr
        # block ratio/base edits if used
        for key in ["is_base", "conversion_to_base", "relative_unit_id", "relative_factor"]:
            if key in data and used:
                return jsonify({"error": "Unit in use. Ratios/base cannot be changed; archive instead."}), 400
        if "is_base" in data:
            want_base = bool(data.get("is_base"))
            if want_base and not unit.is_base:
                other_base = Unit.query.filter_by(company_id=g.current_company.id, category_id=unit.category_id, is_base=True).first()
                if other_base and other_base.id != unit.id:
                    return jsonify({"error": "Another base unit exists"}), 400
                cat = UnitCategory.query.filter_by(id=unit.category_id, company_id=g.current_company.id).first()
                if cat and cat.base_unit_id and cat.base_unit_id != unit.id:
                    return jsonify({"error": "Base unit already set for this category"}), 400
                # For base units, lock conversion to 1 and clear relative refs
                unit.conversion_to_base = 1.0
                unit.relative_unit_id = None
                unit.relative_factor = 0.0
                if cat:
                    cat.base_unit_id = unit.id
            if not want_base and unit.is_base:
                # If demoting base, clear category base_unit_id if it points to this unit
                cat = UnitCategory.query.filter_by(id=unit.category_id, company_id=g.current_company.id).first()
                if cat and cat.base_unit_id == unit.id:
                    cat.base_unit_id = None
            unit.is_base = want_base
        if "conversion_to_base" in data:
            unit.conversion_to_base = float(data.get("conversion_to_base") or 0)
        if "relative_unit_id" in data:
            rel_id = data.get("relative_unit_id")
            if rel_id:
                rel_unit = Unit.query.filter_by(id=rel_id, company_id=g.current_company.id).first()
                if not rel_unit or rel_unit.category_id != unit.category_id:
                    return jsonify({"error": "Relative unit must be in the same category"}), 400
            unit.relative_unit_id = rel_id
        if "relative_factor" in data:
            unit.relative_factor = float(data.get("relative_factor") or 0)

        def _propagate_uom(old_label: str, new_label: str):
            if not old_label or not new_label or old_label == new_label:
                return
            updates = [
                ("purchase_bill_items", "uom", "purchase_bill_id", "purchase_bills"),
                ("purchase_order_items", "uom", "purchase_order_id", "purchase_orders"),
                ("inventory_batches", "uom", None, None),
                ("sale_items", "uom", "sale_id", "sales"),
                ("sale_return_items", "uom", "sale_return_id", "sale_returns"),
                ("expiry_return_lines", "uom", "expiry_return_id", "expiry_returns"),
                ("expiry_return_lines", "base_uom", "expiry_return_id", "expiry_returns"),
            ]
            for table, column, fk, parent in updates:
                if parent:
                    stmt = text(
                        f"""
                        UPDATE {table}
                        SET {column} = :new_label
                        WHERE {column} = :old_label
                          AND {fk} IN (SELECT id FROM {parent} WHERE company_id = :company_id)
                        """
                    )
                    db.session.execute(
                        stmt,
                        {"new_label": new_label, "old_label": old_label, "company_id": unit.company_id},
                    )
                else:
                    stmt = text(
                        f"""
                        UPDATE {table}
                        SET {column} = :new_label
                        WHERE {column} = :old_label AND company_id = :company_id
                        """
                    )
                    db.session.execute(
                        stmt,
                        {"new_label": new_label, "old_label": old_label, "company_id": unit.company_id},
                    )

        # Propagate name/abbreviation changes to stored UoM labels used in documents and inventory.
        if name_changed:
            _propagate_uom(old_name, unit.name)
        if abbr_changed:
            # If abbreviation is used as the visible symbol anywhere, update those too.
            target = unit.abbreviation or unit.name
            _propagate_uom(old_abbreviation, target)

        db.session.commit()
        log_action("unit_updated", {"id": unit.id}, g.current_company.id)
        return jsonify(unit.to_dict())

    @app.route("/units/<int:unit_id>", methods=["DELETE"])
    @require_auth
    @company_required(["admin"])
    def delete_unit(unit_id):
        unit = Unit.query.get_or_404(unit_id)
        if unit.company_id != g.current_company.id:
            return jsonify({"error": "Forbidden"}), 403
        if unit_used(unit):
            return jsonify({"error": "Unit in use. Archive instead of delete."}), 400
        db.session.delete(unit)
        db.session.commit()
        log_action("unit_deleted", {"id": unit.id}, g.current_company.id)
        return jsonify({"deleted": True})

    @app.route("/units/<int:unit_id>/archive", methods=["POST"])
    @require_auth
    @company_required(["admin"])
    def archive_unit(unit_id):
        unit = Unit.query.get_or_404(unit_id)
        if unit.company_id != g.current_company.id:
            return jsonify({"error": "Forbidden"}), 403
        unit.is_archived = True
        db.session.commit()
        return jsonify(unit.to_dict())

    @app.route("/units/<int:unit_id>/restore", methods=["POST"])
    @require_auth
    @company_required(["admin"])
    def restore_unit(unit_id):
        unit = Unit.query.get_or_404(unit_id)
        if unit.company_id != g.current_company.id:
            return jsonify({"error": "Forbidden"}), 403
        unit.is_archived = False
        db.session.commit()
        return jsonify(unit.to_dict())

    # Access rights
    @app.route("/access-rights", methods=["GET"])
    @require_auth
    @company_required(["admin"])
    def list_access_rights():
        items = AccessRight.query.filter_by(company_id=g.current_company.id).order_by(AccessRight.name.asc()).all()
        return jsonify([a.to_dict() for a in items])

    @app.route("/access-rights", methods=["POST"])
    @require_auth
    @company_required(["admin"])
    def create_access_right():
        data = request.get_json() or {}
        name = data.get("name", "").strip()
        description = data.get("description", "")
        can_read = bool(data.get("can_read", True))
        can_write = bool(data.get("can_write", False))
        can_edit = bool(data.get("can_edit", False))
        can_delete = bool(data.get("can_delete", False))
        if not name:
            return jsonify({"error": "Name required"}), 400
        exists = AccessRight.query.filter_by(company_id=g.current_company.id, name=name).first()
        if exists:
            return jsonify({"error": "Access right exists"}), 400
        ar = AccessRight(
            company_id=g.current_company.id,
            name=name,
            description=description,
            can_read=can_read,
            can_write=can_write,
            can_edit=can_edit,
            can_delete=can_delete,
        )
        db.session.add(ar)
        db.session.commit()
        log_action("access_right_created", {"id": ar.id, "name": ar.name}, g.current_company.id)
        return jsonify(ar.to_dict()), 201

    @app.route("/access-rights/<int:ar_id>", methods=["PUT"])
    @require_auth
    @company_required(["admin"])
    def update_access_right(ar_id):
        ar = AccessRight.query.get_or_404(ar_id)
        if ar.company_id != g.current_company.id:
            return jsonify({"error": "Forbidden"}), 403
        data = request.get_json() or {}
        if "name" in data:
            new_name = data.get("name", "").strip()
            if not new_name:
                return jsonify({"error": "Name required"}), 400
            exists = AccessRight.query.filter_by(company_id=g.current_company.id, name=new_name).first()
            if exists and exists.id != ar.id:
                return jsonify({"error": "Access right exists"}), 400
            ar.name = new_name
        if "description" in data:
            ar.description = data.get("description", ar.description)
        for field in ["can_read", "can_write", "can_edit", "can_delete"]:
            if field in data:
                setattr(ar, field, bool(data[field]))
        db.session.commit()
        log_action("access_right_updated", {"id": ar.id}, g.current_company.id)
        return jsonify(ar.to_dict())

    # User permissions (per-section; independent of business rules)
    @app.route("/permissions", methods=["GET"])
    @require_auth
    @company_required(["admin"])
    def list_permissions():
        user_id = request.args.get("user_id")
        query = UserPermission.query
        if user_id:
            query = query.filter_by(user_id=int(user_id))
        items = query.all()
        return jsonify([p.to_dict() for p in items])

    @app.route("/permissions", methods=["POST"])
    @require_auth
    @company_required(["admin"])
    def upsert_permission():
        data = request.get_json() or {}
        user_id = data.get("user_id")
        section = data.get("section", "").strip()
        if not user_id or not section:
            return jsonify({"error": "user_id and section required"}), 400
        perm = UserPermission.query.filter_by(user_id=user_id, section=section).first()
        if not perm:
            perm = UserPermission(user_id=user_id, section=section)
            db.session.add(perm)
        for field in ["can_create", "can_edit", "can_delete", "can_archive"]:
            if field in data:
                setattr(perm, field, bool(data[field]))
        db.session.commit()
        return jsonify(perm.to_dict())

    # Activity logs
    @app.route("/logs", methods=["GET"])
    @require_auth
    @company_required(["admin"])
    def logs():
        limit = min(int(request.args.get("limit", 50)), 200)
        action_filter = request.args.get("action")
        query = ActivityLog.query.filter_by(company_id=g.current_company.id)
        if action_filter:
            query = query.filter(ActivityLog.action.ilike(f"{action_filter}%"))
        items = query.order_by(ActivityLog.created_at.desc()).limit(limit).all()
        user_ids = {i.user_id for i in items if i.user_id}
        users = {}
        if user_ids:
            users = {u.id: u.username for u in User.query.filter(User.id.in_(user_ids)).all()}
        payload = []
        for i in items:
            row = i.to_dict()
            row["user"] = users.get(i.user_id)
            payload.append(row)
        return jsonify(payload)

    @app.route("/logs/me", methods=["GET"])
    @require_auth
    @company_required()
    def my_logs():
        page = max(int(request.args.get("page", 1) or 1), 1)
        per_page = int(request.args.get("per_page", 25) or 25)
        per_page = max(1, min(per_page, 200))
        q = (request.args.get("q") or "").strip()
        from_date_raw = (request.args.get("from") or "").strip()
        to_date_raw = (request.args.get("to") or "").strip()
        user_filter_raw = (request.args.get("user_id") or "").strip()
        current_user_id = getattr(g.current_user, "id", None)
        viewer_role = (getattr(g, "company_role", None) or getattr(g.current_user, "role", "") or "").strip().lower()
        can_view_company_logs = viewer_role in {"admin", "manager", "superuser", "superadmin"} or (
            getattr(g.current_user, "role", "") in ROLE_PLATFORM_ADMINS
        )

        query = ActivityLog.query.filter_by(company_id=g.current_company.id)
        if user_filter_raw:
            if user_filter_raw.lower() == "all":
                if not can_view_company_logs:
                    return jsonify({"error": "Forbidden"}), 403
            else:
                try:
                    requested_user_id = int(user_filter_raw)
                except (TypeError, ValueError):
                    return jsonify({"error": "Invalid user_id"}), 400
                if not can_view_company_logs and requested_user_id != current_user_id:
                    return jsonify({"error": "Forbidden"}), 403
                query = query.filter(ActivityLog.user_id == requested_user_id)
        else:
            # Preserve old behavior when no user filter is sent.
            query = query.filter(ActivityLog.user_id == current_user_id)

        if q:
            query = query.filter(
                or_(
                    ActivityLog.action.ilike(f"%{q}%"),
                    ActivityLog.details.ilike(f"%{q}%"),
                )
            )

        if from_date_raw:
            try:
                from_date = datetime.fromisoformat(from_date_raw).date()
                query = query.filter(func.date(ActivityLog.created_at) >= from_date)
            except ValueError:
                return jsonify({"error": "Invalid from date (expected YYYY-MM-DD)"}), 400
        if to_date_raw:
            try:
                to_date = datetime.fromisoformat(to_date_raw).date()
                query = query.filter(func.date(ActivityLog.created_at) <= to_date)
            except ValueError:
                return jsonify({"error": "Invalid to date (expected YYYY-MM-DD)"}), 400

        total = query.count()
        items = (
            query.order_by(ActivityLog.created_at.desc())
            .offset((page - 1) * per_page)
            .limit(per_page)
            .all()
        )
        user_ids = {i.user_id for i in items if i.user_id}
        usernames = {}
        if user_ids:
            usernames = {u.id: u.username for u in User.query.filter(User.id.in_(user_ids)).all()}
        return jsonify(
            {
                "data": [
                    {
                        **i.to_dict(),
                        "username": usernames.get(i.user_id),
                    }
                    for i in items
                ],
                "meta": {
                    "page": page,
                    "per_page": per_page,
                    "total": total,
                    "pages": (total + per_page - 1) // per_page if per_page else 1,
                },
            }
        )

    @app.route("/mail/threads", methods=["GET"])
    @require_auth
    @company_required()
    def list_mail_threads():
        folder = (request.args.get("folder") or "inbox").lower()
        company_id = g.current_company.id
        counts = {
            "inbox": MailMessage.query.filter_by(company_id=company_id, folder="inbox").count(),
            "sent": MailMessage.query.filter_by(company_id=company_id, folder="sent").count(),
            "read": MailMessage.query.filter_by(company_id=company_id, is_read=True).count(),
            "outbox": MailOutbox.query.filter_by(company_id=company_id).count(),
        }
        threads: list[dict] = []
        if folder == "outbox":
            items = MailOutbox.query.filter_by(company_id=company_id).order_by(MailOutbox.created_at.desc()).all()
            for item in items:
                threads.append(
                    {
                        "id": f"outbox-{item.id}",
                        "subject": item.subject or "(No subject)",
                        "from": g.current_company.email_username or "",
                        "to": item.to_email,
                        "updated_at": _format_dt(item.sent_at or item.created_at),
                        "unread_count": 0,
                        "status": item.status,
                    }
                )
        else:
            messages = (
                MailMessage.query.filter_by(company_id=company_id, folder=folder)
                .order_by(MailMessage.received_at.desc().nullslast(), MailMessage.created_at.desc())
                .all()
            )
            seen: set[str] = set()
            for msg in messages:
                if msg.thread_id in seen:
                    continue
                seen.add(msg.thread_id)
                unread_count = sum(
                    1 for m in messages if m.thread_id == msg.thread_id and not m.is_read
                )
                threads.append(
                    {
                        "id": msg.thread_id,
                        "subject": msg.subject or "(No subject)",
                        "from": msg.sender or "",
                        "to": msg.recipients or "",
                        "updated_at": _format_dt(msg.received_at or msg.created_at),
                        "unread_count": unread_count,
                    }
                )
        return jsonify({"threads": threads, "counts": counts})

    @app.route("/mail/threads/<thread_id>", methods=["GET"])
    @require_auth
    @company_required()
    def get_mail_thread(thread_id: str):
        company_id = g.current_company.id
        if thread_id.startswith("outbox-"):
            outbox_id = int(thread_id.split("-", 1)[1])
            item = MailOutbox.query.filter_by(company_id=company_id, id=outbox_id).first()
            if not item:
                return jsonify({"error": "Thread not found"}), 404
            return jsonify(
                {
                    "thread_id": thread_id,
                    "messages": [
                        {
                            "id": item.id,
                            "subject": item.subject,
                            "from": g.current_company.email_username or "",
                            "to": item.to_email,
                            "body": item.body,
                            "status": item.status,
                            "sent_at": _format_dt(item.sent_at),
                            "created_at": _format_dt(item.created_at),
                        }
                    ],
                }
            )
        messages = (
            MailMessage.query.filter_by(company_id=company_id, thread_id=thread_id)
            .order_by(MailMessage.received_at.asc().nullslast(), MailMessage.created_at.asc())
            .all()
        )
        return jsonify(
            {
                "thread_id": thread_id,
                "messages": [m.to_dict() for m in messages],
            }
        )

    @app.route("/mail/sync", methods=["POST"])
    @require_auth
    @company_required()
    def sync_mail():
        data = request.get_json() or {}
        limit = min(int(data.get("limit", 200)), 500)
        company = g.current_company
        if not (company.email_host and company.email_username and company.email_password):
            return jsonify({"error": "Mail settings incomplete"}), 400
        try:
            totals = sync_company_mail(company, limit)
        except imaplib.IMAP4.error as exc:
            app.logger.exception("Mail sync IMAP authentication failed for company_id=%s", company.id)
            return jsonify({"error": "IMAP authentication failed. Check email/app password and IMAP settings."}), 400
        except RuntimeError as exc:
            app.logger.exception("Mail sync configuration error for company_id=%s", company.id)
            return jsonify({"error": str(exc)}), 400
        except Exception as exc:
            app.logger.exception("Mail sync failed for company_id=%s", company.id)
            return jsonify({"error": f"Failed to sync mail: {exc}"}), 500
        log_action("mail_synced", totals, company.id)
        return jsonify({"synced": totals})


def main():
    app = create_app()
    port = int(os.getenv("PORT", 5000))  # Use port 5000 for backend
    socketio.run(app, host="0.0.0.0", port=port, debug=False)


if __name__ == "__main__":
    main()
