Merge remote-tracking branch 'origin/dev' into sub_laptop_2
This commit is contained in:
@@ -299,6 +299,8 @@ const PREFS_ENDPOINT = "/api/dashboard/me/ui-prefs";
|
||||
const PREFS_PUSH_DELAY_MS = 1500;
|
||||
let applyingServerPrefs = false;
|
||||
let prefsPushTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
/** 계정 취향 받기 — 받기 전에 올리면 이 브라우저 옛 값이 계정 값을 덮으므로 올리기는 이것 뒤에 */
|
||||
let prefsPull: Promise<void> | null = null;
|
||||
|
||||
function localPrefs(): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
@@ -320,17 +322,26 @@ function pushUiPrefs(): void {
|
||||
if (prefsPushTimer) clearTimeout(prefsPushTimer);
|
||||
prefsPushTimer = setTimeout(() => {
|
||||
prefsPushTimer = null;
|
||||
void fetch(PREFS_ENDPOINT, {
|
||||
method: "PUT",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ prefs: localPrefs() }),
|
||||
}).catch(() => undefined);
|
||||
void (prefsPull ?? Promise.resolve())
|
||||
.then(() =>
|
||||
fetch(PREFS_ENDPOINT, {
|
||||
method: "PUT",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ prefs: localPrefs() }),
|
||||
}),
|
||||
)
|
||||
.catch(() => undefined);
|
||||
}, PREFS_PUSH_DELAY_MS);
|
||||
}
|
||||
|
||||
/** 계정에 저장된 취향을 받아 로컬에 얹는다 — 로그인 뒤 한 번. */
|
||||
export async function syncUiPrefs(): Promise<void> {
|
||||
/** 계정에 저장된 취향을 받아 로컬에 얹는다 — 로그인 뒤 한 번(올리기는 받은 뒤로 미룸). */
|
||||
export function syncUiPrefs(): Promise<void> {
|
||||
prefsPull = pullUiPrefs();
|
||||
return prefsPull;
|
||||
}
|
||||
|
||||
async function pullUiPrefs(): Promise<void> {
|
||||
let stored: Record<string, unknown> = {};
|
||||
try {
|
||||
const response = await fetch(PREFS_ENDPOINT, { credentials: "include" });
|
||||
|
||||
@@ -76,7 +76,7 @@ export interface SpreadsheetHandle {
|
||||
setDoc(book: Workbook): void;
|
||||
/** 전부 다시 풀고 다시 그림 · 결과(저장 모양) */
|
||||
recalc(): CalcValues;
|
||||
/** 알약 키 → 연결 칸 값(빈 칸 0) */
|
||||
/** 알약 키 → 연결 칸 값(빈 칸 = 값 · 식 없는 칸은 빠짐 — 「수량 없음」) */
|
||||
getLinkedValues(): Record<string, CalcValue>;
|
||||
/** 알약 키를 지금 시트 `칸`(A1)에 잇기 · 풀기(null) — 알약 누름과 같은 규칙 · 되돌리기 됨 ·
|
||||
* 주소가 틀리거나 읽기만이면 false */
|
||||
@@ -289,7 +289,9 @@ export function createSpreadsheet(
|
||||
for (const s of ctx.book.시트)
|
||||
for (const l of s.연결 ?? []) {
|
||||
const p = parseA1(l.칸);
|
||||
if (p) out[l.키] = toCalcValue(ctx.engine.value(s.id, p.r, p.c));
|
||||
const cell = s.칸[l.칸];
|
||||
const empty = !cell || (cell.식 === undefined && (cell.값 ?? "") === "");
|
||||
if (p && !empty) out[l.키] = toCalcValue(ctx.engine.value(s.id, p.r, p.c));
|
||||
}
|
||||
return out;
|
||||
},
|
||||
|
||||
@@ -470,7 +470,7 @@ def _lowest(row: dict) -> object:
|
||||
|
||||
def pick(kind: str, q: str, limit: int) -> dict:
|
||||
"""고르기 창 — `price` 자재품목(관급 줄 포함 · ref = 키) · `job` 값 있는 공표 노임 직종
|
||||
(미확보·추정 빼고 모든 구분)(ref = 키) · 이름·규격 낱말이 모두 들어간 줄."""
|
||||
(미확보·추정 빼고 모든 구분)(ref = 키) · 낱말마다 이름·규격에 들어 있거나 키와 같은 줄."""
|
||||
if kind not in _PICK:
|
||||
raise StoreError(404, f"없는 고르기 「{kind}」")
|
||||
words = [_squash(w) for w in q.split()]
|
||||
@@ -481,7 +481,7 @@ def pick(kind: str, q: str, limit: int) -> dict:
|
||||
):
|
||||
continue
|
||||
text = _squash(f"{row.get('이름')} {row.get('규격')} {' '.join(row.get('옛이름') or [])}")
|
||||
if all(w in text for w in words):
|
||||
if all(w in text or w == _squash(row.get("키")) for w in words):
|
||||
hits.append(
|
||||
{
|
||||
"ref": str(row.get("키")),
|
||||
|
||||
@@ -54,7 +54,8 @@ function reason(detail: unknown, status: number): string {
|
||||
return lines.filter(Boolean).join(" · ") || `HTTP ${status}`;
|
||||
}
|
||||
|
||||
/** 지금 화면의 층 — 시스템 = 옛 길 그대로 · 회사 · 개인 = `/layers/{층}` 아래(구조물집계표 · 조합 길 모두) ·
|
||||
/** 지금 화면의 층 — 시스템 · 회사 · 개인 모두 `/layers/{층}` 아래(구조물집계표 · 조합 길 모두 · 읽기는
|
||||
* 로그인 누구나 · 쓰기는 서버 층 권한 · 옛 `/api/m02/templates/*` 는 시스템 관리자 전용이라 안 씀) ·
|
||||
* `owner` = 남의 층을 볼 때 주인(회사 `{C}` · 개인 `{C}/{U}` · 자기 층 = null · PLAN 27-1c) —
|
||||
* 지금 층 길에 `?owner=` 로 붙음(M02 화면을 떠나면 페이지가 시스템 · null 로 되돌림) */
|
||||
let activeLayer: Layer = "system";
|
||||
@@ -73,24 +74,20 @@ const owned = (layer: Layer, path: string): string =>
|
||||
? `${path}${path.includes("?") ? "&" : "?"}owner=${enc(activeOwner)}`
|
||||
: path;
|
||||
const scoped = <T>(path: string, init: RequestInit = {}): Promise<T> =>
|
||||
call<T>(
|
||||
owned(activeLayer, `${activeLayer === "system" ? "" : `/layers/${activeLayer}`}${path}`),
|
||||
init,
|
||||
);
|
||||
call<T>(owned(activeLayer, `/layers/${activeLayer}${path}`), init);
|
||||
|
||||
const q = (projectId: string | null): string => (projectId ? `?project_id=${enc(projectId)}` : "");
|
||||
const head = (layer: Layer): string =>
|
||||
layer === "system" ? "/templates" : `/layers/${layer}/templates`;
|
||||
const head = (layer: Layer): string => `/layers/${layer}/templates`;
|
||||
const item = (layer: Layer, kind: Kind, name: string, projectId: string | null): string =>
|
||||
owned(layer, `${head(layer)}/${kind}/${enc(name)}${layer === "system" ? "" : q(projectId)}`);
|
||||
|
||||
/** 시스템 길은 배열 · 층 길은 `{층, 양식: [...]}` — 둘 다 목록으로 */
|
||||
/** 층 길은 `{층, 양식: [...]}` · 옛 시스템 길은 배열 — 둘 다 목록으로 */
|
||||
export const listTemplates = async (
|
||||
layer: Layer,
|
||||
projectId: string | null,
|
||||
): Promise<TemplateInfo[]> => {
|
||||
const got = await call<TemplateInfo[] | { 양식: TemplateInfo[] }>(
|
||||
layer === "system" ? head(layer) : owned(layer, `${head(layer)}${q(projectId)}`),
|
||||
owned(layer, `${head(layer)}${layer === "system" ? "" : q(projectId)}`),
|
||||
);
|
||||
return Array.isArray(got) ? got : got.양식;
|
||||
};
|
||||
@@ -114,7 +111,7 @@ export const saveTemplate = (
|
||||
|
||||
/** 시스템 층만 지움 */
|
||||
export const deleteTemplate = (kind: Kind, name: string, 판: string): Promise<{ ok: boolean }> =>
|
||||
call(`/templates/${kind}/${enc(name)}?판=${enc(판)}`, { method: "DELETE" });
|
||||
call(`${head("system")}/${kind}/${enc(name)}?판=${enc(판)}`, { method: "DELETE" });
|
||||
|
||||
/** 개인·회사 층에서 지움 — 판이 맞을 때만(다르면 409) */
|
||||
export const deleteLayerTemplate = (
|
||||
@@ -204,7 +201,8 @@ export interface OptionsDoc {
|
||||
종류: OptionKind[];
|
||||
}
|
||||
|
||||
export const fetchStructureOptions = (): Promise<OptionsDoc> => call("/structure-options");
|
||||
export const fetchStructureOptions = (): Promise<OptionsDoc> =>
|
||||
call("/layers/system/structure-options"); // 읽기 = 로그인 누구나 · 고쳐 씀은 옛 길(시스템 관리자)
|
||||
|
||||
/** 고쳐 씀 — 숨긴 것만 보냄 · 낡은 판이면 StaleError · 돌려받는 것은 새 문서 */
|
||||
export const saveStructureOptions = (판: string, 표시: OptionsDoc["표시"]): Promise<OptionsDoc> =>
|
||||
|
||||
@@ -15,6 +15,7 @@ const TEXT = {
|
||||
close: "닫기",
|
||||
now: "지금 연결",
|
||||
none: "연결 없음",
|
||||
empty: "빈 칸",
|
||||
picked: "지금 고른 칸으로 ({value})",
|
||||
address: "칸 주소",
|
||||
hint: "예 B6",
|
||||
@@ -51,6 +52,7 @@ function linkedText(opts: AreaLinkOptions): string | null {
|
||||
.find((l) => l.키 === opts.key);
|
||||
if (!link) return null;
|
||||
const value = opts.sheet.getLinkedValues()[opts.key];
|
||||
if (!value) return `${link.칸} = ${TEXT.empty}`;
|
||||
return [link.칸, "=", cellText(value), opts.info.단위 ?? ""].join(" ").trim();
|
||||
}
|
||||
|
||||
|
||||
@@ -47,13 +47,27 @@ export interface AreaPanel {
|
||||
destroy: () => void;
|
||||
}
|
||||
|
||||
/** 시트 알약 값 → 수만(`{키: 수}`) — 수 아닌 칸은 빠짐(「수량 없음」) */
|
||||
/** 칸 값 → 수(쉼표 뗌) — 빈 글 · 글 · 참거짓은 null(서버 `M02_Labor_Doc._number` 와 같음) */
|
||||
function toNumber(raw: unknown): number | null {
|
||||
if (raw === null || raw === undefined || typeof raw === "boolean") return null;
|
||||
const text = String(raw).replace(/,/g, "").trim();
|
||||
const n = Number(text);
|
||||
return text !== "" && Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
/** 시트 알약 값 → `{키: 수}` — 서버 `M02_Labor_Doc.area_values` 와 같은 규칙(짝): 식 칸 = 계산 값의 수 ·
|
||||
* 그 밖 = 칸 값 · 빈 칸 · 글은 빠짐(「수량 없음」 · 반영 「—」) */
|
||||
function quantities(sheet: SpreadsheetHandle | null): Record<string, number> {
|
||||
const out: Record<string, number> = {};
|
||||
for (const [key, v] of Object.entries(sheet?.getLinkedValues() ?? {})) {
|
||||
const n = "수" in v ? Number(v.수) : NaN;
|
||||
if (Number.isFinite(n)) out[key] = n;
|
||||
}
|
||||
if (!sheet) return out;
|
||||
const values = sheet.getLinkedValues();
|
||||
for (const s of sheet.getDoc().시트)
|
||||
for (const l of s.연결 ?? []) {
|
||||
const cell = s.칸[l.칸];
|
||||
const v = values[l.키];
|
||||
const n = toNumber(cell?.식 !== undefined ? (v && "수" in v ? v.수 : null) : cell?.값);
|
||||
if (n !== null) out[l.키] = n;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
|
||||
@@ -478,6 +478,7 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle {
|
||||
return;
|
||||
}
|
||||
root.hidden = false;
|
||||
paintPills(readStateRaw("m02-card-pills") === "true"); // 계정 취향을 늦게 받았어도 열 때마다 따름
|
||||
if (col.id !== column) started.clear();
|
||||
column = col.id;
|
||||
label = col.label;
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* 찾기 = 보이는 칸 글 전부에서 띄어쓰기 AND(나무 찾기와 같은 `matchText`).
|
||||
* ========================================================================== */
|
||||
|
||||
import { createInputField, el } from "@ui/ui_template_elements";
|
||||
import { createInputField, el, showToast } from "@ui/ui_template_elements";
|
||||
import { buildPager } from "../M01_MasterData/M01_MasterData_UI_Cells";
|
||||
import { buildHeadFilters } from "../M01_MasterData/M01_MasterData_UI_Head";
|
||||
import "../M01_MasterData/M01_MasterData_UI_Style.css";
|
||||
@@ -22,12 +22,21 @@ export interface ItemRow extends TreeItem {
|
||||
도번?: string | null;
|
||||
면적번?: string | null;
|
||||
수정일?: Partial<Record<"structure" | "area" | "labor", string>>;
|
||||
labor?: { 번호: string | null; 로직수: number; 합계: number | null } | null;
|
||||
labor?: {
|
||||
번호: string | null;
|
||||
로직수: number;
|
||||
/** 수량 반영 합 · 기준(단가) 합 · 일위대가 문서 단위 */
|
||||
합계: number | null;
|
||||
기준?: number | null;
|
||||
단위?: string | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface Col<R> {
|
||||
head: string;
|
||||
get: (row: R) => string;
|
||||
/** 값 뒤 작은 글(예 「기준」) — 없으면 없음 */
|
||||
note?: (row: R) => string;
|
||||
}
|
||||
|
||||
const TEXT = {
|
||||
@@ -51,6 +60,17 @@ const latest = (row: ItemRow): string =>
|
||||
.pop(),
|
||||
);
|
||||
|
||||
/** 일위대가 합계(39-8 B2) — 수량 반영 합이 있으면 그것 · 없으면 기준(단가) 합 + 작은 글 「기준」 ·
|
||||
* 담은 로직 0 · 계산값 없음 = 「—」 */
|
||||
export function laborSum(row: ItemRow): { text: string; note: string } {
|
||||
const l = row.labor;
|
||||
if (!l?.로직수) return { text: DASH, note: "" };
|
||||
if (l.합계) return { text: money(l.합계), note: "" };
|
||||
return typeof l.기준 === "number"
|
||||
? { text: money(l.기준), note: "기준" }
|
||||
: { text: DASH, note: "" };
|
||||
}
|
||||
|
||||
const NAME: Col<ItemRow> = { head: "이름", get: (r) => leafName(r) };
|
||||
const FOLDER: Col<ItemRow> = { head: "폴더", get: (r) => folderOf(r).join(" / ") };
|
||||
const UNIT: Col<ItemRow> = { head: "단위", get: (r) => r.단위 ?? "" };
|
||||
@@ -73,9 +93,9 @@ export const ITEM_COLS: Record<ListKind, Col<ItemRow>[]> = {
|
||||
{ head: "일위대가번", get: (r) => r.labor?.번호 || DASH },
|
||||
NAME,
|
||||
FOLDER,
|
||||
UNIT,
|
||||
{ head: "단위", get: (r) => r.labor?.단위 ?? "" }, // 일위대가 문서 단위(39-8 B3)
|
||||
{ head: "담은 로직 수", get: (r) => (r.labor ? String(r.labor.로직수) : DASH) },
|
||||
{ head: "합계", get: (r) => money(r.labor?.합계) },
|
||||
{ head: "합계", get: (r) => laborSum(r).text, note: (r) => laborSum(r).note },
|
||||
changed("labor"),
|
||||
],
|
||||
item: [
|
||||
@@ -89,6 +109,16 @@ export const ITEM_COLS: Record<ListKind, Col<ItemRow>[]> = {
|
||||
],
|
||||
};
|
||||
|
||||
const PART_NAME = { structure: "도면", area: "면적산출", labor: "일위대가" } as const;
|
||||
|
||||
/** 그 부분 문서가 없는 줄의 까닭(항목 하나 복사 · 39-8 H3) — 옮기기 전 층의 일위대가처럼 파일이 없으면
|
||||
* 가져올 것이 없음 · 항목 통째(복사 생성)는 없는 문서를 빈 문서로 세워 막지 않음 · 있으면 null */
|
||||
export function missingPart(kind: ListKind, row: ItemRow): string | null {
|
||||
if (kind === "item") return null;
|
||||
const has = kind === "labor" ? !!row.labor : !!row.수정일?.[kind];
|
||||
return has ? null : `이 항목에는 ${PART_NAME[kind]} 문서가 없음`;
|
||||
}
|
||||
|
||||
/** 찾기 · 쪽 — 보일 줄 · 맞춘 쪽 · 맞는 줄 수(화면 밖 · 시험이 부름) */
|
||||
export function pageOf<R>(
|
||||
rows: R[],
|
||||
@@ -131,7 +161,12 @@ export function buildAllItems(
|
||||
export interface ItemListHandle {
|
||||
root: HTMLElement;
|
||||
/** 줄 · 칸 · 누를 일을 바꿔 그림(찾는 글은 그대로 · 쪽은 1) */
|
||||
show: <R>(rows: R[], cols: Col<R>[], onPick: (row: R) => void) => void;
|
||||
show: <R>(
|
||||
rows: R[],
|
||||
cols: Col<R>[],
|
||||
onPick: (row: R) => void,
|
||||
blocked?: (row: R) => string | null,
|
||||
) => void;
|
||||
/** 표 자리에 글 한 줄(불러오는 중 · 못 받음) */
|
||||
message: (text?: string) => void;
|
||||
}
|
||||
@@ -147,7 +182,12 @@ export function buildItemList(): ItemListHandle {
|
||||
grid.replaceChildren(el("p", { className: "m01-master__empty", text }));
|
||||
};
|
||||
|
||||
const show = <R>(rows: R[], cols: Col<R>[], onPick: (row: R) => void): void => {
|
||||
const show = <R>(
|
||||
rows: R[],
|
||||
cols: Col<R>[],
|
||||
onPick: (row: R) => void,
|
||||
blocked?: (row: R) => string | null,
|
||||
): void => {
|
||||
page = 1;
|
||||
draw = (): void => {
|
||||
const got = pageOf(rows, cols, search.value, page);
|
||||
@@ -164,10 +204,23 @@ export function buildItemList(): ItemListHandle {
|
||||
className: "m01-master__table-row",
|
||||
children: cols.map((c) => {
|
||||
const text = c.get(row);
|
||||
return el("td", { text, attrs: { title: text } });
|
||||
const note = c.note?.(row) ?? "";
|
||||
return el("td", {
|
||||
text,
|
||||
attrs: { title: note ? `${text} ${note}` : text },
|
||||
children: note
|
||||
? [el("small", { className: "m01-master__muted", text: ` ${note}` })]
|
||||
: [],
|
||||
});
|
||||
}),
|
||||
});
|
||||
tr.addEventListener("click", () => onPick(row));
|
||||
// 고를 수 없는 줄 — 흐리게 · 누르면 까닭 한 줄(고르기 안 함)
|
||||
const why = blocked?.(row) ?? null;
|
||||
if (why) {
|
||||
tr.classList.add("is-blocked");
|
||||
tr.title = why;
|
||||
}
|
||||
tr.addEventListener("click", () => (why ? showToast(why, "info") : onPick(row)));
|
||||
return tr;
|
||||
});
|
||||
grid.replaceChildren(
|
||||
|
||||
@@ -33,3 +33,9 @@
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
/* 그 부분 문서가 없는 줄(39-8 H3) — 흐리게 · 누르면 까닭만 */
|
||||
.m02-itemlist .m01-master__table-row.is-blocked {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
buildItemList,
|
||||
ITEM_COLS,
|
||||
loadItems,
|
||||
missingPart,
|
||||
type Col,
|
||||
type ItemRow,
|
||||
type ListKind,
|
||||
@@ -141,6 +142,7 @@ export function buildPicker(opt: PickerOptions): { root: HTMLElement } {
|
||||
rows.filter((row) => !opt.skip?.(from, row)),
|
||||
ITEM_COLS[opt.kind],
|
||||
(row) => opt.onPick({ from, row }),
|
||||
(row) => missingPart(opt.kind, row), // 그 부분 문서 없는 줄 = 흐림 · 까닭(39-8 H3)
|
||||
);
|
||||
} catch (error) {
|
||||
if (mine === seq) list.message(TEXT.failed.replace("{value}", why(error)));
|
||||
@@ -183,10 +185,21 @@ export function buildTwoWay(opt: TwoWayOptions): HTMLElement {
|
||||
const copy = createButton({ label: TEXT.makeCopy, variant: "ghost" });
|
||||
const below = el("div", { className: "m02-itempick__below" });
|
||||
let busy = false;
|
||||
fresh.addEventListener("click", opt.onNew);
|
||||
/** 누른 단추 = 지금 화면 — 진한 단추가 고른 쪽 · 복사 생성 목록은 그쪽일 때만 보임 */
|
||||
const choose = (which: HTMLElement): void => {
|
||||
for (const b of [fresh, copy]) {
|
||||
b.classList.toggle("ui-btn--filled", b === which);
|
||||
b.classList.toggle("ui-btn--ghost", b !== which);
|
||||
}
|
||||
below.hidden = which !== copy;
|
||||
};
|
||||
fresh.addEventListener("click", () => {
|
||||
choose(fresh);
|
||||
opt.onNew();
|
||||
});
|
||||
copy.addEventListener("click", () => {
|
||||
choose(copy);
|
||||
if (below.firstChild) return;
|
||||
copy.classList.add("is-active");
|
||||
const picker = buildPicker({
|
||||
...opt.picker,
|
||||
onPick: (picked) =>
|
||||
|
||||
@@ -86,9 +86,19 @@ def items(folder: Path) -> list[dict[str, Any]]:
|
||||
return out
|
||||
|
||||
|
||||
def _number(sums: Any) -> float | None:
|
||||
"""`계산값` 합 칸의 `계` — 수 아닌 값(옛 꼴) · 없음 = null(「—」)."""
|
||||
total = sums.get("계") if isinstance(sums, dict) else None
|
||||
try:
|
||||
return None if total is None or isinstance(total, bool) else float(total)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _list_extras(folder: Path, col: str) -> dict[str, Any]:
|
||||
"""목록 칸(39-5) — `수정일` = 도면 · 면적산출 · 일위대가 파일 시각 · `labor` = 일위대가 문서의
|
||||
번호 · 담은 로직 수 · 합계(`계산값.반영합.계` · 없으면 null) — 파일이 없으면 칸 없음."""
|
||||
번호 · 담은 로직 수 · 합계(`계산값.반영합.계`) · 기준(`계산값.기준합.계`) · 단위 —
|
||||
파일이 없으면 칸 없음."""
|
||||
stamps: dict[str, str] = {}
|
||||
for kind in ("structure", "area", "labor"):
|
||||
path = folder / kind / f"{col}.json"
|
||||
@@ -103,15 +113,13 @@ def _list_extras(folder: Path, col: str) -> dict[str, Any]:
|
||||
doc = json.loads(path.read_bytes())
|
||||
except ValueError:
|
||||
doc = {}
|
||||
total = ((doc.get("계산값") or {}).get("반영합") or {}).get("계")
|
||||
try:
|
||||
total = None if total is None or isinstance(total, bool) else float(total)
|
||||
except (TypeError, ValueError):
|
||||
total = None # 수 아닌 값(옛 꼴)은 「—」
|
||||
calc = doc.get("계산값") or {}
|
||||
labor = {
|
||||
"번호": doc.get("일위대가번"),
|
||||
"로직수": len(doc.get("담은로직") or []),
|
||||
"합계": total,
|
||||
"합계": _number(calc.get("반영합")),
|
||||
"기준": _number(calc.get("기준합")), # 수량 없는 일위대가 = 단가 합(39-8 B2)
|
||||
"단위": doc.get("단위"), # 일위대가 문서 단위(구조물 열 단위 아님 · B3)
|
||||
}
|
||||
return {"수정일": stamps, "labor": labor}
|
||||
|
||||
|
||||
@@ -33,13 +33,18 @@ const HEAD: Record<(typeof COSTS)[number], Parameters<typeof tc>[0]> = {
|
||||
const num = (v: number): string =>
|
||||
Number(v.toPrecision(12)).toLocaleString("ko-KR", { maximumFractionDigits: 6 });
|
||||
|
||||
/** 수량 글 — 「수량 손 3 개소」 · 「수량 B6 = 12.5 ㎡」(칸 = 그 알약을 이은 면적산출 칸) · 없으면 빈 글 */
|
||||
/** 수량 글 — 「수량 손 3 개소」 · 「수량 B6 = 12.5 ㎡」(칸 = 그 알약을 이은 면적산출 칸) ·
|
||||
* 이은 칸이 비었으면 「수량 A1 = 빈 칸」(수량 없음 · 서버와 같음) · 아니면 빈 글 */
|
||||
export function qtyText(
|
||||
line: { 수량?: number | null; 수량출처?: string; 알약?: string | null } | undefined,
|
||||
unit: string,
|
||||
cells: ReadonlyMap<string, string> = new Map(),
|
||||
): string {
|
||||
if (!line || line.수량 === null || line.수량 === undefined) return "";
|
||||
if (!line) return "";
|
||||
if (line.수량 === null || line.수량 === undefined) {
|
||||
const at = line.알약 ? cells.get(line.알약) : undefined;
|
||||
return at ? `${tc("Labor_Qty")} ${at} = ${tc("In_QtyEmpty")}` : "";
|
||||
}
|
||||
const v = `${num(line.수량)}${unit ? ` ${unit}` : ""}`;
|
||||
if (line.수량출처 === "손") return `${tc("Labor_Qty")} ${tc("In_QtyHand")} ${v}`;
|
||||
const cell = (line.알약 && cells.get(line.알약)) || tc("In_QtyCell");
|
||||
|
||||
@@ -75,6 +75,7 @@ const TEXT = {
|
||||
In_Memo: ["메모", "Memo"],
|
||||
In_Qty: ["수량", "Quantity"],
|
||||
In_QtyCell: ["면적산출 칸", "Area sheet cell"],
|
||||
In_QtyEmpty: ["빈 칸", "empty cell"],
|
||||
In_QtyHand: ["손 입력", "Manual"],
|
||||
In_QtyLinked: ["이은 칸 {value}", "Linked cell {value}"],
|
||||
In_QtyNoLink: ["이은 칸 없음 — 면적산출에서 알약을 칸에 이음", "No linked cell"],
|
||||
|
||||
@@ -288,6 +288,14 @@ def get_numbers(layer: Layer, owner: str | None = None, session: Session = Depen
|
||||
return _call(_root(session, layer, owner), store.structure_numbers)
|
||||
|
||||
|
||||
@router.get("/layers/system/structure-options")
|
||||
def get_structure_options(session: Session = Depends(verify_session)) -> dict:
|
||||
"""구조물 옵션 보이기 읽기 — 로그인 누구나(마스터 한 벌 · 고쳐 씀은 옛 길 시스템 관리자만)."""
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Options as options
|
||||
|
||||
return _call(_root(session, "system"), options.listing)
|
||||
|
||||
|
||||
@router.get("/layers/{layer}/structure-combos")
|
||||
def get_combos(layer: Layer, owner: str | None = None, session: Session = Depends(verify_session)):
|
||||
return _call(_root(session, layer, owner), store.read_combos)
|
||||
|
||||
@@ -276,6 +276,8 @@ async def list_layer(
|
||||
manifest = await asyncio.to_thread(layers.read_manifest, folder)
|
||||
for row in rows:
|
||||
row["출처"] = manifest.get(f"{row['종류']}/{row['이름']}")
|
||||
if (row["종류"], row["이름"]) in store.IN_USE: # 화면이 지우기 확인 창 전에 막음
|
||||
row["쓰는곳"] = store.IN_USE[(row["종류"], row["이름"])]
|
||||
return {"층": layer, "양식": rows}
|
||||
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
* 아직 만든 것이 없는 남의 층(404)은 맨 위만 — 첫 쓰기 때 서버가 소속을 보고 뼈대를 세움.
|
||||
* ========================================================================== */
|
||||
|
||||
import { t as L } from "@ui/ui_template_locale";
|
||||
import type { Layer, TreeView } from "./M02_MasterTemplete_Api_Fetch";
|
||||
import { askFolder } from "./M02_MasterTemplete_TreePanel_Dialogs";
|
||||
import { buildFolders, listFolders } from "./M02_MasterTemplete_TreePanel_Model";
|
||||
@@ -22,5 +23,6 @@ export async function askCopyFolder(
|
||||
): Promise<string[] | null> {
|
||||
const view = await fetchTree(to.layer, to.owner).catch(() => null);
|
||||
const folders = view ? listFolders(buildFolders(view)) : [];
|
||||
return askFolder(to.label.replace("{n}", String(n)), folders, () => false, []);
|
||||
const title = to.label.replace("{n}", String(n));
|
||||
return askFolder(title, folders, () => false, [], L("M02_TreeCopyFolder"));
|
||||
}
|
||||
|
||||
@@ -142,12 +142,14 @@ export function askNewItem(opt: {
|
||||
});
|
||||
}
|
||||
|
||||
/** 옮길 폴더 고르기 — `folders` 에서 `blocked` 인 것은 뺌 · 맨 위(폴더 밖)는 늘 있음 */
|
||||
/** 옮길 폴더 고르기 — `folders` 에서 `blocked` 인 것은 뺌 · 맨 위(폴더 밖)는 늘 있음 ·
|
||||
* `fieldLabel` = 칸 글(복사는 「복사할 폴더」) */
|
||||
export function askFolder(
|
||||
title: string,
|
||||
folders: string[][],
|
||||
blocked: (path: string[]) => boolean,
|
||||
current: string[],
|
||||
fieldLabel: string = L("M02_TreeMoveTo"),
|
||||
): Promise<string[] | null> {
|
||||
return new Promise((resolve) => {
|
||||
let done = false;
|
||||
@@ -164,7 +166,7 @@ export function askFolder(
|
||||
beforeClose: () => (finish(null), true),
|
||||
mount: (body, close) => {
|
||||
const field = createSelectField({
|
||||
label: L("M02_TreeMoveTo"),
|
||||
label: fieldLabel,
|
||||
options: choices.map((path, i) => ({
|
||||
value: String(i),
|
||||
text: path.length ? path.join(" / ") : L("M02_TreeTop"),
|
||||
|
||||
@@ -329,6 +329,7 @@ export function createMain(
|
||||
onChange: () => (dirty = !readOnly),
|
||||
...(struct
|
||||
? {
|
||||
laborLabel: (colId: string) => struct.numbers()[colId]?.일위대가번 ?? "",
|
||||
drawingLabel: (colId: string) => struct.numbers()[colId]?.도번 ?? "",
|
||||
areaLabel: (colId: string) => struct.numbers()[colId]?.면적번 ?? "",
|
||||
combo: {
|
||||
|
||||
@@ -97,7 +97,10 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise<Mounte
|
||||
canEdit: canEdit(layer, isAdmin),
|
||||
onPick: (item) => void main.openTable(layer, item.열),
|
||||
settle: () => main.settle(),
|
||||
onChanged: (colId) => main.openTable(layer, colId, true),
|
||||
// 나무가 바뀜(새 항목 · 복사 생성 · 붙여넣기 · 지우기 · 이름 · 옮기기) — 집계표 + 좌측 하위 열 줄 · 번호
|
||||
onChanged: async (colId) => {
|
||||
await Promise.all([main.openTable(layer, colId, true), side.refresh()]);
|
||||
},
|
||||
});
|
||||
const go = async (next: Root, nextOwner: string | null, open: OpenTarget): Promise<void> => {
|
||||
// 같은 주소 안 이동은 머리 가드가 안 물음 — 저장 안 한 고침은 여기서
|
||||
|
||||
@@ -33,7 +33,7 @@ const row = (열, 경로, extra = {}) => ({
|
||||
});
|
||||
const rows = [
|
||||
row("c1", ["기슭막이", "메쌓기", "1.2m"], {
|
||||
labor: { 번호: "SU-000001", 로직수: 3, 합계: 1234567.4 },
|
||||
labor: { 번호: "SU-000001", 로직수: 3, 합계: 1234567.4, 기준: 99, 단위: "개소" },
|
||||
수정일: { structure: "2026-10-01T09:00:00", labor: "2026-10-04T11:00:00" },
|
||||
}),
|
||||
row("c2", ["기슭막이", "찰쌓기"], { labor: { 번호: "SU-000002", 로직수: 0, 합계: null } }),
|
||||
@@ -61,5 +61,16 @@ process.stdout.write(
|
||||
),
|
||||
pageOver: m.pageOf(many, m.ITEM_COLS.structure, "", 9).page,
|
||||
pageEmpty: m.pageOf([], m.ITEM_COLS.structure, "", 2).page,
|
||||
// 그 부분 문서 없는 줄(39-8 H3) — c1 = 일위대가 있음 · 면적 수정일 없음 · c3 = 수정일 · 일위대가 없음
|
||||
sums: [
|
||||
rows[0], // 반영 합 있음
|
||||
{ labor: { 로직수: 14, 합계: 0, 기준: 167746.6 } }, // 수량 없음 = 기준
|
||||
rows[1], // 담은 로직 0
|
||||
{ labor: { 로직수: 2, 합계: null, 기준: null } }, // 계산값 없음
|
||||
rows[2], // 일위대가 없음
|
||||
].map((r) => m.laborSum(r)),
|
||||
missing: ["structure", "area", "labor", "item"].map((k) =>
|
||||
rows.map((r) => m.missingPart(k, r)),
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -341,6 +341,13 @@ def test_고르기_자재품목_직종(client: TestClient) -> None:
|
||||
market = _get(client, "/api/m01/pick", kind="price", q="H형강 관급", limit=50)
|
||||
# 조달청 값이 아직 안 이어진 줄도 섞여 있음 — 이어진 줄이 하나라도 있으면 됨
|
||||
assert any(i["관급"] and i["값"] > 0 for i in market["items"])
|
||||
# 키로도 찾음(찾기 칸 글 「이름 · 규격 · 코드」) · 이름 낱말 AND 는 그대로
|
||||
cement = _get(client, "/api/m01/pick", kind="price", q="시멘트 전국")["items"]
|
||||
code = cement[0]["ref"]
|
||||
by_code = _get(client, "/api/m01/pick", kind="price", q=code.lower())["items"]
|
||||
assert [i["ref"] for i in by_code] == [code]
|
||||
assert _get(client, "/api/m01/pick", kind="price", q=f"시멘트 {code}")["total"] == 1
|
||||
assert _get(client, "/api/m01/pick", kind="price", q=f"없는낱말 {code}")["total"] == 0
|
||||
job = _get(client, "/api/m01/pick", kind="job", q="보통인부")
|
||||
assert job["items"][0]["값"] > 0 and job["items"][0]["ref"] == key_of("LB:1002")
|
||||
assert _get(client, "/api/m01/pick", kind="job", q="CAD설계사")["items"][0]["ref"] == key_of(
|
||||
|
||||
@@ -47,3 +47,44 @@ def test_칸_지정_모달은_setLink_만():
|
||||
assert "address.setError(TEXT.bad)" in link # 틀린 · 빈 주소는 모달 그대로
|
||||
assert "pick.disabled = link.disabled = address.input.disabled = opts.readOnly;" in link
|
||||
assert "saveTemplate" not in link # 저장은 [저장]
|
||||
|
||||
|
||||
def test_빈_칸은_수량_없음_서버와_같음():
|
||||
"""39-8 ② — 빈 칸에 이은 알약 = 수량 없음(반영 「—」 · 「수량 A1 = 빈 칸」) · 0 칸 = 0 · 값 칸 = 값."""
|
||||
from M02_MasterTemplete import M02_Labor_Doc as labor
|
||||
|
||||
doc = {
|
||||
"시트": [
|
||||
{
|
||||
"id": "s1",
|
||||
"칸": {"A2": {"값": 0}, "A3": {"값": "1,250"}, "A4": {"값": "글"}},
|
||||
"연결": [
|
||||
{"키": k, "칸": c}
|
||||
for k, c in (("빈", "A1"), ("영", "A2"), ("값", "A3"), ("글", "A4"))
|
||||
],
|
||||
}
|
||||
]
|
||||
}
|
||||
assert {k: float(v) for k, v in labor.area_values(doc).items()} == {"영": 0, "값": 1250}
|
||||
panel = _text("M02_MasterTemplete_AreaPanel.ts")
|
||||
body = panel.split("function quantities")[1].split("\n}\n")[0]
|
||||
assert 'toNumber(cell?.식 !== undefined ? (v && "수" in v ? v.수 : null) : cell?.값)' in body
|
||||
assert 'String(raw).replace(/,/g, "").trim()' in panel and 'typeof raw === "boolean"' in panel
|
||||
sheet = (ROOT / "A00_Common" / "spreadsheet" / "spreadsheet.ts").read_text(encoding="utf-8")
|
||||
assert 'const empty = !cell || (cell.식 === undefined && (cell.값 ?? "") === "");' in sheet
|
||||
rows = _text("M02_MasterTemplete_Labor_Rows.ts")
|
||||
assert '${tc("Labor_Qty")} ${at} = ${tc("In_QtyEmpty")}' in rows
|
||||
assert "if (!value) return `${link.칸} = ${TEXT.empty}`;" in _text(
|
||||
"M02_MasterTemplete_AreaLink.ts"
|
||||
)
|
||||
|
||||
|
||||
def test_카드_알약_취향은_계정_받은_뒤_올림():
|
||||
"""39-8 ③ — 화면이 뜰 때 브라우저 옛 값이 계정 값을 덮지 않게 · 카드는 열 때마다 취향을 다시 읽음."""
|
||||
state = (ROOT / "A00_Common" / "b_page_state.ts").read_text(encoding="utf-8")
|
||||
push = state.split("function pushUiPrefs")[1].split("export function syncUiPrefs")[0]
|
||||
assert "void (prefsPull ?? Promise.resolve())" in push and "prefs: localPrefs()" in push
|
||||
assert "prefsPull = pullUiPrefs();" in state
|
||||
cards = _text("M02_MasterTemplete_ItemCards.ts")
|
||||
load = cards.split("async function load(")[1]
|
||||
assert 'paintPills(readStateRaw("m02-card-pills") === "true");' in load
|
||||
|
||||
@@ -54,11 +54,23 @@ def test_칸_값_빈_번호_합계_날짜(ts):
|
||||
s, labor = ts["cells"]["structure"], ts["cells"]["labor"]
|
||||
assert s[0] == ["SD-c1", "1.2m", "기슭막이 / 메쌓기", "m", "2026-10-01"]
|
||||
assert s[2][0] == "—" and s[2][2] == "" # 빈 도번 · 맨 위 항목(폴더 없음)
|
||||
assert labor[0][3:] == ["m", "3", "1,234,567", "2026-10-04"] # 합계 원 단위 반올림
|
||||
assert labor[0][3:] == ["개소", "3", "1,234,567", "2026-10-04"] # 일위대가 단위 · 원 단위
|
||||
assert labor[1][5] == "—" and labor[2][0] == labor[2][4] == "—" # 합계 없음 · 일위대가 없음
|
||||
assert ts["cells"]["item"][0][-1] == "2026-10-04" # 항목 통째 = 가장 늦게 고친 날
|
||||
|
||||
|
||||
def test_일위대가_합계_반영_기준_빈(ts):
|
||||
"""39-8 B2 — 반영 합이 있으면 그것 · 없으면 기준 합 + 「기준」 ·
|
||||
담은 로직 0 · 계산값 없음 = 「—」."""
|
||||
assert ts["sums"] == [
|
||||
{"text": "1,234,567", "note": ""},
|
||||
{"text": "167,747", "note": "기준"},
|
||||
{"text": "—", "note": ""},
|
||||
{"text": "—", "note": ""},
|
||||
{"text": "—", "note": ""},
|
||||
]
|
||||
|
||||
|
||||
def test_찾기_쪽(ts):
|
||||
assert ts["find"] == ["c2"] # 낱말 둘 AND(폴더 · 이름 칸)
|
||||
assert ts["findLabor"] == ["c1"] # 번호 칸도 찾음
|
||||
@@ -67,6 +79,15 @@ def test_찾기_쪽(ts):
|
||||
assert ts["pageOver"] == 3 and ts["pageEmpty"] == 1
|
||||
|
||||
|
||||
def test_부분_문서_없는_줄_까닭(ts):
|
||||
"""39-8 H3 — 그 부분 문서가 없으면 고르기 막음(흐림 · 까닭) · 항목 통째는 막지 않음."""
|
||||
structure, area, labor, item = ts["missing"]
|
||||
assert structure == [None, None, "이 항목에는 도면 문서가 없음"]
|
||||
assert area == ["이 항목에는 면적산출 문서가 없음", None, "이 항목에는 면적산출 문서가 없음"]
|
||||
assert labor == [None, None, "이 항목에는 일위대가 문서가 없음"]
|
||||
assert item == [None, None, None]
|
||||
|
||||
|
||||
def test_서버_목록_칸_수정일_labor(world):
|
||||
t = Tree(_as(world), "personal")
|
||||
t.do(op="add-item", 경로=["배수"], 이름="측구", 단위="m")
|
||||
@@ -82,14 +103,27 @@ def test_서버_목록_칸_수정일_labor(world):
|
||||
doc = {
|
||||
"일위대가번": "SU-000001",
|
||||
"담은로직": [{"로직": "GF000219"}, {"로직": "GF000137"}],
|
||||
"계산값": {"반영합": {"계": 12345.6}},
|
||||
"단위": "개소",
|
||||
"계산값": {"반영합": {"계": 12345.6}, "기준합": {"계": 2469.12}},
|
||||
}
|
||||
(root / "labor" / f"{ditch['열']}.json").write_text(json.dumps(doc), encoding="utf-8")
|
||||
(root / "labor" / f"{wall['열']}.json").write_text("{깨짐", encoding="utf-8")
|
||||
rows = {r["열"]: r for r in t.client.get(t.url).json()["items"]}
|
||||
assert rows[ditch["열"]]["labor"] == {"번호": "SU-000001", "로직수": 2, "합계": 12345.6}
|
||||
assert rows[ditch["열"]]["labor"] == {
|
||||
"번호": "SU-000001",
|
||||
"로직수": 2,
|
||||
"합계": 12345.6,
|
||||
"기준": 2469.12,
|
||||
"단위": "개소",
|
||||
}
|
||||
assert "labor" in rows[ditch["열"]]["수정일"]
|
||||
assert rows[wall["열"]]["labor"] == {"번호": None, "로직수": 0, "합계": None}
|
||||
assert rows[wall["열"]]["labor"] == {
|
||||
"번호": None,
|
||||
"로직수": 0,
|
||||
"합계": None,
|
||||
"기준": None,
|
||||
"단위": None,
|
||||
}
|
||||
doc["계산값"] = {"반영합": {"계": "글"}}
|
||||
(root / "labor" / f"{ditch['열']}.json").write_text(json.dumps(doc), encoding="utf-8")
|
||||
again = {r["열"]: r for r in t.client.get(t.url).json()["items"]}
|
||||
|
||||
@@ -53,3 +53,13 @@ def test_층_단추는_구조물_조합_한_벌():
|
||||
page = _text("M02_MasterTemplete_UI_Page.ts")
|
||||
assert page.count('layerBar("') == 1 # 구조물 조합 줄 아래 하나
|
||||
assert 'layerBar("구조물조합", tree.add, () => void main.openTable(layer)),' in page
|
||||
|
||||
|
||||
def test_나무가_바뀌면_좌측_하위_열_줄도_다시_받음():
|
||||
"""39-8 H1 — 새 항목 · 복사 생성 · 붙여넣기 · 지우기 · 이름 · 옮기기 = 나무 `apply` →
|
||||
`onChanged` 한 길 · 집계표만 다시 열면 좌측 하위 줄이 새로고침 전까지 안 따라옴."""
|
||||
page = _text("M02_MasterTemplete_UI_Page.ts")
|
||||
changed = page.split("onChanged: async (colId) => {")[1].split("},")[0]
|
||||
assert "main.openTable(layer, colId, true)" in changed and "side.refresh()" in changed
|
||||
panel = _text("M02_MasterTemplete_TreePanel.ts")
|
||||
assert panel.count("await opt.onChanged(") == 1 # 나무 고침은 모두 apply 한 곳을 거침
|
||||
|
||||
@@ -131,6 +131,7 @@ console.log(JSON.stringify([
|
||||
m.qtyText({ 수량: 20, 수량출처: "칸", 알약: "돌쌓기|㎡" }, "㎡"),
|
||||
m.qtyText({ 수량: 3, 수량출처: "손", 알약: "돌쌓기|㎡" }, "㎡", cells),
|
||||
m.qtyText({ 수량: null, 수량출처: "없음", 알약: "돌쌓기|㎡" }, "㎡", cells),
|
||||
m.qtyText({ 수량: null, 수량출처: "없음", 알약: "돌쌓기|㎡" }, "㎡"),
|
||||
]));
|
||||
"""
|
||||
|
||||
@@ -148,5 +149,6 @@ def test_수량_글은_이은_칸_주소를_보임() -> None:
|
||||
"수량 B6 = 20 ㎡",
|
||||
"수량 면적산출 칸 = 20 ㎡",
|
||||
"수량 손 입력 3 ㎡",
|
||||
"수량 B6 = 빈 칸", # 이은 칸이 비었음 = 수량 없음(39-8 ② · 서버 area_values 와 같음)
|
||||
"",
|
||||
]
|
||||
|
||||
@@ -206,7 +206,9 @@ def test_층_단추_셋_남의_회사_사람_나무():
|
||||
|
||||
def test_층_길_접두어와_고정_풀림():
|
||||
api = _text(M02 / "M02_MasterTemplete_Api_Fetch.ts")
|
||||
assert 'activeLayer === "system" ? "" : `/layers/${activeLayer}`' in api
|
||||
# 시스템 층도 층 길 한 벌(39-8 H2 · 옛 /templates/* 는 시스템 관리자 전용)
|
||||
assert "owned(activeLayer, `/layers/${activeLayer}${path}`)" in api
|
||||
assert 'activeLayer === "system" ? "" :' not in api
|
||||
for path in ("/structures/numbers", "/structure-combos"):
|
||||
assert re.search(rf'scoped\(\s*[`"]{re.escape(path)}', api), path
|
||||
assert "/layers/personal/items/copy" in api
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
"""마스터 층 읽기는 층 길 한 벌(39-8 · sub1 검증 흠 H2) — 회사 관리자 · 일반도 `/layers/system/…` 로
|
||||
표 · 카드 · 일위대가 · 번호 · 장부 · 옵션을 읽음(200) · 쓰기는 서버가 그대로 막음(403).
|
||||
|
||||
시스템 층 = 견본 표(임시 폴더) · storage = 임시 폴더.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from common_util.common_util_auth import verify_session
|
||||
from config import config_system
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Router_Items as items_router
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Router_Layers as layers_router
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
||||
from M02_MasterTemplete import M02_Template_Layers as layers
|
||||
from resources.tester.test_m02_structure import write_sample_table
|
||||
|
||||
SYS = "/api/m02/layers/system"
|
||||
TABLE = f"{SYS}/templates/table/구조물집계표"
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def world(tmp_path, monkeypatch) -> dict[str, Any]:
|
||||
system = tmp_path / "system"
|
||||
system.mkdir()
|
||||
write_sample_table(system)
|
||||
monkeypatch.setattr(store, "FOLDER", system)
|
||||
monkeypatch.setattr(layers, "SYSTEM_ROOT", system)
|
||||
(tmp_path / "storage").mkdir()
|
||||
monkeypatch.setattr(config_system, "STORAGE_BASE_DIR", str(tmp_path / "storage"))
|
||||
with store.at(system):
|
||||
store.create_structure("rv")
|
||||
session: dict[str, Any] = {"user_id": 42, "company_id": 7, "role": "USER", "is_master": False}
|
||||
app = FastAPI()
|
||||
app.include_router(items_router.router)
|
||||
app.include_router(layers_router.router)
|
||||
app.dependency_overrides[verify_session] = lambda: session
|
||||
return {"client": TestClient(app), "session": session}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("role", ["USER", "ADMIN", "SYSTEM_ADMIN"])
|
||||
def test_세_계정_모두_마스터_층_읽기_200(world, role):
|
||||
world["session"]["role"] = role
|
||||
client = world["client"]
|
||||
for url in (
|
||||
f"{SYS}/templates",
|
||||
TABLE,
|
||||
f"{SYS}/templates/structure/rv",
|
||||
f"{SYS}/templates/labor/rv",
|
||||
f"{SYS}/templates/area/rv",
|
||||
f"{SYS}/structures/numbers",
|
||||
f"{SYS}/structure-combos",
|
||||
f"{SYS}/structure-options",
|
||||
):
|
||||
assert client.get(url).status_code == 200, url
|
||||
rows = client.get(f"{SYS}/templates").json()["양식"]
|
||||
table = next(r for r in rows if (r["종류"], r["이름"]) == ("table", "구조물집계표"))
|
||||
assert table["쓰는곳"] # 지우기 확인 창 전에 막는 글
|
||||
|
||||
|
||||
@pytest.mark.parametrize("role", ["USER", "ADMIN"])
|
||||
def test_회사_관리자_일반은_마스터_쓰기_403(world, role):
|
||||
world["session"]["role"] = role
|
||||
client = world["client"]
|
||||
got = client.get(TABLE).json()
|
||||
put = client.put(TABLE, json={"판": got["판"], "문서": got["문서"]})
|
||||
assert put.status_code == 403
|
||||
assert client.post(f"{SYS}/structures/c1").status_code == 403
|
||||
assert client.post(f"{SYS}/labor/rv/copy?from=rv").status_code == 403
|
||||
gone = client.delete(f"{SYS}/templates/area/rv?판=x")
|
||||
assert gone.status_code == 403
|
||||
@@ -8,6 +8,7 @@ master 만 · 단위 줄 바로 아래 차례(키보드) · project 에는 없
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
@@ -70,3 +71,18 @@ def test_text_column_pick_rows_blank():
|
||||
assert _run()["blank"] == [
|
||||
[True, False], [True, False], [True, False], [False, False], [False, False],
|
||||
] # fmt: skip
|
||||
|
||||
|
||||
def test_labor_row_shows_su_number():
|
||||
"""마스터 일위대가 줄 = 그 열 일위대가번(SU) — 열의 옛 `일위대가` 칸(UA 키)은 안 읽음."""
|
||||
ops = OPS.read_text(encoding="utf-8")
|
||||
assert "REF_ROWS: readonly string[] = [KEY_PRICE, KEY_DRAWING, KEY_AREA]" in ops
|
||||
render = (OPS.parent / "ui_template_sheet_render.ts").read_text(encoding="utf-8")
|
||||
assert re.search(r"key === KEY_PRICE\s*\?\s*state\.laborLabel", render)
|
||||
assert "col.일위대가" not in render
|
||||
sheet = (OPS.parent / "ui_template_sheet.ts").read_text(encoding="utf-8")
|
||||
assert "col.일위대가" not in sheet # 읽기 · 쓰기 · 조회 후 선택 모두 안 건드림
|
||||
main = (ROOT / "M02_MasterTemplete" / "M02_MasterTemplete_UI_Main.ts").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
assert 'laborLabel: (colId: string) => struct.numbers()[colId]?.일위대가번 ?? ""' in main
|
||||
|
||||
@@ -25,7 +25,6 @@ import {
|
||||
addLevel,
|
||||
deleteColumn,
|
||||
isBlankPick,
|
||||
KEY_PRICE,
|
||||
KEY_UNIT,
|
||||
navRows,
|
||||
PICK_ROWS,
|
||||
@@ -54,6 +53,8 @@ export interface SheetOptions {
|
||||
/** master — 열 더하기 · 지우기 · 머리 · 층 이름 고치기 없음(열 · 머리는 나무가 정함) */
|
||||
lockHead?: boolean;
|
||||
onChange?: (doc: SheetDoc) => void;
|
||||
/** 일위대가 줄 칸 글(일위대가번 SU · 열 id) — `drawingLabel` 과 같음(열의 옛 `일위대가` 칸은 안 읽음). */
|
||||
laborLabel?: (colId: string) => string;
|
||||
/** 도면 줄 칸 글(마스터 첫 쪽 · 열 id) — 없거나 빈 글이면 「없음」 · 글이 바뀌면 `recalc()` 로 다시 그림. */
|
||||
drawingLabel?: (colId: string) => string;
|
||||
/** 면적산출 줄 칸 글(번호) — `drawingLabel` 과 같음. */
|
||||
@@ -61,8 +62,7 @@ export interface SheetOptions {
|
||||
/** 칸을 고름 — 누르기 · 방향키 · Tab. */
|
||||
onSelect?: (sel: SheetSelection) => void;
|
||||
/** 조회 후 선택 — master 일위대가 · 도면 · 면적산출 칸을 두 번 누르기 · Enter · F2 로 부름.
|
||||
* 글을 돌려주면 일위대가 칸에 넣음 · 번호 글 줄은 부른 쪽이 가져온 뒤 `recalc()`.
|
||||
* 없으면 일위대가는 글로 고침 · 번호 글 줄은 읽기만. */
|
||||
* 번호 글 줄은 부른 쪽이 가져온 뒤 `recalc()` · 없으면 읽기만. */
|
||||
onPick?: (sel: SheetSelection) => Promise<string | null | undefined> | void;
|
||||
/** 구조물 조합 표(PLAN 31-1) — 단위 줄 · 안내 글 없음 · 열 폭 자동 · 고른 열은 가장 깊은 이름 칸을 밝힘 ·
|
||||
* `onHeadEdit` 를 주면 이름 칸을 두 번 누르기 · F2 · Enter 로 고쳐 부른 쪽에 넘김(표 문서는 안 고침 —
|
||||
@@ -87,6 +87,7 @@ export function createSheet(host: HTMLElement, input: SheetDoc, opts: SheetOptio
|
||||
mode: opts.mode,
|
||||
result: { 계산: {}, 합계: {}, 오류: [] },
|
||||
sel: null,
|
||||
laborLabel: opts.laborLabel,
|
||||
drawingLabel: opts.drawingLabel,
|
||||
areaLabel: opts.areaLabel,
|
||||
pick: !!opts.onPick,
|
||||
@@ -236,7 +237,6 @@ export function createSheet(host: HTMLElement, input: SheetDoc, opts: SheetOptio
|
||||
// ── 칸 고치기 ─────────────────────────────────────────────────────
|
||||
const rawText = (key: string, col: SheetColumn): string => {
|
||||
if (key === KEY_UNIT) return col.단위 ?? "";
|
||||
if (key === KEY_PRICE) return col.일위대가 ?? "";
|
||||
if (REF_ROWS.includes(key)) return refText(state, key, col.id);
|
||||
const row = state.doc.줄.find((r) => `d:${r.id}` === key);
|
||||
const formula = row?.식?.[col.id] ?? col.식;
|
||||
@@ -248,15 +248,12 @@ export function createSheet(host: HTMLElement, input: SheetDoc, opts: SheetOptio
|
||||
if (ro || REF_ROWS.includes(key)) return; // 읽기만
|
||||
const value = text.trim();
|
||||
if (key === KEY_UNIT) col.단위 = value || null;
|
||||
else if (key === KEY_PRICE) col.일위대가 = value || null;
|
||||
else setCell(state.doc, key.slice(2), col, text);
|
||||
changed();
|
||||
};
|
||||
/** 조회 후 선택 — 부른 쪽이 고른 글을 돌려주면 일위대가 칸에 넣음(그 사이 문서가 바뀌었으면 새 열에) */
|
||||
/** 조회 후 선택 — 부른 쪽이 가져옴(번호 글 줄 · 문서는 안 고침) */
|
||||
const pick = async (key: string, colId: string): Promise<void> => {
|
||||
const got = await opts.onPick?.(selectionOf(state.doc, key, colId));
|
||||
const col = colOf(colId);
|
||||
if (key === KEY_PRICE && typeof got === "string" && col) write(key, col, got);
|
||||
await opts.onPick?.(selectionOf(state.doc, key, colId));
|
||||
root.focus({ preventScroll: true });
|
||||
};
|
||||
/** 칸 고치기 — 두 번 누르기 = 글 전부 고름 · 바로 치기(initial) = 그 글자로 바꿈 · Enter · F2(atEnd) = 커서를 글 끝에 */
|
||||
|
||||
@@ -22,11 +22,11 @@ export const KEY_UNIT = "s:unit";
|
||||
export const KEY_PRICE = "s:price";
|
||||
export const KEY_DRAWING = "s:drawing";
|
||||
export const KEY_AREA = "s:area";
|
||||
/** 마스터 전용 줄(첫 쪽 단위 줄 아래 차례) — 도면 · 면적산출 줄은 손으로 못 침(번호 글). */
|
||||
/** 마스터 전용 줄(첫 쪽 단위 줄 아래 차례) — 일위대가 · 도면 · 면적산출 줄은 손으로 못 침(번호 글). */
|
||||
export const MASTER_ROWS = [KEY_PRICE, KEY_DRAWING, KEY_AREA] as const;
|
||||
export type MasterRowKey = (typeof MASTER_ROWS)[number];
|
||||
/** 번호 글 줄 — 부른 쪽이 준 글(`refLabel`)만 보임. */
|
||||
export const REF_ROWS: readonly string[] = [KEY_DRAWING, KEY_AREA];
|
||||
/** 번호 글 줄 — 부른 쪽이 준 글(`refLabel`)만 보임(일위대가 = SU · 열의 옛 `일위대가` 칸은 안 읽음). */
|
||||
export const REF_ROWS: readonly string[] = [KEY_PRICE, KEY_DRAWING, KEY_AREA];
|
||||
/** 조회 후 선택 줄 — `onPick` 을 주면 이 줄 칸은 치지 않고 고름. */
|
||||
export const PICK_ROWS: readonly string[] = MASTER_ROWS;
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
isBlankPick,
|
||||
isCalcColumn,
|
||||
KEY_AREA,
|
||||
KEY_PRICE,
|
||||
KEY_UNIT,
|
||||
MASTER_ROWS,
|
||||
type MasterRowKey,
|
||||
@@ -38,7 +39,8 @@ export interface RenderState {
|
||||
mode: SheetMode;
|
||||
result: SheetResult;
|
||||
sel: { r: string; c: string } | null;
|
||||
/** 도면 · 면적산출 줄 칸 글(열 id) — 없거나 빈 글이면 「없음」. */
|
||||
/** 일위대가 · 도면 · 면적산출 줄 칸 글(열 id) — 없거나 빈 글이면 「없음」. */
|
||||
laborLabel?: (colId: string) => string;
|
||||
drawingLabel?: (colId: string) => string;
|
||||
areaLabel?: (colId: string) => string;
|
||||
/** 조회 후 선택 줄(`PICK_ROWS`)을 고름으로 받나 — 칸 겉모양만. */
|
||||
@@ -51,9 +53,13 @@ export interface RenderState {
|
||||
headEditable?: boolean;
|
||||
}
|
||||
|
||||
/** 도면 · 면적산출 줄 칸 글. */
|
||||
/** 일위대가 · 도면 · 면적산출 줄 칸 글(번호). */
|
||||
export const refText = (state: RenderState, key: string, colId: string): string =>
|
||||
(key === KEY_AREA ? state.areaLabel : state.drawingLabel)?.(colId)?.trim() || st("Drawing_None");
|
||||
(key === KEY_AREA
|
||||
? state.areaLabel
|
||||
: key === KEY_PRICE
|
||||
? state.laborLabel
|
||||
: state.drawingLabel)?.(colId)?.trim() || st("Drawing_None");
|
||||
|
||||
/** 열 폭 — 저장 폭(없으면 기본) · 머리 글이 안 잘리는 최소 폭보다 좁지 않게. */
|
||||
const columnWidth = (doc: SheetDoc, col: SheetColumn, mins: Map<string, number>): number =>
|
||||
@@ -68,7 +74,7 @@ function fitMeta(state: RenderState, cols: SheetColumn[], mins: Map<string, numb
|
||||
const rem = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
|
||||
const font = `400 ${rem * 0.76}px ${getComputedStyle(document.body).fontFamily}`;
|
||||
for (const col of cols) {
|
||||
const texts = [col.일위대가 ?? "", ...REF_ROWS.map((key) => refText(state, key, col.id))];
|
||||
const texts = REF_ROWS.map((key) => refText(state, key, col.id));
|
||||
const need = Math.ceil(Math.max(...texts.map((t) => textWidth(t, font)))) + 14;
|
||||
mins.set(col.id, Math.max(mins.get(col.id) ?? 0, need));
|
||||
}
|
||||
@@ -191,11 +197,7 @@ export function renderSheet(state: RenderState): HTMLElement {
|
||||
const tr = el("tr", { className: "ui-sheet__meta", children: [gutter("th", st(label))] });
|
||||
cols.forEach((col, j) => {
|
||||
const blank = isBlankPick(key, col);
|
||||
const text = blank
|
||||
? ""
|
||||
: REF_ROWS.includes(key)
|
||||
? refText(state, key, col.id)
|
||||
: (col.일위대가 ?? "");
|
||||
const text = blank ? "" : refText(state, key, col.id); // 마스터 줄 = 모두 번호 글
|
||||
const td = bodyCell(key, col, j, text);
|
||||
td.classList.remove("is-num");
|
||||
if (state.pick && PICK_ROWS.includes(key) && !blank) td.classList.add("is-pick");
|
||||
|
||||
@@ -143,6 +143,7 @@ export const ui_locales_m2 = {
|
||||
M02_TreeItemName: ["항목 이름", "Item name"],
|
||||
M02_TreeUnit: ["단위", "Unit"],
|
||||
M02_TreeMoveTo: ["옮길 폴더", "Move to"],
|
||||
M02_TreeCopyFolder: ["복사할 폴더", "Copy to folder"],
|
||||
M02_TreeTop: ["맨 위 (폴더 밖)", "Top (no folder)"],
|
||||
M02_TreeOk: ["확인", "OK"],
|
||||
M02_TreeCancel: ["취소", "Cancel"],
|
||||
|
||||
Reference in New Issue
Block a user