diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Block.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Block.ts index 05d33e997..380d65f83 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Block.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Block.ts @@ -397,7 +397,7 @@ export function createJudgeBlock(opt: JudgeBlockOptions): { }); ok.root.dataset.judgeOk = "block"; const actions: HTMLElement[] = [ok.root]; - const open = createButton({ label: L("M02_CheckImported"), size: 24, variant: "ghost", disabled: opt.readOnly, onClick: () => void import("./M02_MasterTemplete_Hopyo_Imported").then((m) => m.openImported({ ...{ file: opt.file, key: opt.key, no: blk.번, target: opt.target, canWrite: !opt.readOnly }, bind: (code, given) => checkJudge(opt.file, opt.key, { 블록: blk.번, 고른: [bare({ 갈래: "기초", 키: code, 이름: code, 입력값: given, 출처: {}, 몫: null })] }).then((res) => ({ 입력값: res.고른[0]?.입력값 ?? given, 필요: res.고른[0]?.입력필요 ?? [] })) })) }); // prettier-ignore + const open = createButton({ label: L("M02_CheckImported"), size: 24, variant: "ghost", disabled: opt.readOnly, onClick: () => void import("./M02_MasterTemplete_Hopyo_Imported").then((m) => m.openImported({ ...{ file: opt.file, key: opt.key, no: blk.번, target: opt.target, canWrite: !opt.readOnly } })) }); // prettier-ignore open.dataset.judgeImported = String(blk.번); const fresh = createBadge(L("M02_CheckNew"), "accent", { size: 18 }); // 「새」(793-3b) — CheckRows.newBadge 꼴(돌림 import 피함) fresh.dataset.checkNew = ""; diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Check.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Check.ts index f3d89c72f..ec4e8fc5c 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Check.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Check.ts @@ -253,22 +253,10 @@ export function openHopyoCheck(opt: CheckOptions): { close: () => void } { }); } - /** 기초 옵션 묶음 — 이 줄 검산(엑셀 값 · 입력 필요) */ - const bindOf = (row: JudgeRow) => (code: string, given: Record) => - checkJudge(opt.file, opt.key, { - 호표: no, - 차례: row.차례, - 고른: [bare({ 갈래: "기초", 키: code, 이름: code, 입력값: given, 출처: {}, 몫: null })], - }).then( - // prettier-ignore - (res) => ({ 입력값: res.고른[0]?.입력값 ?? given, 필요: res.고른[0]?.입력필요 ?? [] }), - ); - /** [가져온 식으로 열기](793-3b) — 단산 블록 → 가져온 식 SK 모달 · [이 줄에 잇기] = 이 줄 고른 것 */ const imported = (row: JudgeRow): void => openImported({ ...{ file: opt.file, key: opt.key, no: row.번!, target: opt.target, canWrite: opt.canWrite }, - bind: bindOf(row), onLink: (p) => kit.edit(row, [p]), }); diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Imported.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Imported.ts index 02e270839..c2e1f3b85 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Imported.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Imported.ts @@ -5,7 +5,8 @@ * · 열기 = 서버 `…/sangeun/from-hopyo`(그 블록 → 가져온 식 SK · 만들 층 · 엑셀 단가 그대로 · 같은 내용 = 그 SK 다시 씀) * → 그 SK 를 읽어 우리 단가산출 화면(`Sangeun_Detail` 시트 · 고침 = 시트 그대로) + 머리 판(`Hopyo_ImportedPanel`) * · [우리 단가로] · [엑셀 단가로] · [이음 고르기] · [우리 기초로 바꾸기] = 서버 `…/sangeun/imported/{price|link|to-base}` - * (화면 캐시 문서 고침 · 저장 없음) → 시트 다시 엶 · 기초로 바꿀 때 옵션 = 불러오기 검산(`bind` · 엑셀 값 묶음 · 입력 필요) + * (화면 캐시 문서 고침 · 저장 없음) → 시트 다시 엶 · 기초로 바꿀 때 옵션 = 블록 풀이 그 기초 후보 바인딩(엑셀 값 · 기본) → + * 블록 검산으로 입력 필요 · 단위 성격이 다르면 「환산 계수」도 입력 필요 * · [저장] = 그 SK 저장(서버가 다시 셈) · [이 줄에 잇기] = 저장 뒤 단산 줄 고른 것 = 그 SK(갈래 단가산출 · 키) — * 확인 · [사전조합 생성]은 기존 길(자동 확인 · 옮김 없음) · 입력 필요가 비면 그 줄 확인 막힘 * ========================================================================== */ @@ -18,6 +19,7 @@ import { askConfirm, openModal } from "@ui/ui_template_modal"; 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 { mountSangeunDetail } from "./M02_MasterTemplete_Sangeun_Detail"; import { @@ -28,7 +30,7 @@ import { type Money4, type Tier, } from "./logic/M02_Hopyo_Imported_Model"; -import type { DraftPick } from "./logic/M02_MasterTemplete_Hopyo_Picks"; +import { bare, type DraftPick } from "./logic/M02_MasterTemplete_Hopyo_Picks"; const TEXT = { Title: ["가져온 식 단가산출 — 단산{n}", "Imported build-up — block {n}"], @@ -53,8 +55,6 @@ export interface ImportedOptions { /** SK 를 만들 층 */ target: HopyoScope; canWrite: boolean; - /** 기초 옵션 묶음(불러오기 검산) → 엔진 입력값 · 입력 필요 */ - bind: (code: string, given: Record) => Promise<{ 입력값: Record; 필요: string[] }>; // prettier-ignore /** 단산 줄에서 열었을 때만 — [이 줄에 잇기] */ onLink?: (pick: DraftPick) => void; } @@ -70,8 +70,24 @@ const fromHopyo = (o: ImportedOptions): Promise => body: JSON.stringify({ 층: o.file.layer, ...(o.file.owner ? { 주인: o.file.owner } : {}), 키: o.key, 번: o.no }), // prettier-ignore }); +/** 기초 옵션 — 블록 풀이 그 기초 후보 바인딩(엑셀 값 · 기본 · 근거 옵션) → 블록 검산 입력 필요 + 엇갈림 */ +async function bind( + o: ImportedOptions, + code: string, + given: Record, +): Promise<{ 입력값: Record; 필요: string[] }> { + const blk = await readJudgeBlock(o.file, o.key, o.no, o.target).catch(() => null); + const b = blk?.절.flatMap((s) => s.후보).find((c) => c.키 === code)?.바인딩; + const values = { ...(b?.기본 ?? {}), ...(b?.입력값 ?? {}), ...given }; + const pick = bare({ 갈래: "기초", 키: code, 이름: code, 입력값: values, 출처: {}, 몫: null }); + const res = await checkJudge(o.file, o.key, { 블록: o.no, 고른: [pick] }); + const need = [...(res.고른[0]?.입력필요 ?? []), ...Object.keys(b?.엇갈림 ?? {})]; + return { 입력값: values, 필요: [...new Set(need)] }; +} + interface EditAnswer { 문서: ImportedDoc; + 필요?: string[]; 전?: Money4 | null; 후?: Money4 | null; 이음없음?: { 자리: string; 이름: string }[]; @@ -163,9 +179,9 @@ export function openImported(o: ImportedOptions): void { async function toBase(code: string, cand: BasisCand | null): Promise { st.busy = true; draw(); - let bound: Awaited>; + let bound: Awaited>; try { - bound = await o.bind(code, cand?.옵션 ?? {}); + bound = await bind(o, code, cand?.옵션 ?? {}); } catch (e) { st.busy = false; draw(); @@ -175,7 +191,7 @@ export function openImported(o: ImportedOptions): void { const got = await run("to-base", { 로직: code, 입력값: bound.입력값 }); if (!got) return; tier = cand?.단 ?? null; - st.need = bound.필요; + st.need = [...bound.필요, ...(got.필요 ?? [])]; st.unlinked = []; draw(); } @@ -184,12 +200,18 @@ export function openImported(o: ImportedOptions): void { openFindPicker({ layer: o.target.layer, owner: o.target.owner, kinds: ["logic"], logic: (x) => void toBase(x.키, null) }); // prettier-ignore } + /** 입력 필요 칸 — 옵션 = SK 입력값 · 「환산」 = SK 환산 계수 */ function fill(name: string, value: string): void { const doc = now(); - const kept = { ...(doc.입력값 ?? {}) }; - if (value.trim()) kept[name] = value.trim(); - else delete kept[name]; + const v = value.trim(); dirty = true; + if (name === "환산") { + const { 환산: _old, ...rest } = doc; + return mount(v && Number(v) > 0 ? { ...rest, 환산: { 계수: Number(v) } } : rest); + } + const kept = { ...(doc.입력값 ?? {}) }; + if (v) kept[name] = v; + else delete kept[name]; mount({ ...doc, 입력값: kept }); } diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_ImportedPanel.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_ImportedPanel.ts index 55aabec9e..1e7700c7b 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_ImportedPanel.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_ImportedPanel.ts @@ -24,6 +24,7 @@ import { newBadge } from "./M02_MasterTemplete_Hopyo_CheckRows"; import { gapText, isImported, + needValues, stillNeed, tiers, TIERS, @@ -61,6 +62,7 @@ const TEXT = { PickBase: ["기초 고르기", "Pick basis"], Gap: ["바꾸기 전 {a} → 후 {b} ({d})", "Before {a} → after {b} ({d})"], Need: ["입력 필요", "Input needed"], + Swap: ["환산 계수(기초 1 단위 = 이 단가산출 몇 단위)", "Unit factor (1 basis unit = n of this)"], NeedHint: ["채워야 이 줄을 확인 · 옮김", "Fill to confirm and move this line"], Close: ["닫기", "Close"], } as const satisfies Record; @@ -222,10 +224,12 @@ export function importedPanel(s: PanelState, a: PanelActs): HTMLElement { 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 } - const left = stillNeed(s.need, s.doc.입력값); + const values = needValues(s.doc); + const left = stillNeed(s.need, values); if (s.need.length) { const fields = s.need.map((k) => { - const f = createInputField({ label: k, size: 28, value: String(s.doc.입력값?.[k] ?? "") }); + const label = k === "환산" ? tx("Swap") : k; + const f = createInputField({ label, size: 28, value: String(values[k] ?? "") }); f.input.dataset.importedNeed = k; f.input.disabled = s.readOnly || s.busy; if (left.includes(k)) f.setError(tx("Need")); diff --git a/M02_MasterTemplete/M02_Sangeun_Const.py b/M02_MasterTemplete/M02_Sangeun_Const.py index d140ee81d..0a97ecbe9 100644 --- a/M02_MasterTemplete/M02_Sangeun_Const.py +++ b/M02_MasterTemplete/M02_Sangeun_Const.py @@ -384,6 +384,7 @@ def sheet(ans: dict, clean: dict, raw: Any, given: dict | None, trail: tuple) -> mids0, env0 = _mids(whole, key, base, values) mids1, _ = _mids(whole, key, formulas(base, clean), values) stamps = (raw.get("M01판") if isinstance(raw, dict) else None) or {} + stamps = {} if imp.is_imported(clean) else stamps # 가져온 식 = M01 짝 없음(793-3b) fresh = stamp(base)["M01판"] why = clean.get("까닭") or {} nums, finds = clean.get("상수덮개") or {}, clean.get("표값덮개") or {} diff --git a/M02_MasterTemplete/M02_Sangeun_Imported.py b/M02_MasterTemplete/M02_Sangeun_Imported.py index 6971e9e05..8d7a02804 100644 --- a/M02_MasterTemplete/M02_Sangeun_Imported.py +++ b/M02_MasterTemplete/M02_Sangeun_Imported.py @@ -28,7 +28,9 @@ SK 문서 덧칸 `가져온` = {로직(엑셀 단가 그대로 · 고치지 않 → {이름(SK 키), 검산, 단가기준}(서버가 다시 셈해 엑셀과 맞댐 · 같은 내용 409 = 기존 길) POST …/sangeun/imported/price 몸 {문서, 기준} → {문서, 전, 후, 이음없음 [{자리, 이름}]} POST …/sangeun/imported/link 몸 {문서, 자리, 이음} → {문서}(이음 고름 · 덮개 열쇠 옮김) - POST …/sangeun/imported/to-base 몸 {문서, 로직(M01 GC), 입력값} → {문서, 전, 후, 차이} + POST …/sangeun/imported/to-base 몸 {문서, 로직(M01 GC), 입력값, 환산?} → {문서, 전, 후, 차이, + 필요?}(기초 결과단위 성격이 SK 단위와 달라 환산 계수가 없으면 후 = None · + 필요 ["환산"] · 793-3b) 셈 답(`…/sangeun/calc`) = 옛 꼴 그대로 · `원래` = 엑셀 금액(「M01 원래」 자리) · 덧칸 `가져온` {단가기준, 파일, 시트, 행, 갈래, 엑셀금액, 글줄 [{행, 글, 자리, 엑셀, 지금, 같음}], 이음없음}. """ @@ -447,21 +449,29 @@ def _rate_rekey(one: Any, move: dict) -> Any: return {**one, "고정": {**one["고정"], "비율": {**rate, "기준": keys}}} -def to_base(doc: Any, code: str, given: dict | None = None) -> dict[str, Any]: +def to_base(doc: Any, code: str, given: dict | None = None, swap: Any = None) -> dict[str, Any]: """[우리 기초로 바꾸기] — 가져온 식 SK → M01 기초(바탕 로직) + 옵션 SK · 고침 걷힘 · - 원본 · 검산 · 근거후보 보존 + `바꿈` {로직, 단가기준, 전, 후, 차이}(1 단위).""" + 원본 · 검산 · 근거후보 보존 + `바꿈` {로직, 단가기준, 전, 후, 차이}(1 단위) · + `swap` = SK 단위 환산 {계수} · 없는데 단위 성격이 다르면 후 = None · `필요` ["환산"](793-3b).""" doc = _need(doc) whole = _whole() before = _money(_run(doc, whole)) head = {k: doc[k] for k in ("키", "이름", "단위", "구분", "상세구분") if k in doc} new = {**head, "바탕": {"로직": str(code or "").strip()}, "입력값": dict(given or {})} new |= {"열린입력": [], "고름": {}, "자재표시": {}, "덮개": {}, "뺀줄": [], "더한줄": []} - after = _money(_run(new, whole)) + new |= {"환산": swap} if swap else {} + need: list[str] = [] + try: + after = _money(_run(new, whole)) + except sd.DocError as e: + if swap or not str(e).startswith("단가산출 단위"): + raise + after, need = None, ["환산"] gap = {k: after[k] - before[k] for k in after} if before and after else None keep = {k: doc[KEY][k] for k in ("원본", "검산", "근거후보") if doc[KEY].get(k)} was = {k: doc[KEY].get(k) for k in ("로직", "단가기준")} new[KEY] = {**keep, "바꿈": {**was, "전": before, "후": after, "차이": gap}} - return {"문서": new, "전": before, "후": after, "차이": gap} + return {"문서": new, "전": before, "후": after, "차이": gap} | ({"필요": need} if need else {}) # ── 만들기(개인 층) ─────────────────────────────────────────────────── diff --git a/M02_MasterTemplete/M02_Sangeun_Router.py b/M02_MasterTemplete/M02_Sangeun_Router.py index 365f50539..18546747e 100644 --- a/M02_MasterTemplete/M02_Sangeun_Router.py +++ b/M02_MasterTemplete/M02_Sangeun_Router.py @@ -152,6 +152,7 @@ class ImportedEditBody(BaseModel): 이음: str | None = None # link 로직: str | None = None # to-base 입력값: dict[str, Any] | None = None # to-base + 환산: dict[str, Any] | None = None # to-base(793-3b · SK 단위 환산 계수) def _sd(fn: Any) -> Any: @@ -169,7 +170,7 @@ def _sd(fn: Any) -> Any: _EDIT = { "price": lambda b: (imp.to_price, b.문서, b.기준), "link": lambda b: (imp.link, b.문서, b.자리, b.이음), - "to-base": lambda b: (imp.to_base, b.문서, b.로직, b.입력값), + "to-base": lambda b: (imp.to_base, b.문서, b.로직, b.입력값, b.환산), } diff --git a/M02_MasterTemplete/logic/M02_Hopyo_Imported_Model.ts b/M02_MasterTemplete/logic/M02_Hopyo_Imported_Model.ts index ceff4b774..bec68ef44 100644 --- a/M02_MasterTemplete/logic/M02_Hopyo_Imported_Model.ts +++ b/M02_MasterTemplete/logic/M02_Hopyo_Imported_Model.ts @@ -61,6 +61,8 @@ export interface ImportedDoc { 단위?: string; 바탕?: { 로직?: string } | null; 입력값?: Record; + /** SK 단위 환산(기초 결과단위 성격이 다를 때 · 728-2) */ + 환산?: { 계수?: number | string }; 가져온?: ImportedMeta; [k: string]: unknown; } @@ -81,6 +83,12 @@ export const wrongLines = (d: ImportedDoc): CheckLine[] => const blank = (v: unknown): boolean => v === undefined || v === null || String(v).trim() === ""; +/** 입력 필요 칸 값 — 입력값 + 「환산」 = 환산 계수 */ +export const needValues = (d: ImportedDoc): Record => ({ + ...(d.입력값 ?? {}), + 환산: d.환산?.계수, +}); + /** 입력 필요 중 아직 빈 칸 */ export const stillNeed = (need: string[], values: Record | undefined): string[] => need.filter((k) => blank(values?.[k])); @@ -93,7 +101,7 @@ export function skPick( need: string[], marks: string[], ): DraftPick & { 표지: string[] } { - const values = doc.입력값 ?? {}; + const values = needValues(doc); return { 갈래: "단가산출", 키: sk, diff --git a/resources/tester/test_793_3b_import_screen.py b/resources/tester/test_793_3b_import_screen.py index 865588a00..d35e2b165 100644 --- a/resources/tester/test_793_3b_import_screen.py +++ b/resources/tester/test_793_3b_import_screen.py @@ -144,3 +144,47 @@ def test_부품상자만_700줄_공용_안_고침(): assert "#" not in "".join(re.findall(r"color:\s*[^;]+", text)), name for name in ("Labor_Rows", "Labor_SangeunModal", "Sangeun_Detail", "Sangeun_Sheet"): assert "Hopyo_Imported" not in _src(name), name # 공용 쪽은 새 모달을 모름 + + +def test_가져온_식_시트_M01바뀜_없음(client): + """가져온 식 SK 는 M01 짝이 없음 — 저장 M01판이 있어도 시트 「M01 바뀜」 · 짝잃음 안 띄움.""" + from M02_MasterTemplete import M02_Sangeun_Calc as calc + + client.app.include_router(sangeun_router.router) + assert _post(client).status_code == 200 + name = _open(client).json()["이름"] + folder = layers.personal_dir(7, 42) + with store.at(folder): + doc = store.read("sangeun", name)["문서"] + spots = list(calc.calc_doc(doc)["시트"]["자리"]) + ans = calc.calc_doc({**doc, "M01판": {k: "다름" for k in [*spots, "x"]}}) + sheet = ans["시트"] + assert sheet["자리"] and not any(x["M01바뀜"] for x in sheet["자리"].values()) + assert sheet["짝잃음"] == [] + + +def test_우리_기초로_바꾸기_환산_입력_필요(tmp_path): + """북부청 단산10 야면석소운반(㎡) → GC001140(원/자재 단위) — 환산 없으면 후 None · + 필요 「환산」 · 블록 풀이 바인딩 옵션 + 계수 1 = 엑셀 94,227(품 같음).""" + from M02_MasterTemplete import M02_Hopyo_Store as hs + from M02_MasterTemplete import M02_Sangeun_Imported as imp + + path = golden.FILES["북부청임도"] + if not path.exists(): + pytest.skip("실무 엑셀 없음") + hs._dir(tmp_path).mkdir(parents=True) + (hs._dir(tmp_path) / f"{KEY}.json").write_text(f'{{"키": "{KEY}", "판": {hs.BOOK}}}', "utf-8") + fh.keep(tmp_path, KEY, path.name, path.read_bytes()) + out = fh.emit(tmp_path, KEY, 10) + doc = {"키": "SK-000000", "이름": "야면석소운반", "단위": "㎡", "바탕": None, "입력값": {}} + doc[imp.KEY] = {"로직": out["로직"], "단가기준": "엑셀", "원본": out["원본"]} + given = {"운반거리": 6, "경사각도": 35, "운반회수": 42, "운반무게": 575, "회당운반량": 25} + given |= {"재료종별": "암석", "재료형상": "해당 없음", "재료갈래": "토사류", "운반로": "양호"} + given |= {"실작업시간": 0} + bare = imp.to_base(doc, "GC001140", given) + assert bare["후"] is None and bare["필요"] == ["환산"] and round(bare["전"]["계"]) == 94227 + got = imp.to_base(doc, "GC001140", given, {"계수": 1}) + assert ( + "필요" not in got and round(got["후"]["계"]) == 94227 and got["문서"]["환산"] == {"계수": 1} + ) + assert got["문서"]["바탕"] == {"로직": "GC001140"} and got["문서"][imp.KEY]["바꿈"]["전"]