feat(일위대가실무): 329-1 · 330-1 · 330-2 서버 — 엑셀꼴 격자 · 행 범위 · 단가산출 근거 맞춤
- 329-1 새 M02_Hopyo_Grid(xls formatting_info 값 · 병합 · 열 폭 · 행 높이 · 서식표 · 틀고정 / xlsx 값만) · read_book 호표 · 줄 「행」 · Store 격자 저장 · 읽기 · 지움(실무자료/격자/<키>.json) · GET hopyo-grid/{key}(옛 파일 404) · Match 요약 .get
- 330-1 새 M02_Hopyo_Basis.read — 단산N 블록 이름 · 단위 · 행 · 출처(책 · 절) · 직종(우리 인력 이름 · 옛이름) · 금액 · 단산 줄 「근거」
- 330-2 Basis.ours · candidates(조항 1.0 · 앞 마디 0.5 · 건설 공통 · 토목 0.1 · 직종 0.5 · 단위 0.15 · 까닭) · match_line 이름 후보와 로직 키로 합침 · 상태 후보 그대로
- 시험 test_m02_hopyo_grid · test_m02_hopyo_basis 새로 · test_m02_hopyo_match 제3호표 구조물터파기 1순위 → 굴착(인력/토사)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
This commit is contained in:
@@ -0,0 +1,231 @@
|
||||
"""M02 일위대가 실무자료 단가산출 근거(PLAN 330) — 엑셀 「단가산출」 블록 → 출처 · 직종 ·
|
||||
우리 돈 로직과 견준 근거 후보.
|
||||
|
||||
읽기(`read`) = B열(머리 「산출근거」) 머리 줄 「N.이름[단위]」(앞 빈칸 없음)부터
|
||||
다음 머리 앞까지 한 블록 ·
|
||||
출처 = 「…품셈」(오타 「품줌」) 뒤 20 글자 안 절 번호 — 앞 글에 산림 · 임도 = 산림품셈 ·
|
||||
그 밖 = 건설품셈 ·
|
||||
직종 = 「이름 : 수」 줄의 빈칸 뺀 이름이 우리 인력(LB) 이름 · 옛이름일 때만(지금 이름으로).
|
||||
견줌(`candidates`) = 같은 책 조항 같음 1.0 · 앞 마디 같음 0.5(조항이 안 맞으면 후보 아님) ·
|
||||
건설 공통 · 토목 덤 0.1 · 직종 겹침(보통인부 뺌) 0.5 · 단위 0.15 · 직종도 뜻 낱말도 안 겹치면 버림 ·
|
||||
같은 점수는 글자 두 개 묶음(괄호 글 포함)이 가름. 자동 확정 없음 — 맞춤 차례는 `M02_Hopyo_Match`.
|
||||
M01 · 원본 로직은 읽기만.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from functools import lru_cache
|
||||
from typing import Any
|
||||
|
||||
from M01_MasterData import M01_MasterData_Store as m01
|
||||
from M02_MasterTemplete import M02_Hopyo_Match as hopyo_match # 서로 부름 — 부를 때만 씀
|
||||
from M02_MasterTemplete import M02_Labor_Inputs as labor_inputs
|
||||
|
||||
SHEET = "단가산출"
|
||||
COMMON = "보통인부"
|
||||
BONUS_PARTS = ("공통", "토목")
|
||||
_HEAD = re.compile(r"^(\d+)\.\s*(.*?)\s*\[([^\[\]]*)\]\s*$")
|
||||
_BOOK = re.compile(r"품[셈줌]")
|
||||
_TRADE = re.compile(r"^[\s*·\-]*([가-힣][가-힣\s]*?)\s*:\s*[\d,]")
|
||||
_TYPOS = (("깍기", "깎기"),)
|
||||
_MONEY = {"합계": "계", "노무비": "노무비", "재료비": "재료비", "경비": "경비"}
|
||||
|
||||
|
||||
def _flat(value: Any) -> str:
|
||||
return re.sub(r"\s+", "", str(value or ""))
|
||||
|
||||
|
||||
# ── 엑셀 블록 읽기 ─────────────────────────────────────────────────────
|
||||
@lru_cache(maxsize=2)
|
||||
def _trade_names(whole_id: int) -> dict[str, str]:
|
||||
"""{빈칸 뺀 이름 · 옛이름: 지금 이름} — 우리 인력(LB)."""
|
||||
out: dict[str, str] = {}
|
||||
for row in m01.loaded()[1].numbers.get("LB", {}).values():
|
||||
name = str(row.get("이름") or "")
|
||||
out[_flat(name)] = name
|
||||
for old in row.get("옛이름") or []:
|
||||
out.setdefault(_flat(old), name)
|
||||
return out
|
||||
|
||||
|
||||
def trade_names() -> dict[str, str]:
|
||||
return _trade_names(id(m01.loaded()[1]))
|
||||
|
||||
|
||||
def sources(text: str) -> list[list[str]]:
|
||||
"""글 → [[책, 절 번호]] — 「건설공사표준품셈 3-2-1」 · 「산림사업표준품셈(9-14)」."""
|
||||
out = []
|
||||
for hit in _BOOK.finditer(text):
|
||||
num = labor_inputs._NUMBER.search(text, hit.end())
|
||||
if not num or num.start() - hit.end() > 20:
|
||||
continue
|
||||
before = text[max(0, hit.start() - 12) : hit.start()]
|
||||
book = "산림품셈" if "산림" in before or "임도" in before else "건설품셈"
|
||||
if [book, num.group()] not in out:
|
||||
out.append([book, num.group()])
|
||||
return out
|
||||
|
||||
|
||||
def _columns(rows: list[list[Any]]) -> tuple[int, int, dict[str, int]]:
|
||||
"""(자료 첫 줄, 산출근거 열, {계 · 노무비 · 재료비 · 경비: 열}) — 머리 없으면 (0, 1, {})."""
|
||||
for at, row in enumerate(rows[:10]):
|
||||
flat = [_flat(v) for v in row]
|
||||
if "산출근거" in flat:
|
||||
money = {key: flat.index(head) for head, key in _MONEY.items() if head in flat}
|
||||
return at + 1, flat.index("산출근거"), money
|
||||
return 0, 1, {}
|
||||
|
||||
|
||||
def read(rows: list[list[Any]] | None) -> dict[int, dict[str, Any]]:
|
||||
"""단가산출 시트 줄 → `{N: {번, 이름, 단위, 행: [시작, 끝], 출처, 직종, 금액}}`
|
||||
(행 = 엑셀 1부터)."""
|
||||
if not rows:
|
||||
return {}
|
||||
start, col, money = _columns(rows)
|
||||
names = trade_names()
|
||||
blocks: dict[int, dict[str, Any]] = {}
|
||||
block: dict[str, Any] | None = None
|
||||
for at, row in enumerate(rows[start:], start + 1):
|
||||
text = row[col] if col < len(row) else None
|
||||
if not isinstance(text, str) or not text.strip():
|
||||
continue
|
||||
head = None if text[:1].isspace() else _HEAD.match(text)
|
||||
if head:
|
||||
block = blocks.setdefault(
|
||||
int(head.group(1)),
|
||||
{
|
||||
"번": int(head.group(1)),
|
||||
"이름": head.group(2),
|
||||
"단위": head.group(3).strip(),
|
||||
"행": [at, at],
|
||||
"출처": [],
|
||||
"직종": [],
|
||||
"금액": {
|
||||
key: v
|
||||
for key, c in money.items()
|
||||
for v in [row[c] if c < len(row) else None]
|
||||
if isinstance(v, (int, float)) and not isinstance(v, bool)
|
||||
},
|
||||
},
|
||||
)
|
||||
continue
|
||||
if block is None:
|
||||
continue
|
||||
block["행"][1] = at
|
||||
block["출처"] += [one for one in sources(text) if one not in block["출처"]]
|
||||
trade = _TRADE.match(text)
|
||||
name = names.get(_flat(trade.group(1))) if trade else None
|
||||
if name and name not in block["직종"]:
|
||||
block["직종"].append(name)
|
||||
return blocks
|
||||
|
||||
|
||||
# ── 우리 쪽 조항 · 직종 ────────────────────────────────────────────────
|
||||
@lru_cache(maxsize=2)
|
||||
def _ours(whole_id: int) -> dict[str, dict[str, Any]]:
|
||||
"""{로직 키: {책, 부문, 구분, 조항: {절 번호}, 직종: {이름}}} — 조항 = `sources()` 묶음 ·
|
||||
직종 = 호표 인력(LB) 요소."""
|
||||
files, whole = m01.loaded()
|
||||
lb = {str(row.get("키")): str(row.get("이름")) for row in whole.numbers.get("LB", {}).values()}
|
||||
out = {}
|
||||
for data in files.values():
|
||||
if data.get("그룹") != "로직":
|
||||
continue
|
||||
for row in data.get("줄") or []:
|
||||
kind = str(row.get("구분") or "")
|
||||
book, _, part = kind.partition(" ")
|
||||
trades = {
|
||||
lb[str(h.get("요소"))]
|
||||
for h in row.get("호표") or []
|
||||
if isinstance(h, dict) and str(h.get("요소")) in lb
|
||||
}
|
||||
clauses = set(labor_inputs._NUMBER.findall(str(labor_inputs.sources(row) or "")))
|
||||
out[str(row.get("키"))] = {
|
||||
"책": book,
|
||||
"부문": part,
|
||||
"구분": kind,
|
||||
"조항": clauses,
|
||||
"직종": trades,
|
||||
}
|
||||
return out
|
||||
|
||||
|
||||
def ours() -> dict[str, dict[str, Any]]:
|
||||
return _ours(id(m01.loaded()[1]))
|
||||
|
||||
|
||||
def _head(number: str) -> str:
|
||||
return number.rsplit("-", 1)[0]
|
||||
|
||||
|
||||
def _clause(theirs: list[list[str]], our: dict[str, Any]) -> tuple[float, list[str], str]:
|
||||
"""(점수, 맞은 우리 조항 글, 꼬리표) — 같은 책만."""
|
||||
nums = [n for book, n in theirs if book == our["책"]]
|
||||
same = sorted(n for n in nums if n in our["조항"])
|
||||
if same:
|
||||
return 1.0, [f"{our['구분']} {n}" for n in same], "조항 같음"
|
||||
heads = {_head(n) for n in nums}
|
||||
near = sorted(n for n in our["조항"] if _head(n) in heads)
|
||||
if near:
|
||||
return 0.5, [f"{our['구분']} {n}" for n in near], "앞 마디 같음"
|
||||
return 0.0, [], ""
|
||||
|
||||
|
||||
def _text(line: dict[str, Any]) -> str:
|
||||
text = f"{line.get('이름') or ''} {line.get('규격') or ''}"
|
||||
for old, new in _TYPOS:
|
||||
text = text.replace(old, new)
|
||||
return text
|
||||
|
||||
|
||||
def candidates(line: dict[str, Any], got: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
"""근거 후보 — 줄 `근거.출처` 가 없으면 빈 것 · `{로직, 이름, 단위, 점수, 근거}` 점수 차례."""
|
||||
basis = line.get("근거") or {}
|
||||
theirs = [list(one) for one in basis.get("출처") or []]
|
||||
if not theirs:
|
||||
return []
|
||||
trades = set(basis.get("직종") or []) - {COMMON}
|
||||
text = _text(line)
|
||||
mine = hopyo_match.words(text) # 괄호 글 뺌 — 「잔토처리(인력)」 ↔ 「철골재 철거(인력)」
|
||||
grams = hopyo_match._grams(hopyo_match.mm.plain(text)) # 괄호 글 포함 — 같은 점수 가름
|
||||
table = ours()
|
||||
scored = []
|
||||
for one in got["로직"]:
|
||||
our = table.get(one["로직"])
|
||||
if not our:
|
||||
continue
|
||||
score, clauses, tag = _clause(theirs, our)
|
||||
if not score:
|
||||
continue
|
||||
shared = sorted(trades & our["직종"])
|
||||
if not shared and not any(a in b or b in a for a in mine for b in one["words"]):
|
||||
continue
|
||||
tags = [tag]
|
||||
if our["책"] == "건설품셈" and our["부문"] in BONUS_PARTS:
|
||||
score += 0.1
|
||||
tags.append(f"건설 {our['부문']}")
|
||||
if shared:
|
||||
score += 0.5
|
||||
tags.append("직종 " + " · ".join(shared))
|
||||
unit = hopyo_match.fits(line.get("단위"), one["단위"])
|
||||
if unit:
|
||||
score += 0.15
|
||||
tags.append("단위 맞음")
|
||||
dice = 2 * len(grams & one["grams"]) / (len(grams) + len(one["grams"]))
|
||||
why = {"조항": clauses, "직종": shared, "단위": unit, "점수": round(score, 3)}
|
||||
scored.append(
|
||||
(score, dice, {**hopyo_match._public(one), "근거": {**why, "까닭": " · ".join(tags)}})
|
||||
)
|
||||
scored.sort(key=lambda x: (-x[0], -x[1]))
|
||||
return [one for _, _, one in scored]
|
||||
|
||||
|
||||
def merge(basis: list[dict[str, Any]], named: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""근거 후보 + 이름 후보 → 로직 키로 합침(점수 = 이름 점수 + 근거 점수) · 점수 차례."""
|
||||
if not basis:
|
||||
return named
|
||||
by_name = {c.get("로직"): c["점수"] for c in named}
|
||||
out = [{**c, "점수": round(c["근거"]["점수"] + by_name.pop(c["로직"], 0), 3)} for c in basis]
|
||||
out += [c for c in named if c.get("로직") in by_name]
|
||||
return sorted(out, key=lambda c: -c["점수"])
|
||||
@@ -11,6 +11,9 @@
|
||||
검산 = 줄 금액 floor(단가 × 수량 × 10) / 10 · 합계 단가 = 셋 합 ·
|
||||
% 줄 = floor(바탕 × 율 / 100 × 10) / 10 · 호표 비목 = floor(줄 금액 합) · 총괄표 합계 —
|
||||
어긋남은 글 목록(막지 않음).
|
||||
행(329-1) = 호표 [제N호표 줄, 마지막 구성 줄] · 구성 줄 한 줄(엑셀 1부터) ·
|
||||
근거(330-1) = 단산N참조 줄에 「단가산출」 블록 `{출처, 직종, 행}`(`M02_Hopyo_Basis.read`) ·
|
||||
`단산` = 블록마다 `{번, 이름, 단위, 행}`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -20,6 +23,8 @@ import re
|
||||
from decimal import ROUND_FLOOR, Decimal, InvalidOperation
|
||||
from typing import Any
|
||||
|
||||
from M02_MasterTemplete import M02_Hopyo_Basis as hopyo_basis
|
||||
|
||||
SHEET = "일위대가"
|
||||
SUMMARY = "일위대가총괄표"
|
||||
COSTS = ("노무비", "재료비", "경비")
|
||||
@@ -67,8 +72,8 @@ def _out(value: Decimal | None) -> int | float | None:
|
||||
|
||||
# ── 파일 → 시트 표(줄마다 값 목록) ────────────────────────────────────────
|
||||
def _sheets(name: str, data: bytes) -> dict[str, list[list[Any]]]:
|
||||
"""시트 이름 → 줄 목록 — 일위대가 · 총괄표만 읽음."""
|
||||
wanted = (SHEET, SUMMARY)
|
||||
"""시트 이름 → 줄 목록 — 일위대가 · 총괄표 · 단가산출만 읽음."""
|
||||
wanted = (SHEET, SUMMARY, hopyo_basis.SHEET)
|
||||
if not data:
|
||||
raise ExcelError("빈 파일")
|
||||
try:
|
||||
@@ -212,7 +217,7 @@ def read_book(name: str, data: bytes) -> dict[str, Any]:
|
||||
start, cols = _columns(rows, ("합계", *COSTS))
|
||||
hopyos: list[dict[str, Any]] = []
|
||||
raw: list[list[dict[str, Any]]] = []
|
||||
for row in rows[start:]:
|
||||
for at, row in enumerate(rows[start:], start + 1):
|
||||
head = _flat(_cell(row, cols.get("호표")))
|
||||
name_ = _text(_cell(row, cols.get("품명")))
|
||||
hit = _HOPYO.fullmatch(head)
|
||||
@@ -224,6 +229,7 @@ def read_book(name: str, data: bytes) -> dict[str, Any]:
|
||||
"규격": _text(_cell(row, cols.get("규격"))),
|
||||
"단위": _text(_cell(row, cols.get("단위"))),
|
||||
"금액": _money(row, cols, 1),
|
||||
"행": [at, at],
|
||||
}
|
||||
)
|
||||
raw.append([])
|
||||
@@ -244,9 +250,15 @@ def read_book(name: str, data: bytes) -> dict[str, Any]:
|
||||
"참조": {"종류": ref.group(1), "번": int(ref.group(2))} if ref else None,
|
||||
}
|
||||
line["갈래"] = kind_of(line)
|
||||
line["행"] = hopyos[-1]["행"][1] = at
|
||||
raw[-1].append(line)
|
||||
if not hopyos:
|
||||
raise ExcelError(f"{SHEET} 시트에 호표(제N호표)가 없음")
|
||||
blocks = hopyo_basis.read(sheets.get(hopyo_basis.SHEET))
|
||||
for line in (one for lines in raw for one in lines):
|
||||
ref = line["참조"] or {}
|
||||
if ref.get("종류") == "단산" and (block := blocks.get(ref["번"])):
|
||||
line["근거"] = {k: block[k] for k in ("출처", "직종", "행")}
|
||||
wrong: list[str] = []
|
||||
totals = _summary(sheets.get(SUMMARY))
|
||||
for hopyo, lines in zip(hopyos, raw, strict=True):
|
||||
@@ -258,7 +270,13 @@ def read_book(name: str, data: bytes) -> dict[str, Any]:
|
||||
{**_json(hopyo), "줄": [_json(line) for line in lines]}
|
||||
for hopyo, lines in zip(hopyos, raw, strict=True)
|
||||
]
|
||||
return {"시트": SHEET, "호표": out, "검산": {"어긋남": wrong, "총괄표": len(totals)}}
|
||||
basis = [{k: b[k] for k in ("번", "이름", "단위", "행")} for b in blocks.values()]
|
||||
return {
|
||||
"시트": SHEET,
|
||||
"호표": out,
|
||||
"단산": basis,
|
||||
"검산": {"어긋남": wrong, "총괄표": len(totals)},
|
||||
}
|
||||
|
||||
|
||||
def _json(value: Any) -> Any:
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
"""M02 일위대가 실무자료 엑셀꼴 격자(PLAN 329-1) — 올린 엑셀 「일위대가」 · 「단가산출」 시트 →
|
||||
A00 산출근거 스프레드시트 Workbook(`A00_Common/spreadsheet/spreadsheet_types.ts` 꼴 ·
|
||||
읽기 전용 보기).
|
||||
|
||||
xls = xlrd `formatting_info` — 값 · 병합 · 열 폭(÷256 글자) ·
|
||||
행 높이(÷20 pt · 시트 기본과 다른 것만) · 서식 표(글꼴 · 굵게 · 맞춤 · 줄바꿈 · 테두리 · 채움 ·
|
||||
숫자 형식) · 틀고정(파일 값 · 없으면 머리 4행).
|
||||
xlsx = 값만 · 넓은 시트는 머리 글(앞 4 줄) 마지막 열까지.
|
||||
결과 `{책, 호표: [{번, 이름, 행}], 단산: [{번, 이름, 행}]}` — 행 = 엑셀 1부터 [시작, 끝].
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
from typing import Any
|
||||
|
||||
from openpyxl.utils import get_column_letter
|
||||
|
||||
from M02_MasterTemplete import M02_Hopyo_Basis as hopyo_basis
|
||||
from M02_MasterTemplete import M02_Hopyo_Excel as hopyo_excel
|
||||
|
||||
SHEETS = (hopyo_excel.SHEET, hopyo_basis.SHEET)
|
||||
HEAD_ROWS = 4
|
||||
_HALIGN = {1: "left", 2: "center", 3: "right", 6: "centerContinuous", 7: "distributed"}
|
||||
_VALIGN = {0: "top", 1: "center"}
|
||||
_LINES = {1: "thin", 2: "medium", 3: "dashed", 4: "dotted", 5: "thick", 6: "double", 7: "hair"}
|
||||
_SIDES = (("위", "top"), ("아래", "bottom"), ("왼", "left"), ("오른", "right"))
|
||||
|
||||
|
||||
class _Styles:
|
||||
"""서식 표 — 같은 서식은 한 번만 · [0] = 기본 `{}`."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.table: list[dict[str, Any]] = [{}]
|
||||
self._index: dict[str, int] = {"{}": 0}
|
||||
|
||||
def add(self, style: dict[str, Any]) -> int:
|
||||
key = repr(sorted(style.items()))
|
||||
if not style:
|
||||
return 0
|
||||
if key not in self._index:
|
||||
self._index[key] = len(self.table)
|
||||
self.table.append(style)
|
||||
return self._index[key]
|
||||
|
||||
|
||||
def _rgb(book: Any, index: int) -> str | None:
|
||||
got = book.colour_map.get(index)
|
||||
return "#{:02X}{:02X}{:02X}".format(*got) if got else None
|
||||
|
||||
|
||||
def _xf_style(book: Any, xf: Any, base: Any) -> dict[str, Any]:
|
||||
font = book.font_list[xf.font_index]
|
||||
out: dict[str, Any] = {}
|
||||
if font.name != base.name:
|
||||
out["글꼴"] = font.name
|
||||
if font.height != base.height:
|
||||
out["크기"] = font.height / 20
|
||||
for key, on in (
|
||||
("굵게", font.bold),
|
||||
("기울임", font.italic),
|
||||
("밑줄", font.underline_type),
|
||||
("취소선", font.struck_out),
|
||||
):
|
||||
if on:
|
||||
out[key] = True
|
||||
color = _rgb(book, font.colour_index)
|
||||
if color and color != "#000000":
|
||||
out["글자색"] = color
|
||||
if xf.background.fill_pattern == 1 and (fill := _rgb(book, xf.background.pattern_colour_index)):
|
||||
out["채움"] = fill
|
||||
align = xf.alignment
|
||||
if align.hor_align in _HALIGN:
|
||||
out["가로"] = _HALIGN[align.hor_align]
|
||||
if align.vert_align in _VALIGN:
|
||||
out["세로"] = _VALIGN[align.vert_align]
|
||||
if align.text_wrapped:
|
||||
out["줄바꿈"] = True
|
||||
sides = {}
|
||||
for name, side in _SIDES:
|
||||
line = getattr(xf.border, f"{side}_line_style")
|
||||
if line:
|
||||
one = {"선": _LINES.get(line, "dashed")}
|
||||
color = _rgb(book, getattr(xf.border, f"{side}_colour_index"))
|
||||
if color and color != "#000000":
|
||||
one["색"] = color
|
||||
sides[name] = one
|
||||
if sides:
|
||||
out["테두리"] = sides
|
||||
fmt = book.format_map.get(xf.format_key)
|
||||
if fmt and fmt.format_str and fmt.format_str != "General":
|
||||
out["형식"] = fmt.format_str
|
||||
return out
|
||||
|
||||
|
||||
def _value(cell: Any) -> Any:
|
||||
import xlrd
|
||||
|
||||
if cell.ctype == xlrd.XL_CELL_TEXT:
|
||||
return cell.value or None
|
||||
if cell.ctype in (xlrd.XL_CELL_NUMBER, xlrd.XL_CELL_DATE):
|
||||
return int(cell.value) if cell.value == int(cell.value) else cell.value
|
||||
if cell.ctype == xlrd.XL_CELL_BOOLEAN:
|
||||
return bool(cell.value)
|
||||
if cell.ctype == xlrd.XL_CELL_ERROR:
|
||||
return xlrd.error_text_from_code.get(cell.value, "#N/A")
|
||||
return None
|
||||
|
||||
|
||||
def _xls_sheet(book: Any, sh: Any, styles: _Styles, sid: str) -> dict[str, Any]:
|
||||
base = book.font_list[0]
|
||||
by_xf: dict[int, int] = {}
|
||||
cells: dict[str, dict[str, Any]] = {}
|
||||
for r in range(sh.nrows):
|
||||
for c in range(sh.ncols):
|
||||
cell = sh.cell(r, c)
|
||||
if cell.xf_index not in by_xf:
|
||||
by_xf[cell.xf_index] = styles.add(
|
||||
_xf_style(book, book.xf_list[cell.xf_index], base)
|
||||
)
|
||||
one: dict[str, Any] = {}
|
||||
if (value := _value(cell)) is not None:
|
||||
one["값"] = value
|
||||
if by_xf[cell.xf_index]:
|
||||
one["서식"] = by_xf[cell.xf_index]
|
||||
if one:
|
||||
cells[f"{get_column_letter(c + 1)}{r + 1}"] = one
|
||||
merged = [
|
||||
f"{get_column_letter(c0 + 1)}{r0 + 1}:{get_column_letter(c1)}{r1}"
|
||||
for r0, r1, c0, c1 in sh.merged_cells
|
||||
]
|
||||
cols = {
|
||||
get_column_letter(c + 1): {
|
||||
"폭": round(info.width / 256, 2),
|
||||
**({"숨김": True} if info.hidden else {}),
|
||||
}
|
||||
for c, info in sh.colinfo_map.items()
|
||||
if c < sh.ncols
|
||||
}
|
||||
rows = {
|
||||
str(r + 1): {"높이": info.height / 20, **({"숨김": True} if info.hidden else {})}
|
||||
for r, info in sh.rowinfo_map.items()
|
||||
if r < sh.nrows and (info.height != sh.default_row_height or info.hidden)
|
||||
}
|
||||
frozen = sh.panes_are_frozen and (sh.horz_split_pos or sh.vert_split_pos)
|
||||
freeze = {"행": sh.horz_split_pos, "열": sh.vert_split_pos} if frozen else {"행": HEAD_ROWS}
|
||||
out = {
|
||||
"id": sid,
|
||||
"이름": sh.name.strip(),
|
||||
"칸": cells,
|
||||
"병합": merged,
|
||||
"열": cols,
|
||||
"행": rows,
|
||||
"틀고정": {k: v for k, v in freeze.items() if v},
|
||||
}
|
||||
if sh.default_row_height:
|
||||
out["기본"] = {"행높이": sh.default_row_height / 20}
|
||||
return out
|
||||
|
||||
|
||||
def _xls(data: bytes) -> dict[str, Any]:
|
||||
import xlrd
|
||||
|
||||
book = xlrd.open_workbook(file_contents=data, formatting_info=True, logfile=io.StringIO())
|
||||
styles = _Styles()
|
||||
found = {sh.name.strip(): sh for sh in book.sheets()}
|
||||
sheets = [
|
||||
_xls_sheet(book, found[name], styles, f"s{at}")
|
||||
for at, name in enumerate((n for n in SHEETS if n in found), 1)
|
||||
]
|
||||
font = book.font_list[0]
|
||||
return {
|
||||
"기본": {"글꼴": font.name, "크기": font.height / 20},
|
||||
"서식": styles.table,
|
||||
"시트": sheets,
|
||||
}
|
||||
|
||||
|
||||
def _xlsx(name: str, data: bytes) -> dict[str, Any]:
|
||||
# ponytail: xlsx 는 값만(병합 · 폭 · 서식은 openpyxl 비 read_only 로 무거움) — 필요하면 그때
|
||||
found = hopyo_excel._sheets(name, data)
|
||||
sheets = []
|
||||
for at, title in enumerate((n for n in SHEETS if n in found), 1):
|
||||
rows = found[title]
|
||||
width = max(
|
||||
(c + 1 for row in rows[:HEAD_ROWS] for c, v in enumerate(row) if v not in (None, "")),
|
||||
default=0,
|
||||
)
|
||||
cells = {
|
||||
f"{get_column_letter(c + 1)}{r + 1}": {
|
||||
"값": v if isinstance(v, (int, float, bool)) else str(v)
|
||||
}
|
||||
for r, row in enumerate(rows)
|
||||
for c, v in enumerate(row[:width])
|
||||
if v not in (None, "")
|
||||
}
|
||||
sheets.append({"id": f"s{at}", "이름": title, "칸": cells, "틀고정": {"행": HEAD_ROWS}})
|
||||
return {"서식": [{}], "시트": sheets}
|
||||
|
||||
|
||||
def read(name: str, data: bytes, parsed: dict[str, Any]) -> dict[str, Any]:
|
||||
"""엑셀 → `{책: Workbook, 호표, 단산}` — `parsed` = `M02_Hopyo_Excel.read_book` 결과
|
||||
(행 범위)."""
|
||||
xlsx = data[:4] == b"PK\x03\x04" or name.lower().endswith(".xlsx")
|
||||
book = _xlsx(name, data) if xlsx else _xls(data)
|
||||
keep = ("번", "이름", "행")
|
||||
return {
|
||||
"책": {"종류": "통합문서", "판": 1, "열": "", **book, "활성": "s1"},
|
||||
"호표": [{k: h.get(k) for k in keep} for h in parsed.get("호표") or []],
|
||||
"단산": [{k: b.get(k) for k in keep} for b in parsed.get("단산") or []],
|
||||
}
|
||||
@@ -4,6 +4,8 @@
|
||||
씨앗(일위대가조합.json 메모 · 읽기만)
|
||||
③ 정리 이름 같음(괄호 · 기계 낱말) ④ 후보 5(글자 두 개 묶음 점수 + 단위 맞음 + 산림품셈 덤 ·
|
||||
뜻 낱말이 하나도 안 겹치면 뺌 — 「잔토처리」 ↔ 「소금처리」) —
|
||||
+ 근거 후보(330-2 · `M02_Hopyo_Basis` · 단산 줄 출처 · 직종 · 이름 거름 안 거침 ·
|
||||
로직 키로 합쳐 점수 더함) ·
|
||||
③ ④ 는 사용자 확정(층 기억) 뒤에만 들어감.
|
||||
줄 갈래: 단가산출 · 기계 · 기타 = 로직 / 자재 · 별도계상 = 자재(M01 자재품목) / 호표 = 그 호표 /
|
||||
% = 안 들어감(로직 덧줄이 셈).
|
||||
@@ -27,6 +29,7 @@ from typing import Any
|
||||
|
||||
from M01_MasterData import M01_MasterData_Store as m01
|
||||
from M01_MasterData import M01_MasterData_Store_Combo as combo_store
|
||||
from M02_MasterTemplete import M02_Hopyo_Basis as hopyo_basis
|
||||
from M02_MasterTemplete import M02_Hopyo_Store as hopyo_store
|
||||
from M02_MasterTemplete import M02_Labor_Inputs as labor_inputs
|
||||
from M02_MasterTemplete import M02_Labor_Ref as refs
|
||||
@@ -295,9 +298,10 @@ def _exact(line: dict[str, Any], pool: str, got: dict[str, Any]) -> list[dict[st
|
||||
|
||||
|
||||
def candidates(
|
||||
line: dict[str, Any], pool: str, got: dict[str, Any] | None = None, limit: int = 5
|
||||
line: dict[str, Any], pool: str, got: dict[str, Any] | None = None, limit: int | None = 5
|
||||
) -> list[dict[str, Any]]:
|
||||
"""③ 정리 이름 같음 먼저 · ④ 글자 두 개 묶음 점수 + 단위 맞음 + 산림품셈 덤 — 위 `limit`."""
|
||||
"""③ 정리 이름 같음 먼저 · ④ 글자 두 개 묶음 점수 + 단위 맞음 + 산림품셈 덤 — 위 `limit`
|
||||
(None = 모두)."""
|
||||
me = tidy(line.get("이름"))
|
||||
grams, unit = _grams(me), line.get("단위")
|
||||
text = mm.plain_spec(f"{line.get('이름')}{line.get('규격')}")
|
||||
@@ -402,11 +406,9 @@ def match_line(
|
||||
return out
|
||||
top = [{**_public(x), "점수": 9} for x in found]
|
||||
same = [_public(x) for x in found]
|
||||
rest = [
|
||||
c
|
||||
for c in candidates(line, pool, got)
|
||||
if {k: v for k, v in c.items() if k != "점수"} not in same
|
||||
]
|
||||
basis = hopyo_basis.candidates(line, got) if pool == "로직" else []
|
||||
named = candidates(line, pool, got, None if basis else 5)
|
||||
rest = [c for c in hopyo_basis.merge(basis, named) if _public(c) not in same]
|
||||
out["후보"] = (top + rest)[:5]
|
||||
return {**out, "상태": "후보" if out["후보"] else "없음"}
|
||||
|
||||
@@ -461,7 +463,8 @@ def view(folder: Path, key: str, no: int | None = None) -> dict[str, Any]:
|
||||
if no is None:
|
||||
got = targets()
|
||||
hopyos = [match_hopyo(h, memo, units, False, got) for h in doc.get("호표") or []]
|
||||
brief = [{k: h[k] for k in ("번", "이름", "규격", "단위", "금액", "맞춤")} for h in hopyos]
|
||||
keep = ("번", "이름", "규격", "단위", "금액", "맞춤")
|
||||
brief = [{k: h.get(k) for k in keep} for h in hopyos]
|
||||
ready = [sum(h["맞춤"][0] for h in hopyos), sum(h["맞춤"][1] for h in hopyos)]
|
||||
return {**hopyo_store.brief(doc), "검산": doc.get("검산"), "호표": brief, "맞춤": ready}
|
||||
hopyo = next((h for h in doc.get("호표") or [] if int(h["번"]) == int(no)), None)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""M02 일위대가 실무자료 저장(PLAN 324-1) — 층 폴더 `실무자료/<키>.json` ·
|
||||
맞춤 기억 `실무자료/맞춤.json`.
|
||||
맞춤 기억 `실무자료/맞춤.json` · 엑셀꼴 격자 `실무자료/격자/<키>.json`(329-1 · 파일과 같이 지움).
|
||||
|
||||
파일 = 읽은 결과만(원본 엑셀 안 둠) `{키, 파일, 올린때, 올린이, 시트, 호표, 검산}` · 키 `HP-######`
|
||||
(층마다 다음 번호) · 맞춤 기억 = `{줄: {줄 열쇠: {로직?|자재?|빼기?, 입력값, 환산, 확정}}}`.
|
||||
@@ -20,6 +20,8 @@ from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
||||
|
||||
DIRNAME = "실무자료"
|
||||
MEMORY = "맞춤"
|
||||
GRID = "격자"
|
||||
NO_GRID = "격자 없음 — 다시 올리면 보임"
|
||||
_KEY = re.compile(r"^HP-(\d{6})$")
|
||||
# ponytail: 프로세스 안 잠금 — 서버를 여럿 띄우면 파일 잠금으로
|
||||
_LOCK = threading.Lock()
|
||||
@@ -42,6 +44,10 @@ def _load(path: Path) -> Any:
|
||||
return None
|
||||
|
||||
|
||||
def _grid_path(folder: str | Path, key: str) -> Path:
|
||||
return _dir(folder) / GRID / _path(folder, key).name
|
||||
|
||||
|
||||
def brief(doc: dict[str, Any]) -> dict[str, Any]:
|
||||
"""목록 한 줄 — `{키, 파일, 올린때, 올린이, 시트, 호표수, 어긋남}`."""
|
||||
keep = ("키", "파일", "올린때", "올린이", "시트")
|
||||
@@ -69,8 +75,14 @@ def read(folder: str | Path, key: str) -> dict[str, Any]:
|
||||
return doc
|
||||
|
||||
|
||||
def save(folder: str | Path, name: str, parsed: dict[str, Any], who: Any) -> dict[str, Any]:
|
||||
"""읽은 결과 → 새 키로 씀 → 목록 한 줄."""
|
||||
def save(
|
||||
folder: str | Path,
|
||||
name: str,
|
||||
parsed: dict[str, Any],
|
||||
who: Any,
|
||||
grid: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""읽은 결과(+ 격자) → 새 키로 씀 → 목록 한 줄."""
|
||||
with _LOCK:
|
||||
have = [
|
||||
int(m.group(1)) for p in _dir(folder).glob("HP-*.json") if (m := _KEY.match(p.stem))
|
||||
@@ -83,16 +95,28 @@ def save(folder: str | Path, name: str, parsed: dict[str, Any], who: Any) -> dic
|
||||
"올린이": who,
|
||||
**parsed,
|
||||
}
|
||||
if grid is not None:
|
||||
book = {**grid["책"], "열": key}
|
||||
atomic_write_json(_grid_path(folder, key), {**grid, "책": book})
|
||||
atomic_write_json(_path(folder, key), doc)
|
||||
return brief(doc)
|
||||
|
||||
|
||||
def grid(folder: str | Path, key: str) -> dict[str, Any]:
|
||||
"""엑셀꼴 격자 `{책, 호표, 단산}` — 없으면(옛 파일) 404."""
|
||||
doc = _load(_grid_path(folder, key))
|
||||
if not isinstance(doc, dict):
|
||||
raise store.StoreError(404, NO_GRID)
|
||||
return doc
|
||||
|
||||
|
||||
def delete(folder: str | Path, key: str) -> None:
|
||||
path = _path(folder, key)
|
||||
with _LOCK:
|
||||
if not path.is_file():
|
||||
raise store.StoreError(404, f"없는 실무자료 「{key}」")
|
||||
path.unlink()
|
||||
_grid_path(folder, key).unlink(missing_ok=True)
|
||||
|
||||
|
||||
# ── 맞춤 기억 ─────────────────────────────────────────────────────────
|
||||
|
||||
@@ -20,6 +20,7 @@ from pydantic import BaseModel
|
||||
from common_util.common_util_auth import verify_session
|
||||
from M02_MasterTemplete import M02_Hopyo_Build as hopyo_build
|
||||
from M02_MasterTemplete import M02_Hopyo_Excel as hopyo_excel
|
||||
from M02_MasterTemplete import M02_Hopyo_Grid as hopyo_grid
|
||||
from M02_MasterTemplete import M02_Hopyo_Match as hopyo_match
|
||||
from M02_MasterTemplete import M02_Hopyo_Store as hopyo_store
|
||||
from M02_MasterTemplete import M02_Labor_Doc as labor
|
||||
@@ -176,7 +177,11 @@ def _upload(name: str, data: bytes, who: dict[str, Any]) -> dict[str, Any]:
|
||||
parsed = hopyo_excel.read_book(name, data)
|
||||
except hopyo_excel.ExcelError as error:
|
||||
raise store.StoreError(400, str(error)) from error
|
||||
return hopyo_store.save(store.root(), name, parsed, who)
|
||||
try:
|
||||
grid = hopyo_grid.read(name, data, parsed)
|
||||
except Exception: # 격자는 보기용 덤 — 못 만들어도 올리기는 됨(보기 창 404)
|
||||
grid = None
|
||||
return hopyo_store.save(store.root(), name, parsed, who, grid)
|
||||
|
||||
|
||||
@router.get("/layers/{layer}/hopyo")
|
||||
@@ -205,7 +210,7 @@ def hopyo_upload(
|
||||
owner: str | None = None,
|
||||
session: Session = Depends(verify_session),
|
||||
) -> dict:
|
||||
"""엑셀(xls · xlsx) 올리기 — 「일위대가」 시트를 읽어 결과만 저장 · 20 MB ·
|
||||
"""엑셀(xls · xlsx) 올리기 — 「일위대가」 시트를 읽어 결과 · 격자 저장 · 20 MB ·
|
||||
엉터리 · 시트 없음 400."""
|
||||
folder = _root(session, layer, owner, write=True)
|
||||
data = file.file.read(HOPYO_MAX_BYTES + 1)
|
||||
@@ -225,6 +230,16 @@ def hopyo_read(
|
||||
return _call(folder, hopyo_match.view, folder, key)
|
||||
|
||||
|
||||
@router.get("/layers/{layer}/hopyo-grid/{key}")
|
||||
def hopyo_grid_read(
|
||||
layer: Layer, key: str, owner: str | None = None, session: Session = Depends(verify_session)
|
||||
) -> dict:
|
||||
"""엑셀꼴 격자(329-1) `{책: Workbook, 호표: [{번, 이름, 행}], 단산: [{번, 이름, 행}]}` ·
|
||||
옛 파일 404."""
|
||||
folder = _root(session, layer, owner)
|
||||
return _call(folder, hopyo_store.grid, folder, key)
|
||||
|
||||
|
||||
@router.get("/layers/{layer}/hopyo/{key}/{no}")
|
||||
def hopyo_one(
|
||||
layer: Layer,
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
"""일위대가 실무자료 단가산출 근거 맞춤(PLAN 330 · `M02_Hopyo_Basis` · 맞춤 후보 `근거`).
|
||||
|
||||
블록 북부청 95 · 사방 67 · 단산93 근거 [건설 3-2-1] · [특별인부] ·
|
||||
후보 93 → GC000083 · 21 → GC000198 · 64 → GC000143 위 둘 · 49 → GC000085 ·
|
||||
잔토처리에 철골재 철거 없음 · 근거 없는 줄 그대로 · 상태 「후보」(자동 확정 없음) ·
|
||||
master_data 그대로.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from M02_MasterTemplete import M02_Hopyo_Basis as hb
|
||||
from M02_MasterTemplete import M02_Hopyo_Excel as hx
|
||||
from M02_MasterTemplete import M02_Hopyo_Match as hm
|
||||
from resources.tester import test_m02_hopyo_excel as base
|
||||
from resources.tester.test_m02_hopyo_excel import (
|
||||
NORTH,
|
||||
SABANG,
|
||||
URL,
|
||||
_post,
|
||||
master_hash,
|
||||
)
|
||||
|
||||
client = base.client # 공용 고정물(임시 층)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def north() -> dict:
|
||||
return hx.read_book(NORTH.name, NORTH.read_bytes())
|
||||
|
||||
|
||||
def _line(book: dict, no: int) -> dict:
|
||||
"""단산 no 를 가리키는 첫 줄."""
|
||||
return next(x for h in book["호표"] for x in h["줄"] if x["참조"] == {"종류": "단산", "번": no})
|
||||
|
||||
|
||||
def test_블록_수_와_근거(north):
|
||||
assert len(north["단산"]) == 95
|
||||
assert [b["번"] for b in north["단산"]] == list(range(1, 96))
|
||||
sabang = hx.read_book(SABANG.name, SABANG.read_bytes())
|
||||
assert len(sabang["단산"]) == 67
|
||||
assert _line(north, 93)["근거"] == {
|
||||
"출처": [["건설품셈", "3-2-1"]],
|
||||
"직종": ["특별인부"],
|
||||
"행": [4229, 4244],
|
||||
}
|
||||
assert _line(north, 21)["근거"]["직종"] == ["콘크리트공", "보통인부"] # 「보 통 인부」 빈칸 뺌
|
||||
assert _line(north, 64)["근거"]["출처"][0] == ["건설품셈", "4-1-1"]
|
||||
lines = [x for h in north["호표"] for x in h["줄"] if x["갈래"] == "단가산출"]
|
||||
assert all("근거" in x for x in lines) # 268 줄 모두 블록 있음
|
||||
|
||||
|
||||
def test_출처_글():
|
||||
assert hb.sources("[건설공사표준품셈 3-2-1 인력굴착]") == [["건설품셈", "3-2-1"]]
|
||||
assert hb.sources("건설표줌품셈(3-1-3) 자갈") == [["건설품셈", "3-1-3"]]
|
||||
assert hb.sources("[2025년 산림사업표준품셈 9-14]") == [["산림품셈", "9-14"]]
|
||||
assert hb.sources("흙운반 [임도표준품셈 4-2]") == [["산림품셈", "4-2"]]
|
||||
assert hb.sources("[건설표준품셈 준용]") == []
|
||||
|
||||
|
||||
def _top(book: dict, no: int) -> list[dict]:
|
||||
got = hm.match_line(_line(book, no), [], hm.units_of(book))
|
||||
assert got["상태"] == "후보" and got["들어감"] is False # 자동 확정 없음
|
||||
return got["후보"]
|
||||
|
||||
|
||||
def test_근거_후보(north):
|
||||
before = master_hash()
|
||||
dig = _top(north, 93)
|
||||
assert dig[0]["로직"] == "GC000083"
|
||||
assert dig[0]["근거"] == {
|
||||
"조항": ["건설품셈 공통 3-2-1"],
|
||||
"직종": ["특별인부"],
|
||||
"단위": True,
|
||||
"점수": 1.75,
|
||||
"까닭": "조항 같음 · 건설 공통 · 직종 특별인부 · 단위 맞음",
|
||||
}
|
||||
assert _top(north, 21)[0]["로직"] == "GC000198"
|
||||
assert "GC000143" in [c["로직"] for c in _top(north, 64)[:2]]
|
||||
assert _top(north, 49)[0]["로직"] == "GC000085"
|
||||
soil = next(x for x in north["호표"][2]["줄"] if x["이름"].startswith("잔토처리"))
|
||||
names = [c["이름"] for c in hm.match_line(soil, [], {})["후보"]]
|
||||
assert not any("철골재" in n for n in names)
|
||||
assert master_hash() == before
|
||||
|
||||
|
||||
def test_근거_없는_줄_그대로(north):
|
||||
bare = [
|
||||
x
|
||||
for h in north["호표"]
|
||||
for x in h["줄"]
|
||||
if x["갈래"] == "단가산출" and not x["근거"]["출처"]
|
||||
]
|
||||
assert bare
|
||||
for line in bare:
|
||||
plain = {k: v for k, v in line.items() if k != "근거"}
|
||||
assert hm.match_line(line, [], {}) == hm.match_line(plain, [], {})
|
||||
|
||||
|
||||
def test_길_응답_근거_칸(client):
|
||||
before = master_hash()
|
||||
assert _post(client).status_code == 200
|
||||
res = client.get(f"{URL.format('personal')}/HP-000001/3")
|
||||
lines = {x["이름"]: x for x in res.json()["호표"]["줄"]}
|
||||
dig = lines["구조물터파기(인력100%)"]
|
||||
assert dig["근거"]["출처"] == [["건설품셈", "3-2-1"]]
|
||||
assert dig["맞춤"]["후보"][0]["근거"]["까닭"].startswith("조항 같음")
|
||||
assert "근거" not in lines["임도표지석(받침,글새김포함)"] # 자재 줄
|
||||
assert master_hash() == before
|
||||
@@ -0,0 +1,120 @@
|
||||
"""일위대가 실무자료 엑셀꼴 격자(PLAN 329-1 · `M02_Hopyo_Grid` · `GET hopyo-grid/{키}`).
|
||||
|
||||
북부청 임도 xls — 병합 11 · 15열 · B20 「제3호표」 · `#,##0` · 호표 68 행 범위 안 겹침 ·
|
||||
제3호표 [20, 24] · 올리면 격자도 · 200 / 옛 파일 404 · 지우면 격자도 · 역할 · xlsx 값만 ·
|
||||
master_data 그대로.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from M02_MasterTemplete import M02_Hopyo_Excel as hx
|
||||
from M02_MasterTemplete import M02_Hopyo_Grid as hg
|
||||
from M02_MasterTemplete import M02_Hopyo_Store as hs
|
||||
from M02_MasterTemplete import M02_Template_Layers as layers
|
||||
from resources.tester import test_m02_hopyo_excel as base
|
||||
from resources.tester.test_m02_hopyo_excel import (
|
||||
NORTH,
|
||||
URL,
|
||||
_post,
|
||||
_xlsx,
|
||||
master_hash,
|
||||
)
|
||||
|
||||
client = base.client # 공용 고정물(임시 층)
|
||||
GRID = "/api/m02/layers/{}/hopyo-grid/{}"
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def north() -> dict:
|
||||
data = NORTH.read_bytes()
|
||||
return hg.read(NORTH.name, data, hx.read_book(NORTH.name, data))
|
||||
|
||||
|
||||
def _sheet(grid: dict, name: str) -> dict:
|
||||
return next(s for s in grid["책"]["시트"] if s["이름"] == name)
|
||||
|
||||
|
||||
def test_격자_모양(north):
|
||||
book = north["책"]
|
||||
assert (book["종류"], book["판"], book["활성"]) == ("통합문서", 1, "s1")
|
||||
assert [s["이름"] for s in book["시트"]] == ["일위대가", "단가산출"]
|
||||
sheet = _sheet(north, "일위대가")
|
||||
assert len(sheet["병합"]) == 11 and "B1:O2" in sheet["병합"]
|
||||
assert len(sheet["열"]) == 15 and sheet["열"]["C"]["폭"] == round(7208 / 256, 2)
|
||||
assert sheet["틀고정"] == {"행": 4}
|
||||
assert sheet["칸"]["B20"]["값"] == "제3호표"
|
||||
assert sheet["칸"]["H5"]["값"] == 1040052
|
||||
styles = book["서식"]
|
||||
assert styles[0] == {} and any(s.get("형식") == "#,##0" for s in styles)
|
||||
assert any(s.get("테두리") for s in styles)
|
||||
assert _sheet(north, "단가산출")["칸"]["B4"]["값"] == "1.유용토운반(무대)[㎥]"
|
||||
|
||||
|
||||
def test_호표_단산_행_범위(north):
|
||||
rows = [h["행"] for h in north["호표"]]
|
||||
assert len(rows) == 68
|
||||
assert all(a[1] < b[0] for a, b in zip(sorted(rows), sorted(rows)[1:], strict=False))
|
||||
assert next(h for h in north["호표"] if h["번"] == 3) == {
|
||||
"번": 3,
|
||||
"이름": "임도표지석",
|
||||
"행": [20, 24],
|
||||
}
|
||||
assert len(north["단산"]) == 95
|
||||
assert next(b for b in north["단산"] if b["번"] == 93)["행"] == [4229, 4244]
|
||||
|
||||
|
||||
def test_줄_행(north):
|
||||
book = hx.read_book(NORTH.name, NORTH.read_bytes())
|
||||
three = next(h for h in book["호표"] if h["번"] == 3)
|
||||
assert three["행"] == [20, 24] and [x["행"] for x in three["줄"]] == [21, 22, 23, 24]
|
||||
|
||||
|
||||
def test_올리기_격자_200_404_지우기(client):
|
||||
before = master_hash()
|
||||
assert _post(client).status_code == 200
|
||||
res = client.get(GRID.format("personal", "HP-000001"))
|
||||
assert res.status_code == 200, res.text
|
||||
got = res.json()
|
||||
assert got["책"]["열"] == "HP-000001" and len(got["호표"]) == 68
|
||||
folder = layers.personal_dir(7, 42)
|
||||
path = folder / hs.DIRNAME / hs.GRID / "HP-000001.json"
|
||||
assert path.is_file()
|
||||
assert [r["키"] for r in client.get(URL.format("personal")).json()["줄"]] == ["HP-000001"]
|
||||
path.unlink() # 옛 파일(324) = 격자 없음
|
||||
old = client.get(GRID.format("personal", "HP-000001"))
|
||||
assert old.status_code == 404 and old.json()["detail"] == "격자 없음 — 다시 올리면 보임"
|
||||
assert _post(client).status_code == 200
|
||||
assert client.get(GRID.format("personal", "HP-000002")).status_code == 200
|
||||
assert client.delete(URL.format("personal") + "/HP-000002").status_code == 200
|
||||
assert not (folder / hs.DIRNAME / hs.GRID / "HP-000002.json").exists()
|
||||
assert client.get(GRID.format("personal", "HP-000002")).status_code == 404
|
||||
assert client.get(GRID.format("personal", "../x")).status_code == 404
|
||||
assert client.get(URL.format("personal") + "/HP-000001/3").status_code == 200 # 길 안 부딪힘
|
||||
assert master_hash() == before
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("role", "layer", "owner", "want"),
|
||||
[
|
||||
({"role": "USER"}, "personal", None, 200),
|
||||
({"role": "USER"}, "personal", "7/43", 403),
|
||||
({"role": "USER"}, "company", None, 200),
|
||||
({"role": "USER"}, "system", None, 200),
|
||||
],
|
||||
)
|
||||
def test_역할_읽기(client, role, layer, owner, want):
|
||||
client.who.update({"role": "SYSTEM_ADMIN"})
|
||||
assert _post(client, layer if want == 200 else "personal").status_code == 200
|
||||
client.who.update(role)
|
||||
params = {"owner": owner} if owner else {}
|
||||
assert client.get(GRID.format(layer, "HP-000001"), params=params).status_code == want
|
||||
|
||||
|
||||
def test_xlsx_값만():
|
||||
data = _xlsx()
|
||||
grid = hg.read("a.xlsx", data, hx.read_book("a.xlsx", data))
|
||||
sheet = grid["책"]["시트"][0]
|
||||
assert sheet["이름"] == "일위대가" and sheet["틀고정"] == {"행": 4}
|
||||
assert sheet["칸"]["B5"] == {"값": "제 1 호표"} and sheet["칸"]["E6"] == {"값": 0.5}
|
||||
assert "K6" not in sheet["칸"] # 머리 글(앞 4 줄) 마지막 열 J 까지
|
||||
assert grid["호표"] == [{"번": 1, "이름": "표지석", "행": [5, 6]}] and grid["단산"] == []
|
||||
@@ -105,7 +105,7 @@ def test_이름_같음_후보_호표_퍼센트(client):
|
||||
assert all(c["규격"] == "25-21-120" for c in remicon["후보"][:3]) # 규격 같은 자재 먼저
|
||||
dig = _hopyo(client, 3)["구조물터파기(인력100%)"]
|
||||
assert dig["상태"] == "후보" and len(dig["후보"]) == 5
|
||||
assert dig["후보"][0]["이름"].startswith("구조물터파기")
|
||||
assert dig["후보"][0]["이름"] == "굴착(인력/토사)" # 330-2 근거(건설 3-2-1 · 특별인부) 1순위
|
||||
ref = _hopyo(client, 45)["고화매트재료비"]
|
||||
assert ref["상태"] == "호표" and ref["대상"] == {"호표": 46, "단위": "㎡"} and ref["들어감"]
|
||||
pct = _hopyo(client, 46)["잡재료비"]
|
||||
|
||||
Reference in New Issue
Block a user