Merge remote-tracking branch 'origin/sub_laptop_3' into main_laptop_1

This commit is contained in:
2026-11-02 22:33:34 +09:00
8 changed files with 203 additions and 10 deletions
+17 -6
View File
@@ -17,6 +17,8 @@ SK 단위 환산(728-2) = 배 = 바탕 기준단위(「100본」) → SK 단위(
화면 셈 길 줄 · 계 = SK 1 단위 몫(`per_unit`).
바탕 재료(747-1) = 별도계상 빈 칸 = 직접계산(M01 기초처럼 재료비에 듦 · 「별도계상」 고른 줄만 밖) ·
일위대가 덮개 줄(`line` · `M02_Labor_Patch.run`)은 일위대가 기본(222-1 · 162-1) 그대로.
보기 넷 한 답(750-1) = 화면 셈 길 답에 `글`(읽는 식) · `중간`(`M02_Sangeun_Views`) · 일위대가 줄
모달 = 문서 `줄모드` true → `line`(일위대가 그 줄 셈과 같은 값).
"""
from __future__ import annotations
@@ -32,6 +34,7 @@ from M02_MasterTemplete import M02_Labor_Fixed as fixed
from M02_MasterTemplete import M02_Labor_Ref as refs
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from M02_MasterTemplete import M02_Sangeun_Doc as sd
from M02_MasterTemplete import M02_Sangeun_Views as views
COSTS = labor.COSTS
Ref = dict[str, Any]
@@ -328,10 +331,12 @@ def run(
trail: tuple = (),
detail: bool = False,
line: bool = False,
seen: bool = True,
) -> dict[str, Any]:
"""SK 셈(저장 안 함) — `run_line` 답 + `{1단위, 배, 정체, 짝잃음, 바탕판}` · 줄마다 `cid` ·
고친 칸 `덮개`(칸 이름) · `원값`. 틀린 문서 · 순환 = `sd.DocError` ·
`detail` = 둘째 기초 세부 펼침(`_detail` · 화면 셈 길만) · `line` = 일위대가 덮개 줄."""
`detail` = 둘째 기초 세부 펼침(`_detail` · 화면 셈 길만) · 그때 `seen` = 보기 덧칸(750-1) ·
`line` = 일위대가 덮개 줄."""
from M02_MasterTemplete.M02_Labor_Expand import Expand
got = resolve(doc, whole, read, trail, given, line)
@@ -345,6 +350,10 @@ def run(
names = {str(i.get("이름")) for i in row.get("입력") or []}
values = {k: v for k, v in {**got["입력값"], **(given or {})}.items() if k in names}
out = labor.run_line(over, key, values, got["켜쌓기율"], got["고름"] or None)
eng = out.get("줄") or []
seen = (
views.text_of(over, key, values, got["고름"], got["정체"], eng) if detail and seen else {}
)
covers = doc.get("덮개") or {}
lines = []
for at, (cid, line) in enumerate(zip(got["정체"], out.get("줄") or [])):
@@ -368,7 +377,7 @@ def run(
]
base = doc.get("바탕") or {}
tag = tag_of(whole, base["로직"]) if "로직" in base else (base.get("참조") or {}).get("키")
return {**out, **head, "1단위": unit, "빠진줄": gone, "꼬리표": tag}
return {**out, **head, **seen, "1단위": unit, "빠진줄": gone, "꼬리표": tag}
def unit(doc: dict, whole: Any, expand: Any, trail: tuple) -> dict[str, Any]:
@@ -495,8 +504,9 @@ def _was(line: dict) -> dict[str, Any]:
return {k: sd.plain(Decimal(str(v))) for k, v in keep.items() if v is not None}
def calc_doc(doc: Any, given: dict | None = None) -> dict[str, Any]:
"""셈 길(`M02_Sangeun_Router`) — 지금 층 문서 · 저장 전 화면 문서 · 틀림 400 · 순환 422."""
def calc_doc(doc: Any, given: dict | None = None, seen: bool = True) -> dict[str, Any]:
"""셈 길(`M02_Sangeun_Router`) — 지금 층 문서 · 저장 전 화면 문서 · 틀림 400 · 순환 422 ·
`seen` = 보기 덧칸(`글` · `중간`) 지음."""
from M01_MasterData import M01_MasterData_Store as m01
whole = m01.loaded()[1]
@@ -504,10 +514,11 @@ def calc_doc(doc: Any, given: dict | None = None) -> dict[str, Any]:
clean = sd.clean(doc, None, whole)
_check_refs(clean, refs.here()) # 저장 전 문서도 위 층만 · 있는 문서 · 순환 422
me = refs.ref_id({"층": refs.here()[0], "주인": refs.here()[1], "키": clean["키"]})
got = run(clean, whole, given, trail=(me,), detail=True)
line = isinstance(doc, dict) and doc.get("줄모드") is True # 일위대가 줄 모달(750-1)
got = run(clean, whole, given, trail=(me,), detail=True, line=line, seen=seen)
except sd.DocError as e:
raise _fail(e) from e
return labor._json({**per_unit(got, clean), "머리": head_of(clean)})
return labor._json({**views.finish(per_unit(got, clean)), "머리": head_of(clean)})
def from_line(
+4 -2
View File
@@ -2,7 +2,8 @@
[단가산출로 저장](715-3) `POST /api/m02/layers/{층}/sangeun/from-line`(층 쓰기 권한).
저장 안 함 · 로그인 사용자 · 층 읽기 권한(`Router_Items._root`) · 화면은 최신 응답만.
몸 = `문서`(저장 전 화면 문서) 또는 `키`(그 층 저장본) + `입력값`(열린입력 · 시험 값).
몸 = `문서`(저장 전 화면 문서) 또는 `키`(그 층 저장본) + `입력값`(열린입력 · 시험 값) ·
`보기` false = 보기 덧칸(`글` · `중간` · 750-1) 안 지음(바탕 원값 셈처럼 금액만 쓸 때).
문서 읽기 · 쓰기 · 새로 · 복사 · 지우기는 양식 길 · 서고 길 그대로(종류 `sangeun`).
⚠ 등록은 `main.py`(로그인만).
"""
@@ -27,6 +28,7 @@ class SangeunCalcBody(BaseModel):
문서: dict[str, Any] | None = None
키: str | None = None
입력값: dict[str, Any] | None = None
보기: bool = True
def _calc(body: SangeunCalcBody) -> dict[str, Any]:
@@ -35,7 +37,7 @@ def _calc(body: SangeunCalcBody) -> dict[str, Any]:
if not sd.KEY.match(str(body.키 or "")):
raise store.StoreError(400, "문서 또는 키(SK-######)")
doc = store.read(sd.KIND, str(body.키))["문서"]
return calc.calc_doc(doc, body.입력값)
return calc.calc_doc(doc, body.입력값, body.보기)
class FromLineBody(BaseModel):
+83
View File
@@ -0,0 +1,83 @@
"""M02 단가산출(SK) 보기 넷 = 셈 답 하나(PLAN 750-1 · 요구 588-1) — 화면 셈 길 답 덧칸.
- `글` = 읽는 식(M01 `/text` 와 같은 꼴 · `master_text.lines` + `master_sheet_line.annotate`) ·
SK 고친 줄(`_Over`) · 같은 입력(자동값 + 입력값) · 같은 고름(`choosing`) 위에서 지음.
줄마다 `cid` = SK 줄 cid(`annotate` 와 같은 차례 — 호표 줄 × `_shares` 비목 · 뒤 덧줄).
금액 · 소계 · 계 = SK `줄` · `기준` 그대로(`finish` — 별도계상 뺌 · 켜쌓기율 · 단위 환산 ÷배 ·
못 셈 줄 까닭) · 식 글(`글` · `이름글`)은 바탕 단위 셈 그대로 + 머리 `나눔`(배 ≠ 1).
- `중간` = 시험 계산 중간값(수 · 글만) — 시험 계산 = {lines: `줄`, sums: `기준`, middle: `중간`}.
계산 자리 = 서버 단독(CLAUDE.md 5장 ③) · 화면은 그리기만.
"""
from __future__ import annotations
from decimal import Decimal
from typing import Any
from M02_MasterTemplete import M02_Labor_Doc as labor
mf = labor.m01.cm.mf
import master_sheet_line as msl # noqa: E402 — scripts 길은 `m01` 이 둠
import master_text as mtx # noqa: E402
_ERRORS = (mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError)
def text_of(over: Any, key: str, values: dict, picks: Any, ids: list, eng: list) -> dict:
"""`{글, 중간}` — `eng` = `run_line` 줄(별도계상 빼기 전 · 차례 맞춤용) · 못 지음 = 까닭."""
row = over.logic(key)
auto = {k: v for k, v in labor.m01.mau.values(over, row).items() if v is not None}
given = {**auto, **labor.m01._dec(values or {})}
try:
with mf.mp.choosing(picks or None):
text = msl.annotate(over, key, given, mtx.lines(over, key, given))
mid = mf.run(over, key, given).get("중간") or {}
except _ERRORS as e:
return {"글": {"ok": False, "reason": str(e) or type(e).__name__}, "중간": {}}
if text.get("ok") and "groups" in text and "결과" not in row:
_tag(row, ids, eng, text)
keep = (Decimal, int, float, str, bool)
return {"글": text, "중간": {k: v for k, v in mid.items() if isinstance(v, keep)}}
def _tag(row: dict, ids: list, eng: list, text: dict) -> None:
"""글 줄마다 `cid` — `annotate` 와 같은 차례(호표 줄 × 몫 비목 → 덧줄)."""
slots = {g["비목"]: iter(g["줄"]) for g in text["groups"]}
items, adds = row.get("호표") or [], row.get("덧줄") or []
for n, line in enumerate(eng):
if n < len(items):
parts = [p for p, _ in mtx._shares(line)] if line.get("비목") else []
else:
parts = [adds[n - len(items)]["비목"]] if n - len(items) < len(adds) else []
for part in parts:
one = next(slots.get(part, iter(())), None)
if one is not None and n < len(ids):
one["cid"] = ids[n]
def finish(ans: dict) -> dict:
"""화면 답(`per_unit` 뒤) — 글 금액 · 소계 · 계 = SK 줄 · 기준 · SK 에 없는 줄(별도계상) 뺌."""
text = ans.get("글") or {}
base = ans.get("기준")
if not text.get("ok") or "groups" not in text or not base:
return ans
sk = {x["cid"]: x for x in ans.get("줄") or [] if x.get("cid")}
groups = []
for g in text["groups"]:
kept = []
for one in g["줄"]:
line = sk.get(one.get("cid"))
if line is None and one.get("cid"):
continue # SK 에서 빠진 줄(별도계상)
if line is not None:
parts = line.get("비목") or {}
money = parts.get(g["비목"]) if len(parts) > 1 else line.get("금액")
one = {**one, "금액": money}
if line.get("까닭"):
one["까닭"] = line["까닭"]
kept.append(one)
if kept:
groups.append({**g, "줄": kept, "소계": base.get(g["비목"])})
times = ans.get("화면배")
head = {"나눔": times} if times not in (None, 1) else {}
return {**ans, "글": {**text, "groups": groups, "계": base.get("계"), **head}}
@@ -632,6 +632,7 @@ export function lineDoc(
덮개: patch?.덮개 ?? {},
뺀줄: patch?.뺀줄 ?? [],
더한줄: patch?.더한줄 ?? [],
줄모드: true, // 셈 = 일위대가 그 줄 셈(별도계상 기본 · 750-1 · 서버 `calc_doc`)
};
if (row.참조) return { ...head, 바탕: { 참조: row.참조 } };
return {
@@ -13,6 +13,7 @@
- `환산근거` = 표 기준배 ↔ 결과단위 배(「10㎡당 → 1㎡당 ÷10」) · 재료 품목 단위 ↔ 줄 단위
(712-2 `master_unit.note`) · 없으면 「」.
줄 차례 맞춤 = `master_text.lines` 와 같은 차례(호표 줄마다 `_shares` 비목 · 뒤 덧줄).
`files` 자리 = 파일 묶음 또는 Master(M02 SK 고친 줄 `_Over` · 750-1 · `_spread` 도 그대로 넘김).
"""
from __future__ import annotations
@@ -151,7 +152,7 @@ def annotate(files: dict, key: str, given: dict, answer: dict) -> dict:
"""`master_text.lines` 답에 줄마다 `KEYS` 덧칸 · 머리 `기준단위` — 못 셈이면 답 그대로."""
if not answer.get("ok") or "groups" not in answer:
return answer
master = mf.Master(files)
master = files if hasattr(files, "logic") else mf.Master(files)
row = master.logic(key)
per = mbs.result_per(row.get("결과단위"))
answer["기준단위"] = _per_text(per) or None
+1 -1
View File
@@ -494,7 +494,7 @@ def lines(files: dict, key: str, given: dict) -> dict:
"""로직 한 줄의 읽는 식 — 비목 묶음마다 줄과 소계 · 돈 아닌 로직은 「결과」 묶음 하나.
호표 줄 계산이 막히면(재료 단가 없음 따위) `row` 구조만으로 이름 식·조각을 짓고
`글`·`금액`은 비움(입력 자체가 없는 경우는 지금처럼 그대로 막힘 — `_env_of` 에서 걸림)."""
master = mf.Master(files)
master = files if hasattr(files, "logic") else mf.Master(files) # Master 째(750-1)
row = master.logic(key)
env = _env_of(master, row, given)
marks = mc.aliases(row, master)
@@ -93,6 +93,7 @@ def test_화면_문서_서버_as_doc_꼴():
"needsRef": ["needsIds", ref],
}
)
assert got["logic"].pop("줄모드") is True and got["ref"].pop("줄모드") is True # 750-1
assert got["logic"] == patch.as_doc({**one, "패치": drop}, whole)
assert got["ref"] == patch.as_doc({**ref, "패치": COVER}, whole)
assert got["bare"]["고름"] == {} and got["bare"]["덮개"] == {}
+94
View File
@@ -0,0 +1,94 @@
"""SK 보기 넷 = 셈 답 하나(PLAN 750-1 · 요구 588-1) — 화면 셈 답 `글`(텍스트 수식) ·
시험 계산(`줄` · `기준` · `중간`) = SK 줄 · 계 · 변형 일곱(기본 · 줄 빼기 · 더하기 · 덧줄 ·
별도계상 · 단위 환산 · 재료 고름) + 참조 바탕 · 일위대가 줄 모달(`줄모드`) = 일위대가 그 줄 셈 ·
`보기` false = 덧칸 없음 · M01 엔진 · storage 안 씀."""
import pytest
from M01_MasterData import M01_MasterData_Store as m01
from M02_MasterTemplete import M02_Labor_Doc as labor
from M02_MasterTemplete import M02_Sangeun_Calc as calc
from M02_MasterTemplete import M02_Sangeun_Doc as sd
from M02_MasterTemplete import M02_Sangeun_Views as views
FORM = "GC000211" # 6-3-1 합판거푸집 — 재료 일곱 · 소모자재 = 재료비 % 덧줄
PLY = "material:합판(내수 12㎜)#0"
DOC = {"키": "SK-000002", "이름": "테스트1", "단위": "㎡", "바탕": {"로직": FORM}}
ADD = {"종류": "인력", "요소": "LB000002", "이름": "보통인부(더함)", "단위": "인", "수량": 0.05}
CASES = {
"기본": {},
"줄 빼기": {"뺀줄": ["material:각재#0"]},
"더하기": {"더한줄": [ADD]},
"덧줄": {"덮개": {"pct:소모자재#0": {"qty": 5}}},
"별도계상": {"자재표시": {PLY: {"별도계상": "별도계상"}}},
"단위 환산": {"단위": "10㎡"},
"재료 고름": {"고름": {PLY: "MT000020"}},
}
def _whole():
return m01.loaded()[1]
def _answer(doc: dict, read=None, line: bool = False) -> dict:
"""`calc_doc` 와 같은 길(층 검사만 뺌)."""
whole = _whole()
clean = sd.clean(doc, None, whole)
got = calc.run(clean, whole, None, read or calc.refs.read_ref, detail=True, line=line)
return labor._json(views.finish(calc.per_unit(got, clean)))
def _same(a: dict) -> None:
text, base = a["글"], a["기준"]
assert text["ok"] is True
money: dict[str, float] = {}
for g in text["groups"]:
assert g["소계"] == pytest.approx(base[g["비목"]])
for one in g["줄"]:
money[one["cid"]] = money.get(one["cid"], 0) + (one["금액"] or 0)
assert set(money) == {x["cid"] for x in a["줄"]} # 글 줄 = SK 줄(cid)
for x in a["줄"]:
assert money[x["cid"]] == pytest.approx(x["금액"] or 0)
assert text["계"] == pytest.approx(base["계"])
assert sum(x["금액"] or 0 for x in a["줄"]) == pytest.approx(base["계"]) # 시험 계산 표 합
assert a["중간"] and all(not isinstance(v, (dict, list)) for v in a["중간"].values())
@pytest.mark.parametrize("name", list(CASES))
def test_four_views_one_answer(name):
a = _answer({**DOC, **CASES[name]})
_same(a)
if name == "별도계상":
assert all(one.get("cid") != PLY for g in a["글"]["groups"] for one in g["줄"])
if name == "단위 환산":
assert a["글"]["나눔"] == pytest.approx(0.1)
plain = _answer(DOC)
assert a["기준"]["계"] == pytest.approx(plain["기준"]["계"] * 10)
if name == "재료 고름":
assert a["기준"]["재료비"] != pytest.approx(_answer(DOC)["기준"]["재료비"])
def test_ref_base_same():
"""참조 바탕(SK 위 SK) — 글도 바탕 SK 고친 줄 위에서."""
parent = {**DOC, **CASES["덧줄"], "뺀줄": ["material:각재#0"]}
ref = {"층": "personal", "주인": None, "키": "SK-000002"}
_same(_answer({**DOC, "키": "SK-000003", "바탕": {"참조": ref}}, read=lambda r: parent))
def test_row_modal_is_hopyo_line():
"""일위대가 줄 모달(`줄모드`) = 일위대가 그 줄 셈(별도계상 기본 · 747 한쪽 흠)."""
whole = _whole()
line = labor._json(labor.drop_apart(labor.run_line(whole, FORM, None), {}))
got = _answer({**DOC, "단위": "식", "줄모드": True}, line=True)
assert got["기준"]["계"] == pytest.approx(line["기준"]["계"])
assert [x["이름"] for x in got["줄"]] == [x["이름"] for x in line["줄"]]
_same(got)
def test_no_views_flag():
"""`보기` false(바탕 원값 셈) = 금액만 · 덧칸 없음 · 금액 같음."""
whole = _whole()
clean = sd.clean(DOC, None, whole)
got = labor._json(calc.run(clean, whole, None, detail=True, seen=False))
assert "글" not in got and "중간" not in got
assert got["기준"]["계"] == pytest.approx(_answer(DOC)["기준"]["계"])