import { expect, test, type APIResponse, type Page, type Response, type TestInfo } from "@playwright/test";
import fs from "node:fs";
import { isLoginPage, loginWithCredentials } from "../auth";
import { loadChecklist, loadIssueMetadata } from "../checklist";
import { extractDownloadRows } from "../downloadText";
import { getRuntimeQaEnv, type RuntimeQaEnv } from "../env";
import type { QaEnvironment } from "../types";

type Kind = "taxable-materials" | "vat-reports";
type NumericKey = "approvalCount" | "approvalAmount" | "cancelCount" | "cancelAmount" |
  "totalCount" | "totalTransactionAmount" | "totalSalesAmount" | "supplyAmount" | "vatAmount" | "totalCommissionAmount";
type Filters = Record<string, string>;
type ScreenRow = Record<string, string>;
interface Department { departmentCode: string; departmentName: string; departmentType: string }
interface ReportRow extends Partial<Record<NumericKey, number>> {
  vendorId: string;
  departmentCode: string;
  vendorName: string;
  corporationCode: string;
  corporationName: string;
  salesLineName: string;
  salesLineDepartments: Department[];
  businessRegistrationType: { value: string; label: string };
  businessRegistrationNumber: string | null;
  residentRegistrationNumber: string | null;
  isReported?: boolean;
  representativeName?: string;
  businessType?: string;
  businessItem?: string;
  address?: string;
  email?: string;
}
interface ListPayload { items: ReportRow[]; totalCount: number; page: number; size: number }
interface Snapshot extends ListPayload {
  kind: Kind;
  month: string;
  summary?: Record<NumericKey, number>;
  summaryStatus: number;
  summaryError?: string;
  screen: ScreenRow[];
  headers: string[];
  response: Response;
}
interface Transaction {
  transaction: {
    id: string;
    amount: number;
    status: string;
    isCancel: boolean;
    payDate: string;
    cancelDate?: string | null;
    originTransactionId?: string | null;
    originTransactionPayDate?: string | null;
  };
  settlementTarget: { settlementCommission: number };
}
interface Transactions {
  items: Transaction[];
  totals: Record<string, number>;
}
interface Inventory { month: string; data: ListPayload }

const KINDS: Kind[] = ["taxable-materials", "vat-reports"];
const MONTH = "2026-08";
const TARGET = "BP100008";
const LABELS: Record<Kind, string> = { "taxable-materials": "과세자료", "vat-reports": "부가세 신고자료" };
const NUMBERS: Record<Kind, NumericKey[]> = {
  "taxable-materials": ["approvalCount", "approvalAmount", "cancelCount", "cancelAmount", "totalCount", "totalTransactionAmount"],
  "vat-reports": ["totalSalesAmount", "supplyAmount", "vatAmount", "totalCommissionAmount"]
};
const SUMMARY_LABELS: Partial<Record<NumericKey, string>> = {
  approvalCount: "승인 건수", approvalAmount: "승인 금액", cancelCount: "취소 건수", cancelAmount: "취소 금액",
  totalCount: "총 건수", totalTransactionAmount: "총 거래금액", totalSalesAmount: "총 매출금액",
  supplyAmount: "공급가액", vatAmount: "VAT", totalCommissionAmount: "수수료 합계"
};
const FILTER_LABELS: Filters = {
  corporationCode: "법인 부서코드", corporationName: "법인명", branchOfficeCode: "영업점 고유코드",
  branchOfficeName: "영업점명", departmentCode: "가맹점 고유코드", vendorName: "가맹점명",
  registrationNumber: "사업자번호 / 주민등록번호"
};
const BUSINESS_TYPES: Filters = {
  UNSPECIFIED: "설정 안 함", NON_BUSINESS: "비사업자", INDIVIDUAL: "개인사업자", CORPORATION: "법인사업자"
};

export function runTaxVatReportConsistencySuite(options: { issueId: string; environment: QaEnvironment; retest?: boolean }): void {
  const metadata = loadIssueMetadata(options.issueId);
  const checklist = loadChecklist(options.issueId, options.environment);
  const roles = ["ADMIN", "TA"].filter((role) => !process.env.QA_ROLE || role === process.env.QA_ROLE.toUpperCase());
  const retestChecks: Record<string, number[]> = {
    ADMIN: [5, 6, 7, 9, 11, 26],
    TA: [2, 5, 6, 7, 9, 11, 22, 23, 24, 25, 26]
  };
  for (const role of roles) {
    const runtime = getRuntimeQaEnv(options.environment, "admin", role);
    test.describe(`[${options.issueId}][${options.environment}] ${metadata.subject} / [${role}] role`, () => {
      test.use({ storageState: runtime.storageState, viewport: { width: 1700, height: 1050 }, acceptDownloads: true });
      for (const [index, item] of checklist.checklist.entries()) {
        if (options.retest && !retestChecks[role].includes(index + 1)) continue;
        const correctedTransactions = options.retest && role === "ADMIN" && index === 10;
        const title = correctedTransactions
          ? "BP100002 2026-08 과세자료와 거래내역의 승인 30건·2,546,428원, 취소 15건·466,720원, 총 45건·2,079,708원 일치 확인"
          : item;
        test(`과세자료·부가세 신고자료 / ${title}`, async ({ page }, info) => {
          test.setTimeout(Number(process.env.QA_TEST_TIMEOUT_MS ?? 240_000));
          page.setDefaultTimeout(12_000);
          const qa = new TaxVatQa(page, runtime, info);
          try {
            if ((process.env.QA_SKIP_ROLES ?? "").split(",").includes(role)) blocked(`${role} 로그인 사전조건 미충족`);
            if (options.retest) qa.log(`재QA: 이전 ${role === "ADMIN" ? "실패 1건·확인 불가 5건" : "실패 5건·확인 불가 6건"}만 재실행; 원 체크리스트 ${index + 1}번`);
            if (correctedTransactions) await qa.correctedTransactionsComparison();
            else await qa.run(index + 1);
          } catch (error) {
            qa.log(`판정 근거: ${error instanceof Error ? error.message : String(error)}`);
            throw error;
          } finally {
            await qa.finish();
          }
        });
      }
    });
  }
}

class TaxVatQa {
  private readonly notes: string[] = [];
  private lastResponse?: Response;
  private imageIndex = 0;
  private permissions: string[] = [];

  constructor(private readonly page: Page, private readonly runtime: RuntimeQaEnv, private readonly info: TestInfo) {
    this.log(`권한 ${runtime.role}; 전역 기본 역할 계정 사용; DEV 읽기 전용 검증; 기준 월 ${MONTH}`);
    page.on("response", async (response) => {
      if (new URL(response.url()).pathname !== "/api/v1/auth/me") return;
      const body = await response.json().catch(() => undefined);
      this.permissions = body?.payload?.permissions ?? [];
    });
  }

  log(message: string): void { this.notes.push(message); }

