diff --git a/src/core/parseWorkbook.ts b/src/core/parseWorkbook.ts index e2fc47b..396f208 100644 --- a/src/core/parseWorkbook.ts +++ b/src/core/parseWorkbook.ts @@ -5,13 +5,21 @@ import type { Bearing, FoundationType, Issue, IpPoint, PierInput, PierType, Row type Cell = string | number | null | undefined; type Grid = Cell[][]; +/** One data row of a table, with the sheet and 1-based row it came from. */ +interface Rec { + sheet: string; + row: number; + cells: Cell[]; +} + export interface SheetReport { name: string; - /** Pier input tables found (교각 정보 표 개수). */ + /** Tables found on this sheet (교각 정보 · IP · 받침). */ tables: { pier: number; ip: number; bearing: number }; + /** Piers whose 교각 정보 row is on this sheet. */ piers: number; issues: Issue[]; - /** A sheet with any E-level issue cannot be selected (spec §2.3). */ + /** No E-level issue on this sheet. Pier selection is checked separately. */ ok: boolean; } @@ -24,116 +32,120 @@ export interface ParseResult { const PIER_TYPES: PierType[] = ['T형', '2주형', '3주형']; const FOUNDATION_TYPES: FoundationType[] = ['말뚝기초', '직접기초', '현장타설말뚝']; -/** Reads every sheet, finds the pier tables by their header strings, and reports each sheet separately. */ +const isPierHeader = (r: Cell[]) => r[0] === '교각명' && r[1] === '교각형식'; +const isIpHeader = (r: Cell[]) => r[0] === '교각명' && r[1] === 'IP점'; +const isBearingHeader = (r: Cell[]) => r[0] === '교각명' && r[1] === '받침번호'; + +/** + * Reads the whole workbook. The three tables (교각 정보, IP, 받침) may sit on different sheets; + * rows are joined by 교각명 across the workbook. The sheet of each pier's 교각 정보 row is kept. + */ export function parseWorkbook(wb: XLSX.WorkBook): ParseResult { - const piers: PierInput[] = []; - const issues: Issue[] = []; - const sheets: SheetReport[] = []; - for (const sheetName of wb.SheetNames) { - const ws = wb.Sheets[sheetName]; + const pierRecs: Rec[] = []; + const ipRecs: Rec[] = []; + const bearingRecs: Rec[] = []; + const counts = new Map(); + + for (const sheet of wb.SheetNames) { + const ws = wb.Sheets[sheet]; const grid = XLSX.utils.sheet_to_json(ws, { header: 1, raw: true, defval: null }) as Grid; - const sheetIssues: Issue[] = []; - const found = parseSheet(grid, sheetName, sheetIssues); - piers.push(...found); - issues.push(...sheetIssues); - sheets.push({ - name: sheetName, - tables: { - pier: findHeaders(grid, (r) => r[0] === '교각명' && r[1] === '교각형식').length, - ip: findHeaders(grid, (r) => r[0] === '교각명' && r[1] === 'IP점').length, - bearing: findHeaders(grid, (r) => r[0] === '교각명' && r[1] === '받침번호').length, - }, - piers: found.length, - issues: sheetIssues, - ok: found.length > 0 && !sheetIssues.some((i) => i.level === 'E'), - }); - } - return { piers, issues, sheets }; -} - -function parseSheet(grid: Grid, sheet: string, issues: Issue[]): PierInput[] { - const pierHeaders = findHeaders(grid, (r) => r[0] === '교각명' && r[1] === '교각형식'); - const ipHeaders = findHeaders(grid, (r) => r[0] === '교각명' && r[1] === 'IP점'); - const bearingHeaders = findHeaders(grid, (r) => r[0] === '교각명' && r[1] === '받침번호'); - - const ips = ipHeaders.flatMap((h) => readBlock(grid, h).map((r) => toIp(r))); - const bearings: { pier: string; bearing: Bearing; row: number; azimuthOk: boolean }[] = []; - for (const h of bearingHeaders) { - for (const { cells, index } of readBlock(grid, h)) { - const azimuth = parseDms(String(cells[5] ?? '')); - bearings.push({ - pier: String(cells[0]), - row: index + 1, - azimuthOk: azimuth !== null, - bearing: { - no: Number(cells[1]), - x: Number(cells[2]), - y: Number(cells[3]), - z: Number(cells[4]), - azimuthDeg: azimuth ?? NaN, - row: String(cells[6]) as Row, - }, - }); + const c = { pier: 0, ip: 0, bearing: 0 }; + for (const [target, match, counter] of [ + [pierRecs, isPierHeader, 'pier'], + [ipRecs, isIpHeader, 'ip'], + [bearingRecs, isBearingHeader, 'bearing'], + ] as const) { + for (const h of findHeaders(grid, match)) { + c[counter]++; + for (const { cells, index } of readBlock(grid, h)) target.push({ sheet, row: index + 1, cells }); + } } + counts.set(sheet, c); } + const issues: Issue[] = []; const piers: PierInput[] = []; - if (pierHeaders.length === 0 && (ipHeaders.length > 0 || bearingHeaders.length > 0)) { - issues.push({ - code: 'E05', - level: 'E', - message: `${sheet}: 교각 정보 표가 이 시트에 없습니다. 교각 1기의 세 표(교각 정보·IP·받침)를 한 시트에 두세요 (시트 1장 = 교각 1기).`, + const pierNames = new Set(); + + for (const p of pierRecs) { + const name = String(p.cells[0]); + pierNames.add(name); + const pierIps = ipRecs.filter((r) => String(r.cells[0]) === name).map((r) => toIp(r.cells)); + const pierBearings = bearingRecs.filter((r) => String(r.cells[0]) === name); + if (pierIps.length === 0 || pierBearings.length === 0) { + const missing = [pierIps.length === 0 ? 'IP' : null, pierBearings.length === 0 ? '받침' : null].filter(Boolean).join('·'); + issues.push({ + code: 'E05', + level: 'E', + pier: name, + sheet: p.sheet, + message: `${p.row}행: ${missing} 표를 입력 파일에서 찾지 못했습니다. 세 표의 교각명이 같은지 확인하세요.`, + }); + continue; + } + + const badAzimuth = pierBearings.find((r) => parseDms(String(r.cells[5] ?? '')) === null); + if (badAzimuth) { + issues.push({ + code: 'E03', + level: 'E', + pier: name, + sheet: badAzimuth.sheet, + message: `${badAzimuth.row}행: 방위각을 읽을 수 없습니다. 예: 75°56'55.9" 형식으로 고치세요.`, + }); + continue; + } + + const type = String(p.cells[1]) as PierType; + const foundation = String(p.cells[5]) as FoundationType; + if (!PIER_TYPES.includes(type) || !FOUNDATION_TYPES.includes(foundation)) { + issues.push({ code: 'E01', level: 'E', pier: name, sheet: p.sheet, message: `${p.row}행: 교각형식 또는 기초 형식을 알 수 없습니다.` }); + continue; + } + + piers.push({ + name, + sheet: p.sheet, + type, + copingTopEl: Number(p.cells[2]), + footingBottomEl: Number(p.cells[3]), + groundEl: Number(p.cells[4]), + foundationType: foundation, + pileLengthMm: Number(p.cells[6]), + copingWidthMm: Number(p.cells[7]), + copingLengthMm: Number(p.cells[8]), + columnCount: Number(p.cells[9]), + ips: pierIps, + bearings: pierBearings.map((r) => toBearing(r.cells)), }); } - for (const h of pierHeaders) { - for (const { cells, index } of readBlock(grid, h)) { - const name = String(cells[0]); - const pierIps = ips.filter((p) => p.pier === name); - const pierBearings = bearings.filter((b) => b.pier === name); - if (pierIps.length === 0 || pierBearings.length === 0) { - const missing = [pierIps.length === 0 ? 'IP' : null, pierBearings.length === 0 ? '받침' : null].filter(Boolean).join('·'); - issues.push({ - code: 'E05', - level: 'E', - pier: name, - message: `${index + 1}행: ${missing} 표가 이 시트에 없습니다. 교각 1기의 세 표를 한 시트에 두세요 (시트 1장 = 교각 1기).`, - }); - continue; - } - const bad = bearings.find((b) => b.pier === name && !b.azimuthOk); - if (bad) { - issues.push({ - code: 'E03', - level: 'E', - pier: name, - message: `${bad.row}행: 방위각을 읽을 수 없습니다. 예: 75°56'55.9" 형식으로 고치세요.`, - }); - continue; - } - const type = String(cells[1]) as PierType; - const foundation = String(cells[5]) as FoundationType; - if (!PIER_TYPES.includes(type) || !FOUNDATION_TYPES.includes(foundation)) { - issues.push({ code: 'E01', level: 'E', pier: name, message: `${index + 1}행: 교각형식 또는 기초 형식을 알 수 없습니다.` }); - continue; - } - piers.push({ - name, - sheet, - type, - copingTopEl: Number(cells[2]), - footingBottomEl: Number(cells[3]), - groundEl: Number(cells[4]), - foundationType: foundation, - pileLengthMm: Number(cells[6]), - copingWidthMm: Number(cells[7]), - copingLengthMm: Number(cells[8]), - columnCount: Number(cells[9]), - ips: ips.filter((p) => p.pier === name).map((p) => p.ip), - bearings: bearings.filter((b) => b.pier === name).map((b) => b.bearing), + + // IP or 받침 rows whose 교각명 has no 교각 정보 row anywhere in the workbook. + for (const r of [...ipRecs, ...bearingRecs]) { + const name = String(r.cells[0]); + if (!pierNames.has(name)) { + issues.push({ + code: 'E05', + level: 'E', + pier: name, + sheet: r.sheet, + message: `${r.row}행: 교각명 ${name}의 교각 정보 행이 입력 파일에 없습니다.`, }); } } - return piers; + + const sheets: SheetReport[] = wb.SheetNames.map((name) => { + const own = issues.filter((i) => i.sheet === name); + return { + name, + tables: counts.get(name)!, + piers: pierRecs.filter((r) => r.sheet === name).length, + issues: own, + ok: !own.some((i) => i.level === 'E'), + }; + }); + + return { piers, issues, sheets }; } function findHeaders(grid: Grid, match: (row: Cell[]) => boolean): number[] { @@ -158,10 +170,17 @@ function readBlock(grid: Grid, header: number): { cells: Cell[]; index: number } return out; } -function toIp(entry: { cells: Cell[]; index: number }): { pier: string; ip: IpPoint } { - const c = entry.cells; +function toIp(c: Cell[]): IpPoint { + return { name: String(c[1]), x: Number(c[2]), y: Number(c[3]), angleDeg: Number(c[4] ?? 0) }; +} + +function toBearing(c: Cell[]): Bearing { return { - pier: String(c[0]), - ip: { name: String(c[1]), x: Number(c[2]), y: Number(c[3]), angleDeg: Number(c[4] ?? 0) }, + no: Number(c[1]), + x: Number(c[2]), + y: Number(c[3]), + z: Number(c[4]), + azimuthDeg: parseDms(String(c[5] ?? '')) ?? NaN, + row: String(c[6]) as Row, }; } diff --git a/src/core/types.ts b/src/core/types.ts index d935bf2..5e3b4e3 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -42,5 +42,7 @@ export interface Issue { code: string; level: IssueLevel; pier?: string; + /** Sheet where the offending row sits. */ + sheet?: string; message: string; } diff --git a/src/main.ts b/src/main.ts index 6dc7fcc..12e6862 100644 --- a/src/main.ts +++ b/src/main.ts @@ -360,8 +360,10 @@ function renderDialog(): void { for (const sheet of p.sheets) { const li = el('li'); const head = el('div', undefined); + const label = + sheet.piers === 0 ? (sheet.ok ? '보조 표 시트 (IP·받침)' : '오류') : sheet.ok ? '선택 가능' : '선택 불가 (오류)'; head.append( - el('span', `${sheet.name} · ${sheet.ok ? '선택 가능' : '선택 불가 (오류)'}`, sheet.ok ? 'ok' : 'bad'), + el('span', `${sheet.name} · ${label}`, sheet.ok ? 'ok' : 'bad'), document.createTextNode(` · 교각 ${sheet.tables.pier}표 · IP ${sheet.tables.ip}표 · 받침 ${sheet.tables.bearing}표`), ); li.appendChild(head); diff --git a/tests/parse.test.ts b/tests/parse.test.ts index d7350fe..adb3e35 100644 --- a/tests/parse.test.ts +++ b/tests/parse.test.ts @@ -1,3 +1,4 @@ +import * as XLSX from 'xlsx'; import { describe, expect, it } from 'vitest'; import { parseWorkbook } from '../src/core/parseWorkbook'; import { loadP1, loadWorkbook } from './helpers'; @@ -60,3 +61,34 @@ describe('tables split across sheets (spec §2.2: 시트 1장 = 교각 1기)', ( expect(sheets[0].ok).toBe(false); }); }); + +describe('three-sheet input (교각 · IP · 받침 on separate sheets, joined by 교각명)', () => { + /** Splits the P1 sheet into the three sheets the user prepares: rows 22–23, 25–29, 31–41 (1-based). */ + function threeSheets(): XLSX.WorkBook { + const src = loadWorkbook(); + const grid = XLSX.utils.sheet_to_json<(string | number | null)[]>(src.Sheets['intent.PR'], { + header: 1, + raw: true, + defval: null, + }); + const wb = XLSX.utils.book_new(); + XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet(grid.slice(21, 23)), '교각'); + XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet(grid.slice(24, 29)), 'IP'); + XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet(grid.slice(30, 41)), '받침'); + return wb; + } + + it('joins the three sheets into one pier with 4 IP points and 10 bearings', () => { + const { piers, issues, sheets } = parseWorkbook(threeSheets()); + expect(issues).toEqual([]); + expect(piers).toHaveLength(1); + expect(piers[0]).toMatchObject({ name: 'P1', sheet: '교각' }); + expect(piers[0].ips).toHaveLength(4); + expect(piers[0].bearings).toHaveLength(10); + expect(sheets.map((s) => [s.name, s.ok, s.piers])).toEqual([ + ['교각', true, 1], + ['IP', true, 0], + ['받침', true, 0], + ]); + }); +});