Merge remote-tracking branch 'origin/sub_laptop_5' into sub_laptop_8
This commit is contained in:
@@ -0,0 +1,264 @@
|
||||
"""B09 내역 줄 ↔ 우리 공종 잇기(PLAN 707-4) — 불러온 줄(707-1 `hand:x…`)을
|
||||
자동 줄(earth · st · haul)에 이어 B08 수량이 붙게. 파일 · 저장 안 만짐(맞춤 기억 읽기 · 쓰기 =
|
||||
부르는 쪽이 층 폴더로).
|
||||
|
||||
제안(`suggest`) — 아직 안 이은 불러온 줄마다 후보 · 꼬리표:
|
||||
「지난번 이음」 = 맞춤 기억 `{품명|규격|단위: {확정: 줄 키, 이름}}`(실무자료 맞춤 꼴 ·
|
||||
규격이 달라도 품명 · 단위가 같고 짝이 하나면) · 키가 없는 공사 = 같은 이름 자동 줄
|
||||
「짝 표」 = 토공 이름 짝 표(`PAIRS` · 코드 상수) · 우리 이름(`Bill_Items.EARTH`) 그대로도
|
||||
`하나` = 한 줄 ↔ 한 줄 — 아니면(암 갈래 · 여러 줄 → 한 줄) 사용자 고름 ·
|
||||
`auto_pairs` = 기억 · 하나만
|
||||
잇기(`link`) — 항목 줄 ↔ 자동 항목 줄 = 707-1 붙임과 같은 꼴(불러온 줄 자리에 자동 줄 ·
|
||||
고정 단가 칸 · 연결 · 수량 갈래 take = 엑셀 수량 덮개 / price = B08(없으면 엑셀) ·
|
||||
불러온 줄 지움 = 두 번 셈 0)
|
||||
묶음 줄 ↔ 구조물 줄(`st:`) 하나(1 : N) — take = 구조물 줄이 묶음 자리 ·
|
||||
안 줄이 그 아래(저절로 묶음 · 금액 = 안 줄 합) / price = 안 줄 금액 합 ÷ 개수(기본 = B08 수량)
|
||||
= 1 단위 고정 단가 · 안 줄 지움
|
||||
(B08 수량 × 단가 · 끝수 비목마다 1 원 안).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from B09_Estimation.B09_Estimation_Bill_Import import MODES, PARTS, _flat, freeze
|
||||
from B09_Estimation.B09_Estimation_Bill_Items import EARTH
|
||||
from M02_MasterTemplete.M02_Hopyo_Match import line_key
|
||||
|
||||
MEMORY = "내역맞춤"
|
||||
TABLE, RECALL = "짝 표", "지난번 이음"
|
||||
HEADS = ("earth", "st", "haul")
|
||||
_TAIL = re.compile(r"^(.+?)\((.*)\)$")
|
||||
ROCK = ("절토(연암)", "암발파")
|
||||
#: 토공 이름 짝 표 — (경쟁사 이름, 규격에 든 글 | "", 우리 이름) · 우리 이름 둘 = 한 줄 ↔ 한 줄 아님
|
||||
#: (EST 암 = 연암 90 % · 보통암 10 % ↔ 우리 연암 · 경암) · 이름 괄호 글 = 규격으로도 봄
|
||||
PAIRS: tuple[tuple[str, str, tuple[str, ...]], ...] = (
|
||||
("수평규준틀", "", ("규준틀설치 종단규준틀",)),
|
||||
("비탈규준틀", "", ("규준틀설치 횡단규준틀",)),
|
||||
("뿌리뽑기", "", ("제근",)),
|
||||
("토사절취", "", ("절토(토사)",)),
|
||||
("암석절취", "", ROCK),
|
||||
("암절취", "", ROCK),
|
||||
("구조물터파기", "토사", ("구조물터파기(토사)",)),
|
||||
("구조물터파기", "암", ("구조물터파기(연암)",)),
|
||||
("터파기", "암", ("구조물터파기(연암)",)),
|
||||
("구조물되메우기", "", ("구조물 되메우기",)),
|
||||
("되메우기", "", ("구조물 되메우기",)),
|
||||
("측구터파기", "토사", ("측구파기(토사)",)),
|
||||
("측구터파기", "암", ("측구파기(연암)",)),
|
||||
("도자운반-토사", "", ("도자운반",)),
|
||||
("도자운반-암", "", ("도자운반",)),
|
||||
("흙운반D/Z19Ton", "", ("도자운반",)),
|
||||
("덤프운반-토사", "", ("덤프운반",)),
|
||||
("덤프운반-암", "", ("덤프운반",)),
|
||||
("흙운반D/P+B/H", "", ("덤프운반",)),
|
||||
("토사운반", "", ("덤프운반",)),
|
||||
("잔토처리", "", ("사토처리",)),
|
||||
("사토운반", "", ("사토처리",)),
|
||||
("사토무대처리", "", ("사토처리",)),
|
||||
("노면고르기및다짐", "", ("노면정리",)),
|
||||
)
|
||||
EARTH_KEY = {name: f"earth:{n}" for n, (_s, name, _u, _w) in enumerate(EARTH, 1)}
|
||||
|
||||
|
||||
def _texts(name: Any, spec: Any) -> list[tuple[str, str]]:
|
||||
"""(이름, 규격) 보기 — 그대로 · 이름 끝 괄호 글을 규격 앞에."""
|
||||
n, s = _flat(name), _flat(spec)
|
||||
hit = _TAIL.match(n)
|
||||
return [(n, s), *([(hit[1], hit[2] + s)] if hit else [])]
|
||||
|
||||
|
||||
def table(name: Any, spec: Any) -> tuple[str, ...]:
|
||||
"""짝 표 → 우리 토공 이름들(없음 = ())."""
|
||||
n, s = _texts(name, spec)[0]
|
||||
for ours in EARTH_KEY:
|
||||
if _flat(ours) in (n, n + s):
|
||||
return (ours,)
|
||||
for alias, part, targets in PAIRS:
|
||||
if any(t == _flat(alias) and _flat(part) in u for t, u in _texts(name, spec)):
|
||||
return targets
|
||||
return ()
|
||||
|
||||
|
||||
def key_of(row: dict[str, Any]) -> str:
|
||||
"""맞춤 기억 열쇠 = 품명 | 규격 | 단위(실무자료 맞춤과 같은 정규화)."""
|
||||
return line_key({"이름": row.get("name"), "규격": row.get("spec"), "단위": row.get("unit")})
|
||||
|
||||
|
||||
def _loose(key: str) -> str:
|
||||
name, _spec, unit = (key.split("|") + ["", ""])[:3]
|
||||
return f"{name}|{unit}"
|
||||
|
||||
|
||||
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]
|
||||
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
|
||||
|
||||
|
||||
def _targets(rows: list[dict[str, Any]]) -> dict[str, dict[str, Any]]:
|
||||
"""이을 수 있는 자동 줄 {키: 줄} — earth · st · haul(항목이든 저절로 묶음이든)."""
|
||||
return {r["key"]: r for r in rows if r.get("auto") and r["key"].split(":")[0] in HEADS}
|
||||
|
||||
|
||||
def _linked(rows: list[dict[str, Any]]) -> set[str]:
|
||||
"""이미 이은 자동 줄 — 불러온 수량(비교 칸) 있음 · 불러온 묶음 아래 · 안 줄을 거느림."""
|
||||
parents = {r.get("parent") for r in rows}
|
||||
return {
|
||||
k
|
||||
for k, r in _targets(rows).items()
|
||||
if r.get("src_qty") is not None
|
||||
or str(r.get("parent") or "").startswith("hand:x")
|
||||
or (k.startswith("st:") and k in parents)
|
||||
}
|
||||
|
||||
|
||||
def _resolve(got: dict[str, Any], targets: dict[str, dict[str, Any]]) -> str | None:
|
||||
key = got.get("확정")
|
||||
if key in targets:
|
||||
return key
|
||||
head = str(key or "").split(":")[0]
|
||||
same = [
|
||||
k for k, r in targets.items() if k.startswith(f"{head}:") and r["name"] == got.get("이름")
|
||||
]
|
||||
return same[0] if len(same) == 1 else None
|
||||
|
||||
|
||||
def suggest(rows: list[dict[str, Any]], memory: dict[str, dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""[{key, kind, name, spec, unit, 후보 [{key, name}], 꼬리표, 하나, 까닭}] —
|
||||
후보 있는 불러온 줄만."""
|
||||
targets = _targets(rows)
|
||||
free = {k: r for k, r in targets.items() if k not in _linked(rows)}
|
||||
out = []
|
||||
for row in rows:
|
||||
if not row["key"].startswith("hand:x"):
|
||||
continue
|
||||
got = recall(memory, row)
|
||||
hit = _resolve(got, free) if got else None
|
||||
if hit:
|
||||
keys, tag = [hit], RECALL
|
||||
elif row["kind"] == "item":
|
||||
keys, tag = [EARTH_KEY[n] for n in table(row["name"], row["spec"])], TABLE
|
||||
keys = [k for k in keys if k in free]
|
||||
else:
|
||||
keys = []
|
||||
if not keys:
|
||||
continue
|
||||
one = len(keys) == 1
|
||||
out.append(
|
||||
{
|
||||
**{f: row.get(f) for f in ("key", "kind", "name", "spec", "unit")},
|
||||
"후보": [{"key": k, "name": free[k]["name"]} for k in keys],
|
||||
"꼬리표": tag,
|
||||
"하나": one,
|
||||
"까닭": "" if one else "한 줄 ↔ 한 줄 아님 — 고름",
|
||||
}
|
||||
)
|
||||
seen: dict[str, int] = {}
|
||||
for s in out:
|
||||
if s["하나"]:
|
||||
seen[s["후보"][0]["key"]] = seen.get(s["후보"][0]["key"], 0) + 1
|
||||
for s in out: # 여러 줄 → 한 자동 줄(도자운반-토사 · -암) = 고름
|
||||
if s["하나"] and seen[s["후보"][0]["key"]] > 1:
|
||||
s.update({"하나": False, "까닭": "여러 줄 → 한 줄 — 고름"})
|
||||
return out
|
||||
|
||||
|
||||
def auto_pairs(suggestions: list[dict[str, Any]]) -> list[tuple[str, str]]:
|
||||
"""저절로 이을 짝 [(불러온 줄 키, 자동 줄 키)] — 지난번 이음 · 하나만."""
|
||||
return [
|
||||
(s["key"], s["후보"][0]["key"]) for s in suggestions if s["꼬리표"] == RECALL and s["하나"]
|
||||
]
|
||||
|
||||
|
||||
def _descendants(entries: list[dict[str, Any]], key: str) -> set[str]:
|
||||
out, grow = set(), {key}
|
||||
while grow:
|
||||
grow = {e["key"] for e in entries if e.get("parent") in grow} - out
|
||||
out |= grow
|
||||
return out
|
||||
|
||||
|
||||
def _place(bill: dict[str, Any], at: int, entry: dict[str, Any], parent: Any) -> None:
|
||||
entry.pop("parent", None)
|
||||
if parent:
|
||||
entry["parent"] = parent
|
||||
bill["rows"].insert(at, entry)
|
||||
if entry["key"] in bill["gone"]:
|
||||
bill["gone"].remove(entry["key"])
|
||||
|
||||
|
||||
def link(
|
||||
user: dict[str, Any],
|
||||
rows: list[dict[str, Any]],
|
||||
pairs: list[tuple[str, str]],
|
||||
mode: str = "take",
|
||||
counts: dict[str, float] | None = None,
|
||||
) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||
"""(새 사용자 값, 알림 {이음, 묶음, 지움, 못함 [{key, 까닭}], 기억 {열쇠: 확정}}) —
|
||||
`rows` = 지금 셈 줄 ·
|
||||
`counts` = 묶음 개수 {불러온 묶음 키: 수}(price 갈래 · 없으면 B08 수량)."""
|
||||
if mode not in MODES:
|
||||
raise ValueError(f"수량 갈래는 {', '.join(MODES)} 중 하나")
|
||||
counts = counts or {}
|
||||
by_key = {r["key"]: r for r in rows}
|
||||
targets = _targets(rows)
|
||||
used = _linked(rows)
|
||||
bill = freeze(user, rows)
|
||||
report: dict[str, Any] = {"이음": 0, "묶음": 0, "지움": 0, "못함": [], "기억": {}}
|
||||
for line, target in pairs:
|
||||
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)
|
||||
why = (
|
||||
"불러온 줄이 아님" if at < 0 or not line.startswith("hand:x") or row is None
|
||||
else "이을 수 없는 줄" if goal is None
|
||||
else "이미 이은 줄" if target in used
|
||||
else "묶음 ↔ 구조물 줄만" if (row["kind"] != "item") != target.startswith("st:")
|
||||
else ""
|
||||
) # fmt: skip
|
||||
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)
|
||||
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")}
|
||||
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:
|
||||
auto["qty"] = hand.get("qty")
|
||||
report["이음"] += 1
|
||||
elif mode == "take":
|
||||
for e in entries: # 안 줄 = 구조물 줄 아래 · 금액 = 안 줄 합
|
||||
if e.get("parent") == line:
|
||||
e["parent"] = target
|
||||
report["묶음"] += 1
|
||||
else:
|
||||
inner = _descendants(entries, line)
|
||||
items = [by_key[k] for k in inner if by_key.get(k, {}).get("kind") == "item"]
|
||||
auto.update(
|
||||
{
|
||||
"ref": None,
|
||||
"price": {p: sum(r.get(p) or 0 for r in items) / count for p in PARTS},
|
||||
"src_qty": count,
|
||||
}
|
||||
)
|
||||
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))
|
||||
_place(bill, at, auto, hand.get("parent"))
|
||||
used.add(target)
|
||||
report["기억"][key_of(row)] = {"확정": target, "이름": goal["name"]}
|
||||
return {**user, "bill": bill}, report
|
||||
@@ -0,0 +1,125 @@
|
||||
"""B09 내역 줄 ↔ 우리 공종 잇기 길(PLAN 707-4) — 저장 안 함(화면 캐시로 → [저장] 때 서버 정본).
|
||||
|
||||
POST /api/projects/{id}/estimation/link/suggest {user?}
|
||||
→ {줄: 제안(`B09_Estimation_Bill_Link.suggest`) · 저절로: 지난번 이음 · 하나 수}
|
||||
POST /api/projects/{id}/estimation/link/apply
|
||||
{pairs [{line, target}], auto, mode, counts?, user?}
|
||||
잇기(`link`) — `auto` = 지난번 이음 · 하나도 더함 →
|
||||
{user(새 캐시), 알림, payload(그 캐시로 셈)} ·
|
||||
이은 짝 = 맞춤 기억에 씀(회사 층 = 회사 관리자 · 아니면 개인 층)
|
||||
맞춤 기억 = 층 `실무자료/내역맞춤.json`(실무자료 맞춤 꼴) · 읽기 = 회사 + 개인(개인이 이김).
|
||||
끼워 넣기 = `B09_Estimation_Router`(같은 머리 · 같은 권한) — 화면 고리 일감 몫.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from B08_Quantity.B08_Quantity_Router_Hydro import _project_root
|
||||
from B09_Estimation import B09_Estimation_Bill_Link as linker
|
||||
from B09_Estimation import B09_Estimation_Store as store
|
||||
from common_util.common_util_auth import verify_session
|
||||
from M02_MasterTemplete import M02_Hopyo_Store as hopyo_store
|
||||
from M02_MasterTemplete import M02_Layer_Access as access
|
||||
|
||||
router = APIRouter()
|
||||
PATH = "/{project_id}/estimation/link"
|
||||
LAYERS = ("company", "personal")
|
||||
|
||||
|
||||
def _folders(session: dict[str, Any]) -> dict[str, tuple[Path, bool]]:
|
||||
"""{층: (폴더, 쓰기)} — 회사 없음 등 = 그 층 건너뜀."""
|
||||
out = {}
|
||||
for layer in LAYERS:
|
||||
try:
|
||||
got = access.grant(session, layer)
|
||||
out[layer] = (access.folder(got, create=True), got.write)
|
||||
except HTTPException:
|
||||
continue
|
||||
return out
|
||||
|
||||
|
||||
def recalled(session: dict[str, Any]) -> dict[str, dict[str, Any]]:
|
||||
"""맞춤 기억 — 회사 위에 개인을 덮음."""
|
||||
out: dict[str, dict[str, Any]] = {}
|
||||
for folder, _write in _folders(session).values():
|
||||
out.update(hopyo_store.memory(folder, linker.MEMORY))
|
||||
return out
|
||||
|
||||
|
||||
def keep(session: dict[str, Any], entries: dict[str, dict[str, Any]]) -> str | None:
|
||||
"""이은 짝 → 회사 층(쓸 수 있으면) · 아니면 개인 층 → 쓴 층(없음 = None)."""
|
||||
folders = _folders(session)
|
||||
layer = next((n for n in LAYERS if n in folders and folders[n][1]), None)
|
||||
if entries and layer:
|
||||
hopyo_store.remember(folders[layer][0], entries, linker.MEMORY)
|
||||
return layer if entries else None
|
||||
|
||||
|
||||
def _draft(raw: Any) -> dict[str, Any] | None:
|
||||
return store.clean_user(raw) if isinstance(raw, dict) else None
|
||||
|
||||
|
||||
class SuggestBody(BaseModel):
|
||||
user: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class Pair(BaseModel):
|
||||
line: str = Field(min_length=1, max_length=80)
|
||||
target: str = Field(min_length=1, max_length=200)
|
||||
|
||||
|
||||
class ApplyBody(BaseModel):
|
||||
pairs: list[Pair] = Field(default_factory=list, max_length=2000)
|
||||
auto: bool = False
|
||||
mode: str = "take"
|
||||
counts: dict[str, float] | None = None
|
||||
user: dict[str, Any] | None = None
|
||||
|
||||
|
||||
async def _rows(project_id: UUID, root: Path, user: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
from B09_Estimation.B09_Estimation_Router import payload # 서로 부름 — 부를 때만
|
||||
|
||||
return (await payload(project_id, root, user, True))["sheets"]["bill"]["rows"]
|
||||
|
||||
|
||||
@router.post(PATH + "/suggest")
|
||||
async def suggest(
|
||||
project_id: UUID, body: SuggestBody, 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)
|
||||
lines = linker.suggest(rows, await asyncio.to_thread(recalled, session))
|
||||
return {"줄": lines, "저절로": len(linker.auto_pairs(lines))}
|
||||
|
||||
|
||||
@router.post(PATH + "/apply")
|
||||
async def apply(
|
||||
project_id: UUID, body: ApplyBody, session: dict[str, Any] = Depends(verify_session)
|
||||
) -> dict[str, Any]:
|
||||
from B09_Estimation.B09_Estimation_Router import payload # 서로 부름 — 부를 때만
|
||||
|
||||
if body.mode not in linker.MODES:
|
||||
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)
|
||||
rows = await _rows(project_id, root, draft)
|
||||
pairs = [(p.line, p.target) for p in body.pairs]
|
||||
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 = store.clean_user(made)
|
||||
layer = await asyncio.to_thread(keep, session, report.pop("기억"))
|
||||
return {
|
||||
"user": made,
|
||||
"알림": {**report, "기억층": layer},
|
||||
"payload": await payload(project_id, root, made, True),
|
||||
}
|
||||
@@ -120,21 +120,24 @@ def delete(folder: str | Path, key: str) -> None:
|
||||
|
||||
|
||||
# ── 맞춤 기억 ─────────────────────────────────────────────────────────
|
||||
def memory(folder: str | Path) -> dict[str, dict[str, Any]]:
|
||||
"""층 맞춤 기억 `{줄 열쇠: 확정}` — 없으면 빈 것."""
|
||||
doc = _load(_dir(folder) / f"{MEMORY}.json")
|
||||
def memory(folder: str | Path, name: str = MEMORY) -> dict[str, dict[str, Any]]:
|
||||
"""층 맞춤 기억 `{줄 열쇠: 확정}` — 없으면 빈 것 ·
|
||||
`name` = 기억 파일(내역 잇기 707-4 = 「내역맞춤」)."""
|
||||
doc = _load(_dir(folder) / f"{name}.json")
|
||||
got = doc.get("줄") if isinstance(doc, dict) else None
|
||||
return got if isinstance(got, dict) else {}
|
||||
|
||||
|
||||
def remember(folder: str | Path, entries: dict[str, dict[str, Any] | None]) -> dict[str, Any]:
|
||||
def remember(
|
||||
folder: str | Path, entries: dict[str, dict[str, Any] | None], name: str = MEMORY
|
||||
) -> dict[str, Any]:
|
||||
"""기억 더함 · 바꿈(값 None = 지움) → 새 기억 전부."""
|
||||
with _LOCK:
|
||||
got = memory(folder)
|
||||
got = memory(folder, name)
|
||||
for key, value in entries.items():
|
||||
if value is None:
|
||||
got.pop(key, None)
|
||||
else:
|
||||
got[key] = value
|
||||
atomic_write_json(_dir(folder) / f"{MEMORY}.json", {"줄": got})
|
||||
atomic_write_json(_dir(folder) / f"{name}.json", {"줄": got})
|
||||
return got
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
"""707-4 내역 줄 ↔ 우리 공종 잇기 — 짝 표 · 맞춤 기억 · 묶음 통째 잇기(`Bill_Link`) ·
|
||||
기억 층(`Router_Link`).
|
||||
|
||||
- 짝 표 = 우리 토공 줄(`Bill_Items.EARTH`) 모두 · 실무 이름(EST 거창 · STmate 봉화) → 우리 줄 ·
|
||||
암 갈래(EST 연암 · 보통암) = 후보 둘 · 하나 아님 · 여러 줄 → 한 줄 = 하나 아님
|
||||
- 맞춤 기억 = 거창 엑셀에서 이은 짝 → 같은 회사 장수 · 진안 .estx 저절로 이음 · 두 번 셈 0
|
||||
- 수량 갈래 take = 엑셀 수량 덮개 / price = B08 · 엑셀 = 비교 칸
|
||||
- 묶음 ↔ 구조물 줄 — take = 안 줄이 구조물 줄 아래 / price = 1 단위 고정 단가 · 안 줄 지움 ·
|
||||
금액 = 엑셀
|
||||
- 못 잇는 짝(남의 줄 · 자재 줄 · 이미 이음 · 꼴 다름) · 기억 층 = 회사 관리자 회사 · 아니면 개인
|
||||
- 셈 짝(`Calc_Bill` py · ts) 안 바뀜 — 거울은 test_707_1 · test_145_3 그대로
|
||||
"""
|
||||
|
||||
import sys
|
||||
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_Bill_Link as lk # noqa: E402
|
||||
from B09_Estimation import B09_Estimation_Estx as ex # noqa: E402
|
||||
from B09_Estimation import B09_Estimation_Router_Link as route # noqa: E402
|
||||
from B09_Estimation import B09_Estimation_Store as store # noqa: E402
|
||||
from B09_Estimation.B09_Estimation_Bill_Excel import read_bill # noqa: E402
|
||||
from B09_Estimation.B09_Estimation_Bill_Items import EARTH, earth_items # noqa: E402
|
||||
from B09_Estimation.B09_Estimation_Calc_Bill import compute_estimation # noqa: E402
|
||||
from M02_MasterTemplete import M02_Hopyo_Store as hopyo_store # noqa: E402
|
||||
|
||||
K = ROOT / "resources" / "knowledge" / "original" / "실무문서"
|
||||
GEO = (
|
||||
K / "01.산불거창(거창북상병곡)/01.내역서/00.25년_산불_거창(거창,북상,병곡,산8외)내역+수량.xlsx"
|
||||
)
|
||||
BONG = K / "04.산불 봉화/01. 내역서/2024년 산불진화임도 실시설계(기번41_현동)_ L=3.46km.xlsx"
|
||||
ESTX = {
|
||||
"장수": K / "02.산불 장수/01.내역서/00.단가변경_26년_산불_무주(장수,장수,덕산,산54-1).estx",
|
||||
"진안": K / "03.산불진안/01.내역서/00.단가변경_26년_산불_무주(진안,백운,백암,산1-2).estx",
|
||||
}
|
||||
ST = "st:pipe:box|1"
|
||||
ITEMS = [{**i, "qty": 100} for i in earth_items(None)] + [
|
||||
{"key": ST, "section": "2.2", "name": "BOX 암거", "unit": "m", "qty": 10,
|
||||
"ref": {"tab": "sg_list", "key": "X"}},
|
||||
] # fmt: skip
|
||||
INPUTS = {"materials": [], "bill": {"items": ITEMS}, "hopyo": {"X": {
|
||||
"labor": 100, "material": 0, "expense": 0}}} # fmt: skip
|
||||
|
||||
|
||||
def _bill(user):
|
||||
return compute_estimation(INPUTS, user, None, {})["bill"]
|
||||
|
||||
|
||||
def _load(parsed):
|
||||
"""707-1 먼저 싣기(붙임 없음) → (사용자 값, 셈 줄)."""
|
||||
user, _ = imp.apply(parsed, {}, _bill({})["rows"], "abcd1234", attach=False)
|
||||
user = store.clean_user(user)
|
||||
return user, _bill(user)["rows"]
|
||||
|
||||
|
||||
def _link(user, rows, pairs, mode="take", **kw):
|
||||
made, report = lk.link(user, rows, pairs, mode, **kw)
|
||||
made = store.clean_user(made)
|
||||
return made, report, _bill(made)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def geo():
|
||||
return read_bill(GEO.name, GEO.read_bytes())
|
||||
|
||||
|
||||
def _pick(suggestions):
|
||||
"""사용자 고름 꼴 — 보통암 = 둘째(암발파) · 그 밖 = 첫 후보 · 자동 줄 하나에 한 줄."""
|
||||
out, used = [], set()
|
||||
for s in suggestions:
|
||||
keys = [c["key"] for c in s["후보"]]
|
||||
goal = keys[1] if len(keys) > 1 and "보통암" in s["spec"] else keys[0]
|
||||
if goal not in used:
|
||||
out.append((s["key"], goal))
|
||||
used.add(goal)
|
||||
return out
|
||||
|
||||
|
||||
def test_짝_표_우리_토공_줄_모두():
|
||||
names = {name for _s, name, _u, _w in EARTH}
|
||||
assert len(names) == len(EARTH) == 17
|
||||
assert all(lk.table(n, "") == (n,) for n in names) # 우리 이름 그대로
|
||||
assert all(set(t) <= names for _a, _p, t in lk.PAIRS) # 짝 표 = 있는 줄만
|
||||
assert set(lk.EARTH_KEY.values()) == {f"earth:{n}" for n in range(1, 18)}
|
||||
|
||||
|
||||
def test_짝_표_실무_이름_암_갈래(geo):
|
||||
user, rows = _load(geo)
|
||||
got = {(s["name"], s["spec"]): s for s in lk.suggest(rows, {})}
|
||||
one = {k: s["후보"][0]["name"] for k, s in got.items() if s["하나"]}
|
||||
assert one == {
|
||||
("토사절취", "백호0.7㎥"): "절토(토사)",
|
||||
("측구터파기", "토사"): "측구파기(토사)",
|
||||
("성토사면다짐", "B/H0.7+유압식콤팩터"): "성토사면다짐",
|
||||
("토사운반", "굴삭기+덤프+굴삭기"): "덤프운반",
|
||||
("수평규준틀", ""): "규준틀설치 종단규준틀",
|
||||
("비탈규준틀", ""): "규준틀설치 횡단규준틀",
|
||||
("구조물 터파기(토사)", "육샹토사(0~2m,굴삭기0.2)"): "구조물터파기(토사)",
|
||||
("터파기(암)", ""): "구조물터파기(연암)",
|
||||
("되메우기", "백호0.7㎥(100%)"): "구조물 되메우기",
|
||||
}
|
||||
rock = got[("암석절취", "보통암")]
|
||||
assert [c["name"] for c in rock["후보"]] == ["절토(연암)", "암발파"]
|
||||
assert not rock["하나"] and rock["까닭"] == "한 줄 ↔ 한 줄 아님 — 고름"
|
||||
side = got[("측구터파기", "연암")] # 연암 · 보통암 둘 → 측구파기(연암) 하나
|
||||
assert not side["하나"] and side["까닭"] == "여러 줄 → 한 줄 — 고름"
|
||||
assert all(s["꼬리표"] == lk.TABLE for s in got.values())
|
||||
bong = lk.suggest(_load(read_bill(BONG.name, BONG.read_bytes()))[1], {})
|
||||
named = {s["name"]: s["후보"][0]["name"] for s in bong if s["하나"]}
|
||||
assert named["토사절취(흙깎기)"] == "절토(토사)"
|
||||
assert named["구조물터파기(육상암0~1m)"] == "구조물터파기(연암)"
|
||||
assert named["비탈규준틀(@60M)"] == "규준틀설치 횡단규준틀"
|
||||
|
||||
|
||||
def test_맞춤_기억_두_번째_공사_저절로(geo, tmp_path):
|
||||
user, rows = _load(geo)
|
||||
made, report, bill = _link(user, rows, _pick(lk.suggest(rows, {})))
|
||||
assert report["이음"] == 12 and report["못함"] == []
|
||||
hopyo_store.remember(tmp_path, report["기억"], lk.MEMORY)
|
||||
assert hopyo_store.memory(tmp_path) == {} # 실무자료 맞춤과 다른 파일
|
||||
memory = hopyo_store.memory(tmp_path, lk.MEMORY)
|
||||
assert memory["암석절취|보통암|m3"]["확정"] == "earth:6"
|
||||
for name, path in ESTX.items():
|
||||
parsed = ex.read_bill(path.name, path.read_bytes())
|
||||
user, rows = _load(parsed)
|
||||
lines = lk.suggest(rows, memory)
|
||||
assert sum(s["꼬리표"] == lk.RECALL for s in lines) == 12, name
|
||||
pairs = lk.auto_pairs(lines)
|
||||
assert len(pairs) == 11, name # 측구 연암 = 보통암 줄과 한 자동 줄 → 고름
|
||||
made, report, bill = _link(user, rows, pairs)
|
||||
assert report["이음"] == 11 and report["못함"] == []
|
||||
keys = [r["key"] for r in bill["rows"]]
|
||||
assert len(keys) == len(set(keys)) # 두 번 셈 0 — 줄 하나씩
|
||||
assert not {k for k, _t in pairs} & set(keys) # 이은 불러온 줄 = 지움
|
||||
# take = 엑셀 수량 · 고정 단가 → 직접비(아는 금액) = 파일 합 + 안 이은 구조물 줄
|
||||
assert bill["totals"]["direct"]["sum"] == parsed["합계"]["직접"] + 1000, name
|
||||
assert lk.auto_pairs(lk.suggest(bill["rows"], memory)) == [] # 다시 = 이음 없음
|
||||
|
||||
|
||||
def test_수량_갈래_가_나(geo):
|
||||
user, rows = _load(geo)
|
||||
line = next(r for r in rows if r["name"] == "토사절취")
|
||||
for mode, qty in (("take", 7641), ("price", 100)):
|
||||
made, report, bill = _link(user, rows, [(line["key"], "earth:4")], mode)
|
||||
cut = next(r for r in bill["rows"] if r["key"] == "earth:4")
|
||||
assert (cut["qty"], cut["src_qty"], cut["fixed"]) == (qty, 7641, True)
|
||||
assert cut["parent"] == line["parent"] # 불러온 자리
|
||||
assert cut["price_sum"] == line["price_sum"]
|
||||
assert line["key"] not in {r["key"] for r in bill["rows"]}
|
||||
|
||||
|
||||
def test_묶음_통째_잇기(geo):
|
||||
user, rows = _load(geo)
|
||||
base = _bill(user)["totals"]["direct"]["sum"]
|
||||
group = next(r for r in rows if r["name"] == "콘크리트구조물")
|
||||
inner = [r["key"] for r in rows if r["parent"] == group["key"]]
|
||||
made, report, bill = _link(user, rows, [(group["key"], ST)], "take")
|
||||
got = {r["key"]: r for r in bill["rows"]}
|
||||
assert report["묶음"] == 1 and group["key"] not in got
|
||||
assert got[ST]["kind"] == "group" and got[ST]["sum"] == group["sum"]
|
||||
assert all(got[k]["parent"] == ST for k in inner) # 안 줄 = 딸려 감(1 : N)
|
||||
assert bill["totals"]["direct"]["sum"] == base - 1000 # 구조물 줄 제 금액 = 안 줄 합으로
|
||||
assert report["기억"] == {"콘크리트구조물||": {"확정": ST, "이름": "BOX 암거"}}
|
||||
made, report, bill = _link(user, rows, [(group["key"], ST)], "price")
|
||||
got = {r["key"]: r for r in bill["rows"]}
|
||||
assert report["지움"] == len(inner) and not set(inner) & set(got)
|
||||
assert got[ST]["kind"] == "item" and (got[ST]["qty"], got[ST]["src_qty"]) == (10, 10)
|
||||
assert abs(got[ST]["sum"] - group["sum"]) <= 3 # B08 수량 × 1 단위 단가 · 끝수
|
||||
made, report, bill = _link(user, rows, [(group["key"], ST)], "price", counts={group["key"]: 5})
|
||||
assert next(r for r in bill["rows"] if r["key"] == ST)["sum"] == pytest.approx(
|
||||
group["sum"] * 2, abs=3
|
||||
) # 경쟁사 5 개 · B08 10 개 = 두 배
|
||||
memory = {"콘크리트구조물||": {"확정": "st:other", "이름": "BOX 암거"}}
|
||||
assert lk.auto_pairs(lk.suggest(rows, memory)) == [(group["key"], ST)] # 키 없음 = 같은 이름
|
||||
|
||||
|
||||
def test_못_잇는_짝(geo):
|
||||
user, rows = _load(geo)
|
||||
line = next(r for r in rows if r["name"] == "토사절취")
|
||||
group = next(r for r in rows if r["name"] == "콘크리트구조물")
|
||||
pairs = [
|
||||
("earth:3", "earth:4"),
|
||||
(line["key"], "mat:x"),
|
||||
(line["key"], ST),
|
||||
(group["key"], "earth:4"),
|
||||
(line["key"], "earth:4"),
|
||||
(next(r for r in rows if r["name"] == "수평규준틀")["key"], "earth:4"),
|
||||
]
|
||||
_made, report, _bill = _link(user, rows, pairs)
|
||||
assert [m["까닭"] for m in report["못함"]] == [
|
||||
"불러온 줄이 아님",
|
||||
"이을 수 없는 줄",
|
||||
"묶음 ↔ 구조물 줄만",
|
||||
"묶음 ↔ 구조물 줄만",
|
||||
"이미 이은 줄",
|
||||
]
|
||||
assert report["이음"] == 1
|
||||
with pytest.raises(ValueError):
|
||||
lk.link(user, rows, [], "both")
|
||||
assert lk.link(user, rows, [], "take")[1]["이음"] == 0
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"role, layer", [("USER", "personal"), ("ADMIN", "company")], ids=["일반", "회사관리자"]
|
||||
)
|
||||
def test_기억_층(tmp_path, monkeypatch, role, layer):
|
||||
monkeypatch.setattr(route.access, "folder", lambda got, create=False: tmp_path / got.layer)
|
||||
session = {"company_id": 7, "user_id": 3, "role": role}
|
||||
assert route.keep(session, {"a||": {"확정": "earth:4", "이름": "절토(토사)"}}) == layer
|
||||
assert route.keep(session, {}) is None
|
||||
hopyo_store.remember(
|
||||
tmp_path / "company", {"a||": {"확정": "earth:5"}, "b||": {"확정": "x"}}, lk.MEMORY
|
||||
)
|
||||
got = route.recalled(session)
|
||||
assert got["a||"]["확정"] == ("earth:4" if layer == "personal" else "earth:5")
|
||||
assert got["b||"]["확정"] == "x"
|
||||
assert route.recalled({"user_id": 3, "role": role}) == {} # 회사 없음 = 층 없음
|
||||
Reference in New Issue
Block a user