Merge remote-tracking branch 'origin/dev' into sub_laptop_7
This commit is contained in:
@@ -0,0 +1,344 @@
|
||||
"""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 원 안).
|
||||
이은 자동 줄 `was` = 되돌릴 것 {rows: [불러온 줄 · price 안 줄], old: 자동 줄 덮개,
|
||||
prev · next: 그때 같은 부모 앞 · 뒤 줄 키} · 수량 갈래 = 줄마다(`modes`) · 없으면 `mode`
|
||||
풀기(`unlink`) — `was` 대로 불러온 줄 · 자동 줄 제자리(맞춤 기억은 그대로 · 잊기 = 부르는 쪽).
|
||||
판(`board`) — 남은 불러온 줄 · 빈 자리(이을 수 있는 자동 줄) · 이은 줄.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
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,
|
||||
modes: dict[str, str] | None = None,
|
||||
) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||
"""(새 사용자 값, 알림 {이음, 묶음, 지움, 못함 [{key, 까닭}], 기억 {열쇠: 확정}}) —
|
||||
`rows` = 지금 셈 줄 ·
|
||||
`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}
|
||||
targets = _targets(rows)
|
||||
used = _linked(rows)
|
||||
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)
|
||||
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_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)
|
||||
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:
|
||||
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)
|
||||
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(
|
||||
{
|
||||
"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))
|
||||
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
|
||||
@@ -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 · 폴더째 지우면 길도 없음) — 같은 머리 · 같은 권한
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
"""B09 내역 줄 ↔ 우리 공종 잇기 길(PLAN 707-4) — 저장 안 함(화면 캐시로 → [저장] 때 서버 정본).
|
||||
|
||||
POST /api/projects/{id}/estimation/link/suggest {user?}
|
||||
→ {줄: 제안(`B09_Estimation_Bill_Link.suggest` · 구조물 묶음에 딸린 줄 뺌) ·
|
||||
저절로: 지난번 이음 · 하나 수 · 남은 · 자리 · 이은(`board`)}
|
||||
POST /api/projects/{id}/estimation/link/apply
|
||||
{pairs [{line, target, mode?}], auto, mode, counts?, user?}
|
||||
잇기(`link`) — `auto` = 지난번 이음 · 하나도 더함 →
|
||||
{user(새 캐시), 알림, payload(그 캐시로 셈)} ·
|
||||
이은 짝 = 맞춤 기억에 씀(회사 층 = 회사 관리자 · 아니면 개인 층)
|
||||
POST …/link/undo {target, user?} → {user, 알림, payload} — 잇기 풀기(맞춤 기억 그대로)
|
||||
POST …/link/forget {key, user?} → {잊음, 기억층, 남음} — 이 짝 잊기(불러온 줄 · 이은 줄 키 →
|
||||
맞춤 열쇠 · 쓸 수 있는 층에서 지움 · 남음 = 못 지운 위 층 기억)
|
||||
맞춤 기억 = 층 `실무자료/내역맞춤.json`(실무자료 맞춤 꼴) · 읽기 = 회사 + 개인(개인이 이김).
|
||||
끼워 넣기 = `B09_Estimation_Router`(같은 머리 · 같은 권한) · 화면 = `B09_Estimation_UI_Link`.
|
||||
"""
|
||||
|
||||
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)
|
||||
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):
|
||||
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)
|
||||
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")
|
||||
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 # 서로 부름 — 부를 때만
|
||||
|
||||
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)
|
||||
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, modes)
|
||||
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),
|
||||
}
|
||||
|
||||
|
||||
@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,
|
||||
"알림": {"풀음": 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: None})
|
||||
left = memo in await asyncio.to_thread(recalled, session)
|
||||
return {"잊음": memo, "기억층": layer, "남음": left}
|
||||
@@ -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(
|
||||
|
||||
@@ -254,8 +254,9 @@ async function post(path: string, body: Row): Promise<Row> {
|
||||
const COST_PART = { 노무비: "labor", 재료비: "material", 경비: "expense" } as const;
|
||||
|
||||
/** 붙인 줄 다시 잇기(705-2) — 후보 = 서버(M02 이음 짝) · 단가 = 이 공사 단가 책 · 바꿈 = 캐시 + 서버 셈 */
|
||||
function relinkFold(ctx: TabContext, cover: string): RelinkSource {
|
||||
const added = (): Row[] => ((ctx.user.daega_cover || {})[cover] || {})[ADD] || [];
|
||||
function relinkFold(ctx: TabContext, cover: string, from: Row): RelinkSource {
|
||||
let now = from; // 붙인 뒤 캐시(ctx.user 는 그리기 전 값) — 바꿀 때마다 새 값
|
||||
const added = (): Row[] => ((now.daega_cover || {})[cover] || {})[ADD] || [];
|
||||
const book = (kind: "노임" | "자재", key: string): number | null | undefined =>
|
||||
(ctx.payload.inputs?.[kind === "노임" ? "labor" : "materials"] || []).find(
|
||||
(r: Row) => r.key === key,
|
||||
@@ -302,9 +303,10 @@ function relinkFold(ctx: TabContext, cover: string): RelinkSource {
|
||||
if ("노임" in c) ln.link.labor = c.노임 ?? null;
|
||||
if ("자재" in c) ln.link.material = c.자재 ?? null;
|
||||
}
|
||||
const all: Row = JSON.parse(JSON.stringify(ctx.user.daega_cover || {}));
|
||||
const all: Row = JSON.parse(JSON.stringify(now.daega_cover || {}));
|
||||
all[cover] = { ...(all[cover] || {}), [ADD]: lines };
|
||||
const user = { ...ctx.user, daega_cover: all };
|
||||
const user = { ...now, daega_cover: all };
|
||||
now = user;
|
||||
Object.assign(ctx.payload, await post("calc", { user })); // 새 이음 키 = 이 공사 단가 책 수요
|
||||
ctx.setUser(user);
|
||||
return { back, note: `바뀐 줄 ${back.length} — [저장]으로 남김` };
|
||||
@@ -333,7 +335,7 @@ async function pasteFold(ctx: TabContext, cover: string, text: string): Promise<
|
||||
`고정 줄 ${got.줄} 붙임 · 엑셀 합계 ${won} · 이음 ${got.이음} · 후보 ${got.이음후보} — [저장]으로 남김 · 이 알림을 누르면 이 공사 단가로 다시 잇기`,
|
||||
"success",
|
||||
9000,
|
||||
() => void openRelink(relinkFold(ctx, cover)),
|
||||
() => void openRelink(relinkFold(ctx, cover, got.user)),
|
||||
);
|
||||
} catch (error) {
|
||||
showToast(`붙여넣기 실패 — ${error instanceof Error ? error.message : String(error)}`, "error");
|
||||
|
||||
Reference in New Issue
Block a user