  async run(check: number): Promise<void> {
    if (check === 1) {
      for (const kind of KINDS) await this.open(kind);
    } else if (check === 2) {
      for (const kind of KINDS) {
        const initial = await this.open(kind);
        this.assertSummary(initial);
        const previous = await this.search(kind, "2026-07", { departmentCode: TARGET });
        expect(previous.items.length).toBeGreaterThan(0);
        this.assertSummary(previous);
        await this.shot(`${kind}-previous-month`);
        await this.page.getByRole("button", { name: "초기화", exact: true }).last().click();
        for (const label of Object.values(FILTER_LABELS)) {
          await expect(this.page.getByRole("textbox", { name: label, exact: true }).last()).toHaveValue("");
        }
        const restored = await this.search(kind);
        this.assertSummary(restored);
        expect(comparable(restored)).toEqual(comparable(initial));
        await this.shot(kind);
        this.log(`${LABELS[kind]}: 7월 검색 ${previous.totalCount}행 -> 초기화 후 8월 ${restored.totalCount}행, 합계 복구`);
      }
    } else if (check === 3 || check === 4) {
      const tax = await this.open("taxable-materials");
      for (const kind of KINDS) {
        const initial = kind === "taxable-materials" ? tax : await this.open(kind);
        const inputs: Filters[] = check === 3 ? [
          { corporationCode: "BP100006" }, { corporationName: "박재민" },
          { branchOfficeCode: "BP100007" }, { branchOfficeName: "박재민_영업" },
          { departmentCode: TARGET }, { vendorName: "박재민_가맹" }
        ] : [
          { registrationNumber: "012-34-56785" }, { businessRegistrationType: "CORPORATION" },
          { businessRegistrationType: "UNSPECIFIED" }, { isReported: "true" }, { isReported: "false" },
          { corporationCode: "BP100006", branchOfficeCode: "BP100007", departmentCode: TARGET,
            vendorName: "박재민_가맹", registrationNumber: "012-34-56785", businessRegistrationType: "CORPORATION", isReported: "false" },
          { departmentCode: TARGET, vendorName: "QA4046_NO_MATCH" }
        ];
        const applicableInputs = kind === "vat-reports"
          ? inputs.map(({ isReported: _isReported, ...rest }) => rest).filter((filters) => Object.keys(filters).length > 0)
          : inputs;
        if (check === 4 && kind === "vat-reports") this.log("부가세 신고자료에는 신고 여부 필터가 없습니다. 원본 이슈의 신고/비신고 항목은 과세자료에서 검증했습니다.");
        for (const filters of applicableInputs) {
          const actual = await this.search(kind, MONTH, filters);
          const expectedCodes = tax.items.filter((row) => matches(row, filters)).map((row) => row.departmentCode).sort();
          expect(actual.items.map((row) => row.departmentCode).sort(), `검색 조건 ${safeFilters(filters)}`).toEqual(expectedCodes);
          this.log(`${LABELS[kind]} ${safeFilters(filters)}: ${actual.totalCount}행, 기준 목록과 일치`);
        }
        expect(initial.totalCount).toBeGreaterThan(0);
      }
    } else if (check === 5 || check === 26) {
      const initial = await this.open("taxable-materials");
      const inventory = await this.inventory(initial);
      const paged = inventory.find((item) => item.data.totalCount > item.data.size);
      if (!paged) {
        this.log(`현재 목록 행 번호: ${initial.screen.map((row) => row.rowNumber).join(", ")}`);
        if (initial.summary) this.assertSummary(initial);
        blocked(`2025-01~2026-08 조회에서 월별 가맹점이 한 페이지(20건) 이내입니다. 현재 건수·행 번호는 확인했으나 다음 목록 로딩 조건이 없습니다. 합계는 ${initial.summary ? "현재 목록 기준 일치" : `HTTP ${initial.summaryStatus} 오류로 별도 실패 항목에 기록`}했습니다.`);
      }
      for (const kind of KINDS) await this.pagination(kind, paged.month);
    } else if (check === 6 || check === 7) {
      const initial = await this.open("taxable-materials");
      const inventory = await this.inventory(initial);
      const missing: string[] = [];
      if (check === 6) {
        for (const [type, label] of Object.entries(BUSINESS_TYPES)) {
          const sample = inventory.find((entry) => entry.data.items.some((row) => row.businessRegistrationType.value === type || row.businessRegistrationType.label === label));
          if (!sample) { missing.push(label); continue; }
          const target = sample.data.items.find((row) => row.businessRegistrationType.value === type || row.businessRegistrationType.label === label)!;
          for (const kind of KINDS) {
            await this.open(kind);
            const data = await this.search(kind, sample.month, { departmentCode: target.departmentCode });
            requireRow(data, target.departmentCode);
            expect(data.screen.some((row) => row.businessRegistrationType === label)).toBe(true);
            await this.shot(kind);
          }
        }
      } else {
        const samples = [
          { label: "비사업자 주민등록번호 표시", matches: (row: ReportRow) => row.businessRegistrationType.label === "비사업자" && !!row.residentRegistrationNumber },
          { label: "사업자번호 표시", matches: (row: ReportRow) => row.businessRegistrationType.label !== "비사업자" && !!row.businessRegistrationNumber },
          { label: "등록번호 빈값 대시 표시", matches: (row: ReportRow) => !(row.businessRegistrationType.label === "비사업자" ? row.residentRegistrationNumber : row.businessRegistrationNumber) }
        ];
        for (const sample of samples) {
          const entry = inventory.find((item) => item.data.items.some(sample.matches));
          if (!entry) { missing.push(sample.label); continue; }
          const target = entry.data.items.find(sample.matches)!;
          for (const kind of KINDS) {
            await this.open(kind);
            requireRow(await this.search(kind, entry.month, { departmentCode: target.departmentCode }), target.departmentCode);
            await this.shot(kind);
            this.log(`${LABELS[kind]} ${entry.month} ${target.departmentCode}: ${sample.label} 화면 대조 완료 (등록번호 마스킹)`);
          }
        }
      }
      if (missing.length) blocked(`화면에 존재하는 사업자 유형·등록번호는 대조했습니다. 2025-01~2026-08 자료에 다음 경계 데이터가 없습니다: ${missing.join(", ")}`);
    } else if (check === 8 || check === 9) {
      const tax = await this.open("taxable-materials");
      const target = requireRow(tax, TARGET);
      const lines = await this.salesLines(target);
      expect(lines.length).toBeGreaterThan(1);
      const first = lines[0].departments.map((d) => `${d.code}:${d.name}`);
      expect(target.salesLineDepartments.map((d) => `${d.departmentCode}:${d.departmentName}`)).toEqual(first);
      this.log(`${TARGET}: 현재 영업라인 ${lines.length}개 중 첫 번째 라인과 보고서의 부서 순서 일치`);
      for (const kind of KINDS) {
        const data = await this.open(kind);
        for (const row of data.items) {
          if (!row.salesLineDepartments.length) continue;
          expect(row.salesLineDepartments[0].departmentType).toBe("CO");
          expect(row.salesLineDepartments.at(-1)?.departmentType).toBe("VE");
          expect(row.salesLineDepartments.slice(1, -1).every((d) => d.departmentType === "BO")).toBe(true);
          const screen = data.screen.find((r) => r.departmentCode === row.departmentCode)!;
          expect(compact(screen.salesLineName)).toBe(compact(row.salesLineDepartments.flatMap((d) => [d.departmentCode, d.departmentName]).join(" ")));
        }
      }
      if (check === 9) {
        const inventory = await this.inventory(tax);
        const all = inventory.flatMap((entry) => entry.data.items);
        this.log(`직가맹 데이터 ${all.filter((row) => row.salesLineDepartments.length === 2).length}행 확인`);
        const missing: string[] = [];
        for (const length of [2, 0]) {
          const entry = inventory.find((item) => item.data.items.some((row) => row.salesLineDepartments.length === length));
          if (!entry) { missing.push(length === 2 ? "직가맹" : "영업라인 미등록"); continue; }
          const sample = entry.data.items.find((row) => row.salesLineDepartments.length === length)!;
          for (const kind of KINDS) {
            await this.open(kind);
            const data = await this.search(kind, entry.month, { departmentCode: sample.departmentCode });
            const row = requireRow(data, sample.departmentCode);
            const displayed = data.screen.find((item) => item.departmentCode === sample.departmentCode)!.salesLineName;
            if (length === 0) expect(["", "-"]).toContain(displayed);
            else expect(compact(displayed)).toBe(compact(row.salesLineDepartments.flatMap((d) => [d.departmentCode, d.departmentName]).join(" ")));
            await this.shot(kind);
          }
        }
        if (missing.length) blocked(`복수 영업라인의 첫 라인 표시 및 존재하는 직가맹은 확인했습니다. 다음 월 집계 자료가 없어 해당 표시를 검증하지 못했습니다: ${missing.join(", ")}`);
      }
    } else if ([10, 11, 12, 15, 16].includes(check)) {
      await this.transactionsComparison(check);
    } else if (check === 13 || check === 14) {
      const data = await this.open("taxable-materials");
      for (const row of data.screen) {
        if (check === 13) expect(amount(row.totalCount)).toBe(amount(row.approvalCount) + amount(row.cancelCount));
        else expect(amount(row.totalTransactionAmount)).toBe(amount(row.approvalAmount) - amount(row.cancelAmount));
      }
      this.log(`${data.totalCount}개 가맹점의 화면 ${check === 13 ? "승인+취소 건수" : "승인-취소 금액"} 산식 일치`);
    } else if ([17, 18, 19, 24].includes(check)) {
      let data = await this.open("vat-reports");
      if (check === 24) {
        this.assertSummary(data);
        const distinguishesRounding = (items: ReportRow[]) => items.every((row) => requiredNumber(row.totalCommissionAmount) >= 0) &&
          items.reduce((sum, row) => sum + requiredNumber(row.vatAmount), 0) !==
          Math.floor(items.reduce((sum, row) => sum + requiredNumber(row.totalCommissionAmount), 0) * 0.090909);
        if (!distinguishesRounding(data.items)) {
          const inventory = await this.inventory(data);
          const sample = inventory.find((entry) => distinguishesRounding(entry.data.items));
          if (!sample) blocked("가맹점별 공급가액·VAT 합계는 일치하지만 2025-01~2026-08의 현재 권한 자료에서 양수 수수료의 합산 재계산값과 차이가 나는 월이 없어 계산 방식 차이를 구별할 수 없습니다.");
          data = await this.search("vat-reports", sample.month);
          await this.shot("vat-reports");
          this.log(`VAT 합산 방식 구별을 위해 ${sample.month}의 ${data.totalCount}개 가맹점을 화면에서 재조회`);
        }
      }
      for (const row of data.items) {
        const fee = requiredNumber(row.totalCommissionAmount);
        const vat = Math.floor(fee * 0.090909);
        expect(row.vatAmount, `${row.departmentCode} floor(수수료 * 0.090909)`).toBe(vat);
        expect(row.supplyAmount).toBeCloseTo(fee - vat, 5);
        expect(requiredNumber(row.supplyAmount) + requiredNumber(row.vatAmount)).toBeCloseTo(fee, 5);
        this.log(`${row.departmentCode}: 수수료 ${fee}, VAT ${vat}, 공급가액 ${row.supplyAmount}`);
      }
      if (check === 19) {
        const sample = data.items.find((row) => Math.floor(requiredNumber(row.totalSalesAmount) * 0.090909) !== row.vatAmount);
        expect(sample, "총매출 기반 오계산과 구별 가능한 가맹점").toBeTruthy();
        this.log(`${sample!.departmentCode}: 총매출 기반 VAT와 실제 VAT가 다르며 수수료 기반 산식과 일치`);
      }
      if (check === 24) {
        this.assertSummary(data);
        const aggregateVat = Math.floor(data.items.reduce((sum, row) => sum + requiredNumber(row.totalCommissionAmount), 0) * 0.090909);
        const sumVat = data.items.reduce((sum, row) => sum + requiredNumber(row.vatAmount), 0);
        this.log(`${data.month} 가맹점별 VAT 합 ${sumVat}; 합산 수수료로 재계산 시 ${aggregateVat}; 화면 ${this.requireSummary(data).vatAmount}`);
        if (sumVat === aggregateVat) blocked("가맹점별 공급가액·VAT 합계는 일치하지만 현재 권한의 자료는 합산 수수료 재계산값도 같아 계산 방식 차이를 구별할 수 없습니다.");
      }
    } else if (check === 20) {
      const data = await this.open("vat-reports");
      const row = requireRow(data, TARGET);
      const business = await this.vendorDetails(row);
      expect(row.representativeName).toBe(business.person);
      expect(row.businessType).toBe(business.businessType);
      expect(row.businessItem).toBe(business.businessItem);
      expect(row.address).toBe(`(${business.address.postalCode}) ${business.address.address1} ${business.address.address2}`.trim());
      this.log(`${TARGET}: 가맹점 수정 화면 원본과 대표자·업태·종목·주소 일치, 우편번호 괄호 표시 확인`);
    } else if (check === 21) {
      const data = await this.open("vat-reports");
      for (const key of ["businessType", "businessItem", "email"] as const) {
        const empty = data.items.filter((row) => !row[key]);
        expect(empty.length, `${key} 빈값 검증 데이터`).toBeGreaterThan(0);
        for (const row of empty) expect(data.screen.find((r) => r.departmentCode === row.departmentCode)?.[key]).toBe("-");
        this.log(`${key}: 빈값 ${empty.length}개 가맹점에서 '-' 표시`);
      }
    } else if (check === 22 || check === 23) {
      const kind = check === 22 ? "taxable-materials" : "vat-reports";
      this.assertSummary(await this.open(kind));
      this.assertSummary(await this.search(kind, MONTH, { corporationCode: "BP100006" }));
      await this.shot(kind);
    } else if (check === 25) {
      for (const kind of KINDS) {
        await this.open(kind);
        const empty = await this.search(kind, MONTH, { vendorName: "QA4046_NONEXISTENT_VENDOR" });
        expect(empty.totalCount).toBe(0);
        expect(empty.screen).toEqual([]);
        const summary = this.requireSummary(empty);
        for (const key of NUMBERS[kind]) expect(summary[key]).toBe(0);
        this.log(`${LABELS[kind]} 존재하지 않는 가맹점 검색: 목록 0행; 합계 ${JSON.stringify(summary)}`);
        await this.shot(`${kind}-empty`);
      }
    } else if (check === 27 || check === 28) {
      for (const kind of KINDS) {
        await this.open(kind);
        const data = await this.search(kind, MONTH, { corporationCode: "BP100006", vendorName: "박재민_가맹" });
        await this.excel(data);
      }
    } else {
      throw new Error(`미구현 체크리스트 ${check}`);
    }
  }

