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

This commit is contained in:
2026-11-02 10:51:53 +09:00
13 changed files with 946 additions and 119 deletions
+85 -7
View File
@@ -25,6 +25,11 @@
통째 대신 · 로직 자재 겹침 · 전국만. `본뜬.품셈` = 품셈으로 넣은 엑셀 줄 차례([품셈으로 바꾸기] 첫 체크) ·
`줄수 {품셈, 고정, 호표, 빠짐, 이음, 이음후보}` · 만든 값 ↔ 엑셀 호표 단가(`비교` · 같음 = 비목마다 1 원 안).
엔진 · 일위대가 계산은 그대로(`M02_Labor_Ref.Expand`).
이음(729-2 · 요구서 내역19 1장 8) = 확인 안 한 단가산출 줄 = 그 블록 SK 참조(이 층 · 같은 원본 · 없으면
`M02_Hopyo_Sangeun` 으로 그 블록만 만듦 · 고름 대기면 못 이음) · 확인한 로직 줄이 차이 SK 의 바탕이면 그 SK
참조(차이 보존) · 노임 = 이름 · 기계 = EQ(`M02_Hopyo_Fixed.Linker`) · 사용자 이음 대상 `{노임|기계: 키}` ·
`{비율: 기준}` = 고정 줄 + 그 이음 · 남은 줄(`Fixed.linked` 아님 · 「우리에 없음」 고른 줄 뺌) 있으면 409
`{까닭, 남은줄}`(저장 안 함 · `save` 거짓 = 막지 않고 `남은줄` 만) · 미리 보기 = `unlinked`(쓰기 없음).
수량(723-1 · 내역 17-1) = 저장 때 걷음(`refs.no_qty`) · 엑셀 수량은 `본뜬.실무수량 {줄: {차례: 수}, 자재: {코드: 수}}`
(출처 기록 · 셈 안 씀 · 엑셀 대조 `compare` 만 — 하위 호표 것까지 `excel_given`).
"""
@@ -43,6 +48,7 @@ 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 hopyo_match
from M02_MasterTemplete import M02_Hopyo_Material as hmat
from M02_MasterTemplete import M02_Hopyo_SkLink as sklink
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
@@ -138,6 +144,10 @@ def _missing(line: dict[str, Any], why: str) -> dict[str, Any]:
}
def _is_sk(one: dict[str, Any]) -> bool:
return str((one.get("참조") or {}).get("키") or "").startswith("SK-")
def _bad(text: str, status: int = 400) -> store.StoreError:
return store.StoreError(status, text)
@@ -156,9 +166,13 @@ class Builder:
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)
self.src, self.dry, self._sk, self._maker = Path(source), False, None, None
self.strict = True # 남은 줄 있으면 409(붙여넣기 `build_all` = 거짓)
# ── 재검사 ───────────────────────────────────────────────────────
def _target(self, raw: dict[str, Any], where: str, line: dict | None = None) -> dict[str, Any]:
if raw.get("노임") or raw.get("기계") or raw.get("비율"):
return self._link(raw, where, line)
if raw.get("전국"):
return self._national(raw, where, line)
if raw.get("호표") is None:
@@ -175,6 +189,23 @@ class Builder:
times, part = ledger.times_of(raw, where)
return {"호표": child, "배": times, "몫": part}
def _link(self, raw: dict[str, Any], where: str, line: dict | None) -> dict[str, Any]:
"""사용자 이음 대상 — `{노임: LB}` · `{기계: EQ}` · `{비율: 기준 비목}`(% 줄 · 수량 = 율)."""
keys = [k for k in ("노임", "기계", "비율") if raw.get(k)]
extra = set(raw) - {"노임", "기계", "비율", "배", "몫", "입력값"}
if len(keys) != 1 or extra or raw.get("입력값"):
raise _bad(f"이음 대상은 노임 · 기계 · 비율 중 하나만 「{where}」")
kind, code = keys[0], str(raw[keys[0]])
if kind == "비율":
if code not in labor.COSTS or line is None or finger.kind_of(line) != "%":
raise _bad(f"비율은 % 줄 · 노무비 · 재료비 · 경비 「{where}」")
if line.get("수량") is None:
raise _bad(f"% 줄 수량(율) 없음 「{where}」")
return {"이음": {"비율": code}}
if code[:2] != hfix.fixed.LINKS[kind] or not hfix.fixed._has(m01.loaded()[1], code):
raise _bad(f"{kind} 이음 「{code}」 M01 에 없음 「{where}」")
return {"이음": {kind: code}}
def _national(self, raw: dict[str, Any], where: str, line: dict | None) -> dict[str, Any]:
"""「전국」 대상 — 줄에서 서버가 다시 셈(이름 · 규격 · 단위 = 서버 것)."""
if set(raw) - {"전국", "배", "몫", "입력값"} or raw.get("입력값"):
@@ -236,11 +267,16 @@ class Builder:
hopyo, file = self.hopyo, self.doc.get("파일") or ""
lines, mats, missing, used, mat_lines = [], {}, [], set(), {}
nation: dict[tuple, dict[str, Any]] = {}
taken, linker, poomsem, kids, kid_n = set(), hfix.Linker(), [], set(), 0
taken, linker, poomsem, kids, kid_n = set(), hfix.Linker(self.strict and save), [], set(), 0
left: list[dict[str, Any]] = []
where = {"파일": file, "호표": self.no}
rows = hopyo.get("줄") or []
def put(line: dict[str, Any], one: dict[str, Any], alias: str | None) -> None:
if "이음" in one:
others = [x for x in rows if x is not line]
lines.append(hfix.line_of(line, others, where, taken, linker, one["이음"]))
return
qty = Decimal(str(line.get("수량") or 0)) * Decimal(str(one["배"]))
memo = _memo(line, one)
if "전국" in one:
@@ -279,6 +315,9 @@ class Builder:
lines.append({"참조": ref, "별칭": alias, "메모": memo, "수량": {"손": _num(qty)}})
return
row = {"로직": one["로직"], "입력값": one["입력값"], "메모": memo}
if (kept := sklink.kept(self, line, one, qty, alias, memo)) is not None:
lines.append(kept)
return
if one["로직"] not in used:
used.add(one["로직"])
lines.append({**row, "별칭": alias, "수량": {"손": _num(qty)}})
@@ -293,7 +332,7 @@ class Builder:
"자재": [],
"본뜬": self._origin(f"{mark(self.key, self.no, at)} {one['로직']}", stamp),
}
ref, _ = _save(wrap)
ref = {"키": "SC-미리"} if self.dry else _save(wrap)[0] # 미리 보기 = 쓰기 없음
lines.append({"참조": ref, "별칭": alias, "메모": memo, "수량": {"손": _num(qty)}})
book: dict[str, dict[str, Any]] = {}
@@ -311,11 +350,15 @@ class Builder:
if got and got.get("뺌") == "안 씀":
missing.append(_missing(line, f"뺌 — {got['뺌']}"))
continue
if not got or got.get("뺌"): # 빠짐 0(705-3) — 하위 호표 참조 · 아니면 고정 줄
if not got or got.get("뺌"): # 빠짐 0(705-3) — 하위 호표 · SK 참조 · 아니면 고정 줄
kid = self._kid(line, kids)
kid_n += bool(kid)
sk, why = (None, None) if kid or got else sklink.sk_line(self, line, save)
others = [x for x in rows if x is not line]
lines.append(kid or hfix.line_of(line, others, where, taken, linker))
one = kid or sk or hfix.line_of(line, others, where, taken, linker)
lines.append(one)
if not hfix.linked(one, bool(got)):
left.append(sklink.left(line, why))
continue
poomsem.append(at)
alias = str(line.get("이름") or "")[: labor.ALIAS_MAX].strip() or None
@@ -339,18 +382,32 @@ class Builder:
if nation:
doc["본뜬"]["전국자재"] = list(nation.values())
doc["본뜬"]["품셈"] = poomsem
if self.dry:
return {"남은줄": left}
if left and save and self.strict:
raise store.StoreError(409, {"까닭": sklink.LEFT, "남은줄": left})
fixed_n = sum(1 for x in lines if "고정" in x)
links = sum(1 for x in lines if any(((x.get("고정") or {}).get("이음") or {}).values()))
count = {"품셈": len(poomsem), "고정": fixed_n, "호표": kid_n}
count |= {"빠짐": sum(1 for x in missing if x["까닭"] != TWICE), "이음": links}
count["단가산출"] = sum(1 for x in lines if _is_sk(x))
if not save:
ledger.write(self.folder, book, self.who, file or None)
return {"문서": doc, "빠진줄": missing, "줄수": {**count, "이음후보": linker.near}}
head = {"문서": doc, "빠진줄": missing, "남은줄": left}
return {**head, "줄수": {**count, "이음후보": linker.near}}
ref, again = _save(doc)
wrote = ledger.write(self.folder, book, self.who, file or None)
out = {"참조": ref, "다시씀": again, "빠진줄": missing, "장부": {"씀": wrote}}
return {**out, "줄수": {**count, "이음후보": linker.near}}
def unlinked(self, checked: dict[int, dict[str, Any]], whole: list[dict[str, Any]]) -> list:
"""미리 보기 — 만들면 남을 줄(쓰기 없음 · SK 는 만들 수 있는지만 셈)."""
self.dry = True
try:
return self.build(checked, whole)["남은줄"]
finally:
self.dry = False
def _kid(self, line: dict[str, Any], kids: set[int]) -> dict[str, Any] | None:
"""확인 안 한 호표 줄 → 확인된 하위 사전조합 참조(단위 같음 · 같은 하위 한 번) · 아니면 None."""
ref = line.get("참조") or {}
@@ -449,6 +506,21 @@ def compare(ref: dict[str, Any], hopyo: dict[str, Any]) -> dict[str, Any]:
return {"엑셀": excel, "우리": ours, "차이": gap, "같음": same, "까닭": got.get("까닭")}
def unlinked(
folder: Path,
source: Path,
key: str,
no: int,
lines: list[dict[str, Any]] | None = None,
whole: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""안 이은 줄 미리 보기(729-3) — 몸 = 만들기 몸 → `{남은줄, 수}`(저장 없음)."""
maker = Builder(folder, source, key, no, None)
checked, pairs = maker.check(list(lines or []), whole)
left = maker.unlinked(checked, pairs)
return {"남은줄": left, "수": len(left)}
def build(
folder: Path,
source: Path,
@@ -486,8 +558,12 @@ def _order(units: dict[int, dict[str, Any]]) -> list[int]:
return out
def build_all(folder: Path, source: Path, key: str, who: Any = None) -> dict[str, Any]:
def build_all(
folder: Path, source: Path, key: str, who: Any = None, strict: bool = False
) -> dict[str, Any]:
"""[모두 고정으로 들여오기](705-3) — 시트 호표 모두 · 하위 먼저 · 줄 = 고정(하위 = 참조) ·
`strict`(불러오기 길 · 729-2) = 남은 줄 있는 호표는 안 만듦(`실패 {번, 까닭, 남은줄}`) ·
붙여넣기(705-2)는 막지 않음 ·
이미 확인된 호표는 덮지 않음(`그대로`) → `{호표, 줄, 품셈, 고정, 호표참조, 빠짐, 이음, 이음후보,
같음, 그대로, 다름 [{번, 이름, 엑셀, 우리, 차이}], 실패 [{번, 까닭}], 만듦 [{번, 이름, 키}]}`."""
units = {int(h["번"]): h for h in hopyo_store.read(source, key).get("호표") or []}
@@ -505,6 +581,7 @@ def build_all(folder: Path, source: Path, key: str, who: Any = None) -> dict[str
out["줄"] += len(units[no].get("줄") or [])
try:
maker = Builder(folder, source, key, no, who)
maker.strict = strict
have = maker.done.get(mark(key, no))
if have:
out["그대로"] += 1
@@ -517,7 +594,8 @@ def build_all(folder: Path, source: Path, key: str, who: Any = None) -> dict[str
got = compare(ref, maker.hopyo)
out["만듦"].append({"번": no, "이름": units[no].get("이름"), "키": ref["키"]})
except store.StoreError as e:
out["실패"].append({"번": no, "까닭": str(e.detail)})
why = e.detail if isinstance(e.detail, dict) else {"까닭": str(e.detail)}
out["실패"].append({"번": no, **why})
continue
if got["같음"]:
out["같음"] += 1
+52 -9
View File
@@ -4,8 +4,9 @@
숫자는 서버가 실무자료 파일에서 읽음(브라우저 값 안 받음).
단가 = 엑셀 단가 노무비 · 재료비 · 경비(빈 칸 0) · 합계만 있는 줄 = 재료비(4장 3) ·
수량 없음 = 수량 1 · 단가 = 엑셀 금액(「금액만」).
이음 = 이름 · 규격 · 단위 · 단가가 다 같은 노임(LB) · 자재(MT)만 저절로 → 엑셀 합계 = 우리 합계 ·
단가가 다른 짝 = `후보` 수만(바꾸기는 705-2 「이 공사 단가로 다시 잇기」).
이음 = 노임(LB) · 자재(MT) = 이름 · 규격 · 단위 · 단가 다 같은 것만 · `by_name`(729-2 불러오기 길 ·
요구서 내역19 3장 7) = 노임 = 이름 같은 LB 하나(값 = 지금 단가 책) · 기계 = 이름(갈래) · 규격 수 같은 EQ
하나 · 못 이은 짝 = `후보` 수만 · 사용자가 고른 이음(`force` {노임|기계|자재: 키} · {비율: 기준}) = 그대로.
% 줄 = 비율 줄 `비율 {기준, 비목}` — 엑셀 밑수(단가 계) = 다른 줄 금액[기준] 합일 때만 ·
아니면(주재료비 등) 수량 1 · 「식」 · 단가 = 엑셀 금액.
같은 이름 · 규격 · 단위 둘째부터 = 이름 끝 「 (2)」(줄 가름 · 705-1 400 피함).
@@ -25,6 +26,7 @@ from M02_MasterTemplete import M02_Hopyo_Material as hmat
from M02_MasterTemplete import M02_Hopyo_Shares as shares
from M02_MasterTemplete import M02_Labor_Doc as labor
from M02_MasterTemplete import M02_Labor_Fixed as fixed
from M02_MasterTemplete.M02_Hopyo_Words import mtypes, specnums
COSTS = ("노무비", "재료비", "경비")
_BASE = re.compile(r"(?<!주)(노무비|재료비|경비|인건비)\s*의")
@@ -32,6 +34,7 @@ GAP = Decimal(1) # 밑수 · 단가 같음 판정 끝수(원)
_d, _plain = fixed._num, fixed._plain
mm_plain = m01.cm.mf.mp.mm.plain
def _parts(money: Any) -> dict[str, Decimal]:
@@ -56,29 +59,62 @@ def _ratio(line: dict[str, Any], others: list[dict[str, Any]]) -> dict[str, str]
return {"기준": base, "비목": paid[0]} if abs(total - under) <= GAP else None
class Linker:
"""이음 — 노임(LB 이름 같음) · 자재(이름 · 규격 같은 한 품목 · 단위 같음)가 단가까지 같을 때만."""
def linked(one: dict[str, Any], decided: bool = False) -> bool:
"""일위대가 줄 하나가 M01 · 기초 · SK 에 이어짐 — 로직 · 참조 · 비율 · 이음 있는 고정 줄 ·
`decided` = 사용자가 「우리에 없음」으로 고른 줄."""
fx = one.get("고정") if isinstance(one, dict) else None
if not isinstance(fx, dict):
return True
return decided or bool(fx.get("비율")) or any((fx.get("이음") or {}).values())
def __init__(self) -> None:
class Linker:
"""이음 — 노임(LB 이름) · 기계(EQ 갈래 · 규격) · 자재(이름 · 규격 같은 한 품목 · 단위 · 단가 같음)."""
def __init__(self, by_name: bool = False) -> None:
self.by_name = (
by_name # 729-2 불러오기 길만 — 붙여넣기 · B09 통째 = 단가 같을 때만(엑셀 합계 그대로)
)
self.whole = m01.loaded()[1]
self.got = hm.targets()
self.labor: dict[str, list[tuple[str, Decimal | None]]] = {}
for row in self.whole.numbers.get("LB", {}).values():
pair = (str(row.get("키")), _d(row.get("값")))
self.labor.setdefault(str(row.get("이름") or ""), []).append(pair)
self.near = 0 # 단가 다른 이음 후보 수
self.machines = [
(str(row.get("키")), mtypes(f"{row.get('상세구분')} {row.get('이름')}"),
set(specnums(row.get("규격"))), mm_plain(row.get("이름")), mm_plain(row.get("규격")))
for row in self.whole.numbers.get("EQ", {}).values()
] # fmt: skip
self.near = 0 # 못 이은 이음 후보 수
def link(self, line: dict[str, Any], price: dict[str, Decimal]) -> dict[str, str | None]:
out: dict[str, str | None] = {"노임": None, "자재": None, "기계": None}
kind = finger.kind_of(line)
if kind == "노임":
pairs = self.labor.get(shares.trade(line.get("이름")) or "", [])
out["노임"] = self._same(pairs, price["노무비"])
one = [code for code, _ in pairs]
same = one[0] if self.by_name and len(one) == 1 else None
out["노임"] = same or self._same(pairs, price["노무비"])
elif kind == "기계" and self.by_name:
out["기계"] = self._machine(line)
elif kind in hm.MATERIAL_KINDS and price["재료비"]:
out["자재"] = self._same(self._mats(line), price["재료비"])
# ponytail: 기계 이음 없음 — 기계 시간 단가는 B09 중기목록표 셈 · 같은 값 판정 못 함
return out
def _machine(self, line: dict[str, Any]) -> str | None:
"""기계 줄 → EQ 하나 — 이름 · 규격 글 같음 › 갈래 겹침 + 규격 수 같음(하나일 때만)."""
name, spec = mm_plain(line.get("이름")), mm_plain(line.get("규격"))
same = [c for c, _, _, n, s in self.machines if n == name and s == spec]
if len(same) != 1:
text = f"{line.get('이름') or ''} {line.get('규격') or ''}"
kinds, nums = mtypes(text), set(specnums(text))
same = [c for c, t, s, _, _ in self.machines if kinds & t and nums and nums == s]
if len(same) == 1:
return same[0]
self.near += bool(same)
return None
def _same(self, pairs: list[tuple[str, Decimal | None]], want: Decimal) -> str | None:
same = [code for code, value in pairs if value is not None and abs(value - want) < GAP]
if len(same) == 1:
@@ -110,11 +146,16 @@ def line_of(
where: dict[str, Any],
taken: set[str],
linker: Linker,
force: dict[str, str] | None = None,
) -> dict[str, Any]:
"""엑셀 줄 하나 → 일위대가 고정 줄 `{고정, 별칭, 메모, 수량}` — `taken` = 이 호표 줄 가름(채워 감)."""
"""엑셀 줄 하나 → 일위대가 고정 줄 `{고정, 별칭, 메모, 수량}` — `taken` = 이 호표 줄 가름(채워 감) ·
`force` = 사용자가 고른 이음 `{노임|기계|자재: 키}` · 비율 `{비율: 기준 비목}`(수량 = 율 %)."""
qty = _d(line.get("수량"))
spec, unit = str(line.get("규격") or "").strip(), str(line.get("단위") or "").strip()
rate = _ratio(line, others) if finger.kind_of(line) == "%" else None
if force and force.get("비율"):
paid = [k for k, v in _parts(line.get("금액")).items() if v]
rate = {"기준": force["비율"], "비목": paid[0] if len(paid) == 1 else force["비율"]}
memo = f"실무 {qty if qty is not None else ''}{unit} 그대로"
if rate:
price = dict.fromkeys(COSTS, Decimal(0))
@@ -135,6 +176,8 @@ def line_of(
"단가": {k: _plain(v) for k, v in price.items()},
}
fx["이음"] = dict.fromkeys(fixed.LINKS) if rate else linker.link(line, price)
if force and not rate:
fx["이음"] = {k: force.get(k) for k in fixed.LINKS}
fx["출처"] = {**where, "줄": int(line.get("차례", 0))}
if rate:
fx["비율"] = rate
+143 -57
View File
@@ -1,11 +1,16 @@
"""M02 일위대가 불러오기 → 단가산출(PLAN 718-1 · 요구서 내역12 4장 22) —
산근(단산) 블록 = 바탕 없는 단가산출.
"""M02 일위대가 불러오기 → 단가산출(PLAN 718-1 · 729-1 · 요구서 내역19 1장 7 · 3장 6) —
산근(단산) 블록마다 매칭 → 비교 → 기초 · SK · 고름 대기.
블록마다 SK 하나 — 그 블록을 부르는 첫 엑셀 줄(「단산N참조」)의 1 단위 단가
(노무비 · 재료비 · 경비)를 고정 더한줄 하나로(`M02_Hopyo_Fixed` 꼴 · 엑셀 숫자 그대로 ·
서버가 파일에서 읽음) · 이름 = 블록 이름(없으면 줄 품명) · 단위 = 줄 단위(없으면 블록 · 「식」) ·
구분 「실무자료」 · 상세구분 = 파일 · 본뜬 = {층 「실무자료」, 키 「HP-###### 단산N」, 파일, 원본,
확인} · 같은 본뜬이 이 층에 있으면 그대로(겹침 없음) · 만드는 층 = 부르는 층(마법사에서 고른 층).
블록마다 그 블록을 부르는 첫 엑셀 줄(「단산N참조」)의 맞춤셈(`M02_Hopyo_Fit` 캐시 · 없으면 그 블록만
셈) → `M02_Hopyo_SkPatch.plan`:
기초 = SK 바탕 = 1위 기초 · 입력값 · 덮개 없음 · SK = 바탕 기초 + 덮개 · 더한줄 · 뺀줄(엑셀 품 그대로) ·
고름 대기 = SK 안 만듦(바탕 없는 SK 로 몰래 채우지 않음) · 후보 다섯 → 사용자가 고른 기초 `바탕 {번: {로직,
입력값?}}` 로 다시 부르면 그 기초에 견줘 SK(이미 있으면 그 키에 덮음).
SK = 이름 블록 이름(없으면 줄 품명) · 단위 = 줄 단위(없으면 블록 · 「식」) · 구분 「실무자료」 · 상세구분 = 파일 ·
본뜬 = {층 「실무자료」, 키 「HP-###### 단산N」, 파일, 원본, 확인, 갈래, 엑셀품, 못옮김} · 같은 본뜬(키 · 원본)이
이 층에 있으면 그대로(겹침 없음) · 만드는 층 = 부르는 층.
별칭 제안 = 기초 · SK 만든 블록의 엑셀 줄 → 이 층 별칭 장부 짝 = 바탕 기초까지만(차이 SK 는 이 층 문서 ·
요구서 3장 6) · 장부에 이미 짝 · 갈래가 있는 줄은 그대로 · 승인 길 = M01 별칭 장부(716).
"""
from __future__ import annotations
@@ -15,10 +20,13 @@ from pathlib import Path
from typing import Any
from common_util.common_util_time import utc_now_text
from M02_MasterTemplete import M02_Hopyo_Fixed as hfix
from M01_MasterData import M01_MasterData_Store as m01
from M02_MasterTemplete import M02_Hopyo_Fit as fit
from M02_MasterTemplete import M02_Hopyo_Ledger as ledger
from M02_MasterTemplete import M02_Hopyo_SkPatch as skpatch
from M02_MasterTemplete import M02_Hopyo_Store as hopyo_store
from M02_MasterTemplete import M02_Labor_Fixed as fixed
from M02_MasterTemplete import M02_Labor_Ref as refs
from M02_MasterTemplete import M02_MasterTemplete_Store as store
ORIGIN = "실무자료"
KIND = "sangeun"
@@ -28,76 +36,154 @@ def mark(key: str, no: int) -> str:
return f"{key} 단산{no}"
def made(folder: str | Path, key: str | None = None) -> dict[str, str]:
"""이 층 실무자료 단가산출 `{본뜬 키: SK 키}`(`key` = 그 HP 것만)."""
def made(
folder: str | Path, key: str | None = None, source: refs.Here | None = None
) -> dict[str, str]:
"""이 층 실무자료 단가산출 `{본뜬 키: SK 키}`(`key` = 그 HP 것만 · `source` = 파일 층으로 거름)."""
out = {}
for path in sorted((Path(folder) / KIND).glob("SK-*.json")):
try:
got = json.loads(path.read_bytes()).get("본뜬")
except (OSError, ValueError, AttributeError):
continue
if isinstance(got, dict) and got.get("층") == ORIGIN:
if key is None or str(got.get("키") or "").startswith(f"{key} "):
out[str(got.get("키"))] = path.stem
if not isinstance(got, dict) or got.get("층") != ORIGIN:
continue
if key is not None and not str(got.get("키") or "").startswith(f"{key} "):
continue
if source is None or not got.get("원본") or tuple(got["원본"]) == tuple(source):
out[str(got.get("키"))] = path.stem
return out
def _doc(line: dict, block: dict, no: int, hp: dict, stamp: str, who: Any) -> dict[str, Any]:
def calls(hp: dict[str, Any]) -> tuple[dict[int, dict], dict[int, dict]]:
"""(블록 {번: 블록}, 부르는 첫 줄 {번: 줄 + `_호표`})."""
blocks = {int(b["번"]): b for b in hp.get("단산") or [] if b.get("번") is not None}
first: dict[int, dict] = {}
for h in hp.get("호표") or []:
for line in h.get("줄") or []:
ref = line.get("참조") or {}
no = int(ref.get("번") or 0) if ref.get("종류") == "단산" else 0
if no in blocks and no not in first:
first[no] = {**line, "_호표": h.get("번")}
return blocks, first
def _bad(text: str, status: int = 400) -> store.StoreError:
return store.StoreError(status, text)
def _picks(raw: Any) -> dict[int, dict[str, Any]]:
"""사용자가 고른 기초 `{번: {로직, 입력값?}}` 재검사 — 돈 로직만 · 입력 이름 = 그 로직 입력."""
if not raw:
return {}
if not isinstance(raw, dict) or len(raw) > 500:
raise _bad("바탕은 {블록 번: {로직, 입력값}}")
logics = ledger._known()[0]
out = {}
for no, one in raw.items():
if not isinstance(one, dict) or str(one.get("로직") or "") not in logics:
raise _bad(f"단산{no} · 돈 로직이 아님 「{(one or {}).get('로직')}」")
given = one.get("입력값")
if given is not None:
bad = set(given) - ledger.input_names(one["로직"]) if isinstance(given, dict) else {1}
if bad:
raise _bad(f"단산{no} · 모르는 입력 {sorted(map(str, bad))}")
out[int(no)] = {"로직": one["로직"], **({"입력값": given} if given is not None else {})}
return out
def _doc(line: dict, block: dict, no: int, hp: dict, plan: dict, sure: dict) -> dict[str, Any]:
name = str(block.get("이름") or line.get("이름") or "").strip() or f"단산{no}"
unit = str(line.get("단위") or block.get("단위") or "").strip() or "식"
spec = str(line.get("규격") or "").strip()
price = {k: hfix._plain(v) for k, v in hfix._parts(line.get("단가")).items()}
fx = {
"이름": name,
"규격": spec,
"단위": unit,
"단가": price,
"이음": dict.fromkeys(fixed.LINKS),
"출처": {"파일": hp.get("파일"), "호표": line.get("_호표"), "줄": int(line.get("차례", 0))},
}
keep = {k: plan[k] for k in ("갈래", "엑셀품", "못옮김")}
return {
"종류": "단가산출",
"이름": name,
"단위": unit,
"구분": ORIGIN,
"상세구분": str(hp.get("파일") or ""),
"본뜬": {
"층": ORIGIN,
"키": mark(hp["키"], no),
"파일": hp.get("파일"),
"원본": list(refs.here()),
"확인": {"누가": who, "때": stamp},
},
"바탕": None,
"입력값": {},
"본뜬": {"층": ORIGIN, "키": mark(hp["키"], no), "파일": hp.get("파일"), **sure, **keep},
"바탕": plan["바탕"],
"입력값": plan["입력값"],
"열린입력": [],
"고름": {},
"자재표시": {},
"덮개": {},
"더한줄": [{"고정": fx, "이름": name, "단위": unit, "수량": 1}],
"뺀줄": [],
**{k: plan[k] for k in ("자재표시", "덮개", "더한줄", "뺀줄")},
}
def build(folder: Path, key: str, who: Any = None) -> dict[str, Any]:
"""[단가산출 만들기] — 산근 블록마다 바탕 없는 단가산출 →
`{블록, 만듦 [{번, 이름, 키}], 그대로}`."""
hp = hopyo_store.read(folder, key)
blocks = {int(b["번"]): b for b in hp.get("단산") or [] if b.get("번") is not None}
calls: dict[int, dict] = {}
for h in hp.get("호표") or []:
for line in h.get("줄") or []:
ref = line.get("참조") or {}
no = int(ref.get("번") or 0) if ref.get("종류") == "단산" else 0
if no in blocks and no not in calls:
calls[no] = {**line, "_호표": h.get("번")}
have, stamp = made(folder, key), utc_now_text()
out: dict[str, Any] = {"블록": len(calls), "만듦": [], "그대로": 0}
for no in sorted(calls):
if mark(key, no) in have:
def _alias(line: dict, plan: dict) -> dict[str, Any]:
origin = {"품명": line.get("이름"), "규격": line.get("규격"), "단위": line.get("단위")}
clauses = (line.get("근거") or {}).get("출처") or []
pair = {"로직": plan["바탕"]["로직"], "입력값": plan["입력값"], "배": 1, "몫": None}
return {
"원래": {k: str(v or "") for k, v in origin.items()},
"갈래": "짝",
"짝": [pair],
"엑셀조항": clauses if isinstance(clauses, list) else [],
}
class Maker:
"""한 번 만들기 — 파일 · 맞춤셈 · 이 층 SK 를 한 번 읽음(`source` = 파일 층 · 쓰기 = `store.root()`)."""
def __init__(self, source: Path, key: str, who: Any = None) -> None:
self.source, self.key, self.who = Path(source), key, who
self.hp = hopyo_store.read(source, key)
self.blocks, self.first = calls(self.hp)
got = fit.result(source, key) or {}
self.cache, self.done = got.get("블록") or {}, bool(got.get("끝"))
self.here = refs.where(source)
self.whole = m01.loaded()[1]
self.sure = {"원본": list(self.here), "확인": {"누가": who, "때": utc_now_text()}}
def plan(self, no: int, pick: dict | None = None) -> dict[str, Any]:
line, block = self.first[no], self.blocks[no]
entry = self.cache.get(fit.unit_key(line))
if entry is None and not pick:
entry = fit.compute(block, line)
return skpatch.plan(entry, block, line, self.whole, pick)
def write(self, no: int, plan: dict, have: str | None) -> str:
doc = _doc(self.first[no], self.blocks[no], no, self.hp, plan, self.sure)
if have:
raw = (store.root() / KIND / f"{have}.json").read_bytes()
store.write(KIND, have, store.version_of(raw), {**doc, "키": have})
return have
return refs.create(KIND, doc)["이름"]
def build(folder: Path, key: str, who: Any = None, picks: Any = None) -> dict[str, Any]:
"""[단가산출 만들기] — 산근 블록마다 기초 · SK · 고름 대기 → `{블록, 만듦 [{번, 이름, 키, 갈래}],
그대로, 기초, SK, 고름대기 [{번, 이름, 단위, 까닭, 후보}], 별칭}` · `picks` = 고른 기초."""
chosen = _picks(picks)
maker = Maker(folder, key, who)
bad = sorted(set(chosen) - set(maker.first))
if bad:
raise _bad(f"없는 블록 · 부르는 줄 없음 「단산{bad[0]}」", 404)
if not maker.done and set(maker.first) - set(chosen):
fit.ensure(folder, key)
raise _bad("맞춤 셈 중 — 끝나면 다시 누름", 409)
have = made(folder, key, maker.here)
out: dict[str, Any] = {"블록": len(maker.first), "만듦": [], "그대로": 0, "고름대기": []}
out.update({skpatch.BASE: 0, skpatch.SK: 0})
books, alias = ledger.books(folder), {}
for no in sorted(maker.first):
old = have.get(mark(key, no))
if old and no not in chosen:
out["그대로"] += 1
continue
doc = _doc(calls[no], blocks[no], no, hp, stamp, who)
got = refs.create(KIND, doc)
out["만듦"].append({"번": no, "이름": doc["이름"], "키": got["이름"]})
plan = maker.plan(no, chosen.get(no))
line = maker.first[no]
if plan["갈래"] == skpatch.WAIT:
head = {"번": no, "이름": maker.blocks[no].get("이름") or line.get("이름")}
out["고름대기"].append({**head, "단위": line.get("단위"), **plan})
continue
got = maker.write(no, plan, old)
out[plan["갈래"]] += 1
name = maker.blocks[no].get("이름") or line.get("이름")
out["만듦"].append({"번": no, "이름": name, "키": got, "갈래": plan["갈래"]})
found = ledger.recall(line, folder, books)
if no in chosen or found is None or found.get("규격다름"):
alias[ledger.key(line)] = _alias(line, plan)
out["별칭"] = ledger.write(folder, alias, who, maker.hp.get("파일") or None)
return out
+108
View File
@@ -0,0 +1,108 @@
"""M02 일위대가 만들기 — 단가산출(SK) 이음 · 남은 줄(PLAN 729-2 · 요구서 내역19 1장 8).
`M02_Hopyo_Build.Builder` 가 부름(`b` = 그 만들기) —
확인 안 한 단가산출 줄 = 그 블록 SK 참조(이 층 · 같은 원본 · 없으면 `M02_Hopyo_Sangeun.Maker` 로 그 블록만
만듦 · 미리 보기 = 키만 · 맞춤 셈 전 · 고름 대기 · 단위 다름 = 못 이음) ·
확인한 로직 줄이 차이 SK 의 바탕 = 그 SK 참조(엑셀 차이 보존 · 별칭 짝 = 기초까지만) ·
남은 줄 = 엑셀 줄 · 갈래 · 까닭 · 후보(단가산출 고름 대기).
"""
from __future__ import annotations
import json
from decimal import Decimal
from typing import Any
from M01_MasterData import M01_MasterData_Store as m01
from M02_MasterTemplete import M02_Hopyo_Finger as finger
from M02_MasterTemplete import M02_Hopyo_Sangeun as sangeun
from M02_MasterTemplete import M02_Labor_Doc as labor
from M02_MasterTemplete import M02_Labor_Ref as refs
mm_unit = m01.cm.mf.mp.mm.plain_unit
LEFT = "안 이은 줄이 남음 — 줄마다 이을 것을 고름"
WHY = {
"단가산출": "맞는 기초 없음 — 고름 대기",
"자재": "자재 후보 여럿 · 없음",
"별도계상": "자재 후보 여럿 · 없음",
"노임": "같은 이름 노임 없음",
"기계": "같은 기계(EQ) 없음 · 여럿",
"호표": "하위 호표 확인 전 · 단위 다름",
"%": "밑수가 다른 줄 금액 합 아님",
}
def _num(value: Decimal) -> int | float:
value = value.normalize()
return int(value) if value == value.to_integral_value() else float(value)
def sks(b: Any) -> dict[str, str]:
"""이 층 그 HP 의 SK `{본뜬 키: SK 키}`(한 번 읽어 둠)."""
if b._sk is None:
b._sk = sangeun.made(b.folder, b.key, b.source)
return b._sk
def sk_doc(b: Any, key: str) -> dict[str, Any]:
try:
got = json.loads((b.folder / "sangeun" / f"{key}.json").read_bytes())
except (OSError, ValueError):
return {}
return got if isinstance(got, dict) else {}
def _ref(key: str, line: dict, alias: Any, memo: str, qty: Decimal) -> dict[str, Any]:
head = {"층": refs.here()[0], "주인": refs.here()[1], "키": key}
return {"참조": head, "별칭": alias, "메모": memo, "수량": {"손": _num(qty)}}
def sk_line(b: Any, line: dict[str, Any], create: bool) -> tuple[dict | None, dict | None]:
"""단가산출 줄 → (그 블록 SK 참조 줄, 못 이은 까닭 {까닭, 후보?}) — `create` = 없으면 만듦."""
ref = line.get("참조") or {}
if ref.get("종류") != "단산":
return None, None
no = int(ref.get("번") or 0)
key = sks(b).get(sangeun.mark(b.key, no))
if key is None and create:
if b._maker is None:
b._maker = sangeun.Maker(b.src, b.key, b.who)
if no not in b._maker.first:
return None, None
if not b._maker.done:
return None, {"까닭": "맞춤 셈 전 — 단가산출 못 만듦"}
plan = b._maker.plan(no)
if plan["갈래"] == sangeun.skpatch.WAIT:
return None, {"까닭": plan["까닭"], "후보": plan["후보"]}
key = f"SK-미리{no}" if b.dry else b._maker.write(no, plan, None)
sks(b)[sangeun.mark(b.key, no)] = key
if key is None:
return None, None
unit = sk_doc(b, key).get("단위") or line.get("단위")
if mm_unit(unit) != mm_unit(line.get("단위")):
return None, {"까닭": f"단가산출 단위 「{unit}」 다름"}
qty = Decimal(str(line.get("수량") or 0))
alias = str(line.get("이름") or "")[: labor.ALIAS_MAX].strip() or None
return _ref(key, line, alias, f"실무 {qty}{line.get('단위') or ''} · 단산{no}", qty), None
def kept(b: Any, line: dict, one: dict, qty: Decimal, alias: Any, memo: str) -> dict | None:
"""확인한 로직 줄이 그 블록 차이 SK 의 바탕 = 그 SK 참조 · 아니면 None."""
ref = line.get("참조") or {}
if ref.get("종류") != "단산" or one.get("몫") is not None:
return None
key = sks(b).get(sangeun.mark(b.key, int(ref.get("번") or 0)))
doc = sk_doc(b, key) if key else {}
base = (doc.get("바탕") or {}).get("로직")
if (doc.get("본뜬") or {}).get("갈래") != sangeun.skpatch.SK or base != one["로직"]:
return None
return _ref(key, line, alias, f"{memo} · 차이 SK", qty)
def left(line: dict[str, Any], why: dict | None) -> dict[str, Any]:
"""남은 줄 한 줄 — 엑셀 줄 · 갈래 · 까닭 · 후보."""
kind = finger.kind_of(line)
keep = ("차례", "이름", "규격", "단위", "수량")
out = {**{k: line.get(k) for k in keep}, "갈래": kind}
out["까닭"] = (why or {}).get("까닭") or WHY.get(kind, "이을 것 없음")
return {**out, "후보": (why or {}).get("후보") or []}
+294
View File
@@ -0,0 +1,294 @@
"""M02 실무 단가산출(산근 블록) → 단가산출(SK) 꼴(PLAN 729-1 · 요구서 내역19 1장 7 · 3장 6 ·
분석 19-B D-1).
블록 맞춤셈 한 칸(`M02_Hopyo_Verdict.block` 꼴 · 캐시 `M02_Hopyo_Fit.result`) → 갈래 셋:
기초 = 판정 맞음(또는 견줘 차이 없음) — 바탕 = 1위 기초 · 입력값 = 옵션 거꾸로 찾은 값 · 덮개 없음
SK = 다른 점 — 바탕 = 1위 기초(조합 둘째부터 = 더한줄 로직 · 수량 = 몫) + 차이 = 엑셀 품 그대로
인력 = 직종마다 덮개 qty(줄 하나) · 배율(여럿) · 엑셀만 직종 = 더한줄 인력 · 우리만 = 뺀줄
장비 = 시간 같은 같은 갈래 줄은 그대로 · 같은 갈래 남은 줄 = 덮개 qty · 엑셀만 = 더한줄
기계경비 로직(굴착기 GC000267 · 그 밖 GC000268 · 기계 = 옵션 거꾸로 찾기) · 남은 우리 = 뺀줄
자재 = 같은 이름 우리 줄 수량 덮개 · 옮기지 못한 차이 = `못옮김`(버리지 않고 본뜬에 적음)
고름 대기 = 후보 · 숫자 없음 — SK 안 만듦 · 후보 다섯(사용자가 고른 기초 `pick` = 견줘 SK · 기초).
cid = `kind:키#n`(첫 기초 바탕 줄만 · `M02_Sangeun_Doc.line_ids`) · 더한줄 · 덮개 qty = 바탕 행 몫
(1 단위 품 × 바탕 결과단위 앞 수 · 셈이 ÷ 배) · 더한줄 로직 수량 = 몫 × 바탕 배 ÷ 그 기초 배.
품 견줌 = 우리 셈(`M02_Sangeun_Calc.run` 줄 수량 ÷ 배) · 같음 = `Option.near`(0.5 % + 엑셀 자릿수 반올림).
우리만 자재 줄은 그대로(판정 견줌도 안 봄 · 엑셀은 자재를 일위대가 줄로 따로 적는 일이 많음).
서버 단독 · 읽기만(쓰기 = `M02_Hopyo_Sangeun`).
"""
from __future__ import annotations
import re
from decimal import Decimal
from typing import Any
from M02_MasterTemplete import M02_Hopyo_Combine as combine
from M02_MasterTemplete import M02_Hopyo_Finger as finger
from M02_MasterTemplete import M02_Hopyo_Fit as fit
from M02_MasterTemplete import M02_Hopyo_Option as option
from M02_MasterTemplete import M02_Hopyo_Ours as ours
from M02_MasterTemplete import M02_Sangeun_Calc as calc
from M02_MasterTemplete.M02_Hopyo_Words import mtypes, specnums
BASE, SK, WAIT = "기초", "SK", "고름 대기"
KEYS = ("덮개", "자재표시", "더한줄", "뺀줄", "못옮김")
TINY = 1e-9
_ADD = re.compile(r"^add:#(\d+)$")
def _near(a: float, b: float) -> bool:
return abs(a - b) <= TINY or option.near(a, b, fit.TOL[0], option.ru(a))
def _num(x: float) -> int | float:
got = Decimal(repr(float(x))).normalize()
return int(got) if got == got.to_integral_value() else float(got)
# ── 우리 셈 → 1 단위 품 ──────────────────────────────────────────────
def _types(sub: str, item: dict, args: str | None, given: dict, auto: dict) -> tuple[set, set]:
"""기계경비 줄 갈래 · 규격 — `M02_Hopyo_Ours._engine` 과 같은 읽기(중간값 없이)."""
ts = set(ours.FIXED_MACH.get(sub, set())) | mtypes(item.get("이름"))
sp = set(specnums(item.get("이름")))
for v in ours._args(args).values():
lit = v.strip("'\"")
if not ours.EQ_CODE.match(lit):
lit = str(given.get(lit) or auto.get(lit) or "")
if ours.EQ_CODE.match(lit):
a, b = ours.eq_of(lit)
ts, sp = ts | a, sp | b
return ts, sp
def parts(doc: dict, whole: Any) -> list[dict[str, Any]]:
"""SK 문서 → 줄마다 `{cid, 갈래 인력|장비|자재|기초|그밖, 이름 · 종류 · 규격, 품(1 단위)}` ·
더한줄 기초 = 그 기초 1 단위 품 × 줄 몫(`L` · `X`)."""
got = calc.resolve(doc, whole)
items = got["줄"]["호표"]
ran = calc.run(doc, whole)
times = Decimal(str(ran["배"]))
labor = ours._labor_names()
auto = {}
if (doc.get("바탕") or {}).get("로직"):
auto = option.m01.mau.values(whole, whole.logic(doc["바탕"]["로직"]))
qty = {x["cid"]: x.get("수량") for x in ran.get("줄") or [] if x.get("cid")}
out = []
for item, cid in zip(items, got["정체"]): # 별도계상 재료 = 셈 줄에서 빠짐 → 품 None
raw = qty.get(cid)
one: dict[str, Any] = {"cid": cid, "갈래": "그밖"}
one["품"] = None if raw is None else float(Decimal(str(raw)) / times)
kind, found = item.get("종류"), ours._SUB.match(str(item.get("요소") or ""))
if kind == "인력":
one.update(갈래="인력", 이름=labor.get(str(item.get("요소")), str(item.get("이름"))))
elif kind == "재료":
one.update(갈래="자재", 이름=str(item.get("이름") or ""))
elif kind == "로직" and found:
sub = found.group(1)
if ours.is_mach_logic(whole.logic(sub)):
ts, sp = _types(sub, item, found.group(2), got["입력값"], auto)
one.update(갈래="장비", 종류=ts, 규격=sp)
elif hit := _ADD.match(cid):
given = (doc.get("더한줄") or [])[int(hit[1])].get("입력값") or {}
v = ours.run(sub, given) or {"L": {}, "X": []}
k = (one["품"] or 0.0) * ours.times_of(whole.logic(sub))
one.update(갈래="기초", 로직=sub, L={t: q * k for t, q in v["L"].items()})
one["X"] = [(set(a), set(b), q * k) for a, b, q in v["X"]]
out.append(one)
return out
def measure(doc: dict, whole: Any) -> dict[str, Any]:
"""SK 1 단위 품 — `{인력 {직종: 품}, 장비 [{갈래, 시간}], 자재 {이름: 수량}}`(시험 · 견줌)."""
labor: dict[str, float] = {}
machines, mats = [], {}
for one in parts(doc, whole):
if one["갈래"] == "인력" and one["품"]:
labor[one["이름"]] = labor.get(one["이름"], 0.0) + one["품"]
elif one["갈래"] == "장비" and one["품"]:
machines.append({"갈래": sorted(one["종류"]), "시간": one["품"]})
elif one["갈래"] == "자재" and one["품"]:
mats[one["이름"]] = mats.get(one["이름"], 0.0) + one["품"]
elif one["갈래"] == "기초":
for t, q in one["L"].items():
labor[t] = labor.get(t, 0.0) + q
machines += [{"갈래": sorted(a), "시간": q} for a, _, q in one["X"] if q]
return {"인력": labor, "장비": machines, "자재": mats}
# ── 차이 → 덮개 · 더한줄 · 뺀줄 ──────────────────────────────────────
class _Plan:
def __init__(self, doc: dict, whole: Any) -> None:
self.doc, self.whole = doc, whole
self.tb = float(calc.times_of(whole.logic(doc["바탕"]["로직"])))
self.lines = parts(doc, whole)
self.lost: list[str] = []
self.lb = {name: key for key, name in ours._labor_names().items()}
def cover(self, cid: str, **one: float) -> None:
self.doc["덮개"][cid] = {k: _num(v) for k, v in one.items()}
def drop(self, cid: str) -> None:
if cid not in self.doc["뺀줄"]:
self.doc["뺀줄"].append(cid)
def add(self, one: dict[str, Any]) -> None:
self.doc["더한줄"].append(one)
def labor(self, excel: dict[str, float]) -> None:
mine = [x for x in self.lines if x["갈래"] == "인력" and not x["cid"].startswith("add:")]
mine = [{**x, "품": x["품"] or 0.0} for x in mine]
extra: dict[str, float] = {}
for x in self.lines:
for t, q in (x.get("L") or {}).items():
extra[t] = extra.get(t, 0.0) + q
for t in sorted(set(excel) | {x["이름"] for x in mine if x["품"]} | set(extra)):
a, rows = excel.get(t, 0.0), [x for x in mine if x["이름"] == t]
b, f = sum(x["품"] for x in rows), extra.get(t, 0.0)
if a and _near(a, b + f):
continue
want = a - f
if want <= TINY:
for x in rows:
self.drop(x["cid"])
if want < -TINY:
self.lost.append(f"{t} 엑셀 {a:.6g} · 더한 기초만 {f:.6g}")
elif not rows:
if t in self.lb:
unit = "인"
self.add({"종류": "인력", "요소": self.lb[t], "이름": t, "단위": unit,
"수량": _num(want * self.tb)}) # fmt: skip
else:
self.lost.append(f"{t} 엑셀 {a:.6g} — 우리 인력에 없음")
elif len(rows) == 1 or not b:
self.cover(rows[0]["cid"], qty=want * self.tb)
for x in rows[1:]:
self.drop(x["cid"])
else:
for x in rows:
self.cover(x["cid"], 배율=want / b)
def machines(self, excel: list[dict[str, Any]], line: dict[str, Any]) -> None:
mine = [x for x in self.lines if x["갈래"] == "장비" and x["품"]]
fixed = [(a, q) for x in self.lines for a, _, q in x.get("X") or [] if q]
used, rest = set(), []
for m in excel:
kinds, h = set(m["갈래"]), m["시간"]
hit = next((x for x in mine if x["cid"] not in used and x["종류"] & kinds
and _near(h, x["품"])), None) # fmt: skip
if hit is not None:
used.add(hit["cid"])
continue
at = next((i for i, (a, q) in enumerate(fixed) if a & kinds and _near(h, q)), None)
if at is not None:
fixed.pop(at)
continue
rest.append(m)
for m in rest:
kinds, sp = set(m["갈래"]), set(m.get("규격") or [])
same = [x for x in mine if x["cid"] not in used and x["종류"] & kinds]
same.sort(key=lambda x: not (sp and sp & x["규격"]))
if same:
used.add(same[0]["cid"])
self.cover(same[0]["cid"], qty=m["시간"] * self.tb)
else:
self._add_machine(m, line)
for x in mine:
if x["cid"] not in used:
self.drop(x["cid"])
self.lost += [f"우리만 장비 {'·'.join(sorted(a))} — 더한 기초 안" for a, _ in fixed]
def _add_machine(self, m: dict[str, Any], line: dict[str, Any]) -> None:
"""엑셀만 장비 → 기계경비 로직 줄(기계 = 그 갈래 · 규격 옵션 거꾸로 찾기)."""
fp = finger.empty(line)
fp["장비"] = [{**m, "시간": 1.0, "밑시간": 1.0, "율": []}]
kinds = set(m["갈래"])
first = next((c for k, c in combine.MACHINE.items() if k in kinds), combine.MACHINE_ANY)
for code in [first, *sorted(combine.MACHINE_SET - {first})]:
got = option.find(code, fp, [])
if got["수준"] <= 1:
name = " ".join([*m["갈래"], *(f"{x:g}" for x in m.get("규격") or [])])
self.add({"종류": "로직", "로직": code, "입력값": got["입력"], "이름": name,
"단위": "시간", "수량": _num(m["시간"] * self.tb)}) # fmt: skip
return
self.lost.append(f"엑셀만 장비 {'·'.join(m['갈래'])} {m['시간']:.6g} 시간 — 기계 못 찾음")
def materials(self, excel: list[dict[str, Any]]) -> None:
mine = [x for x in self.lines if x["갈래"] == "자재" and not x["cid"].startswith("add:")]
for m in excel:
words = option.mat_words(m["이름"])
rows = [x for x in mine if option._named(words, x["이름"])]
if rows and all(x["품"] is None for x in rows): # 우리 = 별도계상 · 엑셀 = 블록 안
self.doc["자재표시"][rows[0]["cid"]] = {"별도계상": "직접계산"}
self.cover(rows[0]["cid"], qty=m["품"] * self.tb)
continue
rows = [x for x in rows if x["품"] is not None]
if rows and _near(m["품"], sum(x["품"] for x in rows)):
continue
if rows:
self.cover(rows[0]["cid"], qty=m["품"] * self.tb)
for x in rows[1:]:
self.drop(x["cid"])
else:
self.lost.append(f"엑셀만 자재 {m['이름']} {m['품']:.6g}")
def _extras(entry: dict, code: str, whole: Any, tb: float) -> list[dict[str, Any]]:
"""조합 둘째부터(체크된 로직) = 더한줄 로직 — 수량 = 몫 × 바탕 배 ÷ 그 기초 배."""
out = []
for c in entry.get("조합") or []:
if not c.get("체크") or c["로직"] == code:
continue
given = ((entry.get("옵션") or {}).get(c["로직"]) or {}).get("입력") or {}
times = ours.times_of(whole.logic(c["로직"]))
qty = float(c.get("몫") or 1) * tb / times
out.append({"종류": "로직", "로직": c["로직"], "입력값": given, "수량": _num(qty)})
return out
def _wait(entry: dict) -> dict[str, Any]:
keep = ("로직", "이름", "단위", "판정", "글")
cands = [{k: c.get(k) for k in keep} for c in (entry.get("후보") or [])[: fit.TOP_OPT]]
why = (entry.get("판정") or {}).get("글") or "후보 없음"
return {"갈래": WAIT, "까닭": f"맞는 기초 없음 — {why}", "후보": cands}
def plan(
entry: dict | None,
block: dict | None,
line: dict[str, Any],
whole: Any,
pick: dict | None = None,
) -> dict[str, Any]:
"""블록 한 칸 → `{갈래 기초|SK|고름 대기, 바탕, 입력값, 덮개, 자재표시, 더한줄, 뺀줄, 못옮김, 엑셀품,
후보?}` ·
`pick` = 사용자가 고른 기초 `{로직, 입력값?}`(옵션 없으면 거꾸로 찾음 · 늘 견줌)."""
entry = entry or {}
state = (entry.get("판정") or {}).get("상태")
efp = finger.excel(block, line)
if pick:
code = str(pick["로직"])
given = pick.get("입력값")
if given is None:
given = option.find(code, efp, (block or {}).get("글") or [])["입력"]
elif state in ("맞음", "다른 점") and entry.get("차례"):
code = entry["차례"][0]
given = ((entry.get("옵션") or {}).get(code) or {}).get("입력") or {}
else:
return _wait(entry)
doc = {"키": "SK-000000", "이름": "견줌", "단위": line.get("단위") or "식",
"바탕": {"로직": code}, "입력값": dict(given), "덮개": {}, "뺀줄": [], "자재표시": {}} # fmt: skip
tb = float(calc.times_of(whole.logic(code)))
doc["더한줄"] = [] if pick else _extras(entry, code, whole, tb)
from M02_MasterTemplete import M02_Hopyo_Verdict as verdict # 판정이 Fit 을 부름 — 늦게 읽음
excel = verdict.excel_qty(efp)
head = {"바탕": doc["바탕"], "입력값": doc["입력값"], "엑셀품": excel}
if state == "맞음" and not pick and not doc["더한줄"]:
return {**head, "갈래": BASE, **dict.fromkeys(KEYS[:2], {}), **dict.fromkeys(KEYS[2:], [])}
work = _Plan(doc, whole)
work.labor({t: x["품"] for t, x in efp["인력"].items() if x.get("품")})
work.machines([m for m in efp["장비"] if m.get("시간")], line)
work.materials([m for m in efp["자재"] if m.get("품")])
out = {k: doc[k] for k in KEYS[:4]}
changed = any(out.values())
return {**head, **out, "갈래": SK if changed else BASE, "못옮김": work.lost}
+5
View File
@@ -314,6 +314,11 @@ def summary(folder: Path, kind: str, key: str, expand: Expand | None = None) ->
layer, owner = where(folder)
base = expand({"층": layer, "주인": owner, "키": key}).get("기준") or {}
out["기준금액"] = float(base["계"]) if "계" in base else None
if kind == "precombo": # 729-2 — 이음 없는 고정 줄 수(로직 · 참조 · 비율 · 이음 = 이어짐)
fixes = [x["고정"] for x in doc.get("담은로직") or [] if isinstance(x.get("고정"), dict)]
out["안이은줄"] = sum(
1 for x in fixes if not x.get("비율") and not any((x.get("이음") or {}).values())
)
# 717-2a 목록 열 — 바탕(로직 「키 이름」 | 참조 키 | None) · 고친 줄 수 · 쓰는 곳 수
if kind == "sangeun":
top, (layer, owner) = doc.get("바탕") or {}, where(folder)
+36 -5
View File
@@ -375,6 +375,9 @@ class BuildTarget(_Strict):
자재: str | None = None
호표: int | None = None
전국: bool | None = None # 「전국」 자재(361-1 · 키 없음 · 서버가 줄에서 다시 셈)
노임: str | None = None # 729-2 사용자 이음 — LB 키(엑셀 숫자 + 이음)
기계: str | None = None # EQ 키
비율: Literal["노무비", "재료비", "경비"] | None = None # % 줄 밑수
입력값: dict[str, Any] = Field(default_factory=dict)
배: float = 1
몫: float | None = None
@@ -422,6 +425,23 @@ def hopyo_make(
return _call(folder, hopyo_build.build, *args)
@router.post("/layers/{layer}/hopyo-unlinked")
def hopyo_unlinked(
layer: Layer,
body: BuildBody,
owner: str | None = None,
session: Session = Depends(verify_session),
) -> dict:
"""안 이은 줄 미리 보기(729-2) — 만들기 몸 그대로 → `{남은줄 [{차례, 이름, 규격, 단위, 갈래, 까닭,
후보}], 수}`(저장 없음)."""
folder = _root(session, layer, owner, write=True)
source = _root(session, body.층, body.주인)
lines = [one.model_dump(exclude_none=True) for one in body.줄]
whole = body.통째.model_dump(exclude_none=True) if body.통째 else None
args = (folder, source, body.키, body.번, lines, whole)
return _call(folder, hopyo_build.unlinked, *args)
class BuildAllBody(_Strict):
"""시트 통째(705-3) — 파일 층 `층` · `주인` · `키` 만(숫자 · 줄 칸 없음 · 서버가 파일에서 읽음)."""
@@ -441,7 +461,7 @@ def hopyo_make_all(
`{호표, 줄, 품셈, 고정, 호표참조, 빠짐, 이음, 이음후보, 같음, 그대로, 다름, 실패}`."""
folder = _root(session, layer, owner, write=True)
source = _root(session, body.층, body.주인)
args = (folder, source, body.키, _who(session))
args = (folder, source, body.키, _who(session), True) # 729-2 — 남은 줄 있는 호표 = 실패
return _call(folder, hopyo_build.build_all, *args)
@@ -503,14 +523,25 @@ def _finish(folder: Path, key: str) -> dict[str, Any]:
return {"지움": key, "만든": len(made)}
class SangeunBody(_Strict):
"""`바탕` = 고름 대기 블록에 사용자가 고른 기초 `{블록 번: {로직, 입력값?}}`(729-1)."""
바탕: dict[str, dict[str, Any]] = Field(default_factory=dict)
@router.post("/layers/{layer}/hopyo/{key}/sangeun")
def hopyo_make_sangeun(
layer: Layer, key: str, owner: str | None = None, session: Session = Depends(verify_session)
layer: Layer,
key: str,
body: SangeunBody | None = None,
owner: str | None = None,
session: Session = Depends(verify_session),
) -> dict:
"""불러오기 결과 「단가산출」(718-1) — 산근 블록마다 바탕 없는 단가산출(파일 층 = 이 층) →
`{블록, 만듦, 그대로}`."""
"""불러오기 결과 「단가산출」(718-1 · 729-1) — 산근 블록마다 매칭 → 비교 → 기초 · SK · 고름 대기
(파일 층 = 이 층) → `{블록, 만듦, 그대로, 기초, SK, 고름대기, 별칭}` · 맞춤 셈 전 409."""
folder = _root(session, layer, owner, write=True)
return _call(folder, hopyo_sangeun.build, folder, key, _who(session))
picks = body.바탕 if body else {}
return _call(folder, hopyo_sangeun.build, folder, key, _who(session), picks)
class PasteBody(_Strict):
+7 -3
View File
@@ -183,9 +183,13 @@ def test_다시_잇기_후보_바꿈_되돌리기(client):
from resources.tester.test_m02_hopyo_excel import _post
assert _post(client).status_code == 200 # 북부청 — 이음 13 · 이음 후보 31(705-3)
made = client.post(
"/api/m02/layers/personal/hopyo-build-all", json={"층": "personal", "키": "HP-000001"}
).json()
from M02_MasterTemplete import M02_Hopyo_Build as hb
from M02_MasterTemplete import M02_MasterTemplete_Store as mstore
from M02_MasterTemplete import M02_Template_Layers as mlayers
mine = mlayers.personal_dir(7, 42) # 모두 고정(막지 않음) — 길은 남은 줄 호표 실패(729-2)
with mstore.at(mine):
made = hb.build_all(mine, mine, "HP-000001")
keys = [x["키"] for x in made["만듦"]]
assert len(keys) == 68
rows = client.post(f"{RELINK}/rows", json={"키": keys}).json()["줄"]
+21 -8
View File
@@ -2,9 +2,10 @@
- 확인 안 한 줄 · 노임 · % · 「우리에 없음」 = 고정 줄(엑셀 숫자 그대로) · 체크한 줄만 품셈 · 하위 호표 = 참조
- % 줄 = 비율 고정 줄 — M02 settle 거울(파이썬 ↔ TS) · 사전조합 셈 · B09 `pct` 줄
- [모두 고정으로 들여오기] — 북부청 · 실무 xlsx 넷: 빠짐 0 · 엑셀 합계 = 우리 합계(호표마다) · 줄 꼬리표 수 ·
확인된 호표 그대로 · 이음 = 단가까지 같은 줄만 · 같은 이름 줄 차례 가름 · 몸에 숫자 칸 없음(422)
- [품셈으로 바꾸기] 전후 — 체크 한 줄 = 품셈 · 나머지 고정 · 되돌리기(빈 몸) = 모두 고정
- 모두 고정(`build_all` 막지 않음 = 붙여넣기 길) — 북부청 · 실무 xlsx 넷: 빠짐 0 · 엑셀 합계 = 우리 합계 ·
줄 꼬리표 수 · 확인된 호표 그대로 · 이음 = 단가까지 같은 줄만(노임 이름 · 기계 EQ = 불러오기 길만) · 같은 이름 줄 차례
가름 · 몸에 숫자 칸 없음(422) · [모두 고정으로 들여오기] 길 = 남은 줄 있는 호표 실패(729-2 · 718-1 시험)
- [품셈으로 바꾸기] 전후 — 체크 한 줄 = 품셈 · 나머지 고정(「우리에 없음」 고름) · 되돌리기 = 모두 고정
층은 임시 폴더(공용 storage 안 씀).
"""
@@ -16,7 +17,9 @@ import pytest
from B09_Estimation.B09_Estimation_Calc_Labor import compute_labor
from B09_Estimation.B09_Estimation_Labor_Source import fixed_line
from M02_MasterTemplete import M02_Hopyo_Build as hb
from M02_MasterTemplete import M02_Hopyo_Fixed as hfix
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from M02_MasterTemplete import M02_Labor_Doc as labor
from M02_MasterTemplete import M02_Template_Layers as layers
@@ -59,9 +62,19 @@ def _fx(name, price, rate=None, unit="식"):
def _all(client, key="HP-000001"):
res = client.post(ALL.format("personal"), json={"층": "personal", "키": key})
assert res.status_code == 200, res.text
return res.json()
"""모두 고정(막지 않음 = 붙여넣기 길 `build_all`) — 길(729-2)은 남은 줄 있는 호표 = 실패."""
folder = layers.personal_dir(7, 42)
with store.at(folder):
return hb.build_all(folder, folder, key)
def _decided(client, body):
"""남은 줄(729-2)을 「우리에 없음」으로 고른 몸."""
left = client.post(BUILD.format("personal").replace("build", "unlinked"), json=body).json()
return {
**body,
"줄": [*body["줄"], *({"차례": x["차례"], "뺌": "우리에 없음"} for x in left["남은줄"])],
}
def _docs():
@@ -213,7 +226,7 @@ def test_품셈으로_바꾸기_되돌리기(client):
_all(client)
body = {"층": "personal", "키": "HP-000001", "번": 3}
one = {"차례": 1, "대상": [{"로직": "GF000101", "입력값": {"지역": "서울"}, "배": 1}]}
res = client.post(BUILD.format("personal"), json={**body, "줄": [one]})
res = client.post(BUILD.format("personal"), json=_decided(client, {**body, "줄": [one]}))
assert res.status_code == 200, res.text
after = res.json()
assert after["다시씀"] is True and after["줄수"]["품셈"] == 1 and after["줄수"]["고정"] == 3
@@ -222,5 +235,5 @@ def test_품셈으로_바꾸기_되돌리기(client):
assert after["비교"]["같음"] is False # 품셈 줄 = 우리 값(엑셀과 다름)
view = client.get("/api/m02/layers/personal/hopyo/HP-000001/3").json()
assert view["호표"]["만든"]["품셈"] == [1] and view["호표"]["만든"]["고정"] == 3
back = client.post(BUILD.format("personal"), json={**body, "줄": []}).json() # 되돌리기
back = client.post(BUILD.format("personal"), json=_decided(client, {**body, "줄": []})).json()
assert back["줄수"]["품셈"] == 0 and back["비교"]["같음"] is True
+15 -21
View File
@@ -27,7 +27,8 @@ def test_이어서_끝내기_지움_겹침_막음(client):
assert again["키"] == "HP-000001" and again["이어서"] is True # 작업 중 같은 엑셀 = 새 키 없음
assert _keys(client) == ["HP-000001"]
made = client.post(ALL, json={"층": "personal", "키": "HP-000001"}).json()
assert len(made["만듦"]) == 68
n = len(made["만듦"]) # 729-2 — 남은 줄 있는 호표 = 실패(남은줄)
assert n and n + len(made["실패"]) == 68 and all(x["남은줄"] for x in made["실패"])
hs.remember(folder, {"장부 줄": {"갈래": "안 씀"}}) # 별칭 장부 = 끝내기 뒤 남음
cache = fit.cache_path(folder, "HP-000001")
cache.write_text("{}", encoding="utf-8")
@@ -36,7 +37,7 @@ def test_이어서_끝내기_지움_겹침_막음(client):
sc_before = sorted((folder / "precombo").glob("SC-*.json"))
res = client.delete(URL.format("personal") + "/HP-000001")
assert res.status_code == 200 and res.json() == {"지움": "HP-000001", "만든": 68}
assert res.status_code == 200 and res.json() == {"지움": "HP-000001", "만든": n}
assert not list((folder / hs.DIRNAME).glob("HP-*.json")) # 이번 HP 0
assert not list((folder / hs.DIRNAME / hs.GRID).glob("HP-*.json"))
assert not cache.exists() and not old_cache.exists()
@@ -45,7 +46,7 @@ def test_이어서_끝내기_지움_겹침_막음(client):
block = _post(client) # 끝낸 같은 엑셀 = 막음 · 안내
assert block.status_code == 409 and "이미 불러온 엑셀" in block.json()["detail"]
assert "단가산출 68" in block.json()["detail"] and _keys(client) == []
assert f"단가산출 {n}" in block.json()["detail"] and _keys(client) == []
assert sorted((folder / "precombo").glob("SC-*.json")) == sc_before
@@ -67,32 +68,25 @@ def test_옛_HP_목록에서만_숨김(client):
assert old.is_file() and _keys(client) == ["HP-000008"]
def test_결과_단가산출_산근_블록_바탕_없음_겹침_없음(client):
def test_결과_단가산출_산근_블록_기초_SK_고름대기_겹침_없음(client):
folder = layers.personal_dir(7, 42)
assert _post(client).json()["키"] == "HP-000001"
url = URL.format("personal") + "/HP-000001/sangeun"
assert client.post(url).status_code == 409 # 729-1 — 맞춤 셈 전
fit.run_job(folder, "HP-000001")
got = client.post(url)
assert got.status_code == 200, got.text
got = got.json()
assert got["블록"] > 0 and len(got["만듦"]) == got["블록"] and got["그대로"] == 0
wait = len(got["고름대기"])
assert got["블록"] > 0 and len(got["만듦"]) + wait == got["블록"] and got["그대로"] == 0
docs = [json.loads(p.read_bytes()) for p in sorted((folder / "sangeun").glob("SK-*.json"))]
assert len(docs) == got["블록"]
assert len(docs) == len(got["만듦"]) == got["기초"] + got["SK"]
one = docs[0]
assert one["바탕"] is None and one["본뜬"]["키"].startswith("HP-000001 단산")
assert one["구분"] == "실무자료" and len(one["더한줄"]) == 1 and one["단위"]
hp = hs.read(folder, "HP-000001")
no = int(one["본뜬"]["키"].rsplit("단산", 1)[1])
line = next( # 그 블록을 부르는 첫 엑셀 줄 = 엑셀 단가 그대로
x
for h in hp["호표"]
for x in h["줄"]
if (x.get("참조") or {}).get("종류") == "단산" and x["참조"]["번"] == no
)
price = one["더한줄"][0]["고정"]["단가"]
assert sum(price.values()) == sum((line["단가"] or {}).get(k) or 0 for k in price)
assert one["바탕"]["로직"] and one["본뜬"]["키"].startswith("HP-000001 단산") # 바탕 없는 SK 0
assert one["구분"] == "실무자료" and one["단위"] and one["본뜬"]["갈래"] in ("기초", "SK")
again = client.post(url).json() # 다시 = 그대로(겹침 없음)
assert again["만듦"] == [] and again["그대로"] == got["블록"]
assert again["만듦"] == [] and again["그대로"] == len(docs)
res = client.delete(URL.format("personal") + "/HP-000001").json()
assert res["만든"] == got["블록"] # 단가산출만 만들어도 지문 기록
assert len(list((folder / "sangeun").glob("SK-*.json"))) == got["블록"] # 남음
assert res["만든"] == len(docs) # 단가산출만 만들어도 지문 기록
assert len(list((folder / "sangeun").glob("SK-*.json"))) == len(docs) # 남음
assert _post(client).status_code == 409
+154
View File
@@ -0,0 +1,154 @@
"""실무문서 단가산출 → 기초 · SK · 고름 대기 · 일위대가 이음 · 남은 줄 막음(PLAN 729-1 · 729-2 · 요구서 내역19).
북부청 임도(실물 엑셀)를 임시 층에 올려 맞춤셈 → [단가산출 만들기] →
기초잡석채집 · 운반 · 다짐(엑셀만 덤프트럭) · 철근가공조립 Type-Ⅰ · Type-Ⅱ · 구조물되메우기 = SK ·
SK 품 = 엑셀 품(차이는 엑셀 값 그대로) · 같은 단가산출 = 기초(덮개 없음) + 별칭 제안(짝 = 기초까지만) ·
고름 대기 = SK 안 만듦 → 고른 기초로 SK · 안 이은 줄 남으면 만들기 409(미리 보기 = 같은 줄) ·
사용자가 고르면 만듦 · 노임 = 이름 이음 · 기계 = EQ 이음 · master_data 원본 그대로. 층 = 임시 폴더.
"""
import json
from M01_MasterData import M01_MasterData_Store as m01
from M02_MasterTemplete import M02_Hopyo_Fit as fit
from M02_MasterTemplete import M02_Hopyo_Fixed as hfix
from M02_MasterTemplete import M02_Hopyo_Ledger as ledger
from M02_MasterTemplete import M02_Hopyo_Option as option
from M02_MasterTemplete import M02_Hopyo_Ours as ours
from M02_MasterTemplete import M02_Hopyo_SkPatch as skpatch
from M02_MasterTemplete import M02_Hopyo_Store as hs
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from M02_MasterTemplete import M02_Template_Layers as layers
from resources.tester.test_m02_hopyo_excel import URL, _post, client, master_hash # noqa: F401
BUILD = "/api/m02/layers/personal/hopyo-build"
LEFT = "/api/m02/layers/personal/hopyo-unlinked"
SAMPLES = { # 블록 번 → (이름 앞, 엑셀 규격 글)
33: "기초잡석채집,운반,다짐",
47: "철근가공조립(현장)", # Type-Ⅰ(소량 시공) — 철근공 4.2 · 보통인부 1.4
72: "철근가공조립(현장)", # Type-ll — 철근공 2.67 · 보통인부 1.02
12: "구조물되메우기(인력100%)",
}
def _same(a: float, b: float) -> bool:
return abs(a - b) <= 1e-9 or option.near(a, b, fit.TOL[0], option.ru(a))
def _by_type(rows: list[dict]) -> dict[str, float]:
out: dict[str, float] = {}
for x in rows:
k = "+".join(sorted(x["갈래"]))
out[k] = out.get(k, 0.0) + x["시간"]
return out
def _sk_docs(folder) -> dict[int, dict]:
out = {}
for path in sorted((folder / "sangeun").glob("SK-*.json")):
doc = json.loads(path.read_bytes())
out[int(doc["본뜬"]["키"].rsplit("단산", 1)[1])] = doc
return out
def test_실무_단가산출_기초_SK_고름대기_일위대가_막음(client):
before = master_hash()
folder = layers.personal_dir(7, 42)
key = _post(client).json()["키"]
url = f"{URL.format('personal')}/{key}/sangeun"
assert client.post(url).status_code == 409 # 맞춤 셈 전
fit.run_job(folder, key)
got = client.post(url, json={})
assert got.status_code == 200, got.text
got = got.json()
assert (
got["기초"] + got["SK"] + len(got["고름대기"])
== got["블록"]
== len(got["만듦"]) + len(got["고름대기"])
)
assert got["SK"] > 0 and got["기초"] > 0 and got["고름대기"]
docs, whole = _sk_docs(folder), m01.loaded()[1]
assert all(d["바탕"] and d["바탕"]["로직"] for d in docs.values()) # 바탕 없는 SK 0
assert not set(docs) & {x["번"] for x in got["고름대기"]}
# SK 품 = 엑셀 품
for no, head in SAMPLES.items():
doc = docs[no]
assert doc["이름"].startswith(head) and doc["본뜬"]["갈래"] == "SK", (no, doc["본뜬"])
excel = doc["본뜬"]["엑셀품"]
with store.at(folder):
ours_q = skpatch.measure(doc, whole)
assert set(ours_q["인력"]) == set(excel["인력"]), no
assert all(_same(q, ours_q["인력"][t]) for t, q in excel["인력"].items()), no
theirs, mine = _by_type(excel["장비"]), _by_type(ours_q["장비"])
assert set(theirs) == set(mine), no
assert all(_same(q, mine[t]) for t, q in theirs.items()), no
dump = [x for x in docs[33]["더한줄"] if x.get("로직") == "GC000268"]
assert any("덤프트럭" in ours.eq_of(_eq(x))[0] for x in dump) # 엑셀만 장비 = 더한줄
assert docs[12]["덮개"]["labor:LB000002#0"]["qty"] == 0.1 # 엑셀 값 그대로(우리 0.01)
assert docs[47]["더한줄"][0]["로직"] == "GC000206" # 조합 둘째 = 더한줄 기초
# 같은 단가산출 = 기초 + 별칭 제안(짝 = 기초까지만)
base = next(d for d in docs.values() if d["본뜬"]["갈래"] == "기초")
assert base["덮개"] == {} and base["더한줄"] == [] and base["뺀줄"] == []
book = ledger.read(folder)
hp = hs.read(folder, key)
lines = {
int(x["참조"]["번"]): x
for h in reversed(hp["호표"])
for x in reversed(h["줄"])
if (x.get("참조") or {}).get("종류") == "단산"
}
no = int(base["본뜬"]["키"].rsplit("단산", 1)[1])
pair = book[ledger.key(lines[no])]["짝"]
assert pair == [{"로직": base["바탕"]["로직"], "입력값": base["입력값"], "배": 1, "몫": None}]
assert book[ledger.key(lines[12])]["짝"][0]["로직"] == docs[12]["바탕"]["로직"]
assert got["별칭"] > 0
# 다시 = 그대로 · 고름 대기 = 고른 기초로 SK
again = client.post(url, json={}).json()
assert again["만듦"] == [] and again["그대로"] == len(docs)
wait = got["고름대기"][0]
code = (wait["후보"] or [{"로직": "GF000113"}])[0]["로직"]
picked = client.post(url, json={"바탕": {str(wait["번"]): {"로직": code}}}).json()
assert [x["번"] for x in picked["만듦"]] == [wait["번"]]
assert _sk_docs(folder)[wait["번"]]["바탕"]["로직"] == code
assert client.post(url, json={"바탕": {"9999": {"로직": code}}}).status_code == 404
assert client.post(url, json={"바탕": {str(wait["번"]): {"로직": "XX"}}}).status_code == 400
# 안 이은 줄 남으면 만들기 막힘 · 미리 보기 = 같은 줄 · 고르면 만듦
body = {"층": "personal", "키": key, "번": 1, "줄": []}
left = client.post(LEFT, json=body).json()
res = client.post(BUILD, json=body)
assert res.status_code == 409 and left["수"] > 0
assert res.json()["detail"]["남은줄"] == left["남은줄"]
assert not list((folder / "precombo").glob("SC-*.json")) # 막힘 = 저장 0
picks = [{"차례": x["차례"], "뺌": "우리에 없음"} for x in left["남은줄"]]
ok = client.post(BUILD, json={**body, "줄": picks})
assert ok.status_code == 200, ok.text
doc = json.loads((folder / "precombo" / f"{ok.json()['참조']['키']}.json").read_bytes())
refs_sk = [x for x in doc["담은로직"] if str((x.get("참조") or {}).get("키")).startswith("SK")]
assert refs_sk # 단가산출 줄 = SK 참조(고정 줄 대신)
assert client.post(LEFT, json={**body, "줄": picks}).json()["수"] == 0
assert master_hash() == before
def _eq(one: dict) -> str:
return str((one.get("입력값") or {}).get("기계") or "")
def test_노임_이름_이음_기계_EQ_이음_사용자_이음(client):
with store.at(layers.personal_dir(7, 42)):
linker = hfix.Linker(by_name=True)
labor = {"갈래": "노임", "이름": "보통인부", "단위": "인"}
price = {"노무비": hfix.Decimal(1), "재료비": hfix.Decimal(0), "경비": hfix.Decimal(0)}
assert linker.link(labor, price)["노임"] == "LB000002" # 단가 달라도 이름으로
machine = {"갈래": "기계", "이름": "덤프트럭", "규격": "15ton", "단위": "hr"}
code = linker.link(machine, price)["기계"]
assert code and "덤프트럭" in ours.eq_of(_eq_code(code))[0]
line = {"갈래": "자재", "이름": "없는자재", "규격": "", "단위": "개", "수량": 2,
"단가": {"재료비": 10}, "차례": 0} # fmt: skip
one = hfix.line_of(line, [], {"파일": "x", "호표": 1}, set(), linker, {"노임": "LB000002"})
assert one["고정"]["이음"] == {"노임": "LB000002", "자재": None, "기계": None}
assert hfix.linked(one) and not hfix.linked(hfix.line_of(line, [], {}, set(), linker))
def _eq_code(key: str) -> str:
"""EQ 키(EQ000001) → 기계 번호(0101-0007)."""
return next(k for k, v in m01.loaded()[1].numbers["EQ"].items() if v.get("키") == key)
+13 -4
View File
@@ -19,13 +19,18 @@ DIG = "구조물터파기(인력100%)|토사|m3"
FILL = "구조물되메우기(인력100%)||m3"
SOIL = "잔토처리(인력100%)||m3"
BUILD = "/api/m02/layers/{}/hopyo-build"
LEFT = "/api/m02/layers/{}/hopyo-unlinked"
LEDGER = "/api/m02/layers/{}/hopyo-ledger"
def _build(client, no, lines=None, layer="personal", want=200, **extra):
def _build(client, no, lines=None, layer="personal", want=200, decide=True, **extra):
"""`decide` = 남은 줄(729-2 · 이음 없는 고정 줄)을 「우리에 없음」으로 고른 뒤 만듦(옛 고정 줄 꼴 그대로)."""
body = {"층": "personal", "키": "HP-000001", "번": no, **extra}
if lines is not None:
body["줄"] = lines
if decide and want == 200 and "통째" not in extra:
left = client.post(LEFT.format(layer), json=body).json().get("남은줄") or []
body["줄"] = [*(lines or []), *({"차례": x["차례"], "뺌": "우리에 없음"} for x in left)]
res = client.post(BUILD.format(layer), json=body)
assert res.status_code == want, res.text
return res.json()
@@ -84,7 +89,9 @@ def test_확인한_줄만_별칭_수량_빠진_줄_장부(client):
]
assert doc["자재"] == [] # 표지석 = 몸에 없음 → 고정 줄
assert got["비교"]["엑셀"] == 1415169 and got["비교"]["우리"] > 0
assert got["다시씀"] is False and got["장부"] == {"씀": 3}
assert got["다시씀"] is False and got["장부"] == {
"씀": 4
} # + 표지석 「우리에 없음」(729-2 고름)
book = _book(mine)
assert book[DIG]["갈래"] == "짝" and book[DIG]["짝"] == [
{"로직": "GF000101", "입력값": {"지역": "서울"}, "배": 1, "몫": None}
@@ -177,7 +184,8 @@ def test_전국_자재_키_없이_본뜬에(client):
}
]
assert [m["까닭"] for m in got["빠진줄"] if m["차례"] == 0] == [hb.NATIONAL]
assert got["장부"]["씀"] == 0
national = hs.read(mine, "HP-000001")["호표"][0]["줄"][0]
assert hb.ledger.key(national) not in _book(mine) # 전국 줄 = 장부 안 씀
_build(client, 1, [{"차례": 1, "대상": [{"전국": True, "배": 1}]}], want=400) # 로직 줄
both = [{"차례": 0, "대상": [{"전국": True, "자재": "MT004258", "배": 1}]}]
_build(client, 1, both, want=400)
@@ -276,7 +284,8 @@ def test_장부_길_보기_고치기_권한(client):
assert _post(client).status_code == 200
_build(client, 3, [{"차례": 1, "대상": [{"로직": "GF000101"}]}])
rows = client.get(LEDGER.format("personal")).json()["줄"]
assert [(r["열쇠"], r["층"], r["고침"]) for r in rows] == [(DIG, "personal", True)]
assert (DIG, "personal", True) in [(r["열쇠"], r["층"], r["고침"]) for r in rows]
assert {r["갈래"] for r in rows if r["열쇠"] != DIG} == {"우리에 없음"} # 729-2 고른 남은 줄
put = {
"줄": {DIG: {"갈래": "짝", "짝": [{"로직": "GF000103", "배": 2}]}, SOIL: {"갈래": "안 씀"}}
}
+13 -5
View File
@@ -56,8 +56,12 @@ def _find(client, key: str, name: str) -> tuple[int, dict]:
raise KeyError(name)
def _build(client, key: str, no: int, lines: list, **extra) -> dict:
def _build(client, key: str, no: int, lines: list, decide: bool = False, **extra) -> dict:
"""`decide` = 남은 줄(729-2)을 「우리에 없음」으로 고른 뒤 만듦."""
body = {"층": "personal", "키": key, "번": no, "줄": lines, **extra}
if decide:
left = client.post("/api/m02/layers/personal/hopyo-unlinked", json=body).json()["남은줄"]
body["줄"] = [*lines, *({"차례": x["차례"], "뺌": "우리에 없음"} for x in left)]
res = client.post("/api/m02/layers/personal/hopyo-build", json=body)
assert res.status_code == 200, res.text
return res.json()
@@ -127,7 +131,7 @@ def test_섞인_줄_몫_배(client):
screen = _node({"real": [line]})
assert screen["lines"][0]["checkable"] and not screen["lines"][0]["checked"]
one = {k: part[k] for k in ("로직", "입력값", "배", "몫")}
made = _build(client, "HP-000001", no, [{"차례": line["차례"], "대상": [one]}])
made = _build(client, "HP-000001", no, [{"차례": line["차례"], "대상": [one]}], decide=True)
doc = _doc(made["참조"])
at = next(i for i, x in enumerate(doc["담은로직"]) if x.get("로직") == "GF000211")
assert doc["담은로직"][at]["수량"] is None # 723-1 — 수량 저장 안 함 · 엑셀 수량 = 출처 기록
@@ -140,9 +144,13 @@ def test_막는_길(client):
"/api/m02/layers/personal/hopyo-build",
json={"층": "personal", "키": "HP-000001", "번": 1, "줄": []},
)
assert (
empty.status_code == 200 and empty.json()["줄수"]["품셈"] == 0
) # 빈 몸 = 모두 고정(705-3)
left = empty.json()["detail"]["남은줄"] # 729-2 — 안 이은 줄 남음 = 안 만듦
assert empty.status_code == 409 and left
peek = client.post(
"/api/m02/layers/personal/hopyo-unlinked",
json={"층": "personal", "키": "HP-000001", "번": 1, "줄": []},
).json()
assert peek == {"남은줄": left, "수": len(left)}
money = client.post(
"/api/m02/layers/personal/hopyo-build",
json={"층": "personal", "키": "HP-000001", "번": 1, "줄": [{"차례": 1, "금액": 1}]},