Files
Aislo/M02_MasterTemplete/logic/M02_Sangeun_Cover_Model.ts
T
eomsangdonandClaude Opus 5.5 08ad680188 feat(728-2): SK 단위 환산 — 기초 기준단위 제안 · 바꾸면 배 · 계수 칸 · 둘째 기초 수량 처음값 · 돈 아닌 기초 뺌
- 서버 Sangeun_Calc: 배 = 바탕 기준단위 → SK 단위(master_unit.swap) · 성격 다름 = 문서 환산 {계수 | 단위중량} · 없으면 셈 · 저장 막음(까닭 글) · 「식」 · 빈 단위 = 옛 셈 그대로(일위대가 줄 모드)
- 화면 셈 답 줄 · 계 = SK 1 단위 몫(per_unit · 화면배) · 머리 띠 「100본 당」 · 「1 본 당」
- 서버 Sangeun_Doc: 환산 칸 검사(계수 양수 · 단위중량 종별 · 형상)
- B09 Labor_Source: SK 참조 배 = 같은 ratio_of(옛 바탕 앞 수 대신 · 기존 문서 같은 값)
- 화면 Sangeun_Detail: SK 단위 처음 값 = 기초 기준단위(「100본」) · 둘째 기초 수량 처음값 = 바탕 기준단위 ÷ 그 기초 기준단위(swapUnit) · 계 칸 원/SK 단위
- 새 Sangeun_Unit: 근거 글 · 환산 계수 칸 · 단위중량 표(CC000337) 고르기
- Cover_Model: 화면 품 × 화면배 = 문서 품(덮개 · 더한줄 · 세부) · 율 그대로
- Find_Pick: 돈 아닌 기초(결과단위 「원」 아님 21 개) 목록에서 뺌
- 낡은 시험 test_715_sangeun_engine(바탕 원/주 + SK ㎡) · test_727(바탕 원/㎡ + SK ㎥) = 성격 다른 단위가 이제 계수 없으면 막혀 SK 단위를 바탕 단위로 고침(금액 같음)
- 새 시험 test_728_2_sk_unit(100본 → 본 1,538.89 · ㎡ 44,791.32 · 계수 · 단위중량 · 식 · 둘째 기초 · 돈 아닌 기초 · 고침 꼴)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 10:52:05 +09:00