  async correctedTransactionsComparison(): Promise<void> {
    const code = "BP100002";
    const expected = {
      approvalCount: 30, approvalAmount: 2_546_428, cancelCount: 15, cancelAmount: 466_720,
      totalCount: 45, totalTransactionAmount: 2_079_708
    };
    await this.open("taxable-materials");
    const data = await this.search("taxable-materials", MONTH, { departmentCode: code });
    expect(data.totalCount).toBe(1);
    const row = requireRow(data, code);
    for (const [key, value] of Object.entries(expected)) expect(row[key as NumericKey], `${code} 과세자료 ${key}`).toBe(value);
    this.assertSummary(data);
    await this.shot("taxable-materials");
    this.log(`${code} ${MONTH} 과세자료: ${JSON.stringify(expected)}`);

    const history = await this.transactions(code, MONTH);
    const approvals = history.items.filter((item) => !item.transaction.isCancel);
    const cancellations = history.items.filter((item) => item.transaction.isCancel);
    const actual = {
      approvalCount: approvals.length,
      approvalAmount: approvals.reduce((sum, item) => sum + item.transaction.amount, 0),
      cancelCount: cancellations.length,
      cancelAmount: -cancellations.reduce((sum, item) => sum + item.transaction.amount, 0),
      totalCount: history.items.length,
      totalTransactionAmount: history.items.reduce((sum, item) => sum + item.transaction.amount, 0)
    };
    this.log(`${code} ${MONTH} 거래내역 전체 행 재계산: ${JSON.stringify(actual)}`);
    this.log(`거래 통계 응답: ${JSON.stringify(history.totals)}`);
    for (const key of ["approvalCount", "approvalAmount", "cancelCount", "totalCount"] as const) expect(history.totals[key]).toBe(actual[key]);
    expect(Math.abs(history.totals.cancelAmount)).toBe(actual.cancelAmount);

    const labels = {
      approvalCount: ["건수", "결제건수"], cancelCount: ["건수", "취소건수"], totalCount: ["건수", "전체건수"],
      approvalAmount: ["거래", "결제금액"], cancelAmount: ["거래", "취소금액"], totalTransactionAmount: ["거래", "거래금액"]
    };
    for (const [key, [group, label]] of Object.entries(labels)) {
      const value = this.page.getByRole("heading", { name: group, exact: true }).last().locator("..")
        .getByText(label, { exact: true }).locator("..");
      await expect.poll(async () => amount(await value.innerText()), { timeout: 15_000 })
        .toBe(actual[key as keyof typeof actual]);
    }
    const statistics = this.page.getByRole("heading", { name: "거래 통계 보기", exact: true }).last().locator("../..");
    await this.info.attach(`${String(++this.imageIndex).padStart(2, "0")}-taxable-materials-transaction-statistics.png`, {
      body: await statistics.screenshot({ animations: "disabled" }), contentType: "image/png"
    });
    expect(actual, `${code} ${MONTH} 과세자료와 실제 거래 통계 비교`).toEqual(expected);
    this.log("과세자료 목록·합계, 거래내역 전체 행 재계산, 거래 통계의 화면 표시값 모두 정정된 기대 금액과 일치");
  }

