Merge remote-tracking branches 'origin/main_laptop_1', 'origin/sub_laptop_3', 'origin/sub_laptop_4', 'origin/sub_laptop_6' and 'origin/sub_laptop_7' into sub_laptop_5

This commit is contained in:
43 changed files with 3073 additions and 414 deletions
+25 -3
View File
@@ -11,6 +11,8 @@
장부 짝이 규격 다름(품명|단위 느슨 열쇠)이면 ⑤ 아님 — 꼬리표 「별칭 · 규격 다름」 · 짝은 후보
끝 덤만.
자재 · 별도계상 = 이름 · 규격 같음 하나면 맞음(체크) · 그 밖 후보.
로직 줄 후보 묶음 표시(358-1) = 이름후보(옛 324 다섯 `Match.name_cands`) · 후보마다 뜻겹침 · 출처같음 ·
책 — 차례 · 무게 · 상태는 그대로.
입력값 출처(칸마다) = 별칭 > 블록(옵션 찾기 값이 규격 읽기와 다름) > 규격(`spec_inputs`) > 자동값.
체크 = 처음 제안일 뿐 — 들어감은 [확인하고 사전조합 만들기](다 창)만. 저장 없음 · 셈은 서버 단독.
장부 = `M02_Hopyo_Ledger`(다 창) · 확인된 사전조합 = `M02_Hopyo_Build.confirmed`(다 창).
@@ -214,15 +216,35 @@ def match_line(line: dict[str, Any], ctx: Ctx, full: bool = True) -> dict[str, A
if alias and alias.get("갈래") in SKIP:
return {**out, "상태": "뺌", "뺌": alias["갈래"], "별칭": _alias_info(alias, False)}
if alias and alias.get("갈래") == "짝" and alias.get("짝"):
return {**out, **_aliased(line, ctx, alias, entry)}
if kind in hm.MATERIAL_KINDS:
got = _aliased(line, ctx, alias, entry)
elif kind in hm.MATERIAL_KINDS:
got = _material(line, ctx, full)
elif entry and entry["판정"]["글"] not in (fit.NO_NUMBER, "우리 로직 준비 중"):
got = _numeric(line, ctx, entry) if full else _brief(entry)
else:
why = "셈 중" if not ctx.calc["끝"] else fit.NO_NUMBER
got = _names_only(line, ctx, why, full)
return {**out, **(_loose(got, loose, line, ctx) if loose and full else got)}
got = _loose(got, loose, line, ctx) if loose and full else got
return {**out, **(_grouped(got, line, ctx) if full and kind not in hm.MATERIAL_KINDS else got)}
def _grouped(got: dict, line: dict, ctx: Ctx) -> dict:
"""묶음 표시(358-1) — 후보 · 이름후보(옛 324 다섯)마다 뜻겹침 · 출처같음(조항 ≥ 0.9) · 책 ·
차례 · 무게 · 상태 그대로(아무것도 숨기지 않음 · 묶음은 화면 몫)."""
mine = hm.words(line.get("이름"))
theirs = hm.fix_typo(((line.get("근거") or {}).get("출처")) or [])
table = hopyo_basis.ours()
def mark(c: dict) -> dict:
one, our = ctx.names.get(c.get("로직")), table.get(c.get("로직"))
if one is None:
return c
same = bool(theirs and our) and hm.clause(theirs, our)[0] >= 0.9
book = (our or {}).get("책", "")
return {**c, "뜻겹침": hm.near(mine, one["words"]), "출처같음": same, "책": book}
named = [mark(c) for c in hm.name_cands(line, ctx.got)]
return {**got, "후보": [mark(c) for c in got.get("후보") or []], "이름후보": named}
def _loose(got: dict, alias: dict, line: dict, ctx: Ctx) -> dict:
+21 -3
View File
@@ -292,10 +292,14 @@ def _exact(line: dict[str, Any], pool: str, got: dict[str, Any]) -> list[dict[st
def candidates(
line: dict[str, Any], pool: str, got: dict[str, Any] | None = None, limit: int | None = 5
line: dict[str, Any],
pool: str,
got: dict[str, Any] | None = None,
limit: int | None = 5,
fold: bool = False,
) -> list[dict[str, Any]]:
"""③ 정리 이름 같음 먼저 · ④ 글자 두 개 묶음 점수 + 단위 맞음 + 산림품셈 덤 — 위 `limit`
(None = 모두)."""
(None = 모두) · `fold` = 로직도 (이름, 규격, 단위)로 접음(옛 324 꼴)."""
me = tidy(line.get("이름"))
grams, unit = _grams(me), line.get("단위")
text = mm.plain_spec(f"{line.get('이름')}{line.get('규격')}")
@@ -313,7 +317,8 @@ def candidates(
scored.sort(key=lambda x: -x[0])
out = []
for score, one in scored:
label = one.get("로직") or (one["이름"], one.get("규격"), one["단위"]) # 로직은 접지 않음
label = (one["이름"], one.get("규격"), one["단위"])
label = label if fold else one.get("로직") or label # 로직은 접지 않음(fold 아니면)
if label in seen:
continue
seen.add(label)
@@ -323,6 +328,19 @@ def candidates(
return out
def name_cands(line: dict[str, Any], got: dict[str, Any] | None = None) -> list[dict[str, Any]]:
"""이름 닮은 로직 다섯(358-1 · 옛 324 데스크탑 판 그대로) — 이름 같음 · 씨앗(점수 9) 먼저 +
이름 점수 후보(로직도 이름 · 규격 · 단위로 접음) · 숫자 판정 차례와 따로(표시 묶음만)."""
got = got or targets()
same = [_public(x) for x in _exact(line, "로직", got)]
rest = [
c
for c in candidates(line, "로직", got, 5, fold=True)
if {k: v for k, v in c.items() if k != "점수"} not in same
]
return ([{**x, "점수": 9} for x in same] + rest)[:5]
# ── 인용 조항 · 이름 · 근거 차례(338 · 설계 2-4 B 차례) ─────────────────────
_OLD_TEXT = re.compile(r"(20(1\d|2[0-4])|1\d년|2[0-4]년|임도품셈|임도표준품셈|사방품셈)")
_TYPO_CLAUSE = re.compile(r"^(\d+)-(\d)(\d)(\d)$") # 13-114 → 13-11-4
+39 -13
View File
@@ -55,34 +55,49 @@
overflow-wrap: break-word;
}
/* 확인 창 줄 표(338 · 339) — 체크 · 갈래 · 수량 · 단위 · 상태 = 줄바꿈 없음(폭 = 글 폭) · 단추 = 세로로 쌓아 좁게 ·
품명 · 규격 · 우리 로직 · 옵션 · 품 비교가 남는 폭 · 칸 여백 좁힘(공용 표보다 앞서게 두 갈래 이름) */
/* 확인 창 줄 표(338 · 339 · 359-3) — 일위대가 상세 꼴(m02-labrow) · 체크 칸 · 수량 · 단위 = 줄바꿈 없음 ·
단추 = 세로로 쌓아 좁게 · 이름 · 별칭 · 품 비교가 남는 폭 · 칸 여백 좁힘(공용 표보다 앞서게 두 갈래 이름) */
.m02-hopyo__table.m02-hopyo__lines :is(th, td) {
padding: var(--spacing-8);
}
.m02-hopyo__table.m02-hopyo__lines
:is(th, td):is(:nth-child(1), :nth-child(2), :nth-child(5), :nth-child(6), :nth-child(7)) {
.m02-hopyo__table.m02-hopyo__lines td:first-child,
.m02-hopyo__lines :is(.m02-labrow__qty, .m02-labrow__unit) {
white-space: nowrap;
}
/* 품명 · 규격 — 낱말 두셋은 한 줄에 */
.m02-hopyo__table.m02-hopyo__lines td:nth-child(3),
/* 이름 칸 대상 줄 — 키 · 이름 · 배 칸 · 몫 한 줄(상세 이름 칸의 한 줄 말줄임을 풂) */
.m02-hopyo__lines .m02-labrow__name > .m02-hopyo__part {
display: flex;
max-width: none;
overflow: visible;
}
.m02-hopyo__lines .m02-labrow__name > .m02-hopyo__state,
.m02-hopyo__lines .m02-labrow__kind > .m02-hopyo__state {
display: inline-block;
margin-left: var(--spacing-4);
}
/* 품 비교 — 항목마다 한 줄(글의 줄바꿈 그대로) */
.m02-hopyo__table.m02-hopyo__lines td.m02-hopyo__compare {
min-width: 9em;
white-space: pre-line;
}
.m02-hopyo__subhead {
margin: var(--spacing-8) 0 0;
font-size: var(--text-body-sm);
}
.m02-hopyo-ledger__table td:nth-child(1) {
min-width: 7em;
}
.m02-hopyo__table.m02-hopyo__lines td:nth-child(4),
.m02-hopyo-ledger__table td:nth-child(2) {
min-width: 6em;
}
/* 옵션 · 품 비교 — 항목마다 한 줄(글의 줄바꿈 그대로) */
.m02-hopyo__table.m02-hopyo__lines td:is(:nth-child(9), :nth-child(10)) {
min-width: 9em;
white-space: pre-line;
}
/* 단추 칸 — 단추 · 넣음/뺌을 세로로 쌓아 칸 폭 = 가장 긴 단추 */
.m02-hopyo__table.m02-hopyo__lines td.m02-hopyo__acts {
width: 1%;
@@ -212,6 +227,17 @@
gap: var(--spacing-4);
}
/* 고르기 창 후보 묶음(358-1) — 뜻 낱말 안 겹치는 「후보」 = 회색 · 숫자만 닮음 = 접힘 */
.m02-hopyo__cand.is-weak {
color: var(--color-text-muted);
}
.m02-hopyo__group > summary {
cursor: pointer;
color: var(--color-text-secondary);
margin-bottom: var(--spacing-4);
}
/* 별칭 장부(338) — 위 층 줄 = 회색 읽기만 · 고친 줄 = 왼쪽 띠 */
.m02-hopyo-ledger__row.is-upper {
color: var(--color-text-muted);
@@ -92,6 +92,10 @@ export interface HopyoTarget {
/** 후보 판정(338) — 맞음 · 다른 점 · 후보 */
판정?: "맞음" | "다른 점" | "후보";
글?: string;
/** 묶음 표시(358-1 · 로직 후보만) — 품명 뜻 낱말 겹침 · 엑셀 출처 조항 같음(≥ 0.9) · 책(산림품셈 · 건설품셈) */
뜻겹침?: boolean;
출처같음?: boolean;
책?: string;
}
/** 맞춤 대상 하나(338 · 로직 여럿 가능) — 배 = 환산 × 몫 · 우리 수량 = 엑셀 수량 × 배 */
@@ -124,6 +128,8 @@ export interface HopyoMatch {
체크: boolean;
대상: HopyoPart[];
후보?: HopyoTarget[];
/** 이름 닮은 로직 다섯(358-1 · 옛 324 판 그대로 · 숫자 판정 차례와 따로 · 로직 줄만) */
이름후보?: HopyoTarget[];
글?: string;
꼬리표?: string[];
품?: { 엑셀: HopyoQty; 우리: HopyoQty; 같음: Record<string, boolean> };
@@ -1,15 +1,15 @@
/* =============================================================================
* M02_MasterTemplete_Hopyo_Check.ts
* 일위대가 실무자료 확인 창(PLAN 338-3 · 설계 4-1) — 호표 하나의 줄마다 서버 맞춤(상태 · 체크 제안 ·
* 우리 로직 · 옵션 · 품 비교)을 보이고, 사용자가 체크한 줄만 [확인하고 사전조합 만들기]로 서버에 보냄.
* · 자리 = 실무자료 본 칸(호표 누름) · [로직 추가] 「실무 호표」 모달 · 엑셀 보기 창 펼침 [확인 창 열기]
* 일위대가 실무자료 확인 창(PLAN 338-3 · 설계 4-1 · 359-3) — 호표 하나의 줄마다 서버 맞춤(상태 · 체크 제안 ·
* 우리 로직 · 옵션 · 품 비교)을 보이고, 사용자가 체크한 줄만 [사전조합 생성]으로 서버에 보냄.
* · 자리 = 엑셀 보기 창 호표 [수정] · [로직 추가] 「실무 호표」 모달
* · 머리 = 호표 · 「제안 n / 줄 m」 · 숫자 판정 셈 중이면 3 초마다 다시 읽음(끝나면 멈춤 · 만진 줄은 그대로)
* · 줄 = [✓] · 갈래 · 품명 · 규격 · 수량 · 단위 · 상태(맞음 · 별칭 · 다른 점 · 후보) · 우리 로직(여럿 · 몫 · 배) ·
* 옵션(칸=값 · 출처 · 못 가름) · 품 비교(엑셀 → 우리 ✓ / ≠) · [고르기] [원문 보기] [엑셀에서 보기] · 뺌 까닭
* · 줄 표 = 구조물 조합 일위대가 상세 꼴(로직 표 · 자재 표 · 품 비교 · 단추 — `M02_MasterTemplete_Hopyo_CheckRows`)
* · 금액 = 줄마다 서버 calc 미리 셈(`logic/M02_MasterTemplete_Hopyo_Draft` 문서 · 저장 안 함 · 줄마다 최신 응답만)
* · 대상 · 옵션을 바꾸면 서버 `guess`(저장 안 함 · 줄마다 최신 응답만)가 우리 품을 다시 셈
* · 캐시 = sessionStorage `m02-hopyo-check:{층}:{주인}:{키}:{번}`(조작 즉시 · 만들기 성공하면 지움) · 자동 저장 없음
* · 화면 잠금은 [확인하고 사전조합 만들기] 때만 · 쓰기 단추 = 만들 층 쓰기 권한만.
* 계산은 서버(`M02_Hopyo_Match` · `M02_Hopyo_Build`) — 화면은 보이기 · 체크 · 고름만.
* · 화면 잠금은 [사전조합 생성] 때만 · 쓰기 단추 = 만들 층 쓰기 권한만.
* 계산은 서버(`M02_Hopyo_Match` · `M02_Hopyo_Build` · `/labor/{열}/calc`) — 화면은 보이기 · 체크 · 고름만.
* ========================================================================== */
import {
@@ -36,22 +36,23 @@ import {
type HopyoPart,
type HopyoScope,
} from "./M02_MasterTemplete_Hopyo_Api";
import { builtWord, openHopyoChoose, targetText } from "./M02_MasterTemplete_Hopyo_Choose";
import {
builtWord,
inputsText,
openHopyoChoose,
sourceText,
targetText,
} from "./M02_MasterTemplete_Hopyo_Choose";
checkTables,
type LinePrice,
type RowsContext,
} from "./M02_MasterTemplete_Hopyo_CheckRows";
import type { HopyoSheetOptions } from "./M02_MasterTemplete_Hopyo_Sheet";
import { fetchLogicInputs } from "./M02_MasterTemplete_Labor_Api";
import { calcLabor, fetchLogicInputs } from "./M02_MasterTemplete_Labor_Api";
import { openLaborInput } from "./M02_MasterTemplete_Labor_Input";
import { draftDoc } from "./logic/M02_MasterTemplete_Hopyo_Draft";
import "./M02_MasterTemplete_Hopyo.css";
export const POLL_MS = 3000;
/** 사전조합을 만들었음(document 알림) — 좌측 「일위대가 사전조합」 목록 다시 */
export const HOPYO_BUILT = "m02-hopyo-built";
const OUT = ["우리에 없음", "안 씀"] as const;
/** 미리 셈 문서 열 이름(서버 calc 는 보낸 문서로 셈 · 저장 안 함) */
const DRAFT_COLUMN = "hopyo-draft";
/** 확인 창 캐시 — 사용자가 만진 것만(서버 제안 위에 덮어 보임) */
export interface CheckDraft {
@@ -181,7 +182,7 @@ export function newest(): (key: number) => () => boolean {
};
}
/** [맞음 · 별칭 모두 체크] 대상 줄 */
/** [맞음 · 별칭 일괄 체크] 대상 줄 */
export const sureLines = (lines: HopyoLine[], d: CheckDraft): HopyoLine[] =>
lines.filter((l) => ["맞음", "별칭"].includes(l.맞춤.상태) && checkable(l, d));
@@ -204,13 +205,11 @@ export interface CheckOptions {
target?: HopyoScope;
/** 만들 층 쓰기 권한 — 없으면 체크 · 만들기 없음(보기만) */
canWrite: boolean;
/** [← 호표 목록](본 칸) */
onBack?: () => void;
/** 만들었음 — [로직 추가] 참조 줄 · 모달 닫기 */
onBuilt?: (got: HopyoBuilt, hopyo: HopyoOne["호표"]) => void;
}
/** 확인 창 몸 — 본 칸 · 모달이 같이 씀 */
/** 확인 창 몸 — 모달(`openHopyoCheck`)에 담김 */
export function buildHopyoCheck(opt: CheckOptions): HTMLElement {
const root = el("div", {
className: "m02-hopyo m02-hopyo-check",
@@ -224,6 +223,9 @@ export function buildHopyoCheck(opt: CheckOptions): HTMLElement {
let seq = 0;
let timer = 0;
const ask = newest(); // 줄마다 최신 미리 셈만
const askPrice = newest(); // 줄마다 최신 금액 미리 셈만
const priced = new Map<number, { sig: string; got: LinePrice }>();
let pending = false;
const keep = (): void => saveDraft(k, d);
async function load(quiet = false): Promise<void> {
@@ -313,161 +315,76 @@ export function buildHopyoCheck(opt: CheckOptions): HTMLElement {
});
}
function partCell(line: HopyoLine): HTMLElement {
const parts = partsOf(line, d);
return el("td", {
className: "m02-hopyo__parts",
children: parts.map((p, at) => {
const times = el("input", {
className: "ui-input ui-input--sm m02-hopyo__times",
attrs: { type: "number", step: "any", min: "0", value: p.배 == null ? "" : String(p.배) },
}) as HTMLInputElement;
times.title = "배 — 우리 수량 = 엑셀 수량 × 배";
times.disabled = !opt.canWrite;
times.addEventListener("change", () => {
const v = times.value === "" ? null : Number(times.value);
d.대상[line.차례] = parts.map((x, i) => (i === at ? { ...x, 배: v } : x));
keep();
draw();
});
const share = p.몫 != null ? ` · 몫 ${p.몫}` : "";
return el("div", {
className: "m02-hopyo__part",
children: [
el("span", {
text: `${targetText(p)} ${p.로직 ?? p.자재 ?? ""}${share}`.trim(),
attrs: { title: p.어디 ?? "" },
}),
times,
...(p.로직
? [
createButton({
label: "옵션",
variant: "ghost",
size: "sm",
onClick: () => editOptions(line, at),
}),
]
: []),
],
});
}),
/** 줄마다 금액 미리 셈(359-3) — 서버 calc(저장 안 함 · 줄마다 최신 응답만 · 문서가 같으면 다시 안 셈) */
function price(line: HopyoLine): LinePrice | null {
const doc = draftDoc(line, partsOf(line, d));
if (!doc) return null;
const sig = JSON.stringify(doc);
const have = priced.get(line.차례);
if (have?.sig === sig) return have.got;
const slot = { sig, got: {} as LinePrice };
priced.set(line.차례, slot);
const mine = askPrice(line.차례);
calcLabor(target.layer, DRAFT_COLUMN, doc, {}, target.owner).then(
(calc) => mine() && ((slot.got = { calc }), soon()),
(error: unknown) => mine() && ((slot.got = { fail: why(error) }), soon()),
);
return slot.got;
}
/** 응답 여럿 = 한 번 그림 */
function soon(): void {
if (pending) return;
pending = true;
requestAnimationFrame(() => {
pending = false;
if (root.isConnected) draw();
});
}
function lineRow(line: HopyoLine): HTMLElement {
const m = line.맞춤;
const parts = partsOf(line, d);
const box = el("input", { attrs: { type: "checkbox", "data-check": String(line.차례) } });
const input = box as HTMLInputElement;
input.checked = isChecked(line, d) && checkable(line, d);
input.disabled = !opt.canWrite || !checkable(line, d);
input.addEventListener("change", () => {
d.체크[line.차례] = input.checked;
const ctx = (): RowsContext => ({
canWrite: opt.canWrite,
d,
price,
check: (line, on) => {
d.체크[line.차례] = on;
keep();
draw();
});
const out = el("select", {
className: "ui-input ui-input--sm",
attrs: { title: "뺌 — 사전조합에 안 넣고 장부에 남김" },
children: [
el("option", { text: "넣음", attrs: { value: "" } }),
...OUT.map((x) => el("option", { text: `뺌 — ${x}`, attrs: { value: x } })),
],
}) as HTMLSelectElement;
out.value = d.뺌[line.차례] ?? "";
out.disabled = !opt.canWrite || m.상태 === "%";
out.addEventListener("change", () => {
if (out.value) d.뺌[line.차례] = out.value;
},
out: (line, value) => {
if (value) d.뺌[line.차례] = value;
else delete d.뺌[line.차례];
d.체크[line.차례] = !!out.value || d.체크[line.차례];
d.체크[line.차례] = !!value || d.체크[line.차례];
keep();
draw();
});
const sheet = (행: [number, number]): void =>
openSheet({
scope: opt.scope,
key: opt.key,
파일: got?.파일 ?? "",
canWrite: opt.canWrite,
target,
단산행: 행,
});
const acts = [
...(opt.canWrite && m.상태 !== "%"
? [
createButton({
label: "고르기",
variant: "ghost",
size: "sm",
onClick: () =>
openHopyoChoose({
line,
openSheet: sheet,
done: (ts) =>
setParts(
line,
ts.map((t) => ({ ...t, 입력값: t.입력값 ?? {}, 몫: t.몫 ?? null })),
),
}),
}),
]
: []),
createButton({
label: "원문 보기",
variant: "ghost",
size: "sm",
onClick: () => void showSource(line),
},
times: (line, at, value) => {
d.대상[line.차례] = partsOf(line, d).map((x, i) => (i === at ? { ...x, 배: value } : x));
keep();
draw();
},
choose: (line) =>
openHopyoChoose({
line,
openSheet: sheetAt,
done: (ts) =>
setParts(
line,
ts.map((t) => ({ ...t, 입력값: t.입력값 ?? {}, 몫: t.몫 ?? null })),
),
}),
...(line.근거
? [
createButton({
label: "엑셀에서 보기",
variant: "ghost",
size: "sm",
onClick: () => sheet(line.근거!.행),
}),
]
: []),
out,
];
const tags = [m.글, ...(m.꼬리표 ?? []), sourceText(line.근거)].filter(Boolean).join("\n");
const opts = parts
.filter((p) => p.로직)
.flatMap((p) => inputsText(p.입력값 ?? {}, p.못읽음 ?? [], p.출처).split(/, (?=[^,=]+=)| · /))
.filter(Boolean);
if (m.갈리는칸?.length) opts.push(`⚠ 못 가름: ${m.갈리는칸.join(", ")}`);
const alias = m.별칭?.규격다름 ? " · 규격 다름" : m.별칭?.근거다름 ? " · 근거 다름" : "";
return el("tr", {
className: `m02-hopyo__line is-${input.checked ? "in" : "out"}`,
attrs: { "data-line": String(line.차례), "data-state": m.상태 },
children: [
el("td", { children: [box] }),
td(line.갈래),
td(line.이름),
td(line.규격),
td(line.수량 === null ? "" : String(line.수량), "m02-hopyo__num"),
td(line.단위),
el("td", {
attrs: { title: tags },
children: [
el("span", {
className: `m02-hopyo__state is-${m.상태.replace(" ", "")}`,
text: `${m.상태}${alias}`,
}),
],
}),
partCell(line),
td(opts.join("\n")), // 옵션 · 품 비교 = 항목마다 한 줄
td(
compareText(d.품[line.차례] ?? m.품)
.split(" · ")
.join("\n"),
),
el("td", { className: "m02-hopyo__acts", children: acts }),
],
source: (line) => void showSource(line),
sheet: sheetAt,
options: editOptions,
});
const sheetAt = (행: [number, number]): void =>
openSheet({
scope: opt.scope,
key: opt.key,
파일: got?.파일 ?? "",
canWrite: opt.canWrite,
target,
단산행: 행,
});
}
async function make(h: HopyoOne["호표"]): Promise<void> {
const whole = !!h.통째 && (d.통째 ?? h.통째.체크);
@@ -483,8 +400,8 @@ export function buildHopyoCheck(opt: CheckOptions): HTMLElement {
.map(([a, b]) => `${a} ${b}`)
.join(" · ")})`
: "") +
" — 확인하고 사전조합을 만듦";
if (!(await showConfirmDialog(ask, "확인하고 만들기"))) return;
" — 사전조합 생성";
if (!(await showConfirmDialog(ask, "생성"))) return;
const from = { 층: opt.scope.layer, 주인: opt.scope.owner, 키: opt.key, 번: opt.no };
showLoadingOverlay();
try {
@@ -567,7 +484,7 @@ export function buildHopyoCheck(opt: CheckOptions): HTMLElement {
const tools: HTMLElement[] = [];
if (opt.canWrite) {
const sure = sureLines(h.줄, d);
const all = createButton({ label: `맞음 · 별칭 모두 체크 (${sure.length})`, size: "sm" });
const all = createButton({ label: `맞음 · 별칭 일괄 체크 (${sure.length})`, size: "sm" });
all.disabled = !sure.length;
all.addEventListener("click", () => {
for (const l of sure) d.체크[l.차례] = true;
@@ -575,19 +492,15 @@ export function buildHopyoCheck(opt: CheckOptions): HTMLElement {
draw();
});
const whole = !!h.통째 && (d.통째 ?? h.통째.체크);
const go = createButton({ label: "확인하고 사전조합 만들기", variant: "filled", size: "sm" });
const go = createButton({ label: "사전조합 생성", variant: "filled", size: "sm" });
go.disabled = !whole && checked < 1;
go.setAttribute("data-hopyo-make", "");
go.addEventListener("click", () => void make(h));
tools.push(all, go);
}
const back = opt.onBack
? [createButton({ label: "← 호표 목록", variant: "ghost", size: "sm", onClick: opt.onBack })]
: [];
const head = el("div", {
className: "m02-hopyo__bar",
children: [
...back,
el("strong", { text: `제${h.번}호표 ${h.이름}` }),
note(h.규격),
note(h.단위),
@@ -598,7 +511,7 @@ export function buildHopyoCheck(opt: CheckOptions): HTMLElement {
: []),
...(h.만든 ? [note(`만든 사전조합 ${h.만든.키}${h.만든.확인 ? " · 확인함" : ""}`)] : []),
createButton({
label: "엑셀에서 보기",
label: "엑셀 보기",
size: "sm",
onClick: () =>
openSheet({
@@ -638,38 +551,10 @@ export function buildHopyoCheck(opt: CheckOptions): HTMLElement {
];
})()
: [];
const heads = [
"",
"갈래",
"품명",
"규격",
"수량",
"단위",
"상태",
"우리 로직 · 배",
"옵션",
"품 비교",
"",
];
root.replaceChildren(
head,
...whole,
h.줄.length
? el("div", {
className: "ui-general-block__table-wrap ui-general-block__table-wrap--sticky-head",
children: [
el("table", {
className: "ui-general-block__table m02-hopyo__table m02-hopyo__lines",
children: [
el("thead", {
children: [el("tr", { children: heads.map((t) => el("th", { text: t })) })],
}),
el("tbody", { children: h.줄.map(lineRow) }),
],
}),
],
})
: note("구성 줄 없음"),
...(h.줄.length ? checkTables(h.줄, ctx()) : [note("구성 줄 없음")]),
...(built ? [builtBlock(built)] : []),
);
}
@@ -678,7 +563,7 @@ export function buildHopyoCheck(opt: CheckOptions): HTMLElement {
return root;
}
/** 확인 창 모달 — [로직 추가] 「실무 호표」 · 엑셀 보기 창 [확인 창 열기] · 만들면 닫음 */
/** 확인 창 모달 — [로직 추가] 「실무 호표」 · 엑셀 보기 창 호표 [수정] · 만들면 닫음 */
export function openHopyoCheck(opt: CheckOptions & { title?: string }): void {
openModal({
title: opt.title ?? `확인 — 제${opt.no}호표`,
@@ -0,0 +1,343 @@
/* =============================================================================
* M02_MasterTemplete_Hopyo_CheckRows.ts
* 확인 창 줄 표(PLAN 359-3) — 구조물 조합 일위대가 상세 꼴(`Labor_Rows` logicTable · matTable)에 품 비교 · 단추만 더함.
* · 로직 표 = [✓] · 차례 · 구분(엑셀 갈래 + 상태) · 이름(우리 로직 · 배 · 옵션 글) · 별칭(엑셀 품명 = 만들 때 별칭) ·
* 수량산출(위 엑셀 / 아래 우리 = 엑셀 × 배) · 결과 단위 · 노무비 · 재료비 · 경비 · 합계 · 품 비교 · 단추
* · 자재 표 = [✓] · 차례 · 코드 · 이름 · 규격 · 수량산출 · 단위 · 세 칸(자재구분 · 별도계상 · 운반비 · 글만) ·
* 가격 다섯 · 엑셀 / 우리 금액 · 단추
* · 금액 칸 = 위 작게 엑셀 · 아래 우리(서버 calc 미리 셈 · 저장 안 함) · 셈 중 「…」
* · 단추 = [고르기] · [원문 보기] · [엑셀 보기] · [옵션](로직 대상마다) · 드롭다운 추가 · 로직없음 · 사용안함
* (화면 글만 · 저장 값 = 빈 값 · 「우리에 없음」 · 「안 씀」 그대로)
* 줄 표 = `tableOf`(대상이 모두 자재 · 대상 없는 자재 · 별도계상 줄 = 자재 표).
* ========================================================================== */
import { createButton, el } from "@ui/ui_template_elements";
import type { HopyoLine, HopyoPart } from "./M02_MasterTemplete_Hopyo_Api";
import {
checkable,
compareText,
isChecked,
partsOf,
type CheckDraft,
} from "./M02_MasterTemplete_Hopyo_Check";
import { inputsText, sourceText } from "./M02_MasterTemplete_Hopyo_Choose";
import type { LaborCalc } from "./M02_MasterTemplete_Labor_Api";
import { markCells, markHeads } from "./M02_MasterTemplete_Labor_MatMarks";
import { moneyCell, PRICE_SLOTS } from "./M02_MasterTemplete_Labor_Rows";
import { tc } from "./M02_MasterTemplete_Labor_Text";
import { tableOf } from "./logic/M02_MasterTemplete_Hopyo_Draft";
/** 드롭다운 — 저장 값(서버 뺌 까닭) → 화면 글 */
export const OUT_TEXT: [string, string][] = [
["", "추가"],
["우리에 없음", "로직없음"],
["안 씀", "사용안함"],
];
const COSTS = ["노무비", "재료비", "경비", "계"] as const;
const COST_HEAD = {
노무비: "Prev_Labor",
재료비: "Prev_Material",
경비: "Prev_Expense",
계: "Prev_Sum",
} as const;
/** 줄 하나 미리 셈 — 셈 중 = 둘 다 없음 */
export interface LinePrice {
calc?: LaborCalc;
fail?: string;
}
export interface RowsContext {
canWrite: boolean;
d: CheckDraft;
price: (line: HopyoLine) => LinePrice | null;
check: (line: HopyoLine, on: boolean) => void;
out: (line: HopyoLine, value: string) => void;
times: (line: HopyoLine, at: number, value: number | null) => void;
choose: (line: HopyoLine) => void;
source: (line: HopyoLine) => void;
sheet: (행: [number, number]) => void;
options: (line: HopyoLine, at: number) => void;
}
const num = (v: number): string =>
Number(v.toPrecision(12)).toLocaleString("ko-KR", { maximumFractionDigits: 6 });
const small = (text: string, cls = "m02-labrow__note"): HTMLElement =>
el("small", { className: cls, text, attrs: { title: text } });
/** 금액 칸 — 위 엑셀(작게) · 아래 우리 */
function pairCell(
excel: number | null | undefined,
ours: number | null | undefined,
wait: boolean,
label: string,
): HTMLElement {
const td = moneyCell(excel, ours, wait, label);
td.querySelector(".m02-labrow__base")?.setAttribute("title", "엑셀");
td.querySelector(".m02-labrow__applied")?.setAttribute("title", "우리(미리 셈)");
return td;
}
/** 수량산출 — 위 엑셀 수량 · 아래 우리(엑셀 × 배 · 대상마다) */
function qtyCell(line: HopyoLine, parts: HopyoPart[]): HTMLElement {
const ours = parts
.map((p) => (p.배 === null || line.수량 === null ? "—" : num(line.수량 * p.배)))
.join(" · ");
return el("td", {
className: "m02-labrow__qty",
attrs: { "data-label": tc("Labor_QtyCol") },
children: [
el("small", {
className: "m02-labrow__base",
text: line.수량 === null ? "—" : `${num(line.수량)} ${line.단위}`.trim(),
attrs: { title: "엑셀" },
}),
el("span", { className: "m02-labrow__applied", text: ours || "—", attrs: { title: "우리" } }),
],
});
}
const stateChip = (line: HopyoLine): HTMLElement => {
const m = line.맞춤;
const alias = m.별칭?.규격다름 ? " · 규격 다름" : m.별칭?.근거다름 ? " · 근거 다름" : "";
const tags = [m.글, ...(m.꼬리표 ?? []), sourceText(line.근거)].filter(Boolean).join("\n");
return el("span", {
className: `m02-hopyo__state is-${m.상태.replace(" ", "")}`,
text: `${m.상태}${alias}`,
attrs: { title: tags },
});
};
/** 대상마다 한 줄 — 키 · 이름 · × 배 칸 · 몫 */
function partLines(line: HopyoLine, parts: HopyoPart[], ctx: RowsContext): HTMLElement[] {
return parts.map((p, at) => {
const times = el("input", {
className: "ui-input ui-input--sm m02-hopyo__times",
attrs: { type: "number", step: "any", min: "0", value: p.배 == null ? "" : String(p.배) },
}) as HTMLInputElement;
times.title = "배 — 우리 수량 = 엑셀 수량 × 배";
times.disabled = !ctx.canWrite;
times.addEventListener("change", () =>
ctx.times(line, at, times.value === "" ? null : Number(times.value)),
);
const key = p.로직 ?? p.자재 ?? (p.호표 !== undefined ? `제${p.호표}호표` : "");
return el("div", {
className: "m02-hopyo__part",
attrs: { title: p.어디 ?? "" },
children: [
el("span", { className: "m02-labrow__key", text: key }),
el("strong", { text: p.이름 ?? "" }),
times,
...(p.몫 != null ? [small(`몫 ${p.몫}`)] : []),
],
});
});
}
/** 이름 칸 덧글 — 옵션(칸=값 · 출처) · 못 가름 · 셈 못 함 까닭 */
function nameNotes(line: HopyoLine, parts: HopyoPart[], price: LinePrice | null): HTMLElement[] {
const opts = parts
.filter((p) => p.로직)
.map((p) => inputsText(p.입력값 ?? {}, p.못읽음 ?? [], p.출처))
.filter(Boolean);
if (line.맞춤.갈리는칸?.length) opts.push(`⚠ 못 가름: ${line.맞춤.갈리는칸.join(", ")}`);
const why = [
price?.fail,
...(price?.calc?.줄 ?? []).map((l) => l.까닭),
...(price?.calc?.자재 ?? []).map((m) => m.까닭),
].filter(Boolean) as string[];
return [
...opts.map((t) => small(t, "m02-labrow__note m02-hopyo__opts")),
...why.map((t) => small(`셈 못 함: ${t}`, "m02-labrow__empty")),
...(parts.some((p) => p.호표 !== undefined) ? [small("하위 호표 = 만든 뒤 셈")] : []),
];
}
function checkCell(line: HopyoLine, ctx: RowsContext): HTMLElement {
const box = el("input", {
attrs: { type: "checkbox", "data-check": String(line.차례) },
}) as HTMLInputElement;
box.checked = isChecked(line, ctx.d) && checkable(line, ctx.d);
box.disabled = !ctx.canWrite || !checkable(line, ctx.d);
box.addEventListener("change", () => ctx.check(line, box.checked));
return el("td", { children: [box] });
}
function actsCell(line: HopyoLine, parts: HopyoPart[], ctx: RowsContext): HTMLElement {
const m = line.맞춤;
const ghost = (label: string, run: () => void): HTMLElement =>
createButton({ label, variant: "ghost", size: "sm", onClick: run });
const out = el("select", {
className: "ui-input ui-input--sm",
attrs: { title: "로직없음 · 사용안함 — 사전조합에 안 넣고 장부에 남김", "data-out": "" },
children: OUT_TEXT.map(([value, text]) => el("option", { text, attrs: { value } })),
}) as HTMLSelectElement;
out.value = ctx.d.뺌[line.차례] ?? "";
out.disabled = !ctx.canWrite || m.상태 === "%";
out.addEventListener("change", () => ctx.out(line, out.value));
const options = parts.flatMap((p, at) => {
if (!p.로직) return [];
const b = ghost("옵션", () => ctx.options(line, at));
b.title = `${p.로직} 옵션`;
return [b];
});
return el("td", {
className: "m02-hopyo__acts",
children: [
...(ctx.canWrite && m.상태 !== "%" ? [ghost("고르기", () => ctx.choose(line))] : []),
ghost("원문 보기", () => ctx.source(line)),
...(line.근거 ? [ghost("엑셀 보기", () => ctx.sheet(line.근거!.행))] : []),
...options,
out,
],
});
}
const rowOf = (line: HopyoLine, ctx: RowsContext, cells: HTMLElement[]): HTMLElement => {
const on = isChecked(line, ctx.d) && checkable(line, ctx.d);
return el("tr", {
className: `m02-labrow__row m02-hopyo__line is-${on ? "in" : "out"}`,
attrs: { "data-line": String(line.차례), "data-state": line.맞춤.상태 },
children: [checkCell(line, ctx), ...cells, actsCell(line, partsOf(line, ctx.d), ctx)],
});
};
function logicRow(line: HopyoLine, ctx: RowsContext): HTMLElement {
const parts = partsOf(line, ctx.d);
const price = ctx.price(line);
const wait = !!price && !price.calc && !price.fail;
const ours = price?.calc?.반영합;
const unit = price?.calc?.줄[0]?.분모 ?? parts[0]?.단위 ?? line.단위;
const compare = compareText(ctx.d.품[line.차례] ?? line.맞춤.품);
return rowOf(line, ctx, [
el("td", { className: "m02-labrow__no", text: String(line.차례 + 1) }),
el("td", { className: "m02-labrow__kind", children: [line.갈래, stateChip(line)] }),
el("td", {
className: "m02-labrow__name m02-hopyo__parts",
children: [
...(parts.length ? partLines(line, parts, ctx) : [small("—", "m02-labrow__muted")]),
...nameNotes(line, parts, price),
],
}),
el("td", {
className: "m02-labrow__alias",
attrs: { title: `${line.이름} ${line.규격}`.trim() },
children: [line.이름, ...(line.규격 ? [small(line.규격)] : [])],
}),
qtyCell(line, parts),
el("td", { className: "m02-labrow__unit", text: unit ?? "" }),
...COSTS.map((c) =>
pairCell(line.금액[c], price?.calc ? (ours?.[c] ?? null) : null, wait, tc(COST_HEAD[c])),
),
el("td", { className: "m02-hopyo__compare", text: compare.split(" · ").join("\n") }),
]);
}
function matRow(line: HopyoLine, ctx: RowsContext): HTMLElement {
const parts = partsOf(line, ctx.d);
const price = ctx.price(line);
const wait = !!price && !price.calc && !price.fail;
const mats = price?.calc?.자재 ?? [];
const first = mats[0];
const ours = mats.length ? mats.reduce((s, m) => s + (m.반영 ?? 0), 0) : null;
const show = (v: number | null | undefined): string =>
wait ? "…" : v === null || v === undefined ? "—" : v.toLocaleString("ko-KR");
const p = parts[0];
return rowOf(line, ctx, [
el("td", { className: "m02-labrow__no", text: String(line.차례 + 1) }),
el("td", {
className: "m02-labrow__code",
text: parts.map((x) => x.자재 ?? "").join(" · ") || "—",
}),
el("td", {
className: "m02-labrow__name m02-hopyo__parts",
children: [
stateChip(line),
...(parts.length ? partLines(line, parts, ctx) : []),
small(`엑셀 ${line.이름}`.trim()),
...nameNotes(line, parts, price),
],
}),
el("td", {
className: "m02-labrow__spec",
text: p?.규격 ?? line.규격,
attrs: { title: p?.규격 ?? line.규격 },
}),
qtyCell(line, parts),
el("td", { className: "m02-labrow__unit", text: p?.단위 ?? line.단위 }),
...markCells({ 별도계상: line.갈래 === "별도계상" ? "별도계상" : "직접계산" }),
...PRICE_SLOTS.map((slot) =>
el("td", {
className: `m02-labrow__num m02-labrow__price${first?.기준칸 === slot ? " is-base-price" : ""}`,
text: show(first?.가격?.[slot]),
attrs: { "data-label": slot, "data-price-slot": slot },
}),
),
pairCell(line.금액.계, price?.calc ? ours : null, wait, tc("Labor_AppliedSum")),
]);
}
const grid = (heads: string[], rows: HTMLElement[], kind: string): HTMLElement =>
el("div", {
className: "ui-general-block__table-wrap ui-general-block__table-wrap--sticky-head",
children: [
el("table", {
className: "ui-general-block__table m02-labrow m02-hopyo__table m02-hopyo__lines",
attrs: { "data-hopyo-table": kind },
children: [
el("thead", {
children: [el("tr", { children: heads.map((t) => el("th", { text: t })) })],
}),
el("tbody", { children: rows }),
],
}),
],
});
/** 줄 표 둘 — 로직 표 · 자재 표(줄 없는 표는 안 그림) */
export function checkTables(lines: HopyoLine[], ctx: RowsContext): HTMLElement[] {
const logic = lines.filter((l) => tableOf(l, partsOf(l, ctx.d)) === "logic");
const mat = lines.filter((l) => !logic.includes(l));
const out: HTMLElement[] = [];
if (logic.length)
out.push(
grid(
[
"",
tc("Col_Order"),
tc("Sub"),
tc("Col_Name"),
tc("Col_Alias"),
tc("Labor_QtyCol"),
tc("Col_Unit"),
...COSTS.map((c) => tc(COST_HEAD[c])),
"품 비교",
"",
],
logic.map((l) => logicRow(l, ctx)),
"logic",
),
);
if (mat.length)
out.push(
el("h4", { className: "m02-hopyo__subhead", text: "자재" }),
grid(
[
"",
tc("Col_Order"),
tc("Labor_Code"),
tc("Col_Name"),
tc("Labor_Spec"),
tc("Labor_QtyCol"),
tc("Labor_Unit"),
...markHeads(),
...PRICE_SLOTS,
`엑셀 / 우리 ${tc("Labor_AppliedSum")}`,
"",
],
mat.map((l) => matRow(l, ctx)),
"mat",
),
);
return out;
}
@@ -1,10 +1,11 @@
/* =============================================================================
* M02_MasterTemplete_Hopyo_Choose.ts
* 일위대가 실무자료 고르기 창(PLAN 338-3 · 설계 4-2 · 옛 `Hopyo_View.choose` 를 옮김) + 글 도우미.
* · 위 = 엑셀 블록 품(직종 · 장비 · 자재) · 인용 조항 · [엑셀에서 보기](단가산출 그 행)
* · 후보 다섯 = 이름 · 키 · 판정 꼬리표(맞음 / 다른 점 / 후보) · 글 · 근거 꼬리표(근거 맞음 초록) · 금액 비 없음
* · 고름 = [고름](한 로직) · 또는 체크 + 몫 칸 → [고른 n개 함께](조합) · 옵션은 부르는 쪽이 서버 `guess` 로 채움
* · [다른 로직 찾기] = `openLogicPicker` 에 찾기 글 미리 넣음(엑셀 인용 조항 번호 → 없으면 품명 첫 뜻 낱말)
* 일위대가 실무자료 고르기 창(PLAN 338-3 · 358 · 설계 4-2 · 옛 `Hopyo_View.choose` 를 옮김) + 글 도우미.
* · 위 = 엑셀 블록 품(직종 · 장비 · 자재) · 인용 조항 · [엑셀 보기](단가산출 그 행)
* · 후보 묶음(358-1 · 숨김 없음) = 1위 + 판정 / 출처 같음 / 이름 닮은 로직(옛 324 다섯) / 숫자만 닮음(접힘 ·
* 뜻 낱말 안 겹치는 「후보」는 회색 끝) · 같은 이름이면 책(산림 · 건설) · 판정 꼬리표 · 근거 꼬리표(근거 맞음 초록)
* · 고름 = [후보](한 로직) · 또는 체크 + 몫 칸 → [일괄 적용(n)](조합) · 옵션은 부르는 쪽이 서버 `guess` 로 채움
* · [다른 로직 검색] = `openLogicPicker` 에 품명 첫 뜻 낱말 · [다른 자재 검색] = 자재 줄 · 재료비 있는 줄(358-2)
* 저장 안 함 — 고른 것은 부르는 쪽 캐시(확인 창)로만 감.
* ========================================================================== */
@@ -15,6 +16,7 @@ import type {
HopyoBasisHit,
HopyoLine,
HopyoLineBasis,
HopyoMatch,
HopyoQty,
HopyoTarget,
} from "./M02_MasterTemplete_Hopyo_Api";
@@ -95,13 +97,71 @@ export const qtyText = (q: HopyoQty | undefined): string =>
].join(" · ")
: "";
/** [다른 로직 찾기] 미리 넣을 글 — 엑셀 인용 조항 번호 → 없으면 품명 괄호 밖 첫 뜻 낱말 */
export function searchHint(line: Pick<HopyoLine, "이름" | "근거">): string {
const clause = line.근거?.출처?.find((x) => x[1])?.[1];
if (clause) return clause;
/** [다른 로직 검색] 미리 넣을 글 — 품명 괄호 밖 첫 뜻 낱말(358-1 · 조항 번호는 모든 낱말 맞춤이라 0건 위험) */
export function searchHint(line: Pick<HopyoLine, "이름">): string {
return /[가-힣A-Za-z]{2,}/.exec(line.이름.replace(/\([^()]*\)/g, " "))?.[0] ?? "";
}
/** 후보 묶음 하나 — 제목 · 접힘(숫자만 닮음) · 후보 */
export interface CandGroup {
제목: string;
접힘: boolean;
후보: HopyoTarget[];
}
/** 약한 후보 — 판정 「후보」 + 품명 뜻 낱말 안 겹침(회색 · 묶음 끝) */
export const weakCand = (c: HopyoTarget): boolean => c.판정 === "후보" && c.뜻겹침 === false;
/** 후보 묶음(358-1 · 숨김 없음 · 차례는 서버 그대로) — 1위 + 판정 / 출처 같음 / 이름 닮은 로직(옛 판 다섯) /
* 숫자만 닮음(접힘 · 약한 후보 뒤로) · 한 후보는 한 묶음에만 · 자재 줄 = 1위 / 그 밖 후보 */
export function candGroups(
m: Pick<HopyoMatch, "후보" | "이름후보">,
material = false,
): CandGroup[] {
const all = m.후보 ?? [];
const seen = new Set<string>();
const take = (list: HopyoTarget[]): HopyoTarget[] =>
list.filter((c) => {
const id = c.로직 ?? c.자재 ?? c.이름 ?? "";
if (seen.has(id)) return false;
seen.add(id);
return true;
});
const top = take(all.slice(0, 1));
if (material)
return [
{ 제목: "1위", 접힘: false, 후보: top },
{ 제목: "그 밖 후보", 접힘: false, 후보: take(all.slice(1)) },
].filter((g) => g.후보.length);
const named = m.이름후보 ?? [];
const source = take([...all.slice(1), ...named].filter((c) => c.출처같음));
const name = take(named);
const rest = take(all.slice(1));
rest.sort((a, b) => Number(weakCand(a)) - Number(weakCand(b)));
return [
{ 제목: "1위", 접힘: false, 후보: top },
{ 제목: "출처 같음", 접힘: false, 후보: source },
{ 제목: "이름 닮은 로직", 접힘: false, 후보: name },
{ 제목: "숫자만 닮음", 접힘: true, 후보: rest },
].filter((g) => g.후보.length);
}
/** 같은 이름 후보 — 책(산림 · 건설)을 붙여 가를 이름 */
export function sameNames(groups: CandGroup[]): Set<string> {
const count = new Map<string, number>();
for (const c of groups.flatMap((g) => g.후보))
count.set(c.이름 ?? "", (count.get(c.이름 ?? "") ?? 0) + 1);
return new Set([...count].filter(([name, n]) => name && n > 1).map(([name]) => name));
}
/** 후보 단추 글 — 「이름 [단위] · 키」 · 같은 이름이면 책 두 글자(산림 · 건설) */
export const candText = (c: HopyoTarget, twins: Set<string>): string =>
`${targetText(c)} · ${c.로직 ?? c.자재 ?? ""}${twins.has(c.이름 ?? "") && c.책 ? ` · ${c.책.slice(0, 2)}` : ""}`;
/** 자재 고름 — 자재 · 별도계상 줄 · 재료비 있는 단가산출 줄(358-2 · P.V.C파이프설치 등) */
export const canPickMaterial = (line: Pick<HopyoLine, "갈래" | "단가">): boolean =>
MATERIAL.has(line.갈래) || !!line.단가?.재료비;
/** 고른 후보 → 만들기 대상(배 1 · 몫 있으면 배 = 몫) */
export const pickedTargets = (picked: { c: HopyoTarget; 몫: number | null }[]): BuildTarget[] =>
picked.map(({ c, 몫 }) => ({
@@ -113,12 +173,12 @@ export const pickedTargets = (picked: { c: HopyoTarget; 몫: number | null }[]):
export interface ChooseOptions {
line: HopyoLine;
/** [엑셀에서 보기] — 단가산출 그 행 */
/** [엑셀 보기] — 단가산출 그 행 */
openSheet?: (행: [number, number]) => void;
done: (targets: BuildTarget[]) => void;
}
/** 고르기 창 — 엑셀 품 · 인용 조항 · 후보 다섯(하나 또는 여럿 · 몫) · 다른 로직 · 자재 찾기 */
/** 고르기 창 — 엑셀 품 · 인용 조항 · 후보 묶음(하나 또는 여럿 · 몫) · 다른 로직 · 자재 검색 */
export function openHopyoChoose(opt: ChooseOptions): void {
const { line } = opt;
const material = MATERIAL.has(line.갈래);
@@ -132,9 +192,9 @@ export function openHopyoChoose(opt: ChooseOptions): void {
opt.done(targets);
};
const picked = new Map<HopyoTarget, HTMLInputElement>();
const together = createButton({ label: "고른 0개 함께", size: "sm", disabled: true });
const together = createButton({ label: "일괄 적용(0)", size: "sm", disabled: true });
const paint = (): void => {
together.querySelector(".ui-btn__label")!.textContent = `고른 ${picked.size}개 함께`;
together.querySelector(".ui-btn__label")!.textContent = `일괄 적용(${picked.size})`;
together.disabled = picked.size < 2;
};
together.addEventListener("click", () =>
@@ -144,7 +204,9 @@ export function openHopyoChoose(opt: ChooseOptions): void {
),
),
);
const list = (line.맞춤.후보 ?? []).map((c) => {
const groups = candGroups(line.맞춤, material);
const twins = sameNames(groups);
const row = (c: HopyoTarget): HTMLElement => {
const share = el("input", {
className: "ui-input ui-input--sm m02-hopyo__times",
attrs: { type: "number", step: "0.05", min: "0", max: "1", placeholder: "몫" },
@@ -157,12 +219,12 @@ export function openHopyoChoose(opt: ChooseOptions): void {
});
const tag = basisTag(c.근거);
return el("div", {
className: "m02-hopyo__cand",
className: `m02-hopyo__cand${weakCand(c) ? " is-weak" : ""}`,
attrs: { "data-cand": c.로직 ?? c.자재 ?? "" },
children: [
box,
createButton({
label: `${targetText(c)} · ${c.로직 ?? c.자재 ?? ""}`,
label: candText(c, twins),
variant: "ghost",
size: "sm",
onClick: () => finish(pickedTargets([{ c, 몫: null }])),
@@ -176,6 +238,7 @@ export function openHopyoChoose(opt: ChooseOptions): void {
]
: []),
...(c.글 ? [note(c.글)] : []),
...(weakCand(c) ? [note("이름 뜻 안 겹침")] : []),
...(tag && c.근거
? [
el("span", {
@@ -188,6 +251,18 @@ export function openHopyoChoose(opt: ChooseOptions): void {
share,
],
});
};
const sections = groups.map((g) => {
const list = el("div", { className: "m02-hopyo__cands", children: g.후보.map(row) });
const title = `${g.제목} (${g.후보.length})`;
if (!g.접힘)
return el("div", { attrs: { "data-group": g.제목 }, children: [note(title), list] });
const fold = el("details", {
className: "m02-hopyo__group",
attrs: { "data-group": g.제목 },
children: [el("summary", { text: `${title} — 이름 · 출처 안 닮음 · 숫자 지문만` }), list],
});
return fold;
});
const excel = qtyText(line.맞춤.품?.엑셀);
const basis = line.근거;
@@ -199,7 +274,7 @@ export function openHopyoChoose(opt: ChooseOptions): void {
...(basis && opt.openSheet
? [
createButton({
label: "엑셀에서 보기",
label: "엑셀 보기",
variant: "ghost",
size: "sm",
onClick: () => opt.openSheet?.(basis.행),
@@ -208,23 +283,37 @@ export function openHopyoChoose(opt: ChooseOptions): void {
: []),
],
});
const more = createButton({
label: material ? "다른 자재 찾기…" : "다른 로직 찾기…",
const findMaterial = createButton({
label: "다른 자재 검색…",
size: "sm",
onClick: () => {
close();
if (material) pickMaterial((x) => opt.done([{ 자재: x.코드, 배: 1 }]));
else
openLogicPicker((x) => opt.done([{ 로직: x.키, 입력값: {}, 배: 1 }]), undefined, {
query: searchHint(line),
});
pickMaterial((x) => opt.done([{ 자재: x.코드, 배: 1 }]));
},
});
const findLogic = createButton({
label: "다른 로직 검색…",
size: "sm",
onClick: () => {
close();
openLogicPicker((x) => opt.done([{ 로직: x.키, 입력값: {}, 배: 1 }]), undefined, {
query: searchHint(line),
});
},
});
const more = [
...(material ? [] : [findLogic]),
...(canPickMaterial(line) ? [findMaterial] : []),
];
body.append(
...(head.childElementCount ? [head] : []),
note(list.length ? "후보 — 하나 고름 · 여럿은 체크(몫 칸) 뒤 [함께]" : "가까운 것 없음"),
el("div", { className: "m02-hopyo__cands", children: list }),
el("div", { className: "m02-hopyo__bar", children: [together, more] }),
note(
groups.length
? "후보 — 하나 고름 · 여럿은 체크(몫 칸) 뒤 [일괄 적용]"
: "가까운 것 없음 — 아래 검색",
),
...sections,
el("div", { className: "m02-hopyo__bar", children: [together, ...more] }),
);
},
});
@@ -62,9 +62,9 @@ export interface HopyoSheetOptions {
canWrite: boolean;
/** 사전조합을 만들 층(없으면 `scope` · [로직 추가] = 문서 층) */
target?: HopyoScope;
/** 처음 고를 호표 번(호표 화면 [엑셀에서 보기]) */
/** 처음 고를 호표 번(확인 창 머리 [엑셀 보기]) */
호표?: number;
/** 처음 보일 단가산출 행(330-3 근거 [엑셀에서 보기]) */
/** 처음 보일 단가산출 행(330-3 근거 [엑셀 보기]) */
단산행?: number | [number, number];
/** 호표 하나를 만들었음 — [로직 추가] 참조 줄 */
onImport?: (got: HopyoBuilt, hopyo: HopyoBrief) => void;
@@ -0,0 +1,56 @@
/* =============================================================================
* M02_MasterTemplete/logic/M02_MasterTemplete_Hopyo_Draft.ts
* 확인 창 금액 미리 보기 꼴(PLAN 359-3) — 엑셀 줄 하나 + 지금 대상 → 일위대가 문서 한 벌(서버 `/labor/{열}/calc` 에
* 보냄 · 저장 안 함 · 금액은 서버가 셈) · 줄이 들어갈 표(로직 표 · 자재 표).
* 정본 = 서버 `M02_Hopyo_Build.Builder.build` 의 `put`([사전조합 생성] 때 다시 셈) — 같은 규칙:
* 우리 수량 = 엑셀 수량 × 배 · 로직 = {로직, 입력값, 수량 {손}} · 자재 = {코드, 수량 {손}, 별도계상 =
* 갈래 별도계상이면 「별도계상」 아니면 「직접계산」} · 같은 자재 = 수량 더함.
* 미리 보기만 다른 것: 호표 대상(하위 사전조합 — 만든 뒤 셈) · 배 없는 대상은 뺌 ·
* 같은 로직 둘째는 뺌(Build = 한 줄 사전조합으로 감쌈 · 한 문서에 같은 로직 둘 = 서버 400).
* 화면 · fetch · sessionStorage 없음(`CLAUDE.md` 4장).
* ========================================================================== */
import type { HopyoLine, HopyoPart } from "../M02_MasterTemplete_Hopyo_Api";
import type { LaborDoc, LaborMat, LaborRow } from "../M02_MasterTemplete_Labor_Api";
export type CheckTable = "logic" | "mat";
/** 엑셀 갈래 중 자재 표로 가는 것(대상 없을 때) */
const MAT_KINDS = ["자재", "별도계상"];
/** 줄이 들어갈 표 — 대상이 모두 자재면 자재 표 · 대상 없으면 갈래(자재 · 별도계상) · 그 밖 로직 표 */
export function tableOf(line: Pick<HopyoLine, "갈래">, parts: HopyoPart[]): CheckTable {
if (parts.length) return parts.every((p) => !!p.자재) ? "mat" : "logic";
return MAT_KINDS.includes(line.갈래) ? "mat" : "logic";
}
/** 엑셀 줄 + 대상 → 미리 셈 문서 · 셀 것이 없으면 null(호표 대상뿐 · 배 없음 · 대상 없음) */
export function draftDoc(
line: Pick<HopyoLine, "수량" | "갈래">,
parts: HopyoPart[],
): LaborDoc | null {
const rows: LaborRow[] = [];
const mats = new Map<string, LaborMat>();
const apart = line.갈래 === "별도계상" ? "별도계상" : "직접계산";
for (const p of parts) {
if (p.배 === null || p.호표 !== undefined) continue;
const qty = (line.수량 ?? 0) * p.배;
if (p.자재) {
const have = mats.get(p.자재)?.수량;
const hand = (have ? have.손 : 0) + qty;
mats.set(p.자재, {
코드: p.자재,
이름: null,
규격: null,
단위: null,
수량: { 손: hand },
별도계상: apart,
});
} else if (p.로직 && !rows.some((r) => r.로직 === p.로직)) {
const values = (p.입력값 ?? {}) as LaborRow["입력값"];
rows.push({ 로직: p.로직, 별칭: null, 메모: null, 입력값: values, 수량: { 손: qty } });
}
}
if (!rows.length && !mats.size) return null;
return { 종류: "일위대가", 열: "", 단위: null, 담은로직: rows, 자재: [...mats.values()] };
}