Merge remote-tracking branches 'origin/sub_laptop_2' and 'origin/sub_laptop_6' into main_laptop_1
This commit is contained in:
@@ -40,11 +40,13 @@ import {
|
||||
import { openModal } from "@ui/ui_template_modal";
|
||||
import { createText } from "@ui/ui_template_text";
|
||||
import { currentProjectId } from "../A00_Common/b_project_switch";
|
||||
import { formatMoney, formatPrice } from "../common_util/common_util_money";
|
||||
import type { Row } from "./B09_Estimation_Calc_Base";
|
||||
import { openBillLink } from "./B09_Estimation_UI_Link";
|
||||
import { money, qty, text, type TabContext, type TabId } from "./B09_Estimation_UI_Tabs";
|
||||
|
||||
const won = (v: unknown): string => Number(v || 0).toLocaleString("ko-KR");
|
||||
const won = (v: unknown): string =>
|
||||
v === null || v === undefined || v === "" ? "빈칸" : formatPrice(Number(v));
|
||||
|
||||
async function post(projectId: string, path: string, body: BodyInit, json = false): Promise<Row> {
|
||||
const response = await fetch(`${API_BASE_URL}/projects/${projectId}/estimation/import/${path}`, {
|
||||
@@ -357,8 +359,7 @@ async function postSheet(kind: Kind, user: Row, file: File | null): Promise<Row>
|
||||
return body!;
|
||||
}
|
||||
|
||||
const wonOrBlank = (v: unknown): string =>
|
||||
typeof v === "number" ? Math.round(v).toLocaleString("en-US") : "";
|
||||
const wonOrBlank = (v: unknown): string => (typeof v === "number" ? formatMoney(v) : "");
|
||||
|
||||
function ratesView(got: Row): { table: DataRow[]; lines: string[]; columns: DataColumn[] } {
|
||||
const check: Row[] = got.check?.lines || [];
|
||||
|
||||
@@ -38,6 +38,7 @@ import type { TabContext } from "./B09_Estimation_UI_Tabs";
|
||||
import { applyAt, openMenuAt } from "./B09_Estimation_UI_Tabs_Bill_Edit";
|
||||
import { askInputs, importLib, type ApplyRef } from "./B09_Estimation_UI_Tabs_Bill_Link";
|
||||
import { pickedKey } from "./B09_Estimation_UI_Tabs_Bill_Level";
|
||||
import { formatMoney } from "../common_util/common_util_money";
|
||||
|
||||
type Source = "here" | "mine" | "company" | "sangeun" | "poomsem" | "past";
|
||||
const SOURCES: Array<[Source, string]> = [
|
||||
@@ -133,7 +134,7 @@ async function sangeunItems(): Promise<Item[]> {
|
||||
return (await listSangeun([{ 층: "system", 주인: null }, ...own])).map((r) => {
|
||||
const name = r.제목 || r.이름;
|
||||
const sub = [r.구분, r.상세구분].filter(Boolean).join(" ");
|
||||
const money = r.기준금액 === null ? "" : `${Math.round(r.기준금액).toLocaleString("ko-KR")}원`;
|
||||
const money = r.기준금액 === null ? "" : `${formatMoney(r.기준금액)}원`;
|
||||
return {
|
||||
tag: `단가산출 · ${layerText[r.ref.층] ?? r.ref.층}`,
|
||||
name,
|
||||
@@ -161,9 +162,7 @@ async function poomsemItems(): Promise<Item[]> {
|
||||
return list.줄.map((r) => ({
|
||||
tag: r.공종 || "품셈",
|
||||
name: r.이름,
|
||||
sub: [r.규격, r.금액 === null ? "" : `${Math.round(r.금액).toLocaleString("ko-KR")}원`]
|
||||
.filter(Boolean)
|
||||
.join(" · "),
|
||||
sub: [r.규격, r.금액 === null ? "" : `${formatMoney(r.금액)}원`].filter(Boolean).join(" · "),
|
||||
unit: r.단위,
|
||||
hit: (q) => !q || matchLogic(q, { ...r, 이름: `${r.이름} ${r.규격}` }).ok,
|
||||
go: (_c, apply) => askStandard(apply, r),
|
||||
|
||||
@@ -179,6 +179,7 @@ export const money = (key: string, label: string, group?: string[], decimals = 0
|
||||
group,
|
||||
kind: "number",
|
||||
decimals,
|
||||
trim: decimals > 0, // 끝 0 없음(「18,064.0」 아님)
|
||||
width: 96,
|
||||
});
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
import type { MapContextMenuItem } from "@ui/ui_template_context_menu";
|
||||
import type { DataRow } from "@ui/ui_template_data_table";
|
||||
import { SOURCES, type Row } from "./B09_Estimation_Calc_Base";
|
||||
import { formatPrice } from "../common_util/common_util_money";
|
||||
import { dateText } from "../common_util/common_util_time";
|
||||
import { openQuotes } from "./B09_Estimation_UI_Tabs_Base_Quote";
|
||||
import {
|
||||
@@ -41,7 +42,7 @@ function priceList(rows: Row[], unitPrice: string, hint: string): TabTable {
|
||||
text("name", "명칭", 200),
|
||||
text("spec", "규격", 140),
|
||||
text("unit", "단위", 56),
|
||||
money("price", unitPrice),
|
||||
money("price", unitPrice, undefined, 3),
|
||||
text("source", "비고", 220),
|
||||
],
|
||||
rows: rows.map((r) => ({ ...r })),
|
||||
@@ -121,7 +122,7 @@ const SUPPLY_NEXT: Record<string, string | null> = { "": "관급", 관급: "별
|
||||
function appliedChoices(r: Row, pick: (s: string | null) => void): MapContextMenuItem[] {
|
||||
const have = SOURCES.filter((s) => (r.prices[s] ?? 0) > 0);
|
||||
const label = (s: string): string =>
|
||||
`${s === r.applied_from ? "✓ " : ""}${s} ${Number(r.prices[s]).toLocaleString("ko-KR")}` +
|
||||
`${s === r.applied_from ? "✓ " : ""}${s} ${formatPrice(Number(r.prices[s]))}` +
|
||||
(s === r.lowest_from ? " · 최저" : "");
|
||||
return [
|
||||
...have.map((s): MapContextMenuItem => [label(s), () => pick(s)]),
|
||||
@@ -196,11 +197,11 @@ export const buildCompare: TabBuild = ({ sheets, user, setUser }): TabTable => {
|
||||
// 185-4 — 출처 = 단가 하나(출처 뜻은 단가 칸 호버) · 견적 = 단가 · 견적서
|
||||
...SOURCES.flatMap((s) =>
|
||||
s === "견적"
|
||||
? [money(`p:${s}`, "단가", [s]), text("files", "견적서", 72, [s])]
|
||||
: [money(`p:${s}`, "단가", [s])],
|
||||
? [money(`p:${s}`, "단가", [s], 3), text("files", "견적서", 72, [s])]
|
||||
: [money(`p:${s}`, "단가", [s], 3)],
|
||||
),
|
||||
money("lowest", "최저"),
|
||||
money("applied", "적용 단가", ["적용"]),
|
||||
money("lowest", "최저", undefined, 3),
|
||||
money("applied", "적용 단가", ["적용"], 3),
|
||||
text("applied_from", "출처", 80, ["적용"]),
|
||||
text("supply", "관급 · 별도", 80),
|
||||
],
|
||||
@@ -294,7 +295,7 @@ export const buildMaterial: TabBuild = ({ sheets }): TabTable => {
|
||||
text("spec", "규격", 160),
|
||||
text("unit", "단위", 56),
|
||||
qty("qty", "수량", 3),
|
||||
money("price", "단가"),
|
||||
money("price", "단가", undefined, 3),
|
||||
money("amount", "총액"),
|
||||
text("supply", "관급 · 별도", 80),
|
||||
text("uses", "비고(쓰는 곳)", 240),
|
||||
@@ -379,7 +380,7 @@ export const buildMachineBlocks: TabBuild = ({ sheets }): TabTable => {
|
||||
links.push(null);
|
||||
});
|
||||
const pair = (key: string, label: string) => [
|
||||
money(`${key}_price`, "단가", [label], 1),
|
||||
money(`${key}_price`, "단가", [label], 3),
|
||||
money(key, "금액", [label], 1),
|
||||
];
|
||||
return {
|
||||
|
||||
@@ -55,7 +55,7 @@ export const buildBill: TabBuild = (ctx): BillTable => {
|
||||
}));
|
||||
const direct: Row = bill.totals?.direct || {};
|
||||
const pair = (key: string, label: string) => [
|
||||
money(`price_${key}`, "단가", [label]),
|
||||
money(`price_${key}`, "단가", [label], 3),
|
||||
money(key, "금액", [label]),
|
||||
];
|
||||
/** 고친 칸 — 셈 줄에 자동 값(auto_칸)이 있음 · 연결 칸 = auto_ref */
|
||||
|
||||
@@ -28,6 +28,7 @@ import { foldMenu } from "./B09_Estimation_UI_Tabs_Bill_Fold_Menu";
|
||||
import { API_BASE_URL } from "@config/config_frontend";
|
||||
import { hideLoadingOverlay, showLoadingOverlay, showToast } from "@ui/ui_template_toast";
|
||||
import { currentProjectId } from "../A00_Common/b_project_switch";
|
||||
import { formatNumber } from "../common_util/common_util_money";
|
||||
import { ADD } from "./B09_Estimation_Calc_Labor";
|
||||
import { withCols } from "../M02_MasterTemplete/M02_MasterTemplete_Labor_Paste";
|
||||
import {
|
||||
@@ -57,8 +58,7 @@ interface FoldLine {
|
||||
/** 지금 펼친 내역 줄 키(다시 그려도 그대로) */
|
||||
let open: string | null = null;
|
||||
|
||||
const plain = (v: unknown): string =>
|
||||
typeof v === "number" ? v.toLocaleString("ko-KR", { maximumFractionDigits: 4 }) : "빈칸";
|
||||
const plain = (v: unknown): string => (typeof v === "number" ? formatNumber(v) : "빈칸");
|
||||
|
||||
/** 되돌림 값 이름 — 단가산출 블록 줄 = 단가산출 값 · 그 밖 = 품셈 값 */
|
||||
const backName = (line: Row | undefined): string => (line?.sk ? "단가산출 값" : "품셈 값");
|
||||
|
||||
@@ -27,6 +27,7 @@ import { createButton, el } from "@ui/ui_template_elements";
|
||||
import { attachHoverInfo } from "@ui/ui_template_hover_info";
|
||||
import { createText } from "@ui/ui_template_text";
|
||||
import { calcSheetRows, type SheetGroup, type SheetLine } from "../A00_Common/calc_sheet";
|
||||
import { formatNumber } from "../common_util/common_util_money";
|
||||
import type { Row } from "./B09_Estimation_Calc_Base";
|
||||
import { fields, lineId } from "./B09_Estimation_Sangeun_Cover";
|
||||
import { money, parseNumber, qty, text, type TabContext } from "./B09_Estimation_UI_Tabs";
|
||||
@@ -49,8 +50,7 @@ const FIELD: Record<string, string> = {
|
||||
expense_price: "expense",
|
||||
};
|
||||
|
||||
const fmt = (v: unknown): string =>
|
||||
typeof v === "number" ? v.toLocaleString("ko-KR", { maximumFractionDigits: 4 }) : "빈칸";
|
||||
const fmt = (v: unknown): string => (typeof v === "number" ? formatNumber(v) : "빈칸");
|
||||
|
||||
/* ── 순수 — 산근 블록 → 계산서 묶음 ── */
|
||||
/** 줄이 쓰는 비목 */
|
||||
|
||||
@@ -47,7 +47,7 @@ function resourceTab(id: string, list: TabId, unitPrice: string, none = ""): Tab
|
||||
text("spec", "규격", 140),
|
||||
qty("qty", "수량", 2),
|
||||
text("unit", "단위", 56),
|
||||
money("price", unitPrice),
|
||||
money("price", unitPrice, undefined, 3),
|
||||
money("amount", "금액"),
|
||||
text("note", "비고", 120),
|
||||
],
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
* ========================================================================== */
|
||||
|
||||
import type { DataRow } from "@ui/ui_template_data_table";
|
||||
import { formatMoney } from "../common_util/common_util_money";
|
||||
import type { Row } from "./B09_Estimation_Calc_Base";
|
||||
import { CURRENT_ONLY, RATE_LINES, TAIL_LINES } from "./B09_Estimation_Calc_Total";
|
||||
import {
|
||||
@@ -31,7 +32,7 @@ import {
|
||||
|
||||
type Num = number | null;
|
||||
|
||||
const won = (v: Num): string => (v === null ? "빈칸" : Math.round(v).toLocaleString("en-US"));
|
||||
const won = (v: Num): string => (v === null ? "빈칸" : formatMoney(v));
|
||||
|
||||
/** 손입력 칸 — 줄 키 → user.estimate 칸 */
|
||||
const HAND: Record<string, [string, string]> = {
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
/* =============================================================================
|
||||
* M01_MasterData_UI_LogicLab_Foot.ts
|
||||
* 화면(모달) 바닥에 붙는 비목 소계 줄(737-1 · PLAN 748-3) — 단가산출 상세(노무비 · 재료비 · 경비 · 계 네 칸) ·
|
||||
* 텍스트 수식(노무비 · 재료비 · 경비 세 칸 · 계 칸 없음 — 중복 표기) 두 보기가 같이 씀.
|
||||
* 값 = 서버 셈 그대로(여기서 다시 셈 안 함) · 계산 전 「—」.
|
||||
* ========================================================================== */
|
||||
|
||||
import { createCalcFormulaRow } from "@ui/ui_template_calc_row";
|
||||
import { el } from "@ui/ui_template_elements";
|
||||
import { formatMoney } from "./M01_MasterData_UI_Logic_Money";
|
||||
import { tl } from "./M01_MasterData_UI_LogicLab_Text";
|
||||
|
||||
export type FootKey = "labor" | "material" | "cost";
|
||||
const LABEL: Record<FootKey, string> = { labor: "노무비", material: "재료비", cost: "경비" };
|
||||
export const FOOT_KEYS = Object.keys(LABEL) as FootKey[];
|
||||
/** 서버 비목 이름 → 칸 */
|
||||
export const FOOT_OF: Record<string, FootKey> = {
|
||||
노무비: "labor",
|
||||
재료비: "material",
|
||||
경비: "cost",
|
||||
};
|
||||
|
||||
/** 비목 소계 줄 — `total` 을 주면 맨 끝 「계」 칸까지 네 칸(주지 않으면 세 칸) */
|
||||
export function buildFoot(
|
||||
sums: Record<FootKey, number>,
|
||||
calculated: boolean,
|
||||
total?: number,
|
||||
): HTMLElement {
|
||||
const cells = FOOT_KEYS.map((k) => {
|
||||
const out = el("span", {
|
||||
className: "ui-calc-row__out is-key",
|
||||
text: calculated ? formatMoney(sums[k]) : "—",
|
||||
});
|
||||
const row = createCalcFormulaRow(LABEL[k], null, out);
|
||||
row.dataset.group = k;
|
||||
row.dataset.subtotal = calculated ? String(sums[k]) : "";
|
||||
return row;
|
||||
});
|
||||
const foot = el("div", {
|
||||
className: "m01lab__foot",
|
||||
children: [
|
||||
el("div", { className: "m01lab__sums", children: cells }),
|
||||
...(total === undefined
|
||||
? []
|
||||
: [
|
||||
el("p", {
|
||||
className: "m01lab__total",
|
||||
attrs: { "data-sum": calculated ? String(total) : "" },
|
||||
children: [
|
||||
el("span", { text: `${tl("Total")} ` }),
|
||||
el("span", { text: calculated ? formatMoney(total) : "—" }),
|
||||
],
|
||||
}),
|
||||
]),
|
||||
],
|
||||
});
|
||||
if (total === undefined) foot.dataset.cols = "3";
|
||||
return foot;
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import { optionGrid, varRow } from "../A00_Common/calc_sheet";
|
||||
import { formatMoney } from "./M01_MasterData_UI_Logic_Money";
|
||||
import { safeReason, tx } from "./M01_MasterData_UI_Logic_Text";
|
||||
import { tl } from "./M01_MasterData_UI_LogicLab_Text";
|
||||
import { buildFoot, FOOT_KEYS, FOOT_OF, type FootKey } from "./M01_MasterData_UI_LogicLab_Foot";
|
||||
import { nameTokens, tokenRow, valueTokens } from "./M01_MasterData_UI_LogicLab_FormulaTokens";
|
||||
import "./M01_MasterData_UI_LogicLab_Formula.css";
|
||||
|
||||
@@ -223,12 +224,9 @@ export function buildFormula(host: HTMLElement, ctx: FormulaContext): void {
|
||||
card.dataset.fxBox = g.비목;
|
||||
return card;
|
||||
});
|
||||
host.replaceChildren(
|
||||
...boxes,
|
||||
el("p", {
|
||||
className: "m01lab__total",
|
||||
attrs: { "data-text-sum": String(text.계) },
|
||||
text: `${tl("Total")} ${formatMoney(text.계)}`,
|
||||
}),
|
||||
);
|
||||
// 748-3 맨 아래 비목 소계 고정 줄(노무비 · 재료비 · 경비 — 계 칸 없음 · 상자 소계는 그대로)
|
||||
const sums = Object.fromEntries(
|
||||
FOOT_KEYS.map((k) => [k, text.groups.find((g) => FOOT_OF[g.비목] === k)?.소계 ?? 0]),
|
||||
) as Record<FootKey, number>;
|
||||
host.replaceChildren(...boxes, buildFoot(sums, calculated));
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@
|
||||
* ========================================================================== */
|
||||
|
||||
import { createBadge } from "@ui/ui_template_badge";
|
||||
import { createCalcFormulaRow } from "@ui/ui_template_calc_row";
|
||||
import { createCard } from "@ui/ui_template_card";
|
||||
import { el } from "@ui/ui_template_elements";
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
@@ -44,6 +43,7 @@ import {
|
||||
openTableModal,
|
||||
type PickCond,
|
||||
} from "./M01_MasterData_UI_LogicLab_Modal";
|
||||
import { buildFoot, FOOT_KEYS, type FootKey } from "./M01_MasterData_UI_LogicLab_Foot";
|
||||
import { fragmentRow, type Fragment } from "./M01_MasterData_UI_LogicLab_Pill";
|
||||
import { tl } from "./M01_MasterData_UI_LogicLab_Text";
|
||||
|
||||
@@ -653,35 +653,17 @@ export function buildHo(ctx: HoContext): HTMLElement[] {
|
||||
const boxes = boxesOf(all, ["인력", "재료", "경비"]);
|
||||
const used = new Set(boxes.flatMap(usesOf));
|
||||
const common = (ctx.row.입력 ?? []).map((s) => s.이름).filter((n) => !used.has(n));
|
||||
const sums = (["labor", "material", "cost"] as Group[]).map((g) => {
|
||||
const v = all.filter((e) => e.group === g).reduce((s, e) => s + (e.amount ?? 0), 0);
|
||||
const out = el("span", {
|
||||
className: "ui-calc-row__out is-key",
|
||||
text: calculated ? formatMoney(v) : "—",
|
||||
});
|
||||
const row = createCalcFormulaRow(COST_OF[g], null, out);
|
||||
row.dataset.group = g;
|
||||
row.dataset.subtotal = calculated ? String(v) : "";
|
||||
return row;
|
||||
});
|
||||
const sums = Object.fromEntries(
|
||||
FOOT_KEYS.map((g) => [
|
||||
g,
|
||||
all.filter((e) => e.group === g).reduce((s, e) => s + (e.amount ?? 0), 0),
|
||||
]),
|
||||
) as Record<FootKey, number>;
|
||||
const total = all.reduce((s, e) => s + (e.amount ?? 0), 0);
|
||||
return [
|
||||
...(boxes.length || !common.length ? [] : [commonCard(optionRows(common, ctx))]),
|
||||
...boxes.map((b) => boxCard(b, ctx, calculated, common)),
|
||||
// 737-1 맨 아래 한 줄 4 칸(노무비 · 재료비 · 경비 · 계) — 화면(모달) 바닥에 붙음
|
||||
el("div", {
|
||||
className: "m01lab__foot",
|
||||
children: [
|
||||
el("div", { className: "m01lab__sums", children: sums }),
|
||||
el("p", {
|
||||
className: "m01lab__total",
|
||||
attrs: { "data-sum": calculated ? String(total) : "" },
|
||||
children: [
|
||||
el("span", { text: `${tl("Total")} ` }),
|
||||
el("span", { text: calculated ? formatMoney(total) : "—" }),
|
||||
],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
buildFoot(sums, calculated, total),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -256,7 +256,7 @@
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: var(--spacing-8);
|
||||
margin-top: var(--spacing-12);
|
||||
margin-top: var(--m01lab-box-gap, var(--spacing-12));
|
||||
padding: var(--spacing-8) var(--spacing-12);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
@@ -272,6 +272,20 @@
|
||||
display: contents;
|
||||
}
|
||||
|
||||
/* 748-3 텍스트 수식 바닥 줄 = 세 칸(계 칸 없음) */
|
||||
.m01lab__foot[data-cols="3"] {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
/* 748-3 비목 상자 사이 간격 = 바닥 고정 줄 위 여유(같은 값 · 두 보기) */
|
||||
.m01-logic__editor {
|
||||
--m01lab-box-gap: var(--spacing-12);
|
||||
}
|
||||
|
||||
.m01lab__ho-box + .m01lab__ho-box {
|
||||
margin-top: var(--m01lab-box-gap, var(--spacing-12));
|
||||
}
|
||||
|
||||
.m01lab__foot .ui-calc-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -20,6 +20,7 @@ import { tx } from "./M01_MasterData_UI_Logic_Text";
|
||||
import { createBadge } from "@ui/ui_template_badge";
|
||||
import { createText } from "@ui/ui_template_text";
|
||||
import { createIconButton } from "@ui/ui_template_button";
|
||||
import { formatNumber } from "../common_util/common_util_money";
|
||||
|
||||
export type PickMode = "element" | "table";
|
||||
export type PickDone = (item: ElementBrief, column?: string) => void;
|
||||
@@ -53,11 +54,7 @@ const groupOf = (key: string): string =>
|
||||
const COSTS = ["노무비", "재료비", "경비"];
|
||||
const COST_OF: Record<string, string> = { 인력: "노무비", 재료: "재료비", 기계: "경비" };
|
||||
|
||||
export function formatNumber(value: unknown): string {
|
||||
if (typeof value === "number") return value.toLocaleString("ko-KR", { maximumFractionDigits: 4 });
|
||||
if (value === null || value === undefined) return "";
|
||||
return typeof value === "object" ? JSON.stringify(value) : String(value);
|
||||
}
|
||||
export { formatNumber };
|
||||
|
||||
function textBox(value: unknown, onInput: (v: string) => void, className = ""): HTMLInputElement {
|
||||
const input = el("input", {
|
||||
|
||||
@@ -1,19 +1,11 @@
|
||||
/* =============================================================================
|
||||
* M01_MasterData_UI_Logic_Money.ts
|
||||
* 돈 모양 한 벌 — 원 단위 정수(반올림). 단가·금액·소계·계 자리만 씀 · 수량·비율은 `formatNumber` 그대로
|
||||
* 돈 모양 — 공용 꼴 한 벌(`common_util/common_util_money.ts`)을 다시 내보냄. 부르는 쪽은 그대로.
|
||||
* ========================================================================== */
|
||||
|
||||
import { formatNumber } from "./M01_MasterData_UI_Logic_Edit";
|
||||
|
||||
export const formatMoney = (value: unknown): string =>
|
||||
typeof value === "number"
|
||||
? value.toLocaleString("ko-KR", { maximumFractionDigits: 0 })
|
||||
: formatNumber(value);
|
||||
|
||||
/** 단가 모양 — 정수로 보이되 200 원 미만은 반올림 오차가 커서 소수 넷째 자리·유효숫자 4 자리 중 많은 쪽(뒤 0 뗌) */
|
||||
export const formatPrice = (value: unknown): string =>
|
||||
typeof value === "number" && value !== 0 && Math.abs(value) < 200
|
||||
? value.toLocaleString("ko-KR", {
|
||||
maximumFractionDigits: Math.max(4, 3 - Math.floor(Math.log10(Math.abs(value)))),
|
||||
})
|
||||
: formatMoney(value);
|
||||
export {
|
||||
formatLineMoney,
|
||||
formatMoney,
|
||||
formatNumber,
|
||||
formatPrice,
|
||||
} from "../common_util/common_util_money";
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
/* =============================================================================
|
||||
* common_util_money.ts
|
||||
* 화면 숫자 글 꼴 한 벌 — 금액 · 단가 · 수량 · 율 · 빈 값 · 음수. 셈 값은 그대로, 보이는 글만 만듦(반올림).
|
||||
* formatMoney 원 단위 정수 (계 · 소계 · 합계 · 내역 금액)
|
||||
* formatLineMoney 최대 소수 1 · 끝 0 뗌 (일위대가 줄 금액 · 산근 근거 줄)
|
||||
* formatPrice 단가 — 200 원 미만 = 유효숫자 넷 · 그 밖 = 최대 소수 3 · 끝 0 뗌
|
||||
* formatNumber 수량 · 율 · 품 — 소수 넷째 자리 · 유효숫자 넷 중 많은 쪽 · 끝 0 뗌
|
||||
* 값이 숫자가 아니면 빈 값 = 「」 · 객체 = JSON 글 · 그 밖 = String.
|
||||
* 짝: `resources/master_data/scripts/master_fmt.py` (거울 시험 `resources/tester/test_588_2_money_fmt.py`).
|
||||
* ========================================================================== */
|
||||
|
||||
const LOCALE = "ko-KR";
|
||||
|
||||
function fixed(value: number, maxDigits: number): string {
|
||||
const text = value.toLocaleString(LOCALE, { maximumFractionDigits: Math.min(20, maxDigits) });
|
||||
return text === "-0" ? "0" : text;
|
||||
}
|
||||
|
||||
/** 수량 꼴 자리 — 소수 넷째 자리 · 유효숫자 넷 중 많은 쪽 (0.00167 → 0.00167) */
|
||||
function numberPlaces(value: number): number {
|
||||
return value === 0 || !Number.isFinite(value)
|
||||
? 4
|
||||
: Math.max(4, 3 - Math.floor(Math.log10(Math.abs(value))));
|
||||
}
|
||||
|
||||
function other(value: unknown): string {
|
||||
if (value === null || value === undefined) return "";
|
||||
return typeof value === "object" ? JSON.stringify(value) : String(value);
|
||||
}
|
||||
|
||||
export function formatNumber(value: unknown): string {
|
||||
return typeof value === "number" ? fixed(value, numberPlaces(value)) : other(value);
|
||||
}
|
||||
|
||||
export function formatMoney(value: unknown): string {
|
||||
return typeof value === "number" ? fixed(value, 0) : other(value);
|
||||
}
|
||||
|
||||
export function formatLineMoney(value: unknown): string {
|
||||
return typeof value === "number" ? fixed(value, 1) : other(value);
|
||||
}
|
||||
|
||||
export function formatPrice(value: unknown): string {
|
||||
if (typeof value !== "number") return other(value);
|
||||
return Math.abs(value) < 200 ? formatNumber(value) : fixed(value, 3);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""숫자 글 꼴 한 벌 — 금액 · 단가 · 수량 · 율 · 빈 값 · 음수. 셈 값은 그대로, 보이는 글만 만듦(반올림 = 절반 올림).
|
||||
|
||||
TS 짝: `common_util/common_util_money.ts` — 두 파일은 같은 글을 냄(거울 시험 `test_588_2_money_fmt.py`).
|
||||
fmt_money 원 단위 정수 (계 · 소계 · 합계 · 내역 금액)
|
||||
fmt_line_money 최대 소수 1 · 끝 0 뗌 (일위대가 줄 금액 · 산근 근거 줄)
|
||||
fmt_price 단가 — 200 원 미만 = 유효숫자 넷 · 그 밖 = 최대 소수 3 · 끝 0 뗌
|
||||
fmt 수량 · 율 · 품 — 소수 넷째 자리 · 유효숫자 넷 중 많은 쪽 · 끝 0 뗌
|
||||
값이 숫자가 아니면 빈 값 = 「」 · 그 밖 = str.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import ROUND_HALF_UP, Decimal, localcontext
|
||||
|
||||
|
||||
def _num(value) -> Decimal | None:
|
||||
"""숫자 → Decimal (불리언 · NaN · 무한대 · 숫자 아님 = None)."""
|
||||
if isinstance(value, bool):
|
||||
return None
|
||||
if isinstance(value, float):
|
||||
value = repr(value) # 짧은 글 → 자바스크립트 숫자와 같은 자리
|
||||
elif not isinstance(value, (Decimal, int)):
|
||||
return None
|
||||
out = Decimal(value)
|
||||
return out if out.is_finite() else None
|
||||
|
||||
|
||||
def _other(value) -> str:
|
||||
return "" if value is None else str(value)
|
||||
|
||||
|
||||
def _fixed(value: Decimal, places: int) -> str:
|
||||
"""소수 `places` 자리까지 절반 올림 · 끝 0 뗌 · 천 단위 쉼표."""
|
||||
with localcontext() as ctx:
|
||||
ctx.prec = 80
|
||||
cut = value.quantize(Decimal(1).scaleb(-min(places, 20)), rounding=ROUND_HALF_UP)
|
||||
sign, digits = ("-", -cut) if cut < 0 else ("", cut)
|
||||
whole, _, rest = f"{digits:f}".partition(".")
|
||||
rest = rest.rstrip("0")
|
||||
return sign + f"{int(whole):,}" + (f".{rest}" if rest else "")
|
||||
|
||||
|
||||
def fmt(value) -> str:
|
||||
d = _num(value)
|
||||
if d is None:
|
||||
return _other(value)
|
||||
return _fixed(d, 4 if not d else max(4, 3 - d.adjusted()))
|
||||
|
||||
|
||||
def fmt_money(value) -> str:
|
||||
d = _num(value)
|
||||
return _other(value) if d is None else _fixed(d, 0)
|
||||
|
||||
|
||||
def fmt_line_money(value) -> str:
|
||||
d = _num(value)
|
||||
return _other(value) if d is None else _fixed(d, 1)
|
||||
|
||||
|
||||
def fmt_price(value) -> str:
|
||||
d = _num(value)
|
||||
if d is None:
|
||||
return _other(value)
|
||||
return fmt(d) if abs(d) < 200 else _fixed(d, 3)
|
||||
@@ -0,0 +1,21 @@
|
||||
/* 원화 꼴 한 벌(`common_util_money.ts`) 거울 헬퍼 — 트랜스파일해 Node 로 돌림 ·
|
||||
* stdin = [값 글 | null ...] (숫자 글은 Number 로) · stdout = {함수: [글 ...]} */
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
|
||||
|
||||
const file = path.join(__dirname, "..", "..", "common_util", "common_util_money.ts");
|
||||
const js = ts.transpileModule(fs.readFileSync(file, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
|
||||
}).outputText;
|
||||
const box = { exports: {} };
|
||||
new Function("exports", "module", "require", js)(box.exports, box, require);
|
||||
|
||||
const cases = JSON.parse(fs.readFileSync(0, "utf8")).map((c) =>
|
||||
c !== null && c.num ? Number(c.num) : c === null ? null : c.text,
|
||||
);
|
||||
const out = {};
|
||||
for (const name of ["formatNumber", "formatMoney", "formatLineMoney", "formatPrice"]) {
|
||||
out[name] = cases.map((c) => box.exports[name](c));
|
||||
}
|
||||
process.stdout.write(JSON.stringify(out));
|
||||
@@ -0,0 +1,66 @@
|
||||
"""원화 꼴 한 벌 거울 시험 — `master_fmt.py`(파이썬) ↔ `common_util_money.ts`(TS · Node) 가 같은 글.
|
||||
|
||||
CLAUDE.md 5장 ① 짝. 값은 숫자 글(`num`) · 숫자 아닌 글(`text`) · 빈 값(None).
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from decimal import Decimal
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, os.path.join(HERE, "..", "master_data", "scripts"))
|
||||
|
||||
import master_fmt as mf # noqa: E402
|
||||
|
||||
NUMS = [
|
||||
"0", "1", "2.5", "-1234.5", "1.00005", "0.12345", "0.00167", "22.9", "1846.39",
|
||||
"124419.853", "275790", "69643.9394", "0.2525", "4", "199.99", "200", "1234567.891",
|
||||
"-0.00001", "0.05", "870.2455", "59020", "1414.9", "-22.96",
|
||||
] # fmt: skip
|
||||
CASES = [{"num": n} for n in NUMS] + [{"text": "가나다"}, {"text": ""}, None]
|
||||
PAIRS = {
|
||||
"formatNumber": mf.fmt,
|
||||
"formatMoney": mf.fmt_money,
|
||||
"formatLineMoney": mf.fmt_line_money,
|
||||
"formatPrice": mf.fmt_price,
|
||||
}
|
||||
|
||||
|
||||
def _ts() -> dict:
|
||||
proc = subprocess.run(
|
||||
["node", os.path.join(HERE, "helper_588_2_money_fmt.cjs")],
|
||||
input=json.dumps(CASES, ensure_ascii=False),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
check=True,
|
||||
)
|
||||
return json.loads(proc.stdout)
|
||||
|
||||
|
||||
def _py_value(case):
|
||||
if case is None:
|
||||
return None
|
||||
return Decimal(case["num"]) if "num" in case else case["text"]
|
||||
|
||||
|
||||
def test_python_ts_same_text():
|
||||
ts_out = _ts()
|
||||
for name, py_fn in PAIRS.items():
|
||||
for case, ts_text in zip(CASES, ts_out[name]):
|
||||
assert py_fn(_py_value(case)) == ts_text, (name, case)
|
||||
|
||||
|
||||
def test_known_shapes():
|
||||
assert mf.fmt(Decimal("1.00005")) == "1.0001"
|
||||
assert mf.fmt(Decimal("0.00167")) == "0.00167"
|
||||
assert mf.fmt(Decimal("4")) == "4" # 율 — 「4 %」 (「4.0」 아님)
|
||||
assert mf.fmt_money(Decimal("124419.853")) == "124,420"
|
||||
assert mf.fmt_line_money(Decimal("69643.9394")) == "69,643.9"
|
||||
assert mf.fmt_line_money(Decimal("18064")) == "18,064" # 「.0」 꼬리 없음
|
||||
assert mf.fmt_price(Decimal("1846.39")) == "1,846.39" # 정수로 숨기지 않음
|
||||
assert mf.fmt_price(Decimal("22.9")) == "22.9"
|
||||
assert mf.fmt_money(Decimal("-0.4")) == "0"
|
||||
assert mf.fmt(None) == "" and mf.fmt("가") == "가"
|
||||
@@ -174,11 +174,14 @@ def test_상세_부품_길_부품상자():
|
||||
cover = _text(M02 / "M02_MasterTemplete_Sangeun_Cover.ts")
|
||||
assert "mountSangeunDetail(host, doc" in view and "createEmpty" not in view
|
||||
assert 'read: "/m02/logic-lib"' in detail and "setLogicApiPaths(LIB)" in detail
|
||||
assert "buildLabDetail(editor" in detail and "cover: { ho, est }" in detail
|
||||
assert (
|
||||
"buildLabDetail(editor" in detail
|
||||
and "cover: { ho, est, open: lineOpener(hoCtx) }" in detail
|
||||
)
|
||||
assert "buildCalc(calcHost" in detail and "openSangeunLinePicker(scope" in detail
|
||||
assert "if (mine !== seq || !alive) return" in detail # 최신 응답만
|
||||
# 724-1 — 단가산출 상세 = 공용 상세(Ho) · 고침 고리 = SK 덮개 · 옛 고침 표 걷음
|
||||
assert "...buildHo({" in detail and 'scope: "m02",' in detail
|
||||
assert "...buildHo(hoCtx)" in detail and 'scope: "m02",' in detail
|
||||
assert "setLineCover(doc, r, c, opened());" in detail and "skCoverTable" not in detail
|
||||
ho = _text(ROOT / "M01_MasterData" / "M01_MasterData_UI_LogicLab_Ho.ts")
|
||||
assert "edit.more?.(e.idx, close)" in ho and '"EditedM02"' in ho
|
||||
|
||||
@@ -27,7 +27,10 @@ def test_합계_한_줄_바닥_고정():
|
||||
assert "position: sticky" in foot and "bottom: 0" in foot
|
||||
assert "repeat(4, minmax(0, 1fr))" in foot
|
||||
assert "overflow: clip" in STYLE # 메인 칸이 굴림 칸이면 바닥에 안 붙음
|
||||
assert 'className: "m01lab__foot"' in HO
|
||||
foot_ts = (M01 / "M01_MasterData_UI_LogicLab_Foot.ts").read_text(encoding="utf-8")
|
||||
assert 'className: "m01lab__foot"' in foot_ts # 748-3 두 보기가 같은 바닥 줄 부품
|
||||
assert "buildFoot(sums, calculated, total)" in HO and "buildFoot(sums, calculated)" in FX
|
||||
assert 'data-cols="3"' in STYLE and ".m01lab__ho-box + .m01lab__ho-box" in STYLE
|
||||
|
||||
|
||||
def test_공통_옵션은_모든_박스에_같이():
|
||||
|
||||
@@ -98,12 +98,14 @@ const hopyoText = (key: string, label: string, width?: number): DataColumn => ({
|
||||
label,
|
||||
width,
|
||||
});
|
||||
const hopyoMoney = (key: string, label: string, group: string): DataColumn => ({
|
||||
/** 단가 = 소수 셋까지 · 금액 = 소수 하나까지 — 끝 0 없음(`275,790` · `109,326.3`) */
|
||||
const hopyoMoney = (key: string, label: string, group: string, decimals: number): DataColumn => ({
|
||||
key,
|
||||
label,
|
||||
group: [group],
|
||||
kind: "number",
|
||||
decimals: 1,
|
||||
decimals,
|
||||
trim: true,
|
||||
width: 96,
|
||||
});
|
||||
|
||||
@@ -114,8 +116,8 @@ export const HOPYO_COLUMNS: DataColumn[] = [
|
||||
{ key: "qty", label: "수량", kind: "number", decimals: 4, trim: true, width: 76 },
|
||||
hopyoText("unit", "단위", 56),
|
||||
...HOPYO_COSTS.flatMap(([key, label]) => [
|
||||
hopyoMoney(`${key}_price`, "단가", label),
|
||||
hopyoMoney(key, "금액", label),
|
||||
hopyoMoney(`${key}_price`, "단가", label, 3),
|
||||
hopyoMoney(key, "금액", label, 1),
|
||||
]),
|
||||
hopyoText("note", "비고", 120),
|
||||
hopyoText("tag", "구분", 56),
|
||||
|
||||
Reference in New Issue
Block a user