  private url(route: string): string {
    if (!this.runtime.baseUrl) blocked("개발계 baseUrl 설정이 없습니다.");
    const url = new URL(route, this.runtime.baseUrl);
    if (this.runtime.tenantId) url.searchParams.set("tenantId", this.runtime.tenantId);
    return url.toString();
  }

  private async navigate(route: string): Promise<void> {
    await this.page.goto(this.url(route), { waitUntil: "domcontentloaded", timeout: 45_000 });
    if (await isLoginPage(this.page, this.runtime)) {
      await loginWithCredentials(this.page, this.runtime);
      await this.page.goto(this.url(route), { waitUntil: "domcontentloaded", timeout: 45_000 });
    }
    expect(new URL(this.page.url()).pathname).toBe(route);
    await expect(this.page.getByRole("button", { name: "검색", exact: true }).last()).toBeVisible({ timeout: 25_000 });
  }

  private async open(kind: Kind): Promise<Snapshot> {
    await this.navigate(`/tax-reports/${kind}`);
    const data = await this.search(kind);
    this.log(`${LABELS[kind]} ${data.month}: ${data.totalCount}개 가맹점 / 합계 ${data.summary ? JSON.stringify(data.summary) : `HTTP ${data.summaryStatus} ${data.summaryError}`}`);
    this.log(`응답 사용자 권한: ${this.permissions.filter((permission) => /MENU_TAX_REPORT|MENU_VAT_REPORT/.test(permission)).join(", ")}`);
    await this.shot(kind);
    return data;
  }

  private async search(kind: Kind, month = MONTH, filters: Filters = {}): Promise<Snapshot> {
    const details = this.page.getByRole("button", { name: "상세검색", exact: true }).last();
    if (await details.isVisible()) await details.click();
    await this.page.getByRole("button", { name: "초기화", exact: true }).last().click();
    await this.page.locator('input[type="month"]').last().fill(month);
    for (const [key, value] of Object.entries(filters)) {
      if (FILTER_LABELS[key]) await this.page.getByRole("textbox", { name: FILTER_LABELS[key], exact: true }).last().fill(value);
      else {
        const label = key === "isReported" ? "신고 여부" : "사업자 구분";
        const option = key === "isReported" ? value === "true" ? "신고" : "비신고" : BUSINESS_TYPES[value];
        await this.page.getByRole("combobox", { name: label, exact: true }).last().click();
        await this.page.getByRole("option", { name: option, exact: true }).click();
        await this.page.keyboard.press("Escape");
      }
    }
    const sameQuery = (response: Response, suffix = "") => {
      const url = new URL(response.url());
      return response.request().method() === "GET" && url.pathname === `/api/v1/tax-reports/${kind}${suffix}` &&
        url.searchParams.get("yearMonth") === month && Object.entries(filters).every(([key, value]) => url.searchParams.get(key) === value);
    };
    const listPromise = this.page.waitForResponse((r) => sameQuery(r), { timeout: 30_000 });
    const summaryPromise = this.page.waitForResponse((r) => sameQuery(r, "/summary"), { timeout: 30_000 });
    await this.page.getByRole("button", { name: "검색", exact: true }).last().click();
    const response = await listPromise;
    const data = await payload<ListPayload>(response);
    const summaryResponse = await summaryPromise;
    const summaryStatus = summaryResponse.status();
    const summaryBody = await summaryResponse.json();
    const summary = summaryResponse.ok() ? await payload<Record<NumericKey, number>>(summaryResponse) : undefined;
    const summaryError: string | undefined = summaryBody.result?.message;
    if (!summary) this.log(`${LABELS[kind]} 합계 오류: HTTP ${summaryStatus}, ${summaryError}. 목록 비교와 분리하여 합계 검증 항목에서 판정합니다.`);
    this.lastResponse = response;
    await expect.poll(async () => (await this.readRows()).map((row) => row.departmentCode).sort(), { timeout: 15_000 })
      .toEqual(data.items.map((row) => row.departmentCode).sort());
    if (summary) {
      for (const key of NUMBERS[kind]) {
        await expect.poll(() => this.summaryValue(key), { timeout: 10_000 }).toBe(Math.round(requiredNumber(summary[key])));
      }
    }
    const screen = await this.readRows();
    const headers = await this.page.getByRole("grid").last().getByRole("columnheader").allTextContents();
    const snapshot = { ...data, kind, month, summary, summaryStatus, summaryError, screen, headers, response };
    this.assertRows(snapshot);
    return snapshot;
  }

