feat(778-5 D-5): 마법사 단가산출 줄 고름 — 제안 · 블록 [확인] 뒤 확인한 것만 옮김(요구 621 · 622 · 623 · 624)

- 단가산출 칸 = 서버 줄 판정(빈 확인) 블록 표: 판정 · 우리 금액 · 엑셀 금액(참고) · 근거 꼬리표 · 만든 SK · [확인] 칩(고를 줄 0 일 때만) · [줄] 펼침 · [보기]
- 줄 표(새 Hopyo_SkLines) = 묶음 · 이름 · 규격 · 매개값 · 상태 칩(엑셀 칠과 한 규칙 unitTone) · 꼬리표 · 방법 · 금액 · 차 · 고름(후보 · 찾기 · 율 기준 · 뺌 + 까닭 · 지움) · 엑셀 보기 뛰기
- 고름 · 확인 = 캐시 m02-hopyo-sk · [확인한 것 옮기기] = 몸 {확인: [{번, 고름}]} · 서버가 다시 셈 · 자동 확인 · 「모두 확인」 없음
- 순수 꼴 = 새 logic/Hopyo_SkPick · 길 = 새 Hopyo_SkApi(Hopyo_Api 안 늘림)
- 마법사 [끝내기] 막힘 글 앞에 「단가산출 확인 안 한 블록 n · 고를 줄 m」(Hopyo_Wizard 한 곳)
- 걷음: 옛 고름 대기 기초 고름 · 줄 비교(Compare.compareView · compareBrief · Api SangeunCompare · CompareLook · CSS) — 쓰는 곳 없음 · 못옮김 창 잇기 만들기(옛 바탕 몸 = 서버가 걷어 422) → 보기만 + 줄 찾기 단추 lineFind 로 다시 씀 · 안 쓰는 문구 39
- 낡은 시험 고침: test_746_2(줄 비교 · 못옮김 요소 단추 시험 걷음 — 기능 걷음) · test_729_3(candLabel · 옛 잇기 몸 기대값 걷음) · test_m02_hopyo_screen(sangeunStep 인자) · 새 test_778_5_sk_pick_screen + helper_778_5_sk_pick.cjs

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-04 14:41:48 +09:00
co-authored by Claude Opus 5.5
parent 8380fa7334
commit 3ffeff84c1
16 changed files with 979 additions and 596 deletions
@@ -367,11 +367,3 @@
.m02-hopyo-side > .m02-side__row {
margin-top: var(--spacing-12);
}
/* 고름 대기 후보 줄 비교(746-2) — 고르기 칸 아래 · 후보 단추 줄 + 표 */
.m02-hopyo__cmp {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
margin-top: var(--spacing-4);
}
@@ -234,27 +234,6 @@ export interface SangeunCand {
글: string;
/** 729-4 — 기준단위 ↔ 엑셀 단위 성격 다름 = 환산 계수 필요(가 1 = 나 몇) */
계수?: { 가: string; 나: string; 갈래: string } | null;
/** 746-1 — 그 기초로 견준 줄 비교(앞 셋) · null = 견줌 못함(단위 성격 다름) */
줄비교?: SangeunCompare | null;
}
/** 줄 비교 꼴(746-1) */
export type CompareLook = "같음" | "품다름" | "엑셀만" | "기초만";
/** 고름 대기 후보 줄 비교(746-1) — 줄마다 엑셀 · 기초 1 단위 품 */
export interface SangeunCompare {
줄: {
갈래: "인력" | "장비" | "자재";
이름: string;
규격?: string;
단위?: string;
엑셀: number | null;
기초: number | null;
꼴: CompareLook;
}[];
수: Record<CompareLook, number>;
못옮김: SangeunLost[];
환산?: unknown;
}
/** M01 요소 후보(746-1) — 못옮김 · 안 이은 기계 줄 · 고르면 `{노임|기계|자재: 키}` 로 이음 */
@@ -1,111 +1,15 @@
/* =============================================================================
* M02_MasterTemplete_Hopyo_Compare.ts
* 불러오기 고름 창 — 후보 블록 줄 비교 · M01 요소 후보(PLAN 746-2 · 요구 585 · 서버 746-1 `줄비교` · `후보`) —
* · `compareView` = 고름 대기 후보(앞 셋)마다 줄 비교 표(줄 · 엑셀 품 · 기초 품 · 꼴 배지) + 못옮김 수 · 후보 바꿈 단추
* · `elemChoices` = 못옮김 · 안 이은 기계 줄의 M01 요소 후보 셋 단추(누르면 부르는 쪽이 이음)
* 보이기만 — 판정 · 순위 · 미리 고름은 서버 그대로 · 고르기는 부르는 쪽.
* 불러오기 M01 요소 후보 단추(PLAN 746-2 · 요구 585 · 서버 746-1 `후보`) —
* · `elemChoices` = 안 이은 줄의 M01 요소 후보 셋 단추(누르면 부르는 쪽이 이음)
* · 옛 고름 대기 기초 줄 비교(`compareView`)는 778-5 D-5 줄 고름으로 걷음
* 보이기만 — 판정 · 순위는 서버 그대로 · 고르기는 부르는 쪽.
* ========================================================================== */
import { createBadge, type BadgeTone } from "@ui/ui_template_badge";
import { createButton, el } from "@ui/ui_template_elements";
import { t as L, type LocaleKey } from "@ui/ui_template_locale";
import { createSegment } from "@ui/ui_template_segment";
import { createTable } from "@ui/ui_template_table";
import { createText } from "@ui/ui_template_text";
import { formatNumber, formatPrice } from "../common_util/common_util_money";
import type {
CompareLook,
ElemCand,
SangeunCand,
SangeunCompare,
} from "./M02_MasterTemplete_Hopyo_Api";
const LOOK: Record<CompareLook, { tone: BadgeTone; key: LocaleKey }> = {
같음: { tone: "success", key: "M02_CmpSame" },
품다름: { tone: "warning", key: "M02_CmpDiff" },
엑셀만: { tone: "info", key: "M02_CmpExcel" },
기초만: { tone: "neutral", key: "M02_CmpBase" },
};
const LOOKS = Object.keys(LOOK) as CompareLook[];
const num = (n: number | null): string => (n === null ? "—" : formatNumber(n));
/** 선택 칸 글 뒤 요약 — 「같음 3 · 품 다름 1」 · 견줌 못함 = "" */
export const compareBrief = (c: SangeunCompare | null | undefined): string =>
c
? LOOKS.filter((k) => c.수[k])
.map((k) => `${L(LOOK[k].key)} ${c.수[k]}`)
.join(" · ")
: "";
/** 줄 비교 표 하나 */
function compareTable(c: SangeunCompare): HTMLElement {
const rows = c.줄.map((x) => [
`${x.갈래} ${x.이름}${x.규격 ? ` ${x.규격}` : ""}`,
`${num(x.엑셀)} ${x.단위 ?? ""}`.trim(),
`${num(x.기초)} ${x.단위 ?? ""}`.trim(),
createBadge(L(LOOK[x.꼴].key), LOOK[x.꼴].tone),
]);
return createTable(
[
{ label: L("M02_SangeunColName") },
{ label: L("M02_CmpColExcel"), width: "112px", align: "end" },
{ label: L("M02_CmpColBase"), width: "112px", align: "end" },
{ label: L("M02_CmpColLook"), width: "84px" },
],
rows,
{ density: "data", empty: L("M02_CmpNone") },
);
}
/**
* 고름 대기 후보 줄 비교 칸 — 앞 후보 줄 비교를 후보 단추로 바꿔 봄 · `show(로직)` 으로 밖(고르기 칸)이 바꿈.
* 줄 비교 있는 후보가 하나도 없으면 null.
*/
export function compareView(
cands: SangeunCand[],
): { root: HTMLElement; show: (로직: string) => void } | null {
const has = cands.filter((c) => c.줄비교);
if (!has.length) return null;
const root = el("div", { className: "m02-hopyo__cmp", attrs: { "data-cmp": "" } });
const body = el("div");
const seg = createSegment({
options: has.map((c, i) => ({ value: c.로직, label: `${i + 1}. ${c.이름}`, title: c.이름 })),
value: has[0]!.로직,
size: 24,
ariaLabel: L("M02_CmpTitle").replace("{name}", ""),
onChange: (v) => paint(v),
});
const paint = (로직: string): void => {
const c = has.find((x) => x.로직 === 로직) ?? has[0]!;
const cmp = c.줄비교!;
body.dataset.cmpOf = c.로직;
const lost = cmp.못옮김.length;
body.replaceChildren(
createText(
[
L("M02_CmpTitle").replace("{name}", c.이름),
compareBrief(cmp),
lost ? L("M02_CmpLost").replace("{n}", String(lost)) : "",
]
.filter(Boolean)
.join(" · "),
"muted",
12,
),
compareTable(cmp),
);
};
root.append(seg.root, body);
paint(has[0]!.로직);
return {
root,
show: (로직) => {
if (!has.some((c) => c.로직 === 로직)) return;
seg.set(로직);
paint(로직);
},
};
}
import { t as L } from "@ui/ui_template_locale";
import { formatPrice } from "../common_util/common_util_money";
import type { ElemCand } from "./M02_MasterTemplete_Hopyo_Api";
/** 요소 후보 단추 줄(셋까지) — 누르면 `pick(후보)` */
export function elemChoices(cands: ElemCand[], pick: (c: ElemCand) => void): HTMLElement | null {
@@ -1,180 +1,93 @@
/* =============================================================================
* M02_MasterTemplete_Hopyo_Lost.ts
* 단가산출 못옮김 잇기 창(PLAN 729-3 · 729-4 이음 · 요구서 내역19 2장 3 · 4) — 실무 블록을 SK 로 옮길 때 우리 M01 에
* 바로 못 옮긴 엑셀 줄(노임 · 기계 · 자재)을 줄마다 M01 요소로 이음 → SK 더한줄(엑셀 수량) · [뺌] = 놓음(까닭 남김).
* · 고름 = 캐시 `m02-hopyo-lost:{층}:{주인}:{키}:{번}` `{id: 잇기}` — 서버는 부를 때마다 그 블록 잇기 전부로 다시 셈
* (앞 잇기 안 남음) → [잇기 만들기] 는 캐시 모두를 보냄 · 바탕 = 그 SK 문서 바탕(로직 · 입력값 · 환산 그대로)
* · 줄마다 M01 요소 후보 셋 단추(746-2 · 서버 `후보`) — 누르면 그 줄에 이음 고름(찾기와 같은 길)
* · 남은 못옮김 = 서버 답 그대로 표 · 0 이면 그 SK 를 쓰는 일위대가 줄이 이어짐(남으면 만들기 409)
* 계산은 서버 — 화면은 고름 · 보이기만 · 저장은 [잇기 만들기] 누를 때만.
* 단가산출 줄 찾기 단추 · 옛 못옮김 창(PLAN 729-4 · 778-5 D-5).
* · `lineFind` = 줄 묶음대로 찾아 넣기 창(`Find_Pick` 탭 하나 · 노임 = 인력 · 기계 = 기계 · 자재 = 재료) →
* 고름 `{노임|기계|자재: 키}` — 마법사 단가산출 줄 표(`Hopyo_SkLines`)가 씀
* · `openLost` = 옛 SK(기초 + 엑셀 차이)의 남은 못옮김 보기만 — 잇기는 마법사 단가산출 칸에서 그 블록 줄 고름 →
* [확인] → [확인한 것 옮기기](서버 778-5 D-3 · 옛 `바탕` 몸 걷음)
* ========================================================================== */
import { createButton, el, showToast } from "@ui/ui_template_elements";
import { createButton, showToast } from "@ui/ui_template_elements";
import { t as L } from "@ui/ui_template_locale";
import { openModal } from "@ui/ui_template_modal";
import { createTable } from "@ui/ui_template_table";
import { createText } from "@ui/ui_template_text";
import { readTemplate } from "./M02_MasterTemplete_Api_Fetch";
import { openFindPicker } from "./M02_MasterTemplete_Find_Pick";
import {
makeSangeun,
type HopyoScope,
type SangeunBuilt,
type SangeunLink,
type SangeunLost,
} from "./M02_MasterTemplete_Hopyo_Api";
import { elemChoices } from "./M02_MasterTemplete_Hopyo_Compare";
import type { HopyoScope, SangeunBuilt, SangeunLost } from "./M02_MasterTemplete_Hopyo_Api";
import type { SkPick } from "./logic/M02_MasterTemplete_Hopyo_SkPick";
const why = (error: unknown): string => (error instanceof Error ? error.message : String(error));
const NONE = "우리에 없음";
const KIND = { 노임: "labor", 기계: "machine", 자재: "material" } as const;
export const lostKey = (scope: HopyoScope, key: string, no: number): string =>
`m02-hopyo-lost:${scope.layer}:${scope.owner ?? ""}:${key}:${no}`;
/** 캐시 잇기 `{id: 잇기}` */
export function loadLinks(k: string): Record<string, SangeunLink> {
try {
const got = JSON.parse(sessionStorage.getItem(k) ?? "{}") as unknown;
return got && typeof got === "object" ? (got as Record<string, SangeunLink>) : {};
} catch {
return {};
}
/** 줄 찾기 단추 — 묶음 갈래 탭 하나 · 고르면 `done({갈래: 키})` */
export function lineFind(opt: {
scope: HopyoScope;
갈래: "노임" | "기계" | "자재";
이름: string;
규격?: string;
done: (p: SkPick) => void;
}): HTMLButtonElement {
const go = createButton({
label: opt.갈래 === "기계" ? L("M02_LeftMachine") : L("M02_LeftFind"),
size: 24,
variant: "ghost",
onClick: () =>
openFindPicker({
layer: opt.scope.layer,
owner: opt.scope.owner,
title: `${opt.이름} ${opt.규격 ?? ""}`.trim(),
kinds: [KIND[opt.갈래]],
query: opt.이름.split(/[\s(]/)[0] ?? "",
logic: () => showToast(L("M02_GridLinkNoLogic"), "info"),
element: (e) => opt.done({ [opt.갈래]: e.코드 } as SkPick),
machine: (e) => opt.done({ 기계: e.코드 }),
}),
});
go.dataset.skFind = opt.갈래;
return go;
}
const saveLinks = (k: string, v: Record<string, SangeunLink>): void =>
sessionStorage.setItem(k, JSON.stringify(v));
/** 고른 것 글 — 키 또는 뺌 */
const linkText = (x: SangeunLink | undefined): string =>
!x ? "" : x.뺌 ? `${L("M02_LostDrop")} · ${x.뺌}` : (x.노임 ?? x.기계 ?? x.자재 ?? "");
export interface LostOptions {
scope: HopyoScope;
key: string;
/** 블록 번(단산N) */
번: number;
/** 그 블록 SK 키 — 바탕을 읽음 */
/** 그 블록 SK 키 */
sk: string;
이름?: string;
lost: SangeunLost[];
/** 잇기 만든 뒤(서버 답) */
/** 옛 꼴 그대로(부르는 쪽) — 보기만이라 안 부름 */
done?: (got: SangeunBuilt) => void;
}
/** 못옮김 잇기 창 */
/** 옛 못옮김 창 — 보기만 · 잇기 = 마법사 단가산출 칸 */
export function openLost(opt: LostOptions): void {
const k = lostKey(opt.scope, opt.key, opt.번);
let lost = opt.lost;
openModal({
title: `${L("M02_LostTitle")} — 단산${opt.번} ${opt.이름 ?? ""}`.trim(),
closeLabel: L("M02_Close"),
width: "960px",
dialogClass: "m02-modal",
mount: (body, _close, actions) => {
const list = el("div", { attrs: { "data-lost": "" } });
const put = (id: string, link: SangeunLink | null): void => {
const all = loadLinks(k);
if (link) all[id] = link;
else delete all[id];
saveLinks(k, all);
draw();
};
const make = createButton({
label: L("M02_LostMake"),
variant: "filled",
onClick: () => void run(),
});
make.dataset.lostMake = "";
actions.append(make);
body.append(createText(L("M02_LostHint"), "hint", 14), list);
async function run(): Promise<void> {
const links = Object.values(loadLinks(k));
if (!links.length) return showToast(L("M02_WizSkPickFirst"), "warning");
make.disabled = true;
try {
const doc = (await readTemplate(opt.scope.layer, "sangeun", opt.sk, null, opt.scope.owner))
.문서 as { 바탕?: { 로직?: string } | null; 입력값?: Record<string, unknown>; 환산?: { 계수?: number } }; // prettier-ignore
const pick = {
...(doc.바탕?.로직 ? { 로직: doc.바탕.로직, 입력값: doc.입력값 ?? {} } : {}),
...(doc.환산?.계수 ? { 환산: { 계수: doc.환산.계수 } } : {}),
잇기: links,
};
const got = await makeSangeun(opt.scope, opt.key, { [opt.번]: pick });
lost = got.만듦.find((x) => x.번 === opt.번)?.못옮김 ?? [];
showToast(L("M02_LostLeft").replace("{n}", String(lost.length)), lost.length ? "warning" : "success"); // prettier-ignore
opt.done?.(got);
draw();
} catch (error) {
showToast(why(error), "error");
} finally {
make.disabled = false;
}
}
function draw(): void {
const all = loadLinks(k);
list.dataset.lost = String(lost.length);
const rows = lost.map((x) => {
const kind = x.갈래;
const find = createButton({
label: kind === "기계" ? L("M02_LeftMachine") : L("M02_LeftFind"),
size: 24,
variant: "ghost",
onClick: () =>
// 736-2 줄 갈래대로 탭 하나(노임 = 인력 · 기계 = 기계 · 자재 = 재료) → 이음 칸 `노임` · `기계` · `자재`
openFindPicker({
layer: opt.scope.layer,
owner: opt.scope.owner,
title: `${x.이름} ${x.규격 ?? ""}`.trim(),
kinds: [kind === "노임" ? "labor" : kind === "기계" ? "machine" : "material"],
query: x.이름.split(/[\s(]/)[0] ?? "",
logic: () => showToast(L("M02_GridLinkNoLogic"), "info"),
element: (e) => put(x.id, { id: x.id, [kind]: e.코드 }),
machine: (e) => put(x.id, { id: x.id, 기계: e.코드 }),
}),
});
const drop = createButton({
label: NONE,
size: 24,
variant: "ghost",
onClick: () => put(x.id, { id: x.id, 뺌: NONE }),
});
drop.title = L("M02_LostDropTip");
const clear = createButton({
label: L("M02_LostClear"),
size: 24,
variant: "link",
onClick: () => put(x.id, null),
});
return [
kind,
x.이름,
x.규격 ?? "",
`${x.수량 ?? ""} ${x.단위 ?? ""}`.trim(),
x.글 ?? "",
elemChoices(x.후보 ?? [], (c) => put(x.id, { id: x.id, [kind]: c.키 })) ?? "",
linkText(all[x.id]),
el("div", { className: "ui-toolbar", children: [find, drop, ...(all[x.id] ? [clear] : [])] }), // prettier-ignore
];
});
list.replaceChildren(
createTable(
[
{ label: L("M02_LeftKind"), width: "56px" },
{ label: L("M02_SangeunColName") },
{ label: L("M02_LeftSpec") },
{ label: L("M02_SangeunColQty"), width: "90px" },
{ label: L("M02_LeftWhy") },
{ label: L("M02_ElemTitle") },
{ label: L("M02_LeftPicked") },
{ label: L("M02_LeftPick"), width: "220px" },
],
rows,
{ density: "data", empty: L("M02_LostZero") },
),
);
}
draw();
mount: (body) => {
const rows = opt.lost.map((x) => [
x.갈래,
x.이름,
x.규격 ?? "",
`${x.수량 ?? ""} ${x.단위 ?? ""}`.trim(),
x.글 ?? "",
]);
body.append(
createText(L("M02_LostHint").replace("{n}", String(opt.번)), "hint", 14),
createTable(
[
{ label: L("M02_LeftKind"), width: "56px" },
{ label: L("M02_SangeunColName") },
{ label: L("M02_LeftSpec") },
{ label: L("M02_SangeunColQty"), width: "90px" },
{ label: L("M02_LeftWhy") },
],
rows,
{ density: "data", empty: L("M02_LostZero") },
),
);
},
});
}
@@ -0,0 +1,21 @@
/* =============================================================================
* M02_MasterTemplete_Hopyo_SkApi.ts
* 마법사 단가산출 길(PLAN 778-5 D-5 · 서버 D-3 `M02_Hopyo_Sangeun.build`) — `POST …/hopyo/{키}/sangeun`
* · 몸 `{확인: [{번, 고름?: {차례: 고름}}]}` — 빈 확인 = 판정만(안 만듦) · 확인한 블록만 단위 SK
* · 답 = 블록마다 줄 판정 · 만듦 · 못만듦(꼴 = `logic/M02_MasterTemplete_Hopyo_SkPick`)
* ========================================================================== */
import { call } from "./M02_MasterTemplete_Api_Fetch";
import { at, type HopyoScope } from "./M02_MasterTemplete_Hopyo_Api";
import type { SkJudged, SkSure } from "./logic/M02_MasterTemplete_Hopyo_SkPick";
/** 줄 판정 · 확인한 블록 옮기기 — 맞춤 셈 전 409 · 틀린 고름 400 · 없는 블록 404 */
export const judgeSangeun = (
scope: HopyoScope,
key: string,
확인: SkSure[] = [],
): Promise<SkJudged> =>
call(at(scope, `/hopyo/${encodeURIComponent(key)}/sangeun`), {
method: "POST",
body: JSON.stringify({ 확인 }),
});
@@ -0,0 +1,163 @@
/* =============================================================================
* M02_MasterTemplete_Hopyo_SkLines.ts
* 마법사 단가산출 블록 펼침 = 줄 표(PLAN 778-5 D-5 · 요구 623 · 624) — 서버 줄 판정(D-3) + 캐시 고름 겹침.
* · 칸 = 묶음 · 이름 · 규격 · 매개값 · 상태 칩(이어짐 · 고를 것 · 못 이음 · 밖 · 뺌 — 엑셀 보기 칠과 한 규칙
* `SheetMap.unitTone`) + 꼬리표(「기본 — 건설업 작업반장」 · 「내가 고름」) · 방법 글 · 우리 금액 · 엑셀 금액 · 차(참고)
* · 고름(밖 줄 빼고) = 서버 후보 단추 · [찾기](`Hopyo_Lost.lineFind` = 찾아 넣기 창) · 율 = 기준 고르기 ·
* 하위 · 값 = 뺌만 · [뺌] + 까닭 · [고름 지움] — 고름은 부르는 쪽 캐시(`onPick`)
* · [보기] = 엑셀 보기 단가산출 상세 그 행(779-4 뛰기)
* 금액 · 판정은 서버 — 고른 줄 금액은 [확인한 것 옮기기] 뒤 서버가 다시 셈(그 전 「—」).
* ========================================================================== */
import { createBadge, type BadgeTone } from "@ui/ui_template_badge";
import { createButton, createInputField, createSelectField, el } from "@ui/ui_template_elements";
import { t as L } from "@ui/ui_template_locale";
import { createTable, type Cell } from "@ui/ui_template_table";
import { formatNumber, formatPrice } from "../common_util/common_util_money";
import type { HopyoScope } from "./M02_MasterTemplete_Hopyo_Api";
import { lineFind } from "./M02_MasterTemplete_Hopyo_Lost";
import { unitTone, type Tone } from "./logic/M02_MasterTemplete_Hopyo_SheetMap";
import {
BASES,
GROUP,
candPick,
candText,
dropOnly,
lineView,
pickable,
type SkBlock,
type SkLine,
type SkPick,
} from "./logic/M02_MasterTemplete_Hopyo_SkPick";
const BADGE: Record<Tone, BadgeTone> = {
success: "success",
warning: "warning",
info: "info",
danger: "danger",
muted: "neutral",
};
const FIND = { L: "노임", X: "기계", M: "자재" } as const;
const won = (v: number | null | undefined): string =>
v === null || v === undefined ? "—" : formatPrice(v);
export interface SkLinesOptions {
scope: HopyoScope;
block: SkBlock;
picks: Record<string, SkPick>;
/** 고름 넣기 · 지움(null) */
onPick: (차례: number, p: SkPick | null) => void;
/** 엑셀 보기 그 행 */
onSheet?: (행: number) => void;
}
/** 상태 칩 + 꼬리표 */
function stateCell(line: SkLine, pick: SkPick | undefined): HTMLElement {
const v = lineView(line, pick);
const diff = !v.mine && line.차 !== null && line.차 !== undefined && Math.abs(line.차) >= 1;
const tone = v.상태 === "뺌" ? "muted" : unitTone(v.상태, diff);
const chip = createBadge(v.상태, BADGE[tone]);
chip.dataset.skState = v.상태;
const tags = v.꼬리표.map((t) => {
const tag = createBadge(t, t === "내가 고름" ? "accent" : "neutral");
tag.dataset.skTag = t;
return tag;
});
return el("div", { className: "ui-toolbar", children: [chip, ...tags] });
}
/** 고름 칸 — 후보 · 찾기 · 율 기준 · 뺌 + 까닭 · 지움 */
function pickCell(opt: SkLinesOptions, line: SkLine, pick: SkPick | undefined): HTMLElement {
const box = el("div", { className: "ui-toolbar", attrs: { "data-sk-pick": String(line.차례) } });
if (!pickable(line)) return box;
const put = (p: SkPick | null): void => opt.onPick(line.차례, p);
if (!dropOnly(line)) {
for (const c of line.후보.slice(0, 3)) {
const p = candPick(line.묶음, c);
if (!p) continue;
const b = createButton({ label: candText(c), size: 24, variant: "ghost", onClick: () => put(p) }); // prettier-ignore
b.dataset.skCand = String(line.차례);
b.title = c.키 ?? c.자재 ?? c.글 ?? "";
box.append(b);
}
const kind = FIND[line.묶음 as keyof typeof FIND];
if (kind) box.append(lineFind({ scope: opt.scope, 갈래: kind, 이름: line.이름 ?? "", 규격: line.규격, done: put })); // prettier-ignore
if (line.묶음 === "R") {
const base = createSelectField({
options: [{ value: "", text: L("M02_SkBasePick") }].concat(
BASES.map((b) => ({ value: b, text: b })),
),
compact: true,
size: 24,
ariaLabel: L("M02_SkBasePick"),
onChange: (v) => v && put({ 기준: v }),
});
box.append(base.root);
}
}
const why = createInputField({ compact: true, size: 24, placeholder: L("M02_SkDropWhy") });
why.input.dataset.skWhy = String(line.차례);
const drop = createButton({
label: L("M02_SkDrop"),
size: 24,
variant: "ghost",
onClick: () => put({ 뺌: why.input.value.trim() || L("M02_SkDropDefault") }),
});
drop.dataset.skDrop = String(line.차례);
box.append(why.root, drop);
if (pick) {
const clear = createButton({ label: L("M02_SkClear"), size: 24, variant: "link", onClick: () => put(null) }); // prettier-ignore
clear.dataset.skClear = String(line.차례);
box.append(clear);
}
return box;
}
/** 블록 줄 표 */
export function skLineTable(opt: SkLinesOptions): HTMLElement {
const rows: Cell[][] = opt.block.줄.map((line) => {
const pick = opt.picks[String(line.차례)];
const v = lineView(line, pick);
const param = Object.entries(line.매개값)
.map(([k, n]) => `${k} ${formatNumber(n)}`)
.join(" · ");
const how = [line.방법, v.글].filter(Boolean).join(" · ");
const sheet =
line.행 !== null && opt.onSheet
? createButton({ label: L("M02_SkSheet"), size: 24, variant: "link", onClick: () => opt.onSheet!(line.행!) }) // prettier-ignore
: "";
if (sheet) sheet.dataset.skSheet = String(line.행);
return [
GROUP[line.묶음] ?? line.묶음,
line.이름 ?? "",
line.규격,
param,
stateCell(line, pick),
how,
v.mine ? "—" : won(line.우리금액),
won(line.엑셀금액),
v.mine || line.차 === null || line.차 === undefined ? "—" : won(line.차),
pickCell(opt, line, pick),
sheet,
];
});
const table = createTable(
[
{ label: L("M02_SkColGroup"), width: "48px" },
{ label: L("M02_SangeunColName") },
{ label: L("M02_LeftSpec") },
{ label: L("M02_SkColParam") },
{ label: L("M02_SkColState") },
{ label: L("M02_SkColHow") },
{ label: L("M02_SkColOurs"), align: "end" },
{ label: L("M02_SkColExcel"), align: "end" },
{ label: L("M02_SkColDiff"), align: "end" },
{ label: L("M02_SkColPick") },
{ label: L("M02_SkSheet"), width: "52px" },
],
rows,
{ density: "data", empty: L("M02_SkNoLines") },
);
table.dataset.skLines = String(opt.block.번);
return table;
}
@@ -5,7 +5,7 @@
* (그 층에 둠 · 같은 엑셀 작업 중 = 그 파일 이어서 · 끝낸 같은 엑셀 = 새로 올림) · 작업 중 파일 이어서
* ② 보기 · 확인 · 만들기 = 엑셀 보기 창(`Hopyo_Sheet` — [모두 고정으로 들여오기] · 호표 [수정] →
* 확인 창 만들기 · [구조물 조합으로] 그대로) · 만들 층 = 고른 층 ·
* 단가산출 체크면 단가산출 칸(`Hopyo_WizardSk` — 기초 · SK · 고름 대기 기초 고름 · 729-3) ·
* 단가산출 체크면 단가산출 칸(`Hopyo_WizardSk` — 블록 줄 고름 · [확인] · 확인한 것만 SK · 778-5 D-5) ·
* 안 이은 줄 띠(`Hopyo_Left` — 안 만든 호표의 남은 줄 · [고르기] = 줄마다 이을 것 · 729-3)
* ③ [끝내기] = 남은 줄 다시 셈 → 있으면 막음(띠 · 남은 수 · 표) · 없으면 물음 → 서버가 이번 HP · 격자 · 맞춤셈 캐시 지움 ·
* 만든 일위대가 · 단가산출(엑셀 사본 걷고 출처만) · 별칭 장부 남음
@@ -38,7 +38,7 @@ import {
type LeftMap,
type LeftScope,
} from "./M02_MasterTemplete_Hopyo_Left";
import { sangeunStep } from "./M02_MasterTemplete_Hopyo_WizardSk";
import { sangeunStep, type SkStep } from "./M02_MasterTemplete_Hopyo_WizardSk";
import { accessFor, type ManageLayer } from "./M02_MasterTemplete_Layer";
const LAYERS: { layer: ManageLayer; text: string }[] = [
@@ -211,6 +211,7 @@ export function buildHopyoWizard(): HopyoWizard {
const band = el("div", { attrs: { "data-hopyo-left": "" } });
let nos: number[] = [];
let map: LeftMap | null = null;
let sk: SkStep | null = null; // 단가산출 칸 — 막힘 글 「확인 안 한 블록 n · 고를 줄 m」
const pick = createButton({
label: L("M02_LeftOpen"),
variant: "ghost",
@@ -222,8 +223,10 @@ export function buildHopyoWizard(): HopyoWizard {
function paint(got: LeftMap | null): void {
map = got;
const n = got ? leftCount(got) : null;
const w = sk?.count();
const skLeft = w?.블록 ? `${L("M02_SkLeft").replace("{n}", String(w.블록)).replace("{m}", String(w.줄))} · ` : ""; // prettier-ignore
finish.disabled = !!n?.줄;
finish.title = n?.줄 ? L("M02_LeftBlocked") : "";
finish.title = n?.줄 ? skLeft + L("M02_LeftBlocked") : "";
band.dataset.left = n ? String(n.줄) : "";
band.replaceChildren(
createBanner({
@@ -231,7 +234,7 @@ export function buildHopyoWizard(): HopyoWizard {
text: !n
? L("M02_LeftUnknown")
: n.줄
? `${L("M02_LeftBand").replace("{n}", String(n.줄)).replace("{h}", String(n.호표))} · ${L("M02_LeftBlocked")}`
? `${skLeft}${L("M02_LeftBand").replace("{n}", String(n.줄)).replace("{h}", String(n.호표))} · ${L("M02_LeftBlocked")}`
: L("M02_LeftZero"),
actions: [pick],
}),
@@ -248,14 +251,12 @@ export function buildHopyoWizard(): HopyoWizard {
}
const back = createButton({ label: "처음으로", variant: "link", onClick: () => void first() });
back.title = "작업은 그대로 보관 — 처음 화면 「작업 중」에서 이어서";
if (wantSangeun) sk = sangeunStep(scope, file.키, file.파일, () => paint(map));
const wrong = file.어긋남 ? ` · 검산 어긋남 ${file.어긋남}` : "";
body.replaceChildren(
createText(`${file.파일} · ${textOf(layer)} 층 · 호표 ${file.호표수}${wrong}`, "muted", 14),
...(wantSangeun
? [
createPanelHead({ title: L("M02_WizSkHead"), level: 4, divider: true }).root,
sangeunStep(scope, file.키).root,
]
? [createPanelHead({ title: L("M02_WizSkHead"), level: 4, divider: true }).root, sk!.root]
: []),
createPanelHead({ title: L("M02_WizIlwiHead"), level: 4, divider: true }).root,
createText(
@@ -1,238 +1,250 @@
/* =============================================================================
* M02_MasterTemplete_Hopyo_WizardSk.ts
* 일위대가 불러오기 마법사 ② 단가산출 단계(PLAN 729-3 · 요구서 내역19 1장 7 · 2장 3) —
* · [단가산출 만들기] = 서버 `sangeun`(산근 블록마다 매칭 → 같으면 기초 · 다르면 SK = 기초 + 엑셀 차이 · 맞는 기초 없음 = 고름 대기)
* · 만든 줄 표 = 블록 · 이름 · 키 · 갈래(기초 · SK 차이 있음) · 이미 만든 블록은 「그대로」 수만
* · 고름 대기 표 = 블록마다 후보(판정 · 글) 고르기 칸 → [고른 기초로 만들기](바탕 = 고른 기초 · 엑셀 품 견줘 SK) ·
* 단위 성격 다른 후보(729-4 `계수`) = 계수 칸(가 1 = 나 몇) 함께 → `환산 {계수}`
* 후보마다 줄 비교 표(746-2 · `Hopyo_Compare` · 서버 `줄비교`) — 고르기 칸 아래 · 후보 단추로 바꿔 봄
* · 못옮김(729-4) = 만든 줄마다 수 · [잇기] = 못옮김 잇기 창(`Hopyo_Lost`) · 만든 줄은 이 칸이 모아 둠(누를 때마다 더함) ·
* 이미 만든 SK 의 남은 못옮김(729-5 `남은못옮김`)도 같은 표
* · 같은 내용(733-1) = 갈래 칸 꼬리표 「같은 것 씀」(이 층 같은 SK 를 씀) · 「위 층에 같은 것 — 연결 권함」(회사 · 마스터 · 알림만) ·
* 수는 요약 글에
* · 별칭 제안 = 이 층 별칭 장부에 남음 · 모두에게 쓰이려면 시스템 관리자가 M01 › 별칭 장부에서 승인(링크 · 716 길 그대로)
* 계산 · 판정은 서버 — 화면은 보이기 · 고름만 · 자동 저장 없음(누를 때만 서버).
* 일위대가 불러오기 마법사 ② 단가산출 칸(PLAN 778-5 D-5 · 요구 621 · 622 · 623 · 624) — 모두 줄로만 · 자동 없음.
* · 열면 서버 줄 판정(`POST …/sangeun` 빈 확인 = 안 만듦 · D-3) → 블록 표: 단산N · 이름 · 판정(「옮길 수 있음」 ·
* 「고를 줄 n」 — 캐시 고름 겹침) · 우리 금액 · 엑셀 금액(참고) · 근거 꼬리표(가까운 기초 이름 · 참고) · 만든 SK ·
* [확인] 칩(고를 줄 0 일 때만 켜짐) · [줄] 펼침 = 줄 표(`Hopyo_SkLines`) · [보기] = 엑셀 보기 그 블록 첫 행
* · 고름 · 확인 = 캐시 `m02-hopyo-sk:{층}:{주인}:{키}`(`SkPick` 꼴) — 누를 때 서버에 안 감 · 자동 저장 없음
* · [확인한 것 옮기기 (n)] = 몸 `{확인: [{번, 고름}]}` → 서버가 M01 지금 값으로 다시 셈 · 단위 SK(확인 안 한 블록 안 만듦) ·
* 만든 블록은 확인에서 뺌 · 못만듦 = 띠(까닭 그대로) · 「모두 확인」 단추 없음
* · `count()` = 아직 안 옮긴 블록 · 고를 줄(마법사 [끝내기] 막힘 글)
* ========================================================================== */
import { ROUTES } from "@config/config_frontend";
import { createBadge } from "@ui/ui_template_badge";
import {
createButton,
createInputField,
createSelectField,
el,
showToast,
} from "@ui/ui_template_elements";
import { createBanner } from "@ui/ui_template_banner";
import { createButton, el, showToast } from "@ui/ui_template_elements";
import { t as L } from "@ui/ui_template_locale";
import { createTable } from "@ui/ui_template_table";
import { tableRow, tableShell, type Column } from "@ui/ui_template_table";
import { createText } from "@ui/ui_template_text";
import { createToggle } from "@ui/ui_template_toggle";
import { formatPrice } from "../common_util/common_util_money";
import type { HopyoScope } from "./M02_MasterTemplete_Hopyo_Api";
import { judgeSangeun } from "./M02_MasterTemplete_Hopyo_SkApi";
import { skLineTable } from "./M02_MasterTemplete_Hopyo_SkLines";
import {
makeSangeun,
type HopyoScope,
type SangeunBuilt,
type SangeunPick,
} from "./M02_MasterTemplete_Hopyo_Api";
import { compareBrief, compareView } from "./M02_MasterTemplete_Hopyo_Compare";
import { openLost } from "./M02_MasterTemplete_Hopyo_Lost";
type Made = SangeunBuilt["만듦"][number];
GOOD,
canConfirm,
picksOf,
readDraft,
setPick,
setSure,
sureBody,
tidy,
verdictOf,
waiting,
type SkBlock,
type SkDraft,
type SkJudged,
} from "./logic/M02_MasterTemplete_Hopyo_SkPick";
const why = (error: unknown): string => (error instanceof Error ? error.message : String(error));
const won = (v: number | null | undefined): string =>
v === null || v === undefined ? "—" : formatPrice(v);
/** 고름 대기 후보 글 — 「이름 [단위] · 판정 · 글」 */
export const candLabel = (c: { 이름: string; 단위: string; 판정: string; 글: string }): string =>
`${c.이름} [${c.단위}] · ${c.판정}${c.글 && c.글 !== c.판정 ? ` · ${c.글}` : ""}`;
export const skDraftKey = (scope: HopyoScope, key: string): string =>
`m02-hopyo-sk:${scope.layer}:${scope.owner ?? ""}:${key}`;
/** 만든 갈래 꼬리표 — 기초 = 초록 · SK = 차이 있음 */
const kindBadge = (kind: string): HTMLElement =>
kind === "SK"
? createBadge(L("M02_WizSkDiff"), "accent")
: createBadge(L("M02_WizSkBase"), "success");
export interface SkStep {
root: HTMLElement;
/** 아직 안 옮긴 블록 · 고를 줄 — 판정 전 null */
count: () => { 블록: number; 줄: number } | null;
}
/** ② 단가산출 단계 칸 — `root` 를 마법사 본 칸에 붙임 */
export function sangeunStep(scope: HopyoScope, key: string): { root: HTMLElement } {
/** ② 단가산출 칸 — `파일` = 엑셀 보기 창 머리 · `onChange` = 판정 · 옮김 뒤(끝내기 글) */
export function sangeunStep(
scope: HopyoScope,
key: string,
파일: string,
onChange?: () => void,
): SkStep {
const dk = skDraftKey(scope, key);
const load = (): SkDraft => readDraft(sessionStorage.getItem(dk));
const save = (d: SkDraft): void => sessionStorage.setItem(dk, JSON.stringify(d));
let got: SkJudged | null = null;
let fails: SkJudged["못만듦"] = [];
const open = new Set<number>(); // 펼친 블록
const out = el("div", { className: "m02-hopyo", attrs: { "data-wiz-sk": "" } });
const make = createButton({
label: L("M02_WizSkMake"),
variant: "filled",
onClick: () => void run(),
});
make.title = L("M02_WizSkMakeTip");
make.dataset.hopyoSangeunMake = "";
const again = createButton({ label: L("M02_SkJudge"), variant: "ghost", size: 28, onClick: () => void judge() }); // prettier-ignore
again.dataset.skJudge = "";
const move = createButton({ label: L("M02_SkMove").replace("{n}", "0"), variant: "filled", size: 28, disabled: true, onClick: () => void run() }); // prettier-ignore
move.title = L("M02_SkMoveTip");
move.dataset.skMove = "";
const root = el("div", {
className: "m02-hopyo",
children: [el("div", { className: "ui-toolbar", children: [make] }), out],
children: [
createText(L("M02_SkHint"), "hint", 14),
out,
el("div", { className: "ui-toolbar", children: [move, again] }),
],
});
const made = new Map<number, Made>(); // 이 칸에서 만든 줄(누를 때마다 더함)
const reused = new Set<number>(); // 733-1 같은 것 씀 블록
const above = new Map<number, string[]>(); // 733-1 위 층 같은 것 「층 키」
async function run(picks?: SangeunPick): Promise<void> {
make.disabled = true;
const sheet = (행: number): void =>
void import("./M02_MasterTemplete_Hopyo_Sheet")
.then((m) =>
m.openHopyoSheet({ scope, key, 파일, canWrite: true, target: scope, 단산행: 행 }),
)
.catch((error: unknown) => showToast(why(error), "error"));
async function judge(): Promise<void> {
again.disabled = true;
out.replaceChildren(createText(L("M02_SkJudging"), "muted", 14));
try {
const got = await makeSangeun(scope, key, picks);
draw(got);
showToast(summary(got), got.고름대기.length ? "warning" : "success");
got = await judgeSangeun(scope, key);
fails = [];
draw();
} catch (error) {
out.replaceChildren(createBanner({ tone: "warning", text: why(error) }));
} finally {
again.disabled = false;
onChange?.();
}
}
async function run(): Promise<void> {
if (!got) return;
const body = sureBody(load(), got.판정);
if (!body.length) return showToast(L("M02_SkSureFirst"), "warning");
move.disabled = true;
try {
const next = await judgeSangeun(scope, key, body);
const made = new Set(next.만듦.map((x) => x.번));
const d = load();
save({ ...d, 확인: d.확인.filter((n) => !made.has(n)) });
got = next;
fails = next.못만듦;
showToast(
L("M02_SkMoved")
.replace("{made}", String(made.size))
.replace("{bad}", String(fails.length)),
fails.length ? "warning" : "success",
);
draw();
} catch (error) {
showToast(why(error), "error");
draw();
} finally {
make.disabled = false;
onChange?.();
}
}
const summary = (got: SangeunBuilt): string =>
L("M02_WizSkSummary")
.replace("{b}", String(got.블록))
.replace("{base}", String(got.기초))
.replace("{sk}", String(got.SK))
.replace("{keep}", String(got.그대로))
.replace("{wait}", String(got.고름대기.length)) +
(got.같은것씀?.length || got.위층같음?.length
? L("M02_WizSkSameSum")
.replace("{same}", String(got.같은것씀?.length ?? 0))
.replace("{above}", String(new Set(got.위층같음?.map((x) => x.번)).size))
: "");
const cols: Column[] = [
{ label: L("M02_SkColBlock"), width: "72px" },
{ label: L("M02_SangeunColName") },
{ label: L("M02_SangeunColUnit"), width: "56px" },
{ label: L("M02_SkColVerdict") },
{ label: L("M02_SkColOurs"), align: "end" },
{ label: L("M02_SkColExcel"), align: "end" },
{ label: L("M02_SkColBasis") },
{ label: L("M02_SkColMade"), width: "110px" },
{ label: L("M02_SkSure"), width: "72px" },
{ label: "", width: "96px" },
];
/** 갈래 칸 — 갈래 + 같은 것 씀 · 위 층 같은 것 꼬리표 */
const kindCell = (x: Made): HTMLElement => {
const tags: HTMLElement[] = [kindBadge(x.갈래)];
if (reused.has(x.번)) {
const tag = createBadge(L("M02_WizSkSame"), "info");
tag.title = L("M02_WizSkSameTip");
tag.dataset.wizSame = String(x.번);
tags.push(tag);
}
const up = above.get(x.번);
if (up?.length) {
const tag = createBadge(L("M02_WizSkAbove"), "warning");
tag.title = up.join(" · ");
tag.dataset.wizAbove = String(x.번);
tags.push(tag);
}
return el("div", { className: "ui-toolbar", children: tags });
};
/** 못옮김 칸 — 수 + [잇기](남은 것 있을 때) */
const lostCell = (x: Made, got: SangeunBuilt): HTMLElement => {
const n = x.못옮김?.length ?? 0;
if (!n) return el("span", { text: "0" });
const go = createButton({
label: `${L("M02_LostOpen")} (${n})`,
/** 블록 한 줄 + 펼친 줄 표 */
function blockRows(b: SkBlock, d: SkDraft): HTMLTableRowElement[] {
const picks = picksOf(d, b.번);
const word = verdictOf(b, picks);
const verdict = createBadge(word, word === GOOD ? "success" : "info");
verdict.dataset.skVerdict = String(b.번);
const basis = b.근거 ? createBadge(`${L("M02_SkBasisTag")} ${b.근거.이름 ?? b.근거.로직}`, "neutral") : "—"; // prettier-ignore
if (basis instanceof HTMLElement) basis.title = `${b.근거!.로직} · ${b.근거!.판정 ?? ""}`;
const made = b.만든 ? createBadge(b.만든, "success") : "—";
const ok = canConfirm(b, picks);
const sure = createToggle({
label: L("M02_SkSure"),
kind: "chip",
size: 24,
onClick: () =>
openLost({ scope, key, 번: x.번, sk: x.키, 이름: x.이름, lost: x.못옮김 ?? [],
done: (next) => draw({ ...got, 만듦: next.만듦, 별칭: got.별칭 }) }), // prettier-ignore
});
go.dataset.wizLost = String(x.번);
return go;
};
function draw(got: SangeunBuilt): void {
for (const x of got.남은못옮김 ?? []) if (!made.has(x.번)) made.set(x.번, { ...x, 갈래: "SK" });
for (const x of got.만듦) made.set(x.번, x);
for (const x of got.같은것씀 ?? []) reused.add(x.번);
for (const x of got.위층같음 ?? []) {
const at = x.층 === "system" ? L("M02_PickerSystem") : L("M02_PickerCompany");
above.set(x.번, [...(above.get(x.번) ?? []), `${at} ${x.키}`]);
}
const table = createTable(
[
{ label: L("M02_WizSkBlock"), width: "80px" },
{ label: L("M02_SangeunColName") },
{ label: L("M02_WizSkKey"), width: "110px" },
{ label: L("M02_WizSkKind"), width: "260px" },
{ label: L("M02_LostCol"), width: "120px" },
],
[...made.values()]
.sort((a, b) => a.번 - b.번)
.map((x) => [`단산${x.번}`, x.이름, x.키, kindCell(x), lostCell(x, got)]),
{ density: "data", empty: L("M02_WizSkNoneMade") },
);
const selects = new Map<number, HTMLSelectElement>();
const coefs = new Map<number, HTMLInputElement>();
const wait = createTable(
[
{ label: L("M02_WizSkBlock"), width: "80px" },
{ label: L("M02_SangeunColName") },
{ label: L("M02_SangeunColUnit"), width: "64px" },
{ label: L("M02_LeftWhy") },
{ label: L("M02_LeftBase"), width: "62%" },
],
got.고름대기.map((x) => {
const pick = createSelectField({
options: [{ value: "", text: L("M02_WizSkNoPick") }].concat(
x.후보.map((c) => ({
value: c.로직,
text: candLabel(c) + (c.줄비교 ? ` · ${compareBrief(c.줄비교)}` : ""),
})),
),
compact: true,
size: 24,
ariaLabel: `${L("M02_LeftBase")} 단산${x.번}`,
});
pick.select.dataset.wizWait = String(x.번);
selects.set(x.번, pick.select);
const coef = createInputField({ type: "number", min: 0, compact: true, size: 24,
placeholder: L("M02_WizSkCoef") }); // prettier-ignore
coef.input.dataset.wizCoef = String(x.번);
const need = (): void => {
const c = x.후보.find((y) => y.로직 === pick.select.value)?.계수;
coef.root.hidden = !c;
coef.input.title = c ? `${c.가} 1 = ${c.나} ?` : "";
};
const cmp = compareView(x.후보);
pick.select.addEventListener("change", () => {
need();
cmp?.show(pick.select.value);
});
need();
coefs.set(x.번, coef.input);
return [`단산${x.번}`, x.이름, x.단위 ?? "", x.까닭, el("div", { children: [el("div", { className: "ui-toolbar", children: [pick.root, coef.root] }), ...(cmp ? [cmp.root] : [])] })]; // prettier-ignore
}),
{ density: "data", empty: L("M02_WizSkNoWait") },
);
const again = createButton({
label: L("M02_WizSkRemake"),
variant: "filled",
size: 28,
onClick: () => {
const picks: SangeunPick = {};
for (const [no, s] of selects) {
if (!s.value) continue;
const c = Number(coefs.get(no)?.value);
const need = got.고름대기.find((x) => x.번 === no)?.후보.find((y) => y.로직 === s.value)?.계수; // prettier-ignore
if (need && !(c > 0)) return showToast(L("M02_WizSkCoefNeed").replace("{n}", String(no)), "warning"); // prettier-ignore
picks[no] = { 로직: s.value, ...(need ? { 환산: { 계수: c } } : {}) };
}
if (!Object.keys(picks).length) return showToast(L("M02_WizSkPickFirst"), "warning");
void run(picks);
checked: d.확인.includes(b.번),
disabled: !ok,
onChange: (on) => {
save(setSure(load(), b, on));
draw();
},
});
again.dataset.wizRemake = "";
const ledger = createButton({
label: L("M02_WizSkLedger"),
variant: "link",
onClick: () => void window.open(`#/${ROUTES.M01_MASTER_DATA}`, "_blank"),
sure.root.dataset.skSure = String(b.번);
sure.root.title = ok ? L("M02_SkSureTip") : L("M02_SkSureOff");
const lines = createButton({
label: `${L("M02_SkOpen")} ${b.줄.length}`,
size: 24,
variant: open.has(b.번) ? "filled" : "ghost",
onClick: () => {
if (open.has(b.번)) open.delete(b.번);
else open.add(b.번);
draw();
},
});
out.replaceChildren(
createText(summary(got), "muted", 14),
table,
...(got.고름대기.length
? [
createText(L("M02_WizSkWaitHint"), "hint", 14),
wait,
el("div", { className: "ui-toolbar", children: [again] }),
]
: []),
el("div", {
className: "ui-toolbar",
children: [
createText(L("M02_WizSkAlias").replace("{n}", String(got.별칭 ?? 0)), "muted", 14),
ledger,
],
}),
);
lines.dataset.skOpen = String(b.번);
const first = b.줄.find((l) => l.행 !== null)?.행 ?? null;
const view = first !== null ? createButton({ label: L("M02_SkSheet"), size: 24, variant: "link", onClick: () => sheet(first) }) : ""; // prettier-ignore
const head = tableRow(cols, [
`단산${b.번}`,
b.이름 ?? "",
b.단위 ?? "",
verdict,
won(b.우리금액),
won(b.엑셀금액),
basis,
made,
sure.root,
el("div", { className: "ui-toolbar", children: [lines, view] }),
]);
head.dataset.skBlock = String(b.번);
if (!open.has(b.번)) return [head];
const table = skLineTable({
scope,
block: b,
picks,
onPick: (at, p) => {
save(tidy(setPick(load(), b.번, at, p), got!.판정));
draw();
},
onSheet: sheet,
});
const sub = tableRow(cols, [{ content: table, colSpan: cols.length }]);
sub.className = "is-sub";
return [head, sub];
}
return { root };
function draw(): void {
if (!got) return;
const d = tidy(load(), got.판정);
save(d);
const blocks = got.판정;
const good = blocks.filter((b) => canConfirm(b, picksOf(d, b.번))).length;
const made = blocks.filter((b) => b.만든).length;
const sum = L("M02_SkSummary")
.replace("{b}", String(blocks.length))
.replace("{ok}", String(good))
.replace("{wait}", String(blocks.length - good))
.replace("{made}", String(made))
.replace("{sure}", String(d.확인.length));
const { root: table, tbody } = tableShell(cols, { density: "data" });
for (const b of blocks) tbody.append(...blockRows(b, d));
const band = fails.length
? [
createBanner({
tone: "warning",
text: L("M02_SkFailed").replace("{n}", String(fails.length)),
details: fails.map((x) => `단산${x.번} ${x.이름 ?? ""} — ${x.까닭}`).join("\n"),
}),
]
: [];
out.replaceChildren(
createText(sum, "muted", 14),
...band,
blocks.length ? table : createText(L("M02_SkNone"), "muted", 14),
);
move.disabled = !d.확인.length;
move.querySelector(".ui-btn__label")!.textContent = L("M02_SkMove").replace(
"{n}",
String(d.확인.length),
);
onChange?.();
}
void judge();
return {
root,
count: () => (got ? waiting(load(), got.판정) : null),
};
}
@@ -0,0 +1,223 @@
/* =============================================================================
* M02_MasterTemplete/logic/M02_MasterTemplete_Hopyo_SkPick.ts
* 마법사 단가산출 줄 고름(PLAN 778-5 D-5 · 요구 621 · 622 · 623 · 624) — 순수 셈.
* · 재료 = 서버 `POST …/hopyo/{키}/sangeun` 답 `판정 [{번 … 줄 […]}]`(D-3 `M02_Hopyo_Sangeun.build`)
* · 고름 캐시 `{고름 {번: {차례: 고름}}, 확인 [번]}` — 줄 고름이 서버 상태를 덮음(꼬리표 「내가 고름」)
* · 블록 [확인] = 남은 고를 줄 0 일 때만 · 몸 = `{확인: [{번, 고름?}]}`(확인한 블록만 · 자동 확인 없음)
* · 금액 · 판정 정본은 서버 — 여기는 캐시를 겹쳐 보이기 · 몸 짓기만
* 화면 · fetch · sessionStorage 없음(`CLAUDE.md` 4장).
* ========================================================================== */
export type SkGroup = "L" | "X" | "M" | "R" | "S" | "E";
/** 줄 고름 — 노임 LB · 기계 EQ · 자재 MT · 율 기준 · 뺌 + 까닭 */
export type SkPick =
| { 노임: string }
| { 기계: string }
| { 자재: string; 별도계상?: boolean }
| { 기준: SkBase; 비목?: string }
| { 뺌: string };
/** 율 기준 — 묶음 · 비목 · 줄 목록(그 엑셀 행 줄) */
export type SkBase = string | { 행: number[] };
/** 서버 후보 — 노임 · 기계 = `키` · 자재 = `자재` · 율 = `기준` */
export interface SkCand {
키?: string;
자재?: string;
기준?: SkBase;
비목?: string;
갈래?: string;
이름?: string | null;
규격?: string;
단위?: string;
구분?: string;
단가?: number | null;
글?: string;
}
export interface SkLine {
차례: number;
행: number | null;
묶음: SkGroup;
이름: string | null;
규격: string;
매개값: Record<string, number>;
엑셀금액: number | null;
상태: string;
방법: string;
단위: unknown;
후보: SkCand[];
꼬리표: string[];
글: string;
우리금액?: number | null;
차?: number | null;
}
export interface SkBlock {
번: number;
이름: string | null;
단위: string | null;
판정: string;
고를줄: number;
우리금액: number | null;
엑셀금액: number | null;
근거: { 로직: string; 이름: string | null; 판정: string | null } | null;
만든: string | null;
줄: SkLine[];
}
export interface SkJudged {
블록: number;
판정: SkBlock[];
만듦: { 번: number; 이름: string | null; 키: string; 갈래: string; 못옮김: unknown[] }[];
못만듦: { 번: number; 이름: string | null; 까닭: string }[];
그대로: number;
같은것씀: { 번: number; 키: string }[];
위층같음: { 번: number; 층: string; 주인: string | null; 키: string }[];
}
/** 확인 목록 한 칸(서버 몸) */
export interface SkSure {
번: number;
고름?: Record<number, SkPick>;
}
/** 캐시 꼴 — 블록 번 · 줄 차례는 JSON 열쇠라 글 */
export interface SkDraft {
고름: Record<string, Record<string, SkPick>>;
확인: number[];
}
export const MINE = "내가 고름";
export const GOOD = "옮길 수 있음";
export const LINKED = "이어짐";
export const PICK = "고를 것";
export const OUT = "밖";
export const DROP = "뺌";
export const GROUP: Record<SkGroup, string> = {
L: "노무",
X: "기계",
M: "자재",
R: "율",
S: "하위",
E: "값",
};
/** 율 기준 고르기 칸 */
export const BASES = ["인력", "재료", "노무비", "재료비", "경비"];
const DONE = new Set([LINKED, OUT, DROP]);
export const emptyDraft = (): SkDraft => ({ 고름: {}, 확인: [] });
/** 캐시 글 → 꼴(틀리면 빈 것) */
export function readDraft(raw: string | null): SkDraft {
try {
const got = JSON.parse(raw ?? "") as Partial<SkDraft> | null;
const picks = got?.고름 && typeof got.고름 === "object" ? got.고름 : {};
const sure = Array.isArray(got?.확인) ? got.확인.filter((n) => Number.isInteger(n)) : [];
return { 고름: picks, 확인: sure };
} catch {
return emptyDraft();
}
}
/** 고른 것 글 — 키 · 율 기준 · 뺌 까닭 */
export function pickText(p: SkPick): string {
if ("뺌" in p) return `${DROP} · ${p.뺌}`;
if ("기준" in p) {
const base = typeof p.기준 === "string" ? p.기준 : `${p.기준.행.join(" · ")} 행`;
return `기준 ${base}${p.비목 ? ` · ${p.비목}` : ""}`;
}
return "노임" in p ? p.노임 : "기계" in p ? p.기계 : p.자재;
}
/** 줄 보이기 — 캐시 고름이 서버 상태를 덮음 */
export function lineView(
line: SkLine,
pick: SkPick | undefined,
): { 상태: string; 꼬리표: string[]; 글: string; mine: boolean } {
if (!pick) return { 상태: line.상태, 꼬리표: line.꼬리표, 글: line.글, mine: false };
return { 상태: "뺌" in pick ? DROP : LINKED, 꼬리표: [MINE], 글: pickText(pick), mine: true };
}
/** 그 블록 캐시 고름 `{차례: 고름}` */
export const picksOf = (d: SkDraft, 번: number): Record<string, SkPick> => d.고름[String(번)] ?? {};
/** 남은 고를 줄 수 — 이어짐 · 밖 · 뺌 아닌 줄(캐시 고름 겹침) */
export const leftLines = (b: SkBlock, picks: Record<string, SkPick>): number =>
b.줄.filter((l) => !DONE.has(lineView(l, picks[String(l.차례)]).상태)).length;
/** [확인] 켜짐 — 고를 줄 0 */
export const canConfirm = (b: SkBlock, picks: Record<string, SkPick>): boolean =>
leftLines(b, picks) === 0;
/** 블록 판정 글 — 캐시 겹친 뒤 「옮길 수 있음」 · 「고를 줄 n」 */
export function verdictOf(b: SkBlock, picks: Record<string, SkPick>): string {
const n = leftLines(b, picks);
return n ? `고를 줄 ${n}` : GOOD;
}
/** 줄 고를 수 있나 — 밖 줄 = 아님 · 하위 · 값 = 뺌만 */
export const pickable = (l: SkLine): boolean => l.상태 !== OUT;
export const dropOnly = (l: SkLine): boolean => l.묶음 === "S" || l.묶음 === "E";
/** 서버 후보 → 고름(그 줄 묶음 꼴 · 아니면 null) */
export function candPick(group: SkGroup, c: SkCand): SkPick | null {
if (group === "L" && c.키?.startsWith("LB")) return { 노임: c.키 };
if (group === "X" && c.키?.startsWith("EQ")) return { 기계: c.키 };
if (group === "M" && c.자재) return { 자재: c.자재 };
if (group === "R" && c.기준 !== undefined && c.기준 !== null)
return { 기준: c.기준, ...(c.비목 ? { 비목: c.비목 } : {}) };
return null;
}
/** 후보 글 — 「이름 규격 [단위]」 · 율 = 기준 */
export function candText(c: SkCand): string {
if (c.기준 !== undefined) return pickText({ 기준: c.기준, ...(c.비목 ? { 비목: c.비목 } : {}) });
const spec = c.규격 ? ` ${c.규격}` : "";
const unit = c.단위 ? ` [${c.단위}]` : "";
return `${c.이름 ?? c.키 ?? c.자재 ?? ""}${spec}${unit}${c.구분 ? ` · ${c.구분}` : ""}`;
}
/** 캐시 정리 — 고를 줄 남은 블록 · 이미 옮겨 판정 없는 블록은 확인에서 뺌 */
export function tidy(d: SkDraft, blocks: SkBlock[]): SkDraft {
const by = new Map(blocks.map((b) => [b.번, b]));
const sure = d.확인.filter((n) => {
const b = by.get(n);
return !!b && canConfirm(b, picksOf(d, n));
});
return { ...d, 확인: [...new Set(sure)].sort((a, b) => a - b) };
}
/** [확인한 것 옮기기] 몸 — 확인한 블록만 · 고름은 그 블록 줄에 있는 차례만 */
export function sureBody(d: SkDraft, blocks: SkBlock[]): SkSure[] {
const by = new Map(blocks.map((b) => [b.번, b]));
return tidy(d, blocks).확인.map((n) => {
const have = new Set(by.get(n)!.줄.map((l) => l.차례));
const picks = Object.entries(picksOf(d, n)).filter(([at]) => have.has(Number(at)));
return picks.length
? { 번: n, 고름: Object.fromEntries(picks.map(([at, p]) => [Number(at), p])) }
: { 번: n };
});
}
/** 마법사 [끝내기] 글 재료 — 아직 안 옮긴 블록 수 · 그 블록 고를 줄 합 */
export function waiting(d: SkDraft, blocks: SkBlock[]): { 블록: number; 줄: number } {
const open = blocks.filter((b) => !b.만든);
return { 블록: open.length, 줄: open.reduce((n, b) => n + leftLines(b, picksOf(d, b.번)), 0) };
}
/** 줄 고름 넣기 · 지움(null) — 새 캐시 */
export function setPick(d: SkDraft, 번: number, 차례: number, p: SkPick | null): SkDraft {
const mine = { ...picksOf(d, 번) };
if (p) mine[String(차례)] = p;
else delete mine[String(차례)];
return { ...d, 고름: { ...d.고름, [String(번)]: mine } };
}
/** [확인] 켬 · 끔 — 고를 줄 남으면 안 켬 */
export function setSure(d: SkDraft, b: SkBlock, on: boolean): SkDraft {
const rest = d.확인.filter((n) => n !== b.번);
const ok = on && canConfirm(b, picksOf(d, b.번));
return { ...d, 확인: ok ? [...rest, b.번].sort((x, y) => x - y) : rest };
}
+1 -9
View File
@@ -1,4 +1,4 @@
/* 729-3 안 이은 줄 화면 헬퍼 — `Hopyo_Left` · `Hopyo_Check` · `Api_Fetch` · `Labor_Unlinked` · `Hopyo_WizardSk` 도우미를
/* 729-3 안 이은 줄 화면 헬퍼 — `Hopyo_Left` · `Hopyo_Check` · `Api_Fetch` · `Labor_Unlinked` 도우미를
* 트랜스파일해 Node 로 돌림 · fetch 대역(부른 길 · 몸 모음 · 409 몸) · sessionStorage 대역 ·
* stdin = {one, made, left, fixes} · stdout = JSON. */
const fs = require("fs");
@@ -54,7 +54,6 @@ global.document = { dispatchEvent: () => true }; // 캐시 고침 알림(779-4)
const left = loadTs(path.join(M02, "M02_MasterTemplete_Hopyo_Left.ts"));
const api = loadTs(path.join(M02, "M02_MasterTemplete_Hopyo_Api.ts"));
const grid = loadTs(path.join(M02, "M02_MasterTemplete_Labor_Unlinked.ts"));
const sk = loadTs(path.join(M02, "M02_MasterTemplete_Hopyo_WizardSk.ts"));
const scope = { layer: "personal", owner: null };
const at = { scope, target: scope, key: "HP-000001" };
@@ -87,13 +86,6 @@ global.document = { dispatchEvent: () => true }; // 캐시 고침 알림(779-4)
unlinkedCalls: calls.filter((x) => x.url.includes("/hopyo-unlinked")).map((x) => x.url),
lastBody: calls.filter((x) => x.url.includes("/hopyo-unlinked")).pop().body,
fixes: grid.unlinkedFixes({ 담은로직: input.fixes }).map((x) => x.이름),
cand: sk.candLabel({ 이름: "되메우기", 단위: "㎥", 판정: "후보", 글: "후보" }),
cand2: sk.candLabel({
이름: "되메우기",
단위: "㎥",
판정: "다른 점",
글: "보통인부 품 다름",
}),
}),
);
})().catch((error) => {
+84
View File
@@ -0,0 +1,84 @@
/* 778-5 D-5 마법사 단가산출 줄 고름 헬퍼 — `logic/Hopyo_SkPick`(순수) · `Hopyo_SkApi`(몸) 를 트랜스파일해 Node 로 돌림 ·
* fetch 대역(부른 길 · 몸 모음) · stdin = {blocks} · stdout = JSON. */
const fs = require("fs");
const path = require("path");
const { makeLoader } = require("./helper_ts_stub_loader.cjs");
const ROOT = path.join(__dirname, "..", "..");
const STUBS = { "@config/config_frontend": { API_BASE_URL: "/api", ROUTES: {} } };
const stub = new Proxy({}, { get: () => () => {} });
const { loadTs } = makeLoader({
root: ROOT,
requireFor: (full) => (request) => {
if (STUBS[request]) return STUBS[request];
if (request.startsWith(".") && !request.endsWith(".css"))
return loadTs(`${path.resolve(path.dirname(full), request)}.ts`);
return stub;
},
});
global.sessionStorage = { getItem: () => null, setItem: () => {}, removeItem: () => {} };
(async () => {
const { blocks } = JSON.parse(fs.readFileSync(0, "utf8"));
const calls = [];
global.fetch = async (url, init = {}) => {
calls.push({
url,
method: init.method ?? "GET",
body: init.body ? JSON.parse(init.body) : null,
});
return { status: 200, ok: true, json: async () => ({ 판정: blocks, 만듦: [], 못만듦: [] }) };
};
const M02 = path.join(ROOT, "M02_MasterTemplete");
const sk = loadTs(path.join(M02, "logic", "M02_MasterTemplete_Hopyo_SkPick.ts"));
const api = loadTs(path.join(M02, "M02_MasterTemplete_Hopyo_SkApi.ts"));
const [a, b, c] = blocks; // a = 고를 줄 2 · b = 옮길 수 있음 · c = 못 이음(하위)
const out = {};
let d = sk.emptyDraft();
out.start = blocks.map((x) => ({
번: x.번,
판정: sk.verdictOf(x, {}),
확인켜짐: sk.canConfirm(x, {}),
}));
out.emptyBody = sk.sureBody(d, blocks); // 아무것도 안 눌렀으면 몸 비움(자동 확인 없음)
d = sk.setSure(d, a, true); // 고를 줄 남은 블록 = 안 켜짐
out.sureBlocked = d.확인;
d = sk.setPick(d, a.번, 1, { 노임: "LB000099" }); // 작업반장 → 다른 노임
out.half = {
판정: sk.verdictOf(a, sk.picksOf(d, a.번)),
켜짐: sk.canConfirm(a, sk.picksOf(d, a.번)),
};
d = sk.setPick(d, a.번, 2, { 자재: "MT000123" });
d = sk.setPick(d, a.번, 3, { 뺌: "우리에 없음" });
out.view = a.줄.map((l) => sk.lineView(l, sk.picksOf(d, a.번)[String(l.차례)]));
out.full = {
판정: sk.verdictOf(a, sk.picksOf(d, a.번)),
켜짐: sk.canConfirm(a, sk.picksOf(d, a.번)),
};
d = sk.setSure(d, a, true);
d = sk.setSure(d, b, true);
d = sk.setSure(d, c, true); // 못 이음 남음 = 안 켜짐
out.sure = d.확인;
out.body = sk.sureBody(d, blocks);
out.wait = sk.waiting(d, blocks);
// 고름 지우면 확인에서 빠짐(tidy)
out.tidy = sk.tidy(sk.setPick(d, a.번, 2, null), blocks).확인;
// 캐시 글 왕복 · 틀린 글
out.round = sk.readDraft(JSON.stringify(d));
out.bad = sk.readDraft("{엉터리");
out.cand = {
L: sk.candPick("L", { 키: "LB000001", 이름: "작업반장" }),
X: sk.candPick("X", { 키: "EQ000019", 이름: "굴착기" }),
M: sk.candPick("M", { 자재: "MT000001", 이름: "시멘트" }),
R: sk.candPick("R", { 기준: "노무비", 비목: "경비" }),
Mbad: sk.candPick("M", { 키: "LB000001" }),
text: sk.candText({ 이름: "합판", 규격: "12mm", 단위: "장" }),
};
await api.judgeSangeun({ layer: "personal", owner: null }, "HP-000001");
await api.judgeSangeun({ layer: "personal", owner: null }, "HP-000001", out.body);
out.calls = calls;
process.stdout.write(JSON.stringify(out));
})().catch((error) => {
process.stderr.write(String(error?.stack ?? error));
process.exit(1);
});
@@ -84,8 +84,6 @@ def test_남은_줄_고름_몸_막힘_판정():
assert got["lastBody"]["줄"] == got["body"] and got["lastBody"]["번"] == 2
# 일위대가표 띠 = 이음 · 비율 없는 고정 줄만
assert got["fixes"] == ["안이음"]
assert got["cand"] == "되메우기 [㎥] · 후보"
assert got["cand2"] == "되메우기 [㎥] · 다른 점 · 보통인부 품 다름"
def _text(name: str) -> str:
@@ -98,15 +96,13 @@ def test_마법사_끝내기_막음_단가산출_칸_목록_열():
assert end.index("await recount()") < end.index("showConfirmDialog(") # 끝내기 전 다시 셈
assert "if (leftCount(got).줄)" in end and "openLeft(" in end # 남으면 막음 · 표
assert "finish.disabled = !!n?.줄;" in wizard # 띠 · 남은 수 · [끝내기] 막음
assert "sangeunStep(scope, file.키)" in wizard and "/sangeun`" not in wizard
sk = _text("M02_MasterTemplete_Hopyo_WizardSk.ts")
assert "makeSangeun(scope, key, picks)" in sk
assert "picks[no] = { 로직: s.value, ...(need ? { 환산: { 계수: c } } : {}) };" in sk # 계수 칸
lost = _text(
"M02_MasterTemplete_Hopyo_Lost.ts"
) # 못옮김 잇기 = 캐시 모두 · 바탕 = SK 문서 바탕
assert "const links = Object.values(loadLinks(k));" in lost and "잇기: links," in lost
assert "로직: doc.바탕.로직, 입력값: doc.입력값 ?? {}" in lost
assert "sangeunStep(scope, file.키, file.파일" in wizard and "/sangeun`" not in wizard
sk = _text(
"M02_MasterTemplete_Hopyo_WizardSk.ts"
) # 778-5 D-5 — 줄 고름 · 확인한 것만(옛 기초 고름 걷음)
assert "judgeSangeun(scope, key, body)" in sk
lost = _text("M02_MasterTemplete_Hopyo_Lost.ts") # 옛 못옮김 창 = 보기만(옛 `바탕` 몸 걷음)
assert "makeSangeun" not in lost and "loadLinks" not in lost
assert "row.못옮김?.length && row.단산 !== undefined && row.SK" in _text(
"M02_MasterTemplete_Hopyo_Left.ts"
)
@@ -1,6 +1,6 @@
"""746-2 지킴 — 불러오기 고르기 창 후보 블록 줄 비교 · 요소 후보 화면(요구 585).
서버 없이 소스로 잡음: 고름 대기 칸이 줄 비교 표를 붙임 · 못옮김 창 · 안 이은 줄 창이 요소 후보 단추를 붙임 ·
서버 없이 소스로 잡음: 안 이은 줄 창이 요소 후보 단추를 붙임(고름 대기 줄 비교 · 못옮김 잇기는 778-5 D-5 줄 고름으로 걷음) ·
안 이은 줄 창의 기초 후보 칸에 요소 후보(`키`)가 섞여 들어가지 않음 · 서버 응답 칸 이름 · 부품 상자만 · 700 줄.
"""
@@ -14,14 +14,7 @@ def _src(name: str) -> str:
return (M02 / f"M02_MasterTemplete_Hopyo_{name}.ts").read_text(encoding="utf-8")
def test_고름_대기_칸이_줄_비교_표를_붙임():
sk = _src("WizardSk")
assert "compareView(x.후보)" in sk and "cmp?.show(pick.select.value)" in sk
assert "compareBrief(c.줄비교)" in sk # 선택 칸 글에 같음 · 품다름 수
def test_못옮김_창_안_이은_줄_창이_요소_후보_단추를_붙임():
assert "elemChoices(x.후보" in _src("Lost")
def test_안_이은_줄_창이_요소_후보_단추를_붙임():
left = _src("Left")
assert "elemChoices(row.후보.filter(isElem)" in left
assert (
@@ -31,14 +24,8 @@ def test_못옮김_창_안_이은_줄_창이_요소_후보_단추를_붙임():
def test_서버_응답_칸_이름_그대로():
api = _src("Api")
for name in (
"줄비교?: SangeunCompare | null",
"엑셀: number | null",
"기초: number | null",
"못옮김: SangeunLost[]",
):
assert name in api
assert 'CompareLook = "같음" | "품다름" | "엑셀만" | "기초만"' in api
assert "못옮김: SangeunLost[]" in api and "export interface ElemCand" in api
assert "SangeunCompare" not in api and "export function compareView" not in _src("Compare")
def test_부품_상자만_날_요소_없음_700줄_안():
@@ -0,0 +1,128 @@
"""778-5 D-5 지킴 — 마법사 단가산출 줄 고름(제안 · 확인 뒤 옮김 · 요구 621 · 622 · 623 · 624).
서버 없이 잡음: 줄 고름 캐시가 서버 상태를 덮음(「내가 고름」) · [확인] = 고를 줄 0 일 때만 · 몸 = 이음 줄
`{확인: [{번, 고름?: {차례: 고름}}]}`(확인한 블록만) · 자동 확인 없음(빈 캐시 = 빈 몸 · 「모두 확인」 단추 없음) ·
화면 = 부품 상자만 · 700 줄.
"""
import json
import os
import re
import subprocess
from pathlib import Path
HERE = os.path.dirname(__file__)
M02 = Path(__file__).resolve().parents[2] / "M02_MasterTemplete"
def _line(at, kind, state, **more):
row = {"차례": at, "행": 100 + at, "묶음": kind, "이름": f"줄{at}", "규격": "", "매개값": {}}
row |= {"엑셀금액": 1000.0, "상태": state, "방법": "", "단위": None, "후보": [], "꼬리표": []}
return {**row, "글": "", "우리금액": None, "차": None, **more}
def _block(no, lines, made=None):
head = {"번": no, "이름": f"블록{no}", "단위": "㎥", "판정": "", "고를줄": 0, "우리금액": None}
return head | {"엑셀금액": 5000.0, "근거": None, "만든": made, "줄": lines}
BOSS = ["기본 — 건설업 작업반장"]
BLOCKS = [
_block(
2,
[
_line(0, "L", "이어짐", 매개값={"품": 0.2}),
_line(1, "L", "이어짐", 꼬리표=BOSS, 후보=[{"키": "LB000099"}]),
_line(2, "M", "고를 것", 후보=[{"자재": "MT000123"}]),
_line(3, "M", "못 이음"),
_line(4, "R", "밖"),
],
),
_block(5, [_line(0, "X", "이어짐"), _line(1, "R", "이어짐")]),
_block(7, [_line(0, "S", "못 이음")], made=None),
]
def _run() -> dict:
proc = subprocess.run(
["node", os.path.join(HERE, "helper_778_5_sk_pick.cjs")],
input=json.dumps({"blocks": BLOCKS}, ensure_ascii=False),
capture_output=True,
text=True,
encoding="utf-8",
check=False,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
def test_판정_확인_켜짐_조건():
got = _run()
assert got["start"] == [
{"번": 2, "판정": "고를 줄 2", "확인켜짐": False},
{"번": 5, "판정": "옮길 수 있음", "확인켜짐": True},
{"번": 7, "판정": "고를 줄 1", "확인켜짐": False},
]
assert got["sureBlocked"] == [] # 고를 줄 남은 블록 = [확인] 안 켜짐
assert got["half"] == {"판정": "고를 줄 2", "켜짐": False} # 이어진 줄 바꿈은 고를 줄 안 줄임
assert got["full"] == {"판정": "옮길 수 있음", "켜짐": True}
assert got["sure"] == [2, 5] # 하위 못 이음 블록 7 = 안 켜짐
def test_줄_표_꼴_내가_고름이_덮음():
view = _run()["view"]
assert view[0] == {"상태": "이어짐", "꼬리표": [], "글": "", "mine": False}
assert view[1] == {"상태": "이어짐", "꼬리표": ["내가 고름"], "글": "LB000099", "mine": True}
assert view[2]["상태"] == "이어짐" and view[2]["글"] == "MT000123"
assert view[3] == {
"상태": "뺌",
"꼬리표": ["내가 고름"],
"글": "뺌 · 우리에 없음",
"mine": True,
}
assert view[4]["상태"] == "밖"
def test_몸_꼴_이음_줄_자동_확인_없음():
got = _run()
assert got["emptyBody"] == [] # 누른 것 없음 = 몸 비움
picks = {"1": {"노임": "LB000099"}, "2": {"자재": "MT000123"}, "3": {"뺌": "우리에 없음"}}
assert got["body"] == [{"번": 2, "고름": picks}, {"번": 5}]
first, second = got["calls"]
assert first["method"] == "POST" and first["body"] == {"확인": []} # 판정만
assert first["url"].endswith("/layers/personal/hopyo/HP-000001/sangeun")
assert second["body"] == {"확인": got["body"]}
assert got["wait"] == {"블록": 3, "줄": 1} # 안 옮긴 블록 셋 · 블록 7 하위 줄
assert got["tidy"] == [5] # 고름 지우면 그 블록 확인 빠짐
assert got["round"]["확인"] == [2, 5] and got["bad"] == {"고름": {}, "확인": []}
def test_후보_고름_꼴():
cand = _run()["cand"]
assert cand["L"] == {"노임": "LB000001"} and cand["X"] == {"기계": "EQ000019"}
assert cand["M"] == {"자재": "MT000001"} and cand["R"] == {"기준": "노무비", "비목": "경비"}
assert cand["Mbad"] is None and cand["text"] == "합판 12mm [장]"
def _src(name: str) -> str:
return (M02 / f"M02_MasterTemplete_Hopyo_{name}.ts").read_text(encoding="utf-8")
def test_화면_자동_없음_부품_상자만():
wiz = _src("WizardSk")
assert "judgeSangeun(scope, key)" in wiz # 열 때 = 판정만
assert "judgeSangeun(scope, key, body)" in wiz and "sureBody(load(), got.판정)" in wiz
assert "모두" not in wiz.split("*/", 1)[1] and "setSure(" in wiz # 확인은 블록마다만
assert 'kind: "chip"' in wiz and "disabled: !ok" in wiz # [확인] 칩 · 고를 줄 0 일 때만
assert "단산행: 행" in wiz # 엑셀 보기 뛰기
lines = _src("SkLines")
assert "lineFind(" in lines and "unitTone(" in lines # 찾기 창 · 엑셀 칠과 한 규칙
wizard = _src("Wizard")
assert 'L("M02_SkLeft")' in wizard and "sk?.count()" in wizard # [끝내기] 막힘 글
for name in ("WizardSk", "SkLines", "SkApi", "Lost", "Compare"):
src = _src(name)
assert len(src.splitlines()) <= 700, name
assert not re.search(r'el\("(button|input|select)"|<button|<input', src), name
assert not re.search(r"#[0-9a-fA-F]{3,6}\b|rgb\(", src), name
logic = (M02 / "logic" / "M02_MasterTemplete_Hopyo_SkPick.ts").read_text(encoding="utf-8")
assert not re.search(r"sessionStorage\.|fetch\(|document\.", logic) # 순수
+1 -1
View File
@@ -361,7 +361,7 @@ def test_좌측_판_자리와_권한():
assert "accessFor(layer, null, user).canWrite" in wizard # 결과 층 = 쓸 수 있는 층만
assert 'let pick: ManageLayer = "personal";' in wizard # 기본 개인
assert (
"let wantSangeun = false;" in wizard and "sangeunStep(scope, file.키)" in wizard
"let wantSangeun = false;" in wizard and "sangeunStep(scope, file.키, file.파일" in wizard
) # 결과 단가산출 체크 → 단가산출 칸(729-3)
+54 -66
View File
@@ -231,47 +231,60 @@ export const ui_locales_m2 = {
M02_FindElementLater: ["일위대가 표 개편 뒤", "After the unit-cost table rework"],
M02_FindOptions: ["옵션 {n}", "{n} options"],
// 729-3 불러오기 마법사 — 단가산출 단계 · 안 이은 줄 · 일위대가표 띠
M02_WizSkCheck: ["단가산출(산근 블록 → 기초 · SK)", "Unit-cost calcs (blocks → base · SK)"],
M02_WizSkCheck: [
"단가산출(블록 줄 → 확인한 것만 SK)",
"Unit-cost calcs (lines → confirmed only)",
],
// 778-5 D-5 마법사 단가산출 줄 고름(제안 · 확인 뒤 옮김)
M02_SkHint: [
"블록마다 줄을 보고 고를 줄을 고른 뒤 [확인] → [확인한 것 옮기기] · 자동으로 옮기지 않음 · 금액 = M01 지금 값(엑셀 금액은 참고)",
"Review each block, pick open lines, confirm, then move · nothing moves automatically · amounts = current M01 (Excel for reference)",
],
M02_SkSummary: [
"블록 {b} · 옮길 수 있음 {ok} · 고를 줄 있음 {wait} · 옮김(SK) {made} · 확인 {sure}",
"Blocks {b} · ready {ok} · open lines {wait} · moved (SK) {made} · confirmed {sure}",
],
M02_SkJudge: ["다시 판정", "Re-check"],
M02_SkJudging: ["줄 판정 중…", "Checking lines…"],
M02_SkMove: ["확인한 것 옮기기 ({n})", "Move confirmed ({n})"],
M02_SkMoveTip: [
"확인한 블록만 단위 SK 로 — 서버가 M01 지금 값으로 다시 셈",
"Only confirmed blocks become SKs — the server recalculates with current M01",
],
M02_SkMoved: ["옮김 {made} · 못 옮김 {bad}", "Moved {made} · not moved {bad}"],
M02_SkFailed: ["못 옮긴 블록 {n} — 까닭 펼쳐 봄", "{n} blocks not moved — see why"],
M02_SkSureFirst: ["확인한 블록 없음 — 블록마다 [확인]", "No confirmed block yet"],
M02_SkSure: ["확인", "Confirm"],
M02_SkSureTip: ["이 블록을 옮길 목록에 넣음", "Add this block to the move list"],
M02_SkSureOff: ["고를 줄이 남음 — 줄 표에서 먼저 고름", "Lines still open — pick them first"],
M02_SkOpen: ["줄", "Lines"],
M02_SkSheet: ["보기", "View"],
M02_SkNone: ["단가산출 블록 없음", "No unit-cost blocks"],
M02_SkNoLines: ["줄 없음", "No lines"],
M02_SkBasisTag: ["근거(참고) —", "Basis (ref) —"],
M02_SkLeft: [
"단가산출 확인 안 한 블록 {n} · 고를 줄 {m}",
"Unit-cost blocks not confirmed {n} · open lines {m}",
],
M02_SkColBlock: ["블록", "Block"],
M02_SkColVerdict: ["판정", "Verdict"],
M02_SkColOurs: ["우리 금액", "Our amount"],
M02_SkColExcel: ["엑셀 금액(참고)", "Excel amount (ref)"],
M02_SkColBasis: ["근거", "Basis"],
M02_SkColMade: ["만든 SK", "SK made"],
M02_SkColGroup: ["묶음", "Group"],
M02_SkColParam: ["매개값", "Value"],
M02_SkColState: ["상태", "State"],
M02_SkColHow: ["방법", "Method"],
M02_SkColDiff: ["차(참고)", "Diff (ref)"],
M02_SkColPick: ["고름", "Pick"],
M02_SkBasePick: ["기준 고름", "Pick base"],
M02_SkDrop: ["뺌", "Drop"],
M02_SkDropWhy: ["까닭", "Reason"],
M02_SkDropDefault: ["우리에 없음", "Not in ours"],
M02_SkClear: ["고름 지움", "Clear"],
M02_WizSkHead: ["단가산출", "Unit-cost calcs"],
M02_WizIlwiHead: ["일위대가", "Unit-price sheets"],
M02_WizSkMake: ["단가산출 만들기", "Make unit-cost calcs"],
M02_WizSkMakeTip: [
"산근 블록마다 맞춤 → 같으면 기초 · 다르면 SK(기초 + 엑셀 차이) · 맞는 기초 없음 = 고름 대기 · 이미 만든 블록은 그대로",
"Each block: same = base · different = SK (base + Excel diff) · no match = waits for a pick · made blocks kept",
],
M02_WizSkSummary: [
"산근 블록 {b} · 기초 {base} · SK {sk} · 그대로 {keep} · 고름 대기 {wait}",
"Blocks {b} · base {base} · SK {sk} · kept {keep} · waiting {wait}",
],
M02_WizSkBlock: ["블록", "Block"],
M02_WizSkKey: ["키", "Key"],
M02_WizSkKind: ["갈래", "Kind"],
M02_WizSkBase: ["기초", "Base"],
M02_WizSkDiff: ["SK · 차이 있음", "SK · differs"],
M02_WizSkSame: ["같은 것 씀", "Reused"],
M02_WizSkSameTip: [
"이 층에 내용 같은 단가산출이 있어 새로 안 만들고 그것을 씀",
"Same content already in this layer — reused, not created",
],
M02_WizSkAbove: ["위 층에 같은 것 — 연결 권함", "Same in upper layer — link suggested"],
M02_WizSkSameSum: [
" · 같은 것 씀 {same} · 위 층에 같은 것 {above}",
" · reused {same} · same above {above}",
],
M02_WizSkNoneMade: ["새로 만든 단가산출 없음", "No new unit-cost calcs"],
M02_WizSkNoWait: ["고름 대기 없음", "Nothing waiting"],
M02_WizSkNoPick: ["고르지 않음", "Not picked"],
M02_WizSkWaitHint: [
"고름 대기 — 맞는 기초 없음 · 블록마다 기초를 골라 [고른 기초로 만들기] = 그 기초 + 엑셀 차이 SK",
"Waiting — no matching base · pick a base per block, then make = that base + Excel diff",
],
M02_WizSkRemake: ["고른 기초로 만들기", "Make with picked bases"],
M02_WizSkPickFirst: ["고른 기초 없음 — 블록마다 먼저 고름", "No base picked yet"],
M02_WizSkAlias: [
"별칭 제안 {n} 줄 — 이 층 별칭 장부에 남음 · 모두에게 쓰임 = 시스템 관리자 승인",
"Alias proposals {n} — kept in this layer's ledger · system admin approves for everyone",
],
M02_WizSkLedger: ["M01 별칭 장부", "M01 alias ledger"],
M02_LeftTitle: ["안 이은 줄", "Unlinked rows"],
M02_LeftHint: [
"만들면 M01 · 기초 · SK 에 안 이어질 엑셀 줄 — 줄마다 이을 것을 고름 · 고른 뒤 엑셀 보기 창 [저장] 또는 호표 [수정] 생성",
@@ -315,37 +328,12 @@ export const ui_locales_m2 = {
"Fixed rows link to labor, material or machine only",
],
M02_LibUnlinked: ["안 이은 줄", "Unlinked"],
M02_WizSkCoef: ["환산 계수", "Coefficient"],
M02_WizSkCoefNeed: [
"단산{n} — 단위 성격 다름 · 환산 계수(0 보다 큼) 필요",
"Block {n} — units differ · enter a coefficient",
],
M02_LostCol: ["못옮김", "Not moved"],
M02_LostOpen: ["잇기", "Link"],
M02_LostTitle: ["못옮김 잇기", "Link unmoved rows"],
M02_LostHint: [
"엑셀에만 있어 SK 로 못 옮긴 줄 — 줄마다 M01 노임 · 기계 · 자재로 이음(SK 더한줄 · 엑셀 수량) 또는 우리에 없음 · 고른 것 모두를 [잇기 만들기]로 보냄",
"Excel-only rows not moved into the SK — link each to M01 labor, machine or material, then make",
"옛 단가산출 SK 의 남은 못옮김(보기만) — 잇기 = 마법사 단가산출 칸에서 단산{n} 블록 줄 고름 → [확인] → [확인한 것 옮기기]",
"Unmoved rows of an old SK (view only) — link in the wizard unit-cost step: pick lines of block {n}, confirm, then move",
],
M02_LostMake: ["잇기 만들기", "Make links"],
M02_LostLeft: ["남은 못옮김 {n}", "{n} unmoved rows left"],
M02_LostZero: ["못옮김 0", "Nothing unmoved"],
M02_LostDrop: ["뺌", "Dropped"],
M02_LostDropTip: [
"우리 M01 에 없음 — SK 에 안 넣고 까닭만 남김(사용자가 고른 것)",
"Not in M01 — kept out of the SK with a note",
],
M02_LostClear: ["고름 지움", "Clear"],
M02_CmpTitle: ["줄 비교 — {name}", "Line compare — {name}"],
M02_CmpNone: ["견줄 수 없음(단위 성격 다름)", "Cannot compare (units differ)"],
M02_CmpSame: ["같음", "Same"],
M02_CmpDiff: ["품 다름", "Qty differs"],
M02_CmpExcel: ["엑셀만", "Excel only"],
M02_CmpBase: ["기초만", "Base only"],
M02_CmpColExcel: ["엑셀 품", "Excel qty"],
M02_CmpColBase: ["기초 품", "Base qty"],
M02_CmpColLook: ["꼴", "Result"],
M02_CmpLost: ["못옮김 {n}", "{n} unmoved"],
M02_ElemTitle: ["M01 요소 후보", "M01 element candidates"],
M02_SheetIlwi: ["일위대가표", "Unit-price sheet"],
M02_SheetList: ["단가산출표", "Price basis list"],