feat(WEB): 생산지시 공정관리 연동 및 견적번호 버그 수정

- 생산지시 페이지에 공정관리 API 연동
  - getProcessList API로 사용중 공정 목록 로드
  - 품목-공정 매칭 함수 추가 (classificationRules 기반)
  - 하드코딩된 DEFAULT_PROCESSES 제거, API 데이터로 대체
  - workSteps 없을 시 안내 메시지 표시

- 수주 등록 시 quote_id 미전달 버그 수정
  - transformFrontendToApi에 quote_id 변환 로직 추가
  - 견적 선택 후 수주 등록 시 견적번호 정상 표시
This commit is contained in:
2026-01-12 17:19:14 +09:00
parent ee9f7a4d81
commit b9f0e24950
2 changed files with 281 additions and 374 deletions

View File

@@ -47,6 +47,8 @@ import {
type Order,
type CreateProductionOrderData,
} from "@/components/orders/actions";
import { getProcessList } from "@/components/process-management/actions";
import type { Process } from "@/types/process";
import { formatAmount } from "@/utils/formatAmount";
// 수주 정보 타입
@@ -56,7 +58,6 @@ interface OrderInfo {
siteName: string;
dueDate: string;
itemCount: number;
totalQuantity: string;
creditGrade: string;
status: string;
}
@@ -92,20 +93,17 @@ interface MaterialRequirement {
status: "sufficient" | "insufficient";
}
// 스크린 품목 상세 타입
// 스크린 품목 상세 타입 (order.items에서 변환)
interface ScreenItemDetail {
no: number;
itemName: string;
location: string;
openWidth: number;
openHeight: number;
productWidth: number;
productHeight: number;
guideRail: string;
shaft: string;
capacity: string;
finish: string;
specification: string;
quantity: number;
unit: string;
unitPrice: number;
supplyAmount: number;
taxAmount: number;
totalAmount: number;
}
// 가이드레일 BOM 타입
@@ -208,137 +206,94 @@ const STATUS_LABELS: Record<string, string> = {
cancelled: "취소",
};
// 샘플 작업지시 카드
const SAMPLE_WORK_ORDER_CARDS: WorkOrderCard[] = [
{
id: "1",
type: "스크린",
orderNumber: "KD-PL-251223-01",
itemCount: 3,
totalQuantity: "3EA",
processes: ["1. 원단절단", "2. 미싱", "3. 앤드락작업", "4. 중간검사", "5. 포장"],
},
{
id: "2",
type: "절곡",
orderNumber: "KD-PL-251223-02",
itemCount: 3,
totalQuantity: "3EA",
processes: ["1. 절단", "2. 절곡", "3. 중간검사", "4. 포장"],
},
];
// 품목과 공정 매칭 함수
function matchItemToProcess(
itemName: string,
itemCode: string | undefined,
processes: Process[]
): Process | null {
for (const process of processes) {
for (const rule of process.classificationRules) {
if (!rule.isActive) continue;
// 샘플 자재 소요량
const SAMPLE_MATERIALS: MaterialRequirement[] = [
{
materialCode: "SCR-MAT-001",
materialName: "스크린 원단",
unit: "㎡",
required: 45,
currentStock: 500,
status: "sufficient",
},
{
materialCode: "SCR-MAT-002",
materialName: "앤드락",
unit: "EA",
required: 6,
currentStock: 800,
status: "sufficient",
},
{
materialCode: "BND-MAT-001",
materialName: "철판",
unit: "KG",
required: 90,
currentStock: 2000,
status: "sufficient",
},
{
materialCode: "BND-MAT-002",
materialName: "가이드레일",
unit: "M",
required: 18,
currentStock: 300,
status: "sufficient",
},
{
materialCode: "BND-MAT-003",
materialName: "케이스",
unit: "EA",
required: 3,
currentStock: 100,
status: "sufficient",
},
];
// 패턴 매칭 규칙
if (rule.registrationType === "pattern") {
let targetValue = "";
if (rule.ruleType === "품목명") {
targetValue = itemName;
} else if (rule.ruleType === "품목코드" && itemCode) {
targetValue = itemCode;
}
// 샘플 스크린 품목 상세
const SAMPLE_SCREEN_ITEMS: ScreenItemDetail[] = [
{
no: 1,
itemName: "스크린 셔터 (프리미엄)",
location: "로비 I-01",
openWidth: 4500,
openHeight: 3500,
productWidth: 4640,
productHeight: 3900,
guideRail: "백면형 120-70",
shaft: '4"',
capacity: "160kg",
finish: "SUS마감",
quantity: 1,
},
{
no: 2,
itemName: "스크린 셔터 (프리미엄)",
location: "카페 I-02",
openWidth: 4500,
openHeight: 3500,
productWidth: 4640,
productHeight: 3900,
guideRail: "백면형 120-70",
shaft: '4"',
capacity: "160kg",
finish: "SUS마감",
quantity: 1,
},
{
no: 3,
itemName: "스크린 셔터 (프리미엄)",
location: "헬스장 I-03",
openWidth: 4500,
openHeight: 3500,
productWidth: 4640,
productHeight: 3900,
guideRail: "백면형 120-70",
shaft: '4"',
capacity: "160kg",
finish: "SUS마감",
quantity: 1,
},
];
if (!targetValue) continue;
// 샘플 가이드레일 BOM
const SAMPLE_GUIDE_RAIL_BOM: GuideRailBom[] = [
{
type: "백면형",
spec: "120-70",
code: "KSE01/KWE01",
length: 3000,
quantity: 6,
},
];
let matched = false;
switch (rule.matchingType) {
case "startsWith":
matched = targetValue.startsWith(rule.conditionValue);
break;
case "endsWith":
matched = targetValue.endsWith(rule.conditionValue);
break;
case "contains":
matched = targetValue.includes(rule.conditionValue);
break;
case "equals":
matched = targetValue === rule.conditionValue;
break;
}
// 샘플 케이스(셔터박스) BOM
const SAMPLE_CASE_BOM: CaseBom[] = [
{ item: "케이스 본체", length: "L: 4000", quantity: 2 },
{ item: "측면 덮개", length: "500-355", quantity: 6 },
];
if (matched) return process;
}
}
}
// 샘플 하단 마감재 BOM
const SAMPLE_BOTTOM_FINISH_BOM: BottomFinishBom[] = [
{ item: "하단마감재", spec: "50-40", length: "L: 4000", quantity: 3 },
];
// 매칭되는 공정이 없으면 공정명으로 단순 매칭 시도
// (예: 품목명에 "스크린"이 포함되면 "스크린" 공정 반환)
for (const process of processes) {
if (itemName.toLowerCase().includes(process.processName.toLowerCase())) {
return process;
}
}
return null;
}
// 수주 품목들에서 매칭되는 공정의 workSteps 추출
function getWorkStepsForOrder(
items: Array<{ itemName: string; itemCode?: string }>,
processes: Process[]
): string[] {
// 첫 번째 품목으로 공정 매칭 시도
if (items.length > 0 && processes.length > 0) {
const firstItem = items[0];
const matchedProcess = matchItemToProcess(
firstItem.itemName,
firstItem.itemCode,
processes
);
if (matchedProcess && matchedProcess.workSteps.length > 0) {
// workSteps에 번호 추가하여 반환
return matchedProcess.workSteps.map(
(step, idx) => `${idx + 1}. ${step}`
);
}
}
// 매칭된 공정이 없거나 workSteps가 없으면 첫 번째 공정의 workSteps 사용
if (processes.length > 0) {
const firstProcess = processes[0];
if (firstProcess.workSteps.length > 0) {
return firstProcess.workSteps.map(
(step, idx) => `${idx + 1}. ${step}`
);
}
}
// 공정이 없으면 빈 배열 반환
return [];
}
export default function ProductionOrderCreatePage() {
const router = useRouter();
@@ -348,6 +303,7 @@ export default function ProductionOrderCreatePage() {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [order, setOrder] = useState<Order | null>(null);
const [processes, setProcesses] = useState<Process[]>([]);
const [isSubmitting, setIsSubmitting] = useState(false);
// 우선순위 상태
@@ -359,16 +315,25 @@ export default function ProductionOrderCreatePage() {
const [generatedOrderNumber, setGeneratedOrderNumber] = useState("");
const [generatedWorkOrderId, setGeneratedWorkOrderId] = useState<string | null>(null);
// 수주 데이터 로드
const fetchOrder = useCallback(async () => {
// 수주 데이터 및 공정 목록 로드
const fetchData = useCallback(async () => {
setLoading(true);
setError(null);
try {
const result = await getOrderById(orderId);
if (result.success && result.data) {
setOrder(result.data);
// 수주 정보와 공정 목록을 병렬로 로드
const [orderResult, processResult] = await Promise.all([
getOrderById(orderId),
getProcessList({ status: "사용중" }),
]);
if (orderResult.success && orderResult.data) {
setOrder(orderResult.data);
} else {
setError(result.error || "수주 정보 조회에 실패했습니다.");
setError(orderResult.error || "수주 정보 조회에 실패했습니다.");
}
if (processResult.success && processResult.data) {
setProcesses(processResult.data.items);
}
} catch {
setError("서버 오류가 발생했습니다.");
@@ -378,8 +343,8 @@ export default function ProductionOrderCreatePage() {
}, [orderId]);
useEffect(() => {
fetchOrder();
}, [fetchOrder]);
fetchData();
}, [fetchData]);
const handleCancel = () => {
router.push(`/ko/sales/order-management-sales/${orderId}`);
@@ -467,7 +432,20 @@ export default function ProductionOrderCreatePage() {
}
const selectedConfig = getSelectedPriorityConfig();
const workOrderCount = SAMPLE_WORK_ORDER_CARDS.length;
const workOrderCount = 1; // 현재는 수주당 하나의 작업지시 생성
// order.items에서 스크린 품목 상세 데이터 변환
const screenItems: ScreenItemDetail[] = (order.items || []).map((item, index) => ({
no: item.serialNo || index + 1,
itemName: item.itemName,
specification: item.specification || "-",
quantity: item.quantity,
unit: item.unit || "EA",
unitPrice: item.unitPrice,
supplyAmount: item.supplyAmount,
taxAmount: item.taxAmount,
totalAmount: item.totalAmount,
}));
// Order에서 UI에 표시할 데이터 변환
const orderInfo = {
@@ -476,7 +454,6 @@ export default function ProductionOrderCreatePage() {
siteName: order.siteName,
dueDate: order.expectedShipDate || "-",
itemCount: order.itemCount,
totalQuantity: `${order.itemCount}EA`,
creditGrade: "B (관리)", // API에서 제공하지 않아 기본값 사용
status: STATUS_LABELS[order.status] || order.status,
amount: order.amount,
@@ -537,10 +514,6 @@ export default function ProductionOrderCreatePage() {
<p className="text-sm text-muted-foreground mb-1"> </p>
<p className="font-medium">{orderInfo.itemCount}</p>
</div>
<div>
<p className="text-sm text-muted-foreground mb-1"></p>
<p className="font-medium">{orderInfo.totalQuantity}</p>
</div>
<div>
<p className="text-sm text-muted-foreground mb-1"></p>
<BadgeSm className="bg-green-100 text-green-700 border-green-200">
@@ -662,98 +635,63 @@ export default function ProductionOrderCreatePage() {
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{SAMPLE_WORK_ORDER_CARDS.map((card) => (
<div
key={card.id}
className={cn(
"border rounded-lg p-4",
card.type === "스크린" ? "bg-blue-50/50 border-blue-200" : "bg-orange-50/50 border-orange-200"
)}
>
<div className="flex items-center justify-between mb-3">
<BadgeSm className={cn(
card.type === "스크린"
? "bg-blue-100 text-blue-700 border-blue-200"
: "bg-orange-100 text-orange-700 border-orange-200"
)}>
{card.type}
</BadgeSm>
<span className="font-mono text-sm font-medium">{card.orderNumber}</span>
</div>
<div className="grid grid-cols-2 gap-4 mb-3">
<div>
<p className="text-sm text-muted-foreground"> </p>
<p className="font-medium">{card.itemCount}</p>
</div>
<div>
<p className="text-sm text-muted-foreground"> </p>
<p className="font-medium">{card.totalQuantity}</p>
</div>
</div>
<div>
<p className="text-sm text-muted-foreground mb-2"> </p>
<div className="flex flex-wrap gap-2">
{card.processes.map((process, idx) => (
{/* 수주 데이터 기반 작업지시 카드 */}
<div className="border rounded-lg p-4 bg-blue-50/50 border-blue-200">
<div className="flex items-center justify-between mb-3">
<BadgeSm className="bg-blue-100 text-blue-700 border-blue-200">
</BadgeSm>
<span className="font-mono text-sm font-medium">{order.lotNumber}</span>
</div>
<div className="mb-3">
<p className="text-sm text-muted-foreground"> </p>
<p className="font-medium">{screenItems.length}</p>
</div>
<div>
<p className="text-sm text-muted-foreground mb-2"> </p>
<div className="flex flex-wrap gap-2">
{(() => {
const workSteps = getWorkStepsForOrder(
(order.items || []).map(item => ({
itemName: item.itemName,
itemCode: item.itemCode,
})),
processes
);
if (workSteps.length === 0) {
return (
<span className="text-sm text-muted-foreground">
.
</span>
);
}
return workSteps.map((step, idx) => (
<BadgeSm
key={idx}
className="bg-gray-50 text-gray-600 border-gray-200"
>
{process}
{step}
</BadgeSm>
))}
</div>
));
})()}
</div>
</div>
))}
</div>
</div>
</CardContent>
</Card>
{/* 자재 소요량 및 재고 현황 */}
{/* 자재 소요량 및 재고 현황 - 추후 BOM API 연동 예정 */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base"> </CardTitle>
</CardHeader>
<CardContent>
<div className="border rounded-lg overflow-hidden">
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-center"></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="text-center"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{SAMPLE_MATERIALS.map((item) => (
<TableRow key={item.materialCode}>
<TableCell>
<code className="text-xs bg-gray-100 px-1.5 py-0.5 rounded">
{item.materialCode}
</code>
</TableCell>
<TableCell>{item.materialName}</TableCell>
<TableCell className="text-center">{item.unit}</TableCell>
<TableCell className="text-right font-medium">{item.required}</TableCell>
<TableCell className="text-right">{item.currentStock.toLocaleString()}</TableCell>
<TableCell className="text-center">
<BadgeSm
className={cn(
item.status === "sufficient"
? "bg-green-100 text-green-700 border-green-200"
: "bg-red-100 text-red-700 border-red-200"
)}
>
{item.status === "sufficient" ? "충분" : "부족"}
</BadgeSm>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<div className="text-center py-8 text-muted-foreground">
<p>BOM .</p>
<p className="text-sm mt-1">( )</p>
</div>
</CardContent>
</Card>
@@ -761,7 +699,7 @@ export default function ProductionOrderCreatePage() {
{/* 스크린 품목 상세 */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base"> ({SAMPLE_SCREEN_ITEMS.length})</CardTitle>
<CardTitle className="text-base"> ({screenItems.length})</CardTitle>
</CardHeader>
<CardContent>
<div className="border rounded-lg overflow-x-auto">
@@ -770,37 +708,39 @@ export default function ProductionOrderCreatePage() {
<TableRow>
<TableHead className="w-[50px] text-center">No</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead></TableHead>
<TableHead className="text-center"></TableHead>
<TableHead className="text-center"></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-center"></TableHead>
<TableHead className="text-center"></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{SAMPLE_SCREEN_ITEMS.map((item) => (
<TableRow key={item.no}>
<TableCell className="text-center font-medium">
{String(item.no).padStart(2, "0")}
{screenItems.length > 0 ? (
screenItems.map((item) => (
<TableRow key={item.no}>
<TableCell className="text-center font-medium">
{String(item.no).padStart(2, "0")}
</TableCell>
<TableCell>{item.itemName}</TableCell>
<TableCell>{item.specification}</TableCell>
<TableCell className="text-center">{item.quantity}</TableCell>
<TableCell className="text-center">{item.unit}</TableCell>
<TableCell className="text-right">{formatAmount(item.unitPrice)}</TableCell>
<TableCell className="text-right">{formatAmount(item.supplyAmount)}</TableCell>
<TableCell className="text-right">{formatAmount(item.taxAmount)}</TableCell>
<TableCell className="text-right font-medium">{formatAmount(item.totalAmount)}</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={9} className="text-center py-8 text-muted-foreground">
.
</TableCell>
<TableCell>{item.itemName}</TableCell>
<TableCell>{item.location}</TableCell>
<TableCell className="text-right">{item.openWidth.toLocaleString()}</TableCell>
<TableCell className="text-right">{item.openHeight.toLocaleString()}</TableCell>
<TableCell className="text-right">{item.productWidth.toLocaleString()}</TableCell>
<TableCell className="text-right">{item.productHeight.toLocaleString()}</TableCell>
<TableCell>{item.guideRail}</TableCell>
<TableCell className="text-center">{item.shaft}</TableCell>
<TableCell className="text-center">{item.capacity}</TableCell>
<TableCell>{item.finish}</TableCell>
<TableCell className="text-center">{item.quantity}</TableCell>
</TableRow>
))}
)}
</TableBody>
</Table>
</div>
@@ -832,91 +772,15 @@ export default function ProductionOrderCreatePage() {
</CardContent>
</Card>
{/* 절곡물 BOM */}
{/* 절곡물 BOM - 추후 BOM API 연동 예정 */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">절곡물 BOM</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
{/* 가이드레일 */}
<div>
<h4 className="text-sm font-medium mb-2">가이드레일</h4>
<div className="border rounded-lg overflow-hidden">
<Table>
<TableHeader>
<TableRow>
<TableHead>형태</TableHead>
<TableHead>규격</TableHead>
<TableHead>코드</TableHead>
<TableHead className="text-right">길이</TableHead>
<TableHead className="text-center">수량</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{SAMPLE_GUIDE_RAIL_BOM.map((item, index) => (
<TableRow key={index}>
<TableCell>{item.type}</TableCell>
<TableCell>{item.spec}</TableCell>
<TableCell>{item.code}</TableCell>
<TableCell className="text-right">{item.length.toLocaleString()}</TableCell>
<TableCell className="text-center">{item.quantity}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
{/* 케이스(셔터박스) */}
<div>
<h4 className="text-sm font-medium mb-2">케이스(셔터박스) - 메인 규격: 500-330</h4>
<div className="border rounded-lg overflow-hidden">
<Table>
<TableHeader>
<TableRow>
<TableHead>품목</TableHead>
<TableHead>길이</TableHead>
<TableHead className="text-center">수량</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{SAMPLE_CASE_BOM.map((item, index) => (
<TableRow key={index}>
<TableCell>{item.item}</TableCell>
<TableCell>{item.length}</TableCell>
<TableCell className="text-center">{item.quantity}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
{/* 하단 마감재 */}
<div>
<h4 className="text-sm font-medium mb-2">하단 마감재</h4>
<div className="border rounded-lg overflow-hidden">
<Table>
<TableHeader>
<TableRow>
<TableHead>품목</TableHead>
<TableHead>규격</TableHead>
<TableHead>길이</TableHead>
<TableHead className="text-center">수량</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{SAMPLE_BOTTOM_FINISH_BOM.map((item, index) => (
<TableRow key={index}>
<TableCell>{item.item}</TableCell>
<TableCell>{item.spec}</TableCell>
<TableCell>{item.length}</TableCell>
<TableCell className="text-center">{item.quantity}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<CardContent>
<div className="text-center py-8 text-muted-foreground">
<p>BOM 데이터 연동 후 절곡물 정보가 표시됩니다.</p>
<p className="text-sm mt-1">(가이드레일, 케이스, 하단 마감재 - 추후 제공 예정)</p>
</div>
</CardContent>
</Card>
@@ -943,7 +807,7 @@ export default function ProductionOrderCreatePage() {
</Button>
<Button onClick={handleConfirm} disabled={isSubmitting}>
<BarChart3 className="h-4 w-4 mr-2" />
생산지시 확정 ({SAMPLE_SCREEN_ITEMS.length}건)
생산지시 확정 ({screenItems.length}건)
</Button>
</div>
</div>

View File

@@ -89,8 +89,7 @@ interface ApiQuoteForSelect {
client?: {
id: number;
name: string;
grade?: string;
representative?: string;
contact_person?: string; // 담당자
phone?: string;
} | null;
items?: ApiQuoteItem[];
@@ -103,12 +102,16 @@ interface ApiQuoteItem {
type_code?: string;
symbol?: string;
specification?: string;
quantity: number;
// QuoteItem 모델 필드명 (calculated_quantity, total_price)
calculated_quantity?: number;
quantity?: number; // fallback
unit?: string;
unit_price: number;
supply_amount: number;
tax_amount: number;
total_amount: number;
total_price?: number;
// 수주 품목에서 사용하는 필드명
supply_amount?: number;
tax_amount?: number;
total_amount?: number;
}
interface ApiWorkOrder {
@@ -327,7 +330,8 @@ export interface QuotationForSelect {
id: string;
quoteNumber: string; // KD-PR-XXXXXX-XX
grade: string; // A(우량), B(관리), C(주의)
client: string; // 발주처
clientId: string | null; // 발주처 ID
client: string; // 발주처명
siteName: string; // 현장명
amount: number; // 총 금액
itemCount: number; // 품목 수
@@ -399,7 +403,7 @@ function transformApiToFrontend(apiData: ApiOrder): Order {
memo: apiData.memo ?? undefined,
remarks: apiData.remarks ?? undefined,
note: apiData.note ?? undefined,
items: apiData.items?.map(transformItemApiToFrontend), // 상세 페이지용 추가 필드 (API에서 매핑)
items: apiData.items?.map(transformItemApiToFrontend) || [], // 상세 페이지용 추가 필드 (API에서 매핑)
manager: apiData.client?.representative ?? undefined,
contact: apiData.client_contact ?? apiData.client?.phone ?? undefined,
deliveryRequestDate: apiData.delivery_date ?? undefined, // delivery_date를 공유
@@ -435,33 +439,61 @@ function transformItemApiToFrontend(apiItem: ApiOrderItem): OrderItem {
};
}
function transformFrontendToApi(data: OrderFormData): Record<string, unknown> {
function transformFrontendToApi(data: OrderFormData | Record<string, unknown>): Record<string, unknown> {
// Handle both API OrderFormData and Registration form's OrderFormData
const formData = data as Record<string, unknown>;
// Get client_id - handle both string (form) and number (api) types
const clientId = formData.clientId;
const clientIdValue = clientId ? (typeof clientId === 'string' ? parseInt(clientId, 10) || null : clientId) : null;
// Get items - handle both form's OrderItem[] and API's OrderItemFormData[]
const items = (formData.items as Array<Record<string, unknown>>) || [];
// Get quote_id from selectedQuotation (견적에서 수주 생성 시)
const selectedQuotation = formData.selectedQuotation as { id?: string } | undefined;
const quoteIdValue = selectedQuotation?.id ? parseInt(selectedQuotation.id, 10) || null : null;
return {
order_type_code: data.orderTypeCode || 'ORDER',
category_code: data.categoryCode || null,
client_id: data.clientId || null,
client_name: data.clientName || null,
client_contact: data.clientContact || null,
site_name: data.siteName || null,
supply_amount: data.supplyAmount || 0,
tax_amount: data.taxAmount || 0,
total_amount: data.totalAmount || 0,
discount_rate: data.discountRate || 0,
discount_amount: data.discountAmount || 0,
delivery_date: data.deliveryDate || null,
delivery_method_code: data.deliveryMethodCode || null,
received_at: data.receivedAt || null,
memo: data.memo || null,
remarks: data.remarks || null,
note: data.note || null,
items: data.items?.map((item) => ({
item_id: item.itemId || null,
item_name: item.itemName,
specification: item.specification || null,
quantity: item.quantity,
unit: item.unit || null,
unit_price: item.unitPrice,
})) || [],
quote_id: quoteIdValue,
order_type_code: formData.orderTypeCode || 'ORDER',
category_code: formData.categoryCode || null,
client_id: clientIdValue,
client_name: formData.clientName || null,
client_contact: formData.clientContact || formData.contact || null,
site_name: formData.siteName || null,
supply_amount: formData.supplyAmount || formData.subtotal || 0,
tax_amount: formData.taxAmount || 0,
total_amount: formData.totalAmount || 0,
discount_rate: formData.discountRate || 0,
discount_amount: formData.discountAmount || 0,
delivery_date: formData.deliveryDate || formData.deliveryRequestDate || null,
delivery_method_code: formData.deliveryMethodCode || formData.deliveryMethod || null,
received_at: formData.receivedAt || null,
memo: formData.memo || null,
remarks: formData.remarks || null,
note: formData.note || null,
items: items.map((item) => {
// Handle both form's OrderItem (id, spec) and API's OrderItemFormData (itemId, specification)
// 중요: 문자열로 전달될 수 있으므로 반드시 Number()로 변환
const quantity = Number(item.quantity) || 0;
const unitPrice = Number(item.unitPrice) || 0;
const supplyAmount = quantity * unitPrice;
const taxAmount = Math.round(supplyAmount * 0.1);
return {
item_id: item.itemId || null,
item_code: item.itemCode || null,
item_name: item.itemName,
specification: item.specification || item.spec || null,
quantity,
unit: item.unit || 'EA',
unit_price: unitPrice,
supply_amount: supplyAmount,
tax_amount: taxAmount,
total_amount: supplyAmount + taxAmount,
};
}),
};
}
@@ -490,6 +522,7 @@ function transformQuoteForSelect(apiData: ApiQuoteForSelect): QuotationForSelect
id: String(apiData.id),
quoteNumber: apiData.quote_number,
grade: apiData.client?.grade || 'B', // 기본값 B(관리)
clientId: apiData.client_id ? String(apiData.client_id) : null,
client: apiData.client_name || apiData.client?.name || '',
siteName: apiData.site_name || '',
amount: apiData.total_amount,
@@ -502,6 +535,14 @@ function transformQuoteForSelect(apiData: ApiQuoteForSelect): QuotationForSelect
}
function transformQuoteItemForSelect(apiItem: ApiQuoteItem): QuotationItem {
// QuoteItem 모델 필드명: calculated_quantity, total_price
// 수주 품목 필드명: quantity, total_amount (fallback)
// 중요: API에서 문자열로 반환될 수 있으므로 반드시 Number()로 변환
const quantity = Number(apiItem.calculated_quantity ?? apiItem.quantity ?? 0);
const unitPrice = Number(apiItem.unit_price ?? 0);
// amount fallback: total_price → total_amount → 수량 * 단가 계산
const amount = Number(apiItem.total_price ?? apiItem.total_amount ?? 0) || (quantity * unitPrice);
return {
id: String(apiItem.id),
itemCode: apiItem.item_code || '',
@@ -509,10 +550,10 @@ function transformQuoteItemForSelect(apiItem: ApiQuoteItem): QuotationItem {
type: apiItem.type_code || '',
symbol: apiItem.symbol || '',
spec: apiItem.specification || '',
quantity: apiItem.quantity,
quantity,
unit: apiItem.unit || 'EA',
unitPrice: apiItem.unit_price,
amount: apiItem.total_amount,
unitPrice,
amount,
};
}
@@ -971,8 +1012,10 @@ export async function getQuotesForSelect(params?: {
try {
const searchParams = new URLSearchParams();
// 확정(FINALIZED) 상태의 견적만 조회
searchParams.set('status', 'FINALIZED');
// 확정(finalized) 상태의 견적만 조회
searchParams.set('status', 'finalized');
// 품목 포함 (수주 전환용)
searchParams.set('with_items', 'true');
if (params?.q) searchParams.set('q', params.q);
if (params?.page) searchParams.set('page', String(params.page));
if (params?.size) searchParams.set('size', String(params.size || 50));