  private async readRows(): Promise<ScreenRow[]> {
    return this.page.getByRole("grid").last().locator('[role="row"][data-id]').evaluateAll((rows) => rows.map((row) =>
      Object.fromEntries(Array.from(row.querySelectorAll<HTMLElement>('[role="gridcell"][data-field]'))
        .map((cell) => [cell.dataset.field!, cell.innerText.trim()]))).filter((row) => !!row.departmentCode));
  }

  private async summaryValue(key: NumericKey): Promise<number> {
    const label = SUMMARY_LABELS[key]!;
    const text = await this.page.getByText(new RegExp(`^${label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*:`)).last().innerText();
    return amount(text.slice(text.indexOf(":") + 1));
  }

  private assertRows(data: Snapshot): void {
    expect(data.screen.length).toBe(data.items.length);
    for (const row of data.items) {
      const screen = data.screen.find((item) => item.departmentCode === row.departmentCode)!;
      expect(screen.vendorName).toBe(row.vendorName);
      expect(screen.corporationCode).toBe(row.corporationCode || "-");
      expect(screen.businessRegistrationType).toBe(row.businessRegistrationType.label);
      const registration = row.businessRegistrationType.label === "비사업자" ? row.residentRegistrationNumber : row.businessRegistrationNumber;
      expect(screen.registrationNumber).toBe(registration || "-");
      for (const key of NUMBERS[data.kind]) expect(amount(screen[key]), `${row.departmentCode} 화면 ${key}`).toBe(Math.round(requiredNumber(row[key])));
      if (data.kind === "vat-reports") {
        for (const key of ["representativeName", "businessType", "businessItem", "email", "address"] as const) expect(screen[key]).toBe(row[key] || "-");
      }
    }
  }

  private assertSummary(data: Snapshot): void {
    const summary = this.requireSummary(data);
    expect(data.items.length, "합계를 비교하기 전에 검색 결과 전체 행이 있어야 합니다.").toBe(data.totalCount);
    for (const key of NUMBERS[data.kind]) {
      const sum = data.items.reduce((total, row) => total + requiredNumber(row[key]), 0);
      expect(summary[key], `${LABELS[data.kind]} ${key} 전체 행 합계`).toBeCloseTo(sum, 5);
      const screenSum = data.screen.reduce((total, row) => total + amount(row[key]), 0);
      expect(Math.round(summary[key]), `${key} 표시된 행 합계`).toBe(screenSum);
    }
    this.log(`${LABELS[data.kind]} ${data.month} ${data.totalCount}개 전체 행 합계와 화면 요약 일치: ${JSON.stringify(summary)}`);
    for (const row of data.items) this.log(`${row.departmentCode} 행 금액: ${JSON.stringify(Object.fromEntries(NUMBERS[data.kind].map((key) => [key, row[key]])))}`);
  }

  private requireSummary(data: Snapshot): Record<NumericKey, number> {
    if (!data.summary) {
      throw new Error(`${this.runtime.role} ${LABELS[data.kind]} 목록 ${data.totalCount}행은 조회되지만 합계 API가 HTTP ${data.summaryStatus} '${data.summaryError}'를 반환합니다. 메뉴 권한: ${this.permissions.filter((permission) => /MENU_TAX_REPORT|MENU_VAT_REPORT/.test(permission)).join(", ")}`);
    }
    return data.summary;
  }

  private async get<T>(url: string, reference = this.lastResponse): Promise<T> {
    if (!reference) throw new Error("실제 화면 조회 요청이 먼저 필요합니다.");
    const headers = Object.fromEntries(Object.entries(await reference.request().allHeaders()).filter(([key]) =>
      !key.startsWith(":") && !["accept-encoding", "content-length", "host"].includes(key)));
    return payload<T>(await this.page.context().request.get(url, { headers, timeout: 20_000 }));
  }

  private async inventory(initial: Snapshot): Promise<Inventory[]> {
    const inventory: Inventory[] = [];
    for (const year of [2025, 2026]) {
      for (let month = 1; month <= (year === 2026 ? 8 : 12); month++) {
        const value = `${year}-${String(month).padStart(2, "0")}`;
        const url = new URL(initial.response.url());
        url.search = new URLSearchParams({ yearMonth: value, size: "20", page: "0" }).toString();
        const data = await this.get<ListPayload>(url.toString(), initial.response);
        for (let page = 1; data.items.length < data.totalCount; page++) {
          url.searchParams.set("page", String(page));
          const next = await this.get<ListPayload>(url.toString(), initial.response);
          expect(next.items.length, `${value} 다음 페이지 데이터`).toBeGreaterThan(0);
          data.items.push(...next.items);
        }
        expect(new Set(data.items.map((row) => row.departmentCode)).size).toBe(data.totalCount);
        inventory.push({ month: value, data });
      }
    }
    this.log(`월별 사전 데이터 조회: ${inventory.map((entry) => `${entry.month}=${entry.data.totalCount}`).join(", ")}`);
    return inventory;
  }

  private async pagination(kind: Kind, month: string): Promise<void> {
    await this.open(kind);
    const initial = await this.search(kind, month);
    const summary = this.requireSummary(initial);
    const items = [...initial.items];
    const seen = new Set(items.map((row) => row.departmentCode));
    for (let next = 1; items.length < initial.totalCount; next++) {
      const responsePromise = this.page.waitForResponse((r) => new URL(r.url()).pathname === `/api/v1/tax-reports/${kind}` &&
        new URL(r.url()).searchParams.get("page") === String(next), { timeout: 20_000 });
      await this.page.getByRole("grid").last().locator(".MuiDataGrid-virtualScroller").evaluate((e) => { e.scrollTop = e.scrollHeight; });
      const data = await payload<ListPayload>(await responsePromise);
      expect(data.items.length).toBeGreaterThan(0);
      for (const row of data.items) { expect(seen.has(row.departmentCode)).toBe(false); seen.add(row.departmentCode); }
      items.push(...data.items);
      for (const key of NUMBERS[kind]) expect(await this.summaryValue(key)).toBe(Math.round(summary[key]));
    }
    expect(seen.size).toBe(initial.totalCount);
    const numbers = (await this.readRows()).map((row) => amount(row.rowNumber));
    expect(new Set(numbers).size).toBe(numbers.length);
    expect(numbers.every((value) => value >= 1 && value <= initial.totalCount)).toBe(true);
    this.log(`${LABELS[kind]} ${month} ${seen.size}행 페이지 중복·누락 없음, 합계 유지`);
  }

