Enhance Item Inspection and Outbound Functionality
- Added `apply_process_name` to the item inspection controller, allowing for better clarity in process identification by joining with the `process_mng` table. - Updated the outbound controller to include additional delivery details, such as `delivery_destination_name` and `customer_name`, with fallback logic for improved data accuracy. - Enhanced the query logic to ensure proper handling of delivery addresses and customer information, improving the overall data retrieval process. (TASK: ERP-XXX)
This commit is contained in:
@@ -18,6 +18,7 @@ interface InspectionRow {
|
||||
inspection_item_name: string | null;
|
||||
inspection_method: string | null;
|
||||
apply_process: string | null;
|
||||
apply_process_name: string | null;
|
||||
classification: string | null;
|
||||
pass_criteria: string | null;
|
||||
upper_limit: string | null;
|
||||
@@ -96,16 +97,25 @@ export async function getGroupedList(req: AuthenticatedRequest, res: Response) {
|
||||
}
|
||||
|
||||
// 3) 페이지 item_code들의 모든 검사항목 row
|
||||
// 적용공정(apply_process)은 공정 코드(P001 등)이므로 process_mng JOIN으로 공정명 해석.
|
||||
// 공정 매핑이 깨졌거나 코드가 없으면 apply_process_name 은 NULL → 프론트에서 코드 fallback.
|
||||
const detailQuery = `
|
||||
SELECT
|
||||
id, item_code, item_name, inspection_type, inspection_standard, inspection_standard_id,
|
||||
inspection_item_name, inspection_method, apply_process, classification,
|
||||
pass_criteria, upper_limit, lower_limit, is_required, is_active, manager_id, memo,
|
||||
sort_order, change_record, created_date, updated_date
|
||||
FROM item_inspection_info
|
||||
WHERE company_code = $1
|
||||
AND item_code = ANY($2::text[])
|
||||
ORDER BY item_code, sort_order, created_date;
|
||||
iii.id, iii.item_code, iii.item_name, iii.inspection_type, iii.inspection_standard,
|
||||
iii.inspection_standard_id, iii.inspection_item_name, iii.inspection_method,
|
||||
iii.apply_process,
|
||||
pm.process_name AS apply_process_name,
|
||||
iii.classification,
|
||||
iii.pass_criteria, iii.upper_limit, iii.lower_limit, iii.is_required, iii.is_active,
|
||||
iii.manager_id, iii.memo, iii.sort_order, iii.change_record,
|
||||
iii.created_date, iii.updated_date
|
||||
FROM item_inspection_info iii
|
||||
LEFT JOIN process_mng pm
|
||||
ON pm.process_code = iii.apply_process
|
||||
AND pm.company_code = iii.company_code
|
||||
WHERE iii.company_code = $1
|
||||
AND iii.item_code = ANY($2::text[])
|
||||
ORDER BY iii.item_code, iii.sort_order, iii.created_date;
|
||||
`;
|
||||
const detailRes = await pool.query(detailQuery, [companyCode, itemCodes]);
|
||||
const rowsByItem: Record<string, InspectionRow[]> = {};
|
||||
|
||||
@@ -75,11 +75,45 @@ export async function getList(req: AuthenticatedRequest, res: Response) {
|
||||
const query = `
|
||||
SELECT
|
||||
om.*,
|
||||
wh.warehouse_name
|
||||
wh.warehouse_name,
|
||||
-- 납품처 (TASK:ERP-097): 출고 자체 납품처 정보 우선,
|
||||
-- 없으면 출처 출하지시(shipment_instruction_detail → shipment_instruction)에서 폴백
|
||||
COALESCE(
|
||||
NULLIF(om.delivery_destination, ''),
|
||||
NULLIF(om.delivery_address, ''),
|
||||
NULLIF(sid.delivery_address, ''),
|
||||
NULLIF(si.delivery_address, ''),
|
||||
''
|
||||
) AS delivery_destination_name,
|
||||
-- 거래처명 (TASK:ERP-098 항목20): 출고 행 customer_name 우선,
|
||||
-- 없으면 customer_mng JOIN, 매핑 깨지면 코드 fallback
|
||||
COALESCE(
|
||||
NULLIF(om.customer_name, ''),
|
||||
NULLIF(c.customer_name, ''),
|
||||
NULLIF(om.customer_code, ''),
|
||||
''
|
||||
) AS customer_name,
|
||||
-- 품목 규격 (TASK:ERP-098 항목19): 유리업종 가로/세로/두께 (item_info JOIN)
|
||||
COALESCE(ii.width::text, '') AS width,
|
||||
COALESCE(ii.height::text, '') AS height,
|
||||
COALESCE(ii.thickness::text, '') AS thickness
|
||||
FROM outbound_mng om
|
||||
LEFT JOIN warehouse_info wh
|
||||
ON om.warehouse_code = wh.warehouse_code
|
||||
AND om.company_code = wh.company_code
|
||||
LEFT JOIN shipment_instruction_detail sid
|
||||
ON om.source_table = 'shipment_instruction_detail'
|
||||
AND om.source_id = sid.id::text
|
||||
AND om.company_code = sid.company_code
|
||||
LEFT JOIN shipment_instruction si
|
||||
ON sid.instruction_id = si.id
|
||||
AND sid.company_code = si.company_code
|
||||
LEFT JOIN customer_mng c
|
||||
ON om.customer_code = c.customer_code
|
||||
AND om.company_code = c.company_code
|
||||
LEFT JOIN item_info ii
|
||||
ON om.item_code = ii.item_number
|
||||
AND om.company_code = ii.company_code
|
||||
${whereClause}
|
||||
ORDER BY om.created_date DESC
|
||||
`;
|
||||
@@ -581,7 +615,17 @@ export async function getShipmentInstructions(
|
||||
let paramIdx = 2;
|
||||
|
||||
if (customer) {
|
||||
conditions.push(`si.partner_id = $${paramIdx}`);
|
||||
// 출하지시(shipment_instruction.partner_id)에는 거래처코드 또는 거래처명이
|
||||
// 혼재 저장될 수 있음(출하지시 등록 화면이 partner_code 미확보 시 거래처명을
|
||||
// fallback 저장). POP 출고는 거래처코드로 조회하므로 코드/명 양쪽 + 거래처
|
||||
// 마스터의 코드/명 모두로 매칭해 누락을 방지한다.
|
||||
conditions.push(
|
||||
`(
|
||||
si.partner_id = $${paramIdx}
|
||||
OR c.customer_code = $${paramIdx}
|
||||
OR c.customer_name = $${paramIdx}
|
||||
)`,
|
||||
);
|
||||
params.push(customer);
|
||||
paramIdx++;
|
||||
}
|
||||
@@ -632,7 +676,7 @@ export async function getShipmentInstructions(
|
||||
ON si.id = sid.instruction_id
|
||||
AND si.company_code = sid.company_code
|
||||
LEFT JOIN customer_mng c
|
||||
ON si.partner_id = c.customer_code
|
||||
ON (si.partner_id = c.customer_code OR si.partner_id = c.customer_name)
|
||||
AND si.company_code = c.company_code
|
||||
LEFT JOIN sales_order_detail sod
|
||||
ON sod.id::text = sid.detail_id::text
|
||||
@@ -641,7 +685,7 @@ export async function getShipmentInstructions(
|
||||
ON ii.item_number = sid.item_code
|
||||
AND ii.company_code = sid.company_code
|
||||
LEFT JOIN customer_item_mapping cim
|
||||
ON cim.customer_id = si.partner_id
|
||||
ON cim.customer_id = COALESCE(c.customer_code, si.partner_id)
|
||||
AND cim.company_code = sid.company_code
|
||||
AND (cim.item_id = sid.item_code OR cim.item_id = ii.id::text)
|
||||
WHERE ${conditions.join(" AND ")}
|
||||
|
||||
@@ -237,8 +237,8 @@ export async function createPkgUnitItem(
|
||||
}
|
||||
|
||||
const result = await pool.query(
|
||||
`INSERT INTO pkg_unit_item (company_code, pkg_code, item_number, pkg_qty, writer)
|
||||
VALUES ($1,$2,$3,$4,$5)
|
||||
`INSERT INTO pkg_unit_item (id, company_code, pkg_code, item_number, pkg_qty, writer, created_date)
|
||||
VALUES (gen_random_uuid()::text, $1,$2,$3,$4,$5, NOW())
|
||||
RETURNING *`,
|
||||
[companyCode, pkg_code, item_number, pkg_qty, req.user!.userId]
|
||||
);
|
||||
@@ -513,8 +513,8 @@ export async function createLoadingUnitPkg(
|
||||
}
|
||||
|
||||
const result = await pool.query(
|
||||
`INSERT INTO loading_unit_pkg (company_code, loading_code, pkg_code, max_load_qty, load_method, writer)
|
||||
VALUES ($1,$2,$3,$4,$5,$6)
|
||||
`INSERT INTO loading_unit_pkg (id, company_code, loading_code, pkg_code, max_load_qty, load_method, writer, created_date)
|
||||
VALUES (gen_random_uuid()::text, $1,$2,$3,$4,$5,$6, NOW())
|
||||
RETURNING *`,
|
||||
[companyCode, loading_code, pkg_code, max_load_qty, load_method, req.user!.userId]
|
||||
);
|
||||
|
||||
@@ -3631,6 +3631,11 @@ export const getChecklistItems = async (
|
||||
.json({ success: false, message: "processId 필수" });
|
||||
}
|
||||
|
||||
// 검사기준 마스터(inspection_standard) JOIN — 판단기준(judgment_criteria) 전달용.
|
||||
// - 체크리스트 row 의 inspection_code 는 비어있는 경우가 많아(템플릿 미연결)
|
||||
// 1차로 검사항목명(detail_content/item_title) ↔ inspection_standard.inspection_item 로 매칭,
|
||||
// 2차로 inspection_code 매칭을 fallback 으로 둔다.
|
||||
// - 매칭된 검사기준의 unit/lower_limit/upper_limit 도 보조 노출(체크리스트 row 자체값 우선).
|
||||
const result = await pool.query(
|
||||
`SELECT
|
||||
pwr.id,
|
||||
@@ -3647,7 +3652,7 @@ export const getChecklistItems = async (
|
||||
pwr.is_required,
|
||||
pwr.inspection_code,
|
||||
pwr.inspection_method,
|
||||
pwr.unit,
|
||||
COALESCE(NULLIF(pwr.unit, ''), ist.unit) AS unit,
|
||||
pwr.lower_limit,
|
||||
pwr.upper_limit,
|
||||
pwr.input_type,
|
||||
@@ -3665,11 +3670,28 @@ export const getChecklistItems = async (
|
||||
pwr.group_paused_at,
|
||||
pwr.group_total_paused_time,
|
||||
pwr.group_completed_at,
|
||||
ist.judgment_criteria
|
||||
ist.judgment_criteria,
|
||||
ist.selection_options,
|
||||
ist.inspection_code AS matched_inspection_code
|
||||
FROM process_work_result pwr
|
||||
LEFT JOIN inspection_standard ist
|
||||
ON pwr.inspection_code = ist.inspection_code
|
||||
AND pwr.company_code = ist.company_code
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT s.judgment_criteria, s.unit, s.inspection_code, s.selection_options
|
||||
FROM inspection_standard s
|
||||
WHERE s.company_code = pwr.company_code
|
||||
AND (
|
||||
(NULLIF(pwr.inspection_code, '') IS NOT NULL
|
||||
AND s.inspection_code = pwr.inspection_code)
|
||||
-- detail_content 에는 UI 구분자(" |") 가 함께 저장된 경우가 있어 양끝 공백/'|' 제거 후 비교
|
||||
OR TRIM(BOTH ' |' FROM COALESCE(s.inspection_item, ''))
|
||||
= TRIM(BOTH ' |' FROM COALESCE(pwr.detail_content, ''))
|
||||
OR TRIM(BOTH ' |' FROM COALESCE(s.inspection_item, ''))
|
||||
= TRIM(BOTH ' |' FROM COALESCE(pwr.item_title, ''))
|
||||
)
|
||||
-- inspection_code 정확매칭을 최우선, 그 다음 항목명 매칭
|
||||
ORDER BY CASE WHEN NULLIF(pwr.inspection_code, '') IS NOT NULL
|
||||
AND s.inspection_code = pwr.inspection_code THEN 0 ELSE 1 END
|
||||
LIMIT 1
|
||||
) ist ON true
|
||||
WHERE pwr.work_order_process_id = $1
|
||||
AND pwr.company_code = $2
|
||||
ORDER BY
|
||||
|
||||
@@ -59,6 +59,9 @@ export async function getList(req: AuthenticatedRequest, res: Response) {
|
||||
SELECT
|
||||
si.*,
|
||||
COALESCE(c.customer_name, '') AS customer_name,
|
||||
-- 납품처 (TASK:ERP-097): 출하지시 헤더의 납품주소 우선,
|
||||
-- 없으면 거래처 기본 납품처(delivery_destination)로 폴백
|
||||
COALESCE(NULLIF(si.delivery_address, ''), NULLIF(dd.destination_name, ''), '') AS delivery_destination_name,
|
||||
COALESCE(
|
||||
json_agg(
|
||||
json_build_object(
|
||||
@@ -91,8 +94,15 @@ export async function getList(req: AuthenticatedRequest, res: Response) {
|
||||
WHERE item_number = sid.item_code AND company_code = si.company_code
|
||||
LIMIT 1
|
||||
) i ON true
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT destination_name FROM delivery_destination
|
||||
WHERE customer_code = si.partner_id AND company_code = si.company_code
|
||||
ORDER BY CASE WHEN is_default = 'true' OR is_default = 'Y' OR is_default = '1' THEN 0 ELSE 1 END,
|
||||
created_date ASC NULLS LAST
|
||||
LIMIT 1
|
||||
) dd ON true
|
||||
${where}
|
||||
GROUP BY si.id, c.customer_name
|
||||
GROUP BY si.id, c.customer_name, dd.destination_name
|
||||
ORDER BY si.created_date DESC
|
||||
`;
|
||||
|
||||
|
||||
@@ -257,7 +257,10 @@ export async function getList(req: AuthenticatedRequest, res: Response) {
|
||||
COALESCE(NULLIF(d.ship_qty,'')::numeric, m.ship_qty, 0) AS shipped_qty,
|
||||
COALESCE(NULLIF(d.width, ''), i.width::text, '') AS width,
|
||||
COALESCE(NULLIF(d.height, ''), i.height::text, '') AS height,
|
||||
COALESCE(NULLIF(d.thickness, ''), i.thickness::text, '') AS thickness
|
||||
COALESCE(NULLIF(d.thickness, ''), i.thickness::text, '') AS thickness,
|
||||
-- 납품처 (TASK:ERP-097): 수주상세/마스터의 납품장소 텍스트 우선,
|
||||
-- 없으면 거래처 기본 납품처(delivery_destination)로 폴백
|
||||
COALESCE(NULLIF(d.delivery_location, ''), NULLIF(m.delivery_address, ''), NULLIF(dd.destination_name, ''), '') AS delivery_destination_name
|
||||
FROM shipment_plan sp
|
||||
LEFT JOIN sales_order_mng m
|
||||
ON sp.sales_order_id = m.id AND sp.company_code = m.company_code
|
||||
@@ -274,6 +277,14 @@ export async function getList(req: AuthenticatedRequest, res: Response) {
|
||||
LEFT JOIN customer_mng c
|
||||
ON COALESCE(NULLIF(m.partner_id, ''), NULLIF(d.delivery_partner_code, '')) = c.customer_code
|
||||
AND sp.company_code = c.company_code
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT destination_name FROM delivery_destination
|
||||
WHERE customer_code = COALESCE(NULLIF(m.partner_id, ''), NULLIF(d.delivery_partner_code, ''))
|
||||
AND company_code = sp.company_code
|
||||
ORDER BY CASE WHEN is_default = 'true' OR is_default = 'Y' OR is_default = '1' THEN 0 ELSE 1 END,
|
||||
created_date ASC NULLS LAST
|
||||
LIMIT 1
|
||||
) dd ON true
|
||||
${whereClause}
|
||||
ORDER BY sp.created_date DESC
|
||||
`;
|
||||
|
||||
@@ -388,10 +388,36 @@ export async function save(req: AuthenticatedRequest, res: Response) {
|
||||
wiId = insertRes.rows[0].id;
|
||||
}
|
||||
|
||||
// 품목별 기본 라우팅 버전 자동 해석 (TASK:ERP-node-104)
|
||||
// 절단계획 적용 등으로 item.routing 이 비어 들어오는 경로를 위해,
|
||||
// 해당 품목의 활성(기본) 라우팅 버전을 조회해 자동 세팅한다.
|
||||
// - is_default=true 우선, 없으면 최신(created_date DESC) 1건
|
||||
// - 품목에 라우팅 버전이 0건이면 빈값 유지(작업지시 생성은 차단하지 않음)
|
||||
const routingCache = new Map<string, string | null>();
|
||||
const resolveDefaultRouting = async (itemCode: string): Promise<string | null> => {
|
||||
const key = String(itemCode || "").trim();
|
||||
if (!key) return null;
|
||||
if (routingCache.has(key)) return routingCache.get(key) || null;
|
||||
const rv = await client.query(
|
||||
`SELECT id FROM item_routing_version
|
||||
WHERE item_code = $1 AND company_code = $2
|
||||
ORDER BY COALESCE(is_default, false) DESC, created_date DESC
|
||||
LIMIT 1`,
|
||||
[key, companyCode]
|
||||
);
|
||||
const resolved = rv.rowCount && rv.rowCount > 0 ? rv.rows[0].id : null;
|
||||
routingCache.set(key, resolved);
|
||||
return resolved;
|
||||
};
|
||||
|
||||
let totalQty = 0;
|
||||
let firstRouting: string | null = null;
|
||||
for (const item of items) {
|
||||
const itemRouting = item.routing || null;
|
||||
// 프론트가 명시한 routing 우선, 없으면 품목 기본 라우팅 버전 자동 조회
|
||||
let itemRouting: string | null = item.routing || null;
|
||||
if (!itemRouting) {
|
||||
itemRouting = await resolveDefaultRouting(item.itemNumber || item.itemCode || item.partCode || "");
|
||||
}
|
||||
if (!firstRouting && itemRouting) firstRouting = itemRouting;
|
||||
totalQty += Number(item.qty || 0);
|
||||
await client.query(
|
||||
|
||||
@@ -12,9 +12,15 @@ import { getPool } from "../database/db";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
// ─── 근무일 계산 헬퍼 (토/일 제외) ───
|
||||
//
|
||||
// ⚠️ 모든 날짜 연산은 UTC 기준으로 통일한다. (TASK:ERP-node-107)
|
||||
// `new Date("2026-05-21")` 는 UTC 자정으로 파싱되는데,
|
||||
// `new Date(); setHours(0,0,0,0)` 는 로컬(KST) 자정 → UTC 로는 전날이 됨.
|
||||
// 둘을 섞으면 `.toISOString()` 직렬화 시 스케줄 날짜가 하루 과거로 밀린다.
|
||||
// → getUTC*/setUTC* 와 todayUTC() 로 기준을 일치시켜 드리프트 제거.
|
||||
|
||||
function isWeekend(date: Date): boolean {
|
||||
const day = date.getDay();
|
||||
const day = date.getUTCDay();
|
||||
return day === 0 || day === 6;
|
||||
}
|
||||
|
||||
@@ -22,7 +28,7 @@ function isWeekend(date: Date): boolean {
|
||||
function skipWeekend(date: Date, direction: "forward" | "backward"): Date {
|
||||
const d = new Date(date);
|
||||
while (isWeekend(d)) {
|
||||
d.setDate(d.getDate() + (direction === "forward" ? 1 : -1));
|
||||
d.setUTCDate(d.getUTCDate() + (direction === "forward" ? 1 : -1));
|
||||
}
|
||||
return d;
|
||||
}
|
||||
@@ -34,12 +40,79 @@ function addWorkingDays(date: Date, workingDays: number): Date {
|
||||
const step = workingDays > 0 ? 1 : -1;
|
||||
let remaining = Math.abs(workingDays);
|
||||
while (remaining > 0) {
|
||||
d.setDate(d.getDate() + step);
|
||||
d.setUTCDate(d.getUTCDate() + step);
|
||||
if (!isWeekend(d)) remaining--;
|
||||
}
|
||||
return d;
|
||||
}
|
||||
|
||||
// 오늘(UTC 자정). 스케줄 입력 날짜(new Date("YYYY-MM-DD"))와 동일 기준.
|
||||
function todayUTC(): Date {
|
||||
const n = new Date();
|
||||
return new Date(Date.UTC(n.getFullYear(), n.getMonth(), n.getDate()));
|
||||
}
|
||||
|
||||
// earliest_due_date(문자열/null) → 유효한 Date 또는 null
|
||||
function parseDueDate(raw: unknown): Date | null {
|
||||
if (raw == null) return null;
|
||||
const s = String(raw).trim();
|
||||
if (!s) return null;
|
||||
// "2026-05-21" 또는 "2026-05-21T..." 형태 모두 앞 10자리만 사용 → UTC 자정 파싱
|
||||
const d = new Date(s.slice(0, 10));
|
||||
return isNaN(d.getTime()) ? null : d;
|
||||
}
|
||||
|
||||
/**
|
||||
* 단일 품목 스케줄 날짜 산출 (preview/generate 공통).
|
||||
* - earliest_due_date 가 유효하지 않으면 오늘을 납기로 간주.
|
||||
* - 생산일수: lead_time>0 이면 lead_time, 아니면 ceil(qty/dailyCapacity) (최소 1일).
|
||||
* - 시작일이 과거면 오늘부터 재배치(근무일 수 유지) — 현행 정책 유지(납기 초과 시 경고만).
|
||||
*/
|
||||
function computeScheduleDates(
|
||||
requiredQty: number,
|
||||
earliestDueDate: unknown,
|
||||
itemLeadTime: number,
|
||||
dailyCapacity: number,
|
||||
safetyLeadTime: number
|
||||
): { startDate: Date; endDate: Date; productionDays: number; dueExceeded: boolean } {
|
||||
const productionDays =
|
||||
itemLeadTime > 0 ? itemLeadTime : Math.max(1, Math.ceil(requiredQty / dailyCapacity));
|
||||
|
||||
// 납기일 미설정 시 오늘을 납기로 간주 (스케줄은 산출하되 dueExceeded 로 경고)
|
||||
const parsedDue = parseDueDate(earliestDueDate);
|
||||
const dueDate = parsedDue || todayUTC();
|
||||
|
||||
let endDate: Date;
|
||||
let startDate: Date;
|
||||
|
||||
if (itemLeadTime > 0) {
|
||||
// 종료일 = 납기일(주말이면 이전 평일), 시작일 = 종료일에서 (리드타임-1) 근무일 전
|
||||
endDate = skipWeekend(new Date(dueDate), "backward");
|
||||
startDate = addWorkingDays(endDate, -(productionDays - 1));
|
||||
} else {
|
||||
// 리드타임 없으면 생산능력 기반: 종료일 = 납기일 - 안전여유(근무일)
|
||||
endDate = addWorkingDays(skipWeekend(new Date(dueDate), "backward"), -safetyLeadTime);
|
||||
startDate = addWorkingDays(endDate, -(productionDays - 1));
|
||||
}
|
||||
|
||||
// 시작일이 과거면 오늘(주말이면 다음 평일)부터 재배치, 작업 근무일 수 유지
|
||||
const today = todayUTC();
|
||||
if (startDate < today) {
|
||||
startDate = skipWeekend(today, "forward");
|
||||
endDate = addWorkingDays(startDate, productionDays - 1);
|
||||
}
|
||||
|
||||
// 납기 초과 판정: 종료일이 납기일(주말 보정 후)보다 늦으면 경고
|
||||
const dueDeadline = skipWeekend(new Date(dueDate), "backward");
|
||||
const dueExceeded = endDate.getTime() > dueDeadline.getTime();
|
||||
|
||||
return { startDate, endDate, productionDays, dueExceeded };
|
||||
}
|
||||
|
||||
function fmtDate(d: Date): string {
|
||||
return d.toISOString().split("T")[0];
|
||||
}
|
||||
|
||||
// ─── 수주 데이터 조회 (품목별 그룹핑) ───
|
||||
|
||||
export async function getOrderSummary(
|
||||
@@ -570,11 +643,17 @@ export async function previewSchedule(
|
||||
) {
|
||||
const pool = getPool();
|
||||
const productType = options.product_type || "완제품";
|
||||
const safetyLeadTime = options.safety_lead_time || 1;
|
||||
// safety_lead_time 은 0 도 유효한 값 → null/undefined 일 때만 기본 1
|
||||
const safetyLeadTime =
|
||||
options.safety_lead_time != null ? options.safety_lead_time : 1;
|
||||
|
||||
const previews: any[] = [];
|
||||
const deletedSchedules: any[] = [];
|
||||
const keptSchedules: any[] = [];
|
||||
// 스케줄 대상에서 제외된 품목 (수량 0 / 납기 미설정 등) — 화면 안내용
|
||||
const skipped: any[] = [];
|
||||
// 납기 초과 경고가 붙은 품목
|
||||
const warnings: any[] = [];
|
||||
|
||||
// 같은 item_code에 대한 삭제/유지 조회는 한 번만 수행
|
||||
if (options.recalculate_unstarted) {
|
||||
@@ -610,31 +689,25 @@ export async function previewSchedule(
|
||||
// (recalculate_unstarted 시 기존 planned는 위에서 이미 삭제됨)
|
||||
const requiredQty = item.required_qty;
|
||||
|
||||
if (requiredQty <= 0) continue;
|
||||
|
||||
// 리드타임 기반 날짜 계산 (근무일 기준, 토/일 제외, 시작·종료 포함 N일)
|
||||
const dueDate = new Date(item.earliest_due_date);
|
||||
const productionDays = itemLeadTime > 0 ? itemLeadTime : Math.ceil(requiredQty / dailyCapacity);
|
||||
let endDate: Date;
|
||||
let startDate: Date;
|
||||
|
||||
if (itemLeadTime > 0) {
|
||||
// 종료일 = 납기일(주말이면 이전 평일), 시작일 = 종료일에서 (리드타임-1) 근무일 전
|
||||
endDate = skipWeekend(new Date(dueDate), "backward");
|
||||
startDate = addWorkingDays(endDate, -(productionDays - 1));
|
||||
} else {
|
||||
// 리드타임이 없으면 생산능력 기반: 종료일 = 납기일 - 안전여유(근무일)
|
||||
endDate = addWorkingDays(skipWeekend(new Date(dueDate), "backward"), -safetyLeadTime);
|
||||
startDate = addWorkingDays(endDate, -(productionDays - 1));
|
||||
// 수량 0 이하 → 스케줄 대상 제외 (껍데기 수주 등)
|
||||
if (!(requiredQty > 0)) {
|
||||
skipped.push({
|
||||
item_code: item.item_code,
|
||||
item_name: item.item_name,
|
||||
reason: "계획 수량이 0 이하",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
if (startDate < today) {
|
||||
// 시작일이 과거면 오늘(주말이면 다음 평일)부터 재배치, 작업 근무일 수 유지
|
||||
startDate = skipWeekend(today, "forward");
|
||||
endDate = addWorkingDays(startDate, productionDays - 1);
|
||||
}
|
||||
const hasValidDue = parseDueDate(item.earliest_due_date) != null;
|
||||
|
||||
const { startDate, endDate, productionDays, dueExceeded } = computeScheduleDates(
|
||||
requiredQty,
|
||||
item.earliest_due_date,
|
||||
itemLeadTime,
|
||||
dailyCapacity,
|
||||
safetyLeadTime
|
||||
);
|
||||
|
||||
// 해당 품목의 수주 건수 확인
|
||||
const orderCountResult = await pool.query(
|
||||
@@ -644,18 +717,34 @@ export async function previewSchedule(
|
||||
);
|
||||
const orderCount = parseInt(orderCountResult.rows[0].cnt, 10);
|
||||
|
||||
// 경고 사유 수집 (스케줄은 산출 — 현행 정책: 경고만)
|
||||
const itemWarnings: string[] = [];
|
||||
if (!hasValidDue) itemWarnings.push("납기일 미설정 (오늘 기준으로 산출)");
|
||||
if (dueExceeded) itemWarnings.push("종료일이 납기일을 초과");
|
||||
|
||||
if (itemWarnings.length > 0) {
|
||||
warnings.push({
|
||||
item_code: item.item_code,
|
||||
item_name: item.item_name,
|
||||
messages: itemWarnings,
|
||||
});
|
||||
}
|
||||
|
||||
previews.push({
|
||||
item_code: item.item_code,
|
||||
item_name: item.item_name,
|
||||
required_qty: requiredQty,
|
||||
plan_qty: requiredQty,
|
||||
daily_capacity: dailyCapacity,
|
||||
hourly_capacity: item.hourly_capacity || 100,
|
||||
production_days: productionDays,
|
||||
start_date: startDate.toISOString().split("T")[0],
|
||||
end_date: endDate.toISOString().split("T")[0],
|
||||
due_date: item.earliest_due_date,
|
||||
start_date: fmtDate(startDate),
|
||||
end_date: fmtDate(endDate),
|
||||
due_date: hasValidDue ? String(item.earliest_due_date).slice(0, 10) : null,
|
||||
lead_time: itemLeadTime,
|
||||
order_count: orderCount,
|
||||
due_exceeded: dueExceeded,
|
||||
warning: itemWarnings.length > 0 ? itemWarnings.join(", ") : null,
|
||||
status: "planned",
|
||||
});
|
||||
}
|
||||
@@ -665,10 +754,12 @@ export async function previewSchedule(
|
||||
new_count: previews.length,
|
||||
kept_count: keptSchedules.length,
|
||||
deleted_count: deletedSchedules.length,
|
||||
skipped_count: skipped.length,
|
||||
warning_count: warnings.length,
|
||||
};
|
||||
|
||||
logger.info("자동 스케줄 미리보기", { companyCode, summary });
|
||||
return { summary, schedules: previews, deletedSchedules, keptSchedules };
|
||||
return { summary, schedules: previews, deletedSchedules, keptSchedules, skipped, warnings };
|
||||
}
|
||||
|
||||
export async function generateSchedule(
|
||||
@@ -680,7 +771,9 @@ export async function generateSchedule(
|
||||
const pool = getPool();
|
||||
const client = await pool.connect();
|
||||
const productType = options.product_type || "완제품";
|
||||
const safetyLeadTime = options.safety_lead_time || 1;
|
||||
// safety_lead_time 은 0 도 유효한 값 → null/undefined 일 때만 기본 1
|
||||
const safetyLeadTime =
|
||||
options.safety_lead_time != null ? options.safety_lead_time : 1;
|
||||
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
@@ -688,6 +781,8 @@ export async function generateSchedule(
|
||||
let deletedCount = 0;
|
||||
let keptCount = 0;
|
||||
const newSchedules: any[] = [];
|
||||
const skipped: any[] = [];
|
||||
const warnings: any[] = [];
|
||||
const deletedQtyByItem = new Map<string, number>();
|
||||
|
||||
// 같은 item_code에 대한 삭제는 한 번만 수행
|
||||
@@ -734,28 +829,37 @@ export async function generateSchedule(
|
||||
// 프론트에서 이미 전체 잔량 기준으로 계산하여 보내므로 그대로 사용
|
||||
// (recalculate_unstarted 시 기존 planned는 위에서 이미 삭제됨)
|
||||
const requiredQty = item.required_qty;
|
||||
if (requiredQty <= 0) continue;
|
||||
|
||||
// 리드타임 기반 날짜 계산 (근무일 기준, 토/일 제외, 시작·종료 포함 N일)
|
||||
const dueDate = new Date(item.earliest_due_date);
|
||||
const productionDays = itemLeadTime > 0 ? itemLeadTime : Math.ceil(requiredQty / dailyCapacity);
|
||||
let endDate: Date;
|
||||
let startDate: Date;
|
||||
|
||||
if (itemLeadTime > 0) {
|
||||
endDate = skipWeekend(new Date(dueDate), "backward");
|
||||
startDate = addWorkingDays(endDate, -(productionDays - 1));
|
||||
} else {
|
||||
endDate = addWorkingDays(skipWeekend(new Date(dueDate), "backward"), -safetyLeadTime);
|
||||
startDate = addWorkingDays(endDate, -(productionDays - 1));
|
||||
// 수량 0 이하 → 스케줄 대상 제외 (껍데기 수주 등)
|
||||
if (!(requiredQty > 0)) {
|
||||
skipped.push({
|
||||
item_code: item.item_code,
|
||||
item_name: item.item_name,
|
||||
reason: "계획 수량이 0 이하",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// 시작일이 오늘보다 이전이면 오늘(주말이면 다음 평일)로 조정
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
if (startDate < today) {
|
||||
startDate = skipWeekend(today, "forward");
|
||||
endDate = addWorkingDays(startDate, productionDays - 1);
|
||||
const hasValidDue = parseDueDate(item.earliest_due_date) != null;
|
||||
|
||||
// 리드타임 기반 날짜 계산 (preview 와 동일 로직 — computeScheduleDates 공유)
|
||||
const { startDate, endDate, dueExceeded } = computeScheduleDates(
|
||||
requiredQty,
|
||||
item.earliest_due_date,
|
||||
itemLeadTime,
|
||||
dailyCapacity,
|
||||
safetyLeadTime
|
||||
);
|
||||
|
||||
const itemWarnings: string[] = [];
|
||||
if (!hasValidDue) itemWarnings.push("납기일 미설정 (오늘 기준으로 산출)");
|
||||
if (dueExceeded) itemWarnings.push("종료일이 납기일을 초과");
|
||||
if (itemWarnings.length > 0) {
|
||||
warnings.push({
|
||||
item_code: item.item_code,
|
||||
item_name: item.item_name,
|
||||
messages: itemWarnings,
|
||||
});
|
||||
}
|
||||
|
||||
// 계획번호 생성 (YYYYMMDD-NNNN 형식)
|
||||
@@ -784,16 +888,17 @@ export async function generateSchedule(
|
||||
[
|
||||
companyCode, planNo, item.item_code, item.item_name,
|
||||
productType, requiredQty,
|
||||
startDate.toISOString().split("T")[0],
|
||||
endDate.toISOString().split("T")[0],
|
||||
item.earliest_due_date,
|
||||
fmtDate(startDate),
|
||||
fmtDate(endDate),
|
||||
// 납기 미설정/빈값이면 NULL 저장 ('' 를 date 컬럼에 넣으면 에러)
|
||||
hasValidDue ? String(item.earliest_due_date).slice(0, 10) : null,
|
||||
item.hourly_capacity || 100,
|
||||
dailyCapacity,
|
||||
item.lead_time || 1,
|
||||
createdBy,
|
||||
]
|
||||
);
|
||||
newSchedules.push(insertResult.rows[0]);
|
||||
newSchedules.push({ ...insertResult.rows[0], due_exceeded: dueExceeded });
|
||||
}
|
||||
|
||||
await client.query("COMMIT");
|
||||
@@ -803,10 +908,12 @@ export async function generateSchedule(
|
||||
new_count: newSchedules.length,
|
||||
kept_count: keptCount,
|
||||
deleted_count: deletedCount,
|
||||
skipped_count: skipped.length,
|
||||
warning_count: warnings.length,
|
||||
};
|
||||
|
||||
logger.info("자동 스케줄 생성 완료", { companyCode, summary });
|
||||
return { summary, schedules: newSchedules };
|
||||
return { summary, schedules: newSchedules, skipped, warnings };
|
||||
} catch (error) {
|
||||
await client.query("ROLLBACK");
|
||||
logger.error("자동 스케줄 생성 실패", { companyCode, error });
|
||||
|
||||
@@ -67,6 +67,28 @@ function normStr(v: any): string {
|
||||
return String(v).trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* category_values에서 라벨로 value_code resolve (회사별)
|
||||
* - 코드값은 회사/환경마다 다르므로 라벨 기준으로 조회
|
||||
* - 매칭 실패 시 null 반환 (호출부에서 빈값 처리)
|
||||
*/
|
||||
async function resolveCategoryCode(
|
||||
client: any,
|
||||
companyCode: string,
|
||||
tableName: string,
|
||||
columnName: string,
|
||||
label: string
|
||||
): Promise<string | null> {
|
||||
const r = await client.query(
|
||||
`SELECT value_code FROM category_values
|
||||
WHERE company_code = $1 AND table_name = $2 AND column_name = $3
|
||||
AND TRIM(value_label) = $4
|
||||
LIMIT 1`,
|
||||
[companyCode, tableName, columnName, label]
|
||||
);
|
||||
return r.rows.length > 0 ? r.rows[0].value_code : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 엑셀 한 줄 → item_info에서 기존 품목 조회, 없으면 INSERT
|
||||
* 반환: { itemNumber: string, created: boolean }
|
||||
@@ -125,18 +147,29 @@ async function resolveOrCreateItem(
|
||||
const seq = (Number(seqR.rows[0]?.c) || 0) + 1;
|
||||
const itemNumber = `${prefix}${String(seq).padStart(4, "0")}`;
|
||||
|
||||
const division = normStr(row.division) || defaultDivision;
|
||||
const unit = normStr(row.unit) || "EA";
|
||||
|
||||
// 항목 9: 관리품목="영업관리", 품목구분="제품", 상태="활성" 고정값
|
||||
// category_values에서 COMPANY_9 기준 라벨→코드 resolve (코드 직접 하드코딩 금지)
|
||||
const divisionCode =
|
||||
(await resolveCategoryCode(client, companyCode, "item_info", "division", "영업관리")) || "";
|
||||
const typeCode =
|
||||
(await resolveCategoryCode(client, companyCode, "item_info", "type", "제품")) || "";
|
||||
const statusCode =
|
||||
(await resolveCategoryCode(client, companyCode, "item_info", "status", "활성")) || "";
|
||||
|
||||
// 항목 12: 수주 디테일 단가 → 판매단가(selling_price). 기준단가(standard_price) 아님
|
||||
const sellingPrice = toNum(row.unit_price);
|
||||
|
||||
await client.query(
|
||||
`INSERT INTO item_info (
|
||||
id, company_code, item_number, item_name,
|
||||
size, width, height, thickness,
|
||||
unit, division, status, writer, created_date
|
||||
unit, division, type, status, selling_price, writer, created_date
|
||||
) VALUES (
|
||||
gen_random_uuid()::text, $1, $2, $3,
|
||||
$4, $5, $6, $7,
|
||||
$8, $9, 'active', $10, NOW()
|
||||
$8, $9, $10, $11, $12, $13, NOW()
|
||||
)`,
|
||||
[
|
||||
companyCode,
|
||||
@@ -147,7 +180,10 @@ async function resolveOrCreateItem(
|
||||
toNum(row.height) || null,
|
||||
toNum(row.thickness) || null,
|
||||
unit,
|
||||
division,
|
||||
divisionCode,
|
||||
typeCode,
|
||||
statusCode,
|
||||
sellingPrice ? String(sellingPrice) : null,
|
||||
userId,
|
||||
]
|
||||
);
|
||||
|
||||
@@ -104,6 +104,7 @@ const GRID_COLUMNS = [
|
||||
{ key: "reference_number", label: "참조번호" },
|
||||
{ key: "source_table", label: "데이터출처" },
|
||||
{ key: "customer_name", label: "거래처" },
|
||||
{ key: "delivery_destination_name", label: "납품처" },
|
||||
{ key: "item_number", label: "품목코드" },
|
||||
{ key: "item_name", label: "품목명" },
|
||||
{ key: "width", label: "가로" },
|
||||
@@ -118,8 +119,8 @@ const GRID_COLUMNS = [
|
||||
{ key: "remark", label: "비고" },
|
||||
];
|
||||
|
||||
// 총 컬럼 수: 체크박스(1) + GRID_COLUMNS(18) = 19
|
||||
const TOTAL_COLS = 19;
|
||||
// 총 컬럼 수: 체크박스(1) + GRID_COLUMNS(19) = 20
|
||||
const TOTAL_COLS = 20;
|
||||
|
||||
// 헤더 필터 Popover
|
||||
function HeaderFilterPopover({
|
||||
@@ -1021,6 +1022,7 @@ export default function OutboundPage() {
|
||||
<TableCell className="text-[13px] truncate max-w-[120px]"><span className="block truncate">{row.reference_number || ""}</span></TableCell>
|
||||
<TableCell className="text-[13px]">{row.source_table || ""}</TableCell>
|
||||
<TableCell className="text-[13px] truncate max-w-[110px]"><span className="block truncate">{row.customer_name || ""}</span></TableCell>
|
||||
<TableCell className="text-[13px] truncate max-w-[120px]"><span className="block truncate">{(row as any).delivery_destination_name || (row as any).delivery_destination || "-"}</span></TableCell>
|
||||
<TableCell className="font-mono text-[13px]">{row.item_number || ""}</TableCell>
|
||||
<TableCell className="text-[13px] max-w-[140px]"><span className="block truncate" title={row.item_name || ""}>{row.item_name || ""}</span></TableCell>
|
||||
<TableCell className="text-right font-mono text-[13px] text-muted-foreground">{(row as any).width || "-"}</TableCell>
|
||||
|
||||
@@ -450,7 +450,9 @@ export default function PackagingPage() {
|
||||
};
|
||||
|
||||
const savePkgMatch = async () => {
|
||||
if (!selectedLoading || !pkgMatchSelected) { toast.error("포장단위를 선택해주세요."); return; }
|
||||
if (!selectedLoading) { toast.error("적재함을 먼저 등록해주세요."); return; }
|
||||
if (!selectedLoading.id) { toast.error("적재함을 먼저 등록해주세요."); return; }
|
||||
if (!pkgMatchSelected) { toast.error("포장단위를 선택해주세요."); return; }
|
||||
if (pkgMatchQty <= 0) { toast.error("최대적재수량을 입력해주세요."); return; }
|
||||
setSaving(true);
|
||||
try {
|
||||
@@ -461,6 +463,7 @@ export default function PackagingPage() {
|
||||
load_method: pkgMatchMethod || undefined,
|
||||
});
|
||||
if (res.success) { toast.success("포장단위 추가 완료"); setPkgMatchModalOpen(false); selectLoading(selectedLoading); }
|
||||
else { toast.error(res.message || "추가 실패"); }
|
||||
} catch { toast.error("추가 실패"); } finally { setSaving(false); }
|
||||
};
|
||||
|
||||
@@ -1057,13 +1060,20 @@ export default function PackagingPage() {
|
||||
<TableBody>
|
||||
{(() => {
|
||||
const kw = pkgMatchSearchKw.toLowerCase();
|
||||
// 등록된 포장재(pkg_unit에 id 보유)만 후보로. 미등록 placeholder 품목 제외.
|
||||
// 상태(ACTIVE/INACTIVE) 무관 — INACTIVE도 선택 가능하도록 노출.
|
||||
const filtered = pkgUnits.filter(p =>
|
||||
p.status === "ACTIVE"
|
||||
!!p.id
|
||||
&& !loadingPkgs.some(lp => lp.pkg_code === p.pkg_code)
|
||||
&& (!kw || p.pkg_code?.toLowerCase().includes(kw) || p.pkg_name?.toLowerCase().includes(kw))
|
||||
);
|
||||
const hasRegisteredPkg = pkgUnits.some(p => !!p.id);
|
||||
return filtered.length === 0 ? (
|
||||
<TableRow><TableCell colSpan={6} className="h-16 text-center text-[13px] text-muted-foreground">추가 가능한 포장단위가 없습니다</TableCell></TableRow>
|
||||
<TableRow><TableCell colSpan={6} className="h-16 text-center text-[13px] text-muted-foreground">
|
||||
{hasRegisteredPkg
|
||||
? "추가 가능한 포장단위가 없습니다"
|
||||
: "등록된 포장재가 없습니다. 포장재 관리 탭에서 포장재를 먼저 등록해주세요."}
|
||||
</TableCell></TableRow>
|
||||
) : filtered.map((p) => (
|
||||
<TableRow
|
||||
key={p.id}
|
||||
@@ -1072,7 +1082,14 @@ export default function PackagingPage() {
|
||||
>
|
||||
<TableCell className="p-2 text-center">{pkgMatchSelected?.id === p.id ? "✓" : ""}</TableCell>
|
||||
<TableCell className="p-2 font-medium">{p.pkg_code}</TableCell>
|
||||
<TableCell className="p-2">{p.pkg_name}</TableCell>
|
||||
<TableCell className="p-2">
|
||||
{p.pkg_name}
|
||||
{p.status !== "ACTIVE" && (
|
||||
<span className="ml-1.5 rounded bg-muted px-1 py-0.5 text-[9px] text-muted-foreground">
|
||||
{STATUS_LABEL[p.status] || "미사용"}
|
||||
</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="p-2">{PKG_TYPE_LABEL[p.pkg_type] || p.pkg_type}</TableCell>
|
||||
<TableCell className="p-2 text-[10px]">{fmtSize(p.width_mm, p.length_mm, p.height_mm)}</TableCell>
|
||||
<TableCell className="p-2 text-right">{Number(p.max_load_kg || 0) > 0 ? `${p.max_load_kg}kg` : "-"}</TableCell>
|
||||
|
||||
@@ -108,7 +108,8 @@ export function SalesOutbound({ cart, onCartClick, saving, outboundType, sourceT
|
||||
`/table-management/tables/${CUSTOMER_SOURCE.tableName}/data`,
|
||||
{
|
||||
page: 1,
|
||||
size: 500,
|
||||
// 거래처는 마스터 참조 데이터 — size:0 으로 전체 조회 (임의 리밋 금지)
|
||||
size: 0,
|
||||
autoFilter: true,
|
||||
sort: { columnName: CUSTOMER_SOURCE.fields.code, order: "desc" },
|
||||
},
|
||||
@@ -237,13 +238,11 @@ export function SalesOutbound({ cart, onCartClick, saving, outboundType, sourceT
|
||||
fetchOrders();
|
||||
}, [fetchOrders]);
|
||||
|
||||
/* Filter orders by selected customer */
|
||||
const filteredOrders = selectedCustomer
|
||||
? orders.filter((o) =>
|
||||
o.customer_code === selectedCustomer.customer_code ||
|
||||
o.customer_name === selectedCustomer.customer_name
|
||||
)
|
||||
: orders;
|
||||
/* 출하지시 목록 — 거래처 필터는 백엔드(getShipmentInstructions)에서 이미
|
||||
* 거래처코드/거래처명 양쪽으로 매칭해 반환하므로, 프론트에서 customer_code
|
||||
* 일치로 재필터하면 partner_id 가 거래처명으로 저장된 건이 누락됨. 백엔드
|
||||
* 결과를 그대로 신뢰하고 키워드 필터만 클라이언트에서 적용한다. */
|
||||
const filteredOrders = orders;
|
||||
|
||||
/* Filter by keyword */
|
||||
const displayOrders = keyword
|
||||
|
||||
@@ -134,6 +134,10 @@ interface ChecklistItem {
|
||||
lower_limit: string | null;
|
||||
upper_limit: string | null;
|
||||
input_type: string | null;
|
||||
/** 검사기준 마스터(inspection_standard)의 판단기준 코드 — CAT_JC_01 수치 / CAT_JC_03 O/X 등 */
|
||||
judgment_criteria?: string | null;
|
||||
/** 선택형(CAT_JC_04) 판단기준의 선택지 목록 */
|
||||
selection_options?: string | null;
|
||||
group_started_at: string | null;
|
||||
group_paused_at: string | null;
|
||||
group_total_paused_time: string | null;
|
||||
@@ -586,12 +590,23 @@ export function ProcessWork({
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Fetch checklist (process_work_result)
|
||||
const checkRes = await dataApi.getTableData("process_work_result", {
|
||||
size: 500,
|
||||
filters: { work_order_process_id: processId },
|
||||
});
|
||||
setChecklist((checkRes.data ?? []) as ChecklistItem[]);
|
||||
// 3. Fetch checklist — 전용 엔드포인트 사용.
|
||||
// /checklist-items 는 inspection_standard 를 JOIN 하여 판단기준(judgment_criteria) 을
|
||||
// 포함해 내려준다(O/X vs 측정값 분기 렌더링에 필요).
|
||||
// generic table-management 조회는 judgment_criteria 가 빠져 모두 측정값 입력으로 잘못 표시됨.
|
||||
try {
|
||||
const checkRes = await apiClient.get(
|
||||
`/pop/production/checklist-items/${processId}`,
|
||||
);
|
||||
setChecklist((checkRes.data?.data ?? []) as ChecklistItem[]);
|
||||
} catch {
|
||||
// fallback: 전용 엔드포인트 실패 시 generic 조회 (판단기준 분기 없이 동작)
|
||||
const fbRes = await dataApi.getTableData("process_work_result", {
|
||||
size: 500,
|
||||
filters: { work_order_process_id: processId },
|
||||
});
|
||||
setChecklist((fbRes.data ?? []) as ChecklistItem[]);
|
||||
}
|
||||
|
||||
// 4. Defect types — 품목별 + 현재 공정 공정검사 항목 (item_inspection_info)
|
||||
try {
|
||||
@@ -2580,24 +2595,30 @@ function ChecklistRow({
|
||||
|
||||
// Inspection type: check limits
|
||||
const detailType = item.detail_type || "";
|
||||
// 판단기준(judgment_criteria) 우선 → 폴백으로 detail_type 매핑
|
||||
const jc =
|
||||
(item as ChecklistItem & { judgment_criteria?: string })
|
||||
.judgment_criteria || "";
|
||||
const isInspection =
|
||||
jc === "CAT_JC_01" ||
|
||||
detailType === "inspection" ||
|
||||
detailType === "number" ||
|
||||
detailType === "equip_condition" ||
|
||||
detailType === "production_result" ||
|
||||
detailType.startsWith("inspect");
|
||||
const isCheckbox =
|
||||
jc === "CAT_JC_03" ||
|
||||
detailType === "checkbox" ||
|
||||
detailType === "check" ||
|
||||
detailType === "checklist" ||
|
||||
detailType === "procedure" ||
|
||||
detailType === "equip_inspection";
|
||||
// 판단기준(judgment_criteria) — 검사기준 마스터(inspection_standard)에서 JOIN.
|
||||
// CAT_JC_01 수치(범위) / CAT_JC_02 텍스트입력 / CAT_JC_03 O/X / CAT_JC_04 선택형
|
||||
const jc = (item.judgment_criteria || "").trim();
|
||||
// 판단기준이 설정돼 있으면 그 값이 렌더링 방식을 '단독으로' 결정한다.
|
||||
// - O/X(CAT_JC_03) 인데 detail_type='inspection' 이라 측정값 칸으로 잘못 렌더되던 결함 수정.
|
||||
// - 판단기준 미설정 시에만 기존 detail_type 폴백 사용(현 동작 유지).
|
||||
const hasJc = jc.startsWith("CAT_JC_");
|
||||
// O/X 판단 (합격/불합격 토글)
|
||||
const isCheckbox = hasJc
|
||||
? jc === "CAT_JC_03"
|
||||
: detailType === "checkbox" ||
|
||||
detailType === "check" ||
|
||||
detailType === "checklist" ||
|
||||
detailType === "procedure" ||
|
||||
detailType === "equip_inspection";
|
||||
// 측정값(수치) 입력
|
||||
const isInspection = hasJc
|
||||
? jc === "CAT_JC_01"
|
||||
: detailType === "inspection" ||
|
||||
detailType === "number" ||
|
||||
detailType === "equip_condition" ||
|
||||
detailType === "production_result" ||
|
||||
detailType.startsWith("inspect");
|
||||
// 텍스트입력(CAT_JC_02) / 선택형(CAT_JC_04) 은 일반 텍스트 입력칸으로 렌더
|
||||
const isPlc = item.input_type === "plc" || detailType === "plc_data";
|
||||
const hasLimits = !!(item.lower_limit || item.upper_limit);
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ import { cn } from "@/lib/utils";
|
||||
|
||||
import {
|
||||
previewWorkInstructionNo, saveWorkInstruction,
|
||||
getEquipmentList, getEmployeeList,
|
||||
getEquipmentList, getEmployeeList, getRoutingVersions,
|
||||
} from "@/lib/api/workInstruction";
|
||||
|
||||
// ─── 공용 다중선택 Popover (설비/작업조/작업자) ────────────────────
|
||||
@@ -150,6 +150,8 @@ export default function WorkInstructionApplyModal({
|
||||
|
||||
const [equipmentOptions, setEquipmentOptions] = useState<{ id: string; equipment_code: string; equipment_name: string }[]>([]);
|
||||
const [workerOptions, setWorkerOptions] = useState<{ user_id: string; user_name: string; dept_name: string | null }[]>([]);
|
||||
// 품목별 라우팅 등록 여부 (true=등록됨 / false=미등록). 로딩 중인 품목은 키 부재.
|
||||
const [routingStatus, setRoutingStatus] = useState<Record<string, boolean>>({});
|
||||
|
||||
// 모달 오픈 시 초기화 + 옵션 로드
|
||||
useEffect(() => {
|
||||
@@ -165,14 +167,35 @@ export default function WorkInstructionApplyModal({
|
||||
})));
|
||||
setStatus("일반");
|
||||
setRemark("");
|
||||
setRoutingStatus({});
|
||||
|
||||
previewWorkInstructionNo().then((r) => { if (r.success) setWiNo(r.instructionNo); }).catch(() => {});
|
||||
getEquipmentList().then((r) => { if (r.success) setEquipmentOptions(r.data || []); }).catch(() => {});
|
||||
getEmployeeList().then((r) => { if (r.success) setWorkerOptions(r.data || []); }).catch(() => {});
|
||||
|
||||
// 품목별 라우팅 등록 여부 사전 조회 (엣지 1 — 라우팅 미등록 안내).
|
||||
// 라우팅 세팅 자체는 백엔드 save 가 자동 처리하므로 여기서는 안내 표시용으로만 사용.
|
||||
const uniqueCodes = [...new Set(initialItems.map((x) => x.itemCode).filter(Boolean))];
|
||||
for (const code of uniqueCodes) {
|
||||
getRoutingVersions("__new__", code)
|
||||
.then((r) => {
|
||||
const has = !!(r.success && r.data && r.data.length > 0);
|
||||
setRoutingStatus((prev) => ({ ...prev, [code]: has }));
|
||||
})
|
||||
.catch(() => {
|
||||
// 조회 실패 시 안내를 띄우지 않음(미등록으로 단정하지 않음)
|
||||
});
|
||||
}
|
||||
}, [open, initialItems]);
|
||||
|
||||
const canSave = useMemo(() => items.length > 0 && items.every((i) => i.qty > 0), [items]);
|
||||
|
||||
// 라우팅 미등록으로 확인된 품목 코드 목록
|
||||
const noRoutingCodes = useMemo(
|
||||
() => [...new Set(items.map((i) => i.itemCode).filter((c) => c && routingStatus[c] === false))],
|
||||
[items, routingStatus]
|
||||
);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!canSave) { toast.error("품목/수량을 확인해주세요"); return; }
|
||||
setSaving(true);
|
||||
@@ -237,6 +260,13 @@ export default function WorkInstructionApplyModal({
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="space-y-5">
|
||||
{noRoutingCodes.length > 0 && (
|
||||
<div className="border border-amber-300 bg-amber-50 text-amber-800 rounded-lg px-4 py-3 text-[12px]">
|
||||
<span className="font-semibold">라우팅 미등록 안내</span> — 다음 품목은 공정 라우팅이 등록되어 있지 않아
|
||||
작업지시에 라우팅이 연결되지 않습니다(생성은 가능). 품목정보에서 라우팅 버전을 등록해주세요:
|
||||
<span className="ml-1 font-mono">{noRoutingCodes.join(", ")}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="bg-muted/30 border rounded-lg p-5">
|
||||
<h4 className="text-[13px] font-bold pb-2 border-b text-foreground">작업지시 기본 정보</h4>
|
||||
<p className="text-[11px] text-muted-foreground mt-2">시작일·완료예정일·설비·작업조·작업자는 아래 품목별로 지정해주세요.</p>
|
||||
|
||||
@@ -169,7 +169,7 @@ export default function CuttingPlanPage() {
|
||||
try {
|
||||
// COMPANY_9는 마스터-디테일 구조 — sales_order_detail에서 직접 조회.
|
||||
// 공통 /cutting-plan/orders는 sales_order_mng.part_name 필터 때문에 데이터가 누락됨.
|
||||
const [detailRes, masterRes, planItemRes, planMngRes] = await Promise.all([
|
||||
const [detailRes, masterRes, planItemRes, planMngRes, customerRes] = await Promise.all([
|
||||
apiClient.post(`/table-management/tables/sales_order_detail/data`, {
|
||||
page: 1, size: 0, autoFilter: true,
|
||||
}),
|
||||
@@ -182,13 +182,23 @@ export default function CuttingPlanPage() {
|
||||
apiClient.post(`/table-management/tables/cutting_plan_mng/data`, {
|
||||
page: 1, size: 0, autoFilter: true,
|
||||
}).catch(() => ({ data: { data: { data: [] } } })),
|
||||
// 거래처 코드(CUST-001)를 거래처명으로 표시하기 위한 마스터 참조 (size:0 전체 조회)
|
||||
apiClient.post(`/table-management/tables/customer_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 customers = customerRes.data?.data?.data || customerRes.data?.data?.rows || [];
|
||||
|
||||
const masterMap = new Map<string, any>(masters.map((m: any) => [m.order_no, m]));
|
||||
// 거래처 코드 → 거래처명 매핑 (매핑 깨짐/빈값이면 코드 fallback)
|
||||
const customerNameMap = new Map<string, string>();
|
||||
for (const c of customers) {
|
||||
if (c.customer_code) customerNameMap.set(String(c.customer_code), c.customer_name || "");
|
||||
}
|
||||
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 }>();
|
||||
@@ -232,9 +242,14 @@ export default function CuttingPlanPage() {
|
||||
const qty = parseFloat(d.qty || "0") || 0;
|
||||
const balance = parseFloat(d.balance_qty || "0") || qty;
|
||||
const batch = orderToPlan.get(d.order_no);
|
||||
// 거래처: partner_id(코드) → customer_mng 거래처명. 매핑 깨짐/빈값이면 코드 fallback.
|
||||
const partnerCode = m.partner_id || "";
|
||||
const customerName = partnerCode
|
||||
? (customerNameMap.get(String(partnerCode)) || partnerCode)
|
||||
: "-";
|
||||
return {
|
||||
order_no: d.order_no,
|
||||
customer: m.partner_id || "-",
|
||||
customer: customerName,
|
||||
partner_id: m.partner_id,
|
||||
part_code: d.part_code || "",
|
||||
part_name: d.part_name || "-",
|
||||
|
||||
@@ -621,6 +621,14 @@ export default function ProductionPlanManagementPage() {
|
||||
});
|
||||
if (res.success) {
|
||||
toast.success(`스케줄이 생성되었습니다 (${res.data.summary.total}건)`);
|
||||
const warnCnt = res.data.summary?.warning_count || 0;
|
||||
const skipCnt = res.data.summary?.skipped_count || 0;
|
||||
if (warnCnt > 0) {
|
||||
toast.warning(`납기 경고 ${warnCnt}건 — 종료일이 납기를 초과하거나 납기일 미설정 품목이 있습니다.`);
|
||||
}
|
||||
if (skipCnt > 0) {
|
||||
toast.info(`${skipCnt}건은 수량이 없어 제외되었습니다.`);
|
||||
}
|
||||
setChangeConfirmModalOpen(false);
|
||||
setPreviewData(null);
|
||||
fetchPlans();
|
||||
@@ -1993,6 +2001,49 @@ export default function ProductionPlanManagementPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 납기일 미설정/초과 경고 (스케줄은 산출됨) */}
|
||||
{((previewData as any).warnings?.length || 0) > 0 && (
|
||||
<div className="rounded-md border border-warning/40 bg-warning/10 p-3">
|
||||
<p className="text-sm font-semibold mb-1.5 text-warning flex items-center gap-1.5">
|
||||
<Package className="h-4 w-4" />
|
||||
납기 경고 ({(previewData as any).warnings.length}건)
|
||||
</p>
|
||||
<ul className="space-y-0.5 text-[12px] text-muted-foreground">
|
||||
{((previewData as any).warnings || []).map((w: any, i: number) => (
|
||||
<li key={`warn-${i}`}>
|
||||
<span className="font-medium text-foreground">{w.item_name || w.item_code}</span>
|
||||
{" — "}
|
||||
{(w.messages || []).join(", ")}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 스케줄 대상에서 제외된 품목 (수량 0 등) */}
|
||||
{((previewData as any).skipped?.length || 0) > 0 && (
|
||||
<div className="rounded-md border border-muted bg-muted/40 p-3">
|
||||
<p className="text-sm font-semibold mb-1.5 text-muted-foreground flex items-center gap-1.5">
|
||||
제외됨 ({(previewData as any).skipped.length}건)
|
||||
</p>
|
||||
<ul className="space-y-0.5 text-[12px] text-muted-foreground">
|
||||
{((previewData as any).skipped || []).map((s: any, i: number) => (
|
||||
<li key={`skip-${i}`}>
|
||||
<span className="font-medium text-foreground">{s.item_name || s.item_code}</span>
|
||||
{" — "}
|
||||
{s.reason}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(previewData.schedules?.length || 0) === 0 && (
|
||||
<div className="rounded-md border border-dashed p-6 text-center text-sm text-muted-foreground">
|
||||
생성할 스케줄이 없습니다. 수주 잔량/납기일을 확인해주세요.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(previewData.schedules?.length || 0) > 0 && (
|
||||
<div>
|
||||
<p className="text-sm font-semibold mb-2 text-success flex items-center gap-1.5">
|
||||
|
||||
@@ -714,7 +714,7 @@ export default function SupplierManagementPage() {
|
||||
const handleSupplierSave = async () => {
|
||||
if (!supplierForm.supplier_name) { toast.error("공급업체명은 필수입니다."); return; }
|
||||
if (!supplierForm.status) { toast.error("상태는 필수입니다."); return; }
|
||||
const errors = validateForm(supplierForm, ["contact_phone", "email", "business_number"]);
|
||||
const errors = validateForm(supplierForm, ["contact_phone", "email", "business_number", "fax_number"]);
|
||||
setFormErrors(errors);
|
||||
if (Object.keys(errors).length > 0) {
|
||||
toast.error("입력 형식을 확인해주세요.");
|
||||
@@ -1896,6 +1896,16 @@ export default function SupplierManagementPage() {
|
||||
/>
|
||||
{formErrors.business_number && <p className="text-xs text-destructive">{formErrors.business_number}</p>}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm">팩스번호</Label>
|
||||
<Input
|
||||
value={supplierForm.fax_number || ""}
|
||||
onChange={(e) => handleFormChange("fax_number", e.target.value)}
|
||||
placeholder="02-1234-5678"
|
||||
className={cn("h-9", formErrors.fax_number && "border-destructive")}
|
||||
/>
|
||||
{formErrors.fax_number && <p className="text-xs text-destructive">{formErrors.fax_number}</p>}
|
||||
</div>
|
||||
<div className="space-y-1.5 col-span-2">
|
||||
<Label className="text-sm">주소</Label>
|
||||
<Input
|
||||
|
||||
@@ -1218,13 +1218,16 @@ export default function ItemInspectionInfoPage() {
|
||||
<TableCell className="text-xs py-2">{(() => {
|
||||
const code = row.apply_process;
|
||||
if (!code) return "-";
|
||||
// excelItemProcessMappings에서 공정명 찾기
|
||||
// 1) 백엔드 process_mng JOIN 결과(apply_process_name) 우선 사용
|
||||
if (row.apply_process_name) return row.apply_process_name;
|
||||
// 2) excelItemProcessMappings에서 공정명 찾기
|
||||
for (const m of excelItemProcessMappings) {
|
||||
const proc = m.processes.find(p => p.code === code);
|
||||
if (proc) return proc.name;
|
||||
}
|
||||
// processOptions (모달용)에서 찾기
|
||||
// 3) processOptions (모달용)에서 찾기
|
||||
const proc = processOptions.find(p => p.code === code);
|
||||
// 매핑이 깨졌거나 못 찾으면 코드 fallback
|
||||
return proc?.name || code;
|
||||
})()}</TableCell>
|
||||
<TableCell className="text-xs py-2">{row.classification || "-"}</TableCell>
|
||||
|
||||
@@ -715,7 +715,7 @@ export default function CustomerManagementPage() {
|
||||
const handleCustomerSave = async () => {
|
||||
if (!customerForm.customer_name) { toast.error("거래처명은 필수입니다."); return; }
|
||||
if (!customerForm.status) { toast.error("상태는 필수입니다."); return; }
|
||||
const errors = validateForm(customerForm, ["business_number"]);
|
||||
const errors = validateForm(customerForm, ["business_number", "phone", "fax"]);
|
||||
setFormErrors(errors);
|
||||
if (Object.keys(errors).length > 0) {
|
||||
toast.error("입력 형식을 확인해주세요.");
|
||||
@@ -1887,6 +1887,26 @@ export default function CustomerManagementPage() {
|
||||
/>
|
||||
{formErrors.business_number && <p className="text-xs text-destructive">{formErrors.business_number}</p>}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm">전화번호</Label>
|
||||
<Input
|
||||
value={customerForm.phone || ""}
|
||||
onChange={(e) => handleFormChange("phone", e.target.value)}
|
||||
placeholder="02-1234-5678"
|
||||
className={cn("h-9", formErrors.phone && "border-destructive")}
|
||||
/>
|
||||
{formErrors.phone && <p className="text-xs text-destructive">{formErrors.phone}</p>}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm">팩스번호</Label>
|
||||
<Input
|
||||
value={customerForm.fax || ""}
|
||||
onChange={(e) => handleFormChange("fax", e.target.value)}
|
||||
placeholder="02-1234-5678"
|
||||
className={cn("h-9", formErrors.fax && "border-destructive")}
|
||||
/>
|
||||
{formErrors.fax && <p className="text-xs text-destructive">{formErrors.fax}</p>}
|
||||
</div>
|
||||
<div className="space-y-1.5 col-span-2">
|
||||
<Label className="text-sm">주소</Label>
|
||||
<Input
|
||||
|
||||
@@ -140,6 +140,13 @@ export default function JeilGlassOrderPage() {
|
||||
const [filterConfig, setFilterConfig] = useState<TableSettings["filters"] | undefined>();
|
||||
const [categoryOptions, setCategoryOptions] = useState<Record<string, { code: string; label: string }[]>>({});
|
||||
|
||||
// 납품처 (거래처별 delivery_destination) — TASK:ERP-097
|
||||
// 거래처 선택 시 해당 거래처에 등록된 납품처 목록을 조회한다.
|
||||
const [destinations, setDestinations] = useState<any[]>([]);
|
||||
const [destLoading, setDestLoading] = useState(false);
|
||||
// 선택한 납품처 id (수주 저장에는 destination_name을 텍스트로 저장 → 하류 화면 호환)
|
||||
const [selectedDestId, setSelectedDestId] = useState<string>("");
|
||||
|
||||
// 채번
|
||||
const [numberingRuleId, setNumberingRuleId] = useState<string | null>(null);
|
||||
|
||||
@@ -203,8 +210,8 @@ export default function JeilGlassOrderPage() {
|
||||
label: `${u.user_name || ""}${u.position_name ? ` (${u.position_name})` : ""}`,
|
||||
}));
|
||||
} catch { /* skip */ }
|
||||
// 품목 카테고리 (단위, 구분, 재질, 유형)
|
||||
for (const col of ["unit", "division", "material", "type"]) {
|
||||
// 품목 카테고리 (단위, 구분, 재질, 유형, 상태)
|
||||
for (const col of ["unit", "division", "material", "type", "status"]) {
|
||||
try {
|
||||
const res = await apiClient.get(`/table-categories/${ITEM_TABLE}/${col}/values`);
|
||||
if (res.data?.success && res.data.data?.length > 0) {
|
||||
@@ -322,6 +329,49 @@ export default function JeilGlassOrderPage() {
|
||||
|
||||
useEffect(() => { fetchMasterOrders(); }, [fetchMasterOrders]);
|
||||
|
||||
// 거래처별 납품처 목록 조회 — TASK:ERP-097
|
||||
// 거래처(partner_id)가 바뀌면 해당 거래처의 delivery_destination 레코드를 전체 조회.
|
||||
const loadDestinations = useCallback(async (customerCode: string) => {
|
||||
if (!customerCode) {
|
||||
setDestinations([]);
|
||||
return;
|
||||
}
|
||||
setDestLoading(true);
|
||||
try {
|
||||
const res = await apiClient.post(`/table-management/tables/delivery_destination/data`, {
|
||||
page: 1, size: 0,
|
||||
dataFilter: { enabled: true, filters: [{ columnName: "customer_code", operator: "equals", value: customerCode }] },
|
||||
autoFilter: true,
|
||||
});
|
||||
const rows = res.data?.data?.data || res.data?.data?.rows || [];
|
||||
setDestinations(rows);
|
||||
} catch {
|
||||
setDestinations([]);
|
||||
} finally {
|
||||
setDestLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 모달의 거래처(partner_id)가 바뀌면 납품처 목록 재조회
|
||||
useEffect(() => {
|
||||
if (!isModalOpen) return;
|
||||
loadDestinations(masterForm.partner_id || "");
|
||||
}, [masterForm.partner_id, isModalOpen, loadDestinations]);
|
||||
|
||||
// 납품처 목록 로드 후, 저장된 납품장소(delivery_address) 값과 매칭되는 납품처 id 복원 (수정 진입 시)
|
||||
useEffect(() => {
|
||||
if (!isModalOpen) return;
|
||||
const addr = masterForm.delivery_address || "";
|
||||
if (!addr || destinations.length === 0) {
|
||||
setSelectedDestId("");
|
||||
return;
|
||||
}
|
||||
const matched = destinations.find(
|
||||
(d) => d.destination_name === addr || d.address === addr,
|
||||
);
|
||||
setSelectedDestId(matched ? String(matched.id) : "");
|
||||
}, [destinations, masterForm.delivery_address, isModalOpen]);
|
||||
|
||||
// 서버 페이징 계산
|
||||
const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
|
||||
const safePage = Math.min(Math.max(1, currentPage), totalPages);
|
||||
@@ -381,6 +431,8 @@ export default function JeilGlassOrderPage() {
|
||||
}
|
||||
setMasterForm({ order_no: previewOrderNo, manager_id: user?.userId || "" });
|
||||
setModalDetailRows([]);
|
||||
setSelectedDestId("");
|
||||
setDestinations([]);
|
||||
setIsEditMode(false);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
@@ -500,9 +552,16 @@ export default function JeilGlassOrderPage() {
|
||||
}
|
||||
// 없으면 자동 등록
|
||||
// COMPANY_9 한정: 수주 디테일 입력값을 신규 item_info 레코드에 연동
|
||||
// (width/height/thickness/size/unit/standard_price 6개 컬럼)
|
||||
// selling_price(판매가격)는 절대 연동 금지 — 기존 품목은 이미 위 found 분기에서 보호됨
|
||||
// (width/height/thickness/size/unit + 판매단가(selling_price))
|
||||
// 신규 생성 시에만 적용 — 기존 품목은 위 found 분기에서 보호됨
|
||||
// company_code를 명시해야 백엔드가 회사별 채번 규칙으로 item_number를 자동 발급함
|
||||
// 관리품목/품목구분/상태: category_values에서 라벨로 코드 resolve (코드 직접 하드코딩 금지)
|
||||
const divisionCode =
|
||||
categoryOptions["item_division"]?.find((o) => o.label === "영업관리")?.code || "";
|
||||
const typeCode =
|
||||
categoryOptions["item_type"]?.find((o) => o.label === "제품")?.code || "";
|
||||
const statusCode =
|
||||
categoryOptions["item_status"]?.find((o) => o.label === "활성")?.code || "";
|
||||
await apiClient.post(`/table-management/tables/${ITEM_TABLE}/add`, {
|
||||
id: crypto.randomUUID(),
|
||||
company_code: user?.companyCode || user?.company_code,
|
||||
@@ -512,7 +571,12 @@ export default function JeilGlassOrderPage() {
|
||||
width: row.width || "",
|
||||
height: row.height || "",
|
||||
thickness: row.thickness || "",
|
||||
standard_price: row.unit_price || "",
|
||||
// 항목 12: 수주 디테일 단가 → 판매단가(selling_price). 기준단가(standard_price) 아님
|
||||
selling_price: row.unit_price || "",
|
||||
// 항목 9: 관리품목="영업관리", 품목구분="제품", 상태="활성" 고정값
|
||||
division: divisionCode,
|
||||
type: typeCode,
|
||||
status: statusCode,
|
||||
});
|
||||
// 등록 후 재조회하여 item_number 획득
|
||||
const reSearch = await apiClient.post(`/table-management/tables/${ITEM_TABLE}/data`, {
|
||||
@@ -972,8 +1036,15 @@ 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 variant="outline" size="sm" onClick={() => {
|
||||
// 엣지: 수주 품목 미선택 상태 → 안내 후 모달 미오픈
|
||||
if (detailCheckedIds.length === 0) {
|
||||
toast.error("출하계획을 작성할 수주 품목을 선택해주세요.");
|
||||
return;
|
||||
}
|
||||
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" /> 엑셀 업로드
|
||||
@@ -1084,7 +1155,56 @@ export default function JeilGlassOrderPage() {
|
||||
placeholder="메모" className="h-9" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{/* 납품처: 거래처별 delivery_destination 선택 — TASK:ERP-097 */}
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-sm">납품처</Label>
|
||||
{selectedDestId && (
|
||||
<button
|
||||
type="button"
|
||||
className="text-[11px] text-muted-foreground hover:text-destructive"
|
||||
onClick={() => {
|
||||
setSelectedDestId("");
|
||||
setMasterForm((p) => ({ ...p, delivery_address: "" }));
|
||||
}}
|
||||
>
|
||||
지우기
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<SmartSelect
|
||||
value={selectedDestId}
|
||||
onValueChange={(v) => {
|
||||
setSelectedDestId(v);
|
||||
const dest = destinations.find((d) => String(d.id) === v);
|
||||
if (dest) {
|
||||
// 선택한 납품처명을 납품장소 텍스트로 저장 (하류 화면 호환)
|
||||
const label = dest.destination_name || dest.address || "";
|
||||
setMasterForm((p) => ({ ...p, delivery_address: label }));
|
||||
// 이미 추가된 품목행의 납품장소도 일괄 갱신
|
||||
setModalDetailRows((prev) =>
|
||||
prev.map((r) => ({ ...r, delivery_location: label })),
|
||||
);
|
||||
}
|
||||
}}
|
||||
options={destinations.map((d) => ({
|
||||
code: String(d.id),
|
||||
label: `${d.destination_name || d.address || d.destination_code}${d.address && d.destination_name ? ` (${d.address})` : ""}`,
|
||||
}))}
|
||||
placeholder={
|
||||
!masterForm.partner_id
|
||||
? "거래처를 먼저 선택해주세요"
|
||||
: destLoading
|
||||
? "납품처 불러오는 중..."
|
||||
: destinations.length === 0
|
||||
? "등록된 납품처 없음"
|
||||
: "납품처 선택"
|
||||
}
|
||||
disabled={!masterForm.partner_id || destLoading}
|
||||
/>
|
||||
</div>
|
||||
{/* 납품장소: 자유 입력 (납품처 미등록 거래처용 fallback) */}
|
||||
<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 }))}
|
||||
|
||||
@@ -31,6 +31,7 @@ const GRID_COLUMNS = [
|
||||
{ key: "instruction_no", label: "출하지시번호" },
|
||||
{ key: "ship_date", label: "출하일자" },
|
||||
{ key: "customer_name", label: "거래처명" },
|
||||
{ key: "delivery_destination_name", label: "납품처" },
|
||||
{ key: "transport_company", label: "운송업체" },
|
||||
{ key: "vehicle_no", label: "차량번호" },
|
||||
{ key: "driver_name", label: "기사명" },
|
||||
@@ -414,6 +415,7 @@ export default function ShippingOrderPage() {
|
||||
instruction_no: order.instruction_no,
|
||||
ship_date: formatDate(order.instruction_date),
|
||||
customer_name: order.customer_name || "-",
|
||||
delivery_destination_name: order.delivery_destination_name || "-",
|
||||
transport_company: order.carrier_name || "-",
|
||||
vehicle_no: order.vehicle_no || "-",
|
||||
driver_name: order.driver_name || "-",
|
||||
@@ -433,6 +435,7 @@ export default function ShippingOrderPage() {
|
||||
instruction_no: idx === 0 ? order.instruction_no : "",
|
||||
ship_date: idx === 0 ? formatDate(order.instruction_date) : "",
|
||||
customer_name: idx === 0 ? (order.customer_name || "-") : "",
|
||||
delivery_destination_name: idx === 0 ? (order.delivery_destination_name || "-") : "",
|
||||
transport_company: idx === 0 ? (order.carrier_name || "-") : "",
|
||||
vehicle_no: idx === 0 ? (order.vehicle_no || "-") : "",
|
||||
driver_name: idx === 0 ? (order.driver_name || "-") : "",
|
||||
|
||||
@@ -25,6 +25,7 @@ const GRID_COLUMNS = [
|
||||
{ key: "order_no", label: "수주번호" },
|
||||
{ key: "due_date", label: "납기일" },
|
||||
{ key: "customer_name", label: "거래처" },
|
||||
{ key: "delivery_destination_name", label: "납품처" },
|
||||
{ key: "part_code", label: "품목코드" },
|
||||
{ key: "part_name", label: "품목명" },
|
||||
{ key: "order_qty", label: "수주수량" },
|
||||
@@ -252,6 +253,7 @@ export default function ShippingPlanPage() {
|
||||
{ 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> },
|
||||
{ key: "delivery_destination_name", label: "납품처", render: (val: any) => <span className="text-sm">{val || "-"}</span> },
|
||||
{ key: "part_code", label: "품목코드", render: (val: any) => <span className="text-muted-foreground text-[13px]">{val || "-"}</span> },
|
||||
{ key: "part_name", label: "품목명", render: (val: any) => <span className="font-medium text-sm">{val || "-"}</span> },
|
||||
{ key: "order_qty", label: "수주수량", align: "right" as const, formatNumber: true },
|
||||
|
||||
@@ -15,6 +15,7 @@ export interface InspectionRowApi {
|
||||
inspection_item_name: string | null;
|
||||
inspection_method: string | null;
|
||||
apply_process: string | null;
|
||||
apply_process_name?: string | null;
|
||||
classification: string | null;
|
||||
pass_criteria: string | null;
|
||||
upper_limit?: string | number | null;
|
||||
|
||||
@@ -31,6 +31,12 @@ export interface OutboundItem {
|
||||
destination_code: string | null;
|
||||
delivery_destination: string | null;
|
||||
delivery_address: string | null;
|
||||
// 납품처명 (TASK:ERP-097): 목록 조회 시 출고/출하지시 체인에서 해석된 납품처명
|
||||
delivery_destination_name?: string | null;
|
||||
// 품목 규격 (TASK:ERP-098): 유리업종 가로/세로/두께 (item_info JOIN, 미입력 시 빈문자열)
|
||||
width?: string | null;
|
||||
height?: string | null;
|
||||
thickness?: string | null;
|
||||
created_date: string;
|
||||
created_by: string | null;
|
||||
}
|
||||
|
||||
@@ -93,10 +93,16 @@ export interface GenerateScheduleResponse {
|
||||
new_count: number;
|
||||
kept_count: number;
|
||||
deleted_count: number;
|
||||
skipped_count?: number;
|
||||
warning_count?: number;
|
||||
};
|
||||
schedules: ProductionPlan[];
|
||||
deletedSchedules?: ProductionPlan[];
|
||||
keptSchedules?: ProductionPlan[];
|
||||
/** 스케줄 대상에서 제외된 품목 (수량 0 등) */
|
||||
skipped?: { item_code: string; item_name: string; reason: string }[];
|
||||
/** 납기일 미설정/초과 등 경고가 붙은 품목 */
|
||||
warnings?: { item_code: string; item_name: string; messages: string[] }[];
|
||||
}
|
||||
|
||||
// ─── API 함수 ───
|
||||
|
||||
@@ -79,6 +79,7 @@ export interface ShipmentPlanListItem {
|
||||
due_date: string;
|
||||
order_qty: string;
|
||||
shipped_qty: string;
|
||||
delivery_destination_name?: string;
|
||||
}
|
||||
|
||||
export interface ShipmentPlanListParams {
|
||||
|
||||
@@ -145,6 +145,8 @@ export function DetailFormModal({
|
||||
const [equipInspLoading, setEquipInspLoading] = useState(false);
|
||||
// 설비점검 row별 선택 — TASK:ERP-021
|
||||
const [equipInspChecked, setEquipInspChecked] = useState<Set<string>>(new Set());
|
||||
// 공정에 지정된 설비 건수 — 0건(설비 미지정) vs 점검항목 0건 메시지 구분용 — TASK:ERP-102
|
||||
const [equipProcEquipCount, setEquipProcEquipCount] = useState<number>(0);
|
||||
|
||||
// 공정 설비 목록 (자재투입 자재별 설비 연결용) — TASK:ERP-022
|
||||
const [processEquipments, setProcessEquipments] = useState<any[]>([]);
|
||||
@@ -355,42 +357,80 @@ export function DetailFormModal({
|
||||
})();
|
||||
}, [open, mode, formData.detail_type, formData.process_inspection_apply, selectedItemCode]);
|
||||
|
||||
// 설비점검 적용 시 해당 공정의 설비 점검항목 로드
|
||||
// 설비점검 적용 시 해당 공정의 설비 점검항목 로드 — TASK:ERP-102
|
||||
// 데이터 흐름: 공정코드 → process_equipment(공정-설비 매핑) → equipment_inspection_item(설비별 점검항목)
|
||||
useEffect(() => {
|
||||
if (!open || formData.detail_type !== "equip_inspection" || formData.equip_inspection_apply !== "apply" || !selectedProcessCode) {
|
||||
setEquipInspItems([]);
|
||||
setEquipInspChecked(new Set());
|
||||
setEquipProcEquipCount(0);
|
||||
return;
|
||||
}
|
||||
(async () => {
|
||||
setEquipInspLoading(true);
|
||||
try {
|
||||
// 1. 해당 공정의 설비 목록 조회
|
||||
const equipRes = await apiClient.post("/table-management/tables/process_equipment/data", {
|
||||
page: 1, size: 100,
|
||||
dataFilter: { enabled: true, filters: [{ columnName: "process_code", operator: "equals", value: selectedProcessCode }] },
|
||||
autoFilter: true,
|
||||
});
|
||||
const equipCodes = (equipRes.data?.data?.data || equipRes.data?.data?.rows || []).map((e: any) => e.equipment_code).filter(Boolean);
|
||||
// 1. 해당 공정의 설비 목록 + 설비명 매핑 조회 (참조 데이터 → size:0 전체 반환)
|
||||
const [equipRes, eqMngRes] = await Promise.all([
|
||||
apiClient.post("/table-management/tables/process_equipment/data", {
|
||||
page: 1, size: 0,
|
||||
dataFilter: { enabled: true, filters: [{ columnName: "process_code", operator: "equals", value: selectedProcessCode }] },
|
||||
autoFilter: true,
|
||||
}),
|
||||
apiClient.post("/table-management/tables/equipment_mng/data", {
|
||||
page: 1, size: 0, autoFilter: true,
|
||||
}),
|
||||
]);
|
||||
const equipCodes: string[] = Array.from(
|
||||
new Set(
|
||||
(equipRes.data?.data?.data || equipRes.data?.data?.rows || [])
|
||||
.map((e: any) => e.equipment_code)
|
||||
.filter(Boolean)
|
||||
)
|
||||
);
|
||||
// 설비코드 → 설비명 매핑 (점검항목에 설비명 병기용)
|
||||
const eqNameMap: Record<string, string> = {};
|
||||
for (const eq of (eqMngRes.data?.data?.data || eqMngRes.data?.data?.rows || [])) {
|
||||
if (eq.equipment_code) eqNameMap[eq.equipment_code] = eq.equipment_name || eq.equipment_code;
|
||||
}
|
||||
|
||||
setEquipProcEquipCount(equipCodes.length);
|
||||
if (equipCodes.length === 0) {
|
||||
// 공정에 지정된 설비 자체가 없음 → 메시지 분기로 원인 구분
|
||||
setEquipInspItems([]);
|
||||
setEquipInspChecked(new Set());
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 각 설비의 점검항목 조회
|
||||
// 2. 각 설비의 점검항목 조회 (참조 데이터 → size:0 전체 반환)
|
||||
const inspRes = await apiClient.post("/table-management/tables/equipment_inspection_item/data", {
|
||||
page: 1, size: 500,
|
||||
page: 1, size: 0,
|
||||
dataFilter: { enabled: true, filters: [{ columnName: "equipment_code", operator: "in", value: equipCodes }] },
|
||||
autoFilter: true,
|
||||
});
|
||||
const rows = inspRes.data?.data?.data || inspRes.data?.data?.rows || [];
|
||||
const rawRows = inspRes.data?.data?.data || inspRes.data?.data?.rows || [];
|
||||
// 설비명 주입 + 동일 점검항목(설비/항목/방법/규격 동일) 중복 제거
|
||||
const seen = new Set<string>();
|
||||
const rows: any[] = [];
|
||||
for (const r of rawRows) {
|
||||
const dedupKey = [
|
||||
r.equipment_code || "",
|
||||
r.inspection_item || "",
|
||||
r.inspection_method || "",
|
||||
r.lower_limit ?? "",
|
||||
r.upper_limit ?? "",
|
||||
r.unit ?? "",
|
||||
].join("|");
|
||||
if (seen.has(dedupKey)) continue;
|
||||
seen.add(dedupKey);
|
||||
rows.push({ ...r, equipment_name: eqNameMap[r.equipment_code] || r.equipment_code || "" });
|
||||
}
|
||||
setEquipInspItems(rows);
|
||||
// 기본값: 전체 선택
|
||||
setEquipInspChecked(new Set(rows.map((r: any, idx: number) => String(r.id ?? idx))));
|
||||
} catch {
|
||||
setEquipInspItems([]);
|
||||
setEquipInspChecked(new Set());
|
||||
setEquipProcEquipCount(0);
|
||||
} finally { setEquipInspLoading(false); }
|
||||
})();
|
||||
}, [open, formData.detail_type, formData.equip_inspection_apply, selectedProcessCode]);
|
||||
@@ -560,12 +600,14 @@ export function DetailFormModal({
|
||||
}
|
||||
for (const item of checkedItems) {
|
||||
const range = (item.lower_limit || item.upper_limit) ? `${item.lower_limit || ""} ~ ${item.upper_limit || ""}${item.unit ? ` ${item.unit}` : ""}` : "";
|
||||
// 설비명 병기 — 동일 점검항목이 복수 설비에 있을 때 구분 가능하도록 — TASK:ERP-102
|
||||
const equipLabel = item.equipment_name || item.equipment_code || "";
|
||||
onSubmit({
|
||||
...submitData,
|
||||
detail_type: "equip_inspection",
|
||||
// 자동연동 분기는 equip_inspection_apply="apply" 가정 — 명시 셋팅으로 수정 모달 라디오 복원 보장
|
||||
equip_inspection_apply: "apply",
|
||||
content: `${item.inspection_item || "-"}${range ? ` | ${range}` : ""}`.trim(),
|
||||
content: `${equipLabel ? `[${equipLabel}] ` : ""}${item.inspection_item || "-"}${range ? ` | ${range}` : ""}`.trim(),
|
||||
is_required: submitData.is_required || "Y",
|
||||
// 점검항목 메타 함께 전송 → 수정 모달 재진입 시 표시 복원
|
||||
unit: item.unit ?? "",
|
||||
@@ -1162,8 +1204,16 @@ export function DetailFormModal({
|
||||
</div>
|
||||
{equipInspLoading ? (
|
||||
<div className="flex items-center gap-2 py-2"><Loader2 className="h-3.5 w-3.5 animate-spin" /><span className="text-[11px] text-muted-foreground">조회 중...</span></div>
|
||||
) : equipProcEquipCount === 0 ? (
|
||||
// 공정-설비 매핑 자체가 0건 — 원인 구분 메시지 — TASK:ERP-102
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
이 공정에 지정된 설비가 없습니다. 공정 마스터에서 사용설비를 먼저 등록해주세요.
|
||||
</p>
|
||||
) : equipInspItems.length === 0 ? (
|
||||
<p className="text-[11px] text-muted-foreground">해당 공정에 지정된 설비의 점검항목이 없습니다.</p>
|
||||
// 설비는 지정됐으나 그 설비들에 점검항목이 0건 — TASK:ERP-102
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
지정된 설비({equipProcEquipCount}대)에 등록된 점검항목이 없습니다. 설비정보에서 점검항목을 먼저 등록해주세요.
|
||||
</p>
|
||||
) : (
|
||||
<table className="w-full text-[11px]">
|
||||
<thead>
|
||||
@@ -1186,7 +1236,7 @@ export function DetailFormModal({
|
||||
}}
|
||||
/>
|
||||
</th>
|
||||
<th className="py-1 text-left font-medium text-amber-700">설비코드</th>
|
||||
<th className="py-1 text-left font-medium text-amber-700">설비</th>
|
||||
<th className="py-1 text-left font-medium text-amber-700">점검항목</th>
|
||||
<th className="py-1 text-left font-medium text-amber-700">점검방법</th>
|
||||
<th className="py-1 text-left font-medium text-amber-700">기준범위</th>
|
||||
@@ -1210,7 +1260,7 @@ export function DetailFormModal({
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
<td className="py-1 font-mono">{item.equipment_code || "-"}</td>
|
||||
<td className="py-1">{item.equipment_name || item.equipment_code || "-"}</td>
|
||||
<td className="py-1">{item.inspection_item || "-"}</td>
|
||||
<td className="py-1">{item.inspection_method || "-"}</td>
|
||||
<td className="py-1 font-mono">{item.lower_limit || item.upper_limit ? `${item.lower_limit || ""} ~ ${item.upper_limit || ""}${item.unit ? ` ${item.unit}` : ""}` : "-"}</td>
|
||||
|
||||
@@ -35,6 +35,9 @@ export function formatField(fieldName: string, value: string): string {
|
||||
case "contact_phone":
|
||||
case "phone":
|
||||
case "cell_phone":
|
||||
case "fax":
|
||||
case "fax_number":
|
||||
case "office_number":
|
||||
return formatPhone(value);
|
||||
case "business_number":
|
||||
return formatBusinessNumber(value);
|
||||
@@ -71,6 +74,9 @@ export function validateField(fieldName: string, value: string): string | null {
|
||||
case "contact_phone":
|
||||
case "phone":
|
||||
case "cell_phone":
|
||||
case "fax":
|
||||
case "fax_number":
|
||||
case "office_number":
|
||||
return validatePhone(value);
|
||||
case "email":
|
||||
return validateEmail(value);
|
||||
|
||||
Reference in New Issue
Block a user