Merge remote-tracking branch 'origin/dev' into sub_laptop_1

This commit is contained in:
2026-09-27 20:20:23 +09:00
128 changed files with 6072 additions and 3176 deletions
+2
View File
@@ -67,6 +67,8 @@ config/corridor_node/
config/server_calc_node/
# 구조물도 식 풀이 번들 — `npm run build:formula` 산출물(2026-09-13).
config/formula_node/
# 산출근거 스프레드시트 재계산 번들 — `npm run build:spreadsheet` 산출물.
config/spreadsheet_node/
# graphify 위키 산출물 — 창끼리 같은 위키를 찾게 **통째로** git 으로 나름
# (2026-09-09 사용자 확정 「출력물 전체를 공유해도 됨」).
+83 -23
View File
@@ -1,55 +1,115 @@
/* =============================================================================
* spreadsheet_address.ts (주인 A)
* 주소 — A1 ↔ 행열(0 부터) · 범위 글 · 열 글자 · 시트 이름 따옴표 · Map 열쇠.
* 0 계약 머리 — 몸은 A 가 채움. 계약은 `spreadsheet_types.ts`.
* 계약은 `spreadsheet_types.ts`.
* ========================================================================== */
import { MAX_COLS, MAX_ROWS } from "./spreadsheet_types";
import type { CellAddress, CellRange } from "./spreadsheet_types";
const todo = (): never => {
throw new Error("spreadsheet_address: 아직 없음(A)");
};
/** 0 → `A` · 27 → `AB` */
export function colName(_c: number): string {
return todo();
export function colName(c: number): string {
let s = "";
for (let n = c + 1; n > 0; n = Math.floor((n - 1) / 26)) {
s = String.fromCharCode(65 + ((n - 1) % 26)) + s;
}
return s;
}
/** `AB` → 27 · 못 읽으면 -1 (대소문자 가리지 않음) */
export function colIndex(_letters: string): number {
return todo();
export function colIndex(letters: string): number {
if (!/^[A-Za-z]{1,3}$/.test(letters)) return -1;
let n = 0;
for (const ch of letters.toUpperCase()) n = n * 26 + ch.charCodeAt(0) - 64;
return n <= MAX_COLS ? n - 1 : -1;
}
/** 행 글(1 부터) → 0 부터 · 못 읽으면 -1 */
export function rowIndex(digits: string): number {
const n = /^\d{1,7}$/.test(digits) ? Number(digits) : 0;
return n >= 1 && n <= MAX_ROWS ? n - 1 : -1;
}
/** (30, 4) → `E31` */
export function toA1(_r: number, _c: number): string {
return todo();
export function toA1(r: number, c: number): string {
return colName(c) + (r + 1);
}
/** `E31` · `$E$31` → {r:30, c:4} · 못 읽으면 null */
export function parseA1(_text: string): CellAddress | null {
return todo();
export function parseA1(text: string): CellAddress | null {
const m = /^\$?([A-Za-z]{1,3})\$?(\d+)$/.exec(text.trim());
if (!m) return null;
const c = colIndex(m[1]);
const r = rowIndex(m[2]);
return c < 0 || r < 0 ? null : { r, c };
}
/** 칸 하나면 `E31` · 아니면 `C3:AD3` · 행 전체 `3:3` · 열 전체 `A:A` */
export function rangeToA1(_range: CellRange): string {
return todo();
export function rangeToA1(range: CellRange): string {
const { r0, c0, r1, c1 } = range;
if (c0 === 0 && c1 === MAX_COLS - 1) return `${r0 + 1}:${r1 + 1}`;
if (r0 === 0 && r1 === MAX_ROWS - 1) return `${colName(c0)}:${colName(c1)}`;
const a = toA1(r0, c0);
return r0 === r1 && c0 === c1 ? a : `${a}:${toA1(r1, c1)}`;
}
/** 닫힌 범위로 세움(뒤집힌 것 바로) */
export function normRange(r0: number, c0: number, r1: number, c1: number): CellRange {
return {
r0: Math.min(r0, r1),
c0: Math.min(c0, c1),
r1: Math.max(r0, r1),
c1: Math.max(c0, c1),
};
}
/** `C3:AD3` · `E31` · `A:A` · `3:5` → 범위(뒤집힌 글은 바로 세움) · 못 읽으면 null */
export function parseRange(_text: string): CellRange | null {
return todo();
export function parseRange(text: string): CellRange | null {
const parts = text.trim().split(":");
if (parts.length > 2) return null;
const [a, b = a] = parts;
const p = parseA1(a);
const q = parseA1(b);
if (p && q) return normRange(p.r, p.c, q.r, q.c);
if (parts.length !== 2) return null;
const strip = (s: string) => s.replace(/^\$/, "");
const ca = colIndex(strip(a));
const cb = colIndex(strip(b));
if (ca >= 0 && cb >= 0) return normRange(0, ca, MAX_ROWS - 1, cb);
const ra = rowIndex(strip(a));
const rb = rowIndex(strip(b));
if (ra >= 0 && rb >= 0) return normRange(ra, 0, rb, MAX_COLS - 1);
return null;
}
export function inRange(range: CellRange, r: number, c: number): boolean {
return r >= range.r0 && r <= range.r1 && c >= range.c0 && c <= range.c1;
}
export function rangesOverlap(a: CellRange, b: CellRange): boolean {
return a.r0 <= b.r1 && b.r0 <= a.r1 && a.c0 <= b.c1 && b.c0 <= a.c1;
}
/** `a` 가 `b` 안에 통째로 듦 */
export function rangeInside(a: CellRange, b: CellRange): boolean {
return a.r0 >= b.r0 && a.r1 <= b.r1 && a.c0 >= b.c0 && a.c1 <= b.c1;
}
/** 식에 넣을 시트 이름 — 글자 · 숫자 · _ 밖이 있으면 `'…'`(안의 `'` 는 `''`) */
export function quoteSheet(_name: string): string {
return todo();
export function quoteSheet(name: string): string {
const plain =
/^[\p{L}_][\p{L}\p{N}_]*$/u.test(name) &&
!/^[A-Za-z]{1,3}\d+$/.test(name) && // 칸 주소처럼 보이는 이름
!/^(TRUE|FALSE)$/i.test(name) &&
!/^R\d*C\d*$/i.test(name); // R1C1 처럼 보이는 이름
return plain ? name : `'${name.replace(/'/g, "''")}'`;
}
/** Map 열쇠 — r × MAX_COLS + c */
export function cellId(_r: number, _c: number): number {
return todo();
export function cellId(r: number, c: number): number {
return r * MAX_COLS + c;
}
export function fromCellId(_id: number): CellAddress {
return todo();
export function fromCellId(id: number): CellAddress {
return { r: Math.floor(id / MAX_COLS), c: id % MAX_COLS };
}
+597 -14
View File
@@ -4,32 +4,615 @@
* 병합 규칙(엑셀): 병합 일부를 가르는 칸 쓰기 · 옮기기는 막음(오류 던짐 → 화면이 알림) ·
* 병합 안에 행열을 넣으면 병합이 늘고 지우면 줆 · 병합하면 왼위 칸 값만 남김.
* 서식 표는 같은 서식을 한 번만 둠(칸은 번호) · 안 쓰는 서식 정리는 저장 전에(`compactStyles`).
* 0 계약 머리 — 몸은 A 가 채움.
* 행열 · 옮기기 · 시트 지우기는 바뀐 시트를 통째로 되돌림(`시트통째`) — 다른 시트 식 글까지 한 번에.
* ========================================================================== */
import type { Command, CommandEffect, Sheet, Workbook } from "./spreadsheet_types";
const todo = (): never => {
throw new Error("spreadsheet_commands: 아직 없음(A)");
};
import {
colName,
inRange,
normRange,
parseA1,
parseRange,
rangeInside,
rangesOverlap,
rangeToA1,
toA1,
} from "./spreadsheet_address";
import { shiftForCommand, shiftSpan } from "./spreadsheet_refshift";
import { MAX_COLS, MAX_ROWS } from "./spreadsheet_types";
import type {
Cell,
CellRange,
CellStyle,
Command,
CommandEffect,
Sheet,
SheetCellAddress,
Workbook,
} from "./spreadsheet_types";
/** 막힌 명령(병합 가름 · 마지막 시트 지우기 · 같은 시트 이름 …) — 화면이 `message` 를 알림 */
export class CommandError extends Error {}
export function applyCommand(_book: Workbook, _command: Command): CommandEffect {
return todo();
type Of<K extends Command["종류"]> = Extract<Command, { 종류: K }>;
const clone = <T>(x: T): T => structuredClone(x);
function sheetOf(book: Workbook, id: string): Sheet {
const s = book.시트.find((x) => x.id === id);
if (!s) throw new CommandError(`없는 시트: ${id}`);
return s;
}
const mergesOf = (s: Sheet): CellRange[] =>
(s.병합 ?? []).map((t) => parseRange(t)).filter((r): r is CellRange => r !== null);
function setMerges(s: Sheet, list: CellRange[]) {
if (list.length) s.병합 = list.map(rangeToA1);
else delete s.병합;
}
/** 칸 열쇠 중 범위 안 것 */
function cellsIn(s: Sheet, ranges: CellRange[]): [string, number, number][] {
const out: [string, number, number][] = [];
for (const a1 of Object.keys(s.칸)) {
const at = parseA1(a1);
if (at && ranges.some((rg) => inRange(rg, at.r, at.c))) out.push([a1, at.r, at.c]);
}
return out;
}
const isFull = (rg: CellRange, rows: boolean) =>
rows ? rg.c0 === 0 && rg.c1 === MAX_COLS - 1 : rg.r0 === 0 && rg.r1 === MAX_ROWS - 1;
function sortKeys(v: unknown): unknown {
if (!v || typeof v !== "object" || Array.isArray(v)) return v;
const o = v as Record<string, unknown>;
return Object.fromEntries(
Object.keys(o)
.sort()
.map((k) => [k, sortKeys(o[k])]),
);
}
const styleKey = (s: CellStyle) => JSON.stringify(sortKeys(s));
function internStyle(book: Workbook, style: CellStyle): number {
const key = styleKey(style);
const i = book.서식.findIndex((s) => styleKey(s) === key);
if (i >= 0) return i;
book.서식.push(style);
return book.서식.length - 1;
}
/** 칸 자리에서 물려받는 서식(칸 서식 없을 때) — 행 · 열 · 0 */
const inherited = (s: Sheet, r: number, c: number) =>
s.행?.[r + 1]?.서식 ?? s.열?.[colName(c)]?.서식 ?? 0;
const addr = (시트: string, r: number, c: number): SheetCellAddress => ({ 시트, r, c });
/** 바뀐 시트만 통째 되돌림 짝 */
function restoreChanged(book: Workbook, old: Sheet[]): Command[] {
const out: Command[] = [];
for (const o of old) {
const now = book.시트.find((x) => x.id === o.id);
if (now && JSON.stringify(now) !== JSON.stringify(o)) out.push({ 종류: "시트통째", 시트: o });
}
return out;
}
/** 모든 식 글을 명령에 맞춰 고침 — 시트 이름을 바꾸기 전에 부름 */
function rewriteAll(book: Workbook, command: Command) {
for (const s of book.시트)
for (const cell of Object.values(s.칸))
if (cell.식 !== undefined) cell.식 = shiftForCommand(cell.식, s.id, command, book);
}
function checkSheetName(book: Workbook, name: string, self?: string) {
if (!name.trim() || name.length > 31) throw new CommandError("시트 이름은 1~31 글자");
if (/[[\]:*?/\\]/.test(name) || name.startsWith("'") || name.endsWith("'"))
throw new CommandError("시트 이름에 못 쓰는 글자: [ ] : * ? / \\ 와 앞뒤 '");
if (book.시트.some((s) => s.id !== self && s.이름.toLowerCase() === name.toLowerCase()))
throw new CommandError(`같은 이름 시트가 있음: ${name}`);
}
// ── 칸 ────────────────────────────────────────────────────────────────────
function setCells(book: Workbook, cmd: Of<"칸">): CommandEffect {
const s = sheetOf(book, cmd.시트);
const merges = mergesOf(s);
const plan: [string, number, number, Cell | null][] = [];
for (const [key, cell] of Object.entries(cmd.칸)) {
const at = parseA1(key);
if (!at) throw new CommandError(`칸 주소 틀림: ${key}`);
if (cell && (cell.값 !== undefined || cell.식 !== undefined))
if (merges.some((m) => inRange(m, at.r, at.c) && (m.r0 !== at.r || m.c0 !== at.c)))
throw new CommandError("병합한 칸은 왼위 칸에만 값을 넣음");
plan.push([toA1(at.r, at.c), at.r, at.c, cell]);
}
const prev: Record<string, Cell | null> = {};
for (const [a1, , , cell] of plan) {
prev[a1] = s.칸[a1] ? clone(s.칸[a1]) : null;
if (cell) s.칸[a1] = clone(cell);
else delete s.칸[a1];
}
return {
undo: { 종류: "칸", 시트: s.id, 칸: prev },
cells: plan.map(([, r, c]) => addr(s.id, r, c)),
rebuild: false,
};
}
function clearContents(book: Workbook, cmd: Of<"내용지움">): CommandEffect {
const s = sheetOf(book, cmd.시트);
const prev: Record<string, Cell | null> = {};
const cells: SheetCellAddress[] = [];
for (const [a1, r, c] of cellsIn(s, cmd.범위)) {
const cell = s.칸[a1];
if (cell.값 === undefined && cell.식 === undefined) continue;
prev[a1] = clone(cell);
if (cell.서식 === undefined) delete s.칸[a1];
else s.칸[a1] = { 서식: cell.서식 };
cells.push(addr(s.id, r, c));
}
return { undo: { 종류: "칸", 시트: s.id, 칸: prev }, cells, rebuild: false };
}
// ── 서식 ────────────────────────────────────────────────────────────────────
/** 범위 서식 바꿈 — `change(지금 번호) → 새 번호`. 행 · 열 전체는 행 · 열 서식 + 든 칸. */
function restyle(
book: Workbook,
sheet: string,
ranges: CellRange[],
change: (i: number) => number,
): CommandEffect {
const s = sheetOf(book, sheet);
const before = clone(s);
const memo = new Map<number, number>();
const f = (i: number) => {
if (!memo.has(i)) memo.set(i, change(i));
return memo.get(i)!;
};
const prev: Record<string, Cell | null> = {};
const cells: SheetCellAddress[] = [];
let whole = false;
const setInfo = (axis: "행" | "열", key: string) => {
const table = (s[axis] ??= {});
const info = (table[key] ??= {});
const idx = f(info.서식 ?? 0);
if (idx) info.서식 = idx;
else delete info.서식;
if (!Object.keys(info).length) delete table[key];
};
const touch = (r: number, c: number) => {
const a1 = toA1(r, c);
if (a1 in prev) return;
const cell = s.칸[a1];
prev[a1] = cell ? clone(cell) : null;
const next: Cell = { ...cell };
const idx = f(cell?.서식 ?? inherited(s, r, c));
if (idx === inherited(s, r, c)) delete next.서식;
else next.서식 = idx;
if (Object.keys(next).length) s.칸[a1] = next;
else delete s.칸[a1];
cells.push(addr(s.id, r, c));
};
for (const rg of ranges) {
const fullRows = isFull(rg, true);
const fullCols = isFull(rg, false);
if (fullRows && fullCols) {
// ponytail: 시트 통째 서식은 든 칸 · 있는 행 · 열 서식만 — 시트 기본 서식 칸이 계약에 없음
whole = true;
for (const k of Object.keys(s.행 ?? {})) setInfo("행", k);
for (const k of Object.keys(s.열 ?? {})) setInfo("열", k);
} else if (fullRows || fullCols) {
whole = true;
for (let i = fullRows ? rg.r0 : rg.c0; i <= (fullRows ? rg.r1 : rg.c1); i++)
setInfo(fullRows ? "행" : "열", fullRows ? String(i + 1) : colName(i));
} else {
if ((rg.r1 - rg.r0 + 1) * (rg.c1 - rg.c0 + 1) > 1_000_000)
throw new CommandError("범위가 너무 큼");
for (let r = rg.r0; r <= rg.r1; r++) for (let c = rg.c0; c <= rg.c1; c++) touch(r, c);
continue;
}
for (const [, r, c] of cellsIn(s, [rg])) touch(r, c);
}
if (s.행 && !Object.keys(s.행).length) delete s.행;
if (s.열 && !Object.keys(s.열).length) delete s.열;
return whole
? { undo: { 종류: "시트통째", 시트: before }, cells, rebuild: true }
: { undo: { 종류: "칸", 시트: s.id, 칸: prev }, cells, rebuild: false };
}
function patchStyle(book: Workbook, cmd: Of<"서식">): CommandEffect {
return restyle(book, cmd.시트, cmd.범위, (i) => {
const next: Record<string, unknown> = { ...book.서식[i] };
for (const [k, v] of Object.entries(cmd.바꿀)) {
if (v === null) delete next[k];
else if (v !== undefined) next[k] = clone(v);
}
return internStyle(book, next as CellStyle);
});
}
// ── 행열 넣기 · 지우기 ──────────────────────────────────────────────────────
function insertDelete(
book: Workbook,
cmd: Of<"행넣기" | "행지우기" | "열넣기" | "열지우기">,
): CommandEffect {
const s = sheetOf(book, cmd.시트);
const rows = cmd.종류.startsWith("행");
const insert = cmd.종류.endsWith("넣기");
const max = rows ? MAX_ROWS : MAX_COLS;
const { at, 수: n } = cmd;
if (n < 1 || at < 0 || at >= max || (!insert && at + n > max))
throw new CommandError("넣기 · 지우기 자리가 틀림");
const pos = (a1: string) => {
const p = parseA1(a1)!;
return rows ? p.r : p.c;
};
if (insert && Object.keys(s.칸).some((a1) => pos(a1) + n >= max))
throw new CommandError("격자 끝 칸이 밀려 나감 — 끝 쪽 칸을 먼저 지움");
const old = book.시트.map(clone);
rewriteAll(book, cmd);
const span = (i: number) => shiftSpan(i, i, insert, at, n, max)?.[0];
const cells: Record<string, Cell> = {};
for (const [a1, cell] of Object.entries(s.칸)) {
const p = parseA1(a1)!;
const i = span(rows ? p.r : p.c);
if (i !== undefined) cells[rows ? toA1(i, p.c) : toA1(p.r, i)] = cell;
}
s.칸 = cells;
const merges: CellRange[] = [];
for (const m of mergesOf(s)) {
const sp = rows
? shiftSpan(m.r0, m.r1, insert, at, n, max)
: shiftSpan(m.c0, m.c1, insert, at, n, max);
if (!sp) continue;
const next = rows ? { ...m, r0: sp[0], r1: sp[1] } : { ...m, c0: sp[0], c1: sp[1] };
if (next.r0 !== next.r1 || next.c0 !== next.c1) merges.push(next);
}
setMerges(s, merges);
const axis = rows ? "행" : "열";
const table = s[axis];
if (table) {
const next: Record<string, (typeof table)[string]> = {};
for (const [k, v] of Object.entries(table)) {
const i = span(rows ? Number(k) - 1 : parseA1(`${k}1`)!.c);
if (i !== undefined) next[rows ? String(i + 1) : colName(i)] = v;
}
if (Object.keys(next).length) s[axis] = next;
else delete s[axis];
}
const key = rows ? "행" : "열";
const frozen = s.틀고정?.[key];
if (frozen && at < frozen)
s.틀고정![key] = insert ? frozen + n : frozen - (Math.min(frozen, at + n) - at);
return { undo: { 종류: "묶음", 명령: restoreChanged(book, old) }, cells: [], rebuild: true };
}
// ── 옮기기 (잘라 붙이기) ─────────────────────────────────────────────────────
function move(book: Workbook, cmd: Of<"옮기기">): CommandEffect {
const s = sheetOf(book, cmd.시트);
const t = sheetOf(book, cmd.대상시트);
const src = normRange(cmd.범위.r0, cmd.범위.c0, cmd.범위.r1, cmd.범위.c1);
const dr = cmd.r - src.r0;
const dc = cmd.c - src.c0;
const dst: CellRange = { r0: cmd.r, c0: cmd.c, r1: src.r1 + dr, c1: src.c1 + dc };
if (dst.r0 < 0 || dst.c0 < 0 || dst.r1 >= MAX_ROWS || dst.c1 >= MAX_COLS)
throw new CommandError("옮길 자리가 격자 밖");
const cuts = (list: CellRange[], rg: CellRange) =>
list.some((m) => rangesOverlap(m, rg) && !rangeInside(m, rg));
if (cuts(mergesOf(s), src) || cuts(mergesOf(t), dst))
throw new CommandError("병합 일부를 가르는 옮기기는 못 함");
if (s === t && dr === 0 && dc === 0)
return { undo: { 종류: "묶음", 명령: [] }, cells: [], rebuild: false };
const old = book.시트.map(clone);
const moved: [number, number, Cell][] = [];
for (const [a1, r, c] of cellsIn(s, [src])) {
const cell = s.칸[a1];
if (cell.식 !== undefined) cell.식 = shiftForCommand(cell.식, s.id, cmd, book, t.id);
moved.push([r + dr, c + dc, cell]);
delete s.칸[a1];
}
rewriteAll(book, cmd);
for (const [a1] of cellsIn(t, [dst])) delete t.칸[a1];
for (const [r, c, cell] of moved) t.칸[toA1(r, c)] = cell;
const sm = mergesOf(s);
const carried = sm.filter((m) => rangeInside(m, src));
setMerges(
s,
sm.filter((m) => !rangeInside(m, src)),
);
setMerges(t, [
...mergesOf(t).filter((m) => !rangeInside(m, dst)),
...carried.map((m) => ({ r0: m.r0 + dr, c0: m.c0 + dc, r1: m.r1 + dr, c1: m.c1 + dc })),
]);
return { undo: { 종류: "묶음", 명령: restoreChanged(book, old) }, cells: [], rebuild: true };
}
// ── 병합 ────────────────────────────────────────────────────────────────────
function merge(book: Workbook, cmd: Of<"병합">): CommandEffect {
const s = sheetOf(book, cmd.시트);
const rg = normRange(cmd.범위.r0, cmd.범위.c0, cmd.범위.r1, cmd.범위.c1);
if (rg.r0 === rg.r1 && rg.c0 === rg.c1)
return { undo: { 종류: "묶음", 명령: [] }, cells: [], rebuild: false };
const list = mergesOf(s);
if (list.some((m) => rangesOverlap(m, rg) && !rangeInside(m, rg)))
throw new CommandError("다른 병합과 걸침");
const absorbed = list.filter((m) => rangeInside(m, rg));
const prev: Record<string, Cell | null> = {};
const cells: SheetCellAddress[] = [];
for (const [a1, r, c] of cellsIn(s, [rg])) {
const cell = s.칸[a1];
if ((r === rg.r0 && c === rg.c0) || (cell.값 === undefined && cell.식 === undefined)) continue;
prev[a1] = clone(cell);
if (cell.서식 === undefined) delete s.칸[a1];
else s.칸[a1] = { 서식: cell.서식 };
cells.push(addr(s.id, r, c));
}
setMerges(s, [...list.filter((m) => !rangeInside(m, rg)), rg]);
const undo: Command[] = [
{ 종류: "병합풀기", 시트: s.id, 범위: rg },
...absorbed.map((m): Command => ({ 종류: "병합", 시트: s.id, 범위: m })),
{ 종류: "칸", 시트: s.id, 칸: prev },
];
return { undo: { 종류: "묶음", 명령: undo }, cells, rebuild: false };
}
function unmerge(book: Workbook, cmd: Of<"병합풀기">): CommandEffect {
const s = sheetOf(book, cmd.시트);
const list = mergesOf(s);
const gone = list.filter((m) => rangesOverlap(m, cmd.범위));
setMerges(
s,
list.filter((m) => !rangesOverlap(m, cmd.범위)),
);
const undo = gone.map((m): Command => ({ 종류: "병합", 시트: s.id, 범위: m }));
return { undo: { 종류: "묶음", 명령: undo }, cells: [], rebuild: false };
}
// ── 행 · 열 모양 ─────────────────────────────────────────────────────────────
/** 행 · 열 정보 한 속성 바꿈 → 원래 값끼리 묶은 되돌림 짝 */
function setAxis<V>(
s: Sheet,
axis: "행" | "열",
idx: number[],
prop: "폭" | "높이" | "숨김",
value: V | null,
undoOf: (idx: number[], prev: V | null) => Command,
): Command {
const table = ((s as unknown as Record<string, Record<string, Record<string, unknown>>>)[axis] ??=
{});
const groups = new Map<string, number[]>();
for (const i of idx) {
const k = axis === "열" ? colName(i) : String(i + 1);
const prev = table[k]?.[prop];
const g = JSON.stringify(prev ?? null);
groups.set(g, [...(groups.get(g) ?? []), i]);
const info = (table[k] ??= {});
if (value === null || value === false) delete info[prop];
else info[prop] = value;
if (!Object.keys(info).length) delete table[k];
}
if (!Object.keys(table).length) delete s[axis];
return { 종류: "묶음", 명령: [...groups].map(([g, list]) => undoOf(list, JSON.parse(g))) };
}
// ── 시트 ────────────────────────────────────────────────────────────────────
function addSheet(book: Workbook, cmd: Of<"시트더하기">): CommandEffect {
if (book.시트.some((s) => s.id === cmd.시트.id))
throw new CommandError(`같은 id 시트가 있음: ${cmd.시트.id}`);
checkSheetName(book, cmd.시트.이름);
book.시트.splice(Math.max(0, Math.min(cmd.자리, book.시트.length)), 0, clone(cmd.시트));
return { undo: { 종류: "시트지우기", 시트: cmd.시트.id }, cells: [], rebuild: true };
}
function removeSheet(book: Workbook, cmd: Of<"시트지우기">): CommandEffect {
const s = sheetOf(book, cmd.시트);
if (book.시트.length === 1) throw new CommandError("마지막 시트는 못 지움");
const at = book.시트.indexOf(s);
const old = book.시트.map(clone);
rewriteAll(book, cmd);
book.시트.splice(at, 1);
if (book.활성 === s.id) book.활성 = book.시트[Math.min(at, book.시트.length - 1)].id;
const undo: Command[] = [
{ 종류: "시트더하기", 시트: old[at], 자리: at },
...restoreChanged(book, old),
];
return { undo: { 종류: "묶음", 명령: undo }, cells: [], rebuild: true };
}
function renameSheet(book: Workbook, cmd: Of<"시트이름">): CommandEffect {
const s = sheetOf(book, cmd.시트);
checkSheetName(book, cmd.이름, s.id);
const prev = s.이름;
rewriteAll(book, cmd);
s.이름 = cmd.이름;
return { undo: { 종류: "시트이름", 시트: s.id, 이름: prev }, cells: [], rebuild: true };
}
function moveSheet(book: Workbook, cmd: Of<"시트옮기기">): CommandEffect {
const s = sheetOf(book, cmd.시트);
const from = book.시트.indexOf(s);
book.시트.splice(from, 1);
book.시트.splice(Math.max(0, Math.min(cmd.자리, book.시트.length)), 0, s);
return { undo: { 종류: "시트옮기기", 시트: s.id, 자리: from }, cells: [], rebuild: false };
}
function replaceSheet(book: Workbook, cmd: Of<"시트통째">): CommandEffect {
const at = book.시트.findIndex((x) => x.id === cmd.시트.id);
if (at < 0) throw new CommandError(`없는 시트: ${cmd.시트.id}`);
const prev = book.시트[at];
book.시트[at] = clone(cmd.시트);
const undo: Of<"시트통째"> = { 종류: "시트통째", 시트: prev };
if (cmd.서식) {
undo.서식 = book.서식;
book.서식 = clone(cmd.서식);
}
return { undo, cells: [], rebuild: true };
}
function batch(book: Workbook, cmd: Of<"묶음">): CommandEffect {
const done: CommandEffect[] = [];
try {
for (const c of cmd.명령) done.push(applyCommand(book, c));
} catch (e) {
for (const d of done.reverse()) applyCommand(book, d.undo);
throw e;
}
return {
undo: { 종류: "묶음", 명령: done.map((d) => d.undo).reverse() },
cells: done.flatMap((d) => d.cells),
rebuild: done.some((d) => d.rebuild),
};
}
// ── 들머리 ──────────────────────────────────────────────────────────────────
export function applyCommand(book: Workbook, command: Command): CommandEffect {
const plain = (undo: Command): CommandEffect => ({ undo, cells: [], rebuild: false });
switch (command.종류) {
case "칸":
return setCells(book, command);
case "내용지움":
return clearContents(book, command);
case "서식":
return patchStyle(book, command);
case "서식지움":
return restyle(book, command.시트, command.범위, () => 0);
case "행넣기":
case "행지우기":
case "열넣기":
case "열지우기":
return insertDelete(book, command);
case "옮기기":
return move(book, command);
case "병합":
return merge(book, command);
case "병합풀기":
return unmerge(book, command);
case "열폭": {
const s = sheetOf(book, command.시트);
return plain(
setAxis(s, "열", command.열, "폭", command.폭, (열, 폭) => ({
종류: "열폭",
시트: s.id,
열,
폭,
})),
);
}
case "행높이": {
const s = sheetOf(book, command.시트);
return plain(
setAxis(s, "행", command.행, "높이", command.높이, (행, 높이) => ({
종류: "행높이",
시트: s.id,
행,
높이,
})),
);
}
case "숨김": {
const s = sheetOf(book, command.시트);
const { 축 } = command;
return plain(
setAxis(s, 축, command.번호, "숨김", command.숨김, (번호, prev) => ({
종류: "숨김",
시트: s.id,
축,
번호,
숨김: prev ?? false,
})),
);
}
case "틀고정": {
const s = sheetOf(book, command.시트);
const prev = { 행: s.틀고정?.행 ?? 0, 열: s.틀고정?.열 ?? 0 };
const next: Sheet["틀고정"] = {};
if (command.행 > 0) next.행 = command.행;
if (command.열 > 0) next.열 = command.열;
if (Object.keys(next).length) s.틀고정 = next;
else delete s.틀고정;
return plain({ 종류: "틀고정", 시트: s.id, ...prev });
}
case "눈금선": {
const s = sheetOf(book, command.시트);
const prev = s.보기?.눈금선 ?? true;
if (command.보임) {
delete s.보기?.눈금선;
if (s.보기 && !Object.keys(s.보기).length) delete s.보기;
} else (s.보기 ??= {}).눈금선 = false;
return plain({ 종류: "눈금선", 시트: s.id, 보임: prev });
}
case "시트더하기":
return addSheet(book, command);
case "시트지우기":
return removeSheet(book, command);
case "시트이름":
return renameSheet(book, command);
case "시트옮기기":
return moveSheet(book, command);
case "시트통째":
return replaceSheet(book, command);
case "묶음":
return batch(book, command);
}
}
/** 빈 통합문서 — 시트 하나(`s1` · 이름은 부른 쪽) · 서식 [{}] */
export function emptyWorkbook(_column: string, _sheetName: string): Workbook {
return todo();
export function emptyWorkbook(column: string, sheetName: string): Workbook {
return {
종류: "통합문서",
판: 1,
열: column,
서식: [{}],
시트: [emptySheet("s1", sheetName)],
활성: "s1",
};
}
export function emptySheet(_id: string, _name: string): Sheet {
return todo();
export function emptySheet(id: string, name: string): Sheet {
return { id, 이름: name, 칸: {} };
}
/** 안 쓰는 서식을 빼고 번호를 다시 매김 — 저장 직전(되돌리기 짝은 안 만듦) */
export function compactStyles(_book: Workbook): void {
return todo();
export function compactStyles(book: Workbook): void {
const next: CellStyle[] = [];
const byKey = new Map<string, number>();
const remap = new Map<number, number>();
const map = (i: number) => {
let j = remap.get(i);
if (j === undefined) {
const style = book.서식[i] ?? {};
const k = styleKey(style);
j = byKey.get(k);
if (j === undefined) {
j = next.push(style) - 1;
byKey.set(k, j);
}
remap.set(i, j);
}
return j;
};
map(0);
for (const s of book.시트) {
for (const cell of Object.values(s.칸)) if (cell.서식 !== undefined) cell.서식 = map(cell.서식);
for (const info of [...Object.values(s.행 ?? {}), ...Object.values(s.열 ?? {})])
if (info.서식 !== undefined) info.서식 = map(info.서식);
}
book.서식 = next;
}
+250 -21
View File
@@ -3,9 +3,24 @@
* 나무 풀기 · 오류 값 전파 · 형 바꾸기(엑셀 규칙). B 의 함수들이 형 바꾸기 도우미를 씀.
* 빈 칸 = 수에서 0 · 글에서 "" · `"12"+1 = 13` · `TRUE+1 = 2` · 글이 수로 안 읽히면 `#VALUE!`.
* 무리수(SQRT · 삼각 · PI · 소수 거듭제곱)는 double → 유효 15 자리 십진 → 분수(B · A 같이 `fromDouble`).
* 0 계약 머리 — 몸은 A 가 채움.
* 오류 전파 = 왼쪽부터 처음 만난 오류. 비교 = 수 < 글 < 참거짓 · 글은 대소문자 가리지 않음.
* ========================================================================== */
import {
add,
cmp,
div,
frac,
fracToString,
mul,
parseDecimal,
roundAt,
sub,
toFrac,
ZERO,
} from "@ui/sheet/ui_template_sheet_frac";
import { normRange } from "./spreadsheet_address";
import { getFunction } from "./spreadsheet_functions";
import type {
CalcValue,
ErrorCode,
@@ -14,45 +29,259 @@ import type {
EvalResult,
FormulaNode,
Frac,
RangeValue,
Scalar,
} from "./spreadsheet_types";
const todo = (): never => {
throw new Error("spreadsheet_eval: 아직 없음(A)");
};
export function evaluate(_node: FormulaNode, _ctx: EvalContext): EvalResult {
return todo();
export function err(code: ErrorCode, why?: string): ErrorValue {
return why === undefined ? { error: code } : { error: code, why };
}
export function err(_code: ErrorCode, _why?: string): ErrorValue {
return todo();
export function isError(value: unknown): value is ErrorValue {
return typeof value === "object" && value !== null && "error" in value;
}
export function isError(_value: unknown): _value is ErrorValue {
return todo();
export function isFrac(value: unknown): value is Frac {
return typeof value === "object" && value !== null && "n" in value && "d" in value;
}
export function isRange(value: unknown): value is RangeValue {
return typeof value === "object" && value !== null && (value as RangeValue).kind === "range";
}
const ONE = frac(1n);
const HUNDRED = frac(100n);
const BIG_D = 10n ** 40n;
/** 분모가 너무 커지면(나눗셈 사슬) 30 자리 십진으로 굳힘 */
export function tame(x: Frac): Frac {
return x.d > BIG_D ? parseDecimal(fracToString(x)) : x;
}
/** 범위 → 칸 하나(1×1 일 때만) · 아니면 `#VALUE!` */
function single(value: EvalResult): Scalar {
if (!isRange(value)) return value;
return value.rows === 1 && value.cols === 1
? value.at(0, 0)
: err("#VALUE!", "범위를 값 하나로 못 씀");
}
/** 수로 — 빈 칸 0 · 참거짓 1/0 · 수 글 · 아니면 `#VALUE!` · 범위는 칸 하나일 때만 */
export function asNumber(_value: EvalResult): Frac | ErrorValue {
return todo();
export function asNumber(value: EvalResult): Frac | ErrorValue {
const v = single(value);
if (v === null) return ZERO;
if (typeof v === "boolean") return v ? ONE : ZERO;
if (typeof v === "string") {
const t = v.trim();
const pct = t.endsWith("%");
try {
const n = parseDecimal(pct ? t.slice(0, -1) : t);
return pct ? div(n, HUNDRED) : n;
} catch {
return err("#VALUE!", `수로 못 읽는 글: "${v}"`);
}
}
return v;
}
/** 일반 형식 글 — 유효 15 자리 · 1e15 넘거나 1e-9 밑이면 `1.5E+18` 꼴(엑셀 `&` 와 같게) */
export function numberText(x: Frac): string {
if (x.n === 0n) return "0";
const d = parseFloat(fracToString(x));
const e = Math.floor(Math.log10(Math.abs(d)));
if (e >= 15 || e < -9) {
const [m, ex] = d.toExponential(14).split("e");
const mant = m.replace(/\.?0+$/, "");
const n = Number(ex);
return `${mant}E${n < 0 ? "-" : "+"}${String(Math.abs(n)).padStart(2, "0")}`;
}
return fracToString(roundAt(x, 14 - e, "round"));
}
/** 글로 — 수는 일반 형식 글(엑셀 `&` 와 같게) · 참거짓 `TRUE`/`FALSE` */
export function asText(_value: EvalResult): string | ErrorValue {
return todo();
export function asText(value: EvalResult): string | ErrorValue {
const v = single(value);
if (v === null) return "";
if (typeof v === "string" || isError(v)) return v;
if (typeof v === "boolean") return v ? "TRUE" : "FALSE";
return numberText(v);
}
export function asBool(_value: EvalResult): boolean | ErrorValue {
return todo();
export function asBool(value: EvalResult): boolean | ErrorValue {
const v = single(value);
if (v === null) return false;
if (typeof v === "boolean" || isError(v)) return v;
if (typeof v === "string") {
const t = v.toUpperCase();
return t === "TRUE"
? true
: t === "FALSE"
? false
: err("#VALUE!", `참거짓으로 못 읽는 글: "${v}"`);
}
return v.n !== 0n;
}
/** double → 유효 15 자리 십진 → 분수 · NaN · 무한은 `#NUM!` */
export function fromDouble(_x: number): Frac | ErrorValue {
return todo();
export function fromDouble(x: number): Frac | ErrorValue {
if (!Number.isFinite(x)) return err("#NUM!", "수가 너무 크거나 뜻이 없음");
return x === 0 ? ZERO : parseDecimal(x.toPrecision(15));
}
export const toDouble = (x: Frac): number => parseFloat(fracToString(x));
/** 저장 모양 — 수는 십진 글(30 자리까지) */
export function toCalcValue(_value: Scalar): CalcValue {
return todo();
export function toCalcValue(value: Scalar): CalcValue {
if (value === null) return { 수: "0" };
if (typeof value === "string") return { 글: value };
if (typeof value === "boolean") return { 참: value };
if (isError(value))
return value.why ? { 오류: value.error, 까닭: value.why } : { 오류: value.error };
return { 수: fracToString(value) };
}
// ── 연산 ────────────────────────────────────────────────────────────────────
function power(a: Frac, b: Frac): Frac | ErrorValue {
if (a.n === 0n) {
if (b.n === 0n) return err("#NUM!", "0^0");
return b.n < 0n ? err("#DIV/0!", "0 의 음수 거듭제곱") : ZERO;
}
if (b.d === 1n) {
const e = b.n < 0n ? -b.n : b.n;
const bits = BigInt(a.n.toString(2).length + a.d.toString(2).length);
// ponytail: 4,000 비트 넘는 정확 거듭제곱은 double 로 — 실무 식엔 없음
if (bits * e <= 4000n) {
const r = frac(a.n ** e, a.d ** e);
return tame(b.n < 0n ? div(ONE, r) : r);
}
} else if (a.n < 0n) return err("#NUM!", "음수의 소수 거듭제곱");
return fromDouble(Math.pow(toDouble(a), toDouble(b)));
}
function arith(op: string, a: Frac, b: Frac): Frac | ErrorValue {
switch (op) {
case "+":
return tame(add(a, b));
case "-":
return tame(sub(a, b));
case "*":
return tame(mul(a, b));
case "/":
return b.n === 0n ? err("#DIV/0!", "0 으로 나눔") : tame(div(a, b));
default:
return power(a, b);
}
}
const RANK = (v: Scalar) => (isFrac(v) ? 0 : typeof v === "string" ? 1 : 2);
/** 엑셀 비교 — 빈 칸은 맞은편 형의 빈 값(0 · "" · FALSE) · 형이 다르면 수 < 글 < 참거짓 */
export function compareValues(a: Scalar, b: Scalar): number {
const blank = (other: Scalar): Scalar =>
isFrac(other)
? ZERO
: typeof other === "string"
? ""
: typeof other === "boolean"
? false
: ZERO;
if (a === null) a = blank(b);
if (b === null) b = blank(a);
const ra = RANK(a);
const rb = RANK(b);
if (ra !== rb) return ra < rb ? -1 : 1;
if (isFrac(a)) return cmp(a, b as Frac);
if (typeof a === "string") {
const x = a.toLowerCase();
const y = (b as string).toLowerCase();
return x === y ? 0 : x < y ? -1 : 1;
}
return a === b ? 0 : a ? 1 : -1;
}
function compareOp(op: string, a: Scalar, b: Scalar): boolean {
const c = compareValues(a, b);
return op === "="
? c === 0
: op === "<>"
? c !== 0
: op === "<"
? c < 0
: op === "<="
? c <= 0
: op === ">"
? c > 0
: c >= 0;
}
// ── 풀기 ────────────────────────────────────────────────────────────────────
export function evaluate(node: FormulaNode, ctx: EvalContext): EvalResult {
switch (node.type) {
case "number":
return node.value;
case "string":
return node.value;
case "bool":
return node.value;
case "error":
return err(node.code);
case "name":
return err("#NAME?", `모르는 이름: ${node.name}`);
case "cell":
case "range": {
const sheet = node.sheet === null ? ctx.sheet : ctx.sheetId(node.sheet);
if (sheet === null) return err("#REF!", `없는 시트: ${node.sheet}`);
if (node.type === "cell") return ctx.cell(sheet, node.ref.r, node.ref.c);
return ctx.range(sheet, normRange(node.from.r, node.from.c, node.to.r, node.to.c));
}
case "unary": {
const v = evaluate(node.arg, ctx);
if (node.op === "+") return single(v);
const n = asNumber(v);
if (isError(n)) return n;
return node.op === "-" ? frac(-n.n, n.d) : div(n, HUNDRED);
}
case "binary": {
const left = evaluate(node.left, ctx);
const right = evaluate(node.right, ctx);
if (node.op === "&") {
const a = asText(left);
if (isError(a)) return a;
const b = asText(right);
return isError(b) ? b : a + b;
}
if (PLAIN_OPS.has(node.op)) {
const a = asNumber(left);
if (isError(a)) return a;
const b = asNumber(right);
return isError(b) ? b : arith(node.op, a, b);
}
const a = single(left);
if (isError(a)) return a;
const b = single(right);
return isError(b) ? b : compareOp(node.op, a, b);
}
case "call": {
const fn = getFunction(node.name);
if (!fn) return err("#NAME?", `모르는 함수: ${node.name}`);
const n = node.args.length;
if (n < fn.min || (fn.max !== undefined && n > fn.max)) {
const want = fn.max === fn.min ? `${fn.min}` : `${fn.min}~${fn.max ?? ""}`;
return err("#VALUE!", `${node.name} 인자 수 ${n} (받는 수 ${want})`);
}
return fn.call(
node.args.map((a) => () => evaluate(a, ctx)),
ctx,
);
}
}
}
const PLAIN_OPS = new Set(["+", "-", "*", "/", "^"]);
/** 수로 온 입력 값 → 풀이 값 */
export function inputValue(v: number | string | boolean): Scalar {
return typeof v === "number" ? toFrac(v) : v;
}
+307 -8
View File
@@ -1,17 +1,316 @@
/* =============================================================================
* spreadsheet_graph.ts (주인 A)
* 계산 엔진 — 식마다 가리키는 칸 · 범위로 의존 그래프 · 바뀐 칸에 딸린 칸만 차례로 다시 풂.
* 순환은 고리의 칸마다 `#CYCLE!`(까닭 = 고리 칸 목록) · 반복 계산 안 함 · 나머지 칸은 계속 풂.
* 식 나무는 식 글마다 캐시. 0 계약 머리 — 몸은 A 가 채움.
* 순환은 고리의 칸마다 `#CYCLE!`(까닭 = 고리 칸 목록) · 반복 계산 안 함 · 나머지 칸은 계속 풂
* (고리를 읽는 칸은 오류 전파로 `#CYCLE!`). 식 나무는 식 글마다 캐시.
* 풀이는 필요할 때 끌어 풂(앞 칸이 안 풀렸으면 먼저) — 차례는 저절로 위상 순서.
* ========================================================================== */
import type { CalcEngine, Workbook } from "./spreadsheet_types";
import { FormulaError, ZERO } from "@ui/sheet/ui_template_sheet_frac";
import { cellId, fromCellId, inRange, parseA1, toA1 } from "./spreadsheet_address";
import { err, evaluate, inputValue, isRange, toCalcValue } from "./spreadsheet_eval";
import { parseFormula } from "./spreadsheet_parser";
import type {
CalcEngine,
CalcValues,
Cell,
CellRange,
EvalContext,
EvalResult,
FormulaNode,
ParseResult,
RangeValue,
Scalar,
SheetCellAddress,
Workbook,
} from "./spreadsheet_types";
const todo = (): never => {
throw new Error("spreadsheet_graph: 아직 없음(A)");
};
interface SheetIndex {
id: string;
cells: Map<number, Cell>;
/** 값 든 칸의 가장 먼 행 · 열 — 행 · 열 전체 범위를 여기까지만 읽음 */
maxR: number;
maxC: number;
}
interface Dep {
sheet: string;
range: CellRange;
}
const DONE = 2;
const ACTIVE = 1;
const keyOf = (sheet: string, id: number) => `${sheet}!${id}`;
function splitKey(key: string): SheetCellAddress {
const i = key.lastIndexOf("!");
return { 시트: key.slice(0, i), ...fromCellId(Number(key.slice(i + 1))) };
}
/** 나무 안 칸 · 범위 참조(시트 이름 → id · 없는 시트는 뺌) */
function collectRefs(
node: FormulaNode,
host: string,
sheetId: (n: string) => string | null,
out: Dep[],
) {
switch (node.type) {
case "cell":
case "range": {
const sheet = node.sheet === null ? host : sheetId(node.sheet);
if (sheet === null) return;
const [a, b] = node.type === "cell" ? [node.ref, node.ref] : [node.from, node.to];
out.push({
sheet,
range: {
r0: Math.min(a.r, b.r),
c0: Math.min(a.c, b.c),
r1: Math.max(a.r, b.r),
c1: Math.max(a.c, b.c),
},
});
return;
}
case "unary":
return collectRefs(node.arg, host, sheetId, out);
case "binary":
collectRefs(node.left, host, sheetId, out);
return collectRefs(node.right, host, sheetId, out);
case "call":
for (const a of node.args) collectRefs(a, host, sheetId, out);
}
}
/** 문서를 참조로 쥠 — 만들 때 전부 풂. */
export function createCalcEngine(_book: Workbook): CalcEngine {
return todo();
export function createCalcEngine(book: Workbook): CalcEngine {
const parsed = new Map<string, ParseResult>();
let sheets = new Map<string, SheetIndex>();
let names = new Map<string, string>();
/** 식 칸 → 가리키는 칸 · 범위 */
let precs = new Map<string, Dep[]>();
/** 칸 하나 → 그 칸을 가리키는 식 칸 */
let cellDeps = new Map<string, Set<string>>();
/** 시트 → 범위를 가리키는 식 칸 */
let rangeDeps = new Map<string, Map<string, CellRange[]>>();
let vals = new Map<string, Scalar>();
let state = new Map<string, number>();
const stack: string[] = [];
const cycle = new Map<string, string>();
const sheetId = (name: string) => names.get(name.toLowerCase()) ?? null;
const parse = (text: string) => {
let p = parsed.get(text);
if (!p) parsed.set(text, (p = parseFormula(text)));
return p;
};
function unlink(key: string) {
for (const d of precs.get(key) ?? []) {
if (d.range.r0 === d.range.r1 && d.range.c0 === d.range.c1)
cellDeps.get(keyOf(d.sheet, cellId(d.range.r0, d.range.c0)))?.delete(key);
else rangeDeps.get(d.sheet)?.delete(key);
}
precs.delete(key);
}
/** 칸 하나를 색인에 올림(식이면 가리키는 곳 잇기) */
function index(sheet: SheetIndex, id: number, cell: Cell | undefined) {
const key = keyOf(sheet.id, id);
unlink(key);
vals.delete(key);
state.delete(key);
cycle.delete(key);
if (!cell || (cell.식 === undefined && cell.값 === undefined)) {
sheet.cells.delete(id);
return;
}
sheet.cells.set(id, cell);
const { r, c } = fromCellId(id);
sheet.maxR = Math.max(sheet.maxR, r);
sheet.maxC = Math.max(sheet.maxC, c);
if (cell.식 === undefined) {
vals.set(key, inputValue(cell.값!));
return;
}
const p = parse(cell.식);
const deps: Dep[] = [];
if (p.ok) collectRefs(p.node, sheet.id, sheetId, deps);
precs.set(key, deps);
for (const d of deps) {
if (d.range.r0 === d.range.r1 && d.range.c0 === d.range.c1) {
const k = keyOf(d.sheet, cellId(d.range.r0, d.range.c0));
let set = cellDeps.get(k);
if (!set) cellDeps.set(k, (set = new Set()));
set.add(key);
} else {
let m = rangeDeps.get(d.sheet);
if (!m) rangeDeps.set(d.sheet, (m = new Map()));
let list = m.get(key);
if (!list) m.set(key, (list = []));
list.push(d.range);
}
}
}
function rebuild(next: Workbook) {
book = next;
sheets = new Map();
names = new Map();
precs = new Map();
cellDeps = new Map();
rangeDeps = new Map();
vals = new Map();
state = new Map();
cycle.clear();
for (const s of book.시트) {
sheets.set(s.id, { id: s.id, cells: new Map(), maxR: 0, maxC: 0 });
names.set(s.이름.toLowerCase(), s.id);
}
for (const s of book.시트) {
const idx = sheets.get(s.id)!;
for (const [a1, cell] of Object.entries(s.칸)) {
const at = parseA1(a1);
if (at) index(idx, cellId(at.r, at.c), cell);
}
}
for (const key of precs.keys()) compute(key);
}
function ctxFor(sheet: string, r: number, c: number): EvalContext {
return { sheet, r, c, cell: read, range, sheetId };
}
function read(sheet: string, r: number, c: number): Scalar {
const key = keyOf(sheet, cellId(r, c));
if (precs.has(key)) return compute(key);
return vals.get(key) ?? null;
}
function range(sheet: string, rg: CellRange): RangeValue {
const s = sheets.get(sheet);
// ponytail: 행 · 열 전체 범위는 값 든 끝까지만 — INDEX(A:A, 끝 너머)는 #REF! 로 갈림
const r1 = Math.min(rg.r1, Math.max(rg.r0, s?.maxR ?? 0));
const c1 = Math.min(rg.c1, Math.max(rg.c0, s?.maxC ?? 0));
return {
kind: "range",
rows: r1 - rg.r0 + 1,
cols: c1 - rg.c0 + 1,
at: (i, j) => read(sheet, rg.r0 + i, rg.c0 + j),
};
}
/** 식 칸 하나 풂 — 풀이 중인 칸을 다시 만나면 그 사이가 고리 */
function compute(key: string): Scalar {
const st = state.get(key);
if (st === DONE) return vals.get(key) ?? null;
if (st === ACTIVE) {
const ring = stack.slice(stack.indexOf(key));
const why = `순환 참조: ${ring.map(label).join(" → ")} → ${label(key)}`;
for (const k of ring) if (!cycle.has(k)) cycle.set(k, why);
return err("#CYCLE!", why);
}
state.set(key, ACTIVE);
stack.push(key);
const { 시트, r, c } = splitKey(key);
const cell = sheets.get(시트)!.cells.get(cellId(r, c))!;
let v: Scalar;
try {
// ponytail: 끌어 풀기는 재귀 — 수천 칸 사슬이면 스택이 넘침, 그때 위상 순서 반복으로
v = run(cell.식!, ctxFor(시트, r, c));
} catch (e) {
state.delete(key);
throw e;
} finally {
stack.pop();
}
const why = cycle.get(key);
if (why) v = err("#CYCLE!", why);
vals.set(key, v);
state.set(key, DONE);
return v;
}
function label(key: string) {
const { 시트, r, c } = splitKey(key);
const s = book.시트.find((x) => x.id === 시트);
return book.시트.length > 1 && s ? `${s.이름}!${toA1(r, c)}` : toA1(r, c);
}
function run(text: string, ctx: EvalContext): Scalar {
const p = parse(text);
if (!p.ok) return err("#NAME?", `식을 못 읽음: ${p.message} (${p.at + 1}번째 글자)`);
let v: EvalResult;
try {
v = evaluate(p.node, ctx);
} catch (e) {
if (e instanceof FormulaError && e.message.startsWith("0 으로"))
return err("#DIV/0!", e.message);
return err("#VALUE!", e instanceof Error ? e.message : String(e));
}
// ponytail: 범위를 칸에 넣으면 한 칸만 — 엑셀 암묵 교차(식 든 행 · 열과 겹친 칸)는 안 함
if (isRange(v))
v = v.rows === 1 && v.cols === 1 ? v.at(0, 0) : err("#VALUE!", "범위를 칸 하나에 못 넣음");
return v === null ? ZERO : v;
}
function dependents(key: string, out: Set<string>) {
for (const k of cellDeps.get(key) ?? []) out.add(k);
const { 시트, r, c } = splitKey(key);
for (const [k, list] of rangeDeps.get(시트) ?? [])
if (list.some((rg) => inRange(rg, r, c))) out.add(k);
}
rebuild(book);
return {
update(changed) {
const dirty = new Set<string>();
for (const a of changed) {
const s = sheets.get(a.시트);
const sheet = book.시트.find((x) => x.id === a.시트);
if (!s || !sheet) continue;
const id = cellId(a.r, a.c);
index(s, id, sheet.칸[toA1(a.r, a.c)]);
dirty.add(keyOf(a.시트, id));
}
// 딸린 칸을 끝까지 모음(고리였던 칸도 다시)
const queue = [...dirty];
while (queue.length) {
const next = new Set<string>();
dependents(queue.pop()!, next);
for (const k of next)
if (!dirty.has(k)) {
dirty.add(k);
queue.push(k);
}
}
for (const k of dirty)
if (precs.has(k)) {
state.delete(k);
cycle.delete(k);
}
for (const k of dirty) if (precs.has(k)) compute(k);
return [...dirty].map(splitKey);
},
rebuild,
value(sheet, r, c) {
return read(sheet, r, c);
},
precedents(sheet, r, c) {
return (precs.get(keyOf(sheet, cellId(r, c))) ?? []).map((d) => ({
시트: d.sheet,
범위: d.range,
}));
},
snapshot() {
const out: CalcValues = {};
for (const key of precs.keys()) {
const { 시트, r, c } = splitKey(key);
(out[시트] ??= {})[toA1(r, c)] = toCalcValue(compute(key));
}
return out;
},
};
}
+30 -8
View File
@@ -1,16 +1,38 @@
/* =============================================================================
* spreadsheet_history.ts (주인 A)
* 되돌리기 · 다시 — 명령과 되돌림 짝을 쌓음(메모리만 · 캐시에 안 넣음).
* 0 계약 머리 — 몸은 A 가 채움.
* undo() 는 되돌림 짝을 · redo() 는 원래 명령을 돌려줌 — 적용은 부른 쪽(`applyCommand`).
* ========================================================================== */
import type { History } from "./spreadsheet_types";
const todo = (): never => {
throw new Error("spreadsheet_history: 아직 없음(A)");
};
import type { Command, History } from "./spreadsheet_types";
/** `limit` = 쌓을 수(넘으면 오래된 것부터 버림) */
export function createHistory(_limit?: number): History {
return todo();
export function createHistory(limit = 100): History {
let done: [Command, Command][] = [];
let undone: [Command, Command][] = [];
return {
push(command, undo) {
done.push([command, undo]);
if (done.length > limit) done.shift();
undone = [];
},
undo() {
const pair = done.pop();
if (!pair) return null;
undone.push(pair);
return pair[1];
},
redo() {
const pair = undone.pop();
if (!pair) return null;
done.push(pair);
return pair[0];
},
canUndo: () => done.length > 0,
canRedo: () => undone.length > 0,
clear() {
done = [];
undone = [];
},
};
}
+297 -13
View File
@@ -4,26 +4,310 @@
* 우선순위는 엑셀과 같음: 범위 `:` > 부호 `-` `+` > `%` > `^` > `* /` > `+ -` > `&` > 비교
* (부호가 `^` 보다 먼저 — `-2^2 = 4`). 같은 단은 왼쪽부터(`^` 도 왼쪽부터 · 엑셀과 같음).
* 글 `"…"` 안 `""` = 따옴표 하나 · 시트 `'이름'!A1` · `이름!A1` · 오류 값 글자 · TRUE/FALSE.
* 0 계약 머리 — 몸은 A 가 채움.
* 참조 낱말 풀기(`parseRef` · `renderRef`)는 참조 옮김(`spreadsheet_refshift.ts`)이 같이 씀.
* ========================================================================== */
import type { FormulaNode, ParseResult, Token } from "./spreadsheet_types";
import { parseDecimal, ZERO } from "@ui/sheet/ui_template_sheet_frac";
import { colIndex, colName, quoteSheet, rowIndex } from "./spreadsheet_address";
import { MAX_COLS, MAX_ROWS } from "./spreadsheet_types";
import type { ErrorCode, FormulaNode, ParseResult, RefCell, Token } from "./spreadsheet_types";
const todo = (): never => {
throw new Error("spreadsheet_parser: 아직 없음(A)");
};
// ── 참조 낱말 ────────────────────────────────────────────────────────────────
/** 앞 `=` 뺀 식 글 → 낱말(빈칸 낱말 포함 · 끝까지 · 못 읽는 글은 `unknown`) — 편집기 색칠 · 가리키기용 */
export function tokenize(_formula: string): Token[] {
return todo();
const IDC = "[\\p{L}\\p{N}_.]";
const END = `(?!${IDC}|\\()`;
const CELL = "(\\$?)([A-Za-z]{1,3})(\\$?)(\\d+)";
const RE_PREFIX = /^(?:'((?:[^']|'')+)'|([\p{L}\p{N}_.]+))!/u;
const RE_AREA = new RegExp(`^${CELL}(?::${CELL})?${END}`, "u");
const RE_COLS = new RegExp(`^(\\$?)([A-Za-z]{1,3}):(\\$?)([A-Za-z]{1,3})${END}`, "u");
const RE_ROWS = new RegExp(`^(\\$?)(\\d+):(\\$?)(\\d+)${END}`, "u");
const RE_ERROR = /^#(NULL!|DIV\/0!|VALUE!|REF!|NAME\?|NUM!|N\/A|CYCLE!)/i;
const RE_IDENT = /^[\p{L}_\\][\p{L}\p{N}_.]*/u;
/** 참조 낱말 하나 — `kind` cell = 칸 · area = `A1:B2` · cols = `A:C` · rows = `3:5`. */
export interface RefParts {
/** 적힌 시트 머리 글 그대로(`'돌-골막이'!`) · 없으면 "" */
prefix: string;
sheet: string | null;
kind: "cell" | "area" | "cols" | "rows";
a: RefCell;
b: RefCell;
}
/** 참조 글 → 조각 · 참조가 아니면 null(주소가 격자 밖이어도 null → 이름) */
export function parseRef(text: string): RefParts | null {
const pm = RE_PREFIX.exec(text);
const prefix = pm ? pm[0] : "";
const sheet = pm ? (pm[1] !== undefined ? pm[1].replace(/''/g, "'") : pm[2]) : null;
const body = text.slice(prefix.length);
const pt = (sr: string, col: number, sc: string, row: number): RefCell => ({
r: row,
c: col,
absR: sc === "$",
absC: sr === "$",
});
let m = RE_AREA.exec(body);
if (m && m[0].length === body.length) {
const a = pt(m[1], colIndex(m[2]), m[3], rowIndex(m[4]));
const b = m[5] === undefined ? { ...a } : pt(m[5], colIndex(m[6]), m[7], rowIndex(m[8]));
if (a.r < 0 || a.c < 0 || b.r < 0 || b.c < 0) return null;
return { prefix, sheet, kind: m[5] === undefined ? "cell" : "area", a, b };
}
m = RE_COLS.exec(body);
if (m && m[0].length === body.length) {
const c0 = colIndex(m[2]);
const c1 = colIndex(m[4]);
if (c0 < 0 || c1 < 0) return null;
return {
prefix,
sheet,
kind: "cols",
a: pt(m[1], c0, "", 0),
b: pt(m[3], c1, "", MAX_ROWS - 1),
};
}
m = RE_ROWS.exec(body);
if (m && m[0].length === body.length) {
const r0 = rowIndex(m[2]);
const r1 = rowIndex(m[4]);
if (r0 < 0 || r1 < 0) return null;
return {
prefix,
sheet,
kind: "rows",
a: pt("", 0, m[1], r0),
b: pt("", MAX_COLS - 1, m[3], r1),
};
}
return null;
}
const cellText = (p: RefCell): string =>
`${p.absC ? "$" : ""}${colName(p.c)}${p.absR ? "$" : ""}${p.r + 1}`;
/** 조각 → 참조 글(시트 머리는 `prefix` 그대로) */
export function renderRef(p: RefParts): string {
if (p.kind === "cols")
return `${p.prefix}${p.a.absC ? "$" : ""}${colName(p.a.c)}:${p.b.absC ? "$" : ""}${colName(p.b.c)}`;
if (p.kind === "rows")
return `${p.prefix}${p.a.absR ? "$" : ""}${p.a.r + 1}:${p.b.absR ? "$" : ""}${p.b.r + 1}`;
return p.prefix + cellText(p.a) + (p.kind === "area" ? `:${cellText(p.b)}` : "");
}
// ── 낱말 나누기 ───────────────────────────────────────────────────────────────
/** 앞 `=` 뺀 식 글 → 낱말(빈칸 낱말 포함 · 끝까지 · 못 읽는 글은 `unknown`) — 편집기 색칠 · 가리키기용.
* 이름(정의 안 된 글자 묶음)도 `unknown` — 파서가 이름 나무로 읽음. */
export function tokenize(formula: string): Token[] {
const out: Token[] = [];
let i = 0;
let ref: string | null;
const push = (kind: Token["kind"], len: number) => {
out.push({ kind, text: formula.slice(i, i + len), start: i, end: i + len });
i += len;
};
while (i < formula.length) {
const rest = formula.slice(i);
const ch = rest[0];
let m: RegExpExecArray | null;
if ((m = /^\s+/.exec(rest))) push("space", m[0].length);
else if (ch === '"') {
const s = /^"(?:[^"]|"")*"?/.exec(rest)!;
push("string", s[0].length);
} else if ((m = /^([\p{L}_][\p{L}\p{N}_.]*)\(/u.exec(rest)) && !/^(TRUE|FALSE)$/i.test(m[1]))
push("func", m[1].length);
else if ((ref = refAt(rest))) push(/#REF!$/i.test(ref) ? "error" : "ref", ref.length);
else if ((m = /^(TRUE|FALSE)(?![\p{L}\p{N}_.(])/iu.exec(rest))) push("bool", m[0].length);
else if ((m = RE_ERROR.exec(rest))) push("error", m[0].length);
else if ((m = /^(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?/.exec(rest))) push("number", m[0].length);
else if ((m = RE_IDENT.exec(rest))) push("unknown", m[0].length);
else if ((m = /^(<>|<=|>=|[-+*/^&=<>%:])/.exec(rest))) push("op", m[0].length);
else if (ch === "(" || ch === ")") push("paren", 1);
else if (ch === ",") push("comma", 1);
else push("unknown", 1);
}
return out;
}
/** 머리 이 자리에서 시작하는 참조(또는 `시트!#REF!`) 글 */
function refAt(rest: string): string | null {
const head = RE_PREFIX.exec(rest)?.[0] ?? "";
const body = rest.slice(head.length);
if (head && /^#REF!/i.test(body)) return head + body.slice(0, 5);
for (const re of [RE_AREA, RE_COLS, RE_ROWS]) {
const m = re.exec(body);
if (m && parseRef(head + m[0])) return head + m[0];
}
return null;
}
// ── 나무 ────────────────────────────────────────────────────────────────────
class Fail {
constructor(
readonly message: string,
readonly at: number,
) {}
}
const COMPARE = ["=", "<>", "<", "<=", ">", ">="];
/** 앞 `=` 뺀 식 글 → 나무. 앞에 `=` 가 붙어 와도 됨. 함수 이름은 대문자로. */
export function parseFormula(_formula: string): ParseResult {
return todo();
export function parseFormula(formula: string): ParseResult {
const text = formula.startsWith("=") ? formula.slice(1) : formula;
const toks = tokenize(text).filter((t) => t.kind !== "space");
let p = 0;
const peek = (): Token | undefined => toks[p];
const isOp = (...ops: string[]) => {
const t = toks[p];
return !!t && t.kind === "op" && ops.includes(t.text);
};
const where = () => (toks[p] ? toks[p].start : text.length);
const binary = (ops: string[], next: () => FormulaNode) => (): FormulaNode => {
let left = next();
while (isOp(...ops)) {
const op = toks[p++].text as "+";
left = { type: "binary", op, left, right: next() };
}
return left;
};
const primary = (): FormulaNode => {
const t = peek();
if (!t) throw new Fail("식이 끝남", text.length);
p++;
switch (t.kind) {
case "number":
return { type: "number", value: parseDecimal(t.text), text: t.text };
case "string":
if (t.text.length < 2 || !t.text.endsWith('"')) throw new Fail("따옴표가 안 닫힘", t.start);
return { type: "string", value: t.text.slice(1, -1).replace(/""/g, '"') };
case "bool":
return { type: "bool", value: t.text.toUpperCase() === "TRUE" };
case "error":
return {
type: "error",
code: t.text.slice(t.text.lastIndexOf("#")).toUpperCase() as ErrorCode,
};
case "ref": {
const r = parseRef(t.text)!;
return r.kind === "cell"
? { type: "cell", sheet: r.sheet, ref: r.a }
: { type: "range", sheet: r.sheet, from: r.a, to: r.b };
}
case "func": {
const name = t.text.toUpperCase().replace(/^_XLFN\./, "");
p++; // `(`
const args: FormulaNode[] = [];
if (peek()?.text === ")") {
p++;
return { type: "call", name, args };
}
for (;;) {
const k = peek();
// 빈 인자(`IF(A1,,1)`) = 0 · 글은 비움
if (k && (k.kind === "comma" || k.text === ")"))
args.push({ type: "number", value: ZERO, text: "" });
else args.push(compare());
const s = toks[p++];
if (s?.kind === "comma") continue;
if (s?.text === ")") return { type: "call", name, args };
throw new Fail("함수 괄호가 안 닫힘", s ? s.start : text.length);
}
}
case "paren": {
if (t.text !== "(") break;
const inner = compare();
if (peek()?.text !== ")") throw new Fail("괄호가 안 닫힘", where());
p++;
return inner;
}
case "unknown":
if (RE_IDENT.exec(t.text)?.[0] === t.text) return { type: "name", name: t.text };
}
throw new Fail(`읽지 못한 글: ${t.text}`, t.start);
};
const unary = (): FormulaNode => {
if (isOp("-", "+")) {
const op = toks[p++].text as "-";
return { type: "unary", op, arg: unary() };
}
return primary();
};
const percent = (): FormulaNode => {
let node = unary();
while (isOp("%")) {
p++;
node = { type: "unary", op: "%", arg: node };
}
return node;
};
const power = binary(["^"], percent);
const mul = binary(["*", "/"], power);
const add = binary(["+", "-"], mul);
const concat = binary(["&"], add);
const compare = binary(COMPARE, concat);
try {
if (!toks.length) throw new Fail("빈 식", 0);
const node = compare();
if (p < toks.length) throw new Fail(`남은 글: ${toks[p].text}`, toks[p].start);
return { ok: true, node };
} catch (e) {
if (e instanceof Fail) return { ok: false, message: e.message, at: e.at };
throw e;
}
}
/** 나무 → 앞 `=` 뺀 식 글(참조 옮김 뒤 다시 적기) — 수는 원래 글(`text`) 그대로 */
export function formulaToText(_node: FormulaNode): string {
return todo();
// ── 나무 → 글 ─────────────────────────────────────────────────────────────────
const PREC: Record<string, number> = { "&": 2, "+": 3, "-": 3, "*": 4, "/": 4, "^": 5 };
for (const op of COMPARE) PREC[op] = 1;
function prec(node: FormulaNode): number {
if (node.type === "binary") return PREC[node.op];
if (node.type === "unary") return node.op === "%" ? 6 : 7;
return 8;
}
const sheetHead = (sheet: string | null) => (sheet === null ? "" : `${quoteSheet(sheet)}!`);
/** 나무 → 앞 `=` 뺀 식 글(참조 옮김 뒤 다시 적기) — 수는 원래 글(`text`) 그대로 · 괄호는 필요한 곳만 */
export function formulaToText(node: FormulaNode): string {
const wrap = (child: FormulaNode, need: boolean) =>
need ? `(${formulaToText(child)})` : formulaToText(child);
switch (node.type) {
case "number":
return node.text;
case "string":
return `"${node.value.replace(/"/g, '""')}"`;
case "bool":
return node.value ? "TRUE" : "FALSE";
case "error":
return node.code;
case "name":
return node.name;
case "cell":
return sheetHead(node.sheet) + cellText(node.ref);
case "range": {
const { from, to } = node;
const kind =
from.r === 0 && to.r === MAX_ROWS - 1
? "cols"
: from.c === 0 && to.c === MAX_COLS - 1
? "rows"
: "area";
return renderRef({ prefix: sheetHead(node.sheet), sheet: node.sheet, kind, a: from, b: to });
}
case "unary":
return node.op === "%"
? `${wrap(node.arg, prec(node.arg) < 6)}%`
: `${node.op}${wrap(node.arg, prec(node.arg) < 7)}`;
case "binary": {
const p = PREC[node.op];
return `${wrap(node.left, prec(node.left) < p)}${node.op}${wrap(node.right, prec(node.right) <= p)}`;
}
case "call":
return `${node.name}(${node.args.map(formulaToText).join(",")})`;
}
}
+3 -7
View File
@@ -2,15 +2,11 @@
* spreadsheet_recalc.ts (주인 A)
* 통합문서 한 벌 풀이 — 서버 Node 진입(`common_util_spreadsheet_node.ts`) · 시험이 부름.
* 화면과 같은 엔진(`createCalcEngine`)을 한 번 돌려 `snapshot()` — 계산이 두 벌이 되지 않게.
* 0 계약 머리 — 몸은 A 가 채움.
* ========================================================================== */
import { createCalcEngine } from "./spreadsheet_graph";
import type { CalcValues, Workbook } from "./spreadsheet_types";
const todo = (): never => {
throw new Error("spreadsheet_recalc: 아직 없음(A)");
};
export function recalcWorkbook(_book: Workbook): CalcValues {
return todo();
export function recalcWorkbook(book: Workbook): CalcValues {
return createCalcEngine(book).snapshot();
}
+163 -17
View File
@@ -1,33 +1,179 @@
/* =============================================================================
* spreadsheet_refshift.ts (주인 A)
* 참조 옮김 — 행열 넣기 · 지우기 · 잘라 붙이기 · 시트 이름 바꾸기 때 식 글을 고쳐 적음(엑셀 규칙) ·
* 복사 · 채우기 때 상대 참조만 옮김 · 구글 R1C1 식 → A1.
* 참조 옮김 — 행열 넣기 · 지우기 · 잘라 붙이기 · 시트 이름 바꾸기 · 시트 지우기 때 식 글을 고쳐 적음
* (엑셀 규칙) · 복사 · 채우기 때 상대 참조만 옮김 · 구글 R1C1 식 → A1.
* 지운 칸 · 격자 밖으로 나간 참조는 `#REF!` · 범위는 남은 만큼 줄어듦(끝까지 지우면 `#REF!`).
* 0 계약 머리 — 몸은 A 가 채움.
* 낱말 단위로 참조만 바꿔 끼움 — 빈칸 · 괄호 · 수 글은 적힌 그대로 둠.
*
* 엑셀 규칙표:
* 넣기 — 넣는 자리 이후를 가리키면 밀림 · 범위가 넣는 자리를 품으면 늘어남(첫 행 앞 넣기는 통째 밀림)
* 지우기 — 지운 칸 하나 = `#REF!` · 범위는 남은 만큼 · 전부 지우면 `#REF!` · `$` 도 똑같이 옮김
* 옮기기 — 옮긴 덩어리 안을 가리키던 참조(범위는 통째 들 때만)는 따라감 · 덮어쓴 자리를 가리키면 `#REF!`
* 복사 — 상대만 (dr, dc) · 행 · 열 전체 범위는 그 축을 안 옮김
* ========================================================================== */
import type { CellAddress, Command, Workbook } from "./spreadsheet_types";
import { quoteSheet, rangeInside } from "./spreadsheet_address";
import { parseRef, renderRef, tokenize } from "./spreadsheet_parser";
import type { RefParts } from "./spreadsheet_parser";
import { MAX_COLS, MAX_ROWS } from "./spreadsheet_types";
import type { CellAddress, CellRange, Command, Workbook } from "./spreadsheet_types";
const todo = (): never => {
throw new Error("spreadsheet_refshift: 아직 없음(A)");
};
const REF_ERR = "#REF!";
/** 행열 · 옮기기 · 시트이름 명령에 맞춰 식 글 고침(식이 든 시트 id = `host`) — 안 바뀌면 같은 글 */
/** 참조 낱말만 `fn` 으로 바꿔 끼움(null = `#REF!`) */
function mapRefs(formula: string, fn: (p: RefParts) => RefParts | string | null): string {
let out = "";
for (const t of tokenize(formula)) {
const p = t.kind === "ref" ? parseRef(t.text) : null;
if (!p) {
out += t.text;
continue;
}
const q = fn(p);
out += q === null ? REF_ERR : typeof q === "string" ? q : renderRef(q);
}
return out;
}
/** 넣기 · 지우기 한 축 — [lo, hi] → 새 [lo, hi] · 다 지워지면 null */
export function shiftSpan(
lo: number,
hi: number,
insert: boolean,
at: number,
n: number,
max: number,
): [number, number] | null {
if (insert) {
if (lo >= at) [lo, hi] = [lo + n, hi + n];
else if (hi >= at) hi += n;
if (lo >= max) return null;
return [lo, Math.min(hi, max - 1)];
}
const end = at + n - 1;
if (lo >= at && hi <= end) return null;
return [lo < at ? lo : lo > end ? lo - n : at, hi < at ? hi : hi > end ? hi - n : at - 1];
}
const sheetIdOf = (book: Workbook, name: string) =>
book.시트.find((s) => s.이름.toLowerCase() === name.toLowerCase())?.id ?? null;
const box = (p: RefParts): CellRange => ({
r0: Math.min(p.a.r, p.b.r),
c0: Math.min(p.a.c, p.b.c),
r1: Math.max(p.a.r, p.b.r),
c1: Math.max(p.a.c, p.b.c),
});
/** 행열 · 옮기기 · 시트이름 · 시트지우기 명령에 맞춰 식 글 고침(식이 든 시트 id = `host`) — 안 바뀌면 같은 글.
* `book` 은 명령 적용 **전** 문서(시트 이름으로 id 를 찾음). `newHost` = 옮기기로 식 칸 자체가 간 시트. */
export function shiftForCommand(
_formula: string,
_host: string,
_command: Command,
_book: Workbook,
formula: string,
host: string,
command: Command,
book: Workbook,
newHost: string = host,
): string {
return todo();
if (command.종류 === "묶음")
return command.명령.reduce((f, c) => shiftForCommand(f, host, c, book, newHost), formula);
const target = (p: RefParts) => (p.sheet === null ? host : sheetIdOf(book, p.sheet));
const nameOf = (id: string) => book.시트.find((s) => s.id === id)?.이름 ?? "";
switch (command.종류) {
case "행넣기":
case "행지우기":
case "열넣기":
case "열지우기": {
const rows = command.종류.startsWith("행");
const insert = command.종류.endsWith("넣기");
return mapRefs(formula, (p) => {
if (target(p) !== command.시트) return p;
if (rows ? p.kind === "cols" : p.kind === "rows") return p; // 다른 축 전체 범위
const [lo, hi] = rows
? [p.a.r, p.b.r].sort((x, y) => x - y)
: [p.a.c, p.b.c].sort((x, y) => x - y);
const span = shiftSpan(lo, hi, insert, command.at, command.수, rows ? MAX_ROWS : MAX_COLS);
if (!span) return null;
const q = structuredClone(p);
// 적힌 차례(뒤집힌 범위)는 바로 세움 — 엑셀도 다시 적을 때 바로 세움
if (rows) [q.a.r, q.b.r] = span;
else [q.a.c, q.b.c] = span;
return q;
});
}
case "옮기기": {
const src = command.범위;
const dr = command.r - src.r0;
const dc = command.c - src.c0;
const dst: CellRange = { r0: command.r, c0: command.c, r1: src.r1 + dr, c1: src.c1 + dc };
return mapRefs(formula, (p) => {
const t = target(p);
if (t === null) return p;
const b = box(p);
let to = t;
const q = structuredClone(p);
if (t === command.시트 && p.kind !== "cols" && p.kind !== "rows" && rangeInside(b, src)) {
to = command.대상시트;
for (const pt of [q.a, q.b]) {
pt.r += dr;
pt.c += dc;
}
} else if (t === command.대상시트 && rangeInside(b, dst)) return null;
if (to === t && newHost === host) return q;
q.prefix = to === newHost ? "" : `${quoteSheet(nameOf(to))}!`;
return q;
});
}
case "시트이름":
return mapRefs(formula, (p) =>
p.sheet !== null && target(p) === command.시트
? { ...p, prefix: `${quoteSheet(command.이름)}!` }
: p,
);
case "시트지우기":
return mapRefs(formula, (p) => (p.sheet !== null && target(p) === command.시트 ? null : p));
default:
return formula;
}
}
/** 복사 · 채우기 — 상대 참조만 (dr, dc) 옮김 · 격자 밖은 `#REF!` */
export function moveFormula(_formula: string, _dr: number, _dc: number): string {
return todo();
export function moveFormula(formula: string, dr: number, dc: number): string {
return mapRefs(formula, (p) => {
const q = structuredClone(p);
for (const pt of [q.a, q.b]) {
if (!pt.absR && p.kind !== "cols") pt.r += dr;
if (!pt.absC && p.kind !== "rows") pt.c += dc;
if (pt.r < 0 || pt.r >= MAX_ROWS || pt.c < 0 || pt.c >= MAX_COLS) return null;
}
return q;
});
}
/** 구글 `data-sheets-formula`(`=2*R[0]C[-1]`) → 붙일 자리 기준 A1 식(앞 `=` 뺌) */
export function r1c1ToA1(_formula: string, _at: CellAddress): string {
return todo();
export function r1c1ToA1(formula: string, at: CellAddress): string {
const text = formula.startsWith("=") ? formula.slice(1) : formula;
const axis = (part: string | undefined, base: number): [number, boolean] =>
part === undefined || part === ""
? [base, false]
: part.startsWith("[")
? [base + Number(part.slice(1, -1)), false]
: [Number(part) - 1, true];
// 글 · 따옴표 시트 이름은 건너뜀 · 이름 한가운데(`ROUND`)는 안 건드림
return text.replace(
/("(?:[^"]|"")*"|'(?:[^']|'')*')|(?<![\p{L}\p{N}_.$])R(\[-?\d+\]|\d+)?C(\[-?\d+\]|\d+)?(?![\p{L}\p{N}_.(])/gu,
(whole, quoted: string | undefined, rp: string | undefined, cp: string | undefined) => {
if (quoted) return whole;
const [r, absR] = axis(rp, at.r);
const [c, absC] = axis(cp, at.c);
if (r < 0 || r >= MAX_ROWS || c < 0 || c >= MAX_COLS) return REF_ERR;
return renderRef({
prefix: "",
sheet: null,
kind: "cell",
a: { r, c, absR, absC },
b: { r, c, absR, absC },
});
},
);
}
@@ -7,8 +7,9 @@ import { API_BASE_URL } from "@config/config_frontend";
/** 층 이름 — 서버와 같은 글 */
export type Layer = "system" | "company" | "personal" | "project";
/** 서버 종류 — 화면의 「표 양식」 = table · 「도면 양식」 = drawing · 「구조물 도면」 = structure */
export type Kind = "table" | "drawing" | "structure";
/** 서버 종류 — 화면의 「표 양식」 = table · 「도면 양식」 = drawing · 「구조물 도면」 = structure ·
* 「상세 산출근거」 = basis(구조물집계표 열마다 한 통합문서) */
export type Kind = "table" | "drawing" | "structure" | "basis";
export interface TemplateInfo {
종류: Kind;
@@ -95,7 +96,7 @@ export const deleteLayerTemplate = (
/* --- 구조물 도면(종류 structure · 이름 = 구조물집계표 열 id) ---
* 읽기 · 저장 · 지우기는 위 readTemplate · saveTemplate · deleteTemplate 에 "structure" */
/** 구조물 도면 문서 — `도면` = 도면 양식과 같은 CAD 문서 · `산출근거` = 표 양식과 같은 표 문서 */
/** 구조물 도면 문서 — `도면` = 도면 양식과 같은 CAD 문서 · 산출근거는 따로(`basis` · 같은 열 id) */
export interface StructureDoc {
양식: "구조물도면";
종류: "structure";
@@ -103,14 +104,13 @@ export interface StructureDoc {
열: string;
도번: string;
도면: { entities: unknown[] } & Record<string, unknown>;
산출근거: { 열: unknown[]; 줄: unknown[] } & Record<string, unknown>;
}
/** 시스템 층 구조물 도면 목록 */
export const listStructures = async (): Promise<TemplateInfo[]> =>
(await listTemplates("system", null)).filter((t) => t.종류 === "structure");
/** 새로 — 도번 자동 · A1 도각을 깐 빈 도면 + 빈 산출근거 표 · 이미 있으면 StaleError */
/** 새로 — 도번 자동 · 빈 도면 (+ 없으면 빈 산출근거 통합문서) · 이미 있으면 StaleError */
export const createStructure = (column: string): Promise<TemplateDoc> =>
call(`/structures/${enc(column)}`, { method: "POST" });
@@ -118,6 +118,11 @@ export const createStructure = (column: string): Promise<TemplateDoc> =>
export const fetchStructureNumbers = (): Promise<Record<string, string>> =>
call("/structures/numbers");
/* --- ⚠ 임시 — [스프레드시트 시험] 단추(1단계 검증 뒤 지움) · 저장 자리 = 서버 tmp 견본 --- */
export const readSpreadsheetTrial = (): Promise<{ 문서: unknown }> => call("/spreadsheet-trial");
export const saveSpreadsheetTrial = (문서: unknown): Promise<{ 문서: unknown }> =>
call("/spreadsheet-trial", { method: "PUT", body: JSON.stringify({ 문서 }) });
/* --- 프로젝트 층 단추 다섯 --- */
const project = (id: string, tail: string): string => `/projects/${enc(id)}/templates/${tail}`;
const post = (path: string, body: object = {}): Promise<unknown> =>
@@ -5,11 +5,15 @@
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from common_util import common_util_spreadsheet as spreadsheet
from common_util.common_util_json import atomic_write_json
from M02_MasterTemplete import M02_MasterTemplete_Store as store
router = APIRouter(prefix="/api/m02", tags=["M02 MasterTemplete"])
@@ -57,3 +61,34 @@ def put_template(kind: str, name: str, body: SaveBody) -> dict:
def delete_template(kind: str, name: str, 판: str | None = None) -> dict:
_call(store.delete, kind, name, 판)
return {"ok": True}
# ── ⚠ 임시(스프레드시트 시험 단추 · 사용자 지시 · 1단계 검증 뒤 지움) ──────────
# 저장 자리 = `tmp/spreadsheet_trial.json`(git 밖) · 없으면 시트 셋 빈 통합문서.
TRIAL_PATH = Path(__file__).resolve().parent.parent / "tmp" / "spreadsheet_trial.json"
def _trial_doc() -> dict[str, Any]:
sheets = [{"id": f"s{i}", "이름": f"시트{i}", "칸": {}} for i in (1, 2, 3)]
return {"종류": "통합문서", "판": 1, "열": "시험", "서식": [{}], "시트": sheets, "활성": "s1"}
@router.get("/spreadsheet-trial")
def get_spreadsheet_trial() -> dict:
if not TRIAL_PATH.is_file():
return {"문서": _trial_doc()}
return {"문서": json.loads(TRIAL_PATH.read_text(encoding="utf-8"))}
class TrialBody(BaseModel):
문서: dict[str, Any]
@router.put("/spreadsheet-trial")
def put_spreadsheet_trial(body: TrialBody) -> dict:
try:
doc = spreadsheet.with_server_values(body.문서)
except spreadsheet.RecalcError as e:
raise HTTPException(status_code=503, detail=str(e)) from e
atomic_write_json(TRIAL_PATH, doc)
return {"문서": doc}
@@ -22,6 +22,7 @@ from B06_Section.B06_Section_Repository import (
get_cross_section_designs,
get_workflow_route_context,
)
from common_util import common_util_spreadsheet as spreadsheet
from common_util.common_util_auth import verify_session
from common_util.common_util_storage import resolve_stored_project_path
from config.config_db import get_db_pool, run_with_connection
@@ -260,6 +261,12 @@ async def save_layer_template(
if kind == "table" and fill.is_fillable(document):
# 채운 표가 와도 설계값은 안 받아 적음 — 양식 + 손 값만(5장 · 브라우저 값을 믿지 않음)
document = fill.strip_design(document)
if kind == "basis":
# 산출근거 계산값은 서버가 같은 TS 엔진을 Node 로 돌려 새로 적음(브라우저 값 버림 · 5장 ②)
try:
document = await asyncio.to_thread(spreadsheet.with_server_values, document)
except spreadsheet.RecalcError as error:
raise HTTPException(status_code=503, detail=str(error)) from error
try:
version = await asyncio.to_thread(
layers.write_template,
@@ -0,0 +1,25 @@
/* ⚠ 임시 — [스프레드시트 시험] 화면(1단계 검증 뒤 지움) */
.m02-trial {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
}
.m02-trial__bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.m02-trial__note {
color: var(--color-text-muted);
font-size: 0.85rem;
}
.m02-trial__sheet {
width: 100%;
height: 75vh;
min-height: 420px;
}
@@ -0,0 +1,89 @@
/* =============================================================================
* M02_MasterTemplete_Spreadsheet_Trial.ts
* ⚠ 임시 — [스프레드시트 시험] 단추(사용자 지시 · 스프레드시트 1단계 검증 뒤 지움:
* 이 파일 · `M02_MasterTemplete_UI_Main.ts` 의 시험 단추 · Api_Fetch `*SpreadsheetTrial` ·
* Router `/spreadsheet-trial` · 저장 자리 `tmp/spreadsheet_trial.json`).
* 빈 통합문서(시트 셋)를 메인 칸에 넓게 엶 · [시험 저장] = 서버가 같은 엔진으로 다시 풀어 tmp 에 적음.
* 글자는 이 파일에 둠.
* ========================================================================== */
import { createButton, el, showToast } from "@ui/ui_template_elements";
import type { SpreadsheetHandle, Workbook } from "../A00_Common/spreadsheet/spreadsheet";
import { readSpreadsheetTrial, saveSpreadsheetTrial } from "./M02_MasterTemplete_Api_Fetch";
import "./M02_MasterTemplete_Spreadsheet_Trial.css";
const TEXT = {
save: "시험 저장",
note: "임시 시험 화면 — 저장은 서버 tmp 견본 자리(작업 양식과 무관)",
saved: "시험 통합문서를 저장했음(서버 재계산)",
failed: "스프레드시트 시험 실패 — {value}",
};
const why = (error: unknown): string => (error instanceof Error ? error.message : String(error));
export interface TrialHandle {
getDoc: () => unknown;
destroy: () => void;
}
/** `onDirty` = 고침 있음 · 저장 뒤 없음(페이지의 떠날 때 물음과 이음) */
export function mountSpreadsheetTrial(
host: HTMLElement,
onDirty: (dirty: boolean) => void,
): TrialHandle {
let sheet: SpreadsheetHandle | null = null;
let alive = true;
const save = createButton({ label: TEXT.save, variant: "filled" });
const area = el("div", { className: "m02-trial__sheet" });
const root = el("div", {
className: "m02-trial",
children: [
el("div", {
className: "m02-trial__bar",
children: [el("span", { className: "m02-trial__note", text: TEXT.note }), save],
}),
area,
],
});
host.replaceChildren(root);
const fail = (error: unknown): void =>
showToast(TEXT.failed.replace("{value}", why(error)), "error");
void (async () => {
try {
const [{ createSpreadsheet }, got] = await Promise.all([
import("../A00_Common/spreadsheet/spreadsheet"),
readSpreadsheetTrial(),
]);
if (!alive) return;
sheet = createSpreadsheet(area, got.문서 as Workbook, { onChange: () => onDirty(true) });
} catch (error) {
if (!alive) return;
area.replaceChildren(el("p", { className: "m02-trial__empty", text: why(error) }));
fail(error);
}
})();
save.addEventListener("click", async () => {
if (!sheet) return;
save.disabled = true;
try {
await saveSpreadsheetTrial(sheet.getDoc());
onDirty(false);
showToast(TEXT.saved, "success");
} catch (error) {
fail(error);
} finally {
save.disabled = false;
}
});
return {
getDoc: () => sheet?.getDoc() ?? null,
destroy: () => {
alive = false;
sheet?.destroy();
root.remove();
},
};
}
+116 -17
View File
@@ -14,14 +14,22 @@ from datetime import datetime
from pathlib import Path
from typing import Any
from common_util import common_util_spreadsheet as spreadsheet
from common_util.common_util_json import atomic_write_json
from M02_MasterTemplete import M02_Template_Layers as layers
FOLDER: Path = Path(__file__).resolve().parent.parent / "resources" / "master_template"
KINDS = layers.KINDS # 시험은 FOLDER 를 사본으로 바꿈
# 구조물 도면 = 구조물집계표 열 id 마다 하나(파일 이름 = 열 id) · 도각 없는 전용 화면(그린 만큼이 설계 영역)
# 산출근거 = 같은 열 id 로 따로 한 파일(`basis/<열 id>.json` · 스프레드시트 통합문서)
STRUCTURE_TABLE = "구조물집계표"
_BASIS_HEAD = (("work", "공종"), ("spec", "규격"), ("detail", "산출 내역"), ("unit", "단위"))
_BASIS_HEAD = ("공종", "규격", "산출 내역", "단위", "수량(m당)")
_BASIS_HEAD_STYLE = {
"굵게": True,
"가로": "center",
"세로": "center",
"테두리": {side: {"선": "thin"} for side in ("위", "아래", "왼", "오른")},
}
_NUMBER = re.compile(r"^구-(\d+)$")
_BAD_NAME = re.compile(r'[\\/:*?"<>|\x00-\x1f]|\.\.')
# ponytail: 저장은 한 번에 하나(프로세스 안 잠금) · 서버를 여럿 띄우면 파일 잠금으로
@@ -80,10 +88,14 @@ def _check_skeleton(kind: str, doc: Any) -> None:
def write(kind: str, name: str, version: str, doc: Any) -> dict[str, Any]:
"""`version` 이 빈 글이면 새로 만듦(이미 있으면 409) · 아니면 그 판일 때만 덮어씀.
구조물집계표를 저장하면 — 새 열의 빈 구조물 도면도 같이 만듦(잠금 밖에서 · `create_structure`
가 다시 잠금을 잡으므로 안에서 부르면 죽음)."""
구조물집계표를 저장하면 — 새 열의 빈 구조물 도면 · 산출근거도 같이 만듦(잠금 밖에서 ·
`create_structure` 가 다시 잠금을 잡으므로 안에서 부르면 죽음).
산출근거(basis)는 서버가 같은 TS 엔진을 Node 로 돌려 `계산값` 을 새로 적음
(브라우저 값 버림 · 5장 ②)."""
path = _path(kind, name)
_check_skeleton(kind, doc)
if kind == "basis":
doc = _server_values(doc)
with _LOCK:
have = version_of(path.read_bytes()) if path.is_file() else ""
if have != (version or ""):
@@ -125,16 +137,17 @@ def _number(doc: Any) -> int:
return int(found.group(1)) if found else 0
def create_structure(column: str) -> dict[str, Any]:
"""열 id 하나의 구조물 도면 새로 — 도번 = 있는 것 중 가장 큰 번호 + 1 · 이미 있으면 409."""
path = _path("structure", column)
def _column_ids() -> list[str]:
table = read("table", STRUCTURE_TABLE)["문서"]
if column not in {col.get("id") for col in table.get("열", []) if isinstance(col, dict)}:
return [col["id"] for col in table.get("열", []) if isinstance(col, dict) and col.get("id")]
def create_structure(column: str) -> dict[str, Any]:
"""열 id 하나의 구조물 도면 새로 — 도번 = 있는 것 중 가장 큰 번호 + 1 · 이미 있으면 409.
산출근거 파일이 없으면 빈 통합문서도 같이 만듦."""
path = _path("structure", column)
if column not in _column_ids():
raise StoreError(404, f"{STRUCTURE_TABLE}에 없는 열 「{column}」")
basis = [{"id": key, "머리": [label], "단위": None, "꼴": "글"} for key, label in _BASIS_HEAD]
basis.append({"id": "qty", "머리": ["수량(m당)"], "단위": None, "꼴": "수"})
# 빈 줄 몇 개를 미리 둠 — 「줄 더하기」 안 눌러도 바로 입력하게
basis_rows = [{"id": str(i), "값": {}} for i in range(1, 4)]
with _LOCK:
if path.is_file():
raise StoreError(409, f"이미 있는 구조물 도면 「{column}」")
@@ -146,21 +159,107 @@ def create_structure(column: str) -> dict[str, Any]:
"열": column,
"도번": f"구-{number:02d}",
"도면": {"format": 6, "entities": []},
"산출근거": {"양식": "산출근거", "종류": "표", "판": 1, "열": basis, "줄": basis_rows},
}
atomic_write_json(path, doc)
ensure_basis(column)
return read("structure", column)
# ── 산출근거 ──────────────────────────────────────────
def _col_letters(index: int) -> str:
letters = ""
index += 1
while index:
index, rest = divmod(index - 1, 26)
letters = chr(65 + rest) + letters
return letters
def basis_doc(column: str, old: Any = None) -> dict[str, Any]:
"""빈 산출근거 통합문서 — 1 행 머리(공종 · 규격 · 산출 내역 · 단위 · 수량(m당)).
`old` = 옛 구조물 문서 `산출근거` 표(열 · 줄) — 머리 글 · 칸 값 · 열 폭을 옮김
(옛 열 식은 없어 안 옮김)."""
columns = old.get("열") if isinstance(old, dict) and isinstance(old.get("열"), list) else None
if columns:
heads = [
" ".join(str(h) for h in (c.get("머리") or []) if h) or c.get("id", "") for c in columns
]
else:
heads, columns = list(_BASIS_HEAD), []
cells: dict[str, Any] = {
f"{_col_letters(i)}1": {"값": head, "서식": 1} for i, head in enumerate(heads) if head
}
for r, row in enumerate(old.get("줄", []) if columns else [], start=2):
values = row.get("값", {}) if isinstance(row, dict) else {}
for i, col in enumerate(columns):
value = values.get(col.get("id"))
if value not in (None, ""):
cells[f"{_col_letters(i)}{r}"] = {"값": value}
widths = ((old or {}).get("보기") or {}).get("열너비") or {} if columns else {}
# 옛 폭은 px · 새 폭은 엑셀 글자 단위(숫자 폭 7px 어림)
info = {
_col_letters(i): {"폭": round(widths[c["id"]] / 7, 1)}
for i, c in enumerate(columns)
if isinstance(widths.get(c.get("id")), (int, float))
}
sheet: dict[str, Any] = {"id": "s1", "이름": "산출근거", "칸": cells}
if info:
sheet["열"] = info
return {
"종류": "통합문서",
"판": 1,
"열": column,
"서식": [{}, _BASIS_HEAD_STYLE],
"시트": [sheet],
"활성": "s1",
}
def ensure_basis(column: str, old: Any = None) -> bool:
"""산출근거 파일이 없으면 만듦(식 없는 새 문서라 `계산값` 없음 · 첫 [저장] 때 서버가 적음)."""
path = _path("basis", column)
with _LOCK:
if path.is_file():
return False
atomic_write_json(path, basis_doc(column, old))
return True
def _server_values(doc: Any) -> dict[str, Any]:
try:
return spreadsheet.with_server_values(doc)
except spreadsheet.RecalcError as e:
raise StoreError(503, str(e)) from e
def migrate_basis() -> dict[str, list[str]]:
"""한 번 — 옛 구조물 문서 안 `산출근거` 표를 `basis/<열 id>.json` 으로 옮기고
구조물 문서에서 뺌 · 그 뒤 산출근거가 없는 집계표 열은 빈 통합문서.
두 번 돌려도 같음(있는 파일은 안 덮음)."""
moved: list[str] = []
for name, doc in _structures():
if not isinstance(doc, dict) or "산출근거" not in doc:
continue
ensure_basis(name, doc["산출근거"])
rest = {key: value for key, value in doc.items() if key != "산출근거"}
with _LOCK:
atomic_write_json(_path("structure", name), rest)
moved.append(name)
made = [column for column in _column_ids() if ensure_basis(column)]
return {"옮김": moved, "새로": made}
def backfill_missing_structures() -> list[str]:
"""구조물집계표 열마다 빈 구조물 도면이 있게 — 없는 열만 열 차례로 새로 만듦(도번 이어서).
"""구조물집계표 열마다 빈 구조물 도면 · 산출근거가 있게 — 없는 열만 열 차례로 새로
만듦(도번 이어서).
그 사이 남이 만들었거나(409) 열 규칙에 안 맞으면(404) 그 열만 건너뜀."""
table = read("table", STRUCTURE_TABLE)["문서"]
have = {name for name, _ in _structures()}
made: list[str] = []
for col in table.get("열", []):
cid = col.get("id") if isinstance(col, dict) else None
if not cid or cid in have:
for cid in _column_ids():
if cid in have:
ensure_basis(cid)
continue
try:
create_structure(cid)
@@ -1,7 +1,9 @@
/* =============================================================================
* M02_MasterTemplete_Structure.ts
* 구조물집계표 아래 설계 컨테이너 — 고른 열의 구조물 도면 미리보기 · [도면 수정] · 상세 산출근거 표.
* 구조물집계표 아래 설계 컨테이너 — 고른 열의 구조물 도면 미리보기 · [도면 수정] · 상세 산출근거.
* 카드 둘(도면 정보 · 상세 산출근거)은 좌측 패널과 같은 공용 ui-sidebar 틀.
* 상세 산출근거 = 스프레드시트(`A00_Common/spreadsheet/`) · 열마다 따로 파일(`basis/<열 id>.json`) ·
* 부품은 이 카드를 열 때만 `import()` 로 받음(다른 페이지 번들에 안 섞임) · [산출근거 저장] 때 서버가 다시 풂.
*
* 구조물 도면은 집계표 열마다 하나(`resources/master_template/structure/<열 id>.json`) ·
* 서버가 열마다 미리 만들어 둠(도번 자동 · 새 열도 집계표 저장 때 서버가 같이 만듦 — `M02_MasterTemplete_Store.py`).
@@ -11,7 +13,7 @@
import { createButton, el, showConfirmDialog, showToast } from "@ui/ui_template_elements";
import { attachCollapsible } from "@ui/ui_template_collapsible";
import { createSheet, type SheetDoc, type SheetHandle } from "@ui/sheet/ui_template_sheet";
import type { SpreadsheetHandle, Workbook } from "../A00_Common/spreadsheet/spreadsheet";
import {
fetchStructureNumbers,
readTemplate,
@@ -36,17 +38,20 @@ const TEXT = {
basis: "상세 산출근거",
noNumber: "도번 없음",
noDrawing: "도면 없음",
noBasis: "표 없음 — 도면이 아직 없음",
noBasis: "산출근거 없음",
noSheet: "스프레드시트를 못 띄움 — {value}",
saved: "산출근거를 저장했음",
dirty: "저장 안 한 산출근거 고침이 있음 — 버리고 넘어갈까?",
kept: "열을 바꾸지 않음 — 산출근거를 먼저 저장할 것",
stale: "그 사이 구조물 도면이 바뀜 — 열을 다시 고를 것",
stale: "그 사이 산출근거가 바뀜 — 열을 다시 고를 것",
readFailed: "구조물 도면을 못 읽음 — {value}",
saveFailed: "저장 못 함 — {value}",
};
const why = (error: unknown): string => (error instanceof Error ? error.message : "");
const fill = (text: string, value: string): string => text.replace("{value}", value);
/** 스프레드시트는 산출근거 카드를 열 때만 받음 */
const loadSpreadsheet = () => import("../A00_Common/spreadsheet/spreadsheet");
/** 집계표 열 — 머리 글만 씀 */
export interface StructureColumn {
@@ -83,9 +88,10 @@ export function createStructurePanel(opts: StructurePanelOptions): StructurePane
let column: string | null = null;
let columnLabel = "";
let doc: StructureDoc | null = null;
let version = "";
let basisDoc: Workbook | null = null;
let basisVersion = "";
let preview: DrawingTemplateHandle | null = null;
let basis: SheetHandle | null = null;
let basis: SpreadsheetHandle | null = null;
let basisDirty = false;
let busy = false;
let seq = 0;
@@ -146,19 +152,34 @@ export function createStructurePanel(opts: StructurePanelOptions): StructurePane
apply();
};
/** 미리보기 · 산출근거 표 — 문서가 없으면 없음 글(서버가 열마다 미리 만들어 둬 보통은 없음) */
const paintBody = (): void => {
/** 산출근거 스프레드시트 — 부품을 늦게 받음 · 그 사이 열이 바뀌면 버림 */
const paintBasis = async (mine: number): Promise<void> => {
if (!basisDoc) return void basisHost.replaceChildren(note(TEXT.noBasis));
try {
const { createSpreadsheet } = await loadSpreadsheet();
if (mine !== seq || !basisDoc) return;
basis = createSpreadsheet(basisHost, basisDoc, {
readOnly: !opts.isAdmin,
onChange: () => (basisDirty = true),
});
} catch (error) {
if (mine === seq) basisHost.replaceChildren(note(fill(TEXT.noSheet, why(error))));
}
};
/** 미리보기 · 산출근거 — 문서가 없으면 없음 글(서버가 열마다 미리 만들어 둬 보통은 없음) */
const paintBody = (mine: number): void => {
drop();
number.textContent = doc?.도번 || numbers[column ?? ""] || TEXT.noNumber;
edit.hidden = !opts.isAdmin;
saveBasis.hidden = !opts.isAdmin || !doc;
saveBasis.hidden = !opts.isAdmin || !basisDoc;
basisHost.replaceChildren();
void paintBasis(mine);
if (!doc) {
previewHost.replaceChildren(note(TEXT.noDrawing));
basisHost.replaceChildren(note(TEXT.noBasis));
return;
}
previewHost.replaceChildren();
basisHost.replaceChildren();
// 미리보기 — 도면 양식과 같은 부품을 읽기만으로. 자동백업 칸은 편집 화면과 갈라 둠.
preview = mountDrawingTemplate(previewHost, doc.도면 as unknown as DrawingTemplateDoc, {
readOnly: true,
@@ -167,10 +188,6 @@ export function createStructurePanel(opts: StructurePanelOptions): StructurePane
});
const frame = previewHost.querySelector("iframe");
if (frame) hideToolbar(frame);
basis = createSheet(basisHost, doc.산출근거 as unknown as SheetDoc, {
mode: "master",
onChange: () => (basisDirty = true),
});
};
async function load(col: StructureColumn | null): Promise<void> {
@@ -188,20 +205,23 @@ export function createStructurePanel(opts: StructurePanelOptions): StructurePane
columnName.textContent = columnLabel;
number.textContent = numbers[col.id] ?? TEXT.noNumber;
doc = null;
version = "";
basisDoc = null;
basisVersion = "";
previewHost.replaceChildren();
basisHost.replaceChildren();
try {
const got = await readTemplate("system", "structure", col.id, null);
if (mine !== seq) return;
doc = got.문서 as StructureDoc;
version = got.판 ?? "";
} catch (error) {
if (mine !== seq) return;
// 도번 목록에 있는 열인데 못 읽으면 알림 — 없는 열은 그냥 「도면 없음」
if (numbers[col.id]) showToast(fill(TEXT.readFailed, why(error)), "error");
const [drawing, sheet] = await Promise.allSettled([
readTemplate("system", "structure", col.id, null),
readTemplate("system", "basis", col.id, null),
]);
if (mine !== seq) return;
if (drawing.status === "fulfilled") doc = drawing.value.문서 as StructureDoc;
// 도번 목록에 있는 열인데 못 읽으면 알림 — 없는 열은 그냥 「도면 없음」
else if (numbers[col.id]) showToast(fill(TEXT.readFailed, why(drawing.reason)), "error");
if (sheet.status === "fulfilled") {
basisDoc = sheet.value.문서 as Workbook;
basisVersion = sheet.value.판 ?? "";
}
paintBody();
paintBody(mine);
}
const show = (colId: string | null, columns: StructureColumn[]): void => {
@@ -217,17 +237,15 @@ export function createStructurePanel(opts: StructurePanelOptions): StructurePane
saveBasis.addEventListener("click", async () => {
const at = column;
if (!at || !doc || !basis || busy) return;
if (!at || !basis || busy) return;
busy = true;
saveBasis.disabled = true;
try {
const next: StructureDoc = {
...doc,
산출근거: basis.getDoc() as unknown as StructureDoc["산출근거"],
};
const info = await saveTemplate("system", "structure", at, null, version, next);
version = info.판;
doc = next;
// 계산값은 서버가 같은 엔진으로 다시 풀어 적음(보낸 값은 버림)
const next = basis.getDoc();
const info = await saveTemplate("system", "basis", at, null, basisVersion, next);
basisVersion = info.판;
basisDoc = next;
basisDirty = false;
showToast(TEXT.saved, "success");
} catch (error) {
@@ -22,6 +22,8 @@ import {
type StructureColumn,
type StructurePanelHandle,
} from "./M02_MasterTemplete_Structure";
// ⚠ 임시 — [스프레드시트 시험] 단추(1단계 검증 뒤 지움)
import { mountSpreadsheetTrial } from "./M02_MasterTemplete_Spreadsheet_Trial";
export interface Selection {
layer: Layer;
@@ -77,6 +79,9 @@ export function createMain(isAdmin: boolean, onSaved?: () => void): MainHandle {
const title = el("h2", { className: "m02-main__title", text: L("M02_PickTemplate") });
const reload = createButton({ label: L("M02_Reload"), variant: "ghost" });
const save = createButton({ label: L("M02_Save"), variant: "filled" });
// ⚠ 임시 — [스프레드시트 시험] 단추(사용자 지시 · 1단계 검증 뒤 지움)
const trial = createButton({ label: "스프레드시트 시험", variant: "ghost" });
trial.hidden = !isAdmin;
const slot = el("div", { className: "m02-main__slot" }); // 도면 양식의 작도 영역 칸이 들어옴
const notice = el("div", { className: "m02-main__notice", attrs: { hidden: "" } });
const host = el("div", { className: "m02-main__host" });
@@ -84,7 +89,7 @@ export function createMain(isAdmin: boolean, onSaved?: () => void): MainHandle {
const root = el("div", {
className: "m02-main",
children: [
el("div", { className: "m02-main__head", children: [title, slot, reload, save] }),
el("div", { className: "m02-main__head", children: [title, slot, reload, save, trial] }),
notice,
host,
extra,
@@ -269,6 +274,18 @@ export function createMain(isAdmin: boolean, onSaved?: () => void): MainHandle {
}
save.addEventListener("click", () => void doSave());
// ⚠ 임시 — [스프레드시트 시험] 빈 통합문서(시트 셋)를 메인 칸에 넓게 엶 · 저장은 서버 tmp 견본 자리
trial.addEventListener("click", async () => {
if (!(await confirmLeave())) return;
seq += 1;
dirty = false;
drop();
showNotice([]);
sel = null;
bar();
title.textContent = "스프레드시트 시험";
editor = mountSpreadsheetTrial(host, (d) => (dirty = d));
});
reload.addEventListener("click", async () => {
if (sel && (await confirmLeave())) void open(sel);
});
+9 -7
View File
@@ -1,10 +1,10 @@
"""M02 양식 층 — 네 층의 자리 · 읽기 · 쓰기 · 복사 · manifest.
층 넷 (PLAN 10-5):
system `resources/master_template/{table,drawing,structure}/<이름>.json` (git)
company `storage/{회사}/templates/{table,drawing,structure}/`
personal `storage/{회사}/{사용자}/templates/{table,drawing,structure}/`
project `storage/{회사}/{사용자}/{프로젝트}/templates/{table,drawing,structure}/`
system `resources/master_template/{table,drawing,structure,basis}/<이름>.json` (git)
company `storage/{회사}/templates/{table,drawing,structure,basis}/`
personal `storage/{회사}/{사용자}/templates/{table,drawing,structure,basis}/`
project `storage/{회사}/{사용자}/{프로젝트}/templates/{table,drawing,structure,basis}/`
+ 초기 사본 `templates/_initial/`
- 층 폴더마다 `manifest.json` — 그 폴더에 든 양식의 출처 `{"table/이름": {층, 이름, 판, 적용일}}`.
@@ -26,7 +26,8 @@ from common_util.common_util_json import atomic_write_json
from config import config_system
LAYERS = ("system", "company", "personal", "project")
KINDS = ("table", "drawing", "structure")
# basis = 산출근거 통합문서(구조물집계표 열마다 · 스프레드시트 `A00_Common/spreadsheet/`)
KINDS = ("table", "drawing", "structure", "basis")
TEMPLATES_DIRNAME = "templates"
INITIAL_DIRNAME = "_initial"
MANIFEST_NAME = "manifest.json"
@@ -165,13 +166,14 @@ def read_template(layer_dir: str | Path, kind: str, name: str) -> dict[str, Any]
_SKELETON = {
"table": ("열",),
"drawing": ("entities",),
"structure": ("도면.entities", "산출근거.열"),
"structure": ("도면.entities",),
"basis": ("시트",),
}
def check_skeleton(kind: str, document: Any) -> None:
"""뼈대 없는 문서는 거절 — 표 = `열` 목록 · 도면 = `entities` 목록 ·
구조물 도면 = `도면.entities` · `산출근거.열` 목록(빈 `{}` 저장 막기)."""
구조물 도면 = `도면.entities` 목록 · 산출근거 = `시트` 목록(빈 `{}` 저장 막기)."""
if not isinstance(document, dict):
raise ValueError("양식 문서는 JSON 객체여야 합니다.")
for key in _SKELETON.get(kind, ()):
+56
View File
@@ -0,0 +1,56 @@
"""산출근거 스프레드시트(통합문서) 서버 재계산 — 화면과 같은 TS 엔진을 Node 로 돌리는 껍데기.
CLAUDE.md 5장 ② — 계산은 `A00_Common/spreadsheet/` 한 벌. 여기는 번들을 부르기만 함
(`npm run build:spreadsheet` → `config/spreadsheet_node/`). [저장] 때 브라우저가 보낸 `계산값` 은
버리고 이 결과를 정본으로 적음.
결과 한 권 = `{시트 id: {A1: {수|글|참|오류}}}`(식 칸만) · 수는 십진 글.
"""
from __future__ import annotations
import copy
from typing import Any
from common_util.common_util_node_bundle import ROOT, build_bundle, run_bundle_json
BUNDLE = ROOT / "config" / "spreadsheet_node" / "common_util_spreadsheet_node.js"
NPM_SCRIPT = "build:spreadsheet"
ENTRY = ROOT / "common_util" / "common_util_spreadsheet_node.ts"
SOURCES = (ROOT / "A00_Common" / "spreadsheet", ROOT / "ui_template" / "sheet")
class RecalcError(Exception):
"""번들 빌드 · 실행 실패 또는 엔진이 그 통합문서를 못 풂 — 저장을 막음(검산 없이 적지 않음)."""
def _stale() -> bool:
"""번들이 없거나 엔진 TS 원본보다 오래됐으면 참 — 낡으면 화면과 서버가 다른 값을 냄."""
if not BUNDLE.is_file():
return True
built_at = BUNDLE.stat().st_mtime
paths = [ENTRY, *(p for folder in SOURCES for p in folder.glob("*.ts"))]
return any(p.stat().st_mtime > built_at for p in paths)
def recalc_workbooks(books: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""통합문서 여럿 → 입력 차례 그대로 `계산값`. 하나라도 못 풀면 `RecalcError`."""
if _stale() and not build_bundle(NPM_SCRIPT):
raise RecalcError("스프레드시트 계산 번들을 못 만듦")
out = run_bundle_json(BUNDLE, NPM_SCRIPT, {"books": books})
if out is None:
raise RecalcError("스프레드시트 계산 번들 실행 실패")
values: list[dict[str, Any]] = []
for result in out["results"]:
if not result.get("ok"):
raise RecalcError(f"스프레드시트 계산 실패 — {result.get('까닭')}")
values.append(result["계산값"])
return values
def with_server_values(book: dict[str, Any]) -> dict[str, Any]:
"""[저장] 직전 — 브라우저 `계산값` 은 버리고 서버 풀이로 새로 적은 사본."""
clean = copy.deepcopy(book)
clean.pop("계산값", None)
clean["계산값"] = recalc_workbooks([clean])[0]
return clean
@@ -0,0 +1,33 @@
/* =============================================================================
* common_util_spreadsheet_node.ts
* 산출근거 스프레드시트 풀이를 **서버가** 돌리는 진입점 — [저장] 때 정본 `계산값`.
*
* 풀이는 화면이 쓰는 `A00_Common/spreadsheet/spreadsheet_recalc.ts` 그대로 — 여기에는 계산이 없음(CLAUDE.md 5장 ②).
* 파이썬 껍데기 = `common_util_spreadsheet.py` · 번들 = `npm run build:spreadsheet`.
*
* 실행: node <번들> <입력.json> <출력.json>
* 입력 { books: Workbook[] }
* 출력 { results: ({ ok: true, 계산값 } | { ok: false, 까닭 })[] } — 입력 차례 그대로 · 한 권이 죽어도 나머지는 풂
* 끝 코드: 0 성공 / 2 인자 오류
* ⚠ `A00_Common/spreadsheet/` 밖에 둠 — node:fs 가 브라우저 번들로 새지 않게.
* ========================================================================== */
import { readFileSync, writeFileSync } from "node:fs";
import { recalcWorkbook } from "../A00_Common/spreadsheet/spreadsheet_recalc";
import type { Workbook } from "../A00_Common/spreadsheet/spreadsheet_types";
const [inputPath, outputPath] = process.argv.slice(2);
if (!inputPath || !outputPath) {
console.error("사용법: node <번들> <입력.json> <출력.json>");
process.exit(2);
}
const input = JSON.parse(readFileSync(inputPath, "utf8")) as { books?: Workbook[] };
const results = (input.books ?? []).map((book) => {
try {
return { ok: true, 계산값: recalcWorkbook(book) };
} catch (error) {
return { ok: false, 까닭: error instanceof Error ? error.message : String(error) };
}
});
writeFileSync(outputPath, JSON.stringify({ results }));
+2 -1
View File
@@ -5,10 +5,11 @@
"type": "module",
"scripts": {
"dev": "node ./config/node_modules/vite/bin/vite.js --configLoader runner",
"build": "node ./config/node_modules/typescript/bin/tsc --noEmit && node ./config/node_modules/vite/bin/vite.js build --configLoader runner && npm run build:corridor && npm run build:server-calc && npm run build:formula && npm run build:b07-cad",
"build": "node ./config/node_modules/typescript/bin/tsc --noEmit && node ./config/node_modules/vite/bin/vite.js build --configLoader runner && npm run build:corridor && npm run build:server-calc && npm run build:formula && npm run build:spreadsheet && npm run build:b07-cad",
"build:corridor": "node ./config/node_modules/vite/bin/vite.js build --configLoader runner --ssr ../B05_Profile/B05_Profile_Corridor_Node.ts --outDir ../config/corridor_node",
"build:server-calc": "node ./config/node_modules/vite/bin/vite.js build --configLoader runner --ssr ../B06_Section/B06_Section_Server_Calc_Node.ts --outDir ../config/server_calc_node",
"build:formula": "node ./config/node_modules/vite/bin/vite.js build --configLoader runner --ssr ../common_util/common_util_sheet_recalc_node.ts --outDir ../config/formula_node",
"build:spreadsheet": "node ./config/node_modules/vite/bin/vite.js build --configLoader runner --ssr ../common_util/common_util_spreadsheet_node.ts --outDir ../config/spreadsheet_node",
"install:b07-cad": "npm --prefix B07_DesignDetail/openwebcad install",
"build:b07-cad": "npm run install:b07-cad && npm --prefix B07_DesignDetail/openwebcad run build",
"preview": "node ./config/node_modules/vite/bin/vite.js preview --configLoader runner",
+56
View File
@@ -0,0 +1,56 @@
{
"종류": "통합문서",
"판": 1,
"열": "be",
"서식": [
{},
{
"굵게": true,
"가로": "center",
"세로": "center",
"테두리": {
"위": {
"선": "thin"
},
"아래": {
"선": "thin"
},
"왼": {
"선": "thin"
},
"오른": {
"선": "thin"
}
}
}
],
"시트": [
{
"id": "s1",
"이름": "산출근거",
"칸": {
"A1": {
"값": "공종",
"서식": 1
},
"B1": {
"값": "규격",
"서식": 1
},
"C1": {
"값": "산출 내역",
"서식": 1
},
"D1": {
"값": "단위",
"서식": 1
},
"E1": {
"값": "수량(m당)",
"서식": 1
}
}
}
],
"활성": "s1"
}
+56
View File
@@ -0,0 +1,56 @@
{
"종류": "통합문서",
"판": 1,
"열": "bg",
"서식": [
{},
{
"굵게": true,
"가로": "center",
"세로": "center",
"테두리": {
"위": {
"선": "thin"
},
"아래": {
"선": "thin"
},
"왼": {
"선": "thin"
},
"오른": {
"선": "thin"
}
}
}
],
"시트": [
{
"id": "s1",
"이름": "산출근거",
"칸": {
"A1": {
"값": "공종",
"서식": 1
},
"B1": {
"값": "규격",
"서식": 1
},
"C1": {
"값": "산출 내역",
"서식": 1
},
"D1": {
"값": "단위",
"서식": 1
},
"E1": {
"값": "수량(m당)",
"서식": 1
}
}
}
],
"활성": "s1"
}
@@ -0,0 +1,56 @@
{
"종류": "통합문서",
"판": 1,
"열": "bm_area",
"서식": [
{},
{
"굵게": true,
"가로": "center",
"세로": "center",
"테두리": {
"위": {
"선": "thin"
},
"아래": {
"선": "thin"
},
"왼": {
"선": "thin"
},
"오른": {
"선": "thin"
}
}
}
],
"시트": [
{
"id": "s1",
"이름": "산출근거",
"칸": {
"A1": {
"값": "공종",
"서식": 1
},
"B1": {
"값": "규격",
"서식": 1
},
"C1": {
"값": "산출 내역",
"서식": 1
},
"D1": {
"값": "단위",
"서식": 1
},
"E1": {
"값": "수량(m당)",
"서식": 1
}
}
}
],
"활성": "s1"
}
@@ -0,0 +1,56 @@
{
"종류": "통합문서",
"판": 1,
"열": "bm_len",
"서식": [
{},
{
"굵게": true,
"가로": "center",
"세로": "center",
"테두리": {
"위": {
"선": "thin"
},
"아래": {
"선": "thin"
},
"왼": {
"선": "thin"
},
"오른": {
"선": "thin"
}
}
}
],
"시트": [
{
"id": "s1",
"이름": "산출근거",
"칸": {
"A1": {
"값": "공종",
"서식": 1
},
"B1": {
"값": "규격",
"서식": 1
},
"C1": {
"값": "산출 내역",
"서식": 1
},
"D1": {
"값": "단위",
"서식": 1
},
"E1": {
"값": "수량(m당)",
"서식": 1
}
}
}
],
"활성": "s1"
}
@@ -0,0 +1,56 @@
{
"종류": "통합문서",
"판": 1,
"열": "bx_len",
"서식": [
{},
{
"굵게": true,
"가로": "center",
"세로": "center",
"테두리": {
"위": {
"선": "thin"
},
"아래": {
"선": "thin"
},
"왼": {
"선": "thin"
},
"오른": {
"선": "thin"
}
}
}
],
"시트": [
{
"id": "s1",
"이름": "산출근거",
"칸": {
"A1": {
"값": "공종",
"서식": 1
},
"B1": {
"값": "규격",
"서식": 1
},
"C1": {
"값": "산출 내역",
"서식": 1
},
"D1": {
"값": "단위",
"서식": 1
},
"E1": {
"값": "수량(m당)",
"서식": 1
}
}
}
],
"활성": "s1"
}
@@ -0,0 +1,56 @@
{
"종류": "통합문서",
"판": 1,
"열": "bx_wing",
"서식": [
{},
{
"굵게": true,
"가로": "center",
"세로": "center",
"테두리": {
"위": {
"선": "thin"
},
"아래": {
"선": "thin"
},
"왼": {
"선": "thin"
},
"오른": {
"선": "thin"
}
}
}
],
"시트": [
{
"id": "s1",
"이름": "산출근거",
"칸": {
"A1": {
"값": "공종",
"서식": 1
},
"B1": {
"값": "규격",
"서식": 1
},
"C1": {
"값": "산출 내역",
"서식": 1
},
"D1": {
"값": "단위",
"서식": 1
},
"E1": {
"값": "수량(m당)",
"서식": 1
}
}
}
],
"활성": "s1"
}
+56
View File
@@ -0,0 +1,56 @@
{
"종류": "통합문서",
"판": 1,
"열": "cs",
"서식": [
{},
{
"굵게": true,
"가로": "center",
"세로": "center",
"테두리": {
"위": {
"선": "thin"
},
"아래": {
"선": "thin"
},
"왼": {
"선": "thin"
},
"오른": {
"선": "thin"
}
}
}
],
"시트": [
{
"id": "s1",
"이름": "산출근거",
"칸": {
"A1": {
"값": "공종",
"서식": 1
},
"B1": {
"값": "규격",
"서식": 1
},
"C1": {
"값": "산출 내역",
"서식": 1
},
"D1": {
"값": "단위",
"서식": 1
},
"E1": {
"값": "수량(m당)",
"서식": 1
}
}
}
],
"활성": "s1"
}
+56
View File
@@ -0,0 +1,56 @@
{
"종류": "통합문서",
"판": 1,
"열": "ec",
"서식": [
{},
{
"굵게": true,
"가로": "center",
"세로": "center",
"테두리": {
"위": {
"선": "thin"
},
"아래": {
"선": "thin"
},
"왼": {
"선": "thin"
},
"오른": {
"선": "thin"
}
}
}
],
"시트": [
{
"id": "s1",
"이름": "산출근거",
"칸": {
"A1": {
"값": "공종",
"서식": 1
},
"B1": {
"값": "규격",
"서식": 1
},
"C1": {
"값": "산출 내역",
"서식": 1
},
"D1": {
"값": "단위",
"서식": 1
},
"E1": {
"값": "수량(m당)",
"서식": 1
}
}
}
],
"활성": "s1"
}
+56
View File
@@ -0,0 +1,56 @@
{
"종류": "통합문서",
"판": 1,
"열": "etc",
"서식": [
{},
{
"굵게": true,
"가로": "center",
"세로": "center",
"테두리": {
"위": {
"선": "thin"
},
"아래": {
"선": "thin"
},
"왼": {
"선": "thin"
},
"오른": {
"선": "thin"
}
}
}
],
"시트": [
{
"id": "s1",
"이름": "산출근거",
"칸": {
"A1": {
"값": "공종",
"서식": 1
},
"B1": {
"값": "규격",
"서식": 1
},
"C1": {
"값": "산출 내역",
"서식": 1
},
"D1": {
"값": "단위",
"서식": 1
},
"E1": {
"값": "수량(m당)",
"서식": 1
}
}
}
],
"활성": "s1"
}
+56
View File
@@ -0,0 +1,56 @@
{
"종류": "통합문서",
"판": 1,
"열": "fb",
"서식": [
{},
{
"굵게": true,
"가로": "center",
"세로": "center",
"테두리": {
"위": {
"선": "thin"
},
"아래": {
"선": "thin"
},
"왼": {
"선": "thin"
},
"오른": {
"선": "thin"
}
}
}
],
"시트": [
{
"id": "s1",
"이름": "산출근거",
"칸": {
"A1": {
"값": "공종",
"서식": 1
},
"B1": {
"값": "규격",
"서식": 1
},
"C1": {
"값": "산출 내역",
"서식": 1
},
"D1": {
"값": "단위",
"서식": 1
},
"E1": {
"값": "수량(m당)",
"서식": 1
}
}
}
],
"활성": "s1"
}
+56
View File
@@ -0,0 +1,56 @@
{
"종류": "통합문서",
"판": 1,
"열": "fp_l",
"서식": [
{},
{
"굵게": true,
"가로": "center",
"세로": "center",
"테두리": {
"위": {
"선": "thin"
},
"아래": {
"선": "thin"
},
"왼": {
"선": "thin"
},
"오른": {
"선": "thin"
}
}
}
],
"시트": [
{
"id": "s1",
"이름": "산출근거",
"칸": {
"A1": {
"값": "공종",
"서식": 1
},
"B1": {
"값": "규격",
"서식": 1
},
"C1": {
"값": "산출 내역",
"서식": 1
},
"D1": {
"값": "단위",
"서식": 1
},
"E1": {
"값": "수량(m당)",
"서식": 1
}
}
}
],
"활성": "s1"
}
+56
View File
@@ -0,0 +1,56 @@
{
"종류": "통합문서",
"판": 1,
"열": "fp_w",
"서식": [
{},
{
"굵게": true,
"가로": "center",
"세로": "center",
"테두리": {
"위": {
"선": "thin"
},
"아래": {
"선": "thin"
},
"왼": {
"선": "thin"
},
"오른": {
"선": "thin"
}
}
}
],
"시트": [
{
"id": "s1",
"이름": "산출근거",
"칸": {
"A1": {
"값": "공종",
"서식": 1
},
"B1": {
"값": "규격",
"서식": 1
},
"C1": {
"값": "산출 내역",
"서식": 1
},
"D1": {
"값": "단위",
"서식": 1
},
"E1": {
"값": "수량(m당)",
"서식": 1
}
}
}
],
"활성": "s1"
}
@@ -0,0 +1,56 @@
{
"종류": "통합문서",
"판": 1,
"열": "gr_curb",
"서식": [
{},
{
"굵게": true,
"가로": "center",
"세로": "center",
"테두리": {
"위": {
"선": "thin"
},
"아래": {
"선": "thin"
},
"왼": {
"선": "thin"
},
"오른": {
"선": "thin"
}
}
}
],
"시트": [
{
"id": "s1",
"이름": "산출근거",
"칸": {
"A1": {
"값": "공종",
"서식": 1
},
"B1": {
"값": "규격",
"서식": 1
},
"C1": {
"값": "산출 내역",
"서식": 1
},
"D1": {
"값": "단위",
"서식": 1
},
"E1": {
"값": "수량(m당)",
"서식": 1
}
}
}
],
"활성": "s1"
}
@@ -0,0 +1,56 @@
{
"종류": "통합문서",
"판": 1,
"열": "gr_rail",
"서식": [
{},
{
"굵게": true,
"가로": "center",
"세로": "center",
"테두리": {
"위": {
"선": "thin"
},
"아래": {
"선": "thin"
},
"왼": {
"선": "thin"
},
"오른": {
"선": "thin"
}
}
}
],
"시트": [
{
"id": "s1",
"이름": "산출근거",
"칸": {
"A1": {
"값": "공종",
"서식": 1
},
"B1": {
"값": "규격",
"서식": 1
},
"C1": {
"값": "산출 내역",
"서식": 1
},
"D1": {
"값": "단위",
"서식": 1
},
"E1": {
"값": "수량(m당)",
"서식": 1
}
}
}
],
"활성": "s1"
}
@@ -0,0 +1,56 @@
{
"종류": "통합문서",
"판": 1,
"열": "gr_sign",
"서식": [
{},
{
"굵게": true,
"가로": "center",
"세로": "center",
"테두리": {
"위": {
"선": "thin"
},
"아래": {
"선": "thin"
},
"왼": {
"선": "thin"
},
"오른": {
"선": "thin"
}
}
}
],
"시트": [
{
"id": "s1",
"이름": "산출근거",
"칸": {
"A1": {
"값": "공종",
"서식": 1
},
"B1": {
"값": "규격",
"서식": 1
},
"C1": {
"값": "산출 내역",
"서식": 1
},
"D1": {
"값": "단위",
"서식": 1
},
"E1": {
"값": "수량(m당)",
"서식": 1
}
}
}
],
"활성": "s1"
}
@@ -0,0 +1,56 @@
{
"종류": "통합문서",
"판": 1,
"열": "h_break",
"서식": [
{},
{
"굵게": true,
"가로": "center",
"세로": "center",
"테두리": {
"위": {
"선": "thin"
},
"아래": {
"선": "thin"
},
"왼": {
"선": "thin"
},
"오른": {
"선": "thin"
}
}
}
],
"시트": [
{
"id": "s1",
"이름": "산출근거",
"칸": {
"A1": {
"값": "공종",
"서식": 1
},
"B1": {
"값": "규격",
"서식": 1
},
"C1": {
"값": "산출 내역",
"서식": 1
},
"D1": {
"값": "단위",
"서식": 1
},
"E1": {
"값": "수량(m당)",
"서식": 1
}
}
}
],
"활성": "s1"
}
@@ -0,0 +1,56 @@
{
"종류": "통합문서",
"판": 1,
"열": "h_ford_basin",
"서식": [
{},
{
"굵게": true,
"가로": "center",
"세로": "center",
"테두리": {
"위": {
"선": "thin"
},
"아래": {
"선": "thin"
},
"왼": {
"선": "thin"
},
"오른": {
"선": "thin"
}
}
}
],
"시트": [
{
"id": "s1",
"이름": "산출근거",
"칸": {
"A1": {
"값": "공종",
"서식": 1
},
"B1": {
"값": "규격",
"서식": 1
},
"C1": {
"값": "산출 내역",
"서식": 1
},
"D1": {
"값": "단위",
"서식": 1
},
"E1": {
"값": "수량(m당)",
"서식": 1
}
}
}
],
"활성": "s1"
}
@@ -0,0 +1,56 @@
{
"종류": "통합문서",
"판": 1,
"열": "h_intake",
"서식": [
{},
{
"굵게": true,
"가로": "center",
"세로": "center",
"테두리": {
"위": {
"선": "thin"
},
"아래": {
"선": "thin"
},
"왼": {
"선": "thin"
},
"오른": {
"선": "thin"
}
}
}
],
"시트": [
{
"id": "s1",
"이름": "산출근거",
"칸": {
"A1": {
"값": "공종",
"서식": 1
},
"B1": {
"값": "규격",
"서식": 1
},
"C1": {
"값": "산출 내역",
"서식": 1
},
"D1": {
"값": "단위",
"서식": 1
},
"E1": {
"값": "수량(m당)",
"서식": 1
}
}
}
],
"활성": "s1"
}
@@ -0,0 +1,56 @@
{
"종류": "통합문서",
"판": 1,
"열": "h_pg_rail",
"서식": [
{},
{
"굵게": true,
"가로": "center",
"세로": "center",
"테두리": {
"위": {
"선": "thin"
},
"아래": {
"선": "thin"
},
"왼": {
"선": "thin"
},
"오른": {
"선": "thin"
}
}
}
],
"시트": [
{
"id": "s1",
"이름": "산출근거",
"칸": {
"A1": {
"값": "공종",
"서식": 1
},
"B1": {
"값": "규격",
"서식": 1
},
"C1": {
"값": "산출 내역",
"서식": 1
},
"D1": {
"값": "단위",
"서식": 1
},
"E1": {
"값": "수량(m당)",
"서식": 1
}
}
}
],
"활성": "s1"
}
@@ -0,0 +1,56 @@
{
"종류": "통합문서",
"판": 1,
"열": "h_pv_rail",
"서식": [
{},
{
"굵게": true,
"가로": "center",
"세로": "center",
"테두리": {
"위": {
"선": "thin"
},
"아래": {
"선": "thin"
},
"왼": {
"선": "thin"
},
"오른": {
"선": "thin"
}
}
}
],
"시트": [
{
"id": "s1",
"이름": "산출근거",
"칸": {
"A1": {
"값": "공종",
"서식": 1
},
"B1": {
"값": "규격",
"서식": 1
},
"C1": {
"값": "산출 내역",
"서식": 1
},
"D1": {
"값": "단위",
"서식": 1
},
"E1": {
"값": "수량(m당)",
"서식": 1
}
}
}
],
"활성": "s1"
}
@@ -0,0 +1,56 @@
{
"종류": "통합문서",
"판": 1,
"열": "h_rip_c",
"서식": [
{},
{
"굵게": true,
"가로": "center",
"세로": "center",
"테두리": {
"위": {
"선": "thin"
},
"아래": {
"선": "thin"
},
"왼": {
"선": "thin"
},
"오른": {
"선": "thin"
}
}
}
],
"시트": [
{
"id": "s1",
"이름": "산출근거",
"칸": {
"A1": {
"값": "공종",
"서식": 1
},
"B1": {
"값": "규격",
"서식": 1
},
"C1": {
"값": "산출 내역",
"서식": 1
},
"D1": {
"값": "단위",
"서식": 1
},
"E1": {
"값": "수량(m당)",
"서식": 1
}
}
}
],
"활성": "s1"
}
@@ -0,0 +1,56 @@
{
"종류": "통합문서",
"판": 1,
"열": "h_rip_m",
"서식": [
{},
{
"굵게": true,
"가로": "center",
"세로": "center",
"테두리": {
"위": {
"선": "thin"
},
"아래": {
"선": "thin"
},
"왼": {
"선": "thin"
},
"오른": {
"선": "thin"
}
}
}
],
"시트": [
{
"id": "s1",
"이름": "산출근거",
"칸": {
"A1": {
"값": "공종",
"서식": 1
},
"B1": {
"값": "규격",
"서식": 1
},
"C1": {
"값": "산출 내역",
"서식": 1
},
"D1": {
"값": "단위",
"서식": 1
},
"E1": {
"값": "수량(m당)",
"서식": 1
}
}
}
],
"활성": "s1"
}
@@ -0,0 +1,56 @@
{
"종류": "통합문서",
"판": 1,
"열": "h_rockfall",
"서식": [
{},
{
"굵게": true,
"가로": "center",
"세로": "center",
"테두리": {
"위": {
"선": "thin"
},
"아래": {
"선": "thin"
},
"왼": {
"선": "thin"
},
"오른": {
"선": "thin"
}
}
}
],
"시트": [
{
"id": "s1",
"이름": "산출근거",
"칸": {
"A1": {
"값": "공종",
"서식": 1
},
"B1": {
"값": "규격",
"서식": 1
},
"C1": {
"값": "산출 내역",
"서식": 1
},
"D1": {
"값": "단위",
"서식": 1
},
"E1": {
"값": "수량(m당)",
"서식": 1
}
}
}
],
"활성": "s1"
}
@@ -0,0 +1,56 @@
{
"종류": "통합문서",
"판": 1,
"열": "h_soil_ditch",
"서식": [
{},
{
"굵게": true,
"가로": "center",
"세로": "center",
"테두리": {
"위": {
"선": "thin"
},
"아래": {
"선": "thin"
},
"왼": {
"선": "thin"
},
"오른": {
"선": "thin"
}
}
}
],
"시트": [
{
"id": "s1",
"이름": "산출근거",
"칸": {
"A1": {
"값": "공종",
"서식": 1
},
"B1": {
"값": "규격",
"서식": 1
},
"C1": {
"값": "산출 내역",
"서식": 1
},
"D1": {
"값": "단위",
"서식": 1
},
"E1": {
"값": "수량(m당)",
"서식": 1
}
}
}
],
"활성": "s1"
}
@@ -0,0 +1,56 @@
{
"종류": "통합문서",
"판": 1,
"열": "h_stone_bed",
"서식": [
{},
{
"굵게": true,
"가로": "center",
"세로": "center",
"테두리": {
"위": {
"선": "thin"
},
"아래": {
"선": "thin"
},
"왼": {
"선": "thin"
},
"오른": {
"선": "thin"
}
}
}
],
"시트": [
{
"id": "s1",
"이름": "산출근거",
"칸": {
"A1": {
"값": "공종",
"서식": 1
},
"B1": {
"값": "규격",
"서식": 1
},
"C1": {
"값": "산출 내역",
"서식": 1
},
"D1": {
"값": "단위",
"서식": 1
},
"E1": {
"값": "수량(m당)",
"서식": 1
}
}
}
],
"활성": "s1"
}
@@ -0,0 +1,56 @@
{
"종류": "통합문서",
"판": 1,
"열": "h_stone_ch",
"서식": [
{},
{
"굵게": true,
"가로": "center",
"세로": "center",
"테두리": {
"위": {
"선": "thin"
},
"아래": {
"선": "thin"
},
"왼": {
"선": "thin"
},
"오른": {
"선": "thin"
}
}
}
],
"시트": [
{
"id": "s1",
"이름": "산출근거",
"칸": {
"A1": {
"값": "공종",
"서식": 1
},
"B1": {
"값": "규격",
"서식": 1
},
"C1": {
"값": "산출 내역",
"서식": 1
},
"D1": {
"값": "단위",
"서식": 1
},
"E1": {
"값": "수량(m당)",
"서식": 1
}
}
}
],
"활성": "s1"
}
@@ -0,0 +1,56 @@
{
"종류": "통합문서",
"판": 1,
"열": "h_waste",
"서식": [
{},
{
"굵게": true,
"가로": "center",
"세로": "center",
"테두리": {
"위": {
"선": "thin"
},
"아래": {
"선": "thin"
},
"왼": {
"선": "thin"
},
"오른": {
"선": "thin"
}
}
}
],
"시트": [
{
"id": "s1",
"이름": "산출근거",
"칸": {
"A1": {
"값": "공종",
"서식": 1
},
"B1": {
"값": "규격",
"서식": 1
},
"C1": {
"값": "산출 내역",
"서식": 1
},
"D1": {
"값": "단위",
"서식": 1
},
"E1": {
"값": "수량(m당)",
"서식": 1
}
}
}
],
"활성": "s1"
}
+56
View File
@@ -0,0 +1,56 @@
{
"종류": "통합문서",
"판": 1,
"열": "memo",
"서식": [
{},
{
"굵게": true,
"가로": "center",
"세로": "center",
"테두리": {
"위": {
"선": "thin"
},
"아래": {
"선": "thin"
},
"왼": {
"선": "thin"
},
"오른": {
"선": "thin"
}
}
}
],
"시트": [
{
"id": "s1",
"이름": "산출근거",
"칸": {
"A1": {
"값": "공종",
"서식": 1
},
"B1": {
"값": "규격",
"서식": 1
},
"C1": {
"값": "산출 내역",
"서식": 1
},
"D1": {
"값": "단위",
"서식": 1
},
"E1": {
"값": "수량(m당)",
"서식": 1
}
}
}
],
"활성": "s1"
}
+56
View File
@@ -0,0 +1,56 @@
{
"종류": "통합문서",
"판": 1,
"열": "mr",
"서식": [
{},
{
"굵게": true,
"가로": "center",
"세로": "center",
"테두리": {
"위": {
"선": "thin"
},
"아래": {
"선": "thin"
},
"왼": {
"선": "thin"
},
"오른": {
"선": "thin"
}
}
}
],
"시트": [
{
"id": "s1",
"이름": "산출근거",
"칸": {
"A1": {
"값": "공종",
"서식": 1
},
"B1": {
"값": "규격",
"서식": 1
},
"C1": {
"값": "산출 내역",
"서식": 1
},
"D1": {
"값": "단위",
"서식": 1
},
"E1": {
"값": "수량(m당)",
"서식": 1
}
}
}
],
"활성": "s1"
}
+56
View File
@@ -0,0 +1,56 @@
{
"종류": "통합문서",
"판": 1,
"열": "ms",
"서식": [
{},
{
"굵게": true,
"가로": "center",
"세로": "center",
"테두리": {
"위": {
"선": "thin"
},
"아래": {
"선": "thin"
},
"왼": {
"선": "thin"
},
"오른": {
"선": "thin"
}
}
}
],
"시트": [
{
"id": "s1",
"이름": "산출근거",
"칸": {
"A1": {
"값": "공종",
"서식": 1
},
"B1": {
"값": "규격",
"서식": 1
},
"C1": {
"값": "산출 내역",
"서식": 1
},
"D1": {
"값": "단위",
"서식": 1
},
"E1": {
"값": "수량(m당)",
"서식": 1
}
}
}
],
"활성": "s1"
}
+56
View File
@@ -0,0 +1,56 @@
{
"종류": "통합문서",
"판": 1,
"열": "no",
"서식": [
{},
{
"굵게": true,
"가로": "center",
"세로": "center",
"테두리": {
"위": {
"선": "thin"
},
"아래": {
"선": "thin"
},
"왼": {
"선": "thin"
},
"오른": {
"선": "thin"
}
}
}
],
"시트": [
{
"id": "s1",
"이름": "산출근거",
"칸": {
"A1": {
"값": "공종",
"서식": 1
},
"B1": {
"값": "규격",
"서식": 1
},
"C1": {
"값": "산출 내역",
"서식": 1
},
"D1": {
"값": "단위",
"서식": 1
},
"E1": {
"값": "수량(m당)",
"서식": 1
}
}
}
],
"활성": "s1"
}
@@ -0,0 +1,56 @@
{
"종류": "통합문서",
"판": 1,
"열": "od_cnt",
"서식": [
{},
{
"굵게": true,
"가로": "center",
"세로": "center",
"테두리": {
"위": {
"선": "thin"
},
"아래": {
"선": "thin"
},
"왼": {
"선": "thin"
},
"오른": {
"선": "thin"
}
}
}
],
"시트": [
{
"id": "s1",
"이름": "산출근거",
"칸": {
"A1": {
"값": "공종",
"서식": 1
},
"B1": {
"값": "규격",
"서식": 1
},
"C1": {
"값": "산출 내역",
"서식": 1
},
"D1": {
"값": "단위",
"서식": 1
},
"E1": {
"값": "수량(m당)",
"서식": 1
}
}
}
],
"활성": "s1"
}
@@ -0,0 +1,56 @@
{
"종류": "통합문서",
"판": 1,
"열": "od_len",
"서식": [
{},
{
"굵게": true,
"가로": "center",
"세로": "center",
"테두리": {
"위": {
"선": "thin"
},
"아래": {
"선": "thin"
},
"왼": {
"선": "thin"
},
"오른": {
"선": "thin"
}
}
}
],
"시트": [
{
"id": "s1",
"이름": "산출근거",
"칸": {
"A1": {
"값": "공종",
"서식": 1
},
"B1": {
"값": "규격",
"서식": 1
},
"C1": {
"값": "산출 내역",
"서식": 1
},
"D1": {
"값": "단위",
"서식": 1
},
"E1": {
"값": "수량(m당)",
"서식": 1
}
}
}
],
"활성": "s1"
}
@@ -0,0 +1,56 @@
{
"종류": "통합문서",
"판": 1,
"열": "pg_in",
"서식": [
{},
{
"굵게": true,
"가로": "center",
"세로": "center",
"테두리": {
"위": {
"선": "thin"
},
"아래": {
"선": "thin"
},
"왼": {
"선": "thin"
},
"오른": {
"선": "thin"
}
}
}
],
"시트": [
{
"id": "s1",
"이름": "산출근거",
"칸": {
"A1": {
"값": "공종",
"서식": 1
},
"B1": {
"값": "규격",
"서식": 1
},
"C1": {
"값": "산출 내역",
"서식": 1
},
"D1": {
"값": "단위",
"서식": 1
},
"E1": {
"값": "수량(m당)",
"서식": 1
}
}
}
],
"활성": "s1"
}
@@ -0,0 +1,56 @@
{
"종류": "통합문서",
"판": 1,
"열": "pp_cp",
"서식": [
{},
{
"굵게": true,
"가로": "center",
"세로": "center",
"테두리": {
"위": {
"선": "thin"
},
"아래": {
"선": "thin"
},
"왼": {
"선": "thin"
},
"오른": {
"선": "thin"
}
}
}
],
"시트": [
{
"id": "s1",
"이름": "산출근거",
"칸": {
"A1": {
"값": "공종",
"서식": 1
},
"B1": {
"값": "규격",
"서식": 1
},
"C1": {
"값": "산출 내역",
"서식": 1
},
"D1": {
"값": "단위",
"서식": 1
},
"E1": {
"값": "수량(m당)",
"서식": 1
}
}
}
],
"활성": "s1"
}
@@ -0,0 +1,56 @@
{
"종류": "통합문서",
"판": 1,
"열": "pp_len",
"서식": [
{},
{
"굵게": true,
"가로": "center",
"세로": "center",
"테두리": {
"위": {
"선": "thin"
},
"아래": {
"선": "thin"
},
"왼": {
"선": "thin"
},
"오른": {
"선": "thin"
}
}
}
],
"시트": [
{
"id": "s1",
"이름": "산출근거",
"칸": {
"A1": {
"값": "공종",
"서식": 1
},
"B1": {
"값": "규격",
"서식": 1
},
"C1": {
"값": "산출 내역",
"서식": 1
},
"D1": {
"값": "단위",
"서식": 1
},
"E1": {
"값": "수량(m당)",
"서식": 1
}
}
}
],
"활성": "s1"
}
+56
View File
@@ -0,0 +1,56 @@
{
"종류": "통합문서",
"판": 1,
"열": "ps",
"서식": [
{},
{
"굵게": true,
"가로": "center",
"세로": "center",
"테두리": {
"위": {
"선": "thin"
},
"아래": {
"선": "thin"
},
"왼": {
"선": "thin"
},
"오른": {
"선": "thin"
}
}
}
],
"시트": [
{
"id": "s1",
"이름": "산출근거",
"칸": {
"A1": {
"값": "공종",
"서식": 1
},
"B1": {
"값": "규격",
"서식": 1
},
"C1": {
"값": "산출 내역",
"서식": 1
},
"D1": {
"값": "단위",
"서식": 1
},
"E1": {
"값": "수량(m당)",
"서식": 1
}
}
}
],
"활성": "s1"
}
+56
View File
@@ -0,0 +1,56 @@
{
"종류": "통합문서",
"판": 1,
"열": "pv_a",
"서식": [
{},
{
"굵게": true,
"가로": "center",
"세로": "center",
"테두리": {
"위": {
"선": "thin"
},
"아래": {
"선": "thin"
},
"왼": {
"선": "thin"
},
"오른": {
"선": "thin"
}
}
}
],
"시트": [
{
"id": "s1",
"이름": "산출근거",
"칸": {
"A1": {
"값": "공종",
"서식": 1
},
"B1": {
"값": "규격",
"서식": 1
},
"C1": {
"값": "산출 내역",
"서식": 1
},
"D1": {
"값": "단위",
"서식": 1
},
"E1": {
"값": "수량(m당)",
"서식": 1
}
}
}
],
"활성": "s1"
}
+56
View File
@@ -0,0 +1,56 @@
{
"종류": "통합문서",
"판": 1,
"열": "pv_b",
"서식": [
{},
{
"굵게": true,
"가로": "center",
"세로": "center",
"테두리": {
"위": {
"선": "thin"
},
"아래": {
"선": "thin"
},
"왼": {
"선": "thin"
},
"오른": {
"선": "thin"
}
}
}
],
"시트": [
{
"id": "s1",
"이름": "산출근거",
"칸": {
"A1": {
"값": "공종",
"서식": 1
},
"B1": {
"값": "규격",
"서식": 1
},
"C1": {
"값": "산출 내역",
"서식": 1
},
"D1": {
"값": "단위",
"서식": 1
},
"E1": {
"값": "수량(m당)",
"서식": 1
}
}
}
],
"활성": "s1"
}
@@ -0,0 +1,56 @@
{
"종류": "통합문서",
"판": 1,
"열": "pv_jt",
"서식": [
{},
{
"굵게": true,
"가로": "center",
"세로": "center",
"테두리": {
"위": {
"선": "thin"
},
"아래": {
"선": "thin"
},
"왼": {
"선": "thin"
},
"오른": {
"선": "thin"
}
}
}
],
"시트": [
{
"id": "s1",
"이름": "산출근거",
"칸": {
"A1": {
"값": "공종",
"서식": 1
},
"B1": {
"값": "규격",
"서식": 1
},
"C1": {
"값": "산출 내역",
"서식": 1
},
"D1": {
"값": "단위",
"서식": 1
},
"E1": {
"값": "수량(m당)",
"서식": 1
}
}
}
],
"활성": "s1"
}
+56
View File
@@ -0,0 +1,56 @@
{
"종류": "통합문서",
"판": 1,
"열": "pv_l",
"서식": [
{},
{
"굵게": true,
"가로": "center",
"세로": "center",
"테두리": {
"위": {
"선": "thin"
},
"아래": {
"선": "thin"
},
"왼": {
"선": "thin"
},
"오른": {
"선": "thin"
}
}
}
],
"시트": [
{
"id": "s1",
"이름": "산출근거",
"칸": {
"A1": {
"값": "공종",
"서식": 1
},
"B1": {
"값": "규격",
"서식": 1
},
"C1": {
"값": "산출 내역",
"서식": 1
},
"D1": {
"값": "단위",
"서식": 1
},
"E1": {
"값": "수량(m당)",
"서식": 1
}
}
}
],
"활성": "s1"
}
+56
View File
@@ -0,0 +1,56 @@
{
"종류": "통합문서",
"판": 1,
"열": "pv_w",
"서식": [
{},
{
"굵게": true,
"가로": "center",
"세로": "center",
"테두리": {
"위": {
"선": "thin"
},
"아래": {
"선": "thin"
},
"왼": {
"선": "thin"
},
"오른": {
"선": "thin"
}
}
}
],
"시트": [
{
"id": "s1",
"이름": "산출근거",
"칸": {
"A1": {
"값": "공종",
"서식": 1
},
"B1": {
"값": "규격",
"서식": 1
},
"C1": {
"값": "산출 내역",
"서식": 1
},
"D1": {
"값": "단위",
"서식": 1
},
"E1": {
"값": "수량(m당)",
"서식": 1
}
}
}
],
"활성": "s1"
}
+56
View File
@@ -0,0 +1,56 @@
{
"종류": "통합문서",
"판": 1,
"열": "rf",
"서식": [
{},
{
"굵게": true,
"가로": "center",
"세로": "center",
"테두리": {
"위": {
"선": "thin"
},
"아래": {
"선": "thin"
},
"왼": {
"선": "thin"
},
"오른": {
"선": "thin"
}
}
}
],
"시트": [
{
"id": "s1",
"이름": "산출근거",
"칸": {
"A1": {
"값": "공종",
"서식": 1
},
"B1": {
"값": "규격",
"서식": 1
},
"C1": {
"값": "산출 내역",
"서식": 1
},
"D1": {
"값": "단위",
"서식": 1
},
"E1": {
"값": "수량(m당)",
"서식": 1
}
}
}
],
"활성": "s1"
}
+56
View File
@@ -0,0 +1,56 @@
{
"종류": "통합문서",
"판": 1,
"열": "rv",
"서식": [
{},
{
"굵게": true,
"가로": "center",
"세로": "center",
"테두리": {
"위": {
"선": "thin"
},
"아래": {
"선": "thin"
},
"왼": {
"선": "thin"
},
"오른": {
"선": "thin"
}
}
}
],
"시트": [
{
"id": "s1",
"이름": "산출근거",
"칸": {
"A1": {
"값": "공종",
"서식": 1
},
"B1": {
"값": "규격",
"서식": 1
},
"C1": {
"값": "산출 내역",
"서식": 1
},
"D1": {
"값": "단위",
"서식": 1
},
"E1": {
"값": "수량(m당)",
"서식": 1
}
}
}
],
"활성": "s1"
}
+56
View File
@@ -0,0 +1,56 @@
{
"종류": "통합문서",
"판": 1,
"열": "rw",
"서식": [
{},
{
"굵게": true,
"가로": "center",
"세로": "center",
"테두리": {
"위": {
"선": "thin"
},
"아래": {
"선": "thin"
},
"왼": {
"선": "thin"
},
"오른": {
"선": "thin"
}
}
}
],
"시트": [
{
"id": "s1",
"이름": "산출근거",
"칸": {
"A1": {
"값": "공종",
"서식": 1
},
"B1": {
"값": "규격",
"서식": 1
},
"C1": {
"값": "산출 내역",
"서식": 1
},
"D1": {
"값": "단위",
"서식": 1
},
"E1": {
"값": "수량(m당)",
"서식": 1
}
}
}
],
"활성": "s1"
}
+56
View File
@@ -0,0 +1,56 @@
{
"종류": "통합문서",
"판": 1,
"열": "sg",
"서식": [
{},
{
"굵게": true,
"가로": "center",
"세로": "center",
"테두리": {
"위": {
"선": "thin"
},
"아래": {
"선": "thin"
},
"왼": {
"선": "thin"
},
"오른": {
"선": "thin"
}
}
}
],
"시트": [
{
"id": "s1",
"이름": "산출근거",
"칸": {
"A1": {
"값": "공종",
"서식": 1
},
"B1": {
"값": "규격",
"서식": 1
},
"C1": {
"값": "산출 내역",
"서식": 1
},
"D1": {
"값": "단위",
"서식": 1
},
"E1": {
"값": "수량(m당)",
"서식": 1
}
}
}
],
"활성": "s1"
}
+56
View File
@@ -0,0 +1,56 @@
{
"종류": "통합문서",
"판": 1,
"열": "sta",
"서식": [
{},
{
"굵게": true,
"가로": "center",
"세로": "center",
"테두리": {
"위": {
"선": "thin"
},
"아래": {
"선": "thin"
},
"왼": {
"선": "thin"
},
"오른": {
"선": "thin"
}
}
}
],
"시트": [
{
"id": "s1",
"이름": "산출근거",
"칸": {
"A1": {
"값": "공종",
"서식": 1
},
"B1": {
"값": "규격",
"서식": 1
},
"C1": {
"값": "산출 내역",
"서식": 1
},
"D1": {
"값": "단위",
"서식": 1
},
"E1": {
"값": "수량(m당)",
"서식": 1
}
}
}
],
"활성": "s1"
}
+56
View File
@@ -0,0 +1,56 @@
{
"종류": "통합문서",
"판": 1,
"열": "ta",
"서식": [
{},
{
"굵게": true,
"가로": "center",
"세로": "center",
"테두리": {
"위": {
"선": "thin"
},
"아래": {
"선": "thin"
},
"왼": {
"선": "thin"
},
"오른": {
"선": "thin"
}
}
}
],
"시트": [
{
"id": "s1",
"이름": "산출근거",
"칸": {
"A1": {
"값": "공종",
"서식": 1
},
"B1": {
"값": "규격",
"서식": 1
},
"C1": {
"값": "산출 내역",
"서식": 1
},
"D1": {
"값": "단위",
"서식": 1
},
"E1": {
"값": "수량(m당)",
"서식": 1
}
}
}
],
"활성": "s1"
}
+56
View File
@@ -0,0 +1,56 @@
{
"종류": "통합문서",
"판": 1,
"열": "wy",
"서식": [
{},
{
"굵게": true,
"가로": "center",
"세로": "center",
"테두리": {
"위": {
"선": "thin"
},
"아래": {
"선": "thin"
},
"왼": {
"선": "thin"
},
"오른": {
"선": "thin"
}
}
}
],
"시트": [
{
"id": "s1",
"이름": "산출근거",
"칸": {
"A1": {
"값": "공종",
"서식": 1
},
"B1": {
"값": "규격",
"서식": 1
},
"C1": {
"값": "산출 내역",
"서식": 1
},
"D1": {
"값": "단위",
"서식": 1
},
"E1": {
"값": "수량(m당)",
"서식": 1
}
}
}
],
"활성": "s1"
}
@@ -7,66 +7,5 @@
"도면": {
"format": 6,
"entities": []
},
"산출근거": {
"양식": "산출근거",
"종류": "표",
"판": 1,
"열": [
{
"id": "work",
"머리": [
"공종"
],
"단위": null,
"꼴": "글"
},
{
"id": "spec",
"머리": [
"규격"
],
"단위": null,
"꼴": "글"
},
{
"id": "detail",
"머리": [
"산출 내역"
],
"단위": null,
"꼴": "글"
},
{
"id": "unit",
"머리": [
"단위"
],
"단위": null,
"꼴": "글"
},
{
"id": "qty",
"머리": [
"수량(m당)"
],
"단위": null,
"꼴": "수"
}
],
"줄": [
{
"id": "1",
"값": {}
},
{
"id": "2",
"값": {}
},
{
"id": "3",
"값": {}
}
]
}
}
@@ -7,66 +7,5 @@
"도면": {
"format": 6,
"entities": []
},
"산출근거": {
"양식": "산출근거",
"종류": "표",
"판": 1,
"열": [
{
"id": "work",
"머리": [
"공종"
],
"단위": null,
"꼴": "글"
},
{
"id": "spec",
"머리": [
"규격"
],
"단위": null,
"꼴": "글"
},
{
"id": "detail",
"머리": [
"산출 내역"
],
"단위": null,
"꼴": "글"
},
{
"id": "unit",
"머리": [
"단위"
],
"단위": null,
"꼴": "글"
},
{
"id": "qty",
"머리": [
"수량(m당)"
],
"단위": null,
"꼴": "수"
}
],
"줄": [
{
"id": "1",
"값": {}
},
{
"id": "2",
"값": {}
},
{
"id": "3",
"값": {}
}
]
}
}
@@ -7,66 +7,5 @@
"도면": {
"format": 6,
"entities": []
},
"산출근거": {
"양식": "산출근거",
"종류": "표",
"판": 1,
"열": [
{
"id": "work",
"머리": [
"공종"
],
"단위": null,
"꼴": "글"
},
{
"id": "spec",
"머리": [
"규격"
],
"단위": null,
"꼴": "글"
},
{
"id": "detail",
"머리": [
"산출 내역"
],
"단위": null,
"꼴": "글"
},
{
"id": "unit",
"머리": [
"단위"
],
"단위": null,
"꼴": "글"
},
{
"id": "qty",
"머리": [
"수량(m당)"
],
"단위": null,
"꼴": "수"
}
],
"줄": [
{
"id": "1",
"값": {}
},
{
"id": "2",
"값": {}
},
{
"id": "3",
"값": {}
}
]
}
}
@@ -7,66 +7,5 @@
"도면": {
"format": 6,
"entities": []
},
"산출근거": {
"양식": "산출근거",
"종류": "표",
"판": 1,
"열": [
{
"id": "work",
"머리": [
"공종"
],
"단위": null,
"꼴": "글"
},
{
"id": "spec",
"머리": [
"규격"
],
"단위": null,
"꼴": "글"
},
{
"id": "detail",
"머리": [
"산출 내역"
],
"단위": null,
"꼴": "글"
},
{
"id": "unit",
"머리": [
"단위"
],
"단위": null,
"꼴": "글"
},
{
"id": "qty",
"머리": [
"수량(m당)"
],
"단위": null,
"꼴": "수"
}
],
"줄": [
{
"id": "1",
"값": {}
},
{
"id": "2",
"값": {}
},
{
"id": "3",
"값": {}
}
]
}
}
@@ -7,66 +7,5 @@
"도면": {
"format": 6,
"entities": []
},
"산출근거": {
"양식": "산출근거",
"종류": "표",
"판": 1,
"열": [
{
"id": "work",
"머리": [
"공종"
],
"단위": null,
"꼴": "글"
},
{
"id": "spec",
"머리": [
"규격"
],
"단위": null,
"꼴": "글"
},
{
"id": "detail",
"머리": [
"산출 내역"
],
"단위": null,
"꼴": "글"
},
{
"id": "unit",
"머리": [
"단위"
],
"단위": null,
"꼴": "글"
},
{
"id": "qty",
"머리": [
"수량(m당)"
],
"단위": null,
"꼴": "수"
}
],
"줄": [
{
"id": "1",
"값": {}
},
{
"id": "2",
"값": {}
},
{
"id": "3",
"값": {}
}
]
}
}
@@ -7,66 +7,5 @@
"도면": {
"format": 6,
"entities": []
},
"산출근거": {
"양식": "산출근거",
"종류": "표",
"판": 1,
"열": [
{
"id": "work",
"머리": [
"공종"
],
"단위": null,
"꼴": "글"
},
{
"id": "spec",
"머리": [
"규격"
],
"단위": null,
"꼴": "글"
},
{
"id": "detail",
"머리": [
"산출 내역"
],
"단위": null,
"꼴": "글"
},
{
"id": "unit",
"머리": [
"단위"
],
"단위": null,
"꼴": "글"
},
{
"id": "qty",
"머리": [
"수량(m당)"
],
"단위": null,
"꼴": "수"
}
],
"줄": [
{
"id": "1",
"값": {}
},
{
"id": "2",
"값": {}
},
{
"id": "3",
"값": {}
}
]
}
}
@@ -7,66 +7,5 @@
"도면": {
"format": 6,
"entities": []
},
"산출근거": {
"양식": "산출근거",
"종류": "표",
"판": 1,
"열": [
{
"id": "work",
"머리": [
"공종"
],
"단위": null,
"꼴": "글"
},
{
"id": "spec",
"머리": [
"규격"
],
"단위": null,
"꼴": "글"
},
{
"id": "detail",
"머리": [
"산출 내역"
],
"단위": null,
"꼴": "글"
},
{
"id": "unit",
"머리": [
"단위"
],
"단위": null,
"꼴": "글"
},
{
"id": "qty",
"머리": [
"수량(m당)"
],
"단위": null,
"꼴": "수"
}
],
"줄": [
{
"id": "1",
"값": {}
},
{
"id": "2",
"값": {}
},
{
"id": "3",
"값": {}
}
]
}
}
@@ -7,66 +7,5 @@
"도면": {
"format": 6,
"entities": []
},
"산출근거": {
"양식": "산출근거",
"종류": "표",
"판": 1,
"열": [
{
"id": "work",
"머리": [
"공종"
],
"단위": null,
"꼴": "글"
},
{
"id": "spec",
"머리": [
"규격"
],
"단위": null,
"꼴": "글"
},
{
"id": "detail",
"머리": [
"산출 내역"
],
"단위": null,
"꼴": "글"
},
{
"id": "unit",
"머리": [
"단위"
],
"단위": null,
"꼴": "글"
},
{
"id": "qty",
"머리": [
"수량(m당)"
],
"단위": null,
"꼴": "수"
}
],
"줄": [
{
"id": "1",
"값": {}
},
{
"id": "2",
"값": {}
},
{
"id": "3",
"값": {}
}
]
}
}
@@ -7,66 +7,5 @@
"도면": {
"format": 6,
"entities": []
},
"산출근거": {
"양식": "산출근거",
"종류": "표",
"판": 1,
"열": [
{
"id": "work",
"머리": [
"공종"
],
"단위": null,
"꼴": "글"
},
{
"id": "spec",
"머리": [
"규격"
],
"단위": null,
"꼴": "글"
},
{
"id": "detail",
"머리": [
"산출 내역"
],
"단위": null,
"꼴": "글"
},
{
"id": "unit",
"머리": [
"단위"
],
"단위": null,
"꼴": "글"
},
{
"id": "qty",
"머리": [
"수량(m당)"
],
"단위": null,
"꼴": "수"
}
],
"줄": [
{
"id": "1",
"값": {}
},
{
"id": "2",
"값": {}
},
{
"id": "3",
"값": {}
}
]
}
}
@@ -7,66 +7,5 @@
"도면": {
"format": 6,
"entities": []
},
"산출근거": {
"양식": "산출근거",
"종류": "표",
"판": 1,
"열": [
{
"id": "work",
"머리": [
"공종"
],
"단위": null,
"꼴": "글"
},
{
"id": "spec",
"머리": [
"규격"
],
"단위": null,
"꼴": "글"
},
{
"id": "detail",
"머리": [
"산출 내역"
],
"단위": null,
"꼴": "글"
},
{
"id": "unit",
"머리": [
"단위"
],
"단위": null,
"꼴": "글"
},
{
"id": "qty",
"머리": [
"수량(m당)"
],
"단위": null,
"꼴": "수"
}
],
"줄": [
{
"id": "1",
"값": {}
},
{
"id": "2",
"값": {}
},
{
"id": "3",
"값": {}
}
]
}
}
@@ -7,66 +7,5 @@
"도면": {
"format": 6,
"entities": []
},
"산출근거": {
"양식": "산출근거",
"종류": "표",
"판": 1,
"열": [
{
"id": "work",
"머리": [
"공종"
],
"단위": null,
"꼴": "글"
},
{
"id": "spec",
"머리": [
"규격"
],
"단위": null,
"꼴": "글"
},
{
"id": "detail",
"머리": [
"산출 내역"
],
"단위": null,
"꼴": "글"
},
{
"id": "unit",
"머리": [
"단위"
],
"단위": null,
"꼴": "글"
},
{
"id": "qty",
"머리": [
"수량(m당)"
],
"단위": null,
"꼴": "수"
}
],
"줄": [
{
"id": "1",
"값": {}
},
{
"id": "2",
"값": {}
},
{
"id": "3",
"값": {}
}
]
}
}
@@ -7,66 +7,5 @@
"도면": {
"format": 6,
"entities": []
},
"산출근거": {
"양식": "산출근거",
"종류": "표",
"판": 1,
"열": [
{
"id": "work",
"머리": [
"공종"
],
"단위": null,
"꼴": "글"
},
{
"id": "spec",
"머리": [
"규격"
],
"단위": null,
"꼴": "글"
},
{
"id": "detail",
"머리": [
"산출 내역"
],
"단위": null,
"꼴": "글"
},
{
"id": "unit",
"머리": [
"단위"
],
"단위": null,
"꼴": "글"
},
{
"id": "qty",
"머리": [
"수량(m당)"
],
"단위": null,
"꼴": "수"
}
],
"줄": [
{
"id": "1",
"값": {}
},
{
"id": "2",
"값": {}
},
{
"id": "3",
"값": {}
}
]
}
}
@@ -7,66 +7,5 @@
"도면": {
"format": 6,
"entities": []
},
"산출근거": {
"양식": "산출근거",
"종류": "표",
"판": 1,
"열": [
{
"id": "work",
"머리": [
"공종"
],
"단위": null,
"꼴": "글"
},
{
"id": "spec",
"머리": [
"규격"
],
"단위": null,
"꼴": "글"
},
{
"id": "detail",
"머리": [
"산출 내역"
],
"단위": null,
"꼴": "글"
},
{
"id": "unit",
"머리": [
"단위"
],
"단위": null,
"꼴": "글"
},
{
"id": "qty",
"머리": [
"수량(m당)"
],
"단위": null,
"꼴": "수"
}
],
"줄": [
{
"id": "1",
"값": {}
},
{
"id": "2",
"값": {}
},
{
"id": "3",
"값": {}
}
]
}
}
@@ -7,66 +7,5 @@
"도면": {
"format": 6,
"entities": []
},
"산출근거": {
"양식": "산출근거",
"종류": "표",
"판": 1,
"열": [
{
"id": "work",
"머리": [
"공종"
],
"단위": null,
"꼴": "글"
},
{
"id": "spec",
"머리": [
"규격"
],
"단위": null,
"꼴": "글"
},
{
"id": "detail",
"머리": [
"산출 내역"
],
"단위": null,
"꼴": "글"
},
{
"id": "unit",
"머리": [
"단위"
],
"단위": null,
"꼴": "글"
},
{
"id": "qty",
"머리": [
"수량(m당)"
],
"단위": null,
"꼴": "수"
}
],
"줄": [
{
"id": "1",
"값": {}
},
{
"id": "2",
"값": {}
},
{
"id": "3",
"값": {}
}
]
}
}
@@ -7,66 +7,5 @@
"도면": {
"format": 6,
"entities": []
},
"산출근거": {
"양식": "산출근거",
"종류": "표",
"판": 1,
"열": [
{
"id": "work",
"머리": [
"공종"
],
"단위": null,
"꼴": "글"
},
{
"id": "spec",
"머리": [
"규격"
],
"단위": null,
"꼴": "글"
},
{
"id": "detail",
"머리": [
"산출 내역"
],
"단위": null,
"꼴": "글"
},
{
"id": "unit",
"머리": [
"단위"
],
"단위": null,
"꼴": "글"
},
{
"id": "qty",
"머리": [
"수량(m당)"
],
"단위": null,
"꼴": "수"
}
],
"줄": [
{
"id": "1",
"값": {}
},
{
"id": "2",
"값": {}
},
{
"id": "3",
"값": {}
}
]
}
}
@@ -7,66 +7,5 @@
"도면": {
"format": 6,
"entities": []
},
"산출근거": {
"양식": "산출근거",
"종류": "표",
"판": 1,
"열": [
{
"id": "work",
"머리": [
"공종"
],
"단위": null,
"꼴": "글"
},
{
"id": "spec",
"머리": [
"규격"
],
"단위": null,
"꼴": "글"
},
{
"id": "detail",
"머리": [
"산출 내역"
],
"단위": null,
"꼴": "글"
},
{
"id": "unit",
"머리": [
"단위"
],
"단위": null,
"꼴": "글"
},
{
"id": "qty",
"머리": [
"수량(m당)"
],
"단위": null,
"꼴": "수"
}
],
"줄": [
{
"id": "1",
"값": {}
},
{
"id": "2",
"값": {}
},
{
"id": "3",
"값": {}
}
]
}
}
@@ -7,66 +7,5 @@
"도면": {
"format": 6,
"entities": []
},
"산출근거": {
"양식": "산출근거",
"종류": "표",
"판": 1,
"열": [
{
"id": "work",
"머리": [
"공종"
],
"단위": null,
"꼴": "글"
},
{
"id": "spec",
"머리": [
"규격"
],
"단위": null,
"꼴": "글"
},
{
"id": "detail",
"머리": [
"산출 내역"
],
"단위": null,
"꼴": "글"
},
{
"id": "unit",
"머리": [
"단위"
],
"단위": null,
"꼴": "글"
},
{
"id": "qty",
"머리": [
"수량(m당)"
],
"단위": null,
"꼴": "수"
}
],
"줄": [
{
"id": "1",
"값": {}
},
{
"id": "2",
"값": {}
},
{
"id": "3",
"값": {}
}
]
}
}
@@ -7,66 +7,5 @@
"도면": {
"format": 6,
"entities": []
},
"산출근거": {
"양식": "산출근거",
"종류": "표",
"판": 1,
"열": [
{
"id": "work",
"머리": [
"공종"
],
"단위": null,
"꼴": "글"
},
{
"id": "spec",
"머리": [
"규격"
],
"단위": null,
"꼴": "글"
},
{
"id": "detail",
"머리": [
"산출 내역"
],
"단위": null,
"꼴": "글"
},
{
"id": "unit",
"머리": [
"단위"
],
"단위": null,
"꼴": "글"
},
{
"id": "qty",
"머리": [
"수량(m당)"
],
"단위": null,
"꼴": "수"
}
],
"줄": [
{
"id": "1",
"값": {}
},
{
"id": "2",
"값": {}
},
{
"id": "3",
"값": {}
}
]
}
}
@@ -7,66 +7,5 @@
"도면": {
"format": 6,
"entities": []
},
"산출근거": {
"양식": "산출근거",
"종류": "표",
"판": 1,
"열": [
{
"id": "work",
"머리": [
"공종"
],
"단위": null,
"꼴": "글"
},
{
"id": "spec",
"머리": [
"규격"
],
"단위": null,
"꼴": "글"
},
{
"id": "detail",
"머리": [
"산출 내역"
],
"단위": null,
"꼴": "글"
},
{
"id": "unit",
"머리": [
"단위"
],
"단위": null,
"꼴": "글"
},
{
"id": "qty",
"머리": [
"수량(m당)"
],
"단위": null,
"꼴": "수"
}
],
"줄": [
{
"id": "1",
"값": {}
},
{
"id": "2",
"값": {}
},
{
"id": "3",
"값": {}
}
]
}
}
@@ -7,66 +7,5 @@
"도면": {
"format": 6,
"entities": []
},
"산출근거": {
"양식": "산출근거",
"종류": "표",
"판": 1,
"열": [
{
"id": "work",
"머리": [
"공종"
],
"단위": null,
"꼴": "글"
},
{
"id": "spec",
"머리": [
"규격"
],
"단위": null,
"꼴": "글"
},
{
"id": "detail",
"머리": [
"산출 내역"
],
"단위": null,
"꼴": "글"
},
{
"id": "unit",
"머리": [
"단위"
],
"단위": null,
"꼴": "글"
},
{
"id": "qty",
"머리": [
"수량(m당)"
],
"단위": null,
"꼴": "수"
}
],
"줄": [
{
"id": "1",
"값": {}
},
{
"id": "2",
"값": {}
},
{
"id": "3",
"값": {}
}
]
}
}
@@ -7,66 +7,5 @@
"도면": {
"format": 6,
"entities": []
},
"산출근거": {
"양식": "산출근거",
"종류": "표",
"판": 1,
"열": [
{
"id": "work",
"머리": [
"공종"
],
"단위": null,
"꼴": "글"
},
{
"id": "spec",
"머리": [
"규격"
],
"단위": null,
"꼴": "글"
},
{
"id": "detail",
"머리": [
"산출 내역"
],
"단위": null,
"꼴": "글"
},
{
"id": "unit",
"머리": [
"단위"
],
"단위": null,
"꼴": "글"
},
{
"id": "qty",
"머리": [
"수량(m당)"
],
"단위": null,
"꼴": "수"
}
],
"줄": [
{
"id": "1",
"값": {}
},
{
"id": "2",
"값": {}
},
{
"id": "3",
"값": {}
}
]
}
}
@@ -7,66 +7,5 @@
"도면": {
"format": 6,
"entities": []
},
"산출근거": {
"양식": "산출근거",
"종류": "표",
"판": 1,
"열": [
{
"id": "work",
"머리": [
"공종"
],
"단위": null,
"꼴": "글"
},
{
"id": "spec",
"머리": [
"규격"
],
"단위": null,
"꼴": "글"
},
{
"id": "detail",
"머리": [
"산출 내역"
],
"단위": null,
"꼴": "글"
},
{
"id": "unit",
"머리": [
"단위"
],
"단위": null,
"꼴": "글"
},
{
"id": "qty",
"머리": [
"수량(m당)"
],
"단위": null,
"꼴": "수"
}
],
"줄": [
{
"id": "1",
"값": {}
},
{
"id": "2",
"값": {}
},
{
"id": "3",
"값": {}
}
]
}
}
@@ -7,66 +7,5 @@
"도면": {
"format": 6,
"entities": []
},
"산출근거": {
"양식": "산출근거",
"종류": "표",
"판": 1,
"열": [
{
"id": "work",
"머리": [
"공종"
],
"단위": null,
"꼴": "글"
},
{
"id": "spec",
"머리": [
"규격"
],
"단위": null,
"꼴": "글"
},
{
"id": "detail",
"머리": [
"산출 내역"
],
"단위": null,
"꼴": "글"
},
{
"id": "unit",
"머리": [
"단위"
],
"단위": null,
"꼴": "글"
},
{
"id": "qty",
"머리": [
"수량(m당)"
],
"단위": null,
"꼴": "수"
}
],
"줄": [
{
"id": "1",
"값": {}
},
{
"id": "2",
"값": {}
},
{
"id": "3",
"값": {}
}
]
}
}
@@ -7,66 +7,5 @@
"도면": {
"format": 6,
"entities": []
},
"산출근거": {
"양식": "산출근거",
"종류": "표",
"판": 1,
"열": [
{
"id": "work",
"머리": [
"공종"
],
"단위": null,
"꼴": "글"
},
{
"id": "spec",
"머리": [
"규격"
],
"단위": null,
"꼴": "글"
},
{
"id": "detail",
"머리": [
"산출 내역"
],
"단위": null,
"꼴": "글"
},
{
"id": "unit",
"머리": [
"단위"
],
"단위": null,
"꼴": "글"
},
{
"id": "qty",
"머리": [
"수량(m당)"
],
"단위": null,
"꼴": "수"
}
],
"줄": [
{
"id": "1",
"값": {}
},
{
"id": "2",
"값": {}
},
{
"id": "3",
"값": {}
}
]
}
}
@@ -7,66 +7,5 @@
"도면": {
"format": 6,
"entities": []
},
"산출근거": {
"양식": "산출근거",
"종류": "표",
"판": 1,
"열": [
{
"id": "work",
"머리": [
"공종"
],
"단위": null,
"꼴": "글"
},
{
"id": "spec",
"머리": [
"규격"
],
"단위": null,
"꼴": "글"
},
{
"id": "detail",
"머리": [
"산출 내역"
],
"단위": null,
"꼴": "글"
},
{
"id": "unit",
"머리": [
"단위"
],
"단위": null,
"꼴": "글"
},
{
"id": "qty",
"머리": [
"수량(m당)"
],
"단위": null,
"꼴": "수"
}
],
"줄": [
{
"id": "1",
"값": {}
},
{
"id": "2",
"값": {}
},
{
"id": "3",
"값": {}
}
]
}
}
@@ -7,66 +7,5 @@
"도면": {
"format": 6,
"entities": []
},
"산출근거": {
"양식": "산출근거",
"종류": "표",
"판": 1,
"열": [
{
"id": "work",
"머리": [
"공종"
],
"단위": null,
"꼴": "글"
},
{
"id": "spec",
"머리": [
"규격"
],
"단위": null,
"꼴": "글"
},
{
"id": "detail",
"머리": [
"산출 내역"
],
"단위": null,
"꼴": "글"
},
{
"id": "unit",
"머리": [
"단위"
],
"단위": null,
"꼴": "글"
},
{
"id": "qty",
"머리": [
"수량(m당)"
],
"단위": null,
"꼴": "수"
}
],
"줄": [
{
"id": "1",
"값": {}
},
{
"id": "2",
"값": {}
},
{
"id": "3",
"값": {}
}
]
}
}
@@ -7,66 +7,5 @@
"도면": {
"format": 6,
"entities": []
},
"산출근거": {
"양식": "산출근거",
"종류": "표",
"판": 1,
"열": [
{
"id": "work",
"머리": [
"공종"
],
"단위": null,
"꼴": "글"
},
{
"id": "spec",
"머리": [
"규격"
],
"단위": null,
"꼴": "글"
},
{
"id": "detail",
"머리": [
"산출 내역"
],
"단위": null,
"꼴": "글"
},
{
"id": "unit",
"머리": [
"단위"
],
"단위": null,
"꼴": "글"
},
{
"id": "qty",
"머리": [
"수량(m당)"
],
"단위": null,
"꼴": "수"
}
],
"줄": [
{
"id": "1",
"값": {}
},
{
"id": "2",
"값": {}
},
{
"id": "3",
"값": {}
}
]
}
}
@@ -7,66 +7,5 @@
"도면": {
"format": 6,
"entities": []
},
"산출근거": {
"양식": "산출근거",
"종류": "표",
"판": 1,
"열": [
{
"id": "work",
"머리": [
"공종"
],
"단위": null,
"꼴": "글"
},
{
"id": "spec",
"머리": [
"규격"
],
"단위": null,
"꼴": "글"
},
{
"id": "detail",
"머리": [
"산출 내역"
],
"단위": null,
"꼴": "글"
},
{
"id": "unit",
"머리": [
"단위"
],
"단위": null,
"꼴": "글"
},
{
"id": "qty",
"머리": [
"수량(m당)"
],
"단위": null,
"꼴": "수"
}
],
"줄": [
{
"id": "1",
"값": {}
},
{
"id": "2",
"값": {}
},
{
"id": "3",
"값": {}
}
]
}
}
@@ -7,66 +7,5 @@
"도면": {
"format": 6,
"entities": []
},
"산출근거": {
"양식": "산출근거",
"종류": "표",
"판": 1,
"열": [
{
"id": "work",
"머리": [
"공종"
],
"단위": null,
"꼴": "글"
},
{
"id": "spec",
"머리": [
"규격"
],
"단위": null,
"꼴": "글"
},
{
"id": "detail",
"머리": [
"산출 내역"
],
"단위": null,
"꼴": "글"
},
{
"id": "unit",
"머리": [
"단위"
],
"단위": null,
"꼴": "글"
},
{
"id": "qty",
"머리": [
"수량(m당)"
],
"단위": null,
"꼴": "수"
}
],
"줄": [
{
"id": "1",
"값": {}
},
{
"id": "2",
"값": {}
},
{
"id": "3",
"값": {}
}
]
}
}
@@ -7,66 +7,5 @@
"도면": {
"format": 6,
"entities": []
},
"산출근거": {
"양식": "산출근거",
"종류": "표",
"판": 1,
"열": [
{
"id": "work",
"머리": [
"공종"
],
"단위": null,
"꼴": "글"
},
{
"id": "spec",
"머리": [
"규격"
],
"단위": null,
"꼴": "글"
},
{
"id": "detail",
"머리": [
"산출 내역"
],
"단위": null,
"꼴": "글"
},
{
"id": "unit",
"머리": [
"단위"
],
"단위": null,
"꼴": "글"
},
{
"id": "qty",
"머리": [
"수량(m당)"
],
"단위": null,
"꼴": "수"
}
],
"줄": [
{
"id": "1",
"값": {}
},
{
"id": "2",
"값": {}
},
{
"id": "3",
"값": {}
}
]
}
}

Some files were not shown because too many files have changed in this diff Show More