feat(827-3): 불러오기 재료 걸음 3 — 고를 재료 판 · 그 파일 안 고름(별칭 아님) · SK 상세 같은 파일 물음
- 새 M02_San_MatPick: 종 열쇠(이름 · 규격 · 단위 · 단서) · 고름 자리 = 개인 층 실무자료/재료고름/<파일 지문>.json([끝내기] 뒤 남음 · 같은 파일 다시 올리면 그대로) · 판(블록마다 재료찾기 판정 → 종 · 첫 판 한 번 셈 → 산근 저장본 칸) · 만들 때 얹기 · 이미 만든 SK 얹기(가져온 그대로인 줄만 · 바꿈 · 따로 고른 줄 그대로 · 풀면 가져온 그대로)
- 만들기: from-hopyo 가 그 파일 고름째 만듦(저장 안 한 판 고름 = 만들 때 저장) · 원본 지문 · 고른 줄 = 이음 + 재료찾기.고름 · 문서 셈 우리 값(엑셀 값 기억 없음) · 별도계상 = 품목만(줄 이음 · 단가 0 그대로)
- 문서 머리 가져온.재료(판정 · 후보 · 단서 · 처음 연결) · 가져온.지문
- 길: GET/POST hopyo-materials/{HP}(판 · [저장] · 만든 것 적용은 물음 예일 때만) · GET/POST material-picks/{지문}(SK 상세 물음)
- 화면: 단가산출 목록 위 「고를 재료」 판(후보 고름 · 찾기 · 고름 풀기 · n 블록에 적용 · 이어진 재료 접힘 · [저장]) · 저장 안 한 고름 = 떠날 때 물음 · 줄 쌓기 재료 줄 판정 배지 · 풍선말(판정 · 단서 · 후보) · 이름 단추 · [이음 고르기] 뒤 「같은 파일 n 줄」 물음
- 별도계상 설명 = 「별도계상 · 품목: 이름 규격」(엔진 이은 품목은 자재표 이름) — test_827_2 기대 글 고침(품목 이음 → 품목 이름)
- 시험 test_827_3 셋(파일 고름 블록 둘 · 다른 파일 0 · 별칭 · 마스터 그대로 · 풀기 · 다시 올림 유지 · 물음 예일 때만 · 따로 바꾼 줄 보존 · 별도계상 품목 + 단가 0)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -37,6 +37,7 @@ import {
|
||||
type StackItem,
|
||||
} from "../common_util/common_util_line_stack";
|
||||
import { placeNames } from "../common_util/common_util_line_stack_copy";
|
||||
import { askFileWide, materialMark, matEntry } from "./line_stack_material";
|
||||
import {
|
||||
draftsOf,
|
||||
machineNow,
|
||||
@@ -170,11 +171,13 @@ function moneyBody(h: EditHooks, i: number): HTMLElement[] {
|
||||
// 가져온 기계 줄(812-9) — 찾기 판정 · 조회어 = 기종 이름만(규격 뺌 · 이은 우리 기계 › 정규화 이름)
|
||||
const found = kind === "기계" ? machineFound(d, own) : undefined;
|
||||
const query = found?.찾기어 ?? String(mat?.검색어 ?? line.이름 ?? "");
|
||||
const swap = (p: Picked): void =>
|
||||
const swap = (p: Picked): void => {
|
||||
h.change((doc) => {
|
||||
repick(doc, i, own, kind, p);
|
||||
pruneDrafts(doc); // 827-1 안 쓰게 된 초안 뺌
|
||||
});
|
||||
if (kind === "재료") void askFileWide(h.machine, d, own, p); // 827-3 같은 파일 같은 재료
|
||||
};
|
||||
const pick = createButton({
|
||||
label: (size && !name.includes(size) ? `${name} ${size}` : name) || "—",
|
||||
variant: "ghost",
|
||||
@@ -202,6 +205,11 @@ function moneyBody(h: EditHooks, i: number): HTMLElement[] {
|
||||
head.push(pick);
|
||||
const mark = machineMark(found, link);
|
||||
if (mark) head.push(mark);
|
||||
const matMark = kind === "재료" ? materialMark(matEntry(d, own), link) : null; // 827-3 판정 · 단서
|
||||
if (matMark) {
|
||||
head.push(matMark);
|
||||
pick.title += ` — ${matMark.title}`;
|
||||
}
|
||||
// 823-1 가져온 줄 바꿔 끼움 = 우리 값 · 「바꿈」 + 되돌리기(원래 것 · 엑셀 값)
|
||||
const sw = (link as { 바꿈?: { 이름?: string; 규격?: string } }).바꿈;
|
||||
if (sw) {
|
||||
@@ -220,11 +228,15 @@ function moneyBody(h: EditHooks, i: number): HTMLElement[] {
|
||||
back.dataset.unswap = String(i);
|
||||
head.push(tag, back);
|
||||
}
|
||||
} else
|
||||
} else {
|
||||
head.push(
|
||||
field(lt("Name"), line.이름, (t) => put((l) => (l.이름 = t)), "m"),
|
||||
field(lt("Price"), shown(line.단가), (t) => put((l) => (l.단가 = real(t))), "l"),
|
||||
);
|
||||
const sym = String(line.단가 ?? "").replace(/\.계$/, ""); // 827-3 못 이은 재료 판정 · 별도계상 품목
|
||||
const unlinked = materialMark(matEntry(d, sym), d.변수?.find((x) => varId(x) === sym)?.연결 ?? {}); // prettier-ignore
|
||||
if (unlinked) head.push(unlinked);
|
||||
}
|
||||
const why = (h.answer.줄?.[i] as { 까닭?: string } | undefined)?.까닭;
|
||||
// 율 밑 번지(복사해 온 조각) — 칸 아래 읽는 이름(804-5)
|
||||
const places = /번지\./.test(`${line.단가} ${line.수량 ?? ""}`) ? placeNames(d, h.items) : null;
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
/* =============================================================================
|
||||
* line_stack_material.ts
|
||||
* 줄 쌓기 가져온 재료 줄 판정 표지 · 같은 파일 물음(PLAN 827-3 · 요구 811-2 · 812-1)
|
||||
* 표지 = 문서 머리 `가져온.재료`(서버 `M02_San_MatPick.entry`) — 아직 못 이은 줄 「규격 없음 — 고르기」 ·
|
||||
* 「후보 여럿」 · 「우리 표에 없음」 / 그 파일 고름 줄 「파일 고름」 / 별도계상 품목 · 풍선말 = 판정 · 방법 · 단서 · 후보
|
||||
* 물음 = 이름 단추로 고른 뒤 한 번 — 「같은 파일의 같은 재료 n 줄에도」 예 = 개인 층 그 파일 고름 +
|
||||
* 그 파일 다른 단가산출의 가져온 그대로인 줄(서버 `…/material-picks/{지문}`) · 이 줄 = 화면 [저장] · 별칭 아님
|
||||
* ========================================================================== */
|
||||
|
||||
import { createBadge } from "@ui/ui_template_badge";
|
||||
import { showToast } from "@ui/ui_template_elements";
|
||||
import { askConfirm } from "@ui/ui_template_modal";
|
||||
import type { FormulaDoc } from "../common_util/common_util_formula_doc";
|
||||
import type { Picked } from "../common_util/common_util_line_stack_pick";
|
||||
import { call, type Layer } from "../M02_MasterTemplete/M02_MasterTemplete_Api_Fetch";
|
||||
import { at } from "../M02_MasterTemplete/M02_MasterTemplete_Hopyo_Api";
|
||||
import type { MachineCtx } from "./line_stack_machine";
|
||||
|
||||
interface Cand {
|
||||
키: string;
|
||||
이름?: string;
|
||||
규격?: string;
|
||||
단위?: string;
|
||||
}
|
||||
|
||||
export interface MatEntry {
|
||||
기호: string;
|
||||
종: string;
|
||||
원래: { 이름: string; 규격: string; 단위: string };
|
||||
판정: string;
|
||||
방법?: string;
|
||||
키?: string | null;
|
||||
후보?: Cand[];
|
||||
단서?: string[];
|
||||
별도계상?: boolean;
|
||||
고름?: Cand;
|
||||
}
|
||||
|
||||
interface Head {
|
||||
재료?: MatEntry[];
|
||||
지문?: string;
|
||||
파일?: string;
|
||||
}
|
||||
|
||||
const LINKED = "이어짐";
|
||||
/** 같은 파일 고름을 저장했음(문서 이벤트) — 불러오기 판이 다시 읽음 */
|
||||
export const MAT_PICKED = "aislo:mat-picked";
|
||||
const enc = encodeURIComponent;
|
||||
const headOf = (d: FormulaDoc): Head | undefined => (d as { 가져온?: Head }).가져온;
|
||||
const text = (c: { 이름?: string; 규격?: string }): string =>
|
||||
`${c.이름 ?? ""} ${c.규격 ?? ""}`.trim();
|
||||
|
||||
/** 그 단가 기호 줄의 재료 판정 칸 */
|
||||
export const matEntry = (d: FormulaDoc, sym: string | undefined): MatEntry | undefined =>
|
||||
sym ? headOf(d)?.재료?.find((x) => x.기호 === sym) : undefined;
|
||||
|
||||
/** 풍선말 글 — 판정 · 방법 · 단서 · 후보(앞 다섯) */
|
||||
export function matTip(e: MatEntry): string {
|
||||
const cands = (e.후보 ?? []).slice(0, 5).map((c, i) => `${i ? "" : "1 순위 "}${text(c)}`);
|
||||
return [
|
||||
`가져온 「${text(e.원래)}」 — ${e.판정}${e.방법 ? ` (${e.방법})` : ""}`,
|
||||
...(e.고름 ? [`파일 고름: ${text(e.고름)}`] : []),
|
||||
...(e.단서 ?? []).map((x) => `단서: ${x}`),
|
||||
...(cands.length ? [`후보: ${cands.join(" · ")}`] : []),
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
/** 줄 표지 — 못 이은 줄 판정 · 파일 고름 · 별도계상 품목(이어진 줄 = 없음) */
|
||||
export function materialMark(
|
||||
e: MatEntry | undefined,
|
||||
link: { 갈래?: string; 키?: string },
|
||||
): HTMLElement | null {
|
||||
if (!e) return null;
|
||||
const filePick = !!e.고름 && link.키 === e.고름.키;
|
||||
const label = e.별도계상
|
||||
? `별도계상 · ${e.고름 ? `품목 ${text(e.고름)}` : e.키 ? "품목 이음" : e.판정}`
|
||||
: filePick
|
||||
? "파일 고름"
|
||||
: e.판정 !== LINKED && link.갈래 === "숫자"
|
||||
? e.판정
|
||||
: "";
|
||||
if (!label) return null;
|
||||
const done = filePick || (e.별도계상 && !!(e.고름 || e.키));
|
||||
const badge = createBadge(label, done ? "info" : e.판정 === "우리 표에 없음" ? "neutral" : "warning", { size: 18 }); // prettier-ignore
|
||||
badge.title = matTip(e);
|
||||
badge.dataset.matMark = e.판정;
|
||||
return badge;
|
||||
}
|
||||
|
||||
/** 같은 파일 물음 몸 — 층 · 이 SK · 파일 지문 · 종 · 가져온 글 · 고른 것 */
|
||||
export interface SameFile {
|
||||
scope: { layer: Layer; owner: string | null };
|
||||
me: string;
|
||||
지문: string;
|
||||
파일: string;
|
||||
종: string;
|
||||
원래: string;
|
||||
p: Picked;
|
||||
}
|
||||
|
||||
/** 이름 단추로 고른 뒤 — 같은 파일 같은 재료에도(물음 하나 · 예일 때만 서버) */
|
||||
export async function askFileWide(
|
||||
ctx: MachineCtx | undefined,
|
||||
d: FormulaDoc,
|
||||
sym: string | undefined,
|
||||
p: Picked,
|
||||
): Promise<void> {
|
||||
const e = matEntry(d, sym);
|
||||
const head = headOf(d);
|
||||
if (!e || e.별도계상 || !head?.지문 || !ctx?.scope || !ctx.me || p.키 === e.키) return;
|
||||
await askSameFile({ scope: ctx.scope, me: ctx.me, 지문: head.지문, 파일: head.파일 ?? "", 종: e.종, 원래: text(e.원래), p }); // prettier-ignore
|
||||
}
|
||||
|
||||
/** 같은 파일 같은 재료 물음 — 예 = 개인 층 그 파일 고름 + 그 파일 다른 SK(가져온 그대로인 줄) · 이 SK = 화면 [저장] */
|
||||
export async function askSameFile(a: SameFile): Promise<void> {
|
||||
const path = `/material-picks/${enc(a.지문)}`;
|
||||
const got = await call<{ 수: { 블록: number; 줄: number } | null; 만든: string[] }>(
|
||||
at(a.scope, `${path}?종=${enc(a.종)}&me=${enc(a.me)}`),
|
||||
).catch(() => null);
|
||||
if (!got) return;
|
||||
const many = got.수 ? `${got.수.줄} 줄(${got.수.블록} 블록)` : "줄";
|
||||
const made = got.만든.length ? ` · 이미 만든 단가산출 ${got.만든.length} 건의 가져온 그대로인 줄도 바뀜` : ""; // prettier-ignore
|
||||
const ok = await askConfirm({
|
||||
message: `같은 파일(${a.파일})의 같은 재료 「${a.원래}」 ${many}에도 「${text(a.p)}」 적용할까? — 다음에 그 파일을 올리면 고른 것이 보임${made} · 별칭 아님`,
|
||||
okLabel: "같은 파일에 적용",
|
||||
cancelLabel: "이 줄만",
|
||||
});
|
||||
if (!ok) return;
|
||||
try {
|
||||
const out = await call<{ 고침: string[] }>(at(a.scope, path), {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
종: a.종,
|
||||
고름: { 키: a.p.키, 이름: a.p.이름, 규격: a.p.규격 ?? "", 단위: a.p.단위 ?? "" },
|
||||
파일: a.파일,
|
||||
빼기: [a.me],
|
||||
}),
|
||||
});
|
||||
document.dispatchEvent(new CustomEvent(MAT_PICKED)); // 불러오기 「고를 재료」 판 다시 읽기
|
||||
showToast(`같은 파일 고름 저장 · 단가산출 ${out.고침.length} 건 고침 — 이 줄은 [저장] 때`, "success"); // prettier-ignore
|
||||
} catch (err) {
|
||||
showToast(err instanceof Error ? err.message : String(err), "error");
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ from typing import Any
|
||||
from M02_MasterTemplete import M02_Doc_Copy as dc
|
||||
from M02_MasterTemplete import M02_Doc_Import_Shape as shape
|
||||
from M02_MasterTemplete import M02_Labor_Ref as refs
|
||||
from M02_MasterTemplete import M02_San_MatPick as mp
|
||||
from M02_MasterTemplete import M02_Sangeun_Doc as sd
|
||||
from M02_MasterTemplete import M02_Sangeun_Imported as imp
|
||||
|
||||
@@ -36,7 +37,8 @@ HEAD = (
|
||||
"원코드",
|
||||
"규격",
|
||||
"확정",
|
||||
) # 뒤 셋 = 원파일(810-3)
|
||||
"지문",
|
||||
) # 규격 · 확정 = 원파일(810-3) · 지문 = 올린 파일(827-3 재료 고름)
|
||||
GUESS = "추정" # 원파일 품셈 조건 글 표지(810-2 `M02_Doc_NativeEst.GUESS`)
|
||||
SUB = "원파일 소계" # 원파일 소계 덧줄(810-2 `M02_Doc_NativeEst`) — 합 밖 · 비목 칸 비움
|
||||
PICK = "소요량" # 고른 표 칸 기호 이름 머리
|
||||
@@ -304,6 +306,7 @@ def _choice(key: str, below: str, items: list[str], value: str) -> dict:
|
||||
|
||||
|
||||
MACHINES = "_기계" # 문서 셈 동안 잠깐 — `derive` 가 머리 `가져온.기계` 로 옮김
|
||||
MATS = "_재료" # 같은 꼴 — 머리 `가져온.재료`(827-3 `M02_San_MatPick.entry`)
|
||||
|
||||
|
||||
def _machine_entry(item: dict, sym: str) -> dict:
|
||||
@@ -329,12 +332,15 @@ def _attach(doc: dict, row: dict, ids: list[str], src: dict[str, dict]) -> None:
|
||||
기계 찾기 판정(812-9) = 문서 `가져온.기계`(단가 기호마다 · 화면 고르기 · 저장 때 별칭)."""
|
||||
by_id = {dc.md.var_id(v): v for v in doc.get("변수") or []}
|
||||
found: list[dict] = doc.setdefault(MACHINES, [])
|
||||
mats: list[dict] = doc.setdefault(MATS, [])
|
||||
for at, line in enumerate((doc.get("금액") or [])[: len(row["호표"])]):
|
||||
item = src.get(ids[at]) if at < len(ids) else None
|
||||
price = str(line.get("단가") or "")
|
||||
var = by_id.get(price.removesuffix(".계"))
|
||||
if item is not None and var is not None and (item.get("자원") or {}).get("찾기"):
|
||||
found.append(_machine_entry(item, dc.md.var_id(var)))
|
||||
if item is not None and var is not None and (one := mp.entry(item, dc.md.var_id(var), _excel(item))): # fmt: skip
|
||||
mats.append(one) # 827-3 재료 찾기 판정 · 그 파일 고름
|
||||
if item is None or var is None or (var.get("연결") or {}).get("갈래") != "숫자":
|
||||
continue
|
||||
excel = _excel(item)
|
||||
@@ -361,7 +367,7 @@ def _attach(doc: dict, row: dict, ids: list[str], src: dict[str, dict]) -> None:
|
||||
if item.get("참조비목") and isinstance(excel, dict):
|
||||
excel = excel[item["참조비목"]]
|
||||
line["단가"], line["비목"] = dc.md.var_id(var), item["참조비목"]
|
||||
var["연결"] = {**link, XL: excel}
|
||||
var["연결"] = link if mp.chosen(item) else {**link, XL: excel} # 827-3 고른 줄 = 우리 값
|
||||
|
||||
|
||||
def _numbers(doc: dict, keep: dict) -> None:
|
||||
@@ -419,12 +425,16 @@ def derive(sk: dict, ctx: tuple | None = None, whole: Any = None) -> dict:
|
||||
_empty(doc) # 빈 산근 = 엑셀 0 원(줄 없음 합) — 「—」 아님
|
||||
marks.append(EMPTY)
|
||||
found = doc.pop(MACHINES, None)
|
||||
mats = doc.pop(MATS, None)
|
||||
head |= {"기계": found} if found else {} # 812-9 기계 찾기 판정(줄 쌓기 고르기 · 저장 때 별칭)
|
||||
doc["가져온"] = head | {"표지": marks} | ({"알림": notes} if notes else {})
|
||||
shape.shape(doc, meta) # 809-1 · 요구 704 — 지은 이름 → 엑셀 글 머리 · EST 기계 세 줄 = 한 줄
|
||||
if found: # 세 줄을 한 줄로 합쳐 사라진 기호의 기계 판정은 뺌
|
||||
alive = {dc.md.var_id(v) for v in doc.get("변수") or []}
|
||||
doc["가져온"]["기계"] = [x for x in found if x["기호"] in alive]
|
||||
if mats: # 827-3 재료 찾기 판정(줄 쌓기 풍선말 · 이미 만든 SK 고름 얹기)
|
||||
alive = {dc.md.var_id(v) for v in doc.get("변수") or []}
|
||||
doc["가져온"]["재료"] = [x for x in mats if x["기호"] in alive]
|
||||
return {**doc, "키": str(sk.get("키") or key), "이름": str(sk.get("이름") or doc.get("이름"))}
|
||||
|
||||
|
||||
|
||||
@@ -26,11 +26,12 @@ 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_Marks as hopyo_marks
|
||||
from M02_MasterTemplete import M02_Hopyo_View as hopyo_view_
|
||||
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_Store as hopyo_store
|
||||
from M02_MasterTemplete import M02_Hopyo_View as hopyo_view_
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
||||
from M02_MasterTemplete import M02_San_MatPick as mat_pick
|
||||
from M02_MasterTemplete import M02_Sangeun_FromHopyo as from_hopyo
|
||||
from M02_MasterTemplete.M02_MasterTemplete_Router_Items import Layer, Session, _call, _root
|
||||
|
||||
@@ -330,3 +331,105 @@ def hopyo_marks_read(
|
||||
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)})
|
||||
|
||||
|
||||
# ── 불러오기 재료 고름(827-3 · 별칭 아님 · 고름 자리 = 개인 층) ──────────────────
|
||||
class MatPickBody(_Strict):
|
||||
"""`고름` {종: 고른 것 | null(풂)} · `만든적용` = 이미 만든 SK 에도(물음 예) · `종` = 얹을 종(없으면 `고름` 의 종)."""
|
||||
|
||||
고름: dict[str, dict[str, Any] | None] = Field(default_factory=dict, max_length=500)
|
||||
만든적용: bool = False
|
||||
종: list[str] | None = Field(default=None, max_length=500)
|
||||
|
||||
|
||||
@router.get("/layers/{layer}/hopyo-materials/{key}")
|
||||
def hopyo_materials(
|
||||
layer: Layer,
|
||||
key: str,
|
||||
owner: str | None = None,
|
||||
to: Layer | None = None,
|
||||
to_owner: str | None = None,
|
||||
session: Session = Depends(verify_session),
|
||||
) -> dict:
|
||||
"""고를 재료 판 — `{지문, 파일, 종 [판정 · 후보 · 단서 · 블록 · 고름?], 만든 [SK]}`(첫 판 = 블록 셈 한 번)."""
|
||||
folder = _root(session, layer, owner)
|
||||
target = _root(session, to, to_owner) if to else folder
|
||||
mine = _root(session, "personal", None)
|
||||
|
||||
def run() -> dict:
|
||||
fp, name = mat_pick.fingerprint(folder, key)
|
||||
picks = mat_pick.read(mine, fp)["고름"]
|
||||
kinds = [g | ({"고름": picks[g["종"]]} if g["종"] in picks else {}) for g in mat_pick.census(folder, key)] # fmt: skip
|
||||
return {"지문": fp, "파일": name, "종": kinds, "만든": mat_pick.made(target, fp)}
|
||||
|
||||
return _call(folder, run)
|
||||
|
||||
|
||||
@router.post("/layers/{layer}/hopyo-materials/{key}")
|
||||
def hopyo_materials_save(
|
||||
layer: Layer,
|
||||
key: str,
|
||||
body: MatPickBody,
|
||||
owner: str | None = None,
|
||||
to: Layer | None = None,
|
||||
to_owner: str | None = None,
|
||||
session: Session = Depends(verify_session),
|
||||
) -> dict:
|
||||
"""[저장] — 고름을 개인 층 그 파일 자리에 · 만든적용 = 그 파일 SK(만들 층)의 가져온 그대로인 줄에도 → `{고름, 고침?, 그대로?}`."""
|
||||
folder = _root(session, layer, owner)
|
||||
mine = _root(session, "personal", None, write=True)
|
||||
fp, name = _call(folder, mat_pick.fingerprint, folder, key)
|
||||
out: dict[str, Any] = {"고름": _call(mine, mat_pick.write, mine, fp, name, body.고름)}
|
||||
if body.만든적용:
|
||||
target = _root(session, to or layer, to_owner if to else owner, write=True)
|
||||
kinds = set(body.종 if body.종 is not None else body.고름)
|
||||
out |= _call(target, mat_pick.apply_made, fp, out["고름"], kinds)
|
||||
return out
|
||||
|
||||
|
||||
class SkPickBody(_Strict):
|
||||
"""SK 상세에서 고른 재료 줄 → 같은 파일 같은 종(물음 예) — `빼기` = 지금 SK(화면 [저장] 몫)."""
|
||||
|
||||
종: str = Field(max_length=4000)
|
||||
고름: dict[str, Any] | None = None
|
||||
파일: str = Field(default="", max_length=200)
|
||||
빼기: list[str] = Field(default_factory=list, max_length=20)
|
||||
|
||||
|
||||
@router.get("/layers/{layer}/material-picks/{fp}")
|
||||
def material_pick_uses(
|
||||
layer: Layer,
|
||||
fp: str,
|
||||
종: str,
|
||||
owner: str | None = None,
|
||||
me: str = "",
|
||||
session: Session = Depends(verify_session),
|
||||
) -> dict:
|
||||
"""SK 상세 물음 — `{수 {블록, 줄}(작업 중 HP 판 · 없으면 null), 만든 [그 종 줄 있는 다른 SK], 고름?}`."""
|
||||
folder = _root(session, layer, owner)
|
||||
mine = _root(session, "personal", None)
|
||||
|
||||
def run() -> dict:
|
||||
n = mat_pick.tally(folder, fp, 종)
|
||||
n = n if n is not None or mine == folder else mat_pick.tally(mine, fp, 종)
|
||||
return {"수": n, "만든": mat_pick.uses(fp, 종, (me,)), "고름": mat_pick.read(mine, fp)["고름"].get(종)} # fmt: skip
|
||||
|
||||
return _call(folder, run)
|
||||
|
||||
|
||||
@router.post("/layers/{layer}/material-picks/{fp}")
|
||||
def material_pick_save(
|
||||
layer: Layer,
|
||||
fp: str,
|
||||
body: SkPickBody,
|
||||
owner: str | None = None,
|
||||
session: Session = Depends(verify_session),
|
||||
) -> dict:
|
||||
"""그 종 고름을 개인 층 파일 자리에 + 이 층 그 파일 다른 SK 의 가져온 그대로인 줄에 → `{고름, 고침, 그대로}`."""
|
||||
folder = _root(session, layer, owner, write=True)
|
||||
mine = _root(session, "personal", None, write=True)
|
||||
picks = _call(mine, mat_pick.write, mine, fp, body.파일, {body.종: body.고름})
|
||||
return {
|
||||
"고름": picks,
|
||||
**_call(folder, mat_pick.apply_made, fp, picks, {body.종}, tuple(body.빼기)),
|
||||
}
|
||||
|
||||
@@ -191,6 +191,22 @@
|
||||
box-shadow: inset 3px 0 0 var(--color-accent);
|
||||
}
|
||||
|
||||
/* 827-3 고를 재료 판 — 단가산출 목록 위 · 판만 굴림(목록은 늘 보임) */
|
||||
.m02-hopyo-matpick {
|
||||
flex: none;
|
||||
max-height: 45%;
|
||||
overflow: auto;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: var(--spacing-4) var(--spacing-8);
|
||||
}
|
||||
|
||||
.m02-hopyo-matpick__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.m02-hopyo-sheet__list {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
|
||||
@@ -47,7 +47,7 @@ export interface HopyoImport {
|
||||
root: HTMLElement;
|
||||
/** 화면 떠남 — 이 화면에서 올리거나 이은 파일 지움(743-1) */
|
||||
leave: () => void;
|
||||
/** 안 옮긴 고름 — 판정 고름 캐시 걷음(809-1) 뒤 늘 거짓 */
|
||||
/** 저장 안 한 재료 고름(827-3) — 판정 고름 캐시는 809-1 에서 걷음 */
|
||||
dirty: () => boolean;
|
||||
}
|
||||
|
||||
@@ -121,6 +121,6 @@ export function buildHopyoImport(a: HopyoImportArgs): HopyoImport {
|
||||
return {
|
||||
root,
|
||||
leave,
|
||||
dirty: () => false,
|
||||
dirty: () => body?.matDirty() ?? false, // 827-3 저장 안 한 재료 고름
|
||||
};
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { askConfirm, openModal } from "@ui/ui_template_modal";
|
||||
import { call, readTemplate, saveTemplate } from "./M02_MasterTemplete_Api_Fetch";
|
||||
import { openFindPicker } from "./M02_MasterTemplete_Find_Pick";
|
||||
import { askSameFile } from "../A00_Common/line_stack_material";
|
||||
import { at, type HopyoScope } from "./M02_MasterTemplete_Hopyo_Api";
|
||||
import { importedPanel, type PanelState } from "./M02_MasterTemplete_Hopyo_ImportedPanel";
|
||||
import { openQtyLinks, type QtyRow, type QtyTally } from "./M02_MasterTemplete_Hopyo_QtyLinks";
|
||||
@@ -44,6 +45,10 @@ export interface ImportedOptions {
|
||||
/** SK 를 만들 층 */
|
||||
target: HopyoScope;
|
||||
canWrite: boolean;
|
||||
/** 827-3 저장 안 한 재료 고름(만들 때 서버가 저장 · 그 고름째 만듦) */
|
||||
picks?: Record<string, unknown>;
|
||||
/** 만들었음(재료 판 다시 읽기) */
|
||||
onMade?: () => void;
|
||||
}
|
||||
|
||||
interface Made {
|
||||
@@ -54,14 +59,14 @@ interface Made {
|
||||
const fromHopyo = (o: ImportedOptions): Promise<Made> =>
|
||||
call(at(o.target, "/sangeun/from-hopyo"), {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ 층: o.file.layer, ...(o.file.owner ? { 주인: o.file.owner } : {}), 키: o.key, 번: o.no }), // prettier-ignore
|
||||
body: JSON.stringify({ 층: o.file.layer, ...(o.file.owner ? { 주인: o.file.owner } : {}), 키: o.key, 번: o.no, ...(o.picks ? { 고름: o.picks } : {}) }), // prettier-ignore
|
||||
});
|
||||
|
||||
interface EditAnswer {
|
||||
문서: ImportedDoc;
|
||||
전?: Money4 | null;
|
||||
후?: Money4 | null;
|
||||
이음없음?: { 자리: string; 이름: string; 까닭?: string; 견적?: boolean }[];
|
||||
이음없음?: { 자리: string; 이름: string; 까닭?: string; 견적?: boolean; 종?: string }[];
|
||||
/** qty 답(797-2) */
|
||||
줄?: QtyRow[];
|
||||
집계?: QtyTally;
|
||||
@@ -168,12 +173,21 @@ export function openImported(o: ImportedOptions): void {
|
||||
|
||||
function link(spot: string, name: string): void {
|
||||
const done = (code: string): void => void run("link", { 자리: spot, 이음: code }).then(() => unlinked()); // prettier-ignore
|
||||
const kind = st.unlinked.find((u) => u.자리 === spot)?.종; // 827-3 재료 줄 = 같은 파일 물음
|
||||
const src = (st.doc.가져온 as { 원본?: { 지문?: string; 파일?: string } } | undefined)?.원본;
|
||||
const same = (e: { 코드: string; 이름: string; 규격?: string; 단위?: string }): void => {
|
||||
if (kind && src?.지문 && !readOnly)
|
||||
void askSameFile({ scope: o.target, me: sk, 지문: src.지문, 파일: src.파일 ?? "", 종: kind, 원래: name, p: { ...e, 키: e.코드 } }); // prettier-ignore
|
||||
};
|
||||
openFindPicker({
|
||||
layer: o.target.layer,
|
||||
owner: o.target.owner,
|
||||
query: name,
|
||||
logic: (x) => done(`로직(${x.키})`),
|
||||
element: (e) => done(e.코드),
|
||||
element: (e) => {
|
||||
done(e.코드);
|
||||
same(e);
|
||||
},
|
||||
machine: (e) => done(e.코드),
|
||||
oil: (e) => done(e.코드),
|
||||
});
|
||||
@@ -202,6 +216,7 @@ export function openImported(o: ImportedOptions): void {
|
||||
const stop = showLoading(root);
|
||||
try {
|
||||
const made = await fromHopyo(o);
|
||||
o.onMade?.();
|
||||
sk = made.이름;
|
||||
showToast((made.다시씀 ? t("Reused") : t("Made")).replace("{k}", sk), "info");
|
||||
const got = await readTemplate(o.target.layer, "sangeun", sk, null, o.target.owner);
|
||||
|
||||
@@ -62,7 +62,7 @@ export const tx = (k: keyof typeof TEXT): string =>
|
||||
export interface PanelState {
|
||||
doc: ImportedDoc;
|
||||
/** 이음 없는 자원(서버 price 답) */
|
||||
unlinked: { 자리: string; 이름: string; 까닭?: string; 견적?: boolean }[];
|
||||
unlinked: { 자리: string; 이름: string; 까닭?: string; 견적?: boolean; 종?: string }[];
|
||||
/** 소요량 · 계수 이음 집계(서버 qty 답 · 797-2) */
|
||||
qty: QtyTally | null;
|
||||
/** 마지막 단가 바꿈 전후 */
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_Hopyo_MatPick.ts
|
||||
* 불러오기 「고를 재료」 판(PLAN 827-3 · 요구 811-2 · 812-1) — 단가산출 목록(`Hopyo_SheetSk`) 머리에 붙음.
|
||||
* · 그 파일 재료 줄을 종(이름 · 규격 · 단위 · 단서)으로 묶어 판정 · 쓰는 블록 수 · 단서 글 · 후보(엔진 차례 · 1 순위)
|
||||
* = 서버 `…/hopyo-materials/{키}`(첫 판 = 블록 셈 한 번 · 그 뒤 저장본)
|
||||
* · 후보 고름 · [찾기](재료 찾기 창 `openStackPick`) → 그 파일 같은 종 줄 전부 · [고름 풀기]
|
||||
* · 「우리 표에 없음」 = 안 골라도 됨(가져온 값) · 이어진 재료 = 접힌 목록(방법 · 단서 — 틀린 이음도 같은 길로 바꿈)
|
||||
* · 고름 = 화면 캐시 → [저장](개인 층 그 파일 자리 · 이미 만든 단가산출 n 건 적용은 물어 고를 때만) ·
|
||||
* 저장 전 블록을 만들면 그 고름째 만듦(`pending` → from-hopyo 몸 · 서버가 저장) · 별칭 · 마스터에 안 씀
|
||||
* ========================================================================== */
|
||||
|
||||
import { createBadge, type BadgeTone } from "@ui/ui_template_badge";
|
||||
import { attachCollapsible, createCollapsibleHead } from "@ui/ui_template_collapsible";
|
||||
import { createButton, el, showToast } from "@ui/ui_template_elements";
|
||||
import { createSelectField } from "@ui/ui_template_input";
|
||||
import { createListRow } from "@ui/ui_template_list_row";
|
||||
import { askChoice } from "@ui/ui_template_modal";
|
||||
import { createText } from "@ui/ui_template_text";
|
||||
import { MAT_PICKED } from "../A00_Common/line_stack_material";
|
||||
import { openStackPick } from "../A00_Common/line_stack_pick";
|
||||
import { call } from "./M02_MasterTemplete_Api_Fetch";
|
||||
import { at, type HopyoScope } from "./M02_MasterTemplete_Hopyo_Api";
|
||||
|
||||
export interface MatCand {
|
||||
키: string;
|
||||
이름: string;
|
||||
규격: string;
|
||||
단위: string;
|
||||
단서?: string;
|
||||
}
|
||||
|
||||
interface MatKind {
|
||||
종: string;
|
||||
이름: string;
|
||||
규격: string;
|
||||
단위: string;
|
||||
판정: string;
|
||||
방법: string;
|
||||
키: string | null;
|
||||
후보: MatCand[];
|
||||
단서: string[];
|
||||
별도계상: boolean;
|
||||
블록: number[];
|
||||
줄: number;
|
||||
고름?: MatCand;
|
||||
}
|
||||
|
||||
interface MatBoard {
|
||||
지문: string;
|
||||
파일: string;
|
||||
종: MatKind[];
|
||||
만든: string[];
|
||||
}
|
||||
|
||||
export type MatPicks = Record<string, MatCand | null>;
|
||||
|
||||
const LINKED = "이어짐";
|
||||
const TONE: Record<string, BadgeTone> = { [LINKED]: "success", "우리 표에 없음": "neutral" };
|
||||
const enc = encodeURIComponent;
|
||||
const why = (e: unknown): string => (e instanceof Error ? e.message : String(e));
|
||||
const label = (c: { 이름: string; 규격: string }): string => `${c.이름} ${c.규격}`.trim();
|
||||
|
||||
export function createMatPick(opt: { scope: HopyoScope; key: string; target: HopyoScope }) {
|
||||
const to = `?to=${opt.target.layer}${opt.target.owner ? `&to_owner=${enc(opt.target.owner)}` : ""}`;
|
||||
const url = at(opt.scope, `/hopyo-materials/${enc(opt.key)}${to}`);
|
||||
const title = createCollapsibleHead({ title: "고를 재료", sub: "고른 것은 이 파일 안에서만 · 별칭 아님 · 「우리 표에 없음」은 안 골라도 됨" }); // prettier-ignore
|
||||
const body = el("div", { className: "m02-hopyo-matpick__body" });
|
||||
const root = el("section", {
|
||||
className: "ui-collapsible m02-hopyo-matpick",
|
||||
attrs: { "data-matpick": opt.key },
|
||||
children: [title.root, body],
|
||||
});
|
||||
attachCollapsible(root);
|
||||
document.addEventListener(MAT_PICKED, () => asked && root.isConnected && void load()); // SK 상세에서 같은 파일 고름
|
||||
let board: MatBoard | null = null;
|
||||
let asked = false;
|
||||
let busy = false;
|
||||
let linkedOpen = false;
|
||||
const edits = new Map<string, MatCand | null>();
|
||||
|
||||
const now = (k: MatKind): MatCand | null =>
|
||||
edits.has(k.종) ? (edits.get(k.종) ?? null) : (k.고름 ?? null);
|
||||
const set = (k: MatKind, c: MatCand | null): void => {
|
||||
if ((c?.키 ?? null) === (k.고름?.키 ?? null)) edits.delete(k.종);
|
||||
else edits.set(k.종, c);
|
||||
draw();
|
||||
};
|
||||
|
||||
function controls(k: MatKind): HTMLElement {
|
||||
const mine = now(k);
|
||||
const parts: HTMLElement[] = [];
|
||||
if (k.후보.length) {
|
||||
const pick = createSelectField({
|
||||
options: [
|
||||
{ value: "", text: mine ? `고름: ${label(mine)}` : "후보에서 고름" },
|
||||
...k.후보.map((c, i) => ({
|
||||
value: String(i),
|
||||
text: `${i ? "" : "1 순위 · "}${label(c)} ${c.단위 ? `(${c.단위})` : ""}`.trim(),
|
||||
})),
|
||||
],
|
||||
compact: true,
|
||||
size: 24,
|
||||
ariaLabel: "후보에서 고름",
|
||||
onChange: (v) => v && set(k, k.후보[Number(v)]),
|
||||
});
|
||||
pick.select.dataset.matCands = k.종;
|
||||
parts.push(pick.root);
|
||||
}
|
||||
const find = createButton({
|
||||
label: "찾기",
|
||||
variant: "ghost",
|
||||
size: 24,
|
||||
iconStart: "search",
|
||||
onClick: () =>
|
||||
openStackPick(
|
||||
"재료",
|
||||
(p) => set(k, { 키: p.키, 이름: p.이름, 규격: p.규격 ?? "", 단위: p.단위 ?? "" }),
|
||||
undefined,
|
||||
k.이름,
|
||||
),
|
||||
});
|
||||
find.dataset.matFind = k.종;
|
||||
parts.push(find);
|
||||
if (mine) {
|
||||
const tag = createBadge(`고름: ${label(mine)} · ${k.블록.length} 블록에 적용`, "info", { size: 18 }); // prettier-ignore
|
||||
tag.dataset.matPicked = k.종;
|
||||
const undo = createButton({ label: "고름 풀기", variant: "ghost", size: 24, iconStart: "undo", onClick: () => set(k, null) }); // prettier-ignore
|
||||
undo.dataset.matUnpick = k.종;
|
||||
parts.push(tag, undo);
|
||||
}
|
||||
return el("div", { className: "ui-toolbar", children: parts });
|
||||
}
|
||||
|
||||
function kindRow(k: MatKind): HTMLElement {
|
||||
const what = `${label(k)}${k.단위 ? ` (${k.단위})` : ""}` || "—";
|
||||
const sub = [k.방법, `${k.블록.length} 블록 · ${k.줄} 줄`, ...k.단서]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
const badge = createBadge(k.별도계상 ? `별도계상 · ${k.판정}` : k.판정, TONE[k.판정] ?? "warning", { size: 18 }); // prettier-ignore
|
||||
const row = createListRow({ label: what, sub, trailing: badge, size: 36, title: `단산 ${k.블록.join(" · ")}` }); // prettier-ignore
|
||||
const box = el("div", { attrs: { "data-mat-kind": k.종 }, children: [row, controls(k)] });
|
||||
if (edits.has(k.종)) box.dataset.matEdited = "";
|
||||
return box;
|
||||
}
|
||||
|
||||
function draw(): void {
|
||||
if (!board) return;
|
||||
const open = board.종.filter((k) => k.판정 !== LINKED);
|
||||
const linked = board.종.filter((k) => k.판정 === LINKED);
|
||||
title.setTitle(`고를 재료 ${open.length} · 이어진 재료 ${linked.length}`);
|
||||
root.dataset.matOpen = String(open.length);
|
||||
const save = createButton({
|
||||
label: edits.size ? `저장(${edits.size} 종)` : "저장",
|
||||
variant: "filled",
|
||||
size: 24,
|
||||
disabled: !edits.size || busy,
|
||||
onClick: () => void persist(),
|
||||
});
|
||||
save.dataset.matSave = "";
|
||||
const head = el("div", {
|
||||
className: "ui-toolbar",
|
||||
children: [save],
|
||||
});
|
||||
const more = el("section", {
|
||||
className: `ui-collapsible${linkedOpen ? "" : " is-collapsed"}`,
|
||||
attrs: { "data-mat-linked": "" },
|
||||
children: [createCollapsibleHead({ title: `이어진 재료 ${linked.length}`, sub: "엔진이 이은 것 — 틀린 이음도 같은 길로 바꿈", level: 4 }).root, ...linked.map(kindRow)], // prettier-ignore
|
||||
});
|
||||
more.addEventListener("click", () =>
|
||||
queueMicrotask(() => (linkedOpen = !more.classList.contains("is-collapsed"))),
|
||||
); // 다시 그려도 펼침 그대로
|
||||
body.replaceChildren(head, ...open.map(kindRow), ...(linked.length ? [more] : []));
|
||||
}
|
||||
|
||||
async function load(): Promise<void> {
|
||||
try {
|
||||
board = await call<MatBoard>(url);
|
||||
draw();
|
||||
} catch (e) {
|
||||
body.replaceChildren(createText(why(e), "error"));
|
||||
}
|
||||
}
|
||||
|
||||
async function persist(): Promise<void> {
|
||||
if (!board || !edits.size) return;
|
||||
const kinds = new Set(edits.keys());
|
||||
const used = board.만든.length;
|
||||
let apply = false;
|
||||
if (used) {
|
||||
const got = await askChoice({
|
||||
message: `이미 만든 단가산출 ${used} 건에도 적용할까? — 가져온 그대로인 줄만 바뀜(따로 고친 줄은 그대로)`,
|
||||
choices: [
|
||||
{ label: "이 파일 고름만", value: "no" },
|
||||
{ label: `단가산출 ${used} 건에도 적용`, value: "yes" },
|
||||
],
|
||||
});
|
||||
if (got === null) return;
|
||||
apply = got === "yes";
|
||||
}
|
||||
busy = true;
|
||||
draw();
|
||||
try {
|
||||
const out = await call<{ 고침?: string[] }>(url, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ 고름: Object.fromEntries(edits), 만든적용: apply, 종: [...kinds] }),
|
||||
});
|
||||
edits.clear();
|
||||
showToast(`재료 고름 저장 — ${kinds.size} 종${apply ? ` · 단가산출 ${out.고침?.length ?? 0} 건 고침` : ""}`, "success"); // prettier-ignore
|
||||
} catch (e) {
|
||||
showToast(why(e), "error");
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
await load();
|
||||
}
|
||||
|
||||
return {
|
||||
root,
|
||||
/** 처음 단가산출 탭에 올 때 한 번(첫 판 = 서버가 블록을 한 번 셈) */
|
||||
ensure(): void {
|
||||
if (asked) return;
|
||||
asked = true;
|
||||
body.replaceChildren(
|
||||
createText("재료 판정 읽는 중… (처음 한 번 — 블록 수만큼 걸림)", "hint"),
|
||||
);
|
||||
void load();
|
||||
},
|
||||
/** 저장 안 한 고름 */
|
||||
dirty: (): boolean => edits.size > 0,
|
||||
/** 블록 만들 때 같이 보낼 저장 안 한 고름(없으면 undefined) */
|
||||
pending: (): MatPicks | undefined => (edits.size ? Object.fromEntries(edits) : undefined),
|
||||
/** 만들기가 고름을 저장했음 — 캐시 비우고 다시 읽음 */
|
||||
reload(): void {
|
||||
edits.clear();
|
||||
if (asked) void load();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type MatPickPanel = ReturnType<typeof createMatPick>;
|
||||
@@ -69,6 +69,8 @@ export interface HopyoSheetBody {
|
||||
skCount: () => { 블록: number; 밖: number; 줄: number } | null;
|
||||
/** 그 단가산출 블록으로 뜀 */
|
||||
showSk: (번: number) => void;
|
||||
/** 저장 안 한 재료 고름(827-3) */
|
||||
matDirty: () => boolean;
|
||||
}
|
||||
|
||||
const ILWI = "일위대가";
|
||||
@@ -331,5 +333,6 @@ export function buildHopyoSheet(opt: HopyoSheetOptions): HopyoSheetBody {
|
||||
destroy: finish,
|
||||
skCount: () => sk?.count() ?? null,
|
||||
showSk: (번) => sk?.show(번),
|
||||
matDirty: () => sk?.dirty() ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
* (경쟁사 로직 그대로 · 엑셀 단가 · 요소 이음 · 소요량 이음)
|
||||
* · 시트 칸 고름 → 그 행 블록을 목록에서 강조(`SheetNav.blockAt`)
|
||||
* · 목록은 처음 단가산출 탭에 올 때 읽음 · 기초 판정 · 블록 판 · [옮기기] 없음(요구 703)
|
||||
* · 머리 = 「고를 재료」 판(827-3 `Hopyo_MatPick`) — 저장 안 한 고름은 블록 만들 때 같이 감
|
||||
* ========================================================================== */
|
||||
|
||||
import { createBadge } from "@ui/ui_template_badge";
|
||||
@@ -16,6 +17,7 @@ import { createListRow } from "@ui/ui_template_list_row";
|
||||
import { createText } from "@ui/ui_template_text";
|
||||
import type { HopyoScope } from "./M02_MasterTemplete_Hopyo_Api";
|
||||
import { openImported } from "./M02_MasterTemplete_Hopyo_Imported";
|
||||
import { createMatPick } from "./M02_MasterTemplete_Hopyo_MatPick";
|
||||
import { readHopyoMarks, type SheetNav } from "./M02_MasterTemplete_Hopyo_SheetNav";
|
||||
import type { BlockMark } from "./logic/M02_MasterTemplete_Hopyo_SheetMap";
|
||||
|
||||
@@ -35,7 +37,8 @@ const STRIPE = { done: "var(--color-success)", ready: "var(--color-info)", out:
|
||||
|
||||
export function createSheetSk(opt: SheetSkOptions) {
|
||||
const list = el("div", { className: "m02-hopyo-sheet__list" });
|
||||
const root = el("div", { className: "m02-hopyo-sheet__side m02-hopyo-sheet__sk", children: [list] }); // prettier-ignore
|
||||
const mat = createMatPick({ scope: opt.scope, key: opt.key, target: opt.target }); // 827-3
|
||||
const root = el("div", { className: "m02-hopyo-sheet__side m02-hopyo-sheet__sk", children: [mat.root, list] }); // prettier-ignore
|
||||
root.setAttribute("data-hopyo-sk-side", "");
|
||||
root.hidden = true;
|
||||
opt.side.after(root);
|
||||
@@ -46,7 +49,7 @@ export function createSheetSk(opt: SheetSkOptions) {
|
||||
|
||||
const mineAt = { layer: "personal", owner: null } as const; // [내 단가산출로](807-1) = 늘 개인 층
|
||||
const open = (no: number, mine: boolean): void =>
|
||||
openImported({ file: opt.scope, key: opt.key, no, target: mine ? mineAt : opt.target, canWrite: mine || opt.canWrite }); // prettier-ignore
|
||||
openImported({ file: opt.scope, key: opt.key, no, target: mine ? mineAt : opt.target, canWrite: mine || opt.canWrite, picks: mat.pending(), onMade: mat.reload }); // prettier-ignore
|
||||
|
||||
function draw(): void {
|
||||
if (!blocks) return;
|
||||
@@ -79,6 +82,7 @@ export function createSheetSk(opt: SheetSkOptions) {
|
||||
function ensure(): void {
|
||||
if (asked) return;
|
||||
asked = true;
|
||||
mat.ensure();
|
||||
if (!blocks) list.replaceChildren(createText(L("M02_JudgeLoading"), "hint"));
|
||||
void readHopyoMarks(opt.scope, opt.key, opt.target)
|
||||
.then((got) => {
|
||||
@@ -130,6 +134,8 @@ export function createSheetSk(opt: SheetSkOptions) {
|
||||
}
|
||||
},
|
||||
destroy(): void {},
|
||||
/** 저장 안 한 재료 고름(827-3) */
|
||||
dirty: mat.dirty,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -177,8 +177,10 @@ def why(item: dict) -> str:
|
||||
from M02_MasterTemplete import M02_Hopyo_Elem as elem
|
||||
from M02_MasterTemplete import M02_Hopyo_Row as hrow
|
||||
|
||||
if apart({"이름": name, "규격": res.get("규격") or ""}):
|
||||
return APART_ITEM if (res.get("재료찾기") or {}).get("키") else APART
|
||||
if apart({"이름": name, "규격": res.get("규격") or ""}): # 827-3 품목 이름까지
|
||||
from M02_MasterTemplete import M02_San_MatPick as mp
|
||||
|
||||
return mp.apart_note(res.get("재료찾기") or {})
|
||||
if elem.oil_rows(name):
|
||||
return "유가 후보 여럿 — 고름"
|
||||
line = {"이름": name, "규격": res.get("규격") or "", "단위": res.get("단위") or ""}
|
||||
@@ -202,6 +204,7 @@ def tally(logic: dict) -> dict:
|
||||
or x.get("참조")
|
||||
or (x.get("자원") or {}).get("조합식")
|
||||
or ((x.get("자원") or {}).get("재료찾기") or {}).get("키") # 827-2 별도계상 품목
|
||||
or ((x.get("자원") or {}).get("재료찾기") or {}).get("고름") # 827-3 사람이 고른 품목
|
||||
)
|
||||
inner = [x for x in items if (x.get("자원") or {}).get("갈래") == "단가산출" or x.get("참조")]
|
||||
return {"됨": done, "줄": len(items), "조합": bool(items) and len(inner) == len(items)}
|
||||
|
||||
@@ -0,0 +1,344 @@
|
||||
"""불러오기 재료 고름(PLAN 827-3 · 요구 811-2 · 812-1) — 서버 단독.
|
||||
|
||||
고름 = 사람이 그 파일 안 한 종(이름 · 규격 · 단위 · 단서 — `kind_of`)에 고른 우리 자재 · 별칭 아님
|
||||
(마스터 · M01 · 별칭 칸 · 다른 파일에 안 씀 · 다른 것으로 바뀔 수 있음).
|
||||
자리 = 개인 층 `실무자료/재료고름/<파일 지문>.json` {파일, 고름 {종: {키, 이름, 규격, 단위}}} — [끝내기] 뒤에도 남음 ·
|
||||
같은 파일(지문)을 다시 올리면 그대로 보임.
|
||||
판(`census`) = 그 HP 블록마다 재료 줄 판정(`자원["재료찾기"]` · 827-2)을 종으로 묶음 —
|
||||
첫 판 열 때 한 번 셈 → 산근 저장본 칸 `재료판`(HP 지울 때 같이 지움).
|
||||
만들기(`using` → `M02_Sangeun_FromHopyo._emit` 이 `apply`) = 고른 종 줄: 이음 = 고른 키 · `재료찾기.고름`
|
||||
(문서 셈 = 우리 값 · 엑셀 값 기억 없음 · `M02_Doc_Import._attach`) · 별도계상 줄 = `고름` 만(줄 이음 · 단가 0 그대로).
|
||||
문서 머리 `가져온.재료` = [{기호, 종, 원래, 판정, 방법, 키, 후보, 단서, 별도계상?, 고름?, 처음}](`entry` ·
|
||||
처음 = 가져온 그대로의 연결 — 고름 풀기 · 사람이 따로 바꾼 줄 가름).
|
||||
이미 만든 SK(`apply_made`) = 원본 지문이 같은 SK 문서 — 가져온 그대로인 줄만(「바꿈」 · 사람이 고른 줄 · 고친 값 뺌) →
|
||||
수식 길 저장(서버가 다시 셈).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy as cp
|
||||
import json
|
||||
from contextlib import contextmanager
|
||||
from contextvars import ContextVar
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from common_util.common_util_json import atomic_write_json
|
||||
from M02_MasterTemplete import M02_Hopyo_Store as hopyo_store
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
||||
|
||||
DIRNAME = "재료고름"
|
||||
CENSUS = "재료판"
|
||||
KINDS = (
|
||||
"규격 없음 — 고르기",
|
||||
"후보 여럿",
|
||||
"우리 표에 없음",
|
||||
"이어짐",
|
||||
) # 판 차례(`M02_San_MatFind`)
|
||||
MAX_PICKS = 2000
|
||||
_PICKS: ContextVar[dict | None] = ContextVar("재료고름", default=None)
|
||||
|
||||
|
||||
def _text(v: Any) -> str:
|
||||
return " ".join(str(v or "").split())
|
||||
|
||||
|
||||
def kind_of(res: dict) -> str:
|
||||
"""종 열쇠 — 가져온 자원 이름 · 규격 · 단위 + 판정 단서(같은 이름이라도 산근 머리 Φ800 · Φ1000 은 다른 종)."""
|
||||
found = res.get("재료찾기") or {}
|
||||
head = "|".join(_text(res.get(k)) for k in ("이름", "규격", "단위"))
|
||||
return head + "|" + ";".join(_text(x) for x in found.get("단서") or [])
|
||||
|
||||
|
||||
def _apart(res: dict) -> bool:
|
||||
from M02_MasterTemplete import M02_San_Link as link
|
||||
|
||||
return link.apart({"이름": res.get("이름") or "", "규격": res.get("규격") or ""})
|
||||
|
||||
|
||||
def _pick(raw: Any) -> dict | None:
|
||||
if not isinstance(raw, dict) or not str(raw.get("키") or "").strip():
|
||||
return None
|
||||
return {k: _text(raw.get(k)) for k in ("키", "이름", "규격", "단위")}
|
||||
|
||||
|
||||
# ── 고름 자리(개인 층) ────────────────────────────────────────────────────
|
||||
def _path(folder: str | Path, fp: str) -> Path:
|
||||
if not fp or not fp.isalnum() or len(fp) > 128:
|
||||
raise store.StoreError(400, "파일 지문")
|
||||
return hopyo_store._dir(folder) / DIRNAME / f"{fp}.json"
|
||||
|
||||
|
||||
def read(folder: str | Path, fp: str) -> dict[str, Any]:
|
||||
"""{파일, 고름 {종: 고른 것}} — 없으면 빈 것."""
|
||||
try:
|
||||
got = json.loads(_path(folder, fp).read_bytes())
|
||||
except (OSError, ValueError):
|
||||
return {"파일": "", "고름": {}}
|
||||
picks = got.get("고름") if isinstance(got.get("고름"), dict) else {}
|
||||
return {
|
||||
"파일": str(got.get("파일") or ""),
|
||||
"고름": {k: p for k, v in picks.items() if (p := _pick(v))},
|
||||
}
|
||||
|
||||
|
||||
def write(folder: str | Path, fp: str, name: str, picks: dict) -> dict[str, dict]:
|
||||
"""종마다 고른 것(None = 고름 풀기)을 얹어 씀 → 지금 고름 전부."""
|
||||
now = read(folder, fp)
|
||||
mine = dict(now["고름"])
|
||||
for k, v in (picks or {}).items():
|
||||
if (p := _pick(v)) is None:
|
||||
mine.pop(str(k), None)
|
||||
else:
|
||||
mine[str(k)] = p
|
||||
if len(mine) > MAX_PICKS:
|
||||
raise store.StoreError(400, "고름이 너무 많음")
|
||||
path = _path(folder, fp)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
atomic_write_json(path, {"파일": name or now["파일"], "고름": mine})
|
||||
return mine
|
||||
|
||||
|
||||
# ── 만들기 ───────────────────────────────────────────────────────────────
|
||||
@contextmanager
|
||||
def using(picks: dict | None):
|
||||
"""이 안에서 만드는 가져온 식 SK(안쪽 블록 포함)에 그 파일 고름을 얹음."""
|
||||
token = _PICKS.set(picks or None)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
_PICKS.reset(token)
|
||||
|
||||
|
||||
def apply(logic: dict) -> int:
|
||||
"""만들 로직 줄에 고름 얹음 → 얹은 줄 수(고름 없으면 0 · 엔진 판정 그대로)."""
|
||||
picks, n = _PICKS.get(), 0
|
||||
for item in (logic or {}).get("호표") or [] if picks else []:
|
||||
res = item.get("자원") or {}
|
||||
found = res.get("재료찾기")
|
||||
if not found or not (p := picks.get(kind_of(res))):
|
||||
continue
|
||||
res["재료찾기"] = {**found, "고름": p}
|
||||
if not _apart(res):
|
||||
item["이음"] = p["키"]
|
||||
n += 1
|
||||
return n
|
||||
|
||||
|
||||
def chosen(item: dict) -> dict | None:
|
||||
"""그 줄 고름(별도계상 아님) — 문서 셈 = 우리 값."""
|
||||
res = item.get("자원") or {}
|
||||
got = (res.get("재료찾기") or {}).get("고름")
|
||||
return got if got and not _apart(res) else None
|
||||
|
||||
|
||||
def item_key(res: dict) -> str | None:
|
||||
"""줄 품목 키 — 고름 › 엔진 이음."""
|
||||
found = res.get("재료찾기") or {}
|
||||
return (found.get("고름") or {}).get("키") or found.get("키")
|
||||
|
||||
|
||||
def apart_note(found: dict) -> str:
|
||||
"""별도계상 줄 설명 — 품목이 있으면 「별도계상 · 품목: 이름 규격」."""
|
||||
from M02_MasterTemplete import M02_San_Link as link
|
||||
|
||||
key = (found.get("고름") or {}).get("키") or found.get("키")
|
||||
if not key:
|
||||
return link.APART
|
||||
one = found.get("고름") or next(
|
||||
(c for c in found.get("후보") or [] if c.get("키") == key), None
|
||||
)
|
||||
if one is None: # 엔진이 이은 품목 = 우리 자재표 줄
|
||||
from M02_MasterTemplete import M02_San_MatFind as mf
|
||||
|
||||
one = ((mf._row(key) or {}).get("row") or {}).get("one") or {}
|
||||
what = f"{_text(one.get('이름'))} {_text(one.get('규격'))}".strip() or key
|
||||
return f"별도계상 · 품목: {what} · 셈은 프로젝트에서"
|
||||
|
||||
|
||||
def entry(item: dict, sym: str, excel: Any) -> dict | None:
|
||||
"""문서 머리 `가져온.재료` 한 칸(재료 찾기 판정 있는 줄만)."""
|
||||
res = item.get("자원") or {}
|
||||
found = res.get("재료찾기")
|
||||
if not found:
|
||||
return None
|
||||
one = {
|
||||
"기호": sym,
|
||||
"종": kind_of(res),
|
||||
"원래": {k: _text(res.get(k)) for k in ("이름", "규격", "단위")},
|
||||
}
|
||||
one |= {k: found.get(k) for k in ("판정", "방법", "키", "후보", "단서")}
|
||||
if _apart(res):
|
||||
one["별도계상"] = True
|
||||
elif found.get("키"):
|
||||
one["처음"] = {"갈래": "요소", "키": found["키"], "엑셀": excel}
|
||||
else:
|
||||
one["처음"] = {"갈래": "숫자", "값": excel}
|
||||
return one | ({"고름": found["고름"]} if found.get("고름") else {})
|
||||
|
||||
|
||||
# ── 판(그 파일 재료 종) ──────────────────────────────────────────────────
|
||||
def census(folder: str | Path, key: str) -> list[dict]:
|
||||
"""HP 블록마다 재료 줄 판정 → 종 [{종, 이름, 규격, 단위, 판정, 방법, 키, 후보, 단서, 별도계상, 블록, 줄}]."""
|
||||
from M02_MasterTemplete import M02_Doc_FromNative as fn
|
||||
from M02_MasterTemplete import M02_Sangeun_FromHopyo as fh
|
||||
|
||||
hopyo_store.read(folder, key) # 없는 HP = 404
|
||||
path = fh._path(folder, key)
|
||||
try:
|
||||
saved = json.loads(path.read_bytes())
|
||||
except (OSError, ValueError) as e:
|
||||
raise store.StoreError(409, fh.RELOAD) from e
|
||||
if isinstance(saved.get(CENSUS), list):
|
||||
return saved[CENSUS]
|
||||
native = saved.get("원파일")
|
||||
src = native if isinstance(native, dict) else fh._excel_of(path, saved) if saved.get("시트") else {} # fmt: skip
|
||||
groups: dict[str, dict] = {}
|
||||
# ponytail: 블록마다 셈 전부(기계 찾기 포함 · 70 블록 10~20 초) — 첫 판 한 번 · 느리면 재료 줄만 도는 길
|
||||
for no in sorted(fn.numbers(src)) if src else []:
|
||||
with using(None):
|
||||
try:
|
||||
out = fn.emit(src, no)
|
||||
except Exception: # noqa: BLE001 — 못 옮기는 블록 = 판에서 뺌(만들기 때 그 까닭이 보임)
|
||||
continue
|
||||
for item in out["로직"].get("호표") or []:
|
||||
res = item.get("자원") or {}
|
||||
found = res.get("재료찾기")
|
||||
if not found:
|
||||
continue
|
||||
k = kind_of(res)
|
||||
g = groups.setdefault(
|
||||
k,
|
||||
{"종": k, **{x: _text(res.get(x)) for x in ("이름", "규격", "단위")},
|
||||
**{x: found.get(x) for x in ("판정", "방법", "키", "후보", "단서")},
|
||||
"별도계상": _apart(res), "블록": [], "줄": 0},
|
||||
) # fmt: skip
|
||||
g["줄"] += 1
|
||||
if no not in g["블록"]:
|
||||
g["블록"].append(no)
|
||||
out_ = sorted(groups.values(), key=lambda g: (KINDS.index(g["판정"]) if g["판정"] in KINDS else 9, -len(g["블록"]), g["이름"])) # fmt: skip
|
||||
atomic_write_json(path, {**saved, CENSUS: out_})
|
||||
return out_
|
||||
|
||||
|
||||
def fingerprint(folder: str | Path, key: str) -> tuple[str, str]:
|
||||
hp = hopyo_store.read(folder, key)
|
||||
return str(hp.get("지문") or ""), str(hp.get("파일") or "")
|
||||
|
||||
|
||||
# ── 이미 만든 SK ─────────────────────────────────────────────────────────
|
||||
def made(folder: str | Path, fp: str) -> list[str]:
|
||||
"""그 층 가져온 식 SK 가운데 원본 지문이 같은 것."""
|
||||
out = []
|
||||
for path in sorted((Path(folder) / "sangeun").glob("SK-*.json")):
|
||||
try:
|
||||
doc = json.loads(path.read_bytes())
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
src = ((doc.get("가져온") or {}).get("원본") or {}) if isinstance(doc, dict) else {}
|
||||
if fp and src.get("지문") == fp:
|
||||
out.append(path.stem)
|
||||
return out
|
||||
|
||||
|
||||
def _same(a: Any, b: Any) -> bool:
|
||||
"""연결이 가져온 그대로(갈래 · 키 · 숫자 값)."""
|
||||
if not isinstance(a, dict) or not isinstance(b, dict) or a.get("바꿈"):
|
||||
return False
|
||||
if a.get("갈래") != b.get("갈래") or a.get("키") != b.get("키"):
|
||||
return False
|
||||
if a.get("갈래") == "숫자":
|
||||
return json.dumps(a.get("값"), default=str) == json.dumps(b.get("값"), default=str) or _num(a.get("값")) == _num(b.get("값")) # fmt: skip
|
||||
return a.get("갈래") != "요소" or "엑셀" in a
|
||||
|
||||
|
||||
def _num(v: Any) -> Any:
|
||||
try:
|
||||
return round(float(v), 6)
|
||||
except (TypeError, ValueError):
|
||||
return v
|
||||
|
||||
|
||||
def patch(doc: dict, picks: dict, kinds: set | None = None) -> int:
|
||||
"""문서 `가져온.재료` 줄에 고름 얹음 · 풂 → 고친 줄 수(사람이 따로 바꾼 줄 · 「바꿈」 줄 그대로)."""
|
||||
import master_doc as md # `M02_Doc_Copy` 가 scripts 를 길에 둔 뒤
|
||||
|
||||
by = {md.var_id(v): v for v in doc.get("변수") or []}
|
||||
n = 0
|
||||
for e in (doc.get("가져온") or {}).get("재료") or []:
|
||||
if kinds is not None and e.get("종") not in kinds:
|
||||
continue
|
||||
p, was = picks.get(e.get("종")), e.get("고름")
|
||||
var = by.get(e.get("기호"))
|
||||
if (p or {}).get("키") == (was or {}).get("키") or var is None:
|
||||
continue
|
||||
link = var.get("연결") or {}
|
||||
if e.get("별도계상"):
|
||||
e.pop("고름", None) if p is None else e.__setitem__("고름", p)
|
||||
var["설명"] = (
|
||||
f"이음 없음 — {apart_note(e)} · 엑셀 단가 그대로" # `Doc_Import._attach` 꼴
|
||||
)
|
||||
n += 1
|
||||
continue
|
||||
mine = was and link.get("갈래") == "요소" and link.get("키") == was.get("키") and "엑셀" not in link and not link.get("바꿈") # fmt: skip
|
||||
if not (mine or _same(link, e.get("처음"))):
|
||||
continue
|
||||
if p:
|
||||
var["연결"] = {"갈래": "요소", "키": p["키"]}
|
||||
e["고름"] = p
|
||||
else:
|
||||
var["연결"] = cp.deepcopy(e.get("처음"))
|
||||
e.pop("고름", None)
|
||||
n += 1
|
||||
return n
|
||||
|
||||
|
||||
def apply_made(fp: str, picks: dict, kinds: set | None = None, skip: tuple = ()) -> dict[str, list]:
|
||||
"""지금 층(`store.root`) 그 파일 SK 에 고름 얹어 저장 → {고침, 그대로}(가져온 그대로인 줄만 바뀜)."""
|
||||
from M02_MasterTemplete import M02_Doc_Sk_Path as skpath
|
||||
from M02_MasterTemplete import M02_Sangeun_Doc as sd
|
||||
|
||||
fixed, kept = [], []
|
||||
for key in made(store.root(), fp):
|
||||
if key in skip:
|
||||
continue
|
||||
got = store.read(sd.KIND, key)
|
||||
doc = cp.deepcopy(skpath.current(got["문서"])["문서"])
|
||||
if not patch(doc, picks, kinds):
|
||||
kept.append(key)
|
||||
continue
|
||||
skpath.save(key, {"문서": doc, "판": got["판"]})
|
||||
fixed.append(key)
|
||||
return {"고침": fixed, "그대로": kept}
|
||||
|
||||
|
||||
def uses(fp: str, kind: str, skip: tuple = ()) -> list[str]:
|
||||
"""지금 층 그 파일 SK 가운데 그 종 줄이 있는 것(SK 상세 물음 · 저장 없음)."""
|
||||
from M02_MasterTemplete import M02_Sangeun_Doc as sd
|
||||
|
||||
out = []
|
||||
for key in made(store.root(), fp):
|
||||
if key in skip:
|
||||
continue
|
||||
doc = json.loads((store.root() / sd.KIND / f"{key}.json").read_bytes())
|
||||
lines = ((doc.get("가져온") or {}).get("로직") or {}).get("호표") or []
|
||||
if any(kind_of(x.get("자원") or {}) == kind for x in lines if (x.get("자원") or {}).get("재료찾기")): # fmt: skip
|
||||
out.append(key)
|
||||
return out
|
||||
|
||||
|
||||
def tally(folder: str | Path, fp: str, kind: str) -> dict | None:
|
||||
"""그 층 작업 중 HP(같은 지문) 판에서 그 종 {블록, 줄} 수 — 판을 아직 안 셈 · HP 없음 = None."""
|
||||
from M02_MasterTemplete import M02_Sangeun_FromHopyo as fh
|
||||
|
||||
for row in hopyo_store.rows(folder):
|
||||
if row.get("지문") != fp:
|
||||
continue
|
||||
try:
|
||||
got = json.loads(fh._path(folder, row["키"]).read_bytes()).get(CENSUS)
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
for g in got or []:
|
||||
if g.get("종") == kind:
|
||||
return {"블록": len(g.get("블록") or []), "줄": g.get("줄") or 0}
|
||||
return None
|
||||
@@ -25,6 +25,7 @@ from M02_MasterTemplete import M02_Doc_FromNative as fn
|
||||
from M02_MasterTemplete import M02_Hopyo_Store as hopyo_store
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
||||
from M02_MasterTemplete import M02_San_Emit as em
|
||||
from M02_MasterTemplete import M02_San_MatPick as mp
|
||||
from M02_MasterTemplete import M02_San_MyMachine as mm
|
||||
|
||||
DIRNAME = "산근"
|
||||
@@ -127,13 +128,17 @@ def _emit(folder: str | Path, key: str, no: int) -> tuple[dict[str, Any], dict]:
|
||||
raise store.StoreError(409, RELOAD) from e
|
||||
native = saved.get("원파일")
|
||||
if isinstance(native, dict) and int(no) in fn.numbers(native): # 원파일 우선(810-2)
|
||||
return fn.emit(native, int(no)), native.get("기계구성") or {}
|
||||
out = fn.emit(native, int(no))
|
||||
mp.apply(out["로직"]) # 827-3 그 파일 재료 고름
|
||||
return out, native.get("기계구성") or {}
|
||||
if not saved.get("시트"):
|
||||
raise store.StoreError(404, f"없는 단산{no}")
|
||||
excel = _excel_of(path, saved)
|
||||
if int(no) not in fn.numbers(excel):
|
||||
raise store.StoreError(404, f"없는 단산{no}")
|
||||
return fn.emit(excel, int(no)), mm.xlsx_makes(saved["시트"])
|
||||
out = fn.emit(excel, int(no))
|
||||
mp.apply(out["로직"])
|
||||
return out, mm.xlsx_makes(saved["시트"])
|
||||
|
||||
|
||||
def _inner(
|
||||
@@ -184,6 +189,8 @@ def make(source: Path, key: str, no: int, name: str = "", _trail: tuple = ()) ->
|
||||
|
||||
out, makes = _emit(source, key, no)
|
||||
out["원본"]["가져온때"] = utc_now_text() # 원문 패널 「어디서 왔나」(807-1)
|
||||
if fp := hopyo_store.read(source, key).get("지문"): # 827-3 그 파일 고름 · 이미 만든 SK 찾기
|
||||
out["원본"]["지문"] = fp
|
||||
_link_inner(source, key, no, out, _trail)
|
||||
try: # 소요량 · 계수 후보 표지(797-2 · 사람 확인) — 못 셈 = 표지 없이 만듦
|
||||
from M02_MasterTemplete import M02_Sangeun_QtyLinks as qty
|
||||
|
||||
@@ -319,15 +319,17 @@ def same_part(doc: Any) -> dict | None:
|
||||
|
||||
|
||||
def unlinked(doc: dict) -> list[dict]:
|
||||
"""이음 없는 자원 줄(표지 · 사람이 고름) `[{자리, 이름, 까닭, 견적?}]`(까닭 = 814-1 `M02_San_Link.why` ·
|
||||
견적 = 815-1 K5 그대로 쓰는 견적 · 운임 · 수수료 줄 — 못 이음 수에서 뺌)."""
|
||||
"""이음 없는 자원 줄(표지 · 사람이 고름) `[{자리, 이름, 까닭, 견적?, 종?}]`(까닭 = 814-1 `M02_San_Link.why` ·
|
||||
견적 = 815-1 K5 그대로 쓰는 견적 · 운임 · 수수료 줄 — 못 이음 수에서 뺌 · 종 = 827-3 재료 종 열쇠)."""
|
||||
from M02_MasterTemplete import M02_San_Link as link
|
||||
from M02_MasterTemplete import M02_San_MatPick as mp
|
||||
|
||||
row = row_of(doc) or {}
|
||||
ids = sd.line_ids(row)
|
||||
return [
|
||||
{"자리": ids[n], "이름": one.get("이름") or "", "까닭": link.why(one)}
|
||||
| ({"견적": True} if (one.get("자원") or {}).get("갈래") == "고정" else {})
|
||||
| ({"종": mp.kind_of(one["자원"])} if (one.get("자원") or {}).get("재료찾기") else {})
|
||||
for n, one in enumerate(doc[KEY]["로직"].get("호표") or [])
|
||||
if not one.get("이음") and not one.get("참조") and not (one.get("자원") or {}).get("내기계")
|
||||
]
|
||||
|
||||
@@ -29,6 +29,7 @@ from pydantic import BaseModel
|
||||
|
||||
from common_util.common_util_auth import verify_session
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
||||
from M02_MasterTemplete import M02_San_MatPick as mat_pick
|
||||
from M02_MasterTemplete import M02_Sangeun_Block as blk
|
||||
from M02_MasterTemplete import M02_Sangeun_Calc as calc
|
||||
from M02_MasterTemplete import M02_Sangeun_Copy as skc
|
||||
@@ -160,6 +161,9 @@ class FromHopyoBody(BaseModel):
|
||||
키: str
|
||||
번: int
|
||||
이름: str = ""
|
||||
고름: dict[str, dict[str, Any] | None] | None = (
|
||||
None # 827-3 판에서 저장 안 한 재료 고름(만들 때 저장)
|
||||
)
|
||||
|
||||
|
||||
@router.post("/layers/{layer}/sangeun/from-hopyo")
|
||||
@@ -173,7 +177,17 @@ def sangeun_from_hopyo(
|
||||
답 = {이름, 검산, 단가기준, 다시씀}(같은 내용 = 그 SK)."""
|
||||
folder = _root(session, layer, owner, write=True)
|
||||
source = _root(session, body.층, body.주인)
|
||||
return _call(folder, _sd(from_hopyo.make), source, body.키, body.번, body.이름)
|
||||
mine = _root(session, "personal", None, write=body.고름 is not None)
|
||||
fp, name = _call(source, mat_pick.fingerprint, source, body.키) # 827-3 그 파일 재료 고름
|
||||
if body.고름 is not None and fp:
|
||||
_call(mine, mat_pick.write, mine, fp, name, body.고름)
|
||||
picks = mat_pick.read(mine, fp)["고름"] if fp else {}
|
||||
|
||||
def run(*args: Any) -> Any:
|
||||
with mat_pick.using(picks):
|
||||
return _sd(from_hopyo.make)(*args)
|
||||
|
||||
return _call(folder, run, source, body.키, body.번, body.이름)
|
||||
|
||||
|
||||
class ImportedEditBody(BaseModel):
|
||||
|
||||
@@ -150,7 +150,12 @@ def test_apart_linked_item_keeps_zero():
|
||||
"""별도계상 줄 = 품목이 하나로 정해지면 판정 키 · 줄 이음은 비움(셈 제외 · 단가 0 그대로)."""
|
||||
key, mark, found = _go("요소(별도계상)", "", "KG")
|
||||
assert key is None and found["키"] == "MT006763" and mark == f"{sl.NOTE}{sl.APART_ITEM}"
|
||||
assert sl.why({"자원": {"갈래": "재료", "이름": "요소(별도계상)", "규격": "", "재료찾기": found}}) == sl.APART_ITEM # fmt: skip
|
||||
why = sl.why(
|
||||
{"자원": {"갈래": "재료", "이름": "요소(별도계상)", "규격": "", "재료찾기": found}}
|
||||
)
|
||||
assert why.startswith("별도계상 · 품목: 요") and why.endswith(
|
||||
"셈은 프로젝트에서"
|
||||
) # 827-3 품목 이름까지
|
||||
assert sl.tally({"호표": [{"이름": "요소", "자원": {"재료찾기": found}}]})["됨"] == 1
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
# ruff: noqa: F811 — 임시 층 fixture(world)를 test_m02_labor_ref 에서 받아 씀
|
||||
"""827-3 지킴 — 불러오기 재료 고름(요구 811-2 · 812-1 · 임시 storage).
|
||||
|
||||
① 파일 고름 → 그 파일 같은 종 줄 전부 이음(블록 둘) · 고른 줄 = 우리 값(엑셀 값 기억 없음) · 문서 머리 `가져온.재료`
|
||||
② 다른 파일 영향 0 · 별칭 · 마스터 안 바뀜 · 고름 풀기 · [끝내기] 뒤 같은 파일 다시 올림 = 고름 그대로
|
||||
③ 이미 만든 SK = 물음 예(`apply_made`)일 때만 · 사람이 따로 바꾼 줄 그대로 · 풀면 가져온 그대로로
|
||||
④ 별도계상 줄 = 품목 「별도계상 · 품목: 이름 규격」 + 단가 0 그대로(줄 이음 없음)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy as cp
|
||||
import json
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from M02_MasterTemplete import M02_Doc_Import as dim
|
||||
from M02_MasterTemplete import M02_Doc_Sk_Path as skpath
|
||||
from M02_MasterTemplete import M02_Elem_Alias as ea
|
||||
from M02_MasterTemplete import M02_Hopyo_Forget as forget
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
||||
from M02_MasterTemplete import M02_San_MatPick as mp
|
||||
from M02_MasterTemplete import M02_Sangeun_FromHopyo as fh
|
||||
from M02_MasterTemplete import M02_Sangeun_Imported as imp
|
||||
from resources.tester import test_810_1_stc as stc
|
||||
from resources.tester.test_m02_labor_ref import world # noqa: F401 — 같은 임시 층
|
||||
|
||||
WIRE = "결속선(철선)|#20 , 0.9M/M|KG|" # 봉화 산근 98 · 99 — 후보 여럿
|
||||
PIPE_BLOCK = 71 # 동부청영월 파형강관(별도계상) Φ800 머리
|
||||
|
||||
|
||||
@lru_cache(maxsize=2)
|
||||
def _raw(tag: str) -> bytes:
|
||||
p = stc.FILES[tag]
|
||||
if not p.exists():
|
||||
pytest.skip(f"STC 없음 {tag}")
|
||||
return p.read_bytes()
|
||||
|
||||
|
||||
def _upload(folder: Path, tag: str) -> str:
|
||||
return fh.upload_native(folder, stc.FILES[tag].name, _raw(tag), {"이름": "sub3임시"})["키"]
|
||||
|
||||
|
||||
def _lines(doc: dict, kind: str) -> list[dict]:
|
||||
return [x for x in doc[imp.KEY]["로직"]["호표"] if mp.kind_of(x.get("자원") or {}) == kind]
|
||||
|
||||
|
||||
def _var(conv: dict, sym: str) -> dict:
|
||||
return next(v for v in conv["변수"] if dim.dc.md.var_id(v) == sym)
|
||||
|
||||
|
||||
def _entry(conv: dict, kind: str) -> dict:
|
||||
return next(e for e in conv["가져온"]["재료"] if e["종"] == kind)
|
||||
|
||||
|
||||
def test_pick_whole_file(world) -> None:
|
||||
me = world["personal"]
|
||||
me.mkdir(parents=True, exist_ok=True)
|
||||
alias = json.dumps(ea.book()["별칭"], ensure_ascii=False, sort_keys=True, default=str)
|
||||
with store.at(me):
|
||||
hp = _upload(me, "봉화")
|
||||
fp, name = mp.fingerprint(me, hp)
|
||||
groups = {g["종"]: g for g in mp.census(me, hp)}
|
||||
wire = groups[WIRE]
|
||||
assert wire["판정"] == "후보 여럿" and wire["블록"] == [98, 99] and len(wire["후보"]) > 1
|
||||
assert all(g["판정"] == "이어짐" or g["후보"] is not None for g in groups.values())
|
||||
pick = wire["후보"][1]
|
||||
assert mp.write(me, fp, name, {WIRE: pick})[WIRE]["키"] == pick["키"]
|
||||
assert mp.tally(me, fp, WIRE) == {"블록": 2, "줄": 2}
|
||||
with mp.using(mp.read(me, fp)["고름"]):
|
||||
made = [fh.make(me, hp, no)["이름"] for no in (98, 99)]
|
||||
for key in made:
|
||||
doc = store.read("sangeun", key)["문서"]
|
||||
assert doc[imp.KEY]["원본"]["지문"] == fp
|
||||
lines = _lines(doc, WIRE)
|
||||
assert lines and all(x["이음"] == pick["키"] for x in lines)
|
||||
assert all(x["자원"]["재료찾기"]["고름"]["키"] == pick["키"] for x in lines)
|
||||
assert all(
|
||||
x["자원"]["재료찾기"]["판정"] == "후보 여럿" for x in lines
|
||||
) # 엔진 판정 그대로
|
||||
conv = dim.derive(doc, None, imp._whole())
|
||||
e = _entry(conv, WIRE)
|
||||
assert e["고름"]["키"] == pick["키"] and e["처음"]["갈래"] == "숫자"
|
||||
assert _var(conv, e["기호"])["연결"] == {"갈래": "요소", "키": pick["키"]} # 우리 값
|
||||
assert sorted(mp.made(me, fp)) == sorted(made)
|
||||
assert mp.uses(fp, WIRE, (made[0],)) == [made[1]]
|
||||
# 다른 파일 = 영향 0
|
||||
other = _upload(me, "울진대흥")
|
||||
fp2, _ = mp.fingerprint(me, other)
|
||||
assert fp2 != fp and mp.read(me, fp2)["고름"] == {}
|
||||
# 고름 풀기
|
||||
assert mp.write(me, fp, name, {WIRE: None}) == {}
|
||||
assert mp.read(me, fp)["고름"] == {}
|
||||
# [끝내기] 뒤 같은 파일 다시 올림 = 고름 그대로
|
||||
mp.write(me, fp, name, {WIRE: pick})
|
||||
forget.finish(me, hp)
|
||||
again = _upload(me, "봉화")
|
||||
assert again != hp and mp.fingerprint(me, again)[0] == fp
|
||||
assert mp.read(me, fp)["고름"][WIRE]["키"] == pick["키"]
|
||||
# 별칭 · 마스터 안 바뀜 · 고름 자리 = 개인 층 실무자료/재료고름 한 파일씩
|
||||
assert json.dumps(ea.book()["별칭"], ensure_ascii=False, sort_keys=True, default=str) == alias
|
||||
assert sorted(p.name for p in (me / "실무자료" / mp.DIRNAME).iterdir()) == [f"{fp}.json"]
|
||||
assert not list(world["system"].rglob(f"{fp}*"))
|
||||
|
||||
|
||||
def test_made_only_on_yes_and_keeps_user_lines(world) -> None:
|
||||
me = world["personal"]
|
||||
me.mkdir(parents=True, exist_ok=True)
|
||||
with store.at(me):
|
||||
hp = _upload(me, "봉화")
|
||||
fp, name = mp.fingerprint(me, hp)
|
||||
sk98, sk99 = (fh.make(me, hp, no)["이름"] for no in (98, 99)) # 고름 전에 만듦
|
||||
conv = skpath.current(store.read("sangeun", sk98)["문서"])["문서"]
|
||||
e = _entry(conv, WIRE)
|
||||
before = _var(conv, e["기호"])["연결"]
|
||||
assert before["갈래"] == "숫자" and "고름" not in e
|
||||
pick = e["후보"][0]
|
||||
mp.write(me, fp, name, {WIRE: pick}) # 판 [저장](물음 아니오) = SK 그대로
|
||||
assert _var(skpath.current(store.read("sangeun", sk98)["문서"])["문서"], e["기호"])["연결"] == before # fmt: skip
|
||||
# 99 = 사람이 그 줄을 따로 고름(줄 쌓기 이름 단추)
|
||||
got = store.read("sangeun", sk99)
|
||||
doc = cp.deepcopy(skpath.current(got["문서"])["문서"])
|
||||
other = e["후보"][2]["키"]
|
||||
_var(doc, _entry(doc, WIRE)["기호"])["연결"] = {"갈래": "요소", "키": other}
|
||||
skpath.save(sk99, {"문서": doc, "판": got["판"]})
|
||||
money = skpath.read(sk98)["셈"]
|
||||
out = mp.apply_made(fp, mp.read(me, fp)["고름"], {WIRE}) # 물음 예
|
||||
assert out == {"고침": [sk98], "그대로": [sk99]}
|
||||
now = skpath.read(sk98)
|
||||
e98 = _entry(now["문서"], WIRE)
|
||||
assert _var(now["문서"], e98["기호"])["연결"] == {"갈래": "요소", "키": pick["키"]}
|
||||
assert e98["고름"]["키"] == pick["키"] and now["셈"] != money # 우리 값으로 다시 셈
|
||||
assert (
|
||||
_var(skpath.read(sk99)["문서"], e["기호"])["연결"]["키"] == other
|
||||
) # 따로 고친 줄 그대로
|
||||
# 풂 = 가져온 그대로(엑셀 값)
|
||||
mp.write(me, fp, name, {WIRE: None})
|
||||
assert mp.apply_made(fp, {}, {WIRE})["고침"] == [sk98]
|
||||
back = skpath.read(sk98)
|
||||
assert _num(_var(back["문서"], e98["기호"])["연결"]) == _num(before)
|
||||
assert "고름" not in _entry(back["문서"], WIRE) and back["셈"] == money
|
||||
|
||||
|
||||
def _num(link: dict) -> tuple:
|
||||
return link["갈래"], float(link["값"])
|
||||
|
||||
|
||||
def test_apart_item_zero(world) -> None:
|
||||
me = world["personal"]
|
||||
me.mkdir(parents=True, exist_ok=True)
|
||||
with store.at(me):
|
||||
hp = _upload(me, "동부청영월")
|
||||
out = fh.emit(me, hp, PIPE_BLOCK)
|
||||
line = next(x for x in out["로직"]["호표"] if "파형강관" in str((x.get("자원") or {}).get("이름"))) # fmt: skip
|
||||
kind = mp.kind_of(line["자원"])
|
||||
found = line["자원"]["재료찾기"]
|
||||
assert found["판정"] == "규격 없음 — 고르기" and line["이음"] is None
|
||||
gov = next(c for c in found["후보"] if "관급" in c["이름"])
|
||||
fp, name = mp.fingerprint(me, hp)
|
||||
plain = fh.make(me, hp, PIPE_BLOCK)
|
||||
mp.write(me, fp, name, {kind: gov})
|
||||
with mp.using(mp.read(me, fp)["고름"]):
|
||||
got = fh.make(me, hp, PIPE_BLOCK)
|
||||
assert got["이름"] != plain["이름"] and got["검산"] == plain["검산"] # 금액 그대로(단가 0)
|
||||
doc = store.read("sangeun", got["이름"])["문서"]
|
||||
mine = _lines(doc, kind)[0]
|
||||
assert mine["이음"] is None and mine["자원"]["재료찾기"]["고름"]["키"] == gov["키"]
|
||||
conv = dim.derive(doc, None, imp._whole())
|
||||
e = _entry(conv, kind)
|
||||
var = _var(conv, e["기호"])
|
||||
assert e["별도계상"] and var["연결"]["갈래"] == "숫자"
|
||||
assert f"별도계상 · 품목: {gov['이름']} {gov['규격']} · 셈은 프로젝트에서" in var["설명"]
|
||||
assert mp.item_key(mine["자원"]) == gov["키"]
|
||||
assert skpath.read(got["이름"])["셈"] == skpath.read(plain["이름"])["셈"]
|
||||
# 이미 만든(고름 전) SK 에 물음 예 = 품목 설명만 · 단가 0 그대로
|
||||
money = skpath.read(plain["이름"])["셈"]
|
||||
assert plain["이름"] in mp.apply_made(fp, {kind: gov}, {kind})["고침"]
|
||||
now = skpath.read(plain["이름"])
|
||||
assert _entry(now["문서"], kind)["고름"]["키"] == gov["키"] and now["셈"] == money
|
||||
assert "품목: 파형강관(관급)" in _var(now["문서"], e["기호"])["설명"]
|
||||
# 이음 없는 줄 목록(가져온 식 머리 [이음 고르기]) = 종 열쇠(같은 파일 물음)
|
||||
assert any(
|
||||
u.get("종") == kind for u in imp.unlinked(store.read("sangeun", plain["이름"])["문서"])
|
||||
)
|
||||
Reference in New Issue
Block a user