- 서버 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>
645 lines
25 KiB
TypeScript
645 lines
25 KiB
TypeScript
/* =============================================================================
|
||
* 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;
|
||
}
|
||
/* ── 순수 셈 끝 ── */
|