feat(727-2 · 727-3): 둘째 기초 세부 줄 펼침 · 세부 덮개 · 뺀줄 · 출처 꼬리표 · 넣은 뒤 고른 옵션 SK 입력값 저장
- 서버 Sangeun_Doc: 더한줄 로직 = 덮개{세부 cid} · 뺀줄[세부 cid] 받음 · SK 입력값 = 바탕 입력 + 더한줄 기초 입력(727-3)
- 서버 Sangeun_Calc: 고친 둘째 기초 = 고친 기초 줄을 엔진으로 셈한 값묶음 줄(B09 굳힘도 같은 값) · 화면 셈 길만 펼침(세부 × 수량) · 뺀세부 · 바탕판 · 꼬리표(원문번호) · 저장 때 세부 원값 · 바탕판
- 화면 Cover_Model: 세부 줄 열쇠 add:#k/세부 cid · 품 = 화면 품 ÷ 수량 · 줄 빼기 · 되살림 · 기초 빼기 · 꼬리표 = 기초 둘 이상일 때만
- 화면 Sangeun_Detail: 옵션 칸 = 바탕 입력 + 더한줄 기초 입력 · 고르면 SK 입력값(열린입력만 시험 값) · M01 개정 알림에 둘째 기초 · [기초 빼기]
- 공용 상세 LogicLab_Ho: 줄 덧칸 꼬리표 배지(칸 폭 고정 · 말줄임 + 풍선 · M01 화면은 꼬리표 없음 = 전후 같음)
- 이음: 둘째 기초 수량 = 그 기초 기준단위 몇 개(처음 1) — 단위 · 배는 728
- 새 시험 test_727_sk_base_add(세부 둘 0.023 · 0.01546 · 기초 둘 계 = 서버 셈 · 덮개 · 뺀줄 · 굳힘 같은 값 · 암석작업 옵션 5,346.93) · 기존 SK 넷 금액 전후 같음
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,8 @@
|
||||
* 줄 정체 cid = `kind:키#n`(바탕 줄) · `add:#k`(더한줄 k 째) · 덮개 원값 = 저장 때 서버가 다시 적음
|
||||
* (화면은 고친 첫때 바탕 값을 적어 둠 — 곁 글용) · 열린입력 있는 SK = 품은 배율만
|
||||
* 일위대가 줄 → 이름 없는 SK 문서 = `lineDoc`(서버 `M02_Labor_Patch.as_doc` 꼴)
|
||||
* 둘째 기초(727-2) = 더한줄 로직 줄을 서버 `펼침`(세부 줄)으로 바꿔 놓음 · 열쇠 `add:#k/세부 cid` ·
|
||||
* 고침 = 그 더한줄의 `덮개` · `뺀줄`(품 = 화면 품 ÷ 수량 · 1 기준단위 몫) · 꼬리표 = 기초 둘 이상일 때만
|
||||
* 화면 · fetch · sessionStorage 없음(`CLAUDE.md` 4장).
|
||||
* ========================================================================== */
|
||||
|
||||
@@ -33,6 +35,10 @@ export interface SkAdded {
|
||||
종류?: string;
|
||||
수량: number;
|
||||
단가?: number;
|
||||
/** 둘째 기초 세부 고침(727-2) — 열쇠 = 세부 cid */
|
||||
덮개?: Record<string, SkCover>;
|
||||
뺀줄?: string[];
|
||||
바탕판?: string;
|
||||
[k: string]: unknown;
|
||||
}
|
||||
|
||||
@@ -65,6 +71,13 @@ export interface SkLine {
|
||||
율?: number;
|
||||
/** 못 셈 줄(725) — 금액 없음 · 계에 안 듦 */
|
||||
까닭?: string;
|
||||
/** 둘째 기초(727-2) — 세부 줄 · 뺀 세부 · 그 기초 지문 · 원문번호 */
|
||||
펼침?: SkLine[];
|
||||
뺀세부?: { cid: string; 이름: string }[];
|
||||
바탕판?: string;
|
||||
꼬리표?: string;
|
||||
/** 세부 줄 — 덮기 전 1 기준단위 품 · 단가 */
|
||||
바탕값?: { qty?: number; 단가?: number } | null;
|
||||
}
|
||||
|
||||
export interface SkMat {
|
||||
@@ -87,6 +100,8 @@ export interface SkAnswer {
|
||||
짝잃음: { cid: string; 까닭: string; 바탕?: string }[];
|
||||
바탕판: string;
|
||||
머리?: string;
|
||||
/** 바탕 원문번호(참조 바탕 = 그 키) */
|
||||
꼬리표?: string | null;
|
||||
}
|
||||
|
||||
export interface CoverRow {
|
||||
@@ -115,11 +130,57 @@ export interface CoverRow {
|
||||
priceEdit: boolean;
|
||||
/** 더한 재료 줄 — 별도계상 고름(기본 계 안) */
|
||||
apartPick: boolean;
|
||||
/** 둘째 기초 세부 줄 — 더한줄 차례 · 세부 cid · 수량(배) · 바탕 줄 · 그 밖 = null */
|
||||
sub: { k: number; cid: string; scale: number } | null;
|
||||
}
|
||||
|
||||
/* ── 순수 셈 ── */
|
||||
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> =>
|
||||
@@ -156,15 +217,17 @@ export function rowsOf(ans: SkAnswer, bare: SkAnswer | null, doc: SkDoc): CoverR
|
||||
금액: number | null;
|
||||
},
|
||||
parts: Record<string, number>,
|
||||
extra: { 묶음?: string | null; 율?: number; apart?: boolean },
|
||||
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 cover = added === null ? doc.덮개?.[cid] : undefined;
|
||||
const pct = cid.startsWith("pct:");
|
||||
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 base = added === null ? (was.get(cid) ?? null) : null;
|
||||
const base = at ? subWas(extra.바탕값) : added === null ? (was.get(cid) ?? null) : null;
|
||||
const old = cover?.원값;
|
||||
return {
|
||||
cid,
|
||||
@@ -191,9 +254,10 @@ export function rowsOf(ans: SkAnswer, bare: SkAnswer | null, doc: SkDoc): CoverR
|
||||
(old.단가 != null && !near(old.단가, base.단가))),
|
||||
priceEdit: !pct && (added === null || ["인력", "재료", "기계"].includes(String(one?.종류))),
|
||||
apartPick: one?.종류 === "재료",
|
||||
sub: at ? { ...at, scale: Number(owner?.수량 ?? 1) } : null,
|
||||
};
|
||||
};
|
||||
for (const l of ans.줄) rows.push(row(l.cid, l, partsOf(l), l));
|
||||
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.차례];
|
||||
@@ -203,6 +267,9 @@ export function rowsOf(ans: SkAnswer, bare: SkAnswer | null, doc: SkDoc): CoverR
|
||||
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 } = {};
|
||||
@@ -212,12 +279,16 @@ const memo = (base: CoverRow["was"]): SkCover["원값"] | undefined => {
|
||||
};
|
||||
|
||||
function putCover(doc: SkDoc, r: CoverRow, cover: SkCover): void {
|
||||
const covers = { ...(doc.덮개 ?? {}) };
|
||||
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[r.cid] = { ...cover, ...(old ? { 원값: old } : {}) };
|
||||
} else delete covers[r.cid];
|
||||
doc.덮개 = covers;
|
||||
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 단위 노 · 재 · 경) */
|
||||
@@ -236,10 +307,12 @@ export function setLineCover(doc: SkDoc, r: CoverRow, c: LineCover | null, opene
|
||||
return;
|
||||
}
|
||||
const cover: SkCover = {};
|
||||
const qty = c?.qty;
|
||||
// 세부 줄 품 = 화면 품 ÷ 더한줄 수량(1 기준단위 몫 · 수량 0 = 그대로)
|
||||
const qty = r.sub && c?.qty != null && r.sub.scale ? c.qty / r.sub.scale : c?.qty;
|
||||
const open = opened && !r.sub; // 열린입력 = 바탕 입력 — 세부 줄은 품 그대로
|
||||
// ponytail: 열린입력 배율 = 고친 품 ÷ 바탕 품(바탕 품 0 · 없음 = 품 고침 못 함)
|
||||
if (qty != null && opened && r.was?.qty) cover.배율 = qty / r.was.qty;
|
||||
if (qty != null && !opened && !near(qty, r.was?.qty)) cover.qty = qty;
|
||||
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);
|
||||
@@ -247,6 +320,12 @@ export function setLineCover(doc: SkDoc, r: CoverRow, c: LineCover | null, opene
|
||||
|
||||
/** 줄 빼기 — 더한줄 = 그 줄 지움 · 바탕 줄 = 뺀줄에 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;
|
||||
@@ -267,11 +346,23 @@ export function setApart(doc: SkDoc, r: CoverRow, on: boolean): void {
|
||||
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));
|
||||
|
||||
@@ -295,7 +386,7 @@ export function sheetGroups(rows: CoverRow[]): SheetGroup[] {
|
||||
글: r.pct ? `${num(r.수량)} %` : `${num(share)} * ${num(r.수량)} = ${num(amount)}`,
|
||||
금액: amount,
|
||||
묶음: r.묶음,
|
||||
장비: entries.length > 1 && r.cid.startsWith("logic:"),
|
||||
장비: entries.length > 1 && (r.sub?.cid ?? r.cid).startsWith("logic:"),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -313,11 +404,14 @@ export interface HoItem {
|
||||
단위?: string;
|
||||
수량: string;
|
||||
비목?: string;
|
||||
/** 출처 꼬리표(727-2 · 기초 둘 이상) */
|
||||
꼬리표?: string;
|
||||
}
|
||||
export interface HoExtra {
|
||||
이름: string;
|
||||
식: string;
|
||||
비목?: string;
|
||||
꼬리표?: string;
|
||||
}
|
||||
export interface HoView {
|
||||
호표: HoItem[];
|
||||
@@ -349,18 +443,28 @@ export function hoView(
|
||||
const it = i < head.length ? head[i] : base.덧줄?.[i - head.length];
|
||||
if (it) known.set(cid, it);
|
||||
});
|
||||
const lineBy = new Map(ans.줄.map((l) => [l.cid, l]));
|
||||
const lineBy = new Map(flatLines(ans).map((l) => [l.cid, l]));
|
||||
const tags = tagsOf(ans, doc);
|
||||
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 ans.정체) {
|
||||
for (const cid of flatIds(ans)) {
|
||||
const l = lineBy.get(cid);
|
||||
const name = l?.이름 ?? cid;
|
||||
if (cid.startsWith("pct:")) {
|
||||
extra.push([cid, (known.get(cid) as HoExtra) ?? { 이름: name, 식: "" }]);
|
||||
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);
|
||||
@@ -378,7 +482,7 @@ export function hoView(
|
||||
수량: String(a?.수량 ?? l?.율 ?? l?.수량 ?? ""),
|
||||
비목: typeof a?.비목 === "string" ? a.비목 : single(l),
|
||||
};
|
||||
ho.push([cid, a ? made : ((known.get(cid) as HoItem) ?? made)]);
|
||||
ho.push([cid, { ...(a ? made : ((known.get(cid) as HoItem) ?? made)), ...tag }]);
|
||||
}
|
||||
const cids = [...ho, ...extra].map(([cid]) => cid);
|
||||
const covers: Record<number, LineCover> = {};
|
||||
@@ -387,11 +491,13 @@ export function hoView(
|
||||
const a = hit ? doc.더한줄?.[Number(hit[1])] : undefined;
|
||||
const part = PART_OF[String(a?.비목 ?? "")];
|
||||
if (a) covers[i] = { qty: a.수량, ...(a.단가 != null && part ? { [part]: a.단가 } : {}) };
|
||||
const c = hit ? undefined : doc.덮개?.[cid];
|
||||
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 = {};
|
||||
if (c.qty != null) out.qty = c.qty;
|
||||
if (c.qty != null) out.qty = at ? c.qty * Number(owner?.수량 ?? 1) : c.qty;
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user