Merge remote-tracking branch 'origin/sub_laptop_2' into main_laptop_1
This commit is contained in:
@@ -14,9 +14,12 @@ import re
|
||||
|
||||
import master_formula as mf
|
||||
|
||||
MAX_NAME = 12 # 이 글자 수를 넘는 표 이름은 억지로 줄이지 않고 표 키를 그대로 보임
|
||||
MAX_NAME = 12 # 짧은 이름의 길이 상한
|
||||
MAX_WORD = 8 # 별칭 꼬리(찾은 줄 값) 한 낱말의 길이 상한
|
||||
_DROP = re.compile(r"[^\w]", re.UNICODE) # 별칭에 못 쓰는 글자(빈칸 · 괄호 · 가운뎃점 …)
|
||||
_SPACE = re.compile(r"\s+")
|
||||
_PAREN = re.compile(r"\([^()]*\)")
|
||||
_WORDS = re.compile(r"[\s·・,/:+×*~∼]+")
|
||||
_QUOTED = re.compile(r"'[^']*'")
|
||||
RESERVED = frozenset((*mf.FUNCS, *mf.COST_ITEMS, "찾기", "로직", "그리고", "또는", "계", "줄"))
|
||||
|
||||
@@ -98,15 +101,146 @@ def plain(text) -> str:
|
||||
return _DROP.sub("", str(text or ""))
|
||||
|
||||
|
||||
def _unparen(text: str, keep: bool) -> str:
|
||||
"""괄호 묶음을 벗김 — `keep` 이면 숫자 없는 묶음(공기식 · 건설)만 글로 남김."""
|
||||
while True:
|
||||
got = _PAREN.sub(
|
||||
lambda m: f" {m.group()[1:-1]} " if keep and not re.search(r"\d", m.group()) else " ",
|
||||
text,
|
||||
)
|
||||
if got == text:
|
||||
return got
|
||||
text = got
|
||||
|
||||
|
||||
def _core(text: str) -> list[str]:
|
||||
"""앞 핵심 낱말 — 머리 번호(1급 · 5-3)는 건너뛰고, 그 뒤 숫자 든 낱말(규격)에서 끊음."""
|
||||
out: list[str] = []
|
||||
for word in _WORDS.split(text):
|
||||
word = plain(word)
|
||||
if not word:
|
||||
continue
|
||||
if re.search(r"\d", word):
|
||||
if out:
|
||||
break
|
||||
continue
|
||||
out.append(word)
|
||||
return out
|
||||
|
||||
|
||||
def _fit(words: list[str], cap: int) -> str:
|
||||
"""낱말들을 이어 이름 하나로 — 길면 첫 낱말 + 끝 낱말 · 그래도 길면 첫 낱말을 자름."""
|
||||
tries = ["".join(words), *([words[0] + words[-1], words[0][:cap]] if words else [])]
|
||||
return next((one for one in tries if one and len(one) <= cap), "")
|
||||
|
||||
|
||||
def short(text, cap: int = MAX_NAME) -> str:
|
||||
"""사람이 읽는 짧은 이름 — 규격·코드를 떼고, 길면 괄호 속 · 가운데 낱말 순으로 버림.
|
||||
늘 같은 글이면 같은 이름 · 글자 · 숫자 · 밑줄만 · 숫자로 시작하지 않음(못 지으면 빈 글)."""
|
||||
text = str(text or "")
|
||||
kept = "".join(_core(_unparen(text, True)))
|
||||
return kept if kept and len(kept) <= cap else _fit(_core(_unparen(text, False)), cap)
|
||||
|
||||
|
||||
def _base(ref: str, master) -> str:
|
||||
"""별칭 밑이름 — 표·로직의 `이름` 칸에서 · 비거나 너무 길면 키를 그대로 보임."""
|
||||
"""별칭 밑이름 — 표·로직 이름을 짧게 · 이름이 없으면 공종 · 상세구분 · 그래도 없으면 키."""
|
||||
try:
|
||||
name = plain(master.get(ref).get("이름"))
|
||||
row = master.get(ref)
|
||||
except mf.FormulaError:
|
||||
name = ""
|
||||
if not name or len(name) > MAX_NAME or name[0].isdigit():
|
||||
return _DROP.sub("", ref) or "표"
|
||||
return name
|
||||
row = {}
|
||||
use = row.get("용도") if isinstance(row.get("용도"), dict) else {}
|
||||
for text in (row.get("이름"), use.get("공종"), row.get("상세구분")):
|
||||
got = short(text)
|
||||
if got:
|
||||
return got
|
||||
return _DROP.sub("", ref) or "표"
|
||||
|
||||
|
||||
def _words(node, master) -> list[str]:
|
||||
"""부르기 인자 하나의 낱말 — 글은 그 값(요소 코드면 품명 + 규격) · 수 · 이름은 그대로."""
|
||||
kind = node[0]
|
||||
if kind == "name":
|
||||
return [node[1]]
|
||||
if kind == "num":
|
||||
return [plain(node[1])]
|
||||
if kind != "str": # 만약(…) 따위 — 안에 적힌 글 가운데 처음 읽히는 것
|
||||
return next(filter(None, (_core(_unparen(one, False)) for one in _texts(node))), [])
|
||||
for ref in (node[1], f"EQ:{node[1]}"): # 기계 입력은 원문번호(0201-0020)
|
||||
try:
|
||||
row = master.get(ref)
|
||||
except mf.FormulaError:
|
||||
continue
|
||||
if row.get("이름"):
|
||||
return [short(row["이름"]), *filter(None, [plain(row.get("규격"))])]
|
||||
return _core(_unparen(str(node[1]), False)) or [plain(node[1])]
|
||||
|
||||
|
||||
def _texts(node):
|
||||
"""마디 안의 글 값(작은따옴표) — 적힌 차례로."""
|
||||
if isinstance(node, tuple) and node[:1] == ("str",):
|
||||
yield str(node[1])
|
||||
elif isinstance(node, (tuple, list)):
|
||||
for one in node:
|
||||
yield from _texts(one)
|
||||
|
||||
|
||||
def _columns(signs: list[str], refs: list[str], master) -> list[dict[str, str]]:
|
||||
"""부르기마다 {칸: 꼬리 낱말} — 다른 표가 섞이면 표 제목도 한 칸(`""`).
|
||||
한 칸 값들이 모두 가진 낱말(`구체콘크리트`)은 떼어 서로 가르는 낱말만 남김."""
|
||||
rows: list[dict[str, list[str]]] = [{} for _ in signs]
|
||||
if len(set(refs)) > 1:
|
||||
for one, ref in zip(rows, refs):
|
||||
try:
|
||||
one[""] = _core(_unparen(str(master.get(ref).get("이름") or ""), False))
|
||||
except mf.FormulaError:
|
||||
one[""] = []
|
||||
for one, sign in zip(rows, signs):
|
||||
try:
|
||||
args = mf.parse(sign)[2]
|
||||
except (mf.FormulaError, IndexError, TypeError):
|
||||
args = []
|
||||
one.update({key: _words(value, master) for key, value in args})
|
||||
out: list[dict[str, str]] = [{} for _ in signs]
|
||||
for key in dict.fromkeys(key for one in rows for key in one):
|
||||
lists = [one.get(key, []) for one in rows]
|
||||
common = set.intersection(*map(set, lists))
|
||||
if not all(set(words) - common for words in lists):
|
||||
common = set()
|
||||
for got, words in zip(out, lists):
|
||||
got[key] = _fit([w for w in words if w not in common], MAX_WORD)
|
||||
return out
|
||||
|
||||
|
||||
def _tails(signs: list[str], refs: list[str], master) -> list[str]:
|
||||
"""같은 밑이름의 부르기들 — 서로 가르는 인자 값만 이어 꼬리로(`철망` · `외업_고급기술자`).
|
||||
가르는 칸은 적게 — 가장 잘 가르는 칸부터 모두 갈릴 때까지 고르고, 줄마다 없어도 되는 칸은 뺌."""
|
||||
cols = _columns(signs, refs, master)
|
||||
order = list(dict.fromkeys(key for one in cols for key in one))
|
||||
|
||||
def seen(one: dict, keys: list[str]) -> tuple:
|
||||
return tuple(one.get(key, "") for key in keys)
|
||||
|
||||
picked: list[str] = []
|
||||
left = list(order)
|
||||
while len({seen(one, picked) for one in cols}) < len(cols) and left:
|
||||
best = max(left, key=lambda key: len({seen(one, [*picked, key]) for one in cols}))
|
||||
if len({seen(one, [*picked, best]) for one in cols}) == len(
|
||||
{seen(o, picked) for o in cols}
|
||||
):
|
||||
break
|
||||
picked.append(best)
|
||||
left.remove(best)
|
||||
out = []
|
||||
for at, one in enumerate(cols):
|
||||
mine = list(picked)
|
||||
for key in reversed(picked): # 늦게 고른(덜 가르는) 칸부터 빼 봄
|
||||
rest = [k for k in mine if k != key]
|
||||
others = (seen(o, rest) for j, o in enumerate(cols) if j != at)
|
||||
if not rest or seen(one, rest) in set(others):
|
||||
break
|
||||
mine = rest
|
||||
out.append("_".join(filter(None, seen(one, sorted(mine, key=order.index)))))
|
||||
return out
|
||||
|
||||
|
||||
def _free(want: str, taken: set[str]) -> str:
|
||||
@@ -144,8 +278,11 @@ def aliases(row: dict, master) -> list[dict]:
|
||||
taken |= set(_about(kind, ref, master)["값칸"])
|
||||
for base, signs in bunch.items():
|
||||
gap = "_" if base[-1:].isdigit() else "" # 키를 밑이름으로 쓴 자리 — 번호가 붙지 않게
|
||||
for at, sign in enumerate(signs):
|
||||
name_of[sign] = _free(f"{base}{gap}{at + 1}" if len(signs) > 1 else base, taken)
|
||||
tails = _tails(signs, [seen[s][1] for s in signs], master) if len(signs) > 1 else [""]
|
||||
for at, (sign, tail) in enumerate(zip(signs, tails)):
|
||||
alone = len(signs) == 1 or any(tails) # 꼬리 없는 하나는 밑이름만
|
||||
want = f"{base}_{tail}" if tail else base if alone else f"{base}{gap}{at + 1}"
|
||||
name_of[sign] = _free(want, taken)
|
||||
|
||||
out = []
|
||||
for sign in order:
|
||||
|
||||
@@ -118,7 +118,7 @@ def _elem_name(master, ref: str) -> str:
|
||||
): # 별칭 이름이 표 열쇠 그대로인 것(QC…) — 밑이름을 풀어쓰지 않음
|
||||
return ref
|
||||
try:
|
||||
name = mc.plain(master.get(ref).get("이름"))
|
||||
name = mc.short(master.get(ref).get("이름"))
|
||||
except mf.FormulaError:
|
||||
name = ""
|
||||
return f"{name}{ELEM_TAIL.get(str(ref)[:2], '단가')}" if name else ref
|
||||
@@ -389,12 +389,24 @@ def _line_text(item: dict, line: dict, env: dict, master, part: str | None) -> s
|
||||
return f"{fmt_price(price)} * {qty} = {fmt_money(money)}"
|
||||
|
||||
|
||||
def _line_names(item: dict, line: dict, env: dict, master, part: str | None, name_of: dict) -> str:
|
||||
def _item_names(items: list) -> list[tuple[str, str]]:
|
||||
"""호표 줄마다 (짧은 품명(규격·코드 뗌), 겹침 꼬리) — 한 로직 안에서 겹치면 꼬리 `_2`."""
|
||||
taken: set[str] = set()
|
||||
out = []
|
||||
for item in items:
|
||||
name = mc.short(item.get("이름")) or "값"
|
||||
out.append((name, mc._free(name, taken)[len(name) :]))
|
||||
return out
|
||||
|
||||
|
||||
def _line_names(
|
||||
item: dict, env: dict, master, part: str | None, name_of: dict, label: tuple[str, str]
|
||||
) -> str:
|
||||
"""같은 줄을 변수 이름으로 — 「석공노임 * 메쌓기인력.석공 * (1 + 증가율) = 석공비」."""
|
||||
name = mc.plain(item.get("이름") or line.get("이름")) or "값"
|
||||
name, dup = label
|
||||
tail = PRICE_TAIL.get(str(item.get("종류") or ""), "단가")
|
||||
price = f"{name}{part or ''}{tail}"
|
||||
money = f"{name}{part}" if part else f"{name}비"
|
||||
price = f"{name}{part or ''}{tail}{dup}"
|
||||
money = f"{name}{part}{dup}" if part else f"{name}비{dup}"
|
||||
qty = _times(_names(item["수량"], env, master, name_of), item, env, master)
|
||||
return f"{price} * {qty} = {money}"
|
||||
|
||||
@@ -406,7 +418,7 @@ def _extra_text(extra: dict, line: dict, env: dict, master) -> str:
|
||||
|
||||
|
||||
def _extra_names(extra: dict, env: dict, master, name_of: dict) -> str:
|
||||
got = mc.plain(extra.get("이름")) or "덧줄"
|
||||
got = mc.short(extra.get("이름")) or "덧줄"
|
||||
return f"{_names(extra['식'], env, master, name_of)} = {got}"
|
||||
|
||||
|
||||
@@ -437,7 +449,7 @@ def lines(files: dict, key: str, given: dict) -> dict:
|
||||
|
||||
if "결과" in row:
|
||||
조각 = _formula_pieces(row["결과"]["식"], env, master, name_of, alias_by_name)
|
||||
got = mc.plain(row.get("이름")) or "결과"
|
||||
got = mc.short(row.get("이름")) or "결과"
|
||||
shown = (
|
||||
fmt_money if str(row.get("결과단위") or "").startswith("원") else fmt
|
||||
) # 돈 결과만 정수
|
||||
@@ -472,10 +484,11 @@ def lines(files: dict, key: str, given: dict) -> dict:
|
||||
|
||||
made: dict[str, list] = {name: [] for name in mf.COST_ITEMS}
|
||||
items = row.get("호표") or []
|
||||
labels = _item_names(items)
|
||||
if result is not None:
|
||||
shown = result["줄"]
|
||||
env["줄"] = {r["이름"]: r["금액"] for r in shown if r["이름"]}
|
||||
for item, line in zip(items, shown):
|
||||
for item, line, label in zip(items, shown, labels):
|
||||
조각 = _formula_pieces(item["수량"], env, master, name_of, alias_by_name)
|
||||
요소 = _item_pieces(item, env, master)
|
||||
for part, money in _shares(line):
|
||||
@@ -483,7 +496,7 @@ def lines(files: dict, key: str, given: dict) -> dict:
|
||||
one = {
|
||||
"이름": _label(item, line),
|
||||
"글": _line_text(item, line, env, master, split),
|
||||
"이름글": _line_names(item, line, env, master, split, name_of),
|
||||
"이름글": _line_names(item, env, master, split, name_of, label),
|
||||
"금액": money,
|
||||
"조각": 조각,
|
||||
"요소조각": 요소,
|
||||
@@ -495,7 +508,7 @@ def lines(files: dict, key: str, given: dict) -> dict:
|
||||
끝수 = _cut_note(row)
|
||||
계 = result["계"]
|
||||
else:
|
||||
for item in items:
|
||||
for item, label in zip(items, labels):
|
||||
parts = (
|
||||
list(mf.COST_ITEMS)
|
||||
if item.get("종류") == "로직" and "비목" not in item
|
||||
@@ -511,7 +524,7 @@ def lines(files: dict, key: str, given: dict) -> dict:
|
||||
{
|
||||
"이름": _label(item, {}),
|
||||
"글": "",
|
||||
"이름글": _line_names(item, {}, env, master, split, name_of),
|
||||
"이름글": _line_names(item, env, master, split, name_of, label),
|
||||
"금액": None,
|
||||
"조각": 조각,
|
||||
"요소조각": 요소,
|
||||
@@ -524,7 +537,7 @@ def lines(files: dict, key: str, given: dict) -> dict:
|
||||
"글": "",
|
||||
"이름글": (
|
||||
f"{_names(extra['식'], env, master, name_of)} = "
|
||||
f"{mc.plain(extra.get('이름')) or '덧줄'}"
|
||||
f"{mc.short(extra.get('이름')) or '덧줄'}"
|
||||
),
|
||||
"금액": None,
|
||||
"조각": _formula_pieces(extra["식"], env, master, name_of, alias_by_name),
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
계약 `resources/master_data/_화면_계약.md` 7장 · 규칙 `resources/master_data/ref/_설계_식_복사.md`.
|
||||
못박는 것
|
||||
- 13-4-1 을 식으로 펴 아무것도 안 고치고 되돌려 저장하면 원본과 계산 결과가 같음.
|
||||
- 12-4 처럼 같은 표를 다른 인자로 쓰는 로직은 별칭이 번호로 갈림.
|
||||
- 12-4 처럼 같은 표를 다른 인자로 쓰는 로직은 별칭이 찾은 줄 값으로 갈림.
|
||||
- 로직 전부(1,354줄)가 펴고 되돌리면 원래 식 글자 그대로 돌아옴.
|
||||
"""
|
||||
|
||||
@@ -83,10 +83,12 @@ def test_별칭이_식에서_이름_하나로_보이고_원문은_뒤에_남는
|
||||
assert "높이에대한증가율표.증가율" in 가로["중간[0].식"]
|
||||
|
||||
|
||||
def test_같은_표를_다른_인자로_쓰면_별칭이_번호로_갈린다(client: TestClient) -> None:
|
||||
def test_같은_표를_다른_인자로_쓰면_별칭이_찾은_줄_값으로_갈린다(client: TestClient) -> None:
|
||||
got = _get(client, "/api/m01/logic/formula", key=FORM)
|
||||
형틀 = [one for one in got["별칭"] if one["참조"] == "QF000357"]
|
||||
assert [one["이름"] for one in 형틀] == [f"합판거푸집{n}" for n in range(1, 8)]
|
||||
assert [one["이름"] for one in 형틀] == [
|
||||
f"합판거푸집_{n}" for n in ("합판", "각재", "철선", "못", "박리제", "형틀목공", "보통인부")
|
||||
]
|
||||
assert [one["원문"] for one in 형틀][:2] == [
|
||||
"찾기(QF000357, 구분='합판')",
|
||||
"찾기(QF000357, 구분='각재')",
|
||||
@@ -96,8 +98,8 @@ def test_같은_표를_다른_인자로_쓰면_별칭이_번호로_갈린다(cli
|
||||
assert len(한벌) == 1 and 한벌[0]["자리"] == ["중간[0].식", "중간[1].식"]
|
||||
|
||||
가로 = {one["자리"]: one["식"] for one in got["줄"]}
|
||||
assert 가로["호표[0].수량"] == "합판거푸집1.기준수량_1회사용 * 재료율"
|
||||
assert 가로["호표[1].수량"] == "합판거푸집2.기준수량_1회사용 * 재료율"
|
||||
assert 가로["호표[0].수량"] == "합판거푸집_합판.기준수량_1회사용 * 재료율"
|
||||
assert 가로["호표[1].수량"] == "합판거푸집_각재.기준수량_1회사용 * 재료율"
|
||||
|
||||
|
||||
def test_로직_부르기도_별칭으로_보인다(client: TestClient) -> None:
|
||||
@@ -155,7 +157,7 @@ def test_식을_고쳐_저장하면_그_값으로_계산된다(client: TestClien
|
||||
|
||||
view = _get(client, "/api/m01/logic/formula", key=FORM)
|
||||
row = view["로직"]
|
||||
row["호표"][6]["수량"] = "합판거푸집6.기준수량_1회사용 * 인력율 * 2" # 형틀목공 품 두 배
|
||||
row["호표"][6]["수량"] = "합판거푸집_형틀목공.기준수량_1회사용 * 인력율 * 2" # 형틀목공 품 두 배
|
||||
made = _post(
|
||||
client,
|
||||
"/api/m01/logic/formula/save",
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import shutil
|
||||
import sys
|
||||
from decimal import Decimal
|
||||
@@ -167,13 +168,58 @@ def test_끝수는_줄이_아니라_소계에_붙는다(client: TestClient) -> N
|
||||
assert not any("버림" in one["글"] for one in group["줄"])
|
||||
|
||||
|
||||
def test_이름_꼴_찾기_별칭이_표_열쇠_그대로면_밑이름으로_풀지_않는다(client: TestClient) -> None:
|
||||
"""GC001008 덧줄 — 별칭 이름이 QC001882 뿐이라 이름 식도 별칭 목록의 그 이름을 씀."""
|
||||
def test_이름_꼴_찾기_별칭은_표_열쇠_대신_짧은_표_제목(client: TestClient) -> None:
|
||||
"""GC001008 덧줄 — 긴 표 제목(잔디깎기 공구손료·경장비 기계경비 요율)은 첫 + 끝 낱말."""
|
||||
got, _ = _both(client, "GC001008")
|
||||
extra = next(one for one in _all_lines(got) if one["이름"].startswith("공구손료"))
|
||||
assert "QC001882.요율 %" in extra["이름글"]
|
||||
assert "잔디깎기공구손료" not in extra["이름글"]
|
||||
assert any(one["이름"] == "QC001882" for one in got["별칭"])
|
||||
assert "잔디깎기요율.요율 %" in extra["이름글"]
|
||||
assert "QC001882" not in extra["이름글"]
|
||||
assert any(one["이름"] == "잔디깎기요율" for one in got["별칭"])
|
||||
|
||||
|
||||
def test_짧은_이름은_규격_코드를_떼고_길면_줄인다() -> None:
|
||||
short = mt.mc.short
|
||||
assert short("바퀴숫돌 평면용 A24 QWV1호 A205×25×25 KSL 6501") == "바퀴숫돌평면용"
|
||||
assert short("크롤러드릴(공기식)(17㎥/min)") == "크롤러드릴공기식"
|
||||
assert short("콘크리트 피니셔(중앙분리대용) 105.9㎾") == "콘크리트피니셔"
|
||||
assert short("1급 기준점 측량") == "기준점측량" # 숫자로 시작하지 않음
|
||||
assert short("잔디깎기 연간 시공횟수별 시공량 할증율") == "잔디깎기할증율"
|
||||
|
||||
|
||||
def test_이름글_별칭은_찾은_줄_값으로_갈리고_표_키_번호가_없다() -> None:
|
||||
"""번호(가스압접1) · 표 키(QC000568) 대신 표 제목 + 찾은 줄 값 · 되돌리면 원래 부르기."""
|
||||
files = store.cm.load(folder=REAL)
|
||||
master = store.cm.master(REAL)
|
||||
want = {
|
||||
"GC000337": "낙석방지망설치재료_철망.수량",
|
||||
"GC000360": "가스압접_용접공.수량",
|
||||
"GC000350": "터널바닥암반청소_굴착기_02.",
|
||||
"GF000185": "구체콘크리트펌프카타설_레미콘.",
|
||||
}
|
||||
for key, part in want.items():
|
||||
got = mt.lines(files, key, dict(store.cm.시험입력(master.logic(key))))
|
||||
names = [one["이름글"] for one in _all_lines(got)]
|
||||
assert any(part in one for one in names), (key, names)
|
||||
assert not any(re.search(r"\b[A-Z]{2}\d{6}", one) for one in names), (key, names)
|
||||
raw_of = {m["이름"]: m["원문"] for m in got["별칭"]}
|
||||
for one in names: # 이름글 속 별칭은 모두 원문 부르기로 되돌려짐
|
||||
back = mt.mc._unmask(one, raw_of)
|
||||
left = [n for n in raw_of if re.search(rf"(?<![\w.]){re.escape(n)}(?!\w)", back)]
|
||||
assert not left, (key, back)
|
||||
assert any("찾기(" in mt.mc._unmask(one, raw_of) for one in names)
|
||||
|
||||
|
||||
def test_긴_품명은_앞_핵심만_겹치면_꼬리_2() -> None:
|
||||
files = store.cm.load(folder=REAL)
|
||||
master = store.cm.master(REAL)
|
||||
for key in ("GC000325", "GC000360"):
|
||||
got = mt.lines(files, key, dict(store.cm.시험입력(master.logic(key))))
|
||||
names = [one["이름글"] for one in _all_lines(got)]
|
||||
assert not any(len(w) > 20 for one in names for w in re.findall(r"[^\W\d]\w*", one))
|
||||
if key == "GC000325":
|
||||
assert any(one.startswith("콘크리트피니셔노무비단가 * ") for one in names)
|
||||
else:
|
||||
assert "보통인부노임_2 * 0 = 보통인부비_2" in names
|
||||
|
||||
|
||||
# ── PLAN 2-1 · 2-2 — 조각(알약) ─────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user