645 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =============================================================================
* M02_MasterTemplete/logic/M02_Sangeun_Cover_Model.ts
* 단가산출(SK) 고침 꼴(PLAN 717-2b · 717-3 · 724-1 — SK 상세 · 일위대가 줄 모드 같이 씀) —
* 서버 셈 답(`/layers/{층}/sangeun/calc`) → 공용 상세(`M01_MasterData_UI_LogicLab_Ho`) 줄 · 계산서 줄 ·
* 고친 칸(`LineCover` · 호표 차례 열쇠) → SK 문서 덮개 · 더한줄 · 뺀줄(꼴 = 서버 `M02_Sangeun_Doc`).
* 금액은 늘 서버가 셈 — 여기는 줄 모양 · 문서 칸 옮김만.
* 줄 정체 cid = `kind:키#n`(바탕 줄) · `add:#k`(더한줄 k 째) · 덮개 원값 = 저장 때 서버가 다시 적음
* (화면은 고친 첫때 바탕 값을 적어 둠 — 곁 글용) · 열린입력 있는 SK = 품은 배율만
* 일위대가 줄 → 이름 없는 SK 문서 = `lineDoc`(서버 `M02_Labor_Patch.as_doc` 꼴)
* 둘째 기초(727-2) = 더한줄 로직 줄을 서버 `펼침`(세부 줄)으로 바꿔 놓음 · 열쇠 `add:#k/세부 cid` ·
* 고침 = 그 더한줄의 `덮개` · `뺀줄`(품 = 화면 품 ÷ 수량 · 1 기준단위 몫) · 꼬리표 = 기초 둘 이상일 때만
* SK 단위 환산(728-2) = 서버가 화면 줄을 SK 1 단위 몫으로(`화면배`) — 문서 품 = 화면 품 × 화면배(율 빼고)
* 화면 · fetch · sessionStorage 없음(`CLAUDE.md` 4장).
* ========================================================================== */
import type { SheetGroup } from "../../A00_Common/calc_sheet";
export type CoverPart = "labor" | "material" | "expense";
export const PART_OF: Record<string, CoverPart> = {
노무비: "labor",
재료비: "material",
경비: "expense",
};
const PARTS: CoverPart[] = ["labor", "material", "expense"];
export interface SkCover {
qty?: number;
배율?: number;
labor?: number;
material?: number;
expense?: number;
원값?: { qty?: number; 단가?: number };
}
export interface SkAdded {
종류?: string;
수량: number;
단가?: number;
/** 둘째 기초 세부 고침(727-2) — 열쇠 = 세부 cid */
덮개?: Record<string, SkCover>;
뺀줄?: string[];
바탕판?: string;
[k: string]: unknown;
}
/** SK 문서 중 고침이 만지는 칸 */
export interface SkDoc {
키?: string;
이름?: string;
단위?: string;
바탕?: { 로직?: string; 참조?: { 층?: string; 주인?: string | null; 키?: string } } | null;
입력값?: Record<string, unknown>;
열린입력?: string[];
덮개?: Record<string, SkCover>;
더한줄?: SkAdded[];
뺀줄?: string[];
고름?: Record<string, string>;
자재표시?: Record<string, unknown>;
바탕판?: string;
[k: string]: unknown;
}
export interface SkLine {
이름: string;
단위?: string;
수량: number | null;
단가: number | null;
금액: number | null;
비목?: Record<string, number | null>;
묶음?: string | null;
cid: string;
율?: number;
/** 못 셈 줄(725) — 금액 없음 · 계에 안 듦 */
까닭?: string;
/** 둘째 기초(727-2) — 세부 줄 · 뺀 세부 · 그 기초 지문 · 원문번호 */
펼침?: SkLine[];
뺀세부?: { cid: string; 이름: string }[];
바탕판?: string;
꼬리표?: string;
/** 세부 줄 — 덮기 전 1 기준단위 품 · 단가 */
바탕값?: { qty?: number; 단가?: number } | null;
}
export interface SkMat {
차례: number;
이름: string;
단위?: string;
수량: number | null;
단가: number | null;
금액: number | null;
}
export interface SkAnswer {
기준: Record<string, number> | null;
줄: SkLine[];
재료?: SkMat[];
까닭?: string | null;
/** 못 셈 줄(725 · 입력 없음 · 수량 없음) — 그 줄만 빠지고 나머지 셈 */
빠진줄?: { cid: string; 이름: string; 까닭: string }[];
정체: string[];
짝잃음: { cid: string; 까닭: string; 바탕?: string }[];
바탕판: string;
머리?: string;
/** 바탕 원문번호(참조 바탕 = 그 키) */
꼬리표?: string | null;
/** 바탕 기준단위 → SK 단위 배(728-2) · 화면 줄을 나눈 배(없으면 1 · 문서 품 = 화면 품 × 화면배) */
배?: number;
화면배?: number;
}
export interface CoverRow {
cid: string;
묶음: string;
이름: string;
단위: string;
수량: number | null;
단가: number | null;
금액: number | null;
/** 비목 몫 금액 */
parts: Record<string, number>;
/** % 덧줄 — 수량 = 율 · 단가 못 고침 */
pct: boolean;
/** 더한줄 차례 · 바탕 줄 = null */
added: number | null;
/** 기준 밖 재료(일위대가 자재목록으로 셈) — 금액이 계에 안 듦 */
apart: boolean;
covered: { qty: boolean; price: boolean };
/** 덮개 배율(열린입력 SK) */
배율: number | null;
/** 바탕 값(덮개 · 더한줄 · 뺀줄 없이 셈) · 없으면 null */
was: { qty: number | null; 단가: number | null } | null;
/** 저장 때 적힌 원값이 지금 바탕 값과 다름(M01 개정) */
revised: boolean;
priceEdit: boolean;
/** 더한 재료 줄 — 별도계상 고름(기본 계 안) */
apartPick: boolean;
/** 둘째 기초 세부 줄 — 더한줄 차례 · 세부 cid · 수량(배) · 바탕 줄 · 그 밖 = null */
sub: { k: number; cid: string; scale: number } | null;
/** 화면배(728-2) — 문서 품 = 화면 품 × t · `was` = 문서 몫 */
t: number;
}
/* ── 순수 셈 ── */
const BUNDLE_OF: Record<string, string> = { 노무비: "인력", 재료비: "재료", 경비: "경비" };
const ADD_RE = /^add:#(\d+)$/;
const SUB_RE = /^add:#(\d+)\/(.+)$/;
/** 세부 열쇠 `add:#k/세부 cid` → {k, cid} */
export function subOf(key: string): { k: number; cid: string } | null {
const m = SUB_RE.exec(key);
return m ? { k: Number(m[1]), cid: m[2] } : null;
}
/** 셈 답 줄 — 둘째 기초 줄은 세부 줄로 바꿔 놓음(정체도 같은 차례) */
export function flatLines(ans: SkAnswer): SkLine[] {
return ans.줄.flatMap((l) => l.펼침 ?? [l]);
}
function flatIds(ans: SkAnswer): string[] {
const by = new Map(ans.줄.map((l) => [l.cid, l]));
return ans.정체.flatMap((c) => by.get(c)?.펼침?.map((x) => x.cid) ?? [c]);
}
/** 기초 수 — 로직 바탕 · 더한줄 로직 */
const baseCount = (doc: SkDoc): number =>
(doc.바탕?.로직 ? 1 : 0) + (doc.더한줄 ?? []).filter((x) => x.종류 === "로직").length;
/** 줄 꼬리표(기초 둘 이상일 때만) — 바탕 줄 = 바탕 원문번호 · 세부 줄 = 그 기초 원문번호 · 그 밖 = "" */
export function tagsOf(ans: SkAnswer, doc: SkDoc): Map<string, string> {
const out = new Map<string, string>();
if (baseCount(doc) < 2) return out;
for (const l of ans.줄) {
if (l.펼침) for (const x of l.펼침) out.set(x.cid, l.꼬리표 ?? "");
else if (!ADD_RE.test(l.cid) && ans.꼬리표) out.set(l.cid, ans.꼬리표);
}
return out;
}
/** 뺀 세부 줄(되살림 띠) */
export const removedSubs = (ans: SkAnswer | null): { cid: string; 이름: string }[] =>
(ans?.줄 ?? []).flatMap((l) => l.뺀세부 ?? []);
/** M01 개정된 둘째 기초 — 저장 때 지문 ≠ 지금 지문 */
export function revisedAdds(ans: SkAnswer | null, doc: SkDoc): number[] {
return (ans?.줄 ?? []).flatMap((l) => {
const hit = ADD_RE.exec(l.cid);
const one = hit ? doc.더한줄?.[Number(hit[1])] : undefined;
return one?.바탕판 && l.바탕판 && one.바탕판 !== l.바탕판 ? [Number(hit![1])] : [];
});
}
const COVER_KEYS = ["qty", "배율", ...PARTS] as const;
const partsOf = (l: { 비목?: Record<string, number | null> }): Record<string, number> =>
Object.fromEntries(Object.entries(l.비목 ?? {}).map(([k, v]) => [k, v ?? 0]));
/** 바탕 값 — 바탕 셈(덮개 · 더한줄 · 뺀줄 없이)의 줄마다 품(덧줄 = 율) · 단가 */
export function baseValues(
bare: SkAnswer | null,
): Map<string, { qty: number | null; 단가: number | null }> {
const out = new Map<string, { qty: number | null; 단가: number | null }>();
if (!bare) return out;
for (const l of bare.줄) out.set(l.cid, { qty: l.율 ?? l.수량, 단가: l.단가 });
for (const m of bare.재료 ?? []) {
const cid = bare.정체[m.차례];
if (cid && !out.has(cid)) out.set(cid, { qty: m.수량, 단가: m.단가 });
}
return out;
}
const near = (a: number | null | undefined, b: number | null | undefined): boolean =>
a != null && b != null && Math.abs(a - b) <= 1e-9 * Math.max(1, Math.abs(a), Math.abs(b));
/** 셈 답 → 표 줄(엔진 줄 + 기준 밖 재료) */
export function rowsOf(ans: SkAnswer, bare: SkAnswer | null, doc: SkDoc): CoverRow[] {
const was = baseValues(bare);
const t = ans.화면배 ?? 1;
const rows: CoverRow[] = [];
const row = (
cid: string,
l: {
이름: string;
단위?: string;
수량: number | null;
단가: number | null;
금액: number | null;
},
parts: Record<string, number>,
extra: { 묶음?: string | null; 율?: number; apart?: boolean; 바탕값?: SkLine["바탕값"] },
): CoverRow => {
const hit = ADD_RE.exec(cid);
const added = hit ? Number(hit[1]) : null;
const at = subOf(cid);
const one = added === null ? undefined : doc.더한줄?.[added];
const owner = at ? doc.더한줄?.[at.k] : undefined;
const cover = at ? owner?.덮개?.[at.cid] : added === null ? doc.덮개?.[cid] : undefined;
const pct = (at?.cid ?? cid).startsWith("pct:");
const keys = Object.keys(parts);
const shown = added === null && !at ? (was.get(cid) ?? null) : null;
const base = at
? subWas(extra.바탕값)
: shown && !pct && shown.qty != null
? { ...shown, qty: shown.qty * t }
: shown;
const old = cover?.원값;
return {
cid,
묶음: extra.묶음 || BUNDLE_OF[keys[0] ?? ""] || "하위",
이름: l.이름,
단위: pct ? "%" : (l.단위 ?? ""),
수량: pct ? (extra.율 ?? l.수량) : l.수량,
단가: pct ? null : l.단가,
금액: l.금액,
parts,
pct,
added,
apart: !!extra.apart,
covered: {
qty: !!cover && ("qty" in cover || "배율" in cover),
price: added === null ? PARTS.some((k) => cover?.[k] != null) : one?.단가 != null,
},
배율: cover?.배율 ?? null,
was: base,
revised:
!!old &&
!!base &&
((old.qty != null && !near(old.qty, base.qty)) ||
(old.단가 != null && !near(old.단가, base.단가))),
priceEdit: !pct && (added === null || ["인력", "재료", "기계"].includes(String(one?.종류))),
apartPick: one?.종류 === "재료",
sub: at ? { ...at, scale: Number(owner?.수량 ?? 1) } : null,
t: pct ? 1 : t,
};
};
for (const l of flatLines(ans)) rows.push(row(l.cid, l, partsOf(l), l));
const seen = new Set(rows.map((r) => r.cid));
for (const m of ans.재료 ?? []) {
const cid = ans.정체[m.차례];
if (!cid || seen.has(cid)) continue;
rows.push(row(cid, m, { 재료비: m.금액 ?? 0 }, { 묶음: "재료", apart: true }));
}
return rows;
}
const subWas = (v: SkLine["바탕값"]): CoverRow["was"] =>
v ? { qty: v.qty ?? null, 단가: v.단가 ?? null } : null;
const memo = (base: CoverRow["was"]): SkCover["원값"] | undefined => {
if (!base) return undefined;
const out: { qty?: number; 단가?: number } = {};
if (base.qty != null) out.qty = base.qty;
if (base.단가 != null) out.단가 = base.단가;
return Object.keys(out).length ? out : undefined;
};
function putCover(doc: SkDoc, r: CoverRow, cover: SkCover): void {
const owner = r.sub ? doc.더한줄?.[r.sub.k] : doc;
if (!owner) return;
const key = r.sub?.cid ?? r.cid;
const covers = { ...(owner.덮개 ?? {}) };
if (COVER_KEYS.some((k) => cover[k] != null)) {
const old = cover.원값 ?? memo(r.was);
covers[key] = { ...cover, ...(old ? { 원값: old } : {}) };
} else delete covers[key];
if (r.sub && !Object.keys(covers).length) delete owner.덮개;
else owner.덮개 = covers;
}
/** 공용 상세 고침 칸(`LogicLab_Cover.LineCover` 와 같은 꼴) — 품 · 비목 단가(로직 줄 = 1 단위 노 · 재 · 경) */
export type LineCover = Pick<SkCover, "qty" | CoverPart>;
/** 공용 상세 고침 한 줄 → 문서 — 더한줄 = 그 줄 수량 · 단가(요소 줄만) · 바탕 줄 = 덮개
* (열린입력 SK = 품 대신 배율 = 고친 품 ÷ 바탕 품 · 바탕 값과 같은 품 = 덮개 안 둠) · `c` null = 고침 풂 */
export function setLineCover(doc: SkDoc, r: CoverRow, c: LineCover | null, opened: boolean): void {
const price = r.priceEdit ? PARTS.map((k) => c?.[k]).find((v) => v != null) : undefined;
if (r.added !== null) {
const one = doc.더한줄?.[r.added];
if (!one) return;
if (c?.qty != null) one.수량 = c.qty * r.t;
if (price == null) delete one.단가;
else one.단가 = price;
return;
}
const cover: SkCover = {};
// 세부 줄 품 = 화면 품 ÷ 더한줄 수량(1 기준단위 몫 · 수량 0 = 그대로)
const raw = c?.qty == null ? c?.qty : c.qty * r.t;
const qty = r.sub && raw != null && r.sub.scale ? raw / r.sub.scale : raw;
const open = opened && !r.sub; // 열린입력 = 바탕 입력 — 세부 줄은 품 그대로
// ponytail: 열린입력 배율 = 고친 품 ÷ 바탕 품(바탕 품 0 · 없음 = 품 고침 못 함)
if (qty != null && open && r.was?.qty) cover.배율 = qty / r.was.qty;
if (qty != null && !open && !near(qty, r.was?.qty)) cover.qty = qty;
if (r.priceEdit) for (const k of PARTS) if (c?.[k] != null) cover[k] = c[k];
if (cover.배율 != null && near(cover.배율, 1)) delete cover.배율;
putCover(doc, r, cover);
}
/** 줄 빼기 — 더한줄 = 그 줄 지움 · 바탕 줄 = 뺀줄에 cid(덮개 · 고름 · 자재표시도 지움) */
export function dropLine(doc: SkDoc, r: CoverRow): void {
const one = r.sub ? doc.더한줄?.[r.sub.k] : undefined;
if (one && r.sub) {
one.뺀줄 = [...(one.뺀줄 ?? []).filter((c) => c !== r.sub!.cid), r.sub.cid];
if (one.덮개) delete one.덮개[r.sub.cid];
return;
}
if (r.added !== null) {
doc.더한줄 = (doc.더한줄 ?? []).filter((_, i) => i !== r.added);
return;
}
doc.뺀줄 = [...(doc.뺀줄 ?? []).filter((c) => c !== r.cid), r.cid];
for (const k of ["덮개", "고름", "자재표시"] as const) {
const map = { ...((doc[k] as Record<string, unknown> | undefined) ?? {}) };
delete map[r.cid];
(doc as Record<string, unknown>)[k] = map;
}
}
/** 더한 재료 줄 별도계상 — 켬 = 계 밖(재료 목록만 · 서버 자재표시) · 끔 = 칸 지움(기본 계 안) */
export function setApart(doc: SkDoc, r: CoverRow, on: boolean): void {
const one = r.added === null ? undefined : doc.더한줄?.[r.added];
if (!one || one.종류 !== "재료") return;
if (on) one.별도계상 = true;
else delete one.별도계상;
}
/** 뺀 줄 되살림 — 세부 열쇠면 그 더한줄의 뺀줄 */
export function restoreLine(doc: SkDoc, cid: string): void {
const at = subOf(cid);
const one = at ? doc.더한줄?.[at.k] : undefined;
if (one && at) {
one.뺀줄 = (one.뺀줄 ?? []).filter((c) => c !== at.cid);
if (!one.뺀줄.length) delete one.뺀줄;
return;
}
doc.뺀줄 = (doc.뺀줄 ?? []).filter((c) => c !== cid);
}
/** 둘째 기초 통째 빼기 — 그 더한줄 지움 */
export function dropBase(doc: SkDoc, k: number): void {
doc.더한줄 = (doc.더한줄 ?? []).filter((_, i) => i !== k);
}
const NUM = new Intl.NumberFormat("ko-KR", { maximumFractionDigits: 4 });
const num = (v: number | null): string => (v == null ? "—" : NUM.format(v));
/** 표 줄 → 공용 계산서(`calc_sheet`) 비목 묶음 — 줄 글 「단가 * 품 = 금액」 · 기준 밖 재료 = 금액 없음 */
export function sheetGroups(rows: CoverRow[]): SheetGroup[] {
const groups = new Map<string, SheetGroup>();
const take = (k: string): SheetGroup => {
let g = groups.get(k);
if (!g) groups.set(k, (g = { 비목: k, 소계: 0, 줄: [] }));
return g;
};
for (const r of rows) {
const entries = Object.entries(r.parts);
for (const [k, v] of entries.length ? entries : [["경비", 0] as [string, number]]) {
const share = entries.length > 1 && r.금액 ? (r.단가 ?? 0) * (v / r.금액) : r.단가;
const amount = r.apart || (!entries.length && r.금액 == null) ? null : v;
const g = take(k);
g.소계 = (g.소계 ?? 0) + (amount ?? 0);
g.줄.push({
이름: r.이름,
글: r.pct ? `${num(r.수량)} %` : `${num(share)} * ${num(r.수량)} = ${num(amount)}`,
금액: amount,
묶음: r.묶음,
장비: entries.length > 1 && (r.sub?.cid ?? r.cid).startsWith("logic:"),
});
}
}
const order = Object.keys(PART_OF);
return [...groups.values()].sort((a, b) => order.indexOf(a.비목) - order.indexOf(b.비목));
}
/* ── 공용 상세(Ho) 줄 ── */
/** Ho 호표 · 덧줄 꼴(`M01_MasterData_UI_Logic_Api` HoLine · NamedFormula 중 쓰는 칸) */
export interface HoItem {
종류: string;
요소: string;
이름?: string;
규격?: string;
단위?: string;
수량: string;
비목?: string;
/** 출처 꼬리표(727-2 · 기초 둘 이상) */
꼬리표?: string;
}
export interface HoExtra {
이름: string;
식: string;
비목?: string;
꼬리표?: string;
}
export interface HoView {
호표: HoItem[];
덧줄: HoExtra[];
/** Ho 줄 차례(호표 → 덧줄)의 셈 줄 — 셈 전 · 못 셈 = null · 별도계상 빠진 줄 = 빈 칸 */
lines: (SkLine | undefined)[] | null;
/** Ho 줄 차례 → cid(고침 열쇠 옮김) */
cids: string[];
/** Ho 줄 차례 → 지금 고침(덮개 · 더한줄) */
covers: Record<number, LineCover>;
}
const KIND_OF: Record<string, string> = { labor: "인력", material: "재료", machine: "기계" };
const ELEMENT_KINDS = ["인력", "재료", "기계"];
/** SK 셈 답 → 공용 상세 줄 — 줄 차례 = 서버 `정체`(바탕 남은 호표 · 더한줄 · 바탕 남은 덧줄) ·
* 바탕 로직 줄을 알면(`base` + 바탕 셈 `bare.정체` 차례) 그 줄 그대로(식 · 요소 · 규격) ·
* 더한줄 = 문서 줄 · 그 밖(참조 바탕) = 셈 줄로 지음 */
export function hoView(
ans: SkAnswer,
bare: SkAnswer | null,
base: { 호표?: HoItem[]; 덧줄?: HoExtra[] } | null,
doc: SkDoc,
): HoView {
const head = base?.호표 ?? [];
const known = new Map<string, HoItem | HoExtra>();
if (base && bare)
bare.정체.forEach((cid, i) => {
const it = i < head.length ? head[i] : base.덧줄?.[i - head.length];
if (it) known.set(cid, it);
});
const lineBy = new Map(flatLines(ans).map((l) => [l.cid, l]));
const tags = tagsOf(ans, doc);
const t = ans.화면배 ?? 1;
const single = (l?: SkLine): string | undefined => {
const parts = Object.entries(l?.비목 ?? {}).filter(([, v]) => v);
return parts.length === 1 ? parts[0][0] : undefined;
};
const ho: [string, HoItem][] = [];
const extra: [string, HoExtra][] = [];
for (const cid of flatIds(ans)) {
const l = lineBy.get(cid);
const name = l?.이름 ?? cid;
const at = subOf(cid);
const tag = tags.has(cid) ? { 꼬리표: tags.get(cid) } : {};
if ((at?.cid ?? cid).startsWith("pct:")) {
extra.push([cid, { ...((known.get(cid) as HoExtra) ?? { 이름: name, 식: "" }), ...tag }]);
continue;
}
if (at) {
const kind = KIND_OF[at.cid.split(":")[0]] ?? "로직";
const qty = String(l?.율 ?? l?.수량 ?? "");
const item = { 종류: kind, 요소: "", 이름: name, 단위: l?.단위 ?? "", 수량: qty };
ho.push([cid, { ...item, 비목: single(l), ...tag }]);
continue;
}
const hit = ADD_RE.exec(cid);
const a = hit ? doc.더한줄?.[Number(hit[1])] : undefined;
const kind = a
? ELEMENT_KINDS.includes(String(a.종류))
? String(a.종류)
: "로직"
: (KIND_OF[cid.split(":")[0]] ?? "로직");
const made: HoItem = {
종류: kind,
요소: typeof a?.요소 === "string" ? a.요소 : "",
이름: name,
단위: l?.단위 ?? String(a?.단위 ?? ""),
수량: String(a ? a.수량 / t : (l?.율 ?? l?.수량 ?? "")),
비목: typeof a?.비목 === "string" ? a.비목 : single(l),
};
ho.push([cid, { ...(a ? made : ((known.get(cid) as HoItem) ?? made)), ...tag }]);
}
const cids = [...ho, ...extra].map(([cid]) => cid);
const covers: Record<number, LineCover> = {};
cids.forEach((cid, i) => {
const hit = ADD_RE.exec(cid);
const a = hit ? doc.더한줄?.[Number(hit[1])] : undefined;
const part = PART_OF[String(a?.비목 ?? "")];
if (a) covers[i] = { qty: a.수량 / t, ...(a.단가 != null && part ? { [part]: a.단가 } : {}) };
const at = subOf(cid);
const owner = at ? doc.더한줄?.[at.k] : undefined;
const c = hit ? undefined : at ? owner?.덮개?.[at.cid] : doc.덮개?.[cid];
if (!c) return;
const l = lineBy.get(cid);
const out: LineCover = {};
const per = (at?.cid ?? cid).startsWith("pct:") ? 1 : t; // 율 = 그대로
if (c.qty != null) out.qty = (at ? c.qty * Number(owner?.수량 ?? 1) : c.qty) / per;
else if (c.배율 != null && l) out.qty = l.율 ?? l.수량 ?? undefined;
for (const k of PARTS) if (c[k] != null) out[k] = c[k];
covers[i] = out;
});
return {
호표: ho.map(([, x]) => x),
덧줄: extra.map(([, x]) => x),
lines: ans.줄.length ? cids.map((c) => lineBy.get(c)) : null,
cids,
covers,
};
}
/** 바탕 로직 호표 차례 고침(덮개 qty · 비목 — 배율 뺌) — 시험 계산 · 텍스트 수식 줄 사본(`applyCovers`)용 */
export function baseCovers(
bare: SkAnswer | null,
count: number,
doc: SkDoc,
): Record<number, LineCover> {
const out: Record<number, LineCover> = {};
(bare?.정체 ?? []).slice(0, count).forEach((cid, i) => {
const c = doc.덮개?.[cid];
const one: LineCover = {};
if (c?.qty != null) one.qty = c.qty;
for (const k of PARTS) if (c?.[k] != null) one[k] = c[k];
if (Object.keys(one).length) out[i] = one;
});
return out;
}
/** 텍스트 수식 답에서 뺀 바탕 줄 글 거름 — 공용 상세는 줄 글을 이름 차례로 붙임(같은 이름 더한줄이
* 뺀 줄 글을 받지 않게) · 차례 = 바탕 이름(호표 → 덧줄)을 따라감(`Ho.attachPieces` 와 같은 길) */
export function dropText<T extends { ok: boolean; groups?: { 줄: { 이름: string }[] }[] }>(
text: T | null,
names: string[],
bare: SkAnswer | null,
doc: SkDoc,
): T | null {
const drop = new Set(doc.뺀줄 ?? []);
const gone = new Set((bare?.정체 ?? []).flatMap((cid, i) => (drop.has(cid) ? [i] : [])));
if (!text?.ok || !text.groups || !gone.size) return text;
const groups = text.groups.map((g) => {
let from = 0;
const 줄 = g.줄.filter((line) => {
const at = names.findIndex((n, i) => i >= from && n === line.이름);
if (at < 0) return true;
from = at + 1;
return !gone.has(at);
});
return { ...g, 줄 };
});
return { ...text, groups };
}
/* ── 일위대가 줄 → SK 문서(717-3 · 723-3 줄 모드) ── */
export interface LinePatch {
덮개?: Record<string, SkCover>;
뺀줄?: string[];
더한줄?: unknown[];
}
/** 줄 꼴(일위대가 `담은로직[]` 한 줄 · 쓰는 칸만) */
export interface PatchRow {
로직?: string;
참조?: { 층: string; 주인?: string | null; 키: string };
입력값?: Record<string, unknown>;
켜쌓기율?: number | null;
고름?: Record<string, unknown>;
자재표시?: Record<string, unknown>;
패치?: LinePatch;
}
const keyed = (value: Record<string, unknown> | undefined, ids: string[]) =>
Object.fromEntries(
Object.entries(value ?? {}).flatMap(([n, v]) =>
/^\d+$/.test(n) && Number(n) < ids.length ? [[ids[Number(n)], v]] : [],
),
);
/** 고름 · 자재표시가 있는 로직 줄 = 바탕 줄 정체가 먼저 있어야 함 */
export const needsIds = (row: PatchRow): boolean =>
!row.참조 &&
(Object.keys(row.고름 ?? {}).length > 0 || Object.keys(row.자재표시 ?? {}).length > 0);
/** 줄 → 이름 없는 SK 문서 — `patch` null = 그 일위대가 고침 없는 원래 값 ·
* 고름 · 자재표시(호표 차례 열쇠)는 바탕 줄 정체(`ids` · 셈 답 `정체`)로 cid 열쇠로 */
export function lineDoc(
row: PatchRow,
ids: string[] | null,
patch: LinePatch | null,
): Record<string, unknown> {
const head = {
키: "SK-000000",
이름: "줄",
단위: "식",
덮개: patch?.덮개 ?? {},
뺀줄: patch?.뺀줄 ?? [],
더한줄: patch?.더한줄 ?? [],
};
if (row.참조) return { ...head, 바탕: { 참조: row.참조 } };
return {
...head,
...(row.켜쌓기율 != null ? { 켜쌓기율: row.켜쌓기율 } : {}),
바탕: { 로직: row.로직 ?? "" },
입력값: { ...(row.입력값 ?? {}) },
고름: ids ? keyed(row.고름, ids) : {},
자재표시: ids ? keyed(row.자재표시, ids) : {},
};
}
/** 빈 칸 · 원값 뺀 패치 — 다 비면 null(줄 칸 안 둠) */
export function cleanPatch(patch: LinePatch | null | undefined): LinePatch | null {
const covers = Object.fromEntries(
Object.entries(patch?.덮개 ?? {}).flatMap(([cid, c]) => {
const keep = Object.fromEntries(
Object.entries(c ?? {}).filter(([k, v]) => k !== "원값" && typeof v === "number"),
);
return Object.keys(keep).length ? [[cid, keep]] : [];
}),
);
const out: LinePatch = {
...(Object.keys(covers).length ? { 덮개: covers } : {}),
...(patch?.뺀줄?.length ? { 뺀줄: [...patch.뺀줄] } : {}),
...(patch?.더한줄?.length ? { 더한줄: [...patch.더한줄] } : {}),
};
return Object.keys(out).length ? out : null;
}
/* ── 순수 셈 끝 ── */