﻿import sqlite3
import re
import threading
from datetime import datetime, timedelta
from pypos.core.base_model import BaseModel
from pypos.core.utils.db_helper import connect_sqlite, connect_sqlite_read_fast
from pypos.core.utils.config_utils import read_config
from pypos.core.utils.path_utils import get_db_path
from pypos.core.utils.sql_query_builder import render_sql_template
from pypos.core.database.schema_migrator import run_schema_migrations_once

# upgraded: inherit base class
class LoadTransaksiModel(BaseModel):
    _PERF_INDEX_INIT_LOCK = threading.Lock()
    _PERF_INDEX_READY = False

    def _connect(self, read_only=False):
        if bool(read_only):
            return connect_sqlite_read_fast(self.db_path)
        return connect_sqlite(self.db_path)

    # edited by glg
    @staticmethod
    def _resolve_positive_limit(value, default=0):
        try:
            parsed = int(value)
        except (TypeError, ValueError, OverflowError):
            parsed = int(default or 0)
        return parsed if parsed > 0 else 0

    # edited by glg
    def _get_history_query_limit(self):
        cfg = read_config() or {}
        return self._resolve_positive_limit(
            cfg.get("history_transaksi_max_rows", 1200),
            default=1200,
        )

    # edited by glg
    def _get_load_transaksi_query_limit(self):
        cfg = read_config() or {}
        return self._resolve_positive_limit(
            cfg.get("load_transaksi_max_rows", 800),
            default=800,
        )

    # edited by glg
    def _ensure_schema_ready_or_fail(self, reason_code="LOAD_TRANSAKSI_SCHEMA_MIGRATION_FAILED"):
        ok = run_schema_migrations_once(self.db_path, strict=False)
        if bool(ok):
            return True
        message = (
            f"{reason_code}: run_schema_migrations_once(strict=False) mengembalikan False "
            f"untuk db_path={self.db_path}"
        )
        self.log_error(message)
        raise RuntimeError(message)

    # edited by glg
    # Index komposit khusus query history agar filter/sort berbasis dtime tidak full-scan.
    def _ensure_performance_indexes(self, cursor):
        cls = self.__class__
        if bool(getattr(cls, "_PERF_INDEX_READY", False)):
            return
        with cls._PERF_INDEX_INIT_LOCK:
            if bool(getattr(cls, "_PERF_INDEX_READY", False)):
                return
            try:
                cursor.execute(
                    """
                    CREATE INDEX IF NOT EXISTS idx_transaksi_history_jenis_trash_dtime
                    ON transaksi(jenis_label, trash, dtime DESC)
                    """
                )
                cursor.execute(
                    """
                    CREATE INDEX IF NOT EXISTS idx_transaksi_history_jenis_trash_user_dtime
                    ON transaksi(jenis_label, trash, oleh_id, dtime DESC)
                    """
                )
                # edited by glg
                # Expression index untuk pola IFNULL(trash,0) agar query history bebas temp sort.
                cursor.execute(
                    """
                    CREATE INDEX IF NOT EXISTS idx_transaksi_history_expr_dtime
                    ON transaksi(jenis_label, IFNULL(trash, 0), dtime DESC)
                    """
                )
                cursor.execute(
                    """
                    CREATE INDEX IF NOT EXISTS idx_transaksi_history_expr_user_dtime
                    ON transaksi(jenis_label, IFNULL(trash, 0), oleh_id, dtime DESC)
                    """
                )
                cursor.connection.commit()
                cls._PERF_INDEX_READY = True
            except (sqlite3.Error, RuntimeError, ValueError, TypeError) as exc:
                self.log_warning(f"Gagal membuat index performa history transaksi: {exc}")

    # edited by glg
    @staticmethod
    def _build_day_range(day_value):
        try:
            base = datetime.strptime(str(day_value or "").strip(), "%Y-%m-%d")
        except (TypeError, ValueError):
            return None
        start_text = base.strftime("%Y-%m-%d 00:00:00")
        end_text = (base + timedelta(days=1)).strftime("%Y-%m-%d 00:00:00")
        return start_text, end_text

    # edited by glg
    def _build_date_range(self, start_date, end_date):
        start_bounds = self._build_day_range(start_date)
        end_bounds = self._build_day_range(end_date)
        if not start_bounds or not end_bounds:
            return None
        start_dt = datetime.strptime(start_bounds[0], "%Y-%m-%d %H:%M:%S")
        end_dt = datetime.strptime(end_bounds[0], "%Y-%m-%d %H:%M:%S")
        if end_dt < start_dt:
            start_dt, end_dt = end_dt, start_dt
        return (
            start_dt.strftime("%Y-%m-%d %H:%M:%S"),
            (end_dt + timedelta(days=1)).strftime("%Y-%m-%d %H:%M:%S"),
        )

    def _has_return_table(self, cursor):
        cursor.execute(
            "SELECT name FROM sqlite_master WHERE type='table' AND name='return_transaksi_penjualan'"
        )
        return cursor.fetchone() is not None

    def _ensure_return_refund_columns(self, cursor):
        _ = cursor
        self._ensure_schema_ready_or_fail("LOAD_TRANSAKSI_SCHEMA_RETURN_MIGRATION_FAILED")

    def _ensure_voucher_tables(self, cursor):
        _ = cursor
        self._ensure_schema_ready_or_fail("LOAD_TRANSAKSI_SCHEMA_VOUCHER_MIGRATION_FAILED")

    def _voucher_subquery(self):
        return """
            SELECT vu.transaksi_id, SUM(COALESCE(vu.nilai_pakai, 0)) AS total_voucher
            FROM voucher_usage vu
            GROUP BY vu.transaksi_id
        """

    def _refund_subquery(self):
        return """
            SELECT r.transaksi_id,
                   SUM(COALESCE(td.produk_ord_hrg, dr.harga, 0) * COALESCE(dr.jumlah, 0)) AS total_refund,
                   SUM(COALESCE(dr.jumlah, 0)) AS total_qty_refund
            FROM return_transaksi_penjualan r
            JOIN detail_return_transaksi_penjualan dr ON dr.return_id = r.id
            LEFT JOIN transaksi_data td
                ON td.transaksi_id = r.transaksi_id AND td.produk_id = dr.produk_id
            WHERE COALESCE(r.refund_method, 'cash') = 'cash'
            GROUP BY r.transaksi_id
        """

    def _get_returned_qty_map(self, cursor, transaksi_id):
        if not self._has_return_table(cursor):
            return {}
        cursor.execute(
            """
            SELECT dr.produk_id, SUM(COALESCE(dr.jumlah, 0)) AS qty_returned
            FROM detail_return_transaksi_penjualan dr
            JOIN return_transaksi_penjualan r ON r.id = dr.return_id
            WHERE r.transaksi_id = ?
              AND COALESCE(r.refund_method, 'cash') = 'cash'
            GROUP BY dr.produk_id
            """,
            (transaksi_id,),
        )
        return {str(row[0]): int(row[1] or 0) for row in cursor.fetchall()}

    # edited by glg
    def _extract_ppn_mode_from_diskon_log(self, diskon_log):
        text = str(diskon_log or "").strip().lower()
        if not text:
            return ""
        match = re.search(r"(?:^|;)ppn_mode=([^;]*)", text)
        if not match:
            return ""
        mode = str(match.group(1) or "").strip().lower()
        return mode if mode in {"include", "exclude"} else ""

    # edited by glg
    def _resolve_ppn_mode(self, cursor, transaksi_id):
        cursor.execute("PRAGMA table_info(transaksi)")
        cols = {str(row[1]) for row in cursor.fetchall() if row and len(row) > 1}

        if "ppn_mode" in cols:
            cursor.execute("SELECT ppn_mode FROM transaksi WHERE id = ? LIMIT 1", (transaksi_id,))
            row = cursor.fetchone()
            mode = str((row[0] if row else "") or "").strip().lower()
            if mode in {"include", "exclude"}:
                return mode

        if "diskon_log" in cols:
            cursor.execute("SELECT diskon_log FROM transaksi WHERE id = ? LIMIT 1", (transaksi_id,))
            row = cursor.fetchone()
            mode = self._extract_ppn_mode_from_diskon_log((row[0] if row else ""))
            if mode in {"include", "exclude"}:
                return mode

        return "exclude"

    def get_filtered_transaksi_list(
        self,
        user_id=None,
        tanggal=None,
        start_date=None,
        end_date=None,
        include_trashed=False,
        limit=None,
    ):
        """PATCH[FrontEndAgent|UXImprovement]: Tambah kolom kasir_nama, settlement_id, dan jumlah_item untuk UX yang lebih informatif"""
        conn = self._connect(read_only=True)
        try:
            cursor = conn.cursor()
            self._ensure_performance_indexes(cursor)
            has_return = self._has_return_table(cursor)
            if has_return:
                self._ensure_return_refund_columns(cursor)
                self._ensure_voucher_tables(cursor)
                query = render_sql_template(
                    """
                    SELECT t.id, t.nomer, t.dtime, t.customers_nama,
                           MAX(
                               0,
                               COALESCE(t.transaksi_nilai, 0)
                               - COALESCE(r.total_refund, 0)
                               - COALESCE(v.total_voucher, 0)
                           ) as total_harga,
                           t.oleh_nama as kasir_nama, t.settlement_id,
                           COALESCE(j.jumlah_item, 0) as jumlah_item,
                           COALESCE(t.trash, 0) as is_trash
                   FROM transaksi t
                   LEFT JOIN ({refund_subquery}) r ON r.transaksi_id = t.id
                   LEFT JOIN ({voucher_subquery}) v ON v.transaksi_id = t.id
                   LEFT JOIN (
                       SELECT td2.transaksi_id, COUNT(*) AS jumlah_item
                       FROM transaksi_data td2
                       LEFT JOIN (
                           SELECT r2.transaksi_id, dr2.produk_id, SUM(COALESCE(dr2.jumlah, 0)) AS qty_returned
                           FROM return_transaksi_penjualan r2
                           JOIN detail_return_transaksi_penjualan dr2 ON dr2.return_id = r2.id
                           WHERE COALESCE(r2.refund_method, 'cash') = 'cash'
                           GROUP BY r2.transaksi_id, dr2.produk_id
                       ) rr ON rr.transaksi_id = td2.transaksi_id AND rr.produk_id = td2.produk_id
                       WHERE (COALESCE(td2.produk_ord_jml, 0) - COALESCE(rr.qty_returned, 0)) > 0
                       GROUP BY td2.transaksi_id
                   ) j ON j.transaksi_id = t.id
                   WHERE t.jenis_label = 'invoice'
                """,
                    refund_subquery=self._refund_subquery(),
                    voucher_subquery=self._voucher_subquery(),
                )
            else:
                query = """
                    SELECT t.id, t.nomer, t.dtime, t.customers_nama, t.transaksi_nilai as total_harga, 
                           t.oleh_nama as kasir_nama, t.settlement_id,
                           COALESCE(j.jumlah_item, 0) as jumlah_item,
                           COALESCE(t.trash, 0) as is_trash
                    FROM transaksi t
                    LEFT JOIN (
                        SELECT transaksi_id, COUNT(*) AS jumlah_item
                        FROM transaksi_data
                        GROUP BY transaksi_id
                    ) j ON j.transaksi_id = t.id
                    WHERE t.jenis_label = 'invoice'
                """
            params = []
            if not include_trashed:
                query += " AND IFNULL(t.trash, 0) = 0"
            if user_id is not None:
                query += " AND t.oleh_id = ?"
                params.append(user_id)
            if start_date is not None and end_date is not None:
                date_range = self._build_date_range(start_date, end_date)
                if date_range:
                    query += " AND t.dtime >= ? AND t.dtime < ?"
                    params.extend(date_range)
                else:
                    query += " AND date(t.dtime) BETWEEN ? AND ?"
                    params.extend([start_date, end_date])
            elif tanggal is not None:
                day_range = self._build_day_range(tanggal)
                if day_range:
                    query += " AND t.dtime >= ? AND t.dtime < ?"
                    params.extend(day_range)
                else:
                    query += " AND date(t.dtime) = ?"
                    params.append(tanggal)
            # Pastikan tidak ada pre-order, draft, atau transaksi yang belum final
            query += " ORDER BY t.dtime DESC"
            resolved_limit = self._resolve_positive_limit(
                self._get_history_query_limit() if limit is None else limit,
                default=0,
            )
            if resolved_limit > 0:
                query += " LIMIT ?"
                params.append(int(resolved_limit))
            cursor.execute(query, params)
            return cursor.fetchall()
        finally:
            conn.close()
    def __init__(self, db_path=None):
        super().__init__()
        self.db_path = db_path or get_db_path()

    def get_tersimpan_transaksi_list(self, limit=None):
        conn = self._connect(read_only=True)
        try:
            cursor = conn.cursor()
            # Query hanya transaksi yang TERSIMPAN (pre-order), bukan invoice yang sudah final
            query = """
                SELECT id, nomer, dtime, customers_nama, transaksi_nilai as total_harga
                FROM transaksi
                WHERE jenis_label = 'simpan_transaksi'
                  AND COALESCE(trash, 0) = 0
                ORDER BY dtime DESC
            """
            params = []
            resolved_limit = self._resolve_positive_limit(
                self._get_load_transaksi_query_limit() if limit is None else limit,
                default=0,
            )
            if resolved_limit > 0:
                query += " LIMIT ?"
                params.append(int(resolved_limit))
            cursor.execute(query, params)
            return cursor.fetchall()
        finally:
            conn.close()

    def get_detail_transaksi(self, transaksi_id):
        conn = self._connect(read_only=True)
        try:
            cursor = conn.cursor()
            returned_map = self._get_returned_qty_map(cursor, transaksi_id)
            cursor.execute("""
                SELECT produk_id, produk_nama, produk_ord_hrg, produk_ord_jml, produk_ord_diskon, satuan
                FROM transaksi_data
                WHERE transaksi_id = ?
                  AND COALESCE(trash, 0) = 0
            """, (transaksi_id,))
            rows = cursor.fetchall()
            results = []
            for row in rows:
                produk_id = str(row[0])
                qty_jual = int(row[3] or 0)
                qty_returned = int(returned_map.get(produk_id, 0))
                qty_net = max(0, qty_jual - qty_returned)
                if qty_net <= 0:
                    continue
                results.append((row[0], row[1], row[2], qty_net, row[4], row[5]))
            return results
        finally:
            conn.close()

    def get_transaksi_header(self, transaksi_id):
        """PATCH[FrontEndAgent|DetailInfo]: Ambil data lengkap transaksi untuk preview informatif"""
        conn = self._connect(read_only=True)
        try:
            cursor = conn.cursor()
            # PATCH[FrontEndAgent|CompatibilityFix]: Query hanya kolom yang ada di tabel
            # PATCH[FrontEndAgent|KembalianFix]: Hitung kembalian = dibayar - total
            has_return = self._has_return_table(cursor)
            if has_return:
                self._ensure_return_refund_columns(cursor)
                self._ensure_voucher_tables(cursor)
                query = render_sql_template(
                    """
                    SELECT
                        t.customers_id,
                        t.customers_nama,
                        t.diskon_persen,
                        t.ppn_persen,
                        MAX(
                            0,
                            COALESCE(t.transaksi_nilai, 0)
                            - COALESCE(r.total_refund, 0)
                            - COALESCE(v.total_voucher, 0)
                        ) as transaksi_nilai,
                        t.dtime,
                        t.oleh_id,
                        t.oleh_nama,
                        t.pembayaran_sys as metode_pembayaran,
                        t.bank_id,
                        t.bank_nama,
                        t.settlement_id,
                        t.transaksi_dibayar as jumlah_bayar,
                        (
                            COALESCE(t.transaksi_dibayar, 0)
                            - MAX(
                                0,
                                COALESCE(t.transaksi_nilai, 0)
                                - COALESCE(r.total_refund, 0)
                                - COALESCE(v.total_voucher, 0)
                            )
                        ) as kembalian,
                        '' as approval_code,
                        t.kartu_nomer as no_kartu
                    FROM transaksi t
                    LEFT JOIN ({refund_subquery}) r ON r.transaksi_id = t.id
                    LEFT JOIN ({voucher_subquery}) v ON v.transaksi_id = t.id
                    WHERE t.id = ?
                """,
                    refund_subquery=self._refund_subquery(),
                    voucher_subquery=self._voucher_subquery(),
                )
                cursor.execute(query, (transaksi_id,))
            else:
                cursor.execute("""
                    SELECT
                        t.customers_id,
                        t.customers_nama,
                        t.diskon_persen,
                        t.ppn_persen,
                        t.transaksi_nilai,
                        t.dtime,
                        t.oleh_id,
                        t.oleh_nama,
                        t.pembayaran_sys as metode_pembayaran,
                        t.bank_id,
                        t.bank_nama,
                        t.settlement_id,
                        t.transaksi_dibayar as jumlah_bayar,
                        (COALESCE(t.transaksi_dibayar, 0) - COALESCE(t.transaksi_nilai, 0)) as kembalian,
                        '' as approval_code,
                        t.kartu_nomer as no_kartu
                    FROM transaksi t
                    WHERE t.id = ?
                """, (transaksi_id,))
            header_row = cursor.fetchone()
            if not header_row:
                return None
            ppn_mode = self._resolve_ppn_mode(cursor, transaksi_id)
            return tuple(list(header_row) + [ppn_mode])
        finally:
            conn.close()

    def get_voucher_usage_info(self, transaksi_id):
        conn = self._connect(read_only=True)
        try:
            conn.row_factory = sqlite3.Row
            cursor = conn.cursor()
            self._ensure_voucher_tables(cursor)
            cursor.execute("PRAGMA table_info(voucher_return)")
            cols = [row[1] for row in cursor.fetchall()]
            code_col = "kode" if "kode" in cols else ("kode_voucher" if "kode_voucher" in cols else None)
            if not code_col:
                return []
            query = render_sql_template(
                """
                SELECT vr.{code_col} AS kode_voucher, vu.nilai_pakai, vu.dtime
                FROM voucher_usage vu
                JOIN voucher_return vr ON vr.id = vu.voucher_id
                WHERE vu.transaksi_id = ?
                ORDER BY vu.dtime DESC
                """,
                code_col=code_col,
            )
            cursor.execute(query, (transaksi_id,))
            rows = cursor.fetchall()
            return [dict(r) for r in rows]
        finally:
            conn.close()

    def get_return_items_info(self, transaksi_id):
        conn = self._connect(read_only=True)
        try:
            conn.row_factory = sqlite3.Row
            cursor = conn.cursor()
            if not self._has_return_table(cursor):
                return []
            cursor.execute(
                """
                SELECT
                    dr.produk_nama,
                    dr.jumlah,
                    dr.subtotal,
                    r.refund_method,
                    r.tanggal_return
                FROM detail_return_transaksi_penjualan dr
                JOIN return_transaksi_penjualan r ON r.id = dr.return_id
                WHERE r.transaksi_id = ?
                ORDER BY r.tanggal_return DESC, dr.id DESC
                """,
                (transaksi_id,),
            )
            rows = cursor.fetchall()
            return [dict(r) for r in rows]
        finally:
            conn.close()

    def get_transaksi_row(self, transaksi_id):
        conn = self._connect(read_only=True)
        try:
            conn.row_factory = sqlite3.Row
            cursor = conn.cursor()
            cursor.execute("""
                SELECT
                    nomer,
                    dtime,
                    transaksi_nilai,
                    diskon_persen,
                    ppn_persen,
                    COALESCE(transaksi_bulat, transaksi_nilai, 0) AS transaksi_bulat,
                    customers_id,
                    customers_nama,
                    fulldate,
                    oleh_id,
                    oleh_nama,
                    jenis_label,
                    transaksi_jenis,
                    settlement_id
                FROM transaksi
                WHERE id = ?
            """, (transaksi_id,))
            row = cursor.fetchone()
            return dict(row) if row else None
        finally:
            conn.close()

    # edited by glg
    @staticmethod
    def _load_table_columns(cursor, table_name):
        cursor.execute(f'PRAGMA table_info("{table_name}")')
        return {
            str(row[1]).strip().lower()
            for row in cursor.fetchall()
            if row and len(row) > 1 and str(row[1]).strip()
        }

    # edited by glg
    def _ensure_delete_audit_table(self, cursor):
        cursor.execute(
            """
            CREATE TABLE IF NOT EXISTS transaksi_delete_audit (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                transaksi_id TEXT NOT NULL,
                jenis_label TEXT,
                action TEXT NOT NULL,
                mode TEXT NOT NULL,
                status TEXT NOT NULL,
                message TEXT,
                dtime_create TEXT NOT NULL DEFAULT (datetime('now'))
            )
            """
        )

    # edited by glg
    def _append_delete_audit(
        self,
        cursor,
        transaksi_id,
        jenis_label,
        action,
        mode,
        status,
        message="",
    ):
        try:
            self._ensure_delete_audit_table(cursor)
            cursor.execute(
                """
                INSERT INTO transaksi_delete_audit (
                    transaksi_id, jenis_label, action, mode, status, message
                ) VALUES (?, ?, ?, ?, ?, ?)
                """,
                (
                    str(transaksi_id or "").strip(),
                    str(jenis_label or "").strip(),
                    str(action or "").strip() or "delete",
                    str(mode or "").strip() or "unknown",
                    str(status or "").strip() or "unknown",
                    str(message or "").strip(),
                ),
            )
        except (sqlite3.Error, RuntimeError, ValueError, TypeError, AttributeError) as exc:
            self.log_warning(f"Gagal menulis audit delete transaksi: {exc}")

    # edited by glg
    def _build_delete_validation_error(
        self,
        transaksi_id,
        jenis_label,
        status,
        message,
        user_message,
    ):
        return {
            "error": {
                "transaksi_id": str(transaksi_id or "").strip(),
                "jenis_label": str(jenis_label or "").strip(),
                "status": str(status or "").strip(),
                "message": str(message or "").strip(),
                "user_message": str(user_message or "").strip() or "Validasi hapus transaksi gagal.",
            }
        }

    # edited by glg
    def _extract_delete_target_info(self, cursor, normalized_id, transaksi_cols):
        has_jenis_label = "jenis_label" in transaksi_cols
        has_transaksi_trash = "trash" in transaksi_cols
        select_parts = ["id"]
        if has_jenis_label:
            select_parts.append("jenis_label")
        if has_transaksi_trash:
            select_parts.append("COALESCE(trash, 0) AS trash_flag")
        query_select = render_sql_template(
            "SELECT {select_parts} FROM transaksi WHERE id = ? LIMIT 1",
            select_parts=", ".join(select_parts),
        )
        cursor.execute(query_select, (normalized_id,))
        target_row = cursor.fetchone()
        if not target_row:
            return None

        field_cursor = 1
        jenis_label = ""
        if has_jenis_label:
            jenis_label = str(target_row[field_cursor] or "").strip().lower()
            field_cursor += 1
        transaksi_trash = 0
        if has_transaksi_trash:
            transaksi_trash = int((target_row[field_cursor] or 0))
        return {
            "jenis_label": jenis_label,
            "transaksi_trash": int(transaksi_trash),
        }

    # edited by glg
    def _prepare_delete_payload(self, cursor, transaksi_id):
        normalized_id = str(transaksi_id or "").strip()
        if not normalized_id:
            return self._build_delete_validation_error(
                transaksi_id=transaksi_id,
                jenis_label="",
                status="invalid_id",
                message="ID transaksi kosong/tidak valid.",
                user_message="ID transaksi tidak valid.",
            )

        transaksi_cols = self._load_table_columns(cursor, "transaksi")
        detail_cols = self._load_table_columns(cursor, "transaksi_data")
        if not transaksi_cols:
            return self._build_delete_validation_error(
                transaksi_id=normalized_id,
                jenis_label="",
                status="missing_transaksi_table",
                message="Tabel transaksi tidak ditemukan.",
                user_message="Tabel transaksi tidak tersedia.",
            )

        target_info = self._extract_delete_target_info(cursor, normalized_id, transaksi_cols)
        if not target_info:
            return self._build_delete_validation_error(
                transaksi_id=normalized_id,
                jenis_label="",
                status="not_found",
                message="Data transaksi tidak ditemukan.",
                user_message="Transaksi tidak ditemukan.",
            )

        jenis_label = str(target_info.get("jenis_label") or "").strip().lower()
        transaksi_trash = int(target_info.get("transaksi_trash") or 0)
        if jenis_label and jenis_label != "simpan_transaksi":
            return self._build_delete_validation_error(
                transaksi_id=normalized_id,
                jenis_label=jenis_label,
                status="rejected_non_saved",
                message="Penolakan hapus: jenis_label bukan simpan_transaksi.",
                user_message="Hanya transaksi tersimpan (simpan_transaksi) yang boleh dihapus dari menu ini.",
            )

        can_soft_delete = "trash" in transaksi_cols and "trash" in detail_cols
        return {
            "error": None,
            "transaksi_id": normalized_id,
            "jenis_label": jenis_label,
            "transaksi_cols": transaksi_cols,
            "detail_cols": detail_cols,
            "can_soft_delete": bool(can_soft_delete),
            "already_trashed": bool(can_soft_delete and transaksi_trash == 1),
        }

    # edited by glg
    def _run_soft_delete(self, cursor, payload):
        normalized_id = str(payload.get("transaksi_id") or "").strip()
        jenis_label = str(payload.get("jenis_label") or "").strip()
        transaksi_cols = payload.get("transaksi_cols") or set()
        detail_cols = payload.get("detail_cols") or set()

        detail_set_parts = ["trash = 1"]
        if "dtime_update" in detail_cols:
            detail_set_parts.append("dtime_update = datetime('now')")
        detail_query = render_sql_template(
            """
            UPDATE transaksi_data
            SET {set_parts}
            WHERE transaksi_id = ?
              AND COALESCE(trash, 0) = 0
            """,
            set_parts=", ".join(detail_set_parts),
        )
        cursor.execute(detail_query, (normalized_id,))
        detail_affected = int(cursor.rowcount or 0)

        transaksi_set_parts = ["trash = 1"]
        if "dtime_update" in transaksi_cols:
            transaksi_set_parts.append("dtime_update = datetime('now')")
        transaksi_query = render_sql_template(
            """
            UPDATE transaksi
            SET {set_parts}
            WHERE id = ?
              AND COALESCE(trash, 0) = 0
            """,
            set_parts=", ".join(transaksi_set_parts),
        )
        cursor.execute(transaksi_query, (normalized_id,))
        header_affected = int(cursor.rowcount or 0)
        self._append_delete_audit(
            cursor,
            transaksi_id=normalized_id,
            jenis_label=jenis_label,
            action="delete",
            mode="soft",
            status="success",
            message=f"header_affected={header_affected};detail_affected={detail_affected}",
        )
        return {"ok": True, "mode": "soft", "status": "success"}

    # edited by glg
    def _run_hard_delete_fallback(self, cursor, payload):
        normalized_id = str(payload.get("transaksi_id") or "").strip()
        jenis_label = str(payload.get("jenis_label") or "").strip()
        cursor.execute("DELETE FROM transaksi_data WHERE transaksi_id = ?", (normalized_id,))
        detail_deleted = int(cursor.rowcount or 0)
        cursor.execute("DELETE FROM transaksi WHERE id = ?", (normalized_id,))
        header_deleted = int(cursor.rowcount or 0)
        self._append_delete_audit(
            cursor,
            transaksi_id=normalized_id,
            jenis_label=jenis_label,
            action="delete",
            mode="hard_fallback",
            status="success",
            message=f"header_deleted={header_deleted};detail_deleted={detail_deleted}",
        )
        return {"ok": True, "mode": "hard_fallback", "status": "success"}

    def delete_transaksi_by_id(self, transaksi_id):
        conn = self._connect(read_only=False)
        cursor = conn.cursor()
        committed = False
        try:
            # edited by glg
            # Prefer soft-delete untuk menjaga jejak audit transaksi.
            cursor.execute("BEGIN IMMEDIATE")
            payload = self._prepare_delete_payload(cursor, transaksi_id)
            error_payload = payload.get("error")
            if error_payload:
                self._append_delete_audit(
                    cursor,
                    transaksi_id=error_payload.get("transaksi_id"),
                    jenis_label=error_payload.get("jenis_label"),
                    action="delete",
                    mode="validation",
                    status=error_payload.get("status"),
                    message=error_payload.get("message"),
                )
                conn.commit()
                committed = True
                raise ValueError(str(error_payload.get("user_message") or "Validasi hapus transaksi gagal."))

            if bool(payload.get("already_trashed")):
                self._append_delete_audit(
                    cursor,
                    transaksi_id=payload.get("transaksi_id"),
                    jenis_label=payload.get("jenis_label"),
                    action="delete",
                    mode="soft",
                    status="noop_already_trashed",
                    message="Transaksi sudah berstatus trash.",
                )
                conn.commit()
                committed = True
                return {
                    "ok": True,
                    "mode": "soft",
                    "status": "noop_already_trashed",
                }

            if bool(payload.get("can_soft_delete")):
                result = self._run_soft_delete(cursor, payload)
            else:
                result = self._run_hard_delete_fallback(cursor, payload)
            conn.commit()
            committed = True
            return result
        except (sqlite3.Error, ValueError, RuntimeError, TypeError):
            if not committed:
                try:
                    conn.rollback()
                except sqlite3.Error:
                    pass
            raise
        finally:
            conn.close()
