Merge remote-tracking branch 'origin/CODEX' into CODEX

This commit is contained in:
2026-10-15 10:15:22 +09:00
21 changed files with 3192 additions and 20 deletions
+204
View File
@@ -0,0 +1,204 @@
"""M02 일위대가 실무자료 → 우리 일위대가 사전조합(PLAN 324-3).
호표 하나 = 사전조합 하나 — 이름 = 품명 + 규격 · 단위 = 엑셀 단위 · 구분 「실무자료」 ·
상세구분 = 파일 · 본뜬 = {층 「실무자료」, 키 「HP-###### 제N호표」, 파일} ·
만드는 층 = 부르는 층(일위대가 문서 층). 들어가는 줄(`M02_Hopyo_Match` 의 `들어감`)만:
로직 = {로직, 별칭 = 엑셀 품명 40 자, 메모 = 「실무 0.14㎥ × 1 · 규격」, 입력값,
수량 {손 = 엑셀 수량 × 환산}} · 같은 로직 둘째부터 = 그 줄 하나짜리 사전조합으로 감싸 참조
(같은 로직 두 번 금지) · 자재 = {코드, 수량 {손}, 별도계상 = 엑셀 값 없음 「별도계상」 ·
아니면 「직접계산」}(같은 자재 = 수량 더함) · 일위N = 그 호표 사전조합을 먼저 만들어 참조
(순환 = 빠진 줄).
같은 층 · 같은 파일 · 같은 호표 = 있던 것을 다시 씀 · `새로` = 그 키에 다시 만들어 덮음(감싼 것도).
빠진 줄(% · 못 맞춤 · 빼기 · 환산 입력 · 추정) = 엑셀 금액과 함께 보고 · 만든 값 ↔ 엑셀 호표 단가.
엔진 · 일위대가 계산은 그대로(`M02_Labor_Ref.Expand`).
"""
from __future__ import annotations
import json
from decimal import Decimal
from pathlib import Path
from typing import Any
from M01_MasterData import M01_MasterData_Store as m01
from M02_MasterTemplete import M02_Hopyo_Match as match
from M02_MasterTemplete import M02_Hopyo_Store as hopyo_store
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
ORIGIN = "실무자료"
WHY = {
"%": "% 줄 — 우리 로직 덧줄이 셈",
"빼기": "뺌(확정)",
"후보": "못 맞춤 — 후보 확정 필요",
"없음": "못 맞춤",
}
def mark(key: str, no: int, at: int | None = None) -> str:
"""본뜬 키 — 호표 「HP-000001 제3호표」 · 감싼 줄 「… 2째 줄」."""
head = f"{key} 제{no}호표"
return head if at is None else f"{head} {at + 1}째 줄"
def _found(origin: str) -> tuple[str, str] | None:
"""지금 층 사전조합 가운데 본뜬 = 이 실무 표시 → (키, 판)."""
for path in sorted((store.root() / "precombo").glob("SC-*.json")):
try:
doc = json.loads(path.read_bytes())
except (OSError, ValueError):
continue
got = doc.get("본뜬") if isinstance(doc, dict) else None
if isinstance(got, dict) and got.get("층") == ORIGIN and got.get("키") == origin:
return path.stem, store.version_of(path.read_bytes())
return None
def _save(doc: dict[str, Any], fresh: bool) -> tuple[dict[str, Any], bool]:
"""(참조, 다시씀) — 있으면 그대로(`fresh` 면 그 키에 덮음) · 없으면 새 키."""
at = refs.here()
have = _found(doc["본뜬"]["키"])
if have and not fresh:
return {"층": at[0], "주인": at[1], "키": have[0]}, True
if have:
store.write("precombo", have[0], have[1], doc)
key = have[0]
else:
key = refs.create("precombo", doc)["이름"]
return {"층": at[0], "주인": at[1], "키": key}, bool(have)
def _num(value: Decimal) -> int | float:
value = value.normalize()
return int(value) if value == value.to_integral_value() else float(value)
def _memo(line: dict[str, Any], times: Any) -> str:
qty = line.get("수량")
spec = str(line.get("규격") or "").strip()
head = f"실무 {qty if qty is not None else ''}{line.get('단위') or ''} × {times}"
return f"{head} · {spec}" if spec else head
def _missing(line: dict[str, Any], why: str) -> dict[str, Any]:
keep = ("차례", "이름", "규격", "수량", "단위")
return {
**{k: line.get(k) for k in keep},
"금액": (line.get("금액") or {}).get("계"),
"까닭": why,
}
class Builder:
"""한 번 만들기 — 파일 · 층 기억 · 우리 쪽 목록을 한 번 읽고 호표마다 만든 참조를 기억."""
def __init__(self, source: Path, folder: Path, key: str, fresh: bool) -> None:
self.doc = hopyo_store.read(source, key)
self.key, self.fresh = key, fresh
self.memo, self.units = match.memories(folder), match.units_of(self.doc)
self.got = match.targets()
self.made: dict[int, dict[str, Any]] = {}
self.missing: dict[int, list[dict[str, Any]]] = {}
def hopyo(self, no: int) -> dict[str, Any]:
found = next((h for h in self.doc.get("호표") or [] if int(h["번"]) == int(no)), None)
if found is None:
raise store.StoreError(404, f"없는 호표 「제{no}호표」")
return found
def build(self, no: int, trail: tuple[int, ...] = ()) -> dict[str, Any]:
"""호표 → 사전조합 참조 — 일위N 은 먼저(순환은 빠진 줄)."""
if no in self.made:
return self.made[no]
hopyo = match.match_hopyo(self.hopyo(no), self.memo, self.units, True, self.got)
lines, mats, missing = [], {}, []
used: set[str] = set()
for line in hopyo["줄"]:
got = line["맞춤"]
if not got["들어감"]:
conv = got.get("환산") or {}
why = WHY.get(got["상태"]) or (
"추정 환산 — 확정 필요" if conv.get("갈래") == "추정" else "환산 입력 필요"
)
missing.append(_missing(line, why))
continue
times = got["환산"]["배"]
qty = Decimal(str(line.get("수량") or 0)) * Decimal(str(times))
target = got["대상"]
alias = str(line.get("이름") or "")[: labor.ALIAS_MAX].strip() or None
memo = _memo(line, times)
if "자재" in target:
code = target["자재"]
apart = "별도계상" if line.get("갈래") == "별도계상" else "직접계산"
have = mats.get(code)
hand = qty + Decimal(str(have["수량"]["손"])) if have else qty
mats[code] = {"코드": code, "수량": {"손": _num(hand)}, "별도계상": apart}
continue
if "호표" in target:
child = int(target["호표"])
if child in (*trail, no):
missing.append(_missing(line, "순환 — 호표가 서로 담음"))
continue
ref = self.build(child, (*trail, no))
lines.append({"참조": ref, "별칭": alias, "메모": memo, "수량": {"손": _num(qty)}})
continue
one = {"로직": target["로직"], "입력값": got.get("입력값") or {}, "메모": memo}
if target["로직"] not in used:
used.add(target["로직"])
lines.append({**one, "별칭": alias, "수량": {"손": _num(qty)}})
continue
wrap = { # 같은 로직 둘째 — 한 줄 사전조합으로 감쌈
"이름": f"{hopyo['이름']} — {line.get('이름')}",
"단위": target.get("단위"),
"구분": ORIGIN,
"상세구분": self.doc.get("파일") or "",
"담은로직": [{**one, "별칭": alias, "수량": {"손": 1}}],
"자재": [],
"본뜬": self._origin(mark(self.key, no, line["차례"])),
}
ref, _ = _save(wrap, self.fresh)
lines.append({"참조": ref, "별칭": alias, "메모": memo, "수량": {"손": _num(qty)}})
name = " ".join(x for x in (hopyo["이름"], hopyo.get("규격")) if x)
doc = {
"이름": name,
"단위": hopyo.get("단위") or None,
"구분": ORIGIN,
"상세구분": self.doc.get("파일") or "",
"담은로직": lines,
"자재": list(mats.values()),
"본뜬": self._origin(mark(self.key, no)),
}
ref, again = _save(doc, self.fresh)
self.made[no] = {**ref, "다시씀": again}
self.missing[no] = missing
return self.made[no]
def _origin(self, origin: str) -> dict[str, Any]:
return {"층": ORIGIN, "주인": None, "키": origin, "파일": self.doc.get("파일")}
def compare(ref: dict[str, Any], hopyo: dict[str, Any]) -> dict[str, Any]:
"""만든 사전조합 1 단위 값 ↔ 엑셀 호표 단가."""
got = refs.Expand(m01.loaded()[1])({k: ref[k] for k in ("층", "주인", "키")})
base = got.get("기준") or {}
ours = float(base["계"]) if "계" in base else None
excel = (hopyo.get("금액") or {}).get("계")
gap = round(ours - excel, 1) if ours is not None and excel is not None else None
return {"엑셀": excel, "우리": ours, "차이": gap, "까닭": got.get("까닭")}
def build(folder: Path, source: Path, key: str, no: int, fresh: bool = False) -> dict[str, Any]:
"""실무 호표(`source` 층 파일) → 지금 층(`folder`) 사전조합 —
`{참조, 이름, 다시씀, 빠진줄, 비교}`(부르는 쪽이 `store.at(folder)`)."""
maker = Builder(source, folder, key, fresh)
made = maker.build(int(no))
hopyo = maker.hopyo(int(no))
ref = {k: made[k] for k in ("층", "주인", "키")}
return {
"참조": ref,
"이름": hopyo.get("이름"),
"다시씀": made["다시씀"],
"빠진줄": maker.missing.get(int(no), []),
"비교": compare(ref, hopyo),
}
+269
View File
@@ -0,0 +1,269 @@
"""M02 일위대가 실무자료 읽기(PLAN 324-1) — 설계내역서 엑셀 「일위대가」 시트 →
호표 · 줄 · 갈래 · 검산.
읽기 = .xls `xlrd`(경고가 stdout 으로 안 나가게 logfile) · .xlsx `openpyxl`(빈 칸 None) ·
시트 · 열은 머리 글(빈칸 뺌)로 찾음 — 호표 · 품명 · 규격 · 수량 · 단위 · 합계 · 노무비 ·
재료비 · 경비 · 비고(돈 머리 = 단가 열 · 다음 열 = 금액).
호표 줄 = 「제N호표」 · 호표 1 단위 값 = 금액 열 · 구성 줄 = 호표 칸 빔 · 품명 있음 ·
끝 = 다음 호표.
갈래 = % · 호표(비고 일위N참조) · 단가산출(단산N참조) · 기계(단위 hr) · 별도계상(값 없음) ·
자재(재료비만) · 기타 — 규격은 원문 그대로 둠.
검산 = 줄 금액 floor(단가 × 수량 × 10) / 10 · 합계 단가 = 셋 합 ·
% 줄 = floor(바탕 × 율 / 100 × 10) / 10 · 호표 비목 = floor(줄 금액 합) · 총괄표 합계 —
어긋남은 글 목록(막지 않음).
"""
from __future__ import annotations
import io
import re
from decimal import ROUND_FLOOR, Decimal, InvalidOperation
from typing import Any
SHEET = "일위대가"
SUMMARY = "일위대가총괄표"
COSTS = ("노무비", "재료비", "경비")
HEADS = ("호표", "품명", "규격", "수량", "단위", "비고")
NO_SHEET = "일위대가 시트 없음"
_HOPYO = re.compile(r"제\s*(\d+)\s*호표")
_REF = re.compile(r"^(단산|일위)\s*(\d+)\s*참조$")
_MACHINE_UNITS = ("hr", "시간", "h")
class ExcelError(ValueError):
"""엉터리 파일 · 시트 없음 — 길이 400 으로 답함."""
def _flat(value: Any) -> str:
return re.sub(r"\s+", "", str(value or ""))
def _text(value: Any) -> str:
if isinstance(value, float) and value == int(value):
value = int(value)
return str(value if value is not None else "").strip()
def _num(value: Any) -> Decimal | None:
if value is None or isinstance(value, bool) or value == "":
return None
try:
got = Decimal(str(value).replace(",", "").strip())
except InvalidOperation:
return None
return got if got.is_finite() else None
def _floor(value: Decimal, places: int = 1) -> Decimal:
step = Decimal(1).scaleb(-places)
return value.quantize(step, rounding=ROUND_FLOOR)
def _out(value: Decimal | None) -> int | float | None:
if value is None:
return None
return int(value) if value == value.to_integral_value() else float(value)
# ── 파일 → 시트 표(줄마다 값 목록) ────────────────────────────────────────
def _sheets(name: str, data: bytes) -> dict[str, list[list[Any]]]:
"""시트 이름 → 줄 목록 — 일위대가 · 총괄표만 읽음."""
wanted = (SHEET, SUMMARY)
if not data:
raise ExcelError("빈 파일")
try:
if data[:4] == b"PK\x03\x04" or name.lower().endswith(".xlsx"):
import openpyxl
book = openpyxl.load_workbook(io.BytesIO(data), read_only=True, data_only=True)
out = {
ws.title.strip(): [list(row) for row in ws.iter_rows(values_only=True)]
for ws in book.worksheets
if ws.title.strip() in wanted
}
book.close()
return out
import xlrd
book = xlrd.open_workbook(file_contents=data, logfile=io.StringIO())
return {
sh.name.strip(): [sh.row_values(r) for r in range(sh.nrows)]
for sh in book.sheets()
if sh.name.strip() in wanted
}
except ExcelError:
raise
except Exception as error: # 라이브러리마다 다른 예외 — 깨진 파일 · 엑셀 아님
raise ExcelError("엑셀 파일을 읽지 못함 — xls · xlsx 가 아니거나 깨진 파일") from error
def _columns(rows: list[list[Any]], money: tuple[str, ...]) -> tuple[int, dict[str, int]]:
"""(자료 첫 줄, {머리: 열}) — 머리 글은 앞 10 줄 안 「호표」 · 「품명」 이 든 줄."""
for at, row in enumerate(rows[:10]):
flat = [_flat(v) for v in row]
if "호표" not in flat or "품명" not in flat:
continue
cols = {head: flat.index(head) for head in (*HEADS, *money) if head in flat}
below = [_flat(v) for v in rows[at + 1]] if at + 1 < len(rows) else []
start = at + 2 if "단가" in below or "금액" in below else at + 1
return start, cols
raise ExcelError(f"{SHEET} 시트 머리 글(호표 · 품명)을 못 찾음")
def _cell(row: list[Any], col: int | None) -> Any:
return row[col] if col is not None and col < len(row) else None
def _money(row: list[Any], cols: dict[str, int], offset: int) -> dict[str, Decimal | None]:
"""비목 넷(합계 = 계) — `offset` 0 = 단가 열 · 1 = 금액 열."""
return {
("계" if head == "합계" else head): _num(_cell(row, None if c is None else c + offset))
for head in ("합계", *COSTS)
for c in [cols.get(head)]
}
# ── 갈래 · 검산 ─────────────────────────────────────────────────────────
def kind_of(line: dict[str, Any]) -> str:
"""구성 줄 갈래 — 단가산출 · 자재 · 기계 · 호표 · % · 별도계상 · 기타."""
ref = line.get("참조") or {}
unit = _flat(line.get("단위")).lower()
price = line["단가"]
if unit == "%":
return "%"
if ref.get("종류") == "일위":
return "호표"
if ref.get("종류") == "단산":
return "단가산출"
if unit in _MACHINE_UNITS:
return "기계"
if not any(price.get(k) for k in ("계", *COSTS)):
return "별도계상"
if price.get("재료비") and not price.get("노무비") and not price.get("경비"):
return "자재"
return "기타"
def _check_line(where: str, line: dict[str, Any]) -> list[str]:
qty, price, amount = line["수량"], line["단가"], line["금액"]
bad = []
if line["갈래"] == "%":
base = price.get("계")
want = _floor(base * qty / 100) if base is not None and qty else None
got = sum((amount.get(k) or Decimal(0)) for k in COSTS)
if want is not None and got != want:
bad.append(f"{where} · % 금액 {got} ≠ {want}")
return bad
if qty is None:
return bad
for k in COSTS:
if price.get(k) is None:
continue
want = _floor(price[k] * qty)
if (amount.get(k) or Decimal(0)) != want:
bad.append(f"{where} · {k} 금액 {amount.get(k)} ≠ {want}")
parts = [price.get(k) for k in COSTS]
if price.get("계") is not None and any(p is not None for p in parts):
total = sum((p or Decimal(0)) for p in parts)
if total != price["계"]:
bad.append(f"{where} · 합계 단가 {price['계']} ≠ {total}")
return bad
def _check_hopyo(hopyo: dict[str, Any], lines: list[dict[str, Any]]) -> list[str]:
where = f"제{hopyo['번']}호표"
bad = []
for line in lines:
bad += _check_line(f"{where} {line['차례'] + 1}째 줄 「{line['이름']}」", line)
if not lines:
return bad
for k in COSTS:
total = sum((line["금액"].get(k) or Decimal(0)) for line in lines)
want, got = _floor(total, 0), hopyo["금액"].get(k) or Decimal(0)
if got != want:
bad.append(f"{where} · {k} {got} ≠ 줄 합 {want}")
return bad
def _summary(rows: list[list[Any]] | None) -> dict[int, Decimal]:
"""총괄표 {호표 번: 합계} — 머리 글이 없으면 빈 것."""
if not rows:
return {}
try:
start, cols = _columns(rows, ("합계",))
except ExcelError:
return {}
out = {}
for row in rows[start:]:
hit = _HOPYO.fullmatch(_flat(_cell(row, cols.get("호표"))))
total = _num(_cell(row, cols.get("합계")))
if hit and total is not None:
out[int(hit.group(1))] = total
return out
# ── 읽기 ──────────────────────────────────────────────────────────────
def read_book(name: str, data: bytes) -> dict[str, Any]:
"""엑셀 → `{시트, 호표: [...], 검산: {어긋남: [글 …], 총괄표: 맞춘 수}}` — 값은 JSON 수."""
sheets = _sheets(name, data)
rows = sheets.get(SHEET)
if rows is None:
raise ExcelError(NO_SHEET)
start, cols = _columns(rows, ("합계", *COSTS))
hopyos: list[dict[str, Any]] = []
raw: list[list[dict[str, Any]]] = []
for row in rows[start:]:
head = _flat(_cell(row, cols.get("호표")))
name_ = _text(_cell(row, cols.get("품명")))
hit = _HOPYO.fullmatch(head)
if hit:
hopyos.append(
{
"번": int(hit.group(1)),
"이름": name_,
"규격": _text(_cell(row, cols.get("규격"))),
"단위": _text(_cell(row, cols.get("단위"))),
"금액": _money(row, cols, 1),
}
)
raw.append([])
continue
if head or not name_ or not hopyos:
continue
note = _text(_cell(row, cols.get("비고")))
ref = _REF.match(note.replace(" ", ""))
line = {
"차례": len(raw[-1]),
"이름": name_,
"규격": _text(_cell(row, cols.get("규격"))),
"수량": _num(_cell(row, cols.get("수량"))),
"단위": _text(_cell(row, cols.get("단위"))),
"단가": _money(row, cols, 0),
"금액": _money(row, cols, 1),
"비고": note,
"참조": {"종류": ref.group(1), "번": int(ref.group(2))} if ref else None,
}
line["갈래"] = kind_of(line)
raw[-1].append(line)
if not hopyos:
raise ExcelError(f"{SHEET} 시트에 호표(제N호표)가 없음")
wrong: list[str] = []
totals = _summary(sheets.get(SUMMARY))
for hopyo, lines in zip(hopyos, raw, strict=True):
wrong += _check_hopyo(hopyo, lines)
total = hopyo["금액"].get("계")
if hopyo["번"] in totals and total is not None and totals[hopyo["번"]] != total:
wrong.append(f"제{hopyo['번']}호표 · 총괄표 합계 {totals[hopyo['번']]} ≠ {total}")
out = [
{**_json(hopyo), "줄": [_json(line) for line in lines]}
for hopyo, lines in zip(hopyos, raw, strict=True)
]
return {"시트": SHEET, "호표": out, "검산": {"어긋남": wrong, "총괄표": len(totals)}}
def _json(value: Any) -> Any:
if isinstance(value, Decimal):
return _out(value)
if isinstance(value, dict):
return {k: _json(v) for k, v in value.items()}
return value
+530
View File
@@ -0,0 +1,530 @@
"""M02 일위대가 실무자료 맞춤(PLAN 324-2) — 엑셀 구성 줄 → 우리 돈 로직 · 자재 · 입력값 · 환산.
차례 = ① 층 기억(같은 층 → 위 층 · 확정분) ② 이름 같음(정규화) ·
씨앗(일위대가조합.json 메모 · 읽기만)
③ 정리 이름 같음(괄호 · 기계 낱말) ④ 후보 5(글자 두 개 묶음 점수 + 단위 맞음 + 산림품셈 덤 ·
뜻 낱말이 하나도 안 겹치면 뺌 — 「잔토처리」 ↔ 「소금처리」) —
③ ④ 는 사용자 확정(층 기억) 뒤에만 들어감.
줄 갈래: 단가산출 · 기계 · 기타 = 로직 / 자재 · 별도계상 = 자재(M01 자재품목) / 호표 = 그 호표 /
% = 안 들어감(로직 덧줄이 셈).
규격 → 입력값 = 고르기 글이 규격(품명 괄호 글 포함)에 있으면 그 값(기계 = 고르기이름) ·
이름 붙은 수 D= H= H: B= T= L= L3= 는 입력 단위로 · 못 읽으면 빈 값(자동값) + 표시.
환산(우리 수량 = 엑셀 수량 × 배): 같은 단위 1 · 같은 성격 = 단위표 · ㎡→㎥ × T · m→㎡ × B ·
m→㎥ × B × T = 「추정」(확정 뒤에만) · 두 단위 로직 = 단위 입력으로 고르고 1 · 그 밖 = 사용자 입력.
M01 은 읽기만(로직 별칭 안 씀).
"""
from __future__ import annotations
import json
import re
import unicodedata
from datetime import datetime
from decimal import Decimal
from functools import lru_cache
from pathlib import Path
from typing import Any
from M01_MasterData import M01_MasterData_Store as m01
from M01_MasterData import M01_MasterData_Store_Combo as combo_store
from M02_MasterTemplete import M02_Hopyo_Store as hopyo_store
from M02_MasterTemplete import M02_Labor_Inputs as labor_inputs
from M02_MasterTemplete import M02_Labor_Ref as refs
from M02_MasterTemplete import M02_MasterTemplete_Store as store
mf = m01.cm.mf
mp = mf.mp
mm = mp.mm
SEED_NAME = "일위대가조합.json"
LOGIC_KINDS = ("단가산출", "기계", "기타")
MATERIAL_KINDS = ("자재", "별도계상")
READY = ("기억", "같음", "호표")
MACH_WORDS = (
("불도우저", "불도저"),
("굴삭기", "굴착기"),
("브레이카", "브레이커"),
("로울러", "롤러"),
("크로울러", "크롤러"),
)
_SEED_TAIL = re.compile(r"\s*\(실무 수는 옛 판 값\)\s*$")
_PAREN = re.compile(r"\([^()]*\)")
_THOUSANDS = re.compile(r"(=\d{1,3}),(\d{3})(?!\d)")
_NAMED = re.compile(r"(?<![A-Za-z])([A-Za-z]\d?)\s*[=:]\s*(\d+(?:\.\d+)?)\s*(mm|cm|m)?", re.I)
# 이름 붙은 수 → 입력 이름 낱말(긴 낱말이 앞섬 · 「뒷길이」 = L3)
LETTERS = {
"D": ("관경", "직경", "구경", "지름"),
"H": ("높이",),
"B": ("폭", "너비"),
"T": ("두께",),
"L": ("거리", "길이"),
"L3": ("뒷길이",),
}
# ── 정규화 ───────────────────────────────────────────────────────────
def line_key(line: dict[str, Any]) -> str:
"""기억 열쇠 = 품명 | 규격 | 단위(정규화)."""
return "|".join(
(
mm.plain(line.get("이름")),
mm.plain_spec(line.get("규격")),
mm.plain_unit(line.get("단위")),
)
)
def tidy(name: Any) -> str:
"""정리 이름 — 괄호 글 · 기계 옛 낱말 · 빈칸."""
text = str(name or "")
for old, new in MACH_WORDS:
text = text.replace(old, new)
return mm.plain(_PAREN.sub("", text))
def words(name: Any) -> frozenset[str]:
"""뜻 낱말 — 괄호 밖 한글 덩어리 · 영문 두 글자 넘는 것
(낱글자 덩어리는 붙임 「면 목」 = 면목)."""
text = unicodedata.normalize("NFKC", str(name or "")).lower()
for old, new in MACH_WORDS:
text = text.replace(old, new)
out, single = [], ""
for piece in re.findall(r"[가-힣]+|[a-z]{2,}", _PAREN.sub(" ", text)):
if len(piece) == 1:
single += piece
continue
out += [single, piece]
single = ""
return frozenset(w for w in [*out, single] if len(w) > 1)
def near(mine: frozenset[str], theirs: frozenset[str]) -> bool:
"""뜻 낱말이 하나라도 겹침(한쪽이 다른 쪽에 듦) — 내 낱말이 없으면 거르지 않음."""
return not mine or any(a in b or b in a for a in mine for b in theirs)
def _spec_text(text: Any) -> str:
out = str(text or "")
for old, new in MACH_WORDS:
out = out.replace(old, new)
return mm.plain_spec(out).replace(",", "").replace("~", "∼")
def numbers(spec: Any) -> dict[str, tuple[Decimal, str | None]]:
"""규격의 이름 붙은 수 `{글자: (값, 단위)}` — 천 단위 쉼표(`D=1,000mm`)를 먼저 붙임."""
text = _THOUSANDS.sub(r"\1\2", unicodedata.normalize("NFKC", str(spec or "")))
return {
letter.upper(): (Decimal(value), unit.lower() if unit else None)
for letter, value, unit in _NAMED.findall(text)
}
def _in_unit(value: Decimal, have: str | None, want: Any) -> Decimal:
"""길이 값을 입력 단위로 — 한쪽이 모르는 단위면 그대로."""
scale = mp.UNITS["길이"]
want = mm.plain_unit(want) if want else None
if have in scale and want in scale:
return value * Decimal(scale[have]) / Decimal(scale[want])
return value
def _json(value: Decimal) -> int | float:
value = value.normalize()
return int(value) if value == value.to_integral_value() else float(value)
def _meters(found: tuple[Decimal, str | None] | None) -> Decimal | None:
if found is None:
return None
return _in_unit(found[0], found[1] or "m", "m")
# ── 규격 → 입력값 ─────────────────────────────────────────────────────
def spec_inputs(line: dict[str, Any], specs: list[dict[str, Any]]) -> tuple[dict, list[str]]:
"""(입력값, 못 읽은 입력 이름) — 고르기 글 · 고르기이름 · 이름 붙은 수 · 두 단위."""
raw = f"{line.get('규격') or ''} {' '.join(_PAREN.findall(str(line.get('이름'))))}"
pool = _spec_text(raw)
named = numbers(line.get("규격"))
tokens = [_spec_text(w) for w in re.split(r"[,\s/()]+", raw)]
unit = mm.plain_unit(line.get("단위"))
values, unread = {}, []
for spec in specs:
name = str(spec.get("이름") or "")
picks = spec.get("고르기") or []
labels = spec.get("고르기이름") or {}
words = [(w, letter) for letter, ws in LETTERS.items() for w in ws if w in name]
letter = max(words, key=lambda x: len(x[0]))[1] if words else None
got: Any = None
if letter in named:
value = _json(_in_unit(*named[letter], spec.get("단위")))
got = value if not picks or value in picks else None
if got is None and picks:
hits = []
for opt in picks:
label = str(labels.get(opt) or opt)
for text in (label, _PAREN.sub("", label)):
flat = _spec_text(text)
if len(flat) > 1 and (flat in pool or (unit and flat == f"{unit}당")):
hits.append((len(flat), opt))
# 규격 낱말이 고르기 글 머리(「4회」 → 「4회사용시」)
hits += [(len(w), opt) for w in tokens if len(w) > 1 and flat.startswith(w)]
got = max(hits, key=lambda x: x[0])[1] if hits else None
if got is not None:
values[name] = got
elif spec.get("자동값") in (None, ""):
unread.append(name)
return values, unread
# ── 환산 ─────────────────────────────────────────────────────────────
def factor(have: Any, want: Any, spec: Any = "") -> dict[str, Any]:
"""`{배, 갈래}` — 우리 수량 = 엑셀 수량 × 배 · 배 None = 사용자 입력."""
units = [u.strip() for u in str(want or "").split("·") if u.strip()]
plain = mm.plain_unit(have)
if len(units) > 1:
ok = plain in {mm.plain_unit(u) for u in units}
return {"배": 1 if ok else None, "갈래": "두 단위" if ok else "입력"}
if plain == mm.plain_unit(want):
return {"배": 1, "갈래": "같음"}
a, b = mp.kind_of(have), mp.kind_of(want)
if a is not None and a == b:
scale = mp.UNITS[a]
ratio = Decimal(scale[plain]) / Decimal(scale[mm.plain_unit(want)])
return {"배": _json(ratio), "갈래": "단위표"}
found = numbers(spec)
width, thick = _meters(found.get("B")), _meters(found.get("T"))
by = {("넓이", "부피"): thick, ("길이", "넓이"): width}
if (a, b) == ("길이", "부피") and width is not None and thick is not None:
by[(a, b)] = width * thick
got = by.get((a, b))
if got:
return {"배": _json(got), "갈래": "추정"}
return {"배": None, "갈래": "입력"}
def fits(have: Any, want: Any) -> bool:
"""단위가 맞음 — 같음 · 같은 성격 · 두 단위 중 하나(후보 점수 덤)."""
units = {mm.plain_unit(u) for u in str(want or "").split("·") if u.strip()}
return mm.plain_unit(have) in units or mp.same_kind(have, want)
# ── 우리 쪽 목록(한 번 읽어 둠) ─────────────────────────────────────────
def _grams(text: str) -> set[str]:
return {text[i : i + 2] for i in range(len(text) - 1)} or {text}
@lru_cache(maxsize=2)
def _targets(whole_id: int) -> dict[str, Any]:
"""로직(돈) · 자재품목 — 정규 이름 · 정리 이름 · 묶음 · 단위."""
files, whole = m01.loaded()
rows = [r for d in files.values() if d.get("그룹") == "로직" for r in d.get("줄") or []]
pills = combo_store.combo_pills({"담은로직": [{"로직": r.get("키")} for r in rows]})["물량"]
unit_of = {code: pill["단위"] for pill in pills for code in pill["로직"]} # 돈 로직만
logics, mats = [], []
for row in rows:
if str(row.get("키")) in unit_of:
name = str(row.get("이름") or "")
logics.append(
{
"로직": str(row["키"]),
"이름": name,
"단위": unit_of[str(row["키"])],
"산림": "산림" in str(row.get("구분") or ""),
"plain": mm.plain(name),
"tidy": tidy(name),
}
)
for key, row in whole.index.get(mm.MARKET, {}).items():
name = str(row.get("이름") or "")
mats.append(
{
"자재": str(key),
"이름": name,
"규격": str(row.get("규격") or ""),
"단위": str(row.get("단위") or ""),
"plain": mm.plain(name),
"spec": mm.plain_spec(row.get("규격")),
"base": (base := re.sub(r"-(사급|관급)$", "", name.strip())),
"tidy": tidy(base),
}
)
names: dict[str, dict[str, list]] = {"로직": {}, "자재": {}}
for pool, group in (("로직", logics), ("자재", mats)):
for one in group:
one["grams"] = _grams(one["tidy"])
one["words"] = words(one["base"] if "base" in one else one["이름"])
names[pool].setdefault(one["plain"], []).append(one)
return {"로직": logics, "자재": mats, "이름": names, "씨앗": _seeds()}
def targets() -> dict[str, Any]:
return _targets(id(m01.loaded()[1]))
def _seeds() -> dict[str, set[str]]:
"""씨앗 `{정규 실무 글: {로직 키}}` — 일위대가조합.json 담은로직 메모(읽기만)."""
try:
doc = json.loads((Path(m01.FOLDER) / SEED_NAME).read_bytes())
except (OSError, ValueError):
return {}
out: dict[str, set[str]] = {}
for row in doc.get("줄") or []:
for one in row.get("담은로직") or []:
memo = _SEED_TAIL.sub("", str(one.get("메모") or ""))
if memo and one.get("로직"):
out.setdefault(mm.plain(memo), set()).add(str(one["로직"]))
return out
def _public(one: dict[str, Any]) -> dict[str, Any]:
keep = ("로직", "자재", "이름", "규격", "단위")
return {k: one[k] for k in keep if k in one}
def _exact(line: dict[str, Any], pool: str, got: dict[str, Any]) -> list[dict[str, Any]]:
"""② 이름 같음 · 씨앗 — 겹치면 여럿(부르는 쪽이 후보로 돌림)."""
name, spec = mm.plain(line.get("이름")), mm.plain_spec(line.get("규격"))
same = got["이름"][pool].get(name) or []
if pool == "자재":
return [m for m in same if m["spec"] == spec] or (same if len(same) == 1 else [])
if same:
return same
codes = got["씨앗"].get(mm.plain(f"{line.get('이름')}{line.get('규격')}")) or got["씨앗"].get(
name
)
return [x for x in got["로직"] if x["로직"] in (codes or ())]
def candidates(
line: dict[str, Any], pool: str, got: dict[str, Any] | None = None, limit: int = 5
) -> list[dict[str, Any]]:
"""③ 정리 이름 같음 먼저 · ④ 글자 두 개 묶음 점수 + 단위 맞음 + 산림품셈 덤 — 위 `limit`."""
me = tidy(line.get("이름"))
grams, unit = _grams(me), line.get("단위")
text = mm.plain_spec(f"{line.get('이름')}{line.get('규격')}")
scored, seen = [], set()
mine = words(line.get("이름"))
for one in (got or targets())[pool]:
if not grams & one["grams"] or not near(mine, one["words"]):
continue
score = 2 * len(grams & one["grams"]) / (len(grams) + len(one["grams"]))
score += 1 if one["tidy"] == me else 0
score += 0.15 if fits(unit, one["단위"]) else 0
score += 0.05 if one.get("산림") else 0
score += 0.5 if one.get("spec") and one["spec"] in text else 0
scored.append((score, one))
scored.sort(key=lambda x: -x[0])
out = []
for score, one in scored:
label = (one["이름"], one.get("규격"), one["단위"])
if label in seen:
continue
seen.add(label)
out.append({**_public(one), "점수": round(score, 3)})
if len(out) == limit:
break
return out
# ── 층 기억 ───────────────────────────────────────────────────────────
def chain(folder: Path) -> list[Path]:
"""기억을 볼 층 폴더 — 같은 층 → 위 층(개인 → 회사 → 마스터)."""
layer, owner = refs.where(folder)
out = [Path(folder)]
if layer == "personal":
out.append(refs.folder_of("company", str(owner).split("/")[0]))
if layer in ("personal", "company"):
out.append(Path(store.FOLDER))
return out
def memories(folder: Path) -> list[dict[str, dict[str, Any]]]:
return [hopyo_store.memory(one) for one in chain(folder)]
def recall(line: dict[str, Any], memo: list[dict[str, dict[str, Any]]]) -> dict[str, Any] | None:
key = line_key(line)
return next((one[key] for one in memo if key in one), None)
# ── 줄 하나 맞춤 ───────────────────────────────────────────────────────
def _logic_specs(code: str) -> list[dict[str, Any]]:
got = labor_inputs.logic_inputs([code])["로직"]
return got[0]["입력"] if got else []
def _target_of(remembered: dict[str, Any], got: dict[str, Any]) -> dict[str, Any] | None:
if remembered.get("로직"):
return next((x for x in got["로직"] if x["로직"] == remembered["로직"]), None)
if remembered.get("자재"):
return next((x for x in got["자재"] if x["자재"] == remembered["자재"]), None)
return None
def match_line(
line: dict[str, Any],
memo: list[dict[str, dict[str, Any]]],
units: dict[int, str],
full: bool = True,
got: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""줄 → `{열쇠, 상태, 대상, 입력값, 못읽음, 환산, 들어감, 후보?}` —
`units` = {호표 번: 단위}(일위N 참조) · `full` = 입력값 · 후보까지(아니면 상태 · 환산만)."""
out: dict[str, Any] = {"열쇠": line_key(line), "대상": None, "입력값": {}, "못읽음": []}
kind = line.get("갈래")
pool = "자재" if kind in MATERIAL_KINDS else "로직"
remembered = recall(line, memo)
got = got or targets()
if kind == "%":
return {**out, "상태": "%", "환산": None, "들어감": False}
if remembered and remembered.get("빼기"):
return {**out, "상태": "빼기", "환산": None, "들어감": False}
if kind == "호표":
no = int((line.get("참조") or {}).get("번") or 0)
conv = factor(line.get("단위"), units.get(no), line.get("규격"))
target = {"호표": no, "단위": units.get(no)}
ready = no in units and conv["배"] is not None and conv["갈래"] != "추정"
return {**out, "상태": "호표", "대상": target, "환산": conv, "들어감": ready}
if remembered and (target := _target_of(remembered, got)):
conv = {"배": remembered.get("환산"), "갈래": "기억"}
state = {"상태": "기억", "대상": _public(target), "입력값": remembered.get("입력값") or {}}
return {**out, **state, "환산": conv, "들어감": conv["배"] is not None}
found = _exact(line, pool, got)
if len(found) == 1:
target = found[0]
conv = factor(line.get("단위"), target["단위"], line.get("규격"))
out.update(상태="같음", 대상=_public(target), 환산=conv)
out["들어감"] = conv["배"] is not None and conv["갈래"] != "추정"
if full and "로직" in target:
out["입력값"], out["못읽음"] = spec_inputs(line, _logic_specs(target["로직"]))
return out
out.update(상태="후보" if found else "없음", 환산=None, 들어감=False)
if not full:
return out
top = [{**_public(x), "점수": 9} for x in found]
same = [_public(x) for x in found]
rest = [
c
for c in candidates(line, pool, got)
if {k: v for k, v in c.items() if k != "점수"} not in same
]
out["후보"] = (top + rest)[:5]
return {**out, "상태": "후보" if out["후보"] else "없음"}
def guess(folder: Path, key: str, no: int, at: int, target: dict[str, Any]) -> dict[str, Any]:
"""후보 · 고른 대상 하나 미리 채움 — `{대상, 입력값, 못읽음, 환산}`(확정 전 화면 보기)."""
line = _line(folder, key, no, at)
found = _target_of(target if isinstance(target, dict) else {}, targets())
if found is None:
raise store.StoreError(400, "돈 로직 · 우리 자재가 아님")
target = _public(found)
conv = factor(line.get("단위"), target.get("단위"), line.get("규격"))
if not target.get("로직"):
return {"대상": target, "입력값": {}, "못읽음": [], "환산": conv}
values, unread = spec_inputs(line, _logic_specs(str(target["로직"])))
return {"대상": target, "입력값": values, "못읽음": unread, "환산": conv}
def _line(folder: Path, key: str, no: int, at: int) -> dict[str, Any]:
doc = hopyo_store.read(folder, key)
hopyo = next((h for h in doc.get("호표") or [] if int(h["번"]) == int(no)), None)
lines = (hopyo or {}).get("줄") or []
if not 0 <= int(at) < len(lines):
raise store.StoreError(404, f"없는 줄 「제{no}호표 {at}」")
return lines[int(at)]
def match_hopyo(
hopyo: dict[str, Any],
memo: list[dict[str, dict[str, Any]]],
units: dict[int, str],
full: bool = True,
got: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""호표 하나 — 줄마다 맞춤 · `맞춤` = [들어감 수, 줄 수]."""
got = got or targets()
lines = [
{**one, "맞춤": match_line(one, memo, units, full, got)} for one in hopyo.get("줄") or []
]
ready = sum(1 for one in lines if one["맞춤"]["들어감"])
return {**hopyo, "줄": lines, "맞춤": [ready, len(lines)]}
def units_of(doc: dict[str, Any]) -> dict[int, str]:
return {int(h["번"]): str(h.get("단위") or "") for h in doc.get("호표") or []}
def view(folder: Path, key: str, no: int | None = None) -> dict[str, Any]:
"""실무자료 보기 — `no` 없으면 호표마다 맞춤 수만 · 있으면 그 호표 줄마다 맞춤 · 후보."""
doc = hopyo_store.read(folder, key)
memo, units = memories(folder), units_of(doc)
if no is None:
got = targets()
hopyos = [match_hopyo(h, memo, units, False, got) for h in doc.get("호표") or []]
brief = [{k: h[k] for k in ("번", "이름", "규격", "단위", "금액", "맞춤")} for h in hopyos]
ready = [sum(h["맞춤"][0] for h in hopyos), sum(h["맞춤"][1] for h in hopyos)]
return {**hopyo_store.brief(doc), "검산": doc.get("검산"), "호표": brief, "맞춤": ready}
hopyo = next((h for h in doc.get("호표") or [] if int(h["번"]) == int(no)), None)
if hopyo is None:
raise store.StoreError(404, f"없는 호표 「제{no}호표」")
return {**hopyo_store.brief(doc), "호표": match_hopyo(hopyo, memo, units)}
def picks(folder: Path) -> list[dict[str, Any]]:
"""[로직 추가] 「실무 호표」 줄 — 이 층 · 위 층 실무자료 호표마다 맞춤 n/m
(이 층 기억 · 만드는 층)."""
memo, got, out = memories(folder), targets(), []
for one in chain(folder):
layer, owner = refs.where(one)
for row in hopyo_store.rows(one):
doc = hopyo_store.read(one, row["키"])
units = units_of(doc)
for hopyo in doc.get("호표") or []:
ready = match_hopyo(hopyo, memo, units, False, got)["맞춤"]
head = {"층": layer, "주인": owner, "키": row["키"], "파일": row["파일"]}
keep = ("번", "이름", "규격", "단위")
out.append({**head, **{k: hopyo.get(k) for k in keep}, "맞춤": ready})
return out
# ── 확정 저장 ─────────────────────────────────────────────────────────
def confirm(folder: Path, entries: dict[str, Any]) -> dict[str, Any]:
"""확정 · 빼기 · 지움 → 층 기억 — `{열쇠: {로직|자재|빼기, 입력값, 환산} | None}`.
로직 = 돈 로직 · 자재 = M01 자재품목 · 환산 = 0 넘는 수(빼기는 안 봄)."""
if not isinstance(entries, dict) or not entries:
raise store.StoreError(400, "확정할 줄이 없음")
got = targets()
logics = {x["로직"] for x in got["로직"]}
mats = {x["자재"] for x in got["자재"]}
stamp = datetime.now().isoformat(timespec="seconds")
clean: dict[str, Any] = {}
for key, raw in entries.items():
if raw is None:
clean[str(key)] = None
continue
if not isinstance(raw, dict) or str(key).count("|") != 2:
raise store.StoreError(400, f"확정 꼴이 틀림 「{key}」")
if raw.get("빼기") is True:
clean[str(key)] = {"빼기": True, "확정": stamp}
continue
code, mat = str(raw.get("로직") or ""), str(raw.get("자재") or "")
if (code and code not in logics) or (mat and mat not in mats) or bool(code) == bool(mat):
raise store.StoreError(400, f"돈 로직 · 우리 자재 하나만 「{key}」")
times = raw.get("환산")
number = None if isinstance(times, bool) else _number(times)
if number is None or number <= 0:
raise store.StoreError(400, f"환산은 0 넘는 수 「{key}」")
values = raw.get("입력값") or {}
if not isinstance(values, dict):
raise store.StoreError(400, f"입력값이 묶음 아님 「{key}」")
head = {"로직": code} if code else {"자재": mat}
clean[str(key)] = {**head, "입력값": values, "환산": _json(number), "확정": stamp}
return {"기억": hopyo_store.remember(folder, clean)}
def _number(raw: Any) -> Decimal | None:
try:
got = Decimal(str(raw).replace(",", "").strip())
except ArithmeticError:
return None
return got if got.is_finite() else None
+116
View File
@@ -0,0 +1,116 @@
"""M02 일위대가 실무자료 저장(PLAN 324-1) — 층 폴더 `실무자료/<키>.json` ·
맞춤 기억 `실무자료/맞춤.json`.
파일 = 읽은 결과만(원본 엑셀 안 둠) `{키, 파일, 올린때, 올린이, 시트, 호표, 검산}` · 키 `HP-######`
(층마다 다음 번호) · 맞춤 기억 = `{줄: {줄 열쇠: {로직?|자재?|빼기?, 입력값, 환산, 확정}}}`.
층 종류(`layers.KINDS`) 밖 폴더 — 양식 목록 · 복사 · 프로젝트 씨앗에 안 섞임 · 권한은 길 몫.
"""
from __future__ import annotations
import json
import re
import threading
from datetime import datetime
from pathlib import Path
from typing import Any
from common_util.common_util_json import atomic_write_json
from M02_MasterTemplete import M02_MasterTemplete_Store as store
DIRNAME = "실무자료"
MEMORY = "맞춤"
_KEY = re.compile(r"^HP-(\d{6})$")
# ponytail: 프로세스 안 잠금 — 서버를 여럿 띄우면 파일 잠금으로
_LOCK = threading.Lock()
def _dir(folder: str | Path) -> Path:
return Path(folder) / DIRNAME
def _path(folder: str | Path, key: str) -> Path:
if not _KEY.match(str(key)):
raise store.StoreError(404, f"없는 실무자료 「{key}」")
return _dir(folder) / f"{key}.json"
def _load(path: Path) -> Any:
try:
return json.loads(path.read_bytes())
except (OSError, ValueError):
return None
def brief(doc: dict[str, Any]) -> dict[str, Any]:
"""목록 한 줄 — `{키, 파일, 올린때, 올린이, 시트, 호표수, 어긋남}`."""
keep = ("키", "파일", "올린때", "올린이", "시트")
return {
**{k: doc.get(k) for k in keep},
"호표수": len(doc.get("호표") or []),
"어긋남": len((doc.get("검산") or {}).get("어긋남") or []),
}
def rows(folder: str | Path) -> list[dict[str, Any]]:
"""층 실무자료 목록(키 차례)."""
out = []
for path in sorted(_dir(folder).glob("HP-*.json")):
doc = _load(path)
if isinstance(doc, dict) and _KEY.match(path.stem):
out.append(brief(doc))
return out
def read(folder: str | Path, key: str) -> dict[str, Any]:
doc = _load(_path(folder, key))
if not isinstance(doc, dict):
raise store.StoreError(404, f"없는 실무자료 「{key}」")
return doc
def save(folder: str | Path, name: str, parsed: dict[str, Any], who: Any) -> dict[str, Any]:
"""읽은 결과 → 새 키로 씀 → 목록 한 줄."""
with _LOCK:
have = [
int(m.group(1)) for p in _dir(folder).glob("HP-*.json") if (m := _KEY.match(p.stem))
]
key = f"HP-{max(have, default=0) + 1:06d}"
doc = {
"키": key,
"파일": name,
"올린때": datetime.now().isoformat(timespec="seconds"),
"올린이": who,
**parsed,
}
atomic_write_json(_path(folder, key), doc)
return brief(doc)
def delete(folder: str | Path, key: str) -> None:
path = _path(folder, key)
with _LOCK:
if not path.is_file():
raise store.StoreError(404, f"없는 실무자료 「{key}」")
path.unlink()
# ── 맞춤 기억 ─────────────────────────────────────────────────────────
def memory(folder: str | Path) -> dict[str, dict[str, Any]]:
"""층 맞춤 기억 `{줄 열쇠: 확정}` — 없으면 빈 것."""
doc = _load(_dir(folder) / f"{MEMORY}.json")
got = doc.get("줄") if isinstance(doc, dict) else None
return got if isinstance(got, dict) else {}
def remember(folder: str | Path, entries: dict[str, dict[str, Any] | None]) -> dict[str, Any]:
"""기억 더함 · 바꿈(값 None = 지움) → 새 기억 전부."""
with _LOCK:
got = memory(folder)
for key, value in entries.items():
if value is None:
got.pop(key, None)
else:
got[key] = value
atomic_write_json(_dir(folder) / f"{MEMORY}.json", {"줄": got})
return got
+1
View File
@@ -210,6 +210,7 @@ def _combo_lines(doc: dict, key: str) -> list[dict[str, Any]]:
if "로직" in one:
head["입력값"] = one["입력값"]
head.update({k: one[k] for k in ("켜쌓기율", "고름", "자재표시") if k in one})
head.update({"별칭": one["별칭"]} if one["별칭"] else {}) # 실무 품명(324-3)
lines.append({**head, "수량": one["수량"] or {"손": 1}, "메모": one["메모"]})
at_ = here()
me = {"층": at_[0], "주인": at_[1], "키": key}
+153 -2
View File
@@ -3,19 +3,25 @@
문서 읽기 · 쓰기는 양식 길(`GET/PUT /api/m02/layers/{층}/templates/labor/{열}`) 그대로 ·
여기는 저장하지 않는 셈만 + 서고(단가승격 · 사전조합 · 50-3) 새로 · 복사(키는 서버가 줌 · 고침 ·
지우기는 양식 길 그대로) + 개인 자재 목록 · 추가(148-2 · 개인 층만 · 품번은 서버) +
사전조합 나무 자유 폴더(158-1 · `M02_Precombo_Folders` · 읽기 = 층 읽기 · 고침 = 층 쓰기).
사전조합 나무 자유 폴더(158-1 · `M02_Precombo_Folders` · 읽기 = 층 읽기 · 고침 = 층 쓰기) +
일위대가 실무자료(324 · `M02_Hopyo_*` · 올리기 · 지우기 = 층 쓰기 · 목록 · 읽기 = 층 읽기).
층 권한 = `Router_Items._root`(`M02_Layer_Access` 한 벌).
⚠ 등록은 `main.py`(로그인만).
"""
from __future__ import annotations
from pathlib import Path
from typing import Any, Literal
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
from pydantic import BaseModel
from common_util.common_util_auth import verify_session
from M02_MasterTemplete import M02_Hopyo_Build as hopyo_build
from M02_MasterTemplete import M02_Hopyo_Excel as hopyo_excel
from M02_MasterTemplete import M02_Hopyo_Match as hopyo_match
from M02_MasterTemplete import M02_Hopyo_Store as hopyo_store
from M02_MasterTemplete import M02_Labor_Doc as labor
from M02_MasterTemplete import M02_Labor_Ref as refs
from M02_MasterTemplete import M02_Layer_Access as access
@@ -159,3 +165,148 @@ def new_material(
raise HTTPException(status_code=400, detail="신규 자재는 개인 층에서만")
folder = _root(session, layer, owner, write=True)
return _call(folder, own.add, folder, body)
# ── 일위대가 실무자료(324) ─────────────────────────────────────────────
HOPYO_MAX_BYTES = 20 * 1024 * 1024
def _upload(name: str, data: bytes, who: dict[str, Any]) -> dict[str, Any]:
try:
parsed = hopyo_excel.read_book(name, data)
except hopyo_excel.ExcelError as error:
raise store.StoreError(400, str(error)) from error
return hopyo_store.save(store.root(), name, parsed, who)
@router.get("/layers/{layer}/hopyo")
def hopyo_list(
layer: Layer, owner: str | None = None, session: Session = Depends(verify_session)
) -> dict:
"""실무자료 목록 `{줄: [{키, 파일, 올린때, 올린이, 시트, 호표수, 어긋남}]}`."""
folder = _root(session, layer, owner)
return {"줄": hopyo_store.rows(folder)}
@router.get("/layers/{layer}/hopyo-picks")
def hopyo_picks(
layer: Layer, owner: str | None = None, session: Session = Depends(verify_session)
) -> dict:
"""[로직 추가] 「실무 호표」 줄 — 이 층 · 위 층 파일의 호표마다
`{층, 주인, 키, 파일, 번, 이름, 규격, 단위, 맞춤}`(맞춤 n/m = 이 층 기억)."""
folder = _root(session, layer, owner)
return {"줄": _call(folder, hopyo_match.picks, folder)}
@router.post("/layers/{layer}/hopyo")
def hopyo_upload(
layer: Layer,
file: UploadFile = File(...),
owner: str | None = None,
session: Session = Depends(verify_session),
) -> dict:
"""엑셀(xls · xlsx) 올리기 — 「일위대가」 시트를 읽어 결과만 저장 · 20 MB ·
엉터리 · 시트 없음 400."""
folder = _root(session, layer, owner, write=True)
data = file.file.read(HOPYO_MAX_BYTES + 1)
if len(data) > HOPYO_MAX_BYTES:
raise HTTPException(status_code=400, detail="파일이 너무 큼(20 MB 넘음)")
name = Path(file.filename or "실무자료").name[:120]
who = {"id": session.get("user_id"), "이름": session.get("name")}
return _call(folder, _upload, name, data, who)
@router.get("/layers/{layer}/hopyo/{key}")
def hopyo_read(
layer: Layer, key: str, owner: str | None = None, session: Session = Depends(verify_session)
) -> dict:
"""실무자료 하나 — 검산 · 호표마다 `맞춤` [들어감, 줄 수] · 파일 `맞춤` 합."""
folder = _root(session, layer, owner)
return _call(folder, hopyo_match.view, folder, key)
@router.get("/layers/{layer}/hopyo/{key}/{no}")
def hopyo_one(
layer: Layer,
key: str,
no: int,
owner: str | None = None,
session: Session = Depends(verify_session),
) -> dict:
"""호표 하나 — 줄마다 맞춤(상태 · 대상 · 입력값 · 환산 · 들어감 · 후보 5)."""
folder = _root(session, layer, owner)
return _call(folder, hopyo_match.view, folder, key, no)
class GuessBody(BaseModel):
"""`차례` = 호표 안 줄(0부터) · `대상` = {로직} | {자재}."""
차례: int
대상: dict[str, Any]
@router.post("/layers/{layer}/hopyo/{key}/{no}/guess")
def hopyo_guess(
layer: Layer,
key: str,
no: int,
body: GuessBody,
owner: str | None = None,
session: Session = Depends(verify_session),
) -> dict:
"""고른 대상 미리 채움(저장 안 함) — 규격 → 입력값 · 환산."""
folder = _root(session, layer, owner)
return _call(folder, hopyo_match.guess, folder, key, no, body.차례, body.대상)
class MemoryBody(BaseModel):
"""`줄` = {줄 열쇠: {로직|자재, 입력값, 환산} | {빼기: true} | null(지움)}."""
줄: dict[str, Any]
@router.post("/layers/{layer}/hopyo-memory")
def hopyo_memory(
layer: Layer,
body: MemoryBody,
owner: str | None = None,
session: Session = Depends(verify_session),
) -> dict:
"""맞춤 확정 · 빼기 → 층 기억(층 쓰기) — 다음부터 같은 줄은 자동."""
folder = _root(session, layer, owner, write=True)
return _call(folder, hopyo_match.confirm, folder, body.줄)
class BuildBody(BaseModel):
"""실무 호표 → 이 층 사전조합 — 파일 층 `층` · `주인` · `키` + 호표 `번` ·
`새로` = 있던 것을 덮음."""
층: Layer
주인: str | None = None
키: str
번: int
새로: bool = False
@router.post("/layers/{layer}/hopyo-build")
def hopyo_make(
layer: Layer,
body: BuildBody,
owner: str | None = None,
session: Session = Depends(verify_session),
) -> dict:
"""실무 호표 → 우리 일위대가 사전조합(이 층 쓰기 · 파일 층 읽기) —
`{참조, 이름, 다시씀, 빠진줄, 비교}`."""
folder = _root(session, layer, owner, write=True)
source = _root(session, body.층, body.주인)
return _call(folder, hopyo_build.build, folder, source, body.키, body.번, body.새로)
@router.delete("/layers/{layer}/hopyo/{key}")
def hopyo_delete(
layer: Layer, key: str, owner: str | None = None, session: Session = Depends(verify_session)
) -> dict:
"""실무자료 지우기(만든 사전조합 · 맞춤 기억은 그대로)."""
folder = _root(session, layer, owner, write=True)
_call(folder, hopyo_store.delete, folder, key)
return {"지움": key}
@@ -0,0 +1,151 @@
/* M02 일위대가 실무자료(PLAN 324-4) — 좌측 판 · 본 화면(호표 표 · 줄 맞춤) · 색은 공용 토큰(밝음 · 어두움 같이) */
.m02-hopyo-side {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
}
.m02-hopyo {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
min-width: 0;
}
.m02-hopyo__bar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--spacing-8);
}
.m02-hopyo__note {
margin: 0;
color: var(--color-text-secondary);
font-size: var(--text-body-sm);
}
.m02-hopyo__warn {
margin: 0;
color: var(--color-warning);
font-size: var(--text-body-sm);
white-space: pre-wrap;
}
.m02-hopyo__search {
max-width: 320px;
}
.m02-hopyo__row {
cursor: pointer;
}
.m02-hopyo__row:hover {
background: var(--color-mist-violet);
}
.m02-hopyo__num {
text-align: right;
font-variant-numeric: tabular-nums;
}
/* 줄 표 — 갈래 · 수량 · 단위 · 엑셀 단가 · 맞춤 · 입력값 · 환산 · 단추 = 줄바꿈 없음(글자가 세로로 안
갈라짐 · 폭 = 글 폭) · 품명 · 규격 · 대상이 남는 폭 · 칸 여백 좁힘(공용 표보다 앞서게 두 갈래 이름) */
.m02-hopyo__table.m02-hopyo__lines :is(th, td) {
padding: var(--spacing-8);
}
.m02-hopyo__table.m02-hopyo__lines
:is(th, td):is(
:nth-child(1),
:nth-child(4),
:nth-child(5),
:nth-child(6),
:nth-child(7),
:nth-child(9),
:nth-child(10),
:nth-child(11)
) {
white-space: nowrap;
}
/* 입력값 — 길면 줄임표(전체 글은 칸 풍선 도움말) */
.m02-hopyo__table.m02-hopyo__lines td:nth-child(9) {
max-width: 24em;
overflow: hidden;
text-overflow: ellipsis;
}
.m02-hopyo__line.is-out {
color: var(--color-text-muted);
}
.m02-hopyo__state {
display: inline-block;
padding: 0 var(--spacing-4);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
font-size: var(--text-caption);
white-space: nowrap;
}
.m02-hopyo__state.is-기억,
.m02-hopyo__state.is-같음,
.m02-hopyo__state.is-호표 {
border-color: var(--color-success);
color: var(--color-success);
}
.m02-hopyo__state.is-후보 {
border-color: var(--color-warning);
color: var(--color-warning);
}
.m02-hopyo__state.is-없음 {
border-color: var(--color-danger);
color: var(--color-danger);
}
.m02-hopyo__times {
width: 6em;
}
.m02-hopyo__acts {
white-space: nowrap;
}
.m02-hopyo__acts > button + button {
margin-left: var(--spacing-4);
}
.m02-hopyo__block {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
}
.m02-hopyo__block h4 {
margin: 0;
font-size: var(--text-body-sm);
}
.m02-hopyo__built {
padding: var(--spacing-8);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-surface-raised);
}
/* 고르기 창 머리 — 제목이 두 줄이어도 [닫기] 글이 안 갈라지고 안 줄어듦 */
.m02-hopyo__choose .ui-modal__top > .ui-btn {
flex-shrink: 0;
white-space: nowrap;
}
.m02-hopyo__cands {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--spacing-4);
margin-bottom: var(--spacing-8);
}
@@ -0,0 +1,190 @@
/* =============================================================================
* M02_MasterTemplete_Hopyo_Api.ts
* 일위대가 실무자료(PLAN 324) 서버 길 — `/api/m02/layers/{층}/hopyo…` · 층 = 화면 층(`Api_Fetch` 지금 층) ·
* 올리기 = FormData(숨은 file 입력) · 그 밖 = 공용 `call`(서버 까닭 글 그대로).
* ========================================================================== */
import { API_BASE_URL } from "@config/config_frontend";
import { call, getActiveLayer, getActiveOwner, type Layer } from "./M02_MasterTemplete_Api_Fetch";
import type { LaborRef } from "./M02_MasterTemplete_Labor_Calc";
/** 층 · 주인(null = 자기 층) */
export interface HopyoScope {
layer: Layer;
owner: string | null;
}
export interface HopyoFile {
키: string;
파일: string;
올린때: string;
올린이: { id: number; 이름: string | null } | null;
시트: string;
호표수: number;
어긋남: number;
}
export type Money = Record<"계" | "노무비" | "재료비" | "경비", number | null>;
export interface HopyoBrief {
번: number;
이름: string;
규격: string;
단위: string;
금액: Money;
/** [들어감 수, 줄 수] */
맞춤: [number, number];
}
export interface HopyoView extends HopyoFile {
검산: { 어긋남: string[]; 총괄표: number };
호표: HopyoBrief[];
맞춤: [number, number];
}
/** 맞춤 대상 — 로직 · 자재 · 다른 호표 */
export interface HopyoTarget {
로직?: string;
자재?: string;
호표?: number;
이름?: string;
규격?: string;
단위?: string | null;
점수?: number;
}
export interface HopyoMatch {
열쇠: string;
상태: "기억" | "같음" | "후보" | "없음" | "빼기" | "호표" | "%";
대상: HopyoTarget | null;
입력값: Record<string, unknown>;
못읽음: string[];
환산: { 배: number | null; 갈래: string } | null;
들어감: boolean;
후보?: HopyoTarget[];
}
export interface HopyoLine {
차례: number;
이름: string;
규격: string;
수량: number | null;
단위: string;
갈래: string;
단가: Money;
금액: Money;
비고: string;
맞춤: HopyoMatch;
}
export interface HopyoOne extends HopyoFile {
호표: HopyoBrief & { 줄: HopyoLine[] };
}
export interface HopyoGuess {
대상: HopyoTarget;
입력값: Record<string, unknown>;
못읽음: string[];
환산: { 배: number | null; 갈래: string };
}
export interface HopyoMissing {
차례: number;
이름: string;
규격: string;
수량: number | null;
단위: string;
금액: number | null;
까닭: string;
}
export interface HopyoBuilt {
참조: LaborRef;
이름: string;
다시씀: boolean;
빠진줄: HopyoMissing[];
비교: { 엑셀: number | null; 우리: number | null; 차이: number | null; 까닭: string | null };
}
/** [로직 추가] 「실무 호표」 줄 하나 */
export interface HopyoPickRow extends Omit<HopyoBrief, "금액"> {
층: Layer;
주인: string | null;
키: string;
파일: string;
}
/** 확정 한 줄 — 로직 · 자재 · 빼기 · null(지움) */
export type HopyoMemory =
| { 로직: string; 입력값: Record<string, unknown>; 환산: number }
| { 자재: string; 입력값: Record<string, unknown>; 환산: number }
| { 빼기: true }
| null;
const enc = encodeURIComponent;
/** 지금 화면 층(구조물 조합 층) */
export const pageScope = (): HopyoScope => ({ layer: getActiveLayer(), owner: getActiveOwner() });
const at = (scope: HopyoScope, path: string): string => {
const who = scope.owner && scope.layer !== "system" ? `owner=${enc(scope.owner)}` : "";
const join = path.includes("?") ? "&" : "?";
return `/layers/${scope.layer}${path}${who ? join + who : ""}`;
};
export const listHopyo = async (scope: HopyoScope): Promise<HopyoFile[]> =>
(await call<{ 줄: HopyoFile[] }>(at(scope, "/hopyo"))).줄;
export const readHopyo = (scope: HopyoScope, key: string): Promise<HopyoView> =>
call(at(scope, `/hopyo/${enc(key)}`));
export const readHopyoOne = (scope: HopyoScope, key: string, no: number): Promise<HopyoOne> =>
call(at(scope, `/hopyo/${enc(key)}/${no}`));
export const guessHopyo = (
scope: HopyoScope,
key: string,
no: number,
차례: number,
대상: HopyoTarget,
): Promise<HopyoGuess> =>
call(at(scope, `/hopyo/${enc(key)}/${no}/guess`), {
method: "POST",
body: JSON.stringify({ 차례, 대상 }),
});
export const saveHopyoMemory = (
scope: HopyoScope,
줄: Record<string, HopyoMemory>,
): Promise<unknown> =>
call(at(scope, "/hopyo-memory"), { method: "POST", body: JSON.stringify({ 줄 }) });
export const deleteHopyo = (scope: HopyoScope, key: string): Promise<unknown> =>
call(at(scope, `/hopyo/${enc(key)}`), { method: "DELETE" });
/** 실무 호표 → `target` 층 사전조합(파일 층 = `from`) */
export const buildHopyo = (
target: HopyoScope,
from: { 층: Layer; 주인: string | null; 키: string; 번: number },
새로 = false,
): Promise<HopyoBuilt> =>
call(at(target, "/hopyo-build"), {
method: "POST",
body: JSON.stringify({ ...from, 새로 }),
});
export const listHopyoPicks = async (scope: HopyoScope): Promise<HopyoPickRow[]> =>
(await call<{ 줄: HopyoPickRow[] }>(at(scope, "/hopyo-picks"))).줄;
/** 엑셀 올리기 — 서버 까닭 글 그대로(시트 없음 · 20 MB · 엉터리) */
export async function uploadHopyo(scope: HopyoScope, file: File): Promise<HopyoFile> {
const form = new FormData();
form.append("file", file);
const response = await fetch(`${API_BASE_URL}/m02${at(scope, "/hopyo")}`, {
method: "POST",
credentials: "include",
body: form,
});
const body = (await response.json().catch(() => ({}))) as { detail?: unknown } & HopyoFile;
if (!response.ok)
throw new Error(typeof body.detail === "string" ? body.detail : `HTTP ${response.status}`);
return body;
}
@@ -0,0 +1,58 @@
/* =============================================================================
* M02_MasterTemplete_Hopyo_Pick.ts
* [로직 추가] 창의 「실무 호표」 줄(PLAN 324-4) — 일위대가 문서 층 · 위 층 실무자료의 호표마다 한 줄(맞춤 n/m) ·
* 고르면 서버가 그 호표를 문서 층 사전조합으로 만들고(`/hopyo-build` · 있던 것은 다시 씀) `lib.pick` 으로
* 참조 줄(별칭 = 호표 품명) · 빠진 줄이 있으면 알림. 프로젝트 층엔 없음.
* ========================================================================== */
import { showToast } from "@ui/ui_template_elements";
import { buildHopyo, listHopyoPicks, type HopyoPickRow } from "./M02_MasterTemplete_Hopyo_Api";
import type { LibraryPick, PickExtra } from "./M02_MasterTemplete_Labor_Pick";
import { tc } from "./M02_MasterTemplete_Labor_Text";
export const HOPYO_KIND = "hopyo";
const LAYER_TEXT: Record<string, string> = { system: "마스터", company: "회사", personal: "개인" };
const ALIAS_MAX = 40; // 별칭 상한(44-6 · 서버 `ALIAS_MAX`)
/** 서고 고르기에 「실무 호표」 덧줄 · 종류 고르기(단가산출 · 사전조합 · 실무 호표)를 붙임 */
export function withHopyo(lib: LibraryPick): LibraryPick {
if (lib.layer === "project") return lib;
const scope = { layer: lib.layer, owner: lib.owner };
return {
...lib,
kindFilter: lib.kindFilter ?? [
{ value: "logic", text: tc("Kind_Logic") },
{ value: "precombo", text: tc("Kind_Combo") },
{ value: HOPYO_KIND, text: "실무 호표" },
],
more: async () => (await listHopyoPicks(scope)).map((row) => hopyoExtra(row, lib)),
};
}
/** 실무 호표 한 줄 */
export function hopyoExtra(row: HopyoPickRow, lib: LibraryPick): PickExtra {
return {
kind: HOPYO_KIND,
key: `${row.층}|${row.주인 ?? ""}|${row.키}|${row.번}`,
tag: "실무 호표",
name: [row.이름, row.규격].filter(Boolean).join(" "),
sub: `${row.파일} · 제${row.번}호표 · ${LAYER_TEXT[row.층] ?? row.층}`,
unit: `${row.단위} · 맞춤 ${row.맞춤[0]}/${row.맞춤[1]}`,
onPick: () => void pickHopyo(row, lib),
};
}
/** 고름 — 서버가 사전조합을 만들고(다시 씀) 참조 줄을 더함 */
export async function pickHopyo(row: HopyoPickRow, lib: LibraryPick): Promise<void> {
try {
const from = { 층: row.층, 주인: row.주인, 키: row.키, 번: row.번 };
const got = await buildHopyo({ layer: lib.layer, owner: lib.owner }, from);
const miss = got.빠진줄.length;
const head = `「${row.이름}」 → 사전조합 ${got.참조.키}${got.다시씀 ? "(있던 것)" : ""}`;
if (miss) showToast(`${head} · 빠진 줄 ${miss} — 실무자료 화면에서 맞춤`, "warning", 6000);
else showToast(head, "success");
lib.pick(got.참조, got.이름, row.이름.slice(0, ALIAS_MAX).trim() || null);
} catch (error) {
showToast(error instanceof Error ? error.message : String(error), "error");
}
}
@@ -0,0 +1,128 @@
/* =============================================================================
* M02_MasterTemplete_Hopyo_Side.ts
* 좌측 「일위대가 실무자료」 컨테이너(PLAN 324-4 · 일위대가 사전조합 아래) — 층 = 구조물 조합 층(화면 층) ·
* [엑셀 올리기](숨은 file 입력 .xls · .xlsx · 쓰기 권한 없으면 꺼짐) + 파일 줄 → 누르면 본 화면
* (`M02_MasterTemplete_Hopyo_View`) · 고름 표시 = 본 화면이 메인 칸에 붙어 있는 동안만.
* ========================================================================== */
import { createButton, el, showToast } from "@ui/ui_template_elements";
import { listHopyo, pageScope, uploadHopyo, type HopyoFile } from "./M02_MasterTemplete_Hopyo_Api";
import { buildHopyoView } from "./M02_MasterTemplete_Hopyo_View";
export const HOPYO_TITLE = "일위대가 실무자료";
export interface HopyoSectionOptions {
/** 이 층 쓰기 권한 — 없으면 올리기 꺼짐 · 본 화면 확정 · 만들기 없음 */
canWrite: boolean;
/** 저장 안 한 고침을 버릴지 확인 — 취소면 false */
confirmLeave: () => Promise<boolean>;
/** 메인 칸에 화면을 띄움 */
show: (label: string, node: HTMLElement) => (dirty: boolean) => void;
/** 화면을 열었음 — 좌측 다른 고름 표시를 지움 */
onOpen: () => void;
}
export interface HopyoSection {
title: string;
body: HTMLElement;
/** 다른 화면이 열렸으면 고름 표시를 지움 */
paint: () => void;
refresh: () => Promise<void>;
}
const why = (error: unknown): string => (error instanceof Error ? error.message : String(error));
export function buildHopyoSection(opt: HopyoSectionOptions): HopyoSection {
const scope = pageScope();
const list = el("div", { className: "m02-side__items ui-sidebar-list" });
const input = el("input", {
attrs: { type: "file", accept: ".xls,.xlsx", "data-hopyo-file": "" },
}) as HTMLInputElement;
input.hidden = true;
const upload = createButton({
label: "엑셀 올리기",
variant: "filled",
size: "sm",
disabled: !opt.canWrite,
onClick: () => input.click(),
});
upload.title = opt.canWrite ? "설계내역서 엑셀(일위대가 시트)" : "이 층 쓰기 권한 없음";
const body = el("div", {
className: "m02-hopyo-side",
children: [el("div", { className: "m02-side__row", children: [upload, input] }), list],
});
let files: HopyoFile[] = [];
let open: { key: string; view: HTMLElement } | null = null;
let seq = 0;
input.addEventListener("change", () => {
const file = input.files?.[0];
input.value = "";
if (!file) return;
upload.disabled = true;
uploadHopyo(scope, file)
.then(async (got) => {
const wrong = got.어긋남 ? ` · 검산 어긋남 ${got.어긋남}` : "";
showToast(`올림 — 호표 ${got.호표수}${wrong}`, got.어긋남 ? "warning" : "success");
await refresh();
await show(got);
})
.catch((error) => showToast(why(error), "error"))
.finally(() => (upload.disabled = !opt.canWrite));
});
function paintList(): void {
list.replaceChildren(
...(files.length
? files.map((f) => {
const row = el("button", {
className: `m02-side__item ui-sidebar-row${open?.key === f.키 ? " is-active" : ""}`,
text: `${f.파일} · 호표 ${f.호표수}`,
attrs: { type: "button", title: `${f.키} · ${f.올린때}`, "data-hopyo-key": f.키 },
});
row.addEventListener("click", () => void show(f));
return row;
})
: [el("p", { className: "m02-side__empty", text: "올린 실무자료 없음" })]),
);
}
async function show(file: HopyoFile): Promise<void> {
if (open?.key === file.키 && open.view.isConnected) return;
if (!(await opt.confirmLeave())) return;
const view = buildHopyoView(file.키, {
scope,
canWrite: opt.canWrite,
onDeleted: () => {
view.replaceChildren(el("p", { className: "m02-side__empty", text: "지운 실무자료" }));
void refresh();
},
});
opt.show(`${HOPYO_TITLE} — ${file.파일}`, view);
opt.onOpen();
open = { key: file.키, view };
paintList();
}
async function refresh(): Promise<void> {
const mine = ++seq;
try {
const got = await listHopyo(scope);
if (mine === seq) files = got;
} catch (error) {
if (mine === seq) files = [];
showToast(why(error), "error");
}
if (mine === seq) paintList();
}
const paint = (): void => {
if (open && !open.view.isConnected) {
open = null;
paintList();
}
};
void refresh();
return { title: HOPYO_TITLE, body, paint, refresh };
}
@@ -0,0 +1,485 @@
/* =============================================================================
* M02_MasterTemplete_Hopyo_View.ts
* 일위대가 실무자료 본 화면(PLAN 324-4) — 파일 하나: 파일 · 시트 · 호표 수 · 검산 · 맞춤 n/m · 호표 표 + 찾기 ·
* [지우기] / 호표 하나: 줄 표(갈래 · 품명 · 규격 · 수량 · 단위 · 엑셀 단가 · 맞춤 · 대상 · 입력값 · 환산 ·
* [고르기] [확정] [빼기]) · [같음 모두 확정] · 빠진 줄 · [사전조합 만들기] → 값 비교 · 다시 씀이면 [새로 만들기].
* 맞춤 · 만들기는 서버(`M02_Hopyo_Match` · `M02_Hopyo_Build`) · 쓰기 단추는 층 쓰기 권한만.
* ========================================================================== */
import { createButton, el, showConfirmDialog, showToast } from "@ui/ui_template_elements";
import { loadingError, showLoading } from "@ui/ui_template_loading";
import { openModal } from "@ui/ui_template_modal";
import {
buildHopyo,
deleteHopyo,
guessHopyo,
readHopyo,
readHopyoOne,
saveHopyoMemory,
type HopyoBuilt,
type HopyoGuess,
type HopyoLine,
type HopyoMemory,
type HopyoOne,
type HopyoScope,
type HopyoTarget,
type HopyoView,
} from "./M02_MasterTemplete_Hopyo_Api";
import { openLogicPicker } from "./M02_MasterTemplete_Labor_Pick";
import { pickMaterial } from "./M02_Material_Pick";
import "./M02_MasterTemplete_Hopyo.css";
export interface HopyoViewOptions {
scope: HopyoScope;
/** 층 쓰기 권한 — 없으면 확정 · 만들기 · 지우기 단추 없음 */
canWrite: boolean;
/** 파일을 지웠음 — 좌측 목록 다시 */
onDeleted: () => void;
}
const STATE: Record<string, string> = {
기억: "기억",
같음: "같음",
후보: "후보",
없음: "없음",
빼기: "뺌",
호표: "호표",
"%": "% 줄",
};
const MATERIAL = new Set(["자재", "별도계상"]);
const why = (error: unknown): string => (error instanceof Error ? error.message : String(error));
const won = (v: number | null | undefined): string =>
v === null || v === undefined ? "" : v.toLocaleString("ko-KR");
const fail = (error: unknown): void => showToast(`실패 — ${why(error)}`, "error");
const grid = (heads: string[], body: HTMLElement, cls = ""): HTMLElement =>
el("div", {
className: "ui-general-block__table-wrap ui-general-block__table-wrap--sticky-head",
children: [
el("table", {
className: `ui-general-block__table m02-hopyo__table ${cls}`.trim(),
children: [
el("thead", {
children: [el("tr", { children: heads.map((t) => el("th", { text: t })) })],
}),
body,
],
}),
],
});
const table = (heads: string[], rows: HTMLElement[]): HTMLElement =>
grid(heads, el("tbody", { children: rows }));
const td = (text: string, cls = ""): HTMLElement =>
el("td", { text, className: cls, attrs: { title: text } });
const note = (text: string): HTMLElement => el("span", { className: "m02-hopyo__note", text });
/** 대상 글 — 「이름 [단위]」 · 자재는 규격도 · 호표 = 「제N호표」 */
export const targetText = (t: HopyoTarget | null | undefined): string => {
if (!t) return "";
if (t.호표 !== undefined) return `제${t.호표}호표 ${t.단위 ? `[${t.단위}]` : ""}`.trim();
const spec = t.규격 ? ` ${t.규격}` : "";
return `${t.이름 ?? t.로직 ?? t.자재 ?? ""}${spec}${t.단위 ? ` [${t.단위}]` : ""}`;
};
/** 입력값 글 — 「이름=값」 · 못 읽은 입력 표시 */
export const inputsText = (values: Record<string, unknown>, unread: string[]): string =>
[
Object.entries(values)
.map(([k, v]) => `${k}=${String(v)}`)
.join(", "),
unread.length ? `⚠ 못 읽음: ${unread.join(", ")}` : "",
]
.filter(Boolean)
.join(" · ");
/** 만들기 결과 낱말 — [새로 만들기] = 「새로 만들어 덮음」 · 있던 것 = 「다시 씀」 · 그 밖 「만듦」 */
export const builtWord = (got: Pick<HopyoBuilt, "다시씀">, fresh: boolean): string =>
fresh ? "새로 만들어 덮음" : got.다시씀 ? "다시 씀" : "만듦";
/** 확정 한 줄 몸 — 대상 · 입력값 · 환산(0 넘는 수) · 없으면 null */
export function memoryOf(
target: HopyoTarget | null,
values: Record<string, unknown>,
times: number | null,
): HopyoMemory {
if (!target || times === null || !(times > 0)) return null;
if (target.로직) return { 로직: target.로직, 입력값: values, 환산: times };
if (target.자재) return { 자재: target.자재, 입력값: values, 환산: times };
return null;
}
export function buildHopyoView(key: string, opt: HopyoViewOptions): HTMLElement {
const view = el("div", { className: "m02-hopyo", attrs: { "data-hopyo-view": key } });
let nav = 0; // 늦게 온 응답 버림
let query = "";
async function load<T>(get: () => Promise<T>, retry: () => void): Promise<T | null> {
const my = ++nav;
const stop = showLoading(view);
try {
const got = await get();
return my === nav ? got : null;
} catch (error) {
if (my === nav) view.replaceChildren(loadingError(why(error), retry));
return null;
} finally {
stop();
}
}
/* ── 파일 하나 — 호표 표 ─────────────────────────────────────── */
async function drawFile(): Promise<void> {
const file = await load(
() => readHopyo(opt.scope, key),
() => void drawFile(),
);
if (!file) return;
const search = el("input", {
className: "ui-input ui-input--sm m02-hopyo__search",
attrs: { type: "search", placeholder: "호표 · 품명 · 규격 찾기", value: query },
}) as HTMLInputElement;
const body = el("tbody");
const paint = (): void => {
const q = search.value.trim().toLowerCase();
query = search.value;
body.replaceChildren(
...file.호표
.filter((h) => !q || `제${h.번}호표 ${h.이름} ${h.규격}`.toLowerCase().includes(q))
.map((h) => {
const tr = el("tr", {
className: "m02-hopyo__row",
attrs: { "data-hopyo-no": String(h.번) },
children: [
td(`제${h.번}호표`),
td(h.이름),
td(h.규격),
td(h.단위),
td(won(h.금액.계), "m02-hopyo__num"),
td(`${h.맞춤[0]} / ${h.맞춤[1]}`, "m02-hopyo__num"),
],
});
tr.addEventListener("click", () => void drawOne(h.번));
return tr;
}),
);
};
search.addEventListener("input", paint);
paint();
const wrong = file.검산.어긋남;
view.replaceChildren(
el("div", {
className: "m02-hopyo__bar",
children: [
el("strong", { text: file.파일 }),
note(`시트 ${file.시트}`),
note(`호표 ${file.호표수}`),
note(wrong.length ? `검산 어긋남 ${wrong.length}` : "검산 맞음"),
note(`맞춤 ${file.맞춤[0]} / ${file.맞춤[1]}`),
...(opt.canWrite ? [dropButton(file)] : []),
],
}),
...(wrong.length ? [el("p", { className: "m02-hopyo__warn", text: wrong.join("\n") })] : []),
search,
grid(["호표", "품명", "규격", "단위", "엑셀 단가", "맞춤"], body),
);
}
const dropButton = (file: HopyoView): HTMLButtonElement =>
createButton({
label: "지우기",
variant: "danger",
size: "sm",
onClick: async () => {
const ask = `실무자료 「${file.파일}」을 지움 — 만든 사전조합 · 맞춤 기억은 그대로`;
if (!(await showConfirmDialog(ask, "지우기"))) return;
try {
await deleteHopyo(opt.scope, key);
showToast("지움", "success");
opt.onDeleted();
} catch (error) {
fail(error);
}
},
});
/* ── 호표 하나 — 줄 맞춤 ────────────────────────────────────── */
async function drawOne(no: number, built?: HopyoBuilt & { 새로: boolean }): Promise<void> {
const got = await load(
() => readHopyoOne(opt.scope, key, no),
() => void drawOne(no),
);
if (!got) return;
const h = got.호표;
const picked = new Map<number, HopyoGuess>(); // 고른 대상(확정 전)
const back = createButton({ label: "← 호표 목록", variant: "ghost", size: "sm" });
back.addEventListener("click", () => void drawFile());
const rows = el("tbody");
const paintRows = (): void => {
rows.replaceChildren(...h.줄.map((line) => lineRow(got, line, picked, paintRows)));
};
paintRows();
const same = h.줄.filter((x) => x.맞춤.상태 === "같음" && x.맞춤.환산?.배 != null);
const tools: HTMLElement[] = [];
if (opt.canWrite) {
const all = createButton({ label: `같음 모두 확정 (${same.length})`, size: "sm" });
all.disabled = !same.length;
all.addEventListener("click", () => void confirmAll(no, same));
const make = createButton({ label: "사전조합 만들기", variant: "filled", size: "sm" });
make.addEventListener("click", () => void build(no, false));
tools.push(all, make);
}
const missing = h.줄.filter((x) => !x.맞춤.들어감);
view.replaceChildren(
el("div", {
className: "m02-hopyo__bar",
children: [
back,
el("strong", { text: `제${h.번}호표 ${h.이름}` }),
note(h.규격),
note(h.단위),
note(`엑셀 단가 ${won(h.금액.계)}`),
note(`맞춤 ${h.맞춤[0]} / ${h.맞춤[1]}`),
...tools,
],
}),
h.줄.length
? grid(
[
"갈래",
"품명",
"규격",
"수량",
"단위",
"엑셀 단가",
"맞춤",
"대상",
"입력값",
"환산",
"",
],
rows,
"m02-hopyo__lines",
)
: el("p", { className: "m02-hopyo__note", text: "구성 줄 없음" }),
...(missing.length ? [missingBlock(missing)] : []),
...(built ? [builtBlock(no, built)] : []),
);
}
function lineRow(
got: HopyoOne,
line: HopyoLine,
picked: Map<number, HopyoGuess>,
repaint: () => void,
): HTMLElement {
const m = line.맞춤;
const mine = picked.get(line.차례);
const target = mine?.대상 ?? m.대상;
const values = mine?.입력값 ?? m.입력값;
const conv = mine?.환산 ?? m.환산;
const fixed = ["%", "호표", "빼기"].includes(m.상태);
const times = el("input", {
className: "ui-input ui-input--sm m02-hopyo__times",
attrs: {
type: "number",
step: "any",
min: "0",
value: conv?.배 != null ? String(conv.배) : "",
},
}) as HTMLInputElement;
times.disabled = !opt.canWrite || fixed || !target;
const state = mine ? "고름" : (STATE[m.상태] ?? m.상태);
const actions: HTMLElement[] = [];
if (opt.canWrite && m.상태 === "빼기") {
actions.push(
createButton({
label: "되살림",
variant: "ghost",
size: "sm",
onClick: () => void remember(got.호표.번, { [m.열쇠]: null }),
}),
);
} else if (opt.canWrite && !["%", "호표"].includes(m.상태)) {
actions.push(
createButton({
label: "고르기",
variant: "ghost",
size: "sm",
onClick: () =>
choose(line, (t) =>
guessHopyo(opt.scope, key, got.호표.번, line.차례, t).then(
(g) => (picked.set(line.차례, g), repaint()),
fail,
),
),
}),
createButton({
label: "확정",
size: "sm",
disabled: !target,
onClick: () => {
const body = memoryOf(target, values, times.value === "" ? null : Number(times.value));
if (!body) return showToast("대상과 0 넘는 환산을 넣어야 확정", "error");
void remember(got.호표.번, { [m.열쇠]: body });
},
}),
createButton({
label: "빼기",
variant: "ghost",
size: "sm",
onClick: () => void remember(got.호표.번, { [m.열쇠]: { 빼기: true } }),
}),
);
}
return el("tr", {
className: `m02-hopyo__line is-${m.들어감 ? "in" : "out"}`,
attrs: { "data-line": String(line.차례), "data-state": m.상태 },
children: [
td(line.갈래),
td(line.이름),
td(line.규격),
td(line.수량 === null ? "" : String(line.수량), "m02-hopyo__num"),
td(line.단위),
td(won(line.단가.계), "m02-hopyo__num"),
el("td", {
children: [el("span", { className: `m02-hopyo__state is-${m.상태}`, text: state })],
}),
td(targetText(target)),
td(inputsText(values, mine?.못읽음 ?? m.못읽음)),
el("td", {
children: [times, note(conv?.갈래 ? ` ${conv.갈래}` : "")],
}),
el("td", { className: "m02-hopyo__acts", children: actions }),
],
});
}
/** 대상 고르기 — 후보 5 + 다른 로직 · 자재 찾기 */
function choose(line: HopyoLine, done: (t: HopyoTarget) => void): void {
const material = MATERIAL.has(line.갈래);
openModal({
title: `고르기 — ${line.이름} ${line.규격}`.trim(),
closeLabel: "닫기",
dialogClass: "m02-modal m02-hopyo__choose",
mount: (body, close) => {
const pick = (t: HopyoTarget): void => {
close();
done(t);
};
const list = (line.맞춤.후보 ?? []).map((c) =>
createButton({
label: `${targetText(c)} · ${c.로직 ?? c.자재 ?? ""}`,
variant: "ghost",
size: "sm",
onClick: () => pick(c),
}),
);
const more = createButton({
label: material ? "다른 자재 찾기…" : "다른 로직 찾기…",
size: "sm",
onClick: () => {
close();
if (material) pickMaterial((x) => done({ 자재: x.코드 }));
else openLogicPicker((x) => done({ 로직: x.키 }));
},
});
body.append(
el("p", { className: "m02-hopyo__note", text: list.length ? "후보" : "가까운 것 없음" }),
el("div", { className: "m02-hopyo__cands", children: list }),
more,
);
},
});
}
async function remember(no: number, 줄: Record<string, HopyoMemory>): Promise<void> {
try {
await saveHopyoMemory(opt.scope, 줄);
await drawOne(no);
} catch (error) {
fail(error);
}
}
async function confirmAll(no: number, lines: HopyoLine[]): Promise<void> {
const body: Record<string, HopyoMemory> = {};
for (const x of lines) {
const one = memoryOf(x.맞춤.대상, x.맞춤.입력값, x.맞춤.환산?.배 ?? null);
if (one) body[x.맞춤.열쇠] = one;
}
await remember(no, body);
}
async function build(no: number, fresh: boolean): Promise<void> {
try {
const from = { 층: opt.scope.layer, 주인: opt.scope.owner, 키: key, 번: no };
const got = await buildHopyo(opt.scope, from, fresh);
const word = builtWord(got, fresh);
const miss = got.빠진줄.length;
showToast(miss ? `${word} — 빠진 줄 ${miss}` : word, miss ? "warning" : "success");
await drawOne(no, { ...got, 새로: fresh });
} catch (error) {
fail(error);
}
}
const missingBlock = (lines: HopyoLine[]): HTMLElement =>
el("div", {
className: "m02-hopyo__block",
children: [
el("h4", { text: `빠진 줄 ${lines.length} — 사전조합에 안 들어감` }),
table(
["품명", "규격", "수량", "단위", "엑셀 금액", "까닭"],
lines.map((x) =>
el("tr", {
children: [
td(x.이름),
td(x.규격),
td(x.수량 === null ? "" : String(x.수량), "m02-hopyo__num"),
td(x.단위),
td(won(x.금액.계), "m02-hopyo__num"),
td(STATE[x.맞춤.상태] ?? x.맞춤.상태),
],
}),
),
),
],
});
function builtBlock(no: number, got: HopyoBuilt & { 새로: boolean }): HTMLElement {
const c = got.비교;
const again = got.다시씀
? [
note(
got.새로
? `사전조합 ${got.참조.키} 를 새로 만들어 덮음`
: `있던 사전조합 ${got.참조.키} 를 다시 씀`,
),
createButton({
label: "새로 만들기",
variant: "danger",
size: "sm",
onClick: async () => {
const ask = `사전조합 ${got.참조.키} 를 지금 맞춤으로 다시 만들어 덮음`;
if (await showConfirmDialog(ask, "새로 만들기")) await build(no, true);
},
}),
]
: [note(`새 사전조합 ${got.참조.키}`)];
return el("div", {
className: "m02-hopyo__block m02-hopyo__built",
attrs: { "data-built": got.참조.키 },
children: [
el("div", { className: "m02-hopyo__bar", children: again }),
note(
`값 비교 — 엑셀 ${won(c.엑셀)} · 우리 ${won(c.우리 === null ? null : Math.round(c.우리))}` +
` · 차이 ${won(c.차이)}${c.까닭 ? ` · ${c.까닭}` : ""}`,
),
],
});
}
void drawFile();
return view;
}
@@ -59,7 +59,7 @@ import { openLaborInput } from "./M02_MasterTemplete_Labor_Input";
import { openQtyModal } from "./M02_MasterTemplete_Labor_QtyModal";
import { openRefEdit } from "./M02_MasterTemplete_Labor_RefEdit";
import { afterRowDrag } from "./M02_MasterTemplete_Labor_RowDrag";
import { openLogicPicker } from "./M02_MasterTemplete_Labor_Pick";
import { openLogicPicker, withHopyo } from "./M02_MasterTemplete_Labor_Pick";
import { logicMatRows } from "./M02_MasterTemplete_Labor_LogicMats";
import { setLogicMark, setMark } from "./M02_MasterTemplete_Labor_MatMarks";
import { logicTable, matTable, qtyShort } from "./M02_MasterTemplete_Labor_Rows";
@@ -484,12 +484,12 @@ export function mountLaborDetail(
: opt.layer === getActiveLayer()
? getActiveOwner()
: null,
pick: (ref: LaborRef, name: string) => {
pick: (ref: LaborRef, name: string, 별칭: string | null = null) => {
const id = lineId({ 참조: ref });
if (cur.담은로직.some((r) => lineId(r) === id))
return showToast(tc("Labor_AlreadyRef").replace("{value}", `${ref.키} ${name}`), "error");
const 수량 = lib ? { 손: 1 } : null; // 사전조합 줄 기본 1(51-6)
cur.담은로직.push({ 로직: "", 참조: ref, 별칭: null, 메모: null, 입력값: {}, 수량 });
cur.담은로직.push({ 로직: "", 참조: ref, 별칭, 메모: null, 입력값: {}, 수량 });
changed();
},
};
@@ -507,7 +507,7 @@ export function mountLaborDetail(
void loadLogics().then(() => changed());
},
// 사전조합도 같은 창 · 같은 목록(51-11 · 자기 자신 · 순환은 [저장] 때 서버 422)
library,
withHopyo(library),
),
});
const addMat = addMaterialButton(
@@ -4,7 +4,8 @@
* 단가승격 · 일위조합 줄은 층 단추(위 층만 · 짝 `M02_Labor_Ref.readable`) 층의 서고 · 고르면 참조 {층 · 주인 · 키} ·
* 프로젝트 · 카드엔 서고 줄 없음 · 거름 = 로직 품셈 분류 + 사전조합 새 분류(149-2 · 구조물군 › 공통 + 종류)
* 단가연결(251 · B08 거리이정표 · B09 설계내역서) = 종류 고르기(단가산출 · 사전조합 · 덧붙인 줄 종류) +
* 덧붙일 줄(구조물 조합 · B09 호표) — `lib.extra` · `lib.kindFilter`
* 덧붙일 줄(구조물 조합 · B09 호표) — `lib.extra` · `lib.kindFilter` · 늦게 받는 덧줄 `lib.more`
* (324-4 실무 호표 · `withHopyo`)
* ========================================================================== */
import { createButton, createSelectField, el, showToast } from "@ui/ui_template_elements";
@@ -28,28 +29,35 @@ import { fetchStructureDef } from "./StructOptions/M02_SO_Api";
import "../M01_MasterData/M01_MasterData_UI_Logic_Style.css"; // 창 겉틀
import "./M02_MasterTemplete_Labor.css";
export { withHopyo } from "./M02_MasterTemplete_Hopyo_Pick";
const opts = (values: string[]): { value: string; text: string }[] =>
[L("M01_All"), ...values].map((v, i) => ({ value: i ? v : "", text: v }));
type Scope = Omit<LaborRef, "키">;
/** 덧붙일 줄 하나 — kind = 종류 고르기 값 */
export interface PickExtra {
kind: string;
key: string;
tag: string;
name: string;
sub: string;
unit: string;
onPick: () => void;
}
/** 서고 탭 — 일위대가가 있는 층 · 주인(null = 자기 층) · 고르면 참조 */
export interface LibraryPick {
layer: Layer;
owner: string | null;
pick: (ref: LaborRef, name: string) => void;
/** `alias` = 참조 줄 별칭(324-4 실무 호표 품명 · 없으면 빈칸) */
pick: (ref: LaborRef, name: string, alias?: string | null) => void;
/** 서고 줄만(로직 목록 안 받음 · B09 설계내역서 연결 204-2) · 창 제목 */
libOnly?: boolean;
title?: string;
/** 목록 끝 덧붙일 줄(251 · 구조물 조합 · B09 호표) — kind = 종류 고르기 값 */
extra?: Array<{
kind: string;
key: string;
tag: string;
name: string;
sub: string;
unit: string;
onPick: () => void;
}>;
extra?: PickExtra[];
/** 창을 연 뒤 받는 덧줄(324-4 실무 호표) — 받으면 `extra` 뒤에 붙음 */
more?: () => Promise<PickExtra[]>;
/** 종류 고르기 칸(251 · 값 logic · precombo · 덧붙인 줄 kind) */
kindFilter?: Array<{ value: string; text: string }>;
}
@@ -97,6 +105,7 @@ export function openLogicPicker(done: (logic: LogicSummary) => void, lib?: Libra
const kindOk = (k: string): boolean => !kindSel?.select.value || kindSel.select.value === k;
let loading = false;
let seq = 0;
let later: PickExtra[] = []; // lib.more 로 받은 덧줄
/* 공용 찾기 목록 — 구분 · 상세구분 2열 · 이름 왼쪽 맞춤 · ↑ ↓ Enter */
const finder = createFindList({
placeholder: tc("Pick_Search"),
@@ -120,13 +129,13 @@ export function openLogicPicker(done: (logic: LogicSummary) => void, lib?: Libra
(!detail.select.value || x.row.상세구분 === detail.select.value) &&
(!q || `${x.row.이름} ${x.row.제목 ?? ""}`.toLowerCase().includes(q)),
);
const extra = (lib?.extra ?? []).filter(
const extra = [...(lib?.extra ?? []), ...later].filter(
(x) =>
kindOk(x.kind) &&
!sub.select.value &&
(!q || `${x.tag} ${x.name} ${x.sub}`.toLowerCase().includes(q)),
);
const total = all.length + libRows.length + (lib?.extra?.length ?? 0);
const total = all.length + libRows.length + (lib?.extra?.length ?? 0) + later.length;
const shown = found.length + libFound.length + extra.length;
finder.hint.textContent = loading
? tc("Loading")
@@ -271,6 +280,10 @@ export function openLogicPicker(done: (logic: LogicSummary) => void, lib?: Libra
document.body.append(backdrop);
finder.search.focus();
void lib?.more?.().then(
(got) => ((later = got), find()),
(error) => showToast(error instanceof Error ? error.message : tc("Load_Failed"), "error"),
);
if (!lib?.libOnly)
void Promise.all([fetchLogicSubs(), fetchLogics()])
.then(([s, l]) => {
@@ -1,7 +1,8 @@
/* =============================================================================
* M02_MasterTemplete_UI_Side.ts
* 마스터 템플릿 좌측 패널 — 「구조물 조합」(층 단추 · 나무) · 「일위대가 사전조합」(층 단추 따로 · 2단 폴더 나무 ·
* `UI_Side_Precombo` · 50-3 · 134-3) · 「도면 양식」(새로 · 본떠 · 지우기).
* `UI_Side_Precombo` · 50-3 · 134-3) · 「일위대가 실무자료」(구조물 조합 층 · `Hopyo_Side` · 324-4) ·
* 「도면 양식」(새로 · 본떠 · 지우기).
* 서버 길은 계약 `6_계약.md`.
* 구조물 도면 · 일위대가 · 면적산출 상세는 좌측 줄이 아니라 항목 카드 [수정]으로(PLAN 45-2).
* 화면 이름 「구조물 조합」(PLAN 26-7a) — 파일 이름 · 양식 키 · API 길은 「구조물집계표」 그대로.
@@ -31,6 +32,7 @@ import {
import { STRUCTURE_TABLE } from "./M02_MasterTemplete_Structure";
import { buildOptionsSection } from "./StructOptions/M02_SO_Options";
import { buildPracticeSection } from "./M02_MasterTemplete_Practice";
import { buildHopyoSection } from "./M02_MasterTemplete_Hopyo_Side";
import { canEdit, type Selection } from "./M02_MasterTemplete_UI_Main";
import {
buildPrecomboSection,
@@ -165,11 +167,19 @@ export function buildSide(opt: SideOptions): SideHandle {
onOpen: () => (options.clear(), paint()),
})
: null;
// 일위대가 실무자료(324-4) — 사전조합 아래 · 층 = 구조물 조합 층 · 쓰기 권한 없으면 올리기 꺼짐
const hopyo = buildHopyoSection({
canWrite: canEdit(layer, opt.isAdmin),
confirmLeave: opt.confirmLeave,
show: opt.showView,
onOpen: () => (options.clear(), practice?.clear(), paint()),
});
const listHost = el("div", {
className: "m02-side__lists ui-sidebar-panel",
children: [
summary,
precomboSec,
section(hopyo.title, hopyo.body),
...(extras ? [extras.옵션, extras.도면].filter((s): s is HTMLElement => !!s) : []),
...(practice ? [section(practice.title, practice.body)] : []),
],
@@ -225,6 +235,7 @@ export function buildSide(opt: SideOptions): SideHandle {
function paint(): void {
paintList();
precombo.paint();
hopyo.paint();
paintButtons();
if (opt.getOpen()) options.clear();
if (opt.getOpen()) practice?.clear();
@@ -0,0 +1,66 @@
# 324 일위대가 실무자료 — 조사 · 정함 (요구 223-5 · 2026-10-14)
요구(사용자 원문 요약): M02 좌측 「일위대가 실무자료」 버튼으로 엑셀 올림 → 「일위대가」 탭 분석 · 구조물조합 일위대가 [로직 추가] 때 실무자료 호표 불러오기 · 엑셀 단가산출 이름이 우리 로직 이름과 다름 → 맞으면 엑셀 이름 = 일위대가 별칭 · 옵션 값 = 규격 열 · 남의 실무문서를 우리 일위대가로 · 단위 다르면 환산한 결과로 맞춤 · 상세 분석 뒤 신규 계획 · 구현은 sub1(sub_laptop_1 가지).
조사 = Plan 넷(엑셀 구조 · 우리 단가산출/일위대가 구조 · M02 화면 · 맞춤/단위 도구). 경로는 저장소 기준.
## 1. 엑셀 「일위대가」 시트 (북부청 임도 공통단가 .xls 기준)
- 읽기 = **xlrd 2.0.2**(venv 에 있음 · requirements 에 없음 → 적어 넣음) · `xlrd.open_workbook(file_contents=…, logfile=io.StringIO())`(OLE2 경고가 stdout 으로 안 나가게) · .xlsx = openpyxl(빈 칸 None).
- 시트 = `일위대가`(560×15) · 목록 = `일위대가총괄표`(검산용) · 2·3행 머리 글(빈칸 빼고)로 찾음 · 자료 4행부터 · 숨김 없음.
- 열(0부터): 1 호표 · 2 품명 · 3 규격 · 4 수량 · 5 단위 · 6/7 합계 단가/금액 · 8/9 노무비 · 10/11 재료비 · 12/13 경비 · 14 비고 — **차례 = 합계 · 노무비 · 재료비 · 경비**.
- 호표 줄 = `re.fullmatch(r'제\s*(\d+)\s*호표', 1열)` · 1 단위 값은 **금액 열(7/9/11/13)** · 단가 열 빔.
- 구성 줄 = 1열 빔 · 2열 있음 · 끝 = 다음 호표 줄 또는 시트 끝(합계 줄 없음 · 빈 줄 하나로 띄움).
- 구성 줄 갈래(415 줄): 값 있음 350 · 값 없음(별도계상) 64 · % 1(4열 = 율 · 6열 = 바탕 금액 · 규격 「재료비의」 「노무비의」) · 참조 = 비고 `^(단산|일위)(\d+)참조$`(단산 268 · 일위 1).
- 갈래 나누기(맞춤 쪽 조사): 단산 참조 = 단가산출 · 참조 없고 재료비만 = 자재(143 · 자재단가 시트로 확인 가능) · 단위 hr 등 = 기계(3) · 일위N = 다른 호표.
- 검산 규칙(68 + 27 호표 어긋남 0): 줄 금액 = floor(단가 × 수량 × 10) / 10 · 6열 = 8+10+12 · 호표 노무/재료/경비 = floor(줄 금액 합) · % 줄 = floor(바탕 × 율 / 100 × 10) / 10.
- 수: 68 호표(제1~68 빈 번호 없음) · 줄 0~21 · 제61호표 = 줄 없음 · 값 빔.
- 보기: 제1호표 배수관날개벽(유입구) 「D=1,000mm,일반형」 개소 1,040,052 · 제3호표 임도표지석 「910×600×150」 개소 1,415,169(줄 = 표지석 재료 ㎥ 1 · 구조물터파기(인력100%) 토사 ㎥ 0.14 단산93 · 되메우기 ㎥ 0.05 단산12 · 잔토처리 ㎥ 0.09 단산13) · 제46호표 고화매트재료비 ㎡(제45호표가 일위46참조로 씀).
- 규격 쓰는 법: 자유 글 많음 · `B=` `H:`(쌍점) `D=` `A×B×C` `H=` `T=` `L=` · 구분 = 쉼표 · 빈칸 · 천 단위 쉼표 함정(`D=1,000mm` 만 = 뒤 숫자 셋) → `re.sub(r'(=\d{1,3}),(\d{3})(?!\d)', r'\1\2', s)` 뒤 `([A-Za-z]\d?)\s*[=:]\s*([\d.]+)\s*(mm|cm|m)?` · 원문 늘 보관 · × Φ Ø ㎜ ㎥ ㎡ 섞임.
- 단위: 호표 = M(= m) 37 · 개소 19 · ㎡ 12 / 줄 = ㎥ ㎡ M Ton/TON EA 공/㎥ kg hr 개소 % 개 · 사방 파일 더 = 식 경간 개 인 조.
- 다른 파일: 사방(계류+댐) = 같은 꼴 27 호표 · 수목진료 · 2016 사방표준품셈(안) = 일위대가 시트 없음(단가산출 · 단가산출총괄표만) → 「일위대가 시트 없음」.
- 함정: 이름만 같은 호표 많음 → 열쇠 = 파일 + 호표 번호 · 이름 끝 전각 빈칸(strip) · 이름 안 옵션(`큰돌바닥막이(H=0.5)`) · 호표 단위 ≠ 줄 단위(개소 안 ㎥ 줄).
## 2. 우리 쪽 구조
- 단가산출 로직(원본 · 손대지 않음) = `resources/master_data/로직_*.json` 1,469 줄 `{키 GF…, 이름, 결과단위 "원/㎡", 입력:[{이름, 단위, 고르기, 범위}], 중간, 호표, 덧줄, 끝수}` · 엔진 `master_formula.run` → `{줄, 노무비, 재료비, 경비, 계}` · 결과단위 읽기 `M01_MasterData_Store_Combo.combo_pills`(「원/10㎡」 = 분모 ㎡ · 배 10).
- 일위대가 문서(구조물 열 하나) = `{층}/templates/labor/<열>.json` `{종류, 열, 단위, 본뜬, 일위대가번, 담은로직:[{로직, 별칭, 메모, 입력값, 수량} | {참조:{층,주인,키}, 별칭, 메모, 수량}], 자재:[{코드, 이름, 규격, 단위, 수량, 자재구분?, 별도계상?, 운반비?}], 계산값}`(`M02_Labor_Doc.py` 머리 글) · 반영 = 기준 × 수량 ÷ 배 · **같은 로직 두 번 금지** · 별칭 ≤ 40 자(44-6 · 쓰기 됨 · B09 가 대가 이름으로 씀).
- 일위대가 사전조합 = `{층}/precombo/SC-######.json` `{종류:"사전조합", 이름, 단위, 담은로직, 자재, 구분, 상세구분, 본뜬}` · 값 = Σ(기준 ÷ 배) × 손 · 일위대가가 참조 줄로 씀(같은 층 · 위 층만) · 만들기 `POST /api/m02/layers/{l}/library/precombo`(`M02_Labor_Router.py:91` → `M02_Labor_Ref.py:257`) · **`_combo_lines`(`M02_Labor_Ref.py:202`) 가 별칭을 버림 → 한 줄 고침 필요** · 같은 로직 두 번 금지도 같음(labor.clean).
- [로직 추가] = `M02_MasterTemplete_Labor_Detail.ts:496-512`(693 줄 · 거의 끝) → `openLogicPicker`(`M02_MasterTemplete_Labor_Pick.ts:75`) = 로직 + 사전조합 한 목록 · **덧줄 자리 `LibraryPick.extra / kindFilter`(:43-54 · B09 가 이미 씀)** · 고르면 `lib.pick(ref, name)`(:487-494)이 참조 줄을 더함 · 사전조합 다듬기 = 참조 [수정] 창(`Labor_RefEdit.ts`).
- 좌측 판 = `M02_MasterTemplete_UI_Side.ts`(차례: 구조물 조합 · 일위대가 사전조합 · 구조물 옵션 정의 · 도면 양식 · 실무문서) · `section()` 꼴 · 층 = 해시 root · 사전조합은 따로 층 줄(pre) · 이름 금지 낱말(시험이 봄) = `practice` · `LaborSection` · `labor?.`.
- 「실무문서」(기존) = 올리기 없음 · `일위대가조합.json` UA 37 읽기 목록 + [통째 지우기](시스템 관리자만) — 이번 기능과 별개 · 그대로 둠.
- 올리기 본 = `M02_MasterTemplete_Router_Drawing.py:57-92`(UploadFile · 20 MB · 스레드 파싱 · 엉터리 400) + `M02_MasterTemplete_Drawing.ts:145-162 · 297-329`(숨은 file 입력 · FormData).
- 권한(`M02_Layer_Access.py`) = system 쓰기 시스템 관리자 · 회사 = 시스템 관리자 · 그 회사 관리자 · 개인 = 본인 · 회사 관리자.
- 정규화 · 단위 도구(그대로 씀): `master_material.plain / plain_spec / plain_unit`(NFKC · ㎥→m3 · M→m · Ton→톤 · EA→개) · `master_pick.UNITS / kind_of / same_kind / convert` · `M02_Labor_Inputs.logic_inputs`(입력 · 고르기 · 고르기이름 · 자동값) · 씨앗 = `일위대가조합.json` 메모(실무 이름 약 80 → 로직 43 · 읽기만) · 선례 = `resources/master_data/scripts/compare_estx.py`(PAIRS = 외부 코드 · 로직 · 입력 · 환산 배수 · `base_name` · `MACH_WORDS`).
- 맞춤 어림(북부청 단가산출 이름 95): 같음 9 · 괄호 빼면 9 · UA 씨앗 13 → 31 자동 · 64 = 후보 확정 필요(difflib 첫 후보 자주 틀림).
- 지키는 것: `resources/master_data/*.json` · `일위대가조합.json` 원본 안 고침(시험으로 sha256 확인) · M01 로직 별칭(208-1 · 원본 안) 안 씀 · `main.py`(802 줄) 안 건드림 → 길은 `M02_Labor_Router.py` 에.
## 3. 정함(브레인 · 사용자가 맡김)
1. 층 = 구조물 조합 층(root)과 같은 규칙 · 읽기 · 쓰기 권한 그대로(마스터 = 시스템 관리자) · 자리 = `{층 폴더}/실무자료/<id>.json` + 맞춤 기억 `{층 폴더}/실무자료/맞춤.json` · 템플릿 목록 · 복사 · 프로젝트 씨앗에 안 섞임.
2. 올리기 = 파싱 결과만 저장(원본 파일 안 둠) · 파일 이름 · 올린 때 · 올린 이 · 시트 · 호표 · 검산 결과.
3. 호표 불러오기 = 그 호표를 우리 **일위대가 사전조합** 하나로 만들고(같은 층에 같은 파일 · 같은 호표 것이 있으면 다시 씀) 일위대가에 참조 한 줄(별칭 = 엑셀 호표 품명 · 메모 = 규격 · 수량 손 1) — 사전조합 = 「우리 일위대가」 · 여러 구조물이 같이 씀 · 엔진 안 바꿈.
4. 사전조합 안 줄 = 엑셀 구성 줄마다 맞춘 것: 로직 줄 `{로직, 별칭 = 엑셀 품명(40 자), 메모 = 「실무 0.14㎥ × 1 · 규격」, 입력값 = 규격에서, 수량 손 = 엑셀 수량 × 환산}` · 자재 줄(우리 자재와 맞은 것 · 별도계상 표시 그대로) · 일위N참조 = 그 호표 사전조합을 먼저 만들어 참조 줄(순환 막음).
5. 같은 로직 두 번(예 레미콘타설 철근 · 무근) = 둘째부터 한 줄 사전조합으로 감싸 참조(문서 규칙 그대로).
6. 맞춤 차례 = ① 층 기억(확정분) ② 이름 같음(정규화) · 씨앗(UA 메모) ③ 정리 이름 같음(괄호 빼기 · 기계 낱말) ④ 후보 5(글자 두 개 묶음 점수 + 단위 맞음 · 산림품셈 덤) — ③④ 는 사용자 확정 뒤에만 들어감 · 확정 = 층 기억에 남아 다음부터 자동.
7. 규격 → 입력값 = 고르기 글이 규격(괄호 글 포함)에 있으면 그 값 · 기계 = 고르기이름(이름 + 용량) · 이름 붙은 수(D= H= H: B= T= L= L3=)는 입력 단위로 바꿈 · 못 읽으면 빈 값(자동값) + 표시.
8. 환산(우리 수량 = 엑셀 수량 × f): 같은 단위 f = 1(10㎡ 배는 엔진) · 같은 종류 = 단위표 · 다른 종류 = 규격 T · B 로 추정(㎡→㎥ × T · m→㎡ × B · m→㎥ × B × T · 「추정」 표시 · 확정 뒤에만) · 그 밖(개소 · 회 · hr 등) = 사용자 입력 · 두 단위 로직 = 단위 입력으로 고르고 f = 1.
9. 안 들어가는 줄 = % 줄(우리 로직 덧줄이 셈) · 못 맞춘 줄 · 값 없는 줄 중 못 맞춘 것 — 화면에 「빠진 줄 · 엑셀 금액」 · 만든 사전조합 값 ↔ 엑셀 호표 단가 한 줄 비교.
10. 일위대가 시트 없는 파일 = 400 「일위대가 시트 없음」(단가산출총괄표 대체는 이번 밖).
## 4. 단계 (PLAN 324)
- 324-1 읽기 · 저장 · 올리기 길 — `M02_Hopyo_Excel.py`(xls · xlsx → 호표 · 줄 · 갈래 · 참조 · 검산) · `M02_Hopyo_Store.py`(층 폴더 · 잠금 · 원자 쓰기) · 길(올리기 · 목록 · 읽기 · 지우기) · requirements xlrd.
- 324-2 맞춤 — `M02_Hopyo_Match.py`(정규화 · 규격 → 입력 · 후보 · 기억 · 환산) · 길(후보 · 확정 저장).
- 324-3 사전조합 만들기 — `M02_Hopyo_Build.py`(호표 → 사전조합 · 별칭 · 감싸기 · 일위N · 자재 · 다시 씀) · `_combo_lines` 별칭 살림 · 길(만들기).
- 324-4 화면 — 좌측 「일위대가 실무자료」 판(올리기 · 파일 줄) · 본 화면(호표 표 · 찾기 · 줄 맞춤 · 확정 · 빠진 줄 · 값 비교) · [로직 추가] 고르기 창에 「실무 호표」 줄(고르면 사전조합 만들고 참조 줄).
- 324-5 확인 — 시험 + typecheck · 브레인 화면 확인(북부청 파일 올림 → 제3호표 맞춤 → 구조물 일위대가에 불러오기 → 저장) → 아티팩트 보고.
## 5. 시험
- 파싱: 북부청 68 · 사방 27 · 검산 어긋남 0 · 제1 · 제3 · 제46 · 제61(빈 호표) 값 · 일위대가 없는 파일 400 · xlsx 길(작은 파일 만들어).
- 맞춤: `D=1,000mm` · `H:1.2m` · `B=2.3,H=0.4` · `Φ40×200` · 단위 정규화 · 환산(같음 · 같은 종류 · T · B 추정 · 입력) · 기억 우선.
- 길: 역할 표(시스템 관리자 · 회사 관리자 · 일반 · 남의 개인 층 403) · 엉터리 파일 400 · 20 MB · 지우기.
- 만들기: 별칭 남음 · 수량 = 엑셀 × 환산 · 같은 로직 둘 = 감쌈 · 일위N 참조 · 같은 호표 다시 = 같은 사전조합 · 빠진 줄 보고.
- 원본 지킴: 올리기 · 맞춤 · 만들기 뒤 `resources/master_data/*.json` sha256 그대로.
- 화면: 소스 확인 시험(기존 꼴) · 고르기 창 덧줄 node 시험.
+1
View File
@@ -62,5 +62,6 @@ uvicorn==0.24.0
watchfiles==1.2.0
wheel==0.47.0
whitebox==2.3.6
xlrd==2.0.2
pytest>=8
httpx<0.28
@@ -0,0 +1,88 @@
/* M02 일위대가 실무자료(324-4) 헬퍼 — `M02_MasterTemplete_Hopyo_Pick.ts`(고르기 창 「실무 호표」 덧줄) ·
* `M02_MasterTemplete_Hopyo_View.ts` 글 도우미를 트랜스파일해 Node 로 돌림 · fetch 대역이 부른 길 · 몸을 모음 ·
* stdin = {rows, built} · stdout = {calls, kinds, rows, picked, project, view} JSON. */
const fs = require("fs");
const path = require("path");
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
const ROOT = path.join(__dirname, "..", "..");
const STUBS = { "@config/config_frontend": { API_BASE_URL: "/api" } };
// 본 화면이 부르는 고르기 창 · 자재 창은 DOM · 모듈 메타를 씀 — 대역(글 도우미만 시험)
const STUB_FILES = ["M02_MasterTemplete_Labor_Pick", "M02_Material_Pick"];
const stub = new Proxy({}, { get: (_t, k) => (k === "currentLanguageIndex" ? () => 0 : () => {}) });
const cache = new Map();
function loadTs(file) {
const full = path.resolve(file);
if (cache.has(full)) return cache.get(full);
const js = ts.transpileModule(fs.readFileSync(full, "utf8"), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const box = { exports: {} };
cache.set(full, box.exports);
const localRequire = (request) => {
if (STUBS[request]) return STUBS[request];
if (STUB_FILES.some((name) => request.endsWith(name))) return stub;
if (request.startsWith(".") && !request.endsWith(".css"))
return loadTs(`${path.resolve(path.dirname(full), request)}.ts`);
return stub;
};
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
return box.exports;
}
(async () => {
const input = JSON.parse(fs.readFileSync(0, "utf8"));
const calls = [];
global.fetch = async (url, init = {}) => {
calls.push({
url,
method: init.method ?? "GET",
body: init.body ? JSON.parse(init.body) : null,
});
const body = url.includes("/hopyo-picks") ? { 줄: input.rows } : input.built;
return { status: 200, ok: true, json: async () => body };
};
const M02 = path.join(ROOT, "M02_MasterTemplete");
const pick = loadTs(path.join(M02, "M02_MasterTemplete_Hopyo_Pick.ts"));
const picked = [];
const lib = {
layer: "company",
owner: "7",
pick: (ref, name, alias) => picked.push({ ref, name, alias }),
};
const wrapped = pick.withHopyo(lib);
const rows = (await wrapped.more()).map(({ onPick: _, ...r }) => r);
await pick.pickHopyo(input.rows[0], lib);
const project = pick.withHopyo({ ...lib, layer: "project" });
const view = loadTs(path.join(M02, "M02_MasterTemplete_Hopyo_View.ts"));
const out = {
calls,
kinds: wrapped.kindFilter.map((k) => k.value),
rows,
picked,
project: project.more === undefined && project.kindFilter === undefined,
view: {
target: [
view.targetText({ 로직: "GF000101", 이름: "구조물터파기", 단위: "㎥" }),
view.targetText({ 자재: "MT1", 이름: "레미콘", 규격: "25-21-120", 단위: "㎥" }),
view.targetText({ 호표: 46, 단위: "㎡" }),
],
inputs: view.inputsText({ 횟수: "4회사용시", 지역: "서울" }, ["보정작업"]),
memory: [
view.memoryOf({ 로직: "GF000101" }, { 지역: "서울" }, 1),
view.memoryOf({ 자재: "MT1" }, {}, 0.5),
view.memoryOf({ 로직: "GF000101" }, {}, 0),
view.memoryOf(null, {}, 1),
],
word: [
view.builtWord({ 다시씀: false }, false),
view.builtWord({ 다시씀: true }, false),
view.builtWord({ 다시씀: true }, true),
],
},
};
process.stdout.write(JSON.stringify(out));
})().catch((error) => {
process.stderr.write(String(error?.stack ?? error));
process.exit(1);
});
+152
View File
@@ -0,0 +1,152 @@
"""일위대가 실무자료 → 사전조합 만들기(PLAN 324-3 · `M02_Hopyo_Build`) — 별칭 남음 ·
수량 = 엑셀 × 환산 · 자재 · 같은 로직 둘 = 감쌈 · 일위N 참조 · 순환 ·
같은 호표 다시 = 같은 사전조합 · 새로 = 덮음 ·
빠진 줄 · 값 비교 · 길 권한 · master_data 원본 그대로. 층은 임시 폴더.
"""
import json
from M02_MasterTemplete import M02_Hopyo_Store as hs
from M02_MasterTemplete import M02_Template_Layers as layers
# 올리기 길 시험의 `client` 마당을 같이 씀
# ruff: noqa: F811
from resources.tester.test_m02_hopyo_excel import URL, _post, client, master_hash # noqa: F401
DIG = "구조물터파기(인력100%)|토사|m3"
FILL = "구조물되메우기(인력100%)||m3"
SOIL = "잔토처리(인력100%)||m3"
STONE = "임도표지석(받침,글새김포함)|900x600x200x150|m3"
MEMORY = "/api/m02/layers/{}/hopyo-memory"
BUILD = "/api/m02/layers/{}/hopyo-build"
def _remember(client, layer="personal", **lines):
res = client.post(MEMORY.format(layer), json={"줄": lines})
assert res.status_code == 200, res.text
def _build(client, no, layer="personal", want=200, **extra):
body = {"층": "personal", "키": "HP-000001", "번": no, **extra}
res = client.post(BUILD.format(layer), json=body)
assert res.status_code == want, res.text
return res.json()
def _doc(layer_dir, key):
return json.loads((layer_dir / "precombo" / f"{key}.json").read_bytes())
def test_만들기_별칭_수량_메모_빠진_줄_비교(client):
before = master_hash()
assert _post(client).status_code == 200
_remember(
client,
**{
DIG: {"로직": "GF000101", "입력값": {"지역": "서울"}, "환산": 1},
FILL: {"로직": "GF000113", "환산": 2},
},
)
got = _build(client, 3)
mine = layers.personal_dir(7, 42)
doc = _doc(mine, got["참조"]["키"])
assert (doc["이름"], doc["단위"], doc["구분"]) == ("임도표지석 910×600×150", "개소", "실무자료")
assert doc["상세구분"].endswith("(공통단가).xls")
assert doc["본뜬"]["층"] == "실무자료" and doc["본뜬"]["키"] == "HP-000001 제3호표"
assert doc["담은로직"] == [
{
"로직": "GF000101",
"입력값": {"지역": "서울"},
"별칭": "구조물터파기(인력100%)",
"수량": {"손": 0.14},
"메모": "실무 0.14㎥ × 1 · 토사",
},
{
"로직": "GF000113",
"입력값": {},
"별칭": "구조물되메우기(인력100%)",
"수량": {"손": 0.1}, # 엑셀 0.05 × 환산 2
"메모": "실무 0.05㎥ × 2",
},
]
assert [(x["이름"], x["금액"]) for x in got["빠진줄"]] == [
("임도표지석(받침,글새김포함)", 1400000),
("잔토처리(인력100%)", 3097.1),
]
assert got["비교"]["엑셀"] == 1415169 and got["비교"]["우리"] > 0
assert got["비교"]["차이"] == round(got["비교"]["우리"] - 1415169, 1)
assert master_hash() == before
def test_자재_같은_로직_감쌈_다시씀_새로(client):
assert _post(client).status_code == 200
_remember(
client,
**{
DIG: {"로직": "GF000101", "환산": 1},
SOIL: {"로직": "GF000101", "환산": 1},
STONE: {"자재": "MT004258", "환산": 1},
},
)
got = _build(client, 3)
mine = layers.personal_dir(7, 42)
doc = _doc(mine, got["참조"]["키"])
first, wrapped = doc["담은로직"]
assert first["로직"] == "GF000101" and first["별칭"] == "구조물터파기(인력100%)"
assert wrapped["별칭"] == "잔토처리(인력100%)" and wrapped["수량"] == {"손": 0.09}
inner = _doc(mine, wrapped["참조"]["키"])
assert inner["본뜬"]["키"] == "HP-000001 제3호표 4째 줄"
assert inner["담은로직"][0]["로직"] == "GF000101" and inner["담은로직"][0]["수량"] == {"손": 1}
assert [(m["코드"], m["수량"], m["별도계상"]) for m in doc["자재"]] == [
("MT004258", {"손": 1}, "직접계산")
]
files = sorted(p.name for p in (mine / "precombo").glob("*.json"))
again = _build(client, 3)
assert again["다시씀"] is True and again["참조"] == got["참조"]
assert sorted(p.name for p in (mine / "precombo").glob("*.json")) == files
_remember(client, **{SOIL: {"빼기": True}})
fresh = _build(client, 3, 새로=True)
assert fresh["참조"] == got["참조"] and fresh["다시씀"] is True
assert len(_doc(mine, got["참조"]["키"])["담은로직"]) == 1 # 덮음
assert sorted(p.name for p in (mine / "precombo").glob("*.json")) == files
def test_일위N_참조_먼저_만듦(client):
assert _post(client).status_code == 200
got = _build(client, 45)
mine = layers.personal_dir(7, 42)
lines = _doc(mine, got["참조"]["키"])["담은로직"]
ref = lines[0]
assert ref["별칭"] == "고화매트재료비" and ref["수량"] == {"손": 2.3}
child = _doc(mine, ref["참조"]["키"])
assert child["이름"] == "고화매트재료비" and child["본뜬"]["키"] == "HP-000001 제46호표"
assert any(x["까닭"].startswith("% 줄") for x in _build(client, 46)["빠진줄"])
def test_순환은_빠진_줄(client):
folder = layers.personal_dir(7, 42)
_post(client) # 층 뼈대
line = {"차례": 0, "이름": "남", "규격": "", "수량": 1, "단위": "식", "갈래": "호표"}
money = {"계": 1, "노무비": 1, "재료비": None, "경비": None}
hopyos = [
{"번": 1, "이름": "가", "규격": "", "단위": "식", "금액": money, "줄": [
{**line, "참조": {"종류": "일위", "번": 2}, "금액": money}]},
{"번": 2, "이름": "나", "규격": "", "단위": "식", "금액": money, "줄": [
{**line, "참조": {"종류": "일위", "번": 1}, "금액": money}]},
] # fmt: skip
key = hs.save(folder, "loop.xls", {"시트": "일위대가", "호표": hopyos, "검산": {}}, None)["키"]
got = _build(client, 1, 키=key)
assert got["빠진줄"] == []
inner = _doc(folder, _doc(folder, got["참조"]["키"])["담은로직"][0]["참조"]["키"])
assert inner["담은로직"] == [] # 제2호표 안 제1호표 = 순환 → 빠짐
def test_길_권한과_위_층_파일(client):
client.who.update(role="SYSTEM_ADMIN")
assert _post(client, "system").status_code == 200
client.who.update(role="USER")
_build(client, 3, layer="system", 층="system", want=403) # 마스터 쓰기 = 시스템 관리자
got = _build(client, 3, 층="system") # 마스터 파일 → 내 층 사전조합
assert got["참조"]["층"] == "personal" and got["참조"]["주인"] == "7/42"
assert _build(client, 99, 층="system", want=404)
assert client.get(URL.format("personal")).json()["줄"] == [] # 파일은 마스터에만
+209
View File
@@ -0,0 +1,209 @@
"""일위대가 실무자료 읽기 · 저장 · 올리기 길(PLAN 324-1 · `M02_Hopyo_Excel` · `M02_Hopyo_Store`).
견본 = `resources/knowledge/original/실무문서/2026_실무일위대가 모음/`(북부청 임도 · 사방) ·
검산 어긋남 0 · 호표 값 · 시트 없음 400 · xlsx 길 · 역할 표 · 엉터리 · 20 MB · 지우기 ·
양식 목록에 안 섞임 ·
master_data 원본 그대로(sha256). 층은 임시 폴더.
"""
import hashlib
import io
from pathlib import Path
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from common_util.common_util_auth import verify_session
from config import config_system
from M02_MasterTemplete import M02_Hopyo_Excel as hx
from M02_MasterTemplete import M02_Hopyo_Store as hs
from M02_MasterTemplete import M02_Labor_Router as labor_router
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from M02_MasterTemplete import M02_Template_Layers as layers
from resources.tester.test_m02_structure import write_sample_table
ROOT = Path(__file__).resolve().parents[2]
SAMPLES = ROOT / "resources" / "knowledge" / "original" / "실무문서" / "2026_실무일위대가 모음"
NORTH = SAMPLES / "2026년_북부지방산림청_임도사업_설계내역서(공통단가).xls"
SABANG = SAMPLES / "2026년_북부지방산림청_사방사업(계류+댐)_설계내역서.xls"
NO_SHEET = SAMPLES / "2016_상반_산림청_사방사업표준품셈(안).xls"
URL = "/api/m02/layers/{}/hopyo"
USER = {"user_id": 42, "company_id": 7, "role": "USER", "is_master": False, "name": "갑"}
def master_hash() -> dict[str, str]:
"""master_data 원본(*.json · 일위대가조합.json) sha256 — 올리기 · 맞춤 · 만들기 뒤 그대로."""
folder = ROOT / "resources" / "master_data"
return {p.name: hashlib.sha256(p.read_bytes()).hexdigest() for p in folder.glob("*.json")}
@pytest.fixture(scope="module")
def north() -> dict:
return hx.read_book(NORTH.name, NORTH.read_bytes())
def _one(book: dict, no: int) -> dict:
return next(h for h in book["호표"] if h["번"] == no)
# ── 읽기 ──────────────────────────────────────────────────────────────
def test_북부청_68_호표_검산_어긋남_0(north):
assert [h["번"] for h in north["호표"]] == list(range(1, 69))
assert north["검산"] == {"어긋남": [], "총괄표": 67} # 제61호표 = 값 없음
lines = [line for h in north["호표"] for line in h["줄"]]
assert len(lines) == 415
kinds = {k: sum(1 for x in lines if x["갈래"] == k) for k in ("단가산출", "%", "호표", "기계")}
assert kinds == {"단가산출": 268, "%": 1, "호표": 1, "기계": 3}
def test_사방_27_호표_검산_어긋남_0():
got = hx.read_book(SABANG.name, SABANG.read_bytes())
assert len(got["호표"]) == 27 and got["검산"]["어긋남"] == []
def test_호표_값과_줄(north):
one = _one(north, 1)
assert (one["이름"], one["규격"], one["단위"]) == (
"배수관날개벽(유입구)",
"D=1,000mm,일반형",
"개소",
)
assert one["금액"] == {"계": 1040052, "노무비": 777224, "재료비": 239284, "경비": 23544}
assert one["줄"][0]["갈래"] == "별도계상" and one["줄"][0]["규격"] == "별도계상"
three = _one(north, 3)
assert [(x["이름"], x["수량"], x["단위"], x["갈래"]) for x in three["줄"]] == [
("임도표지석(받침,글새김포함)", 1, "㎥", "자재"),
("구조물터파기(인력100%)", 0.14, "㎥", "단가산출"),
("구조물되메우기(인력100%)", 0.05, "㎥", "단가산출"),
("잔토처리(인력100%)", 0.09, "㎥", "단가산출"),
]
assert three["줄"][1]["참조"] == {"종류": "단산", "번": 93}
assert three["금액"]["계"] == 1415169 and three["금액"]["경비"] is None
assert _one(north, 45)["줄"][0]["참조"] == {"종류": "일위", "번": 46}
pct = [x for x in _one(north, 46)["줄"] if x["갈래"] == "%"]
assert [(x["규격"], x["수량"], x["단가"]["계"]) for x in pct] == [("재료비의", 3, 42390)]
empty = _one(north, 61)
assert empty["줄"] == [] and empty["금액"]["계"] is None
def test_일위대가_시트_없음():
with pytest.raises(hx.ExcelError, match="일위대가 시트 없음"):
hx.read_book(NO_SHEET.name, NO_SHEET.read_bytes())
with pytest.raises(hx.ExcelError, match="읽지 못함"):
hx.read_book("x.xls", b"not an excel file")
def _xlsx() -> bytes:
import openpyxl
book = openpyxl.Workbook()
ws = book.active
ws.title = "일위대가"
ws.append([None, "일위대가"])
ws.append([])
ws.append([None, "호 표", "품 명", "규 격", "수 량", "단위", "합 계", None, "노무비", None])
ws.append([None, None, None, None, None, None, "단 가", "금 액", "단 가", "금 액"])
ws.append([None, "제 1 호표", "표지석 ", "A×B", None, "개소", None, 1000, None, 1000])
ws.append([None, None, "터파기", "토사", 0.5, "㎥", 2000, 1000, 2000, 1000, "비고"])
out = io.BytesIO()
book.save(out)
return out.getvalue()
def test_xlsx_길():
got = hx.read_book("a.xlsx", _xlsx())
one = got["호표"][0]
assert (one["번"], one["이름"], one["금액"]["계"]) == (1, "표지석", 1000)
assert one["줄"][0]["수량"] == 0.5 and got["검산"]["어긋남"] == []
# ── 저장 · 길 ──────────────────────────────────────────────────────────
@pytest.fixture()
def client(tmp_path, monkeypatch):
system = tmp_path / "system"
system.mkdir()
write_sample_table(system)
monkeypatch.setattr(store, "FOLDER", system)
monkeypatch.setattr(layers, "SYSTEM_ROOT", system)
(tmp_path / "storage").mkdir()
monkeypatch.setattr(config_system, "STORAGE_BASE_DIR", str(tmp_path / "storage"))
app = FastAPI()
app.include_router(labor_router.router)
who = dict(USER)
app.dependency_overrides[verify_session] = lambda: who
got = TestClient(app)
got.who, got.system = who, system
return got
def _post(client, layer="personal", data=None, name=NORTH.name, **params):
files = {"file": (name, data if data is not None else NORTH.read_bytes())}
return client.post(URL.format(layer), files=files, params=params)
def test_올리기_목록_읽기_지우기(client):
before = master_hash()
res = _post(client)
assert res.status_code == 200, res.text
row = res.json()
assert (row["키"], row["파일"], row["호표수"], row["어긋남"]) == (
"HP-000001",
NORTH.name,
68,
0,
)
assert row["올린이"] == {"id": 42, "이름": "갑"}
assert client.get(URL.format("personal")).json()["줄"] == [row]
doc = client.get(URL.format("personal") + "/HP-000001").json()
assert len(doc["호표"]) == 68 and doc["시트"] == "일위대가"
folder = layers.personal_dir(7, 42)
assert (folder / hs.DIRNAME / "HP-000001.json").is_file()
assert all(r["종류"] in layers.KINDS for r in layers.list_templates(folder)) # 양식에 안 섞임
assert not any("실무자료" in str(p) for _, p in layers.layer_files("precombo"))
assert _post(client).json()["키"] == "HP-000002"
assert client.delete(URL.format("personal") + "/HP-000001").status_code == 200
assert [r["키"] for r in client.get(URL.format("personal")).json()["줄"]] == ["HP-000002"]
assert client.get(URL.format("personal") + "/HP-000001").status_code == 404
assert client.get(URL.format("personal") + "/../x").status_code == 404
assert master_hash() == before
def test_엉터리_시트없음_큰_파일_400(client, monkeypatch):
bad = _post(client, data=b"garbage", name="a.xls")
assert bad.status_code == 400 and "읽지 못함" in bad.json()["detail"]
none = _post(client, data=NO_SHEET.read_bytes(), name=NO_SHEET.name)
assert none.status_code == 400 and none.json()["detail"] == "일위대가 시트 없음"
monkeypatch.setattr(labor_router, "HOPYO_MAX_BYTES", 1000)
big = _post(client)
assert big.status_code == 400 and "20 MB" in big.json()["detail"]
assert client.get(URL.format("personal")).json()["줄"] == []
@pytest.mark.parametrize(
("role", "layer", "owner", "want"),
[
({"role": "SYSTEM_ADMIN"}, "system", None, 200),
({"role": "USER"}, "system", None, 403),
({"role": "ADMIN"}, "company", None, 200),
({"role": "USER"}, "company", None, 403),
({"role": "USER"}, "personal", None, 200),
({"role": "USER"}, "personal", "7/43", 403),
({"role": "USER"}, "personal", "8/50", 403),
],
)
def test_역할_표(client, role, layer, owner, want):
client.who.update(role)
params = {"owner": owner} if owner else {}
assert _post(client, layer, **params).status_code == want
if want == 200:
assert client.get(URL.format(layer)).json()["줄"][0]["호표수"] == 68
def test_마스터_층은_마스터_폴더(client):
client.who.update(role="SYSTEM_ADMIN")
assert _post(client, "system").status_code == 200
assert (client.system / hs.DIRNAME / "HP-000001.json").is_file()
client.who.update(role="USER")
assert client.get(URL.format("system")).json()["줄"][0]["키"] == "HP-000001" # 읽기 = 누구나
assert client.delete(URL.format("system") + "/HP-000001").status_code == 403
+202
View File
@@ -0,0 +1,202 @@
"""일위대가 실무자료 맞춤(PLAN 324-2 · `M02_Hopyo_Match`) — 규격 → 입력값 · 단위 · 환산 ·
맞춤 차례 · 기억 우선 · 길(보기 · 미리 채움 · 확정) · master_data 원본 그대로. 층은 임시 폴더.
"""
import pytest
from M02_MasterTemplete import M02_Hopyo_Match as hm
from M02_MasterTemplete import M02_Template_Layers as layers
# 올리기 길 시험의 `client` 마당을 같이 씀
# ruff: noqa: F811
from resources.tester.test_m02_hopyo_excel import URL, _post, client, master_hash # noqa: F401
mm = hm.mm
# ── 규격 · 단위 · 환산 ─────────────────────────────────────────────────
def test_규격_이름_붙은_수():
assert hm.numbers("D=1,000mm,일반형") == {"D": (1000, "mm")}
assert hm.numbers("H:1.2m") == {"H": (hm.Decimal("1.2"), "m")}
assert {k: float(v) for k, (v, _) in hm.numbers("B=2.3,H=0.4,야면석").items()} == {
"B": 2.3,
"H": 0.4,
}
assert hm.numbers("L3=35cm적용,장비사용") == {"L3": (35, "cm")}
assert hm.numbers("Φ40×200") == {} and mm.plain_spec("Φ40×200") == "ø40x200"
def test_단위_정규화():
got = [mm.plain_unit(u) for u in ("㎥", "M", "Ton", "TON", "EA", "㎡")]
assert got == ["m3", "m", "톤", "톤", "개", "m2"]
@pytest.mark.parametrize(
("have", "want", "spec", "times", "kind"),
[
("㎥", "㎥", "", 1, "같음"),
("M", "m", "", 1, "같음"),
("kg", "ton", "", 0.001, "단위표"),
("㎡", "㎥", "T=150mm", 0.15, "추정"),
("M", "㎡", "B=2.3,H=0.4", 2.3, "추정"),
("M", "㎥", "B=2.3,T=0.2", 0.46, "추정"),
("개소", "㎥", "", None, "입력"),
("㎥", "㎡ · ㎥", "", 1, "두 단위"),
("M", "㎡ · ㎥", "", None, "입력"),
],
)
def test_환산(have, want, spec, times, kind):
assert hm.factor(have, want, spec) == {"배": times, "갈래": kind}
SPECS = [
{"이름": "뒷길이", "단위": "㎝", "고르기": [25, 35, 45], "자동값": None},
{"이름": "관경", "단위": "mm", "자동값": None},
{"이름": "설치높이", "단위": "m", "자동값": None},
{
"이름": "기계",
"고르기": ["0201-0020", "0201-0060"],
"고르기이름": {
"0201-0020": "굴착기(무한궤도) 0.2㎥",
"0201-0060": "굴착기(무한궤도) 0.6㎥",
},
"자동값": None,
},
{"이름": "횟수", "고르기": ["3회사용시", "4회사용시"], "자동값": None},
{"이름": "단위당", "고르기": ["㎡당", "㎥당"], "자동값": None},
{"이름": "지역", "고르기": ["전국평균", "서울"], "자동값": "전국평균"},
{"이름": "보정작업", "고르기": ["해당 없음", "전석섞인토사"], "자동값": None},
]
def test_규격에서_입력값():
line = {"이름": "찰붙임(4회)", "규격": "L3=35cm적용,굴삭기0.6㎥,D=1,000mm,H:1.2m", "단위": "㎥"}
values, unread = hm.spec_inputs(line, SPECS)
assert values == {
"뒷길이": 35,
"관경": 1000,
"설치높이": 1.2,
"기계": "0201-0060",
"횟수": "4회사용시",
"단위당": "㎥당",
}
assert unread == ["보정작업"] # 지역 = 자동값 · 못 읽은 것만 표시
other = {"이름": "찰붙임", "규격": "L3=40cm", "단위": "㎡"}
values, unread = hm.spec_inputs(other, SPECS[:1])
assert values == {} and unread == ["뒷길이"] # 고르기 밖 수 = 빈 값 + 표시
# ── 맞춤 차례 ────────────────────────────────────────────────────────
def _hopyo(client, no, layer="personal"):
res = client.get(f"{URL.format(layer)}/HP-000001/{no}")
assert res.status_code == 200, res.text
return {one["이름"]: one["맞춤"] for one in res.json()["호표"]["줄"]}
def test_이름_같음_후보_호표_퍼센트(client):
before = master_hash()
assert _post(client).status_code == 200
first = _hopyo(client, 1)
plywood = first["합판거푸집"]
assert plywood["상태"] == "같음" and plywood["대상"]["로직"] == "GF000160"
assert plywood["입력값"] == {"횟수": "4회사용시"} and plywood["들어감"] is True
remicon = first["레미콘(25-21-120)"]
assert remicon["상태"] == "후보" and remicon["들어감"] is False
assert all(c["규격"] == "25-21-120" for c in remicon["후보"][:3]) # 규격 같은 자재 먼저
dig = _hopyo(client, 3)["구조물터파기(인력100%)"]
assert dig["상태"] == "후보" and len(dig["후보"]) == 5
assert dig["후보"][0]["이름"].startswith("구조물터파기")
ref = _hopyo(client, 45)["고화매트재료비"]
assert ref["상태"] == "호표" and ref["대상"] == {"호표": 46, "단위": "㎡"} and ref["들어감"]
pct = _hopyo(client, 46)["잡재료비"]
assert pct["상태"] == "%" and pct["들어감"] is False
whole = client.get(f"{URL.format('personal')}/HP-000001").json()
assert whole["맞춤"][1] == 415 and 0 < whole["맞춤"][0] < 415
assert next(h for h in whole["호표"] if h["번"] == 61)["맞춤"] == [0, 0]
assert master_hash() == before
def test_미리_채움_확정_기억_우선(client):
assert _post(client).status_code == 200
url = f"{URL.format('personal')}/HP-000001/3/guess"
got = client.post(url, json={"차례": 1, "대상": {"로직": "GF000101"}}).json()
assert got["환산"] == {"배": 1, "갈래": "같음"} and got["대상"]["단위"] == "㎥"
assert client.post(url, json={"차례": 1, "대상": {"로직": "XX"}}).status_code == 400
key = _hopyo(client, 3)["구조물터파기(인력100%)"]["열쇠"]
assert key == "구조물터파기(인력100%)|토사|m3"
body = {"줄": {key: {"로직": "GF000101", "입력값": {"지역": "서울"}, "환산": 1}}}
assert client.post("/api/m02/layers/personal/hopyo-memory", json=body).status_code == 200
dig = _hopyo(client, 3)["구조물터파기(인력100%)"]
assert (dig["상태"], dig["대상"]["로직"], dig["입력값"], dig["들어감"]) == (
"기억",
"GF000101",
{"지역": "서울"},
True,
)
# 이름 같음보다 기억이 앞섬 · 빼기
plywood = _hopyo(client, 1)["합판거푸집"]["열쇠"]
out = {"줄": {plywood: {"빼기": True}}}
assert client.post("/api/m02/layers/personal/hopyo-memory", json=out).status_code == 200
assert _hopyo(client, 1)["합판거푸집"]["상태"] == "빼기"
clear = {"줄": {plywood: None}}
assert client.post("/api/m02/layers/personal/hopyo-memory", json=clear).status_code == 200
assert _hopyo(client, 1)["합판거푸집"]["상태"] == "같음"
def test_확정_검사와_위_층_기억(client):
bad = [
{"로직": "GC000422", "환산": 1}, # 돈 아닌 로직
{"로직": "GF000101", "환산": 0},
{"로직": "GF000101", "자재": "MT004258", "환산": 1},
{"환산": 1},
]
for one in bad:
res = client.post("/api/m02/layers/personal/hopyo-memory", json={"줄": {"a|b|c": one}})
assert res.status_code == 400, one
assert client.post("/api/m02/layers/system/hopyo-memory", json={"줄": {}}).status_code == 403
client.who.update(role="SYSTEM_ADMIN")
key = "구조물터파기(인력100%)|토사|m3"
body = {"줄": {key: {"로직": "GF000103", "환산": 1}}}
assert client.post("/api/m02/layers/system/hopyo-memory", json=body).status_code == 200
client.who.update(role="USER")
assert _post(client).status_code == 200
assert _hopyo(client, 3)["구조물터파기(인력100%)"]["대상"]["로직"] == "GF000103" # 위 층
mine = {"줄": {key: {"로직": "GF000101", "환산": 1}}}
assert client.post("/api/m02/layers/personal/hopyo-memory", json=mine).status_code == 200
assert _hopyo(client, 3)["구조물터파기(인력100%)"]["대상"]["로직"] == "GF000101" # 같은 층 먼저
assert hm.chain(layers.personal_dir(7, 42))[1] == layers.company_dir(7)
def test_로직_추가_고르기_줄은_이_층과_위_층(client):
client.who.update(role="SYSTEM_ADMIN")
assert _post(client, "system").status_code == 200
client.who.update(role="USER")
assert _post(client).status_code == 200
rows = client.get("/api/m02/layers/personal/hopyo-picks").json()["줄"]
assert [r["층"] for r in rows].count("personal") == 68
assert [r["층"] for r in rows].count("system") == 68
three = next(r for r in rows if r["층"] == "system" and r["번"] == 3)
assert {k: three[k] for k in ("주인", "키", "이름", "단위")} == {
"주인": None,
"키": "HP-000001",
"이름": "임도표지석",
"단위": "개소",
}
assert three["맞춤"][1] == 4
system = client.get("/api/m02/layers/system/hopyo-picks").json()["줄"]
assert {r["층"] for r in system} == {"system"} # 마스터 = 마스터만
def test_뜻_낱말이_안_겹치면_후보_아님():
assert hm.words("면 목") == {"면목"} and hm.words("구조물되메우기(인력100%)") == {
"구조물되메우기"
}
assert hm.words("되메우기 및 다짐(소형장비)") == {"되메우기", "다짐"}
soil = {"이름": "잔토처리(인력100%)", "규격": "", "단위": "㎥", "갈래": "단가산출"}
assert hm.candidates(soil, "로직") == [] # 「처리」 두 글자만 겹침(소금처리 · 바탕처리)
stone = {"이름": "임도표지석(받침,글새김포함)", "규격": "", "단위": "㎥", "갈래": "자재"}
assert hm.candidates(stone, "자재") == [] # 속도표지판 · 도로표지용페인트
got = hm.match_line(soil, [], {})
assert got["상태"] == "없음" and got["후보"] == []
fill = {"이름": "구조물되메우기(인력100%)", "규격": "", "단위": "㎥", "갈래": "단가산출"}
assert hm.candidates(fill, "로직")[0]["이름"] == "되메우기" # 한쪽 낱말에 듦 = 겹침
+147
View File
@@ -0,0 +1,147 @@
"""일위대가 실무자료 화면(PLAN 324-4) — 좌측 판 · 본 화면 · [로직 추가] 「실무 호표」 줄.
- 고르기 창 덧줄(Node · fetch 대역): 이 층 · 위 층 호표 줄(맞춤 n/m) · 종류 고르기 ·
고르면 문서 층에 만들기 길 → `lib.pick` 참조 줄(별칭 = 호표 품명) · 프로젝트 층엔 없음 ·
본 화면 글 도우미
- 소스: 좌측 판 자리(사전조합 아래 · 구조물 조합 층 · 쓰기 권한 없으면 올리기 꺼짐 ·
숨은 file 입력) · Labor_Detail 줄 수 그대로 · 새 파일 이름 · 700 줄 · 색 = 공용 토큰
"""
import json
import os
import re
import subprocess
from pathlib import Path
HERE = os.path.dirname(os.path.abspath(__file__))
M02 = Path(HERE).parents[1] / "M02_MasterTemplete"
NEW = ("Hopyo_Api", "Hopyo_Pick", "Hopyo_Side", "Hopyo_View")
def _text(name: str) -> str:
return (M02 / name).read_text(encoding="utf-8")
def _run() -> dict:
row = {
"층": "system",
"주인": None,
"키": "HP-000001",
"파일": "북부청.xls",
"번": 3,
"이름": "임도표지석",
"규격": "910×600×150",
"단위": "개소",
"맞춤": [2, 4],
}
built = {
"참조": {"층": "company", "주인": "7", "키": "SC-000009"},
"이름": "임도표지석",
"다시씀": False,
"빠진줄": [{"차례": 0}],
"비교": {},
}
proc = subprocess.run(
["node", os.path.join(HERE, "helper_m02_hopyo_pick.cjs")],
input=json.dumps({"rows": [row], "built": built}, ensure_ascii=False),
capture_output=True,
text=True,
encoding="utf-8",
check=True,
)
return json.loads(proc.stdout)
def test_고르기_창_실무_호표_줄과_고름():
got = _run()
assert got["calls"][0] == {
"url": "/api/m02/layers/company/hopyo-picks?owner=7",
"method": "GET",
"body": None,
}
assert got["kinds"] == ["logic", "precombo", "hopyo"]
assert got["rows"] == [
{
"kind": "hopyo",
"key": "system||HP-000001|3",
"tag": "실무 호표",
"name": "임도표지석 910×600×150",
"sub": "북부청.xls · 제3호표 · 마스터",
"unit": "개소 · 맞춤 2/4",
}
]
build = got["calls"][1]
assert build["url"] == "/api/m02/layers/company/hopyo-build?owner=7" # 만드는 층 = 문서 층
assert build["body"] == {
"층": "system",
"주인": None,
"키": "HP-000001",
"번": 3,
"새로": False,
}
assert got["picked"] == [
{
"ref": {"층": "company", "주인": "7", "키": "SC-000009"},
"name": "임도표지석",
"alias": "임도표지석",
}
]
assert got["project"] is True # 프로젝트 층 = 덧줄 없음
def test_본_화면_글_도우미():
view = _run()["view"]
assert view["target"] == ["구조물터파기 [㎥]", "레미콘 25-21-120 [㎥]", "제46호표 [㎡]"]
assert view["inputs"] == "횟수=4회사용시, 지역=서울 · ⚠ 못 읽음: 보정작업"
assert view["memory"] == [
{"로직": "GF000101", "입력값": {"지역": "서울"}, "환산": 1},
{"자재": "MT1", "입력값": {}, "환산": 0.5},
None, # 환산 0 = 확정 못 함
None, # 대상 없음
]
assert view["word"] == ["만듦", "다시 씀", "새로 만들어 덮음"] # 알림 · 판 글
def test_줄_표_줄바꿈_없음과_고르기_창_머리():
css = _text("M02_MasterTemplete_Hopyo.css")
rule = css.split(".m02-hopyo__table.m02-hopyo__lines\n :is(th, td):is(")[1].split("}")[0]
cols = [int(n) for n in re.findall(r":nth-child\((\d+)\)", rule)]
assert cols == [1, 4, 5, 6, 7, 9, 10, 11] and "white-space: nowrap;" in rule
heads = ["갈래", "품명", "규격", "수량", "단위", "엑셀 단가", "맞춤", "대상", "입력값", "환산"]
view = _text("M02_MasterTemplete_Hopyo_View.ts")
flat = re.sub(r"\s+", "", view)
assert re.sub(r"\s+", "", ",".join(f'"{h}"' for h in heads)) in flat # 열 차례 그대로
top = css.split(".m02-hopyo__choose .ui-modal__top > .ui-btn {")[1].split("}")[0]
assert "flex-shrink: 0;" in top and "white-space: nowrap;" in top
assert '"가까운 것 없음"' in view and "builtWord(got, fresh)" in view
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 ?")
assert "canWrite: canEdit(layer, opt.isAdmin)," in side # 구조물 조합 층 쓰기 권한
assert "hopyo.paint();" in side.split("function paint(): void {")[1]
names = set(re.findall(r"\b\w*[Pp]ractice\w*\b", side))
assert names <= {"buildPracticeSection", "practice", "M02_MasterTemplete_Practice"} # 옛 이름만
assert "LaborSection" not in side and "labor?." not in side and "picked" not in side
hopyo_side = _text("M02_MasterTemplete_Hopyo_Side.ts")
assert 'accept: ".xls,.xlsx"' in hopyo_side and "input.hidden = true;" in hopyo_side
assert "disabled: !opt.canWrite," in hopyo_side and "pageScope()" in hopyo_side
def test_로직_추가_한_곳과_파일_규칙():
detail = _text("M02_MasterTemplete_Labor_Detail.ts")
assert len(detail.splitlines()) == 693 # 줄 수 그대로(제자리 고침)
assert "withHopyo(library)," in detail
assert 'cur.담은로직.push({ 로직: "", 참조: ref, 별칭, 메모: null' in detail
pick = _text("M02_MasterTemplete_Labor_Pick.ts")
assert "more?: () => Promise<PickExtra[]>;" in pick and "...later" in pick
for name in NEW:
text = _text(f"M02_MasterTemplete_{name}.ts")
assert len(text.splitlines()) <= 700, name
css = _text("M02_MasterTemplete_Hopyo.css")
assert "var(--color-" in css and not re.search(r"#[0-9a-fA-F]{3,6}\b", css)
assert '"./M02_MasterTemplete_Hopyo.css"' in _text("M02_MasterTemplete_Hopyo_View.ts")