diff --git a/M02_MasterTemplete/M02_Hopyo_Router.py b/M02_MasterTemplete/M02_Hopyo_Router.py index 38a3ec811..cc96feae1 100644 --- a/M02_MasterTemplete/M02_Hopyo_Router.py +++ b/M02_MasterTemplete/M02_Hopyo_Router.py @@ -33,6 +33,7 @@ from M02_MasterTemplete import M02_Hopyo_Paste as hopyo_paste from M02_MasterTemplete import M02_Hopyo_Relink as hopyo_relink from M02_MasterTemplete import M02_Hopyo_Row as hopyo_row from M02_MasterTemplete import M02_Hopyo_Store as hopyo_store +from M02_MasterTemplete import M02_Labor_Doc as labor from M02_MasterTemplete import M02_Layer_Access as access from M02_MasterTemplete import M02_MasterTemplete_Store as store from M02_MasterTemplete.M02_MasterTemplete_Router_Items import Layer, Session, _call, _root @@ -173,6 +174,65 @@ def hopyo_move( return _call(folder, hopyo_maker.move, *args) +class PriceBody(BaseModel): + """우리 금액 미리 셈(786-2) — 파일 층 `층` · `주인` · `호표` 번 · `줄 {차례: [고른 것]}`.""" + + model_config = ConfigDict(extra="forbid") + 층: Layer + 주인: str | None = None + 호표: int + 줄: dict[int, list[Pick]] = Field(default_factory=dict, max_length=300) + + +def _price(folder: Path, source: Path, key: str, no: int, rows: dict[int, list[dict]]) -> dict: + """줄마다 옮기기와 같은 길(`Make.Move.pick` 재검사 · `Make.Lines.picks` 줄 · 자재)로 + 한 줄 문서 → `Labor_Doc.calc`(정본 셈 · 저장 없음 · 같은 기초 둘째 = 미리 참조) · + 막힘 = 그 줄 까닭만.""" + mv = hopyo_maker.Move(folder, source, key, None) + if no not in mv.units: + raise store.StoreError(404, f"없는 제{no}호표") + lines = mv.units[no].get("줄") or [] + b = hopyo_build.Builder(folder, source, key, no, None) + b.dry, b.strict = True, False + where = {"파일": mv.hp.get("파일") or "", "호표": no} + out: dict[int, dict[str, Any]] = {} + for at, picks in rows.items(): + if not 0 <= at < len(lines) or not picks: + continue + try: + got = [mv.pick(p, lines[at], f"제{no}호표 {at + 1}째 줄") for p in picks] + except hopyo_maker.Stuck as e: + out[at] = {"금액": None, "까닭": [e.why], "칸": e.cells} + continue + body = hopyo_maker.Lines(b, lines, where, "", hopyo_maker.hfix.Linker(False)) + body.picks(at, lines[at], got) + doc = {"종류": "일위대가", "열": "hopyo-price", "단위": None, "담은로직": body.lines} + try: + calc = labor.calc(labor.clean({**doc, "자재": list(body.mats.values())}), {}) + except (ValueError, store.StoreError) as e: + out[at] = {"금액": None, "까닭": [str(getattr(e, "detail", e))], "칸": []} + continue + why = [x["까닭"] for x in [*calc.get("줄", []), *calc.get("자재", [])] if x.get("까닭")] + out[at] = {"금액": calc.get("반영합"), "자재": calc.get("자재", []), "까닭": why, "칸": []} + return {"줄": out} + + +@router.post("/layers/{layer}/hopyo/{key}/price") +def hopyo_price( + layer: Layer, + key: str, + body: PriceBody, + owner: str | None = None, + session: Session = Depends(verify_session), +) -> dict: + """우리 금액 미리 셈(786-2 [수정] 모달 · 저장 없음) — 경로 층 = 만들 층(참조 읽기) · + 몸 층 = 파일 층 → `{줄 {차례: {금액 {노무비 · 재료비 · 경비 · 계}, 자재, 까닭, 칸}}}`.""" + folder = _root(session, layer, owner) + source = _root(session, body.층, body.주인) + rows = {at: [p.model_dump(exclude_none=True) for p in ps] for at, ps in body.줄.items()} + return _call(folder, _price, folder, source, key, body.호표, rows) + + # ── 옛 길(785-7 까지 · 옛 화면) · 그대로 쓰는 길 ───────────────────────────── HOPYO_MAX_BYTES = 20 * 1024 * 1024 diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo.css b/M02_MasterTemplete/M02_MasterTemplete_Hopyo.css index 38a3d639f..3529b36c5 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo.css +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo.css @@ -285,3 +285,51 @@ .m02-hopyo-side > .m02-side__row { margin-top: var(--spacing-12); } + +/* [수정] 모달 「확인 — 제N호표」 줄 표(786-2 · 옛 359-3 꼴) — 일위대가 상세 꼴(m02-labrow) · 확인 · 행 · 수량 · 단위 = + 줄바꿈 없음 · 단추 = 세로로 쌓아 좁게 · 이름 · 별칭 · 품 비교가 남는 폭 */ +.m02-hopyo__table.m02-hopyo__lines :is(th, td) { + padding: var(--spacing-8); + vertical-align: top; +} + +.m02-hopyo__lines :is(td:first-child, td:nth-child(2), .m02-labrow__qty, .m02-labrow__unit) { + white-space: nowrap; +} + +.m02-hopyo__lines th > .ui-badge { + vertical-align: middle; +} + +.m02-hopyo__lines .m02-labrow__name { + max-width: none; + white-space: normal; +} + +.m02-hopyo__lines td.m02-hopyo__compare { + min-width: 7em; + white-space: pre-line; +} + +.m02-hopyo__lines td.m02-hopyo__acts { + width: 1%; +} + +.m02-hopyo__lines .m02-hopyo__acts > * { + display: flex; + width: 100%; + white-space: nowrap; +} + +.m02-hopyo__lines .m02-hopyo__acts > * + * { + margin: var(--spacing-4) 0 0; +} + +.m02-hopyo__solved > td { + background: var(--color-surface-raised); +} + +.m02-hopyo__subhead { + margin: var(--spacing-8) 0 0; + font-size: var(--text-body-sm); +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Check.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Check.ts new file mode 100644 index 000000000..a78b55e2c --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Check.ts @@ -0,0 +1,351 @@ +/* ============================================================================= + * M02_MasterTemplete_Hopyo_Check.ts + * 불러오기 [수정] 모달 「확인 — 제N호표」(PLAN 786-2 · 요구 644 · 645 · 785 전 옛 확인 창 꼴 되살림) — + * 엑셀 보기 오른쪽 호표 목록 [수정](`Hopyo_Sheet`)이 엶. + * · 머리 = 호표 · 엑셀 단가 · 제안 n / 줄 m · 확인 n · 만든 사전조합 · + * 단추 [호표 제안 모두 확인](새) · [사전조합 생성] · [구조물 조합으로] · [엑셀 보기] + * · 몸 = 로직 표 + 자재 표(구조물 조합 일위대가 상세 꼴 · `Hopyo_CheckRows`) · 줄 칸 부품 = `Hopyo_Rows.createRowKit` + * · 우리 금액 = 서버 `/hopyo/{키}/price`(옮기기와 같은 길로 한 줄 문서 → `Labor_Doc.calc` · 저장 없음 · + * 고름이 바뀐 줄만 · 묶어 한 번 · 줄마다 최신 응답만) + * · 고름 · 확인 = 판정 캐시(`createJudgeState` · 시트 칠 · 오른쪽 [옮기기 (n)] 과 한 벌) · 자동 확인 · 자동 옮김 없음 + * · [사전조합 생성] = 옮기기 길(`runMove`) · 이 호표의 확인한 줄만 · 서버가 정본으로 다시 셈 + * · [구조물 조합으로] = 이 호표가 사전조합으로 옮겨진 뒤만 · 구조물 조합 만들기 길(`Hopyo_ToItem`) + * · 행 번호 · [엑셀 보기] = 모달 닫고 시트 그 행(고름은 캐시로 남음 · 다시 [수정] 하면 그대로) + * · 옛 [맞음·별칭 일괄 체크] · [품셈으로 바꾸기] · [모두 고정으로] = 지운 서버 길 몫 — [호표 제안 모두 확인]이 갈음 + * ========================================================================== */ + +import { createButton, el, showConfirmDialog, showToast } from "@ui/ui_template_elements"; +import { loadingError, showLoading } from "@ui/ui_template_loading"; +import { t as L } from "@ui/ui_template_locale"; +import { openModal } from "@ui/ui_template_modal"; +import { createText } from "@ui/ui_template_text"; +import { createSourceView } from "../M01_MasterData/M01_MasterData_UI_LogicLab_Source"; +import { formatPrice } from "../common_util/common_util_money"; +import { call } from "./M02_MasterTemplete_Api_Fetch"; +import { at, HOPYO_BUILT, type HopyoBrief, type HopyoScope } from "./M02_MasterTemplete_Hopyo_Api"; +import { + checkJudge, + readJudgeBlock, + readJudgeOne, + type JudgeOne, + type JudgeRow, + type Pick, +} from "./M02_MasterTemplete_Hopyo_JudgeApi"; +import { + checkTables, + newBadge, + NO_LOGIC, + type LinePrice, + type PriceLine, +} from "./M02_MasterTemplete_Hopyo_CheckRows"; +import { + createRowKit, + runMove, + type JudgeState, + type SheetRole, +} from "./M02_MasterTemplete_Hopyo_Rows"; +import { toItem } from "./M02_MasterTemplete_Hopyo_ToItem"; +import { fetchLogicInputs } from "./M02_MasterTemplete_Labor_Api"; +import { openLaborInput } from "./M02_MasterTemplete_Labor_Input"; +import { openLogicPicker } from "./M02_MasterTemplete_Labor_Pick"; +import { + bare, + confirmSuggested, + lineOf, + moveBody, + samePick, + setInput, + type DraftPick, +} from "./logic/M02_MasterTemplete_Hopyo_Picks"; +import "./M02_MasterTemplete_Hopyo.css"; + +const enc = encodeURIComponent; +const why = (e: unknown): string => (e instanceof Error ? e.message : String(e)); +const note = (text: string): HTMLElement => createText(text, "muted"); +const won = (v: number | null | undefined): string => (v === null || v === undefined ? "—" : formatPrice(v)); // prettier-ignore + +/** 우리 금액 미리 셈 — 경로 층 = 만들 층 · 몸 층 = 파일 층(저장 없음) */ +const priceJudge = ( + target: HopyoScope, + file: HopyoScope, + key: string, + no: number, + 줄: Record, +): Promise<{ 줄: Record }> => + call(at(target, `/hopyo/${enc(key)}/price`), { + method: "POST", + body: JSON.stringify({ 층: file.layer, ...(file.owner ? { 주인: file.owner } : {}), 호표: no, 줄 }), // prettier-ignore + }); + +export interface CheckOptions { + /** 실무자료 파일 층 */ + file: HopyoScope; + key: string; + /** 사전조합 · 구조물 조합을 만들 층 */ + target: HopyoScope; + state: JudgeState; + no: number; + /** 목록 한 줄(엑셀 단가 · 제안 n / 줄 m) */ + brief?: HopyoBrief; + canWrite: boolean; + /** 모달 닫은 뒤 엑셀 보기 그 행 · 그 호표 */ + onSheet: (행: number, role: SheetRole) => void; + onHopyo: () => void; + /** 옮긴 뒤(목록 · 칠 다시 읽음) */ + onMoved: () => void; +} + +/** [수정] 모달 */ +export function openHopyoCheck(opt: CheckOptions): { close: () => void } { + const readOnly = !opt.canWrite; + const no = opt.no; + const root = el("div", { className: "m02-hopyo m02-hopyo-check", attrs: { "data-hopyo-check": `${opt.key}|${no}` } }); // prettier-ignore + let one: JudgeOne | null = null; + let shut: () => void = () => {}; + let off: () => void = () => {}; + let busy = false; + const leave = (): void => { + off(); + shut(); + }; + + // ── 우리 금액(줄마다 최신 응답만 · 바뀐 줄만 묶어 한 번) ── + const priced = new Map(); + let queued: Record = {}; + let seq = 0; + let timer = 0; + let pending = false; + function soon(): void { + if (pending) return; + pending = true; + window.setTimeout(() => { + pending = false; + if (root.isConnected) draw(); + }, 0); + } + function flush(): void { + const ask = queued; + queued = {}; + const my = ++seq; + for (const k of Object.keys(ask)) { + const slot = priced.get(Number(k)); + if (slot) slot.seq = my; + } + priceJudge(opt.target, opt.file, opt.key, no, ask).then( + (got) => { + for (const k of Object.keys(ask)) { + const slot = priced.get(Number(k)); + const line = got.줄[k]; + if (slot?.seq === my) slot.val = line ? { got: line } : { fail: L("M02_CheckNoCalc") }; + } + soon(); + }, + (e: unknown) => { + for (const k of Object.keys(ask)) { + const slot = priced.get(Number(k)); + if (slot?.seq === my) slot.val = { fail: why(e) }; + } + soon(); + }, + ); + } + function price(row: JudgeRow): LinePrice | null { + const picks = kit.picksOf(row); + if (!picks.length) return null; + const 배 = lineOf(opt.state.get(), no, row.차례)?.배; + const body = picks.map((p) => bare(p, 배)); + const sig = JSON.stringify(body); + const have = priced.get(row.차례); + if (have?.sig === sig) return have.val; + priced.set(row.차례, { sig, seq: -1, val: { wait: true } }); + queued[String(row.차례)] = body; + window.clearTimeout(timer); + timer = window.setTimeout(flush, 250); + return { wait: true }; + } + + const kit = createRowKit({ + file: opt.file, + key: opt.key, + state: opt.state, + no, + readOnly, + redraw: () => draw(), + onSheet: (r, role) => { + leave(); + opt.onSheet(r, role); + }, + }); + + /** [원문 보기] — 엑셀 인용 조항(단산 줄 = 블록 근거) · 우리 로직 출처 */ + async function showSource(row: JudgeRow): Promise { + const blk = row.번 !== undefined ? await readJudgeBlock(opt.file, opt.key, row.번, opt.target).catch(() => null) : null; // prettier-ignore + const excel = (blk?.근거?.출처 ?? []).map((x) => x.join(" ").trim()).filter(Boolean); + const codes = kit.picksOf(row).flatMap((p) => (p.갈래 === "기초" && p.키 ? [p.키] : [])); + const ours = (await fetchLogicInputs(codes).catch(() => ({ 로직: [] }))).로직.flatMap((x) => + x.출처 ? [`${x.로직} — ${x.출처}`] : [], + ); + openModal({ + title: `${L("M02_CheckSource")} — ${row.이름 ?? ""}`, + closeLabel: L("Common_Btn_Close"), + wide: true, + dialogClass: "m02-modal m02-hopyo__src", + mount: (body) => { + if (row.근거) body.append(note(row.근거)); + const part = (title: string, list: string[], cut: (s: string) => string): void => { + body.append(el("h4", { text: title })); + if (!list.length) body.append(note(L("M02_CheckNone"))); + for (const s of list) { + const view = createSourceView({ path: "/m02/logic-lib/source", pdf: "/m02/logic-lib/source-pdf" }); // prettier-ignore + view.show(cut(s)); + body.append(note(s), view.root); + } + }; + part(L("M02_CheckSourceExcel"), excel, (s) => s); + part(L("M02_CheckSourceOurs"), ours, (s) => s.split(" — ").slice(1).join(" — ")); + }, + }); + } + + /** 기초 하나 바꿔 넣음(같은 것 자리) */ + const swap = (row: JudgeRow, from: DraftPick, to: DraftPick): void => + kit.edit( + row, + kit.picksOf(row).map((x) => (samePick(x, from) ? to : x)), + ); + + /** [옵션] — 그 기초 입력값 모달(구조물 조합 상세와 같은 창) · 바뀐 칸 = 출처 「사용자」 */ + function options(row: JudgeRow, p: DraftPick): void { + fetchLogicInputs([String(p.키)]).then( + (res) => + openLaborInput({ + title: `${p.키} ${p.이름}`.trim(), + logic: res.로직[0] ?? null, + values: p.입력값 as never, + memo: null, + readOnly, + done: (r) => { + let next = p; + const got = r.입력값 as Record; + for (const k of new Set([...Object.keys(got), ...Object.keys(p.입력값)])) + if (got[k] !== p.입력값[k]) next = setInput(next, k, got[k] ?? ""); + swap(row, p, next); + }, + }), + (e: unknown) => showToast(why(e), "error"), + ); + } + + /** [고르기] — 로직 찾기 창(구조물 조합 [로직 추가]와 같은 창) → 검산으로 입력값 · 입력 필요 받아 더함 */ + function pick(row: JudgeRow): void { + openLogicPicker((logic) => { + const raw: DraftPick = { 갈래: "기초", 키: logic.키, 이름: logic.이름, 입력값: {}, 출처: {}, 몫: null }; // prettier-ignore + checkJudge(opt.file, opt.key, { 호표: no, 차례: row.차례, 고른: [bare(raw)] }).then( + (res) => { + const c = res.고른[0]; + const p: DraftPick = { ...raw, 입력값: c?.입력값 ?? {}, 필요: c?.입력필요 ?? [] }; + kit.edit(row, [...kit.picksOf(row).filter((x) => !samePick(x, p)), p]); + }, + (e: unknown) => showToast(why(e), "error"), + ); + }); + } + + /** 추가(빈 값) = 제안으로 되돌림 · 로직없음 = 고정 줄(엑셀 숫자 그대로) */ + function out(row: JudgeRow, value: string): void { + if (value === NO_LOGIC) { + kit.edit(row, [{ 갈래: "고정", 키: null, 이름: row.이름 ?? "", 입력값: {}, 출처: {}, 몫: null }]); // prettier-ignore + return; + } + const d = opt.state.get(); + const lines = { ...(d.호표[String(no)] ?? {}) }; + delete lines[String(row.차례)]; + opt.state.set({ ...d, 호표: { ...d.호표, [String(no)]: lines } }); + } + + /** [사전조합 생성] — 이 호표의 확인한 줄만 옮김 */ + async function make(): Promise { + const all = moveBody(opt.state.get(), opt.file); + const mine = all.body.호표[String(no)] ?? {}; + const n = Object.keys(mine).length; + if (!n) return void showToast(L("M02_JudgeMoveNone"), "info"); + const ask = L("M02_CheckMakeAsk").replace("{a}", String(n)).replace("{b}", String((one?.줄.length ?? 0) - n)); // prettier-ignore + if (!(await showConfirmDialog(ask, L("M02_CheckMake")))) return; + busy = true; + draw(); + const stuck = all.막힘.filter((x) => x.호표 === no); + const r = await runMove(opt, { ...all.body, 호표: { [String(no)]: mine }, 블록: {} }, stuck); + busy = false; + if (r) { + document.dispatchEvent(new Event(HOPYO_BUILT)); + opt.onMoved(); + await load(true); + } else draw(); + } + + function draw(): void { + if (!one) return; + const h = one; + const done = h.줄.filter((r) => lineOf(opt.state.get(), no, r.차례)?.확인).length; + const [a, m] = opt.brief?.맞춤 ?? [h.줄.filter((r) => r.판정 === "제안 있음").length, h.줄.length]; // prettier-ignore + const all = createButton({ label: L("M02_JudgeConfirmAll"), size: "sm", variant: "ghost", disabled: readOnly, onClick: () => opt.state.set(confirmSuggested(opt.state.get(), no, h.줄)) }); // prettier-ignore + all.dataset.judgeAll = String(no); + const go = createButton({ label: L("M02_CheckMake"), size: "sm", variant: "filled", disabled: readOnly || busy || !done, onClick: () => void make() }); // prettier-ignore + go.dataset.hopyoMake = ""; + const item = createButton({ label: L("M02_CheckToItem"), size: "sm", disabled: readOnly || !h.만든, onClick: () => void toItem({ target: opt.target, sc: h.만든!, name: [h.이름, h.규격].filter(Boolean).join(" "), unit: h.단위 }) }); // prettier-ignore + item.dataset.hopyoToItem = ""; + item.title = h.만든 ? "" : L("M02_CheckToItemHint"); + const sheet = createButton({ label: L("M02_CheckSheet"), size: "sm", onClick: () => { leave(); opt.onHopyo(); } }); // prettier-ignore + sheet.dataset.hopyoSheet = ""; + const head = el("div", { + className: "ui-toolbar m02-hopyo__bar m02-judge__wrap", + children: [ + el("strong", { text: `${L("M02_JudgeHopyo").replace("{n}", String(no))} ${h.이름}` }), + note(h.규격), + note(h.단위), + note(`${L("M02_CheckExcelPrice")} ${won(opt.brief?.금액.계)}`), + note(L("M02_CheckMoneyHint")), + note(L("M02_CheckCount").replace("{a}", String(a)).replace("{m}", String(m)).replace("{c}", String(done))), // prettier-ignore + ...(h.만든 ? [note(`${L("M02_JudgeMade")} ${L("M02_PrecomboTitle")} ${h.만든}`)] : []), + newBadge(), + all, + go, + item, + sheet, + ], + }); + const legend = el("div", { className: "ui-toolbar m02-judge__wrap", children: [newBadge(), note(L("M02_CheckNewHint"))] }); // prettier-ignore + const ctx = { kit, readOnly, price, pick, source: (r: JudgeRow) => void showSource(r), options, out }; // prettier-ignore + root.replaceChildren(head, legend, ...(h.줄.length ? checkTables(h.줄, ctx) : [note(L("M02_CheckNoLines"))])); // prettier-ignore + } + + async function load(quiet = false): Promise { + const stop = quiet ? () => {} : showLoading(root); + try { + one = await readJudgeOne(opt.file, opt.key, no, opt.target); + draw(); + } catch (e) { + if (!quiet) root.replaceChildren(loadingError(why(e), () => void load())); + } finally { + stop(); + } + } + + openModal({ + title: L("M02_CheckTitle").replace("{n}", String(no)), + closeLabel: L("Common_Btn_Close"), + wide: true, + dialogClass: "m02-modal m02-hopyo__check-modal", + beforeClose: () => { + off(); + return true; + }, + mount: (body, close) => { + shut = close; + body.append(root); + }, + }); + off = opt.state.on(() => draw()); + void load(); + return { close: leave }; +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_CheckRows.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_CheckRows.ts new file mode 100644 index 000000000..cfcdfe272 --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_CheckRows.ts @@ -0,0 +1,307 @@ +/* ============================================================================= + * M02_MasterTemplete_Hopyo_CheckRows.ts + * [수정] 모달 줄 표(PLAN 786-2 · 요구 644 · 옛 359-3 꼴 되살림) — 구조물 조합 일위대가 상세 꼴 + * (`Labor_Rows.moneyCell` · `PRICE_SLOTS` · `Labor_MatMarks` 빌려 씀 · 그 파일은 안 고침)에 새 판정 칸(785)을 얹음. + * · 로직 표 = [확인] · 행 · 구분(엑셀 갈래 + 1차 판정) · 이름(고름 칩 · 단산 [블록 풀이] · 셈 못 함 까닭) · + * 별칭(엑셀 품명) · 수량산출(위 엑셀 / 아래 우리 = 엑셀 × 배 · 단위 환산 칸) · 단위 · + * 노무비 · 재료비 · 경비 · 합계(위 엑셀 단가 / 아래 우리 = 서버 미리 셈 ÷ 엑셀 수량) · 품 비교 · 단추 + * · 자재 표 = [확인] · 행 · 코드 · 이름 · 규격 · 수량산출 · 단위 · 세 칸(글만) · 가격 다섯 · 엑셀 / 우리 합계 · 단추 + * · 단추 = [고르기] · [원문 보기] · [옵션](기초마다) · 추가 / 로직없음(= 고정 줄) — 옛 「사용안함」(뺌)은 새 옮기기 길에 없어 안 살림 + * · 「새」 배지(785 새 기능) = 머리 칸 [확인] · 구분(판정) · 이름(고름 · 블록 풀이) · 수량산출(단위 환산) + * 줄 표 = `tableOf`(고른 것이 모두 재료 · 유가 · 없으면 엑셀 갈래 · 별도계상 줄 = 자재 표). + * ========================================================================== */ + +import { createBadge } from "@ui/ui_template_badge"; +import { createButton, createSelectField, el } from "@ui/ui_template_elements"; +import { t as L } from "@ui/ui_template_locale"; +import { formatPrice } from "../common_util/common_util_money"; +import type { JudgeRow } from "./M02_MasterTemplete_Hopyo_JudgeApi"; +import { apartRow, bar, show, type RowKit } from "./M02_MasterTemplete_Hopyo_Rows"; +import type { CalcMaterial } from "./M02_MasterTemplete_Labor_Calc"; +import { markCells, markHeads } from "./M02_MasterTemplete_Labor_MatMarks"; +import { moneyCell, PRICE_SLOTS } from "./M02_MasterTemplete_Labor_Rows"; +import { tc } from "./M02_MasterTemplete_Labor_Text"; +import type { DraftPick } from "./logic/M02_MasterTemplete_Hopyo_Picks"; + +type Money4 = Record<"노무비" | "재료비" | "경비" | "계", number | null>; + +/** 서버 미리 셈 한 줄(`/hopyo/{키}/price`) */ +export interface PriceLine { + 금액: Money4 | null; + 자재?: CalcMaterial[]; + 까닭: string[]; + 칸: string[]; +} +/** 줄 금액 — 셈 중 = wait · 고른 것 없음 = null */ +export type LinePrice = { wait: true } | { got: PriceLine } | { fail: string }; + +export interface CheckCtx { + kit: RowKit; + readOnly: boolean; + price: (row: JudgeRow) => LinePrice | null; + pick: (row: JudgeRow) => void; + source: (row: JudgeRow) => void; + options: (row: JudgeRow, p: DraftPick) => void; + /** 추가(빈 값 = 제안으로 되돌림) · 로직없음(고정 줄) */ + out: (row: JudgeRow, value: string) => void; +} + +const COSTS = ["노무비", "재료비", "경비", "계"] as const; +const COST_HEAD = { + 노무비: "Prev_Labor", + 재료비: "Prev_Material", + 경비: "Prev_Expense", + 계: "Prev_Sum", +} as const; +const MAT_KINDS = ["재료", "유가"]; +export const NO_LOGIC = "로직없음"; + +const small = (text: string, cls = "m02-labrow__note"): HTMLElement => + el("small", { className: cls, text, attrs: { title: text } }); + +/** 「새」 배지(785 새 기능 표지) */ +export function newBadge(): HTMLElement { + const b = createBadge(L("M02_CheckNew"), "accent", { size: 18 }); + b.dataset.checkNew = ""; + b.title = L("M02_CheckNewHint"); + return b; +} + +/** 줄이 들어갈 표 */ +export function tableOf(row: JudgeRow, picks: DraftPick[]): "logic" | "mat" { + if (apartRow(row)) return "mat"; + if (picks.length) return picks.every((p) => MAT_KINDS.includes(p.갈래)) ? "mat" : "logic"; + return row.갈래.length > 0 && row.갈래.every((k) => MAT_KINDS.includes(k)) ? "mat" : "logic"; +} + +/** 우리 금액 → 엑셀 1 단위(서버 반영합 ÷ 엑셀 수량 · 수량 없음 = 그대로) */ +function perUnit(v: number | null | undefined, row: JudgeRow): number | null { + if (v === null || v === undefined) return null; + const q = row.수량.엑셀; + return q ? v / q : v; +} + +const priced = (p: LinePrice | null): PriceLine | null => (p && "got" in p ? p.got : null); +const waiting = (p: LinePrice | null): boolean => !!p && "wait" in p; + +/** 이름 칸 덧글 — 셈 못 함 까닭 · 입력 필요 칸 */ +function notes(p: LinePrice | null): HTMLElement[] { + const got = priced(p); + const why = [...(p && "fail" in p ? [p.fail] : []), ...(got?.까닭 ?? [])]; + const out = why.map((t) => small(`${L("M02_CheckNoCalc")}: ${t}`, "ui-text ui-text--error")); + if (got?.칸.length) out.push(small(`${L("M02_JudgeNeed")}: ${got.칸.join(" · ")}`, "ui-text ui-text--error")); // prettier-ignore + return out; +} + +/** 품 비교 — 고른 기초의 줄 대조 판정 수(단산 줄 · 블록 풀이) */ +export function compareText(row: JudgeRow, picks: DraftPick[]): string { + const keys = new Set(picks.map((p) => p.키)); + const count = new Map(); + for (const sec of row.풀이?.절 ?? []) + for (const c of sec.후보) + if (keys.has(c.키)) for (const x of c.줄) count.set(x.판정, (count.get(x.판정) ?? 0) + 1); + return [...count].map(([k, n]) => `${k} ${n}`).join("\n"); +} + +/** 수량산출 — 위 엑셀 수량 · 아래 우리(엑셀 × 배) + 단위 환산 칸 */ +function qtyCell(row: JudgeRow, ctx: CheckCtx, picks: DraftPick[]): HTMLElement { + const 배 = ctx.kit.timesOf(row); + const ex = row.수량.엑셀; + const ours = ex === null || 배 === null ? "—" : show(ex * 배); + const unit = ctx.kit.unitInput(row, picks); + return el("td", { + className: "m02-labrow__qty", + attrs: { "data-label": tc("Labor_QtyCol") }, + children: [ + el("small", { className: "m02-labrow__base", text: ex === null ? "—" : `${show(ex)} ${row.단위 ?? ""}`.trim(), attrs: { title: L("M02_JudgeColExcel") } }), // prettier-ignore + el("span", { className: "m02-labrow__applied", text: ours, attrs: { title: L("M02_JudgeColOurs") } }), // prettier-ignore + ...(unit ? [unit] : []), + ], + }); +} + +function actsCell(row: JudgeRow, ctx: CheckCtx, picks: DraftPick[]): HTMLElement { + const ghost = (label: string, run: () => void, at: string): HTMLElement => { + const b = createButton({ label, variant: "ghost", size: "sm", onClick: run }); + b.dataset.checkAct = at; + return b; + }; + const kids: HTMLElement[] = []; + if (!ctx.readOnly && !apartRow(row)) kids.push(ghost(L("M02_CheckPick"), () => ctx.pick(row), "pick")); // prettier-ignore + kids.push(ghost(L("M02_CheckSource"), () => ctx.source(row), "source")); + for (const p of picks.filter((x) => x.갈래 === "기초")) { + const b = ghost(L("M02_CheckOption"), () => ctx.options(row, p), "option"); + b.title = `${p.키} ${L("M02_CheckOption")}`; + kids.push(b); + } + if (!apartRow(row)) { + const fixed = picks.length === 1 && picks[0].갈래 === "고정"; + const out = createSelectField({ + options: [ + { value: "", text: L("M02_CheckAdd") }, + { value: NO_LOGIC, text: L("M02_CheckNoLogic") }, + ], + value: fixed ? NO_LOGIC : "", + compact: true, + ariaLabel: L("M02_CheckNoLogic"), + onChange: (v) => ctx.out(row, v), + }); + out.select.disabled = ctx.readOnly; + out.select.dataset.checkOut = String(row.차례); + out.root.title = L("M02_CheckNoLogicHint"); + kids.push(out.root); + } + return el("td", { className: "m02-hopyo__acts", children: kids }); +} + +const rowOf = ( + row: JudgeRow, + ctx: CheckCtx, + picks: DraftPick[], + cells: HTMLElement[], +): HTMLElement => + // prettier-ignore + el("tr", { + className: "m02-labrow__row m02-hopyo__line", + attrs: { "data-line": String(row.차례), "data-verdict": row.판정 }, + children: [ + el("td", { children: [ctx.kit.okChip(row, picks)] }), + el("td", { className: "m02-labrow__no", children: [ctx.kit.rowJump(row)] }), + ...cells, + actsCell(row, ctx, picks), + ], + }); + +const kindCell = (row: JudgeRow, ctx: CheckCtx): HTMLElement => + el("td", { + className: "m02-labrow__kind", + attrs: { title: row.근거 }, + children: [bar([...row.갈래.map((k) => createBadge(k, "neutral")), ctx.kit.verdict(row)])], + }); + +function logicRow(row: JudgeRow, ctx: CheckCtx): HTMLElement[] { + const picks = ctx.kit.picksOf(row); + const p = ctx.price(row); + const got = priced(p); + const wait = waiting(p); + const unit = picks[0]?.단위 || row.단위 || ""; + const tr = rowOf(row, ctx, picks, [ + kindCell(row, ctx), + el("td", { className: "m02-labrow__name m02-hopyo__parts", children: [ctx.kit.chips(row, picks), ...notes(p)] }), // prettier-ignore + el("td", { + className: "m02-labrow__alias", + attrs: { title: `${row.이름 ?? ""} ${row.규격 ?? ""}`.trim() }, + children: [row.이름 ?? "", ...(row.규격 ? [small(row.규격)] : [])], + }), + qtyCell(row, ctx, picks), + el("td", { className: "m02-labrow__unit", text: unit }), + ...COSTS.map( + (c) => + moneyCell(row.단가?.[c] ?? null, got ? perUnit(got.금액?.[c], row) : null, wait, tc(COST_HEAD[c])), // prettier-ignore + ), + el("td", { className: "m02-hopyo__compare", text: compareText(row, picks) }), + ]); + const body = ctx.kit.solved(row); + if (!body) return [tr]; + const wide = el("td", { children: [body], attrs: { colspan: "15" } }); + return [tr, el("tr", { className: "m02-hopyo__solved", children: [wide] })]; +} + +function matRow(row: JudgeRow, ctx: CheckCtx): HTMLElement { + const picks = ctx.kit.picksOf(row); + const p = ctx.price(row); + const got = priced(p); + const wait = waiting(p); + const mats = got?.자재 ?? []; + const first = mats[0]; + const ours = mats.length ? mats.reduce((s, m) => s + (m.반영 ?? 0), 0) : null; + const cell = (v: number | null | undefined): string => + wait ? "…" : v === null || v === undefined ? "—" : formatPrice(v); + const pk = picks[0]; + const apart = apartRow(row) || !!pk?.별도계상; + return rowOf(row, ctx, picks, [ + el("td", { className: "m02-labrow__code", text: picks.map((x) => x.키 ?? "").filter(Boolean).join(" · ") || "—" }), // prettier-ignore + el("td", { + className: "m02-labrow__name m02-hopyo__parts", + children: [ctx.kit.chips(row, picks), small(`${L("M02_JudgeColExcel")} ${row.이름 ?? ""}`.trim()), ...notes(p)], // prettier-ignore + }), + el("td", { className: "m02-labrow__spec", text: pk?.규격 || row.규격 || "", attrs: { title: pk?.규격 || row.규격 || "" } }), // prettier-ignore + qtyCell(row, ctx, picks), + el("td", { className: "m02-labrow__unit", text: pk?.단위 || row.단위 || "" }), + ...markCells({ 별도계상: apart ? "별도계상" : "직접계산" }), + ...PRICE_SLOTS.map((slot) => + el("td", { + className: `m02-labrow__num m02-labrow__price${first?.기준칸 === slot ? " is-base-price" : ""}`, + text: cell(first?.가격?.[slot]), + attrs: { "data-label": slot, "data-price-slot": slot }, + }), + ), + moneyCell(row.단가?.계 ?? null, got ? perUnit(ours, row) : null, wait, tc("Labor_AppliedSum")), + ]); +} + +/** 머리 칸 — 글 · 새 기능이면 「새」 배지 */ +const head = (text: string, fresh = false): HTMLElement => + el("th", { children: fresh ? [text, " ", newBadge()] : [text] }); + +const grid = (heads: HTMLElement[], rows: HTMLElement[], kind: string): 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-labrow m02-hopyo__table m02-hopyo__lines", + attrs: { "data-hopyo-table": kind }, + children: [ + el("thead", { children: [el("tr", { children: heads })] }), + el("tbody", { children: rows }), + ], + }), + ], + }); + +/** 줄 표 둘 — 로직 표 · 자재 표(줄 없는 표는 안 그림) */ +export function checkTables(rows: JudgeRow[], ctx: CheckCtx): HTMLElement[] { + const logic = rows.filter((r) => tableOf(r, ctx.kit.picksOf(r)) === "logic"); + const mat = rows.filter((r) => !logic.includes(r)); + const first = [head(L("M02_JudgeColOk"), true), head(L("M02_JudgeColRow"))]; + const out: HTMLElement[] = []; + if (logic.length) + out.push( + grid( + [ + ...first, + head(`${tc("Sub")} · ${L("M02_JudgeColVerdict")}`, true), + head(`${tc("Col_Name")} · ${L("M02_JudgeColPick")}`, true), + head(tc("Col_Alias")), + head(tc("Labor_QtyCol"), true), + head(tc("Col_Unit")), + ...COSTS.map((c) => head(tc(COST_HEAD[c]))), + head(L("M02_CheckCompare")), + head(""), + ], + logic.flatMap((r) => logicRow(r, ctx)), + "logic", + ), + ); + if (mat.length) + out.push( + el("h4", { className: "m02-hopyo__subhead", text: L("M02_CheckMat") }), + grid( + [ + ...first, + head(tc("Labor_Code")), + head(`${tc("Col_Name")} · ${L("M02_JudgeColPick")}`, true), + head(tc("Labor_Spec")), + head(tc("Labor_QtyCol"), true), + head(tc("Labor_Unit")), + ...markHeads().map((t) => head(t)), + ...PRICE_SLOTS.map((t) => head(t)), + head(`${L("M02_JudgeColExcel")} / ${L("M02_JudgeColOurs")} ${tc("Labor_AppliedSum")}`), + head(""), + ], + mat.map((r) => matRow(r, ctx)), + "mat", + ), + ); + return out; +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Rows.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Rows.ts index e7a1d55e6..3106feb1b 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Rows.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Rows.ts @@ -1,13 +1,14 @@ /* ============================================================================= * M02_MasterTemplete_Hopyo_Rows.ts - * 엑셀 불러오기 줄 판(PLAN 785-6 · 요구 641 · 642 · 설계 642-A 2-3 · 2-4 · 2-6 · 분석 642-D 2-3 · 4) — 시트 아래 `[data-hopyo-lines]`. - * · 호표 하나 = 줄마다 1차 판정 칩(제안 있음 · 2차 필요 · 후보 여럿 · 후보 없음) · 갈래 칩 · 근거 · - * 후보 복수 고름(기초 · 단가산출 · 일위대가 · 인력 · 재료 · 유가 · 기계 · 율 · 고정 — 한 줄에 여럿) · - * 수량 · 단위 환산 칸(`logic/Hopyo_Unit` · 같은 단위 = 숨김) · [보기] = 엑셀 그 행 · [확인] - * · 단산 줄 = [블록 풀이] 펼침(`Hopyo_Block.solvedBody` · 절마다 기초 · 입력값 칸) + 「단가산출 통째」 칩(단산N → SK) - * · 머리 [호표 제안 모두 확인](사용자 누름 · 다 풀린 제안만) · 아래 [옮기기](`createJudgeMove` · 오른쪽 판 아래) + * 엑셀 불러오기 줄 칸 부품 · 옮기기(PLAN 785-6 · 786-2 · 요구 641 · 642 · 644 · 설계 642-A 2-3 · 2-4 · 2-6). + * · `createRowKit` = 호표 하나의 줄 칸 부품 — [수정] 모달 「확인 — 제N호표」(`Hopyo_Check` · `Hopyo_CheckRows`)가 + * 옛 확인 창 표 칸 안에 담음: 1차 판정 칩 · 후보 복수 고름 칩(기초 · 단가산출 · 일위대가 · 인력 · 재료 · 유가 · + * 기계 · 율 · 고정) · 단산 줄 [블록 풀이] 펼침(`Hopyo_Block.solvedBody` · 절마다 기초 · 입력값 칸 · 출처 꼬리표) + + * 「단가산출 통째」 칩(단산N → SK) · 단위 환산 칸(`logic/Hopyo_Unit` · 같은 단위 = 숨김) · [확인] + * · `createJudgeMove` = 오른쪽 판 아래 [옮기기 (n)] · `runMove` = 옮기기 한 번(모달 [사전조합 생성]도 이 길 · 이 호표만) * · 이미 옮긴 것(409) = 물음 한 번 뒤 덮음 * · 고름 · 확인 = 캐시(`createJudgeState`) · 자동 확인 · 자동 옮김 없음 · 옮김 = 서버가 정본으로 다시 셈 + * (785-6 시트 아래 줄 판 몸은 786-2 에서 모달로 옮김) * ========================================================================== */ import { createBadge, type BadgeTone } from "@ui/ui_template_badge"; @@ -19,8 +20,6 @@ import { showToast, } from "@ui/ui_template_elements"; import { t as L } from "@ui/ui_template_locale"; -import { createPanelHead } from "@ui/ui_template_panel_head"; -import { tableRow, tableShell, type Cell, type Column } from "@ui/ui_template_table"; import { createText } from "@ui/ui_template_text"; import { createToggle } from "@ui/ui_template_toggle"; import { formatNumber } from "../common_util/common_util_money"; @@ -30,14 +29,13 @@ import { createJudgeState, solvedBody, type JudgeState } from "./M02_MasterTempl import { checkJudge, moveJudge, - readJudgeOne, - type JudgeOne, type JudgeRow, + type MoveBody, + type MoveBlocked, type MoveResult, } from "./M02_MasterTemplete_Hopyo_JudgeApi"; import { bare, - confirmSuggested, confirmable, editLine, lineOf, @@ -59,38 +57,26 @@ const VERDICT: Record = { "후보 여럿": "warning", "후보 없음": "danger", }; -const show = (v: unknown): string => +export const show = (v: unknown): string => v === null || v === undefined ? "" : typeof v === "number" ? formatNumber(v) : String(v); -const bar = (kids: (HTMLElement | string)[]): HTMLElement => +export const bar = (kids: (HTMLElement | string)[]): HTMLElement => el("div", { className: "ui-toolbar m02-judge__wrap", children: kids }); export type SheetRole = "일위대가표" | "단가산출상세"; -export interface JudgeRowsOptions { - host: HTMLElement; +export interface RowKitOptions { /** 파일 층 */ file: HopyoScope; key: string; - /** 만들 층 */ - target: HopyoScope; state: JudgeState; no: number; /** 엑셀 보기 그 행(일위대가표 · 단가산출 상세) */ onSheet?: (행: number, role: SheetRole) => void; - onClose?: () => void; readOnly?: boolean; + /** [블록 풀이] 펼침 · 접음 — 부르는 쪽이 다시 그림 */ + redraw: () => void; } -const COLS: Column[] = [ - { label: L("M02_JudgeColRow"), width: "52px" }, - { label: L("M02_JudgeColName") }, - { label: L("M02_JudgeColVerdict"), width: "84px" }, - { label: L("M02_JudgeColKind") }, - { label: L("M02_JudgeColPick") }, - { label: L("M02_JudgeColQty"), align: "end" }, - { label: L("M02_JudgeColOk"), width: "72px" }, -]; - /** 2차 줄 「단가산출 통째」 고른 것 — 단산N(같은 옮기기 블록 먼저 SK) */ const wholeSk = (row: JudgeRow): DraftPick => ({ 갈래: "단가산출", @@ -103,25 +89,29 @@ const wholeSk = (row: JudgeRow): DraftPick => ({ 몫: null, }); -/** 시트 아래 줄 판 — 호표 하나 */ -export function createJudgeRows(opt: JudgeRowsOptions): { root: HTMLElement; destroy: () => void } { - const root = el("section", { className: "m02-judge", attrs: { "data-judge-rows": String(opt.no) } }); // prettier-ignore - root.append(createText(L("M02_JudgeLoading"), "hint")); - opt.host.replaceChildren(root); - let one: JudgeOne | null = null; +/** 별도계상 줄(786-1 표지) — 고름 칩 없이 글만 */ +export const apartRow = (row: JudgeRow): boolean => !!(row as { 별도계상?: boolean }).별도계상; + +/** 호표 하나 줄 칸 부품 — 판정 칩 · 고름 칩 · 수량(단위 환산) · [확인] · 블록 풀이 몸 */ +export function createRowKit(opt: RowKitOptions) { + const no = opt.no; const open = new Set(); const bodies = new Map>(); - let alive = true; - const no = opt.no; - const picksOf = (row: JudgeRow): DraftPick[] => linePicks(opt.state.get(), no, row); const edit = (row: JudgeRow, list: DraftPick[]): void => opt.state.set(editLine(opt.state.get(), no, row, list)); + function verdict(row: JudgeRow): HTMLElement { + const v = createBadge(row.판정, VERDICT[row.판정] ?? "neutral"); + v.dataset.judgeVerdict = row.판정; + return v; + } + function chips(row: JudgeRow, picks: DraftPick[]): HTMLElement { const kids: HTMLElement[] = []; + if (apartRow(row)) return bar([createText(L("M02_CheckApart"), "muted")]); if (row.번 !== undefined && row.풀이) { - const solve = createToggle({ kind: "chip", label: L("M02_JudgeSolve"), checked: open.has(row.차례), onChange: (on) => { if (on) open.add(row.차례); else open.delete(row.차례); draw(); } }); // prettier-ignore + const solve = createToggle({ kind: "chip", label: L("M02_JudgeSolve"), checked: open.has(row.차례), onChange: (on) => { if (on) open.add(row.차례); else open.delete(row.차례); opt.redraw(); } }); // prettier-ignore solve.root.dataset.judgeSolve = String(row.번); const sk = wholeSk(row); const whole = createToggle({ kind: "chip", label: `${L("M02_JudgeWholeSk")} ${sk.키}`, checked: picks.some((p) => samePick(p, sk)), disabled: opt.readOnly, onChange: () => edit(row, togglePick(picks, sk)) }); // prettier-ignore @@ -130,6 +120,7 @@ export function createJudgeRows(opt: JudgeRowsOptions): { root: HTMLElement; des for (const p of picks.filter((x) => x.갈래 === "기초")) { const b = createBadge(`${p.키}`, missing(p).length ? "danger" : "accent"); b.dataset.judgePicked = String(p.키); + if (missing(p).length) b.title = `${L("M02_JudgeNeed")} ${missing(p).join(" · ")}`; kids.push(b); } } else { @@ -142,18 +133,24 @@ export function createJudgeRows(opt: JudgeRowsOptions): { root: HTMLElement; des chip.root.dataset.judgeCand = `${c.갈래}:${c.키 ?? c.이름}`; chip.root.title = c.키 ?? ""; kids.push(chip.root); + const need = picks.find((p) => samePick(p, c)); + if (need && missing(need).length) { + const b = createBadge(`${L("M02_JudgeNeed")} ${missing(need).length}`, "danger"); + b.dataset.judgeNeed = missing(need).join(","); + kids.push(b); + } } if (!seen.length) kids.push(createText(L("M02_JudgeNoCand"), "warning")); } return bar(kids); } - function qtyCell(row: JudgeRow, picks: DraftPick[]): HTMLElement { + /** 단위 환산 칸 — 같은 단위면 null */ + function unitInput(row: JudgeRow, picks: DraftPick[]): HTMLElement | null { const line = lineOf(opt.state.get(), no, row.차례); const 배 = line?.배 ?? row.수량.배; const view = unitView({ 배, 환산: { 배: row.수량.배, 갈래: row.수량.갈래 } }, row.단위, picks[0]?.단위); // prettier-ignore - const text = `${show(row.수량.엑셀)}${배 !== null && 배 !== 1 ? ` × ${show(배)}` : ""}`; - if (view.mode !== "input" || opt.readOnly) return bar([text]); + if (view.mode !== "input" || opt.readOnly) return null; const f = createInputField({ compact: true, size: 24, value: show(배), unit: view.to, placeholder: L("M02_JudgeUnitHint").replace("{from}", view.from) }); // prettier-ignore f.input.dataset.judgeTimes = String(row.차례); f.input.addEventListener("change", () => { @@ -162,10 +159,14 @@ export function createJudgeRows(opt: JudgeRowsOptions): { root: HTMLElement; des opt.state.set(putLine(opt.state.get(), no, row.차례, { 고른: picks, 확인: false, 배: next })); }); if (배 === null) f.setError(L("M02_JudgeNeed")); - return bar([text, L("M02_JudgeUnitHint").replace("{from}", view.from), f.root]); + return bar([L("M02_JudgeUnitHint").replace("{from}", view.from), f.root]); } - function okCell(row: JudgeRow, picks: DraftPick[]): HTMLElement { + /** 지금 배(사용자 단위 환산 › 서버) */ + const timesOf = (row: JudgeRow): number | null => + lineOf(opt.state.get(), no, row.차례)?.배 ?? row.수량.배; + + function okChip(row: JudgeRow, picks: DraftPick[]): HTMLElement { const line = lineOf(opt.state.get(), no, row.차례); const ok = createToggle({ kind: "chip", @@ -186,41 +187,25 @@ export function createJudgeRows(opt: JudgeRowsOptions): { root: HTMLElement; des return ok.root; } - function rowCells(row: JudgeRow): Cell[] { - const picks = picksOf(row); - const jump = - row.행 !== null && opt.onSheet - ? createButton({ label: String(row.행), size: 24, variant: "link", onClick: () => opt.onSheet!(row.행!, "일위대가표") }) // prettier-ignore - : show(row.행); - if (jump instanceof HTMLElement) jump.dataset.judgeRow = String(row.행); - const v = createBadge(row.판정, VERDICT[row.판정] ?? "neutral"); - v.dataset.judgeVerdict = row.판정; - const kinds = row.갈래.map((k) => { - const b = createBadge(k, "neutral"); - b.dataset.judgeKind = k; - return b; - }); - const name = [row.이름, row.규격, row.단위 ? `(${row.단위})` : ""].filter(Boolean).join(" "); - return [ - jump, - name, - v, - bar([...kinds, row.근거]), - chips(row, picks), - qtyCell(row, picks), - okCell(row, picks), - ]; + /** 행 번호 단추 — 엑셀 보기 그 행 */ + function rowJump(row: JudgeRow): HTMLElement | string { + if (row.행 === null || !opt.onSheet) return show(row.행); + const b = createButton({ label: String(row.행), size: 24, variant: "link", onClick: () => opt.onSheet!(row.행!, "일위대가표") }); // prettier-ignore + b.dataset.judgeRow = String(row.행); + return b; } /** 블록 풀이 몸이 쥐는 고른 것 = 단가산출 통째 칩 빼고(기초 · 덧 줄) */ const isSk = (p: DraftPick): boolean => p.갈래 === "단가산출"; const inner = (row: JudgeRow): DraftPick[] => picksOf(row).filter((p) => !isSk(p)); - function solved(row: JudgeRow): HTMLElement { + /** 펼친 단산 줄의 블록 풀이 몸(접힘 = null) */ + function solved(row: JudgeRow): HTMLElement | null { + if (!row.풀이 || !open.has(row.차례)) return null; let body = bodies.get(row.차례); if (!body) { body = solvedBody({ - solved: row.풀이!, + solved: row.풀이, picks: inner(row), readOnly: opt.readOnly, excel: row.단가, @@ -234,51 +219,9 @@ export function createJudgeRows(opt: JudgeRowsOptions): { root: HTMLElement; des return body.root; } - function draw(): void { - if (!one) return; - const all = createButton({ - label: L("M02_JudgeConfirmAll"), - size: 24, - variant: "ghost", - disabled: opt.readOnly, - onClick: () => opt.state.set(confirmSuggested(opt.state.get(), no, one!.줄)), - }); - all.dataset.judgeAll = String(no); - const actions: HTMLElement[] = [all]; - if (opt.onClose) actions.push(createButton({ label: L("Common_Btn_Close"), size: 24, variant: "ghost", onClick: opt.onClose })); // prettier-ignore - const title = `${L("M02_JudgeHopyo").replace("{n}", String(no))} ${one.이름}`; - const sub = [one.규격, one.단위, one.만든 ? `${L("M02_JudgeMade")} ${one.만든}` : ""].filter(Boolean).join(" · "); // prettier-ignore - const { root: table, tbody } = tableShell(COLS, { density: "data" }); - for (const row of one.줄) { - const tr = tableRow(COLS, rowCells(row)); - tr.dataset.judgeLine = String(row.차례); - tbody.append(tr); - if (row.풀이 && open.has(row.차례)) - tbody.append(tableRow(COLS, [{ content: solved(row), colSpan: COLS.length }])); - } - root.replaceChildren(createPanelHead({ title, sub, actions, divider: true }).root, table); - } - - const off = opt.state.on(draw); - void readJudgeOne(opt.file, opt.key, no, opt.target) - .then((got) => { - if (!alive) return; - one = got; - draw(); - }) - .catch((e: Error) => { - if (alive) root.replaceChildren(createText(e.message, "error")); - }); - - return { - root, - destroy(): void { - alive = false; - off(); - root.remove(); - }, - }; + return { picksOf, edit, verdict, chips, unitInput, timesOf, okChip, rowJump, solved }; } +export type RowKit = ReturnType; export interface JudgeMoveOptions { file: HopyoScope; @@ -289,6 +232,44 @@ export interface JudgeMoveOptions { onMoved?: (r: MoveResult) => void; } +/** 옮기기 한 번 — 409 = 물음 뒤 덮음 · 옮긴 것은 캐시에서 걷음 · 알림 · 실패 = null */ +export async function runMove( + opt: Pick, + body: MoveBody, + 막힘: MoveBlocked[], +): Promise { + try { + const r = await moveJudge(opt.target, opt.key, body).catch(async (e: unknown) => { + // 이미 옮긴 호표 · 블록 — 물음 한 번 뒤 덮음(아니면 그만) + if (!(e instanceof StaleError) || !(await askConfirm({ message: e.message }))) throw e; + return moveJudge(opt.target, opt.key, { ...body, 덮음: true }); + }); + const stuck = new Set(r.막힘.map((x) => `${x.호표 ?? "b" + x.블록}:${x.차례 ?? ""}`)); + let d = opt.state.get(); + for (const [h, lines] of Object.entries(body.호표)) + for (const at of Object.keys(lines)) + if (!stuck.has(`${h}:${at}`)) { + const { [at]: _gone, ...rest } = d.호표[h] ?? {}; + d = { ...d, 호표: { ...d.호표, [h]: rest } }; + } + for (const b of Object.keys(body.블록)) + if (!stuck.has(`b${b}:`)) { + const { [b]: _gone, ...rest } = d.블록; + d = { ...d, 블록: rest }; + } + opt.state.set(d); + const msg = L("M02_JudgeMoved") + .replace("{a}", String(r.만듦.length)) + .replace("{b}", String(r.단가산출.length)) + .replace("{c}", String(r.막힘.length + 막힘.length)); + showToast(msg, r.막힘.length + 막힘.length ? "warning" : "success"); + return r; + } catch (e) { + showToast((e as Error).message, "error"); + return null; + } +} + /** 오른쪽 판 아래 [옮기기 (n)] — 확인한 줄 · 블록만 · 서버가 정본으로 다시 셈 · 옮긴 것은 캐시에서 걷음 */ export function createJudgeMove(opt: JudgeMoveOptions): { root: HTMLElement; destroy: () => void } { const btn = createButton({ label: L("M02_JudgeMove"), size: 28, variant: "filled", onClick: () => void go() }); // prettier-ignore @@ -307,37 +288,9 @@ export function createJudgeMove(opt: JudgeMoveOptions): { root: HTMLElement; des return; } btn.disabled = true; - try { - const r = await moveJudge(opt.target, opt.key, body).catch(async (e: unknown) => { - // 이미 옮긴 호표 · 블록 — 물음 한 번 뒤 덮음(아니면 그만) - if (!(e instanceof StaleError) || !(await askConfirm({ message: e.message }))) throw e; - return moveJudge(opt.target, opt.key, { ...body, 덮음: true }); - }); - const stuck = new Set(r.막힘.map((x) => `${x.호표 ?? "b" + x.블록}:${x.차례 ?? ""}`)); - let d = opt.state.get(); - for (const [h, lines] of Object.entries(body.호표)) - for (const at of Object.keys(lines)) - if (!stuck.has(`${h}:${at}`)) { - const { [at]: _gone, ...rest } = d.호표[h] ?? {}; - d = { ...d, 호표: { ...d.호표, [h]: rest } }; - } - for (const b of Object.keys(body.블록)) - if (!stuck.has(`b${b}:`)) { - const { [b]: _gone, ...rest } = d.블록; - d = { ...d, 블록: rest }; - } - opt.state.set(d); - const msg = L("M02_JudgeMoved") - .replace("{a}", String(r.만듦.length)) - .replace("{b}", String(r.단가산출.length)) - .replace("{c}", String(r.막힘.length + 막힘.length)); - showToast(msg, r.막힘.length + 막힘.length ? "warning" : "success"); - opt.onMoved?.(r); - } catch (e) { - showToast((e as Error).message, "error"); - } finally { - count(); - } + const r = await runMove(opt, body, 막힘); + count(); + if (r) opt.onMoved?.(r); } const off = opt.state.on(count); diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Sheet.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Sheet.ts index 43ea0eef2..e41d7201c 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Sheet.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Sheet.ts @@ -7,7 +7,7 @@ * 오른쪽 = 호표 목록(제N호표 [수정] · 찾기 · 제안 n/m — 다 제안이면 녹색 · 뒤 셈 중이면 3 초마다 다시) · * 단가산출표 · 상세 탭이면 단가산출 목록으로 바뀜(780-2 · `Hopyo_SheetSk`). * · 목록 누름 → 시트에서 그 호표 영역(B열 ~ 끝 열) 고름 · 칸 누름 → 그 행 호표를 목록에서 강조 · 머리 행이면 영역 고름 - * · [수정] = 시트 아래 줄 판(785-6 `Hopyo_Rows` · 줄 누름과 따로) · 단가산출 탭 블록 = 같은 자리 블록 판 · + * · [수정] = 모달 「확인 — 제N호표」(786-2 `Hopyo_Check` · 줄 누름과 따로) · 단가산출 탭 블록 = 시트 아래 블록 판 · * 오른쪽 판 아래 [옮기기](확인한 줄 · 블록 · 고름 캐시 `createJudgeState` 한 벌) * · 격자 = 서버 `/hopyo-grid/{키}` · 옛 파일(격자 없음)은 시트 자리에 까닭 글 · 목록은 됨 * · `buildHopyoSheet` = 몸(머리 · 띠 · 시트 + 목록) · 옛 [저장] · [모두 고정으로 들여오기] · 모달 껍데기는 785-7 에서 지움 @@ -28,11 +28,8 @@ import { type HopyoView, type HopyoScope, } from "./M02_MasterTemplete_Hopyo_Api"; -import { - createJudgeMove, - createJudgeRows, - createJudgeState, -} from "./M02_MasterTemplete_Hopyo_Rows"; +import { openHopyoCheck } from "./M02_MasterTemplete_Hopyo_Check"; +import { createJudgeMove, createJudgeState } from "./M02_MasterTemplete_Hopyo_Rows"; import { lineOf } from "./logic/M02_MasterTemplete_Hopyo_Picks"; import * as Nav from "./M02_MasterTemplete_Hopyo_SheetNav"; import { createSheetSk, type SheetSk } from "./M02_MasterTemplete_Hopyo_SheetSk"; @@ -72,7 +69,7 @@ export interface HopyoSheetBody { destroy: () => void; /** 안 옮긴 단가산출 블록 수(판정 전 null) */ skCount: () => { 블록: number; 밖: number; 줄: number } | null; - /** 그 단가산출 블록으로 뜀 + 줄 판 엶 */ + /** 그 단가산출 블록으로 뜀 + 블록 판 엶 */ showSk: (번: number) => void; } @@ -133,7 +130,7 @@ export function buildHopyoSheet(opt: HopyoSheetOptions): HopyoSheetBody { const items = new Map(); const target = opt.target ?? opt.scope; const state = createJudgeState(opt.scope, opt.key); // 판정 고름 캐시(785-6) - let panel: { destroy: () => void } | null = null; // 시트 아래 줄 판 몸 + let panel: { destroy: () => void } | null = null; // 시트 아래 블록 판 몸 const offPaint = state.on(() => nav?.repaint()); // 확인 바뀜 = 다시 칠 const head = el("div", { @@ -152,7 +149,7 @@ export function buildHopyoSheet(opt: HopyoSheetOptions): HopyoSheetBody { const lines = el("div", { className: "m02-hopyo-sheet__lines", attrs: { "data-hopyo-lines": "" }, - }); // 시트 아래 줄 판 자리 + }); // 시트 아래 블록 판 자리(단가산출 탭 · SheetSk) lines.hidden = true; const closeLines = (): void => { panel?.destroy(); @@ -242,7 +239,7 @@ export function buildHopyoSheet(opt: HopyoSheetOptions): HopyoSheetBody { if (at.시트 !== ilwi || at.범위 !== text) sheet.select(text, ilwi); } - /** [수정] — 그 호표 자리 + 시트 아래 줄 판(줄 누름과 따로) */ + /** [수정] — 그 호표 자리 + 모달 「확인 — 제N호표」(786-2 · 줄 누름과 따로) */ const editButton = (h: HopyoBrief): HTMLElement => { const b = createButton({ label: "수정", @@ -251,19 +248,18 @@ export function buildHopyoSheet(opt: HopyoSheetOptions): HopyoSheetBody { onClick: (e) => { e.stopPropagation(); focus(h.번); - openLines((host) => - createJudgeRows({ - host, - file: opt.scope, - key: opt.key, - target, - state, - no: h.번, - readOnly: !opt.canWrite, - onClose: closeLines, - onSheet: (r, role) => nav?.jumpTo(role === "일위대가표" ? ilwi : detail, r), - }), - ); + openHopyoCheck({ + file: opt.scope, + key: opt.key, + target, + state, + no: h.번, + brief: h, + canWrite: opt.canWrite, + onSheet: (r, role) => nav?.jumpTo(role === "일위대가표" ? ilwi : detail, r), + onHopyo: () => focus(h.번), + onMoved: () => void refresh(), + }); }, }); b.setAttribute("data-hopyo-edit", String(h.번)); diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_ToItem.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_ToItem.ts new file mode 100644 index 000000000..71bae1765 --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_ToItem.ts @@ -0,0 +1,77 @@ +/* ============================================================================= + * M02_MasterTemplete_Hopyo_ToItem.ts + * [구조물 조합으로](PLAN 786-2 · 옛 704-4 꼴 되살림) — [수정] 모달에서 이 호표가 옮겨진 일위대가(SC)를 담은 + * 구조물 조합 항목을 만듦. 창 = 항목 이름(기본 = 호표 이름 · 규격) · [만들기]. + * 길 = 구조물 조합 나무 항목 더하기(`editTree` add-item · 「실무자료」 폴더) → 그 항목 일위대가 문서에 SC 참조 한 줄 + * (`readTemplate` · `saveTemplate` — 서버가 정리 · 셈) · 구조물 조합 공용 파일 · 길은 부르기만. + * ========================================================================== */ + +import { createButton, showToast } from "@ui/ui_template_elements"; +import { createInputField } from "@ui/ui_template_input"; +import { t as L } from "@ui/ui_template_locale"; +import { openModal } from "@ui/ui_template_modal"; +import { createText } from "@ui/ui_template_text"; +import { editTree, fetchTree, readTemplate, saveTemplate } from "./M02_MasterTemplete_Api_Fetch"; +import type { HopyoScope } from "./M02_MasterTemplete_Hopyo_Api"; + +/** 만든 항목이 놓이는 폴더 */ +export const ITEM_PATH = ["실무자료"]; + +export interface ToItemOptions { + /** 만들 층(일위대가 SC 가 있는 층) */ + target: HopyoScope; + /** 이 호표가 옮겨진 일위대가 키(SC-…) */ + sc: string; + name: string; + unit: string; +} + +/** 항목 더하기 → 일위대가 문서에 SC 참조 한 줄 → 새 열 id */ +export async function makeItem(o: ToItemOptions, label: string): Promise { + const { layer, owner } = o.target; + const tree = await fetchTree(layer, owner); + const made = await editTree(layer, { op: "add-item", 경로: ITEM_PATH, 이름: label, 단위: o.unit }, tree.판, owner); // prettier-ignore + const col = [made.알림?.열].flat()[0]; + if (!col) throw new Error(L("M02_CheckToItemFail")); + const now = await readTemplate(layer, "labor", col, null, owner); + const doc = (now.문서 ?? {}) as Record; + const line = { + 참조: { 층: layer, 주인: owner, 키: o.sc }, + 별칭: null, + 메모: null, + 수량: { 손: 1 }, + }; + await saveTemplate(layer, "labor", col, null, now.판 ?? "", { ...doc, 담은로직: [line], 단위: o.unit || doc.단위 || null }, owner); // prettier-ignore + return col; +} + +export function toItem(o: ToItemOptions): void { + const name = createInputField({ label: L("M02_CheckItemName"), value: o.name }); + openModal({ + title: `${L("M02_CheckToItem")} — ${o.sc}`, + closeLabel: L("Common_Btn_Cancel"), + width: "40rem", + mount: (body, close, actions) => { + const go = createButton({ + label: L("M02_CheckItemMake"), + onClick: async () => { + const label = name.input.value.trim(); + if (!label) return name.setError(L("M02_CheckItemEmpty")); + go.disabled = true; + try { + await makeItem(o, label); + close(); + showToast(L("M02_CheckItemMade").replace("{name}", label).replace("{path}", ITEM_PATH.join(" › ")), "success", 6000); // prettier-ignore + } catch (e) { + showToast(`${L("M02_CheckToItemFail")} — ${e instanceof Error ? e.message : String(e)}`, "error"); // prettier-ignore + } finally { + go.disabled = false; + } + }, + }); + go.dataset.hopyoItemMake = ""; + actions.append(go); + body.append(createText(L("M02_CheckItemHint"), "muted"), name.root); + }, + }); +} diff --git a/resources/tester/test_642_6_screen.py b/resources/tester/test_642_6_screen.py index f6f5fe10a..b0a54b990 100644 --- a/resources/tester/test_642_6_screen.py +++ b/resources/tester/test_642_6_screen.py @@ -262,10 +262,10 @@ def test_블록칠_새판정(monkeypatch): def test_엑셀보기_이음_가운데페이지없음(): - """줄 판 = 시트 아래 `[data-hopyo-lines]` · [옮기기] = 오른쪽 판 아래 · 단가산출 목록 = 새 블록 판정.""" + """[수정] = 모달 「확인 — 제N호표」(786-2) · [옮기기] = 오른쪽 판 아래 · 단가산출 목록 = 새 블록 판정.""" assert not (M02 / "M02_MasterTemplete_Hopyo_Wizard.ts").exists() sheet = (M02 / "M02_MasterTemplete_Hopyo_Sheet.ts").read_text(encoding="utf-8") - assert "createJudgeRows({" in sheet and "createJudgeMove({" in sheet + assert "openHopyoCheck({" in sheet and "createJudgeMove({" in sheet right = sheet.split('className: "m02-hopyo-sheet__side"')[1].split("});")[0] assert "move.root" in right sk = (M02 / "M02_MasterTemplete_Hopyo_SheetSk.ts").read_text(encoding="utf-8") diff --git a/resources/tester/test_786_2_check_modal.py b/resources/tester/test_786_2_check_modal.py new file mode 100644 index 000000000..c706e4153 --- /dev/null +++ b/resources/tester/test_786_2_check_modal.py @@ -0,0 +1,123 @@ +# -*- coding: utf-8 -*- +"""786-2 불러오기 [수정] 모달 지킴 — 「확인 — 제N호표」 옛 꼴 되살림 + 새 기능 「새」 표지 +(요구 644 · 645). + +- 우리 금액 길(`/hopyo/{키}/price` · 저장 없음) — 옮기기와 같은 길로 한 줄 문서 → + `Labor_Doc.calc`: 예시 1 야면석소운반(단산10 · GC001140 · 입력 필요 0 · 엑셀 1 단위 94,227) · + 예시 3 합판거푸집(GC000211 · 높이감률 · 구멍땜 입력 필요 → 0 넣으면 노무 38,291) · + 쓰기 0(SC · SK 수 그대로) +- 화면 소스 — [수정] = 모달(`openHopyoCheck` · 부품 상자 모달) · 시트 아래 호표 줄 판 몸 없음 · + 「새」 배지 · 구조물 조합 공용 부품(`Labor_Rows` · `Labor_MatMarks` · `Labor_Input` · + `Labor_Pick`)은 import 만 · 날 button · input · select 0 · 700 줄 +임시 storage(`client` 마당) · M01 원본 그대로. +""" + +import re +from pathlib import Path + +from M02_MasterTemplete import M02_Hopyo_Judge as judge +from M02_MasterTemplete import M02_Template_Layers as layers + +# ruff: noqa: F811 +from resources.tester.test_m02_hopyo_excel import _post, client, master_hash # noqa: F401 + +ROOT = Path(__file__).resolve().parents[2] +M02 = ROOT / "M02_MasterTemplete" +API = "/api/m02/layers/personal" +KEY = "HP-000001" +MINE = ("Check", "CheckRows", "ToItem", "Rows") +SHARED = ("Labor_Rows", "Labor_MatMarks", "Labor_Input", "Labor_Pick", "Labor_Detail", "Structure") + + +def _src(name: str) -> str: + return (M02 / f"M02_MasterTemplete_{name}.ts").read_text(encoding="utf-8") + + +def _at(unit, no): + return next(r for r in unit["줄"] if r.get("번") == no and "단가산출" in r["갈래"]) + + +def _base(row): + part = row["풀이"]["절"][0] + cand = next(c for c in part["후보"] if c["키"] == part["고름"]) + return {"갈래": "기초", "키": cand["키"], "입력값": cand["바인딩"]["입력값"]} + + +def _count(kind): + folder = layers.personal_dir(7, 42) / kind + return len(list(folder.glob("*.json"))) if folder.exists() else 0 + + +def test_우리금액_옮기기와_같은_길_저장없음(client): + before = master_hash() + assert _post(client).status_code == 200 + judge.solve_all(layers.personal_dir(7, 42), KEY) + made = (_count("precombo"), _count("sangeun")) + url = f"{API}/hopyo/{KEY}/price" + + two = client.get(f"{API}/hopyo-judge/{KEY}/2").json() + ten = _at(two, 10) # 예시 1 야면석소운반 + got = client.post( + url, json={"층": "personal", "호표": 2, "줄": {str(ten["차례"]): [_base(ten)]}} + ) + assert got.status_code == 200, got.text + line = got.json()["줄"][str(ten["차례"])] + assert line["까닭"] == [] and line["칸"] == [] + assert int(line["금액"]["계"] / ten["수량"]["엑셀"]) == 94227 == ten["단가"]["계"] + + one = client.get(f"{API}/hopyo-judge/{KEY}/1").json() + form = _at(one, 24) # 예시 3 합판거푸집 + ex3 = _base(form) + need = client.post(url, json={"층": "personal", "호표": 1, "줄": {str(form["차례"]): [ex3]}}) + stuck = need.json()["줄"][str(form["차례"])] + assert stuck["금액"] is None and stuck["칸"] == ["높이감률", "구멍땜"] + ex3["입력값"] = {**ex3["입력값"], "높이감률": 0, "구멍땜": 0} + ok = client.post(url, json={"층": "personal", "호표": 1, "줄": {str(form["차례"]): [ex3]}}) + money = ok.json()["줄"][str(form["차례"])]["금액"] + assert abs(money["노무비"] / form["수량"]["엑셀"] - 38291) <= 2 # 엑셀 노무 38,291(줄 0.1 원 끝수) + + bad = client.post( + url, json={"층": "personal", "호표": 2, "줄": {"0": [{**_base(ten), "금액": 1}]}} + ) + assert bad.status_code == 422 # 금액 칸 = 서버가 셈 + assert (_count("precombo"), _count("sangeun")) == made # 쓰기 0 + assert master_hash() == before + + +def test_수정은_모달_줄판몸_없음(): + sheet = _src("Hopyo_Sheet") + edit = sheet.split("const editButton")[1].split("function paintList")[0] + assert "openHopyoCheck({" in edit and "openLines" not in edit + assert "createJudgeRows" not in sheet + _src("Hopyo_Rows") + check = _src("Hopyo_Check") + assert 'from "@ui/ui_template_modal"' in check and "openModal({" in check + assert 'L("M02_CheckTitle")' in check and "confirmSuggested(" in check # [호표 제안 모두 확인] + assert "runMove(" in check and "[String(no)]: mine" in check # [사전조합 생성] = 이 호표만 + assert "h.만든" in check.split("const item = ")[1].split(";")[0] # 옮겨진 뒤만 + locale = (ROOT / "ui_template/ui_template_locale_m2_judge.ts").read_text(encoding="utf-8") + assert 'M02_CheckTitle: ["확인 — 제{n}호표"' in locale and 'M02_CheckNew: ["새"' in locale + + +def test_새_배지_옛_표_꼴(): + rows = _src("Hopyo_CheckRows") + assert "createBadge(" in rows and "newBadge()" in rows and "dataset.checkNew" in rows + assert "newBadge()" in _src("Hopyo_Check") + for part in ("moneyCell(", "PRICE_SLOTS", "markCells(", "markHeads()", '"logic"', '"mat"'): + assert part in rows, part + assert "M02_CheckApart" in _src("Hopyo_Rows") # 별도계상 줄 = 고름 칩 없이 글 + + +def test_공용파일_import_만_부품상자만_700줄(): + texts = {n: _src(f"Hopyo_{n}") for n in MINE} + joined = "\n".join(texts.values()) + for name in ("Labor_Rows", "Labor_MatMarks", "Labor_Input", "Labor_Pick"): + assert f'from "./M02_MasterTemplete_{name}"' in joined, name + for name in SHARED: # 공용 쪽은 새 모달을 모름(거꾸로 이음 없음) + assert "Hopyo_Check" not in _src(name) and "Hopyo_ToItem" not in _src(name), name + for name, text in texts.items(): + assert len(text.splitlines()) <= 700, name + assert not re.search(r"""\bel\(\s*["'](button|input|select)["']""", text), name + assert not re.search(r"""createElement\(\s*["'](button|input|select)["']""", text), name + assert "