Merge remote-tracking branch 'origin/sub_laptop_3' into main_laptop_1

This commit is contained in:
2026-10-16 21:29:22 +09:00
11 changed files with 504 additions and 51 deletions
+34 -4
View File
@@ -12,7 +12,9 @@
로직 = {로직, 별칭 = 엑셀 품명 40 자, 메모 「실무 0.14㎥ × 1 · 규격」, 입력값,
수량 {손 = 엑셀 수량 × 배}} · 한 줄 로직 여럿 = 같은 별칭 · 같은 로직 둘째부터 = 한 줄
사전조합으로 감싸 참조(같은 로직 두 번 금지) ·
자재 = {코드, 수량 {손}, 별도계상}(같은 자재 = 수량 더함) · 호표 = 확인된 사전조합 참조.
자재 = {코드, 수량 {손}, 세 칸}(같은 자재 = 수량 더함 · 세 칸 = `Material.marks`
별도계상 · 자재구분 · 운반비 · 358-2) · 로직 자재와 같은 품목 = 사용자 자재에서 빼고
세 칸만 그 로직 `자재표시` 로(빠진 줄에 까닭) · 호표 = 확인된 사전조합 참조.
같은 본뜬 사전조합이 있으면 확인한 내용으로 덮음(`다시씀`) · 확인한 줄은 별칭 장부에 남음
(`M02_Hopyo_Ledger` · 호표 참조 줄 뺌).
빠진 줄(확인 안 함 · 뺌 · %) = 엑셀 금액과 함께 보고 · 만든 값 ↔ 엑셀 호표 단가(참고).
@@ -29,6 +31,8 @@ from typing import Any
from M01_MasterData import M01_MasterData_Store as m01
from M02_MasterTemplete import M02_Hopyo_Ledger as ledger
from M02_MasterTemplete import M02_Hopyo_Match as hopyo_match
from M02_MasterTemplete import M02_Hopyo_Material as hmat
from M02_MasterTemplete import M02_Hopyo_Store as hopyo_store
from M02_MasterTemplete import M02_Labor_Doc as labor
from M02_MasterTemplete import M02_Labor_Ref as refs
@@ -39,6 +43,7 @@ LINES_MAX = 500
UNCHECKED = "확인 안 함"
PERCENT = "% 줄 — 우리 로직 덧줄이 셈"
WHOLE = "호표 통째로 대신"
TWICE = "로직 자재와 같은 품목 — 세 칸만 로직 자재표시로(수량 = 로직 셈)"
def mark(key: str, no: int, at: int | None = None) -> str:
@@ -131,6 +136,7 @@ class Builder:
self.source = refs.where(source)
self.done = confirmed(folder, self.source)
self.known = ledger._known()
self.mat_names = {x["자재"]: x["이름"] for x in hopyo_match.targets()["자재"]}
self.hopyo = next((h for h in self.doc.get("호표") or [] if int(h["번"]) == self.no), None)
if self.hopyo is None:
raise _bad(f"없는 호표 「제{no}호표」", 404)
@@ -197,20 +203,26 @@ class Builder:
def build(self, checked: dict[int, dict[str, Any]], whole: list[dict[str, Any]]) -> dict:
stamp = datetime.now().isoformat(timespec="seconds")
hopyo, file = self.hopyo, self.doc.get("파일") or ""
lines, mats, missing, used = [], {}, [], set()
lines, mats, missing, used, mat_lines = [], {}, [], set(), {}
def put(line: dict[str, Any], one: dict[str, Any], alias: str | None) -> None:
qty = Decimal(str(line.get("수량") or 0)) * Decimal(str(one["배"]))
memo = _memo(line, one)
if "자재" in one:
apart = "별도계상" if line.get("갈래") == "별도계상" else "직접계산"
have = mats.get(one["자재"])
hand = qty + Decimal(str(have["수량"]["손"])) if have else qty
name = self.mat_names.get(one["자재"], "")
mats[one["자재"]] = {
"코드": one["자재"],
"수량": {"손": _num(hand)},
"별도계상": apart,
**(
have
and {k: have[k] for k in labor.MARKS if k in have}
or hmat.marks(line, name)
),
}
if not have:
mat_lines[one["자재"]] = line
return
if "호표" in one:
ref = {
@@ -261,6 +273,8 @@ class Builder:
pairs = [p for p in got["대상"] if "호표" not in p]
if pairs:
book[ledger.key(line)] = self._row(line, "짝", pairs)
for code in self._inside(lines, mats): # 로직 자재와 같은 품목 = 세 칸만 로직 자재표시로
missing.append(_missing(mat_lines[code], TWICE))
name = " ".join(x for x in (hopyo["이름"], hopyo.get("규격")) if x)
doc = {
"이름": name,
@@ -275,6 +289,22 @@ class Builder:
wrote = ledger.write(self.folder, book, self.who, file or None)
return {"참조": ref, "다시씀": again, "빠진줄": missing, "장부": {"씀": wrote}}
def _inside(self, lines: list[dict[str, Any]], mats: dict[str, dict[str, Any]]) -> list[str]:
"""로직 자재와 같은 품목(Labor_Doc `no_twice` 400 막음) — 사용자 자재에서 빼고
세 칸을 그 로직 줄 `자재표시 {재료 줄 차례: 세 칸}` 으로(수량 = 로직 셈) → 옮긴 코드."""
whole, moved = m01.loaded()[1], []
for row in (r for r in lines if r.get("로직")):
got = labor.run_line(whole, row["로직"], row.get("입력값"), None, None)
for m in got.get("재료") or []:
code = (m.get("품목") or {}).get("키")
if code in mats and code not in moved:
marks = {k: mats[code][k] for k in labor.MARKS if k in mats[code]}
row.setdefault("자재표시", {})[str(m["차례"])] = marks
moved.append(code)
for code in moved:
mats.pop(code)
return moved
def _row(self, line: dict[str, Any], kind: str, pairs: list[dict[str, Any]]) -> dict[str, Any]:
"""장부 한 줄 — 엑셀조항 = 줄 근거 출처(있으면)."""
origin = {"품명": line.get("이름"), "규격": line.get("규격"), "단위": line.get("단위")}
+2 -2
View File
@@ -151,6 +151,8 @@ def kind_of(line: dict[str, Any]) -> str:
return "%"
if ref.get("종류") == "일위":
return "호표"
if price.get("재료비") and not price.get("노무비") and not price.get("경비"):
return "자재" # 재료비만 = 자재(「단산N참조」여도 · 358-2)
if ref.get("종류") == "단산":
return "단가산출"
if unit in _MACHINE_UNITS:
@@ -159,8 +161,6 @@ def kind_of(line: dict[str, Any]) -> str:
return "노임"
if not any(price.get(k) for k in ("계", *COSTS)):
return "별도계상"
if price.get("재료비") and not price.get("노무비") and not price.get("경비"):
return "자재"
return "기타"
+79 -33
View File
@@ -4,15 +4,16 @@
상태 차례 = ① % ② 호표 참조(만들 층에 확인된 사전조합이면 체크) ③ 노임(그릇 없음) ④ 장부 「우리에
없음 ·
안 씀」 → 뺌 ⑤ 장부 짝 → 별칭(근거 다름 · 숫자 판정 맞음이 다른 로직이면 체크 안 함) ⑥
안 씀」 → 뺌 ⑤ 장부 짝 → 별칭(근거 다름이면 체크 안 함 · 숫자 판정 맞음이 다른 로직이면
⑥ 판정 먼저 + 꼬리표 「장부와 다름」 · 짝은 후보 끝 덤 · 358-3) ⑥
맞춤셈
판정(맞음 = 체크 · 다른 점 · 후보) ⑦ 셈 전 · 숫자 없음 → 이름 · 근거 후보(「셈 중」 · 「숫자 판정
없음」).
장부 짝이 규격 다름(품명|단위 느슨 열쇠)이면 ⑤ 아님 — 꼬리표 「별칭 · 규격 다름」 · 짝은 후보
끝 덤만.
자재 · 별도계상 = 이름 · 규격 같음 하나면 맞음(체크) · 그 밖 후보.
로직 줄 후보 묶음 표시(358-1) = 이름후보(옛 324 다섯 `Match.name_cands`) · 후보마다 뜻겹침 · 출처같음 ·
책 — 차례 · 무게 · 상태는 그대로.
자재 · 별도계상 = 이름 · 규격 같음 하나 · 바탕 이름 · 규격 수 같은 한 품목이면 맞음(358-2).
로직 줄 후보 묶음 표시(358-1) = 이름후보(옛 324 다섯 `Match.name_cands`) · 후보마다
뜻겹침 · 출처같음 · 책 — 차례 · 무게 · 상태는 그대로.
입력값 출처(칸마다) = 별칭 > 블록(옵션 찾기 값이 규격 읽기와 다름) > 규격(`spec_inputs`) > 자동값.
체크 = 처음 제안일 뿐 — 들어감은 [확인하고 사전조합 만들기](다 창)만. 저장 없음 · 셈은 서버 단독.
장부 = `M02_Hopyo_Ledger`(다 창) · 확인된 사전조합 = `M02_Hopyo_Build.confirmed`(다 창).
@@ -29,6 +30,7 @@ from M02_MasterTemplete import M02_Hopyo_Finger as finger
from M02_MasterTemplete import M02_Hopyo_Fit as fit
from M02_MasterTemplete import M02_Hopyo_Ledger as ledger
from M02_MasterTemplete import M02_Hopyo_Match as hm
from M02_MasterTemplete import M02_Hopyo_Material as hmat
from M02_MasterTemplete import M02_Hopyo_Option as option
from M02_MasterTemplete import M02_Hopyo_Ours as ours
from M02_MasterTemplete import M02_Hopyo_Store as hopyo_store
@@ -37,6 +39,9 @@ from M02_MasterTemplete import M02_Labor_Ref as refs
from M02_MasterTemplete import M02_MasterTemplete_Store as store
SKIP = ("우리에 없음", "안 씀")
LOOSE = "별칭 · 규격 다름"
LEDGER_OFF = "장부와 다름"
OUTSIDE = ("%", "노임", "뺌") # 연결 셈 밖(358-3 ②)
class Ctx:
@@ -117,6 +122,26 @@ def _qty(entry: dict, found: dict | None) -> dict:
return {"엑셀": ex, "우리": us, "같음": same}
def _picked(entry: dict) -> list[dict]:
"""체크할 조합 — 로직이 하나뿐이면 조합 아님(기계 몫은 꼬리표)."""
combo = [c for c in entry.get("조합") or [] if c["체크"]]
return combo if len(combo) > 1 else []
def _check(entry: dict, line: dict, ctx: Ctx) -> bool:
"""체크 제안 한 규칙(358-3 · 목록 수 = 확인 창) — 판정 맞음 · 대상(조합 또는 1위) 있음 ·
대상마다 환산 배 있음(추정 아님)."""
codes = [c["로직"] for c in _picked(entry)] or (entry.get("차례") or [])[:1]
if entry["판정"]["상태"] != "맞음" or not codes:
return False
for code in codes:
unit = (ctx.names.get(code) or {}).get("단위", "")
conv = hm.factor(line.get("단위"), unit, line.get("규격"))
if conv["배"] is None or conv["갈래"] == "추정":
return False
return True
def _numeric(line: dict, ctx: Ctx, entry: dict) -> dict:
"""⑥ 맞춤셈 판정 → 상태 · 대상 · 후보 · 품 · 글 · 꼬리표."""
judged = entry["판정"]
@@ -124,8 +149,7 @@ def _numeric(line: dict, ctx: Ctx, entry: dict) -> dict:
top = (entry.get("차례") or [None])[0]
found = (entry.get("옵션") or {}).get(top)
use = state in ("맞음", "다른 점")
combo = [c for c in entry.get("조합") or [] if c["체크"]]
combo = combo if len(combo) > 1 else [] # 체크할 로직이 하나뿐이면 조합 아님(기계 몫은 꼬리표)
combo = _picked(entry)
if combo:
targets = [
_target(
@@ -146,8 +170,7 @@ def _numeric(line: dict, ctx: Ctx, entry: dict) -> dict:
if any("블록" in t["출처"].values() for t in targets):
tags.append("옵션 — 블록에서")
tags += [f"근거 {b} {n}" for b, n in (entry.get("지문") or {}).get("출처") or []]
ok = all(t["배"] is not None and t["환산"]["갈래"] != "추정" for t in targets)
out = {"상태": state, "체크": state == "맞음" and bool(targets) and ok, "대상": targets}
out = {"상태": state, "체크": _check(entry, line, ctx), "대상": targets}
out.update(후보=entry.get("후보") or [], 글=judged["글"], 꼬리표=tags, 품=_qty(entry, found))
if found and found.get("갈리는칸"):
out["갈리는칸"] = found["갈리는칸"]
@@ -174,21 +197,30 @@ def _names_only(line: dict, ctx: Ctx, why: str, full: bool) -> dict:
def _material(line: dict, ctx: Ctx, full: bool) -> dict:
found = hm._exact(line, "자재", ctx.got)
if len(found) == 1:
"""자재 · 별도계상 줄(358-2 · `M02_Hopyo_Material`) — 「별도계상」 글 = 꼬리표 · 맞음 =
이름 · 규격 같음 하나 · 또는 바탕 이름 · 규격 수 같은 한 품목(변형 = 첫째 + 꼬리표)."""
me, apart = hmat.clean(line)
tags = [hmat.APART] if apart else []
found = hm._exact(me, "자재", ctx.got)
text = "이름 · 규격 같음"
if len(found) != 1:
sure, more = hmat.sure(me)
found, text, tags = [r["one"] for r in sure], "바탕 이름 · 규격 같음", tags + more
if found:
one = found[0]
conv = hm.factor(line.get("단위"), one["단위"], line.get("규격"))
target = {**hm._public(one), "입력값": {}, "못읽음": [], "출처": {}, "환산": conv}
target.update(몫=None, 배=conv["배"])
ok = conv["배"] is not None and conv["갈래"] != "추정"
return {"상태": "맞음", "체크": ok, "대상": [target], "후보": [], "글": "이름 · 규격 같음"}
cands = [{**c, "판정": "후보"} for c in hm.candidates(line, "자재", ctx.got, 5)] if full else []
return {
"상태": "후보" if cands or not full else "없음",
"체크": False,
"대상": [],
"후보": cands,
}
cands = [
{**hm._public(x), "점수": None, "판정": "맞음" if not at else "후보", "글": text}
for at, x in enumerate(found[:6])
]
out = {"상태": "맞음", "체크": ok, "대상": [target], "후보": cands if full else []}
return {**out, "글": text, "꼬리표": tags}
cands = hmat.candidates(me) if full else []
state = "후보" if cands or not full else "없음"
return {"상태": state, "체크": False, "대상": [], "후보": cands, "꼬리표": tags}
def match_line(line: dict[str, Any], ctx: Ctx, full: bool = True) -> dict[str, Any]:
@@ -215,12 +247,15 @@ def match_line(line: dict[str, Any], ctx: Ctx, full: bool = True) -> dict[str, A
entry = ctx.cache.get(fit.unit_key(line)) if kind not in hm.MATERIAL_KINDS else None
if alias and alias.get("갈래") in SKIP:
return {**out, "상태": "뺌", "뺌": alias["갈래"], "별칭": _alias_info(alias, False)}
if alias and alias.get("갈래") == "짝" and alias.get("짝") and _ledger_off(entry, alias):
loose, alias = alias, None # 숫자 판정 맞음 = 장부 짝 아닌 로직 → 판정 먼저(358-3 ①)
loose = {**loose, "_까닭": LEDGER_OFF}
if alias and alias.get("갈래") == "짝" and alias.get("짝"):
got = _aliased(line, ctx, alias, entry)
elif kind in hm.MATERIAL_KINDS:
got = _material(line, ctx, full)
elif entry and entry["판정"]["글"] not in (fit.NO_NUMBER, "우리 로직 준비 중"):
got = _numeric(line, ctx, entry) if full else _brief(entry)
got = _numeric(line, ctx, entry) if full else _brief(entry, line, ctx)
else:
why = "셈 중" if not ctx.calc["끝"] else fit.NO_NUMBER
got = _names_only(line, ctx, why, full)
@@ -247,10 +282,17 @@ def _grouped(got: dict, line: dict, ctx: Ctx) -> dict:
return {**got, "후보": [mark(c) for c in got.get("후보") or []], "이름후보": named}
def _ledger_off(entry: dict | None, alias: dict) -> bool:
"""장부 짝(정확 열쇠)이 숫자 판정 맞음 1위를 안 담음."""
codes = {p.get("로직") for p in alias.get("짝") or []}
return bool(entry and entry["판정"]["상태"] == "맞음" and entry["차례"][0] not in codes)
def _loose(got: dict, alias: dict, line: dict, ctx: Ctx) -> dict:
"""규격 다른 별칭 — 꼬리표 「별칭 · 규격 다름」 + 짝 로직 · 자재가 후보에 없으면 끝에 덤(상태 ·
대상 · 옵션은 이 줄 숫자 그대로)."""
tags = [*(got.get("꼬리표") or []), "별칭 · 규격 다름"]
"""규격 다른 별칭 · 숫자 판정과 다른 장부 짝 — 꼬리표(「별칭 · 규격 다름」 · 「장부와 다름」) +
짝 로직 · 자재가 후보에 없으면 끝에 덤(상태 · 대상 · 옵션은 이 줄 숫자 그대로)."""
word = alias.get("_까닭") or LOOSE
tags = [*(got.get("꼬리표") or []), word]
have = {c.get("로직") or c.get("자재") for c in got.get("후보") or []}
have |= {t.get("로직") or t.get("자재") for t in got.get("대상") or []}
extra = []
@@ -262,13 +304,17 @@ def _loose(got: dict, alias: dict, line: dict, ctx: Ctx) -> dict:
for k, v in _pair(p, line, ctx).items()
if k in ("로직", "자재", "이름", "단위")
}
extra.append({**one, "점수": None, "판정": "후보", "글": "별칭 · 규격 다름"})
extra.append({**one, "점수": None, "판정": "후보", "글": word})
return {**got, "꼬리표": tags, "후보": [*(got.get("후보") or []), *extra]}
def _brief(entry: dict) -> dict:
state = entry["판정"]["상태"]
return {"상태": state, "체크": state == "맞음", "글": entry["판정"]["글"]}
def _brief(entry: dict, line: dict, ctx: Ctx) -> dict:
"""목록 수(`full` 아님) — 체크는 확인 창과 같은 `_check`."""
return {
"상태": entry["판정"]["상태"],
"체크": _check(entry, line, ctx),
"글": entry["판정"]["글"],
}
def _alias_info(alias: dict, basis_off: bool) -> dict:
@@ -277,7 +323,7 @@ def _alias_info(alias: dict, basis_off: bool) -> dict:
def _aliased(line: dict, ctx: Ctx, alias: dict, entry: dict | None) -> dict:
"""⑤ 장부 짝(정확 열쇠) — 체크 = 근거 같음 · 숫자 판정 맞음이 다른 로직 아님."""
"""⑤ 장부 짝(정확 열쇠 · 숫자 판정 맞음 1위를 담음 또는 판정 맞음 아님) — 체크 = 근거 같음."""
targets = [_pair(p, line, ctx) for p in alias["짝"]]
codes = {t["로직"] for t in targets if t.get("로직")}
theirs = ((line.get("근거") or {}).get("출처")) or []
@@ -285,12 +331,9 @@ def _aliased(line: dict, ctx: Ctx, alias: dict, entry: dict | None) -> dict:
off = bool(theirs and codes) and all(
hm.clause(theirs, table[c])[0] < 0.9 for c in codes if c in table
)
other = bool(entry and entry["판정"]["상태"] == "맞음" and entry["차례"][0] not in codes)
check = not off and not other
tags = ["별칭 — " + " · ".join(str(x) for x in (alias.get("층"), alias.get("때")) if x)]
tags += ["근거 다름"] * off
tags += [f"숫자 판정 맞음 = {entry['차례'][0]}"] if other else []
out = {"상태": "별칭", "체크": check, "대상": targets, "글": "별칭 장부 짝", "꼬리표": tags}
out = {"상태": "별칭", "체크": not off, "대상": targets, "글": "별칭 장부 짝", "꼬리표": tags}
out["별칭"] = _alias_info(alias, off)
if entry and entry.get("품"):
out["품"] = _qty(entry, (entry.get("옵션") or {}).get(next(iter(codes), None)))
@@ -317,10 +360,13 @@ def whole(hopyo: dict, ctx: Ctx) -> dict | None:
def hopyo_view(hopyo: dict[str, Any], ctx: Ctx, full: bool = True) -> dict[str, Any]:
"""호표 하나 — 줄마다 맞춤 · `맞춤` = [체크 제안 수, 줄 수] · 통째 · 셈 · 만든."""
"""호표 하나 — 줄마다 맞춤 · `맞춤` = [체크 제안 수, 줄 수] · 연결할(% · 노임 · 뺌 밖 줄 수) ·
연결된(그중 체크 제안 수 · 358-3) · 통째 · 셈 · 만든."""
lines = [{**one, "맞춤": match_line(one, ctx, full)} for one in hopyo.get("줄") or []]
check = sum(1 for one in lines if one["맞춤"]["체크"])
out = {**hopyo, "줄": lines, "맞춤": [check, len(lines)]}
need = [one for one in lines if one["맞춤"]["상태"] not in OUTSIDE]
out = {**hopyo, "줄": lines, "맞춤": [check, len(lines)], "연결할": len(need)}
out["연결된"] = sum(1 for one in need if one["맞춤"]["체크"])
if full:
made = ctx.done.get(hopyo_build.mark(ctx.key, int(hopyo["번"])))
out.update(통째=whole(hopyo, ctx), 셈=ctx.calc)
+3 -2
View File
@@ -455,11 +455,12 @@ def view(folder: Path, key: str, no: int | None = None, to: Path | None = None)
doc = ctx.doc
if no is None:
hopyos = [lines.hopyo_view(h, ctx, False) for h in doc.get("호표") or []]
keep = ("번", "이름", "규격", "단위", "금액", "맞춤", "행")
keep = ("번", "이름", "규격", "단위", "금액", "맞춤", "행", "연결할", "연결된")
brief = [{k: h.get(k) for k in keep if k in h} for h in hopyos]
ready = [sum(h["맞춤"][0] for h in hopyos), sum(h["맞춤"][1] for h in hopyos)]
head = {**hopyo_store.brief(doc), "검산": doc.get("검산"), "셈": ctx.calc}
return {**head, "호표": brief, "맞춤": ready}
links = {k: sum(h[k] for h in hopyos) for k in ("연결할", "연결된")}
return {**head, "호표": brief, "맞춤": ready, **links}
hopyo = ctx.units.get(int(no))
if hopyo is None:
raise store.StoreError(404, f"없는 호표 「제{no}호표」")
+251
View File
@@ -0,0 +1,251 @@
"""M02 일위대가 실무자료 자재 · 별도계상 줄 맞춤(PLAN 358-2) — `Line._material` 이 부름.
줄 다듬기 = 「별도계상」 글은 표시로 뗌(「조약돌(별도계상)」 · 규격 「별도계상」) · m/m = mm.
바탕 이름 = 괄호 · 지역 · 관급/사급 꼬리 뗀 이름(「레미콘(서울)-사급」 → 레미콘).
규격 맞음(0~1) = 정규 규격 같음 1 · 범위(「3.0≤T<4.5」)에 듦 1 · 그 밖 = 줄 규격 수가
자재 규격 수에 있는 몫(단위 배 1 · 10 · 1000 · 2 % 허용 · 면목 0.03 m = 30 mm) ·
줄 괄호 글 낱말(「몸통」)은 자재 이름에 들어야 함.
맞음 = ① 이름 · 규격 같음 하나(`Match._exact`) ② 바탕 이름 · 단위 · 규격 맞음 1 이 한 품목뿐
(지역 · 관급/사급만 다른 변형 = 한 품목 · 같은 단위 먼저 · 줄 글의 관급/사급 · 지역 먼저 ·
없으면 별도계상 줄 = 관급 · 재료비 줄 = 사급 · 지역 없는 것 · M01 차례 → 꼬리표 「못 가림」).
그 밖 = 후보 다섯(변형은 하나로 접고 글에 수). 곳 낱말 = M01 레미콘 이름에서(자료).
가져오기 세 칸(`marks`) = 별도계상(표시 → 별도계상 · 아니면 직접계산) · 자재구분(고른
자재 이름 · 줄 글) · 운반비(「운반비 미포함 · 별도」 → 미포함). 로직 판정과 무관.
"""
from __future__ import annotations
import re
import unicodedata
from functools import lru_cache
from typing import Any
from M02_MasterTemplete import M02_Hopyo_Match as hm
mm = hm.mm
APART = "별도계상"
SUPPLY = ("관급", "사급")
NO_REGION = ("전국", "전지역")
REGIONS = (
*"서울 인천 부산 대구 광주 대전 울산 세종 제주 경기 강원 충북 충남 전북 전남 경북 경남".split(),
*"충청 전라 경상 용인 포천 강릉 원주 창원 경주 구미 김천 안동 포항 광양 목포 전주 공주".split(),
*"보령 서산 아산 천안 제천 청주 충주 춘천 김해 양산 거제 통영 진주 밀양".split(),
*"여주 이천 가평 연천".split(),
)
SKIP_WORDS = frozenset({"조합공통품목"})
_APART = re.compile(r"\(?\s*별도\s*계상\s*\)?")
_NUM = re.compile(r"\d+(?:\.\d+)?")
_RANGE = re.compile(r"(\d+(?:\.\d+)?)\s*(<=|≤|<)\s*[A-Za-z]\s*(<=|≤|<)\s*(\d+(?:\.\d+)?)")
_TAIL = re.compile(r"[\s,\-]*([가-힣]+)$")
SCALES = (1, 10, 100, 1000, 0.1, 0.01, 0.001)
def _nfkc(text: Any) -> str:
return unicodedata.normalize("NFKC", str(text or "")).replace("m/m", "mm")
@lru_cache(maxsize=1)
def _places() -> frozenset[str]:
"""곳 낱말 — M01 레미콘 이름의 지역 · 시군 낱말(자료에서 · 「홍천군」 · 「미원면」)."""
names = (one["이름"] for one in hm.targets()["자재"] if one["이름"].startswith("레미콘("))
words = {w for n in names for w in re.findall(r"[가-힣]{2,}", _nfkc(n))}
return frozenset(words - {"레미콘", "조합공통품목", *SUPPLY})
def _region(word: str) -> bool:
return word.startswith(REGIONS) or word in NO_REGION or word in _places()
def _paren_words(name: str) -> set[str]:
return {w for g in re.findall(r"\(([^()]*)\)", name) for w in re.findall(r"[가-힣]{2,}", g)}
def base(name: Any) -> str:
"""바탕 이름 — 괄호 글 · 끝 지역 · 관급/사급 꼬리 뗌(정규화)."""
text = _nfkc(name)
while "(" in text and (cut := re.sub(r"\([^()]*\)", "", text)) != text:
text = cut
while (m := _TAIL.search(text)) and (m.group(1) in SUPPLY or _region(m.group(1))):
if m.start() == 0:
break
text = text[: m.start()]
for w in SUPPLY:
text = text[: -len(w)] if text.endswith(w) and len(text) > len(w) else text
return mm.plain(text)
def clean(line: dict[str, Any]) -> tuple[dict[str, Any], bool]:
"""(「별도계상」 글 뗀 줄, 별도계상 표시) — 갈래 별도계상 줄도 표시."""
out, apart = dict(line), line.get("갈래") == APART
for k in ("이름", "규격"):
text = str(line.get(k) or "")
if _APART.search(text):
apart, text = True, _APART.sub("", text).strip(" ,")
out[k] = text
return out, apart
def _nums(text: str) -> list[float]:
return [float(x) for x in _NUM.findall(text)]
def _ranges(text: str) -> list[tuple[float, bool, float, bool]]:
return [(float(a), op1 != "<", float(b), op2 != "<") for a, op1, op2, b in _RANGE.findall(text)]
def _words(text: str) -> frozenset[str]:
return (
frozenset(w for w in re.findall(r"[가-힣]{2,}", text) if not _region(w) and w not in SUPPLY)
- SKIP_WORDS
)
@lru_cache(maxsize=2)
def _pool(got_id: int) -> tuple[list[dict[str, Any]], dict[str, list[dict[str, Any]]]]:
"""자재마다 바탕 이름 · 규격 수 · 범위 · 지역 · 관급/사급 — (목록, {바탕: [자재]})."""
rows, index = [], {}
for at, one in enumerate(hm.targets()["자재"]):
name, spec = _nfkc(one["이름"]), _nfkc(one["규격"])
words = set(re.findall(r"[가-힣]+", name))
row = {
"one": one,
"at": at,
"base": base(name),
"nums": _nums(spec),
"ranges": _ranges(spec),
"spec": mm.plain_spec(spec),
"region": sorted(w for w in words if _region(w) and w not in NO_REGION),
"supply": next((w for w in SUPPLY if w in name), None),
"key": (
tuple(sorted(_nums(spec))),
mm.plain_unit(one["단위"]),
_words(spec) | _words(" ".join(_paren_words(name))),
),
}
row["grams"] = hm._grams(row["base"])
rows.append(row)
index.setdefault(row["base"], []).append(row)
return rows, index
def pool() -> tuple[list[dict[str, Any]], dict[str, list[dict[str, Any]]]]:
return _pool(id(hm.targets()))
def _near(x: float, y: float) -> bool:
return any(abs(x * s - y) <= 0.02 * max(abs(y), 1e-9) for s in SCALES)
def spec_fit(line: dict[str, Any], row: dict[str, Any]) -> float:
"""규격 맞음 0~1 — 같음 · 범위에 듦 · 줄 규격 수가 자재 규격 수에 있는 몫."""
text = _nfkc(f"{line.get('규격') or ''} {' '.join(hm._PAREN.findall(str(line.get('이름'))))}")
if mm.plain_spec(line.get("규격")) == row["spec"]:
return 1.0
mine = _nums(text)
if row["ranges"]:
inside = any(
(a < x or (lo and a == x)) and (x < b or (hi and x == b))
for x in mine
for a, lo, b, hi in row["ranges"]
)
return 1.0 if inside else 0.0
if not mine or not row["nums"]:
return 0.0
return sum(any(_near(x, y) for y in row["nums"]) for x in mine) / len(mine)
def _wants(line: dict[str, Any]) -> tuple[str | None, list[str]]:
"""(관급/사급, 지역 낱말) — 줄 글 · 없으면 별도계상 줄 = 관급 · 재료비 줄 = 사급."""
text = _nfkc(f"{line.get('이름')} {line.get('규격')} {line.get('비고') or ''}")
words = set(re.findall(r"[가-힣]+", text))
supply = next((w for w in SUPPLY if w in text), None)
if supply is None:
supply = (
"관급" if clean(line)[1] else "사급" if (line.get("단가") or {}).get("재료비") else None
)
return supply, [w for w in words if _region(w) and w not in NO_REGION]
def _prefer(rows: list[dict[str, Any]], line: dict[str, Any]) -> list[dict[str, Any]]:
"""변형 차례 — 줄 글의 관급/사급 · 지역 먼저 · 지역 없는 것 · 엑셀(M01) 차례."""
supply, regions = _wants(line)
def rank(r: dict[str, Any]) -> tuple:
hit = any(w[:2] == x[:2] for w in regions for x in r["region"])
return (
supply is not None and r["supply"] not in (None, supply),
bool(regions) and not hit,
bool(r["region"]),
r["at"],
)
return sorted(rows, key=rank)
def _paren_ok(line: dict[str, Any], row: dict[str, Any]) -> bool:
return _paren_words(_nfkc(line.get("이름"))) <= set(re.findall(r"[가-힣]+", row["one"]["이름"]))
def sure(line: dict[str, Any]) -> tuple[list[dict[str, Any]], list[str]]:
"""맞음 ② — 바탕 이름 같음 · 단위 · 괄호 낱말 · 규격 맞음 1 이 한 품목 → (변형 차례, 꼬리표) ·
아니면 ([], [])."""
_, index = pool()
rows = [
r
for r in index.get(base(line.get("이름")), [])
if hm.fits(line.get("단위"), r["one"]["단위"]) and _paren_ok(line, r)
]
full = [r for r in rows if spec_fit(line, r) == 1.0]
unit = mm.plain_unit(line.get("단위"))
full = [r for r in full if r["key"][1] == unit] or full # 같은 단위 먼저(kg ↔ Ton 둘 다면)
if not full or len({r["key"] for r in full}) > 1:
return [], []
full = _prefer(full, line)
tags = [f"지역 · 관급/사급 못 가림 — {len(full)}개 중 첫째"] if len(full) > 1 else []
return full, tags
def candidates(line: dict[str, Any], limit: int = 5) -> list[dict[str, Any]]:
"""자재 후보 — 바탕 이름 글자 묶음 + 바탕 같음 1 + 단위 0.15 + 규격 맞음 0.5 + 괄호 낱말 0.2 ·
뜻 낱말 · 규격 수 둘 다 안 닮으면 뺌 · 변형(지역 · 관급/사급)은 하나로 접음(글 = 변형 수)."""
rows, _ = pool()
me = base(line.get("이름"))
grams, mine = hm._grams(me), hm.words(line.get("이름"))
parens = _paren_words(_nfkc(line.get("이름")))
scored: dict[tuple, list] = {}
for r in rows:
if not grams & r["grams"]:
continue
fit = spec_fit(line, r)
if not hm.near(mine, r["one"]["words"]) and fit < 0.5:
continue
score = 2 * len(grams & r["grams"]) / (len(grams) + len(r["grams"]))
score += (r["base"] == me) + 0.15 * hm.fits(line.get("단위"), r["one"]["단위"])
score += 0.5 * fit + (0.2 if parens and _paren_ok(line, r) else 0)
fold = (r["base"], *r["key"])
have = scored.setdefault(fold, [score, []])
have[0] = max(have[0], score)
have[1].append(r)
best = sorted(scored.values(), key=lambda x: (-x[0], x[1][0]["at"]))[:limit]
out = []
for score, group in best:
first = _prefer(group, line)[0]
one = {**hm._public(first["one"]), "점수": round(score, 3), "판정": "후보"}
out.append(
{**one, "글": f"변형 {len(group)}개 — 지역 · 관급/사급"} if len(group) > 1 else one
)
return out
def marks(line: dict[str, Any], name: str = "") -> dict[str, str]:
"""가져오기 세 칸(Labor_Doc `MARKS` 값) — 별도계상 · 자재구분(고른 자재 `name` →
줄 글 → 줄 갈래) · 운반비(모르면 칸 없음 = 기본)."""
_, apart = clean(line)
out = {"별도계상": APART if apart else "직접계산"}
supply = next((w for w in SUPPLY if w in str(name)), None) or _wants(line)[0]
if supply:
out["자재구분"] = supply
text = re.sub(r"\s", "", _nfkc(f"{line.get('규격')} {line.get('비고') or ''}"))
if "운반비미포함" in text or "운반비별도" in text:
out["운반비"] = "미포함"
return out
+5 -2
View File
@@ -168,9 +168,12 @@ def _blocks(rows: list[list[Any]], table: dict[str, dict[str, str]]) -> dict[str
def _kind(code: str, line: dict[str, Any]) -> str:
if code.startswith("PR") or _flat(line["단위"]) == "%":
return "%"
price = line["단가"]
if code[0] == "M":
apart = line["비고"].startswith("별산") or line["규격"] == "별도계상"
return "별도계상" if apart or not any(line["단가"].values()) else "자재"
apart = line["비고"].startswith("별산") or "별도계상" in f"{line['규격']}{line['이름']}"
return "별도계상" if apart or not any(price.values()) else "자재"
if code[0] == "D" and price.get("재료비") and not price.get("노무비") and not price.get("경비"):
return "자재" # 재료비만 = 자재(358-2 · 엑셀꼴과 같음)
return _KIND.get(code[0], "기타")
+5 -1
View File
@@ -215,7 +215,11 @@ def hopyo_list(
) -> dict:
"""실무자료 목록 `{줄: [{키, 파일, 올린때, 올린이, 시트, 호표수, 어긋남}]}`."""
folder = _root(session, layer, owner)
return {"줄": hopyo_store.rows(folder)}
rows = hopyo_store.rows(folder)
if (ensure := _later("M02_Hopyo_Fit", "ensure")) is not None:
for row in rows: # 지금 판 맞춤셈 없는 파일 = 목록 열 때 뒤 셈 예약(358-3 ③)
ensure(folder, row["키"])
return {"줄": rows}
@router.get("/layers/{layer}/hopyo-picks")
@@ -36,6 +36,9 @@ export interface HopyoBrief {
금액: Money;
/** [체크 제안 수, 줄 수] */
맞춤: [number, number];
/** 연결할 줄 수(% · 노임 · 뺌 밖 · 358-3) · 연결된 줄 수(그중 체크 제안 — 확인 창과 한 규칙) */
연결할?: number;
연결된?: number;
/** 일위대가 시트 행 [시작, 끝](엑셀 1부터 · 329 · 옛 파일 없음) */
행?: [number, number];
}
@@ -58,6 +61,9 @@ export interface HopyoView extends HopyoFile {
검산: { 어긋남: string[]; 총괄표: number };
호표: HopyoBrief[];
맞춤: [number, number];
/** 파일 전체 연결할 · 연결된 줄 수(358-3) */
연결할?: number;
연결된?: number;
/** 뒤 숫자 판정 셈(끝나면 맞춤 수가 바뀜) */
셈?: HopyoCalc;
}