feat(793-3b): 근거 제안 세 단을 793-3a 길로 이음 · 이 줄에 잇기 고른 것 = 옮길 층 SK · 환산 계수 넣을 때 서버가 다시 바꿈

- 가져온 식 만들 때 SK 가져온.근거후보 = 793-3a 블록 제안 근거후보(San_Basis_Store.block · 못 셈 = 빈 칸)
- 머리 판 후보 = 원문번호 · 옵션 · 품검산 · 입력필요 n · 까닭 [표지 글] · [우리 기초로 바꾸기] 입력 필요 = 바인딩 + 후보 입력필요 + 환산
- 환산 계수 = 가져온 식에서 to-base 다시(바꿈 전 · 후 서버 정본) · 계수 없을 땐 시트 안 엶(셈 길 400 없음)
- 단산 줄 고른 것 층 · 주인 뺌 — 층을 주면 옮기기가 SK 를 못 찾아 고정 줄로 옮겨짐
- 시험 test_793_3b 9(근거후보 채움 · SK 참조 옮기기)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-05 18:41:43 +09:00
co-authored by Claude Opus 5.5
parent 538151177e
commit 9b92d67189
6 changed files with 86 additions and 27 deletions
@@ -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<typeof mountSangeunDetail> | 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<string, unknown>): Promise<EditAnswer | null> {
async function run(
how: string,
body: Record<string, unknown>,
from: ImportedDoc = now(),
): Promise<EditAnswer | null> {
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();
@@ -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);
+9 -2
View File
@@ -4,7 +4,8 @@
`실무자료/산근/<HP>.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:
+3 -1
View File
@@ -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)["이름"]
@@ -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<string, unknown>;
품검산?: 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<string, unknown> => ({
export const stillNeed = (need: string[], values: Record<string, unknown> | 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")}`;
}
@@ -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()["막힘"]