Merge remote-tracking branches 'origin/main_laptop_1' and 'origin/sub_laptop_2' into sub_laptop_1
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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("까닭")}
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
@@ -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, "상태": "노임", "글": "노임 · 그릇 없음"}
|
||||
|
||||
@@ -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
|
||||
@@ -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 ("연결할", "연결된")}
|
||||
|
||||
@@ -29,7 +29,7 @@ WHY = {
|
||||
"별도계상": "자재 후보 여럿 · 없음",
|
||||
"노임": "같은 이름 노임 없음",
|
||||
"기계": "같은 기계(EQ) 없음 · 여럿",
|
||||
"호표": "하위 호표 확인 전 · 단위 다름",
|
||||
"호표": "하위 호표 단위 다름 · 같은 하위 둘째",
|
||||
"%": "밑수가 다른 줄 금액 합 아님",
|
||||
}
|
||||
|
||||
|
||||
@@ -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:")]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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]["고정"]
|
||||
|
||||
@@ -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]}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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: [
|
||||
|
||||
Reference in New Issue
Block a user