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:
@@ -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;
|
||||
}
|
||||
@@ -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]:
|
||||
|
||||
@@ -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));
|
||||
@@ -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["기준"]["계"]))
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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"],
|
||||
|
||||
Reference in New Issue
Block a user