diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Imported.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Imported.ts index c2e1f3b85..3ed485e82 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Imported.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Imported.ts @@ -13,6 +13,7 @@ import { createButton } from "@ui/ui_template_button"; import { el, showToast } from "@ui/ui_template_elements"; +import { createText } from "@ui/ui_template_text"; import { loadingError, showLoading } from "@ui/ui_template_loading"; import { currentLanguageIndex } from "@ui/ui_template_locale"; import { askConfirm, openModal } from "@ui/ui_template_modal"; @@ -20,7 +21,7 @@ import { call, readTemplate, saveTemplate } from "./M02_MasterTemplete_Api_Fetch import { openFindPicker } from "./M02_MasterTemplete_Find_Pick"; import { at, type HopyoScope } from "./M02_MasterTemplete_Hopyo_Api"; import { checkJudge, readJudgeBlock } from "./M02_MasterTemplete_Hopyo_JudgeApi"; -import { importedPanel, type PanelState } from "./M02_MasterTemplete_Hopyo_ImportedPanel"; +import { importedPanel, tx, type PanelState } from "./M02_MasterTemplete_Hopyo_ImportedPanel"; import { mountSangeunDetail } from "./M02_MasterTemplete_Sangeun_Detail"; import { marksOf, @@ -104,6 +105,8 @@ export function openImported(o: ImportedOptions): void { let sk = ""; let 판 = ""; let tier: Tier | null = null; + /** 우리 기초로 바꾸기 전 가져온 식 · 고른 기초(환산 계수 넣을 때 다시 바꿈) */ + let based: { src: ImportedDoc; code: string } | null = null; let dirty = false; let close = (): void => {}; let handle: ReturnType | null = null; @@ -123,6 +126,13 @@ export function openImported(o: ImportedOptions): void { }); draw(); } + /** 환산 계수 없이는 못 셈 — 시트 안 엶(셈 길 400 없음) · 머리 판 붉은 칸만 */ + function hold(doc: ImportedDoc): void { + handle?.destroy(); + handle = null; + st.doc = doc; + host.replaceChildren(createText(tx("NeedHint"), "warning", 14)); + } const now = (): ImportedDoc => (handle?.getDoc() as ImportedDoc | undefined) ?? st.doc; function draw(): void { @@ -132,13 +142,18 @@ export function openImported(o: ImportedOptions): void { } /** 서버 고침 한 번(눌림 막음) → 시트 다시 엶 */ - async function run(how: string, body: Record): Promise { + async function run( + how: string, + body: Record, + from: ImportedDoc = now(), + ): Promise { st.busy = true; draw(); try { - const got = await edit(how, { 문서: now(), ...body }); + const got = await edit(how, { 문서: from, ...body }); dirty = true; - mount(got.문서); + if (got.필요?.includes("환산")) hold(got.문서); + else mount(got.문서); return got; } catch (e) { showToast(why(e), "error"); @@ -188,10 +203,12 @@ export function openImported(o: ImportedOptions): void { return void showToast(why(e), "error"); } st.busy = false; - const got = await run("to-base", { 로직: code, 입력값: bound.입력값 }); + const src = now(); + const got = await run("to-base", { 로직: code, 입력값: bound.입력값 }, src); if (!got) return; + based = { src, code }; tier = cand?.단 ?? null; - st.need = [...bound.필요, ...(got.필요 ?? [])]; + st.need = [...new Set([...bound.필요, ...(cand?.입력필요 ?? []), ...(got.필요 ?? [])])]; st.unlinked = []; draw(); } @@ -205,9 +222,10 @@ export function openImported(o: ImportedOptions): void { const doc = now(); const v = value.trim(); dirty = true; - if (name === "환산") { - const { 환산: _old, ...rest } = doc; - return mount(v && Number(v) > 0 ? { ...rest, 환산: { 계수: Number(v) } } : rest); + if (name === "환산" && based) { + // 가져온 식에서 다시 바꿈(바꿈 전 · 후 = 서버 정본 · 옵션은 지금 값) + const swap = v && Number(v) > 0 ? { 계수: Number(v) } : null; + return void run("to-base", { 로직: based.code, 입력값: doc.입력값 ?? {}, 환산: swap }, based.src); // prettier-ignore } const kept = { ...(doc.입력값 ?? {}) }; if (v) kept[name] = v; @@ -237,7 +255,7 @@ export function openImported(o: ImportedOptions): void { variant: "ghost", onClick: async () => { if (dirty && !(await save())) return; - o.onLink!(skPick(sk, st.doc, o.target, st.need, marksOf(st.doc, tier))); + o.onLink!(skPick(sk, st.doc, st.need, marksOf(st.doc, tier))); showToast(t("Linked").replace("{k}", sk), "info"); dirty = false; close(); diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_ImportedPanel.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_ImportedPanel.ts index 1e7700c7b..ca4f822f6 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_ImportedPanel.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_ImportedPanel.ts @@ -25,6 +25,7 @@ import { gapText, isImported, needValues, + reasonText, stillNeed, tiers, TIERS, @@ -57,6 +58,7 @@ const TEXT = { 근접: ["근접", "Near"], 사람: ["사람 고름", "Human pick"], None: ["없음", "None"], + Qty: ["품 {v}", "Qty {v}"], NoCand: ["근거 제안 없음 — [기초 고르기]로 고름", "No suggestion — pick a basis"], ToBase: ["우리 기초로 바꾸기", "Convert to our basis"], PickBase: ["기초 고르기", "Pick basis"], @@ -146,9 +148,11 @@ function cand(c: BasisCand, s: PanelState, a: PanelActs): HTMLElement { attrs: { "data-basis-cand": c.기초, "data-basis-tier": c.단 }, children: [ createBadge(`${c.기초}${c.이름 ? ` ${c.이름}` : ""}`, "accent"), + ...(c.원문번호 ? [muted(c.원문번호)] : []), ...(opts ? [muted(opts)] : []), - ...(c.차이 !== undefined ? [muted(String(c.차이))] : []), - ...(c.까닭 ? [muted(c.까닭)] : []), + ...(c.품검산 ? [createBadge(tx("Qty").replace("{v}", c.품검산), c.품검산 === "같음" ? "success" : "warning")] : []), // prettier-ignore + ...(c.입력필요?.length ? [createBadge(`${tx("Need")} ${c.입력필요.length}`, "danger")] : []), + ...(reasonText(c) ? [muted(reasonText(c))] : []), go, ], }); @@ -201,7 +205,7 @@ export function importedPanel(s: PanelState, a: PanelActs): HTMLElement { head.push("divider", muted(`${tx("Basis")} ${meta.단가기준 ?? "엑셀"}`), flip); if (s.price) { const g = tx("Gap").replace("{a}", won(s.price.전)).replace("{b}", won(s.price.후)); - const t = muted(g.replace("{d}", gapText(s.price.전, s.price.후) || "0")); + const t = muted(g.replace("{d}", gapText(s.price.전, s.price.후))); t.dataset.importedGap = ""; head.push(t); } @@ -222,7 +226,7 @@ export function importedPanel(s: PanelState, a: PanelActs): HTMLElement { const gone = meta.바꿈; if (!here && gone) { const g = tx("Gap").replace("{a}", won(gone.전)).replace("{b}", won(gone.후)); - kids.push(createBanner({ tone: "info", text: g.replace("{d}", gapText(gone.전, gone.후) || "0") })); // prettier-ignore + kids.push(createBanner({ tone: "info", text: g.replace("{d}", gapText(gone.전, gone.후)) })); // prettier-ignore } const values = needValues(s.doc); const left = stillNeed(s.need, values); diff --git a/M02_MasterTemplete/M02_Sangeun_FromHopyo.py b/M02_MasterTemplete/M02_Sangeun_FromHopyo.py index 20cf6c23e..8ae88c45d 100644 --- a/M02_MasterTemplete/M02_Sangeun_FromHopyo.py +++ b/M02_MasterTemplete/M02_Sangeun_FromHopyo.py @@ -4,7 +4,8 @@ `실무자료/산근/.json` 에 둠 (원본 엑셀은 안 둠 · HP 지울 때 같이 지움) → [가져온 식으로 열기] = 그 블록을 793-2a 내보내기 (`M02_San_Emit` · 이음 = 1차 줄 판정) → 793-2 만들기(`M02_Sangeun_Imported.create` · 개인 층 · -엑셀 단가 그대로) · 같은 내용 SK 가 있으면 그것(다시씀). +엑셀 단가 그대로 · `가져온.근거후보` = 793-3a 근거 제안 `M02_San_Basis_Store.block` 근거후보) · +같은 내용 SK 가 있으면 그것(다시씀 · 근거후보 그대로). 블록 번 = 판정 블록 번(STmate = 코드 「D00102」 숫자 · EST = 단가산출 블록 번). STmate 블록 끝전(비목버림 | 합버림_큰나머지)은 파일 설정 — 참조 평가기로 둘 다 셈해 엑셀 계와 맞는 것. @@ -93,12 +94,18 @@ def emit(folder: str | Path, key: str, no: int) -> dict[str, Any]: def make(source: Path, key: str, no: int, name: str = "") -> dict[str, Any]: """지금 층(만들 층)에 가져온 식 SK — 같은 내용이 있으면 그 SK(`다시씀`).""" + from M02_MasterTemplete import M02_San_Basis_Store as san_basis from M02_MasterTemplete import M02_Sangeun_Imported as imp out = emit(source, key, no) title = str(name or "").strip() or str(out["로직"].get("이름") or f"단산{no}") try: - return {**imp.create(out["로직"], out["원본"], title), "다시씀": False} + basis = san_basis.block(source, key, no).get("근거후보") or None + except Exception: # noqa: BLE001 — 근거 제안 못 셈 = 빈 칸(가져온 식은 만듦) + basis = None + try: + got = imp.create(out["로직"], out["원본"], title, basis=basis) + return {**got, "다시씀": False} except store.StoreError as e: same = (e.detail or {}).get("같은것") if isinstance(e.detail, dict) else None if e.status != 409 or not same: diff --git a/M02_MasterTemplete/M02_Sangeun_Imported.py b/M02_MasterTemplete/M02_Sangeun_Imported.py index 8d7a02804..b93320af0 100644 --- a/M02_MasterTemplete/M02_Sangeun_Imported.py +++ b/M02_MasterTemplete/M02_Sangeun_Imported.py @@ -477,7 +477,7 @@ def to_base(doc: Any, code: str, given: dict | None = None, swap: Any = None) -> # ── 만들기(개인 층) ─────────────────────────────────────────────────── def create( logic: Any, source: Any, name: str = "", unit: str = "", head: dict | None = None, - new: bool = False, use: str | None = None, + new: bool = False, use: str | None = None, basis: list | None = None, ) -> dict[str, Any]: # fmt: skip """엔진 로직 줄 + 원본 → 지금 층 새 가져온 식 SK — 서버가 다시 셈해 엑셀과 맞댐(검산 · 다르면 `같음` False 로 표지 · 막지 않음) · 같은 층 같은 내용 = 409(`ask` · 733-2).""" @@ -493,6 +493,8 @@ def create( meta = {"로직": row, "단가기준": "엑셀"} if source not in (None, {}): meta["원본"] = _meta(source, "원본") + if basis: # 근거 제안(793-3a `제안.근거후보` 그대로 · 793-3b) + meta["근거후보"] = _meta(basis, "근거후보") doc[KEY] = meta got = check(sd.clean(doc, None, whole), whole) key = ask.ask(sd.KIND, doc, new, use) or refs.create(sd.KIND, doc)["이름"] diff --git a/M02_MasterTemplete/logic/M02_Hopyo_Imported_Model.ts b/M02_MasterTemplete/logic/M02_Hopyo_Imported_Model.ts index bec68ef44..3084d4cb0 100644 --- a/M02_MasterTemplete/logic/M02_Hopyo_Imported_Model.ts +++ b/M02_MasterTemplete/logic/M02_Hopyo_Imported_Model.ts @@ -2,7 +2,7 @@ * logic/M02_Hopyo_Imported_Model.ts * 불러오기 단산 줄 = 가져온 식 단가산출(PLAN 793-3b · 요구 646 · 647) — 꼴 · 읽기 · 바꾸기만. * · SK `가져온` 덧칸 꼴(793-2 `M02_Sangeun_Imported.py` 머리) · 근거 제안 세 단(확정 · 근접 · 사람 고름 · 793-3a 가 채움) - * · 단산 줄 고른 것 = {갈래 단가산출, 키 SK-…, 층, 주인, 필요(입력 필요), 표지(결과 칩)} — 필요가 비어야 확인 · 옮김 + * · 단산 줄 고른 것 = {갈래 단가산출, 키 SK-…, 필요(입력 필요), 표지(결과 칩)} — 층 = 옮길 층 — 필요가 비어야 확인 · 옮김 * · 줄 결과 칩 = 가져온 식 있음 · 근거 확정 · 근접 · 사람 고름 · 입력 필요 n(「2차 필요」 대신) * 화면 · fetch · sessionStorage 없음(`CLAUDE.md` 4장). * ========================================================================== */ @@ -13,17 +13,23 @@ export type Money4 = Record<"노무비" | "재료비" | "경비" | "계", number export type Tier = "확정" | "근접" | "사람"; export const TIERS: Tier[] = ["확정", "근접", "사람"]; -/** 근거 후보 하나(793-3a) */ +/** 근거 후보 하나(793-3a `제안.근거후보`) — 까닭 = [[표지(판 차이 · 현장 값 · 감률 · 할증 배수 · 단위 기준 차이 · 그 밖), 글]] */ export interface BasisCand { 기초: string; 이름?: string; + 원문번호?: string; 옵션?: Record; - 품검산?: unknown; + /** 글에 없는 칸(감률 · 할증 포함) — 사용자 입력 */ + 입력필요?: string[]; + 품검산?: "같음" | "다름" | "짝 없음" | null; 단: Tier; - 까닭?: string; - 차이?: string | number; + 까닭?: [string, string][]; } +/** 근거 후보 까닭 글(「표지 글」 · 줄마다) */ +export const reasonText = (c: BasisCand): string => + (c.까닭 ?? []).map(([tag, text]) => `${tag} ${text}`.trim()).join(" · "); + export interface CheckLine { 자리: string; 행?: number | null; @@ -93,11 +99,10 @@ export const needValues = (d: ImportedDoc): Record => ({ export const stillNeed = (need: string[], values: Record | undefined): string[] => need.filter((k) => blank(values?.[k])); -/** 단산 줄 고른 것 — 그 SK(필요 = 빈 입력 필요 칸 · `missing` 이 막음) */ +/** 단산 줄 고른 것 — 그 SK(필요 = 빈 입력 필요 칸 · `missing` 이 막음) · 층 · 주인 안 둠 = 옮길 층(SK 를 만든 층)의 SK */ export function skPick( sk: string, doc: ImportedDoc, - scope: { layer: string; owner: string | null }, need: string[], marks: string[], ): DraftPick & { 표지: string[] } { @@ -110,8 +115,6 @@ export function skPick( 입력값: Object.fromEntries(need.map((k) => [k, values[k] ?? ""])), 출처: {}, 몫: null, - 층: scope.layer, - 주인: scope.owner, 필요: need, 표지: marks, }; @@ -136,7 +139,7 @@ export function gapText( before: Money4 | null | undefined, after: Money4 | null | undefined, ): string { - if (!before || !after) return ""; + if (!before || !after) return "—"; const d = after.계 - before.계; return `${d > 0 ? "+" : ""}${d.toLocaleString("ko-KR")}`; } diff --git a/resources/tester/test_793_3b_import_screen.py b/resources/tester/test_793_3b_import_screen.py index d35e2b165..5f5ac019b 100644 --- a/resources/tester/test_793_3b_import_screen.py +++ b/resources/tester/test_793_3b_import_screen.py @@ -66,6 +66,10 @@ def test_단산_블록_가져온_식_만들기_다시씀_지움(client): with store.at(folder): doc = store.read("sangeun", got["이름"])["문서"] assert doc["바탕"] is None and doc["가져온"]["원본"]["엑셀금액"]["계"] == 30369 + ten = _open(client, 10).json() # 야면석소운반 — 793-3a 근거 제안 = SK 가져온.근거후보 + with store.at(folder): + cand = store.read("sangeun", ten["이름"])["문서"]["가져온"]["근거후보"] + assert cand and cand[0]["기초"] == "GC001140" and cand[0]["단"] in ("확정", "근접", "사람") assert _open(client, 99999).status_code == 404 fh._path(folder, KEY).unlink() # 산근 없는 옛 HP = 다시 올림 old = _open(client) @@ -188,3 +192,24 @@ def test_우리_기초로_바꾸기_환산_입력_필요(tmp_path): "필요" not in got and round(got["후"]["계"]) == 94227 and got["문서"]["환산"] == {"계수": 1} ) assert got["문서"]["바탕"] == {"로직": "GC001140"} and got["문서"][imp.KEY]["바꿈"]["전"] + + +def test_이_줄에_잇기_옮기기_SK_참조(client): + """[이 줄에 잇기] 고른 것 {갈래 단가산출, 키 SK}(층 · 주인 없음) → 옮기기 = 그 SK 참조 줄.""" + from M02_MasterTemplete import M02_Hopyo_Judge as judge + + client.app.include_router(sangeun_router.router) + assert _post(client).status_code == 200 + folder = layers.personal_dir(7, 42) + judge.solve_all(folder, KEY) + sk = _open(client, 10).json()["이름"] + pick = {"갈래": "단가산출", "키": sk, "이름": "야면석소운반", "단위": "㎡"} # 화면 skPick 꼴 + pick |= {"입력값": {}, "출처": {}, "몫": None, "필요": [], "표지": ["가져온 식 있음"]} + body = {"층": "personal", "호표": {"2": {"3": [pick]}}, "블록": {}} + got = client.post(f"{API}/hopyo/{KEY}/move", json=body) + assert got.status_code == 200, got.text + sc = got.json()["만듦"][0]["키"] + with store.at(folder): + lines = store.read("precombo", sc)["문서"]["담은로직"] + refs = [x["참조"]["키"] for x in lines if x.get("참조")] + assert got.json()["막힘"] == [] and refs == [sk], got.json()["막힘"]