Merge remote-tracking branches 'origin/main_laptop_1' and 'origin/sub_laptop_8' into sub_laptop_5
This commit is contained in:
@@ -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 []:
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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 · 폴더째 지우면 길도 없음) — 같은 머리 · 같은 권한
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)
|
||||
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 건.
|
||||
@@ -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));
|
||||
|
||||
@@ -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) ─────────────────────────────
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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 포함)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user