feat(M02): 338-3c 확인 창 · 고르기 창 · 별칭 장부 화면 — 체크한 줄만 [확인하고 사전조합 만들기] · 옛 즉시 확정 · 바로 만들기 없앰
- 새 Hopyo_Check(확인 창 · 본 칸 · 모달) — 상태 색 · 체크 제안 · 우리 로직 여럿 · 배 · 몫 · 옵션 · 품 비교 · 뺌 · 원문 보기 · 캐시 m02-hopyo-check · 셈 중 3 초 다시 읽음 · 최신 응답만 - 새 Hopyo_Choose(고르기 창 · 후보 판정 · 여럿 고름 · 몫 · 찾기 글 미리 넣음) · 새 Hopyo_Ledger(장부 화면 · 고친 것 캐시 · [저장] 때만 PUT) - 고침 View(호표 하나 = 확인 창) · Sheet(펼침 뒤 한 번 · 하위 호표 먼저) · Pick(확인된 것 바로 참조 · 아니면 확인 창) · Api(3-3 꼴 · 만들기 몸 · 장부 길) · Side([별칭 장부]) · css · Labor_Pick(query) - 없앰 saveHopyoMemory · [확정] · [같음 모두 확정] · [근거 맞음 모두 확정] · 바로 만들기 - 시험 test_m02_hopyo_screen.py 새로 · helper_m02_hopyo_check.cjs 새 · pick · seam 도우미 손질 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XWh5wNNycMkbwMpACz1rte
This commit is contained in:
@@ -49,8 +49,8 @@
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* 줄 표 — 갈래 · 수량 · 단위 · 엑셀 단가 · 맞춤 · 입력값 · 환산 · 단추 = 줄바꿈 없음(글자가 세로로 안
|
||||
갈라짐 · 폭 = 글 폭) · 품명 · 규격 · 대상이 남는 폭 · 칸 여백 좁힘(공용 표보다 앞서게 두 갈래 이름) */
|
||||
/* 확인 창 줄 표(338) — 체크 · 갈래 · 수량 · 단위 · 상태 · 단추 = 줄바꿈 없음(글자가 세로로 안 갈라짐 ·
|
||||
폭 = 글 폭) · 품명 · 규격 · 우리 로직 · 옵션 · 품 비교가 남는 폭 · 칸 여백 좁힘(공용 표보다 앞서게 두 갈래 이름) */
|
||||
.m02-hopyo__table.m02-hopyo__lines :is(th, td) {
|
||||
padding: var(--spacing-8);
|
||||
}
|
||||
@@ -58,19 +58,17 @@
|
||||
.m02-hopyo__table.m02-hopyo__lines
|
||||
:is(th, td):is(
|
||||
:nth-child(1),
|
||||
:nth-child(4),
|
||||
:nth-child(2),
|
||||
:nth-child(5),
|
||||
:nth-child(6),
|
||||
:nth-child(7),
|
||||
:nth-child(9),
|
||||
:nth-child(10),
|
||||
:nth-child(11)
|
||||
) {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 입력값 — 길면 줄임표(전체 글은 칸 풍선 도움말) */
|
||||
.m02-hopyo__table.m02-hopyo__lines td:nth-child(9) {
|
||||
/* 옵션 · 품 비교 — 길면 줄임표(전체 글은 칸 풍선 도움말) */
|
||||
.m02-hopyo__table.m02-hopyo__lines td:is(:nth-child(9), :nth-child(10)) {
|
||||
max-width: 24em;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@@ -89,23 +87,56 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.m02-hopyo__state.is-기억,
|
||||
.m02-hopyo__state.is-같음,
|
||||
/* 상태 색(338) — 맞음 초록 · 별칭 파랑 · 다른 점 주황 · 후보 회색(테두리 기본) · 없음 빨강 */
|
||||
.m02-hopyo__state.is-맞음,
|
||||
.m02-hopyo__state.is-호표 {
|
||||
border-color: var(--color-success);
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.m02-hopyo__state.is-후보 {
|
||||
.m02-hopyo__state.is-별칭 {
|
||||
border-color: var(--color-info);
|
||||
color: var(--color-info);
|
||||
}
|
||||
|
||||
.m02-hopyo__state.is-다른점 {
|
||||
border-color: var(--color-warning);
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
.m02-hopyo__state.is-후보,
|
||||
.m02-hopyo__state.is-뺌,
|
||||
.m02-hopyo__state.is-노임 {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.m02-hopyo__state.is-없음 {
|
||||
border-color: var(--color-danger);
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
/* 우리 로직 칸 — 대상 여럿 = 칸 안 여러 줄(이름 · 키 · 몫 · 배 칸 · [옵션]) */
|
||||
.m02-hopyo__parts {
|
||||
min-width: 14em;
|
||||
}
|
||||
|
||||
.m02-hopyo__part {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.m02-hopyo__part + .m02-hopyo__part {
|
||||
margin-top: var(--spacing-4);
|
||||
}
|
||||
|
||||
.m02-hopyo__whole {
|
||||
padding: var(--spacing-4) var(--spacing-8);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.m02-hopyo__times {
|
||||
width: 6em;
|
||||
}
|
||||
@@ -150,6 +181,22 @@
|
||||
margin-bottom: var(--spacing-8);
|
||||
}
|
||||
|
||||
.m02-hopyo__cand {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
/* 별칭 장부(338) — 위 층 줄 = 회색 읽기만 · 고친 줄 = 왼쪽 띠 */
|
||||
.m02-hopyo-ledger__row.is-upper {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.m02-hopyo-ledger__row.is-edited > td:first-child {
|
||||
box-shadow: inset 3px 0 0 var(--color-accent);
|
||||
}
|
||||
|
||||
/* 엑셀 보기 창(329) — 왼쪽 읽기 전용 시트 · 오른쪽 호표 목록 · 불러오기 결과 */
|
||||
.m02-hopyo-sheet__dialog > .ui-modal__body {
|
||||
overflow: hidden;
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_Hopyo_Api.ts
|
||||
* 일위대가 실무자료(PLAN 324) 서버 길 — `/api/m02/layers/{층}/hopyo…` · 층 = 화면 층(`Api_Fetch` 지금 층) ·
|
||||
* 올리기 = FormData(숨은 file 입력) · 그 밖 = 공용 `call`(서버 까닭 글 그대로).
|
||||
* 일위대가 실무자료(PLAN 324 · 338) 서버 길 — `/api/m02/layers/{층}/hopyo…` · 층 = 화면 층(`Api_Fetch` 지금 층) ·
|
||||
* 올리기 = FormData(숨은 file 입력) · 그 밖 = 공용 `call`(서버 까닭 글 그대로) ·
|
||||
* 맞춤 응답 꼴 = 설계 3-3(상태 · 체크 제안 · 대상 목록 · 품 비교) · 만들기 몸 = 확인한 줄만(3-6) · 별칭 장부(3-5).
|
||||
* ========================================================================== */
|
||||
|
||||
import { API_BASE_URL } from "@config/config_frontend";
|
||||
@@ -33,7 +34,7 @@ export interface HopyoBrief {
|
||||
규격: string;
|
||||
단위: string;
|
||||
금액: Money;
|
||||
/** [들어감 수, 줄 수] */
|
||||
/** [체크 제안 수, 줄 수] */
|
||||
맞춤: [number, number];
|
||||
/** 일위대가 시트 행 [시작, 끝](엑셀 1부터 · 329 · 옛 파일 없음) */
|
||||
행?: [number, number];
|
||||
@@ -75,7 +76,7 @@ export interface HopyoLineBasis {
|
||||
행: [number, number];
|
||||
}
|
||||
|
||||
/** 맞춤 대상 — 로직 · 자재 · 다른 호표 */
|
||||
/** 후보 · 고른 대상 글 — 로직 · 자재 · 다른 호표 */
|
||||
export interface HopyoTarget {
|
||||
로직?: string;
|
||||
자재?: string;
|
||||
@@ -86,17 +87,55 @@ export interface HopyoTarget {
|
||||
점수?: number;
|
||||
/** 근거 맞춤(330 · 옛 파일 · 근거 없는 줄은 없음) */
|
||||
근거?: HopyoBasisHit;
|
||||
/** 후보 판정(338) — 맞음 · 다른 점 · 후보 */
|
||||
판정?: "맞음" | "다른 점" | "후보";
|
||||
글?: string;
|
||||
}
|
||||
|
||||
/** 맞춤 대상 하나(338 · 로직 여럿 가능) — 배 = 환산 × 몫 · 우리 수량 = 엑셀 수량 × 배 */
|
||||
export interface HopyoPart extends HopyoTarget {
|
||||
입력값: Record<string, unknown>;
|
||||
못읽음?: string[];
|
||||
/** 칸마다 값 출처 — 별칭 · 블록 · 규격 · 자동값 */
|
||||
출처?: Record<string, string>;
|
||||
환산?: { 배: number | null; 갈래: string } | null;
|
||||
몫?: number | null;
|
||||
배: number | null;
|
||||
/** 블록 절 제목(조합 덧 로직) */
|
||||
어디?: string;
|
||||
}
|
||||
|
||||
/** 품 묶음 — 직종 품 · 장비 시간 · 자재 수량 */
|
||||
export interface HopyoQty {
|
||||
인력?: Record<string, number>;
|
||||
장비?: { 갈래: string | string[]; 시간: number }[];
|
||||
자재?: { 이름: string; 수량: number }[];
|
||||
}
|
||||
|
||||
export type HopyoState =
|
||||
"맞음" | "별칭" | "다른 점" | "후보" | "없음" | "뺌" | "호표" | "%" | "노임";
|
||||
|
||||
export interface HopyoMatch {
|
||||
열쇠: string;
|
||||
상태: "기억" | "같음" | "후보" | "없음" | "빼기" | "호표" | "%";
|
||||
대상: HopyoTarget | null;
|
||||
입력값: Record<string, unknown>;
|
||||
못읽음: string[];
|
||||
환산: { 배: number | null; 갈래: string } | null;
|
||||
들어감: boolean;
|
||||
상태: HopyoState;
|
||||
/** 처음 체크 제안(확인 대기 — 들어감 아님) */
|
||||
체크: boolean;
|
||||
대상: HopyoPart[];
|
||||
후보?: HopyoTarget[];
|
||||
글?: string;
|
||||
꼬리표?: string[];
|
||||
품?: { 엑셀: HopyoQty; 우리: HopyoQty; 같음: Record<string, boolean> };
|
||||
갈리는칸?: string[];
|
||||
별칭?: { 층: Layer; 주인: string | null; 규격다름?: boolean; 근거다름?: boolean };
|
||||
뺌?: string;
|
||||
}
|
||||
|
||||
/** 숫자 판정 셈 — 끝 · 한 블록 · 모두 · 까닭(우리 로직 준비 중 등) */
|
||||
export interface HopyoCalc {
|
||||
끝: boolean;
|
||||
한: number;
|
||||
모두: number;
|
||||
까닭?: string;
|
||||
}
|
||||
|
||||
export interface HopyoLine {
|
||||
@@ -115,14 +154,38 @@ export interface HopyoLine {
|
||||
}
|
||||
|
||||
export interface HopyoOne extends HopyoFile {
|
||||
호표: HopyoBrief & { 줄: HopyoLine[] };
|
||||
호표: HopyoBrief & {
|
||||
줄: HopyoLine[];
|
||||
/** 직접 줄 호표 통째 제안(노임 · 장비를 바로 적은 호표) */
|
||||
통째?: { 대상: HopyoPart[]; 글?: string; 체크: boolean } | null;
|
||||
셈?: HopyoCalc;
|
||||
/** 만들 층에 이 호표로 만든 사전조합 */
|
||||
만든?: { 키: string; 확인: boolean } | null;
|
||||
};
|
||||
}
|
||||
|
||||
/** 미리 셈(저장 안 함) — 고른 대상 · 옵션으로 우리 품 */
|
||||
export interface HopyoGuess {
|
||||
대상: HopyoTarget;
|
||||
입력값: Record<string, unknown>;
|
||||
못읽음: string[];
|
||||
환산: { 배: number | null; 갈래: string };
|
||||
대상: HopyoPart[];
|
||||
품?: HopyoMatch["품"];
|
||||
글?: string;
|
||||
}
|
||||
|
||||
/** 만들기 몸 대상 하나 — 로직 · 자재 · 호표 중 하나(금액 · 품 칸 없음 — 서버가 셈) */
|
||||
export interface BuildTarget {
|
||||
로직?: string;
|
||||
자재?: string;
|
||||
호표?: number;
|
||||
입력값?: Record<string, unknown>;
|
||||
배: number;
|
||||
몫?: number | null;
|
||||
}
|
||||
|
||||
/** 확인한 줄 — 대상 목록 또는 뺌 까닭 */
|
||||
export interface BuildLine {
|
||||
차례: number;
|
||||
대상?: BuildTarget[];
|
||||
뺌?: "우리에 없음" | "안 씀";
|
||||
}
|
||||
|
||||
export interface HopyoMissing {
|
||||
@@ -141,22 +204,45 @@ export interface HopyoBuilt {
|
||||
다시씀: boolean;
|
||||
빠진줄: HopyoMissing[];
|
||||
비교: { 엑셀: number | null; 우리: number | null; 차이: number | null; 까닭: string | null };
|
||||
장부?: { 씀: number };
|
||||
}
|
||||
|
||||
/** [로직 추가] 「실무 호표」 줄 하나 */
|
||||
/** [로직 추가] 「실무 호표」 줄 하나 — 확인 = 문서 층에 확인된 사전조합 */
|
||||
export interface HopyoPickRow extends Omit<HopyoBrief, "금액"> {
|
||||
층: Layer;
|
||||
주인: string | null;
|
||||
키: string;
|
||||
파일: string;
|
||||
확인?: LaborRef | null;
|
||||
}
|
||||
|
||||
/** 확정 한 줄 — 로직 · 자재 · 빼기 · null(지움) */
|
||||
export type HopyoMemory =
|
||||
| { 로직: string; 입력값: Record<string, unknown>; 환산: number }
|
||||
| { 자재: string; 입력값: Record<string, unknown>; 환산: number }
|
||||
| { 빼기: true }
|
||||
| null;
|
||||
/** 별칭 장부 짝 하나 */
|
||||
export interface LedgerPair {
|
||||
로직?: string;
|
||||
자재?: string;
|
||||
입력값: Record<string, unknown>;
|
||||
배: number;
|
||||
몫: number | null;
|
||||
}
|
||||
|
||||
/** 별칭 장부 한 줄(쓰기 꼴) */
|
||||
export interface LedgerEntry {
|
||||
원래: { 품명: string; 규격: string; 단위: string };
|
||||
갈래: "짝" | "우리에 없음" | "안 씀";
|
||||
짝: LedgerPair[];
|
||||
엑셀조항?: unknown[];
|
||||
}
|
||||
|
||||
/** 별칭 장부 보기 한 줄 — 층 · 고침(이 층 · 쓰기 권한) */
|
||||
export interface LedgerRow extends LedgerEntry {
|
||||
열쇠: string;
|
||||
누가: { id: number; 이름: string | null } | null;
|
||||
때: string | null;
|
||||
처음파일: string | null;
|
||||
층: Layer;
|
||||
주인: string | null;
|
||||
고침: boolean;
|
||||
}
|
||||
|
||||
const enc = encodeURIComponent;
|
||||
/** 지금 화면 층(구조물 조합 층) */
|
||||
@@ -177,39 +263,58 @@ export const readHopyo = (scope: HopyoScope, key: string): Promise<HopyoView> =>
|
||||
export const readHopyoGrid = (scope: HopyoScope, key: string): Promise<HopyoGrid> =>
|
||||
call(at(scope, `/hopyo-grid/${enc(key)}`));
|
||||
|
||||
export const readHopyoOne = (scope: HopyoScope, key: string, no: number): Promise<HopyoOne> =>
|
||||
call(at(scope, `/hopyo/${enc(key)}/${no}`));
|
||||
/** 호표 하나 — `to` = 만들 층(호표 참조 줄 체크 · 만든 · 없으면 파일 층) */
|
||||
export const readHopyoOne = (
|
||||
scope: HopyoScope,
|
||||
key: string,
|
||||
no: number,
|
||||
to?: HopyoScope,
|
||||
): Promise<HopyoOne> => {
|
||||
const want = to ? `?to=${to.layer}${to.owner ? `&to_owner=${enc(to.owner)}` : ""}` : "";
|
||||
return call(at(scope, `/hopyo/${enc(key)}/${no}${want}`));
|
||||
};
|
||||
|
||||
/** 미리 셈(저장 안 함) — 고른 대상 목록 · 옵션 → 우리 품 */
|
||||
export const guessHopyo = (
|
||||
scope: HopyoScope,
|
||||
key: string,
|
||||
no: number,
|
||||
차례: number,
|
||||
대상: HopyoTarget,
|
||||
대상: BuildTarget[],
|
||||
): Promise<HopyoGuess> =>
|
||||
call(at(scope, `/hopyo/${enc(key)}/${no}/guess`), {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ 차례, 대상 }),
|
||||
});
|
||||
|
||||
export const saveHopyoMemory = (
|
||||
export const readLedger = async (scope: HopyoScope): Promise<LedgerRow[]> =>
|
||||
(await call<{ 줄: LedgerRow[] }>(at(scope, "/hopyo-ledger"))).줄;
|
||||
|
||||
/** 별칭 장부 [저장] — 고친 줄 · null(지움) · 이 층만 */
|
||||
export const saveLedger = async (
|
||||
scope: HopyoScope,
|
||||
줄: Record<string, HopyoMemory>,
|
||||
): Promise<unknown> =>
|
||||
call(at(scope, "/hopyo-memory"), { method: "POST", body: JSON.stringify({ 줄 }) });
|
||||
줄: Record<string, LedgerEntry | null>,
|
||||
): Promise<LedgerRow[]> =>
|
||||
(
|
||||
await call<{ 줄: LedgerRow[] }>(at(scope, "/hopyo-ledger"), {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ 줄 }),
|
||||
})
|
||||
).줄;
|
||||
|
||||
export const deleteHopyo = (scope: HopyoScope, key: string): Promise<unknown> =>
|
||||
call(at(scope, `/hopyo/${enc(key)}`), { method: "DELETE" });
|
||||
|
||||
/** 실무 호표 → `target` 층 사전조합(파일 층 = `from`) */
|
||||
/** [확인하고 사전조합 만들기] — 확인한 줄만 · 직접 줄 호표 = `통째` → `target` 층 사전조합(파일 층 = `from`) */
|
||||
export const buildHopyo = (
|
||||
target: HopyoScope,
|
||||
from: { 층: Layer; 주인: string | null; 키: string; 번: number },
|
||||
새로 = false,
|
||||
줄: BuildLine[],
|
||||
통째?: { 대상: BuildTarget[] } | null,
|
||||
): Promise<HopyoBuilt> =>
|
||||
call(at(target, "/hopyo-build"), {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ ...from, 새로 }),
|
||||
body: JSON.stringify(통째 ? { ...from, 통째 } : { ...from, 줄 }),
|
||||
});
|
||||
|
||||
export const listHopyoPicks = async (scope: HopyoScope): Promise<HopyoPickRow[]> =>
|
||||
|
||||
@@ -0,0 +1,680 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_Hopyo_Check.ts
|
||||
* 일위대가 실무자료 확인 창(PLAN 338-3 · 설계 4-1) — 호표 하나의 줄마다 서버 맞춤(상태 · 체크 제안 ·
|
||||
* 우리 로직 · 옵션 · 품 비교)을 보이고, 사용자가 체크한 줄만 [확인하고 사전조합 만들기]로 서버에 보냄.
|
||||
* · 자리 = 실무자료 본 칸(호표 누름) · [로직 추가] 「실무 호표」 모달 · 엑셀 보기 창 펼침 [확인 창 열기]
|
||||
* · 머리 = 호표 · 「제안 n / 줄 m」 · 숫자 판정 셈 중이면 3 초마다 다시 읽음(끝나면 멈춤 · 만진 줄은 그대로)
|
||||
* · 줄 = [✓] · 갈래 · 품명 · 규격 · 수량 · 단위 · 상태(맞음 · 별칭 · 다른 점 · 후보) · 우리 로직(여럿 · 몫 · 배) ·
|
||||
* 옵션(칸=값 · 출처 · 못 가름) · 품 비교(엑셀 → 우리 ✓ / ≠) · [고르기] [원문 보기] [엑셀에서 보기] · 뺌 까닭
|
||||
* · 대상 · 옵션을 바꾸면 서버 `guess`(저장 안 함 · 줄마다 최신 응답만)가 우리 품을 다시 셈
|
||||
* · 캐시 = sessionStorage `m02-hopyo-check:{층}:{주인}:{키}:{번}`(조작 즉시 · 만들기 성공하면 지움) · 자동 저장 없음
|
||||
* · 화면 잠금은 [확인하고 사전조합 만들기] 때만 · 쓰기 단추 = 만들 층 쓰기 권한만.
|
||||
* 계산은 서버(`M02_Hopyo_Match` · `M02_Hopyo_Build`) — 화면은 보이기 · 체크 · 고름만.
|
||||
* ========================================================================== */
|
||||
|
||||
import {
|
||||
createButton,
|
||||
el,
|
||||
hideLoadingOverlay,
|
||||
showConfirmDialog,
|
||||
showLoadingOverlay,
|
||||
showToast,
|
||||
} from "@ui/ui_template_elements";
|
||||
import { loadingError, showLoading } from "@ui/ui_template_loading";
|
||||
import { openModal } from "@ui/ui_template_modal";
|
||||
import { createSourceView } from "../M01_MasterData/M01_MasterData_UI_LogicLab_Source";
|
||||
import {
|
||||
buildHopyo,
|
||||
guessHopyo,
|
||||
readHopyoOne,
|
||||
type BuildLine,
|
||||
type BuildTarget,
|
||||
type HopyoBuilt,
|
||||
type HopyoLine,
|
||||
type HopyoMatch,
|
||||
type HopyoOne,
|
||||
type HopyoPart,
|
||||
type HopyoScope,
|
||||
} from "./M02_MasterTemplete_Hopyo_Api";
|
||||
import {
|
||||
builtWord,
|
||||
inputsText,
|
||||
openHopyoChoose,
|
||||
sourceText,
|
||||
targetText,
|
||||
} from "./M02_MasterTemplete_Hopyo_Choose";
|
||||
import type { HopyoSheetOptions } from "./M02_MasterTemplete_Hopyo_Sheet";
|
||||
import { fetchLogicInputs } from "./M02_MasterTemplete_Labor_Api";
|
||||
import { openLaborInput } from "./M02_MasterTemplete_Labor_Input";
|
||||
import "./M02_MasterTemplete_Hopyo.css";
|
||||
|
||||
export const POLL_MS = 3000;
|
||||
const OUT = ["우리에 없음", "안 씀"] as const;
|
||||
|
||||
/** 확인 창 캐시 — 사용자가 만진 것만(서버 제안 위에 덮어 보임) */
|
||||
export interface CheckDraft {
|
||||
체크: Record<number, boolean>;
|
||||
대상: Record<number, HopyoPart[]>;
|
||||
뺌: Record<number, string>;
|
||||
품: Record<number, HopyoMatch["품"]>;
|
||||
통째?: boolean;
|
||||
}
|
||||
|
||||
const empty = (): CheckDraft => ({ 체크: {}, 대상: {}, 뺌: {}, 품: {} });
|
||||
export const draftKey = (scope: HopyoScope, key: string, no: number): string =>
|
||||
`m02-hopyo-check:${scope.layer}:${scope.owner ?? ""}:${key}:${no}`;
|
||||
|
||||
export function loadDraft(k: string): CheckDraft {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(k);
|
||||
return raw ? { ...empty(), ...(JSON.parse(raw) as Partial<CheckDraft>) } : empty();
|
||||
} catch {
|
||||
return empty();
|
||||
}
|
||||
}
|
||||
export function saveDraft(k: string, d: CheckDraft): void {
|
||||
try {
|
||||
sessionStorage.setItem(k, JSON.stringify(d));
|
||||
} catch {
|
||||
/* 캐시 못 씀 = 이 화면에만 남음 */
|
||||
}
|
||||
}
|
||||
export function dropDraft(k: string): void {
|
||||
try {
|
||||
sessionStorage.removeItem(k);
|
||||
} catch {
|
||||
/* 없음 */
|
||||
}
|
||||
}
|
||||
|
||||
/** 줄의 지금 대상 — 사용자가 고른 것(캐시) · 없으면 서버 제안 */
|
||||
export const partsOf = (line: HopyoLine, d: CheckDraft): HopyoPart[] => {
|
||||
const got = d.대상[line.차례] ?? line.맞춤.대상;
|
||||
return Array.isArray(got) ? got : []; // 옛 응답(대상 하나) = 대상 없음
|
||||
};
|
||||
/** 체크 — 사용자가 만진 줄은 캐시 · 아니면 서버 제안 */
|
||||
export const isChecked = (line: HopyoLine, d: CheckDraft): boolean =>
|
||||
d.체크[line.차례] ?? !!line.맞춤.체크;
|
||||
/** 체크할 수 있음 — % 줄 아님 · 뺌 까닭 또는 대상(배 0 넘음) */
|
||||
export function checkable(line: HopyoLine, d: CheckDraft): boolean {
|
||||
if (line.맞춤.상태 === "%") return false;
|
||||
if (d.뺌[line.차례]) return true;
|
||||
const parts = partsOf(line, d);
|
||||
return parts.length > 0 && parts.every((p) => p.배 !== null && p.배 > 0);
|
||||
}
|
||||
|
||||
/** 대상 → 만들기 몸 대상(이름 · 품 같은 보이기 칸은 안 보냄) */
|
||||
export function toTarget(p: HopyoPart): BuildTarget {
|
||||
if (p.호표 !== undefined) return { 호표: p.호표, 배: p.배 ?? 1 };
|
||||
const head = p.로직 ? { 로직: p.로직, 입력값: p.입력값 ?? {} } : { 자재: p.자재 };
|
||||
return { ...head, 배: p.배 ?? 1, ...(p.몫 != null ? { 몫: p.몫 } : {}) };
|
||||
}
|
||||
|
||||
/** 체크한 줄만 → 만들기 몸 `줄` */
|
||||
export const bodyOf = (lines: HopyoLine[], d: CheckDraft): BuildLine[] =>
|
||||
lines
|
||||
.filter((l) => isChecked(l, d) && checkable(l, d))
|
||||
.map((l) =>
|
||||
d.뺌[l.차례]
|
||||
? { 차례: l.차례, 뺌: d.뺌[l.차례] as BuildLine["뺌"] }
|
||||
: { 차례: l.차례, 대상: partsOf(l, d).map(toTarget) },
|
||||
);
|
||||
|
||||
/** 빠질 줄 까닭별 수 — 확인 안 함 · 뺌 · % 줄 */
|
||||
export function leftOut(lines: HopyoLine[], d: CheckDraft): Record<string, number> {
|
||||
const out: Record<string, number> = {};
|
||||
for (const l of lines) {
|
||||
const why =
|
||||
l.맞춤.상태 === "%"
|
||||
? "% 줄"
|
||||
: !isChecked(l, d) || !checkable(l, d)
|
||||
? "확인 안 함"
|
||||
: d.뺌[l.차례]
|
||||
? "뺌"
|
||||
: null;
|
||||
if (why) out[why] = (out[why] ?? 0) + 1;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const n4 = (v: number | undefined): string =>
|
||||
v === undefined ? "–" : String(Math.round(v * 10000) / 10000);
|
||||
|
||||
/** 품 비교 글 — 「콘크리트공 0.1091 → 0.1091 ✓ · 굴착기 0.29 → 0.31 ≠」 */
|
||||
export function compareText(q: HopyoMatch["품"] | undefined): string {
|
||||
if (!q) return "";
|
||||
const ex = q.엑셀.인력 ?? {};
|
||||
const ours = q.우리.인력 ?? {};
|
||||
const mark = (k: string): string => (q.같음[k] ? "✓" : "≠");
|
||||
const out = [...new Set([...Object.keys(ex), ...Object.keys(ours)])].map(
|
||||
(k) => `${k} ${n4(ex[k])} → ${n4(ours[k])} ${mark(k)}`,
|
||||
);
|
||||
const name = (g: string | string[]): string => [g].flat().join("+");
|
||||
for (const x of q.엑셀.장비 ?? []) {
|
||||
const o = (q.우리.장비 ?? []).find((y) => name(y.갈래) === name(x.갈래));
|
||||
out.push(`${name(x.갈래)} ${n4(x.시간)} → ${n4(o?.시간)} ${mark([x.갈래].flat()[0])}`);
|
||||
}
|
||||
return out.join(" · ");
|
||||
}
|
||||
|
||||
/** 줄마다 최신 요청만 — `ask(줄)` 이 돌려준 함수가 그 뒤 같은 줄 요청이 없을 때만 true */
|
||||
export function newest(): (key: number) => () => boolean {
|
||||
const last = new Map<number, number>();
|
||||
return (key) => {
|
||||
const my = (last.get(key) ?? 0) + 1;
|
||||
last.set(key, my);
|
||||
return () => last.get(key) === my;
|
||||
};
|
||||
}
|
||||
|
||||
/** [맞음 · 별칭 모두 체크] 대상 줄 */
|
||||
export const sureLines = (lines: HopyoLine[], d: CheckDraft): HopyoLine[] =>
|
||||
lines.filter((l) => ["맞음", "별칭"].includes(l.맞춤.상태) && checkable(l, d));
|
||||
|
||||
const why = (error: unknown): string => (error instanceof Error ? error.message : String(error));
|
||||
const won = (v: number | null | undefined): string =>
|
||||
v === null || v === undefined ? "" : v.toLocaleString("ko-KR");
|
||||
const fail = (error: unknown): void => showToast(`실패 — ${why(error)}`, "error");
|
||||
const note = (text: string): HTMLElement => el("span", { className: "m02-hopyo__note", text });
|
||||
const td = (text: string, cls = ""): HTMLElement =>
|
||||
el("td", { text, className: cls, attrs: { title: text } });
|
||||
const openSheet = (o: HopyoSheetOptions): void =>
|
||||
void import("./M02_MasterTemplete_Hopyo_Sheet").then((m) => m.openHopyoSheet(o), fail);
|
||||
|
||||
export interface CheckOptions {
|
||||
/** 실무자료 파일 층 */
|
||||
scope: HopyoScope;
|
||||
key: string;
|
||||
no: number;
|
||||
/** 사전조합을 만들 층(없으면 파일 층) */
|
||||
target?: HopyoScope;
|
||||
/** 만들 층 쓰기 권한 — 없으면 체크 · 만들기 없음(보기만) */
|
||||
canWrite: boolean;
|
||||
/** [← 호표 목록](본 칸) */
|
||||
onBack?: () => void;
|
||||
/** 만들었음 — [로직 추가] 참조 줄 · 모달 닫기 */
|
||||
onBuilt?: (got: HopyoBuilt, hopyo: HopyoOne["호표"]) => void;
|
||||
}
|
||||
|
||||
/** 확인 창 몸 — 본 칸 · 모달이 같이 씀 */
|
||||
export function buildHopyoCheck(opt: CheckOptions): HTMLElement {
|
||||
const root = el("div", {
|
||||
className: "m02-hopyo m02-hopyo-check",
|
||||
attrs: { "data-hopyo-check": `${opt.key}|${opt.no}` },
|
||||
});
|
||||
const target = opt.target ?? opt.scope;
|
||||
const k = draftKey(opt.scope, opt.key, opt.no);
|
||||
let d = loadDraft(k);
|
||||
let got: HopyoOne | null = null;
|
||||
let built: HopyoBuilt | null = null;
|
||||
let seq = 0;
|
||||
let timer = 0;
|
||||
const ask = newest(); // 줄마다 최신 미리 셈만
|
||||
const keep = (): void => saveDraft(k, d);
|
||||
|
||||
async function load(quiet = false): Promise<void> {
|
||||
const my = ++seq;
|
||||
const stop = quiet ? () => {} : showLoading(root);
|
||||
try {
|
||||
const one = await readHopyoOne(opt.scope, opt.key, opt.no, opt.target);
|
||||
if (my !== seq) return;
|
||||
got = one;
|
||||
draw();
|
||||
} catch (error) {
|
||||
if (my === seq && !quiet) root.replaceChildren(loadingError(why(error), () => void load()));
|
||||
} finally {
|
||||
stop();
|
||||
}
|
||||
}
|
||||
|
||||
/** 대상 바꿈 → 캐시 · 체크 · 서버 미리 셈(최신 응답만) */
|
||||
function setParts(line: HopyoLine, parts: HopyoPart[]): void {
|
||||
d.대상[line.차례] = parts;
|
||||
d.체크[line.차례] = true;
|
||||
delete d.뺌[line.차례];
|
||||
keep();
|
||||
draw();
|
||||
const mine = ask(line.차례);
|
||||
guessHopyo(opt.scope, opt.key, opt.no, line.차례, parts.map(toTarget)).then((g) => {
|
||||
if (!mine()) return;
|
||||
if (Array.isArray(g.대상)) d.대상[line.차례] = g.대상;
|
||||
if (g.품) d.품[line.차례] = g.품;
|
||||
keep();
|
||||
draw();
|
||||
}, fail);
|
||||
}
|
||||
|
||||
function editOptions(line: HopyoLine, at: number): void {
|
||||
const parts = partsOf(line, d);
|
||||
const p = parts[at];
|
||||
if (!p?.로직) return;
|
||||
fetchLogicInputs([p.로직]).then(
|
||||
(res) =>
|
||||
openLaborInput({
|
||||
title: `${p.로직} ${p.이름 ?? ""}`.trim(),
|
||||
logic: res.로직[0] ?? null,
|
||||
values: (p.입력값 ?? {}) as never,
|
||||
memo: null,
|
||||
readOnly: !opt.canWrite,
|
||||
done: (r) =>
|
||||
setParts(
|
||||
line,
|
||||
parts.map((x, i) => (i === at ? { ...x, 입력값: r.입력값 } : x)),
|
||||
),
|
||||
}),
|
||||
fail,
|
||||
);
|
||||
}
|
||||
|
||||
/** [원문 보기] — 엑셀 인용 조항 · 우리 로직 출처 두 갈래 */
|
||||
async function showSource(line: HopyoLine): Promise<void> {
|
||||
const excel = (line.근거?.출처 ?? []).map((x) => x.join(" ").trim()).filter(Boolean);
|
||||
const codes = partsOf(line, d).flatMap((p) => (p.로직 ? [p.로직] : []));
|
||||
const ours = codes.length
|
||||
? (await fetchLogicInputs(codes).catch(() => ({ 로직: [] }))).로직.flatMap((x) =>
|
||||
x.출처 ? [`${x.로직} — ${x.출처}`] : [],
|
||||
)
|
||||
: [];
|
||||
openModal({
|
||||
title: `원문 — ${line.이름}`,
|
||||
closeLabel: "닫기",
|
||||
wide: true,
|
||||
dialogClass: "m02-modal m02-hopyo__src",
|
||||
mount: (body) => {
|
||||
const part = (head: string, list: string[], cut: (s: string) => string): void => {
|
||||
body.append(el("h4", { text: head }));
|
||||
if (!list.length) body.append(note("없음"));
|
||||
for (const s of list) {
|
||||
const view = createSourceView({
|
||||
path: "/m02/logic-lib/source",
|
||||
pdf: "/m02/logic-lib/source-pdf",
|
||||
});
|
||||
view.show(cut(s));
|
||||
body.append(note(s), view.root);
|
||||
}
|
||||
};
|
||||
part("엑셀 인용 조항", excel, (s) => s);
|
||||
part("우리 로직 출처", ours, (s) => s.split(" — ").slice(1).join(" — "));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
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),
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
],
|
||||
});
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
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;
|
||||
else delete d.뺌[line.차례];
|
||||
d.체크[line.차례] = !!out.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),
|
||||
}),
|
||||
...(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.로직)
|
||||
.map((p) => inputsText(p.입력값 ?? {}, p.못읽음 ?? [], p.출처))
|
||||
.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(" / ")),
|
||||
td(compareText(d.품[line.차례] ?? m.품)),
|
||||
el("td", { className: "m02-hopyo__acts", children: acts }),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
async function make(h: HopyoOne["호표"]): Promise<void> {
|
||||
const whole = !!h.통째 && (d.통째 ?? h.통째.체크);
|
||||
const lines = whole ? [] : bodyOf(h.줄, d);
|
||||
const out = leftOut(h.줄, d);
|
||||
const put = lines.filter((x) => x.대상).length;
|
||||
const outs = lines.length - put;
|
||||
const ask = whole
|
||||
? `호표 통째로 만듦 — ${h.통째!.대상.map(targetText).join(" · ")}`
|
||||
: `넣을 줄 ${put} · 뺌 ${outs} · 빠질 줄 ${Object.values(out).reduce((a, b) => a + b, 0)}` +
|
||||
(Object.keys(out).length
|
||||
? `(${Object.entries(out)
|
||||
.map(([a, b]) => `${a} ${b}`)
|
||||
.join(" · ")})`
|
||||
: "") +
|
||||
" — 확인하고 사전조합을 만듦";
|
||||
if (!(await showConfirmDialog(ask, "확인하고 만들기"))) return;
|
||||
const from = { 층: opt.scope.layer, 주인: opt.scope.owner, 키: opt.key, 번: opt.no };
|
||||
showLoadingOverlay();
|
||||
try {
|
||||
const res = await buildHopyo(
|
||||
target,
|
||||
from,
|
||||
lines,
|
||||
whole ? { 대상: h.통째!.대상.map(toTarget) } : null,
|
||||
);
|
||||
dropDraft(k);
|
||||
d = empty();
|
||||
built = res;
|
||||
const miss = res.빠진줄.length;
|
||||
showToast(`${res.참조.키} ${builtWord(res)}${miss ? ` — 빠진 줄 ${miss}` : ""}`, "success");
|
||||
opt.onBuilt?.(res, h);
|
||||
await load(true);
|
||||
} catch (error) {
|
||||
fail(error);
|
||||
} finally {
|
||||
hideLoadingOverlay();
|
||||
}
|
||||
}
|
||||
|
||||
function builtBlock(res: HopyoBuilt): HTMLElement {
|
||||
const c = res.비교;
|
||||
const rows = res.빠진줄.map((x) =>
|
||||
el("tr", {
|
||||
children: [
|
||||
td(x.이름),
|
||||
td(x.규격),
|
||||
td(x.수량 === null ? "" : String(x.수량), "m02-hopyo__num"),
|
||||
td(x.단위),
|
||||
td(won(x.금액), "m02-hopyo__num"),
|
||||
td(x.까닭),
|
||||
],
|
||||
}),
|
||||
);
|
||||
return el("div", {
|
||||
className: "m02-hopyo__block m02-hopyo__built",
|
||||
attrs: { "data-built": res.참조.키 },
|
||||
children: [
|
||||
note(res.다시씀 ? `있던 ${res.참조.키} 를 확인한 내용으로 덮음` : `${res.참조.키} 만듦`),
|
||||
...(rows.length
|
||||
? [
|
||||
el("h4", { text: `빠진 줄 ${rows.length}` }),
|
||||
el("table", {
|
||||
className: "ui-general-block__table m02-hopyo__table",
|
||||
children: [
|
||||
el("thead", {
|
||||
children: [
|
||||
el("tr", {
|
||||
children: ["품명", "규격", "수량", "단위", "엑셀 금액", "까닭"].map((t) =>
|
||||
el("th", { text: t }),
|
||||
),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
el("tbody", { children: rows }),
|
||||
],
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
note(
|
||||
`값 비교(참고) — 엑셀 ${won(c.엑셀)} · 우리 ${won(c.우리 === null ? null : Math.round(c.우리))}` +
|
||||
` · 차이 ${won(c.차이)}${c.까닭 ? ` · ${c.까닭}` : ""}`,
|
||||
),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
function draw(): void {
|
||||
if (!got) return;
|
||||
const h = got.호표;
|
||||
const checked = h.줄.filter((l) => isChecked(l, d) && checkable(l, d)).length;
|
||||
const calc = h.셈;
|
||||
window.clearTimeout(timer);
|
||||
if (calc && !calc.끝)
|
||||
timer = window.setTimeout(() => root.isConnected && void load(true), POLL_MS);
|
||||
const tools: HTMLElement[] = [];
|
||||
if (opt.canWrite) {
|
||||
const sure = sureLines(h.줄, d);
|
||||
const all = createButton({ label: `맞음 · 별칭 모두 체크 (${sure.length})`, size: "sm" });
|
||||
all.disabled = !sure.length;
|
||||
all.addEventListener("click", () => {
|
||||
for (const l of sure) d.체크[l.차례] = true;
|
||||
keep();
|
||||
draw();
|
||||
});
|
||||
const whole = !!h.통째 && (d.통째 ?? h.통째.체크);
|
||||
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.단위),
|
||||
note(`엑셀 단가 ${won(h.금액.계)}`),
|
||||
note(`제안 ${h.맞춤[0]} / 줄 ${h.맞춤[1]} · 체크 ${checked}`),
|
||||
...(calc && !calc.끝
|
||||
? [note(`숫자 판정 셈 중 ${calc.한}/${calc.모두}${calc.까닭 ? ` · ${calc.까닭}` : ""}`)]
|
||||
: []),
|
||||
...(h.만든 ? [note(`만든 사전조합 ${h.만든.키}${h.만든.확인 ? " · 확인함" : ""}`)] : []),
|
||||
createButton({
|
||||
label: "엑셀에서 보기",
|
||||
size: "sm",
|
||||
onClick: () =>
|
||||
openSheet({
|
||||
scope: opt.scope,
|
||||
key: opt.key,
|
||||
파일: got!.파일,
|
||||
canWrite: opt.canWrite,
|
||||
target,
|
||||
호표: opt.no,
|
||||
}),
|
||||
}),
|
||||
...tools,
|
||||
],
|
||||
});
|
||||
const whole = h.통째
|
||||
? (() => {
|
||||
const box = el("input", {
|
||||
attrs: { type: "checkbox", "data-hopyo-whole": "" },
|
||||
}) as HTMLInputElement;
|
||||
box.checked = d.통째 ?? h.통째.체크;
|
||||
box.disabled = !opt.canWrite;
|
||||
box.addEventListener("change", () => {
|
||||
d.통째 = box.checked;
|
||||
keep();
|
||||
draw();
|
||||
});
|
||||
return [
|
||||
el("label", {
|
||||
className: "m02-hopyo__bar m02-hopyo__whole",
|
||||
children: [
|
||||
box,
|
||||
note(
|
||||
`통째 = ${h.통째.대상.map(targetText).join(" · ")}${h.통째.글 ? ` — ${h.통째.글}` : ""}`,
|
||||
),
|
||||
],
|
||||
}),
|
||||
];
|
||||
})()
|
||||
: [];
|
||||
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("구성 줄 없음"),
|
||||
...(built ? [builtBlock(built)] : []),
|
||||
);
|
||||
}
|
||||
|
||||
void load();
|
||||
return root;
|
||||
}
|
||||
|
||||
/** 확인 창 모달 — [로직 추가] 「실무 호표」 · 엑셀 보기 창 [확인 창 열기] · 만들면 닫음 */
|
||||
export function openHopyoCheck(opt: CheckOptions & { title?: string }): void {
|
||||
openModal({
|
||||
title: opt.title ?? `확인 — 제${opt.no}호표`,
|
||||
closeLabel: "닫기",
|
||||
wide: true,
|
||||
dialogClass: "m02-modal m02-hopyo__check-modal",
|
||||
mount: (body, close) =>
|
||||
body.append(
|
||||
buildHopyoCheck({
|
||||
...opt,
|
||||
onBuilt: (got, h) => {
|
||||
opt.onBuilt?.(got, h);
|
||||
close();
|
||||
},
|
||||
}),
|
||||
),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_Hopyo_Choose.ts
|
||||
* 일위대가 실무자료 고르기 창(PLAN 338-3 · 설계 4-2 · 옛 `Hopyo_View.choose` 를 옮김) + 글 도우미.
|
||||
* · 위 = 엑셀 블록 품(직종 · 장비 · 자재) · 인용 조항 · [엑셀에서 보기](단가산출 그 행)
|
||||
* · 후보 다섯 = 이름 · 키 · 판정 꼬리표(맞음 / 다른 점 / 후보) · 글 · 근거 꼬리표(근거 맞음 초록) · 금액 비 없음
|
||||
* · 고름 = [고름](한 로직) · 또는 체크 + 몫 칸 → [고른 n개 함께](조합) · 옵션은 부르는 쪽이 서버 `guess` 로 채움
|
||||
* · [다른 로직 찾기] = `openLogicPicker` 에 찾기 글 미리 넣음(엑셀 인용 조항 번호 → 없으면 품명 첫 뜻 낱말)
|
||||
* 저장 안 함 — 고른 것은 부르는 쪽 캐시(확인 창)로만 감.
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, el } from "@ui/ui_template_elements";
|
||||
import { openModal } from "@ui/ui_template_modal";
|
||||
import type {
|
||||
BuildTarget,
|
||||
HopyoBasisHit,
|
||||
HopyoLine,
|
||||
HopyoLineBasis,
|
||||
HopyoQty,
|
||||
HopyoTarget,
|
||||
} from "./M02_MasterTemplete_Hopyo_Api";
|
||||
import { openLogicPicker } from "./M02_MasterTemplete_Labor_Pick";
|
||||
import { pickMaterial } from "./M02_Material_Pick";
|
||||
import "./M02_MasterTemplete_Hopyo.css";
|
||||
|
||||
const MATERIAL = new Set(["자재", "별도계상"]);
|
||||
const note = (text: string): HTMLElement => el("span", { className: "m02-hopyo__note", text });
|
||||
const num = (v: number): string => String(Math.round(v * 10000) / 10000);
|
||||
|
||||
/** 대상 글 — 「이름 [단위]」 · 자재는 규격도 · 호표 = 「제N호표」 */
|
||||
export const targetText = (t: HopyoTarget | null | undefined): string => {
|
||||
if (!t) return "";
|
||||
if (t.호표 !== undefined) return `제${t.호표}호표 ${t.단위 ? `[${t.단위}]` : ""}`.trim();
|
||||
const spec = t.규격 ? ` ${t.규격}` : "";
|
||||
return `${t.이름 ?? t.로직 ?? t.자재 ?? ""}${spec}${t.단위 ? ` [${t.단위}]` : ""}`;
|
||||
};
|
||||
|
||||
/** 입력값 글 — 「이름=값」 · 칸 출처(블록 · 규격 · 별칭) · 못 가르는 칸 표시 */
|
||||
export const inputsText = (
|
||||
values: Record<string, unknown>,
|
||||
unread: string[],
|
||||
from: Record<string, string> = {},
|
||||
): string =>
|
||||
[
|
||||
Object.entries(values)
|
||||
.map(([k, v]) => `${k}=${String(v)}${from[k] && from[k] !== "자동값" ? `(${from[k]})` : ""}`)
|
||||
.join(", "),
|
||||
unread.length ? `⚠ 못 가름: ${unread.join(", ")}` : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
|
||||
/** 만들기 결과 낱말 — 있던 것을 덮음 · 새로 만듦 */
|
||||
export const builtWord = (got: { 다시씀: boolean }): string =>
|
||||
got.다시씀 ? "있던 것을 확인한 내용으로 덮음" : "만듦";
|
||||
|
||||
/** 근거 맞음 — 조항 같음(앞 마디 아님) · 단위 맞음 · 직종 겹침
|
||||
* (줄 근거 직종이 보통인부뿐이거나 없으면 — 기계 등 — 직종은 안 봄) */
|
||||
export const basisOk = (b: HopyoBasisHit | undefined, line?: HopyoLineBasis): boolean =>
|
||||
!!b &&
|
||||
b.까닭.startsWith("조항 같음") &&
|
||||
b.단위 &&
|
||||
(b.직종.length > 0 || !(line?.직종 ?? []).some((x) => x !== "보통인부"));
|
||||
|
||||
/** 후보 근거 꼬리표 — 「조항 건설 공통 3-2-1 · 직종 특별인부 · 단위 같음」 · 근거 없으면 "" */
|
||||
export const basisTag = (b: HopyoBasisHit | undefined): string =>
|
||||
b
|
||||
? [
|
||||
b.조항.length ? `조항 ${b.조항.join(", ")}` : "",
|
||||
b.직종.length ? `직종 ${b.직종.join(", ")}` : "",
|
||||
b.단위 ? "단위 같음" : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ")
|
||||
: "";
|
||||
|
||||
/** 엑셀 근거 줄 — 「출처 건설표준품셈 3-2-1 · 직종 특별인부 · 단가산출 93–101행」 · 없으면 "" */
|
||||
export const sourceText = (b: HopyoLineBasis | undefined): string =>
|
||||
b
|
||||
? [
|
||||
b.출처.length ? `출처 ${b.출처.map((x) => x.join(" ").trim()).join(", ")}` : "",
|
||||
b.직종.length ? `직종 ${b.직종.join(", ")}` : "",
|
||||
`단가산출 ${b.행[0] === b.행[1] ? b.행[0] : `${b.행[0]}–${b.행[1]}`}행`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ")
|
||||
: "";
|
||||
|
||||
/** 품 글 — 「콘크리트공 0.1091 · 굴착기 0.29hr · 레미콘 1.02」 */
|
||||
export const qtyText = (q: HopyoQty | undefined): string =>
|
||||
q
|
||||
? [
|
||||
...Object.entries(q.인력 ?? {}).map(([k, v]) => `${k} ${num(v)}`),
|
||||
...(q.장비 ?? []).map((x) => `${[x.갈래].flat().join("+")} ${num(x.시간)}hr`),
|
||||
...(q.자재 ?? []).map((x) => `${x.이름} ${num(x.수량)}`),
|
||||
].join(" · ")
|
||||
: "";
|
||||
|
||||
/** [다른 로직 찾기] 미리 넣을 글 — 엑셀 인용 조항 번호 → 없으면 품명 괄호 밖 첫 뜻 낱말 */
|
||||
export function searchHint(line: Pick<HopyoLine, "이름" | "근거">): string {
|
||||
const clause = line.근거?.출처?.find((x) => x[1])?.[1];
|
||||
if (clause) return clause;
|
||||
return /[가-힣A-Za-z]{2,}/.exec(line.이름.replace(/\([^()]*\)/g, " "))?.[0] ?? "";
|
||||
}
|
||||
|
||||
/** 고른 후보 → 만들기 대상(배 1 · 몫 있으면 배 = 몫) */
|
||||
export const pickedTargets = (picked: { c: HopyoTarget; 몫: number | null }[]): BuildTarget[] =>
|
||||
picked.map(({ c, 몫 }) => ({
|
||||
...(c.로직 ? { 로직: c.로직 } : { 자재: c.자재 }),
|
||||
입력값: {},
|
||||
배: 몫 ?? 1,
|
||||
몫,
|
||||
}));
|
||||
|
||||
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.갈래);
|
||||
openModal({
|
||||
title: `고르기 — ${line.이름} ${line.규격}`.trim(),
|
||||
closeLabel: "닫기",
|
||||
dialogClass: "m02-modal m02-hopyo__choose",
|
||||
mount: (body, close) => {
|
||||
const finish = (targets: BuildTarget[]): void => {
|
||||
close();
|
||||
opt.done(targets);
|
||||
};
|
||||
const picked = new Map<HopyoTarget, HTMLInputElement>();
|
||||
const together = createButton({ label: "고른 0개 함께", size: "sm", disabled: true });
|
||||
const paint = (): void => {
|
||||
together.querySelector(".ui-btn__label")!.textContent = `고른 ${picked.size}개 함께`;
|
||||
together.disabled = picked.size < 2;
|
||||
};
|
||||
together.addEventListener("click", () =>
|
||||
finish(
|
||||
pickedTargets(
|
||||
[...picked].map(([c, box]) => ({ c, 몫: box.value === "" ? null : Number(box.value) })),
|
||||
),
|
||||
),
|
||||
);
|
||||
const list = (line.맞춤.후보 ?? []).map((c) => {
|
||||
const share = el("input", {
|
||||
className: "ui-input ui-input--sm m02-hopyo__times",
|
||||
attrs: { type: "number", step: "0.05", min: "0", max: "1", placeholder: "몫" },
|
||||
}) as HTMLInputElement;
|
||||
const box = el("input", { attrs: { type: "checkbox", title: "함께 고름(조합)" } });
|
||||
box.addEventListener("change", () => {
|
||||
if ((box as HTMLInputElement).checked) picked.set(c, share);
|
||||
else picked.delete(c);
|
||||
paint();
|
||||
});
|
||||
const tag = basisTag(c.근거);
|
||||
return el("div", {
|
||||
className: "m02-hopyo__cand",
|
||||
attrs: { "data-cand": c.로직 ?? c.자재 ?? "" },
|
||||
children: [
|
||||
box,
|
||||
createButton({
|
||||
label: `${targetText(c)} · ${c.로직 ?? c.자재 ?? ""}`,
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () => finish(pickedTargets([{ c, 몫: null }])),
|
||||
}),
|
||||
...(c.판정
|
||||
? [
|
||||
el("span", {
|
||||
className: `m02-hopyo__state is-${c.판정.replace(" ", "")}`,
|
||||
text: c.판정,
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
...(c.글 ? [note(c.글)] : []),
|
||||
...(tag && c.근거
|
||||
? [
|
||||
el("span", {
|
||||
className: `m02-hopyo__basis${basisOk(c.근거, line.근거) ? " is-ok" : ""}`,
|
||||
text: tag,
|
||||
attrs: { title: `${c.근거.까닭} · 점수 ${c.근거.점수}` },
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
share,
|
||||
],
|
||||
});
|
||||
});
|
||||
const excel = qtyText(line.맞춤.품?.엑셀);
|
||||
const basis = line.근거;
|
||||
const head = el("div", {
|
||||
className: "m02-hopyo__bar m02-hopyo__source",
|
||||
children: [
|
||||
...(excel ? [note(`엑셀 품 — ${excel}`)] : []),
|
||||
...(basis ? [note(`엑셀 근거 — ${sourceText(basis)}`)] : []),
|
||||
...(basis && opt.openSheet
|
||||
? [
|
||||
createButton({
|
||||
label: "엑셀에서 보기",
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () => opt.openSheet?.(basis.행),
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
],
|
||||
});
|
||||
const more = createButton({
|
||||
label: material ? "다른 자재 찾기…" : "다른 로직 찾기…",
|
||||
size: "sm",
|
||||
onClick: () => {
|
||||
close();
|
||||
if (material) pickMaterial((x) => opt.done([{ 자재: x.코드, 배: 1 }]));
|
||||
else
|
||||
openLogicPicker((x) => opt.done([{ 로직: x.키, 입력값: {}, 배: 1 }]), undefined, {
|
||||
query: searchHint(line),
|
||||
});
|
||||
},
|
||||
});
|
||||
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] }),
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_Hopyo_Ledger.ts
|
||||
* 일위대가 실무자료 별칭 장부 화면(PLAN 338-3 · 설계 4-4) — 본 칸 · 좌측 판 [별칭 장부] 로 엶.
|
||||
* · 표 = 엑셀 품명 · 규격 · 단위 | 갈래 | 우리 로직(여럿) · 옵션 · 배 · 몫 | 누가 · 때 · 처음 파일 | 층(위 층 회색 읽기만)
|
||||
* · 할 일 = 찾기 · 갈래 거름 · 갈래 바꿈 · [로직 바꿈] · [로직 더함] · 배 · 몫 · [옵션] · [지우기] ·
|
||||
* 위 층 줄 [내 층으로 고쳐 씀]
|
||||
* · 고친 것 = sessionStorage `m02-hopyo-ledger:{층}:{주인}` → [저장] 때만 `PUT`(서버가 짝을 다시 검사)
|
||||
* · 안 둠 = 발주처 칸 · 자동 올림 · 자동 합침 · 쓰인 횟수 · 회사로 올리기.
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, el, showToast } from "@ui/ui_template_elements";
|
||||
import { loadingError, showLoading } from "@ui/ui_template_loading";
|
||||
import {
|
||||
readLedger,
|
||||
saveLedger,
|
||||
type HopyoScope,
|
||||
type LedgerEntry,
|
||||
type LedgerPair,
|
||||
type LedgerRow,
|
||||
} from "./M02_MasterTemplete_Hopyo_Api";
|
||||
import { fetchLogicInputs } from "./M02_MasterTemplete_Labor_Api";
|
||||
import { openLaborInput } from "./M02_MasterTemplete_Labor_Input";
|
||||
import { openLogicPicker } from "./M02_MasterTemplete_Labor_Pick";
|
||||
import "./M02_MasterTemplete_Hopyo.css";
|
||||
|
||||
export const LEDGER_TITLE = "별칭 장부";
|
||||
const KINDS = ["짝", "우리에 없음", "안 씀"] as const;
|
||||
const LAYER_TEXT: Record<string, string> = { system: "마스터", company: "회사", personal: "개인" };
|
||||
type Edits = Record<string, LedgerEntry | null>;
|
||||
/** 화면 한 줄 — 서버 줄 위에 고친 것 */
|
||||
export type LedgerView = LedgerRow & { 고친?: boolean };
|
||||
|
||||
export const ledgerKey = (scope: HopyoScope): string =>
|
||||
`m02-hopyo-ledger:${scope.layer}:${scope.owner ?? ""}`;
|
||||
function loadEdits(k: string): Edits {
|
||||
try {
|
||||
return JSON.parse(sessionStorage.getItem(k) ?? "{}") as Edits;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
function saveEdits(k: string, edits: Edits): void {
|
||||
try {
|
||||
if (Object.keys(edits).length) sessionStorage.setItem(k, JSON.stringify(edits));
|
||||
else sessionStorage.removeItem(k);
|
||||
} catch {
|
||||
/* 캐시 못 씀 = 이 화면에만 남음 */
|
||||
}
|
||||
}
|
||||
|
||||
/** 쓰기 꼴 — 보기 칸(층 · 누가 · 때 …) 뺌 */
|
||||
export const entryOf = (row: LedgerEntry): LedgerEntry => ({
|
||||
원래: row.원래,
|
||||
갈래: row.갈래,
|
||||
짝:
|
||||
row.갈래 === "짝"
|
||||
? row.짝.map((p) => ({ ...p, 입력값: p.입력값 ?? {}, 몫: p.몫 ?? null }))
|
||||
: [],
|
||||
엑셀조항: row.엑셀조항 ?? [],
|
||||
});
|
||||
|
||||
/** 서버 줄 + 고친 것 → 화면 줄 — 이 층 줄은 고친 값 · 지운 것 뺌 · 위 층에서 옮긴 줄은 이 층 줄로 더함 */
|
||||
export function mergeRows(rows: LedgerRow[], edits: Edits, scope: HopyoScope): LedgerView[] {
|
||||
const mine = rows.filter((r) => r.고침);
|
||||
const out: LedgerView[] = [];
|
||||
for (const r of mine) {
|
||||
if (!(r.열쇠 in edits)) out.push(r);
|
||||
else if (edits[r.열쇠]) out.push({ ...r, ...edits[r.열쇠]!, 고친: true });
|
||||
}
|
||||
for (const [key, entry] of Object.entries(edits)) {
|
||||
if (!entry || mine.some((r) => r.열쇠 === key)) continue;
|
||||
out.push({
|
||||
...entry,
|
||||
열쇠: key,
|
||||
누가: null,
|
||||
때: null,
|
||||
처음파일: null,
|
||||
층: scope.layer,
|
||||
주인: scope.owner,
|
||||
고침: true,
|
||||
고친: true,
|
||||
});
|
||||
}
|
||||
return [...out, ...rows.filter((r) => !r.고침)];
|
||||
}
|
||||
|
||||
/** 짝 글 — 「GC000198 × 1 · 몫 0.1 · 소량감률=50」 */
|
||||
export const pairText = (p: LedgerPair): string =>
|
||||
[
|
||||
`${p.로직 ?? p.자재 ?? ""} × ${p.배}`,
|
||||
p.몫 != null ? `몫 ${p.몫}` : "",
|
||||
Object.entries(p.입력값 ?? {})
|
||||
.map(([k, v]) => `${k}=${String(v)}`)
|
||||
.join(", "),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
|
||||
/** 찾기 · 갈래 거름 */
|
||||
export const filterRows = (rows: LedgerView[], q: string, kind: string): LedgerView[] => {
|
||||
const want = q.trim().toLowerCase();
|
||||
return rows.filter(
|
||||
(r) =>
|
||||
(!kind || r.갈래 === kind) &&
|
||||
(!want ||
|
||||
`${r.원래.품명} ${r.원래.규격} ${r.짝.map(pairText).join(" ")}`
|
||||
.toLowerCase()
|
||||
.includes(want)),
|
||||
);
|
||||
};
|
||||
|
||||
const why = (error: unknown): string => (error instanceof Error ? error.message : String(error));
|
||||
const td = (text: string, cls = ""): HTMLElement =>
|
||||
el("td", { text, className: cls, attrs: { title: text } });
|
||||
const note = (text: string): HTMLElement => el("span", { className: "m02-hopyo__note", text });
|
||||
|
||||
export interface LedgerOptions {
|
||||
scope: HopyoScope;
|
||||
/** 이 층 쓰기 권한 — 없으면 보기만 */
|
||||
canWrite: boolean;
|
||||
}
|
||||
|
||||
export function buildHopyoLedger(opt: LedgerOptions): HTMLElement {
|
||||
const root = el("div", {
|
||||
className: "m02-hopyo m02-hopyo-ledger",
|
||||
attrs: { "data-hopyo-ledger": "" },
|
||||
});
|
||||
const k = ledgerKey(opt.scope);
|
||||
let edits = loadEdits(k);
|
||||
let rows: LedgerRow[] = [];
|
||||
const search = el("input", {
|
||||
className: "ui-input ui-input--sm m02-hopyo__search",
|
||||
attrs: { type: "search", placeholder: "품명 · 규격 · 로직 찾기" },
|
||||
}) as HTMLInputElement;
|
||||
const kind = el("select", {
|
||||
className: "ui-input ui-input--sm",
|
||||
children: [
|
||||
el("option", { text: "갈래 전체", attrs: { value: "" } }),
|
||||
...KINDS.map((x) => el("option", { text: x, attrs: { value: x } })),
|
||||
],
|
||||
}) as HTMLSelectElement;
|
||||
const save = createButton({ label: "저장", variant: "filled", size: "sm" });
|
||||
const drop = createButton({ label: "고친 것 버림", variant: "ghost", size: "sm" });
|
||||
const body = el("tbody");
|
||||
const keep = (): void => {
|
||||
saveEdits(k, edits);
|
||||
paint();
|
||||
};
|
||||
const edit = (r: LedgerView, next: Partial<LedgerEntry>): void => {
|
||||
edits[r.열쇠] = entryOf({ ...entryOf(r), ...next });
|
||||
keep();
|
||||
};
|
||||
|
||||
function pairCell(r: LedgerView, mine: boolean): HTMLElement {
|
||||
const pairs: HTMLElement[] = r.짝.map((p, at) => {
|
||||
const parts: HTMLElement[] = [el("span", { text: pairText(p) })];
|
||||
if (mine) {
|
||||
const times = el("input", {
|
||||
className: "ui-input ui-input--sm m02-hopyo__times",
|
||||
attrs: { type: "number", step: "any", min: "0", value: String(p.배), title: "배" },
|
||||
}) as HTMLInputElement;
|
||||
times.addEventListener("change", () =>
|
||||
edit(r, { 짝: r.짝.map((x, i) => (i === at ? { ...x, 배: Number(times.value) } : x)) }),
|
||||
);
|
||||
parts.push(times);
|
||||
if (p.로직)
|
||||
parts.push(
|
||||
createButton({
|
||||
label: "옵션",
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () =>
|
||||
void fetchLogicInputs([p.로직!]).then(
|
||||
(got) =>
|
||||
openLaborInput({
|
||||
title: p.로직!,
|
||||
logic: got.로직[0] ?? null,
|
||||
values: p.입력값 as never,
|
||||
memo: null,
|
||||
readOnly: false,
|
||||
done: (res) =>
|
||||
edit(r, {
|
||||
짝: r.짝.map((x, i) => (i === at ? { ...x, 입력값: res.입력값 } : x)),
|
||||
}),
|
||||
}),
|
||||
(error) => showToast(why(error), "error"),
|
||||
),
|
||||
}),
|
||||
createButton({
|
||||
label: "×",
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () => edit(r, { 짝: r.짝.filter((_, i) => i !== at) }),
|
||||
}),
|
||||
);
|
||||
}
|
||||
return el("div", { className: "m02-hopyo__part", children: parts });
|
||||
});
|
||||
if (mine && r.갈래 === "짝")
|
||||
pairs.push(
|
||||
createButton({
|
||||
label: "로직 더함",
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () =>
|
||||
openLogicPicker((x) =>
|
||||
edit(r, { 짝: [...r.짝, { 로직: x.키, 입력값: {}, 배: 1, 몫: null }] }),
|
||||
),
|
||||
}),
|
||||
);
|
||||
return el("td", { className: "m02-hopyo__parts", children: pairs });
|
||||
}
|
||||
|
||||
function rowOf(r: LedgerView): HTMLElement {
|
||||
const mine = opt.canWrite && r.고침;
|
||||
const kindCell = mine
|
||||
? (() => {
|
||||
const s = el("select", {
|
||||
className: "ui-input ui-input--sm",
|
||||
children: KINDS.map((x) => el("option", { text: x, attrs: { value: x } })),
|
||||
}) as HTMLSelectElement;
|
||||
s.value = r.갈래;
|
||||
s.addEventListener("change", () =>
|
||||
edit(r, { 갈래: s.value as LedgerEntry["갈래"], 짝: s.value === "짝" ? r.짝 : [] }),
|
||||
);
|
||||
return el("td", { children: [s] });
|
||||
})()
|
||||
: td(r.갈래);
|
||||
const acts: HTMLElement[] = [];
|
||||
if (mine)
|
||||
acts.push(
|
||||
createButton({
|
||||
label: "지우기",
|
||||
variant: "danger",
|
||||
size: "sm",
|
||||
onClick: () => {
|
||||
edits[r.열쇠] = null;
|
||||
keep();
|
||||
},
|
||||
}),
|
||||
);
|
||||
else if (opt.canWrite && !rows.some((x) => x.고침 && x.열쇠 === r.열쇠) && !edits[r.열쇠])
|
||||
acts.push(
|
||||
createButton({
|
||||
label: "내 층으로 고쳐 씀",
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () => {
|
||||
edits[r.열쇠] = entryOf(r);
|
||||
keep();
|
||||
},
|
||||
}),
|
||||
);
|
||||
const when = [r.누가?.이름, r.때?.replace("T", " ").slice(0, 16), r.처음파일]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
return el("tr", {
|
||||
className: `m02-hopyo-ledger__row${r.고침 ? "" : " is-upper"}${r.고친 ? " is-edited" : ""}`,
|
||||
attrs: { "data-ledger": r.열쇠 },
|
||||
children: [
|
||||
td(r.원래.품명),
|
||||
td(r.원래.규격),
|
||||
td(r.원래.단위),
|
||||
kindCell,
|
||||
pairCell(r, mine),
|
||||
td(when),
|
||||
td(r.고침 ? "내 층" : (LAYER_TEXT[r.층] ?? r.층)),
|
||||
el("td", { className: "m02-hopyo__acts", children: acts }),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
function paint(): void {
|
||||
const all = mergeRows(rows, edits, opt.scope);
|
||||
const shown = filterRows(all, search.value, kind.value);
|
||||
const n = Object.keys(edits).length;
|
||||
save.querySelector(".ui-btn__label")!.textContent = n ? `저장 (${n})` : "저장";
|
||||
save.disabled = drop.disabled = !n;
|
||||
body.replaceChildren(
|
||||
...(shown.length
|
||||
? shown.map(rowOf)
|
||||
: [el("tr", { children: [el("td", { text: "장부 줄 없음", attrs: { colspan: "8" } })] })]),
|
||||
);
|
||||
}
|
||||
search.addEventListener("input", paint);
|
||||
kind.addEventListener("change", paint);
|
||||
drop.addEventListener("click", () => {
|
||||
edits = {};
|
||||
keep();
|
||||
});
|
||||
save.addEventListener("click", async () => {
|
||||
save.disabled = true;
|
||||
try {
|
||||
rows = await saveLedger(opt.scope, edits);
|
||||
edits = {};
|
||||
showToast("장부 저장", "success");
|
||||
keep();
|
||||
} catch (error) {
|
||||
showToast(`저장 못 함 — ${why(error)}`, "error");
|
||||
paint();
|
||||
}
|
||||
});
|
||||
|
||||
async function load(): Promise<void> {
|
||||
const stop = showLoading(root);
|
||||
try {
|
||||
rows = await readLedger(opt.scope);
|
||||
} catch (error) {
|
||||
root.replaceChildren(loadingError(why(error), () => void load()));
|
||||
return;
|
||||
} finally {
|
||||
stop();
|
||||
}
|
||||
root.replaceChildren(
|
||||
el("div", {
|
||||
className: "m02-hopyo__bar",
|
||||
children: [
|
||||
el("strong", { text: LEDGER_TITLE }),
|
||||
note("엑셀 이름 → 우리 로직 + 옵션 + 배 · 확인하고 만들 때 남음 · 아래 층이 이김"),
|
||||
search,
|
||||
kind,
|
||||
...(opt.canWrite ? [save, drop] : []),
|
||||
],
|
||||
}),
|
||||
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",
|
||||
children: [
|
||||
el("thead", {
|
||||
children: [
|
||||
el("tr", {
|
||||
children: [
|
||||
"엑셀 품명",
|
||||
"규격",
|
||||
"단위",
|
||||
"갈래",
|
||||
"우리 로직 · 배 · 몫 · 옵션",
|
||||
"누가 · 때 · 처음 파일",
|
||||
"층",
|
||||
"",
|
||||
].map((t) => el("th", { text: t })),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
body,
|
||||
],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
paint();
|
||||
}
|
||||
|
||||
void load();
|
||||
return root;
|
||||
}
|
||||
@@ -1,13 +1,13 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_Hopyo_Pick.ts
|
||||
* [로직 추가] 창의 「실무 호표」 줄(PLAN 324-4) — 일위대가 문서 층 · 위 층 실무자료의 호표마다 한 줄(맞춤 n/m) ·
|
||||
* 고르면 서버가 그 호표를 문서 층 사전조합으로 만들고(`/hopyo-build` · 있던 것은 다시 씀) `lib.pick` 으로
|
||||
* 참조 줄(별칭 = 호표 품명) · 빠진 줄이 있으면 알림 · 파일마다 「엑셀로 보기 · 여러 호표」 줄(329 엑셀 보기 창 ·
|
||||
* 고른 호표마다 참조 줄). 프로젝트 층엔 없음.
|
||||
* [로직 추가] 창의 「실무 호표」 줄(PLAN 324-4 · 338-3) — 일위대가 문서 층 · 위 층 실무자료의 호표마다 한 줄(제안 n/m) ·
|
||||
* 고름 = 문서 층에 확인된 사전조합이 있으면 그것을 바로 참조 · 없으면 확인 창 모달(`Hopyo_Check` · 늦게 불러옴) →
|
||||
* [확인하고 사전조합 만들기] → `lib.pick` 참조 줄(별칭 = 호표 품명) · 확인 없이 만들지 않음 ·
|
||||
* 파일마다 「엑셀로 보기 · 여러 호표」 줄(329 엑셀 보기 창 · 고른 호표마다 참조 줄). 프로젝트 층엔 없음.
|
||||
* ========================================================================== */
|
||||
|
||||
import { showToast } from "@ui/ui_template_elements";
|
||||
import { buildHopyo, listHopyoPicks, type HopyoPickRow } from "./M02_MasterTemplete_Hopyo_Api";
|
||||
import { listHopyoPicks, type HopyoPickRow } from "./M02_MasterTemplete_Hopyo_Api";
|
||||
import type { LibraryPick, PickExtra } from "./M02_MasterTemplete_Labor_Pick";
|
||||
import { tc } from "./M02_MasterTemplete_Labor_Text";
|
||||
|
||||
@@ -73,21 +73,29 @@ export function hopyoExtra(row: HopyoPickRow, lib: LibraryPick): PickExtra {
|
||||
tag: "실무 호표",
|
||||
name: [row.이름, row.규격].filter(Boolean).join(" "),
|
||||
sub: `${row.파일} · 제${row.번}호표 · ${LAYER_TEXT[row.층] ?? row.층}`,
|
||||
unit: `${row.단위} · 맞춤 ${row.맞춤[0]}/${row.맞춤[1]}`,
|
||||
unit: `${row.단위} · ${row.확인 ? `확인 ${row.확인.키}` : `제안 ${row.맞춤[0]}/${row.맞춤[1]}`}`,
|
||||
onPick: () => void pickHopyo(row, lib),
|
||||
};
|
||||
}
|
||||
|
||||
/** 고름 — 서버가 사전조합을 만들고(다시 씀) 참조 줄을 더함 */
|
||||
/** 고름 — 확인된 사전조합이면 바로 참조 · 아니면 확인 창 모달 → 만든 뒤 참조 */
|
||||
export async function pickHopyo(row: HopyoPickRow, lib: LibraryPick): Promise<void> {
|
||||
const alias = row.이름.slice(0, ALIAS_MAX).trim() || null;
|
||||
if (row.확인) {
|
||||
showToast(`「${row.이름}」 → 확인한 사전조합 ${row.확인.키}`, "success");
|
||||
return lib.pick(row.확인, row.이름, alias);
|
||||
}
|
||||
try {
|
||||
const from = { 층: row.층, 주인: row.주인, 키: row.키, 번: row.번 };
|
||||
const got = await buildHopyo({ layer: lib.layer, owner: lib.owner }, from);
|
||||
const miss = got.빠진줄.length;
|
||||
const head = `「${row.이름}」 → 사전조합 ${got.참조.키}${got.다시씀 ? "(있던 것)" : ""}`;
|
||||
if (miss) showToast(`${head} · 빠진 줄 ${miss} — 실무자료 화면에서 맞춤`, "warning", 6000);
|
||||
else showToast(head, "success");
|
||||
lib.pick(got.참조, got.이름, row.이름.slice(0, ALIAS_MAX).trim() || null);
|
||||
const check = await import("./M02_MasterTemplete_Hopyo_Check");
|
||||
check.openHopyoCheck({
|
||||
title: `확인 — ${row.파일} 제${row.번}호표 ${row.이름}`,
|
||||
scope: { layer: row.층, owner: row.주인 },
|
||||
key: row.키,
|
||||
no: row.번,
|
||||
target: { layer: lib.layer, owner: lib.owner },
|
||||
canWrite: true,
|
||||
onBuilt: (got) => lib.pick(got.참조, got.이름, alias),
|
||||
});
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : String(error), "error");
|
||||
}
|
||||
|
||||
@@ -1,15 +1,23 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_Hopyo_Sheet.ts
|
||||
* 일위대가 실무자료 엑셀 보기 창(PLAN 329) — 큰 모달 · 왼쪽 = 올린 엑셀을 연 것처럼 읽기 전용 시트(A00 산출근거
|
||||
* 스프레드시트 · 탭 일위대가 · 단가산출) · 오른쪽 = 호표 목록(체크 · 찾기 · 맞춤 n/m) + [고른 n개 불러오기].
|
||||
* 일위대가 실무자료 엑셀 보기 창(PLAN 329 · 338-3) — 큰 모달 · 왼쪽 = 올린 엑셀을 연 것처럼 읽기 전용 시트(A00 산출근거
|
||||
* 스프레드시트 · 탭 일위대가 · 단가산출) · 오른쪽 = 호표 목록(체크 · 찾기 · 제안 n/m) + [고른 n개 확인하고 만들기].
|
||||
* · 목록 누름 → 시트에서 그 호표 영역(B열 ~ 끝 열) 고름 · 칸 누름 → 그 행 호표를 목록에서 강조 · 영역 고름
|
||||
* · 불러오기 = 고른 호표마다 차례로 사전조합 만들기(`/hopyo-build`) → 결과 표(사전조합 키 · 빠진 줄 · 값 차이) ·
|
||||
* · ① [고른 n개 확인하고 만들기] = 고른 호표마다 서버 보기 → 펼침 표(넣을 줄 = 체크 제안 · 빠질 줄 · 셈 중 ·
|
||||
* [확인 창 열기]) ② [이대로 n개 만들기] = 하위 호표부터 차례로 `/hopyo-build`(몸 = 그 호표 체크 줄) —
|
||||
* 펼쳐 보이고 한 번 누름 = 사용자 확인 · 결과 표(사전조합 키 · 빠진 줄 · 값 차이) ·
|
||||
* `onImport` 가 있으면 호표마다 알림([로직 추가] 참조 줄)
|
||||
* · 격자 = 서버 `/hopyo-grid/{키}` · 옛 파일(격자 없음)은 시트 자리에 까닭 글 · 목록 · 불러오기는 됨
|
||||
* · 격자 = 서버 `/hopyo-grid/{키}` · 옛 파일(격자 없음)은 시트 자리에 까닭 글 · 목록 · 확인하고 만들기는 됨
|
||||
* ⚠ 부르는 쪽은 `import()` 로 엶 · 스프레드시트도 열 때 불러옴(다른 화면 번들에 안 섞이게).
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, el, showToast } from "@ui/ui_template_elements";
|
||||
import {
|
||||
createButton,
|
||||
el,
|
||||
hideLoadingOverlay,
|
||||
showLoadingOverlay,
|
||||
showToast,
|
||||
} from "@ui/ui_template_elements";
|
||||
import { openModal } from "@ui/ui_template_modal";
|
||||
import type { Sheet } from "../A00_Common/spreadsheet/spreadsheet_types";
|
||||
import type { SpreadsheetHandle } from "../A00_Common/spreadsheet/spreadsheet";
|
||||
@@ -17,13 +25,24 @@ import {
|
||||
buildHopyo,
|
||||
readHopyo,
|
||||
readHopyoGrid,
|
||||
readHopyoOne,
|
||||
type HopyoBrief,
|
||||
type HopyoBuilt,
|
||||
type HopyoGrid,
|
||||
type HopyoOne,
|
||||
type HopyoScope,
|
||||
type HopyoSpan,
|
||||
} from "./M02_MasterTemplete_Hopyo_Api";
|
||||
import { builtWord } from "./M02_MasterTemplete_Hopyo_View";
|
||||
import {
|
||||
bodyOf,
|
||||
draftKey,
|
||||
leftOut,
|
||||
loadDraft,
|
||||
openHopyoCheck,
|
||||
partsOf,
|
||||
toTarget,
|
||||
} from "./M02_MasterTemplete_Hopyo_Check";
|
||||
import { builtWord } from "./M02_MasterTemplete_Hopyo_Choose";
|
||||
import "./M02_MasterTemplete_Hopyo.css";
|
||||
|
||||
export interface HopyoSheetOptions {
|
||||
@@ -31,7 +50,7 @@ export interface HopyoSheetOptions {
|
||||
scope: HopyoScope;
|
||||
key: string;
|
||||
파일: string;
|
||||
/** 만들 층 쓰기 권한 — 없으면 체크 · 불러오기 없음(보기만) */
|
||||
/** 만들 층 쓰기 권한 — 없으면 체크 · 만들기 없음(보기만) */
|
||||
canWrite: boolean;
|
||||
/** 사전조합을 만들 층(없으면 `scope` · [로직 추가] = 문서 층) */
|
||||
target?: HopyoScope;
|
||||
@@ -68,6 +87,19 @@ export function lastColumn(sheet: Pick<Sheet, "칸" | "열">): string {
|
||||
return best;
|
||||
}
|
||||
|
||||
/** 만들 차례 — 하위 호표(호표 참조 줄이 가리키는 고른 호표)부터 · 순환은 남은 차례대로 */
|
||||
export function buildOrder(ones: { 번: number; 아래: number[] }[]): number[] {
|
||||
const left = new Map(ones.map((x) => [x.번, x.아래.filter((n) => n !== x.번)]));
|
||||
const out: number[] = [];
|
||||
while (left.size) {
|
||||
const ready = [...left].find(([, kids]) => kids.every((n) => !left.has(n)));
|
||||
const no = ready ? ready[0] : [...left.keys()][0];
|
||||
out.push(no);
|
||||
left.delete(no);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 단가산출 행 → 행 전체 범위 A1(`4:9`) */
|
||||
export const rowsText = (행: number | [number, number]): string =>
|
||||
typeof 행 === "number" ? `${행}:${행}` : `${행[0]}:${행[1]}`;
|
||||
@@ -94,7 +126,7 @@ export function openHopyoSheet(opt: HopyoSheetOptions): void {
|
||||
}) as HTMLInputElement;
|
||||
const list = el("div", { className: "m02-hopyo-sheet__list" });
|
||||
const count = note("");
|
||||
const go = createButton({ label: "고른 0개 불러오기", variant: "filled", size: "sm" });
|
||||
const go = createButton({ label: "고른 0개 확인하고 만들기", variant: "filled", size: "sm" });
|
||||
go.disabled = true;
|
||||
const result = el("div", { className: "m02-hopyo-sheet__result" });
|
||||
const right = el("div", {
|
||||
@@ -122,7 +154,7 @@ export function openHopyoSheet(opt: HopyoSheetOptions): void {
|
||||
});
|
||||
|
||||
const paintGo = (): void => {
|
||||
go.querySelector(".ui-btn__label")!.textContent = `고른 ${checked.size}개 불러오기`;
|
||||
go.querySelector(".ui-btn__label")!.textContent = `고른 ${checked.size}개 확인하고 만들기`;
|
||||
go.disabled = !checked.size;
|
||||
};
|
||||
|
||||
@@ -226,57 +258,147 @@ export function openHopyoSheet(opt: HopyoSheetOptions): void {
|
||||
}
|
||||
}
|
||||
|
||||
async function importPicked(): Promise<void> {
|
||||
const nos = [...checked].sort((a, b) => a - b);
|
||||
const target = opt.target ?? opt.scope;
|
||||
const rows: HTMLElement[] = [];
|
||||
let ok = 0;
|
||||
go.disabled = true;
|
||||
for (const no of nos) {
|
||||
const h = briefs.find((x) => x.번 === no);
|
||||
if (!h || closed) continue;
|
||||
const head = el("td", { text: `제${no}호표 ${h.이름}`.trim() });
|
||||
try {
|
||||
const from = { 층: opt.scope.layer, 주인: opt.scope.owner, 키: opt.key, 번: no };
|
||||
const got = await buildHopyo(target, from);
|
||||
ok += 1;
|
||||
opt.onImport?.(got, h);
|
||||
rows.push(
|
||||
el("tr", {
|
||||
attrs: { "data-built": got.참조.키 },
|
||||
children: [
|
||||
head,
|
||||
el("td", { text: `${got.참조.키} · ${builtWord(got, false)}` }),
|
||||
el("td", { text: String(got.빠진줄.length), className: "m02-hopyo__num" }),
|
||||
el("td", { text: won(got.비교.차이), className: "m02-hopyo__num" }),
|
||||
],
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
rows.push(el("tr", { children: [head, el("td", { text: `실패 — ${why(error)}` })] }));
|
||||
}
|
||||
result.replaceChildren(
|
||||
el("table", {
|
||||
className: "ui-general-block__table m02-hopyo__table",
|
||||
children: [
|
||||
el("thead", {
|
||||
children: [
|
||||
el("tr", {
|
||||
children: ["호표", "사전조합", "빠진 줄", "값 차이"].map((t) =>
|
||||
el("th", { text: t }),
|
||||
),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
el("tbody", { children: rows }),
|
||||
],
|
||||
const table = (heads: string[], rows: HTMLElement[]): HTMLElement =>
|
||||
el("table", {
|
||||
className: "ui-general-block__table m02-hopyo__table",
|
||||
children: [
|
||||
el("thead", {
|
||||
children: [el("tr", { children: heads.map((t) => el("th", { text: t })) })],
|
||||
}),
|
||||
);
|
||||
el("tbody", { children: rows }),
|
||||
],
|
||||
});
|
||||
|
||||
/** 호표 하나의 몸 — 확인 창 캐시 위 체크 제안 · 통째면 줄 없음 */
|
||||
function bodyFor(one: HopyoOne): Parameters<typeof buildHopyo>[2] {
|
||||
const h = one.호표;
|
||||
const d = loadDraft(draftKey(opt.scope, opt.key, h.번));
|
||||
return h.통째 && (d.통째 ?? h.통째.체크) ? [] : bodyOf(h.줄, d);
|
||||
}
|
||||
function wholeFor(one: HopyoOne): Parameters<typeof buildHopyo>[3] {
|
||||
const h = one.호표;
|
||||
const d = loadDraft(draftKey(opt.scope, opt.key, h.번));
|
||||
return h.통째 && (d.통째 ?? h.통째.체크) ? { 대상: h.통째.대상.map(toTarget) } : null;
|
||||
}
|
||||
|
||||
/** ① 고른 호표마다 서버 보기 → 펼침 표 */
|
||||
async function review(): Promise<void> {
|
||||
const target = opt.target ?? opt.scope;
|
||||
const nos = [...checked].sort((a, b) => a - b);
|
||||
go.disabled = true;
|
||||
const ones: HopyoOne[] = [];
|
||||
for (const no of nos) {
|
||||
if (closed) return;
|
||||
try {
|
||||
ones.push(await readHopyoOne(opt.scope, opt.key, no, target));
|
||||
} catch (error) {
|
||||
showToast(`제${no}호표 — ${why(error)}`, "error");
|
||||
}
|
||||
}
|
||||
paintGo();
|
||||
showToast(`불러옴 ${ok} / ${nos.length}`, ok === nos.length ? "success" : "warning");
|
||||
const rows = ones.map((one) => {
|
||||
const h = one.호표;
|
||||
const d = loadDraft(draftKey(opt.scope, opt.key, h.번));
|
||||
const out = Object.entries(leftOut(h.줄, d));
|
||||
const calc = h.셈 && !h.셈.끝 ? `셈 중 ${h.셈.한}/${h.셈.모두}` : "";
|
||||
const brief = briefs.find((x) => x.번 === h.번) ?? h;
|
||||
return el("tr", {
|
||||
attrs: { "data-review": String(h.번) },
|
||||
children: [
|
||||
el("td", { text: `제${h.번}호표 ${h.이름}`.trim() }),
|
||||
el("td", {
|
||||
text: wholeFor(one) ? "통째" : String(bodyFor(one).filter((x) => x.대상).length),
|
||||
className: "m02-hopyo__num",
|
||||
}),
|
||||
el("td", { text: out.map(([a, b]) => `${a} ${b}`).join(" · ") }),
|
||||
el("td", { text: calc }),
|
||||
el("td", {
|
||||
children: [
|
||||
createButton({
|
||||
label: "확인 창 열기",
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () =>
|
||||
openHopyoCheck({
|
||||
scope: opt.scope,
|
||||
key: opt.key,
|
||||
no: h.번,
|
||||
target,
|
||||
canWrite: opt.canWrite,
|
||||
onBuilt: (got) => opt.onImport?.(got, brief),
|
||||
}),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
});
|
||||
const ready = ones.filter((one) => bodyFor(one).length || wholeFor(one));
|
||||
const all = createButton({
|
||||
label: `이대로 ${ready.length}개 만들기`,
|
||||
variant: "filled",
|
||||
size: "sm",
|
||||
disabled: !ready.length,
|
||||
onClick: () => void makeAll(ready),
|
||||
});
|
||||
all.setAttribute("data-hopyo-make-all", "");
|
||||
result.replaceChildren(
|
||||
table(["호표", "넣을 줄", "빠질 줄", "숫자 판정", ""], rows),
|
||||
el("div", {
|
||||
className: "m02-hopyo__bar",
|
||||
children: [all, note("넣을 줄 = 체크 제안 · 확인 창에서 고친 것")],
|
||||
}),
|
||||
);
|
||||
}
|
||||
go.addEventListener("click", () => void importPicked());
|
||||
|
||||
/** ② 한 번 누름 = 사용자 확인 — 하위 호표부터 차례로 만들기 */
|
||||
async function makeAll(ones: HopyoOne[]): Promise<void> {
|
||||
const target = opt.target ?? opt.scope;
|
||||
const by = new Map(ones.map((one) => [one.호표.번, one]));
|
||||
const order = buildOrder(
|
||||
ones.map((one) => {
|
||||
const d = loadDraft(draftKey(opt.scope, opt.key, one.호표.번));
|
||||
const kids = one.호표.줄.flatMap((l) =>
|
||||
partsOf(l, d).flatMap((p) => (p.호표 !== undefined ? [p.호표] : [])),
|
||||
);
|
||||
return { 번: one.호표.번, 아래: kids };
|
||||
}),
|
||||
);
|
||||
const rows: HTMLElement[] = [];
|
||||
let ok = 0;
|
||||
showLoadingOverlay();
|
||||
try {
|
||||
for (const no of order) {
|
||||
const one = by.get(no)!;
|
||||
const h = briefs.find((x) => x.번 === no) ?? one.호표;
|
||||
const head = el("td", { text: `제${no}호표 ${h.이름}`.trim() });
|
||||
try {
|
||||
const from = { 층: opt.scope.layer, 주인: opt.scope.owner, 키: opt.key, 번: no };
|
||||
const got = await buildHopyo(target, from, bodyFor(one), wholeFor(one));
|
||||
ok += 1;
|
||||
opt.onImport?.(got, h);
|
||||
rows.push(
|
||||
el("tr", {
|
||||
attrs: { "data-built": got.참조.키 },
|
||||
children: [
|
||||
head,
|
||||
el("td", { text: `${got.참조.키} · ${builtWord(got)}` }),
|
||||
el("td", { text: String(got.빠진줄.length), className: "m02-hopyo__num" }),
|
||||
el("td", { text: won(got.비교.차이), className: "m02-hopyo__num" }),
|
||||
],
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
rows.push(el("tr", { children: [head, el("td", { text: `실패 — ${why(error)}` })] }));
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
hideLoadingOverlay();
|
||||
}
|
||||
result.replaceChildren(table(["호표", "사전조합", "빠진 줄", "값 차이(참고)"], rows));
|
||||
showToast(`만듦 ${ok} / ${order.length}`, ok === order.length ? "success" : "warning");
|
||||
}
|
||||
go.addEventListener("click", () => void review());
|
||||
|
||||
void load();
|
||||
}
|
||||
|
||||
@@ -3,11 +3,13 @@
|
||||
* 좌측 「일위대가 실무자료」 컨테이너(PLAN 324-4 · 일위대가 사전조합 아래) — 층 = 구조물 조합 층(화면 층) ·
|
||||
* [엑셀 올리기](숨은 file 입력 .xls · .xlsx · 쓰기 권한 없으면 꺼짐) + 파일 줄 → 누르면 본 화면
|
||||
* (`M02_MasterTemplete_Hopyo_View`) · 고름 표시 = 본 화면이 메인 칸에 붙어 있는 동안만 ·
|
||||
* 올린 직후 엑셀 보기 창(329 · `M02_MasterTemplete_Hopyo_Sheet` 늦게 불러옴)을 자동으로 엶.
|
||||
* 올린 직후 엑셀 보기 창(329 · `M02_MasterTemplete_Hopyo_Sheet` 늦게 불러옴)을 자동으로 엶 ·
|
||||
* 파일 목록 아래 [별칭 장부](338-3) → 본 칸 장부 화면(`M02_MasterTemplete_Hopyo_Ledger`).
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, el, showToast } from "@ui/ui_template_elements";
|
||||
import { listHopyo, pageScope, uploadHopyo, type HopyoFile } from "./M02_MasterTemplete_Hopyo_Api";
|
||||
import { buildHopyoLedger, LEDGER_TITLE } from "./M02_MasterTemplete_Hopyo_Ledger";
|
||||
import { buildHopyoView } from "./M02_MasterTemplete_Hopyo_View";
|
||||
|
||||
export const HOPYO_TITLE = "일위대가 실무자료";
|
||||
@@ -48,9 +50,19 @@ export function buildHopyoSection(opt: HopyoSectionOptions): HopyoSection {
|
||||
onClick: () => input.click(),
|
||||
});
|
||||
upload.title = opt.canWrite ? "설계내역서 엑셀(일위대가 시트)" : "이 층 쓰기 권한 없음";
|
||||
const ledger = el("button", {
|
||||
className: "m02-side__item ui-sidebar-row",
|
||||
text: LEDGER_TITLE,
|
||||
attrs: {
|
||||
type: "button",
|
||||
title: "엑셀 이름 → 우리 로직 짝 보기 · 고치기",
|
||||
"data-hopyo-ledger-open": "",
|
||||
},
|
||||
});
|
||||
ledger.addEventListener("click", () => void showLedger());
|
||||
const body = el("div", {
|
||||
className: "m02-hopyo-side",
|
||||
children: [el("div", { className: "m02-side__row", children: [upload, input] }), list],
|
||||
children: [el("div", { className: "m02-side__row", children: [upload, input] }), list, ledger],
|
||||
});
|
||||
let files: HopyoFile[] = [];
|
||||
let open: { key: string; view: HTMLElement } | null = null;
|
||||
@@ -75,6 +87,7 @@ export function buildHopyoSection(opt: HopyoSectionOptions): HopyoSection {
|
||||
});
|
||||
|
||||
function paintList(): void {
|
||||
ledger.classList.toggle("is-active", open?.key === LEDGER_TITLE);
|
||||
list.replaceChildren(
|
||||
...(files.length
|
||||
? files.map((f) => {
|
||||
@@ -107,6 +120,16 @@ export function buildHopyoSection(opt: HopyoSectionOptions): HopyoSection {
|
||||
paintList();
|
||||
}
|
||||
|
||||
async function showLedger(): Promise<void> {
|
||||
if (open?.key === LEDGER_TITLE && open.view.isConnected) return;
|
||||
if (!(await opt.confirmLeave())) return;
|
||||
const view = buildHopyoLedger({ scope, canWrite: opt.canWrite });
|
||||
opt.show(`${HOPYO_TITLE} — ${LEDGER_TITLE}`, view);
|
||||
opt.onOpen();
|
||||
open = { key: LEDGER_TITLE, view };
|
||||
paintList();
|
||||
}
|
||||
|
||||
async function refresh(): Promise<void> {
|
||||
const mine = ++seq;
|
||||
try {
|
||||
|
||||
@@ -1,58 +1,31 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_Hopyo_View.ts
|
||||
* 일위대가 실무자료 본 화면(PLAN 324-4) — 파일 하나: 파일 · 시트 · 호표 수 · 검산 · 맞춤 n/m · 호표 표 + 찾기 ·
|
||||
* [지우기] / 호표 하나: 줄 표(갈래 · 품명 · 규격 · 수량 · 단위 · 엑셀 단가 · 맞춤 · 대상 · 입력값 · 환산 ·
|
||||
* [고르기] [확정] [빼기]) · [같음 모두 확정] · 빠진 줄 · [사전조합 만들기] → 값 비교 · 다시 씀이면 [새로 만들기] ·
|
||||
* [엑셀로 보기] · [엑셀에서 보기](329 엑셀 보기 창 · 늦게 불러옴 · 호표 화면은 그 호표 고른 채) ·
|
||||
* 고르기 창 후보 근거 꼬리표 · 엑셀 근거 줄 + [엑셀에서 보기](단가산출 그 행) · [근거 맞음 모두 확정](330).
|
||||
* 일위대가 실무자료 본 화면(PLAN 324-4 · 338-3) — 파일 하나: 파일 · 시트 · 호표 수 · 검산 · 제안 n/m · 호표 표 + 찾기 ·
|
||||
* [지우기] · [엑셀로 보기](329 엑셀 보기 창 · 늦게 불러옴) / 호표 하나 = 확인 창(`M02_MasterTemplete_Hopyo_Check` ·
|
||||
* 체크한 줄만 [확인하고 사전조합 만들기]) — 즉시 확정 · 바로 만들기 단추 없음.
|
||||
* 맞춤 · 만들기는 서버(`M02_Hopyo_Match` · `M02_Hopyo_Build`) · 쓰기 단추는 층 쓰기 권한만.
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, el, showConfirmDialog, showToast } from "@ui/ui_template_elements";
|
||||
import { loadingError, showLoading } from "@ui/ui_template_loading";
|
||||
import { openModal } from "@ui/ui_template_modal";
|
||||
import {
|
||||
buildHopyo,
|
||||
deleteHopyo,
|
||||
guessHopyo,
|
||||
readHopyo,
|
||||
readHopyoOne,
|
||||
saveHopyoMemory,
|
||||
type HopyoBasisHit,
|
||||
type HopyoBuilt,
|
||||
type HopyoGuess,
|
||||
type HopyoLine,
|
||||
type HopyoLineBasis,
|
||||
type HopyoMemory,
|
||||
type HopyoOne,
|
||||
type HopyoScope,
|
||||
type HopyoTarget,
|
||||
type HopyoView,
|
||||
} from "./M02_MasterTemplete_Hopyo_Api";
|
||||
import { buildHopyoCheck } from "./M02_MasterTemplete_Hopyo_Check";
|
||||
import type { HopyoSheetOptions } from "./M02_MasterTemplete_Hopyo_Sheet";
|
||||
import { openLogicPicker } from "./M02_MasterTemplete_Labor_Pick";
|
||||
import { pickMaterial } from "./M02_Material_Pick";
|
||||
import "./M02_MasterTemplete_Hopyo.css";
|
||||
|
||||
export interface HopyoViewOptions {
|
||||
scope: HopyoScope;
|
||||
/** 층 쓰기 권한 — 없으면 확정 · 만들기 · 지우기 단추 없음 */
|
||||
/** 층 쓰기 권한 — 없으면 체크 · 만들기 · 지우기 단추 없음 */
|
||||
canWrite: boolean;
|
||||
/** 파일을 지웠음 — 좌측 목록 다시 */
|
||||
onDeleted: () => void;
|
||||
}
|
||||
|
||||
const STATE: Record<string, string> = {
|
||||
기억: "기억",
|
||||
같음: "같음",
|
||||
후보: "후보",
|
||||
없음: "없음",
|
||||
빼기: "뺌",
|
||||
호표: "호표",
|
||||
"%": "% 줄",
|
||||
};
|
||||
const MATERIAL = new Set(["자재", "별도계상"]);
|
||||
|
||||
const why = (error: unknown): string => (error instanceof Error ? error.message : String(error));
|
||||
const won = (v: number | null | undefined): string =>
|
||||
v === null || v === undefined ? "" : v.toLocaleString("ko-KR");
|
||||
@@ -61,12 +34,12 @@ const fail = (error: unknown): void => showToast(`실패 — ${why(error)}`, "er
|
||||
const openSheet = (o: HopyoSheetOptions): void =>
|
||||
void import("./M02_MasterTemplete_Hopyo_Sheet").then((m) => m.openHopyoSheet(o), fail);
|
||||
|
||||
const grid = (heads: string[], body: HTMLElement, cls = ""): HTMLElement =>
|
||||
const grid = (heads: string[], body: HTMLElement): 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-hopyo__table ${cls}`.trim(),
|
||||
className: "ui-general-block__table m02-hopyo__table",
|
||||
children: [
|
||||
el("thead", {
|
||||
children: [el("tr", { children: heads.map((t) => el("th", { text: t })) })],
|
||||
@@ -76,87 +49,10 @@ const grid = (heads: string[], body: HTMLElement, cls = ""): HTMLElement =>
|
||||
}),
|
||||
],
|
||||
});
|
||||
const table = (heads: string[], rows: HTMLElement[]): HTMLElement =>
|
||||
grid(heads, el("tbody", { children: rows }));
|
||||
const td = (text: string, cls = ""): HTMLElement =>
|
||||
el("td", { text, className: cls, attrs: { title: text } });
|
||||
const note = (text: string): HTMLElement => el("span", { className: "m02-hopyo__note", text });
|
||||
|
||||
/** 대상 글 — 「이름 [단위]」 · 자재는 규격도 · 호표 = 「제N호표」 */
|
||||
export const targetText = (t: HopyoTarget | null | undefined): string => {
|
||||
if (!t) return "";
|
||||
if (t.호표 !== undefined) return `제${t.호표}호표 ${t.단위 ? `[${t.단위}]` : ""}`.trim();
|
||||
const spec = t.규격 ? ` ${t.규격}` : "";
|
||||
return `${t.이름 ?? t.로직 ?? t.자재 ?? ""}${spec}${t.단위 ? ` [${t.단위}]` : ""}`;
|
||||
};
|
||||
|
||||
/** 입력값 글 — 「이름=값」 · 못 읽은 입력 표시 */
|
||||
export const inputsText = (values: Record<string, unknown>, unread: string[]): string =>
|
||||
[
|
||||
Object.entries(values)
|
||||
.map(([k, v]) => `${k}=${String(v)}`)
|
||||
.join(", "),
|
||||
unread.length ? `⚠ 못 읽음: ${unread.join(", ")}` : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
|
||||
/** 만들기 결과 낱말 — [새로 만들기] = 「새로 만들어 덮음」 · 있던 것 = 「다시 씀」 · 그 밖 「만듦」 */
|
||||
export const builtWord = (got: Pick<HopyoBuilt, "다시씀">, fresh: boolean): string =>
|
||||
fresh ? "새로 만들어 덮음" : got.다시씀 ? "다시 씀" : "만듦";
|
||||
|
||||
/** 확정 한 줄 몸 — 대상 · 입력값 · 환산(0 넘는 수) · 없으면 null */
|
||||
export function memoryOf(
|
||||
target: HopyoTarget | null,
|
||||
values: Record<string, unknown>,
|
||||
times: number | null,
|
||||
): HopyoMemory {
|
||||
if (!target || times === null || !(times > 0)) return null;
|
||||
if (target.로직) return { 로직: target.로직, 입력값: values, 환산: times };
|
||||
if (target.자재) return { 자재: target.자재, 입력값: values, 환산: times };
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 근거 맞음(330 [근거 맞음 모두 확정]) — 조항 같음(앞 마디 아님) · 단위 맞음 · 직종 겹침
|
||||
* (줄 근거 직종이 보통인부뿐이거나 없으면 — 기계 등 — 직종은 안 봄) */
|
||||
export const basisOk = (b: HopyoBasisHit | undefined, line?: HopyoLineBasis): boolean =>
|
||||
!!b &&
|
||||
b.까닭.startsWith("조항 같음") &&
|
||||
b.단위 &&
|
||||
(b.직종.length > 0 || !(line?.직종 ?? []).some((x) => x !== "보통인부"));
|
||||
|
||||
/** 후보 근거 꼬리표 — 「조항 건설 공통 3-2-1 · 직종 특별인부 · 단위 같음」 · 근거 없으면 "" */
|
||||
export const basisTag = (b: HopyoBasisHit | undefined): string =>
|
||||
b
|
||||
? [
|
||||
b.조항.length ? `조항 ${b.조항.join(", ")}` : "",
|
||||
b.직종.length ? `직종 ${b.직종.join(", ")}` : "",
|
||||
b.단위 ? "단위 같음" : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ")
|
||||
: "";
|
||||
|
||||
/** 엑셀 근거 줄 — 「출처 건설표준품셈 3-2-1 · 직종 특별인부 · 단가산출 93–101행」 · 없으면 "" */
|
||||
export const sourceText = (b: HopyoLineBasis | undefined): string =>
|
||||
b
|
||||
? [
|
||||
b.출처.length ? `출처 ${b.출처.map((x) => x.join(" ").trim()).join(", ")}` : "",
|
||||
b.직종.length ? `직종 ${b.직종.join(", ")}` : "",
|
||||
`단가산출 ${b.행[0] === b.행[1] ? b.행[0] : `${b.행[0]}–${b.행[1]}`}행`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ")
|
||||
: "";
|
||||
|
||||
/** [근거 맞음 모두 확정] 대상 — 「후보」 줄의 지금 대상이 근거 맞으면 그것 · 아니면 근거 맞는 첫 후보 · 없으면 null */
|
||||
export function basisPick(line: HopyoLine): HopyoTarget | null {
|
||||
const m = line.맞춤;
|
||||
if (m.상태 !== "후보") return null;
|
||||
if (m.대상 && basisOk(m.대상.근거, line.근거)) return m.대상;
|
||||
return (m.후보 ?? []).find((c) => basisOk(c.근거, line.근거)) ?? null;
|
||||
}
|
||||
|
||||
export function buildHopyoView(key: string, opt: HopyoViewOptions): HTMLElement {
|
||||
const view = el("div", { className: "m02-hopyo", attrs: { "data-hopyo-view": key } });
|
||||
let nav = 0; // 늦게 온 응답 버림
|
||||
@@ -223,7 +119,7 @@ export function buildHopyoView(key: string, opt: HopyoViewOptions): HTMLElement
|
||||
note(`시트 ${file.시트}`),
|
||||
note(`호표 ${file.호표수}`),
|
||||
note(wrong.length ? `검산 어긋남 ${wrong.length}` : "검산 맞음"),
|
||||
note(`맞춤 ${file.맞춤[0]} / ${file.맞춤[1]}`),
|
||||
note(`제안 ${file.맞춤[0]} / 줄 ${file.맞춤[1]}`),
|
||||
createButton({
|
||||
label: "엑셀로 보기",
|
||||
size: "sm",
|
||||
@@ -235,7 +131,7 @@ export function buildHopyoView(key: string, opt: HopyoViewOptions): HTMLElement
|
||||
}),
|
||||
...(wrong.length ? [el("p", { className: "m02-hopyo__warn", text: wrong.join("\n") })] : []),
|
||||
search,
|
||||
grid(["호표", "품명", "규격", "단위", "엑셀 단가", "맞춤"], body),
|
||||
grid(["호표", "품명", "규격", "단위", "엑셀 단가", "제안 / 줄"], body),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -245,7 +141,7 @@ export function buildHopyoView(key: string, opt: HopyoViewOptions): HTMLElement
|
||||
variant: "danger",
|
||||
size: "sm",
|
||||
onClick: async () => {
|
||||
const ask = `실무자료 「${file.파일}」을 지움 — 만든 사전조합 · 맞춤 기억은 그대로`;
|
||||
const ask = `실무자료 「${file.파일}」을 지움 — 만든 사전조합 · 별칭 장부는 그대로`;
|
||||
if (!(await showConfirmDialog(ask, "지우기"))) return;
|
||||
try {
|
||||
await deleteHopyo(opt.scope, key);
|
||||
@@ -257,358 +153,20 @@ export function buildHopyoView(key: string, opt: HopyoViewOptions): HTMLElement
|
||||
},
|
||||
});
|
||||
|
||||
/* ── 호표 하나 — 줄 맞춤 ────────────────────────────────────── */
|
||||
async function drawOne(no: number, built?: HopyoBuilt & { 새로: boolean }): Promise<void> {
|
||||
const got = await load(
|
||||
() => readHopyoOne(opt.scope, key, no),
|
||||
() => void drawOne(no),
|
||||
);
|
||||
if (!got) return;
|
||||
const h = got.호표;
|
||||
const picked = new Map<number, HopyoGuess>(); // 고른 대상(확정 전)
|
||||
const back = createButton({ label: "← 호표 목록", variant: "ghost", size: "sm" });
|
||||
back.addEventListener("click", () => void drawFile());
|
||||
const rows = el("tbody");
|
||||
const paintRows = (): void => {
|
||||
rows.replaceChildren(...h.줄.map((line) => lineRow(got, line, picked, paintRows)));
|
||||
};
|
||||
paintRows();
|
||||
const same = h.줄.filter((x) => x.맞춤.상태 === "같음" && x.맞춤.환산?.배 != null);
|
||||
const tools: HTMLElement[] = [];
|
||||
if (opt.canWrite) {
|
||||
const all = createButton({ label: `같음 모두 확정 (${same.length})`, size: "sm" });
|
||||
all.disabled = !same.length;
|
||||
all.addEventListener("click", () => void confirmAll(no, same));
|
||||
tools.push(all);
|
||||
// 근거(330) — 옛 파일 · 근거 없는 호표면 단추 없음
|
||||
if (h.줄.some((x) => x.근거 || x.맞춤.후보?.some((c) => c.근거))) {
|
||||
const fit = h.줄.filter((x) => basisPick(x));
|
||||
const ok = createButton({ label: `근거 맞음 모두 확정 (${fit.length})`, size: "sm" });
|
||||
ok.title = "조항 · 직종 · 단위가 모두 맞는 후보만";
|
||||
ok.disabled = !fit.length;
|
||||
ok.addEventListener("click", () => void confirmBasis(no, fit));
|
||||
tools.push(ok);
|
||||
}
|
||||
const make = createButton({ label: "사전조합 만들기", variant: "filled", size: "sm" });
|
||||
make.addEventListener("click", () => void build(no, false));
|
||||
tools.push(make);
|
||||
}
|
||||
const missing = h.줄.filter((x) => !x.맞춤.들어감);
|
||||
/* ── 호표 하나 — 확인 창 ────────────────────────────────────── */
|
||||
function drawOne(no: number): void {
|
||||
nav += 1; // 파일 표 늦은 응답 버림
|
||||
view.replaceChildren(
|
||||
el("div", {
|
||||
className: "m02-hopyo__bar",
|
||||
children: [
|
||||
back,
|
||||
el("strong", { text: `제${h.번}호표 ${h.이름}` }),
|
||||
note(h.규격),
|
||||
note(h.단위),
|
||||
note(`엑셀 단가 ${won(h.금액.계)}`),
|
||||
note(`맞춤 ${h.맞춤[0]} / ${h.맞춤[1]}`),
|
||||
createButton({
|
||||
label: "엑셀에서 보기",
|
||||
size: "sm",
|
||||
onClick: () =>
|
||||
openSheet({
|
||||
scope: opt.scope,
|
||||
key,
|
||||
파일: got.파일,
|
||||
canWrite: opt.canWrite,
|
||||
호표: no,
|
||||
}),
|
||||
}),
|
||||
...tools,
|
||||
],
|
||||
buildHopyoCheck({
|
||||
scope: opt.scope,
|
||||
key,
|
||||
no,
|
||||
canWrite: opt.canWrite,
|
||||
onBack: () => void drawFile(),
|
||||
}),
|
||||
h.줄.length
|
||||
? grid(
|
||||
[
|
||||
"갈래",
|
||||
"품명",
|
||||
"규격",
|
||||
"수량",
|
||||
"단위",
|
||||
"엑셀 단가",
|
||||
"맞춤",
|
||||
"대상",
|
||||
"입력값",
|
||||
"환산",
|
||||
"",
|
||||
],
|
||||
rows,
|
||||
"m02-hopyo__lines",
|
||||
)
|
||||
: el("p", { className: "m02-hopyo__note", text: "구성 줄 없음" }),
|
||||
...(missing.length ? [missingBlock(missing)] : []),
|
||||
...(built ? [builtBlock(no, built)] : []),
|
||||
);
|
||||
}
|
||||
|
||||
function lineRow(
|
||||
got: HopyoOne,
|
||||
line: HopyoLine,
|
||||
picked: Map<number, HopyoGuess>,
|
||||
repaint: () => void,
|
||||
): HTMLElement {
|
||||
const m = line.맞춤;
|
||||
const mine = picked.get(line.차례);
|
||||
const target = mine?.대상 ?? m.대상;
|
||||
const values = mine?.입력값 ?? m.입력값;
|
||||
const conv = mine?.환산 ?? m.환산;
|
||||
const fixed = ["%", "호표", "빼기"].includes(m.상태);
|
||||
const times = el("input", {
|
||||
className: "ui-input ui-input--sm m02-hopyo__times",
|
||||
attrs: {
|
||||
type: "number",
|
||||
step: "any",
|
||||
min: "0",
|
||||
value: conv?.배 != null ? String(conv.배) : "",
|
||||
},
|
||||
}) as HTMLInputElement;
|
||||
times.disabled = !opt.canWrite || fixed || !target;
|
||||
const state = mine ? "고름" : (STATE[m.상태] ?? m.상태);
|
||||
const actions: HTMLElement[] = [];
|
||||
if (opt.canWrite && m.상태 === "빼기") {
|
||||
actions.push(
|
||||
createButton({
|
||||
label: "되살림",
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () => void remember(got.호표.번, { [m.열쇠]: null }),
|
||||
}),
|
||||
);
|
||||
} else if (opt.canWrite && !["%", "호표"].includes(m.상태)) {
|
||||
actions.push(
|
||||
createButton({
|
||||
label: "고르기",
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () =>
|
||||
choose(line, got.파일, (t) =>
|
||||
guessHopyo(opt.scope, key, got.호표.번, line.차례, t).then(
|
||||
(g) => (picked.set(line.차례, g), repaint()),
|
||||
fail,
|
||||
),
|
||||
),
|
||||
}),
|
||||
createButton({
|
||||
label: "확정",
|
||||
size: "sm",
|
||||
disabled: !target,
|
||||
onClick: () => {
|
||||
const body = memoryOf(target, values, times.value === "" ? null : Number(times.value));
|
||||
if (!body) return showToast("대상과 0 넘는 환산을 넣어야 확정", "error");
|
||||
void remember(got.호표.번, { [m.열쇠]: body });
|
||||
},
|
||||
}),
|
||||
createButton({
|
||||
label: "빼기",
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () => void remember(got.호표.번, { [m.열쇠]: { 빼기: true } }),
|
||||
}),
|
||||
);
|
||||
}
|
||||
return el("tr", {
|
||||
className: `m02-hopyo__line is-${m.들어감 ? "in" : "out"}`,
|
||||
attrs: { "data-line": String(line.차례), "data-state": m.상태 },
|
||||
children: [
|
||||
td(line.갈래),
|
||||
td(line.이름),
|
||||
td(line.규격),
|
||||
td(line.수량 === null ? "" : String(line.수량), "m02-hopyo__num"),
|
||||
td(line.단위),
|
||||
td(won(line.단가.계), "m02-hopyo__num"),
|
||||
el("td", {
|
||||
children: [el("span", { className: `m02-hopyo__state is-${m.상태}`, text: state })],
|
||||
}),
|
||||
td(targetText(target)),
|
||||
td(inputsText(values, mine?.못읽음 ?? m.못읽음)),
|
||||
el("td", {
|
||||
children: [times, note(conv?.갈래 ? ` ${conv.갈래}` : "")],
|
||||
}),
|
||||
el("td", { className: "m02-hopyo__acts", children: actions }),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
/** 대상 고르기 — 엑셀 근거 줄 · 후보 5(근거 꼬리표) + 다른 로직 · 자재 찾기 */
|
||||
function choose(line: HopyoLine, 파일: string, done: (t: HopyoTarget) => void): void {
|
||||
const material = MATERIAL.has(line.갈래);
|
||||
openModal({
|
||||
title: `고르기 — ${line.이름} ${line.규격}`.trim(),
|
||||
closeLabel: "닫기",
|
||||
dialogClass: "m02-modal m02-hopyo__choose",
|
||||
mount: (body, close) => {
|
||||
const pick = (t: HopyoTarget): void => {
|
||||
close();
|
||||
done(t);
|
||||
};
|
||||
const list = (line.맞춤.후보 ?? []).map((c) => {
|
||||
const button = createButton({
|
||||
label: `${targetText(c)} · ${c.로직 ?? c.자재 ?? ""}`,
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () => pick(c),
|
||||
});
|
||||
const tag = basisTag(c.근거);
|
||||
if (tag && c.근거)
|
||||
button.append(
|
||||
el("span", {
|
||||
className: `m02-hopyo__basis${basisOk(c.근거, line.근거) ? " is-ok" : ""}`,
|
||||
text: tag,
|
||||
attrs: { title: `${c.근거.까닭} · 점수 ${c.근거.점수}` },
|
||||
}),
|
||||
);
|
||||
return button;
|
||||
});
|
||||
const basis = line.근거;
|
||||
const source = basis
|
||||
? [
|
||||
el("div", {
|
||||
className: "m02-hopyo__bar m02-hopyo__source",
|
||||
children: [
|
||||
note(`엑셀 근거 — ${sourceText(basis)}`),
|
||||
createButton({
|
||||
label: "엑셀에서 보기",
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () =>
|
||||
openSheet({
|
||||
scope: opt.scope,
|
||||
key,
|
||||
파일,
|
||||
canWrite: opt.canWrite,
|
||||
단산행: basis.행,
|
||||
}),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
]
|
||||
: [];
|
||||
const more = createButton({
|
||||
label: material ? "다른 자재 찾기…" : "다른 로직 찾기…",
|
||||
size: "sm",
|
||||
onClick: () => {
|
||||
close();
|
||||
if (material) pickMaterial((x) => done({ 자재: x.코드 }));
|
||||
else openLogicPicker((x) => done({ 로직: x.키 }));
|
||||
},
|
||||
});
|
||||
body.append(
|
||||
...source,
|
||||
el("p", { className: "m02-hopyo__note", text: list.length ? "후보" : "가까운 것 없음" }),
|
||||
el("div", { className: "m02-hopyo__cands", children: list }),
|
||||
more,
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function remember(no: number, 줄: Record<string, HopyoMemory>): Promise<void> {
|
||||
try {
|
||||
await saveHopyoMemory(opt.scope, 줄);
|
||||
await drawOne(no);
|
||||
} catch (error) {
|
||||
fail(error);
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmAll(no: number, lines: HopyoLine[]): Promise<void> {
|
||||
const body: Record<string, HopyoMemory> = {};
|
||||
for (const x of lines) {
|
||||
const one = memoryOf(x.맞춤.대상, x.맞춤.입력값, x.맞춤.환산?.배 ?? null);
|
||||
if (one) body[x.맞춤.열쇠] = one;
|
||||
}
|
||||
await remember(no, body);
|
||||
}
|
||||
|
||||
/** 근거 맞는 후보로 확정 — 지금 대상이 아니면 입력값 · 환산을 서버에서 다시 읽음 */
|
||||
async function confirmBasis(no: number, lines: HopyoLine[]): Promise<void> {
|
||||
const body: Record<string, HopyoMemory> = {};
|
||||
for (const x of lines) {
|
||||
const c = basisPick(x);
|
||||
const m = x.맞춤;
|
||||
if (!c) continue;
|
||||
const g =
|
||||
c === m.대상
|
||||
? { 대상: c, 입력값: m.입력값, 환산: m.환산 }
|
||||
: await guessHopyo(opt.scope, key, no, x.차례, c).catch(() => null);
|
||||
const one = g && memoryOf(g.대상, g.입력값, g.환산?.배 ?? null);
|
||||
if (one) body[m.열쇠] = one;
|
||||
}
|
||||
if (!Object.keys(body).length) return showToast("확정할 줄 없음 — 환산을 못 읽음", "warning");
|
||||
await remember(no, body);
|
||||
}
|
||||
|
||||
async function build(no: number, fresh: boolean): Promise<void> {
|
||||
try {
|
||||
const from = { 층: opt.scope.layer, 주인: opt.scope.owner, 키: key, 번: no };
|
||||
const got = await buildHopyo(opt.scope, from, fresh);
|
||||
const word = builtWord(got, fresh);
|
||||
const miss = got.빠진줄.length;
|
||||
showToast(miss ? `${word} — 빠진 줄 ${miss}` : word, miss ? "warning" : "success");
|
||||
await drawOne(no, { ...got, 새로: fresh });
|
||||
} catch (error) {
|
||||
fail(error);
|
||||
}
|
||||
}
|
||||
|
||||
const missingBlock = (lines: HopyoLine[]): HTMLElement =>
|
||||
el("div", {
|
||||
className: "m02-hopyo__block",
|
||||
children: [
|
||||
el("h4", { text: `빠진 줄 ${lines.length} — 사전조합에 안 들어감` }),
|
||||
table(
|
||||
["품명", "규격", "수량", "단위", "엑셀 금액", "까닭"],
|
||||
lines.map((x) =>
|
||||
el("tr", {
|
||||
children: [
|
||||
td(x.이름),
|
||||
td(x.규격),
|
||||
td(x.수량 === null ? "" : String(x.수량), "m02-hopyo__num"),
|
||||
td(x.단위),
|
||||
td(won(x.금액.계), "m02-hopyo__num"),
|
||||
td(STATE[x.맞춤.상태] ?? x.맞춤.상태),
|
||||
],
|
||||
}),
|
||||
),
|
||||
),
|
||||
],
|
||||
});
|
||||
|
||||
function builtBlock(no: number, got: HopyoBuilt & { 새로: boolean }): HTMLElement {
|
||||
const c = got.비교;
|
||||
const again = got.다시씀
|
||||
? [
|
||||
note(
|
||||
got.새로
|
||||
? `사전조합 ${got.참조.키} 를 새로 만들어 덮음`
|
||||
: `있던 사전조합 ${got.참조.키} 를 다시 씀`,
|
||||
),
|
||||
createButton({
|
||||
label: "새로 만들기",
|
||||
variant: "danger",
|
||||
size: "sm",
|
||||
onClick: async () => {
|
||||
const ask = `사전조합 ${got.참조.키} 를 지금 맞춤으로 다시 만들어 덮음`;
|
||||
if (await showConfirmDialog(ask, "새로 만들기")) await build(no, true);
|
||||
},
|
||||
}),
|
||||
]
|
||||
: [note(`새 사전조합 ${got.참조.키}`)];
|
||||
return el("div", {
|
||||
className: "m02-hopyo__block m02-hopyo__built",
|
||||
attrs: { "data-built": got.참조.키 },
|
||||
children: [
|
||||
el("div", { className: "m02-hopyo__bar", children: again }),
|
||||
note(
|
||||
`값 비교 — 엑셀 ${won(c.엑셀)} · 우리 ${won(c.우리 === null ? null : Math.round(c.우리))}` +
|
||||
` · 차이 ${won(c.차이)}${c.까닭 ? ` · ${c.까닭}` : ""}`,
|
||||
),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
void drawFile();
|
||||
return view;
|
||||
}
|
||||
|
||||
@@ -80,7 +80,12 @@ const LAYER_TEXT = {
|
||||
personal: "Lib_Personal",
|
||||
} as const;
|
||||
|
||||
export function openLogicPicker(done: (logic: LogicSummary) => void, lib?: LibraryPick): void {
|
||||
/** `pre.query` = 찾기 칸에 미리 넣을 글(338 실무자료 고르기 창 · 인용 조항 번호 · 품명 낱말) */
|
||||
export function openLogicPicker(
|
||||
done: (logic: LogicSummary) => void,
|
||||
lib?: LibraryPick,
|
||||
pre?: { query?: string },
|
||||
): void {
|
||||
const subs: SubBrief[] = [];
|
||||
let classes: ReturnType<typeof precomboClasses> = []; // 사전조합 새 분류(149-2) — 서고 줄이 있을 때만
|
||||
let all: LogicSummary[] = [];
|
||||
@@ -112,6 +117,7 @@ export function openLogicPicker(done: (logic: LogicSummary) => void, lib?: Libra
|
||||
filters: [...(kindSel ? [kindSel.root] : []), sub.root, detail.root],
|
||||
extra: [layerBar],
|
||||
});
|
||||
if (pre?.query) finder.search.value = pre.query;
|
||||
|
||||
const find = (): void => {
|
||||
const q = finder.search.value.trim().toLowerCase();
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
/* M02 일위대가 실무자료 확인 창(338-3) 헬퍼 — `Hopyo_Check` · `Hopyo_Choose` · `Hopyo_Ledger` · `Hopyo_Sheet`
|
||||
* 도우미와 `Hopyo_Api` 길을 트랜스파일해 Node 로 돌림 · fetch 대역이 부른 길 · 몸을 모음 · sessionStorage 대역 ·
|
||||
* stdin = {lines, rows, ledger} · stdout = {check, choose, ledger, sheet, calls, pick} JSON. */
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
|
||||
|
||||
const ROOT = path.join(__dirname, "..", "..");
|
||||
const STUBS = { "@config/config_frontend": { API_BASE_URL: "/api" } };
|
||||
// DOM · 모듈 메타를 쓰는 창은 대역(도우미 · 길만 시험)
|
||||
const STUB_FILES = [
|
||||
"M02_MasterTemplete_Labor_Pick",
|
||||
"M02_Material_Pick",
|
||||
"M02_MasterTemplete_Labor_Api",
|
||||
"M02_MasterTemplete_Labor_Input",
|
||||
"M01_MasterData_UI_LogicLab_Source",
|
||||
];
|
||||
const opened = [];
|
||||
const stub = new Proxy(
|
||||
{},
|
||||
{
|
||||
get: (_t, k) =>
|
||||
k === "currentLanguageIndex"
|
||||
? () => 0
|
||||
: k === "openModal"
|
||||
? (o) => opened.push(o.title)
|
||||
: () => {},
|
||||
},
|
||||
);
|
||||
const cache = new Map();
|
||||
function loadTs(file) {
|
||||
const full = path.resolve(file);
|
||||
if (cache.has(full)) return cache.get(full);
|
||||
const js = ts.transpileModule(fs.readFileSync(full, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
|
||||
}).outputText;
|
||||
const box = { exports: {} };
|
||||
cache.set(full, box.exports);
|
||||
const localRequire = (request) => {
|
||||
if (STUBS[request]) return STUBS[request];
|
||||
if (STUB_FILES.some((name) => request.endsWith(name))) return stub;
|
||||
if (request.startsWith(".") && !request.endsWith(".css"))
|
||||
return loadTs(`${path.resolve(path.dirname(full), request)}.ts`);
|
||||
return stub;
|
||||
};
|
||||
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
|
||||
return box.exports;
|
||||
}
|
||||
|
||||
const store = new Map();
|
||||
global.sessionStorage = {
|
||||
getItem: (k) => (store.has(k) ? store.get(k) : null),
|
||||
setItem: (k, v) => store.set(k, String(v)),
|
||||
removeItem: (k) => store.delete(k),
|
||||
};
|
||||
|
||||
(async () => {
|
||||
const input = JSON.parse(fs.readFileSync(0, "utf8"));
|
||||
const calls = [];
|
||||
global.fetch = async (url, init = {}) => {
|
||||
calls.push({
|
||||
url,
|
||||
method: init.method ?? "GET",
|
||||
body: init.body ? JSON.parse(init.body) : null,
|
||||
});
|
||||
const body = url.includes("/hopyo-picks") || url.includes("/hopyo-ledger") ? { 줄: [] } : {};
|
||||
return { status: 200, ok: true, json: async () => body };
|
||||
};
|
||||
const M02 = path.join(ROOT, "M02_MasterTemplete");
|
||||
const check = loadTs(path.join(M02, "M02_MasterTemplete_Hopyo_Check.ts"));
|
||||
const choose = loadTs(path.join(M02, "M02_MasterTemplete_Hopyo_Choose.ts"));
|
||||
const ledger = loadTs(path.join(M02, "M02_MasterTemplete_Hopyo_Ledger.ts"));
|
||||
const sheet = loadTs(path.join(M02, "M02_MasterTemplete_Hopyo_Sheet.ts"));
|
||||
const api = loadTs(path.join(M02, "M02_MasterTemplete_Hopyo_Api.ts"));
|
||||
const pick = loadTs(path.join(M02, "M02_MasterTemplete_Hopyo_Pick.ts"));
|
||||
const { lines } = input;
|
||||
const scope = { layer: "personal", owner: null };
|
||||
const key = check.draftKey(scope, "HP-000001", 3);
|
||||
|
||||
// 서버 제안 그대로 → 사용자가 만짐(체크 끔 · 뺌 · 고른 대상) → 캐시 다시 읽음
|
||||
const fresh = check.loadDraft(key);
|
||||
const suggested = check.bodyOf(lines, fresh);
|
||||
const d = check.loadDraft(key);
|
||||
d.체크[1] = false; // 맞음 줄 체크 끔
|
||||
d.뺌[3] = "우리에 없음";
|
||||
d.체크[3] = true;
|
||||
d.대상[4] = [{ 로직: "GF000211", 이름: "고임돌", 입력값: { 유형: "보통" }, 배: 0.1, 몫: 0.1 }];
|
||||
d.체크[4] = true;
|
||||
check.saveDraft(key, d);
|
||||
const again = check.loadDraft(key);
|
||||
const ask = check.newest();
|
||||
const a = ask(1);
|
||||
const b = ask(1);
|
||||
const c = ask(2);
|
||||
const out = {
|
||||
check: {
|
||||
key,
|
||||
suggested,
|
||||
edited: check.bodyOf(lines, again),
|
||||
leftOut: check.leftOut(lines, again),
|
||||
checkable: lines.map((l) => check.checkable(l, fresh)),
|
||||
sure: check.sureLines(lines, fresh).map((l) => l.차례),
|
||||
compare: check.compareText(lines[1].맞춤.품),
|
||||
toTarget: [
|
||||
check.toTarget({ 호표: 46, 이름: "고화매트", 입력값: {}, 배: 2.3 }),
|
||||
check.toTarget({ 자재: "MT1", 이름: "레미콘", 입력값: {}, 배: 1, 몫: null }),
|
||||
],
|
||||
newest: [a(), b(), c()],
|
||||
dropped: (check.dropDraft(key), store.has(key)),
|
||||
},
|
||||
choose: {
|
||||
hint: [
|
||||
choose.searchHint({
|
||||
이름: "레미콘타설(장비사용)",
|
||||
근거: { 출처: [["건설품셈", "6-1-1"]], 직종: [], 행: [1, 1] },
|
||||
}),
|
||||
choose.searchHint({ 이름: "(소)고임돌 채집 및 운반" }),
|
||||
],
|
||||
picked: choose.pickedTargets([
|
||||
{ c: { 로직: "GC000206" }, 몫: null },
|
||||
{ c: { 로직: "GF000211" }, 몫: 0.1 },
|
||||
]),
|
||||
inputs: choose.inputsText({ 소량감률: 50, 지역: "서울" }, ["측압"], {
|
||||
소량감률: "블록",
|
||||
지역: "자동값",
|
||||
}),
|
||||
word: [choose.builtWord({ 다시씀: false }), choose.builtWord({ 다시씀: true })],
|
||||
qty: choose.qtyText(lines[1].맞춤.품.엑셀),
|
||||
target: choose.targetText({ 호표: 46, 단위: "㎡" }),
|
||||
},
|
||||
ledger: (() => {
|
||||
const rows = input.ledger;
|
||||
const edits = {
|
||||
"가|b|m3": null, // 지움
|
||||
"나||m3": { 원래: { 품명: "나", 규격: "", 단위: "m3" }, 갈래: "안 씀", 짝: [] },
|
||||
"다||개": ledger.entryOf(rows[2]), // 위 층 → 내 층
|
||||
};
|
||||
const merged = ledger.mergeRows(rows, edits, { layer: "personal", owner: "7/42" });
|
||||
return {
|
||||
key: ledger.ledgerKey({ layer: "company", owner: "7" }),
|
||||
merged: merged.map((r) => [r.열쇠, r.층, r.고침, !!r.고친, r.갈래]),
|
||||
filter: ledger.filterRows(merged, "MT1", "").map((r) => r.열쇠),
|
||||
kind: ledger.filterRows(merged, "", "안 씀").map((r) => r.열쇠),
|
||||
pair: ledger.pairText({ 로직: "GC000198", 입력값: { 소량감률: 50 }, 배: 1, 몫: 0.1 }),
|
||||
entry: ledger.entryOf({ ...rows[0], 갈래: "우리에 없음" }),
|
||||
};
|
||||
})(),
|
||||
sheet: {
|
||||
order: sheet.buildOrder([
|
||||
{ 번: 45, 아래: [46] },
|
||||
{ 번: 46, 아래: [] },
|
||||
{ 번: 3, 아래: [] },
|
||||
{ 번: 7, 아래: [8] },
|
||||
{ 번: 8, 아래: [7] },
|
||||
]),
|
||||
},
|
||||
};
|
||||
// 길 — 만들기(줄 · 통째) · 호표 하나(만들 층) · 미리 셈 · 장부
|
||||
const from = { 층: "system", 주인: null, 키: "HP-000001", 번: 3 };
|
||||
const target = { layer: "company", owner: "7" };
|
||||
await api.buildHopyo(target, from, suggested);
|
||||
await api.buildHopyo(target, from, [], { 대상: [{ 로직: "GF000225", 배: 1 }] });
|
||||
await api.readHopyoOne({ layer: "system", owner: null }, "HP-000001", 3, target);
|
||||
await api.guessHopyo(scope, "HP-000001", 3, 4, [
|
||||
{ 로직: "GF000211", 입력값: {}, 배: 0.1, 몫: 0.1 },
|
||||
]);
|
||||
await api.readLedger(target);
|
||||
await api.saveLedger(target, { "가|b|m3": null });
|
||||
out.calls = calls.splice(0);
|
||||
// [로직 추가] 실무 호표 — 확인된 것 = 바로 참조 · 아닌 것 = 확인 창(만들기 길 안 부름)
|
||||
const picked = [];
|
||||
const lib = {
|
||||
layer: "company",
|
||||
owner: "7",
|
||||
pick: (ref, name, alias) => picked.push({ ref, name, alias }),
|
||||
};
|
||||
for (const row of input.rows) await pick.pickHopyo(row, lib);
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
out.pick = { picked, calls: calls.splice(0), opened };
|
||||
process.stdout.write(JSON.stringify(out));
|
||||
})().catch((error) => {
|
||||
process.stderr.write(String(error?.stack ?? error));
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
/* M02 일위대가 실무자료(324-4) 헬퍼 — `M02_MasterTemplete_Hopyo_Pick.ts`(고르기 창 「실무 호표」 덧줄) ·
|
||||
* `M02_MasterTemplete_Hopyo_View.ts` 글 도우미 · `M02_MasterTemplete_Hopyo_Sheet.ts` 엑셀 보기 창 도우미(329 · 행 → 호표)를
|
||||
/* M02 일위대가 실무자료(324-4 · 338-3) 헬퍼 — `M02_MasterTemplete_Hopyo_Pick.ts`(고르기 창 「실무 호표」 덧줄) ·
|
||||
* `M02_MasterTemplete_Hopyo_Choose.ts` 글 도우미 · `M02_MasterTemplete_Hopyo_Sheet.ts` 엑셀 보기 창 도우미(329 · 행 → 호표)를
|
||||
* 트랜스파일해 Node 로 돌림 · fetch 대역이 부른 길 · 몸을 모음 ·
|
||||
* stdin = {rows, built} · stdout = {calls, kinds, rows, picked, project, view, sheet} JSON. */
|
||||
const fs = require("fs");
|
||||
@@ -8,8 +8,14 @@ const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "t
|
||||
|
||||
const ROOT = path.join(__dirname, "..", "..");
|
||||
const STUBS = { "@config/config_frontend": { API_BASE_URL: "/api" } };
|
||||
// 본 화면이 부르는 고르기 창 · 자재 창은 DOM · 모듈 메타를 씀 — 대역(글 도우미만 시험)
|
||||
const STUB_FILES = ["M02_MasterTemplete_Labor_Pick", "M02_Material_Pick"];
|
||||
// 고르기 창 · 자재 창 · 입력 창 · 원문 창은 DOM · 모듈 메타를 씀 — 대역(글 도우미만 시험)
|
||||
const STUB_FILES = [
|
||||
"M02_MasterTemplete_Labor_Pick",
|
||||
"M02_Material_Pick",
|
||||
"M02_MasterTemplete_Labor_Api",
|
||||
"M02_MasterTemplete_Labor_Input",
|
||||
"M01_MasterData_UI_LogicLab_Source",
|
||||
];
|
||||
const stub = new Proxy({}, { get: (_t, k) => (k === "currentLanguageIndex" ? () => 0 : () => {}) });
|
||||
const cache = new Map();
|
||||
function loadTs(file) {
|
||||
@@ -55,7 +61,7 @@ function loadTs(file) {
|
||||
const rows = (await wrapped.more()).map(({ onPick: _, ...r }) => r);
|
||||
await pick.pickHopyo(input.rows[0], lib);
|
||||
const project = pick.withHopyo({ ...lib, layer: "project" });
|
||||
const view = loadTs(path.join(M02, "M02_MasterTemplete_Hopyo_View.ts"));
|
||||
const view = loadTs(path.join(M02, "M02_MasterTemplete_Hopyo_Choose.ts"));
|
||||
const sheet = loadTs(path.join(M02, "M02_MasterTemplete_Hopyo_Sheet.ts"));
|
||||
const api = loadTs(path.join(M02, "M02_MasterTemplete_Hopyo_Api.ts"));
|
||||
await api.readHopyoGrid({ layer: "company", owner: "7" }, "HP-000001");
|
||||
@@ -76,17 +82,6 @@ function loadTs(file) {
|
||||
view.targetText({ 호표: 46, 단위: "㎡" }),
|
||||
],
|
||||
inputs: view.inputsText({ 횟수: "4회사용시", 지역: "서울" }, ["보정작업"]),
|
||||
memory: [
|
||||
view.memoryOf({ 로직: "GF000101" }, { 지역: "서울" }, 1),
|
||||
view.memoryOf({ 자재: "MT1" }, {}, 0.5),
|
||||
view.memoryOf({ 로직: "GF000101" }, {}, 0),
|
||||
view.memoryOf(null, {}, 1),
|
||||
],
|
||||
word: [
|
||||
view.builtWord({ 다시씀: false }, false),
|
||||
view.builtWord({ 다시씀: true }, false),
|
||||
view.builtWord({ 다시씀: true }, true),
|
||||
],
|
||||
},
|
||||
basis: (() => {
|
||||
const hit = {
|
||||
@@ -99,9 +94,6 @@ function loadTs(file) {
|
||||
const half = { ...hit, 직종: [] };
|
||||
const trade = { 출처: [["건설품셈", "3-2-1"]], 직종: ["특별인부", "보통인부"], 행: [1, 1] };
|
||||
const plain = { ...trade, 직종: ["보통인부"] };
|
||||
const line = (상태, 대상, 후보) => ({ 근거: trade, 맞춤: { 상태, 대상, 후보 } });
|
||||
const a = { 로직: "GF000101", 근거: half };
|
||||
const b = { 로직: "GC000083", 근거: hit };
|
||||
return {
|
||||
ok: [hit, half, { ...hit, 단위: false }, { ...hit, 까닭: "앞 마디 같음" }, undefined].map(
|
||||
(b) => view.basisOk(b, trade),
|
||||
@@ -114,12 +106,6 @@ function loadTs(file) {
|
||||
view.sourceText({ 출처: [], 직종: [], 행: [7, 7] }),
|
||||
view.sourceText(undefined),
|
||||
],
|
||||
pick: [
|
||||
line("후보", a, [a, b]),
|
||||
line("후보", b, [b]),
|
||||
line("같음", b, [b]),
|
||||
line("후보", a, [a]),
|
||||
].map((x) => view.basisPick(x)?.로직 ?? null),
|
||||
};
|
||||
})(),
|
||||
sheet: {
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
/* M02 일위대가 실무자료 이음매(329 · 330) 헬퍼 — 실제 서버 응답(격자 · 호표 맞춤)을
|
||||
* `M02_MasterTemplete_Hopyo_Sheet.ts`(행 → 호표 · 영역 A1) · `M02_MasterTemplete_Hopyo_View.ts`
|
||||
* (근거 꼬리표 · 엑셀 근거 줄 · 근거 맞음 대상)에 먹임 ·
|
||||
* stdin = {grid, lines, all} · stdout = {at, span, rows, lines: [{이름, source, tag, ok, pick}],
|
||||
* count: {top, pick, half}} JSON — count = 「후보」 줄 1순위 근거 맞음 · 근거 맞음 대상 · 그 가운데 앞 마디. */
|
||||
* `M02_MasterTemplete_Hopyo_Sheet.ts`(행 → 호표 · 영역 A1) · `M02_MasterTemplete_Hopyo_Choose.ts`
|
||||
* (근거 꼬리표 · 엑셀 근거 줄 · 근거 맞음)에 먹임 ·
|
||||
* stdin = {grid, lines, all} · stdout = {at, span, rows, lines: [{이름, source, tag, ok}],
|
||||
* count: {top}} JSON — count = 「후보」 줄 1순위 근거 맞음. */
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
|
||||
|
||||
const ROOT = path.join(__dirname, "..", "..");
|
||||
const STUBS = { "@config/config_frontend": { API_BASE_URL: "/api" } };
|
||||
const STUB_FILES = ["M02_MasterTemplete_Labor_Pick", "M02_Material_Pick"];
|
||||
const STUB_FILES = [
|
||||
"M02_MasterTemplete_Labor_Pick",
|
||||
"M02_Material_Pick",
|
||||
"M02_MasterTemplete_Labor_Api",
|
||||
"M02_MasterTemplete_Labor_Input",
|
||||
"M01_MasterData_UI_LogicLab_Source",
|
||||
];
|
||||
const stub = new Proxy({}, { get: (_t, k) => (k === "currentLanguageIndex" ? () => 0 : () => {}) });
|
||||
const cache = new Map();
|
||||
function loadTs(file) {
|
||||
@@ -34,7 +40,7 @@ function loadTs(file) {
|
||||
const input = JSON.parse(fs.readFileSync(0, "utf8"));
|
||||
const M02 = path.join(ROOT, "M02_MasterTemplete");
|
||||
const sheet = loadTs(path.join(M02, "M02_MasterTemplete_Hopyo_Sheet.ts"));
|
||||
const view = loadTs(path.join(M02, "M02_MasterTemplete_Hopyo_View.ts"));
|
||||
const view = loadTs(path.join(M02, "M02_MasterTemplete_Hopyo_Choose.ts"));
|
||||
const { grid } = input;
|
||||
const ilwi = grid.책.시트.find((s) => s.이름 === "일위대가");
|
||||
const three = grid.호표.find((h) => h.번 === 3);
|
||||
@@ -49,15 +55,11 @@ const out = {
|
||||
source: view.sourceText(line.근거),
|
||||
tag: view.basisTag(top?.근거),
|
||||
ok: view.basisOk(top?.근거, line.근거),
|
||||
pick: view.basisPick(line)?.로직 ?? null,
|
||||
};
|
||||
}),
|
||||
};
|
||||
const open = (input.all ?? []).filter((line) => line.맞춤.상태 === "후보");
|
||||
const picks = open.map((line) => view.basisPick(line)).filter(Boolean);
|
||||
out.count = {
|
||||
top: open.filter((line) => view.basisOk((line.맞춤.후보 ?? [])[0]?.근거, line.근거)).length,
|
||||
pick: picks.length,
|
||||
half: picks.filter((c) => !c.근거.까닭.startsWith("조항 같음")).length,
|
||||
};
|
||||
process.stdout.write(JSON.stringify(out));
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
"""일위대가 실무자료 화면(PLAN 324-4) — 좌측 판 · 본 화면 · [로직 추가] 「실무 호표」 줄.
|
||||
"""일위대가 실무자료 화면(PLAN 324-4 · 338-3) — 좌측 판 · 본 화면 · 확인 창 · 고르기 창 ·
|
||||
별칭 장부 · [로직 추가] 「실무 호표」 줄 · 엑셀 보기 창.
|
||||
|
||||
- 고르기 창 덧줄(Node · fetch 대역): 이 층 · 위 층 호표 줄(맞춤 n/m) · 종류 고르기 ·
|
||||
고르면 문서 층에 만들기 길 → `lib.pick` 참조 줄(별칭 = 호표 품명) · 프로젝트 층엔 없음 ·
|
||||
본 화면 글 도우미
|
||||
- 고르기 창 근거(330-3): 꼬리표 · 엑셀 근거 줄 · [근거 맞음 모두 확정] 대상
|
||||
- 엑셀 보기 창(329): 행 → 호표 · 호표 영역 A1 · 격자 길 · 여는 자리 넷 · 늦게 불러옴
|
||||
- 소스: 좌측 판 자리(사전조합 아래 · 구조물 조합 층 · 쓰기 권한 없으면 올리기 꺼짐 ·
|
||||
숨은 file 입력) · Labor_Detail 줄 수 그대로 · 새 파일 이름 · 700 줄 · 색 = 공용 토큰
|
||||
- 확인 창 도우미(Node · fetch · sessionStorage 대역 · `helper_m02_hopyo_check.cjs`):
|
||||
체크 → 몸(체크한 줄만 · 뺌 · 고른 대상 · 몫 · 배 · 호표) · 빠질 줄 까닭 · 캐시 키 ·
|
||||
최신 응답만 · 품 비교 · 만들기 · 통째 · 만들 층 · 미리 셈 · 장부 길 · 장부 화면 합침 ·
|
||||
하위 호표 먼저 · 확인된 것 = 바로 참조
|
||||
- 고르기 창 덧줄(`helper_m02_hopyo_pick.cjs`): 이 층 · 위 층 호표 줄 · 종류 고르기 ·
|
||||
프로젝트 층엔 없음 · 글 도우미 · 근거 꼬리표 · 엑셀 보기 창 행 → 호표
|
||||
- 소스: 옛 즉시 확정 · 바로 만들기 없음(saveHopyoMemory · [확정] · [같음 모두 확정] ·
|
||||
[근거 맞음 모두 확정] · [사전조합 만들기]) · `buildHopyo(` 는 확인 창 · 엑셀 보기 창 펼침에서만 ·
|
||||
좌측 판 자리 · 700 줄 · 색 = 공용 토큰
|
||||
"""
|
||||
|
||||
import json
|
||||
@@ -17,45 +20,181 @@ from pathlib import Path
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
M02 = Path(HERE).parents[1] / "M02_MasterTemplete"
|
||||
NEW = ("Hopyo_Api", "Hopyo_Pick", "Hopyo_Side", "Hopyo_View", "Hopyo_Sheet")
|
||||
NEW = (
|
||||
"Hopyo_Api",
|
||||
"Hopyo_Pick",
|
||||
"Hopyo_Side",
|
||||
"Hopyo_View",
|
||||
"Hopyo_Sheet",
|
||||
"Hopyo_Check",
|
||||
"Hopyo_Choose",
|
||||
"Hopyo_Ledger",
|
||||
)
|
||||
ROW = {
|
||||
"층": "system",
|
||||
"주인": None,
|
||||
"키": "HP-000001",
|
||||
"파일": "북부청.xls",
|
||||
"번": 3,
|
||||
"이름": "임도표지석",
|
||||
"규격": "910×600×150",
|
||||
"단위": "개소",
|
||||
"맞춤": [2, 4],
|
||||
}
|
||||
SURE = {"로직": "GC000198", "이름": "장비사용타설", "입력값": {"소량감률": 50}, "배": 1, "몫": None}
|
||||
QTY = {
|
||||
"엑셀": {"인력": {"콘크리트공": 0.1091}, "장비": [{"갈래": "굴착기", "시간": 0.29}]},
|
||||
"우리": {"인력": {"콘크리트공": 0.1091}, "장비": [{"갈래": "굴착기", "시간": 0.31}]},
|
||||
"같음": {"콘크리트공": True, "굴착기": False},
|
||||
}
|
||||
|
||||
|
||||
def _text(name: str) -> str:
|
||||
return (M02 / name).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _run() -> dict:
|
||||
row = {
|
||||
"층": "system",
|
||||
"주인": None,
|
||||
"키": "HP-000001",
|
||||
"파일": "북부청.xls",
|
||||
"번": 3,
|
||||
"이름": "임도표지석",
|
||||
"규격": "910×600×150",
|
||||
"단위": "개소",
|
||||
"맞춤": [2, 4],
|
||||
}
|
||||
built = {
|
||||
"참조": {"층": "company", "주인": "7", "키": "SC-000009"},
|
||||
"이름": "임도표지석",
|
||||
"다시씀": False,
|
||||
"빠진줄": [{"차례": 0}],
|
||||
"비교": {},
|
||||
}
|
||||
def _node(helper: str, payload: dict) -> dict:
|
||||
proc = subprocess.run(
|
||||
["node", os.path.join(HERE, "helper_m02_hopyo_pick.cjs")],
|
||||
input=json.dumps({"rows": [row], "built": built}, ensure_ascii=False),
|
||||
["node", os.path.join(HERE, helper)],
|
||||
input=json.dumps(payload, ensure_ascii=False),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
check=True,
|
||||
check=False,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
return json.loads(proc.stdout)
|
||||
|
||||
|
||||
def test_고르기_창_실무_호표_줄과_고름():
|
||||
got = _run()
|
||||
def _line(no, state, check, parts, **extra):
|
||||
match = {"열쇠": f"줄{no}||m3", "상태": state, "체크": check, "대상": parts, **extra}
|
||||
return {"차례": no, "이름": f"줄{no}", "규격": "", "수량": 1, "단위": "㎥", "갈래": "단가산출",
|
||||
"맞춤": match} # fmt: skip
|
||||
|
||||
|
||||
def _check() -> dict:
|
||||
lines = [
|
||||
_line(0, "후보", False, [{"자재": "MT004258", "입력값": {}, "배": 1}]),
|
||||
_line(1, "맞음", True, [SURE], 품=QTY),
|
||||
_line(2, "별칭", True, [{"로직": "GF000101", "입력값": {}, "배": 1}]),
|
||||
_line(3, "다른 점", False, [{"로직": "GF000103", "입력값": {}, "배": 1}]),
|
||||
_line(4, "후보", False, []),
|
||||
_line(5, "%", False, []),
|
||||
_line(6, "호표", True, [{"호표": 46, "입력값": {}, "배": 2.3}]),
|
||||
_line(7, "노임", False, [{"로직": "GF000300", "입력값": {}, "배": None}]),
|
||||
]
|
||||
ledger = [
|
||||
{"열쇠": "가|b|m3", "원래": {"품명": "가", "규격": "b", "단위": "m3"}, "갈래": "짝",
|
||||
"짝": [{"로직": "GC000198", "입력값": {}, "배": 1, "몫": None}], "층": "personal",
|
||||
"주인": "7/42", "고침": True},
|
||||
{"열쇠": "나||m3", "원래": {"품명": "나", "규격": "", "단위": "m3"}, "갈래": "짝",
|
||||
"짝": [{"로직": "GF000101", "입력값": {}, "배": 1, "몫": None}], "층": "personal",
|
||||
"주인": "7/42", "고침": True},
|
||||
{"열쇠": "다||개", "원래": {"품명": "다", "규격": "", "단위": "개"}, "갈래": "짝",
|
||||
"짝": [{"자재": "MT1", "입력값": {}, "배": 1, "몫": None}], "층": "system",
|
||||
"주인": None, "고침": False},
|
||||
] # fmt: skip
|
||||
rows = [
|
||||
{**ROW, "확인": {"층": "company", "주인": "7", "키": "SC-000009"}},
|
||||
{**ROW, "번": 4, "이름": "배수관", "확인": None},
|
||||
]
|
||||
return _node("helper_m02_hopyo_check.cjs", {"lines": lines, "ledger": ledger, "rows": rows})
|
||||
|
||||
|
||||
def test_확인_창_체크한_줄만_몸():
|
||||
got = _check()["check"]
|
||||
assert got["key"] == "m02-hopyo-check:personal::HP-000001:3"
|
||||
assert got["suggested"] == [ # 서버 체크 제안 그대로 · 배 없는 줄 · % 줄 · 후보는 안 감
|
||||
{"차례": 1, "대상": [{"로직": "GC000198", "입력값": {"소량감률": 50}, "배": 1}]},
|
||||
{"차례": 2, "대상": [{"로직": "GF000101", "입력값": {}, "배": 1}]},
|
||||
{"차례": 6, "대상": [{"호표": 46, "배": 2.3}]},
|
||||
]
|
||||
assert got["edited"] == [ # 캐시 — 맞음 줄 체크 끔 · 뺌 · 고른 대상(몫 · 배)
|
||||
{"차례": 2, "대상": [{"로직": "GF000101", "입력값": {}, "배": 1}]},
|
||||
{"차례": 3, "뺌": "우리에 없음"},
|
||||
{
|
||||
"차례": 4,
|
||||
"대상": [{"로직": "GF000211", "입력값": {"유형": "보통"}, "배": 0.1, "몫": 0.1}],
|
||||
},
|
||||
{"차례": 6, "대상": [{"호표": 46, "배": 2.3}]},
|
||||
]
|
||||
assert got["leftOut"] == {"확인 안 함": 3, "뺌": 1, "% 줄": 1}
|
||||
assert got["checkable"] == [True, True, True, True, False, False, True, False]
|
||||
assert got["sure"] == [1, 2] # [맞음 · 별칭 모두 체크]
|
||||
assert got["compare"] == "콘크리트공 0.1091 → 0.1091 ✓ · 굴착기 0.29 → 0.31 ≠"
|
||||
assert got["toTarget"] == [{"호표": 46, "배": 2.3}, {"자재": "MT1", "배": 1}]
|
||||
assert got["newest"] == [False, True, True] # 같은 줄 늦은 응답 버림 · 다른 줄은 따로
|
||||
assert got["dropped"] is False # 만들기 성공 = 캐시 지움
|
||||
|
||||
|
||||
def test_고르기_창_도우미():
|
||||
got = _check()["choose"]
|
||||
assert got["hint"] == ["6-1-1", "고임돌"] # 인용 조항 번호 → 없으면 품명 첫 뜻 낱말
|
||||
assert got["picked"] == [
|
||||
{"로직": "GC000206", "입력값": {}, "배": 1, "몫": None},
|
||||
{"로직": "GF000211", "입력값": {}, "배": 0.1, "몫": 0.1},
|
||||
]
|
||||
assert got["inputs"] == "소량감률=50(블록), 지역=서울 · ⚠ 못 가름: 측압"
|
||||
assert got["word"] == ["만듦", "있던 것을 확인한 내용으로 덮음"]
|
||||
assert got["qty"] == "콘크리트공 0.1091 · 굴착기 0.29hr"
|
||||
assert got["target"] == "제46호표 [㎡]"
|
||||
|
||||
|
||||
def test_별칭_장부_화면_합침():
|
||||
got = _check()["ledger"]
|
||||
assert got["key"] == "m02-hopyo-ledger:company:7"
|
||||
assert got["merged"] == [
|
||||
["나||m3", "personal", True, True, "안 씀"], # 고친 줄
|
||||
["다||개", "personal", True, True, "짝"], # 위 층 → 내 층으로 고쳐 씀
|
||||
["다||개", "system", False, False, "짝"], # 위 층 = 그대로 읽기만
|
||||
] # 지운 줄(가) 없음
|
||||
assert got["filter"] == ["다||개", "다||개"] and got["kind"] == ["나||m3"]
|
||||
assert got["pair"] == "GC000198 × 1 · 몫 0.1 · 소량감률=50"
|
||||
assert got["entry"]["짝"] == [] and "층" not in got["entry"]
|
||||
|
||||
|
||||
def test_길_만들기_통째_만들_층_미리_셈_장부():
|
||||
calls = _check()["calls"]
|
||||
build = "/api/m02/layers/company/hopyo-build?owner=7"
|
||||
head = {"층": "system", "주인": None, "키": "HP-000001", "번": 3}
|
||||
assert calls[0]["url"] == build and calls[0]["body"]["줄"][0]["차례"] == 1
|
||||
assert {k: calls[0]["body"][k] for k in head} == head and "새로" not in calls[0]["body"]
|
||||
assert calls[1]["body"] == {**head, "통째": {"대상": [{"로직": "GF000225", "배": 1}]}}
|
||||
assert calls[2] == {
|
||||
"url": "/api/m02/layers/system/hopyo/HP-000001/3?to=company&to_owner=7",
|
||||
"method": "GET",
|
||||
"body": None,
|
||||
}
|
||||
assert calls[3]["url"] == "/api/m02/layers/personal/hopyo/HP-000001/3/guess"
|
||||
assert calls[3]["body"]["대상"][0]["몫"] == 0.1
|
||||
assert calls[4]["url"] == "/api/m02/layers/company/hopyo-ledger?owner=7"
|
||||
assert calls[5]["method"] == "PUT" and calls[5]["body"] == {"줄": {"가|b|m3": None}}
|
||||
|
||||
|
||||
def test_로직_추가_확인된_것은_바로_참조_아니면_확인_창():
|
||||
got = _check()["pick"]
|
||||
assert got["picked"] == [
|
||||
{
|
||||
"ref": {"층": "company", "주인": "7", "키": "SC-000009"},
|
||||
"name": "임도표지석",
|
||||
"alias": "임도표지석",
|
||||
}
|
||||
]
|
||||
assert got["calls"] == [] # 확인 없이 만들지 않음
|
||||
assert got["opened"] == ["확인 — 북부청.xls 제4호표 배수관"]
|
||||
|
||||
|
||||
def test_엑셀_보기_창_하위_호표_먼저():
|
||||
assert _check()["sheet"]["order"] == [46, 45, 3, 7, 8] # 순환은 남은 차례
|
||||
|
||||
|
||||
def _pick() -> dict:
|
||||
row = {**ROW, "확인": {"층": "company", "주인": "7", "키": "SC-000009"}}
|
||||
return _node("helper_m02_hopyo_pick.cjs", {"rows": [row], "built": {}})
|
||||
|
||||
|
||||
def test_고르기_창_실무_호표_줄():
|
||||
got = _pick()
|
||||
assert got["calls"][0] == {
|
||||
"url": "/api/m02/layers/company/hopyo-picks?owner=7",
|
||||
"method": "GET",
|
||||
@@ -69,7 +208,7 @@ def test_고르기_창_실무_호표_줄과_고름():
|
||||
"tag": "실무 호표",
|
||||
"name": "임도표지석 910×600×150",
|
||||
"sub": "북부청.xls · 제3호표 · 마스터",
|
||||
"unit": "개소 · 맞춤 2/4",
|
||||
"unit": "개소 · 확인 SC-000009",
|
||||
},
|
||||
{
|
||||
"kind": "hopyo",
|
||||
@@ -80,119 +219,16 @@ def test_고르기_창_실무_호표_줄과_고름():
|
||||
"unit": "호표 1",
|
||||
},
|
||||
]
|
||||
build = got["calls"][1]
|
||||
assert build["url"] == "/api/m02/layers/company/hopyo-build?owner=7" # 만드는 층 = 문서 층
|
||||
assert build["body"] == {
|
||||
"층": "system",
|
||||
"주인": None,
|
||||
"키": "HP-000001",
|
||||
"번": 3,
|
||||
"새로": False,
|
||||
}
|
||||
assert got["picked"] == [
|
||||
{
|
||||
"ref": {"층": "company", "주인": "7", "키": "SC-000009"},
|
||||
"name": "임도표지석",
|
||||
"alias": "임도표지석",
|
||||
}
|
||||
]
|
||||
assert got["picked"][0]["ref"]["키"] == "SC-000009"
|
||||
assert got["project"] is True # 프로젝트 층 = 덧줄 없음
|
||||
|
||||
|
||||
def test_본_화면_글_도우미():
|
||||
view = _run()["view"]
|
||||
def test_글_도우미와_근거_꼬리표():
|
||||
got = _pick()
|
||||
view = got["view"]
|
||||
assert view["target"] == ["구조물터파기 [㎥]", "레미콘 25-21-120 [㎥]", "제46호표 [㎡]"]
|
||||
assert view["inputs"] == "횟수=4회사용시, 지역=서울 · ⚠ 못 읽음: 보정작업"
|
||||
assert view["memory"] == [
|
||||
{"로직": "GF000101", "입력값": {"지역": "서울"}, "환산": 1},
|
||||
{"자재": "MT1", "입력값": {}, "환산": 0.5},
|
||||
None, # 환산 0 = 확정 못 함
|
||||
None, # 대상 없음
|
||||
]
|
||||
assert view["word"] == ["만듦", "다시 씀", "새로 만들어 덮음"] # 알림 · 판 글
|
||||
|
||||
|
||||
def test_줄_표_줄바꿈_없음과_고르기_창_머리():
|
||||
css = _text("M02_MasterTemplete_Hopyo.css")
|
||||
rule = css.split(".m02-hopyo__table.m02-hopyo__lines\n :is(th, td):is(")[1].split("}")[0]
|
||||
cols = [int(n) for n in re.findall(r":nth-child\((\d+)\)", rule)]
|
||||
assert cols == [1, 4, 5, 6, 7, 9, 10, 11] and "white-space: nowrap;" in rule
|
||||
heads = ["갈래", "품명", "규격", "수량", "단위", "엑셀 단가", "맞춤", "대상", "입력값", "환산"]
|
||||
view = _text("M02_MasterTemplete_Hopyo_View.ts")
|
||||
flat = re.sub(r"\s+", "", view)
|
||||
assert re.sub(r"\s+", "", ",".join(f'"{h}"' for h in heads)) in flat # 열 차례 그대로
|
||||
top = css.split(".m02-hopyo__choose .ui-modal__top > .ui-btn {")[1].split("}")[0]
|
||||
assert "flex-shrink: 0;" in top and "white-space: nowrap;" in top
|
||||
assert '"가까운 것 없음"' in view and "builtWord(got, fresh)" in view
|
||||
|
||||
|
||||
def test_좌측_판_자리와_권한():
|
||||
side = _text("M02_MasterTemplete_UI_Side.ts")
|
||||
assert 'import { buildHopyoSection } from "./M02_MasterTemplete_Hopyo_Side";' in side
|
||||
host = side.split('className: "m02-side__lists ui-sidebar-panel"')[1]
|
||||
assert host.index("precomboSec,") < host.index("section(hopyo.title, hopyo.body),")
|
||||
assert host.index("section(hopyo.title, hopyo.body),") < host.index("extras ?")
|
||||
assert "canWrite: canEdit(layer, opt.isAdmin)," in side # 구조물 조합 층 쓰기 권한
|
||||
assert "hopyo.paint();" in side.split("function paint(): void {")[1]
|
||||
names = set(re.findall(r"\b\w*[Pp]ractice\w*\b", side))
|
||||
assert names <= {"buildPracticeSection", "practice", "M02_MasterTemplete_Practice"} # 옛 이름만
|
||||
assert "LaborSection" not in side and "labor?." not in side and "picked" not in side
|
||||
hopyo_side = _text("M02_MasterTemplete_Hopyo_Side.ts")
|
||||
assert 'accept: ".xls,.xlsx"' in hopyo_side and "input.hidden = true;" in hopyo_side
|
||||
assert "disabled: !opt.canWrite," in hopyo_side and "pageScope()" in hopyo_side
|
||||
|
||||
|
||||
def test_로직_추가_한_곳과_파일_규칙():
|
||||
detail = _text("M02_MasterTemplete_Labor_Detail.ts")
|
||||
assert len(detail.splitlines()) == 693 # 줄 수 그대로(제자리 고침)
|
||||
assert "withHopyo(library)," in detail
|
||||
assert 'cur.담은로직.push({ 로직: "", 참조: ref, 별칭, 메모: null' in detail
|
||||
pick = _text("M02_MasterTemplete_Labor_Pick.ts")
|
||||
assert "more?: () => Promise<PickExtra[]>;" in pick and "...later" in pick
|
||||
for name in NEW:
|
||||
text = _text(f"M02_MasterTemplete_{name}.ts")
|
||||
assert len(text.splitlines()) <= 700, name
|
||||
css = _text("M02_MasterTemplete_Hopyo.css")
|
||||
assert "var(--color-" in css and not re.search(r"#[0-9a-fA-F]{3,6}\b", css)
|
||||
assert '"./M02_MasterTemplete_Hopyo.css"' in _text("M02_MasterTemplete_Hopyo_View.ts")
|
||||
|
||||
|
||||
def test_엑셀_보기_창_행과_호표():
|
||||
got = _run()
|
||||
assert got["calls"][2] == {
|
||||
"url": "/api/m02/layers/company/hopyo-grid/HP-000001?owner=7",
|
||||
"method": "GET",
|
||||
"body": None,
|
||||
}
|
||||
sheet = got["sheet"]
|
||||
assert sheet["at"] == [None, 3, 3, 3, 4, 4, None] # 22행 → 제3호표 · 범위 밖 = 없음
|
||||
assert sheet["span"] == "B20:O24" # 제3호표 영역 = B열 ~ 끝 열(열 폭 정보 O)
|
||||
assert sheet["last"] == ["AA", "A"]
|
||||
assert sheet["rows"] == ["4345:4345", "93:101"] # 단가산출 행 전체
|
||||
|
||||
|
||||
def test_엑셀_보기_창_여는_자리와_늦게_불러옴():
|
||||
lazy = 'import("./M02_MasterTemplete_Hopyo_Sheet")'
|
||||
side = _text("M02_MasterTemplete_Hopyo_Side.ts")
|
||||
assert lazy in side.split("await show(got);")[1].split("})")[0] # 올린 직후 자동
|
||||
view = _text("M02_MasterTemplete_Hopyo_View.ts")
|
||||
assert lazy in view and '"엑셀로 보기"' in view and '"엑셀에서 보기"' in view
|
||||
assert "호표: no" in view # 호표 화면 = 그 호표 고른 채
|
||||
pick = _text("M02_MasterTemplete_Hopyo_Pick.ts")
|
||||
assert lazy in pick and "lib.pick(got.참조" in pick
|
||||
static = r'^import (?!type )[^(;]*"\./M02_MasterTemplete_Hopyo_Sheet"'
|
||||
for text in (side, view, pick):
|
||||
assert not re.search(static, text, re.M) # 정적 import 없음(타입만)
|
||||
sheet = _text("M02_MasterTemplete_Hopyo_Sheet.ts")
|
||||
assert 'import("../A00_Common/spreadsheet/spreadsheet")' in sheet
|
||||
assert not re.search(
|
||||
r'^import \{[^}]*\} from "\.\./A00_Common/spreadsheet/spreadsheet"', sheet, re.M
|
||||
)
|
||||
assert "readOnly: true" in sheet and "wide: true" in sheet
|
||||
|
||||
|
||||
def test_고르기_창_근거_꼬리표와_근거_맞음():
|
||||
basis = _run()["basis"]
|
||||
assert view["inputs"] == "횟수=4회사용시, 지역=서울 · ⚠ 못 가름: 보정작업"
|
||||
basis = got["basis"]
|
||||
assert basis["ok"] == [True, False, False, False, False] # 조항 같음 · 직종 · 단위 모두
|
||||
assert basis["plain"] == [True, True] # 줄 직종이 보통인부뿐 · 없음 = 직종 안 봄
|
||||
assert basis["tag"] == [
|
||||
@@ -205,8 +241,104 @@ def test_고르기_창_근거_꼬리표와_근거_맞음():
|
||||
"단가산출 7행",
|
||||
"",
|
||||
]
|
||||
# 지금 대상이 근거 안 맞으면 근거 맞는 후보 · 「후보」 줄만 · 근거 맞는 것 없으면 없음
|
||||
assert basis["pick"] == ["GC000083", "GC000083", None, None]
|
||||
|
||||
|
||||
def test_엑셀_보기_창_행과_호표():
|
||||
got = _pick()
|
||||
assert got["calls"][1] == {
|
||||
"url": "/api/m02/layers/company/hopyo-grid/HP-000001?owner=7",
|
||||
"method": "GET",
|
||||
"body": None,
|
||||
}
|
||||
sheet = got["sheet"]
|
||||
assert sheet["at"] == [None, 3, 3, 3, 4, 4, None] # 22행 → 제3호표 · 범위 밖 = 없음
|
||||
assert sheet["span"] == "B20:O24" # 제3호표 영역 = B열 ~ 끝 열(열 폭 정보 O)
|
||||
assert sheet["last"] == ["AA", "A"]
|
||||
assert sheet["rows"] == ["4345:4345", "93:101"] # 단가산출 행 전체
|
||||
|
||||
|
||||
# ── 소스 ──────────────────────────────────────────────────────────────
|
||||
def test_옛_즉시_확정과_바로_만들기_없음():
|
||||
texts = {name: _text(f"M02_MasterTemplete_{name}.ts") for name in NEW}
|
||||
every = "\n".join(texts.values())
|
||||
for gone in ("saveHopyoMemory", "hopyo-memory", "HopyoMemory", "memoryOf", "basisPick"):
|
||||
assert gone not in every, gone
|
||||
for label in (
|
||||
"같음 모두 확정",
|
||||
"근거 맞음 모두 확정",
|
||||
'"확정"',
|
||||
'"사전조합 만들기"',
|
||||
"불러오기",
|
||||
):
|
||||
assert label not in every, label
|
||||
users = sorted(name for name, text in texts.items() if "buildHopyo(" in text)
|
||||
assert users == ["Hopyo_Check", "Hopyo_Sheet"] # 확인 창 · 펼침 뒤 한 번
|
||||
check = texts["Hopyo_Check"]
|
||||
assert '"확인하고 사전조합 만들기"' in check and "맞음 · 별칭 모두 체크" in check
|
||||
assert "showLoadingOverlay()" in check and "dropDraft(k)" in check
|
||||
assert "m02-hopyo-check:" in check and "POLL_MS" in check
|
||||
sheet = texts["Hopyo_Sheet"]
|
||||
assert "확인하고 만들기" in sheet and "이대로 ${ready.length}개 만들기" in sheet
|
||||
assert "확인 창 열기" in sheet and "buildOrder(" in sheet
|
||||
pick = texts["Hopyo_Pick"]
|
||||
assert "if (row.확인)" in pick and 'import("./M02_MasterTemplete_Hopyo_Check")' in pick
|
||||
view = texts["Hopyo_View"]
|
||||
assert "buildHopyoCheck(" in view and "choose(" not in view
|
||||
ledger = texts["Hopyo_Ledger"]
|
||||
assert "m02-hopyo-ledger:" in ledger and "saveLedger(" in ledger
|
||||
for gone in ("발주처", "회사로 올리기", "쓰인 횟수"):
|
||||
assert gone not in ledger.split("*/", 1)[1] # 머리 글 「안 둠」 밖
|
||||
|
||||
|
||||
def test_좌측_판_자리와_권한():
|
||||
side = _text("M02_MasterTemplete_UI_Side.ts")
|
||||
assert 'import { buildHopyoSection } from "./M02_MasterTemplete_Hopyo_Side";' in side
|
||||
host = side.split('className: "m02-side__lists ui-sidebar-panel"')[1]
|
||||
assert host.index("precomboSec,") < host.index("section(hopyo.title, hopyo.body),")
|
||||
assert host.index("section(hopyo.title, hopyo.body),") < host.index("extras ?")
|
||||
assert "canWrite: canEdit(layer, opt.isAdmin)," in side # 구조물 조합 층 쓰기 권한
|
||||
assert "hopyo.paint();" in side.split("function paint(): void {")[1]
|
||||
hopyo_side = _text("M02_MasterTemplete_Hopyo_Side.ts")
|
||||
assert 'accept: ".xls,.xlsx"' in hopyo_side and "input.hidden = true;" in hopyo_side
|
||||
assert "disabled: !opt.canWrite," in hopyo_side and "pageScope()" in hopyo_side
|
||||
assert "buildHopyoLedger({ scope, canWrite: opt.canWrite })" in hopyo_side # [별칭 장부]
|
||||
assert hopyo_side.index("list, ledger]") > 0
|
||||
|
||||
|
||||
def test_로직_추가_한_곳과_파일_규칙():
|
||||
detail = _text("M02_MasterTemplete_Labor_Detail.ts")
|
||||
assert len(detail.splitlines()) == 693 # 줄 수 그대로
|
||||
assert "withHopyo(library)," in detail
|
||||
pick = _text("M02_MasterTemplete_Labor_Pick.ts")
|
||||
assert "more?: () => Promise<PickExtra[]>;" in pick and "...later" in pick
|
||||
assert "if (pre?.query) finder.search.value = pre.query;" in pick # 찾기 글 미리 넣음
|
||||
for name in NEW:
|
||||
text = _text(f"M02_MasterTemplete_{name}.ts")
|
||||
assert len(text.splitlines()) <= 700, name
|
||||
css = _text("M02_MasterTemplete_Hopyo.css")
|
||||
assert "var(--color-" in css and not re.search(r"#[0-9a-fA-F]{3,6}\b", css)
|
||||
for state in ("맞음", "별칭", "다른점", "후보"):
|
||||
assert f".m02-hopyo__state.is-{state}" in css
|
||||
assert '"./M02_MasterTemplete_Hopyo.css"' in _text("M02_MasterTemplete_Hopyo_Check.ts")
|
||||
|
||||
|
||||
def test_엑셀_보기_창_여는_자리와_늦게_불러옴():
|
||||
lazy = 'import("./M02_MasterTemplete_Hopyo_Sheet")'
|
||||
side = _text("M02_MasterTemplete_Hopyo_Side.ts")
|
||||
assert lazy in side.split("await show(got);")[1].split("})")[0] # 올린 직후 자동
|
||||
view = _text("M02_MasterTemplete_Hopyo_View.ts")
|
||||
assert "단산행: basis.행" in view and "근거 맞음 모두 확정" in view
|
||||
assert "x.근거 || x.맞춤.후보?.some((c) => c.근거)" in view # 옛 파일 = 단추 없음
|
||||
assert lazy in view and '"엑셀로 보기"' in view
|
||||
check = _text("M02_MasterTemplete_Hopyo_Check.ts")
|
||||
assert lazy in check and '"엑셀에서 보기"' in check and "호표: opt.no" in check
|
||||
assert "단산행: 행" in check
|
||||
pick = _text("M02_MasterTemplete_Hopyo_Pick.ts")
|
||||
assert lazy in pick
|
||||
static = r'^import (?!type )[^(;]*"\./M02_MasterTemplete_Hopyo_Sheet"'
|
||||
for text in (side, view, pick, check):
|
||||
assert not re.search(static, text, re.M) # 정적 import 없음(타입만)
|
||||
sheet = _text("M02_MasterTemplete_Hopyo_Sheet.ts")
|
||||
assert 'import("../A00_Common/spreadsheet/spreadsheet")' in sheet
|
||||
assert not re.search(
|
||||
r'^import \{[^}]*\} from "\.\./A00_Common/spreadsheet/spreadsheet"', sheet, re.M
|
||||
)
|
||||
assert "readOnly: true" in sheet and "wide: true" in sheet
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
화면 도우미(`helper_m02_hopyo_seam.cjs` → Hopyo_Sheet · Hopyo_View)에 먹임.
|
||||
|
||||
행 22 → 제3호표 · 25 빈 줄 · 26 → 제4호표 · 영역 B20:O24 · 단산93 행 4229:4244 ·
|
||||
엑셀 근거 줄 · 근거 꼬리표 · 근거 맞음 대상 · 북부청 「후보」 줄 1순위 근거 맞음 = 서버 기준 123 ·
|
||||
앞 마디 같음 0(330 덧고침 2).
|
||||
엑셀 근거 줄 · 근거 꼬리표 · 근거 맞음 · 북부청 「후보」 줄 1순위 근거 맞음 = 서버 기준 123
|
||||
(338 — 근거 맞음 모두 확정 단추 없앰 · 도우미는 고르기 창 `Hopyo_Choose`).
|
||||
"""
|
||||
|
||||
import json
|
||||
@@ -42,14 +42,12 @@ def test_실제_응답_화면_도우미(client):
|
||||
res = client.get(f"{URL.format('personal')}/HP-000001/{one['번']}").json()
|
||||
every += [x for x in res["호표"]["줄"] if x["갈래"] == "단가산출"]
|
||||
got = _node({"grid": grid, "lines": lines, "all": every})
|
||||
assert got["count"]["top"] == 123 and got["count"]["half"] == 0
|
||||
assert got["count"]["pick"] >= 123
|
||||
assert got["count"]["top"] == 123
|
||||
assert got["at"] == [None, 3, 3, 3, None, 4] # 25행 = 호표 사이 빈 줄
|
||||
assert got["span"] == "B20:O24" and got["rows"] == "4229:4244"
|
||||
by = {x["이름"]: x for x in got["lines"]}
|
||||
dig = by["구조물터파기(인력100%)"]
|
||||
assert dig["source"] == "출처 건설품셈 3-2-1 · 직종 특별인부 · 단가산출 4229–4244행"
|
||||
assert dig["tag"] == "조항 건설품셈 공통 3-2-1 · 직종 특별인부 · 단위 같음"
|
||||
assert dig["ok"] is True and dig["pick"] == "GC000083"
|
||||
assert by["비탈면 보호공"]["pick"] == "GC000143"
|
||||
assert dig["ok"] is True
|
||||
assert by["흙깍기(토사)"]["ok"] is True # 엑셀 직종 없는 줄(기계) = 조항 같음 + 단위
|
||||
|
||||
Reference in New Issue
Block a user