  private async transactionsComparison(check: number): Promise<void> {
    const kind: Kind = check >= 15 ? "vat-reports" : "taxable-materials";
    const data = await this.open(kind);
    const target = requireRow(data, TARGET);
    const trade = await this.transactions(TARGET, MONTH);
    if (check === 10) {
      expect(target.approvalCount).toBe(trade.totals.approvalCount);
      expect(target.approvalAmount).toBe(trade.totals.approvalAmount);
    } else if (check === 11) {
      const candidates = data.items.filter((row) => requiredNumber(row.cancelCount) > 0).sort((a, b) => requiredNumber(b.cancelCount) - requiredNumber(a.cancelCount));
      let partial = false;
      for (const candidate of candidates) {
        const history = candidate.departmentCode === TARGET ? trade : await this.transactions(candidate.departmentCode, MONTH);
        const cancellations = history.items.filter((item) => item.transaction.isCancel);
        const cancellationAmount = -cancellations.reduce((total, item) => total + item.transaction.amount, 0);
        const comparison = `${candidate.departmentCode} ${MONTH}: 과세자료 취소 ${candidate.cancelCount}건/${candidate.cancelAmount}원, 동일 가맹점 거래내역 취소 ${cancellations.length}건/${cancellationAmount}원`;
        this.log(comparison);
        expect(candidate.cancelCount, comparison).toBe(cancellations.length);
        expect(candidate.cancelAmount, comparison).toBe(cancellationAmount);
        expect(candidate.cancelAmount).toBeGreaterThan(0);
        const counts = new Map<string, number>();
        for (const item of cancellations) {
          const original = item.transaction.originTransactionId;
          if (original) counts.set(original, (counts.get(original) ?? 0) + 1);
        }
        const repeated = [...counts.values()].filter((value) => value > 1);
        this.log(`${candidate.departmentCode}: 취소 이벤트 ${cancellations.length}, 양수 취소합 ${candidate.cancelAmount}, 같은 원승인 반복취소 그룹 ${repeated.join(",") || "없음"}`);
        partial ||= repeated.length > 0;
      }
      if (!partial) {
        const inventory = await this.inventory(data);
        const codes = [...new Set(inventory.flatMap((entry) => entry.data.items.filter((row) => requiredNumber(row.cancelCount) > 0).map((row) => row.departmentCode)))];
        for (const code of codes) {
          const history = await this.transactions(code, "2025-01", MONTH);
          const groups = new Map<string, Transaction[]>();
          for (const item of history.items.filter((item) => item.transaction.isCancel && item.transaction.originTransactionId)) {
            const key = item.transaction.originTransactionId!;
            groups.set(key, [...(groups.get(key) ?? []), item]);
          }
          const repeated = [...groups.values()].find((items) => items.length > 1);
          this.log(`${code} 2025-01~${MONTH}: 취소 ${history.items.filter((item) => item.transaction.isCancel).length}건, 반복 원승인 그룹 ${[...groups.values()].filter((items) => items.length > 1).length}개`);
          if (!repeated) continue;
          const month = repeated[0].transaction.payDate.slice(0, 7);
          const monthly = await this.transactions(code, month);
          await this.open("taxable-materials");
          const report = await this.search("taxable-materials", month, { departmentCode: code });
          const row = requireRow(report, code);
          expect(row.cancelCount).toBe(monthly.totals.cancelCount);
          expect(row.cancelAmount).toBe(Math.abs(monthly.totals.cancelAmount));
          await this.shot("taxable-materials");
          this.log(`${code} ${month}: 반복 부분취소 원승인 그룹 ${repeated.length}건 확인; 과세자료 취소 ${row.cancelCount}건/${row.cancelAmount}원과 거래내역 일치`);
          partial = true;
          break;
        }
      }
      if (!partial) blocked("현재 권한의 8월 취소 발생 횟수·금액은 거래내역과 일치합니다. 2025-01~2026-08의 조회 가능한 가맹점 거래를 다시 확인해도 동일 원승인의 반복 부분취소 데이터가 없어 이 하위 조건은 미검증입니다.");
    } else if (check === 12) {
      const cross = trade.items.filter((item) => item.transaction.isCancel && item.transaction.originTransactionPayDate &&
        item.transaction.originTransactionPayDate.slice(0, 7) !== MONTH);
      if (!cross.length) blocked("원승인 월과 취소 월이 다른 거래 자료가 없습니다.");
      expect(target.cancelCount).toBe(trade.totals.cancelCount);
      expect(target.cancelAmount).toBe(Math.abs(trade.totals.cancelAmount));
      for (const item of cross) {
        expect(item.transaction.payDate.slice(0, 7)).toBe(MONTH);
        this.log(`교차월 취소: 원승인 ${item.transaction.originTransactionPayDate}, 취소 ${item.transaction.payDate}, 금액 ${item.transaction.amount}`);
      }
      const originalMonth = cross[0].transaction.originTransactionPayDate!.slice(0, 7);
      const oldTrade = await this.transactions(TARGET, originalMonth);
      await this.open("taxable-materials");
      const oldTax = requireRow(await this.search("taxable-materials", originalMonth, { departmentCode: TARGET }), TARGET);
      expect(oldTax.cancelCount).toBe(oldTrade.totals.cancelCount);
      expect(oldTax.cancelAmount).toBe(Math.abs(oldTrade.totals.cancelAmount));
      this.log(`${originalMonth} 취소 ${oldTax.cancelCount}건 / ${MONTH} 취소 ${target.cancelCount}건: 실제 발생 월 거래내역과 각각 일치`);
    } else if (check === 15) {
      expect(target.totalSalesAmount).toBe(trade.totals.approvalAmount + trade.totals.cancelAmount);
    } else {
      const fee = trade.items.reduce((sum, row) => sum + requiredNumber(row.settlementTarget?.settlementCommission), 0);
      expect(target.totalCommissionAmount).toBeCloseTo(fee, 5);
      this.log(`${TARGET} 월별 승인·취소 ${trade.items.length}개 거래의 정산수수료 합 ${fee}, 부가세 신고자료 ${target.totalCommissionAmount}`);
    }
    this.log(`${TARGET} ${MONTH} 거래 통계: 승인 ${trade.totals.approvalCount}건/${trade.totals.approvalAmount}, 취소 ${trade.totals.cancelCount}건/${trade.totals.cancelAmount}`);
  }

