# 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: 3927/dev/deposit-confirmation-excel.spec.ts >> [3927][dev] 부서별 운영상태 관리 정산시스템 이관 권한별 노출 검증 / [TA] role >> TA 계정으로 입금확인 엑셀 버튼을 확인하고 검색 목록과 다운로드 데이터가 동일한지 확인
- Location: src/qa/scenarios/depositConfirmationExcel.ts:61:7

# Error details

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

# Test source

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