diff --git a/src/components/production/WorkOrders/actions.ts b/src/components/production/WorkOrders/actions.ts
index 82b21dc8..357d938d 100644
--- a/src/components/production/WorkOrders/actions.ts
+++ b/src/components/production/WorkOrders/actions.ts
@@ -1040,3 +1040,42 @@ export async function getProcessOptions(): Promise<{
return { success: false, data: [], error: '서버 오류가 발생했습니다.' };
}
}
+
+// ===== 자재 투입 LOT 번호 조회 =====
+export interface MaterialInputLot {
+ lot_no: string;
+ item_code: string;
+ item_name: string;
+ total_qty: number;
+ input_count: number;
+ first_input_at: string;
+}
+
+export async function getMaterialInputLots(workOrderId: string): Promise<{
+ success: boolean;
+ data: MaterialInputLot[];
+ error?: string;
+}> {
+ try {
+ const { response, error } = await serverFetch(
+ `${process.env.NEXT_PUBLIC_API_URL}/api/v1/work-orders/${workOrderId}/material-input-lots`,
+ { method: 'GET' }
+ );
+
+ if (error || !response) {
+ return { success: false, data: [], error: error?.message || 'API 요청 실패' };
+ }
+
+ const result = await response.json();
+
+ if (!response.ok || !result.success) {
+ return { success: false, data: [], error: result.message || '투입 LOT 조회에 실패했습니다.' };
+ }
+
+ return { success: true, data: result.data || [] };
+ } catch (error) {
+ if (isNextRedirectError(error)) throw error;
+ console.error('[WorkOrderActions] getMaterialInputLots error:', error);
+ return { success: false, data: [], error: '서버 오류가 발생했습니다.' };
+ }
+}
diff --git a/src/components/production/WorkOrders/documents/InspectionReportModal.tsx b/src/components/production/WorkOrders/documents/InspectionReportModal.tsx
index 78177374..7157921e 100644
--- a/src/components/production/WorkOrders/documents/InspectionReportModal.tsx
+++ b/src/components/production/WorkOrders/documents/InspectionReportModal.tsx
@@ -262,22 +262,22 @@ export function InspectionReportModal({
const data = contentRef.current.getInspectionData();
setIsSaving(true);
try {
- // 템플릿 모드: Document 기반 저장
+ // 템플릿 모드: Document 기반 저장 (정규화 형식)
if (activeTemplate) {
const inspData = data as {
template_id: number;
- items: { id: string; apiItemId?: number; judgment: string | null; values: Record }[];
- inadequateContent: string;
- overall_result: string | null;
+ records: Array<{
+ section_id: number | null;
+ column_id: number | null;
+ row_index: number;
+ field_key: string;
+ field_value: string | null;
+ }>;
};
const result = await saveInspectionDocument(workOrderId, {
step_id: activeStepId ?? undefined,
title: activeTemplate.title || activeTemplate.name,
- data: inspData.items.map(item => ({
- item_id: item.apiItemId || item.id,
- judgment: item.judgment,
- values: item.values,
- })),
+ data: inspData.records,
});
if (result.success) {
toast.success('검사 문서가 저장되었습니다.');
diff --git a/src/components/production/WorkOrders/documents/TemplateInspectionContent.tsx b/src/components/production/WorkOrders/documents/TemplateInspectionContent.tsx
index 3a15c93b..91af866d 100644
--- a/src/components/production/WorkOrders/documents/TemplateInspectionContent.tsx
+++ b/src/components/production/WorkOrders/documents/TemplateInspectionContent.tsx
@@ -56,6 +56,17 @@ interface TemplateInspectionContentProps {
// ===== 유틸 =====
+/** API 저장소 이미지 URL 생성 (사원관리와 동일 패턴) */
+function getImageUrl(path: string | null | undefined): string {
+ if (!path) return '';
+ if (path.startsWith('http://') || path.startsWith('https://')) return path;
+ const apiUrl = process.env.NEXT_PUBLIC_API_URL || '';
+ // tenant storage 경로 (숫자/로 시작: {tenant_id}/temp/...)
+ if (/^\d+\//.test(path)) return `${apiUrl}/storage/tenants/${path}`;
+ // 레거시 경로 (document-templates/xxx.jpg 등)
+ return `${apiUrl}/storage/${path}`;
+}
+
/** field_values.reference_attribute에서 작업 아이템의 실제 치수를 resolve */
function resolveReferenceValue(
item: InspectionTemplateSectionItem,
@@ -307,32 +318,147 @@ export const TemplateInspectionContent = forwardRef ({
getInspectionData: () => {
- const items = effectiveWorkItems.map((wi, idx) => ({
- id: wi.id,
- apiItemId: wi.apiItemId,
- judgment: getRowJudgment(idx),
- values: template.columns.reduce((acc, col) => {
- const sectionItem = columnItemMap.get(col.id);
- if (!sectionItem) return acc;
- const key = `${idx}-${col.id}`;
- const sectionId = itemSectionMap.get(sectionItem.id);
- const saveKey = sectionId != null
- ? `section_${sectionId}_item_${sectionItem.id}`
- : `item_${sectionItem.id}`;
- acc[saveKey] = cellValues[key] || null;
- return acc;
- }, {} as Record),
- }));
+ const records: Array<{
+ section_id: number | null;
+ column_id: number | null;
+ row_index: number;
+ field_key: string;
+ field_value: string | null;
+ }> = [];
- return {
- template_id: template.id,
- items,
- inadequateContent,
- overall_result: overallResult,
- };
+ // ===== 1. 기본 필드 (bf_xxx) =====
+ if (template.basic_fields?.length > 0) {
+ for (const field of template.basic_fields) {
+ const val = resolveFieldValue(field);
+ if (val && val !== '-' && val !== '(입력)') {
+ records.push({
+ section_id: null, column_id: null, row_index: 0,
+ field_key: `bf_${field.id}`,
+ field_value: val,
+ });
+ }
+ }
+ }
+
+ // ===== 2. 행별 검사 데이터 =====
+ effectiveWorkItems.forEach((wi, rowIdx) => {
+ for (const col of template.columns) {
+ // 일련번호 컬럼 → 저장 (mng show에서 표시용)
+ if (isSerialColumn(col.label)) {
+ records.push({
+ section_id: null, column_id: col.id, row_index: rowIdx,
+ field_key: 'value', field_value: String(rowIdx + 1),
+ });
+ continue;
+ }
+
+ // 판정 컬럼 → 자동 계산 결과 저장
+ if (isJudgmentColumn(col.label)) {
+ const judgment = getRowJudgment(rowIdx);
+ if (judgment) {
+ records.push({
+ section_id: null, column_id: col.id, row_index: rowIdx,
+ field_key: 'value', field_value: judgment === '적' ? 'OK' : 'NG',
+ });
+ }
+ continue;
+ }
+
+ const sectionItem = columnItemMap.get(col.id);
+ if (!sectionItem) continue;
+
+ const sectionId = itemSectionMap.get(sectionItem.id) ?? null;
+ const key = `${rowIdx}-${col.id}`;
+ const cell = cellValues[key];
+
+ const mType = sectionItem.measurement_type || sectionItem.measurementType || '';
+
+ if (col.column_type === 'complex' && col.sub_labels) {
+ // 복합 컬럼: sub_label 유형별 처리
+ let inputIdx = 0;
+ for (const subLabel of col.sub_labels) {
+ const sl = subLabel.toLowerCase();
+
+ if (sl.includes('도면') || sl.includes('기준')) {
+ // 기준치 → formatStandard 결과 저장
+ const stdVal = formatStandard(sectionItem, wi);
+ records.push({
+ section_id: sectionId, column_id: col.id, row_index: rowIdx,
+ field_key: 'standard', field_value: stdVal || null,
+ });
+ } else if (sl.includes('ok') || sl.includes('ng')) {
+ // OK·NG → cell.status 저장
+ const n = inputIdx + 1;
+ if (mType === 'checkbox') {
+ records.push({
+ section_id: sectionId, column_id: col.id, row_index: rowIdx,
+ field_key: `n${n}_ok`,
+ field_value: cell?.status === 'good' ? 'OK' : '',
+ });
+ records.push({
+ section_id: sectionId, column_id: col.id, row_index: rowIdx,
+ field_key: `n${n}_ng`,
+ field_value: cell?.status === 'bad' ? 'NG' : '',
+ });
+ }
+ inputIdx++;
+ } else {
+ // 측정값
+ const n = inputIdx + 1;
+ const val = cell?.measurements?.[inputIdx] || null;
+ if (mType === 'checkbox') {
+ records.push({
+ section_id: sectionId, column_id: col.id, row_index: rowIdx,
+ field_key: `n${n}_ok`,
+ field_value: val?.toLowerCase() === 'ok' ? 'OK' : '',
+ });
+ records.push({
+ section_id: sectionId, column_id: col.id, row_index: rowIdx,
+ field_key: `n${n}_ng`,
+ field_value: val?.toLowerCase() === 'ng' ? 'NG' : '',
+ });
+ } else {
+ records.push({
+ section_id: sectionId, column_id: col.id, row_index: rowIdx,
+ field_key: `n${n}`,
+ field_value: val,
+ });
+ }
+ inputIdx++;
+ }
+ }
+ } else if (cell?.value !== undefined) {
+ records.push({
+ section_id: sectionId, column_id: col.id, row_index: rowIdx,
+ field_key: 'value', field_value: cell.value || null,
+ });
+ } else if (cell?.text !== undefined) {
+ records.push({
+ section_id: sectionId, column_id: col.id, row_index: rowIdx,
+ field_key: 'value', field_value: cell.text || null,
+ });
+ }
+ }
+ });
+
+ // ===== 3. 종합판정 =====
+ records.push({
+ section_id: null, column_id: null, row_index: 0,
+ field_key: 'overall_result', field_value: overallResult,
+ });
+
+ // ===== 4. 부적합 내용 (비고) =====
+ if (inadequateContent) {
+ records.push({
+ section_id: null, column_id: null, row_index: 0,
+ field_key: 'remark', field_value: inadequateContent,
+ });
+ }
+
+ return { template_id: template.id, records };
},
}));
@@ -518,9 +644,9 @@ export const TemplateInspectionContent = forwardRef■ {section.title || section.name}
{section.image_path ? (
) : (
diff --git a/src/components/production/WorkOrders/types.ts b/src/components/production/WorkOrders/types.ts
index b220ac41..270422a3 100644
--- a/src/components/production/WorkOrders/types.ts
+++ b/src/components/production/WorkOrders/types.ts
@@ -110,6 +110,8 @@ export interface WorkOrderItem {
productName: string;
floorCode: string; // 층/부호
specification: string; // 규격
+ width?: number; // 제작사이즈 가로 (mm) - options JSON
+ height?: number; // 제작사이즈 세로 (mm) - options JSON
quantity: number;
unit: string; // 단위
orderNodeId: number | null; // 개소 ID
@@ -253,6 +255,7 @@ export interface WorkOrderItemApi {
unit: string | null;
sort_order: number;
status: 'waiting' | 'in_progress' | 'completed';
+ options?: Record | null;
created_at: string;
updated_at: string;
source_order_item?: {
@@ -456,6 +459,8 @@ export function transformApiToFrontend(api: WorkOrderApi): WorkOrder {
productName: item.item_name,
floorCode: [item.source_order_item?.floor_code, item.source_order_item?.symbol_code].filter(Boolean).join('/') || '-',
specification: item.specification || '-',
+ width: typeof item.options?.width === 'number' ? item.options.width : undefined,
+ height: typeof item.options?.height === 'number' ? item.options.height : undefined,
quantity: item.quantity,
unit: item.unit || '-',
orderNodeId: item.source_order_item?.order_node_id ?? null,