  private async transactions(code: string, month: string, throughMonth = month): Promise<Transactions> {
    const initial = this.page.waitForResponse((r) => new URL(r.url()).pathname === "/api/v2/transactions" &&
      !new URL(r.url()).searchParams.has("cursor"), { timeout: 25_000 });
    await this.navigate("/trade-history-v2");
    await (await initial).finished();
    const dates = this.page.locator('input[type="date"]');
    const from = `${month}-01`;
    const lastDay = new Date(Number(throughMonth.slice(0, 4)), Number(throughMonth.slice(5)), 0).getDate();
    const to = `${throughMonth}-${lastDay}`;
    const codeInput = this.page.getByRole("textbox", { name: "고유번호", exact: true }).last();
    // Reapply only the search precondition if asynchronous filter initialization clears it.
    await expect(async () => {
      await dates.nth((await dates.count()) - 2).fill(from);
      await dates.last().fill(to);
      await this.page.waitForTimeout(600);
      await codeInput.fill(code);
      await codeInput.blur();
      await this.page.waitForTimeout(800);
      await expect(dates.nth((await dates.count()) - 2)).toHaveValue(from, { timeout: 1000 });
      await expect(dates.last()).toHaveValue(to, { timeout: 1000 });
      await expect(codeInput).toHaveValue(code, { timeout: 1000 });
    }).toPass({ timeout: 20_000, intervals: [500, 1000, 1500] });
    const match = (r: Response, suffix: string) => new URL(r.url()).pathname === `/api/v2/transactions${suffix}` &&
      new URL(r.url()).searchParams.get("departmentCode") === code && new URL(r.url()).searchParams.get("from") === `${month}-01`;
    const listPromise = this.page.waitForResponse((r) => match(r, ""), { timeout: 30_000 });
    const totalPromise = this.page.waitForResponse((r) => match(r, "/total"), { timeout: 30_000 });
    await this.page.getByRole("button", { name: "검색", exact: true }).last().click();
    const response = await listPromise;
    let data = await payload<{ items: Transaction[]; nextCursor?: string }>(response);
    const totals = await payload<Record<string, number>>(await totalPromise);
    const items = [...data.items];
    const cursors = new Set<string>();
    while (data.nextCursor) {
      expect(cursors.has(data.nextCursor), "거래내역 cursor가 반복되면 안 됩니다.").toBe(false);
      cursors.add(data.nextCursor);
      const url = new URL(response.url());
      url.searchParams.set("cursor", data.nextCursor);
      data = await this.get<{ items: Transaction[]; nextCursor?: string }>(url.toString(), response);
      items.push(...data.items);
    }
    expect(items.length).toBe(totals.totalCount);
    expect(new Set(items.map((item) => item.transaction.id)).size).toBe(items.length);
    if (items.length) await expect(this.page.getByRole("grid").last()).toContainText(code, { timeout: 15_000 });
    else await expect(this.page.getByRole("grid").last().locator('[role="row"][data-id]')).toHaveCount(0);
    await this.shot(`transactions-${code}-${month}`);
    this.log(`거래내역 검색 ${code}/${month}~${throughMonth}: ${items.length}개 전체 응답을 cursor로 대조, 화면 표와 통계 표시 확인`);
    return { items, totals };
  }

  private async vendorRow(row: ReportRow) {
    const initial = this.page.waitForResponse((r) => new URL(r.url()).pathname === "/api/v2/vendors/manage", { timeout: 25_000 });
    await this.navigate("/business/vendor-manage");
    await (await initial).finished();
    await this.page.getByRole("checkbox", { name: "가맹점명 직접 입력", exact: true }).last().check();
    await this.page.waitForTimeout(600);
    await this.page.getByRole("textbox", { name: "가맹점명", exact: true }).last().fill(row.vendorName);
    await this.page.waitForTimeout(600);
    await expect(this.page.getByRole("textbox", { name: "가맹점명", exact: true }).last()).toHaveValue(row.vendorName);
    const response = this.page.waitForResponse((r) => new URL(r.url()).pathname === "/api/v2/vendors/manage" &&
      new URL(r.url()).searchParams.get("vendorName") === row.vendorName, { timeout: 25_000 });
    await this.page.getByRole("button", { name: "검색", exact: true }).last().click();
    await payload(await response);
    const target = this.page.getByRole("grid").last().getByRole("row").filter({ has: this.page.getByRole("button", { name: row.departmentCode, exact: true }) });
    await expect(target).toHaveCount(1, { timeout: 15_000 });
    return target;
  }

  private async salesLines(row: ReportRow): Promise<Array<{ departments: Array<{ code: string; name: string }> }>> {
    const target = await this.vendorRow(row);
    const response = this.page.waitForResponse((r) => new URL(r.url()).pathname === `/api/v2/sales-lines/departments/${row.vendorId}`, { timeout: 25_000 });
    await target.locator('[data-field="salesLineCount"] button').first().click();
    const result = await payload<{ content: Array<{ departments: Array<{ code: string; name: string }> }> }>(await response);
    await expect(this.page.getByRole("dialog").last()).toContainText(row.vendorName);
    await this.shot("merchant-sales-lines");
    await this.page.getByRole("dialog").last().getByRole("button", { name: "닫기", exact: true }).click();
    return result.content;
  }

  private async vendorDetails(row: ReportRow): Promise<{
    person: string; businessType: string; businessItem: string;
    address: { postalCode: string; address1: string; address2: string };
  }> {
    const target = await this.vendorRow(row);
    const response = this.page.waitForResponse((r) => new URL(r.url()).pathname === `/api/v2/vendors/${row.vendorId}`, { timeout: 25_000 });
    await target.locator("svg.lucide-pencil").click();
    const data = await payload<{ businessInfo: { person: string; businessType: string; businessItem: string;
      address: { postalCode: string; address1: string; address2: string } } }>(await response);
    const dialog = this.page.getByRole("dialog").last();
    await expect(dialog.getByRole("textbox", { name: "대표자", exact: false }).first()).toHaveValue(data.businessInfo.person);
    await this.shot("merchant-business-information");
    await dialog.getByRole("button", { name: "취소", exact: true }).click();
    return data.businessInfo;
  }

