# Instructions

- Following Playwright test failed.
- Explain why, be concise, respect Playwright best practices.
- Provide a snippet of code with the fix, if possible.

# Test info

- Name: 3872/dev/deposit-confirmation-excel.spec.ts >> [3872][dev] ADMIN/TA 입금확인 엑셀 검색 결과 일치 검증 / [TA] role >> TA 계정으로 입금확인 엑셀 버튼을 확인하고 검색 목록과 다운로드 데이터가 동일한지 확인
- Location: src/qa/scenarios/depositConfirmationExcel.ts:63:7

# Error details

```
Error: BLOCKED: TA 계정의 입금확인 엑셀 요청이 권한 없음으로 차단되었습니다.
```

# Test source

```ts
  326 |     if (cells.length === 0) {
  327 |       cells = (await row.locator(":scope > *").allTextContents()).map(normalize);
  328 |     }
  329 |     if (cells.length === 0) {
  330 |       cells = (await row.innerText().catch(() => "")).split(/\n+/).map(normalize);
  331 |     }
  332 |     if (!headers.length && cells.some((cell) => REQUIRED_SCREEN_HEADERS.includes(cell))) {
  333 |       headers = cells;
  334 |       continue;
  335 |     }
  336 |     if (cells.some(Boolean)) roleRows.push(cells);
  337 |   }
  338 |   return { headers, rows: roleRows, totalCount, bodyText };
  339 | }
  340 |
  341 | function findExcelTable(rows: string[][]): { headers: string[]; rows: string[][] } {
  342 |   const headerIndex = rows.findIndex((row) => row.some((cell) => REQUIRED_SCREEN_HEADERS.includes(normalize(cell))));
  343 |   if (headerIndex < 0) {
  344 |     throw new Error(`엑셀에서 입금확인 헤더 행을 찾지 못했습니다. rows=${JSON.stringify(rows.slice(0, 4))}`);
  345 |   }
  346 |   const headers = rows[headerIndex].map(normalize);
  347 |   const dataRows = rows.slice(headerIndex + 1).filter((row) => row.some((cell) => normalize(cell)));
  348 |   return { headers, rows: dataRows };
  349 | }
  350 |
  351 | function buildComparableKeys(headers: string[], rows: string[][]): string[] {
  352 |   const amountIndex = findHeaderIndex(headers, "입금액");
  353 |   const dateIndex = findHeaderIndex(headers, "입금일시");
  354 |   if (amountIndex < 0 || dateIndex < 0) {
  355 |     throw new Error(`화면/엑셀 비교에 필요한 헤더를 찾지 못했습니다. headers=${headers.join(" | ")}`);
  356 |   }
  357 |
  358 |   // The screen omits empty cells from its virtualized row DOM, while the
  359 |   // spreadsheet preserves those columns. Compare stable business fields
  360 |   // instead of relying on positional indexes that differ between the two.
  361 |   return rows
  362 |     .map((row) => {
  363 |       const amount = canonicalAmount(
  364 |         row.find((value) => /\d[\d,]*\s*원$/.test(normalize(value))) ?? row[amountIndex] ?? ""
  365 |       );
  366 |       const date = canonicalDate(
  367 |         row.find((value) => /\d{4}-\d{2}-\d{2}/.test(normalize(value))) ?? row[dateIndex] ?? ""
  368 |       );
  369 |       const title = normalize(
  370 |         row.filter((value) => /입금|테스트|tid\s*=/i.test(normalize(value))).join(" ")
  371 |       );
  372 |       const account = normalize(
  373 |         row.find((value) => /^\d{10,}$/.test(normalize(value).replace(/\s/g, ""))) ?? ""
  374 |       );
  375 |       return [title, amount, date, account].join("|");
  376 |     })
  377 |     .sort();
  378 | }
  379 |
  380 | function findHeaderIndex(headers: string[], expected: string): number {
  381 |   return headers.findIndex((header) => normalize(header).replace(/\s/g, "") === expected.replace(/\s/g, ""));
  382 | }
  383 |
  384 | function parseTotalCount(text: string): number | undefined {
  385 |   const matches = Array.from(text.matchAll(/총\s*(\d+)\s*\/\s*(\d+)\s*건/g));
  386 |   const match = matches[matches.length - 1];
  387 |   return match ? Number(match[2]) : undefined;
  388 | }
  389 |
  390 | function canonicalAmount(value: string): string {
  391 |   const normalized = normalize(value).replace(/,/g, "");
  392 |   if (/^-?\d+\.0$/.test(normalized)) {
  393 |     return normalized.slice(0, -2);
  394 |   }
  395 |   return normalized.replace(/[^\d-]/g, "");
  396 | }
  397 |
  398 | function canonicalDate(value: string): string {
  399 |   const normalized = normalize(value);
  400 |   const numeric = Number(normalized);
  401 |   if (Number.isFinite(numeric) && numeric > 30000) {
  402 |     return excelSerialDate(numeric);
  403 |   }
  404 |   return normalized.match(/\d{4}-\d{2}-\d{2}/)?.[0] ?? normalized;
  405 | }
  406 |
  407 | function excelSerialDate(serial: number): string {
  408 |   const date = new Date(Date.UTC(1899, 11, 30) + serial * 86_400_000);
  409 |   return date.toISOString().slice(0, 10);
  410 | }
  411 |
  412 | function requirePage(pages: QaPageDefinition[], name: string): QaPageDefinition {
  413 |   const page = pages.find((candidate) => candidate.name === name);
  414 |   if (!page) throw new Error(`${name} 페이지 정의가 필요합니다.`);
  415 |   return page;
  416 | }
  417 |
  418 | function runtimeBlockReason(runtime: RuntimeQaEnv): string | undefined {
  419 |   if (!runtime.baseUrl) return `${runtime.application}/${runtime.environment}/${runtime.role} baseUrl이 없습니다.`;
  420 |   if (!runtime.tenantId) return `${runtime.application}/${runtime.environment}/${runtime.role} tenantId가 없습니다.`;
  421 |   if (!runtime.storageState) return `${runtime.application}/${runtime.environment}/${runtime.role} storage state가 없습니다.`;
  422 |   return undefined;
  423 | }
  424 |
  425 | function blocked(message: string): never {
> 426 |   throw new Error(`BLOCKED: ${message}`);
      |         ^ Error: BLOCKED: TA 계정의 입금확인 엑셀 요청이 권한 없음으로 차단되었습니다.
  427 | }
  428 |
  429 | async function withTracedPage(
  430 |   browser: Browser,
  431 |   runtime: RuntimeQaEnv,
  432 |   testInfo: TestInfo,
  433 |   pageName: string,
  434 |   role: string,
  435 |   suffix: string,
  436 |   callback: (page: Page) => Promise<void>
  437 | ): Promise<void> {
  438 |   const context = await browser.newContext(runtime.storageState ? { storageState: runtime.storageState, acceptDownloads: true } : { acceptDownloads: true });
  439 |   const page = await context.newPage();
  440 |   try {
  441 |     await callback(page);
  442 |   } finally {
  443 |     await context.close().catch(() => undefined);
  444 |   }
  445 | }
  446 |
  447 | async function attachScreenshot(page: Page, testInfo: TestInfo, pageName: string, role: string, suffix: string): Promise<void> {
  448 |   const filePath = testInfo.outputPath(`${safe(pageName)}-${safe(role)}-${safe(suffix)}.png`);
  449 |   await page.screenshot({ path: filePath, fullPage: false, timeout: 10_000 });
  450 |   await testInfo.attach(path.basename(filePath), { path: filePath, contentType: "image/png" });
  451 | }
  452 |
  453 | async function attachEvidenceLog(testInfo: TestInfo, pageName: string, role: string, suffix: string, lines: string[]): Promise<void> {
  454 |   await testInfo.attach(`${safe(pageName)}-${safe(role)}-${safe(suffix)}.md`, {
  455 |     body: `${lines.join("\n")}\n`,
  456 |     contentType: "text/markdown"
  457 |   });
  458 | }
  459 |
  460 | async function settle(page: Page): Promise<void> {
  461 |   await page.waitForLoadState("domcontentloaded");
  462 |   await page.waitForLoadState("networkidle", { timeout: 8_000 }).catch(() => undefined);
  463 |   await page.waitForTimeout(700);
  464 | }
  465 |
  466 | function normalize(value: string): string {
  467 |   return value.replace(/\s+/g, " ").trim();
  468 | }
  469 |
  470 | function formatRow(row?: string[]): string {
  471 |   return row?.map(normalize).join(" | ") || "없음";
  472 | }
  473 |
  474 | function evidenceSnippet(value: string, maxLength: number): string {
  475 |   return value.length > maxLength ? `${value.slice(0, maxLength)}...` : value;
  476 | }
  477 |
  478 | function redactUrl(value: string): string {
  479 |   try {
  480 |     const url = new URL(value);
  481 |     for (const key of ["token", "access_token", "refresh_token"]) url.searchParams.delete(key);
  482 |     return url.toString();
  483 |   } catch {
  484 |     return value;
  485 |   }
  486 | }
  487 |
  488 | function safe(value: string): string {
  489 |   return value.replace(/[^a-zA-Z0-9가-힣._-]+/g, "-").slice(0, 120);
  490 | }
  491 |
```