diff --git a/backend-node/src/controllers/shippingOrderController.ts b/backend-node/src/controllers/shippingOrderController.ts index 0f875ec5..c5122ad1 100644 --- a/backend-node/src/controllers/shippingOrderController.ts +++ b/backend-node/src/controllers/shippingOrderController.ts @@ -58,7 +58,7 @@ export async function getList(req: AuthenticatedRequest, res: Response) { const query = ` SELECT si.*, - COALESCE(c.customer_name, si.partner_id, '') AS customer_name, + COALESCE(c.customer_name, '') AS customer_name, COALESCE( json_agg( json_build_object( diff --git a/backend-node/src/controllers/shippingPlanController.ts b/backend-node/src/controllers/shippingPlanController.ts index 06deaa0a..b772fb84 100644 --- a/backend-node/src/controllers/shippingPlanController.ts +++ b/backend-node/src/controllers/shippingPlanController.ts @@ -11,6 +11,32 @@ import { Response } from "express"; import { AuthenticatedRequest } from "../types/auth"; import { getPool } from "../database/db"; import { logger } from "../utils/logger"; +import { numberingRuleService } from "../services/numberingRuleService"; + +// shipment_plan_no 채번 — 채번규칙 우선, 없으면 SP-YYYYMMDD-NNN fallback +async function allocateShipmentPlanNo( + client: any, + companyCode: string, + planDate: string | null +): Promise { + try { + const rule = await numberingRuleService.getNumberingRuleByColumn( + companyCode, "shipment_plan", "shipment_plan_no" + ); + if (rule) { + return await numberingRuleService.allocateCode( + rule.ruleId, companyCode, { plan_date: planDate } + ); + } + } catch { /* 채번규칙 조회 실패 시 fallback */ } + const today = new Date().toISOString().split("T")[0].replace(/-/g, ""); + const seqRes = await client.query( + `SELECT COUNT(*) + 1 AS seq FROM shipment_plan WHERE company_code = $1 AND shipment_plan_no LIKE $2`, + [companyCode, `SP-${today}-%`] + ); + const seq = String(seqRes.rows[0].seq).padStart(3, "0"); + return `SP-${today}-${seq}`; +} // UUID 포맷 감지 (하이픈 포함 36자) const isUUID = (val: string) => @@ -95,7 +121,8 @@ async function getNormalizedOrders( dueDate: r.due_date || "", orderQty: Number(r.order_qty || 0), shipQty: Number(r.ship_qty || 0), - balanceQty: Number(r.balance_qty || 0), + // balance_qty가 NULL/0이면 orderQty - shipQty fallback (수주 등록 시 채워지지 않은 데이터 보정) + balanceQty: Number(r.balance_qty) || (Number(r.order_qty || 0) - Number(r.ship_qty || 0)), })); } else { // 마스터 기준 → 거래처 JOIN @@ -139,7 +166,8 @@ async function getNormalizedOrders( dueDate: r.due_date || "", orderQty: Number(r.order_qty || 0), shipQty: Number(r.ship_qty || 0), - balanceQty: Number(r.balance_qty || 0), + // balance_qty가 NULL/0이면 orderQty - shipQty fallback (수주 등록 시 채워지지 않은 데이터 보정) + balanceQty: Number(r.balance_qty) || (Number(r.order_qty || 0) - Number(r.ship_qty || 0)), })); } } @@ -451,10 +479,11 @@ export async function getAggregate(req: AuthenticatedRequest, res: Response) { .json({ success: false, message: "해당 수주를 찾을 수 없습니다" }); } - // 2) 품목별 그룹핑 + // 2) 품목별 그룹핑 — part_code가 비어있으면 detail/master ID 단위로 분리해 + // 품번 없는 직접 입력 품목들이 한 그룹으로 병합되지 않도록 한다 const partCodeMap = new Map(); for (const order of orders) { - const key = order.partCode || "UNKNOWN"; + const key = order.partCode || `__no_part__${order.detailId || order.masterId || Math.random()}`; if (!partCodeMap.has(key)) partCodeMap.set(key, []); partCodeMap.get(key)!.push(order); } @@ -637,12 +666,13 @@ export async function batchSave(req: AuthenticatedRequest, res: Response) { ); } + const planNo = await allocateShipmentPlanNo(client, companyCode, planDateValue); const insertRes = await client.query( `INSERT INTO shipment_plan - (company_code, detail_id, sales_order_id, plan_qty, plan_date, status, created_by) - VALUES ($1, $2, $3, $4, COALESCE($5::date, CURRENT_DATE), 'READY', $6) + (company_code, shipment_plan_no, detail_id, sales_order_id, plan_qty, plan_date, status, created_by) + VALUES ($1, $2, $3, $4, $5, COALESCE($6::date, CURRENT_DATE), 'READY', $7) RETURNING *`, - [companyCode, sourceId, detail.master_id, planQty, planDateValue, userId] + [companyCode, planNo, sourceId, detail.master_id, planQty, planDateValue, userId] ); savedPlans.push(insertRes.rows[0]); @@ -679,12 +709,13 @@ export async function batchSave(req: AuthenticatedRequest, res: Response) { ); } + const planNo = await allocateShipmentPlanNo(client, companyCode, planDateValue); const insertRes = await client.query( `INSERT INTO shipment_plan - (company_code, sales_order_id, plan_qty, plan_date, status, created_by) - VALUES ($1, $2, $3, COALESCE($4::date, CURRENT_DATE), 'READY', $5) + (company_code, shipment_plan_no, sales_order_id, plan_qty, plan_date, status, created_by) + VALUES ($1, $2, $3, $4, COALESCE($5::date, CURRENT_DATE), 'READY', $6) RETURNING *`, - [companyCode, masterId, planQty, planDateValue, userId] + [companyCode, planNo, masterId, planQty, planDateValue, userId] ); savedPlans.push(insertRes.rows[0]); diff --git a/backend-node/src/services/cuttingPlanService.ts b/backend-node/src/services/cuttingPlanService.ts index 97b85be7..411b43f2 100644 --- a/backend-node/src/services/cuttingPlanService.ts +++ b/backend-node/src/services/cuttingPlanService.ts @@ -30,11 +30,13 @@ export async function getMaterials(companyCode: string, cutType: string) { GROUP BY item_code ) inv ON inv.item_code = ii.item_number WHERE ii.company_code = $1 + -- division(관리품목) 컬럼에 '원자재' 또는 '구매관리' 라벨이 포함된 품목 매칭 + -- (구매관리 품목도 원판으로 취급하라는 사용자 요청, "구매관리,원자재" 다중 등록도 자동 매칭) AND EXISTS ( SELECT 1 FROM category_values cv WHERE cv.table_name = 'item_info' AND cv.column_name = 'division' - AND cv.value_label = '원자재' + AND cv.value_label IN ('원자재', '구매관리') AND cv.is_active = true AND (cv.company_code = $1 OR cv.company_code IS NULL OR cv.company_code = '') AND cv.value_code = ANY(string_to_array(REPLACE(ii.division, ' ', ''), ',')) diff --git a/frontend/app/(main)/COMPANY_9/production/cutting-plan/page.tsx b/frontend/app/(main)/COMPANY_9/production/cutting-plan/page.tsx index a2d05dd9..5b9b3873 100644 --- a/frontend/app/(main)/COMPANY_9/production/cutting-plan/page.tsx +++ b/frontend/app/(main)/COMPANY_9/production/cutting-plan/page.tsx @@ -93,6 +93,15 @@ export default function CuttingPlanPage() { const [checkedOrders, setCheckedOrders] = useState>(new Set()); const [loadingOrders, setLoadingOrders] = useState(false); + // 생산계획 탭 + const [productionPlans, setProductionPlans] = useState([]); + const [loadingProductionPlans, setLoadingProductionPlans] = useState(false); + const [checkedProductionPlans, setCheckedProductionPlans] = useState>(new Set()); + // 출하계획 탭 + const [shipmentPlans, setShipmentPlans] = useState([]); + const [loadingShipmentPlans, setLoadingShipmentPlans] = useState(false); + const [checkedShipmentPlans, setCheckedShipmentPlans] = useState>(new Set()); + // 설정 상태 const [cutType, setCutType] = useState("area"); const [calcMode, setCalcMode] = useState<"auto" | "manual">("auto"); @@ -158,44 +167,95 @@ export default function CuttingPlanPage() { const loadOrders = useCallback(async () => { setLoadingOrders(true); try { - const res = await apiClient.get("/cutting-plan/orders", { - params: { - from: dateFrom || undefined, - to: dateTo || undefined, - keyword: orderKeyword || undefined, - page: orderPage, - limit: orderLimit, - excludeInPlan: excludeInPlan ? "true" : undefined, - }, + // COMPANY_9는 마스터-디테일 구조 — sales_order_detail에서 직접 조회. + // 공통 /cutting-plan/orders는 sales_order_mng.part_name 필터 때문에 데이터가 누락됨. + const [detailRes, masterRes, planItemRes, planMngRes] = await Promise.all([ + apiClient.post(`/table-management/tables/sales_order_detail/data`, { + page: 1, size: 0, autoFilter: true, + }), + apiClient.post(`/table-management/tables/sales_order_mng/data`, { + page: 1, size: 0, autoFilter: true, + }), + apiClient.post(`/table-management/tables/cutting_plan_item/data`, { + page: 1, size: 0, autoFilter: true, + }).catch(() => ({ data: { data: { data: [] } } })), + apiClient.post(`/table-management/tables/cutting_plan_mng/data`, { + page: 1, size: 0, autoFilter: true, + }).catch(() => ({ data: { data: { data: [] } } })), + ]); + const details = detailRes.data?.data?.data || detailRes.data?.data?.rows || []; + const masters = masterRes.data?.data?.data || masterRes.data?.data?.rows || []; + const planItems = planItemRes.data?.data?.data || planItemRes.data?.data?.rows || []; + const planMngs = planMngRes.data?.data?.data || planMngRes.data?.data?.rows || []; + + const masterMap = new Map(masters.map((m: any) => [m.order_no, m])); + const planMngMap = new Map(planMngs.map((p: any) => [String(p.id), p])); + // src_no(=order_no) → 첫 plan 매칭 + const orderToPlan = new Map(); + for (const cpi of planItems) { + if (!cpi.src_no || orderToPlan.has(cpi.src_no)) continue; + const mng = planMngMap.get(String(cpi.plan_id)); + if (mng) orderToPlan.set(cpi.src_no, { batch_id: mng.id, batch_no: mng.plan_no }); + } + + // 검색 키워드/날짜 필터 + const kw = (orderKeyword || "").trim().toLowerCase(); + const fromDate = dateFrom || ""; + const toDate = dateTo || ""; + + const filtered = details.filter((d: any) => { + const m = masterMap.get(d.order_no) || {}; + const od = m.order_date ? String(m.order_date).substring(0, 10) : ""; + if (fromDate && (!od || od < fromDate)) return false; + if (toDate && (!od || od > toDate)) return false; + if (excludeInPlan && orderToPlan.has(d.order_no)) return false; + if (kw) { + const hit = + (d.order_no || "").toLowerCase().includes(kw) || + (d.part_name || "").toLowerCase().includes(kw) || + (d.part_code || "").toLowerCase().includes(kw); + if (!hit) return false; + } + return true; }); - const payload = res.data?.data || {}; - const raw = payload.rows || []; - setOrderTotal(payload.total || 0); - const rows: OrderRow[] = raw.map((o: any) => { - const dims = parseSpec(o.spec); - const qty = +o.order_qty || 0; - const balance = +o.balance_qty || qty; + + // 클라이언트 페이지네이션 + const total = filtered.length; + const start = (orderPage - 1) * orderLimit; + const paged = filtered.slice(start, start + orderLimit); + + const rows: OrderRow[] = paged.map((d: any) => { + const m = masterMap.get(d.order_no) || {}; + const w = parseFloat(d.width || "0") || 0; + const h = parseFloat(d.height || "0") || 0; + const dims = (!w || !h) ? parseSpec(d.spec) : {}; + const qty = parseFloat(d.qty || "0") || 0; + const balance = parseFloat(d.balance_qty || "0") || qty; + const batch = orderToPlan.get(d.order_no); return { - order_no: o.order_no, - customer: o.partner_id || "-", - partner_id: o.partner_id, - part_code: o.part_code || "", - part_name: o.part_name || "-", - spec: o.spec || "", + order_no: d.order_no, + customer: m.partner_id || "-", + partner_id: m.partner_id, + part_code: d.part_code || "", + part_name: d.part_name || "-", + spec: d.spec || (w && h ? `${w}*${h}` : ""), order_qty: qty, - due_date: o.due_date ? String(o.due_date).substring(0, 10) : "", + due_date: d.due_date + ? String(d.due_date).substring(0, 10) + : (m.due_date ? String(m.due_date).substring(0, 10) : ""), status: balance <= 0 ? "완료" : "미계획", - type: dims.type || "area", - width: dims.width || 0, - height: dims.height || 0, + type: "area" as CutType, + width: w || dims.width || 0, + height: h || dims.height || 0, length: dims.length || 0, - item_id: o.item_id ? String(o.item_id) : undefined, - item_name: o.item_name || undefined, - batch_id: o.batch_id ?? undefined, - batch_no: o.batch_no ?? undefined, + item_id: d.item_id ? String(d.item_id) : undefined, + item_name: d.part_name || undefined, + batch_id: batch?.batch_id, + batch_no: batch?.batch_no, }; }); setOrders(rows); + setOrderTotal(total); } catch (e: any) { toast.error("수주 조회 실패: " + (e?.message || "")); } finally { @@ -203,8 +263,127 @@ export default function CuttingPlanPage() { } }, [dateFrom, dateTo, orderKeyword, orderPage, orderLimit, excludeInPlan]); + // 생산계획 / 출하계획 fetch + // — 치수(width/height) 채우기 위해 sales_order_detail/item_info 동시 조회 후 매핑 + // — excludeInPlan ON: cutting_plan_item에 (src_type, src_no)로 매칭된 행 제외 + const loadProductionPlans = useCallback(async () => { + setLoadingProductionPlans(true); + try { + const [planRes, detailRes, itemRes, cpiRes] = await Promise.all([ + apiClient.post(`/table-management/tables/production_plan_mng/data`, { + page: 1, size: 0, autoFilter: true, + sort: { columnName: "plan_date", order: "desc" }, + }), + apiClient.post(`/table-management/tables/sales_order_detail/data`, { + page: 1, size: 0, autoFilter: true, + }), + apiClient.post(`/table-management/tables/item_info/data`, { + page: 1, size: 0, autoFilter: true, + }), + apiClient.post(`/table-management/tables/cutting_plan_item/data`, { + page: 1, size: 0, autoFilter: true, + }).catch(() => ({ data: { data: { data: [] } } })), + ]); + const plans = planRes.data?.data?.data || planRes.data?.data?.rows || []; + const details = detailRes.data?.data?.data || detailRes.data?.data?.rows || []; + const items = itemRes.data?.data?.data || itemRes.data?.data?.rows || []; + const cpiAll = cpiRes.data?.data?.data || cpiRes.data?.data?.rows || []; + + const detailByOrderNo = new Map(); + for (const d of details) if (d.order_no && !detailByOrderNo.has(d.order_no)) detailByOrderNo.set(d.order_no, d); + const itemByCode = new Map(); + for (const it of items) if (it.item_number) itemByCode.set(it.item_number, it); + const usedSet = new Set( + cpiAll.filter((x: any) => x.src_type === "production" && x.src_no).map((x: any) => String(x.src_no)) + ); + + const enriched = plans + .filter((p: any) => !excludeInPlan || !usedSet.has(String(p.plan_no))) + .map((p: any) => { + const d = p.order_no ? detailByOrderNo.get(p.order_no) : null; + const ii = p.item_code ? itemByCode.get(p.item_code) : null; + const w = parseFloat(d?.width || ii?.width || "0") || 0; + const h = parseFloat(d?.height || ii?.height || "0") || 0; + const t = parseFloat(d?.thickness || ii?.thickness || "0") || 0; + return { ...p, _width: w, _height: h, _thickness: t, _itemId: ii?.id || d?.item_id || null }; + }); + setProductionPlans(enriched); + } catch (e: any) { + toast.error("생산계획 조회 실패: " + (e?.message || "")); + } finally { + setLoadingProductionPlans(false); + } + }, [excludeInPlan]); + + const loadShipmentPlans = useCallback(async () => { + setLoadingShipmentPlans(true); + try { + const [planRes, detailRes, masterRes, itemRes, cpiRes] = await Promise.all([ + apiClient.post(`/table-management/tables/shipment_plan/data`, { + page: 1, size: 0, autoFilter: true, + sort: { columnName: "plan_date", order: "desc" }, + }), + apiClient.post(`/table-management/tables/sales_order_detail/data`, { + page: 1, size: 0, autoFilter: true, + }), + apiClient.post(`/table-management/tables/sales_order_mng/data`, { + page: 1, size: 0, autoFilter: true, + }), + apiClient.post(`/table-management/tables/item_info/data`, { + page: 1, size: 0, autoFilter: true, + }), + apiClient.post(`/table-management/tables/cutting_plan_item/data`, { + page: 1, size: 0, autoFilter: true, + }).catch(() => ({ data: { data: { data: [] } } })), + ]); + const plans = planRes.data?.data?.data || planRes.data?.data?.rows || []; + const details = detailRes.data?.data?.data || detailRes.data?.data?.rows || []; + const masters = masterRes.data?.data?.data || masterRes.data?.data?.rows || []; + const items = itemRes.data?.data?.data || itemRes.data?.data?.rows || []; + const cpiAll = cpiRes.data?.data?.data || cpiRes.data?.data?.rows || []; + + const detailById = new Map(details.map((d: any) => [String(d.id), d])); + const masterById = new Map(masters.map((m: any) => [String(m.id), m])); + const itemByCode = new Map(); + for (const it of items) if (it.item_number) itemByCode.set(it.item_number, it); + const usedSet = new Set( + cpiAll.filter((x: any) => x.src_type === "shipment" && x.src_no).map((x: any) => String(x.src_no)) + ); + + const enriched = plans + .filter((p: any) => !excludeInPlan || !usedSet.has(String(p.shipment_plan_no))) + .map((p: any) => { + const d = p.detail_id ? detailById.get(String(p.detail_id)) : null; + const m = p.sales_order_id ? masterById.get(String(p.sales_order_id)) : null; + const partCode = d?.part_code || ""; + const ii = partCode ? itemByCode.get(partCode) : null; + const w = parseFloat(d?.width || ii?.width || "0") || 0; + const h = parseFloat(d?.height || ii?.height || "0") || 0; + const t = parseFloat(d?.thickness || ii?.thickness || "0") || 0; + return { + ...p, + _orderNo: d?.order_no || m?.order_no || "", + _partCode: partCode, + _partName: d?.part_name || ii?.item_name || "", + _spec: d?.spec || (w && h ? `${w}*${h}` : ""), + _width: w, _height: h, _thickness: t, + _itemId: ii?.id || d?.item_id || null, + }; + }); + setShipmentPlans(enriched); + } catch (e: any) { + toast.error("출하계획 조회 실패: " + (e?.message || "")); + } finally { + setLoadingShipmentPlans(false); + } + }, [excludeInPlan]); + useEffect(() => { loadMaterials(); }, [loadMaterials]); useEffect(() => { loadOrders(); }, [loadOrders]); + useEffect(() => { + if (leftTab === "plan") loadProductionPlans(); + else if (leftTab === "ship") loadShipmentPlans(); + }, [leftTab, loadProductionPlans, loadShipmentPlans]); // 절단유형 바뀌면 선택/결과 리셋 useEffect(() => { @@ -238,56 +417,141 @@ export default function CuttingPlanPage() { }, []); // ─────────────────────────────────────────────────────── - // 계획에 추가 + // 계획에 추가 — 출처(수주/생산계획/출하계획)별 공통 처리 // ─────────────────────────────────────────────────────── + type SrcType = "order" | "production" | "shipment"; + interface NormalizedSrc { + srcType: SrcType; + srcNo: string; // cutting_plan_item.src_no 에 저장될 값 + name: string; + code?: string; + itemId?: string; + width: number; + height: number; + length: number; + qty: number; + warnNoSize: boolean; + } + + const addItemsFromSources = useCallback( + (srcs: NormalizedSrc[], clearChecked: () => void) => { + if (srcs.length === 0) { + toast.error("추가할 항목을 선택하세요"); + return; + } + const newItems: (PlanItem & { srcOrders?: string[] })[] = []; + let skipped = 0; + let warnSize = 0; + for (const s of srcs) { + if (s.warnNoSize) warnSize++; + const sameKey = (p: PlanItem) => + p.name === s.name && + (p as any).srcType === s.srcType && + Math.abs((p.width || 0) - s.width) < 0.1 && + Math.abs((p.height || 0) - s.height) < 0.1 && + Math.abs((p.length || 0) - s.length) < 0.1; + const existsInPlan = planItems.find(sameKey) as (PlanItem & { srcOrders?: string[] }) | undefined; + const existsInNew = newItems.find(sameKey); + if (existsInPlan || existsInNew) { + const target = (existsInNew || existsInPlan)!; + target.qty = (target.qty || 0) + s.qty; + target.srcOrders = [...(target.srcOrders || []), s.srcNo]; + skipped++; + continue; + } + newItems.push({ + name: s.name, + code: s.code, + item_id: s.itemId, + width: s.width, + height: s.height, + length: s.length, + qty: s.qty, + dir: "무관", + color: COLORS[(planItems.length + newItems.length) % COLORS.length], + placed: 0, + srcType: s.srcType, + srcOrders: [s.srcNo], + } as PlanItem & { srcOrders?: string[] }); + } + setPlanItems((prev) => [...prev, ...newItems]); + clearChecked(); + setBatchResult(null); + const msgs: string[] = []; + if (newItems.length) msgs.push(`${newItems.length}개 품목 추가`); + if (skipped) msgs.push(`${skipped}건 수량 합산`); + if (warnSize) msgs.push(`${warnSize}건 치수 미입력 (수동 입력 필요)`); + toast.success(msgs.join(" · ") || "추가 없음"); + }, + [planItems] + ); + const addToPlan = useCallback(() => { - if (checkedOrders.size === 0) { - toast.error("추가할 항목을 선택하세요"); - return; - } - const newItems: PlanItem[] = []; - let skipped = 0; + const srcs: NormalizedSrc[] = []; checkedOrders.forEach((orderNo) => { const o = orders.find((x) => x.order_no === orderNo); if (!o) return; - // 중복 기준: 품목명 + 가로 + 세로 + 길이 (완전히 같은 규격만 중복으로 취급) - const sameKey = (p: PlanItem) => - p.name === o.part_name && - Math.abs((p.width || 0) - (o.width || 0)) < 0.1 && - Math.abs((p.height || 0) - (o.height || 0)) < 0.1 && - Math.abs((p.length || 0) - (o.length || 0)) < 0.1; - const existsInPlan = planItems.find(sameKey); - const existsInNew = newItems.find(sameKey); - if (existsInPlan || existsInNew) { - // 같은 규격이면 수량 합산 + 수주번호 추가 - const target = (existsInNew || existsInPlan!) as PlanItem & { srcOrders?: string[] }; - target.qty = (target.qty || 0) + (o.order_qty || 0); - target.srcOrders = [...(target.srcOrders || []), orderNo]; - skipped++; - return; - } - newItems.push({ + const w = o.width || 0, h = o.height || 0, l = o.length || 0; + srcs.push({ + srcType: "order", + srcNo: orderNo, name: o.item_name || o.part_name || "-", code: o.part_code || undefined, - item_id: o.item_id || undefined, - width: o.width || 0, - height: o.height || 0, - length: o.length || 0, + itemId: o.item_id || undefined, + width: w, height: h, length: l, qty: o.order_qty || 0, - dir: "무관", - color: COLORS[(planItems.length + newItems.length) % COLORS.length], - placed: 0, - srcOrders: [orderNo], - } as PlanItem & { srcOrders?: string[] }); + warnNoSize: !w && !h && !l, + }); }); - setPlanItems((prev) => [...prev, ...newItems]); - setCheckedOrders(new Set()); - setBatchResult(null); - const msgs: string[] = []; - if (newItems.length) msgs.push(`${newItems.length}개 품목 추가`); - if (skipped) msgs.push(`${skipped}건 수량 합산`); - toast.success(msgs.join(" · ") || "추가 없음"); - }, [checkedOrders, orders, planItems]); + addItemsFromSources(srcs, () => setCheckedOrders(new Set())); + }, [checkedOrders, orders, addItemsFromSources]); + + const addProductionPlansToPlan = useCallback(() => { + const srcs: NormalizedSrc[] = []; + checkedProductionPlans.forEach((rowKey) => { + const p = productionPlans.find((x) => String(x.id) === rowKey); + if (!p) return; + if (p.status === "completed" || p.status === "cancelled") { + toast.error(`완료/취소된 생산계획은 추가할 수 없습니다`); + return; + } + const w = Number(p._width) || 0, h = Number(p._height) || 0; + const srcNo = String(p.plan_no || p.id); + srcs.push({ + srcType: "production", + srcNo, + name: p.item_name || p.item_code || "-", + code: p.item_code || undefined, + itemId: p._itemId || undefined, + width: w, height: h, length: 0, + qty: Number(p.plan_qty) || 0, + warnNoSize: !w && !h, + }); + }); + addItemsFromSources(srcs, () => setCheckedProductionPlans(new Set())); + }, [checkedProductionPlans, productionPlans, addItemsFromSources]); + + const addShipmentPlansToPlan = useCallback(() => { + const srcs: NormalizedSrc[] = []; + checkedShipmentPlans.forEach((rowKey) => { + const p = shipmentPlans.find((x) => String(x.id) === rowKey); + if (!p) return; + const w = Number(p._width) || 0, h = Number(p._height) || 0; + // shipment_plan_no가 비어 있으면 id로 fallback (cutting_plan_item.src_no 저장용) + const srcNo = String(p.shipment_plan_no || p.id); + srcs.push({ + srcType: "shipment", + srcNo, + name: p._partName || "-", + code: p._partCode || undefined, + itemId: p._itemId || undefined, + width: w, height: h, length: 0, + qty: Number(p.plan_qty) || 0, + warnNoSize: !w && !h, + }); + }); + addItemsFromSources(srcs, () => setCheckedShipmentPlans(new Set())); + }, [checkedShipmentPlans, shipmentPlans, addItemsFromSources]); const updateItem = useCallback((idx: number, field: keyof PlanItem, value: any) => { setPlanItems((prev) => { @@ -514,6 +778,7 @@ export default function CuttingPlanPage() { dir: (it.dir as Dir) || "무관", color: it.color || COLORS[itemMap.size % COLORS.length], placed: +it.placed_qty || 0, + srcType: (it.src_type === "production" || it.src_type === "shipment" || it.src_type === "order") ? it.src_type : undefined, srcOrders: it.src_no ? [it.src_no] : [], }); } @@ -623,9 +888,10 @@ export default function CuttingPlanPage() { const items = planItems.map((p, i) => { const srcOrders = (p as PlanItem & { srcOrders?: string[] }).srcOrders || []; + const srcType = (p as PlanItem).srcType; // 'order' | 'production' | 'shipment' return { seq: i + 1, - src_type: srcOrders.length > 0 ? "order" : "manual", + src_type: srcType || (srcOrders.length > 0 ? "order" : "manual"), src_no: srcOrders.length === 1 ? srcOrders[0] : null, src_orders: srcOrders, item_id: p.item_id || null, @@ -936,16 +1202,168 @@ export default function CuttingPlanPage() { )} - -
- -

생산계획 데이터가 없습니다

+ +
+ + 총 {productionPlans.length.toLocaleString()}건 + {" | "}선택 {checkedProductionPlans.size}건 + +
+ + +
+
+
+ {loadingProductionPlans ? ( +
+ +
+ ) : productionPlans.length === 0 ? ( +
+ +

생산계획 데이터가 없습니다

+
+ ) : ( + + + + + 0} + onCheckedChange={(c) => setCheckedProductionPlans(c ? new Set(productionPlans.map((p) => String(p.id))) : new Set())} + className="h-4 w-4" + /> + + 계획번호 + 수주번호 + 품목명 + 규격(WxH) + 계획수량 + 계획일 + 상태 + + + + {productionPlans.map((p, idx) => { + const key = String(p.id); + return ( + setCheckedProductionPlans((prev) => { + const n = new Set(prev); n.has(key) ? n.delete(key) : n.add(key); return n; + })} + > + e.stopPropagation()}> + setCheckedProductionPlans((prev) => { + const n = new Set(prev); n.has(key) ? n.delete(key) : n.add(key); return n; + })} + className="h-4 w-4" + /> + + {p.plan_no || #{p.id}} + {p.order_no || "-"} + {p.item_name || p.item_code || "-"} + + {p._width && p._height ? `${p._width}×${p._height}` : "-"} + + {Number(p.plan_qty || 0).toLocaleString()} + {p.plan_date ? String(p.plan_date).substring(0, 10) : "-"} + {p.status || "-"} + + ); + })} + +
+ )}
- -
- -

출하계획 데이터가 없습니다

+ +
+ + 총 {shipmentPlans.length.toLocaleString()}건 + {" | "}선택 {checkedShipmentPlans.size}건 + +
+ + +
+
+
+ {loadingShipmentPlans ? ( +
+ +
+ ) : shipmentPlans.length === 0 ? ( +
+ +

출하계획 데이터가 없습니다

+
+ ) : ( + + + + + 0} + onCheckedChange={(c) => setCheckedShipmentPlans(c ? new Set(shipmentPlans.map((p) => String(p.id))) : new Set())} + className="h-4 w-4" + /> + + 출하계획번호 + 수주번호 + 품목명 + 규격(WxH) + 계획수량 + 계획일 + 상태 + + + + {shipmentPlans.map((s, idx) => { + const key = String(s.id); + return ( + setCheckedShipmentPlans((prev) => { + const n = new Set(prev); n.has(key) ? n.delete(key) : n.add(key); return n; + })} + > + e.stopPropagation()}> + setCheckedShipmentPlans((prev) => { + const n = new Set(prev); n.has(key) ? n.delete(key) : n.add(key); return n; + })} + className="h-4 w-4" + /> + + {s.shipment_plan_no || #{s.id}} + {s._orderNo || "-"} + {s._partName || "-"} + + {s._width && s._height ? `${s._width}×${s._height}` : "-"} + + {Number(s.plan_qty || 0).toLocaleString()} + {s.plan_date ? String(s.plan_date).substring(0, 10) : "-"} + {s.status || "-"} + + ); + })} + +
+ )}
diff --git a/frontend/app/(main)/COMPANY_9/sales/order/page.tsx b/frontend/app/(main)/COMPANY_9/sales/order/page.tsx index d954fe91..6707e0ee 100644 --- a/frontend/app/(main)/COMPANY_9/sales/order/page.tsx +++ b/frontend/app/(main)/COMPANY_9/sales/order/page.tsx @@ -24,7 +24,7 @@ import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/componen import { Plus, Trash2, Save, Loader2, FileSpreadsheet, Download, Pencil, ClipboardList, Package, Search, X, Settings2, GripVertical, - ChevronsLeft, ChevronLeft, ChevronRight, ChevronsRight, + ChevronsLeft, ChevronLeft, ChevronRight, ChevronsRight, Truck, } from "lucide-react"; import { cn } from "@/lib/utils"; import { apiClient } from "@/lib/api/client"; @@ -39,6 +39,7 @@ import { SalesOrderExcelModal } from "./SalesOrderExcelModal"; import { exportToExcel } from "@/lib/utils/excelExport"; import { FormDatePicker } from "@/components/screen/filters/FormDatePicker"; import { TableSettingsModal, TableSettings, loadTableSettings } from "@/components/common/TableSettingsModal"; +import { ShippingPlanBatchModal } from "@/components/common/ShippingPlanBatchModal"; import { previewNumberingCode, allocateNumberingCode } from "@/lib/api/numberingRule"; const MASTER_TABLE = "sales_order_mng"; @@ -83,6 +84,7 @@ const RIGHT_COLUMNS: DataGridColumn[] = [ { key: "unit_price", label: "단가", width: "w-[85px]", formatNumber: true, align: "right" }, { key: "amount", label: "금액", width: "w-[95px]", formatNumber: true, align: "right" }, { key: "due_date", label: "납기일", width: "w-[100px]" }, + { key: "delivery_location", label: "납품장소", minWidth: "min-w-[140px]" }, { key: "memo", label: "비고", width: "w-[80px]" }, ]; @@ -108,6 +110,8 @@ export default function JeilGlassOrderPage() { // 우측: 디테일 const [detailItems, setDetailItems] = useState([]); const [detailLoading, setDetailLoading] = useState(false); + const [detailCheckedIds, setDetailCheckedIds] = useState([]); + const [shippingPlanOpen, setShippingPlanOpen] = useState(false); // 모달 const [isModalOpen, setIsModalOpen] = useState(false); @@ -347,6 +351,7 @@ export default function JeilGlassOrderPage() { // 우측: 선택된 수주 디테일 조회 (division 코드→라벨 변환) useEffect(() => { + setDetailCheckedIds([]); if (!selectedOrderNo) { setDetailItems([]); return; } const items = allDetails .filter((d) => d.order_no === selectedOrderNo) @@ -494,10 +499,20 @@ export default function JeilGlassOrderPage() { continue; } // 없으면 자동 등록 + // COMPANY_9 한정: 수주 디테일 입력값을 신규 item_info 레코드에 연동 + // (width/height/thickness/size/unit/standard_price 6개 컬럼) + // selling_price(판매가격)는 절대 연동 금지 — 기존 품목은 이미 위 found 분기에서 보호됨 + // company_code를 명시해야 백엔드가 회사별 채번 규칙으로 item_number를 자동 발급함 await apiClient.post(`/table-management/tables/${ITEM_TABLE}/add`, { + id: crypto.randomUUID(), + company_code: user?.companyCode || user?.company_code, item_name: row.part_name, size: row.spec || "", unit: row.unit || "", + width: row.width || "", + height: row.height || "", + thickness: row.thickness || "", + standard_price: row.unit_price || "", }); // 등록 후 재조회하여 item_number 획득 const reSearch = await apiClient.post(`/table-management/tables/${ITEM_TABLE}/data`, { @@ -571,6 +586,7 @@ export default function JeilGlassOrderPage() { } = row; await apiClient.post(`/table-management/tables/${DETAIL_TABLE}/add`, { ...detailFields, + id: crypto.randomUUID(), order_no: masterForm.order_no, seq_no: String(i + 1), }); @@ -588,17 +604,14 @@ export default function JeilGlassOrderPage() { }; // 품목 검색 - // 품목 검색 (서버 페이지네이션) — 관리품목=영업관리, 품목구분=제품 고정 - // COMPANY_9: type 컬럼에 코드가 저장돼 있어 코드값으로 equals 필터 + // 품목 검색 (서버 페이지네이션) — 관리품목=영업관리만 적용 (type 강제 필터 제거) const searchItems = async (pageOverride?: number) => { setItemSearchLoading(true); const page = pageOverride ?? itemSearchPage; try { const salesCode = categoryOptions["item_division"]?.find((o) => o.label === "영업관리")?.code; - const productCode = categoryOptions["item_type"]?.find((o) => o.label === "제품")?.code; const filters: any[] = []; if (salesCode) filters.push({ columnName: "division", operator: "contains", value: salesCode }); - if (productCode) filters.push({ columnName: "type", operator: "equals", value: productCode }); if (itemSearchKeyword) { const kwRes = await apiClient.post(`/table-management/tables/item_info/data`, { page: 1, size: 0, @@ -692,6 +705,7 @@ export default function JeilGlassOrderPage() { pack_count: "0", qty: "", unit_price: item.selling_price || item.standard_price || "", amount: "", due_date: "", memo: "", + delivery_location: masterForm.delivery_address || "", }; }); setModalDetailRows((prev) => [...prev, ...newRows]); @@ -744,6 +758,7 @@ export default function JeilGlassOrderPage() { pkg_code: "", pkg_qty_per_unit: "0", pkg_options: [], pack_count: "0", qty: "", unit_price: "", amount: "", due_date: "", memo: "", + delivery_location: masterForm.delivery_address || "", }]); }; @@ -957,6 +972,9 @@ export default function JeilGlassOrderPage() { )}
+ @@ -979,6 +997,9 @@ export default function JeilGlassOrderPage() { data={detailItems} loading={detailLoading} showRowNumber + showCheckbox + checkedIds={detailCheckedIds} + onCheckedChange={setDetailCheckedIds} tableName={DETAIL_TABLE} emptyMessage="품목이 없습니다" /> @@ -1063,6 +1084,13 @@ export default function JeilGlassOrderPage() { placeholder="메모" className="h-9" />
+
+
+ + setMasterForm((p) => ({ ...p, delivery_address: e.target.value }))} + placeholder="납품장소 (행 추가 시 품목별 납품장소 기본값으로 사용됩니다)" className="h-9" /> +
+
{/* 품목 리피터 */}
@@ -1098,11 +1126,12 @@ export default function JeilGlassOrderPage() { 단가 금액 납기일 + 납품장소 {modalDetailRows.length === 0 ? ( - 품목을 추가해주세요 + 품목을 추가해주세요 ) : modalDetailRows.map((row, idx) => ( updateDetailRow(idx, "due_date", v)} placeholder="납기일" /> + + updateDetailRow(idx, "delivery_location", e.target.value)} + className="h-8 text-sm" placeholder="납품장소" /> + ))} {/* 합계 행 */} @@ -1229,6 +1262,7 @@ export default function JeilGlassOrderPage() { {modalDetailRows.reduce((s, r) => s + (parseFloat(r.amount) || 0), 0).toLocaleString()}원 + )} @@ -1354,6 +1388,14 @@ export default function JeilGlassOrderPage() { onSave={applyTableSettings} /> + {/* 출하계획 동시 등록 모달 */} + + {ConfirmDialogComponent}
); diff --git a/frontend/app/(main)/COMPANY_9/sales/shipping-plan/page.tsx b/frontend/app/(main)/COMPANY_9/sales/shipping-plan/page.tsx index 9e0398f9..29a84962 100644 --- a/frontend/app/(main)/COMPANY_9/sales/shipping-plan/page.tsx +++ b/frontend/app/(main)/COMPANY_9/sales/shipping-plan/page.tsx @@ -21,6 +21,7 @@ import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSea import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable"; const GRID_COLUMNS = [ + { key: "shipment_plan_no", label: "출하계획번호" }, { key: "order_no", label: "수주번호" }, { key: "due_date", label: "납기일" }, { key: "customer_name", label: "거래처" }, @@ -247,6 +248,7 @@ export default function ShippingPlanPage() {
val ? {val} : #{row?.id} }, { key: "order_no", label: "수주번호", render: (val: any) => {val || "-"} }, { key: "due_date", label: "납기일", align: "center" as const, render: (val: any) => {formatDate(val)} }, { key: "customer_name", label: "거래처", render: (val: any) => {val || "-"} }, diff --git a/frontend/components/common/ShippingPlanBatchModal.tsx b/frontend/components/common/ShippingPlanBatchModal.tsx index 3b23465f..4158331c 100644 --- a/frontend/components/common/ShippingPlanBatchModal.tsx +++ b/frontend/components/common/ShippingPlanBatchModal.tsx @@ -244,6 +244,7 @@ export function ShippingPlanBatchModal({ const rows = newPlans[partCode] || []; const firstOrder = orders[0]; + const hasNoPartCode = partCode.startsWith("__no_part__"); return (
{/* 품목 헤더 */} @@ -251,14 +252,16 @@ export function ShippingPlanBatchModal({
-
품목코드
-
{partCode}
+
{hasNoPartCode ? "품명" : "품목코드"}
+
{hasNoPartCode ? (firstOrder?.partName || "-") : partCode}
-
-
품명
-
{firstOrder?.partName || "-"}
-
+ {!hasNoPartCode && ( +
+
품명
+
{firstOrder?.partName || "-"}
+
+ )}
{/* 통계 카드 (신규 입력량 반영) */} diff --git a/frontend/lib/cutting/packing.ts b/frontend/lib/cutting/packing.ts index 88e13351..1bc3537c 100644 --- a/frontend/lib/cutting/packing.ts +++ b/frontend/lib/cutting/packing.ts @@ -31,6 +31,7 @@ export interface PlanItem { dir: Dir; color: string; placed?: number; + srcType?: "order" | "production" | "shipment"; // cutting_plan_item.src_type } export interface Placement {