feat(728-2): SK 단위 환산 — 기초 기준단위 제안 · 바꾸면 배 · 계수 칸 · 둘째 기초 수량 처음값 · 돈 아닌 기초 뺌

- 서버 Sangeun_Calc: 배 = 바탕 기준단위 → SK 단위(master_unit.swap) · 성격 다름 = 문서 환산 {계수 | 단위중량} · 없으면 셈 · 저장 막음(까닭 글) · 「식」 · 빈 단위 = 옛 셈 그대로(일위대가 줄 모드)
- 화면 셈 답 줄 · 계 = SK 1 단위 몫(per_unit · 화면배) · 머리 띠 「100본 당」 · 「1 본 당」
- 서버 Sangeun_Doc: 환산 칸 검사(계수 양수 · 단위중량 종별 · 형상)
- B09 Labor_Source: SK 참조 배 = 같은 ratio_of(옛 바탕 앞 수 대신 · 기존 문서 같은 값)
- 화면 Sangeun_Detail: SK 단위 처음 값 = 기초 기준단위(「100본」) · 둘째 기초 수량 처음값 = 바탕 기준단위 ÷ 그 기초 기준단위(swapUnit) · 계 칸 원/SK 단위
- 새 Sangeun_Unit: 근거 글 · 환산 계수 칸 · 단위중량 표(CC000337) 고르기
- Cover_Model: 화면 품 × 화면배 = 문서 품(덮개 · 더한줄 · 세부) · 율 그대로
- Find_Pick: 돈 아닌 기초(결과단위 「원」 아님 21 개) 목록에서 뺌
- 낡은 시험 test_715_sangeun_engine(바탕 원/주 + SK ㎡) · test_727(바탕 원/㎡ + SK ㎥) = 성격 다른 단위가 이제 계수 없으면 막혀 SK 단위를 바탕 단위로 고침(금액 같음)
- 새 시험 test_728_2_sk_unit(100본 → 본 1,538.89 · ㎡ 44,791.32 · 계수 · 단위중량 · 식 · 둘째 기초 · 돈 아닌 기초 · 고침 꼴)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-02 10:52:05 +09:00
co-authored by Claude Opus 5.5
parent de2775d554
commit 08ad680188
12 changed files with 440 additions and 20 deletions
@@ -289,10 +289,13 @@ class Builder:
if bid is None:
why = "단가산출을 못 셈(바탕 없음 · 순환)"
return {"kind": "blank", "name": name, "qty": each, "why": why}
from M02_MasterTemplete.M02_Sangeun_Calc import times_of
from M02_MasterTemplete import M02_Sangeun_Calc as sc
block = self.blocks[bid] # 블록 = 굳힌 줄 결과단위(배) 몫 · M02 참조 줄 = 1 단위
per = float(times_of(whole.logic(doc["바탕"]["로직"])))
try: # 배 = 바탕 기준단위 → SK 단위(728-2)
per = float(sc.ratio_of(doc, whole.logic(doc["바탕"]["로직"]), whole))
except sc.sd.DocError as e:
return {"kind": "blank", "name": name, "qty": each, "why": str(e)}
return {"kind": "daega", "key": bid, "name": block["name"], "unit": block["unit"],
"qty": None if each is None else each / per} # fmt: skip
if refs.kind_of(ref.get("키")) == "promote": # 옛 단가승격 참조 = 일위대가화 = 대가
@@ -10,6 +10,7 @@
* · 부르는 곳 = M02 단가산출 [항목 추가](`Sangeun_Pick`) · 일위대가 [로직 추가]
* (`Labor_Pick` 이 `lib.sangeun` 이면 이 창) — B09 · 불러오기는 옛 창(`Labor_Pick`)
* · (727-1) 기초 = 로직마다 한 줄(`logicHeads` · 옵션은 넣은 뒤 고름 · 옛 창도 같이) · 서버 목록 그대로
* · (728-2) 돈 아닌 기초(결과단위가 「원」 아님 · ㎥/시간 등 21 개) = 목록에서 뺌
* ========================================================================== */
import { createDataTable } from "@ui/ui_template_data_table";
@@ -153,6 +154,7 @@ export function openFindPicker(opt: FindPickOptions): void {
const hit = matchLogic(q, { ...x, 이름: `${x.이름} ${x.규격}` }); // 공용 · 208-1
if (!hit.ok) return [];
const logic = logics.find((y) => y.키 === x.키);
if (logic && !logic.결과단위.trim().startsWith("원")) return []; // 돈 아닌 기초(작업량 · 728-2) 뺌
return [
{
품명: join(x.이름, aliasNote(hit.alias)),
@@ -20,6 +20,7 @@ import { createInputField } from "@ui/ui_template_input";
import { t as L } from "@ui/ui_template_locale";
import { createText } from "@ui/ui_template_text";
import { calcSheetRows } from "../A00_Common/calc_sheet";
import { swapUnit } from "../A00_Common/master_unit_swap";
import { buildCalc } from "../M01_MasterData/M01_MasterData_UI_Logic_Calc";
import {
fetchLogic,
@@ -37,6 +38,7 @@ import { createViewPicker } from "../M01_MasterData/M01_MasterData_UI_LogicLab_V
import type { Layer } from "./M02_MasterTemplete_Api_Fetch";
import { calcSangeun, removedStrip } from "./M02_MasterTemplete_Sangeun_Cover";
import { openSangeunLinePicker } from "./M02_MasterTemplete_Sangeun_Pick";
import { baseUnitOf, buildUnitSwap, type SwapHow } from "./M02_MasterTemplete_Sangeun_Unit";
import {
baseCovers,
dropBase,
@@ -65,8 +67,6 @@ export interface SangeunDetailOptions {
const LIB = { read: "/m02/logic-lib", calc: "/m02/logic-lib" };
const clone = <T>(v: T): T => JSON.parse(JSON.stringify(v)) as T;
/** 결과단위 「원/10㎡」 → SK 단위 「㎡」(1 단위 값 · 배는 서버가 나눔) */
const unitOf = (result: string): string => result.replace(/^원\s*\/\s*[\d.,]*\s*/, "").trim();
export function mountSangeunDetail(
host: HTMLElement,
@@ -105,6 +105,7 @@ export function mountSangeunDetail(
onClick: () =>
openSangeunLinePicker(scope, (line) => {
if ("종류" in line && line.종류 === "로직" && !doc.바탕) return setBase(line.로직);
if ("종류" in line && line.종류 === "로직") return void addBase(line);
doc.더한줄 = [
...(doc.더한줄 ?? []),
line as unknown as NonNullable<SkDoc["더한줄"]>[number],
@@ -113,6 +114,25 @@ export function mountSangeunDetail(
}),
});
addButton.hidden = !edit;
/** 둘째 기초(728-2) — 수량 처음값 = 바탕 기준단위 1 개 = 그 기초 기준단위 몇 개(같은 성격 · 다르면 1) */
async function addBase(line: { 로직: string; 수량: number }): Promise<void> {
let qty = 1;
const have = base ? baseUnitOf(base.logic.결과단위) : "";
try {
const swap = have
? swapUnit(have, baseUnitOf((await fetchLogic(line.로직)).logic.결과단위))
: null;
if (swap?.배 != null && swap.계수 === null) qty = swap.배;
} catch {
/* 못 읽음 = 1 그대로 */
}
if (!alive) return;
doc.더한줄 = [
...(doc.더한줄 ?? []),
{ ...line, 수량: qty } as unknown as NonNullable<SkDoc["더한줄"]>[number],
];
touch();
}
/** 첫 기초 = 바탕(옛 [기본 로직 선택] 동작 · 단위는 `loadBase` 가 기초 결과단위로) */
function setBase(key: string): void {
doc.바탕 = { 로직: key };
@@ -215,9 +235,23 @@ export function mountSangeunDetail(
const baseName = base ? `${base.logic.키} ${base.logic.이름}` : null;
const baseText = baseName ?? doc.바탕?.참조?.키 ?? L("M02_SangeunNoBase");
const revised = revisedBase();
const swap = base
? buildUnitSwap({
result: base.logic.결과단위,
unit: doc.단위 ?? "",
how: doc.환산 as SwapHow | undefined,
edit,
onChange: (how) => {
if (how) doc.환산 = how;
else delete doc.환산;
touch();
},
})
: null;
head.replaceChildren(
field(L("M02_Name"), "이름"),
field(L("M02_SangeunUnit"), "단위"),
...(swap ? [swap] : []),
el("div", {
className: "m02-sk__tags",
attrs: { "data-sk-base": baseKey() ?? "" },
@@ -303,7 +337,7 @@ export function mountSangeunDetail(
? calcSheetRows({
묶음: sheetGroups(rows),
계: ans.기준?.계 ?? null,
sumLabel: `${L("M02_SangeunTotal")} (${row.결과단위 || "원"})`,
sumLabel: `${L("M02_SangeunTotal")} (${doc.단위 && doc.단위 !== "식" ? `원/${doc.단위}` : row.결과단위 || "원"})`,
bundle: true,
inputs: row.입력,
values,
@@ -423,7 +457,7 @@ export function mountSangeunDetail(
});
}
/** `fresh` = 막 넣은 바탕 — 단위가 비거나 「식」이면 기초 결과단위(앞 수 뗌 · 배는 서버가 나눔) */
/** `fresh` = 막 넣은 바탕 — 단위가 비거나 「식」이면 기초 기준단위(「100본」 · 바꾸면 서버가 환산 · 728-2) */
async function loadBase(fresh = false): Promise<void> {
const key = baseKey();
base = null;
@@ -437,7 +471,7 @@ export function mountSangeunDetail(
}
if (!alive) return;
if (fresh && base && (!doc.단위 || doc.단위 === "식")) {
doc.단위 = unitOf(base.logic.결과단위) || doc.단위;
doc.단위 = baseUnitOf(base.logic.결과단위) || doc.단위;
touch(false);
}
draw();
@@ -0,0 +1,103 @@
/* =============================================================================
* M02_MasterTemplete_Sangeun_Unit.ts
* 단가산출(SK) 단위 환산 칸(PLAN 728-2 · 요구서_내역19 3장 5) — 바탕 기준단위(「100본」) → SK 단위 근거 글 ·
* 같은 성격 = 배 자동(글만) · 성격 다름 = 환산 계수 칸 · 부피 ↔ 무게 = 단위중량 표(CC000337) 고르기.
* 칸 보임 · 근거 글 = `swapUnit`(짝 `A00_Common/master_unit_swap.ts`) · 셈 = 서버(`M02_Sangeun_Calc.ratio_of`)
* 문서 칸 `환산` {계수 | 단위중량 {종별, 형상}}(꼴 = 서버 `M02_Sangeun_Doc._swap_how`) · 바꿈 = 캐시 → [저장].
* ========================================================================== */
import { el } from "@ui/ui_template_elements";
import { createInputField, createSelectField } from "@ui/ui_template_input";
import { t as L } from "@ui/ui_template_locale";
import { createText } from "@ui/ui_template_text";
import { swapUnit } from "../A00_Common/master_unit_swap";
import { loadTable } from "../M01_MasterData/M01_MasterData_UI_Logic_Note";
export interface SwapHow {
계수?: number;
단위중량?: { 종별: string; 형상: string };
}
const WEIGHT_TABLE = "CC000337";
/** 결과단위 「원/100본」 → 기준단위 「100본」(SK 단위 처음 값) */
export const baseUnitOf = (result: string): string => result.replace(/^원\s*\/\s*/, "").trim();
/** 단위중량 표 줄 → ㎏/㎥(위 끝 · 서버 `master_unit.unit_weight` 기본과 같음) */
const rhoOf = (row: Record<string, unknown> | undefined): number | null =>
row && Number.isFinite(Number(row.위)) ? Number(row.위) : null;
/** 환산 칸 — 「식」 · 빈 단위 · 바탕 없음 = null(옛 셈) */
export function buildUnitSwap(opt: {
result: string;
unit: string;
how: SwapHow | undefined;
edit: boolean;
onChange: (how: SwapHow | undefined) => void;
}): HTMLElement | null {
const have = baseUnitOf(opt.result);
if (!have || !opt.unit || opt.unit === "식") return null;
const how = opt.how ?? {};
const probe = swapUnit(have, opt.unit, 1000, how.계수);
const root = el("div", { className: "m02-sk__tags", attrs: { "data-sk-swap": "" } });
const note = createText("", "muted", 14);
const say = (rho: number | null): void => {
const got = swapUnit(have, opt.unit, rho, how.계수);
note.textContent = got.근거;
note.dataset.skSwapBlocked = got.배 === null ? "1" : "";
};
if (probe.계수 === "사용자") {
const [, ua] = probe.가;
const [, ub] = probe.나;
const box = createInputField({
label: `${L("M02_SangeunSwapCoef")} — 1 ${ua} = ? ${ub}`,
type: "number",
value: how.계수 != null ? String(how.계수) : "",
min: 0,
compact: true,
size: 28,
});
box.input.dataset.skSwapCoef = "";
box.input.disabled = !opt.edit;
box.input.addEventListener("change", () => {
const v = Number(box.input.value);
opt.onChange(Number.isFinite(v) && v > 0 ? { 계수: v } : undefined);
});
root.append(box.root);
} else if (probe.계수 === "단위중량") {
const pick = how.단위중량;
const value = pick ? `${pick.종별}|${pick.형상}` : "";
const select = createSelectField({
label: `${L("M02_SangeunSwapWeight")} (${WEIGHT_TABLE})`,
options: [{ value: "", text: L("M02_SangeunSwapPick") }],
value,
compact: true,
size: 28,
disabled: !opt.edit,
onChange: (v) => {
const [종별, 형상] = v.split("|");
opt.onChange(v ? { 단위중량: { 종별, 형상 } } : undefined);
},
});
select.select.dataset.skSwapWeight = "";
root.append(select.root);
void loadTable(WEIGHT_TABLE).then((table) => {
const rows = table?.줄 ?? [];
select.setOptions(
[
{ value: "", text: L("M02_SangeunSwapPick") },
...rows.map((r) => ({
value: `${String(r.종별)}|${String(r.형상)}`,
text: `${String(r.종별)} · ${String(r.형상)} (${String(r.위)}㎏/㎥)`,
})),
],
value,
);
say(rhoOf(rows.find((r) => r.종별 === pick?.종별 && r.형상 === pick?.형상)));
});
}
say(null);
if (!root.childElementCount && !note.textContent) return null;
root.append(note);
return root;
}
+68 -3
View File
@@ -11,6 +11,10 @@
(B09 굳힘도 그 값) · 화면 셈 길(`calc_doc`) = 그 줄 덧칸 `펼침`(세부 줄 × 수량) · `뺀세부` ·
`바탕판` ·
`꼬리표`(원문번호) · 답 `꼬리표` = 바탕 원문번호.
SK 단위 환산(728-2) = 배 = 바탕 기준단위(「100본」) → SK 단위(`master_unit.swap` ·
「식」 · 빈 단위 = 앞 수 뗀 바탕 단위 = 옛 셈) ·
다른 성격 = 문서 `환산` {계수 | 단위중량 {종별, 형상}} · 없으면 셈 막음(까닭) ·
화면 셈 길 줄 · 계 = SK 1 단위 몫(`per_unit`).
"""
from __future__ import annotations
@@ -56,6 +60,39 @@ def times_of(row: dict) -> Decimal:
return per[0][0] if len(per) == 1 else Decimal(1)
def base_unit(row: dict) -> tuple[str, str] | None:
"""바탕 기준단위 — 「원/100본」 → (「100본」, 「본」) · 분모 없거나 여럿 = None."""
from M01_MasterData import M01_MasterData_Store_Combo as combo_store
per = combo_store.mu.per(row.get("결과단위"))
if len(per) != 1:
return None
n, u = per[0]
return (u if n == 1 else f"{format(n, 'f')}{u}", u)
def ratio_of(doc: dict, row: dict, whole: Any) -> Decimal:
"""SK 배(728-2) — 바탕 기준단위 1 개 = SK 단위 몇 개 · 1 단위 = 기준 ÷ 배 ·
못 바꿈 = `DocError`."""
from M01_MasterData import M01_MasterData_Store_Combo as combo_store
mu = combo_store.mu
got = base_unit(row)
if got is None:
return Decimal(1)
want = str(doc.get("단위") or "").strip()
want = (
got[1] if want in ("", "식") else want
) # 일위대가 줄 · 「식」 = 옛 셈(앞 수 뗀 바탕 단위)
how = doc.get("환산") or {}
pick = how.get("단위중량") or {}
rho = mu.unit_weight(whole, pick.get("종별"), pick.get("형상")) if pick else None
swap = mu.swap(got[0], want, rho, how.get("계수"))
if swap["배"] is None:
raise sd.DocError(f"단가산출 단위 · {swap['근거']}")
return swap["배"]
def fingerprint(row: dict) -> str:
raw = json.dumps(row, ensure_ascii=False, sort_keys=True, default=str)
return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:16]
@@ -113,7 +150,7 @@ def resolve(
"짝잃음": base["짝잃음"] + got["짝잃음"],
"입력값": {**base["입력값"], **(doc.get("입력값") or {})},
"켜쌓기율": rate,
"배": times_of(base["줄"]),
"배": ratio_of(doc, base["줄"], whole),
"바탕판": fingerprint(base["줄"]),
}
@@ -336,7 +373,34 @@ def unit(doc: dict, whole: Any, expand: Any, trail: tuple) -> dict[str, Any]:
def head_of(doc: dict) -> str:
return f"1 {doc.get('단위')} 당"
"""머리 띠 — 「1 본 당」 · 앞 수 있는 단위 = 「100본 당」."""
unit = str(doc.get("단위") or "")
return f"{unit} 당" if unit[:1].isdigit() else f"1 {unit} 당"
def per_unit(got: dict, doc: dict) -> dict:
"""화면 셈 답 줄 · 계 = SK 1 단위 몫(728-2) — 수량 · 금액 · 비목 ÷배(율 · 단가 그대로) ·
「식」 · 빈 단위(일위대가 줄 모드) = 옛 바탕 몫 그대로."""
times = got.get("배") or Decimal(1)
if times == 1 or str(doc.get("단위") or "").strip() in ("", "식"):
return got
def cut(v: Any) -> Any:
return None if v is None else Decimal(str(v)) / times
def one(x: dict) -> dict:
out = {**x, "금액": cut(x.get("금액"))}
if "율" not in x:
out["수량"] = cut(x.get("수량"))
if x.get("비목"):
out["비목"] = {k: cut(v) for k, v in x["비목"].items()}
if x.get("펼침"):
out["펼침"] = [one(y) for y in x["펼침"]]
return out
lines = [one(x) for x in got.get("줄") or []]
goods = [one(x) for x in got.get("재료") or []]
return {**got, "줄": lines, "재료": goods, "기준": got.get("1단위"), "화면배": times}
# ── 저장 ──────────────────────────────────────────────────────────────
@@ -374,6 +438,7 @@ def clean_doc(doc: Any, here_: refs.Here) -> dict[str, Any]:
if cid in was:
one["원값"] = _was(was[cid])
out["바탕판"] = fingerprint(base["줄"])
ratio_of(out, base["줄"], whole) # 단위 못 바꿈(계수 없음) = 저장 막음(728-2)
_stamp_adds(out, base, whole)
except sd.DocError as e:
raise _fail(e) from e
@@ -413,7 +478,7 @@ def calc_doc(doc: Any, given: dict | None = None) -> dict[str, Any]:
got = run(clean, whole, given, trail=(me,), detail=True)
except sd.DocError as e:
raise _fail(e) from e
return labor._json({**got, "머리": head_of(clean)})
return labor._json({**per_unit(got, clean), "머리": head_of(clean)})
def from_line(line: Any, name: str, unit: str) -> dict[str, Any]:
+21 -1
View File
@@ -10,7 +10,9 @@
별도계상?(재료 · 없음 = 계 안)}
| {종류: 로직, 로직, 입력값, 수량, 덮개?, 뺀줄?, 바탕판?}
| {참조 | 고정, 이름?, 단위?, 수량}],
뺀줄 [cid], 바탕판}
뺀줄 [cid], 바탕판, 환산?}
SK 단위 환산(728-2) = `환산` {계수: 양수}(성격 다름 · 바탕 앞 수 뗀 1 단위 = SK 몇 단위) |
{단위중량: {종별, 형상}}(부피 ↔ 무게 · 표 CC000337) — 셈 = `M02_Sangeun_Calc.ratio_of`.
둘째 기초(727-2) = 더한줄 로직 — 수량 = 그 기초 기준단위 몇 개 · `덮개` {세부 cid: 덮개} ·
`뺀줄` [세부 cid](세부 cid = 그 기초 줄 `kind:키#n` · 화면 열쇠 `add:#k/세부 cid`) ·
`바탕판` = 저장 때 서버가 그 기초 지문.
@@ -282,6 +284,8 @@ def clean(doc: Any, base_row: dict | None = None, whole: Any = None) -> dict[str
out["입력값"], out["열린입력"] = given, opened
if doc.get("켜쌓기율") is not None:
out["켜쌓기율"] = plain(_num(doc["켜쌓기율"], "켜쌓기율"))
if doc.get("환산"):
out["환산"] = _swap_how(doc["환산"])
picks = {}
for cid, mt in (doc.get("고름") or {}).items():
mt = str(mt or "")
@@ -304,6 +308,22 @@ def clean(doc: Any, base_row: dict | None = None, whole: Any = None) -> dict[str
return out
def _swap_how(raw: Any) -> dict[str, Any]:
"""단위 환산 칸(728-2) — 계수(양수) · 단위중량 {종별, 형상} 중 있는 것만."""
if not isinstance(raw, dict):
raise DocError("환산이 묶음 아님")
out: dict[str, Any] = {}
if raw.get("계수") is not None:
coef = _num(raw["계수"], "환산 계수")
if coef <= 0:
raise DocError("환산 계수는 0 보다 커야 함")
out["계수"] = plain(coef)
pick = raw.get("단위중량")
if isinstance(pick, dict) and pick.get("종별") and pick.get("형상"):
out["단위중량"] = {k: str(pick[k]) for k in ("종별", "형상")}
return out
def _marks(raw: Any) -> dict[str, dict[str, str]]:
from M02_MasterTemplete import M02_Labor_Doc as labor
@@ -9,6 +9,7 @@
* 일위대가 줄 → 이름 없는 SK 문서 = `lineDoc`(서버 `M02_Labor_Patch.as_doc` 꼴)
* 둘째 기초(727-2) = 더한줄 로직 줄을 서버 `펼침`(세부 줄)으로 바꿔 놓음 · 열쇠 `add:#k/세부 cid` ·
* 고침 = 그 더한줄의 `덮개` · `뺀줄`(품 = 화면 품 ÷ 수량 · 1 기준단위 몫) · 꼬리표 = 기초 둘 이상일 때만
* SK 단위 환산(728-2) = 서버가 화면 줄을 SK 1 단위 몫으로(`화면배`) — 문서 품 = 화면 품 × 화면배(율 빼고)
* 화면 · fetch · sessionStorage 없음(`CLAUDE.md` 4장).
* ========================================================================== */
@@ -102,6 +103,9 @@ export interface SkAnswer {
머리?: string;
/** 바탕 원문번호(참조 바탕 = 그 키) */
꼬리표?: string | null;
/** 바탕 기준단위 → SK 단위 배(728-2) · 화면 줄을 나눈 배(없으면 1 · 문서 품 = 화면 품 × 화면배) */
배?: number;
화면배?: number;
}
export interface CoverRow {
@@ -132,6 +136,8 @@ export interface CoverRow {
apartPick: boolean;
/** 둘째 기초 세부 줄 — 더한줄 차례 · 세부 cid · 수량(배) · 바탕 줄 · 그 밖 = null */
sub: { k: number; cid: string; scale: number } | null;
/** 화면배(728-2) — 문서 품 = 화면 품 × t · `was` = 문서 몫 */
t: number;
}
/* ── 순수 셈 ── */
@@ -206,6 +212,7 @@ const near = (a: number | null | undefined, b: number | null | undefined): boole
/** 셈 답 → 표 줄(엔진 줄 + 기준 밖 재료) */
export function rowsOf(ans: SkAnswer, bare: SkAnswer | null, doc: SkDoc): CoverRow[] {
const was = baseValues(bare);
const t = ans.화면배 ?? 1;
const rows: CoverRow[] = [];
const row = (
cid: string,
@@ -227,7 +234,12 @@ export function rowsOf(ans: SkAnswer, bare: SkAnswer | null, doc: SkDoc): CoverR
const cover = at ? owner?.덮개?.[at.cid] : added === null ? doc.덮개?.[cid] : undefined;
const pct = (at?.cid ?? cid).startsWith("pct:");
const keys = Object.keys(parts);
const base = at ? subWas(extra.바탕값) : added === null ? (was.get(cid) ?? null) : null;
const shown = added === null && !at ? (was.get(cid) ?? null) : null;
const base = at
? subWas(extra.바탕값)
: shown && !pct && shown.qty != null
? { ...shown, qty: shown.qty * t }
: shown;
const old = cover?.원값;
return {
cid,
@@ -255,6 +267,7 @@ export function rowsOf(ans: SkAnswer, bare: SkAnswer | null, doc: SkDoc): CoverR
priceEdit: !pct && (added === null || ["인력", "재료", "기계"].includes(String(one?.종류))),
apartPick: one?.종류 === "재료",
sub: at ? { ...at, scale: Number(owner?.수량 ?? 1) } : null,
t: pct ? 1 : t,
};
};
for (const l of flatLines(ans)) rows.push(row(l.cid, l, partsOf(l), l));
@@ -301,14 +314,15 @@ export function setLineCover(doc: SkDoc, r: CoverRow, c: LineCover | null, opene
if (r.added !== null) {
const one = doc.더한줄?.[r.added];
if (!one) return;
if (c?.qty != null) one.수량 = c.qty;
if (c?.qty != null) one.수량 = c.qty * r.t;
if (price == null) delete one.단가;
else one.단가 = price;
return;
}
const cover: SkCover = {};
// 세부 줄 품 = 화면 품 ÷ 더한줄 수량(1 기준단위 몫 · 수량 0 = 그대로)
const qty = r.sub && c?.qty != null && r.sub.scale ? c.qty / r.sub.scale : c?.qty;
const raw = c?.qty == null ? c?.qty : c.qty * r.t;
const qty = r.sub && raw != null && r.sub.scale ? raw / r.sub.scale : raw;
const open = opened && !r.sub; // 열린입력 = 바탕 입력 — 세부 줄은 품 그대로
// ponytail: 열린입력 배율 = 고친 품 ÷ 바탕 품(바탕 품 0 · 없음 = 품 고침 못 함)
if (qty != null && open && r.was?.qty) cover.배율 = qty / r.was.qty;
@@ -445,6 +459,7 @@ export function hoView(
});
const lineBy = new Map(flatLines(ans).map((l) => [l.cid, l]));
const tags = tagsOf(ans, doc);
const t = ans.화면배 ?? 1;
const single = (l?: SkLine): string | undefined => {
const parts = Object.entries(l?.비목 ?? {}).filter(([, v]) => v);
return parts.length === 1 ? parts[0][0] : undefined;
@@ -479,7 +494,7 @@ export function hoView(
요소: typeof a?.요소 === "string" ? a.요소 : "",
이름: name,
단위: l?.단위 ?? String(a?.단위 ?? ""),
수량: String(a?.수량 ?? l?.율 ?? l?.수량 ?? ""),
수량: String(a ? a.수량 / t : (l?.율 ?? l?.수량 ?? "")),
비목: typeof a?.비목 === "string" ? a.비목 : single(l),
};
ho.push([cid, { ...(a ? made : ((known.get(cid) as HoItem) ?? made)), ...tag }]);
@@ -490,14 +505,15 @@ export function hoView(
const hit = ADD_RE.exec(cid);
const a = hit ? doc.더한줄?.[Number(hit[1])] : undefined;
const part = PART_OF[String(a?.비목 ?? "")];
if (a) covers[i] = { qty: a.수량, ...(a.단가 != null && part ? { [part]: a.단가 } : {}) };
if (a) covers[i] = { qty: a.수량 / t, ...(a.단가 != null && part ? { [part]: a.단가 } : {}) };
const at = subOf(cid);
const owner = at ? doc.더한줄?.[at.k] : undefined;
const c = hit ? undefined : at ? owner?.덮개?.[at.cid] : doc.덮개?.[cid];
if (!c) return;
const l = lineBy.get(cid);
const out: LineCover = {};
if (c.qty != null) out.qty = at ? c.qty * Number(owner?.수량 ?? 1) : c.qty;
const per = (at?.cid ?? cid).startsWith("pct:") ? 1 : t; // 율 = 그대로
if (c.qty != null) out.qty = (at ? c.qty * Number(owner?.수량 ?? 1) : c.qty) / per;
else if (c.배율 != null && l) out.qty = l.율 ?? l.수량 ?? undefined;
for (const k of PARTS) if (c[k] != null) out[k] = c[k];
covers[i] = out;
@@ -0,0 +1,44 @@
/* PLAN 728-2 헬퍼 — SK 고침 꼴(`logic/M02_Sangeun_Cover_Model.ts`)이 화면배(SK 1 단위 몫)를 문서 몫으로 되돌리는지.
* 짝 = `test_728_2_sk_unit.py`. 결과 = 표준출력 JSON.
*/
const path = require("path");
const { makeLoader } = require("./helper_ts_stub_loader.cjs");
const ROOT = path.join(__dirname, "..", "..");
const { load } = makeLoader({ root: ROOT });
const m = load("M02_MasterTemplete", "logic", "M02_Sangeun_Cover_Model.ts");
// GF000287 단목베기 SK 단위 본 — 서버 화면 답 = 100본 몫 ÷100(화면배 100)
const line = (cid, 이름, 수량, 금액) => ({
cid,
이름,
단위: "인",
수량,
단가: 금액 / 수량,
금액,
비목: { 노무비: 금액 },
});
const ans = {
기준: { 계: 1538.894 },
줄: [line("labor:LB000101#0", "벌목부", 0.002, 506.486), line("add:#0", "보통인부", 0.5, 86034)],
정체: ["labor:LB000101#0", "add:#0"],
짝잃음: [],
바탕판: "",
배: 100,
화면배: 100,
};
const bare = { ...ans, 줄: [ans.줄[0]], 정체: ["labor:LB000101#0"] };
const doc = {
단위: "본",
바탕: { 로직: "GF000287" },
더한줄: [{ 종류: "인력", 요소: "LB000002", 수량: 50 }],
};
const rows = m.rowsOf(ans, bare, doc);
const out = { was: rows[0].was, t: rows.map((r) => r.t) };
m.setLineCover(doc, rows[0], { qty: 0.003 }, false); // 화면 0.003 = 문서 0.3(100본 몫)
m.setLineCover(doc, rows[1], { qty: 0.6 }, false); // 더한 인력 화면 0.6 = 문서 60
out.cover = doc.덮개;
out.added = doc.더한줄[0].수량;
const view = m.hoView(ans, bare, null, doc);
out.shown = view.covers;
process.stdout.write(JSON.stringify(out));
+2 -2
View File
@@ -46,7 +46,7 @@ def test_price_cover_is_hand_price_and_pct_follows():
def test_bundle_cover_on_logic_line_partial():
bare = labor.run_line(_whole(), SUB, {})
sub = bare["줄"][2]
got = _run(바탕={"로직": SUB}, 덮개={"logic:GC000275#0": {"expense": 1000}})
got = _run(바탕={"로직": SUB}, 단위="주", 덮개={"logic:GC000275#0": {"expense": 1000}})
line = got["줄"][2]
qty = Decimal(str(sub["수량"]))
assert line["비목"]["경비"] == pytest.approx(float(qty * 1000))
@@ -57,7 +57,7 @@ def test_bundle_cover_on_logic_line_partial():
def test_untouched_lines_same_as_engine():
bare = labor.run_line(_whole(), SUB, {})
got = _run(바탕={"로직": SUB})
got = _run(바탕={"로직": SUB}, 단위="주") # 바탕 단위(728-2 — 성격 다르면 계수 칸)
assert got["기준"]["계"] == pytest.approx(float(bare["기준"]["계"]))
+7 -1
View File
@@ -189,7 +189,13 @@ def test_727_3_옵션_저장_일위대가_그_옵션(world): # noqa: F811
assert float(up["기준"]["계"]) == pytest.approx(_m01(GF, rock), rel=1e-9)
assert _m01(GF, rock) > _m01(GF, {"지역": "전국평균", "보정작업": "해당 없음"})
# 둘째 기초 입력(바탕에 없는 이름)도 SK 입력값에 남고 그 옵션으로 셈
two = _sk(world["personal"], 바탕={"로직": PCT}, 더한줄=[{**SECOND, "로직": GF}], 입력값=rock)
two = _sk( # 단위 = 바탕 단위(728-2 — 성격 다르면 계수 칸)
world["personal"],
바탕={"로직": PCT},
단위="㎡",
더한줄=[{**SECOND, "로직": GF}],
입력값=rock,
)
assert two["입력값"] == rock
got = _run(world["personal"], two)
assert _line(got, "add:#0")["금액"] == pytest.approx(_m01(GF, rock), rel=1e-9)
+124
View File
@@ -0,0 +1,124 @@
"""728-2 지킴 — SK 단위 환산: 바탕 기준단위 → SK 단위 배 · 화면 줄 · 계 = SK 1 단위 몫 ·
계수 없으면 셈 막음 · 「식」 = 옛 셈 · 둘째 기초 수량 처음값 · 돈 아닌 기초 고르기 창에서 뺌 ·
고침 꼴 화면배 되돌림."""
from __future__ import annotations
import json
import subprocess
import sys
from decimal import Decimal
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(ROOT))
sys.path.insert(0, str(ROOT / "resources/master_data/scripts"))
import master_unit as mu # noqa: E402
from M01_MasterData import M01_MasterData_Store as m01 # noqa: E402
from M02_MasterTemplete import M02_Sangeun_Calc as sc # noqa: E402
from M02_MasterTemplete import M02_Sangeun_Doc as sd # noqa: E402
@pytest.fixture(scope="module")
def whole():
return m01.loaded()[1]
def _doc(base, unit, **more):
return {"키": "SK-999999", "이름": "sub3임시", "단위": unit, "바탕": {"로직": base}, **more}
def _shown(whole, doc):
clean = sd.clean(doc, None, whole)
return sc.per_unit(sc.run(clean, whole), clean)
def test_단목베기_100본_제안_본으로_바꾸면_1본_계(whole):
full = _shown(whole, _doc("GF000287", "100본"))
assert full["배"] == 1 and round(full["기준"]["계"], 2) == Decimal("153889.40")
assert sc.head_of({"단위": "100본"}) == "100본 당"
one = _shown(whole, _doc("GF000287", "본"))
assert one["배"] == 100 and round(one["기준"]["계"], 2) == Decimal("1538.89")
assert sc.head_of({"단위": "본"}) == "1 본 당"
pairs = [(x["수량"], y["수량"]) for x, y in zip(full["줄"], one["줄"], strict=True)]
assert all(b == a / 100 for a, b in pairs) and one["화면배"] == 100 # 세부 품 ÷100
def test_사석_수상고르기_10제곱미터_에서_1제곱미터(whole):
one = _shown(whole, _doc("GC000421", "㎡"))
assert round(one["기준"]["계"], 2) == Decimal("44791.32")
def test_성격_다르면_계수_칸_없으면_셈_막음(whole):
with pytest.raises(sd.DocError, match="환산 계수"):
_shown(whole, _doc("GF000099", "m"))
base = _shown(whole, _doc("GF000099", "㎥"))["기준"]["계"]
two = _shown(whole, _doc("GF000099", "m", 환산={"계수": 2}))
assert two["배"] == 2 and two["기준"]["계"] == base / 2
with pytest.raises(sd.DocError, match="단위중량"):
_shown(whole, _doc("GF000099", "톤"))
rock = _shown(
whole, _doc("GF000099", "톤", 환산={"단위중량": {"종별": "암석", "형상": "화강암"}})
)
assert rock["배"] == Decimal("2.7") # 1 ㎥ = 2.7 톤(2,700 ㎏/㎥)
with pytest.raises(sd.DocError, match="0 보다"):
sd.clean(_doc("GF000099", "m", 환산={"계수": 0}), None, whole)
def test_식_빈_단위는_옛_셈(whole):
old = _shown(whole, _doc("GF000287", "식"))
assert old["배"] == 100 and round(old["1단위"]["계"], 2) == Decimal("1538.89")
assert "화면배" not in old and old["기준"]["계"] == Decimal("153889.4") # 화면 = 바탕 몫 그대로
def test_둘째_기초_수량_처음값_배(whole):
"""바탕 10㎡(GC000421) + 둘째 기초 100㎡(GF000041) — 처음값 0.1 = 바탕 기준단위 몫."""
qty = mu.swap("10㎡", "100㎡")["배"]
assert qty == Decimal("0.1")
alone = sc.run(sd.clean(_doc("GF000041", "100㎡"), None, whole), whole)["1단위"]["계"]
one = {"종류": "로직", "로직": "GF000041", "입력값": {}, "수량": float(qty)}
got = sc.run(sd.clean(_doc("GC000421", "㎡", 더한줄=[one]), None, whole), whole)
bare = sc.run(sd.clean(_doc("GC000421", "㎡"), None, whole), whole)
added = got["1단위"]["계"] - bare["1단위"]["계"]
assert abs(added - alone * qty / 10) < Decimal("0.01") # ㎡ 몫 = 100㎡ 값 ÷100
src = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_Sangeun_Detail.ts").read_text(
encoding="utf-8"
)
assert "swapUnit(" in src and "addBase(" in src and "baseUnitOf(" in src
def test_돈_아닌_기초_고르기_창에서_뺌(whole):
money = [
r
for tid, d in whole.index.items()
if tid in ("GC", "GF", "QC", "QF")
for r in d.values()
if isinstance(r, dict)
and "결과단위" in r
and not str(r["결과단위"]).strip().startswith("원")
]
assert len(money) == 21
src = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_Find_Pick.ts").read_text(encoding="utf-8")
assert '!logic.결과단위.trim().startsWith("원")' in src
def test_고침_꼴_화면배_되돌림():
proc = subprocess.run( # noqa: S603 — 고정 실행 파일
["node", str(HERE / "helper_728_2_cover_unit.cjs")],
capture_output=True,
text=True,
encoding="utf-8",
timeout=60,
)
assert proc.returncode == 0, proc.stderr
out = json.loads(proc.stdout)
assert out["was"]["qty"] == pytest.approx(0.2) # 바탕 값 = 문서 몫(100본)
assert out["cover"]["labor:LB000101#0"]["qty"] == pytest.approx(0.3)
assert out["added"] == pytest.approx(60)
assert out["shown"]["0"]["qty"] == pytest.approx(0.003) # 다시 그리면 화면 몫
assert out["shown"]["1"]["qty"] == pytest.approx(0.6)
+3
View File
@@ -203,6 +203,9 @@ export const ui_locales_m2 = {
M02_SangeunRemoved: ["뺀 줄", "Removed rows"],
M02_SangeunUnit: ["단위(1 단위 값)", "Unit (value per 1)"],
M02_SangeunNeed: ["비우면 저장 안 됨", "Required"],
M02_SangeunSwapCoef: ["환산 계수", "Conversion factor"],
M02_SangeunSwapWeight: ["단위중량", "Unit weight"],
M02_SangeunSwapPick: ["고르세요", "Choose"],
M02_SangeunAddLine: ["항목 추가", "Add item"],
M02_SangeunDrop: ["빼기", "Remove"],
M02_SangeunDropBase: ["기초 빼기", "Remove base"],