diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index 5c2415ea..652677ca 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -71,7 +71,6 @@ import tableCategoryValueRoutes from "./routes/tableCategoryValueRoutes"; // 카 import codeMergeRoutes from "./routes/codeMergeRoutes"; // 코드 병합 import numberingRuleRoutes from "./routes/numberingRuleRoutes"; // 채번 규칙 관리 import entitySearchRoutes from "./routes/entitySearchRoutes"; // 엔티티 검색 -import orderRoutes from "./routes/orderRoutes"; // 수주 관리 import screenEmbeddingRoutes from "./routes/screenEmbeddingRoutes"; // 화면 임베딩 및 데이터 전달 import vehicleTripRoutes from "./routes/vehicleTripRoutes"; // 차량 운행 이력 관리 import driverRoutes from "./routes/driverRoutes"; // 공차중계 운전자 관리 @@ -249,7 +248,6 @@ app.use("/api/table-categories", tableCategoryValueRoutes); // 카테고리 값 app.use("/api/code-merge", codeMergeRoutes); // 코드 병합 app.use("/api/numbering-rules", numberingRuleRoutes); // 채번 규칙 관리 app.use("/api/entity-search", entitySearchRoutes); // 엔티티 검색 -app.use("/api/orders", orderRoutes); // 수주 관리 app.use("/api/driver", driverRoutes); // 공차중계 운전자 관리 app.use("/api/tax-invoice", taxInvoiceRoutes); // 세금계산서 관리 app.use("/api/cascading-relations", cascadingRelationRoutes); // 연쇄 드롭다운 관계 관리 diff --git a/backend-node/src/controllers/orderController.ts b/backend-node/src/controllers/orderController.ts deleted file mode 100644 index 82043964..00000000 --- a/backend-node/src/controllers/orderController.ts +++ /dev/null @@ -1,276 +0,0 @@ -import { Response } from "express"; -import { AuthenticatedRequest } from "../types/auth"; -import { getPool } from "../database/db"; -import { logger } from "../utils/logger"; - -/** - * 수주 번호 생성 함수 - * 형식: ORD + YYMMDD + 4자리 시퀀스 - * 예: ORD250114001 - */ -async function generateOrderNumber(companyCode: string): Promise { - const pool = getPool(); - const today = new Date(); - const year = today.getFullYear().toString().slice(2); // 25 - const month = String(today.getMonth() + 1).padStart(2, "0"); // 01 - const day = String(today.getDate()).padStart(2, "0"); // 14 - const dateStr = `${year}${month}${day}`; // 250114 - - // 당일 수주 카운트 조회 - const countQuery = ` - SELECT COUNT(*) as count - FROM order_mng_master - WHERE objid LIKE $1 - AND writer LIKE $2 - `; - - const pattern = `ORD${dateStr}%`; - const result = await pool.query(countQuery, [pattern, `%${companyCode}%`]); - const count = parseInt(result.rows[0]?.count || "0"); - const seq = count + 1; - - return `ORD${dateStr}${String(seq).padStart(4, "0")}`; // ORD250114001 -} - -/** - * 수주 등록 API - * POST /api/orders - */ -export async function createOrder(req: AuthenticatedRequest, res: Response) { - const pool = getPool(); - - try { - const { - inputMode, // 입력 방식 - customerCode, // 거래처 코드 - deliveryDate, // 납품일 - items, // 품목 목록 - memo, // 메모 - } = req.body; - - // 멀티테넌시 - const companyCode = req.user!.companyCode; - const userId = req.user!.userId; - - // 유효성 검사 - if (!customerCode) { - return res.status(400).json({ - success: false, - message: "거래처 코드는 필수입니다", - }); - } - - if (!items || items.length === 0) { - return res.status(400).json({ - success: false, - message: "품목은 최소 1개 이상 필요합니다", - }); - } - - // 수주 번호 생성 - const orderNo = await generateOrderNumber(companyCode); - - // 전체 금액 계산 - const totalAmount = items.reduce( - (sum: number, item: any) => sum + (item.amount || 0), - 0 - ); - - // 수주 마스터 생성 - const masterQuery = ` - INSERT INTO order_mng_master ( - objid, - partner_objid, - final_delivery_date, - reason, - status, - reg_date, - writer - ) VALUES ($1, $2, $3, $4, $5, NOW(), $6) - RETURNING * - `; - - const masterResult = await pool.query(masterQuery, [ - orderNo, - customerCode, - deliveryDate || null, - memo || null, - "진행중", - `${userId}|${companyCode}`, - ]); - - const masterObjid = masterResult.rows[0].objid; - - // 수주 상세 (품목) 생성 - for (let i = 0; i < items.length; i++) { - const item = items[i]; - const subObjid = `${orderNo}_${i + 1}`; - - const subQuery = ` - INSERT INTO order_mng_sub ( - objid, - order_mng_master_objid, - part_objid, - partner_objid, - partner_price, - partner_qty, - delivery_date, - status, - regdate, - writer - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW(), $9) - `; - - await pool.query(subQuery, [ - subObjid, - masterObjid, - item.item_code || item.id, // 품목 코드 - customerCode, - item.unit_price || 0, - item.quantity || 0, - item.delivery_date || deliveryDate || null, - "진행중", - `${userId}|${companyCode}`, - ]); - } - - logger.info("수주 등록 성공", { - companyCode, - orderNo, - masterObjid, - itemCount: items.length, - totalAmount, - }); - - res.json({ - success: true, - data: { - orderNo, - masterObjid, - itemCount: items.length, - totalAmount, - }, - message: "수주가 등록되었습니다", - }); - } catch (error: any) { - logger.error("수주 등록 오류", { - error: error.message, - stack: error.stack, - }); - res.status(500).json({ - success: false, - message: error.message || "수주 등록 중 오류가 발생했습니다", - }); - } -} - -/** - * 수주 목록 조회 API (마스터 + 품목 JOIN) - * GET /api/orders - */ -export async function getOrders(req: AuthenticatedRequest, res: Response) { - const pool = getPool(); - - try { - const { page = "1", limit = "20", searchText = "" } = req.query; - const companyCode = req.user!.companyCode; - - const offset = (parseInt(page as string) - 1) * parseInt(limit as string); - - // WHERE 조건 - const whereConditions: string[] = []; - const params: any[] = []; - let paramIndex = 1; - - // 멀티테넌시 (writer 필드에 company_code 포함) - if (companyCode !== "*") { - whereConditions.push(`m.writer LIKE $${paramIndex}`); - params.push(`%${companyCode}%`); - paramIndex++; - } - - // 검색 - if (searchText) { - whereConditions.push(`m.objid LIKE $${paramIndex}`); - params.push(`%${searchText}%`); - paramIndex++; - } - - const whereClause = - whereConditions.length > 0 - ? `WHERE ${whereConditions.join(" AND ")}` - : ""; - - // 카운트 쿼리 (고유한 수주 개수) - const countQuery = ` - SELECT COUNT(DISTINCT m.objid) as count - FROM order_mng_master m - ${whereClause} - `; - const countResult = await pool.query(countQuery, params); - const total = parseInt(countResult.rows[0]?.count || "0"); - - // 데이터 쿼리 (마스터 + 품목 JOIN) - const dataQuery = ` - SELECT - m.objid as order_no, - m.partner_objid, - m.final_delivery_date, - m.reason, - m.status, - m.reg_date, - m.writer, - COALESCE( - json_agg( - CASE WHEN s.objid IS NOT NULL THEN - json_build_object( - 'sub_objid', s.objid, - 'part_objid', s.part_objid, - 'partner_price', s.partner_price, - 'partner_qty', s.partner_qty, - 'delivery_date', s.delivery_date, - 'status', s.status, - 'regdate', s.regdate - ) - END - ORDER BY s.regdate - ) FILTER (WHERE s.objid IS NOT NULL), - '[]'::json - ) as items - FROM order_mng_master m - LEFT JOIN order_mng_sub s ON m.objid = s.order_mng_master_objid - ${whereClause} - GROUP BY m.objid, m.partner_objid, m.final_delivery_date, m.reason, m.status, m.reg_date, m.writer - ORDER BY m.reg_date DESC - LIMIT $${paramIndex} OFFSET $${paramIndex + 1} - `; - - params.push(parseInt(limit as string)); - params.push(offset); - - const dataResult = await pool.query(dataQuery, params); - - logger.info("수주 목록 조회 성공", { - companyCode, - total, - page: parseInt(page as string), - itemCount: dataResult.rows.length, - }); - - res.json({ - success: true, - data: dataResult.rows, - pagination: { - total, - page: parseInt(page as string), - limit: parseInt(limit as string), - }, - }); - } catch (error: any) { - logger.error("수주 목록 조회 오류", { error: error.message }); - res.status(500).json({ - success: false, - message: error.message, - }); - } -} diff --git a/backend-node/src/routes/orderRoutes.ts b/backend-node/src/routes/orderRoutes.ts deleted file mode 100644 index a59b5f43..00000000 --- a/backend-node/src/routes/orderRoutes.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { Router } from "express"; -import { authenticateToken } from "../middleware/authMiddleware"; -import { createOrder, getOrders } from "../controllers/orderController"; - -const router = Router(); - -/** - * 수주 등록 - * POST /api/orders - */ -router.post("/", authenticateToken, createOrder); - -/** - * 수주 목록 조회 - * GET /api/orders - */ -router.get("/", authenticateToken, getOrders); - -export default router; - diff --git a/frontend/components/order/OrderCustomerSearch.tsx b/frontend/components/order/OrderCustomerSearch.tsx deleted file mode 100644 index bcd351f9..00000000 --- a/frontend/components/order/OrderCustomerSearch.tsx +++ /dev/null @@ -1,49 +0,0 @@ -"use client"; - -import React from "react"; -import { AutocompleteSearchInputComponent } from "@/lib/registry/components/autocomplete-search-input"; - -/** - * 수주 등록 전용 거래처 검색 컴포넌트 - * - * 이 컴포넌트는 수주 등록 화면 전용이며, 설정이 고정되어 있습니다. - * 범용 AutocompleteSearchInput과 달리 customer_mng 테이블만 조회합니다. - */ - -interface OrderCustomerSearchProps { - /** 현재 선택된 거래처 코드 */ - value: string; - /** 거래처 선택 시 콜백 (거래처 코드, 전체 데이터) */ - onChange: (customerCode: string | null, fullData?: any) => void; - /** 비활성화 여부 */ - disabled?: boolean; -} - -export function OrderCustomerSearch({ - value, - onChange, - disabled = false, -}: OrderCustomerSearchProps) { - return ( - - ); -} - diff --git a/frontend/components/order/OrderItemRepeaterTable.tsx b/frontend/components/order/OrderItemRepeaterTable.tsx deleted file mode 100644 index dbfe5eee..00000000 --- a/frontend/components/order/OrderItemRepeaterTable.tsx +++ /dev/null @@ -1,135 +0,0 @@ -"use client"; - -import React from "react"; -import { ModalRepeaterTableComponent } from "@/lib/registry/components/modal-repeater-table"; -import type { - RepeaterColumnConfig, - CalculationRule, -} from "@/lib/registry/components/modal-repeater-table"; - -/** - * 수주 등록 전용 품목 반복 테이블 컴포넌트 - * - * 이 컴포넌트는 수주 등록 화면 전용이며, 설정이 고정되어 있습니다. - * 범용 ModalRepeaterTable과 달리 item_info 테이블만 조회하며, - * 수주 등록에 필요한 컬럼과 계산 공식이 미리 설정되어 있습니다. - */ - -interface OrderItemRepeaterTableProps { - /** 현재 선택된 품목 목록 */ - value: any[]; - /** 품목 목록 변경 시 콜백 */ - onChange: (items: any[]) => void; - /** 비활성화 여부 */ - disabled?: boolean; -} - -// 수주 등록 전용 컬럼 설정 (고정) -const ORDER_COLUMNS: RepeaterColumnConfig[] = [ - { - field: "item_number", - label: "품번", - editable: false, - width: "120px", - }, - { - field: "item_name", - label: "품명", - editable: false, - width: "180px", - }, - { - field: "specification", - label: "규격", - editable: false, - width: "150px", - }, - { - field: "material", - label: "재질", - editable: false, - width: "120px", - }, - { - field: "quantity", - label: "수량", - type: "number", - editable: true, - required: true, - defaultValue: 1, - width: "100px", - }, - { - field: "selling_price", - label: "단가", - type: "number", - editable: true, - required: true, - width: "120px", - }, - { - field: "amount", - label: "금액", - type: "number", - editable: false, - calculated: true, - width: "120px", - }, - { - field: "order_date", - label: "수주일", - type: "date", - editable: true, - width: "130px", - }, - { - field: "delivery_date", - label: "납기일", - type: "date", - editable: true, - width: "130px", - }, -]; - -// 수주 등록 전용 계산 공식 (고정) -const ORDER_CALCULATION_RULES: CalculationRule[] = [ - { - result: "amount", - formula: "quantity * selling_price", - dependencies: ["quantity", "selling_price"], - }, -]; - -export function OrderItemRepeaterTable({ - value, - onChange, - disabled = false, -}: OrderItemRepeaterTableProps) { - return ( - - ); -} - diff --git a/frontend/components/order/OrderRegistrationModal.tsx b/frontend/components/order/OrderRegistrationModal.tsx deleted file mode 100644 index e47e124f..00000000 --- a/frontend/components/order/OrderRegistrationModal.tsx +++ /dev/null @@ -1,572 +0,0 @@ -"use client"; - -import React, { useState } from "react"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; -import { Button } from "@/components/ui/button"; -import { Label } from "@/components/ui/label"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { OrderCustomerSearch } from "./OrderCustomerSearch"; -import { OrderItemRepeaterTable } from "./OrderItemRepeaterTable"; -import { toast } from "sonner"; -import { apiClient } from "@/lib/api/client"; - -interface OrderRegistrationModalProps { - open: boolean; - onOpenChange: (open: boolean) => void; - onSuccess?: () => void; -} - -export function OrderRegistrationModal({ - open, - onOpenChange, - onSuccess, -}: OrderRegistrationModalProps) { - // 입력 방식 - const [inputMode, setInputMode] = useState("customer_first"); - - // 판매 유형 (국내/해외) - const [salesType, setSalesType] = useState("domestic"); - - // 단가 기준 (기준단가/거래처별단가) - const [priceType, setPriceType] = useState("standard"); - - // 폼 데이터 - const [formData, setFormData] = useState({ - customerCode: "", - customerName: "", - contactPerson: "", - deliveryDestination: "", - deliveryAddress: "", - deliveryDate: "", - memo: "", - // 무역 정보 (해외 판매 시) - incoterms: "", - paymentTerms: "", - currency: "KRW", - portOfLoading: "", - portOfDischarge: "", - hsCode: "", - }); - - // 선택된 품목 목록 - const [selectedItems, setSelectedItems] = useState([]); - - // 납기일 일괄 적용 플래그 (딱 한 번만 실행) - const [isDeliveryDateApplied, setIsDeliveryDateApplied] = useState(false); - - // 저장 중 - const [isSaving, setIsSaving] = useState(false); - - // 저장 처리 - const handleSave = async () => { - try { - // 유효성 검사 - if (!formData.customerCode) { - toast.error("거래처를 선택해주세요"); - return; - } - - if (selectedItems.length === 0) { - toast.error("품목을 추가해주세요"); - return; - } - - setIsSaving(true); - - // 수주 등록 API 호출 - const orderData: any = { - inputMode, - salesType, - priceType, - customerCode: formData.customerCode, - contactPerson: formData.contactPerson, - deliveryDestination: formData.deliveryDestination, - deliveryAddress: formData.deliveryAddress, - deliveryDate: formData.deliveryDate, - items: selectedItems, - memo: formData.memo, - }; - - // 해외 판매 시 무역 정보 추가 - if (salesType === "export") { - orderData.tradeInfo = { - incoterms: formData.incoterms, - paymentTerms: formData.paymentTerms, - currency: formData.currency, - portOfLoading: formData.portOfLoading, - portOfDischarge: formData.portOfDischarge, - hsCode: formData.hsCode, - }; - } - - const response = await apiClient.post("/orders", orderData); - - if (response.data.success) { - toast.success("수주가 등록되었습니다"); - onOpenChange(false); - onSuccess?.(); - - // 폼 초기화 - resetForm(); - } else { - toast.error(response.data.message || "수주 등록에 실패했습니다"); - } - } catch (error: any) { - console.error("수주 등록 오류:", error); - toast.error( - error.response?.data?.message || "수주 등록 중 오류가 발생했습니다" - ); - } finally { - setIsSaving(false); - } - }; - - // 취소 처리 - const handleCancel = () => { - onOpenChange(false); - resetForm(); - }; - - // 폼 초기화 - const resetForm = () => { - setInputMode("customer_first"); - setSalesType("domestic"); - setPriceType("standard"); - setFormData({ - customerCode: "", - customerName: "", - contactPerson: "", - deliveryDestination: "", - deliveryAddress: "", - deliveryDate: "", - memo: "", - incoterms: "", - paymentTerms: "", - currency: "KRW", - portOfLoading: "", - portOfDischarge: "", - hsCode: "", - }); - setSelectedItems([]); - setIsDeliveryDateApplied(false); // 플래그 초기화 - }; - - // 품목 목록 변경 핸들러 (납기일 일괄 적용 로직 포함) - const handleItemsChange = (newItems: any[]) => { - // 1️⃣ 플래그가 이미 true면 그냥 업데이트만 (일괄 적용 완료 상태) - if (isDeliveryDateApplied) { - setSelectedItems(newItems); - return; - } - - // 2️⃣ 품목이 없으면 그냥 업데이트 - if (newItems.length === 0) { - setSelectedItems(newItems); - return; - } - - // 3️⃣ 현재 상태: 납기일이 있는 행과 없는 행 개수 체크 - const itemsWithDate = newItems.filter((item) => item.delivery_date); - const itemsWithoutDate = newItems.filter((item) => !item.delivery_date); - - // 4️⃣ 조건: 정확히 1개만 날짜가 있고, 나머지는 모두 비어있을 때 일괄 적용 - if (itemsWithDate.length === 1 && itemsWithoutDate.length > 0) { - // 5️⃣ 전체 일괄 적용 - const selectedDate = itemsWithDate[0].delivery_date; - const updatedItems = newItems.map((item) => ({ - ...item, - delivery_date: selectedDate, // 모든 행에 동일한 납기일 적용 - })); - - setSelectedItems(updatedItems); - setIsDeliveryDateApplied(true); // 플래그 활성화 (다음부터는 일괄 적용 안 함) - - console.log("✅ 납기일 일괄 적용 완료:", selectedDate); - console.log(` - 대상: ${itemsWithoutDate.length}개 행에 ${selectedDate} 적용`); - } else { - // 그냥 업데이트 - setSelectedItems(newItems); - } - }; - - // 전체 금액 계산 - const totalAmount = selectedItems.reduce( - (sum, item) => sum + (item.amount || 0), - 0 - ); - - return ( - - - - 수주 등록 - - 새로운 수주를 등록합니다 - - - -
- {/* 상단 셀렉트 박스 3개 */} -
- {/* 입력 방식 */} -
- - -
- - {/* 판매 유형 */} -
- - -
- - {/* 단가 기준 */} -
- - -
-
- - {/* 거래처 정보 (항상 표시) */} - {inputMode === "customer_first" && ( -
-
- 🏢 - 거래처 정보 -
- -
- {/* 거래처 */} -
- - { - setFormData({ - ...formData, - customerCode: code || "", - customerName: fullData?.customer_name || "", - }); - }} - /> -
- - {/* 담당자 */} -
- - - setFormData({ ...formData, contactPerson: e.target.value }) - } - className="flex h-8 w-full rounded-md border border-input bg-background px-3 py-2 text-xs ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 sm:h-10 sm:text-sm" - /> -
- - {/* 납품처 */} -
- - - setFormData({ ...formData, deliveryDestination: e.target.value }) - } - className="flex h-8 w-full rounded-md border border-input bg-background px-3 py-2 text-xs ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 sm:h-10 sm:text-sm" - /> -
- - {/* 납품장소 */} -
- - - setFormData({ ...formData, deliveryAddress: e.target.value }) - } - className="flex h-8 w-full rounded-md border border-input bg-background px-3 py-2 text-xs ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 sm:h-10 sm:text-sm" - /> -
-
-
- )} - - {inputMode === "quotation" && ( -
-
- - -
-
- )} - - {inputMode === "unit_price" && ( -
-
- - -
-
- )} - - {/* 추가된 품목 */} -
- - -
- - {/* 전체 금액 표시 */} - {selectedItems.length > 0 && ( -
-
- 전체 금액: {totalAmount.toLocaleString()}원 -
-
- )} - - {/* 무역 정보 (해외 판매 시에만 표시) */} - {salesType === "export" && ( -
-
- 🌏 - 무역 정보 -
- -
- {/* 인코텀즈 */} -
- - -
- - {/* 결제 조건 */} -
- - -
- - {/* 통화 */} -
- - -
-
- -
- {/* 선적항 */} -
- - - setFormData({ ...formData, portOfLoading: e.target.value }) - } - className="flex h-8 w-full rounded-md border border-input bg-background px-3 py-2 text-xs ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 sm:h-10 sm:text-sm" - /> -
- - {/* 도착항 */} -
- - - setFormData({ ...formData, portOfDischarge: e.target.value }) - } - className="flex h-8 w-full rounded-md border border-input bg-background px-3 py-2 text-xs ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 sm:h-10 sm:text-sm" - /> -
- - {/* HS Code */} -
- - - setFormData({ ...formData, hsCode: e.target.value }) - } - className="flex h-8 w-full rounded-md border border-input bg-background px-3 py-2 text-xs ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 sm:h-10 sm:text-sm" - /> -
-
-
- )} - - {/* 메모 */} -
- -