Merge remote-tracking branches 'origin/main_laptop_1' and 'origin/sub_laptop_2' into sub_laptop_1

This commit is contained in:
2026-11-04 11:56:46 +09:00
34 changed files with 1270 additions and 202 deletions
+7
View File
@@ -72,6 +72,9 @@ export interface SpreadsheetOptions {
conditions?: ConditionOptions;
/** 그림 열쇠 → 주소(요구 347-5 · 시트 `그림`) — 없으면 이름 적힌 빈 상자 */
figureSrc?: FigureSrc;
/** 칸 칠(요구 621-5) — 시트 id · 행 · 열(0 부터) → 꼴 이름 `success|warning|info|danger|muted`(흐림) + `mine`(왼쪽 띠) ·
* 공백으로 여럿 · 칸 `data-tone` 으로 나감 · 보이는 칸만 부름 · 안 주면 화면 그대로 */
칠?: (시트: string, r: number, c: number) => string | null | undefined;
}
export interface SpreadsheetHandle {
@@ -93,6 +96,8 @@ export interface SpreadsheetHandle {
/** HTML 표를 지금 시트 고른 칸(왼위)부터 붙임(105-1 도면 → 면적) — 붙여넣기와 같은 길(병합 · 되돌리기 한 번) ·
* `check` = 덮을 자리의 값 칸 · 알약 칸 수 · 범위 → false 면 안 붙임 · 붙인 범위 A1 · 읽기만 · 못 하면 null */
pasteTable(html: string, check?: PasteCheck): Promise<string | null>;
/** 보이는 칸만 다시 그림(`칠` 결과가 바뀐 뒤) — 문서는 안 읽음 */
repaint(): void;
/** 범위 고름 · 보이게 굴림(329-2 엑셀 보기 창) — `시트` = id 나 이름(없으면 지금 시트 · 다르면 그 탭으로) ·
* 범위 여럿 = 여러 고름 · 시트가 없거나 주소가 틀리면 false(고름 그대로) */
select(범위: string | string[], 시트?: string): boolean;
@@ -138,6 +143,7 @@ export function createSpreadsheet(
root,
book,
readOnly,
fill: opts.칠,
engine: createCalcEngine(book),
history: createHistory(),
selection: selectCell(firstSheet(book), 0, 0),
@@ -369,6 +375,7 @@ export function createSpreadsheet(
return 이름;
},
pasteTable: (html, check) => pasteTableAt(ctx, html, check),
repaint: () => grid.render(),
select(range, sheetKey) {
const target = sheetKey
? ctx.book.시트.find((s) => s.id === sheetKey || s.이름 === sheetKey)
@@ -276,3 +276,40 @@ html[data-touch="on"] .aislo-grid__fill-handle {
touch-action: pan-x pan-y;
}
}
/* 칸 칠(옵션 `칠` · 요구 621-5) — 칸 원래 채움(인라인 background-color) 위에 겹침 · 어두움은 --color-* 가 따라감. */
.aislo-cell[data-tone~="success"] {
background-image: linear-gradient(
color-mix(in srgb, var(--color-success) 18%, transparent),
color-mix(in srgb, var(--color-success) 18%, transparent)
);
}
.aislo-cell[data-tone~="warning"] {
background-image: linear-gradient(
color-mix(in srgb, var(--color-warning) 18%, transparent),
color-mix(in srgb, var(--color-warning) 18%, transparent)
);
}
.aislo-cell[data-tone~="info"] {
background-image: linear-gradient(
color-mix(in srgb, var(--color-info) 18%, transparent),
color-mix(in srgb, var(--color-info) 18%, transparent)
);
}
.aislo-cell[data-tone~="danger"] {
background-image: linear-gradient(
color-mix(in srgb, var(--color-danger) 18%, transparent),
color-mix(in srgb, var(--color-danger) 18%, transparent)
);
}
.aislo-cell[data-tone~="muted"] {
color: var(--color-text-muted) !important;
}
.aislo-cell[data-tone~="mine"] {
box-shadow: inset 3px 0 0 var(--color-accent);
}
/* 읽기만 — 시트 탭 「+」 숨김 */
.ss--readonly .ss-tabs__add {
display: none;
}
@@ -319,6 +319,9 @@ export function renderCells(
if (need > w * 0.9) longs.push({ r: r0, c: c0, el: cellEl, own: w, need, align });
}
}
const tone = ctx.fill?.(sheet.id, r0, c0);
if (tone) cellEl.dataset.tone = tone;
else delete cellEl.dataset.tone; // 재사용 div — 앞 칸 꼴을 지움
cellEl.classList.toggle("is-error", display.isError);
cellEl.classList.toggle("has-comment", !!comment);
cellEl.classList.toggle(
@@ -94,6 +94,8 @@ export interface SpreadsheetContext {
/** 지금 문서 — 명령만이 바꿈(직접 고치지 말 것) */
book: Workbook;
readOnly: boolean;
/** 칸 칠(옵션 `칠`) — 시트 id · 행 · 열 → 꼴 이름(공백으로 여럿 · 없으면 칠 없음) · 보이는 칸만 부름 */
fill?: (시트: string, r: number, c: number) => string | null | undefined;
engine: CalcEngine;
history: History;
selection: Selection;
+45 -110
View File
@@ -5,7 +5,7 @@
몸이 비면 = 모두 고정(705-3 · [모두 고정으로 들여오기]).
서버 재검사(브라우저 값을 받아 적지 않음 · 금액 · 품 칸 없음) — 차례 = 그 호표 줄 · 대상 하나 =
돈 로직 · M01 자재품목 · 확인된 호표 중 하나 · 입력 이름 = 그 로직 입력 · 배 0 넘음 · 몫 (0, 1] ·
호표 = 만들 층에 확인된 사전조합(없으면 409 「제N호표 먼저 확인」).
호표 = 만들 층에 확인된 사전조합(확인 안 된 하위 = 저장 때 하위 먼저 함께 만듦 · `M02_Hopyo_Kids.first` · 777-1).
호표 하나 = 사전조합 하나 — 이름 = 품명 + 규격 · 단위 = 엑셀 단위 · 구분 「실무자료」 ·
상세구분 = 파일 · 본뜬 = {층 「실무자료」, 키 「HP-###### 제N호표」, 파일, 원본 [층, 주인],
확인 {누가, 때}} · 만드는 층 = 부르는 층(일위대가 문서 층).
@@ -18,7 +18,8 @@
「전국」 자재(361-1 · 키 · 단가 없음 · 서버가 줄에서 다시 셈) = 일위대가 자재 칸이 M01 키만
받아 `본뜬.전국자재 [{이름, 규격, 단위, 수량, 세 칸}]` 에 적고 빠진 줄에 까닭(금액 없음 ·
지역은 프로젝트에서) · 장부엔 안 남김.
같은 본뜬 사전조합이 있으면 확인한 내용으로 덮음(`다시씀`) · 확인한 줄은 별칭 장부에 남음
같은 본뜬 사전조합이 있으면 확인한 내용으로 덮음(`다시씀`) · 없으면 같은 내용 SC 재사용(`재사용` · `M02_Hopyo_Kids.save`) ·
`하위` = 부르는 하위 호표 `[{번, 키, 새로}]`(부모 줄 = 하위 사본) · 확인한 줄은 별칭 장부에 남음
(`M02_Hopyo_Ledger` · 호표 참조 줄 뺌).
빠짐 0(705-3) — 확인 안 한 줄 · 노임 · % · 「우리에 없음」 줄 = 고정 줄(`M02_Hopyo_Fixed` · 엑셀 숫자
그대로) · 확인 안 한 호표 줄 = 확인된 하위 사전조합 참조(단위 같음 · 아니면 고정) · 빠진 줄 = 「안 씀」 ·
@@ -31,13 +32,12 @@
`{비율: 기준}` = 고정 줄 + 그 이음 · 남은 줄(`Fixed.linked` 아님 · 「우리에 없음」 고른 줄 뺌) 있으면 409
`{까닭, 남은줄}`(저장 안 함 · `save` 거짓 = 막지 않고 `남은줄` 만) · 미리 보기 = `unlinked`(쓰기 없음).
수량(742-1 · 요구 584-1) = 엑셀 수량을 줄 · 자재 저장 수량(`{손}`)으로 · 같은 값 `본뜬.실무수량 {줄: {차례: 수},
자재: {코드: 수}}`(출처 기록 · 엑셀 대조 `compare` — 하위 호표 것까지 `excel_given`).
자재: {코드: 수}}`(출처 기록 · 엑셀 대조 `M02_Hopyo_Compare` — 하위 호표 것까지 `excel_given`).
"""
from __future__ import annotations
import json
from decimal import ROUND_FLOOR, ROUND_HALF_UP, Decimal
from decimal import Decimal
from pathlib import Path
from typing import Any
@@ -45,6 +45,7 @@ from common_util.common_util_time import utc_now_text
from M01_MasterData import M01_MasterData_Store as m01
from M02_MasterTemplete import M02_Hopyo_Finger as finger
from M02_MasterTemplete import M02_Hopyo_Fixed as hfix
from M02_MasterTemplete import M02_Hopyo_Kids as hkids
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
@@ -52,7 +53,12 @@ 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
from M02_MasterTemplete import M02_Library_Same as lsame
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from M02_MasterTemplete.M02_Hopyo_Compare import ( # noqa: F401 — 부르는 쪽 이름 그대로
compare,
excel_given,
)
ORIGIN = "실무자료"
LINES_MAX = 500
@@ -69,23 +75,8 @@ def mark(key: str, no: int, at: int | None = None) -> str:
return head if at is None else f"{head} {at + 1}째 줄"
def _origins(folder: Path):
"""층 사전조합 가운데 실무자료 본뜬 → (키, 본뜬, 판)."""
for path in sorted((Path(folder) / "precombo").glob("SC-*.json")):
try:
raw = path.read_bytes()
doc = json.loads(raw)
except (OSError, ValueError):
continue
got = doc.get("본뜬") if isinstance(doc, dict) else None
if isinstance(got, dict) and got.get("층") == ORIGIN:
yield path.stem, got, raw
def _same_source(got: dict[str, Any], source: refs.Here | None) -> bool:
"""본뜬 원본 층이 같음 — 원본 없는 옛 것 · `source` None 은 가리지 않음."""
have = got.get("원본")
return source is None or not have or tuple(have) == tuple(source)
_origins = hkids.entries # B09 가 빌려 씀(또 칸까지)
_same_source = hkids.same_source
def confirmed(folder: str | Path, source: refs.Here | None = None) -> dict[str, str]:
@@ -97,31 +88,6 @@ def confirmed(folder: str | Path, source: refs.Here | None = None) -> dict[str,
}
def _save(doc: dict[str, Any]) -> tuple[dict[str, Any], bool]:
"""(참조, 다시씀) — 같은 본뜬(키 · 원본)이 있으면 그 키에 덮음 · 없으면 새 키 · 엑셀 수량 = 저장(742-1)."""
hands = {
"줄": {str(i): x["수량"]["손"] for i, x in enumerate(doc["담은로직"]) if x.get("수량")},
"자재": {m["코드"]: m["수량"]["손"] for m in doc.get("자재") or [] if m.get("수량")},
}
doc = {**doc, "본뜬": {**doc["본뜬"], "실무수량": hands}}
at = refs.here()
want = doc["본뜬"]
have = next(
(
(key, store.version_of(raw))
for key, got, raw in _origins(store.root())
if got.get("키") == want["키"] and _same_source(got, tuple(want["원본"]))
),
None,
)
if have:
store.write("precombo", have[0], have[1], doc)
key = have[0]
else:
key = refs.create("precombo", doc)["이름"]
return {"층": at[0], "주인": at[1], "키": key}, bool(have)
def _num(value: Decimal) -> int | float:
value = value.normalize()
return int(value) if value == value.to_integral_value() else float(value)
@@ -163,10 +129,12 @@ class Builder:
self.done = confirmed(folder, self.source)
self.known = ledger._known()
self.mat_names = {x["자재"]: x["이름"] for x in hopyo_match.targets()["자재"]}
self.units = {int(h["번"]) for h in self.doc.get("호표") or []}
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.same, self.reused = lsame.Same(), False # 같은 내용 SC 찾기(한 요청 메모)
self.strict = True # 남은 줄 있으면 409(붙여넣기 `build_all` = 거짓)
self.doc_id = ledger.doc_id(source, key) # 739-1 이 불러오기에서 이은 짝
@@ -185,8 +153,10 @@ class Builder:
child = int(raw["호표"])
if child == self.no:
raise _bad(f"호표가 자기를 담음 「{where}」")
if mark(self.key, child) not in self.done:
raise _bad(f"제{child}호표 먼저 확인", 409)
if mark(self.key, child) not in self.done and not self.dry:
raise _bad(
f"제{child}호표 먼저 확인", 409
) # 저장 길은 하위 먼저 만든 뒤(`hkids.first`)
times, part = ledger.times_of(raw, where)
return {"호표": child, "배": times, "몫": part}
@@ -311,7 +281,7 @@ class Builder:
ref = {
"층": refs.here()[0],
"주인": refs.here()[1],
"키": self.done[mark(self.key, one["호표"])],
"키": self.done.get(mark(self.key, one["호표"]), "SC-미리"),
}
lines.append({"참조": ref, "별칭": alias, "메모": memo, "수량": {"손": _num(qty)}})
return
@@ -336,7 +306,9 @@ class Builder:
"자재": [],
"본뜬": self._origin(f"{mark(self.key, self.no, at)} {one['로직']}", stamp),
}
ref = {"키": "SC-미리"} if self.dry else _save(wrap)[0] # 미리 보기 = 쓰기 없음
ref = (
{"키": "SC-미리"} if self.dry else hkids.save(wrap, self.same)[0]
) # 미리 보기 = 쓰기 없음
lines.append({"참조": ref, "별칭": alias, "메모": memo, "수량": {"손": _num(qty)}})
book: dict[str, dict[str, Any]] = {}
@@ -395,13 +367,15 @@ class Builder:
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))
sub = [{"번": n, "키": self.done.get(mark(self.key, n))} for n in sorted(kids)]
if not save:
ledger.write(self.folder, book, self.who, file or None, self.doc_id)
head = {"문서": doc, "빠진줄": missing, "남은줄": left}
head = {"문서": doc, "빠진줄": missing, "남은줄": left, "하위": sub}
return {**head, "줄수": {**count, "이음후보": linker.near}}
ref, again = _save(doc)
ref, again, self.reused = hkids.save(doc, self.same)
wrote = ledger.write(self.folder, book, self.who, file or None, self.doc_id)
out = {"참조": ref, "다시씀": again, "빠진줄": missing, "장부": {"씀": wrote}}
out = {"참조": ref, "다시씀": again, "재사용": self.reused, "빠진줄": missing, "하위": sub}
out["장부"] = {"씀": wrote}
return {**out, "줄수": {**count, "이음후보": linker.near}}
def unlinked(self, checked: dict[int, dict[str, Any]], whole: list[dict[str, Any]]) -> list:
@@ -418,7 +392,9 @@ class Builder:
if finger.kind_of(line) != "호표" and ref.get("종류") != "일위":
return None
no = int(ref.get("번") or 0)
made = self.done.get(mark(self.key, no))
made = self.done.get(mark(self.key, no)) or (
"SC-미리" if self.dry and no in self.units else None
)
unit = next((h.get("단위") for h in self.doc["호표"] if int(h["번"]) == no), None)
times = hopyo_match.factor(line.get("단위"), unit, line.get("규격"))["배"]
if not made or no in kids or no == self.no or times != 1:
@@ -457,59 +433,6 @@ class Builder:
}
# 엑셀 끝수 꼴(705-3) — (줄 금액 0.1 · 합 원 자름, 계 = 줄 계 합에서) · EST = 비목 합 내림 ·
# STmate = 줄 반올림 · 계 = 줄 계 합 내림(비목 머리 값과 따로)
CUTS = ((ROUND_FLOOR, False), (ROUND_HALF_UP, True), (ROUND_HALF_UP, False))
def _excel_round(got: dict[str, Any], how: str, by_line: bool) -> Decimal | None:
"""엑셀 끝수(705-3) — 줄 금액 비목마다 0.1(`how`) · 하위 호표 단가 = 원 · 비율 줄 = 기준 × 율 ÷ 100 ·
합 = 비목마다 원(`how`) 더함 · `by_line` = 줄 계 합 원 내림 · 못 셈 줄 있으면 None."""
parts = dict.fromkeys(labor.COSTS, Decimal(0))
for one in got.get("안") or []:
if one.get("기준") is None:
return None
cut = 100 if (one.get("고정") or {}).get("비율") else 1
for k in labor.COSTS:
unit = Decimal(one["기준"][k])
unit = unit.to_integral_value(how) if "참조" in one else unit
parts[k] += (unit * one["수량"] / cut).quantize(Decimal("0.1"), how)
if by_line:
return sum(parts.values()).to_integral_value(ROUND_FLOOR)
return sum(v.to_integral_value(how) for v in parts.values())
def excel_given(ref: dict[str, Any], depth: int = 1) -> dict[str, Any]:
"""`본뜬.실무수량` → 안 줄 알약 값(723-1 · 하위 사전조합까지) — 엑셀 대조 전용."""
doc = refs.read_ref(ref) if depth <= refs.DEPTH else None
if not isinstance(doc, dict):
return {}
rid, hands = refs.ref_id(ref), (doc.get("본뜬") or {}).get("실무수량") or {}
out = {f"{rid}#{i}": v for i, v in (hands.get("줄") or {}).items()}
out |= {f"{rid}#자재|{code}": v for code, v in (hands.get("자재") or {}).items()}
for one in refs.refs_in(doc):
out |= excel_given(one, depth + 1)
return out
def compare(ref: dict[str, Any], hopyo: dict[str, Any]) -> dict[str, Any]:
"""만든 사전조합 1 단위 값 ↔ 엑셀 호표 단가(참고 · 판정 아님) — 우리 = 엑셀 끝수 규칙(내림 ·
반올림 중 엑셀과 같은 쪽 · `_excel_round`) · 수량 = 엑셀 수량(`excel_given` · 723-1)."""
head = {k: ref[k] for k in ("층", "주인", "키")}
got = refs.Expand(m01.loaded()[1], given=excel_given(head))(head)
base = got.get("기준") or {}
excel = (hopyo.get("금액") or {}).get("계")
cuts = [_excel_round(got, *one) for one in CUTS] if base else []
cuts = [c for c in cuts if c is not None]
cut = next(
(c for c in cuts if abs(c - Decimal(str(excel or 0))) < 1), cuts[0] if cuts else None
)
ours = float(cut if cut is not None else base["계"]) if "계" in base else None
gap = round(ours - excel, 1) if ours is not None and excel is not None else None
same = ours is not None and abs(ours - (excel or 0)) < 1 # 705-3 — 엑셀 금액 없음 = 0
return {"엑셀": excel, "우리": ours, "차이": gap, "같음": same, "까닭": got.get("까닭")}
def unlinked(
folder: Path,
source: Path,
@@ -520,6 +443,7 @@ def unlinked(
) -> dict[str, Any]:
"""안 이은 줄 미리 보기(729-3) — 몸 = 만들기 몸 → `{남은줄, 수}`(저장 없음)."""
maker = Builder(folder, source, key, no, None)
maker.dry = True # 하위 확인 전 호표 대상 = 저장 때 함께 만듦(409 아님)
checked, pairs = maker.check(list(lines or []), whole)
left = maker.unlinked(checked, pairs)
return {"남은줄": left, "수": len(left)}
@@ -535,10 +459,16 @@ def build(
who: Any = None,
) -> dict[str, Any]:
"""실무 호표(`source` 층 파일) + 확인한 줄 → 지금 층(`folder`) 사전조합 ·
별칭 장부 — `{참조, 이름, 다시씀, 빠진줄, 비교, 장부}`(부르는 쪽이 `store.at(folder)`)."""
별칭 장부 — `{참조, 이름, 다시씀, 재사용, 빠진줄, 하위, 비교, 장부}`(부르는 쪽이 `store.at(folder)`) ·
확인 안 된 하위 호표 = 먼저 서버 체크 제안대로 함께 만듦(777-1 · 실패 = 409 「하위 제N호표 실패」)."""
kids = (
hkids.first(folder, source, key, no, who, lines) if whole is None else []
) # 통째 = 하위 줄 안 씀
new = {x["번"] for x in kids}
maker = Builder(folder, source, key, no, who)
checked, pairs = maker.check(list(lines or []), whole)
made = maker.build(checked, pairs)
made["하위"] = [{**x, "새로": x["번"] in new} for x in made["하위"]]
return {**made, "이름": maker.hopyo.get("이름"), "비교": compare(made["참조"], maker.hopyo)}
@@ -581,12 +511,16 @@ def build_all(
"실패": [],
"만듦": [],
}
shared, failed = lsame.Same(), set()
for no in _order(units):
out["줄"] += len(units[no].get("줄") or [])
bad = next((k for k in hkids.kids_of(units[no]) if k in failed), None)
try:
maker = Builder(folder, source, key, no, who)
maker.strict = strict
maker.strict, maker.same = strict, shared
have = maker.done.get(mark(key, no))
if bad is not None and not have: # 777-1 — 하위 실패 연쇄 까닭 따로
raise store.StoreError(409, {"까닭": f"하위 제{bad}호표 실패", "하위": bad})
if have:
out["그대로"] += 1
ref = {"층": refs.here()[0], "주인": refs.here()[1], "키": have}
@@ -600,6 +534,7 @@ def build_all(
except store.StoreError as e:
why = e.detail if isinstance(e.detail, dict) else {"까닭": str(e.detail)}
out["실패"].append({"번": no, **why})
failed.add(no)
continue
if got["같음"]:
out["같음"] += 1
+66
View File
@@ -0,0 +1,66 @@
"""M02 실무 호표 만든 일위대가 ↔ 엑셀 호표 단가 대조(PLAN 705-3 · 723-1 · 742-1 · 777-1 `M02_Hopyo_Build` 에서 떼어 냄).
참고 · 판정 아님 — 우리 = 엑셀 끝수 꼴(EST 비목 합 내림 · STmate 줄 반올림 · 계 = 줄 계 합 내림) 중 엑셀과 같은 쪽 ·
수량 = 엑셀 수량(`본뜬.실무수량` · 하위 사전조합까지 `excel_given`).
"""
from __future__ import annotations
from decimal import ROUND_FLOOR, ROUND_HALF_UP, Decimal
from typing import Any
from M01_MasterData import M01_MasterData_Store as m01
from M02_MasterTemplete import M02_Labor_Doc as labor
from M02_MasterTemplete import M02_Labor_Ref as refs
# 엑셀 끝수 꼴(705-3) — (줄 금액 0.1 · 합 원 자름, 계 = 줄 계 합에서) · EST = 비목 합 내림 ·
# STmate = 줄 반올림 · 계 = 줄 계 합 내림(비목 머리 값과 따로)
CUTS = ((ROUND_FLOOR, False), (ROUND_HALF_UP, True), (ROUND_HALF_UP, False))
def _excel_round(got: dict[str, Any], how: str, by_line: bool) -> Decimal | None:
"""엑셀 끝수(705-3) — 줄 금액 비목마다 0.1(`how`) · 하위 호표 단가 = 원 · 비율 줄 = 기준 × 율 ÷ 100 ·
합 = 비목마다 원(`how`) 더함 · `by_line` = 줄 계 합 원 내림 · 못 셈 줄 있으면 None."""
parts = dict.fromkeys(labor.COSTS, Decimal(0))
for one in got.get("안") or []:
if one.get("기준") is None:
return None
cut = 100 if (one.get("고정") or {}).get("비율") else 1
for k in labor.COSTS:
unit = Decimal(one["기준"][k])
unit = unit.to_integral_value(how) if "참조" in one else unit
parts[k] += (unit * one["수량"] / cut).quantize(Decimal("0.1"), how)
if by_line:
return sum(parts.values()).to_integral_value(ROUND_FLOOR)
return sum(v.to_integral_value(how) for v in parts.values())
def excel_given(ref: dict[str, Any], depth: int = 1) -> dict[str, Any]:
"""`본뜬.실무수량` → 안 줄 알약 값(723-1 · 하위 사전조합까지) — 엑셀 대조 전용."""
doc = refs.read_ref(ref) if depth <= refs.DEPTH else None
if not isinstance(doc, dict):
return {}
rid, hands = refs.ref_id(ref), (doc.get("본뜬") or {}).get("실무수량") or {}
out = {f"{rid}#{i}": v for i, v in (hands.get("줄") or {}).items()}
out |= {f"{rid}#자재|{code}": v for code, v in (hands.get("자재") or {}).items()}
for one in refs.refs_in(doc):
out |= excel_given(one, depth + 1)
return out
def compare(ref: dict[str, Any], hopyo: dict[str, Any]) -> dict[str, Any]:
"""만든 사전조합 1 단위 값 ↔ 엑셀 호표 단가(참고 · 판정 아님) — 우리 = 엑셀 끝수 규칙(내림 ·
반올림 중 엑셀과 같은 쪽 · `_excel_round`) · 수량 = 엑셀 수량(`excel_given` · 723-1)."""
head = {k: ref[k] for k in ("층", "주인", "키")}
got = refs.Expand(m01.loaded()[1], given=excel_given(head))(head)
base = got.get("기준") or {}
excel = (hopyo.get("금액") or {}).get("계")
cuts = [_excel_round(got, *one) for one in CUTS] if base else []
cuts = [c for c in cuts if c is not None]
cut = next(
(c for c in cuts if abs(c - Decimal(str(excel or 0))) < 1), cuts[0] if cuts else None
)
ours = float(cut if cut is not None else base["계"]) if "계" in base else None
gap = round(ours - excel, 1) if ours is not None and excel is not None else None
same = ours is not None and abs(ours - (excel or 0)) < 1 # 705-3 — 엑셀 금액 없음 = 0
return {"엑셀": excel, "우리": ours, "차이": gap, "같음": same, "까닭": got.get("까닭")}
+6 -6
View File
@@ -80,9 +80,9 @@ def _out(value: Decimal | None) -> int | float | None:
# ── 파일 → 시트 표(줄마다 값 목록) ────────────────────────────────────────
def _sheets(name: str, data: bytes) -> dict[str, list[list[Any]]]:
"""시트 이름 → 줄 목록 — 일위대가 · 총괄표 · 단가산출만 읽음."""
wanted = (SHEET, SUMMARY, hopyo_basis.SHEET)
def _sheets(name: str, data: bytes, wanted: tuple[str, ...] = ()) -> dict[str, list[list[Any]]]:
"""시트 이름 → 줄 목록 — 일위대가 · 총괄표 · 단가산출만 읽음(`wanted` = 더 읽을 시트)."""
keep = (SHEET, SUMMARY, hopyo_basis.SHEET, *wanted)
if not data:
raise ExcelError("빈 파일")
if b"DRMONE" in data[:256] and data[:4] not in (b"PK\x03\x04", _OLE):
@@ -93,11 +93,11 @@ def _sheets(name: str, data: bytes) -> dict[str, list[list[Any]]]:
book = openpyxl.load_workbook(io.BytesIO(data), read_only=True, data_only=True)
if hopyo_stmate.is_stmate([t.strip() for t in book.sheetnames]):
wanted = hopyo_stmate.SHEETS
keep = (*hopyo_stmate.SHEETS, *wanted)
out = {
ws.title.strip(): [list(row) for row in ws.iter_rows(values_only=True)]
for ws in book.worksheets
if ws.title.strip() in wanted
if ws.title.strip() in keep
}
book.close()
return out
@@ -107,7 +107,7 @@ def _sheets(name: str, data: bytes) -> dict[str, list[list[Any]]]:
return {
sh.name.strip(): [sh.row_values(r) for r in range(sh.nrows)]
for sh in book.sheets()
if sh.name.strip() in wanted
if sh.name.strip() in keep
}
except ExcelError:
raise
+6 -2
View File
@@ -5,7 +5,7 @@
HP · 격자 · 맞춤셈 캐시 지움 · 그 HP 로 만든 이 층 일위대가(SC) · 단가산출(SK) 엑셀 사본 걷음
(`본뜬` → 출처 `{층 「실무자료」, 파일, 때, 누가}` + 전국자재 · 복사원본 ·
SC 줄 메모 「실무 …」 걷음 · `본뜬.실무수량` = 모든 값이 그 줄 저장 `수량.손` 과 같을 때만
걷음(742-1 · 다르면 남김 = 금액 그대로)) · SK `본뜬.또` = 그 HP 몫만 뺌 ·
걷음(742-1 · 다르면 남김 = 금액 그대로)) · SK · SC `본뜬.또` = 그 HP 몫만 뺌 ·
문서 몸(줄 · 덮개 · 더한줄 · 별칭 · 구분)과 별칭 장부는 그대로 ·
끝낸 기록 없음(같은 엑셀 다시 올림 됨 · 키 번은 `불러옴.json` 번으로 다시 안 씀).
옛 파일 정리 `tidy`(한 번 · 층 폴더마다 · 미리 보기 = 표만) — 옛 HP(지문 없음) · 하루 지난 HP 끝냄 ·
@@ -113,7 +113,11 @@ def forget(folder: str | Path, gone: Callable[[str], bool], apply: bool = True)
got = doc["본뜬"]
if not any(hit(x) for x in [got, *(got.get("또") or [])] if isinstance(x, dict)):
continue
new = strip_sc(doc) if kind == "precombo" else strip_sk(doc, hit)
new = strip_sk(doc, hit) if kind == "sangeun" or not hit(got) else strip_sc(doc)
if kind == "precombo" and (
also := [x for x in got.get("또") or [] if isinstance(x, dict) and not hit(x)]
):
new["본뜬"]["또"] = also # 777-1 같은 내용 SC 를 나눠 쓴 다른 불러오기 몫은 그대로
if new != doc:
out.append(path.stem)
if apply:
+86 -7
View File
@@ -8,12 +8,18 @@ xls = xlrd `formatting_info` — 값 · 병합 · 열 폭(÷256 글자) ·
숫자 형식) · 틀고정(파일 값 · 없으면 머리 4행).
xlsx = 값 · 열 폭(`<cols>`) · 넓은 시트는 머리 글(앞 4 줄) 마지막 열까지 ·
STmate 숨은 칸 글(「_x0005_`BDCOD…」) 뺌.
결과 `{책, 호표: [{번, 이름, 행}], 단산: [{번, 이름, 행}]}` — 행 = 엑셀 1부터 [시작, 끝].
결과 `{책, 호표: [{번, 이름, 행}], 단산: [{번, 이름, 행}], 보기, 원본, 목록, 줄}` —
행 = 엑셀 1부터 [시작, 끝].
세 보기(779-1) = 탭 차례 일위대가표 · 단가산출표(목록표 — 엑셀꼴 단가산출총괄표 · STmate
단가산출근거목록표) · 단가산출상세 · `보기 {역할: 시트 id}` · `원본 {역할: 원본 시트 이름}` ·
`목록 {호표: [{번, 행}], 단산: [{번, 행}]}`(목록 시트의 행 1부터) ·
`줄 [{번, 차례, 행, 단산?: {번, 행: [시작, 끝]}, 일위?: {번, 행: [시작, 끝]}}]`.
"""
from __future__ import annotations
import io
import re
import zipfile
from typing import Any
@@ -24,7 +30,23 @@ from M02_MasterTemplete import M02_Hopyo_Basis as hopyo_basis
from M02_MasterTemplete import M02_Hopyo_Excel as hopyo_excel
from M02_MasterTemplete import M02_Hopyo_Stmate as hopyo_stmate
SHEETS = (hopyo_excel.SHEET, hopyo_basis.SHEET, hopyo_stmate.SHEET, hopyo_stmate.CALC)
LIST_E, LIST_S, HOPYO_S = "단가산출총괄표", hopyo_stmate.LISTS[0], "일위대가목록표"
SHEETS = (
hopyo_excel.SHEET,
LIST_E,
hopyo_basis.SHEET,
hopyo_stmate.SHEET,
LIST_S,
hopyo_stmate.CALC,
)
ROLES = {
"일위대가표": (hopyo_excel.SHEET, hopyo_stmate.SHEET),
"단가산출표": (LIST_E, LIST_S),
"단가산출상세": (hopyo_basis.SHEET, hopyo_stmate.CALC),
}
_B, _D, _NO = re.compile(r"(B\d{5}|제\d+호표)"), re.compile(r"(D\d{5})"), re.compile(r"(\d+)")
_HOPYO = hopyo_excel._HOPYO
EXTRA = (LIST_E, hopyo_excel.SUMMARY, HOPYO_S) # 격자 몫으로 더 읽는 시트
HEAD_ROWS = 4
_HALIGN = {1: "left", 2: "center", 3: "right", 6: "centerContinuous", 7: "distributed"}
_VALIGN = {0: "top", 1: "center"}
@@ -213,9 +235,11 @@ def _xlsx_widths(data: bytes) -> dict[str, dict[str, dict[str, Any]]]:
return out
def _xlsx(name: str, data: bytes) -> dict[str, Any]:
def _xlsx(
name: str, data: bytes, found: dict[str, list[list[Any]]] | None = None
) -> dict[str, Any]:
# ponytail: xlsx 는 값 · 열 폭만(병합 · 서식은 openpyxl 비 read_only 로 무거움) — 필요하면 그때
found = hopyo_excel._sheets(name, data)
found = found or hopyo_excel._sheets(name, data, EXTRA)
widths = _xlsx_widths(data)
sheets = []
for at, title in enumerate((n for n in SHEETS if n in found), 1):
@@ -244,14 +268,69 @@ def _xlsx(name: str, data: bytes) -> dict[str, Any]:
return {"서식": [{}], "시트": sheets}
def _pairs(
rows: list[list[Any]] | None, col: int, rx: re.Pattern[str], hidden: bool = False
) -> list[tuple[str, int]]:
"""목록 시트 → [(열쇠, 행)] — `col` 칸 글이 `rx` 에 꼭 맞는 줄(엑셀 1부터) · `hidden` =
STmate 숨은 칸 「COD|…」 코드 먼저(목록 차례 번 ≠ 진짜 번)."""
out = []
for at, row in enumerate(rows or [], 1):
meta = hopyo_stmate._META.search(" ".join(map(str, row))) if hidden else None
cell = hopyo_excel._flat(hopyo_excel._text(row[col] if col < len(row) else None))
if meta:
out.append((meta.group(1), at))
elif hit := rx.fullmatch(cell):
out.append((hit.group(1), at))
return out
def _lines(parsed: dict[str, Any]) -> list[dict[str, Any]]:
"""일위대가 줄 → 엑셀 행 · 단산 줄 → 블록 행 · 일위 줄 → 하위 호표 행."""
block = {b["번"]: b["행"] for b in parsed.get("단산") or []}
hopyo = {h["번"]: h["행"] for h in parsed.get("호표") or []}
out = []
for h in parsed.get("호표") or []:
for line in h.get("줄") or []:
one: dict[str, Any] = {"번": h["번"], "차례": line["차례"], "행": line.get("행")}
ref = line.get("참조") or {}
for key, rows in (("단산", block), ("일위", hopyo)):
if ref.get("종류") == key and ref.get("번") in rows:
one[key] = {"번": ref["번"], "행": rows[ref["번"]]}
out.append(one)
return out
def _lists(rows: dict[str, Any], parsed: dict[str, Any]) -> dict[str, list[dict[str, int]]]:
"""목록 시트 행 ↔ 호표 번 · 단산(블록) 번."""
if parsed.get("꼴") == "STmate": # 숨은 코드(없으면 A 열) — 호표 = 코드 → 번 · 단산 = 코드 숫자
code = {h["코드"]: h["번"] for h in parsed.get("호표") or [] if h.get("코드")}
hopyo = [
(int(k[1:-2]) if k[0] == "제" else code.get(k), at)
for k, at in _pairs(rows.get(HOPYO_S), 0, _B, True)
]
calc = [(int(k[1:]), at) for k, at in _pairs(rows.get(LIST_S), 0, _D, True)]
else: # B 열 「제N호표」 · 「No」
hopyo = [(int(k), at) for k, at in _pairs(rows.get(hopyo_excel.SUMMARY), 1, _HOPYO)]
calc = [(int(k), at) for k, at in _pairs(rows.get(LIST_E), 1, _NO)]
pair = lambda got: [{"번": k, "행": at} for k, at in got if k is not None] # noqa: E731
return {"호표": pair(hopyo), "단산": pair(calc)}
def read(name: str, data: bytes, parsed: dict[str, Any]) -> dict[str, Any]:
"""엑셀 → `{책: Workbook, 호표, 단산}` — `parsed` = `M02_Hopyo_Excel.read_book` 결과
(행 범위)."""
"""엑셀 → `{책: 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)
rows = hopyo_excel._sheets(name, data, EXTRA) # ponytail: xls 는 서식 없이 한 번 더 읽음
book = _xlsx(name, data, rows) if xlsx else _xls(data)
keep = ("번", "이름", "행")
ids = {s["이름"]: s["id"] for s in book["시트"]}
on = {role: next((n for n in names if n in ids), None) for role, names in ROLES.items()}
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 []],
"보기": {role: ids[n] for role, n in on.items() if n},
"원본": {role: n for role, n in on.items() if n},
"목록": _lists(rows, parsed),
"줄": _lines(parsed),
}
+6
View File
@@ -3,6 +3,7 @@
[구조물 조합으로] = 확인 창 몸(체크한 줄 · 통째 · `M02_Hopyo_Build.check` 그대로)으로 줄을 만들어(`build(save=False)` ·
사전조합 SC 문서 안 씀 · 옛 [사전조합 생성] 길은 그대로) 이 층 구조물집계표에 새 항목(SU · `M02_Item_Personal.create_item`
· 경로 「실무자료」) + 그 일위대가 문서에 줄을 담음(수량 = 1 단위 몫 `{손}`).
확인 안 된 하위 호표 = 먼저 일위대가로 함께 만듦(777-1 `M02_Hopyo_Kids.first` · 줄 = 하위 사본 · 못 만들면 고정 줄).
규격 묶음 = 같은 파일에서 이름 · 단위 같고 줄 구성(줄 이름 · 단위 차례) 같은 · 규격만 다른 호표(5-A 「구성 같은 묶음」) →
한 항목의 규격 열(704-4) — 열 이름 = 규격 글에서 달라지는 칸(「H=1.5」 → 이름 H · 열 1.5) · 아니면 이름 「규격」 ·
열마다 같은 몸으로 만든 줄 수량(줄 차례 같음 · 다르면 400) · 기본 열 = 연 호표.
@@ -15,6 +16,7 @@ from pathlib import Path
from typing import Any
from M02_MasterTemplete import M02_Hopyo_Build as hb
from M02_MasterTemplete import M02_Hopyo_Kids as hkids
from M02_MasterTemplete import M02_Hopyo_Store as hopyo_store
from M02_MasterTemplete import M02_Item_Personal as personal
from M02_MasterTemplete import M02_Labor_Spec as spec
@@ -82,6 +84,10 @@ def siblings(source: Path, key: str, no: int) -> dict[str, Any]:
def _doc(folder: Path, source: Path, key: str, no: int, lines: list, whole: Any, who: Any) -> dict:
try: # 777-1 — 하위 호표 먼저(엑셀 단가 한 줄로 납작 안 함) · 못 만들면 지금처럼 고정 줄
hkids.first(folder, source, key, no, who, lines) if whole is None else None
except store.StoreError:
pass
maker = hb.Builder(folder, source, key, no, who)
checked, pairs = maker.check(list(lines or []), whole)
return {**maker.build(checked, pairs, save=False), "호표": maker.hopyo}
+247
View File
@@ -0,0 +1,247 @@
"""M02 실무 호표가 다른 호표를 부를 때 = 일위대가 둘(PLAN 777-1 · 요구 621-4 · 요구 581 「SK · SC 서로 독립」).
하위 함께 만들기 `first` — 부모 저장(`M02_Hopyo_Build.build` · [구조물 조합으로])에서 확인 안 된 하위 호표(호표 줄 ·
호표 대상 · 깊이 끝까지)를 하위 먼저 그 하위의 서버 체크 제안 몸(`proposal`)으로 만듦(409 「먼저 확인」 대신) ·
부모 줄 = 하위 사본(740-1 그대로) · 하위 실패 = 409 `{까닭 「하위 제N호표 실패 — …」, 하위 N, 하위남은줄}`.
화면 캐시 고름은 화면이 하위를 먼저 보냄(`M02_MasterTemplete_Hopyo_Kids.ts`).
`proposal` 짝 = 화면 `bodyFor` · `wholeFor`(`M02_MasterTemplete_Hopyo_Kids.ts` · 캐시 빈 것) — 체크 제안 줄 · 대상 꼴 같음.
같은 내용 일위대가 재사용 `save` — 같은 본뜬(키 · 원본)이면 그 SC(혼자 쓰면 덮음 · 나눠 쓰면 내용 같을 때만) ·
아니면 이 층 같은 이름 · 같은 내용 SC(`M02_Library_Same.find_same` + 수량까지) 의 `본뜬.또` 에 출처를 더해 씀
(SK 꼴 `M02_Hopyo_Sangeun.Maker.write`) · 없으면 새로 → 다시 불러와도 SC 가 안 늚.
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
from M02_MasterTemplete import M02_Hopyo_Finger as finger
from M02_MasterTemplete import M02_Hopyo_Store as hopyo_store
from M02_MasterTemplete import M02_Labor_Ref as refs
from M02_MasterTemplete import M02_Library_Flat as lflat
from M02_MasterTemplete import M02_Library_Same as lsame
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from M02_MasterTemplete.M02_Hopyo_Sangeun import _same_mark
ORIGIN = "실무자료"
KIND = "precombo"
# ── 같은 내용 재사용 ─────────────────────────────────────────────────
def entries(folder: str | Path):
"""층 사전조합 실무자료 출처 → (키, 본뜬 · 또 한 칸, 판) — 또 = 같은 내용으로 이 SC 를 쓴 뒤 출처."""
for path in sorted((Path(folder) / KIND).glob("SC-*.json")):
try:
raw = path.read_bytes()
doc = json.loads(raw)
except (OSError, ValueError):
continue
got = doc.get("본뜬") if isinstance(doc, dict) else None
if isinstance(got, dict) and got.get("층") == ORIGIN:
for one in [got, *(x for x in got.get("또") or [] if isinstance(x, dict))]:
yield path.stem, one, raw
def same_source(got: dict[str, Any], source: refs.Here | None) -> bool:
"""본뜬 원본 층이 같음 — 원본 없는 옛 것 · `source` None 은 가리지 않음."""
have = got.get("원본")
return source is None or not have or tuple(have) == tuple(source)
def _bare(value: Any) -> Any:
"""사본 블록 `주인`(몸 지문 · 메모까지 셈) 뺌 — 셈 내용은 `몸` 이 다 가짐."""
if isinstance(value, list):
return [_bare(x) for x in value]
if not isinstance(value, dict):
return value
return {k: _bare(x) for k, x in value.items() if not (k == "주인" and "몸" in value)}
def _rows(doc: dict[str, Any], part: str) -> list[str]:
return sorted(json.dumps(lsame.norm(_bare(x)), ensure_ascii=False, sort_keys=True) for x in doc.get(part) or []) # fmt: skip
def body(doc: dict[str, Any]) -> list:
"""셈 내용 그대로(수량까지 · 하위 = 펼친 사본 몸) — `find_same` 지문은 SC 수량을 안 봄."""
flat = lflat.flat_doc(KIND, doc)
head = [lsame.unit(flat.get("단위")), lsame.norm(flat.get("규격"))]
return [*head, _rows(flat, "담은로직"), _rows(flat, "자재")]
def _entry(doc: dict[str, Any]) -> dict[str, Any]:
return {**doc["본뜬"], "이름": doc["이름"]}
def _split(old: dict[str, Any], mine: dict[str, Any]) -> tuple[dict, list]:
got = old.get("본뜬") or {}
own = {k: v for k, v in got.items() if k != "또"}
rest = [x for x in got.get("또") or [] if isinstance(x, dict) and not _same_mark(x, mine)]
return own, rest
def _put(key: str, raw: bytes, old: dict, own: dict, also: list, doc: dict | None = None) -> None:
new = {**(doc or old), "본뜬": {**own, "또": also} if also else own}
if new != old:
store.write(KIND, key, store.version_of(raw), new)
def _empty(root: Path, doc: dict[str, Any]) -> list[str]:
"""빈 호표(줄 · 자재 없음 = 지문 없음) — 이름 · 단위 같은 실무자료 SC."""
if doc.get("담은로직") or doc.get("자재"):
return []
out = []
for path in sorted((root / KIND).glob("SC-*.json")):
try:
got = json.loads(path.read_bytes())
except (OSError, ValueError):
continue
same = (got.get("이름"), lsame.unit(got.get("단위"))) == (doc["이름"], lsame.unit(doc.get("단위"))) # fmt: skip
if same and (got.get("본뜬") or {}).get("층") == ORIGIN:
out.append(path.stem)
return out
def save(doc: dict[str, Any], same: lsame.Same | None = None) -> tuple[dict[str, Any], bool, bool]:
"""(참조, 다시씀, 재사용) — 엑셀 수량 = 저장(742-1) · `same` = 한 요청 안 지문 메모."""
hands = {
"줄": {str(i): x["수량"]["손"] for i, x in enumerate(doc["담은로직"]) if x.get("수량")},
"자재": {m["코드"]: m["수량"]["손"] for m in doc.get("자재") or [] if m.get("수량")},
}
doc = {**doc, "본뜬": {**doc["본뜬"], "실무수량": hands}}
same, root, mine = same or lsame.Same(), store.root(), doc["본뜬"]
here = refs.here()
ref = lambda key: {"층": here[0], "주인": here[1], "키": key} # noqa: E731
have = next(
(
(key, raw)
for key, got, raw in entries(root)
if got.get("키") == mine["키"] and same_source(got, tuple(mine["원본"]))
),
None,
)
if have:
key, raw = have
old = json.loads(raw)
own, rest = _split(old, mine)
owner, alike = _same_mark(own, mine), body(old) == body(doc)
if owner and (not rest or alike): # 혼자 · 내용 같음 = 제자리 덮음(나눈 출처 그대로)
_put(key, raw, old, mine, rest, doc)
same.add(root, KIND, key, doc)
return ref(key), True, False
if alike: # 남의 SC 를 같은 내용으로 씀 — 제 출처 칸만
_put(key, raw, old, own, [*rest, _entry(doc)])
return ref(key), True, False
head = {k: v for k, v in rest[0].items() if k != "이름"} if owner else own
_put(key, raw, old, head, rest[1:] if owner else rest) # 내용 달라짐 = 떼어 냄
for key in same.find_same(root, KIND, doc) or _empty(root, doc):
raw = (root / KIND / f"{key}.json").read_bytes()
old = json.loads(raw)
if old.get("이름") == doc["이름"] and body(old) == body(doc): # 이름까지 = 같은 호표
own, rest = _split(old, mine)
_put(key, raw, old, own, [*rest, _entry(doc)])
return ref(key), False, True
key = refs.create(KIND, doc)["이름"]
same.add(root, KIND, key, doc)
return ref(key), False, False
# ── 하위 함께 만들기 ─────────────────────────────────────────────────
def kids_of(hopyo: dict[str, Any]) -> list[int]:
"""호표 줄이 부르는 호표 번(줄 차례)."""
out = []
for line in hopyo.get("줄") or []:
ref = line.get("참조") or {}
if finger.kind_of(line) == "호표" or ref.get("종류") == "일위":
out.append(int(ref.get("번") or 0))
return out
def order(units: dict[int, dict[str, Any]], no: int, also: Any = ()) -> list[int]:
"""`no` 아래 하위 호표(+ `also` 와 그 아래) — 하위 먼저 · `no` 뺌 · 고리는 끊음."""
out: list[int] = []
seen = {int(no)}
def visit(at: int) -> None:
for kid in kids_of(units.get(at) or {}):
if kid not in seen and kid in units:
seen.add(kid)
visit(kid)
out.append(kid)
visit(int(no))
for kid in also:
if kid not in seen and kid in units:
seen.add(kid)
visit(kid)
out.append(kid)
return out
def _target(p: dict[str, Any]) -> dict[str, Any]:
"""대상 → 만들기 몸 대상(화면 `toTarget` 짝)."""
times = 1 if p.get("배") is None else p["배"]
if p.get("호표") is not None:
return {"호표": p["호표"], "배": times}
link = next((k for k in ("노임", "기계", "비율") if p.get(k)), None)
if link:
return {link: p[link], "배": 1}
head = (
{"로직": p["로직"], "입력값": p.get("입력값") or {}}
if p.get("로직")
else {"전국": True}
if p.get("전국")
else {"자재": p.get("자재")}
)
return {**head, "배": times, **({"몫": p["몫"]} if p.get("몫") is not None else {})}
def proposal(hopyo: dict[str, Any]) -> tuple[list[dict[str, Any]], dict[str, Any] | None]:
"""서버 체크 제안 몸 (줄, 통째) — 보기(`M02_Hopyo_Line.hopyo_view`) 체크 제안 줄만(화면 `bodyFor` 짝)."""
whole = hopyo.get("통째")
if whole and whole.get("체크"):
return [], {"대상": [_target(p) for p in whole.get("대상") or []]}
lines = []
for line in hopyo.get("줄") or []:
got = line.get("맞춤") or {}
parts = got.get("대상") if isinstance(got.get("대상"), list) else []
if got.get("상태") == "%":
ok = bool(parts and parts[0].get("비율"))
else:
ok = bool(parts) and all((p.get("배") or 0) > 0 for p in parts)
if got.get("체크") and ok:
lines.append({"차례": line["차례"], "대상": [_target(p) for p in parts]})
return lines, None
def first(
folder: Path, source: Path, key: str, no: int, who: Any, lines: Any = ()
) -> list[dict[str, Any]]:
"""확인 안 된 하위 호표(호표 줄 + 몸 `lines` 의 호표 대상)를 하위 먼저 서버 체크 제안대로 만듦 →
`[{번, 키}]`(새로 만든 것) ·
실패 = 409 `{까닭 「하위 제N호표 실패 — …」, 하위, 하위남은줄}`(만든 하위는 남음)."""
from M02_MasterTemplete import M02_Hopyo_Build as hb
from M02_MasterTemplete import M02_Hopyo_Match as hm
units = {int(h["번"]): h for h in hopyo_store.read(source, key).get("호표") or []}
also = [
t["호표"] for x in lines or [] for t in x.get("대상") or [] if t.get("호표") is not None
]
todo = order(units, no, also)
done = hb.confirmed(folder, refs.where(source)) if todo else {}
made: list[dict[str, Any]] = []
for kid in todo:
if hb.mark(key, kid) in done:
continue
try:
lines, whole = proposal(hm.view(source, key, kid, to=folder)["호표"])
maker = hb.Builder(folder, source, key, kid, who)
got = maker.build(*maker.check(lines, whole))
except store.StoreError as e:
why = e.detail if isinstance(e.detail, dict) else {"까닭": str(e.detail)}
left = why.get("남은줄") or []
detail = {"까닭": f"하위 제{kid}호표 실패 — {why.get('까닭')}", "하위": kid}
raise store.StoreError(409, {**detail, "하위남은줄": left}) from e
done[hb.mark(key, kid)] = got["참조"]["키"]
made.append({"번": kid, "키": got["참조"]["키"]})
return made
+1 -1
View File
@@ -256,7 +256,7 @@ def match_line(line: dict[str, Any], ctx: Ctx, full: bool = True) -> dict[str, A
target = {"호표": no, "이름": h.get("이름", ""), "단위": h.get("단위"), "입력값": {}}
target.update(못읽음=[], 출처={}, 환산=conv, 몫=None, 배=conv["배"])
ok = bool(made) and conv["배"] is not None and conv["갈래"] != "추정"
text = f"확인된 일위대가 {made}" if made else f"제{no}호표 먼저 확인"
text = f"하위 일위대가 {made} 사본" if made else f"제{no}호표 — 저장 때 하위로 함께 만듦"
return {**out, "상태": "호표", "체크": ok, "대상": [target], "글": text}
if kind == "노임":
return {**out, "상태": "노임", "글": "노임 · 그릇 없음"}
+63
View File
@@ -0,0 +1,63 @@
"""M02 불러오기 엑셀 보기 하이라이트 재료(PLAN 779-2 · 요구서 621 3장 5) — 읽기만(파일 · storage 변화 0).
블록 `[{번, 판정, SK, 갈래, 못옮김}]` = 단가산출 만들기 결과(`Sangeun.made`) + 맞춤셈 캐시(`Fit.result`)를 읽기만.
- `판정` = 그 블록을 부르는 첫 줄의 맞춤셈 판정 상태(맞음 · 다른 점 · 후보 …) · 안 불리거나 셈 전이면 None
- `SK` = 만든 SK 키 · 없으면 None / `갈래` · `못옮김` = 그 SK 본뜬(출처별) · 없으면 None · []
줄 상태는 `Match.view` 호표마다 `줄상태 [[차례, 상태, 체크]]` · `만든`(SC 키)로 실려 감.
칠 꼴(화면 779-4 가 정함 · 상태 이름 맞춤표 — 「이어짐」 = 확인 창과 한 규칙 `체크`):
| 칠 | 줄 상태 (`줄상태`) | 블록 (`판정` · `SK` · `못옮김`) |
| 초록 이어짐 | 맞음 · 별칭 · 호표(`체크`) · 체크한 다른 점 | SK 있음 + 못옮김 [] (갈래 「기초」 포함) |
| 주황 엑셀과 다름 | 다른 점 + `체크` | SK 있음 + 갈래 「SK」(차이) |
| 파랑 고를 것 | 후보 · `체크` 아닌 다른 점 · 호표(`체크` 아님) | SK 없음 + 판정 후보 · 고름 대기 |
| 빨강 못 이음 | 없음 · 노임 · (안 이은 줄 = 마법사 몫) | SK 있음 + 못옮김 남음 |
| 흐림 밖 | % · 뺌 | 판정 None (안 불리는 블록) |
| 띠 내가 고름 | 화면 캐시(서버 아님) | — |
최종 판정은 화면(`SheetMap`)이 이 재료 + 캐시 고름으로 내림 — 여기는 상태 이름을 그대로 실음.
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
from M02_MasterTemplete import M02_Hopyo_Fit as fit
from M02_MasterTemplete import M02_Hopyo_Sangeun as sangeun
from M02_MasterTemplete import M02_Hopyo_Store as hopyo_store
from M02_MasterTemplete import M02_Labor_Ref as refs
def _origin(to: Path, sk: str, mark: str, here: refs.Here) -> dict[str, Any]:
try:
doc = json.loads((to / sangeun.KIND / f"{sk}.json").read_bytes())
except (OSError, ValueError):
return {}
return sangeun.origin_of(doc, mark, here)
def blocks(folder: Path, key: str, to: Path | None = None) -> list[dict[str, Any]]:
"""블록마다 `{번, 판정, SK, 갈래, 못옮김}` — `to` = 만든 SK 를 볼 층(없으면 파일 층)."""
folder, to = Path(folder), Path(to or folder)
hp = hopyo_store.read(folder, key)
here = refs.where(folder)
cache = (fit.result(folder, key) or {}).get("블록") or {}
have = sangeun.made(to, key, here)
all_, first = sangeun.calls(hp)
out = []
for no in sorted(all_):
line = first.get(no)
entry = cache.get(fit.unit_key(line)) if line else None
mark = sangeun.mark(key, no)
sk = have.get(mark)
origin = _origin(to, sk, mark, here) if sk else {}
out.append(
{
"번": no,
"판정": ((entry or {}).get("판정") or {}).get("상태"),
"SK": sk,
"갈래": origin.get("갈래"),
"못옮김": origin.get("못옮김") or [],
}
)
return out
+7 -1
View File
@@ -447,7 +447,8 @@ def units_of(doc: dict[str, Any]) -> dict[int, str]:
def view(folder: Path, key: str, no: int | None = None, to: Path | None = None) -> dict[str, Any]:
"""실무자료 보기 — `no` 없으면 호표마다 [체크 제안 수, 줄 수] · 있으면 그 호표 줄마다 맞춤(3-3)
"""실무자료 보기 — `no` 없으면 호표마다 [체크 제안 수, 줄 수] · `줄상태 [[차례, 상태, 체크]]` ·
`만든`(그 호표 SC 키 · 없으면 None) · 있으면 그 호표 줄마다 맞춤(3-3)
·
`to` = 만들 층(장부 · 확인된 사전조합 · 없으면 파일 층) · 맞춤셈이 없으면 뒤 셈을 넣음."""
lines = _line_mod()
@@ -457,6 +458,11 @@ def view(folder: Path, key: str, no: int | None = None, to: Path | None = None)
hopyos = [lines.hopyo_view(h, ctx, False) for h in doc.get("호표") or []]
keep = ("번", "이름", "규격", "단위", "금액", "맞춤", "행", "연결할", "연결된")
brief = [{k: h.get(k) for k in keep if k in h} for h in hopyos]
for one, h in zip(brief, hopyos): # 779-2 · 이미 센 줄 상태를 버리지 않음(덧 셈 0)
one["줄상태"] = [
[i, x["맞춤"]["상태"], x["맞춤"]["체크"]] for i, x in enumerate(h["줄"])
]
one["만든"] = ctx.done.get(lines.hopyo_build.mark(key, int(h["번"])))
ready = [sum(h["맞춤"][0] for h in hopyos), sum(h["맞춤"][1] for h in hopyos)]
head = {**hopyo_store.brief(doc), "검산": doc.get("검산"), "셈": ctx.calc}
links = {k: sum(h[k] for h in hopyos) for k in ("연결할", "연결된")}
+1 -1
View File
@@ -29,7 +29,7 @@ WHY = {
"별도계상": "자재 후보 여럿 · 없음",
"노임": "같은 이름 노임 없음",
"기계": "같은 기계(EQ) 없음 · 여럿",
"호표": "하위 호표 확인 전 · 단위 다름",
"호표": "하위 호표 단위 다름 · 같은 하위 둘째",
"%": "밑수가 다른 줄 금액 합 아님",
}
+12 -3
View File
@@ -9,7 +9,8 @@
기계경비 로직(굴착기 GC000267 · 그 밖 GC000268 · 기계 = 옵션 거꾸로 찾기) · 남은 우리 = 뺀줄
자재 = 같은 이름 우리 줄 수량 덮개 · 옮기지 못한 차이 = `못옮김`(버리지 않고 본뜬에 적음)
못옮김 = `[{id, 갈래 노임|기계|자재, 이름, 규격, 단위, 수량(엑셀 1 단위 품), 글, 후보}]` · 사용자 `잇기`
`[{id, 노임|기계|자재: 키}]` = SK 더한줄 인력 · 기계 · 재료(엑셀 수량 그대로) · `{id, 뺌: 까닭}` = 놓음.
`[{id, 노임|기계|자재: 키}]` = SK 더한줄 인력 · 재료 · 기계(EQ = 기계경비 기초 로직 줄 · 778-2)
(엑셀 수량 그대로) · `{id, 뺌: 까닭}` = 놓음.
SK 단위(728-2) = 엑셀 줄 단위 · 바탕 기준단위와 성격 다름 = 환산 계수 = 우리 품 ÷ 엑셀 품(인력 · 장비
둘 이상이 같은 비일 때만 · `환산` {계수}) · 못 구함 = 고름 대기(까닭 「단위 성격 다름 · 계수 필요」).
고름 대기 = 후보 · 숫자 없음 — SK 안 만듦 · 후보 다섯(사용자가 고른 기초 `pick` = 견줘 SK · 기초) ·
@@ -49,7 +50,7 @@ LOOKS = ("같음", "품다름", "엑셀만", "기초만") # 줄비교 꼴(746-1
COMPARE = 3 # 고름 대기 후보 앞 몇에 줄비교
LINK = {
"노임": ("인력", "LB"),
"기계": ("기계", "EQ"),
"기계": ("로직", "EQ"), # EQ = 기계경비 기초 로직 줄 + 기계 입력(778-2)
"자재": ("재료", "MT"),
} # 잇기 칸 → 더한줄 · 키 머리
@@ -228,9 +229,17 @@ class _Plan:
raise LinkError(f"「{got['id']}」 · 없는 키 「{key}」") from e
self.lost.remove(got)
name = str(elem.get("이름") or got["이름"])
qty = _num(float(got["수량"]) * self.tb)
if kind == "로직": # EQ 키 바로 = 셈 못 함 → 기계경비 기초(기계 = 그 EQ)
kinds = mtypes(f"{elem.get('상세구분')} {elem.get('이름')}")
code = _mach_logics(self.whole, kinds, elem)[0]
name = f"{name} {elem.get('규격') or ''}".strip()
self.add({"종류": kind, "로직": code, "입력값": {MACH_IN: elem.get("원문번호")},
"이름": name, "단위": got["단위"] or "시간", "수량": qty}) # fmt: skip
continue
unit = got["단위"] or ("인" if kind == "인력" else str(elem.get("단위") or ""))
self.add({"종류": kind, "요소": key, "이름": name, "단위": unit,
"수량": _num(float(got["수량"]) * self.tb)}) # fmt: skip
"수량": qty}) # fmt: skip
def labor(self, excel: dict[str, float]) -> None:
mine = [x for x in self.lines if x["갈래"] == "인력" and not x["cid"].startswith("add:")]
+26 -25
View File
@@ -16,8 +16,6 @@
from __future__ import annotations
import importlib
import inspect
from pathlib import Path
from typing import Any, Literal
@@ -27,11 +25,14 @@ from pydantic import BaseModel, ConfigDict, Field
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_Fit as hopyo_fit
from M02_MasterTemplete import M02_Hopyo_Forget as hopyo_forget
from M02_MasterTemplete import M02_Hopyo_Grid as hopyo_grid
from M02_MasterTemplete import M02_Hopyo_Item as hopyo_item
from M02_MasterTemplete import M02_Hopyo_Ledger as hopyo_ledger
from M02_MasterTemplete import M02_Hopyo_Marks as hopyo_marks
from M02_MasterTemplete import M02_Hopyo_Match as hopyo_match
from M02_MasterTemplete import M02_Hopyo_Ours as hopyo_ours
from M02_MasterTemplete import M02_Hopyo_Paste as hopyo_paste
from M02_MasterTemplete import M02_Hopyo_Relink as hopyo_relink
from M02_MasterTemplete import M02_Hopyo_Sangeun as hopyo_sangeun
@@ -211,17 +212,7 @@ def new_material(
HOPYO_MAX_BYTES = 20 * 1024 * 1024
def _later(module: str, name: str):
"""맞춤 엔진(가 창) 함수 — 아직 없으면 None(올리기 · 시동은 그대로)."""
try:
got = importlib.import_module(f"M02_MasterTemplete.{module}")
except ImportError:
return None
return getattr(got, name, None)
if (_warm := _later("M02_Hopyo_Ours", "warm")) is not None:
_warm() # 시동 — 우리 지문 캐시(디스크) 읽어 둠
hopyo_ours.warm() # 시동 — 우리 지문 캐시(디스크) 읽어 둠
def _who(session: Session) -> dict[str, Any]:
@@ -242,8 +233,7 @@ def _upload(name: str, data: bytes, who: dict[str, Any]) -> dict[str, Any]:
except Exception: # 격자는 보기용 덤 — 못 만들어도 올리기는 됨(보기 창 404)
grid = None
row = hopyo_store.save(store.root(), name, parsed, who, grid, mark)
if (ensure := _later("M02_Hopyo_Fit", "ensure")) is not None:
ensure(store.root(), row["키"]) # 맞춤 뒤 셈(일감 줄 · 화면 안 기다림)
hopyo_fit.ensure(store.root(), row["키"]) # 맞춤 뒤 셈(일감 줄 · 화면 안 기다림)
return row
@@ -254,9 +244,8 @@ def hopyo_list(
"""실무자료 목록 `{줄: [{키, 파일, 올린때, 올린이, 시트, 호표수, 어긋남}]}`."""
folder = _root(session, layer, owner)
rows = hopyo_store.rows(folder)
if (ensure := _later("M02_Hopyo_Fit", "ensure")) is not None:
for row in rows: # 지금 판 맞춤셈 없는 파일 = 목록 열 때 뒤 셈 예약(358-3 ③)
ensure(folder, row["키"])
for row in rows: # 지금 판 맞춤셈 없는 파일 = 목록 열 때 뒤 셈 예약(358-3 ③)
hopyo_fit.ensure(folder, row["키"])
return {"줄": rows}
@@ -328,11 +317,9 @@ def hopyo_one(
session: Session = Depends(verify_session),
) -> dict:
"""호표 하나 — 줄마다 맞춤(상태 · 체크 제안 · 대상 · 후보 5) · `to` = 만들 층(없으면 파일 층 ·
호표 참조 줄 체크 · 만든 · 맞춤 엔진이 `to` 를 받을 때만)."""
호표 참조 줄 체크 · 만든)."""
folder = _root(session, layer, owner)
if to is None or "to" not in inspect.signature(hopyo_match.view).parameters:
return _call(folder, hopyo_match.view, folder, key, no)
target = _root(session, to, to_owner)
target = _root(session, to, to_owner) if to is not None else None
return _call(folder, lambda: hopyo_match.view(folder, key, no, to=target))
@@ -395,13 +382,12 @@ class BuildWhole(_Strict):
class BuildBody(_Strict):
"""실무 호표 → 이 층 사전조합 — 파일 층 `층` · `주인` · `키` + 호표 `번` + 확인한 `줄` ·
직접 줄 호표 = `통째` · 같은 본뜬은 늘 덮음(`새로` = 옛 화면 호환 · 안 씀)."""
직접 줄 호표 = `통째` · 같은 본뜬은 늘 덮음."""
층: Layer
주인: str | None = None
키: str
번: int
새로: bool = False
줄: list[BuildLine] = Field(default_factory=list, max_length=500)
통째: BuildWhole | None = None
@@ -414,7 +400,7 @@ def hopyo_make(
session: Session = Depends(verify_session),
) -> dict:
"""[확인하고 사전조합 만들기] — 확인한 줄만(이 층 쓰기 · 파일 층 읽기) · 별칭 장부 씀 →
`{참조, 이름, 다시씀, 빠진줄, 비교, 장부}` · 몸 없음 400 · 하위 호표 확인 전 409."""
`{참조, 이름, 다시씀, 재사용, 빠진줄, 하위, 비교, 장부}` · 하위 호표 확인 전 = 함께 만듦(실패 409)."""
folder = _root(session, layer, owner, write=True)
source = _root(session, body.층, body.주인)
lines = [one.model_dump(exclude_none=True) for one in body.줄]
@@ -622,3 +608,18 @@ def hopyo_to_item(
args = (folder, source, body.키, body.번, lines, whole, body.묶음, body.이름, _who(session))
args += (body.새로, body.씀)
return _call(folder, hopyo_item.make_item, *args)
@router.get("/layers/{layer}/hopyo-marks/{key}")
def hopyo_marks_read(
layer: Layer,
key: str,
owner: str | None = None,
to: Layer | None = None,
to_owner: str | None = None,
session: Session = Depends(verify_session),
) -> dict:
"""하이라이트 재료(779-2 · 읽기만) — `블록 [{번, 판정, SK, 갈래, 못옮김}]` · `to` = SK 를 볼 층."""
folder = _root(session, layer, owner)
target = _root(session, to, to_owner) if to else folder
return _call(folder, lambda: {"블록": hopyo_marks.blocks(folder, key, target)})
@@ -29,7 +29,6 @@ import { openModal } from "@ui/ui_template_modal";
import { t as L } from "@ui/ui_template_locale";
import { createSourceView } from "../M01_MasterData/M01_MasterData_UI_LogicLab_Source";
import {
buildHopyo,
guessHopyo,
readHopyoOne,
type BuildLine,
@@ -41,6 +40,7 @@ import {
type HopyoPart,
type HopyoScope,
} from "./M02_MasterTemplete_Hopyo_Api";
import { buildWithKids as buildHopyo } from "./M02_MasterTemplete_Hopyo_Kids"; // 777-1 하위 함께
import { builtWord, openHopyoChoose, targetText } from "./M02_MasterTemplete_Hopyo_Choose";
import {
checkTables,
@@ -0,0 +1,130 @@
/* =============================================================================
* M02_MasterTemplete_Hopyo_Kids.ts
* 일위대가 실무자료 만들기 몸 · 하위 호표 함께(PLAN 777-1 · 요구 621-4) — 호표가 다른 호표를 부르면 일위대가 둘
* (하위 일위대가 따로 + 부모 줄 = 하위 사본).
* · bodyFor · wholeFor = 확인 창 캐시 위 체크 제안(짝 = 서버 `M02_Hopyo_Kids.proposal` · 캐시 빈 것)
* · unmadeKids = 고른 호표 줄이 부르는 확인 안 된 하위 호표(깊이 끝까지 · 하위 먼저) — [저장]이 그 캐시대로 함께 만듦
* · buildWithKids = 확인 창 [생성] — 확인 안 된 하위를 그 캐시대로 먼저 → 부모(실패 = 「하위 제N호표 실패」)
* · 캐시 없는 하위도 서버가 부모 저장 때 제안대로 함께 만듦(`M02_Hopyo_Kids.first`) — 화면은 캐시 고름을 살림
* · kidsText = 결과 글 「· 하위 제4호표 SC-11(사본 담음)」 · leftAt = 하위 실패의 남은 줄 = 그 하위 [고르기]
* ========================================================================== */
import { t as L } from "@ui/ui_template_locale";
import { StaleError } from "./M02_MasterTemplete_Api_Fetch";
import {
buildHopyo,
readHopyoOne,
type HopyoBuilt,
type HopyoOne,
type HopyoScope,
} from "./M02_MasterTemplete_Hopyo_Api";
import { bodyOf, draftKey, loadDraft, partsOf, toTarget } from "./M02_MasterTemplete_Hopyo_Check";
type Body = Parameters<typeof buildHopyo>[2];
type Whole = Parameters<typeof buildHopyo>[3];
type From = Parameters<typeof buildHopyo>[1];
/** 호표 하나의 몸 — 확인 창 캐시 위 체크 제안 · 통째면 줄 없음 */
export function bodyFor(scope: HopyoScope, key: string, one: HopyoOne): Body {
const h = one.호표;
const d = loadDraft(draftKey(scope, key, h.번));
return h.통째 && (d.통째 ?? h.통째.체크) ? [] : bodyOf(h.줄, d);
}
export function wholeFor(scope: HopyoScope, key: string, one: HopyoOne): Whole {
const h = one.호표;
const d = loadDraft(draftKey(scope, key, h.번));
return h.통째 && (d.통째 ?? h.통째.체크) ? { 대상: h.통째.대상.map(toTarget) } : null;
}
/** (줄, 통째) 몸 한 벌 */
export const bodyPair = (scope: HopyoScope, key: string, one: HopyoOne): [Body, Whole] => [
bodyFor(scope, key, one),
wholeFor(scope, key, one),
];
/** 호표 줄이 부르는 호표 번(체크 안 한 호표 줄 · 캐시에서 고른 호표 대상까지) */
const kidNos = (scope: HopyoScope, key: string, one: HopyoOne): number[] => {
const d = loadDraft(draftKey(scope, key, one.호표.번));
return one.호표.줄.flatMap((l) =>
partsOf(l, d).flatMap((p) => (p.호표 !== undefined ? [p.호표] : [])),
);
};
/** [저장] 할 호표 — 함께 만드는 하위 · 체크 줄(통째) 있음 · 하위를 부름(호표 줄 = 하위 사본) */
export function readyFor(scope: HopyoScope, key: string, one: HopyoOne, picked: number[]): boolean {
const [lines, whole] = bodyPair(scope, key, one);
return (
!picked.includes(one.호표.번) || !!lines.length || !!whole || !!kidNos(scope, key, one).length
);
}
/** 확인 안 된 하위 호표 보기 — 하위 먼저 · `ones` 와 이미 만든 하위(그 아래도) 뺌 */
export async function unmadeKids(
scope: HopyoScope,
key: string,
target: HopyoScope,
ones: HopyoOne[],
): Promise<HopyoOne[]> {
const out: HopyoOne[] = [];
const seen = new Set(ones.map((one) => one.호표.번));
async function visit(one: HopyoOne): Promise<void> {
for (const no of kidNos(scope, key, one)) {
if (seen.has(no)) continue;
seen.add(no);
const kid = await readHopyoOne(scope, key, no, target);
if (kid.호표.만든) continue;
await visit(kid);
out.push(kid);
}
}
for (const one of ones) await visit(one);
return out;
}
/** 하위 실패 — 까닭 「하위 제N호표 실패 — …」 · 남은 줄은 그 하위 몫(`leftAt`) */
function kidFail(error: unknown, no: number): StaleError {
const left = (error as { detail?: { 남은줄?: unknown } } | null)?.detail?.남은줄;
const why = error instanceof Error ? error.message : String(error);
const text = `${L("M02_HopyoKidFail").replace("{n}", String(no))} — ${why}`;
return new StaleError(text, { 까닭: text, 하위: no, 하위남은줄: left ?? [] });
}
/** 확인 창 [생성] — 확인 안 된 하위를 그 캐시대로 먼저 만들고 부모 */
export async function buildWithKids(
target: HopyoScope,
from: From,
줄: Body,
통째?: Whole,
): Promise<HopyoBuilt> {
const scope = { layer: from.층, owner: from.주인 };
const me = await readHopyoOne(scope, from.키, from.번, target);
for (const kid of await unmadeKids(scope, from.키, target, [me])) {
const no = kid.호표.번;
const body = [bodyFor(scope, from.키, kid), wholeFor(scope, from.키, kid)] as const;
await buildHopyo(target, { ...from, 번: no }, ...body).catch((error: unknown) => {
throw kidFail(error, no);
});
}
return buildHopyo(target, from, 줄, 통째);
}
/** 하위 실패면 (그 하위 남은 줄 몸, 하위 번) · 아니면 그대로 — 결과 표 [고르기]가 그 하위를 엶 */
export function leftAt(error: unknown, no: number): [unknown, number] {
const d = (error as { detail?: { 하위?: number; 하위남은줄?: unknown } } | null)?.detail;
return d?.하위 !== undefined ? [{ detail: { 남은줄: d.하위남은줄 } }, d.하위] : [error, no];
}
/** 결과 글 꼬리 — 같은 내용 재사용 · 하위 일위대가(사본 담음 · 함께 만듦) */
export function kidsText(got: HopyoBuilt): string {
const more = got as HopyoBuilt & {
재사용?: boolean;
하위?: { 번: number; 키: string | null; 새로?: boolean }[];
};
const kids = (more.하위 ?? []).map((k) => {
const head = L("M02_HopyoKid")
.replace("{n}", String(k.번))
.replace("{key}", k.키 ?? "");
return ` · ${head}${k.새로 ? ` · ${L("M02_HopyoKidNew")}` : ""}`;
});
return (more.재사용 ? ` · ${L("M02_HopyoReused")}` : "") + kids.join("");
}
@@ -6,8 +6,8 @@
* 오른쪽 = 호표 목록(체크 · 제N호표 [수정] · 찾기 · 제안 n/m — 다 연결이면 녹색 · 뒤 셈 중이면 3 초마다 다시) + [저장].
* · 목록 누름 → 시트에서 그 호표 영역(B열 ~ 끝 열) 고름 · 칸 누름 → 그 행 호표를 목록에서 강조 · 영역 고름
* · [수정] = 그 호표 확인 창(`openHopyoCheck` · 줄 누름과 따로)
* · [저장] = 체크한 호표마다 체크 줄(확인 창 캐시 · 없으면 서버 제안)로 하위 호표부터 차례로 `/hopyo-build` —
* 물음 한 번(확인 창 안 연 호표 수) · 체크 줄 없는 호표 건너뜀 · 하위 확인 전(409)은 안내 ·
* · [저장] = 체크한 호표 + 확인 안 된 하위 호표(777-1 · `Hopyo_Kids`)마다 체크 줄(확인 창 캐시 · 없으면 서버 제안)로
* 하위 호표부터 차례로 `/hopyo-build` — 물음 한 번 · 체크 줄 없는 고른 호표 건너뜀 · 하위 실패 = 그 하위 [고르기] ·
* 결과 표(사전조합 키 · 빠진 줄 · 값 차이) · `onImport` 가 있으면 호표마다 알림([로직 추가] 참조 줄)
* · [모두 고정으로 들여오기](705-3) = 물음 한 번 → 서버 `/hopyo-build-all`(호표 모두 · 하위 먼저 · 엑셀 숫자 그대로 ·
* 이미 만든 호표 그대로) → 알림 「호표 n · 줄 m · 품셈 줄 a · 고정 줄 b · 빠짐 0 · 엑셀 합계 = 우리 합계 n/n」 + 다음 걸음
@@ -27,7 +27,6 @@ import { openModal } from "@ui/ui_template_modal";
import { t as L } from "@ui/ui_template_locale";
import type { Sheet } from "../A00_Common/spreadsheet/spreadsheet_types";
import type { SpreadsheetHandle } from "../A00_Common/spreadsheet/spreadsheet";
import { StaleError } from "./M02_MasterTemplete_Api_Fetch";
import {
buildHopyo,
buildHopyoAll,
@@ -45,7 +44,6 @@ import {
type HopyoView,
} from "./M02_MasterTemplete_Hopyo_Api";
import {
bodyOf,
draftKey,
hasDraft,
HOPYO_BUILT,
@@ -53,8 +51,8 @@ import {
openHopyoCheck,
partsOf,
POLL_MS,
toTarget,
} from "./M02_MasterTemplete_Hopyo_Check";
import { bodyPair, kidsText, leftAt, readyFor, unmadeKids } from "./M02_MasterTemplete_Hopyo_Kids";
import { builtWord } from "./M02_MasterTemplete_Hopyo_Choose";
import { openRelink, relinkKeys } from "./M02_MasterTemplete_Hopyo_Relink";
import { leftButton } from "./M02_MasterTemplete_Hopyo_Left";
@@ -83,7 +81,7 @@ export interface HopyoSheetOptions {
}
const ILWI = "일위대가";
const DANSAN = "단가산출";
const DANSAN = ["단가산출", "단가산출근거"]; // 형식별 블록 시트(EST · STmate)
const why = (error: unknown): string => (error instanceof Error ? error.message : String(error));
const won = (v: number | null | undefined): string =>
@@ -392,7 +390,8 @@ export function openHopyoSheet(opt: HopyoSheetOptions): void {
poll(file);
if (grid instanceof Error) return void left.replaceChildren(note(grid.message));
await mountSheet(grid);
if (opt.단산행 !== undefined) sheet?.select(rowsText(opt.단산행), DANSAN);
const rows = opt.단산행 === undefined ? null : rowsText(opt.단산행);
if (rows) DANSAN.some((name) => sheet?.select(rows, name));
else if (current !== null) focus(current);
} catch (error) {
if (!closed) {
@@ -413,17 +412,7 @@ export function openHopyoSheet(opt: HopyoSheetOptions): void {
],
});
/** 호표 하나의 몸 — 확인 창 캐시 위 체크 제안 · 통째면 줄 없음 */
function bodyFor(one: HopyoOne): Parameters<typeof buildHopyo>[2] {
const h = one.호표;
const d = loadDraft(draftKey(opt.scope, opt.key, h.번));
return h.통째 && (d.통째 ?? h.통째.체크) ? [] : bodyOf(h.줄, d);
}
function wholeFor(one: HopyoOne): Parameters<typeof buildHopyo>[3] {
const h = one.호표;
const d = loadDraft(draftKey(opt.scope, opt.key, h.번));
return h.통째 && (d.통째 ?? h.통째.체크) ? { 대상: h.통째.대상.map(toTarget) } : null;
}
const body = (one: HopyoOne) => bodyPair(opt.scope, opt.key, one);
const headCell = (no: number, name: string): HTMLElement =>
el("td", { text: `제${no}호표 ${name}`.trim() });
@@ -448,11 +437,12 @@ export function openHopyoSheet(opt: HopyoSheetOptions): void {
skipped.push(el("tr", { children: [headCell(no, ""), el("td", { text: why(error) })] }));
}
}
ones.push(...(await unmadeKids(opt.scope, opt.key, target, ones))); // 777-1 하위 함께
} finally {
hideLoadingOverlay();
paintGo();
}
const ready = ones.filter((one) => bodyFor(one).length || wholeFor(one));
const ready = ones.filter((one) => readyFor(opt.scope, opt.key, one, nos)); // 하위 함께
for (const one of ones)
if (!ready.includes(one))
skipped.push(
@@ -488,7 +478,7 @@ export function openHopyoSheet(opt: HopyoSheetOptions): void {
const cell = headCell(no, h.이름);
try {
const from = { 층: opt.scope.layer, 주인: opt.scope.owner, 키: opt.key, 번: no };
const got = await buildHopyo(target, from, bodyFor(one), wholeFor(one));
const got = await buildHopyo(target, from, ...body(one));
ok += 1;
opt.onImport?.(got, h);
rows.push(
@@ -496,18 +486,16 @@ export function openHopyoSheet(opt: HopyoSheetOptions): void {
attrs: { "data-built": got.참조.키 },
children: [
cell,
el("td", { text: `${got.참조.키} · ${builtWord(got)}` }),
el("td", { text: `${got.참조.키} · ${builtWord(got)}${kidsText(got)}` }),
el("td", { text: String(got.빠진줄.length), className: "m02-hopyo__num" }),
el("td", { text: won(got.비교.차이), className: "m02-hopyo__num" }),
],
}),
);
} catch (error) {
const left = leftButton(error, { scope: opt.scope, target, key: opt.key, 파일: opt.파일, 번: no }); // prettier-ignore
const text =
left || !(error instanceof StaleError)
? `실패 — ${why(error)}`
: `안내 — ${why(error)} · 하위 호표를 함께 고르거나 먼저 저장`;
const [err, kid] = leftAt(error, no); // 하위 실패 = 그 하위 남은 줄
const left = leftButton(err, { scope: opt.scope, target, key: opt.key, 파일: opt.파일, 번: kid }); // prettier-ignore
const text = `실패 — ${why(error)}`;
rows.push(el("tr", { children: [cell, el("td", { text }), ...(left ? [el("td", { children: [left] })] : [])] })); // prettier-ignore
}
}
@@ -16,3 +16,8 @@ M02 의 엑셀 불러오기에서 경쟁사 2개의 프로그램의 출력을
4. 경쟁사 출력의 일위대가에서 다른 호표의 일위대가를 참조한경우 두개의 일위대가로 가져오는게 맞는것같음.
5. 엑셀을 보여줄때 선택되고 제대로 연결이 되었다면 엑셀보기에도 하이라이트 되는게 어떤지.
위 내용을 정밀하게 조사후 신규 계획서와 신규 요구서 작성 필요
## 요구 622 (11-04 · 사용자 · 요구서_621 4장 물음 답 · 778 다시 조사)
1. (옮기는 기준) 너가 지금 오해를 한게 있는데 우리나 경쟁사의단가산출을 보면 결국 형태가 노무/경비/자재로 구분되어있고 각 그룹의 세부항목은 하나의 별도의 계산 방법이 있다는거지. 그게 모여서 하나의 단가산출이되는거고 이걸 구분해서 사용하면 고객이 넣은 단가산출이든 일위대가든 일위대가의 단가산출이든 쉽게 분석이된단는 생각이야. 다시 조사해봐
2. (고름 대기 62) 옮기는 기준을 먼저 보고 판단
@@ -512,7 +512,7 @@ def fill_empty(row: dict, given: dict) -> dict:
return {**given, **gap}
def _inputs(row: dict, given: dict) -> dict:
def _inputs(row: dict, given: dict, master=None) -> dict:
env = {}
for spec in row.get("입력", []):
name = spec["이름"]
@@ -529,8 +529,8 @@ def _inputs(row: dict, given: dict) -> dict:
pass
if isinstance(value, (int, float)):
value = Decimal(str(value))
value = mp.resolve(spec, value) # 기계 EQ 키 → 원문 분류번호
if "고르기" in spec and value not in spec["고르기"] and not mp.machine(spec, value):
value = mp.resolve(spec, value, master) # 기계 EQ 키 → 원문 분류번호
if "고르기" in spec and value not in spec["고르기"] and not mp.machine(spec, value, master):
raise FormulaError(f"입력 「{name}」={value} 고르기 밖")
if "범위" in spec:
low, high = spec["범위"]
@@ -567,7 +567,7 @@ def run(master: Master, ref: str, given: dict, depth: int = 0) -> dict:
if isinstance(row.get("흐름"), dict): # 흐름 로직 — 깔림에 기대지 않고 스스로 깖
_flow_hooks()
return FLOW_RUN(master, ref, row, given)
env = _inputs(row, given)
env = _inputs(row, given, master)
for step in row.get("중간", []):
try:
env[step["이름"]] = evaluate(parse(step["식"]), env, master, depth)
+13 -8
View File
@@ -33,22 +33,27 @@ def choosing(picks: dict | None):
CHOSEN.reset(token)
def _numbers() -> dict:
return EQ.get().numbers.get("EQ", {}) if EQ.get() else {}
def _eq(master=None):
"""기계를 찾을 마스터 — 셈에 넘어온 것(기계 줄 있음) › 지은 문맥의 것(`EQ` · 다른 스레드는 빔)."""
return master if (getattr(master, "numbers", None) or {}).get("EQ") else EQ.get()
def resolve(spec: dict, value):
def _numbers(master=None) -> dict:
return _eq(master).numbers.get("EQ", {}) if _eq(master) else {}
def resolve(spec: dict, value, master=None):
"""기계 입력이 EQ 키로 오면 원문 분류번호로 풂 — 고르기 목록(원문번호)과 같은 꼴."""
if not (isinstance(value, str) and value.strip()[:2] == "EQ" and EQ.get()):
if not (isinstance(value, str) and value.strip()[:2] == "EQ" and _eq(master)):
return value
row = EQ.get().index.get("EQ", {}).get(value.strip())
row = _eq(master).index.get("EQ", {}).get(value.strip())
number = str(row.get("원문번호") or "") if row else ""
return number if number and machine(spec, number) else value
return number if number and machine(spec, number, master) else value
def machine(spec: dict, value) -> bool:
def machine(spec: dict, value, master=None) -> bool:
"""기계 입력 — 고르기 목록 밖 원문 분류번호도 기계 마스터에 있으면 받음."""
known = _numbers()
known = _numbers(master)
picks = spec.get("고르기") or []
return bool(picks) and str(value).strip() in known and all(str(o) in known for o in picks)
+5 -1
View File
@@ -28,7 +28,11 @@ def test_이어서_끝내기_지움_겹침_막음(client):
assert _keys(client) == ["HP-000001"]
made = client.post(ALL, json={"층": "personal", "키": "HP-000001"}).json()
n = len(made["만듦"]) # 729-2 — 남은 줄 있는 호표 = 실패(남은줄)
assert n and n + len(made["실패"]) == 68 and all(x["남은줄"] for x in made["실패"])
assert (
n
and n + len(made["실패"]) == 68
and all(x.get("남은줄") or x.get("하위") for x in made["실패"])
)
hs.remember(folder, {"장부 줄": {"갈래": "안 씀"}}) # 별칭 장부 = 끝내기 뒤 남음
cache = fit.cache_path(folder, "HP-000001")
cache.write_text("{}", encoding="utf-8")
+191
View File
@@ -0,0 +1,191 @@
"""777-1 호표가 다른 호표를 부를 때 = 일위대가 둘(요구 621-4 · `M02_Hopyo_Kids`).
- 하위 확인 전 부모 저장 = 409 없이 하위 · 부모 둘(하위 = 서버 체크 제안대로 · 부모 줄 = 하위 사본 · 답 `하위`)
- 하위가 남은 줄로 못 만들면 = 409 「하위 제N호표 실패」 · `하위` · `하위남은줄`(부모 · 하위 안 씀)
- 같은 엑셀 두 번([끝내기] 뒤 다시 올림) = 같은 내용 SC 재사용(`본뜬.또`) · SC 안 늚 · 끝내면 그 몫만 뺌
- 깊이 3 사슬 = 손자 → 자식 → 부모 차례로 · 부모 안 사본 안에 사본
- 서버 체크 제안 몸(`proposal`) = 화면 `bodyFor` 꼴(체크 줄만 · % = 밑수 고른 것만 · 통째)
층은 임시 폴더(공용 storage 안 씀).
"""
import json
import pytest
from M02_MasterTemplete import M02_Hopyo_Build as hb
from M02_MasterTemplete import M02_Hopyo_Fit as fit
from M02_MasterTemplete import M02_Hopyo_Forget as forget
from M02_MasterTemplete import M02_Hopyo_Kids as hk
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
# 올리기 길 시험의 `client` 마당을 같이 씀
# ruff: noqa: F811
from resources.tester.test_705_3_hopyo_fixed import DOCS, FILES
from resources.tester.test_m02_hopyo_excel import _post, client, master_hash # noqa: F401
BUILD = "/api/m02/layers/personal/hopyo-build"
LEFT = "/api/m02/layers/personal/hopyo-unlinked"
GEO = DOCS / FILES["거창"]
def _mine():
return layers.personal_dir(7, 42)
def _scs():
return sorted(p.stem for p in (_mine() / "precombo").glob("SC-*.json"))
def _doc(key):
return json.loads((_mine() / "precombo" / f"{key}.json").read_bytes())
def _decided(client, body):
left = client.post(LEFT, json=body).json()["남은줄"]
return {**body, "줄": [*body["줄"], *({"차례": x["차례"], "뺌": "우리에 없음"} for x in left)]}
def _geo(client, key="HP-000001"):
assert _post(client, data=GEO.read_bytes(), name=GEO.name).status_code == 200
fit.run_job(_mine(), key) # 숫자 판정 · 단가산출 맞춤셈(서버 체크 제안이 씀)
def test_하위_확인_전_부모_저장_둘_생김(client):
before = master_hash()
_geo(client)
body = _decided(client, {"층": "personal", "키": "HP-000001", "번": 3, "줄": []})
res = client.post(BUILD, json=body)
assert res.status_code == 200, res.text # 409 「제4호표 먼저 확인」 없음
got = res.json()
kid = hb.confirmed(_mine())["HP-000001 제4호표"]
assert got["하위"] == [{"번": 4, "키": kid, "새로": True}]
assert hb.confirmed(_mine())["HP-000001 제3호표"] == got["참조"]["키"] != kid
copy = next(x for x in _doc(got["참조"]["키"])["담은로직"] if "참조" in x)
assert copy["참조"]["층"] == "사본" and copy["참조"]["키"] == kid # 부모 줄 = 하위 사본(740-1)
again = client.post(BUILD, json=body).json() # 다시 저장 = 하위 그대로 · 부모 덮음
assert again["하위"] == [{"번": 4, "키": kid, "새로": False}] and again["다시씀"] is True
assert master_hash() == before
def test_하위_실패_까닭_따로_아무것도_안_씀(client):
assert _post(client).status_code == 200 # 북부청 임도 제45호표 → 제46호표(남은 줄 셋)
body = {"층": "personal", "키": "HP-000001", "번": 45, "줄": []}
res = client.post(BUILD, json=body)
assert res.status_code == 409, res.text
why = res.json()["detail"]
assert why["까닭"].startswith("하위 제46호표 실패 — ") and why["하위"] == 46
assert why["하위남은줄"] and "남은줄" not in why # 부모 [고르기]로 잘못 안 엶 — 하위 몫
assert hb.confirmed(_mine()) == {}
left = client.post(LEFT, json=body).json()["남은줄"] # 미리 보기 = 하위 호표 줄은 함께 만듦
assert all(x["갈래"] != "호표" for x in left)
def test_같은_엑셀_두_번_SC_안_늚(client):
assert _post(client).status_code == 200
folder = _mine()
with store.at(folder):
first = hb.build_all(folder, folder, "HP-000001")
made = _scs()
assert first["실패"] == [] and len(made) <= first["호표"] + 3
keys = {x["번"]: x["키"] for x in first["만듦"]}
forget.finish(folder, "HP-000001")
assert hb.confirmed(folder) == {} # 끝낸 뒤 = 출처만(키 뺌)
up = _post(client).json()
assert up["키"] == "HP-000002" and not up.get("이어서")
with store.at(folder):
second = hb.build_all(folder, folder, "HP-000002")
assert _scs() == made # 같은 내용 = 재사용 · 안 늚
assert {x["번"]: x["키"] for x in second["만듦"]} == keys
assert (second["같음"], second["다름"]) == (first["같음"], first["다름"])
shared = [k for k in made if any(x["키"].startswith("HP-000002 ") for x in _doc(k)["본뜬"].get("또", []))] # fmt: skip
assert shared and set(hb.confirmed(folder)) == {hb.mark("HP-000002", n) for n in keys}
forget.finish(folder, "HP-000002") # 그 몫만 뺌
assert _scs() == made and not any(_doc(k)["본뜬"].get("또") for k in made)
def test_깊이_3_사슬(client, monkeypatch):
_geo(client)
folder = _mine()
units = {int(h["번"]): h for h in hs.read(folder, "HP-000001")["호표"]}
deep = {
no: hk.order(units, no)
for no in units
if any(hk.kids_of(units[k]) for k in hk.kids_of(units[no]) if k in units)
}
top = min(deep)
plain = hk.proposal
def decided(hopyo): # 하위 남은 줄 = 「우리에 없음」(화면 [고르기] 몫을 시험이 대신)
lines, _ = plain({**hopyo, "통째": None}) # 줄로(통째면 하위 줄이 빠짐 — 사슬을 보려고)
body = {"층": "personal", "키": "HP-000001", "번": hopyo["번"], "줄": lines}
return _decided(client, body)["줄"], None
monkeypatch.setattr(hk, "proposal", decided)
body = _decided(client, {"층": "personal", "키": "HP-000001", "번": top, "줄": []})
res = client.post(BUILD, json=body)
assert res.status_code == 200, res.text
done = hb.confirmed(folder)
assert all(hb.mark("HP-000001", k) in done for k in deep[top]) # 손자까지 다 만듦
new = [x["번"] for x in res.json()["하위"] if x["새로"]]
assert new and set(new) <= set(deep[top])
def depth(doc):
inner = [
x["참조"]["몸"] for x in doc.get("담은로직") or [] if (x.get("참조") or {}).get("몸")
]
return 1 + max((depth(x) for x in inner), default=0)
assert depth(_doc(done[hb.mark("HP-000001", top)])) == 3 # 부모 안 사본 안에 사본
@pytest.mark.parametrize(
("view", "want"),
[
(
{
"줄": [
{
"차례": 0,
"맞춤": {
"체크": True,
"상태": "로직",
"대상": [{"로직": "GF1", "입력값": {"a": 1}, "배": 2, "몫": 0.5}],
},
}, # fmt: skip
{
"차례": 1,
"맞춤": {"체크": False, "상태": "로직", "대상": [{"로직": "GF2", "배": 1}]},
}, # fmt: skip
{"차례": 2, "맞춤": {"체크": True, "상태": "%", "대상": [{"비율": None}]}},
{
"차례": 3,
"맞춤": {"체크": True, "상태": "호표", "대상": [{"호표": 4, "배": None}]},
}, # fmt: skip
{
"차례": 4,
"맞춤": {"체크": True, "상태": "자재", "대상": [{"자재": "MT1", "배": 0}]},
}, # fmt: skip
{
"차례": 5,
"맞춤": {"체크": True, "상태": "자재", "대상": [{"전국": True, "배": 3}]},
}, # fmt: skip
]
},
(
[
{"차례": 0, "대상": [{"로직": "GF1", "입력값": {"a": 1}, "배": 2, "몫": 0.5}]},
{"차례": 5, "대상": [{"전국": True, "배": 3}]},
],
None,
),
),
(
{"줄": [], "통째": {"체크": True, "대상": [{"로직": "GF1", "배": 1}]}},
([], {"대상": [{"로직": "GF1", "입력값": {}, "배": 1}]}),
),
],
)
def test_서버_체크_제안_몸_화면_짝(view, want):
assert hk.proposal(view) == want
@@ -0,0 +1,74 @@
"""못옮김 기계 잇기 = 기계경비 기초 로직 줄(PLAN 778-2 · 분석 621-B 4-2).
EQ 키 바로 더한줄은 셈 오류 → 사용자 잇기 `{id, 기계: EQ…}` = SK 더한줄
`{종류: 로직, 로직: 기계경비 기초, 입력값: {기계: 기계 번호}}` · SK 셈 됨(그 기계 시간당 기계경비 ×
엑셀 시간 × 배) · 갈래 다른 키 · 없는 키 = LinkError. 고르기 목록 밖 기계 = 서버 둘째 요청 ·
지은 문맥 밖 스레드에도 셈 됨(마스터를 셈에 넘김). master 읽기만.
"""
import threading
import pytest
from M01_MasterData import M01_MasterData_Store as m01
from M02_MasterTemplete import M02_Hopyo_Combine as combine
from M02_MasterTemplete import M02_Hopyo_SkPatch as skpatch
from M02_MasterTemplete import M02_Sangeun_Calc as calc
from M02_MasterTemplete import M02_Sangeun_Router as sangeun_router
from resources.tester.test_m02_hopyo_excel import client # noqa: F401
BASE = "GC000267" # 바탕 아무 기초(시간 단위) — 셈만 봄
def _plan(whole) -> skpatch._Plan:
doc = {"키": "SK-000000", "이름": "견줌", "단위": "시간", "바탕": {"로직": BASE},
"입력값": {}, "덮개": {}, "뺀줄": [], "자재표시": {}, "더한줄": []} # fmt: skip
work = skpatch._Plan(doc, whole)
one = {"id": "기계:굴착기", "갈래": "기계", "이름": "굴착기", "규격": "0.6", "단위": "시간",
"수량": 0.25, "글": "엑셀만 장비 — 기계 못 찾음", "후보": []} # fmt: skip
work.lost = [one, {**one, "id": "기계:굴착기#1"}]
return work
def test_기계_잇기_뒤_SK_셈_됨():
whole = m01.loaded()[1]
work = _plan(whole)
work.link([{"id": "기계:굴착기", "기계": "EQ000026"}])
add = work.doc["더한줄"][-1]
assert add["종류"] == "로직" and add["로직"] in combine.MACHINE_SET - {
combine.MACHINE["굴착기"]
}
assert add["입력값"] == {skpatch.MACH_IN: "0211-0060"} and "요소" not in add
assert add["수량"] == pytest.approx(0.25 * work.tb) and add["단위"] == "시간"
ran = calc.run(work.doc, whole) # 전 = 「Decimal × dict」 셈 오류
line = next(x for x in ran["줄"] if x.get("cid") == "add:#0")
assert float(line["금액"]) > 0
one = {"종류": "로직", "로직": add["로직"], "입력값": add["입력값"], "수량": 1}
hour = calc.run({**work.doc, "바탕": None, "더한줄": [one]}, whole)["기준"]
assert float(hour["경비"]) == 27120 # 굴착기(타이어) 0.6㎥ 시간당 경비(엑셀 14-6 과 같음)
# 무한궤도 = 굴착기(무한궤도) 기계경비 · 갈래 다른 키 · 없는 키 = 틀림
work.link([{"id": "기계:굴착기#1", "기계": "EQ000019"}])
assert work.doc["더한줄"][-1]["로직"] == combine.MACHINE["굴착기"]
work.lost = _plan(whole).lost
with pytest.raises(skpatch.LinkError):
work.link([{"id": "기계:굴착기", "기계": "MT000010"}])
with pytest.raises(skpatch.LinkError):
work.link([{"id": "기계:굴착기", "기계": "EQ999999"}])
def test_목록_밖_기계_서버_둘째_요청에도_셈(client): # noqa: F811
m01.loaded() # 마스터 = 이 스레드에서 지음 · 요청 = 다른 스레드(문맥 안 따라감)
client.app.include_router(sangeun_router.router)
one = {"종류": "로직", "로직": "GC000268", "입력값": {skpatch.MACH_IN: "0211-0060"},
"이름": "굴착기(타이어) 0.6㎥", "단위": "시간", "수량": 1} # fmt: skip
doc = {"키": "SK-000000", "이름": "t", "단위": "시간", "바탕": None, "입력값": {}, "덮개": {},
"뺀줄": [], "자재표시": {}, "더한줄": [one]} # fmt: skip
for _ in range(2):
got = client.post("/api/m02/layers/personal/sangeun/calc", json={"문서": doc})
assert got.status_code == 200, got.text
assert got.json()["기준"]["경비"] == 27120, got.json().get("까닭")
whole, out = m01.loaded()[1], {} # 지은 문맥 밖 스레드(서버 요청 스레드 풀과 같음)
run = threading.Thread(target=lambda: out.update(calc.run(doc, whole)))
run.start()
run.join()
assert out["기준"] and float(out["기준"]["경비"]) == 27120, out.get("까닭")
@@ -0,0 +1,78 @@
"""779-2 불러오기 엑셀 하이라이트 재료 — 목록 `줄상태` 수 = 줄 수 · 상태 = 확인 창 상태 ·
`hopyo-marks` 길 읽기만(층 폴더 파일 변화 0) · 블록 판정 = 맞춤셈 캐시 · SK = 만든 것."""
import hashlib
from M02_MasterTemplete import M02_Hopyo_Fit as fit
from M02_MasterTemplete import M02_Hopyo_Sangeun as sangeun
from M02_MasterTemplete import M02_Template_Layers as layers
from resources.tester.test_m02_hopyo_excel import NORTH, URL, _post, client # noqa: F401
# ruff: noqa: F811
KEY = "HP-000001"
def _tree(root) -> dict[str, str]:
return {
str(p.relative_to(root)): hashlib.sha256(p.read_bytes()).hexdigest()
for p in sorted(root.rglob("*"))
if p.is_file()
}
def _ready(client):
res = _post(client, data=NORTH.read_bytes(), name=NORTH.name)
assert res.status_code == 200 and res.json()["키"] == KEY
folder = layers.personal_dir(7, 42)
fit.run_job(folder, KEY)
return folder
def test_줄상태_수_줄_수_확인창_같음(client):
_ready(client)
base = URL.format("personal")
whole = client.get(f"{base}/{KEY}").json()
assert whole["호표"]
diff = {}
for h in whole["호표"]:
one = client.get(f"{base}/{KEY}/{h['번']}").json()["호표"]
assert len(h["줄상태"]) == len(one["줄"]) == h["맞춤"][1], h["번"]
for (i, state, check), line in zip(h["줄상태"], one["줄"]):
full = line["맞춤"]["상태"]
if state != full:
diff[(state, full)] = diff.get((state, full), 0) + 1
assert check == line["맞춤"]["체크"]
assert h["만든"] is None # 확인된 사전조합 전
assert [x[0] for x in h["줄상태"]] == list(range(len(h["줄상태"])))
# 목록 = 줄 맞춤 `full=False`(덧 셈 0) — 이름 후보 안 찾은 줄(없음)은 「후보」로 옴
assert set(diff) <= {("후보", "없음")}, diff
def test_marks_읽기만_판정_캐시_같음_SK(client):
folder = _ready(client)
base = URL.format("personal")
before = _tree(folder)
got = client.get(f"{base}-marks/{KEY}")
assert got.status_code == 200, got.text
rows = got.json()["블록"]
assert _tree(folder) == before # 읽기만 — 파일 변화 0
cache = fit.result(folder, KEY)["블록"]
hp = client.get(f"{base}-grid/{KEY}") # 길 살아 있음(블록 수는 파일에서)
assert hp.status_code == 200
assert rows and [r["번"] for r in rows] == sorted(r["번"] for r in rows)
judged = [r for r in rows if r["판정"]]
assert judged and all(r["판정"] in ("맞음", "다른 점", "후보", "없음") for r in judged)
assert {r["판정"] for r in judged} <= {e["판정"]["상태"] for e in cache.values()}
assert all(r["SK"] is None and r["갈래"] is None and r["못옮김"] == [] for r in rows)
# [단가산출 만들기] 뒤 = SK · 갈래 · 못옮김 실림
made = client.post(f"{base}/{KEY}/sangeun")
assert made.status_code == 200, made.text
after = {r["번"]: r for r in client.get(f"{base}-marks/{KEY}").json()["블록"]}
for one in made.json()["만듦"]:
row = after[one["번"]]
assert (row["SK"], row["갈래"], row["못옮김"]) == (one["키"], one["갈래"], one["못옮김"])
assert sangeun.made(folder, KEY)
snap = _tree(folder)
client.get(f"{base}-marks/{KEY}")
assert _tree(folder) == snap
assert client.get(f"{base}-marks/HP-999999").status_code in (404, 400)
+37
View File
@@ -0,0 +1,37 @@
"""779-3 지킴 — 공용 격자 「칠」 옵션(요구 621-5): 안 주면 data-tone 없음 · 주면 보이는 칸만 · 색은 --color-* 만.
화면 수치 판정은 tmp 의 playwright 일회 검증 · 여기서는 길이 빠지지 않았는지만 지킴."""
import re
from pathlib import Path
SS = Path(__file__).parents[2] / "A00_Common/spreadsheet"
def read(name: str) -> str:
return (SS / name).read_text(encoding="utf-8")
def test_fill_option_flows_option_to_ctx_to_cell():
assert "칠?:" in read("spreadsheet.ts") and "fill: opts.칠" in read("spreadsheet.ts")
assert "fill?:" in read("spreadsheet_view_types.ts")
def test_tone_set_only_in_visible_cell_loop_and_cleared_when_absent():
cells = read("spreadsheet_grid_cells.ts")
assert cells.count("ctx.fill?.(") == 1 # 칸 그리는 반복(보이는 칸) 안 한 곳뿐
assert "cellEl.dataset.tone = tone" in cells
assert "delete cellEl.dataset.tone" in cells # 재사용 div — 안 주면 data-tone 없음
def test_repaint_handle_only_rerenders_grid():
assert re.search(r"repaint:\s*\(\)\s*=>\s*grid\.render\(\)", read("spreadsheet.ts"))
def test_css_tones_use_color_tokens_only_and_readonly_hides_add():
css = read("spreadsheet_grid.css")
tone = css[css.index('[data-tone~="success"]') :]
for name in ("success", "warning", "info", "danger"):
assert f"var(--color-{name}) 18%" in tone
assert "--draw-" not in tone
assert "var(--color-text-muted)" in tone and "var(--color-accent)" in tone
assert ".ss--readonly .ss-tabs__add" in css
+74
View File
@@ -0,0 +1,74 @@
"""779-1 격자 세 보기 — 여덟 실무 파일: 보기 셋(두 꼴 같은 역할) · 목록 짝 100 % · 줄 행 대응.
`M02_Hopyo_Grid.read` 응답 `보기 · 원본 · 목록 · 줄` — 서버 몫만(화면 없음).
"""
import warnings
import pytest
from M02_MasterTemplete import M02_Hopyo_Excel as hx
from M02_MasterTemplete import M02_Hopyo_Grid as hg
from resources.tester.helper_m02_hopyo_golden import FILES
warnings.filterwarnings("ignore", category=UserWarning, module="openpyxl")
ROLES = {"일위대가표", "단가산출표", "단가산출상세"}
@pytest.fixture(scope="module", params=sorted(FILES))
def one(request):
path = FILES[request.param]
data = path.read_bytes()
parsed = hx.read_book(path.name, data)
return parsed, hg.read(path.name, data, parsed)
def test_보기_셋(one):
parsed, grid = one
assert set(grid["보기"]) == set(grid["원본"]) == ROLES
names = {s["id"]: s["이름"] for s in grid["책"]["시트"]}
assert [names[grid["보기"][r]] for r in ("일위대가표", "단가산출표", "단가산출상세")] == [
grid["원본"][r] for r in ("일위대가표", "단가산출표", "단가산출상세")
]
assert list(names) == [grid["보기"][r] for r in ("일위대가표", "단가산출표", "단가산출상세")]
assert len(grid["책"]["시트"]) == 3
assert parsed["꼴"] in ("엑셀꼴", "STmate")
def test_목록_짝_100(one):
parsed, grid = one
for key in ("호표", "단산"):
got = grid["목록"][key]
assert sorted(x["번"] for x in got) == sorted(x["번"] for x in parsed[key]), key
rows = [x["행"] for x in got]
assert len(set(rows)) == len(rows) and min(rows) >= 4
def test_줄_행_대응(one):
parsed, grid = one
lines = [(h["번"], x) for h in parsed["호표"] for x in h["줄"]]
assert [(x["번"], x["차례"], x["행"]) for x in grid["줄"]] == [
(n, x["차례"], x["행"]) for n, x in lines
]
span = {h["번"]: h["행"] for h in parsed["호표"]}
block = {b["번"]: b["행"] for b in parsed["단산"]}
for one_, (no, line) in zip(grid["줄"], lines, strict=True):
assert span[no][0] < one_["행"] <= span[no][1]
ref = line.get("참조") or {}
if ref.get("종류") == "단산" and ref["번"] in block:
assert one_["단산"] == {"번": ref["번"], "행": block[ref["번"]]}
else:
assert "단산" not in one_
if ref.get("종류") == "일위" and ref.get("번") in span:
assert one_["일위"] == {"번": ref["번"], "행": span[ref["번"]]}
else:
assert "일위" not in one_
assert any("단산" in x for x in grid["줄"])
def test_두_꼴_같은_역할_셋():
"""역할마다 엑셀꼴 · STmate 시트 이름 하나씩 · 탭 차례 = 역할 차례(꼴별로)."""
assert {r: len(n) for r, n in hg.ROLES.items()} == dict.fromkeys(ROLES, 2)
first = [hg.SHEETS.index(n[0]) for n in hg.ROLES.values()]
second = [hg.SHEETS.index(n[1]) for n in hg.ROLES.values()]
assert first == sorted(first) and second == sorted(second)
+5 -2
View File
@@ -1,6 +1,6 @@
"""일위대가 실무자료 → 사전조합 만들기(PLAN 324-3 · 338-3 · `M02_Hopyo_Build`) — 확인한 줄만 ·
몸 없음 400 · 빠진 줄 까닭 · 로직 여럿 같은 별칭 · 같은 로직 감쌈 · 배 · 몫 · 자재 · 통째 ·
재검사 400(없는 로직 · 모르는 입력 · 배 ≤ 0 · 금액 칸) · 하위 호표 409 → 확인 뒤 참조 · 장부 씀 ·
재검사 400(없는 로직 · 모르는 입력 · 배 ≤ 0 · 금액 칸) · 하위 실패 409(777-1) → 확인 뒤 참조 · 장부 씀 ·
`본뜬.확인` · 덮음 · 옛 즉시 확정 길 404 · 장부 길(보기 · 고치기 · 권한) · picks 확인 ·
master_data 원본 그대로. 층은 임시 폴더.
"""
@@ -244,7 +244,10 @@ def test_몸_없음_재검사_400(client):
def test_하위_호표_먼저_확인_퍼센트_빠짐_통째(client):
assert _post(client).status_code == 200
ref = [{"차례": 0, "대상": [{"호표": 46, "배": 1}]}]
assert "제46호표 먼저 확인" in _build(client, 45, ref, want=409)["detail"]
why = _build(client, 45, ref, want=409)[
"detail"
] # 777-1 — 하위 먼저 함께(제안대로) · 남은 줄로 실패
assert why["까닭"].startswith("하위 제46호표 실패") and why["하위"] == 46 and why["하위남은줄"]
child = _build(client, 46, [{"차례": 0, "뺌": "안 씀"}])
assert [x["까닭"] for x in child["빠진줄"]] == ["뺌 — 안 씀"] # % 줄 = 비율 고정 줄(705-3)
rate = _doc(layers.personal_dir(7, 42), child["참조"]["키"])["담은로직"][-1]["고정"]
+6 -2
View File
@@ -38,7 +38,7 @@ def _sheet(grid: dict, name: str) -> dict:
def test_격자_모양(north):
book = north["책"]
assert (book["종류"], book["판"], book["활성"]) == ("통합문서", 1, "s1")
assert [s["이름"] for s in book["시트"]] == ["일위대가", "단가산출"]
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)
@@ -143,7 +143,11 @@ def test_STmate_격자():
data = path.read_bytes()
parsed = hx.read_book(path.name, data)
grid = hg.read(path.name, data, parsed)
assert [s["이름"] for s in grid["책"]["시트"]] == ["일위대가표", "단가산출근거"]
assert [s["이름"] for s in grid["책"]["시트"]] == [
"일위대가표",
"단가산출근거목록표",
"단가산출근거",
]
cells = _sheet(grid, "일위대가표")["칸"]
one = grid["호표"][0]
assert one == {"번": 1, "이름": "녹생토(종자배합)", "행": [5, 10]}
+5 -1
View File
@@ -140,7 +140,11 @@ def test_새_상태_체크_제안(client):
assert dig["대상"][0]["로직"] == "GC000083" and len(dig["후보"]) == 5
assert "특별인부 엑셀 0.3542 · 우리 0.3704" in dig["꼬리표"]
ref = _hopyo(client, 45)["고화매트재료비"]
assert ref["상태"] == "호표" and ref["체크"] is False and ref["글"] == "제46호표 먼저 확인"
assert (
ref["상태"] == "호표"
and ref["체크"] is False
and ref["글"] == "제46호표 — 저장 때 하위로 함께 만듦"
) # 777-1
assert ref["대상"][0]["호표"] == 46 and ref["대상"][0]["단위"] == "㎡"
pct = _hopyo(client, 46)["잡재료비"]
assert pct["상태"] == "%" and pct["체크"] is False
+3 -1
View File
@@ -319,7 +319,9 @@ def test_옛_즉시_확정과_바로_만들기_없음():
save = sheet.split("async function save(")[1].split("async function makeAll(")[0]
assert 'showConfirmDialog(ask, "저장")' in save and "hasDraft(" in save
assert "건너뜀 — 체크 줄 없음" in save and "await makeAll(ready, skipped);" in save
assert "buildOrder(" in sheet and "error instanceof StaleError" in sheet # 하위 먼저 · 409 안내
assert (
"buildOrder(" in sheet and "unmadeKids(" in sheet and "leftAt(" in sheet
) # 하위 먼저 · 함께(777-1)
edit = sheet.split("const editButton =")[1].split("function paintList(")[0]
assert '"수정"' in edit and "e.stopPropagation();" in edit and "openHopyoCheck({" in edit
row = sheet.split("function paintList(")[1]
+4
View File
@@ -297,6 +297,10 @@ export const ui_locales_m2 = {
M02_LeftMakeSk: ["SK 만들기", "Make SK"],
M02_LeftSkNone: ["만든 단가산출 없음 — 고름 대기 그대로", "Nothing made — still waiting"],
M02_LeftChoose: ["고르기", "Pick"],
M02_HopyoKid: ["하위 제{n}호표 {key}(사본 담음)", "Sub sheet {n} {key} (copy inside)"],
M02_HopyoKidNew: ["함께 만듦", "made together"],
M02_HopyoKidFail: ["하위 제{n}호표 실패", "Sub sheet {n} failed"],
M02_HopyoReused: ["같은 내용 일위대가 다시 씀", "Same unit-price reused"],
M02_LeftFind: ["찾기", "Find"],
M02_LeftMachine: ["기계", "Machine"],
M02_LeftNoneTip: [