Merge remote-tracking branches 'origin/main_laptop_1' and 'origin/sub_laptop_8' into sub_laptop_5

This commit is contained in:
2026-10-31 18:11:07 +09:00
25 changed files with 1314 additions and 64 deletions
+31 -12
View File
@@ -340,24 +340,43 @@ def copies(project_root: str | Path) -> list[tuple[Path, dict[str, Any]]]:
return out
def used_folders(user: dict[str, Any]) -> set[str]:
def used_folders(user: dict[str, Any], project_root: str | Path | None = None) -> set[str]:
"""설계내역서 덮개 줄 · 거리이정표 운반 연결(251-5)이 잇는 서고 사본 폴더 +
일위대가 복사본(703-2 `@copy`)의 원본 사본."""
copies = [
str(lines["@copy"].get("from") or "")
for lines in (user.get("daega_cover") or {}).values()
일위대가 복사본(703-2 `@copy`)의 원본 사본 — 복사본의 복사본(`K:`)은 맨 처음 원본까지 ·
사전조합 원본(`C:참조`) = 그 참조를 담은 사본 폴더(`project_root` 줄 때)."""
covers = user.get("daega_cover") or {}
def origin(src: str) -> str:
seen: set[str] = set()
while src.startswith("K:") and src not in seen:
seen.add(src)
src = str(((covers.get(src) or {}).get("@copy") or {}).get("from") or "")
return src
srcs = {
origin(str(lines["@copy"].get("from") or ""))
for lines in covers.values()
if isinstance(lines, dict) and isinstance(lines.get("@copy"), dict)
]
return {
str(ref["lib"])
for row in ref_rows(user)
if isinstance(ref := row.get("ref"), dict) and ref.get("tab") == "dg" and ref.get("lib")
} | {src[2:] for src in copies if src.startswith("L:")}
}
pre = {src[2:] for src in srcs if src.startswith("C:")}
return (
{
str(ref["lib"])
for row in ref_rows(user)
if isinstance(ref := row.get("ref"), dict) and ref.get("tab") == "dg" and ref.get("lib")
}
| {src[2:] for src in srcs if src.startswith("L:")}
| {
folder.name
for folder, manifest in (copies(project_root) if pre and project_root else [])
if refs.ref_id((manifest.get("원본") or {}).get("참조") or {}) in pre
}
)
def prune(project_root: str | Path, user: dict[str, Any]) -> list[str]:
"""[저장] 뒤 — 줄이 안 잇는 사본 폴더를 지움(다시 고르면 새로 들여옴) · 지운 이름."""
keep = used_folders(user)
keep = used_folders(user, project_root)
base = library_dir(project_root)
gone = []
for child in sorted(base.iterdir()) if base.is_dir() else []:
+94 -12
View File
@@ -15,10 +15,15 @@
안 줄이 그 아래(저절로 묶음 · 금액 = 안 줄 합) / price = 안 줄 금액 합 ÷ 개수(기본 = B08 수량)
= 1 단위 고정 단가 · 안 줄 지움
(B08 수량 × 단가 · 끝수 비목마다 1 원 안).
이은 자동 줄 `was` = 되돌릴 것 {rows: [불러온 줄 · price 안 줄], old: 자동 줄 덮개,
prev · next: 그때 같은 부모 앞 · 뒤 줄 키} · 수량 갈래 = 줄마다(`modes`) · 없으면 `mode`
풀기(`unlink`) — `was` 대로 불러온 줄 · 자동 줄 제자리(맞춤 기억은 그대로 · 잊기 = 부르는 쪽).
판(`board`) — 남은 불러온 줄 · 빈 자리(이을 수 있는 자동 줄) · 이은 줄.
"""
from __future__ import annotations
import copy
import re
from typing import Any
@@ -28,6 +33,7 @@ from M02_MasterTemplete.M02_Hopyo_Match import line_key
MEMORY = "내역맞춤"
TABLE, RECALL = "짝 표", "지난번 이음"
FORGOT = "잊음" # 기억 칸 {잊음: True} = 이 짝 잊기 표시 — 느슨한 기억에서도 안 불러옴 · 다시 이으면 덮임
HEADS = ("earth", "st", "haul")
_TAIL = re.compile(r"^(.+?)\((.*)\)$")
ROCK = ("절토(연암)", "암발파")
@@ -92,12 +98,13 @@ def _loose(key: str) -> str:
def recall(memory: dict[str, dict[str, Any]], row: dict[str, Any]) -> dict[str, Any] | None:
"""기억 한 칸 — 같은 열쇠 · 없으면 품명 · 단위 같고 짝이 하나인 것."""
"""기억 한 칸 — 같은 열쇠 · 없으면 품명 · 단위 같고 짝이 하나인 것 ·
잊은 칸은 없음 취급 + 느슨한 기억에서는 다른 짝으로 셈(잊은 짝 곁에서 하나로 좁히지 않음)."""
key = key_of(row)
if isinstance(memory.get(key), dict):
return memory[key]
return None if memory[key].get(FORGOT) else memory[key]
loose = [v for k, v in memory.items() if isinstance(v, dict) and _loose(k) == _loose(key)]
return loose[0] if len({v.get("확정") for v in loose}) == 1 else None
return loose[0] if len({v.get("확정") for v in loose}) == 1 and loose[0].get("확정") else None
def _targets(rows: list[dict[str, Any]]) -> dict[str, dict[str, Any]]:
@@ -198,11 +205,14 @@ def link(
pairs: list[tuple[str, str]],
mode: str = "take",
counts: dict[str, float] | None = None,
modes: dict[str, str] | None = None,
) -> tuple[dict[str, Any], dict[str, Any]]:
"""(새 사용자 값, 알림 {이음, 묶음, 지움, 못함 [{key, 까닭}], 기억 {열쇠: 확정}}) —
`rows` = 지금 셈 줄 ·
`counts` = 묶음 개수 {불러온 묶음 키: 수}(price 갈래 · 없으면 B08 수량)."""
if mode not in MODES:
`counts` = 묶음 개수 {불러온 묶음 키: 수}(price 갈래 · 없으면 B08 수량) ·
`modes` = 줄마다 수량 갈래 {불러온 줄 키: take | price}."""
modes = modes or {}
if any(m not in MODES for m in (mode, *modes.values())):
raise ValueError(f"수량 갈래는 {', '.join(MODES)} 중 하나")
counts = counts or {}
by_key = {r["key"]: r for r in rows}
@@ -211,6 +221,7 @@ def link(
bill = freeze(user, rows)
report: dict[str, Any] = {"이음": 0, "묶음": 0, "지움": 0, "못함": [], "기억": {}}
for line, target in pairs:
mode_ = modes.get(line, mode)
entries = bill["rows"]
at = next((n for n, e in enumerate(entries) if e["key"] == line), -1)
row, goal = by_key.get(line), targets.get(target)
@@ -221,32 +232,37 @@ def link(
else "묶음 ↔ 구조물 줄만" if (row["kind"] != "item") != target.startswith("st:")
else ""
) # fmt: skip
if not why and row["kind"] != "item" and mode == "price":
if not why and row["kind"] != "item" and mode_ == "price":
count = counts.get(line, goal.get("auto_qty", goal.get("qty")))
why = "" if count else "개수 없음(B08 수량 없음) — 넘겨받기로"
if why:
report["못함"].append({"key": line, "까닭": why})
continue
old = next((e for e in entries if e["key"] == target), None)
if old is not None:
entries.remove(old)
old_at = next((n for n, e in enumerate(entries) if e["key"] == target), -1)
old = entries.pop(old_at) if old_at >= 0 else None
sibs = [e["key"] for e in entries if old and e.get("parent") == old.get("parent")]
cut = sum(1 for e in entries[:old_at] if old and e.get("parent") == old.get("parent"))
was = {"old": copy.deepcopy(old), "prev": sibs[:cut], "next": sibs[cut:]}
at = next(n for n, e in enumerate(entries) if e["key"] == line)
hand = entries.pop(at)
auto = {k: v for k, v in (old or {"key": target}).items() if k not in ("price", "src_qty")}
was["rows"] = [copy.deepcopy(hand)]
skip = ("price", "src_qty", "was")
auto = {k: v for k, v in (old or {"key": target}).items() if k not in skip}
if row["kind"] == "item":
auto.update({"ref": hand.get("ref"), "src_qty": hand.get("src_qty", hand.get("qty"))})
if hand.get("price"):
auto["price"] = hand["price"]
if mode == "take" or goal.get("auto_qty", goal.get("qty")) is None:
if mode_ == "take" or goal.get("auto_qty", goal.get("qty")) is None:
auto["qty"] = hand.get("qty")
report["이음"] += 1
elif mode == "take":
elif mode_ == "take":
for e in entries: # 안 줄 = 구조물 줄 아래 · 금액 = 안 줄 합
if e.get("parent") == line:
e["parent"] = target
report["묶음"] += 1
else:
inner = _descendants(entries, line)
was["rows"] += [copy.deepcopy(e) for e in entries if e["key"] in inner]
items = [by_key[k] for k in inner if by_key.get(k, {}).get("kind") == "item"]
auto.update(
{
@@ -258,7 +274,73 @@ def link(
bill["rows"] = entries = [e for e in entries if e["key"] not in inner]
at = min(at, len(entries))
report.update(묶음=report["묶음"] + 1, 지움=report["지움"] + len(inner))
auto["was"] = was
_place(bill, at, auto, hand.get("parent"))
used.add(target)
report["기억"][key_of(row)] = {"확정": target, "이름": goal["name"]}
return {**user, "bill": bill}, report
def unlink(
user: dict[str, Any], rows: list[dict[str, Any]], target: str
) -> tuple[dict[str, Any], str]:
"""(새 사용자 값, 못한 까닭 · 됨 = "") — 이은 자동 줄 `was` 대로 불러온 줄 · 자동 줄 되돌림."""
bill = freeze(user, rows)
entries = bill["rows"]
at = next((n for n, e in enumerate(entries) if e["key"] == target), -1)
was = entries[at].get("was") if at >= 0 else None
back = was.get("rows") if isinstance(was, dict) else None
if not back:
return user, "이은 줄이 아님"
entries.pop(at)
head = back[0]["key"]
for e in entries: # take 묶음 안 줄 = 불러온 묶음 아래로
if e.get("parent") == target:
e["parent"] = head
entries[at:at] = copy.deepcopy(back)
old = was.get("old")
if old: # 자동 줄 = 그때 뒤 줄 앞 · 없으면 앞 줄 뒤 · 둘 다 없음 = 셈이 기본 부모 끝
where = {e["key"]: n for n, e in enumerate(entries)}
after = [where[k] + 1 for k in was.get("prev") or [] if k in where]
spot = next((where[k] for k in was.get("next") or [] if k in where), None)
spot = spot if spot is not None else (after[-1] if after else None)
if spot is not None:
entries.insert(spot, old)
return {**user, "bill": bill}, ""
def _under(rows: list[dict[str, Any]], key: str) -> bool:
"""불러온 줄이 이은 자동 줄(구조물 take 묶음) 아래인지."""
by_key = {r["key"]: r for r in rows}
up = by_key.get(key, {}).get("parent")
while up:
if not str(up).startswith("hand:"):
return up in by_key and str(up).split(":")[0] in HEADS
up = by_key.get(up, {}).get("parent")
return False
def board(user: dict[str, Any], rows: list[dict[str, Any]]) -> dict[str, Any]:
"""잇기 창 판 — 남은 불러온 줄(구조물 묶음에 딸린 줄 뺌) · 빈 자리 · 이은 줄."""
def pick(r: dict[str, Any]) -> dict[str, Any]:
return {f: r.get(f) for f in ("key", "kind", "name", "spec", "unit")}
used = _linked(rows)
entries = {e["key"]: e for e in ((user.get("bill") or {}).get("rows") or [])}
by_key = {r["key"]: r for r in rows}
linked = []
for key, e in entries.items():
was = e.get("was") if isinstance(e.get("was"), dict) else None
if was and was.get("rows") and key in by_key:
hand = was["rows"][0]
linked.append({**pick(by_key[key]), "from": pick(hand), "memo": key_of(hand)})
return {
"남은": [
pick(r) for r in rows if r["key"].startswith("hand:x") and not _under(rows, r["key"])
],
"자리": [
{**pick(r), "head": k.split(":")[0]} for k, r in _targets(rows).items() if k not in used
],
"이은": linked,
}
+3 -1
View File
@@ -88,6 +88,7 @@ from B09_Estimation.B09_Estimation_Labor_Source import daega_inputs
from B09_Estimation.B09_Estimation_Linked import linked_resource_keys
from B09_Estimation.B09_Estimation_Rates import rate_candidates, rate_table
from B09_Estimation.B09_Estimation_Router_Import import router as import_router
from B09_Estimation.B09_Estimation_Router_Link import router as link_router
from B09_Estimation.B09_Estimation_Router_Quotes import router as quotes_router
from B09_Estimation.B09_Estimation_Summary import compute_summary, hopyo_lines, used_materials
from common_util.common_util_auth import verify_session
@@ -209,7 +210,7 @@ async def _demand(root: Path, draft: dict[str, Any] | None = None) -> list[str]:
"""단가 수요 키 — 연결된 사본 + 설계내역서 손 줄 로직(166-2) +
거리이정표 운반 연결(251-5) · draft = 화면 캐시(296 · 서고 사본 = 그 줄이 잇는 것만)."""
user = await asyncio.to_thread(calc_user, root, draft) # 운반 값 사본 먼저(263-2)
keep = None if draft is None else library.used_folders(user)
keep = None if draft is None else library.used_folders(user, root)
keys = await asyncio.to_thread(linked_resource_keys, root, keep)
hand = await asyncio.to_thread(sangeun.hand_resource_keys, user)
hand = [*hand, *added_keys(user.get("daega_cover"))] # 705-2 붙인 줄 이음
@@ -589,6 +590,7 @@ async def past_import(
# 166-4 견적서 파일 길 · 707-1~3 경쟁사 결과물 불러오기 길 — 같은 머리 · 같은 권한
router.include_router(quotes_router)
router.include_router(import_router)
router.include_router(link_router) # 707-4 내역 줄 ↔ 우리 공종 잇기
# 302 초안 길(draft/*_Router.py 의 router · 폴더째 지우면 길도 없음) — 같은 머리 · 같은 권한
+38 -4
View File
@@ -18,6 +18,10 @@
{file_key, name, sheets, counts, linked, missing,
patch{labor_cover, machine_cover, materials}}
file_key = 세 길 같은 표지(`_keep` · `_saved`) — 같은 파일을 다시 올리지 않음 ·
.estx(EST 원본 · 707-5 `B09_Estimation_Estx`) · .STC(STmate 원본 · 707-6 `B09_Estimation_Stc`) =
이름 끝으로 갈림 — 내역 줄 · 단가표 같은 그릇 · 요율 = 400 「같은 공사 엑셀에서」 ·
미리 보기 `호표` = 대가 · 산근 목록 수 + 코드로 가리킨 줄(이음 후보만) ·
`알림` = 안 읽은 몫(다공구 · 변경 차수)
화면은 `patch` 를 캐시에 얹고 [저장] 때 서버가 정본으로 다시 셈.
`B09_Estimation_Router` 가 끼워 넣음(같은 머리 · 같은 권한).
"""
@@ -37,9 +41,11 @@ from pydantic import BaseModel, Field
from B08_Quantity.B08_Quantity_Router_Hydro import _project_root
from B09_Estimation import B09_Estimation_Bill_Import as importer
from B09_Estimation import B09_Estimation_Bill_Library as library
from B09_Estimation import B09_Estimation_Estx as estx
from B09_Estimation import B09_Estimation_Import_Prices as import_prices
from B09_Estimation import B09_Estimation_Import_Rates as import_rates
from B09_Estimation import B09_Estimation_Prices as prices
from B09_Estimation import B09_Estimation_Stc as stc
from B09_Estimation import B09_Estimation_Store as store
from B09_Estimation.B09_Estimation_Bill_Excel import BillExcelError, read_bill
from B09_Estimation.B09_Estimation_Calc_Base import compute_base
@@ -82,13 +88,32 @@ def _keep(root: Path, name: str, data: bytes) -> str:
return key
_RAW = {".estx": estx, ".stc": stc}
def _raw(name: str) -> Any:
"""원본 파일 읽기 모듈(.estx · .STC) — 엑셀이면 None."""
return _RAW.get(Path(name).suffix.lower())
def _parse(name: str, data: bytes) -> dict[str, Any]:
try:
return read_bill(name, data)
return (raw.read_bill if (raw := _raw(name)) else read_bill)(name, data)
except BillExcelError as error:
raise HTTPException(status_code=400, detail=str(error)) from error
def _hopyo(name: str, data: bytes, parsed: dict[str, Any]) -> dict[str, int] | None:
"""원본 호표 목록 수 · 내역 줄이 코드로 가리킨 수(이음 후보 — 구성 줄은 `UNKNOWN`)."""
if not (raw := _raw(name)):
return None
got = raw.read_hopyo(name, data)
codes = {(k, x["코드"]) for k, v in got.items() for x in v}
hits = [h for line in parsed["줄"] if (h := line.get("호표"))]
return {**{k: len(v) for k, v in got.items()},
"가리킴": sum((h["종류"], h["코드"]) in codes for h in hits)} # fmt: skip
def _draft(raw: Any) -> dict[str, Any] | None:
if raw in (None, ""):
return None
@@ -158,8 +183,10 @@ async def import_bill(
if not file_key:
raise HTTPException(status_code=400, detail="파일을 고름")
path = _saved(root, file_key)
data = path.read_bytes()
if file is None:
parsed = await asyncio.to_thread(_parse, path.name, path.read_bytes())
parsed = await asyncio.to_thread(_parse, path.name, data)
hopyo = await asyncio.to_thread(_hopyo, path.name, data, parsed)
rows = (await payload(project_id, root, _draft(user), True))["sheets"]["bill"]["rows"]
found = await asyncio.to_thread(_found, session, path.name, _numbers(parsed))
return {
@@ -167,6 +194,8 @@ async def import_bill(
"파일": path.name,
**importer.preview(parsed, rows),
"이음": len(found),
"호표": hopyo,
"알림": parsed.get("알림") or [],
}
@@ -234,6 +263,10 @@ async def import_rate_sheet(
root = await _project_root(project_id)
key, name, data = await _file(file, file_key, root)
draft = _draft(user)
if _raw(name):
raise HTTPException(
400, f"원가계산서 요율은 {Path(name).suffix} 에서 못 읽음 — 같은 공사 엑셀에서 불러옴"
)
mine = (draft if draft is not None else await asyncio.to_thread(store.read_user, root))[
"estimate"
]
@@ -262,8 +295,9 @@ async def import_price_sheets(
draft = _draft(user)
mine = draft if draft is not None else await asyncio.to_thread(store.read_user, root)
try:
read = await asyncio.to_thread(import_prices.read_prices, name, data)
except ExcelError as error:
reader = raw.read_prices if (raw := _raw(name)) else import_prices.read_prices
read = await asyncio.to_thread(reader, name, data)
except (ExcelError, BillExcelError) as error:
raise HTTPException(400, str(error)) from error
keys = await main._demand(root) # 화면 단가 목록(GET)과 같은 수요 — 사본 전부
b08 = await main._b08_for(project_id, [], True)
+64 -8
View File
@@ -1,14 +1,19 @@
"""B09 내역 줄 ↔ 우리 공종 잇기 길(PLAN 707-4) — 저장 안 함(화면 캐시로 → [저장] 때 서버 정본).
POST /api/projects/{id}/estimation/link/suggest {user?}
→ {줄: 제안(`B09_Estimation_Bill_Link.suggest`) · 저절로: 지난번 이음 · 하나 수}
→ {줄: 제안(`B09_Estimation_Bill_Link.suggest` · 구조물 묶음에 딸린 줄 뺌) ·
저절로: 지난번 이음 · 하나 수 · 남은 · 자리 · 이은(`board`)}
POST /api/projects/{id}/estimation/link/apply
{pairs [{line, target}], auto, mode, counts?, user?}
{pairs [{line, target, mode?}], auto, mode, counts?, user?} · 알림 남은 = 안 이은 항목 수
잇기(`link`) — `auto` = 지난번 이음 · 하나도 더함 →
{user(새 캐시), 알림, payload(그 캐시로 셈)} ·
이은 짝 = 맞춤 기억에 씀(회사 층 = 회사 관리자 · 아니면 개인 층)
POST …/link/undo {target, user?} → {user, 알림, payload} — 잇기 풀기(맞춤 기억 그대로)
POST …/link/forget {key, user?} → {잊음, 기억층, 남음} — 이 짝 잊기(불러온 줄 · 이은 줄 키 →
맞춤 열쇠 · 쓸 수 있는 층에 잊음 표시 `{잊음: True}` — 느슨한 기억도 안 불러옴 ·
개인 층 표시가 회사 기억을 덮음 · 남음 = 그래도 살아 있는 기억)
맞춤 기억 = 층 `실무자료/내역맞춤.json`(실무자료 맞춤 꼴) · 읽기 = 회사 + 개인(개인이 이김).
끼워 넣기 = `B09_Estimation_Router`(같은 머리 · 같은 권한) — 화면 고리 일감 몫.
끼워 넣기 = `B09_Estimation_Router`(같은 머리 · 같은 권한) · 화면 = `B09_Estimation_UI_Link`.
"""
from __future__ import annotations
@@ -73,6 +78,17 @@ class SuggestBody(BaseModel):
class Pair(BaseModel):
line: str = Field(min_length=1, max_length=80)
target: str = Field(min_length=1, max_length=200)
mode: str | None = None
class UndoBody(BaseModel):
target: str = Field(min_length=1, max_length=200)
user: dict[str, Any] | None = None
class ForgetBody(BaseModel):
key: str = Field(min_length=1, max_length=200)
user: dict[str, Any] | None = None
class ApplyBody(BaseModel):
@@ -96,8 +112,11 @@ async def suggest(
root = await _project_root(project_id)
draft = _draft(body.user) or await asyncio.to_thread(store.read_user, root)
rows = await _rows(project_id, root, draft)
lines = linker.suggest(rows, await asyncio.to_thread(recalled, session))
return {"줄": lines, "저절로": len(linker.auto_pairs(lines))}
board = linker.board(draft, rows)
left = {r["key"] for r in board["남은"]}
memory = await asyncio.to_thread(recalled, session)
lines = [s for s in linker.suggest(rows, memory) if s["key"] in left]
return {"줄": lines, "저절로": len(linker.auto_pairs(lines)), **board}
@router.post(PATH + "/apply")
@@ -106,7 +125,8 @@ async def apply(
) -> dict[str, Any]:
from B09_Estimation.B09_Estimation_Router import payload # 서로 부름 — 부를 때만
if body.mode not in linker.MODES:
modes = {p.line: p.mode for p in body.pairs if p.mode}
if any(m not in linker.MODES for m in (body.mode, *modes.values())):
raise HTTPException(status_code=400, detail="수량 갈래는 take · price 중 하나")
root = await _project_root(project_id)
draft = _draft(body.user) or await asyncio.to_thread(store.read_user, root)
@@ -115,11 +135,47 @@ async def apply(
if body.auto:
memory = await asyncio.to_thread(recalled, session)
pairs += [p for p in linker.auto_pairs(linker.suggest(rows, memory)) if p not in pairs]
made, report = linker.link(draft, rows, pairs, body.mode, body.counts)
made, report = linker.link(draft, rows, pairs, body.mode, body.counts, modes)
made = store.clean_user(made)
layer = await asyncio.to_thread(keep, session, report.pop("기억"))
out = await payload(project_id, root, made, True)
left = linker.board(made, out["sheets"]["bill"]["rows"])["남은"]
report["남은"] = sum(1 for r in left if r["kind"] == "item")
return {"user": made, "알림": {**report, "기억층": layer}, "payload": out}
@router.post(PATH + "/undo")
async def undo(
project_id: UUID, body: UndoBody, session: dict[str, Any] = Depends(verify_session)
) -> dict[str, Any]:
from B09_Estimation.B09_Estimation_Router import payload # 서로 부름 — 부를 때만
root = await _project_root(project_id)
draft = _draft(body.user) or await asyncio.to_thread(store.read_user, root)
made, why = linker.unlink(draft, await _rows(project_id, root, draft), body.target)
if why:
raise HTTPException(status_code=400, detail=why)
made = store.clean_user(made)
return {
"user": made,
"알림": {**report, "기억층": layer},
"알림": {"풀음": 1},
"payload": await payload(project_id, root, made, True),
}
@router.post(PATH + "/forget")
async def forget(
project_id: UUID, body: ForgetBody, session: dict[str, Any] = Depends(verify_session)
) -> dict[str, Any]:
root = await _project_root(project_id)
draft = _draft(body.user) or await asyncio.to_thread(store.read_user, root)
rows = await _rows(project_id, root, draft)
board = linker.board(draft, rows)
hit = next((r for r in board["남은"] if r["key"] == body.key), None)
memo = linker.key_of(hit) if hit else None
memo = memo or next((r["memo"] for r in board["이은"] if r["key"] == body.key), None)
if not memo:
raise HTTPException(status_code=400, detail="불러온 줄 · 이은 줄이 아님")
layer = await asyncio.to_thread(keep, session, {memo: {linker.FORGOT: True}})
left = bool((await asyncio.to_thread(recalled, session)).get(memo, {}).get("확정"))
return {"잊음": memo, "기억층": layer, "남음": left}
+274
View File
@@ -0,0 +1,274 @@
"""B09 STmate 원본(.STC) 바로 읽기(PLAN 707-6) — 메모리에서 풀어 평문 표만 · 저장 안 함.
.STC = 보통 ZIP · 표마다 dBase/FoxPro DBF(cp949 · 이름 대소문자 섞임)
(지식DB `original/원가계산/STmate/STC_컨테이너_구조.md` · `STC_필드사전_핵심.md`):
머리 4 레코드 수 · 8 머리 길이 · 10 레코드 길이 · 32 부터 필드 설명자 32 바이트씩(0x0D 끝) ·
필드 = 이름으로만 찾음(위치는 판마다 다름) · 레코드 첫 바이트 `*` = 지운 줄.
접미 1 = 당초 · 2~4 = 변경 차수 — 당초만 읽음.
돌려주는 꼴 = 엑셀 · .estx 읽기와 같은 dict:
`read_bill` = `B09_Estimation_Bill_Excel.read_bill`(707-1) — BOQ11
BDCODE1 T1~T5 = 묶음(클수록 바깥) · E1~E5 = 소계 · 합계(그 깊이 묶음 닫음 · 안 읽음) ·
C1~C5 = 제외금액 합계(닫는 묶음 = 제외 · 관급 등) · `:` = 페이지 넘김 · 이름 없음 = 빈 줄 ·
단위 끝 `%` = 율 줄(제외 안 「수수료」 = 조달수수료율 · 그 밖 = 1 식 · 단가 = 금액) ·
호표 = 코드 B 대가 · D 산근 · M 자재 · 번 = COSTN 같은 글자 코드 정렬 차례
(STmate 엑셀 비고 「대가 5호표」 · 「자재 28」 과 같음 — 봉화 · 기번3 실측) ·
`알림` = 다공구(BOQ12 ~) · 변경 차수 줄 수(안 읽음)
`read_prices` = `B09_Estimation_Import_Prices.read_prices`(707-3) — COSTN
L 노임 · M 자재(+ 출처 다섯 칸 AMT_11~51 · 쪽 PG_11~51 · 이름 = Start8x.Ini JMOK1~5) ·
X 기계(시간당 노 · 재 · 경 · 단위 「X」 → 시간)
`read_hopyo` = 호표 목록(COSTN B 대가 · D 산근 — 코드 · 번 · 이름 · 단가 셋)
안 읽음(같은 공사 STmate 엑셀 「일위대가표」 로 메움) = `UNKNOWN`.
"""
from __future__ import annotations
import io
import struct
import zipfile
from decimal import Decimal, InvalidOperation
from typing import Any
from B09_Estimation.B09_Estimation_Bill_Excel import BillExcelError, _plain, _unwrap
UNKNOWN = (
"일위대가 구성 수량(BDQTY — 난독)",
"단가산출 전개 글(COSTN.SMT — 메모 자리 규칙 미확정)",
"원가계산서 요율(PRJLST 는 금액만)",
)
#: 가격자료 다섯 칸 이름 — Start8x.Ini JMOK1~5 가 없을 때
SOURCES = ("조달가격", "물가자료", "물가정보", "거래가격", "견적단가")
HOPYO = {"B": "대가", "D": "산근", "M": "자재"}
PART_KEYS = ("labor", "material", "expense")
class StcError(BillExcelError):
"""못 읽는 .STC — 길이 400 으로 답함."""
def _zip(data: bytes) -> tuple[zipfile.ZipFile, dict[str, str]]:
if data[:4] != b"PK\x03\x04":
raise StcError(".STC 가 아님 — ZIP 머리 없음")
try:
book = zipfile.ZipFile(io.BytesIO(data))
except zipfile.BadZipFile as error:
raise StcError("깨진 .STC") from error
if any(i.flag_bits & 1 for i in book.infolist()):
raise StcError("암호 걸린 .STC — 읽을 수 없음")
return book, {n.upper(): n for n in book.namelist()}
def table(book: tuple[zipfile.ZipFile, dict[str, str]], name: str) -> list[dict[str, Any]]:
"""`{name}.DBF` → 레코드 목록(파일 차례 · 지운 줄 뺌) — C 글 · N 수(Decimal) · L 참 거짓."""
zf, names = book
if f"{name}.DBF" not in names:
raise StcError(f".STC 안에 {name} 표가 없음")
d = zf.read(names[f"{name}.DBF"])
count, start, size = struct.unpack_from("<IHH", d, 4)
cols, at, pos = [], 32, 1
while at + 32 <= len(d) and d[at] != 0x0D:
field = d[at : at + 11].split(b"\0")[0].decode("ascii", "replace")
cols.append((field, chr(d[at + 11]), pos, d[at + 16]))
pos, at = pos + d[at + 16], at + 32
if start + count * size > len(d):
raise StcError(f"{name} 표 길이가 머리와 다름")
out = []
for r in range(count):
base = start + r * size
if d[base : base + 1] == b"*":
continue
row: dict[str, Any] = {}
for field, kind, off, width in cols:
raw = d[base + off : base + off + width]
if kind == "C":
row[field] = raw.decode("cp949", "replace").strip()
elif kind in "NF":
try:
row[field] = Decimal(raw.decode("ascii").strip() or 0)
except (InvalidOperation, UnicodeDecodeError):
row[field] = Decimal(0)
elif kind == "L":
row[field] = raw in (b"T", b"t", b"Y", b"y")
out.append(row)
return out
def _parts(row: dict[str, Any], money: str) -> dict[str, Decimal]:
"""money = 'AMT1'(단가) · '_AMT1'(금액) → {labor, material, expense}."""
return {k: row.get(f"{p}{money}") or Decimal(0) for p, k in zip("LMS", PART_KEYS)}
def _numbers(costn: list[dict[str, Any]]) -> dict[str, int]:
"""대가 · 산근 · 자재 코드 → 번(같은 글자 코드 정렬 차례)."""
out = {}
for head in HOPYO:
for n, code in enumerate(sorted({r["CODE"] for r in costn if r["CODE"][:1] == head}), 1):
out[code] = n
return out
def _changes(rows: list[dict[str, Any]]) -> int:
"""변경 차수(2~4) 금액이 찬 줄 수 — 수량 칸만 베낀 당초 판은 0."""
return sum(any(r.get(f"T_AMT{k}") for k in "234") for r in rows)
def read_bill(name: str, data: bytes) -> dict[str, Any]:
""".STC → 엑셀 `read_bill` 과 같은 꼴(`행` = BOQ11 차례 1~)."""
book = _zip(data)
rows = table(book, "BOQ11")
numbers = _numbers(table(book, "COSTN"))
lines: list[dict[str, Any]] = []
stack: list[tuple[int, dict[str, Any]]] = [] # (깊이, 묶음 줄)
skipped = 0
for at, row in enumerate(rows, 1):
code, item_name = row.get("BDCODE1") or "", row.get("NAME1") or ""
kind = code[:1] if len(code) == 2 and code[1:].isdigit() else ""
if kind in ("E", "C"): # 소계 · 합계 — 그 깊이 묶음 닫음 · C = 제외
skipped += 1
hit = next(
(n for n in range(len(stack) - 1, -1, -1) if stack[n][0] == int(code[1])), None
)
if hit is not None:
stack[hit][1]["제외"] = stack[hit][1]["제외"] or kind == "C"
del stack[hit:]
continue
if not item_name or code == ":":
continue
group = kind == "T"
if group:
while stack and stack[-1][0] <= int(code[1]):
stack.pop()
up = stack[-1][1] if stack else None
hopyo = None
if not group and code[:1] in HOPYO:
hopyo = {"종류": HOPYO[code[0]], "코드": code}
if code in numbers:
hopyo["번"] = numbers[code]
line = {
"행": at,
"묶음": group,
"위": up["행"] if up else None,
"공종": (row.get("ITNUM") or "") if group else "",
"이름": item_name,
"규격": row.get("SIZE1") or "",
"단위": row.get("UNIT1") or "",
"수량": None if group else row.get("QTY1") or Decimal(0),
"단가": _parts(row, "AMT1"),
"금액": _parts(row, "_AMT1"),
"비고": "",
"호표": hopyo,
"제외": False,
}
if group:
stack.append((int(code[1]), line))
lines.append(line)
by_row = {x["행"]: x for x in lines}
out, fee, pct_lines = [], None, 0
for line in lines: # 부모가 앞 — 제외 물려받음 · 맨 위 「관급」
up = by_row.get(line["위"])
line["제외"] = line["제외"] or (up["제외"] if up else "관급" in line["이름"])
if not line["묶음"] and line["단위"].endswith("%"):
if line["제외"] and "수수료" in line["이름"]:
fee = fee if fee is not None else line["수량"]
continue
pct_lines += 1
qty = _plain(line["수량"])
line.update({"단가": line["금액"], "수량": Decimal(1), "단위": "식"})
line["규격"] = " ".join(t for t in (line["규격"], f"({qty} %)") if t)
out.append(line)
_unwrap(out)
total = {"직접": Decimal(0), "제외": Decimal(0)}
for line in out:
if not line["묶음"]:
total["제외" if line["제외"] else "직접"] += sum(line["금액"].values())
notes = [
f"다공구 — {t[:-4]} {len(table(book, t[:-4]))} 줄 있음(당초 첫 공구 BOQ11 만 읽음)"
for t in sorted(book[1])
if t.startswith("BOQ1") and t.endswith(".DBF") and t != "BOQ11.DBF"
]
if changed := _changes(rows):
notes.append(f"변경 차수 — {changed} 줄 있음(당초만 읽음)")
return _plain(
{
"꼴": "stc",
"시트": "BOQ11",
"줄": out,
"합계": total,
"조달수수료율": fee,
"합계줄": skipped,
"율줄": pct_lines,
"알림": notes,
}
)
def _item(row: dict[str, Any]) -> dict[str, Any]:
return {"name": row["NAME"], "spec": row.get("SIZE") or "", "unit": row.get("UNIT") or ""}
def _titles(book: tuple[zipfile.ZipFile, dict[str, str]]) -> tuple[str, ...]:
zf, names = book
if "START8X.INI" not in names:
return SOURCES
text = zf.read(names["START8X.INI"]).decode("cp949", "replace")
got = dict(line.split("=", 1) for line in text.splitlines() if "=" in line)
return tuple(got.get(f"JMOK{n}", "").strip() or SOURCES[n - 1] for n in range(1, 6))
def read_prices(name: str, data: bytes) -> dict[str, Any]:
""".STC → 707-3 `read_prices` 꼴 {labor, material, machine, *_sheet} ·
자재 줄 + `sources` [{출처, 단가, 쪽}](값 있는 칸만)."""
book = _zip(data)
titles = _titles(book)
costn = [
r for r in table(book, "COSTN") if r.get("NAME") and not (r.get("UNIT") or "").endswith("%")
]
labor = [{**_item(r), "price": r["TAMT1"]} for r in costn if r["CODE"][:1] == "L"]
material = [
{
**_item(r),
"price": r["TAMT1"],
"sources": [
{"출처": titles[n - 1], "단가": r.get(f"AMT_{n}1"), "쪽": r.get(f"PG_{n}1") or ""}
for n in range(1, 6)
if r.get(f"AMT_{n}1")
],
}
for r in costn
if r["CODE"][:1] == "M"
]
machine = [
{**_item(r), "unit": "시간", **_parts(r, "AMT1")} for r in costn if r["CODE"][:1] == "X"
]
return _plain(
{
"labor": labor,
"labor_sheet": "COSTN L",
"material": material,
"material_sheet": "COSTN M",
"machine": machine,
"machine_sheet": "COSTN X",
}
)
def read_hopyo(name: str, data: bytes) -> dict[str, list[dict[str, Any]]]:
""".STC → {대가: COSTN B, 산근: COSTN D} 목록
[{코드, 번, 이름, 규격, 단위, 단가 {labor, material, expense}, 합계}] — 구성 줄은 `UNKNOWN`."""
costn = table(_zip(data), "COSTN")
numbers = _numbers(costn)
out: dict[str, list[dict[str, Any]]] = {"대가": [], "산근": []}
for r in sorted(costn, key=lambda r: r["CODE"]):
if r["CODE"][:1] in "BD" and r.get("NAME"):
out[HOPYO[r["CODE"][0]]].append(
{
"코드": r["CODE"],
"번": numbers[r["CODE"]],
"이름": r["NAME"],
"규격": r.get("SIZE") or "",
"단위": r.get("UNIT") or "",
"단가": _parts(r, "AMT1"),
"합계": r.get("TAMT1") or Decimal(0),
}
)
return _plain(out)
+15
View File
@@ -168,6 +168,19 @@ def _fixed_cells(row: dict[str, Any]) -> dict[str, Any]:
return out
def _clean_was(raw: Any) -> dict[str, Any] | None:
"""707-4 이은 자동 줄 되돌릴 것 — 불러온 줄(hand 먼저) · 자동 줄 덮개 · 앞 · 뒤 줄 키."""
if not isinstance(raw, dict) or not isinstance(raw.get("rows"), list):
return None
rows = _clean_v2({"rows": raw["rows"]}).get("rows") or []
if not rows or not rows[0]["key"].startswith("hand:"):
return None
old = raw.get("old")
old = (_clean_v2({"rows": [old]}).get("rows") or [None])[0] if isinstance(old, dict) else None
near = {k: [x for x in raw.get(k) or [] if isinstance(x, str)][:500] for k in ("prev", "next")}
return {"rows": rows, "old": old, **near}
def _clean_v2(raw: dict[str, Any]) -> dict[str, Any]:
"""184 덮개 v2 — 표 차례(겹친 키 · `fee` 버림) · 새 줄(`hand:`) = 모든 칸 ·
자동 줄 = 고친 칸만(값 · null = 일부러 빈칸 · 못 쓰는 값 = 칸 버림) · `gone` = 자동 줄 키 ·
@@ -204,6 +217,8 @@ def _clean_v2(raw: dict[str, Any]) -> dict[str, Any]:
if "ref" in row and (row["ref"] is None or _clean_ref(row["ref"]) is not None):
out["ref"] = _clean_ref(row["ref"])
out.update(_fixed_cells(row))
if was := _clean_was(row.get("was")):
out["was"] = was
rows.append(out)
given = raw.get("gone") if isinstance(raw.get("gone"), list) else []
gone = list(
+44 -10
View File
@@ -14,6 +14,10 @@
* 길 = `B09_Estimation_Router_Import.py` import/rates · import/prices(file | file_key · user = 화면 캐시) →
* 확인 창(짝 표 · 식 다름 · 짝 없음 n) → [적용] = 답 `patch` 를 캐시에 얹음 · [저장] 때 서버 정본
* 올린 파일 = 내역 줄 · 요율 · 단가표 세 길이 같은 표지(file_key · `last`) — 같은 파일 다시 안 올림
*
* EST 원본(.estx · PLAN 707-5) = 같은 단추 · 같은 창 — 내역 줄 + [단가표도 같이](받은 patch 를 캐시에 함께) ·
* 요율 = 서버 400 「같은 공사 엑셀에서」 안내 · 호표 목록 = 수만(이음 후보 · 구성 줄 못 읽음)
* STmate 원본(.STC · PLAN 707-6) = .estx 와 같은 자리 · 안 읽은 몫(다공구 · 변경 차수) = 창 알림 줄
* ========================================================================== */
import { API_BASE_URL } from "@config/config_frontend";
@@ -31,6 +35,7 @@ import { openModal } from "@ui/ui_template_modal";
import { createText } from "@ui/ui_template_text";
import { currentProjectId } from "../A00_Common/b_project_switch";
import type { Row } from "./B09_Estimation_Calc_Base";
import { openBillLink } from "./B09_Estimation_UI_Link";
import { money, qty, text, type TabContext, type TabId } from "./B09_Estimation_UI_Tabs";
const won = (v: unknown): string => Number(v || 0).toLocaleString("ko-KR");
@@ -78,13 +83,21 @@ export function createBillImport(
): HTMLButtonElement {
const open = (seen: Row, mode: { value: string }): void => {
const total = seen.합계 || {};
const raw = ({ estx: ".estx", stc: ".STC" } as Record<string, string>)[seen.꼴];
const lines = [
`${seen.파일} · ${seen.꼴} 「${seen.시트}」 · 공종 ${seen.묶음} · 항목 ${seen.항목}`,
`엑셀 직접비 합 ${won(total.직접)} 원 · 제외(관급 등) ${won(total.제외)} 원` +
`${raw ?? "엑셀"} 직접비 합 ${won(total.직접)} 원 · 제외(관급 등) ${won(total.제외)} 원` +
(seen.조달수수료율 != null ? ` · 조달수수료율 ${seen.조달수수료율} %` : ""),
seen.이음
? `같은 파일로 들여온 일위대가 ${seen.이음} 호표 — 그 줄은 일위대가에 이음`
: "같은 파일로 들여온 일위대가 없음 — 모든 줄 = 엑셀 단가(고정)",
...(seen.호표
? [
`호표 목록 대가 ${seen.호표.대가} · 산근 ${seen.호표.산근} — 내역 줄 ${seen.호표.가리킴} 개가 코드로 가리킴(이음 후보 · 구성 줄은 못 읽음)`,
`원가계산서 요율은 ${raw} 에서 못 읽음 — 같은 공사 엑셀에서(총괄 · 원가계산서 탭)`,
]
: []),
...((seen.알림 as string[] | undefined) ?? []),
];
const take = createCheckbox("넘겨받기 — 불러온 수량을 씀(수량 산출 값은 곁에)", {
radio: "b09-import-mode",
@@ -106,6 +119,11 @@ export function createBillImport(
const drop = createCheckbox(`그 밖 자동 줄 ${seen.남는자동} 개 지움`, {
disabled: !seen.남는자동,
});
const withPrices = seen.호표
? createCheckbox("단가표도 같이 — 노임 · 자재 · 기계 단가를 이 공사 단가 책에 덮음", {
checked: true,
})
: null;
const run = createButton({ label: "불러오기", variant: "filled" });
run.dataset.b09ImportRun = "";
openModal({
@@ -117,6 +135,7 @@ export function createBillImport(
...lines.map((t) => createText(t, "muted", 14)),
el("div", { className: "b09-import__choices", children: [take.root, price.root] }),
el("div", { className: "b09-import__choices", children: [attach.root, drop.root] }),
...(withPrices ? [withPrices.root] : []),
);
actions.append(run);
run.addEventListener("click", async () => {
@@ -132,11 +151,22 @@ export function createBillImport(
showLoadingOverlay();
try {
const got = await post(projectId, "bill/apply", JSON.stringify(ask), true);
let made = merged(draft, got.user);
let tail = "";
if (withPrices?.input.checked) {
const sheet = await postSheet("prices", made, null);
made = { ...made, ...sheet.patch };
const c: Row = sheet.counts || {};
tail = ` · 단가표 이음 ${Object.entries(PART)
.map(([k, name]) => `${name} ${c[k]?.linked ?? 0}`)
.join(" · ")}`;
}
// 창 닫고 다시 그림 — 표 높이(fillViewport)가 열린 창 가림막을 아래 자리로 셈
close();
done(got.payload, merged(draft, got.user));
done(got.payload, made);
void openBillLink(user(), done, mode.value, true); // 707-4 다음: 지난번 이음 바로 · 남은 것만 창
const n = got.알림 || {};
const head = `새 줄 ${n.실음} · 붙임 ${n.붙임} · 지움 ${n.지움} · 이음 ${n.이음} — [저장]해야 남음`;
const head = `새 줄 ${n.실음} · 붙임 ${n.붙임} · 지움 ${n.지움} · 이음 ${n.이음}${tail} — [저장]해야 남음`;
const off: Row[] = got.어긋남 || [];
if (!off.length) return void showToast(`${head} · 엑셀 금액과 모두 같음`, "success");
// 어긋난 줄 = 표 높이를 잰 뒤(다음 화면) 보기 창
@@ -167,7 +197,7 @@ export function createBillImport(
label: "엑셀 내역 불러오기",
variant: "ghost",
onClick: () =>
pickFiles({ accept: ".xls,.xlsx" }, ([file]) => {
pickFiles({ accept: ".xls,.xlsx,.estx,.stc" }, ([file]) => {
const form = new FormData();
form.append("file", file);
form.append("user", JSON.stringify(user()));
@@ -206,11 +236,11 @@ const PART: Record<string, string> = { labor: "노임", material: "자재", mach
/** 마지막에 올린 파일(서버 기억 열쇠) — 같은 파일 다시 씀 */
let last: { key: string; name: string } | null = null;
async function postSheet(kind: Kind, ctx: TabContext, file: File | null): Promise<Row> {
async function postSheet(kind: Kind, user: Row, file: File | null): Promise<Row> {
const form = new FormData();
if (file) form.append("file", file);
else if (last) form.append("file_key", last.key);
form.append("user", JSON.stringify(ctx.user));
form.append("user", JSON.stringify(user));
const url = `${API_BASE_URL}/projects/${currentProjectId()}/estimation/import/${kind}`;
const response = await fetch(url, { method: "POST", body: form, credentials: "include" });
const body = (await response.json().catch(() => null)) as Row | null;
@@ -332,8 +362,9 @@ const fail = (error: unknown): void => showToast(`불러오지 못함 — ${Stri
function pick(kind: Kind, ctx: TabContext): void {
pickFiles(
{ accept: ".xlsx,.xls" },
([file]) => void postSheet(kind, ctx, file).then((got) => openResult(kind, ctx, got), fail),
{ accept: ".xlsx,.xls,.estx,.stc" },
([file]) =>
void postSheet(kind, ctx.user, file).then((got) => openResult(kind, ctx, got), fail),
);
}
@@ -347,9 +378,12 @@ export function importTools(tab: TabId, ctx: TabContext): HTMLElement[] {
size: "sm",
onClick: () =>
last
? void postSheet(kind, ctx, null).then(
? void postSheet(kind, ctx.user, null).then(
(got) => openResult(kind, ctx, got),
() => pick(kind, ctx),
(error: unknown) => {
fail(error); // 지난 파일이 .estx · .STC(요율) · 없음 = 까닭 알리고 다시 고름
pick(kind, ctx);
},
)
: pick(kind, ctx),
});
+348
View File
@@ -0,0 +1,348 @@
/* =============================================================================
* B09_Estimation_UI_Link.ts
* B09 설계내역서 내역 줄 ↔ 우리 공종 잇기 창(PLAN 707-4) — 불러온 경쟁사 줄(707-1 `hand:x…`)을
* 우리 자동 줄(토공 · 운반 · 구조물)에 이어 B08 수량이 붙게. 저장 안 함(받은 캐시 → [저장] 때 서버 정본).
*
* 길 = `B09_Estimation_Router_Link.py` link/suggest(제안 · 남은 줄 · 빈 자리 · 이은 줄) ·
* link/apply(잇기 · 맞춤 기억 씀) · link/undo(잇기 풀기) · link/forget(이 짝 잊기) — 셈 = 서버 단독
* 창 = 제안 줄(꼬리표 지난번 이음 · 짝 표 · 이름 같음 · 후보 고름 · 수량 갈래 · 묶음 개수 · [잇기]) ·
* 제안 없는 줄([우리 공종으로 잇기] = 단가연결 고르기 창 재사용 · 우리 줄만) · 이은 줄([잇기 풀기] ·
* [이 짝 잊기]) · 머리 [모두 제안대로](짧은 길 = 불러오기 → 모두 제안대로 → [저장])
* 수량 갈래 기본 = 불러올 때 고른 것(가 넘겨받기 · 나 단가만) · 줄에서 바꿈 ·
* 묶음 나 = 개수 칸(빈칸 = B08 수량 · 다르면 수량 칸 붉게)
* 고리 = 불러오기 끝(`…_UI_Import`) · 설계내역서 우클릭(`…_UI_Tabs_Bill_Edit`)
* ========================================================================== */
import { API_BASE_URL } from "@config/config_frontend";
import type { MapContextMenuItem } from "@ui/ui_template_context_menu";
import { createBadge, type BadgeTone } from "@ui/ui_template_badge";
import { createButton, el, showToast } from "@ui/ui_template_elements";
import { createInputField, createSelectField } from "@ui/ui_template_input";
import { createListRow } from "@ui/ui_template_list_row";
import { openModal } from "@ui/ui_template_modal";
import { createText } from "@ui/ui_template_text";
import { createToolbar } from "@ui/ui_template_toolbar";
import {
openLogicPicker,
type PickExtra,
} from "../M02_MasterTemplete/M02_MasterTemplete_Labor_Pick";
import { currentProjectId } from "../A00_Common/b_project_switch";
import type { Row } from "./B09_Estimation_Calc_Base";
import type { TabContext } from "./B09_Estimation_UI_Tabs";
/** 받은 원가 자료 · 새 캐시를 화면에 — 불러오기 고리 = 페이지 `done` · 우클릭 = 탭 ctx */
export type LinkDone = (payload: Row, user: Row) => void;
type Mode = "take" | "price";
const MODES = [
{ value: "take", text: "가 넘겨받기" },
{ value: "price", text: "나 단가만" },
];
const HEAD = { earth: "토공", haul: "운반", st: "구조물" } as Record<string, string>;
const TONE: Record<string, BadgeTone> = { "지난번 이음": "success", "짝 표": "info" };
/** 마지막에 고른 수량 갈래(불러올 때 · 창에서) — 우클릭 길 기본 */
let lastMode: Mode = "take";
const flat = (t: unknown): string => String(t ?? "").replace(/\s+/g, "");
const label = (r: Row): string =>
[r.name, r.spec].filter(Boolean).join(" ") + (r.unit ? ` (${r.unit})` : "");
async function post(path: string, body: Row): Promise<Row> {
const response = await fetch(
`${API_BASE_URL}/projects/${currentProjectId()}/estimation/link/${path}`,
{
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
},
);
const got = (await response.json().catch(() => null)) as Row | null;
if (!response.ok || !got) {
const detail = got?.detail;
throw new Error(typeof detail === "string" ? detail : `HTTP ${response.status}`);
}
return got;
}
const fail = (head: string) => (error: unknown) =>
showToast(`${head} — ${error instanceof Error ? error.message : error}`, "error");
/** 잇기 한 번 — 받은 것 화면에 → 새 캐시 · 알림 글 */
async function linkPairs(user: Row, done: LinkDone, pairs: Row[], counts: Row): Promise<Row> {
const got = await post("apply", { pairs, mode: lastMode, counts, user });
done(got.payload, got.user);
const n = got.알림 || {};
const miss: Row[] = n.못함 || [];
const head = `이음 ${n.이음} · 묶음 ${n.묶음}${n.지움 ? ` · 안 줄 지움 ${n.지움}` : ""}`;
const tail =
(miss.length ? ` · 못함 ${miss.length}(${miss[0].까닭})` : "") +
(n.남은 != null ? ` · 안 이은 불러온 항목 ${n.남은}` : "");
showToast(`${head}${tail} — [저장]해야 남음`, miss.length ? "warning" : "success", 6000);
return got.user;
}
/** 우리 줄 고르기 — 단가연결 고르기 창(덧줄만 · 종류 = 토공 · 운반 · 구조물) */
function pickOurs(spots: Row[], line: Row, take: (key: string) => void): void {
const group = line.kind !== "item";
const extra: PickExtra[] = spots
.filter((s) => (s.head === "st") === group)
.map((s) => ({
kind: String(s.head),
key: String(s.key),
tag: HEAD[s.head] ?? s.head,
name: String(s.name ?? ""),
sub: String(s.spec ?? ""),
unit: String(s.unit ?? ""),
onPick: () => take(String(s.key)),
}));
if (!extra.length)
return void showToast(group ? "빈 구조물 줄 없음" : "빈 토공 · 운반 줄 없음", "warning");
const kinds = [...new Set(extra.map((x) => x.kind))];
openLogicPicker(() => undefined, {
layer: "project",
owner: null,
libOnly: true,
title: `우리 공종으로 잇기 — ${label(line)}`,
extra,
kindFilter: kinds.map((k) => ({ value: k, text: HEAD[k] ?? k })),
pick: () => undefined,
});
}
/** 잇기 창 — user = 지금 캐시 · mode = 불러올 때 고른 갈래(없으면 마지막) ·
* fresh = 불러오기 끝에서 부름 — 지난번 이음은 먼저 바로 잇고 남은 것만 창에(남은 0 = 창 없음) */
export async function openBillLink(
user: Row,
done: LinkDone,
mode?: string,
fresh = false,
): Promise<void> {
if (mode === "take" || mode === "price") lastMode = mode;
let cur = user;
let seen: Row;
try {
seen = await post("suggest", { user: cur });
if (fresh && seen.저절로) {
const got = await post("apply", {
pairs: [],
auto: true,
mode: lastMode,
counts: {},
user: cur,
});
done(got.payload, got.user);
cur = got.user;
showToast(
`지난번 이음 ${got.알림.이음 + got.알림.묶음} 줄 바로 이음 — [저장]해야 남음`,
"success",
);
seen = await post("suggest", { user: cur });
}
} catch (error) {
return fail("제안을 못 받음")(error);
}
if (fresh && !seen.남은.length) return; // 남은 줄 없음 — 창 안 뜸
if (!seen.남은.length && !seen.이은.length) return; // 불러온 줄 없음
let busy = false;
const run = (job: () => Promise<Row | void>): void => {
if (busy) return;
busy = true;
job()
.then((next) => {
if (next) cur = next;
return post("suggest", { user: cur });
})
.then((next) => ((seen = next), draw()), fail("잇기 못 함"))
.finally(() => (busy = false));
};
const all = createButton({ label: "모두 제안대로", variant: "filled" });
const modeSel = createSelectField({
options: MODES,
value: lastMode,
compact: true,
label: "수량 갈래(줄에서 바꿈)",
onChange: (v) => (lastMode = v as Mode),
});
const body = el("div", { className: "b09-import__choices" });
// 줄마다 고른 것(후보 · 갈래 · 개수) — 다시 그려도 남음
const chosen = new Map<string, { target: string; mode: Mode | null; count: string }>();
const pickOf = (s: Row) => {
// 기본 = 앞 줄이 안 고른 첫 후보(암석절취 연암 · 보통암 → 절토(연암) · 암발파)
const taken = new Set([...chosen.values()].map((c) => c.target));
const first = (s.후보 as Row[]).find((c) => !taken.has(c.key)) ?? s.후보[0];
const got = chosen.get(s.key) ?? { target: first.key, mode: null, count: "" };
chosen.set(s.key, got);
return got;
};
const pairOf = (s: Row): Row => {
const p = pickOf(s);
return { line: s.key, target: p.target, ...(p.mode && { mode: p.mode }) };
};
const countsOf = (lines: Row[]): Row =>
Object.fromEntries(
lines
.map((s) => [s.key, Number(pickOf(s).count)])
.filter(([, n]) => Number.isFinite(n) && (n as number) > 0),
);
const forget = (key: string): void =>
run(async () => {
const got = await post("forget", { key, user: cur });
showToast(
got.남음 ? "이 짝 잊음 — 회사 기억이 남음(회사 관리자만 지움)" : "이 짝 잊음",
got.남음 ? "warning" : "success",
);
});
const suggestRow = (s: Row): HTMLElement => {
const p = pickOf(s);
const tags = [createBadge(s.꼬리표, TONE[s.꼬리표] ?? "neutral")];
if (s.후보.some((c: Row) => flat(c.name) === flat(s.name)))
tags.push(createBadge("이름 같음", "accent"));
if (!s.하나) tags.push(createBadge("고름", "warning"));
const target = createSelectField({
options: s.후보.map((c: Row) => ({ value: c.key, text: `→ ${c.name}` })),
value: p.target,
compact: true,
ariaLabel: "이을 우리 줄",
onChange: (v) => (p.target = v),
});
const lineMode = createSelectField({
options: [{ value: "", text: "창 갈래" }, ...MODES],
value: p.mode ?? "",
compact: true,
ariaLabel: "이 줄 수량 갈래",
onChange: (v) => (p.mode = (v || null) as Mode | null),
});
const items: HTMLElement[] = [...tags, target.root, lineMode.root];
if (s.kind !== "item") {
const count = createInputField({
type: "number",
compact: true,
min: 0,
value: p.count,
placeholder: "개수(B08)",
onInput: (v) => (p.count = v),
});
items.push(count.root);
}
items.push(
createButton({
label: "잇기",
variant: "ghost",
onClick: () => run(() => linkPairs(cur, done, [pairOf(s)], countsOf([s]))),
}),
);
if (s.꼬리표 === "지난번 이음")
items.push(
createButton({ label: "이 짝 잊기", variant: "ghost", onClick: () => forget(s.key) }),
);
return createListRow({
label: label(s),
sub: s.까닭 || (s.kind === "item" ? "" : "묶음 → 구조물 줄 통째"),
trailing: createToolbar({ items, size: 24 }),
});
};
const freeRow = (r: Row): HTMLElement =>
createListRow({
label: label(r),
sub: r.kind === "item" ? "" : "묶음",
trailing: createButton({
label: "우리 공종으로 잇기",
variant: "ghost",
size: 24,
onClick: () =>
pickOurs(seen.자리, r, (target) =>
run(() => linkPairs(cur, done, [{ line: r.key, target }], {})),
),
}),
});
const linkedRow = (r: Row): HTMLElement =>
createListRow({
label: `${label(r.from)} → ${r.name}`,
sub: HEAD[String(r.key).split(":")[0]] ?? "",
trailing: createToolbar({
size: 24,
items: [
createButton({
label: "잇기 풀기",
variant: "ghost",
onClick: () => run(() => undoLink(cur, done, r.key)),
}),
createButton({ label: "이 짝 잊기", variant: "ghost", onClick: () => forget(r.key) }),
],
}),
});
const draw = (): void => {
const lines: Row[] = seen.줄;
const has = new Set(lines.map((s) => s.key));
const free = (seen.남은 as Row[]).filter((r) => !has.has(r.key));
const items = (seen.남은 as Row[]).filter((r) => r.kind === "item").length;
const recall = lines.filter((s) => s.꼬리표 === "지난번 이음").length;
all.disabled = !lines.length;
body.replaceChildren(
createText(
`안 이은 불러온 줄 — 항목 ${items} · 묶음 ${seen.남은.length - items} · 제안 ${lines.length}` +
`(지난번 이음 ${recall} · 짝 표 ${lines.length - recall}) · 이은 줄 ${seen.이은.length}`,
"muted",
14,
),
createToolbar({ items: [modeSel.root] }),
...(lines.length ? [createText("제안", "muted", 14), ...lines.map(suggestRow)] : []),
...(free.length ? [createText("제안 없음", "muted", 14), ...free.map(freeRow)] : []),
...(seen.이은.length
? [createText("이은 줄", "muted", 14), ...seen.이은.map(linkedRow)]
: []),
);
};
openModal({
title: mode ? "다음: 우리 공종으로 잇기" : "우리 공종으로 잇기",
closeLabel: "닫기",
width: "960px",
mount: (box, close, actions) => {
all.dataset.b09LinkAll = "";
all.addEventListener("click", () => {
const lines: Row[] = seen.줄;
run(async () => {
const used = new Set<string>(); // 같은 우리 줄 둘째부터 = 남김(고름)
const pairs = lines.map(pairOf).filter((p) => !used.has(p.target) && used.add(p.target));
const next = await linkPairs(cur, done, pairs, countsOf(lines));
close(); // 짧은 길 — 남은 줄은 알림 · 우클릭으로
return next;
});
});
actions.append(all);
draw();
box.append(body);
},
});
}
async function undoLink(user: Row, done: LinkDone, target: string): Promise<Row> {
const got = await post("undo", { target, user });
done(got.payload, got.user);
showToast("잇기 풂 — 불러온 줄로 · [저장]해야 남음", "success");
return got.user;
}
/** 설계내역서 우클릭 — 불러온 줄 = 잇기 · 이은 줄(`was`) = 풀기 · 잊기 */
export function linkMenu(ctx: TabContext, key: string): MapContextMenuItem[] {
const done: LinkDone = (payload, user) => {
Object.assign(ctx.payload, payload);
ctx.setUser(user);
};
const entry = ((ctx.user.bill?.rows as Row[]) || []).find((e) => e.key === key);
const forget = (): void =>
void post("forget", { key, user: ctx.user }).then(
(got) => showToast(got.남음 ? "회사 기억이 남음(회사 관리자만 지움)" : "이 짝 잊음", "info"),
fail("못 잊음"),
);
if (entry?.was)
return [
["잇기 풀기", () => void undoLink(ctx.user, done, key).catch(fail("못 풂"))],
["이 짝 잊기", forget],
];
if (!key.startsWith("hand:x")) return [];
return [["우리 공종으로 잇기", () => void openBillLink(ctx.user, done)]];
}
@@ -26,6 +26,7 @@ import {
import type { Row } from "./B09_Estimation_Calc_Base";
import type { TabContext, TabTable } from "./B09_Estimation_UI_Tabs";
import { linkItems, type ApplyRef } from "./B09_Estimation_UI_Tabs_Bill_Link";
import { linkMenu } from "./B09_Estimation_UI_Link";
/** 설계내역서 표 = 탭 표 + 줄 우클릭 메뉴 */
export type BillTable = TabTable & {
@@ -269,6 +270,7 @@ export function billMenu(ctx: TabContext, rows: DataRow[], i: number): MapContex
const [picks, more] = linkFor(ctx, rows, i); // 251-6 단가연결 = 모달 하나(옛 하위 메뉴 다섯)
parts[1].push(...picks, ...more);
}
parts[1].push(...linkMenu(ctx, key)); // 707-4 내역 줄 ↔ 우리 공종 잇기 · 풀기 · 잊기
// 옮김 · 레벨 — 같은 부모 형제와 자손째
if (!fixed(key)) {
add(2, "위로 ↑", (w, e) => {
+13 -3
View File
@@ -350,12 +350,22 @@ def carry(doc: dict[str, Any], source: Here) -> dict[str, Any]:
got = read_ref(ref)
if got is None:
raise _bad(f"없는 참조 「{ref['키']}」")
body = origin_mark({**got, "담은로직": _moved(got.get("담은로직"), move, depth + 1)}, ref)
made = create(kind_of(ref["키"]), body)
body = {**got, "담은로직": _moved(got.get("담은로직"), move, depth + 1)}
made = create(kind_of(ref["키"]), origin_mark(_spec_moved(body, done), ref))
done[ref_id(ref)] = {"층": target[0], "주인": target[1], "키": made["이름"]}
return done[ref_id(ref)]
return {**doc, "담은로직": _moved(doc.get("담은로직"), move, 1)}
return _spec_moved({**doc, "담은로직": _moved(doc.get("담은로직"), move, 1)}, done)
def _spec_moved(doc: dict[str, Any], done: dict[str, Ref]) -> dict[str, Any]:
"""규격 열 수량(704-4 · 줄 id = `ref_id`) 열쇠도 새 참조로 — 안 바꾸면 그 줄 열 값이 버려짐."""
spec = doc.get("규격")
qty = spec.get("수량") if isinstance(spec, dict) else None
if not isinstance(qty, dict) or not done:
return doc
moved = {old: ref_id(new) for old, new in done.items()}
return {**doc, "규격": {**spec, "수량": {moved.get(k, k): v for k, v in qty.items()}}}
def _moved(lines: Any, move: Callable[[Ref, int], Ref], depth: int) -> list[Any]:
@@ -0,0 +1,50 @@
# 통합 길 점검 — 701~707 합친 판 (sub6 · 2026-10-31)
판 = sub_laptop_6 (31cb4ba3a 포함 · 받기 뒤) · 서버 8008 · 5181 · 검증 프로젝트 B 검증_sub6 · 층 쓰기 = 브레인검증회사(3) 일반 · 관리자 「sub6임시」.
화면 = 헤드리스(Edge) 한 창을 띄워 두고 차례로 조작 · 사진 `tmp/통합_화면/`.
누름 = 마우스 누름 + 글 입력 칸 하나 = 1 · 「전」 = `_분석_내역3-C` · `_분석_내역5-C` 표.
## 1. 대표 일 누름 표
| 일 | 전 | 지금 누름 · 화면 이동 | 막힘 | 수치 |
| --- | --- | --- | --- | --- |
| T1 있는 공사 열기 → 첫 화면 | 3 · 첫 화면 빈 원가계산서 · 빈 줄 수 셋 | 3(프로젝트 · 카드 · 원가계산) · 이동 1 | 없음 | 첫 화면 = 설계내역서 · 「금액 빈 줄 23」 하나 |
| T2 일위대가 품 고쳐 이 공사만 | 7 막힘 | 2(내역 줄 · 품 칸) + [저장] 1 · 이동 0 | 없음 | 레미콘 품 0.57 → 0.6 · 줄 38,465 → 40,490 즉시 · [저장] 뒤 서버 161,960 = 화면 |
| T3 규격만 다른 일위대가 새로 | 12~13 · 이동 2 | 6(우클릭 · 복사해 새 · 규격 · [만들기] · [바꿈] · 품 칸) · 이동 0 | 흠 A(고침) | 대가 복사본에 덮개 0.6 같이 감 · 합판 5 |
| T4 지난 공사에서 가져오기 | 막힘 | 3(내역 줄 · 「지난 공사」 · 두 번 누름) | 없음 | 1.9 s 안 연결 · 흠 ③ |
| T5 노임 새 판 · 고친 값 지킴 | 공사마다 2 + 구조물마다 2~3 | 3(내역 점검 · [업데이트] · [적용]) | 흠 A(고침) | 미리 보기 「값 바뀜 2 · 고친 칸 3 지킴」 → 덮개 그대로 · 40,490 그대로 |
| T6 줄 추가 → 단가 → 금액 → 총액 | 10 막힘 | 11(우클릭 · 항목 추가 · 이름 · 수량 · 단위 · 단가연결 · 찾기 · 줄 · [담기] · [연결] · [저장]) | 흠 ① ② | 연결 뒤 1.4 s 5,651 × 10 = 56,510 · 원가계산서 직접노무비 48,690 · 서버 = 화면 |
| K1 엑셀 호표 하나(붙여넣기) | 13~19 | 3(격자 · Ctrl+V · [닫기]) + 처음 한 번 열 짝 [확인] 1 | 없음 | 대흥 제17호표 「엑셀 합계 = 우리 합계 1/1」 · 줄만 붙임 = 고정 줄 2 · 89,156 · [저장] 뒤 남음 |
| K2 시트 통째 | 약 74 | 6(펼침 · 올리기 3 · [모두 고정으로 들여오기] · [들여오기]) | 없음 | 거창 호표 14 · 줄 68 · 빠짐 0 · 14/14 · 61.6 s |
| K3 새 구조물 조합 | 약 50 | 5(호표 [수정] · [구조물 조합으로] · 이름 · [만들기]) + [새로고침] 1 | 흠 ⑥ | 제5 · 6호표 = 규격 열 둘 저절로 · 1,057,254(엑셀 1,057,245 · 하위 호표 끝수) |
| K4 규격만 다른 조합 | 22~28 + 도면 | 0(K3 묶음이 열 둘을 바로 만듦) · 손으로 열 더하기 = 7(704-4 값) | 없음 | 2.0 열 1,146,411.6 · 2.5 열 1,270,715.6(붙인 고정 줄 포함) |
| K5 회사에 올리기 요청 · 승인 | 일반 막힘 · 관리자 4 | 요청 3(우클릭 · 요청 · [요청]) · 승인 3 + 회사 단추 1~2 | 흠 B(고침) · 흠 ⑦ | 고친 뒤 회사 사본 2.5 열 1,270,715.6 = 원본 |
| K6 설계(B06)에서 고르고 금액 | 약 11~12 | B06 구조물 배치 · 측점 · 미지정 · 열 · [적용] · [저장] = 6 → B09 1 = 7 | 없음(금액 빈칸 = 고른 마스터 항목의 빈 수량 6 줄 · 자료 몫) | B08 집계표 열 · B09 2.2 「관 공」 대가1 연결 · B06 저장 127 s |
| 덧 경쟁사 결과물 통째 | 707 각 창 값 | 내역 3(단추 · 파일 · [불러오기]) + [저장] · 요율 2(단추 · [적용] — 같은 파일 기억) · 단가표 2 | .estx · 공종 잇기 화면 미합류 | 거창 직접비 384,916,227 + 이 공사 줄 218,470 = 385,134,697 = 서버 · 단가표 뒤 385,129,593 = 서버 · 덮개 0.6 그대로 |
## 2. 이음매 흠 — 고친 것
| # | 만나는 기능 | 흠 | 고침 · 파일 | 확인 |
| --- | --- | --- | --- | --- |
| A | 703-2 복사 × 703-3 [업데이트] × 204-2 서고 정리 | 사전조합 연결 줄을 복사해 [바꿈] → 복사본 원본 열쇠(`C:참조…`)를 「쓰는 사본」으로 안 셈 → [업데이트] 뒤 노임 줄이 책에서 빠져 금액 40,490 → 0(빈 줄 3) · [저장] 정리가 원본 사본 폴더를 지울 수 있음 | 복사본의 복사본은 맨 처음 원본까지 · `C:참조` = 그 참조 사본 폴더도 지킴 · `B09_Estimation_Bill_Library.py` · `B09_Estimation_Router.py` · 시험 `test_703_b09_daega_copy.py` | 시험 29 통과 · [업데이트] 뒤 40,490 그대로 · [저장] 뒤 서버 161,960 = 화면 · 사본 폴더 남음 · ebd33de9c |
| B | 704-4 규격 열 × 706-1 승인(회사로 복사) | 개인 하위 호표 참조가 회사 새 키로 바뀌는데 규격 열 수량 열쇠는 옛 키 → 그 줄 열 값 버려짐(2.5 열 1,270,715.6 → 1,259,136.2 · 차 11,579.4) | 참조 따라 복사(`carry`) 때 규격 수량 열쇠도 새 참조로 · `M02_Labor_Ref.py` · 시험 `test_m02_labor_ref.py` | 시험 27 + 704_4 10 통과 · 다시 승인 → 1,270,715.6 = 원본 · b3f3aed0a |
## 3. 이음매 흠 — 목록(안 고침)
| # | 자리 | 흠 | 크기 |
| --- | --- | --- | --- |
| ① | B09 단가연결 찾기 | 내역서 자기 줄 이름 「측구파기」로 찾으면 0 건(로직 이름 「측구터파기」) · 목록 금액 칸 「-」(704-1 기준 목록 금액이 이 창엔 안 옴) | 작음 · 낱말 짝 표 |
| ② | 704-2 계산서 × B09 산근 | [담기] 전 계산서 5,654 · 연결 뒤 5,651 — 계산서는 줄 반올림, B09 는 줄마다 버림 | 작음 · 끝수 규칙 하나로 |
| ③ | 703-1 가져오기 × 701-3 빈 줄 | 줄 0 개 일위대가를 이으면 금액 0 으로 「빈 줄」에서 빠짐(22 → 21) — 빈 일위대가가 확정을 통과할 수 있음 | 판단 필요(빈 = 0 인지 모름인지) · 계산 짝 |
| ④ | B09 단가연결 | ㎥ 줄에 「개소」 일위대가를 이어도 단위 다름 알림 없음 | 작음 |
| ⑤ | 번호 노출(703-4 밖 자리) | 복사해 새 일위대가 창 규격 칸에 「SC-000001」 · 재료 창고 규격 「SC-0…」 · B09 줄 비고 「personal\|1/3\|SP3-000015」 · M02 격자 머리 「SU-000011」 · 비고 「SC-000002」 | 작음 · 낱말 |
| ⑥ | 704-4 [구조물 조합으로] | 만든 항목이 M02 표(행렬)에 [새로고침] 전엔 안 보임 | 작음(+1 누름) |
| ⑦ | 706-1 관리자 | 「올리기 요청 n」 띠가 회사 층 단추를 눌러야 보임(관리자 기본 = 개인) | 작음(+1~2 누름) |
| ⑧ | 걸린 시간(기록만) | B09 [저장] 16.6 · 32.6 · 53.5 s → 내역 116 줄 뒤 206.8 · 257.3 s · B06 저장 127 s · B08 저장 135 s · K2 들여오기 61.6 s · 엑셀 내역 불러오기 64.8 s | 속도는 따로 |
| ⑨ | 미합류 | 707-5 .estx 고리(sub7) · 707-4 공종 잇기 화면(sub5) — 이 판에선 내역 불러오기 뒤 잇기 창 없음 | 그 창 몫 |
| ⑩ | B06 [저장](70x 밖 · 참고) | 관 지점 옵션 옛 칸(집수정인데 inlet_revet_* 돌쌓기 10 m)이 지워지고 동물이동형 기본 true 가 들어감 | 참고 |
## 4. 되돌림
- 검증_sub6 — B09(사용자 값 · 단가 책 · 서고 사본) · B06 저장이 바꾼 파일(관 지점 · 종단 · 횡단 351 m · 새 파일 둘) · 구조물 사본 = 시작 때 판으로 되돌림 → 서버 「빈 줄 23 · 연결 0 · 줄 37」 = 시작 때.
- 브레인검증회사 — 개인 · 회사 구조물 조합 「sub6임시 BOX(암거)」 화면 지우기 · 사전조합 개인 15 · 회사 10 · 실무자료 둘 지움 · 올리기 요청 장부(내 줄 둘뿐) 지움 → `storage/3` 안 「sub6임시」 0 건.
+16 -9
View File
@@ -44,17 +44,19 @@ const sty = (n) => ({
paddingBottom: n.css.paddingBottom ?? "0px",
borderBottomWidth: n.css.borderBottomWidth ?? "0px",
marginBottom: n.css.marginBottom ?? "0px",
position: n.css.position ?? "static",
});
const box = { exports: {} };
new Function("exports", "module", "require", "window", "document", "getComputedStyle", "ResizeObserver", js)(
box.exports,
box,
(p) => (p.endsWith(".css") ? {} : require(p)),
win,
{ body: null },
sty,
RO,
);
new Function(
"exports",
"module",
"require",
"window",
"document",
"getComputedStyle",
"ResizeObserver",
js,
)(box.exports, box, (p) => (p.endsWith(".css") ? {} : require(p)), win, { body: null }, sty, RO);
const m = box.exports;
// 문서: body(padding-bottom 8) > card(padding-bottom 12 · gap 8) > [root][avg 20][info 30][hint 40]
@@ -81,4 +83,9 @@ hint.height = 60;
observers.forEach((o) => o.fn());
out.afterHintGrow = parseFloat(root.style.maxHeight);
out.firstMinusGrow = out.first - 20;
// 열린 창 가림막(body 끝 · position fixed · 창 높이) = 아래 자리에 안 셈(707-5)
link(body, card, el("backdrop", { position: "fixed" }, 900));
out.belowWithModal = m.spaceBelow(root);
out.belowAfterGrow = 3 * 8 + 20 + 30 + 60 + 12 + 8;
console.log(JSON.stringify(out));
+2 -1
View File
@@ -147,6 +147,7 @@ const { loadTs } = makeLoader({
root: ROOT,
requireFor: (full) => (request) => {
if (request in STUBS) return STUBS[request];
if (request.endsWith(".css")) return {}; // 부품 글꼴 import(ui_template_icons.css 등)
if (request.startsWith(".")) return loadTs(`${path.resolve(path.dirname(full), request)}.ts`);
return require(request);
},
@@ -178,7 +179,7 @@ const flush = () => new Promise((resolve) => setTimeout(resolve, 20));
const out = {};
void openItemPicker({ current: null, canCopy: false });
await flush();
const [, cols] = modal.body.children;
const [, , cols] = modal.body.children; // 위 표 · 규격 표(704-4) · 열
const [search, layerBar, treeHost] = cols.children[0].children;
const searchInput = search.children[0];
const active = () => treeHost.all().find((n) => n.classList.contains("is-active"))?.dataset.id;
@@ -119,6 +119,7 @@ const { loadTs } = makeLoader({
requireFor: (full) => (request) => {
if (CATALOG.isCatalogRequest(request)) return CATALOG;
if (STUBS[request]) return STUBS[request];
if (request.endsWith(".css")) return {}; // 부품 글꼴 import(ui_template_icons.css 등) — 트랜스파일 못 함
// 화면 공용 부품(@ui/…) — 이 시험이 안 쓰는 것이라 뭘 불러도 아무 일 안 하는 함수로 막아 둠
if (request.startsWith("@ui/")) return new Proxy({}, { get: () => () => {} });
// 공용 계산(@util/…) — tsconfig 별칭대로 common_util 폴더에서 읽음
@@ -177,3 +177,4 @@ def test_창_끝까지_셈에_아래_안내_줄_포함():
assert r["below"] == r["belowExpected"] == 134 # 아래 줄 · 안내 줄 둘 + 여백
assert r["bottom"] == r["viewport"] # 문서 맨 아래 = 창 끝 → 페이지 스크롤 0
assert r["afterHintGrow"] == r["firstMinusGrow"] # 안내 줄이 늘면 표 칸이 그만큼 줄어듦
assert r["belowWithModal"] == r["belowAfterGrow"] # 열린 창 가림막(fixed) = 아래 자리 아님
@@ -117,7 +117,7 @@ def test_모달_배선_고르면_보이고_편집_화면에서는_숨김():
ts = _text(UI / "ui_template_item_picker.ts")
assert "createComboTable((colId) => pickColumn(colId))" in ts # 280-2 표 고름 → 나무
assert "paintTable();" in ts # 고른 항목 · 층이 바뀔 때마다(280-2)
assert "body.append(tableWrap, cols, editHost," in ts # 맨 위
assert "body.append(tableWrap, specHost, cols, editHost," in ts # 맨 위(704-4 규격 표 뒤)
assert ts.count("tableWrap.hidden = true;") == 1 # 편집 화면 열 때
assert ts.count("tableWrap.hidden = false;") == 1 # 돌아올 때
assert "comboTable.destroy();" in ts # 닫을 때 표 거둠
@@ -96,6 +96,28 @@ def test_서고_사본_원본_지킴():
assert library.used_folders(user) == {"combo@system@-@SS1"}
def test_사전조합_원본_복사본_사본_지킴(tmp_path):
"""통합 점검 — 사전조합(`C:참조`) 복사본 · 복사본의 복사본 = 원본 사본 폴더 지킴(업데이트 수요 · 저장 정리)."""
base = library.library_dir(tmp_path)
for name, ref in (
("precombo@personal@1-3@SC-000001", {"층": "personal", "주인": "1/3", "키": "SC-000001"}),
("precombo@personal@1-3@SC-000002", {"층": "personal", "주인": "1/3", "키": "SC-000002"}),
):
(base / name).mkdir(parents=True)
manifest = {"종류": "precombo", "원본": {"참조": ref}}
(base / name / "manifest.json").write_text(json.dumps(manifest), encoding="utf-8")
src = "C:참조|personal|1/3|SC-000001"
user = {
"daega_cover": {
"K:aa": {"@copy": {"from": src, "name": "복사"}},
"K:bb": {"@copy": {"from": "K:aa", "name": "복사의 복사"}},
}
}
assert library.used_folders(user) == set() # 폴더 모름 = 옛 꼴
assert library.used_folders(user, tmp_path) == {"precombo@personal@1-3@SC-000001"}
assert library.prune(tmp_path, user) == ["precombo@personal@1-3@SC-000002"]
# ── 내 자료집(개인 층 사전조합 · 임시 storage) ─────────────────────────────
+80
View File
@@ -221,3 +221,83 @@ def test_기억_층(tmp_path, monkeypatch, role, layer):
assert got["a||"]["확정"] == ("earth:4" if layer == "personal" else "earth:5")
assert got["b||"]["확정"] == "x"
assert route.recalled({"user_id": 3, "role": role}) == {} # 회사 없음 = 층 없음
def _same(a, b):
"""두 셈 = 같은 줄 · 같은 직접비."""
assert [r["key"] for r in a["rows"]] == [r["key"] for r in b["rows"]]
assert a["totals"]["direct"]["sum"] == b["totals"]["direct"]["sum"]
@pytest.mark.parametrize("mode", ["take", "price"])
def test_잇기_풀기_제자리(geo, mode):
user, rows = _load(geo)
before = _bill(user)
group = next(r for r in rows if r["name"] == "콘크리트구조물")
pairs = [*_pick(lk.suggest(rows, {})), (group["key"], ST)]
made, report, bill = _link(user, rows, pairs, mode)
assert report["이음"] == 12 and report["묶음"] == 1
linked = [e for e in made["bill"]["rows"] if e.get("was")]
assert len(linked) == 13 # 저장 다듬기 뒤에도 되돌릴 것 남음
for entry in linked[::2] + linked[1::2]: # 이은 차례와 다르게
made, why = lk.unlink(made, bill["rows"], entry["key"])
assert why == ""
made = store.clean_user(made)
bill = _bill(made)
_same(bill, before)
assert lk.unlink(made, bill["rows"], "earth:4")[1] == "이은 줄이 아님"
def test_줄마다_갈래_판(geo):
user, rows = _load(geo)
cut = next(r for r in rows if r["name"] == "토사절취")
side = next(r for r in rows if r["name"] == "측구터파기" and r["spec"] == "토사")
pairs = [(cut["key"], "earth:4"), (side["key"], "earth:11")]
made, report, bill = _link(user, rows, pairs, "take", modes={side["key"]: "price"})
got = {r["key"]: r for r in bill["rows"]}
assert got["earth:4"]["qty"] == cut["qty"] and got["earth:11"]["qty"] == 100
with pytest.raises(ValueError):
lk.link(user, rows, pairs, "take", modes={cut["key"]: "both"})
board = lk.board(made, bill["rows"])
left = {r["key"] for r in board["남은"]}
assert cut["key"] not in left and side["key"] not in left
assert {r["key"] for r in board["이은"]} == {"earth:4", "earth:11"}
assert board["이은"][0]["memo"] in report["기억"]
assert not {"earth:4", "earth:11"} & {r["key"] for r in board["자리"]}
group = next(r for r in rows if r["name"] == "콘크리트구조물")
inner = {r["key"] for r in rows if r["parent"] == group["key"]}
made, _r, bill = _link(made, bill["rows"], [(group["key"], ST)], "take")
left = {r["key"] for r in lk.board(made, bill["rows"])["남은"]}
assert not inner & left # 구조물 묶음에 딸린 줄 = 남은 줄 아님
def test_이_짝_잊기(tmp_path, monkeypatch):
monkeypatch.setattr(route.access, "folder", lambda got, create=False: tmp_path / got.layer)
user = {"company_id": 7, "user_id": 3, "role": "USER"}
gone = {lk.FORGOT: True}
route.keep(user, {"a||": {"확정": "earth:4"}, "b||": {"확정": "earth:5"}})
hopyo_store.remember(tmp_path / "company", {"c||": {"확정": "x"}}, lk.MEMORY)
assert route.keep(user, {"a||": gone}) == "personal"
assert route.recalled(user)["a||"] == gone and route.recalled(user)["b||"]["확정"] == "earth:5"
route.keep(user, {"c||": gone}) # 일반 = 회사 기억을 개인 층 표시로 덮음
assert route.recalled(user)["c||"] == gone
def test_잊은_짝_느슨한_기억에도_안_나옴(tmp_path, monkeypatch):
monkeypatch.setattr(route.access, "folder", lambda got, create=False: tmp_path / got.layer)
user = {"company_id": 7, "user_id": 3, "role": "USER"}
row = {"name": "토사절취", "spec": "A", "unit": "m3"}
other = {"name": "토사절취", "spec": "B", "unit": "m3"}
live = {"확정": "earth:4", "이름": "절토(토사)"}
route.keep(user, {lk.key_of(other): live})
assert (
lk.recall(route.recalled(user), row) == live
) # 규격 달라도 품명 · 단위 같음 = 느슨한 기억
route.keep(user, {lk.key_of(row): {lk.FORGOT: True}})
memory = route.recalled(user)
assert lk.recall(memory, row) is None # 잊음 = 느슨한 기억도 끊김
assert lk.recall(memory, other) == live # 다른 줄은 그대로
third = {"name": "토사절취", "spec": "C", "unit": "m3"}
assert lk.recall(memory, third) is None # 잊은 짝 곁에서는 느슨하게 안 좁힘
route.keep(user, {lk.key_of(row): live}) # 다시 이음 = 풀림
assert lk.recall(route.recalled(user), row) == live
+14
View File
@@ -125,3 +125,17 @@ def test_깨진_파일_표_없음_막힘():
z.writestr("Other.dat", b"")
with pytest.raises(ex.EstxError, match="EstMainDat"):
ex.read_bill("a.estx", buf.getvalue())
def test_불러오기_길_estx_갈림(data):
"""707-5 고리 — 이름 끝 .estx = 원본 읽기 · 호표 = 목록 수 + 코드로 가리킨 줄(이음 후보)."""
from B09_Estimation import B09_Estimation_Router_Import as route
name = "거창.ESTX"
parsed = route._parse(name, data["거창"])
assert parsed["꼴"] == "estx" and parsed["합계"]["직접"] == 384916227
assert route._hopyo(name, data["거창"], parsed) == {"대가": 14, "산근": 61, "가리킴": 48}
assert route._hopyo("a.xlsx", data["거창"], parsed) is None
with pytest.raises(route.HTTPException) as got:
route._parse("a.estx", b"not a zip")
assert got.value.status_code == 400
+174
View File
@@ -0,0 +1,174 @@
"""707-6 STmate 원본(.STC) 바로 읽기 — `B09_Estimation_Stc` (읽기만 · 저장 안 함).
- 실무 .STC 일곱(지식DB 실무문서) = 항목 수 · 직접비 · 제외 · 노임 · 자재 · 기계 · 호표 목록 줄 수 ·
직접비 = 파일 안 공사 금액(PRJLST TCOST1)
- 기번3 · 영월 · 봉화 = 같은 공사 STmate 엑셀(707-1 · 707-3)과 줄마다 같음
(이름 · 규격 · 수량 · 단가 · 금액 · 호표 코드 · 번 · 제외 · 묶음 나무 · 단가표)
- 다공구(BOQ12) · 변경 차수 = 당초만 읽고 알림 · 707-1 짜기 그릇에 그대로 부음
- 깨진 파일 · 표 없음 = 막힘 · 불러오기 길 = 이름 끝 .STC 갈림
"""
import io
import sys
import time
import zipfile
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from B09_Estimation import B09_Estimation_Bill_Import as imp # noqa: E402
from B09_Estimation import B09_Estimation_Import_Prices as ip # noqa: E402
from B09_Estimation import B09_Estimation_Stc as st # noqa: E402
from B09_Estimation import B09_Estimation_Store as store # noqa: E402
from B09_Estimation.B09_Estimation_Bill_Excel import read_bill as read_excel # noqa: E402
from B09_Estimation.B09_Estimation_Calc_Bill import compute_estimation # noqa: E402
K = ROOT / "resources" / "knowledge" / "original" / "실무문서"
G3 = "2024년 간선임도(기번3-울진.대흥)(공통)/1. 설계원본/01 내역자료/2024년 간선임도사업(기번3)"
YW = "05. 동부청 영월/내역서/2024년 임도시설사업2차(산불진화_기번6)-영월"
BH = "04.산불 봉화/01. 내역서/{}2024년 산불진화임도 실시설계(기번41_현동)_ L=3.46km"
SG = "25년 산불진화임도(기번8)(울진소광)/"
NEW = "2025년 산불진화임도 신설사업(기번8 울진.금강송.소광.산29외)"
FILES = {
"기번3": K / f"{G3}.STC",
"영월": K / f"{YW}.STC",
"봉화": K / f"{BH.format('13-')}.STC",
"계류보전": K / "20250527 2025년 계류보전사업(기번1-영덕.병곡.영리.산214-1)(변경)/01.내역자료/2025년 계류보전사업(기번1 영덕.병곡.영.산214)(변경).STC", # noqa: E501
"소광": K / f"{SG}1. 설계원본/03-내역/22-2025년 산불진화임도사업(울진 금강송 소광).STC",
"소광다공구": K / f"{SG}{NEW}.STC",
"소광변경": K / f"{SG}변경 1차분-{NEW}.STC",
} # fmt: skip
EXCEL = {"기번3": K / f"{G3}.xlsx", "영월": K / f"{YW}.xlsx", "봉화": K / f"{BH.format('')}.xlsx"}
#: (항목 수, 직접비, 제외, 노임, 자재, 기계, 대가, 산근)
WANT = {
"기번3": (136, 710359136, 69474220, 12, 39, 25, 34, 46),
"영월": (108, 767148700, 75263810, 16, 55, 29, 23, 69),
"봉화": (77, 781283415, 142783280, 19, 67, 25, 45, 65),
"계류보전": (98, 93027484, 0, 7, 25, 18, 13, 35),
"소광": (46, 197135038, 53316160, 14, 35, 24, 25, 40),
"소광다공구": (46, 197135038, 53316160, 14, 41, 24, 37, 62),
"소광변경": (70, 173311386, 53316160, 14, 40, 31, 39, 54),
}
NOTES = {
"계류보전": ["변경 차수 — 96 줄 있음(당초만 읽음)"],
"소광다공구": ["다공구 — BOQ12 89 줄 있음(당초 첫 공구 BOQ11 만 읽음)"],
"소광변경": ["변경 차수 — 93 줄 있음(당초만 읽음)"],
}
EMPTY = {"materials": [], "bill": {"items": []}, "hopyo": {}}
@pytest.fixture(scope="module")
def data():
return {k: p.read_bytes() for k, p in FILES.items()}
@pytest.mark.parametrize("name", list(FILES))
def test_실무_stc_줄_수_합계_공사금액(data, name):
t = time.perf_counter()
bill = st.read_bill(FILES[name].name, data[name])
prices = st.read_prices(FILES[name].name, data[name])
hopyo = st.read_hopyo(FILES[name].name, data[name])
print(f"{name} 읽기 {time.perf_counter() - t:.3f} s")
got = (
sum(1 for x in bill["줄"] if not x["묶음"]),
bill["합계"]["직접"],
bill["합계"]["제외"],
*(len(prices[k]) for k in ("labor", "material", "machine")),
len(hopyo["대가"]),
len(hopyo["산근"]),
)
assert got == WANT[name]
assert bill["알림"] == NOTES.get(name, [])
# 직접비 = 파일 안 공사 금액(첫 공구)
prj = st.table(st._zip(data[name]), "PRJLST")
assert bill["합계"]["직접"] == prj[0]["TCOST1"]
assert all(r["price"] > 0 for r in prices["labor"])
assert all(h["합계"] == sum(h["단가"].values()) for h in hopyo["대가"] + hopyo["산근"])
@pytest.mark.parametrize("name", list(EXCEL))
def test_엑셀과_줄마다_같음(data, name):
mine = st.read_bill("a.STC", data[name])
excel = read_excel(EXCEL[name].name, EXCEL[name].read_bytes())
def key(line, numbered):
h = line["호표"] or {}
number = h.get("번") if numbered.get(line["행"]) else None
return (line["이름"], line["규격"], line["단위"], line["수량"], line["단가"],
line["금액"], h.get("코드"), number, line["제외"]) # fmt: skip
# 번 = 엑셀 비고에 있을 때만 견줌(영월 · 봉화 자재 = 비고에 번 없음)
has = [bool((x["호표"] or {}).get("번")) for x in excel["줄"]]
assert len(mine["줄"]) == len(has)
numbered = [{x["행"]: n for x, n in zip(got["줄"], has)} for got in (mine, excel)]
items = [[key(x, n) for x in got["줄"]] for got, n in zip((mine, excel), numbered)]
assert items[0] == items[1]
def tree(got):
names = {x["행"]: x["이름"] for x in got["줄"]}
return [(x["이름"], names.get(x["위"])) for x in got["줄"]]
assert tree(mine) == tree(excel)
assert mine["합계"] == excel["합계"]
assert mine["조달수수료율"] == excel["조달수수료율"] == 0.54
@pytest.mark.parametrize("name", ["기번3", "봉화"])
def test_단가표_엑셀과_같음(data, name):
mine = st.read_prices("a.STC", data[name])
excel = ip.read_prices(EXCEL[name].name, EXCEL[name].read_bytes())
for kind in ("labor", "material", "machine"):
keys = ("name", "spec", "unit", "price", "labor", "material", "expense")
rows = [sorted(tuple(r.get(k) for k in keys) for r in got[kind]) for got in (mine, excel)]
assert rows[0] == rows[1], kind
def test_자재_출처_칸(data):
stuff = st.read_prices("a.STC", data["봉화"])["material"]
pipe = next(x for x in stuff if x["name"] == "PVC파이프")
assert pipe["price"] == 3475
assert {"출처": "거래가격", "단가": 3475, "쪽": "793"} in pipe["sources"] # 엑셀 자재단가대비표
assert [s["출처"] for s in pipe["sources"]] == ["물가자료", "물가정보", "거래가격"]
def test_707_1_그릇에_부음(data):
got = st.read_bill("a.STC", data["봉화"])
rows = compute_estimation(EMPTY, {}, None, {})["bill"]["rows"]
made, report = imp.apply(got, {}, rows, "abcd1234")
made = store.clean_user(made)
bill = compute_estimation(EMPTY, made, None, {})["bill"]
assert report["실음"] == len(got["줄"])
assert bill["totals"]["direct"]["sum"] == 781283415
assert bill["totals"]["direct"]["blank"] == 0
assert imp.differences(got, report, bill["rows"]) == []
assert made["estimate"]["procurement_fee_pct"] == 0.54
def test_깨진_파일_표_없음_막힘():
with pytest.raises(st.StcError):
st.read_bill("a.STC", b"not a zip")
with pytest.raises(st.StcError):
st.read_bill("a.STC", b"PK\x03\x04 broken")
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w") as z:
z.writestr("COSTN.DBF", b"")
with pytest.raises(st.StcError, match="BOQ11"):
st.read_bill("a.STC", buf.getvalue())
def test_불러오기_길_stc_갈림(data):
"""이름 끝 .STC = 원본 읽기 · 호표 = 목록 수 + 코드로 가리킨 줄(이음 후보)."""
from B09_Estimation import B09_Estimation_Router_Import as route
name = "봉화.stc"
parsed = route._parse(name, data["봉화"])
assert parsed["꼴"] == "stc" and parsed["합계"]["직접"] == 781283415
assert route._hopyo(name, data["봉화"], parsed) == {"대가": 45, "산근": 65, "가리킴": 61}
assert route._hopyo("a.xlsx", data["봉화"], parsed) is None
with pytest.raises(route.HTTPException) as got:
route._parse("a.STC", b"not a zip")
assert got.value.status_code == 400
+21
View File
@@ -326,6 +326,27 @@ def test_복사는_못_읽는_참조를_같이_복사_본뜬(world):
assert e.value.status == 400
def test_복사_새_키_규격_열_수량도_따라감(world):
"""통합 점검 — 회사에 올리기 승인 · 회사로 복사: 개인 참조가 회사 새 키로 바뀌면 규격 열 수량 열쇠도."""
inner = _combo(world["personal"], [{"로직": TEN}], "안")
old = f"참조|personal|7/42|{inner}"
spec = {
"이름": "H",
"열": ["1.5", "2.0"],
"기본": "1.5",
"수량": {old: {"1.5": 1, "2.0": 1.3}, "고정|돌|가|㎡": {"1.5": 2, "2.0": 2.6}},
}
doc = {"담은로직": [{"참조": {**MINE, "키": inner}}], "규격": spec}
with store.at(world["company"]):
got = refs.carry(doc, ("personal", "7/42"))
new = f"참조|company|7|{got['담은로직'][0]['참조']['키']}"
assert got["규격"]["수량"] == {
new: {"1.5": 1, "2.0": 1.3},
"고정|돌|가|㎡": {"1.5": 2, "2.0": 2.6},
}
assert doc["규격"]["수량"][old] == {"1.5": 1, "2.0": 1.3} # 원본 그대로
def test_길_새로_복사_권한(world):
from fastapi import FastAPI
from fastapi.testclient import TestClient
+1 -1
View File
@@ -77,7 +77,7 @@ def test_사전조합_층_단추는_페이지를_다시_안_세움():
# 구조물 조합 층 단추는 그대로 해시 이동 한 벌
assert page.count('layerBar("') == 1
assert 'layerBar("구조물조합", tree.add, () => void main.openTable(layer)),' in page
assert "precombo: { scope: () => pre, bar: preBar }," in page
assert "precombo: {\n scope: () => pre,\n bar: preBar," in page # 706-1 more 덧붙음
def test_고름에_owner_권한_서고_길에_명시_owner():
@@ -300,5 +300,7 @@ def test_스냅샷이_없으면_알림(world):
def test_초기화는_templates_를_안_건드리고_복제는_통째로():
assert not any(tree.startswith("templates") for tree in initial._FILE_TREES)
tool = (config_system.PROJECT_ROOT / "db_management/tools_clone_project.py").read_text("utf-8")
tool = (config_system.PROJECT_ROOT / "common_util/common_util_project_copy.py").read_text(
"utf-8"
) # 복제 도구 본체(tools_clone_project = CLI 껍데기)
assert '"robocopy",' in tool and '"/E",' in tool # 프로젝트 폴더 전체(templates 포함)
+2 -1
View File
@@ -410,7 +410,8 @@ export function spaceBelow(el: HTMLElement): number {
for (let s = node.nextElementSibling; s; s = s.nextElementSibling) {
const h = s.getBoundingClientRect().height;
const m = getComputedStyle(s); // 203 형제 위아래 바깥 여백도(B09 안내 글 margin-top)
if (h) sum += h + gap + px(m.marginTop) + px(m.marginBottom);
const out = m.position === "fixed" || m.position === "absolute"; // 열린 창 가림막 · 알림 = 자리 안 씀
if (h && !out) sum += h + gap + px(m.marginTop) + px(m.marginBottom);
}
sum += px(own.marginBottom) + px(around.paddingBottom) + px(around.borderBottomWidth);
}