# 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: 4147/dev/privacy-masking-regression.spec.ts >> [4147][dev] [QA] 개인정보 마스킹 및 원문 조회 역할별 회귀 검증 >> [BO] 영업점관리 / Excel 기본 마스킹 및 PIN 원문 다운로드
- Location: src/qa/scenarios/privacyMaskingRegression.ts:116:11

# Error details

```
Error: 영업점관리 Excel 생성 응답에서 문서 ID를 찾지 못했습니다.
```

# Test source

```ts
  373 | }> {
  374 |   if (!(await hasActionableRows(page))) {
  375 |     blocked(`${runtime.role} ${definition.name}은 기간 확장 후에도 Excel 검증 데이터가 없습니다.`);
  376 |   }
  377 |
  378 |   const maskedRequest = await requestExcelDocument(page, runtime, definition, false);
  379 |   await openTargetPage(page, runtime, definition);
  380 |   await ensureSearchData(page);
  381 |   const unmaskedRequest = await requestExcelDocument(page, runtime, definition, true);
  382 |
  383 |   const documents = await waitForExcelDocuments(page, runtime, [maskedRequest.id, unmaskedRequest.id]);
  384 |   const masked = requireDocument(documents, maskedRequest.id);
  385 |   const unmasked = requireDocument(documents, unmaskedRequest.id);
  386 |   expect(normalizeMode(masked.exposureMode), `${definition.name} 기본 Excel은 MASKED여야 합니다.`).toBe("MASKED");
  387 |   expect(normalizeMode(unmasked.exposureMode), `${definition.name} 원문 Excel은 UNMASKED여야 합니다.`).toBe("UNMASKED");
  388 |
  389 |   const maskedBuffer = await downloadDocument(page, masked);
  390 |   const unmaskedBuffer = await downloadDocument(page, unmasked);
  391 |   const maskedText = extractDownloadBufferText(maskedBuffer, masked.filename ?? "masked.xlsx");
  392 |   const unmaskedText = extractDownloadBufferText(unmaskedBuffer, unmasked.filename ?? "unmasked.xlsx");
  393 |   const maskedStars = countMaskCharacters(maskedText);
  394 |   const unmaskedStars = countMaskCharacters(unmaskedText);
  395 |
  396 |   if (maskedStars === 0) {
  397 |     blocked(`${runtime.role} ${definition.name} 기본 Excel에 마스킹 검증이 가능한 개인정보 값이 없습니다.`);
  398 |   }
  399 |   expect(unmaskedStars, `${definition.name} 원문 Excel은 기본 Excel보다 마스킹 문자가 적어야 합니다.`).toBeLessThan(maskedStars);
  400 |   return { masked, unmasked, maskedStars, unmaskedStars };
  401 | }
  402 |
  403 | async function requestExcelDocument(
  404 |   page: Page,
  405 |   runtime: RuntimeQaEnv,
  406 |   definition: PrivacyPageDefinition,
  407 |   unmasked: boolean
  408 | ): Promise<ExcelRequestResult> {
  409 |   const interaction = await interactionRoot(page);
  410 |   const excelButton = await firstVisible(interaction.locator("button:visible").filter({ hasText: /^엑셀$/ }));
  411 |   if (!excelButton) {
  412 |     throw new Error(`${runtime.role} ${definition.name} 화면에 Excel 버튼이 표시되지 않습니다.`);
  413 |   }
  414 |   await domClick(excelButton);
  415 |   await page.waitForTimeout(300);
  416 |
  417 |   let dialog = await findExcelDialog(page);
  418 |   if (!dialog) {
  419 |     const menu = await firstVisible(
  420 |       page.locator("li:visible, [role='menuitem']:visible, button:visible, a:visible")
  421 |         .filter({ hasText: /조회결과\s*다운로드|엑셀\s*다운로드/ })
  422 |     );
  423 |     if (!menu) throw new Error(`${runtime.role} ${definition.name} Excel 조회결과 다운로드 메뉴가 없습니다.`);
  424 |     await domClick(menu);
  425 |     dialog = await findExcelDialog(page, 10_000);
  426 |   }
  427 |   if (!dialog) throw new Error(`${definition.name} Excel 마스킹 안내 모달이 표시되지 않았습니다.`);
  428 |   const root = dialog;
  429 |   const checkbox = root.getByLabel(/마스킹 해제 후 다운로드/).last();
  430 |   if (unmasked) {
  431 |     await expect(checkbox, `${definition.name} Excel 원문 다운로드 선택 항목이 표시되어야 합니다.`).toBeVisible();
  432 |     await checkbox.check();
  433 |   }
  434 |
  435 |   const responses: Response[] = [];
  436 |   const observedRequests: string[] = [];
  437 |   const requestListener = (request: { method: () => string; url: () => string }): void => {
  438 |     if (/excel|masking|authentication/i.test(request.url())) {
  439 |       observedRequests.push(`${request.method()} ${new URL(request.url()).pathname}`);
  440 |     }
  441 |   };
  442 |   const listener = (response: Response): void => {
  443 |     const request = response.request();
  444 |     if (request.method() !== "POST") return;
  445 |     if (!/\/api\/v1\/excel(?:[/?]|$)/.test(response.url())) return;
  446 |     if (/export-authorizations/.test(response.url())) return;
  447 |     responses.push(response);
  448 |   };
  449 |   page.on("request", requestListener);
  450 |   page.on("response", listener);
  451 |   try {
  452 |     const downloadButton = await firstVisible(root.getByRole("button", { name: /다운로드/ }));
  453 |     if (!downloadButton) throw new Error(`${definition.name} Excel 다운로드 실행 버튼을 찾지 못했습니다.`);
  454 |     await domClick(downloadButton);
  455 |
  456 |     if (unmasked) {
  457 |       const pin = await firstVisible(page.locator("input[placeholder*='PIN'], input[name='pin'], input[type='password']"), 10_000);
  458 |       if (!pin) throw new Error(`${definition.name} 원문 Excel 선택 후 PIN 인증창이 표시되지 않았습니다.`);
  459 |       await pin.fill(runtime.twoFactorCode ?? "secret-redacted");
  460 |       const pinDialog = pin.locator("xpath=ancestor::*[@role='dialog'][1]");
  461 |       const pinRoot = (await pinDialog.count()) > 0 ? pinDialog : page.locator("body");
  462 |       const confirm = await firstVisible(pinRoot.locator("button:visible").filter({ hasText: /^(확인|인증)$/ }), 5_000);
  463 |       if (!confirm) {
  464 |         const buttonNames = (await pinRoot.locator("button:visible").allTextContents()).map(normalize).filter(Boolean);
  465 |         throw new Error(`${definition.name} 원문 Excel PIN 확인 버튼을 찾지 못했습니다. 표시 버튼=${buttonNames.join(" | ")}`);
  466 |       }
  467 |       await confirm.click({ force: true });
  468 |     }
  469 |
  470 |     const response = await waitForCollectedExcelResponse(page, responses, observedRequests);
  471 |     const json = await response.json().catch(() => undefined);
  472 |     const id = findDocumentId(json);
> 473 |     if (!id) throw new Error(`${definition.name} Excel 생성 응답에서 문서 ID를 찾지 못했습니다.`);
      |                    ^ Error: 영업점관리 Excel 생성 응답에서 문서 ID를 찾지 못했습니다.
  474 |     await closeVisibleDialogs(page);
  475 |     return { id, mode: unmasked ? "UNMASKED" : "MASKED" };
  476 |   } finally {
  477 |     page.off("request", requestListener);
  478 |     page.off("response", listener);
  479 |   }
  480 | }
  481 |
  482 | async function findExcelDialog(page: Page, timeoutMs = 1_000): Promise<Locator | undefined> {
  483 |   const checkbox = await firstVisible(page.getByLabel(/마스킹 해제 후 다운로드/), timeoutMs);
  484 |   if (!checkbox) return undefined;
  485 |   const dialog = checkbox.locator("xpath=ancestor::*[@role='dialog'][1]");
  486 |   return (await dialog.count()) > 0 ? dialog : page.locator("body");
  487 | }
  488 |
  489 | async function waitForCollectedExcelResponse(
  490 |   page: Page,
  491 |   responses: Response[],
  492 |   observedRequests: string[]
  493 | ): Promise<Response> {
  494 |   const deadline = Date.now() + 30_000;
  495 |   while (Date.now() < deadline) {
  496 |     if (responses.length > 0) return responses.at(-1)!;
  497 |     await page.waitForTimeout(200);
  498 |   }
  499 |   throw new Error(`Excel 생성 POST 응답을 30초 안에 확인하지 못했습니다. 관련 요청=${observedRequests.join(" | ") || "없음"}`);
  500 | }
  501 |
  502 | async function waitForExcelDocuments(
  503 |   page: Page,
  504 |   runtime: RuntimeQaEnv,
  505 |   ids: string[]
  506 | ): Promise<ExcelDocument[]> {
  507 |   if (!runtime.baseUrl) blocked(`${runtime.role} baseUrl이 없습니다.`);
  508 |   const documentUrl = new URL("/document", ensureTrailingSlash(runtime.baseUrl!));
  509 |   if (runtime.tenantId) documentUrl.searchParams.set("tenantId", runtime.tenantId);
  510 |   const deadline = Date.now() + 120_000;
  511 |   let latest: ExcelDocument[] = [];
  512 |
  513 |   while (Date.now() < deadline) {
  514 |     const responsePromise = page.waitForResponse(
  515 |       (response) => response.request().method() === "GET" && /\/api\/v1\/excel\?/.test(response.url()),
  516 |       { timeout: 15_000 }
  517 |     ).catch(() => undefined);
  518 |     await page.goto(documentUrl.toString(), { waitUntil: "domcontentloaded", timeout: 30_000 });
  519 |     const response = await responsePromise;
  520 |     await settle(page);
  521 |     if (response) {
  522 |       const json = await response.json().catch(() => undefined);
  523 |       latest = collectDocuments(json);
  524 |       const selected = ids.map((id) => latest.find((document) => document.id === id)).filter(Boolean) as ExcelDocument[];
  525 |       if (selected.length === ids.length && selected.every((document) => normalizeMode(document.status) === "AVAILABLE")) {
  526 |         return selected;
  527 |       }
  528 |     }
  529 |     await page.waitForTimeout(2_000);
  530 |   }
  531 |   blocked(`문서보관함에서 Excel ${ids.join(", ")} 생성 완료를 120초 안에 확인하지 못했습니다. 마지막 문서 수=${latest.length}`);
  532 | }
  533 |
  534 | async function downloadDocument(page: Page, document: ExcelDocument): Promise<Buffer> {
  535 |   const root = await interactionRoot(page);
  536 |   const row = root.locator(`[data-id='${cssEscape(document.id)}']`).first();
  537 |   await expect(row, `문서보관함에 ${document.id} 행이 표시되어야 합니다.`).toBeVisible({ timeout: 10_000 });
  538 |   const control = await firstVisible(row.getByRole("link", { name: /다운로드/ }))
  539 |     ?? await firstVisible(row.getByRole("button", { name: /다운로드/ }))
  540 |     ?? await firstVisible(row.getByText("다운로드", { exact: true }));
  541 |   if (!control) throw new Error(`${document.id} 문서 다운로드 버튼을 찾지 못했습니다.`);
  542 |   const downloadPromise = page.waitForEvent("download", { timeout: 30_000 });
  543 |   await domClick(control);
  544 |   const download = await downloadPromise;
  545 |   return readDownloadBuffer(download);
  546 | }
  547 |
  548 | async function readDownloadBuffer(download: Download): Promise<Buffer> {
  549 |   const filePath = await download.path();
  550 |   if (!filePath) throw new Error(`${download.suggestedFilename()} 다운로드 임시 파일 경로가 없습니다.`);
  551 |   return fs.readFileSync(filePath);
  552 | }
  553 |
  554 | function findDocumentId(value: unknown): string | undefined {
  555 |   if (typeof value === "string" && /^[a-zA-Z0-9-]{16,}$/.test(value)) return value;
  556 |   if (!value || typeof value !== "object") return undefined;
  557 |   const object = value as Record<string, unknown>;
  558 |   for (const key of ["documentId", "excelId", "id"]) {
  559 |     const candidate = object[key];
  560 |     if (typeof candidate === "string" && candidate.length >= 16) return candidate;
  561 |   }
  562 |   for (const key of ["payload", "data", "result"]) {
  563 |     const candidate = findDocumentId(object[key]);
  564 |     if (candidate) return candidate;
  565 |   }
  566 |   return undefined;
  567 | }
  568 |
  569 | function collectDocuments(value: unknown): ExcelDocument[] {
  570 |   const result: ExcelDocument[] = [];
  571 |   const visit = (candidate: unknown): void => {
  572 |     if (!candidate || typeof candidate !== "object") return;
  573 |     if (Array.isArray(candidate)) {
```