/** * 절단계획 서비스 * - 원자재 / 수주 / 품목 조회 * - 절단계획 CRUD (헤더 + 품목 + 원판 + 피스 좌표) */ import { getPool } from "../database/db"; // ───────────────────────────────────────────────────────── // 조회: 원자재 (item_info.division = 'CAT_DIV_RAW_MAT') // ───────────────────────────────────────────────────────── export async function getMaterials(companyCode: string, cutType: string) { const pool = getPool(); const q = ` SELECT ii.id, ii.item_number, ii.item_name, COALESCE(ii.width::numeric, 0) AS width, COALESCE(ii.height::numeric, 0) AS height, COALESCE(ii.thickness::numeric, 0) AS thickness, ii.size, ii.material, COALESCE(inv.stock, 0) AS stock, COALESCE(inv.safety, 0) AS safety_stock FROM item_info ii LEFT JOIN ( SELECT item_code, SUM(COALESCE(NULLIF(current_qty,'')::numeric, 0)) AS stock, SUM(COALESCE(NULLIF(safety_qty,'')::numeric, 0)) AS safety FROM inventory_stock WHERE company_code = $1 GROUP BY item_code ) inv ON inv.item_code = ii.item_number WHERE ii.company_code = $1 AND ii.division = 'CAT_DIV_RAW_MAT' AND COALESCE(ii.status,'active') <> 'deleted' ORDER BY ii.item_name `; const r = await pool.query(q, [companyCode]); return r.rows.map((row: any) => ({ ...row, stock: Number(row.stock) || 0, safety_stock: Number(row.safety_stock) || 0, length: cutType === "length" ? Number(row.width || 0) : 0, unit: cutType === "length" ? "개" : "장", })); } // ───────────────────────────────────────────────────────── // 조회: 품목 검색 (완제품) // ───────────────────────────────────────────────────────── export async function searchItems(companyCode: string, keyword?: string) { const pool = getPool(); const params: any[] = [companyCode]; let where = `company_code = $1 AND division <> 'CAT_DIV_RAW_MAT'`; if (keyword) { params.push(`%${keyword}%`); where += ` AND (item_name ILIKE $2 OR item_number ILIKE $2)`; } const q = ` SELECT id, item_number, item_name, size, COALESCE(width::numeric,0) AS width, COALESCE(height::numeric,0) AS height, COALESCE(thickness::numeric,0) AS thickness FROM item_info WHERE ${where} ORDER BY item_name LIMIT 200 `; const r = await pool.query(q, params); return r.rows; } // ───────────────────────────────────────────────────────── // 조회: 수주 소스 — 페이지네이션 + 계획/출하로 넘어간 건 제외 옵션 // ───────────────────────────────────────────────────────── export async function getOrders( companyCode: string, opts?: { from?: string; to?: string; page?: number; limit?: number; excludeInPlan?: boolean; keyword?: string } ) { const pool = getPool(); const params: any[] = [companyCode]; let where = `so.company_code = $1 AND COALESCE(so.part_name,'') <> ''`; if (opts?.from) { params.push(opts.from); where += ` AND so.order_date >= $${params.length}`; } if (opts?.to) { params.push(opts.to); where += ` AND so.order_date <= $${params.length}`; } if (opts?.keyword) { params.push(`%${opts.keyword}%`); where += ` AND (so.order_no ILIKE $${params.length} OR so.part_name ILIKE $${params.length} OR so.part_code ILIKE $${params.length})`; } // 생산계획/출하계획으로 넘어간 수주만 제외 (절단계획은 본 화면에서 관리하므로 보여줌 → 배치번호로 식별) if (opts?.excludeInPlan) { where += ` AND NOT EXISTS (SELECT 1 FROM production_plan_mng pp WHERE pp.order_no = so.order_no AND pp.company_code = so.company_code) AND NOT EXISTS (SELECT 1 FROM shipment_plan sp WHERE sp.sales_order_id = so.id AND sp.company_code = so.company_code) `; } // 총 건수 const cntQ = `SELECT COUNT(*)::int AS total FROM sales_order_mng so WHERE ${where}`; const cntR = await pool.query(cntQ, params); const total = cntR.rows[0]?.total || 0; // 페이지네이션 const page = Math.max(1, opts?.page || 1); const limit = Math.min(1000, Math.max(1, opts?.limit || 100)); const offset = (page - 1) * limit; const q = ` SELECT so.order_no, so.order_date, so.due_date, so.partner_id, so.part_code, so.part_name, so.spec, so.material, COALESCE(so.order_qty::numeric,0) AS order_qty, COALESCE(so.balance_qty::numeric,0) AS balance_qty, so.status, ii.id AS item_id, ii.item_name AS item_name, cpm.id AS batch_id, cpm.plan_no AS batch_no FROM sales_order_mng so LEFT JOIN item_info ii ON ii.item_number = so.part_code AND ii.company_code = so.company_code LEFT JOIN cutting_plan_item cpi ON cpi.src_no = so.order_no LEFT JOIN cutting_plan_mng cpm ON cpm.id = cpi.plan_id AND cpm.company_code = so.company_code WHERE ${where} ORDER BY cpm.plan_no DESC NULLS LAST, so.order_date DESC NULLS LAST, so.order_no DESC LIMIT ${limit} OFFSET ${offset} `; const r = await pool.query(q, params); return { rows: r.rows, total, page, limit }; } // ───────────────────────────────────────────────────────── // 조회: 계획 목록 // ───────────────────────────────────────────────────────── export async function getPlans( companyCode: string, filter: { from?: string; to?: string; planNo?: string; status?: string } ) { const pool = getPool(); const params: any[] = [companyCode]; let where = `company_code = $1`; if (filter.from) { params.push(filter.from); where += ` AND plan_date_from >= $${params.length}`; } if (filter.to) { params.push(filter.to); where += ` AND plan_date_to <= $${params.length}`; } if (filter.planNo) { params.push(`%${filter.planNo}%`); where += ` AND plan_no ILIKE $${params.length}`; } if (filter.status) { params.push(filter.status); where += ` AND status = $${params.length}`; } const q = ` SELECT id, plan_no, plan_date_from, plan_date_to, cut_type, pack_mode, calc_mode, status, partner_id, total_sheets, util_rate, created_date FROM cutting_plan_mng WHERE ${where} ORDER BY id DESC LIMIT 200 `; const r = await pool.query(q, params); return r.rows; } // ───────────────────────────────────────────────────────── // 조회: 계획 상세 (헤더 + 품목 + 원판 + 피스) // ───────────────────────────────────────────────────────── export async function getPlanDetail(companyCode: string, planId: number) { const pool = getPool(); const hdr = await pool.query( `SELECT * FROM cutting_plan_mng WHERE id = $1 AND company_code = $2`, [planId, companyCode] ); if (!hdr.rows.length) return null; // item_info JOIN으로 item_number/item_name 복원. item_id가 null인 옛 데이터는 src_no → sales_order_mng.part_code로 fallback. const items = await pool.query( `SELECT cpi.*, COALESCE(ii_direct.item_number, ii_order.item_number) AS item_number, COALESCE(ii_direct.item_name, ii_order.item_name, cpi.item_name) AS item_name_resolved FROM cutting_plan_item cpi LEFT JOIN item_info ii_direct ON ii_direct.id::text = cpi.item_id LEFT JOIN sales_order_mng so ON so.order_no = cpi.src_no AND so.company_code = $2 LEFT JOIN item_info ii_order ON ii_order.item_number = so.part_code AND ii_order.company_code = $2 WHERE cpi.plan_id = $1 ORDER BY cpi.seq, cpi.id`, [planId, companyCode] ); const sheets = await pool.query(`SELECT * FROM cutting_plan_sheet WHERE plan_id = $1 ORDER BY sheet_no`, [planId]); const sheetIds = sheets.rows.map((s: any) => s.id); let placements: any[] = []; if (sheetIds.length) { const p = await pool.query( `SELECT * FROM cutting_plan_placement WHERE sheet_id = ANY($1::int[]) ORDER BY sheet_id, placement_order, id`, [sheetIds] ); placements = p.rows; } return { header: hdr.rows[0], items: items.rows, sheets: sheets.rows, placements }; } // ───────────────────────────────────────────────────────── // 다음 계획번호 생성: CP-YYYY-NNNN // ───────────────────────────────────────────────────────── export async function nextPlanNo(companyCode: string): Promise { const pool = getPool(); const y = new Date().getFullYear(); const prefix = `CP-${y}-`; const r = await pool.query( `SELECT plan_no FROM cutting_plan_mng WHERE company_code=$1 AND plan_no LIKE $2 ORDER BY plan_no DESC LIMIT 1`, [companyCode, `${prefix}%`] ); let next = 1; if (r.rows.length) { const last = r.rows[0].plan_no as string; const tail = parseInt(last.substring(prefix.length), 10); if (!isNaN(tail)) next = tail + 1; } return `${prefix}${String(next).padStart(4, "0")}`; } // ───────────────────────────────────────────────────────── // 계획 저장 (신규 또는 업데이트) — 헤더+자식 전체 대체 // payload: { header, items, sheets, placements } // sheets[i].placements[j] 구조도 허용 (중첩형) // ───────────────────────────────────────────────────────── export async function savePlan(companyCode: string, userId: string, payload: any) { const pool = getPool(); const client = await pool.connect(); try { await client.query("BEGIN"); const h = payload.header || {}; let planId: number; if (h.id) { // UPDATE 헤더 const up = await client.query( `UPDATE cutting_plan_mng SET plan_no=$1, plan_date_from=$2, plan_date_to=$3, cut_type=$4, calc_mode=$5, pack_mode=$6, mat_item_id=$7, mat_item_id_2=$8, kerf=$9, margin=$10, min_remnant=$11, min_reuse=$12, partner_id=$13, status=$14, total_sheets=$15, total_pieces=$16, util_rate=$17, total_loss=$18, rotated_count=$19, remarks=$20, updated_date=NOW(), updated_by=$21 WHERE id=$22 AND company_code=$23 RETURNING id`, [ h.plan_no, h.plan_date_from || null, h.plan_date_to || null, h.cut_type || "area", h.calc_mode || "auto", h.pack_mode || "mixed", h.mat_item_id || null, h.mat_item_id_2 || null, h.kerf ?? 3, h.margin ?? 2, h.min_remnant ?? 100, h.min_reuse ?? 100, h.partner_id || null, h.status || "draft", h.total_sheets ?? null, h.total_pieces ?? null, h.util_rate ?? null, h.total_loss ?? null, h.rotated_count ?? null, h.remarks || null, userId, h.id, companyCode, ] ); if (!up.rows.length) throw new Error("계획을 찾을 수 없거나 권한이 없습니다"); planId = up.rows[0].id; // 자식 전체 삭제 후 재등록 await client.query(`DELETE FROM cutting_plan_placement WHERE sheet_id IN (SELECT id FROM cutting_plan_sheet WHERE plan_id=$1)`, [planId]); await client.query(`DELETE FROM cutting_plan_sheet WHERE plan_id=$1`, [planId]); await client.query(`DELETE FROM cutting_plan_item WHERE plan_id=$1`, [planId]); } else { // INSERT 헤더 const plan_no = h.plan_no || (await nextPlanNo(companyCode)); const ins = await client.query( `INSERT INTO cutting_plan_mng (company_code, plan_no, plan_date_from, plan_date_to, cut_type, calc_mode, pack_mode, mat_item_id, mat_item_id_2, kerf, margin, min_remnant, min_reuse, partner_id, status, total_sheets, total_pieces, util_rate, total_loss, rotated_count, remarks, created_by) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22) RETURNING id`, [ companyCode, plan_no, h.plan_date_from || null, h.plan_date_to || null, h.cut_type || "area", h.calc_mode || "auto", h.pack_mode || "mixed", h.mat_item_id || null, h.mat_item_id_2 || null, h.kerf ?? 3, h.margin ?? 2, h.min_remnant ?? 100, h.min_reuse ?? 100, h.partner_id || null, h.status || "draft", h.total_sheets ?? null, h.total_pieces ?? null, h.util_rate ?? null, h.total_loss ?? null, h.rotated_count ?? null, h.remarks || null, userId, ] ); planId = ins.rows[0].id; } // 품목 저장 — 원래 index (프론트 itemIdx) 를 id 매핑용으로 추적 // 한 PlanItem이 여러 수주를 합산했을 때(src_orders.length > 1)는 수주별 row 분리 저장 // (각 수주에 batch_no 연결되도록). 첫 row에만 piece 정보 + 나머지는 같은 메타 + qty 0. const itemIdMap = new Map(); // 프론트 itemIdx → DB id (첫 row) const items = payload.items || []; for (let i = 0; i < items.length; i++) { const it = items[i]; const srcList: (string | null)[] = Array.isArray(it.src_orders) && it.src_orders.length > 0 ? it.src_orders : [it.src_no || null]; let firstId: number | null = null; for (let k = 0; k < srcList.length; k++) { const orderNo = srcList[k]; const r = await client.query( `INSERT INTO cutting_plan_item (plan_id, seq, src_type, src_no, item_id, item_name, item_spec, width, height, length, qty, dir, color, placed_qty) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14) RETURNING id`, [ planId, it.seq ?? i + 1, it.src_type || (orderNo ? "order" : "manual"), orderNo, it.item_id || null, it.item_name || "", it.item_spec || null, it.width ?? null, it.height ?? null, it.length ?? null, k === 0 ? (it.qty ?? 0) : 0, // 첫 row에만 qty (배치/통계용) it.dir || "무관", it.color || null, k === 0 ? (it.placed_qty ?? 0) : 0, ] ); if (firstId === null) firstId = r.rows[0].id; } if (firstId !== null) itemIdMap.set(i, firstId); } // 원판(시트) + 배치 저장 — payload.sheets[i].placements[] 중첩형 지원 const sheets = payload.sheets || []; for (let s = 0; s < sheets.length; s++) { const sh = sheets[s]; const sr = await client.query( `INSERT INTO cutting_plan_sheet (plan_id, sheet_no, mat_item_id, mat_name, mat_width, mat_height, mat_length, util_rate, used_area, remnant_area, used_length, remnant_length, group_key, remnants) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14) RETURNING id`, [ planId, sh.sheet_no ?? s + 1, sh.mat_item_id || null, sh.mat_name || null, sh.mat_width ?? null, sh.mat_height ?? null, sh.mat_length ?? null, sh.util_rate ?? null, sh.used_area ?? null, sh.remnant_area ?? null, sh.used_length ?? null, sh.remnant_length ?? null, sh.group_key || null, Array.isArray(sh.remnants) ? JSON.stringify(sh.remnants) : null, ] ); const sheetId = sr.rows[0].id; const placements = sh.placements || []; for (let p = 0; p < placements.length; p++) { const pl = placements[p]; const planItemId = pl.plan_item_id ?? (pl.itemIdx != null ? itemIdMap.get(pl.itemIdx) : null); await client.query( `INSERT INTO cutting_plan_placement (sheet_id, plan_item_id, x, y, w, h, start_x, seg_length, rotated, placement_order) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)`, [ sheetId, planItemId || null, pl.x ?? null, pl.y ?? null, pl.w ?? null, pl.h ?? null, pl.start_x ?? null, pl.seg_length ?? null, !!pl.rotated, pl.placement_order ?? p, ] ); } } await client.query("COMMIT"); return { id: planId, plan_no: payload.header?.plan_no }; } catch (e) { await client.query("ROLLBACK"); throw e; } finally { client.release(); } } // ───────────────────────────────────────────────────────── // 삭제 // ───────────────────────────────────────────────────────── export async function deletePlan(companyCode: string, planId: number) { const pool = getPool(); const r = await pool.query( `DELETE FROM cutting_plan_mng WHERE id=$1 AND company_code=$2 RETURNING id`, [planId, companyCode] ); return r.rows.length > 0; }