fix(793-3b): 우리 기초로 바꾸기 — 옵션 = 블록 풀이 후보 바인딩 · 단위 성격 다르면 환산 계수 입력 필요 · 가져온 식 시트 M01 바뀜 표지 걷음

- to-base 몸 환산? · 환산 없이 단위 성격 다름 = 후 None · 필요 ["환산"](막지 않음 · 화면 붉은 칸)
- 화면 bind = 블록 풀이 그 기초 후보 바인딩(엑셀 값 · 기본) + 블록 검산 입력 필요 · 줄 검산(빈 입력값)은 옵션을 못 읽었음
- 가져온 식 SK = M01 짝 없음 — 시트 M01바뀜 · 짝잃음 안 띄움(저장 M01판이 바탕 줄과 달라 늘 떴음)
- 시험 test_793_3b 8

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-05 18:26:07 +09:00
co-authored by Claude Opus 5.5
parent ca72718e26
commit 5a6cccd997
9 changed files with 110 additions and 32 deletions
@@ -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 = "";
@@ -253,22 +253,10 @@ export function openHopyoCheck(opt: CheckOptions): { close: () => void } {
});
}
/** 기초 옵션 묶음 — 이 줄 검산(엑셀 값 · 입력 필요) */
const bindOf = (row: JudgeRow) => (code: string, given: Record<string, unknown>) =>
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]),
});
@@ -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<string, unknown>) => Promise<{ 입력값: Record<string, unknown>; 필요: string[] }>; // prettier-ignore
/** 단산 줄에서 열었을 때만 — [이 줄에 잇기] */
onLink?: (pick: DraftPick) => void;
}
@@ -70,8 +70,24 @@ const fromHopyo = (o: ImportedOptions): Promise<Made> =>
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<string, unknown>,
): Promise<{ 입력값: Record<string, unknown>; 필요: 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<void> {
st.busy = true;
draw();
let bound: Awaited<ReturnType<ImportedOptions["bind"]>>;
let bound: Awaited<ReturnType<typeof bind>>;
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 });
}
@@ -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<string, readonly [string, string]>;
@@ -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"));
+1
View File
@@ -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 {}
+15 -5
View File
@@ -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 {})
# ── 만들기(개인 층) ───────────────────────────────────────────────────
+2 -1
View File
@@ -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.환산),
}
@@ -61,6 +61,8 @@ export interface ImportedDoc {
단위?: string;
바탕?: { 로직?: string } | null;
입력값?: Record<string, unknown>;
/** 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<string, unknown> => ({
...(d.입력값 ?? {}),
환산: d.환산?.계수,
});
/** 입력 필요 중 아직 빈 칸 */
export const stillNeed = (need: string[], values: Record<string, unknown> | 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,
@@ -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]["바꿈"]["전"]