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),
|
||||
}
|
||||
Reference in New Issue
Block a user