Files
vexplor_dev/backend-node/src/services/cuttingPlanService.ts
DDD1542 28c1c8c029 feat: Add cutting plan management for COMPANY_30
- Cutting optimization (Guillotine FFDH) with mixed/homogeneous modes
- Remnant management with persistence (cutting_plan_sheet.remnants JSONB)
- Work instruction creation linked via batch_no/cutting_plan_id

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 14:03:45 +09:00

386 lines
18 KiB
TypeScript

/**
* 절단계획 서비스
* - 원자재 / 수주 / 품목 조회
* - 절단계획 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<string> {
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<number, number>(); // 프론트 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;
}