feat(729-2): 일위대가 불러오기 이음 — 단가산출 줄 = SK 참조 · 노임 이름 · 기계 EQ · 사용자 이음 · 남은 줄 있으면 만들기 막음

- M02_Hopyo_Build · 새 M02_Hopyo_SkLink: 확인 안 한 단가산출 줄 = 그 블록 SK 참조(없으면 그 블록만 만듦) · 확인한 로직이 차이 SK 바탕이면 SK 참조 · 남은 줄 있으면 409 {까닭, 남은줄} · 미리 보기 unlinked(쓰기 없음) · build_all strict = 남은 줄 호표 실패(붙여넣기 길은 막지 않음)
- M02_Hopyo_Fixed: Linker by_name(불러오기 길만) = 노임 이름 같은 LB · 기계 이름 · 규격 같은 EQ · 사용자 이음 force {노임|기계} · {비율} · linked()
- M02_Labor_Router: hopyo-unlinked 길 · 대상 칸 노임 · 기계 · 비율 · 단가산출 몸 바탕 · hopyo-build-all strict
- M02_Labor_Ref: 일위대가 목록 summary 안이은줄
- 시험: 새 test_729_hopyo_sk_link(북부청 기초잡석 · 철근 Type-Ⅰ · Ⅱ · 되메우기 SK 품 = 엑셀 품 · 기초 + 별칭 · 고름 대기 · 막힘 · 노임 · 기계 이음) · 낡은 시험 고침 — 718-1(모두 들여오기 = 남은 줄 호표 실패 · 단가산출 새 꼴) · hopyo_build · flow(남은 줄 「우리에 없음」 고른 뒤 만듦 · 빈 몸 = 409) · 705-3 · 705-2(모두 고정 = 막지 않는 build_all 직접)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-02 10:51:18 +09:00
co-authored by Claude Opus 5.5
parent 8a0fd4d03d
commit 68820e80f3
11 changed files with 509 additions and 62 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
+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 []}
+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}]},