Merge remote-tracking branches 'origin/sub_laptop_1' and 'origin/sub_laptop_5' into main_laptop_1

This commit is contained in:
2026-11-02 11:34:04 +09:00
17 changed files with 1152 additions and 49 deletions
@@ -31,8 +31,15 @@ export interface TemplateDoc {
문서: unknown;
}
/** 서버가 판 불일치로 막았을 때 */
export class StaleError extends Error {}
/** 서버가 판 불일치로 막았을 때 · `detail` = 서버 몸 그대로(729-3 `{까닭, 남은줄}`) */
export class StaleError extends Error {
constructor(
message: string,
readonly detail?: unknown,
) {
super(message);
}
}
/** `/api/m02` 아래 길 부름 — 409 = StaleError · 그 밖 실패 = 서버 까닭 글(하위 폴더도 씀) */
export async function call<T>(path: string, init: RequestInit = {}): Promise<T> {
@@ -42,7 +49,8 @@ export async function call<T>(path: string, init: RequestInit = {}): Promise<T>
...init,
});
const body = (await response.json().catch(() => ({}))) as { detail?: unknown } & T;
if (response.status === 409) throw new StaleError(reason(body.detail, response.status));
if (response.status === 409)
throw new StaleError(reason(body.detail, response.status), body.detail);
if (!response.ok) throw new Error(reason(body.detail, response.status));
return body;
}
@@ -50,6 +58,8 @@ export async function call<T>(path: string, init: RequestInit = {}): Promise<T>
/** 서버가 막은 까닭 그대로 — 문장 · `{errors: [...]}` · 입력 검사 목록(`[{msg}]`) */
function reason(detail: unknown, status: number): string {
if (typeof detail === "string") return detail;
const why = (detail as { 까닭?: unknown } | null)?.까닭;
if (typeof why === "string") return why;
const list = Array.isArray(detail) ? detail : (detail as { errors?: unknown } | null)?.errors;
const lines = Array.isArray(list)
? list.map((x) => (typeof x === "string" ? x : String((x as { msg?: unknown })?.msg ?? "")))
@@ -92,6 +92,10 @@ export interface HopyoTarget {
호표?: number;
/** 「전국」 자리표(361-1) — 지역 변형만 있는 자재 · 키 · 단가 없음 */
전국?: boolean;
/** 729-2 사용자 이음 — 노임 LB · 기계 EQ · % 줄 밑수(엑셀 숫자 + 이음 · 하나만) */
노임?: string;
기계?: string;
비율?: LinkBase;
/** 같은 품목 지역 · 관급/사급 변형(361-1 · 고르기 창이 접음) */
변형?: boolean;
이름?: string;
@@ -205,11 +209,74 @@ export interface BuildTarget {
호표?: number;
/** 「전국」 자재(361-1) — 이름 · 규격 · 단위는 서버가 줄에서 다시 셈 */
전국?: boolean;
/** 729-2 사용자 이음(하나만 · 입력값 없음) */
노임?: string;
기계?: string;
비율?: LinkBase;
입력값?: Record<string, unknown>;
배: number;
몫?: number | null;
}
/** % 줄 밑수 비목(729-2) */
export type LinkBase = "노무비" | "재료비" | "경비";
/** 단가산출 기초 후보(729-1 고름 대기) — 판정 · 글 */
export interface SangeunCand {
로직: string;
이름: string;
단위: string;
판정: string;
글: string;
}
/** 안 이은 줄(729-2) — 만들면 남을 엑셀 줄 · 단가산출 줄 후보 = 고름 대기 기초 후보 */
export interface HopyoLeft {
차례: number;
이름: string;
규격: string;
단위: string;
수량: number | null;
갈래: string;
까닭: string;
후보: SangeunCand[];
}
/** 옮기지 못한 엑셀 줄(729-4) — M01 요소로 이으면 SK 더한줄 */
export interface SangeunLost {
id: string;
이름: string;
규격?: string;
단위?: string;
수량?: number | null;
갈래: "자재" | "기계" | "노임";
후보?: unknown[];
}
/** [단가산출 만들기](729-1) 답 — 만듦 = 기초 · SK · 고름 대기 = 블록마다 후보 */
export interface SangeunBuilt {
블록: number;
만듦: { 번: number; 이름: string; 키: string; 갈래: "기초" | "SK"; 못옮김?: SangeunLost[] }[];
그대로: number;
기초: number;
SK: number;
고름대기: {
번: number;
이름: string;
단위: string;
갈래: string;
까닭: string;
후보: SangeunCand[];
}[];
별칭: number;
}
/** 고른 기초(블록 번 → 로직) · 729-4 잇기 */
export type SangeunPick = Record<
number,
{ 로직: string; 입력값?: Record<string, unknown>; 잇기?: Record<string, string>[] }
>;
/** 확인한 줄 — 대상 목록 또는 뺌 까닭 */
export interface BuildLine {
차례: number;
@@ -396,6 +463,31 @@ export const buildHopyoAll = (
): Promise<HopyoBuiltAll> =>
call(at(target, "/hopyo-build-all"), { method: "POST", body: JSON.stringify(from) });
/** 안 이은 줄 미리 보기(729-2 · 저장 없음) — 몸 = 만들기 몸 */
export const leftHopyo = (
target: HopyoScope,
from: { 층: Layer; 주인: string | null; 키: string; 번: number },
줄: BuildLine[],
): Promise<{ 남은줄: HopyoLeft[]; 수: number }> =>
call(at(target, "/hopyo-unlinked"), { method: "POST", body: JSON.stringify({ ...from, 줄 }) });
/** 409 몸의 남은 줄(729-2 · 만들기 막힘) — 아니면 null */
export const leftOf = (error: unknown): HopyoLeft[] | null => {
const got = (error as { detail?: { 남은줄?: unknown } } | null)?.detail?.남은줄;
return Array.isArray(got) ? (got as HopyoLeft[]) : null;
};
/** [단가산출 만들기](729-1) — 산근 블록마다 기초 · SK · 고름 대기 · `바탕` = 고른 기초 */
export const makeSangeun = (
scope: HopyoScope,
key: string,
바탕?: SangeunPick,
): Promise<SangeunBuilt> =>
call(at(scope, `/hopyo/${enc(key)}/sangeun`), {
method: "POST",
body: JSON.stringify(바탕 ? { 바탕 } : {}),
});
export const listHopyoPicks = async (scope: HopyoScope): Promise<HopyoPickRow[]> =>
(await call<{ 줄: HopyoPickRow[] }>(at(scope, "/hopyo-picks"))).줄;
@@ -52,6 +52,7 @@ import { calcLabor, fetchLogicInputs } from "./M02_MasterTemplete_Labor_Api";
import { openLaborInput } from "./M02_MasterTemplete_Labor_Input";
import { draftDoc } from "./logic/M02_MasterTemplete_Hopyo_Draft";
import { toItem } from "./M02_MasterTemplete_Hopyo_ToItem";
import { openLeftOf } from "./M02_MasterTemplete_Hopyo_Left";
import "./M02_MasterTemplete_Hopyo.css";
export const POLL_MS = 3000;
@@ -114,8 +115,8 @@ export const isChecked = (line: HopyoLine, d: CheckDraft): boolean =>
d.체크[line.차례] ?? !!line.맞춤.체크;
/** 체크할 수 있음 — % 줄 아님 · 뺌 까닭 또는 대상(배 0 넘음) */
export function checkable(line: HopyoLine, d: CheckDraft): boolean {
if (line.맞춤.상태 === "%") return false;
if (d.뺌[line.차례]) return true;
if (line.맞춤.상태 === "%") return !!partsOf(line, d)[0]?.비율; // 729-3 밑수 고른 % 줄만
const parts = partsOf(line, d);
return parts.length > 0 && parts.every((p) => p.배 !== null && p.배 > 0);
}
@@ -123,6 +124,8 @@ export function checkable(line: HopyoLine, d: CheckDraft): boolean {
/** 대상 → 만들기 몸 대상(이름 · 품 같은 보이기 칸은 안 보냄) */
export function toTarget(p: HopyoPart): BuildTarget {
if (p.호표 !== undefined) return { 호표: p.호표, 배: p.배 ?? 1 };
const link = (["노임", "기계", "비율"] as const).find((x) => p[x]); // 729-3 사용자 이음(엑셀 숫자 + 이음)
if (link) return { [link]: p[link], 배: 1 } as BuildTarget;
const head = p.로직
? { 로직: p.로직, 입력값: p.입력값 ?? {} }
: p.전국
@@ -450,7 +453,8 @@ export function buildHopyoCheck(opt: CheckOptions): HTMLElement {
opt.onBuilt?.(res, h);
await load(true);
} catch (error) {
fail(error);
const at = { scope: opt.scope, target, key: opt.key, 파일: got?.파일 ?? "", 번: opt.no };
if (!openLeftOf(error, at, () => ((d = loadDraft(k)), draw()))) fail(error); // 729-3 남은 줄
} finally {
hideLoadingOverlay();
}
@@ -0,0 +1,378 @@
/* =============================================================================
* M02_MasterTemplete_Hopyo_Left.ts
* 일위대가 불러오기 안 이은 줄(PLAN 729-3 · 요구서 내역19 1장 8 · 2장 4) — 만들면 남을 엑셀 줄 표 · 줄마다 고름.
* · 줄 표 = 서버 `/hopyo-unlinked`(저장 없음 · 몸 = 확인 창 캐시로 지을 만들기 몸) · 409 `{까닭, 남은줄}` 도 그 표
* · 고름 = 확인 창 캐시(`Hopyo_Check` draft) 대상 · 뺌 — [저장] · [생성] 몸이 그대로 씀(새 저장 꼴 없음)
* [고르기] = 확인 창 고르기 창(후보 · 기초 · 자재 찾기 · 옵션은 서버 `guess`) · [찾기] = 기초 · 노임(LB) · 자재(MT) ·
* [기계] = M01 기계(EQ) · % 줄 = 밑수 비목 · [우리에 없음] = 엑셀 숫자 그대로(사용자가 고른 것)
* · 단가산출 고름 대기 줄 = 후보 기초 고름 → [SK 만들기](서버 `sangeun` 바탕 · 엑셀 차이 보존) → 그 줄은 SK 참조
* · 남은 수 = 안 만든 호표만(만든 호표는 서버가 남은 줄 있으면 안 만듦) — 마법사 [끝내기] 막음 · 띠
* 계산 · 판정은 서버 — 화면은 보이기 · 고름만.
* ========================================================================== */
import { createBadge } from "@ui/ui_template_badge";
import { createBanner } from "@ui/ui_template_banner";
import { createFindList } from "@ui/ui_template_find_list";
import { createButton, createSelectField, el, showToast } from "@ui/ui_template_elements";
import { t as L } from "@ui/ui_template_locale";
import { openModal } from "@ui/ui_template_modal";
import { createTable, type Cell } from "@ui/ui_template_table";
import { createText } from "@ui/ui_template_text";
import { searchElements } from "../M01_MasterData/M01_MasterData_UI_Logic_Api";
import {
guessHopyo,
leftHopyo,
leftOf,
makeSangeun,
readHopyoOne,
type BuildLine,
type HopyoLeft,
type HopyoLine,
type HopyoOne,
type HopyoPart,
type HopyoScope,
type LinkBase,
} from "./M02_MasterTemplete_Hopyo_Api";
import { bodyOf, draftKey, loadDraft, saveDraft, toTarget } from "./M02_MasterTemplete_Hopyo_Check";
import { openHopyoChoose } from "./M02_MasterTemplete_Hopyo_Choose";
import { openFindPicker } from "./M02_MasterTemplete_Find_Pick";
/** 파일 층 · 만들 층 · 파일 키 */
export interface LeftScope {
scope: HopyoScope;
target: HopyoScope;
key: string;
}
/** 호표마다 남은 줄(그 호표 읽은 것과 함께) */
export type LeftMap = Map<number, { one: HopyoOne; left: HopyoLeft[] }>;
const NONE = "우리에 없음";
const BASES: LinkBase[] = ["노무비", "재료비", "경비"];
const why = (error: unknown): string => (error instanceof Error ? error.message : String(error));
/** 남은 줄 수 · 호표 수 */
export const leftCount = (map: LeftMap): { 줄: number; 호표: number } => {
const nos = [...map.values()].filter((x) => x.left.length);
return { 줄: nos.reduce((n, x) => n + x.left.length, 0), 호표: nos.length };
};
/** 캐시로 지을 만들기 몸 — 확인 창 · [저장]과 같은 규칙(통째 호표는 줄 없음) */
export function draftLines(at: LeftScope, one: HopyoOne): BuildLine[] {
const h = one.호표;
const d = loadDraft(draftKey(at.scope, at.key, h.번));
return h.통째 && (d.통째 ?? h.통째.체크) ? [] : bodyOf(h.줄, d);
}
/** 호표 하나 다시 셈 — 서버 미리 보기(만든 호표 = 남은 줄 없음) */
async function recount(at: LeftScope, no: number, map: LeftMap): Promise<void> {
const one = await readHopyoOne(at.scope, at.key, no, at.target);
const from = { 층: at.scope.layer, 주인: at.scope.owner, 키: at.key, 번: no };
const made = !!one.호표.만든?.확인;
const left = made ? [] : (await leftHopyo(at.target, from, draftLines(at, one))).남은줄;
map.set(no, { one, left });
}
/** 호표 여럿 남은 줄(차례로 · `step` = 몇째) — 실패한 호표는 까닭 글로 알림 */
export async function countLeft(
at: LeftScope,
nos: number[],
step?: (done: number, all: number) => void,
): Promise<LeftMap> {
const map: LeftMap = new Map();
for (const [i, no] of nos.entries()) {
step?.(i, nos.length);
try {
await recount(at, no, map);
} catch (error) {
showToast(`제${no}호표 — ${why(error)}`, "warning");
}
}
return map;
}
/** 캐시에 한 줄 고름 — 대상 또는 뺌 「우리에 없음」(parts = null · 체크 켬) */
export function put(at: LeftScope, no: number, 차례: number, parts: HopyoPart[] | null): void {
const k = draftKey(at.scope, at.key, no);
const d = loadDraft(k);
d.체크[차례] = true;
if (parts) {
d.대상[차례] = parts;
delete d.뺌[차례];
} else {
d.뺌[차례] = NONE;
delete d.대상[차례];
}
saveDraft(k, d);
}
/** 고른 것 글 — 캐시 대상 · 뺌 */
function pickedText(at: LeftScope, no: number, 차례: number): string {
const d = loadDraft(draftKey(at.scope, at.key, no));
if (d.뺌[차례]) return d.뺌[차례];
const parts = d.대상[차례] ?? [];
return parts
.map((p) => p.이름 ?? p.로직 ?? p.자재 ?? p.노임 ?? p.기계 ?? p.비율 ?? "")
.filter(Boolean)
.join(" + ");
}
/** M01 기계(EQ) 하나 고르기 — 이름 · 규격 낱말 */
export function pickMachine(
line: { 이름: string; 규격?: string },
done: (code: string, name: string) => void,
): void {
openModal({
title: `${L("M02_LeftMachine")} — ${line.이름} ${line.규격 ?? ""}`.trim(),
closeLabel: L("M02_Close"),
dialogClass: "m02-modal",
mount: (body, close) => {
const find = createFindList({ placeholder: L("M02_LeftMachineFind") });
let seq = 0;
const run = (q: string): void => {
const mine = ++seq;
searchElements("기계", q).then(
(got) => {
if (mine !== seq) return;
find.hint.textContent = `${got.total}`;
find.setRows(
got.items.map((x) => ({
key: x.ref,
name: `${x.이름 ?? ""} ${x.규격 ?? ""}`.trim(),
sub: x.ref,
onPick: () => {
close();
done(x.ref, `${x.이름 ?? ""} ${x.규격 ?? ""}`.trim());
},
})),
);
},
(error) => showToast(why(error), "error"),
);
};
find.search.addEventListener("input", () => run(find.search.value));
find.search.value = line.이름.split(/[\s(]/)[0] ?? "";
run(find.search.value);
body.append(find.root);
},
});
}
/** 남은 줄 창 — `map` 이 없으면 `nos` 를 서버에 물어 채움 · 닫을 때 `onClose(map)` */
export function openLeft(
at: LeftScope & { 파일: string; nos?: number[]; map?: LeftMap },
onClose?: (map: LeftMap) => void,
): void {
const map: LeftMap = at.map ?? new Map();
openModal({
title: `${L("M02_LeftTitle")} — ${at.파일}`,
closeLabel: L("M02_Close"),
width: "1100px",
wide: true,
dialogClass: "m02-modal",
beforeClose: () => {
onClose?.(map);
return true;
},
mount: (body) => {
const head = el("div");
const list = el("div");
body.append(createText(L("M02_LeftHint"), "hint", 14), head, list);
const again = async (no: number): Promise<void> => {
try {
await recount(at, no, map);
} catch (error) {
showToast(why(error), "error");
}
draw();
};
const choose = (no: number, line: HopyoLine): void =>
openHopyoChoose({
line,
done: (ts) => {
const parts = ts.map((t) => ({ ...t, 입력값: t.입력값 ?? {}, 몫: t.몫 ?? null }));
guessHopyo(at.scope, at.key, no, line.차례, parts.map(toTarget))
.then((g) => (Array.isArray(g.대상) ? g.대상 : parts))
.catch(() => parts)
.then((got) => {
put(at, no, line.차례, got);
void again(no);
});
},
});
const link = (no: number, 차례: number, part: Partial<HopyoPart>, name: string): void => {
put(at, no, 차례, [{ ...part, 이름: name, 입력값: {}, 배: 1 }]);
void again(no);
};
const find = (no: number, line: HopyoLine): void =>
openFindPicker({
layer: at.target.layer,
owner: at.target.owner,
title: `${line.이름} ${line.규격}`.trim(),
logic: (x) => link(no, line.차례, { 로직: x.키 }, x.이름),
element: (e) =>
link(no, line.차례, e.종류 === "인력" ? { 노임: e.코드 } : { 자재: e.코드 }, e.이름),
});
const makeSk = async (no: number, block: number, 로직: string): Promise<void> => {
try {
const got = await makeSangeun(at.scope, at.key, { [block]: { 로직 } });
const sk = got.만듦.find((x) => x.번 === block);
showToast(sk ? `${sk.키} · ${sk.갈래}` : L("M02_LeftSkNone"), sk ? "success" : "warning");
} catch (error) {
showToast(why(error), "error");
}
await again(no);
};
const actions = (no: number, row: HopyoLeft, line: HopyoLine | undefined): HTMLElement => {
const box = el("div", { className: "ui-toolbar" });
const block = (line as { 참조?: { 종류?: string; 번?: number } } | undefined)?.참조;
if (row.후보.length && block?.종류 === "단산" && block.번 !== undefined) {
const pick = createSelectField({
options: row.후보.map((c) => ({
value: c.로직,
text: `${c.이름} [${c.단위}] · ${c.판정}${c.글 && c.글 !== c.판정 ? ` · ${c.글}` : ""}`,
})),
compact: true,
size: 24,
ariaLabel: L("M02_LeftBase"),
});
const go = createButton({
label: L("M02_LeftMakeSk"),
size: 24,
onClick: () => void makeSk(no, block.번!, pick.select.value),
});
go.dataset.leftSk = String(block.번);
box.append(pick.root, go);
}
if (row.갈래 === "%" && line) {
const base = createSelectField({
options: [{ value: "", text: L("M02_LeftBase") }].concat(
BASES.map((b) => ({ value: b, text: b })),
),
compact: true,
size: 24,
ariaLabel: L("M02_LeftBase"),
onChange: (v) => v && link(no, row.차례, { 비율: v as LinkBase }, v),
});
box.append(base.root);
}
if (line && row.갈래 !== "%")
box.append(
createButton({ label: L("M02_LeftChoose"), size: 24, variant: "ghost", onClick: () => choose(no, line) }),
createButton({ label: L("M02_LeftFind"), size: 24, variant: "ghost", onClick: () => find(no, line) }),
); // prettier-ignore
if (line && row.갈래 === "기계")
box.append(
createButton({
label: L("M02_LeftMachine"),
size: 24,
variant: "ghost",
onClick: () =>
pickMachine(line, (code, name) => link(no, row.차례, { 기계: code }, name)),
}),
);
const none = createButton({
label: NONE,
size: 24,
variant: "ghost",
onClick: () => {
put(at, no, row.차례, null);
void again(no);
},
});
none.title = L("M02_LeftNoneTip");
box.append(none);
return box;
};
const draw = (): void => {
const n = leftCount(map);
head.replaceChildren(
n.줄
? createBanner({
tone: "warning",
text: L("M02_LeftBand").replace("{n}", String(n.줄)).replace("{h}", String(n.호표)),
})
: createBanner({ tone: "success", text: L("M02_LeftZero") }),
);
head.dataset.left = String(n.줄);
const rows: Cell[][] = [];
for (const [no, { one, left }] of [...map].sort((a, b) => a[0] - b[0]))
for (const row of left) {
const line = one.호표.줄.find((l) => l.차례 === row.차례);
rows.push([
`제${no}호표`,
row.이름,
row.규격,
`${row.수량 ?? ""} ${row.단위}`.trim(),
createBadge(row.갈래, "warning"),
row.까닭,
pickedText(at, no, row.차례),
actions(no, row, line),
]);
}
list.replaceChildren(
createTable(
[
{ label: L("M02_LeftHopyo"), width: "88px" },
{ label: L("M02_SangeunColName") },
{ label: L("M02_LeftSpec") },
{ label: L("M02_SangeunColQty"), width: "80px" },
{ label: L("M02_LeftKind"), width: "80px" },
{ label: L("M02_LeftWhy") },
{ label: L("M02_LeftPicked") },
{ label: L("M02_LeftPick") },
],
rows,
{ density: "data", empty: L("M02_LeftZero") },
),
);
};
if (at.map) draw();
else {
list.replaceChildren(createText(L("M02_LeftCounting"), "muted", 14));
void countLeft(at, at.nos ?? [], (i, all) => {
list.replaceChildren(
createText(`${L("M02_LeftCounting")} ${i + 1} / ${all}`, "muted", 14),
);
}).then((got) => {
for (const [k, v] of got) map.set(k, v);
draw();
});
}
},
});
}
/** 만들기 실패 까닭이 남은 줄이면 그 표를 엶(참) · 아니면 거짓(부르는 쪽이 까닭 글) */
export function openLeftOf(
error: unknown,
at: LeftScope & { 파일: string; 번: number },
onClose?: (map: LeftMap) => void,
): boolean {
const left = leftOf(error);
if (!left) return false;
void readHopyoOne(at.scope, at.key, at.번, at.target).then(
(one) => openLeft({ ...at, map: new Map([[at.번, { one, left }]]) }, onClose),
(e) => showToast(why(e), "error"),
);
return true;
}
/** 결과 표 칸 단추 — 남은 줄로 막힌 호표면 [고르기 (n)] · 아니면 null */
export function leftButton(
error: unknown,
at: LeftScope & { 파일: string; 번: number },
): HTMLButtonElement | null {
const left = leftOf(error);
if (!left) return null;
const go = createButton({
label: `${L("M02_LeftChoose")} (${left.length})`,
size: 24,
onClick: () => void openLeftOf(error, at),
});
go.dataset.leftOpen = String(at.번);
return go;
}
@@ -57,6 +57,7 @@ import {
} from "./M02_MasterTemplete_Hopyo_Check";
import { builtWord } from "./M02_MasterTemplete_Hopyo_Choose";
import { openRelink, relinkKeys } from "./M02_MasterTemplete_Hopyo_Relink";
import { leftButton } from "./M02_MasterTemplete_Hopyo_Left";
import "./M02_MasterTemplete_Hopyo.css";
export interface HopyoSheetOptions {
@@ -499,11 +500,12 @@ export function openHopyoSheet(opt: HopyoSheetOptions): void {
}),
);
} catch (error) {
const left = leftButton(error, { scope: opt.scope, target, key: opt.key, 파일: opt.파일, 번: no }); // prettier-ignore
const text =
error instanceof StaleError
? `안내 — ${why(error)} · 하위 호표를 함께 고르거나 먼저 저장`
: `실패 — ${why(error)}`;
rows.push(el("tr", { children: [cell, el("td", { text })] }));
left || !(error instanceof StaleError)
? `실패 — ${why(error)}`
: `안내 — ${why(error)} · 하위 호표를 함께 고르거나 먼저 저장`;
rows.push(el("tr", { children: [cell, el("td", { text }), ...(left ? [el("td", { children: [left] })] : [])] })); // prettier-ignore
}
}
} finally {
@@ -557,9 +559,10 @@ export function openHopyoSheet(opt: HopyoSheetOptions): void {
],
}),
),
...got.실패.map((x) =>
el("tr", { children: [el("td", { text: `제${x.번}호표` }), el("td", { text: x.까닭 })] }),
),
...got.실패.map((x) => {
const left = leftButton({ detail: x }, { scope: opt.scope, target, key: opt.key, 파일: opt.파일, 번: x.번 }); // prettier-ignore
return el("tr", { children: [el("td", { text: `제${x.번}호표` }), el("td", { text: x.까닭 }), ...(left ? [el("td", { children: [left] })] : [])] }); // prettier-ignore
}),
];
const relink = createButton({
label: "다음 — 이 공사 단가로 다시 잇기",
@@ -5,8 +5,10 @@
* (그 층에 둠 · 같은 엑셀 작업 중 = 그 파일 이어서 · 끝낸 같은 엑셀 = 서버 409 안내) · 작업 중 파일 이어서
* ② 보기 · 확인 · 만들기 = 엑셀 보기 창(`Hopyo_Sheet` — [모두 고정으로 들여오기] · 호표 [수정] →
* 확인 창 만들기 · [구조물 조합으로] 그대로) · 만들 층 = 고른 층 ·
* 단가산출 체크면 [단가산출 만들기] = 산근 블록마다 바탕 없는 단가산출(서버 `M02_Hopyo_Sangeun` · 겹침 없음)
* ③ [끝내기] = 물음 → 서버가 이번 HP · 격자 · 맞춤셈 캐시 지움 · 만든 일위대가 · 단가산출 · 별칭 장부 남음 · 지문 기록
* 단가산출 체크면 단가산출 칸(`Hopyo_WizardSk` — 기초 · SK · 고름 대기 기초 고름 · 729-3) ·
* 안 이은 줄 띠(`Hopyo_Left` — 안 만든 호표의 남은 줄 · [고르기] = 줄마다 이을 것 · 729-3)
* ③ [끝내기] = 남은 줄 다시 셈 → 있으면 막음(띠 · 남은 수 · 표) · 없으면 물음 → 서버가 이번 HP · 격자 · 맞춤셈 캐시 지움 ·
* 만든 일위대가 · 단가산출 · 별칭 장부 남음 · 지문 기록
* 작업 중 = 서버에 보관(떠났다 와도 ① 목록에서 이어서) · 옛 HP(지문 없음)는 서버 목록에서 숨김.
* ========================================================================== */
@@ -24,7 +26,18 @@ import {
type HopyoFile,
type HopyoScope,
} from "./M02_MasterTemplete_Hopyo_Api";
import { call } from "./M02_MasterTemplete_Api_Fetch";
import { createBanner } from "@ui/ui_template_banner";
import { t as L } from "@ui/ui_template_locale";
import { readHopyo } from "./M02_MasterTemplete_Hopyo_Api";
import { hasDraft, draftKey } from "./M02_MasterTemplete_Hopyo_Check";
import {
countLeft,
leftCount,
openLeft,
type LeftMap,
type LeftScope,
} from "./M02_MasterTemplete_Hopyo_Left";
import { sangeunStep } from "./M02_MasterTemplete_Hopyo_WizardSk";
import { accessFor, type ManageLayer } from "./M02_MasterTemplete_Layer";
const LAYERS: { layer: ManageLayer; text: string }[] = [
@@ -112,7 +125,7 @@ export function buildHopyoWizard(): HTMLElement {
});
seg.root.dataset.hopyoLayer = "";
const ilwi = createCheckbox("일위대가(고정 줄)", { checked: true, disabled: true });
const sangeun = createCheckbox("단가산출(산근 블록 · 바탕 없음)", {
const sangeun = createCheckbox(L("M02_WizSkCheck"), {
checked: wantSangeun,
onChange: (on) => (wantSangeun = on),
});
@@ -171,54 +184,89 @@ export function buildHopyoWizard(): HTMLElement {
)
.catch((error: unknown) => showToast(why(error), "error")),
});
const sangeun = createButton({
label: "단가산출 만들기",
variant: "filled",
onClick: () => void makeSangeun(),
});
sangeun.title =
"엑셀 산근 블록마다 바탕 없는 단가산출(엑셀 단가 그대로 · 이미 만든 블록은 그대로)";
sangeun.dataset.hopyoSangeunMake = "";
const finish = createButton({ label: "끝내기", variant: "ghost", onClick: () => void end() });
finish.dataset.hopyoFinish = "";
const at: LeftScope = { scope, target: scope, key: file.키 };
const band = el("div", { attrs: { "data-hopyo-left": "" } });
let nos: number[] = [];
let map: LeftMap | null = null;
const pick = createButton({
label: L("M02_LeftOpen"),
variant: "ghost",
size: 28,
onClick: () => openLeft({ ...at, 파일: file.파일, nos, ...(map ? { map } : {}) }, paint),
});
pick.dataset.hopyoLeftOpen = "";
/** 띠 — 남은 줄 있으면 [끝내기] 막음 · 까닭 · 남은 수 */
function paint(got: LeftMap | null): void {
map = got;
const n = got ? leftCount(got) : null;
finish.disabled = !!n?.줄;
finish.title = n?.줄 ? L("M02_LeftBlocked") : "";
band.dataset.left = n ? String(n.줄) : "";
band.replaceChildren(
createBanner({
tone: !n ? "info" : n.줄 ? "warning" : "success",
text: !n
? L("M02_LeftUnknown")
: n.줄
? `${L("M02_LeftBand").replace("{n}", String(n.줄)).replace("{h}", String(n.호표))} · ${L("M02_LeftBlocked")}`
: L("M02_LeftZero"),
actions: [pick],
}),
);
}
paint(null);
async function recount(): Promise<LeftMap> {
if (!nos.length) nos = (await readHopyo(scope, file.키)).호표.map((h) => h.번);
const got = await countLeft(at, nos, (i, all) =>
band.replaceChildren(createText(`${L("M02_LeftCounting")} ${i + 1} / ${all}`, "muted", 14)),
);
paint(got);
return got;
}
const back = createButton({ label: "처음으로", variant: "link", onClick: () => void first() });
back.title = "작업은 그대로 보관 — 처음 화면 「작업 중」에서 이어서";
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_WizIlwiHead"), level: 4, divider: true }).root,
createText(
"엑셀 보기 창에서 [모두 고정으로 들여오기] 또는 호표 [수정] → 확인하고 만들기 · [구조물 조합으로] · 만든 일위대가 · 단가산출은 고른 층에 들어감",
"hint",
),
el("div", {
className: "ui-toolbar",
children: [view, ...(wantSangeun ? [sangeun] : []), finish, back],
}),
band,
el("div", { className: "ui-toolbar", children: [view, finish, back] }),
);
async function makeSangeun(): Promise<void> {
sangeun.disabled = true;
try {
const got = await call<{ 블록: number; 만듦: unknown[]; 그대로: number }>(
`/layers/${layer}/hopyo/${encodeURIComponent(file.키)}/sangeun`,
{ method: "POST" },
);
const kind = got.블록 ? "success" : "warning";
showToast(
`단가산출 ${got.만듦.length} 만듦 · 그대로 ${got.그대로} · 산근 블록 ${got.블록}`,
kind,
);
} catch (error) {
showToast(why(error), "error");
} finally {
sangeun.disabled = false;
}
}
async function end(): Promise<void> {
steps.set("3");
finish.disabled = true;
let got: LeftMap;
try {
got = await recount();
} catch (error) {
showToast(why(error), "error");
finish.disabled = false;
return steps.set("2");
}
if (leftCount(got).줄) {
showToast(L("M02_LeftBlocked"), "warning");
openLeft({ ...at, 파일: file.파일, nos, map: got }, paint);
return steps.set("2");
}
const loose = [...got.values()].filter(
(x) => !x.one.호표.만든?.확인 && hasDraft(draftKey(scope, file.키, x.one.호표.번)),
).length;
const ok = await showConfirmDialog(
`「${file.파일}」 불러오기를 끝냄 — 올린 파일(엑셀 결과 · 격자 · 맞춤 캐시)을 지움 · 만든 일위대가 · 단가산출과 별칭 장부는 남음 · 하나라도 만들었으면 같은 엑셀은 다시 올리지 못함`,
`「${file.파일}」 불러오기를 끝냄 — 올린 파일(엑셀 결과 · 격자 · 맞춤 캐시)을 지움 · 만든 일위대가 · 단가산출과 별칭 장부는 남음 · 하나라도 만들었으면 같은 엑셀은 다시 올리지 못함` +
(loose ? ` · ${L("M02_LeftLoose").replace("{n}", String(loose))}` : ""),
"끝내기",
);
if (!ok) return steps.set("2");
@@ -0,0 +1,145 @@
/* =============================================================================
* M02_MasterTemplete_Hopyo_WizardSk.ts
* 일위대가 불러오기 마법사 ② 단가산출 단계(PLAN 729-3 · 요구서 내역19 1장 7 · 2장 3) —
* · [단가산출 만들기] = 서버 `sangeun`(산근 블록마다 매칭 → 같으면 기초 · 다르면 SK = 기초 + 엑셀 차이 · 맞는 기초 없음 = 고름 대기)
* · 만든 줄 표 = 블록 · 이름 · 키 · 갈래(기초 · SK 차이 있음) · 이미 만든 블록은 「그대로」 수만
* · 고름 대기 표 = 블록마다 후보(판정 · 글) 고르기 칸 → [고른 기초로 만들기](바탕 = 고른 기초 · 엑셀 품 견줘 SK)
* · 별칭 제안 = 이 층 별칭 장부에 남음 · 모두에게 쓰이려면 시스템 관리자가 M01 › 별칭 장부에서 승인(링크 · 716 길 그대로)
* 계산 · 판정은 서버 — 화면은 보이기 · 고름만 · 자동 저장 없음(누를 때만 서버).
* ========================================================================== */
import { ROUTES } from "@config/config_frontend";
import { createBadge } from "@ui/ui_template_badge";
import { createButton, createSelectField, el, showToast } from "@ui/ui_template_elements";
import { t as L } from "@ui/ui_template_locale";
import { createTable } from "@ui/ui_template_table";
import { createText } from "@ui/ui_template_text";
import {
makeSangeun,
type HopyoScope,
type SangeunBuilt,
type SangeunPick,
} from "./M02_MasterTemplete_Hopyo_Api";
const why = (error: unknown): string => (error instanceof Error ? error.message : String(error));
/** 고름 대기 후보 글 — 「이름 [단위] · 판정 · 글」 */
export const candLabel = (c: { 이름: string; 단위: string; 판정: string; 글: string }): string =>
`${c.이름} [${c.단위}] · ${c.판정}${c.글 && c.글 !== c.판정 ? ` · ${c.글}` : ""}`;
/** 만든 갈래 꼬리표 — 기초 = 초록 · SK = 차이 있음 */
const kindBadge = (kind: string): HTMLElement =>
kind === "SK"
? createBadge(L("M02_WizSkDiff"), "accent")
: createBadge(L("M02_WizSkBase"), "success");
/** ② 단가산출 단계 칸 — `root` 를 마법사 본 칸에 붙임 */
export function sangeunStep(scope: HopyoScope, key: string): { root: HTMLElement } {
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 root = el("div", {
className: "m02-hopyo",
children: [el("div", { className: "ui-toolbar", children: [make] }), out],
});
async function run(picks?: SangeunPick): Promise<void> {
make.disabled = true;
try {
const got = await makeSangeun(scope, key, picks);
draw(got);
showToast(summary(got), got.고름대기.length ? "warning" : "success");
} catch (error) {
showToast(why(error), "error");
} finally {
make.disabled = false;
}
}
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));
function draw(got: SangeunBuilt): void {
const made = createTable(
[
{ label: L("M02_WizSkBlock"), width: "80px" },
{ label: L("M02_SangeunColName") },
{ label: L("M02_WizSkKey"), width: "110px" },
{ label: L("M02_WizSkKind"), width: "120px" },
],
got.만듦.map((x) => [`단산${x.번}`, x.이름, x.키, kindBadge(x.갈래)]),
{ density: "data", empty: L("M02_WizSkNoneMade") },
);
const selects = new Map<number, HTMLSelectElement>();
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: "40%" },
],
got.고름대기.map((x) => {
const pick = createSelectField({
options: [{ value: "", text: L("M02_WizSkNoPick") }].concat(
x.후보.map((c) => ({ value: c.로직, text: candLabel(c) })),
),
compact: true,
size: 24,
ariaLabel: `${L("M02_LeftBase")} 단산${x.번}`,
});
pick.select.dataset.wizWait = String(x.번);
selects.set(x.번, pick.select);
return [`단산${x.번}`, x.이름, x.단위 ?? "", x.까닭, pick.root];
}),
{ 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) picks[no] = { 로직: s.value };
if (!Object.keys(picks).length) return showToast(L("M02_WizSkPickFirst"), "warning");
void run(picks);
},
});
again.dataset.wizRemake = "";
const ledger = createButton({
label: L("M02_WizSkLedger"),
variant: "link",
onClick: () => void window.open(`#/${ROUTES.M01_MASTER_DATA}`, "_blank"),
});
out.replaceChildren(
createText(summary(got), "muted", 14),
made,
...(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,
],
}),
);
}
return { root };
}
@@ -7,6 +7,7 @@
* 자재 줄 = 이름 · 규격 · 수량 · 단위 · 재료비 단가 = 기준단가 · 금액 = 반영 · 별도계상은 비고 「합계 밖」
* 빈 줄(금액 모름 · 수량 없음) = 합계 줄 「빈 줄 n」 붉게 — 701-4 경고와 같은 줄
* 규격 열(704-4 · `Labor_Spec`) = 수량 칸 열마다 · 고칠 때 격자 위 규격 줄 + 수량 칸 고침
* 안 이은 고정 줄(729-3 · `Labor_Unlinked`) = 격자 위 띠 「M01 연결 없음 n 줄」 · 고칠 때 [연결]
* ========================================================================== */
import {
@@ -23,6 +24,7 @@ import { tc } from "./M02_MasterTemplete_Labor_Text";
import { valueLine } from "./M02_Labor_Values_Model";
import { specBar, specCells, withSpecs, type SpecEdit } from "./M02_MasterTemplete_Labor_Spec";
import { pasteInto } from "./M02_MasterTemplete_Labor_Paste";
import { unlinkedBar } from "./M02_MasterTemplete_Labor_Unlinked";
const money = (m: LaborCost | null | undefined, times = 1): HopyoMoney =>
m
@@ -121,6 +123,21 @@ export const laborGrid = (
logics: ReadonlyMap<string, ItemLogic>,
edit?: SpecEdit | false,
spec = true,
): HTMLElement => {
const grid = gridOf(doc, calc, logics, edit, spec);
const bar = unlinkedBar(doc, edit);
if (!bar) return grid;
const box = document.createElement("div");
box.append(bar, grid);
return box;
};
const gridOf = (
doc: LaborDoc,
calc: LaborCalc | null,
logics: ReadonlyMap<string, ItemLogic>,
edit?: SpecEdit | false,
spec = true,
): HTMLElement => {
const block = laborBlock(doc, calc, logics);
if (!edit) return createHopyoGrid([block]).root;
@@ -0,0 +1,110 @@
/* =============================================================================
* M02_MasterTemplete_Labor_Unlinked.ts
* 일위대가표 안 이은 줄 띠 · [연결](PLAN 729-3 · 요구서 내역19 1장 8 · 분석 19-B D-2) —
* · 안 이은 줄 = 고정 줄 중 비율 줄 아님 · 이음(노임 · 자재 · 기계) 모두 빈 것(서버 목록 summary `안이은줄` 과 같은 규칙)
* · 띠 = 「M01 연결 없음 n 줄(옛 자료)」 · 고칠 수 있으면 [연결] → 줄마다 [찾기](노임 LB · 자재 MT · 721 찾아 넣기 창) ·
* [기계](M01 기계 EQ) — 고정 줄 이름 · 규격 · 엑셀 숫자(출처)는 그대로 · 이음만 붙임 → 상세 고침(저장은 [저장])
* 계산은 서버(이음 = 단가 책 값) — 화면은 고름만.
* ========================================================================== */
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 { openModal } from "@ui/ui_template_modal";
import { createTable } from "@ui/ui_template_table";
import { getActiveLayer, getActiveOwner } from "./M02_MasterTemplete_Api_Fetch";
import { openFindPicker } from "./M02_MasterTemplete_Find_Pick";
import { pickMachine } from "./M02_MasterTemplete_Hopyo_Left";
import type { LaborDoc } from "./M02_MasterTemplete_Labor_Api";
import type { LaborFixed } from "./M02_MasterTemplete_Labor_Calc";
/** 안 이은 고정 줄 */
export const unlinkedFixes = (doc: LaborDoc): LaborFixed[] =>
doc.담은로직
.map((row) => row.고정)
.filter(
(fx): fx is LaborFixed => !!fx && !fx.비율 && !Object.values(fx.이음 ?? {}).some(Boolean),
);
/** 이음 붙임 — 하나만(노임 · 자재 · 기계) */
const linkTo = (fx: LaborFixed, kind: "노임" | "자재" | "기계", code: string): void => {
fx.이음 = { 노임: null, 자재: null, 기계: null, [kind]: code };
};
/** [연결] 창 — 줄마다 찾기 · 기계 · 고르면 `changed` */
function openLink(doc: LaborDoc, changed: () => void): void {
openModal({
title: L("M02_GridLinkTitle"),
closeLabel: L("M02_Close"),
width: "960px",
dialogClass: "m02-modal",
mount: (body) => {
const draw = (): void => {
const rows = unlinkedFixes(doc).map((fx) => {
const done = (kind: "노임" | "자재" | "기계", code: string): void => {
linkTo(fx, kind, code);
changed();
draw();
};
const find = createButton({
label: L("M02_LeftFind"),
size: 24,
variant: "ghost",
onClick: () =>
openFindPicker({
layer: getActiveLayer(),
owner: getActiveOwner(),
title: `${fx.이름} ${fx.규격 ?? ""}`.trim(),
logic: () => showToast(L("M02_GridLinkNoLogic"), "info"),
element: (e) => done(e.종류 === "인력" ? "노임" : "자재", e.코드),
}),
});
const machine = createButton({
label: L("M02_LeftMachine"),
size: 24,
variant: "ghost",
onClick: () => pickMachine(fx, (code) => done("기계", code)),
});
return [
fx.이름,
fx.규격 ?? "",
fx.단위 ?? "",
el("div", { className: "ui-toolbar", children: [find, machine] }),
];
});
body.replaceChildren(
createTable(
[
{ label: L("M02_SangeunColName") },
{ label: L("M02_LeftSpec") },
{ label: L("M02_SangeunColUnit"), width: "64px" },
{ label: L("M02_LeftPick"), width: "180px" },
],
rows,
{ density: "data", empty: L("M02_LeftZero") },
),
);
};
draw();
},
});
}
/** 띠 — 안 이은 줄이 없으면 null · `edit` 있으면 [연결] */
export function unlinkedBar(
doc: LaborDoc,
edit?: ((qtyOnly: boolean) => void) | false,
): HTMLElement | null {
const n = unlinkedFixes(doc).length;
if (!n) return null;
const go = edit
? [createButton({ label: L("M02_GridLink"), size: 24, onClick: () => openLink(doc, () => edit(false)) })]
: []; // prettier-ignore
const bar = createBanner({
tone: "warning",
text: L("M02_GridUnlinked").replace("{n}", String(n)),
actions: go,
});
bar.dataset.laborUnlinked = String(n);
return bar;
}
@@ -2,6 +2,7 @@
* M02_MasterTemplete_Library_List.ts
* 서고 목록표(51-4 · 717-2a) — 좌측 일위대가 · 단가산출 층 단추를 누르면 상세 칸에 뜸.
* 표 = 고름 · 키 · 구분 · 상세구분(51-12) · 이름 · 단위 · 기준 금액(서버 1 단위 계) · 고친 날 · 줄 누름 = 그 문서 상세 ·
* 일위대가 = 안 이은 줄 수(729-3 · 서버 `summary` · 0 은 빈칸).
* 단가산출 = 구분 둘 대신 바탕(M01 로직 · 없으면 「바탕 없음」) · 고친 줄 수 · 쓰는 곳 수(서버 `summary`).
* 머리 [생성] · [복사](고른 줄) · [삭제](고른 줄 · 쓰는 곳 있으면 서버 409 까닭 그대로).
* 꼴 = M01 단가산출 로직 목록 — 찾기 줄(`buildHeadFilters`) · 쪽(`buildPager`) · 표(`m01-master__grid`) 를 M01 에서 import.
@@ -44,6 +45,8 @@ type Row = TemplateInfo & {
바탕?: string | null;
고친줄수?: number;
쓰는곳수?: number;
/** 일위대가 안 이은 고정 줄 수(729-2 summary) */
안이은줄?: number;
};
export interface LibraryListOptions {
@@ -80,6 +83,7 @@ const COLUMNS: Record<LibraryKind, () => [string, (row: Row) => string][]> = {
[tc("Lib_Name"), (r) => r.제목 ?? ""],
[tc("Lib_Unit"), (r) => r.단위 ?? ""],
[tc("Lib_Base"), (r) => money(r.기준금액)],
[L("M02_LibUnlinked"), (r) => (r.안이은줄 ? String(r.안이은줄) : "")], // 729-3
[tc("Lib_Date"), (r) => r.수정일],
],
sangeun: () => [
@@ -0,0 +1,100 @@
/* 729-3 안 이은 줄 화면 헬퍼 — `Hopyo_Left` · `Hopyo_Check` · `Api_Fetch` · `Labor_Unlinked` · `Hopyo_WizardSk` 도우미를
* 트랜스파일해 Node 로 돌림 · fetch 대역(부른 길 · 몸 모음 · 409 몸) · sessionStorage 대역 ·
* stdin = {one, made, left, fixes} · stdout = JSON. */
const fs = require("fs");
const path = require("path");
const { makeLoader, memoryStorage } = require("./helper_ts_stub_loader.cjs");
const ROOT = path.join(__dirname, "..", "..");
const STUBS = { "@config/config_frontend": { API_BASE_URL: "/api", ROUTES: {} } };
const STUB_FILES = [
"M02_MasterTemplete_Labor_Pick",
"M02_Material_Pick",
"M02_MasterTemplete_Labor_Api",
"M02_MasterTemplete_Labor_Input",
"M01_MasterData_UI_LogicLab_Source",
"M01_MasterData_UI_Logic_Api",
"M02_MasterTemplete_Hopyo_CheckRows",
"M02_MasterTemplete_Find_Pick",
];
const stub = new Proxy({}, { get: (_t, k) => (k === "currentLanguageIndex" ? () => 0 : () => {}) });
const { loadTs } = makeLoader({
root: ROOT,
requireFor: (full) => (request) => {
if (STUBS[request]) return STUBS[request];
if (STUB_FILES.some((name) => request.endsWith(name))) return stub;
if (request.startsWith(".") && !request.endsWith(".css"))
return loadTs(`${path.resolve(path.dirname(full), request)}.ts`);
return stub;
},
});
global.sessionStorage = memoryStorage();
(async () => {
const input = JSON.parse(fs.readFileSync(0, "utf8"));
const calls = [];
global.fetch = async (url, init = {}) => {
const body = init.body ? JSON.parse(init.body) : null;
calls.push({ url, method: init.method ?? "GET", body });
if (url.includes("/hopyo-build"))
return { status: 409, ok: false, json: async () => ({ detail: { 까닭: "남음", 남은줄: input.left } }) }; // prettier-ignore
if (url.includes("/hopyo-unlinked")) {
const done = new Set((body.줄 ?? []).map((x) => x.차례));
const left = input.left.filter((x) => !done.has(x.차례));
return { status: 200, ok: true, json: async () => ({ 남은줄: left, 수: left.length }) };
}
const no = Number(url.split("?")[0].split("/").pop());
const one = { ...input.one, 호표: { ...input.one.호표, 번: no, 만든: input.made.includes(no) ? { 키: "SC-1", 확인: true } : null } }; // prettier-ignore
return { status: 200, ok: true, json: async () => one };
};
const M02 = path.join(ROOT, "M02_MasterTemplete");
const check = loadTs(path.join(M02, "M02_MasterTemplete_Hopyo_Check.ts"));
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" };
// 만들기 409 → 남은 줄 · 까닭 글
let stale = null;
try {
await api.buildHopyo(scope, { 층: "personal", 주인: null, 키: "HP-000001", 번: 2 }, []);
} catch (error) {
stale = { message: error.message, left: api.leftOf(error), name: error.constructor.name };
}
const before = await left.countLeft(at, [1, 2]);
// 줄마다 고름 — 노임 · 기계 · 비율 · 우리에 없음 · 로직
const [a, b, c, d, e] = input.left;
left.put(at, 2, a.차례, [{ 노임: "LB000002", 이름: "보통인부", 입력값: {}, 배: 1 }]);
left.put(at, 2, b.차례, [{ 기계: "EQ000069", 이름: "덤프트럭", 입력값: {}, 배: 1 }]);
left.put(at, 2, c.차례, [{ 비율: "노무비", 이름: "노무비", 입력값: {}, 배: 1 }]);
left.put(at, 2, d.차례, null);
const half = await left.countLeft(at, [1, 2]);
left.put(at, 2, e.차례, [{ 로직: "GF000113", 이름: "되메우기", 입력값: {}, 배: 1 }]);
const after = await left.countLeft(at, [1, 2]);
const body = left.draftLines(at, after.get(2).one);
const count = (m) => left.leftCount(m);
process.stdout.write(
JSON.stringify({
stale,
before: count(before),
half: count(half),
after: count(after),
body,
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) => {
process.stderr.write(String(error?.stack ?? error));
process.exit(1);
});
@@ -14,6 +14,7 @@ const STUB_FILES = [
"M02_MasterTemplete_Labor_Api",
"M02_MasterTemplete_Labor_Input",
"M01_MasterData_UI_LogicLab_Source",
"M02_MasterTemplete_Hopyo_Left", // 남은 줄 창(729-3 · DOM · 찾기 창)
"M02_MasterTemplete_Hopyo_CheckRows", // 표 그리기(DOM · Labor_Rows) — 359-3
];
const opened = [];
@@ -15,6 +15,7 @@ const STUB_FILES = [
"M02_MasterTemplete_Labor_Api",
"M02_MasterTemplete_Labor_Input",
"M01_MasterData_UI_LogicLab_Source",
"M02_MasterTemplete_Hopyo_Left", // 남은 줄 창(729-3 · DOM · 찾기 창)
];
const stub = new Proxy({}, { get: (_t, k) => (k === "currentLanguageIndex" ? () => 0 : () => {}) });
const { loadTs } = makeLoader({
@@ -15,6 +15,7 @@ const STUB_FILES = [
"M02_MasterTemplete_Labor_Api",
"M02_MasterTemplete_Labor_Input",
"M01_MasterData_UI_LogicLab_Source",
"M02_MasterTemplete_Hopyo_Left", // 남은 줄 창(729-3 · DOM · 찾기 창)
];
const stub = new Proxy({}, { get: (_t, k) => (k === "currentLanguageIndex" ? () => 0 : () => {}) });
const { loadTs } = makeLoader({
@@ -0,0 +1,115 @@
"""일위대가 불러오기 마법사 안 이은 줄 화면(PLAN 729-3 · 요구서 내역19 1장 8 · 2장 4).
헬퍼(`helper_729_3_hopyo_left.cjs` · fetch · sessionStorage 대역)로 진짜 TS 를 돌림 —
만들기 409 `{까닭, 남은줄}` = StaleError 까닭 글 + 남은 줄 · 남은 수 = 안 만든 호표만(만든 호표는 안 물음) ·
줄마다 고름(노임 · 기계 · 비율 · 우리에 없음 · 로직) = 확인 창 캐시 → 만들기 몸 그대로 · 0 될 때까지 ·
일위대가표 안 이은 고정 줄(비율 · 이음 있는 줄 뺌) · 고름 대기 후보 글.
소스: [끝내기] = 남은 줄 다시 셈 → 있으면 막음(띠 · 표) · 단가산출 칸 · 목록 열 · 화면 요소 = 부품 상자 · 700 줄.
"""
import json
import os
import re
import subprocess
from pathlib import Path
HERE = os.path.dirname(os.path.abspath(__file__))
M02 = Path(HERE).parents[1] / "M02_MasterTemplete"
def _line(no: int, kind: str = "자재", state: str = "후보") -> dict:
match = {"열쇠": f"줄{no}", "상태": state, "체크": False, "대상": []}
return {"차례": no, "이름": f"줄{no}", "규격": "", "수량": 1, "단위": "㎥", "갈래": kind,
"단가": {}, "금액": {}, "비고": "", "맞춤": match} # fmt: skip
def _left(no: int, kind: str) -> dict:
return {"차례": no, "이름": f"줄{no}", "규격": "", "단위": "㎥", "수량": 1, "갈래": kind,
"까닭": "이을 것 없음", "후보": []} # fmt: skip
LINES = [_line(0, "노임"), _line(1, "기계"), _line(2, "%", "%"), _line(3), _line(4, "단가산출")]
LEFT = [_left(0, "노임"), _left(1, "기계"), _left(2, "%"), _left(3, "자재"), _left(4, "단가산출")]
FIXES = [
{"고정": {"이름": "안이음", "단가": {}, "이음": {"노임": None, "자재": None, "기계": None}}},
{
"고정": {
"이름": "이음",
"단가": {},
"이음": {"노임": "LB000002", "자재": None, "기계": None},
}
},
{"고정": {"이름": "비율", "단가": {}, "비율": {"기준": "노무비", "비목": "경비"}}},
{"로직": "GF000113"},
]
def _run() -> dict:
payload = {
"one": {"키": "HP-000001", "파일": "북부청.xls", "호표": {"이름": "x", "줄": LINES}},
"made": [1],
"left": LEFT,
"fixes": FIXES,
}
proc = subprocess.run(
["node", os.path.join(HERE, "helper_729_3_hopyo_left.cjs")],
input=json.dumps(payload, 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()
# 만들기 409 = StaleError · 까닭 글 · 남은 줄 그대로
assert got["stale"]["name"] == "StaleError" and got["stale"]["message"] == "남음"
assert got["stale"]["left"] == LEFT
# 만든 호표(1) 는 안 물음 · 안 만든 호표(2) 만 남은 줄
assert got["before"] == {"줄": 5, "호표": 1}
assert all(u.endswith("/layers/personal/hopyo-unlinked") for u in got["unlinkedCalls"])
assert len(got["unlinkedCalls"]) == 3 # 셈 세 번 × 안 만든 호표 하나
assert got["half"] == {"줄": 1, "호표": 1}
assert got["after"] == {"줄": 0, "호표": 0}
# 고른 것 = 만들기 몸(서버 이음 대상 꼴 · 입력값 없음 · 배 1)
body = {x["차례"]: x for x in got["body"]}
assert body[0] == {"차례": 0, "대상": [{"노임": "LB000002", "배": 1}]}
assert body[1] == {"차례": 1, "대상": [{"기계": "EQ000069", "배": 1}]}
assert body[2] == {"차례": 2, "대상": [{"비율": "노무비", "배": 1}]} # % 줄 = 밑수 고른 것만
assert body[3] == {"차례": 3, "뺌": "우리에 없음"}
assert body[4] == {"차례": 4, "대상": [{"로직": "GF000113", "입력값": {}, "배": 1}]}
assert got["lastBody"]["줄"] == got["body"] and got["lastBody"]["번"] == 2
# 일위대가표 띠 = 이음 · 비율 없는 고정 줄만
assert got["fixes"] == ["안이음"]
assert got["cand"] == "되메우기 [㎥] · 후보"
assert got["cand2"] == "되메우기 [㎥] · 다른 점 · 보통인부 품 다름"
def _text(name: str) -> str:
return (M02 / name).read_text(encoding="utf-8")
def test_마법사_끝내기_막음_단가산출_칸_목록_열():
wizard = _text("M02_MasterTemplete_Hopyo_Wizard.ts")
end = wizard.split("async function end()")[1]
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 and "picks[no] = { 로직: s.value }" in sk
lib = _text("M02_MasterTemplete_Library_List.ts")
assert 'L("M02_LibUnlinked"), (r) => (r.안이은줄' in lib
assert "unlinkedBar(doc, edit)" in _text("M02_MasterTemplete_Labor_Grid.ts")
for name in (
"M02_MasterTemplete_Hopyo_Left.ts",
"M02_MasterTemplete_Hopyo_WizardSk.ts",
"M02_MasterTemplete_Labor_Unlinked.ts",
):
src = _text(name)
assert len(src.splitlines()) <= 700, name
assert 'el("button"' not in src and 'el("input"' not in src, name # 부품 상자만
assert not re.search(r"#[0-9a-fA-F]{3,6}|rgb\(", src), name # 색 = 공통 값
+3 -1
View File
@@ -358,7 +358,9 @@ def test_좌측_판_자리와_권한():
assert 'accept: ".xls,.xlsx"' in wizard and "input.hidden = true;" in wizard
assert "accessFor(layer, null, user).canWrite" in wizard # 결과 층 = 쓸 수 있는 층만
assert 'let pick: ManageLayer = "personal";' in wizard # 기본 개인
assert "let wantSangeun = false;" in wizard and "/sangeun`" in wizard # 결과 단가산출 체크
assert (
"let wantSangeun = false;" in wizard and "sangeunStep(scope, file.키)" in wizard
) # 결과 단가산출 체크 → 단가산출 칸(729-3)
def test_로직_추가_한_곳과_파일_규칙():
+72
View File
@@ -255,4 +255,76 @@ export const ui_locales_m2 = {
M02_FindLogic: ["단가산출 기초", "Unit-cost logic"],
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_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_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 에 안 이어질 엑셀 줄 — 줄마다 이을 것을 고름 · 고른 뒤 엑셀 보기 창 [저장] 또는 호표 [수정] 생성",
"Excel rows that would stay unlinked — pick a link per row, then save in the sheet window",
],
M02_LeftBand: ["안 이은 줄 {n} (호표 {h})", "Unlinked rows {n} (sheets {h})"],
M02_LeftZero: ["안 이은 줄 0", "No unlinked rows"],
M02_LeftUnknown: [
"안 이은 줄 — 아직 안 셈 · [끝내기] 때 다시 셈",
"Unlinked rows — not counted yet",
],
M02_LeftBlocked: ["안 이은 줄이 남아 [끝내기] 막힘", "Finish blocked — unlinked rows left"],
M02_LeftLoose: ["고른 뒤 안 만든 호표 {n} 개는 빠짐", "{n} picked but unmade sheets are dropped"],
M02_LeftOpen: ["안 이은 줄 고르기", "Pick unlinked rows"],
M02_LeftCounting: ["안 이은 줄 셈 중", "Counting unlinked rows"],
M02_LeftHopyo: ["호표", "Sheet"],
M02_LeftSpec: ["규격", "Spec"],
M02_LeftKind: ["갈래", "Kind"],
M02_LeftWhy: ["까닭", "Reason"],
M02_LeftPicked: ["고른 것", "Picked"],
M02_LeftPick: ["고름", "Pick"],
M02_LeftBase: ["기초", "Base"],
M02_LeftMakeSk: ["SK 만들기", "Make SK"],
M02_LeftSkNone: ["만든 단가산출 없음 — 고름 대기 그대로", "Nothing made — still waiting"],
M02_LeftChoose: ["고르기", "Pick"],
M02_LeftFind: ["찾기", "Find"],
M02_LeftMachine: ["기계", "Machine"],
M02_LeftMachineFind: ["기계 이름 · 규격으로 찾기", "Search machines by name or spec"],
M02_LeftNoneTip: [
"우리 M01 에 없음 — 엑셀 숫자 그대로 고정 줄(사용자가 고른 것으로 남음)",
"Not in M01 — keep the Excel numbers as a fixed row",
],
M02_GridUnlinked: ["M01 연결 없음 {n} 줄(옛 자료)", "{n} rows not linked to M01 (old data)"],
M02_GridLink: ["연결", "Link"],
M02_GridLinkTitle: ["안 이은 줄 연결", "Link unlinked rows"],
M02_GridLinkNoLogic: [
"고정 줄은 노임 · 자재 · 기계로 이음 — 기초는 [로직 추가]로 줄을 바꿈",
"Fixed rows link to labor, material or machine only",
],
M02_LibUnlinked: ["안 이은 줄", "Unlinked"],
} as const;