- 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>
386 lines
18 KiB
TypeScript
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;
|
|
}
|