From 215cc1531db7e9323f6a8c16dfe62ed7bc47446a Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 4 Nov 2026 13:43:51 +0900 Subject: [PATCH] =?UTF-8?q?feat(778-5):=20=EC=A4=84=20=EC=9E=87=EA=B8=B0?= =?UTF-8?q?=20=ED=95=9C=20=EB=B2=8C=20=E2=80=94=20=EB=8B=A8=EC=9C=84=20?= =?UTF-8?q?=EC=A4=84=20=E2=86=92=20=EC=9A=B0=EB=A6=AC=20=EA=B3=84=EC=82=B0?= =?UTF-8?q?=20=EB=8B=A8=EC=9C=84=20+=20=EC=83=81=ED=83=9C(=EC=9D=B4?= =?UTF-8?q?=EC=96=B4=EC=A7=90=20=C2=B7=20=EA=B3=A0=EB=A5=BC=20=EA=B2=83=20?= =?UTF-8?q?=C2=B7=20=EB=AA=BB=20=EC=9D=B4=EC=9D=8C)=20=C2=B7=20=EC=9D=BC?= =?UTF-8?q?=EC=9C=84=EB=8C=80=EA=B0=80=20=EC=9E=90=EC=9E=AC=20=EB=A7=9E?= =?UTF-8?q?=EC=B6=A4=20=EA=B0=99=EC=9D=80=20=ED=95=A8=EC=88=98(D-2=20?= =?UTF-8?q?=C2=B7=20=EC=9A=94=EA=B5=AC=20622=20=C2=B7=20623=20=C2=B7=20624?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 새 M02_Hopyo_Units: 노무 LB(같은 이름 여럿 = 건설업 먼저 · 작업반장) · 기계 EQ(Fixed.Linker) → 기계경비 기초 로직 줄(778-2) · 없는 규격 = 가까운 후보만 · 자재 MT(별칭 장부 › 이름 · 규격 › 바탕 이름 · 숫자 규격 · 이름에 붙은 규격 · 글자 규격 = 고를 것) · 손료율 방법 · 율 778-4 꼴(기준꼴 → 묶음 · 비목 · 줄 목록) · 하위 · 값 - Material: 숫자 규격 치수 차례 · 구분자 무관 견줌(dims) · 글자 규격 판별(lettered) · 단위 없는 줄(EST 블록) = 단위 안 봄 - Line._material = Units.material — 일위대가 자재 체크 제안 147 → 146(봉화 광명단조합페인트 「1종2류(철재)」 = 글자 규격 → 후보 · 요구 624-2) - 여덟 파일 불리는 블록 줄 842: 노무 366/366 · 기계 180/190 · 자재 71/130 · 율 150/152 · 하위 3/3 · 값 1/1 · 줄 다 이어진 블록 156/206 Co-Authored-By: Claude Opus 5.5 --- M02_MasterTemplete/M02_Hopyo_Line.py | 16 +- M02_MasterTemplete/M02_Hopyo_Material.py | 27 ++- M02_MasterTemplete/M02_Hopyo_Units.py | 266 +++++++++++++++++++++++ 3 files changed, 298 insertions(+), 11 deletions(-) create mode 100644 M02_MasterTemplete/M02_Hopyo_Units.py diff --git a/M02_MasterTemplete/M02_Hopyo_Line.py b/M02_MasterTemplete/M02_Hopyo_Line.py index c85ab4a69..15549b76a 100644 --- a/M02_MasterTemplete/M02_Hopyo_Line.py +++ b/M02_MasterTemplete/M02_Hopyo_Line.py @@ -38,6 +38,7 @@ 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 +from M02_MasterTemplete import M02_Hopyo_Units as units from M02_MasterTemplete import M02_Hopyo_Verdict as verdict from M02_MasterTemplete import M02_Labor_Ref as refs from M02_MasterTemplete import M02_MasterTemplete_Store as store @@ -208,13 +209,14 @@ def _material(line: dict, ctx: Ctx, full: bool) -> dict: """자재 · 별도계상 줄(358-2 · `M02_Hopyo_Material`) — 「별도계상」 글 = 꼬리표 · 맞음 = 이름 · 규격 같음 하나 · 또는 바탕 이름 · 규격 수 같은 한 품목(줄 글 지역 · 지역 없는 것 · 「전국」 자리표 · 361-1) · 후보 = 그 품목 변형 모두(`변형` · 고르기 창이 접음).""" - 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 + me, _ = hmat.clean(line) + hit = units.material(line, ctx.got, full) # 줄 잇기 한 벌(778-5 · 단가산출 블록 자재와 같음) + found, text, tags = hit["찾음"], hit["글"], hit["꼬리표"] + if hit["상태"] == units.PICK and hit["후보"] and not found: # 글자 규격 = 후보로만(요구 624-2) + cands = hit["후보"] if hit["글"] == units.LETTERS else [] + cands = cands or (hmat.candidates(me) if full else []) + return {"상태": "후보", "체크": False, "대상": [], "후보": cands if full else [], "글": text, + "꼬리표": tags} # fmt: skip if found: target = _mat_target(found[0], line) conv = target["환산"] diff --git a/M02_MasterTemplete/M02_Hopyo_Material.py b/M02_MasterTemplete/M02_Hopyo_Material.py index 3f00b9529..4e3d3d7e8 100644 --- a/M02_MasterTemplete/M02_Hopyo_Material.py +++ b/M02_MasterTemplete/M02_Hopyo_Material.py @@ -45,6 +45,8 @@ _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) +_N = r"\d+(?:\.\d+)?" +_DIMS = re.compile(rf"(?i)((?:{_N})(?:\s*[×xX*]\s*{_N})*)\s*(?:mm|cm|m|㎜|㎝|㎡|㎥|kg|t|ton)?") def _nfkc(text: Any) -> str: @@ -148,11 +150,27 @@ 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 dims(spec: Any) -> tuple[float, ...] | None: + """숫자 규격(778-5 · 요구 624-2) → 치수 차례 무관 묶음 — 「600×1200」 = 「1200x600 mm」 · 구분자 + × x X * · 끝 단위 하나 · 글 섞이면 None.""" + hit = _DIMS.fullmatch(_nfkc(spec).strip()) + return tuple(sorted(_nums(hit.group(1)))) if hit else None + + +def lettered(spec: Any) -> bool: + """글자 든 규격(한글 낱말 · 지역 · 관급/사급 · 별도계상 뺌) — 같은 규격 판정 대신 사용자 확인.""" + return bool(_words(_APART.sub("", _nfkc(spec)))) + + def spec_fit(line: dict[str, Any], row: dict[str, Any]) -> float: - """규격 맞음 0~1 — 같음 · 범위에 듦 · 줄 규격 수가 자재 규격 수에 있는 몫.""" + """규격 맞음 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 + a = dims(line.get("규격")) + if a and a == dims((row.get("one") or {}).get("규격")): + return 1.0 mine = _nums(text) if row["ranges"]: inside = any( @@ -204,13 +222,14 @@ def _paren_ok(line: dict[str, Any], row: dict[str, Any]) -> bool: def sure(line: dict[str, Any]) -> tuple[list[dict[str, Any]], list[str]]: - """맞음 ② — 바탕 이름 같음 · 단위 · 괄호 낱말 · 규격 맞음 1 이 한 품목 → (변형 차례, 꼬리표) · - 아니면 ([], []).""" + """맞음 ② — 바탕 이름 같음 · 단위(줄에 없으면 안 봄 · EST 블록 줄) · 괄호 낱말 · 규격 맞음 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) + if (not line.get("단위") or 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("단위")) diff --git a/M02_MasterTemplete/M02_Hopyo_Units.py b/M02_MasterTemplete/M02_Hopyo_Units.py new file mode 100644 index 000000000..a112dde55 --- /dev/null +++ b/M02_MasterTemplete/M02_Hopyo_Units.py @@ -0,0 +1,266 @@ +"""M02 일위대가 실무자료 줄 잇기 한 벌(PLAN 778-5 · D-2 · 요구 622 · 623 · 624) — 단위 줄 +(`M02_Hopyo_UnitLine`) → 우리 계산 단위 + 상태. 형식(EST · STmate) · 대상(단가산출 블록 · 일위대가 줄)을 +모름 — 일위대가 자재 줄(`M02_Hopyo_Line._material`)도 `material` 을 씀. + +잇기 = `{상태 이어짐|고를 것|못 이음, 방법, 단위, 후보, 꼬리표, 글}` · 자동 없음(제안만 · 사용자가 확인). + L 노무 = 직종 LB(이름 · 옛이름) · 같은 이름 여럿 = 건설업 먼저(작업반장 · 요구 624-1) · 단위 `{노임}` + X 기계 = EQ(`Fixed.Linker._machine` · 778-1 괄호 낱말) → 기계경비 기초 로직(`SkPatch._mach_logics` · + 778-2) · 단위 `{기계, 로직, 할증?}` · 없는 규격 = 가까운 규격 후보만(`Elem.machines` · 자동 고름 없음) + M 자재 = MT(`material` — 별칭 장부 › 이름 · 규격 같음 › 바탕 이름 · 숫자 규격 같음(치수 차례 · 구분자 + 무관) · 글자 든 규격 = 후보로만) · 손료율 = 방법 「수량 × 손료율 × 단가」 · 별도 = 단위 `{자재?, + 별도계상}` · 단위 `{자재}` + R 율 = 778-4 꼴 `{기준, 비목}` — 기준 = `기준꼴`(인력 · 재료 묶음 · 노무비 · 줄 목록 `{행: [...]}`) · + 우리에 없는 율 항목(설치비 · 제경비)도 율 줄 · 기준 못 찾음 = 고를 것(글 기준 제안) + S 하위 = `{하위 {종류 단산|일위, 번}}`(SK · SC 참조) · E 값 = `{값: 금액}`(고정 금액 줄) +별칭 장부 짝(자재) = 숫자보다 먼저(`Line.match_line` 규칙). 서버 단독 · 읽기만. +""" + +from __future__ import annotations + +import re +from typing import Any + +from M02_MasterTemplete import M02_Hopyo_Elem as elem +from M02_MasterTemplete import M02_Hopyo_Fixed as hfix +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_Shares as shares +from M02_MasterTemplete import M02_Hopyo_SkPatch as skpatch +from M02_MasterTemplete import M02_Hopyo_UnitLine as unit_line +from M02_MasterTemplete.M02_Hopyo_Words import mtypes + +LINKED, PICK, NONE = "이어짐", "고를 것", "못 이음" +HOW = { + "L": "노무 = 직종 × 품 × 노임", + "X": "기계 = 시간 × 시간당 기계경비", + "M": "자재 = 수량 × 단가", + "R": "율 = 기준 × 율", + "S": "하위 = 하위 값 × 수량", + "E": "값 = 고정 금액", +} +WEAR = "자재 = 수량 × 손료율 × 단가" +APART = "자재 = 별도계상(0 원)" +TRADE_FIRST = "건설업" # 같은 이름 노임 여럿 = 건설업 먼저(임도 = 토목 · 요구 624-1) +LETTERS = "글자 규격 — 확인" +# 율 기준꼴(`UnitLine.base_rows`) → 778-4 기준(묶음 · 비목) · 그 밖 = 줄 목록 +BASES = {"인력": "인력", "인력(블록)": "인력", "자재": "재료", "자재(블록)": "재료"} +BASES |= {k: k for k in ("노무비", "재료비", "경비")} # 일위대가 % 줄 기준(규격 글) +_PART_WORDS = (("공구손료", "경비"), ("재료", "재료비"), ("자재", "재료비"), ("소모", "재료비")) + + +def _cands(rows: list[dict[str, Any]]) -> list[dict[str, Any]]: + return [{**hm._public(r), "점수": r.get("점수"), "판정": "후보"} for r in rows] + + +def _split(me: dict[str, Any]) -> list[dict[str, Any]]: + """이름에 붙은 규격 · 괄호 이름 — 「이름 규격」 · 「이름(규격)」(끝 괄호에 수) · 「유로폼(패널)」 = + 유로폼패널 · 「패널(유로폼)」 = 유로폼패널 · 끝 「[㎥]」 뗌.""" + name = re.sub(r"\[[^\]]*\]\s*$", "", str(me.get("이름") or "")).strip() + spec = str(me.get("규격") or "") + pairs = [] + if not spec: + head, _, rest = name.partition(" ") + pairs += [(head, rest)] if rest else [] + if hit := re.match(r"^(.+?)\s*[((]([^()()]*\d[^()()]*)[))]\s*$", name): + pairs.append((hit.group(1), hit.group(2))) + out = [] + for n, sp in [*pairs, (name, spec)]: + if hit := re.fullmatch(r"([^()]+)\(([^()\d]+)\)", n.strip()): + a, b = hit.group(1).strip(), hit.group(2).strip() + out += [{**me, "이름": a + b, "규격": sp}, {**me, "이름": b + a, "규격": sp}] + elif (n, sp) != (me.get("이름"), spec): + out.append({**me, "이름": n, "규격": sp}) + return out + + +def material(line: dict[str, Any], got: dict[str, Any], full: bool = True) -> dict[str, Any]: + """자재 줄(별도계상 글 뗌) → `{상태, 찾음 [M01 자재], 글, 꼬리표, 후보}` — 이름 · 규격 같음 하나 › + 바탕 이름 · 숫자 규격 같음(이름에 붙은 규격 「이름 규격」 · 「이름(규격)」도) · 글자 규격 = 고를 것.""" + me, apart = hmat.clean(line) + tags = [hmat.APART] if apart else [] + found = hm._exact(me, "자재", got) + if len(found) == 1: + return {"상태": LINKED, "찾음": found, "글": "이름 · 규격 같음", "꼬리표": tags, "후보": []} + tries = [me, *_split(me)] + for one in tries: + sure, more = hmat.sure(one) + if not sure: + continue + rows = [r["one"] for r in sure] + if hmat.lettered(one.get("규격")): + cands = [{**hm._public(r), "점수": None, "판정": "후보", "글": LETTERS} for r in rows] + return {"상태": PICK, "찾음": [], "글": LETTERS, "꼬리표": tags + more, "후보": cands} + return {"상태": LINKED, "찾음": rows, "글": "바탕 이름 · 규격 같음", "꼬리표": tags + more, + "후보": []} # fmt: skip + cands = hmat.candidates(me) if full else [] + state = PICK if cands or not full else NONE + return { + "상태": state, + "찾음": [], + "글": "후보만" if cands else "없음", + "꼬리표": tags, + "후보": cands, + } + + +def rate_part(name: Any) -> str: + """율 금액이 드는 비목 — 공구손료 = 경비 · 재료 · 자재 · 소모 = 재료비 · 그 밖 경비.""" + flat = re.sub(r"\s", "", str(name or "")) + return next((part for word, part in _PART_WORDS if word in flat), "경비") + + +def _text_base(one: dict[str, Any]) -> str: + """율 줄 기준 글(「노무비의」 · 「주재료의」 · STmate 표지) → 778-4 기준 제안.""" + text = re.sub(r"\s", "", f"{one.get('기준') or ''} {one.get('기준글') or ''} {one.get('이름')}") + if "주재료" in text or "주자재" in text or "M:" in text: + return "재료" + if "재료비" in text: + return "재료비" + if "경비" in text and "노무" not in text: + return "경비" + return "노무비" + + +class Units: + """한 번 잇기 — M01 · 목록 · 장부를 한 번 읽음(`books` = `Ledger.books` · 없으면 장부 안 봄).""" + + def __init__(self, books: list | None = None, doc: str | None = None) -> None: + self.linker = hfix.Linker(by_name=True) + self.whole, self.got = self.linker.whole, self.linker.got + self.books, self.doc = books, doc + self.eqs = {str(r.get("키")): r for r in self.whole.numbers.get("EQ", {}).values()} + self.lb: dict[str, list[dict]] = {} + for row in self.whole.numbers.get("LB", {}).values(): + self.lb.setdefault(str(row.get("이름") or ""), []).append(row) + + def line(self, one: dict[str, Any]) -> dict[str, Any]: + kind = one.get("묶음") + how = HOW.get(str(kind), HOW["E"]) + got = getattr(self, f"_{kind}", self._E)(one) + return {"방법": how, "후보": [], "꼬리표": [], "글": "", **got} + + def _L(self, one: dict[str, Any]) -> dict[str, Any]: + name = shares.trade(one.get("직종") or one.get("이름")) or "" + rows = self.lb.get(name, []) + cands = [{"키": str(r.get("키")), "갈래": "노임", "이름": r.get("이름"), + "구분": r.get("구분"), "단가": elem._num(r.get("값"))} for r in rows] # fmt: skip + if len(rows) == 1: + return {"상태": LINKED, "단위": {"노임": cands[0]["키"]}} + first = [c for c in cands if c["구분"] == TRADE_FIRST] + if len(first) == 1: + rest = [c for c in cands if c is not first[0]] + tag = f"기본 — {TRADE_FIRST} {name}" + return {"상태": LINKED, "단위": {"노임": first[0]["키"]}, "후보": rest, "꼬리표": [tag]} + named = elem.labor(str(one.get("이름") or "")) + return {"상태": PICK if named or cands else NONE, "단위": None, "후보": cands or named} + + def _X(self, one: dict[str, Any]) -> dict[str, Any]: + line = {"이름": one.get("이름"), "규격": one.get("규격") or ""} + code = self.linker._machine(line) + if code is None: + cands = elem.machines(f"{line['이름']} {line['규격']}") + return {"상태": PICK if cands else NONE, "단위": None, "후보": cands, + "글": "가까운 규격 — 고름" if cands else "같은 기계 없음"} # fmt: skip + eq = self.eqs.get(code) or {} + kinds = mtypes(f"{eq.get('상세구분')} {eq.get('이름')}") + unit = {"기계": code, "로직": skpatch._mach_logics(self.whole, kinds, eq)[0]} + unit |= {"할증": one["할증"]} if one.get("할증") else {} + return { + "상태": LINKED, + "단위": unit, + "글": f"{eq.get('이름')} {eq.get('규격') or ''}".strip(), + } + + def _M(self, one: dict[str, Any]) -> dict[str, Any]: + how = WEAR if one.get("손료율") else HOW["M"] + apart = one.get("어떻게") == "별도" or one.get("별도") + line = { + "이름": one.get("이름"), + "규격": one.get("규격") or "", + "단위": one.get("단위") or "", + } + line |= {"갈래": hmat.APART} if apart else {} + pair = self._alias(line) + if pair: + unit = {"자재": pair} | ({"별도계상": True} if apart else {}) + return {"상태": LINKED, "방법": how, "단위": unit, "꼬리표": ["별칭 장부 짝"]} + got = material(line, self.got) + apart = apart or hmat.APART in got["꼬리표"] + found = [r for r in got["찾음"] if r.get("자재")] + if got["상태"] == LINKED and found: + unit = {"자재": found[0]["자재"]} | ({"별도계상": True} if apart else {}) + return { + "상태": LINKED, + "방법": how, + "단위": unit, + "글": got["글"], + "꼬리표": got["꼬리표"], + } + if apart: # 별도계상 = 0 원 줄(우리 자재 못 찾아도 셈 같음) + return {"상태": LINKED, "방법": APART, "단위": {"별도계상": True}, "후보": got["후보"]} + state = got["상태"] if got["상태"] != LINKED else PICK # 「전국」 자리표 = 지역 고름 + return {"상태": state, "방법": how, "단위": None, "후보": got["후보"] or _cands(got["찾음"]), + "글": got["글"], "꼬리표": got["꼬리표"]} # fmt: skip + + def _alias(self, line: dict[str, Any]) -> str | None: + """별칭 장부 자재 짝(자동 정확 열쇠 · 하나) — 숫자보다 먼저.""" + if self.books is None: + return None + got = ledger.recall(line, "", self.books, doc=self.doc) + if not got or got.get("제안") or got.get("규격다름") or got.get("갈래") != "짝": + return None + pairs = [p.get("자재") for p in got.get("짝") or []] + return pairs[0] if len(pairs) == 1 and pairs[0] else None + + def _R(self, one: dict[str, Any]) -> dict[str, Any]: + part = rate_part(one.get("이름")) + shape = one.get("기준꼴") + tags = ["이름 없음"] if one.get("이름없음") else [] + if shape in BASES: + return {"상태": LINKED, "단위": {"기준": BASES[shape], "비목": part}, "꼬리표": tags} + if shape and one.get("기준줄"): + unit = {"기준": {"행": list(one["기준줄"])}, "비목": part} + return {"상태": LINKED, "단위": unit, "글": shape, "꼬리표": tags} + guess = {"기준": _text_base(one), "비목": part} + return {"상태": PICK, "단위": None, "후보": [guess], "글": "기준 못 찾음 — 기준 고름", + "꼬리표": tags} # fmt: skip + + def _S(self, one: dict[str, Any]) -> dict[str, Any]: + sub = one.get("하위") or {} + if not sub.get("번"): + return {"상태": NONE, "단위": None, "글": "하위 번 없음"} + return {"상태": LINKED, "단위": {"하위": {"종류": sub.get("종류"), "번": int(sub["번"])}}} + + def _E(self, one: dict[str, Any]) -> dict[str, Any]: + got = one.get("금액") + if got is None: + got = unit_line.money(one.get("단가")) + if got is None: + return {"상태": NONE, "단위": None, "글": "금액 없음"} + return {"상태": LINKED, "방법": HOW["E"], "단위": {"값": float(got)}} + + +def hopyo_unit(line: dict[str, Any]) -> dict[str, Any]: + """일위대가 줄(`UnitLine.check_hopyo` 투영 뒤) → 단위 줄 꼴 — 이름 · 규격 · 단위 · 품 = 수량 · 별도.""" + one = {k: line.get(k) for k in ("묶음", "이름", "규격", "단위", "할증")} + one.update(품=line.get("수량"), 금액=unit_line.money(line.get("금액")), 직종=None) + if line.get("갈래") == "별도계상": + one["어떻게"] = "별도" + if one["묶음"] == "R": + base = str(line.get("기준") or "노무비") + one.update(기준=base, 기준꼴={"주재료비": "자재"}.get(base, base)) + one["품"] = float(line["수량"]) / 100 if line.get("수량") is not None else None + ref = line.get("참조") or {} + if one["묶음"] == "S" and ref.get("번"): + one["하위"] = {"종류": ref.get("종류"), "번": ref.get("번")} + return one + + +def summary(rows: list[dict[str, Any]]) -> dict[str, dict[str, int]]: + """묶음별 `{묶음: {이어짐, 고를 것, 못 이음}}` — 줄 연결 표.""" + out: dict[str, dict[str, int]] = {} + for kind, state in rows: + one = out.setdefault(kind, dict.fromkeys((LINKED, PICK, NONE), 0)) + one[state] += 1 + return out