feat(parse): join pier, IP, and bearing tables across sheets by 교각명
- one workbook may hold the three tables on separate sheets (user decision, recorded in DOC/03_설계); spec 2.2 one-sheet-per-pier is superseded - E05 for rows whose 교각명 has no matching 교각 정보 row, or a pier missing IP/받침 - helper sheets (IP only, 받침 only) are shown as 보조 표 시트, not selectable - test: three-sheet workbook built from the P1 sheet Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
4f21bd63bf
commit
727cd3b2e2
4 files changed
+161
-106
No files matched your search
+124
-105
@@ -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<string, { pier: number; ip: number; bearing: number }>();
|
||||
|
||||
for (const sheet of wb.SheetNames) {
|
||||
const ws = wb.Sheets[sheet];
|
||||
const grid = XLSX.utils.sheet_to_json<Cell[]>(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<string>();
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -42,5 +42,7 @@ export interface Issue {
|
||||
code: string;
|
||||
level: IssueLevel;
|
||||
pier?: string;
|
||||
/** Sheet where the offending row sits. */
|
||||
sheet?: string;
|
||||
message: string;
|
||||
}
|
||||
+3
-1
@@ -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);
|
||||
|
||||
@@ -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],
|
||||
]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user