  private async excel(data: Snapshot): Promise<void> {
    const responsePromise = this.page.waitForResponse((r) => new URL(r.url()).pathname === `/api/v1/excel/tax-reports/${data.kind}`, { timeout: 30_000 });
    await this.page.getByRole("button", { name: "엑셀", exact: true }).last().click();
    await this.page.getByText("엑셀 다운로드", { exact: true }).last().click();
    const response = await responsePromise;
    const documentId = await payload<string>(response);
    const request = response.request();
    this.log(`${LABELS[data.kind]} Excel 생성: ${request.method()} ${new URL(request.url()).pathname}; 월 ${data.month}, 검색된 ${data.totalCount}행`);
    await this.page.getByRole("button", { name: "문서관리 페이지로 이동", exact: true }).last().click();
    await this.page.waitForURL(/\/document/, { timeout: 20_000 });
    const entry = this.page.getByRole("grid").last().locator(`[data-id="${documentId}"]`);
    await expect(entry).toBeVisible({ timeout: 60_000 });
    await expect.poll(async () => {
      if (await entry.getByText("다운로드", { exact: true }).isVisible()) return true;
      await this.page.getByRole("button", { name: "검색", exact: true }).last().click();
      return false;
    }, { timeout: 90_000, intervals: [1000, 2000, 3000], message: "문서보관함을 재조회해 요청한 문서의 생성 완료 확인" }).toBe(true);
    await this.shot(`${data.kind}-document-ready`);
    const downloadPromise = this.page.waitForEvent("download", { timeout: 30_000 });
    await entry.getByText("다운로드", { exact: true }).click();
    const download = await downloadPromise;
    expect(await download.failure()).toBeNull();
    const fileName = download.suggestedFilename();
    expect(fileName).toMatch(/\.xlsx$/i);
    const filePath = this.info.outputPath(`${data.kind}.xlsx`);
    await download.saveAs(filePath);
    const workbook = extractDownloadRows(fs.readFileSync(filePath), fileName);
    const headerIndex = workbook.findIndex((row) => row.includes("가맹점명"));
    expect(headerIndex).toBeGreaterThanOrEqual(0);
    const headers = workbook[headerIndex].map(compact);
    const rows = workbook.slice(headerIndex + 1).filter((row) => row.some((cell) => cell.trim()));
    const codeIndex = headers.findIndex((header) => /가맹(점)?고유코드/.test(header));
    const nameIndex = headers.indexOf("가맹점명");
    expect(codeIndex).toBeGreaterThan(-1);
    expect(codeIndex).toBeLessThan(nameIndex);
    expect(rows.length).toBe(data.totalCount);
    expect(rows.map((row) => row[codeIndex]).sort()).toEqual(data.items.map((row) => row.departmentCode).sort());
    const numbers: Record<NumericKey, string[]> = {
      approvalCount: ["승인건수"], approvalAmount: ["승인금액"], cancelCount: ["취소건수"], cancelAmount: ["취소금액"],
      totalCount: ["총건수"], totalTransactionAmount: ["거래금액", "총거래금액"],
      totalSalesAmount: ["총매출금액", "총매출"], supplyAmount: ["공급가액", "수수료공급가액"],
      vatAmount: ["VAT", "수수료세액(VAT)", "수수료VAT"], totalCommissionAmount: ["수수료합계"]
    };
    for (const expected of data.items) {
      const row = rows.find((item) => item[codeIndex] === expected.departmentCode)!;
      const cell = (aliases: string[]) => {
        const index = headers.findIndex((header) => aliases.includes(header));
        expect(index, `엑셀 헤더 ${aliases.join(" / ")}`).toBeGreaterThan(-1);
        return row[index];
      };
      expect(row[nameIndex]).toBe(expected.vendorName);
      expect(compact(cell(["영업라인"])) ).toBe(compact(expected.salesLineName));
      expect(cell(["사업자유형"])).toBe(expected.businessRegistrationType.label);
      const registration = expected.businessRegistrationType.label === "비사업자" ? expected.residentRegistrationNumber : expected.businessRegistrationNumber;
      expect(cell(["사업자번호/주민등록번호"]) || "-").toBe(registration || "-");
      for (const key of NUMBERS[data.kind]) expect(amount(cell(numbers[key]))).toBeCloseTo(requiredNumber(expected[key]), 5);
    }
    this.log(`${LABELS[data.kind]}: 문서보관함에서 생성 문서 ID를 직접 찾아 다운로드 완료. Excel ${rows.length}행의 코드/명칭/영업라인/유형/등록번호/거래·수수료 금액 일치. 헤더: ${headers.join(" | ")}`);
  }

  private async shot(label: string): Promise<void> {
    if (this.page.isClosed() || this.page.url() === "about:blank") return;
    const capture = async (name: string) => {
      await this.info.attach(`${String(++this.imageIndex).padStart(2, "0")}-${name}.png`, {
        body: await this.page.screenshot({ fullPage: false, animations: "disabled", mask: [
          this.page.getByRole("grid").last().locator('[data-field="contact"]:visible, [data-field="registrationNumber"]:visible'),
          this.page.locator('input[type="password"]:visible'),
          ...(this.runtime.username ? [this.page.getByText(this.runtime.username, { exact: true }).last()] : [])
        ] }), contentType: "image/png"
      });
    };
    await capture(label);
    if (await this.page.getByRole("dialog").count()) return;
    const scroller = this.page.getByRole("grid").last().locator(".MuiDataGrid-virtualScroller");
    if (!(await scroller.count())) return;
    const dimensions = await scroller.evaluate((element) => ({ left: element.scrollLeft, width: element.clientWidth, total: element.scrollWidth }));
    if (dimensions.total <= dimensions.width) return;
    try {
      await scroller.evaluate((element) => { element.scrollLeft = element.scrollWidth; });
      await this.page.waitForTimeout(100);
      await capture(`${label}-amount-columns`);
    } finally {
      await scroller.evaluate((element, left) => { element.scrollLeft = left; }, dimensions.left);
    }
  }

  async finish(): Promise<void> {
    await this.shot("final-screen").catch(() => undefined);
    await this.info.attach("tax-vat-evidence.md", {
      body: this.notes.join("\n\n"), contentType: "text/markdown"
    });
  }
}

async function payload<T>(response: Response | APIResponse): Promise<T> {
  expect(response.ok(), `조회 HTTP ${response.status()} ${new URL(response.url()).pathname}`).toBe(true);
  const body = await response.json() as { result?: { code: number; message: string }; payload?: T };
  expect(body.result?.code, `API 결과 ${body.result?.message ?? ""}`).toBe(200);
  expect(body.payload, "조회 payload").not.toBeNull();
  expect(body.payload, "조회 payload").not.toBeUndefined();
  return body.payload!;
}

function amount(value: string): number {
  const result = Number(value?.replace(/[^\d.\-]/g, ""));
  expect(value, "금액/건수 표시 값").toBeTruthy();
  expect(Number.isFinite(result)).toBe(true);
  return result;
}

function requiredNumber(value: number | undefined): number {
  expect(typeof value, "숫자 필드가 누락되면 안 됩니다.").toBe("number");
  expect(Number.isFinite(value)).toBe(true);
  return value!;
}

function requireRow(data: Snapshot, code: string): ReportRow {
  const row = data.items.find((item) => item.departmentCode === code);
  if (!row) blocked(`${data.month} ${LABELS[data.kind]}에서 비교 대상 ${code}를 조회하지 못했습니다.`);
  return row;
}

function matches(row: ReportRow, filters: Filters): boolean {
  return Object.entries(filters).every(([key, value]) => {
    if (key === "branchOfficeCode") return row.salesLineDepartments.some((d) => d.departmentType === "BO" && d.departmentCode.includes(value));
    if (key === "branchOfficeName") return row.salesLineDepartments.some((d) => d.departmentType === "BO" && d.departmentName.includes(value));
    if (key === "registrationNumber") return `${row.businessRegistrationNumber ?? ""}${row.residentRegistrationNumber ?? ""}`.replace(/-/g, "").includes(value.replace(/-/g, ""));
    if (key === "businessRegistrationType") return row.businessRegistrationType.value === value;
    if (key === "isReported") return String(row.isReported) === value;
    return String(row[key as keyof ReportRow] ?? "").includes(value);
  });
}

function comparable(data: Snapshot): unknown {
  return { items: data.items, totalCount: data.totalCount, summary: data.summary };
}

function compact(value: string): string { return value.replace(/\s/g, ""); }
function safeFilters(filters: Filters): string {
  return JSON.stringify({ ...filters, ...(filters.registrationNumber ? { registrationNumber: "[masked]" } : {}) });
}
function blocked(message: string): never { throw new Error(`BLOCKED: ${message}`); }
