feat(일위대가실무): 324-4 화면 — 좌측 「일위대가 실무자료」 판 · 본 화면(호표 표 · 줄 맞춤 · 확정 · 빠진 줄 · 값 비교) · [로직 추가] 「실무 호표」 줄

- 좌측 판(Hopyo_Side): 일위대가 사전조합 아래 · 구조물 조합 층 · 쓰기 권한 없으면 [엑셀 올리기] 꺼짐 · 숨은 file 입력 .xls,.xlsx · 파일 줄 → 본 화면
- 본 화면(Hopyo_View): 파일 · 시트 · 호표 수 · 검산 · 맞춤 n/m · 호표 표 + 찾기 · [지우기] / 호표 줄 표(갈래 · 품명 · 규격 · 수량 · 단위 · 엑셀 단가 · 맞춤 · 대상 · 입력값 · 환산) · [고르기](후보 5 + 다른 로직 · 자재 찾기 → 미리 채움) · [확정] · [빼기] · [되살림] · [같음 모두 확정] · 빠진 줄 · [사전조합 만들기] → 값 비교 · 다시 씀이면 [새로 만들기](확인 뒤 덮음)
- [로직 추가](Hopyo_Pick · Labor_Pick 늦은 덧줄 `more` · 종류 고르기): 문서 층 · 위 층 호표 줄(맞춤 n/m) → 고르면 서버가 문서 층 사전조합을 만들고 참조 줄(별칭 = 호표 품명) · 빠진 줄 알림 · 프로젝트 층 없음
- Labor_Detail: 제자리 넷 줄(가져오기 · pick 별칭 받음 · withHopyo) · 693 줄 그대로
- 서버: GET /layers/{층}/hopyo-picks(이 층 · 위 층 · 이 층 기억)
- 색 = 공용 토큰(밝음 · 어두움) · 시험 test_m02_hopyo_screen 4 + hopyo-picks 1 · typecheck 0

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
This commit is contained in:
2026-10-14 22:27:57 +09:00
co-authored by Claude Opus 5.5
parent 736aa405cf
commit 56f606f331
13 changed files with 1278 additions and 19 deletions
+17
View File
@@ -445,6 +445,23 @@ def view(folder: Path, key: str, no: int | None = None) -> dict[str, Any]:
return {**hopyo_store.brief(doc), "호표": match_hopyo(hopyo, memo, units)}
def picks(folder: Path) -> list[dict[str, Any]]:
"""[로직 추가] 「실무 호표」 줄 — 이 층 · 위 층 실무자료 호표마다 맞춤 n/m
(이 층 기억 · 만드는 층)."""
memo, got, out = memories(folder), targets(), []
for one in chain(folder):
layer, owner = refs.where(one)
for row in hopyo_store.rows(one):
doc = hopyo_store.read(one, row["키"])
units = units_of(doc)
for hopyo in doc.get("호표") or []:
ready = match_hopyo(hopyo, memo, units, False, got)["맞춤"]
head = {"층": layer, "주인": owner, "키": row["키"], "파일": row["파일"]}
keep = ("번", "이름", "규격", "단위")
out.append({**head, **{k: hopyo.get(k) for k in keep}, "맞춤": ready})
return out
# ── 확정 저장 ─────────────────────────────────────────────────────────
def confirm(folder: Path, entries: dict[str, Any]) -> dict[str, Any]:
"""확정 · 빼기 · 지움 → 층 기억 — `{열쇠: {로직|자재|빼기, 입력값, 환산} | None}`.
+12 -1
View File
@@ -188,6 +188,16 @@ def hopyo_list(
return {"줄": hopyo_store.rows(folder)}
@router.get("/layers/{layer}/hopyo-picks")
def hopyo_picks(
layer: Layer, owner: str | None = None, session: Session = Depends(verify_session)
) -> dict:
"""[로직 추가] 「실무 호표」 줄 — 이 층 · 위 층 파일의 호표마다
`{층, 주인, 키, 파일, 번, 이름, 규격, 단위, 맞춤}`(맞춤 n/m = 이 층 기억)."""
folder = _root(session, layer, owner)
return {"줄": _call(folder, hopyo_match.picks, folder)}
@router.post("/layers/{layer}/hopyo")
def hopyo_upload(
layer: Layer,
@@ -268,7 +278,8 @@ def hopyo_memory(
class BuildBody(BaseModel):
"""실무 호표 → 이 층 사전조합 — 파일 층 `층` · `주인` · `키` + 호표 `번` · `새로` = 있던 것을 덮음."""
"""실무 호표 → 이 층 사전조합 — 파일 층 `층` · `주인` · `키` + 호표 `번` ·
`새로` = 있던 것을 덮음."""
층: Layer
주인: str | None = None
@@ -0,0 +1,118 @@
/* M02 일위대가 실무자료(PLAN 324-4) — 좌측 판 · 본 화면(호표 표 · 줄 맞춤) · 색은 공용 토큰(밝음 · 어두움 같이) */
.m02-hopyo-side {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
}
.m02-hopyo {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
min-width: 0;
}
.m02-hopyo__bar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--spacing-8);
}
.m02-hopyo__note {
margin: 0;
color: var(--color-text-secondary);
font-size: var(--text-body-sm);
}
.m02-hopyo__warn {
margin: 0;
color: var(--color-warning);
font-size: var(--text-body-sm);
white-space: pre-wrap;
}
.m02-hopyo__search {
max-width: 320px;
}
.m02-hopyo__row {
cursor: pointer;
}
.m02-hopyo__row:hover {
background: var(--color-mist-violet);
}
.m02-hopyo__num {
text-align: right;
font-variant-numeric: tabular-nums;
}
.m02-hopyo__line.is-out {
color: var(--color-text-muted);
}
.m02-hopyo__state {
display: inline-block;
padding: 0 var(--spacing-4);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
font-size: var(--text-caption);
white-space: nowrap;
}
.m02-hopyo__state.is-기억,
.m02-hopyo__state.is-같음,
.m02-hopyo__state.is-호표 {
border-color: var(--color-success);
color: var(--color-success);
}
.m02-hopyo__state.is-후보 {
border-color: var(--color-warning);
color: var(--color-warning);
}
.m02-hopyo__state.is-없음 {
border-color: var(--color-danger);
color: var(--color-danger);
}
.m02-hopyo__times {
width: 6em;
}
.m02-hopyo__acts {
white-space: nowrap;
}
.m02-hopyo__acts > button + button {
margin-left: var(--spacing-4);
}
.m02-hopyo__block {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
}
.m02-hopyo__block h4 {
margin: 0;
font-size: var(--text-body-sm);
}
.m02-hopyo__built {
padding: var(--spacing-8);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-surface-raised);
}
.m02-hopyo__cands {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--spacing-4);
margin-bottom: var(--spacing-8);
}
@@ -0,0 +1,190 @@
/* =============================================================================
* M02_MasterTemplete_Hopyo_Api.ts
* 일위대가 실무자료(PLAN 324) 서버 길 — `/api/m02/layers/{층}/hopyo…` · 층 = 화면 층(`Api_Fetch` 지금 층) ·
* 올리기 = FormData(숨은 file 입력) · 그 밖 = 공용 `call`(서버 까닭 글 그대로).
* ========================================================================== */
import { API_BASE_URL } from "@config/config_frontend";
import { call, getActiveLayer, getActiveOwner, type Layer } from "./M02_MasterTemplete_Api_Fetch";
import type { LaborRef } from "./M02_MasterTemplete_Labor_Calc";
/** 층 · 주인(null = 자기 층) */
export interface HopyoScope {
layer: Layer;
owner: string | null;
}
export interface HopyoFile {
키: string;
파일: string;
올린때: string;
올린이: { id: number; 이름: string | null } | null;
시트: string;
호표수: number;
어긋남: number;
}
export type Money = Record<"계" | "노무비" | "재료비" | "경비", number | null>;
export interface HopyoBrief {
번: number;
이름: string;
규격: string;
단위: string;
금액: Money;
/** [들어감 수, 줄 수] */
맞춤: [number, number];
}
export interface HopyoView extends HopyoFile {
검산: { 어긋남: string[]; 총괄표: number };
호표: HopyoBrief[];
맞춤: [number, number];
}
/** 맞춤 대상 — 로직 · 자재 · 다른 호표 */
export interface HopyoTarget {
로직?: string;
자재?: string;
호표?: number;
이름?: string;
규격?: string;
단위?: string | null;
점수?: number;
}
export interface HopyoMatch {
열쇠: string;
상태: "기억" | "같음" | "후보" | "없음" | "빼기" | "호표" | "%";
대상: HopyoTarget | null;
입력값: Record<string, unknown>;
못읽음: string[];
환산: { 배: number | null; 갈래: string } | null;
들어감: boolean;
후보?: HopyoTarget[];
}
export interface HopyoLine {
차례: number;
이름: string;
규격: string;
수량: number | null;
단위: string;
갈래: string;
단가: Money;
금액: Money;
비고: string;
맞춤: HopyoMatch;
}
export interface HopyoOne extends HopyoFile {
호표: HopyoBrief & { 줄: HopyoLine[] };
}
export interface HopyoGuess {
대상: HopyoTarget;
입력값: Record<string, unknown>;
못읽음: string[];
환산: { 배: number | null; 갈래: string };
}
export interface HopyoMissing {
차례: number;
이름: string;
규격: string;
수량: number | null;
단위: string;
금액: number | null;
까닭: string;
}
export interface HopyoBuilt {
참조: LaborRef;
이름: string;
다시씀: boolean;
빠진줄: HopyoMissing[];
비교: { 엑셀: number | null; 우리: number | null; 차이: number | null; 까닭: string | null };
}
/** [로직 추가] 「실무 호표」 줄 하나 */
export interface HopyoPickRow extends Omit<HopyoBrief, "금액"> {
층: Layer;
주인: string | null;
키: string;
파일: string;
}
/** 확정 한 줄 — 로직 · 자재 · 빼기 · null(지움) */
export type HopyoMemory =
| { 로직: string; 입력값: Record<string, unknown>; 환산: number }
| { 자재: string; 입력값: Record<string, unknown>; 환산: number }
| { 빼기: true }
| null;
const enc = encodeURIComponent;
/** 지금 화면 층(구조물 조합 층) */
export const pageScope = (): HopyoScope => ({ layer: getActiveLayer(), owner: getActiveOwner() });
const at = (scope: HopyoScope, path: string): string => {
const who = scope.owner && scope.layer !== "system" ? `owner=${enc(scope.owner)}` : "";
const join = path.includes("?") ? "&" : "?";
return `/layers/${scope.layer}${path}${who ? join + who : ""}`;
};
export const listHopyo = async (scope: HopyoScope): Promise<HopyoFile[]> =>
(await call<{ 줄: HopyoFile[] }>(at(scope, "/hopyo"))).줄;
export const readHopyo = (scope: HopyoScope, key: string): Promise<HopyoView> =>
call(at(scope, `/hopyo/${enc(key)}`));
export const readHopyoOne = (scope: HopyoScope, key: string, no: number): Promise<HopyoOne> =>
call(at(scope, `/hopyo/${enc(key)}/${no}`));
export const guessHopyo = (
scope: HopyoScope,
key: string,
no: number,
차례: number,
대상: HopyoTarget,
): Promise<HopyoGuess> =>
call(at(scope, `/hopyo/${enc(key)}/${no}/guess`), {
method: "POST",
body: JSON.stringify({ 차례, 대상 }),
});
export const saveHopyoMemory = (
scope: HopyoScope,
줄: Record<string, HopyoMemory>,
): Promise<unknown> =>
call(at(scope, "/hopyo-memory"), { method: "POST", body: JSON.stringify({ 줄 }) });
export const deleteHopyo = (scope: HopyoScope, key: string): Promise<unknown> =>
call(at(scope, `/hopyo/${enc(key)}`), { method: "DELETE" });
/** 실무 호표 → `target` 층 사전조합(파일 층 = `from`) */
export const buildHopyo = (
target: HopyoScope,
from: { 층: Layer; 주인: string | null; 키: string; 번: number },
새로 = false,
): Promise<HopyoBuilt> =>
call(at(target, "/hopyo-build"), {
method: "POST",
body: JSON.stringify({ ...from, 새로 }),
});
export const listHopyoPicks = async (scope: HopyoScope): Promise<HopyoPickRow[]> =>
(await call<{ 줄: HopyoPickRow[] }>(at(scope, "/hopyo-picks"))).줄;
/** 엑셀 올리기 — 서버 까닭 글 그대로(시트 없음 · 20 MB · 엉터리) */
export async function uploadHopyo(scope: HopyoScope, file: File): Promise<HopyoFile> {
const form = new FormData();
form.append("file", file);
const response = await fetch(`${API_BASE_URL}/m02${at(scope, "/hopyo")}`, {
method: "POST",
credentials: "include",
body: form,
});
const body = (await response.json().catch(() => ({}))) as { detail?: unknown } & HopyoFile;
if (!response.ok)
throw new Error(typeof body.detail === "string" ? body.detail : `HTTP ${response.status}`);
return body;
}
@@ -0,0 +1,58 @@
/* =============================================================================
* M02_MasterTemplete_Hopyo_Pick.ts
* [로직 추가] 창의 「실무 호표」 줄(PLAN 324-4) — 일위대가 문서 층 · 위 층 실무자료의 호표마다 한 줄(맞춤 n/m) ·
* 고르면 서버가 그 호표를 문서 층 사전조합으로 만들고(`/hopyo-build` · 있던 것은 다시 씀) `lib.pick` 으로
* 참조 줄(별칭 = 호표 품명) · 빠진 줄이 있으면 알림. 프로젝트 층엔 없음.
* ========================================================================== */
import { showToast } from "@ui/ui_template_elements";
import { buildHopyo, listHopyoPicks, type HopyoPickRow } from "./M02_MasterTemplete_Hopyo_Api";
import type { LibraryPick, PickExtra } from "./M02_MasterTemplete_Labor_Pick";
import { tc } from "./M02_MasterTemplete_Labor_Text";
export const HOPYO_KIND = "hopyo";
const LAYER_TEXT: Record<string, string> = { system: "마스터", company: "회사", personal: "개인" };
const ALIAS_MAX = 40; // 별칭 상한(44-6 · 서버 `ALIAS_MAX`)
/** 서고 고르기에 「실무 호표」 덧줄 · 종류 고르기(단가산출 · 사전조합 · 실무 호표)를 붙임 */
export function withHopyo(lib: LibraryPick): LibraryPick {
if (lib.layer === "project") return lib;
const scope = { layer: lib.layer, owner: lib.owner };
return {
...lib,
kindFilter: lib.kindFilter ?? [
{ value: "logic", text: tc("Kind_Logic") },
{ value: "precombo", text: tc("Kind_Combo") },
{ value: HOPYO_KIND, text: "실무 호표" },
],
more: async () => (await listHopyoPicks(scope)).map((row) => hopyoExtra(row, lib)),
};
}
/** 실무 호표 한 줄 */
export function hopyoExtra(row: HopyoPickRow, lib: LibraryPick): PickExtra {
return {
kind: HOPYO_KIND,
key: `${row.층}|${row.주인 ?? ""}|${row.키}|${row.번}`,
tag: "실무 호표",
name: [row.이름, row.규격].filter(Boolean).join(" "),
sub: `${row.파일} · 제${row.번}호표 · ${LAYER_TEXT[row.층] ?? row.층}`,
unit: `${row.단위} · 맞춤 ${row.맞춤[0]}/${row.맞춤[1]}`,
onPick: () => void pickHopyo(row, lib),
};
}
/** 고름 — 서버가 사전조합을 만들고(다시 씀) 참조 줄을 더함 */
export async function pickHopyo(row: HopyoPickRow, lib: LibraryPick): Promise<void> {
try {
const from = { 층: row.층, 주인: row.주인, 키: row.키, 번: row.번 };
const got = await buildHopyo({ layer: lib.layer, owner: lib.owner }, from);
const miss = got.빠진줄.length;
const head = `「${row.이름}」 → 사전조합 ${got.참조.키}${got.다시씀 ? "(있던 것)" : ""}`;
if (miss) showToast(`${head} · 빠진 줄 ${miss} — 실무자료 화면에서 맞춤`, "warning", 6000);
else showToast(head, "success");
lib.pick(got.참조, got.이름, row.이름.slice(0, ALIAS_MAX).trim() || null);
} catch (error) {
showToast(error instanceof Error ? error.message : String(error), "error");
}
}
@@ -0,0 +1,128 @@
/* =============================================================================
* M02_MasterTemplete_Hopyo_Side.ts
* 좌측 「일위대가 실무자료」 컨테이너(PLAN 324-4 · 일위대가 사전조합 아래) — 층 = 구조물 조합 층(화면 층) ·
* [엑셀 올리기](숨은 file 입력 .xls · .xlsx · 쓰기 권한 없으면 꺼짐) + 파일 줄 → 누르면 본 화면
* (`M02_MasterTemplete_Hopyo_View`) · 고름 표시 = 본 화면이 메인 칸에 붙어 있는 동안만.
* ========================================================================== */
import { createButton, el, showToast } from "@ui/ui_template_elements";
import { listHopyo, pageScope, uploadHopyo, type HopyoFile } from "./M02_MasterTemplete_Hopyo_Api";
import { buildHopyoView } from "./M02_MasterTemplete_Hopyo_View";
export const HOPYO_TITLE = "일위대가 실무자료";
export interface HopyoSectionOptions {
/** 이 층 쓰기 권한 — 없으면 올리기 꺼짐 · 본 화면 확정 · 만들기 없음 */
canWrite: boolean;
/** 저장 안 한 고침을 버릴지 확인 — 취소면 false */
confirmLeave: () => Promise<boolean>;
/** 메인 칸에 화면을 띄움 */
show: (label: string, node: HTMLElement) => (dirty: boolean) => void;
/** 화면을 열었음 — 좌측 다른 고름 표시를 지움 */
onOpen: () => void;
}
export interface HopyoSection {
title: string;
body: HTMLElement;
/** 다른 화면이 열렸으면 고름 표시를 지움 */
paint: () => void;
refresh: () => Promise<void>;
}
const why = (error: unknown): string => (error instanceof Error ? error.message : String(error));
export function buildHopyoSection(opt: HopyoSectionOptions): HopyoSection {
const scope = pageScope();
const list = el("div", { className: "m02-side__items ui-sidebar-list" });
const input = el("input", {
attrs: { type: "file", accept: ".xls,.xlsx", "data-hopyo-file": "" },
}) as HTMLInputElement;
input.hidden = true;
const upload = createButton({
label: "엑셀 올리기",
variant: "filled",
size: "sm",
disabled: !opt.canWrite,
onClick: () => input.click(),
});
upload.title = opt.canWrite ? "설계내역서 엑셀(일위대가 시트)" : "이 층 쓰기 권한 없음";
const body = el("div", {
className: "m02-hopyo-side",
children: [el("div", { className: "m02-side__row", children: [upload, input] }), list],
});
let files: HopyoFile[] = [];
let open: { key: string; view: HTMLElement } | null = null;
let seq = 0;
input.addEventListener("change", () => {
const file = input.files?.[0];
input.value = "";
if (!file) return;
upload.disabled = true;
uploadHopyo(scope, file)
.then(async (got) => {
const wrong = got.어긋남 ? ` · 검산 어긋남 ${got.어긋남}` : "";
showToast(`올림 — 호표 ${got.호표수}${wrong}`, got.어긋남 ? "warning" : "success");
await refresh();
await show(got);
})
.catch((error) => showToast(why(error), "error"))
.finally(() => (upload.disabled = !opt.canWrite));
});
function paintList(): void {
list.replaceChildren(
...(files.length
? files.map((f) => {
const row = el("button", {
className: `m02-side__item ui-sidebar-row${open?.key === f.키 ? " is-active" : ""}`,
text: `${f.파일} · 호표 ${f.호표수}`,
attrs: { type: "button", title: `${f.키} · ${f.올린때}`, "data-hopyo-key": f.키 },
});
row.addEventListener("click", () => void show(f));
return row;
})
: [el("p", { className: "m02-side__empty", text: "올린 실무자료 없음" })]),
);
}
async function show(file: HopyoFile): Promise<void> {
if (open?.key === file.키 && open.view.isConnected) return;
if (!(await opt.confirmLeave())) return;
const view = buildHopyoView(file.키, {
scope,
canWrite: opt.canWrite,
onDeleted: () => {
view.replaceChildren(el("p", { className: "m02-side__empty", text: "지운 실무자료" }));
void refresh();
},
});
opt.show(`${HOPYO_TITLE} — ${file.파일}`, view);
opt.onOpen();
open = { key: file.키, view };
paintList();
}
async function refresh(): Promise<void> {
const mine = ++seq;
try {
const got = await listHopyo(scope);
if (mine === seq) files = got;
} catch (error) {
if (mine === seq) files = [];
showToast(why(error), "error");
}
if (mine === seq) paintList();
}
const paint = (): void => {
if (open && !open.view.isConnected) {
open = null;
paintList();
}
};
void refresh();
return { title: HOPYO_TITLE, body, paint, refresh };
}
@@ -0,0 +1,478 @@
/* =============================================================================
* M02_MasterTemplete_Hopyo_View.ts
* 일위대가 실무자료 본 화면(PLAN 324-4) — 파일 하나: 파일 · 시트 · 호표 수 · 검산 · 맞춤 n/m · 호표 표 + 찾기 ·
* [지우기] / 호표 하나: 줄 표(갈래 · 품명 · 규격 · 수량 · 단위 · 엑셀 단가 · 맞춤 · 대상 · 입력값 · 환산 ·
* [고르기] [확정] [빼기]) · [같음 모두 확정] · 빠진 줄 · [사전조합 만들기] → 값 비교 · 다시 씀이면 [새로 만들기].
* 맞춤 · 만들기는 서버(`M02_Hopyo_Match` · `M02_Hopyo_Build`) · 쓰기 단추는 층 쓰기 권한만.
* ========================================================================== */
import { createButton, el, showConfirmDialog, showToast } from "@ui/ui_template_elements";
import { loadingError, showLoading } from "@ui/ui_template_loading";
import { openModal } from "@ui/ui_template_modal";
import {
buildHopyo,
deleteHopyo,
guessHopyo,
readHopyo,
readHopyoOne,
saveHopyoMemory,
type HopyoBuilt,
type HopyoGuess,
type HopyoLine,
type HopyoMemory,
type HopyoOne,
type HopyoScope,
type HopyoTarget,
type HopyoView,
} from "./M02_MasterTemplete_Hopyo_Api";
import { openLogicPicker } from "./M02_MasterTemplete_Labor_Pick";
import { pickMaterial } from "./M02_Material_Pick";
import "./M02_MasterTemplete_Hopyo.css";
export interface HopyoViewOptions {
scope: HopyoScope;
/** 층 쓰기 권한 — 없으면 확정 · 만들기 · 지우기 단추 없음 */
canWrite: boolean;
/** 파일을 지웠음 — 좌측 목록 다시 */
onDeleted: () => void;
}
const STATE: Record<string, string> = {
기억: "기억",
같음: "같음",
후보: "후보",
없음: "없음",
빼기: "뺌",
호표: "호표",
"%": "% 줄",
};
const MATERIAL = new Set(["자재", "별도계상"]);
const why = (error: unknown): string => (error instanceof Error ? error.message : String(error));
const won = (v: number | null | undefined): string =>
v === null || v === undefined ? "" : v.toLocaleString("ko-KR");
const fail = (error: unknown): void => showToast(`실패 — ${why(error)}`, "error");
const grid = (heads: string[], body: HTMLElement, cls = ""): HTMLElement =>
el("div", {
className: "ui-general-block__table-wrap ui-general-block__table-wrap--sticky-head",
children: [
el("table", {
className: `ui-general-block__table m02-hopyo__table ${cls}`.trim(),
children: [
el("thead", {
children: [el("tr", { children: heads.map((t) => el("th", { text: t })) })],
}),
body,
],
}),
],
});
const table = (heads: string[], rows: HTMLElement[]): HTMLElement =>
grid(heads, el("tbody", { children: rows }));
const td = (text: string, cls = ""): HTMLElement =>
el("td", { text, className: cls, attrs: { title: text } });
const note = (text: string): HTMLElement => el("span", { className: "m02-hopyo__note", text });
/** 대상 글 — 「이름 [단위]」 · 자재는 규격도 · 호표 = 「제N호표」 */
export const targetText = (t: HopyoTarget | null | undefined): string => {
if (!t) return "";
if (t.호표 !== undefined) return `제${t.호표}호표 ${t.단위 ? `[${t.단위}]` : ""}`.trim();
const spec = t.규격 ? ` ${t.규격}` : "";
return `${t.이름 ?? t.로직 ?? t.자재 ?? ""}${spec}${t.단위 ? ` [${t.단위}]` : ""}`;
};
/** 입력값 글 — 「이름=값」 · 못 읽은 입력 표시 */
export const inputsText = (values: Record<string, unknown>, unread: string[]): string =>
[
Object.entries(values)
.map(([k, v]) => `${k}=${String(v)}`)
.join(", "),
unread.length ? `⚠ 못 읽음: ${unread.join(", ")}` : "",
]
.filter(Boolean)
.join(" · ");
/** 확정 한 줄 몸 — 대상 · 입력값 · 환산(0 넘는 수) · 없으면 null */
export function memoryOf(
target: HopyoTarget | null,
values: Record<string, unknown>,
times: number | null,
): HopyoMemory {
if (!target || times === null || !(times > 0)) return null;
if (target.로직) return { 로직: target.로직, 입력값: values, 환산: times };
if (target.자재) return { 자재: target.자재, 입력값: values, 환산: times };
return null;
}
export function buildHopyoView(key: string, opt: HopyoViewOptions): HTMLElement {
const view = el("div", { className: "m02-hopyo", attrs: { "data-hopyo-view": key } });
let nav = 0; // 늦게 온 응답 버림
let query = "";
async function load<T>(get: () => Promise<T>, retry: () => void): Promise<T | null> {
const my = ++nav;
const stop = showLoading(view);
try {
const got = await get();
return my === nav ? got : null;
} catch (error) {
if (my === nav) view.replaceChildren(loadingError(why(error), retry));
return null;
} finally {
stop();
}
}
/* ── 파일 하나 — 호표 표 ─────────────────────────────────────── */
async function drawFile(): Promise<void> {
const file = await load(
() => readHopyo(opt.scope, key),
() => void drawFile(),
);
if (!file) return;
const search = el("input", {
className: "ui-input ui-input--sm m02-hopyo__search",
attrs: { type: "search", placeholder: "호표 · 품명 · 규격 찾기", value: query },
}) as HTMLInputElement;
const body = el("tbody");
const paint = (): void => {
const q = search.value.trim().toLowerCase();
query = search.value;
body.replaceChildren(
...file.호표
.filter((h) => !q || `제${h.번}호표 ${h.이름} ${h.규격}`.toLowerCase().includes(q))
.map((h) => {
const tr = el("tr", {
className: "m02-hopyo__row",
attrs: { "data-hopyo-no": String(h.번) },
children: [
td(`제${h.번}호표`),
td(h.이름),
td(h.규격),
td(h.단위),
td(won(h.금액.계), "m02-hopyo__num"),
td(`${h.맞춤[0]} / ${h.맞춤[1]}`, "m02-hopyo__num"),
],
});
tr.addEventListener("click", () => void drawOne(h.번));
return tr;
}),
);
};
search.addEventListener("input", paint);
paint();
const wrong = file.검산.어긋남;
view.replaceChildren(
el("div", {
className: "m02-hopyo__bar",
children: [
el("strong", { text: file.파일 }),
note(`시트 ${file.시트}`),
note(`호표 ${file.호표수}`),
note(wrong.length ? `검산 어긋남 ${wrong.length}` : "검산 맞음"),
note(`맞춤 ${file.맞춤[0]} / ${file.맞춤[1]}`),
...(opt.canWrite ? [dropButton(file)] : []),
],
}),
...(wrong.length ? [el("p", { className: "m02-hopyo__warn", text: wrong.join("\n") })] : []),
search,
grid(["호표", "품명", "규격", "단위", "엑셀 단가", "맞춤"], body),
);
}
const dropButton = (file: HopyoView): HTMLButtonElement =>
createButton({
label: "지우기",
variant: "danger",
size: "sm",
onClick: async () => {
const ask = `실무자료 「${file.파일}」을 지움 — 만든 사전조합 · 맞춤 기억은 그대로`;
if (!(await showConfirmDialog(ask, "지우기"))) return;
try {
await deleteHopyo(opt.scope, key);
showToast("지움", "success");
opt.onDeleted();
} catch (error) {
fail(error);
}
},
});
/* ── 호표 하나 — 줄 맞춤 ────────────────────────────────────── */
async function drawOne(no: number, built?: HopyoBuilt): Promise<void> {
const got = await load(
() => readHopyoOne(opt.scope, key, no),
() => void drawOne(no),
);
if (!got) return;
const h = got.호표;
const picked = new Map<number, HopyoGuess>(); // 고른 대상(확정 전)
const back = createButton({ label: "← 호표 목록", variant: "ghost", size: "sm" });
back.addEventListener("click", () => void drawFile());
const rows = el("tbody");
const paintRows = (): void => {
rows.replaceChildren(...h.줄.map((line) => lineRow(got, line, picked, paintRows)));
};
paintRows();
const same = h.줄.filter((x) => x.맞춤.상태 === "같음" && x.맞춤.환산?.배 != null);
const tools: HTMLElement[] = [];
if (opt.canWrite) {
const all = createButton({ label: `같음 모두 확정 (${same.length})`, size: "sm" });
all.disabled = !same.length;
all.addEventListener("click", () => void confirmAll(no, same));
const make = createButton({ label: "사전조합 만들기", variant: "filled", size: "sm" });
make.addEventListener("click", () => void build(no, false));
tools.push(all, make);
}
const missing = h.줄.filter((x) => !x.맞춤.들어감);
view.replaceChildren(
el("div", {
className: "m02-hopyo__bar",
children: [
back,
el("strong", { text: `제${h.번}호표 ${h.이름}` }),
note(h.규격),
note(h.단위),
note(`엑셀 단가 ${won(h.금액.계)}`),
note(`맞춤 ${h.맞춤[0]} / ${h.맞춤[1]}`),
...tools,
],
}),
h.줄.length
? grid(
[
"갈래",
"품명",
"규격",
"수량",
"단위",
"엑셀 단가",
"맞춤",
"대상",
"입력값",
"환산",
"",
],
rows,
"m02-hopyo__lines",
)
: el("p", { className: "m02-hopyo__note", text: "구성 줄 없음" }),
...(missing.length ? [missingBlock(missing)] : []),
...(built ? [builtBlock(no, built)] : []),
);
}
function lineRow(
got: HopyoOne,
line: HopyoLine,
picked: Map<number, HopyoGuess>,
repaint: () => void,
): HTMLElement {
const m = line.맞춤;
const mine = picked.get(line.차례);
const target = mine?.대상 ?? m.대상;
const values = mine?.입력값 ?? m.입력값;
const conv = mine?.환산 ?? m.환산;
const fixed = ["%", "호표", "빼기"].includes(m.상태);
const times = el("input", {
className: "ui-input ui-input--sm m02-hopyo__times",
attrs: {
type: "number",
step: "any",
min: "0",
value: conv?.배 != null ? String(conv.배) : "",
},
}) as HTMLInputElement;
times.disabled = !opt.canWrite || fixed || !target;
const state = mine ? "고름" : (STATE[m.상태] ?? m.상태);
const actions: HTMLElement[] = [];
if (opt.canWrite && m.상태 === "빼기") {
actions.push(
createButton({
label: "되살림",
variant: "ghost",
size: "sm",
onClick: () => void remember(got.호표.번, { [m.열쇠]: null }),
}),
);
} else if (opt.canWrite && !["%", "호표"].includes(m.상태)) {
actions.push(
createButton({
label: "고르기",
variant: "ghost",
size: "sm",
onClick: () =>
choose(line, (t) =>
guessHopyo(opt.scope, key, got.호표.번, line.차례, t).then(
(g) => (picked.set(line.차례, g), repaint()),
fail,
),
),
}),
createButton({
label: "확정",
size: "sm",
disabled: !target,
onClick: () => {
const body = memoryOf(target, values, times.value === "" ? null : Number(times.value));
if (!body) return showToast("대상과 0 넘는 환산을 넣어야 확정", "error");
void remember(got.호표.번, { [m.열쇠]: body });
},
}),
createButton({
label: "빼기",
variant: "ghost",
size: "sm",
onClick: () => void remember(got.호표.번, { [m.열쇠]: { 빼기: true } }),
}),
);
}
return el("tr", {
className: `m02-hopyo__line is-${m.들어감 ? "in" : "out"}`,
attrs: { "data-line": String(line.차례), "data-state": m.상태 },
children: [
td(line.갈래),
td(line.이름),
td(line.규격),
td(line.수량 === null ? "" : String(line.수량), "m02-hopyo__num"),
td(line.단위),
td(won(line.단가.계), "m02-hopyo__num"),
el("td", {
children: [el("span", { className: `m02-hopyo__state is-${m.상태}`, text: state })],
}),
td(targetText(target)),
td(inputsText(values, mine?.못읽음 ?? m.못읽음)),
el("td", {
children: [times, note(conv?.갈래 ? ` ${conv.갈래}` : "")],
}),
el("td", { className: "m02-hopyo__acts", children: actions }),
],
});
}
/** 대상 고르기 — 후보 5 + 다른 로직 · 자재 찾기 */
function choose(line: HopyoLine, done: (t: HopyoTarget) => void): void {
const material = MATERIAL.has(line.갈래);
openModal({
title: `고르기 — ${line.이름} ${line.규격}`.trim(),
closeLabel: "닫기",
dialogClass: "m02-modal m02-hopyo__choose",
mount: (body, close) => {
const pick = (t: HopyoTarget): void => {
close();
done(t);
};
const list = (line.맞춤.후보 ?? []).map((c) =>
createButton({
label: `${targetText(c)} · ${c.로직 ?? c.자재 ?? ""}`,
variant: "ghost",
size: "sm",
onClick: () => pick(c),
}),
);
const more = createButton({
label: material ? "다른 자재 찾기…" : "다른 로직 찾기…",
size: "sm",
onClick: () => {
close();
if (material) pickMaterial((x) => done({ 자재: x.코드 }));
else openLogicPicker((x) => done({ 로직: x.키 }));
},
});
body.append(
el("p", { className: "m02-hopyo__note", text: list.length ? "후보" : "후보 없음" }),
el("div", { className: "m02-hopyo__cands", children: list }),
more,
);
},
});
}
async function remember(no: number, 줄: Record<string, HopyoMemory>): Promise<void> {
try {
await saveHopyoMemory(opt.scope, 줄);
await drawOne(no);
} catch (error) {
fail(error);
}
}
async function confirmAll(no: number, lines: HopyoLine[]): Promise<void> {
const body: Record<string, HopyoMemory> = {};
for (const x of lines) {
const one = memoryOf(x.맞춤.대상, x.맞춤.입력값, x.맞춤.환산?.배 ?? null);
if (one) body[x.맞춤.열쇠] = one;
}
await remember(no, body);
}
async function build(no: number, fresh: boolean): Promise<void> {
try {
const from = { 층: opt.scope.layer, 주인: opt.scope.owner, 키: key, 번: no };
const got = await buildHopyo(opt.scope, from, fresh);
showToast(
got.빠진줄.length ? `만듦 — 빠진 줄 ${got.빠진줄.length}` : "만듦",
got.빠진줄.length ? "warning" : "success",
);
await drawOne(no, got);
} catch (error) {
fail(error);
}
}
const missingBlock = (lines: HopyoLine[]): HTMLElement =>
el("div", {
className: "m02-hopyo__block",
children: [
el("h4", { text: `빠진 줄 ${lines.length} — 사전조합에 안 들어감` }),
table(
["품명", "규격", "수량", "단위", "엑셀 금액", "까닭"],
lines.map((x) =>
el("tr", {
children: [
td(x.이름),
td(x.규격),
td(x.수량 === null ? "" : String(x.수량), "m02-hopyo__num"),
td(x.단위),
td(won(x.금액.계), "m02-hopyo__num"),
td(STATE[x.맞춤.상태] ?? x.맞춤.상태),
],
}),
),
),
],
});
function builtBlock(no: number, got: HopyoBuilt): HTMLElement {
const c = got.비교;
const again = got.다시씀
? [
note(`있던 사전조합 ${got.참조.키} 를 다시 씀`),
createButton({
label: "새로 만들기",
variant: "danger",
size: "sm",
onClick: async () => {
const ask = `사전조합 ${got.참조.키} 를 지금 맞춤으로 다시 만들어 덮음`;
if (await showConfirmDialog(ask, "새로 만들기")) await build(no, true);
},
}),
]
: [note(`새 사전조합 ${got.참조.키}`)];
return el("div", {
className: "m02-hopyo__block m02-hopyo__built",
attrs: { "data-built": got.참조.키 },
children: [
el("div", { className: "m02-hopyo__bar", children: again }),
note(
`값 비교 — 엑셀 ${won(c.엑셀)} · 우리 ${won(c.우리 === null ? null : Math.round(c.우리))}` +
` · 차이 ${won(c.차이)}${c.까닭 ? ` · ${c.까닭}` : ""}`,
),
],
});
}
void drawFile();
return view;
}
@@ -59,7 +59,7 @@ import { openLaborInput } from "./M02_MasterTemplete_Labor_Input";
import { openQtyModal } from "./M02_MasterTemplete_Labor_QtyModal";
import { openRefEdit } from "./M02_MasterTemplete_Labor_RefEdit";
import { afterRowDrag } from "./M02_MasterTemplete_Labor_RowDrag";
import { openLogicPicker } from "./M02_MasterTemplete_Labor_Pick";
import { openLogicPicker, withHopyo } from "./M02_MasterTemplete_Labor_Pick";
import { logicMatRows } from "./M02_MasterTemplete_Labor_LogicMats";
import { setLogicMark, setMark } from "./M02_MasterTemplete_Labor_MatMarks";
import { logicTable, matTable, qtyShort } from "./M02_MasterTemplete_Labor_Rows";
@@ -484,12 +484,12 @@ export function mountLaborDetail(
: opt.layer === getActiveLayer()
? getActiveOwner()
: null,
pick: (ref: LaborRef, name: string) => {
pick: (ref: LaborRef, name: string, 별칭: string | null = null) => {
const id = lineId({ 참조: ref });
if (cur.담은로직.some((r) => lineId(r) === id))
return showToast(tc("Labor_AlreadyRef").replace("{value}", `${ref.키} ${name}`), "error");
const 수량 = lib ? { 손: 1 } : null; // 사전조합 줄 기본 1(51-6)
cur.담은로직.push({ 로직: "", 참조: ref, 별칭: null, 메모: null, 입력값: {}, 수량 });
cur.담은로직.push({ 로직: "", 참조: ref, 별칭, 메모: null, 입력값: {}, 수량 });
changed();
},
};
@@ -507,7 +507,7 @@ export function mountLaborDetail(
void loadLogics().then(() => changed());
},
// 사전조합도 같은 창 · 같은 목록(51-11 · 자기 자신 · 순환은 [저장] 때 서버 422)
library,
withHopyo(library),
),
});
const addMat = addMaterialButton(
@@ -4,7 +4,8 @@
* 단가승격 · 일위조합 줄은 층 단추(위 층만 · 짝 `M02_Labor_Ref.readable`) 층의 서고 · 고르면 참조 {층 · 주인 · 키} ·
* 프로젝트 · 카드엔 서고 줄 없음 · 거름 = 로직 품셈 분류 + 사전조합 새 분류(149-2 · 구조물군 › 공통 + 종류)
* 단가연결(251 · B08 거리이정표 · B09 설계내역서) = 종류 고르기(단가산출 · 사전조합 · 덧붙인 줄 종류) +
* 덧붙일 줄(구조물 조합 · B09 호표) — `lib.extra` · `lib.kindFilter`
* 덧붙일 줄(구조물 조합 · B09 호표) — `lib.extra` · `lib.kindFilter` · 늦게 받는 덧줄 `lib.more`
* (324-4 실무 호표 · `withHopyo`)
* ========================================================================== */
import { createButton, createSelectField, el, showToast } from "@ui/ui_template_elements";
@@ -28,28 +29,35 @@ import { fetchStructureDef } from "./StructOptions/M02_SO_Api";
import "../M01_MasterData/M01_MasterData_UI_Logic_Style.css"; // 창 겉틀
import "./M02_MasterTemplete_Labor.css";
export { withHopyo } from "./M02_MasterTemplete_Hopyo_Pick";
const opts = (values: string[]): { value: string; text: string }[] =>
[L("M01_All"), ...values].map((v, i) => ({ value: i ? v : "", text: v }));
type Scope = Omit<LaborRef, "키">;
/** 덧붙일 줄 하나 — kind = 종류 고르기 값 */
export interface PickExtra {
kind: string;
key: string;
tag: string;
name: string;
sub: string;
unit: string;
onPick: () => void;
}
/** 서고 탭 — 일위대가가 있는 층 · 주인(null = 자기 층) · 고르면 참조 */
export interface LibraryPick {
layer: Layer;
owner: string | null;
pick: (ref: LaborRef, name: string) => void;
/** `alias` = 참조 줄 별칭(324-4 실무 호표 품명 · 없으면 빈칸) */
pick: (ref: LaborRef, name: string, alias?: string | null) => void;
/** 서고 줄만(로직 목록 안 받음 · B09 설계내역서 연결 204-2) · 창 제목 */
libOnly?: boolean;
title?: string;
/** 목록 끝 덧붙일 줄(251 · 구조물 조합 · B09 호표) — kind = 종류 고르기 값 */
extra?: Array<{
kind: string;
key: string;
tag: string;
name: string;
sub: string;
unit: string;
onPick: () => void;
}>;
extra?: PickExtra[];
/** 창을 연 뒤 받는 덧줄(324-4 실무 호표) — 받으면 `extra` 뒤에 붙음 */
more?: () => Promise<PickExtra[]>;
/** 종류 고르기 칸(251 · 값 logic · precombo · 덧붙인 줄 kind) */
kindFilter?: Array<{ value: string; text: string }>;
}
@@ -97,6 +105,7 @@ export function openLogicPicker(done: (logic: LogicSummary) => void, lib?: Libra
const kindOk = (k: string): boolean => !kindSel?.select.value || kindSel.select.value === k;
let loading = false;
let seq = 0;
let later: PickExtra[] = []; // lib.more 로 받은 덧줄
/* 공용 찾기 목록 — 구분 · 상세구분 2열 · 이름 왼쪽 맞춤 · ↑ ↓ Enter */
const finder = createFindList({
placeholder: tc("Pick_Search"),
@@ -120,13 +129,13 @@ export function openLogicPicker(done: (logic: LogicSummary) => void, lib?: Libra
(!detail.select.value || x.row.상세구분 === detail.select.value) &&
(!q || `${x.row.이름} ${x.row.제목 ?? ""}`.toLowerCase().includes(q)),
);
const extra = (lib?.extra ?? []).filter(
const extra = [...(lib?.extra ?? []), ...later].filter(
(x) =>
kindOk(x.kind) &&
!sub.select.value &&
(!q || `${x.tag} ${x.name} ${x.sub}`.toLowerCase().includes(q)),
);
const total = all.length + libRows.length + (lib?.extra?.length ?? 0);
const total = all.length + libRows.length + (lib?.extra?.length ?? 0) + later.length;
const shown = found.length + libFound.length + extra.length;
finder.hint.textContent = loading
? tc("Loading")
@@ -271,6 +280,10 @@ export function openLogicPicker(done: (logic: LogicSummary) => void, lib?: Libra
document.body.append(backdrop);
finder.search.focus();
void lib?.more?.().then(
(got) => ((later = got), find()),
(error) => showToast(error instanceof Error ? error.message : tc("Load_Failed"), "error"),
);
if (!lib?.libOnly)
void Promise.all([fetchLogicSubs(), fetchLogics()])
.then(([s, l]) => {
@@ -1,7 +1,8 @@
/* =============================================================================
* M02_MasterTemplete_UI_Side.ts
* 마스터 템플릿 좌측 패널 — 「구조물 조합」(층 단추 · 나무) · 「일위대가 사전조합」(층 단추 따로 · 2단 폴더 나무 ·
* `UI_Side_Precombo` · 50-3 · 134-3) · 「도면 양식」(새로 · 본떠 · 지우기).
* `UI_Side_Precombo` · 50-3 · 134-3) · 「일위대가 실무자료」(구조물 조합 층 · `Hopyo_Side` · 324-4) ·
* 「도면 양식」(새로 · 본떠 · 지우기).
* 서버 길은 계약 `6_계약.md`.
* 구조물 도면 · 일위대가 · 면적산출 상세는 좌측 줄이 아니라 항목 카드 [수정]으로(PLAN 45-2).
* 화면 이름 「구조물 조합」(PLAN 26-7a) — 파일 이름 · 양식 키 · API 길은 「구조물집계표」 그대로.
@@ -31,6 +32,7 @@ import {
import { STRUCTURE_TABLE } from "./M02_MasterTemplete_Structure";
import { buildOptionsSection } from "./StructOptions/M02_SO_Options";
import { buildPracticeSection } from "./M02_MasterTemplete_Practice";
import { buildHopyoSection } from "./M02_MasterTemplete_Hopyo_Side";
import { canEdit, type Selection } from "./M02_MasterTemplete_UI_Main";
import {
buildPrecomboSection,
@@ -165,11 +167,19 @@ export function buildSide(opt: SideOptions): SideHandle {
onOpen: () => (options.clear(), paint()),
})
: null;
// 일위대가 실무자료(324-4) — 사전조합 아래 · 층 = 구조물 조합 층 · 쓰기 권한 없으면 올리기 꺼짐
const hopyo = buildHopyoSection({
canWrite: canEdit(layer, opt.isAdmin),
confirmLeave: opt.confirmLeave,
show: opt.showView,
onOpen: () => (options.clear(), practice?.clear(), paint()),
});
const listHost = el("div", {
className: "m02-side__lists ui-sidebar-panel",
children: [
summary,
precomboSec,
section(hopyo.title, hopyo.body),
...(extras ? [extras.옵션, extras.도면].filter((s): s is HTMLElement => !!s) : []),
...(practice ? [section(practice.title, practice.body)] : []),
],
@@ -225,6 +235,7 @@ export function buildSide(opt: SideOptions): SideHandle {
function paint(): void {
paintList();
precombo.paint();
hopyo.paint();
paintButtons();
if (opt.getOpen()) options.clear();
if (opt.getOpen()) practice?.clear();
@@ -0,0 +1,83 @@
/* M02 일위대가 실무자료(324-4) 헬퍼 — `M02_MasterTemplete_Hopyo_Pick.ts`(고르기 창 「실무 호표」 덧줄) ·
* `M02_MasterTemplete_Hopyo_View.ts` 글 도우미를 트랜스파일해 Node 로 돌림 · fetch 대역이 부른 길 · 몸을 모음 ·
* stdin = {rows, built} · stdout = {calls, kinds, rows, picked, project, view} JSON. */
const fs = require("fs");
const path = require("path");
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
const ROOT = path.join(__dirname, "..", "..");
const STUBS = { "@config/config_frontend": { API_BASE_URL: "/api" } };
// 본 화면이 부르는 고르기 창 · 자재 창은 DOM · 모듈 메타를 씀 — 대역(글 도우미만 시험)
const STUB_FILES = ["M02_MasterTemplete_Labor_Pick", "M02_Material_Pick"];
const stub = new Proxy({}, { get: (_t, k) => (k === "currentLanguageIndex" ? () => 0 : () => {}) });
const cache = new Map();
function loadTs(file) {
const full = path.resolve(file);
if (cache.has(full)) return cache.get(full);
const js = ts.transpileModule(fs.readFileSync(full, "utf8"), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const box = { exports: {} };
cache.set(full, box.exports);
const localRequire = (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;
};
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
return box.exports;
}
(async () => {
const input = 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,
});
const body = url.includes("/hopyo-picks") ? { 줄: input.rows } : input.built;
return { status: 200, ok: true, json: async () => body };
};
const M02 = path.join(ROOT, "M02_MasterTemplete");
const pick = loadTs(path.join(M02, "M02_MasterTemplete_Hopyo_Pick.ts"));
const picked = [];
const lib = {
layer: "company",
owner: "7",
pick: (ref, name, alias) => picked.push({ ref, name, alias }),
};
const wrapped = pick.withHopyo(lib);
const rows = (await wrapped.more()).map(({ onPick: _, ...r }) => r);
await pick.pickHopyo(input.rows[0], lib);
const project = pick.withHopyo({ ...lib, layer: "project" });
const view = loadTs(path.join(M02, "M02_MasterTemplete_Hopyo_View.ts"));
const out = {
calls,
kinds: wrapped.kindFilter.map((k) => k.value),
rows,
picked,
project: project.more === undefined && project.kindFilter === undefined,
view: {
target: [
view.targetText({ 로직: "GF000101", 이름: "구조물터파기", 단위: "㎥" }),
view.targetText({ 자재: "MT1", 이름: "레미콘", 규격: "25-21-120", 단위: "㎥" }),
view.targetText({ 호표: 46, 단위: "㎡" }),
],
inputs: view.inputsText({ 횟수: "4회사용시", 지역: "서울" }, ["보정작업"]),
memory: [
view.memoryOf({ 로직: "GF000101" }, { 지역: "서울" }, 1),
view.memoryOf({ 자재: "MT1" }, {}, 0.5),
view.memoryOf({ 로직: "GF000101" }, {}, 0),
view.memoryOf(null, {}, 1),
],
},
};
process.stdout.write(JSON.stringify(out));
})().catch((error) => {
process.stderr.write(String(error?.stack ?? error));
process.exit(1);
});
+20
View File
@@ -165,3 +165,23 @@ def test_확정_검사와_위_층_기억(client):
assert client.post("/api/m02/layers/personal/hopyo-memory", json=mine).status_code == 200
assert _hopyo(client, 3)["구조물터파기(인력100%)"]["대상"]["로직"] == "GF000101" # 같은 층 먼저
assert hm.chain(layers.personal_dir(7, 42))[1] == layers.company_dir(7)
def test_로직_추가_고르기_줄은_이_층과_위_층(client):
client.who.update(role="SYSTEM_ADMIN")
assert _post(client, "system").status_code == 200
client.who.update(role="USER")
assert _post(client).status_code == 200
rows = client.get("/api/m02/layers/personal/hopyo-picks").json()["줄"]
assert [r["층"] for r in rows].count("personal") == 68
assert [r["층"] for r in rows].count("system") == 68
three = next(r for r in rows if r["층"] == "system" and r["번"] == 3)
assert {k: three[k] for k in ("주인", "키", "이름", "단위")} == {
"주인": None,
"키": "HP-000001",
"이름": "임도표지석",
"단위": "개소",
}
assert three["맞춤"][1] == 4
system = client.get("/api/m02/layers/system/hopyo-picks").json()["줄"]
assert {r["층"] for r in system} == {"system"} # 마스터 = 마스터만
+132
View File
@@ -0,0 +1,132 @@
"""일위대가 실무자료 화면(PLAN 324-4) — 좌측 판 · 본 화면 · [로직 추가] 「실무 호표」 줄.
- 고르기 창 덧줄(Node · fetch 대역): 이 층 · 위 층 호표 줄(맞춤 n/m) · 종류 고르기 ·
고르면 문서 층에 만들기 길 → `lib.pick` 참조 줄(별칭 = 호표 품명) · 프로젝트 층엔 없음 ·
본 화면 글 도우미
- 소스: 좌측 판 자리(사전조합 아래 · 구조물 조합 층 · 쓰기 권한 없으면 올리기 꺼짐 ·
숨은 file 입력) · Labor_Detail 줄 수 그대로 · 새 파일 이름 · 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"
NEW = ("Hopyo_Api", "Hopyo_Pick", "Hopyo_Side", "Hopyo_View")
def _text(name: str) -> str:
return (M02 / name).read_text(encoding="utf-8")
def _run() -> dict:
row = {
"층": "system",
"주인": None,
"키": "HP-000001",
"파일": "북부청.xls",
"번": 3,
"이름": "임도표지석",
"규격": "910×600×150",
"단위": "개소",
"맞춤": [2, 4],
}
built = {
"참조": {"층": "company", "주인": "7", "키": "SC-000009"},
"이름": "임도표지석",
"다시씀": False,
"빠진줄": [{"차례": 0}],
"비교": {},
}
proc = subprocess.run(
["node", os.path.join(HERE, "helper_m02_hopyo_pick.cjs")],
input=json.dumps({"rows": [row], "built": built}, ensure_ascii=False),
capture_output=True,
text=True,
encoding="utf-8",
check=True,
)
return json.loads(proc.stdout)
def test_고르기_창_실무_호표_줄과_고름():
got = _run()
assert got["calls"][0] == {
"url": "/api/m02/layers/company/hopyo-picks?owner=7",
"method": "GET",
"body": None,
}
assert got["kinds"] == ["logic", "precombo", "hopyo"]
assert got["rows"] == [
{
"kind": "hopyo",
"key": "system||HP-000001|3",
"tag": "실무 호표",
"name": "임도표지석 910×600×150",
"sub": "북부청.xls · 제3호표 · 마스터",
"unit": "개소 · 맞춤 2/4",
}
]
build = got["calls"][1]
assert build["url"] == "/api/m02/layers/company/hopyo-build?owner=7" # 만드는 층 = 문서 층
assert build["body"] == {
"층": "system",
"주인": None,
"키": "HP-000001",
"번": 3,
"새로": False,
}
assert got["picked"] == [
{
"ref": {"층": "company", "주인": "7", "키": "SC-000009"},
"name": "임도표지석",
"alias": "임도표지석",
}
]
assert got["project"] is True # 프로젝트 층 = 덧줄 없음
def test_본_화면_글_도우미():
view = _run()["view"]
assert view["target"] == ["구조물터파기 [㎥]", "레미콘 25-21-120 [㎥]", "제46호표 [㎡]"]
assert view["inputs"] == "횟수=4회사용시, 지역=서울 · ⚠ 못 읽음: 보정작업"
assert view["memory"] == [
{"로직": "GF000101", "입력값": {"지역": "서울"}, "환산": 1},
{"자재": "MT1", "입력값": {}, "환산": 0.5},
None, # 환산 0 = 확정 못 함
None, # 대상 없음
]
def test_좌측_판_자리와_권한():
side = _text("M02_MasterTemplete_UI_Side.ts")
assert 'import { buildHopyoSection } from "./M02_MasterTemplete_Hopyo_Side";' in side
host = side.split('className: "m02-side__lists ui-sidebar-panel"')[1]
assert host.index("precomboSec,") < host.index("section(hopyo.title, hopyo.body),")
assert host.index("section(hopyo.title, hopyo.body),") < host.index("extras ?")
assert "canWrite: canEdit(layer, opt.isAdmin)," in side # 구조물 조합 층 쓰기 권한
assert "hopyo.paint();" in side.split("function paint(): void {")[1]
names = set(re.findall(r"\b\w*[Pp]ractice\w*\b", side))
assert names <= {"buildPracticeSection", "practice", "M02_MasterTemplete_Practice"} # 옛 이름만
assert "LaborSection" not in side and "labor?." not in side and "picked" not in side
hopyo_side = _text("M02_MasterTemplete_Hopyo_Side.ts")
assert 'accept: ".xls,.xlsx"' in hopyo_side and "input.hidden = true;" in hopyo_side
assert "disabled: !opt.canWrite," in hopyo_side and "pageScope()" in hopyo_side
def test_로직_추가_한_곳과_파일_규칙():
detail = _text("M02_MasterTemplete_Labor_Detail.ts")
assert len(detail.splitlines()) == 693 # 줄 수 그대로(제자리 고침)
assert "withHopyo(library)," in detail
assert 'cur.담은로직.push({ 로직: "", 참조: ref, 별칭, 메모: null' in detail
pick = _text("M02_MasterTemplete_Labor_Pick.ts")
assert "more?: () => Promise<PickExtra[]>;" in pick and "...later" in pick
for name in NEW:
text = _text(f"M02_MasterTemplete_{name}.ts")
assert len(text.splitlines()) <= 700, name
css = _text("M02_MasterTemplete_Hopyo.css")
assert "var(--color-" in css and not re.search(r"#[0-9a-fA-F]{3,6}\b", css)
assert '"./M02_MasterTemplete_Hopyo.css"' in _text("M02_MasterTemplete_Hopyo_View.ts")