/* ============================================================================= * M02_MasterTemplete_Hopyo_Api.ts * 일위대가 실무자료(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"; import type { Workbook } from "../A00_Common/spreadsheet/spreadsheet"; import { call, getActiveLayer, getActiveOwner, type Layer } from "./M02_MasterTemplete_Api_Fetch"; import type { LaborFixed, LaborRef } from "./M02_MasterTemplete_Labor_Calc"; import type { MatMarks } from "./M02_MasterTemplete_Labor_Api"; /** 층 · 주인(null = 자기 층) */ export interface HopyoScope { layer: Layer; owner: string | null; } export interface HopyoFile { 키: string; 파일: string; 올린때: string; 올린이: { id: number; 이름: string | null } | null; 시트: string; 호표수: number; 어긋남: number; } export type Money = Record<"계" | "노무비" | "재료비" | "경비", number | null>; export interface HopyoBrief { 번: number; 이름: string; 규격: string; 단위: string; 금액: Money; /** [체크 제안 수, 줄 수] */ 맞춤: [number, number]; /** 연결할 줄 수(% · 노임 · 뺌 밖 · 358-3) · 연결된 줄 수(그중 체크 제안 — 확인 창과 한 규칙) */ 연결할?: number; 연결된?: number; /** 일위대가 시트 행 [시작, 끝](엑셀 1부터 · 329 · 옛 파일 없음) */ 행?: [number, number]; } /** 엑셀 보기 창 목록 한 줄 — 호표 · 단가산출 블록 행 범위 */ export interface HopyoSpan { 번: number; 이름: string; 행: [number, number]; } /** 엑셀 보기 창 격자(329) — 시트 「일위대가」 · 「단가산출」 */ export interface HopyoGrid { 책: Workbook; 호표: HopyoSpan[]; 단산: HopyoSpan[]; } export interface HopyoView extends HopyoFile { 검산: { 어긋남: string[]; 총괄표: number }; 호표: HopyoBrief[]; 맞춤: [number, number]; /** 파일 전체 연결할 · 연결된 줄 수(358-3) */ 연결할?: number; 연결된?: number; /** 뒤 숫자 판정 셈(끝나면 맞춤 수가 바뀜) */ 셈?: HopyoCalc; } /** 후보가 단가산출 근거와 맞는 것(330-2) — 맞은 조항 글 · 겹친 직종 · 단위 같음 · 점수 · 까닭 꼬리표 */ export interface HopyoBasisHit { 조항: string[]; 직종: string[]; 단위: boolean; 점수: number; 까닭: string; } /** 단산 줄 근거(330-1) — 단가산출 블록의 출처([책, 번호]) · 직종 · 엑셀 행 [시작, 끝] */ export interface HopyoLineBasis { 출처: [string, string][]; 직종: string[]; 행: [number, number]; } /** 후보 · 고른 대상 글 — 로직 · 자재 · 다른 호표 */ export interface HopyoTarget { 로직?: string; 자재?: string; 호표?: number; /** 「전국」 자리표(361-1) — 지역 변형만 있는 자재 · 키 · 단가 없음 */ 전국?: boolean; /** 729-2 사용자 이음 — 노임 LB · 기계 EQ · % 줄 밑수(엑셀 숫자 + 이음 · 하나만) */ 노임?: string; 기계?: string; 비율?: LinkBase; /** 같은 품목 지역 · 관급/사급 변형(361-1 · 고르기 창이 접음) */ 변형?: boolean; 이름?: string; 규격?: string; 단위?: string | null; 점수?: number; /** 근거 맞춤(330 · 옛 파일 · 근거 없는 줄은 없음) */ 근거?: HopyoBasisHit; /** 후보 판정(338) — 맞음 · 다른 점 · 후보 */ 판정?: "맞음" | "다른 점" | "후보"; 글?: string; /** 묶음 표시(358-1 · 로직 후보만) — 품명 뜻 낱말 겹침 · 엑셀 출처 조항 같음(≥ 0.9) · 책(산림품셈 · 건설품셈) */ 뜻겹침?: boolean; 출처같음?: boolean; /** 745-1 미리 고름 점 — 「다른 점」 1위 + 출처 같음(서버) · 자동 확정 아님 · 만들기는 사용자가 누름 */ 미리?: boolean; 책?: string; } /** 맞춤 대상 하나(338 · 로직 여럿 가능) — 배 = 환산 × 몫 · 우리 수량 = 엑셀 수량 × 배 */ export interface HopyoPart extends HopyoTarget { 입력값: Record; 못읽음?: string[]; /** 칸마다 값 출처 — 별칭 · 블록 · 규격 · 자동값 */ 출처?: Record; 환산?: { 배: number | null; 갈래: string } | null; 몫?: number | null; 배: number | null; /** 블록 절 제목(조합 덧 로직) */ 어디?: string; /** 자재 세 칸(361-2) — 만들기에 적힐 값(서버 `Material.marks`) */ 세칸?: MatMarks; } /** 품 묶음 — 직종 품 · 장비 시간 · 자재 수량 */ export interface HopyoQty { 인력?: Record; 장비?: { 갈래: string | string[]; 시간: number }[]; 자재?: { 이름: string; 수량: number }[]; } export type HopyoState = "맞음" | "별칭" | "다른 점" | "후보" | "없음" | "뺌" | "호표" | "%" | "노임"; export interface HopyoMatch { 열쇠: string; 상태: HopyoState; /** 처음 체크 제안(확인 대기 — 들어감 아님) */ 체크: boolean; 대상: HopyoPart[]; 후보?: HopyoTarget[]; /** 이름 닮은 로직 다섯(358-1 · 옛 324 판 그대로 · 숫자 판정 차례와 따로 · 로직 줄만) */ 이름후보?: HopyoTarget[]; 글?: string; 꼬리표?: string[]; 품?: { 엑셀: HopyoQty; 우리: HopyoQty; 같음: Record }; 갈리는칸?: string[]; 별칭?: { 층: Layer; 주인: string | null; 규격다름?: boolean; 근거다름?: boolean }; 뺌?: string; /** 739-1 내 제안(승인 전 회사 · 개인 층 장부 짝) — 자동 아님 · 고르기 창 맨 위 */ 제안?: { 갈래: string; 짝: LedgerPair[]; 대상: HopyoPart[]; 층: Layer; 때: string | null }; } /** 숫자 판정 셈 — 끝 · 한 블록 · 모두 · 까닭(우리 로직 준비 중 등) */ export interface HopyoCalc { 끝: boolean; 한: number; 모두: number; 까닭?: string; } export interface HopyoLine { 차례: number; 이름: string; 규격: string; 수량: number | null; 단위: string; 갈래: string; 단가: Money; 금액: Money; 비고: string; 맞춤: HopyoMatch; /** 단가산출 근거(330 · 「단산N참조」 줄만) */ 근거?: HopyoLineBasis; } export interface HopyoOne extends HopyoFile { 호표: HopyoBrief & { 줄: HopyoLine[]; /** 직접 줄 호표 통째 제안(노임 · 장비를 바로 적은 호표) */ 통째?: { 대상: HopyoPart[]; 글?: string; 체크: boolean } | null; 셈?: HopyoCalc; /** 만들 층에 이 호표로 만든 사전조합 */ 만든?: { 키: string; 확인: boolean; /** 705-3 — 품셈으로 넣은 엑셀 줄 차례(옛 것 = null) · 고정 줄 수 */ 품셈?: number[] | null; 고정?: number; } | null; }; } /** 미리 셈(저장 안 함) — 고른 대상 · 옵션으로 우리 품 */ export interface HopyoGuess { 대상: HopyoPart[]; 품?: HopyoMatch["품"]; 글?: string; } /** 만들기 몸 대상 하나 — 로직 · 자재 · 호표 중 하나(금액 · 품 칸 없음 — 서버가 셈) */ export interface BuildTarget { 로직?: string; 자재?: string; 호표?: number; /** 「전국」 자재(361-1) — 이름 · 규격 · 단위는 서버가 줄에서 다시 셈 */ 전국?: boolean; /** 729-2 사용자 이음(하나만 · 입력값 없음) */ 노임?: string; 기계?: string; 비율?: LinkBase; 입력값?: Record; 배: number; 몫?: number | null; } /** % 줄 밑수 비목(729-2) */ export type LinkBase = "노무비" | "재료비" | "경비"; /** 단가산출 기초 후보(729-1 고름 대기) — 판정 · 글 */ export interface SangeunCand { 로직: string; 이름: string; 단위: string; 판정: string; 글: string; /** 729-4 — 기준단위 ↔ 엑셀 단위 성격 다름 = 환산 계수 필요(가 1 = 나 몇) */ 계수?: { 가: string; 나: string; 갈래: string } | null; /** 746-1 — 그 기초로 견준 줄 비교(앞 셋) · null = 견줌 못함(단위 성격 다름) */ 줄비교?: SangeunCompare | null; } /** 줄 비교 꼴(746-1) */ export type CompareLook = "같음" | "품다름" | "엑셀만" | "기초만"; /** 고름 대기 후보 줄 비교(746-1) — 줄마다 엑셀 · 기초 1 단위 품 */ export interface SangeunCompare { 줄: { 갈래: "인력" | "장비" | "자재"; 이름: string; 규격?: string; 단위?: string; 엑셀: number | null; 기초: number | null; 꼴: CompareLook; }[]; 수: Record; 못옮김: SangeunLost[]; 환산?: unknown; } /** M01 요소 후보(746-1) — 못옮김 · 안 이은 기계 줄 · 고르면 `{노임|기계|자재: 키}` 로 이음 */ export interface ElemCand { 키: string; 갈래: "노임" | "기계" | "자재"; 이름: string; 규격?: string; 단위?: string; 단가?: number | null; } /** 요소 후보인가(기초 후보 `로직` 과 가름) */ export const isElem = (c: unknown): c is ElemCand => typeof c === "object" && c !== null && "키" in c && "갈래" in c; /** 안 이은 줄(729-2) — 만들면 남을 엑셀 줄 · 단가산출 줄 후보 = 고름 대기 기초 후보 */ export interface HopyoLeft { 차례: number; 이름: string; 규격: string; 단위: string; 수량: number | null; 갈래: string; 까닭: string; 후보: (SangeunCand | ElemCand)[]; /** 729-4 — 못옮김 남은 SK 를 쓰는 줄 = 그 블록 · SK · 못옮김(잇기 = [단가산출 만들기]) */ 단산?: number; SK?: string; 못옮김?: SangeunLost[]; } /** 옮기지 못한 엑셀 줄(729-4) — M01 요소로 이으면 SK 더한줄 · id = 「갈래:이름」 */ export interface SangeunLost { id: string; 이름: string; 규격?: string; 단위?: string; 수량?: number | null; 갈래: "자재" | "기계" | "노임"; 글?: string; 후보?: ElemCand[]; } /** 못옮김 잇기 한 줄(729-4) — 이음 키(LB · EQ · MT) 또는 뺌 까닭 */ export type SangeunLink = { id: string; 뺌?: string } & Partial< Record<"노임" | "기계" | "자재", string> >; /** [단가산출 만들기](729-1) 답 — 만듦 = 기초 · SK · 고름 대기 = 블록마다 후보 */ export interface SangeunBuilt { 블록: number; 만듦: { 번: number; 이름: string; 키: string; 갈래: "기초" | "SK"; 못옮김?: SangeunLost[] }[]; 그대로: number; 기초: number; SK: number; 고름대기: { 번: number; 이름: string; 단위: string; 갈래: string; 까닭: string; 후보: SangeunCand[]; }[]; 별칭: number; /** 729-5 — 이미 만든(그대로) SK 중 못옮김 남은 것 */ 남은못옮김?: { 번: number; 이름: string; 키: string; 못옮김: SangeunLost[] }[]; /** 733-1 — 이 층 같은 내용 SK 를 씀(새로 안 만듦) */ 같은것씀?: { 번: number; 키: string }[]; /** 733-1 — 위 층(회사 · 마스터)에 같은 내용 SK(알림만 · 연결 안 함) */ 위층같음?: { 번: number; 층: string; 주인: string | null; 키: string }[]; } /** 고른 기초(블록 번 → 로직) · 729-4 잇기 */ export type SangeunPick = Record< number, { 로직?: string; 입력값?: Record; 환산?: { 계수?: number }; /** 그 블록 잇기 전부(부를 때마다 다시 셈 · 앞 잇기 안 남음) */ 잇기?: SangeunLink[]; } >; /** 확인한 줄 — 대상 목록 또는 뺌 까닭 */ export interface BuildLine { 차례: number; 대상?: BuildTarget[]; 뺌?: "우리에 없음" | "안 씀"; } export interface HopyoMissing { 차례: number; 이름: string; 규격: string; 수량: number | null; 단위: string; 금액: number | null; 까닭: string; } export interface HopyoBuilt { 참조: LaborRef; 이름: string; 다시씀: boolean; 빠진줄: HopyoMissing[]; 비교: { 엑셀: number | null; 우리: number | null; 차이: number | null; /** 705-3 — 엑셀 끝수 규칙으로 같음 */ 같음?: boolean; 까닭: string | null; }; 장부?: { 씀: number }; /** 705-3 — 엑셀 줄 갈래 수 */ 줄수?: HopyoLineCount; } /** 705-3 줄 갈래 수 — 품셈 · 고정 · 호표(하위 참조) · 빠짐 · 이음(저절로) · 이음후보(단가 다름) */ export interface HopyoLineCount { 품셈: number; 고정: number; 호표: number; 빠짐: number; 이음: number; 이음후보: number; } /** [모두 고정으로 들여오기] 결과(705-3) */ export interface HopyoBuiltAll { 호표: number; 줄: number; 품셈: number; 고정: number; 호표참조: number; 빠짐: number; 이음: number; 이음후보: number; 같음: number; 그대로: number; 다름: { 번: number; 이름: string; 엑셀: number | null; 우리: number | null; 차이: number | null; }[]; 실패: { 번: number; 까닭: string }[]; /** 만든 · 그대로 둔 사전조합(705-2 · 다시 잇기) */ 만듦?: { 번: number; 이름: string; 키: string }[]; } /** [로직 추가] 「실무 호표」 줄 하나 — 확인 = 문서 층에 확인된 사전조합 */ export interface HopyoPickRow extends Omit { 층: Layer; 주인: string | null; 키: string; 파일: string; 확인?: LaborRef | null; } /** 별칭 장부 짝 하나 */ export interface LedgerPair { 로직?: string; 자재?: string; 입력값: Record; 배: 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; /** 739-1 M01 승인 상태 — 마스터 줄 = 승인 */ 상태?: "승인" | "대기" | "반려"; } const enc = encodeURIComponent; /** 지금 화면 층(구조물 조합 층) */ export const pageScope = (): HopyoScope => ({ layer: getActiveLayer(), owner: getActiveOwner() }); const at = (scope: HopyoScope, path: string): string => { const who = scope.owner && scope.layer !== "system" ? `owner=${enc(scope.owner)}` : ""; const join = path.includes("?") ? "&" : "?"; return `/layers/${scope.layer}${path}${who ? join + who : ""}`; }; export const listHopyo = async (scope: HopyoScope): Promise => (await call<{ 줄: HopyoFile[] }>(at(scope, "/hopyo"))).줄; export const readHopyo = (scope: HopyoScope, key: string): Promise => call(at(scope, `/hopyo/${enc(key)}`)); /** 격자 — 없으면 404 「격자 없음 — 다시 올리면 보임」(옛 파일) */ export const readHopyoGrid = (scope: HopyoScope, key: string): Promise => call(at(scope, `/hopyo-grid/${enc(key)}`)); /** 호표 하나 — `to` = 만들 층(호표 참조 줄 체크 · 만든 · 없으면 파일 층) */ export const readHopyoOne = ( scope: HopyoScope, key: string, no: number, to?: HopyoScope, ): Promise => { 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, 대상: BuildTarget[], ): Promise => call(at(scope, `/hopyo/${enc(key)}/${no}/guess`), { method: "POST", body: JSON.stringify({ 차례, 대상 }), }); export const readLedger = async (scope: HopyoScope): Promise => (await call<{ 줄: LedgerRow[] }>(at(scope, "/hopyo-ledger"))).줄; /** 별칭 장부 [저장] — 고친 줄 · null(지움) · 이 층만 */ export const saveLedger = async ( scope: HopyoScope, 줄: Record, ): Promise => ( await call<{ 줄: LedgerRow[] }>(at(scope, "/hopyo-ledger"), { method: "PUT", body: JSON.stringify({ 줄 }), }) ).줄; /** [끝내기] · 화면 떠남(743-1) — `keepalive` = 페이지를 닫아도 요청은 감 */ export const deleteHopyo = (scope: HopyoScope, key: string, keepalive = false): Promise => call(at(scope, `/hopyo/${enc(key)}`), { method: "DELETE", keepalive }); /** [확인하고 사전조합 만들기] — 확인한 줄만 · 직접 줄 호표 = `통째` → `target` 층 사전조합(파일 층 = `from`) */ export const buildHopyo = ( target: HopyoScope, from: { 층: Layer; 주인: string | null; 키: string; 번: number }, 줄: BuildLine[], 통째?: { 대상: BuildTarget[] } | null, ): Promise => call(at(target, "/hopyo-build"), { method: "POST", body: JSON.stringify(통째 ? { ...from, 통째 } : { ...from, 줄 }), }); /** [모두 고정으로 들여오기](705-3) — 시트 호표 모두 · 숫자는 서버가 파일에서 */ export const buildHopyoAll = ( target: HopyoScope, from: { 층: Layer; 주인: string | null; 키: string }, ): Promise => call(at(target, "/hopyo-build-all"), { method: "POST", body: JSON.stringify(from) }); /** 안 이은 줄 미리 보기(729-2 · 저장 없음) — 몸 = 만들기 몸 */ export const leftHopyo = ( target: HopyoScope, from: { 층: Layer; 주인: string | null; 키: string; 번: number }, 줄: BuildLine[], ): Promise<{ 남은줄: HopyoLeft[]; 수: number }> => call(at(target, "/hopyo-unlinked"), { method: "POST", body: JSON.stringify({ ...from, 줄 }) }); /** 409 몸의 남은 줄(729-2 · 만들기 막힘) — 아니면 null */ export const leftOf = (error: unknown): HopyoLeft[] | null => { const got = (error as { detail?: { 남은줄?: unknown } } | null)?.detail?.남은줄; return Array.isArray(got) ? (got as HopyoLeft[]) : null; }; /** [단가산출 만들기](729-1) — 산근 블록마다 기초 · SK · 고름 대기 · `바탕` = 고른 기초 */ export const makeSangeun = ( scope: HopyoScope, key: string, 바탕?: SangeunPick, ): Promise => call(at(scope, `/hopyo/${enc(key)}/sangeun`), { method: "POST", body: JSON.stringify(바탕 ? { 바탕 } : {}), }); export const listHopyoPicks = async (scope: HopyoScope): Promise => (await call<{ 줄: HopyoPickRow[] }>(at(scope, "/hopyo-picks"))).줄; /** 엑셀 올리기 — 서버 까닭 글 그대로(시트 없음 · 20 MB · 엉터리) */ export async function uploadHopyo(scope: HopyoScope, file: File): Promise { const form = new FormData(); form.append("file", file); const response = await fetch(`${API_BASE_URL}/m02${at(scope, "/hopyo")}`, { method: "POST", credentials: "include", body: form, }); const body = (await response.json().catch(() => ({}))) as { detail?: unknown } & HopyoFile; if (!response.ok) throw new Error(typeof body.detail === "string" ? body.detail : `HTTP ${response.status}`); return body; } /** 엑셀 붙여넣기 열 짝(705-2) — 칸 차례(0부터) · 노 · 재 · 경 = 단가 칸 · 금액 = 합계 금액 칸 */ export type PasteCols = Partial< Record<"품명" | "규격" | "수량" | "단위" | "금액" | "노" | "재" | "경", number> >; /** 붙여넣기 답 — 물음(열 짝 짐작 + 보기) · 줄(고정 줄 · 문서에 넣음) · 만듦(호표마다 사전조합) */ export type PasteResult = | { 갈래: "물음"; 열: PasteCols; 짐작: boolean; 보기: string[][] } | { 갈래: "줄"; 열: PasteCols; 줄: { 고정: LaborFixed; 별칭: string | null; 메모: string; 수량: { 손: number | null } }[]; 엑셀: number; 이음: number; 이음후보: number; } | ({ 갈래: "만듦"; 열: PasteCols; 키: string } & HopyoBuiltAll); /** [Ctrl+V](705-2) — 붙인 글 그대로(숫자는 서버가 글에서) */ export const pasteHopyo = ( scope: HopyoScope, 글: string, 열: PasteCols | null, opt: { 줄만?: boolean; 있음?: string[] } = {}, ): Promise => call(at(scope, "/hopyo-paste"), { method: "POST", body: JSON.stringify({ 글, ...(열 ? { 열 } : {}), ...opt }), }); /** 다시 잇기 표 한 줄(705-2) — 붙인 단가 · 이은 단가(지금) · 후보 */ export interface RelinkRow { 키: string | null; 줄: string; 이름: string; 규격: string; 단위: string; 수량: number | null; 붙인: Record<"노무비" | "재료비" | "경비", number>; 이음: Record<"노임" | "자재", string | null>; 지금: Partial>; 후보: { 비목: "노임" | "자재"; 키: string; 이름: string; 규격: string; 단위: string; 단가: number | null; }[]; } /** 바꿈 한 줄 — 값 null = 이음 풂 */ export interface RelinkChange { 키: string; 줄: string; 노임?: string | null; 자재?: string | null; } export const relinkRows = async ( scope: HopyoScope, body: { 키?: string[]; 문서?: unknown }, ): Promise => ( await call<{ 줄: RelinkRow[] }>(at(scope, "/hopyo-relink/rows"), { method: "POST", body: JSON.stringify(body), }) ).줄; export const relinkApply = ( scope: HopyoScope, 바꿈: RelinkChange[], ): Promise<{ 바뀜: number; 문서: { 키: string; 이름: string; 전: number | null; 후: number | null }[]; 되돌림: RelinkChange[]; }> => call(at(scope, "/hopyo-relink"), { method: "POST", body: JSON.stringify({ 바꿈 }) }); /** 규격 묶음 제안(704-4) — 이름 · 단위 · 줄 구성 같고 규격만 다른 호표(연 호표 먼저) */ export const hopyoSiblings = ( scope: HopyoScope, key: string, no: number, ): Promise<{ 묶음: { 번: number; 규격: string; 열: string }[]; 이름: string | null }> => call(at(scope, `/hopyo/${enc(key)}/${no}/siblings`)); /** [구조물 조합으로](704-4) — 확인 창 몸 그대로 → 이 층 구조물 조합 항목(사전조합 안 만듦) · * `extra` = 같은 내용 409 뒤 다시 보냄(733-2 · 씀 = 그 항목 · 답 `씀` · 새로 = 그래도 새로) */ export const buildHopyoItem = ( target: HopyoScope, from: { 층: Layer; 주인: string | null; 키: string; 번: number }, 줄: BuildLine[], 통째: { 대상: BuildTarget[] } | null, 묶음: number[], 이름: string, extra: { 씀?: string; 새로?: boolean } = {}, ): Promise<{ key: string; 열: string; 이름: string; 줄수?: number; 열수?: number; 씀?: boolean }> => call(at(target, "/hopyo-item"), { method: "POST", body: JSON.stringify({ ...from, ...(통째 ? { 통째 } : { 줄 }), 묶음, 이름, ...extra }), });