Merge remote-tracking branch 'origin/sub_laptop_2' into main_laptop_1
This commit is contained in:
@@ -35,6 +35,7 @@ from typing import Any
|
||||
|
||||
from M02_MasterTemplete import M02_Labor_Fixed as fixed
|
||||
from M02_MasterTemplete import M02_Labor_Ref as refs
|
||||
from M02_MasterTemplete import M02_Sangeun_Const as const
|
||||
from M02_MasterTemplete import M02_Sangeun_Doc as sd
|
||||
|
||||
ADD = re.compile(r"^add:#(\d+)$")
|
||||
@@ -72,6 +73,7 @@ def _read(ref: refs.Ref, read: Read, trail: tuple) -> tuple[dict, tuple]:
|
||||
got = read(ref)
|
||||
if not isinstance(got, dict):
|
||||
raise sd.DocError(f"없는 참조 「{ref.get('키')}」")
|
||||
const.no_flat(got) # 상수 · 표값 덮개 SK = 안 펼침(790-2)
|
||||
return got, (*trail, rid)
|
||||
|
||||
|
||||
@@ -256,6 +258,7 @@ def _cover_add(add: dict, cover: dict) -> dict:
|
||||
def compose(under: dict, over: dict, whole: Any) -> dict:
|
||||
"""펼친 바탕 SK `under` 위에 `over`(덮개 · 뺀줄 · 고름 · 자재표시 · 더한줄 · 입력값) → 한 벌 —
|
||||
위 줄 정체를 아래 바탕 정체로 옮김(`patched` 의 `정체` 짝) · 짝 잃은 위 고침은 버림(값 없음)."""
|
||||
const.no_flat(over) # 위 상수 · 표값 덮개 자리 = 아래 정체로 못 옮김(790-2)
|
||||
base = under.get("바탕")
|
||||
row = whole.logic(base["로직"]) if (base or {}).get("로직") else sd.empty_row(under)
|
||||
got = sd.patched(row, under)
|
||||
@@ -522,8 +525,8 @@ def reset(kind: str, doc: dict) -> tuple[dict, int]:
|
||||
doc = flat_doc(kind, doc)
|
||||
if kind == "sangeun":
|
||||
adds, n = _reset_adds(doc.get("더한줄") or [])
|
||||
n += len(doc.get("덮개") or {}) + len(doc.get("뺀줄") or [])
|
||||
return {**doc, "덮개": {}, "뺀줄": [], "더한줄": adds}, n
|
||||
n += const.covers_n(doc) # 790-2 상수 · 표값 덮개도 걷음(까닭 같이)
|
||||
return {**const.restore(doc), "더한줄": adds}, n
|
||||
return _reset_sc(doc)
|
||||
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ from M02_MasterTemplete import M02_Labor_Doc as labor
|
||||
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_Const as const, M02_Sangeun_Doc as sd
|
||||
from M02_MasterTemplete import M02_Sangeun_Views as views
|
||||
|
||||
COSTS = labor.COSTS
|
||||
@@ -536,7 +536,7 @@ def clean_doc(doc: Any, here_: refs.Here) -> dict[str, Any]:
|
||||
for cid, one in out["덮개"].items():
|
||||
if cid in was:
|
||||
one["원값"] = _was(was[cid])
|
||||
out["바탕판"] = fingerprint(base["줄"])
|
||||
out |= const.stamp(base["줄"]) # 바탕판 · M01판(790-2)
|
||||
ratio_of(out, base["줄"], whole) # 단위 못 바꿈(계수 없음) = 저장 막음(728-2)
|
||||
_stamp_adds(out, base, whole)
|
||||
except sd.DocError as e:
|
||||
@@ -579,7 +579,7 @@ def calc_doc(doc: Any, given: dict | None = None, seen: bool = True) -> dict[str
|
||||
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({**views.finish(per_unit(got, clean)), "머리": head_of(clean)})
|
||||
return const.answer(got, clean, doc, given, (me,)) # 옛 답 + 원래 · 시트(790-2)
|
||||
|
||||
|
||||
def from_line(
|
||||
|
||||
@@ -0,0 +1,404 @@
|
||||
"""M02 단가산출(SK) 숫자 넓힘(PLAN 790-2 · 요구서_652 · _분석_650-H3 E2) — 새 덮개 갈래 · 시트 답.
|
||||
|
||||
짝 = `logic/M02_Sangeun_Const_Model.ts`(캐시 고치기 · M01 원래로 · 까닭 · 더한줄 열쇠 옮김 ·
|
||||
같은 꼴) · 거울 시험 `resources/tester/test_790_2_sangeun_const.py`.
|
||||
|
||||
문서 덧칸(없으면 안 둠 — 옛 SK 그대로 · 지문 그대로):
|
||||
상수덮개 {자리: {n: 값}} 자리 식의 n 번째 숫자 상수(0 부터 · 엔진 파서 차례 · 찾기 머리 · 키 ·
|
||||
따옴표 글 안 셈) → 값 · 식 구조 그대로(옵션 바꾸면 나머지 따라감)
|
||||
표값덮개 {자리: {n: 값}} 자리 식의 n 번째 표 찾기 값(`찾기(…).칸`) → 값 · 그 줄만 표 연결 끊김
|
||||
글줄 [{자리, 글}] 셈 없는 글 줄(근거 · ※ 조건) · 자리 뒤에 찍힘("" = 맨 위)
|
||||
까닭 {열쇠: 글} 덮개 · 더한줄 · 뺀줄 · 새 덮개마다(예정가격작성기준 제2조 ②)
|
||||
M01판 {자리: 지문} 저장 때 서버가 바탕 줄마다 적음(`stamp` · `sd.clean` 은 버림 =
|
||||
바탕판처럼) · 셈 때 받은 값과 지금 바탕이 다르면 「M01바뀜」
|
||||
자리 = 바탕 줄 cid(호표 · 덧줄 `kind:키#n`) · 중간 줄 `mid:이름` · 열쇠 = 자리 · `add:#k` ·
|
||||
`add:#k/세부 cid` · 식 칸 = 중간 「식」 · 호표 「수량」 · 덧줄 「식」.
|
||||
셈 = `M02_Sangeun_Doc.patched` 첫머리 `formulas`(바탕 줄 식을 바꾼 사본) → 모든 셈 길(화면 · 저장
|
||||
원값 · B09 · 펼침 겹침) 한 벌 · 펼침(`M02_Library_Flat._read`) = 새 덮개 SK 는 안 펼침
|
||||
(옛 참조 그대로) · [초기화](`M02_Library_Flat.reset`) = `restore` 전체.
|
||||
화면 셈 답(`answer`) 덧칸 `원래` {노무비, 재료비, 경비, 계}(1 단위) + `시트` {자리, 글줄, 짝잃음} ·
|
||||
M01 원래 = 같은 옵션 · 덮개 · 뺀줄 · 더한줄 · 새 덮개 없는 셈.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import functools
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
from M02_MasterTemplete import M02_Sangeun_Doc as sd
|
||||
|
||||
NEW = ("상수덮개", "표값덮개", "글줄", "까닭")
|
||||
_SPOT = re.compile(r"^(mid:.+|(labor|material|machine|logic|pct):.*#\d+)$")
|
||||
_KEY = re.compile(r"^(mid:.+|(add:#\d+/)?(labor|material|machine|logic|pct):.*#\d+|add:#\d+)$")
|
||||
_ADD = re.compile(r"^add:#(\d+)(/.*)?$")
|
||||
FIELD = {"중간": "식", "호표": "수량", "덧줄": "식"}
|
||||
LONG = 500 # 글 · 까닭 길이 상한
|
||||
MANY = 200 # 글줄 수 상한
|
||||
|
||||
|
||||
# ── 식 속 자리 ────────────────────────────────────────────────────────
|
||||
@functools.cache
|
||||
def _parser() -> type:
|
||||
"""엔진 파서(`master_formula._Parser`) + 숫자 · 표 찾기 글자 자리 기록(엔진과 같은 차례)."""
|
||||
from M02_MasterTemplete import M02_Labor_Doc as labor
|
||||
|
||||
class Spots(labor.m01.cm.mf._Parser):
|
||||
def __init__(self, text: str) -> None:
|
||||
super().__init__(text)
|
||||
self.spots: list[tuple[str, int, int]] = []
|
||||
|
||||
def primary(self): # noqa: ANN202
|
||||
self.ws()
|
||||
at = self.i
|
||||
node = super().primary()
|
||||
if node[0] == "num":
|
||||
self.spots.append(("num", at, self.i))
|
||||
return node
|
||||
|
||||
def unary(self): # noqa: ANN202
|
||||
self.ws()
|
||||
at = self.i
|
||||
node = super().unary()
|
||||
inner = node
|
||||
while inner[0] == "field":
|
||||
inner = inner[1]
|
||||
if node[0] == "field" and inner[0] == "find":
|
||||
self.spots.append(("find", at, self.i))
|
||||
return node
|
||||
|
||||
return Spots
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=4096)
|
||||
def scan(text: str) -> dict[str, list[tuple[int, int]]]:
|
||||
"""식 글 → {num: [(처음,끝)], find: [(처음, 끝)]}(왼쪽부터) · 못 읽는 식 = 빈 목록."""
|
||||
p = _parser()(text)
|
||||
try:
|
||||
p.parse()
|
||||
except Exception: # noqa: BLE001 — 엔진 FormulaError · 깊은 괄호
|
||||
return {"num": [], "find": []}
|
||||
spots = [(t, a, a + len(text[a:b].rstrip())) for t, a, b in p.spots] # 뒤 빈칸(`.` 엿봄) 뗌
|
||||
return {k: sorted((a, b) for t, a, b in spots if t == k) for k in ("num", "find")}
|
||||
|
||||
|
||||
def _lit(value: Any) -> str:
|
||||
got = sd.text(value)
|
||||
return f"({got})" if got.startswith("-") else got
|
||||
|
||||
|
||||
def swap(text: str, nums: dict, finds: dict) -> str:
|
||||
"""식 글의 n 번째 숫자 · 표 찾기를 값으로(원래 식 차례 · 바꾼 표 찾기 안 숫자는 버림)."""
|
||||
spots = scan(text)
|
||||
cuts = [(*spots["find"][int(n)], v) for n, v in finds.items() if int(n) < len(spots["find"])]
|
||||
for n, v in nums.items():
|
||||
if int(n) < len(spots["num"]):
|
||||
a, b = spots["num"][int(n)]
|
||||
if not any(x <= a and b <= y for x, y, _ in cuts):
|
||||
cuts.append((a, b, v))
|
||||
for a, b, v in sorted(cuts, reverse=True):
|
||||
text = text[:a] + _lit(v) + text[b:]
|
||||
return text
|
||||
|
||||
|
||||
def places(row: dict) -> dict[str, tuple[str, int]]:
|
||||
"""자리 → (갈래, 차례) — 중간 `mid:이름` · 호표 · 덧줄 cid."""
|
||||
out = {f"mid:{s.get('이름')}": ("중간", at) for at, s in enumerate(row.get("중간") or [])}
|
||||
n = len(row.get("호표") or [])
|
||||
for at, cid in enumerate(sd.line_ids(row)):
|
||||
out[cid] = ("호표", at) if at < n else ("덧줄", at - n)
|
||||
return out
|
||||
|
||||
|
||||
def formulas(row: dict, doc: dict) -> dict:
|
||||
"""바탕 줄 + 상수 · 표값 덮개 → 식 바꾼 사본(덮개 없음 = 그 줄 그대로 · 순수)."""
|
||||
nums, finds = doc.get("상수덮개") or {}, doc.get("표값덮개") or {}
|
||||
if not nums and not finds:
|
||||
return row
|
||||
out = {**row, **{k: list(row.get(k) or []) for k in FIELD}}
|
||||
for spot, (part, at) in places(row).items():
|
||||
if spot in nums or spot in finds:
|
||||
line = out[part][at] = dict(out[part][at])
|
||||
field = FIELD[part]
|
||||
line[field] = swap(str(line.get(field)), nums.get(spot) or {}, finds.get(spot) or {})
|
||||
return out
|
||||
|
||||
|
||||
def lost(row: dict, doc: dict) -> list[dict]:
|
||||
"""짝 잃은 새 덮개 — 바탕에 없는 자리 · 없는 n 번째."""
|
||||
where, out = places(row), []
|
||||
for name, kind in (("상수덮개", "num"), ("표값덮개", "find")):
|
||||
for spot, cover in (doc.get(name) or {}).items():
|
||||
if spot not in where:
|
||||
out.append({"cid": spot, "까닭": f"{name} — 바탕에 없는 줄"})
|
||||
continue
|
||||
part, at = where[spot]
|
||||
have = len(scan(str((row[part][at] or {}).get(FIELD[part])))[kind])
|
||||
out += [
|
||||
{"cid": spot, "까닭": f"{name} — {int(n) + 1} 번째 없음"}
|
||||
for n in cover
|
||||
if int(n) >= have
|
||||
]
|
||||
return out
|
||||
|
||||
|
||||
def no_flat(doc: Any) -> None:
|
||||
"""펼침 막음 — 상수 · 표값 덮개 SK(펼친 줄에 못 옮김 · 옛 참조 그대로 = 값 그대로)."""
|
||||
if isinstance(doc, dict) and (doc.get("상수덮개") or doc.get("표값덮개")):
|
||||
raise sd.DocError("상수 · 표값 덮개 단가산출은 펼치지 않음(참조 그대로)")
|
||||
|
||||
|
||||
def covers_n(doc: dict) -> int:
|
||||
"""[초기화] 걷힐 고침 수 — 덮개 · 뺀줄 · 상수 · 표값 덮개 칸."""
|
||||
keys = ("덮개", "뺀줄", "상수덮개", "표값덮개")
|
||||
return sum(len(doc.get(k) or ()) for k in keys)
|
||||
|
||||
|
||||
# ── 문서 꼴 ──────────────────────────────────────────────────────────
|
||||
def _key(raw: Any, where: str, pat: re.Pattern = _SPOT) -> str:
|
||||
if not isinstance(raw, str) or not pat.match(raw):
|
||||
raise sd.DocError(f"{where} · 자리 꼴 아님 「{raw}」")
|
||||
return raw
|
||||
|
||||
|
||||
def _words(raw: Any, where: str) -> str:
|
||||
got = str(raw or "").strip()
|
||||
if len(got) > LONG:
|
||||
raise sd.DocError(f"{where} · 글이 {LONG} 자를 넘음")
|
||||
return got
|
||||
|
||||
|
||||
def _covers(raw: Any, name: str) -> dict:
|
||||
if raw in (None, {}):
|
||||
return {}
|
||||
if not isinstance(raw, dict):
|
||||
raise sd.DocError(f"{name} · 묶음 아님")
|
||||
out = {}
|
||||
for spot, one in raw.items():
|
||||
where = f"{name} 「{spot}」"
|
||||
if not isinstance(one, dict):
|
||||
raise sd.DocError(f"{where} · {{n: 값}} 아님")
|
||||
got = {}
|
||||
for n, v in one.items():
|
||||
if not re.fullmatch(r"\d{1,3}", str(n)):
|
||||
raise sd.DocError(f"{where} · n 은 0 부터 수 「{n}」")
|
||||
got[str(int(n))] = sd.plain(sd._num(v, f"{where} {n}"))
|
||||
if got:
|
||||
out[_key(spot, name)] = got
|
||||
return out
|
||||
|
||||
|
||||
def clean_more(raw: Any, out: dict) -> dict:
|
||||
"""`sd.clean` 끝 — 새 덮개 칸 검사 · 빈 칸은 안 둠(`M01판` · 원값은 서버 몫이라 버림)."""
|
||||
raw = raw if isinstance(raw, dict) else {}
|
||||
for name in ("상수덮개", "표값덮개"):
|
||||
if got := _covers(raw.get(name), name):
|
||||
out[name] = got
|
||||
texts = raw.get("글줄") or []
|
||||
if not isinstance(texts, list) or len(texts) > MANY:
|
||||
raise sd.DocError(f"글줄 · {MANY} 줄까지 목록")
|
||||
lines = []
|
||||
for at, one in enumerate(texts, 1):
|
||||
one = one if isinstance(one, dict) else {}
|
||||
spot = str(one.get("자리") or "")
|
||||
words = _words(one.get("글"), f"글줄 {at}째")
|
||||
if words:
|
||||
lines.append({"자리": spot and _key(spot, f"글줄 {at}째", _KEY), "글": words})
|
||||
if lines:
|
||||
out["글줄"] = lines
|
||||
why = raw.get("까닭") or {}
|
||||
if not isinstance(why, dict):
|
||||
raise sd.DocError("까닭 · 묶음 아님")
|
||||
why = {_key(k, "까닭", _KEY): _words(v, f"까닭 「{k}」") for k, v in why.items()}
|
||||
if why := {k: v for k, v in why.items() if v}:
|
||||
out["까닭"] = why
|
||||
return out
|
||||
|
||||
|
||||
def _print(one: Any) -> str:
|
||||
raw = json.dumps(one, ensure_ascii=False, sort_keys=True, default=str)
|
||||
return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:12]
|
||||
|
||||
|
||||
def stamp(row: dict) -> dict[str, Any]:
|
||||
"""저장 때 서버 칸 — 바탕판(옛 칸 그대로) + M01판(자리마다 지문)."""
|
||||
from M02_MasterTemplete import M02_Sangeun_Calc as calc
|
||||
|
||||
lines = [*(row.get("중간") or []), *(row.get("호표") or []), *(row.get("덧줄") or [])]
|
||||
spots = [f"mid:{s.get('이름')}" for s in row.get("중간") or []] + sd.line_ids(row)
|
||||
return {"바탕판": calc.fingerprint(row), "M01판": dict(zip(spots, map(_print, lines)))}
|
||||
|
||||
|
||||
# ── 고치기(짝 = Const_Model.ts) ──────────────────────────────────────
|
||||
def restore(doc: dict, key: str | None = None) -> dict:
|
||||
"""[M01 원래로] — 전체(None) = 덮개 · 뺀줄 · 상수 · 표값 덮개 · 그 까닭 지움(더한줄 · 글줄 ·
|
||||
옵션 남김) · 열쇠 하나 = 그 자리 덮개 · 뺀줄 · 새 덮개 · 까닭(세부 `add:#k/cid` =
|
||||
그 더한줄 세부)."""
|
||||
out = {k: v for k, v in doc.items() if k not in ("상수덮개", "표값덮개", "까닭")}
|
||||
why = dict(doc.get("까닭") or {})
|
||||
if key is None:
|
||||
out |= {"덮개": {}, "뺀줄": []}
|
||||
why = {k: v for k, v in why.items() if _top_add(k)}
|
||||
return out | ({"까닭": why} if why else {})
|
||||
hit = _ADD.match(key)
|
||||
if hit and hit[2]:
|
||||
k, sub = int(hit[1]), hit[2][1:]
|
||||
adds = [dict(x) for x in doc.get("더한줄") or []]
|
||||
if k < len(adds):
|
||||
one = {n: v for n, v in adds[k].items() if n not in ("덮개", "뺀줄")}
|
||||
covers = {c: v for c, v in (adds[k].get("덮개") or {}).items() if c != sub}
|
||||
drop = [c for c in adds[k].get("뺀줄") or [] if c != sub]
|
||||
adds[k] = one | ({"덮개": covers} if covers else {}) | ({"뺀줄": drop} if drop else {})
|
||||
out["더한줄"] = adds
|
||||
else:
|
||||
out["덮개"] = {c: v for c, v in (doc.get("덮개") or {}).items() if c != key}
|
||||
out["뺀줄"] = [c for c in doc.get("뺀줄") or [] if c != key]
|
||||
for name in ("상수덮개", "표값덮개"):
|
||||
if got := {s: v for s, v in (doc.get(name) or {}).items() if s != key}:
|
||||
out[name] = got
|
||||
why.pop(key, None)
|
||||
return out | ({"까닭": why} if why else {})
|
||||
|
||||
|
||||
def _top_add(key: str) -> bool:
|
||||
hit = _ADD.match(key)
|
||||
return bool(hit) and not hit[2]
|
||||
|
||||
|
||||
def drop_add_keys(doc: dict, k: int) -> dict:
|
||||
"""더한줄 k 를 뺀 뒤 — 까닭 · 글줄 열쇠 `add:#j`(j > k) 를 한 칸 당김 · k 는 지움."""
|
||||
|
||||
def move(key: str) -> str | None:
|
||||
hit = _ADD.match(key)
|
||||
if not hit:
|
||||
return key
|
||||
j = int(hit[1])
|
||||
return None if j == k else f"add:#{j - 1 if j > k else j}{hit[2] or ''}"
|
||||
|
||||
out = dict(doc)
|
||||
if "까닭" in doc:
|
||||
why = {move(x): v for x, v in doc["까닭"].items() if move(x)}
|
||||
out.pop("까닭")
|
||||
out |= {"까닭": why} if why else {}
|
||||
if "글줄" in doc:
|
||||
out["글줄"] = [{**t, "자리": move(t["자리"]) or ""} for t in doc["글줄"]]
|
||||
return out
|
||||
|
||||
|
||||
def kinds(doc: dict, spot: str) -> list[str]:
|
||||
"""자리의 덮개 갈래 — 품 · 단가 · 상수 · 표값 · 뺌(차례 그대로)."""
|
||||
cover, out = (doc.get("덮개") or {}).get(spot) or {}, []
|
||||
if "qty" in cover or "배율" in cover:
|
||||
out.append("품")
|
||||
if any(k in cover for k in sd.PARTS):
|
||||
out.append("단가")
|
||||
out += [
|
||||
n for n, k in (("상수", "상수덮개"), ("표값", "표값덮개")) if spot in (doc.get(k) or {})
|
||||
]
|
||||
return out + (["뺌"] if spot in (doc.get("뺀줄") or []) else [])
|
||||
|
||||
|
||||
# ── 화면 셈 답 ────────────────────────────────────────────────────────
|
||||
def answer(got: dict, clean: dict, raw: Any, given: dict | None, trail: tuple) -> dict:
|
||||
"""`M02_Sangeun_Calc.calc_doc` 답 — 옛 답 + `원래` · `시트`."""
|
||||
from M02_MasterTemplete import M02_Sangeun_Calc as calc
|
||||
|
||||
out = {**calc.views.finish(calc.per_unit(got, clean)), "머리": calc.head_of(clean)}
|
||||
return calc.labor._json(out | sheet(out, clean, raw, given, trail))
|
||||
|
||||
|
||||
def _bare(doc: dict) -> dict:
|
||||
out = {k: v for k, v in doc.items() if k not in (*NEW, "뺀더한줄")}
|
||||
return out | {"덮개": {}, "뺀줄": [], "더한줄": []}
|
||||
|
||||
|
||||
def _mids(whole: Any, key: str, row: dict, values: dict) -> tuple[dict, dict]:
|
||||
"""(중간 값, 엔진 입력) — 시험 계산과 같은 길(`M02_Sangeun_Views.text_of`) · 못 셈 = 빈."""
|
||||
from M02_MasterTemplete import M02_Sangeun_Calc as calc
|
||||
|
||||
over = calc._Over(whole, key, {**row, "키": key})
|
||||
m01 = calc.labor.m01
|
||||
auto = {k: v for k, v in m01.mau.values(over, over.logic(key)).items() if v is not None}
|
||||
given = {**auto, **m01._dec(values or {})}
|
||||
try:
|
||||
mid = calc.labor.mcut.engine(over, key, m01.cm.mf.run(over, key, given)).get("중간") or {}
|
||||
except Exception: # noqa: BLE001 — 엔진 FormulaError · 셈 틀림 = 값 없음
|
||||
return {}, given
|
||||
return mid, given
|
||||
|
||||
|
||||
def _finds(whole: Any, text: str, env: dict) -> list[dict]:
|
||||
from M02_MasterTemplete import M02_Labor_Doc as labor
|
||||
|
||||
mf, out = labor.m01.cm.mf, []
|
||||
for n, (a, b) in enumerate(scan(text)["find"]):
|
||||
try:
|
||||
value = mf.evaluate(mf.parse(text[a:b]), dict(env), whole)
|
||||
except Exception: # noqa: BLE001
|
||||
value = None
|
||||
out.append({"n": n, "글": text[a:b], "값": value})
|
||||
return out
|
||||
|
||||
|
||||
def sheet(ans: dict, clean: dict, raw: Any, given: dict | None, trail: tuple) -> dict:
|
||||
"""`원래` · `시트` — 자리마다 원래 · 지금 · 덮개 갈래 · 까닭 · M01바뀜 · 상수 · 표값."""
|
||||
from M02_MasterTemplete import M02_Labor_Ref as refs
|
||||
from M02_MasterTemplete import M02_Sangeun_Calc as calc
|
||||
|
||||
whole = calc.labor.m01.loaded()[1]
|
||||
try:
|
||||
base = calc._base_of(clean, whole, refs.read_ref, trail)["줄"]
|
||||
bare = calc.per_unit(calc.run(_bare(clean), whole, given, trail=trail), clean)
|
||||
except sd.DocError:
|
||||
return {"원래": None, "시트": {"자리": {}, "글줄": clean.get("글줄") or [], "짝잃음": []}}
|
||||
was = {x["cid"]: x for x in bare.get("줄") or [] if x.get("cid")}
|
||||
now = {x["cid"]: x for x in ans.get("줄") or [] if x.get("cid")}
|
||||
key = str(clean.get("키") or "SK-000000")
|
||||
values = {**(clean.get("입력값") or {}), **(given or {})}
|
||||
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 {}
|
||||
fresh = stamp(base)["M01판"]
|
||||
why = clean.get("까닭") or {}
|
||||
nums, finds = clean.get("상수덮개") or {}, clean.get("표값덮개") or {}
|
||||
spots: dict[str, dict] = {}
|
||||
for spot, (part, at) in places(base).items():
|
||||
line = base[part][at]
|
||||
text = str(line.get(FIELD[part]))
|
||||
consts = [
|
||||
{"n": n, "값": nums.get(spot, {}).get(str(n), v), "원값": v}
|
||||
for n, v in enumerate(Decimal(text[a:b]) for a, b in scan(text)["num"])
|
||||
]
|
||||
tables = [
|
||||
{**t, "원값": t["값"], "값": finds.get(spot, {}).get(str(t["n"]), t["값"])}
|
||||
for t in _finds(whole, text, {**env0, **mids0})
|
||||
]
|
||||
if part == "중간":
|
||||
name = str(line.get("이름"))
|
||||
old, cur = mids0.get(name), mids1.get(name)
|
||||
else:
|
||||
old, cur = (was.get(spot) or {}).get("금액"), (now.get(spot) or {}).get("금액")
|
||||
spots[spot] = {
|
||||
"갈래": part,
|
||||
"이름": line.get("이름") or "",
|
||||
"식": text,
|
||||
"상수": consts,
|
||||
"표값": tables,
|
||||
"원래": old,
|
||||
"지금": cur,
|
||||
"덮개": kinds(clean, spot),
|
||||
"까닭": why.get(spot),
|
||||
"M01바뀜": spot in stamps and stamps[spot] != fresh.get(spot),
|
||||
}
|
||||
for cid, line in now.items():
|
||||
if _ADD.match(cid) and cid not in spots:
|
||||
spots[cid] = {"갈래": "더한줄", "이름": line.get("이름") or "", "원래": None}
|
||||
spots[cid] |= {"지금": line.get("금액"), "덮개": [], "까닭": why.get(cid)}
|
||||
gone = lost(base, clean) + [
|
||||
{"cid": s, "까닭": "M01 바뀜 — 바탕에 없는 줄"} for s in stamps if s not in fresh
|
||||
]
|
||||
top = {"글줄": clean.get("글줄") or [], "짝잃음": gone}
|
||||
return {"원래": bare.get("기준"), "시트": {"자리": spots, **top}}
|
||||
@@ -36,6 +36,8 @@ import re
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from typing import Any
|
||||
|
||||
from M02_MasterTemplete import M02_Sangeun_Const as const
|
||||
|
||||
KIND = "sangeun"
|
||||
TITLE = "단가산출"
|
||||
PREFIX = "SK"
|
||||
@@ -342,13 +344,11 @@ def clean(doc: Any, base_row: dict | None = None, whole: Any = None) -> dict[str
|
||||
_added(one, f"더한줄 {at}째", whole) for at, one in enumerate(doc.get("더한줄") or [], 1)
|
||||
]
|
||||
_rate_keys(out["더한줄"])
|
||||
drop = [_cid(x, "뺀줄") for x in doc.get("뺀줄") or []]
|
||||
out["뺀줄"] = drop = [_cid(x, "뺀줄") for x in doc.get("뺀줄") or []]
|
||||
if len(set(drop)) != len(drop):
|
||||
raise DocError("뺀줄에 같은 줄 두 번")
|
||||
out["뺀줄"] = drop
|
||||
if gone := _gone_adds(doc.get("뺀더한줄"), whole):
|
||||
out["뺀더한줄"] = gone
|
||||
return out
|
||||
out |= {"뺀더한줄": gone} if (gone := _gone_adds(doc.get("뺀더한줄"), whole)) else {}
|
||||
return const.clean_more(doc, out) # 새 덮개 칸(790-2)
|
||||
|
||||
|
||||
def is_rate(one: Any) -> bool:
|
||||
@@ -460,11 +460,11 @@ def patched(
|
||||
`picks` · `shown` = 바탕 쪽 차례 열쇠 고름 · 자재표시(바탕 SK 사슬) → 새 차례로 옮김.
|
||||
돌려줌 = {줄, 고름 {차례: MT}, 자재표시 {차례: 칸}, 정체 [새 줄 cid · 더한줄 = `add:#k`],
|
||||
짝잃음 [{cid, 까닭}]}."""
|
||||
row = const.formulas(row, doc) # 상수 · 표값 덮개(790-2) = 식 바꾼 바탕
|
||||
items, extras = list(row.get("호표") or []), list(row.get("덧줄") or [])
|
||||
ids = line_ids(row)
|
||||
covers, drop = doc.get("덮개") or {}, set(doc.get("뺀줄") or [])
|
||||
mine = doc.get("고름") or {}
|
||||
marks = doc.get("자재표시") or {}
|
||||
mine, marks = doc.get("고름") or {}, doc.get("자재표시") or {}
|
||||
lost = [
|
||||
{"cid": c, "까닭": "뺀줄 — 바탕에 없는 줄"} for c in doc.get("뺀줄") or [] if c not in ids
|
||||
]
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete/logic/M02_Sangeun_Const_Model.ts
|
||||
* 단가산출(SK) 숫자 넓힘 캐시 고치기(PLAN 790-2 · 요구서_652 · _분석_650-H3 E2) —
|
||||
* 짝 = 서버 `M02_MasterTemplete/M02_Sangeun_Const.py`(`restore` · `drop_add_keys` · `kinds` 같은 꼴)
|
||||
* · 거울 시험 `resources/tester/test_790_2_sangeun_const.py`.
|
||||
* 상수덮개 {자리: {n: 값}} 식 n 번째 숫자(0 부터 · 서버 답 `시트.자리[자리].상수` 의 n)
|
||||
* 표값덮개 {자리: {n: 값}} 식 n 번째 `찾기(…).칸`(서버 답 `표값` 의 n)
|
||||
* 글줄 [{자리, 글}] 셈 없는 글 줄 · 자리 뒤("" = 맨 위)
|
||||
* 까닭 {열쇠: 글} 덮개 · 더한줄 · 뺀줄 · 새 덮개마다
|
||||
* M01판 서버 몫 — 받은 그대로 둠(셈 때 「M01바뀜」 판정)
|
||||
* 자리 = 바탕 cid · 중간 `mid:이름` · 열쇠 = 자리 · `add:#k` · `add:#k/세부 cid`.
|
||||
* 금액은 늘 서버가 셈 — 여기는 문서 칸 고치기만(새 문서를 돌려줌 · 받은 문서 안 바꿈).
|
||||
* 화면 · fetch · sessionStorage 없음(`CLAUDE.md` 4장).
|
||||
* ========================================================================== */
|
||||
|
||||
export type NumCovers = Record<string, Record<string, number>>;
|
||||
|
||||
export interface TextLine {
|
||||
자리: string;
|
||||
글: string;
|
||||
}
|
||||
|
||||
export interface ConstDoc {
|
||||
덮개?: Record<string, Record<string, unknown>>;
|
||||
뺀줄?: string[];
|
||||
더한줄?: Record<string, unknown>[];
|
||||
상수덮개?: NumCovers;
|
||||
표값덮개?: NumCovers;
|
||||
글줄?: TextLine[];
|
||||
까닭?: Record<string, string>;
|
||||
[k: string]: unknown;
|
||||
}
|
||||
|
||||
export type CoverKind = "품" | "단가" | "상수" | "표값" | "뺌";
|
||||
|
||||
const ADD = /^add:#(\d+)(\/.*)?$/;
|
||||
const PARTS = ["labor", "material", "expense"];
|
||||
|
||||
/** 빈 묶음 · 목록 칸은 지움(서버 `clean_more` 처럼 없으면 안 둠). */
|
||||
function put<T extends ConstDoc>(doc: T, name: string, value: unknown): T {
|
||||
const out: ConstDoc = { ...doc };
|
||||
const empty =
|
||||
value == null ||
|
||||
(Array.isArray(value)
|
||||
? value.length === 0
|
||||
: typeof value === "object" && Object.keys(value).length === 0);
|
||||
if (empty) delete out[name];
|
||||
else out[name] = value;
|
||||
return out as T;
|
||||
}
|
||||
|
||||
function setNum<T extends ConstDoc>(
|
||||
doc: T,
|
||||
name: "상수덮개" | "표값덮개",
|
||||
spot: string,
|
||||
n: number,
|
||||
v: number | null,
|
||||
): T {
|
||||
const all: NumCovers = { ...(doc[name] ?? {}) };
|
||||
const one = { ...(all[spot] ?? {}) };
|
||||
if (v == null) delete one[String(n)];
|
||||
else one[String(n)] = v;
|
||||
if (Object.keys(one).length) all[spot] = one;
|
||||
else delete all[spot];
|
||||
return put(doc, name, all);
|
||||
}
|
||||
|
||||
/** 식 속 n 번째 숫자 상수 → 값(null = 원래로). */
|
||||
export const setConst = <T extends ConstDoc>(
|
||||
doc: T,
|
||||
spot: string,
|
||||
n: number,
|
||||
v: number | null,
|
||||
): T => setNum(doc, "상수덮개", spot, n, v);
|
||||
|
||||
/** 식 속 n 번째 표 찾기 값 → 값(null = 표 연결 되살림). */
|
||||
export const setTable = <T extends ConstDoc>(
|
||||
doc: T,
|
||||
spot: string,
|
||||
n: number,
|
||||
v: number | null,
|
||||
): T => setNum(doc, "표값덮개", spot, n, v);
|
||||
|
||||
/** 글 줄 넣기 — `at` 차례(없으면 끝). */
|
||||
export function addText<T extends ConstDoc>(doc: T, spot: string, text: string, at?: number): T {
|
||||
const lines = [...(doc.글줄 ?? [])];
|
||||
lines.splice(at ?? lines.length, 0, { 자리: spot, 글: text });
|
||||
return put(doc, "글줄", lines);
|
||||
}
|
||||
|
||||
export function editText<T extends ConstDoc>(doc: T, at: number, text: string): T {
|
||||
const lines = (doc.글줄 ?? []).map((t, i) => (i === at ? { ...t, 글: text } : t));
|
||||
return put(doc, "글줄", lines);
|
||||
}
|
||||
|
||||
export function dropText<T extends ConstDoc>(doc: T, at: number): T {
|
||||
return put(
|
||||
doc,
|
||||
"글줄",
|
||||
(doc.글줄 ?? []).filter((_, i) => i !== at),
|
||||
);
|
||||
}
|
||||
|
||||
/** 까닭 — 빈 글 = 지움. */
|
||||
export function setReason<T extends ConstDoc>(doc: T, key: string, text: string): T {
|
||||
const why = { ...(doc.까닭 ?? {}) };
|
||||
if (text.trim()) why[key] = text.trim();
|
||||
else delete why[key];
|
||||
return put(doc, "까닭", why);
|
||||
}
|
||||
|
||||
const topAdd = (key: string): boolean => {
|
||||
const hit = ADD.exec(key);
|
||||
return !!hit && !hit[2];
|
||||
};
|
||||
|
||||
/** [M01 원래로] — 열쇠 없음 = 전체(덮개 · 뺀줄 · 상수 · 표값 덮개 · 그 까닭 · 더한줄 · 글줄 · 옵션 남김) ·
|
||||
* 열쇠 = 그 자리 덮개 · 뺀줄 · 새 덮개 · 까닭(`add:#k/cid` = 그 더한줄 세부) · 짝 `restore`. */
|
||||
export function restoreM01<T extends ConstDoc>(doc: T, key?: string): T {
|
||||
let out: ConstDoc = { ...doc };
|
||||
delete out.상수덮개;
|
||||
delete out.표값덮개;
|
||||
delete out.까닭;
|
||||
const why = { ...(doc.까닭 ?? {}) };
|
||||
if (key == null) {
|
||||
out = { ...out, 덮개: {}, 뺀줄: [] };
|
||||
const kept = Object.fromEntries(Object.entries(why).filter(([k]) => topAdd(k)));
|
||||
return put(out, "까닭", kept) as T;
|
||||
}
|
||||
const hit = ADD.exec(key);
|
||||
if (hit && hit[2]) {
|
||||
const k = Number(hit[1]);
|
||||
const sub = hit[2].slice(1);
|
||||
const adds = (doc.더한줄 ?? []).map((x) => ({ ...x }));
|
||||
if (k < adds.length) {
|
||||
const was = adds[k];
|
||||
const one: Record<string, unknown> = { ...was };
|
||||
delete one.덮개;
|
||||
delete one.뺀줄;
|
||||
const covers = Object.fromEntries(
|
||||
Object.entries((was.덮개 as Record<string, unknown>) ?? {}).filter(([c]) => c !== sub),
|
||||
);
|
||||
const drop = ((was.뺀줄 as string[]) ?? []).filter((c) => c !== sub);
|
||||
if (Object.keys(covers).length) one.덮개 = covers;
|
||||
if (drop.length) one.뺀줄 = drop;
|
||||
adds[k] = one;
|
||||
}
|
||||
out.더한줄 = adds;
|
||||
} else {
|
||||
out.덮개 = Object.fromEntries(Object.entries(doc.덮개 ?? {}).filter(([c]) => c !== key));
|
||||
out.뺀줄 = (doc.뺀줄 ?? []).filter((c) => c !== key);
|
||||
for (const name of ["상수덮개", "표값덮개"] as const) {
|
||||
const got = Object.fromEntries(Object.entries(doc[name] ?? {}).filter(([s]) => s !== key));
|
||||
out = put(out, name, got);
|
||||
}
|
||||
}
|
||||
delete why[key];
|
||||
return put(out, "까닭", why) as T;
|
||||
}
|
||||
|
||||
/** 더한줄 k 를 뺀 뒤 — 까닭 · 글줄 열쇠 `add:#j`(j > k) 한 칸 당김 · k 는 지움 · 짝 `drop_add_keys`. */
|
||||
export function dropAddKeys<T extends ConstDoc>(doc: T, k: number): T {
|
||||
const move = (key: string): string | null => {
|
||||
const hit = ADD.exec(key);
|
||||
if (!hit) return key;
|
||||
const j = Number(hit[1]);
|
||||
return j === k ? null : `add:#${j > k ? j - 1 : j}${hit[2] ?? ""}`;
|
||||
};
|
||||
let out: ConstDoc = { ...doc };
|
||||
if (doc.까닭) {
|
||||
const why: Record<string, string> = {};
|
||||
for (const [x, v] of Object.entries(doc.까닭)) {
|
||||
const to = move(x);
|
||||
if (to) why[to] = v;
|
||||
}
|
||||
out = put(out, "까닭", why);
|
||||
}
|
||||
if (doc.글줄) out.글줄 = doc.글줄.map((t) => ({ ...t, 자리: move(t.자리) ?? "" }));
|
||||
return out as T;
|
||||
}
|
||||
|
||||
/** 자리의 덮개 갈래(차례 = 품 · 단가 · 상수 · 표값 · 뺌) · 짝 `kinds`. */
|
||||
export function coverKinds(doc: ConstDoc, spot: string): CoverKind[] {
|
||||
const cover = doc.덮개?.[spot] ?? {};
|
||||
const out: CoverKind[] = [];
|
||||
if ("qty" in cover || "배율" in cover) out.push("품");
|
||||
if (PARTS.some((k) => k in cover)) out.push("단가");
|
||||
if (doc.상수덮개?.[spot]) out.push("상수");
|
||||
if (doc.표값덮개?.[spot]) out.push("표값");
|
||||
if ((doc.뺀줄 ?? []).includes(spot)) out.push("뺌");
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
# ruff: noqa: F811 — 임시 층 fixture(world)를 test_m02_labor_ref 에서 받아 씀
|
||||
"""790-2 단가산출(SK) 숫자 넓힘 — 상수 · 표값 덮개 · 글 줄 · 까닭 · M01 원래로 · 짝잃음(요구서_652).
|
||||
|
||||
- 금액 = 서버 셈 = _분석_650-H2 4 장 견본(지붕 감률 17 → 20 = 61,088 · 공구손료 1 → 2 % = 51,802 ·
|
||||
인력운반 120 → 100 = 38,696 · 표값 운반량 30 = 35,764) · 옵션 바꿔도 상수 덮개 식 따라감
|
||||
- 식 속 숫자 차례 = 엔진 파서(찾기 머리 · 키 · EQ: · 따옴표 글 안 셈)
|
||||
- 옛 SK(새 칸 없음) = 꼴 · 금액 그대로 · 저장 = M01판 · 셈 답 `원래` · `시트`
|
||||
- [M01 원래로] · 더한줄 열쇠 옮김 · 덮개 갈래 = 파이썬 `M02_Sangeun_Const` ↔ TS `Const_Model` 거울
|
||||
- 펼침 = 상수 덮개 SK 안 펼침(참조 그대로 · 값 그대로) · [초기화] = 새 덮개도 걷음
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from M01_MasterData import M01_MasterData_Store as m01
|
||||
from M02_MasterTemplete import M02_Labor_Ref as refs
|
||||
from M02_MasterTemplete import M02_Library_Flat as flat
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
||||
from M02_MasterTemplete import M02_Sangeun_Calc as calc
|
||||
from M02_MasterTemplete import M02_Sangeun_Const as const
|
||||
from M02_MasterTemplete import M02_Sangeun_Doc as sd
|
||||
from resources.tester.test_m02_labor_ref import MINE, world # noqa: F401 — 같은 임시 층
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
FORM = "GC000211" # 합판거푸집
|
||||
CARRY = "GC001140" # 인력운반(지게)
|
||||
ROOF = "지붕 슬래브(경사 20˚ 미만)"
|
||||
TOOL = "pct:공구손료 및 경장비#0"
|
||||
F211 = {
|
||||
"자재지역": "포항",
|
||||
"유형": "보통",
|
||||
"사용횟수": "6회",
|
||||
"높이감률": 0,
|
||||
"지붕슬래브": "아님",
|
||||
"폼타이": "아님",
|
||||
"폼타이규격": "12.7㎜",
|
||||
"측압": "3t/㎡",
|
||||
"구멍땜": 0,
|
||||
}
|
||||
|
||||
|
||||
def _whole():
|
||||
return m01.loaded()[1]
|
||||
|
||||
|
||||
def _carry():
|
||||
return {
|
||||
**json.loads(json.dumps(_whole().logic(CARRY)["시험입력"], default=str)),
|
||||
"재료종별": "암석",
|
||||
"재료형상": "화강암",
|
||||
"운반회수": 0,
|
||||
}
|
||||
|
||||
|
||||
def _doc(code, given, **more):
|
||||
return {
|
||||
"키": "SK-000001",
|
||||
"이름": "견본",
|
||||
"단위": "식",
|
||||
"바탕": {"로직": code},
|
||||
"입력값": given,
|
||||
**more,
|
||||
}
|
||||
|
||||
|
||||
def _sum(doc):
|
||||
whole = _whole()
|
||||
return float(calc.run(sd.clean(doc, None, whole), whole)["기준"]["계"])
|
||||
|
||||
|
||||
# ── 식 속 자리 ────────────────────────────────────────────────────────
|
||||
def test_scan_엔진_차례():
|
||||
t = "찾기(QC000431, 유형=유형).시공량 * (1 - (높이감률 + 만약(지붕슬래브 == '아님', 0, 17)) / 100)"
|
||||
got = const.scan(t)
|
||||
assert [t[a:b] for a, b in got["num"]] == ["1", "0", "17", "100"]
|
||||
assert [t[a:b] for a, b in got["find"]] == ["찾기(QC000431, 유형=유형).시공량"]
|
||||
tool = "(형틀목공 * LB000007 + 보통인부 * LB000002) * 1 / 100"
|
||||
assert [tool[a:b] for a, b in const.scan(tool)["num"]] == ["1", "100"]
|
||||
assert const.scan("EQ:0201-0012.잡재료율 * 2 + '3t/㎡'")["num"] == [(20, 21)]
|
||||
assert const.scan("((((") == {"num": [], "find": []} # 못 읽는 식 = 빈
|
||||
assert const.swap(t, {"2": 20}, {"0": -45}).startswith("(-45) * (1 - ")
|
||||
assert ", 0, 20)) / 100)" in const.swap(t, {"2": 20}, {})
|
||||
|
||||
|
||||
# ── 금액 = 견본(서버 셈) ──────────────────────────────────────────────
|
||||
def test_상수_표값_덮개_견본_금액():
|
||||
roof = {**F211, "지붕슬래브": ROOF}
|
||||
assert _sum(_doc(FORM, F211)) == 51419
|
||||
assert _sum(_doc(FORM, roof)) == 59341
|
||||
assert _sum(_doc(FORM, roof, 상수덮개={"mid:시공량": {"2": 20}})) == 61088
|
||||
assert _sum(_doc(FORM, F211, 상수덮개={TOOL: {"0": 2}})) == 51802
|
||||
assert _sum(_doc(CARRY, _carry())) == 42917
|
||||
assert _sum(_doc(CARRY, _carry(), 상수덮개={"mid:일운반회수": {"1": 100}})) == 38696
|
||||
assert _sum(_doc(CARRY, _carry(), 표값덮개={"mid:운반량": {"0": 30}})) == 35764
|
||||
|
||||
|
||||
def test_옵션_바꿔도_상수_덮개_따라감():
|
||||
"""품 덮개(숫자로 굳힘)와 달리 식 구조 그대로 — 유형 복잡 = 손으로 식 고친 줄과 같은 값."""
|
||||
from M02_MasterTemplete import M02_Labor_Doc as labor
|
||||
|
||||
whole = _whole()
|
||||
given = {**F211, "지붕슬래브": ROOF, "유형": "복잡"}
|
||||
got = _sum(_doc(FORM, given, 상수덮개={"mid:시공량": {"2": 20}}))
|
||||
row = json.loads(json.dumps(whole.logic(FORM), ensure_ascii=False, default=str))
|
||||
for s in row["중간"]:
|
||||
if s["이름"] == "시공량":
|
||||
s["식"] = s["식"].replace(", 0, 17))", ", 0, 20))")
|
||||
hand = labor.run_line(calc._Over(whole, FORM, row), FORM, given)["기준"]["계"]
|
||||
assert got == float(hand) and got not in (61088, _sum(_doc(FORM, given)))
|
||||
|
||||
|
||||
# ── 문서 꼴 ──────────────────────────────────────────────────────────
|
||||
def test_새_칸_꼴_검사():
|
||||
whole = _whole()
|
||||
doc = _doc(
|
||||
FORM,
|
||||
F211,
|
||||
상수덮개={"mid:시공량": {"2": "20"}, TOOL: {}},
|
||||
표값덮개={},
|
||||
글줄=[{"자리": "", "글": " 건설표준품셈 6-3-1 "}, {"자리": "mid:시공량", "글": ""}],
|
||||
까닭={"mid:시공량": "현장 경사 반영", "add:#0": " ", "add:#1/labor:LB000002#0": "x"},
|
||||
)
|
||||
got = sd.clean(doc, None, whole)
|
||||
assert got["상수덮개"] == {"mid:시공량": {"2": 20}} and "표값덮개" not in got
|
||||
assert got["글줄"] == [{"자리": "", "글": "건설표준품셈 6-3-1"}]
|
||||
assert got["까닭"] == {"mid:시공량": "현장 경사 반영", "add:#1/labor:LB000002#0": "x"}
|
||||
for bad in (
|
||||
{"상수덮개": {"엉뚱": {"0": 1}}},
|
||||
{"상수덮개": {"mid:시공량": {"a": 1}}},
|
||||
{"표값덮개": {"mid:운반량": {"0": "글"}}},
|
||||
{"까닭": {"x": "y"}},
|
||||
{"글줄": [{"자리": "", "글": "가" * 501}]},
|
||||
):
|
||||
with pytest.raises(sd.DocError):
|
||||
sd.clean(_doc(FORM, F211, **bad), None, whole)
|
||||
|
||||
|
||||
def test_옛_SK_꼴_금액_그대로():
|
||||
whole = _whole()
|
||||
old = _doc(FORM, F211, 덮개={"labor:LB000007#0": {"qty": 0.2}}, 뺀줄=["labor:LB000002#0"])
|
||||
got = sd.clean(old, None, whole)
|
||||
assert not set(got) & {*const.NEW, "M01판"}
|
||||
assert const.formulas(whole.logic(FORM), got) is whole.logic(
|
||||
FORM
|
||||
) # 새 덮개 없음 = 그 줄 그대로
|
||||
assert sd.clean({**old, "M01판": {"mid:시공량": "x"}}, None, whole) == got # M01판 = 서버 몫
|
||||
|
||||
|
||||
# ── 저장 · 셈 답(임시 storage) ─────────────────────────────────────────
|
||||
def _sk(folder, **doc):
|
||||
with store.at(folder):
|
||||
return refs.create("sangeun", {"이름": "내 단가산출", "단위": "식", "더한줄": [], **doc})[
|
||||
"이름"
|
||||
]
|
||||
|
||||
|
||||
def _read(folder, key):
|
||||
with store.at(folder):
|
||||
return store.read("sangeun", key)["문서"]
|
||||
|
||||
|
||||
def _calc(folder, doc, given=None):
|
||||
with store.at(folder):
|
||||
return calc.calc_doc(doc, given)
|
||||
|
||||
|
||||
def test_저장_M01판_셈답_원래_시트(world):
|
||||
mine = world["personal"]
|
||||
key = _sk(
|
||||
mine,
|
||||
바탕={"로직": FORM},
|
||||
입력값={**F211, "지붕슬래브": ROOF},
|
||||
상수덮개={"mid:시공량": {"2": 20}},
|
||||
덮개={"labor:LB000002#0": {"qty": 0.05}},
|
||||
글줄=[{"자리": "", "글": "※ 지붕 경사 반영"}],
|
||||
까닭={"mid:시공량": "감률 20 % 적용"},
|
||||
)
|
||||
doc = _read(mine, key)
|
||||
assert doc["상수덮개"] == {"mid:시공량": {"2": 20}} and len(doc["바탕판"]) == 16
|
||||
assert set(doc["M01판"]) >= {"mid:시공량", TOOL, "labor:LB000007#0"}
|
||||
got = _calc(mine, doc)
|
||||
assert got["원래"]["계"] == 59341 # 같은 옵션 · 덮개 없음
|
||||
assert got["기준"]["계"] != 59341
|
||||
no_qty = _calc(mine, {**doc, "덮개": {}})
|
||||
assert no_qty["기준"]["계"] == 61088 and no_qty["원래"]["계"] == 59341
|
||||
spot = got["시트"]["자리"]["mid:시공량"]
|
||||
assert spot["갈래"] == "중간" and spot["덮개"] == ["상수"] and spot["까닭"] == "감률 20 % 적용"
|
||||
assert spot["상수"][2] == {"n": 2, "값": 20, "원값": 17}
|
||||
assert spot["표값"][0]["글"].startswith("찾기(QC000431") and spot["표값"][0]["값"] == 45
|
||||
assert spot["원래"] > spot["지금"] and spot["M01바뀜"] is False
|
||||
line = got["시트"]["자리"]["labor:LB000002#0"]
|
||||
assert line["덮개"] == ["품"] and line["원래"] != line["지금"]
|
||||
assert got["시트"]["글줄"] == [{"자리": "", "글": "※ 지붕 경사 반영"}]
|
||||
assert got["시트"]["짝잃음"] == []
|
||||
# M01 바뀜 — 저장 지문과 지금 바탕이 다른 자리만 · 사라진 자리 = 짝잃음
|
||||
stale = {**doc, "M01판": {**doc["M01판"], "mid:시공량": "000000000000", "mid:없는줄": "1"}}
|
||||
got = _calc(mine, stale)
|
||||
flags = {s for s, x in got["시트"]["자리"].items() if x.get("M01바뀜")}
|
||||
assert flags == {"mid:시공량"}
|
||||
assert {"cid": "mid:없는줄", "까닭": "M01 바뀜 — 바탕에 없는 줄"} in got["시트"]["짝잃음"]
|
||||
|
||||
|
||||
def test_짝잃은_새_덮개_알림(world):
|
||||
doc = _doc(FORM, F211, 상수덮개={"mid:없음": {"0": 1}, "mid:시공량": {"9": 1}})
|
||||
got = _calc(world["personal"], doc)
|
||||
assert got["기준"]["계"] == 51419
|
||||
why = {(x["cid"], x["까닭"]) for x in got["시트"]["짝잃음"]}
|
||||
assert why == {
|
||||
("mid:없음", "상수덮개 — 바탕에 없는 줄"),
|
||||
("mid:시공량", "상수덮개 — 10 번째 없음"),
|
||||
}
|
||||
|
||||
|
||||
def test_옛_저장본_셈답(world):
|
||||
"""새 칸 없는 옛 저장본(M01판 없음) = 금액 그대로 · M01바뀜 없음."""
|
||||
got = _calc(world["personal"], {**_doc(FORM, F211), "바탕판": "0" * 16})
|
||||
assert got["기준"]["계"] == 51419 == got["원래"]["계"]
|
||||
assert not any(x.get("M01바뀜") for x in got["시트"]["자리"].values())
|
||||
|
||||
|
||||
def test_펼침_막음_초기화_걷음(world):
|
||||
mine = world["personal"]
|
||||
a = _sk(mine, 바탕={"로직": CARRY}, 입력값=_carry(), 상수덮개={"mid:일운반회수": {"1": 100}})
|
||||
b = _sk(mine, 바탕=None, 더한줄=[{"참조": {**MINE, "키": a}, "수량": 1}])
|
||||
assert _read(mine, b)["더한줄"][0]["참조"]["키"] == a # 안 펼침 = 참조 그대로
|
||||
assert _calc(mine, _read(mine, b))["기준"]["계"] == 38696
|
||||
with store.at(mine), pytest.raises(store.StoreError):
|
||||
flat.flat_lines("sangeun", {**MINE, "키": a})
|
||||
doc, n = flat.reset("sangeun", {**_read(mine, a), "까닭": {"mid:일운반회수": "가정"}})
|
||||
assert n == 1 and not set(doc) & {"상수덮개", "까닭"}
|
||||
|
||||
|
||||
# ── 파이썬 ↔ TS 거울 ─────────────────────────────────────────────────
|
||||
MIRROR_DOC = {
|
||||
"덮개": {"labor:LB000002#0": {"qty": 1}, "labor:LB000007#0": {"labor": 5}},
|
||||
"뺀줄": ["pct:소모자재#0"],
|
||||
"더한줄": [
|
||||
{
|
||||
"종류": "로직",
|
||||
"로직": "GC000001",
|
||||
"덮개": {"labor:X#0": {"qty": 1}},
|
||||
"뺀줄": ["material:Y#0"],
|
||||
},
|
||||
{"종류": "인력"},
|
||||
],
|
||||
"상수덮개": {"mid:시공량": {"2": 20}, "labor:LB000002#0": {"0": 3}},
|
||||
"표값덮개": {"mid:운반량": {"0": 30}},
|
||||
"글줄": [{"자리": "add:#1", "글": "a"}, {"자리": "add:#0", "글": "b"}],
|
||||
"까닭": {
|
||||
"mid:시공량": "c",
|
||||
"add:#0": "d",
|
||||
"add:#1": "e",
|
||||
"add:#0/labor:X#0": "f",
|
||||
"pct:소모자재#0": "g",
|
||||
},
|
||||
}
|
||||
KEYS = [
|
||||
None,
|
||||
"mid:시공량",
|
||||
"labor:LB000002#0",
|
||||
"pct:소모자재#0",
|
||||
"add:#0/labor:X#0",
|
||||
"add:#0/material:Y#0",
|
||||
"없음",
|
||||
]
|
||||
|
||||
|
||||
def _ts(body: str):
|
||||
src = (ROOT / "M02_MasterTemplete/logic/M02_Sangeun_Const_Model.ts").read_text("utf-8")
|
||||
got = subprocess.run(
|
||||
["node", "--experimental-strip-types", "--no-warnings",
|
||||
"--input-type=module-typescript", "-e", src + body],
|
||||
capture_output=True, text=True, encoding="utf-8", timeout=60,
|
||||
) # fmt: skip
|
||||
assert got.returncode == 0, got.stderr
|
||||
return json.loads(got.stdout)
|
||||
|
||||
|
||||
def test_거울_M01원래로_열쇠옮김_갈래():
|
||||
d = json.dumps(MIRROR_DOC, ensure_ascii=False)
|
||||
keys = json.dumps(KEYS)
|
||||
spots = json.dumps(
|
||||
["labor:LB000002#0", "labor:LB000007#0", "pct:소모자재#0", "mid:운반량", "x"]
|
||||
)
|
||||
ts = _ts(
|
||||
f"\nconst d = {d};"
|
||||
f"\nconst out = {{r: {keys}.map((k) => restoreM01(d, k ?? undefined)),"
|
||||
" a: [0, 1, 5].map((k) => dropAddKeys(d, k)),"
|
||||
f" k: {spots}.map((s) => coverKinds(d, s))}};"
|
||||
"\nconsole.log(JSON.stringify(out));"
|
||||
)
|
||||
assert ts["r"] == [const.restore(MIRROR_DOC, k) for k in KEYS]
|
||||
assert ts["a"] == [const.drop_add_keys(MIRROR_DOC, k) for k in (0, 1, 5)]
|
||||
assert ts["k"] == [const.kinds(MIRROR_DOC, s) for s in json.loads(spots)]
|
||||
assert ts["k"][0] == ["품", "상수"] and ts["k"][2] == ["뺌"]
|
||||
|
||||
|
||||
def test_TS_고치기_꼴_서버가_받음():
|
||||
"""setConst · setTable · addText · editText · dropText · setReason 결과 = 서버 꼴 그대로 통과."""
|
||||
ts = _ts(
|
||||
"\nlet d = {키: 'SK-000001', 이름: '견본', 단위: '식', 바탕: {로직: 'GC000211'}};"
|
||||
"\nd = setConst(d, 'mid:시공량', 2, 20); d = setConst(d, 'mid:시공량', 0, 1);"
|
||||
"\nd = setConst(d, 'mid:시공량', 0, null); d = setTable(d, 'mid:시공량', 0, 45);"
|
||||
"\nd = setTable(d, 'mid:시공량', 0, null);"
|
||||
"\nd = addText(d, '', '근거'); d = addText(d, 'mid:시공량', '※ 조건', 0);"
|
||||
"\nd = editText(d, 1, '근거 고침'); d = dropText(d, 0);"
|
||||
"\nd = setReason(d, 'mid:시공량', ' 까닭 '); d = setReason(d, 'add:#0', '');"
|
||||
"\nconsole.log(JSON.stringify(d));"
|
||||
)
|
||||
assert ts["상수덮개"] == {"mid:시공량": {"2": 20}} and "표값덮개" not in ts
|
||||
assert ts["글줄"] == [{"자리": "", "글": "근거 고침"}] and ts["까닭"] == {"mid:시공량": "까닭"}
|
||||
got = sd.clean({**ts, "입력값": F211}, None, _whole())
|
||||
assert {k: got[k] for k in const.NEW if k in got} == {k: ts[k] for k in const.NEW if k in ts}
|
||||
Reference in New Issue
Block a user