Merge remote-tracking branch 'origin/dev' into sub_laptop_4
This commit is contained in:
@@ -15,10 +15,9 @@ import re
|
||||
from decimal import Decimal
|
||||
from functools import lru_cache
|
||||
|
||||
import master_unit as mu
|
||||
from check_master_body import section_of
|
||||
|
||||
# ponytail: 단위 별칭은 여기 짧은 표 — 712-2 단위 모듈이 서면 그쪽 별칭으로 갈아 끼움
|
||||
_ALIAS = {"ea": "개", "톤": "ton", "t": "ton", "㎞": "km", "hr": "시간", "min": "분", "㎏": "kg"}
|
||||
_UNITS = sorted(
|
||||
(
|
||||
"㎡ ㎥ ㎢ ㎞ km ㎜ mm ㎝ cm m ha ton 톤 t kg ㎏ 개소 개 본 대 회 조 기 틀 "
|
||||
@@ -33,11 +32,10 @@ _UNIT_AT_END = re.compile(
|
||||
)
|
||||
_HANGUL = re.compile(r"[가-힣]")
|
||||
_HEAD = re.compile(r"^\(\s*(?:단위\s*:\s*)?([^()]*당[^()]*)\)$") # 원문 머리 줄 「(단위: ㎥당)」
|
||||
_RESULT = re.compile(r"원\s*/\s*(\d[\d,]*\.?\d*)?\s*([^\s·(\d,.][^\s·(]*)") # 「원/10㎡」
|
||||
|
||||
|
||||
def unit(text: str) -> str:
|
||||
return _ALIAS.get(text, text)
|
||||
return mu.shown(text) # 보이는 꼴 별칭 한 벌(712-2 `master_unit`)
|
||||
|
||||
|
||||
def _times(text: str | None) -> Decimal:
|
||||
@@ -97,7 +95,7 @@ def basis(table: dict) -> dict:
|
||||
|
||||
def result_per(result_unit) -> list[tuple[Decimal, str]]:
|
||||
"""로직 결과단위 분모 — 「원/10㎡」 → [(10, ㎡)] · 여럿이면 여럿."""
|
||||
return [(_times(n), unit(u)) for n, u in _RESULT.findall(str(result_unit or ""))]
|
||||
return [(n, unit(u)) for n, u in mu.per(result_unit)]
|
||||
|
||||
|
||||
def _formula_text(row: dict) -> str:
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""인력 · 재료 · 경비 묶음 — **셈 때 파생**(정본에 칸 없음 · PLAN 712-1 · 요구서 원칙 5).
|
||||
|
||||
규칙 한 줄:
|
||||
- 호표 · 덧줄 = `비목` 따라(노무비 → 인력 · 재료비 → 재료 · 경비 → 경비).
|
||||
- 비목 없는 로직 줄 = 부르는 로직이 기계경비(`is_machine`)면 경비 + `장비` · 아니면 하위.
|
||||
두 줄 모두 엔진 줄 `비목` 에 노무비 · 재료비 · 경비 세 몫이 그대로 남음(한 줄 · 흩어 넣기 둘 다).
|
||||
|
||||
기계경비 판별 = `is_machine` 한 곳 — M01 엔진 · M02 엑셀 맞춤(`M02_Hopyo_Ours`) ·
|
||||
B09 산근(`B09_Estimation_Sangeun` · `_Text`)이 같이 씀.
|
||||
엔진 줄 덧칸 = `묶음`(출력 꼬리표만 · 입력 값 덧칸 `값묶음` 은 715-1b 몫) · 기계경비 줄 `장비` true.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
import master_formula as mf
|
||||
|
||||
BUNDLES = ("인력", "재료", "경비", "하위")
|
||||
BY_ITEM = {"노무비": "인력", "재료비": "재료", "경비": "경비"}
|
||||
BY_KIND = {"인력": "인력", "재료": "재료", "기계": "경비"} # 비목 빈 옛 줄 받침
|
||||
|
||||
|
||||
def is_machine(row: dict | None) -> bool:
|
||||
"""기계경비 로직 — 돈 로직이고 결과단위 「원/시간」(작업량 「㎥/시간」 · 이름 「시간당」 아님)."""
|
||||
if not row or "결과" in row or isinstance(row.get("흐름"), dict):
|
||||
return False
|
||||
return re.sub(r"\s", "", str(row.get("결과단위") or "")) == "원/시간"
|
||||
|
||||
|
||||
def called(master, item: dict, env: dict | None = None) -> dict | None:
|
||||
"""로직 줄이 부르는 로직 줄(정본) — 못 찾으면 None."""
|
||||
try:
|
||||
node = mf.parse(item.get("요소") or "")
|
||||
if not node or node[0] != "logic":
|
||||
return None
|
||||
key = mf.fill(node[1], env) if env is not None and "{" in node[1] else node[1]
|
||||
return master.logic(key)
|
||||
except (mf.FormulaError, mf.EmptyInput, KeyError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
def bundle(master, item: dict, env: dict | None = None) -> tuple[str, bool]:
|
||||
"""줄 하나(호표 · 덧줄 정본 줄) → (묶음, 장비인가)."""
|
||||
if item.get("비목") in BY_ITEM:
|
||||
return BY_ITEM[item["비목"]], False
|
||||
if item.get("종류") == "로직":
|
||||
return ("경비", True) if is_machine(called(master, item, env)) else ("하위", False)
|
||||
return BY_KIND.get(str(item.get("종류")), "하위"), False
|
||||
|
||||
|
||||
def tag(master, row: dict, lines: list[dict], env: dict | None = None) -> list[dict]:
|
||||
"""엔진 `run` 줄에 `묶음`(+ 기계경비 줄 `장비`) — 줄 차례 = 호표 줄 하나씩 뒤 덧줄 하나씩."""
|
||||
for item, line in zip([*row.get("호표", []), *row.get("덧줄", [])], lines):
|
||||
name, machine = bundle(master, item, env)
|
||||
line["묶음"] = name
|
||||
if machine:
|
||||
line["장비"] = True
|
||||
return lines
|
||||
@@ -657,6 +657,7 @@ def run(master: Master, ref: str, given: dict, depth: int = 0) -> dict:
|
||||
}
|
||||
)
|
||||
sums = {k: cut(row, v, "계") for k, v in sums.items()} # 비목 합마다 끊고 계는 그 합
|
||||
mb.tag(master, row, lines, env) # 줄마다 인력 · 재료 · 경비 · 하위 묶음(712-1)
|
||||
return {"줄": lines, **sums, "계": sum(sums.values()), "중간": _shown(env)}
|
||||
|
||||
|
||||
@@ -676,6 +677,7 @@ from master_material import ( # noqa: E402, F401
|
||||
pick,
|
||||
)
|
||||
import master_pick as mp # noqa: E402 — 검색어 · 고름 · 단위
|
||||
import master_bundle as mb # noqa: E402 — 인력 · 재료 · 경비 묶음
|
||||
from master_formula_check import ( # noqa: E402, F401 — 검사는 나눈 파일 · 이름은 여기서 그대로
|
||||
_check_cut,
|
||||
_check_node,
|
||||
|
||||
@@ -12,6 +12,8 @@ import re
|
||||
import unicodedata
|
||||
from decimal import Decimal
|
||||
|
||||
import master_unit as mu
|
||||
|
||||
LINKED = "MP" # 품셈재료 테이블ID
|
||||
MARKET = "MT" # 자재품목 테이블ID
|
||||
PRICED = "MO" # 유가전력 테이블ID — 값 한 칸
|
||||
@@ -27,7 +29,6 @@ PICK_KEYS = (
|
||||
) # 재료 고르기 조건 칸 (`_틀.md` 7장)
|
||||
REGION = "자재지역" # 로직 입력 — 「지역」 은 유가 고르기가 씀
|
||||
TARIFF = "전력 계약종별" # 로직 입력 — 품셈·계약예규에 계약종별 규정 없음
|
||||
_UNITS = {"ea": "개", "ton": "톤", "t": "톤", "l": "ℓ", "리터": "ℓ", "set": "조", "세트": "조"}
|
||||
|
||||
|
||||
def plain(s) -> str:
|
||||
@@ -41,8 +42,7 @@ def plain_spec(s) -> str:
|
||||
|
||||
|
||||
def plain_unit(s) -> str:
|
||||
s = plain(s).replace("ℓ", "l")
|
||||
return _UNITS.get(s, s)
|
||||
return mu.plain(s) # 별칭 · 맞대기 꼴 한 벌(712-2)
|
||||
|
||||
|
||||
def _offers(master: mf.Master, key: str) -> list[tuple[Decimal, str]]:
|
||||
|
||||
@@ -9,18 +9,10 @@ from __future__ import annotations
|
||||
|
||||
from contextlib import contextmanager
|
||||
from contextvars import ContextVar
|
||||
from decimal import Decimal
|
||||
|
||||
import master_material as mm
|
||||
from master_unit import UNITS, convert, kind_of, same_kind # noqa: F401 — 단위 한 벌(712-2)
|
||||
|
||||
# 「같은 성격」 단위 — {성격: {정규 단위: 기준 단위 몇 배}} (키는 `plain_unit` 을 거친 꼴)
|
||||
UNITS = {
|
||||
"무게": {"g": 1, "kg": 1000, "톤": 1000000},
|
||||
"길이": {"mm": 1, "cm": 10, "m": 1000, "km": 1000000},
|
||||
"넓이": {"cm2": 1, "m2": 10000},
|
||||
"부피": {"cm3": 1, "ℓ": 1000, "m3": 1000000},
|
||||
}
|
||||
_SCALE = {u: (kind, n) for kind, group in UNITS.items() for u, n in group.items()}
|
||||
REGIONS = (
|
||||
"전국평균 전국 서울 경기 강원 충북 충남 전북 전남 경북 경남 부산 대구 인천 대전 울산 세종 제주 광주"
|
||||
" 전남광주"
|
||||
@@ -60,27 +52,6 @@ def machine(spec: dict, value) -> bool:
|
||||
return bool(picks) and str(value).strip() in known and all(str(o) in known for o in picks)
|
||||
|
||||
|
||||
# ── 단위 ───────────────────────────────────────────────────────────────
|
||||
def kind_of(unit) -> str | None:
|
||||
got = _SCALE.get(mm.plain_unit(unit))
|
||||
return got[0] if got else None
|
||||
|
||||
|
||||
def same_kind(a, b) -> bool:
|
||||
"""단위가 같거나 같은 성격(무게 · 길이 · 넓이 · 부피)."""
|
||||
pa, pb = mm.plain_unit(a), mm.plain_unit(b)
|
||||
return pa == pb or (kind_of(a) is not None and kind_of(a) == kind_of(b))
|
||||
|
||||
|
||||
def convert(price, have, want):
|
||||
"""`have` 단위 단가 → `want` 단위 단가 — 같은 성격이 아니면 그대로."""
|
||||
if price is None or not want or mm.plain_unit(have) == mm.plain_unit(want):
|
||||
return price
|
||||
if not same_kind(have, want) or kind_of(have) is None:
|
||||
return price
|
||||
return price * Decimal(_SCALE[mm.plain_unit(want)][1]) / Decimal(_SCALE[mm.plain_unit(have)][1])
|
||||
|
||||
|
||||
# ── 찾기 ───────────────────────────────────────────────────────────────
|
||||
def _words(term) -> tuple[list[str], list[str]]:
|
||||
"""(이름·규격에 든 낱말, 지역 낱말) — 지역 낱말은 고르는 차례에만 쓰임."""
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""계산서 줄 덧칸 — 「쓰는 입력」 · 묶음 · 기준단위 · 환산 근거(PLAN 714-1 · 요구서 4장 5 · 8).
|
||||
|
||||
`master_text.lines` 답(비목 묶음 줄)에 줄마다 덧칸을 얹음 — M01 `/text` 와 M02 `/logic-lib/sheet`
|
||||
두 길이 같은 함수(`annotate`)를 거쳐 같은 줄 꼴(`KEYS`)을 냄. 셈은 `master_formula.run` 한 벌.
|
||||
- `쓰는입력` = 그 줄 값(수량 식 · 요소 · 덧줄 식)에 쓰인 로직 입력 이름 — 중간값을 거슬러 감 ·
|
||||
덧줄이 부르는 비목 합 · 줄 금액은 거스르지 않음(율 식에 직접 쓴 입력만).
|
||||
- `묶음` · `장비` = 712-1 `master_bundle`(엔진 줄 덧칸 그대로).
|
||||
- `펼침`(장비 줄만 · 714-1b) = 부른 기계경비 로직의 계산서 줄(조종원 · 주연료 · 잡재료비 · 기계손료 …) ·
|
||||
시간당 비목 소계 · 계 · 부른 키 · 인자 — 셈 결과를 그대로 실음(환산 규칙을 여기 두지 않음).
|
||||
- `기준단위` = 그 줄 수량 식이 찾는 표의 기준(「10㎡」 · 712-3 `master_basis.basis`) · 답 머리
|
||||
`기준단위` = 로직 결과단위 분모(「10㎡」 · 여럿이면 「㎡ · ㎥」).
|
||||
- `환산근거` = 표 기준배 ↔ 결과단위 배(「10㎡당 → 1㎡당 ÷10」) · 재료 품목 단위 ↔ 줄 단위
|
||||
(712-2 `master_unit.note`) · 없으면 「」.
|
||||
줄 차례 맞춤 = `master_text.lines` 와 같은 차례(호표 줄마다 `_shares` 비목 · 뒤 덧줄).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from decimal import Decimal
|
||||
|
||||
import master_basis as mbs
|
||||
import master_bundle as mb
|
||||
import master_formula as mf
|
||||
import master_text as mtx
|
||||
import master_unit as mu
|
||||
|
||||
KEYS = ("쓰는입력", "묶음", "장비", "기준단위", "환산근거", "펼침")
|
||||
_HOLE = re.compile(r"\{([^{}]+)\}")
|
||||
_ERRORS = (mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError)
|
||||
|
||||
|
||||
def _names(expr) -> tuple[set[str], set[str]]:
|
||||
"""식 하나의 (이름 · 찾는 표 키) — `{입력}` 구멍 이름도 셈."""
|
||||
if isinstance(expr, dict): # 재료 고르기 조건 묶음 — 값 글의 구멍만
|
||||
return {h for v in expr.values() for h in _HOLE.findall(str(v))}, set()
|
||||
if expr in (None, ""):
|
||||
return set(), set()
|
||||
try:
|
||||
tree = mf.parse(expr)
|
||||
except mf.FormulaError:
|
||||
return set(_HOLE.findall(str(expr))), set()
|
||||
names, tables = set(), set()
|
||||
for node in mf.walk(tree):
|
||||
if node[0] == "name":
|
||||
names.add(node[1])
|
||||
elif node[0] in ("find", "logic", "ref"):
|
||||
names |= set(_HOLE.findall(node[1]))
|
||||
if node[0] == "find":
|
||||
tables.add(node[1])
|
||||
return names, tables
|
||||
|
||||
|
||||
def _closure(row: dict, exprs: list) -> tuple[list[str], list[str]]:
|
||||
"""식들이 쓰는 (입력 이름 · 표 키) — 중간값은 그 식으로 거슬러 감 · 입력 차례대로."""
|
||||
steps = {s["이름"]: s["식"] for s in row.get("중간", [])}
|
||||
inputs = [s["이름"] for s in row.get("입력", [])]
|
||||
seen, tables, todo = set(), [], list(exprs)
|
||||
while todo:
|
||||
names, found = _names(todo.pop())
|
||||
tables += [t for t in sorted(found) if t not in tables]
|
||||
for name in names - seen:
|
||||
seen.add(name)
|
||||
if name in steps:
|
||||
todo.append(steps[name])
|
||||
return [n for n in inputs if n in seen], tables
|
||||
|
||||
|
||||
def _times(x: Decimal) -> str:
|
||||
return format(x.normalize(), "f")
|
||||
|
||||
|
||||
def _per_text(per: list[tuple[Decimal, str]]) -> str:
|
||||
return " · ".join(dict.fromkeys(f"{_times(n) if n != 1 else ''}{u}" for n, u in per))
|
||||
|
||||
|
||||
def _basis(master, tables: list[str], env: dict) -> dict | None:
|
||||
for ref in tables:
|
||||
try:
|
||||
got = mbs.basis(master.get(mf.fill(ref, env) if "{" in ref else ref))
|
||||
except (*_ERRORS, mf.EmptyInput):
|
||||
continue
|
||||
if got["갈래"] == "단위":
|
||||
return got
|
||||
return None
|
||||
|
||||
|
||||
def _note(basis: dict | None, per: list, line: dict) -> str:
|
||||
notes = []
|
||||
if basis and len(per) == 1:
|
||||
times, one = per[0]
|
||||
if mu.plain(basis["기준단위"]) == mu.plain(mbs.unit(one)) and basis["기준배"] != times:
|
||||
ratio = basis["기준배"] / times
|
||||
how = f"÷{_times(ratio)}" if ratio > 1 else f"×{_times(1 / ratio)}"
|
||||
notes.append(
|
||||
f"{_times(basis['기준배'])}{basis['기준단위']}당 → {_times(times)}{one}당 {how}"
|
||||
)
|
||||
goods = line.get("품목") or {}
|
||||
if goods.get("단위") and line.get("단위"):
|
||||
notes.append(mu.note(goods["단위"], line["단위"]))
|
||||
return " · ".join(n for n in notes if n)
|
||||
|
||||
|
||||
def _fields(master, row, exprs, env, per, line: dict, bundle: tuple[str, bool] | None) -> dict:
|
||||
uses, tables = _closure(row, exprs)
|
||||
basis = _basis(master, tables, env)
|
||||
out = {
|
||||
"쓰는입력": uses,
|
||||
"기준단위": f"{_times(basis['기준배']) if basis['기준배'] != 1 else ''}{basis['기준단위']}"
|
||||
if basis
|
||||
else None,
|
||||
"환산근거": _note(basis, per, line),
|
||||
}
|
||||
if bundle:
|
||||
out["묶음"] = bundle[0]
|
||||
if bundle[1]:
|
||||
out["장비"] = True
|
||||
return out
|
||||
|
||||
|
||||
def _spread(files: dict, master, item: dict, env: dict) -> dict:
|
||||
"""장비 줄이 부른 기계경비 로직 한 장 — `{키, 이름, 단위, 입력, 줄, 소계, 계}` · 못 셈 = 까닭."""
|
||||
node = mf.parse(item["요소"])
|
||||
key = node[1]
|
||||
try:
|
||||
key = mf.fill(key, env) if "{" in key else key
|
||||
given = {k: mf.evaluate(v, env, master, 1) for k, v in node[2]}
|
||||
got = mtx.lines(files, key, given)
|
||||
except (*_ERRORS, mf.EmptyInput) as e:
|
||||
return {"키": key, "까닭": str(e) or type(e).__name__}
|
||||
if not got.get("ok"):
|
||||
return {"키": key, "입력": given, "까닭": got.get("reason")}
|
||||
groups = got.get("groups") or []
|
||||
return {
|
||||
"키": key,
|
||||
"이름": master.logic(key).get("이름") or "",
|
||||
"단위": _per_text(mbs.result_per(master.logic(key).get("결과단위"))) or None,
|
||||
"입력": given,
|
||||
"줄": [
|
||||
{"비목": g["비목"], **{k: one.get(k) for k in ("이름", "글", "금액")}}
|
||||
for g in groups
|
||||
for one in g["줄"]
|
||||
],
|
||||
"소계": {g["비목"]: g.get("소계") for g in groups},
|
||||
"계": got.get("계"),
|
||||
}
|
||||
|
||||
|
||||
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)
|
||||
row = master.logic(key)
|
||||
per = mbs.result_per(row.get("결과단위"))
|
||||
answer["기준단위"] = _per_text(per) or None
|
||||
try:
|
||||
env = mtx._env_of(master, row, given)
|
||||
except _ERRORS:
|
||||
env = {}
|
||||
if "결과" in row:
|
||||
for line in answer["groups"][0]["줄"]:
|
||||
line.update(_fields(master, row, [row["결과"]["식"]], env, per, {}, None))
|
||||
return answer
|
||||
try:
|
||||
result = mf.run(master, key, given)
|
||||
except _ERRORS:
|
||||
result = None
|
||||
slots = {g["비목"]: iter(g["줄"]) for g in answer["groups"]}
|
||||
items = row.get("호표") or []
|
||||
for n, item in enumerate(items):
|
||||
line = result["줄"][n] if result else {}
|
||||
if result:
|
||||
parts = [p for p, _ in mtx._shares(line)]
|
||||
elif item.get("종류") == "로직" and "비목" not in item:
|
||||
parts = list(mf.COST_ITEMS)
|
||||
else:
|
||||
parts = [item.get("비목")]
|
||||
exprs = [item.get("수량"), item.get("요소")]
|
||||
got = _fields(master, row, exprs, env, per, line, mb.bundle(master, item, env))
|
||||
if got.get("장비"):
|
||||
got["펼침"] = _spread(files, master, item, env)
|
||||
for part in parts:
|
||||
target = next(slots.get(part, iter(())), None)
|
||||
if target is not None:
|
||||
target.update(got)
|
||||
for extra in row.get("덧줄") or []:
|
||||
target = next(slots.get(extra["비목"], iter(())), None)
|
||||
if target is not None:
|
||||
target.update(
|
||||
_fields(master, row, [extra["식"]], env, per, {}, mb.bundle(master, extra))
|
||||
)
|
||||
return answer
|
||||
@@ -0,0 +1,121 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""단위 한 벌 — 별칭 · 개수 묶음 · 결과단위 배 읽기 · 같은 성격 환산 · 환산 근거 글(PLAN 712-2).
|
||||
|
||||
쓰는 곳: 재료 단가 → 줄 단위(`master_pick`) · 자재 맞대기 별칭(`master_material.plain_unit`) ·
|
||||
조합 알약 배(`M01_MasterData_Store_Combo`) · 엑셀 맞춤 배(`M02_Hopyo_Ours` · `_Match`) ·
|
||||
표 기준배(`master_basis`) · 계산서 줄 환산 근거(714-1). 서버 단독(③) — TS 는 서버가 준 글만 씀.
|
||||
- 개수 묶음(개 · EA · 본 · 매) = 1 : 1 같은 성격 · 조 · 세트는 묶음 밖(한 조 = 여럿).
|
||||
- 부피 ↔ 무게 = 단위중량 표 CC000337 에서 고른 줄(`unit_weight`)을 받을 때만 · 기본 줄 없음
|
||||
(사용자 확인 거리) · 밀도(kg ↔ ℓ) · ㎾h 등은 환산 없이 경고 글만.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import unicodedata
|
||||
from decimal import Decimal
|
||||
|
||||
# 맞대기 꼴(`plain`) 별칭 — NFKC · 소문자 · 빈칸 없앤 뒤
|
||||
ALIAS = {"ea": "개", "ton": "톤", "t": "톤", "l": "ℓ", "리터": "ℓ", "set": "조", "세트": "조"}
|
||||
# 보이는 꼴(`shown`) 별칭 — 표 기준 · 결과단위 분모(원문 기호 ㎡ · ㎥ 그대로)
|
||||
SHOWN = {"ea": "개", "톤": "ton", "t": "ton", "㎞": "km", "hr": "시간", "min": "분", "㎏": "kg"}
|
||||
# 같은 성격 — {성격: {맞대기 단위: 기준 단위 몇 배}}
|
||||
UNITS = {
|
||||
"무게": {"g": 1, "kg": 1000, "톤": 1000000},
|
||||
"길이": {"mm": 1, "cm": 10, "m": 1000, "km": 1000000},
|
||||
"넓이": {"cm2": 1, "m2": 10000},
|
||||
"부피": {"cm3": 1, "ℓ": 1000, "m3": 1000000},
|
||||
"개수": {"개": 1, "본": 1, "매": 1},
|
||||
}
|
||||
_SCALE = {u: (kind, n) for kind, group in UNITS.items() for u, n in group.items()}
|
||||
WEIGHT_TABLE = "CC000337" # 건설품셈 공통 1-2-4 재료의 단위중량(㎏/㎥)
|
||||
# 결과단위 분모 — 「원/10㎡」 → (10, ㎡) · 「원/1,000본」 → (1000, 본)
|
||||
_PER = re.compile(r"원\s*/\s*(\d[\d,]*\.?\d*|\.\d+)?\s*([^\s·(\d,.][^\s·(]*)")
|
||||
|
||||
|
||||
def plain(unit) -> str:
|
||||
"""맞대기 꼴 — 「㎥」 = 「m3」 · 「EA」 = 「개」 · 「Ton」 = 「톤」 · 「리터」 = 「ℓ」."""
|
||||
s = re.sub(r"\s+", "", unicodedata.normalize("NFKC", str(unit or "")).lower())
|
||||
s = s.replace("ℓ", "l")
|
||||
return ALIAS.get(s, s)
|
||||
|
||||
|
||||
def shown(unit) -> str:
|
||||
return SHOWN.get(unit, unit)
|
||||
|
||||
|
||||
def per(result_unit) -> list[tuple[Decimal, str]]:
|
||||
"""결과단위 분모마다 (배, 단위 원문 글) — 배 없으면 1 · 분모가 여럿이면 여럿."""
|
||||
return [
|
||||
(Decimal(n.replace(",", "")) if n else Decimal(1), u)
|
||||
for n, u in _PER.findall(str(result_unit or ""))
|
||||
]
|
||||
|
||||
|
||||
def kind_of(unit) -> str | None:
|
||||
got = _SCALE.get(plain(unit))
|
||||
return got[0] if got else None
|
||||
|
||||
|
||||
def same_kind(a, b) -> bool:
|
||||
"""단위가 같거나 같은 성격(무게 · 길이 · 넓이 · 부피 · 개수)."""
|
||||
return plain(a) == plain(b) or (kind_of(a) is not None and kind_of(a) == kind_of(b))
|
||||
|
||||
|
||||
def _weighs(a, b) -> bool:
|
||||
return {kind_of(a), kind_of(b)} == {"무게", "부피"}
|
||||
|
||||
|
||||
def factor(have, want, density=None) -> Decimal | None:
|
||||
"""`have` 1 단위가 `want` 몇 단위인가 — 단가는 ÷, 수량은 ×.
|
||||
부피 ↔ 무게는 단위중량(㎏/㎥) `density` 가 있을 때만 · 못 하면 None."""
|
||||
a, b = plain(have), plain(want)
|
||||
if a == b:
|
||||
return Decimal(1)
|
||||
if same_kind(a, b):
|
||||
return Decimal(_SCALE[a][1]) / Decimal(_SCALE[b][1])
|
||||
if density and _weighs(a, b):
|
||||
grams_per_cm3 = Decimal(str(density)) / Decimal(1000)
|
||||
base = Decimal(_SCALE[a][1]) * (
|
||||
grams_per_cm3 if kind_of(a) == "부피" else 1 / grams_per_cm3
|
||||
)
|
||||
return base / Decimal(_SCALE[b][1])
|
||||
return None
|
||||
|
||||
|
||||
def convert(price, have, want, density=None):
|
||||
"""`have` 단위 단가 → `want` 단위 단가 — 못 바꾸면 그대로(경고는 부르는 쪽)."""
|
||||
if price is None or not want or not have or plain(have) == plain(want):
|
||||
return price
|
||||
if same_kind(have, want): # × 뒤 ÷ — 옛 셈과 같은 Decimal 꼴(자릿수)
|
||||
return price * Decimal(_SCALE[plain(want)][1]) / Decimal(_SCALE[plain(have)][1])
|
||||
got = factor(want, have, density) # want 1 단위 = have 몇 단위
|
||||
return price if got is None or got == 1 else price * got
|
||||
|
||||
|
||||
def note(have, want, density=None) -> str:
|
||||
"""환산 근거 글 — 「kg → 톤 ×1000」 · 「본 = 개(개수 1:1)」 · 못 바꾸면 까닭."""
|
||||
if not have or not want or plain(have) == plain(want):
|
||||
return ""
|
||||
got = factor(want, have, density)
|
||||
if got is not None:
|
||||
if kind_of(have) == "개수" and got == 1:
|
||||
return f"{have} = {want}(개수 1:1)"
|
||||
how = f" · 단위중량 {density}㎏/㎥" if _weighs(have, want) else ""
|
||||
return f"{have} → {want} ×{_num(got)}{how}"
|
||||
if _weighs(have, want):
|
||||
return f"{have} ↔ {want} — 단위중량({WEIGHT_TABLE}) 골라야 환산 · 지금 그대로"
|
||||
return f"{have} ↔ {want} — 성격 다름 · 환산 없음(경고)"
|
||||
|
||||
|
||||
def _num(x: Decimal) -> str:
|
||||
return format(x.normalize(), "f") if x == x.to_integral_value() else format(x, "f")[:10]
|
||||
|
||||
|
||||
def unit_weight(master, kind: str, shape: str, end: str = "위") -> Decimal | None:
|
||||
"""단위중량 표에서 고른 줄의 ㎏/㎥ — 기본 줄 없음(고르지 않으면 None)."""
|
||||
for row in master.get(WEIGHT_TABLE).get("줄") or []:
|
||||
if row.get("종별") == kind and row.get("형상") == shape:
|
||||
got = row.get(end)
|
||||
return None if got is None else Decimal(str(got))
|
||||
return None
|
||||
@@ -0,0 +1,47 @@
|
||||
/* 717-1 헬퍼 — 상세 부품 길 주입 · 가짜 fetch 로 부른 주소만 모음. 짝 = `test_717_1_logic_lib.py`. */
|
||||
const path = require("path");
|
||||
const { makeLoader } = require("./helper_ts_stub_loader.cjs");
|
||||
|
||||
const ROOT = path.join(__dirname, "..", "..");
|
||||
const { load } = makeLoader({
|
||||
root: ROOT,
|
||||
stubs: { M01_MasterData_UI_Logic_Edit: {}, M01_MasterData_UI_Logic_Text: {} },
|
||||
});
|
||||
const urls = [];
|
||||
global.fetch = async (url) => {
|
||||
urls.push(String(url).replace("http://fake", ""));
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: "",
|
||||
json: async () => ({ logics: [], subs: [], items: [], rows: [] }),
|
||||
};
|
||||
};
|
||||
|
||||
const api = load("M01_MasterData", "M01_MasterData_UI_Logic_Api.ts");
|
||||
const rows = load("M01_MasterData", "M01_MasterData_Api_Fetch.ts");
|
||||
const note = load("M01_MasterData", "M01_MasterData_UI_Logic_Note.ts");
|
||||
|
||||
async function round(tag) {
|
||||
await api.fetchLogic("K");
|
||||
await api.fetchAuto("K");
|
||||
await api.searchElements("재료", "q");
|
||||
await api.fetchMaterials({ sub: "s" });
|
||||
await api.runCalc({ key: "K", inputs: {} });
|
||||
await api.runText({ key: "K", inputs: {} });
|
||||
await rows.fetchRows("기계.json", 1, 5, "q");
|
||||
await rows.fetchPick("price", "q");
|
||||
await note.loadTable(`T-${tag}`);
|
||||
await rows.fetchGroups(); // 관리자 길 — 늘 /m01
|
||||
await api.saveFiles([]); // 저장 — 늘 /m01
|
||||
return urls.splice(0).map((u) => u.split("?")[0]);
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const out = { before: await round("a") };
|
||||
api.setLogicApiPaths({ read: "/m02/logic-lib", calc: "/m02/calc-x" });
|
||||
out.injected = await round("b");
|
||||
api.setLogicApiPaths();
|
||||
out.after = await round("c");
|
||||
process.stdout.write(JSON.stringify(out));
|
||||
})();
|
||||
@@ -1,5 +1,5 @@
|
||||
/* M02 일위대가 사전조합 층(134-3) 헬퍼 — `M02_MasterTemplete_Api_Fetch.ts` 명시 owner 길 ·
|
||||
* `M02_MasterTemplete_UI_Side_Precombo.ts` `buildPrecomboFolders`(158-1 자유 폴더) 를 트랜스파일해 Node 로 돌림 ·
|
||||
* `M02_MasterTemplete_UI_Side_Library.ts` `buildLibraryFolders`(158-1 자유 폴더 · 716-4) 를 트랜스파일해 Node 로 돌림 ·
|
||||
* `precomboClasses`(134-5 · 구조물군 › 종류 + 공통) · fetch 대역이 부른 주소를 모음 ·
|
||||
* stdin = {rows, def?, view?} · stdout = {urls, tree, classes} JSON. */
|
||||
const fs = require("fs");
|
||||
@@ -31,6 +31,7 @@ global.fetch = async (url) => {
|
||||
const side = loadTs(
|
||||
path.join(ROOT, "M02_MasterTemplete", "M02_MasterTemplete_UI_Side_Precombo.ts"),
|
||||
);
|
||||
const lib = loadTs(path.join(ROOT, "M02_MasterTemplete", "M02_MasterTemplete_UI_Side_Library.ts"));
|
||||
api.setActiveLayer("personal", "7/99"); // 페이지 = 남의 개인을 보는 중
|
||||
await api.readTemplate("personal", "labor", "x1", null); // 페이지 층 = 그 사람
|
||||
await api.readTemplate("personal", "precombo", "SC-000001", null, null); // 사전조합 = 내 개인
|
||||
@@ -38,11 +39,11 @@ global.fetch = async (url) => {
|
||||
await api.saveTemplate("system", "precombo", "SC-000002", null, "v", {}, null);
|
||||
const shape = (f) => ({
|
||||
path: f.path,
|
||||
count: side.docCount(f),
|
||||
count: lib.docCount(f),
|
||||
docs: f.docs.map((r) => r.이름),
|
||||
folders: f.folders.map(shape),
|
||||
});
|
||||
const tree = shape(side.buildPrecomboFolders(input.rows, input.view ?? { 폴더: [], 자리: {} }));
|
||||
const tree = shape(lib.buildLibraryFolders(input.rows, input.view ?? { 폴더: [], 자리: {} }));
|
||||
const classes = input.def ? side.precomboClasses(input.def) : null; // 134-5
|
||||
process.stdout.write(JSON.stringify({ urls, tree, classes }));
|
||||
})();
|
||||
|
||||
@@ -111,7 +111,8 @@ def test_글_속_금액은_산근_풀이_값과_같음():
|
||||
whole = item_prices.load_prices(item_prices.to_json(item_prices.collect([code])))
|
||||
sheets, price_of = _solver_prices(whole, code, given)
|
||||
block = sg.Solver(sheets).add(whole, {"code": code, "given": given}, "s1")
|
||||
times = sg._unit(code)[1] # 결과단위 배(「원/1,000㎡」) — 산근 풀이와 같은 1 단위
|
||||
# 결과단위 배(「원/1,000㎡」) — 산근 풀이와 같은 1 단위
|
||||
times = sg._unit(whole.logic(code))[1]
|
||||
got = st.render_sangeun_text(whole, code, given, price_of, times=times)
|
||||
parts = " + ".join(f"{st.PART_NAME[p]} {block[p]:,.0f}" for p in sg.PARTS)
|
||||
assert got[-1] == f"계 : {parts} = {block['sum']:,.0f}", code
|
||||
|
||||
@@ -30,7 +30,7 @@ SYS = {"층": "system", "주인": None}
|
||||
|
||||
def _save(folder, doc):
|
||||
with store.at(folder):
|
||||
key = refs.create("precombo", {"이름": "자재 조합", **doc})["이름"]
|
||||
key = refs.create("precombo", {"이름": "자재 조합", "단위": "㎡", **doc})["이름"]
|
||||
return key, json.loads((folder / "precombo" / f"{key}.json").read_bytes())
|
||||
|
||||
|
||||
@@ -82,9 +82,9 @@ def _world_combo(world): # noqa: F811
|
||||
# MAT = 직접계산(222-1 · 기본 별도계상이면 셈에서 빠짐)
|
||||
mats = [{"코드": MAT, "수량": {"손": 2}, "별도계상": "직접계산"}, {"코드": WIRE, "수량": None}]
|
||||
with store.at(world["system"]):
|
||||
key = refs.create("precombo", {"이름": "자재 조합", "담은로직": lines, "자재": mats})[
|
||||
"이름"
|
||||
]
|
||||
key = refs.create(
|
||||
"precombo", {"이름": "자재 조합", "단위": "㎡", "담은로직": lines, "자재": mats}
|
||||
)["이름"]
|
||||
stone = Decimal(str(labor.run_line(m01.loaded()[1], STONE, {})["기준"]["계"]))
|
||||
return key, stone + _price(MAT) * 2
|
||||
|
||||
@@ -114,7 +114,11 @@ def test_B08_참조_줄_재료에_사전조합_자재_손_곱(world): # noqa: F
|
||||
with store.at(world["system"]):
|
||||
outer = refs.create(
|
||||
"precombo",
|
||||
{"이름": "겹", "담은로직": [{"참조": {**SYS, "키": key}, "수량": {"손": 3}}]},
|
||||
{
|
||||
"이름": "겹",
|
||||
"단위": "㎡",
|
||||
"담은로직": [{"참조": {**SYS, "키": key}, "수량": {"손": 3}}],
|
||||
},
|
||||
)["이름"]
|
||||
got = _ref_mats(m01.loaded()[1], refs.read_ref, {**SYS, "키": outer})
|
||||
mats = [(m["품목"]["키"], q) for m, q in got if m.get("자재")]
|
||||
|
||||
@@ -49,16 +49,17 @@ def test_줄_해시_옵션은_M01():
|
||||
from test_b01_settings_cards import _cards
|
||||
|
||||
cards = _cards({"role": "SYSTEM_ADMIN", "is_master": False}, 3)
|
||||
heads = [c[2].split("?")[0] + "?" + c[2].split("?")[1].split("&")[0] for c in cards[1:7]]
|
||||
heads = [c[2].split("?")[0] + "?" + c[2].split("?")[1].split("&")[0] for c in cards[1:8]]
|
||||
assert heads == [
|
||||
"m01-master-data?open=로직",
|
||||
"m01-master-data?open=옵션",
|
||||
"m01-master-data?open=별칭", # 718-3
|
||||
"m02-master-template?open=단가산출", # 717-2a
|
||||
"m02-master-template?open=일위대가조합",
|
||||
"m02-master-template?open=구조물조합",
|
||||
"m02-master-template?open=도면",
|
||||
]
|
||||
assert all("&root=master" in c[2] for c in cards[4:7])
|
||||
assert all("&root=master" in c[2] for c in cards[4:8])
|
||||
|
||||
|
||||
@needs_node
|
||||
@@ -77,25 +78,22 @@ def test_해시_진입_셋():
|
||||
+ fn.group(0).replace("export function", "function")
|
||||
+ "\nconsole.log(JSON.stringify(entryFromHash(null)));"
|
||||
)
|
||||
for want in ("구조물조합", "옵션", "도면", "일위대가조합"):
|
||||
for want in ("구조물조합", "옵션", "도면", "일위대가조합", "단가산출"):
|
||||
got = _run(script, f"open={want}&root=master&company=3")
|
||||
assert got["open"] == want and got["root"] == "master" and got["company"] == "3"
|
||||
|
||||
|
||||
def test_reveal_옵션만_줄을_누름():
|
||||
def test_reveal_옵션은_M01_로():
|
||||
side = _text(M02 / "M02_MasterTemplete_UI_Side.ts")
|
||||
body = side.split("const reveal = ")[1].split("\n };")[0]
|
||||
assert "if (!sec) return;" in body # 시스템 층 아니면 옵션 · 도면 줄 없음
|
||||
assert 'sec.classList.remove("is-collapsed");' in body # 도면 = 펼치기만
|
||||
# 716-1 · 716-3 · 717-2a — 옵션 정의는 M01 로 옮김 · M02 reveal 은 단가산출 · 일위대가 · 도면만 펼침
|
||||
flat = " ".join(body.split())
|
||||
assert (
|
||||
'if (target === "옵션") options.body.querySelector<HTMLElement>("button")?.click();' in body
|
||||
)
|
||||
# 옵션 컨테이너 안 단추 = 「구조물 옵션 정의」 줄 하나뿐
|
||||
options = _text(M02 / "StructOptions" / "M02_SO_Options.ts")
|
||||
assert options.count('el("button"') == 1
|
||||
assert "openDefScreen" in options
|
||||
# 옵션 컨테이너 = 구조물 옵션 정의 줄 · 시스템 층에만
|
||||
assert "옵션: section(options.title, options.body)," in side
|
||||
'target === "단가산출" ? sangeunSec : target === "일위대가조합" ? precomboSec'
|
||||
' : target === "도면" ? drawing : null'
|
||||
) in flat
|
||||
assert 'sec?.classList.remove("is-collapsed");' in body
|
||||
assert "options" not in body and "옵션: section(" not in side
|
||||
page = _text(M02 / "M02_MasterTemplete_UI_Page.ts")
|
||||
assert 'if (entry.open !== "구조물조합") side.reveal(entry.open);' in page
|
||||
|
||||
|
||||
@@ -62,7 +62,7 @@ def world(tmp_path, monkeypatch):
|
||||
company = layers.company_dir(7)
|
||||
company.mkdir(parents=True)
|
||||
with store.at(company):
|
||||
other = refs.create("precombo", {"이름": "회사 조합", "담은로직": lines[:1]})["이름"]
|
||||
other = refs.create("precombo", {"이름": "회사 조합", "단위": "㎡", "담은로직": lines[:1]})["이름"]
|
||||
project = tmp_path / "storage" / "7" / "1" / "proj"
|
||||
project.mkdir(parents=True)
|
||||
return {"system": system, "key": key, "company": other, "root": project}
|
||||
|
||||
@@ -120,7 +120,8 @@ def test_화면_기준_목록_계산서_담기() -> None:
|
||||
assert "openStandardSheet(row, () => {\n close();\n done(logic);" in pick
|
||||
assert "(!work.select.value || x.공종 === work.select.value)" in pick
|
||||
sheet = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_Labor_Sheet.ts").read_text("utf-8")
|
||||
assert 'from "@ui/ui_template_calc_row"' in sheet and "createSourceView(" in sheet
|
||||
# 줄 · 소계 · 계 = 공용 단가산출 계산서(714-2 · 계산 줄 부품을 그 안에서 씀)
|
||||
assert 'from "../A00_Common/calc_sheet"' in sheet and "createSourceView(" in sheet
|
||||
for name in ("Labor_Pick", "Labor_Sheet"):
|
||||
text = (ROOT / f"M02_MasterTemplete/M02_MasterTemplete_{name}.ts").read_text("utf-8")
|
||||
assert len(text.splitlines()) <= 700
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
"""712-1 지킴 — 인력 · 재료 · 경비 묶음 셈 때 파생 · 기계경비 판별 한 곳(`master_bundle`)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT / "resources/master_data/scripts"))
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
import check_master as cm # noqa: E402
|
||||
import master_auto as mau # noqa: E402
|
||||
import master_bundle as mb # noqa: E402
|
||||
import master_formula as mf # noqa: E402
|
||||
|
||||
FILES = cm.load()
|
||||
MASTER = mf.Master(FILES)
|
||||
LOGICS = {r["키"]: r for d in FILES.values() if d.get("그룹") == "로직" for r in d.get("줄") or []}
|
||||
|
||||
|
||||
def test_every_line_bundled():
|
||||
"""호표 · 덧줄 전수 — 하위(못 가름) ≤ 31 · 나머지는 인력 · 재료 · 경비 · 기계경비 줄 = 경비."""
|
||||
count = dict.fromkeys(mb.BUNDLES, 0)
|
||||
machine = 0
|
||||
for row in LOGICS.values():
|
||||
for item in [*row.get("호표", []), *row.get("덧줄", [])]:
|
||||
name, is_mach = mb.bundle(MASTER, item)
|
||||
count[name] += 1
|
||||
machine += is_mach
|
||||
if is_mach:
|
||||
assert name == "경비"
|
||||
assert sum(count.values()) > 5_000
|
||||
assert count["하위"] <= 31, count
|
||||
assert machine >= 1_000, machine
|
||||
|
||||
|
||||
def test_machine_rule_one_place():
|
||||
"""기계경비 = 결과단위 원/시간 돈 로직 — 작업량(㎥/시간) · 이름 「시간당」 은 아님."""
|
||||
machines = {k for k, r in LOGICS.items() if mb.is_machine(r)}
|
||||
assert {"GC000267", "GC000268", "GF000145"} <= machines
|
||||
assert not machines & {"GF000102", "GC000422", "GC000260"} # 작업량 · ㎡/시간 · 원/㎡
|
||||
sys.path.insert(0, str(ROOT))
|
||||
from M02_MasterTemplete import M02_Hopyo_Ours as ours
|
||||
|
||||
assert all(ours.is_mach_logic(r) == mb.is_machine(r) for r in LOGICS.values())
|
||||
|
||||
|
||||
def test_run_lines_tagged():
|
||||
"""엔진 `run` 줄마다 `묶음` · 기계경비 줄은 `장비` + 비목 노 · 재 · 경 세 몫."""
|
||||
seen = set()
|
||||
for key in ("GF000169", "GF000103"):
|
||||
row = LOGICS[key]
|
||||
given = {k: v for k, v in mau.values(MASTER, row).items() if v is not None}
|
||||
got = mf.run(MASTER, key, given)
|
||||
for line in got["줄"]:
|
||||
assert line["묶음"] in mb.BUNDLES
|
||||
seen.add(line["묶음"])
|
||||
if line.get("장비"):
|
||||
assert set(line["비목"]) == set(mf.COST_ITEMS)
|
||||
total = sum((line["비목"].get(k) or Decimal(0)) for line in got["줄"] for k in line["비목"])
|
||||
assert abs(total - got["계"]) < 1
|
||||
assert {"인력", "재료", "하위"} <= seen
|
||||
@@ -0,0 +1,59 @@
|
||||
"""712-2 지킴 — 단위 한 벌(`master_unit`) · 다섯 자리가 같은 모듈을 씀 · 알약 배 전수."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT / "resources/master_data/scripts"))
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
import check_master as cm # noqa: E402
|
||||
import master_basis as mbs # noqa: E402
|
||||
import master_formula as mf # noqa: E402
|
||||
import master_material as mm # noqa: E402
|
||||
import master_pick as mp # noqa: E402
|
||||
import master_unit as mu # noqa: E402
|
||||
|
||||
|
||||
def test_alias_and_count_group():
|
||||
assert mu.plain("EA") == mu.plain("ea") == "개"
|
||||
assert mu.plain("Ton") == mu.plain("t") == mu.plain("톤") == "톤"
|
||||
assert mu.plain("㎥") == "m3" and mu.plain("리터") == mu.plain("ℓ") == "ℓ"
|
||||
assert mu.same_kind("본", "개") and mu.same_kind("매", "EA")
|
||||
assert not mu.same_kind("조", "개") # 한 조 = 여럿 — 묶음 밖
|
||||
assert mu.note("본", "개") == "본 = 개(개수 1:1)"
|
||||
assert mm.plain_unit("EA") == mu.plain("EA")
|
||||
assert mp.convert is mu.convert and mp.UNITS is mu.UNITS
|
||||
|
||||
|
||||
def test_same_kind_convert():
|
||||
assert mu.convert(Decimal(1000), "톤", "kg") == Decimal(1) # 톤 당 1000 원 = kg 당 1 원
|
||||
assert mu.convert(Decimal(5), "kg", "톤") == Decimal(5000)
|
||||
assert mu.convert(Decimal(7), "m", "km") == Decimal(7000)
|
||||
assert mu.factor("kg", "ton") == Decimal("0.001") # B08 「/1000」(kg → ton) 손 식과 같은 값
|
||||
assert mu.note("kg", "톤") == "kg → 톤 ×1000"
|
||||
assert mu.convert(Decimal(3), "kg", "ℓ") == Decimal(3) # 밀도 = 경고만 · 그대로
|
||||
assert "성격 다름" in mu.note("ℓ", "㎾h")
|
||||
|
||||
|
||||
def test_volume_weight_only_when_picked():
|
||||
"""부피 ↔ 무게 = 단위중량 표 고른 줄이 있을 때만 · 기본 줄 없음."""
|
||||
master = mf.Master(cm.load())
|
||||
assert mu.factor("㎥", "톤") is None
|
||||
assert "단위중량(CC000337)" in mu.note("㎥", "톤")
|
||||
rho = mu.unit_weight(master, "모래", "건조")
|
||||
assert rho is not None and rho > 1000
|
||||
assert mu.factor("㎥", "톤", rho) == rho / 1000 # 1 ㎥ = ρ/1000 톤
|
||||
assert mu.convert(Decimal(100), "㎥", "톤", rho) == Decimal(100) * 1000 / rho
|
||||
assert mu.unit_weight(master, "없는 종별", "건조") is None
|
||||
|
||||
|
||||
def test_result_per_one_parser():
|
||||
assert mu.per("원/10㎡") == [(Decimal(10), "㎡")]
|
||||
assert mu.per("원/1,000본") == [(Decimal(1000), "본")]
|
||||
assert mu.per("원/㎡ · 원/㎥(단위 따름)") == [(Decimal(1), "㎡"), (Decimal(1), "㎥")]
|
||||
assert mu.per("㎥/시간") == []
|
||||
assert mbs.result_per("원/ton") == [(Decimal(1), "ton")]
|
||||
@@ -0,0 +1,34 @@
|
||||
"""713-2 정본 개정 확인 창 지킴 시험 — 글로 잡음(화면 · 서버 없음).
|
||||
|
||||
- `/save` 몸에 `개정확인` 이 실림 · 로직 화면 [저장]은 `saveRevised` 한 길(개정 409 → 창 → 다시 보냄)
|
||||
- 창은 부품 상자(모달 · 목록 줄 · 배지)만 · 옛 `stale` 409 는 이 창 대상 밖
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
M01 = Path(__file__).resolve().parents[2] / "M01_MasterData"
|
||||
|
||||
|
||||
def _read(name: str) -> str:
|
||||
return (M01 / f"M01_MasterData_UI_{name}.ts").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_save_body_carries_confirm():
|
||||
api = _read("Logic_Api")
|
||||
assert 'request("/save", { files, 개정확인 })' in api
|
||||
|
||||
|
||||
def test_lab_save_goes_through_revise():
|
||||
lab = _read("LogicLab")
|
||||
assert "saveRevised((ok) => saveFiles(" in lab
|
||||
assert "await saveFiles(" not in lab.replace("saveRevised((ok) => saveFiles(", "")
|
||||
|
||||
|
||||
def test_revise_window_parts_only():
|
||||
rev = _read("LogicLab_Revise")
|
||||
for part in ("ui_template_modal", "ui_template_list_row", "ui_template_badge"):
|
||||
assert f"@ui/{part}" in rev
|
||||
assert "<button" not in rev and "document.createElement" not in rev
|
||||
# 개정 409 만 창 — 옛 stale 409 는 부르는 쪽 토스트 그대로
|
||||
assert "개정?: ReviseHit[]" in rev and "if (!hits) throw error" in rev
|
||||
assert "save(true)" in rev
|
||||
@@ -0,0 +1,75 @@
|
||||
"""714-1 지킴 — 계산서 줄 덧칸(쓰는 입력 · 묶음 · 기준단위 · 환산 근거) · M01 `/text` 와
|
||||
M02 `/logic-lib/sheet` 가 같은 줄 꼴(`master_sheet_line`)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from M01_MasterData import M01_MasterData_Store as store # noqa: E402
|
||||
from M01_MasterData import M01_MasterData_Store_Sheet as sheet # noqa: E402
|
||||
from M02_MasterTemplete import M02_Logic_Standard as standard # noqa: E402
|
||||
|
||||
msl = sheet.msl
|
||||
num = standard.labor_inputs._num # Decimal → JSON 수(두 길 같은 규칙)
|
||||
|
||||
|
||||
def _text(key: str) -> dict:
|
||||
auto = {k: v for k, v in store.auto(key)["값"].items() if v is not None}
|
||||
return sheet.text(key, auto)
|
||||
|
||||
|
||||
def _lines(got: dict, groups: str = "groups") -> list[dict]:
|
||||
return [line for g in got[groups] for line in g["줄"]]
|
||||
|
||||
|
||||
def test_line_keys_and_bundle():
|
||||
got = _text("GF000103") # 구조물터파기 — 인력 + 굴착기 기계경비 줄
|
||||
assert got["ok"] and got["기준단위"] == "㎥"
|
||||
lines = _lines(got)
|
||||
assert all({"쓰는입력", "묶음", "기준단위", "환산근거"} <= set(line) for line in lines)
|
||||
machine = [line for line in lines if line.get("장비")]
|
||||
assert machine and all(line["묶음"] == "경비" for line in machine)
|
||||
assert {"지역", "보정작업"} <= set(machine[0]["쓰는입력"])
|
||||
assert any(line["묶음"] == "인력" for line in lines)
|
||||
|
||||
|
||||
def test_machine_spread():
|
||||
"""714-1b — 장비 줄 「펼침」 = 부른 기계경비 로직 계산서(조종원 · 연료 · 손료) · 시간당 계."""
|
||||
lines = _lines(_text("GF000103"))
|
||||
spread = next(line for line in lines if line.get("장비"))["펼침"]
|
||||
assert spread["키"].startswith("GC0002") and spread["단위"] == "시간"
|
||||
names = " ".join(one["이름"] for one in spread["줄"])
|
||||
assert "조종원" in names and "손료" in names
|
||||
sums = [v for v in spread["소계"].values() if v is not None]
|
||||
assert spread["계"] == sum(sums) and spread["계"] > 0
|
||||
assert not any(line.get("펼침") for line in lines if not line.get("장비"))
|
||||
|
||||
|
||||
def test_uses_follow_middle_values():
|
||||
"""중간값을 거슬러 입력까지 — 돌쌓기 석공 품은 높이 · 쌓기 · 돌 입력을 씀."""
|
||||
lines = _lines(_text("GF000219"))
|
||||
assert {"높이", "쌓기", "돌"} <= set(lines[0]["쓰는입력"])
|
||||
|
||||
|
||||
def test_basis_note_tenfold():
|
||||
"""표 10㎡당 → 결과 원/㎡ 「÷10」 · 결과도 원/10㎡ 면 근거 없음."""
|
||||
one = _lines(_text("GC000037"))
|
||||
assert one[0]["기준단위"] == "10㎡" and one[0]["환산근거"] == "10㎡당 → 1㎡당 ÷10"
|
||||
ten = _text("GF000124")
|
||||
assert ten["기준단위"] == "10㎡" and all(not x["환산근거"] for x in _lines(ten))
|
||||
|
||||
|
||||
def test_two_routes_same_shape():
|
||||
"""M01 `/text` 와 M02 계산서 — 줄 차례 · 덧칸 값이 같음(금액 끝수만 다를 수 있음)."""
|
||||
for key in ("GF000169", "GF000103", "GF000219"):
|
||||
m01 = _lines(_text(key))
|
||||
m02 = _lines(standard.sheet(key, {}), "묶음")
|
||||
assert len(m01) == len(m02)
|
||||
for a, b in zip(m01, m02):
|
||||
assert set(b) == {"이름", "글", "금액", *msl.KEYS}
|
||||
assert a["이름"] == b["이름"]
|
||||
assert num([a.get(k) for k in msl.KEYS]) == [b.get(k) for k in msl.KEYS]
|
||||
@@ -0,0 +1,84 @@
|
||||
"""714-2 공용 단가산출 계산서 지킴 시험 — 글 · 순수 함수(화면 · 서버 없음).
|
||||
|
||||
- 704-2 M02 계산서 보기 · M01 「인력·재료·경비」 보기가 한 벌(`A00_Common/calc_sheet.ts`)을 씀
|
||||
- 계산서는 부품 상자만(날 단추 · 입력 · 표 없음) · 화면에서 다시 셈 안 함(값 = 서버)
|
||||
"""
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
SHEET = ROOT / "A00_Common" / "calc_sheet.ts"
|
||||
|
||||
|
||||
def _text(p: Path) -> str:
|
||||
return p.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_한_벌을_씀():
|
||||
m02 = _text(ROOT / "M02_MasterTemplete" / "M02_MasterTemplete_Labor_Sheet.ts")
|
||||
est = _text(ROOT / "M01_MasterData" / "M01_MasterData_UI_LogicLab_Est.ts")
|
||||
detail = _text(ROOT / "M01_MasterData" / "M01_MasterData_UI_LogicLab_Detail.ts")
|
||||
assert "calcSheetRows(" in m02 and "createCalcFormulaRow" not in m02
|
||||
assert "calcSheetRows(" in est and "buildEst(" in detail
|
||||
|
||||
|
||||
def test_부품_상자만():
|
||||
s = _text(SHEET)
|
||||
assert "<button" not in s and "document.createElement" not in s and "<table" not in s
|
||||
assert "@ui/ui_template_calc_row" in s and "@ui/ui_template_input" in s
|
||||
|
||||
|
||||
@pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음")
|
||||
def test_식_글():
|
||||
s = _text(SHEET)
|
||||
pure = s[s.index("/* ── 순수 셈 ── */") : s.index("/* ── 순수 셈 끝 ── */")]
|
||||
body = 'console.log(JSON.stringify([sheetFormula("268,908 * 0.12 = 32,269"), sheetFormula("59,020 * 1 * 8 / 165 = 2,862"), sheetFormula("")]))'
|
||||
out = subprocess.run(
|
||||
["node", "--experimental-strip-types", "--no-warnings",
|
||||
"--input-type=module-typescript", "-e", pure + "\n" + body],
|
||||
capture_output=True, text=True, encoding="utf-8", timeout=60,
|
||||
) # fmt: skip
|
||||
assert out.returncode == 0, out.stderr
|
||||
assert json.loads(out.stdout) == ["268,908 × 0.12", "59,020 × 1 × 8 / 165", ""]
|
||||
|
||||
|
||||
@pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음")
|
||||
def test_묶음_장비_한_줄():
|
||||
s = _text(SHEET)
|
||||
pure = s[s.index("/* ── 순수 셈 ── */") : s.index("/* ── 순수 셈 끝 ── */")]
|
||||
body = """
|
||||
const eq = (n, a, 쓰는입력 = []) => ({ 이름: n, 글: "", 금액: a, 묶음: "경비", 장비: true, 쓰는입력, 기준단위: "일" });
|
||||
const groups = [
|
||||
{ 비목: "노무비", 소계: 0, 줄: [
|
||||
{ 이름: "포설공", 글: "", 금액: 10, 묶음: "인력", 쓰는입력: [] },
|
||||
eq("굴착기", 100, ["지역"]), eq("굴착기", 7),
|
||||
{ 이름: "보통인부", 글: "", 금액: 5, 묶음: "인력", 쓰는입력: ["두께"], 기준단위: "10㎡", 환산근거: "10㎡당 → 1㎡당 ÷10" } ] },
|
||||
{ 비목: "재료비", 소계: 0, 줄: [ eq("굴착기", 20), eq("굴착기", 2), { 이름: "모래", 글: "", 금액: 3 } ] },
|
||||
{ 비목: "경비", 소계: 0, 줄: [ eq("굴착기", 30), eq("굴착기", null), { 이름: "공구손료", 글: "", 금액: 1, 묶음: "경비" } ] },
|
||||
];
|
||||
const got = bundleLines(groups).map((b) => ({ 묶음: b.묶음, 소계: b.소계, 쓰는입력: b.쓰는입력, 기준단위: b.기준단위, 환산근거: b.환산근거,
|
||||
줄: b.줄.map((l) => "몫" in l ? [l.이름, l.금액, l.몫.map((x) => x.비목)] : l.이름) }));
|
||||
console.log(JSON.stringify(got));
|
||||
"""
|
||||
out = subprocess.run(
|
||||
["node", "--experimental-strip-types", "--no-warnings",
|
||||
"--input-type=module-typescript", "-e", pure + chr(10) + body],
|
||||
capture_output=True, text=True, encoding="utf-8", timeout=60,
|
||||
) # fmt: skip
|
||||
assert out.returncode == 0, out.stderr
|
||||
got = json.loads(out.stdout)
|
||||
# 차례 = 인력 · 재료 · 경비 · 같은 이름 장비 둘은 k 번째끼리 한 줄(노 · 재 · 경 몫) · 비목 노무비 장비는 경비(장비) 묶음
|
||||
assert [b["묶음"] for b in got] == ["인력", "재료", "경비"]
|
||||
assert got[0]["줄"] == ["포설공", "보통인부"] and got[0]["소계"] == 15
|
||||
assert got[0]["쓰는입력"] == ["두께"] and got[0]["기준단위"] == ["10㎡"]
|
||||
assert got[0]["환산근거"] == ["10㎡당 → 1㎡당 ÷10"]
|
||||
assert got[1]["줄"] == ["모래"]
|
||||
eq = got[2]
|
||||
assert eq["줄"][0] == ["굴착기", 150, ["노무비", "재료비", "경비"]]
|
||||
assert eq["줄"][1] == ["굴착기", 9, ["노무비", "재료비", "경비"]]
|
||||
assert eq["줄"][2] == "공구손료" and eq["소계"] == 160 and eq["쓰는입력"] == ["지역"]
|
||||
@@ -0,0 +1,62 @@
|
||||
"""714-3 텍스트 수식 입력 단추 지킴 시험 — 줄이 쓰는 입력 고르기(순수 함수 · node) + 이음 글.
|
||||
|
||||
`M01_MasterData_UI_LogicLab_Formula.ts` 「순수 셈」 구간만 떼어 돌림(화면 · 서버 없음).
|
||||
"""
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
M01 = Path(__file__).resolve().parents[2] / "M01_MasterData"
|
||||
FX = M01 / "M01_MasterData_UI_LogicLab_Formula.ts"
|
||||
|
||||
pytestmark = pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음")
|
||||
|
||||
|
||||
def _run(body: str) -> object:
|
||||
text = FX.read_text(encoding="utf-8")
|
||||
pure = text[text.index("/* ── 순수 셈: 줄이 쓰는 입력 ── */") : text.index("/* ── 순수 셈 끝 ── */")]
|
||||
out = subprocess.run(
|
||||
["node", "--experimental-strip-types", "--no-warnings",
|
||||
"--input-type=module-typescript", "-e", pure + "\n" + body],
|
||||
capture_output=True, text=True, encoding="utf-8", timeout=60,
|
||||
) # fmt: skip
|
||||
assert out.returncode == 0, out.stderr
|
||||
return json.loads(out.stdout)
|
||||
|
||||
|
||||
def test_표_행_열쇠와_이름_조각():
|
||||
got = _run(
|
||||
"""
|
||||
const line = {
|
||||
조각: [
|
||||
{ kind: "표", 글: "25 · 깬잡석", 참조: { 키: "QF1", 행: { 뒷길이: 25, 돌: "깬잡석", 줄번호: 3 } } },
|
||||
{ kind: "기호", 글: "*" },
|
||||
{ kind: "값", 글: "두께", 값: "20" },
|
||||
{ kind: "값", 글: "0.009" },
|
||||
{ kind: "값", 글: "뒷길이", 값: "99" },
|
||||
],
|
||||
요소조각: [{ kind: "표", 글: "x", 참조: { 키: "QF2", 행: 4 } }],
|
||||
};
|
||||
console.log(JSON.stringify(usedInputs(line, ["뒷길이", "돌", "두께", "쌓기"])));
|
||||
"""
|
||||
)
|
||||
# 처음 나온 값 · 입력 아닌 열쇠(줄번호) · 이름 없는 수(0.009) · 행 번호(4) 는 뺌
|
||||
assert got == [["뒷길이", "25"], ["돌", "깬잡석"], ["두께", "20"]]
|
||||
|
||||
|
||||
def test_빈_줄():
|
||||
assert _run('console.log(JSON.stringify(usedInputs({}, ["a"])))') == []
|
||||
|
||||
|
||||
def test_이음_시험_입력만_고침():
|
||||
lab = (M01 / "M01_MasterData_UI_LogicLab.ts").read_text(encoding="utf-8")
|
||||
detail = (M01 / "M01_MasterData_UI_LogicLab_Detail.ts").read_text(encoding="utf-8")
|
||||
# 텍스트 수식 고침 = 시험 계산 입력 + 다시 셈(흐름 예시값과 같은 길) · 캐시(drafts)에 안 들어감
|
||||
assert "onInput: (name, v) => syncLeft(current, { [name]: v }, true)" in lab
|
||||
assert "onInput: ctx.onInput" in detail
|
||||
fx = FX.read_text(encoding="utf-8")
|
||||
assert "touch" not in fx and "drafts" not in fx
|
||||
@@ -0,0 +1,135 @@
|
||||
# ruff: noqa: F811 — 임시 층 fixture(world)를 test_m02_labor_ref 에서 받아 씀
|
||||
"""일위대가 · 사전조합 줄 덮개 · [단가산출로 저장] · 단위 빈칸 막기(PLAN 715-3) — 임시 storage.
|
||||
|
||||
줄 덮개 = 그 줄에만(단가산출 원본 그대로) · 값 = 같은 덮개 SK 값 = 손셈 ·
|
||||
줄 고름(차례)이 뺀줄 뒤 제자리 ·
|
||||
[단가산출로 저장] = 새 SK + 참조 줄 · 같은 값 · 단위 빈칸 400 · 덮개 없는 줄 = 옛 값.
|
||||
"""
|
||||
|
||||
from decimal import Decimal
|
||||
|
||||
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_Labor_Ref as refs
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
||||
from M02_MasterTemplete import M02_Sangeun_Calc as calc
|
||||
from resources.tester.test_m02_labor_ref import MINE, world # noqa: F401 — 같은 임시 층
|
||||
|
||||
PCT = "GC001109" # 원/㎡ · 인력 둘(LB000036 · LB000002) + 「노무비 * 3 / 100」
|
||||
TWO_MATS = "GC000565" # 원/m · 재료 줄 둘
|
||||
COVER = {"덮개": {"labor:LB000036#0": {"qty": 0.5}}}
|
||||
|
||||
|
||||
def _whole():
|
||||
return m01.loaded()[1]
|
||||
|
||||
|
||||
def _sc(folder, lines, unit="㎡"):
|
||||
with store.at(folder):
|
||||
return refs.create("precombo", {"이름": "조합", "단위": unit, "담은로직": lines})["이름"]
|
||||
|
||||
|
||||
def _value(folder, key):
|
||||
with store.at(folder):
|
||||
return refs.Expand(_whole())({**MINE, "키": key})
|
||||
|
||||
|
||||
def test_logic_line_cover_in_precombo(world):
|
||||
"""사전조합 로직 줄 덮개 = 같은 덮개 SK 값 · 손셈(품 0.5 × 단가 + 둘째 줄 · 3 % 덧줄)."""
|
||||
bare = labor.run_line(_whole(), PCT, {})
|
||||
sc = _sc(world["personal"], [{"로직": PCT, "패치": COVER}])
|
||||
with store.at(world["personal"]):
|
||||
kept = store.read("precombo", sc)["문서"]["담은로직"][0]
|
||||
assert kept["패치"] == COVER
|
||||
got = _value(world["personal"], sc)["기준"]
|
||||
labor_sum = Decimal("0.5") * Decimal(str(bare["줄"][0]["단가"])) + Decimal(
|
||||
str(bare["줄"][1]["금액"])
|
||||
)
|
||||
assert got["노무비"] == pytest.approx(labor_sum)
|
||||
assert got["경비"] == pytest.approx(labor_sum * 3 / 100, abs=1)
|
||||
|
||||
|
||||
def test_line_pick_by_order_stays_after_drop(world):
|
||||
"""줄 고름 = 바탕 호표 차례(2) · 패치 뺀줄로 첫 재료 줄을 빼도 그 자재가 그 줄에."""
|
||||
import master_pick as mp
|
||||
|
||||
whole = _whole()
|
||||
row = whole.logic(TWO_MATS)
|
||||
item = row["호표"][2]
|
||||
want = mp.search(whole, item["요소"], {}, item.get("단위"))[1]
|
||||
one = {
|
||||
"로직": TWO_MATS,
|
||||
"고름": {"2": want},
|
||||
"자재표시": {"2": {"별도계상": "직접계산"}},
|
||||
"패치": {"뺀줄": ["material:" + row["호표"][1]["이름"] + "#0"]},
|
||||
}
|
||||
with store.at(world["personal"]):
|
||||
doc = labor.clean({"담은로직": [one]})
|
||||
got = labor.calc(doc, {})["줄"][0]
|
||||
picked = [x for x in got["줄"] if x.get("품목")]
|
||||
assert [x["품목"]["키"] for x in picked] == [want]
|
||||
|
||||
|
||||
def test_ref_line_cover_only_that_line(world):
|
||||
"""SK 참조 줄 덮개 = 그 줄만 · SK 원본 · 다른 줄 값 그대로."""
|
||||
with store.at(world["personal"]):
|
||||
sk = refs.create(
|
||||
"sangeun", {"이름": "밑", "단위": "㎡", "바탕": {"로직": PCT}, "더한줄": []}
|
||||
)["이름"]
|
||||
before = store.read("sangeun", sk)["판"]
|
||||
ref = {**MINE, "키": sk}
|
||||
sc = _sc(
|
||||
world["personal"],
|
||||
[{"참조": ref, "수량": {"손": 1}, "패치": COVER}, {"로직": "GC001111", "수량": {"손": 1}}],
|
||||
)
|
||||
inner = _value(world["personal"], sc)["안"]
|
||||
plain = _value(world["personal"], _sc(world["personal"], [{"참조": ref}]))["기준"]
|
||||
covered = calc.calc_doc(
|
||||
{"키": "SK-000999", "이름": "x", "단위": "㎡", "바탕": {"로직": PCT}, **COVER}
|
||||
)
|
||||
assert inner[0]["기준"]["계"] == pytest.approx(Decimal(str(covered["1단위"]["계"])))
|
||||
assert inner[0]["기준"]["계"] != plain["계"]
|
||||
with store.at(world["personal"]):
|
||||
assert store.read("sangeun", sk)["판"] == before
|
||||
|
||||
|
||||
def test_save_as_sangeun(world):
|
||||
"""[단가산출로 저장] — 새 SK(바탕 · 입력값 · 고름 cid · 덮개) · 참조 줄 · 같은 1 단위 값."""
|
||||
one = {"로직": PCT, "패치": COVER, "수량": {"손": 2}, "별칭": "내 줄"}
|
||||
with store.at(world["personal"]):
|
||||
line_value = labor.calc(labor.clean({"담은로직": [one]}), {})["줄"][0]["기준"]
|
||||
got = calc.from_line(one, "줄로 만든", "㎡")
|
||||
doc = store.read("sangeun", got["이름"])["문서"]
|
||||
value = calc.calc_doc(doc)["기준"]
|
||||
assert got["줄"] == {"참조": {**MINE, "키": got["이름"]}, "수량": {"손": 2}, "별칭": "내 줄"}
|
||||
assert doc["바탕"] == {"로직": PCT} and doc["덮개"]["labor:LB000036#0"]["qty"] == 0.5
|
||||
assert value["계"] == pytest.approx(line_value["계"])
|
||||
|
||||
|
||||
def test_refusals(world):
|
||||
with store.at(world["personal"]):
|
||||
with pytest.raises(store.StoreError, match="단위가 빔"):
|
||||
refs.create("precombo", {"이름": "x", "단위": " ", "담은로직": [{"로직": PCT}]})
|
||||
assert refs.create("precombo", {"이름": "빈 새 문서", "단위": None, "담은로직": []})["이름"]
|
||||
bad = {"로직": PCT, "패치": {"더한줄": [{"참조": {**MINE, "키": "SK-000001"}, "수량": 1}]}}
|
||||
with pytest.raises(store.StoreError, match="참조 못 넣음"):
|
||||
labor.clean({"담은로직": [bad]})
|
||||
with pytest.raises(store.StoreError, match="수가 아님"):
|
||||
labor.clean(
|
||||
{"담은로직": [{"로직": PCT, "패치": {"덮개": {"labor:LB000036#0": {"qty": "식"}}}}]}
|
||||
)
|
||||
sc = _sc(world["personal"], [{"로직": PCT}])
|
||||
with pytest.raises(store.StoreError, match="줄 덮개는"):
|
||||
labor.clean({"담은로직": [{"참조": {**MINE, "키": sc}, "패치": COVER}]})
|
||||
|
||||
|
||||
def test_no_patch_same_value(world):
|
||||
"""덮개 없는 줄 = run_line 값 그대로(옛 셈 길)."""
|
||||
sc = _sc(world["personal"], [{"로직": PCT, "패치": {}}])
|
||||
with store.at(world["personal"]):
|
||||
assert "패치" not in store.read("precombo", sc)["문서"]["담은로직"][0]
|
||||
assert _value(world["personal"], sc)["기준"]["계"] == pytest.approx(
|
||||
labor.run_line(_whole(), PCT, {})["기준"]["계"]
|
||||
)
|
||||
@@ -1,4 +1,5 @@
|
||||
"""M02 단가산출(SK) 서고 · 셈(PLAN 715-1b · 715-2 · `M02_Sangeun_Calc` · `M02_Labor_Ref`) — 임시 storage.
|
||||
# ruff: noqa: F811 — 임시 층 fixture(world)를 test_m02_labor_ref 에서 받아 씀
|
||||
"""M02 단가산출(SK) 서고 · 셈(PLAN 715-1b · 715-2) — 임시 storage.
|
||||
|
||||
고친 줄 값 = 손셈 · 덧줄 % 엔진이 다시 셈 · 뺀줄 뒤 고름 제자리 · 짝 잃음 남김 · 원값 서버 ·
|
||||
순환 422 · 쓰는 곳 지우기 409 · 사전조합이 SK 를 담음 · 층 복사 왕복 같은 값.
|
||||
@@ -46,7 +47,7 @@ def _bare(code):
|
||||
|
||||
|
||||
def test_qty_cover_hand_and_pct_recomputed(world):
|
||||
"""품 덮개 = 손셈 · 「노무비 * 3 / 100」 덧줄이 덮인 노무비에서 다시 셈 · 원값 = 서버가 그때 M01 값."""
|
||||
"""품 덮개 = 손셈 · 「노무비 * 3 / 100」 덧줄 다시 셈 · 원값 = 서버가 셈."""
|
||||
bare = _bare(PCT)
|
||||
first, second = bare["줄"][0], bare["줄"][1]
|
||||
key = _sk(
|
||||
@@ -134,14 +135,18 @@ def test_precombo_holds_sangeun(world):
|
||||
with store.at(world["personal"]):
|
||||
sc = refs.create(
|
||||
"precombo",
|
||||
{"이름": "조합", "담은로직": [{"참조": {**MINE, "키": sk}, "수량": {"손": 2}}]},
|
||||
{
|
||||
"이름": "조합",
|
||||
"단위": "㎡",
|
||||
"담은로직": [{"참조": {**MINE, "키": sk}, "수량": {"손": 2}}],
|
||||
},
|
||||
)["이름"]
|
||||
got = labor.calc({"담은로직": [{"참조": {**MINE, "키": sc}, "수량": {"손": 1}}]}, {})
|
||||
with pytest.raises(store.StoreError) as e:
|
||||
store.delete("sangeun", sk)
|
||||
one = _calc(world["personal"], sk)["1단위"]["계"]
|
||||
assert got["반영합"]["계"] == pytest.approx(one * 2, abs=1)
|
||||
assert e.value.status == 409 and "사전조합 개인 7/42" in str(e.value.detail)
|
||||
assert e.value.status == 409 and "일위대가 개인 7/42" in str(e.value.detail)
|
||||
|
||||
|
||||
def test_layer_copy_round_trip(world):
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
"""서고 나무 폴더(PLAN 716-4) — 종류 인자 · 안에 든 것째 지움 · 쓰는 곳 409 · 여럿 · 복사 붙여넣기.
|
||||
|
||||
임시 storage.
|
||||
|
||||
`/api/m02/layers/{층}/{종류}-tree` = 일위대가(precombo) · 단가산출(sangeun) 따로 ·
|
||||
폴더 지우기 = 안의 문서 파일째(모두 검사 뒤 한 번에 · 바깥에서 쓰면 409 + 그 이름 ·
|
||||
지우는 것끼리 참조는 됨) ·
|
||||
`대상` 여럿 옮기기 · 지우기 · `…-tree/paste` = 새 키 · 같은 폴더 이름 「(1)」 · 본뜬 = 원본.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from M02_MasterTemplete import M02_Precombo_Folders as pre
|
||||
from M02_MasterTemplete import M02_Template_Layers as layers
|
||||
from resources.tester.test_m02_precombo_folders import Tree, client # noqa: F401 — 같은 임시 층
|
||||
|
||||
MINE = {"층": "personal", "주인": "7/42"}
|
||||
SK = "/api/m02/layers/personal/library/sangeun"
|
||||
|
||||
|
||||
def _sk(c: TestClient, name: str, **doc) -> str:
|
||||
body = {"문서": {"이름": name, "단위": "㎡", "더한줄": [], **doc}}
|
||||
res = c.post(SK, json=body)
|
||||
assert res.status_code == 200, res.text
|
||||
return res.json()["이름"]
|
||||
|
||||
|
||||
def _files(kind: str) -> list[str]:
|
||||
return sorted(p.stem for p in (layers.personal_dir(7, 42) / kind).glob("*.json"))
|
||||
|
||||
|
||||
def _sk_tree(c: TestClient) -> Tree:
|
||||
t = Tree(c)
|
||||
t.url = "/api/m02/layers/personal/sangeun-tree"
|
||||
t.now = c.get(t.url).json()
|
||||
return t
|
||||
|
||||
|
||||
def test_종류마다_따로_자리_파일(client: TestClient): # noqa: F811
|
||||
_sk(client, "갑")
|
||||
sk = _sk_tree(client)
|
||||
sk.do(op="add-folder", 경로=[], 이름="단가")
|
||||
sk.do(op="move", 키="SK-000001", 새경로=["단가"])
|
||||
assert Tree(client).now["폴더"] == [] # 일위대가 나무는 그대로
|
||||
root = layers.personal_dir(7, 42)
|
||||
assert (root / pre.file_of("sangeun")).is_file()
|
||||
assert not (root / pre.file_of("precombo")).exists()
|
||||
assert client.get("/api/m02/layers/personal/labor-tree").status_code == 422 # 모르는 종류
|
||||
|
||||
|
||||
def test_폴더_지우기는_안에_든_것째_지우는_것끼리_참조는_됨(client: TestClient): # noqa: F811
|
||||
a = _sk(client, "바탕")
|
||||
b = _sk(client, "위", 바탕={"참조": {**MINE, "키": a}})
|
||||
sk = _sk_tree(client)
|
||||
sk.do(op="add-folder", 경로=[], 이름="묶음")
|
||||
sk.do(op="add-folder", 경로=["묶음"], 이름="안")
|
||||
sk.do(op="move", 키=a, 새경로=["묶음", "안"])
|
||||
sk.do(op="move", 키=b, 새경로=["묶음"])
|
||||
got = sk.do(op="delete", 경로=["묶음"])
|
||||
assert sorted(got["알림"]["지운문서"]) == [a, b]
|
||||
assert _files("sangeun") == [] and sk.now["폴더"] == []
|
||||
|
||||
|
||||
def test_바깥에서_쓰면_409_그_이름_아무것도_안_지움(client: TestClient): # noqa: F811
|
||||
a = _sk(client, "바탕")
|
||||
body = {"문서": {"이름": "쓰는 일위대가", "담은로직": [{"참조": {**MINE, "키": a}}]}}
|
||||
assert client.post("/api/m02/layers/personal/library/precombo", json=body).status_code == 200
|
||||
sk = _sk_tree(client)
|
||||
sk.do(op="add-folder", 경로=[], 이름="묶음")
|
||||
sk.do(op="move", 키=a, 새경로=["묶음"])
|
||||
detail = sk.do(409, op="delete", 경로=["묶음"])["detail"]
|
||||
assert "쓰는 곳" in detail and "「쓰는 일위대가」" in detail and a in detail
|
||||
assert _files("sangeun") == [a] and client.get(sk.url).json()["폴더"] == [["묶음"]]
|
||||
|
||||
|
||||
def test_여럿_옮기기_지우기(client: TestClient): # noqa: F811
|
||||
t = Tree(client) # 개인 층 일위대가 SC-000001~3
|
||||
t.do(op="add-folder", 경로=[], 이름="가")
|
||||
t.do(op="add-folder", 경로=[], 이름="나")
|
||||
t.do(
|
||||
op="move", 대상=[{"키": "SC-000001"}, {"키": "SC-000002"}, {"경로": ["나"]}], 새경로=["가"]
|
||||
)
|
||||
assert t.now["폴더"] == [["가"], ["가", "나"]]
|
||||
assert t.now["자리"] == {"SC-000001": ["가"], "SC-000002": ["가"]}
|
||||
got = t.do(op="delete", 대상=[{"경로": ["가"]}, {"키": "SC-000001"}, {"키": "SC-000003"}])
|
||||
assert sorted(got["알림"]["지운문서"]) == ["SC-000001", "SC-000002", "SC-000003"]
|
||||
assert _files("precombo") == [] and t.now["폴더"] == []
|
||||
|
||||
|
||||
def test_복사_붙여넣기_새_키_같은_이름_폴더는_1(client: TestClient): # noqa: F811
|
||||
t = Tree(client)
|
||||
t.do(op="add-folder", 경로=[], 이름="가")
|
||||
t.do(op="add-folder", 경로=["가"], 이름="속")
|
||||
t.do(op="move", 키="SC-000001", 새경로=["가", "속"])
|
||||
url = "/api/m02/layers/personal/precombo-tree/paste"
|
||||
picks = [{"경로": ["가"]}, {"경로": [], "키": "SC-000002"}]
|
||||
res = client.post(url, json={"from_layer": "personal", "대상": picks, "경로": ["가"]})
|
||||
assert res.status_code == 200, res.text
|
||||
got = res.json()
|
||||
assert got["알림"]["만든문서"] == ["SC-000004", "SC-000005"]
|
||||
assert ["가", "가"] in got["폴더"] and ["가", "가", "속"] in got["폴더"]
|
||||
assert got["자리"]["SC-000004"] == ["가", "가", "속"] and got["자리"]["SC-000005"] == ["가"]
|
||||
again = client.post(url, json={"from_layer": "personal", "대상": [{"경로": ["가"]}]}).json()
|
||||
assert ["가(1)"] in again["폴더"] # 같은 이름 = 「(1)」
|
||||
doc = client.get("/api/m02/layers/personal/templates/precombo/SC-000005").json()["문서"]
|
||||
assert doc["이름"] == "나" and doc["본뜬"]["키"] == "SC-000002"
|
||||
bad = client.post(url, json={"from_layer": "personal", "대상": [{"키": "SC-000099"}]})
|
||||
assert bad.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kind", ["precombo", "sangeun"])
|
||||
def test_순수_지우기_알림(kind: str, tmp_path: Path):
|
||||
doc = {"폴더": [["가"], ["가", "나"]], "자리": {"X-1": ["가", "나"], "X-2": []}}
|
||||
out, note = pre.apply(doc, {"op": "delete", "경로": ["가"]}, {"X-1", "X-2"})
|
||||
assert out == {"폴더": [], "자리": {}} and note["지운문서"] == ["X-1"]
|
||||
assert pre.file_of(kind) == f"{kind}_폴더.json"
|
||||
@@ -0,0 +1,93 @@
|
||||
"""717-1 상세 부품 공용 — 길 주입(TS) · M02 `logic-lib` 읽기 · 셈 길(로그인 · 쓰기 없음).
|
||||
|
||||
TS = `helper_717_1_logic_api_paths.cjs`(가짜 fetch 주소) · 서버 = 정본 읽기만(파일 지문 같음).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi import Depends, FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from common_util.common_util_auth import verify_session
|
||||
from M01_MasterData import M01_MasterData_Store as store
|
||||
from M02_MasterTemplete import M02_Logic_Lib_Read as lib
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
HELPER = Path(__file__).parent / "helper_717_1_logic_api_paths.cjs"
|
||||
KEY = "GF000001" # 경계표시 — 보통인부 한 줄
|
||||
BASE = "/api/m02/logic-lib"
|
||||
|
||||
|
||||
@pytest.mark.skipif(not (ROOT / "config" / "node_modules").is_dir(), reason="의존성 미설치")
|
||||
def test_길_주입_없으면_m01_있으면_넘긴_길() -> None:
|
||||
done = subprocess.run( # noqa: S603 — 고정 실행 파일
|
||||
["node", str(HELPER)], cwd=str(ROOT), capture_output=True, text=True, encoding="utf-8"
|
||||
)
|
||||
assert done.returncode == 0, done.stderr
|
||||
out = json.loads(done.stdout)
|
||||
assert out["before"] == out["after"] # 안 넘기면 지금 그대로
|
||||
assert all(u.startswith("/m01/") for u in out["before"])
|
||||
shared, admin = out["injected"][:-2], out["injected"][-2:]
|
||||
assert all(u.startswith(("/m02/logic-lib/", "/m02/calc-x/")) for u in shared)
|
||||
assert [u for u in shared if u.startswith("/m02/calc-x/")] == [
|
||||
"/m02/calc-x/calc",
|
||||
"/m02/calc-x/text",
|
||||
]
|
||||
assert admin == ["/m01/groups", "/m01/save"] # 관리자 · 저장 길은 늘 M01
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def user() -> TestClient:
|
||||
"""main.py 와 같은 등록(로그인만) · 일반 사용자."""
|
||||
app = FastAPI()
|
||||
app.include_router(lib.router, dependencies=[Depends(verify_session)])
|
||||
app.dependency_overrides[verify_session] = lambda: {
|
||||
"user_id": 42, "company_id": 7, "role": "USER", "is_master": False,
|
||||
} # fmt: skip
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def _prints() -> dict[str, str]:
|
||||
return {p.name: hashlib.sha256(p.read_bytes()).hexdigest() for p in store.FOLDER.glob("*.json")}
|
||||
|
||||
|
||||
def test_일반_계정_읽기(user: TestClient) -> None:
|
||||
assert user.get(f"{BASE}/logic/auto", params={"key": KEY}).status_code == 200
|
||||
assert user.get(f"{BASE}/elements", params={"group": "인력", "q": "LB000002"}).json()["items"]
|
||||
assert user.get(f"{BASE}/pick", params={"kind": "price", "q": "시멘트"}).status_code == 200
|
||||
assert user.get(f"{BASE}/rows", params={"file": "기계.json", "size": 3}).json()["rows"]
|
||||
assert user.get(f"{BASE}/materials", params={"sub": "시멘트"}).status_code == 200
|
||||
flow = user.get(f"{BASE}/logic/flow", params={"key": KEY})
|
||||
assert flow.status_code == 200 and "flow" in flow.json()
|
||||
found = user.get(f"{BASE}/elements", params={"group": "소요량", "q": "QF000025"}).json()
|
||||
file = next(i["file"] for i in found["items"] if i["ref"] == "QF000025")
|
||||
assert user.get(f"{BASE}/table", params={"file": file, "key": "QF000025"}).json()["table"]
|
||||
|
||||
|
||||
def test_요소_표_밖_파일은_막음(user: TestClient) -> None:
|
||||
logic_file = store.logics("", "", "", None, "")[0]["file"]
|
||||
assert user.get(f"{BASE}/rows", params={"file": logic_file}).status_code == 403
|
||||
assert user.get(f"{BASE}/table", params={"file": "기계.json", "key": "x"}).status_code == 403
|
||||
assert user.get(f"{BASE}/rows", params={"file": "재료_../x.json"}).status_code == 404
|
||||
|
||||
|
||||
def test_저장_안_한_줄_셈_쓰기_없음(user: TestClient) -> None:
|
||||
before = _prints()
|
||||
saved = user.post(f"{BASE}/calc", json={"key": KEY, "inputs": {}}).json()
|
||||
row = store.logic(KEY)["logic"]
|
||||
same = user.post(f"{BASE}/calc", json={"key": KEY, "inputs": {}, "row": row}).json()
|
||||
line = row["호표"][0]
|
||||
row2 = {**row, "호표": [{**line, "수량": f"({line['수량']}) * 2"}, *row["호표"][1:]]}
|
||||
double = user.post(f"{BASE}/calc", json={"key": KEY, "inputs": {}, "row": row2}).json()
|
||||
text = user.post(f"{BASE}/text", json={"key": KEY, "inputs": {}, "row": row2}).json()
|
||||
assert saved["ok"] and same["sums"] == saved["sums"]
|
||||
assert double["sums"]["노무비"] == pytest.approx(saved["sums"]["노무비"] * 2)
|
||||
assert text["ok"] and text["계"] == pytest.approx(double["sums"]["계"], rel=1e-6)
|
||||
assert store.logic(KEY)["logic"]["호표"][0]["수량"] == line["수량"] # 정본 그대로
|
||||
assert _prints() == before
|
||||
@@ -0,0 +1,54 @@
|
||||
"""M02 단가산출 칸 목록 줄(PLAN 717-2a · `M02_Labor_Ref.summary` sangeun 갈래) — 임시 storage.
|
||||
|
||||
바탕 = 로직 「키 이름」 · 참조 = SK 키 · 없음 = None · 고친 줄 수 = 덮개 + 더한줄 + 뺀줄 ·
|
||||
쓰는 곳 수 = 그 SK 를 바탕 · 더한줄로 담은 문서 수 · 일위대가(precombo) 줄엔 안 붙음.
|
||||
"""
|
||||
|
||||
from M01_MasterData import M01_MasterData_Store as m01
|
||||
from M02_MasterTemplete import M02_Labor_Ref as refs
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
||||
from M02_MasterTemplete.M02_Labor_Expand import Expand
|
||||
from resources.tester.test_m02_labor_ref import MINE, world # noqa: F401 — 같은 임시 층
|
||||
|
||||
PCT = "GC001109"
|
||||
|
||||
|
||||
def _sk(folder, **doc):
|
||||
with store.at(folder):
|
||||
return refs.create("sangeun", {"이름": "목록 시험", "단위": "㎡", "더한줄": [], **doc})[
|
||||
"이름"
|
||||
]
|
||||
|
||||
|
||||
def _row(folder, kind, key):
|
||||
return refs.summary(folder, kind, key, Expand(m01.loaded()[1]))
|
||||
|
||||
|
||||
def test_sangeun_row_base_edits_users(world):
|
||||
mine = world["personal"]
|
||||
a = _sk(mine, 바탕={"로직": PCT}, 덮개={"labor:LB000036#0": {"qty": 0.5}}, 뺀줄=["pct:x#0"])
|
||||
name = m01.loaded()[1].logic(PCT)["이름"]
|
||||
row = _row(mine, "sangeun", a)
|
||||
assert row["바탕"] == f"{PCT} {name}".strip()
|
||||
assert row["고친줄수"] == 2 and row["쓰는곳수"] == 0
|
||||
assert row["제목"] == "목록 시험" and row["단위"] == "㎡"
|
||||
# 바탕 참조 SK · 더한줄 하나 → 그 SK 쓰는 곳 1
|
||||
b = _sk(
|
||||
mine,
|
||||
바탕={"참조": {**MINE, "키": a}},
|
||||
더한줄=[{"종류": "인력", "요소": "LB000036", "이름": "보통인부", "단위": "인", "수량": 1}],
|
||||
)
|
||||
row_b = _row(mine, "sangeun", b)
|
||||
assert row_b["바탕"] == a and row_b["고친줄수"] == 1
|
||||
assert _row(mine, "sangeun", a)["쓰는곳수"] == 1
|
||||
# 바탕 없음
|
||||
c = _sk(mine)
|
||||
row_c = _row(mine, "sangeun", c)
|
||||
assert row_c["바탕"] is None and row_c["고친줄수"] == 0
|
||||
|
||||
|
||||
def test_precombo_row_has_no_sangeun_fields(world):
|
||||
with store.at(world["personal"]):
|
||||
sc = refs.create("precombo", {"이름": "조합", "담은로직": []})["이름"]
|
||||
row = _row(world["personal"], "precombo", sc)
|
||||
assert not {"바탕", "고친줄수", "쓰는곳수"} & set(row)
|
||||
@@ -0,0 +1,76 @@
|
||||
"""717-4 단가산출(SK) 고르기 — SK 목록 층 차례 · 고른 SK 참조 꼴 · 일위대가 줄 금액."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from M02_MasterTemplete import M02_Labor_Ref as refs
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
||||
from M02_MasterTemplete import M02_Sangeun_Calc as calc
|
||||
from resources.tester.test_m02_labor_ref import COMPANY, MINE, world # noqa: F401 — 같은 임시 층
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
M02 = ROOT / "M02_MasterTemplete"
|
||||
PCT = "GC001109" # 원/㎡ · 인력 둘 + 덧줄 %
|
||||
|
||||
|
||||
def _text(path: Path) -> str:
|
||||
return path.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_sk_list_layer_order_and_ref_shape():
|
||||
"""목록 = 마스터 › 회사 › 개인 차례 · 종류 sangeun 만 · 고른 줄 = 참조 {층, 주인, 키}."""
|
||||
pick = _text(M02 / "M02_MasterTemplete_Sangeun_Pick.ts")
|
||||
order = re.search(r"LAYER_ORDER = \[([^\]]+)\]", pick).group(1)
|
||||
assert [x.strip().strip('"') for x in order.split(",")] == ["system", "company", "personal"]
|
||||
assert "[...scopes].sort((a, b) => rank(a) - rank(b))" in pick
|
||||
assert "r.종류 === SANGEUN_KIND" in pick and 'SANGEUN_KIND = "sangeun"' in pick
|
||||
assert 'ref: { 층: at.층, 주인: at.층 === "system" ? null : at.주인, 키: r.이름 }' in pick
|
||||
labor = _text(M02 / "M02_MasterTemplete_Labor_Pick.ts")
|
||||
assert "lib?.pick(x.ref, x.제목 || x.이름);" in labor # SK 탭 줄 누름 = 참조
|
||||
assert 'if (tab === "sk") return findSangeun(q);' in labor
|
||||
assert "sangeun: true," in _text(
|
||||
M02 / "M02_MasterTemplete_Hopyo_Pick.ts"
|
||||
) # 일위대가 [로직 추가]
|
||||
store_ts = _text(ROOT / "B09_Estimation" / "B09_Estimation_UI_Store.ts")
|
||||
assert '["sangeun", "단가산출"],' in store_ts and "sangeun: sangeunItems," in store_ts
|
||||
# 더한줄 고르기 — 715 이음 꼴 셋
|
||||
assert 'done({ 종류: "로직", 로직: x.키, 입력값: {}, 수량: 1 })' in pick
|
||||
assert "done({ 참조: ref, 수량: 1 })" in pick
|
||||
assert 'done({ 종류: "재료", 요소: m.코드, 이름: m.이름, 단위: m.단위, 수량: 1 })' in pick
|
||||
|
||||
|
||||
def _sk(folder, name):
|
||||
with store.at(folder):
|
||||
return refs.create(
|
||||
"sangeun", {"이름": name, "단위": "㎡", "바탕": {"로직": PCT}, "더한줄": []}
|
||||
)["이름"]
|
||||
|
||||
|
||||
def test_list_amount_and_labor_line_holds_upper_sk(world): # noqa: F811
|
||||
"""층 목록 SK 줄에 1 단위 `기준금액` · 개인 일위대가 줄이 회사 SK 를 참조 = SK 1 단위 × 수량."""
|
||||
from M02_MasterTemplete import M02_Labor_Doc as labor
|
||||
|
||||
sk = _sk(world["company"], "회사 단가산출")
|
||||
with store.at(world["company"]):
|
||||
rows = [r for r in store.list_all() if r["종류"] == "sangeun"]
|
||||
refs.summaries(world["company"], rows)
|
||||
one = calc.calc_doc(store.read("sangeun", sk)["문서"])["1단위"]["계"]
|
||||
assert [r["이름"] for r in rows] == [sk]
|
||||
assert rows[0]["제목"] == "회사 단가산출" and rows[0]["기준금액"] == pytest.approx(one, abs=1)
|
||||
with store.at(world["personal"]):
|
||||
sc = refs.create(
|
||||
"precombo",
|
||||
{
|
||||
"이름": "일위대가",
|
||||
"단위": "㎡",
|
||||
"담은로직": [{"참조": {**COMPANY, "키": sk}, "수량": {"손": 3}}],
|
||||
},
|
||||
)["이름"]
|
||||
saved = store.read("precombo", sc)["문서"]["담은로직"][0]["참조"]
|
||||
got = labor.calc({"담은로직": [{"참조": {**MINE, "키": sc}, "수량": {"손": 1}}]}, {})
|
||||
assert saved == {**COMPANY, "키": sk}
|
||||
assert got["반영합"]["계"] == pytest.approx(one * 3, abs=1)
|
||||
@@ -45,7 +45,7 @@ def test_이어서_끝내기_지움_겹침_막음(client):
|
||||
|
||||
block = _post(client) # 끝낸 같은 엑셀 = 막음 · 안내
|
||||
assert block.status_code == 409 and "이미 불러온 엑셀" in block.json()["detail"]
|
||||
assert "일위대가 68" in block.json()["detail"] and _keys(client) == []
|
||||
assert "단가산출 68" in block.json()["detail"] and _keys(client) == []
|
||||
assert sorted((folder / "precombo").glob("SC-*.json")) == sc_before
|
||||
|
||||
|
||||
@@ -65,3 +65,34 @@ def test_옛_HP_목록에서만_숨김(client):
|
||||
assert client.get("/api/m02/layers/personal/hopyo-picks").json()["줄"] == []
|
||||
assert _post(client).json()["키"] == "HP-000008" # 옛 번 뒤로
|
||||
assert old.is_file() and _keys(client) == ["HP-000008"]
|
||||
|
||||
|
||||
def test_결과_단가산출_산근_블록_바탕_없음_겹침_없음(client):
|
||||
folder = layers.personal_dir(7, 42)
|
||||
assert _post(client).json()["키"] == "HP-000001"
|
||||
url = URL.format("personal") + "/HP-000001/sangeun"
|
||||
got = client.post(url)
|
||||
assert got.status_code == 200, got.text
|
||||
got = got.json()
|
||||
assert got["블록"] > 0 and len(got["만듦"]) == got["블록"] and got["그대로"] == 0
|
||||
docs = [json.loads(p.read_bytes()) for p in sorted((folder / "sangeun").glob("SK-*.json"))]
|
||||
assert len(docs) == got["블록"]
|
||||
one = docs[0]
|
||||
assert one["바탕"] is None and one["본뜬"]["키"].startswith("HP-000001 단산")
|
||||
assert one["구분"] == "실무자료" and len(one["더한줄"]) == 1 and one["단위"]
|
||||
hp = hs.read(folder, "HP-000001")
|
||||
no = int(one["본뜬"]["키"].rsplit("단산", 1)[1])
|
||||
line = next( # 그 블록을 부르는 첫 엑셀 줄 = 엑셀 단가 그대로
|
||||
x
|
||||
for h in hp["호표"]
|
||||
for x in h["줄"]
|
||||
if (x.get("참조") or {}).get("종류") == "단산" and x["참조"]["번"] == no
|
||||
)
|
||||
price = one["더한줄"][0]["고정"]["단가"]
|
||||
assert sum(price.values()) == sum((line["단가"] or {}).get(k) or 0 for k in price)
|
||||
again = client.post(url).json() # 다시 = 그대로(겹침 없음)
|
||||
assert again["만듦"] == [] and again["그대로"] == got["블록"]
|
||||
res = client.delete(URL.format("personal") + "/HP-000001").json()
|
||||
assert res["만든"] == got["블록"] # 단가산출만 만들어도 지문 기록
|
||||
assert len(list((folder / "sangeun").glob("SK-*.json"))) == got["블록"] # 남음
|
||||
assert _post(client).status_code == 409
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
"""719-1 프로젝트 사본 — M02 단가산출(SK) 풀어 굳힘(`M02_Item_Sangeun` · `M02_Item_Source` ·
|
||||
`M02_Item_Prices` · `B09_Estimation_Bill_Library`) — 임시 storage(견본 층 · 실제 M01 읽기만).
|
||||
|
||||
구조물 사본 · B09 서고 사본 셈 = M02 층 SK 셈 · 굳힌 줄은 사본 `단가.json` 로직 줄 · 원본 SK 를
|
||||
지워도 같은 값 · 같은 SK = 같은 굳힌 키(다시 복사 · SK 고친 뒤도) · 원본 층 지문 그대로 ·
|
||||
서고 manifest 원본판 = SK 바뀌면 다름.
|
||||
"""
|
||||
|
||||
# ruff: noqa: F811 — 임시 층 fixture `world` 를 들여 씀
|
||||
import hashlib
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from B09_Estimation import B09_Estimation_Bill_Library as library
|
||||
from M02_MasterTemplete import M02_Item_Prices as prices
|
||||
from M02_MasterTemplete import M02_Item_Sangeun as sk
|
||||
from M02_MasterTemplete import M02_Item_Source as source
|
||||
from M02_MasterTemplete import M02_Labor_Doc as labor
|
||||
from M02_MasterTemplete import M02_Labor_Ref as refs
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
||||
from resources.tester.test_715_sangeun_store import PCT, _sk
|
||||
from resources.tester.test_m02_labor_ref import MINE, world # noqa: F401 — 같은 임시 층
|
||||
|
||||
COVER = {"labor:LB000036#0": {"qty": 0.5}}
|
||||
ADD = [{"종류": "인력", "요소": "LB000002", "수량": 0.1}]
|
||||
|
||||
|
||||
def _print(folder):
|
||||
"""층 폴더 지문 — 파일 이름 · 바이트."""
|
||||
out = hashlib.sha256()
|
||||
for path in sorted(p for p in folder.rglob("*") if p.is_file()):
|
||||
out.update(str(path.relative_to(folder)).encode() + path.read_bytes())
|
||||
return out.hexdigest()
|
||||
|
||||
|
||||
def _top(ref, qty=2):
|
||||
with store.at(ref["folder"]):
|
||||
doc = labor.clean({"열": "rv", "담은로직": [{"참조": ref["ref"], "수량": {"손": qty}}]})
|
||||
return doc, labor.calc(doc, {})["줄"][0]
|
||||
|
||||
|
||||
def _mine(world):
|
||||
key = _sk(world["personal"], 바탕={"로직": PCT}, 덮개=COVER, 더한줄=ADD)
|
||||
return key, {"folder": world["personal"], "ref": {**MINE, "키": key}}
|
||||
|
||||
|
||||
def test_구조물_사본_굳힌_줄_값_같음_원본_지워도(world):
|
||||
key, ref = _mine(world)
|
||||
doc, live = _top(ref)
|
||||
assert not live["까닭"] and live["기준"]["계"] > 0
|
||||
before = _print(world["personal"])
|
||||
rows = source.labor_doc_rows(doc)
|
||||
bundle = source.price_bundle(rows, world["personal"])
|
||||
assert _print(world["personal"]) == before # 원본 층 안 건드림
|
||||
rid = refs.ref_id(ref["ref"])
|
||||
frozen = rows["참조사본"][rid]
|
||||
fk = frozen["바탕"]["로직"]
|
||||
assert fk == sk.frozen_key(rid, set()) and fk.startswith("GX9")
|
||||
assert frozen["덮개"] == {} and frozen["더한줄"] == [] and frozen["뺀줄"] == []
|
||||
assert frozen["원본"]["덮개"]["labor:LB000036#0"]["qty"] == 0.5 # 고친 근거
|
||||
assert [r["키"] for r in bundle[sk.FILE]["줄"]] == [fk]
|
||||
json.dumps(rows["참조사본"], ensure_ascii=False) # 사본 파일로 쓸 수 있는 꼴(Decimal 없음)
|
||||
(world["personal"] / "sangeun" / f"{key}.json").unlink() # 원본이 사라져도 사본만 봄
|
||||
got = prices.calc(rows, json.loads(json.dumps(bundle, ensure_ascii=False)), {}, {}, None)
|
||||
line = got["줄"][0]
|
||||
assert not line["까닭"]
|
||||
for part in ("노무비", "재료비", "경비", "계"):
|
||||
assert line["기준"][part] == pytest.approx(live["기준"][part], rel=1e-9)
|
||||
assert line["반영"]["계"] == pytest.approx(live["반영"]["계"], rel=1e-9)
|
||||
|
||||
|
||||
def test_같은_SK_같은_굳힌_키_다른_SK_다른_키(world):
|
||||
key, ref = _mine(world)
|
||||
doc, _ = _top(ref)
|
||||
rid = refs.ref_id(ref["ref"])
|
||||
first = source.labor_doc_rows(doc)["참조사본"][rid]["바탕"]["로직"]
|
||||
with store.at(world["personal"]): # SK 를 고쳐도 굳힌 키 그대로(B09 덮개 짝 지킴)
|
||||
now = store.read("sangeun", key)
|
||||
store.write("sangeun", key, now["판"], {**now["문서"], "덮개": {}})
|
||||
again = source.labor_doc_rows(doc)["참조사본"][rid]
|
||||
assert again["바탕"]["로직"] == first and again["원본"]["덮개"] == {}
|
||||
other = refs.ref_id({"층": "company", "주인": "7", "키": key}) # 같은 번호 다른 층
|
||||
assert sk.frozen_key(other, set()) != first
|
||||
taken = {first}
|
||||
assert sk.frozen_key(rid, taken) != first and len(taken) == 2 # 겹치면 다음 번호
|
||||
|
||||
|
||||
def test_B09_서고_사본_값_같음_원본판(world, tmp_path):
|
||||
_, ref = _mine(world)
|
||||
with store.at(world["personal"]):
|
||||
sc = refs.create(
|
||||
"precombo",
|
||||
{"이름": "조합", "단위": "㎡", "담은로직": [{"참조": ref["ref"], "수량": {"손": 2}}]},
|
||||
)["이름"]
|
||||
_, live = _top({"folder": world["personal"], "ref": {**MINE, "키": sc}}, 1)
|
||||
before = _print(world["personal"])
|
||||
root = tmp_path / "proj"
|
||||
made = library.import_copy(root, "precombo", "personal", "7/42", sc)
|
||||
assert made["만듦"] and _print(world["personal"]) == before
|
||||
folder = library.library_dir(root) / made["폴더"]
|
||||
read = {p.name: json.loads(p.read_text(encoding="utf-8")) for p in folder.glob("*.json")}
|
||||
got = prices.calc(read[source.LABOR_FILE], read[source.PRICE_FILE], {}, {}, None)["줄"][0]
|
||||
assert not got["까닭"]
|
||||
assert got["기준"]["계"] == pytest.approx(live["기준"]["계"], rel=1e-9)
|
||||
mark = read["manifest.json"]["원본판"]
|
||||
built = library._build("precombo", "personal", "7/42", sc)[0]
|
||||
assert library.source_version(built) == mark # 다시 모아도 같은 판
|
||||
again = library.import_copy(root, "precombo", "personal", "7/42", sc)
|
||||
assert again == {"폴더": made["폴더"], "만듦": False}
|
||||
with store.at(world["personal"]): # SK 고침 → 원본판 다름
|
||||
now = store.read("sangeun", ref["ref"]["키"])
|
||||
store.write("sangeun", ref["ref"]["키"], now["판"], {**now["문서"], "덮개": {}})
|
||||
assert library.source_version(library._build("precombo", "personal", "7/42", sc)[0]) != mark
|
||||
|
||||
|
||||
def test_SK_없는_사본_그대로():
|
||||
"""SK 없는 참조사본 = 받은 묶음 그대로(기존 사본 · 공사 금액 전후 같음)."""
|
||||
copies = {"x": {"종류": "사전조합", "담은로직": []}}
|
||||
assert sk.freeze(copies) is copies and sk.rows(copies) == [] and sk.materials(copies) == []
|
||||
@@ -0,0 +1,267 @@
|
||||
"""719-2 공사 쪽 M02 단가산출(SK) — B09 대가 블록 · 산근 · [업데이트] · 내 자료집 SK(임시 storage ·
|
||||
실제 M01 읽기만).
|
||||
|
||||
- 굳힌 SK = 품셈 꼴 대가 블록(`Labor_Source.sangeun`) · 금액 = M02 SK 셈 · 단가산출 속 일위대가 줄 =
|
||||
사전조합 블록을 부르는 줄 · 로직 더한줄의 하위 로직 = 산근 목록(`Sangeun.rows_of`)
|
||||
- [업데이트] = SK 고친 뒤 다시 굳힘 — 굳힌 키 · cover · cid · 입력값 · 고른자재 · B09 덮개 그대로 ·
|
||||
덮개 밑 값(base) = 새 단가산출 값 · 서고 사본(`sangeun`)도 원본판 다르면 다시 굳힘 ·
|
||||
미리 보기 SK 줄
|
||||
- 내 자료집 = SK 블록 · 품셈 블록 → 개인 층 SK(B09 덮개 = SK 덮개) → 서고로 다시 불러와 같은 금액
|
||||
"""
|
||||
|
||||
# ruff: noqa: F811 — 임시 층 fixture `world` 를 들여 씀
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from B09_Estimation import B09_Estimation_Bill_Library as library
|
||||
from B09_Estimation import B09_Estimation_Daega_Personal as personal
|
||||
from B09_Estimation import B09_Estimation_Prices as prices
|
||||
from B09_Estimation import B09_Estimation_Refresh as refresh
|
||||
from B09_Estimation import B09_Estimation_Sangeun as sangeun
|
||||
from B09_Estimation.B09_Estimation_Calc_Base import compute_base
|
||||
from B09_Estimation.B09_Estimation_Calc_Labor import compute_labor
|
||||
from B09_Estimation.B09_Estimation_Labor_Source import daega_inputs
|
||||
from B09_Estimation.B09_Estimation_Linked import linked_resource_keys
|
||||
from M02_MasterTemplete import M02_Item_Copy as item_copy
|
||||
from M02_MasterTemplete import M02_Item_Snapshot as snapshots
|
||||
from M02_MasterTemplete import M02_Item_Source as source
|
||||
from M02_MasterTemplete import M02_Labor_Ref as refs
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
||||
from resources.tester.test_145_2_b09_labor_calc import STONE, _item, _project, _snap
|
||||
from resources.tester.test_715_sangeun_store import PCT, _calc, _sk
|
||||
from resources.tester.test_m02_labor_ref import MINE, world # noqa: F401 — 같은 임시 층
|
||||
|
||||
TWO_MATS = "GC000565" # 재료 줄 둘(고른자재 열쇠)
|
||||
COVER = {"labor:LB000036#0": {"qty": 0.5}}
|
||||
PRICE = {"labor:LB000002#0": {"labor": 200000}}
|
||||
OWNER = "7/42"
|
||||
|
||||
|
||||
def _json(value):
|
||||
return json.loads(json.dumps(value, ensure_ascii=False, default=str))
|
||||
|
||||
|
||||
def _sc(world, lines):
|
||||
with store.at(world["personal"]):
|
||||
doc = {"이름": "속 일위대가", "단위": "㎡", "담은로직": lines}
|
||||
return refs.create("precombo", doc)["이름"]
|
||||
|
||||
|
||||
def _mine(world, full=False):
|
||||
"""SK = 품 덮개 · 인력 더한줄 · 로직 더한줄(하위 산근) · full = 단가 덮개 + SC 더한줄."""
|
||||
added = [
|
||||
{"종류": "인력", "요소": "LB000002", "수량": 0.1},
|
||||
{"종류": "로직", "로직": PCT, "입력값": {}, "수량": 0.5},
|
||||
]
|
||||
cover = COVER
|
||||
if full:
|
||||
sc = _sc(world, [{"로직": PCT, "수량": {"손": 1}}])
|
||||
added.append({"참조": {**MINE, "키": sc}, "수량": 2}) # 단가산출 속 일위대가
|
||||
cover = {**COVER, **PRICE}
|
||||
return _sk(world["personal"], 바탕={"로직": PCT}, 덮개=cover, 더한줄=added)
|
||||
|
||||
|
||||
def _engine_ready(world):
|
||||
"""엔진 입력 덧칸(715-1b `단가` · `값묶음`)이 붙었나 — 아니면 M02 도 못 셈."""
|
||||
key = _sk(
|
||||
world["personal"], 이름="엔진", 바탕={"로직": PCT}, 덮개={"labor:LB000002#0": {"labor": 1}}
|
||||
)
|
||||
got = _calc(world["personal"], key)
|
||||
return not got.get("까닭") and got["줄"][1]["단가"] == 1
|
||||
|
||||
|
||||
def _b09(root, cover=None):
|
||||
keys = linked_resource_keys(root)
|
||||
inputs = prices.build_inputs(None, keys)
|
||||
inputs["daega"] = daega_inputs(root)
|
||||
base = compute_base(inputs, {})
|
||||
sg = sangeun.build(root, base)
|
||||
rows = {r["key"]: r for r in sg["sg_list"]}
|
||||
out = compute_labor(inputs["daega"], base, rows, cover)
|
||||
return inputs["daega"], {b["id"]: b for b in out["daega_blocks"]}, rows
|
||||
|
||||
|
||||
def _lib(world, tmp_path, key, kind="sangeun"):
|
||||
root = _project(tmp_path / "proj", [])
|
||||
made = library.import_copy(root, kind, "personal", OWNER, key)
|
||||
return root, made["폴더"]
|
||||
|
||||
|
||||
def _set_cover(world, key, cover):
|
||||
with store.at(world["personal"]):
|
||||
now = store.read("sangeun", key)
|
||||
store.write("sangeun", key, now["판"], {**now["문서"], "덮개": cover})
|
||||
|
||||
|
||||
def _same_money(block, live):
|
||||
assert block["sum"] == pytest.approx(float(live["1단위"]["계"]), abs=5) # 끝수(내림)만 다름
|
||||
for part, name in (("labor", "노무비"), ("material", "재료비"), ("expense", "경비")):
|
||||
assert block[part] == pytest.approx(float(live["1단위"][name]), abs=3)
|
||||
|
||||
|
||||
def test_SK_대가_블록_금액_M02_같음_하위_산근(world, tmp_path):
|
||||
key = _mine(world)
|
||||
live = _calc(world["personal"], key)
|
||||
assert not live.get("까닭") and live["1단위"]["계"] > 0
|
||||
root, folder = _lib(world, tmp_path, key)
|
||||
daega, done, sg_rows = _b09(root)
|
||||
block = done[daega["libs"][folder]]
|
||||
assert block["cover"].startswith("P:") and block["kind"] == "품셈"
|
||||
lines = block["lines"]
|
||||
assert all(ln.get("sk") for ln in lines)
|
||||
assert lines[0]["kind"] == "labor" and lines[0]["qty"] == 0.5
|
||||
assert lines[0]["poom"] == {"qty": 0.018, "단가": 233250} # M02 에서 고친 줄 = 품셈 값 곁
|
||||
assert "poom" not in lines[1]
|
||||
sub = next(ln for ln in lines if ln["kind"] == "sangeun")
|
||||
assert sub["key"] in sg_rows and sub["key"].startswith(f"SG:{PCT}") and sub["qty"] == 0.5
|
||||
_same_money(block, live)
|
||||
|
||||
|
||||
def test_SK_단가_덮개_속_일위대가_펼침(world, tmp_path):
|
||||
if not _engine_ready(world):
|
||||
pytest.skip("715-1b 엔진 입력 덧칸(단가 · 값묶음) 뒤 — 지금은 M02 SK 셈도 못 함")
|
||||
key = _mine(world, full=True)
|
||||
live = _calc(world["personal"], key)
|
||||
root, folder = _lib(world, tmp_path, key)
|
||||
daega, done, _ = _b09(root)
|
||||
block = done[daega["libs"][folder]]
|
||||
lines = block["lines"]
|
||||
assert lines[1]["kind"] == "fixed" and lines[1]["prices"]["labor"] == pytest.approx(200000)
|
||||
sc_line = next(ln for ln in lines if ln["kind"] == "daega")
|
||||
assert done[sc_line["key"]]["kind"] == "사전조합" and sc_line["qty"] == 2
|
||||
_same_money(block, live)
|
||||
|
||||
|
||||
def test_구조물_사본_SK_줄_업데이트_덮개_입력값_고른자재_굳힌키_그대로(
|
||||
world, tmp_path, monkeypatch
|
||||
):
|
||||
key = _mine(world)
|
||||
ref = {**MINE, "키": key}
|
||||
with store.at(world["personal"]):
|
||||
from M02_MasterTemplete import M02_Labor_Doc as labor
|
||||
|
||||
doc = labor.clean(
|
||||
{
|
||||
"열": "rv",
|
||||
"담은로직": [
|
||||
{"참조": ref, "수량": {"손": 2}},
|
||||
{"로직": "GF000219", "입력값": {}, "수량": {"손": 1}},
|
||||
{"로직": TWO_MATS, "수량": {"손": 1}},
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
def build(_item_): # 원본 층 → 사본 파일(구조물 원본 대신 이 일위대가 문서)
|
||||
rows = source.labor_doc_rows(doc)
|
||||
return _json(
|
||||
{
|
||||
source.LABOR_FILE: rows,
|
||||
source.PRICE_FILE: source.price_bundle(rows, world["personal"]),
|
||||
snapshots.VALUES_FILE: {},
|
||||
}
|
||||
)
|
||||
|
||||
monkeypatch.setattr(item_copy, "build_snapshot", build)
|
||||
root = _project(tmp_path / "proj", [(100, "SS000901")])
|
||||
folder = snapshots.structures_dir(root) / snapshots.folder_name("pipe@100.0")
|
||||
folder.mkdir(parents=True)
|
||||
values = {"GF000219": {k: {"값": v} for k, v in STONE.items()}}
|
||||
picks = {TWO_MATS: {"1": "MT000001"}}
|
||||
for name, data in {
|
||||
**build(None),
|
||||
snapshots.VALUES_FILE: values,
|
||||
snapshots.PICKS_FILE: picks,
|
||||
snapshots.MANIFEST_NAME: {"원본": {**_item("SS000901"), "경로": ["x"]}},
|
||||
}.items():
|
||||
(folder / name).write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
|
||||
rid = refs.ref_id(ref)
|
||||
frozen_key = json.loads((folder / source.LABOR_FILE).read_text("utf-8"))["참조사본"][rid]
|
||||
frozen_key = frozen_key["바탕"]["로직"]
|
||||
daega, _, _ = _b09(root)
|
||||
combo = next(b for b in daega["blocks"] if b["id"] == daega["owners"]["pipe@100.0"])
|
||||
sk_block = next(b for b in daega["blocks"] if b["id"] == combo["lines"][0]["key"])
|
||||
cid = sk_block["lines"][0]["cid"]
|
||||
cover = {sk_block["cover"]: {cid: {"qty": 0.9}}}
|
||||
# SK 고침(품 0.5 → 0.7) → [재적용](업데이트) = 다시 풀어 굳힘
|
||||
_set_cover(world, key, {**COVER, "labor:LB000036#0": {"qty": 0.7}})
|
||||
item_copy.reapply(root, folder.name)
|
||||
after = json.loads((folder / source.LABOR_FILE).read_text("utf-8"))["참조사본"][rid]
|
||||
assert after["바탕"]["로직"] == frozen_key # 굳힌 키 그대로
|
||||
assert (
|
||||
json.loads((folder / snapshots.VALUES_FILE).read_text("utf-8"))["GF000219"]
|
||||
== (values["GF000219"])
|
||||
)
|
||||
assert json.loads((folder / snapshots.PICKS_FILE).read_text("utf-8")) == picks
|
||||
daega2, done, _ = _b09(root, cover)
|
||||
block = next(b for b in done.values() if b["cover"] == sk_block["cover"])
|
||||
line = block["lines"][0]
|
||||
assert line["cid"] == cid and line["covered"] == ["qty"] and line["qty"] == 0.9
|
||||
assert line["base"]["qty"] == 0.7 # 덮개 밑 = 새 단가산출 값(「단가산출 값으로」 되돌림 값)
|
||||
assert daega2["owners"]["pipe@100.0"] in done
|
||||
|
||||
|
||||
def test_서고_SK_업데이트_미리보기_다시_굳힘(world, tmp_path):
|
||||
key = _mine(world)
|
||||
root, folder = _lib(world, tmp_path, key)
|
||||
assert library.changed(root) == []
|
||||
before = daega_inputs(root)
|
||||
cover_key = next(b for b in before["blocks"] if b["id"] == before["libs"][folder])["cover"]
|
||||
_set_cover(world, key, {**COVER, "labor:LB000036#0": {"qty": 0.7}})
|
||||
got = refresh.preview(root, None, [], None)
|
||||
assert [(x["키"], x["판"]) for x in got["단가산출"]] == [(key, "바뀜")]
|
||||
assert [c["폴더"] for c in got["사본"]] == [folder]
|
||||
assert refresh.reapply_changed(root)["다시"] == ["내 단가산출"]
|
||||
assert library.changed(root) == [] and [f.name for f, _ in library.copies(root)] == [folder]
|
||||
after = daega_inputs(root)
|
||||
block = next(b for b in after["blocks"] if b["id"] == after["libs"][folder])
|
||||
assert block["cover"] == cover_key and block["lines"][0]["qty"] == 0.7
|
||||
|
||||
|
||||
def test_내_자료집_SK_왕복_같은_금액(world, tmp_path):
|
||||
key = _mine(world)
|
||||
root, folder = _lib(world, tmp_path, key)
|
||||
daega, _, _ = _b09(root)
|
||||
block = next(b for b in daega["blocks"] if b["id"] == daega["libs"][folder])
|
||||
first, second = block["lines"][0]["cid"], block["lines"][1]["cid"]
|
||||
priced = {block["cover"]: {first: {"qty": 0.9}, second: {"labor": 210000}}}
|
||||
made = personal.build(root, block["cover"], priced, "내 SK 2")
|
||||
doc = made["문서"]
|
||||
assert doc["종류"] == "단가산출" and doc["바탕"] == {"로직": PCT}
|
||||
assert doc["덮개"]["labor:LB000036#0"] == {"qty": 0.9}
|
||||
assert doc["덮개"]["labor:LB000002#0"] == {"labor": 210000}
|
||||
assert doc["더한줄"][0]["요소"] == "LB000002"
|
||||
assert made["담음"] == {"품": 1, "단가": 1} and made["안담음"] == {"품": 0, "단가": 0}
|
||||
third = block["lines"][2]["cid"] # 더한줄 인력 = 그 더한줄 수량
|
||||
cover = {block["cover"]: {first: {"qty": 0.9}, third: {"qty": 0.3}}}
|
||||
doc = personal.build(root, block["cover"], cover, "내 SK 2")["문서"]
|
||||
assert doc["더한줄"][0]["수량"] == 0.3 and doc["덮개"] == {"labor:LB000036#0": {"qty": 0.9}}
|
||||
_, done, _ = _b09(root, cover)
|
||||
want = done[daega["libs"][folder]]["sum"]
|
||||
with store.at(world["personal"]):
|
||||
new = personal.write(doc)["이름"]
|
||||
with pytest.raises(store.StoreError) as same:
|
||||
personal.write(doc)
|
||||
assert same.value.status == 409 and same.value.detail["같은이름"] == new
|
||||
assert new.startswith("SK-") and new != key
|
||||
root2, folder2 = _lib(world, tmp_path / "b", new)
|
||||
daega2, done2, _ = _b09(root2)
|
||||
assert done2[daega2["libs"][folder2]]["sum"] == pytest.approx(want, abs=2)
|
||||
|
||||
|
||||
def test_내_자료집_품셈_블록_SK(world, tmp_path):
|
||||
root = _project(tmp_path / "proj", [(100, "SS000901")])
|
||||
_snap(root, "pipe@100.0", "SS000901", [{"로직": PCT, "승격": True, "수량": {"손": 1}}], {})
|
||||
daega = daega_inputs(root)
|
||||
top = next(b for b in daega["blocks"] if b["id"] == daega["owners"]["pipe@100.0"])
|
||||
block = next(b for b in daega["blocks"] if b["id"] == top["lines"][0]["key"])
|
||||
assert block["kind"] == "품셈" and block["origin"]["로직"] == PCT
|
||||
cover = {block["cover"]: {block["lines"][0]["cid"]: {"qty": 0.5}}}
|
||||
doc = personal.build(root, block["cover"], cover, "내 해체")["문서"]
|
||||
assert doc["바탕"] == {"로직": PCT} and doc["단위"] == "㎡"
|
||||
assert doc["덮개"] == {"labor:LB000036#0": {"qty": 0.5}}
|
||||
with store.at(world["personal"]):
|
||||
new = personal.write(doc)["이름"]
|
||||
plain = _sk(world["personal"], 이름="견줌", 바탕={"로직": PCT}, 덮개=doc["덮개"])
|
||||
a, b = _calc(world["personal"], new), _calc(world["personal"], plain)
|
||||
assert a["1단위"]["계"] == b["1단위"]["계"] > 0
|
||||
@@ -97,22 +97,23 @@ WRITE_CO = READ_CO.replace("CompanyRead", "CompanyWrite")
|
||||
|
||||
|
||||
@pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음")
|
||||
def test_시스템_관리자_줄_칸별_여덟_고른_회사_해시():
|
||||
# 716-2 · 718-3 — 대시보드 설계 기본값 = 칸별 줄: M01 4(마스터 요소 · 단가산출 로직 · 구조물 옵션 정의 ·
|
||||
# 별칭 장부) +
|
||||
# M02 3(일위대가 · 구조물 조합 · 도면 양식) + 계산 자리
|
||||
def test_시스템_관리자_줄_칸별_아홉_고른_회사_해시():
|
||||
# 716-2 · 718-3 · 717-2a — 대시보드 설계 기본값 = 칸별 줄: M01 4(마스터 요소 · 단가산출 로직 ·
|
||||
# 구조물 옵션 정의 · 별칭 장부) +
|
||||
# M02 4(단가산출 · 일위대가 · 구조물 조합 · 도면 양식) + 계산 자리
|
||||
assert _cards({"role": "SYSTEM_ADMIN", "is_master": False}, 3) == [
|
||||
["M01_Title", READ_SRC, M01],
|
||||
["B01_Dashboard_CardLogic", READ_SRC, f"{M01}?open=로직"],
|
||||
["B01_Dashboard_CardStructOptionDef", WRITE_M, f"{M01}?open=옵션"],
|
||||
["B01_Dashboard_CardAliasLedger", WRITE_M, f"{M01}?open=별칭"],
|
||||
["B01_Dashboard_CardSangeun", WRITE_M, f"{M02}?open=단가산출&root=master&company=3"],
|
||||
["B01_Dashboard_CardPrecombo", WRITE_M, f"{M02}?open=일위대가조합&root=master&company=3"],
|
||||
["B01_Dashboard_CardStructCombo", WRITE_M, f"{M02}?open=구조물조합&root=master&company=3"],
|
||||
["B01_Dashboard_CardDrawing", WRITE_M, f"{M02}?open=도면&root=master&company=3"],
|
||||
CALC_SITES,
|
||||
]
|
||||
# 회사가 없으면 해시에 안 실음
|
||||
assert _cards({"role": "SYSTEM_ADMIN", "is_master": False})[5][2] == (
|
||||
assert _cards({"role": "SYSTEM_ADMIN", "is_master": False})[6][2] == (
|
||||
f"{M02}?open=구조물조합&root=master"
|
||||
)
|
||||
|
||||
@@ -243,7 +244,8 @@ def test_구조물_조합_기본_층_개인():
|
||||
134-4 — 층 없는 주소도 개인(회사 없으면 마스터) · 사전조합 기본 층도 같은 규칙."""
|
||||
combo = f"{M02}?open=구조물조합&root=personal"
|
||||
rows = _cards({"role": "SYSTEM_ADMIN", "is_master": False, "company_id": 1}, 3)
|
||||
assert rows[4:7] == [
|
||||
assert rows[4:8] == [
|
||||
["B01_Dashboard_CardSangeun", WRITE_M, f"{M02}?open=단가산출&root=personal&company=3"],
|
||||
["B01_Dashboard_CardPrecombo", WRITE_M, f"{M02}?open=일위대가조합&root=personal&company=3"],
|
||||
["B01_Dashboard_CardStructCombo", WRITE_M, f"{combo}&company=3"],
|
||||
["B01_Dashboard_CardDrawing", WRITE_M, f"{M02}?open=도면&root=master&company=3"],
|
||||
@@ -254,7 +256,7 @@ def test_구조물_조합_기본_층_개인():
|
||||
assert _cards({"role": "USER", "is_master": True, "company_id": 1})[0][2] == combo
|
||||
assert _cards({"role": "USER", "is_master": False, "company_id": 1})[0][2] == combo
|
||||
# 개인 층 없음(회사 없음) = 지금 규칙
|
||||
assert _cards({"role": "SYSTEM_ADMIN", "is_master": False, "company_id": None})[5][2] == (
|
||||
assert _cards({"role": "SYSTEM_ADMIN", "is_master": False, "company_id": None})[6][2] == (
|
||||
f"{M02}?open=구조물조합&root=master"
|
||||
)
|
||||
assert _cards({"role": "ADMIN", "is_master": False, "company_id": None})[0][2] == (
|
||||
|
||||
@@ -308,7 +308,7 @@ def test_옛_즉시_확정과_바로_만들기_없음():
|
||||
users = sorted(name for name, text in texts.items() if "buildHopyo(" in text)
|
||||
assert users == ["Hopyo_Check", "Hopyo_Sheet"] # 확인 창 · 엑셀 보기 [저장] 물음 뒤
|
||||
check = texts["Hopyo_Check"]
|
||||
assert '"사전조합 생성"' in check and "맞음 · 별칭 일괄 체크" in check
|
||||
assert '`${L("M02_PrecomboTitle")} 생성`' in check and "맞음 · 별칭 일괄 체크" in check
|
||||
assert "확인하고" not in check # 359-4 명사형
|
||||
assert "showLoadingOverlay()" in check and "dropDraft(k)" in check
|
||||
assert "m02-hopyo-check:" in check and "POLL_MS" in check
|
||||
@@ -338,8 +338,10 @@ def test_좌측_판_자리와_권한():
|
||||
side = _text("M02_MasterTemplete_UI_Side.ts")
|
||||
assert 'import { buildHopyoSection } from "./M02_MasterTemplete_Hopyo_Side";' in side
|
||||
host = side.split('className: "m02-side__lists ui-sidebar-panel"')[1]
|
||||
assert host.index("precomboSec,") < host.index("section(hopyo.title, hopyo.body),")
|
||||
assert host.index("section(hopyo.title, hopyo.body),") < host.index("extras ?")
|
||||
# 716-3 차례 — 일위대가 › 구조물 조합 › 구분선 › 일위대가 불러오기 › 도면 양식
|
||||
order = ("precomboSec,", "summary,", "createDivider(),", "section(hopyo.title, hopyo.body),")
|
||||
assert [host.index(k) for k in order] == sorted(host.index(k) for k in order)
|
||||
assert host.index("section(hopyo.title, hopyo.body),") < host.index("drawing ?")
|
||||
assert "canWrite: canEdit(layer, opt.isAdmin)," in side # 구조물 조합 층 쓰기 권한
|
||||
assert "hopyo.paint();" in side.split("function paint(): void {")[1]
|
||||
hopyo_side = _text("M02_MasterTemplete_Hopyo_Side.ts")
|
||||
@@ -354,14 +356,12 @@ def test_좌측_판_자리와_권한():
|
||||
assert 'accept: ".xls,.xlsx"' in wizard and "input.hidden = true;" in wizard
|
||||
assert "accessFor(layer, null, user).canWrite" in wizard # 결과 층 = 쓸 수 있는 층만
|
||||
assert 'let pick: ManageLayer = "personal";' in wizard # 기본 개인
|
||||
assert "export const SANGEUN_READY = false;" in wizard # 단가산출 결과 = 715-2 뒤 켬
|
||||
assert "let wantSangeun = false;" in wizard and "/sangeun`" in wizard # 결과 단가산출 체크
|
||||
|
||||
|
||||
def test_로직_추가_한_곳과_파일_규칙():
|
||||
detail = _text("M02_MasterTemplete_Labor_Detail.ts")
|
||||
assert (
|
||||
len(detail.splitlines()) == 700
|
||||
) # 줄 수 그대로(361-2 전국 자재 부르는 줄 둘 · 553-5 보조 글 부품 -2 · 704-3 격자 +7)
|
||||
assert len(detail.splitlines()) <= 700 # 700 줄 넘지 않음(717-3 나눔으로 줄어듦)
|
||||
assert "withHopyo(library)," in detail
|
||||
pick = _text("M02_MasterTemplete_Labor_Pick.ts")
|
||||
assert "more?: () => Promise<PickExtra[]>;" in pick and "...later" in pick
|
||||
@@ -441,7 +441,8 @@ def test_확인_창_손질_339():
|
||||
assert make.index("document.dispatchEvent(new Event(HOPYO_BUILT));") < make.index("opt.onBuilt")
|
||||
side = _text("M02_MasterTemplete_UI_Side.ts")
|
||||
assert "document.addEventListener(HOPYO_BUILT, onBuilt);" in side
|
||||
assert "void precombo.refresh()" in side.split("const onBuilt")[1].split(";")[0]
|
||||
onb = side.split("const onBuilt")[1].split(";")[0]
|
||||
assert "precombo.refresh()" in onb and "sangeun.refresh()" in onb # 717-2a — 둘 다 새로고침
|
||||
sheet = _text("M02_MasterTemplete_Hopyo_Sheet.ts")
|
||||
assert "poll(file);" in sheet and "poll(next);" in sheet and "POLL_MS" in sheet
|
||||
assert "if (ok) document.dispatchEvent(new Event(HOPYO_BUILT));" in sheet
|
||||
|
||||
@@ -121,7 +121,7 @@ def test_화면_칸_부품_사용자_줄_로직_줄_같은_칸():
|
||||
marks = _src("M02_MasterTemplete_Labor_MatMarks.ts")
|
||||
assert '"—"' not in marks # 「—」 항목 없음(162-1)
|
||||
assert "e.stopPropagation()" in marks # 줄 누름과 따로
|
||||
rows = _src("M02_MasterTemplete_Labor_Rows.ts")
|
||||
rows = _src("M02_MasterTemplete_Labor_MatTable.ts") # 717-3 자재목록 표 나눔
|
||||
assert "...markCells(mat, on?.mark" in rows and "...markHeads()," in rows
|
||||
assert "...markCells(" in _src("M02_MasterTemplete_Labor_LogicMats.ts")
|
||||
detail = _src("M02_MasterTemplete_Labor_Detail.ts")
|
||||
|
||||
@@ -70,7 +70,7 @@ def _promote(folder, code=TEN, values=None, **extra):
|
||||
|
||||
def _combo(folder, lines, name="조합"):
|
||||
with store.at(folder):
|
||||
return refs.create("precombo", {"이름": name, "담은로직": lines})["이름"]
|
||||
return refs.create("precombo", {"이름": name, "단위": "㎡", "담은로직": lines})["이름"]
|
||||
|
||||
|
||||
def _read(folder, kind, key):
|
||||
@@ -225,11 +225,15 @@ def test_calc_는_참조를_펼침_승격은_기준_나누기_배_사전조합
|
||||
one = got["줄"][0]
|
||||
want = Decimal(str(_base(TEN)["계"])) / 10 * 2
|
||||
assert one["기준"]["계"] == pytest.approx(float(want))
|
||||
assert (one["분모"], one["배"], one["알약"]) == (None, 1, refs.ref_id(ref))
|
||||
assert (one["분모"], one["배"], one["알약"]) == (
|
||||
"㎡",
|
||||
1,
|
||||
refs.ref_id(ref),
|
||||
) # 단위 = 그 사전조합 것
|
||||
assert one["반영"]["계"] == pytest.approx(float(want * 5))
|
||||
assert one["안"][0]["수량"] == 2 and one["안"][0]["참조"]["키"] == sp
|
||||
pills = labor.pills(doc)
|
||||
assert pills == [{"키": refs.ref_id(ref), "이름": "둘", "단위": "", "로직": [], "참조": ref}]
|
||||
assert pills == [{"키": refs.ref_id(ref), "이름": "둘", "단위": "㎡", "로직": [], "참조": ref}]
|
||||
|
||||
|
||||
def test_승격_줄_상세는_원_로직_키_표_사전조합은_안_줄만(world):
|
||||
@@ -364,7 +368,7 @@ def test_길_새로_복사_권한(world):
|
||||
}
|
||||
client = TestClient(app)
|
||||
url = "/api/m02/layers/{}/library/{}"
|
||||
body = {"문서": {"이름": "가", "담은로직": [{"로직": TEN}]}}
|
||||
body = {"문서": {"이름": "가", "단위": "㎡", "담은로직": [{"로직": TEN}]}}
|
||||
got = client.post(url.format("personal", "precombo"), json=body).json()
|
||||
assert got["이름"] == "SC-000001"
|
||||
assert client.post(url.format("system", "precombo"), json=body).status_code == 403
|
||||
|
||||
@@ -89,7 +89,8 @@ def test_주소_인자를_라우트에서_뗌():
|
||||
assert (
|
||||
'raw === "master" || raw === "company" || raw === "personal"' in layer
|
||||
) # 없음 = 기본(134-4)
|
||||
assert '["구조물조합", "옵션", "도면", "일위대가조합"]' in layer # 일위대가 조합 서고 = 50-3
|
||||
# 일위대가 조합 서고 = 50-3 · 단가산출 = 717-2a
|
||||
assert '["구조물조합", "옵션", "도면", "일위대가조합", "단가산출"]' in layer
|
||||
picker = _text(ROOT / "ui_template" / "ui_template_item_picker.ts")
|
||||
assert 'open: "구조물조합", root: "personal"' in picker and "layer=personal" not in picker
|
||||
|
||||
|
||||
@@ -62,7 +62,7 @@ def test_옛_품셈_분류는_미분류_새_값은_그대로():
|
||||
|
||||
|
||||
def test_화면_원천은_구조물_정의_새_문서는_미분류():
|
||||
detail = (M02 / "M02_MasterTemplete_Labor_Detail.ts").read_text("utf-8")
|
||||
detail = (M02 / "M02_MasterTemplete_Labor_LibMeta.ts").read_text("utf-8") # 717-3 나눔
|
||||
assert "subs = precomboClasses(def);" in detail and "fetchLibLogicSubs" not in detail
|
||||
assert '[...new Set([now ?? "", ...names])]' in detail # 지금 값도 선택지 그대로
|
||||
lib = (M02 / "M02_MasterTemplete_Library_List.ts").read_text("utf-8")
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""일위대가 사전조합 나무 = 자유 폴더(PLAN 158-1 · `M02_Precombo_Folders`) — 임시 storage.
|
||||
|
||||
새 폴더(깊이 자유) · 문서 옮기기(파일은 제자리 · 자리만) · 폴더 이름 바꾸기 ·
|
||||
옮기기(아래 자리 따라감) · 지우기(빈 폴더만 · 문서 있으면 409 까닭) · 판 409 ·
|
||||
옮기기(아래 자리 따라감) · 지우기(안에 든 문서째 · 716-4) · 판 409 ·
|
||||
층(마스터 · 회사 · 개인)마다 따로 · 문서 수(양식 목록) 그대로 · 쓰기 권한 = 층 규칙(마스터 403).
|
||||
"""
|
||||
|
||||
@@ -88,7 +88,7 @@ def test_문서_옮기기는_자리만_파일과_문서_수_그대로(client: Te
|
||||
assert t.now["자리"] == {"SC-000001": ["옹벽", "찰쌓기"], "SC-000002": ["옹벽"]}
|
||||
t.do(op="move", 키="SC-000002", 새경로=[]) # 뿌리로 = 자리 지움
|
||||
assert t.now["자리"] == {"SC-000001": ["옹벽", "찰쌓기"]}
|
||||
assert t.do(404, op="move", 키="SC-000009", 새경로=[])["detail"].startswith("없는 사전조합")
|
||||
assert t.do(404, op="move", 키="SC-000009", 새경로=[])["detail"].startswith("없는 문서")
|
||||
assert t.do(404, op="move", 키="SC-000001", 새경로=["없음"])
|
||||
personal = layers.personal_dir(7, 42)
|
||||
assert sorted(p.name for p in (personal / "precombo").iterdir()) == [
|
||||
@@ -97,7 +97,7 @@ def test_문서_옮기기는_자리만_파일과_문서_수_그대로(client: Te
|
||||
"SC-000003.json",
|
||||
]
|
||||
assert _docs(client) == ["SC-000001", "SC-000002", "SC-000003"] # 자리 파일은 목록에 안 섞임
|
||||
assert (personal / pre.FILE).is_file()
|
||||
assert (personal / pre.file_of("precombo")).is_file()
|
||||
|
||||
|
||||
def test_폴더_이름_바꾸기_옮기기는_아래_자리가_따라감(client: TestClient):
|
||||
@@ -117,17 +117,16 @@ def test_폴더_이름_바꾸기_옮기기는_아래_자리가_따라감(client:
|
||||
assert t.now["알림"]["바뀐이름"] == ["나(1)"] and t.now["자리"]["SC-000001"] == ["나(1)"]
|
||||
|
||||
|
||||
def test_지우기는_빈_폴더만_문서가_있으면_까닭(client: TestClient):
|
||||
def test_지우기는_안에_든_문서째(client: TestClient):
|
||||
t = Tree(client)
|
||||
t.do(op="add-folder", 경로=[], 이름="가")
|
||||
t.do(op="add-folder", 경로=["가"], 이름="나")
|
||||
t.do(op="add-folder", 경로=["가", "나"], 이름="다")
|
||||
t.do(op="move", 키="SC-000003", 새경로=["가", "나", "다"])
|
||||
assert "사전조합 1개" in t.do(409, op="delete", 경로=["가"])["detail"]
|
||||
t.do(op="move", 키="SC-000003", 새경로=[])
|
||||
t.do(op="delete", 경로=["가"]) # 빈 아래 폴더까지
|
||||
got = t.do(op="delete", 경로=["가"]) # 아래 폴더 · 문서까지(716-4)
|
||||
assert got["알림"]["지운문서"] == ["SC-000003"]
|
||||
assert t.now["폴더"] == [] and t.now["자리"] == {}
|
||||
assert _docs(client) == ["SC-000001", "SC-000002", "SC-000003"]
|
||||
assert _docs(client) == ["SC-000001", "SC-000002"]
|
||||
|
||||
|
||||
def test_판이_다르면_409_고침_안_함(client: TestClient):
|
||||
|
||||
@@ -84,7 +84,8 @@ def test_고름에_owner_권한_서고_길에_명시_owner():
|
||||
side = _text("M02_MasterTemplete_UI_Side_Precombo.ts")
|
||||
assert "PRECOMBO_TITLE" not in side # 716-3 — 제목은 locale 키 「일위대가」
|
||||
assert "owner: scope.owner," in side and "canWrite: scope.canWrite," in side
|
||||
assert "listTemplates(at.layer, null, at.owner)" in side
|
||||
lib_side = _text("M02_MasterTemplete_UI_Side_Library.ts") # 716-4 서고 칸 한 벌
|
||||
assert "listTemplates(at.layer, null, at.owner)" in lib_side
|
||||
main = _text("M02_MasterTemplete_UI_Main.ts")
|
||||
assert (
|
||||
"const writable = (at: Selection): boolean => at.canWrite ?? canEdit(at.layer, isAdmin);"
|
||||
@@ -100,23 +101,26 @@ def test_고름에_owner_권한_서고_길에_명시_owner():
|
||||
|
||||
|
||||
def test_분류_고정_폴더_지움_자유_폴더_메뉴_끌어_옮기기():
|
||||
side = _text("M02_MasterTemplete_UI_Side_Precombo.ts")
|
||||
pre_side = _text("M02_MasterTemplete_UI_Side_Precombo.ts")
|
||||
side = _text("M02_MasterTemplete_UI_Side_Library.ts") # 716-4 서고 칸 한 벌
|
||||
for gone in ("groupRows", "fetchStructureDef", "Lib_Unsorted", "shape"):
|
||||
assert gone not in side, gone
|
||||
assert "fetchPrecomboTree(at.layer, at.owner)" in side
|
||||
assert "editPrecomboTree(at.layer, body, view.판, at.owner)" in side
|
||||
assert gone not in side and gone not in pre_side, gone
|
||||
assert "fetchPrecomboTree(at.layer, at.owner, kind)" in side
|
||||
assert "editPrecomboTree(at.layer, body, 판, at.owner, kind)" in side
|
||||
menu = side.split("const menuAt = (")[1].split("\n };")[0]
|
||||
for label in ('L("M02_TreeAddFolder")', 'tc("Lib_NewHere")', 'L("M02_TreeRename")'):
|
||||
for label in ('L("M02_TreeAddFolder")', "opt.text.newHere", 'L("M02_TreeRename")'):
|
||||
assert label in menu, label
|
||||
assert 'L("M02_Delete"), () => void remove(path, count)' in menu
|
||||
assert 'item(L("M02_Delete"), () => void remove([target]))' in menu
|
||||
assert 'opt.scope().canWrite ? undefined : tc("ReadOnly")' in menu # 읽기만 층 = 흐림
|
||||
remove = side.split("const remove = async (")[1].split("\n };")[0]
|
||||
assert 'if (count) return void showToast(tc("Lib_FolderFull")' in remove # 빈 폴더만
|
||||
assert "showConfirmDialog(ask" in remove and 'op: "delete", 대상:' in remove # 안에 든 것째
|
||||
assert 'newHere: tc("Lib_NewHere")' in pre_side
|
||||
assert '({ 이름: name, 구분: "", 상세구분: "", 단위: null, 담은로직: [] })' in pre_side
|
||||
make = side.split("const make = (path: string[]): void => {")[1].split("\n };")[0]
|
||||
assert 'const 문서 = { 이름: name, 구분: "", 상세구분: "", 단위: null, 담은로직: [] };' in make
|
||||
assert "{ 문서: opt.blank(name) }" in make
|
||||
assert 'if (path.length) await edit({ op: "move", 키: got.이름, 새경로: path });' in make
|
||||
assert "attachTreeDrag({" in side and "if (canWrite) drag.arm(row, node.id);" in side
|
||||
assert "openTreeMenu(event, menuAt([], 0));" in side # 빈 자리 = 뿌리 메뉴
|
||||
assert "openTreeMenu(event, menuAt([]));" in side # 빈 자리 = 뿌리 메뉴
|
||||
assert "open: toggled.get(id) ?? count > 0," in side
|
||||
lib = _text("M02_MasterTemplete_Library_List.ts")
|
||||
assert "export const nameDialog = (" in lib
|
||||
@@ -125,7 +129,7 @@ def test_분류_고정_폴더_지움_자유_폴더_메뉴_끌어_옮기기():
|
||||
def test_목록표_빈_구분_미분류_고르기_창_새_분류_거름():
|
||||
"""149-2 — 목록표 빈 구분 = 미분류 · 고르기 창 거름 = 품셈 구분 + 사전조합 새 분류"""
|
||||
lib = _text("M02_MasterTemplete_Library_List.ts")
|
||||
assert 'row.구분 || tc("Lib_Unsorted"),' in lib
|
||||
assert '(r) => r.구분 || tc("Lib_Unsorted")],' in lib # 717-2a 열 표(COLUMNS)
|
||||
pick = _text("M02_MasterTemplete_Labor_Pick.ts")
|
||||
assert "new Map([...subs, ...classes].map((s) => [s.name, new Set(s.details)]))" in pick
|
||||
assert "classes = precomboClasses(def);" in pick
|
||||
@@ -139,6 +143,7 @@ def test_왼쪽_칸_차례_일위대가_구조물조합_구분선_불러오기_7
|
||||
side = _text("M02_MasterTemplete_UI_Side.ts")
|
||||
order = side[side.index("m02-side__lists") :]
|
||||
marks = [
|
||||
"sangeunSec,", # 717-2a 맨 위
|
||||
"precomboSec,",
|
||||
"summary,",
|
||||
"createDivider()",
|
||||
|
||||
Reference in New Issue
Block a user