Enhance Shipping Order and Plan Functionality

- Updated the shipping order controller to improve customer name retrieval by removing unnecessary partner_id fallback.
- Implemented shipment plan number allocation logic in the shipping plan controller, ensuring unique numbering based on defined rules or fallback mechanisms.
- Enhanced the batch save functionality to include the new shipment plan number in the database insertions.
- Added new state management for production and shipment plans in the Cutting Plan page, allowing for better organization and retrieval of related data.
- Introduced delivery location field in the sales order page, improving data entry for shipping details.

(TASK: ERP-XXX)
This commit is contained in:
kjs
2026-05-12 16:24:33 +09:00
parent 1003273709
commit bd978ff80c
8 changed files with 603 additions and 104 deletions

View File

@@ -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(

View File

@@ -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<string> {
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<string, NormalizedOrder[]>();
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]);

View File

@@ -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, ' ', ''), ','))

View File

@@ -93,6 +93,15 @@ export default function CuttingPlanPage() {
const [checkedOrders, setCheckedOrders] = useState<Set<string>>(new Set());
const [loadingOrders, setLoadingOrders] = useState(false);
// 생산계획 탭
const [productionPlans, setProductionPlans] = useState<any[]>([]);
const [loadingProductionPlans, setLoadingProductionPlans] = useState(false);
const [checkedProductionPlans, setCheckedProductionPlans] = useState<Set<string>>(new Set());
// 출하계획 탭
const [shipmentPlans, setShipmentPlans] = useState<any[]>([]);
const [loadingShipmentPlans, setLoadingShipmentPlans] = useState(false);
const [checkedShipmentPlans, setCheckedShipmentPlans] = useState<Set<string>>(new Set());
// 설정 상태
const [cutType, setCutType] = useState<CutType>("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<string, any>(masters.map((m: any) => [m.order_no, m]));
const planMngMap = new Map<string, any>(planMngs.map((p: any) => [String(p.id), p]));
// src_no(=order_no) → 첫 plan 매칭
const orderToPlan = new Map<string, { batch_id: number; batch_no: string }>();
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<string, any>();
for (const d of details) if (d.order_no && !detailByOrderNo.has(d.order_no)) detailByOrderNo.set(d.order_no, d);
const itemByCode = new Map<string, any>();
for (const it of items) if (it.item_number) itemByCode.set(it.item_number, it);
const usedSet = new Set<string>(
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<string, any>(details.map((d: any) => [String(d.id), d]));
const masterById = new Map<string, any>(masters.map((m: any) => [String(m.id), m]));
const itemByCode = new Map<string, any>();
for (const it of items) if (it.item_number) itemByCode.set(it.item_number, it);
const usedSet = new Set<string>(
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() {
)}
</TabsContent>
<TabsContent value="plan" className="flex-1 overflow-hidden mt-0">
<div className="flex h-full flex-col items-center justify-center text-muted-foreground">
<CalendarClock className="h-10 w-10 opacity-40 mb-2" />
<p className="text-xs"> </p>
<TabsContent value="plan" className="flex-1 overflow-hidden mt-0 flex flex-col">
<div className="shrink-0 flex items-center justify-between border-b bg-muted/20 px-3 py-2 gap-2">
<span className="text-xs text-muted-foreground">
<strong className="text-foreground">{productionPlans.length.toLocaleString()}</strong>
{" | "} <strong className="text-primary">{checkedProductionPlans.size}</strong>
</span>
<div className="flex items-center gap-1.5">
<Button size="sm" onClick={addProductionPlansToPlan} disabled={checkedProductionPlans.size === 0}>
<Plus className="mr-1 h-3.5 w-3.5" />
</Button>
<Button size="sm" variant="outline" className="h-7" onClick={loadProductionPlans}>
<RefreshCw className="h-3.5 w-3.5" />
</Button>
</div>
</div>
<div className="flex-1 overflow-auto">
{loadingProductionPlans ? (
<div className="flex h-full items-center justify-center">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
) : productionPlans.length === 0 ? (
<div className="flex h-full flex-col items-center justify-center text-muted-foreground py-10">
<CalendarClock className="h-10 w-10 opacity-40 mb-2" />
<p className="text-xs"> </p>
</div>
) : (
<Table>
<TableHeader className="sticky top-0 z-10 bg-muted">
<TableRow>
<TableHead style={{ width: 36 }}>
<Checkbox
checked={checkedProductionPlans.size === productionPlans.length && productionPlans.length > 0}
onCheckedChange={(c) => setCheckedProductionPlans(c ? new Set(productionPlans.map((p) => String(p.id))) : new Set())}
className="h-4 w-4"
/>
</TableHead>
<TableHead className="text-xs"></TableHead>
<TableHead className="text-xs"></TableHead>
<TableHead className="text-xs"></TableHead>
<TableHead className="text-xs text-right">(WxH)</TableHead>
<TableHead className="text-xs text-right"></TableHead>
<TableHead className="text-xs"></TableHead>
<TableHead className="text-xs"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{productionPlans.map((p, idx) => {
const key = String(p.id);
return (
<TableRow
key={`${p.id}-${idx}`}
className={cn("cursor-pointer", checkedProductionPlans.has(key) && "bg-primary/5")}
onClick={() => setCheckedProductionPlans((prev) => {
const n = new Set(prev); n.has(key) ? n.delete(key) : n.add(key); return n;
})}
>
<TableCell onClick={(e) => e.stopPropagation()}>
<Checkbox
checked={checkedProductionPlans.has(key)}
onCheckedChange={() => setCheckedProductionPlans((prev) => {
const n = new Set(prev); n.has(key) ? n.delete(key) : n.add(key); return n;
})}
className="h-4 w-4"
/>
</TableCell>
<TableCell className="font-mono text-[11px] text-primary">{p.plan_no || <span className="text-muted-foreground">#{p.id}</span>}</TableCell>
<TableCell className="text-xs">{p.order_no || "-"}</TableCell>
<TableCell className="text-xs">{p.item_name || p.item_code || "-"}</TableCell>
<TableCell className="text-xs text-right text-muted-foreground">
{p._width && p._height ? `${p._width}×${p._height}` : "-"}
</TableCell>
<TableCell className="text-xs text-right">{Number(p.plan_qty || 0).toLocaleString()}</TableCell>
<TableCell className="text-xs">{p.plan_date ? String(p.plan_date).substring(0, 10) : "-"}</TableCell>
<TableCell className="text-xs">{p.status || "-"}</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
)}
</div>
</TabsContent>
<TabsContent value="ship" className="flex-1 overflow-hidden mt-0">
<div className="flex h-full flex-col items-center justify-center text-muted-foreground">
<Truck className="h-10 w-10 opacity-40 mb-2" />
<p className="text-xs"> </p>
<TabsContent value="ship" className="flex-1 overflow-hidden mt-0 flex flex-col">
<div className="shrink-0 flex items-center justify-between border-b bg-muted/20 px-3 py-2 gap-2">
<span className="text-xs text-muted-foreground">
<strong className="text-foreground">{shipmentPlans.length.toLocaleString()}</strong>
{" | "} <strong className="text-primary">{checkedShipmentPlans.size}</strong>
</span>
<div className="flex items-center gap-1.5">
<Button size="sm" onClick={addShipmentPlansToPlan} disabled={checkedShipmentPlans.size === 0}>
<Plus className="mr-1 h-3.5 w-3.5" />
</Button>
<Button size="sm" variant="outline" className="h-7" onClick={loadShipmentPlans}>
<RefreshCw className="h-3.5 w-3.5" />
</Button>
</div>
</div>
<div className="flex-1 overflow-auto">
{loadingShipmentPlans ? (
<div className="flex h-full items-center justify-center">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
) : shipmentPlans.length === 0 ? (
<div className="flex h-full flex-col items-center justify-center text-muted-foreground py-10">
<Truck className="h-10 w-10 opacity-40 mb-2" />
<p className="text-xs"> </p>
</div>
) : (
<Table>
<TableHeader className="sticky top-0 z-10 bg-muted">
<TableRow>
<TableHead style={{ width: 36 }}>
<Checkbox
checked={checkedShipmentPlans.size === shipmentPlans.length && shipmentPlans.length > 0}
onCheckedChange={(c) => setCheckedShipmentPlans(c ? new Set(shipmentPlans.map((p) => String(p.id))) : new Set())}
className="h-4 w-4"
/>
</TableHead>
<TableHead className="text-xs"></TableHead>
<TableHead className="text-xs"></TableHead>
<TableHead className="text-xs"></TableHead>
<TableHead className="text-xs text-right">(WxH)</TableHead>
<TableHead className="text-xs text-right"></TableHead>
<TableHead className="text-xs"></TableHead>
<TableHead className="text-xs"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{shipmentPlans.map((s, idx) => {
const key = String(s.id);
return (
<TableRow
key={`${s.id}-${idx}`}
className={cn("cursor-pointer", checkedShipmentPlans.has(key) && "bg-primary/5")}
onClick={() => setCheckedShipmentPlans((prev) => {
const n = new Set(prev); n.has(key) ? n.delete(key) : n.add(key); return n;
})}
>
<TableCell onClick={(e) => e.stopPropagation()}>
<Checkbox
checked={checkedShipmentPlans.has(key)}
onCheckedChange={() => setCheckedShipmentPlans((prev) => {
const n = new Set(prev); n.has(key) ? n.delete(key) : n.add(key); return n;
})}
className="h-4 w-4"
/>
</TableCell>
<TableCell className="font-mono text-[11px] text-primary">{s.shipment_plan_no || <span className="text-muted-foreground">#{s.id}</span>}</TableCell>
<TableCell className="text-xs">{s._orderNo || "-"}</TableCell>
<TableCell className="text-xs">{s._partName || "-"}</TableCell>
<TableCell className="text-xs text-right text-muted-foreground">
{s._width && s._height ? `${s._width}×${s._height}` : "-"}
</TableCell>
<TableCell className="text-xs text-right">{Number(s.plan_qty || 0).toLocaleString()}</TableCell>
<TableCell className="text-xs">{s.plan_date ? String(s.plan_date).substring(0, 10) : "-"}</TableCell>
<TableCell className="text-xs">{s.status || "-"}</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
)}
</div>
</TabsContent>
</Tabs>

View File

@@ -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<any[]>([]);
const [detailLoading, setDetailLoading] = useState(false);
const [detailCheckedIds, setDetailCheckedIds] = useState<string[]>([]);
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() {
)}
</div>
<div className="flex items-center gap-1.5">
<Button variant="outline" size="sm" disabled={detailCheckedIds.length === 0} onClick={() => setShippingPlanOpen(true)}>
<Truck className="w-4 h-4 mr-1" /> {detailCheckedIds.length > 0 && ` (${detailCheckedIds.length})`}
</Button>
<Button variant="outline" size="sm" onClick={() => setExcelUploadOpen(true)}>
<FileSpreadsheet className="w-4 h-4 mr-1" />
</Button>
@@ -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" />
</div>
</div>
<div className="grid grid-cols-1 gap-4">
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input value={masterForm.delivery_address || ""} onChange={(e) => setMasterForm((p) => ({ ...p, delivery_address: e.target.value }))}
placeholder="납품장소 (행 추가 시 품목별 납품장소 기본값으로 사용됩니다)" className="h-9" />
</div>
</div>
{/* 품목 리피터 */}
<div className="border rounded-lg">
@@ -1098,11 +1126,12 @@ export default function JeilGlassOrderPage() {
<TableHead className="min-w-[90px]"></TableHead>
<TableHead className="min-w-[90px]"></TableHead>
<TableHead className="min-w-[140px]"></TableHead>
<TableHead className="min-w-[160px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{modalDetailRows.length === 0 ? (
<TableRow><TableCell colSpan={15} className="text-center text-muted-foreground py-8"> </TableCell></TableRow>
<TableRow><TableCell colSpan={16} className="text-center text-muted-foreground py-8"> </TableCell></TableRow>
) : modalDetailRows.map((row, idx) => (
<TableRow
key={row._id || idx}
@@ -1215,6 +1244,10 @@ export default function JeilGlassOrderPage() {
<TableCell>
<FormDatePicker value={row.due_date || ""} onChange={(v) => updateDetailRow(idx, "due_date", v)} placeholder="납기일" />
</TableCell>
<TableCell>
<Input value={row.delivery_location || ""} onChange={(e) => updateDetailRow(idx, "delivery_location", e.target.value)}
className="h-8 text-sm" placeholder="납품장소" />
</TableCell>
</TableRow>
))}
{/* 합계 행 */}
@@ -1229,6 +1262,7 @@ export default function JeilGlassOrderPage() {
{modalDetailRows.reduce((s, r) => s + (parseFloat(r.amount) || 0), 0).toLocaleString()}
</TableCell>
<TableCell></TableCell>
<TableCell></TableCell>
</TableRow>
)}
</TableBody>
@@ -1354,6 +1388,14 @@ export default function JeilGlassOrderPage() {
onSave={applyTableSettings}
/>
{/* 출하계획 동시 등록 모달 */}
<ShippingPlanBatchModal
open={shippingPlanOpen}
onOpenChange={setShippingPlanOpen}
selectedDetailIds={detailCheckedIds}
onSuccess={fetchMasterOrders}
/>
{ConfirmDialogComponent}
</div>
);

View File

@@ -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() {
<div className="flex-1 overflow-auto">
<EDataTable
columns={[
{ key: "shipment_plan_no", label: "출하계획번호", render: (val: any, row: any) => val ? <span className="font-mono text-[12px] text-primary">{val}</span> : <span className="font-mono text-[11px] text-muted-foreground">#{row?.id}</span> },
{ key: "order_no", label: "수주번호", render: (val: any) => <span className="font-medium text-sm">{val || "-"}</span> },
{ key: "due_date", label: "납기일", align: "center" as const, render: (val: any) => <span className="text-sm">{formatDate(val)}</span> },
{ key: "customer_name", label: "거래처", render: (val: any) => <span className="text-sm">{val || "-"}</span> },

View File

@@ -244,6 +244,7 @@ export function ShippingPlanBatchModal({
const rows = newPlans[partCode] || [];
const firstOrder = orders[0];
const hasNoPartCode = partCode.startsWith("__no_part__");
return (
<div key={partCode} className="border rounded-xl overflow-hidden bg-card">
{/* 품목 헤더 */}
@@ -251,14 +252,16 @@ export function ShippingPlanBatchModal({
<div className="flex items-center gap-3">
<Package className="h-7 w-7 text-muted-foreground shrink-0" />
<div>
<div className="text-[10px] text-muted-foreground"></div>
<div className="font-bold">{partCode}</div>
<div className="text-[10px] text-muted-foreground">{hasNoPartCode ? "품명" : "품목코드"}</div>
<div className="font-bold">{hasNoPartCode ? (firstOrder?.partName || "-") : partCode}</div>
</div>
</div>
<div className="text-right">
<div className="text-[10px] text-muted-foreground"></div>
<div className="font-bold">{firstOrder?.partName || "-"}</div>
</div>
{!hasNoPartCode && (
<div className="text-right">
<div className="text-[10px] text-muted-foreground"></div>
<div className="font-bold">{firstOrder?.partName || "-"}</div>
</div>
)}
</div>
{/* 통계 카드 (신규 입력량 반영) */}

View File

@@ -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 {