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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 []}
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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()["줄"]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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: {"갈래": "안 씀"}}
|
||||
}
|
||||
|
||||
@@ -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}]},
|
||||
|
||||
Reference in New Issue
Block a user